@adecore/agents-react 0.24.2 → 0.25.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.
Files changed (103) hide show
  1. package/dist/chat/index.d.ts +4 -7
  2. package/dist/chat/index.js +4 -7
  3. package/dist/chat/logic/continue-after-stop.d.ts +3 -0
  4. package/dist/chat/logic/continue-after-stop.js +28 -0
  5. package/dist/chat/logic/thread-cards.d.ts +0 -1
  6. package/dist/chat/logic/thread-cards.js +0 -4
  7. package/dist/chat/preferences.d.ts +0 -1
  8. package/dist/chat/preferences.js +0 -3
  9. package/dist/chat/stash.d.ts +6 -0
  10. package/dist/chat/stash.js +13 -0
  11. package/dist/chat/ui/Composer.js +26 -5
  12. package/dist/chat/ui/Pickers.js +1 -1
  13. package/dist/chat/ui/composer/ComposerControls.d.ts +1 -0
  14. package/dist/chat/ui/composer/ComposerControls.js +3 -3
  15. package/dist/chat/ui/composer/use-composer-submit.d.ts +3 -1
  16. package/dist/chat/ui/composer/use-composer-submit.js +22 -4
  17. package/dist/chat/ui/composer/use-continuable-turn.d.ts +2 -0
  18. package/dist/chat/ui/composer/use-continuable-turn.js +12 -0
  19. package/dist/chat/ui/composer/use-notice.d.ts +3 -1
  20. package/dist/chat/ui/composer/use-notice.js +8 -6
  21. package/dist/chat/ui/index.d.ts +9 -28
  22. package/dist/chat/ui/index.js +8 -27
  23. package/dist/chat/ui/intelligent-ui/UiReply.js +20 -2
  24. package/dist/chat/ui/intelligent-ui/node-text.d.ts +3 -1
  25. package/dist/chat/ui/intelligent-ui/node-text.js +32 -0
  26. package/dist/chat/ui/intelligent-ui/registry.js +15 -5
  27. package/dist/chat/ui/intelligent-ui/render-context.d.ts +6 -0
  28. package/dist/chat/ui/intelligent-ui/renderers/base-land.d.ts +1 -0
  29. package/dist/chat/ui/intelligent-ui/renderers/base-land.js +6 -0
  30. package/dist/chat/ui/intelligent-ui/renderers/binding.d.ts +6 -0
  31. package/dist/chat/ui/intelligent-ui/renderers/binding.js +10 -0
  32. package/dist/chat/ui/intelligent-ui/renderers/content.d.ts +2 -0
  33. package/dist/chat/ui/intelligent-ui/renderers/content.js +68 -6
  34. package/dist/chat/ui/intelligent-ui/renderers/inputs.d.ts +2 -0
  35. package/dist/chat/ui/intelligent-ui/renderers/inputs.js +40 -13
  36. package/dist/chat/ui/intelligent-ui/renderers/map-slots.d.ts +20 -0
  37. package/dist/chat/ui/intelligent-ui/renderers/map-slots.js +87 -0
  38. package/dist/chat/ui/intelligent-ui/renderers/map-style.d.ts +18 -0
  39. package/dist/chat/ui/intelligent-ui/renderers/map-style.js +72 -0
  40. package/dist/chat/ui/intelligent-ui/renderers/map-view.d.ts +15 -0
  41. package/dist/chat/ui/intelligent-ui/renderers/map-view.js +213 -0
  42. package/dist/chat/ui/intelligent-ui/renderers/map.d.ts +3 -0
  43. package/dist/chat/ui/intelligent-ui/renderers/map.js +113 -0
  44. package/dist/chat/ui/intelligent-ui/renderers/place-point.d.ts +3 -0
  45. package/dist/chat/ui/intelligent-ui/renderers/place-point.js +13 -0
  46. package/dist/chat/ui/intelligent-ui/renderers/text.d.ts +2 -0
  47. package/dist/chat/ui/intelligent-ui/renderers/text.js +14 -1
  48. package/dist/chat/ui/intelligent-ui/use-in-view.d.ts +1 -0
  49. package/dist/chat/ui/intelligent-ui/use-in-view.js +17 -0
  50. package/dist/host.d.ts +16 -0
  51. package/dist/index.d.ts +2 -2
  52. package/dist/index.js +1 -1
  53. package/dist/locales/en/agent-chat.d.ts +47 -0
  54. package/dist/locales/en/agent-chat.js +48 -1
  55. package/dist/locales/nl/agent-chat.d.ts +47 -0
  56. package/dist/locales/nl/agent-chat.js +48 -1
  57. package/dist/prompts/index.d.ts +3 -5
  58. package/dist/prompts/index.js +3 -5
  59. package/dist/providers/index.d.ts +3 -9
  60. package/dist/providers/index.js +3 -9
  61. package/dist/state/index.d.ts +2 -2
  62. package/dist/state/index.js +2 -2
  63. package/dist/state/usage.d.ts +0 -1
  64. package/dist/state/usage.js +0 -3
  65. package/dist/theme.css +10 -0
  66. package/package.json +9 -8
  67. package/src/chat/index.ts +2 -23
  68. package/src/chat/logic/continue-after-stop.ts +32 -0
  69. package/src/chat/logic/thread-cards.ts +0 -5
  70. package/src/chat/preferences.ts +0 -4
  71. package/src/chat/stash.ts +17 -0
  72. package/src/chat/ui/Composer.tsx +33 -5
  73. package/src/chat/ui/Pickers.tsx +1 -0
  74. package/src/chat/ui/composer/ComposerControls.tsx +13 -3
  75. package/src/chat/ui/composer/use-composer-submit.ts +25 -5
  76. package/src/chat/ui/composer/use-continuable-turn.ts +14 -0
  77. package/src/chat/ui/composer/use-notice.ts +14 -7
  78. package/src/chat/ui/index.ts +9 -49
  79. package/src/chat/ui/intelligent-ui/UiReply.tsx +24 -1
  80. package/src/chat/ui/intelligent-ui/node-text.ts +34 -1
  81. package/src/chat/ui/intelligent-ui/registry.ts +33 -5
  82. package/src/chat/ui/intelligent-ui/render-context.ts +14 -0
  83. package/src/chat/ui/intelligent-ui/renderers/base-land.ts +7 -0
  84. package/src/chat/ui/intelligent-ui/renderers/binding.ts +14 -0
  85. package/src/chat/ui/intelligent-ui/renderers/content.tsx +144 -9
  86. package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +79 -17
  87. package/src/chat/ui/intelligent-ui/renderers/map-slots.ts +105 -0
  88. package/src/chat/ui/intelligent-ui/renderers/map-style.ts +96 -0
  89. package/src/chat/ui/intelligent-ui/renderers/map-view.tsx +251 -0
  90. package/src/chat/ui/intelligent-ui/renderers/map.tsx +226 -0
  91. package/src/chat/ui/intelligent-ui/renderers/place-point.ts +16 -0
  92. package/src/chat/ui/intelligent-ui/renderers/text.tsx +28 -1
  93. package/src/chat/ui/intelligent-ui/use-in-view.ts +18 -0
  94. package/src/host.ts +28 -0
  95. package/src/index.ts +2 -12
  96. package/src/locales/en/agent-chat.ts +48 -1
  97. package/src/locales/nl/agent-chat.ts +48 -1
  98. package/src/prompts/index.ts +3 -18
  99. package/src/providers/index.ts +3 -9
  100. package/src/state/index.ts +1 -16
  101. package/src/state/usage.ts +0 -4
  102. package/src/stylesheets.d.ts +2 -0
  103. package/src/theme.css +10 -0
@@ -1,8 +1,8 @@
1
1
  import clsx from 'clsx';
2
- import { createContext, useContext, useId } from 'react';
2
+ import { createContext, useContext, useId, useState, type ReactNode } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import type { UiProps } from '@adecore/intelligent-ui';
5
- import { PreviewCard, Tooltip } from '@adecore/ui';
5
+ import { Pill, PreviewCard, Tooltip, Wipe } from '@adecore/ui';
6
6
  import { formatNumber } from '@adecore/ui/format';
7
7
  import { useChatHost } from '../../../../host';
8
8
  import { CodeBlock } from '../../CodeBlock';
@@ -24,11 +24,39 @@ export function CodeBlockRenderer({ node }: UiRendererProps<UiProps<'CodeBlock'>
24
24
  );
25
25
  }
26
26
 
27
- /* Its own component, so the host's hook is only called for an image that names an attachment. */
28
- function AttachedImage({ scopeId, chatId, attachmentId, alt }: { scopeId: string; chatId: string; attachmentId: string; alt: string }) {
27
+ // Set by a Gallery, so the images inside it draw as cells of its grid.
28
+ const GalleryContext = createContext(false);
29
+
30
+ /*
31
+ * Its own component, so the host's hook is only called for an image that names an attachment. In a
32
+ * gallery a cell that cannot show its picture says what it was, since the square alone tells nothing.
33
+ */
34
+ function AttachedImage({
35
+ scopeId,
36
+ chatId,
37
+ attachmentId,
38
+ alt,
39
+ cell,
40
+ unnamed
41
+ }: {
42
+ scopeId: string;
43
+ chatId: string;
44
+ attachmentId: string;
45
+ alt: string;
46
+ cell: boolean;
47
+ unnamed: boolean;
48
+ }) {
29
49
  const host = useChatHost();
30
50
  const source = host.attachments.useUrl(scopeId, chatId, attachmentId);
31
- return <ImageThumb source={source} alt={alt} className="max-h-70 max-w-full object-contain" />;
51
+ if (!cell) {
52
+ return <ImageThumb source={source} alt={alt} className="max-h-70 max-w-full object-contain" />;
53
+ }
54
+ return (
55
+ <>
56
+ <ImageThumb source={source} alt={alt} aspect={1} />
57
+ {unnamed && source.failure !== null && <figcaption className="text-xs wrap-anywhere text-text-faint">{alt}</figcaption>}
58
+ </>
59
+ );
32
60
  }
33
61
 
34
62
  /*
@@ -37,21 +65,128 @@ function AttachedImage({ scopeId, chatId, attachmentId, alt }: { scopeId: string
37
65
  */
38
66
  export function ImageRenderer({ node, children, context }: UiRendererProps<UiProps<'Image'>>) {
39
67
  const { t } = useTranslation('agent-chat');
68
+ const cell = useContext(GalleryContext);
40
69
  const { attachment, alt } = node.props;
41
70
  const caption = uiNodeLabel(node);
42
71
  const name = alt ?? (caption === '' ? t('blocks.image.label') : caption);
43
72
  return (
44
- <figure className="flex flex-col items-start gap-1.5">
73
+ <figure className={cell ? 'flex min-w-0 flex-col gap-1' : 'flex flex-col items-start gap-1.5'}>
45
74
  {attachment === undefined ? (
46
- <ImageThumb source={{ url: null, failure: t('blocks.image.unavailable') }} alt={name} />
75
+ <ImageThumb source={{ url: null, failure: t('blocks.image.unavailable') }} alt={name} aspect={cell ? 1 : undefined} />
47
76
  ) : (
48
- <AttachedImage scopeId={context.scopeId} chatId={context.chatId} attachmentId={attachment} alt={name} />
77
+ <AttachedImage scopeId={context.scopeId} chatId={context.chatId} attachmentId={attachment} alt={name} cell={cell} unnamed={caption === ''} />
78
+ )}
79
+ {caption !== '' && <figcaption className={cell ? 'text-xs wrap-anywhere text-text-muted' : 'px-2 text-xs text-text-muted'}>{children}</figcaption>}
80
+ {cell && caption === '' && attachment === undefined && (
81
+ <figcaption className="text-xs wrap-anywhere text-text-faint">{alt ?? t('blocks.image.unavailable')}</figcaption>
49
82
  )}
50
- {caption !== '' && <figcaption className="px-2 text-xs text-text-muted">{children}</figcaption>}
51
83
  </figure>
52
84
  );
53
85
  }
54
86
 
87
+ /* Three columns, two in a narrow block; the container decides, not the window, since a chat can be any width. */
88
+ export function GalleryRenderer({ node, children }: UiRendererProps<UiProps<'Gallery'>>) {
89
+ const { t } = useTranslation('agent-chat');
90
+ return (
91
+ <GalleryContext value>
92
+ <div role="group" aria-label={t('blocks.gallery.label', { count: uiChildrenOf(node, 'Image').length })} className="@container px-2">
93
+ <div className="chat-ui-nodes grid grid-cols-2 gap-2 @min-[420px]:grid-cols-3">{children}</div>
94
+ </div>
95
+ </GalleryContext>
96
+ );
97
+ }
98
+
99
+ // The shape of the frame until the first picture reports its own.
100
+ const COMPARE_ASPECT = 4 / 3;
101
+
102
+ /* The tallest a comparison stands, in pixels: `max-h-96`. */
103
+ const COMPARE_MAX_HEIGHT = 384;
104
+
105
+ /*
106
+ * Two attachments in one frame, shaped like the first. The split is only this renderer's state. With one
107
+ * attachment gone the other stands alone with the reason; with both gone the thumbnail says why.
108
+ */
109
+ export function CompareRenderer({ node, children, context }: UiRendererProps<UiProps<'Compare'>>) {
110
+ const { t } = useTranslation('agent-chat');
111
+ const host = useChatHost();
112
+ const { before, after } = node.props;
113
+ const beforeLabel = node.props.beforeLabel || t('blocks.compare.before');
114
+ const afterLabel = node.props.afterLabel || t('blocks.compare.after');
115
+ const first = host.attachments.useUrl(context.scopeId, context.chatId, before);
116
+ const second = host.attachments.useUrl(context.scopeId, context.chatId, after);
117
+ const [split, setSplit] = useState(0.5);
118
+ const [aspect, setAspect] = useState(COMPARE_ASPECT);
119
+ const caption = uiNodeLabel(node);
120
+ // Each picture is named by its side and by what the whole comparison shows.
121
+ const describe = (side: string): string => (caption === '' ? side : `${side}: ${caption}`);
122
+ const loading = (source: typeof first) => source.url === null && source.failure === null;
123
+ const label = t('blocks.compare.label', { before: beforeLabel, after: afterLabel });
124
+ let body: ReactNode;
125
+ if (loading(first) || loading(second)) {
126
+ body = <ImageThumb source={{ url: null, failure: null }} alt={label} aspect={COMPARE_ASPECT} />;
127
+ } else if (first.url !== null && second.url !== null) {
128
+ body = (
129
+ // The frame keeps the picture's shape inside its tallest height: past it the width follows, so a portrait picture is not letterboxed.
130
+ <div
131
+ className="relative max-h-96 overflow-hidden rounded-lg border border-border bg-surface-sunken"
132
+ style={{ aspectRatio: aspect, width: `min(100%, ${COMPARE_MAX_HEIGHT * aspect}px)` }}
133
+ >
134
+ <Wipe
135
+ value={split}
136
+ onValueChange={setSplit}
137
+ label={label}
138
+ before={
139
+ <img
140
+ src={first.url}
141
+ alt={describe(beforeLabel)}
142
+ draggable={false}
143
+ className="size-full object-contain select-none"
144
+ onLoad={(event) => {
145
+ const { naturalWidth, naturalHeight } = event.currentTarget;
146
+ if (naturalWidth > 0 && naturalHeight > 0) {
147
+ setAspect(naturalWidth / naturalHeight);
148
+ }
149
+ }}
150
+ />
151
+ }
152
+ after={<img src={second.url} alt={describe(afterLabel)} draggable={false} className="size-full object-contain select-none" />}
153
+ />
154
+ <CompareChip side="left" label={beforeLabel} />
155
+ <CompareChip side="right" label={afterLabel} />
156
+ </div>
157
+ );
158
+ } else if (first.url === null && second.url === null) {
159
+ body = <ImageThumb source={first} alt={label} aspect={COMPARE_ASPECT} />;
160
+ } else {
161
+ const shown = first.url !== null ? { source: first, name: describe(beforeLabel) } : { source: second, name: describe(afterLabel) };
162
+ body = (
163
+ <>
164
+ <ImageThumb source={shown.source} alt={shown.name} className="max-h-70 max-w-full object-contain" />
165
+ <span className="px-2 text-xs text-text-faint">{t('blocks.compare.missing')}</span>
166
+ </>
167
+ );
168
+ }
169
+ return (
170
+ <figure className="flex flex-col items-start gap-1.5 px-2">
171
+ {body}
172
+ {caption !== '' && <figcaption className="text-xs text-text-muted">{children}</figcaption>}
173
+ </figure>
174
+ );
175
+ }
176
+
177
+ /* A chip on the picture gets a ground of its own, since a pill's alpha ground would vanish over an image. */
178
+ function CompareChip({ side, label }: { side: 'left' | 'right'; label: string }) {
179
+ return (
180
+ <span
181
+ className={clsx('pointer-events-none absolute top-2 z-20 max-w-2/5 truncate rounded-md bg-surface-raised', side === 'left' ? 'left-2' : 'right-2')}
182
+ >
183
+ <Pill shape="tag" size="sm" tone="muted">
184
+ {label}
185
+ </Pill>
186
+ </span>
187
+ );
188
+ }
189
+
55
190
  export function SourcesRenderer({ node, children }: UiRendererProps<UiProps<'Sources'>>) {
56
191
  const { t } = useTranslation('agent-chat');
57
192
  const headingId = useId();
@@ -1,11 +1,13 @@
1
- import { createContext, useContext, type MouseEvent, type ReactNode } from 'react';
1
+ import { createContext, useContext, useId, type MouseEvent, type ReactNode } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { useTranslation } from 'react-i18next';
4
- import type { UiBinding, UiProps, UiViewNode } from '@adecore/intelligent-ui';
5
- import { Button, Checkbox, Segmented, Slider, Switch, Tooltip } from '@adecore/ui';
6
- import { uiChildrenOf, uiNodeLabel, uiWithUnit } from '../node-text';
7
- import type { UiRenderContext, UiRendererProps } from '../render-context';
4
+ import type { UiProps } from '@adecore/intelligent-ui';
5
+ import { Button, Checkbox, Input, Segmented, Select, Slider, Switch, Tooltip } from '@adecore/ui';
6
+ import { formatNumber } from '@adecore/ui/format';
7
+ import { uiChildrenOf, uiNodeLabel, uiOwnLabel, uiWithUnit } from '../node-text';
8
+ import type { UiRendererProps } from '../render-context';
8
9
  import { useTruncated } from '../use-truncated';
10
+ import { bindingOf, PENDING } from './binding';
9
11
 
10
12
  type Scalar = UiProps<'Item'>['value'];
11
13
 
@@ -17,18 +19,6 @@ interface ChecklistState {
17
19
 
18
20
  const ChecklistContext = createContext<ChecklistState>({ values: [], enabled: false, toggle: () => undefined });
19
21
 
20
- /*
21
- * The binding of an input, or null for a read-only node. An input works once its own node closed and
22
- * only through its binding, so the value never leaves the block until a Choice carries it.
23
- */
24
- function bindingOf<Value>(node: UiViewNode<{ value: Value }>, context: UiRenderContext): UiBinding<Value> | null {
25
- const binding = node.bindings.value as UiBinding<Value> | undefined;
26
- return binding === undefined || !node.complete || context.answer !== null ? null : binding;
27
- }
28
-
29
- /* An input whose node has not closed yet stands at 60% and takes no pointer. */
30
- const PENDING = 'pointer-events-none opacity-60';
31
-
32
22
  /* The rows of a checklist as one named group. */
33
23
  function ChecklistGroup({ pending, children }: { pending: boolean; children: ReactNode }) {
34
24
  const { t } = useTranslation('agent-chat');
@@ -136,6 +126,78 @@ export function SegmentedRenderer({ node, context }: UiRendererProps<UiProps<'Se
136
126
  );
137
127
  }
138
128
 
129
+ /* The longest a text field takes when its agent names none; the schema allows up to 1024. */
130
+ const DEFAULT_MAX_LENGTH = 256;
131
+
132
+ /* The count under a field appears once the field is this full, and never nags before. */
133
+ const COUNT_FROM = 0.8;
134
+
135
+ /*
136
+ * A line of text: the label above the field, which stops taking characters at its limit. Nothing here binds
137
+ * a key or submits on Enter, so the value reaches the agent only through a Choice.
138
+ */
139
+ export function TextFieldRenderer({ node, children, context }: UiRendererProps<UiProps<'TextField'>>) {
140
+ const { t } = useTranslation('agent-chat');
141
+ const id = useId();
142
+ const binding = bindingOf(node, context);
143
+ const { maxLength = DEFAULT_MAX_LENGTH, placeholder } = node.props;
144
+ const value = binding?.value ?? node.props.value;
145
+ const countId = `${id}-count`;
146
+ const counting = value.length >= maxLength * COUNT_FROM;
147
+ return (
148
+ <div className={clsx('flex flex-col gap-1 px-2', !node.complete && PENDING)}>
149
+ <label htmlFor={id} className="text-sm text-text-muted">
150
+ {children}
151
+ </label>
152
+ <Input
153
+ id={id}
154
+ value={value}
155
+ size="sm"
156
+ maxLength={maxLength}
157
+ placeholder={placeholder}
158
+ disabled={binding === null}
159
+ aria-describedby={counting ? countId : undefined}
160
+ className="max-w-96"
161
+ onChange={(event) => binding?.onValueChange(event.target.value)}
162
+ />
163
+ {counting && (
164
+ <span id={countId} className="text-xs text-text-faint tabular-nums">
165
+ {t('blocks.textField.count', { used: formatNumber(value.length), max: formatNumber(maxLength) })}
166
+ </span>
167
+ )}
168
+ </div>
169
+ );
170
+ }
171
+
172
+ /*
173
+ * Left aligned, never the full width. Like Segmented, each Option is an item by its place, since a value may be any scalar.
174
+ * The text beside its options is its label, drawn above it and read as its name.
175
+ */
176
+ export function SelectRenderer({ node, context }: UiRendererProps<UiProps<'Select'>>) {
177
+ const { t } = useTranslation('agent-chat');
178
+ const binding = bindingOf(node, context);
179
+ const current = binding?.value ?? node.props.value;
180
+ const options = uiChildrenOf(node, 'Option').map((option, index) => ({ id: String(index), label: uiNodeLabel(option), value: option.props.value }));
181
+ const label = uiOwnLabel(node);
182
+ return (
183
+ <div className={clsx('flex flex-col items-start gap-1 px-2', !node.complete && PENDING)}>
184
+ {label !== '' && <span className="text-sm text-text-muted">{label}</span>}
185
+ <Select
186
+ value={options.find((option) => option.value === current)?.id ?? null}
187
+ items={options.map((option) => ({ value: option.id, label: option.label }))}
188
+ label={label === '' ? t('blocks.options') : label}
189
+ disabled={binding === null || options.length === 0}
190
+ onValueChange={(id) => {
191
+ const option = options[Number(id)];
192
+ if (option !== undefined) {
193
+ binding?.onValueChange(option.value);
194
+ }
195
+ }}
196
+ />
197
+ </div>
198
+ );
199
+ }
200
+
139
201
  /* Runs its local action once its own node closed; like an input, it closes once the block is answered. A label cut to the button's one line reads whole in its tooltip. */
140
202
  export function ButtonRenderer({ node, children, context }: UiRendererProps<UiProps<'Button'>>) {
141
203
  const enabled = node.onAction !== undefined && node.complete && context.answer === null && node.props.disabled !== true;
@@ -0,0 +1,105 @@
1
+ import { useEffect, useSyncExternalStore } from 'react';
2
+
3
+ /*
4
+ * A map holds a WebGL context for as long as it is drawn, and a browser keeps only so many of them before
5
+ * it drops the oldest without a word. This is the page's budget of them for maps, kept under what the
6
+ * terminals of `@adecore/terminal` take from the same pool.
7
+ */
8
+ export const MAX_LIVE_MAPS = 4;
9
+
10
+ export type MapSlotState = 'idle' | 'granted' | 'queued';
11
+
12
+ /*
13
+ * Who may draw a map right now. A map in view asks for a slot and gets one while there are fewer than
14
+ * `capacity` held; the others queue, in the order they asked, and are granted a slot as one is given back.
15
+ * A person can `take` one for a queued map: the longest-held map gives its slot up and queues first.
16
+ */
17
+ export class MapSlots {
18
+ private readonly capacity: number;
19
+ private readonly held: string[] = [];
20
+ private readonly queue: string[] = [];
21
+ private readonly listeners = new Set<() => void>();
22
+
23
+ constructor(capacity: number) {
24
+ this.capacity = capacity;
25
+ }
26
+
27
+ request(id: string): void {
28
+ if (this.held.includes(id) || this.queue.includes(id)) {
29
+ return;
30
+ }
31
+ (this.held.length < this.capacity ? this.held : this.queue).push(id);
32
+ this.changed();
33
+ }
34
+
35
+ release(id: string): void {
36
+ const wasHeld = this.held.includes(id);
37
+ const wasQueued = this.queue.includes(id);
38
+ if (!wasHeld && !wasQueued) {
39
+ return;
40
+ }
41
+ this.held.splice(0, this.held.length, ...this.held.filter((entry) => entry !== id));
42
+ this.queue.splice(0, this.queue.length, ...this.queue.filter((entry) => entry !== id));
43
+ while (this.held.length < this.capacity && this.queue.length > 0) {
44
+ this.held.push(this.queue.shift()!);
45
+ }
46
+ this.changed();
47
+ }
48
+
49
+ take(id: string): void {
50
+ if (this.held.includes(id)) {
51
+ return;
52
+ }
53
+ const at = this.queue.indexOf(id);
54
+ if (at >= 0) {
55
+ this.queue.splice(at, 1);
56
+ }
57
+ if (this.held.length >= this.capacity) {
58
+ this.queue.unshift(this.held.shift()!);
59
+ }
60
+ this.held.push(id);
61
+ this.changed();
62
+ }
63
+
64
+ stateOf(id: string): MapSlotState {
65
+ return this.held.includes(id) ? 'granted' : this.queue.includes(id) ? 'queued' : 'idle';
66
+ }
67
+
68
+ subscribe = (listener: () => void): (() => void) => {
69
+ this.listeners.add(listener);
70
+ return () => {
71
+ this.listeners.delete(listener);
72
+ };
73
+ };
74
+
75
+ private changed(): void {
76
+ for (const listener of [...this.listeners]) {
77
+ listener();
78
+ }
79
+ }
80
+ }
81
+
82
+ let page: MapSlots | null = null;
83
+
84
+ /* The one budget of the page, made on first use. */
85
+ export function pageMapSlots(): MapSlots {
86
+ page ??= new MapSlots(MAX_LIVE_MAPS);
87
+ return page;
88
+ }
89
+
90
+ /* A map's place in the budget while it `wanted`, which is while it is in view; gone from it otherwise and on unmount. */
91
+ export function useMapSlot(id: string, wanted: boolean, slots: MapSlots = pageMapSlots()): { state: MapSlotState; take(): void } {
92
+ const state = useSyncExternalStore(
93
+ slots.subscribe,
94
+ () => slots.stateOf(id),
95
+ () => 'idle' as const
96
+ );
97
+ useEffect(() => {
98
+ if (!wanted) {
99
+ return;
100
+ }
101
+ slots.request(id);
102
+ return () => slots.release(id);
103
+ }, [slots, id, wanted]);
104
+ return { state, take: () => slots.take(id) };
105
+ }
@@ -0,0 +1,96 @@
1
+ import type { LngLatBoundsLike, MapOptions } from 'maplibre-gl';
2
+ import { BASE_LAND_JSON } from './base-land';
3
+
4
+ // The package does not export its style type, only the options that take one.
5
+ export type MapStyle = Exclude<MapOptions['style'], string | undefined>;
6
+
7
+ type Expression = unknown[];
8
+
9
+ /* The two grounds of the built-in map, as the page paints them. */
10
+ export interface MapColors {
11
+ land: string;
12
+ water: string;
13
+ }
14
+
15
+ export interface MapPoint {
16
+ id: string;
17
+ lat: number;
18
+ lon: number;
19
+ }
20
+
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
+ export const MAP_MAX_ZOOM = { tiles: 16, outline: 6 } as const;
23
+
24
+ /* The map a page draws without tiles: land over water, from the outline of the world that ships with the package. */
25
+ export function baseMapStyle({ land, water }: MapColors): MapStyle {
26
+ return {
27
+ version: 8,
28
+ sources: { land: { type: 'geojson', data: JSON.parse(BASE_LAND_JSON) } },
29
+ layers: [
30
+ { id: 'water', type: 'background', paint: { 'background-color': water } },
31
+ { id: 'land', type: 'fill', source: 'land', paint: { 'fill-color': land } }
32
+ ]
33
+ };
34
+ }
35
+
36
+ const TOKEN = /\{([^{}]+)\}/g;
37
+
38
+ /* A `{name}` text field as the expression it stands for, since a field combined with another expression is no longer read for tokens. */
39
+ function tokenExpression(text: string): Expression | string {
40
+ const parts: (Expression | string)[] = [];
41
+ let at = 0;
42
+ for (const match of text.matchAll(TOKEN)) {
43
+ if (match.index > at) {
44
+ parts.push(text.slice(at, match.index));
45
+ }
46
+ parts.push(['to-string', ['coalesce', ['get', match[1]], '']]);
47
+ at = match.index + match[0].length;
48
+ }
49
+ if (parts.length === 0) {
50
+ return text;
51
+ }
52
+ if (at < text.length) {
53
+ parts.push(text.slice(at));
54
+ }
55
+ return parts.length === 1 && typeof parts[0] !== 'string' ? parts[0] : ['concat', ...parts];
56
+ }
57
+
58
+ /*
59
+ * A label that prefers the property `field` of a feature and falls back to what the style wrote. A text
60
+ * field that is neither a string nor an expression (a legacy zoom function) is left as it is.
61
+ */
62
+ export function localizedTextField(original: unknown, field: string): unknown {
63
+ const fallback = typeof original === 'string' ? tokenExpression(original) : original;
64
+ if (typeof fallback !== 'string' && !Array.isArray(fallback)) {
65
+ return original;
66
+ }
67
+ return ['coalesce', ['get', field], fallback];
68
+ }
69
+
70
+ /*
71
+ * The bounds that hold every point, or null for none. Longitudes go round the globe, so the bounds leave out
72
+ * the widest gap between two neighbors: points on both sides of the date line span a few degrees, not 360.
73
+ * The east edge may then pass 180, which the map takes.
74
+ */
75
+ export function boundsOf(points: readonly MapPoint[]): LngLatBoundsLike | null {
76
+ if (points.length === 0) {
77
+ return null;
78
+ }
79
+ const lons = points.map((point) => point.lon).sort((a, b) => a - b);
80
+ let widest = lons[0]! + 360 - lons[lons.length - 1]!;
81
+ let west = lons[0]!;
82
+ let east = lons[lons.length - 1]!;
83
+ for (let i = 1; i < lons.length; i++) {
84
+ const gap = lons[i]! - lons[i - 1]!;
85
+ if (gap > widest) {
86
+ widest = gap;
87
+ west = lons[i]!;
88
+ east = lons[i - 1]! + 360;
89
+ }
90
+ }
91
+ const lats = points.map((point) => point.lat);
92
+ return [
93
+ [west, Math.min(...lats)],
94
+ [east, Math.max(...lats)]
95
+ ];
96
+ }