Skip to content

Commit 53363dc

Browse files
Grace Gormleyclaude
andcommitted
Add network visualization, document map, and EIS map components
- Update SigmaExample.Client.jsx with subtheme graph architecture (v4 collection, lightenColor, dominantParent logic) - Add EISMap.client.jsx component - Add document-map and theme-network visualization pages - Update mdx.tsx, canopy.yml, and visualization layout/index pages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 04c5646 commit 53363dc

10 files changed

Lines changed: 855 additions & 302 deletions

File tree

app/components/EISMap.client.jsx

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import React, { useEffect, useRef, useState } from "react";
2+
3+
const COLLECTION_URL =
4+
"https://raw.githubusercontent.com/gracegormley-gkg/canumpy-/refs/heads/main/collection-eis-v4.json";
5+
6+
const CANOPY_BASE_URL = "https://nulib-ds.github.io/EIS-Final";
7+
8+
const LEAFLET_CSS = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
9+
const LEAFLET_JS = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js";
10+
11+
const themeColors = {
12+
"Water Systems": "#3b82f6",
13+
"Wildlife and Natural Areas": "#16a34a",
14+
"Energy Systems": "#f59e0b",
15+
"Transportation Infrastructure": "#94a3b8",
16+
"Urban Development": "#f97316",
17+
"Climate and Weather Modification": "#06b6d4",
18+
"Industrial Production and Materials": "#ef4444",
19+
"Place-Based Conflicts": "#8b5cf6",
20+
"Governance": "#d97706",
21+
"Indigenous Narratives": "#10b981",
22+
};
23+
24+
function manifestToCanopyUrl(manifestId) {
25+
const filename = manifestId.split("/").pop().replace(/\.json$/, "");
26+
const slug = filename.slice(0, 50);
27+
return `${CANOPY_BASE_URL}/works/${slug}.html`;
28+
}
29+
30+
function getMeta(metadata, key) {
31+
const entry = metadata?.find((m) => m.label?.none?.[0] === key);
32+
return entry?.value?.none ?? [];
33+
}
34+
35+
const loaders = new Map();
36+
37+
function loadAsset(kind, url) {
38+
if (!loaders.has(url)) {
39+
loaders.set(
40+
url,
41+
new Promise((resolve, reject) => {
42+
const tag = document.createElement(kind === "script" ? "script" : "link");
43+
if (kind === "script") {
44+
tag.src = url;
45+
} else {
46+
tag.rel = "stylesheet";
47+
tag.href = url;
48+
}
49+
tag.onload = resolve;
50+
tag.onerror = () => reject(new Error(`Failed to load ${url}`));
51+
document.head.appendChild(tag);
52+
})
53+
);
54+
}
55+
return loaders.get(url);
56+
}
57+
58+
export default function EISMap({ style }) {
59+
const containerRef = useRef(null);
60+
const mapRef = useRef(null);
61+
const [loading, setLoading] = useState(true);
62+
const [docCount, setDocCount] = useState(0);
63+
64+
useEffect(() => {
65+
let cancelled = false;
66+
67+
const mount = async () => {
68+
await Promise.all([
69+
loadAsset("style", LEAFLET_CSS),
70+
loadAsset("script", LEAFLET_JS),
71+
]);
72+
73+
const res = await fetch(COLLECTION_URL);
74+
const collection = await res.json();
75+
const items = collection.items ?? [];
76+
77+
const geoItems = items
78+
.map((item) => {
79+
const meta = item.metadata ?? [];
80+
const latStr = getMeta(meta, "Latitude")[0];
81+
const lngStr = getMeta(meta, "Longitude")[0];
82+
if (!latStr || !lngStr) return null;
83+
const lat = parseFloat(latStr);
84+
const lng = parseFloat(lngStr);
85+
if (isNaN(lat) || isNaN(lng)) return null;
86+
return {
87+
id: item.id,
88+
title: item.label?.none?.[0] ?? "Untitled",
89+
lat,
90+
lng,
91+
location: getMeta(meta, "Geocoded Location")[0] ?? "",
92+
themes: getMeta(meta, "Themes"),
93+
thumbnail: item.thumbnail?.[0]?.id,
94+
};
95+
})
96+
.filter(Boolean);
97+
98+
if (cancelled || !containerRef.current || mapRef.current) return;
99+
100+
const L = window.L;
101+
const map = L.map(containerRef.current, {
102+
center: [38, -96],
103+
zoom: 4,
104+
scrollWheelZoom: true,
105+
});
106+
mapRef.current = map;
107+
108+
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
109+
attribution:
110+
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
111+
maxZoom: 18,
112+
}).addTo(map);
113+
114+
geoItems.forEach((item) => {
115+
const color = themeColors[item.themes[0]] ?? "#6642a4";
116+
117+
const pinSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="32" viewBox="0 0 24 32">
118+
<path d="M12 0C5.373 0 0 5.373 0 12c0 8.25 12 20 12 20S24 20.25 24 12C24 5.373 18.627 0 12 0z" fill="${color}" />
119+
<circle cx="12" cy="12" r="4.5" fill="white" fill-opacity="0.9" />
120+
</svg>`;
121+
122+
const icon = L.divIcon({
123+
html: pinSvg,
124+
className: "",
125+
iconSize: [24, 32],
126+
iconAnchor: [12, 32],
127+
popupAnchor: [0, -34],
128+
});
129+
130+
const marker = L.marker([item.lat, item.lng], { icon }).addTo(map);
131+
132+
const thumbHtml = item.thumbnail
133+
? `<img src="${item.thumbnail}" style="width:100%;height:110px;object-fit:cover;border-radius:4px;margin-bottom:8px;" />`
134+
: "";
135+
136+
const themePills = item.themes
137+
.map(
138+
(t) =>
139+
`<span style="display:inline-block;background:${themeColors[t] ?? "#6642a4"}22;color:${themeColors[t] ?? "#6642a4"};border-radius:3px;padding:1px 6px;font-size:11px;margin:1px;">${t}</span>`
140+
)
141+
.join("");
142+
143+
marker.bindPopup(
144+
`<div style="max-width:260px;font-family:sans-serif;line-height:1.4;">
145+
${thumbHtml}
146+
<div style="font-weight:600;font-size:13px;margin-bottom:4px;">${item.title}</div>
147+
${item.location ? `<div style="font-size:11px;color:#666;margin-bottom:6px;">${item.location}</div>` : ""}
148+
${themePills ? `<div style="margin-bottom:8px;">${themePills}</div>` : ""}
149+
<a href="${manifestToCanopyUrl(item.id)}" target="_blank" rel="noopener" style="font-size:12px;color:#6642a4;text-decoration:underline;">View Document →</a>
150+
</div>`
151+
);
152+
});
153+
154+
setDocCount(geoItems.length);
155+
setLoading(false);
156+
};
157+
158+
mount().catch(console.error);
159+
160+
return () => {
161+
cancelled = true;
162+
if (mapRef.current) {
163+
mapRef.current.remove();
164+
mapRef.current = null;
165+
}
166+
};
167+
}, []);
168+
169+
return (
170+
<div style={{ position: "relative", ...style }}>
171+
{loading && (
172+
<div
173+
style={{
174+
position: "absolute",
175+
inset: 0,
176+
display: "flex",
177+
alignItems: "center",
178+
justifyContent: "center",
179+
background: "#f9f9f9",
180+
zIndex: 10,
181+
fontSize: 14,
182+
color: "#888",
183+
}}
184+
>
185+
Loading map…
186+
</div>
187+
)}
188+
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
189+
{!loading && (
190+
<div
191+
style={{
192+
position: "absolute",
193+
bottom: 24,
194+
left: 12,
195+
zIndex: 1000,
196+
background: "rgba(255,255,255,0.9)",
197+
borderRadius: 6,
198+
padding: "6px 10px",
199+
fontSize: 12,
200+
color: "#444",
201+
pointerEvents: "none",
202+
}}
203+
>
204+
{docCount} documents mapped
205+
</div>
206+
)}
207+
</div>
208+
);
209+
}

0 commit comments

Comments
 (0)