@k2b/cloud 0.45.0 → 0.46.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 (71) hide show
  1. package/package.json +5 -2
  2. package/src/_internal/app-queries.ts +73 -0
  3. package/src/_internal/capabilities.ts +137 -9
  4. package/src/_internal/define-app.ts +2 -0
  5. package/src/_internal/registry-validation.ts +17 -4
  6. package/src/_internal/registry.ts +32 -4
  7. package/src/_internal/resource-principals.ts +36 -0
  8. package/src/ai/capabilities.ts +4 -2
  9. package/src/ai/chat/map-messages.ts +270 -0
  10. package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
  11. package/src/ai/chat/map-notebook.tsx +301 -0
  12. package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
  13. package/src/ai/chat/map-widget.css +107 -0
  14. package/src/ai/chat/map-widget.tsx +1279 -0
  15. package/src/ai/chat/message-utils.ts +1 -0
  16. package/src/ai/chat/tool-names.ts +2 -0
  17. package/src/ai/chat/turn-layout.ts +2 -1
  18. package/src/ai/chat/turn-view.tsx +17 -1
  19. package/src/ai/default-tools.ts +3 -0
  20. package/src/ai/executor.ts +13 -1
  21. package/src/ai/map-contracts.ts +209 -0
  22. package/src/ai/map-execution.ts +14 -0
  23. package/src/ai/map-notebook-save.ts +276 -0
  24. package/src/ai/map-notebook.ts +32 -0
  25. package/src/ai/map-opening-hours.ts +40 -0
  26. package/src/ai/map-routes.ts +84 -0
  27. package/src/ai/map-service.ts +288 -0
  28. package/src/ai/map-store.ts +137 -0
  29. package/src/ai/map-tool.ts +73 -0
  30. package/src/ai/migrate.ts +3 -0
  31. package/src/ai/protocol.ts +3 -1
  32. package/src/ai/routes.ts +2 -0
  33. package/src/ai/store.ts +19 -6
  34. package/src/ai/tools.ts +14 -0
  35. package/src/ai/types.ts +3 -0
  36. package/src/api/capabilities.ts +90 -12
  37. package/src/api/mcp.ts +4 -2
  38. package/src/browser/notifications.ts +25 -8
  39. package/src/capabilities/discussion-providers.ts +99 -0
  40. package/src/capabilities/executions.ts +2 -2
  41. package/src/capabilities/migrate.ts +10 -1
  42. package/src/capabilities/resource-readers.ts +58 -0
  43. package/src/capabilities/server-invoke.ts +23 -13
  44. package/src/capabilities/server.ts +17 -6
  45. package/src/capabilities/testing.ts +13 -0
  46. package/src/contracts/capabilities.ts +137 -4
  47. package/src/contracts/discussions.ts +91 -0
  48. package/src/contracts/geo-document.ts +60 -0
  49. package/src/contracts/geo-snapshot.ts +86 -0
  50. package/src/contracts/geo.ts +14 -0
  51. package/src/contracts/index.ts +2 -0
  52. package/src/contracts/outgoing-mail.ts +1 -1
  53. package/src/contracts/resource-types.ts +21 -0
  54. package/src/geo/AddressAutocomplete.tsx +213 -0
  55. package/src/geo/messages.ts +29 -0
  56. package/src/geo/navigation.ts +25 -0
  57. package/src/geo/ui.ts +2 -0
  58. package/src/services/accounts/app.ts +28 -6
  59. package/src/services/accounts/entities.ts +11 -4
  60. package/src/services/accounts/groups.ts +10 -4
  61. package/src/services/accounts/local-groups.ts +87 -38
  62. package/src/services/accounts/machine-group-management.ts +94 -0
  63. package/src/services/geo/cache.ts +2 -1
  64. package/src/services/geo/geoapify.ts +3 -0
  65. package/src/services/identity/invocation-token.ts +110 -0
  66. package/src/services/pdf/geo-snapshot.ts +107 -0
  67. package/src/services/pdf/index.ts +2 -0
  68. package/src/ssr/AdminArea.tsx +2 -2
  69. package/src/ssr/AdminLayout.tsx +1 -1
  70. package/src/ssr/AdminSidebar.tsx +1 -1
  71. package/src/styles/global.css +2 -0
@@ -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
+ }