geo-morpher 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@ GeoJSON morphing utilities for animating between regular geography and cartogram
8
8
  ![](demo.gif)
9
9
 
10
10
  > [!TIP]
11
- > To quickly create a grid cartogram, check out ![gridmapper](https://danylaksono.is-a.dev/gridmapper/).
11
+ > To quickly create a grid cartogram, check out gridmapper (https://danylaksono.is-a.dev/gridmapper/).
12
12
 
13
13
  ## Features
14
14
 
@@ -291,7 +291,8 @@
291
291
  "lodash/cloneDeep.js": "https://esm.sh/lodash@4.17.21/cloneDeep?bundle",
292
292
  "lodash/keyBy.js": "https://esm.sh/lodash@4.17.21/keyBy?bundle",
293
293
  "lodash/mapValues.js": "https://esm.sh/lodash@4.17.21/mapValues?bundle",
294
- "lodash/isEmpty.js": "https://esm.sh/lodash@4.17.21/isEmpty?bundle"
294
+ "lodash/isEmpty.js": "https://esm.sh/lodash@4.17.21/isEmpty?bundle",
295
+ "geo-morpher": "https://esm.sh/geo-morpher@0.1.4"
295
296
  }
296
297
  }
297
298
  </script>
@@ -5,8 +5,8 @@ import {
5
5
  createMapLibreCustomGlyphLayer,
6
6
  parseCSV,
7
7
  WGS84Projection,
8
- } from "../../src/index.js";
9
- import { flattenPositions } from "../../src/adapters/shared/geometry.js";
8
+ flattenPositions,
9
+ } from "geo-morpher";
10
10
 
11
11
  const metrics = [
12
12
  {
@@ -227,7 +227,7 @@ function hideTooltip(map) {
227
227
  }
228
228
 
229
229
  async function fetchJSON(fileName) {
230
- const response = await fetch(new URL(`../../../data/${fileName}`, import.meta.url));
230
+ const response = await fetch(new URL(`../../data/${fileName}`, import.meta.url));
231
231
  if (!response.ok) {
232
232
  throw new Error(`Failed to fetch ${fileName}: ${response.status}`);
233
233
  }
@@ -235,7 +235,7 @@ async function fetchJSON(fileName) {
235
235
  }
236
236
 
237
237
  async function fetchText(fileName) {
238
- const response = await fetch(new URL(`../../../data/${fileName}`, import.meta.url));
238
+ const response = await fetch(new URL(`../../data/${fileName}`, import.meta.url));
239
239
  if (!response.ok) {
240
240
  throw new Error(`Failed to fetch ${fileName}: ${response.status}`);
241
241
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "geo-morpher",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "GeoJSON morphing utilities with MapLibre-first adapter and Leaflet compatibility",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  createGridCartogramFeatureCollection,
8
8
  normalizeCartogramInput,
9
9
  } from "./utils/cartogram.js";
10
+ import { flattenPositions } from "./adapters/shared/geometry.js";
10
11
 
11
12
  export {
12
13
  GeoMorpher,
@@ -25,6 +26,7 @@ export {
25
26
  parseCSV,
26
27
  createGridCartogramFeatureCollection,
27
28
  normalizeCartogramInput,
29
+ flattenPositions,
28
30
  };
29
31
 
30
32
  // MapLibre-first convenience aliases (default adapter)
@@ -1,735 +0,0 @@
1
- import maplibregl from "maplibre-gl";
2
- import {
3
- GeoMorpher,
4
- createMapLibreMorphLayers,
5
- createMapLibreCustomGlyphLayer,
6
- parseCSV,
7
- WGS84Projection,
8
- } from "../../src/index.js";
9
- import { flattenPositions } from "../../src/adapters/shared/geometry.js";
10
-
11
- const metrics = [
12
- {
13
- key: "Indeks Pembangunan Literasi Masyarakat",
14
- label: "Literacy Index",
15
- color: "#3b82f6", // Vibrant Blue
16
- format: (value) => `${value.toFixed(2)} pts`,
17
- },
18
- {
19
- key: "Pemerataan Layanan Perpustakaan",
20
- label: "Library Access",
21
- color: "#06b6d4", // Cyan
22
- format: (value) => `${(value * 100).toFixed(1)}%`,
23
- },
24
- {
25
- key: "Ketercukupan Koleksi Perpustakaan",
26
- label: "Collection Sufficiency",
27
- color: "#f59e0b", // Amber
28
- format: (value) => `${(value * 100).toFixed(1)}%`,
29
- },
30
- {
31
- key: "Rasio Ketercukupan Tenaga Perpustakaan",
32
- label: "Staff Adequacy",
33
- color: "#ef4444", // Red
34
- format: (value) => `${(value * 100).toFixed(1)}%`,
35
- },
36
- {
37
- key: "Tingkat Kunjungan Masyarakat per hari",
38
- label: "Daily Visits",
39
- color: "#a855f7", // Purple
40
- format: (value) => `${(value * 100).toFixed(1)}%`,
41
- },
42
- ];
43
-
44
- const BASE_STYLE = {
45
- version: 8,
46
- name: "Indonesia Literacy",
47
- metadata: {
48
- "geo-morpher": "maplibre-demo-indonesia",
49
- },
50
- glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
51
- sources: {
52
- osm: {
53
- type: "raster",
54
- tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
55
- tileSize: 256,
56
- maxzoom: 19,
57
- attribution: "&copy; OpenStreetMap contributors",
58
- },
59
- },
60
- layers: [
61
- {
62
- id: "background",
63
- type: "background",
64
- paint: {
65
- "background-color": "#020617",
66
- },
67
- },
68
- {
69
- id: "osm",
70
- type: "raster",
71
- source: "osm",
72
- paint: {
73
- "raster-opacity": 0.2,
74
- "raster-brightness-max": 0.6,
75
- "raster-saturation": -0.7,
76
- },
77
- },
78
- ],
79
- };
80
-
81
- const clamp = (value, min = 0, max = 1) => {
82
- const numeric = Number(value);
83
- if (!Number.isFinite(numeric)) return min;
84
- if (numeric <= min) return min;
85
- if (numeric >= max) return max;
86
- return numeric;
87
- };
88
-
89
- const tooltipEl = document.getElementById("tooltip");
90
- const statusEl = document.getElementById("status");
91
- const slider = document.getElementById("morphFactor");
92
- const factorValue = document.getElementById("factorValue");
93
- const playButton = document.getElementById("play-button");
94
- const playIcon = document.getElementById("play-icon");
95
- const pauseIcon = document.getElementById("pause-icon");
96
- const glyphLegendEl = document.getElementById("glyphLegend");
97
- const regularToggle = document.getElementById("toggle-regular");
98
- const interpolatedToggle = document.getElementById("toggle-interpolated");
99
- const cartogramToggle = document.getElementById("toggle-cartogram");
100
- const glyphToggle = document.getElementById("toggle-glyphs");
101
- let hasBootstrapped = false;
102
- let isPlaying = false;
103
- let animationFrameId = null;
104
- let animationDirection = 1; // 1 for forward, -1 for backward
105
-
106
- // comparison mode state --------------------------------------------------
107
- // when a province/glyph is clicked we compute an outline drawing function
108
- // for that province's glyph shape; the function is invoked during glyph
109
- // rendering to project the white outline across every glyph on the canvas.
110
- let comparisonOutlineDraw = null;
111
- // last selected properties (used for hover comparisons)
112
- let selectedComparisonProperties = null;
113
- // will be populated after the glyph layer is created
114
- let glyphControls;
115
- // store the most recent hovered props so we can rebuild tooltip on selection change
116
- let lastHoveredProps = null;
117
-
118
- function createComparisonOutline(properties) {
119
- // compute normalized lengths once; the returned function will scale them
120
- // appropriately for whatever glyph size is being drawn
121
- const normalizedLens = metrics.map((m) => {
122
- const rawValue = Number(properties[m.key] ?? 0);
123
- return m.normalize(rawValue);
124
- });
125
-
126
- return (ctx, x, y, size) => {
127
- const radius = size / 2 - 4;
128
- const centerRadius = 3;
129
- const angleStep = (Math.PI * 2) / metrics.length;
130
-
131
- ctx.save();
132
- ctx.beginPath();
133
- normalizedLens.forEach((norm, i) => {
134
- const petalLength = 2 + norm * (radius - centerRadius - 2);
135
- const angle = angleStep * i - Math.PI / 2;
136
- const sx = Math.cos(angle) * (centerRadius + petalLength);
137
- const sy = Math.sin(angle) * (centerRadius + petalLength);
138
- if (i === 0) {
139
- ctx.moveTo(x + sx, y + sy);
140
- } else {
141
- ctx.lineTo(x + sx, y + sy);
142
- }
143
- });
144
- ctx.closePath();
145
- ctx.strokeStyle = "#fff";
146
- ctx.lineWidth = 1;
147
- ctx.stroke();
148
- ctx.restore();
149
- };
150
- }
151
-
152
-
153
- // helper to build tooltip html given hovered and optionally selected props
154
- function buildTooltipHTML(hoverProps, selectedProps) {
155
- if (!hoverProps) return "";
156
- const buildRows = (props, includeRight = false) =>
157
- metrics
158
- .map(
159
- (m) => {
160
- const left = m.format(props[m.key]);
161
- const right = includeRight && selectedProps ? m.format(selectedProps[m.key]) : "";
162
- if (includeRight) {
163
- return `
164
- <div class="tooltip-row comparison-row">
165
- <span class="tooltip-label">${m.label}</span>
166
- <span class="tooltip-value" style="color:${m.color}">${left}</span>
167
- <span class="tooltip-value right" style="color:${m.color}">${right}</span>
168
- </div>
169
- `;
170
- }
171
- return `
172
- <div class="tooltip-row">
173
- <span class="tooltip-label">${m.label}</span>
174
- <span class="tooltip-value" style="color:${m.color}">${left}</span>
175
- </div>
176
- `;
177
- }
178
- )
179
- .join("");
180
-
181
- if (selectedProps) {
182
- const header = `<div class="tooltip-header comparison"><span>${hoverProps.PROVINSI}</span><span>${selectedProps.PROVINSI}</span></div>`;
183
- const rows = buildRows(hoverProps, true);
184
- return header + rows;
185
- }
186
-
187
- return `<div class="tooltip-header">${hoverProps.PROVINSI}</div>` + buildRows(hoverProps);
188
- }
189
-
190
- function updateComparisonSelection(props) {
191
- selectedComparisonProperties = props || null;
192
- if (props) {
193
- comparisonOutlineDraw = createComparisonOutline(props);
194
- } else {
195
- comparisonOutlineDraw = null;
196
- }
197
- if (glyphControls) {
198
- // force all glyphs to redraw with new outline state
199
- glyphControls.updateGlyphs({});
200
- }
201
- // if we are currently displaying a tooltip, refresh its content
202
- if (tooltipEl && lastHoveredProps) {
203
- tooltipEl.innerHTML = buildTooltipHTML(lastHoveredProps, selectedComparisonProperties);
204
- }
205
- }
206
-
207
-
208
- function hideTooltip(map) {
209
- if (tooltipEl) {
210
- tooltipEl.style.display = "none";
211
- }
212
-
213
- if (map?.getCanvas) {
214
- map.getCanvas().style.cursor = "";
215
- }
216
- }
217
-
218
- async function fetchJSON(fileName) {
219
- const response = await fetch(new URL(`../../../data/${fileName}`, import.meta.url));
220
- if (!response.ok) {
221
- throw new Error(`Failed to fetch ${fileName}: ${response.status}`);
222
- }
223
- return response.json();
224
- }
225
-
226
- async function fetchText(fileName) {
227
- const response = await fetch(new URL(`../../../data/${fileName}`, import.meta.url));
228
- if (!response.ok) {
229
- throw new Error(`Failed to fetch ${fileName}: ${response.status}`);
230
- }
231
- return response.text();
232
- }
233
-
234
-
235
- function computeBounds(featureCollection) {
236
- const bounds = new maplibregl.LngLatBounds();
237
- featureCollection.features.forEach((feature) => {
238
- flattenPositions(feature.geometry).forEach(([lng, lat]) => {
239
- if (Number.isFinite(lng) && Number.isFinite(lat)) {
240
- bounds.extend([lng, lat]);
241
- }
242
- });
243
- });
244
- return bounds;
245
- }
246
-
247
- function buildLegend() {
248
- if (!glyphLegendEl) return;
249
- glyphLegendEl.innerHTML = "";
250
- metrics.forEach((metric) => {
251
- const item = document.createElement("li");
252
- item.className = "legend-item";
253
- item.innerHTML = `
254
- <span class="legend-swatch" style="background:${metric.color}"></span>
255
- <span>${metric.label}</span>
256
- `;
257
- glyphLegendEl.appendChild(item);
258
- });
259
- }
260
-
261
- function createRoseChartGlyph({ data, feature }) {
262
- if (!data) return null;
263
- const properties =
264
- data?.data?.properties ??
265
- data?.properties ??
266
- feature?.properties ?? {};
267
-
268
- return {
269
- size: 72,
270
- shape: "custom",
271
- customRender: (ctx, x, y, size) => {
272
- const radius = size / 2 - 4;
273
- const centerRadius = 3;
274
- const spikes = metrics.length;
275
- const angleStep = (Math.PI * 2) / spikes;
276
-
277
- // Drop shadow for the whole glyph
278
- ctx.shadowBlur = 8;
279
- ctx.shadowColor = "rgba(0, 0, 0, 0.4)";
280
- ctx.shadowOffsetY = 2;
281
-
282
- // Draw background circle
283
- ctx.fillStyle = "rgba(15, 23, 42, 0.75)";
284
- ctx.beginPath();
285
- ctx.arc(x, y, radius + 2, 0, Math.PI * 2);
286
- ctx.fill();
287
-
288
- ctx.shadowBlur = 0;
289
- ctx.shadowOffsetY = 0;
290
-
291
- // Draw grid circles
292
- ctx.strokeStyle = "rgba(255, 255, 255, 0.1)";
293
- ctx.lineWidth = 0.5;
294
- [0.25, 0.5, 0.75, 1.0].forEach(p => {
295
- const r = centerRadius + p * (radius - centerRadius);
296
- ctx.beginPath();
297
- ctx.arc(x, y, r, 0, Math.PI * 2);
298
- ctx.stroke();
299
- });
300
-
301
- // Draw petals
302
- metrics.forEach((metric, i) => {
303
- const rawValue = Number(properties[metric.key] ?? 0);
304
- const normalized = metric.normalize(rawValue);
305
- const petalLength = 2 + normalized * (radius - centerRadius - 2);
306
-
307
- const startAngle = angleStep * i - Math.PI / 2 - angleStep / 2 + 0.08;
308
- const endAngle = angleStep * i - Math.PI / 2 + angleStep / 2 - 0.08;
309
-
310
- // Petal shape
311
- ctx.save();
312
- ctx.fillStyle = metric.color;
313
- ctx.globalAlpha = 0.8;
314
- ctx.beginPath();
315
- ctx.moveTo(x + Math.cos(startAngle) * centerRadius, y + Math.sin(startAngle) * centerRadius);
316
- ctx.arc(x, y, centerRadius + petalLength, startAngle, endAngle);
317
- ctx.lineTo(x + Math.cos(endAngle) * centerRadius, y + Math.sin(endAngle) * centerRadius);
318
- ctx.closePath();
319
- ctx.fill();
320
-
321
- // Highlighting edge
322
- ctx.strokeStyle = "rgba(255, 255, 255, 0.5)";
323
- ctx.lineWidth = 1;
324
- ctx.stroke();
325
- ctx.restore();
326
- });
327
-
328
- // Draw center circle with glow
329
- ctx.save();
330
- ctx.shadowBlur = 6;
331
- ctx.shadowColor = "rgba(255, 255, 255, 0.8)";
332
- ctx.fillStyle = "#fff";
333
- ctx.beginPath();
334
- ctx.arc(x, y, centerRadius, 0, Math.PI * 2);
335
- ctx.fill();
336
- ctx.restore();
337
-
338
- // if a comparison glyph is selected, draw its outline
339
- if (typeof comparisonOutlineDraw === "function") {
340
- comparisonOutlineDraw(ctx, x, y, size);
341
- }
342
- },
343
- };
344
- }
345
-
346
- function normalizeLiteracyRecords(records) {
347
- const numericKeys = new Set(metrics.map((metric) => metric.key));
348
-
349
- // First pass: find min/max for each metric
350
- metrics.forEach(metric => {
351
- const values = records.map(r => Number(r[metric.key])).filter(v => !isNaN(v));
352
- const min = Math.min(...values);
353
- const max = Math.max(...values);
354
-
355
- // We want a bit of padding so the smallest isn't zero length
356
- metric.normalize = (val) => {
357
- const v = Number(val);
358
- if (isNaN(v)) return 0;
359
- if (max === min) return 0.5;
360
- return clamp((v - min) / (max - min));
361
- };
362
- });
363
-
364
- return records.map((record) => {
365
- const normalized = { ...record };
366
- numericKeys.forEach((key) => {
367
- if (key in normalized) {
368
- const value = Number(normalized[key]);
369
- normalized[key] = Number.isFinite(value) ? value : 0;
370
- }
371
- });
372
- return normalized;
373
- });
374
- }
375
-
376
- async function bootstrap() {
377
- if (hasBootstrapped) {
378
- return;
379
- }
380
- hasBootstrapped = true;
381
-
382
- try {
383
- if (statusEl) {
384
- statusEl.textContent = "Loading data…";
385
- }
386
-
387
- const [regularGeoJSON, cartogramCSV, literacyCSV] = await Promise.all([
388
- fetchJSON("indonesia/indonesia_provice_boundary.geojson"),
389
- fetchText("indonesia/indonesia-grid.csv"),
390
- fetchText("indonesia/literasi_2024.csv"),
391
- ]);
392
-
393
- const literacyRecords = normalizeLiteracyRecords(parseCSV(literacyCSV));
394
-
395
- const aggregations = metrics.reduce((acc, metric) => {
396
- acc[metric.key] = "mean";
397
- return acc;
398
- }, {});
399
-
400
- const morpher = new GeoMorpher({
401
- regularGeoJSON,
402
- cartogramGeoJSON: cartogramCSV,
403
- data: literacyRecords,
404
- joinColumn: "ID",
405
- geoJSONJoinColumn: "id",
406
- aggregations,
407
- normalize: false,
408
- projection: WGS84Projection,
409
- cartogramGridOptions: {
410
- idField: "ID",
411
- rowField: "row",
412
- colField: "col",
413
- cellPadding: 0.08,
414
- rowOrientation: "top",
415
- colOrientation: "left",
416
- },
417
- });
418
-
419
- await morpher.prepare();
420
-
421
- const initialFactor = Number(slider.value);
422
- factorValue.textContent = initialFactor.toFixed(2);
423
-
424
- buildLegend();
425
-
426
- const map = new maplibregl.Map({
427
- container: "map",
428
- style: BASE_STYLE,
429
- center: [117.5, -2.5],
430
- zoom: 4.2,
431
- attributionControl: false,
432
- });
433
-
434
- map.addControl(new maplibregl.NavigationControl(), "top-right");
435
- map.addControl(new maplibregl.AttributionControl({ compact: true }), "bottom-left");
436
-
437
- const bounds = computeBounds(morpher.getRegularFeatureCollection());
438
-
439
- map.on("load", async () => {
440
- const morphControls = await createMapLibreMorphLayers({
441
- morpher,
442
- map,
443
- morphFactor: initialFactor,
444
- idBase: "geomorpher-indonesia",
445
- regularStyle: {
446
- paint: {
447
- "fill-color": "#1e293b",
448
- "fill-opacity": 0.4,
449
- "fill-outline-color": "rgba(255,255,255,0.2)",
450
- },
451
- },
452
- cartogramStyle: {
453
- paint: {
454
- "fill-color": "#1e293b",
455
- "fill-opacity": 0.4,
456
- "fill-outline-color": "rgba(255,255,255,0.2)",
457
- },
458
- },
459
- interpolatedStyle: {
460
- paint: {
461
- "fill-color": [
462
- "case",
463
- ["boolean", ["feature-state", "hover"], false],
464
- "#60a5fa",
465
- "#3b82f6"
466
- ],
467
- "fill-opacity": [
468
- "case",
469
- ["boolean", ["feature-state", "hover"], false],
470
- 0.4,
471
- 0.15
472
- ],
473
- "fill-outline-color": [
474
- "case",
475
- ["boolean", ["feature-state", "hover"], false],
476
- "#ffffff",
477
- "rgba(255, 255, 255, 0.4)"
478
- ],
479
- },
480
- },
481
- });
482
-
483
- // Update OSM opacity based on initial factor
484
- map.setPaintProperty("osm", "raster-opacity", 0.2 * (1 - initialFactor));
485
-
486
- let hoveredStateId = null;
487
-
488
- // Add province labels layer
489
- map.addSource("province-labels", {
490
- type: "geojson",
491
- data: morpher.getInterpolatedFeatureCollection(initialFactor),
492
- });
493
-
494
- map.addLayer({
495
- id: "province-labels-layer",
496
- type: "symbol",
497
- source: "province-labels",
498
- layout: {
499
- "text-field": ["get", "PROVINSI"],
500
- "text-font": ["Open Sans Regular"],
501
- "text-size": 10,
502
- "text-offset": [0, 2.5],
503
- "text-anchor": "top",
504
- "text-transform": "uppercase",
505
- "text-letter-spacing": 0.1,
506
- },
507
- paint: {
508
- "text-color": "#94a3b8",
509
- "text-halo-color": "rgba(2, 6, 23, 0.8)",
510
- "text-halo-width": 1,
511
- },
512
- });
513
-
514
- glyphControls = await createMapLibreCustomGlyphLayer({
515
- morpher,
516
- map,
517
- morphFactor: initialFactor,
518
- geometry: "interpolated",
519
- drawGlyph: ({ feature, featureId, data }) => {
520
- if (!data) return null;
521
- return createRoseChartGlyph({ data, feature });
522
- },
523
- glyphOptions: {
524
- size: 72,
525
- },
526
- });
527
-
528
- const applyLayerVisibility = () => {
529
- const vis = {
530
- regular: regularToggle ? regularToggle.checked : false,
531
- cartogram: cartogramToggle ? cartogramToggle.checked : false,
532
- interpolated: interpolatedToggle ? interpolatedToggle.checked : true,
533
- };
534
- morphControls.setLayerVisibility(vis);
535
-
536
- map.setLayoutProperty(
537
- "province-labels-layer",
538
- "visibility",
539
- vis.interpolated ? "visible" : "none"
540
- );
541
-
542
- if (glyphToggle && !glyphToggle.checked) {
543
- glyphControls.clear();
544
- } else {
545
- glyphControls.updateGlyphs({ morphFactor: Number(slider.value) });
546
- }
547
- };
548
-
549
- // Hover handling
550
- const handleMouseMove = (e) => {
551
- const features = map.queryRenderedFeatures(e.point, {
552
- layers: [
553
- morphControls.layerIds.interpolated,
554
- morphControls.layerIds.regular,
555
- morphControls.layerIds.cartogram
556
- ].filter(id => map.getLayer(id))
557
- });
558
-
559
- if (features.length > 0) {
560
- const feature = features[0];
561
-
562
- if (hoveredStateId !== null && hoveredStateId !== undefined) {
563
- map.setFeatureState(
564
- { source: morphControls.sourceIds.interpolated, id: hoveredStateId },
565
- { hover: false }
566
- );
567
- }
568
- hoveredStateId = feature.id;
569
- if (hoveredStateId !== null && hoveredStateId !== undefined) {
570
- map.setFeatureState(
571
- { source: morphControls.sourceIds.interpolated, id: hoveredStateId },
572
- { hover: true }
573
- );
574
- }
575
-
576
- // Use featureId or properties.id to join with morpher data
577
- const id = feature.properties.id || feature.properties.ID;
578
- const data = morpher.getKeyData()[id];
579
-
580
- if (data && tooltipEl) {
581
- const props = data.data.properties;
582
- lastHoveredProps = props;
583
- tooltipEl.innerHTML = buildTooltipHTML(props, selectedComparisonProperties);
584
- tooltipEl.style.display = "block";
585
- tooltipEl.style.left = `${e.originalEvent.pageX + 15}px`;
586
- tooltipEl.style.top = `${e.originalEvent.pageY + 15}px`;
587
- map.getCanvas().style.cursor = "pointer";
588
- }
589
- } else {
590
- hideTooltip(map);
591
- }
592
- };
593
-
594
- const hideTooltip = () => {
595
- if (tooltipEl) {
596
- tooltipEl.style.display = "none";
597
- }
598
- if (map?.getCanvas) {
599
- map.getCanvas().style.cursor = "";
600
- }
601
- if (hoveredStateId !== null && hoveredStateId !== undefined) {
602
- map.setFeatureState(
603
- { source: morphControls.sourceIds.interpolated, id: hoveredStateId },
604
- { hover: false }
605
- );
606
- hoveredStateId = null;
607
- }
608
- };
609
-
610
- map.on("mousemove", handleMouseMove);
611
- map.on("mouseleave", morphControls.layerIds.interpolated, hideTooltip);
612
- map.on("movestart", hideTooltip);
613
- map.on("zoomstart", hideTooltip);
614
-
615
- // comparison mode click handler
616
- map.on("click", (e) => {
617
- const features = map.queryRenderedFeatures(e.point, {
618
- layers: [
619
- morphControls.layerIds.interpolated,
620
- morphControls.layerIds.regular,
621
- morphControls.layerIds.cartogram,
622
- ].filter((id) => map.getLayer(id)),
623
- });
624
-
625
- if (features.length > 0) {
626
- const feat = features[0];
627
- const id = feat.properties.id || feat.properties.ID;
628
- const data = morpher.getKeyData()[id];
629
- if (data && data.data && data.data.properties) {
630
- updateComparisonSelection(data.data.properties);
631
- return;
632
- }
633
- }
634
- // clicked outside any province -> clear selection
635
- updateComparisonSelection(null);
636
- });
637
-
638
- applyLayerVisibility();
639
-
640
- [regularToggle, cartogramToggle, interpolatedToggle, glyphToggle]
641
- .filter(Boolean)
642
- .forEach((input) => input.addEventListener("change", applyLayerVisibility));
643
-
644
- if (bounds && typeof bounds.isEmpty === "function" && !bounds.isEmpty()) {
645
- map.fitBounds(bounds, { padding: 48, linear: true, duration: 0 });
646
- }
647
-
648
- const updateUIForFactor = (value) => {
649
- slider.value = value;
650
- factorValue.textContent = value.toFixed(2);
651
- morphControls.updateMorphFactor(value);
652
-
653
- // Update OSM opacity
654
- map.setPaintProperty("osm", "raster-opacity", 0.2 * (1 - value));
655
-
656
- // Update labels
657
- const source = map.getSource("province-labels");
658
- if (source) {
659
- source.setData(morpher.getInterpolatedFeatureCollection(value));
660
- }
661
-
662
- if (!glyphToggle || glyphToggle.checked) {
663
- glyphControls.updateGlyphs({ morphFactor: value });
664
- }
665
- };
666
-
667
- slider.addEventListener("input", (event) => {
668
- const value = Number(event.target.value);
669
- if (isPlaying) {
670
- stopAnimation();
671
- }
672
- updateUIForFactor(value);
673
- });
674
-
675
- const animate = () => {
676
- if (!isPlaying) return;
677
-
678
- let currentValue = Number(slider.value);
679
- const step = 0.005;
680
-
681
- currentValue += step * animationDirection;
682
-
683
- if (currentValue >= 1) {
684
- currentValue = 1;
685
- animationDirection = -1;
686
- } else if (currentValue <= 0) {
687
- currentValue = 0;
688
- animationDirection = 1;
689
- }
690
-
691
- updateUIForFactor(currentValue);
692
- animationFrameId = requestAnimationFrame(animate);
693
- };
694
-
695
- const startAnimation = () => {
696
- isPlaying = true;
697
- playIcon.style.display = "none";
698
- pauseIcon.style.display = "block";
699
- animate();
700
- };
701
-
702
- const stopAnimation = () => {
703
- isPlaying = false;
704
- playIcon.style.display = "block";
705
- pauseIcon.style.display = "none";
706
- if (animationFrameId) {
707
- cancelAnimationFrame(animationFrameId);
708
- animationFrameId = null;
709
- }
710
- };
711
-
712
- playButton.addEventListener("click", () => {
713
- if (isPlaying) {
714
- stopAnimation();
715
- } else {
716
- startAnimation();
717
- }
718
- });
719
-
720
- if (statusEl) {
721
- statusEl.textContent = "Ready";
722
- }
723
- });
724
- } catch (error) {
725
- console.error(error);
726
- if (statusEl) {
727
- statusEl.textContent = "Something went wrong";
728
- }
729
- hasBootstrapped = false;
730
- }
731
- }
732
-
733
- bootstrap();
734
-
735
-