geo-morpher 0.1.2 → 0.1.3

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.
@@ -0,0 +1,770 @@
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: "© 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 0->1, -1 for 1->0
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
+
157
+ const buildRows = () =>
158
+ metrics
159
+ .map((m) => {
160
+ const leftValue = m.format(hoverProps[m.key]);
161
+ const rightValue = selectedProps ? m.format(selectedProps[m.key]) : null;
162
+
163
+ if (selectedProps) {
164
+ return `
165
+ <tr>
166
+ <td class="tooltip-label">${m.label}</td>
167
+ <td class="tooltip-value" style="color:${m.color}">${leftValue}</td>
168
+ <td class="tooltip-value right" style="color:${m.color}">${rightValue}</td>
169
+ </tr>
170
+ `;
171
+ }
172
+ return `
173
+ <div class="tooltip-row">
174
+ <span class="tooltip-label">${m.label}</span>
175
+ <span class="tooltip-value" style="color:${m.color}">${leftValue}</span>
176
+ </div>
177
+ `;
178
+ })
179
+ .join("");
180
+
181
+ if (selectedProps) {
182
+ return `
183
+ <table class="comparison-table">
184
+ <thead>
185
+ <tr>
186
+ <th>Metric</th>
187
+ <th class="header-hovered">${hoverProps.PROVINSI}</th>
188
+ <th class="header-selected">${selectedProps.PROVINSI}</th>
189
+ </tr>
190
+ </thead>
191
+ <tbody>
192
+ ${buildRows()}
193
+ </tbody>
194
+ </table>
195
+ `;
196
+ }
197
+
198
+ return `<div class="tooltip-header">${hoverProps.PROVINSI}</div>` + buildRows();
199
+ }
200
+
201
+ function updateComparisonSelection(props) {
202
+ selectedComparisonProperties = props || null;
203
+ if (props) {
204
+ comparisonOutlineDraw = createComparisonOutline(props);
205
+ } else {
206
+ comparisonOutlineDraw = null;
207
+ }
208
+ if (glyphControls) {
209
+ // force all glyphs to redraw with new outline state
210
+ glyphControls.updateGlyphs({});
211
+ }
212
+ // if we are currently displaying a tooltip, refresh its content
213
+ if (tooltipEl && lastHoveredProps) {
214
+ tooltipEl.innerHTML = buildTooltipHTML(lastHoveredProps, selectedComparisonProperties);
215
+ }
216
+ }
217
+
218
+
219
+ function hideTooltip(map) {
220
+ if (tooltipEl) {
221
+ tooltipEl.style.display = "none";
222
+ }
223
+
224
+ if (map?.getCanvas) {
225
+ map.getCanvas().style.cursor = "";
226
+ }
227
+ }
228
+
229
+ async function fetchJSON(fileName) {
230
+ const response = await fetch(new URL(`../../../data/${fileName}`, import.meta.url));
231
+ if (!response.ok) {
232
+ throw new Error(`Failed to fetch ${fileName}: ${response.status}`);
233
+ }
234
+ return response.json();
235
+ }
236
+
237
+ async function fetchText(fileName) {
238
+ const response = await fetch(new URL(`../../../data/${fileName}`, import.meta.url));
239
+ if (!response.ok) {
240
+ throw new Error(`Failed to fetch ${fileName}: ${response.status}`);
241
+ }
242
+ return response.text();
243
+ }
244
+
245
+
246
+ function computeBounds(featureCollection) {
247
+ const bounds = new maplibregl.LngLatBounds();
248
+ featureCollection.features.forEach((feature) => {
249
+ flattenPositions(feature.geometry).forEach(([lng, lat]) => {
250
+ if (Number.isFinite(lng) && Number.isFinite(lat)) {
251
+ bounds.extend([lng, lat]);
252
+ }
253
+ });
254
+ });
255
+ return bounds;
256
+ }
257
+
258
+ function buildLegend() {
259
+ if (!glyphLegendEl) return;
260
+ glyphLegendEl.innerHTML = "";
261
+ metrics.forEach((metric) => {
262
+ const item = document.createElement("li");
263
+ item.className = "legend-item";
264
+ item.innerHTML = `
265
+ <span class="legend-swatch" style="background:${metric.color}"></span>
266
+ <span>${metric.label}</span>
267
+ `;
268
+ glyphLegendEl.appendChild(item);
269
+ });
270
+ }
271
+
272
+ function createRoseChartGlyph({ data, feature }) {
273
+ if (!data) return null;
274
+ const properties =
275
+ data?.data?.properties ??
276
+ data?.properties ??
277
+ feature?.properties ?? {};
278
+
279
+ return {
280
+ size: 72,
281
+ shape: "custom",
282
+ customRender: (ctx, x, y, size) => {
283
+ const radius = size / 2 - 4;
284
+ const centerRadius = 3;
285
+ const spikes = metrics.length;
286
+ const angleStep = (Math.PI * 2) / spikes;
287
+
288
+ // Drop shadow for the whole glyph
289
+ ctx.shadowBlur = 8;
290
+ ctx.shadowColor = "rgba(0, 0, 0, 0.4)";
291
+ ctx.shadowOffsetY = 2;
292
+
293
+ // Draw background circle
294
+ ctx.fillStyle = "rgba(15, 23, 42, 0.75)";
295
+ ctx.beginPath();
296
+ ctx.arc(x, y, radius + 2, 0, Math.PI * 2);
297
+ ctx.fill();
298
+
299
+ ctx.shadowBlur = 0;
300
+ ctx.shadowOffsetY = 0;
301
+
302
+ // Draw grid circles
303
+ ctx.strokeStyle = "rgba(255, 255, 255, 0.1)";
304
+ ctx.lineWidth = 0.5;
305
+ [0.25, 0.5, 0.75, 1.0].forEach(p => {
306
+ const r = centerRadius + p * (radius - centerRadius);
307
+ ctx.beginPath();
308
+ ctx.arc(x, y, r, 0, Math.PI * 2);
309
+ ctx.stroke();
310
+ });
311
+
312
+ // Draw petals
313
+ metrics.forEach((metric, i) => {
314
+ const rawValue = Number(properties[metric.key] ?? 0);
315
+ const normalized = metric.normalize(rawValue);
316
+ const petalLength = 2 + normalized * (radius - centerRadius - 2);
317
+
318
+ const startAngle = angleStep * i - Math.PI / 2 - angleStep / 2 + 0.08;
319
+ const endAngle = angleStep * i - Math.PI / 2 + angleStep / 2 - 0.08;
320
+
321
+ // Petal shape
322
+ ctx.save();
323
+ ctx.fillStyle = metric.color;
324
+ ctx.globalAlpha = 0.8;
325
+ ctx.beginPath();
326
+ ctx.moveTo(x + Math.cos(startAngle) * centerRadius, y + Math.sin(startAngle) * centerRadius);
327
+ ctx.arc(x, y, centerRadius + petalLength, startAngle, endAngle);
328
+ ctx.lineTo(x + Math.cos(endAngle) * centerRadius, y + Math.sin(endAngle) * centerRadius);
329
+ ctx.closePath();
330
+ ctx.fill();
331
+
332
+ // Highlighting edge
333
+ ctx.strokeStyle = "rgba(255, 255, 255, 0.5)";
334
+ ctx.lineWidth = 1;
335
+ ctx.stroke();
336
+ ctx.restore();
337
+ });
338
+
339
+ // Draw center circle with glow
340
+ ctx.save();
341
+ ctx.shadowBlur = 6;
342
+ ctx.shadowColor = "rgba(255, 255, 255, 0.8)";
343
+ ctx.fillStyle = "#fff";
344
+ ctx.beginPath();
345
+ ctx.arc(x, y, centerRadius, 0, Math.PI * 2);
346
+ ctx.fill();
347
+ ctx.restore();
348
+
349
+ // if a comparison glyph is selected, draw its outline
350
+ if (typeof comparisonOutlineDraw === "function") {
351
+ comparisonOutlineDraw(ctx, x, y, size);
352
+ }
353
+ },
354
+ };
355
+ }
356
+
357
+ function normalizeLiteracyRecords(records) {
358
+ const numericKeys = new Set(metrics.map((metric) => metric.key));
359
+
360
+ // First pass: find min/max for each metric
361
+ metrics.forEach(metric => {
362
+ const values = records.map(r => Number(r[metric.key])).filter(v => !isNaN(v));
363
+ const min = Math.min(...values);
364
+ const max = Math.max(...values);
365
+
366
+ // We want a bit of padding so the smallest isn't zero length
367
+ metric.normalize = (val) => {
368
+ const v = Number(val);
369
+ if (isNaN(v)) return 0;
370
+ if (max === min) return 0.5;
371
+ return clamp((v - min) / (max - min));
372
+ };
373
+ });
374
+
375
+ return records.map((record) => {
376
+ const normalized = { ...record };
377
+ numericKeys.forEach((key) => {
378
+ if (key in normalized) {
379
+ const value = Number(normalized[key]);
380
+ normalized[key] = Number.isFinite(value) ? value : 0;
381
+ }
382
+ });
383
+ return normalized;
384
+ });
385
+ }
386
+
387
+ async function bootstrap() {
388
+ if (hasBootstrapped) {
389
+ return;
390
+ }
391
+ hasBootstrapped = true;
392
+
393
+ try {
394
+ if (statusEl) {
395
+ statusEl.textContent = "Loading data…";
396
+ }
397
+
398
+ const [regularGeoJSON, cartogramCSV, literacyCSV] = await Promise.all([
399
+ fetchJSON("indonesia/indonesia_provice_boundary.geojson"),
400
+ fetchText("indonesia/indonesia-grid.csv"),
401
+ fetchText("indonesia/literasi_2024.csv"),
402
+ ]);
403
+
404
+ const literacyRecords = normalizeLiteracyRecords(parseCSV(literacyCSV));
405
+
406
+ const aggregations = metrics.reduce((acc, metric) => {
407
+ acc[metric.key] = "mean";
408
+ return acc;
409
+ }, {});
410
+
411
+ const morpher = new GeoMorpher({
412
+ regularGeoJSON,
413
+ cartogramGeoJSON: cartogramCSV,
414
+ data: literacyRecords,
415
+ joinColumn: "ID",
416
+ geoJSONJoinColumn: "id",
417
+ aggregations,
418
+ normalize: false,
419
+ projection: WGS84Projection,
420
+ cartogramGridOptions: {
421
+ idField: "ID",
422
+ rowField: "row",
423
+ colField: "col",
424
+ cellPadding: 0.08,
425
+ rowOrientation: "top",
426
+ colOrientation: "left",
427
+ },
428
+ });
429
+
430
+ await morpher.prepare();
431
+
432
+ const initialFactor = Number(slider.value);
433
+ factorValue.textContent = initialFactor.toFixed(2);
434
+
435
+ buildLegend();
436
+
437
+ const map = new maplibregl.Map({
438
+ container: "map",
439
+ style: BASE_STYLE,
440
+ center: [117.5, -2.5],
441
+ zoom: 4.2,
442
+ attributionControl: false,
443
+ });
444
+
445
+ map.addControl(new maplibregl.NavigationControl(), "top-right");
446
+ map.addControl(new maplibregl.AttributionControl({ compact: true }), "bottom-left");
447
+
448
+ const bounds = computeBounds(morpher.getRegularFeatureCollection());
449
+
450
+ map.on("load", async () => {
451
+ const morphControls = await createMapLibreMorphLayers({
452
+ morpher,
453
+ map,
454
+ morphFactor: initialFactor,
455
+ idBase: "geomorpher-indonesia",
456
+ regularStyle: {
457
+ paint: {
458
+ "fill-color": "#1e293b",
459
+ "fill-opacity": 0.4,
460
+ "fill-outline-color": "rgba(255,255,255,0.2)",
461
+ },
462
+ },
463
+ cartogramStyle: {
464
+ paint: {
465
+ "fill-color": "#1e293b",
466
+ "fill-opacity": 0.4,
467
+ "fill-outline-color": "rgba(255,255,255,0.2)",
468
+ },
469
+ },
470
+ interpolatedStyle: {
471
+ paint: {
472
+ "fill-color": [
473
+ "case",
474
+ ["boolean", ["feature-state", "hover"], false],
475
+ "#60a5fa",
476
+ "#3b82f6"
477
+ ],
478
+ "fill-opacity": [
479
+ "case",
480
+ ["boolean", ["feature-state", "hover"], false],
481
+ 0.4,
482
+ 0.15
483
+ ],
484
+ "fill-outline-color": [
485
+ "case",
486
+ ["boolean", ["feature-state", "hover"], false],
487
+ "#ffffff",
488
+ "rgba(255, 255, 255, 0.4)"
489
+ ],
490
+ },
491
+ },
492
+ });
493
+
494
+ // Update OSM opacity based on initial factor
495
+ map.setPaintProperty("osm", "raster-opacity", 0.2 * (1 - initialFactor));
496
+
497
+ let hoveredStateId = null;
498
+
499
+ // Add province labels layer
500
+ map.addSource("province-labels", {
501
+ type: "geojson",
502
+ data: morpher.getInterpolatedFeatureCollection(initialFactor),
503
+ });
504
+
505
+ map.addLayer({
506
+ id: "province-labels-layer",
507
+ type: "symbol",
508
+ source: "province-labels",
509
+ layout: {
510
+ "text-field": ["get", "PROVINSI"],
511
+ "text-font": ["Open Sans Regular"],
512
+ "text-size": 10,
513
+ "text-offset": [0, 2.5],
514
+ "text-anchor": "top",
515
+ "text-transform": "uppercase",
516
+ "text-letter-spacing": 0.1,
517
+ },
518
+ paint: {
519
+ "text-color": "#94a3b8",
520
+ "text-halo-color": "rgba(2, 6, 23, 0.8)",
521
+ "text-halo-width": 1,
522
+ },
523
+ });
524
+
525
+ glyphControls = await createMapLibreCustomGlyphLayer({
526
+ morpher,
527
+ map,
528
+ morphFactor: initialFactor,
529
+ geometry: "interpolated",
530
+ drawGlyph: ({ feature, featureId, data }) => {
531
+ if (!data) return null;
532
+ return createRoseChartGlyph({ data, feature });
533
+ },
534
+ glyphOptions: {
535
+ size: 72,
536
+ },
537
+ });
538
+
539
+ const applyLayerVisibility = () => {
540
+ const vis = {
541
+ regular: regularToggle ? regularToggle.checked : false,
542
+ cartogram: cartogramToggle ? cartogramToggle.checked : false,
543
+ interpolated: interpolatedToggle ? interpolatedToggle.checked : true,
544
+ };
545
+ morphControls.setLayerVisibility(vis);
546
+
547
+ map.setLayoutProperty(
548
+ "province-labels-layer",
549
+ "visibility",
550
+ vis.interpolated ? "visible" : "none"
551
+ );
552
+
553
+ if (glyphToggle && !glyphToggle.checked) {
554
+ glyphControls.clear();
555
+ } else {
556
+ glyphControls.updateGlyphs({ morphFactor: Number(slider.value) });
557
+ }
558
+ };
559
+
560
+ // Hover handling
561
+ const handleMouseMove = (e) => {
562
+ const features = map.queryRenderedFeatures(e.point, {
563
+ layers: [
564
+ morphControls.layerIds.interpolated,
565
+ morphControls.layerIds.regular,
566
+ morphControls.layerIds.cartogram
567
+ ].filter(id => map.getLayer(id))
568
+ });
569
+
570
+ if (features.length > 0) {
571
+ const feature = features[0];
572
+
573
+ if (hoveredStateId !== null && hoveredStateId !== undefined) {
574
+ map.setFeatureState(
575
+ { source: morphControls.sourceIds.interpolated, id: hoveredStateId },
576
+ { hover: false }
577
+ );
578
+ }
579
+ hoveredStateId = feature.id;
580
+ if (hoveredStateId !== null && hoveredStateId !== undefined) {
581
+ map.setFeatureState(
582
+ { source: morphControls.sourceIds.interpolated, id: hoveredStateId },
583
+ { hover: true }
584
+ );
585
+ }
586
+
587
+ // Use featureId or properties.id to join with morpher data
588
+ const id = feature.properties.id || feature.properties.ID;
589
+ const data = morpher.getKeyData()[id];
590
+
591
+ if (data && tooltipEl) {
592
+ const props = data.data.properties;
593
+ lastHoveredProps = props;
594
+ tooltipEl.innerHTML = buildTooltipHTML(props, selectedComparisonProperties);
595
+ tooltipEl.style.display = "block";
596
+ tooltipEl.style.left = `${e.originalEvent.pageX + 15}px`;
597
+ tooltipEl.style.top = `${e.originalEvent.pageY + 15}px`;
598
+ map.getCanvas().style.cursor = "pointer";
599
+ }
600
+ } else {
601
+ hideTooltip(map);
602
+ }
603
+ };
604
+
605
+ const hideTooltip = () => {
606
+ if (tooltipEl) {
607
+ tooltipEl.style.display = "none";
608
+ }
609
+ if (map?.getCanvas) {
610
+ map.getCanvas().style.cursor = "";
611
+ }
612
+ if (hoveredStateId !== null && hoveredStateId !== undefined) {
613
+ map.setFeatureState(
614
+ { source: morphControls.sourceIds.interpolated, id: hoveredStateId },
615
+ { hover: false }
616
+ );
617
+ hoveredStateId = null;
618
+ }
619
+ };
620
+
621
+ map.on("mousemove", handleMouseMove);
622
+ map.on("mouseleave", morphControls.layerIds.interpolated, hideTooltip);
623
+ map.on("movestart", hideTooltip);
624
+ map.on("zoomstart", hideTooltip);
625
+
626
+ // comparison mode click handler
627
+ map.on("click", (e) => {
628
+ const features = map.queryRenderedFeatures(e.point, {
629
+ layers: [
630
+ morphControls.layerIds.interpolated,
631
+ morphControls.layerIds.regular,
632
+ morphControls.layerIds.cartogram,
633
+ ].filter((id) => map.getLayer(id)),
634
+ });
635
+
636
+ if (features.length > 0) {
637
+ const feat = features[0];
638
+ const id = feat.properties.id || feat.properties.ID;
639
+ const data = morpher.getKeyData()[id];
640
+ if (data && data.data && data.data.properties) {
641
+ updateComparisonSelection(data.data.properties);
642
+ return;
643
+ }
644
+ }
645
+ // clicked outside any province -> clear selection
646
+ updateComparisonSelection(null);
647
+ });
648
+
649
+ applyLayerVisibility();
650
+
651
+ [regularToggle, cartogramToggle, interpolatedToggle, glyphToggle]
652
+ .filter(Boolean)
653
+ .forEach((input) => input.addEventListener("change", applyLayerVisibility));
654
+
655
+ if (bounds && typeof bounds.isEmpty === "function" && !bounds.isEmpty()) {
656
+ map.fitBounds(bounds, { padding: 48, linear: true, duration: 0 });
657
+ }
658
+
659
+ const updateUIForFactor = (value) => {
660
+ slider.value = value;
661
+ factorValue.textContent = value.toFixed(2);
662
+ morphControls.updateMorphFactor(value);
663
+
664
+ // Update OSM opacity
665
+ map.setPaintProperty("osm", "raster-opacity", 0.2 * (1 - value));
666
+
667
+ // Update labels
668
+ const source = map.getSource("province-labels");
669
+ if (source) {
670
+ source.setData(morpher.getInterpolatedFeatureCollection(value));
671
+ }
672
+
673
+ if (!glyphToggle || glyphToggle.checked) {
674
+ glyphControls.updateGlyphs({ morphFactor: value });
675
+ }
676
+ };
677
+
678
+ slider.addEventListener("input", (event) => {
679
+ const value = Number(event.target.value);
680
+ if (isPlaying) {
681
+ stopAnimation();
682
+ }
683
+ updateUIForFactor(value);
684
+ });
685
+
686
+ let lastTimestamp = 0;
687
+ const animate = (timestamp) => {
688
+ if (!isPlaying) return;
689
+
690
+ if (!lastTimestamp) lastTimestamp = timestamp;
691
+ const elapsed = timestamp - lastTimestamp;
692
+ lastTimestamp = timestamp;
693
+
694
+ // Roughly 1.5 seconds for a full 0 to 1 transition
695
+ const speed = 0.0006;
696
+ let currentValue = Number(slider.value);
697
+
698
+ currentValue += elapsed * speed * animationDirection;
699
+
700
+ if (animationDirection === 1 && currentValue >= 1) {
701
+ currentValue = 1;
702
+ updateUIForFactor(currentValue);
703
+ animationDirection = -1; // Set next direction
704
+ stopAnimation();
705
+ return;
706
+ } else if (animationDirection === -1 && currentValue <= 0) {
707
+ currentValue = 0;
708
+ updateUIForFactor(currentValue);
709
+ animationDirection = 1; // Set next direction
710
+ stopAnimation();
711
+ return;
712
+ }
713
+
714
+ updateUIForFactor(currentValue);
715
+ animationFrameId = requestAnimationFrame(animate);
716
+ };
717
+
718
+ const startAnimation = () => {
719
+ if (isPlaying) return;
720
+
721
+ // Ensure starting point is correct for the current direction
722
+ // if user manually moved the slider
723
+ const currentValue = Number(slider.value);
724
+ if (animationDirection === 1 && currentValue >= 1) {
725
+ updateUIForFactor(0);
726
+ } else if (animationDirection === -1 && currentValue <= 0) {
727
+ updateUIForFactor(1);
728
+ }
729
+
730
+ isPlaying = true;
731
+ lastTimestamp = 0;
732
+ playIcon.style.display = "none";
733
+ pauseIcon.style.display = "block";
734
+ animationFrameId = requestAnimationFrame(animate);
735
+ };
736
+
737
+ const stopAnimation = () => {
738
+ isPlaying = false;
739
+ playIcon.style.display = "block";
740
+ pauseIcon.style.display = "none";
741
+ if (animationFrameId) {
742
+ cancelAnimationFrame(animationFrameId);
743
+ animationFrameId = null;
744
+ }
745
+ };
746
+
747
+ playButton.addEventListener("click", () => {
748
+ if (isPlaying) {
749
+ stopAnimation();
750
+ } else {
751
+ startAnimation();
752
+ }
753
+ });
754
+
755
+ if (statusEl) {
756
+ statusEl.textContent = "Ready";
757
+ }
758
+ });
759
+ } catch (error) {
760
+ console.error(error);
761
+ if (statusEl) {
762
+ statusEl.textContent = "Something went wrong";
763
+ }
764
+ hasBootstrapped = false;
765
+ }
766
+ }
767
+
768
+ bootstrap();
769
+
770
+