@aiaiai-pt/design-system 0.16.0 → 0.17.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.
@@ -73,6 +73,18 @@
73
73
  * in submit modes. */
74
74
  captcha?: Snippet;
75
75
  submitLabel?: string;
76
+ /** Forwarded VERBATIM to every `geo` parameter's MapPicker (the renderer
77
+ * stays generic — boundary/overlay semantics live in the consumer):
78
+ * `boundary` draws the dashed tenant-boundary overlay and arms the
79
+ * out-of-bounds check; `layers` are ordered GeoJSON overlays (unbounded);
80
+ * `onoutofbounds(outside, coords)` fires on every point placement when a
81
+ * boundary is set — NON-blocking, the consumer owns surfacing/gating. */
82
+ boundary?: unknown;
83
+ layers?: unknown[];
84
+ onoutofbounds?: (outside: boolean, coords: [number, number]) => void;
85
+ /** Inline error rendered by the geo MapPicker(s) (e.g. the consumer's
86
+ * out-of-bounds copy) — forwarded as MapPicker's `error`. */
87
+ geoError?: string;
76
88
  }
77
89
 
78
90
  let {
@@ -86,6 +98,10 @@
86
98
  uploadFile = undefined,
87
99
  captcha = undefined,
88
100
  submitLabel = "Submit",
101
+ boundary = undefined,
102
+ layers = [],
103
+ onoutofbounds = undefined,
104
+ geoError = undefined,
89
105
  }: Props = $props();
90
106
 
91
107
  let values = $state<Record<string, unknown>>({});
@@ -451,6 +467,10 @@
451
467
  <MapPicker
452
468
  mode="point"
453
469
  height="20rem"
470
+ {boundary}
471
+ {layers}
472
+ {onoutofbounds}
473
+ error={geoError}
454
474
  label={String(parameter.label ?? key)}
455
475
  center={Array.isArray(parameter.default_value)
456
476
  ? (parameter.default_value as [number, number])
@@ -58,6 +58,18 @@ interface Props {
58
58
  * in submit modes. */
59
59
  captcha?: Snippet;
60
60
  submitLabel?: string;
61
+ /** Forwarded VERBATIM to every `geo` parameter's MapPicker (the renderer
62
+ * stays generic — boundary/overlay semantics live in the consumer):
63
+ * `boundary` draws the dashed tenant-boundary overlay and arms the
64
+ * out-of-bounds check; `layers` are ordered GeoJSON overlays (unbounded);
65
+ * `onoutofbounds(outside, coords)` fires on every point placement when a
66
+ * boundary is set — NON-blocking, the consumer owns surfacing/gating. */
67
+ boundary?: unknown;
68
+ layers?: unknown[];
69
+ onoutofbounds?: (outside: boolean, coords: [number, number]) => void;
70
+ /** Inline error rendered by the geo MapPicker(s) (e.g. the consumer's
71
+ * out-of-bounds copy) — forwarded as MapPicker's `error`. */
72
+ geoError?: string;
61
73
  }
62
74
  declare const ActionFormRenderer: import("svelte").Component<Props, {}, "">;
63
75
  type ActionFormRenderer = ReturnType<typeof ActionFormRenderer>;
@@ -15,7 +15,7 @@
15
15
  <script>
16
16
  import { fromLonLat } from 'ol/proj.js';
17
17
  import { boundingExtent } from 'ol/extent.js';
18
- import { createTileLayer, createMapStyles, watchTheme, renderMapError } from './map-utils.js';
18
+ import { createTileLayer, createMapStyles, createOverlayLayers, watchTheme, renderMapError } from './map-utils.js';
19
19
 
20
20
  let {
21
21
  /** @type {{ id: string, lon: number, lat: number, label?: string, [key: string]: any }[]} */
@@ -31,6 +31,12 @@
31
31
  distance = 40,
32
32
  /** @type {import('./map-utils.js').TileSourceConfig} */
33
33
  tileSource = { type: 'osm' },
34
+ /** @type {import('./map-utils.js').OverlayLayerDef[]} — ordered GeoJSON
35
+ * overlays rendered between the tiles and the cluster layer. Each
36
+ * entry: inline `data` or a `url` (e.g. the platform's
37
+ * `/{app}/public/layers/{id}/features`), optional flat or GeoStyler
38
+ * `style`. Unbounded — render as many as the consumer configures. */
39
+ layers = [],
34
40
  /** @type {((marker: { id: string, lon: number, lat: number, label?: string }) => void) | undefined} */
35
41
  onclick = undefined,
36
42
  /** @type {string} */
@@ -50,11 +56,40 @@
50
56
  let _vectorSource = $state();
51
57
  /** @type {any} — Cluster source */
52
58
  let _clusterSource = $state();
59
+ /** @type {any} — shared style factory (set at mount, consumed by the
60
+ * overlay owner effect) */
61
+ let _styles = $state();
53
62
  /** @type {any} — Feature constructor */
54
63
  let _Feature;
55
64
  /** @type {any} — Point constructor */
56
65
  let _Point;
57
66
 
67
+ // The `layers` overlays have ONE owner: this effect (same contract as
68
+ // MapPicker's boundary). Consumers typically RESOLVE overlay defs
69
+ // asynchronously (layer codes → url defs, after a fetch), so a
70
+ // mount-time-only build silently drops them — the overlays must track
71
+ // `layers` for as long as the map lives. The sequence counter drops
72
+ // stale async builds when the defs change mid-flight.
73
+ /** @type {any[]} */
74
+ let _overlayLayers = [];
75
+ let _overlaySeq = 0;
76
+ $effect(() => {
77
+ const defs = layers;
78
+ const map = _map;
79
+ const styles = _styles;
80
+ if (!map || !styles) return;
81
+ const seq = ++_overlaySeq;
82
+ void (async () => {
83
+ const built = defs?.length ? await createOverlayLayers(defs, styles) : [];
84
+ if (seq !== _overlaySeq || _map !== map) return;
85
+ for (const l of _overlayLayers) map.removeLayer(l);
86
+ _overlayLayers = built;
87
+ // Between the tiles (index 0) and the cluster layer — overlays never
88
+ // cover the markers.
89
+ built.forEach((l, i) => map.getLayers().insertAt(1 + i, l));
90
+ })();
91
+ });
92
+
58
93
  // Reactive: animate when the CALLER's center/zoom props change. Guarded
59
94
  // against the mount-time run — with no explicit `center` the view was
60
95
  // initialised to the markers' extent/mean, and animating to the prop
@@ -135,6 +170,7 @@
135
170
  // Store refs for reactive effects
136
171
  _vectorSource = vectorSource;
137
172
  _clusterSource = clusterSource;
173
+ _styles = styles;
138
174
  _Feature = Feature;
139
175
  _Point = Point;
140
176
 
@@ -186,6 +222,8 @@
186
222
 
187
223
  map = new OlMap({
188
224
  target: container,
225
+ // The `layers` overlays are NOT built here — the reactive owner
226
+ // effect above inserts them between the tiles and the cluster layer.
189
227
  layers: [tileLayer, clusterLayer],
190
228
  overlays: [tooltipOverlay],
191
229
  view: new View({
@@ -23,6 +23,7 @@ declare const MapCluster: import("svelte").Component<{
23
23
  zoom?: number;
24
24
  distance?: number;
25
25
  tileSource?: Record<string, any>;
26
+ layers?: any[];
26
27
  onclick?: any;
27
28
  height?: string;
28
29
  class?: string;
@@ -33,6 +34,7 @@ type $$ComponentProps = {
33
34
  zoom?: number;
34
35
  distance?: number;
35
36
  tileSource?: Record<string, any>;
37
+ layers?: any[];
36
38
  onclick?: any;
37
39
  height?: string;
38
40
  class?: string;
@@ -12,7 +12,7 @@
12
12
  -->
13
13
  <script>
14
14
  import { fromLonLat } from 'ol/proj.js';
15
- import { createTileLayer, createMapStyles, watchTheme, renderMapError } from './map-utils.js';
15
+ import { createTileLayer, createMapStyles, createOverlayLayers, watchTheme, renderMapError } from './map-utils.js';
16
16
 
17
17
  let {
18
18
  /** @type {[number, number]} — [longitude, latitude] WGS84 */
@@ -25,6 +25,12 @@
25
25
  polygon = undefined,
26
26
  /** @type {import('./map-utils.js').TileSourceConfig} */
27
27
  tileSource = { type: 'osm' },
28
+ /** @type {import('./map-utils.js').OverlayLayerDef[]} — ordered GeoJSON
29
+ * overlays rendered between the tiles and the marker/polygon layer.
30
+ * Each entry: inline `data` or a `url` (e.g. the platform's
31
+ * `/{app}/public/layers/{id}/features`), optional flat or GeoStyler
32
+ * `style`. See map-utils OverlayLayerDef. */
33
+ layers = [],
28
34
  /** @type {string} */
29
35
  height = '100%',
30
36
  /** @type {string} */
@@ -35,6 +41,38 @@
35
41
  /** @type {HTMLElement | undefined} */
36
42
  let container = $state();
37
43
 
44
+ // Hoisted references for the overlay owner effect
45
+ /** @type {import('ol/Map.js').default | undefined} */
46
+ let _map = $state();
47
+ /** @type {any} — shared style factory (set at mount) */
48
+ let _styles = $state();
49
+
50
+ // The `layers` overlays have ONE owner: this effect (same contract as
51
+ // MapPicker's boundary). Consumers typically RESOLVE overlay defs
52
+ // asynchronously (layer codes → url defs, after a fetch), so a
53
+ // mount-time-only build silently drops them — the overlays must track
54
+ // `layers` for as long as the map lives. The sequence counter drops
55
+ // stale async builds when the defs change mid-flight.
56
+ /** @type {any[]} */
57
+ let _overlayLayers = [];
58
+ let _overlaySeq = 0;
59
+ $effect(() => {
60
+ const defs = layers;
61
+ const map = _map;
62
+ const styles = _styles;
63
+ if (!map || !styles) return;
64
+ const seq = ++_overlaySeq;
65
+ void (async () => {
66
+ const built = defs?.length ? await createOverlayLayers(defs, styles) : [];
67
+ if (seq !== _overlaySeq || _map !== map) return;
68
+ for (const l of _overlayLayers) map.removeLayer(l);
69
+ _overlayLayers = built;
70
+ // Between the tiles (index 0) and the marker/polygon vector layer —
71
+ // overlays never cover the marker.
72
+ built.forEach((l, i) => map.getLayers().insertAt(1 + i, l));
73
+ })();
74
+ });
75
+
38
76
  $effect(() => {
39
77
  if (!container) return;
40
78
 
@@ -71,6 +109,8 @@
71
109
  ]);
72
110
  if (disposed) return;
73
111
 
112
+ _styles = styles;
113
+
74
114
  /** @type {Feature[]} */
75
115
  const features = [];
76
116
 
@@ -94,6 +134,8 @@
94
134
 
95
135
  map = new OlMap({
96
136
  target: container,
137
+ // The `layers` overlays are NOT built here — the reactive owner
138
+ // effect above inserts them between the tiles and the vector layer.
97
139
  layers: [tileLayer, vectorLayer],
98
140
  view: new View({
99
141
  center: fromLonLat(center),
@@ -101,10 +143,14 @@
101
143
  }),
102
144
  controls: [],
103
145
  });
146
+ _map = map;
104
147
 
105
148
  disposeTheme = watchTheme(() => {
106
149
  styles.refresh();
107
150
  vectorLayer.getSource()?.changed();
151
+ // Token-styled overlays (no custom style) re-read via the shared
152
+ // styles object; poke their sources so OL repaints.
153
+ for (const l of _overlayLayers) l.getSource()?.changed();
108
154
  });
109
155
  } catch (err) { renderMapError(container, 'MapDisplay', /** @type {Error} */ (err)); } })();
110
156
 
@@ -21,6 +21,7 @@ declare const MapDisplay: import("svelte").Component<{
21
21
  marker?: any;
22
22
  polygon?: any;
23
23
  tileSource?: Record<string, any>;
24
+ layers?: any[];
24
25
  height?: string;
25
26
  class?: string;
26
27
  } & Record<string, any>, {}, "">;
@@ -30,6 +31,7 @@ type $$ComponentProps = {
30
31
  marker?: any;
31
32
  polygon?: any;
32
33
  tileSource?: Record<string, any>;
34
+ layers?: any[];
33
35
  height?: string;
34
36
  class?: string;
35
37
  } & Record<string, any>;
@@ -21,7 +21,7 @@
21
21
  -->
22
22
  <script>
23
23
  import { fromLonLat } from 'ol/proj.js';
24
- import { createTileLayer, getHeatmapGradient, watchTheme, renderMapError } from './map-utils.js';
24
+ import { createTileLayer, createMapStyles, createOverlayLayers, getHeatmapGradient, watchTheme, renderMapError } from './map-utils.js';
25
25
 
26
26
  let {
27
27
  /** @type {{ lon: number, lat: number, weight?: number }[]} */
@@ -38,6 +38,12 @@
38
38
  gradient = undefined,
39
39
  /** @type {import('./map-utils.js').TileSourceConfig} */
40
40
  tileSource = { type: 'osm' },
41
+ /** @type {import('./map-utils.js').OverlayLayerDef[]} — ordered GeoJSON
42
+ * overlays rendered between the tiles and the heatmap layer. Each
43
+ * entry: inline `data` or a `url` (e.g. the platform's
44
+ * `/{app}/public/layers/{id}/features`), optional flat or GeoStyler
45
+ * `style`. Unbounded — render as many as the consumer configures. */
46
+ layers = [],
41
47
  /** @type {number} — max zoom when auto-fitting to points extent */
42
48
  maxZoom = 17,
43
49
  /** @type {string} */
@@ -50,6 +56,38 @@
50
56
  /** @type {HTMLElement | undefined} */
51
57
  let container = $state();
52
58
 
59
+ // Hoisted references for the overlay owner effect
60
+ /** @type {import('ol/Map.js').default | undefined} */
61
+ let _map = $state();
62
+ /** @type {any} — shared style factory (set at mount) */
63
+ let _styles = $state();
64
+
65
+ // The `layers` overlays have ONE owner: this effect (same contract as
66
+ // MapPicker's boundary). Consumers typically RESOLVE overlay defs
67
+ // asynchronously (layer codes → url defs, after a fetch), so a
68
+ // mount-time-only build silently drops them — the overlays must track
69
+ // `layers` for as long as the map lives. The sequence counter drops
70
+ // stale async builds when the defs change mid-flight.
71
+ /** @type {any[]} */
72
+ let _overlayLayers = [];
73
+ let _overlaySeq = 0;
74
+ $effect(() => {
75
+ const defs = layers;
76
+ const map = _map;
77
+ const styles = _styles;
78
+ if (!map || !styles) return;
79
+ const seq = ++_overlaySeq;
80
+ void (async () => {
81
+ const built = defs?.length ? await createOverlayLayers(defs, styles) : [];
82
+ if (seq !== _overlaySeq || _map !== map) return;
83
+ for (const l of _overlayLayers) map.removeLayer(l);
84
+ _overlayLayers = built;
85
+ // Between the tiles (index 0) and the heatmap layer — overlays never
86
+ // cover the heat surface.
87
+ built.forEach((l, i) => map.getLayers().insertAt(1 + i, l));
88
+ })();
89
+ });
90
+
53
91
  $effect(() => {
54
92
  if (!container) return;
55
93
 
@@ -81,6 +119,12 @@
81
119
  const tileLayer = await createTileLayer(tileSource);
82
120
  if (disposed) return;
83
121
 
122
+ // Style factory only feeds overlay fallback styles here — the heatmap
123
+ // layer itself styles via gradient tokens.
124
+ const styles = await createMapStyles(container);
125
+ if (disposed) return;
126
+ _styles = styles;
127
+
84
128
  // Non-linear weight normalization: sqrt lifts low values so they're
85
129
  // visible while preserving relative ordering. OL expects 0-1.
86
130
  const maxWeight = Math.max(...points.map(p => p.weight ?? 1), 1);
@@ -106,12 +150,15 @@
106
150
 
107
151
  map = new OlMap({
108
152
  target: container,
153
+ // The `layers` overlays are NOT built here — the reactive owner
154
+ // effect above inserts them between the tiles and the heatmap layer.
109
155
  layers: [tileLayer, heatmapLayer],
110
156
  view: new View({
111
157
  center: fromLonLat(center),
112
158
  zoom,
113
159
  }),
114
160
  });
161
+ _map = map;
115
162
 
116
163
  // Auto-fit view to points extent
117
164
  if (points.length > 0) {
@@ -32,6 +32,7 @@ declare const MapHeatmap: import("svelte").Component<{
32
32
  blur?: number;
33
33
  gradient?: any;
34
34
  tileSource?: Record<string, any>;
35
+ layers?: any[];
35
36
  maxZoom?: number;
36
37
  height?: string;
37
38
  class?: string;
@@ -44,6 +45,7 @@ type $$ComponentProps = {
44
45
  blur?: number;
45
46
  gradient?: any;
46
47
  tileSource?: Record<string, any>;
48
+ layers?: any[];
47
49
  maxZoom?: number;
48
50
  height?: string;
49
51
  class?: string;
@@ -22,7 +22,16 @@
22
22
 
23
23
  <script>
24
24
  import { fromLonLat, toLonLat } from 'ol/proj.js';
25
- import { createTileLayer, createMapStyles, watchTheme, renderMapError } from './map-utils.js';
25
+ import {
26
+ createTileLayer,
27
+ createMapStyles,
28
+ createOverlayLayers,
29
+ createBoundaryLayer,
30
+ boundaryToRings,
31
+ pointInRings,
32
+ watchTheme,
33
+ renderMapError,
34
+ } from './map-utils.js';
26
35
  import GeoSearch from './GeoSearch.svelte';
27
36
 
28
37
  let {
@@ -50,6 +59,18 @@
50
59
  searchViewbox = undefined,
51
60
  /** @type {import('./map-utils.js').TileSourceConfig} */
52
61
  tileSource = { type: 'osm' },
62
+ /** @type {import('./map-utils.js').OverlayLayerDef[]} — ordered GeoJSON
63
+ * overlays between the tiles and the draw layer (see MapDisplay). */
64
+ layers = [],
65
+ /** @type {any} — tenant boundary: GeoJSON Polygon/MultiPolygon/Feature/
66
+ * FeatureCollection or a raw ring [[lon,lat],...]. Rendered as a dashed
67
+ * --map-boundary-* overlay; point placements are tested against it. */
68
+ boundary = undefined,
69
+ /** @type {((outside: boolean, coords: [number, number]) => void) | undefined}
70
+ * Fired on every point placement (map click or search pick) when a
71
+ * `boundary` is set. The pin still lands — surfacing/blocking is the
72
+ * consumer's call (the intake hard-gate is server-side regardless). */
73
+ onoutofbounds = undefined,
53
74
  /** @type {((coords: [number, number] | number[][]) => void) | undefined} */
54
75
  onchange = undefined,
55
76
  /** @type {((displayName: string) => void) | undefined} — the resolved
@@ -74,6 +95,9 @@
74
95
  let container = $state();
75
96
  /** @type {import('ol/Map.js').default | undefined} */
76
97
  let _map = $state();
98
+ /** @type {any} — shared style factory (set at mount, consumed by the
99
+ * overlay owner effect) */
100
+ let _styles = $state();
77
101
  /** @type {any} — VectorSource for placing markers via search */
78
102
  let _vectorSource;
79
103
  /** @type {any} */
@@ -103,6 +127,7 @@
103
127
  _vectorSource.clear();
104
128
  _vectorSource.addFeature(new _Feature({ geometry: new _Point(fromLonLat([lon, lat])) }));
105
129
  value = [lon, lat];
130
+ checkBoundary([lon, lat]);
106
131
  onchange?.([lon, lat]);
107
132
  }
108
133
  }
@@ -112,6 +137,64 @@
112
137
  searchCoords = [lon, lat];
113
138
  }
114
139
 
140
+ const boundaryRings = $derived(boundaryToRings(boundary));
141
+
142
+ /** @param {[number, number]} coords */
143
+ function checkBoundary(coords) {
144
+ if (!boundaryRings.length || !onoutofbounds) return;
145
+ onoutofbounds(!pointInRings(coords, boundaryRings), coords);
146
+ }
147
+
148
+ // The dashed boundary overlay has ONE owner: this effect. Consumers
149
+ // typically FETCH the boundary (it arrives after map init), so a
150
+ // mount-time-only build silently drops it — the overlay must track
151
+ // `boundaryRings` for as long as the map lives. The sequence counter
152
+ // drops stale async builds when the rings change mid-flight.
153
+ /** @type {any} */
154
+ let _boundaryLayer = null;
155
+ let _boundarySeq = 0;
156
+ $effect(() => {
157
+ const rings = boundaryRings;
158
+ const map = _map;
159
+ if (!map || !container) return;
160
+ const seq = ++_boundarySeq;
161
+ void (async () => {
162
+ const layer = rings.length ? await createBoundaryLayer(rings, container) : null;
163
+ if (seq !== _boundarySeq || _map !== map) return;
164
+ if (_boundaryLayer) map.removeLayer(_boundaryLayer);
165
+ _boundaryLayer = layer;
166
+ if (layer) {
167
+ // Just below the pin/draw vector layer — boundary never covers the pin.
168
+ const coll = map.getLayers();
169
+ coll.insertAt(coll.getLength() - 1, layer);
170
+ }
171
+ })();
172
+ });
173
+
174
+ // The `layers` overlays get the same single-owner treatment as the
175
+ // boundary above: consumers typically RESOLVE overlay defs asynchronously
176
+ // (layer codes → url defs, after a fetch), so a mount-time-only build
177
+ // silently drops them.
178
+ /** @type {any[]} */
179
+ let _overlayLayers = [];
180
+ let _overlaySeq = 0;
181
+ $effect(() => {
182
+ const defs = layers;
183
+ const map = _map;
184
+ const styles = _styles;
185
+ if (!map || !styles) return;
186
+ const seq = ++_overlaySeq;
187
+ void (async () => {
188
+ const built = defs?.length ? await createOverlayLayers(defs, styles) : [];
189
+ if (seq !== _overlaySeq || _map !== map) return;
190
+ for (const l of _overlayLayers) map.removeLayer(l);
191
+ _overlayLayers = built;
192
+ // Between the tiles (index 0) and the boundary/pin layers — overlays
193
+ // never cover the pin.
194
+ built.forEach((l, i) => map.getLayers().insertAt(1 + i, l));
195
+ })();
196
+ });
197
+
115
198
  $effect(() => {
116
199
  if (!container || disabled) return;
117
200
 
@@ -148,6 +231,11 @@
148
231
  ]);
149
232
  if (disposed) return;
150
233
 
234
+ // Neither the boundary overlay NOR the `layers` overlays are built
235
+ // here — the reactive owner effects above track their late-arriving
236
+ // props for as long as the map lives.
237
+ _styles = styles;
238
+
151
239
  const vectorSource = new VectorSource();
152
240
  _vectorSource = vectorSource;
153
241
  _Feature = Feature;
@@ -191,6 +279,7 @@
191
279
  const wgs84 = /** @type {[number, number]} */ (toLonLat(coords));
192
280
  value = wgs84;
193
281
  handleMapPointPlaced(wgs84[0], wgs84[1]);
282
+ checkBoundary(wgs84);
194
283
  onchange?.(wgs84);
195
284
  } else {
196
285
  const coords = /** @type {import('ol/geom/Polygon.js').default} */ (geom).getCoordinates()[0];
@@ -216,6 +305,7 @@
216
305
  disposeTheme = watchTheme(() => {
217
306
  styles.refresh();
218
307
  vectorSource.changed();
308
+ for (const l of _overlayLayers) l.getSource()?.changed();
219
309
  });
220
310
  } catch (err) { renderMapError(container, 'MapPicker', /** @type {Error} */ (err)); } })();
221
311
 
@@ -34,6 +34,9 @@ declare const MapPicker: import("svelte").Component<{
34
34
  searchProviderUrl?: any;
35
35
  searchViewbox?: any;
36
36
  tileSource?: Record<string, any>;
37
+ layers?: any[];
38
+ boundary?: any;
39
+ onoutofbounds?: any;
37
40
  onchange?: any;
38
41
  onaddress?: any;
39
42
  height?: string;
@@ -53,6 +56,9 @@ type $$ComponentProps = {
53
56
  searchProviderUrl?: any;
54
57
  searchViewbox?: any;
55
58
  tileSource?: Record<string, any>;
59
+ layers?: any[];
60
+ boundary?: any;
61
+ onoutofbounds?: any;
56
62
  onchange?: any;
57
63
  onaddress?: any;
58
64
  height?: string;
@@ -336,3 +336,264 @@ export function getHeatmapGradient(el) {
336
336
 
337
337
  return ["rgba(251, 227, 142, 0)", "#fbe38e", "#fb923c", "#e85a28", "#ae2a1e"];
338
338
  }
339
+
340
+ // ─── Overlay Layers (#187 D-c) ───────────────────────────────────
341
+
342
+ /**
343
+ * @typedef {object} OverlayLayerStyle
344
+ * Flat style subset applied to a whole overlay layer. Colors are CSS color
345
+ * strings (they come from layer DATA — geolayers GeoStyler styles — not
346
+ * from design tokens; absent values fall back to the DS-tokened defaults).
347
+ * @property {string} [pointColor]
348
+ * @property {number} [pointRadius]
349
+ * @property {string} [strokeColor]
350
+ * @property {number} [strokeWidth]
351
+ * @property {string} [fillColor]
352
+ *
353
+ * @typedef {object} OverlayLayerDef
354
+ * One ordered overlay rendered between the tile layer and the component's
355
+ * own interactive vector layer. v1 is GeoJSON-only — the platform's citizen
356
+ * data plane serves GeoJSON (`/{app}/public/layers/{id}/features`); WMS/tile
357
+ * overlays arrive with the per-tenant raster principal (spec open question).
358
+ * @property {string} [id]
359
+ * @property {'geojson'} [type]
360
+ * @property {object} [data] — inline GeoJSON (Feature/FeatureCollection)
361
+ * @property {string} [url] — fetched when `data` is absent
362
+ * @property {OverlayLayerStyle | object} [style] — flat style or GeoStyler
363
+ * JSON (best-effort subset via `geoStylerToFlat`)
364
+ * @property {boolean} [visible]
365
+ */
366
+
367
+ /**
368
+ * Best-effort GeoStyler → flat style. Reads the first symbolizer of the
369
+ * first rule — enough for the platform's single-rule layer styles. Unknown
370
+ * shapes return {} so the DS-tokened defaults apply.
371
+ *
372
+ * @param {any} style
373
+ * @returns {OverlayLayerStyle}
374
+ */
375
+ export function geoStylerToFlat(style) {
376
+ if (!style || typeof style !== "object") return {};
377
+ if (!Array.isArray(style.rules)) return /** @type {OverlayLayerStyle} */ (style);
378
+ const sym = style.rules?.[0]?.symbolizers?.[0];
379
+ if (!sym || typeof sym !== "object") return {};
380
+ /** @type {OverlayLayerStyle} */
381
+ const flat = {};
382
+ if (sym.kind === "Mark" || sym.kind === "Icon") {
383
+ if (typeof sym.color === "string") flat.pointColor = sym.color;
384
+ if (typeof sym.radius === "number") flat.pointRadius = sym.radius;
385
+ if (typeof sym.strokeColor === "string") flat.strokeColor = sym.strokeColor;
386
+ if (typeof sym.strokeWidth === "number") flat.strokeWidth = sym.strokeWidth;
387
+ } else if (sym.kind === "Line") {
388
+ if (typeof sym.color === "string") flat.strokeColor = sym.color;
389
+ if (typeof sym.width === "number") flat.strokeWidth = sym.width;
390
+ } else if (sym.kind === "Fill") {
391
+ if (typeof sym.color === "string") flat.fillColor = sym.color;
392
+ if (typeof sym.outlineColor === "string") flat.strokeColor = sym.outlineColor;
393
+ if (typeof sym.outlineWidth === "number") flat.strokeWidth = sym.outlineWidth;
394
+ }
395
+ return flat;
396
+ }
397
+
398
+ /**
399
+ * Builds OL vector layers for `layers` overlay defs, ordered as given.
400
+ * URL-sourced layers load asynchronously into their source; a fetch failure
401
+ * leaves that overlay empty and logs a warning (the map still renders).
402
+ *
403
+ * @param {OverlayLayerDef[]} defs
404
+ * @param {import('./map-utils.js').MapStyles} styles — DS default styles
405
+ * @returns {Promise<import('ol/layer/Vector.js').default[]>}
406
+ */
407
+ export async function createOverlayLayers(defs, styles) {
408
+ if (!Array.isArray(defs) || defs.length === 0) return [];
409
+
410
+ const [
411
+ { default: VectorLayer },
412
+ { default: VectorSource },
413
+ { default: GeoJSON },
414
+ { default: Style },
415
+ { default: CircleStyle },
416
+ { default: Fill },
417
+ { default: Stroke },
418
+ ] = await Promise.all([
419
+ import("ol/layer/Vector.js"),
420
+ import("ol/source/Vector.js"),
421
+ import("ol/format/GeoJSON.js"),
422
+ import("ol/style/Style.js"),
423
+ import("ol/style/Circle.js"),
424
+ import("ol/style/Fill.js"),
425
+ import("ol/style/Stroke.js"),
426
+ ]);
427
+
428
+ const format = new GeoJSON({ featureProjection: "EPSG:3857" });
429
+
430
+ /** @param {OverlayLayerDef} def */
431
+ function buildStyleFn(def) {
432
+ const flat = geoStylerToFlat(def.style);
433
+ const hasCustom = Object.keys(flat).length > 0;
434
+ if (!hasCustom) {
435
+ return (/** @type {any} */ feature) => {
436
+ const type = feature.getGeometry()?.getType();
437
+ return type === "Point" || type === "MultiPoint"
438
+ ? styles.marker
439
+ : styles.polygon;
440
+ };
441
+ }
442
+ const stroke = new Stroke({
443
+ color: flat.strokeColor ?? flat.pointColor ?? "#3366cc",
444
+ width: flat.strokeWidth ?? 2,
445
+ });
446
+ const fill = new Fill({
447
+ color: flat.fillColor ?? "rgba(51,102,204,0.15)",
448
+ });
449
+ const point = new Style({
450
+ image: new CircleStyle({
451
+ radius: flat.pointRadius ?? 6,
452
+ fill: new Fill({ color: flat.pointColor ?? flat.strokeColor ?? "#3366cc" }),
453
+ stroke,
454
+ }),
455
+ });
456
+ const shape = new Style({ fill, stroke });
457
+ return (/** @type {any} */ feature) => {
458
+ const type = feature.getGeometry()?.getType();
459
+ return type === "Point" || type === "MultiPoint" ? point : shape;
460
+ };
461
+ }
462
+
463
+ return defs
464
+ .filter((def) => def && def.visible !== false)
465
+ .map((def) => {
466
+ const source = new VectorSource();
467
+ if (def.data) {
468
+ source.addFeatures(format.readFeatures(def.data));
469
+ } else if (def.url) {
470
+ fetch(def.url)
471
+ .then((r) => {
472
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
473
+ return r.json();
474
+ })
475
+ .then((geojson) => source.addFeatures(format.readFeatures(geojson)))
476
+ .catch((err) =>
477
+ console.warn(
478
+ `[map] overlay layer ${def.id ?? def.url} failed to load:`,
479
+ err,
480
+ ),
481
+ );
482
+ }
483
+ return new VectorLayer({ source, style: buildStyleFn(def) });
484
+ });
485
+ }
486
+
487
+ // ─── Boundary (#187 D-e prep) ────────────────────────────────────
488
+
489
+ /**
490
+ * Normalises a boundary prop into polygon rings (lon/lat WGS84).
491
+ * Accepts: raw ring `number[][]`, GeoJSON Polygon / MultiPolygon,
492
+ * Feature, or FeatureCollection (first polygonal feature per entry).
493
+ *
494
+ * @param {any} boundary
495
+ * @returns {number[][][]} — array of outer rings (one per polygon)
496
+ */
497
+ export function boundaryToRings(boundary) {
498
+ if (!boundary) return [];
499
+ if (Array.isArray(boundary)) {
500
+ // Raw ring: [[lon,lat], ...]
501
+ return boundary.length >= 3 ? [boundary] : [];
502
+ }
503
+ if (boundary.type === "FeatureCollection") {
504
+ return (boundary.features ?? []).flatMap((/** @type {any} */ f) =>
505
+ boundaryToRings(f),
506
+ );
507
+ }
508
+ if (boundary.type === "Feature") return boundaryToRings(boundary.geometry);
509
+ if (boundary.type === "Polygon") {
510
+ const ring = boundary.coordinates?.[0];
511
+ return ring && ring.length >= 3 ? [ring] : [];
512
+ }
513
+ if (boundary.type === "MultiPolygon") {
514
+ return (boundary.coordinates ?? [])
515
+ .map((/** @type {any} */ poly) => poly?.[0])
516
+ .filter((/** @type {any} */ r) => r && r.length >= 3);
517
+ }
518
+ return [];
519
+ }
520
+
521
+ /**
522
+ * Ray-casting point-in-polygon over WGS84 rings (outer rings only — holes
523
+ * are out of scope for the boundary gate; the BFF hard-gate is authoritative).
524
+ *
525
+ * @param {[number, number]} lonLat
526
+ * @param {number[][][]} rings — from `boundaryToRings`
527
+ * @returns {boolean} true when the point is inside ANY ring
528
+ */
529
+ export function pointInRings(lonLat, rings) {
530
+ const [x, y] = lonLat;
531
+ for (const ring of rings) {
532
+ let inside = false;
533
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
534
+ const [xi, yi] = ring[i];
535
+ const [xj, yj] = ring[j];
536
+ const intersects =
537
+ yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
538
+ if (intersects) inside = !inside;
539
+ }
540
+ if (inside) return true;
541
+ }
542
+ return false;
543
+ }
544
+
545
+ /**
546
+ * Builds the boundary overlay layer (dashed outline, faint fill — distinct
547
+ * from the selection polygon so "the edge of the allowed area" reads as
548
+ * context, not content). Token-driven: --map-boundary-*.
549
+ *
550
+ * @param {number[][][]} rings
551
+ * @param {Element} el — token read context
552
+ * @returns {Promise<import('ol/layer/Vector.js').default | null>}
553
+ */
554
+ export async function createBoundaryLayer(rings, el) {
555
+ if (!rings.length) return null;
556
+ const [
557
+ { default: VectorLayer },
558
+ { default: VectorSource },
559
+ { default: Feature },
560
+ { default: Polygon },
561
+ { default: Style },
562
+ { default: Fill },
563
+ { default: Stroke },
564
+ { fromLonLat },
565
+ ] = await Promise.all([
566
+ import("ol/layer/Vector.js"),
567
+ import("ol/source/Vector.js"),
568
+ import("ol/Feature.js"),
569
+ import("ol/geom/Polygon.js"),
570
+ import("ol/style/Style.js"),
571
+ import("ol/style/Fill.js"),
572
+ import("ol/style/Stroke.js"),
573
+ import("ol/proj.js"),
574
+ ]);
575
+
576
+ const dash = cssPx(el, "--map-boundary-dash", 8);
577
+ const style = new Style({
578
+ fill: new Fill({
579
+ color: cssVar(el, "--map-boundary-fill", "rgba(255,107,53,0.06)"),
580
+ }),
581
+ stroke: new Stroke({
582
+ color: cssVar(el, "--map-boundary-stroke", "#ff6b35"),
583
+ width: cssPx(el, "--map-boundary-stroke-width", 2),
584
+ lineDash: [dash, dash * 0.75],
585
+ }),
586
+ });
587
+
588
+ const features = rings.map(
589
+ (ring) =>
590
+ new Feature({
591
+ geometry: new Polygon([ring.map((c) => fromLonLat(c))]),
592
+ }),
593
+ );
594
+
595
+ return new VectorLayer({
596
+ source: new VectorSource({ features }),
597
+ style,
598
+ });
599
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/design-system",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "description": "Design system tokens and Svelte components for aiaiai products",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -797,6 +797,12 @@
797
797
  --map-polygon-stroke: var(--color-accent);
798
798
  --map-polygon-stroke-width: var(--border-width-thick);
799
799
 
800
+ /* Boundary overlay (#187 — tenant geofence context, not content) */
801
+ --map-boundary-fill: color-mix(in srgb, var(--color-accent) 8%, transparent);
802
+ --map-boundary-stroke: var(--map-polygon-stroke);
803
+ --map-boundary-stroke-width: var(--map-polygon-stroke-width);
804
+ --map-boundary-dash: var(--space-sm);
805
+
800
806
  /* Heatmap (accent-derived sequential ramp — follows theme) */
801
807
  --map-heatmap-stop-1: var(--color-accent-subtle);
802
808
  --map-heatmap-stop-2: color-mix(