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 +1 -1
- package/examples/indonesia/index.html +2 -1
- package/examples/indonesia/main.js +4 -4
- package/package.json +1 -1
- package/src/index.js +2 -0
- package/examples/maplibre/indonesia/main.js +0 -735
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ GeoJSON morphing utilities for animating between regular geography and cartogram
|
|
|
8
8
|

|
|
9
9
|
|
|
10
10
|
> [!TIP]
|
|
11
|
-
> To quickly create a grid cartogram, check out
|
|
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
|
-
|
|
9
|
-
|
|
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(
|
|
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(
|
|
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
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: "© 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
|
-
|