@aiaiai-pt/design-system 0.51.2 → 0.53.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/MapCluster.svelte +52 -11
- package/components/MapCluster.svelte.d.ts +11 -0
- package/components/MapDisplay.svelte +5 -0
- package/components/MapHeatmap.svelte +5 -0
- package/components/MapPicker.svelte +5 -0
- package/components/map-utils.d.ts +67 -6
- package/components/map-utils.js +160 -5
- package/package.json +44 -1
- package/tokens/components.css +25 -0
|
@@ -11,14 +11,28 @@
|
|
|
11
11
|
markers={[{ id: '1', lon: -9.14, lat: 38.74, label: 'HQ' }]}
|
|
12
12
|
onclick={(marker) => goto(`/equipment/${marker.id}`)}
|
|
13
13
|
/>
|
|
14
|
+
|
|
15
|
+
A marker with `pin` text is drawn as a labelled pin (a price, a time) from
|
|
16
|
+
the --map-pin-* tokens; `active: true` uses the active pin style, and the
|
|
17
|
+
marker whose popup is open is drawn active too.
|
|
18
|
+
|
|
19
|
+
@example
|
|
20
|
+
<MapCluster
|
|
21
|
+
markers={[{ id: 'h1', lon: -9.14, lat: 38.71, label: 'Hotel Lis', pin: '€182', active: true }]}
|
|
22
|
+
tileSource={{ type: 'osm', tone: 'muted' }}
|
|
23
|
+
{popup}
|
|
24
|
+
/>
|
|
14
25
|
-->
|
|
15
26
|
<script>
|
|
27
|
+
import { tick } from 'svelte';
|
|
16
28
|
import { fromLonLat } from 'ol/proj.js';
|
|
17
29
|
import { boundingExtent } from 'ol/extent.js';
|
|
18
|
-
import { createTileLayer, createMapStyles, createOverlayLayers, clusterTooltipLabel, watchTheme, renderMapError } from './map-utils.js';
|
|
30
|
+
import { createTileLayer, createMapStyles, createOverlayLayers, clusterFeatureStyle, clusterTooltipLabel, popupCenter, watchTheme, renderMapError } from './map-utils.js';
|
|
19
31
|
|
|
20
32
|
let {
|
|
21
|
-
/** @type {{ id: string, lon: number, lat: number, label?: string, [key: string]: any }[]}
|
|
33
|
+
/** @type {{ id: string, lon: number, lat: number, label?: string, pin?: string, active?: boolean, [key: string]: any }[]}
|
|
34
|
+
* `pin`: text drawn as the marker (a price, a time). `active`: draw
|
|
35
|
+
* that pin in the active style (chosen, saved, current). */
|
|
22
36
|
markers = [],
|
|
23
37
|
/** @type {[number, number] | undefined} — initial center [lon, lat].
|
|
24
38
|
* Omit to auto-FIT the markers' extent (the right default for "show
|
|
@@ -76,6 +90,14 @@
|
|
|
76
90
|
let _vectorSource = $state();
|
|
77
91
|
/** @type {any} — Cluster source */
|
|
78
92
|
let _clusterSource = $state();
|
|
93
|
+
/** @type {any} — Cluster vector layer */
|
|
94
|
+
let _clusterLayer = $state();
|
|
95
|
+
|
|
96
|
+
// The marker whose popup is open is drawn active: restyle on change.
|
|
97
|
+
$effect(() => {
|
|
98
|
+
void selected;
|
|
99
|
+
_clusterLayer?.changed();
|
|
100
|
+
});
|
|
79
101
|
/** @type {any} — shared style factory (set at mount, consumed by the
|
|
80
102
|
* overlay owner effect) */
|
|
81
103
|
let _styles = $state();
|
|
@@ -196,11 +218,9 @@
|
|
|
196
218
|
|
|
197
219
|
const clusterLayer = new VectorLayer({
|
|
198
220
|
source: clusterSource,
|
|
199
|
-
style: (feature) =>
|
|
200
|
-
const size = feature.get('features')?.length ?? 1;
|
|
201
|
-
return styles.cluster(size);
|
|
202
|
-
},
|
|
221
|
+
style: (feature) => clusterFeatureStyle(feature, styles, selected?.id),
|
|
203
222
|
});
|
|
223
|
+
_clusterLayer = clusterLayer;
|
|
204
224
|
|
|
205
225
|
// Tooltip overlay
|
|
206
226
|
const tooltipEl = document.createElement('div');
|
|
@@ -312,11 +332,19 @@
|
|
|
312
332
|
selected = data;
|
|
313
333
|
if (coords) {
|
|
314
334
|
_popupOverlay?.setPosition(coords);
|
|
315
|
-
// Fly TO the clicked marker: animate the view so the marker
|
|
316
|
-
// anchored popup
|
|
317
|
-
// tapped" gesture
|
|
318
|
-
// card
|
|
319
|
-
|
|
335
|
+
// Fly TO the clicked marker: animate the view so the marker and
|
|
336
|
+
// its anchored popup are centred together — a clear "the map moved
|
|
337
|
+
// to what I tapped" gesture. Measured after the snippet renders, so
|
|
338
|
+
// a tall card is not cut off at the top of a short map.
|
|
339
|
+
void tick().then(() => {
|
|
340
|
+
const view = map?.getView();
|
|
341
|
+
const height = map?.getSize()?.[1] ?? 0;
|
|
342
|
+
if (!view) return;
|
|
343
|
+
view.animate({
|
|
344
|
+
center: popupCenter(coords, popupEl?.offsetHeight ?? 0, height, view.getResolution() ?? 0),
|
|
345
|
+
duration: 400,
|
|
346
|
+
});
|
|
347
|
+
});
|
|
320
348
|
}
|
|
321
349
|
} else {
|
|
322
350
|
onclick?.(data);
|
|
@@ -372,6 +400,13 @@
|
|
|
372
400
|
styles.refresh();
|
|
373
401
|
clusterSource.changed();
|
|
374
402
|
});
|
|
403
|
+
// Pins are drawn on canvas with the token font. If the web font was
|
|
404
|
+
// still loading, redraw them once it is ready.
|
|
405
|
+
document.fonts?.ready.then(() => {
|
|
406
|
+
if (disposed) return;
|
|
407
|
+
styles.refresh();
|
|
408
|
+
clusterSource.changed();
|
|
409
|
+
});
|
|
375
410
|
} catch (err) { renderMapError(container, 'MapCluster', /** @type {Error} */ (err)); } })();
|
|
376
411
|
|
|
377
412
|
// Escape closes an open popup (keyboard dismissal).
|
|
@@ -420,6 +455,12 @@
|
|
|
420
455
|
border-radius: inherit;
|
|
421
456
|
}
|
|
422
457
|
|
|
458
|
+
/* tileSource.tone 'muted': the tile layer has its own canvas (see
|
|
459
|
+
tileLayerClass), so only the basemap is filtered. */
|
|
460
|
+
.map-cluster :global(.map-tiles-muted) {
|
|
461
|
+
filter: var(--map-tiles-muted-filter);
|
|
462
|
+
}
|
|
463
|
+
|
|
423
464
|
/* Overlay element wrapper — OL owns its position (inline transform); this
|
|
424
465
|
just lifts it above the map controls. The card chrome is the consumer's
|
|
425
466
|
MapPopup. */
|
|
@@ -16,6 +16,17 @@ type MapCluster = {
|
|
|
16
16
|
* markers={[{ id: '1', lon: -9.14, lat: 38.74, label: 'HQ' }]}
|
|
17
17
|
* onclick={(marker) => goto(`/equipment/${marker.id}`)}
|
|
18
18
|
* />
|
|
19
|
+
*
|
|
20
|
+
* A marker with `pin` text is drawn as a labelled pin (a price, a time) from
|
|
21
|
+
* the --map-pin-* tokens; `active: true` uses the active pin style, and the
|
|
22
|
+
* marker whose popup is open is drawn active too.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* <MapCluster
|
|
26
|
+
* markers={[{ id: 'h1', lon: -9.14, lat: 38.71, label: 'Hotel Lis', pin: '€182', active: true }]}
|
|
27
|
+
* tileSource={{ type: 'osm', tone: 'muted' }}
|
|
28
|
+
* {popup}
|
|
29
|
+
* />
|
|
19
30
|
*/
|
|
20
31
|
declare const MapCluster: import("svelte").Component<{
|
|
21
32
|
markers?: any[];
|
|
@@ -182,4 +182,9 @@
|
|
|
182
182
|
.map-display :global(.ol-viewport) {
|
|
183
183
|
border-radius: inherit;
|
|
184
184
|
}
|
|
185
|
+
|
|
186
|
+
/* tileSource.tone 'muted': only the basemap's own canvas is filtered. */
|
|
187
|
+
.map-display :global(.map-tiles-muted) {
|
|
188
|
+
filter: var(--map-tiles-muted-filter);
|
|
189
|
+
}
|
|
185
190
|
</style>
|
|
@@ -208,4 +208,9 @@
|
|
|
208
208
|
.map-heatmap :global(.ol-viewport) {
|
|
209
209
|
border-radius: inherit;
|
|
210
210
|
}
|
|
211
|
+
|
|
212
|
+
/* tileSource.tone 'muted': only the basemap's own canvas is filtered. */
|
|
213
|
+
.map-heatmap :global(.map-tiles-muted) {
|
|
214
|
+
filter: var(--map-tiles-muted-filter);
|
|
215
|
+
}
|
|
211
216
|
</style>
|
|
@@ -397,6 +397,11 @@
|
|
|
397
397
|
border-radius: inherit;
|
|
398
398
|
}
|
|
399
399
|
|
|
400
|
+
/* tileSource.tone 'muted': only the basemap's own canvas is filtered. */
|
|
401
|
+
.map-picker-canvas :global(.map-tiles-muted) {
|
|
402
|
+
filter: var(--map-tiles-muted-filter);
|
|
403
|
+
}
|
|
404
|
+
|
|
400
405
|
.map-picker-canvas.map-picker-error {
|
|
401
406
|
border-color: var(--input-error-border-color);
|
|
402
407
|
}
|
|
@@ -3,11 +3,25 @@
|
|
|
3
3
|
* Not exported from index.js — internal to map components only.
|
|
4
4
|
*/
|
|
5
5
|
/**
|
|
6
|
-
* @typedef {{
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* @typedef {{ tone?: 'muted' }} TileTone — `tone: 'muted'` greys the
|
|
7
|
+
* basemap (and darkens it in the dark scheme) through
|
|
8
|
+
* `--map-tiles-muted-filter`, so markers and routes carry the colour. It
|
|
9
|
+
* works with any source, including keyless OSM.
|
|
10
|
+
* @typedef {{ type: 'osm' } & TileTone} OsmSource
|
|
11
|
+
* @typedef {{ type: 'xyz', url: string, attributions?: string, maxZoom?: number } & TileTone} XyzSource
|
|
12
|
+
* @typedef {{ type: 'stadia', layer: string, retina?: boolean, apiKey?: string } & TileTone} StadiaSource
|
|
9
13
|
* @typedef {OsmSource | XyzSource | StadiaSource} TileSourceConfig
|
|
10
14
|
*/
|
|
15
|
+
/**
|
|
16
|
+
* The tile layer's class. OpenLayers draws consecutive layers that share a
|
|
17
|
+
* class on one canvas, so a distinct class gives the tiles a canvas of their
|
|
18
|
+
* own. The map components filter that canvas with `--map-tiles-muted-filter`;
|
|
19
|
+
* the markers and overlays above it stay at full colour.
|
|
20
|
+
*
|
|
21
|
+
* @param {TileSourceConfig} config
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function tileLayerClass(config: TileSourceConfig): string;
|
|
11
25
|
/**
|
|
12
26
|
* Creates an OL tile layer from a config object.
|
|
13
27
|
*
|
|
@@ -20,6 +34,9 @@ export function createTileLayer(config: TileSourceConfig): Promise<import("ol/la
|
|
|
20
34
|
* @property {import('ol/style/Style.js').default} marker
|
|
21
35
|
* @property {import('ol/style/Style.js').default} polygon
|
|
22
36
|
* @property {(size: number) => import('ol/style/Style.js').default} cluster
|
|
37
|
+
* @property {(text: string, active?: boolean) => import('ol/style/Style.js').default} pin
|
|
38
|
+
* — a label drawn as the marker itself (a price, a time, a short name),
|
|
39
|
+
* from the --map-pin-* tokens; `active` uses the --map-pin-active-* set
|
|
23
40
|
* @property {() => void} refresh — re-read CSS tokens, mutate styles in place
|
|
24
41
|
*/
|
|
25
42
|
/**
|
|
@@ -116,6 +133,36 @@ export function geoStylerToFlat(style: any): OverlayLayerStyle;
|
|
|
116
133
|
* @returns {Promise<import('ol/layer/Vector.js').default[]>}
|
|
117
134
|
*/
|
|
118
135
|
export function createOverlayLayers(defs: OverlayLayerDef[], styles: import("./map-utils.js").MapStyles): Promise<import("ol/layer/Vector.js").default[]>;
|
|
136
|
+
/**
|
|
137
|
+
* Decide how one feature of the cluster layer is drawn.
|
|
138
|
+
*
|
|
139
|
+
* A cluster of one is its marker: a pin with the marker's `pin` text when it
|
|
140
|
+
* has one (active when the marker says `active` or is the one whose popup is
|
|
141
|
+
* open), otherwise the plain marker dot. A cluster of many shows its count.
|
|
142
|
+
*
|
|
143
|
+
* @param {{ get: (key: string) => any }} feature — a Cluster-source feature
|
|
144
|
+
* @param {Pick<MapStyles, 'marker' | 'cluster' | 'pin'>} styles
|
|
145
|
+
* @param {string | number | null | undefined} [selectedId] — id of the
|
|
146
|
+
* marker whose popup is open
|
|
147
|
+
* @returns {import('ol/style/Style.js').default}
|
|
148
|
+
*/
|
|
149
|
+
export function clusterFeatureStyle(feature: {
|
|
150
|
+
get: (key: string) => any;
|
|
151
|
+
}, styles: Pick<MapStyles, "marker" | "cluster" | "pin">, selectedId?: string | number | null | undefined): import("ol/style/Style.js").default;
|
|
152
|
+
/**
|
|
153
|
+
* Where to centre the view so a marker and the popup above it are centred
|
|
154
|
+
* together: the marker sits below the middle by half the popup's height, so
|
|
155
|
+
* a card taller than half the map is not cut off at the top. The shift is
|
|
156
|
+
* capped so the marker stays `margin` pixels inside the bottom edge.
|
|
157
|
+
*
|
|
158
|
+
* @param {number[]} coords — marker coordinate in view projection (y grows north)
|
|
159
|
+
* @param {number} popupHeight — popup element height, px
|
|
160
|
+
* @param {number} mapHeight — map viewport height, px
|
|
161
|
+
* @param {number} resolution — view resolution, map units per px
|
|
162
|
+
* @param {number} [margin] — px kept between marker and bottom edge
|
|
163
|
+
* @returns {number[]}
|
|
164
|
+
*/
|
|
165
|
+
export function popupCenter(coords: number[], popupHeight: number, mapHeight: number, resolution: number, margin?: number): number[];
|
|
119
166
|
/**
|
|
120
167
|
* Decide the hover-tooltip text for a feature under the pointer.
|
|
121
168
|
*
|
|
@@ -161,26 +208,40 @@ export function pointInRings(lonLat: [number, number], rings: number[][][]): boo
|
|
|
161
208
|
* @returns {Promise<import('ol/layer/Vector.js').default | null>}
|
|
162
209
|
*/
|
|
163
210
|
export function createBoundaryLayer(rings: number[][][], el: Element): Promise<import("ol/layer/Vector.js").default | null>;
|
|
211
|
+
/**
|
|
212
|
+
* — `tone: 'muted'` greys the
|
|
213
|
+
* basemap (and darkens it in the dark scheme) through
|
|
214
|
+
* `--map-tiles-muted-filter`, so markers and routes carry the colour. It
|
|
215
|
+
* works with any source, including keyless OSM.
|
|
216
|
+
*/
|
|
217
|
+
export type TileTone = {
|
|
218
|
+
tone?: "muted";
|
|
219
|
+
};
|
|
164
220
|
export type OsmSource = {
|
|
165
221
|
type: "osm";
|
|
166
|
-
};
|
|
222
|
+
} & TileTone;
|
|
167
223
|
export type XyzSource = {
|
|
168
224
|
type: "xyz";
|
|
169
225
|
url: string;
|
|
170
226
|
attributions?: string;
|
|
171
227
|
maxZoom?: number;
|
|
172
|
-
};
|
|
228
|
+
} & TileTone;
|
|
173
229
|
export type StadiaSource = {
|
|
174
230
|
type: "stadia";
|
|
175
231
|
layer: string;
|
|
176
232
|
retina?: boolean;
|
|
177
233
|
apiKey?: string;
|
|
178
|
-
};
|
|
234
|
+
} & TileTone;
|
|
179
235
|
export type TileSourceConfig = OsmSource | XyzSource | StadiaSource;
|
|
180
236
|
export type MapStyles = {
|
|
181
237
|
marker: import("ol/style/Style.js").default;
|
|
182
238
|
polygon: import("ol/style/Style.js").default;
|
|
183
239
|
cluster: (size: number) => import("ol/style/Style.js").default;
|
|
240
|
+
/**
|
|
241
|
+
* — a label drawn as the marker itself (a price, a time, a short name),
|
|
242
|
+
* from the --map-pin-* tokens; `active` uses the --map-pin-active-* set
|
|
243
|
+
*/
|
|
244
|
+
pin: (text: string, active?: boolean) => import("ol/style/Style.js").default;
|
|
184
245
|
/**
|
|
185
246
|
* — re-read CSS tokens, mutate styles in place
|
|
186
247
|
*/
|
package/components/map-utils.js
CHANGED
|
@@ -4,12 +4,29 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* @typedef {{
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* @typedef {{ tone?: 'muted' }} TileTone — `tone: 'muted'` greys the
|
|
8
|
+
* basemap (and darkens it in the dark scheme) through
|
|
9
|
+
* `--map-tiles-muted-filter`, so markers and routes carry the colour. It
|
|
10
|
+
* works with any source, including keyless OSM.
|
|
11
|
+
* @typedef {{ type: 'osm' } & TileTone} OsmSource
|
|
12
|
+
* @typedef {{ type: 'xyz', url: string, attributions?: string, maxZoom?: number } & TileTone} XyzSource
|
|
13
|
+
* @typedef {{ type: 'stadia', layer: string, retina?: boolean, apiKey?: string } & TileTone} StadiaSource
|
|
10
14
|
* @typedef {OsmSource | XyzSource | StadiaSource} TileSourceConfig
|
|
11
15
|
*/
|
|
12
16
|
|
|
17
|
+
/**
|
|
18
|
+
* The tile layer's class. OpenLayers draws consecutive layers that share a
|
|
19
|
+
* class on one canvas, so a distinct class gives the tiles a canvas of their
|
|
20
|
+
* own. The map components filter that canvas with `--map-tiles-muted-filter`;
|
|
21
|
+
* the markers and overlays above it stay at full colour.
|
|
22
|
+
*
|
|
23
|
+
* @param {TileSourceConfig} config
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
export function tileLayerClass(config) {
|
|
27
|
+
return config?.tone === "muted" ? "ol-layer map-tiles-muted" : "ol-layer";
|
|
28
|
+
}
|
|
29
|
+
|
|
13
30
|
/**
|
|
14
31
|
* Creates an OL tile layer from a config object.
|
|
15
32
|
*
|
|
@@ -18,10 +35,12 @@
|
|
|
18
35
|
*/
|
|
19
36
|
export async function createTileLayer(config) {
|
|
20
37
|
const { default: TileLayer } = await import("ol/layer/Tile.js");
|
|
38
|
+
const className = tileLayerClass(config);
|
|
21
39
|
|
|
22
40
|
if (config.type === "stadia") {
|
|
23
41
|
const { default: StadiaMaps } = await import("ol/source/StadiaMaps.js");
|
|
24
42
|
return new TileLayer({
|
|
43
|
+
className,
|
|
25
44
|
source: new StadiaMaps({
|
|
26
45
|
layer: config.layer,
|
|
27
46
|
retina: config.retina ?? true,
|
|
@@ -33,6 +52,7 @@ export async function createTileLayer(config) {
|
|
|
33
52
|
if (config.type === "xyz") {
|
|
34
53
|
const { default: XYZ } = await import("ol/source/XYZ.js");
|
|
35
54
|
return new TileLayer({
|
|
55
|
+
className,
|
|
36
56
|
source: new XYZ({
|
|
37
57
|
url: config.url,
|
|
38
58
|
attributions: config.attributions,
|
|
@@ -43,7 +63,7 @@ export async function createTileLayer(config) {
|
|
|
43
63
|
}
|
|
44
64
|
|
|
45
65
|
const { default: OSM } = await import("ol/source/OSM.js");
|
|
46
|
-
return new TileLayer({ source: new OSM() });
|
|
66
|
+
return new TileLayer({ className, source: new OSM() });
|
|
47
67
|
}
|
|
48
68
|
|
|
49
69
|
// ─── Theme-Reactive Style Factory ────────────────────────────────
|
|
@@ -53,6 +73,9 @@ export async function createTileLayer(config) {
|
|
|
53
73
|
* @property {import('ol/style/Style.js').default} marker
|
|
54
74
|
* @property {import('ol/style/Style.js').default} polygon
|
|
55
75
|
* @property {(size: number) => import('ol/style/Style.js').default} cluster
|
|
76
|
+
* @property {(text: string, active?: boolean) => import('ol/style/Style.js').default} pin
|
|
77
|
+
* — a label drawn as the marker itself (a price, a time, a short name),
|
|
78
|
+
* from the --map-pin-* tokens; `active` uses the --map-pin-active-* set
|
|
56
79
|
* @property {() => void} refresh — re-read CSS tokens, mutate styles in place
|
|
57
80
|
*/
|
|
58
81
|
|
|
@@ -71,12 +94,14 @@ export async function createMapStyles(el) {
|
|
|
71
94
|
{ default: Fill },
|
|
72
95
|
{ default: Stroke },
|
|
73
96
|
{ default: Text },
|
|
97
|
+
{ default: Icon },
|
|
74
98
|
] = await Promise.all([
|
|
75
99
|
import("ol/style/Style.js"),
|
|
76
100
|
import("ol/style/Circle.js"),
|
|
77
101
|
import("ol/style/Fill.js"),
|
|
78
102
|
import("ol/style/Stroke.js"),
|
|
79
103
|
import("ol/style/Text.js"),
|
|
104
|
+
import("ol/style/Icon.js"),
|
|
80
105
|
]);
|
|
81
106
|
|
|
82
107
|
// Mutable fill/stroke refs — mutated in-place on refresh()
|
|
@@ -106,6 +131,10 @@ export async function createMapStyles(el) {
|
|
|
106
131
|
|
|
107
132
|
/** @type {Map<string, import('ol/style/Style.js').default>} */
|
|
108
133
|
const clusterCache = new Map();
|
|
134
|
+
/** @type {Map<string, import('ol/style/Style.js').default>} */
|
|
135
|
+
const pinCache = new Map();
|
|
136
|
+
/** @type {Record<string, any>} — pin tokens, read in readTokens() */
|
|
137
|
+
let pinTokens = {};
|
|
109
138
|
|
|
110
139
|
function readTokens() {
|
|
111
140
|
markerFillObj.setColor(cssVar(el, "--map-marker-fill", "#ff6b35"));
|
|
@@ -123,6 +152,23 @@ export async function createMapStyles(el) {
|
|
|
123
152
|
const font = cssVar(el, "--map-cluster-font", "monospace");
|
|
124
153
|
const fontSize = cssVar(el, "--map-cluster-font-size", "12px");
|
|
125
154
|
clusterFontStr = `600 ${fontSize} ${font}`;
|
|
155
|
+
pinTokens = {
|
|
156
|
+
bg: resolveColor(el, cssVar(el, "--map-pin-bg", "#fff")),
|
|
157
|
+
text: resolveColor(el, cssVar(el, "--map-pin-text", "#2c2825")),
|
|
158
|
+
border: resolveColor(el, cssVar(el, "--map-pin-border-color", "#999")),
|
|
159
|
+
activeBg: resolveColor(el, cssVar(el, "--map-pin-active-bg", "#2c2825")),
|
|
160
|
+
activeText: resolveColor(el, cssVar(el, "--map-pin-active-text", "#fff")),
|
|
161
|
+
activeBorder: resolveColor(
|
|
162
|
+
el,
|
|
163
|
+
cssVar(el, "--map-pin-active-border-color", "#2c2825"),
|
|
164
|
+
),
|
|
165
|
+
borderWidth: cssPx(el, "--map-pin-border-width", 1),
|
|
166
|
+
radius: cssPx(el, "--map-pin-radius", 999),
|
|
167
|
+
paddingX: cssPx(el, "--map-pin-padding-x", 8),
|
|
168
|
+
paddingY: cssPx(el, "--map-pin-padding-y", 4),
|
|
169
|
+
fontSize: cssPx(el, "--map-pin-font-size", 12),
|
|
170
|
+
font: `${cssVar(el, "--map-pin-font-weight", "600")} ${cssPx(el, "--map-pin-font-size", 12)}px ${cssVar(el, "--map-pin-font", "sans-serif")}`,
|
|
171
|
+
};
|
|
126
172
|
|
|
127
173
|
// Rebuild marker image with new radius (CircleStyle radius is immutable)
|
|
128
174
|
marker.setImage(
|
|
@@ -164,12 +210,74 @@ export async function createMapStyles(el) {
|
|
|
164
210
|
return style;
|
|
165
211
|
}
|
|
166
212
|
|
|
213
|
+
/**
|
|
214
|
+
* A pill with the text inside, drawn on a canvas at the device's pixel
|
|
215
|
+
* ratio and used as the marker image, so it hit-tests, clusters and
|
|
216
|
+
* themes like any marker.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} text
|
|
219
|
+
* @param {boolean} [active]
|
|
220
|
+
*/
|
|
221
|
+
function pin(text, active = false) {
|
|
222
|
+
const key = `${active ? "a" : "n"}|${text}`;
|
|
223
|
+
const cached = pinCache.get(key);
|
|
224
|
+
if (cached) return cached;
|
|
225
|
+
|
|
226
|
+
const t = pinTokens;
|
|
227
|
+
const ratio = window.devicePixelRatio || 1;
|
|
228
|
+
const canvas = document.createElement("canvas");
|
|
229
|
+
const measure = canvas.getContext("2d");
|
|
230
|
+
if (!measure) return marker;
|
|
231
|
+
measure.font = t.font;
|
|
232
|
+
const width = Math.ceil(
|
|
233
|
+
measure.measureText(text).width + 2 * (t.paddingX + t.borderWidth),
|
|
234
|
+
);
|
|
235
|
+
const height = Math.ceil(t.fontSize + 2 * (t.paddingY + t.borderWidth));
|
|
236
|
+
canvas.width = width * ratio;
|
|
237
|
+
canvas.height = height * ratio;
|
|
238
|
+
|
|
239
|
+
const ctx = /** @type {CanvasRenderingContext2D} */ (
|
|
240
|
+
canvas.getContext("2d")
|
|
241
|
+
);
|
|
242
|
+
ctx.scale(ratio, ratio);
|
|
243
|
+
const inset = t.borderWidth / 2;
|
|
244
|
+
ctx.beginPath();
|
|
245
|
+
ctx.roundRect(
|
|
246
|
+
inset,
|
|
247
|
+
inset,
|
|
248
|
+
width - t.borderWidth,
|
|
249
|
+
height - t.borderWidth,
|
|
250
|
+
Math.min(t.radius, height / 2),
|
|
251
|
+
);
|
|
252
|
+
ctx.fillStyle = active ? t.activeBg : t.bg;
|
|
253
|
+
ctx.fill();
|
|
254
|
+
if (t.borderWidth > 0) {
|
|
255
|
+
ctx.lineWidth = t.borderWidth;
|
|
256
|
+
ctx.strokeStyle = active ? t.activeBorder : t.border;
|
|
257
|
+
ctx.stroke();
|
|
258
|
+
}
|
|
259
|
+
ctx.font = t.font;
|
|
260
|
+
ctx.fillStyle = active ? t.activeText : t.text;
|
|
261
|
+
ctx.textAlign = "center";
|
|
262
|
+
ctx.textBaseline = "middle";
|
|
263
|
+
ctx.fillText(text, width / 2, height / 2);
|
|
264
|
+
|
|
265
|
+
const style = new Style({
|
|
266
|
+
image: new Icon({ img: canvas, scale: 1 / ratio }),
|
|
267
|
+
// Active pins draw above the rest; pins above plain markers.
|
|
268
|
+
zIndex: active ? 2 : 1,
|
|
269
|
+
});
|
|
270
|
+
pinCache.set(key, style);
|
|
271
|
+
return style;
|
|
272
|
+
}
|
|
273
|
+
|
|
167
274
|
function refresh() {
|
|
168
275
|
readTokens();
|
|
169
276
|
clusterCache.clear(); // cluster styles must be rebuilt with new colors
|
|
277
|
+
pinCache.clear();
|
|
170
278
|
}
|
|
171
279
|
|
|
172
|
-
return { marker, polygon, cluster, refresh };
|
|
280
|
+
return { marker, polygon, cluster, pin, refresh };
|
|
173
281
|
}
|
|
174
282
|
|
|
175
283
|
// ─── Theme Watcher ───────────────────────────────────────────────
|
|
@@ -489,6 +597,53 @@ export async function createOverlayLayers(defs, styles) {
|
|
|
489
597
|
});
|
|
490
598
|
}
|
|
491
599
|
|
|
600
|
+
// ─── Clustered marker style ──────────────────────────────────────
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Decide how one feature of the cluster layer is drawn.
|
|
604
|
+
*
|
|
605
|
+
* A cluster of one is its marker: a pin with the marker's `pin` text when it
|
|
606
|
+
* has one (active when the marker says `active` or is the one whose popup is
|
|
607
|
+
* open), otherwise the plain marker dot. A cluster of many shows its count.
|
|
608
|
+
*
|
|
609
|
+
* @param {{ get: (key: string) => any }} feature — a Cluster-source feature
|
|
610
|
+
* @param {Pick<MapStyles, 'marker' | 'cluster' | 'pin'>} styles
|
|
611
|
+
* @param {string | number | null | undefined} [selectedId] — id of the
|
|
612
|
+
* marker whose popup is open
|
|
613
|
+
* @returns {import('ol/style/Style.js').default}
|
|
614
|
+
*/
|
|
615
|
+
export function clusterFeatureStyle(feature, styles, selectedId) {
|
|
616
|
+
const members = feature.get("features");
|
|
617
|
+
if (!members?.length) return styles.marker;
|
|
618
|
+
if (members.length > 1) return styles.cluster(members.length);
|
|
619
|
+
const data = members[0].get("markerData");
|
|
620
|
+
const text = data?.pin == null ? "" : String(data.pin);
|
|
621
|
+
if (!text) return styles.marker;
|
|
622
|
+
const active =
|
|
623
|
+
data.active === true || (selectedId != null && data.id === selectedId);
|
|
624
|
+
return styles.pin(text, active);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ─── Popup framing ───────────────────────────────────────────────
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Where to centre the view so a marker and the popup above it are centred
|
|
631
|
+
* together: the marker sits below the middle by half the popup's height, so
|
|
632
|
+
* a card taller than half the map is not cut off at the top. The shift is
|
|
633
|
+
* capped so the marker stays `margin` pixels inside the bottom edge.
|
|
634
|
+
*
|
|
635
|
+
* @param {number[]} coords — marker coordinate in view projection (y grows north)
|
|
636
|
+
* @param {number} popupHeight — popup element height, px
|
|
637
|
+
* @param {number} mapHeight — map viewport height, px
|
|
638
|
+
* @param {number} resolution — view resolution, map units per px
|
|
639
|
+
* @param {number} [margin] — px kept between marker and bottom edge
|
|
640
|
+
* @returns {number[]}
|
|
641
|
+
*/
|
|
642
|
+
export function popupCenter(coords, popupHeight, mapHeight, resolution, margin = 24) {
|
|
643
|
+
const shift = Math.max(0, Math.min(popupHeight / 2, mapHeight / 2 - margin));
|
|
644
|
+
return [coords[0], coords[1] + shift * resolution];
|
|
645
|
+
}
|
|
646
|
+
|
|
492
647
|
// ─── Hover tooltip label ─────────────────────────────────────────
|
|
493
648
|
|
|
494
649
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiaiai-pt/design-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
4
4
|
"description": "Design system tokens and Svelte components for aiaiai products",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -91,7 +91,50 @@
|
|
|
91
91
|
"ol": "^10.0.0",
|
|
92
92
|
"svelte": "^5.0.0"
|
|
93
93
|
},
|
|
94
|
+
"peerDependenciesMeta": {
|
|
95
|
+
"@codemirror/commands": {
|
|
96
|
+
"optional": true
|
|
97
|
+
},
|
|
98
|
+
"@codemirror/lang-json": {
|
|
99
|
+
"optional": true
|
|
100
|
+
},
|
|
101
|
+
"@codemirror/lang-python": {
|
|
102
|
+
"optional": true
|
|
103
|
+
},
|
|
104
|
+
"@codemirror/lang-sql": {
|
|
105
|
+
"optional": true
|
|
106
|
+
},
|
|
107
|
+
"@codemirror/language": {
|
|
108
|
+
"optional": true
|
|
109
|
+
},
|
|
110
|
+
"@codemirror/state": {
|
|
111
|
+
"optional": true
|
|
112
|
+
},
|
|
113
|
+
"@codemirror/view": {
|
|
114
|
+
"optional": true
|
|
115
|
+
},
|
|
116
|
+
"@lezer/highlight": {
|
|
117
|
+
"optional": true
|
|
118
|
+
},
|
|
119
|
+
"date-fns": {
|
|
120
|
+
"optional": true
|
|
121
|
+
},
|
|
122
|
+
"echarts": {
|
|
123
|
+
"optional": true
|
|
124
|
+
},
|
|
125
|
+
"ol": {
|
|
126
|
+
"optional": true
|
|
127
|
+
}
|
|
128
|
+
},
|
|
94
129
|
"devDependencies": {
|
|
130
|
+
"@codemirror/commands": "^6.10.3",
|
|
131
|
+
"@codemirror/lang-json": "^6.0.2",
|
|
132
|
+
"@codemirror/lang-python": "^6.2.1",
|
|
133
|
+
"@codemirror/lang-sql": "^6.10.0",
|
|
134
|
+
"@codemirror/language": "^6.12.2",
|
|
135
|
+
"@codemirror/state": "^6.6.0",
|
|
136
|
+
"@codemirror/view": "^6.40.0",
|
|
137
|
+
"@lezer/highlight": "^1.0.0",
|
|
95
138
|
"@sveltejs/package": "^2.5.7",
|
|
96
139
|
"date-fns": "^4.1.0",
|
|
97
140
|
"echarts": "^6.1.0",
|
package/tokens/components.css
CHANGED
|
@@ -859,6 +859,26 @@
|
|
|
859
859
|
--map-marker-stroke: var(--color-surface);
|
|
860
860
|
--map-marker-stroke-width: var(--border-width-thick);
|
|
861
861
|
|
|
862
|
+
/* Pin: a marker drawn as a label (marker.pin: a price, a time) */
|
|
863
|
+
--map-pin-bg: var(--color-surface);
|
|
864
|
+
--map-pin-text: var(--color-text);
|
|
865
|
+
--map-pin-border-color: var(--color-border-strong);
|
|
866
|
+
--map-pin-border-width: var(--border-width);
|
|
867
|
+
--map-pin-radius: var(--radius-pill);
|
|
868
|
+
--map-pin-padding-x: var(--space-sm);
|
|
869
|
+
--map-pin-padding-y: var(--space-xs);
|
|
870
|
+
--map-pin-font: var(--type-label-font);
|
|
871
|
+
--map-pin-font-size: var(--type-label-size);
|
|
872
|
+
--map-pin-font-weight: var(--type-heading-weight);
|
|
873
|
+
/* Active pin (marker.active, or the marker whose popup is open) */
|
|
874
|
+
--map-pin-active-bg: var(--color-text);
|
|
875
|
+
--map-pin-active-text: var(--color-surface);
|
|
876
|
+
--map-pin-active-border-color: var(--color-text);
|
|
877
|
+
|
|
878
|
+
/* Basemap tone (tileSource.tone: 'muted'): quiet tiles so markers and
|
|
879
|
+
routes carry the colour. Redefined for the dark scheme below. */
|
|
880
|
+
--map-tiles-muted-filter: grayscale(1) contrast(0.7) brightness(1.08);
|
|
881
|
+
|
|
862
882
|
/* Cluster */
|
|
863
883
|
--map-cluster-radius: var(--space-md);
|
|
864
884
|
--map-cluster-fill: var(--color-accent);
|
|
@@ -995,3 +1015,8 @@
|
|
|
995
1015
|
--focus-ring-offset: var(--border-width-thick);
|
|
996
1016
|
--focus-ring-color: var(--color-accent);
|
|
997
1017
|
}
|
|
1018
|
+
|
|
1019
|
+
/* Dark scheme: the muted basemap inverts to a dark ground. */
|
|
1020
|
+
:root[data-scheme="dark"] {
|
|
1021
|
+
--map-tiles-muted-filter: grayscale(1) invert(1) contrast(0.8) brightness(0.9);
|
|
1022
|
+
}
|