@aiaiai-pt/design-system 0.15.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.
- package/components/ActionFormRenderer.svelte +117 -2
- package/components/ActionFormRenderer.svelte.d.ts +9 -0
- package/components/GeoSearch.svelte +8 -2
- package/components/GeoSearch.svelte.d.ts +2 -0
- package/components/MapCluster.svelte +52 -12
- package/components/MapCluster.svelte.d.ts +2 -2
- package/components/MapDisplay.svelte +14 -2
- package/components/MapDisplay.svelte.d.ts +2 -0
- package/components/MapPicker.svelte +50 -2
- package/components/MapPicker.svelte.d.ts +8 -0
- package/components/action-form-renderer-payload.d.ts +6 -0
- package/components/action-form-renderer-payload.ts +10 -1
- package/components/map-utils.js +261 -0
- package/package.json +1 -1
- package/tokens/components.css +7 -1
- package/tokens/semantic.css +68 -0
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import CodeBlock from "./CodeBlock.svelte";
|
|
8
8
|
import Input from "./Input.svelte";
|
|
9
9
|
import MapPicker from "./MapPicker.svelte";
|
|
10
|
+
import FileUpload from "./FileUpload.svelte";
|
|
11
|
+
import FileUploadItem from "./FileUploadItem.svelte";
|
|
10
12
|
import Select from "./Select.svelte";
|
|
11
13
|
import Tag from "./Tag.svelte";
|
|
12
14
|
import type { Snippet } from "svelte";
|
|
@@ -62,6 +64,11 @@
|
|
|
62
64
|
/** Injected apply. Required for a working submit button; absent → no button
|
|
63
65
|
* (so admin-preview / adapter-preview stay read-only). */
|
|
64
66
|
onApply?: (payload: Record<string, unknown>) => Promise<ApplyResult>;
|
|
67
|
+
/** Transport for `file`-typed parameters (#75 M5 slice 4b): receives one
|
|
68
|
+
* validated File, returns its storage key. The renderer owns the UI +
|
|
69
|
+
* the attachment_keys payload contract; the CONSUMER owns transport/auth
|
|
70
|
+
* (FileUpload philosophy). Omitted → file params render disabled. */
|
|
71
|
+
uploadFile?: (file: File) => Promise<{ key: string } | { error: string }>;
|
|
65
72
|
/** Environment-specific captcha (e.g. Turnstile), rendered above the button
|
|
66
73
|
* in submit modes. */
|
|
67
74
|
captcha?: Snippet;
|
|
@@ -76,11 +83,22 @@
|
|
|
76
83
|
mode = "admin-preview",
|
|
77
84
|
schema = null,
|
|
78
85
|
onApply = undefined,
|
|
86
|
+
uploadFile = undefined,
|
|
79
87
|
captcha = undefined,
|
|
80
88
|
submitLabel = "Submit",
|
|
81
89
|
}: Props = $props();
|
|
82
90
|
|
|
83
91
|
let values = $state<Record<string, unknown>>({});
|
|
92
|
+
// `file` params live OUTSIDE values — their keys ride the payload's
|
|
93
|
+
// top-level attachment_keys, never raw_values (the form schema doesn't
|
|
94
|
+
// know them; validation would reject strays).
|
|
95
|
+
let fileUploads = $state<Record<string, Array<{ key: string; name: string }>>>({});
|
|
96
|
+
let fileBusy = $state<Record<string, boolean>>({});
|
|
97
|
+
let fileError = $state<Record<string, string>>({});
|
|
98
|
+
// Platform caps mirror the public upload surface (3 × 5MB, web image types).
|
|
99
|
+
const FILE_MAX_COUNT = 3;
|
|
100
|
+
const FILE_MAX_BYTES = 5 * 1024 * 1024;
|
|
101
|
+
const FILE_ACCEPT = "image/jpeg,image/png,image/webp";
|
|
84
102
|
|
|
85
103
|
// Submit state (apply seam). Only meaningful in submit modes with an onApply.
|
|
86
104
|
let submitting = $state(false);
|
|
@@ -124,7 +142,11 @@
|
|
|
124
142
|
const canSubmit = $derived(
|
|
125
143
|
visibleParameters
|
|
126
144
|
.filter((parameter) => parameter.required)
|
|
127
|
-
.every((parameter) =>
|
|
145
|
+
.every((parameter) =>
|
|
146
|
+
String(parameter.type ?? "") === "file"
|
|
147
|
+
? (fileUploads[parameterKey(parameter)] ?? []).length > 0
|
|
148
|
+
: !isEmpty(values[parameterKey(parameter)]),
|
|
149
|
+
),
|
|
128
150
|
);
|
|
129
151
|
|
|
130
152
|
async function handleSubmit(): Promise<void> {
|
|
@@ -287,6 +309,45 @@
|
|
|
287
309
|
: {};
|
|
288
310
|
}
|
|
289
311
|
|
|
312
|
+
async function handleFiles(key: string, files: File[]) {
|
|
313
|
+
if (!uploadFile) return;
|
|
314
|
+
fileError = { ...fileError, [key]: "" };
|
|
315
|
+
const existing = fileUploads[key] ?? [];
|
|
316
|
+
const room = FILE_MAX_COUNT - existing.length;
|
|
317
|
+
const batch = files.slice(0, room);
|
|
318
|
+
if (files.length > room) {
|
|
319
|
+
fileError = { ...fileError, [key]: `Up to ${FILE_MAX_COUNT} files` };
|
|
320
|
+
}
|
|
321
|
+
if (batch.length === 0) return;
|
|
322
|
+
fileBusy = { ...fileBusy, [key]: true };
|
|
323
|
+
try {
|
|
324
|
+
for (const file of batch) {
|
|
325
|
+
const result = await uploadFile(file);
|
|
326
|
+
if ("key" in result) {
|
|
327
|
+
fileUploads = {
|
|
328
|
+
...fileUploads,
|
|
329
|
+
[key]: [...(fileUploads[key] ?? []), { key: result.key, name: file.name }],
|
|
330
|
+
};
|
|
331
|
+
} else {
|
|
332
|
+
fileError = { ...fileError, [key]: result.error };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
} finally {
|
|
336
|
+
fileBusy = { ...fileBusy, [key]: false };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function removeFile(key: string, storageKey: string) {
|
|
341
|
+
fileUploads = {
|
|
342
|
+
...fileUploads,
|
|
343
|
+
[key]: (fileUploads[key] ?? []).filter((f) => f.key !== storageKey),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function allAttachmentKeys(): string[] {
|
|
348
|
+
return Object.values(fileUploads).flatMap((list) => list.map((f) => f.key));
|
|
349
|
+
}
|
|
350
|
+
|
|
290
351
|
function buildPayload(): Record<string, unknown> {
|
|
291
352
|
return buildActionPayload({
|
|
292
353
|
action: renderedAction ?? null,
|
|
@@ -294,6 +355,7 @@
|
|
|
294
355
|
targetConfig: targetConfig(),
|
|
295
356
|
sourceSchema: sourceSchema(),
|
|
296
357
|
rawValues: visibleValueBag(),
|
|
358
|
+
attachmentKeys: allAttachmentKeys(),
|
|
297
359
|
schemaVersion: schema?.schema_version ?? null,
|
|
298
360
|
mode,
|
|
299
361
|
}) as unknown as Record<string, unknown>;
|
|
@@ -350,11 +412,42 @@
|
|
|
350
412
|
]}
|
|
351
413
|
onchange={(value: string) => setValue(key, value === "true")}
|
|
352
414
|
/>
|
|
415
|
+
{:else if type === "file"}
|
|
416
|
+
<!-- `file` parameter (#75 M5 slice 4b): upload-as-you-attach. The keys
|
|
417
|
+
ride payload.attachment_keys; raw_values never sees this param. -->
|
|
418
|
+
<div class="afr-file-param">
|
|
419
|
+
<span class="afr-file-label">{String(parameter.label ?? key)}</span>
|
|
420
|
+
{#each fileUploads[key] ?? [] as f (f.key)}
|
|
421
|
+
<FileUploadItem
|
|
422
|
+
name={f.name}
|
|
423
|
+
onremove={() => removeFile(key, f.key)}
|
|
424
|
+
/>
|
|
425
|
+
{/each}
|
|
426
|
+
{#if (fileUploads[key] ?? []).length < FILE_MAX_COUNT}
|
|
427
|
+
<FileUpload
|
|
428
|
+
accept={FILE_ACCEPT}
|
|
429
|
+
maxSize={FILE_MAX_BYTES}
|
|
430
|
+
multiple
|
|
431
|
+
disabled={!uploadFile || fileBusy[key]}
|
|
432
|
+
onfiles={(files: File[]) => handleFiles(key, files)}
|
|
433
|
+
onreject={() => {
|
|
434
|
+
fileError = { ...fileError, [key]: "JPEG, PNG or WebP up to 5MB" };
|
|
435
|
+
}}
|
|
436
|
+
/>
|
|
437
|
+
{/if}
|
|
438
|
+
{#if fileError[key]}
|
|
439
|
+
<p class="afr-file-error" role="alert">{fileError[key]}</p>
|
|
440
|
+
{/if}
|
|
441
|
+
</div>
|
|
353
442
|
{:else if type === "geo"}
|
|
354
443
|
<!-- A `geo` parameter renders the DS map-picker; its value is the
|
|
355
444
|
[lon, lat] the BFF's GEO_PARSE binding transform turns into a Point.
|
|
356
445
|
The renderer stays generic — geo is just another param type, the
|
|
357
|
-
occurrence/location coupling lives entirely in the ontology binding.
|
|
446
|
+
occurrence/location coupling lives entirely in the ontology binding.
|
|
447
|
+
SIBLING-ADDRESS CONVENTION: when the form also declares a parameter
|
|
448
|
+
keyed `address`, the picker's resolved address (search pick or
|
|
449
|
+
reverse geocode) auto-fills it — tracking the pin; the citizen can
|
|
450
|
+
still edit the field afterwards (a later pin overwrites). -->
|
|
358
451
|
<MapPicker
|
|
359
452
|
mode="point"
|
|
360
453
|
height="20rem"
|
|
@@ -366,6 +459,11 @@
|
|
|
366
459
|
? (values[key] as [number, number])
|
|
367
460
|
: undefined}
|
|
368
461
|
onchange={(coords: [number, number]) => setValue(key, coords)}
|
|
462
|
+
onaddress={(displayName: string) => {
|
|
463
|
+
if (renderedParameters.some((p) => parameterKey(p) === "address")) {
|
|
464
|
+
setValue("address", displayName);
|
|
465
|
+
}
|
|
466
|
+
}}
|
|
369
467
|
/>
|
|
370
468
|
{:else}
|
|
371
469
|
<Input
|
|
@@ -538,6 +636,23 @@
|
|
|
538
636
|
gap: var(--space-lg);
|
|
539
637
|
}
|
|
540
638
|
|
|
639
|
+
.afr-file-param {
|
|
640
|
+
display: flex;
|
|
641
|
+
flex-direction: column;
|
|
642
|
+
gap: var(--space-xs);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
.afr-file-label {
|
|
646
|
+
font-size: var(--input-label-size, var(--type-body-sm-size));
|
|
647
|
+
color: var(--color-text-secondary);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
.afr-file-error {
|
|
651
|
+
margin: 0;
|
|
652
|
+
font-size: var(--type-body-sm-size);
|
|
653
|
+
color: var(--input-error-text);
|
|
654
|
+
}
|
|
655
|
+
|
|
541
656
|
.submit-captcha {
|
|
542
657
|
min-height: var(--space-4xl);
|
|
543
658
|
}
|
|
@@ -45,6 +45,15 @@ interface Props {
|
|
|
45
45
|
/** Injected apply. Required for a working submit button; absent → no button
|
|
46
46
|
* (so admin-preview / adapter-preview stay read-only). */
|
|
47
47
|
onApply?: (payload: Record<string, unknown>) => Promise<ApplyResult>;
|
|
48
|
+
/** Transport for `file`-typed parameters (#75 M5 slice 4b): receives one
|
|
49
|
+
* validated File, returns its storage key. The renderer owns the UI +
|
|
50
|
+
* the attachment_keys payload contract; the CONSUMER owns transport/auth
|
|
51
|
+
* (FileUpload philosophy). Omitted → file params render disabled. */
|
|
52
|
+
uploadFile?: (file: File) => Promise<{
|
|
53
|
+
key: string;
|
|
54
|
+
} | {
|
|
55
|
+
error: string;
|
|
56
|
+
}>;
|
|
48
57
|
/** Environment-specific captcha (e.g. Turnstile), rendered above the button
|
|
49
58
|
* in submit modes. */
|
|
50
59
|
captcha?: Snippet;
|
|
@@ -43,6 +43,10 @@
|
|
|
43
43
|
viewbox = undefined,
|
|
44
44
|
/** @type {((lon: number, lat: number, item: { label: string, value: string }) => void) | undefined} */
|
|
45
45
|
onlocation = undefined,
|
|
46
|
+
/** @type {((displayName: string) => void) | undefined} — fires with the
|
|
47
|
+
* resolved address label on BOTH paths: reverse geocode (map click /
|
|
48
|
+
* coords set) and forward selection. Consumers feed address fields. */
|
|
49
|
+
onresolved = undefined,
|
|
46
50
|
/** @type {[number, number] | undefined} — set externally to reverse-geocode and show address */
|
|
47
51
|
coords = $bindable(undefined),
|
|
48
52
|
/** @type {string} */
|
|
@@ -78,6 +82,7 @@
|
|
|
78
82
|
if (result.display_name) {
|
|
79
83
|
value = `${lon},${lat}`;
|
|
80
84
|
items = [{ value: `${lon},${lat}`, label: result.display_name, description: result.type?.replace(/_/g, ' ') ?? '' }];
|
|
85
|
+
onresolved?.(result.display_name);
|
|
81
86
|
}
|
|
82
87
|
} catch {
|
|
83
88
|
// Reverse geocoding failed — leave search bar as-is
|
|
@@ -135,9 +140,10 @@
|
|
|
135
140
|
const [lonStr, latStr] = val.split(',');
|
|
136
141
|
const lon = parseFloat(lonStr);
|
|
137
142
|
const lat = parseFloat(latStr);
|
|
138
|
-
if (!Number.isNaN(lon) && !Number.isNaN(lat)
|
|
143
|
+
if (!Number.isNaN(lon) && !Number.isNaN(lat)) {
|
|
139
144
|
const selectedItem = items.find((i) => i.value === val);
|
|
140
|
-
|
|
145
|
+
if (selectedItem?.label) onresolved?.(selectedItem.label);
|
|
146
|
+
onlocation?.(lon, lat, selectedItem ?? { label: '', value: val });
|
|
141
147
|
}
|
|
142
148
|
}
|
|
143
149
|
</script>
|
|
@@ -36,6 +36,7 @@ declare const GeoSearch: import("svelte").Component<{
|
|
|
36
36
|
providerUrl?: string;
|
|
37
37
|
viewbox?: any;
|
|
38
38
|
onlocation?: any;
|
|
39
|
+
onresolved?: any;
|
|
39
40
|
coords?: any;
|
|
40
41
|
class?: string;
|
|
41
42
|
} & Record<string, any>, {}, "coords">;
|
|
@@ -49,6 +50,7 @@ type $$ComponentProps = {
|
|
|
49
50
|
providerUrl?: string;
|
|
50
51
|
viewbox?: any;
|
|
51
52
|
onlocation?: any;
|
|
53
|
+
onresolved?: any;
|
|
52
54
|
coords?: any;
|
|
53
55
|
class?: string;
|
|
54
56
|
} & Record<string, any>;
|
|
@@ -14,13 +14,17 @@
|
|
|
14
14
|
-->
|
|
15
15
|
<script>
|
|
16
16
|
import { fromLonLat } from 'ol/proj.js';
|
|
17
|
+
import { boundingExtent } from 'ol/extent.js';
|
|
17
18
|
import { createTileLayer, createMapStyles, watchTheme, renderMapError } from './map-utils.js';
|
|
18
19
|
|
|
19
20
|
let {
|
|
20
21
|
/** @type {{ id: string, lon: number, lat: number, label?: string, [key: string]: any }[]} */
|
|
21
22
|
markers = [],
|
|
22
|
-
/** @type {[number, number]} — initial center [lon, lat]
|
|
23
|
-
|
|
23
|
+
/** @type {[number, number] | undefined} — initial center [lon, lat].
|
|
24
|
+
* Omit to auto-FIT the markers' extent (the right default for "show
|
|
25
|
+
* my data" maps; explicit center wins for dashboards pinned to a
|
|
26
|
+
* region). */
|
|
27
|
+
center = undefined,
|
|
24
28
|
/** @type {number} */
|
|
25
29
|
zoom = 6,
|
|
26
30
|
/** @type {number} — cluster distance in pixels */
|
|
@@ -51,13 +55,21 @@
|
|
|
51
55
|
/** @type {any} — Point constructor */
|
|
52
56
|
let _Point;
|
|
53
57
|
|
|
54
|
-
// Reactive: animate
|
|
58
|
+
// Reactive: animate when the CALLER's center/zoom props change. Guarded
|
|
59
|
+
// against the mount-time run — with no explicit `center` the view was
|
|
60
|
+
// initialised to the markers' extent/mean, and animating to the prop
|
|
61
|
+
// default ([0,0], null island) shoved every marker off-screen.
|
|
62
|
+
let _lastCenterKey = $state("");
|
|
55
63
|
$effect(() => {
|
|
56
|
-
|
|
64
|
+
const key = center ? `${center[0]},${center[1]},${zoom}` : "";
|
|
65
|
+
if (!_map || !center) return;
|
|
66
|
+
if (key === _lastCenterKey) return;
|
|
67
|
+
const first = _lastCenterKey === "";
|
|
68
|
+
_lastCenterKey = key;
|
|
69
|
+
if (first) return; // initial view already honours the props
|
|
57
70
|
const view = _map.getView();
|
|
58
71
|
if (!view) return;
|
|
59
|
-
|
|
60
|
-
view.animate({ center: targetCenter, zoom, duration: 300 });
|
|
72
|
+
view.animate({ center: fromLonLat(center), zoom, duration: 300 });
|
|
61
73
|
});
|
|
62
74
|
|
|
63
75
|
// Reactive: update markers when props change
|
|
@@ -155,14 +167,22 @@
|
|
|
155
167
|
element: tooltipEl,
|
|
156
168
|
positioning: 'bottom-center',
|
|
157
169
|
offset: [0, -12],
|
|
170
|
+
// The hover tooltip anchors AT the feature — OL's default
|
|
171
|
+
// stopEvent:true puts it in the event-stopping overlay pane, so the
|
|
172
|
+
// very click the tooltip invites lands on the overlay container and
|
|
173
|
+
// dies (the element's own pointer-events:none can't help; the
|
|
174
|
+
// CONTAINER captures). Informational overlay → never stop events.
|
|
175
|
+
stopEvent: false,
|
|
158
176
|
});
|
|
159
177
|
|
|
160
|
-
const viewCenter =
|
|
161
|
-
? fromLonLat(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
178
|
+
const viewCenter = center
|
|
179
|
+
? fromLonLat(center)
|
|
180
|
+
: markers.length > 0
|
|
181
|
+
? fromLonLat([
|
|
182
|
+
markers.reduce((s, m) => s + m.lon, 0) / markers.length,
|
|
183
|
+
markers.reduce((s, m) => s + m.lat, 0) / markers.length,
|
|
184
|
+
])
|
|
185
|
+
: fromLonLat([0, 0]);
|
|
166
186
|
|
|
167
187
|
map = new OlMap({
|
|
168
188
|
target: container,
|
|
@@ -174,6 +194,14 @@
|
|
|
174
194
|
}),
|
|
175
195
|
});
|
|
176
196
|
_map = map;
|
|
197
|
+
// No explicit center → fit the markers' extent so every cluster is on
|
|
198
|
+
// screen regardless of how the data spreads (mean-centre alone leaves
|
|
199
|
+
// distant clouds outside the default zoom).
|
|
200
|
+
if (!center && markers.length > 1) {
|
|
201
|
+
const coords = markers.map((m) => fromLonLat([m.lon, m.lat]));
|
|
202
|
+
const ext = boundingExtent(coords);
|
|
203
|
+
map.getView().fit(ext, { padding: [48, 48, 48, 48], maxZoom: 15 });
|
|
204
|
+
}
|
|
177
205
|
|
|
178
206
|
// Hover: show tooltip
|
|
179
207
|
map.on('pointermove', (evt) => {
|
|
@@ -212,6 +240,18 @@
|
|
|
212
240
|
const data = clustered[0].get('markerData');
|
|
213
241
|
if (data) onclick(data);
|
|
214
242
|
} else if (clustered?.length > 1) {
|
|
243
|
+
// A cluster of (near-)IDENTICAL coordinates can never split by
|
|
244
|
+
// zooming (stacked reports at one point are common in civic
|
|
245
|
+
// data) — open the first item instead of zoom-looping forever.
|
|
246
|
+
const coords = clustered.map((f) => f.getGeometry()?.getCoordinates()).filter(Boolean);
|
|
247
|
+
const lons = coords.map((c) => c[0]);
|
|
248
|
+
const lats = coords.map((c) => c[1]);
|
|
249
|
+
const spread = Math.max(...lons) - Math.min(...lons) + (Math.max(...lats) - Math.min(...lats));
|
|
250
|
+
if (spread < 1) { // metres in web-mercator units — a stacked pile
|
|
251
|
+
const data = clustered[0].get('markerData');
|
|
252
|
+
if (data) onclick(data);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
215
255
|
const view = map?.getView();
|
|
216
256
|
const currentZoom = view?.getZoom() ?? zoom;
|
|
217
257
|
view?.animate({
|
|
@@ -19,7 +19,7 @@ type MapCluster = {
|
|
|
19
19
|
*/
|
|
20
20
|
declare const MapCluster: import("svelte").Component<{
|
|
21
21
|
markers?: any[];
|
|
22
|
-
center?: any
|
|
22
|
+
center?: any;
|
|
23
23
|
zoom?: number;
|
|
24
24
|
distance?: number;
|
|
25
25
|
tileSource?: Record<string, any>;
|
|
@@ -29,7 +29,7 @@ declare const MapCluster: import("svelte").Component<{
|
|
|
29
29
|
} & Record<string, any>, {}, "">;
|
|
30
30
|
type $$ComponentProps = {
|
|
31
31
|
markers?: any[];
|
|
32
|
-
center?: any
|
|
32
|
+
center?: any;
|
|
33
33
|
zoom?: number;
|
|
34
34
|
distance?: number;
|
|
35
35
|
tileSource?: Record<string, any>;
|
|
@@ -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 {
|
|
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,8 +59,24 @@
|
|
|
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,
|
|
76
|
+
/** @type {((displayName: string) => void) | undefined} — the resolved
|
|
77
|
+
* address whenever the pin settles (search pick or reverse geocode of a
|
|
78
|
+
* map click). Feed it to an address field; coords stay `onchange`. */
|
|
79
|
+
onaddress = undefined,
|
|
55
80
|
/** @type {string} */
|
|
56
81
|
height = '100%',
|
|
57
82
|
/** @type {string | undefined} */
|
|
@@ -99,6 +124,7 @@
|
|
|
99
124
|
_vectorSource.clear();
|
|
100
125
|
_vectorSource.addFeature(new _Feature({ geometry: new _Point(fromLonLat([lon, lat])) }));
|
|
101
126
|
value = [lon, lat];
|
|
127
|
+
checkBoundary([lon, lat]);
|
|
102
128
|
onchange?.([lon, lat]);
|
|
103
129
|
}
|
|
104
130
|
}
|
|
@@ -108,6 +134,14 @@
|
|
|
108
134
|
searchCoords = [lon, lat];
|
|
109
135
|
}
|
|
110
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
|
+
|
|
111
145
|
$effect(() => {
|
|
112
146
|
if (!container || disabled) return;
|
|
113
147
|
|
|
@@ -144,6 +178,12 @@
|
|
|
144
178
|
]);
|
|
145
179
|
if (disposed) return;
|
|
146
180
|
|
|
181
|
+
const [overlayLayers, boundaryLayer] = await Promise.all([
|
|
182
|
+
createOverlayLayers(layers, styles),
|
|
183
|
+
createBoundaryLayer(boundaryRings, container),
|
|
184
|
+
]);
|
|
185
|
+
if (disposed) return;
|
|
186
|
+
|
|
147
187
|
const vectorSource = new VectorSource();
|
|
148
188
|
_vectorSource = vectorSource;
|
|
149
189
|
_Feature = Feature;
|
|
@@ -187,6 +227,7 @@
|
|
|
187
227
|
const wgs84 = /** @type {[number, number]} */ (toLonLat(coords));
|
|
188
228
|
value = wgs84;
|
|
189
229
|
handleMapPointPlaced(wgs84[0], wgs84[1]);
|
|
230
|
+
checkBoundary(wgs84);
|
|
190
231
|
onchange?.(wgs84);
|
|
191
232
|
} else {
|
|
192
233
|
const coords = /** @type {import('ol/geom/Polygon.js').default} */ (geom).getCoordinates()[0];
|
|
@@ -199,7 +240,12 @@
|
|
|
199
240
|
|
|
200
241
|
map = new OlMap({
|
|
201
242
|
target: container,
|
|
202
|
-
layers: [
|
|
243
|
+
layers: [
|
|
244
|
+
tileLayer,
|
|
245
|
+
...overlayLayers,
|
|
246
|
+
...(boundaryLayer ? [boundaryLayer] : []),
|
|
247
|
+
vectorLayer,
|
|
248
|
+
],
|
|
203
249
|
view: new View({
|
|
204
250
|
center: initialCenter,
|
|
205
251
|
zoom,
|
|
@@ -212,6 +258,7 @@
|
|
|
212
258
|
disposeTheme = watchTheme(() => {
|
|
213
259
|
styles.refresh();
|
|
214
260
|
vectorSource.changed();
|
|
261
|
+
for (const l of overlayLayers) l.getSource()?.changed();
|
|
215
262
|
});
|
|
216
263
|
} catch (err) { renderMapError(container, 'MapPicker', /** @type {Error} */ (err)); } })();
|
|
217
264
|
|
|
@@ -234,6 +281,7 @@
|
|
|
234
281
|
providerUrl={searchProviderUrl}
|
|
235
282
|
viewbox={searchViewbox}
|
|
236
283
|
onlocation={handleGeoLocation}
|
|
284
|
+
onresolved={onaddress}
|
|
237
285
|
bind:coords={searchCoords}
|
|
238
286
|
size="sm"
|
|
239
287
|
/>
|
|
@@ -34,7 +34,11 @@ 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;
|
|
41
|
+
onaddress?: any;
|
|
38
42
|
height?: string;
|
|
39
43
|
id?: any;
|
|
40
44
|
class?: string;
|
|
@@ -52,7 +56,11 @@ type $$ComponentProps = {
|
|
|
52
56
|
searchProviderUrl?: any;
|
|
53
57
|
searchViewbox?: any;
|
|
54
58
|
tileSource?: Record<string, any>;
|
|
59
|
+
layers?: any[];
|
|
60
|
+
boundary?: any;
|
|
61
|
+
onoutofbounds?: any;
|
|
55
62
|
onchange?: any;
|
|
63
|
+
onaddress?: any;
|
|
56
64
|
height?: string;
|
|
57
65
|
id?: any;
|
|
58
66
|
class?: string;
|
|
@@ -39,10 +39,16 @@ export interface BuildArgs {
|
|
|
39
39
|
sourceSchema: Record<string, unknown>;
|
|
40
40
|
/** Submitted values keyed by parameter source_field_path or key. */
|
|
41
41
|
rawValues: Record<string, unknown>;
|
|
42
|
+
/** Uploaded file storage keys (`file`-typed parameters) — the platform
|
|
43
|
+
* attachment contract: a TOP-LEVEL `attachment_keys` sibling, never part
|
|
44
|
+
* of raw_values (the form schema doesn't validate file params). */
|
|
45
|
+
attachmentKeys?: string[];
|
|
42
46
|
schemaVersion: string | null;
|
|
43
47
|
mode: RendererMode;
|
|
44
48
|
}
|
|
45
49
|
export interface ActionPayload {
|
|
50
|
+
/** Uploaded file storage keys (file-typed params) — absent when none. */
|
|
51
|
+
attachment_keys?: string[];
|
|
46
52
|
source: string;
|
|
47
53
|
action: {
|
|
48
54
|
id: string | null;
|
|
@@ -42,11 +42,17 @@ export interface BuildArgs {
|
|
|
42
42
|
sourceSchema: Record<string, unknown>;
|
|
43
43
|
/** Submitted values keyed by parameter source_field_path or key. */
|
|
44
44
|
rawValues: Record<string, unknown>;
|
|
45
|
+
/** Uploaded file storage keys (`file`-typed parameters) — the platform
|
|
46
|
+
* attachment contract: a TOP-LEVEL `attachment_keys` sibling, never part
|
|
47
|
+
* of raw_values (the form schema doesn't validate file params). */
|
|
48
|
+
attachmentKeys?: string[];
|
|
45
49
|
schemaVersion: string | null;
|
|
46
50
|
mode: RendererMode;
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
export interface ActionPayload {
|
|
54
|
+
/** Uploaded file storage keys (file-typed params) — absent when none. */
|
|
55
|
+
attachment_keys?: string[];
|
|
50
56
|
source: string;
|
|
51
57
|
action: {
|
|
52
58
|
id: string | null;
|
|
@@ -102,7 +108,7 @@ function pickScope(targetConfig: Record<string, unknown>): Record<string, unknow
|
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
export function buildActionPayload(args: BuildArgs): ActionPayload {
|
|
105
|
-
const { action, placement, targetConfig, sourceSchema, rawValues, schemaVersion, mode } = args;
|
|
111
|
+
const { action, placement, targetConfig, sourceSchema, rawValues, schemaVersion, mode, attachmentKeys } = args;
|
|
106
112
|
|
|
107
113
|
const sourceFromSchema = nullableString(sourceSchema.source);
|
|
108
114
|
const targetModel =
|
|
@@ -143,6 +149,9 @@ export function buildActionPayload(args: BuildArgs): ActionPayload {
|
|
|
143
149
|
target_model: targetModel,
|
|
144
150
|
form_definition_id: nullableString(targetConfig.form_definition_id),
|
|
145
151
|
},
|
|
152
|
+
...(attachmentKeys && attachmentKeys.length > 0
|
|
153
|
+
? { attachment_keys: attachmentKeys }
|
|
154
|
+
: {}),
|
|
146
155
|
};
|
|
147
156
|
}
|
|
148
157
|
|
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
|
@@ -514,7 +514,7 @@
|
|
|
514
514
|
POPOVER
|
|
515
515
|
═══════════════════════════════════════════════ */
|
|
516
516
|
|
|
517
|
-
--popover-bg: var(--
|
|
517
|
+
--popover-bg: var(--color-surface-raised);
|
|
518
518
|
--popover-border: var(--elevation-border-strong);
|
|
519
519
|
--popover-shadow: var(--elevation-overlay);
|
|
520
520
|
--popover-radius: var(--radius-md);
|
|
@@ -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(
|
package/tokens/semantic.css
CHANGED
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
--color-surface: var(--raw-color-neutral-50);
|
|
19
19
|
--color-surface-secondary: var(--raw-color-neutral-100);
|
|
20
20
|
--color-surface-tertiary: var(--raw-color-neutral-200);
|
|
21
|
+
/* Raised surface — popovers/menus that float ABOVE the page surface
|
|
22
|
+
(lighter than the page in light scheme, lighter-than-base in dark). */
|
|
23
|
+
--color-surface-raised: var(--raw-color-white);
|
|
21
24
|
--color-overlay: rgba(44, 40, 37, 0.5);
|
|
22
25
|
|
|
23
26
|
/* Borders */
|
|
@@ -216,6 +219,71 @@
|
|
|
216
219
|
--content-padding-y: var(--space-lg);
|
|
217
220
|
}
|
|
218
221
|
|
|
222
|
+
/* ═══════════════════════════════════════════════
|
|
223
|
+
DARK SCHEME — `<html data-scheme="dark">`
|
|
224
|
+
═══════════════════════════════════════════════
|
|
225
|
+
|
|
226
|
+
One generic layer over the NEUTRAL-derived roles (surfaces, borders,
|
|
227
|
+
text, overlay). Brand hues survive untouched — the accent stays the
|
|
228
|
+
theme's accent — while every `-subtle` wash is re-derived from its
|
|
229
|
+
live hue via color-mix, so a tenant theme's accent gets a correct
|
|
230
|
+
dark wash for free. Per-theme bespoke dark = a `[data-theme="x"]`
|
|
231
|
+
block in the theme file, not here.
|
|
232
|
+
|
|
233
|
+
Selector is `:root[data-scheme="dark"]` (0,1,1): it TIES tenant theme
|
|
234
|
+
blocks (`:root[data-theme="x"]`, runtime-injected in <head>) and wins
|
|
235
|
+
on order — DS styles load after the injected theme block. Consumers
|
|
236
|
+
resolving an "auto" preference must set the RESOLVED value on <html>
|
|
237
|
+
(a pre-paint `prefers-color-scheme` read), never "auto" itself. */
|
|
238
|
+
|
|
239
|
+
:root[data-scheme="dark"] {
|
|
240
|
+
color-scheme: dark;
|
|
241
|
+
|
|
242
|
+
/* Surfaces — darkest at the base, lighter as elevation rises */
|
|
243
|
+
--color-surface: var(--raw-color-neutral-950);
|
|
244
|
+
--color-surface-secondary: var(--raw-color-neutral-900);
|
|
245
|
+
--color-surface-tertiary: var(--raw-color-neutral-800);
|
|
246
|
+
--color-surface-raised: var(--raw-color-neutral-900);
|
|
247
|
+
--color-overlay: rgba(0, 0, 0, 0.65);
|
|
248
|
+
|
|
249
|
+
/* Borders */
|
|
250
|
+
--color-border: var(--raw-color-neutral-800);
|
|
251
|
+
--color-border-strong: var(--raw-color-neutral-700);
|
|
252
|
+
|
|
253
|
+
/* Text — warm off-whites off the same neutral ramp */
|
|
254
|
+
--color-text: var(--raw-color-neutral-100);
|
|
255
|
+
--color-text-secondary: var(--raw-color-neutral-400);
|
|
256
|
+
--color-text-muted: var(--raw-color-neutral-500);
|
|
257
|
+
|
|
258
|
+
/* Subtle washes — derived from the LIVE hue (theme-set or default),
|
|
259
|
+
mixed into the dark surface instead of the light-only raw tints. */
|
|
260
|
+
--color-accent-subtle: color-mix(
|
|
261
|
+
in srgb,
|
|
262
|
+
var(--color-accent) 16%,
|
|
263
|
+
var(--raw-color-neutral-950)
|
|
264
|
+
);
|
|
265
|
+
--color-destructive-subtle: color-mix(
|
|
266
|
+
in srgb,
|
|
267
|
+
var(--color-destructive) 14%,
|
|
268
|
+
var(--raw-color-neutral-950)
|
|
269
|
+
);
|
|
270
|
+
--color-success-subtle: color-mix(
|
|
271
|
+
in srgb,
|
|
272
|
+
var(--color-success) 14%,
|
|
273
|
+
var(--raw-color-neutral-950)
|
|
274
|
+
);
|
|
275
|
+
--color-warning-subtle: color-mix(
|
|
276
|
+
in srgb,
|
|
277
|
+
var(--color-warning) 14%,
|
|
278
|
+
var(--raw-color-neutral-950)
|
|
279
|
+
);
|
|
280
|
+
--color-info-subtle: color-mix(
|
|
281
|
+
in srgb,
|
|
282
|
+
var(--color-info) 14%,
|
|
283
|
+
var(--raw-color-neutral-950)
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
219
287
|
/* ─── Tablet (768px+) ─── */
|
|
220
288
|
@media (min-width: 768px) {
|
|
221
289
|
:root {
|