geo-morpher 0.1.0
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 +513 -0
- package/data/indonesia/indonesia-grid.csv +39 -0
- package/data/indonesia/indonesia_provice_boundary.geojson +40 -0
- package/data/indonesia/indonesia_provice_boundary.geojson_old +45 -0
- package/data/indonesia/literasi_2024.csv +39 -0
- package/data/oxford_lsoas_cartogram.json +2744 -0
- package/data/oxford_lsoas_regular.json +4715 -0
- package/dist/index.cjs +3304 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +3271 -0
- package/dist/index.js.map +1 -0
- package/examples/browser/README.md +189 -0
- package/examples/browser/index.html +260 -0
- package/examples/browser/indonesia/index.html +262 -0
- package/examples/browser/indonesia/main.js +400 -0
- package/examples/browser/main.js +225 -0
- package/examples/browser/maplibre/index.html +283 -0
- package/examples/browser/maplibre/main.js +339 -0
- package/examples/browser/zoom-scaling-glyphs.html +257 -0
- package/examples/browser/zoom-scaling-glyphs.js +281 -0
- package/examples/custom-projection.js +236 -0
- package/examples/native.js +52 -0
- package/morphs.js +1 -0
- package/package.json +87 -0
- package/src/adapters/leaflet/glyphLayer.js +282 -0
- package/src/adapters/leaflet/index.js +16 -0
- package/src/adapters/leaflet/morphLayers.js +231 -0
- package/src/adapters/leaflet/utils/collections.js +41 -0
- package/src/adapters/leaflet/utils/coordinates.js +64 -0
- package/src/adapters/leaflet/utils/glyphNormalizer.js +94 -0
- package/src/adapters/leaflet.js +9 -0
- package/src/adapters/maplibre/glyphLayer.js +279 -0
- package/src/adapters/maplibre/index.js +8 -0
- package/src/adapters/maplibre/morphLayers.js +460 -0
- package/src/adapters/maplibre/utils/coordinates.js +134 -0
- package/src/adapters/maplibre/utils/customGlyphLayer.js +0 -0
- package/src/adapters/maplibre/utils/glyphNormalizer.js +136 -0
- package/src/adapters/maplibre.js +6 -0
- package/src/core/geomorpher.js +474 -0
- package/src/index.js +38 -0
- package/src/lib/osgb/ellipsoid.js +82 -0
- package/src/lib/osgb/index.js +192 -0
- package/src/utils/cartogram.js +295 -0
- package/src/utils/csv.js +107 -0
- package/src/utils/enrichment.js +167 -0
- package/src/utils/projection.js +65 -0
- package/src/utils/projections.js +90 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import L from "npm:leaflet";
|
|
2
|
+
import {
|
|
3
|
+
GeoMorpher,
|
|
4
|
+
createLeafletMorphLayers,
|
|
5
|
+
createLeafletGlyphLayer,
|
|
6
|
+
} from "../../src/index.js";
|
|
7
|
+
|
|
8
|
+
const formatStat = (value) => value.toLocaleString(undefined, {
|
|
9
|
+
maximumFractionDigits: 0,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const statusEl = document.getElementById("status");
|
|
13
|
+
const slider = document.getElementById("morphFactor");
|
|
14
|
+
const factorValue = document.getElementById("factorValue");
|
|
15
|
+
const regularCountEl = document.getElementById("count-regular");
|
|
16
|
+
const cartogramCountEl = document.getElementById("count-cartogram");
|
|
17
|
+
const basemapToggle = document.getElementById("basemapBlurToggle");
|
|
18
|
+
const glyphLegendEl = document.getElementById("glyphLegend");
|
|
19
|
+
|
|
20
|
+
async function fetchJSON(fileName) {
|
|
21
|
+
const url = new URL(`../../data/${fileName}`, import.meta.url);
|
|
22
|
+
const response = await fetch(url);
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
throw new Error(`Failed to fetch ${fileName}: ${response.status}`);
|
|
25
|
+
}
|
|
26
|
+
return response.json();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function bootstrap() {
|
|
30
|
+
try {
|
|
31
|
+
statusEl.textContent = "Loading data…";
|
|
32
|
+
|
|
33
|
+
const [regularGeoJSON, cartogramGeoJSON] = await Promise.all([
|
|
34
|
+
fetchJSON("oxford_lsoas_regular.json"),
|
|
35
|
+
fetchJSON("oxford_lsoas_cartogram.json"),
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const aggregations = {
|
|
39
|
+
population: "sum",
|
|
40
|
+
households: "sum",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const sampleData = regularGeoJSON.features.map((feature) => ({
|
|
44
|
+
lsoa: feature.properties.code,
|
|
45
|
+
population: Number(feature.properties.population ?? 0),
|
|
46
|
+
households: Number(feature.properties.households ?? 0),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
const morpher = new GeoMorpher({
|
|
50
|
+
regularGeoJSON,
|
|
51
|
+
cartogramGeoJSON,
|
|
52
|
+
data: sampleData,
|
|
53
|
+
aggregations,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await morpher.prepare();
|
|
57
|
+
|
|
58
|
+
const map = L.map("map", { preferCanvas: true });
|
|
59
|
+
map.setView([51.752, -1.2577], 12);
|
|
60
|
+
|
|
61
|
+
const glyphPane = map.createPane("glyphs");
|
|
62
|
+
glyphPane.style.zIndex = 650;
|
|
63
|
+
glyphPane.style.pointerEvents = "none";
|
|
64
|
+
|
|
65
|
+
const basemapLayer = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
66
|
+
maxZoom: 18,
|
|
67
|
+
attribution:
|
|
68
|
+
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
69
|
+
}).addTo(map);
|
|
70
|
+
|
|
71
|
+
const initialFactor = Number(slider.value);
|
|
72
|
+
factorValue.textContent = initialFactor.toFixed(2);
|
|
73
|
+
let currentMorphFactor = initialFactor;
|
|
74
|
+
let basemapEffectEnabled = basemapToggle ? basemapToggle.checked : true;
|
|
75
|
+
|
|
76
|
+
const categories = [
|
|
77
|
+
{ key: "population", label: "Population", color: "#4e79a7" },
|
|
78
|
+
{ key: "households", label: "Households", color: "#f28e2c" },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
if (glyphLegendEl) {
|
|
82
|
+
glyphLegendEl.innerHTML = "";
|
|
83
|
+
for (const { key, label, color } of categories) {
|
|
84
|
+
const item = document.createElement("li");
|
|
85
|
+
item.className = "legend-item";
|
|
86
|
+
item.innerHTML = `
|
|
87
|
+
<span class="legend-swatch" style="background: ${color}"></span>
|
|
88
|
+
<span>${label} <small style="color: #94a3b8; font-size: 0.8rem;">(${key})</small></span>
|
|
89
|
+
`;
|
|
90
|
+
glyphLegendEl.appendChild(item);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const createPieChartSVG = (slices, { size = 56, stroke = "white" } = {}) => {
|
|
95
|
+
const radius = size / 2;
|
|
96
|
+
const center = radius;
|
|
97
|
+
|
|
98
|
+
const total = slices.reduce((sum, slice) => sum + Math.max(0, slice.value), 0);
|
|
99
|
+
if (!Number.isFinite(total) || total <= 0) {
|
|
100
|
+
return `<svg width="${size}" height="${size}"></svg>`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let currentAngle = -Math.PI / 2;
|
|
104
|
+
const segments = slices
|
|
105
|
+
.filter((slice) => Number.isFinite(slice.value) && slice.value > 0)
|
|
106
|
+
.map((slice) => {
|
|
107
|
+
const angle = (slice.value / total) * Math.PI * 2;
|
|
108
|
+
const endAngle = currentAngle + angle;
|
|
109
|
+
const largeArc = angle > Math.PI ? 1 : 0;
|
|
110
|
+
const startX = center + radius * Math.cos(currentAngle);
|
|
111
|
+
const startY = center + radius * Math.sin(currentAngle);
|
|
112
|
+
const endX = center + radius * Math.cos(endAngle);
|
|
113
|
+
const endY = center + radius * Math.sin(endAngle);
|
|
114
|
+
const path = [
|
|
115
|
+
`M ${center} ${center}`,
|
|
116
|
+
`L ${startX.toFixed(2)} ${startY.toFixed(2)}`,
|
|
117
|
+
`A ${radius} ${radius} 0 ${largeArc} 1 ${endX.toFixed(2)} ${endY.toFixed(2)}`,
|
|
118
|
+
"Z",
|
|
119
|
+
].join(" ");
|
|
120
|
+
currentAngle = endAngle;
|
|
121
|
+
return `<path d="${path}" fill="${slice.color}" stroke="${stroke}" stroke-width="1"></path>`;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const content = segments.join("");
|
|
125
|
+
return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">${content}</svg>`;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const { group, regularLayer, tweenLayer, cartogramLayer, updateMorphFactor } =
|
|
129
|
+
await createLeafletMorphLayers({
|
|
130
|
+
morpher,
|
|
131
|
+
L,
|
|
132
|
+
morphFactor: initialFactor,
|
|
133
|
+
regularStyle: () => ({ color: "#1f77b4", weight: 1, fillOpacity: 0.15 }),
|
|
134
|
+
cartogramStyle: () => ({ color: "#ff7f0e", weight: 1, fillOpacity: 0.15 }),
|
|
135
|
+
tweenStyle: () => ({ color: "#22c55e", weight: 2, fillOpacity: 0 }),
|
|
136
|
+
onEachFeature: (feature, layer) => {
|
|
137
|
+
layer.bindPopup(`LSOA ${feature.properties.code}`);
|
|
138
|
+
},
|
|
139
|
+
basemapLayer,
|
|
140
|
+
basemapEffect: {
|
|
141
|
+
blurRange: [0, 14],
|
|
142
|
+
opacityRange: [1, 0.05],
|
|
143
|
+
grayscaleRange: [0, 1],
|
|
144
|
+
isEnabled: () => basemapEffectEnabled,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
group.addTo(map);
|
|
149
|
+
|
|
150
|
+
const glyphControls = await createLeafletGlyphLayer({
|
|
151
|
+
morpher,
|
|
152
|
+
L,
|
|
153
|
+
map,
|
|
154
|
+
geometry: "interpolated",
|
|
155
|
+
morphFactor: initialFactor,
|
|
156
|
+
pane: "glyphs",
|
|
157
|
+
drawGlyph: ({ data, feature }) => {
|
|
158
|
+
const properties = data?.data?.properties ?? feature.properties ?? {};
|
|
159
|
+
const slices = categories
|
|
160
|
+
.map(({ key, color }) => ({
|
|
161
|
+
key,
|
|
162
|
+
color,
|
|
163
|
+
value: Number(properties?.[key] ?? 0),
|
|
164
|
+
}))
|
|
165
|
+
.filter((slice) => slice.value > 0);
|
|
166
|
+
|
|
167
|
+
if (slices.length === 0) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
html: createPieChartSVG(slices, { size: 52 }),
|
|
173
|
+
className: "pie-chart-marker",
|
|
174
|
+
iconSize: [52, 52],
|
|
175
|
+
iconAnchor: [26, 26],
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const glyphLayer = glyphControls.layer;
|
|
181
|
+
|
|
182
|
+
const overlays = {
|
|
183
|
+
"Regular geography": regularLayer,
|
|
184
|
+
"Tween morph": tweenLayer,
|
|
185
|
+
"Cartogram geography": cartogramLayer,
|
|
186
|
+
"Pie glyphs": glyphLayer,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
L.control.layers(null, overlays, {
|
|
190
|
+
collapsed: window.matchMedia("(max-width: 768px)").matches,
|
|
191
|
+
}).addTo(map);
|
|
192
|
+
|
|
193
|
+
map.fitBounds(regularLayer.getBounds(), { padding: [20, 20] });
|
|
194
|
+
|
|
195
|
+
regularCountEl.textContent = formatStat(
|
|
196
|
+
morpher.getRegularFeatureCollection().features.length
|
|
197
|
+
);
|
|
198
|
+
cartogramCountEl.textContent = formatStat(
|
|
199
|
+
morpher.getCartogramFeatureCollection().features.length
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
slider.addEventListener("input", (event) => {
|
|
203
|
+
const value = Number(event.target.value);
|
|
204
|
+
factorValue.textContent = value.toFixed(2);
|
|
205
|
+
currentMorphFactor = value;
|
|
206
|
+
updateMorphFactor(value);
|
|
207
|
+
glyphControls.updateGlyphs({ morphFactor: value });
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (basemapToggle) {
|
|
211
|
+
basemapToggle.addEventListener("change", (event) => {
|
|
212
|
+
basemapEffectEnabled = event.target.checked;
|
|
213
|
+
updateMorphFactor(currentMorphFactor);
|
|
214
|
+
glyphControls.updateGlyphs({ morphFactor: currentMorphFactor });
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
statusEl.textContent = "Ready";
|
|
219
|
+
} catch (error) {
|
|
220
|
+
console.error(error);
|
|
221
|
+
statusEl.textContent = "Something went wrong";
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
bootstrap();
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>GeoMorpher · MapLibre Demo</title>
|
|
8
|
+
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@5.8.0/dist/maplibre-gl.css" />
|
|
9
|
+
<style>
|
|
10
|
+
:root {
|
|
11
|
+
color-scheme: light;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
body {
|
|
15
|
+
margin: 0;
|
|
16
|
+
font-family: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
17
|
+
background: #f8fafc;
|
|
18
|
+
color: #1f2933;
|
|
19
|
+
min-height: 100vh;
|
|
20
|
+
display: flex;
|
|
21
|
+
flex-direction: column;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
header {
|
|
25
|
+
padding: 1rem 1.5rem;
|
|
26
|
+
background: white;
|
|
27
|
+
border-bottom: 1px solid #e2e8f0;
|
|
28
|
+
display: flex;
|
|
29
|
+
justify-content: space-between;
|
|
30
|
+
align-items: center;
|
|
31
|
+
gap: 1rem;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
header h1 {
|
|
35
|
+
margin: 0;
|
|
36
|
+
font-size: 1.25rem;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
main {
|
|
40
|
+
flex: 1;
|
|
41
|
+
display: grid;
|
|
42
|
+
gap: 1.5rem;
|
|
43
|
+
padding: 1.5rem;
|
|
44
|
+
grid-template-columns: minmax(260px, 340px) 1fr;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@media (max-width: 960px) {
|
|
48
|
+
main {
|
|
49
|
+
grid-template-columns: 1fr;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.panel {
|
|
54
|
+
background: white;
|
|
55
|
+
border-radius: 1rem;
|
|
56
|
+
box-shadow: 0 10px 40px rgba(15, 23, 42, 0.08);
|
|
57
|
+
padding: 1.5rem;
|
|
58
|
+
display: flex;
|
|
59
|
+
flex-direction: column;
|
|
60
|
+
gap: 1.25rem;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.panel h2 {
|
|
64
|
+
margin: 0;
|
|
65
|
+
font-size: 1.1rem;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.stat-group {
|
|
69
|
+
display: grid;
|
|
70
|
+
gap: 0.75rem;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.legend {
|
|
74
|
+
display: grid;
|
|
75
|
+
gap: 0.6rem;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.legend h3 {
|
|
79
|
+
margin: 0;
|
|
80
|
+
font-size: 0.95rem;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.legend-item {
|
|
84
|
+
display: flex;
|
|
85
|
+
align-items: center;
|
|
86
|
+
gap: 0.6rem;
|
|
87
|
+
font-size: 0.9rem;
|
|
88
|
+
color: #475569;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.legend-swatch {
|
|
92
|
+
width: 0.9rem;
|
|
93
|
+
height: 0.9rem;
|
|
94
|
+
border-radius: 999px;
|
|
95
|
+
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.15);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.stat {
|
|
99
|
+
display: flex;
|
|
100
|
+
justify-content: space-between;
|
|
101
|
+
font-size: 0.95rem;
|
|
102
|
+
color: #475569;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.stat strong {
|
|
106
|
+
color: #0f172a;
|
|
107
|
+
font-weight: 600;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.slider {
|
|
111
|
+
display: grid;
|
|
112
|
+
gap: 0.5rem;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.slider label {
|
|
116
|
+
font-weight: 600;
|
|
117
|
+
font-size: 0.95rem;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
input[type="range"] {
|
|
121
|
+
width: 100%;
|
|
122
|
+
accent-color: #4c1d95;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.toggle {
|
|
126
|
+
display: grid;
|
|
127
|
+
gap: 0.4rem;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.toggle label {
|
|
131
|
+
display: flex;
|
|
132
|
+
align-items: center;
|
|
133
|
+
gap: 0.5rem;
|
|
134
|
+
font-weight: 600;
|
|
135
|
+
font-size: 0.95rem;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.toggle input[type="checkbox"] {
|
|
139
|
+
accent-color: #4c1d95;
|
|
140
|
+
width: 1.2rem;
|
|
141
|
+
height: 1.2rem;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.toggle p {
|
|
145
|
+
margin: 0;
|
|
146
|
+
color: #64748b;
|
|
147
|
+
font-size: 0.85rem;
|
|
148
|
+
line-height: 1.4;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.layer-toggles {
|
|
152
|
+
display: grid;
|
|
153
|
+
gap: 0.35rem;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.layer-toggles label {
|
|
157
|
+
display: flex;
|
|
158
|
+
align-items: center;
|
|
159
|
+
gap: 0.5rem;
|
|
160
|
+
font-weight: 500;
|
|
161
|
+
font-size: 0.9rem;
|
|
162
|
+
color: #334155;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
code {
|
|
166
|
+
background: #f1f5f9;
|
|
167
|
+
border-radius: 0.375rem;
|
|
168
|
+
padding: 0.125rem 0.375rem;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#map {
|
|
172
|
+
width: 100%;
|
|
173
|
+
min-height: 70vh;
|
|
174
|
+
border-radius: 1rem;
|
|
175
|
+
border: 1px solid #e2e8f0;
|
|
176
|
+
overflow: hidden;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.pie-chart-marker {
|
|
180
|
+
display: flex;
|
|
181
|
+
align-items: center;
|
|
182
|
+
justify-content: center;
|
|
183
|
+
border-radius: 999px;
|
|
184
|
+
background: rgba(255, 255, 255, 0.75);
|
|
185
|
+
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.18);
|
|
186
|
+
pointer-events: none;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
footer {
|
|
190
|
+
padding: 0.75rem 1.5rem 1.5rem;
|
|
191
|
+
font-size: 0.85rem;
|
|
192
|
+
color: #64748b;
|
|
193
|
+
}
|
|
194
|
+
</style>
|
|
195
|
+
<script type="importmap">
|
|
196
|
+
{
|
|
197
|
+
"imports": {
|
|
198
|
+
"maplibre-gl": "https://esm.sh/maplibre-gl@5.8.0?bundle",
|
|
199
|
+
"@turf/turf": "https://esm.sh/@turf/turf@6.5.0?bundle",
|
|
200
|
+
"@turf/helpers": "https://esm.sh/@turf/helpers@6.5.0?bundle",
|
|
201
|
+
"flubber": "https://esm.sh/flubber@0.4.2?bundle",
|
|
202
|
+
"lodash/cloneDeep.js": "https://esm.sh/lodash@4.17.21/cloneDeep?bundle",
|
|
203
|
+
"lodash/keyBy.js": "https://esm.sh/lodash@4.17.21/keyBy?bundle",
|
|
204
|
+
"lodash/mapValues.js": "https://esm.sh/lodash@4.17.21/mapValues?bundle",
|
|
205
|
+
"lodash/isEmpty.js": "https://esm.sh/lodash@4.17.21/isEmpty?bundle"
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
</script>
|
|
209
|
+
</head>
|
|
210
|
+
|
|
211
|
+
<body>
|
|
212
|
+
<header>
|
|
213
|
+
<h1>GeoMorpher · MapLibre Demo</h1>
|
|
214
|
+
<span id="status">Loading…</span>
|
|
215
|
+
</header>
|
|
216
|
+
<main>
|
|
217
|
+
<section class="panel">
|
|
218
|
+
<div>
|
|
219
|
+
<h2>Controls</h2>
|
|
220
|
+
<p style="margin-top: 0.35rem; margin-bottom: 1rem; color: #64748b;">
|
|
221
|
+
Morph between the regular LSOA geometry and its population-weighted cartogram using MapLibre GL for
|
|
222
|
+
rendering.
|
|
223
|
+
</p>
|
|
224
|
+
</div>
|
|
225
|
+
<div class="slider">
|
|
226
|
+
<label for="morphFactor">
|
|
227
|
+
Morph factor: <strong id="factorValue">0.00</strong>
|
|
228
|
+
</label>
|
|
229
|
+
<input id="morphFactor" type="range" min="0" max="1" step="0.05" value="0" />
|
|
230
|
+
</div>
|
|
231
|
+
<div class="toggle">
|
|
232
|
+
<label for="basemapEffectToggle">
|
|
233
|
+
<input id="basemapEffectToggle" type="checkbox" checked />
|
|
234
|
+
Fade basemap in cartogram view
|
|
235
|
+
</label>
|
|
236
|
+
<p>
|
|
237
|
+
Adjusts raster opacity and brightness via MapLibre paint properties as the morph factor approaches
|
|
238
|
+
the cartogram.
|
|
239
|
+
</p>
|
|
240
|
+
</div>
|
|
241
|
+
<div>
|
|
242
|
+
<h3 style="margin: 0 0 0.35rem; font-size: 0.95rem;">Layer visibility</h3>
|
|
243
|
+
<div class="layer-toggles">
|
|
244
|
+
<label><input id="toggle-regular" type="checkbox" checked /> Regular geography</label>
|
|
245
|
+
<label><input id="toggle-interpolated" type="checkbox" checked /> Tween morph</label>
|
|
246
|
+
<label><input id="toggle-cartogram" type="checkbox" checked /> Cartogram geography</label>
|
|
247
|
+
<label><input id="toggle-glyphs" type="checkbox" checked /> Pie glyphs</label>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
<div class="legend">
|
|
251
|
+
<h3>Pie glyph categories</h3>
|
|
252
|
+
<ul id="glyphLegend" style="list-style: none; margin: 0; padding: 0; display: grid; gap: 0.5rem;"></ul>
|
|
253
|
+
<p style="margin: 0; font-size: 0.85rem; color: #64748b;">
|
|
254
|
+
Glyphs are rendered with <code>maplibregl.Marker</code> elements and follow the tweened geography.
|
|
255
|
+
</p>
|
|
256
|
+
</div>
|
|
257
|
+
<div class="stat-group">
|
|
258
|
+
<div class="stat">
|
|
259
|
+
<span>Regular features</span>
|
|
260
|
+
<strong id="count-regular">–</strong>
|
|
261
|
+
</div>
|
|
262
|
+
<div class="stat">
|
|
263
|
+
<span>Cartogram features</span>
|
|
264
|
+
<strong id="count-cartogram">–</strong>
|
|
265
|
+
</div>
|
|
266
|
+
<div class="stat">
|
|
267
|
+
<span>Aggregated properties</span>
|
|
268
|
+
<strong><code>population</code>, <code>households</code></strong>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
</section>
|
|
272
|
+
<section class="panel" style="padding: 0; overflow: hidden;">
|
|
273
|
+
<div id="map"></div>
|
|
274
|
+
</section>
|
|
275
|
+
</main>
|
|
276
|
+
<footer>
|
|
277
|
+
Data sourced from <code>data/oxford_lsoas_regular.json</code> and <code>data/oxford_lsoas_cartogram.json</code>.
|
|
278
|
+
Tiles by OpenStreetMap.
|
|
279
|
+
</footer>
|
|
280
|
+
<script type="module" src="./main.js"></script>
|
|
281
|
+
</body>
|
|
282
|
+
|
|
283
|
+
</html>
|