Skip to content

Commit b87ae66

Browse files
committed
Add GeoProjection/true-size to easily compare countries and us state with one another across different projections (mercator, orthographic, natural earth, etc)
1 parent 796f066 commit b87ae66

1 file changed

Lines changed: 318 additions & 0 deletions

File tree

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
<script lang="ts">
2+
import {
3+
geoEqualEarth,
4+
geoEquirectangular,
5+
geoMercator,
6+
geoNaturalEarth1,
7+
geoOrthographic,
8+
geoCentroid,
9+
type GeoProjection
10+
} from 'd3-geo';
11+
import { feature } from 'topojson-client';
12+
13+
import { interpolateTurbo } from 'd3-scale-chromatic';
14+
15+
import { Chart, GeoPath, Graticule, Layer } from 'layerchart';
16+
import { Button, SelectField } from 'svelte-ux';
17+
import { getCountriesTopology, getUsStatesTopology } from '$lib/geo.remote';
18+
19+
const countriesTopo = await getCountriesTopology();
20+
const countries = feature(countriesTopo, countriesTopo.objects.countries);
21+
22+
const statesTopo = await getUsStatesTopology();
23+
const usStates = feature(statesTopo, statesTopo.objects.states);
24+
25+
const projections = [
26+
{ label: 'Mercator', value: geoMercator },
27+
{ label: 'Orthographic', value: geoOrthographic },
28+
{ label: 'Equal Earth', value: geoEqualEarth },
29+
{ label: 'Natural Earth', value: geoNaturalEarth1 },
30+
{ label: 'Equirectangular', value: geoEquirectangular }
31+
];
32+
33+
const goldenRatio = 0.618033988749895;
34+
let hueOffset = $state(0);
35+
36+
function nextColor() {
37+
const color = interpolateTurbo((hueOffset + 0.1) % 1);
38+
hueOffset = (hueOffset + goldenRatio) % 1;
39+
return color;
40+
}
41+
42+
let projection = $state(geoMercator);
43+
44+
type SelectedShape = {
45+
feature: GeoJSON.Feature;
46+
offset: [number, number];
47+
rotation: number;
48+
color: string;
49+
};
50+
51+
let selectedShapes = $state<SelectedShape[]>([]);
52+
53+
const countryOptions = $derived(
54+
countries.features
55+
.map((f) => ({ label: f.properties?.name ?? String(f.id), value: f }))
56+
.filter((o) => o.label)
57+
.sort((a, b) => a.label.localeCompare(b.label))
58+
);
59+
60+
const stateOptions = $derived(
61+
usStates.features
62+
.map((f) => ({ label: f.properties?.name ?? String(f.id), value: f }))
63+
.filter((o) => o.label)
64+
.sort((a, b) => a.label.localeCompare(b.label))
65+
);
66+
67+
let selectedCountry = $state<GeoJSON.Feature | null>(null);
68+
let selectedState = $state<GeoJSON.Feature | null>(null);
69+
70+
$effect(() => {
71+
if (selectedCountry) {
72+
addShape(selectedCountry);
73+
selectedCountry = null;
74+
}
75+
});
76+
77+
$effect(() => {
78+
if (selectedState) {
79+
addShape(selectedState);
80+
selectedState = null;
81+
}
82+
});
83+
84+
function addShape(feat: GeoJSON.Feature) {
85+
const color = nextColor();
86+
selectedShapes.push({ feature: feat, offset: [0, 0], rotation: 0, color });
87+
}
88+
89+
function removeShape(index: number) {
90+
selectedShapes.splice(index, 1);
91+
}
92+
93+
// --- Coordinate transformation (translate + rotate) ---
94+
95+
function transformCoords(
96+
coords: GeoJSON.Position | GeoJSON.Position[] | GeoJSON.Position[][] | GeoJSON.Position[][][],
97+
dLon: number,
98+
dLat: number,
99+
angleDeg: number,
100+
centerLon: number,
101+
centerLat: number
102+
): any {
103+
if (typeof coords[0] === 'number') {
104+
let [lon, lat] = coords as GeoJSON.Position;
105+
// Rotate around centroid first, then translate
106+
if (angleDeg !== 0) {
107+
const rad = (angleDeg * Math.PI) / 180;
108+
const cos = Math.cos(rad);
109+
const sin = Math.sin(rad);
110+
const dx = lon - centerLon;
111+
const dy = lat - centerLat;
112+
lon = centerLon + dx * cos - dy * sin;
113+
lat = centerLat + dx * sin + dy * cos;
114+
}
115+
return [lon + dLon, lat + dLat];
116+
}
117+
return (coords as any[]).map((c: any) =>
118+
transformCoords(c, dLon, dLat, angleDeg, centerLon, centerLat)
119+
);
120+
}
121+
122+
function transformGeometry(
123+
geometry: GeoJSON.Geometry,
124+
dLon: number,
125+
dLat: number,
126+
angleDeg: number,
127+
centerLon: number,
128+
centerLat: number
129+
): GeoJSON.Geometry {
130+
if (geometry.type === 'GeometryCollection') {
131+
return {
132+
...geometry,
133+
geometries: geometry.geometries.map((g) =>
134+
transformGeometry(g, dLon, dLat, angleDeg, centerLon, centerLat)
135+
)
136+
};
137+
}
138+
return {
139+
...geometry,
140+
coordinates: transformCoords(
141+
(geometry as GeoJSON.Polygon).coordinates,
142+
dLon,
143+
dLat,
144+
angleDeg,
145+
centerLon,
146+
centerLat
147+
)
148+
};
149+
}
150+
151+
function transformFeature(
152+
feat: GeoJSON.Feature,
153+
dLon: number,
154+
dLat: number,
155+
angleDeg: number
156+
): GeoJSON.Feature {
157+
const [centerLon, centerLat] = geoCentroid(feat);
158+
return {
159+
...feat,
160+
geometry: transformGeometry(feat.geometry, dLon, dLat, angleDeg, centerLon, centerLat)
161+
};
162+
}
163+
164+
// --- Drag handling ---
165+
166+
let dragIndex = $state<number | null>(null);
167+
let dragStartLonLat = $state<[number, number] | null>(null);
168+
let dragStartOffset = $state<[number, number] | null>(null);
169+
170+
function svgPoint(e: PointerEvent): [number, number] {
171+
const el = e.target as SVGGraphicsElement;
172+
const pt = new DOMPoint(e.clientX, e.clientY);
173+
const svgPt = pt.matrixTransform(el.getScreenCTM()!.inverse());
174+
return [svgPt.x, svgPt.y];
175+
}
176+
177+
function startDrag(e: PointerEvent, index: number, proj: GeoProjection | undefined) {
178+
e.stopPropagation();
179+
dragIndex = index;
180+
const coords = svgPoint(e);
181+
const lonLat = proj?.invert?.(coords);
182+
if (lonLat) {
183+
dragStartLonLat = lonLat as [number, number];
184+
dragStartOffset = [...selectedShapes[index].offset];
185+
}
186+
(e.target as Element).setPointerCapture(e.pointerId);
187+
}
188+
189+
function onDrag(e: PointerEvent, proj: GeoProjection | undefined) {
190+
if (dragIndex === null || !dragStartLonLat || !dragStartOffset) return;
191+
const coords = svgPoint(e);
192+
const lonLat = proj?.invert?.(coords);
193+
if (lonLat) {
194+
selectedShapes[dragIndex].offset = [
195+
dragStartOffset[0] + (lonLat[0] - dragStartLonLat[0]),
196+
dragStartOffset[1] + (lonLat[1] - dragStartLonLat[1])
197+
];
198+
}
199+
}
200+
201+
function endDrag() {
202+
dragIndex = null;
203+
dragStartLonLat = null;
204+
dragStartOffset = null;
205+
}
206+
207+
const data = { countriesTopo, statesTopo, countries, usStates };
208+
export { data };
209+
</script>
210+
211+
<div class="grid gap-2">
212+
<div class="grid grid-cols-3 gap-2 screenshot-hidden">
213+
<SelectField
214+
label="Projection"
215+
options={projections}
216+
bind:value={projection}
217+
clearable={false}
218+
/>
219+
<SelectField
220+
label="Add country"
221+
options={countryOptions}
222+
bind:value={selectedCountry}
223+
search={async (text, options) =>
224+
options.filter((o) => o.label.toLowerCase().includes(text.toLowerCase()))}
225+
clearable
226+
placeholder="Search countries..."
227+
/>
228+
<SelectField
229+
label="Add US state"
230+
options={stateOptions}
231+
bind:value={selectedState}
232+
search={async (text, options) =>
233+
options.filter((o) => o.label.toLowerCase().includes(text.toLowerCase()))}
234+
clearable
235+
placeholder="Search states..."
236+
/>
237+
</div>
238+
239+
{#if selectedShapes.length}
240+
<div class="flex gap-2 flex-wrap items-center screenshot-hidden">
241+
{#each selectedShapes as shape, i}
242+
<div class="flex items-center gap-1 border rounded-lg px-2 py-1">
243+
<span class="w-3 h-3 rounded-full inline-block shrink-0" style:background={shape.color}
244+
></span>
245+
<span class="text-sm whitespace-nowrap"
246+
>{shape.feature.properties?.name ?? 'Unknown'}</span
247+
>
248+
<input
249+
type="range"
250+
min={-180}
251+
max={180}
252+
step={1}
253+
bind:value={shape.rotation}
254+
class="w-20 h-4 accent-current"
255+
style:color={shape.color}
256+
title="Rotate: {shape.rotation}°"
257+
/>
258+
<span class="text-xs text-surface-content/50 w-8 text-right">{shape.rotation}°</span>
259+
<button
260+
class="text-surface-content/40 hover:text-surface-content ml-1"
261+
onclick={() => removeShape(i)}>×</button
262+
>
263+
</div>
264+
{/each}
265+
</div>
266+
{/if}
267+
268+
<div class="h-150 bg-surface-100/50 border rounded-lg overflow-hidden">
269+
<Chart
270+
geo={{
271+
projection,
272+
fitGeojson: countries
273+
}}
274+
transform={{
275+
mode: 'projection',
276+
scrollMode: 'scale',
277+
scaleExtent: [0.5, 10],
278+
translateExtent: [
279+
[-300, -200],
280+
[300, 200]
281+
]
282+
}}
283+
padding={{ top: 8, bottom: 8, left: 8, right: 8 }}
284+
>
285+
{#snippet children({ context })}
286+
<Layer>
287+
<Graticule class="stroke-surface-content/10" />
288+
{#each countries.features as feature}
289+
<GeoPath geojson={feature} class="stroke-surface-content/20 fill-surface-200" />
290+
{/each}
291+
</Layer>
292+
293+
<Layer>
294+
{#each selectedShapes as shape, i (i)}
295+
{@const translated = transformFeature(
296+
shape.feature,
297+
shape.offset[0],
298+
shape.offset[1],
299+
shape.rotation
300+
)}
301+
<GeoPath
302+
geojson={translated}
303+
fill={shape.color}
304+
fill-opacity={0.5}
305+
stroke={shape.color}
306+
strokeWidth={2 / context.transform.scale}
307+
class={dragIndex === i ? 'cursor-grabbing' : 'cursor-grab'}
308+
onpointerdown={(e) => startDrag(e, i, context.geo.projection)}
309+
onpointermove={(e) => onDrag(e, context.geo.projection)}
310+
onpointerup={() => endDrag()}
311+
onpointercancel={() => endDrag()}
312+
/>
313+
{/each}
314+
</Layer>
315+
{/snippet}
316+
</Chart>
317+
</div>
318+
</div>

0 commit comments

Comments
 (0)