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.
Files changed (47) hide show
  1. package/README.md +513 -0
  2. package/data/indonesia/indonesia-grid.csv +39 -0
  3. package/data/indonesia/indonesia_provice_boundary.geojson +40 -0
  4. package/data/indonesia/indonesia_provice_boundary.geojson_old +45 -0
  5. package/data/indonesia/literasi_2024.csv +39 -0
  6. package/data/oxford_lsoas_cartogram.json +2744 -0
  7. package/data/oxford_lsoas_regular.json +4715 -0
  8. package/dist/index.cjs +3304 -0
  9. package/dist/index.cjs.map +1 -0
  10. package/dist/index.js +3271 -0
  11. package/dist/index.js.map +1 -0
  12. package/examples/browser/README.md +189 -0
  13. package/examples/browser/index.html +260 -0
  14. package/examples/browser/indonesia/index.html +262 -0
  15. package/examples/browser/indonesia/main.js +400 -0
  16. package/examples/browser/main.js +225 -0
  17. package/examples/browser/maplibre/index.html +283 -0
  18. package/examples/browser/maplibre/main.js +339 -0
  19. package/examples/browser/zoom-scaling-glyphs.html +257 -0
  20. package/examples/browser/zoom-scaling-glyphs.js +281 -0
  21. package/examples/custom-projection.js +236 -0
  22. package/examples/native.js +52 -0
  23. package/morphs.js +1 -0
  24. package/package.json +87 -0
  25. package/src/adapters/leaflet/glyphLayer.js +282 -0
  26. package/src/adapters/leaflet/index.js +16 -0
  27. package/src/adapters/leaflet/morphLayers.js +231 -0
  28. package/src/adapters/leaflet/utils/collections.js +41 -0
  29. package/src/adapters/leaflet/utils/coordinates.js +64 -0
  30. package/src/adapters/leaflet/utils/glyphNormalizer.js +94 -0
  31. package/src/adapters/leaflet.js +9 -0
  32. package/src/adapters/maplibre/glyphLayer.js +279 -0
  33. package/src/adapters/maplibre/index.js +8 -0
  34. package/src/adapters/maplibre/morphLayers.js +460 -0
  35. package/src/adapters/maplibre/utils/coordinates.js +134 -0
  36. package/src/adapters/maplibre/utils/customGlyphLayer.js +0 -0
  37. package/src/adapters/maplibre/utils/glyphNormalizer.js +136 -0
  38. package/src/adapters/maplibre.js +6 -0
  39. package/src/core/geomorpher.js +474 -0
  40. package/src/index.js +38 -0
  41. package/src/lib/osgb/ellipsoid.js +82 -0
  42. package/src/lib/osgb/index.js +192 -0
  43. package/src/utils/cartogram.js +295 -0
  44. package/src/utils/csv.js +107 -0
  45. package/src/utils/enrichment.js +167 -0
  46. package/src/utils/projection.js +65 -0
  47. package/src/utils/projections.js +90 -0
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Glyph normalization utilities for MapLibre adapter
3
+ * @module adapters/maplibre/utils/glyphNormalizer
4
+ */
5
+
6
+ import { isHTMLElement } from "./coordinates.js";
7
+
8
+ export const DEFAULT_GLYPH_CLASS = "geomorpher-glyph";
9
+ export const DEFAULT_ICON_SIZE = [48, 48];
10
+ export const DEFAULT_ICON_ANCHOR = [24, 24];
11
+
12
+ const ensureElement = ({ html, element, className }) => {
13
+ if (isHTMLElement(element)) {
14
+ if (className) {
15
+ element.classList.add(className);
16
+ }
17
+ if (element.style) {
18
+ element.style.pointerEvents = element.style.pointerEvents || "none";
19
+ element.style.position = element.style.position || "absolute";
20
+ }
21
+ return element;
22
+ }
23
+
24
+ if (typeof document === "undefined") {
25
+ throw new Error("MapLibre glyph normalization requires a DOM environment");
26
+ }
27
+
28
+ const wrapper = document.createElement("div");
29
+ wrapper.className = className ?? DEFAULT_GLYPH_CLASS;
30
+ wrapper.style.position = "absolute";
31
+ wrapper.style.pointerEvents = "none";
32
+ if (typeof html === "string") {
33
+ wrapper.innerHTML = html;
34
+ }
35
+
36
+ return wrapper;
37
+ };
38
+
39
+ const applyIconSizing = (element, iconSize) => {
40
+ if (!iconSize || iconSize.length < 2 || !element?.style) return;
41
+ const [width, height] = iconSize;
42
+ if (Number.isFinite(width)) {
43
+ element.style.width = `${width}px`;
44
+ }
45
+ if (Number.isFinite(height)) {
46
+ element.style.height = `${height}px`;
47
+ }
48
+ };
49
+
50
+ const computeOffset = ({ iconSize, iconAnchor }) => {
51
+ if (Array.isArray(iconAnchor) && iconAnchor.length >= 2) {
52
+ const [x, y] = iconAnchor;
53
+ if (Number.isFinite(x) && Number.isFinite(y)) {
54
+ return [-x, -y];
55
+ }
56
+ }
57
+
58
+ if (Array.isArray(iconSize) && iconSize.length >= 2) {
59
+ const [width, height] = iconSize;
60
+ if (Number.isFinite(width) && Number.isFinite(height)) {
61
+ return [-width / 2, -height / 2];
62
+ }
63
+ }
64
+
65
+ return undefined;
66
+ };
67
+
68
+ /**
69
+ * Normalize drawGlyph results into MapLibre marker configuration.
70
+ *
71
+ * @param {Object} params
72
+ * @param {*} params.result - Raw result from drawGlyph callback
73
+ * @returns {Object|null} - Normalized glyph configuration or null
74
+ */
75
+ export function normalizeGlyphResult({ result }) {
76
+ if (result == null) {
77
+ return null;
78
+ }
79
+
80
+ if (isHTMLElement(result) || typeof result === "string") {
81
+ const element = ensureElement({ html: result, element: result });
82
+ applyIconSizing(element, DEFAULT_ICON_SIZE);
83
+ const offset = computeOffset({ iconSize: DEFAULT_ICON_SIZE });
84
+ return {
85
+ element,
86
+ markerOptions: {
87
+ offset,
88
+ },
89
+ };
90
+ }
91
+
92
+ if (typeof result === "object") {
93
+ if (result.element || isHTMLElement(result)) {
94
+ const element = ensureElement({
95
+ element: result.element ?? result,
96
+ className: result.className,
97
+ html: result.html,
98
+ });
99
+ applyIconSizing(element, result.iconSize ?? DEFAULT_ICON_SIZE);
100
+ const offset = computeOffset({
101
+ iconSize: result.iconSize,
102
+ iconAnchor: result.iconAnchor,
103
+ });
104
+
105
+ return {
106
+ element,
107
+ markerOptions: {
108
+ offset,
109
+ ...(result.markerOptions ?? {}),
110
+ },
111
+ };
112
+ }
113
+
114
+ if (result.html || result.className) {
115
+ const element = ensureElement({
116
+ html: result.html,
117
+ className: result.className ?? DEFAULT_GLYPH_CLASS,
118
+ });
119
+ applyIconSizing(element, result.iconSize ?? DEFAULT_ICON_SIZE);
120
+ const offset = computeOffset({
121
+ iconSize: result.iconSize,
122
+ iconAnchor: result.iconAnchor,
123
+ });
124
+
125
+ return {
126
+ element,
127
+ markerOptions: {
128
+ offset,
129
+ ...(result.markerOptions ?? {}),
130
+ },
131
+ };
132
+ }
133
+ }
134
+
135
+ return null;
136
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Temporary MapLibre adapter shim.
3
+ * Provides forward-compatible exports while implementation stabilises.
4
+ */
5
+ export { createMapLibreMorphLayers } from "./maplibre/morphLayers.js";
6
+ export { createMapLibreGlyphLayer } from "./maplibre/glyphLayer.js";
@@ -0,0 +1,474 @@
1
+ import cloneDeep from "lodash/cloneDeep.js";
2
+ import keyBy from "lodash/keyBy.js";
3
+ import mapValues from "lodash/mapValues.js";
4
+ import flubber from "flubber";
5
+ import * as turf from "@turf/turf";
6
+ import { enrichGeoData, createLookup } from "../utils/enrichment.js";
7
+ import { toWGS84FeatureCollection } from "../utils/projection.js";
8
+ import { normalizeCartogramInput } from "../utils/cartogram.js";
9
+
10
+ const clampFactor = (value) => {
11
+ if (!Number.isFinite(value)) return 0;
12
+ if (value <= 0) return 0;
13
+ if (value >= 1) return 1;
14
+ return value;
15
+ };
16
+
17
+ const RING_VISIBILITY_EPSILON = 1e-3;
18
+ const PLACEHOLDER_SCALE = 0.02;
19
+ const MIN_PLACEHOLDER_SIZE = 1e-4;
20
+
21
+ const ensureClosedRing = (ring) => {
22
+ if (!Array.isArray(ring) || ring.length === 0) return [];
23
+ const first = ring[0];
24
+ const last = ring[ring.length - 1];
25
+ if (!Array.isArray(first) || !Array.isArray(last)) return [];
26
+ if (first.length < 2 || last.length < 2) return [];
27
+ if (first[0] === last[0] && first[1] === last[1]) {
28
+ return ring.slice();
29
+ }
30
+ return [...ring, first];
31
+ };
32
+
33
+ const extractOuterRings = (geometry) => {
34
+ if (!geometry) return [];
35
+ if (geometry.type === "Polygon") {
36
+ const ring = geometry.coordinates?.[0];
37
+ return ring ? [ensureClosedRing(ring)] : [];
38
+ }
39
+ if (geometry.type === "MultiPolygon") {
40
+ const polygons = Array.isArray(geometry.coordinates) ? geometry.coordinates : [];
41
+ return polygons
42
+ .map((polygon) => (Array.isArray(polygon) && polygon[0] ? ensureClosedRing(polygon[0]) : null))
43
+ .filter((ring) => Array.isArray(ring) && ring.length >= 3);
44
+ }
45
+ return [];
46
+ };
47
+
48
+ const computeRingCentroid = (ring) => {
49
+ if (!Array.isArray(ring) || ring.length === 0) return [0, 0];
50
+ let sumX = 0;
51
+ let sumY = 0;
52
+ let count = 0;
53
+ for (const coordinate of ring) {
54
+ if (!Array.isArray(coordinate) || coordinate.length < 2) continue;
55
+ const [x, y] = coordinate;
56
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
57
+ sumX += x;
58
+ sumY += y;
59
+ count += 1;
60
+ }
61
+ if (count === 0) return [0, 0];
62
+ return [sumX / count, sumY / count];
63
+ };
64
+
65
+ const computeRingBounds = (ring) => {
66
+ let minX = Number.POSITIVE_INFINITY;
67
+ let minY = Number.POSITIVE_INFINITY;
68
+ let maxX = Number.NEGATIVE_INFINITY;
69
+ let maxY = Number.NEGATIVE_INFINITY;
70
+
71
+ for (const coordinate of ring ?? []) {
72
+ if (!Array.isArray(coordinate) || coordinate.length < 2) continue;
73
+ const [x, y] = coordinate;
74
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
75
+ if (x < minX) minX = x;
76
+ if (y < minY) minY = y;
77
+ if (x > maxX) maxX = x;
78
+ if (y > maxY) maxY = y;
79
+ }
80
+
81
+ if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
82
+ return { width: 0, height: 0 };
83
+ }
84
+
85
+ return {
86
+ width: Math.max(maxX - minX, 0),
87
+ height: Math.max(maxY - minY, 0),
88
+ };
89
+ };
90
+
91
+ const createPlaceholderRing = (referenceRing) => {
92
+ if (!Array.isArray(referenceRing) || referenceRing.length === 0) return null;
93
+ const centroid = computeRingCentroid(referenceRing);
94
+ const [cx, cy] = centroid;
95
+ if (!Number.isFinite(cx) || !Number.isFinite(cy)) return null;
96
+
97
+ const { width, height } = computeRingBounds(referenceRing);
98
+ const span = Math.max(width, height);
99
+ const offset = Math.max(span * PLACEHOLDER_SCALE, MIN_PLACEHOLDER_SIZE);
100
+
101
+ const ring = [
102
+ [cx - offset, cy - offset],
103
+ [cx + offset, cy - offset],
104
+ [cx + offset, cy + offset],
105
+ [cx - offset, cy + offset],
106
+ ];
107
+
108
+ return ensureClosedRing(ring);
109
+ };
110
+
111
+ const distanceSquared = (a, b) => {
112
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length < 2 || b.length < 2) return Number.POSITIVE_INFINITY;
113
+ const dx = a[0] - b[0];
114
+ const dy = a[1] - b[1];
115
+ return dx * dx + dy * dy;
116
+ };
117
+
118
+ const matchRingPairs = (fromRings, toRings) => {
119
+ const pairs = [];
120
+ const toPool = toRings.map((ring) => ({
121
+ ring,
122
+ centroid: computeRingCentroid(ring),
123
+ }));
124
+
125
+ for (const ring of fromRings) {
126
+ const centroid = computeRingCentroid(ring);
127
+ let bestIndex = -1;
128
+ let bestDistance = Number.POSITIVE_INFINITY;
129
+ for (let index = 0; index < toPool.length; index += 1) {
130
+ const candidate = toPool[index];
131
+ const candidateDistance = distanceSquared(centroid, candidate.centroid);
132
+ if (candidateDistance < bestDistance) {
133
+ bestDistance = candidateDistance;
134
+ bestIndex = index;
135
+ }
136
+ }
137
+
138
+ if (bestIndex >= 0) {
139
+ const [match] = toPool.splice(bestIndex, 1);
140
+ pairs.push({ fromRing: ring, toRing: match.ring });
141
+ } else {
142
+ pairs.push({ fromRing: ring, toRing: null });
143
+ }
144
+ }
145
+
146
+ for (const remaining of toPool) {
147
+ pairs.push({ fromRing: null, toRing: remaining.ring });
148
+ }
149
+
150
+ return pairs;
151
+ };
152
+
153
+ const createRingInterpolator = ({ fromRing, toRing }) => {
154
+ if (fromRing && toRing) {
155
+ const interpolator = flubber.interpolate(fromRing, toRing, { string: false });
156
+ return {
157
+ interpolate: (factor) => interpolator(factor),
158
+ isVisible: () => true,
159
+ };
160
+ }
161
+
162
+ if (fromRing && !toRing) {
163
+ const placeholder = createPlaceholderRing(fromRing);
164
+ if (!placeholder) {
165
+ const constantRing = ensureClosedRing(fromRing);
166
+ return {
167
+ interpolate: () => constantRing,
168
+ isVisible: (factor) => factor < 1 - RING_VISIBILITY_EPSILON,
169
+ };
170
+ }
171
+
172
+ const interpolator = flubber.interpolate(fromRing, placeholder, { string: false });
173
+ return {
174
+ interpolate: (factor) => interpolator(factor),
175
+ isVisible: (factor) => factor < 1 - RING_VISIBILITY_EPSILON,
176
+ };
177
+ }
178
+
179
+ if (!fromRing && toRing) {
180
+ const placeholder = createPlaceholderRing(toRing);
181
+ if (!placeholder) {
182
+ const constantRing = ensureClosedRing(toRing);
183
+ return {
184
+ interpolate: () => constantRing,
185
+ isVisible: (factor) => factor > RING_VISIBILITY_EPSILON,
186
+ };
187
+ }
188
+
189
+ const interpolator = flubber.interpolate(placeholder, toRing, { string: false });
190
+ return {
191
+ interpolate: (factor) => interpolator(factor),
192
+ isVisible: (factor) => factor > RING_VISIBILITY_EPSILON,
193
+ };
194
+ }
195
+
196
+ return null;
197
+ };
198
+
199
+ const createGeometryInterpolator = ({ fromGeometry, toGeometry }) => {
200
+ const fromRings = extractOuterRings(fromGeometry);
201
+ const toRings = extractOuterRings(toGeometry);
202
+
203
+ if (!fromRings.length && !toRings.length) {
204
+ return null;
205
+ }
206
+
207
+ const pairs = matchRingPairs(fromRings, toRings);
208
+ const ringInterpolators = pairs
209
+ .map((pair) => createRingInterpolator(pair))
210
+ .filter((entry) => entry && typeof entry.interpolate === "function");
211
+
212
+ if (!ringInterpolators.length) {
213
+ return null;
214
+ }
215
+
216
+ const geometryType =
217
+ ringInterpolators.length === 1 && fromGeometry?.type !== "MultiPolygon" && toGeometry?.type !== "MultiPolygon"
218
+ ? "Polygon"
219
+ : "MultiPolygon";
220
+
221
+ return {
222
+ type: geometryType,
223
+ interpolate: (rawFactor) => {
224
+ const factor = clampFactor(rawFactor);
225
+ const rings = [];
226
+
227
+ for (const entry of ringInterpolators) {
228
+ if (typeof entry.isVisible === "function" && !entry.isVisible(factor)) {
229
+ continue;
230
+ }
231
+
232
+ try {
233
+ rings.push(ensureClosedRing(entry.interpolate(factor)));
234
+ } catch (error) {
235
+ rings.push([]);
236
+ }
237
+ }
238
+
239
+ const filteredRings = rings.filter((ring) => Array.isArray(ring) && ring.length >= 4);
240
+ const effectiveRings = filteredRings.length ? filteredRings : rings;
241
+
242
+ if (geometryType === "Polygon") {
243
+ const ring = effectiveRings.find((candidate) => Array.isArray(candidate) && candidate.length >= 4) ?? [];
244
+ return [ring];
245
+ }
246
+
247
+ const multiRings = effectiveRings.filter((ring) => Array.isArray(ring) && ring.length >= 4);
248
+ const ringsToUse = multiRings.length ? multiRings : effectiveRings;
249
+ return ringsToUse.map((ring) => [ring]);
250
+ },
251
+ };
252
+ };
253
+
254
+ function withCentroid(feature) {
255
+ const centroid = turf.centroid(feature);
256
+ return {
257
+ ...feature,
258
+ centroid: turf.getCoord(centroid),
259
+ };
260
+ }
261
+
262
+ export class GeoMorpher {
263
+ constructor({
264
+ regularGeoJSON,
265
+ cartogramGeoJSON,
266
+ data = null,
267
+ getData = null,
268
+ joinColumn = "lsoa",
269
+ geoJSONJoinColumn = "code",
270
+ aggregations = {},
271
+ normalize = true,
272
+ projection = null,
273
+ cartogramGridOptions = {},
274
+ }) {
275
+ this.regularGeoJSON = regularGeoJSON;
276
+ this.cartogramGeoJSON = cartogramGeoJSON;
277
+ this.data = data;
278
+ this.getData = getData;
279
+ this.joinColumn = joinColumn;
280
+ this.geoJSONJoinColumn = geoJSONJoinColumn;
281
+ this.aggregations = aggregations;
282
+ this.normalize = normalize;
283
+ this.projection = projection;
284
+ this.cartogramGridOptions = cartogramGridOptions ?? {};
285
+
286
+ this._normalizedCartogramGeoJSON = null;
287
+
288
+ this.state = {
289
+ prepared: false,
290
+ regularEnriched: null,
291
+ cartogramEnriched: null,
292
+ regularWGS84: null,
293
+ cartogramWGS84: null,
294
+ geographyLookup: {},
295
+ cartogramLookup: {},
296
+ keyData: {},
297
+ interpolators: {},
298
+ };
299
+ }
300
+
301
+ ensureCartogramGeoJSON() {
302
+ if (this._normalizedCartogramGeoJSON) {
303
+ return this._normalizedCartogramGeoJSON;
304
+ }
305
+
306
+ this._normalizedCartogramGeoJSON = normalizeCartogramInput({
307
+ input: this.cartogramGeoJSON,
308
+ regularGeoJSON: this.regularGeoJSON,
309
+ joinProperty: this.geoJSONJoinColumn,
310
+ gridOptions: this.cartogramGridOptions,
311
+ });
312
+
313
+ return this._normalizedCartogramGeoJSON;
314
+ }
315
+
316
+ async prepare() {
317
+ const modelData = await this.loadData();
318
+
319
+ const regularEnriched = enrichGeoData({
320
+ data: modelData,
321
+ geojson: cloneDeep(this.regularGeoJSON),
322
+ joinColumn: this.joinColumn,
323
+ geoJSONJoinColumn: this.geoJSONJoinColumn,
324
+ aggregations: this.aggregations,
325
+ normalize: this.normalize,
326
+ });
327
+
328
+ const baseCartogramGeoJSON = this.ensureCartogramGeoJSON();
329
+
330
+ const cartogramEnriched = enrichGeoData({
331
+ data: modelData,
332
+ geojson: cloneDeep(baseCartogramGeoJSON),
333
+ joinColumn: this.joinColumn,
334
+ geoJSONJoinColumn: this.geoJSONJoinColumn,
335
+ aggregations: this.aggregations,
336
+ normalize: this.normalize,
337
+ });
338
+
339
+ const regularWGS84 = toWGS84FeatureCollection(regularEnriched, this.projection);
340
+ const cartogramWGS84 = toWGS84FeatureCollection(cartogramEnriched, this.projection);
341
+
342
+ const geographyLookup = createLookup(regularWGS84.features, (feature) =>
343
+ feature?.properties?.[this.geoJSONJoinColumn]
344
+ );
345
+ const cartogramLookup = createLookup(cartogramWGS84.features, (feature) =>
346
+ feature?.properties?.[this.geoJSONJoinColumn]
347
+ );
348
+
349
+ const keyData = keyBy(
350
+ regularEnriched.features.map((feature) => ({
351
+ code: feature.properties?.[this.geoJSONJoinColumn],
352
+ population: Number(feature.properties?.population ?? 0),
353
+ data: feature,
354
+ })),
355
+ "code"
356
+ );
357
+
358
+ const interpolators = {};
359
+ for (const [code, feature] of Object.entries(geographyLookup)) {
360
+ const cartogramFeature = cartogramLookup[code];
361
+ const geometryInterpolator = createGeometryInterpolator({
362
+ fromGeometry: feature?.geometry,
363
+ toGeometry: cartogramFeature?.geometry,
364
+ });
365
+
366
+ if (!geometryInterpolator) continue;
367
+
368
+ interpolators[code] = geometryInterpolator;
369
+ }
370
+
371
+ this.state = {
372
+ prepared: true,
373
+ regularEnriched,
374
+ cartogramEnriched,
375
+ regularWGS84: {
376
+ ...regularWGS84,
377
+ features: regularWGS84.features.map(withCentroid),
378
+ },
379
+ cartogramWGS84: {
380
+ ...cartogramWGS84,
381
+ features: cartogramWGS84.features.map(withCentroid),
382
+ },
383
+ geographyLookup: mapValues(geographyLookup, withCentroid),
384
+ cartogramLookup: mapValues(cartogramLookup, withCentroid),
385
+ keyData,
386
+ interpolators,
387
+ };
388
+
389
+ return this;
390
+ }
391
+
392
+ async loadData() {
393
+ if (Array.isArray(this.data)) return this.data;
394
+ if (typeof this.getData === "function") {
395
+ const result = await this.getData();
396
+ this.data = result;
397
+ return result;
398
+ }
399
+ return [];
400
+ }
401
+
402
+ isPrepared() {
403
+ return Boolean(this.state.prepared);
404
+ }
405
+
406
+ assertPrepared() {
407
+ if (!this.isPrepared()) {
408
+ throw new Error("GeoMorpher.prepare() must be called before accessing data");
409
+ }
410
+ }
411
+
412
+ getKeyData() {
413
+ this.assertPrepared();
414
+ return this.state.keyData;
415
+ }
416
+
417
+ getRegularFeatureCollection() {
418
+ this.assertPrepared();
419
+ return cloneDeep(this.state.regularWGS84);
420
+ }
421
+
422
+ getCartogramFeatureCollection() {
423
+ this.assertPrepared();
424
+ return cloneDeep(this.state.cartogramWGS84);
425
+ }
426
+
427
+ getGeographyLookup() {
428
+ this.assertPrepared();
429
+ return cloneDeep(this.state.geographyLookup);
430
+ }
431
+
432
+ getCartogramLookup() {
433
+ this.assertPrepared();
434
+ return cloneDeep(this.state.cartogramLookup);
435
+ }
436
+
437
+ getInterpolatedFeatureCollection(factor = 0.5) {
438
+ this.assertPrepared();
439
+ const features = [];
440
+
441
+ for (const [code, entry] of Object.entries(this.state.interpolators)) {
442
+ if (!entry || typeof entry.interpolate !== "function") continue;
443
+ const baseFeature = this.state.geographyLookup[code];
444
+ if (!baseFeature) continue;
445
+
446
+ const coordinates = entry.interpolate(factor);
447
+
448
+ if (!coordinates || !Array.isArray(coordinates) || !coordinates.length) continue;
449
+
450
+ const geometry = entry.type === "MultiPolygon"
451
+ ? { type: "MultiPolygon", coordinates }
452
+ : { type: "Polygon", coordinates };
453
+
454
+ features.push({
455
+ type: "Feature",
456
+ properties: {
457
+ ...baseFeature.properties,
458
+ code,
459
+ morph_factor: factor,
460
+ },
461
+ geometry,
462
+ });
463
+ }
464
+
465
+ return turf.featureCollection(features.map(withCentroid));
466
+ }
467
+
468
+ getInterpolatedLookup(factor = 0.5) {
469
+ const collection = this.getInterpolatedFeatureCollection(factor);
470
+ return createLookup(collection.features, (feature) =>
471
+ feature?.properties?.[this.geoJSONJoinColumn]
472
+ );
473
+ }
474
+ }
package/src/index.js ADDED
@@ -0,0 +1,38 @@
1
+ import { GeoMorpher } from "./core/geomorpher.js";
2
+ import { createLeafletMorphLayers, createLeafletGlyphLayer } from "./adapters/leaflet/index.js";
3
+ import { createMapLibreMorphLayers, createMapLibreGlyphLayer } from "./adapters/maplibre/index.js";
4
+ import { WGS84Projection, WebMercatorProjection, isLikelyWGS84, createProj4Projection } from "./utils/projections.js";
5
+ import { parseCSV } from "./utils/csv.js";
6
+ import {
7
+ createGridCartogramFeatureCollection,
8
+ normalizeCartogramInput,
9
+ } from "./utils/cartogram.js";
10
+
11
+ export {
12
+ GeoMorpher,
13
+ createLeafletMorphLayers,
14
+ createLeafletGlyphLayer,
15
+ createMapLibreMorphLayers,
16
+ createMapLibreGlyphLayer,
17
+ WGS84Projection,
18
+ WebMercatorProjection,
19
+ isLikelyWGS84,
20
+ createProj4Projection,
21
+ parseCSV,
22
+ createGridCartogramFeatureCollection,
23
+ normalizeCartogramInput,
24
+ };
25
+
26
+ export async function geoMorpher(options) {
27
+ const morpher = new GeoMorpher(options);
28
+ await morpher.prepare();
29
+ return {
30
+ morpher,
31
+ keyData: morpher.getKeyData(),
32
+ regularGeodataLookup: morpher.getGeographyLookup(),
33
+ regularGeodataWgs84: morpher.getRegularFeatureCollection(),
34
+ cartogramGeodataLookup: morpher.getCartogramLookup(),
35
+ cartogramGeodataWgs84: morpher.getCartogramFeatureCollection(),
36
+ tweenLookup: morpher.getInterpolatedLookup(options?.morphFactor ?? 0.5),
37
+ };
38
+ }