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.
- package/README.md +77 -0
- package/examples/README.md +175 -0
- package/examples/choropleth-demographique.json +90 -0
- package/examples/corpus-documentaire.json +170 -0
- package/examples/diagnostic-temporel.json +160 -0
- package/examples/heatmap-rag.json +84 -0
- package/examples/maquette-3d.json +153 -0
- package/examples/minimal.json +19 -0
- package/examples/multi-layers-narrative.json +119 -0
- package/examples/timeline-overlay-simple.json +90 -0
- package/examples/validation-terrain-zebra.json +98 -0
- package/geo-components.js +1719 -0
- package/package.json +77 -0
- package/schemas/README.md +125 -0
- package/schemas/scene_manifest.pydantic.py +1000 -0
- package/schemas/scene_manifest.schema.json +905 -0
- package/schemas/scene_manifest.zod.ts +66 -0
|
@@ -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, "&")
|
|
517
|
+
.replace(/</g, "<")
|
|
518
|
+
.replace(/>/g, ">")
|
|
519
|
+
.replace(/"/g, """)
|
|
520
|
+
.replace(/'/g, "'");
|
|
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
|
+
};
|