@aiaiai-pt/design-system 0.51.2 → 0.52.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.
@@ -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 (and its
316
- // anchored popup) is centred — a clear "the map moved to what I
317
- // tapped" gesture, not a subtle edge-nudge. The small ficha-resumo
318
- // card opens above the now-centred marker and stays in view.
319
- map?.getView().animate({ center: coords, duration: 400 });
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 {{ type: 'osm' }} OsmSource
7
- * @typedef {{ type: 'xyz', url: string, attributions?: string, maxZoom?: number }} XyzSource
8
- * @typedef {{ type: 'stadia', layer: string, retina?: boolean, apiKey?: string }} StadiaSource
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
  */
@@ -4,12 +4,29 @@
4
4
  */
5
5
 
6
6
  /**
7
- * @typedef {{ type: 'osm' }} OsmSource
8
- * @typedef {{ type: 'xyz', url: string, attributions?: string, maxZoom?: number }} XyzSource
9
- * @typedef {{ type: 'stadia', layer: string, retina?: boolean, apiKey?: string }} StadiaSource
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.51.2",
3
+ "version": "0.52.0",
4
4
  "description": "Design system tokens and Svelte components for aiaiai products",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
+ }