Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/components/MapView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useMemo, useState } from "react";
import Image from "next/image";
import type { Concession, PublicCompanyProject, SourcePack } from "@/lib/types";

type ProjectWithDistance = PublicCompanyProject & { distanceKm: number };
Expand Down Expand Up @@ -272,10 +273,13 @@ function RasterLayer({
filter: string;
}) {
return (
<img
<Image
src={src}
alt=""
className="pointer-events-none absolute inset-0 h-full w-full object-cover object-center transition-opacity duration-300"
fill
sizes="100vw"
priority
className="pointer-events-none object-cover object-center transition-opacity duration-300"
style={{
filter,
opacity,
Expand Down
67 changes: 57 additions & 10 deletions src/components/Workstation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import projectsData from "@/data/public_companies.json";
import sourcePacksData from "@/data/source_packs.json";
import { getNearbyProjects } from "@/lib/geo";
import { sourcePackToFact } from "@/lib/evidence";
import { findPlace, listPlaceNames } from "@/lib/places";
import type {
Concession,
EvidenceItem,
Expand Down Expand Up @@ -119,6 +120,7 @@ export default function Workstation({
const [focusedProjectId, setFocusedProjectId] = useState<string>();
const [intakeText, setIntakeText] = useState(initialIntakeText || intakeSeedText);
const [intakeError, setIntakeError] = useState("");
const [placeQuery, setPlaceQuery] = useState("");
const [compareId, setCompareId] = useState(staticDemos[1].concession.id);
const [headerOpen, setHeaderOpen] = useState(true);
const selected =
Expand All @@ -138,16 +140,14 @@ export default function Workstation({
[compareSelected],
);

const selectedSourcesRaw = sourcePacks.filter(
(source) => source.concessionId === selected.id,
);
const selectedSources =
selectedSourcesRaw.length > 0 ? selectedSourcesRaw : sourcePacks;
const compareSourcesRaw = sourcePacks.filter(
(source) => source.concessionId === compareSelected.id,
);
const compareSources =
compareSourcesRaw.length > 0 ? compareSourcesRaw : sourcePacks;
const selectedSources = useMemo(() => {
const raw = sourcePacks.filter((source) => source.concessionId === selected.id);
return raw.length > 0 ? raw : sourcePacks;
}, [selected.id]);
const compareSources = useMemo(() => {
const raw = sourcePacks.filter((source) => source.concessionId === compareSelected.id);
return raw.length > 0 ? raw : sourcePacks;
}, [compareSelected.id]);

const closestMapAnchor = nearbyProjects[0];
const selectedCommoditySet = new Set(
Expand Down Expand Up @@ -249,6 +249,30 @@ export default function Workstation({
}
}

function applyPlaceName() {
const place = findPlace(placeQuery);
if (!place) {
setIntakeError(
`Could not find "${placeQuery.trim() || "place"}" in Côte d'Ivoire. Try: ${listPlaceNames().join(", ")}.`,
);
return;
}
const json = JSON.stringify(
{ lat: place.lat, lng: place.lng, name: place.name },
null,
2,
);
setIntakeText(json);
setIntakeError("");
try {
const concession = parseIntakeConcession(json);
setRuntimeConcessions((current) => upsertConcession(current, concession));
selectConcession(concession.id);
} catch (error) {
setIntakeError(error instanceof Error ? error.message : "Could not parse intake JSON");
}
}

function activateCoverageLayer(label: string) {
switch (label) {
case "Map anchors":
Expand Down Expand Up @@ -479,6 +503,29 @@ export default function Workstation({
Paste a coordinate, GeoJSON Point, Polygon, or MultiPolygon. A point becomes a
small license-style package and reuses the public-source recon workflow.
</p>
<div className="mt-3 grid grid-cols-[1fr_auto] gap-2">
<input
type="text"
value={placeQuery}
onChange={(event) => setPlaceQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
applyPlaceName();
}
}}
placeholder={`Place name — try: ${listPlaceNames().join(", ")}`}
spellCheck={false}
className="h-9 rounded-md border border-[#2a3140] bg-[#07090d] px-3 text-xs text-[#d5dbea] outline-none transition placeholder:text-[#5a6477] focus:border-[#22c55e]"
/>
<button
type="button"
onClick={applyPlaceName}
className="h-9 rounded-md border border-[#2a3140] bg-[#10141c] px-3 text-xs font-semibold text-[#b7c0d0] transition hover:border-[#22c55e] hover:text-[#a7f3d0]"
>
Use
</button>
</div>
<form action="/" method="get">
<textarea
name="intake"
Expand Down
66 changes: 66 additions & 0 deletions src/lib/places.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
export type CivPlace = {
name: string;
lat: number;
lng: number;
blurb: string;
};

const civPlaces: CivPlace[] = [
{
name: "Abidjan",
lat: 5.36,
lng: -4.0083,
blurb: "Economic capital, southern coast",
},
{
name: "Yamoussoukro",
lat: 6.8276,
lng: -5.2893,
blurb: "Political capital, central CI",
},
{
name: "Bouaké",
lat: 7.6939,
lng: -5.0303,
blurb: "Third city, north of Bandama",
},
{
name: "Yaouré",
lat: 6.9,
lng: -5.18,
blurb: "Perseus Yaouré gold mine area",
},
{
name: "Kokumbo",
lat: 6.32,
lng: -5.41,
blurb: "Bandama corridor ASM area",
},
];

const placeAliases: Record<string, string> = {
yaoure: "Yaouré",
bouake: "Bouaké",
};

function normalize(input: string) {
return input
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "");
}

export function findPlace(query: string): CivPlace | undefined {
const key = normalize(query);
if (!key) return undefined;
const aliased = placeAliases[key];
if (aliased) {
return civPlaces.find((place) => place.name === aliased);
}
return civPlaces.find((place) => normalize(place.name) === key);
}

export function listPlaceNames(): string[] {
return civPlaces.map((place) => place.name);
}