@aiaiai-pt/design-system 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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} */
@@ -71,6 +77,9 @@
71
77
  ]);
72
78
  if (disposed) return;
73
79
 
80
+ const overlayLayers = await createOverlayLayers(layers, styles);
81
+ if (disposed) return;
82
+
74
83
  /** @type {Feature[]} */
75
84
  const features = [];
76
85
 
@@ -94,7 +103,7 @@
94
103
 
95
104
  map = new OlMap({
96
105
  target: container,
97
- layers: [tileLayer, vectorLayer],
106
+ layers: [tileLayer, ...overlayLayers, vectorLayer],
98
107
  view: new View({
99
108
  center: fromLonLat(center),
100
109
  zoom,
@@ -105,6 +114,9 @@
105
114
  disposeTheme = watchTheme(() => {
106
115
  styles.refresh();
107
116
  vectorLayer.getSource()?.changed();
117
+ // Token-styled overlays (no custom style) re-read via the shared
118
+ // styles object; poke their sources so OL repaints.
119
+ for (const l of overlayLayers) l.getSource()?.changed();
108
120
  });
109
121
  } catch (err) { renderMapError(container, 'MapDisplay', /** @type {Error} */ (err)); } })();
110
122
 
@@ -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>;
@@ -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
@@ -103,6 +124,7 @@
103
124
  _vectorSource.clear();
104
125
  _vectorSource.addFeature(new _Feature({ geometry: new _Point(fromLonLat([lon, lat])) }));
105
126
  value = [lon, lat];
127
+ checkBoundary([lon, lat]);
106
128
  onchange?.([lon, lat]);
107
129
  }
108
130
  }
@@ -112,6 +134,14 @@
112
134
  searchCoords = [lon, lat];
113
135
  }
114
136
 
137
+ const boundaryRings = $derived(boundaryToRings(boundary));
138
+
139
+ /** @param {[number, number]} coords */
140
+ function checkBoundary(coords) {
141
+ if (!boundaryRings.length || !onoutofbounds) return;
142
+ onoutofbounds(!pointInRings(coords, boundaryRings), coords);
143
+ }
144
+
115
145
  $effect(() => {
116
146
  if (!container || disabled) return;
117
147
 
@@ -148,6 +178,12 @@
148
178
  ]);
149
179
  if (disposed) return;
150
180
 
181
+ const [overlayLayers, boundaryLayer] = await Promise.all([
182
+ createOverlayLayers(layers, styles),
183
+ createBoundaryLayer(boundaryRings, container),
184
+ ]);
185
+ if (disposed) return;
186
+
151
187
  const vectorSource = new VectorSource();
152
188
  _vectorSource = vectorSource;
153
189
  _Feature = Feature;
@@ -191,6 +227,7 @@
191
227
  const wgs84 = /** @type {[number, number]} */ (toLonLat(coords));
192
228
  value = wgs84;
193
229
  handleMapPointPlaced(wgs84[0], wgs84[1]);
230
+ checkBoundary(wgs84);
194
231
  onchange?.(wgs84);
195
232
  } else {
196
233
  const coords = /** @type {import('ol/geom/Polygon.js').default} */ (geom).getCoordinates()[0];
@@ -203,7 +240,12 @@
203
240
 
204
241
  map = new OlMap({
205
242
  target: container,
206
- layers: [tileLayer, vectorLayer],
243
+ layers: [
244
+ tileLayer,
245
+ ...overlayLayers,
246
+ ...(boundaryLayer ? [boundaryLayer] : []),
247
+ vectorLayer,
248
+ ],
207
249
  view: new View({
208
250
  center: initialCenter,
209
251
  zoom,
@@ -216,6 +258,7 @@
216
258
  disposeTheme = watchTheme(() => {
217
259
  styles.refresh();
218
260
  vectorSource.changed();
261
+ for (const l of overlayLayers) l.getSource()?.changed();
219
262
  });
220
263
  } catch (err) { renderMapError(container, 'MapPicker', /** @type {Error} */ (err)); } })();
221
264
 
@@ -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.0",
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(