@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.
- package/components/ActionFormRenderer.svelte +20 -0
- package/components/ActionFormRenderer.svelte.d.ts +12 -0
- package/components/MapCluster.svelte +39 -1
- package/components/MapCluster.svelte.d.ts +2 -0
- package/components/MapDisplay.svelte +47 -1
- package/components/MapDisplay.svelte.d.ts +2 -0
- package/components/MapHeatmap.svelte +48 -1
- package/components/MapHeatmap.svelte.d.ts +2 -0
- package/components/MapPicker.svelte +91 -1
- package/components/MapPicker.svelte.d.ts +6 -0
- package/components/map-utils.js +261 -0
- package/package.json +1 -1
- package/tokens/components.css +6 -0
|
@@ -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 {
|
|
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;
|
package/components/map-utils.js
CHANGED
|
@@ -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
package/tokens/components.css
CHANGED
|
@@ -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(
|