@ldforge/geo 0.0.0-stage → 0.1.2

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/src/index.ts ADDED
@@ -0,0 +1,421 @@
1
+ import { createSvgIcon } from '@ldforge/core';
2
+ import type { ResultRendererPlugin } from '@ldforge/core';
3
+ import { prepareMode } from './modes.js';
4
+ import type { GeoMode } from './modes.js';
5
+ export type {
6
+ GeoMode,
7
+ ClusterConstructor,
8
+ ClusterIndex,
9
+ HeatFactory,
10
+ HeatHandle,
11
+ } from './modes.js';
12
+ import { buildGeoFeatures, GeoDataError } from './model.js';
13
+ import type { GeoFeature } from './model.js';
14
+ export { parseWkt, GeoDataError } from './model.js';
15
+ export type { Geometry, GeoFeature, Position } from './model.js';
16
+
17
+ export const DEFAULT_GEO_LIBRARY_URL =
18
+ 'https://cdn.jsdelivr.net/npm/leaflet@1.9.4/+esm';
19
+ export const DEFAULT_GEO_STYLESHEET_URL =
20
+ 'https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css';
21
+ export interface GeoMapHandle {
22
+ fitBounds(
23
+ bounds: unknown,
24
+ options: { padding: [number, number]; maxZoom: number },
25
+ ): void;
26
+ remove(): void;
27
+ invalidateSize(): void;
28
+ getZoom(): number;
29
+ getSize(): { x: number; y: number };
30
+ latLngToContainerPoint(latlng: [number, number]): { x: number; y: number };
31
+ on(events: string, callback: () => void): void;
32
+ off(events: string, callback: () => void): void;
33
+ setView(latlng: unknown, zoom: number): void;
34
+ }
35
+ export interface GeoLayerHandle {
36
+ addTo(map: GeoMapHandle): GeoLayerHandle;
37
+ getBounds(): unknown;
38
+ remove(): void;
39
+ }
40
+ /** Structural interface: Leaflet types are not required by consumers. */
41
+ export interface GeoLibrary {
42
+ map(container: HTMLElement): GeoMapHandle;
43
+ tileLayer(
44
+ url: string,
45
+ options: { attribution: string; maxZoom: number },
46
+ ): { addTo(map: GeoMapHandle): unknown };
47
+ circleMarker(latlng: unknown, options: { radius: number }): unknown;
48
+ geoJSON(
49
+ data: { type: 'FeatureCollection'; features: GeoFeature[] },
50
+ options: {
51
+ pointToLayer(feature: GeoFeature, latlng: unknown): unknown;
52
+ onEachFeature(
53
+ feature: GeoFeature,
54
+ layer: {
55
+ bindPopup(content: HTMLElement): unknown;
56
+ bindTooltip(
57
+ content: HTMLElement,
58
+ options: { permanent: boolean; direction: string },
59
+ ): unknown;
60
+ on(event: string, callback: () => void): unknown;
61
+ getLatLng(): unknown;
62
+ },
63
+ ): void;
64
+ },
65
+ ): GeoLayerHandle;
66
+ }
67
+ export interface GeoRendererOptions {
68
+ readonly libraryUrl?: string;
69
+ readonly loadLibrary?: () => Promise<unknown>;
70
+ readonly clusterLibraryUrl?: string;
71
+ readonly heatLibraryUrl?: string;
72
+ readonly loadClusterLibrary?: () => Promise<unknown>;
73
+ readonly loadHeatLibrary?: () => Promise<unknown>;
74
+ readonly mode?: GeoMode;
75
+ /** false when the host supplies Leaflet CSS itself. */
76
+ readonly stylesheetUrl?: string | false;
77
+ /** false to disable basemap requests. */
78
+ readonly tileUrl?: string | false;
79
+ /** Trusted host configuration; Leaflet renders attribution as HTML. */
80
+ readonly tileAttribution?: string;
81
+ }
82
+ const messages = {
83
+ en: {
84
+ label: 'Map',
85
+ loading: 'Loading map…',
86
+ failed: 'Could not load or render the map.',
87
+ retry: 'Retry loading map',
88
+ empty: 'No geometry to display.',
89
+ invalid: 'Invalid WGS84 WKT geometry at row',
90
+ data: 'Map data',
91
+ mode: 'Map display',
92
+ normal: 'Normal',
93
+ grouped: 'Grouped',
94
+ heatmap: 'Heatmap',
95
+ },
96
+ nl: {
97
+ label: 'Kaart',
98
+ loading: 'Kaart laden…',
99
+ failed: 'De kaart kon niet geladen of getoond worden.',
100
+ retry: 'Kaart opnieuw laden',
101
+ empty: 'Geen geometrie om te tonen.',
102
+ invalid: 'Ongeldige WGS84 WKT-geometrie op rij',
103
+ data: 'Kaartgegevens',
104
+ mode: 'Kaartweergave',
105
+ normal: 'Normaal',
106
+ grouped: 'Gegroepeerd',
107
+ heatmap: 'Heatmap',
108
+ },
109
+ };
110
+ export const DEFAULT_CLUSTER_LIBRARY_URL =
111
+ 'https://cdn.jsdelivr.net/npm/supercluster@8.0.1/+esm';
112
+ export const DEFAULT_HEAT_LIBRARY_URL =
113
+ 'https://cdn.jsdelivr.net/npm/simpleheat@0.4.0/+esm';
114
+ const modules = new Map<string, Promise<unknown>>();
115
+ function httpUrl(value: string, base: string): string {
116
+ const url = new URL(value, base);
117
+ if (!['http:', 'https:'].includes(url.protocol))
118
+ throw new Error('Expected HTTP(S) URL.');
119
+ return url.href;
120
+ }
121
+ function externalModule(url: string): Promise<unknown> {
122
+ let promise = modules.get(url);
123
+ if (!promise) {
124
+ promise = import(/* @vite-ignore */ url).catch((error: unknown) => {
125
+ modules.delete(url);
126
+ throw error;
127
+ });
128
+ modules.set(url, promise);
129
+ }
130
+ return promise;
131
+ }
132
+ const styles = new WeakMap<Document, Map<string, Promise<void>>>();
133
+ function stylesheet(doc: Document, url: string): Promise<void> {
134
+ let entries = styles.get(doc);
135
+ if (!entries) {
136
+ entries = new Map();
137
+ styles.set(doc, entries);
138
+ }
139
+ let promise = entries.get(url);
140
+ if (!promise) {
141
+ promise = new Promise<void>((resolve, reject) => {
142
+ const link = doc.createElement('link');
143
+ link.rel = 'stylesheet';
144
+ link.href = url;
145
+ const timer = setTimeout(() => fail(), 15000);
146
+ function fail(): void {
147
+ clearTimeout(timer);
148
+ entries?.delete(url);
149
+ link.remove();
150
+ reject(new Error('Map CSS failed.'));
151
+ }
152
+ link.onload = () => {
153
+ clearTimeout(timer);
154
+ resolve();
155
+ };
156
+ link.onerror = fail;
157
+ doc.head.append(link);
158
+ });
159
+ entries.set(url, promise);
160
+ }
161
+ return promise;
162
+ }
163
+ function library(module: unknown): GeoLibrary {
164
+ const exports = module as { default?: unknown } | null;
165
+ const candidate = (exports?.default ?? module) as GeoLibrary | null;
166
+ if (
167
+ !candidate ||
168
+ ['map', 'tileLayer', 'circleMarker', 'geoJSON'].some(
169
+ (key) => typeof candidate[key as keyof GeoLibrary] !== 'function',
170
+ )
171
+ )
172
+ throw new Error('Expected Leaflet ESM module.');
173
+ return candidate;
174
+ }
175
+
176
+ export function createGeoRenderer(
177
+ options: GeoRendererOptions = {},
178
+ ): ResultRendererPlugin {
179
+ let loaded: Promise<GeoLibrary> | undefined;
180
+ function load(doc: Document): Promise<GeoLibrary> {
181
+ loaded ??= Promise.resolve()
182
+ .then(() =>
183
+ options.loadLibrary
184
+ ? options.loadLibrary()
185
+ : externalModule(
186
+ httpUrl(
187
+ options.libraryUrl ?? DEFAULT_GEO_LIBRARY_URL,
188
+ doc.baseURI,
189
+ ),
190
+ ),
191
+ )
192
+ .then(library)
193
+ .catch((error: unknown) => {
194
+ loaded = undefined;
195
+ throw error;
196
+ });
197
+ return loaded;
198
+ }
199
+ return {
200
+ id: 'geo',
201
+ label: { en: 'Map', nl: 'Kaart' },
202
+ documentationUrl: 'https://ldforge.nl/plugins.html#geo',
203
+ icon: createSvgIcon([
204
+ 'M9 18l-6 3V6l6-3 6 3 6-3v15l-6 3-6-3zM9 3v15M15 6v15',
205
+ ]),
206
+ supports: (result) =>
207
+ result.kind === 'bindings' && result.variables.includes('geometry'),
208
+ render(container, result, context) {
209
+ const text = messages[context?.locale ?? 'en'];
210
+ const doc = container.ownerDocument;
211
+ let features: GeoFeature[];
212
+ try {
213
+ features = buildGeoFeatures(result);
214
+ } catch (error) {
215
+ const alert = doc.createElement('p');
216
+ alert.setAttribute('role', 'alert');
217
+ alert.textContent =
218
+ error instanceof GeoDataError
219
+ ? `${text.invalid} ${error.row}.`
220
+ : text.failed;
221
+ container.replaceChildren(alert);
222
+ return;
223
+ }
224
+ if (!features.length) {
225
+ container.textContent = text.empty;
226
+ return;
227
+ }
228
+ const section = doc.createElement('section');
229
+ section.className = 'ldforge-geo';
230
+ const heading = doc.createElement('h3');
231
+ heading.textContent = context?.directives?.['geo-title'] ?? text.label;
232
+ const modeLabel = doc.createElement('label');
233
+ modeLabel.textContent = text.mode;
234
+ const select = doc.createElement('select');
235
+ select.setAttribute('aria-label', text.mode);
236
+ for (const mode of ['normal', 'grouped', 'heatmap'] as const) {
237
+ const option = doc.createElement('option');
238
+ option.value = mode;
239
+ option.textContent = text[mode];
240
+ select.append(option);
241
+ }
242
+ const initialMode =
243
+ context?.directives?.['geo-mode'] ?? options.mode ?? 'normal';
244
+ select.value = ['normal', 'grouped', 'heatmap'].includes(initialMode)
245
+ ? initialMode
246
+ : 'normal';
247
+ modeLabel.append(select);
248
+ const frame = doc.createElement('div');
249
+ frame.style.height = '420px';
250
+ frame.style.isolation = 'isolate';
251
+ frame.setAttribute('role', 'region');
252
+ frame.setAttribute('aria-label', heading.textContent);
253
+ const status = doc.createElement('p');
254
+ status.setAttribute('role', 'status');
255
+ const retry = doc.createElement('button');
256
+ retry.type = 'button';
257
+ retry.textContent = text.retry;
258
+ const details = doc.createElement('details');
259
+ const summary = doc.createElement('summary');
260
+ summary.textContent = text.data;
261
+ const list = doc.createElement('ul');
262
+ list.style.overflowWrap = 'anywhere';
263
+ for (const feature of features) {
264
+ const item = doc.createElement('li');
265
+ item.textContent = `${feature.properties.label}: ${feature.geometry.type} ${JSON.stringify(feature.geometry.coordinates)}`;
266
+ list.append(item);
267
+ }
268
+ details.append(summary, list);
269
+ section.append(heading, modeLabel, frame, status, retry, details);
270
+ container.replaceChildren(section);
271
+ let disposed = false;
272
+ let map: GeoMapHandle | undefined;
273
+ let observer: ResizeObserver | undefined;
274
+ let clearMode: (() => void) | undefined;
275
+ let generation = 0;
276
+ let activeLibrary: GeoLibrary | undefined;
277
+ function draw(features: GeoFeature[]): GeoLayerHandle {
278
+ if (!map || !activeLibrary) throw new Error('Map unavailable.');
279
+ return activeLibrary
280
+ .geoJSON(
281
+ { type: 'FeatureCollection', features },
282
+ {
283
+ pointToLayer: (feature, latlng) =>
284
+ activeLibrary?.circleMarker(latlng, {
285
+ radius: feature.properties.count ? 16 : 6,
286
+ }),
287
+ onEachFeature(feature, layer) {
288
+ const label = doc.createElement('span');
289
+ label.textContent = feature.properties.label;
290
+ if (feature.properties.count) {
291
+ layer.bindTooltip(label, {
292
+ permanent: true,
293
+ direction: 'center',
294
+ });
295
+ layer.on('click', () =>
296
+ map?.setView(
297
+ layer.getLatLng(),
298
+ feature.properties.expansionZoom ?? 19,
299
+ ),
300
+ );
301
+ } else layer.bindPopup(label);
302
+ },
303
+ },
304
+ )
305
+ .addTo(map);
306
+ }
307
+ async function updateMode(): Promise<void> {
308
+ const version = ++generation;
309
+ status.textContent = text.loading;
310
+ retry.hidden = true;
311
+ section.setAttribute('aria-busy', 'true');
312
+ try {
313
+ const render = await prepareMode(
314
+ select.value as GeoMode,
315
+ features,
316
+ () =>
317
+ options.loadClusterLibrary
318
+ ? options.loadClusterLibrary()
319
+ : externalModule(
320
+ httpUrl(
321
+ options.clusterLibraryUrl ?? DEFAULT_CLUSTER_LIBRARY_URL,
322
+ doc.baseURI,
323
+ ),
324
+ ),
325
+ () =>
326
+ options.loadHeatLibrary
327
+ ? options.loadHeatLibrary()
328
+ : externalModule(
329
+ httpUrl(
330
+ options.heatLibraryUrl ?? DEFAULT_HEAT_LIBRARY_URL,
331
+ doc.baseURI,
332
+ ),
333
+ ),
334
+ );
335
+ if (disposed || version !== generation || !map) return;
336
+ clearMode?.();
337
+ clearMode = undefined;
338
+ clearMode = render(map, frame, draw);
339
+ status.textContent = '';
340
+ } catch {
341
+ if (disposed || version !== generation) return;
342
+ status.textContent = text.failed;
343
+ retry.hidden = false;
344
+ } finally {
345
+ if (!disposed && version === generation)
346
+ section.setAttribute('aria-busy', 'false');
347
+ }
348
+ }
349
+ select.disabled = true;
350
+ select.addEventListener('change', () => {
351
+ void updateMode();
352
+ });
353
+ async function activate(): Promise<void> {
354
+ retry.hidden = true;
355
+ status.textContent = text.loading;
356
+ frame.hidden = true;
357
+ section.setAttribute('aria-busy', 'true');
358
+ try {
359
+ const [L] = await Promise.all([
360
+ load(doc),
361
+ options.stylesheetUrl === false
362
+ ? Promise.resolve()
363
+ : stylesheet(
364
+ doc,
365
+ httpUrl(
366
+ options.stylesheetUrl ?? DEFAULT_GEO_STYLESHEET_URL,
367
+ doc.baseURI,
368
+ ),
369
+ ),
370
+ ]);
371
+ if (disposed) return;
372
+ frame.hidden = false;
373
+ map = L.map(frame);
374
+ if (options.tileUrl !== false)
375
+ L.tileLayer(
376
+ options.tileUrl ??
377
+ 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
378
+ {
379
+ attribution:
380
+ options.tileAttribution ??
381
+ '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
382
+ maxZoom: 19,
383
+ },
384
+ ).addTo(map);
385
+ activeLibrary = L;
386
+ const bounds = draw(features);
387
+ map.fitBounds(bounds.getBounds(), { padding: [24, 24], maxZoom: 14 });
388
+ bounds.remove();
389
+ select.disabled = false;
390
+ await updateMode();
391
+ if (disposed) return;
392
+ if (typeof ResizeObserver !== 'undefined') {
393
+ observer = new ResizeObserver(() => map?.invalidateSize());
394
+ observer.observe(frame);
395
+ }
396
+ } catch {
397
+ map?.remove();
398
+ map = undefined;
399
+ if (disposed) return;
400
+ frame.hidden = true;
401
+ status.textContent = text.failed;
402
+ retry.hidden = false;
403
+ } finally {
404
+ if (!disposed) section.setAttribute('aria-busy', 'false');
405
+ }
406
+ }
407
+ retry.addEventListener('click', () => {
408
+ if (map) void updateMode();
409
+ else void activate();
410
+ });
411
+ void activate();
412
+ return () => {
413
+ disposed = true;
414
+ generation++;
415
+ clearMode?.();
416
+ observer?.disconnect();
417
+ map?.remove();
418
+ };
419
+ },
420
+ };
421
+ }
package/src/model.ts ADDED
@@ -0,0 +1,137 @@
1
+ import type { QueryResult } from '@ldforge/core';
2
+
3
+ export type Position = [number, number];
4
+ export type Geometry =
5
+ | { type: 'Point'; coordinates: Position }
6
+ | { type: 'LineString' | 'MultiPoint'; coordinates: Position[] }
7
+ | { type: 'Polygon' | 'MultiLineString'; coordinates: Position[][] }
8
+ | { type: 'MultiPolygon'; coordinates: Position[][][] };
9
+ export interface GeoFeature {
10
+ type: 'Feature';
11
+ geometry: Geometry;
12
+ properties: { label: string; count?: number; expansionZoom?: number };
13
+ }
14
+ export class GeoDataError extends Error {
15
+ constructor(readonly row: number) {
16
+ super(`Invalid WGS84 WKT geometry at row ${row}.`);
17
+ }
18
+ }
19
+
20
+ /** Strict two-dimensional WKT, longitude first. No projection is guessed. */
21
+ export function parseWkt(input: string): Geometry {
22
+ const source = input
23
+ .trim()
24
+ .replace(
25
+ /^<http:\/\/www\.opengis\.net\/def\/crs\/OGC\/1\.3\/CRS84>\s+/,
26
+ '',
27
+ );
28
+ const match =
29
+ /^(POINT|LINESTRING|POLYGON|MULTIPOINT|MULTILINESTRING|MULTIPOLYGON)\s*(\(.*\))$/is.exec(
30
+ source,
31
+ );
32
+ if (!match?.[1] || !match[2]) throw new Error('Unsupported WKT or CRS.');
33
+ const rawTokens = [...match[2].matchAll(/[(),]|[^\s(),]+/g)];
34
+ const tokens = rawTokens.map((token) => token[0]);
35
+ let cursor = 0;
36
+ function take(expected: string): void {
37
+ if (tokens[cursor++] !== expected) throw new Error('Invalid WKT.');
38
+ }
39
+ function position(): Position {
40
+ const values = [tokens[cursor++], tokens[cursor++]];
41
+ if (
42
+ values.some(
43
+ (value) =>
44
+ !value || !/^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(value),
45
+ )
46
+ )
47
+ throw new Error('Invalid position.');
48
+ const x = Number(values[0]);
49
+ const y = Number(values[1]);
50
+ if (
51
+ !Number.isFinite(x) ||
52
+ !Number.isFinite(y) ||
53
+ Math.abs(x) > 180 ||
54
+ Math.abs(y) > 90
55
+ )
56
+ throw new Error('Invalid WGS84 position.');
57
+ return [x, y];
58
+ }
59
+ function list<T>(read: () => T): T[] {
60
+ take('(');
61
+ const items = [read()];
62
+ while (tokens[cursor] === ',') {
63
+ cursor++;
64
+ items.push(read());
65
+ }
66
+ take(')');
67
+ return items;
68
+ }
69
+ function line(): Position[] {
70
+ const points = list(position);
71
+ if (points.length < 2) throw new Error('Line needs two positions.');
72
+ return points;
73
+ }
74
+ function ring(): Position[] {
75
+ const points = line();
76
+ if (
77
+ points.length < 4 ||
78
+ points[0]?.[0] !== points.at(-1)?.[0] ||
79
+ points[0]?.[1] !== points.at(-1)?.[1]
80
+ )
81
+ throw new Error('Ring must close.');
82
+ return points;
83
+ }
84
+ let geometry: Geometry;
85
+ switch (match[1].toUpperCase()) {
86
+ case 'POINT': {
87
+ take('(');
88
+ const coordinates = position();
89
+ take(')');
90
+ geometry = { type: 'Point', coordinates };
91
+ break;
92
+ }
93
+ case 'LINESTRING':
94
+ geometry = { type: 'LineString', coordinates: line() };
95
+ break;
96
+ case 'POLYGON':
97
+ geometry = { type: 'Polygon', coordinates: list(ring) };
98
+ break;
99
+ case 'MULTIPOINT':
100
+ geometry = {
101
+ type: 'MultiPoint',
102
+ coordinates: list(() => {
103
+ if (tokens[cursor] !== '(') return position();
104
+ take('(');
105
+ const point = position();
106
+ take(')');
107
+ return point;
108
+ }),
109
+ };
110
+ break;
111
+ case 'MULTILINESTRING':
112
+ geometry = { type: 'MultiLineString', coordinates: list(line) };
113
+ break;
114
+ default:
115
+ geometry = { type: 'MultiPolygon', coordinates: list(() => list(ring)) };
116
+ }
117
+ if (cursor !== tokens.length) throw new Error('Trailing WKT data.');
118
+ return geometry;
119
+ }
120
+
121
+ export function buildGeoFeatures(result: QueryResult): GeoFeature[] {
122
+ if (result.kind !== 'bindings') return [];
123
+ return result.bindings.map((row, index) => {
124
+ try {
125
+ const term = row.geometry;
126
+ if (!term || !['literal', 'typed-literal'].includes(term.type))
127
+ throw new Error('Missing geometry.');
128
+ return {
129
+ type: 'Feature',
130
+ geometry: parseWkt(term.value),
131
+ properties: { label: row.geometryLabel?.value ?? `#${index + 1}` },
132
+ };
133
+ } catch {
134
+ throw new GeoDataError(index + 1);
135
+ }
136
+ });
137
+ }