@k2b/cloud 0.45.0 → 0.47.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 (131) hide show
  1. package/package.json +11 -4
  2. package/scripts/build.ts +5 -1
  3. package/scripts/preload.ts +5 -1
  4. package/src/_internal/app-queries.ts +73 -0
  5. package/src/_internal/capabilities.ts +137 -9
  6. package/src/_internal/define-app.ts +40 -0
  7. package/src/_internal/heartbeat.ts +19 -0
  8. package/src/_internal/postgres-application-name.ts +11 -0
  9. package/src/_internal/process-sync.ts +9 -0
  10. package/src/_internal/readiness.ts +6 -1
  11. package/src/_internal/registry-validation.ts +17 -4
  12. package/src/_internal/registry.ts +32 -4
  13. package/src/_internal/resource-principals.ts +6 -0
  14. package/src/_internal/workload-credential.ts +153 -0
  15. package/src/_internal/workload-protocol.ts +36 -0
  16. package/src/ai/application-tasks.ts +5 -2
  17. package/src/ai/capabilities.ts +4 -2
  18. package/src/ai/chart-block.ts +36 -711
  19. package/src/ai/chat/map-messages.ts +270 -0
  20. package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
  21. package/src/ai/chat/map-notebook.tsx +301 -0
  22. package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
  23. package/src/ai/chat/map-widget.css +107 -0
  24. package/src/ai/chat/map-widget.tsx +1279 -0
  25. package/src/ai/chat/message-utils.ts +1 -0
  26. package/src/ai/chat/tool-names.ts +2 -0
  27. package/src/ai/chat/turn-layout.ts +2 -1
  28. package/src/ai/chat/turn-view.tsx +17 -1
  29. package/src/ai/default-tool-contracts.ts +21 -259
  30. package/src/ai/default-tools.ts +3 -0
  31. package/src/ai/executor.ts +13 -1
  32. package/src/ai/map-contracts.ts +209 -0
  33. package/src/ai/map-execution.ts +14 -0
  34. package/src/ai/map-notebook-save.ts +276 -0
  35. package/src/ai/map-notebook.ts +32 -0
  36. package/src/ai/map-opening-hours.ts +40 -0
  37. package/src/ai/map-routes.ts +84 -0
  38. package/src/ai/map-service.ts +288 -0
  39. package/src/ai/map-store.ts +137 -0
  40. package/src/ai/map-tool.ts +73 -0
  41. package/src/ai/migrate.ts +3 -0
  42. package/src/ai/protocol.ts +3 -1
  43. package/src/ai/routes.ts +2 -0
  44. package/src/ai/store.ts +19 -6
  45. package/src/ai/tools.ts +14 -0
  46. package/src/ai/types.ts +3 -0
  47. package/src/api/admin-app-keys.ts +66 -0
  48. package/src/api/admin-core-settings.ts +2 -0
  49. package/src/api/admin-identity.ts +27 -1
  50. package/src/api/admin-settings-env.ts +120 -0
  51. package/src/api/capabilities.ts +99 -15
  52. package/src/api/index.ts +3 -0
  53. package/src/api/mcp.ts +4 -2
  54. package/src/api/settings-ops.ts +35 -0
  55. package/src/app-key.ts +125 -0
  56. package/src/browser/CloudResourceSearch.tsx +48 -41
  57. package/src/browser/notifications.ts +25 -8
  58. package/src/capabilities/discussion-providers.ts +100 -0
  59. package/src/capabilities/executions.ts +2 -2
  60. package/src/capabilities/migrate.ts +10 -1
  61. package/src/capabilities/resource-readers.ts +69 -0
  62. package/src/capabilities/response.ts +4 -1
  63. package/src/capabilities/server-invoke.ts +64 -36
  64. package/src/capabilities/server.ts +17 -6
  65. package/src/capabilities/testing.ts +13 -0
  66. package/src/capabilities/types.ts +1 -1
  67. package/src/cli/admin/app-credentials.ts +32 -7
  68. package/src/cli/admin/app-keys.ts +226 -0
  69. package/src/cli/admin/index.ts +7 -1
  70. package/src/cli/admin/settings.ts +62 -0
  71. package/src/config/define-env.ts +63 -8
  72. package/src/config/env.ts +27 -21
  73. package/src/config/index.ts +12 -1
  74. package/src/config/workflow-ai.ts +28 -0
  75. package/src/contracts/capabilities.ts +137 -4
  76. package/src/contracts/discussions.ts +91 -0
  77. package/src/contracts/geo-document.ts +60 -0
  78. package/src/contracts/geo-snapshot.ts +86 -0
  79. package/src/contracts/geo.ts +14 -0
  80. package/src/contracts/index.ts +2 -0
  81. package/src/contracts/outgoing-mail.ts +1 -1
  82. package/src/contracts/resource-types.ts +21 -0
  83. package/src/contracts/settings-env.ts +21 -0
  84. package/src/contracts/settings-types.ts +6 -0
  85. package/src/geo/AddressAutocomplete.tsx +213 -0
  86. package/src/geo/messages.ts +29 -0
  87. package/src/geo/navigation.ts +25 -0
  88. package/src/geo/ui.ts +2 -0
  89. package/src/index.ts +9 -0
  90. package/src/runtime/clients.ts +72 -0
  91. package/src/runtime/connections.ts +37 -0
  92. package/src/runtime/resolve-connections.ts +91 -0
  93. package/src/server/client-ip.ts +1 -1
  94. package/src/services/accounts/app.ts +28 -6
  95. package/src/services/accounts/entities.ts +11 -4
  96. package/src/services/accounts/groups.ts +10 -4
  97. package/src/services/accounts/local-groups.ts +87 -38
  98. package/src/services/accounts/machine-group-management.ts +94 -0
  99. package/src/services/app-keys.ts +298 -0
  100. package/src/services/gateway.ts +6 -0
  101. package/src/services/geo/cache.ts +2 -1
  102. package/src/services/geo/geoapify.ts +3 -0
  103. package/src/services/identity/index.ts +3 -0
  104. package/src/services/identity/invocation-token.ts +110 -0
  105. package/src/services/identity/resource-readers.ts +80 -0
  106. package/src/services/identity/runtime-config.ts +14 -6
  107. package/src/services/identity/workload-runtime.ts +253 -0
  108. package/src/services/index.ts +4 -1
  109. package/src/services/outgoing-mail/resolve-host.ts +5 -4
  110. package/src/services/pdf/geo-snapshot.ts +107 -0
  111. package/src/services/pdf/index.ts +2 -0
  112. package/src/services/service-account-credentials.ts +63 -21
  113. package/src/services/settings/api.ts +3 -3
  114. package/src/services/settings/binding.ts +45 -0
  115. package/src/services/settings/core-settings.ts +99 -1
  116. package/src/services/settings/defaults.ts +16 -2
  117. package/src/services/settings/env-status.ts +69 -0
  118. package/src/services/settings/index.ts +69 -26
  119. package/src/services/settings/store.ts +73 -8
  120. package/src/services/settings/validation.ts +40 -0
  121. package/src/shared/index.ts +11 -10
  122. package/src/shared/markdown/extensions/tables.ts +10 -1
  123. package/src/ssr/AdminArea.tsx +2 -2
  124. package/src/ssr/AdminLayout.tsx +1 -1
  125. package/src/ssr/AdminSidebar.tsx +1 -1
  126. package/src/start.ts +36 -0
  127. package/src/styles/global.css +2 -0
  128. package/src/workflows/ai/index.ts +1 -0
  129. package/src/workflows/ai/runtime.ts +16 -8
  130. package/src/workflows/ai/settings.ts +5 -0
  131. package/src/shared/markdown/formula.ts +0 -1283
@@ -0,0 +1,1279 @@
1
+ import type { MapViewLine, MapViewMarker, MapViewOwnLocation } from "@k2b/ui";
2
+ import { Button, ButtonLink, Checkbox, NumberInput, Select, TextInput, useLocale } from "@k2b/ui";
3
+ import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";
4
+ import { z } from "zod";
5
+ import { requestSearchNavigation } from "../../browser/search-bridge";
6
+ import type { GeoCategorySchema, GeoPoint } from "../../contracts/geo";
7
+ import type { GeoRouteOptions } from "../../contracts/geo-route";
8
+ import { AddressAutocomplete } from "../../geo/AddressAutocomplete";
9
+ import { CloudMap } from "../../geo/CloudMap";
10
+ import { geoNavigationHref, geoNavigationPlatform } from "../../geo/navigation";
11
+ import { type AiMap, type AiMapCommand, AiMapReferenceSchema, AiMapSchema, AiMapToolResultSchema, aiMapWebsite } from "../map-contracts";
12
+ import { mapOpeningHours } from "../map-opening-hours";
13
+ import type { AiTurnBlock } from "../protocol";
14
+ import { mapMessages } from "./map-messages";
15
+ import { MapNotebookSave } from "./map-notebook";
16
+
17
+ type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
18
+ const responseSchema = z.object({ map: AiMapSchema, canAdmin: z.boolean().optional() });
19
+ const eventName = "cloud:assistant-map";
20
+ export const mapAnchor = (id: string) => `assistant-map-${id}`;
21
+
22
+ /** Native result at the shared chat result seam, including follow-up links. Never an HTML artifact. */
23
+ export function AiMapToolBlock(props: { block: ToolBlock }) {
24
+ const parsed = createMemo(() => {
25
+ const result = AiMapToolResultSchema.safeParse(props.block.result);
26
+ return result.success ? result.data : null;
27
+ });
28
+ const locale = useLocale();
29
+ const t = () => mapMessages(locale());
30
+ const initialFull = AiMapSchema.safeParse(parsed()?.map);
31
+ const [loaded, setLoaded] = createSignal<AiMap | undefined>(initialFull.success ? initialFull.data : undefined);
32
+ createEffect(() => {
33
+ const result = parsed();
34
+ if (!result) return;
35
+ const full = AiMapSchema.safeParse(result.map);
36
+ if (full.success) setLoaded(full.data);
37
+ if (typeof window === "undefined") return;
38
+ window.dispatchEvent(new CustomEvent(eventName, { detail: result.map }));
39
+ if (full.success || !result.anchor) return;
40
+ const abort = new AbortController();
41
+ onCleanup(() => abort.abort());
42
+ void fetch(`/api/ai/conversations/${encodeURIComponent(result.map.conversationId)}/maps/${result.map.id}`, { signal: abort.signal })
43
+ .then(async (response) => {
44
+ if (!response.ok) throw new Error("MAP_LOAD_FAILED");
45
+ const value = responseSchema.parse(await response.json());
46
+ if (!abort.signal.aborted) setLoaded(value.map);
47
+ })
48
+ .catch(() => {
49
+ if (!abort.signal.aborted) setLoadFailed(true);
50
+ });
51
+ });
52
+ const [loadFailed, setLoadFailed] = createSignal(false);
53
+ return (
54
+ <Show
55
+ when={parsed()}
56
+ fallback={
57
+ <p role="status" class="text-dimmed">
58
+ {props.block.status === "running" ? t().loading : t().failed}
59
+ </p>
60
+ }
61
+ >
62
+ {(result) => (
63
+ <Show
64
+ when={result().anchor}
65
+ fallback={
66
+ <a
67
+ class="ai-map__jump"
68
+ href={`?conversation=${encodeURIComponent(result().map.conversationId)}${result().map.anchorSeq ? `&message=${result().map.anchorSeq}` : ""}#${mapAnchor(result().map.id)}`}
69
+ onClick={async (event) => {
70
+ const href = event.currentTarget.href;
71
+ const target = document.getElementById(mapAnchor(result().map.id));
72
+ if (target) {
73
+ event.preventDefault();
74
+ target.scrollIntoView({ block: "start", behavior: "instant" });
75
+ target.focus({ preventScroll: true });
76
+ } else {
77
+ event.preventDefault();
78
+ try {
79
+ const current = responseSchema.parse(
80
+ await (
81
+ await fetch(`/api/ai/conversations/${encodeURIComponent(result().map.conversationId)}/maps/${result().map.id}`)
82
+ ).json(),
83
+ ).map;
84
+ // The host can be full Assistant or its panel. Keep the link's SSR/browser path
85
+ // when loading an older map instead of navigating the panel to the full app.
86
+ const targetUrl = new URL(href);
87
+ targetUrl.searchParams.set("conversation", current.conversationId);
88
+ if (current.anchorSeq) targetUrl.searchParams.set("message", String(current.anchorSeq));
89
+ targetUrl.hash = mapAnchor(current.id);
90
+ const destination = targetUrl.href;
91
+ const handled = await requestSearchNavigation({
92
+ href: destination,
93
+ ref: { type: "assistant.chat", id: current.conversationId },
94
+ });
95
+ if (!handled) window.location.assign(destination);
96
+ else document.getElementById(mapAnchor(current.id))?.scrollIntoView({ block: "start" });
97
+ } catch {
98
+ setLoadFailed(true);
99
+ }
100
+ }
101
+ }}
102
+ >
103
+ {t().jump}: {result().map.title}
104
+ <Show when={loadFailed()}>
105
+ <span role="alert"> · {t().failed}</span>
106
+ </Show>
107
+ </a>
108
+ }
109
+ >
110
+ <Show
111
+ when={loaded()}
112
+ fallback={
113
+ <section
114
+ id={mapAnchor(result().map.id)}
115
+ class="ai-map ai-map__placeholder"
116
+ data-kind={result().map.kind}
117
+ aria-busy={!loadFailed()}
118
+ >
119
+ <p role="status">{loadFailed() ? t().failed : t().loading}</p>
120
+ <Show when={loadFailed()}>
121
+ <Button size="sm" onClick={() => window.location.reload()}>
122
+ {t().reload}
123
+ </Button>
124
+ </Show>
125
+ </section>
126
+ }
127
+ >
128
+ {(value) => <AssistantMap initial={value()} loading={props.block.status === "running"} />}
129
+ </Show>
130
+ </Show>
131
+ )}
132
+ </Show>
133
+ );
134
+ }
135
+
136
+ export function AssistantMap(props: { initial: AiMap; loading?: boolean }) {
137
+ const locale = useLocale();
138
+ const t = () => mapMessages(locale());
139
+ const [map, setMap] = createSignal(props.initial);
140
+ const [selected, setSelected] = createSignal(props.initial.selectionId ?? "");
141
+ const [expanded, setExpanded] = createSignal(false);
142
+ const [busy, setBusy] = createSignal(false);
143
+ const [error, setError] = createSignal("");
144
+ const [conflict, setConflict] = createSignal(false);
145
+ const [canAdmin, setCanAdmin] = createSignal(false);
146
+ const [mode, setMode] = createSignal<"ready" | "insert" | "options" | "along" | "start" | "save" | "repair">("ready");
147
+ const [width, setWidth] = createSignal(796);
148
+ const [location, setLocation] = createSignal<MapViewOwnLocation>();
149
+ const [locationError, setLocationError] = createSignal("");
150
+ const [start, setStart] = createSignal("");
151
+ const [category, setCategory] = createSignal<z.infer<typeof GeoCategorySchema>>("attraction");
152
+ const [placeName, setPlaceName] = createSignal(props.initial.lastSearch?.name ?? "");
153
+ const [fromKm, setFromKm] = createSignal(40);
154
+ const [toKm, setToKm] = createSignal(80);
155
+ const [travelMode, setTravelMode] = createSignal<"car" | "bike" | "walk">("car");
156
+ const [avoid, setAvoid] = createSignal<NonNullable<GeoRouteOptions["avoid"]>>([]);
157
+ const [repairStop, setRepairStop] = createSignal("");
158
+ const [repairPoint, setRepairPoint] = createSignal<GeoPoint>();
159
+ const [scope, setScope] = createSignal("all");
160
+ const [navigationPlatform, setNavigationPlatform] = createSignal<ReturnType<typeof geoNavigationPlatform>>("desktop");
161
+ const candidateIndex = () => {
162
+ const index = Number(/^candidate-(\d)$/.exec(selection())?.[1] ?? 0);
163
+ return index < (map().candidates?.length ?? 0) ? index : 0;
164
+ };
165
+ const waiting = () => busy() || (map().pending && props.loading);
166
+ const candidateRows = () => (
167
+ <div
168
+ role="listbox"
169
+ aria-label={t().choose}
170
+ tabIndex={0}
171
+ onKeyDown={(event) => {
172
+ const count = map().candidates?.length ?? 0;
173
+ const current = candidateIndex();
174
+ const next =
175
+ event.key === "Home"
176
+ ? 0
177
+ : event.key === "End"
178
+ ? count - 1
179
+ : event.key === "ArrowDown"
180
+ ? Math.min(count - 1, current + 1)
181
+ : event.key === "ArrowUp"
182
+ ? Math.max(0, current - 1)
183
+ : undefined;
184
+ if (next !== undefined) {
185
+ event.preventDefault();
186
+ setSelected(`candidate-${next}`);
187
+ }
188
+ if (event.key === "Enter") void run({ type: "choose", index: candidateIndex() });
189
+ }}
190
+ aria-activedescendant={`${mapAnchor(map().id)}-candidate-${candidateIndex()}`}
191
+ >
192
+ <p>{t().choose}</p>
193
+ <Show when={costConfirmationRequired()}>
194
+ <p role="status">{t().cost}</p>
195
+ </Show>
196
+ <For each={map().candidates}>
197
+ {(candidate, index) => (
198
+ <Button
199
+ variant="ghost"
200
+ size="sm"
201
+ onPointerEnter={() => setSelected(`candidate-${index()}`)}
202
+ onFocus={() => setSelected(`candidate-${index()}`)}
203
+ onClick={() => setSelected(`candidate-${index()}`)}
204
+ role="option"
205
+ tabIndex={-1}
206
+ id={`${mapAnchor(map().id)}-candidate-${index()}`}
207
+ aria-selected={candidateIndex() === index()}
208
+ disabled={busy()}
209
+ class="ai-map__candidate"
210
+ title={candidate.formatted}
211
+ >
212
+ <span>
213
+ {index() + 1}. {candidate.formatted}
214
+ </span>
215
+ </Button>
216
+ )}
217
+ </For>
218
+ </div>
219
+ );
220
+ let root!: HTMLElement;
221
+ let disposed = false;
222
+ const controller = new AbortController();
223
+ const narrow = () => width() < 600;
224
+ const panel = () => width() <= 368 && typeof window !== "undefined" && window.innerWidth >= 800;
225
+ const url = () => `/api/ai/conversations/${encodeURIComponent(map().conversationId)}/maps/${map().id}`;
226
+ const number = (value: number, digits = 0) => new Intl.NumberFormat(locale(), { maximumFractionDigits: digits }).format(value);
227
+ const km = (value: number) => `${number(value / 1000, 1)} km`;
228
+ const minutes = (value: number) => `${number(Math.round(value / 60))} min`;
229
+ const time = (value: string) =>
230
+ new Intl.DateTimeFormat(locale(), { hour: "2-digit", minute: "2-digit", timeZone: map().timeZone ?? "UTC" }).format(new Date(value));
231
+ const update = (value: AiMap) => {
232
+ if (value.id !== map().id || value.conversationId !== map().conversationId || value.revision < map().revision) return;
233
+ if (value.revision > map().revision && value.selectionId !== map().selectionId) setSelected(value.selectionId ?? "");
234
+ setMap(value);
235
+ };
236
+ createEffect(() => update(props.initial));
237
+ const publish = (value: AiMap) => {
238
+ update(value);
239
+ window.dispatchEvent(new CustomEvent(eventName, { detail: value }));
240
+ };
241
+ const reload = async () => {
242
+ try {
243
+ const response = await fetch(url(), { signal: controller.signal });
244
+ if (!response.ok) throw new Error(t().failed);
245
+ const result = responseSchema.parse(await response.json());
246
+ if (disposed) return;
247
+ publish(result.map);
248
+ setCanAdmin(result.canAdmin ?? false);
249
+ setConflict(false);
250
+ setError("");
251
+ } catch {
252
+ if (!disposed) setError(t().failed);
253
+ }
254
+ };
255
+ onMount(() => {
256
+ setNavigationPlatform(geoNavigationPlatform(navigator.userAgent));
257
+ const observer = new ResizeObserver((entries) => {
258
+ if (entries[0]) setWidth(entries[0].contentRect.width);
259
+ });
260
+ observer.observe(root);
261
+ const receive = (event: Event) => {
262
+ if (event instanceof CustomEvent) {
263
+ const value = AiMapSchema.safeParse(event.detail);
264
+ if (value.success) update(value.data);
265
+ else {
266
+ const ref = AiMapReferenceSchema.safeParse(event.detail);
267
+ if (
268
+ ref.success &&
269
+ ref.data.id === map().id &&
270
+ ref.data.conversationId === map().conversationId &&
271
+ ref.data.revision > map().revision
272
+ )
273
+ void reload();
274
+ }
275
+ }
276
+ };
277
+ const focus = () => {
278
+ if (!busy()) void reload();
279
+ };
280
+ window.addEventListener(eventName, receive);
281
+ window.addEventListener("focus", focus);
282
+ void reload();
283
+ onCleanup(() => {
284
+ disposed = true;
285
+ controller.abort();
286
+ observer.disconnect();
287
+ window.removeEventListener(eventName, receive);
288
+ window.removeEventListener("focus", focus);
289
+ });
290
+ });
291
+ const run = async (command: AiMapCommand) => {
292
+ if (busy() || conflict()) return;
293
+ setBusy(true);
294
+ setError("");
295
+ try {
296
+ const response = await fetch(`${url()}/action`, {
297
+ method: "POST",
298
+ headers: { "content-type": "application/json" },
299
+ body: JSON.stringify({ revision: map().revision, command }),
300
+ signal: controller.signal,
301
+ });
302
+ if (response.status === 409) {
303
+ setConflict(true);
304
+ throw new Error(t().conflict);
305
+ }
306
+ if (!response.ok) throw new Error(t().failed);
307
+ const result = responseSchema.parse(await response.json());
308
+ if (disposed) return;
309
+ publish(result.map);
310
+ if (result.map.issue || !result.map.picker) setMode("ready");
311
+ } catch {
312
+ if (!disposed) setError(conflict() ? t().conflict : t().failed);
313
+ } finally {
314
+ if (!disposed) setBusy(false);
315
+ }
316
+ };
317
+ const places = createMemo(() => {
318
+ const groups = map().groups;
319
+ const seen = new Set<string>();
320
+ return groups.flatMap((group, groupIndex) =>
321
+ group.places.flatMap((place, index) => {
322
+ if (seen.has(place.id)) return [];
323
+ seen.add(place.id);
324
+ return [{ place, mark: groupIndex === 0 ? String(index + 1) : String.fromCharCode(65 + index), second: groupIndex > 0 }];
325
+ }),
326
+ );
327
+ });
328
+ const choices = () => map().picker ?? [];
329
+ const selection = () => selected() || (map().candidates?.length ? "candidate-0" : choices()[0]?.id || places()[0]?.place.id || "");
330
+ const selectedPlace = () => [...choices(), ...places().map((p) => p.place)].find((p) => p.id === selection());
331
+ const categoryLabel = (categories: string[]) =>
332
+ categories
333
+ .map((c) =>
334
+ c === "entertainment.museum"
335
+ ? t().museum
336
+ : c === "catering.cafe"
337
+ ? t().cafe
338
+ : c === "catering.restaurant"
339
+ ? t().restaurant
340
+ : c.replaceAll("_", " ").split(".").at(-1),
341
+ )
342
+ .join(", ");
343
+ const web = () => map().web.find((w) => w.placeId === selectedPlace()?.id);
344
+ const visiblePlaces = () => {
345
+ const all = places();
346
+ if (!narrow() || expanded()) return all;
347
+ const second = all.filter((p) => p.second);
348
+ return (second.length ? second : all).slice(0, 4);
349
+ };
350
+ const selectPlace = (id: string) => {
351
+ setSelected(id);
352
+ if (narrow() && !visiblePlaces().some((p) => p.place.id === id) && places().some((p) => p.place.id === id)) setExpanded(true);
353
+ };
354
+ const keydown = (event: KeyboardEvent) => {
355
+ const list = visiblePlaces();
356
+ const index = list.findIndex((p) => p.place.id === selection());
357
+ let next = index;
358
+ if (event.key === "Home") next = 0;
359
+ else if (event.key === "End") next = list.length - 1;
360
+ else if (event.key === "ArrowDown") next = Math.min(index + 1, list.length - 1);
361
+ else if (event.key === "ArrowUp") next = Math.max(index - 1, 0);
362
+ else return;
363
+ event.preventDefault();
364
+ const row = list[next];
365
+ if (row) {
366
+ setSelected(row.place.id);
367
+ root.querySelector(`[data-option-index="${next}"]`)?.scrollIntoView({ block: "nearest" });
368
+ }
369
+ };
370
+ const markers = createMemo<MapViewMarker[]>(() => {
371
+ if (map().candidates?.length)
372
+ return map().candidates!.map((p, i) => ({
373
+ id: `candidate-${i}`,
374
+ mark: i + 1,
375
+ label: p.formatted,
376
+ point: p.point,
377
+ tone: "second",
378
+ selected: selection() === `candidate-${i}`,
379
+ }));
380
+ const base = choices().length
381
+ ? choices().map((p, i) => ({
382
+ id: p.id,
383
+ mark: i + 1,
384
+ label: p.name,
385
+ point: p.point,
386
+ tone: "second" as const,
387
+ selected: selection() === p.id,
388
+ }))
389
+ : places().map((p) => ({
390
+ id: p.place.id,
391
+ mark: p.mark,
392
+ label: p.place.name,
393
+ point: p.place.point,
394
+ tone: p.second ? ("second" as const) : ("accent" as const),
395
+ selected: selection() === p.place.id,
396
+ }));
397
+ const route = map().route;
398
+ const stops =
399
+ (route?.stops ?? map().plan?.stops.flatMap((s) => (s.point ? [{ id: s.id, label: s.label, point: s.point }] : [])))?.map((s, i) => ({
400
+ id: s.id,
401
+ mark: String.fromCharCode(65 + i),
402
+ label: s.label ?? s.id,
403
+ point: s.point,
404
+ tone: "accent" as const,
405
+ })) ?? [];
406
+ const preview = map().preview;
407
+ if (preview?.changes.type === "insert") {
408
+ const inserted = preview.result.route.stops.find((s) => !route?.stops.some((old) => old.id === s.id));
409
+ if (inserted) stops.push({ id: inserted.id, mark: "+", label: inserted.label ?? "+", point: inserted.point, tone: "accent" });
410
+ }
411
+ return choices().length ? base : [...stops, ...base];
412
+ });
413
+ const lines = createMemo<MapViewLine[]>(() => {
414
+ if (map().candidates?.length) return [];
415
+ const route = map().route;
416
+ const preview = map().preview;
417
+ if (!route) return [];
418
+ if (!preview) return [{ id: "route", polyline6: route.polyline6 }];
419
+ return [
420
+ { id: "before", polyline6: route.polyline6, tone: "previous" },
421
+ { id: "preview", polyline6: preview.result.route.polyline6, tone: preview.changes.type === "insert" ? "preview" : "accent" },
422
+ ];
423
+ });
424
+ const canReduceReservation = () =>
425
+ Boolean(map().plan && !map().route && map().issue?.requiredCredits && (map().issue?.availableCredits ?? 0) > 0) &&
426
+ !map().availability.cloud.exhausted &&
427
+ !map().availability.person.exhausted;
428
+ const blocked = () =>
429
+ !map().availability.configured ||
430
+ map().issue?.code === "GEO_NOT_CONFIGURED" ||
431
+ (map().issue?.code === "GEO_BUDGET_EXHAUSTED" && !canReduceReservation());
432
+ const locate = () => {
433
+ if (!navigator.geolocation) {
434
+ setLocationError(t().locationFailed);
435
+ return;
436
+ }
437
+ navigator.geolocation.getCurrentPosition(
438
+ (position) => {
439
+ if (disposed) return;
440
+ setLocation({ point: { lat: position.coords.latitude, lon: position.coords.longitude }, accuracyMeters: position.coords.accuracy });
441
+ setLocationError("");
442
+ },
443
+ (error) => {
444
+ if (!disposed) setLocationError(error.code === error.PERMISSION_DENIED ? t().noLocation : t().locationFailed);
445
+ },
446
+ { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },
447
+ );
448
+ };
449
+ const routeHere = async () => {
450
+ const place = selectedPlace();
451
+ if (!place) return;
452
+ if (!location()) {
453
+ setMode("start");
454
+ return;
455
+ }
456
+ await run({ type: "location", destinationId: place.id, point: location()!.point });
457
+ };
458
+ const search = (insert: boolean) =>
459
+ run({
460
+ type: "search",
461
+ insert,
462
+ search: {
463
+ title: t().stopSearch,
464
+ name: placeName().trim() || undefined,
465
+ categories: [category()],
466
+ along: { fromKm: fromKm(), toKm: toKm() },
467
+ },
468
+ });
469
+ const showOptions = () => {
470
+ setMode("options");
471
+ setTravelMode(map().route?.options.mode ?? "car");
472
+ setAvoid(map().route?.options.avoid ?? []);
473
+ };
474
+ const optionChange = () => {
475
+ const leg = scope() === "all" ? undefined : map().route?.legs[Number(scope())];
476
+ return run({
477
+ type: "preview",
478
+ change: {
479
+ type: "options",
480
+ options: { mode: travelMode(), avoid: avoid() },
481
+ ...(leg ? { leg: { fromStopId: leg.fromStopId, toStopId: leg.toStopId } } : {}),
482
+ },
483
+ });
484
+ };
485
+ const changedRoute = () => map().preview?.result.route ?? map().route;
486
+ const costConfirmationRequired = () => map().issue?.requiresCostConfirmation || map().issue?.code === "GEO_COST_OVERRUN";
487
+ const issueText = () => {
488
+ const issue = map().issue;
489
+ if (!map().availability.configured || issue?.code === "GEO_NOT_CONFIGURED") return t().off;
490
+ if (issue?.code === "GEO_BUDGET_EXHAUSTED")
491
+ return `${issue.scope === "person" ? t().person : t().cloud}: ${t().budget}. ${t().reset}: ${issue.resetsAt ? new Intl.DateTimeFormat(locale(), { dateStyle: "medium", timeStyle: "short", timeZone: map().timeZone ?? "UTC" }).format(new Date(issue.resetsAt)) : ""}`;
492
+ if (issue?.code === "GEO_COST_OVERRUN") return t().cost;
493
+ if (issue?.code === "GEO_LIMIT_EXCEEDED" && issue.requiredCredits !== undefined) return t().routeLimit;
494
+ if (issue?.code === "GEO_NO_ROUTE") return t().noRoute;
495
+ if (issue?.code === "GEO_BUSY") return `${t().failed} (${issue.retryAfterSeconds ?? 1} s)`;
496
+ return t().failed;
497
+ };
498
+ const recovery = () => (
499
+ <div class="ai-map__state" role="status">
500
+ <p>{issueText()}</p>
501
+ <Show when={!costConfirmationRequired()}>
502
+ <Button variant="ghost" size="sm" disabled={busy()} onClick={() => void run({ type: "retry" })}>
503
+ {t().retry}
504
+ </Button>
505
+ </Show>
506
+ <Show when={map().issue?.requiredCredits !== undefined}>
507
+ <p>
508
+ {t().budgetNumbers({
509
+ required: number(map().issue!.requiredCredits!),
510
+ available: number(map().issue!.availableCredits ?? 0),
511
+ missing: number(map().issue!.shortfallCredits ?? 0),
512
+ })}
513
+ </p>
514
+ </Show>
515
+ <Show when={costConfirmationRequired()}>
516
+ <Show when={map().issue?.code !== "GEO_COST_OVERRUN"}>
517
+ <p>{t().cost}</p>
518
+ </Show>
519
+ <Show when={map().issue?.costs}>
520
+ {(costs) => (
521
+ <p>
522
+ {t().recordedCosts({
523
+ settled: number(costs().settledCredits),
524
+ unknown: number(costs().unknownCredits),
525
+ })}
526
+ </p>
527
+ )}
528
+ </Show>
529
+ <p>{t().noInvoiceCap}</p>
530
+ <Button size="sm" disabled={busy()} onClick={() => void run({ type: "retry" })}>
531
+ {t().resumeRoute}
532
+ </Button>
533
+ </Show>
534
+ <Show when={map().plan && !map().route && !costConfirmationRequired() && !blocked()}>
535
+ <Button
536
+ variant="ghost"
537
+ size="sm"
538
+ onClick={() => {
539
+ setRepairStop(map().ambiguousStopId ?? map().plan!.stops.at(-1)!.id);
540
+ setStart("");
541
+ setRepairPoint(undefined);
542
+ setMode("repair");
543
+ }}
544
+ >
545
+ {t().changeStop}
546
+ </Button>
547
+ </Show>
548
+ <Show when={canAdmin() && (blocked() || map().issue?.requiredCredits !== undefined)}>
549
+ <ButtonLink href="/admin/settings?tab=places" variant="ghost" size="sm">
550
+ {map().availability.configured ? t().limits : t().configure}
551
+ </ButtonLink>
552
+ </Show>
553
+ </div>
554
+ );
555
+ const placeRows = () => (
556
+ <div
557
+ class="ai-map__list"
558
+ data-expanded={expanded()}
559
+ role="listbox"
560
+ tabIndex={0}
561
+ aria-label={t().places}
562
+ aria-activedescendant={
563
+ visiblePlaces().some((p) => p.place.id === selection())
564
+ ? `${mapAnchor(map().id)}-option-${visiblePlaces().findIndex((p) => p.place.id === selection())}`
565
+ : undefined
566
+ }
567
+ onKeyDown={keydown}
568
+ >
569
+ <Show
570
+ when={places().length && !map().issue && !waiting()}
571
+ fallback={
572
+ waiting() ? (
573
+ <div class="ai-map__skeleton" role="status">
574
+ {t().loading}
575
+ <For each={[1, 2, 3, 4]}>{() => <span />}</For>
576
+ </div>
577
+ ) : map().candidates?.length && map().issue?.code === "GEO_ADDRESS_AMBIGUOUS" ? (
578
+ candidateRows()
579
+ ) : map().issue || map().pending ? (
580
+ recovery()
581
+ ) : (
582
+ <div class="ai-map__state">
583
+ <p>{t().noPlaces}</p>
584
+ <Button
585
+ size="sm"
586
+ variant="ghost"
587
+ disabled={!map().lastSearch}
588
+ onClick={() =>
589
+ void run({
590
+ type: "search",
591
+ search: { ...map().lastSearch!, radiusMeters: 10000 },
592
+ })
593
+ }
594
+ >
595
+ {t().expand}
596
+ </Button>
597
+ </div>
598
+ )
599
+ }
600
+ >
601
+ <div class="ai-map__group" data-second={visiblePlaces()[0]?.second}>
602
+ {map().groups[visiblePlaces()[0]?.second ? 1 : 0]?.title}
603
+ </div>
604
+ <For each={visiblePlaces()}>
605
+ {(row, index) => (
606
+ <div
607
+ role="option"
608
+ id={`${mapAnchor(map().id)}-option-${index()}`}
609
+ data-option-index={index()}
610
+ aria-selected={selection() === row.place.id}
611
+ class="ai-map__place"
612
+ data-second={row.second}
613
+ onClick={() => selectPlace(row.place.id)}
614
+ onKeyDown={keydown}
615
+ >
616
+ <span class="ai-map__mark">{row.mark}</span>
617
+ <span class="ai-map__place-text">
618
+ <span>{row.place.name}</span>
619
+ <small>
620
+ {categoryLabel(row.place.categories)} ·{" "}
621
+ {mapOpeningHours(row.place.openingHours, map().viewedAt ?? row.place.provenance.computedAt, map().timeZone, locale())}
622
+ </small>
623
+ </span>
624
+ <span class="ai-map__distance">
625
+ {row.place.atMeters !== undefined
626
+ ? `km ${number(row.place.atMeters / 1000)}`
627
+ : row.place.distanceMeters !== undefined
628
+ ? km(row.place.distanceMeters)
629
+ : ""}
630
+ </span>
631
+ </div>
632
+ )}
633
+ </For>
634
+ <Show when={narrow()}>
635
+ <Button size="sm" variant="ghost" class="ai-map__more" onClick={() => setExpanded(!expanded())}>
636
+ {expanded() ? t().less : `${t().all} ${number(places().length)} ${t().places}`}
637
+ </Button>
638
+ </Show>
639
+ </Show>
640
+ </div>
641
+ );
642
+ const info = () => (
643
+ <div class="ai-map__info" aria-live="polite">
644
+ <Show when={!map().issue && !waiting() ? selectedPlace() : undefined}>
645
+ {(p) => (
646
+ <>
647
+ <h4>{p().name}</h4>
648
+ <div class="text-dimmed">{categoryLabel(p().categories)}</div>
649
+ <div>◷ {mapOpeningHours(p().openingHours, map().viewedAt ?? p().provenance.computedAt, map().timeZone, locale())}</div>
650
+ <div>{p().address.formatted}</div>
651
+ <Show when={aiMapWebsite(p().website)}>
652
+ {(href) => (
653
+ <a href={href()} target="_blank" rel="noopener noreferrer">
654
+ {new URL(href()).hostname}
655
+ </a>
656
+ )}
657
+ </Show>
658
+ <p class="ai-map__description">{web()?.description}</p>
659
+ <small>
660
+ {t().source}: {p().provenance.attribution.text}
661
+ <Show when={web()}>
662
+ {" "}
663
+ · {t().description}:{" "}
664
+ <a href={web()!.url} target="_blank" rel="noopener noreferrer">
665
+ {new URL(web()!.url).hostname}
666
+ </a>
667
+ </Show>
668
+ </small>
669
+ </>
670
+ )}
671
+ </Show>
672
+ </div>
673
+ );
674
+ return (
675
+ <div class="ai-map-host">
676
+ <section
677
+ ref={root}
678
+ id={mapAnchor(map().id)}
679
+ class="ai-map"
680
+ data-kind={map().kind}
681
+ data-expanded={expanded()}
682
+ tabIndex={-1}
683
+ aria-label={map().title}
684
+ aria-busy={busy() || props.loading}
685
+ >
686
+ <Show
687
+ when={!blocked()}
688
+ fallback={
689
+ <>
690
+ {recovery()}
691
+ <Show when={map().route?.stops ?? map().plan?.stops}>
692
+ {(stops) => (
693
+ <ol class="ai-map__saved-stops">
694
+ <For each={stops()}>
695
+ {(stop, index) => (
696
+ <li>
697
+ {String.fromCharCode(65 + index())} · {stop.label ?? stop.id}
698
+ </li>
699
+ )}
700
+ </For>
701
+ </ol>
702
+ )}
703
+ </Show>
704
+ <Show when={places().length}>
705
+ <ul>
706
+ <For each={places()}>
707
+ {(p) => (
708
+ <li>
709
+ {p.place.name} · {p.place.address.formatted}
710
+ </li>
711
+ )}
712
+ </For>
713
+ </ul>
714
+ </Show>
715
+ <Show when={places().length || map().route}>
716
+ <small class="ai-map__provenance">
717
+ {map().route?.provenance.attribution.text ?? places()[0]?.place.provenance.attribution.text}
718
+ </small>
719
+ </Show>
720
+ </>
721
+ }
722
+ >
723
+ <div class="ai-map__head">
724
+ <Show
725
+ when={map().kind === "route"}
726
+ fallback={
727
+ <>
728
+ <Show
729
+ when={map().groups.length > 1}
730
+ fallback={
731
+ <>
732
+ <span>{map().title}</span>
733
+ <span class="ai-map__chip">
734
+ {number(places().length)} {t().places}
735
+ </span>
736
+ </>
737
+ }
738
+ >
739
+ <For each={map().groups}>
740
+ {(g) => (
741
+ <span class="ai-map__chip" data-second={map().groups.indexOf(g) > 0}>
742
+ {g.title} · {number(g.places.length)}
743
+ </span>
744
+ )}
745
+ </For>
746
+ </Show>
747
+ </>
748
+ }
749
+ >
750
+ {map().title}{" "}
751
+ <span class="ai-map__chip">
752
+ {number(map().route?.stops.length ?? map().plan?.stops.length ?? 0)} {t().stops}
753
+ </span>
754
+ </Show>
755
+ </div>
756
+ <CloudMap
757
+ height={map().kind === "route" ? (narrow() ? 240 : 330) : narrow() ? (panel() ? 200 : 230) : 330}
758
+ markers={markers()}
759
+ lines={lines()}
760
+ fit={map().candidates?.length || choices().length ? "markers" : "all"}
761
+ selectedId={selection()}
762
+ onSelect={selectPlace}
763
+ ownLocation={location()}
764
+ onLocate={locate}
765
+ availability={map().availability}
766
+ notice={locationError() || (busy() || props.loading ? t().loading : undefined)}
767
+ />
768
+ <Show
769
+ when={map().kind === "places"}
770
+ fallback={
771
+ <>
772
+ <div class="ai-map__facts">
773
+ <div>
774
+ <strong>{changedRoute() ? km(changedRoute()!.distanceMeters) : "—"}</strong>
775
+ <small>{t().distance}</small>
776
+ </div>
777
+ <div>
778
+ <strong>{changedRoute() ? minutes(changedRoute()!.durationSeconds) : "—"}</strong>
779
+ <small>{t().duration}</small>
780
+ </div>
781
+ <Show when={map().preview}>
782
+ {(preview) => (
783
+ <div data-gain={preview().result.delta.durationSeconds < 0}>
784
+ <strong>
785
+ {preview().result.delta.durationSeconds > 0 ? "+" : ""}
786
+ {minutes(preview().result.delta.durationSeconds)}
787
+ </strong>
788
+ <small>
789
+ {preview().result.delta.distanceMeters > 0 ? "+" : ""}
790
+ {km(preview().result.delta.distanceMeters)}
791
+ </small>
792
+ </div>
793
+ )}
794
+ </Show>
795
+ </div>
796
+ <div class="ai-map__stages">
797
+ <Switch>
798
+ <Match when={waiting()}>
799
+ <div class="ai-map__skeleton" role="status">
800
+ {t().loading}
801
+ <For each={[1, 2, 3, 4]}>{() => <span />}</For>
802
+ </div>
803
+ </Match>
804
+ <Match when={map().candidates?.length && map().issue?.code === "GEO_ADDRESS_AMBIGUOUS"}>{candidateRows()}</Match>
805
+ <Match when={mode() === "options"}>
806
+ <div class="ai-map__opts">
807
+ <Select
808
+ label={t().scope}
809
+ value={scope()}
810
+ onValueChange={(v) => {
811
+ setScope(v ?? "all");
812
+ const options = v && v !== "all" ? map().route?.legs[Number(v)]?.options : map().route?.options;
813
+ setTravelMode(options?.mode ?? "car");
814
+ setAvoid(options?.avoid ?? []);
815
+ }}
816
+ options={[
817
+ { value: "all", label: t().whole },
818
+ ...(map().route?.legs.map((leg, i) => ({
819
+ value: String(i),
820
+ label: `${String.fromCharCode(65 + i)} → ${String.fromCharCode(66 + i)}`,
821
+ })) ?? []),
822
+ ]}
823
+ />
824
+ <Select
825
+ label={t().mode}
826
+ value={travelMode()}
827
+ onValueChange={(v) => {
828
+ if (v === "car" || v === "bike" || v === "walk") {
829
+ setTravelMode(v);
830
+ if (v !== "car") setAvoid(avoid().filter((a) => a === "ferries"));
831
+ }
832
+ }}
833
+ options={[
834
+ { value: "car", label: t().car },
835
+ { value: "bike", label: t().bike },
836
+ { value: "walk", label: t().walk },
837
+ ]}
838
+ />
839
+ <div class="ai-map__actions">
840
+ <For each={["highways", "tolls", "ferries"] as const}>
841
+ {(item) => (
842
+ <Checkbox
843
+ label={t()[item]}
844
+ value={avoid().includes(item)}
845
+ disabled={travelMode() !== "car" && item !== "ferries"}
846
+ onValueChange={(v) => setAvoid(v ? [...avoid(), item] : avoid().filter((a) => a !== item))}
847
+ />
848
+ )}
849
+ </For>
850
+ </div>
851
+ <small>{t().prefs}</small>
852
+ </div>
853
+ </Match>
854
+ <Match when={mode() === "insert" || mode() === "along"}>
855
+ <div class="ai-map__search">
856
+ <div class="ai-map__actions">
857
+ <TextInput
858
+ label={t().placeName}
859
+ type="search"
860
+ icon="ti ti-search"
861
+ placeholder={t().placeNamePlaceholder}
862
+ value={placeName()}
863
+ maxLength={300}
864
+ disabled={busy()}
865
+ onValueChange={setPlaceName}
866
+ />
867
+ <Select
868
+ label={t().category}
869
+ value={category()}
870
+ onValueChange={(v) => {
871
+ if (v === "attraction" || v === "restaurant" || v === "cafe") setCategory(v);
872
+ }}
873
+ options={[
874
+ { value: "attraction", label: t().waterfall },
875
+ { value: "restaurant", label: t().restaurant },
876
+ { value: "cafe", label: t().cafe },
877
+ ]}
878
+ />
879
+ </div>
880
+ <Show when={!choices().length}>
881
+ <div class="ai-map__actions">
882
+ <NumberInput label={t().from} value={fromKm()} min={0} onValueChange={(v) => setFromKm(v ?? 0)} />
883
+ <NumberInput label={t().to} value={toKm()} min={0} onValueChange={(v) => setToKm(v ?? 0)} />
884
+ </div>
885
+ </Show>
886
+ </div>
887
+ <For each={choices()}>
888
+ {(p, i) => (
889
+ <Button variant="ghost" size="sm" disabled={busy()} onClick={() => selectPlace(p.id)}>
890
+ {i() + 1}. {p.name}
891
+ </Button>
892
+ )}
893
+ </For>
894
+ </Match>
895
+ <Match when={map().issue || (map().pending && !props.loading)}>{recovery()}</Match>
896
+ <Match when={map().groups.length > 0 && !!map().lastSearch?.along}>
897
+ <For each={places().slice(0, 5)}>
898
+ {(row) => (
899
+ <Button variant="ghost" size="sm" onClick={() => selectPlace(row.place.id)}>
900
+ {row.mark} {row.place.name} · km {number((row.place.atMeters ?? 0) / 1000)} · {t().corridor}{" "}
901
+ {km(row.place.distanceToRouteMeters ?? 0)}
902
+ </Button>
903
+ )}
904
+ </For>
905
+ <Show when={selectedPlace()}>
906
+ {(p) => (
907
+ <div class="ai-map__route-place">
908
+ {p().name} · {p().address.formatted}
909
+ </div>
910
+ )}
911
+ </Show>
912
+ </Match>
913
+ <Match when={map().preview?.changes.type === "optimize"}>
914
+ <div class="ai-map__order">
915
+ <div>
916
+ <strong>{t().before}</strong>
917
+ <ol>
918
+ <For each={map().route?.stops}>{(stop) => <li>{stop.label ?? stop.id}</li>}</For>
919
+ </ol>
920
+ </div>
921
+ <div>
922
+ <strong>{t().after}</strong>
923
+ <ol>
924
+ <For each={changedRoute()?.stops}>{(stop) => <li>{stop.label ?? stop.id}</li>}</For>
925
+ </ol>
926
+ </div>
927
+ </div>
928
+ </Match>
929
+ <Match when={true}>
930
+ <For each={changedRoute()?.legs}>
931
+ {(leg, i) => (
932
+ <div
933
+ class="ai-map__leg"
934
+ data-changed={
935
+ !!map().preview &&
936
+ map().route?.legs.find((old) => old.fromStopId === leg.fromStopId && old.toStopId === leg.toStopId)
937
+ ?.polyline6 !== leg.polyline6
938
+ }
939
+ >
940
+ <span>
941
+ {map().route?.stops.some((s) => s.id === leg.fromStopId)
942
+ ? String.fromCharCode(65 + map().route!.stops.findIndex((s) => s.id === leg.fromStopId))
943
+ : "+"}{" "}
944
+ →{" "}
945
+ {map().route?.stops.some((s) => s.id === leg.toStopId)
946
+ ? String.fromCharCode(65 + map().route!.stops.findIndex((s) => s.id === leg.toStopId))
947
+ : "+"}
948
+ </span>
949
+ <span>
950
+ {changedRoute()?.stops.find((s) => s.id === leg.toStopId)?.label ?? leg.toStopId}
951
+ <small>
952
+ {leg.options.mode ? t()[leg.options.mode] : ""}
953
+ {leg.options.avoid?.length ? ` · ${t().without} ${leg.options.avoid.map((a) => t()[a]).join(", ")}` : ""}
954
+ </small>
955
+ </span>
956
+ <span>{km(leg.distanceMeters)}</span>
957
+ <span>{minutes(leg.durationSeconds)}</span>
958
+ </div>
959
+ )}
960
+ </For>
961
+ </Match>
962
+ </Switch>
963
+ </div>
964
+ </>
965
+ }
966
+ >
967
+ <div class="ai-map__split">
968
+ {info()}
969
+ {placeRows()}
970
+ </div>
971
+ </Show>
972
+ <div class="ai-map__zone">
973
+ <Switch>
974
+ <Match when={mode() === "save"}>
975
+ <MapNotebookSave map={map()} onCancel={() => setMode("ready")} />
976
+ </Match>
977
+ <Match when={mode() === "repair"}>
978
+ <Select
979
+ label={t().changeStop}
980
+ value={repairStop()}
981
+ onValueChange={(v) => {
982
+ setRepairStop(v ?? "");
983
+ setStart("");
984
+ setRepairPoint(undefined);
985
+ }}
986
+ options={
987
+ map().plan?.stops.map((s, i) => ({
988
+ value: s.id,
989
+ label: `${String.fromCharCode(65 + i)} · ${s.label ?? s.address?.text ?? s.id}`,
990
+ })) ?? []
991
+ }
992
+ />
993
+ <AddressAutocomplete
994
+ label={t().start}
995
+ value={start()}
996
+ availability={map().availability}
997
+ disabled={busy()}
998
+ onValueChange={(value) => {
999
+ setStart(value);
1000
+ setRepairPoint(undefined);
1001
+ }}
1002
+ onOwnEntry={() => setRepairPoint(undefined)}
1003
+ onPick={(address) => {
1004
+ setStart(address.formatted.slice(0, 300));
1005
+ setRepairPoint(address.point);
1006
+ }}
1007
+ />
1008
+ <div class="ai-map__actions">
1009
+ <Button
1010
+ size="sm"
1011
+ disabled={busy() || !repairStop() || !start().trim()}
1012
+ onClick={() =>
1013
+ void run({
1014
+ type: "repair",
1015
+ stopId: repairStop(),
1016
+ address: start(),
1017
+ ...(repairPoint() ? { point: repairPoint() } : {}),
1018
+ })
1019
+ }
1020
+ >
1021
+ {t().calculate}
1022
+ </Button>
1023
+ <Button size="sm" variant="ghost" onClick={() => setMode("ready")}>
1024
+ {t().cancel}
1025
+ </Button>
1026
+ </div>
1027
+ </Match>
1028
+ <Match when={mode() === "start"}>
1029
+ <TextInput label={t().start} value={start()} onValueChange={setStart} />
1030
+ <div class="ai-map__actions">
1031
+ <Button
1032
+ size="sm"
1033
+ disabled={!start().trim() || busy()}
1034
+ onClick={() =>
1035
+ void run({
1036
+ type: "route",
1037
+ title: selectedPlace()?.name ?? t().route,
1038
+ stops: [
1039
+ { id: "start", address: start() },
1040
+ { id: "destination", placeId: selection() },
1041
+ ],
1042
+ })
1043
+ }
1044
+ >
1045
+ {t().go}
1046
+ </Button>
1047
+ <Button size="sm" variant="ghost" onClick={locate}>
1048
+ {t().locate}
1049
+ </Button>
1050
+ <Button size="sm" variant="ghost" onClick={() => setMode("ready")}>
1051
+ {t().cancel}
1052
+ </Button>
1053
+ </div>
1054
+ </Match>
1055
+ <Match when={map().candidates?.length && map().issue?.code === "GEO_ADDRESS_AMBIGUOUS"}>
1056
+ <div class="ai-map__confirm">
1057
+ <p>{t().choose}</p>
1058
+ <div class="ai-map__actions">
1059
+ <Button size="sm" disabled={busy()} onClick={() => void run({ type: "choose", index: candidateIndex() })}>
1060
+ {map().candidates?.[candidateIndex()]?.formatted} · {t().useStop}
1061
+ </Button>
1062
+ <Show when={map().plan}>
1063
+ <Button
1064
+ size="sm"
1065
+ variant="ghost"
1066
+ onClick={() => {
1067
+ setRepairStop(map().ambiguousStopId ?? map().plan!.stops[0]!.id);
1068
+ setStart("");
1069
+ setRepairPoint(undefined);
1070
+ setMode("repair");
1071
+ }}
1072
+ >
1073
+ {t().changeStop}
1074
+ </Button>
1075
+ </Show>
1076
+ </div>
1077
+ </div>
1078
+ </Match>
1079
+ <Match when={map().preview}>
1080
+ {(preview) => (
1081
+ <div class="ai-map__confirm">
1082
+ <p>{preview().title}?</p>
1083
+ <small>
1084
+ {t().before}: {km(map().route!.distanceMeters)}, {minutes(map().route!.durationSeconds)} · {t().after}:{" "}
1085
+ {km(preview().result.route.distanceMeters)}, {minutes(preview().result.route.durationSeconds)}
1086
+ </small>
1087
+ <div class="ai-map__actions">
1088
+ <Button size="sm" disabled={busy() || conflict()} onClick={() => void run({ type: "accept" })}>
1089
+ {preview().title}
1090
+ </Button>
1091
+ <Button size="sm" variant="ghost" disabled={busy()} onClick={() => void run({ type: "discard" })}>
1092
+ {t().discard}
1093
+ </Button>
1094
+ </div>
1095
+ </div>
1096
+ )}
1097
+ </Match>
1098
+ <Match when={mode() === "options"}>
1099
+ <div class="ai-map__actions">
1100
+ <Button size="sm" disabled={busy()} onClick={() => void optionChange()}>
1101
+ {t().calculate}
1102
+ </Button>
1103
+ <Button size="sm" variant="ghost" onClick={() => setMode("ready")}>
1104
+ {t().cancel}
1105
+ </Button>
1106
+ </div>
1107
+ </Match>
1108
+ <Match when={mode() === "insert" || mode() === "along"}>
1109
+ <div class="ai-map__actions">
1110
+ <Button
1111
+ size="sm"
1112
+ disabled={busy() || placeName().trim().length > 300 || toKm() <= fromKm() || toKm() - fromKm() > 200}
1113
+ onClick={() => void search(mode() === "insert")}
1114
+ >
1115
+ {t().search}
1116
+ </Button>
1117
+ <Show when={mode() === "insert" && choices().some((p) => p.id === selection())}>
1118
+ <Button
1119
+ size="sm"
1120
+ disabled={busy()}
1121
+ onClick={() => void run({ type: "preview", change: { type: "insert", placeId: selection() } })}
1122
+ >
1123
+ {t().preview} {selectedPlace()?.name}
1124
+ </Button>
1125
+ </Show>
1126
+ <Button
1127
+ size="sm"
1128
+ variant="ghost"
1129
+ onClick={() => {
1130
+ setMode("ready");
1131
+ void run({ type: "discard" });
1132
+ }}
1133
+ >
1134
+ {t().cancel}
1135
+ </Button>
1136
+ </div>
1137
+ </Match>
1138
+ <Match when={true}>
1139
+ <Show when={map().receipt}>
1140
+ {(receipt) => (
1141
+ <div class="ai-map__receipt">
1142
+ <span>{receipt().title}</span>
1143
+ <time dateTime={receipt().at}>{time(receipt().at)}</time>
1144
+ <Show when={map().undo}>
1145
+ <Button variant="ghost" size="xs" disabled={busy()} onClick={() => void run({ type: "undo" })}>
1146
+ {t().undo}
1147
+ </Button>
1148
+ </Show>
1149
+ </div>
1150
+ )}
1151
+ </Show>
1152
+ <div class="ai-map__actions" data-quiet={waiting() || !!map().issue} inert={waiting() || !!map().issue}>
1153
+ <Show
1154
+ when={map().kind === "places"}
1155
+ fallback={
1156
+ <>
1157
+ <Button
1158
+ size="sm"
1159
+ variant="secondary"
1160
+ disabled={busy() || !map().route}
1161
+ onClick={() => {
1162
+ setMode("insert");
1163
+ setCategory("attraction");
1164
+ setFromKm(0);
1165
+ setToKm(Math.min(200, (map().route?.distanceMeters ?? 0) / 1000));
1166
+ }}
1167
+ >
1168
+ {t().insert}
1169
+ </Button>
1170
+ <Button
1171
+ size="sm"
1172
+ variant="secondary"
1173
+ disabled={busy() || !map().route}
1174
+ onClick={() => void run({ type: "preview", change: { type: "optimize" } })}
1175
+ >
1176
+ {t().optimize}
1177
+ </Button>
1178
+ <Button size="sm" variant="secondary" disabled={!map().route} onClick={showOptions}>
1179
+ {t().options}
1180
+ </Button>
1181
+ <Button
1182
+ size="sm"
1183
+ variant="secondary"
1184
+ disabled={!map().route}
1185
+ onClick={() => {
1186
+ setMode("along");
1187
+ setCategory("restaurant");
1188
+ setFromKm(40);
1189
+ setToKm(80);
1190
+ }}
1191
+ >
1192
+ {t().along}
1193
+ </Button>
1194
+ <Show when={map().route?.stops.at(-1)}>
1195
+ {(end) => (
1196
+ <ButtonLink
1197
+ size="sm"
1198
+ variant="secondary"
1199
+ target="_blank"
1200
+ rel="noopener noreferrer"
1201
+ href={geoNavigationHref(
1202
+ { point: end().point, text: end().label ?? "", name: end().label },
1203
+ navigationPlatform(),
1204
+ )}
1205
+ >
1206
+ {t().nav}
1207
+ </ButtonLink>
1208
+ )}
1209
+ </Show>
1210
+ </>
1211
+ }
1212
+ >
1213
+ <Button size="sm" variant="secondary" disabled={!selectedPlace() || busy()} onClick={() => void routeHere()}>
1214
+ <i class="ti ti-route" aria-hidden="true" /> {t().here}
1215
+ </Button>
1216
+ <Button size="sm" variant="secondary" disabled={busy() || !places().length} onClick={() => setMode("save")}>
1217
+ <i class="ti ti-notebook" aria-hidden="true" /> {t().save}
1218
+ </Button>
1219
+ <Button
1220
+ size="sm"
1221
+ variant="secondary"
1222
+ disabled={!selectedPlace() || busy()}
1223
+ onClick={() =>
1224
+ void run({ type: "search", search: { title: t().nearby, categories: ["cafe"], placeId: selection() } })
1225
+ }
1226
+ >
1227
+ <i class="ti ti-radar" aria-hidden="true" /> {t().nearby}
1228
+ </Button>
1229
+ </Show>
1230
+ <Show when={map().kind === "route"}>
1231
+ <Button
1232
+ size="sm"
1233
+ variant="secondary"
1234
+ disabled={busy() || (!places().length && !map().route)}
1235
+ onClick={() => setMode("save")}
1236
+ >
1237
+ <i class="ti ti-notebook" aria-hidden="true" /> {t().save}
1238
+ </Button>
1239
+ </Show>
1240
+ </div>
1241
+ </Match>
1242
+ </Switch>
1243
+ </div>
1244
+ <div class="ai-map__provenance">
1245
+ <Show
1246
+ when={map().route}
1247
+ fallback={
1248
+ <>
1249
+ Geoapify · OpenStreetMap
1250
+ <Show when={web()}>
1251
+ {" "}
1252
+ ·{" "}
1253
+ <a href={web()!.url} target="_blank" rel="noopener noreferrer">
1254
+ {new URL(web()!.url).hostname}
1255
+ </a>
1256
+ </Show>
1257
+ </>
1258
+ }
1259
+ >
1260
+ {(route) => (
1261
+ <>
1262
+ {t().route}: {route().provenance.attribution.text} · {t().noTraffic} {time(route().provenance.computedAt)}
1263
+ </>
1264
+ )}
1265
+ </Show>
1266
+ </div>
1267
+ </Show>
1268
+ <Show when={error()}>
1269
+ <div role="alert" class="ai-map__error">
1270
+ {error()}{" "}
1271
+ <Button variant="ghost" size="sm" disabled={busy()} onClick={() => void reload()}>
1272
+ {t().reload}
1273
+ </Button>
1274
+ </div>
1275
+ </Show>
1276
+ </section>
1277
+ </div>
1278
+ );
1279
+ }