import type { AssistantActivityArea, AssistantApplied, AssistantChangeEffect, AssistantMessage } from '@trek/shared'; import type { Assignment, AssignmentsMap } from '../types'; /** * What the map makes of the assistant's chat: the suggestions still waiting to * be decided, the area a running step searches, and the days a block of * changes touched. Pure and React-free, so the store and the planner's map * hook read the same answers and the tests need neither. */ /** [lat, lng], the order both map renderers take. */ export type LatLng = [number, number]; /** Stops by day: the trip store's map, or a snapshot of some of its days. */ interface DayPlans { readonly [dayId: number]: readonly Assignment[] | undefined; } /** One block of changes is named by its first call, the same key the change card builds from its items. */ export function blockKeyOf(toolCallIds: readonly string[]): string { return toolCallIds[0] ?? ''; } /** The effect each change of the thread has on the day plan, by its call. */ export function changeEffects(messages: readonly AssistantMessage[]): Map { const effects = new Map(); for (const m of messages) { for (const p of m.parts) { if (p.type === 'data-trek-change' && p.data.effect) effects.set(p.data.toolCallId, p.data.effect); } } return effects; } /** The receipt of every applied change of the thread, by its call. */ export function receiptsOf(messages: readonly AssistantMessage[]): Map { const receipts = new Map(); for (const m of messages) { for (const p of m.parts) if (p.type === 'data-trek-applied') receipts.set(p.data.toolCallId, p.data); } return receipts; } /** * A change the map may draw before it exists: its call, what it does to the * plan, and how far it got. `waiting` is undecided, `running` applied but not * answered yet, `applied` done, with the place it made when it made one. */ export interface GhostCandidate { toolCallId: string; effect: AssistantChangeEffect; status: 'waiting' | 'running' | 'applied'; placeId: number | null; } /** * Every change of the thread that could stand on the map as a ghost, in the * order of the chat. Discarded, expired and failed changes are not, and * neither is a removal: what goes is already on the map. */ export function ghostCandidates(messages: readonly AssistantMessage[]): GhostCandidate[] { const effects = changeEffects(messages); if (effects.size === 0) return []; const receipts = receiptsOf(messages); const candidates: GhostCandidate[] = []; for (const m of messages) { for (const p of m.parts) { if (p.type !== 'tool-call' || !p.approval || p.approval.resolution) continue; const effect = effects.get(p.toolCallId); if (!effect || effect.kind === 'remove' || p.approval.approved === false) continue; const receipt = receipts.get(p.toolCallId); if (p.approval.approved === undefined) { candidates.push({ toolCallId: p.toolCallId, effect, status: 'waiting', placeId: null }); } else if (receipt) { candidates.push({ toolCallId: p.toolCallId, effect, status: 'applied', placeId: receipt.ids[0] ?? null }); } else if (p.result === undefined) { candidates.push({ toolCallId: p.toolCallId, effect, status: 'running', placeId: null }); } } } return candidates; } /** * The candidates the map draws as ghosts now. One waiting for a decision * always is. A new place the user applied stays one until the trip holds the * place it made (its id comes with the receipt, the place itself with the * broadcast), so the pin never blinks out between the two. An applied change * to a place that already existed is that place's own pin from then on. */ export function liveGhosts( candidates: readonly GhostCandidate[], knownPlaceIds: ReadonlySet ): GhostCandidate[] { return candidates.filter((c) => { if (c.status === 'waiting') return true; if (!c.effect.place) return false; return c.status === 'running' || (c.placeId !== null && !knownPlaceIds.has(c.placeId)); }); } const METERS_PER_DEGREE = 111_320; /** The corners of the area a running step searches, or nothing when it names none. */ export function activityFocus(area: AssistantActivityArea | null): LatLng[] { if (!area) return []; if (area.type === 'bbox') { return [ [area.south, area.west], [area.north, area.east], ]; } const dLat = area.radiusM / METERS_PER_DEGREE; // Near a pole a degree of longitude shrinks to nothing; the floor keeps the box finite. const dLng = area.radiusM / (METERS_PER_DEGREE * Math.max(Math.cos((area.lat * Math.PI) / 180), 0.01)); return [ [area.lat - dLat, area.lng - dLng], [area.lat + dLat, area.lng + dLng], ]; } /** The days a set of changes touches: where each lands and where a move comes from. */ export function blockDays(effects: readonly AssistantChangeEffect[]): number[] { const days = new Set(); for (const e of effects) { days.add(e.dayId); if (e.fromDayId !== null) days.add(e.fromDayId); } return [...days]; } /** The positions of the stops on the given days. */ export function dayPoints(assignments: DayPlans, dayIds: readonly number[]): LatLng[] { const points: LatLng[] = []; for (const dayId of dayIds) { for (const visit of assignments[dayId] ?? []) { const lat = visit.place?.lat; const lng = visit.place?.lng; if (typeof lat === 'number' && typeof lng === 'number') points.push([lat, lng]); } } return points; } /** The plan of the given days as it stands, for "before" once the changes ran. */ export function snapshotDays(assignments: DayPlans, dayIds: readonly number[]): Record { const snapshot: Record = {}; for (const dayId of dayIds) snapshot[dayId] = [...(assignments[dayId] ?? [])]; return snapshot; } /** * The plan the map shows while "before" is on: the snapshot's days laid over * the planner's view of the rest. * * The snapshot is taken from the store, the view hides some stops (hotel * stops, service stops the traveller switched off). A stop still stored on its * day but missing from the view is one of those and stays hidden; a stop no * longer stored there is one the block took away and shows again. */ export function overlaySnapshot( view: AssignmentsMap, stored: AssignmentsMap, snapshot: Readonly> ): AssignmentsMap { const next: AssignmentsMap = { ...view }; for (const [day, visits] of Object.entries(snapshot)) { const storedIds = new Set((stored[day] ?? []).map((v) => v.id)); const shownIds = new Set((view[day] ?? []).map((v) => v.id)); next[day] = visits.filter((v) => !storedIds.has(v.id) || shownIds.has(v.id)); } return next; } /** Where the floating window sits, as the store keeps it: `x` counts from the inline start. */ export interface WindowRect { x: number; y: number; width: number; height: number; } /** The screen edge something covers the map from, and how far in it reaches, in CSS pixels. */ interface MapCover { side: 'left' | 'right'; px: number; } /** Room left between the window and what the map frames beside it. */ const WINDOW_GAP = 24; /** * How far the floating window reaches over the map from the screen edge it is * nearer to, so a fit lands in the part still visible. Screen geometry, so the * window's inline-start offset is turned into a distance from the physical * edge first. A window that takes most of the width leaves nothing worth * framing beside it and covers nothing here. */ export function windowCover(rect: WindowRect | null, viewportWidth: number, rtl: boolean): MapCover | null { if (!rect || viewportWidth <= 0 || rect.width > viewportWidth * 0.6) return null; const fromScreenStart = rtl ? viewportWidth - rect.x - rect.width : rect.x; if (fromScreenStart + rect.width / 2 > viewportWidth / 2) { return { side: 'right', px: Math.max(0, viewportWidth - fromScreenStart + WINDOW_GAP) }; } return { side: 'left', px: Math.max(0, fromScreenStart + rect.width + WINDOW_GAP) }; }