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
package/README.md
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
# geo-morpher
|
|
2
|
+
|
|
3
|
+
Imperative GeoJSON morphing utilities for animating between regular geography and cartograms, packaged as a native JavaScript library with a MapLibre-first adapter and optional Leaflet helpers for DOM-heavy experiences.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install geo-morpher
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Bring your own MapLibre or Leaflet instance (both listed as peer dependencies). MapLibre is the default rendering path and ships with GPU-accelerated layers; Leaflet stays available when you need direct DOM access.
|
|
15
|
+
|
|
16
|
+
### Choosing an adapter
|
|
17
|
+
|
|
18
|
+
- MapLibre adapter (`createMapLibreMorphLayers`, `createMapLibreGlyphLayer`) is the default. Use it for performance, style expression control, and WebGL-based effects.
|
|
19
|
+
- Leaflet adapter (`createLeafletMorphLayers`, `createLeafletGlyphLayer`) remains supported for teams who want to manipulate markers, tooltips, or UI directly in the DOM.
|
|
20
|
+
- Both adapters share the same `GeoMorpher` core, so you can swap adapters without recalculating morph data.
|
|
21
|
+
- Examples under `examples/browser/` demonstrate both integrations; switch adapters by loading the corresponding entry point.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
Project structure highlights:
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
src/
|
|
29
|
+
core/ # GeoMorpher core engine
|
|
30
|
+
adapters/ # Integration helpers (Leaflet, etc.)
|
|
31
|
+
lib/ # Shared runtime utilities (OSGB projection)
|
|
32
|
+
utils/ # Data enrichment and projection helpers
|
|
33
|
+
data/ # Sample Oxford LSOA datasets
|
|
34
|
+
examples/ # Runnable native JS scripts
|
|
35
|
+
test/ # node:test coverage for core behaviours
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### MapLibre adapter
|
|
39
|
+
|
|
40
|
+
- `createMapLibreMorphLayers` provisions GeoJSON sources and fill layers for regular, cartogram, and interpolated geometries, exposing an `updateMorphFactor` helper to drive tweening from UI controls.
|
|
41
|
+
- `createMapLibreGlyphLayer` renders glyphs with `maplibregl.Marker` instances; enable `scaleWithZoom` to regenerate glyph markup as users zoom.
|
|
42
|
+
- Pass your MapLibre namespace explicitly (`maplibreNamespace: maplibregl`) when calling glyph helpers in module-bundled builds where `maplibregl` is not attached to `globalThis`.
|
|
43
|
+
- For heavy glyph scenes, consider upgrading to a [CustomLayerInterface](https://www.maplibre.org/maplibre-gl-js/docs/API/interfaces/CustomLayerInterface/) implementation that batches drawing on the GPU. The marker pipeline keeps the API simple while offering a documented migration path.
|
|
44
|
+
- Track ongoing enhancements and open items in `docs/maplibre-migration-plan.md` before adopting advanced features like custom shader glyphs.
|
|
45
|
+
|
|
46
|
+
### Leaflet adapter
|
|
47
|
+
|
|
48
|
+
- `createLeafletMorphLayers` keeps the existing DOM-centric workflow for basemap blur/fade effects and marker overlays.
|
|
49
|
+
- `createLeafletGlyphLayer` accepts any HTML, SVG, Canvas, or Leaflet icon—handy when you need tooltips, popups, and other DOM components under full control.
|
|
50
|
+
- The basemap effect still uses DOM filters (`blur`, `opacity`, `grayscale`) and can target any Leaflet layer container.
|
|
51
|
+
- Stick with Leaflet when you require deep integration with existing Leaflet plugins or server-rendered markup; migrate to MapLibre later without touching your data preparation code.
|
|
52
|
+
|
|
53
|
+
#### MapLibre basemap effects
|
|
54
|
+
|
|
55
|
+
`createMapLibreMorphLayers` accepts a `basemapEffect` configuration that interpolates paint properties on existing style layers as the morph factor changes. This mirrors the Leaflet DOM-based blur/fade behaviour while staying inside the MapLibre style system.
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
const morph = await createMapLibreMorphLayers({
|
|
59
|
+
morpher,
|
|
60
|
+
map,
|
|
61
|
+
basemapEffect: {
|
|
62
|
+
layers: ["basemap", "basemap-labels"],
|
|
63
|
+
properties: {
|
|
64
|
+
"raster-opacity": [1, 0.15],
|
|
65
|
+
"raster-brightness-max": { from: 1, to: 1.4 },
|
|
66
|
+
},
|
|
67
|
+
propertyClamp: {
|
|
68
|
+
"raster-brightness-max": [0, 2],
|
|
69
|
+
},
|
|
70
|
+
easing: (t) => t * t, // optional easing curve
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Update morph factor, basemap effect adjusts automatically
|
|
75
|
+
morph.updateMorphFactor(0.75);
|
|
76
|
+
|
|
77
|
+
// Apply effect manually (e.g., when animating via requestAnimationFrame)
|
|
78
|
+
morph.applyBasemapEffect(0.5);
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- Provide a `layers` string/array or resolver function to target paint properties across multiple layers.
|
|
82
|
+
- Supply ranges (`[from, to]` or `{ from, to }`) for numeric properties such as `raster-opacity`, `fill-opacity`, or `line-opacity`.
|
|
83
|
+
- Use functions in `properties[layerId]` for full control or to manipulate non-numeric paint values.
|
|
84
|
+
- Capture-and-reset logic ensures properties revert to their original values when the effect is disabled.
|
|
85
|
+
- Canvas-style blur is not built-in; use a custom MapLibre layer if a true blur shader is required.
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
### 1. Prepare morphing data
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
import { GeoMorpher } from "geo-morpher";
|
|
92
|
+
import regularGeoJSON from "./data/oxford_lsoas_regular.json" assert { type: "json" };
|
|
93
|
+
import cartogramGeoJSON from "./data/oxford_lsoas_cartogram.json" assert { type: "json" };
|
|
94
|
+
|
|
95
|
+
const morpher = new GeoMorpher({
|
|
96
|
+
regularGeoJSON,
|
|
97
|
+
cartogramGeoJSON,
|
|
98
|
+
data: await fetchModelData(),
|
|
99
|
+
aggregations: {
|
|
100
|
+
population: "sum",
|
|
101
|
+
households: "sum",
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await morpher.prepare();
|
|
106
|
+
|
|
107
|
+
const regular = morpher.getRegularFeatureCollection();
|
|
108
|
+
const cartogram = morpher.getCartogramFeatureCollection();
|
|
109
|
+
const tween = morpher.getInterpolatedFeatureCollection(0.5);
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
#### Using custom projections
|
|
113
|
+
|
|
114
|
+
By default, `GeoMorpher` assumes input data is in **OSGB** (British National Grid) and converts to WGS84 for Leaflet. If your data is in a different coordinate system, pass a custom projection:
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
import { GeoMorpher, WGS84Projection, isLikelyWGS84 } from "geo-morpher";
|
|
118
|
+
|
|
119
|
+
// Auto-detect coordinate system
|
|
120
|
+
const detectedProjection = isLikelyWGS84(regularGeoJSON);
|
|
121
|
+
console.log("Detected:", detectedProjection); // 'WGS84', 'OSGB', or 'UNKNOWN'
|
|
122
|
+
|
|
123
|
+
// For data already in WGS84 (lat/lng)
|
|
124
|
+
const morpher = new GeoMorpher({
|
|
125
|
+
regularGeoJSON,
|
|
126
|
+
cartogramGeoJSON,
|
|
127
|
+
projection: WGS84Projection, // No transformation needed
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// For Web Mercator data
|
|
131
|
+
import { WebMercatorProjection } from "geo-morpher";
|
|
132
|
+
const morpher = new GeoMorpher({
|
|
133
|
+
regularGeoJSON,
|
|
134
|
+
cartogramGeoJSON,
|
|
135
|
+
projection: WebMercatorProjection,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Custom projection (e.g., using proj4)
|
|
139
|
+
const customProjection = {
|
|
140
|
+
toGeo: ([x, y]) => {
|
|
141
|
+
// Transform [x, y] to [lng, lat]
|
|
142
|
+
return [lng, lat];
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
See `examples/custom-projection.js` for detailed examples.
|
|
148
|
+
|
|
149
|
+
### 2. Drop the morph straight into Leaflet
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
import L from "leaflet";
|
|
153
|
+
import { createLeafletMorphLayers } from "geo-morpher";
|
|
154
|
+
|
|
155
|
+
const basemapLayer = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
156
|
+
attribution: "© OpenStreetMap contributors",
|
|
157
|
+
}).addTo(map);
|
|
158
|
+
|
|
159
|
+
let blurEnabled = true;
|
|
160
|
+
|
|
161
|
+
const {
|
|
162
|
+
group,
|
|
163
|
+
regularLayer,
|
|
164
|
+
cartogramLayer,
|
|
165
|
+
tweenLayer,
|
|
166
|
+
updateMorphFactor,
|
|
167
|
+
} = await createLeafletMorphLayers({
|
|
168
|
+
morpher,
|
|
169
|
+
L,
|
|
170
|
+
morphFactor: 0.25,
|
|
171
|
+
regularStyle: () => ({ color: "#1f77b4", weight: 1 }),
|
|
172
|
+
cartogramStyle: () => ({ color: "#ff7f0e", weight: 1 }),
|
|
173
|
+
tweenStyle: () => ({ color: "#2ca02c", weight: 2 }),
|
|
174
|
+
onEachFeature: (feature, layer) => {
|
|
175
|
+
layer.bindTooltip(`${feature.properties.code}`);
|
|
176
|
+
},
|
|
177
|
+
basemapLayer,
|
|
178
|
+
basemapEffect: {
|
|
179
|
+
blurRange: [0, 12],
|
|
180
|
+
opacityRange: [1, 0.05],
|
|
181
|
+
grayscaleRange: [0, 1],
|
|
182
|
+
isEnabled: () => blurEnabled,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
group.addTo(map);
|
|
187
|
+
|
|
188
|
+
// Update the tween geometry whenever you like
|
|
189
|
+
updateMorphFactor(0.75);
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Provide either `basemapLayer` (any Leaflet layer with a container) or `basemapEffect.target` to tell the helper which element to manipulate. By default the basemap will progressively blur and fade as the morph factor approaches 1, but you can adjust the ranges—or add brightness/grayscale tweaks—to match your design. You can also wire up UI to toggle the behaviour at runtime by returning `false` from `basemapEffect.isEnabled`.
|
|
193
|
+
|
|
194
|
+
### 3. Overlay multivariate glyphs
|
|
195
|
+
|
|
196
|
+
The glyph system is **completely customizable** with no hardcoded chart types. You provide a rendering function that can return any visualization you can create with HTML, SVG, Canvas, or third-party libraries like D3.js or Chart.js. The helper automatically keeps markers positioned and synchronized with the morphing geometry.
|
|
197
|
+
|
|
198
|
+
**Example with pie charts:**
|
|
199
|
+
|
|
200
|
+
```js
|
|
201
|
+
import {
|
|
202
|
+
GeoMorpher,
|
|
203
|
+
createLeafletMorphLayers,
|
|
204
|
+
createLeafletGlyphLayer,
|
|
205
|
+
} from "geo-morpher";
|
|
206
|
+
|
|
207
|
+
const categories = [
|
|
208
|
+
{ key: "population", color: "#4e79a7" },
|
|
209
|
+
{ key: "households", color: "#f28e2c" },
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
const drawPie = ({ data, feature }) => {
|
|
213
|
+
const properties = data?.data?.properties ?? feature.properties ?? {};
|
|
214
|
+
const slices = categories
|
|
215
|
+
.map(({ key, color }) => ({
|
|
216
|
+
value: Number(properties[key] ?? 0),
|
|
217
|
+
color,
|
|
218
|
+
}))
|
|
219
|
+
.filter((slice) => slice.value > 0);
|
|
220
|
+
|
|
221
|
+
if (slices.length === 0) return null;
|
|
222
|
+
|
|
223
|
+
const svg = buildPieSVG(slices); // your own renderer (D3, Canvas, vanilla SVG...)
|
|
224
|
+
return {
|
|
225
|
+
html: svg,
|
|
226
|
+
className: "pie-chart-marker",
|
|
227
|
+
iconSize: [52, 52],
|
|
228
|
+
iconAnchor: [26, 26],
|
|
229
|
+
};
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const glyphLayer = await createLeafletGlyphLayer({
|
|
233
|
+
morpher,
|
|
234
|
+
L,
|
|
235
|
+
map,
|
|
236
|
+
geometry: "interpolated",
|
|
237
|
+
morphFactor: 0.25,
|
|
238
|
+
pane: "glyphs",
|
|
239
|
+
drawGlyph: drawPie,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Keep glyphs synced with the tweened geometry
|
|
243
|
+
slider.addEventListener("input", (event) => {
|
|
244
|
+
const value = Number(event.target.value);
|
|
245
|
+
updateMorphFactor(value);
|
|
246
|
+
glyphLayer.updateGlyphs({ morphFactor: value });
|
|
247
|
+
});
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
`drawGlyph` receives `{ feature, featureId, data, morpher, geometry, morphFactor }` and can return:
|
|
251
|
+
|
|
252
|
+
- `null`/`undefined` to skip the feature
|
|
253
|
+
- A plain HTML string or DOM element
|
|
254
|
+
- An object with `html`, `iconSize`, `iconAnchor`, `className`, `pane`, and optional `markerOptions`
|
|
255
|
+
- Or an object containing a pre-built `icon` (any Leaflet `Icon`), if you need full control
|
|
256
|
+
|
|
257
|
+
**Configuration object properties:**
|
|
258
|
+
|
|
259
|
+
| Property | Type | Default | Description |
|
|
260
|
+
|----------|------|---------|-------------|
|
|
261
|
+
| `html` | string \| HTMLElement | - | Your custom HTML/SVG string or DOM element |
|
|
262
|
+
| `className` | string | `"geomorpher-glyph"` | CSS class for the marker |
|
|
263
|
+
| `iconSize` | [number, number] | `[48, 48]` | Width and height in pixels |
|
|
264
|
+
| `iconAnchor` | [number, number] | `[24, 24]` | Anchor point in pixels (center by default) |
|
|
265
|
+
| `pane` | string | - | Leaflet pane name for z-index control |
|
|
266
|
+
| `markerOptions` | object | `{}` | Additional Leaflet marker options |
|
|
267
|
+
| `divIconOptions` | object | `{}` | Additional Leaflet divIcon options |
|
|
268
|
+
| `icon` | L.Icon | - | Pre-built Leaflet icon (overrides all other options) |
|
|
269
|
+
|
|
270
|
+
Optionally provide `getGlyphData` or `filterFeature` callbacks to customise how data/visibility is resolved. When you call `glyphLayer.clear()` all markers are removed; `glyphLayer.getState()` exposes the current geometry, morph factor, and marker count.
|
|
271
|
+
|
|
272
|
+
#### Data contract for glyphs
|
|
273
|
+
|
|
274
|
+
By default `createLeafletGlyphLayer` will surface whatever the core `GeoMorpher` knows about the current feature via `morpher.getKeyData()`:
|
|
275
|
+
|
|
276
|
+
| field | type | description |
|
|
277
|
+
|--------------|----------|-------------|
|
|
278
|
+
| `feature` | GeoJSON Feature | The rendered feature taken from the requested geography (`regular`, `cartogram`, or tweened). Includes `feature.properties` and a `centroid` array. |
|
|
279
|
+
enum{} | Resolved via `getFeatureId(feature)` (defaults to `feature.properties.code ?? feature.properties.id`). |
|
|
280
|
+
| `featureId` | string | Resolved via `getFeatureId(feature)` (defaults to `feature.properties.code ?? feature.properties.id`). |
|
|
281
|
+
| `data` | object \| null | When using the built-in lookup this is the morpher key entry: `{ code, population, data }`. The `data` property holds the *enriched* GeoJSON feature returned from `GeoMorpher.prepare()`—handy when you stored additional indicators during enrichment. |
|
|
282
|
+
| `morpher` | `GeoMorpher` | The instance you passed in, allowing on-demand queries (`getInterpolatedLookup`, etc.). |
|
|
283
|
+
| `geometry` | string \| function | The geometry source currently in play (`regular`, `cartogram`, or `interpolated`). |
|
|
284
|
+
| `morphFactor`| number | The morph factor used for the last update (only meaningful when geometry is `interpolated`). |
|
|
285
|
+
|
|
286
|
+
If you want a different data shape, supply `getGlyphData`:
|
|
287
|
+
|
|
288
|
+
```js
|
|
289
|
+
const glyphLayer = await createLeafletGlyphLayer({
|
|
290
|
+
morpher,
|
|
291
|
+
L,
|
|
292
|
+
drawGlyph,
|
|
293
|
+
getGlyphData: ({ featureId }) => externalStatsById[featureId],
|
|
294
|
+
});
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
The callback receives the same context object (minus the final `data` field) and should return whatever payload your renderer expects. `filterFeature(context)` lets you drop glyphs entirely (return `false`) for a given feature.
|
|
298
|
+
|
|
299
|
+
#### Alternative chart types and rendering approaches
|
|
300
|
+
|
|
301
|
+
The glyph system accepts any HTML/SVG content. Here are examples with different visualization types:
|
|
302
|
+
|
|
303
|
+
**Bar chart:**
|
|
304
|
+
```js
|
|
305
|
+
drawGlyph: ({ data, feature }) => {
|
|
306
|
+
const values = [data.value1, data.value2, data.value3];
|
|
307
|
+
const bars = values.map((v, i) =>
|
|
308
|
+
`<rect x="${i*20}" y="${60-v}" width="15" height="${v}" fill="steelblue"/>`
|
|
309
|
+
).join('');
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
html: `<svg width="60" height="60">${bars}</svg>`,
|
|
313
|
+
iconSize: [60, 60],
|
|
314
|
+
iconAnchor: [30, 30],
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
**Using D3.js:**
|
|
320
|
+
```js
|
|
321
|
+
import * as d3 from "d3";
|
|
322
|
+
|
|
323
|
+
drawGlyph: ({ data }) => {
|
|
324
|
+
const div = document.createElement('div');
|
|
325
|
+
div.style.width = '80px';
|
|
326
|
+
div.style.height = '80px';
|
|
327
|
+
|
|
328
|
+
const svg = d3.select(div).append('svg')
|
|
329
|
+
.attr('width', 80)
|
|
330
|
+
.attr('height', 80);
|
|
331
|
+
|
|
332
|
+
// Use D3 to create any visualization
|
|
333
|
+
svg.selectAll('circle')
|
|
334
|
+
.data(data.values)
|
|
335
|
+
.enter().append('circle')
|
|
336
|
+
.attr('cx', (d, i) => i * 20 + 10)
|
|
337
|
+
.attr('cy', 40)
|
|
338
|
+
.attr('r', d => d.radius)
|
|
339
|
+
.attr('fill', d => d.color);
|
|
340
|
+
|
|
341
|
+
return div; // Return DOM element directly
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
**Custom icons or images:**
|
|
346
|
+
```js
|
|
347
|
+
drawGlyph: ({ data }) => {
|
|
348
|
+
return {
|
|
349
|
+
html: `<img src="/icons/${data.category}.png" width="32" height="32"/>`,
|
|
350
|
+
iconSize: [32, 32],
|
|
351
|
+
iconAnchor: [16, 16],
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
**Pre-built Leaflet icons:**
|
|
357
|
+
```js
|
|
358
|
+
drawGlyph: ({ data }) => {
|
|
359
|
+
const icon = L.icon({
|
|
360
|
+
iconUrl: `/markers/${data.type}.png`,
|
|
361
|
+
iconSize: [32, 32],
|
|
362
|
+
iconAnchor: [16, 32],
|
|
363
|
+
popupAnchor: [0, -32],
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
return { icon }; // Full control over Leaflet icon
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
**Sparkline with HTML Canvas:**
|
|
371
|
+
```js
|
|
372
|
+
drawGlyph: ({ data }) => {
|
|
373
|
+
const canvas = document.createElement('canvas');
|
|
374
|
+
canvas.width = 80;
|
|
375
|
+
canvas.height = 40;
|
|
376
|
+
const ctx = canvas.getContext('2d');
|
|
377
|
+
|
|
378
|
+
// Draw sparkline
|
|
379
|
+
ctx.strokeStyle = '#4e79a7';
|
|
380
|
+
ctx.lineWidth = 2;
|
|
381
|
+
ctx.beginPath();
|
|
382
|
+
data.timeSeries.forEach((value, i) => {
|
|
383
|
+
const x = (i / (data.timeSeries.length - 1)) * 80;
|
|
384
|
+
const y = 40 - (value * 40);
|
|
385
|
+
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
|
386
|
+
});
|
|
387
|
+
ctx.stroke();
|
|
388
|
+
|
|
389
|
+
return canvas.toDataURL(); // Return as data URL
|
|
390
|
+
}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
#### Zoom-scaling glyphs
|
|
394
|
+
|
|
395
|
+
By default, glyphs maintain a fixed pixel size regardless of map zoom level (standard Leaflet marker behavior). However, you can enable `scaleWithZoom` to make glyphs resize proportionally with the underlying map features—ideal for waffle charts, heatmap cells, or other visualizations that should fill polygon bounds.
|
|
396
|
+
|
|
397
|
+
```js
|
|
398
|
+
const glyphLayer = await createLeafletGlyphLayer({
|
|
399
|
+
morpher,
|
|
400
|
+
L,
|
|
401
|
+
map,
|
|
402
|
+
scaleWithZoom: true, // Enable zoom-responsive sizing
|
|
403
|
+
drawGlyph: ({ data, feature, featureBounds, zoom }) => {
|
|
404
|
+
if (!featureBounds) return null;
|
|
405
|
+
|
|
406
|
+
const { width, height } = featureBounds; // Pixel dimensions at current zoom
|
|
407
|
+
|
|
408
|
+
// Create waffle chart that fills the cartogram polygon
|
|
409
|
+
const gridSize = 10;
|
|
410
|
+
const cellSize = Math.min(width, height) / gridSize;
|
|
411
|
+
const fillRatio = data.value / data.max;
|
|
412
|
+
const filledCells = Math.floor(gridSize * gridSize * fillRatio);
|
|
413
|
+
|
|
414
|
+
const cells = [];
|
|
415
|
+
for (let i = 0; i < gridSize; i++) {
|
|
416
|
+
for (let j = 0; j < gridSize; j++) {
|
|
417
|
+
const index = i * gridSize + j;
|
|
418
|
+
const filled = index < filledCells;
|
|
419
|
+
cells.push(
|
|
420
|
+
`<rect x="${j * cellSize}" y="${i * cellSize}"
|
|
421
|
+
width="${cellSize}" height="${cellSize}"
|
|
422
|
+
fill="${filled ? '#4e79a7' : '#e0e0e0'}"/>`
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
html: `<svg width="${width}" height="${height}">${cells.join('')}</svg>`,
|
|
429
|
+
iconSize: [width, height],
|
|
430
|
+
iconAnchor: [width / 2, height / 2],
|
|
431
|
+
};
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
When `scaleWithZoom` is enabled:
|
|
437
|
+
- `featureBounds` provides `{ width, height, center, bounds }` in pixels at the current zoom level
|
|
438
|
+
- `zoom` provides the current map zoom level
|
|
439
|
+
- Glyphs automatically update when users zoom in/out
|
|
440
|
+
- Call `glyphLayer.destroy()` to clean up zoom listeners when removing the layer
|
|
441
|
+
|
|
442
|
+
A complete example is available at `examples/browser/zoom-scaling-glyphs.html`.
|
|
443
|
+
|
|
444
|
+
### Legacy wrapper
|
|
445
|
+
|
|
446
|
+
If you previously relied on the `geoMorpher` factory from the Observable notebook, it is still available:
|
|
447
|
+
|
|
448
|
+
```js
|
|
449
|
+
import { geoMorpher } from "geo-morpher";
|
|
450
|
+
|
|
451
|
+
const result = await geoMorpher({
|
|
452
|
+
regularGeoJSON,
|
|
453
|
+
cartogramGeoJSON,
|
|
454
|
+
data,
|
|
455
|
+
aggregations,
|
|
456
|
+
morphFactor: 0.5,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
console.log(result.tweenLookup);
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
### Native JS example
|
|
463
|
+
|
|
464
|
+
A runnable script using the bundled Oxford datasets lives in `examples/native.js`:
|
|
465
|
+
|
|
466
|
+
```bash
|
|
467
|
+
node examples/native.js
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
It loads `data/oxford_lsoas_regular.json` and `data/oxford_lsoas_cartogram.json`, mirrors their population/household properties into a basic dataset, and prints counts plus a sample tweened feature—all without any bundlers or UI frameworks.
|
|
471
|
+
|
|
472
|
+
### Native browser examples (Leaflet & MapLibre)
|
|
473
|
+
|
|
474
|
+
Serve the browser demos to see geo-morpher running on top of either Leaflet or MapLibre without a build step. Dependencies are resolved via import maps to CDN-hosted ES modules.
|
|
475
|
+
|
|
476
|
+
```bash
|
|
477
|
+
npm run examples:browser
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
Then open:
|
|
481
|
+
- Leaflet demo: <http://localhost:4173/examples/browser/index.html>
|
|
482
|
+
- MapLibre demo: <http://localhost:4173/examples/browser/maplibre/index.html>
|
|
483
|
+
|
|
484
|
+
Each demo provides a morph slider and glyph overlays; the MapLibre version showcases GPU-driven rendering, paint-property basemap fading, and DOM marker glyphs running through the new adapter. (An internet connection is required to fetch CDN-hosted modules and map tiles.)
|
|
485
|
+
|
|
486
|
+
**Additional examples:**
|
|
487
|
+
- `examples/browser/maplibre/index.html` - MapLibre adaptation with basemap paint-property effects and layer toggles
|
|
488
|
+
- `examples/browser/zoom-scaling-glyphs.html` - Demonstrates zoom-responsive waffle charts that resize to fill cartogram polygons as you zoom in/out
|
|
489
|
+
|
|
490
|
+
## Testing
|
|
491
|
+
|
|
492
|
+
Run the bundled smoke tests with:
|
|
493
|
+
|
|
494
|
+
```bash
|
|
495
|
+
npm test
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
## Build & publish
|
|
499
|
+
|
|
500
|
+
- `npm run build` compiles ESM and CJS bundles to `dist/` via Rollup.
|
|
501
|
+
- `npm run clean` removes the generated `dist/` folder prior to fresh builds.
|
|
502
|
+
- Publishing runs `npm run build` automatically through the `prepare` hook; verify outputs before executing `npm publish`.
|
|
503
|
+
|
|
504
|
+
## Roadmap & pending updates
|
|
505
|
+
|
|
506
|
+
- GPU-batched glyph rendering via MapLibre `CustomLayerInterface` remains on the roadmap for high-volume scenes—follow progress in `docs/maplibre-migration-plan.md`.
|
|
507
|
+
- Adapter-level automated tests (Jest + MapLibre mocks) are planned to complement the current core-only test suite.
|
|
508
|
+
- Post-processing basemap effects (true blur, grayscale for vector tiles) are being explored as custom layers; today, stick to opacity/brightness tweaks via `basemapEffect`.
|
|
509
|
+
- Globe projection support will land after the MapLibre adapter stabilizes on the current release line; expect a follow-up example once the API settles.
|
|
510
|
+
|
|
511
|
+
## License
|
|
512
|
+
|
|
513
|
+
MIT
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
ID,Provinsi,Initials,row,col
|
|
2
|
+
11,Aceh,ACE,1,1
|
|
3
|
+
51,Bali,BAL,7,7
|
|
4
|
+
36,Banten,BAN,6,3
|
|
5
|
+
17,Bengkulu,BEN,4,1
|
|
6
|
+
34,Daerah Istimewa Yogyakarta,DIY,7,5
|
|
7
|
+
31,Daerah Khusus Ibukota Jakarta,JKT,6,4
|
|
8
|
+
75,Gorontalo,GOR,2,8
|
|
9
|
+
15,Jambi,JAM,3,2
|
|
10
|
+
32,Jawa Barat,JBR,7,4
|
|
11
|
+
33,Jawa Tengah,JTG,6,5
|
|
12
|
+
35,Jawa Timur,JTM,6,6
|
|
13
|
+
61,Kalimantan Barat,KBA,3,4
|
|
14
|
+
63,Kalimantan Selatan,KSL,4,5
|
|
15
|
+
62,Kalimantan Tengah,KTG,4,4
|
|
16
|
+
64,Kalimantan Timur,KTI,3,5
|
|
17
|
+
65,Kalimantan Utara,KUT,2,5
|
|
18
|
+
19,Kepulauan Bangka Belitung,BKB,4,2
|
|
19
|
+
21,Kepulauan Riau,KEP,2,3
|
|
20
|
+
18,Lampung,LAM,5,2
|
|
21
|
+
81,Maluku,MAL,5,10
|
|
22
|
+
82,Maluku Utara,MUT,4,10
|
|
23
|
+
52,Nusa Tenggara Barat,NTB,7,8
|
|
24
|
+
53,Nusa Tenggara Timur,NTT,7,9
|
|
25
|
+
91-A,Papua,PAP,4,13
|
|
26
|
+
92-A,Papua Barat,PAB,4,12
|
|
27
|
+
92-B,Papua Barat Daya,PBD,3,12
|
|
28
|
+
91-B,Papua Pegunungan,PPG,5,13
|
|
29
|
+
91-C,Papua Selatan,PSL,6,13
|
|
30
|
+
91-D,Papua Tengah,PTG,5,12
|
|
31
|
+
14,Riau,RIA,2,2
|
|
32
|
+
76,Sulawesi Barat,SBR,4,7
|
|
33
|
+
73,Sulawesi Selatan,SSL,5,7
|
|
34
|
+
72,Sulawesi Tengah,STN,3,8
|
|
35
|
+
74,Sulawesi Tenggara,STG,4,8
|
|
36
|
+
71,Sulawesi Utara,SUT,2,9
|
|
37
|
+
13,Sumatera Barat,SBP,3,1
|
|
38
|
+
16,Sumatera Selatan,SSE,5,1
|
|
39
|
+
12,Sumatera Utara,SUM,2,1
|