@adecore/agents-react 0.25.5 → 0.25.7

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.
@@ -340,7 +340,7 @@ function UiBlockBody({ block, context, answered, frozen, unrecorded }) {
340
340
  const unknown = block.catalogVersion !== UI_CATALOG_VERSION;
341
341
  const failed = !unknown && !block.nodes.length && block.diagnostics.length > 0;
342
342
  const shownAsText = unknown ? t('blocks.unreadable') : failed ? block.diagnostics[0].message : undefined;
343
- return (_jsxs("div", { ref: setElement, className: "relative", "data-ui-block": block.id, "data-ui-revision": block.revision, tabIndex: -1, children: [flashing && _jsx("span", { className: "chat-flash pointer-events-none absolute inset-0 rounded-lg", "aria-hidden": true }, flash.nonce), _jsx(UiBlockFrame, { live: queries.live, nodes: evaluation.nodes, phase: context.phase, fixes: [...block.diagnostics, ...evaluation.diagnostics], shownAsText: shownAsText, answered: answered, children: unknown || failed ? (_jsx(ReplyMarkdown, { text: block.fallback, streaming: false })) : (_jsx(CitationContext, { value: citations, children: _jsx(UiNodes, { nodes: evaluation.nodes, parent: BLOCK, texts: texts, context: rendering }) })) })] }));
343
+ return (_jsxs("div", { ref: setElement, className: "relative", "data-ui-block": block.id, "data-ui-revision": block.revision, tabIndex: -1, children: [flashing && _jsx("span", { className: "chat-flash pointer-events-none absolute inset-0 rounded-lg", "aria-hidden": true }, flash.nonce), _jsx(UiBlockFrame, { live: queries.live, nodes: evaluation.nodes, phase: context.phase, fixes: [...block.diagnostics, ...evaluation.diagnostics].filter((diagnostic) => diagnostic.severity !== 'warning'), shownAsText: shownAsText, answered: answered, children: unknown || failed ? (_jsx(ReplyMarkdown, { text: block.fallback, streaming: false })) : (_jsx(CitationContext, { value: citations, children: _jsx(UiNodes, { nodes: evaluation.nodes, parent: BLOCK, texts: texts, context: rendering }) })) })] }));
344
344
  }
345
345
  /* Prose and cards a block gap apart, as the rows of a turn are; text that is only the newline between two fences draws nothing. */
346
346
  export function UiReply({ text, blocks, context, answers, queries, reply }) {
@@ -76,7 +76,7 @@ export function TextFieldRenderer({ node, children, context }) {
76
76
  const value = binding?.value ?? node.props.value;
77
77
  const countId = `${id}-count`;
78
78
  const counting = value.length >= maxLength * COUNT_FROM;
79
- return (_jsxs("div", { className: clsx('flex flex-col gap-1 px-2', !node.complete && PENDING), children: [_jsx("label", { htmlFor: id, className: "text-sm text-text-muted", children: children }), _jsx(Input, { id: id, value: value, size: "sm", maxLength: maxLength, placeholder: placeholder, disabled: binding === null, "aria-describedby": counting ? countId : undefined, className: "max-w-96", onChange: (event) => binding?.onValueChange(event.target.value) }), counting && (_jsx("span", { id: countId, className: "text-xs text-text-faint tabular-nums", children: t('blocks.textField.count', { used: formatNumber(value.length), max: formatNumber(maxLength) }) }))] }));
79
+ return (_jsxs("div", { className: clsx('flex flex-col gap-1 px-2', !node.complete && PENDING), children: [_jsx("label", { htmlFor: id, className: "text-sm text-text-muted", children: children }), _jsx(Input, { id: id, value: value, size: "sm", maxLength: maxLength, placeholder: placeholder, disabled: binding === null, "aria-describedby": counting ? countId : undefined, className: "max-w-96 bg-surface-sunken", onChange: (event) => binding?.onValueChange(event.target.value) }), counting && (_jsx("span", { id: countId, className: "text-xs text-text-muted tabular-nums", children: t('blocks.textField.count', { used: formatNumber(value.length), max: formatNumber(maxLength) }) }))] }));
80
80
  }
81
81
  /*
82
82
  * Left aligned, never the full width. Like Segmented, each Option is an item by its place, since a value may be any scalar.
@@ -13,6 +13,18 @@ export declare const MAP_MAX_ZOOM: {
13
13
  readonly tiles: 16;
14
14
  readonly outline: 6;
15
15
  };
16
+ export declare const MAP_FIT_PADDING = 32;
17
+ export declare function mapFitOptions(outline: boolean): {
18
+ padding: number;
19
+ maxZoom: number;
20
+ };
21
+ export declare const MAP_MARKER_SIZE = 20;
22
+ export interface ScreenPoint {
23
+ id: string;
24
+ x: number;
25
+ y: number;
26
+ }
27
+ export declare function spreadOverlaps(points: readonly ScreenPoint[]): Map<string, [number, number]>;
16
28
  export declare function baseMapStyle({ land, water }: MapColors): MapStyle;
17
29
  export declare function localizedTextField(original: unknown, field: string): unknown;
18
30
  export declare function boundsOf(points: readonly MapPoint[]): LngLatBoundsLike | null;
@@ -1,6 +1,50 @@
1
1
  import { BASE_LAND_JSON } from './base-land.js';
2
2
  /* How far a map zooms to fit its points: street level with tiles, and only a country or two on the outline, which has nothing finer. */
3
3
  export const MAP_MAX_ZOOM = { tiles: 16, outline: 6 };
4
+ /* The room around the places in a fitted view, in pixels. */
5
+ export const MAP_FIT_PADDING = 32;
6
+ /* What fits every place in view: the first view and the way back to it share it, so they cannot drift apart. */
7
+ export function mapFitOptions(outline) {
8
+ return { padding: MAP_FIT_PADDING, maxZoom: outline ? MAP_MAX_ZOOM.outline : MAP_MAX_ZOOM.tiles };
9
+ }
10
+ /* The width of a marker, in pixels. */
11
+ export const MAP_MARKER_SIZE = 20;
12
+ // Whole pixels to try around a place, nearest ring first: right, below, left, above, then the corners.
13
+ const SPREAD_STEPS = [
14
+ [20, 0],
15
+ [0, 20],
16
+ [-20, 0],
17
+ [0, -20],
18
+ [15, 15],
19
+ [-15, 15],
20
+ [-15, -15],
21
+ [15, -15],
22
+ [40, 0],
23
+ [0, 40],
24
+ [-40, 0],
25
+ [0, -40],
26
+ [30, 30],
27
+ [-30, 30],
28
+ [-30, -30],
29
+ [30, -30]
30
+ ];
31
+ /*
32
+ * Offsets, in pixels, that keep markers from covering each other. A marker that fits where it is stays; one
33
+ * that lands on an earlier marker moves to the nearest free spot beside it. The places are not moved on the
34
+ * map, only their markers, and the offsets are worked out again at every zoom, so a map zoomed in far
35
+ * enough draws them where they are.
36
+ */
37
+ export function spreadOverlaps(points) {
38
+ const offsets = new Map();
39
+ const placed = [];
40
+ for (const point of points) {
41
+ const free = ([dx, dy]) => placed.every((other) => Math.hypot(other.x - (point.x + dx), other.y - (point.y + dy)) >= MAP_MARKER_SIZE);
42
+ const step = [[0, 0], ...SPREAD_STEPS].find(free) ?? [0, 0];
43
+ offsets.set(point.id, [step[0], step[1]]);
44
+ placed.push({ x: point.x + step[0], y: point.y + step[1] });
45
+ }
46
+ return offsets;
47
+ }
4
48
  /* The map a page draws without tiles: land over water, from the outline of the world that ships with the package. */
5
49
  export function baseMapStyle({ land, water }) {
6
50
  return {
@@ -2,11 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import 'maplibre-gl/dist/maplibre-gl.css';
3
3
  import { useEffect, useRef, useState } from 'react';
4
4
  import { Map as MapLibreMap, Marker, setWorkerUrl } from 'maplibre-gl';
5
- import { Minus, Plus } from 'lucide-react';
5
+ import { Maximize, Minus, Plus } from 'lucide-react';
6
6
  import { useTranslation } from 'react-i18next';
7
7
  import { ButtonGroup, IconButton } from '@adecore/ui';
8
8
  import { useChatHost } from '../../../../host.js';
9
- import { baseMapStyle, boundsOf, localizedTextField, MAP_MAX_ZOOM } from './map-style.js';
9
+ import { baseMapStyle, boundsOf, localizedTextField, mapFitOptions, spreadOverlaps } from './map-style.js';
10
10
  const MARKER = 'flex size-5 items-center justify-center rounded-full bg-map-marker text-xs text-map-marker-text tabular-nums shadow-raised';
11
11
  const PICKED = 'z-1 bg-accent text-accent-text outline-2 outline-surface';
12
12
  // A tile that fails this many times with none loading in between leaves the map empty, so the outline stands in.
@@ -191,7 +191,7 @@ export function MapView({ markers, source, selected, onPick, onStatus }) {
191
191
  if (map === null || bounds === null) {
192
192
  return;
193
193
  }
194
- map.fitBounds(bounds, { padding: 32, maxZoom: outline ? MAP_MAX_ZOOM.outline : MAP_MAX_ZOOM.tiles, duration: 0 });
194
+ map.fitBounds(bounds, { ...mapFitOptions(outline), duration: 0 });
195
195
  // The points are compared by their coordinates, so a render that rebuilt the same list does not move the map.
196
196
  // oxlint-disable-next-line react-hooks/exhaustive-deps
197
197
  }, [map, pointsKey, outline]);
@@ -207,14 +207,29 @@ export function MapView({ markers, source, selected, onPick, onStatus }) {
207
207
  node.className = `${MARKER} ${marker.id === selected ? PICKED : ''} ${pickable ? 'cursor-pointer' : ''}`;
208
208
  node.textContent = String(marker.number);
209
209
  node.addEventListener('click', () => latest.current.onPick?.(marker.id));
210
- return new Marker({ element: node }).setLngLat([marker.lon, marker.lat]).addTo(map);
210
+ return { marker, drawing: new Marker({ element: node }).setLngLat([marker.lon, marker.lat]).addTo(map) };
211
211
  });
212
+ const spread = () => {
213
+ const offsets = spreadOverlaps(drawn.map(({ marker }) => ({ id: marker.id, ...map.project([marker.lon, marker.lat]) })));
214
+ for (const { marker, drawing } of drawn) {
215
+ drawing.setOffset(offsets.get(marker.id) ?? [0, 0]);
216
+ }
217
+ };
218
+ spread();
219
+ map.on('zoomend', spread);
212
220
  return () => {
213
- for (const marker of drawn) {
214
- marker.remove();
221
+ map.off('zoomend', spread);
222
+ for (const { drawing } of drawn) {
223
+ drawing.remove();
215
224
  }
216
225
  };
217
226
  // oxlint-disable-next-line react-hooks/exhaustive-deps
218
227
  }, [map, pointsKey, selected, pickable]);
219
- return (_jsxs("div", { className: "absolute inset-0", children: [_jsx("div", { ref: element, "aria-hidden": true, className: "size-full" }), _jsxs(ButtonGroup, { className: "absolute top-2 right-2 rounded-md border border-border bg-surface-raised shadow-raised", children: [_jsx(IconButton, { icon: Plus, size: "sm", label: t('blocks.map.zoomIn'), onClick: () => map?.zoomIn() }), _jsx(IconButton, { icon: Minus, size: "sm", label: t('blocks.map.zoomOut'), onClick: () => map?.zoomOut() })] })] }));
228
+ const showAll = () => {
229
+ const bounds = boundsOf(markers);
230
+ if (map !== null && bounds !== null) {
231
+ map.fitBounds(bounds, mapFitOptions(outline));
232
+ }
233
+ };
234
+ return (_jsxs("div", { className: "absolute inset-0", children: [_jsx("div", { ref: element, "aria-hidden": true, className: "size-full" }), _jsxs(ButtonGroup, { className: "absolute top-2 right-2 rounded-md border border-border bg-surface-raised shadow-raised", children: [_jsx(IconButton, { icon: Plus, size: "sm", label: t('blocks.map.zoomIn'), onClick: () => map?.zoomIn() }), _jsx(IconButton, { icon: Minus, size: "sm", label: t('blocks.map.zoomOut'), onClick: () => map?.zoomOut() }), markers.length > 1 && _jsx(IconButton, { icon: Maximize, size: "sm", label: t('blocks.map.showAll'), onClick: showAll })] })] }));
220
235
  }
@@ -74,5 +74,5 @@ export function EventRenderer({ node, children }) {
74
74
  const time = uiEventTime(at);
75
75
  // A neutral dot says nothing the title does not, and a detail is part of what the row says.
76
76
  const label = [time, uiNodeLabel(node), tone === 'neutral' ? '' : t(`blocks.event.tone.${tone}`), detail ?? ''].filter((part) => part !== '').join(', ');
77
- return (_jsxs("li", { "aria-label": label, className: "group/event relative flex min-h-7 gap-3 pb-3 last:pb-0", children: [_jsx("span", { "aria-hidden": true, className: clsx('mt-1 size-2 shrink-0 rounded-full bg-current', UI_TONE_TEXT[tone]) }), _jsx("span", { "aria-hidden": true, className: "absolute top-3.5 bottom-0 left-0.75 w-px bg-border group-last/event:hidden" }), _jsxs("div", { className: "flex min-w-0 grow flex-col gap-0.5", children: [parseUiEventTime(at) === null ? (_jsx("span", { className: "text-xs wrap-anywhere text-text-faint tabular-nums", children: time })) : (_jsx("time", { dateTime: at, className: "text-xs text-text-faint tabular-nums", children: time })), _jsx("div", { className: "text-sm wrap-break-word text-text", children: children }), detail !== undefined && detail !== '' && _jsx("div", { className: "text-xs wrap-anywhere text-text-faint", children: detail })] })] }));
77
+ return (_jsxs("li", { "aria-label": label, className: "group/event relative flex min-h-7 gap-3 pb-3 last:pb-0", children: [_jsx("span", { "aria-hidden": true, className: clsx('mt-1 size-2 shrink-0 rounded-full bg-current', UI_TONE_TEXT[tone]) }), _jsx("span", { "aria-hidden": true, className: "absolute top-3.5 bottom-0 left-0.75 w-px bg-border group-last/event:hidden" }), _jsxs("div", { className: "flex min-w-0 grow flex-col gap-0.5", children: [parseUiEventTime(at) === null ? (_jsx("span", { className: "text-xs wrap-anywhere text-text-muted tabular-nums", children: time })) : (_jsx("time", { dateTime: at, className: "text-xs text-text-muted tabular-nums", children: time })), _jsx("div", { className: "text-sm wrap-break-word text-text", children: children }), detail !== undefined && detail !== '' && _jsx("div", { className: "text-xs wrap-anywhere text-text-muted", children: detail })] })] }));
78
78
  }
@@ -599,6 +599,7 @@ declare const _default: {
599
599
  notFound: string;
600
600
  zoomIn: string;
601
601
  zoomOut: string;
602
+ showAll: string;
602
603
  scrollHint: string;
603
604
  touchHint: string;
604
605
  offline: string;
@@ -599,6 +599,7 @@ export default {
599
599
  notFound: 'Not on the map',
600
600
  zoomIn: 'Zoom in',
601
601
  zoomOut: 'Zoom out',
602
+ showAll: 'Show all places',
602
603
  scrollHint: 'Hold ⌘ or Ctrl and scroll to zoom',
603
604
  touchHint: 'Use two fingers to move the map',
604
605
  offline: 'Map tiles are not available offline. The places stay listed.',
@@ -599,6 +599,7 @@ declare const _default: {
599
599
  notFound: string;
600
600
  zoomIn: string;
601
601
  zoomOut: string;
602
+ showAll: string;
602
603
  scrollHint: string;
603
604
  touchHint: string;
604
605
  offline: string;
@@ -599,6 +599,7 @@ export default {
599
599
  notFound: 'Niet op de kaart',
600
600
  zoomIn: 'Inzoomen',
601
601
  zoomOut: 'Uitzoomen',
602
+ showAll: 'Alle plekken tonen',
602
603
  scrollHint: 'Houd ⌘ of Ctrl ingedrukt en scrol om te zoomen',
603
604
  touchHint: 'Gebruik twee vingers om de kaart te bewegen',
604
605
  offline: 'Kaarttegels zijn offline niet beschikbaar. De plekken blijven in de lijst.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents-react",
3
- "version": "0.25.5",
3
+ "version": "0.25.7",
4
4
  "description": "React agent chat threads, composers, approvals, provider accounts and usage views.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -67,11 +67,11 @@
67
67
  "test:visuals": "bun --config=integration.bunfig.toml --conditions=source test ./src/chat/visual-viewport.integration.test.ts"
68
68
  },
69
69
  "dependencies": {
70
- "@adecore/agent-contracts": "0.25.5",
71
- "@adecore/editor": "0.25.5",
72
- "@adecore/editor-react": "0.25.5",
73
- "@adecore/intelligent-ui": "0.25.5",
74
- "@adecore/merge": "0.25.5",
70
+ "@adecore/agent-contracts": "0.25.7",
71
+ "@adecore/editor": "0.25.7",
72
+ "@adecore/editor-react": "0.25.7",
73
+ "@adecore/intelligent-ui": "0.25.7",
74
+ "@adecore/merge": "0.25.7",
75
75
  "@codemirror/commands": "^6.11.1",
76
76
  "@codemirror/language": "^6.12.4",
77
77
  "@codemirror/state": "^6.7.6",
@@ -94,14 +94,14 @@
94
94
  "zustand": "^5.0.15"
95
95
  },
96
96
  "peerDependencies": {
97
- "@adecore/ui": "^0.25.5",
97
+ "@adecore/ui": "^0.25.7",
98
98
  "i18next": "^26.4.2",
99
99
  "react": "^19.3.0",
100
100
  "react-dom": "^19.3.0",
101
101
  "react-i18next": "^17.0.15"
102
102
  },
103
103
  "devDependencies": {
104
- "@adecore/ui": "0.25.5",
104
+ "@adecore/ui": "0.25.7",
105
105
  "@types/bun": "^1.4.2",
106
106
  "@types/react": "^19.3.0",
107
107
  "@types/react-dom": "^19.3.0",
@@ -507,7 +507,7 @@ function UiBlockBody({
507
507
  live={queries.live}
508
508
  nodes={evaluation.nodes}
509
509
  phase={context.phase}
510
- fixes={[...block.diagnostics, ...evaluation.diagnostics]}
510
+ fixes={[...block.diagnostics, ...evaluation.diagnostics].filter((diagnostic) => diagnostic.severity !== 'warning')}
511
511
  shownAsText={shownAsText}
512
512
  answered={answered}
513
513
  >
@@ -157,11 +157,11 @@ export function TextFieldRenderer({ node, children, context }: UiRendererProps<U
157
157
  placeholder={placeholder}
158
158
  disabled={binding === null}
159
159
  aria-describedby={counting ? countId : undefined}
160
- className="max-w-96"
160
+ className="max-w-96 bg-surface-sunken"
161
161
  onChange={(event) => binding?.onValueChange(event.target.value)}
162
162
  />
163
163
  {counting && (
164
- <span id={countId} className="text-xs text-text-faint tabular-nums">
164
+ <span id={countId} className="text-xs text-text-muted tabular-nums">
165
165
  {t('blocks.textField.count', { used: formatNumber(value.length), max: formatNumber(maxLength) })}
166
166
  </span>
167
167
  )}
@@ -21,6 +21,62 @@ export interface MapPoint {
21
21
  /* How far a map zooms to fit its points: street level with tiles, and only a country or two on the outline, which has nothing finer. */
22
22
  export const MAP_MAX_ZOOM = { tiles: 16, outline: 6 } as const;
23
23
 
24
+ /* The room around the places in a fitted view, in pixels. */
25
+ export const MAP_FIT_PADDING = 32;
26
+
27
+ /* What fits every place in view: the first view and the way back to it share it, so they cannot drift apart. */
28
+ export function mapFitOptions(outline: boolean): { padding: number; maxZoom: number } {
29
+ return { padding: MAP_FIT_PADDING, maxZoom: outline ? MAP_MAX_ZOOM.outline : MAP_MAX_ZOOM.tiles };
30
+ }
31
+
32
+ /* The width of a marker, in pixels. */
33
+ export const MAP_MARKER_SIZE = 20;
34
+
35
+ export interface ScreenPoint {
36
+ id: string;
37
+ x: number;
38
+ y: number;
39
+ }
40
+
41
+ // Whole pixels to try around a place, nearest ring first: right, below, left, above, then the corners.
42
+ const SPREAD_STEPS: readonly (readonly [number, number])[] = [
43
+ [20, 0],
44
+ [0, 20],
45
+ [-20, 0],
46
+ [0, -20],
47
+ [15, 15],
48
+ [-15, 15],
49
+ [-15, -15],
50
+ [15, -15],
51
+ [40, 0],
52
+ [0, 40],
53
+ [-40, 0],
54
+ [0, -40],
55
+ [30, 30],
56
+ [-30, 30],
57
+ [-30, -30],
58
+ [30, -30]
59
+ ];
60
+
61
+ /*
62
+ * Offsets, in pixels, that keep markers from covering each other. A marker that fits where it is stays; one
63
+ * that lands on an earlier marker moves to the nearest free spot beside it. The places are not moved on the
64
+ * map, only their markers, and the offsets are worked out again at every zoom, so a map zoomed in far
65
+ * enough draws them where they are.
66
+ */
67
+ export function spreadOverlaps(points: readonly ScreenPoint[]): Map<string, [number, number]> {
68
+ const offsets = new Map<string, [number, number]>();
69
+ const placed: { x: number; y: number }[] = [];
70
+ for (const point of points) {
71
+ const free = ([dx, dy]: readonly [number, number]): boolean =>
72
+ placed.every((other) => Math.hypot(other.x - (point.x + dx), other.y - (point.y + dy)) >= MAP_MARKER_SIZE);
73
+ const step = [[0, 0] as const, ...SPREAD_STEPS].find(free) ?? ([0, 0] as const);
74
+ offsets.set(point.id, [step[0], step[1]]);
75
+ placed.push({ x: point.x + step[0], y: point.y + step[1] });
76
+ }
77
+ return offsets;
78
+ }
79
+
24
80
  /* The map a page draws without tiles: land over water, from the outline of the world that ships with the package. */
25
81
  export function baseMapStyle({ land, water }: MapColors): MapStyle {
26
82
  return {
@@ -1,11 +1,11 @@
1
1
  import 'maplibre-gl/dist/maplibre-gl.css';
2
2
  import { useEffect, useRef, useState } from 'react';
3
3
  import { Map as MapLibreMap, Marker, setWorkerUrl } from 'maplibre-gl';
4
- import { Minus, Plus } from 'lucide-react';
4
+ import { Maximize, Minus, Plus } from 'lucide-react';
5
5
  import { useTranslation } from 'react-i18next';
6
6
  import { ButtonGroup, IconButton } from '@adecore/ui';
7
7
  import { useChatHost, type UiMapSource } from '../../../../host';
8
- import { baseMapStyle, boundsOf, localizedTextField, MAP_MAX_ZOOM, type MapColors, type MapPoint } from './map-style';
8
+ import { baseMapStyle, boundsOf, localizedTextField, mapFitOptions, spreadOverlaps, type MapColors, type MapPoint } from './map-style';
9
9
 
10
10
  /*
11
11
  * What the view is drawing: `base` by the source's choice, `offline` and `unavailable` as a fallback from tiles that
@@ -219,7 +219,7 @@ export function MapView({ markers, source, selected, onPick, onStatus }: MapView
219
219
  if (map === null || bounds === null) {
220
220
  return;
221
221
  }
222
- map.fitBounds(bounds, { padding: 32, maxZoom: outline ? MAP_MAX_ZOOM.outline : MAP_MAX_ZOOM.tiles, duration: 0 });
222
+ map.fitBounds(bounds, { ...mapFitOptions(outline), duration: 0 });
223
223
  // The points are compared by their coordinates, so a render that rebuilt the same list does not move the map.
224
224
  // oxlint-disable-next-line react-hooks/exhaustive-deps
225
225
  }, [map, pointsKey, outline]);
@@ -236,22 +236,39 @@ export function MapView({ markers, source, selected, onPick, onStatus }: MapView
236
236
  node.className = `${MARKER} ${marker.id === selected ? PICKED : ''} ${pickable ? 'cursor-pointer' : ''}`;
237
237
  node.textContent = String(marker.number);
238
238
  node.addEventListener('click', () => latest.current.onPick?.(marker.id));
239
- return new Marker({ element: node }).setLngLat([marker.lon, marker.lat]).addTo(map);
239
+ return { marker, drawing: new Marker({ element: node }).setLngLat([marker.lon, marker.lat]).addTo(map) };
240
240
  });
241
+ const spread = (): void => {
242
+ const offsets = spreadOverlaps(drawn.map(({ marker }) => ({ id: marker.id, ...map.project([marker.lon, marker.lat]) })));
243
+ for (const { marker, drawing } of drawn) {
244
+ drawing.setOffset(offsets.get(marker.id) ?? [0, 0]);
245
+ }
246
+ };
247
+ spread();
248
+ map.on('zoomend', spread);
241
249
  return () => {
242
- for (const marker of drawn) {
243
- marker.remove();
250
+ map.off('zoomend', spread);
251
+ for (const { drawing } of drawn) {
252
+ drawing.remove();
244
253
  }
245
254
  };
246
255
  // oxlint-disable-next-line react-hooks/exhaustive-deps
247
256
  }, [map, pointsKey, selected, pickable]);
248
257
 
258
+ const showAll = (): void => {
259
+ const bounds = boundsOf(markers);
260
+ if (map !== null && bounds !== null) {
261
+ map.fitBounds(bounds, mapFitOptions(outline));
262
+ }
263
+ };
264
+
249
265
  return (
250
266
  <div className="absolute inset-0">
251
267
  <div ref={element} aria-hidden className="size-full" />
252
268
  <ButtonGroup className="absolute top-2 right-2 rounded-md border border-border bg-surface-raised shadow-raised">
253
269
  <IconButton icon={Plus} size="sm" label={t('blocks.map.zoomIn')} onClick={() => map?.zoomIn()} />
254
270
  <IconButton icon={Minus} size="sm" label={t('blocks.map.zoomOut')} onClick={() => map?.zoomOut()} />
271
+ {markers.length > 1 && <IconButton icon={Maximize} size="sm" label={t('blocks.map.showAll')} onClick={showAll} />}
255
272
  </ButtonGroup>
256
273
  </div>
257
274
  );
@@ -152,14 +152,14 @@ export function EventRenderer({ node, children }: UiRendererProps<UiProps<'Event
152
152
  <div className="flex min-w-0 grow flex-col gap-0.5">
153
153
  {/* A time nobody could read stays as written and is no `dateTime` for a machine to read. */}
154
154
  {parseUiEventTime(at) === null ? (
155
- <span className="text-xs wrap-anywhere text-text-faint tabular-nums">{time}</span>
155
+ <span className="text-xs wrap-anywhere text-text-muted tabular-nums">{time}</span>
156
156
  ) : (
157
- <time dateTime={at} className="text-xs text-text-faint tabular-nums">
157
+ <time dateTime={at} className="text-xs text-text-muted tabular-nums">
158
158
  {time}
159
159
  </time>
160
160
  )}
161
161
  <div className="text-sm wrap-break-word text-text">{children}</div>
162
- {detail !== undefined && detail !== '' && <div className="text-xs wrap-anywhere text-text-faint">{detail}</div>}
162
+ {detail !== undefined && detail !== '' && <div className="text-xs wrap-anywhere text-text-muted">{detail}</div>}
163
163
  </div>
164
164
  </li>
165
165
  );
@@ -599,6 +599,7 @@ export default {
599
599
  notFound: 'Not on the map',
600
600
  zoomIn: 'Zoom in',
601
601
  zoomOut: 'Zoom out',
602
+ showAll: 'Show all places',
602
603
  scrollHint: 'Hold ⌘ or Ctrl and scroll to zoom',
603
604
  touchHint: 'Use two fingers to move the map',
604
605
  offline: 'Map tiles are not available offline. The places stay listed.',
@@ -599,6 +599,7 @@ export default {
599
599
  notFound: 'Niet op de kaart',
600
600
  zoomIn: 'Inzoomen',
601
601
  zoomOut: 'Uitzoomen',
602
+ showAll: 'Alle plekken tonen',
602
603
  scrollHint: 'Houd ⌘ of Ctrl ingedrukt en scrol om te zoomen',
603
604
  touchHint: 'Gebruik twee vingers om de kaart te bewegen',
604
605
  offline: 'Kaarttegels zijn offline niet beschikbaar. De plekken blijven in de lijst.',