passerelle-geo-components 0.2.0-alpha.1

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.
@@ -0,0 +1,1719 @@
1
+ /**
2
+ * geo-components — Web Components CEREMA pour cartes MapLibre et composants
3
+ * controllers pilotés. Brique commune cross-projet (qgis-sspcloud, Widgets Grist,
4
+ * CRM-Grist, ZEBRA, MobSciDat, atlas-territorial, SURFAC²E, cerema-livrables).
5
+ *
6
+ * Conçu pour le navigateur : ES module, zéro dépendance runtime propre, ouvert
7
+ * dans un widget Grist self-contained, une PWA, une page HTML statique, un
8
+ * iframe SSR de storymap, un shadow DOM d'application React/Vue/Lit.
9
+ *
10
+ * Peer-dépendance optionnelle : `geoai-kit@^0.2.0` (utilise `styleByField` si
11
+ * présent, sinon fallback interne).
12
+ *
13
+ * Peer-dépendance obligatoire : `maplibre-gl@^5.6.1` chargé globalement dans
14
+ * la page hôte (via CDN unpkg, bundler applicatif, ou tag script). Le composant
15
+ * détecte l'absence et affiche un fallback.
16
+ *
17
+ * Contrat de données : `InteractiveMapParams V1.13` défini par qgis-sspcloud
18
+ * dans `hub/hub/models/component_params.py`. Voir README.md et MIGRATION.md.
19
+ *
20
+ * Composants livrés v0.1 :
21
+ * - <geo-map> : wrapper MapLibre reader thématique 2D
22
+ * - <geo-timeline> : controller slider temporel avec play/pause
23
+ * - <geo-legend> : légende métier 3 formats (chips/gradient_bar/proportional)
24
+ *
25
+ * Pattern de binding : CustomEvent 'geo:bind' bubbling + composed, relayé par
26
+ * un orchestrateur singleton attaché au document. Cross-iframe via postMessage
27
+ * avec validation d'origine. Pattern inspiré de Widgets Grist
28
+ * `skills/inter-widget.md` (convention `app.emit`/`app.on` Artefactory) et
29
+ * `skills/bridge.md` (pattern grist-bridge callback + retarget iframe),
30
+ * adapté au niveau Custom Elements avec un événement dédié 'geo:bind'.
31
+ * Voir table de correspondance dans MIGRATION.md.
32
+ *
33
+ * @module geo-components
34
+ * @version 0.1.0-alpha.0
35
+ */
36
+
37
+ "use strict";
38
+
39
+ // ──────────────────────────────────────────────────────────────────────────
40
+ // 0. Constantes et catalogue
41
+ // ──────────────────────────────────────────────────────────────────────────
42
+
43
+ /** Version de la lib. */
44
+ export const LIB_VERSION = "0.1.0-alpha.1";
45
+
46
+ /**
47
+ * Catalogue des 6 basemaps du contrat InteractiveMapParams V1.13.
48
+ * Chaque entrée fournit un style MapLibre standalone (raster ou vector).
49
+ * Extension V1.22 prévue : CARTO Positron, dark auto.
50
+ */
51
+ export const BASEMAPS = {
52
+ "osm": {
53
+ label: "OpenStreetMap",
54
+ style: {
55
+ version: 8,
56
+ sources: {
57
+ osm: {
58
+ type: "raster",
59
+ tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
60
+ tileSize: 256,
61
+ attribution: "© OpenStreetMap contributors",
62
+ },
63
+ },
64
+ layers: [{ id: "osm", type: "raster", source: "osm" }],
65
+ },
66
+ },
67
+ "plan-ign-v2": {
68
+ label: "Plan IGN",
69
+ style: {
70
+ version: 8,
71
+ sources: {
72
+ ign: {
73
+ type: "raster",
74
+ tiles: [
75
+ "https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0" +
76
+ "&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2&STYLE=normal" +
77
+ "&FORMAT=image/png&TILEMATRIXSET=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}",
78
+ ],
79
+ tileSize: 256,
80
+ attribution: "© IGN Géoplateforme",
81
+ },
82
+ },
83
+ layers: [{ id: "ign", type: "raster", source: "ign" }],
84
+ },
85
+ },
86
+ "ortho-ign": {
87
+ label: "Photos aériennes IGN",
88
+ style: {
89
+ version: 8,
90
+ sources: {
91
+ ortho: {
92
+ type: "raster",
93
+ tiles: [
94
+ "https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0" +
95
+ "&LAYER=ORTHOIMAGERY.ORTHOPHOTOS&STYLE=normal" +
96
+ "&FORMAT=image/jpeg&TILEMATRIXSET=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}",
97
+ ],
98
+ tileSize: 256,
99
+ attribution: "© IGN Géoplateforme",
100
+ },
101
+ },
102
+ layers: [{ id: "ortho", type: "raster", source: "ortho" }],
103
+ },
104
+ },
105
+ "dsfr-sobre": {
106
+ label: "Plan sobre DSFR",
107
+ style: {
108
+ version: 8,
109
+ sources: {
110
+ dsfr: {
111
+ type: "raster",
112
+ tiles: [
113
+ "https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}.png",
114
+ ],
115
+ tileSize: 256,
116
+ attribution: "© Stadia Maps © OpenMapTiles © OpenStreetMap",
117
+ },
118
+ },
119
+ layers: [{ id: "dsfr", type: "raster", source: "dsfr" }],
120
+ },
121
+ },
122
+ "hillshade-ign": {
123
+ label: "Estompage IGN",
124
+ style: {
125
+ version: 8,
126
+ sources: {
127
+ hs: {
128
+ type: "raster",
129
+ tiles: [
130
+ "https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0" +
131
+ "&LAYER=ELEVATION.SLOPES&STYLE=normal" +
132
+ "&FORMAT=image/png&TILEMATRIXSET=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}",
133
+ ],
134
+ tileSize: 256,
135
+ attribution: "© IGN Géoplateforme",
136
+ },
137
+ },
138
+ layers: [{ id: "hs", type: "raster", source: "hs" }],
139
+ },
140
+ },
141
+ "etalab": {
142
+ label: "Etalab",
143
+ style: {
144
+ version: 8,
145
+ sources: {
146
+ etalab: {
147
+ type: "raster",
148
+ tiles: [
149
+ "https://openmaptiles.geo.data.gouv.fr/styles/osm-bright/{z}/{x}/{y}.png",
150
+ ],
151
+ tileSize: 256,
152
+ attribution: "© Etalab © OpenMapTiles © OpenStreetMap",
153
+ },
154
+ },
155
+ layers: [{ id: "etalab", type: "raster", source: "etalab" }],
156
+ },
157
+ },
158
+ };
159
+
160
+ /** Palette DSFR CEREMA par défaut (fallback quand aucune classification). */
161
+ export const DEFAULT_PALETTE = [
162
+ "#000091", // bleu Marianne
163
+ "#e1000f", // rouge Marianne
164
+ "#1f8d4d", // vert
165
+ "#ff6f00", // orange
166
+ "#9c27b0", // violet
167
+ "#0288d1", // bleu clair
168
+ ];
169
+
170
+ /** Marseille 4e arr., défaut historique cohérent avec qgis-sspcloud. */
171
+ export const DEFAULT_CENTER = [5.39, 43.30];
172
+ export const DEFAULT_ZOOM = 13;
173
+
174
+ // ──────────────────────────────────────────────────────────────────────────
175
+ // 1. Helpers internes
176
+ // ──────────────────────────────────────────────────────────────────────────
177
+
178
+ /**
179
+ * Détecte MapLibre GL JS chargé globalement. Retourne l'objet ou null.
180
+ * Ne charge PAS MapLibre : c'est à la page hôte de le fournir.
181
+ */
182
+ function _detectMapLibre() {
183
+ if (typeof window === "undefined") return null;
184
+ return window.maplibregl || null;
185
+ }
186
+
187
+ /**
188
+ * Résout la zone d'étude en {center, zoom, bbox} exploitables par MapLibre.
189
+ * Supporte les 3 modes du contrat V1.13 : commune | manual | study.
190
+ * Pour "commune" et "study", nécessite une résolution externe (fournie via
191
+ * config.resolveZone) sinon fallback sur defaults.
192
+ */
193
+ function _resolveZone(zoneConfig, hostContext) {
194
+ const zone = zoneConfig || {};
195
+ const kind = zone.kind || "manual";
196
+
197
+ if (kind === "manual") {
198
+ return {
199
+ center: [
200
+ zone.center_lng ?? DEFAULT_CENTER[0],
201
+ zone.center_lat ?? DEFAULT_CENTER[1],
202
+ ],
203
+ zoom: zone.zoom ?? DEFAULT_ZOOM,
204
+ bbox: zone.bbox || null,
205
+ };
206
+ }
207
+
208
+ if (kind === "study" && hostContext?.study_bbox) {
209
+ const [w, s, e, n] = hostContext.study_bbox;
210
+ return {
211
+ center: [(w + e) / 2, (s + n) / 2],
212
+ zoom: DEFAULT_ZOOM,
213
+ bbox: [w, s, e, n],
214
+ };
215
+ }
216
+
217
+ if (kind === "commune" && hostContext?.commune_bbox) {
218
+ const [w, s, e, n] = hostContext.commune_bbox;
219
+ return {
220
+ center: [(w + e) / 2, (s + n) / 2],
221
+ zoom: DEFAULT_ZOOM,
222
+ bbox: [w, s, e, n],
223
+ };
224
+ }
225
+
226
+ // Fallback si résolution impossible
227
+ return { center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, bbox: null };
228
+ }
229
+
230
+ /**
231
+ * Détecte le type de géométrie GeoJSON (point/line/fill) pour ajouter la
232
+ * bonne layer MapLibre par défaut si aucune classification n'est fournie.
233
+ *
234
+ * V0.2.0 (Chantier 4 Sprint V0.2, 2026-07-10) : ajoute sniff de la geojson
235
+ * si geometry_type non fourni. Fix bug POINT rendering remonté par
236
+ * cerema-livrables (points rendus en fill invisibles avant fix).
237
+ */
238
+ function _detectGeomType(geometryTypeHint, geojson) {
239
+ const h = (geometryTypeHint || "").toLowerCase();
240
+ if (h.includes("point")) return "point";
241
+ if (h.includes("line") || h.includes("linestring")) return "line";
242
+ if (h.includes("polygon")) return "fill";
243
+ // Sniff depuis geojson si pas de hint typé
244
+ if (geojson && Array.isArray(geojson.features) && geojson.features.length > 0) {
245
+ const firstGeom = geojson.features[0].geometry;
246
+ if (firstGeom && typeof firstGeom.type === "string") {
247
+ const g = firstGeom.type.toLowerCase();
248
+ if (g.includes("point")) return "point";
249
+ if (g.includes("line")) return "line";
250
+ if (g.includes("polygon")) return "fill";
251
+ }
252
+ }
253
+ return "fill";
254
+ }
255
+
256
+ /**
257
+ * V0.2.0 (Chantier 4) : détecte si une FeatureCollection est hétérogène
258
+ * (mélange de polygones et de points). Utile pour auto-splitter en 2 layers
259
+ * MapLibre distincts (fix bug Livrables).
260
+ * Retourne { hasPolygon, hasPoint, hasLine, isMixed }.
261
+ */
262
+ function _analyzeGeojson(geojson) {
263
+ const result = { hasPolygon: false, hasPoint: false, hasLine: false, isMixed: false };
264
+ if (!geojson || !Array.isArray(geojson.features)) return result;
265
+ for (const f of geojson.features) {
266
+ const t = (f.geometry && f.geometry.type || "").toLowerCase();
267
+ if (t.includes("polygon")) result.hasPolygon = true;
268
+ else if (t.includes("point")) result.hasPoint = true;
269
+ else if (t.includes("line")) result.hasLine = true;
270
+ if ((result.hasPolygon + result.hasPoint + result.hasLine) > 1) {
271
+ result.isMixed = true;
272
+ }
273
+ }
274
+ return result;
275
+ }
276
+
277
+ /**
278
+ * V0.2.0 (Chantier 4) : construit un layer 'line' outline dérivé du même
279
+ * source (pattern outline déclaratif V0.3.1 style.outline).
280
+ * Absorbe le workaround Livrables (LLINE ajouté impératif après geo:map-ready).
281
+ */
282
+ function _buildOutlineLayer(baseLayerId, sourceId, outlineConfig, opacity) {
283
+ return {
284
+ id: baseLayerId + "-outline",
285
+ type: "line",
286
+ source: sourceId,
287
+ paint: {
288
+ "line-color": outlineConfig.color || "#000091",
289
+ "line-width": outlineConfig.width ?? 1,
290
+ "line-opacity": (opacity ?? 1) * 0.9,
291
+ },
292
+ };
293
+ }
294
+
295
+ /**
296
+ * V0.2.0 (Chantier 4) : construit un layer 'circle' point creux (pattern
297
+ * V0.3.1 style.hollow_point). Absorbe le workaround Livrables (addPoints
298
+ * impératif avec stroke coloré par field).
299
+ */
300
+ function _buildHollowPointLayer(baseLayerId, sourceId, hollowConfig, themedColor, opacity) {
301
+ const radius = hollowConfig.radius ?? 7;
302
+ const strokeField = hollowConfig.stroke_color_from_field;
303
+ return {
304
+ id: baseLayerId,
305
+ type: "circle",
306
+ source: sourceId,
307
+ paint: {
308
+ "circle-radius": radius,
309
+ "circle-color": "rgba(148,163,184,0.15)",
310
+ "circle-stroke-width": 2,
311
+ "circle-stroke-color": strokeField
312
+ ? ["get", strokeField]
313
+ : (themedColor || "#000091"),
314
+ "circle-opacity": (opacity ?? 1) * 0.9,
315
+ },
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Applique une classification thématique à un layer MapLibre : catégorisé,
321
+ * gradué, ou couleur fixe. Utilise `paint_expression` s'il est pré-calculé
322
+ * (pattern qgis-sspcloud SSR), sinon dérive une expression MapLibre.
323
+ */
324
+ function _paintForClassification(classif, fallbackColor) {
325
+ if (!classif) return fallbackColor;
326
+ if (classif.paint_expression) return classif.paint_expression;
327
+
328
+ // Si classification déclarée mais pas d'expression pré-calculée,
329
+ // dérive une expression simple (V0.1 : basique, V0.3 : full geoai-kit).
330
+ const { field, method, palette } = classif;
331
+ const kit = typeof window !== "undefined" && window.GeoAIKit;
332
+ if (kit && typeof kit.styleByField === "function") {
333
+ try {
334
+ return kit.styleByField(classif) || fallbackColor;
335
+ } catch (e) {
336
+ return fallbackColor;
337
+ }
338
+ }
339
+
340
+ // Fallback minimal : simple match categorized
341
+ // P1 correctness (review 2026-07-08) : respecte le palette utilisateur du
342
+ // contrat V1.13 s'il est fourni, sinon fallback DEFAULT_PALETTE.
343
+ if (method === "categorized" && classif.breaks_manual) {
344
+ const userPalette = Array.isArray(palette) ? palette : null;
345
+ const stops = ["match", ["get", field]];
346
+ (classif.breaks_manual || []).forEach((v, i) => {
347
+ const color = userPalette
348
+ ? userPalette[i % userPalette.length]
349
+ : DEFAULT_PALETTE[i % DEFAULT_PALETTE.length];
350
+ stops.push(v, color);
351
+ });
352
+ stops.push(fallbackColor);
353
+ return stops;
354
+ }
355
+
356
+ return fallbackColor;
357
+ }
358
+
359
+ /**
360
+ * Construit un layer MapLibre depuis une entrée du contrat V1.13 layers_override.
361
+ * Gère heatmap, proportional, classification, fill/line/circle par défaut.
362
+ */
363
+ function _buildMapLibreLayer(sceneLayer, override, index) {
364
+ const safeId = _sanitizeId(sceneLayer.id || String(index));
365
+ const layerId = "gc-lyr-" + safeId;
366
+ const sourceId = "gc-src-" + safeId;
367
+ const geom = _detectGeomType(sceneLayer.geometry_type);
368
+ const fallbackColor = DEFAULT_PALETTE[index % DEFAULT_PALETTE.length];
369
+ const classif = (override && override.classification) || sceneLayer.classification;
370
+ const themedColor = _paintForClassification(classif, fallbackColor);
371
+ const opacity = override?.opacity ?? 1.0;
372
+
373
+ // Heatmap
374
+ if (override?.heatmap_field && geom === "point") {
375
+ return {
376
+ id: layerId,
377
+ type: "heatmap",
378
+ source: sourceId,
379
+ maxzoom: 18,
380
+ paint: {
381
+ "heatmap-weight": [
382
+ "interpolate", ["linear"],
383
+ ["coalesce", ["to-number", ["get", override.heatmap_field], 0], 0],
384
+ 0, 0, 100, 1,
385
+ ],
386
+ "heatmap-intensity": ["interpolate", ["linear"], ["zoom"], 0, 1, 18, 3],
387
+ "heatmap-color": [
388
+ "interpolate", ["linear"], ["heatmap-density"],
389
+ 0, "rgba(0,0,145,0)",
390
+ 0.2, "rgba(0,99,203,0.4)",
391
+ 0.4, "rgba(255,225,0,0.6)",
392
+ 0.6, "rgba(255,103,0,0.8)",
393
+ 1, "rgba(225,0,15,1)",
394
+ ],
395
+ "heatmap-radius": ["interpolate", ["linear"], ["zoom"], 0, 2, 18, 30],
396
+ "heatmap-opacity": opacity,
397
+ },
398
+ };
399
+ }
400
+
401
+ // Proportional (points)
402
+ if (geom === "point" && override?.proportional_field) {
403
+ const propMin = override.proportional_min ?? 0;
404
+ const propMax = override.proportional_max ?? 1000;
405
+ const rMin = override.proportional_radius_min ?? 4;
406
+ const rMax = override.proportional_radius_max ?? 30;
407
+ return {
408
+ id: layerId,
409
+ type: "circle",
410
+ source: sourceId,
411
+ paint: {
412
+ "circle-color": themedColor,
413
+ "circle-radius": [
414
+ "interpolate", ["linear"],
415
+ ["coalesce", ["to-number", ["get", override.proportional_field], 0], 0],
416
+ propMin, rMin,
417
+ propMax, rMax,
418
+ ],
419
+ "circle-stroke-width": 1,
420
+ "circle-stroke-color": "#fff",
421
+ "circle-opacity": 0.75 * opacity,
422
+ },
423
+ };
424
+ }
425
+
426
+ // Points simples
427
+ if (geom === "point") {
428
+ return {
429
+ id: layerId,
430
+ type: "circle",
431
+ source: sourceId,
432
+ paint: {
433
+ "circle-color": themedColor,
434
+ "circle-radius": 6,
435
+ "circle-stroke-width": 1,
436
+ "circle-stroke-color": "#fff",
437
+ "circle-opacity": opacity,
438
+ },
439
+ };
440
+ }
441
+
442
+ // Lignes
443
+ if (geom === "line") {
444
+ return {
445
+ id: layerId,
446
+ type: "line",
447
+ source: sourceId,
448
+ paint: {
449
+ "line-color": themedColor,
450
+ "line-width": 2,
451
+ "line-opacity": opacity,
452
+ },
453
+ };
454
+ }
455
+
456
+ // Polygones par défaut
457
+ return {
458
+ id: layerId,
459
+ type: "fill",
460
+ source: sourceId,
461
+ paint: {
462
+ "fill-color": themedColor,
463
+ "fill-opacity": 0.6 * opacity,
464
+ "fill-outline-color": "#ffffff",
465
+ },
466
+ };
467
+ }
468
+
469
+ /**
470
+ * Retourne la propriete de paint MapLibre appropriee selon le type de layer.
471
+ * P1 correctness (review 2026-07-08) : evite crash sur layers non-polygone
472
+ * quand on applique setPaintProperty('fill-color' | 'fill-opacity').
473
+ *
474
+ * @param {string} layerType 'fill' | 'circle' | 'line' | 'heatmap' | ...
475
+ * @param {'color'|'opacity'} what
476
+ * @returns {string|null}
477
+ */
478
+ function _colorPaintPropFor(layerType, what) {
479
+ const suffix = what === "opacity" ? "-opacity" : "-color";
480
+ switch (layerType) {
481
+ case "fill":
482
+ // Note : le heatmap n'a pas de "single color" mais un ramp density.
483
+ return "fill" + suffix;
484
+ case "circle":
485
+ return "circle" + suffix;
486
+ case "line":
487
+ return "line" + suffix;
488
+ case "heatmap":
489
+ // heatmap : opacity OK, color = ramp density (retourne null pour color).
490
+ return what === "opacity" ? "heatmap-opacity" : null;
491
+ case "fill-extrusion":
492
+ return "fill-extrusion" + suffix;
493
+ default:
494
+ return null;
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Sanitize un ID pour MapLibre v5 (accepte uniquement [A-Za-z0-9_-]).
500
+ * Les caractères non-ASCII (accents, cedilles) et espaces sont remplacés
501
+ * par des underscores. Fix V1.20.5 : scene_manifest QGIS peut produire
502
+ * des IDs avec accents (ex: "bâtiments__bd_topo_") qui font silencieusement
503
+ * echouer addSource/addLayer sur MapLibre v5.
504
+ */
505
+ function _sanitizeId(s) {
506
+ if (!s) return "unnamed";
507
+ return String(s).replace(/[^A-Za-z0-9_-]/g, "_");
508
+ }
509
+
510
+ /**
511
+ * Escape HTML minimal pour popup_template mustache.
512
+ */
513
+ function _escapeHtml(s) {
514
+ if (s === null || s === undefined) return "";
515
+ return String(s)
516
+ .replace(/&/g, "&amp;")
517
+ .replace(/</g, "&lt;")
518
+ .replace(/>/g, "&gt;")
519
+ .replace(/"/g, "&quot;")
520
+ .replace(/'/g, "&#39;");
521
+ }
522
+
523
+ /**
524
+ * Applique un popup_template mustache-like {prop} sur les propriétés d'une
525
+ * feature. Escape HTML par défaut (contrat qgis-sspcloud V1.13).
526
+ */
527
+ function _renderPopup(template, props) {
528
+ return (template || "").replace(/\{(\w+)\}/g, (m, k) => {
529
+ const v = props[k];
530
+ return v === undefined ? "" : _escapeHtml(v);
531
+ });
532
+ }
533
+
534
+ /**
535
+ * Parse un attribut HTML `params` (JSON string) en objet. Retourne {} si vide
536
+ * ou invalide (log warn).
537
+ */
538
+ function _parseParams(raw) {
539
+ if (!raw) return {};
540
+ try {
541
+ return typeof raw === "string" ? JSON.parse(raw) : raw;
542
+ } catch (e) {
543
+ console.warn("[geo-components] params attribute JSON invalide:", e);
544
+ return {};
545
+ }
546
+ }
547
+
548
+ // ──────────────────────────────────────────────────────────────────────────
549
+ // 2. Orchestrateur bindings — singleton attaché au document
550
+ // ──────────────────────────────────────────────────────────────────────────
551
+
552
+ /**
553
+ * GeoBindings orchestrateur : reçoit les CustomEvent 'geo:bind' émis par les
554
+ * composants controllers (timeline, scenario_switch, filter_chips) et route
555
+ * vers le composant carte target via son API impérative applyBinding().
556
+ *
557
+ * Fonctionnement in-DOM (même page) : le CustomEvent bulle jusqu'au document
558
+ * grâce à {bubbles: true, composed: true} (traverse les Shadow DOM).
559
+ *
560
+ * Fonctionnement cross-iframe : les composants émettent aussi via
561
+ * window.parent.postMessage({type: 'geo:bind', detail: {...}}, '*') si détectent
562
+ * qu'ils sont dans un iframe enfant. L'orchestrateur du parent relaye vers
563
+ * l'iframe cible.
564
+ *
565
+ * Convention événement (compatible Widgets Grist skills/inter-widget.md) :
566
+ * detail: { from: cid_source, target: cid_target, prop: 'time'|'scenario'|..., value }
567
+ *
568
+ * Enregistrement singleton : le premier composant à s'installer sur la page
569
+ * enregistre les listeners. install() est idempotent.
570
+ */
571
+ export class GeoBindings {
572
+ constructor() {
573
+ this._installed = false;
574
+ /**
575
+ * P1 security (review 2026-07-08) : whitelist des origines autorisees pour
576
+ * les postMessage entrants. Defaut = [location.origin] (uniquement les
577
+ * iframes servies depuis la meme origine que la page hote).
578
+ *
579
+ * Le consommateur peut relacher en amont :
580
+ * GeoBindings.allowedOrigins.push('https://widgets.grist.example.fr')
581
+ * OU declarer une variable globale AVANT que la lib charge :
582
+ * window.__GEO_BINDINGS_TRUSTED_ORIGINS = ['https://...']
583
+ */
584
+ this.allowedOrigins = [];
585
+ if (typeof location !== "undefined" && location.origin) {
586
+ this.allowedOrigins.push(location.origin);
587
+ }
588
+ if (typeof window !== "undefined" && Array.isArray(window.__GEO_BINDINGS_TRUSTED_ORIGINS)) {
589
+ window.__GEO_BINDINGS_TRUSTED_ORIGINS.forEach((o) => {
590
+ if (typeof o === "string" && !this.allowedOrigins.includes(o)) {
591
+ this.allowedOrigins.push(o);
592
+ }
593
+ });
594
+ }
595
+ }
596
+
597
+ install() {
598
+ if (this._installed) return;
599
+ if (typeof document === "undefined") return;
600
+
601
+ // Listener in-DOM
602
+ document.addEventListener("geo:bind", (ev) => this._route(ev.detail));
603
+
604
+ // Listener cross-iframe avec validation origin
605
+ if (typeof window !== "undefined") {
606
+ window.addEventListener("message", (ev) => {
607
+ if (!ev.data || ev.data.type !== "geo:bind") return;
608
+ // P1 security : rejette les postMessage d'origines non whitelistees.
609
+ // Prevent UI spoofing depuis iframe hostile qui pourrait piloter
610
+ // un <geo-map> a distance sans consentement.
611
+ if (!this._isAllowedOrigin(ev.origin)) {
612
+ console.warn(
613
+ "[geo-components] postMessage geo:bind rejete, origin non autorisee:",
614
+ ev.origin
615
+ );
616
+ return;
617
+ }
618
+ this._route(ev.data.detail, true);
619
+ });
620
+ }
621
+
622
+ this._installed = true;
623
+ }
624
+
625
+ _isAllowedOrigin(origin) {
626
+ if (!origin) return false;
627
+ // 'null' est l'origin d'un iframe sandbox sans allow-same-origin — rejete.
628
+ if (origin === "null") return false;
629
+ return this.allowedOrigins.includes(origin);
630
+ }
631
+
632
+ _route(detail, fromMessage) {
633
+ if (!detail || !detail.target) return;
634
+ const target = this._resolveTarget(detail.target);
635
+ if (target && typeof target.applyBinding === "function") {
636
+ try {
637
+ target.applyBinding(detail);
638
+ } catch (e) {
639
+ console.warn("[geo-components] applyBinding failed on", detail.target, e);
640
+ }
641
+ return;
642
+ }
643
+ // Cross-iframe fallback : chercher un iframe avec data-cid correspondant
644
+ const iframe = document.querySelector(
645
+ 'iframe[data-cid="' + detail.target + '"]'
646
+ );
647
+ if (iframe && iframe.contentWindow && !fromMessage) {
648
+ iframe.contentWindow.postMessage(
649
+ { type: "geo:bind", detail },
650
+ "*"
651
+ );
652
+ }
653
+ }
654
+
655
+ /**
656
+ * Resolution tolerante du target : cherche successivement plusieurs
657
+ * conventions d'ID pour absorber les differences de nommage entre
658
+ * projets consommateurs (qgis-sspcloud prefixe geo-map par 'geomap_' + cid[:8],
659
+ * d'autres pourraient utiliser cid entier ou 'map-' + cid).
660
+ *
661
+ * Ordre de resolution :
662
+ * 1. Match exact sur id HTML : #{target}
663
+ * 2. data-cid exact : [data-cid="{target}"]
664
+ * 3. Prefixes courants sur target[:8] : #geomap_{target[:8]}, #map-{target[:8]}
665
+ * 4. Contient le target (fuzzy) : [id*="{target}"] parmi les geo-map/geo-* connus
666
+ */
667
+ _resolveTarget(rawTarget) {
668
+ if (!rawTarget) return null;
669
+ const trimmed = String(rawTarget).trim();
670
+ if (!trimmed) return null;
671
+
672
+ // 1. Match exact
673
+ let el = document.querySelector("#" + CSS.escape(trimmed));
674
+ if (el) return el;
675
+
676
+ // 2. data-cid exact
677
+ el = document.querySelector('[data-cid="' + trimmed.replace(/"/g, '\\"') + '"]');
678
+ if (el) return el;
679
+
680
+ // 3. Prefixes courants sur les 8 premiers hex du target (convention cid[:8])
681
+ const short = trimmed.slice(0, 8);
682
+ for (const prefix of ["geomap_", "map-", "geo-map-"]) {
683
+ el = document.querySelector("#" + CSS.escape(prefix + short));
684
+ if (el) return el;
685
+ }
686
+
687
+ // 4. Fuzzy sur les Web Components de la lib qui exposent applyBinding
688
+ const candidates = document.querySelectorAll(
689
+ "geo-map, geo-chart, geo-timeline, geo-legend"
690
+ );
691
+ for (const c of candidates) {
692
+ const cid = c.getAttribute("data-cid") || c.id || "";
693
+ if (cid === trimmed || cid.includes(short) || trimmed.includes(cid)) {
694
+ return c;
695
+ }
696
+ }
697
+ return null;
698
+ }
699
+
700
+ /**
701
+ * Publie un event depuis un composant. Émet le CustomEvent local ET, si
702
+ * on est dans un iframe enfant, relaye vers le parent via postMessage.
703
+ */
704
+ static publish(sourceEl, detail) {
705
+ const event = new CustomEvent("geo:bind", {
706
+ bubbles: true,
707
+ composed: true,
708
+ detail,
709
+ });
710
+ sourceEl.dispatchEvent(event);
711
+ if (typeof window !== "undefined" && window.parent !== window) {
712
+ try {
713
+ window.parent.postMessage({ type: "geo:bind", detail }, "*");
714
+ } catch (e) {
715
+ /* cross-origin sandbox */
716
+ }
717
+ }
718
+ }
719
+ }
720
+
721
+ /** Singleton exporté. Auto-installé à la première utilisation d'un composant. */
722
+ export const geoBindings = new GeoBindings();
723
+
724
+ // ──────────────────────────────────────────────────────────────────────────
725
+ // 3. <geo-map> — Wrapper MapLibre reader thématique 2D
726
+ // ──────────────────────────────────────────────────────────────────────────
727
+
728
+ /**
729
+ * Custom Element <geo-map> — consomme InteractiveMapParams V1.13.
730
+ *
731
+ * Attributs HTML :
732
+ * - id (obligatoire pour bindings, standard HTML)
733
+ * - params (JSON string : InteractiveMapParams V1.13)
734
+ * - style (CSS ; height/width recommandés)
735
+ *
736
+ * Properties JS :
737
+ * - .params (object, alternative à l'attribut)
738
+ * - .hostContext (object, {study_bbox, commune_bbox, catalog_layers} pour
739
+ * résoudre les zones et layers by-id du contrat V1.13)
740
+ *
741
+ * API impérative (post-init) :
742
+ * - map : instance maplibregl.Map (accès brut)
743
+ * - setFilter(layerId, filter)
744
+ * - setPaintProperty(layerId, prop, value)
745
+ * - setLayoutProperty(layerId, prop, value)
746
+ * - fitBoundsTo(bbox)
747
+ * - setLayerVisibility(layerId, visible)
748
+ * - setLayerOpacity(layerId, opacity)
749
+ * - applyBinding({prop, value}) — appelé par l'orchestrateur
750
+ *
751
+ * Événements dispatch :
752
+ * - 'geo:map-ready' quand map.on('load') a résolu
753
+ * - 'geo:map-error' si MapLibre indisponible ou erreur init
754
+ */
755
+ export class GeoMap extends HTMLElement {
756
+ static get observedAttributes() {
757
+ return ["params"];
758
+ }
759
+
760
+ constructor() {
761
+ super();
762
+ this.attachShadow({ mode: "open" });
763
+ this.map = null;
764
+ this._layerIds = [];
765
+ this._params = {};
766
+ this._hostContext = {};
767
+ }
768
+
769
+ connectedCallback() {
770
+ geoBindings.install();
771
+ // Upgrade dance en connectedCallback (pas constructor) : la spec Custom
772
+ // Elements appelle attributeChangedCallback APRES le constructor lors de
773
+ // l'upgrade. Si on faisait la dance dans le constructor, notre restore
774
+ // via setter serait ecrase par attributeChangedCallback qui reparse
775
+ // l'attribut string (sans _catalog_layers). connectedCallback tourne
776
+ // APRES attributeChangedCallback, donc ici on peut sereinement re-appliquer
777
+ // les expandos posees par le script inline serveur-side (partial v2).
778
+ // On ecrit directement dans _params/_hostContext pour eviter le double
779
+ // _render() qu'un passage par le setter provoquerait.
780
+ for (const propName of ["params", "hostContext"]) {
781
+ if (Object.prototype.hasOwnProperty.call(this, propName)) {
782
+ const value = this[propName];
783
+ delete this[propName];
784
+ if (propName === "params") this._params = value || {};
785
+ else this._hostContext = value || {};
786
+ }
787
+ }
788
+ this._render();
789
+ }
790
+
791
+ disconnectedCallback() {
792
+ if (this.map) {
793
+ try { this.map.remove(); } catch (e) { /* ignore */ }
794
+ this.map = null;
795
+ }
796
+ }
797
+
798
+ attributeChangedCallback(name, oldVal, newVal) {
799
+ if (name === "params" && oldVal !== newVal) {
800
+ this._params = _parseParams(newVal);
801
+ if (this.isConnected) this._render();
802
+ }
803
+ }
804
+
805
+ // Properties JS
806
+ get params() { return this._params; }
807
+ set params(v) {
808
+ this._params = v || {};
809
+ if (this.isConnected) this._render();
810
+ }
811
+
812
+ get hostContext() { return this._hostContext; }
813
+ set hostContext(v) {
814
+ this._hostContext = v || {};
815
+ if (this.isConnected) this._render();
816
+ }
817
+
818
+ _render() {
819
+ // P1 correctness (review 2026-07-08) : libere l'instance MapLibre existante
820
+ // AVANT de reconstruire le DOM. Sans cela, chaque changement de params fuit
821
+ // un contexte WebGL (limite navigateur ~16 avant que la carte cesse d'afficher).
822
+ if (this.map) {
823
+ try { this.map.remove(); } catch (e) { /* ignore */ }
824
+ this.map = null;
825
+ this._layerIds = [];
826
+ }
827
+
828
+ // Merge attribut + property
829
+ if (!Object.keys(this._params).length) {
830
+ this._params = _parseParams(this.getAttribute("params"));
831
+ }
832
+
833
+ const params = this._params;
834
+ const shadow = this.shadowRoot;
835
+ shadow.innerHTML = "";
836
+
837
+ // Base styles Shadow DOM
838
+ const style = document.createElement("style");
839
+ style.textContent = `
840
+ :host { display: block; position: relative; width: 100%; }
841
+ .gc-wrap { background: #fff; border-radius: 6px; overflow: hidden;
842
+ border: 1px solid #e5e5e5; }
843
+ .gc-hdr { padding: 14px 20px; border-bottom: 1px solid #e5e5e5;
844
+ background: #f6f6f6; }
845
+ .gc-title { color: #000091; font-size: 15px; font-weight: 700;
846
+ display: block; font-family: Marianne, system-ui, sans-serif; }
847
+ .gc-subtitle { color: #444; font-size: 13px; margin-top: 2px;
848
+ font-family: Marianne, system-ui, sans-serif; }
849
+ .gc-desc { padding: 10px 20px; background: #fafbfd;
850
+ border-bottom: 1px solid #e5e5e5; font-size: 13px;
851
+ color: #333; line-height: 1.5;
852
+ font-family: Marianne, system-ui, sans-serif; }
853
+ .gc-map { width: 100%; }
854
+ .gc-src { padding: 8px 20px; background: #fafafa;
855
+ border-top: 1px solid #f0f0f0; font-size: 11px;
856
+ color: #666; font-style: italic;
857
+ font-family: Marianne, system-ui, sans-serif; }
858
+ .gc-caveat { padding: 8px 20px; background: #fff8e6;
859
+ border-top: 1px solid #ffe1a8; font-size: 11px;
860
+ color: #7a4b00;
861
+ font-family: Marianne, system-ui, sans-serif; }
862
+ .gc-fallback { padding: 40px; text-align: center; color: #666;
863
+ font-family: Marianne, system-ui, sans-serif; }
864
+ /* V1.20.6 : slot overlay pour timeline/legende in-map. Positionne
865
+ absolute sur la carte (au-dessus de la basemap + layers). L'element
866
+ insere dans ce slot est ancre visuellement a la carte, quel que
867
+ soit le contexte hote (storymap, widget Grist Atlas, ...). */
868
+ .gc-overlay { position: absolute; left: 16px; right: 16px; bottom: 16px;
869
+ z-index: 5; pointer-events: none; }
870
+ .gc-overlay ::slotted(*) { pointer-events: auto; }
871
+ `;
872
+ shadow.appendChild(style);
873
+
874
+ const wrap = document.createElement("div");
875
+ wrap.className = "gc-wrap";
876
+ wrap.style.position = "relative";
877
+ shadow.appendChild(wrap);
878
+
879
+ // Trio TITRE + description
880
+ if (params.title || params.subtitle) {
881
+ const hdr = document.createElement("div");
882
+ hdr.className = "gc-hdr";
883
+ if (params.title) {
884
+ const t = document.createElement("strong");
885
+ t.className = "gc-title";
886
+ t.textContent = params.title;
887
+ hdr.appendChild(t);
888
+ }
889
+ if (params.subtitle) {
890
+ const s = document.createElement("div");
891
+ s.className = "gc-subtitle";
892
+ s.textContent = params.subtitle;
893
+ hdr.appendChild(s);
894
+ }
895
+ wrap.appendChild(hdr);
896
+ }
897
+ if (params.description) {
898
+ const d = document.createElement("div");
899
+ d.className = "gc-desc";
900
+ d.textContent = params.description;
901
+ wrap.appendChild(d);
902
+ }
903
+
904
+ // Canvas map
905
+ const height = params.height || 480;
906
+ const mapDiv = document.createElement("div");
907
+ mapDiv.className = "gc-map";
908
+ mapDiv.style.height = height + "px";
909
+ mapDiv.style.position = "relative";
910
+ wrap.appendChild(mapDiv);
911
+
912
+ // V1.20.6 : slot overlay (position absolute au-dessus du canvas MapLibre).
913
+ // Un <geo-timeline slot="overlay"> insere dans le light DOM du <geo-map>
914
+ // sera rendu en overlay ancre visuellement sur la carte (pattern StoryMap
915
+ // ESRI / Kepler). Cross-projet : le meme HTML fonctionne dans une storymap
916
+ // qgis-sspcloud, un widget Grist Atlas, une single page HTML embed.
917
+ const overlay = document.createElement("div");
918
+ overlay.className = "gc-overlay";
919
+ const slot = document.createElement("slot");
920
+ slot.setAttribute("name", "overlay");
921
+ overlay.appendChild(slot);
922
+ mapDiv.appendChild(overlay);
923
+
924
+ // Trio SOURCE datée
925
+ if (params.source) {
926
+ const src = document.createElement("div");
927
+ src.className = "gc-src";
928
+ src.textContent = "Source : " + params.source;
929
+ wrap.appendChild(src);
930
+ }
931
+ if (params.caveat) {
932
+ const cav = document.createElement("div");
933
+ cav.className = "gc-caveat";
934
+ cav.innerHTML =
935
+ '<strong style="color:#b34000">⚠ Caveat :</strong> ' +
936
+ _escapeHtml(params.caveat);
937
+ wrap.appendChild(cav);
938
+ }
939
+
940
+ // Init MapLibre
941
+ const ml = _detectMapLibre();
942
+ if (!ml) {
943
+ mapDiv.innerHTML =
944
+ '<div class="gc-fallback">MapLibre GL JS indisponible. ' +
945
+ "Vérifiez le chargement de maplibre-gl@^5.6.1 dans la page hôte.</div>";
946
+ this.dispatchEvent(new CustomEvent("geo:map-error", {
947
+ detail: { reason: "maplibre_missing" },
948
+ }));
949
+ return;
950
+ }
951
+
952
+ try {
953
+ this._initMap(ml, mapDiv, params);
954
+ } catch (e) {
955
+ console.error("[geo-components] Init map failed:", e);
956
+ mapDiv.innerHTML =
957
+ '<div class="gc-fallback">Erreur d\'initialisation de la carte.</div>';
958
+ this.dispatchEvent(new CustomEvent("geo:map-error", {
959
+ detail: { reason: "init_failed", error: String(e) },
960
+ }));
961
+ }
962
+ }
963
+
964
+ _initMap(ml, container, params) {
965
+ // Résolution zone
966
+ const zone = _resolveZone(params.zone, this._hostContext);
967
+
968
+ // Basemap
969
+ const basemapId = params.basemap_id || "osm";
970
+ const basemap = BASEMAPS[basemapId] || BASEMAPS["osm"];
971
+
972
+ this.map = new ml.Map({
973
+ container,
974
+ style: basemap.style,
975
+ center: zone.center,
976
+ zoom: zone.zoom,
977
+ });
978
+ this.map.addControl(new ml.NavigationControl({ showCompass: false }), "top-left");
979
+
980
+ const layersOverride = params.layers_override || [];
981
+ const catalogLayers =
982
+ this._hostContext.catalog_layers || params._catalog_layers || [];
983
+
984
+ this._layerIds = [];
985
+
986
+ // P2 contract-drift V1.13 (review 2026-07-08) : respecter LayerOverride.z_index.
987
+ // Le contrat V1.13 declare z_index (int) pour reordonner les layers a
988
+ // l'affichage. On trie une copie des catalogLayers par z_index (fallback
989
+ // ordre du catalog).
990
+ const orderedLayers = catalogLayers
991
+ .map((scene, i) => {
992
+ const override = layersOverride.find((o) => o.layer_id_ref === scene.id) || null;
993
+ return { scene, override, catalogIndex: i };
994
+ })
995
+ .sort((a, b) => {
996
+ const az = a.override?.z_index ?? a.catalogIndex;
997
+ const bz = b.override?.z_index ?? b.catalogIndex;
998
+ return az - bz;
999
+ });
1000
+
1001
+ // V1.20.5 patch (2026-07-08) : si le style basemap est deja charge
1002
+ // (typiquement lors d'un re-render apres set params), appliquer les
1003
+ // layers immediatement au lieu d'attendre l'event "load" qui ne
1004
+ // tirera jamais. Sinon on attache le callback normal.
1005
+ const applyLayers = () => {
1006
+ // Ajout des layers depuis catalog_layers, avec overrides appliqués
1007
+ orderedLayers.forEach(({ scene: sceneLayer, override, catalogIndex: i }) => {
1008
+ if (override && override.visible === false) return;
1009
+
1010
+ const safeId = _sanitizeId(sceneLayer.id || String(i));
1011
+ const sourceId = "gc-src-" + safeId;
1012
+ try {
1013
+ this.map.addSource(sourceId, {
1014
+ type: "geojson",
1015
+ data: sceneLayer.geojson || { type: "FeatureCollection", features: [] },
1016
+ });
1017
+
1018
+ // V0.2.0 Chantier 4 : détection type + auto-split polygon+point
1019
+ // Analyse la geojson pour détecter FC hétérogène et éviter le bug
1020
+ // POINT invisibles (remontée cerema-livrables).
1021
+ const analysis = _analyzeGeojson(sceneLayer.geojson);
1022
+ const isMixedFC = analysis.isMixed && analysis.hasPolygon && analysis.hasPoint;
1023
+
1024
+ if (isMixedFC) {
1025
+ // FC mixte polygone+point : rendre 2 layers distincts sur même source
1026
+ // - un layer 'fill' avec filter Polygon
1027
+ // - un layer 'circle' avec filter Point
1028
+ const fillLayer = _buildMapLibreLayer(
1029
+ { ...sceneLayer, geometry_type: "polygon" },
1030
+ override, i
1031
+ );
1032
+ fillLayer.filter = ["match", ["geometry-type"], ["Polygon", "MultiPolygon"], true, false];
1033
+ this.map.addLayer(fillLayer);
1034
+ this._layerIds.push(fillLayer.id);
1035
+ this._wireInteractions(ml, fillLayer.id, sceneLayer, override);
1036
+
1037
+ const pointLayer = _buildMapLibreLayer(
1038
+ { ...sceneLayer, geometry_type: "point", id: sceneLayer.id + "-pt" },
1039
+ override, i
1040
+ );
1041
+ pointLayer.filter = ["match", ["geometry-type"], ["Point", "MultiPoint"], true, false];
1042
+ this.map.addLayer(pointLayer);
1043
+ this._layerIds.push(pointLayer.id);
1044
+ this._wireInteractions(ml, pointLayer.id, sceneLayer, override);
1045
+ } else {
1046
+ // Cas standard : 1 seul layer, avec hollow_point si demandé
1047
+ let layerDef;
1048
+ const styleOverride = override || {};
1049
+ if (
1050
+ styleOverride.hollow_point &&
1051
+ styleOverride.hollow_point.enabled &&
1052
+ (_detectGeomType(sceneLayer.geometry_type, sceneLayer.geojson) === "point")
1053
+ ) {
1054
+ // V0.3.1 style.hollow_point : construction directe du circle creux
1055
+ const fallbackColor = DEFAULT_PALETTE[i % DEFAULT_PALETTE.length];
1056
+ const classif = (override && override.classification) || sceneLayer.classification;
1057
+ const themedColor = _paintForClassification(classif, fallbackColor);
1058
+ const opacity = override?.opacity ?? 1.0;
1059
+ layerDef = _buildHollowPointLayer(
1060
+ "gc-lyr-" + safeId, sourceId,
1061
+ styleOverride.hollow_point,
1062
+ themedColor, opacity
1063
+ );
1064
+ } else {
1065
+ layerDef = _buildMapLibreLayer(sceneLayer, override, i);
1066
+ }
1067
+ this.map.addLayer(layerDef);
1068
+ this._layerIds.push(layerDef.id);
1069
+
1070
+ // V0.3.1 style.outline : ajout automatique layer 'line' sur même source
1071
+ if (
1072
+ styleOverride.outline &&
1073
+ styleOverride.outline.enabled &&
1074
+ (layerDef.type === "fill" || layerDef.type === "fill-extrusion")
1075
+ ) {
1076
+ const opacity = override?.opacity ?? 1.0;
1077
+ const outlineLayer = _buildOutlineLayer(
1078
+ layerDef.id, sourceId,
1079
+ styleOverride.outline, opacity
1080
+ );
1081
+ this.map.addLayer(outlineLayer);
1082
+ this._layerIds.push(outlineLayer.id);
1083
+ }
1084
+
1085
+ // Interactions : hover / popup
1086
+ this._wireInteractions(ml, layerDef.id, sceneLayer, override);
1087
+ }
1088
+ } catch (e) {
1089
+ console.warn(
1090
+ "[geo-components] addLayer failed for",
1091
+ sceneLayer.id, "->", safeId,
1092
+ e
1093
+ );
1094
+ }
1095
+ });
1096
+
1097
+ // fitBounds auto sur zone.bbox ou premier layer avec features
1098
+ if (zone.bbox) {
1099
+ this.map.fitBounds(zone.bbox, { padding: 40, maxZoom: 16 });
1100
+ } else if (catalogLayers.length && catalogLayers[0].geojson) {
1101
+ try {
1102
+ const b = new ml.LngLatBounds();
1103
+ (catalogLayers[0].geojson.features || []).forEach((f) => {
1104
+ const walk = (c) => {
1105
+ if (typeof c[0] === "number") { b.extend(c); return; }
1106
+ c.forEach(walk);
1107
+ };
1108
+ walk((f.geometry && f.geometry.coordinates) || []);
1109
+ });
1110
+ if (!b.isEmpty()) this.map.fitBounds(b, { padding: 40, maxZoom: 16 });
1111
+ } catch (e) { /* ignore */ }
1112
+ }
1113
+
1114
+ this.dispatchEvent(new CustomEvent("geo:map-ready", {
1115
+ detail: { map: this.map, layerIds: this._layerIds },
1116
+ }));
1117
+ };
1118
+ // V1.20.5 patch : evite race condition apres re-render (setter params
1119
+ // ou hostContext). Si le style basemap est deja charge, applique
1120
+ // immediatement ; sinon attend l'event "load" comme d'habitude.
1121
+ if (this.map.isStyleLoaded && this.map.isStyleLoaded()) {
1122
+ applyLayers();
1123
+ } else {
1124
+ this.map.on("load", applyLayers);
1125
+ }
1126
+ }
1127
+
1128
+ _wireInteractions(ml, layerId, sceneLayer, override) {
1129
+ const hoverAttrs = (override && override.hover_attributes) || [];
1130
+ const popupTpl = override && override.popup_template;
1131
+ const tooltipField = override && override.tooltip_field;
1132
+ // P2 contract-drift V1.13 (review 2026-07-08) : name_override utilise dans
1133
+ // le titre du popup a la place du nom brut du scene_layer.
1134
+ const displayName =
1135
+ (override && override.name_override) || sceneLayer.name || "Feature";
1136
+
1137
+ // P2 contract-drift V1.13 : tooltip_field (attribut unique quick tooltip)
1138
+ // Prioritaire sur hover_attributes si les deux sont definis : tooltip_field
1139
+ // etant "quick label", hover_attributes est le "tooltip etendu".
1140
+ if (tooltipField && !hoverAttrs.length) {
1141
+ const quickTip = new ml.Popup({
1142
+ closeButton: false, closeOnClick: false, maxWidth: "220px",
1143
+ });
1144
+ this.map.on("mousemove", layerId, (e) => {
1145
+ this.map.getCanvas().style.cursor = "pointer";
1146
+ const v = e.features[0]?.properties?.[tooltipField];
1147
+ if (v !== undefined && v !== null) {
1148
+ quickTip
1149
+ .setLngLat(e.lngLat)
1150
+ .setHTML('<div style="font-size:12px">' + _escapeHtml(String(v)) + "</div>")
1151
+ .addTo(this.map);
1152
+ }
1153
+ });
1154
+ this.map.on("mouseleave", layerId, () => {
1155
+ this.map.getCanvas().style.cursor = "";
1156
+ quickTip.remove();
1157
+ });
1158
+ }
1159
+
1160
+ // Tooltip hover etendu (hover_attributes whitelist)
1161
+ if (hoverAttrs.length) {
1162
+ const tooltip = new ml.Popup({
1163
+ closeButton: false, closeOnClick: false, maxWidth: "240px",
1164
+ });
1165
+ this.map.on("mousemove", layerId, (e) => {
1166
+ this.map.getCanvas().style.cursor = "pointer";
1167
+ const props = (e.features[0] && e.features[0].properties) || {};
1168
+ const rows = hoverAttrs
1169
+ .filter((a) => props[a] !== undefined)
1170
+ .map((a) =>
1171
+ '<div style="padding:2px 0"><strong>' +
1172
+ _escapeHtml(a) + "</strong> : " +
1173
+ _escapeHtml(String(props[a]).slice(0, 80)) + "</div>"
1174
+ )
1175
+ .join("");
1176
+ if (rows) {
1177
+ tooltip
1178
+ .setLngLat(e.lngLat)
1179
+ .setHTML('<div style="font-size:12px">' + rows + "</div>")
1180
+ .addTo(this.map);
1181
+ }
1182
+ });
1183
+ this.map.on("mouseleave", layerId, () => {
1184
+ this.map.getCanvas().style.cursor = "";
1185
+ tooltip.remove();
1186
+ });
1187
+ }
1188
+
1189
+ // Popup click
1190
+ const popup = new ml.Popup({ closeButton: true, maxWidth: "320px" });
1191
+ this.map.on("click", layerId, (e) => {
1192
+ const props = (e.features[0] && e.features[0].properties) || {};
1193
+ let html;
1194
+ if (popupTpl) {
1195
+ html = _renderPopup(popupTpl, props);
1196
+ } else {
1197
+ const entries = Object.entries(props).slice(0, 10);
1198
+ html =
1199
+ '<div style="font-size:13px"><strong style="color:#000091">' +
1200
+ _escapeHtml(displayName) + "</strong>" +
1201
+ entries
1202
+ .map(
1203
+ ([k, v]) =>
1204
+ '<div style="padding:3px 0;border-top:1px solid #eee;margin-top:3px">' +
1205
+ '<span style="color:#666;font-size:11px">' + _escapeHtml(k) +
1206
+ "</span><br><strong>" +
1207
+ _escapeHtml(String(v).slice(0, 120)) + "</strong></div>"
1208
+ )
1209
+ .join("") + "</div>";
1210
+ }
1211
+ popup.setLngLat(e.lngLat).setHTML(html).addTo(this.map);
1212
+ });
1213
+ }
1214
+
1215
+ // ── API impérative ─────────────────────────────────────────────────────
1216
+ // P1 correctness (review 2026-07-08) : chaque methode guarde l'existence du
1217
+ // layer via getLayer() pour eviter les crashes MapLibre "The layer X does
1218
+ // not exist in the map's style" (typiquement sur ids stales, layer
1219
+ // supprime, ou binding recu avant map.on('load')).
1220
+
1221
+ setFilter(layerId, filter) {
1222
+ if (!this.map || !this.map.getLayer(layerId)) return;
1223
+ this.map.setFilter(layerId, filter);
1224
+ }
1225
+ setPaintProperty(layerId, prop, value) {
1226
+ if (!this.map || !this.map.getLayer(layerId)) return;
1227
+ this.map.setPaintProperty(layerId, prop, value);
1228
+ }
1229
+ setLayoutProperty(layerId, prop, value) {
1230
+ if (!this.map || !this.map.getLayer(layerId)) return;
1231
+ this.map.setLayoutProperty(layerId, prop, value);
1232
+ }
1233
+ fitBoundsTo(bbox) {
1234
+ if (this.map && bbox && bbox.length === 4) {
1235
+ this.map.fitBounds(bbox, { padding: 40, maxZoom: 16 });
1236
+ }
1237
+ }
1238
+ setLayerVisibility(layerId, visible) {
1239
+ if (!this.map || !this.map.getLayer(layerId)) return;
1240
+ this.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none");
1241
+ }
1242
+ setLayerOpacity(layerId, opacity) {
1243
+ if (!this.map) return;
1244
+ const layer = this.map.getLayer(layerId);
1245
+ if (!layer) return;
1246
+ const paintProp = _colorPaintPropFor(layer.type, "opacity");
1247
+ if (paintProp) this.map.setPaintProperty(layerId, paintProp, opacity);
1248
+ }
1249
+
1250
+ /**
1251
+ * Change la couleur d'un layer selon son type (fill/circle/line).
1252
+ * P1 correctness (review 2026-07-08) : evite crash sur layers non-polygone.
1253
+ */
1254
+ setLayerColor(layerId, color) {
1255
+ if (!this.map) return;
1256
+ const layer = this.map.getLayer(layerId);
1257
+ if (!layer) return;
1258
+ const paintProp = _colorPaintPropFor(layer.type, "color");
1259
+ if (paintProp) this.map.setPaintProperty(layerId, paintProp, color);
1260
+ else console.warn("[geo-components] setLayerColor: type layer non supporte:", layer.type);
1261
+ }
1262
+
1263
+ /**
1264
+ * Point d'entrée de l'orchestrateur bindings. Applique un binding reçu.
1265
+ */
1266
+ applyBinding(detail) {
1267
+ const { prop, value } = detail || {};
1268
+ if (!this.map || !prop) return;
1269
+ const primaryLayer = this._layerIds[0];
1270
+
1271
+ switch (prop) {
1272
+ case "time": {
1273
+ // Filtre temporel : ['<=', ['get', 'annee'], year]
1274
+ const year = _asYear(value);
1275
+ if (year != null && primaryLayer) {
1276
+ const timeField = detail.field || "annee";
1277
+ this.map.setFilter(primaryLayer, ["<=", ["get", timeField], year]);
1278
+ }
1279
+ break;
1280
+ }
1281
+ case "scenario":
1282
+ // P1 correctness (review 2026-07-08) : utiliser setLayerColor qui
1283
+ // detecte le type du layer (fill/circle/line) au lieu de hard-coder
1284
+ // fill-color (crashait sur circle/line).
1285
+ if (detail.style_map && primaryLayer) {
1286
+ const color = detail.style_map[value];
1287
+ if (color) this.setLayerColor(primaryLayer, color);
1288
+ }
1289
+ break;
1290
+ case "filter":
1291
+ if (primaryLayer && value) this.setFilter(primaryLayer, value);
1292
+ break;
1293
+ case "bbox":
1294
+ this.fitBoundsTo(value);
1295
+ break;
1296
+ case "layers":
1297
+ if (value && typeof value === "object") {
1298
+ Object.entries(value).forEach(([lid, visible]) => {
1299
+ this.setLayerVisibility(lid, !!visible);
1300
+ });
1301
+ }
1302
+ break;
1303
+ case "opacity":
1304
+ if (value && typeof value === "object") {
1305
+ Object.entries(value).forEach(([lid, opacity]) => {
1306
+ this.setLayerOpacity(lid, +opacity);
1307
+ });
1308
+ }
1309
+ break;
1310
+ default:
1311
+ console.warn("[geo-components] Unknown binding prop:", prop);
1312
+ }
1313
+ }
1314
+ }
1315
+
1316
+ /** Utilitaire : normalise une valeur de binding "time" en année entière. */
1317
+ function _asYear(v) {
1318
+ if (v == null) return null;
1319
+ if (typeof v === "number") return Math.floor(v);
1320
+ const s = String(v);
1321
+ const m = s.match(/(\d{4})/);
1322
+ return m ? +m[1] : null;
1323
+ }
1324
+
1325
+ // ──────────────────────────────────────────────────────────────────────────
1326
+ // 4. <geo-timeline> — Controller slider temporel
1327
+ // ──────────────────────────────────────────────────────────────────────────
1328
+
1329
+ /**
1330
+ * Custom Element <geo-timeline> — slider avec play/pause/step. Émet des
1331
+ * events geo:bind {prop:'time', value} pour piloter un <geo-map> target.
1332
+ *
1333
+ * Attributs HTML :
1334
+ * - id (obligatoire)
1335
+ * - min (année ou date ISO)
1336
+ * - max
1337
+ * - step (défaut 1)
1338
+ * - value (initial ; défaut = min)
1339
+ * - target (id du <geo-map> à piloter)
1340
+ * - field (champ GeoJSON à filtrer ; défaut 'annee')
1341
+ * - format ('year' | 'date-fr' ; défaut 'year')
1342
+ * - play-speed (multiplier vitesse play ; défaut '1x')
1343
+ * - label (texte affiché à gauche du slider)
1344
+ */
1345
+ export class GeoTimeline extends HTMLElement {
1346
+ static get observedAttributes() {
1347
+ return ["min", "max", "step", "value", "target", "field", "format", "label"];
1348
+ }
1349
+
1350
+ constructor() {
1351
+ super();
1352
+ this.attachShadow({ mode: "open" });
1353
+ this._playing = false;
1354
+ this._playTimer = null;
1355
+ }
1356
+
1357
+ connectedCallback() {
1358
+ geoBindings.install();
1359
+ this._render();
1360
+ // V1.20.6 : auto-anchor overlay in-map. Si mode="overlay" (default) et
1361
+ // que le target resout localement a un <geo-map>, se deplacer dans le
1362
+ // light DOM de la carte avec slot="overlay" pour un rendu ancre visuellement.
1363
+ // Sinon (cross-iframe : target dans un autre document), rester en flow
1364
+ // et compter sur postMessage via GeoBindings.
1365
+ this._autoAnchor();
1366
+ }
1367
+
1368
+ disconnectedCallback() {
1369
+ this._stopPlay();
1370
+ }
1371
+
1372
+ _autoAnchor() {
1373
+ // Deja ancre (setAttribute slot deja fait), pas de re-move.
1374
+ if (this.getAttribute("slot") === "overlay") return;
1375
+ const mode = this.getAttribute("mode") || "overlay";
1376
+ if (mode !== "overlay") return;
1377
+ const rawTarget = this.getAttribute("target");
1378
+ if (!rawTarget) return;
1379
+ // Utilise le resolveur tolerant de GeoBindings (multi-conventions ID).
1380
+ const targetEl = geoBindings._resolveTarget
1381
+ ? geoBindings._resolveTarget(rawTarget)
1382
+ : document.querySelector("#" + CSS.escape(rawTarget));
1383
+ if (!targetEl || targetEl.tagName !== "GEO-MAP") return;
1384
+ // Cacher toute la section source (evite titre "Timeline" orphelin et
1385
+ // gros espace vide). Prefere story-section (ancetre du story-component)
1386
+ // pour supprimer aussi le h2 titre. Fallback : parent direct.
1387
+ let hideCandidate = this.parentElement;
1388
+ while (hideCandidate && hideCandidate !== document.body) {
1389
+ const cls = hideCandidate.className || "";
1390
+ if (typeof cls === "string" && /\bstory-section\b/.test(cls)) break;
1391
+ hideCandidate = hideCandidate.parentElement;
1392
+ }
1393
+ if (hideCandidate && hideCandidate !== document.body) {
1394
+ hideCandidate.setAttribute("data-gc-overlay-source", "1");
1395
+ hideCandidate.style.display = "none";
1396
+ }
1397
+ // Move dans le light DOM du <geo-map> : le slot name="overlay" cote
1398
+ // shadow DOM va le rendre en position absolute sur la carte.
1399
+ this.setAttribute("slot", "overlay");
1400
+ // Cosmetique : override styles pour un look overlay (fond opaque,
1401
+ // shadow subtile) le composant restant lisible sur la basemap.
1402
+ this.style.pointerEvents = "auto";
1403
+ this.style.boxShadow = "0 2px 8px rgba(0,0,0,0.15)";
1404
+ targetEl.appendChild(this); // move DOM : trigger disconnectedCallback + connectedCallback
1405
+ }
1406
+
1407
+ attributeChangedCallback() {
1408
+ if (this.isConnected) this._render();
1409
+ }
1410
+
1411
+ _render() {
1412
+ const shadow = this.shadowRoot;
1413
+ shadow.innerHTML = "";
1414
+
1415
+ const min = +(this.getAttribute("min") || 1900);
1416
+ const max = +(this.getAttribute("max") || 2024);
1417
+ const step = +(this.getAttribute("step") || 1);
1418
+ const value = +(this.getAttribute("value") || min);
1419
+ const label = this.getAttribute("label") || "Année";
1420
+
1421
+ const style = document.createElement("style");
1422
+ style.textContent = `
1423
+ :host { display: block; padding: 10px 14px; background: #fff;
1424
+ border: 1px solid #e5e5e5; border-radius: 6px;
1425
+ font-family: Marianne, system-ui, sans-serif; }
1426
+ .row { display: flex; align-items: center; gap: 10px; }
1427
+ .lbl { font-size: 12px; color: #666; text-transform: uppercase;
1428
+ letter-spacing: 0.5px; font-weight: 600; }
1429
+ .btn { padding: 4px 10px; background: #000091; color: #fff;
1430
+ border: none; border-radius: 3px; cursor: pointer;
1431
+ font-family: inherit; font-size: 13px; font-weight: 600; }
1432
+ .btn:hover { background: #1212a3; }
1433
+ .val { color: #000091; font-weight: 700; font-size: 14px;
1434
+ min-width: 60px; text-align: center; }
1435
+ input[type=range] { flex: 1; accent-color: #000091; }
1436
+ `;
1437
+ shadow.appendChild(style);
1438
+
1439
+ const row = document.createElement("div");
1440
+ row.className = "row";
1441
+
1442
+ const l = document.createElement("span");
1443
+ l.className = "lbl";
1444
+ l.textContent = label;
1445
+ row.appendChild(l);
1446
+
1447
+ const btn = document.createElement("button");
1448
+ btn.className = "btn";
1449
+ btn.textContent = "▶";
1450
+ btn.setAttribute("aria-label", "Play / Pause");
1451
+ btn.addEventListener("click", () => {
1452
+ if (this._playing) this._stopPlay(); else this._startPlay();
1453
+ btn.textContent = this._playing ? "⏸" : "▶";
1454
+ });
1455
+ row.appendChild(btn);
1456
+
1457
+ const range = document.createElement("input");
1458
+ range.type = "range";
1459
+ range.min = min;
1460
+ range.max = max;
1461
+ range.step = step;
1462
+ range.value = value;
1463
+ range.setAttribute("aria-label", label);
1464
+ range.addEventListener("input", (e) => {
1465
+ this._publish(+e.target.value);
1466
+ valEl.textContent = this._format(+e.target.value);
1467
+ });
1468
+ row.appendChild(range);
1469
+
1470
+ const valEl = document.createElement("span");
1471
+ valEl.className = "val";
1472
+ valEl.textContent = this._format(value);
1473
+ row.appendChild(valEl);
1474
+
1475
+ shadow.appendChild(row);
1476
+
1477
+ this._range = range;
1478
+ this._valEl = valEl;
1479
+
1480
+ // Publie la valeur initiale pour synchroniser le target
1481
+ this._publish(value);
1482
+ }
1483
+
1484
+ _publish(value) {
1485
+ const target = this.getAttribute("target") || "";
1486
+ const field = this.getAttribute("field") || "annee";
1487
+ if (!target) return;
1488
+ GeoBindings.publish(this, {
1489
+ from: this.id,
1490
+ target,
1491
+ prop: "time",
1492
+ field,
1493
+ value,
1494
+ });
1495
+ }
1496
+
1497
+ _format(v) {
1498
+ const format = this.getAttribute("format") || "year";
1499
+ if (format === "year") return String(Math.floor(v));
1500
+ if (format === "date-fr") {
1501
+ try {
1502
+ return new Date(v).toLocaleDateString("fr-FR");
1503
+ } catch (e) { return String(v); }
1504
+ }
1505
+ return String(v);
1506
+ }
1507
+
1508
+ _startPlay() {
1509
+ const speed = this.getAttribute("play-speed") || "1x";
1510
+ const intervalMs =
1511
+ speed === "0.5x" ? 800 :
1512
+ speed === "2x" ? 200 :
1513
+ speed === "4x" ? 100 : 400;
1514
+ this._playing = true;
1515
+ this._playTimer = setInterval(() => {
1516
+ const step = +(this.getAttribute("step") || 1);
1517
+ const max = +(this.getAttribute("max") || 2024);
1518
+ let v = +this._range.value + step;
1519
+ if (v > max) v = +this.getAttribute("min");
1520
+ this._range.value = v;
1521
+ this._valEl.textContent = this._format(v);
1522
+ this._publish(v);
1523
+ }, intervalMs);
1524
+ }
1525
+
1526
+ _stopPlay() {
1527
+ this._playing = false;
1528
+ if (this._playTimer) {
1529
+ clearInterval(this._playTimer);
1530
+ this._playTimer = null;
1531
+ }
1532
+ }
1533
+ }
1534
+
1535
+ // ──────────────────────────────────────────────────────────────────────────
1536
+ // 5. <geo-legend> — Légende métier CEREMA
1537
+ // ──────────────────────────────────────────────────────────────────────────
1538
+
1539
+ /**
1540
+ * Custom Element <geo-legend> — portage 1:1 du partial _legend_partial.j2 de
1541
+ * qgis-sspcloud. 3 formats supportés : chips (défaut), gradient_bar,
1542
+ * proportional.
1543
+ *
1544
+ * Attributs HTML :
1545
+ * - format ('chips'|'gradient_bar'|'proportional' ; défaut 'chips')
1546
+ * - items (JSON string : [{label, color, count?, size?}])
1547
+ * - position ('bottom'|'right'|'floating' ; défaut 'bottom')
1548
+ * - title (défaut 'Légende')
1549
+ */
1550
+ export class GeoLegend extends HTMLElement {
1551
+ static get observedAttributes() {
1552
+ return ["format", "items", "position", "title"];
1553
+ }
1554
+
1555
+ constructor() {
1556
+ super();
1557
+ this.attachShadow({ mode: "open" });
1558
+ }
1559
+
1560
+ connectedCallback() {
1561
+ this._render();
1562
+ }
1563
+
1564
+ attributeChangedCallback() {
1565
+ if (this.isConnected) this._render();
1566
+ }
1567
+
1568
+ _render() {
1569
+ const shadow = this.shadowRoot;
1570
+ shadow.innerHTML = "";
1571
+
1572
+ const format = this.getAttribute("format") || "chips";
1573
+ const title = this.getAttribute("title") || "Légende";
1574
+ // P2 contract-drift V1.13 (review 2026-07-08) : position bottom|right|floating.
1575
+ const position = this.getAttribute("position") || "bottom";
1576
+ let items = [];
1577
+ try {
1578
+ items = JSON.parse(this.getAttribute("items") || "[]");
1579
+ } catch (e) {
1580
+ console.warn("[geo-components] geo-legend items JSON invalide:", e);
1581
+ }
1582
+
1583
+ // Positionnement selon attribut position (V1.13)
1584
+ const positionStyles = {
1585
+ bottom: ":host { position: relative; }",
1586
+ right: ":host { position: relative; max-width: 300px; }",
1587
+ floating:
1588
+ ":host { position: absolute; bottom: 20px; left: 20px; z-index: 10; " +
1589
+ "box-shadow: 0 2px 8px rgba(0,0,0,0.15); background: rgba(255,255,255,0.96); }",
1590
+ };
1591
+
1592
+ const style = document.createElement("style");
1593
+ style.textContent = (positionStyles[position] || positionStyles.bottom) + `
1594
+ :host { display: block; padding: 12px 20px; background: #fafafa;
1595
+ border: 1px solid #e5e5e5; border-radius: 6px;
1596
+ font-family: Marianne, system-ui, sans-serif;
1597
+ font-size: 12px; color: #444; }
1598
+ .ttl { color: #000091; text-transform: uppercase; font-size: 11px;
1599
+ letter-spacing: 0.5px; font-weight: 700; display: block;
1600
+ margin-bottom: 8px; }
1601
+ .chips { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; }
1602
+ .chip { display: inline-flex; align-items: center; gap: 6px; }
1603
+ .swatch { width: 14px; height: 14px; border-radius: 3px;
1604
+ border: 1px solid rgba(0,0,0,0.1); }
1605
+ .cnt { color: #888; font-style: italic; }
1606
+ .grad { display: flex; height: 14px; border-radius: 3px; overflow: hidden;
1607
+ border: 1px solid rgba(0,0,0,0.1); max-width: 520px; }
1608
+ .grad > div { flex: 1; }
1609
+ .grad-labels { display: flex; font-size: 10px; color: #666;
1610
+ margin-top: 4px; max-width: 520px; }
1611
+ .grad-labels > div { flex: 1; text-align: center; padding: 0 2px; }
1612
+ .prop { display: flex; align-items: flex-end; gap: 14px; flex-wrap: wrap; }
1613
+ .prop-item { display: flex; flex-direction: column;
1614
+ align-items: center; gap: 2px; }
1615
+ .prop-circle { border-radius: 50%; border: 1px solid #fff;
1616
+ box-shadow: 0 0 0 1px rgba(0,0,0,0.2); opacity: 0.75; }
1617
+ `;
1618
+ shadow.appendChild(style);
1619
+
1620
+ const t = document.createElement("strong");
1621
+ t.className = "ttl";
1622
+ t.textContent = title;
1623
+ shadow.appendChild(t);
1624
+
1625
+ if (format === "gradient_bar") {
1626
+ const bar = document.createElement("div");
1627
+ bar.className = "grad";
1628
+ items.forEach((it) => {
1629
+ const div = document.createElement("div");
1630
+ div.style.background = it.color;
1631
+ bar.appendChild(div);
1632
+ });
1633
+ shadow.appendChild(bar);
1634
+ const labels = document.createElement("div");
1635
+ labels.className = "grad-labels";
1636
+ items.forEach((it) => {
1637
+ const div = document.createElement("div");
1638
+ div.textContent = it.label || "";
1639
+ labels.appendChild(div);
1640
+ });
1641
+ shadow.appendChild(labels);
1642
+ } else if (format === "proportional") {
1643
+ const wrap = document.createElement("div");
1644
+ wrap.className = "prop";
1645
+ items.forEach((it) => {
1646
+ const item = document.createElement("div");
1647
+ item.className = "prop-item";
1648
+ const size = it.size || 20;
1649
+ const circ = document.createElement("div");
1650
+ circ.className = "prop-circle";
1651
+ circ.style.width = size + "px";
1652
+ circ.style.height = size + "px";
1653
+ circ.style.background = it.color;
1654
+ item.appendChild(circ);
1655
+ const lbl = document.createElement("span");
1656
+ lbl.style.fontSize = "10px";
1657
+ lbl.style.color = "#666";
1658
+ lbl.textContent = it.label || "";
1659
+ item.appendChild(lbl);
1660
+ wrap.appendChild(item);
1661
+ });
1662
+ shadow.appendChild(wrap);
1663
+ } else {
1664
+ // chips
1665
+ const wrap = document.createElement("div");
1666
+ wrap.className = "chips";
1667
+ items.forEach((it) => {
1668
+ const chip = document.createElement("span");
1669
+ chip.className = "chip";
1670
+ const sw = document.createElement("span");
1671
+ sw.className = "swatch";
1672
+ sw.style.background = it.color;
1673
+ chip.appendChild(sw);
1674
+ const txt = document.createElement("span");
1675
+ txt.textContent = it.label || "";
1676
+ if (it.count !== undefined && it.count !== null) {
1677
+ const em = document.createElement("em");
1678
+ em.className = "cnt";
1679
+ em.textContent = " (" + it.count + ")";
1680
+ txt.appendChild(em);
1681
+ }
1682
+ chip.appendChild(txt);
1683
+ wrap.appendChild(chip);
1684
+ });
1685
+ shadow.appendChild(wrap);
1686
+ }
1687
+ }
1688
+ }
1689
+
1690
+ // ──────────────────────────────────────────────────────────────────────────
1691
+ // 6. Auto-registration Custom Elements
1692
+ // ──────────────────────────────────────────────────────────────────────────
1693
+
1694
+ if (typeof customElements !== "undefined") {
1695
+ if (!customElements.get("geo-map")) {
1696
+ customElements.define("geo-map", GeoMap);
1697
+ }
1698
+ if (!customElements.get("geo-timeline")) {
1699
+ customElements.define("geo-timeline", GeoTimeline);
1700
+ }
1701
+ if (!customElements.get("geo-legend")) {
1702
+ customElements.define("geo-legend", GeoLegend);
1703
+ }
1704
+ }
1705
+
1706
+ // ──────────────────────────────────────────────────────────────────────────
1707
+ // 7. Export public
1708
+ // ──────────────────────────────────────────────────────────────────────────
1709
+
1710
+ export default {
1711
+ LIB_VERSION,
1712
+ BASEMAPS,
1713
+ DEFAULT_PALETTE,
1714
+ GeoMap,
1715
+ GeoTimeline,
1716
+ GeoLegend,
1717
+ GeoBindings,
1718
+ geoBindings,
1719
+ };