mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
feat(assistant): hike, walk and route cards with their lines on the map, and "Plan this"
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* FE-AI-ENTITY-001 to FE-AI-ENTITY-004
|
||||
* FE-AI-ENTITY-001 to FE-AI-ENTITY-005
|
||||
*
|
||||
* What a receipt offers for the thing it changed, read off the entity's
|
||||
* look: the map pin where the plan can show it, "Open" where it lives in
|
||||
@@ -20,6 +20,8 @@ vi.mock('./cardLooks', async (importOriginal) => {
|
||||
...actual.ENTITY_LOOK.tour,
|
||||
openLabelKey: 'assistant.change.viewOnMap',
|
||||
phoneOpenLabelKey: 'assistant.history.open',
|
||||
onMap: undefined,
|
||||
phoneOpen: undefined,
|
||||
open: (applied: AssistantApplied) => ({
|
||||
kind: 'tab',
|
||||
tab: 'tour-planner',
|
||||
@@ -27,6 +29,12 @@ vi.mock('./cardLooks', async (importOriginal) => {
|
||||
}),
|
||||
},
|
||||
budget: { ...actual.ENTITY_LOOK.budget, open: () => ({ kind: 'tab', tab: 'finanzplan' }) },
|
||||
reservation: {
|
||||
...actual.ENTITY_LOOK.reservation,
|
||||
openLabelKey: undefined,
|
||||
open: () => ({ kind: 'tab', tab: 'buchungen' }),
|
||||
phoneOpen: () => null,
|
||||
},
|
||||
};
|
||||
return { ...actual, ENTITY_LOOK: looks, entityLookOf: (entity: AssistantApplied['entity']) => looks[entity] };
|
||||
});
|
||||
@@ -67,7 +75,7 @@ describe('EntityActions', () => {
|
||||
});
|
||||
|
||||
it('FE-AI-ENTITY-003: what has no place on the map gets no pin', () => {
|
||||
const { container } = renderActions(applied('packing', [2], null), { onOpen: vi.fn() });
|
||||
const { container } = renderActions(applied('memory', [2], null), { onOpen: vi.fn() });
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
@@ -87,4 +95,15 @@ describe('EntityActions', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
|
||||
expect(onOpen).toHaveBeenLastCalledWith({ kind: 'tab', tab: 'finanzplan' });
|
||||
});
|
||||
|
||||
it('FE-AI-ENTITY-005: on the phone "Open" goes where the look names for the phone, and shows none where it names nothing', () => {
|
||||
const onOpen = vi.fn();
|
||||
const desktop = renderActions(applied('reservation', [5], null), { onOpen });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
|
||||
expect(onOpen).toHaveBeenLastCalledWith({ kind: 'tab', tab: 'buchungen' });
|
||||
desktop.unmount();
|
||||
|
||||
renderActions(applied('reservation', [5], null), { onOpen, variant: 'fullscreen' });
|
||||
expect(screen.queryByRole('button', { name: 'Open' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,8 +11,9 @@ import { ICON_BUTTON } from './parts/assistantTones';
|
||||
/**
|
||||
* What a receipt offers for the thing it changed, as its entity's look says:
|
||||
* the map pin where the plan's map can show it, and "Open" where it lives
|
||||
* somewhere else in TREK (a tab of the trip, another page). Neither shows
|
||||
* where the shell around the chat cannot open anything.
|
||||
* somewhere else in TREK (a tab of the trip, another page; on the phone what
|
||||
* its look names for it there). Neither shows where the shell around the
|
||||
* chat cannot open anything.
|
||||
*/
|
||||
export default function EntityActions({ applied, card }: { applied: AssistantApplied; card: TrekCard | null }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -20,8 +21,9 @@ export default function EntityActions({ applied, card }: { applied: AssistantApp
|
||||
if (!onOpen) return null;
|
||||
const look = entityLookOf(applied.entity);
|
||||
const onMap = showOnMapTarget(applied, card);
|
||||
const open = look.open?.(applied, card) ?? null;
|
||||
const openKey = variant === 'fullscreen' ? (look.phoneOpenLabelKey ?? look.openLabelKey) : look.openLabelKey;
|
||||
const phone = variant === 'fullscreen';
|
||||
const open = (phone && look.phoneOpen ? look.phoneOpen(applied, card) : look.open?.(applied, card)) ?? null;
|
||||
const openKey = phone ? (look.phoneOpenLabelKey ?? look.openLabelKey) : look.openLabelKey;
|
||||
const openLabel = openKey ? t(openKey) : t('assistant.card.common.open');
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface EntityLook {
|
||||
phoneOpenLabelKey?: string;
|
||||
onMap?(applied: AssistantApplied, card: TrekCard | null): { placeId: number | null; dayId: number | null } | null;
|
||||
open?(applied: AssistantApplied, card: TrekCard | null): EntityTarget | null;
|
||||
/** Where "Open" goes on the phone instead, where the tab `open` names may not exist; null shows none there. */
|
||||
phoneOpen?(applied: AssistantApplied, card: TrekCard | null): EntityTarget | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* FE-AI-TOUR-035 to FE-AI-TOUR-036, FE-AI-TOUR-044
|
||||
*
|
||||
* The saved tours of a trip as the Tours sidebar lists them: the avatar, the
|
||||
* name, time, length and climb, the days a tour is on or that it is on none
|
||||
* yet, the rest counted as a way to the Tours tab (on the phone, to the plan);
|
||||
* resting on a row lights the tour's line on the map, a tap shows it there,
|
||||
* and a card that goes puts out the line it lit.
|
||||
*/
|
||||
import type { TourListCard as TourList } from '@trek/shared';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TOUR_LIST } from '../../../../../tests/helpers/assistantTour';
|
||||
import { act, fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import { useAssistantStore } from '../../../../store/assistantStore';
|
||||
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
|
||||
import TourListCard from './TourListCard';
|
||||
|
||||
const ASSISTANT = useAssistantStore.getState();
|
||||
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(ASSISTANT, true);
|
||||
});
|
||||
|
||||
function renderList(view: Partial<AssistantView> = {}, card: TourList = TOUR_LIST) {
|
||||
const value: AssistantView = { tripId: 4, variant: 'window', ...view };
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={value}>
|
||||
<TourListCard cards={[{ toolCallId: 'call_3', card }]} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('TourListCard', () => {
|
||||
it('FE-AI-TOUR-035: lists the tours with their figures and days, and counts the rest as a way to the Tours tab', () => {
|
||||
const onOpen = vi.fn();
|
||||
renderList({ onOpen });
|
||||
expect(screen.getByRole('region', { name: 'Tours' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Hochfelln from Bergen')).toBeInTheDocument();
|
||||
expect(screen.getByText('8.2 km')).toBeInTheDocument();
|
||||
expect(screen.getByText('640 m')).toBeInTheDocument();
|
||||
expect(screen.getByText('Day 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Not on a day yet')).toBeInTheDocument();
|
||||
expect(screen.getByText('4')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '2 more' }));
|
||||
expect(onOpen).toHaveBeenCalledWith({ kind: 'tab', tab: 'tour-planner' });
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-036: resting on a row lights the tour on the map, a tap shows it there', () => {
|
||||
const onOpen = vi.fn();
|
||||
renderList({ onOpen });
|
||||
const row = screen.getByRole('button', { name: /Chiemsee by kayak/ });
|
||||
fireEvent.mouseEnter(row);
|
||||
expect(useAssistantStore.getState().mapLink).toMatchObject({ tripId: 4, hoveredId: 'tour:42', source: 'card' });
|
||||
fireEvent.mouseLeave(row);
|
||||
expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull();
|
||||
fireEvent.click(row);
|
||||
expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 42, dayId: null });
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-044: names several days at once, counts the rest towards the plan on the phone, and puts out its light when it goes', () => {
|
||||
const onOpen = vi.fn();
|
||||
const first = TOUR_LIST.rows[0] as TourList['rows'][number];
|
||||
const card: TourList = { ...TOUR_LIST, dayId: 22, rows: [{ ...first, days: [2, 4] }] };
|
||||
const view = renderList({ onOpen, variant: 'fullscreen' }, card);
|
||||
expect(screen.getByText('Days 2, 4')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '2 more' }));
|
||||
expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: null, dayId: 22 });
|
||||
|
||||
fireEvent.mouseEnter(screen.getByRole('button', { name: /Hochfelln from Bergen/ }));
|
||||
expect(useAssistantStore.getState().mapLink?.hoveredId).toBe('tour:41');
|
||||
view.unmount();
|
||||
expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull();
|
||||
|
||||
// A line lit from the map is the map's: a card that goes leaves it lit.
|
||||
const again = renderList({ onOpen }, card);
|
||||
act(() => useAssistantStore.getState().setMapHover(4, 'tour:41', 'map'));
|
||||
again.unmount();
|
||||
expect(useAssistantStore.getState().mapLink).toMatchObject({ hoveredId: 'tour:41', source: 'map' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { TourListCard as TourList, TourListRow } from '@trek/shared';
|
||||
import { CalendarDays, Clock, MoveHorizontal, Route, TrendingUp } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { useAssistantStore } from '../../../../store/assistantStore';
|
||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { formatTourDuration } from '../../../Tours/planner/tourDurationInput';
|
||||
import { TourDayRowAvatar } from '../../../Tours/TourDayRowFacts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import type { CardEntry, EntityTarget } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardMore, CardRow, CardRows } from '../parts/CardRows';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { useLineHover } from './useTourRouteCard';
|
||||
|
||||
/**
|
||||
* The saved tours of a trip, or of one of its days, as the Tours sidebar
|
||||
* lists them: the tour's avatar with its grade, its name, its time, length
|
||||
* and climb, and the days it is on or that it is on none yet. Resting on a
|
||||
* row lights the tour's line on the map; a tap shows it there. What the card
|
||||
* leaves out is counted at its end, a way to the Tours tab (on the phone, to
|
||||
* the plan).
|
||||
*/
|
||||
|
||||
/** The map's key for a saved tour's line, the one the plan draws it under. */
|
||||
const tourRef = (placeId: number) => `tour:${placeId}`;
|
||||
|
||||
/** Where the tours the card leaves out are listed; the phone has no Tours tab and shows them on the plan. */
|
||||
const TOURS_TAB: EntityTarget = { kind: 'tab', tab: 'tour-planner' };
|
||||
|
||||
/** The days a tour is on, "Day 2" or "Days 2, 4", or that it is on none yet. */
|
||||
function dayWords(days: readonly number[], t: (key: string, params?: Record<string, string | number>) => string) {
|
||||
if (days.length === 0) return t('assistant.card.tour.unplanned');
|
||||
if (days.length === 1) return t('assistant.card.tour.day', { day: days[0] ?? '' });
|
||||
return t('assistant.card.tour.days', { days: days.join(', ') });
|
||||
}
|
||||
|
||||
function TourRow({ row, lit, onHover }: { row: TourListRow; lit: boolean; onHover: (on: boolean) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const { onOpen } = useAssistantView();
|
||||
const glyph = 'flex-none text-content-faint';
|
||||
const facts = (
|
||||
<span className="mt-1 flex flex-wrap items-center gap-1">
|
||||
{row.minutes !== null && (
|
||||
<WhiteBadge icon={<Clock size={10} strokeWidth={2.2} className={glyph} />}>
|
||||
{formatTourDuration(row.minutes)}
|
||||
</WhiteBadge>
|
||||
)}
|
||||
{row.distanceM !== null && (
|
||||
<WhiteBadge icon={<MoveHorizontal size={10} strokeWidth={2.2} className={glyph} />}>
|
||||
{format.distance(row.distanceM / 1000)}
|
||||
</WhiteBadge>
|
||||
)}
|
||||
{row.ascentM !== null && (
|
||||
<WhiteBadge icon={<TrendingUp size={10} strokeWidth={2.2} className={glyph} />}>
|
||||
{format.elevation(row.ascentM)}
|
||||
</WhiteBadge>
|
||||
)}
|
||||
<SoftPill icon={<CalendarDays size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{dayWords(row.days, t)}
|
||||
</SoftPill>
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<CardRow
|
||||
lead={<TourDayRowAvatar tour={{ tour_type: row.activity, max_hiking_difficulty: row.limit }} size={28} />}
|
||||
title={<bdi className="line-clamp-2">{row.name}</bdi>}
|
||||
facts={facts}
|
||||
lit={lit}
|
||||
onHover={onHover}
|
||||
onActivate={onOpen ? () => onOpen({ kind: 'map', placeId: row.placeId, dayId: null }) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TourListBody({ card }: { card: TourList }) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId, variant } = useAssistantView();
|
||||
const hovered = useAssistantStore((s) => (s.mapLink?.tripId === tripId ? s.mapLink.hoveredId : null));
|
||||
const setMapHover = useAssistantStore((s) => s.setMapHover);
|
||||
const hover = useLineHover();
|
||||
|
||||
// A card that goes puts out the line it lit.
|
||||
useEffect(
|
||||
() => () => {
|
||||
const link = useAssistantStore.getState().mapLink;
|
||||
const lit = link?.tripId === tripId && link.source === 'card' && link.hoveredId?.startsWith('tour:');
|
||||
if (lit) setMapHover(tripId, null, 'card');
|
||||
},
|
||||
[tripId, setMapHover]
|
||||
);
|
||||
|
||||
return (
|
||||
<CardFrame label={t('assistant.card.tour.list')}>
|
||||
<CardHeader icon={Route} tone="info" title={t('assistant.card.tour.list')} count={card.rows.length + card.more} />
|
||||
<div className="mt-1.5">
|
||||
<CardRows>
|
||||
{card.rows.map((row) => (
|
||||
<TourRow
|
||||
key={row.placeId}
|
||||
row={row}
|
||||
lit={hovered === tourRef(row.placeId)}
|
||||
onHover={(on) => hover(tourRef(row.placeId), on)}
|
||||
/>
|
||||
))}
|
||||
</CardRows>
|
||||
<CardMore
|
||||
count={card.more}
|
||||
labelKey="assistant.place.more"
|
||||
target={variant === 'fullscreen' ? { kind: 'map', placeId: null, dayId: card.dayId } : TOURS_TAB}
|
||||
/>
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TourListCard({ cards }: { cards: readonly CardEntry[] }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{cards.map(({ toolCallId, card }) =>
|
||||
card.kind === 'tourList' ? <TourListBody key={toolCallId} card={card} /> : null
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { TourRouteCard } from '@trek/shared';
|
||||
import { Check, Loader2, MapPin, Plus } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import { CardOpenButton } from '../parts/CardOpen';
|
||||
import { tourTarget } from './tourLook';
|
||||
import { useTakeProposal } from './useTourRouteCard';
|
||||
|
||||
/**
|
||||
* The foot of a route card: "On map" always; "Plan this" on a proposed hike
|
||||
* or city walk for someone who may add to the trip, off with its reason said
|
||||
* while it cannot be taken, and once taken a note that the tour waits below
|
||||
* for a decision (or, once applied, that it was added); "Open in Tours" on a saved tour. Taller on the phone, as
|
||||
* tall as the card's own "Open"; the phone has no Tours tab, so there a
|
||||
* saved tour opens as the place it is on the plan.
|
||||
*/
|
||||
export default function TourRouteActions({
|
||||
toolCallId,
|
||||
card,
|
||||
letter,
|
||||
onShow,
|
||||
}: {
|
||||
toolCallId: string;
|
||||
card: TourRouteCard;
|
||||
/** The chosen way's letter in a ways card: "Plan way B". */
|
||||
letter?: string;
|
||||
onShow: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { variant } = useAssistantView();
|
||||
const take = useTakeProposal(toolCallId, card);
|
||||
const phone = variant === 'fullscreen';
|
||||
const tall = phone ? 'h-10' : 'h-7';
|
||||
const planLabel = letter ? t('assistant.card.tour.planWay', { letter }) : t('assistant.card.tour.plan');
|
||||
let caption: string | null = null;
|
||||
if (take.state === 'failed') caption = t('assistant.card.tour.askAgain');
|
||||
else if (take.state !== 'taken' && take.reasonKey) caption = t(take.reasonKey);
|
||||
return (
|
||||
<div className="mt-2.5 flex flex-col items-end gap-1">
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShow}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2.5 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover hover:text-content ${tall}`}
|
||||
>
|
||||
<MapPin size={12} aria-hidden="true" />
|
||||
{t('assistant.place.showOnMap')}
|
||||
</button>
|
||||
{card.source === 'saved' && card.placeId !== null && (
|
||||
<CardOpenButton
|
||||
target={phone ? { kind: 'map', placeId: card.placeId, dayId: null } : tourTarget(card.placeId)}
|
||||
labelKey={phone ? 'assistant.card.tour.openPhone' : 'assistant.card.tour.open'}
|
||||
/>
|
||||
)}
|
||||
{take.visible && take.state === 'taken' && (
|
||||
<SoftPill tone="success" icon={<Check size={10} strokeWidth={2.4} aria-hidden="true" />}>
|
||||
{take.applied ? t('assistant.card.tour.added') : t('assistant.card.tour.taken')}
|
||||
</SoftPill>
|
||||
)}
|
||||
{take.visible && take.state !== 'taken' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={take.disabled}
|
||||
onClick={() => void take.take()}
|
||||
className={`inline-flex items-center gap-1 rounded-full bg-accent px-3 text-caption font-semibold text-accent-text transition-opacity hover:opacity-90 disabled:cursor-default disabled:opacity-40 ${tall}`}
|
||||
>
|
||||
{take.state === 'busy' ? (
|
||||
<Loader2 size={12} className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Plus size={12} aria-hidden="true" />
|
||||
)}
|
||||
{planLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{take.visible && caption && (
|
||||
<p className="m-0 text-end text-caption text-content-muted" role="status">{caption}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* FE-AI-TOUR-029 to FE-AI-TOUR-032, FE-AI-TOUR-043
|
||||
*
|
||||
* The ways of one answer as the planner offers a section's ways: one choice
|
||||
* per way with how it differs from the chosen one, one profile with the
|
||||
* others drawn thin, the choice kept per answer and lit on the map, side by
|
||||
* side where the window is roomy, and any other route as a card of its own;
|
||||
* the card lit with its chosen line both ways, and "On map" framing them all.
|
||||
*/
|
||||
import type { TourRouteCard } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { GENTLE, HIKE, SAVED } from '../../../../../tests/helpers/assistantTour';
|
||||
import { buildTrip, buildUser } from '../../../../../tests/helpers/factories';
|
||||
import { act, fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
|
||||
import { useAuthStore } from '../../../../store/authStore';
|
||||
import { useTripStore } from '../../../../store/tripStore';
|
||||
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
|
||||
import TourRouteBlock from './TourRouteBlock';
|
||||
|
||||
const TRIP = 4;
|
||||
const ASSISTANT = useAssistantStore.getState();
|
||||
const TRIPS = useTripStore.getState();
|
||||
const AUTH = useAuthStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ user: buildUser({ id: 1 }) });
|
||||
useTripStore.setState({ trip: buildTrip({ id: TRIP, user_id: 1 }) });
|
||||
useAssistantStore.setState({
|
||||
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), threadId: 7 } },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(ASSISTANT, true);
|
||||
useTripStore.setState(TRIPS, true);
|
||||
useAuthStore.setState(AUTH, true);
|
||||
});
|
||||
|
||||
function renderBlock(cards: { toolCallId: string; card: TourRouteCard }[], view: Partial<AssistantView> = {}) {
|
||||
const value: AssistantView = { tripId: TRIP, variant: 'window', density: 'compact', onOpen: vi.fn(), ...view };
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={value}>
|
||||
<TourRouteBlock cards={cards} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const WAYS = [
|
||||
{ toolCallId: 'call_1', card: { ...HIKE, label: 'Short and steep' } },
|
||||
{ toolCallId: 'call_2', card: GENTLE },
|
||||
];
|
||||
|
||||
describe('TourRouteBlock', () => {
|
||||
it('FE-AI-TOUR-029: two ways read as one choice, the other way compared with the chosen one on one profile', () => {
|
||||
const { container } = renderBlock(WAYS);
|
||||
const group = screen.getByRole('radiogroup', { name: '2 ways' });
|
||||
const ways = group.querySelectorAll('[role="radio"]');
|
||||
expect(ways).toHaveLength(2);
|
||||
expect(ways[0]).toHaveAttribute('aria-checked', 'true');
|
||||
expect(ways[0]).toHaveAccessibleName('Short and steep');
|
||||
expect(ways[1]).toHaveAccessibleName('Long and gentle');
|
||||
expect(ways[1]?.querySelector('[data-testid="leg-variant-delta"]')).not.toBeNull();
|
||||
expect(ways[0]?.querySelector('[data-testid="leg-variant-delta"]')).toBeNull();
|
||||
expect(container.querySelector('[data-profile-compare="call_2"]')).not.toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Plan way A' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-030: choosing a way keeps it for the answer and plans it; pointing at a way lights its line', () => {
|
||||
renderBlock(WAYS);
|
||||
const second = screen.getByRole('radio', { name: 'Long and gentle' });
|
||||
fireEvent.mouseEnter(second);
|
||||
expect(useAssistantStore.getState().mapLink).toMatchObject({ tripId: TRIP, hoveredId: 'call_2', source: 'card' });
|
||||
fireEvent.mouseLeave(second);
|
||||
expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull();
|
||||
fireEvent.click(second);
|
||||
expect(chatOf(useAssistantStore.getState(), TRIP).picks).toEqual({ 'tour:call_1': 'call_2' });
|
||||
expect(second).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Plan way B' })).toBeInTheDocument();
|
||||
expect(document.querySelector('[data-profile-compare="call_1"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-031: the ways stand side by side in a roomy window and stay rows on the phone', () => {
|
||||
renderBlock(WAYS, { density: 'roomy' });
|
||||
expect(screen.getByRole('radiogroup')).toHaveClass('grid-cols-2');
|
||||
renderBlock(WAYS, { density: 'roomy', variant: 'fullscreen' });
|
||||
expect(screen.getAllByRole('radiogroup')[1]).not.toHaveClass('grid-cols-2');
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-032: a saved tour beside a proposal, or one proposal alone, is a card of its own', () => {
|
||||
renderBlock([
|
||||
{ toolCallId: 'call_1', card: SAVED },
|
||||
{ toolCallId: 'call_2', card: { ...HIKE, name: 'Wank' } },
|
||||
]);
|
||||
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('region', { name: 'Hochfelln from Bergen' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('region', { name: 'Wank' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-043: resting on the ways card lights the chosen line, a line lit on the map rings it, "On map" frames every way', () => {
|
||||
const requestCamera = vi.fn();
|
||||
useAssistantStore.setState({ requestCamera });
|
||||
const far: TourRouteCard = { ...GENTLE, line: [[47.9, 12.8, 600], ...GENTLE.line.slice(1)] };
|
||||
renderBlock([WAYS[0] as (typeof WAYS)[number], { toolCallId: 'call_2', card: far }]);
|
||||
const card = screen.getByRole('region', { name: '2 ways' });
|
||||
fireEvent.mouseEnter(card);
|
||||
expect(useAssistantStore.getState().mapLink).toMatchObject({ tripId: TRIP, hoveredId: 'call_1', source: 'card' });
|
||||
fireEvent.mouseLeave(card);
|
||||
expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull();
|
||||
|
||||
act(() => useAssistantStore.getState().setMapHover(TRIP, 'call_1', 'map'));
|
||||
expect(card.parentElement).toHaveClass('ring-1');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'On map' }));
|
||||
const points = requestCamera.mock.calls[0]?.[1] as [number, number][];
|
||||
expect(points).toHaveLength(HIKE.line.length + far.line.length);
|
||||
expect(points).toContainEqual([47.9, 12.8]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import TourRouteCard from './TourRouteCard';
|
||||
import { routeEntries, wayGroups } from './tourRouteModel';
|
||||
import TourWays from './TourWays';
|
||||
|
||||
/**
|
||||
* The routes of one answer: two or three ways of one route proposed together
|
||||
* as one card to choose from, every other route (a saved tour, a paddle or a
|
||||
* ski plan, a single proposal) as a card of its own.
|
||||
*/
|
||||
export default function TourRouteBlock({ cards }: { cards: readonly CardEntry[] }) {
|
||||
const groups = wayGroups(routeEntries(cards));
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{groups.map((group) => {
|
||||
const first = group[0];
|
||||
if (!first) return null;
|
||||
if (group.length >= 2) return <TourWays key={first.toolCallId} entries={group} />;
|
||||
return <TourRouteCard key={first.toolCallId} toolCallId={first.toolCallId} card={first.card} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* FE-AI-TOUR-021 to FE-AI-TOUR-028, FE-AI-TOUR-037 to FE-AI-TOUR-042
|
||||
*
|
||||
* A route in the chat as the Tours tab shows a hike: its figures and grade,
|
||||
* a profile only where the heights are known, the profile, the stop chips
|
||||
* and the map moving one spot, the day with the sun, the hours and the
|
||||
* warnings, "Plan this" with its gate (the right to add, the thread already
|
||||
* holding the tour, what came of a take), "Open in Tours" on a saved tour,
|
||||
* and the phone's readout row, taller buttons and open on the plan.
|
||||
*/
|
||||
import type { AssistantMessage, AssistantMessagePart, TourRouteCard as Route } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { GENTLE, HIKE, HIKE_LINE, SAVED } from '../../../../../tests/helpers/assistantTour';
|
||||
import { buildTrip, buildUser } from '../../../../../tests/helpers/factories';
|
||||
import { act, fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import { chatOf, type HoldOutcome, useAssistantStore } from '../../../../store/assistantStore';
|
||||
import { useAuthStore } from '../../../../store/authStore';
|
||||
import { usePermissionsStore } from '../../../../store/permissionsStore';
|
||||
import { useTripStore } from '../../../../store/tripStore';
|
||||
import { setForcedOffline } from '../../../../sync/networkMode';
|
||||
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
|
||||
import TourRouteCard from './TourRouteCard';
|
||||
|
||||
const TRIP = 4;
|
||||
const ASSISTANT = useAssistantStore.getState();
|
||||
const TRIPS = useTripStore.getState();
|
||||
const AUTH = useAuthStore.getState();
|
||||
const PERMISSIONS = usePermissionsStore.getState();
|
||||
|
||||
const takeProposal = vi.fn(async (): Promise<HoldOutcome> => 'held');
|
||||
const requestCamera = vi.fn();
|
||||
const onRevealMap = vi.fn();
|
||||
|
||||
/** A waiting change in another answer, which holds "Plan this" back. */
|
||||
const UNDECIDED: AssistantMessage = {
|
||||
id: 'msg-9',
|
||||
role: 'assistant',
|
||||
status: 'awaiting_approval',
|
||||
createdAt: '2026-10-21T08:00:00.000Z',
|
||||
parts: [{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_place', args: {}, approval: { id: 'w1' } }],
|
||||
};
|
||||
|
||||
/** The answer that proposed the hike under `call_1`, as the thread keeps it. */
|
||||
function proposed(): AssistantMessage {
|
||||
return {
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
status: 'complete',
|
||||
createdAt: '2026-10-21T07:00:00.000Z',
|
||||
parts: [{ type: 'data-trek-card', data: { toolCallId: 'call_1', card: HIKE } }],
|
||||
};
|
||||
}
|
||||
|
||||
/** The change "Plan this" held for that hike: applied (`true`) or discarded (`false`). */
|
||||
function heldTake(approved: boolean): AssistantMessage {
|
||||
const parts: AssistantMessagePart[] = [
|
||||
{ type: 'tool-call', toolCallId: 't1', toolName: 'take_proposal', args: {}, approval: { id: 't1', approved } },
|
||||
{
|
||||
type: 'data-trek-change',
|
||||
data: {
|
||||
toolCallId: 't1',
|
||||
verbKey: 'addTour',
|
||||
subject: 'Hochfelln from Bergen',
|
||||
rows: [],
|
||||
dayId: 22,
|
||||
placeIds: [],
|
||||
undoable: true,
|
||||
card: { ...HIKE, source: 'pending' },
|
||||
},
|
||||
},
|
||||
];
|
||||
return { id: 'msg-2', role: 'assistant', status: 'complete', createdAt: '2026-10-21T07:05:00.000Z', parts };
|
||||
}
|
||||
|
||||
function seed(messages: AssistantMessage[] = []) {
|
||||
useAuthStore.setState({ user: buildUser({ id: 1 }) });
|
||||
useTripStore.setState({ trip: buildTrip({ id: TRIP, user_id: 1 }) });
|
||||
useAssistantStore.setState({
|
||||
takeProposal,
|
||||
requestCamera,
|
||||
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), threadId: 7, messages } },
|
||||
});
|
||||
}
|
||||
|
||||
function renderCard(card: Route, view: Partial<AssistantView> = {}) {
|
||||
const value: AssistantView = {
|
||||
tripId: TRIP,
|
||||
variant: 'window',
|
||||
density: 'compact',
|
||||
onRevealMap,
|
||||
onOpen: vi.fn(),
|
||||
...view,
|
||||
};
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={value}>
|
||||
<TourRouteCard toolCallId="call_1" card={card} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setForcedOffline(false);
|
||||
seed();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(ASSISTANT, true);
|
||||
useTripStore.setState(TRIPS, true);
|
||||
useAuthStore.setState(AUTH, true);
|
||||
usePermissionsStore.setState(PERMISSIONS, true);
|
||||
setForcedOffline(false);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('TourRouteCard', () => {
|
||||
it('FE-AI-TOUR-021: states the name, the figures as badges, the day and the hardest grade, uncertain when little is graded', () => {
|
||||
const { container } = renderCard(HIKE);
|
||||
expect(screen.getByText('Hochfelln from Bergen')).toBeInTheDocument();
|
||||
for (const figure of ['time', 'distance', 'ascent', 'descent']) {
|
||||
expect(container.querySelector(`[data-figure="${figure}"]`)).not.toBeNull();
|
||||
}
|
||||
expect(screen.getByText('8.2 km')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('640 m')).toHaveLength(2);
|
||||
expect(screen.getByText(/^Day 2, /)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('tour-grade')).toHaveTextContent('T3');
|
||||
expect(screen.getByTestId('tour-grade')).toHaveAccessibleName(/Highest known: T3 Demanding mountain hiking/);
|
||||
renderCard({ ...GENTLE, name: 'Gentle way' });
|
||||
expect(screen.getAllByTestId('tour-grade')[1]).toHaveTextContent('T2?');
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-022: draws a profile only where every height is known, and says why a route is drawn straight', () => {
|
||||
renderCard(HIKE);
|
||||
expect(screen.getByRole('group', { name: 'Elevation profile of Hochfelln from Bergen' })).toBeInTheDocument();
|
||||
const gap = HIKE_LINE.map((point, index): [number, number, number | null] =>
|
||||
index === 2 ? [point[0], point[1], null] : point
|
||||
);
|
||||
renderCard({ ...HIKE, line: gap });
|
||||
expect(screen.getByText('No heights for this route, so there is no profile')).toBeInTheDocument();
|
||||
renderCard({ ...HIKE, routed: false, problem: { why: 'no_way', position: null, snappedM: null } });
|
||||
expect(screen.getByText('No path within T3 joins these points')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('group', { name: /Elevation profile/ })).toHaveLength(1);
|
||||
renderCard({ ...HIKE, routed: false, limit: null, problem: { why: 'no_way', position: null, snappedM: null } });
|
||||
expect(screen.getByText('No path joins these points')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-023: the profile sets the spot in focus for the map, and a spot from the map is read with its clock', () => {
|
||||
const { container } = renderCard(HIKE);
|
||||
const profile = screen.getByRole('group', { name: 'Elevation profile of Hochfelln from Bergen' });
|
||||
fireEvent.focus(profile);
|
||||
expect(useAssistantStore.getState().routeFocus).toMatchObject({ tripId: TRIP, ref: 'call_1', source: 'card' });
|
||||
expect(useAssistantStore.getState().routeFocus?.focus.distanceMeters).toBe(0);
|
||||
const focus = { distanceMeters: 4100, elevationMeters: 1400, lat: 47.795, lng: 12.625, sampleIndex: 5 };
|
||||
act(() => useAssistantStore.getState().setRouteFocus({ tripId: TRIP, ref: 'call_1', focus, source: 'map' }));
|
||||
const readout = container.querySelector('[data-profile-focus-readout]');
|
||||
expect(readout).toHaveTextContent('At 4.1 km: 1400 m, about 09:40, T3 Demanding mountain hiking');
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-024: a stop chip moves the spot in focus to the stop while pointed at', () => {
|
||||
renderCard(HIKE);
|
||||
const hut = screen.getByRole('button', { name: 'Strausshütte, 09:40' });
|
||||
fireEvent.mouseEnter(hut);
|
||||
expect(useAssistantStore.getState().routeFocus?.focus.distanceMeters).toBeCloseTo(2400, 0);
|
||||
fireEvent.mouseLeave(hut);
|
||||
expect(useAssistantStore.getState().routeFocus).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Water, 80 m off the way' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('list', { name: 'Stops along the way' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-025: lays out the day with the sun and the walk, the forecast hours with likely rain, and the warnings', () => {
|
||||
renderCard(HIKE);
|
||||
expect(
|
||||
screen.getByRole('img', {
|
||||
name: 'Daylight and walking time: Sunrise 07:00, Walking 08:00 to 11:20, Sunset 18:00',
|
||||
})
|
||||
).toBeInTheDocument();
|
||||
const hours = screen.getByRole('list', { name: 'Weather near the highest point' });
|
||||
expect(hours.querySelectorAll('li')).toHaveLength(4);
|
||||
expect(hours).toHaveTextContent('40 %');
|
||||
expect(hours).not.toHaveTextContent('10 %');
|
||||
expect(screen.getByText('Thunderstorms possible from 11:00')).toBeInTheDocument();
|
||||
renderCard({ ...HIKE, schedule: { ...(HIKE.schedule as NonNullable<Route['schedule']>), weather: 'climate' } });
|
||||
expect(screen.getByText('Typical weather for the date, no forecast yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-026: "Plan this" holds the hike below, waits with its reason, and asks to ask again when refused', async () => {
|
||||
renderCard(HIKE);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Plan this' }));
|
||||
});
|
||||
expect(takeProposal).toHaveBeenCalledWith(TRIP, 'call_1');
|
||||
expect(screen.getByText('Added below for you to confirm')).toBeInTheDocument();
|
||||
|
||||
takeProposal.mockImplementationOnce(async () => {
|
||||
const chat = chatOf(useAssistantStore.getState(), TRIP);
|
||||
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, error: 'generic' } } });
|
||||
return 'refused';
|
||||
});
|
||||
renderCard({ ...HIKE, name: 'Second' });
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Plan this' }));
|
||||
});
|
||||
expect(screen.getByText('This can no longer be planned from here. Ask for it again.')).toBeInTheDocument();
|
||||
|
||||
// Any other refusal is the chat's to say: the button is offered again.
|
||||
takeProposal.mockImplementationOnce(async () => {
|
||||
const chat = chatOf(useAssistantStore.getState(), TRIP);
|
||||
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, error: 'too_many_requests' } } });
|
||||
return 'refused';
|
||||
});
|
||||
renderCard({ ...HIKE, name: 'Third' });
|
||||
const third = screen.getAllByRole('button', { name: 'Plan this' })[1] as HTMLElement;
|
||||
await act(async () => {
|
||||
fireEvent.click(third);
|
||||
});
|
||||
expect(third).toBeEnabled();
|
||||
expect(screen.getAllByText('This can no longer be planned from here. Ask for it again.')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-027: "Plan this" is off while offline or while another change waits, and absent where nothing can be taken', () => {
|
||||
seed([UNDECIDED]);
|
||||
renderCard(HIKE);
|
||||
expect(screen.getByRole('button', { name: 'Plan this' })).toBeDisabled();
|
||||
expect(screen.getByText('Apply or discard the changes above first.')).toBeInTheDocument();
|
||||
act(() => setForcedOffline(true));
|
||||
expect(screen.getByText('Offline. The assistant needs internet.')).toBeInTheDocument();
|
||||
renderCard({ ...HIKE, routed: false, name: 'Straight' });
|
||||
expect(screen.getAllByRole('button', { name: 'Plan this' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-028: a saved tour opens in Tours; on the phone the readout is a row, buttons are taller and the map peeks', () => {
|
||||
const onOpen = vi.fn();
|
||||
renderCard(SAVED, { onOpen });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Open in Tours/ }));
|
||||
expect(onOpen).toHaveBeenCalledWith({ kind: 'tab', tab: 'tour-planner', params: { tour: '41' } });
|
||||
expect(screen.queryByRole('button', { name: 'Plan this' })).not.toBeInTheDocument();
|
||||
|
||||
renderCard({ ...HIKE, name: 'On the phone' }, { variant: 'fullscreen' });
|
||||
expect(screen.getByText('Slide along the profile to read it')).toBeInTheDocument();
|
||||
const onMap = screen.getAllByRole('button', { name: 'On map' })[1] as HTMLElement;
|
||||
expect(onMap).toHaveClass('h-10');
|
||||
expect(screen.getByRole('button', { name: 'Plan this' })).toHaveClass('h-10');
|
||||
fireEvent.click(onMap);
|
||||
expect(onRevealMap).toHaveBeenCalled();
|
||||
expect(requestCamera).toHaveBeenCalledWith(TRIP, expect.any(Array), 'fit', 'user');
|
||||
expect(useAssistantStore.getState().routePeek).toEqual({ tripId: TRIP, toolCallId: 'call_1' });
|
||||
const focus = { distanceMeters: 2400, elevationMeters: 920, lat: 47.78, lng: 12.61, sampleIndex: 2 };
|
||||
act(() => useAssistantStore.getState().setRouteFocus({ tripId: TRIP, ref: 'call_1', focus, source: 'map' }));
|
||||
expect(screen.getByTestId('tour-profile-readout')).toHaveTextContent('920 m');
|
||||
expect(screen.getByTestId('tour-profile-readout')).toHaveTextContent('T2');
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-037: on the phone a saved tour opens as its place on the plan, since the phone has no Tours tab', () => {
|
||||
const onOpen = vi.fn();
|
||||
renderCard(SAVED, { onOpen, variant: 'fullscreen' });
|
||||
const open = screen.getByRole('button', { name: /Open tour/ });
|
||||
expect(open).toHaveClass('h-10');
|
||||
fireEvent.click(open);
|
||||
expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 41, dayId: null });
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-038: "Plan this" is absent for a member who may not add places to the trip', () => {
|
||||
usePermissionsStore.setState({ permissions: { place_edit: 'trip_owner' } });
|
||||
useTripStore.setState({ trip: buildTrip({ id: TRIP, user_id: 2 }) });
|
||||
renderCard(HIKE);
|
||||
expect(screen.queryByRole('button', { name: 'Plan this' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'On map' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-039: a card drawn again does not offer a tour the thread already holds or applied, only one discarded', () => {
|
||||
const view = renderCard(HIKE);
|
||||
expect(screen.getByRole('button', { name: 'Plan this' })).toBeEnabled();
|
||||
view.unmount();
|
||||
|
||||
seed([proposed(), heldTake(true)]);
|
||||
const again = renderCard(HIKE);
|
||||
expect(screen.queryByRole('button', { name: 'Plan this' })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Added to the trip')).toBeInTheDocument();
|
||||
again.unmount();
|
||||
|
||||
seed([proposed(), heldTake(false)]);
|
||||
renderCard(HIKE);
|
||||
expect(screen.getByRole('button', { name: 'Plan this' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-040: what came of a take decides the card, not an error the chat still shows from before', async () => {
|
||||
const chat = chatOf(useAssistantStore.getState(), TRIP);
|
||||
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, error: 'generic' } } });
|
||||
renderCard(HIKE);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Plan this' }));
|
||||
});
|
||||
expect(screen.getByText('Added below for you to confirm')).toBeInTheDocument();
|
||||
|
||||
// Held nothing (a turn started, the thread changed): the button is offered again, nothing said taken.
|
||||
takeProposal.mockResolvedValueOnce('skipped');
|
||||
renderCard({ ...HIKE, name: 'Skipped' });
|
||||
const second = screen.getByRole('button', { name: 'Plan this' });
|
||||
await act(async () => {
|
||||
fireEvent.click(second);
|
||||
});
|
||||
expect(second).toBeEnabled();
|
||||
expect(screen.getAllByText('Added below for you to confirm')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-041: says why a route is drawn straight for each reason, and keeps two warnings of one kind', () => {
|
||||
const straight = (why: NonNullable<Route['problem']>['why'], position: number | null = null): Route => ({
|
||||
...HIKE,
|
||||
name: why,
|
||||
routed: false,
|
||||
problem: { why, position, snappedM: null },
|
||||
});
|
||||
renderCard(straight('point_off_the_way', 2));
|
||||
expect(screen.getByText('Point 2 is too far from any path')).toBeInTheDocument();
|
||||
renderCard(straight('ferry_only'));
|
||||
expect(
|
||||
screen.getByText('Some stations can only be reached by boat, so the walk is drawn straight')
|
||||
).toBeInTheDocument();
|
||||
renderCard(straight('too_long'));
|
||||
expect(screen.getByText('This way is longer than one hike is planned for')).toBeInTheDocument();
|
||||
renderCard(straight('not_planned'));
|
||||
expect(screen.queryByText('The way is planned when the tour is added')).not.toBeInTheDocument();
|
||||
|
||||
const rain = [
|
||||
{ kind: 'rain' as const, at: '10:00', count: null },
|
||||
{ kind: 'rain' as const, at: '14:00', count: null },
|
||||
];
|
||||
renderCard({ ...HIKE, name: 'Wet', warnings: rain });
|
||||
expect(screen.getByText('Rain likely from 10:00')).toBeInTheDocument();
|
||||
expect(screen.getByText('Rain likely from 14:00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-042: the day shows the latest start when none is set, and the walk in the dark in red', () => {
|
||||
const schedule = HIKE.schedule as NonNullable<Route['schedule']>;
|
||||
renderCard({ ...HIKE, schedule: { ...schedule, start: null, arrive: null, latestStart: '10:30' } });
|
||||
expect(screen.getByText('Start by 10:30 to finish before sunset')).toBeInTheDocument();
|
||||
|
||||
const { container } = renderCard({
|
||||
...HIKE,
|
||||
name: 'Early',
|
||||
schedule: { ...schedule, start: '06:00', arrive: '19:00', latestStart: '10:30' },
|
||||
});
|
||||
expect(container.querySelectorAll('[data-testid="tour-day"] .bg-danger')).toHaveLength(2);
|
||||
expect(screen.getAllByText('Start by 10:30 to finish before sunset')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { TourRouteCard as Route } from '@trek/shared';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import type { DistanceIndexedProfileSample } from '../../../../utils/routeGeometry';
|
||||
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import TourRouteActions from './TourRouteActions';
|
||||
import TourRouteDay from './TourRouteDay';
|
||||
import TourRouteHead from './TourRouteHead';
|
||||
import { profileSamples } from './tourRouteModel';
|
||||
import { TourRouteProblem, TourRouteWarnings } from './TourRouteNotes';
|
||||
import TourRouteProfile from './TourRouteProfile';
|
||||
import TourRouteStops from './TourRouteStops';
|
||||
import { routeName } from './tourRouteWords';
|
||||
import { useTourRouteCard } from './useTourRouteCard';
|
||||
|
||||
/**
|
||||
* One route in the chat, the way the Tours tab shows a hike: drawn small
|
||||
* beside its name, its grade and figures, its height profile, its stops along
|
||||
* the way, its day with the sun and the weather, what to look out for, and
|
||||
* the ways onward ("On map", "Plan this", "Open in Tours"). Resting on the
|
||||
* card lights its line on the map; resting on the line lights the card.
|
||||
*/
|
||||
|
||||
/** Everything under a route's head, shared by a single route and the chosen way of a ways card. */
|
||||
export function TourRouteBody({
|
||||
toolCallId,
|
||||
card,
|
||||
route,
|
||||
color,
|
||||
compare,
|
||||
letter,
|
||||
}: {
|
||||
toolCallId: string;
|
||||
card: Route;
|
||||
route: ReturnType<typeof useTourRouteCard>;
|
||||
color: string;
|
||||
compare?: ReadonlyArray<{ key: string; samples: readonly DistanceIndexedProfileSample[]; color: string }>;
|
||||
letter?: string;
|
||||
}) {
|
||||
const samples = useMemo(() => profileSamples(card), [card]);
|
||||
return (
|
||||
<>
|
||||
<TourRouteProfile
|
||||
toolCallId={toolCallId}
|
||||
card={card}
|
||||
samples={samples}
|
||||
color={color}
|
||||
focus={route.focus}
|
||||
onFocus={route.setFocus}
|
||||
compare={compare}
|
||||
/>
|
||||
<TourRouteStops card={card} samples={samples} onFocus={route.setFocus} />
|
||||
<TourRouteDay card={card} />
|
||||
<TourRouteWarnings card={card} />
|
||||
<TourRouteProblem card={card} />
|
||||
<TourRouteActions toolCallId={toolCallId} card={card} letter={letter} onShow={route.showOnMap} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TourRouteCard({ toolCallId, card }: { toolCallId: string; card: Route }) {
|
||||
const { t } = useTranslation();
|
||||
const { density } = useAssistantView();
|
||||
const route = useTourRouteCard(toolCallId, card);
|
||||
return (
|
||||
<div {...route.handlers} className={`rounded-xl ${route.litFromMap ? 'ring-1 ring-accent' : ''}`}>
|
||||
<CardFrame label={routeName(card, t)}>
|
||||
<TourRouteHead card={card} sketchHeight={density === 'roomy' ? 64 : 56} onShow={route.showOnMap} />
|
||||
<TourRouteBody toolCallId={toolCallId} card={card} route={route} color={variantPaintCss('current')} />
|
||||
</CardFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { TourRouteCard, TourRouteSchedule } from '@trek/shared';
|
||||
import { Clock, Droplet, Sunrise, Sunset } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import { weatherIconFor } from '../../../Weather/weatherIcons';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { type BarSpan, dayBarOf, forecastHours, RAIN_SHOWN_PCT } from './tourRouteDayModel';
|
||||
|
||||
/**
|
||||
* A tour's day in its card: one bar with the daylight and the walk on it
|
||||
* (the walk in the dark, before sunrise or after sunset, in red), sunrise and
|
||||
* sunset at its ends, the latest start when no start is set, and the
|
||||
* forecast hour by hour for the walk near its highest point. A day only a
|
||||
* climate is known for says so and shows no hours, since those are last
|
||||
* year's.
|
||||
*/
|
||||
|
||||
/** A stretch of the bar, placed along the reading direction. */
|
||||
function Span({ span, className }: { span: BarSpan; className: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute inset-y-0 ${className}`}
|
||||
style={{ insetInlineStart: `${span.start}%`, width: `${span.size}%` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** One hour of the forecast: the hour, the sky, the temperature, and the rain when it is likely. */
|
||||
function HourCell({ hour }: { hour: TourRouteSchedule['hours'][number] }) {
|
||||
const Icon = weatherIconFor(hour.main);
|
||||
const rain = hour.rainPct !== null && hour.rainPct >= RAIN_SHOWN_PCT ? hour.rainPct : null;
|
||||
return (
|
||||
<li className="flex w-9 flex-col items-center gap-0.5 text-caption tabular-nums text-content-secondary">
|
||||
<span className="text-content-faint">{String(hour.hour).padStart(2, '0')}</span>
|
||||
<Icon size={14} strokeWidth={2} aria-hidden="true" />
|
||||
<span>{`${hour.tempC}°`}</span>
|
||||
{rain !== null && (
|
||||
<span className="inline-flex items-center text-info">
|
||||
<Droplet size={9} strokeWidth={2.4} aria-hidden="true" />
|
||||
{`${rain} %`}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TourRouteDay({ card }: { card: TourRouteCard }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const schedule = card.schedule;
|
||||
if (!schedule) return null;
|
||||
const bar = dayBarOf(schedule);
|
||||
const hours = forecastHours(schedule);
|
||||
const arrive = schedule.arrive ? format.clock(schedule.arrive) : null;
|
||||
const walk =
|
||||
schedule.start && arrive
|
||||
? t('assistant.card.tour.walkTime', {
|
||||
start: format.clock(schedule.start),
|
||||
end: schedule.nextDay ? `${arrive} (${t('assistant.card.tour.nextDay')})` : arrive,
|
||||
})
|
||||
: null;
|
||||
const sunrise = schedule.sunrise ? t('assistant.card.tour.sunrise', { time: format.clock(schedule.sunrise) }) : null;
|
||||
const sunset = schedule.sunset ? t('assistant.card.tour.sunset', { time: format.clock(schedule.sunset) }) : null;
|
||||
const barLabel = `${t('assistant.card.tour.daylight')}: ${[sunrise, walk, sunset].filter(Boolean).join(', ')}`;
|
||||
const end = 'inline-flex flex-none items-center gap-1 text-caption tabular-nums text-content-muted';
|
||||
return (
|
||||
<div className="mt-2" data-testid="tour-day">
|
||||
{bar && (
|
||||
<div className="flex items-center gap-2" role="img" aria-label={barLabel}>
|
||||
{schedule.sunrise && (
|
||||
<span className={end}>
|
||||
<Sunrise size={12} strokeWidth={2} aria-hidden="true" />
|
||||
{format.clock(schedule.sunrise)}
|
||||
</span>
|
||||
)}
|
||||
<span className="relative h-2 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-tertiary">
|
||||
{bar.daylight && <Span span={bar.daylight} className="bg-warning-soft" />}
|
||||
{bar.walk && <Span span={bar.walk} className="bg-accent" />}
|
||||
{bar.dark.map((span) => (
|
||||
<Span key={span.start} span={span} className="bg-danger" />
|
||||
))}
|
||||
</span>
|
||||
{schedule.sunset && (
|
||||
<span className={end}>
|
||||
{format.clock(schedule.sunset)}
|
||||
<Sunset size={12} strokeWidth={2} aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!schedule.start && schedule.latestStart && (
|
||||
<div className="mt-1 flex">
|
||||
<SoftPill icon={<Clock size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t('assistant.card.tour.latestStart', { time: format.clock(schedule.latestStart) })}
|
||||
</SoftPill>
|
||||
</div>
|
||||
)}
|
||||
{schedule.weather === 'climate' && (
|
||||
<p className="m-0 mt-1 text-caption text-content-faint">{t('assistant.card.tour.climate')}</p>
|
||||
)}
|
||||
{hours.length > 0 && (
|
||||
<ul
|
||||
aria-label={t('assistant.card.tour.weatherNear')}
|
||||
className="m-0 mt-1.5 flex list-none flex-wrap gap-x-0.5 gap-y-1 p-0"
|
||||
>
|
||||
{hours.map((hour) => (
|
||||
<HourCell key={hour.hour} hour={hour} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { TourRouteCard } from '@trek/shared';
|
||||
import { CalendarDays, Clock, MoveHorizontal, TrendingDown, TrendingUp, type LucideIcon } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { SacStepChip } from '../../../Tours/hike/SacStepChip';
|
||||
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
|
||||
import { formatTourDuration } from '../../../Tours/planner/tourDurationInput';
|
||||
import { tourDayRowIcon } from '../../../Tours/tourDayRowIcon';
|
||||
import { CardFacts } from '../parts/CardFacts';
|
||||
import RouteSketch from '../parts/RouteSketch';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { type FactKey, factKeysOf, factValue, percentText } from './tourRouteModel';
|
||||
import { sketchOf } from './tourRouteSketchModel';
|
||||
import { routeName, SAC_WORDS, timeLabelKey, TOUR_TYPE_WORDS } from './tourRouteWords';
|
||||
|
||||
/**
|
||||
* The head of a route card: the route drawn small beside its name, the
|
||||
* hardest grade it reaches (or what kind of tour it is), and its figures as
|
||||
* the white badges every tour card uses, with the day it is planned on.
|
||||
*/
|
||||
|
||||
/** Each figure's glyph and its tooltip's name, as the planner's way cards name them; the time's by its basis. */
|
||||
const FACT_LOOK: Readonly<Record<FactKey, { Icon: LucideIcon; labelKey: string | null }>> = {
|
||||
time: { Icon: Clock, labelKey: null },
|
||||
distance: { Icon: MoveHorizontal, labelKey: 'tours.detail.distance' },
|
||||
ascent: { Icon: TrendingUp, labelKey: 'tours.detail.ascent' },
|
||||
descent: { Icon: TrendingDown, labelKey: 'tours.detail.descent' },
|
||||
};
|
||||
|
||||
/** The figures the route's activity states, in its order. */
|
||||
export function TourRouteFigures({ card }: { card: TourRouteCard }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const text = (key: FactKey, value: number) => {
|
||||
if (key === 'time') return formatTourDuration(value);
|
||||
return key === 'distance' ? format.distance(value / 1000) : format.elevation(value);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{factKeysOf(card).map((key) => {
|
||||
const { Icon, labelKey } = FACT_LOOK[key];
|
||||
const value = factValue(card, key);
|
||||
if (value === null) return null;
|
||||
return (
|
||||
<Tooltip key={key} label={t(labelKey ?? timeLabelKey(card))}>
|
||||
<span className="inline-flex" data-figure={key}>
|
||||
<WhiteBadge icon={<Icon size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
|
||||
{text(key, value)}
|
||||
</WhiteBadge>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The grade of a hike, as the hike's own card shows it: the hardest SAC step
|
||||
* along the way, uncertain when too little of it is graded, with what it
|
||||
* means, the routing limit and where grades come from in its tooltip. Any
|
||||
* other tour says what kind it is instead.
|
||||
*/
|
||||
export function TourGrade({ card }: { card: TourRouteCard }) {
|
||||
const { t } = useTranslation();
|
||||
if (card.activity !== 'hike' || !card.grades) {
|
||||
const Icon = tourDayRowIcon(card.activity);
|
||||
return (
|
||||
<SoftPill icon={<Icon size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t(TOUR_TYPE_WORDS[card.activity].labelKey)}
|
||||
</SoftPill>
|
||||
);
|
||||
}
|
||||
const { highest, unknownShare } = card.grades;
|
||||
const uncertain = unknownShare >= 0.5;
|
||||
const meaning = highest === null ? undefined : SAC_WORDS[highest - 1];
|
||||
const parts = [
|
||||
highest !== null && meaning && `${t('tours.hike.sac.highest')}: T${highest} ${t(meaning.labelKey)}`,
|
||||
unknownShare > 0 && t('assistant.card.tour.sacUnknown', { percent: percentText(unknownShare) }),
|
||||
card.limit !== null && `${t('tours.hike.sac.routingLimit')}: T${card.limit}`,
|
||||
t('tours.hike.sac.source'),
|
||||
].filter((part): part is string => typeof part === 'string');
|
||||
const label = parts.join('. ');
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<span role="img" aria-label={label} className="inline-flex flex-none" data-testid="tour-grade">
|
||||
<SacStepChip step={highest} uncertain={uncertain} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** The day a route is planned on, as words: "Day 2, Tue 14 Oct", or the date alone. */
|
||||
function DayPill({ card }: { card: TourRouteCard }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const schedule = card.schedule;
|
||||
if (!schedule) return null;
|
||||
const date = format.date(schedule.date);
|
||||
const text =
|
||||
schedule.dayNumber !== null ? `${t('assistant.card.tour.day', { day: schedule.dayNumber })}, ${date}` : date;
|
||||
return <SoftPill icon={<CalendarDays size={10} strokeWidth={2.2} aria-hidden="true" />}>{text}</SoftPill>;
|
||||
}
|
||||
|
||||
/** The route drawn small: a button that shows it on the map. */
|
||||
export function TourRouteSketch({
|
||||
card,
|
||||
height,
|
||||
paint = variantPaintCss('current'),
|
||||
onShow,
|
||||
}: {
|
||||
card: TourRouteCard;
|
||||
height: number;
|
||||
paint?: string;
|
||||
onShow?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { lines, marks } = sketchOf(card, paint);
|
||||
const name = routeName(card, t);
|
||||
return (
|
||||
<RouteSketch
|
||||
lines={lines}
|
||||
marks={marks}
|
||||
height={height}
|
||||
ariaLabel={t('assistant.card.tour.sketch', { name })}
|
||||
onActivate={onShow}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TourRouteHead({
|
||||
card,
|
||||
sketchHeight,
|
||||
onShow,
|
||||
}: {
|
||||
card: TourRouteCard;
|
||||
sketchHeight: number;
|
||||
onShow: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const name = routeName(card, t);
|
||||
return (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="w-[72px] flex-none">
|
||||
<TourRouteSketch card={card} height={sketchHeight} onShow={onShow} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<bdi title={name} className="line-clamp-2 min-w-0 flex-1 text-body font-semibold leading-tight text-content">
|
||||
{name}
|
||||
</bdi>
|
||||
<TourGrade card={card} />
|
||||
</div>
|
||||
{card.label && <p className="m-0 mt-0.5 truncate text-caption text-content-muted">{card.label}</p>}
|
||||
<CardFacts>
|
||||
<TourRouteFigures card={card} />
|
||||
<DayPill card={card} />
|
||||
</CardFacts>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* FE-AI-TOUR-033 to FE-AI-TOUR-034
|
||||
*
|
||||
* A held tour change in its row: the route drawn small with its figures and
|
||||
* grade and a small profile, the kind of tour as the row's tile; a tour whose
|
||||
* way is planned only once added says so without a profile; a changed route
|
||||
* says how the new way differs from the one it replaces.
|
||||
*/
|
||||
import type { TourRouteCard } from '@trek/shared';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { HIKE } from '../../../../../tests/helpers/assistantTour';
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import { useAssistantStore } from '../../../../store/assistantStore';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
|
||||
import TourRouteInRow, { TourRouteLead } from './TourRouteInRow';
|
||||
|
||||
const ASSISTANT = useAssistantStore.getState();
|
||||
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(ASSISTANT, true);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const PENDING: TourRouteCard = { ...HIKE, source: 'pending' };
|
||||
|
||||
function item(card: TourRouteCard, state: ChangeItem['state'] = 'pending'): ChangeItem {
|
||||
return { toolCallId: 'call_7', preview: null, applied: null, state, card };
|
||||
}
|
||||
|
||||
function renderRow(card: TourRouteCard, view: Partial<AssistantView> = {}) {
|
||||
const value: AssistantView = { tripId: 4, variant: 'window', ...view };
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={value}>
|
||||
<TourRouteLead card={card} item={item(card)} />
|
||||
<TourRouteInRow card={card} item={item(card)} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('TourRouteInRow', () => {
|
||||
it('FE-AI-TOUR-033: draws the route with its figures, grade and a small profile; the sketch shows it on the map', () => {
|
||||
const requestCamera = vi.fn();
|
||||
useAssistantStore.setState({ requestCamera });
|
||||
const { container } = renderRow(PENDING);
|
||||
expect(container.querySelector('[data-figure="distance"]')).toHaveTextContent('8.2 km');
|
||||
expect(screen.getByTestId('tour-grade')).toHaveTextContent('T3');
|
||||
expect(container.querySelectorAll('[data-testid="tour-route-row"] svg path[stroke]').length).toBeGreaterThan(0);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show Hochfelln from Bergen on the map' }));
|
||||
expect(requestCamera).toHaveBeenCalledWith(4, expect.any(Array), 'fit', 'user');
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-034: a way planned only once added says so without a profile; a changed way states its difference', () => {
|
||||
const straight: TourRouteCard = {
|
||||
...PENDING,
|
||||
routed: false,
|
||||
problem: { why: 'not_planned', position: null, snappedM: null },
|
||||
};
|
||||
const { container } = renderRow(straight);
|
||||
expect(screen.getByText('The way is planned when the tour is added')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-profile-band]')).toBeNull();
|
||||
const previous = {
|
||||
line: HIKE.line.map(([lat, lng]): [number, number] => [lat, lng]),
|
||||
distanceM: 6600,
|
||||
ascentM: 700,
|
||||
minutes: 180,
|
||||
};
|
||||
renderRow({ ...PENDING, name: 'Changed', previous });
|
||||
const delta = screen.getByTestId('leg-variant-delta');
|
||||
expect(delta).toHaveTextContent('+1.6 km');
|
||||
expect(delta).toHaveTextContent('+20 min');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { TourRouteCard } from '@trek/shared';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { useSettingsStore } from '../../../../store/settingsStore';
|
||||
import ElevationProfile from '../../../shared/ElevationProfile';
|
||||
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
|
||||
import { VariantDelta } from '../../../Tours/planner/LegVariantsCard';
|
||||
import { tourDayRowIcon } from '../../../Tours/tourDayRowIcon';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { verbLookOf } from '../cardLooks';
|
||||
import KindTile from '../parts/KindTile';
|
||||
import { TourGrade, TourRouteFigures, TourRouteSketch } from './TourRouteHead';
|
||||
import { legVariantOf, previousVariantOf, profileBands, profileSamples } from './tourRouteModel';
|
||||
import { useTourRouteCard } from './useTourRouteCard';
|
||||
|
||||
/**
|
||||
* A held tour change in its row, and the receipt it turns into: the route
|
||||
* drawn small with its figures and grade, a small profile under it, and for a
|
||||
* changed route how the new way differs from the one it replaces (the old
|
||||
* line faint in the sketch). A tour whose way is planned only once it is
|
||||
* added is drawn straight and says so. The receipt keeps all of it; "On map"
|
||||
* and "Open in Tours" sit at the receipt's end.
|
||||
*/
|
||||
|
||||
/** The tile in front of the row: what kind of tour, in the tone of what happens to it. */
|
||||
export function TourRouteLead({ card, item }: { card: TourRouteCard; item: ChangeItem }) {
|
||||
const tone = verbLookOf(item.preview?.verbKey ?? 'addTour').tone;
|
||||
const settled = item.state === 'discarded' || item.state === 'expired';
|
||||
return <KindTile icon={tourDayRowIcon(card.activity)} tone={tone} size="sm" dimmed={settled} />;
|
||||
}
|
||||
|
||||
export default function TourRouteInRow({ card, item }: { card: TourRouteCard; item: ChangeItem }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
|
||||
const { showOnMap } = useTourRouteCard(item.toolCallId, card);
|
||||
const samples = useMemo(() => profileSamples(card), [card]);
|
||||
const previous = previousVariantOf(card);
|
||||
const notPlanned = card.problem?.why === 'not_planned';
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5" data-testid="tour-route-row">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-16 flex-none">
|
||||
<TourRouteSketch card={card} height={40} onShow={showOnMap} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
<TourRouteFigures card={card} />
|
||||
<TourGrade card={card} />
|
||||
{previous && (
|
||||
<VariantDelta
|
||||
variant={legVariantOf(card, 'current')}
|
||||
current={previous}
|
||||
unit={unit}
|
||||
t={t}
|
||||
hint={t('assistant.card.tour.deltaBefore')}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{notPlanned && (
|
||||
<p className="m-0 mt-1 text-caption text-content-faint">{t('assistant.card.tour.notPlanned')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!notPlanned && samples.length >= 2 && (
|
||||
<ElevationProfile
|
||||
samples={samples}
|
||||
color={variantPaintCss('current')}
|
||||
height={36}
|
||||
gradientId={`assistant-tour-row-${item.toolCallId.replace(/[^\w-]/g, '-')}`}
|
||||
bands={profileBands(card, samples)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { TourRouteCard } from '@trek/shared';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import { TourNotice } from '../../../Tours/tourParts';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { PROBLEM_LOOK, WARNING_LOOK } from './tourRouteWords';
|
||||
|
||||
/**
|
||||
* What to look out for on a route: its warnings as pills (thunder, rain, the
|
||||
* dark; legs of a plan that failed), and why a route is drawn straight as a
|
||||
* notice, the way the Tours tab says it. A held change whose way is planned
|
||||
* only once applied says so in its row instead.
|
||||
*/
|
||||
|
||||
export function TourRouteWarnings({ card }: { card: TourRouteCard }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
if (card.warnings.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap gap-1" data-testid="tour-warnings">
|
||||
{card.warnings.map((warning, index) => {
|
||||
const look = WARNING_LOOK[warning.kind];
|
||||
return (
|
||||
<SoftPill
|
||||
key={`${warning.kind}-${index}`}
|
||||
tone={look.tone}
|
||||
icon={<AlertTriangle size={10} strokeWidth={2.2} aria-hidden="true" />}
|
||||
>
|
||||
{t(look.labelKey, { time: warning.at ? format.clock(warning.at) : '', count: warning.count ?? 1 })}
|
||||
</SoftPill>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TourRouteProblem({ card }: { card: TourRouteCard }) {
|
||||
const { t } = useTranslation();
|
||||
const problem = card.problem;
|
||||
if (!problem || problem.why === 'not_planned') return null;
|
||||
// No path within a limit the card does not know is said without one.
|
||||
const text =
|
||||
problem.why === 'no_way' && card.limit === null
|
||||
? t('assistant.card.tour.problem.no_way_any')
|
||||
: t(PROBLEM_LOOK[problem.why].labelKey, { grade: card.limit ?? '', point: problem.position ?? 1 });
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<TourNotice tone="warning" icon={<AlertTriangle size={12} strokeWidth={2} aria-hidden="true" />}>
|
||||
{text}
|
||||
</TourNotice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { TourRouteCard } from '@trek/shared';
|
||||
import { Clock, Mountain, MoveHorizontal, TrendingUp } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { useSettingsStore } from '../../../../store/settingsStore';
|
||||
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../../utils/routeGeometry';
|
||||
import { WhiteBadge } from '../../../Planner/planParts';
|
||||
import ElevationProfile from '../../../shared/ElevationProfile';
|
||||
import { SacStepChip } from '../../../Tours/hike/SacStepChip';
|
||||
import { profileFocusLabel } from '../../../Tours/tourPresentation';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { timeAt } from './tourRouteDayModel';
|
||||
import { gradeAt, profileBands, profileGrades, profileMarks, sacAt, STEEP_GRADE } from './tourRouteModel';
|
||||
import { routeName, SAC_WORDS } from './tourRouteWords';
|
||||
|
||||
/**
|
||||
* A route's height profile in its card: the hard grades (or else the steep
|
||||
* stretches) tinted, the named stops and stage ends marked, the other ways of
|
||||
* an answer drawn thin on the same axes. The spot in focus is the one the map
|
||||
* and the stop chips move too; its words add the time on the way and the
|
||||
* grade of the path there, or else how steep it is where it is steep. On
|
||||
* the phone a finger scrubs along it while the chat still scrolls, and the
|
||||
* spot is read in a row under it instead of a box over it. A route without
|
||||
* heights says so instead of drawing a flat line.
|
||||
*/
|
||||
|
||||
interface ProfileProps {
|
||||
toolCallId: string;
|
||||
card: TourRouteCard;
|
||||
samples: DistanceIndexedProfileSample[];
|
||||
color: string;
|
||||
focus: RouteProfileFocus | null;
|
||||
onFocus: (focus: RouteProfileFocus | null) => void;
|
||||
compare?: ReadonlyArray<{ key: string; samples: readonly DistanceIndexedProfileSample[]; color: string }>;
|
||||
}
|
||||
|
||||
/** What the phone's row under the profile says about the spot in focus. */
|
||||
function ReadoutRow({
|
||||
card,
|
||||
focus,
|
||||
grade,
|
||||
sac,
|
||||
}: {
|
||||
card: TourRouteCard;
|
||||
focus: RouteProfileFocus | null;
|
||||
grade: number | null;
|
||||
sac: number | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
if (!focus) return <p className="m-0 mt-1 text-caption text-content-faint">{t('assistant.card.tour.scrubHint')}</p>;
|
||||
const time = timeAt(card, focus.distanceMeters);
|
||||
const glyph = 'flex-none text-content-faint';
|
||||
return (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1" aria-live="polite" data-testid="tour-profile-readout">
|
||||
<WhiteBadge icon={<MoveHorizontal size={10} strokeWidth={2.2} className={glyph} />}>
|
||||
{format.distance(focus.distanceMeters / 1000)}
|
||||
</WhiteBadge>
|
||||
{focus.elevationMeters !== null && (
|
||||
<WhiteBadge icon={<Mountain size={10} strokeWidth={2.2} className={glyph} />}>
|
||||
{format.elevation(focus.elevationMeters)}
|
||||
</WhiteBadge>
|
||||
)}
|
||||
{time && (
|
||||
<WhiteBadge icon={<Clock size={10} strokeWidth={2.2} className={glyph} />}>
|
||||
{time.clock ? format.clock(time.clock) : format.minutes(time.minutes)}
|
||||
</WhiteBadge>
|
||||
)}
|
||||
{sac !== null && <SacStepChip step={sac} />}
|
||||
{sac === null && grade !== null && grade >= STEEP_GRADE && (
|
||||
<WhiteBadge icon={<TrendingUp size={10} strokeWidth={2.2} className={glyph} />}>
|
||||
{t('assistant.card.tour.steep', { grade: `${Math.round(grade)} %` })}
|
||||
</WhiteBadge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TourRouteProfile({ toolCallId, card, samples, color, focus, onFocus, compare }: ProfileProps) {
|
||||
const { t } = useTranslation();
|
||||
const { variant, density } = useAssistantView();
|
||||
const format = useCardFormat();
|
||||
const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
|
||||
const grades = useMemo(() => profileGrades(samples), [samples]);
|
||||
const bands = useMemo(() => profileBands(card, samples), [card, samples]);
|
||||
if (samples.length < 2) {
|
||||
if (!card.routed) return null;
|
||||
return <p className="m-0 mt-2 text-caption text-content-faint">{t('assistant.card.tour.noHeights')}</p>;
|
||||
}
|
||||
const phone = variant === 'fullscreen';
|
||||
const focusGrade = focus ? gradeAt(grades, focus.distanceMeters) : null;
|
||||
const focusSac = focus ? sacAt(card, focus.distanceMeters) : null;
|
||||
|
||||
/** The spot in words: how far, how high, about when, and the grade there, else how steep where it is steep. */
|
||||
const describe = (spot: RouteProfileFocus): string => {
|
||||
const time = timeAt(card, spot.distanceMeters);
|
||||
const text = time
|
||||
? t('assistant.card.tour.focusTime', {
|
||||
distance: format.distance(spot.distanceMeters / 1000),
|
||||
elevation: spot.elevationMeters === null ? '-' : format.elevation(spot.elevationMeters),
|
||||
time: time.clock ? format.clock(time.clock) : format.minutes(time.minutes),
|
||||
})
|
||||
: profileFocusLabel(spot, t, unit);
|
||||
const sac = sacAt(card, spot.distanceMeters);
|
||||
const meaning = sac === null ? undefined : SAC_WORDS[sac - 1];
|
||||
if (meaning) return `${text}, T${sac} ${t(meaning.labelKey)}`;
|
||||
const grade = gradeAt(grades, spot.distanceMeters);
|
||||
if (grade === null || grade < STEEP_GRADE) return text;
|
||||
return `${text}, ${t('assistant.card.tour.steep', { grade: `${Math.round(grade)} %` })}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<ElevationProfile
|
||||
samples={samples}
|
||||
color={color}
|
||||
height={phone || density === 'roomy' ? 64 : 52}
|
||||
gradientId={`assistant-tour-${toolCallId.replace(/[^\w-]/g, '-')}`}
|
||||
ariaLabel={t('assistant.card.tour.profile', { name: routeName(card, t) })}
|
||||
focus={focus}
|
||||
onFocusChange={onFocus}
|
||||
formatFocus={describe}
|
||||
bands={bands}
|
||||
marks={profileMarks(card)}
|
||||
compare={compare}
|
||||
readout={phone ? 'none' : 'tooltip'}
|
||||
keepFocusOnLift={phone}
|
||||
/>
|
||||
{phone && <ReadoutRow card={card} focus={focus} grade={focusGrade} sac={focusSac} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { TourRouteCard, TourRouteStop } from '@trek/shared';
|
||||
import { AlertTriangle, Flag, MapPin, MountainSnow, type LucideIcon } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import {
|
||||
type DistanceIndexedProfileSample,
|
||||
focusRouteProfileAtDistance,
|
||||
type RouteProfileFocus,
|
||||
} from '../../../../utils/routeGeometry';
|
||||
import { TOUR_POI_LOOKS } from '../../../Map/poiCategories';
|
||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { StageEndCorner } from '../../../Tours/stages/StageParts';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { chipStops } from './tourRouteModel';
|
||||
import { FLAG_LOOK, stopName } from './tourRouteWords';
|
||||
|
||||
/**
|
||||
* The stops of a route as chips in the order they lie along it: the start
|
||||
* and the end, the huts, summits and stations on the way with their clock,
|
||||
* the walking time between them faint, and what lies a little off the way
|
||||
* quieter still. Pointing at a chip or tapping it moves the spot in focus
|
||||
* there, on the profile and on the map alike. Past eight the middle ones
|
||||
* fold into a count that names them.
|
||||
*/
|
||||
|
||||
/** A stop's glyph: the flag at either end, the mountain at the highest point, the place's own for a kind. */
|
||||
function iconOf(stop: TourRouteStop): LucideIcon {
|
||||
if (stop.kind === 'highest') return MountainSnow;
|
||||
if (stop.kind && stop.kind !== 'station') return TOUR_POI_LOOKS[stop.kind].Icon;
|
||||
return stop.role === 'start' || stop.role === 'end' ? Flag : MapPin;
|
||||
}
|
||||
|
||||
/** The spot in focus for a stop: on the profile where it has one, else the stop itself at its distance. */
|
||||
function focusOf(stop: TourRouteStop, samples: readonly DistanceIndexedProfileSample[]): RouteProfileFocus {
|
||||
return (
|
||||
focusRouteProfileAtDistance(samples, stop.atM) ?? {
|
||||
distanceMeters: stop.atM,
|
||||
elevationMeters: null,
|
||||
lat: stop.lat,
|
||||
lng: stop.lng,
|
||||
sampleIndex: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function StopChip({
|
||||
stop,
|
||||
samples,
|
||||
onFocus,
|
||||
}: {
|
||||
stop: TourRouteStop;
|
||||
samples: readonly DistanceIndexedProfileSample[];
|
||||
onFocus: (focus: RouteProfileFocus | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const Icon = iconOf(stop);
|
||||
const name = stopName(stop, t);
|
||||
const along = stop.role === 'along';
|
||||
const off =
|
||||
along && stop.offM !== null
|
||||
? t('assistant.card.tour.alongOff', { distance: format.distance(stop.offM / 1000) })
|
||||
: null;
|
||||
const stay = stop.stayMinutes ? t('assistant.card.tour.stay', { time: format.minutes(stop.stayMinutes) }) : null;
|
||||
const clock = stop.clock ? format.clock(stop.clock) : null;
|
||||
const label = [name, clock, off, stay].filter(Boolean).join(', ');
|
||||
const glyph = <Icon size={10} strokeWidth={2.2} aria-hidden="true" className="flex-none text-content-faint" />;
|
||||
const body = (
|
||||
<>
|
||||
<bdi className="max-w-[10rem] truncate">{name}</bdi>
|
||||
{clock && <span className="tabular-nums text-content-muted">{clock}</span>}
|
||||
{off && <span className="text-content-faint">{off}</span>}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Tooltip label={label}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onMouseEnter={() => onFocus(focusOf(stop, samples))}
|
||||
onMouseLeave={() => onFocus(null)}
|
||||
onFocus={() => onFocus(focusOf(stop, samples))}
|
||||
onBlur={() => onFocus(null)}
|
||||
onClick={() => onFocus(focusOf(stop, samples))}
|
||||
className="relative inline-flex rounded-full focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
{along ? <WhiteBadge icon={glyph}>{body}</WhiteBadge> : <SoftPill icon={glyph}>{body}</SoftPill>}
|
||||
{stop.stageEnd && <StageEndCorner />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{stay && <span className="text-caption text-content-faint">{stay}</span>}
|
||||
{stop.flag && (
|
||||
<SoftPill tone="warning" icon={<AlertTriangle size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t(FLAG_LOOK[stop.flag].labelKey)}
|
||||
</SoftPill>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The walking time from one chip to the next, faint between them; nothing where it is not known. */
|
||||
function Between({ from, to }: { from: TourRouteStop; to: TourRouteStop }) {
|
||||
const format = useCardFormat();
|
||||
if (from.minutes === null || to.minutes === null || to.minutes <= from.minutes) return null;
|
||||
return (
|
||||
<span className="text-caption tabular-nums text-content-faint">{format.minutes(to.minutes - from.minutes)}</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TourRouteStops({
|
||||
card,
|
||||
samples,
|
||||
onFocus,
|
||||
}: {
|
||||
card: TourRouteCard;
|
||||
samples: readonly DistanceIndexedProfileSample[];
|
||||
onFocus: (focus: RouteProfileFocus | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { head, folded, tail } = chipStops(card);
|
||||
if (head.length === 0) return null;
|
||||
const chip = (stop: TourRouteStop, index: number, list: readonly TourRouteStop[]) => {
|
||||
// The time is walked from the last chip that has one: a place beside the way has none.
|
||||
const before = list
|
||||
.slice(0, index)
|
||||
.reverse()
|
||||
.find((other) => other.minutes !== null);
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{before && <Between from={before} to={stop} />}
|
||||
<StopChip stop={stop} samples={samples} onFocus={onFocus} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
const names = folded.map((stop) => stopName(stop, t)).join(', ');
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5" role="list" aria-label={t('assistant.card.tour.stops')}>
|
||||
{head.map((stop, index) => (
|
||||
<span key={`h-${index}`} role="listitem" className="inline-flex">
|
||||
{chip(stop, index, head)}
|
||||
</span>
|
||||
))}
|
||||
{folded.length > 0 && (
|
||||
<span role="listitem" className="inline-flex">
|
||||
<Tooltip label={names}>
|
||||
<span className="inline-flex">
|
||||
<WhiteBadge>
|
||||
{t('assistant.card.tour.stopsMore', { count: folded.length })}
|
||||
<span className="sr-only">{`: ${names}`}</span>
|
||||
</WhiteBadge>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
{tail.map((stop, index) => (
|
||||
<span key={`t-${index}`} role="listitem" className="inline-flex">
|
||||
{chip(stop, index, tail)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
|
||||
import { useSettingsStore } from '../../../../store/settingsStore';
|
||||
import { SacStepChip } from '../../../Tours/hike/SacStepChip';
|
||||
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
|
||||
import { VariantDelta } from '../../../Tours/planner/LegVariantsCard';
|
||||
import { VariantFigures, WayChoice } from '../../../Tours/planner/mapCardParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import { TourRouteBody } from './TourRouteCard';
|
||||
import { legVariantOf, profileSamples, type RouteEntry, WAY_KEYS } from './tourRouteModel';
|
||||
import { useLineHover, useTourRouteCard } from './useTourRouteCard';
|
||||
|
||||
/** The ways of one answer with the chosen one known: its choices, then the chosen way's body. */
|
||||
function WaysCard({
|
||||
entries,
|
||||
chosen,
|
||||
chosenIndex,
|
||||
blockKey,
|
||||
}: {
|
||||
entries: readonly RouteEntry[];
|
||||
chosen: RouteEntry;
|
||||
chosenIndex: number;
|
||||
blockKey: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId, variant, density } = useAssistantView();
|
||||
const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
|
||||
const pick = useAssistantStore((s) => s.pick);
|
||||
const hover = useLineHover();
|
||||
const all = useMemo(() => entries.map((entry) => entry.card), [entries]);
|
||||
const route = useTourRouteCard(chosen.toolCallId, chosen.card, all);
|
||||
const chosenKey = WAY_KEYS[chosenIndex] ?? 'a';
|
||||
|
||||
const compare = useMemo(
|
||||
() =>
|
||||
entries.flatMap((entry, index) => {
|
||||
const samples = profileSamples(entry.card);
|
||||
if (index === chosenIndex || samples.length < 2) return [];
|
||||
return [{ key: entry.toolCallId, samples, color: variantPaintCss(WAY_KEYS[index] ?? 'a') }];
|
||||
}),
|
||||
[entries, chosenIndex]
|
||||
);
|
||||
|
||||
const current = legVariantOf(chosen.card, chosenKey);
|
||||
// Side by side where the window is roomy; on the phone the ways stay rows, so a finger scrubs the profile freely.
|
||||
let columns = '';
|
||||
if (density === 'roomy' && variant !== 'fullscreen') columns = entries.length >= 3 ? 'grid-cols-3' : 'grid-cols-2';
|
||||
const title = t('assistant.card.tour.ways', { count: entries.length });
|
||||
return (
|
||||
<div {...route.handlers} className={`rounded-xl ${route.litFromMap ? 'ring-1 ring-accent' : ''}`}>
|
||||
<CardFrame label={title}>
|
||||
<p className="m-0 text-caption text-content-muted">{title}</p>
|
||||
<div role="radiogroup" aria-label={title} className={`mt-1.5 grid gap-1.5 ${columns}`}>
|
||||
{entries.map((entry, index) => {
|
||||
const key = WAY_KEYS[index] ?? 'a';
|
||||
const letter = key.toUpperCase();
|
||||
const way = legVariantOf(entry.card, key);
|
||||
const grades = entry.card.grades;
|
||||
return (
|
||||
<WayChoice
|
||||
key={entry.toolCallId}
|
||||
way={way}
|
||||
name={entry.card.label ?? t('tours.variants.option', { letter })}
|
||||
chosen={index === chosenIndex}
|
||||
aside={
|
||||
<>
|
||||
{index !== chosenIndex && (
|
||||
<VariantDelta
|
||||
variant={way}
|
||||
current={current}
|
||||
unit={unit}
|
||||
t={t}
|
||||
hint={t('assistant.card.tour.deltaTo', { letter: chosenKey.toUpperCase() })}
|
||||
/>
|
||||
)}
|
||||
{grades && <SacStepChip step={grades.highest} uncertain={grades.unknownShare >= 0.5} />}
|
||||
</>
|
||||
}
|
||||
onSelect={() => pick(tripId, blockKey, entry.toolCallId)}
|
||||
onHover={(on) => hover(entry.toolCallId, on)}
|
||||
>
|
||||
<VariantFigures variant={way} unit={unit} t={t} />
|
||||
</WayChoice>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<TourRouteBody
|
||||
toolCallId={chosen.toolCallId}
|
||||
card={chosen.card}
|
||||
route={route}
|
||||
color={variantPaintCss(chosenKey)}
|
||||
compare={compare}
|
||||
letter={chosenKey.toUpperCase()}
|
||||
/>
|
||||
</CardFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Two or three ways of one route an answer proposed, as the planner offers a
|
||||
* section's ways: each a choice with its letter in the colour it is drawn in,
|
||||
* its own words, how it differs from the chosen one and its hardest grade,
|
||||
* its figures below. One profile holds them all, the chosen way filled and
|
||||
* the others thin on the same axes; under it the chosen way's stops, day and
|
||||
* warnings, and "Plan way B". A choice is kept per answer, and pointing at a
|
||||
* way lights its line on the map.
|
||||
*/
|
||||
export default function TourWays({ entries }: { entries: readonly RouteEntry[] }) {
|
||||
const { tripId } = useAssistantView();
|
||||
const blockKey = `tour:${entries[0]?.toolCallId ?? ''}`;
|
||||
const picked = useAssistantStore((s) => chatOf(s, tripId).picks[blockKey] ?? null);
|
||||
const index = entries.findIndex((entry) => entry.toolCallId === picked);
|
||||
const chosenIndex = index < 0 ? 0 : index;
|
||||
const chosen = entries[chosenIndex];
|
||||
if (!chosen) return null;
|
||||
return <WaysCard entries={entries} chosen={chosen} chosenIndex={chosenIndex} blockKey={blockKey} />;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* FE-AI-TOUR-017 to FE-AI-TOUR-020
|
||||
*
|
||||
* The tours' part of the chat's pure look: the verbs drawn with the route
|
||||
* icon, the reading steps named, an applied tour shown by its line and opened
|
||||
* in the Tours tab (on the phone, which has none, by its pin), proposals of
|
||||
* one activity grouped as ways, and the lines the plan's map draws for a
|
||||
* route and for the way a change replaces.
|
||||
*/
|
||||
import type { AssistantApplied } from '@trek/shared';
|
||||
import { Route } from 'lucide-react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { GENTLE, HIKE, HIKE_LINE, SAVED } from '../../../../../tests/helpers/assistantTour';
|
||||
import { CARD_MAP_LOOK, CARD_RULES, ENTITY_LOOK, STEP_LOOKS, VERB_LOOK } from '../cardLooks';
|
||||
import { tourRouteLines } from './tourLook';
|
||||
|
||||
const applied = (ids: number[]): AssistantApplied => ({
|
||||
toolCallId: 'c1',
|
||||
entity: 'tour',
|
||||
ids,
|
||||
dayId: 3,
|
||||
undoUntil: null,
|
||||
});
|
||||
|
||||
describe('tourLook', () => {
|
||||
it('FE-AI-TOUR-017: adding and changing a tour wear the route icon, and the tour reads have their steps', () => {
|
||||
expect(VERB_LOOK.addTour).toMatchObject({ Icon: Route, tone: 'success' });
|
||||
expect(VERB_LOOK.updateTour).toMatchObject({ Icon: Route, tone: 'info' });
|
||||
expect(STEP_LOOKS.propose_city_walk?.stepKey).toBe('assistant.step.planningWalk');
|
||||
expect(STEP_LOOKS.plan_ski_route?.stepKey).toBe('assistant.step.planningRoute');
|
||||
expect(STEP_LOOKS.find_tours_for_day?.stepKey).toBe('assistant.step.readingTours');
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-018: an applied tour shows its line on the plan and opens in the Tours tab, on the phone by its pin alone', () => {
|
||||
const tour = ENTITY_LOOK.tour;
|
||||
expect(tour.openLabelKey).toBe('assistant.card.tour.open');
|
||||
expect(tour.phoneOpen?.(applied([41]), null)).toBeNull();
|
||||
expect(tour.onMap?.(applied([41]), null)).toEqual({ placeId: 41, dayId: null });
|
||||
expect(tour.open?.(applied([41]), null)).toEqual({ kind: 'tab', tab: 'tour-planner', params: { tour: '41' } });
|
||||
expect(tour.onMap?.(applied([]), SAVED)).toEqual({ placeId: 41, dayId: null });
|
||||
expect(tour.onMap?.(applied([]), null)).toBeNull();
|
||||
expect(tour.open?.(applied([]), HIKE)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-019: proposals of one activity share a group, so an answer shows them as ways', () => {
|
||||
expect(CARD_RULES.group(HIKE)).toBe('tourRoute:ways:hike');
|
||||
expect(CARD_RULES.group(GENTLE)).toBe('tourRoute:ways:hike');
|
||||
expect(CARD_RULES.group({ ...HIKE, activity: 'walk' })).toBe('tourRoute:ways:walk');
|
||||
expect(CARD_RULES.group(SAVED)).toBe('tourRoute');
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-020: the map draws the route with its named stops and hard grades, dashed while straight, the replaced way faint, and no pin over a routed way', () => {
|
||||
expect(CARD_MAP_LOOK.lines(HIKE, 'look')).toEqual([
|
||||
{
|
||||
id: 'route',
|
||||
lines: [HIKE_LINE.map(([lat, lng]) => [lat, lng])],
|
||||
color: 'var(--hike-variant-current)',
|
||||
casing: 'var(--bike-highlight-casing)',
|
||||
dashed: false,
|
||||
stops: [
|
||||
{ lat: 47.77, lng: 12.59, label: 'Bergen' },
|
||||
{ lat: 47.78, lng: 12.59, label: 'Strausshütte' },
|
||||
{ lat: 47.795, lng: 12.59, label: 'Hochfelln' },
|
||||
{ lat: 47.77, lng: 12.59, label: 'Bergen' },
|
||||
],
|
||||
tints: [
|
||||
{
|
||||
line: [
|
||||
[47.79, 12.62],
|
||||
[47.795, 12.625],
|
||||
],
|
||||
color: 'var(--hike-sac-t3)',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(tourRouteLines({ ...HIKE, routed: false }, 'pending')[0]?.dashed).toBe(true);
|
||||
expect(tourRouteLines({ ...HIKE, routed: false }, 'pending')[0]?.tints).toBeUndefined();
|
||||
expect(tourRouteLines(HIKE, 'before')).toEqual([]);
|
||||
const line = HIKE_LINE.map(([lat, lng]): [number, number] => [lat, lng]);
|
||||
const previous = { line, distanceM: 1, ascentM: null, minutes: null };
|
||||
expect(tourRouteLines({ ...HIKE, previous }, 'before')).toEqual([
|
||||
{ id: 'previous', lines: [line], color: 'var(--text-faint)', casing: 'var(--bike-highlight-casing)', dim: true },
|
||||
]);
|
||||
expect(tourRouteLines({ ...HIKE, line: [] }, 'look')).toEqual([]);
|
||||
expect(CARD_MAP_LOOK.hidesEffectGhost(HIKE)).toBe(true);
|
||||
expect(CARD_MAP_LOOK.hidesEffectGhost({ ...HIKE, routed: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,132 @@
|
||||
import { type AssistantApplied, cumulativeMetres, type TourRouteCard, type TrekCard } from '@trek/shared';
|
||||
import { Route } from 'lucide-react';
|
||||
|
||||
import type { PackLook } from '../cardTypes';
|
||||
import type { RouteLineSpec, RouteUse } from '../../../../types/assistantMap';
|
||||
import type { EntityTarget, PackLook } from '../cardTypes';
|
||||
|
||||
/**
|
||||
* What the tours bring to the chat's pure look: the verbs, tool names and steps
|
||||
* they add, what an applied change of theirs opens, and the look of each of
|
||||
* their kinds. An applied tour has no way to open yet, as before. A look stays
|
||||
* pure, icons and literals only: the chat's main chunk reads every look, so
|
||||
* whatever a look imports ships with it.
|
||||
* What the tours bring to the chat's pure look: the verbs of their changes,
|
||||
* the names of their reading steps, what an applied tour opens (its line on
|
||||
* the plan, the tour in the Tours tab) and the look of each of their kinds:
|
||||
* how an answer groups the ways it proposed, what to ask next and the lines
|
||||
* the plan's map draws for a route. A look stays pure, icons and literals
|
||||
* only: the chat's main chunk reads every look, so whatever a look imports
|
||||
* ships with it.
|
||||
*/
|
||||
|
||||
/** The colours a route is drawn in on the plan, the planner's own for the way taken now (styles/hike.css). */
|
||||
const ROUTE_PAINT = { line: 'var(--hike-variant-current)', casing: 'var(--bike-highlight-casing)' } as const;
|
||||
|
||||
/** The tour a change or a card is about: the applied change's id, else the saved tour the card shows. */
|
||||
function tourIdOf(applied: AssistantApplied, card: TrekCard | null): number | null {
|
||||
return applied.ids[0] ?? (card?.kind === 'tourRoute' ? card.placeId : null);
|
||||
}
|
||||
|
||||
/** A saved tour opened in the Tours tab; the planner takes `?tour=` once and opens it. The phone has no such tab. */
|
||||
export function tourTarget(placeId: number): EntityTarget {
|
||||
return { kind: 'tab', tab: 'tour-planner', params: { tour: String(placeId) } };
|
||||
}
|
||||
|
||||
/**
|
||||
* What the plan's map draws for a route: the way itself, dashed while it is
|
||||
* only its stops joined straight; for the way a held change replaces, the old
|
||||
* line faint beside it.
|
||||
*/
|
||||
export function tourRouteLines(card: TourRouteCard, use: RouteUse): RouteLineSpec[] {
|
||||
if (use === 'before') {
|
||||
if (!card.previous) return [];
|
||||
const lines = [card.previous.line];
|
||||
return [{ id: 'previous', lines, color: 'var(--text-faint)', casing: ROUTE_PAINT.casing, dim: true }];
|
||||
}
|
||||
if (card.line.length < 2) return [];
|
||||
const line = card.line.map(([lat, lng]): [number, number] => [lat, lng]);
|
||||
const spec: RouteLineSpec = {
|
||||
id: 'route',
|
||||
lines: [line],
|
||||
color: ROUTE_PAINT.line,
|
||||
casing: ROUTE_PAINT.casing,
|
||||
dashed: !card.routed,
|
||||
};
|
||||
const stops = card.stops.flatMap((stop) => (stop.name ? [{ lat: stop.lat, lng: stop.lng, label: stop.name }] : []));
|
||||
const tints = card.routed ? hardStretches(card, line) : [];
|
||||
if (stops.length > 0) spec.stops = stops;
|
||||
if (tints.length > 0) spec.tints = tints;
|
||||
return [spec];
|
||||
}
|
||||
|
||||
/** The hard grades' colours (styles/hike.css), from T3 on: the easy ones stay the way's own colour. */
|
||||
const HARD_GRADE_PAINT: Readonly<Record<number, string>> = {
|
||||
3: 'var(--hike-sac-t3)',
|
||||
4: 'var(--hike-sac-t4)',
|
||||
5: 'var(--hike-sac-t5)',
|
||||
6: 'var(--hike-sac-t6)',
|
||||
};
|
||||
|
||||
/**
|
||||
* The stretches graded T3 or harder, cut out of the line by how far along
|
||||
* it they lie (scaled to the route's own length, as the card measures its
|
||||
* stops), each in its grade's colour.
|
||||
*/
|
||||
function hardStretches(card: TourRouteCard, line: [number, number][]): NonNullable<RouteLineSpec['tints']> {
|
||||
const along = cumulativeMetres(card.line);
|
||||
const total = along[along.length - 1] ?? 0;
|
||||
const scale = card.distanceM !== null && total > 0 ? card.distanceM / total : 1;
|
||||
return card.stretches.flatMap((stretch) => {
|
||||
const color = stretch.facet === 'sac' ? HARD_GRADE_PAINT[stretch.step] : undefined;
|
||||
if (!color) return [];
|
||||
const part = line.filter((_, index) => {
|
||||
const at = (along[index] ?? 0) * scale;
|
||||
return at >= stretch.fromM && at <= stretch.toM;
|
||||
});
|
||||
return part.length >= 2 ? [{ line: part, color }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export const TOUR_LOOK = {
|
||||
verbs: {},
|
||||
verbs: {
|
||||
addTour: { Icon: Route, tone: 'success', labelKey: 'assistant.change.verb.addTour' },
|
||||
updateTour: { Icon: Route, tone: 'info', labelKey: 'assistant.change.verb.updateTour' },
|
||||
},
|
||||
tools: {},
|
||||
steps: {},
|
||||
steps: {
|
||||
propose_city_walk: { stepKey: 'assistant.step.planningWalk' },
|
||||
plan_paddle_route: { stepKey: 'assistant.step.planningRoute' },
|
||||
plan_ski_route: { stepKey: 'assistant.step.planningRoute' },
|
||||
get_tour_plan: { stepKey: 'assistant.step.readingTour' },
|
||||
list_tours: { stepKey: 'assistant.step.readingTours' },
|
||||
find_tours_for_day: { stepKey: 'assistant.step.readingTours' },
|
||||
search_tour_pois: { stepKey: 'assistant.step.tourPois' },
|
||||
},
|
||||
entities: {
|
||||
tour: { Icon: Route },
|
||||
tour: {
|
||||
Icon: Route,
|
||||
openLabelKey: 'assistant.card.tour.open',
|
||||
onMap: (applied, card) => {
|
||||
const placeId = tourIdOf(applied, card);
|
||||
return placeId === null ? null : { placeId, dayId: null };
|
||||
},
|
||||
open: (applied, card) => {
|
||||
const placeId = tourIdOf(applied, card);
|
||||
return placeId === null ? null : tourTarget(placeId);
|
||||
},
|
||||
// The phone opens a tour as its place on the plan, which the map pin beside it already does.
|
||||
phoneOpen: () => null,
|
||||
},
|
||||
},
|
||||
cards: {
|
||||
tourRoute: {},
|
||||
tourList: {},
|
||||
// Two or three ways of one activity proposed in one answer read as one card to choose from.
|
||||
tourRoute: {
|
||||
group: (card) => (card.source === 'proposal' ? `tourRoute:ways:${card.activity}` : 'tourRoute'),
|
||||
followUps: [
|
||||
{ id: 'tourEasier', labelKey: 'assistant.followUp.tourEasier' },
|
||||
{ id: 'tourPacking', labelKey: 'assistant.followUp.tourPacking' },
|
||||
],
|
||||
lines: tourRouteLines,
|
||||
// A routed way draws its own line and stops: the pin of the place it adds would only cover its start.
|
||||
hidesEffectGhost: (card) => card.routed && card.line.length >= 2,
|
||||
},
|
||||
tourList: {
|
||||
followUps: [{ id: 'tourFit', labelKey: 'assistant.followUp.tourFit' }],
|
||||
},
|
||||
},
|
||||
} satisfies PackLook<'tourRoute' | 'tourList'>;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* FE-AI-TOUR-013 to FE-AI-TOUR-016
|
||||
*
|
||||
* A tour's day on its card: the daylight and the walk on one bar, the walk
|
||||
* in the dark marked apart, an arrival past midnight on the next day, the
|
||||
* forecast's hours only for a forecast, and the time on the way at a spot.
|
||||
*/
|
||||
import type { TourRouteSchedule } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { HIKE, SAVED } from '../../../../../tests/helpers/assistantTour';
|
||||
import { clockAfter, clockMinutes, dayBarOf, forecastHours, timeAt } from './tourRouteDayModel';
|
||||
|
||||
const DAY = HIKE.schedule as TourRouteSchedule;
|
||||
|
||||
describe('tourRouteDayModel', () => {
|
||||
it('FE-AI-TOUR-013: lays the daylight and the walk on the hours from one before sunrise to one after sunset', () => {
|
||||
expect(dayBarOf(DAY)).toEqual({
|
||||
daylight: { start: 7.69, size: 84.62 },
|
||||
walk: { start: 15.38, size: 25.64 },
|
||||
dark: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-014: marks the walk before sunrise and after sunset, and puts an arrival past midnight on the next day', () => {
|
||||
const long = { ...DAY, start: '06:00', arrive: '19:00' };
|
||||
expect(dayBarOf(long)?.dark).toEqual([
|
||||
{ start: 6.67, size: 6.67 },
|
||||
{ start: 86.67, size: 6.67 },
|
||||
]);
|
||||
const night = { ...DAY, start: '22:00', arrive: '01:30', nextDay: true, sunrise: null, sunset: null };
|
||||
expect(dayBarOf(night)).toEqual({ daylight: null, walk: { start: 16.67, size: 58.33 }, dark: [] });
|
||||
expect(dayBarOf({ ...night, start: null, arrive: null })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-015: shows the hours of a forecast only, never last year’s climate hours', () => {
|
||||
expect(forecastHours(DAY)).toHaveLength(4);
|
||||
expect(forecastHours({ ...DAY, weather: 'climate' })).toEqual([]);
|
||||
expect(forecastHours({ ...DAY, weather: null })).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-016: the time at a spot is shared out by distance, as a clock when the day has a start', () => {
|
||||
expect(clockMinutes('08:05')).toBe(485);
|
||||
expect(clockAfter('23:30', 45)).toBe('00:15');
|
||||
expect(timeAt(HIKE, 4100)).toEqual({ clock: '09:40', minutes: 100 });
|
||||
expect(timeAt(HIKE, 99_999)).toEqual({ clock: '11:20', minutes: 200 });
|
||||
expect(timeAt(SAVED, 4100)).toEqual({ clock: null, minutes: 100 });
|
||||
expect(timeAt({ ...HIKE, minutes: null }, 4100)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { TourRouteCard, TourRouteSchedule } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* A tour's day as its card draws it, pure: where the daylight and the walk
|
||||
* lie on one bar (and the parts of the walk out in the dark), which hours of
|
||||
* the forecast to show, and the time on the way at a spot of it.
|
||||
*/
|
||||
|
||||
const DAY_MINUTES = 24 * 60;
|
||||
/** From which likelihood of rain an hour shows its drop. */
|
||||
export const RAIN_SHOWN_PCT = 30;
|
||||
|
||||
/** Minutes after midnight of an `HH:MM` clock. */
|
||||
export function clockMinutes(clock: string): number {
|
||||
const [hours, minutes] = clock.split(':').map(Number);
|
||||
return (hours ?? 0) * 60 + (minutes ?? 0);
|
||||
}
|
||||
|
||||
/** The `HH:MM` clock `minutes` after `clock`, past midnight round again. */
|
||||
export function clockAfter(clock: string, minutes: number): string {
|
||||
const total = (((clockMinutes(clock) + Math.round(minutes)) % DAY_MINUTES) + DAY_MINUTES) % DAY_MINUTES;
|
||||
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** A stretch of the bar, as shares of its length in percent. */
|
||||
export interface BarSpan {
|
||||
start: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/** The day's bar: the daylight, the walk, the parts of the walk in the dark, each placed on the hours shown. */
|
||||
interface DayBar {
|
||||
daylight: BarSpan | null;
|
||||
walk: BarSpan | null;
|
||||
dark: BarSpan[];
|
||||
}
|
||||
|
||||
const round = (value: number) => Math.round(value * 100) / 100;
|
||||
|
||||
/**
|
||||
* The bar of a scheduled day, from an hour before the first thing it shows
|
||||
* to an hour after the last (the sun, the start, the arrival), so the walk
|
||||
* reads against the daylight. An arrival past midnight lies on the next day.
|
||||
* Null when the day knows neither the sun nor a start.
|
||||
*/
|
||||
export function dayBarOf(schedule: TourRouteSchedule): DayBar | null {
|
||||
const sunrise = schedule.sunrise ? clockMinutes(schedule.sunrise) : null;
|
||||
const sunset = schedule.sunset ? clockMinutes(schedule.sunset) : null;
|
||||
const start = schedule.start ? clockMinutes(schedule.start) : null;
|
||||
let arrive = schedule.arrive ? clockMinutes(schedule.arrive) : null;
|
||||
if (start !== null && arrive !== null && (schedule.nextDay || arrive < start)) arrive += DAY_MINUTES;
|
||||
const known = [sunrise, sunset, start, arrive].filter((value): value is number => value !== null);
|
||||
if (known.length === 0) return null;
|
||||
const from = Math.max(0, Math.floor(Math.min(...known) / 60) * 60 - 60);
|
||||
const to = Math.min(2 * DAY_MINUTES, Math.ceil(Math.max(...known) / 60) * 60 + 60);
|
||||
const span = (a: number, b: number): BarSpan => ({
|
||||
start: round(((a - from) / (to - from)) * 100),
|
||||
size: round(((b - a) / (to - from)) * 100),
|
||||
});
|
||||
const daylight = sunrise !== null && sunset !== null && sunset > sunrise ? span(sunrise, sunset) : null;
|
||||
const walk = start !== null && arrive !== null && arrive > start ? span(start, arrive) : null;
|
||||
const dark: BarSpan[] = [];
|
||||
if (walk && start !== null && arrive !== null && sunrise !== null && sunset !== null) {
|
||||
if (start < sunrise) dark.push(span(start, Math.min(arrive, sunrise)));
|
||||
if (arrive > sunset) dark.push(span(Math.max(start, sunset), arrive));
|
||||
}
|
||||
return { daylight, walk, dark };
|
||||
}
|
||||
|
||||
/** The forecast's hours to show as cells: only a forecast's, since a climate's are last year's. */
|
||||
export function forecastHours(schedule: TourRouteSchedule): TourRouteSchedule['hours'] {
|
||||
return schedule.weather === 'forecast' ? schedule.hours : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The time on the way at a spot along it, shared out by distance: the clock
|
||||
* when the day has a start, else the minutes since setting off. Null for a
|
||||
* route without a time or a length.
|
||||
*/
|
||||
export function timeAt(card: TourRouteCard, atM: number): { clock: string | null; minutes: number } | null {
|
||||
if (card.minutes === null || !card.distanceM) return null;
|
||||
const minutes = Math.round(card.minutes * Math.min(1, Math.max(0, atM / card.distanceM)));
|
||||
const start = card.schedule?.start ?? null;
|
||||
return { clock: start ? clockAfter(start, minutes) : null, minutes };
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* FE-AI-TOUR-001 to FE-AI-TOUR-012, FE-AI-TOUR-045 to FE-AI-TOUR-046
|
||||
*
|
||||
* A tour card's numbers: the profile scaled to the route's own length, the
|
||||
* hard grades tinted before the steep stretches, the stops worth a chip, the
|
||||
* figures each activity states, a way as the planner's way cards read one,
|
||||
* what "Plan this" can take, how an answer's routes are grouped, the words of
|
||||
* a stop and of a route's time, the grade under a spot and the sketch's lines
|
||||
* and marks.
|
||||
*/
|
||||
import type { TourRouteCard, TourRouteStop } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { GENTLE, HIKE, HIKE_LINE, SAVED, TOUR_LIST, tourStop } from '../../../../../tests/helpers/assistantTour';
|
||||
import {
|
||||
canTake,
|
||||
chipStops,
|
||||
factKeysOf,
|
||||
gradeAt,
|
||||
legVariantOf,
|
||||
percentText,
|
||||
previousVariantOf,
|
||||
profileBands,
|
||||
profileGrades,
|
||||
profileMarks,
|
||||
profileSamples,
|
||||
routeEntries,
|
||||
routePoints,
|
||||
sacAt,
|
||||
sacPaint,
|
||||
steepStretches,
|
||||
wayGroups,
|
||||
} from './tourRouteModel';
|
||||
import { sketchOf } from './tourRouteSketchModel';
|
||||
import { routeName, stopName, timeLabelKey } from './tourRouteWords';
|
||||
|
||||
/** A kilometre of way, flat, then 40 % steep from 300 m to 700 m, then flat again. */
|
||||
const RAMP = [0, 0, 0, 0, 40, 80, 120, 160, 160, 160, 160].map((elevationMeters, index) => ({
|
||||
distanceMeters: index * 100,
|
||||
elevationMeters,
|
||||
lat: 47,
|
||||
lng: 11 + index * 0.001,
|
||||
}));
|
||||
|
||||
/** The way a held change replaces, in brief. */
|
||||
const PREVIOUS_LINE: [number, number][] = [
|
||||
[47.7, 12.5],
|
||||
[47.8, 12.6],
|
||||
];
|
||||
|
||||
const named = (name: string, atM: number): TourRouteStop => tourStop({ name, atM });
|
||||
const t = (key: string) => key;
|
||||
|
||||
describe('tourRouteModel', () => {
|
||||
it('FE-AI-TOUR-001: the profile follows the line, scaled so it ends at the route length the card states', () => {
|
||||
const samples = profileSamples(HIKE);
|
||||
expect(samples).toHaveLength(HIKE_LINE.length);
|
||||
expect(samples[0]).toEqual({ distanceMeters: 0, elevationMeters: 600, lat: 47.77, lng: 12.59 });
|
||||
expect(samples[samples.length - 1]?.distanceMeters).toBeCloseTo(8200, 6);
|
||||
expect(samples[4]?.distanceMeters).toBeCloseTo(3132.1, 0);
|
||||
expect(samples.map((sample) => sample.elevationMeters)).toEqual(HIKE_LINE.map((point) => point[2]));
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-002: no profile for a route joined straight or one where a single height is missing', () => {
|
||||
expect(profileSamples({ ...HIKE, routed: false })).toEqual([]);
|
||||
const gap = HIKE_LINE.map((point, index): [number, number, number | null] =>
|
||||
index === 3 ? [point[0], point[1], null] : point
|
||||
);
|
||||
expect(profileSamples({ ...HIKE, line: gap })).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-003: tints the T3 and harder stretches in their SAC colour, and the steep ones only without them', () => {
|
||||
expect(profileBands(HIKE, RAMP)).toEqual([{ fromM: 3000, toM: 4100, color: 'var(--hike-sac-t3)' }]);
|
||||
expect(profileBands(GENTLE, RAMP)).toEqual([{ fromM: 300, toM: 700, color: 'var(--warning)' }]);
|
||||
expect(sacPaint(5)).toBe('var(--hike-sac-t5)');
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-004: finds the steep stretches over 27 % and reads the grade at a spot of the profile', () => {
|
||||
expect(steepStretches(RAMP)).toEqual([{ fromM: 300, toM: 700 }]);
|
||||
const grades = profileGrades(RAMP);
|
||||
expect(gradeAt(grades, 450)).toBe(40);
|
||||
expect(gradeAt(grades, 50)).toBe(0);
|
||||
expect(gradeAt(grades, 2000)).toBeNull();
|
||||
// A ramp shorter than 100 m is no steep stretch.
|
||||
const short = RAMP.map((sample) => ({ ...sample, distanceMeters: sample.distanceMeters / 20 }));
|
||||
expect(steepStretches(short)).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-005: marks the named stops and the stage ends across the profile, six at most', () => {
|
||||
expect(profileMarks(HIKE)).toEqual([2400, 4100]);
|
||||
const many = Array.from({ length: 9 }, (_, index) => named(`Hut ${index}`, index * 100));
|
||||
expect(profileMarks({ ...HIKE, stops: many })).toHaveLength(6);
|
||||
expect(profileMarks({ ...HIKE, stops: [tourStop({ role: 'end', atM: 900, stageEnd: true })] })).toEqual([900]);
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-006: a chip for every stop but a shaping point, the middle folded past eight', () => {
|
||||
const shaping = tourStop({ role: 'via', atM: 50 });
|
||||
expect(chipStops({ ...HIKE, stops: [...HIKE.stops, shaping] }).head).toHaveLength(5);
|
||||
const vias = Array.from({ length: 10 }, (_, index) => named(`Station ${index}`, (index + 1) * 100));
|
||||
const stops = [tourStop({ role: 'start' }), ...vias, tourStop({ role: 'end', atM: 2000 })];
|
||||
const { head, folded, tail } = chipStops({ ...HIKE, stops });
|
||||
expect(head.map((stop) => stop.name ?? stop.role)).toEqual(['start', 'Station 0', 'Station 1', 'Station 2']);
|
||||
expect(folded).toHaveLength(6);
|
||||
expect(tail.map((stop) => stop.name ?? stop.role)).toEqual(['Station 9', 'end']);
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-007: each activity states its own figures, leaving out what the route does not know', () => {
|
||||
expect(factKeysOf(HIKE)).toEqual(['time', 'distance', 'ascent', 'descent']);
|
||||
expect(factKeysOf({ ...HIKE, activity: 'ski' })).toEqual(['time', 'distance', 'descent']);
|
||||
const paddle: TourRouteCard = { ...HIKE, activity: 'kayak', minutes: null, ascentM: null, descentM: null };
|
||||
expect(factKeysOf(paddle)).toEqual(['distance']);
|
||||
expect(percentText(0.4)).toBe('40 %');
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-008: reads a route and the way a change replaces as the planner reads its ways', () => {
|
||||
expect(legVariantOf(HIKE, 'b')).toEqual({
|
||||
key: 'b',
|
||||
current: false,
|
||||
coordinates: HIKE_LINE.map(([lat, lng]) => [lat, lng]),
|
||||
distanceM: 8200,
|
||||
gainM: 640,
|
||||
lossM: 640,
|
||||
minutes: 200,
|
||||
heights: 'known',
|
||||
});
|
||||
expect(legVariantOf({ ...HIKE, ascentM: null }, 'current').heights).toBe('unknown');
|
||||
expect(previousVariantOf(HIKE)).toBeNull();
|
||||
const previous = { line: PREVIOUS_LINE, distanceM: 7000, ascentM: 500, minutes: 180 };
|
||||
expect(previousVariantOf({ ...HIKE, previous })).toMatchObject({
|
||||
key: 'current',
|
||||
distanceM: 7000,
|
||||
gainM: 500,
|
||||
lossM: null,
|
||||
minutes: 180,
|
||||
heights: 'known',
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-009: "Plan this" takes a planned hike or a city walk, nothing else', () => {
|
||||
expect(canTake(HIKE)).toBe(true);
|
||||
expect(canTake({ ...HIKE, routed: false })).toBe(false);
|
||||
expect(canTake({ ...HIKE, activity: 'walk', routed: false })).toBe(true);
|
||||
expect(canTake(SAVED)).toBe(false);
|
||||
expect(canTake({ ...HIKE, activity: 'kayak', source: 'plan' })).toBe(false);
|
||||
expect(canTake({ ...HIKE, source: 'pending' })).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-010: proposals of one answer form ways of three at most; anything else stands alone', () => {
|
||||
const entry = (toolCallId: string, card: TourRouteCard) => ({ toolCallId, card });
|
||||
const routes = routeEntries([entry('a', HIKE), { toolCallId: 'list', card: TOUR_LIST }, entry('b', GENTLE)]);
|
||||
expect(routes.map((route) => route.toolCallId)).toEqual(['a', 'b']);
|
||||
expect(wayGroups(routes).map((group) => group.length)).toEqual([2]);
|
||||
const four = ['a', 'b', 'c', 'd'].map((id) => entry(id, HIKE));
|
||||
expect(wayGroups(four).map((group) => group.length)).toEqual([3, 1]);
|
||||
expect(wayGroups([entry('s', SAVED), entry('h', HIKE)]).map((group) => group.length)).toEqual([1, 1]);
|
||||
expect(routePoints(HIKE)).toHaveLength(HIKE_LINE.length);
|
||||
expect(routePoints({ ...HIKE, line: [] })).toEqual(HIKE.stops.map((stop) => [stop.lat, stop.lng]));
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-011: a stop without a name says what it is or where it lies; a walk without a name is its kind', () => {
|
||||
expect(stopName(named('Strausshütte', 10), t)).toBe('Strausshütte');
|
||||
expect(stopName(tourStop({ kind: 'shelter' }), t)).toBe('assistant.card.tour.stop.shelter');
|
||||
expect(stopName(tourStop({ role: 'start' }), t)).toBe('assistant.card.tour.stop.start');
|
||||
expect(stopName(tourStop({ kind: 'station' }), t)).toBe('assistant.card.tour.stop.other');
|
||||
expect(stopName(tourStop({ kind: 'cafe' }), t)).toBe('assistant.card.tour.stop.other');
|
||||
expect(routeName({ name: null, activity: 'walk' }, t)).toBe('tourTypes.walk');
|
||||
expect(routeName(HIKE, t)).toBe('Hochfelln from Bergen');
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-012: the sketch tints the hard stretch, dashes a straight route and draws one disc for a loop', () => {
|
||||
const { lines, marks } = sketchOf(HIKE, 'var(--hike-variant-a)');
|
||||
expect(lines.map((line) => line.id)).toEqual(['route', 'sac-1']);
|
||||
expect(lines[0]).toMatchObject({
|
||||
color: 'var(--hike-variant-a)',
|
||||
casing: 'var(--bike-highlight-casing)',
|
||||
dashed: false,
|
||||
});
|
||||
expect(lines[1]?.points).toEqual([
|
||||
[47.785, 12.615],
|
||||
[47.79, 12.62],
|
||||
[47.795, 12.625],
|
||||
[47.79, 12.63],
|
||||
]);
|
||||
expect(marks.map((mark) => mark.id)).toEqual(['start']);
|
||||
const oneWay = { ...HIKE, routed: false, stops: [HIKE.stops[0] as TourRouteStop, named('Hochfelln', 4100)] };
|
||||
const end = { ...(oneWay.stops[1] as TourRouteStop), role: 'end' as const, lat: 47.795, lng: 12.625 };
|
||||
const straight = sketchOf({ ...oneWay, stops: [oneWay.stops[0] as TourRouteStop, end] }, 'red');
|
||||
expect(straight.lines[0]?.dashed).toBe(true);
|
||||
expect(straight.marks.map((mark) => mark.shape)).toEqual(['service', 'stop']);
|
||||
const previous = { line: PREVIOUS_LINE, distanceM: 1, ascentM: null, minutes: null };
|
||||
const changed = sketchOf({ ...HIKE, previous }, 'red');
|
||||
expect(changed.lines[0]).toMatchObject({ id: 'previous', dim: true });
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-045: reads the SAC step under a spot, the harder where two grades meet, none where nothing is graded', () => {
|
||||
expect(sacAt(HIKE, 1200)).toBe(2);
|
||||
expect(sacAt(HIKE, 3000)).toBe(3);
|
||||
expect(sacAt(HIKE, 4100)).toBe(3);
|
||||
expect(sacAt(HIKE, 6000)).toBeNull();
|
||||
expect(sacAt({ stretches: [{ facet: 'mark', fromM: 0, toM: 100, kind: 'ferry' }] }, 50)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-046: names the time of a route by how it was reckoned, and a paddle tour by its own without a basis', () => {
|
||||
expect(timeLabelKey(HIKE)).toBe('tours.planner.walkingEstimatedInfo');
|
||||
expect(timeLabelKey({ ...HIKE, minutesBasis: 'router' })).toBe('tours.scope.routed');
|
||||
expect(timeLabelKey(SAVED)).toBe('tours.scope.planned');
|
||||
expect(timeLabelKey({ ...HIKE, activity: 'walk', minutesBasis: 'stations' })).toBe('tours.scope.plannedWithStays');
|
||||
expect(timeLabelKey({ ...HIKE, activity: 'ski', minutesBasis: 'piste' })).toBe('tours.ski.time');
|
||||
expect(timeLabelKey({ ...HIKE, activity: 'kayak', minutesBasis: null })).toBe('tours.paddle.timeEstimated');
|
||||
expect(timeLabelKey({ ...HIKE, minutesBasis: null })).toBe('tours.planner.walkingEstimated');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { cumulativeMetres, type TourRouteCard, type TourRouteStop } from '@trek/shared';
|
||||
|
||||
import type { DistanceIndexedProfileSample } from '../../../../utils/routeGeometry';
|
||||
import { hikePaintCss } from '../../../Tours/hike/hikePaint';
|
||||
import type { LegVariant, LegVariantKey } from '../../../Tours/planner/legVariants';
|
||||
import { gradeSegments } from '../../../Tours/terrain/tourTerrainModel';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
|
||||
/**
|
||||
* A tour card's numbers, pure: the height profile scaled to the route's own
|
||||
* length, the stretches to tint in it, the stops worth a chip, the figures an
|
||||
* activity states, a way as the planner's way cards read one, and the words
|
||||
* of every warning, problem and stop as literal tables.
|
||||
*/
|
||||
|
||||
/** The letters the ways of one answer go by, in the order the answer gave them. */
|
||||
export const WAY_KEYS = ['a', 'b', 'c'] as const;
|
||||
|
||||
/** A route card of an answer, under the tool call it came from. */
|
||||
export interface RouteEntry {
|
||||
toolCallId: string;
|
||||
card: TourRouteCard;
|
||||
}
|
||||
|
||||
/** The route cards among an answer's cards of the tours' group. */
|
||||
export function routeEntries(cards: readonly CardEntry[]): RouteEntry[] {
|
||||
return cards.flatMap(({ toolCallId, card }) => (card.kind === 'tourRoute' ? [{ toolCallId, card }] : []));
|
||||
}
|
||||
|
||||
/**
|
||||
* How an answer's route cards are laid out: proposals of one activity as one
|
||||
* card of ways to choose from, three at most each; anything else (a saved
|
||||
* tour, a paddle or ski plan) on its own.
|
||||
*/
|
||||
export function wayGroups(entries: readonly RouteEntry[]): RouteEntry[][] {
|
||||
if (entries.length < 2 || entries.some((entry) => entry.card.source !== 'proposal')) {
|
||||
return entries.map((entry) => [entry]);
|
||||
}
|
||||
const groups: RouteEntry[][] = [];
|
||||
for (let index = 0; index < entries.length; index += WAY_KEYS.length) {
|
||||
groups.push(entries.slice(index, index + WAY_KEYS.length));
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** From how steep a stretch counts as steep, in percent, and how long it must be to be marked. */
|
||||
export const STEEP_GRADE = 27;
|
||||
const STEEP_MIN_M = 100;
|
||||
/** The stretch a grade is measured over, as the 3D view measures it. */
|
||||
const GRADE_WINDOW_M = 200;
|
||||
|
||||
/** How many stop chips show before the middle ones fold into a count: the first four, the last two. */
|
||||
const CHIP_LIMIT = 8;
|
||||
|
||||
const SAC_PAINTS = ['t1', 't2', 't3', 't4', 't5', 't6'] as const;
|
||||
|
||||
/** An SAC step's colour as a CSS variable, as the hike's own card draws it. */
|
||||
export function sacPaint(step: number): string {
|
||||
return hikePaintCss(SAC_PAINTS[Math.min(6, Math.max(1, step)) - 1] ?? 't1');
|
||||
}
|
||||
|
||||
/** Whether every point of the line has its height, which a profile needs. */
|
||||
function hasHeights(card: TourRouteCard): boolean {
|
||||
return card.line.length >= 2 && card.line.every((point) => point[2] !== null);
|
||||
}
|
||||
|
||||
/** Metres along the line at each point, scaled to the route's own length so the stops' positions meet it. */
|
||||
export function alongOf(card: TourRouteCard): number[] {
|
||||
const along = cumulativeMetres(card.line);
|
||||
const total = along[along.length - 1] ?? 0;
|
||||
const scale = card.distanceM !== null && total > 0 ? card.distanceM / total : 1;
|
||||
return along.map((metres) => metres * scale);
|
||||
}
|
||||
|
||||
/** The height profile's samples; none for a route joined straight or one without heights. */
|
||||
export function profileSamples(card: TourRouteCard): DistanceIndexedProfileSample[] {
|
||||
if (!card.routed || !hasHeights(card)) return [];
|
||||
const along = alongOf(card);
|
||||
return card.line.map(([lat, lng, height], index) => ({
|
||||
distanceMeters: along[index] ?? 0,
|
||||
elevationMeters: height ?? 0,
|
||||
lat,
|
||||
lng,
|
||||
}));
|
||||
}
|
||||
|
||||
/** A stretch to tint behind the profile, in metres along the route. */
|
||||
interface ProfileBand {
|
||||
fromM: number;
|
||||
toM: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** The grade of each stretch of the profile, where it ends: what `gradeAt` reads. */
|
||||
export function profileGrades(samples: readonly DistanceIndexedProfileSample[]): { toM: number; grade: number }[] {
|
||||
return gradeSegments(samples, GRADE_WINDOW_M).map((segment, index) => ({
|
||||
toM: samples[index + 1]?.distanceMeters ?? 0,
|
||||
grade: segment.grade,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The SAC step of the way at a spot, the hardest where graded stretches meet; null where it is not graded. */
|
||||
export function sacAt(card: Pick<TourRouteCard, 'stretches'>, atM: number): number | null {
|
||||
let step: number | null = null;
|
||||
for (const stretch of card.stretches) {
|
||||
if (stretch.facet !== 'sac' || atM < stretch.fromM || atM > stretch.toM) continue;
|
||||
step = Math.max(step ?? 0, stretch.step);
|
||||
}
|
||||
return step;
|
||||
}
|
||||
|
||||
/** How steep the way is at a spot, in percent; null past its end or without a profile. */
|
||||
export function gradeAt(grades: readonly { toM: number; grade: number }[], atM: number): number | null {
|
||||
return grades.find((stretch) => stretch.toM >= atM)?.grade ?? null;
|
||||
}
|
||||
|
||||
/** The stretches at least `STEEP_GRADE` steep for at least `STEEP_MIN_M`, uphill or down, joined where they touch. */
|
||||
export function steepStretches(samples: readonly DistanceIndexedProfileSample[]): { fromM: number; toM: number }[] {
|
||||
const runs: { fromM: number; toM: number }[] = [];
|
||||
let open: { fromM: number; toM: number } | null = null;
|
||||
profileGrades(samples).forEach(({ toM, grade }, index) => {
|
||||
const fromM = samples[index]?.distanceMeters ?? 0;
|
||||
if (grade < STEEP_GRADE) {
|
||||
open = null;
|
||||
} else if (open) {
|
||||
open.toM = toM;
|
||||
} else {
|
||||
open = { fromM, toM };
|
||||
runs.push(open);
|
||||
}
|
||||
});
|
||||
return runs.filter((run) => run.toM - run.fromM >= STEEP_MIN_M);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the profile tints: the stretches graded T3 and harder in their SAC
|
||||
* colour, else the steep ones in the warning colour. Never both, so no two
|
||||
* tints lie over each other.
|
||||
*/
|
||||
export function profileBands(card: TourRouteCard, samples: readonly DistanceIndexedProfileSample[]): ProfileBand[] {
|
||||
const graded = card.stretches.flatMap((stretch): ProfileBand[] =>
|
||||
stretch.facet === 'sac' && stretch.step >= 3
|
||||
? [{ fromM: stretch.fromM, toM: stretch.toM, color: sacPaint(stretch.step) }]
|
||||
: []
|
||||
);
|
||||
if (graded.length > 0) return graded;
|
||||
return steepStretches(samples).map((run) => ({ ...run, color: 'var(--warning)' }));
|
||||
}
|
||||
|
||||
/** Where the profile marks a spot across it: the named stops on the way and the stage ends, six at most. */
|
||||
export function profileMarks(card: TourRouteCard): number[] {
|
||||
const marked = card.stops.filter((stop) => stop.stageEnd || (stop.role === 'via' && stop.name !== null));
|
||||
// One mark per spot: the profile keys its marks by where they stand.
|
||||
return [...new Set(marked.map((stop) => stop.atM))].slice(0, 6);
|
||||
}
|
||||
|
||||
/** The stop chips in three runs: the first ones, the middle folded into a count, the last ones. */
|
||||
interface StopChips {
|
||||
head: TourRouteStop[];
|
||||
folded: TourRouteStop[];
|
||||
tail: TourRouteStop[];
|
||||
}
|
||||
|
||||
/** The stops as chips: the start, the end, the named vias and what lies along the way, the middle folded past eight. */
|
||||
export function chipStops(card: TourRouteCard): StopChips {
|
||||
const chips = card.stops.filter((stop) => stop.role !== 'via' || stop.name !== null || stop.kind !== null);
|
||||
if (chips.length <= CHIP_LIMIT) return { head: chips, folded: [], tail: [] };
|
||||
return { head: chips.slice(0, 4), folded: chips.slice(4, -2), tail: chips.slice(-2) };
|
||||
}
|
||||
|
||||
/** A figure a card can state. */
|
||||
export type FactKey = 'time' | 'distance' | 'ascent' | 'descent';
|
||||
|
||||
/** A figure's value, null where the route does not know it. */
|
||||
export function factValue(card: TourRouteCard, key: FactKey): number | null {
|
||||
if (key === 'time') return card.minutes;
|
||||
if (key === 'distance') return card.distanceM;
|
||||
return key === 'ascent' ? card.ascentM : card.descentM;
|
||||
}
|
||||
|
||||
/** The figures an activity states, in its order: on skis the descent matters, on the water the distance. */
|
||||
export function factKeysOf(card: TourRouteCard): FactKey[] {
|
||||
const order: FactKey[] =
|
||||
card.activity === 'ski'
|
||||
? ['time', 'distance', 'descent']
|
||||
: card.activity === 'kayak'
|
||||
? ['distance', 'time']
|
||||
: ['time', 'distance', 'ascent', 'descent'];
|
||||
return order.filter((key) => factValue(card, key) !== null);
|
||||
}
|
||||
|
||||
/** The route as the planner's way cards read a way, so their badges, marks and differences read it too. */
|
||||
export function legVariantOf(card: TourRouteCard, key: LegVariantKey): LegVariant {
|
||||
return {
|
||||
key,
|
||||
current: key === 'current',
|
||||
coordinates: card.line.map(([lat, lng]): [number, number] => [lat, lng]),
|
||||
distanceM: card.distanceM ?? 0,
|
||||
gainM: card.ascentM,
|
||||
lossM: card.descentM,
|
||||
minutes: card.minutes,
|
||||
heights: card.ascentM !== null ? 'known' : 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
/** The way a held change of a saved tour replaces, as a way to compare the new one with; null without one. */
|
||||
export function previousVariantOf(card: TourRouteCard): LegVariant | null {
|
||||
const previous = card.previous;
|
||||
if (!previous) return null;
|
||||
return {
|
||||
key: 'current',
|
||||
current: true,
|
||||
coordinates: previous.line.map(([lat, lng]): [number, number] => [lat, lng]),
|
||||
distanceM: previous.distanceM,
|
||||
gainM: previous.ascentM,
|
||||
lossM: null,
|
||||
minutes: previous.minutes,
|
||||
heights: previous.ascentM !== null ? 'known' : 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
/** The points to frame on the map: the line, else the stops a route without one joins. */
|
||||
export function routePoints(card: TourRouteCard): [number, number][] {
|
||||
if (card.line.length >= 2) return card.line.map(([lat, lng]): [number, number] => [lat, lng]);
|
||||
return card.stops.map((stop): [number, number] => [stop.lat, stop.lng]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether "Plan this" can take the proposal: a hike whose way was planned,
|
||||
* or a city walk. A paddle or ski plan, a saved tour and a held change cannot.
|
||||
*/
|
||||
export function canTake(card: TourRouteCard): boolean {
|
||||
if (card.source !== 'proposal') return false;
|
||||
return card.activity === 'walk' || (card.activity === 'hike' && card.routed);
|
||||
}
|
||||
|
||||
/** A share as words take it, "40 %". */
|
||||
export function percentText(share: number): string {
|
||||
return `${Math.round(share * 100)} %`;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { haversineMetres, type TourRouteCard } from '@trek/shared';
|
||||
|
||||
import type { SketchLine, SketchMark } from '../parts/routeSketchModel';
|
||||
import { alongOf, sacPaint } from './tourRouteModel';
|
||||
|
||||
/**
|
||||
* A tour card's sketch, pure: the way in the colour the planner draws it in
|
||||
* (dashed while it is only its stops joined straight), the stretches graded
|
||||
* T3 and harder tinted over it, the carried, ferried and lifted stretches
|
||||
* dotted, the way a held change replaces faint beneath, the start as a disc
|
||||
* and the end as a ring, one disc when the way comes back to its start.
|
||||
*/
|
||||
|
||||
/** The white rim the planner's highlighted lines wear. */
|
||||
const CASING = 'var(--bike-highlight-casing)';
|
||||
/** How close the end may lie to the start for the way to count as a loop, in metres. */
|
||||
const LOOP_M = 60;
|
||||
|
||||
type Point = readonly [number, number];
|
||||
|
||||
/** The points of the line between two places along it, with one point either side so the piece joins the way. */
|
||||
function pieceOf(points: readonly Point[], along: readonly number[], fromM: number, toM: number): Point[] {
|
||||
const inside = along.flatMap((metres, index) => (metres >= fromM && metres <= toM ? [index] : []));
|
||||
const first = inside[0];
|
||||
const last = inside[inside.length - 1];
|
||||
if (first === undefined || last === undefined) return [];
|
||||
const piece = points.slice(Math.max(0, first - 1), Math.min(points.length, last + 2));
|
||||
return piece.length >= 2 ? piece : [];
|
||||
}
|
||||
|
||||
/** The lines and marks of a route's sketch; `paint` is the colour of the way (the chosen way's in a ways card). */
|
||||
export function sketchOf(card: TourRouteCard, paint: string): { lines: SketchLine[]; marks: SketchMark[] } {
|
||||
const lines: SketchLine[] = [];
|
||||
if (card.previous) {
|
||||
lines.push({ id: 'previous', points: card.previous.line, color: 'var(--text-faint)', casing: CASING, dim: true });
|
||||
}
|
||||
const points = card.line.map(([lat, lng]): Point => [lat, lng]);
|
||||
if (points.length >= 2) {
|
||||
lines.push({ id: 'route', points, color: paint, casing: CASING, dashed: !card.routed });
|
||||
const along = alongOf(card);
|
||||
card.stretches.forEach((stretch, index) => {
|
||||
if (stretch.facet === 'ski') return;
|
||||
if (stretch.facet === 'sac' && stretch.step < 3) return;
|
||||
const piece = pieceOf(points, along, stretch.fromM, stretch.toM);
|
||||
if (piece.length === 0) return;
|
||||
lines.push(
|
||||
stretch.facet === 'sac'
|
||||
? { id: `sac-${index}`, points: piece, color: sacPaint(stretch.step), casing: CASING }
|
||||
: { id: `mark-${index}`, points: piece, color: 'var(--text-muted)', casing: CASING, dashed: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
const start = card.stops.find((stop) => stop.role === 'start');
|
||||
const end = [...card.stops].reverse().find((stop) => stop.role === 'end');
|
||||
const marks: SketchMark[] = [];
|
||||
if (start) marks.push({ id: 'start', lat: start.lat, lng: start.lng, shape: 'service', color: 'var(--accent)' });
|
||||
const loop = start && end && haversineMetres(start.lat, start.lng, end.lat, end.lng) <= LOOP_M;
|
||||
if (end && !loop) marks.push({ id: 'end', lat: end.lat, lng: end.lng, shape: 'stop' });
|
||||
return { lines, marks };
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { TourRouteCard, TourRouteStop, TourTypeKey } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The words of a tour card as literal tables, so each key is named where it
|
||||
* is read: what kind of tour a route is, what its SAC grades mean, what a
|
||||
* stop without a name is, what each warning says and how loud, why a route
|
||||
* is drawn straight, why a city walk's station is flagged.
|
||||
*/
|
||||
|
||||
/** What kind of tour each activity is, as the Tours tab names it. */
|
||||
export const TOUR_TYPE_WORDS: Readonly<Record<TourTypeKey, { labelKey: string }>> = {
|
||||
hike: { labelKey: 'tourTypes.hike' },
|
||||
bike: { labelKey: 'tourTypes.bike' },
|
||||
run: { labelKey: 'tourTypes.run' },
|
||||
ski: { labelKey: 'tourTypes.ski' },
|
||||
kayak: { labelKey: 'tourTypes.kayak' },
|
||||
walk: { labelKey: 'tourTypes.walk' },
|
||||
food: { labelKey: 'tourTypes.food' },
|
||||
horse: { labelKey: 'tourTypes.horse' },
|
||||
};
|
||||
|
||||
/** What each SAC step means, T1 first, as the hike's own card says it. */
|
||||
export const SAC_WORDS = [
|
||||
{ labelKey: 'tours.hike.sac.t1' },
|
||||
{ labelKey: 'tours.hike.sac.t2' },
|
||||
{ labelKey: 'tours.hike.sac.t3' },
|
||||
{ labelKey: 'tours.hike.sac.t4' },
|
||||
{ labelKey: 'tours.hike.sac.t5' },
|
||||
{ labelKey: 'tours.hike.sac.t6' },
|
||||
] as const;
|
||||
|
||||
/** A route's name: its own, else the kind of tour it is (a city walk is proposed without one). */
|
||||
export function routeName(card: Pick<TourRouteCard, 'name' | 'activity'>, t: (key: string) => string): string {
|
||||
return card.name ?? t(TOUR_TYPE_WORDS[card.activity].labelKey);
|
||||
}
|
||||
|
||||
type StopWord = NonNullable<TourRouteStop['kind']> | 'start' | 'end';
|
||||
|
||||
/** The words for a stop without a name of its own, by its kind or its role. */
|
||||
const STOP_WORDS: Readonly<Partial<Record<StopWord, { labelKey: string }>>> = {
|
||||
start: { labelKey: 'assistant.card.tour.stop.start' },
|
||||
end: { labelKey: 'assistant.card.tour.stop.end' },
|
||||
peak: { labelKey: 'assistant.card.tour.stop.peak' },
|
||||
highest: { labelKey: 'assistant.card.tour.stop.highest' },
|
||||
shelter: { labelKey: 'assistant.card.tour.stop.shelter' },
|
||||
drinking_water: { labelKey: 'assistant.card.tour.stop.drinking_water' },
|
||||
viewpoint: { labelKey: 'assistant.card.tour.stop.viewpoint' },
|
||||
};
|
||||
|
||||
/** A stop's name: its own, else what it is, else where it is on the way. */
|
||||
export function stopName(stop: TourRouteStop, t: (key: string) => string): string {
|
||||
if (stop.name) return stop.name;
|
||||
const role = stop.role === 'start' || stop.role === 'end' ? STOP_WORDS[stop.role] : undefined;
|
||||
const word = (stop.kind ? STOP_WORDS[stop.kind] : undefined) ?? role;
|
||||
return t(word?.labelKey ?? 'assistant.card.tour.stop.other');
|
||||
}
|
||||
|
||||
type WarningKind = TourRouteCard['warnings'][number]['kind'];
|
||||
|
||||
/** What each warning says and how loud: the dark is danger, the rest a warning. */
|
||||
export const WARNING_LOOK = {
|
||||
thunder: { labelKey: 'assistant.card.tour.warn.thunder', tone: 'warning' },
|
||||
rain: { labelKey: 'assistant.card.tour.warn.rain', tone: 'warning' },
|
||||
dark_end: { labelKey: 'assistant.card.tour.warn.dark_end', tone: 'danger' },
|
||||
dark_start: { labelKey: 'assistant.card.tour.warn.dark_start', tone: 'danger' },
|
||||
dusk: { labelKey: 'assistant.card.tour.warn.dusk', tone: 'warning' },
|
||||
legs_failed: { labelKey: 'assistant.card.tour.warn.legs_failed', tone: 'warning' },
|
||||
} as const satisfies Record<WarningKind, { labelKey: string; tone: 'warning' | 'danger' }>;
|
||||
|
||||
/** Why a route is drawn straight, in words. */
|
||||
export const PROBLEM_LOOK = {
|
||||
no_valhalla: { labelKey: 'assistant.card.tour.problem.no_valhalla' },
|
||||
router_unavailable: { labelKey: 'assistant.card.tour.problem.router_unavailable' },
|
||||
no_way: { labelKey: 'assistant.card.tour.problem.no_way' },
|
||||
point_off_the_way: { labelKey: 'assistant.card.tour.problem.point_off_the_way' },
|
||||
too_long: { labelKey: 'assistant.card.tour.problem.too_long' },
|
||||
ferry_only: { labelKey: 'assistant.card.tour.problem.ferry_only' },
|
||||
not_planned: { labelKey: 'assistant.card.tour.notPlanned' },
|
||||
} as const satisfies Record<NonNullable<TourRouteCard['problem']>['why'], { labelKey: string }>;
|
||||
|
||||
/** Why a city walk's station is flagged, in words. */
|
||||
export const FLAG_LOOK = {
|
||||
closed: { labelKey: 'assistant.card.tour.flag.closed' },
|
||||
late: { labelKey: 'assistant.card.tour.flag.late' },
|
||||
after_frame: { labelKey: 'assistant.card.tour.flag.after_frame' },
|
||||
} as const satisfies Record<NonNullable<TourRouteStop['flag']>, { labelKey: string }>;
|
||||
|
||||
/** What a route's time is, by how it was reckoned, as the Tours tab names each. */
|
||||
const TIME_WORDS = {
|
||||
din33466: { labelKey: 'tours.planner.walkingEstimatedInfo' },
|
||||
router: { labelKey: 'tours.scope.routed' },
|
||||
planned: { labelKey: 'tours.scope.planned' },
|
||||
stations: { labelKey: 'tours.scope.plannedWithStays' },
|
||||
piste: { labelKey: 'tours.ski.time' },
|
||||
} as const satisfies Record<NonNullable<TourRouteCard['minutesBasis']>, { labelKey: string }>;
|
||||
|
||||
/** The name of a route's time: by how it was reckoned, else a paddle's own, else a walking time. */
|
||||
export function timeLabelKey(card: Pick<TourRouteCard, 'minutesBasis' | 'activity'>): string {
|
||||
if (card.minutesBasis) return TIME_WORDS[card.minutesBasis].labelKey;
|
||||
return card.activity === 'kayak' ? 'tours.paddle.timeEstimated' : 'tours.planner.walkingEstimated';
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* FE-AI-TOUR-047
|
||||
*
|
||||
* Whether a proposal was already taken, read from the thread: a later answer
|
||||
* holding "Plan this" for the same way, waiting or applied, takes it; one
|
||||
* discarded, cancelled or expired, another way, or a thread that no longer
|
||||
* holds the proposal does not.
|
||||
*/
|
||||
import type { AssistantMessage, AssistantMessagePart, AssistantToolApproval, TourRouteCard } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { GENTLE, HIKE } from '../../../../../tests/helpers/assistantTour';
|
||||
import { takeInThread } from './tourTakeModel';
|
||||
|
||||
const message = (id: string, parts: AssistantMessagePart[]): AssistantMessage => ({
|
||||
id,
|
||||
role: 'assistant',
|
||||
status: 'complete',
|
||||
createdAt: '2026-10-22T08:00:00.000Z',
|
||||
parts,
|
||||
});
|
||||
|
||||
const proposal = message('m1', [{ type: 'data-trek-card', data: { toolCallId: 'call_1', card: HIKE } }]);
|
||||
|
||||
/** The answer holding "Plan this" for `card`, decided as `approval` says. */
|
||||
function take(card: TourRouteCard, approval: Omit<AssistantToolApproval, 'id'> = {}, toolName = 'take_proposal') {
|
||||
return message('m2', [
|
||||
{ type: 'tool-call', toolCallId: 't1', toolName, args: {}, approval: { id: 't1', ...approval } },
|
||||
{
|
||||
type: 'data-trek-change',
|
||||
data: {
|
||||
toolCallId: 't1',
|
||||
verbKey: 'addTour',
|
||||
subject: card.name ?? '',
|
||||
rows: [],
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
undoable: true,
|
||||
card: { ...card, source: 'pending' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
describe('takeInThread', () => {
|
||||
it('FE-AI-TOUR-047: a later take of the same way, waiting or applied, takes the proposal; nothing else does', () => {
|
||||
expect(takeInThread([proposal], 'call_1', HIKE)).toBeNull();
|
||||
expect(takeInThread([proposal, take(HIKE)], 'call_1', HIKE)).toBe('waiting');
|
||||
expect(takeInThread([proposal, take(HIKE, { approved: true })], 'call_1', HIKE)).toBe('applied');
|
||||
expect(takeInThread([proposal, take(HIKE, { approved: false })], 'call_1', HIKE)).toBeNull();
|
||||
expect(takeInThread([proposal, take(HIKE, { resolution: 'expired' })], 'call_1', HIKE)).toBeNull();
|
||||
expect(takeInThread([proposal, take(HIKE, {}, 'create_hike')], 'call_1', HIKE)).toBeNull();
|
||||
|
||||
const other = { ...GENTLE, line: [[47.9, 12.8, 600], ...GENTLE.line.slice(1)] } satisfies TourRouteCard;
|
||||
expect(takeInThread([proposal, take(other)], 'call_1', HIKE)).toBeNull();
|
||||
expect(takeInThread([take(HIKE), proposal], 'call_1', HIKE)).toBeNull();
|
||||
expect(takeInThread([take(HIKE)], 'call_1', HIKE)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { AssistantMessage, TourRouteCard } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Whether a proposal was already taken ("Plan this"), read from the thread
|
||||
* rather than from the card, so a card drawn again (the chat reopened, the
|
||||
* thread loaded anew, a long answer scrolled away and back) does not offer
|
||||
* the same tour twice. The change the server holds names the way it creates,
|
||||
* not the card it came from, so it is matched by its way.
|
||||
*/
|
||||
|
||||
/** The tool a change held by "Plan this" runs under, as the server names it. */
|
||||
const TAKE_TOOL = 'take_proposal';
|
||||
|
||||
/** Whether two routes are one way: one activity, as many points, the same ends. */
|
||||
function sameWay(a: TourRouteCard, b: TourRouteCard): boolean {
|
||||
if (a.activity !== b.activity || a.line.length !== b.line.length || a.stops.length !== b.stops.length) return false;
|
||||
const ends = (card: TourRouteCard) => [card.line[0], card.line.at(-1)].map((point) => point?.slice(0, 2).join(','));
|
||||
const [aStart, aEnd] = ends(a);
|
||||
const [bStart, bEnd] = ends(b);
|
||||
return aStart === bStart && aEnd === bEnd;
|
||||
}
|
||||
|
||||
/** The index of the answer that drew the proposal's card; -1 when the thread no longer holds it. */
|
||||
function proposalIndex(messages: readonly AssistantMessage[], toolCallId: string): number {
|
||||
return messages.findIndex((message) =>
|
||||
message.parts.some((part) => part.type === 'data-trek-card' && part.data.toolCallId === toolCallId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Where a take of the proposal stands in the thread: waiting for a decision, applied, or none. */
|
||||
type ThreadTake = 'waiting' | 'applied' | null;
|
||||
|
||||
/**
|
||||
* The take a later answer holds for the same way, still waiting or applied.
|
||||
* One discarded, cancelled or expired leaves the proposal free to be taken
|
||||
* again.
|
||||
*/
|
||||
export function takeInThread(
|
||||
messages: readonly AssistantMessage[],
|
||||
toolCallId: string,
|
||||
card: TourRouteCard
|
||||
): ThreadTake {
|
||||
const from = proposalIndex(messages, toolCallId);
|
||||
if (from < 0) return null;
|
||||
for (const message of messages.slice(from + 1)) {
|
||||
const open = new Map<string, ThreadTake>();
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== 'tool-call' || part.toolName !== TAKE_TOOL || part.approval?.resolution) continue;
|
||||
if (part.approval?.approved !== false) open.set(part.toolCallId, part.approval?.approved ? 'applied' : 'waiting');
|
||||
}
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== 'data-trek-change' || part.data.card?.kind !== 'tourRoute') continue;
|
||||
const take = open.get(part.data.toolCallId);
|
||||
if (take && sameWay(part.data.card, card)) return take;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
import { type CardView, PENDING_VIEW } from '../cardTypes';
|
||||
import type { CardView } from '../cardTypes';
|
||||
import TourListCard from './TourListCard';
|
||||
import TourRouteBlock from './TourRouteBlock';
|
||||
import TourRouteInRow, { TourRouteLead } from './TourRouteInRow';
|
||||
|
||||
/**
|
||||
* How the kinds of the tours draw in the chat; each one draws nothing until its
|
||||
* cards are built. This file only maps each kind to its view: the components
|
||||
* live in sibling files, since a file that exports anything besides components
|
||||
* breaks fast refresh, and lint:warnings holds
|
||||
* react-refresh/only-export-components at zero for a new file.
|
||||
* How the kinds of the tours draw in the chat: routes as the Tours tab's own
|
||||
* route card (two or three ways of one route as one card to choose from),
|
||||
* under a held change the route it adds or changes with the kind of tour as
|
||||
* the row's tile, and saved tours as the sidebar's rows. This file only maps
|
||||
* each kind to its view: the components live in sibling files, since a file
|
||||
* that exports anything besides components breaks fast refresh, and
|
||||
* lint:warnings holds react-refresh/only-export-components at zero for a new
|
||||
* file.
|
||||
*/
|
||||
export const TOUR_VIEWS = {
|
||||
tourRoute: PENDING_VIEW,
|
||||
tourList: PENDING_VIEW,
|
||||
tourRoute: { Block: TourRouteBlock, Lead: TourRouteLead, Body: TourRouteInRow },
|
||||
tourList: { Block: TourListCard },
|
||||
} satisfies { [K in 'tourRoute' | 'tourList']: CardView<K> };
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { TourRouteCard } from '@trek/shared';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { useNetworkMode } from '../../../../hooks/useNetworkMode';
|
||||
import { chatOf, type HoldOutcome, useAssistantStore } from '../../../../store/assistantStore';
|
||||
import { useCanDo } from '../../../../store/permissionsStore';
|
||||
import { useTripStore } from '../../../../store/tripStore';
|
||||
import type { RouteProfileFocus } from '../../../../utils/routeGeometry';
|
||||
import { hasUndecided } from '../../assistantParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import { useChangeHover } from '../../useChangeHover';
|
||||
import { canTake, routePoints } from './tourRouteModel';
|
||||
import { takeInThread } from './tourTakeModel';
|
||||
|
||||
/**
|
||||
* What a route card and the phone's route peek share: the light between the
|
||||
* card and its line on the map (resting on one lights the other), the spot
|
||||
* of the route in focus (the profile, a stop chip and the map's pointer all
|
||||
* move one), and "On map", which frames the route (a ways card frames all
|
||||
* its ways, `frame`); on the phone the chat steps aside first and the route's
|
||||
* peek opens over the map.
|
||||
*/
|
||||
export function useTourRouteCard(toolCallId: string, card: TourRouteCard, frame?: readonly TourRouteCard[]) {
|
||||
const { tripId, variant, onRevealMap } = useAssistantView();
|
||||
const hover = useChangeHover(tripId, toolCallId);
|
||||
const focus = useAssistantStore((s) =>
|
||||
s.routeFocus?.tripId === tripId && s.routeFocus.ref === toolCallId ? s.routeFocus.focus : null
|
||||
);
|
||||
const setRouteFocus = useAssistantStore((s) => s.setRouteFocus);
|
||||
const requestCamera = useAssistantStore((s) => s.requestCamera);
|
||||
const setRoutePeek = useAssistantStore((s) => s.setRoutePeek);
|
||||
|
||||
/** Puts the focus out, if it is still this card's. */
|
||||
const release = useCallback(() => {
|
||||
const current = useAssistantStore.getState().routeFocus;
|
||||
if (current?.tripId === tripId && current.ref === toolCallId) setRouteFocus(null);
|
||||
}, [tripId, toolCallId, setRouteFocus]);
|
||||
|
||||
const setFocus = useCallback(
|
||||
(next: RouteProfileFocus | null) => {
|
||||
if (next) setRouteFocus({ tripId, ref: toolCallId, focus: next, source: 'card' });
|
||||
else release();
|
||||
},
|
||||
[tripId, toolCallId, setRouteFocus, release]
|
||||
);
|
||||
|
||||
// A card that goes takes its spot on the map with it.
|
||||
useEffect(() => release, [release]);
|
||||
|
||||
const showOnMap = useCallback(() => {
|
||||
const points = (frame ?? [card]).flatMap(routePoints);
|
||||
if (points.length === 0) return;
|
||||
onRevealMap?.();
|
||||
requestCamera(tripId, points, 'fit', 'user');
|
||||
if (variant === 'fullscreen') setRoutePeek({ tripId, toolCallId });
|
||||
}, [card, frame, tripId, toolCallId, variant, onRevealMap, requestCamera, setRoutePeek]);
|
||||
|
||||
return { lit: hover.lit, litFromMap: hover.litFromMap, handlers: hover.handlers, focus, setFocus, showOnMap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lights a line on the map while what draws it is pointed at (a way of a
|
||||
* ways card, a saved tour's row), by the key the map draws it under; only
|
||||
* what lit it puts it out, since the pointer may already rest on another.
|
||||
*/
|
||||
export function useLineHover() {
|
||||
const { tripId } = useAssistantView();
|
||||
const setMapHover = useAssistantStore((s) => s.setMapHover);
|
||||
return useCallback(
|
||||
(ref: string, on: boolean) => {
|
||||
if (on) {
|
||||
setMapHover(tripId, ref, 'card');
|
||||
return;
|
||||
}
|
||||
const link = useAssistantStore.getState().mapLink;
|
||||
if (link?.tripId === tripId && link.hoveredId === ref) setMapHover(tripId, null, 'card');
|
||||
},
|
||||
[tripId, setMapHover]
|
||||
);
|
||||
}
|
||||
|
||||
/** Where "Plan this" stands: not tapped, asking, held below for a decision, or refused. */
|
||||
type TakeState = 'idle' | 'busy' | 'taken' | 'failed';
|
||||
|
||||
/** Where a take leaves the card: held is taken, a refusal says why, nothing held offers the button again. */
|
||||
function stateAfter(outcome: HoldOutcome, error: string | null): TakeState {
|
||||
if (outcome === 'held') return 'taken';
|
||||
// A proposal the server can no longer find or create comes back as `generic`: the card asks for it again.
|
||||
// Any other refusal (busy, a limit) is the chat's to say, and the button is offered again.
|
||||
return outcome === 'refused' && error === 'generic' ? 'failed' : 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* "Plan this" on a proposed hike or city walk: the server holds the tour it
|
||||
* creates as a change below, to decide like any other, without a model turn.
|
||||
* It shows for someone who may add places to the trip, and waits (saying
|
||||
* why) while offline, while the assistant answers or while another change is
|
||||
* undecided, since one decision answers every open card. A proposal the
|
||||
* server can no longer find or create is refused; the card then asks for it
|
||||
* to be asked again. Taken is read from the thread too, so a card drawn again
|
||||
* after its tour was held or applied does not offer it a second time.
|
||||
*/
|
||||
export function useTakeProposal(toolCallId: string, card: TourRouteCard) {
|
||||
const { tripId } = useAssistantView();
|
||||
const { offline } = useNetworkMode();
|
||||
const can = useCanDo();
|
||||
const trip = useTripStore((s) => (s.trip?.id === tripId ? s.trip : null));
|
||||
const { isRunning, threadId, waiting, held } = useAssistantStore(
|
||||
useShallow((s) => {
|
||||
const chat = chatOf(s, tripId);
|
||||
return {
|
||||
isRunning: chat.isRunning,
|
||||
threadId: chat.threadId,
|
||||
waiting: hasUndecided(chat.messages),
|
||||
held: takeInThread(chat.messages, toolCallId, card),
|
||||
};
|
||||
})
|
||||
);
|
||||
const takeProposal = useAssistantStore((s) => s.takeProposal);
|
||||
const [tapped, setState] = useState<TakeState>('idle');
|
||||
const state: TakeState = held !== null && tapped !== 'busy' ? 'taken' : tapped;
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const visible = canTake(card) && !!trip && can('place_edit', trip);
|
||||
let reasonKey: string | null = null;
|
||||
if (offline) reasonKey = 'assistant.error.offline';
|
||||
else if (waiting && state !== 'taken') reasonKey = 'assistant.composer.decideFirst';
|
||||
const disabled = reasonKey !== null || isRunning || threadId === null || state === 'busy';
|
||||
|
||||
const take = useCallback(async () => {
|
||||
setState('busy');
|
||||
const outcome = await takeProposal(tripId, toolCallId);
|
||||
if (!mounted.current) return;
|
||||
// A refusal is kept as the chat's error, read only when the take was refused.
|
||||
setState(stateAfter(outcome, chatOf(useAssistantStore.getState(), tripId).error));
|
||||
}, [takeProposal, tripId, toolCallId]);
|
||||
|
||||
return { visible, disabled, reasonKey, state, applied: held === 'applied', take };
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* FE-AI-PEEK-001 to FE-AI-PEEK-007
|
||||
*
|
||||
* The phone's peek at a route over the map: found where the chat shows the
|
||||
* route (an answer's card, one of its ways, a held change), its name,
|
||||
* figures and profile with the readout under it, the ways as marks a tap
|
||||
* chooses between, the map's spot read in the readout, and a close button;
|
||||
* loaded on first use. Closing it, or moving it to another way, takes the
|
||||
* light a tap on the map put on the line along.
|
||||
*/
|
||||
import type { AssistantMessage, AssistantMessagePart, TourRouteCard } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { GENTLE, HIKE } from '../../../../tests/helpers/assistantTour';
|
||||
import { buildTrip, buildUser } from '../../../../tests/helpers/factories';
|
||||
import { act, fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import { chatOf, useAssistantStore } from '../../../store/assistantStore';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import { useTripStore } from '../../../store/tripStore';
|
||||
import RoutePeek from './RoutePeek';
|
||||
import RoutePeekLazy from './RoutePeekLazy';
|
||||
import { peekRoute } from './routePeekModel';
|
||||
|
||||
const TRIP = 4;
|
||||
const ASSISTANT = useAssistantStore.getState();
|
||||
const TRIPS = useTripStore.getState();
|
||||
const AUTH = useAuthStore.getState();
|
||||
|
||||
function read(toolCallId: string, card: TourRouteCard): AssistantMessagePart[] {
|
||||
return [
|
||||
{ type: 'tool-call', toolCallId, toolName: 'propose_hike', args: {}, result: {} },
|
||||
{ type: 'data-trek-card', data: { toolCallId, card } },
|
||||
];
|
||||
}
|
||||
|
||||
function held(toolCallId: string, card: TourRouteCard): AssistantMessagePart[] {
|
||||
return [
|
||||
{ type: 'tool-call', toolCallId, toolName: 'create_hike', args: {}, approval: { id: toolCallId } },
|
||||
{
|
||||
type: 'data-trek-change',
|
||||
data: {
|
||||
toolCallId,
|
||||
verbKey: 'addTour',
|
||||
subject: 'Hochfelln',
|
||||
rows: [],
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
undoable: true,
|
||||
card: { ...card, source: 'pending' },
|
||||
},
|
||||
} as AssistantMessagePart,
|
||||
];
|
||||
}
|
||||
|
||||
function message(parts: AssistantMessagePart[]): AssistantMessage {
|
||||
return { id: 'msg-1', role: 'assistant', parts, status: 'complete', createdAt: '2026-10-21T08:00:00.000Z' };
|
||||
}
|
||||
|
||||
const WAYS = message([...read('call_1', { ...HIKE, label: 'Short and steep' }), ...read('call_2', GENTLE)]);
|
||||
|
||||
function setChat(messages: AssistantMessage[], peek: string | null) {
|
||||
useAssistantStore.setState((s) => ({
|
||||
byTrip: { [TRIP]: { ...chatOf(s, TRIP), threadId: 7, messages } },
|
||||
routePeek: peek === null ? null : { tripId: TRIP, toolCallId: peek },
|
||||
}));
|
||||
}
|
||||
|
||||
function renderPeek() {
|
||||
return render(<RoutePeek tripId={TRIP} onOpen={vi.fn()} onFocusDay={vi.fn()} onRevealMap={vi.fn()} />);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ user: buildUser({ id: 1 }) });
|
||||
useTripStore.setState({ trip: buildTrip({ id: TRIP, user_id: 1 }) });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(ASSISTANT, true);
|
||||
useTripStore.setState(TRIPS, true);
|
||||
useAuthStore.setState(AUTH, true);
|
||||
});
|
||||
|
||||
describe('peekRoute', () => {
|
||||
it('FE-AI-PEEK-001: finds a route among the ways of an answer, on its own, or as a held change, and nothing else', () => {
|
||||
const ways = peekRoute([WAYS], 'call_2');
|
||||
expect(ways?.entry.toolCallId).toBe('call_2');
|
||||
expect(ways?.ways.map((way) => way.toolCallId)).toEqual(['call_1', 'call_2']);
|
||||
expect(ways?.blockKey).toBe('tour:call_1');
|
||||
|
||||
const alone = peekRoute([message(read('call_9', HIKE))], 'call_9');
|
||||
expect(alone?.ways).toEqual([]);
|
||||
|
||||
const change = peekRoute([message(held('call_5', HIKE))], 'call_5');
|
||||
expect(change?.entry.card.source).toBe('pending');
|
||||
expect(change?.ways).toEqual([]);
|
||||
|
||||
expect(peekRoute([WAYS], 'call_7')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RoutePeek', () => {
|
||||
it('FE-AI-PEEK-002: shows nothing while closed, for another trip, or once the route is gone', () => {
|
||||
setChat([WAYS], null);
|
||||
const { container } = renderPeek();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
act(() => useAssistantStore.setState({ routePeek: { tripId: 9, toolCallId: 'call_1' } }));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
act(() => useAssistantStore.setState({ routePeek: { tripId: TRIP, toolCallId: 'call_7' } }));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-AI-PEEK-003: names the route with its grade and figures over its profile, and closes', () => {
|
||||
setChat([message(read('call_9', HIKE))], 'call_9');
|
||||
renderPeek();
|
||||
const peek = screen.getByTestId('route-peek');
|
||||
expect(peek).toHaveAccessibleName('Hochfelln from Bergen');
|
||||
expect(screen.getByTestId('tour-grade')).toBeInTheDocument();
|
||||
expect(peek.querySelector('[data-figure="distance"]')).not.toBeNull();
|
||||
expect(screen.getByRole('group', { name: 'Elevation profile of Hochfelln from Bergen' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('group', { name: /way/ })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close the route' }));
|
||||
expect(useAssistantStore.getState().routePeek).toBeNull();
|
||||
expect(screen.queryByTestId('route-peek')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-PEEK-004: the ways are marks with 44 px targets, and a tap chooses one for the answer and shows it', () => {
|
||||
setChat([WAYS], 'call_1');
|
||||
renderPeek();
|
||||
const group = screen.getByRole('group', { name: '2 ways' });
|
||||
const marks = group.querySelectorAll('button');
|
||||
expect(marks).toHaveLength(2);
|
||||
expect(marks[0]).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(marks[0]).toHaveClass('h-11', 'w-11');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Long and gentle' }));
|
||||
expect(chatOf(useAssistantStore.getState(), TRIP).picks).toEqual({ 'tour:call_1': 'call_2' });
|
||||
expect(useAssistantStore.getState().routePeek).toEqual({ tripId: TRIP, toolCallId: 'call_2' });
|
||||
expect(screen.getByRole('button', { name: 'Long and gentle' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('FE-AI-PEEK-005: a spot picked on the map is read in the row under the profile, and the hint shows otherwise', () => {
|
||||
setChat([message(read('call_9', HIKE))], 'call_9');
|
||||
renderPeek();
|
||||
expect(screen.getByText('Slide along the profile to read it')).toBeInTheDocument();
|
||||
|
||||
const focus = { distanceMeters: 4100, elevationMeters: 1400, lat: 47.795, lng: 12.625, sampleIndex: 5 };
|
||||
act(() => useAssistantStore.getState().setRouteFocus({ tripId: TRIP, ref: 'call_9', focus, source: 'map' }));
|
||||
expect(screen.getByTestId('tour-profile-readout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-PEEK-006: the peek loads with its route the first time a route is shown over the map', async () => {
|
||||
setChat([message(read('call_9', HIKE))], 'call_9');
|
||||
render(<RoutePeekLazy tripId={TRIP} onOpen={vi.fn()} onFocusDay={vi.fn()} onRevealMap={vi.fn()} />);
|
||||
expect(await screen.findByTestId('route-peek')).toHaveAccessibleName('Hochfelln from Bergen');
|
||||
});
|
||||
|
||||
it('FE-AI-PEEK-007: closing the peek puts out the light a map tap put on its line, not a light from the chat', () => {
|
||||
setChat([WAYS], 'call_1');
|
||||
act(() => useAssistantStore.getState().setMapHover(TRIP, 'call_1', 'map'));
|
||||
renderPeek();
|
||||
// Another way in the peek moves the light with it.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Long and gentle' }));
|
||||
expect(useAssistantStore.getState().mapLink).toEqual({ tripId: TRIP, hoveredId: 'call_2', source: 'map' });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close the route' }));
|
||||
expect(useAssistantStore.getState().mapLink).toEqual({ tripId: TRIP, hoveredId: null, source: 'map' });
|
||||
|
||||
act(() => {
|
||||
useAssistantStore.getState().setMapHover(TRIP, 'call_1', 'card');
|
||||
useAssistantStore.getState().setRoutePeek({ tripId: TRIP, toolCallId: 'call_1' });
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close the route' }));
|
||||
expect(useAssistantStore.getState().mapLink).toEqual({ tripId: TRIP, hoveredId: 'call_1', source: 'card' });
|
||||
expect(useAssistantStore.getState().routePeek).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../../store/assistantStore';
|
||||
import { variantPaintCss } from '../../Tours/planner/legVariantPaint';
|
||||
import { VariantMark } from '../../Tours/planner/mapCardParts';
|
||||
import { type AssistantView, AssistantViewContext, useAssistantView } from '../assistantViewContext';
|
||||
import { TourGrade, TourRouteFigures } from '../cards/tour/TourRouteHead';
|
||||
import { profileSamples, WAY_KEYS } from '../cards/tour/tourRouteModel';
|
||||
import TourRouteProfile from '../cards/tour/TourRouteProfile';
|
||||
import { routeName } from '../cards/tour/tourRouteWords';
|
||||
import { useTourRouteCard } from '../cards/tour/useTourRouteCard';
|
||||
import { moveRoutePeek } from './routePeekControl';
|
||||
import { type PeekRoute, peekRoute } from './routePeekModel';
|
||||
|
||||
/** The peek's body, for the route found: its ways, its name and figures, its profile and the way to close it. */
|
||||
function PeekBody({ route }: { route: PeekRoute }) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId } = useAssistantView();
|
||||
const { entry, ways, blockKey } = route;
|
||||
const { card, toolCallId } = entry;
|
||||
const pick = useAssistantStore((s) => s.pick);
|
||||
const shown = useTourRouteCard(toolCallId, card, ways.length > 0 ? ways.map((way) => way.card) : undefined);
|
||||
const samples = useMemo(() => profileSamples(card), [card]);
|
||||
const index = ways.findIndex((way) => way.toolCallId === toolCallId);
|
||||
const color = variantPaintCss(index >= 0 ? (WAY_KEYS[index] ?? 'a') : 'current');
|
||||
const name = routeName(card, t);
|
||||
const waysLabel = t('assistant.card.tour.ways', { count: ways.length });
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={name}
|
||||
data-testid="route-peek"
|
||||
className="trek-assistant-enter absolute inset-x-3 bottom-[calc(env(safe-area-inset-bottom,0px)+142px)] z-[42] rounded-2xl border border-edge-faint bg-surface-elevated px-3 pb-3 pt-2 shadow-elevated backdrop-blur-2xl"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1 pt-1.5">
|
||||
<h3 className="m-0 line-clamp-2 text-body font-semibold leading-tight text-content">
|
||||
<bdi>{name}</bdi>
|
||||
</h3>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<TourGrade card={card} />
|
||||
<TourRouteFigures card={card} />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveRoutePeek(tripId, null)}
|
||||
aria-label={t('assistant.card.tour.peekClose')}
|
||||
className="-me-1.5 grid h-11 w-11 flex-none place-items-center rounded-full text-content-secondary hover:bg-surface-hover"
|
||||
>
|
||||
<X size={18} strokeWidth={2.2} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{ways.length > 0 && (
|
||||
<div role="group" aria-label={waysLabel} className="mt-1 flex items-center gap-1">
|
||||
{ways.map((way, position) => {
|
||||
const key = WAY_KEYS[position] ?? 'a';
|
||||
const letter = key.toUpperCase();
|
||||
const chosen = way.toolCallId === toolCallId;
|
||||
return (
|
||||
<button
|
||||
key={way.toolCallId}
|
||||
type="button"
|
||||
aria-pressed={chosen}
|
||||
aria-label={way.card.label ?? t('tours.variants.option', { letter })}
|
||||
onClick={() => {
|
||||
pick(tripId, blockKey, way.toolCallId);
|
||||
moveRoutePeek(tripId, way.toolCallId);
|
||||
}}
|
||||
className="grid h-11 w-11 place-items-center rounded-full hover:bg-surface-hover"
|
||||
>
|
||||
<VariantMark variant={{ key, current: false }} chosen={chosen} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<TourRouteProfile
|
||||
toolCallId={toolCallId}
|
||||
card={card}
|
||||
samples={samples}
|
||||
color={color}
|
||||
focus={shown.focus}
|
||||
onFocus={shown.setFocus}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Finds the route the peek is open on in this trip's chat; nothing while it is closed or the route is gone. */
|
||||
function PeekFinder() {
|
||||
const { tripId } = useAssistantView();
|
||||
const toolCallId = useAssistantStore((s) => (s.routePeek?.tripId === tripId ? s.routePeek.toolCallId : null));
|
||||
const messages = useAssistantStore((s) => chatOf(s, tripId).messages);
|
||||
const route = useMemo(() => (toolCallId ? peekRoute(messages, toolCallId) : null), [messages, toolCallId]);
|
||||
return route ? <PeekBody route={route} /> : null;
|
||||
}
|
||||
|
||||
interface RoutePeekProps {
|
||||
tripId: number;
|
||||
onOpen: NonNullable<AssistantView['onOpen']>;
|
||||
onFocusDay: NonNullable<AssistantView['onFocusDay']>;
|
||||
onRevealMap: NonNullable<AssistantView['onRevealMap']>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The phone's peek at a route over the map, while the chat stands aside:
|
||||
* opened by "On map" on a route card or a tap on a route line. It shows the
|
||||
* route as its card does in brief (name, grade, figures) and its height
|
||||
* profile with the readout under it, a finger scrubbing along it while the
|
||||
* map's pin follows; the ways of an answer as their marks, 44 px targets, a
|
||||
* tap choosing one as the card's choice does; and a close button. The map
|
||||
* stays usable around it, and the chat's pill below brings the chat back.
|
||||
*/
|
||||
export default function RoutePeek({ tripId, onOpen, onFocusDay, onRevealMap }: RoutePeekProps) {
|
||||
const view = useMemo<AssistantView>(
|
||||
() => ({ tripId, variant: 'fullscreen', density: 'compact', onOpen, onFocusDay, onRevealMap }),
|
||||
[tripId, onOpen, onFocusDay, onRevealMap]
|
||||
);
|
||||
return (
|
||||
<AssistantViewContext.Provider value={view}>
|
||||
<PeekFinder />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { type ComponentProps, Suspense } from 'react';
|
||||
|
||||
import { lazyWithRetry } from '../../../utils/lazyWithRetry';
|
||||
import CardBoundary from '../cards/parts/CardBoundary';
|
||||
|
||||
/**
|
||||
* The phone's route peek, loaded the first time a route is shown over the
|
||||
* map, so the trip screen does not carry the route cards' code before the
|
||||
* chat ever draws one. A peek that fails to load or to draw shows nothing:
|
||||
* the map and the chat's pill stay as they are.
|
||||
*/
|
||||
const RoutePeek = lazyWithRetry(() => import('./RoutePeek'));
|
||||
|
||||
export default function RoutePeekLazy(props: ComponentProps<typeof RoutePeek>) {
|
||||
return (
|
||||
<CardBoundary>
|
||||
<Suspense fallback={null}>
|
||||
<RoutePeek {...props} />
|
||||
</Suspense>
|
||||
</CardBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
|
||||
/**
|
||||
* Opens the phone's route peek on another route, or closes it, and moves the
|
||||
* light a tap on the map put on a route with it. On the phone no pointer
|
||||
* ever leaves a line, so nothing else would put that light out: the line
|
||||
* would stay lit on the map and its card in the chat after the peek is gone.
|
||||
* A light the chat itself put on (a card under the finger) is left alone.
|
||||
* Kept apart from the peek, so the trip screen can close it without loading
|
||||
* the peek's code.
|
||||
*/
|
||||
export function moveRoutePeek(tripId: number, toolCallId: string | null): void {
|
||||
const { mapLink, setMapHover, setRoutePeek } = useAssistantStore.getState();
|
||||
if (mapLink?.tripId === tripId && mapLink.source === 'map' && mapLink.hoveredId !== null) {
|
||||
setMapHover(tripId, toolCallId, 'map');
|
||||
}
|
||||
setRoutePeek(toolCallId === null ? null : { tripId, toolCallId });
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { AssistantMessage } from '@trek/shared';
|
||||
|
||||
import { messageBlocks } from '../assistantParts';
|
||||
import { routeEntries, type RouteEntry, wayGroups } from '../cards/tour/tourRouteModel';
|
||||
|
||||
/**
|
||||
* The route the phone's peek shows, found where the chat shows it: the card,
|
||||
* and when it is one of the ways of an answer, all of them with the key the
|
||||
* chat keeps the chosen way under (the ways card's own, `tour:<first call>`).
|
||||
*/
|
||||
export interface PeekRoute {
|
||||
entry: RouteEntry;
|
||||
/** The ways it is one of, in the answer's order; empty for a route on its own. */
|
||||
ways: RouteEntry[];
|
||||
blockKey: string;
|
||||
}
|
||||
|
||||
/** The route card of a call, laid out as the chat lays it out; null when the chat holds no route under it. */
|
||||
export function peekRoute(messages: readonly AssistantMessage[], toolCallId: string): PeekRoute | null {
|
||||
for (const message of messages) {
|
||||
for (const block of messageBlocks(message, false)) {
|
||||
if (block.kind === 'cards') {
|
||||
for (const group of wayGroups(routeEntries(block.entries))) {
|
||||
const entry = group.find((way) => way.toolCallId === toolCallId);
|
||||
if (!entry) continue;
|
||||
const ways = group.length >= 2 ? group : [];
|
||||
return { entry, ways, blockKey: `tour:${group[0]?.toolCallId ?? ''}` };
|
||||
}
|
||||
} else if (block.kind === 'change') {
|
||||
const card = block.items.find((item) => item.toolCallId === toolCallId)?.card;
|
||||
if (card?.kind === 'tourRoute') return { entry: { toolCallId, card }, ways: [], blockKey: '' };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* FE-MAP-ROUTE-001 to FE-MAP-ROUTE-004
|
||||
*
|
||||
* The chat's routes on the Leaflet map: a rim under each line and nothing
|
||||
* but an invisible fat line taking the pointer; dashes for a way not chosen,
|
||||
* faint for the way a change replaces; the hard grades over the line, a disc
|
||||
* at the start and a ring at the end; the named stops only while lit; and
|
||||
* the pointer onto, along and off a line reported by its key; a line that
|
||||
* lights up brought to the front. What Leaflet makes of the options is
|
||||
* Leaflet's: the paths come back as data here.
|
||||
*/
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { ReactNode, Ref } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { AssistantRouteLine } from '../../types/assistantMap';
|
||||
import { AssistantGhostLineLayer } from './AssistantGhostLineLayer';
|
||||
|
||||
type Handlers = Record<string, ((event?: unknown) => void) | undefined>;
|
||||
|
||||
const bringToFront = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('react-leaflet', async () => {
|
||||
const { useImperativeHandle } = await vi.importActual<typeof import('react')>('react');
|
||||
return {
|
||||
FeatureGroup: ({ children, ref }: { children?: ReactNode; ref?: Ref<{ bringToFront: () => void }> }) => {
|
||||
useImperativeHandle(ref, () => ({ bringToFront }));
|
||||
return <div data-testid="group">{children}</div>;
|
||||
},
|
||||
Polyline: ({
|
||||
positions,
|
||||
pathOptions,
|
||||
interactive,
|
||||
eventHandlers,
|
||||
}: {
|
||||
positions: unknown;
|
||||
pathOptions?: Record<string, unknown>;
|
||||
interactive?: boolean;
|
||||
eventHandlers?: Handlers;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="polyline"
|
||||
data-points={JSON.stringify(positions)}
|
||||
data-color={String(pathOptions?.color ?? '')}
|
||||
data-weight={String(pathOptions?.weight ?? '')}
|
||||
data-opacity={String(pathOptions?.opacity ?? '')}
|
||||
data-dash={String(pathOptions?.dashArray ?? '')}
|
||||
data-dash-named={String(pathOptions ? 'dashArray' in pathOptions : false)}
|
||||
data-interactive={String(interactive)}
|
||||
onMouseEnter={() => eventHandlers?.mouseover?.()}
|
||||
onMouseLeave={() => eventHandlers?.mouseout?.()}
|
||||
onMouseMove={() => eventHandlers?.mousemove?.({ latlng: { lat: 47.78, lng: 12.6 } })}
|
||||
onClick={() => eventHandlers?.click?.({ latlng: { lat: 47.79, lng: 12.61 } })}
|
||||
/>
|
||||
),
|
||||
CircleMarker: ({
|
||||
center,
|
||||
pathOptions,
|
||||
children,
|
||||
}: {
|
||||
center: [number, number];
|
||||
pathOptions?: Record<string, unknown>;
|
||||
children?: ReactNode;
|
||||
}) => (
|
||||
<div data-testid="disc" data-center={JSON.stringify(center)} data-fill={String(pathOptions?.fillColor ?? '')}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Tooltip: ({ children }: { children?: ReactNode }) => <span data-testid="stop-name">{children}</span>,
|
||||
};
|
||||
});
|
||||
|
||||
const LINE: AssistantRouteLine = {
|
||||
id: 'c1:look:route',
|
||||
ref: 'c1',
|
||||
lines: [
|
||||
[
|
||||
[47.77, 12.59],
|
||||
[47.78, 12.6],
|
||||
[47.79, 12.62],
|
||||
],
|
||||
],
|
||||
color: '#0a84ff',
|
||||
casing: '#ffffff',
|
||||
ends: { start: [47.77, 12.59], end: [47.79, 12.62] },
|
||||
stops: [{ lat: 47.78, lng: 12.6, label: 'Strausshütte' }],
|
||||
tints: [
|
||||
{
|
||||
line: [
|
||||
[47.78, 12.6],
|
||||
[47.79, 12.62],
|
||||
],
|
||||
color: '#7c3aed',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const paths = () => screen.queryAllByTestId('polyline');
|
||||
/** What a path was asked for, as plain data. */
|
||||
const asked = (el: HTMLElement | undefined) => ({ ...el?.dataset });
|
||||
|
||||
describe('AssistantGhostLineLayer', () => {
|
||||
it('FE-MAP-ROUTE-001: a rim, the line, its hard grade and its ends; only the invisible line takes the pointer', () => {
|
||||
render(<AssistantGhostLineLayer lines={[LINE]} />);
|
||||
const [rim, core, grade, hit] = paths();
|
||||
expect(asked(rim)).toMatchObject({ color: '#ffffff', weight: '7', interactive: 'false' });
|
||||
expect(asked(core)).toMatchObject({ color: '#0a84ff', weight: '4', dash: '', interactive: 'false' });
|
||||
expect(asked(grade)).toMatchObject({ color: '#7c3aed', interactive: 'false' });
|
||||
expect(asked(hit)).toMatchObject({ weight: '16', opacity: '0', interactive: 'undefined' });
|
||||
const discs = screen.getAllByTestId('disc');
|
||||
expect(discs.map((disc) => disc.dataset.fill)).toEqual(['#0a84ff', '#ffffff']);
|
||||
// The stops show only while the line is lit.
|
||||
expect(screen.queryByTestId('stop-name')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MAP-ROUTE-002: dashed, faint, a loop with one disc, and the lit line with its named stops', () => {
|
||||
const loop: AssistantRouteLine = { ...LINE, dashed: true, dim: true, ends: { start: [47.77, 12.59], end: null } };
|
||||
const { rerender } = render(<AssistantGhostLineLayer lines={[loop]} />);
|
||||
expect(asked(paths()[1])).toMatchObject({ dash: '6 7', opacity: '0.45' });
|
||||
expect(screen.getAllByTestId('disc')).toHaveLength(1);
|
||||
|
||||
rerender(<AssistantGhostLineLayer lines={[{ ...LINE, hot: true }]} />);
|
||||
expect(screen.getByTestId('stop-name')).toHaveTextContent('Strausshütte');
|
||||
|
||||
rerender(<AssistantGhostLineLayer lines={[]} />);
|
||||
expect(paths()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-MAP-ROUTE-003: the pointer onto, along and off a line, and a tap on it, report its key and where', () => {
|
||||
const onHover = vi.fn();
|
||||
const onPoint = vi.fn();
|
||||
render(<AssistantGhostLineLayer lines={[LINE]} onHover={onHover} onPoint={onPoint} />);
|
||||
const hit = paths()[3] as HTMLElement;
|
||||
fireEvent.mouseEnter(hit);
|
||||
fireEvent.mouseMove(hit);
|
||||
fireEvent.click(hit);
|
||||
fireEvent.mouseLeave(hit);
|
||||
expect(onHover.mock.calls).toEqual([['c1'], [null]]);
|
||||
expect(onPoint.mock.calls).toEqual([
|
||||
['c1', 47.78, 12.6],
|
||||
['c1', 47.79, 12.61],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-MAP-ROUTE-004: a dashed way that lights up loses its dashes and comes to the front; a calm one stays put', () => {
|
||||
bringToFront.mockClear();
|
||||
const way: AssistantRouteLine = { ...LINE, dashed: true };
|
||||
const other: AssistantRouteLine = { ...LINE, id: 'c2:look:route', ref: 'c2' };
|
||||
const { rerender } = render(<AssistantGhostLineLayer lines={[way, other]} />);
|
||||
expect(asked(paths()[1])).toMatchObject({ dash: '6 7' });
|
||||
expect(bringToFront).not.toHaveBeenCalled();
|
||||
|
||||
// The same line, now lit: its dash is asked away by name, so Leaflet's merged style drops it.
|
||||
rerender(<AssistantGhostLineLayer lines={[other, { ...way, hot: true }]} />);
|
||||
const lit = screen.getAllByTestId('group')[1];
|
||||
const core = lit?.querySelectorAll<HTMLElement>('[data-testid="polyline"]')[1];
|
||||
expect(asked(core)).toMatchObject({ dash: '', dashNamed: 'true' });
|
||||
expect(bringToFront).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { FeatureGroup as LeafletFeatureGroup, LeafletMouseEvent } from 'leaflet';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { CircleMarker, FeatureGroup, Polyline, Tooltip } from 'react-leaflet';
|
||||
|
||||
import type { AssistantRouteLine } from '../../types/assistantMap';
|
||||
|
||||
/** A rim under the line, the line, and the invisible fat line that takes the pointer. */
|
||||
const CASING_WEIGHT = 7;
|
||||
const CORE_WEIGHT = 4;
|
||||
const HIT_WEIGHT = 16;
|
||||
/** A way not chosen, or one joined straight between its stops. */
|
||||
const DASH = '6 7';
|
||||
/** The way a change replaces, beside the new one. */
|
||||
const DIM_OPACITY = 0.45;
|
||||
const END_RADIUS = 5;
|
||||
const STOP_RADIUS = 4;
|
||||
const ROUND = { lineCap: 'round', lineJoin: 'round' } as const;
|
||||
|
||||
interface LineHandlers {
|
||||
onHover?: (ref: string | null) => void;
|
||||
onPoint?: (ref: string, lat: number, lng: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One route the chat shows: its rim and its line (dashed while it is a way
|
||||
* not chosen or only its stops joined straight, faint while it is the way a
|
||||
* change replaces), its hard grades over it, a disc at its start and a ring
|
||||
* at its end, its named stops with their names while it is lit, and a wide
|
||||
* invisible line on top that reports the pointer: onto it, along it (for the route's profile)
|
||||
* and off it. Nothing but the invisible line takes the pointer, so the plan's
|
||||
* own lines and pins stay where a click lands. Leaflet keeps each path where
|
||||
* it was first drawn, whatever order the lines come in later, so a line that
|
||||
* lights up is brought to the front by hand.
|
||||
*/
|
||||
function GhostLine({ line, onHover, onPoint }: { line: AssistantRouteLine } & LineHandlers) {
|
||||
const group = useRef<LeafletFeatureGroup | null>(null);
|
||||
useEffect(() => {
|
||||
if (line.hot) group.current?.bringToFront();
|
||||
}, [line.hot]);
|
||||
const opacity = line.dim ? DIM_OPACITY : 1;
|
||||
const point = (event: LeafletMouseEvent) => onPoint?.(line.ref, event.latlng.lat, event.latlng.lng);
|
||||
return (
|
||||
<FeatureGroup ref={group}>
|
||||
<Polyline
|
||||
positions={line.lines}
|
||||
interactive={false}
|
||||
pathOptions={{ color: line.casing, weight: CASING_WEIGHT, opacity: opacity * 0.9, ...ROUND }}
|
||||
/>
|
||||
<Polyline
|
||||
positions={line.lines}
|
||||
interactive={false}
|
||||
pathOptions={{
|
||||
color: line.color,
|
||||
weight: CORE_WEIGHT,
|
||||
opacity,
|
||||
// Always named: Leaflet merges a new style into the old one, so a dash left out would stay drawn.
|
||||
dashArray: line.dashed ? DASH : undefined,
|
||||
...ROUND,
|
||||
}}
|
||||
/>
|
||||
{line.tints?.map((tint, index) => (
|
||||
<Polyline
|
||||
key={index}
|
||||
positions={tint.line}
|
||||
interactive={false}
|
||||
pathOptions={{ color: tint.color, weight: CORE_WEIGHT, opacity, ...ROUND }}
|
||||
/>
|
||||
))}
|
||||
{line.ends && (
|
||||
<CircleMarker
|
||||
center={line.ends.start}
|
||||
radius={END_RADIUS}
|
||||
interactive={false}
|
||||
pathOptions={{ color: line.casing, weight: 2, fillColor: line.color, fillOpacity: 1, opacity }}
|
||||
/>
|
||||
)}
|
||||
{line.ends?.end && (
|
||||
<CircleMarker
|
||||
center={line.ends.end}
|
||||
radius={END_RADIUS}
|
||||
interactive={false}
|
||||
pathOptions={{ color: line.color, weight: 3, fillColor: line.casing, fillOpacity: 1, opacity }}
|
||||
/>
|
||||
)}
|
||||
{line.hot &&
|
||||
line.stops?.map((stop, index) => (
|
||||
<CircleMarker
|
||||
key={index}
|
||||
center={[stop.lat, stop.lng]}
|
||||
radius={STOP_RADIUS}
|
||||
interactive={false}
|
||||
pathOptions={{ color: line.casing, weight: 2, fillColor: line.color, fillOpacity: 1 }}
|
||||
>
|
||||
<Tooltip permanent direction="top" className="map-tooltip">
|
||||
{stop.label}
|
||||
</Tooltip>
|
||||
</CircleMarker>
|
||||
))}
|
||||
<Polyline
|
||||
positions={line.lines}
|
||||
bubblingMouseEvents={false}
|
||||
pathOptions={{ color: line.casing, weight: HIT_WEIGHT, opacity: 0 }}
|
||||
eventHandlers={{
|
||||
mouseover: () => onHover?.(line.ref),
|
||||
mouseout: () => onHover?.(null),
|
||||
mousemove: point,
|
||||
click: point,
|
||||
}}
|
||||
/>
|
||||
</FeatureGroup>
|
||||
);
|
||||
}
|
||||
|
||||
interface AssistantGhostLineLayerProps extends LineHandlers {
|
||||
lines: readonly AssistantRouteLine[] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The routes the chat shows on the Leaflet map, in the order they come
|
||||
* (the store's route model puts the one under the pointer last; here a line
|
||||
* brings itself to the front when it lights up). Over the plan's lines,
|
||||
* under its pins. The twin of
|
||||
* useAssistantGhostLinesGL.
|
||||
*/
|
||||
export function AssistantGhostLineLayer({ lines, onHover, onPoint }: AssistantGhostLineLayerProps) {
|
||||
if (!lines?.length) return null;
|
||||
return (
|
||||
<>
|
||||
{lines.map((line) => (
|
||||
<GhostLine key={line.id} line={line} onHover={onHover} onPoint={onPoint} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { ViewportPadding } from '../../utils/mapViewport';
|
||||
import { useAssistantMarkerClasses } from './assistantCamera';
|
||||
import { AssistantCameraController } from './AssistantCameraController';
|
||||
import { AssistantGhostLayer } from './AssistantGhostLayer';
|
||||
import { AssistantGhostLineLayer } from './AssistantGhostLineLayer';
|
||||
import { useReportViewLeaflet } from './assistantViewport';
|
||||
import type { AssistantMapProps } from './mapViewContract';
|
||||
|
||||
@@ -15,15 +16,20 @@ interface AssistantMapLeafletProps {
|
||||
|
||||
/**
|
||||
* Everything the assistant does on the Leaflet map, mounted inside the map
|
||||
* container as one child, so MapView only hands its props on: the ghosts,
|
||||
* the camera, the pop and pulse on the real pins, and the part of the map
|
||||
* the assistant is told is on screen.
|
||||
* container as one child, so MapView only hands its props on: the routes
|
||||
* its cards show, the ghosts, the camera, the pop and pulse on the real
|
||||
* pins, and the part of the map the assistant is told is on screen.
|
||||
*/
|
||||
export function AssistantMapLeaflet({ assistant, padding, markerEl }: AssistantMapLeafletProps) {
|
||||
useAssistantMarkerClasses(markerEl, assistant.poppedPlaceIds, assistant.assistantPulsePlaceId);
|
||||
useReportViewLeaflet();
|
||||
return (
|
||||
<>
|
||||
<AssistantGhostLineLayer
|
||||
lines={assistant.assistantRouteLines}
|
||||
onHover={assistant.onAssistantRouteHover}
|
||||
onPoint={assistant.onAssistantRoutePoint}
|
||||
/>
|
||||
<AssistantGhostLayer
|
||||
ghosts={assistant.assistantGhosts}
|
||||
hoveredId={assistant.assistantHoveredId}
|
||||
|
||||
@@ -49,6 +49,7 @@ import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
|
||||
import { selectedPlaceTarget } from './selectedPlaceTarget'
|
||||
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers'
|
||||
|
||||
// Marker grouping for the GL map (#1385): MapLibre/Mapbox can't show the rich
|
||||
// HTML photo markers *and* cluster them natively, so we feed the place points
|
||||
@@ -57,8 +58,6 @@ import { selectedPlaceTarget } from './selectedPlaceTarget'
|
||||
// points the source reports as currently unclustered. Grouping is always on,
|
||||
// matching the Leaflet map's MarkerClusterGroup.
|
||||
const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters'
|
||||
const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle'
|
||||
const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count'
|
||||
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
|
||||
|
||||
type PlaceWithCoords = Place & { lat: number; lng: number }
|
||||
|
||||
@@ -64,6 +64,19 @@
|
||||
animation: trek-ghost-enter 180ms var(--ease-out-quint) both;
|
||||
}
|
||||
|
||||
/* A spot a card marks (a via of a drive): a small dashed dot in the middle of the ghost's box, no icon. */
|
||||
.trek-ghost--point {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.trek-ghost--point .trek-ghost-face {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.trek-ghost.is-hot .trek-ghost-face {
|
||||
border-style: solid;
|
||||
animation:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-MAP-GHOST-001 to 003: the assistant's suggested places as map pins, built once for both renderers.
|
||||
// FE-MAP-GHOST-001 to 004: the assistant's suggested places as map pins, built once for both renderers.
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { GHOST_HOT_CLASS, buildGhostElement, mountGhosts, setGhostHot } from './assistantGhostMarker';
|
||||
|
||||
@@ -54,4 +54,28 @@ describe('assistantGhostMarker', () => {
|
||||
setGhostHot(elements, null);
|
||||
expect(b.classList.contains(GHOST_HOT_CLASS)).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MAP-GHOST-004: a spot a card marks is a dot without an icon, and every ghost of one change lights by its call', () => {
|
||||
const point = buildGhostElement({ id: 'c1:via:0', ref: 'c1', lat: 41.9, lng: 12.5, name: 'Via', variant: 'point' });
|
||||
expect(point.classList.contains('trek-ghost--point')).toBe(true);
|
||||
expect(point.querySelector('.trek-ghost-face svg')).toBeNull();
|
||||
|
||||
const onHover = vi.fn();
|
||||
const onClick = vi.fn();
|
||||
const ghosts = [
|
||||
{ id: 'c1:stop:0', ref: 'c1', lat: 41.9, lng: 12.5, name: 'Stop', variant: 'pin' as const },
|
||||
{ id: 'c1:via:0', ref: 'c1', lat: 41.91, lng: 12.5, name: 'Via', variant: 'point' as const },
|
||||
{ id: 'c2', lat: 41.92, lng: 12.5, name: 'Other' },
|
||||
];
|
||||
const mounted = mountGhosts(ghosts, () => ({ remove: vi.fn() }), { current: { onHover, onClick } });
|
||||
mounted.elements.get('c1:via:0')?.dispatchEvent(new Event('pointerenter'));
|
||||
mounted.elements.get('c1:stop:0')?.dispatchEvent(new MouseEvent('click'));
|
||||
expect(onHover).toHaveBeenCalledWith('c1');
|
||||
expect(onClick).toHaveBeenCalledWith('c1');
|
||||
|
||||
setGhostHot(mounted.elements, 'c1');
|
||||
const hot = [...mounted.elements.values()].map((el) => el.classList.contains(GHOST_HOT_CLASS));
|
||||
expect(hot).toEqual([true, true, false]);
|
||||
mounted.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,17 +34,22 @@ function ghostIcon(name: string | undefined): string {
|
||||
return markup;
|
||||
}
|
||||
|
||||
/**
|
||||
* A ghost's element: a dashed pin with its category's icon, or for a spot a
|
||||
* card marks (`point`) a small dashed dot without one.
|
||||
*/
|
||||
export function buildGhostElement(ghost: AssistantGhost): HTMLDivElement {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'trek-ghost';
|
||||
el.className = ghost.variant === 'point' ? 'trek-ghost trek-ghost--point' : 'trek-ghost';
|
||||
el.dataset.ghostId = ghost.id;
|
||||
el.dataset.ghostRef = ghost.ref ?? ghost.id;
|
||||
el.setAttribute('role', 'button');
|
||||
el.setAttribute('aria-label', ghost.name);
|
||||
if (ghost.color) el.style.setProperty('--ghost-color', ghost.color);
|
||||
const face = document.createElement('div');
|
||||
face.className = 'trek-ghost-face';
|
||||
// A fixed glyph from the icon table, never anything the ghost carries.
|
||||
face.innerHTML = ghostIcon(ghost.icon);
|
||||
if (ghost.variant !== 'point') face.innerHTML = ghostIcon(ghost.icon);
|
||||
el.appendChild(face);
|
||||
return el;
|
||||
}
|
||||
@@ -79,11 +84,12 @@ export function mountGhosts(
|
||||
const cleanups: (() => void)[] = [];
|
||||
for (const ghost of ghosts) {
|
||||
const el = buildGhostElement(ghost);
|
||||
const enter = () => handlers.current.onHover?.(ghost.id);
|
||||
const ref = ghost.ref ?? ghost.id;
|
||||
const enter = () => handlers.current.onHover?.(ref);
|
||||
const leave = () => handlers.current.onHover?.(null);
|
||||
const click = (event: Event) => {
|
||||
event.stopPropagation();
|
||||
handlers.current.onClick?.(ghost.id);
|
||||
handlers.current.onClick?.(ref);
|
||||
};
|
||||
const key = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') click(event);
|
||||
@@ -111,7 +117,7 @@ export function mountGhosts(
|
||||
};
|
||||
}
|
||||
|
||||
/** Marks the hovered ghost, and only it, without rebuilding any pin. */
|
||||
/** Marks the hovered ghosts (every one of a change lit by its call), and only them, without rebuilding any pin. */
|
||||
export function setGhostHot(elements: ReadonlyMap<string, HTMLElement>, hoveredId: string | null | undefined): void {
|
||||
elements.forEach((el, id) => el.classList.toggle(GHOST_HOT_CLASS, id === hoveredId));
|
||||
elements.forEach((el, id) => el.classList.toggle(GHOST_HOT_CLASS, (el.dataset.ghostRef ?? id) === hoveredId));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DawarichTrack, RoadtripHazard, RoadtripVia } from '@trek/shared';
|
||||
import type { Day, Place, Reservation, RouteVia } from '../../types';
|
||||
import type { AssistantRouteLine } from '../../types/assistantMap';
|
||||
import type { ViewportPadding } from '../../utils/mapViewport';
|
||||
import type { RouteProfileFocus } from '../../utils/routeGeometry';
|
||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
|
||||
@@ -209,6 +210,12 @@ export interface MapViewProps {
|
||||
assistantCamera?: AssistantCamera | null;
|
||||
/** Places that just arrived from an applied suggestion; their pins pop once. */
|
||||
poppedPlaceIds?: readonly number[];
|
||||
/** The routes the chat shows beside the plan: proposed ways, held changes, the way a change replaces. */
|
||||
assistantRouteLines?: AssistantRouteLine[];
|
||||
/** The pointer onto a route line (its `ref`) or off every one; the phone leaves it out, a tap points instead. */
|
||||
onAssistantRouteHover?: (ref: string | null) => void;
|
||||
/** The pointer moving along a route line, or a tap on it: where on the map, for the route's profile. */
|
||||
onAssistantRoutePoint?: (ref: string, lat: number, lng: number) => void;
|
||||
/** While the map shows the plan before a change, its pins cannot be dragged onto a day. */
|
||||
markersLocked?: boolean;
|
||||
}
|
||||
@@ -224,9 +231,17 @@ export type AssistantMapProps = Pick<
|
||||
| 'assistantCamera'
|
||||
| 'poppedPlaceIds'
|
||||
| 'markersLocked'
|
||||
| 'assistantRouteLines'
|
||||
| 'onAssistantRouteHover'
|
||||
| 'onAssistantRoutePoint'
|
||||
| 'routeProfileFocus'
|
||||
>;
|
||||
|
||||
/** A suggested place drawn before it exists. `color` and `icon` are its category's. */
|
||||
/**
|
||||
* A suggested place drawn before it exists. `color` and `icon` are its
|
||||
* category's. A spot a card marks (a road trip's new via) is a small
|
||||
* `point` instead of a pin, and lights with its change's row by `ref`.
|
||||
*/
|
||||
export interface AssistantGhost {
|
||||
id: string;
|
||||
lat: number;
|
||||
@@ -234,6 +249,9 @@ export interface AssistantGhost {
|
||||
name: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
variant?: 'pin' | 'point';
|
||||
/** The key it lights by, when several ghosts belong to one change; its `id` otherwise. */
|
||||
ref?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* The GL map's cluster bubbles for the trip's places (MapViewGL draws them):
|
||||
* named here so the layers drawn over them, the assistant's route lines among
|
||||
* them, can leave a tap on a bubble to the bubble.
|
||||
*/
|
||||
export const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle';
|
||||
export const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count';
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* FE-MAP-ROUTE-GL-001 to FE-MAP-ROUTE-GL-005
|
||||
*
|
||||
* The chat's routes on the GL map, the twin of the Leaflet layer: one
|
||||
* GeoJSON source (lines with their rims and dashes, grades, ends, the lit
|
||||
* line's stops) added the first time there is a route and again after a
|
||||
* basemap rebuilds the style (never into a style still loading), and the
|
||||
* invisible line reporting the pointer, except where a place pin or a
|
||||
* cluster bubble over it takes the pointer.
|
||||
*/
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { AssistantRouteLine } from '../../types/assistantMap';
|
||||
import {
|
||||
type LineMap,
|
||||
ROUTE_LINE_LAYERS,
|
||||
routeLineFeatures,
|
||||
useAssistantGhostLinesGL,
|
||||
} from './useAssistantGhostLinesGL';
|
||||
|
||||
const LINE: AssistantRouteLine = {
|
||||
id: 'c1:look:route',
|
||||
ref: 'c1',
|
||||
lines: [
|
||||
[
|
||||
[47.77, 12.59],
|
||||
[47.79, 12.62],
|
||||
],
|
||||
],
|
||||
color: '#0a84ff',
|
||||
casing: '#ffffff',
|
||||
ends: { start: [47.77, 12.59], end: [47.79, 12.62] },
|
||||
stops: [{ lat: 47.78, lng: 12.6, label: 'Strausshütte' }],
|
||||
tints: [
|
||||
{
|
||||
line: [
|
||||
[47.78, 12.6],
|
||||
[47.79, 12.62],
|
||||
],
|
||||
color: '#7c3aed',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
type Listener = (event?: unknown) => void;
|
||||
|
||||
/** A stand-in GL map that keeps its sources, layers and listeners, and can rebuild its style. */
|
||||
function fakeMap() {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const sources = new Map<string, { data: unknown; setData: (data: unknown) => void }>();
|
||||
const layers: string[] = [];
|
||||
const listeners: { type: string; layer: string | null; fn: Listener }[] = [];
|
||||
const map = {
|
||||
getSource: vi.fn((id: string) => sources.get(id)),
|
||||
addSource: vi.fn((id: string, source: { data: unknown }) => {
|
||||
const entry = {
|
||||
data: source.data,
|
||||
setData: (data: unknown) => {
|
||||
entry.data = data;
|
||||
},
|
||||
};
|
||||
sources.set(id, entry);
|
||||
}),
|
||||
addLayer: vi.fn((layer: { id: string }) => layers.push(layer.id)),
|
||||
getLayer: vi.fn((): unknown => undefined),
|
||||
isStyleLoaded: vi.fn(() => true),
|
||||
queryRenderedFeatures: vi.fn((): unknown[] => []),
|
||||
getContainer: () => container,
|
||||
on: vi.fn((type: string, layerOrFn: string | Listener, fn?: Listener) => {
|
||||
if (typeof layerOrFn === 'function') listeners.push({ type, layer: null, fn: layerOrFn });
|
||||
else if (fn) listeners.push({ type, layer: layerOrFn, fn });
|
||||
}),
|
||||
off: vi.fn((type: string, layerOrFn: string | Listener, fn?: Listener) => {
|
||||
const target = typeof layerOrFn === 'function' ? layerOrFn : fn;
|
||||
const index = listeners.findIndex((l) => l.type === type && l.fn === target);
|
||||
if (index >= 0) listeners.splice(index, 1);
|
||||
}),
|
||||
};
|
||||
const fire = (type: string, layer: string | null, event?: unknown) =>
|
||||
listeners.filter((l) => l.type === type && l.layer === layer).forEach((l) => l.fn(event));
|
||||
const restyle = () => {
|
||||
sources.clear();
|
||||
layers.length = 0;
|
||||
fire('styledata', null);
|
||||
};
|
||||
const data = () => sources.get('assistant-route-lines')?.data as ReturnType<typeof routeLineFeatures> | undefined;
|
||||
return { map, container, layers, listeners, fire, restyle, data };
|
||||
}
|
||||
|
||||
function mount(fake: ReturnType<typeof fakeMap>, lines: AssistantRouteLine[], handlers = {}) {
|
||||
const mapRef = { current: fake.map as unknown as LineMap };
|
||||
return renderHook(
|
||||
(props: { lines: AssistantRouteLine[] }) =>
|
||||
useAssistantGhostLinesGL({ mapRef, mapReady: true, lines: props.lines, ...handlers }),
|
||||
{ initialProps: { lines } }
|
||||
);
|
||||
}
|
||||
|
||||
describe('useAssistantGhostLinesGL', () => {
|
||||
it('FE-MAP-ROUTE-GL-001: a route is its line, its grade and its ends, in GeoJSON order; its stops only while lit', () => {
|
||||
const calm = routeLineFeatures([{ ...LINE, dim: true, dashed: true }]);
|
||||
expect(calm.features.map((f) => f.properties.kind)).toEqual(['line', 'tint', 'end', 'end']);
|
||||
expect(calm.features[0]?.properties).toMatchObject({ ref: 'c1', dashed: true, hot: false, opacity: 0.45 });
|
||||
expect(calm.features[0]?.geometry).toEqual({
|
||||
type: 'MultiLineString',
|
||||
coordinates: [
|
||||
[
|
||||
[12.59, 47.77],
|
||||
[12.62, 47.79],
|
||||
],
|
||||
],
|
||||
});
|
||||
expect(calm.features[3]?.properties).toMatchObject({ fill: '#ffffff', stroke: '#0a84ff', strokeWidth: 3 });
|
||||
|
||||
const lit = routeLineFeatures([{ ...LINE, hot: true }]);
|
||||
expect(lit.features[lit.features.length - 1]).toMatchObject({
|
||||
properties: { kind: 'stop', label: 'Strausshütte' },
|
||||
geometry: { type: 'Point', coordinates: [12.6, 47.78] },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MAP-ROUTE-GL-002: nothing is added before a route; then one source and its layers, new data on change, back after a restyle', () => {
|
||||
const fake = fakeMap();
|
||||
const hook = mount(fake, []);
|
||||
expect(fake.map.addSource).not.toHaveBeenCalled();
|
||||
|
||||
hook.rerender({ lines: [LINE] });
|
||||
expect(fake.map.addSource).toHaveBeenCalledTimes(1);
|
||||
expect(fake.layers).toEqual([
|
||||
ROUTE_LINE_LAYERS.casing,
|
||||
ROUTE_LINE_LAYERS.line,
|
||||
ROUTE_LINE_LAYERS.dashed,
|
||||
ROUTE_LINE_LAYERS.tint,
|
||||
ROUTE_LINE_LAYERS.ends,
|
||||
ROUTE_LINE_LAYERS.stops,
|
||||
ROUTE_LINE_LAYERS.hit,
|
||||
]);
|
||||
expect(fake.data()?.features).toHaveLength(4);
|
||||
|
||||
hook.rerender({ lines: [{ ...LINE, hot: true }] });
|
||||
expect(fake.map.addSource).toHaveBeenCalledTimes(1);
|
||||
expect(fake.data()?.features).toHaveLength(5);
|
||||
|
||||
fake.restyle();
|
||||
expect(fake.map.addSource).toHaveBeenCalledTimes(2);
|
||||
expect(fake.data()?.features).toHaveLength(5);
|
||||
|
||||
hook.rerender({ lines: [] });
|
||||
expect(fake.data()?.features).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("FE-MAP-ROUTE-GL-003: the pointer onto, along and off a route, a tap on it, and a stop's name; unmounting lets go", () => {
|
||||
const fake = fakeMap();
|
||||
const onHover = vi.fn();
|
||||
const onPoint = vi.fn();
|
||||
const hook = mount(fake, [LINE], { onHover, onPoint });
|
||||
const feature = { features: [{ properties: { ref: 'c1' } }] };
|
||||
fake.fire('mousemove', ROUTE_LINE_LAYERS.hit, { ...feature, lngLat: { lng: 12.6, lat: 47.78 } });
|
||||
fake.fire('click', ROUTE_LINE_LAYERS.hit, { ...feature, lngLat: { lng: 12.61, lat: 47.79 } });
|
||||
fake.fire('mouseleave', ROUTE_LINE_LAYERS.hit);
|
||||
expect(onHover.mock.calls).toEqual([['c1'], [null]]);
|
||||
expect(onPoint.mock.calls).toEqual([
|
||||
['c1', 47.78, 12.6],
|
||||
['c1', 47.79, 12.61],
|
||||
]);
|
||||
|
||||
const stop = { features: [{ properties: { label: 'Strausshütte' } }], lngLat: { lng: 12.6, lat: 47.78 } };
|
||||
fake.fire('mousemove', ROUTE_LINE_LAYERS.stops, { ...stop, point: { x: 40, y: 50 } });
|
||||
expect(fake.container.querySelector('[data-testid="assistant-route-label"]')).toHaveTextContent('Strausshütte');
|
||||
|
||||
hook.unmount();
|
||||
expect(fake.listeners).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-MAP-ROUTE-GL-004: a tap or a pointer on a place pin or a cluster bubble over a route is left to the pin', () => {
|
||||
const fake = fakeMap();
|
||||
const onHover = vi.fn();
|
||||
const onPoint = vi.fn();
|
||||
mount(fake, [LINE], { onHover, onPoint });
|
||||
const point = { x: 40, y: 50 };
|
||||
const at = { features: [{ properties: { ref: 'c1' } }], lngLat: { lng: 12.6, lat: 47.78 }, point };
|
||||
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'maplibregl-marker';
|
||||
const photo = marker.appendChild(document.createElement('img'));
|
||||
fake.fire('click', ROUTE_LINE_LAYERS.hit, { ...at, originalEvent: { target: photo } });
|
||||
fake.fire('mousemove', ROUTE_LINE_LAYERS.hit, { ...at, originalEvent: { target: photo } });
|
||||
expect(onPoint).not.toHaveBeenCalled();
|
||||
expect(onHover.mock.calls).toEqual([[null]]);
|
||||
|
||||
fake.map.getLayer.mockReturnValue({});
|
||||
fake.map.queryRenderedFeatures.mockReturnValue([{ id: 7 }]);
|
||||
fake.fire('click', ROUTE_LINE_LAYERS.hit, at);
|
||||
expect(onPoint).not.toHaveBeenCalled();
|
||||
expect(fake.map.queryRenderedFeatures).toHaveBeenCalledWith(at.point, {
|
||||
layers: ['trip-place-clusters-circle', 'trip-place-clusters-count'],
|
||||
});
|
||||
|
||||
// Off the bubbles the line takes the tap again.
|
||||
fake.map.queryRenderedFeatures.mockReturnValue([]);
|
||||
fake.fire('click', ROUTE_LINE_LAYERS.hit, at);
|
||||
expect(onPoint).toHaveBeenCalledWith('c1', 47.78, 12.6);
|
||||
});
|
||||
|
||||
it('FE-MAP-ROUTE-GL-005: a route that comes while a new basemap is loading is put on once the style is ready', () => {
|
||||
const fake = fakeMap();
|
||||
fake.map.isStyleLoaded.mockReturnValue(false);
|
||||
mount(fake, [LINE]);
|
||||
expect(fake.map.addSource).not.toHaveBeenCalled();
|
||||
|
||||
fake.map.isStyleLoaded.mockReturnValue(true);
|
||||
fake.fire('styledata', null);
|
||||
expect(fake.map.addSource).toHaveBeenCalledTimes(1);
|
||||
expect(fake.data()?.features).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
import type { AssistantRouteLine } from '../../types/assistantMap';
|
||||
import { mapHoverLabel } from './mapHoverLabel';
|
||||
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers';
|
||||
|
||||
const SOURCE_ID = 'assistant-route-lines';
|
||||
export const ROUTE_LINE_LAYERS = {
|
||||
casing: `${SOURCE_ID}-casing`,
|
||||
line: `${SOURCE_ID}-line`,
|
||||
dashed: `${SOURCE_ID}-line-dashed`,
|
||||
tint: `${SOURCE_ID}-tint`,
|
||||
ends: `${SOURCE_ID}-ends`,
|
||||
stops: `${SOURCE_ID}-stops`,
|
||||
hit: `${SOURCE_ID}-hit`,
|
||||
} as const;
|
||||
|
||||
/** The way a change replaces, beside the new one. */
|
||||
const DIM_OPACITY = 0.45;
|
||||
const ROUND = { 'line-cap': 'round', 'line-join': 'round' } as const;
|
||||
|
||||
type Position = [number, number];
|
||||
interface Feature {
|
||||
type: 'Feature';
|
||||
properties: Record<string, string | number | boolean>;
|
||||
geometry:
|
||||
| { type: 'MultiLineString'; coordinates: Position[][] }
|
||||
| { type: 'LineString'; coordinates: Position[] }
|
||||
| { type: 'Point'; coordinates: Position };
|
||||
}
|
||||
interface FeatureCollection {
|
||||
type: 'FeatureCollection';
|
||||
features: Feature[];
|
||||
}
|
||||
|
||||
/** What a pointer event on a layer hands over: where, and the feature under it. */
|
||||
interface LayerEvent {
|
||||
lngLat: { lng: number; lat: number };
|
||||
point?: { x: number; y: number };
|
||||
features?: { properties?: Record<string, unknown> | null }[];
|
||||
/** The DOM event behind it: a place pin is a DOM marker over the canvas, and its taps reach the layers too. */
|
||||
originalEvent?: { target?: EventTarget | null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice of the GL map the route lines need. Every call is optional, so a
|
||||
* stand-in map without them draws nothing instead of throwing; Mapbox and
|
||||
* MapLibre both fit.
|
||||
*/
|
||||
export interface LineMap {
|
||||
getSource?(id: string): { setData?: (data: FeatureCollection) => void } | undefined;
|
||||
addSource?(id: string, source: { type: 'geojson'; data: FeatureCollection }): unknown;
|
||||
addLayer?(layer: Record<string, unknown>): unknown;
|
||||
getLayer?(id: string): unknown;
|
||||
isStyleLoaded?(): boolean;
|
||||
queryRenderedFeatures?(point: { x: number; y: number }, options: { layers: string[] }): unknown[];
|
||||
getContainer?(): HTMLElement;
|
||||
getCanvas?(): HTMLElement;
|
||||
on?(type: 'styledata', listener: () => void): unknown;
|
||||
on?(type: 'mousemove' | 'mouseleave' | 'click', layer: string, listener: (event: LayerEvent) => void): unknown;
|
||||
off?(type: 'styledata', listener: () => void): unknown;
|
||||
off?(type: 'mousemove' | 'mouseleave' | 'click', layer: string, listener: (event: LayerEvent) => void): unknown;
|
||||
}
|
||||
|
||||
/** [lat, lng] as the store keeps points, [lng, lat] as GeoJSON wants them. */
|
||||
const flip = ([lat, lng]: [number, number]): Position => [lng, lat];
|
||||
|
||||
const pointFeature = (properties: Feature['properties'], coordinates: Position): Feature => ({
|
||||
type: 'Feature',
|
||||
properties,
|
||||
geometry: { type: 'Point', coordinates },
|
||||
});
|
||||
|
||||
/**
|
||||
* The routes as one GeoJSON collection: each route its line (with its rim
|
||||
* colour, whether it is dashed, how faint, whether it is lit), its hard
|
||||
* grades, its two ends, and its named stops while it is lit. Pure, for the
|
||||
* tests.
|
||||
*/
|
||||
export function routeLineFeatures(lines: readonly AssistantRouteLine[]): FeatureCollection {
|
||||
const features: Feature[] = [];
|
||||
for (const line of lines) {
|
||||
const opacity = line.dim ? DIM_OPACITY : 1;
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
kind: 'line',
|
||||
ref: line.ref,
|
||||
color: line.color,
|
||||
casing: line.casing,
|
||||
dashed: !!line.dashed,
|
||||
hot: !!line.hot,
|
||||
opacity,
|
||||
},
|
||||
geometry: { type: 'MultiLineString', coordinates: line.lines.map((part) => part.map(flip)) },
|
||||
});
|
||||
for (const tint of line.tints ?? []) {
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: { kind: 'tint', color: tint.color, opacity },
|
||||
geometry: { type: 'LineString', coordinates: tint.line.map(flip) },
|
||||
});
|
||||
}
|
||||
if (line.ends) {
|
||||
const start = { kind: 'end', fill: line.color, stroke: line.casing, strokeWidth: 2, opacity };
|
||||
features.push(pointFeature(start, flip(line.ends.start)));
|
||||
if (line.ends.end) {
|
||||
const end = { kind: 'end', fill: line.casing, stroke: line.color, strokeWidth: 3, opacity };
|
||||
features.push(pointFeature(end, flip(line.ends.end)));
|
||||
}
|
||||
}
|
||||
if (!line.hot) continue;
|
||||
for (const stop of line.stops ?? []) {
|
||||
const properties = { kind: 'stop', fill: line.color, stroke: line.casing, label: stop.label };
|
||||
features.push(pointFeature(properties, [stop.lng, stop.lat]));
|
||||
}
|
||||
}
|
||||
return { type: 'FeatureCollection', features };
|
||||
}
|
||||
|
||||
const kindIs = (kind: string) => ['==', ['get', 'kind'], kind];
|
||||
|
||||
/** The layers, bottom first: rims, lines, dashed lines, grades, ends, stops, and the invisible line on top. */
|
||||
function addRouteLineLayers(map: LineMap): void {
|
||||
map.addSource?.(SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
||||
const source = SOURCE_ID;
|
||||
const line = (id: string, filter: unknown[], paint: Record<string, unknown>) =>
|
||||
map.addLayer?.({ id, type: 'line', source, filter, paint, layout: ROUND });
|
||||
const solid = ['all', kindIs('line'), ['==', ['get', 'dashed'], false]];
|
||||
const dashed = ['all', kindIs('line'), ['==', ['get', 'dashed'], true]];
|
||||
const opacity = ['get', 'opacity'];
|
||||
line(ROUTE_LINE_LAYERS.casing, kindIs('line'), {
|
||||
'line-color': ['get', 'casing'],
|
||||
'line-width': 7,
|
||||
'line-opacity': ['*', opacity, 0.9],
|
||||
});
|
||||
line(ROUTE_LINE_LAYERS.line, solid, { 'line-color': ['get', 'color'], 'line-width': 4, 'line-opacity': opacity });
|
||||
// Dashes are counted in line widths: 6 px on, 7 px off at a width of 4, as the Leaflet map draws them.
|
||||
line(ROUTE_LINE_LAYERS.dashed, dashed, {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-width': 4,
|
||||
'line-opacity': opacity,
|
||||
'line-dasharray': [1.5, 1.75],
|
||||
});
|
||||
line(ROUTE_LINE_LAYERS.tint, kindIs('tint'), {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-width': 4,
|
||||
'line-opacity': opacity,
|
||||
});
|
||||
const disc = (id: string, filter: unknown[], radius: number) =>
|
||||
map.addLayer?.({
|
||||
id,
|
||||
type: 'circle',
|
||||
source,
|
||||
filter,
|
||||
paint: {
|
||||
'circle-radius': radius,
|
||||
'circle-color': ['get', 'fill'],
|
||||
'circle-stroke-color': ['get', 'stroke'],
|
||||
'circle-stroke-width': ['coalesce', ['get', 'strokeWidth'], 2],
|
||||
'circle-opacity': ['coalesce', opacity, 1],
|
||||
'circle-stroke-opacity': ['coalesce', opacity, 1],
|
||||
},
|
||||
});
|
||||
disc(ROUTE_LINE_LAYERS.ends, kindIs('end'), 5);
|
||||
disc(ROUTE_LINE_LAYERS.stops, kindIs('stop'), 4);
|
||||
line(ROUTE_LINE_LAYERS.hit, kindIs('line'), { 'line-color': ['get', 'casing'], 'line-width': 16, 'line-opacity': 0 });
|
||||
}
|
||||
|
||||
interface AssistantGhostLinesOptions {
|
||||
mapRef: { readonly current: LineMap | null };
|
||||
mapReady: boolean;
|
||||
lines: readonly AssistantRouteLine[] | undefined;
|
||||
onHover?: (ref: string | null) => void;
|
||||
onPoint?: (ref: string, lat: number, lng: number) => void;
|
||||
}
|
||||
|
||||
/** The ref of the route line under a pointer event, if any. */
|
||||
function refOf(event: LayerEvent): string | null {
|
||||
const ref = event.features?.[0]?.properties?.ref;
|
||||
return typeof ref === 'string' ? ref : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the pointer is on something of the plan's that lies over the route
|
||||
* on screen: a place pin (a DOM marker) or a cluster bubble (drawn under the
|
||||
* route's layers, but the bubble is what a tap there means). The plan's own
|
||||
* tracks leave such a tap alone the same way.
|
||||
*/
|
||||
function onPlanPin(map: LineMap, event: LayerEvent): boolean {
|
||||
const target = event.originalEvent?.target;
|
||||
if (target instanceof Element && target.closest('.mapboxgl-marker, .maplibregl-marker')) return true;
|
||||
if (!event.point || !map.queryRenderedFeatures || !map.getLayer?.(PLACE_CLUSTER_CIRCLE_LAYER_ID)) return false;
|
||||
const layers = [PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID];
|
||||
return map.queryRenderedFeatures(event.point, { layers }).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The routes the chat shows on the GL map, the twin of the Leaflet
|
||||
* AssistantGhostLineLayer: one GeoJSON source and its layers, added the
|
||||
* first time there is a route to draw and again whenever the basemap
|
||||
* rebuilds the style, the data set anew when the routes change. The
|
||||
* invisible line reports the pointer onto, along and off a route; a stop
|
||||
* under the pointer shows its name in the map's hover label.
|
||||
*/
|
||||
export function useAssistantGhostLinesGL({ mapRef, mapReady, lines, onHover, onPoint }: AssistantGhostLinesOptions) {
|
||||
const handlers = useRef({ onHover, onPoint });
|
||||
useLayoutEffect(() => {
|
||||
handlers.current = { onHover, onPoint };
|
||||
}, [onHover, onPoint]);
|
||||
const data = useRef<FeatureCollection>(routeLineFeatures([]));
|
||||
const added = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !mapReady || typeof map.getSource !== 'function') return;
|
||||
data.current = routeLineFeatures(lines ?? []);
|
||||
// Nothing is added to a map that never had a route to draw.
|
||||
if (!added.current && data.current.features.length === 0) return;
|
||||
added.current = true;
|
||||
if (!map.getSource(SOURCE_ID)) {
|
||||
// A basemap still loading takes no layers; its styledata puts the routes on once it is ready.
|
||||
if (map.isStyleLoaded?.() === false) return;
|
||||
addRouteLineLayers(map);
|
||||
}
|
||||
map.getSource(SOURCE_ID)?.setData?.(data.current);
|
||||
}, [lines, mapReady, mapRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !mapReady || typeof map.on !== 'function' || typeof map.off !== 'function') return;
|
||||
const label = mapHoverLabel(map, 'assistant-route-label');
|
||||
// A new basemap takes every layer with it: put the routes back, as the plan's own tracks are.
|
||||
const restore = () => {
|
||||
if (!added.current || !map.getSource || map.getSource(SOURCE_ID)) return;
|
||||
addRouteLineLayers(map);
|
||||
map.getSource(SOURCE_ID)?.setData?.(data.current);
|
||||
};
|
||||
const move = (event: LayerEvent) => {
|
||||
const ref = refOf(event);
|
||||
if (ref === null) return;
|
||||
// On a pin over the line the pin is what the pointer is on: the line goes out as if left.
|
||||
if (onPlanPin(map, event)) {
|
||||
handlers.current.onHover?.(null);
|
||||
return;
|
||||
}
|
||||
handlers.current.onHover?.(ref);
|
||||
handlers.current.onPoint?.(ref, event.lngLat.lat, event.lngLat.lng);
|
||||
};
|
||||
const leave = () => handlers.current.onHover?.(null);
|
||||
const click = (event: LayerEvent) => {
|
||||
const ref = refOf(event);
|
||||
if (ref !== null && !onPlanPin(map, event)) handlers.current.onPoint?.(ref, event.lngLat.lat, event.lngLat.lng);
|
||||
};
|
||||
const stop = (event: LayerEvent) => {
|
||||
const name = event.features?.[0]?.properties?.label;
|
||||
if (typeof name === 'string' && event.point) label.show({ name, detail: null }, event.point);
|
||||
};
|
||||
const hideLabel = () => label.hide();
|
||||
map.on('styledata', restore);
|
||||
map.on('mousemove', ROUTE_LINE_LAYERS.hit, move);
|
||||
map.on('mouseleave', ROUTE_LINE_LAYERS.hit, leave);
|
||||
map.on('click', ROUTE_LINE_LAYERS.hit, click);
|
||||
map.on('mousemove', ROUTE_LINE_LAYERS.stops, stop);
|
||||
map.on('mouseleave', ROUTE_LINE_LAYERS.stops, hideLabel);
|
||||
return () => {
|
||||
map.off?.('styledata', restore);
|
||||
map.off?.('mousemove', ROUTE_LINE_LAYERS.hit, move);
|
||||
map.off?.('mouseleave', ROUTE_LINE_LAYERS.hit, leave);
|
||||
map.off?.('click', ROUTE_LINE_LAYERS.hit, click);
|
||||
map.off?.('mousemove', ROUTE_LINE_LAYERS.stops, stop);
|
||||
map.off?.('mouseleave', ROUTE_LINE_LAYERS.stops, hideLabel);
|
||||
label.remove();
|
||||
};
|
||||
}, [mapReady, mapRef]);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { PLACE_POP_CLASS, PLACE_PULSE_CLASS } from './assistantGhostMarker';
|
||||
import { useReportViewGL } from './assistantViewport';
|
||||
import type { AssistantMapProps } from './mapViewContract';
|
||||
import { useAssistantCameraGL, type CameraEngine, type CameraMap } from './useAssistantCameraGL';
|
||||
import { useAssistantGhostLinesGL, type LineMap } from './useAssistantGhostLinesGL';
|
||||
import { useAssistantGhostMarkersGL, type GhostMap } from './useAssistantGhostMarkersGL';
|
||||
|
||||
interface PinGL {
|
||||
@@ -14,7 +15,7 @@ interface PinGL {
|
||||
|
||||
interface AssistantMapGLOptions<Pin extends PinGL> {
|
||||
/** The live GL map, typed by what the parts read; the engine itself stays out of this module. */
|
||||
mapRef: { readonly current: (CameraMap & GhostMap) | null };
|
||||
mapRef: { readonly current: (CameraMap & GhostMap & LineMap) | null };
|
||||
mapReady: boolean;
|
||||
gl: CameraEngine;
|
||||
/** The ghosts' pins, in the ref the renderer repositions on its render clock. */
|
||||
@@ -40,8 +41,8 @@ export function carryAssistantMarks(from: HTMLElement, to: HTMLElement): void {
|
||||
|
||||
/**
|
||||
* Everything the assistant does on the GL map in one call, so MapViewGL only
|
||||
* hands its props on: the ghosts, the camera, and the pop and pulse on the
|
||||
* real pins. The twin of AssistantMapLeaflet.
|
||||
* hands its props on: the routes its cards show, the ghosts, the camera, and
|
||||
* the pop and pulse on the real pins. The twin of AssistantMapLeaflet.
|
||||
*/
|
||||
export function useAssistantMapGL<Pin extends PinGL>({
|
||||
mapRef,
|
||||
@@ -53,6 +54,13 @@ export function useAssistantMapGL<Pin extends PinGL>({
|
||||
padding,
|
||||
assistant,
|
||||
}: AssistantMapGLOptions<Pin>): void {
|
||||
useAssistantGhostLinesGL({
|
||||
mapRef,
|
||||
mapReady,
|
||||
lines: assistant.assistantRouteLines,
|
||||
onHover: assistant.onAssistantRouteHover,
|
||||
onPoint: assistant.onAssistantRoutePoint,
|
||||
});
|
||||
useAssistantGhostMarkersGL({
|
||||
mapRef,
|
||||
mapReady,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { HIKE_FACETS, type HikeFacet } from './hikeFacetSpecs';
|
||||
import { facetPaint, isUnknownClass, type FacetRow } from './hikeFacets';
|
||||
import { hikePaintCss } from './hikePaint';
|
||||
import { sacStepOfClass } from './sacGrades';
|
||||
import { GradeChip } from './SacStepChip';
|
||||
import type { HikeWays } from './useHikeWays';
|
||||
|
||||
const percent = (share: number) => `${Math.round(share * 100)} %`;
|
||||
@@ -52,20 +53,8 @@ const writtenTags = (facet: HikeFacet, row: FacetRow) => row.tags.map((tag) => `
|
||||
function ClassChip({ facet, row }: { facet: HikeFacet; row: FacetRow }) {
|
||||
const step = sacStepOfClass(row.key);
|
||||
const unknown = isUnknownClass(row.key);
|
||||
return (
|
||||
<span className="inline-flex h-[18px] w-[34px] flex-none items-center justify-center gap-1 rounded-full bg-surface-card font-geist font-semibold tabular-nums text-content shadow-sm ring-1 ring-inset ring-edge-faint">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-2 w-2 flex-none rounded-full"
|
||||
style={{ background: paintOf(facet, row.key) }}
|
||||
/>
|
||||
{(step !== null || unknown) && (
|
||||
<span className={unknown ? 'text-content-faint' : ''} style={fs(10.5)}>
|
||||
{step === null ? '?' : stepName(step)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
const text = step !== null ? stepName(step) : unknown ? '?' : null;
|
||||
return <GradeChip color={paintOf(facet, row.key)} text={text} faint={unknown} />;
|
||||
}
|
||||
|
||||
/** Where a row's class comes from, said in its tooltip: what OpenStreetMap wrote, or why it is unknown. */
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* FE-TOUR-SAC-CHIP-001 to FE-TOUR-SAC-CHIP-002
|
||||
*
|
||||
* A grade as the hike's chip: a dot in its map colour beside its short name,
|
||||
* the highest SAC step with a question mark when too little is graded, and a
|
||||
* lone question mark in the grey of the unknown when nothing is.
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { GradeChip, SacStepChip } from './SacStepChip';
|
||||
|
||||
const dotOf = (container: HTMLElement) => container.querySelector<HTMLElement>('[aria-hidden="true"]');
|
||||
|
||||
describe('SacStepChip', () => {
|
||||
it('FE-TOUR-SAC-CHIP-001: names the step in its colour, uncertain with a question mark', () => {
|
||||
const { container } = render(<SacStepChip step={3} />);
|
||||
expect(screen.getByText('T3')).toBeInTheDocument();
|
||||
expect(dotOf(container)?.style.background).toBe('var(--hike-sac-t3)');
|
||||
render(<SacStepChip step={4} uncertain />);
|
||||
expect(screen.getByText('T4?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-SAC-CHIP-002: no known step reads as a faint question mark in the grey of the unknown, never as easy', () => {
|
||||
const { container } = render(<SacStepChip step={null} />);
|
||||
expect(screen.getByText('?')).toHaveClass('text-content-faint');
|
||||
expect(dotOf(container)?.style.background).toBe('var(--bike-surface-unknown)');
|
||||
const plain = render(<GradeChip color="var(--hike-way-path)" text={null} />);
|
||||
expect(plain.container.textContent).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { bikePaintCss } from '../bike/bikePaint';
|
||||
import { hikePaintCss } from './hikePaint';
|
||||
|
||||
/**
|
||||
* A grade as the small white chip the hike's card shows it with: a dot in the
|
||||
* colour the map draws it in, and its short name beside it. The classes of a
|
||||
* hike's facets and the SAC step a route reaches share it, so a grade reads
|
||||
* the same in the planner and in the chat.
|
||||
*/
|
||||
|
||||
/** The SAC steps' paints, by step: T1 first. */
|
||||
const SAC_PAINTS = ['t1', 't2', 't3', 't4', 't5', 't6'] as const;
|
||||
|
||||
/** A step's colour as a CSS variable: its own for T1 to T6, the grey of the unknown otherwise. */
|
||||
function sacStepPaint(step: number | null): string {
|
||||
const paint = step === null ? undefined : SAC_PAINTS[step - 1];
|
||||
return paint ? hikePaintCss(paint) : bikePaintCss('unknown');
|
||||
}
|
||||
|
||||
/** The chip: the dot, and the short name when there is one; faint for what the data does not say. */
|
||||
export function GradeChip({ color, text, faint = false }: { color: string; text: string | null; faint?: boolean }) {
|
||||
const width = text !== null && text.length > 2 ? 'px-1.5' : 'w-[34px]';
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex h-[18px] flex-none items-center justify-center gap-1 rounded-full bg-surface-card font-geist font-semibold tabular-nums text-content shadow-sm ring-1 ring-inset ring-edge-faint ${width}`}
|
||||
>
|
||||
<span aria-hidden="true" className="h-2 w-2 flex-none rounded-full" style={{ background: color }} />
|
||||
{text !== null && (
|
||||
<span className={faint ? 'text-content-faint' : ''} style={fs(10.5)}>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The highest SAC step a route reaches ("T3"), with a question mark when too
|
||||
* little of the way is graded to be sure ("T3?"), or a lone question mark
|
||||
* when no step is known at all: an ungraded way never reads as an easy one.
|
||||
*/
|
||||
export function SacStepChip({ step, uncertain = false }: { step: number | null; uncertain?: boolean }) {
|
||||
if (step === null) return <GradeChip color={sacStepPaint(null)} text="?" faint />;
|
||||
return <GradeChip color={sacStepPaint(step)} text={uncertain ? `T${step}?` : `T${step}`} />;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { SoftPill, WhiteBadge } from '../../Planner/planParts';
|
||||
import { DialogSection, fs } from '../../shared/DialogShell';
|
||||
import { CardAction, TourNotice } from '../tourParts';
|
||||
import { linkName } from './packingLinkName';
|
||||
import { CategoryDot, DimmedLink } from './packingLinkParts';
|
||||
import { CheckCircle, DimmedLink, PackedBar, PackTile } from './packingLinkParts';
|
||||
import { TourPackingLinkDialog } from './TourPackingLinkDialog';
|
||||
import { useTourPackingLink, type TourPackingLinkController } from './useTourPackingLink';
|
||||
|
||||
@@ -30,36 +30,11 @@ function SectionPill({ onClick, icon, children }: { onClick: () => void; icon: R
|
||||
);
|
||||
}
|
||||
|
||||
/** The backpack tile with the item count on its corner, the way a waypoint's number carries its marks. */
|
||||
function PackTile({ count, color }: { count: number; color: string | null }) {
|
||||
return (
|
||||
<span className="relative grid h-9 w-9 flex-none place-items-center rounded-full bg-surface-secondary text-content-muted">
|
||||
<Backpack size={16} strokeWidth={2} aria-hidden="true" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -end-1.5 -top-1.5 grid h-[18px] min-w-[18px] place-items-center rounded-full bg-accent px-1 font-geist font-semibold tabular-nums text-accent-text ring-2 ring-surface-card"
|
||||
style={fs(9.5)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
{color && (
|
||||
<span className="absolute -bottom-0.5 -start-0.5 grid h-3.5 w-3.5 place-items-center rounded-full bg-surface-card">
|
||||
<CategoryDot color={color} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One item as Packing keeps it, read only: ticked off or not, its name and how many. */
|
||||
function ItemRow({ item }: { item: PackingItem }) {
|
||||
return (
|
||||
<li className="flex min-w-0 items-center gap-2 py-1">
|
||||
<span
|
||||
className={`grid h-4 w-4 flex-none place-items-center rounded-full ${item.checked ? 'bg-accent text-accent-text' : 'ring-1 ring-inset ring-edge'}`}
|
||||
>
|
||||
{!!item.checked && <Check size={10} strokeWidth={3} aria-hidden="true" />}
|
||||
</span>
|
||||
<CheckCircle done={!!item.checked} />
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate ${item.checked ? 'text-content-faint line-through' : 'text-content-secondary'}`}
|
||||
style={fs(12, 'body')}
|
||||
@@ -109,12 +84,7 @@ function LinkedCard({ packing, onOpenPacking }: { packing: TourPackingLinkContro
|
||||
</div>
|
||||
{total > 0 ? (
|
||||
<>
|
||||
<div className="h-1 overflow-hidden rounded-full bg-surface-secondary" aria-hidden="true">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent transition-[width] duration-300"
|
||||
style={{ width: `${(linked.packed / total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<PackedBar packed={linked.packed} total={total} />
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{linked.items.slice(0, SHOWN_ITEMS).map((item) => (
|
||||
<ItemRow key={item.id} item={item} />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { TourPackingLink } from '@trek/shared';
|
||||
import { Package, PowerOff } from 'lucide-react';
|
||||
import { Backpack, Check, type LucideIcon, Package, PowerOff } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { tintOf, WhiteBadge } from '../../Planner/planParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { TourNotice } from '../tourParts';
|
||||
import { linkName } from './packingLinkName';
|
||||
|
||||
@@ -40,3 +41,65 @@ export function CountBadge({ count }: { count: number }) {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The round tile of a list with its item count on the corner, the way a
|
||||
* waypoint's number carries its marks: a backpack for packing, another glyph
|
||||
* for another list (the chat's to-dos). A category colour sits on the other
|
||||
* corner when the list is one category of it.
|
||||
*/
|
||||
export function PackTile({
|
||||
count,
|
||||
color,
|
||||
icon: Icon = Backpack,
|
||||
}: {
|
||||
count: number;
|
||||
color: string | null;
|
||||
icon?: LucideIcon;
|
||||
}) {
|
||||
return (
|
||||
<span className="relative grid h-9 w-9 flex-none place-items-center rounded-full bg-surface-secondary text-content-muted">
|
||||
<Icon size={16} strokeWidth={2} aria-hidden="true" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -end-1.5 -top-1.5 grid h-[18px] min-w-[18px] place-items-center rounded-full bg-accent px-1 font-geist font-semibold tabular-nums text-accent-text ring-2 ring-surface-card"
|
||||
style={fs(9.5)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
{color && (
|
||||
<span className="absolute -bottom-0.5 -start-0.5 grid h-3.5 w-3.5 place-items-center rounded-full bg-surface-card">
|
||||
<CategoryDot color={color} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The round check of a list row: filled once it is done, an empty ring before.
|
||||
* `pending` draws a dashed accent ring around it, for a tick a change has not
|
||||
* made yet.
|
||||
*/
|
||||
export function CheckCircle({ done, pending = false }: { done: boolean; pending?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`grid h-4 w-4 flex-none place-items-center rounded-full ${done ? 'bg-accent text-accent-text' : 'ring-1 ring-inset ring-edge'} ${pending ? 'outline-dashed outline-1 outline-offset-2 outline-accent' : ''}`}
|
||||
>
|
||||
{done && <Check size={10} strokeWidth={3} aria-hidden="true" />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** How much of a list is packed, as a thin bar in the accent colour. */
|
||||
export function PackedBar({ packed, total }: { packed: number; total: number }) {
|
||||
const share = total > 0 ? Math.min(1, packed / total) : 0;
|
||||
return (
|
||||
<div className="h-1 overflow-hidden rounded-full bg-surface-secondary" aria-hidden="true">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent transition-[width] duration-300"
|
||||
style={{ width: `${share * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,17 +27,23 @@ function endName(stop: number | undefined, t: Translate): string {
|
||||
return stop === undefined ? t('tours.planner.shapingPoint') : t('tours.planner.waypointLabel', { n: stop });
|
||||
}
|
||||
|
||||
/** How another way differs from the one taken now, in length and time; nothing it cannot say. */
|
||||
function VariantDelta({
|
||||
/**
|
||||
* How another way differs from the one taken now, in length and time; nothing
|
||||
* it cannot say. `hint` names what it is compared with, the way taken now
|
||||
* unless said otherwise.
|
||||
*/
|
||||
export function VariantDelta({
|
||||
variant,
|
||||
current,
|
||||
unit,
|
||||
t,
|
||||
hint,
|
||||
}: {
|
||||
variant: LegVariant;
|
||||
current: LegVariant;
|
||||
unit: DistanceUnit;
|
||||
t: Translate;
|
||||
hint?: string;
|
||||
}) {
|
||||
const delta = variantDelta(variant, current);
|
||||
const parts = [
|
||||
@@ -51,7 +57,7 @@ function VariantDelta({
|
||||
].filter((part) => part.text !== null);
|
||||
if (parts.length === 0) return null;
|
||||
return (
|
||||
<Tooltip label={t('tours.variants.deltaHint')}>
|
||||
<Tooltip label={hint ?? t('tours.variants.deltaHint')}>
|
||||
<span className="flex flex-wrap gap-1" data-testid="leg-variant-delta">
|
||||
{parts.map(({ key, Icon, text }) => (
|
||||
<SoftPill key={key} icon={<Icon size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Day } from '../../../types'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { useSettingsStore } from '../../../store/settingsStore'
|
||||
import { useTourFerrataCrossingStore } from '../../../store/tourFerrataCrossingStore'
|
||||
import { formatDistance, formatElevation } from '../../../utils/units'
|
||||
import { formatDistance } from '../../../utils/units'
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog'
|
||||
import { DialogButton, NEUTRAL_TINT, fs } from '../../shared/DialogShell'
|
||||
import EmptyState from '../../shared/EmptyState'
|
||||
@@ -15,7 +15,7 @@ import { BOX } from '../../Planner/bookings/bookingParts'
|
||||
import { BarButton, SoftPill } from '../../Planner/planParts'
|
||||
import TourListRow from '../TourListRow'
|
||||
import ElevationProfile from '../../shared/ElevationProfile'
|
||||
import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../tourPresentation'
|
||||
import { formatPlannedTourDuration, hikeSourceBadgeLabel, profileFocusLabel, tourSource } from '../tourPresentation'
|
||||
import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection, type TourMetricField } from '../tourParts'
|
||||
import { movingTimeLabel, tourMetricFacts, withoutHeights } from '../tourFacts'
|
||||
import { TourDetailsSection, TourNameField } from './TourEditorFields'
|
||||
@@ -40,7 +40,7 @@ import { stageKeysOfTour } from '../stages/stageSpread'
|
||||
import { TourStageOverview } from '../stages/TourStageOverview'
|
||||
import { useTourStageRail } from '../stages/useTourStageRail'
|
||||
import { useTripStore } from '../../../store/tripStore'
|
||||
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry'
|
||||
import type { DistanceIndexedProfileSample } from '../../../utils/routeGeometry'
|
||||
import type { TourPlannerController, TourPlannerStatus } from './useTourPlanner'
|
||||
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
|
||||
import TourConversionSection from '../conversion/TourConversionSection'
|
||||
@@ -82,13 +82,6 @@ export const RAIL_HEAD = 'flex flex-none items-start gap-2 border-b border-edge-
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number | null>) => string
|
||||
|
||||
function profileFocusLabel(focus: RouteProfileFocus, t: Translate, unit: 'metric' | 'imperial'): string {
|
||||
return t('tours.planner.inspector.profileFocus', {
|
||||
distance: formatDistance(focus.distanceMeters / 1000, unit),
|
||||
elevation: focus.elevationMeters == null ? '-' : formatElevation(focus.elevationMeters, unit),
|
||||
})
|
||||
}
|
||||
|
||||
/** The title of a rail's head band, with an optional line under it. */
|
||||
export function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; aside?: ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// FE-TOUR-LEG-VARIANT-PAINT-001 to FE-TOUR-LEG-VARIANT-PAINT-004: the ways of a compared section
|
||||
// FE-TOUR-LEG-VARIANT-PAINT-001 to FE-TOUR-LEG-VARIANT-PAINT-005: the ways of a compared section
|
||||
// and the round hikes offered on the planner map, each in its colour, the one in focus on top
|
||||
// (RTE-02, RTE-03).
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { drawOrder, useLegVariantLines, variantPaintCss } from './legVariantPaint';
|
||||
import { drawOrder, useLegVariantLines, useVariantPaint, variantPaintCss } from './legVariantPaint';
|
||||
import type { LegVariant } from './legVariants';
|
||||
|
||||
const variant = (key: LegVariant['key'], lat: number): LegVariant => ({
|
||||
@@ -87,4 +87,15 @@ describe('legVariantPaint', () => {
|
||||
const empty = renderHook(() => useLegVariantLines([], null, null, rounds, null));
|
||||
expect(empty.result.current.route).toEqual([WAYS[1].coordinates, WAYS[2].coordinates]);
|
||||
});
|
||||
|
||||
it('FE-TOUR-LEG-VARIANT-PAINT-005: hands the colours of the ways and their rim resolved, for a map outside the planner', () => {
|
||||
const { result } = renderHook(() => useVariantPaint());
|
||||
expect(result.current).toEqual({
|
||||
current: '#0a84ff',
|
||||
a: '#f97316',
|
||||
b: '#d946ef',
|
||||
c: '#14b8a6',
|
||||
casing: '#ffffff',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,11 @@ export function variantPaintCss(key: LegVariantKey): string {
|
||||
return `var(${VARIANT_PAINT_TOKENS[key]})`;
|
||||
}
|
||||
|
||||
/** The ways' colours and their rim resolved for map paint, again whenever the theme changes. */
|
||||
export function useVariantPaint(): Readonly<Record<LegVariantKey | 'casing', string>> {
|
||||
return useMapPaint(VARIANT_PAINT_TOKENS);
|
||||
}
|
||||
|
||||
type Drawn = Parameters<typeof withRouteHighlight>;
|
||||
/** A way as the map draws it: its line and the key its colour goes by. */
|
||||
type DrawnWay = Pick<LegVariant, 'key' | 'coordinates'>;
|
||||
@@ -60,7 +65,7 @@ export function useLegVariantLines(
|
||||
variants: readonly DrawnWay[] | null,
|
||||
focus: LegVariantKey | null
|
||||
) {
|
||||
const paint = useMapPaint(VARIANT_PAINT_TOKENS);
|
||||
const paint = useVariantPaint();
|
||||
return useMemo(() => {
|
||||
const ordered = variants ? drawOrder(variants, focus) : [];
|
||||
const highlight = ordered.length
|
||||
|
||||
@@ -48,7 +48,7 @@ export function StopDisc({ stop }: { stop: number | undefined }) {
|
||||
* the route's own icon, the others by their letter. The chosen one carries a
|
||||
* tick at its corner.
|
||||
*/
|
||||
function VariantMark({ variant, chosen }: { variant: Pick<LegVariant, 'key' | 'current'>; chosen: boolean }) {
|
||||
export function VariantMark({ variant, chosen }: { variant: Pick<LegVariant, 'key' | 'current'>; chosen: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className="relative grid h-7 w-7 flex-none place-items-center rounded-full font-geist font-bold uppercase shadow-sm"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { estimateBadgeText, estimateLabel, filterTours, formatPlannedTourDuration, hikeSourceBadgeLabel, plannedTotalText, tourDayMinutes, tourPlannedTimes, tourSource, tourWebsitePresentation } from './tourPresentation'
|
||||
import { estimateBadgeText, estimateLabel, filterTours, formatPlannedTourDuration, hikeSourceBadgeLabel, plannedTotalText, profileFocusLabel, tourDayMinutes, tourPlannedTimes, tourSource, tourWebsitePresentation } from './tourPresentation'
|
||||
|
||||
describe('tourPresentation', () => {
|
||||
it('formats planned Tour totals using the shared compact duration formatter', () => {
|
||||
@@ -128,4 +128,11 @@ describe('tourPresentation', () => {
|
||||
('does not classify unsafe URL %s', value => {
|
||||
expect(tourWebsitePresentation(value)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-TOUR-PRES-001: says a spot of the profile by how far along and how high, without a height where none is known', () => {
|
||||
const t = vi.fn((key: string, params?: Record<string, unknown>) => `${key} ${params?.distance} ${params?.elevation}`)
|
||||
const focus = { distanceMeters: 2500, elevationMeters: 1234.4, lat: 47.6, lng: 12.6, sampleIndex: 3 }
|
||||
expect(profileFocusLabel(focus, t, 'metric')).toBe('tours.planner.inspector.profileFocus 2.5 km 1234 m')
|
||||
expect(profileFocusLabel({ ...focus, elevationMeters: null }, t, 'metric')).toBe('tours.planner.inspector.profileFocus 2.5 km -')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TourListItem, TourOrigin } from '@trek/shared'
|
||||
import { tourTimeEstimate, tourWebsiteSchema, type TourTimeEstimate } from '@trek/shared'
|
||||
import { formatDurationShort } from '@trek/shared/roadtrip'
|
||||
import type { RouteProfileFocus } from '../../utils/routeGeometry'
|
||||
import { formatDistance, formatElevation } from '../../utils/units'
|
||||
import { walkTimesOf, type WalkTimes } from './planner/stationModel'
|
||||
import type { TourMetricField } from './tourParts'
|
||||
|
||||
@@ -207,3 +209,15 @@ export function statusLabel(status: TourFilter, t: Translate): string {
|
||||
if (status === 'planned') return t('places.planned')
|
||||
return t('places.all')
|
||||
}
|
||||
|
||||
/**
|
||||
* A spot of a route's height profile in words, as the planner's profile and
|
||||
* the chat's tour card both say it: how far along, and how high where the
|
||||
* heights are known.
|
||||
*/
|
||||
export function profileFocusLabel(focus: RouteProfileFocus, t: Translate, unit: 'metric' | 'imperial'): string {
|
||||
return t('tours.planner.inspector.profileFocus', {
|
||||
distance: formatDistance(focus.distanceMeters / 1000, unit),
|
||||
elevation: focus.elevationMeters == null ? '-' : formatElevation(focus.elevationMeters, unit),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -164,4 +164,51 @@ describe('ElevationProfile', () => {
|
||||
expect(band).toHaveAttribute('width', '220')
|
||||
expect(band).toHaveAttribute('fill', 'var(--map-ferrata)')
|
||||
})
|
||||
|
||||
it('FE-ELEV-001: a band that brings its own colour is drawn in it, a little stronger than the via ferrata tint', () => {
|
||||
const { container } = render(
|
||||
<ElevationProfile samples={samples} color="currentColor" gradientId="graded" bands={[{ fromM: 0, toM: 150, color: 'var(--hike-sac-t4)' }]} />,
|
||||
)
|
||||
const band = container.querySelector('[data-profile-band]')
|
||||
expect(band).toHaveAttribute('fill', 'var(--hike-sac-t4)')
|
||||
expect(band).toHaveAttribute('fill-opacity', '0.22')
|
||||
})
|
||||
|
||||
it('FE-ELEV-002: other ways are drawn thin on axes shared with the route, so a longer one reaches the right edge', () => {
|
||||
const longer = [
|
||||
{ distanceMeters: 0, elevationMeters: 100, lat: 48, lng: 11 },
|
||||
{ distanceMeters: 600, elevationMeters: 300, lat: 48.06, lng: 11.06 },
|
||||
]
|
||||
const { container } = render(
|
||||
<ElevationProfile samples={samples} color="currentColor" gradientId="compared" compare={[{ key: 'b', samples: longer, color: 'var(--hike-variant-b)' }]} />,
|
||||
)
|
||||
const other = container.querySelector('[data-profile-compare="b"]')
|
||||
expect(other).toHaveAttribute('d', 'M0.0,96.0 L440.0,4.0')
|
||||
expect(other).toHaveAttribute('stroke', 'var(--hike-variant-b)')
|
||||
const line = container.querySelector('path[stroke="currentColor"]')?.getAttribute('d')
|
||||
expect(line).toBe('M0.0,96.0 L73.3,50.0 L220.0,73.0')
|
||||
expect(container.querySelector('path[fill^="url("]')?.getAttribute('d')).toContain('L220.0,100 L0.0,100 Z')
|
||||
})
|
||||
|
||||
it('FE-ELEV-003: with the readout left to the caller no box is drawn over the chart, the focus line stays', () => {
|
||||
const focus = { distanceMeters: 100, elevationMeters: 200, lat: 48.01, lng: 11.01, sampleIndex: 1 }
|
||||
const { container } = render(
|
||||
<ElevationProfile samples={samples} color="currentColor" gradientId="quiet" ariaLabel="Elevation profile" focus={focus} onFocusChange={vi.fn()} readout="none" />,
|
||||
)
|
||||
expect(container.querySelector('[data-profile-focus-readout]')).toBeNull()
|
||||
expect(container.querySelector('[data-profile-focus-line]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ELEV-004: keeps the spot a finger lifted from, while a mouse leaving clears it as before', () => {
|
||||
const onFocus = vi.fn()
|
||||
render(
|
||||
<ElevationProfile samples={samples} color="currentColor" gradientId="kept" ariaLabel="Elevation profile" onFocusChange={onFocus} keepFocusOnLift />,
|
||||
)
|
||||
const profile = screen.getByRole('group', { name: 'Elevation profile' })
|
||||
fireEvent.pointerLeave(profile, { pointerType: 'touch' })
|
||||
fireEvent.pointerCancel(profile, { pointerType: 'touch' })
|
||||
expect(onFocus).not.toHaveBeenCalledWith(null)
|
||||
fireEvent.pointerLeave(profile, { pointerType: 'mouse' })
|
||||
expect(onFocus).toHaveBeenLastCalledWith(null)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,19 +11,41 @@ interface ElevationProfileProps {
|
||||
focus?: RouteProfileFocus | null
|
||||
onFocusChange?: (focus: RouteProfileFocus | null) => void
|
||||
formatFocus?: (focus: RouteProfileFocus) => string
|
||||
/** Stretches to mark behind the line, in metres along the route (the via ferratas a tour climbs). */
|
||||
bands?: ReadonlyArray<{ fromM: number; toM: number }>
|
||||
/**
|
||||
* Stretches to mark behind the line, in metres along the route (the via
|
||||
* ferratas a tour climbs, the hard grades of a hike), in their own colour
|
||||
* where they bring one.
|
||||
*/
|
||||
bands?: ReadonlyArray<{ fromM: number; toM: number; color?: string }>
|
||||
/** Spots to mark across the profile, in metres along the route (where the stages of a multi-day tour end). */
|
||||
marks?: readonly number[]
|
||||
/** Other ways of the same route, drawn thin on the same axes so they compare at a glance. */
|
||||
compare?: ReadonlyArray<{ key: string; samples: readonly DistanceIndexedProfileSample[]; color: string }>
|
||||
/** Where the spot in focus is said: in a box over the chart, or nowhere (the caller says it itself). */
|
||||
readout?: 'tooltip' | 'none'
|
||||
/** Keeps the spot a finger lifted from in focus, so it can still be read; a mouse leaving clears it as ever. */
|
||||
keepFocusOnLift?: boolean
|
||||
}
|
||||
|
||||
/** A route profile whose horizontal axis represents travelled distance. */
|
||||
export default function ElevationProfile({ samples, color, height = 100, gradientId, ariaLabel, focus = null, onFocusChange, formatFocus, bands, marks }: ElevationProfileProps) {
|
||||
export default function ElevationProfile({ samples, color, height = 100, gradientId, ariaLabel, focus = null, onFocusChange, formatFocus, bands, marks, compare, readout = 'tooltip', keepFocusOnLift = false }: ElevationProfileProps) {
|
||||
const [keyboardAnnouncement, setKeyboardAnnouncement] = useState('')
|
||||
const pointerFrameRef = useRef<number | null>(null)
|
||||
const pendingPointerDistanceRef = useRef<number | null>(null)
|
||||
const width = 440
|
||||
const chart = useMemo(() => elevationProfileModel.prepareElevationProfileChart(samples, width, height), [samples, height])
|
||||
const domain = useMemo(
|
||||
() => compare?.length ? elevationProfileModel.sharedProfileDomain([samples, ...compare.map(other => other.samples)]) : null,
|
||||
[samples, compare],
|
||||
)
|
||||
const chart = useMemo(() => elevationProfileModel.prepareElevationProfileChart(samples, width, height, domain), [samples, height, domain])
|
||||
const compared = useMemo(
|
||||
() => (compare ?? []).map(other => ({
|
||||
key: other.key,
|
||||
color: other.color,
|
||||
path: elevationProfileModel.prepareElevationProfileChart(other.samples, width, height, domain).linePath,
|
||||
})),
|
||||
[compare, height, domain],
|
||||
)
|
||||
const interactive = samples.length >= 2 && !!onFocusChange && chart.hasGeographicCoordinates
|
||||
const focusAtDistance = (distanceMeters: number): RouteProfileFocus | null =>
|
||||
focusRouteProfileAtDistance(samples, distanceMeters)
|
||||
@@ -66,8 +88,9 @@ export default function ElevationProfile({ samples, color, height = 100, gradien
|
||||
: `${Math.round(next.distanceMeters)} m · ${Math.round(next.elevationMeters)} m`))
|
||||
else setKeyboardAnnouncement('')
|
||||
}
|
||||
const clearPointerFocus = (): void => {
|
||||
const clearPointerFocus = (event: React.PointerEvent<SVGSVGElement>): void => {
|
||||
cancelPendingPointerFocus()
|
||||
if (keepFocusOnLift && event.pointerType !== 'mouse') return
|
||||
onFocusChange?.(null)
|
||||
setKeyboardAnnouncement('')
|
||||
}
|
||||
@@ -124,12 +147,16 @@ export default function ElevationProfile({ samples, color, height = 100, gradien
|
||||
{bands?.map(band => (
|
||||
<rect key={`${band.fromM}-${band.toM}`} data-profile-band="true" x={(band.fromM / chart.maxDistance) * width} y={0}
|
||||
width={Math.max(1, ((band.toM - band.fromM) / chart.maxDistance) * width)} height={height}
|
||||
fill="var(--map-ferrata)" fillOpacity={0.16} />
|
||||
fill={band.color ?? 'var(--map-ferrata)'} fillOpacity={band.color ? 0.22 : 0.16} />
|
||||
))}
|
||||
{marks?.map(atM => (
|
||||
<line key={atM} data-profile-mark="true" x1={(atM / chart.maxDistance) * width} x2={(atM / chart.maxDistance) * width}
|
||||
y1={0} y2={height} stroke="var(--accent)" strokeOpacity={0.55} strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
{compared.map(other => (
|
||||
<path key={other.key} data-profile-compare={other.key} d={other.path} fill="none" stroke={other.color} strokeOpacity={0.55}
|
||||
strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
<path d={chart.areaPath} fill={`url(#${gradientId})`} />
|
||||
<path d={chart.linePath} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
|
||||
{focusX != null && (
|
||||
@@ -144,7 +171,7 @@ export default function ElevationProfile({ samples, color, height = 100, gradien
|
||||
<circle data-profile-focus-point="true" cx={`${(focusX / width) * 100}%`} cy={focusY} r={4} fill={color} stroke="var(--bg-card)" strokeWidth={2} />
|
||||
</svg>
|
||||
)}
|
||||
{interactive && focusX != null && (
|
||||
{interactive && readout === 'tooltip' && focusX != null && (
|
||||
<div
|
||||
data-profile-focus-readout="true"
|
||||
className="absolute top-1 z-10 overflow-hidden rounded-lg border border-edge-faint bg-surface-card px-2.5 py-1 text-content shadow-elevated"
|
||||
|
||||
@@ -16,10 +16,37 @@ export interface ElevationProfileChartModel {
|
||||
areaPath: string
|
||||
}
|
||||
|
||||
/** The axes a chart is drawn on: its lowest and highest heights and its length. */
|
||||
interface ElevationProfileDomain {
|
||||
minElevation: number
|
||||
maxElevation: number
|
||||
maxDistance: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One set of axes for several profiles drawn over each other (the ways a
|
||||
* route can take), so a way that climbs higher or runs longer looks it.
|
||||
* Null when none of them has a sample.
|
||||
*/
|
||||
export function sharedProfileDomain(
|
||||
profiles: ReadonlyArray<readonly DistanceIndexedProfileSample[]>,
|
||||
): ElevationProfileDomain | null {
|
||||
const samples = profiles.flat()
|
||||
if (samples.length === 0) return null
|
||||
const elevations = samples.map(sample => sample.elevationMeters)
|
||||
return {
|
||||
minElevation: Math.min(...elevations),
|
||||
maxElevation: Math.max(...elevations),
|
||||
maxDistance: Math.max(...profiles.map(profile => profile[profile.length - 1]?.distanceMeters ?? 0)),
|
||||
}
|
||||
}
|
||||
|
||||
/** The chart of a profile, on its own axes or on the `domain` it shares with others. */
|
||||
export function prepareElevationProfileChart(
|
||||
samples: readonly DistanceIndexedProfileSample[],
|
||||
width: number,
|
||||
height: number,
|
||||
domain: ElevationProfileDomain | null = null,
|
||||
): ElevationProfileChartModel {
|
||||
if (samples.length === 0) return {
|
||||
minElevation: 0,
|
||||
@@ -32,10 +59,10 @@ export function prepareElevationProfileChart(
|
||||
areaPath: '',
|
||||
}
|
||||
const elevations = samples.map(sample => sample.elevationMeters)
|
||||
const minElevation = Math.min(...elevations)
|
||||
const maxElevation = Math.max(...elevations)
|
||||
const minElevation = domain?.minElevation ?? Math.min(...elevations)
|
||||
const maxElevation = domain?.maxElevation ?? Math.max(...elevations)
|
||||
const elevationSpan = Math.max(maxElevation - minElevation, 1)
|
||||
const maxDistance = samples[samples.length - 1].distanceMeters || 1
|
||||
const maxDistance = (domain?.maxDistance ?? samples[samples.length - 1].distanceMeters) || 1
|
||||
const points = samples.map(sample => ({
|
||||
x: (sample.distanceMeters / maxDistance) * width,
|
||||
y: height - ((sample.elevationMeters - minElevation) / elevationSpan) * (height - 8) - 4,
|
||||
@@ -50,6 +77,9 @@ export function prepareElevationProfileChart(
|
||||
hasGeographicCoordinates: samples.every(sample => Number.isFinite(sample.lat) && Number.isFinite(sample.lng)),
|
||||
points,
|
||||
linePath,
|
||||
areaPath: `${linePath} L${width},${height} L0,${height} Z`,
|
||||
// On shared axes a shorter way ends before the right edge: its area closes under its own ends.
|
||||
areaPath: domain
|
||||
? `${linePath} L${points[points.length - 1].x.toFixed(1)},${height} L${points[0].x.toFixed(1)},${height} Z`
|
||||
: `${linePath} L${width},${height} L0,${height} Z`,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user