mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a430e8546 |
@@ -102,7 +102,7 @@
|
||||
"components/Studio/BookSheetsView.tsx": 2,
|
||||
"components/Studio/SpreadView.tsx": 3,
|
||||
"components/Studio/StudioElementsPanel.tsx": 8,
|
||||
"components/Studio/StudioInspector.tsx": 1,
|
||||
"components/Studio/StudioInspector.tsx": 2,
|
||||
"components/Studio/StudioShell.tsx": 2,
|
||||
"components/Studio/StudioSidebar.tsx": 2,
|
||||
"components/Studio/TravelElements.tsx": 5,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type AssistantChatRequest,
|
||||
type AssistantDecisionsRequest,
|
||||
type AssistantErrorCode,
|
||||
type AssistantInsertFoundPlaceRequest,
|
||||
type AssistantRenderUploadResponse,
|
||||
type AssistantScope,
|
||||
type AssistantShareRequest,
|
||||
@@ -143,6 +144,13 @@ export const assistantApi = {
|
||||
body: AssistantAddFoundPlaceRequest
|
||||
): Promise<AssistantAddFoundPlaceResponse> =>
|
||||
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places`, body).then((r) => r.data),
|
||||
// "+ Tour": a found place taken into the saved tour open in the planner, held as a change like "+ Day 3".
|
||||
insertFoundPlace: (
|
||||
tripId: number,
|
||||
threadId: number,
|
||||
body: AssistantInsertFoundPlaceRequest
|
||||
): Promise<AssistantAddFoundPlaceResponse> =>
|
||||
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places/tour`, body).then((r) => r.data),
|
||||
// A proposal an answer showed as a card ("Plan this"): answers like a found place, with the message holding it.
|
||||
takeProposal: (
|
||||
tripId: number,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AssistantFoundPlace } from '@trek/shared';
|
||||
import { Check, MapPin, Plus, Star } from 'lucide-react';
|
||||
import { Check, MapPin, Plus, Route, Star } from 'lucide-react';
|
||||
import { createElement, type ReactNode, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
@@ -25,6 +25,24 @@ function AddButton({ place, strip, tall }: { place: AssistantFoundPlace; strip:
|
||||
);
|
||||
}
|
||||
|
||||
/** "+ Tour": takes the place into the saved tour open in the planner as a stop, held as a change in the chat. */
|
||||
function TourButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
if (!strip.tour) return null;
|
||||
return (
|
||||
<Tooltip label={t('assistant.place.addToTourHint', { name: strip.tour.name })} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => strip.addToTour(place)}
|
||||
className={`${ACTION} ${tall ? 'h-10' : 'h-7'} min-w-0 px-2`}
|
||||
>
|
||||
<Route size={12} aria-hidden="true" className="flex-shrink-0" />
|
||||
<span className="truncate">{t('assistant.place.addToTour')}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
type Category = ReturnType<Strip['categoryOf']>;
|
||||
|
||||
/** The picture, or the category's icon on its colour, with the category and whether it is open on it. */
|
||||
@@ -185,8 +203,9 @@ export default function AssistantPlaceCard({
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<div className="mt-auto flex min-w-0 px-3 pb-3 pt-2.5">
|
||||
<div className="mt-auto flex min-w-0 gap-1.5 px-3 pb-3 pt-2.5">
|
||||
<PlaceAction place={place} strip={strip} suggested={suggested} tall={tall} />
|
||||
<TourButton place={place} strip={strip} tall={tall} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { fetchPhoto } from '../../services/photoService';
|
||||
import { assistantChatKey } from '../../store/assistantChatKey';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { setOpenTour } from '../../store/openTourStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { setForcedOffline } from '../../sync/networkMode';
|
||||
import AssistantPlaceStrip from './AssistantPlaceStrip';
|
||||
@@ -105,6 +106,7 @@ afterEach(() => {
|
||||
useAuthStore.setState(AUTH, true);
|
||||
globalThis.IntersectionObserver = RealObserver;
|
||||
setForcedOffline(false);
|
||||
setOpenTour(null);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -238,3 +240,22 @@ describe('AssistantPlaceStrip', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Add to trip' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('"+ Tour" on a found place (P6)', () => {
|
||||
it('FE-AI-PLACES-011: shows only while a saved tour of this trip is open, and holds the place as its stop', () => {
|
||||
seed();
|
||||
const insertFoundPlace = vi.fn(async () => 'held' as const);
|
||||
useAssistantStore.setState({ insertFoundPlace });
|
||||
setOpenTour({ tripId: TRIP, placeId: 64, name: 'Herzogstand' });
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add to tour' }));
|
||||
expect(insertFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 64);
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-012: a tour open on another trip, or a traveller who may not edit, gets no "+ Tour"', () => {
|
||||
seed();
|
||||
setOpenTour({ tripId: TRIP + 1, placeId: 64, name: 'Elsewhere' });
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
expect(screen.queryByRole('button', { name: 'Add to tour' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,7 @@ const ERRORS: Record<AssistantErrorCode, AssistantErrorView> = {
|
||||
decide_first: { messageKey: 'assistant.error.decideFirst', action: null },
|
||||
too_many_requests: { messageKey: 'assistant.error.rateLimited', action: null },
|
||||
no_bookings_found: { messageKey: 'assistant.error.noBookingsFound', action: null },
|
||||
no_tracks_found: { messageKey: 'assistant.error.noTracksFound', action: null },
|
||||
import_unavailable: { messageKey: 'assistant.error.importUnavailable', action: null },
|
||||
generic: {
|
||||
messageKey: 'assistant.error.generic',
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { type AssistantApplied, cumulativeMetres, type TourRouteCard, type TrekCard } from '@trek/shared';
|
||||
import { ChartNoAxesColumn, Circle, CircleCheck, CircleSlash, Footprints, Luggage, Route } from 'lucide-react';
|
||||
import {
|
||||
ChartNoAxesColumn,
|
||||
Circle,
|
||||
CircleCheck,
|
||||
CircleSlash,
|
||||
Footprints,
|
||||
Luggage,
|
||||
MapPinMinus,
|
||||
MapPinPlus,
|
||||
Route,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { RouteLineSpec, RouteUse } from '../../../../types/assistantMap';
|
||||
import type { EntityTarget, PackLook } from '../cardTypes';
|
||||
@@ -95,6 +105,8 @@ export const TOUR_LOOK = {
|
||||
countTour: { Icon: ChartNoAxesColumn, tone: 'success', labelKey: 'assistant.change.verb.countTour' },
|
||||
linkPacking: { Icon: Luggage, tone: 'success', labelKey: 'assistant.change.verb.linkPacking' },
|
||||
unlinkPacking: { Icon: Luggage, tone: 'info', labelKey: 'assistant.change.verb.unlinkPacking' },
|
||||
addTourStop: { Icon: MapPinPlus, tone: 'success', labelKey: 'assistant.change.verb.addTourStop' },
|
||||
removeTourStop: { Icon: MapPinMinus, tone: 'danger', labelKey: 'assistant.change.verb.removeTourStop' },
|
||||
},
|
||||
tools: {},
|
||||
steps: {
|
||||
@@ -106,6 +118,8 @@ export const TOUR_LOOK = {
|
||||
find_tours_for_day: { stepKey: 'assistant.step.readingTours' },
|
||||
search_tour_pois: { stepKey: 'assistant.step.tourPois' },
|
||||
get_city_walk_progress: { stepKey: 'assistant.step.readingTour' },
|
||||
// P7: a route file attached in the chat, read by the server into its tracks.
|
||||
read_tour_file: { stepKey: 'assistant.step.readingTourFile' },
|
||||
},
|
||||
entities: {
|
||||
tour: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LLM_PHOTO_EXTENSIONS } from '@trek/shared';
|
||||
import { ASSISTANT_TOUR_FILE_EXTENSIONS, LLM_PHOTO_EXTENSIONS } from '@trek/shared';
|
||||
import { type DragEvent, useState } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
@@ -6,7 +6,7 @@ import { assistantScopeOf } from '../../store/assistantChatKey';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useToast } from '../shared/Toast';
|
||||
|
||||
/** What the booking import reads (the server checks again), and how big one file may be. */
|
||||
/** What the booking import reads and the route files taken in as tours (the server checks again), and how big one file may be. */
|
||||
const ATTACH_EXTENSIONS: readonly string[] = [
|
||||
'.pdf',
|
||||
'.eml',
|
||||
@@ -16,6 +16,7 @@ const ATTACH_EXTENSIONS: readonly string[] = [
|
||||
'.htm',
|
||||
'.txt',
|
||||
...LLM_PHOTO_EXTENSIONS,
|
||||
...ASSISTANT_TOUR_FILE_EXTENSIONS,
|
||||
];
|
||||
const MAX_ATTACH_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
@@ -29,7 +30,7 @@ function hasFiles(e: DragEvent): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands a booking file to the chat, from the paperclip, a paste or a drop on
|
||||
* Hands a booking file or a route file (GPX, KML, KMZ) to the chat, from the paperclip, a paste or a drop on
|
||||
* the window. One file at a time, refused here with a toast when it is not
|
||||
* one the import reads or too big, so a wrong file never costs a round trip.
|
||||
* The drop handlers go on the whole chat; `dragging` lights its drop zone.
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useOpenTourStore } from '../../store/openTourStore';
|
||||
import { useCanDo } from '../../store/permissionsStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
@@ -41,6 +42,10 @@ function lastSpot(stops: readonly Assignment[] | undefined): { lat: number; lng:
|
||||
* Adding opens the planner's own place form, filled in with the place and on
|
||||
* the day in focus: the traveller sees everything about it and saves it as
|
||||
* any other place, so nothing waits for the chat's open decisions.
|
||||
*
|
||||
* P6: while a saved hike or city walk is open in the planner, "+ Tour" takes
|
||||
* a place into it as a stop: held as a change in the chat like any other,
|
||||
* so the stop is only saved once the traveller applies it.
|
||||
*/
|
||||
export function useAssistantPlaceStrip() {
|
||||
const { tripId, onRevealMap } = useAssistantView();
|
||||
@@ -60,6 +65,8 @@ export function useAssistantPlaceStrip() {
|
||||
);
|
||||
const focusDayId = useAssistantStore((s) => chatOf(s, tripId).focus?.dayId ?? null);
|
||||
const openPlaceDraft = useAssistantStore((s) => s.openPlaceDraft);
|
||||
const insertFoundPlace = useAssistantStore((s) => s.insertFoundPlace);
|
||||
const openTour = useOpenTourStore((s) => (s.open?.tripId === tripId ? s.open : null));
|
||||
const point = useCardPoint();
|
||||
|
||||
const focusDay = (days ?? []).find((d) => d.id === focusDayId) ?? null;
|
||||
@@ -85,6 +92,14 @@ export function useAssistantPlaceStrip() {
|
||||
openPlaceDraft(trip.id, place, focusDay?.id ?? null);
|
||||
};
|
||||
|
||||
/** The saved tour a place can be taken into as a stop, null while none is open or nothing can be added. */
|
||||
const tour = canAdd ? openTour : null;
|
||||
|
||||
/** Holds the place as a stop of the open tour, to decide in the chat. */
|
||||
const addToTour = (place: AssistantFoundPlace) => {
|
||||
if (tour) void insertFoundPlace(tripId, place.ref, tour.placeId);
|
||||
};
|
||||
|
||||
/** Shows the place on the map; on the phone the chat steps aside first. */
|
||||
const show = point.show;
|
||||
|
||||
@@ -96,5 +111,5 @@ export function useAssistantPlaceStrip() {
|
||||
[hoverPoint]
|
||||
);
|
||||
|
||||
return { canAdd, categoryOf, distanceOf, open, show, hover };
|
||||
return { canAdd, categoryOf, distanceOf, open, show, hover, tour, addToTour };
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ function SheetView({
|
||||
height: `${sheet.height + box.bleed * 2}mm`,
|
||||
// The spread's own paper colour, carried out into the bleed. Without
|
||||
// it a coloured page would be cut with a white edge on three sides.
|
||||
background: (sheet.spread.backgroundPaint ? null : sheet.spread.background) ?? '#ffffff', // theme-lint-disable: paper, not app chrome
|
||||
background: sheet.spread.background ?? '#ffffff', // theme-lint-disable — paper, not app chrome
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -8,9 +8,6 @@ import { iconComponent } from './iconLibrary'
|
||||
import { HOLED_SHAPES, SHAPE_PATHS, scalePath, unitPath } from './shapes'
|
||||
import { TravelElementView } from './TravelElements'
|
||||
import { PageNumbers } from './PageNumbers'
|
||||
import { PaintedShapeView } from './paint/PaintedShape'
|
||||
import { SpreadPaint } from './paint/paintRender'
|
||||
import { usePaintId } from './paint/usePaintId'
|
||||
|
||||
/**
|
||||
* One spread, drawn.
|
||||
@@ -89,8 +86,7 @@ const FRAME_INSET: Record<string, { pad: number; bottom: number }> = {
|
||||
*/
|
||||
function ShapeView({ el }: { el: BookShapeElement }) {
|
||||
const fill = el.fill ?? 'transparent'
|
||||
// Per mounted copy: page mode draws a spread twice (paint/paintRender.tsx).
|
||||
const gradientId = usePaintId(`g-${el.id}`)
|
||||
const gradientId = `g-${el.id}`
|
||||
const gradient = el.gradient !== 'none' && el.fill
|
||||
|
||||
if (el.shape === 'rect' || el.shape === 'ellipse') {
|
||||
@@ -175,7 +171,7 @@ function PhotoView({ el, big, print, dropLabel }: {
|
||||
// stylesheet that a person may well end up reading.
|
||||
const pad = round2(deco.pad * side)
|
||||
const bottom = round2(deco.bottom * side)
|
||||
const clipId = usePaintId(`c-${el.id}`)
|
||||
const clipId = `c-${el.id}`
|
||||
const clipped = el.mask && el.mask !== 'rect'
|
||||
|
||||
/*
|
||||
@@ -398,9 +394,7 @@ export function ElementView({
|
||||
return <PhotoView el={el} big={big} print={print} dropLabel={dropLabel} />
|
||||
}
|
||||
|
||||
if (el.kind === 'shape') {
|
||||
return el.paint ? <PaintedShapeView el={el} paint={el.paint} frameStyle={frameStyle(el)} /> : <ShapeView el={el} />
|
||||
}
|
||||
if (el.kind === 'shape') return <ShapeView el={el} />
|
||||
|
||||
// Above the catch-all below, which routes everything that is not text into
|
||||
// the travel elements and draws null for anything they do not know.
|
||||
@@ -480,8 +474,7 @@ export function SpreadView({
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
// Under a paint the sheet is the paper, which a see-through paint lets show.
|
||||
background: (spread.backgroundPaint ? null : spread.background) ?? '#ffffff', // theme-lint-disable: paper, not app chrome
|
||||
background: spread.background ?? '#ffffff', // theme-lint-disable — paper, not app chrome
|
||||
/*
|
||||
* On screen the page ends at the trim, so anything past it is clipped.
|
||||
* In print it must not be: the bleed is the part that runs off the edge
|
||||
@@ -492,10 +485,6 @@ export function SpreadView({
|
||||
overflow: print ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{spread.backgroundPaint && (
|
||||
<SpreadPaint paint={spread.backgroundPaint} width={w} height={page.pageHeight} bleed={page.bleed} idPrefix={spread.id} />
|
||||
)}
|
||||
|
||||
{spread.elements.map(el => (
|
||||
<ElementView
|
||||
key={el.id}
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { bookElementId, spreadPaint, withSpreadPaint, type BookPageSetup } from '@trek/shared';
|
||||
import { Check, CopyCheck, ImageOff } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { photoSrc } from './bookRender';
|
||||
import {
|
||||
backgroundCandidates,
|
||||
backgroundPhotoOf,
|
||||
backgroundToAll,
|
||||
withBackgroundPhoto,
|
||||
withoutBackgroundPhoto,
|
||||
} from './paint/backgroundPhoto';
|
||||
import { spreadPhotoIds } from './paint/paletteModel';
|
||||
import { usePaintChannel } from './paint/usePaintChannel';
|
||||
import { Section } from './StudioControls';
|
||||
import { StudioPaintPicker } from './StudioPaintPicker';
|
||||
import type { JourneySource, StudioT } from './StudioSidebar';
|
||||
|
||||
/**
|
||||
* The page's background, in the inspector when nothing is selected.
|
||||
*
|
||||
* Until now only the assistant could set it; a person had to lay a shape
|
||||
* across the page and remember to send it to the back. Three things, in the
|
||||
* order people reach for them: the paint itself, the same background for
|
||||
* every page, and a photograph under the whole page.
|
||||
*/
|
||||
export function StudioBackgroundSection({
|
||||
spreadIndex,
|
||||
page,
|
||||
source,
|
||||
t,
|
||||
}: {
|
||||
spreadIndex: number;
|
||||
page: BookPageSetup;
|
||||
source: JourneySource;
|
||||
t: StudioT;
|
||||
}) {
|
||||
const spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]);
|
||||
const commit = useStudioStore((s) => s.commit);
|
||||
const channel = usePaintChannel((paint) => (doc) => ({
|
||||
...doc,
|
||||
spreads: doc.spreads.map((sp, i) => (i === spreadIndex ? withSpreadPaint(sp, paint) : sp)),
|
||||
}));
|
||||
|
||||
// "Applied" stands in the button for a moment, so a press that changed pages out of sight is seen to have worked.
|
||||
const [applied, setApplied] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!applied) return;
|
||||
const timer = window.setTimeout(() => setApplied(false), 1600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [applied]);
|
||||
|
||||
const current = backgroundPhotoOf(spread, page);
|
||||
const candidates = useMemo(() => {
|
||||
if (!spread) return [];
|
||||
const entry = source.entries.find((e) => e.id === spread.entryId);
|
||||
return backgroundCandidates(
|
||||
spreadPhotoIds(spread),
|
||||
entry?.photoIds ?? [],
|
||||
source.photos.map((p) => p.photoId)
|
||||
);
|
||||
}, [spread, source]);
|
||||
|
||||
if (!spread) return null;
|
||||
|
||||
const setPhoto = (photoId: number) =>
|
||||
commit((doc) => ({
|
||||
...doc,
|
||||
spreads: doc.spreads.map((sp, i) => {
|
||||
if (i !== spreadIndex) return sp;
|
||||
return withBackgroundPhoto(sp, page, photoId, () => bookElementId('p')) ?? sp;
|
||||
}),
|
||||
}));
|
||||
const clearPhoto = () =>
|
||||
commit((doc) => ({
|
||||
...doc,
|
||||
spreads: doc.spreads.map((sp, i) => (i === spreadIndex ? withoutBackgroundPhoto(sp, page) : sp)),
|
||||
}));
|
||||
const applyToAll = () => {
|
||||
commit((doc) => backgroundToAll(doc, spreadIndex));
|
||||
setApplied(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Section label={t('journey.studio.paint.background')}>
|
||||
<StudioPaintPicker
|
||||
value={spreadPaint(spread)}
|
||||
channel={channel}
|
||||
t={t}
|
||||
noneLabel={t('journey.studio.paint.paper')}
|
||||
/>
|
||||
|
||||
<button type="button" className={`st-chip st-paint-apply ${applied ? 'is-done' : ''}`} onClick={applyToAll}>
|
||||
{applied ? <Check size={13} strokeWidth={2.4} /> : <CopyCheck size={13} />}
|
||||
{applied ? t('journey.studio.paint.appliedAll') : t('journey.studio.paint.applyAll')}
|
||||
</button>
|
||||
|
||||
<div className="st-paint-field">
|
||||
<span className="st-paint-caption">{t('journey.studio.paint.photoBackground')}</span>
|
||||
{candidates.length ? (
|
||||
<>
|
||||
<div className="st-bgphoto-grid">
|
||||
{candidates.map((id) => (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
className={`st-bgphoto ${current?.photoId === id ? 'is-on' : ''}`}
|
||||
aria-pressed={current?.photoId === id}
|
||||
aria-label={t('journey.studio.paint.photoBackground')}
|
||||
onClick={() => (current?.photoId === id ? clearPhoto() : setPhoto(id))}
|
||||
>
|
||||
<img src={photoSrc(id, false)} alt="" loading="lazy" draggable={false} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="st-hint st-paint-hint">{t('journey.studio.paint.photoBackgroundHint')}</p>
|
||||
{current && (
|
||||
<button type="button" className="st-chip is-danger st-paint-apply" onClick={clearPhoto}>
|
||||
<ImageOff size={13} />
|
||||
{t('journey.studio.paint.removePhotoBackground')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="st-hint st-paint-hint">{t('journey.studio.paint.noPhotos')}</p>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,6 @@ import { FRAME_SHAPES, SHAPE_GROUPS } from './shapes'
|
||||
import { ShapeGlyph } from './StudioElementsPanel'
|
||||
import { TravelInspector } from './StudioTravelInspector'
|
||||
import { Swatches } from './StudioSwatches'
|
||||
import { StudioBackgroundSection } from './StudioBackgroundSection'
|
||||
import { StudioShapePaint } from './StudioShapePaint'
|
||||
import { formatBookCoords } from '@trek/shared'
|
||||
import { iconComponent, iconLabel, searchIcons } from './iconLibrary'
|
||||
import { Choice, Line, NumField, Section, Switch } from './StudioControls'
|
||||
@@ -102,8 +100,6 @@ export function StudioInspector({
|
||||
<aside className="st-panel st-inspector">
|
||||
<div className="st-panel-head"><span>{t('journey.studio.document')}</span></div>
|
||||
<div className="st-panel-scroll">
|
||||
<StudioBackgroundSection spreadIndex={spreadIndex} page={page} source={source} t={t} />
|
||||
|
||||
<Section label={t('journey.studio.pageNumbers')}>
|
||||
<Switch
|
||||
label={t('journey.studio.pageNumbers')}
|
||||
@@ -519,13 +515,54 @@ export function StudioInspector({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<StudioShapePaint
|
||||
el={el}
|
||||
spreadIndex={spreadIndex}
|
||||
selection={selection}
|
||||
onStroke={c => set({ stroke: c, strokeWidth: el.strokeWidth || 0.5 })}
|
||||
t={t}
|
||||
/>
|
||||
<Section label={t('journey.studio.colour')}>
|
||||
{/*
|
||||
Fill and stroke, both named. The panel used to show one
|
||||
unlabelled swatch row that wrote `fill`, so on an outline-only
|
||||
shape it lit up a colour that was not being drawn and there was
|
||||
no way to reach the line you could actually see.
|
||||
*/}
|
||||
<Line label={t('journey.studio.fill')} wide>
|
||||
<Swatches value={el.fill ?? '#111111'} onPick={c => set({ fill: c })} />
|
||||
</Line>
|
||||
<Line label={t('journey.studio.stroke')} wide>
|
||||
<Swatches
|
||||
value={el.stroke ?? '#141414'}
|
||||
onPick={c => set({ stroke: c, strokeWidth: el.strokeWidth || 0.5 })}
|
||||
/>
|
||||
</Line>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Switch
|
||||
label={t('journey.studio.fillOn')}
|
||||
on={el.fill !== null}
|
||||
onToggle={() => set({
|
||||
// Turning the fill off on a shape with no stroke would make
|
||||
// it invisible, so the outline comes on with it.
|
||||
fill: el.fill === null ? '#111111' : null,
|
||||
...(el.fill !== null && !el.stroke ? { stroke: '#141414', strokeWidth: el.strokeWidth || 0.5 } : {}),
|
||||
})}
|
||||
/>
|
||||
<Switch
|
||||
label={t('journey.studio.gradient')}
|
||||
on={el.gradient !== 'none'}
|
||||
onToggle={() => set({ gradient: el.gradient === 'none' ? 'down' : 'none' })}
|
||||
/>
|
||||
</div>
|
||||
{el.gradient !== 'none' && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Line label={t('journey.studio.gradient')}>
|
||||
<Choice
|
||||
value={el.gradient}
|
||||
options={[
|
||||
{ value: 'down' as const, label: t('journey.studio.gradientDown') },
|
||||
{ value: 'up' as const, label: t('journey.studio.gradientUp') },
|
||||
]}
|
||||
onPick={gradient => set({ gradient })}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section label={t('journey.studio.strokeStyle')} defaultOpen={false}>
|
||||
<Line label={t('journey.studio.strokeStyle')} wide>
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
import {
|
||||
PATTERN_SCALE,
|
||||
RADIAL_RADIUS,
|
||||
stopAlpha,
|
||||
type BookGradientPaint,
|
||||
type BookPaint,
|
||||
type BookPattern,
|
||||
type BookPatternPaint,
|
||||
} from '@trek/shared';
|
||||
import { ArrowLeftRight, Ban, Trash2 } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import '../../styles/studio-paint.css';
|
||||
import { NumField } from './StudioControls';
|
||||
import type { StudioT } from './StudioSidebar';
|
||||
import { Swatches } from './StudioSwatches';
|
||||
import { AngleDial } from './paint/AngleDial';
|
||||
import { GradientStopBar } from './paint/GradientStopBar';
|
||||
import { PatternSwatches } from './paint/PatternSwatches';
|
||||
import { RadialPad } from './paint/RadialPad';
|
||||
import {
|
||||
PATTERN_ANGLE,
|
||||
clampRadius,
|
||||
clampScale,
|
||||
cssPaint,
|
||||
gradientAs,
|
||||
kindOf,
|
||||
normAngle,
|
||||
paintAs,
|
||||
removeStop,
|
||||
reverseStops,
|
||||
updateStop,
|
||||
type PaintKind,
|
||||
} from './paint/paintEdit';
|
||||
import type { PaintChannel } from './paint/usePaintChannel';
|
||||
|
||||
/**
|
||||
* Paint, picked: none, one colour, a gradient or a pattern.
|
||||
*
|
||||
* ── How it is laid out ───────────────────────────────────────────────────
|
||||
*
|
||||
* One row of four tabs says what kind of paint it is, each with a swatch of
|
||||
* what choosing it gives, and below it only the controls that kind has. The
|
||||
* tab is the question people ask first ("flat or a gradient?"), so it is a
|
||||
* row they can read at a glance rather than a menu.
|
||||
*
|
||||
* Switching kind keeps the colour: a solid becomes a gradient from that
|
||||
* colour to a lighter partner, a gradient becomes a pattern in its first
|
||||
* colour, and coming back to a kind already visited in this session brings
|
||||
* it back as it was left, so trying a pattern costs nothing.
|
||||
*
|
||||
* Every drag (a stop, the dial, the centre, a slider) writes live and is one
|
||||
* undo step when it is let go, through the channel the caller hands in.
|
||||
*/
|
||||
|
||||
const KINDS: readonly PaintKind[] = ['none', 'solid', 'gradient', 'pattern'];
|
||||
/** A kind's name on its tab. */
|
||||
function kindLabel(k: Exclude<PaintKind, 'none'>, t: StudioT): string {
|
||||
if (k === 'solid') return t('journey.studio.paint.solid');
|
||||
return k === 'gradient' ? t('journey.studio.paint.gradient') : t('journey.studio.paint.pattern');
|
||||
}
|
||||
|
||||
export function StudioPaintPicker({
|
||||
value,
|
||||
channel,
|
||||
t,
|
||||
noneLabel,
|
||||
}: {
|
||||
value: BookPaint | null;
|
||||
channel: PaintChannel;
|
||||
t: StudioT;
|
||||
/** What "no paint" is called here: no fill on a shape, the paper on a page. */
|
||||
noneLabel: string;
|
||||
}) {
|
||||
const kind = kindOf(value);
|
||||
// The last paint of each kind shown here, so a kind visited again comes back as it was left.
|
||||
const [memory, setMemory] = useState<Partial<Record<PaintKind, BookPaint>>>({});
|
||||
const [stop, setStop] = useState(0);
|
||||
|
||||
const write = (paint: BookPaint | null, done = true) => {
|
||||
const k = kindOf(paint);
|
||||
if (paint && k !== 'none') setMemory((m) => ({ ...m, [k]: paint }));
|
||||
if (done) channel.commit(paint);
|
||||
else channel.live(paint);
|
||||
};
|
||||
|
||||
const choose = (next: PaintKind) => {
|
||||
if (next === kind) return;
|
||||
const kept = value ? { ...memory, [kind]: value } : memory;
|
||||
if (value) setMemory((m) => ({ ...m, [kind]: value }));
|
||||
setStop(0);
|
||||
write(paintAs(next, value, kept));
|
||||
};
|
||||
|
||||
const tabPreview = (k: PaintKind) =>
|
||||
k === 'none' ? undefined : cssPaint(k === kind ? value : paintAs(k, value, memory));
|
||||
|
||||
return (
|
||||
<div className="st-paint">
|
||||
<div className="st-paint-tabs" role="tablist" aria-label={t('journey.studio.paint.kind')}>
|
||||
{KINDS.map((k) => (
|
||||
<button
|
||||
type="button"
|
||||
key={k}
|
||||
role="tab"
|
||||
aria-selected={k === kind}
|
||||
className={`st-paint-tab ${k === kind ? 'is-on' : ''}`}
|
||||
onClick={() => choose(k)}
|
||||
>
|
||||
<span
|
||||
className={`st-paint-tab-chip ${k === 'none' ? 'is-none' : ''} ${k === 'pattern' ? 'is-pattern' : ''}`}
|
||||
// A pattern's chip keeps its strokes (studio-paint.css) over the ground colour.
|
||||
style={k === 'pattern' ? { backgroundColor: tabPreview(k) } : { background: tabPreview(k) }}
|
||||
aria-hidden
|
||||
>
|
||||
{k === 'none' && <Ban size={11} strokeWidth={2.2} />}
|
||||
</span>
|
||||
<span className="st-paint-tab-label">{k === 'none' ? noneLabel : kindLabel(k, t)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{value?.type === 'solid' && (
|
||||
<Swatches
|
||||
value={value.color}
|
||||
onPick={(color) => write({ ...value, color })}
|
||||
alpha={stopAlpha(value)}
|
||||
onAlpha={(alpha, done) => write(withAlpha(value, alpha), done)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(value?.type === 'linear' || value?.type === 'radial') && (
|
||||
<GradientControls
|
||||
paint={value}
|
||||
stop={Math.min(stop, value.stops.length - 1)}
|
||||
onStop={setStop}
|
||||
write={write}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{value?.type === 'pattern' && <PatternControls paint={value} write={write} t={t} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const withAlpha = <T extends { alpha?: number }>(o: T, alpha: number): T => {
|
||||
const { alpha: _old, ...rest } = o;
|
||||
return (alpha >= 1 ? rest : { ...rest, alpha: Math.round(alpha * 100) / 100 }) as T;
|
||||
};
|
||||
|
||||
type Write = (paint: BookPaint | null, done?: boolean) => void;
|
||||
|
||||
function GradientControls({
|
||||
paint,
|
||||
stop,
|
||||
onStop,
|
||||
write,
|
||||
t,
|
||||
}: {
|
||||
paint: BookGradientPaint;
|
||||
stop: number;
|
||||
onStop: (i: number) => void;
|
||||
write: Write;
|
||||
t: StudioT;
|
||||
}) {
|
||||
const current = paint.stops[stop];
|
||||
const canRemove = paint.stops.length > 2;
|
||||
return (
|
||||
<div className="st-paint-body">
|
||||
<div className="st-paint-row">
|
||||
<div className="st-seg" role="radiogroup" aria-label={t('journey.studio.paint.gradient')}>
|
||||
{(['linear', 'radial'] as const).map((type) => (
|
||||
<button
|
||||
type="button"
|
||||
key={type}
|
||||
role="radio"
|
||||
aria-checked={paint.type === type}
|
||||
className={`st-seg-item ${paint.type === type ? 'is-on' : ''}`}
|
||||
onClick={() => write(gradientAs(paint, type))}
|
||||
>
|
||||
{type === 'linear' ? t('journey.studio.paint.linear') : t('journey.studio.paint.radial')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="st-chip is-icon"
|
||||
title={t('journey.studio.paint.reverse')}
|
||||
aria-label={t('journey.studio.paint.reverse')}
|
||||
onClick={() => write(reverseStops(paint))}
|
||||
>
|
||||
<ArrowLeftRight size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<GradientStopBar
|
||||
paint={paint}
|
||||
selected={stop}
|
||||
onSelect={onStop}
|
||||
onChange={(p, done) => write(p, done)}
|
||||
labels={{
|
||||
bar: t('journey.studio.paint.addStopHint'),
|
||||
stop: (pos) => t('journey.studio.paint.stopAt', { pos }),
|
||||
}}
|
||||
/>
|
||||
|
||||
{current && (
|
||||
<div className="st-paint-stop">
|
||||
<div className="st-paint-row">
|
||||
<span className="st-paint-caption">
|
||||
{t('journey.studio.paint.stopAt', { pos: Math.round(current.at * 100) })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="st-chip is-icon is-danger"
|
||||
disabled={!canRemove}
|
||||
title={t('journey.studio.paint.removeStop')}
|
||||
aria-label={t('journey.studio.paint.removeStop')}
|
||||
onClick={() => {
|
||||
onStop(Math.max(0, stop - 1));
|
||||
write(removeStop(paint, stop));
|
||||
}}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<Swatches
|
||||
value={current.color}
|
||||
onPick={(color) => write(updateStop(paint, stop, { color }))}
|
||||
alpha={stopAlpha(current)}
|
||||
onAlpha={(alpha, done) => write(updateStop(paint, stop, { alpha }), done)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paint.type === 'linear' ? (
|
||||
<div className="st-paint-row is-dial">
|
||||
<AngleDial
|
||||
value={paint.angle}
|
||||
label={t('journey.studio.paint.angle')}
|
||||
onChange={(angle, done) => write({ ...paint, angle }, done)}
|
||||
/>
|
||||
<NumField
|
||||
label={t('journey.studio.paint.angle')}
|
||||
value={normAngle(paint.angle)}
|
||||
min={0}
|
||||
max={359}
|
||||
step={1}
|
||||
unit="°"
|
||||
onChange={(angle) => write({ ...paint, angle: normAngle(angle) })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="st-paint-row is-dial">
|
||||
<RadialPad paint={paint} label={t('journey.studio.paint.centre')} onChange={(p, done) => write(p, done)} />
|
||||
<Slider
|
||||
label={t('journey.studio.paint.radius')}
|
||||
value={paint.r}
|
||||
min={RADIAL_RADIUS.min}
|
||||
max={RADIAL_RADIUS.max}
|
||||
shown={`${Math.round(paint.r * 100)}%`}
|
||||
onChange={(r, done) => write({ ...paint, r: clampRadius(r) }, done)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PatternControls({ paint, write, t }: { paint: BookPatternPaint; write: Write; t: StudioT }) {
|
||||
const pick = (pattern: BookPattern) => write({ ...paint, pattern, angle: PATTERN_ANGLE[pattern] ?? paint.angle });
|
||||
return (
|
||||
<div className="st-paint-body">
|
||||
<PatternSwatches value={paint} onPick={pick} label={(p) => t(`journey.studio.pattern.${p}`)} />
|
||||
|
||||
<div className="st-paint-field">
|
||||
<span className="st-paint-caption">{t('journey.studio.paint.ink')}</span>
|
||||
<Swatches value={paint.fg} onPick={(fg) => write({ ...paint, fg })} />
|
||||
</div>
|
||||
|
||||
<div className="st-paint-field">
|
||||
<div className="st-paint-row">
|
||||
<span className="st-paint-caption">{t('journey.studio.paint.ground')}</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={paint.bg === null}
|
||||
className={`st-chip is-pick ${paint.bg === null ? 'is-on' : ''}`}
|
||||
onClick={() => write({ ...paint, bg: paint.bg === null ? '#ffffff' : null })}
|
||||
>
|
||||
{t('journey.studio.paint.seeThrough')}
|
||||
</button>
|
||||
</div>
|
||||
{paint.bg !== null && <Swatches value={paint.bg} onPick={(bg) => write({ ...paint, bg })} />}
|
||||
</div>
|
||||
|
||||
<div className="st-paint-row is-dial">
|
||||
<AngleDial
|
||||
value={paint.angle}
|
||||
label={t('journey.studio.paint.angle')}
|
||||
onChange={(angle, done) => write({ ...paint, angle }, done)}
|
||||
/>
|
||||
<Slider
|
||||
label={t('journey.studio.paint.scale')}
|
||||
value={paint.scale}
|
||||
min={PATTERN_SCALE.min}
|
||||
max={PATTERN_SCALE.max}
|
||||
shown={`${Math.round(paint.scale * 100)}%`}
|
||||
log
|
||||
onChange={(scale, done) => write({ ...paint, scale: clampScale(scale) }, done)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A labelled slider with its value beside it; `done` as for the dial. */
|
||||
function Slider({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
shown,
|
||||
onChange,
|
||||
log = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
shown: string;
|
||||
onChange: (v: number, done: boolean) => void;
|
||||
/** Even steps by ratio rather than by difference: a pattern's scale doubles, it does not add. */
|
||||
log?: boolean;
|
||||
}) {
|
||||
const steps = 100;
|
||||
const toValue = (raw: string) => {
|
||||
const t = Number(raw) / steps;
|
||||
return log ? min * (max / min) ** t : min + (max - min) * t;
|
||||
};
|
||||
const pos = Math.round((log ? Math.log(value / min) / Math.log(max / min) : (value - min) / (max - min)) * steps);
|
||||
// What the slider was last moved to: a controlled input may still show the old value when it is let go.
|
||||
const moved = useRef(value);
|
||||
return (
|
||||
<label className="st-paint-slider">
|
||||
<span className="st-paint-slider-head">
|
||||
<span className="st-paint-caption">{label}</span>
|
||||
<span className="st-paint-value">{shown}</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={steps}
|
||||
step={1}
|
||||
value={pos}
|
||||
onPointerDown={() => {
|
||||
moved.current = value;
|
||||
}}
|
||||
onKeyDown={() => {
|
||||
moved.current = value;
|
||||
}}
|
||||
onChange={(e) => {
|
||||
moved.current = toValue(e.target.value);
|
||||
onChange(moved.current, false);
|
||||
}}
|
||||
onPointerUp={() => onChange(moved.current, true)}
|
||||
onKeyUp={() => onChange(moved.current, true)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { shapePaint, withShapePaint, type BookElement, type BookShapeElement } from '@trek/shared';
|
||||
import { usePaintChannel } from './paint/usePaintChannel';
|
||||
import { Line, Section } from './StudioControls';
|
||||
import { StudioPaintPicker } from './StudioPaintPicker';
|
||||
import type { StudioT } from './StudioSidebar';
|
||||
import { Swatches } from './StudioSwatches';
|
||||
|
||||
/** The outline a shape is given when its fill goes and it has none, so it never turns invisible. */
|
||||
const OUTLINE = '#141414';
|
||||
|
||||
/**
|
||||
* A shape's colours: its fill as a paint, and its outline.
|
||||
*
|
||||
* The fill goes to every selected shape at once, the way the rest of the
|
||||
* inspector's properties do. Taking the fill away from a shape with no
|
||||
* outline gives it one, as the old fill switch did: a shape with neither is
|
||||
* a shape nobody can see or find again.
|
||||
*/
|
||||
export function StudioShapePaint({
|
||||
el,
|
||||
spreadIndex,
|
||||
selection,
|
||||
onStroke,
|
||||
t,
|
||||
}: {
|
||||
el: BookShapeElement;
|
||||
spreadIndex: number;
|
||||
selection: readonly string[];
|
||||
onStroke: (color: string) => void;
|
||||
t: StudioT;
|
||||
}) {
|
||||
const channel = usePaintChannel((paint) => (doc) => ({
|
||||
...doc,
|
||||
spreads: doc.spreads.map((sp, i) =>
|
||||
i !== spreadIndex
|
||||
? sp
|
||||
: {
|
||||
...sp,
|
||||
elements: sp.elements.map((e): BookElement => {
|
||||
if (e.kind !== 'shape' || !selection.includes(e.id)) return e;
|
||||
const painted = withShapePaint(e, paint);
|
||||
return paint === null && !e.stroke
|
||||
? { ...painted, stroke: OUTLINE, strokeWidth: e.strokeWidth || 0.5 }
|
||||
: painted;
|
||||
}),
|
||||
}
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Section label={t('journey.studio.colour')}>
|
||||
<Line label={t('journey.studio.fill')} wide>
|
||||
<StudioPaintPicker value={shapePaint(el)} channel={channel} t={t} noneLabel={t('journey.studio.paint.none')} />
|
||||
</Line>
|
||||
<Line label={t('journey.studio.stroke')} wide>
|
||||
<Swatches value={el.stroke ?? OUTLINE} onPick={onStroke} />
|
||||
</Line>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { ProviderPicker } from '../Journey/JourneyDetailPageProviderPicker'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { StudioCanvas } from './StudioCanvas'
|
||||
import { StudioInspector } from './StudioInspector'
|
||||
import { StudioPaletteScope } from './paint/StudioPalette'
|
||||
import { StudioWordmark } from './StudioWordmark'
|
||||
import { SaveIndicator } from './SaveIndicator'
|
||||
import { downloadSpread } from './spreadFile'
|
||||
@@ -125,18 +124,16 @@ export default function StudioShell() {
|
||||
onBrowseProvider={s.browseProvider}
|
||||
/>
|
||||
<Workbench s={s} bookView={bookView} />
|
||||
<StudioPaletteScope spreadIndex={s.activeSpread} t={s.t}>
|
||||
<StudioInspector
|
||||
spreadIndex={s.activeSpread}
|
||||
page={s.page}
|
||||
stats={s.stats}
|
||||
path={s.path}
|
||||
source={s.source}
|
||||
setPageNumbers={s.setPageNumbers}
|
||||
t={s.t}
|
||||
locale={s.locale}
|
||||
/>
|
||||
</StudioPaletteScope>
|
||||
<StudioInspector
|
||||
spreadIndex={s.activeSpread}
|
||||
page={s.page}
|
||||
stats={s.stats}
|
||||
path={s.path}
|
||||
source={s.source}
|
||||
setPageNumbers={s.setPageNumbers}
|
||||
t={s.t}
|
||||
locale={s.locale}
|
||||
/>
|
||||
</div>
|
||||
<StudioAssistantColumn assistant={assistant} title={s.journey?.title} bookVersion={s.bookVersion} />
|
||||
<StudioPageStage assistant={assistant} />
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import { useRef } from 'react'
|
||||
import { BookOpen, History, Image } from 'lucide-react'
|
||||
import { useStudioPalette } from './paint/paletteModel'
|
||||
import '../../styles/studio-paint.css'
|
||||
|
||||
/**
|
||||
* The colour picker, shared by everything in the inspector that has a colour.
|
||||
*
|
||||
@@ -13,12 +8,6 @@ import '../../styles/studio-paint.css'
|
||||
* The palette is print ink rather than app chrome — it does not follow the
|
||||
* user's theme, because a book is printed once and read on paper, where an
|
||||
* accent chosen for a dark interface means nothing.
|
||||
*
|
||||
* Under the fixed inks sit up to three short rows from the open book
|
||||
* (paint/StudioPalette.tsx): the colours the book already uses, the colours
|
||||
* of the photos on this page, and what this person picked lately. Each row
|
||||
* is there only when it has something in it, so a new book shows the inks
|
||||
* alone, as before.
|
||||
*/
|
||||
|
||||
const SWATCHES = [
|
||||
@@ -27,118 +16,23 @@ const SWATCHES = [
|
||||
'#1e3a8a', '#9f1239', '#4c1d95',
|
||||
]
|
||||
|
||||
function Swatch({ color, on, onPick }: { color: string; on: boolean; onPick: (c: string) => void }) {
|
||||
export function Swatches({ value, onPick }: { value: string; onPick: (c: string) => void }) {
|
||||
return (
|
||||
<button type="button"
|
||||
className={`st-swatch ${on ? 'is-on' : ''}`}
|
||||
style={{ background: color }} // theme-lint-disable: a colour picker shows the colours themselves
|
||||
onClick={() => onPick(color)}
|
||||
aria-label={color}
|
||||
aria-pressed={on}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Swatches({ value, onPick, alpha, onAlpha, rows = true }: {
|
||||
value: string
|
||||
onPick: (c: string) => void
|
||||
/** With `onAlpha`, an opacity slider under the swatches, 0 to 1. */
|
||||
alpha?: number
|
||||
/** `done` is false while the slider is dragged and true when it is let go. */
|
||||
onAlpha?: (a: number, done: boolean) => void
|
||||
/** The book, photo and recent rows; off where only the fixed inks make sense. */
|
||||
rows?: boolean
|
||||
}) {
|
||||
const palette = useStudioPalette()
|
||||
const current = value.toLowerCase()
|
||||
// The wheel reports every colour it passes through; only the one it stops on is worth remembering.
|
||||
const wheel = useRef<string | null>(null)
|
||||
const pick = (c: string) => {
|
||||
palette?.remember(c)
|
||||
onPick(c)
|
||||
}
|
||||
|
||||
const groups = rows && palette
|
||||
? ([
|
||||
['book', BookOpen, palette.book],
|
||||
['photos', Image, palette.photos],
|
||||
['recent', History, palette.recent],
|
||||
] as const).filter(([, , list]) => list.length > 0)
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="st-swatch-stack">
|
||||
<div className="st-swatches">
|
||||
{SWATCHES.map(c => <Swatch key={c} color={c} on={current === c} onPick={pick} />)}
|
||||
{/* Anything at all, for a book that has to match a cover or a brand. */}
|
||||
<label className="st-swatch st-swatch-custom">
|
||||
{/* Named by the colour it holds, the way the preset swatches above are. */}
|
||||
<input
|
||||
type="color"
|
||||
aria-label={value}
|
||||
value={value}
|
||||
onChange={e => { wheel.current = e.target.value; onPick(e.target.value) }}
|
||||
onBlur={() => { if (wheel.current) palette?.remember(wheel.current); wheel.current = null }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{groups.map(([key, Icon, list]) => (
|
||||
<div key={key} className="st-swatch-row" role="group" aria-label={palette?.labels[key]}>
|
||||
<span className="st-swatch-row-icon" title={palette?.labels[key]} aria-hidden>
|
||||
<Icon size={12} strokeWidth={2} />
|
||||
</span>
|
||||
<div className="st-swatches">
|
||||
{list.map(c => <Swatch key={c} color={c} on={current === c} onPick={pick} />)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{onAlpha && <AlphaSlider color={value} value={alpha ?? 1} onChange={onAlpha} label={palette?.labels.opacity} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How opaque the colour is, as a slider over the colour itself.
|
||||
*
|
||||
* The track runs from nothing to the full colour over a chequerboard, so the
|
||||
* slider shows what it does rather than needing a label to explain it, and
|
||||
* the figure beside it says the exact value for anyone matching a reference.
|
||||
*/
|
||||
export function AlphaSlider({ color, value, onChange, label }: {
|
||||
color: string
|
||||
value: number
|
||||
onChange: (a: number, done: boolean) => void
|
||||
/** The slider's accessible name, in the reader's language. */
|
||||
label?: string
|
||||
}) {
|
||||
const pct = Math.round(value * 100)
|
||||
// What the slider was last moved to: a controlled input may still show the old value when it is let go.
|
||||
const moved = useRef(value)
|
||||
const move = (raw: string) => {
|
||||
moved.current = Math.max(0, Math.min(100, Number(raw))) / 100
|
||||
onChange(moved.current, false)
|
||||
}
|
||||
const finish = () => onChange(moved.current, true)
|
||||
return (
|
||||
<div className="st-alpha">
|
||||
<div className="st-alpha-track" style={{ ['--st-alpha-colour' as string]: color }}>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={pct}
|
||||
aria-label={label}
|
||||
onPointerDown={() => { moved.current = value }}
|
||||
onKeyDown={() => { moved.current = value }}
|
||||
onChange={e => move(e.target.value)}
|
||||
onPointerUp={finish}
|
||||
onKeyUp={finish}
|
||||
<div className="st-swatches">
|
||||
{SWATCHES.map(c => (
|
||||
<button type="button"
|
||||
key={c}
|
||||
className={`st-swatch ${value.toLowerCase() === c ? 'is-on' : ''}`}
|
||||
style={{ background: c }} // theme-lint-disable — a colour picker shows the colours themselves
|
||||
onClick={() => onPick(c)}
|
||||
aria-label={c}
|
||||
/>
|
||||
</div>
|
||||
<span className="st-alpha-value">{pct}%</span>
|
||||
))}
|
||||
{/* Anything at all, for a book that has to match a cover or a brand. */}
|
||||
<label className="st-swatch st-swatch-custom">
|
||||
{/* Named by the colour it holds, the way the preset swatches above are. */}
|
||||
<input type="color" aria-label={value} value={value} onChange={e => onPick(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useRef, type KeyboardEvent, type PointerEvent } from 'react';
|
||||
import { dialAngle, normAngle } from './paintEdit';
|
||||
|
||||
/**
|
||||
* An angle, set by turning a dial.
|
||||
*
|
||||
* A number field alone asks people to know that a gradient running to the
|
||||
* bottom right is 135 degrees; a dial lets them point at it. It snaps to the
|
||||
* eight compass angles when the pointer passes close to one (Alt turns the
|
||||
* snap off), and takes the arrow keys, Shift for steps of fifteen.
|
||||
*
|
||||
* `done` is false while the dial is turned and true on the value it is let go
|
||||
* on, so the turn is one undo step (usePaintChannel.ts).
|
||||
*/
|
||||
export function AngleDial({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (angle: number, done: boolean) => void;
|
||||
label: string;
|
||||
}) {
|
||||
const dial = useRef<HTMLDivElement>(null);
|
||||
const dragging = useRef(false);
|
||||
const last = useRef(value);
|
||||
|
||||
const read = (e: PointerEvent<HTMLDivElement>) => {
|
||||
const box = dial.current?.getBoundingClientRect();
|
||||
if (!box) return last.current;
|
||||
return dialAngle(e.clientX - (box.left + box.width / 2), e.clientY - (box.top + box.height / 2), e.altKey);
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
dragging.current = true;
|
||||
last.current = read(e);
|
||||
onChange(last.current, false);
|
||||
};
|
||||
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragging.current) return;
|
||||
const a = read(e);
|
||||
if (a === last.current) return;
|
||||
last.current = a;
|
||||
onChange(a, false);
|
||||
};
|
||||
const onPointerUp = () => {
|
||||
if (!dragging.current) return;
|
||||
dragging.current = false;
|
||||
onChange(last.current, true);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const step = e.shiftKey ? 15 : 1;
|
||||
const by =
|
||||
e.key === 'ArrowRight' || e.key === 'ArrowUp' ? step : e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -step : 0;
|
||||
if (!by) return;
|
||||
e.preventDefault();
|
||||
last.current = normAngle(value + by);
|
||||
onChange(last.current, false);
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key.startsWith('Arrow')) onChange(last.current, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dial}
|
||||
className="st-dial"
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label={label}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={359}
|
||||
aria-valuenow={normAngle(value)}
|
||||
aria-valuetext={`${normAngle(value)}°`}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
>
|
||||
<span className="st-dial-needle" style={{ transform: `rotate(${normAngle(value)}deg)` }} aria-hidden>
|
||||
<span className="st-dial-knob" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import type { BookGradientPaint } from '@trek/shared';
|
||||
import { useRef, useState, type KeyboardEvent, type PointerEvent } from 'react';
|
||||
import { addStop, cssStops, removeStop, updateStop } from './paintEdit';
|
||||
|
||||
/**
|
||||
* The stops of a gradient, on a bar that shows the gradient itself.
|
||||
*
|
||||
* Click the bar to add a stop there, in the colour the gradient already has
|
||||
* at that point, so adding one changes nothing until it is moved or recoloured.
|
||||
* Drag a stop along the bar to move it, or pull it off the bar downwards
|
||||
* (it fades) and let go to take it out; the arrow keys move the chosen one, Delete removes it. A
|
||||
* gradient keeps at least two stops, so the last two cannot be pulled off.
|
||||
*
|
||||
* The bar runs left to right in every language, like the gradient preview
|
||||
* it carries, so it sets its own direction (studio-paint.css).
|
||||
*/
|
||||
|
||||
/** How far below the bar a stop is pulled before letting go removes it, in pixels. */
|
||||
const PULL_OFF = 28;
|
||||
|
||||
export function GradientStopBar({
|
||||
paint,
|
||||
selected,
|
||||
onSelect,
|
||||
onChange,
|
||||
labels,
|
||||
}: {
|
||||
paint: BookGradientPaint;
|
||||
selected: number;
|
||||
onSelect: (index: number) => void;
|
||||
onChange: (paint: BookGradientPaint, done: boolean) => void;
|
||||
labels: { bar: string; stop: (pos: number) => string };
|
||||
}) {
|
||||
const bar = useRef<HTMLDivElement>(null);
|
||||
const drag = useRef<{ index: number; moved: boolean; off: boolean; paint: BookGradientPaint } | null>(null);
|
||||
// The stop being pulled off, drawn faded until it is let go, when it goes.
|
||||
const [leaving, setLeaving] = useState<number | null>(null);
|
||||
const canRemove = paint.stops.length > 2;
|
||||
|
||||
const posOf = (clientX: number) => {
|
||||
const box = bar.current?.getBoundingClientRect();
|
||||
if (!box || box.width <= 0) return 0;
|
||||
return (clientX - box.left) / box.width;
|
||||
};
|
||||
|
||||
const onBarDown = (e: PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0 || e.target !== e.currentTarget) return;
|
||||
const added = addStop(paint, posOf(e.clientX));
|
||||
if (!added) return;
|
||||
onSelect(added.index);
|
||||
onChange(added.paint, true);
|
||||
};
|
||||
|
||||
const onStopDown = (index: number) => (e: PointerEvent<HTMLButtonElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
onSelect(index);
|
||||
drag.current = { index, moved: false, off: false, paint };
|
||||
};
|
||||
|
||||
const onStopMove = (e: PointerEvent<HTMLButtonElement>) => {
|
||||
const d = drag.current;
|
||||
if (!d) return;
|
||||
const box = bar.current?.getBoundingClientRect();
|
||||
const off = canRemove && !!box && e.clientY > box.bottom + PULL_OFF;
|
||||
const next = updateStop(d.paint, d.index, { at: posOf(e.clientX) });
|
||||
drag.current = { ...d, moved: true, off, paint: next };
|
||||
setLeaving(off ? d.index : null);
|
||||
onChange(next, false);
|
||||
};
|
||||
|
||||
const onStopUp = () => {
|
||||
const d = drag.current;
|
||||
drag.current = null;
|
||||
setLeaving(null);
|
||||
if (!d?.moved) return;
|
||||
if (d.off) {
|
||||
onSelect(Math.max(0, d.index - 1));
|
||||
onChange(removeStop(d.paint, d.index), true);
|
||||
return;
|
||||
}
|
||||
onChange(d.paint, true);
|
||||
};
|
||||
|
||||
const onStopKey = (index: number) => (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
const stop = paint.stops[index];
|
||||
if (!stop) return;
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && canRemove) {
|
||||
e.preventDefault();
|
||||
onSelect(Math.max(0, index - 1));
|
||||
onChange(removeStop(paint, index), true);
|
||||
return;
|
||||
}
|
||||
const step = e.shiftKey ? 0.1 : 0.01;
|
||||
const by = e.key === 'ArrowRight' ? step : e.key === 'ArrowLeft' ? -step : 0;
|
||||
if (!by) return;
|
||||
e.preventDefault();
|
||||
onChange(updateStop(paint, index, { at: stop.at + by }), true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="st-stopbar-wrap">
|
||||
<div ref={bar} className="st-stopbar" title={labels.bar} aria-label={labels.bar} onPointerDown={onBarDown}>
|
||||
<span
|
||||
className="st-stopbar-fill"
|
||||
style={{ backgroundImage: `linear-gradient(90deg, ${cssStops(paint.stops)})` }}
|
||||
aria-hidden
|
||||
/>
|
||||
{paint.stops.map((stop, i) => (
|
||||
<button
|
||||
type="button"
|
||||
// The index is the stop: two stops may share a colour and a position.
|
||||
key={i}
|
||||
className={`st-stop ${i === selected ? 'is-on' : ''} ${leaving === i ? 'is-leaving' : ''}`}
|
||||
style={{ ['--at' as string]: `${Math.round(stop.at * 1000) / 10}%`, ['--stop' as string]: stop.color }}
|
||||
aria-label={labels.stop(Math.round(stop.at * 100))}
|
||||
aria-pressed={i === selected}
|
||||
onPointerDown={onStopDown(i)}
|
||||
onPointerMove={onStopMove}
|
||||
onPointerUp={onStopUp}
|
||||
onPointerCancel={onStopUp}
|
||||
onKeyDown={onStopKey(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { BookPaint, BookShapeElement } from '@trek/shared';
|
||||
import { useMemo, type CSSProperties } from 'react';
|
||||
import { HOLED_SHAPES } from '../shapes';
|
||||
import { shapeOutline, strokeDash } from './paintGeometry';
|
||||
import { PaintedPath } from './paintRender';
|
||||
|
||||
/**
|
||||
* A shape that carries a paint, drawn wholly in SVG.
|
||||
*
|
||||
* The shapes from before paint stay as SpreadView draws them, the rectangle
|
||||
* and the ellipse as CSS boxes, so no book that exists changes by a pixel.
|
||||
* A painted shape is one path whatever its kind: a gradient and a pattern
|
||||
* need an outline to lie over and to be cut to, and a rectangle with a radius
|
||||
* is as much an outline as a heart is.
|
||||
*
|
||||
* As in SpreadView, the path is drawn into the frame inset by half the
|
||||
* stroke, so the whole stroke sits inside the frame the editor snapped.
|
||||
*/
|
||||
|
||||
export function PaintedShapeView({
|
||||
el,
|
||||
paint,
|
||||
frameStyle,
|
||||
}: {
|
||||
el: BookShapeElement;
|
||||
paint: BookPaint;
|
||||
frameStyle: CSSProperties;
|
||||
}) {
|
||||
const sw = el.stroke ? el.strokeWidth : 0;
|
||||
const w = Math.max(0.01, el.frame.w - sw);
|
||||
const h = Math.max(0.01, el.frame.h - sw);
|
||||
const d = useMemo(() => shapeOutline(el, w, h, sw / 2), [el, w, h, sw]);
|
||||
const box = useMemo(() => ({ x: 0, y: 0, w, h }), [w, h]);
|
||||
|
||||
return (
|
||||
<div style={frameStyle}>
|
||||
<svg
|
||||
width={`${el.frame.w}mm`}
|
||||
height={`${el.frame.h}mm`}
|
||||
viewBox={`0 0 ${el.frame.w} ${el.frame.h}`}
|
||||
style={{ display: 'block', overflow: 'visible' }}
|
||||
>
|
||||
<g transform={sw ? `translate(${sw / 2} ${sw / 2})` : undefined}>
|
||||
<PaintedPath
|
||||
d={d}
|
||||
paint={paint}
|
||||
box={box}
|
||||
idPrefix={el.id}
|
||||
fillRule={HOLED_SHAPES.has(el.shape) ? 'evenodd' : undefined}
|
||||
stroke={el.stroke ?? undefined}
|
||||
strokeWidth={sw || undefined}
|
||||
strokeDasharray={strokeDash(el.strokeStyle, sw)}
|
||||
strokeLinecap={el.strokeStyle === 'dotted' ? 'round' : undefined}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { BOOK_PATTERNS, type BookPattern, type BookPatternPaint } from '@trek/shared';
|
||||
import { useMemo } from 'react';
|
||||
import { PATTERN_ANGLE } from './paintEdit';
|
||||
import { patternGeometry } from './patterns';
|
||||
|
||||
/**
|
||||
* The twelve repeats, each drawn as a tile in the paint's own colours.
|
||||
*
|
||||
* Drawn by the same generator the page uses, at a smaller scale, so a tile
|
||||
* is a true preview of what choosing it does rather than an icon of it, and
|
||||
* a repeat that looks wrong in the tile looks wrong on the page too.
|
||||
*/
|
||||
|
||||
const TILE = 24;
|
||||
|
||||
function PatternTile({ pattern, fg, bg }: { pattern: BookPattern; fg: string; bg: string | null }) {
|
||||
const geo = useMemo(
|
||||
() =>
|
||||
patternGeometry({ pattern, scale: 0.42, angle: PATTERN_ANGLE[pattern] ?? 0 }, { x: 0, y: 0, w: TILE, h: TILE }),
|
||||
[pattern]
|
||||
);
|
||||
return (
|
||||
<svg viewBox={`0 0 ${TILE} ${TILE}`} className="st-pattern-tile-art" aria-hidden>
|
||||
{bg && <rect width={TILE} height={TILE} fill={bg} />}
|
||||
<g transform={geo.transform}>
|
||||
{geo.layers.map((layer, i) => (
|
||||
<path key={i} d={layer.d} fill={fg} fillOpacity={layer.opacity < 1 ? layer.opacity : undefined} />
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PatternSwatches({
|
||||
value,
|
||||
onPick,
|
||||
label,
|
||||
}: {
|
||||
value: BookPatternPaint;
|
||||
onPick: (pattern: BookPattern) => void;
|
||||
label: (pattern: BookPattern) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="st-pattern-grid" role="radiogroup">
|
||||
{BOOK_PATTERNS.map((pattern) => (
|
||||
<button
|
||||
type="button"
|
||||
key={pattern}
|
||||
role="radio"
|
||||
aria-checked={value.pattern === pattern}
|
||||
aria-label={label(pattern)}
|
||||
title={label(pattern)}
|
||||
className={`st-pattern-tile ${value.pattern === pattern ? 'is-on' : ''} ${value.bg ? '' : 'is-clear'}`}
|
||||
onClick={() => onPick(pattern)}
|
||||
>
|
||||
<PatternTile pattern={pattern} fg={value.fg} bg={value.bg} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import type { BookGradientPaint } from '@trek/shared';
|
||||
import { useRef, type KeyboardEvent, type PointerEvent } from 'react';
|
||||
import { clampUnit, cssStops } from './paintEdit';
|
||||
|
||||
/**
|
||||
* Where a radial gradient starts: a small preview of it with its centre on
|
||||
* it, to drag. The preview is the gradient itself, so the point being moved
|
||||
* is the bright spot or the dark one people are actually placing.
|
||||
*
|
||||
* Like the stop bar it is a picture, laid out left to right in every
|
||||
* language (studio-paint.css), with the arrow keys for a step of 2 %.
|
||||
*/
|
||||
export function RadialPad({
|
||||
paint,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
paint: Extract<BookGradientPaint, { type: 'radial' }>;
|
||||
onChange: (paint: BookGradientPaint, done: boolean) => void;
|
||||
label: string;
|
||||
}) {
|
||||
const pad = useRef<HTMLDivElement>(null);
|
||||
const dragging = useRef(false);
|
||||
const last = useRef(paint);
|
||||
|
||||
const at = (e: PointerEvent<HTMLDivElement>) => {
|
||||
const box = pad.current?.getBoundingClientRect();
|
||||
if (!box || box.width <= 0 || box.height <= 0) return paint;
|
||||
return {
|
||||
...paint,
|
||||
cx: clampUnit((e.clientX - box.left) / box.width),
|
||||
cy: clampUnit((e.clientY - box.top) / box.height),
|
||||
};
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
dragging.current = true;
|
||||
last.current = at(e);
|
||||
onChange(last.current, false);
|
||||
};
|
||||
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragging.current) return;
|
||||
last.current = at(e);
|
||||
onChange(last.current, false);
|
||||
};
|
||||
const onPointerUp = () => {
|
||||
if (!dragging.current) return;
|
||||
dragging.current = false;
|
||||
onChange(last.current, true);
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const dx = e.key === 'ArrowRight' ? 0.02 : e.key === 'ArrowLeft' ? -0.02 : 0;
|
||||
const dy = e.key === 'ArrowDown' ? 0.02 : e.key === 'ArrowUp' ? -0.02 : 0;
|
||||
if (!dx && !dy) return;
|
||||
e.preventDefault();
|
||||
onChange({ ...paint, cx: clampUnit(paint.cx + dx), cy: clampUnit(paint.cy + dy) }, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pad}
|
||||
className="st-radial-pad"
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label={label}
|
||||
aria-valuetext={`${Math.round(paint.cx * 100)}% ${Math.round(paint.cy * 100)}%`}
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at ${paint.cx * 100}% ${paint.cy * 100}%, ${cssStops(paint.stops)})`,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<span
|
||||
className="st-radial-dot"
|
||||
style={{ ['--cx' as string]: `${paint.cx * 100}%`, ['--cy' as string]: `${paint.cy * 100}%` }}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { bookColours, type BookDocument, type BookSpread, type PaletteSwatch } from '@trek/shared';
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useStudioStore } from '../../../store/studioStore';
|
||||
import { photoSrc } from '../bookRender';
|
||||
import {
|
||||
StudioPaletteContext,
|
||||
readRecent,
|
||||
spreadPhotoIds,
|
||||
withRecent,
|
||||
writeRecent,
|
||||
type StudioPaletteValue,
|
||||
} from './paletteModel';
|
||||
import { photoSwatches, readPhotoPalette } from './photoPalette';
|
||||
|
||||
/**
|
||||
* The colours every swatch row in the inspector offers beside its fixed inks:
|
||||
* the book's own, the ones in the page's photographs, and the ones this
|
||||
* person picked lately.
|
||||
*
|
||||
* A context rather than props, because the swatch rows sit at every depth of
|
||||
* the inspector, the travel elements' panels included, and each of them
|
||||
* should offer the same three rows without every caller passing them down.
|
||||
*/
|
||||
|
||||
/** The palettes of the page's photos, read once per photo for as long as the inspector is open. */
|
||||
function usePhotoColours(photoIds: readonly number[]): string[] {
|
||||
const [read, setRead] = useState<ReadonlyMap<number, PaletteSwatch[]>>(() => new Map());
|
||||
// The ids travel as one string, so a new array holding the same photos reads nothing again.
|
||||
const key = photoIds.join(',');
|
||||
|
||||
useEffect(() => {
|
||||
const ids = key ? key.split(',').map(Number) : [];
|
||||
const missing = ids.filter((id) => !read.has(id));
|
||||
if (!missing.length) return;
|
||||
const ctrl = new AbortController();
|
||||
void Promise.all(
|
||||
missing.map(async (id) => [id, await readPhotoPalette(photoSrc(id, false), ctrl.signal)] as const)
|
||||
).then((found) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
setRead((prev) => new Map([...prev, ...found]));
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
}, [key, read]);
|
||||
|
||||
return useMemo(() => {
|
||||
const ids = key ? key.split(',').map(Number) : [];
|
||||
return photoSwatches(ids.map((id) => read.get(id) ?? []));
|
||||
}, [key, read]);
|
||||
}
|
||||
|
||||
export function StudioPaletteProvider({
|
||||
doc,
|
||||
spread,
|
||||
labels,
|
||||
children,
|
||||
}: {
|
||||
doc: BookDocument | null;
|
||||
spread: BookSpread | undefined;
|
||||
labels: StudioPaletteValue['labels'];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const book = useMemo(() => bookColours(doc), [doc]);
|
||||
const photos = usePhotoColours(useMemo(() => spreadPhotoIds(spread), [spread]));
|
||||
const [recent, setRecent] = useState(readRecent);
|
||||
const remember = useCallback((hex: string) => {
|
||||
setRecent((list) => {
|
||||
const next = withRecent(list, hex);
|
||||
writeRecent(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const value = useMemo(() => ({ book, photos, recent, remember, labels }), [book, photos, recent, remember, labels]);
|
||||
return <StudioPaletteContext.Provider value={value}>{children}</StudioPaletteContext.Provider>;
|
||||
}
|
||||
|
||||
/** The provider for the open book: the inspector of the page being edited sits inside it. */
|
||||
export function StudioPaletteScope({
|
||||
spreadIndex,
|
||||
t,
|
||||
children,
|
||||
}: {
|
||||
spreadIndex: number;
|
||||
t: (k: string) => string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const doc = useStudioStore((s) => s.doc);
|
||||
const labels = useMemo(
|
||||
() => ({
|
||||
book: t('journey.studio.paint.bookColours'),
|
||||
photos: t('journey.studio.paint.photoColours'),
|
||||
recent: t('journey.studio.paint.recentColours'),
|
||||
opacity: t('journey.studio.paint.opacity'),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
return (
|
||||
<StudioPaletteProvider doc={doc} spread={doc?.spreads[spreadIndex]} labels={labels}>
|
||||
{children}
|
||||
</StudioPaletteProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import {
|
||||
MAX_SPREAD_ELEMENTS,
|
||||
bookPhotoElementSchema,
|
||||
spreadPaint,
|
||||
withSpreadPaint,
|
||||
type BookDocument,
|
||||
type BookElement,
|
||||
type BookPageSetup,
|
||||
type BookPhotoElement,
|
||||
type BookSpread,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The page background's two moves beyond its paint: a photo laid under the
|
||||
* whole page, and one page's background given to every page.
|
||||
*
|
||||
* A background photo is an ordinary photo element, not a field of the page:
|
||||
* full bleed across the spread, at the back, and locked, so it does not come
|
||||
* away under a click meant for what is on top of it. Being an element, it
|
||||
* prints, crops, filters and undoes like every other picture, and the
|
||||
* assistant reaches it with the operations it already has (add, fill, lock).
|
||||
*/
|
||||
|
||||
/** The frame of a photo that covers the whole sheet, bleed included. */
|
||||
export function bleedFrame(spread: BookSpread, page: BookPageSetup) {
|
||||
const pages = spread.role === 'inner' ? 2 : 1;
|
||||
return {
|
||||
x: -page.bleed,
|
||||
y: -page.bleed,
|
||||
w: page.pageWidth * pages + page.bleed * 2,
|
||||
h: page.pageHeight + page.bleed * 2,
|
||||
};
|
||||
}
|
||||
|
||||
const near = (a: number, b: number) => Math.abs(a - b) < 0.6;
|
||||
|
||||
/** The page's background photo: the locked picture at the back that covers the whole sheet. Null when it has none. */
|
||||
export function backgroundPhotoOf(spread: BookSpread | undefined, page: BookPageSetup): BookPhotoElement | null {
|
||||
const back = spread?.elements[0];
|
||||
if (!spread || !back || back.kind !== 'photo' || !back.locked || back.photoId == null) return null;
|
||||
const want = bleedFrame(spread, page);
|
||||
const f = back.frame;
|
||||
return near(f.x, want.x) && near(f.y, want.y) && near(f.w, want.w) && near(f.h, want.h) && !back.rotation
|
||||
? back
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page with `photoId` as its background: the one it had is replaced, a
|
||||
* new one goes in at the back. Null when the page is already full.
|
||||
*/
|
||||
export function withBackgroundPhoto(
|
||||
spread: BookSpread,
|
||||
page: BookPageSetup,
|
||||
photoId: number,
|
||||
newId: () => string
|
||||
): BookSpread | null {
|
||||
const old = backgroundPhotoOf(spread, page);
|
||||
const rest = old ? spread.elements.slice(1) : spread.elements;
|
||||
if (rest.length >= MAX_SPREAD_ELEMENTS) return null;
|
||||
const photo = bookPhotoElementSchema.parse({
|
||||
id: old?.id ?? newId(),
|
||||
kind: 'photo',
|
||||
frame: bleedFrame(spread, page),
|
||||
locked: true,
|
||||
photoId,
|
||||
fit: 'cover',
|
||||
});
|
||||
return { ...spread, elements: [photo as BookElement, ...rest] };
|
||||
}
|
||||
|
||||
/** The page without its background photo. */
|
||||
export function withoutBackgroundPhoto(spread: BookSpread, page: BookPageSetup): BookSpread {
|
||||
return backgroundPhotoOf(spread, page) ? { ...spread, elements: spread.elements.slice(1) } : spread;
|
||||
}
|
||||
|
||||
const isCover = (spread: BookSpread) => spread.role === 'cover' || spread.role === 'back';
|
||||
|
||||
/**
|
||||
* Every page given the background of page `from`: its paint and colour,
|
||||
* nothing else. "Every page" is every page of its kind: an inside page
|
||||
* gives its background to the inside of the book, a cover to the covers,
|
||||
* since a cover is designed on its own and an inside page's paper is rarely
|
||||
* what the cover wants.
|
||||
*/
|
||||
export function backgroundToAll(doc: BookDocument, from: number): BookDocument {
|
||||
const source = doc.spreads[from];
|
||||
if (!source) return doc;
|
||||
const paint = spreadPaint(source);
|
||||
const kind = isCover(source);
|
||||
return {
|
||||
...doc,
|
||||
spreads: doc.spreads.map((sp, i) => (i === from || isCover(sp) !== kind ? sp : withSpreadPaint(sp, paint))),
|
||||
};
|
||||
}
|
||||
|
||||
/** The pictures offered as a background: the page's own first, then its entry's, then the gallery's. */
|
||||
export function backgroundCandidates(
|
||||
spreadPhotos: readonly number[],
|
||||
entryPhotos: readonly number[],
|
||||
gallery: readonly number[],
|
||||
limit = 8
|
||||
): number[] {
|
||||
return [...new Set([...spreadPhotos, ...entryPhotos, ...gallery])].slice(0, limit);
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
import {
|
||||
MAX_PAINT_STOPS,
|
||||
PATTERN_SCALE,
|
||||
RADIAL_RADIUS,
|
||||
paintColours,
|
||||
parseHex,
|
||||
sortedStops,
|
||||
stopAlpha,
|
||||
toHex,
|
||||
type BookGradientPaint,
|
||||
type BookPaint,
|
||||
type BookPaintStop,
|
||||
type BookPattern,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The arithmetic of the paint picker, kept out of the components so it can
|
||||
* be tested on its own: switching between kinds without losing the colour,
|
||||
* stops added where they are clicked in the colour already there, angles
|
||||
* read off a pointer and snapped to the ones people mean.
|
||||
*/
|
||||
|
||||
export type PaintKind = 'none' | 'solid' | 'gradient' | 'pattern';
|
||||
|
||||
const WHITE = '#ffffff';
|
||||
const INK = '#111111';
|
||||
|
||||
export const kindOf = (paint: BookPaint | null): PaintKind =>
|
||||
!paint ? 'none' : paint.type === 'linear' || paint.type === 'radial' ? 'gradient' : paint.type;
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
||||
const r3 = (v: number) => Math.round(v * 1000) / 1000;
|
||||
|
||||
/** Two colours mixed, `t` of the way from `a` to `b`. */
|
||||
export function mixHex(a: string, b: string, t: number): string {
|
||||
const ca = parseHex(a);
|
||||
const cb = parseHex(b);
|
||||
if (!ca || !cb) return a.toLowerCase();
|
||||
const m = (x: number, y: number) => x + (y - x) * t;
|
||||
return toHex({ r: m(ca.r, cb.r), g: m(ca.g, cb.g), b: m(ca.b, cb.b) });
|
||||
}
|
||||
|
||||
/** The colour a paint leads with: what a solid keeps when it becomes a gradient, and back. */
|
||||
export function leadColour(paint: BookPaint | null, fallback = INK): string {
|
||||
return paintColours(paint)[0] ?? fallback;
|
||||
}
|
||||
|
||||
/** A lighter partner for a colour, for the second stop of a fresh gradient or a pattern's ground. */
|
||||
export function partnerOf(color: string, t = 0.65): string {
|
||||
const rgb = parseHex(color);
|
||||
if (!rgb) return WHITE;
|
||||
// Light colours get a darker partner, so the gradient never runs white into white.
|
||||
const light = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000 > 200;
|
||||
return mixHex(color, light ? INK : WHITE, light ? 0.35 : t);
|
||||
}
|
||||
|
||||
/** The angle a pattern looks right at when first picked: stripes on the diagonal, the rest square. */
|
||||
export const PATTERN_ANGLE: Partial<Record<BookPattern, number>> = { stripes: 45, pinstripe: 0 };
|
||||
|
||||
/**
|
||||
* The paint of another kind, built from the one there is. `memory` holds the
|
||||
* last paint of each kind the picker showed, so going to gradient and back to
|
||||
* pattern returns the pattern as it was rather than a fresh default.
|
||||
*/
|
||||
export function paintAs(
|
||||
kind: PaintKind,
|
||||
from: BookPaint | null,
|
||||
memory: Partial<Record<PaintKind, BookPaint>> = {}
|
||||
): BookPaint | null {
|
||||
if (kind === 'none') return null;
|
||||
const remembered = memory[kind];
|
||||
if (remembered) return remembered;
|
||||
const lead = leadColour(from);
|
||||
switch (kind) {
|
||||
case 'solid':
|
||||
return { type: 'solid', color: lead };
|
||||
case 'gradient':
|
||||
return {
|
||||
type: 'linear',
|
||||
angle: 180,
|
||||
stops: [
|
||||
{ at: 0, color: lead },
|
||||
{ at: 1, color: partnerOf(lead) },
|
||||
],
|
||||
};
|
||||
case 'pattern':
|
||||
return { type: 'pattern', pattern: 'stripes', fg: lead, bg: partnerOf(lead, 0.82), scale: 1, angle: 45 };
|
||||
}
|
||||
}
|
||||
|
||||
/** The colour a gradient shows at a point, alpha included, for a stop added there. */
|
||||
export function colourAt(stops: readonly BookPaintStop[], at: number): { color: string; alpha: number } {
|
||||
const list = sortedStops(stops);
|
||||
const first = list[0];
|
||||
const last = list[list.length - 1];
|
||||
if (!first || !last) return { color: INK, alpha: 1 };
|
||||
if (at <= first.at) return { color: first.color.toLowerCase(), alpha: stopAlpha(first) };
|
||||
if (at >= last.at) return { color: last.color.toLowerCase(), alpha: stopAlpha(last) };
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
const a = list[i - 1];
|
||||
const b = list[i];
|
||||
if (!a || !b || at > b.at) continue;
|
||||
const t = b.at === a.at ? 0 : (at - a.at) / (b.at - a.at);
|
||||
return { color: mixHex(a.color, b.color, t), alpha: r3(stopAlpha(a) + (stopAlpha(b) - stopAlpha(a)) * t) };
|
||||
}
|
||||
return { color: last.color.toLowerCase(), alpha: stopAlpha(last) };
|
||||
}
|
||||
|
||||
const stopOf = (at: number, color: string, alpha: number): BookPaintStop =>
|
||||
alpha < 1 ? { at, color, alpha } : { at, color };
|
||||
|
||||
/** A stop added at `at` in the colour the gradient already has there. Null when the gradient is full. */
|
||||
export function addStop(paint: BookGradientPaint, at: number): { paint: BookGradientPaint; index: number } | null {
|
||||
if (paint.stops.length >= MAX_PAINT_STOPS) return null;
|
||||
const pos = r3(clamp(at, 0, 1));
|
||||
const { color, alpha } = colourAt(paint.stops, pos);
|
||||
const stops = [...paint.stops, stopOf(pos, color, alpha)];
|
||||
return { paint: { ...paint, stops }, index: stops.length - 1 };
|
||||
}
|
||||
|
||||
/** A gradient without one of its stops. A gradient keeps at least two. */
|
||||
export function removeStop(paint: BookGradientPaint, index: number): BookGradientPaint {
|
||||
if (paint.stops.length <= 2 || index < 0 || index >= paint.stops.length) return paint;
|
||||
return { ...paint, stops: paint.stops.filter((_, i) => i !== index) };
|
||||
}
|
||||
|
||||
/** One stop changed; its position kept between 0 and 1 and to three decimals. */
|
||||
export function updateStop(paint: BookGradientPaint, index: number, patch: Partial<BookPaintStop>): BookGradientPaint {
|
||||
const stops = paint.stops.map((s, i) => {
|
||||
if (i !== index) return s;
|
||||
const next = { ...s, ...patch, at: r3(clamp(patch.at ?? s.at, 0, 1)) };
|
||||
if (next.alpha !== undefined && next.alpha >= 1) delete next.alpha;
|
||||
return next;
|
||||
});
|
||||
return { ...paint, stops };
|
||||
}
|
||||
|
||||
/** The gradient run the other way. */
|
||||
export function reverseStops(paint: BookGradientPaint): BookGradientPaint {
|
||||
return { ...paint, stops: paint.stops.map((s) => ({ ...s, at: r3(1 - s.at) })) };
|
||||
}
|
||||
|
||||
/** Linear or radial, keeping the stops. */
|
||||
export function gradientAs(paint: BookGradientPaint, type: 'linear' | 'radial'): BookGradientPaint {
|
||||
if (paint.type === type) return paint;
|
||||
return type === 'linear'
|
||||
? { type: 'linear', angle: 180, stops: paint.stops }
|
||||
: { type: 'radial', cx: 0.5, cy: 0.5, r: 1, stops: paint.stops };
|
||||
}
|
||||
|
||||
/** A whole-degree angle between 0 and 359. */
|
||||
export function normAngle(deg: number): number {
|
||||
const a = Math.round(deg) % 360;
|
||||
return a < 0 ? a + 360 : a;
|
||||
}
|
||||
|
||||
/**
|
||||
* The angle a pointer at (`dx`, `dy`) from the dial's centre points at, 0 up
|
||||
* and clockwise like the paint itself. Within a few degrees of a multiple of
|
||||
* 45 it lands on it, since those are the angles people mean; `fine` (a held
|
||||
* modifier) turns the snap off.
|
||||
*/
|
||||
export function dialAngle(dx: number, dy: number, fine = false): number {
|
||||
const raw = normAngle((Math.atan2(dx, -dy) * 180) / Math.PI);
|
||||
if (fine) return raw;
|
||||
const near = Math.round(raw / 45) * 45;
|
||||
return Math.abs(raw - near) <= 4 ? normAngle(near) : raw;
|
||||
}
|
||||
|
||||
export const clampScale = (v: number) => r3(clamp(v, PATTERN_SCALE.min, PATTERN_SCALE.max));
|
||||
export const clampRadius = (v: number) => r3(clamp(v, RADIAL_RADIUS.min, RADIAL_RADIUS.max));
|
||||
export const clampUnit = (v: number) => r3(clamp(v, 0, 1));
|
||||
|
||||
/** A CSS gradient for the picker's own previews (the stop bar, the tab swatches): not for the page. */
|
||||
export function cssStops(stops: readonly BookPaintStop[]): string {
|
||||
return sortedStops(stops)
|
||||
.map((s) => {
|
||||
const rgb = parseHex(s.color);
|
||||
const c = rgb ? `rgb(${rgb.r} ${rgb.g} ${rgb.b} / ${stopAlpha(s)})` : s.color;
|
||||
return `${c} ${Math.round(s.at * 1000) / 10}%`;
|
||||
})
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/** A paint as a CSS background, for a preview chip in the picker. */
|
||||
export function cssPaint(paint: BookPaint | null): string | undefined {
|
||||
if (!paint) return undefined;
|
||||
switch (paint.type) {
|
||||
case 'solid': {
|
||||
const rgb = parseHex(paint.color);
|
||||
return rgb ? `rgb(${rgb.r} ${rgb.g} ${rgb.b} / ${stopAlpha(paint)})` : paint.color;
|
||||
}
|
||||
case 'linear':
|
||||
return `linear-gradient(${paint.angle}deg, ${cssStops(paint.stops)})`;
|
||||
case 'radial':
|
||||
return `radial-gradient(circle at ${paint.cx * 100}% ${paint.cy * 100}%, ${cssStops(paint.stops)})`;
|
||||
case 'pattern':
|
||||
return paint.bg ?? undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { BookShapeElement } from '@trek/shared';
|
||||
import { SHAPE_PATHS, scalePath } from '../shapes';
|
||||
import type { PatternBox } from './patterns';
|
||||
|
||||
/** The outlines and lines paint is laid along, in millimetres. */
|
||||
|
||||
/** Hundredths of a millimetre, and never minus zero, which would print as "-0". */
|
||||
export const r2 = (v: number) => Math.round(v * 100) / 100 || 0;
|
||||
|
||||
/**
|
||||
* Where a linear gradient's line runs over a box, the way CSS places it: through
|
||||
* the centre at `angle` (0 towards the top, clockwise), long enough that the
|
||||
* first and last stop land on the box's far corners.
|
||||
*/
|
||||
export function linearLine(angle: number, box: PatternBox): { x1: number; y1: number; x2: number; y2: number } {
|
||||
const a = (angle * Math.PI) / 180;
|
||||
const dx = Math.sin(a);
|
||||
const dy = -Math.cos(a);
|
||||
const half = (Math.abs(box.w * dx) + Math.abs(box.h * dy)) / 2;
|
||||
const cx = box.x + box.w / 2;
|
||||
const cy = box.y + box.h / 2;
|
||||
return { x1: r2(cx - dx * half), y1: r2(cy - dy * half), x2: r2(cx + dx * half), y2: r2(cy + dy * half) };
|
||||
}
|
||||
|
||||
/** A rectangle's outline as path data, with its corners rounded to `radius`. */
|
||||
export function roundedRectPath(w: number, h: number, radius: number): string {
|
||||
const r = Math.max(0, Math.min(radius, w / 2, h / 2));
|
||||
if (r <= 0) return `M0 0H${r2(w)}V${r2(h)}H0Z`;
|
||||
const a = `A${r2(r)} ${r2(r)} 0 0 1`;
|
||||
return (
|
||||
`M${r2(r)} 0H${r2(w - r)}${a} ${r2(w)} ${r2(r)}V${r2(h - r)}${a} ${r2(w - r)} ${r2(h)}` +
|
||||
`H${r2(r)}${a} 0 ${r2(h - r)}V${r2(r)}${a} ${r2(r)} 0Z`
|
||||
);
|
||||
}
|
||||
|
||||
/** An ellipse filling a box, as path data. */
|
||||
export function ellipsePath(w: number, h: number): string {
|
||||
const rx = r2(w / 2);
|
||||
const ry = r2(h / 2);
|
||||
return `M0 ${ry}A${rx} ${ry} 0 1 0 ${r2(w)} ${ry}A${rx} ${ry} 0 1 0 0 ${ry}Z`;
|
||||
}
|
||||
|
||||
/** The dash a stroke style draws, proportional to the stroke so its rhythm holds at any width. */
|
||||
export function strokeDash(style: BookShapeElement['strokeStyle'], sw: number): string | undefined {
|
||||
if (style === 'dashed') return `${sw * 3} ${sw * 2}`;
|
||||
if (style === 'dotted') return `${sw * 0.01} ${sw * 2}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The outline of a shape in a box of `w` × `h` millimetres. */
|
||||
export function shapeOutline(
|
||||
el: Pick<BookShapeElement, 'shape' | 'radius'>,
|
||||
w: number,
|
||||
h: number,
|
||||
inset: number
|
||||
): string {
|
||||
if (el.shape === 'rect') return roundedRectPath(w, h, Math.max(0, el.radius - inset));
|
||||
if (el.shape === 'ellipse') return ellipsePath(w, h);
|
||||
return scalePath(SHAPE_PATHS[el.shape], w, h);
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import { sortedStops, stopAlpha, type BookPaint, type BookPaintStop } from '@trek/shared';
|
||||
import { useMemo, type ReactNode, type SVGProps } from 'react';
|
||||
import { linearLine, r2 } from './paintGeometry';
|
||||
import { patternGeometry, type PatternBox } from './patterns';
|
||||
import { usePaintId } from './usePaintId';
|
||||
|
||||
/**
|
||||
* Paint, drawn: the one renderer for a shape's fill and a page's background.
|
||||
*
|
||||
* Every paint becomes SVG the print path already knows: a fill with
|
||||
* `fill-opacity`, a `linearGradient` or `radialGradient` in user space, or a
|
||||
* pattern drawn out as geometry (patterns.ts) under a `clipPath` in user
|
||||
* space. Nothing here is lazy, measured or raster, so what the editor shows
|
||||
* is what headless Chromium prints.
|
||||
*
|
||||
* ── Why the ids come from useId ──────────────────────────────────────────
|
||||
*
|
||||
* Page mode draws each double spread twice, once per half (bookSheets.ts),
|
||||
* and a thumbnail draws it again beside the canvas. An id taken from the
|
||||
* element alone would then exist twice in one document, and `url(#id)`
|
||||
* resolves to the first, so the second half would borrow the first half's
|
||||
* gradient, and a pattern clipped to the first copy's path would vanish from
|
||||
* the second. React's useId is unique per mounted instance and the same on
|
||||
* every render of it, which is exactly the property a defs id needs; the
|
||||
* element's own id is kept in front only to make the markup readable.
|
||||
*/
|
||||
|
||||
function Stops({ stops }: { stops: readonly BookPaintStop[] }) {
|
||||
return (
|
||||
<>
|
||||
{sortedStops(stops).map((s, i) => (
|
||||
<stop
|
||||
// Two stops may share an offset (a hard edge), so the index keys them.
|
||||
key={i}
|
||||
offset={r2(s.at)}
|
||||
stopColor={s.color}
|
||||
stopOpacity={stopAlpha(s) < 1 ? stopAlpha(s) : undefined}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a painted outline needs: the defs to put in the SVG, and how to fill the outline. */
|
||||
interface PaintParts {
|
||||
defs: ReactNode;
|
||||
fill: string;
|
||||
fillOpacity?: number;
|
||||
/** A pattern's geometry, to draw clipped to the outline over its fill. */
|
||||
pattern: { clipId: string; layers: { d: string; opacity: number }[]; transform?: string; ink: string } | null;
|
||||
}
|
||||
|
||||
function paintParts(paint: BookPaint, box: PatternBox, id: string): PaintParts {
|
||||
switch (paint.type) {
|
||||
case 'solid':
|
||||
return {
|
||||
defs: null,
|
||||
fill: paint.color,
|
||||
fillOpacity: stopAlpha(paint) < 1 ? stopAlpha(paint) : undefined,
|
||||
pattern: null,
|
||||
};
|
||||
case 'linear': {
|
||||
const line = linearLine(paint.angle, box);
|
||||
return {
|
||||
defs: (
|
||||
<linearGradient id={id} gradientUnits="userSpaceOnUse" {...line}>
|
||||
<Stops stops={paint.stops} />
|
||||
</linearGradient>
|
||||
),
|
||||
fill: `url(#${id})`,
|
||||
pattern: null,
|
||||
};
|
||||
}
|
||||
case 'radial':
|
||||
return {
|
||||
defs: (
|
||||
<radialGradient
|
||||
id={id}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx={r2(box.x + paint.cx * box.w)}
|
||||
cy={r2(box.y + paint.cy * box.h)}
|
||||
r={r2((paint.r * Math.hypot(box.w, box.h)) / 2)}
|
||||
>
|
||||
<Stops stops={paint.stops} />
|
||||
</radialGradient>
|
||||
),
|
||||
fill: `url(#${id})`,
|
||||
pattern: null,
|
||||
};
|
||||
case 'pattern': {
|
||||
const geo = patternGeometry(paint, box);
|
||||
return {
|
||||
defs: null,
|
||||
fill: paint.bg ?? 'none',
|
||||
pattern: { clipId: `${id}-clip`, layers: geo.layers, transform: geo.transform, ink: paint.fg },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type OutlineProps = Omit<SVGProps<SVGPathElement>, 'fill' | 'd'>;
|
||||
|
||||
/**
|
||||
* An outline filled with a paint, inside an `<svg>` whose user units are
|
||||
* millimetres. `box` is the outline's box in that space, which a gradient and
|
||||
* a pattern are laid over. The stroke, when the outline has one, is drawn on
|
||||
* top of a pattern rather than under it, so the edge stays crisp.
|
||||
*/
|
||||
export function PaintedPath({
|
||||
d,
|
||||
paint,
|
||||
box,
|
||||
idPrefix,
|
||||
fillRule,
|
||||
stroke,
|
||||
...rest
|
||||
}: {
|
||||
d: string;
|
||||
paint: BookPaint | null;
|
||||
box: PatternBox;
|
||||
/** The element's id, to make the markup readable; uniqueness comes from useId. */
|
||||
idPrefix: string;
|
||||
fillRule?: 'evenodd' | 'nonzero';
|
||||
} & OutlineProps) {
|
||||
const id = usePaintId(`p-${idPrefix}`);
|
||||
// The caller holds `box` steady between renders (a memo of the frame), so a re-render
|
||||
// that moved nothing does not lay the pattern out again.
|
||||
const parts = useMemo(() => (paint ? paintParts(paint, box, id) : null), [paint, box, id]);
|
||||
const outline = { d, fillRule, clipRule: fillRule };
|
||||
|
||||
if (!parts) return <path {...outline} fill="none" stroke={stroke} {...rest} />;
|
||||
|
||||
const pattern = parts.pattern;
|
||||
return (
|
||||
<>
|
||||
{(parts.defs || pattern) && (
|
||||
<defs>
|
||||
{parts.defs}
|
||||
{pattern && (
|
||||
<clipPath id={pattern.clipId} clipPathUnits="userSpaceOnUse">
|
||||
<path {...outline} />
|
||||
</clipPath>
|
||||
)}
|
||||
</defs>
|
||||
)}
|
||||
<path
|
||||
{...outline}
|
||||
fill={parts.fill}
|
||||
fillOpacity={parts.fillOpacity}
|
||||
stroke={pattern ? undefined : stroke}
|
||||
{...(pattern ? {} : rest)}
|
||||
/>
|
||||
{pattern && (
|
||||
<>
|
||||
<g clipPath={`url(#${pattern.clipId})`}>
|
||||
<g transform={pattern.transform}>
|
||||
{pattern.layers.map((layer, i) => (
|
||||
<path
|
||||
key={i}
|
||||
d={layer.d}
|
||||
fill={pattern.ink}
|
||||
fillOpacity={layer.opacity < 1 ? layer.opacity : undefined}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</g>
|
||||
{stroke && <path {...outline} fill="none" stroke={stroke} {...rest} />}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A page's paint, drawn under everything on it and out into the bleed.
|
||||
*
|
||||
* Positioned in the spread's own millimetres from minus the bleed, so print,
|
||||
* whose window shows the bleed, gets paint to the cut, and the editor, which
|
||||
* clips at the trim, shows the same paint up to it.
|
||||
*/
|
||||
export function SpreadPaint({
|
||||
paint,
|
||||
width,
|
||||
height,
|
||||
bleed,
|
||||
idPrefix,
|
||||
}: {
|
||||
paint: BookPaint;
|
||||
width: number;
|
||||
height: number;
|
||||
bleed: number;
|
||||
idPrefix: string;
|
||||
}) {
|
||||
const w = r2(width + bleed * 2);
|
||||
const h = r2(height + bleed * 2);
|
||||
const box = useMemo(() => ({ x: 0, y: 0, w, h }), [w, h]);
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
width={`${w}mm`}
|
||||
height={`${h}mm`}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
// A page is laid out left to right in every language; its own direction makes the start its left edge.
|
||||
style={{
|
||||
position: 'absolute',
|
||||
direction: 'ltr',
|
||||
insetInlineStart: `${-bleed}mm`,
|
||||
top: `${-bleed}mm`,
|
||||
display: 'block',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<PaintedPath d={`M0 0H${w}V${h}H0Z`} paint={paint} box={box} idPrefix={`bg-${idPrefix}`} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { BookSpread } from '@trek/shared';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
/** The palette rows' state and the pure parts of it (StudioPalette.tsx holds the provider). */
|
||||
|
||||
export interface StudioPaletteValue {
|
||||
/** The colours the book is drawn in, most used first. */
|
||||
book: string[];
|
||||
/** The colours of the photos on the page being edited. */
|
||||
photos: string[];
|
||||
/** What this person picked lately, newest first. */
|
||||
recent: string[];
|
||||
remember: (hex: string) => void;
|
||||
/** The labels the rows carry, in the reader's language. */
|
||||
labels: { book: string; photos: string; recent: string; opacity: string };
|
||||
}
|
||||
|
||||
export const StudioPaletteContext = createContext<StudioPaletteValue | null>(null);
|
||||
|
||||
/** The palette rows, or null outside a provider (a swatch row then shows its fixed inks only). */
|
||||
export function useStudioPalette(): StudioPaletteValue | null {
|
||||
return useContext(StudioPaletteContext);
|
||||
}
|
||||
|
||||
export const RECENT_KEY = 'trek.studio.recentColours';
|
||||
export const MAX_RECENT = 8;
|
||||
|
||||
/** The recent colours as stored. Storage can be missing or refuse; that is an empty list, not an error. */
|
||||
export function readRecent(): string[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(RECENT_KEY);
|
||||
const list: unknown = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(list)
|
||||
? list.filter((c): c is string => typeof c === 'string' && /^#[0-9a-f]{6}$/.test(c)).slice(0, MAX_RECENT)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** A colour put first in the recent list, which keeps each colour once. */
|
||||
export function withRecent(list: readonly string[], hex: string): string[] {
|
||||
const c = hex.toLowerCase();
|
||||
return [c, ...list.filter((x) => x !== c)].slice(0, MAX_RECENT);
|
||||
}
|
||||
|
||||
export function writeRecent(list: readonly string[]) {
|
||||
try {
|
||||
window.localStorage.setItem(RECENT_KEY, JSON.stringify(list));
|
||||
} catch {
|
||||
// A private window or a full quota: the list lives for this session only.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** The photo ids a page shows, placed or parked, in paint order. */
|
||||
export function spreadPhotoIds(spread: BookSpread | undefined): number[] {
|
||||
if (!spread) return [];
|
||||
const ids = [...spread.elements, ...spread.parked].flatMap((e) =>
|
||||
e.kind === 'photo' && e.photoId != null ? [e.photoId] : []
|
||||
);
|
||||
return [...new Set(ids)].slice(0, 8);
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
import type { BookPattern } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The twelve repeats a pattern paint draws, as plain geometry.
|
||||
*
|
||||
* Not SVG `<pattern>`: a pattern tile has never been through this print path,
|
||||
* and a RIP that rasterises tiles at its own resolution prints seams. Each
|
||||
* repeat is drawn out in full instead, as a handful of `<path>` elements whose
|
||||
* `d` holds every primitive, and clipped to the shape by the caller. The DOM
|
||||
* stays a few nodes per element whatever the scale.
|
||||
*
|
||||
* Everything is in millimetres in the caller's user space, rounded to
|
||||
* hundredths as the document stores its own numbers, and every repeat is
|
||||
* capped at `MAX_PATTERN_PRIMITIVES`: past that the cell grows rather than the
|
||||
* page, so a tiny scale on a whole spread costs no more than it looks worth.
|
||||
*/
|
||||
|
||||
export const MAX_PATTERN_PRIMITIVES = 2000;
|
||||
/** The cell of a pattern at scale 1. */
|
||||
export const PATTERN_CELL_MM = 8;
|
||||
|
||||
export interface PatternBox {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** One colour layer: every primitive of it in one path, at a share of the ink's opacity. */
|
||||
interface PatternLayer {
|
||||
d: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
interface PatternGeometry {
|
||||
layers: PatternLayer[];
|
||||
/** How many primitives the layers hold together. */
|
||||
count: number;
|
||||
/** Turns the drawing about the box's centre; absent when it is not turned. */
|
||||
transform?: string;
|
||||
/** The cell the repeat was drawn at, after the cap had its say. */
|
||||
cell: number;
|
||||
}
|
||||
|
||||
/** Hundredths of a millimetre, and never minus zero, which would print as "-0". */
|
||||
const r2 = (v: number) => Math.round(v * 100) / 100 || 0;
|
||||
const f = (v: number) => String(r2(v));
|
||||
|
||||
/**
|
||||
* How many primitives a repeat needs over a region: `area` primitives per
|
||||
* cell squared, `lin` per cell along the width and the height. Read before
|
||||
* drawing, so the cap moves the cell once instead of drawing twice, and on
|
||||
* the high side, so the cap grows the cell rather than leaving a corner of
|
||||
* the box undrawn.
|
||||
*/
|
||||
const DENSITY: Record<BookPattern, { area: number; lin: number }> = {
|
||||
checker: { area: 2, lin: 0 },
|
||||
gingham: { area: 0, lin: 1 },
|
||||
stripes: { area: 0, lin: 1 },
|
||||
pinstripe: { area: 0, lin: 2 },
|
||||
dots: { area: 4, lin: 0 },
|
||||
polka: { area: 2, lin: 0 },
|
||||
zigzag: { area: 2, lin: 0 },
|
||||
waves: { area: 2, lin: 0 },
|
||||
grid: { area: 0, lin: 2 },
|
||||
scales: { area: 2, lin: 0 },
|
||||
sunburst: { area: 0, lin: 0 },
|
||||
confetti: { area: 1.2, lin: 0 },
|
||||
};
|
||||
|
||||
/** A primitive count estimate for a repeat over a region at a cell size. */
|
||||
export function estimatePrimitives(pattern: BookPattern, cell: number, w: number, h: number): number {
|
||||
if (pattern === 'sunburst') return sunburstRays(cell);
|
||||
const d = DENSITY[pattern];
|
||||
// The repeats that need room to tuck in start a cell before the region and end a cell after it.
|
||||
const pw = w + cell * 2;
|
||||
const ph = h + cell * 2;
|
||||
return (d.area * pw * ph) / (cell * cell) + (d.lin * (pw + ph)) / cell;
|
||||
}
|
||||
|
||||
/** The cell a repeat is drawn at: its own, or larger where the cap would be passed. */
|
||||
export function fittedCell(
|
||||
pattern: BookPattern,
|
||||
cell: number,
|
||||
w: number,
|
||||
h: number,
|
||||
cap = MAX_PATTERN_PRIMITIVES
|
||||
): number {
|
||||
let c = cell;
|
||||
// Two rounds cover the linear term, which grows only with the square root.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const n = estimatePrimitives(pattern, c, w, h);
|
||||
if (n <= cap * 0.95) break;
|
||||
c *= Math.sqrt(n / (cap * 0.95)) * 1.02;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
/** Collects primitives into a path, refusing any past the cap. */
|
||||
class Pen {
|
||||
parts: string[] = [];
|
||||
count = 0;
|
||||
constructor(private readonly cap: number) {}
|
||||
|
||||
get full(): boolean {
|
||||
return this.count >= this.cap;
|
||||
}
|
||||
|
||||
private push(d: string) {
|
||||
if (this.full) return;
|
||||
this.parts.push(d);
|
||||
this.count++;
|
||||
}
|
||||
|
||||
rect(x: number, y: number, w: number, h: number) {
|
||||
this.push(`M${f(x)} ${f(y)}h${f(w)}v${f(h)}h${f(-w)}Z`);
|
||||
}
|
||||
|
||||
circle(cx: number, cy: number, r: number) {
|
||||
this.push(`M${f(cx - r)} ${f(cy)}a${f(r)} ${f(r)} 0 1 0 ${f(r * 2)} 0a${f(r)} ${f(r)} 0 1 0 ${f(-r * 2)} 0Z`);
|
||||
}
|
||||
|
||||
polygon(points: readonly (readonly [number, number])[]) {
|
||||
if (points.length < 3) return;
|
||||
this.push(`M${points.map(([x, y]) => `${f(x)} ${f(y)}`).join('L')}Z`);
|
||||
}
|
||||
|
||||
/** Raw path data counted as `n` primitives, for a band drawn as one outline. */
|
||||
raw(d: string, n: number) {
|
||||
if (this.full) return;
|
||||
this.parts.push(d);
|
||||
this.count += n;
|
||||
}
|
||||
|
||||
get d(): string {
|
||||
return this.parts.join('');
|
||||
}
|
||||
}
|
||||
|
||||
interface Region {
|
||||
x0: number;
|
||||
y0: number;
|
||||
x1: number;
|
||||
y1: number;
|
||||
}
|
||||
|
||||
/** Values of `start + k * step` from at or below `from` up to `to`, aligned to the region's origin. */
|
||||
function steps(from: number, to: number, step: number, offset = 0): number[] {
|
||||
const out: number[] = [];
|
||||
const first = Math.floor((from - offset) / step) * step + offset;
|
||||
for (let v = first; v < to && out.length < 4000; v += step) out.push(v);
|
||||
return out;
|
||||
}
|
||||
|
||||
type Draw = (pen: Pen, region: Region, cell: number, alt: Pen) => void;
|
||||
|
||||
const DRAW: Record<Exclude<BookPattern, 'sunburst'>, Draw> = {
|
||||
checker: (pen, g, s) => {
|
||||
const half = s / 2;
|
||||
steps(g.y0, g.y1, half).forEach((y, j) => {
|
||||
steps(g.x0, g.x1, half).forEach((x, i) => {
|
||||
if ((i + j) % 2 === 0) pen.rect(x, y, half, half);
|
||||
});
|
||||
});
|
||||
},
|
||||
// Two layers of half-opaque bands, so where they cross reads darker: the weave.
|
||||
gingham: (pen, g, s, alt) => {
|
||||
for (const y of steps(g.y0, g.y1, s)) pen.rect(g.x0, y, g.x1 - g.x0, s / 2);
|
||||
for (const x of steps(g.x0, g.x1, s)) alt.rect(x, g.y0, s / 2, g.y1 - g.y0);
|
||||
},
|
||||
stripes: (pen, g, s) => {
|
||||
for (const x of steps(g.x0, g.x1, s)) pen.rect(x, g.y0, s / 2, g.y1 - g.y0);
|
||||
},
|
||||
pinstripe: (pen, g, s) => {
|
||||
const w = Math.max(0.2, s * 0.06);
|
||||
for (const x of steps(g.x0, g.x1, s / 2)) pen.rect(x, g.y0, w, g.y1 - g.y0);
|
||||
},
|
||||
dots: (pen, g, s) => {
|
||||
const p = s / 2;
|
||||
for (const y of steps(g.y0, g.y1 + p, p))
|
||||
for (const x of steps(g.x0, g.x1 + p, p)) pen.circle(x + p / 2, y + p / 2, s * 0.07);
|
||||
},
|
||||
// A square lattice with a second one in its gaps: the polka of a sundress.
|
||||
polka: (pen, g, s) => {
|
||||
const r = s * 0.17;
|
||||
for (const y of steps(g.y0 - s, g.y1 + s, s)) {
|
||||
for (const x of steps(g.x0 - s, g.x1 + s, s)) {
|
||||
pen.circle(x, y, r);
|
||||
pen.circle(x + s / 2, y + s / 2, r);
|
||||
}
|
||||
}
|
||||
},
|
||||
zigzag: (pen, g, s) => {
|
||||
const half = s / 2;
|
||||
const amp = s / 4;
|
||||
const t = s * 0.14;
|
||||
for (const y of steps(g.y0 - s, g.y1 + s, s)) {
|
||||
const xs = steps(g.x0 - s, g.x1 + s, half);
|
||||
const top = xs.map((x, i) => [x, y + (i % 2 ? amp : 0)] as const);
|
||||
const bottom = [...top].reverse().map(([x, yy]) => [x, yy + t] as const);
|
||||
pen.raw(`M${[...top, ...bottom].map(([x, yy]) => `${f(x)} ${f(yy)}`).join('L')}Z`, Math.max(1, xs.length - 1));
|
||||
}
|
||||
},
|
||||
waves: (pen, g, s) => {
|
||||
const half = s / 2;
|
||||
const amp = s * 0.18;
|
||||
const t = s * 0.12;
|
||||
for (const y of steps(g.y0 - s, g.y1 + s, s)) {
|
||||
const xs = steps(g.x0 - s, g.x1 + s, half);
|
||||
if (xs.length < 2) continue;
|
||||
const curve = (dy: number, list: number[], dir: 1 | -1) =>
|
||||
list
|
||||
.slice(1)
|
||||
.map((x, i) => {
|
||||
const from = list[i] ?? x;
|
||||
const k = dir === 1 ? i : list.length - 2 - i;
|
||||
const bend = k % 2 ? amp : -amp;
|
||||
return `Q${f((from + x) / 2)} ${f(y + dy + bend)} ${f(x)} ${f(y + dy)}`;
|
||||
})
|
||||
.join('');
|
||||
const back = [...xs].reverse();
|
||||
const first = xs[0] ?? 0;
|
||||
const last = back[0] ?? 0;
|
||||
pen.raw(`M${f(first)} ${f(y)}${curve(0, xs, 1)}L${f(last)} ${f(y + t)}${curve(t, back, -1)}Z`, xs.length - 1);
|
||||
}
|
||||
},
|
||||
grid: (pen, g, s) => {
|
||||
const w = Math.max(0.2, s * 0.05);
|
||||
for (const x of steps(g.x0, g.x1, s)) pen.rect(x, g.y0, w, g.y1 - g.y0);
|
||||
for (const y of steps(g.y0, g.y1, s)) pen.rect(g.x0, y, g.x1 - g.x0, w);
|
||||
},
|
||||
// Lower half rings in rows half a cell apart, every other row shifted, so each scale tucks under the next.
|
||||
scales: (pen, g, s) => {
|
||||
const r = s / 2;
|
||||
const t = Math.max(0.2, s * 0.08);
|
||||
steps(g.y0 - s, g.y1 + s, s / 2).forEach((y, row) => {
|
||||
for (const x of steps(g.x0 - s, g.x1 + s, s, row % 2 ? r : 0)) {
|
||||
const cx = x + r;
|
||||
pen.raw(
|
||||
`M${f(cx - r)} ${f(y)}A${f(r)} ${f(r)} 0 0 0 ${f(cx + r)} ${f(y)}` +
|
||||
`L${f(cx + r - t)} ${f(y)}A${f(r - t)} ${f(r - t)} 0 0 1 ${f(cx - r + t)} ${f(y)}Z`,
|
||||
1
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
confetti: (pen, g, s) => {
|
||||
const rand = mulberry32(Math.round(s * 1000) + 17);
|
||||
const area = (g.x1 - g.x0) * (g.y1 - g.y0);
|
||||
const n = Math.round((area / (s * s)) * 1.2);
|
||||
for (let i = 0; i < n && !pen.full; i++) {
|
||||
const x = g.x0 + rand() * (g.x1 - g.x0);
|
||||
const y = g.y0 + rand() * (g.y1 - g.y0);
|
||||
const turn = rand() * Math.PI;
|
||||
if (rand() < 0.35) {
|
||||
pen.circle(x, y, s * (0.04 + rand() * 0.03));
|
||||
continue;
|
||||
}
|
||||
const len = s * (0.12 + rand() * 0.1);
|
||||
const wid = s * 0.05;
|
||||
const cos = Math.cos(turn);
|
||||
const sin = Math.sin(turn);
|
||||
const corner = (u: number, v: number) => [x + u * cos - v * sin, y + u * sin + v * cos] as const;
|
||||
pen.polygon([
|
||||
corner(-len / 2, -wid / 2),
|
||||
corner(len / 2, -wid / 2),
|
||||
corner(len / 2, wid / 2),
|
||||
corner(-len / 2, wid / 2),
|
||||
]);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** A seeded generator, so a confetti page draws the same scatter on every render and in print. */
|
||||
export function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/** How many rays a sunburst has at a cell size: more as the cell shrinks, between 8 and 72. */
|
||||
export function sunburstRays(cell: number): number {
|
||||
const n = Math.round((24 * PATTERN_CELL_MM) / Math.max(0.1, cell));
|
||||
return Math.max(8, Math.min(72, n - (n % 2)));
|
||||
}
|
||||
|
||||
function sunburst(pen: Pen, box: PatternBox, cell: number, angle: number) {
|
||||
const cx = box.x + box.w / 2;
|
||||
const cy = box.y + box.h / 2;
|
||||
// Past the corners whichever way it turns.
|
||||
const reach = Math.hypot(box.w, box.h);
|
||||
const n = sunburstRays(cell);
|
||||
const step = (Math.PI * 2) / n;
|
||||
const start = (angle * Math.PI) / 180 - Math.PI / 2;
|
||||
for (let i = 0; i < n; i += 2) {
|
||||
const a = start + i * step;
|
||||
pen.polygon([
|
||||
[cx, cy],
|
||||
[cx + Math.cos(a) * reach, cy + Math.sin(a) * reach],
|
||||
[cx + Math.cos(a + step) * reach, cy + Math.sin(a + step) * reach],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A repeat over a box. The geometry covers the box however it is turned: the
|
||||
* region drawn is the square around the box's circumscribed circle, and the
|
||||
* caller turns it with `transform` and clips it to the shape.
|
||||
*/
|
||||
export function patternGeometry(
|
||||
paint: { pattern: BookPattern; scale: number; angle: number },
|
||||
box: PatternBox,
|
||||
cap = MAX_PATTERN_PRIMITIVES
|
||||
): PatternGeometry {
|
||||
const empty = { layers: [], count: 0, cell: PATTERN_CELL_MM * paint.scale };
|
||||
if (!(box.w > 0 && box.h > 0) || !Number.isFinite(paint.scale) || paint.scale <= 0) return empty;
|
||||
|
||||
const turned = ((paint.angle % 360) + 360) % 360 !== 0;
|
||||
const cx = box.x + box.w / 2;
|
||||
const cy = box.y + box.h / 2;
|
||||
const radius = Math.hypot(box.w, box.h) / 2;
|
||||
const region: Region =
|
||||
turned && paint.pattern !== 'sunburst'
|
||||
? { x0: cx - radius, y0: cy - radius, x1: cx + radius, y1: cy + radius }
|
||||
: { x0: box.x, y0: box.y, x1: box.x + box.w, y1: box.y + box.h };
|
||||
const rw = region.x1 - region.x0;
|
||||
const rh = region.y1 - region.y0;
|
||||
const cell = fittedCell(paint.pattern, PATTERN_CELL_MM * paint.scale, rw, rh, cap);
|
||||
|
||||
const pen = new Pen(cap);
|
||||
if (paint.pattern === 'sunburst') {
|
||||
sunburst(pen, box, cell, paint.angle);
|
||||
return { layers: [{ d: pen.d, opacity: 1 }], count: pen.count, cell };
|
||||
}
|
||||
|
||||
// The second layer only ever holds gingham's cross bands; the cell fitted above keeps both under the cap together.
|
||||
const alt = new Pen(cap);
|
||||
DRAW[paint.pattern](pen, region, cell, alt);
|
||||
const count = pen.count + alt.count;
|
||||
const layers =
|
||||
paint.pattern === 'gingham'
|
||||
? [
|
||||
{ d: pen.d, opacity: 0.5 },
|
||||
{ d: alt.d, opacity: 0.5 },
|
||||
]
|
||||
: [{ d: pen.d, opacity: 1 }];
|
||||
return {
|
||||
layers: layers.filter((l) => l.d),
|
||||
count,
|
||||
cell,
|
||||
...(turned ? { transform: `rotate(${f(paint.angle)} ${f(cx)} ${f(cy)})` } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { clusterPalettes, parseHex, toHex, toHsl, type PaletteSwatch } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The colours of a photograph, read in the browser while editing.
|
||||
*
|
||||
* The server keeps no palette for a photo the client can read, and the page
|
||||
* being edited is the one whose photos matter, so the swatches are read from
|
||||
* the pictures themselves: a thumbnail drawn small onto a canvas, its pixels
|
||||
* sorted into a few clusters by k-means. Small on purpose, 48 pixels across is
|
||||
* plenty to say a picture is mostly sea and sand, and it costs nothing next to
|
||||
* the thumbnail the page has already loaded.
|
||||
*
|
||||
* The canvas is only read here, never drawn into the book: what prints stays
|
||||
* the vector page.
|
||||
*/
|
||||
|
||||
/** How many clusters one photo is read into. */
|
||||
const PHOTO_CLUSTERS = 5;
|
||||
/** The side of the thumbnail the pixels are read from. */
|
||||
const SAMPLE = 48;
|
||||
|
||||
type Rgb = { r: number; g: number; b: number };
|
||||
|
||||
const dist2 = (a: Rgb, b: Rgb) => (a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2;
|
||||
|
||||
/** The opaque pixels of an RGBA buffer, every `stride`-th one. */
|
||||
export function samplePixels(data: ArrayLike<number>, stride = 1): Rgb[] {
|
||||
const out: Rgb[] = [];
|
||||
for (let i = 0; i + 3 < data.length; i += 4 * stride) {
|
||||
if ((data[i + 3] ?? 0) < 128) continue;
|
||||
out.push({ r: data[i] ?? 0, g: data[i + 1] ?? 0, b: data[i + 2] ?? 0 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* k-means over pixels, seeded by farthest points from the mean so the same
|
||||
* picture always gives the same answer: no random start, no flicker as the
|
||||
* inspector re-renders. Clusters come back largest first.
|
||||
*/
|
||||
export function kmeansPalette(pixels: readonly Rgb[], k = PHOTO_CLUSTERS, rounds = 8): PaletteSwatch[] {
|
||||
if (!pixels.length || k < 1) return [];
|
||||
const mean = pixels.reduce((m, p) => ({ r: m.r + p.r, g: m.g + p.g, b: m.b + p.b }), { r: 0, g: 0, b: 0 });
|
||||
const first = { r: mean.r / pixels.length, g: mean.g / pixels.length, b: mean.b / pixels.length };
|
||||
const centres: Rgb[] = [first];
|
||||
while (centres.length < k) {
|
||||
let best: Rgb | null = null;
|
||||
let bestD = 0;
|
||||
for (const p of pixels) {
|
||||
const d = Math.min(...centres.map((c) => dist2(c, p)));
|
||||
if (d > bestD) {
|
||||
bestD = d;
|
||||
best = p;
|
||||
}
|
||||
}
|
||||
if (!best) break;
|
||||
centres.push({ ...best });
|
||||
}
|
||||
|
||||
let sizes: number[] = centres.map(() => 0);
|
||||
for (let round = 0; round < rounds; round++) {
|
||||
const sums = centres.map(() => ({ r: 0, g: 0, b: 0, n: 0 }));
|
||||
for (const p of pixels) {
|
||||
let at = 0;
|
||||
let near = Infinity;
|
||||
centres.forEach((c, i) => {
|
||||
const d = dist2(c, p);
|
||||
if (d < near) {
|
||||
near = d;
|
||||
at = i;
|
||||
}
|
||||
});
|
||||
const s = sums[at];
|
||||
if (!s) continue;
|
||||
s.r += p.r;
|
||||
s.g += p.g;
|
||||
s.b += p.b;
|
||||
s.n++;
|
||||
}
|
||||
sums.forEach((s, i) => {
|
||||
if (s.n) centres[i] = { r: s.r / s.n, g: s.g / s.n, b: s.b / s.n };
|
||||
});
|
||||
sizes = sums.map((s) => s.n);
|
||||
}
|
||||
|
||||
return centres
|
||||
.map((c, i) => ({ hex: toHex(c), share: (sizes[i] ?? 0) / pixels.length }))
|
||||
.filter((s) => s.share > 0)
|
||||
.sort((a, b) => b.share - a.share || a.hex.localeCompare(b.hex));
|
||||
}
|
||||
|
||||
/**
|
||||
* The swatches several photos offer together: the clusters they share, most
|
||||
* common first, with near-greys and near-duplicates thinned so the row is a
|
||||
* choice of colours rather than five shades of the same overcast sky.
|
||||
*/
|
||||
export function photoSwatches(palettes: readonly PaletteSwatch[][], limit = 6): string[] {
|
||||
const out: string[] = [];
|
||||
let greys = 0;
|
||||
for (const swatch of clusterPalettes(palettes, 10)) {
|
||||
const rgb = parseHex(swatch.hex);
|
||||
if (!rgb) continue;
|
||||
const grey = toHsl(rgb).s < 0.12;
|
||||
// One grey is a useful neutral; a second is the same colour again.
|
||||
if (grey && greys >= 1) continue;
|
||||
if (grey) greys++;
|
||||
out.push(swatch.hex);
|
||||
if (out.length >= limit) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Read one picture's palette. Resolves empty when the picture cannot be read, never rejects. */
|
||||
export function readPhotoPalette(src: string, signal?: AbortSignal): Promise<PaletteSwatch[]> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal?.aborted || typeof document === 'undefined') {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
img.decoding = 'async';
|
||||
const done = (out: PaletteSwatch[]) => {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
resolve(out);
|
||||
};
|
||||
const abort = () => {
|
||||
img.src = '';
|
||||
done([]);
|
||||
};
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
img.onerror = () => done([]);
|
||||
img.onload = () => done(paletteOfImage(img));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
function paletteOfImage(img: HTMLImageElement): PaletteSwatch[] {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = SAMPLE;
|
||||
canvas.height = SAMPLE;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) return [];
|
||||
try {
|
||||
ctx.drawImage(img, 0, 0, SAMPLE, SAMPLE);
|
||||
return kmeansPalette(samplePixels(ctx.getImageData(0, 0, SAMPLE, SAMPLE).data, 2));
|
||||
} catch {
|
||||
// A picture from another origin taints the canvas; it simply offers no swatches.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { BookDocument, BookPaint } from '@trek/shared';
|
||||
import { useLayoutEffect, useMemo, useRef } from 'react';
|
||||
import { useStudioStore } from '../../../store/studioStore';
|
||||
|
||||
/**
|
||||
* How the paint picker writes: a value set while a control is dragged is
|
||||
* `live`, and the one it is let go on is `commit`.
|
||||
*
|
||||
* A drag of a gradient stop or the angle dial passes through dozens of
|
||||
* values, and each would otherwise be an undo step. So the first live value
|
||||
* opens a gesture on the store, every live value after it moves the document
|
||||
* inside that gesture, and the commit closes it: one drag, one undo. A click
|
||||
* that is a commit with no live value before it is one ordinary step.
|
||||
*/
|
||||
export interface PaintChannel {
|
||||
live: (paint: BookPaint | null) => void;
|
||||
commit: (paint: BookPaint | null) => void;
|
||||
}
|
||||
|
||||
export function usePaintChannel(write: (paint: BookPaint | null) => (doc: BookDocument) => BookDocument): PaintChannel {
|
||||
const begin = useStudioStore((s) => s.beginGesture);
|
||||
const end = useStudioStore((s) => s.endGesture);
|
||||
const apply = useStudioStore((s) => s.apply);
|
||||
const commit = useStudioStore((s) => s.commit);
|
||||
const open = useRef(false);
|
||||
// The writer is a fresh closure on every render; the channel reads the latest one.
|
||||
const writer = useRef(write);
|
||||
useLayoutEffect(() => {
|
||||
writer.current = write;
|
||||
});
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
live: (paint) => {
|
||||
if (!open.current) {
|
||||
begin();
|
||||
open.current = true;
|
||||
}
|
||||
apply(writer.current(paint));
|
||||
},
|
||||
commit: (paint) => {
|
||||
if (!open.current) {
|
||||
commit(writer.current(paint));
|
||||
return;
|
||||
}
|
||||
apply(writer.current(paint));
|
||||
end();
|
||||
open.current = false;
|
||||
},
|
||||
}),
|
||||
[begin, end, apply, commit]
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useId } from 'react';
|
||||
|
||||
/**
|
||||
* Defs ids for paint, unique per mounted copy of a page (paintRender.tsx says why).
|
||||
*/
|
||||
|
||||
/** A defs id unique to this mounted instance, safe inside `url(#...)`. */
|
||||
export function usePaintId(prefix: string): string {
|
||||
const raw = useId();
|
||||
return `${prefix}-${raw.replace(/[^\w-]/g, '')}`;
|
||||
}
|
||||
@@ -41,18 +41,6 @@ function stationSplit<T extends RoutePoint>(point: T & StationParts): { station:
|
||||
return { station, rest: rest as T };
|
||||
}
|
||||
|
||||
/**
|
||||
* A new point at the end of the tour; a loop keeps returning to its start,
|
||||
* so there the point goes in before the way back.
|
||||
*/
|
||||
export function withPointAppended<T extends { lat: number; lng: number; role: string }>(
|
||||
points: readonly T[],
|
||||
point: T
|
||||
): T[] {
|
||||
if (!isClosedLoop(points)) return [...points, { ...point, role: 'end' }];
|
||||
return [...points.slice(0, -1), { ...point, role: 'via' }, points[points.length - 1]];
|
||||
}
|
||||
|
||||
/**
|
||||
* The tour taken back from its last point to its start: one end point on the
|
||||
* start's spot, not a second copy of the start among the stops. `newId` names
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { act, fireEvent, render, renderHook, screen } from '@testing-library/react';
|
||||
import { withPointAppended } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildCategory, buildPlace } from '../../../../tests/helpers/factories';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
unlinkedPoint,
|
||||
type TourPlannerWaypoint,
|
||||
} from './tourPlannerWaypoints';
|
||||
import { withPointAppended } from './tourRouteActions';
|
||||
import { TripPlacePicker } from './TripPlacePicker';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
import { useTripPlaceWaypoints } from './useTripPlaceWaypoints';
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { nearnessToLine } from '@trek/shared';
|
||||
import { nearnessToLine, withPointAppended } from '@trek/shared';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTripStore } from '../../../store/tripStore';
|
||||
import type { Category, Place } from '../../../types';
|
||||
import { randomId } from '../../../utils/randomId';
|
||||
import { useLinkedPlaceOf } from '../walk/useWalkSchedule';
|
||||
import { unlinkedPoint, type TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
import { withPointAppended } from './tourRouteActions';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
|
||||
/** A place of the trip as the picker offers it. */
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { isClosedLoop, type PlannableTourType, type TourFixedLeg } from '@trek/shared';
|
||||
import { isClosedLoop, withPointAppended, type PlannableTourType, type TourFixedLeg } from '@trek/shared';
|
||||
import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react';
|
||||
import { randomId } from '../../../utils/randomId';
|
||||
import { holdsHorseSection } from '../horse/horseLegs';
|
||||
import { unlinkedPoint, type TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
import { closeLoop, reverseRoute, toggleFreeLeg, withPointAppended } from './tourRouteActions';
|
||||
import { closeLoop, reverseRoute, toggleFreeLeg } from './tourRouteActions';
|
||||
|
||||
type WaypointChange = TourPlannerWaypoint[] | ((current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TourPoi, WalkTheme } from '@trek/shared';
|
||||
import { TOUR_POI_CORRIDORS_M } from '@trek/shared';
|
||||
import { placesInTour, TOUR_POI_CORRIDORS_M } from '@trek/shared';
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
@@ -30,7 +30,6 @@ import { usePopoverList } from '../../shared/usePopoverList';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { RailCard, TourNotice } from '../tourParts';
|
||||
import { formatPlannedTourDuration } from '../tourPresentation';
|
||||
import { placesInTour } from './poiInsertion';
|
||||
import { SearchNotices } from './TourPoiSearchNotices';
|
||||
import { usePoiInsertion, type InsertionOutcome } from './usePoiInsertion';
|
||||
import { useSelectedPoi, useTourPoiSearch, type TourPoiSearch } from './useTourPoiSearch';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TourPoi } from '@trek/shared';
|
||||
import { isClosedLoop } from '@trek/shared';
|
||||
import { insertionPreview, isClosedLoop, straightDetourM, type InsertionSlot } from '@trek/shared';
|
||||
import { ChevronDown, CornerDownRight, Flag, Info, Plus } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
@@ -12,7 +12,6 @@ import { usePopoverList } from '../../shared/usePopoverList';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { useWaypointPlaceNames } from '../planner/useWaypointPlaceNames';
|
||||
import { insertionPreview, straightDetourM, type InsertionSlot } from './poiInsertion';
|
||||
import type { usePoiInsertion } from './usePoiInsertion';
|
||||
|
||||
type Unit = Parameters<typeof formatDistance>[1];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TourPoi } from '@trek/shared';
|
||||
import { chosenAfterId, insertionBlock, insertionSlots, withPointAfter, type TourPoi } from '@trek/shared';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useTourPoiStore, type InsertedTourPoi } from '../../../store/tourPoiStore';
|
||||
@@ -6,7 +6,6 @@ import { randomId } from '../../../utils/randomId';
|
||||
import { TOUR_POI_LOOKS } from '../../Map/poiCategories';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import type { TourPlannerController, TourPlannerStatus } from '../planner/useTourPlanner';
|
||||
import { chosenAfterId, insertionBlock, insertionSlots, withPointAfter } from './poiInsertion';
|
||||
|
||||
/** The route is planned through and its measures can be read. */
|
||||
const SETTLED: readonly TourPlannerStatus[] = ['ready', 'saved', 'elevation-failed'];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { chosenAfterId, insertionPreview } from '@trek/shared';
|
||||
import { useMemo } from 'react';
|
||||
import { useTourPoiStore } from '../../../store/tourPoiStore';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { chosenAfterId, insertionPreview } from './poiInsertion';
|
||||
import { useSelectedPoi } from './useTourPoiSearch';
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* FE-TOUR-OPEN-001 to FE-TOUR-OPEN-004
|
||||
*
|
||||
* P6: the saved tour the editor has open, as the chat's "+ Tour" reads it:
|
||||
* only a saved hike or city walk the user may edit, on screen, with nothing
|
||||
* unsaved, and nothing once the editor is gone.
|
||||
*/
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { setOpenTour, useOpenTourStore } from '../../../store/openTourStore';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { usePublishOpenTour } from './usePublishOpenTour';
|
||||
|
||||
type Planner = Parameters<typeof usePublishOpenTour>[0];
|
||||
|
||||
const planner = (over: Partial<Planner> = {}): Planner => ({
|
||||
tripId: 4,
|
||||
mode: { type: 'edit-saved', placeId: 64 } as TourPlannerController['mode'],
|
||||
name: 'Herzogstand',
|
||||
tourType: 'hike',
|
||||
hasUnsavedChanges: false,
|
||||
canEdit: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
afterEach(() => setOpenTour(null));
|
||||
|
||||
describe('usePublishOpenTour', () => {
|
||||
it('FE-TOUR-OPEN-001: a saved hike open on screen is the tour a found place can go into', () => {
|
||||
renderHook(() => usePublishOpenTour(planner(), true));
|
||||
expect(useOpenTourStore.getState().open).toEqual({ tripId: 4, placeId: 64, name: 'Herzogstand' });
|
||||
});
|
||||
|
||||
it('FE-TOUR-OPEN-002: none while the editor is off screen, holds unsaved changes, or the user may not edit', () => {
|
||||
const { rerender } = renderHook(({ p, shown }) => usePublishOpenTour(p, shown), {
|
||||
initialProps: { p: planner(), shown: false },
|
||||
});
|
||||
expect(useOpenTourStore.getState().open).toBeNull();
|
||||
rerender({ p: planner({ hasUnsavedChanges: true }), shown: true });
|
||||
expect(useOpenTourStore.getState().open).toBeNull();
|
||||
rerender({ p: planner({ canEdit: false }), shown: true });
|
||||
expect(useOpenTourStore.getState().open).toBeNull();
|
||||
rerender({ p: planner({ tourType: 'walk' }), shown: true });
|
||||
expect(useOpenTourStore.getState().open).toMatchObject({ placeId: 64 });
|
||||
});
|
||||
|
||||
it('FE-TOUR-OPEN-003: a new draft, or a tour type that takes no stop from the chat, is none', () => {
|
||||
const { rerender } = renderHook(({ p }) => usePublishOpenTour(p, true), {
|
||||
initialProps: { p: planner({ mode: { type: 'new-draft' } as TourPlannerController['mode'] }) },
|
||||
});
|
||||
expect(useOpenTourStore.getState().open).toBeNull();
|
||||
rerender({ p: planner({ tourType: 'bike' }) });
|
||||
expect(useOpenTourStore.getState().open).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-OPEN-004: the editor gone, nothing is open any more', () => {
|
||||
const { unmount } = renderHook(() => usePublishOpenTour(planner(), true));
|
||||
expect(useOpenTourStore.getState().open).not.toBeNull();
|
||||
unmount();
|
||||
expect(useOpenTourStore.getState().open).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { TOUR_STOP_TYPES } from '@trek/shared';
|
||||
import { useEffect } from 'react';
|
||||
import { setOpenTour } from '../../../store/openTourStore';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
|
||||
type PublishedPlanner = Pick<
|
||||
TourPlannerController,
|
||||
'tripId' | 'mode' | 'name' | 'tourType' | 'hasUnsavedChanges' | 'canEdit'
|
||||
>;
|
||||
|
||||
const STOP_TYPES: readonly string[] = TOUR_STOP_TYPES;
|
||||
|
||||
/**
|
||||
* P6: tells the chat which saved tour the editor has open, so a place the
|
||||
* assistant found can be taken into it with "+ Tour". Only a saved hike or
|
||||
* city walk the user may edit counts, and only while the editor holds nothing
|
||||
* unsaved: the stop is saved on the server, and a draft would then stand
|
||||
* against a newer tour. `shown` is whether the editor is on screen at all.
|
||||
*/
|
||||
export function usePublishOpenTour(planner: PublishedPlanner, shown: boolean): void {
|
||||
const { tripId, mode, name, tourType, hasUnsavedChanges, canEdit } = planner;
|
||||
const placeId = mode.type === 'edit-saved' ? mode.placeId : null;
|
||||
const trip = Number(tripId);
|
||||
const takesStop = shown && placeId !== null && canEdit && !hasUnsavedChanges && STOP_TYPES.includes(tourType);
|
||||
useEffect(() => {
|
||||
setOpenTour(takesStop && placeId !== null ? { tripId: trip, placeId, name } : null);
|
||||
}, [name, placeId, takesStop, trip]);
|
||||
// The editor gone, the chat no longer offers to take anything into it.
|
||||
useEffect(() => () => setOpenTour(null), []);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { usePaddleChecklist } from '../../components/Tours/paddle/usePaddleCheck
|
||||
import { useTrackPaddlePlan } from '../../components/Tours/paddle/useTrackPaddlePlan';
|
||||
import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner';
|
||||
import { useTourRevisionWatch } from '../../components/Tours/planner/useTourRevisionWatch';
|
||||
import { usePublishOpenTour } from '../../components/Tours/poi/usePublishOpenTour';
|
||||
import { useSkiClassification } from '../../components/Tours/ski/useSkiClassification';
|
||||
import { useRecordedTrack } from '../../components/Tours/useRecordedTrack';
|
||||
import { useTourGpxImport } from '../../components/Tours/useTourGpxImport';
|
||||
@@ -85,6 +86,7 @@ export function useTourWorkspace(planner: TripPlanner, canPlaceEdit: boolean, ca
|
||||
});
|
||||
|
||||
useTourRevisionWatch(tourPlanner, tours); // P2C-03: a save made elsewhere reaches the open editor
|
||||
usePublishOpenTour(tourPlanner, activeTab === 'tour-planner' && !isMobile); // P6: the chat's "+ Tour" takes a found place into the tour open here
|
||||
const { viewImportedTour, openTourInPlanner } = useTourOpeners(tourPlanner, places, tours);
|
||||
|
||||
// The tours workspace's GPX import, shared by its button and its file drop. One new
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type AssistantAddFoundPlaceRequest,
|
||||
type AssistantChatRequest,
|
||||
type AssistantDecisionsRequest,
|
||||
type AssistantInsertFoundPlaceRequest,
|
||||
type AssistantShareRequest,
|
||||
type AssistantTakeProposalRequest,
|
||||
type BookDocument,
|
||||
@@ -86,6 +87,12 @@ export const assistantRepo = {
|
||||
return assistantAddFoundPlaceResponseSchema.parse(await assistantApi.addFoundPlace(tripId, threadId, body));
|
||||
},
|
||||
|
||||
/** A found place taken into a saved tour as a stop ("+ Tour"), held as a change; no model runs for it. */
|
||||
async insertFoundPlace(tripId: number, threadId: number, body: AssistantInsertFoundPlaceRequest) {
|
||||
requireOnline();
|
||||
return assistantAddFoundPlaceResponseSchema.parse(await assistantApi.insertFoundPlace(tripId, threadId, body));
|
||||
},
|
||||
|
||||
/** A proposal an answer showed as a card ("Plan this"), held as a change; no model runs for it. */
|
||||
async takeProposal(tripId: number, threadId: number, body: AssistantTakeProposalRequest) {
|
||||
requireOnline();
|
||||
|
||||
@@ -228,6 +228,8 @@ interface AssistantState {
|
||||
* thread changed under it, and always skipped under a journey key.
|
||||
*/
|
||||
takeProposal: (key: AssistantChatKey, toolCallId: string) => Promise<HoldOutcome>;
|
||||
/** Takes a found place into the saved tour open in the planner as a stop ("+ Tour"), held as a change to decide. */
|
||||
insertFoundPlace: (key: AssistantChatKey, ref: string, tourId: number) => Promise<HoldOutcome>;
|
||||
}
|
||||
|
||||
/** What came of a change the server was asked to hold without a model turn. */
|
||||
@@ -772,6 +774,10 @@ function compareActions(ctx: Ctx) {
|
||||
holdWithoutTurn(ctx, tripId, (threadId) =>
|
||||
assistantRepo.takeProposal(tripId, threadId, { ...clientContext(), toolCallId })
|
||||
),
|
||||
insertFoundPlace: (tripId: number, ref: string, tourId: number) =>
|
||||
holdWithoutTurn(ctx, tripId, (threadId) =>
|
||||
assistantRepo.insertFoundPlace(tripId, threadId, { ...clientContext(), ref, tourId })
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/** The saved tour open in the planner's editor, as the chat may take a found place into it. */
|
||||
interface OpenTour {
|
||||
tripId: number;
|
||||
placeId: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface OpenTourState {
|
||||
/** Null while no saved hike or city walk is open, or while its editor holds changes not yet saved. */
|
||||
open: OpenTour | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* P6: which saved tour the planner has open, for "+ Tour" on a place the
|
||||
* assistant found. The planner writes it (usePublishOpenTour), the chat's
|
||||
* place cards read it; nothing else is kept here.
|
||||
*/
|
||||
export const useOpenTourStore = create<OpenTourState>(() => ({ open: null }));
|
||||
|
||||
export function setOpenTour(open: OpenTour | null): void {
|
||||
const current = useOpenTourStore.getState().open;
|
||||
const same =
|
||||
current === open ||
|
||||
(!!current &&
|
||||
!!open &&
|
||||
current.tripId === open.tripId &&
|
||||
current.placeId === open.placeId &&
|
||||
current.name === open.name);
|
||||
if (!same) useOpenTourStore.setState({ open });
|
||||
}
|
||||
@@ -1,641 +0,0 @@
|
||||
/* ============================================================================
|
||||
TREK Studio: the paint picker, the swatch rows and the page background.
|
||||
|
||||
Its own sheet beside studio.css (which is past the size ceiling), imported by
|
||||
the components that use it. Scoped under `.trek-dash` for the same reason
|
||||
every Studio rule is: dashboard.css resets `button` at (0,1,1).
|
||||
|
||||
Chrome only. The colours a swatch, a stop or a preview shows are the book's
|
||||
and arrive inline; everything around them resolves against the dashboard
|
||||
tokens, so the picker follows the user's theme and accent.
|
||||
============================================================================ */
|
||||
|
||||
/* The chequerboard under anything see-through, drawn in the ink so it reads in
|
||||
light and dark alike. */
|
||||
.trek-dash .st-paint,
|
||||
.trek-dash .st-swatch-stack {
|
||||
--st-check: color-mix(in oklch, var(--ink) 9%, transparent);
|
||||
--st-check-bg: conic-gradient(var(--st-check) 25%, transparent 0 50%, var(--st-check) 0 75%, transparent 0) 0 0 / 8px
|
||||
8px;
|
||||
}
|
||||
|
||||
/* ----------------- swatch rows ----------------- */
|
||||
.trek-dash .st-swatch-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.trek-dash .st-swatch {
|
||||
position: relative;
|
||||
}
|
||||
.trek-dash .st-swatch:focus {
|
||||
outline: none;
|
||||
}
|
||||
.trek-dash .st-swatch:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--accent-soft),
|
||||
0 0 0 3px var(--accent);
|
||||
}
|
||||
.trek-dash .st-swatch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px dashed var(--line);
|
||||
}
|
||||
.trek-dash .st-swatch-row-icon {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 22px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.trek-dash .st-swatch-row .st-swatches {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ----------------- opacity ----------------- */
|
||||
.trek-dash .st-alpha {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.trek-dash .st-alpha-track {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--st-check-bg), var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--line-2);
|
||||
/* The colour fades in left to right in every language, like the slider it is. */
|
||||
direction: ltr;
|
||||
}
|
||||
.trek-dash .st-alpha-track::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, transparent, var(--st-alpha-colour));
|
||||
}
|
||||
.trek-dash .st-alpha-value,
|
||||
.trek-dash .st-paint-value {
|
||||
flex: none;
|
||||
min-width: 34px;
|
||||
text-align: end;
|
||||
font-size: calc(11px * var(--fs-scale-caption, 1));
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
/* A range input with nothing of the browser's left but the thumb. */
|
||||
.trek-dash .st-alpha input[type='range'],
|
||||
.trek-dash .st-paint-slider input[type='range'] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.trek-dash .st-alpha input[type='range'] {
|
||||
position: relative;
|
||||
height: 14px;
|
||||
}
|
||||
.trek-dash .st-paint-slider input[type='range'] {
|
||||
height: 16px;
|
||||
/* The track is drawn as the input's own background so the thumb sits on it. */
|
||||
background: linear-gradient(var(--line-2), var(--line-2)) center / 100% 3px no-repeat;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.trek-dash .st-alpha input[type='range']::-webkit-slider-thumb,
|
||||
.trek-dash .st-paint-slider input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--line-2),
|
||||
var(--sh-sm);
|
||||
transition: transform 0.14s;
|
||||
}
|
||||
.trek-dash .st-alpha input[type='range']::-moz-range-thumb,
|
||||
.trek-dash .st-paint-slider input[type='range']::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--line-2),
|
||||
var(--sh-sm);
|
||||
}
|
||||
.trek-dash .st-alpha input[type='range']:active::-webkit-slider-thumb,
|
||||
.trek-dash .st-paint-slider input[type='range']:active::-webkit-slider-thumb {
|
||||
transform: scale(1.12);
|
||||
}
|
||||
.trek-dash .st-alpha input[type='range']:focus {
|
||||
outline: none;
|
||||
}
|
||||
.trek-dash .st-alpha input[type='range']:focus-visible::-webkit-slider-thumb,
|
||||
.trek-dash .st-paint-slider input[type='range']:focus-visible::-webkit-slider-thumb {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--accent-soft),
|
||||
0 0 0 3px var(--accent);
|
||||
}
|
||||
|
||||
/* ----------------- the picker ----------------- */
|
||||
.trek-dash .st-paint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Four kinds, each with a chip of what it gives: read at a glance, not a menu. */
|
||||
.trek-dash .st-paint-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in oklch, var(--ink) 5%, transparent);
|
||||
}
|
||||
.trek-dash .st-paint-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 6px 2px 5px;
|
||||
border-radius: 8px;
|
||||
color: var(--ink-3);
|
||||
transition:
|
||||
background 0.16s,
|
||||
color 0.16s,
|
||||
box-shadow 0.16s;
|
||||
}
|
||||
.trek-dash .st-paint-tab:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
.trek-dash .st-paint-tab.is-on {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
box-shadow:
|
||||
var(--sh-xs),
|
||||
0 0 0 1px var(--line);
|
||||
}
|
||||
.trek-dash .st-paint-tab:focus {
|
||||
outline: none;
|
||||
}
|
||||
.trek-dash .st-paint-tab:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--accent-soft),
|
||||
0 0 0 3px var(--accent);
|
||||
}
|
||||
.trek-dash .st-paint-tab-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--ink) 14%, transparent);
|
||||
background-color: var(--surface);
|
||||
color: var(--ink-3);
|
||||
transition: transform 0.16s;
|
||||
}
|
||||
.trek-dash .st-paint-tab.is-on .st-paint-tab-chip {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
.trek-dash .st-paint-tab-chip.is-none {
|
||||
background: transparent;
|
||||
}
|
||||
/* A pattern's chip is its ground with a few diagonal strokes of ink, a sign rather than the repeat. */
|
||||
.trek-dash .st-paint-tab-chip.is-pattern {
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
color-mix(in oklch, var(--ink) 55%, transparent) 0 1.5px,
|
||||
transparent 1.5px 4px
|
||||
);
|
||||
}
|
||||
.trek-dash .st-paint-tab-label {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: calc(10.5px * var(--fs-scale-caption, 1));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.trek-dash .st-paint-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.trek-dash .st-paint-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.trek-dash .st-paint-row.is-dial {
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.trek-dash .st-paint-row.is-dial > .st-num,
|
||||
.trek-dash .st-paint-row.is-dial > .st-paint-slider {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.trek-dash .st-paint-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
.trek-dash .st-paint-caption {
|
||||
font-size: calc(11px * var(--fs-scale-caption, 1));
|
||||
color: var(--ink-3);
|
||||
line-height: 1.25;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.trek-dash .st-paint-stop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 9px 10px 10px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in oklch, var(--ink) 3.5%, transparent);
|
||||
}
|
||||
.trek-dash .st-chip:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.trek-dash .st-chip:disabled:hover {
|
||||
background: color-mix(in oklch, var(--ink) 5%, transparent);
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
/* A small segmented choice inside the picker, quieter than the tabs above it. */
|
||||
.trek-dash .st-seg {
|
||||
display: inline-flex;
|
||||
padding: 2px;
|
||||
gap: 2px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in oklch, var(--ink) 5%, transparent);
|
||||
}
|
||||
.trek-dash .st-seg-item {
|
||||
height: 23px;
|
||||
padding: 0 10px;
|
||||
border-radius: 6px;
|
||||
font-size: calc(11.5px * var(--fs-scale-caption, 1));
|
||||
font-weight: 500;
|
||||
color: var(--ink-3);
|
||||
transition:
|
||||
background 0.16s,
|
||||
color 0.16s;
|
||||
}
|
||||
.trek-dash .st-seg-item:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
.trek-dash .st-seg-item.is-on {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
box-shadow: var(--sh-xs);
|
||||
}
|
||||
.trek-dash .st-seg-item:focus {
|
||||
outline: none;
|
||||
}
|
||||
.trek-dash .st-seg-item:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
|
||||
/* ----------------- the stop bar ----------------- */
|
||||
/* Room below the bar for the handles, which hang under it like a ruler's marks. */
|
||||
.trek-dash .st-stopbar-wrap {
|
||||
padding: 0 8px 14px;
|
||||
}
|
||||
.trek-dash .st-stopbar {
|
||||
position: relative;
|
||||
height: 24px;
|
||||
border-radius: 7px;
|
||||
background: var(--st-check-bg), var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--line-2);
|
||||
cursor: copy;
|
||||
/* A picture of the gradient, left to right in every language. */
|
||||
direction: ltr;
|
||||
touch-action: none;
|
||||
}
|
||||
.trek-dash .st-stopbar-fill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--ink) 10%, transparent);
|
||||
}
|
||||
.trek-dash .st-stop {
|
||||
position: absolute;
|
||||
inset-inline-start: var(--at);
|
||||
top: 13px;
|
||||
width: 16px;
|
||||
height: 22px;
|
||||
margin-inline-start: -8px;
|
||||
padding: 0;
|
||||
border-radius: 5px 5px 8px 8px;
|
||||
background: var(--stop);
|
||||
box-shadow:
|
||||
0 0 0 2px var(--surface),
|
||||
0 0 0 3px var(--line-2),
|
||||
var(--sh-sm);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
transition:
|
||||
box-shadow 0.14s,
|
||||
transform 0.14s,
|
||||
opacity 0.14s;
|
||||
}
|
||||
/* The tip that points up into the bar at the stop's exact position. */
|
||||
.trek-dash .st-stop::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline-start: 50%;
|
||||
top: -6px;
|
||||
width: 2px;
|
||||
height: 6px;
|
||||
margin-inline-start: -1px;
|
||||
border-radius: 1px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 0 1px var(--line-2);
|
||||
}
|
||||
.trek-dash .st-stop:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.trek-dash .st-stop:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.trek-dash .st-stop.is-on {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--surface),
|
||||
0 0 0 4px var(--accent),
|
||||
var(--sh-md);
|
||||
z-index: 1;
|
||||
}
|
||||
.trek-dash .st-stop.is-leaving {
|
||||
opacity: 0.35;
|
||||
transform: translateY(6px) scale(0.9);
|
||||
}
|
||||
.trek-dash .st-stop:focus {
|
||||
outline: none;
|
||||
}
|
||||
.trek-dash .st-stop:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--surface),
|
||||
0 0 0 4px var(--accent);
|
||||
}
|
||||
|
||||
/* ----------------- the angle dial ----------------- */
|
||||
.trek-dash .st-dial {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle, var(--surface) 0 55%, transparent 56%),
|
||||
repeating-conic-gradient(
|
||||
from -1deg,
|
||||
color-mix(in oklch, var(--ink) 30%, transparent) 0 2deg,
|
||||
transparent 2deg 45deg
|
||||
),
|
||||
var(--surface);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--line-2),
|
||||
var(--sh-xs);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
/* A compass: 0 is up and 90 is to the right whichever way the text runs. */
|
||||
direction: ltr;
|
||||
transition: box-shadow 0.14s;
|
||||
}
|
||||
.trek-dash .st-dial:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.trek-dash .st-dial:focus {
|
||||
outline: none;
|
||||
}
|
||||
.trek-dash .st-dial:focus-visible {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--line-2),
|
||||
0 0 0 2px var(--accent-soft),
|
||||
0 0 0 3px var(--accent);
|
||||
}
|
||||
.trek-dash .st-dial-needle {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
transition: transform 0.08s ease-out;
|
||||
}
|
||||
.trek-dash .st-dial-knob {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
inset-inline-start: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-inline-start: -4px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--surface);
|
||||
}
|
||||
/* The needle runs from the centre to the knob. */
|
||||
.trek-dash .st-dial-needle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
bottom: 50%;
|
||||
inset-inline-start: 50%;
|
||||
width: 1.5px;
|
||||
margin-inline-start: -0.75px;
|
||||
border-radius: 1px;
|
||||
background: color-mix(in oklch, var(--accent) 70%, transparent);
|
||||
}
|
||||
.trek-dash .st-dial-needle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
inset-inline-start: 50%;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
margin: -2.5px 0 0 -2.5px;
|
||||
border-radius: 50%;
|
||||
background: var(--ink-2);
|
||||
}
|
||||
|
||||
/* ----------------- the radial centre ----------------- */
|
||||
.trek-dash .st-radial-pad {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 64px;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--line-2);
|
||||
cursor: crosshair;
|
||||
direction: ltr;
|
||||
touch-action: none;
|
||||
}
|
||||
.trek-dash .st-radial-pad:focus {
|
||||
outline: none;
|
||||
}
|
||||
.trek-dash .st-radial-pad:focus-visible {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--line-2),
|
||||
0 0 0 2px var(--accent);
|
||||
}
|
||||
.trek-dash .st-radial-dot {
|
||||
position: absolute;
|
||||
inset-inline-start: var(--cx);
|
||||
top: var(--cy);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin: -5px 0 0 -5px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
box-shadow:
|
||||
0 0 0 1.5px var(--accent),
|
||||
var(--sh-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ----------------- sliders ----------------- */
|
||||
.trek-dash .st-paint-slider {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.trek-dash .st-paint-slider-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ----------------- pattern tiles ----------------- */
|
||||
.trek-dash .st-pattern-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 5px;
|
||||
}
|
||||
.trek-dash .st-pattern-tile {
|
||||
aspect-ratio: 1;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background-color: var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--line-2);
|
||||
transition:
|
||||
transform 0.14s,
|
||||
box-shadow 0.14s;
|
||||
}
|
||||
.trek-dash .st-pattern-tile.is-clear {
|
||||
background: var(--st-check-bg), var(--surface);
|
||||
}
|
||||
.trek-dash .st-pattern-tile:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--line-2),
|
||||
var(--sh-sm);
|
||||
}
|
||||
.trek-dash .st-pattern-tile.is-on {
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
.trek-dash .st-pattern-tile:focus {
|
||||
outline: none;
|
||||
}
|
||||
.trek-dash .st-pattern-tile:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--accent-soft),
|
||||
0 0 0 3px var(--accent);
|
||||
}
|
||||
.trek-dash .st-pattern-tile-art {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ----------------- page background ----------------- */
|
||||
.trek-dash .st-paint-apply {
|
||||
align-self: flex-start;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.trek-dash .st-paint-apply.is-done {
|
||||
background: color-mix(in oklch, var(--accent) 14%, transparent);
|
||||
color: var(--ink);
|
||||
}
|
||||
.trek-dash .st-paint-field + .st-paint-field,
|
||||
.trek-dash .st-paint-apply + .st-paint-field {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.trek-dash .st-paint-hint {
|
||||
padding: 0;
|
||||
font-size: calc(11.5px * var(--fs-scale-caption, 1));
|
||||
line-height: 1.5;
|
||||
}
|
||||
.trek-dash .st-bgphoto-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 5px;
|
||||
}
|
||||
.trek-dash .st-bgphoto {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
transition:
|
||||
transform 0.14s,
|
||||
box-shadow 0.14s;
|
||||
}
|
||||
.trek-dash .st-bgphoto img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.trek-dash .st-bgphoto:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--sh-sm);
|
||||
}
|
||||
.trek-dash .st-bgphoto.is-on {
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
.trek-dash .st-bgphoto:focus {
|
||||
outline: none;
|
||||
}
|
||||
.trek-dash .st-bgphoto:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--accent-soft),
|
||||
0 0 0 3px var(--accent);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trek-dash .st-paint-tab,
|
||||
.trek-dash .st-paint-tab-chip,
|
||||
.trek-dash .st-stop,
|
||||
.trek-dash .st-dial-needle,
|
||||
.trek-dash .st-pattern-tile,
|
||||
.trek-dash .st-bgphoto {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -128,13 +128,6 @@ function pick(c: HTMLElement, label: string) {
|
||||
fireEvent.click(chip!)
|
||||
}
|
||||
|
||||
/** A kind of paint, from the picker's tabs. */
|
||||
function tab(c: HTMLElement, label: string) {
|
||||
const button = [...c.querySelectorAll('.st-paint-tab')].find(b => b.textContent === label)
|
||||
if (!button) throw new Error(`no ${label} tab`)
|
||||
fireEvent.click(button)
|
||||
}
|
||||
|
||||
/** On or off: the app switch, on its own line beside the words. */
|
||||
function toggle(c: HTMLElement, label: string) {
|
||||
const row = [...c.querySelectorAll('.st-switch')]
|
||||
@@ -448,46 +441,36 @@ describe('a shape', () => {
|
||||
|
||||
/*
|
||||
* The rule that matters here: a shape with neither fill nor stroke is a
|
||||
* shape nobody can see or find again, so taking the fill away has to bring
|
||||
* shape nobody can see or find again, so turning the fill off has to bring
|
||||
* the outline on with it.
|
||||
*/
|
||||
it('cannot be turned invisible by taking the fill away', () => {
|
||||
it('cannot be turned invisible by switching the fill off', () => {
|
||||
load([shape({ fill: '#111827', stroke: null, strokeWidth: 0 })])
|
||||
const { container } = draw()
|
||||
|
||||
tab(container, 'journey.studio.paint.none')
|
||||
toggle(container, 'journey.studio.fillOn')
|
||||
|
||||
expect(el()).toMatchObject({ fill: null, stroke: '#141414', strokeWidth: 0.5 })
|
||||
})
|
||||
|
||||
it('brings a fill back when one colour is chosen again', () => {
|
||||
it('brings the fill back when it is switched on again', () => {
|
||||
load([shape({ fill: null, stroke: '#141414', strokeWidth: 0.5 })])
|
||||
const { container } = draw()
|
||||
|
||||
tab(container, 'journey.studio.paint.solid')
|
||||
toggle(container, 'journey.studio.fillOn')
|
||||
|
||||
expect(el()).toMatchObject({ fill: '#111111', stroke: '#141414' })
|
||||
})
|
||||
|
||||
it('paints a gradient from its own colour, and keeps fill as the colour that stands for it', () => {
|
||||
load([shape({ fill: '#1e3a8a' })])
|
||||
it('fades the fill, and takes a direction once it does', () => {
|
||||
load([shape()])
|
||||
const { container } = draw()
|
||||
|
||||
tab(container, 'journey.studio.paint.gradient')
|
||||
toggle(container, 'journey.studio.gradient')
|
||||
expect(el()).toMatchObject({ gradient: 'down' })
|
||||
|
||||
const painted = el() as Extract<BookElement, { kind: 'shape' }>
|
||||
expect(painted.paint).toMatchObject({ type: 'linear', angle: 180, stops: [{ at: 0, color: '#1e3a8a' }, { at: 1 }] })
|
||||
expect(painted.fill).toMatch(/^#[0-9a-f]{6}$/)
|
||||
expect(painted.gradient).toBe('none')
|
||||
})
|
||||
|
||||
/* A cover fade from before paint reads as the gradient it draws, so the picker opens on it. */
|
||||
it('shows an old fade as a gradient', () => {
|
||||
load([shape({ gradient: 'up' })])
|
||||
const { container } = draw()
|
||||
|
||||
const on = container.querySelector('.st-paint-tab[aria-selected="true"]')
|
||||
expect(on?.textContent).toBe('journey.studio.paint.gradient')
|
||||
pick(container, 'journey.studio.gradientUp')
|
||||
expect(el()).toMatchObject({ gradient: 'up' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import { act as hookAct, renderHook } from '@testing-library/react';
|
||||
import type { BookDocument, BookElement, BookSpread } from '@trek/shared';
|
||||
import { MAX_SPREAD_ELEMENTS, bookPageSetupSchema, bookShapeElementSchema, withSpreadPaint } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
backgroundCandidates,
|
||||
backgroundPhotoOf,
|
||||
backgroundToAll,
|
||||
bleedFrame,
|
||||
withBackgroundPhoto,
|
||||
withoutBackgroundPhoto,
|
||||
} from '../../../src/components/Studio/paint/backgroundPhoto';
|
||||
import { usePaintChannel } from '../../../src/components/Studio/paint/usePaintChannel';
|
||||
import { StudioBackgroundSection } from '../../../src/components/Studio/StudioBackgroundSection';
|
||||
import type { JourneySource } from '../../../src/components/Studio/StudioSidebar';
|
||||
import { useStudioStore } from '../../../src/store/studioStore';
|
||||
import { act, fireEvent, render } from '../../helpers/render';
|
||||
|
||||
/** The value, or a failed test saying it was missing. */
|
||||
function need<T>(value: T | null | undefined): T {
|
||||
if (value == null) throw new Error('expected a value');
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page background: its paint, the same background on every page, and a
|
||||
* photograph under the whole page; and the channel that makes a drag one undo.
|
||||
*/
|
||||
|
||||
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
|
||||
const frame = { x: 20, y: 20, w: 40, h: 40 };
|
||||
const shape = (id: string): BookElement => bookShapeElementSchema.parse({ id, kind: 'shape', frame }) as BookElement;
|
||||
|
||||
const spread = (id: string, over: Partial<BookSpread> = {}): BookSpread => ({
|
||||
id,
|
||||
role: 'inner',
|
||||
background: null,
|
||||
elements: [],
|
||||
parked: [],
|
||||
entryId: null,
|
||||
...over,
|
||||
});
|
||||
const book = (spreads: BookSpread[]): BookDocument => ({ version: 1, title: 'T', page, spreads }) as BookDocument;
|
||||
const stripes = {
|
||||
type: 'pattern' as const,
|
||||
pattern: 'stripes' as const,
|
||||
fg: '#112233',
|
||||
bg: '#ffeedd',
|
||||
scale: 1,
|
||||
angle: 45,
|
||||
};
|
||||
|
||||
const source: JourneySource = {
|
||||
entries: [
|
||||
{
|
||||
id: 4,
|
||||
title: 'Porto',
|
||||
story: null,
|
||||
location: null,
|
||||
date: null,
|
||||
lat: null,
|
||||
lng: null,
|
||||
mood: null,
|
||||
weather: null,
|
||||
pros: [],
|
||||
cons: [],
|
||||
photoIds: [21, 22],
|
||||
},
|
||||
],
|
||||
photos: [
|
||||
{ photoId: 21, entryIds: [4] },
|
||||
{ photoId: 30, entryIds: [] },
|
||||
],
|
||||
photoEntries: {},
|
||||
};
|
||||
|
||||
describe('a background photo', () => {
|
||||
it('covers the whole sheet, bleed included, on a spread and on a single page', () => {
|
||||
expect(bleedFrame(spread('a'), page)).toEqual({ x: -3, y: -3, w: 426, h: 216 });
|
||||
expect(bleedFrame(spread('c', { role: 'cover' }), page)).toEqual({ x: -3, y: -3, w: 216, h: 216 });
|
||||
});
|
||||
|
||||
it('goes in at the back, locked, and replaces the one there was', () => {
|
||||
let n = 0;
|
||||
const newId = () => `bg${++n}`;
|
||||
const once = need(withBackgroundPhoto(spread('a', { elements: [shape('s1')] }), page, 12, newId));
|
||||
expect(once.elements.map((e) => e.id)).toEqual(['bg1', 's1']);
|
||||
expect(once.elements[0]).toMatchObject({
|
||||
kind: 'photo',
|
||||
photoId: 12,
|
||||
locked: true,
|
||||
fit: 'cover',
|
||||
frame: { x: -3, w: 426 },
|
||||
});
|
||||
expect(backgroundPhotoOf(once, page)?.photoId).toBe(12);
|
||||
|
||||
const twice = need(withBackgroundPhoto(once, page, 13, newId));
|
||||
expect(twice.elements.map((e) => e.id)).toEqual(['bg1', 's1']);
|
||||
expect(backgroundPhotoOf(twice, page)?.photoId).toBe(13);
|
||||
|
||||
expect(withoutBackgroundPhoto(twice, page).elements.map((e) => e.id)).toEqual(['s1']);
|
||||
const plain = spread('b', { elements: [shape('s1')] });
|
||||
expect(withoutBackgroundPhoto(plain, page)).toBe(plain);
|
||||
});
|
||||
|
||||
it('is not a photo that only happens to be at the back: unlocked, turned or smaller does not count', () => {
|
||||
const placed = need(withBackgroundPhoto(spread('a'), page, 5, () => 'p'));
|
||||
const back = placed.elements[0] as Extract<BookElement, { kind: 'photo' }>;
|
||||
expect(backgroundPhotoOf({ ...placed, elements: [{ ...back, locked: false }] }, page)).toBeNull();
|
||||
expect(backgroundPhotoOf({ ...placed, elements: [{ ...back, rotation: 3 }] }, page)).toBeNull();
|
||||
expect(backgroundPhotoOf({ ...placed, elements: [{ ...back, frame }] }, page)).toBeNull();
|
||||
expect(backgroundPhotoOf({ ...placed, elements: [{ ...back, photoId: null }] }, page)).toBeNull();
|
||||
expect(backgroundPhotoOf(undefined, page)).toBeNull();
|
||||
});
|
||||
|
||||
it('is refused on a full page', () => {
|
||||
const full = spread('a', { elements: Array.from({ length: MAX_SPREAD_ELEMENTS }, (_, i) => shape(`s${i}`)) });
|
||||
expect(withBackgroundPhoto(full, page, 1, () => 'x')).toBeNull();
|
||||
});
|
||||
|
||||
it('is offered from the page first, then its entry, then the gallery, each once', () => {
|
||||
expect(backgroundCandidates([3, 4], [4, 5], [5, 6, 7], 4)).toEqual([3, 4, 5, 6]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the same background on every page', () => {
|
||||
it('copies the paint and its colour to the pages of its kind, the covers to the covers', () => {
|
||||
const from = withSpreadPaint(spread('a'), stripes);
|
||||
const doc = book([
|
||||
spread('cv', { role: 'cover', background: '#202020' }),
|
||||
from,
|
||||
spread('b', { background: '#000000' }),
|
||||
spread('l', { role: 'last' }),
|
||||
]);
|
||||
const out = backgroundToAll(doc, 1);
|
||||
expect(out.spreads[2]).toMatchObject({ backgroundPaint: stripes, background: '#ffeedd' });
|
||||
expect(out.spreads[3]).toMatchObject({ backgroundPaint: stripes });
|
||||
expect(out.spreads[0]).toBe(doc.spreads[0]);
|
||||
expect(backgroundToAll(doc, 9)).toBe(doc);
|
||||
|
||||
const covers = backgroundToAll(doc, 0);
|
||||
expect(covers.spreads[2]).toBe(doc.spreads[2]);
|
||||
const paper = backgroundToAll(book([spread('a'), spread('b', { background: '#000000' })]), 0);
|
||||
expect(paper.spreads[1].background).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the background section', () => {
|
||||
beforeEach(() => {
|
||||
useStudioStore.getState().load(book([spread('a', { entryId: 4, elements: [shape('s1')] }), spread('b')]));
|
||||
});
|
||||
const draw = () => render(<StudioBackgroundSection spreadIndex={0} page={page} source={source} t={(k) => k} />);
|
||||
const spreads = () => need(useStudioStore.getState().doc).spreads;
|
||||
|
||||
it('paints the page, gives every page the same, and says so for a moment', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { container } = draw();
|
||||
fireEvent.click(
|
||||
need(
|
||||
[...container.querySelectorAll('.st-paint-tab')].find((b) => b.textContent === 'journey.studio.paint.solid')
|
||||
)
|
||||
);
|
||||
expect(spreads()[0].background).toBe('#111111');
|
||||
const apply = need(container.querySelector('.st-paint-apply'));
|
||||
fireEvent.click(apply);
|
||||
expect(spreads()[1].background).toBe('#111111');
|
||||
expect(apply.textContent).toBe('journey.studio.paint.appliedAll');
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
expect(apply.textContent).toBe('journey.studio.paint.applyAll');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('offers the entry and the gallery photos, lays one under the page, and takes it away again', () => {
|
||||
const { container } = draw();
|
||||
const thumbs = container.querySelectorAll('.st-bgphoto');
|
||||
expect(thumbs).toHaveLength(3);
|
||||
fireEvent.click(thumbs[1]);
|
||||
expect(backgroundPhotoOf(spreads()[0], page)?.photoId).toBe(22);
|
||||
expect(need(container.querySelector('.st-bgphoto[aria-pressed="true"] img')).getAttribute('src')).toContain('22');
|
||||
fireEvent.click(need(container.querySelector('.st-paint-apply.is-danger')));
|
||||
expect(backgroundPhotoOf(spreads()[0], page)).toBeNull();
|
||||
fireEvent.click(thumbs[0]);
|
||||
fireEvent.click(need(container.querySelector('.st-bgphoto[aria-pressed="true"]')));
|
||||
expect(spreads()[0].elements.map((e) => e.id)).toEqual(['s1']);
|
||||
});
|
||||
|
||||
it('says how to get a photo when the journey has none', () => {
|
||||
const { container } = render(
|
||||
<StudioBackgroundSection
|
||||
spreadIndex={1}
|
||||
page={page}
|
||||
source={{ entries: [], photos: [], photoEntries: {} }}
|
||||
t={(k) => k}
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector('.st-bgphoto')).toBeNull();
|
||||
expect(container.textContent).toContain('journey.studio.paint.noPhotos');
|
||||
});
|
||||
|
||||
it('draws nothing for a page that is not there', () => {
|
||||
const { container } = render(<StudioBackgroundSection spreadIndex={9} page={page} source={source} t={(k) => k} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the paint channel', () => {
|
||||
beforeEach(() => {
|
||||
useStudioStore.getState().load(book([spread('a')]));
|
||||
});
|
||||
|
||||
it('makes a drag one undo step, and a click one of its own', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePaintChannel((paint) => (doc) => ({
|
||||
...doc,
|
||||
spreads: doc.spreads.map((sp) => withSpreadPaint(sp, paint)),
|
||||
}))
|
||||
);
|
||||
const store = useStudioStore.getState;
|
||||
|
||||
hookAct(() => {
|
||||
result.current.live({ type: 'solid', color: '#111111', alpha: 0.2 });
|
||||
result.current.live({ type: 'solid', color: '#111111', alpha: 0.5 });
|
||||
result.current.commit({ type: 'solid', color: '#111111', alpha: 0.6 });
|
||||
});
|
||||
expect(need(store().doc).spreads[0].backgroundPaint).toEqual({ type: 'solid', color: '#111111', alpha: 0.6 });
|
||||
expect(store().past).toHaveLength(1);
|
||||
|
||||
hookAct(() => {
|
||||
result.current.commit({ type: 'solid', color: '#222222' });
|
||||
});
|
||||
expect(need(store().doc).spreads[0].background).toBe('#222222');
|
||||
expect(store().past).toHaveLength(2);
|
||||
|
||||
hookAct(() => {
|
||||
store().undo();
|
||||
});
|
||||
hookAct(() => {
|
||||
store().undo();
|
||||
});
|
||||
expect(need(store().doc).spreads[0].backgroundPaint).toBeUndefined();
|
||||
expect(need(store().doc).spreads[0].background).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,176 +0,0 @@
|
||||
import type { BookGradientPaint, BookPaint } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
addStop,
|
||||
clampRadius,
|
||||
clampScale,
|
||||
clampUnit,
|
||||
colourAt,
|
||||
cssPaint,
|
||||
cssStops,
|
||||
dialAngle,
|
||||
gradientAs,
|
||||
kindOf,
|
||||
leadColour,
|
||||
mixHex,
|
||||
normAngle,
|
||||
paintAs,
|
||||
partnerOf,
|
||||
removeStop,
|
||||
reverseStops,
|
||||
updateStop,
|
||||
} from '../../../src/components/Studio/paint/paintEdit';
|
||||
|
||||
/** The value, or a failed test saying it was missing. */
|
||||
function need<T>(value: T | null | undefined): T {
|
||||
if (value == null) throw new Error('expected a value');
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The picker's arithmetic: kinds, stops, angles and the previews it draws of them. */
|
||||
|
||||
const grad: BookGradientPaint = {
|
||||
type: 'linear',
|
||||
angle: 90,
|
||||
stops: [
|
||||
{ at: 0, color: '#000000' },
|
||||
{ at: 1, color: '#ffffff', alpha: 0.5 },
|
||||
],
|
||||
};
|
||||
|
||||
describe('kinds', () => {
|
||||
it('names the kind of a paint, a gradient being linear or radial', () => {
|
||||
expect(kindOf(null)).toBe('none');
|
||||
expect(kindOf({ type: 'solid', color: '#000000' })).toBe('solid');
|
||||
expect(kindOf(grad)).toBe('gradient');
|
||||
expect(kindOf({ ...grad, type: 'radial', cx: 0.5, cy: 0.5, r: 1 } as BookPaint)).toBe('gradient');
|
||||
expect(kindOf({ type: 'pattern', pattern: 'dots', fg: '#000000', bg: null, scale: 1, angle: 0 })).toBe('pattern');
|
||||
});
|
||||
|
||||
it('keeps the colour across kinds: a solid becomes a gradient from it and a pattern in it', () => {
|
||||
const solid: BookPaint = { type: 'solid', color: '#1e3a8a' };
|
||||
expect(paintAs('none', solid)).toBeNull();
|
||||
expect(paintAs('solid', grad)).toEqual({ type: 'solid', color: '#000000' });
|
||||
const g = paintAs('gradient', solid);
|
||||
expect(g).toMatchObject({ type: 'linear', angle: 180, stops: [{ at: 0, color: '#1e3a8a' }, { at: 1 }] });
|
||||
const p = paintAs('pattern', solid);
|
||||
expect(p).toMatchObject({ type: 'pattern', pattern: 'stripes', fg: '#1e3a8a', scale: 1, angle: 45 });
|
||||
expect(paintAs('solid', null)).toEqual({ type: 'solid', color: '#111111' });
|
||||
});
|
||||
|
||||
it('brings back the paint a kind had when it was left', () => {
|
||||
const kept: BookPaint = { type: 'pattern', pattern: 'polka', fg: '#c2410c', bg: null, scale: 2, angle: 0 };
|
||||
expect(paintAs('pattern', grad, { pattern: kept })).toBe(kept);
|
||||
});
|
||||
|
||||
it('reads the lead colour of a paint, with a fallback for none', () => {
|
||||
expect(leadColour(grad)).toBe('#000000');
|
||||
expect(leadColour(null, '#abcdef')).toBe('#abcdef');
|
||||
});
|
||||
|
||||
it('finds a partner that is lighter for a dark colour and darker for a light one', () => {
|
||||
expect(partnerOf('#000000')).toBe(mixHex('#000000', '#ffffff', 0.65));
|
||||
expect(partnerOf('#ffffff')).toBe(mixHex('#ffffff', '#111111', 0.35));
|
||||
expect(partnerOf('nope')).toBe('#ffffff');
|
||||
expect(mixHex('#000000', '#ffffff', 0.5)).toBe('#808080');
|
||||
expect(mixHex('bad', '#ffffff', 0.5)).toBe('bad');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stops', () => {
|
||||
it('reads the colour a gradient has at a point, alpha and all', () => {
|
||||
expect(colourAt(grad.stops, 0.5)).toEqual({ color: '#808080', alpha: 0.75 });
|
||||
expect(colourAt(grad.stops, -1)).toEqual({ color: '#000000', alpha: 1 });
|
||||
expect(colourAt(grad.stops, 2)).toEqual({ color: '#ffffff', alpha: 0.5 });
|
||||
expect(colourAt([], 0.5)).toEqual({ color: '#111111', alpha: 1 });
|
||||
expect(
|
||||
colourAt(
|
||||
[
|
||||
{ at: 0.5, color: '#ff0000' },
|
||||
{ at: 0.5, color: '#0000ff' },
|
||||
],
|
||||
0.5
|
||||
).color
|
||||
).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('adds a stop in the colour already there, and refuses a seventh', () => {
|
||||
const added = need(addStop(grad, 0.25));
|
||||
expect(added.index).toBe(2);
|
||||
expect(added.paint.stops[2]).toEqual({ at: 0.25, color: '#404040', alpha: 0.875 });
|
||||
let full: BookGradientPaint = grad;
|
||||
for (let i = 0; i < 4; i++) full = need(addStop(full, i / 10)).paint;
|
||||
expect(full.stops).toHaveLength(6);
|
||||
expect(addStop(full, 0.5)).toBeNull();
|
||||
expect(need(addStop(grad, 4)).paint.stops[2].at).toBe(1);
|
||||
});
|
||||
|
||||
it('removes a stop but keeps two, and ignores an index that is not there', () => {
|
||||
const three = need(addStop(grad, 0.5)).paint;
|
||||
expect(removeStop(three, 2).stops).toHaveLength(2);
|
||||
expect(removeStop(grad, 0)).toBe(grad);
|
||||
expect(removeStop(three, 9)).toBe(three);
|
||||
});
|
||||
|
||||
it('moves and recolours a stop, keeping it on the bar and dropping a full alpha', () => {
|
||||
const moved = updateStop(grad, 0, { at: 1.4 });
|
||||
expect(moved.stops[0].at).toBe(1);
|
||||
const opaque = updateStop(grad, 1, { alpha: 1 });
|
||||
expect('alpha' in opaque.stops[1]).toBe(false);
|
||||
expect(updateStop(grad, 1, { color: '#ff0000' }).stops[1]).toEqual({ at: 1, color: '#ff0000', alpha: 0.5 });
|
||||
});
|
||||
|
||||
it('runs a gradient the other way, and turns linear into radial and back with its stops', () => {
|
||||
expect(reverseStops(grad).stops.map((s) => s.at)).toEqual([1, 0]);
|
||||
const r = gradientAs(grad, 'radial');
|
||||
expect(r).toEqual({ type: 'radial', cx: 0.5, cy: 0.5, r: 1, stops: grad.stops });
|
||||
expect(gradientAs(r, 'linear')).toEqual({ type: 'linear', angle: 180, stops: grad.stops });
|
||||
expect(gradientAs(grad, 'linear')).toBe(grad);
|
||||
});
|
||||
});
|
||||
|
||||
describe('angles and ranges', () => {
|
||||
it('keeps an angle to whole degrees from 0 to 359', () => {
|
||||
expect(normAngle(360)).toBe(0);
|
||||
expect(normAngle(-90)).toBe(270);
|
||||
expect(normAngle(45.4)).toBe(45);
|
||||
});
|
||||
|
||||
it('reads a pointer as an angle, 0 up and clockwise, snapping to the compass unless asked not to', () => {
|
||||
expect(dialAngle(0, -10)).toBe(0);
|
||||
expect(dialAngle(10, 0)).toBe(90);
|
||||
expect(dialAngle(0, 10)).toBe(180);
|
||||
expect(dialAngle(-10, 0)).toBe(270);
|
||||
// 47 degrees lands on 45; with the modifier it stays where it was pointed.
|
||||
const a = (47 * Math.PI) / 180;
|
||||
expect(dialAngle(Math.sin(a), -Math.cos(a))).toBe(45);
|
||||
expect(dialAngle(Math.sin(a), -Math.cos(a), true)).toBe(47);
|
||||
const b = (60 * Math.PI) / 180;
|
||||
expect(dialAngle(Math.sin(b), -Math.cos(b))).toBe(60);
|
||||
});
|
||||
|
||||
it('holds scale, radius and a unit inside what the document takes', () => {
|
||||
expect(clampScale(10)).toBe(4);
|
||||
expect(clampScale(0.1)).toBe(0.25);
|
||||
expect(clampRadius(0)).toBe(0.05);
|
||||
expect(clampRadius(3)).toBe(2);
|
||||
expect(clampUnit(-1)).toBe(0);
|
||||
expect(clampUnit(0.12345)).toBe(0.123);
|
||||
});
|
||||
});
|
||||
|
||||
describe('previews', () => {
|
||||
it('writes stops and paints as CSS for the picker chips', () => {
|
||||
expect(cssStops(grad.stops)).toBe('rgb(0 0 0 / 1) 0%, rgb(255 255 255 / 0.5) 100%');
|
||||
expect(cssPaint(null)).toBeUndefined();
|
||||
expect(cssPaint({ type: 'solid', color: '#ff0000', alpha: 0.2 })).toBe('rgb(255 0 0 / 0.2)');
|
||||
expect(cssPaint(grad)).toBe(`linear-gradient(90deg, ${cssStops(grad.stops)})`);
|
||||
expect(cssPaint({ type: 'radial', cx: 0.25, cy: 0.5, r: 1, stops: grad.stops })).toMatch(
|
||||
/^radial-gradient\(circle at 25% 50%/
|
||||
);
|
||||
expect(cssPaint({ type: 'pattern', pattern: 'dots', fg: '#000000', bg: '#ffffff', scale: 1, angle: 0 })).toBe(
|
||||
'#ffffff'
|
||||
);
|
||||
expect(cssPaint({ type: 'pattern', pattern: 'dots', fg: '#000000', bg: null, scale: 1, angle: 0 })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,234 +0,0 @@
|
||||
import type { BookDocument, BookSpread } from '@trek/shared';
|
||||
import { bookPhotoElementSchema, bookShapeElementSchema } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
MAX_RECENT,
|
||||
RECENT_KEY,
|
||||
readRecent,
|
||||
spreadPhotoIds,
|
||||
withRecent,
|
||||
} from '../../../src/components/Studio/paint/paletteModel';
|
||||
import {
|
||||
kmeansPalette,
|
||||
photoSwatches,
|
||||
readPhotoPalette,
|
||||
samplePixels,
|
||||
} from '../../../src/components/Studio/paint/photoPalette';
|
||||
import { StudioPaletteProvider } from '../../../src/components/Studio/paint/StudioPalette';
|
||||
import { AlphaSlider, Swatches } from '../../../src/components/Studio/StudioSwatches';
|
||||
import { act, fireEvent, render } from '../../helpers/render';
|
||||
|
||||
/** The value, or a failed test saying it was missing. */
|
||||
function need<T>(value: T | null | undefined): T {
|
||||
if (value == null) throw new Error('expected a value');
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The colours the swatch rows offer beyond the fixed inks: the book's own,
|
||||
* the photos', and what was picked lately, and the opacity slider under them.
|
||||
*/
|
||||
|
||||
const frame = { x: 0, y: 0, w: 10, h: 10 };
|
||||
const labels = { book: 'Book', photos: 'Photos', recent: 'Recent', opacity: 'Opacity' };
|
||||
|
||||
const spread = (over: Partial<BookSpread> = {}): BookSpread => ({
|
||||
id: 'sp1',
|
||||
role: 'inner',
|
||||
background: '#f4efe6',
|
||||
elements: [],
|
||||
parked: [],
|
||||
entryId: null,
|
||||
...over,
|
||||
});
|
||||
const doc = (spreads: BookSpread[]): BookDocument => ({
|
||||
version: 1,
|
||||
title: 'T',
|
||||
page: {} as BookDocument['page'],
|
||||
spreads,
|
||||
});
|
||||
|
||||
describe('the photo palette', () => {
|
||||
it('reads the opaque pixels only, every n-th one', () => {
|
||||
const data = [255, 0, 0, 255, 0, 255, 0, 0, 0, 0, 255, 255];
|
||||
expect(samplePixels(data)).toEqual([
|
||||
{ r: 255, g: 0, b: 0 },
|
||||
{ r: 0, g: 0, b: 255 },
|
||||
]);
|
||||
expect(samplePixels(data, 2)).toEqual([
|
||||
{ r: 255, g: 0, b: 0 },
|
||||
{ r: 0, g: 0, b: 255 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('clusters a picture into its colours, largest first, the same answer every time', () => {
|
||||
const red = Array.from({ length: 30 }, () => ({ r: 250, g: 10, b: 10 }));
|
||||
const blue = Array.from({ length: 10 }, () => ({ r: 10, g: 10, b: 240 }));
|
||||
const out = kmeansPalette([...red, ...blue], 2);
|
||||
expect(out.map((s) => s.hex)).toEqual(['#fa0a0a', '#0a0af0']);
|
||||
expect(out[0].share).toBeCloseTo(0.75);
|
||||
expect(kmeansPalette([...red, ...blue], 2)).toEqual(out);
|
||||
expect(kmeansPalette([], 3)).toEqual([]);
|
||||
// Asking for more clusters than the picture has colours gives the colours it has.
|
||||
expect(kmeansPalette(red, 5).map((s) => s.hex)).toEqual(['#fa0a0a']);
|
||||
});
|
||||
|
||||
it('offers the shared colours of several photos, and one grey at most', () => {
|
||||
const swatches = photoSwatches([
|
||||
[
|
||||
{ hex: '#808080', share: 0.5 },
|
||||
{ hex: '#c2410c', share: 0.3 },
|
||||
],
|
||||
[
|
||||
{ hex: '#7f7f7f', share: 0.4 },
|
||||
{ hex: '#202020', share: 0.3 },
|
||||
{ hex: '#1e3a8a', share: 0.3 },
|
||||
],
|
||||
]);
|
||||
expect(swatches).toContain('#c2410c');
|
||||
expect(swatches).toContain('#1e3a8a');
|
||||
expect(swatches.filter((h) => h === '#808080' || h === '#7f7f7f' || h === '#202020').length).toBeLessThanOrEqual(1);
|
||||
expect(photoSwatches([], 6)).toEqual([]);
|
||||
});
|
||||
|
||||
it('resolves empty, never rejects, for a picture that will not load or a read already called off', async () => {
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(readPhotoPalette('/nope.jpg', ctrl.signal)).resolves.toEqual([]);
|
||||
|
||||
const original = globalThis.Image;
|
||||
class FailingImage {
|
||||
onerror: (() => void) | null = null;
|
||||
onload: (() => void) | null = null;
|
||||
decoding = '';
|
||||
set src(v: string) {
|
||||
if (v) queueMicrotask(() => this.onerror?.());
|
||||
}
|
||||
}
|
||||
globalThis.Image = FailingImage as unknown as typeof Image;
|
||||
try {
|
||||
await expect(readPhotoPalette('/broken.jpg')).resolves.toEqual([]);
|
||||
} finally {
|
||||
globalThis.Image = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('recent colours', () => {
|
||||
beforeEach(() => window.localStorage.clear());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('keeps each colour once, newest first, up to the limit', () => {
|
||||
let list: string[] = [];
|
||||
for (let i = 0; i < 12; i++) list = withRecent(list, `#00000${i % 10}`);
|
||||
expect(list).toHaveLength(MAX_RECENT);
|
||||
expect(withRecent(['#aaaaaa', '#bbbbbb'], '#BBBBBB')).toEqual(['#bbbbbb', '#aaaaaa']);
|
||||
});
|
||||
|
||||
it('reads what storage holds, and an empty list from storage that is broken or refuses', () => {
|
||||
window.localStorage.setItem(RECENT_KEY, JSON.stringify(['#123456', 'red', 4]));
|
||||
expect(readRecent()).toEqual(['#123456']);
|
||||
window.localStorage.setItem(RECENT_KEY, '{oops');
|
||||
expect(readRecent()).toEqual([]);
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('denied');
|
||||
});
|
||||
expect(readRecent()).toEqual([]);
|
||||
});
|
||||
|
||||
it('remembers a picked colour and shows it as a row, where a fresh book shows the inks alone', () => {
|
||||
const { container } = render(
|
||||
<StudioPaletteProvider doc={doc([spread()])} spread={undefined} labels={labels}>
|
||||
<Swatches value="#111111" onPick={() => {}} />
|
||||
</StudioPaletteProvider>
|
||||
);
|
||||
// The book row: the page's own colour.
|
||||
expect(container.querySelector('[aria-label="Book"]')).not.toBeNull();
|
||||
expect(container.querySelector('[aria-label="Recent"]')).toBeNull();
|
||||
act(() => {
|
||||
fireEvent.click(need(container.querySelector('[aria-label="#0f766e"]')));
|
||||
});
|
||||
const recent = container.querySelector('[aria-label="Recent"]');
|
||||
expect(recent?.querySelector('[aria-label="#0f766e"]')).not.toBeNull();
|
||||
expect(JSON.parse(need(window.localStorage.getItem(RECENT_KEY)))).toEqual(['#0f766e']);
|
||||
});
|
||||
|
||||
it('carries on without storage when writing is refused', () => {
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('quota');
|
||||
});
|
||||
const { container } = render(
|
||||
<StudioPaletteProvider doc={null} spread={undefined} labels={labels}>
|
||||
<Swatches value="#111111" onPick={() => {}} />
|
||||
</StudioPaletteProvider>
|
||||
);
|
||||
act(() => {
|
||||
fireEvent.click(need(container.querySelector('[aria-label="#9f1239"]')));
|
||||
});
|
||||
expect(container.querySelector('[aria-label="Recent"] [aria-label="#9f1239"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the swatch rows', () => {
|
||||
it('lists the photos of a page, placed and parked, once each', () => {
|
||||
const p = (id: string, photoId: number | null) =>
|
||||
bookPhotoElementSchema.parse({ id, kind: 'photo', frame, photoId });
|
||||
const shape = bookShapeElementSchema.parse({ id: 's', kind: 'shape', frame });
|
||||
expect(
|
||||
spreadPhotoIds(spread({ elements: [p('a', 3), shape, p('b', null), p('c', 3)], parked: [p('d', 9)] }))
|
||||
).toEqual([3, 9]);
|
||||
expect(spreadPhotoIds(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows the fixed inks alone outside the book, and hides the rows when asked', () => {
|
||||
const { container, rerender } = render(<Swatches value="#111111" onPick={() => {}} />);
|
||||
expect(container.querySelectorAll('.st-swatch-row')).toHaveLength(0);
|
||||
rerender(
|
||||
<StudioPaletteProvider doc={doc([spread()])} spread={undefined} labels={labels}>
|
||||
<Swatches value="#111111" onPick={() => {}} rows={false} />
|
||||
</StudioPaletteProvider>
|
||||
);
|
||||
expect(container.querySelectorAll('.st-swatch-row')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('remembers the colour the wheel stops on, not every one it passes', () => {
|
||||
const onPick = vi.fn();
|
||||
const { container } = render(
|
||||
<StudioPaletteProvider doc={null} spread={undefined} labels={labels}>
|
||||
<Swatches value="#111111" onPick={onPick} />
|
||||
</StudioPaletteProvider>
|
||||
);
|
||||
const wheel = need(container.querySelector('input[type="color"]'));
|
||||
fireEvent.change(wheel, { target: { value: '#123456' } });
|
||||
fireEvent.change(wheel, { target: { value: '#654321' } });
|
||||
expect(onPick).toHaveBeenCalledTimes(2);
|
||||
expect(container.querySelector('[aria-label="Recent"]')).toBeNull();
|
||||
act(() => {
|
||||
fireEvent.blur(wheel);
|
||||
});
|
||||
expect(container.querySelectorAll('[aria-label="Recent"] .st-swatch')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the opacity slider', () => {
|
||||
it('reports while dragged and once more when let go', () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(<AlphaSlider color="#ff0000" value={0.5} onChange={onChange} label="Opacity" />);
|
||||
const range = container.querySelector('input[type="range"]') as HTMLInputElement;
|
||||
expect(range.value).toBe('50');
|
||||
expect(need(container.querySelector('.st-alpha-value')).textContent).toBe('50%');
|
||||
fireEvent.change(range, { target: { value: '30' } });
|
||||
expect(onChange).toHaveBeenLastCalledWith(0.3, false);
|
||||
fireEvent.pointerUp(range);
|
||||
expect(onChange).toHaveBeenLastCalledWith(0.3, true);
|
||||
fireEvent.keyUp(range, { key: 'ArrowLeft' });
|
||||
expect(onChange).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('sits under the swatches only when the caller takes an alpha', () => {
|
||||
const { container, rerender } = render(<Swatches value="#111111" onPick={() => {}} />);
|
||||
expect(container.querySelector('.st-alpha')).toBeNull();
|
||||
rerender(<Swatches value="#111111" onPick={() => {}} alpha={0.4} onAlpha={() => {}} />);
|
||||
expect((container.querySelector('.st-alpha input') as HTMLInputElement).value).toBe('40');
|
||||
});
|
||||
});
|
||||
@@ -1,158 +0,0 @@
|
||||
import { BOOK_PATTERNS, type BookPattern } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MAX_PATTERN_PRIMITIVES,
|
||||
PATTERN_CELL_MM,
|
||||
estimatePrimitives,
|
||||
fittedCell,
|
||||
mulberry32,
|
||||
patternGeometry,
|
||||
sunburstRays,
|
||||
} from '../../../src/components/Studio/paint/patterns';
|
||||
|
||||
/**
|
||||
* The pattern generators: the twelve repeats drawn out as geometry. These are
|
||||
* what prints, so the checks are about the numbers that land in the PDF:
|
||||
* that every repeat draws, that it covers the box however it is turned, that
|
||||
* the cap holds on the largest page at the smallest scale, and that the same
|
||||
* paint always draws the same thing.
|
||||
*/
|
||||
|
||||
const box = { x: 0, y: 0, w: 60, h: 40 };
|
||||
const spread = { x: -3, y: -3, w: 426, h: 216 };
|
||||
const draw = (pattern: BookPattern, over: { scale?: number; angle?: number } = {}, at = box) =>
|
||||
patternGeometry({ pattern, scale: over.scale ?? 1, angle: over.angle ?? 0 }, at);
|
||||
|
||||
/** Every number in a path, so its range and precision can be read. */
|
||||
const numbers = (d: string) => (d.match(/-?\d*\.?\d+/g) ?? []).map(Number);
|
||||
/** Where each primitive starts: the absolute point after every M. */
|
||||
const starts = (d: string) =>
|
||||
[...d.matchAll(/M(-?[\d.]+) (-?[\d.]+)/g)].map((m) => [Number(m[1]), Number(m[2])] as const);
|
||||
|
||||
describe('every repeat', () => {
|
||||
it.each(BOOK_PATTERNS.map((p) => [p]))(
|
||||
'%s draws something, within the cap, at hundredths of a millimetre',
|
||||
(pattern) => {
|
||||
const geo = draw(pattern);
|
||||
expect(geo.count).toBeGreaterThan(0);
|
||||
expect(geo.count).toBeLessThanOrEqual(MAX_PATTERN_PRIMITIVES);
|
||||
expect(geo.layers.length).toBeGreaterThan(0);
|
||||
for (const layer of geo.layers) {
|
||||
expect(layer.d.startsWith('M')).toBe(true);
|
||||
expect(layer.opacity).toBeGreaterThan(0);
|
||||
expect(layer.opacity).toBeLessThanOrEqual(1);
|
||||
for (const n of numbers(layer.d)) {
|
||||
expect(Number.isFinite(n)).toBe(true);
|
||||
expect(Math.round(n * 100) / 100).toBe(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each(BOOK_PATTERNS.map((p) => [p]))('%s draws the same path every time', (pattern) => {
|
||||
expect(draw(pattern, { scale: 0.8, angle: 30 })).toEqual(draw(pattern, { scale: 0.8, angle: 30 }));
|
||||
});
|
||||
|
||||
it.each(BOOK_PATTERNS.map((p) => [p]))(
|
||||
'%s holds the cap across a whole double spread at the smallest scale',
|
||||
(pattern) => {
|
||||
const geo = draw(pattern, { scale: 0.25, angle: 17 }, spread);
|
||||
expect(geo.count).toBeGreaterThan(0);
|
||||
expect(geo.count).toBeLessThanOrEqual(MAX_PATTERN_PRIMITIVES);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(BOOK_PATTERNS.map((p) => [p]))('%s draws more of itself at a smaller scale, up to the cap', (pattern) => {
|
||||
if (pattern === 'gingham' || pattern === 'stripes' || pattern === 'pinstripe' || pattern === 'grid') {
|
||||
expect(draw(pattern, { scale: 0.5 }).count).toBeGreaterThan(draw(pattern, { scale: 2 }).count);
|
||||
return;
|
||||
}
|
||||
expect(draw(pattern, { scale: 0.5 }).count).toBeGreaterThanOrEqual(draw(pattern, { scale: 2 }).count);
|
||||
});
|
||||
});
|
||||
|
||||
describe('turning', () => {
|
||||
it('turns the drawing about the box centre and covers the box whichever way it points', () => {
|
||||
const geo = draw('stripes', { angle: 45 });
|
||||
expect(geo.transform).toBe('rotate(45 30 20)');
|
||||
const xs = starts(geo.layers[0].d).map(([x]) => x);
|
||||
// The region is the square around the box's circle: past both edges by the corner's reach.
|
||||
const reach = Math.hypot(60, 40) / 2;
|
||||
expect(Math.min(...xs)).toBeLessThanOrEqual(30 - reach);
|
||||
expect(Math.max(...xs)).toBeGreaterThanOrEqual(30 + reach - PATTERN_CELL_MM);
|
||||
});
|
||||
|
||||
it('draws an unturned repeat over the box alone, with no transform', () => {
|
||||
const geo = draw('checker', { angle: 360 });
|
||||
expect(geo.transform).toBeUndefined();
|
||||
const values = numbers(geo.layers[0].d);
|
||||
expect(Math.min(...values)).toBeGreaterThanOrEqual(-PATTERN_CELL_MM);
|
||||
});
|
||||
|
||||
it('turns a sunburst by its rays rather than by a transform', () => {
|
||||
const straight = draw('sunburst');
|
||||
const turned = draw('sunburst', { angle: 10 });
|
||||
expect(turned.transform).toBeUndefined();
|
||||
expect(turned.layers[0].d).not.toBe(straight.layers[0].d);
|
||||
expect(turned.count).toBe(straight.count);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the cap', () => {
|
||||
it('grows the cell instead of the page', () => {
|
||||
const cell = fittedCell('dots', 2, 426, 216);
|
||||
expect(cell).toBeGreaterThan(2);
|
||||
expect(estimatePrimitives('dots', cell, 426, 216)).toBeLessThanOrEqual(MAX_PATTERN_PRIMITIVES);
|
||||
// A small box at a fair scale keeps its own cell.
|
||||
expect(fittedCell('dots', 8, 60, 40)).toBe(8);
|
||||
});
|
||||
|
||||
it('never passes a cap given lower, and reports the cell it used', () => {
|
||||
const geo = patternGeometry({ pattern: 'confetti', scale: 0.25, angle: 0 }, spread, 50);
|
||||
expect(geo.count).toBeLessThanOrEqual(50);
|
||||
expect(geo.cell).toBeGreaterThan(PATTERN_CELL_MM * 0.25);
|
||||
});
|
||||
|
||||
it('counts a sunburst by its rays, between 8 and 72 of them', () => {
|
||||
expect(sunburstRays(PATTERN_CELL_MM)).toBe(24);
|
||||
expect(sunburstRays(1000)).toBe(8);
|
||||
expect(sunburstRays(0.01)).toBe(72);
|
||||
expect(estimatePrimitives('sunburst', PATTERN_CELL_MM, 10, 10)).toBe(24);
|
||||
expect(draw('sunburst').count).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the odd cases', () => {
|
||||
it('draws nothing for an empty box or a scale that is not a number', () => {
|
||||
expect(draw('dots', {}, { x: 0, y: 0, w: 0, h: 10 }).count).toBe(0);
|
||||
expect(patternGeometry({ pattern: 'dots', scale: Number.NaN, angle: 0 }, box).layers).toEqual([]);
|
||||
expect(patternGeometry({ pattern: 'dots', scale: -1, angle: 0 }, box).count).toBe(0);
|
||||
});
|
||||
|
||||
it('draws gingham as two half-opaque layers, so where the bands cross reads darker', () => {
|
||||
const geo = draw('gingham');
|
||||
expect(geo.layers.map((l) => l.opacity)).toEqual([0.5, 0.5]);
|
||||
});
|
||||
|
||||
it('scatters confetti from a seed, the same scatter for the same paint', () => {
|
||||
const a = mulberry32(7);
|
||||
const b = mulberry32(7);
|
||||
const seq = [a(), a(), a()];
|
||||
expect([b(), b(), b()]).toEqual(seq);
|
||||
for (const v of seq) {
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThan(1);
|
||||
}
|
||||
expect(draw('confetti', { scale: 1 })).not.toEqual(draw('confetti', { scale: 1.5 }));
|
||||
});
|
||||
|
||||
it('offsets a box that does not start at the origin', () => {
|
||||
const geo = draw('grid', {}, { x: 100, y: 50, w: 20, h: 20 });
|
||||
for (const [x, y] of starts(geo.layers[0].d)) {
|
||||
expect(x).toBeGreaterThanOrEqual(100 - PATTERN_CELL_MM);
|
||||
expect(y).toBeGreaterThanOrEqual(50 - PATTERN_CELL_MM);
|
||||
expect(x).toBeLessThan(120);
|
||||
expect(y).toBeLessThan(70);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,324 +0,0 @@
|
||||
import type { BookPaint } from '@trek/shared';
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { StudioPaintPicker } from '../../../src/components/Studio/StudioPaintPicker';
|
||||
import { AngleDial } from '../../../src/components/Studio/paint/AngleDial';
|
||||
import { GradientStopBar } from '../../../src/components/Studio/paint/GradientStopBar';
|
||||
import { RadialPad } from '../../../src/components/Studio/paint/RadialPad';
|
||||
import type { PaintChannel } from '../../../src/components/Studio/paint/usePaintChannel';
|
||||
import { fireEvent, render } from '../../helpers/render';
|
||||
|
||||
/** The value, or a failed test saying it was missing. */
|
||||
function need<T>(value: T | null | undefined): T {
|
||||
if (value == null) throw new Error('expected a value');
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The paint picker, driven the way a person drives it: tabs, swatches, the
|
||||
* stop bar, the dial. What is asserted is the paint it writes and whether a
|
||||
* write is a live step inside a drag or the one that ends it.
|
||||
*/
|
||||
|
||||
const t = (k: string, p?: Record<string, string | number>) => (p ? `${k}:${JSON.stringify(p)}` : k);
|
||||
|
||||
/** The picker over its own state, as the inspector holds it through the store. */
|
||||
function Harness({ start, channel }: { start: BookPaint | null; channel: PaintChannel }) {
|
||||
const [value, setValue] = useState<BookPaint | null>(start);
|
||||
const write: PaintChannel = {
|
||||
live: (p) => {
|
||||
channel.live(p);
|
||||
setValue(p);
|
||||
},
|
||||
commit: (p) => {
|
||||
channel.commit(p);
|
||||
setValue(p);
|
||||
},
|
||||
};
|
||||
return <StudioPaintPicker value={value} channel={write} t={t} noneLabel="none" />;
|
||||
}
|
||||
|
||||
const spy = () => ({ live: vi.fn(), commit: vi.fn() });
|
||||
const last = (fn: ReturnType<typeof vi.fn>) => fn.mock.calls[fn.mock.calls.length - 1]?.[0] as BookPaint | null;
|
||||
|
||||
/** A rect for the bar, the dial or the pad: jsdom lays nothing out. */
|
||||
function sized(el: Element, box = { left: 0, top: 0, width: 200, height: 24 }) {
|
||||
el.getBoundingClientRect = () =>
|
||||
({
|
||||
...box,
|
||||
right: box.left + box.width,
|
||||
bottom: box.top + box.height,
|
||||
x: box.left,
|
||||
y: box.top,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
}
|
||||
|
||||
const tab = (c: HTMLElement, label: string) =>
|
||||
fireEvent.click(need([...c.querySelectorAll('.st-paint-tab')].find((b) => b.textContent === label)));
|
||||
|
||||
describe('the kind tabs', () => {
|
||||
it('marks the kind the paint is, and offers no fill under its own name', () => {
|
||||
const { container } = render(<Harness start={null} channel={spy()} />);
|
||||
const tabs = [...container.querySelectorAll('.st-paint-tab')];
|
||||
expect(tabs.map((b) => b.textContent)).toEqual([
|
||||
'none',
|
||||
'journey.studio.paint.solid',
|
||||
'journey.studio.paint.gradient',
|
||||
'journey.studio.paint.pattern',
|
||||
]);
|
||||
expect(tabs[0].getAttribute('aria-selected')).toBe('true');
|
||||
});
|
||||
|
||||
it('keeps the colour across kinds, and brings a visited kind back as it was left', () => {
|
||||
const channel = spy();
|
||||
const { container } = render(<Harness start={{ type: 'solid', color: '#1e3a8a' }} channel={channel} />);
|
||||
|
||||
tab(container, 'journey.studio.paint.pattern');
|
||||
expect(last(channel.commit)).toMatchObject({ type: 'pattern', fg: '#1e3a8a' });
|
||||
fireEvent.click(need(container.querySelector('[aria-label="journey.studio.pattern.polka"]')));
|
||||
expect(last(channel.commit)).toMatchObject({ type: 'pattern', pattern: 'polka' });
|
||||
|
||||
tab(container, 'journey.studio.paint.gradient');
|
||||
expect(last(channel.commit)).toMatchObject({ type: 'linear', stops: [{ color: '#1e3a8a' }, {}] });
|
||||
|
||||
tab(container, 'journey.studio.paint.pattern');
|
||||
expect(last(channel.commit)).toMatchObject({ type: 'pattern', pattern: 'polka' });
|
||||
|
||||
tab(container, 'none');
|
||||
expect(last(channel.commit)).toBeNull();
|
||||
expect(channel.live).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('one colour', () => {
|
||||
it('writes the swatch picked, and its opacity live while dragged and once when let go', () => {
|
||||
const channel = spy();
|
||||
const { container } = render(<Harness start={{ type: 'solid', color: '#111111' }} channel={channel} />);
|
||||
fireEvent.click(need(container.querySelector('[aria-label="#c2410c"]')));
|
||||
expect(last(channel.commit)).toEqual({ type: 'solid', color: '#c2410c' });
|
||||
|
||||
const range = container.querySelector('.st-alpha input') as HTMLInputElement;
|
||||
fireEvent.change(range, { target: { value: '40' } });
|
||||
expect(last(channel.live)).toEqual({ type: 'solid', color: '#c2410c', alpha: 0.4 });
|
||||
fireEvent.pointerUp(range);
|
||||
expect(last(channel.commit)).toEqual({ type: 'solid', color: '#c2410c', alpha: 0.4 });
|
||||
|
||||
fireEvent.change(range, { target: { value: '100' } });
|
||||
fireEvent.pointerUp(range);
|
||||
expect(last(channel.commit)).toEqual({ type: 'solid', color: '#c2410c' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a gradient', () => {
|
||||
const start: BookPaint = {
|
||||
type: 'linear',
|
||||
angle: 90,
|
||||
stops: [
|
||||
{ at: 0, color: '#000000' },
|
||||
{ at: 1, color: '#ffffff' },
|
||||
],
|
||||
};
|
||||
|
||||
it('adds a stop where the bar is clicked, recolours the chosen stop and takes it away', () => {
|
||||
const channel = spy();
|
||||
const { container } = render(<Harness start={start} channel={channel} />);
|
||||
const bar = need(container.querySelector('.st-stopbar'));
|
||||
sized(bar);
|
||||
fireEvent.pointerDown(bar, { button: 0, clientX: 50 });
|
||||
const added = last(channel.commit);
|
||||
expect(added?.type === 'linear' && added.stops).toHaveLength(3);
|
||||
expect(added?.type === 'linear' && added.stops[2]).toEqual({ at: 0.25, color: '#404040' });
|
||||
expect(container.querySelectorAll('.st-stop')[2].getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
const stopPanel = need(container.querySelector('.st-paint-stop'));
|
||||
fireEvent.click(need(stopPanel.querySelector('[aria-label="#0f766e"]')));
|
||||
expect(last(channel.commit)).toMatchObject({ stops: [{}, {}, { at: 0.25, color: '#0f766e' }] });
|
||||
|
||||
fireEvent.click(need(stopPanel.querySelector('[aria-label="journey.studio.paint.removeStop"]')));
|
||||
const removed = last(channel.commit);
|
||||
expect(removed?.type === 'linear' && removed.stops).toHaveLength(2);
|
||||
// Two stops left: the bin is off.
|
||||
expect(
|
||||
(container.querySelector('.st-paint-stop [aria-label="journey.studio.paint.removeStop"]') as HTMLButtonElement)
|
||||
.disabled
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reverses, turns radial and back, and sets the angle from the number field', () => {
|
||||
const channel = spy();
|
||||
const { container } = render(<Harness start={start} channel={channel} />);
|
||||
fireEvent.click(need(container.querySelector('[aria-label="journey.studio.paint.reverse"]')));
|
||||
expect(last(channel.commit)).toMatchObject({ stops: [{ at: 1 }, { at: 0 }] });
|
||||
|
||||
const radio = (label: string) =>
|
||||
need([...container.querySelectorAll('.st-seg-item')].find((b) => b.textContent === label));
|
||||
fireEvent.click(radio('journey.studio.paint.radial'));
|
||||
expect(last(channel.commit)).toMatchObject({ type: 'radial', cx: 0.5, cy: 0.5, r: 1 });
|
||||
expect(container.querySelector('.st-radial-pad')).not.toBeNull();
|
||||
fireEvent.click(radio('journey.studio.paint.linear'));
|
||||
expect(last(channel.commit)).toMatchObject({ type: 'linear', angle: 180 });
|
||||
|
||||
const field = container.querySelector('.st-num input') as HTMLInputElement;
|
||||
fireEvent.change(field, { target: { value: '400' } });
|
||||
fireEvent.blur(field);
|
||||
expect(last(channel.commit)).toMatchObject({ angle: 359 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a pattern', () => {
|
||||
const start: BookPaint = { type: 'pattern', pattern: 'dots', fg: '#111111', bg: '#ffffff', scale: 1, angle: 0 };
|
||||
|
||||
it('draws a tile for each repeat, turns stripes to the diagonal, and lets the ground go', () => {
|
||||
const channel = spy();
|
||||
const { container } = render(<Harness start={start} channel={channel} />);
|
||||
expect(container.querySelectorAll('.st-pattern-tile')).toHaveLength(12);
|
||||
expect(need(container.querySelector('.st-pattern-tile[aria-checked="true"]')).getAttribute('aria-label')).toBe(
|
||||
'journey.studio.pattern.dots'
|
||||
);
|
||||
|
||||
fireEvent.click(need(container.querySelector('[aria-label="journey.studio.pattern.stripes"]')));
|
||||
expect(last(channel.commit)).toMatchObject({ pattern: 'stripes', angle: 45 });
|
||||
|
||||
fireEvent.click(need(container.querySelector('[role="switch"]')));
|
||||
expect(last(channel.commit)).toMatchObject({ bg: null });
|
||||
expect(container.querySelectorAll('.st-pattern-tile.is-clear')).toHaveLength(12);
|
||||
fireEvent.click(need(container.querySelector('[role="switch"]')));
|
||||
expect(last(channel.commit)).toMatchObject({ bg: '#ffffff' });
|
||||
});
|
||||
|
||||
it('sizes the repeat on an even ratio scale, live and then once', () => {
|
||||
const channel = spy();
|
||||
const { container } = render(<Harness start={start} channel={channel} />);
|
||||
const range = container.querySelector('.st-paint-slider input') as HTMLInputElement;
|
||||
// Scale 1 sits a little under halfway along 0.25 to 4.
|
||||
expect(Number(range.value)).toBe(50);
|
||||
fireEvent.change(range, { target: { value: '100' } });
|
||||
expect(last(channel.live)).toMatchObject({ scale: 4 });
|
||||
fireEvent.pointerUp(range);
|
||||
expect(last(channel.commit)).toMatchObject({ scale: 4 });
|
||||
expect(need(container.querySelector('.st-paint-value')).textContent).toBe('400%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the dial', () => {
|
||||
it('turns with the pointer, snapping to the compass, and is one commit when let go', () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(<AngleDial value={0} onChange={onChange} label="Angle" />);
|
||||
const dial = need(container.querySelector('.st-dial'));
|
||||
sized(dial, { left: 0, top: 0, width: 40, height: 40 });
|
||||
fireEvent.pointerDown(dial, { button: 0, clientX: 40, clientY: 20 });
|
||||
expect(onChange).toHaveBeenLastCalledWith(90, false);
|
||||
fireEvent.pointerMove(dial, { clientX: 20, clientY: 40 });
|
||||
expect(onChange).toHaveBeenLastCalledWith(180, false);
|
||||
fireEvent.pointerUp(dial);
|
||||
expect(onChange).toHaveBeenLastCalledWith(180, true);
|
||||
fireEvent.pointerMove(dial, { clientX: 0, clientY: 20 });
|
||||
expect(onChange).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('takes the arrow keys, Shift for fifteen at a time', () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(<AngleDial value={350} onChange={onChange} label="Angle" />);
|
||||
const dial = need(container.querySelector('.st-dial'));
|
||||
expect(dial.getAttribute('aria-valuenow')).toBe('350');
|
||||
fireEvent.keyDown(dial, { key: 'ArrowRight', shiftKey: true });
|
||||
expect(onChange).toHaveBeenLastCalledWith(5, false);
|
||||
fireEvent.keyUp(dial, { key: 'ArrowRight' });
|
||||
expect(onChange).toHaveBeenLastCalledWith(5, true);
|
||||
fireEvent.keyDown(dial, { key: 'a' });
|
||||
expect(onChange).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the stop bar', () => {
|
||||
const paint: BookPaint & { type: 'linear' } = {
|
||||
type: 'linear',
|
||||
angle: 0,
|
||||
stops: [
|
||||
{ at: 0, color: '#000000' },
|
||||
{ at: 0.5, color: '#888888' },
|
||||
{ at: 1, color: '#ffffff' },
|
||||
],
|
||||
};
|
||||
const labels = { bar: 'bar', stop: (pos: number) => `stop ${pos}` };
|
||||
|
||||
it('drags a stop along the bar live, and pulls it off to remove it', () => {
|
||||
const onChange = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
const { container } = render(
|
||||
<GradientStopBar paint={paint} selected={0} onSelect={onSelect} onChange={onChange} labels={labels} />
|
||||
);
|
||||
sized(need(container.querySelector('.st-stopbar')));
|
||||
const stop = container.querySelectorAll('.st-stop')[1];
|
||||
fireEvent.pointerDown(stop, { button: 0, clientX: 100 });
|
||||
expect(onSelect).toHaveBeenCalledWith(1);
|
||||
fireEvent.pointerMove(stop, { clientX: 150, clientY: 10 });
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ stops: [paint.stops[0], { at: 0.75, color: '#888888' }, paint.stops[2]] }),
|
||||
false
|
||||
);
|
||||
fireEvent.pointerMove(stop, { clientX: 150, clientY: 90 });
|
||||
expect(stop.className).toContain('is-leaving');
|
||||
fireEvent.pointerUp(stop);
|
||||
const removed = onChange.mock.calls[onChange.mock.calls.length - 1];
|
||||
expect(removed[1]).toBe(true);
|
||||
expect(removed[0].stops).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('moves the stop with the arrow keys and removes it with Delete', () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<GradientStopBar paint={paint} selected={1} onSelect={() => {}} onChange={onChange} labels={labels} />
|
||||
);
|
||||
const stop = container.querySelectorAll('.st-stop')[1];
|
||||
expect(stop.getAttribute('aria-label')).toBe('stop 50');
|
||||
fireEvent.keyDown(stop, { key: 'ArrowLeft', shiftKey: true });
|
||||
expect(onChange.mock.calls[0][0].stops[1].at).toBe(0.4);
|
||||
fireEvent.keyDown(stop, { key: 'Delete' });
|
||||
expect(onChange.mock.calls[1][0].stops).toHaveLength(2);
|
||||
fireEvent.keyDown(stop, { key: 'x' });
|
||||
expect(onChange).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not add a stop past six, or when the click lands on a stop', () => {
|
||||
const onChange = vi.fn();
|
||||
const six = { ...paint, stops: [0, 0.2, 0.4, 0.6, 0.8, 1].map((at) => ({ at, color: '#000000' })) };
|
||||
const { container } = render(
|
||||
<GradientStopBar paint={six} selected={0} onSelect={() => {}} onChange={onChange} labels={labels} />
|
||||
);
|
||||
const bar = need(container.querySelector('.st-stopbar'));
|
||||
sized(bar);
|
||||
fireEvent.pointerDown(bar, { button: 0, clientX: 30 });
|
||||
fireEvent.pointerDown(need(container.querySelector('.st-stop')), { button: 0, clientX: 0 });
|
||||
fireEvent.pointerUp(need(container.querySelector('.st-stop')));
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the radial centre', () => {
|
||||
it('moves the centre with the pointer and the keys', () => {
|
||||
const onChange = vi.fn();
|
||||
const radial = {
|
||||
type: 'radial' as const,
|
||||
cx: 0.5,
|
||||
cy: 0.5,
|
||||
r: 1,
|
||||
stops: [
|
||||
{ at: 0, color: '#ffffff' },
|
||||
{ at: 1, color: '#000000' },
|
||||
],
|
||||
};
|
||||
const { container } = render(<RadialPad paint={radial} onChange={onChange} label="Centre" />);
|
||||
const pad = need(container.querySelector('.st-radial-pad'));
|
||||
sized(pad, { left: 0, top: 0, width: 100, height: 50 });
|
||||
fireEvent.pointerDown(pad, { button: 0, clientX: 25, clientY: 10 });
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ cx: 0.25, cy: 0.2 }), false);
|
||||
fireEvent.pointerMove(pad, { clientX: 200, clientY: -5 });
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ cx: 1, cy: 0 }), false);
|
||||
fireEvent.pointerUp(pad);
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ cx: 1, cy: 0 }), true);
|
||||
fireEvent.keyDown(pad, { key: 'ArrowDown' });
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ cx: 0.5, cy: 0.52 }), true);
|
||||
});
|
||||
});
|
||||
@@ -1,252 +0,0 @@
|
||||
import type { BookDocument, BookElement, BookPaint, BookSpread } from '@trek/shared';
|
||||
import { bookPageSetupSchema, bookPhotoElementSchema, bookShapeElementSchema } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BookSheetsView } from '../../../src/components/Studio/BookSheetsView';
|
||||
import {
|
||||
ellipsePath,
|
||||
linearLine,
|
||||
roundedRectPath,
|
||||
shapeOutline,
|
||||
strokeDash,
|
||||
} from '../../../src/components/Studio/paint/paintGeometry';
|
||||
import { PaintedPath, SpreadPaint } from '../../../src/components/Studio/paint/paintRender';
|
||||
import { SpreadView } from '../../../src/components/Studio/SpreadView';
|
||||
import { render } from '../../helpers/render';
|
||||
|
||||
/** The value, or a failed test saying it was missing. */
|
||||
function need<T>(value: T | null | undefined): T {
|
||||
if (value == null) throw new Error('expected a value');
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint as the page draws it, and so as it prints.
|
||||
*
|
||||
* The one assertion this file exists for is the defs ids: page mode draws a
|
||||
* double spread twice, once per half, and an id that is the element's own
|
||||
* would then exist twice in the document. `url(#id)` resolves to the first,
|
||||
* so the second half would borrow the first half's gradient and lose its
|
||||
* pattern clip. Every id must be unique, and every reference must land on an
|
||||
* id inside its own copy of the page.
|
||||
*/
|
||||
|
||||
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
|
||||
const frame = { x: 20, y: 30, w: 80, h: 50 };
|
||||
|
||||
const linear: BookPaint = {
|
||||
type: 'linear',
|
||||
angle: 90,
|
||||
stops: [
|
||||
{ at: 0, color: '#ff0000' },
|
||||
{ at: 1, color: '#0000ff', alpha: 0.4 },
|
||||
],
|
||||
};
|
||||
const radial: BookPaint = {
|
||||
type: 'radial',
|
||||
cx: 0.5,
|
||||
cy: 0.5,
|
||||
r: 1,
|
||||
stops: [
|
||||
{ at: 0, color: '#ffffff' },
|
||||
{ at: 1, color: '#000000' },
|
||||
],
|
||||
};
|
||||
const dots: BookPaint = { type: 'pattern', pattern: 'dots', fg: '#112233', bg: '#ffeedd', scale: 1, angle: 30 };
|
||||
|
||||
const shape = (id: string, over: Record<string, unknown> = {}): BookElement =>
|
||||
bookShapeElementSchema.parse({ id, kind: 'shape', frame, ...over }) as BookElement;
|
||||
|
||||
const painted = (): BookSpread => ({
|
||||
id: 'sp1',
|
||||
role: 'inner',
|
||||
background: '#c0c0c0',
|
||||
backgroundPaint: radial,
|
||||
elements: [
|
||||
shape('g1', { paint: linear }),
|
||||
shape('h1', { shape: 'heart', paint: dots, stroke: '#000000', strokeWidth: 1 }),
|
||||
shape('r1', { shape: 'rect', radius: 4, paint: dots }),
|
||||
shape('old', { fill: '#336699', gradient: 'down', shape: 'star-5' }),
|
||||
bookPhotoElementSchema.parse({ id: 'p1', kind: 'photo', frame, photoId: 3, mask: 'heart' }) as BookElement,
|
||||
],
|
||||
parked: [],
|
||||
entryId: null,
|
||||
});
|
||||
|
||||
const ids = (root: ParentNode) => [...root.querySelectorAll('[id]')].map((e) => e.id);
|
||||
|
||||
/** Every `url(#...)` a copy of the page points at, in attributes and in inline styles alike. */
|
||||
const refs = (root: Element) => {
|
||||
const out: string[] = [];
|
||||
for (const el of [root, ...root.querySelectorAll('*')]) {
|
||||
for (const attr of [...el.attributes]) {
|
||||
for (const m of attr.value.matchAll(/url\(#([^)]+)\)/g)) out.push(m[1]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
describe('a spread drawn twice', () => {
|
||||
it('gives every defs id of each copy its own value', () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
<div className="copy">
|
||||
<SpreadView spread={painted()} page={page} />
|
||||
</div>
|
||||
<div className="copy">
|
||||
<SpreadView spread={painted()} page={page} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
const all = ids(container);
|
||||
// A gradient, a radial background, two pattern clips, an old fade and a photo mask, per copy.
|
||||
expect(all.length).toBeGreaterThanOrEqual(12);
|
||||
expect(new Set(all).size).toBe(all.length);
|
||||
for (const id of all) expect(id).toMatch(/^[\w-]+$/);
|
||||
|
||||
for (const copy of container.querySelectorAll('.copy')) {
|
||||
const own = new Set(ids(copy));
|
||||
const pointed = refs(copy);
|
||||
expect(pointed.length).toBeGreaterThan(0);
|
||||
for (const ref of pointed) expect(own.has(ref)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('stays unique when page mode prints the two halves of a spread', () => {
|
||||
const doc = { version: 1, title: 'T', page, spreads: [painted()] } as BookDocument;
|
||||
const { container } = render(<BookSheetsView doc={doc} mode="pages" marks={false} />);
|
||||
expect(container.querySelectorAll('.bx-sheet')).toHaveLength(2);
|
||||
const all = ids(container);
|
||||
expect(new Set(all).size).toBe(all.length);
|
||||
for (const sheet of container.querySelectorAll('.bx-sheet')) {
|
||||
const own = new Set(ids(sheet));
|
||||
for (const ref of refs(sheet)) expect(own.has(ref)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the page background', () => {
|
||||
it('paints the sheet out into the bleed and leaves the paper under it', () => {
|
||||
const { container } = render(<SpreadView spread={painted()} page={page} />);
|
||||
const sheet = container.firstElementChild as HTMLElement;
|
||||
expect(sheet.style.background).toContain('rgb(255, 255, 255)');
|
||||
const svg = sheet.firstElementChild as SVGSVGElement;
|
||||
expect(svg.tagName.toLowerCase()).toBe('svg');
|
||||
expect(svg.getAttribute('width')).toBe('426mm');
|
||||
expect(svg.getAttribute('height')).toBe('216mm');
|
||||
expect(svg.style.top).toBe('-3mm');
|
||||
expect(svg.querySelector('radialGradient')?.getAttribute('r')).toBe(
|
||||
String(Math.round(Math.hypot(426, 216) * 50) / 100)
|
||||
);
|
||||
});
|
||||
|
||||
it('draws a page without paint exactly as before: its colour and nothing else', () => {
|
||||
const plain: BookSpread = { ...painted(), backgroundPaint: undefined, background: '#123456', elements: [] };
|
||||
const { container } = render(<SpreadView spread={plain} page={page} />);
|
||||
const sheet = container.firstElementChild as HTMLElement;
|
||||
expect(sheet.style.background).toContain('rgb(18, 52, 86)');
|
||||
expect(sheet.querySelector('svg')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the bleed window on paper too, when the page has a paint', () => {
|
||||
const doc = { version: 1, title: 'T', page, spreads: [{ ...painted(), role: 'cover' as const }] } as BookDocument;
|
||||
const { container } = render(<BookSheetsView doc={doc} mode="spreads" marks={false} />);
|
||||
const bleedWindow = need(container.querySelector('.bx-sheet')).firstElementChild as HTMLElement;
|
||||
expect(bleedWindow.style.background).toContain('rgb(255, 255, 255)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a painted shape', () => {
|
||||
it('draws a gradient in user space along the angle, its see-through stop as stop-opacity', () => {
|
||||
const { container } = render(
|
||||
<SpreadView spread={{ ...painted(), elements: [shape('g1', { paint: linear })] }} page={page} />
|
||||
);
|
||||
const grad = need(container.querySelector('linearGradient'));
|
||||
expect(grad.getAttribute('gradientUnits')).toBe('userSpaceOnUse');
|
||||
expect([grad.getAttribute('x1'), grad.getAttribute('x2')]).toEqual(['0', '80']);
|
||||
const stops = [...grad.querySelectorAll('stop')];
|
||||
expect(stops.map((s) => s.getAttribute('stop-opacity'))).toEqual([null, '0.4']);
|
||||
const path = container.querySelector(`path[fill="url(#${grad.id})"]`);
|
||||
expect(path).not.toBeNull();
|
||||
});
|
||||
|
||||
it('draws a pattern clipped to the outline, over its ground, under its stroke', () => {
|
||||
const { container } = render(
|
||||
<SpreadView
|
||||
spread={{
|
||||
...painted(),
|
||||
backgroundPaint: undefined,
|
||||
elements: [shape('h1', { shape: 'heart', paint: dots, stroke: '#000000', strokeWidth: 1 })],
|
||||
}}
|
||||
page={page}
|
||||
/>
|
||||
);
|
||||
const clip = need(container.querySelector('clipPath'));
|
||||
expect(clip.getAttribute('clipPathUnits')).toBe('userSpaceOnUse');
|
||||
const group = need(container.querySelector(`g[clip-path="url(#${clip.id})"]`));
|
||||
expect(need(group.querySelector('g')).getAttribute('transform')).toMatch(/^rotate\(30 /);
|
||||
const paths = [...container.querySelectorAll('svg path')];
|
||||
const ground = paths.find((p) => p.getAttribute('fill') === '#ffeedd');
|
||||
const outline = paths[paths.length - 1];
|
||||
expect(ground).toBeDefined();
|
||||
expect([outline.getAttribute('fill'), outline.getAttribute('stroke')]).toEqual(['none', '#000000']);
|
||||
});
|
||||
|
||||
it('draws a painted rectangle as an outline with its corners rounded inside the stroke', () => {
|
||||
expect(shapeOutline({ shape: 'rect', radius: 4 }, 20, 10, 1)).toBe(roundedRectPath(20, 10, 3));
|
||||
expect(shapeOutline({ shape: 'ellipse', radius: 0 }, 20, 10, 0)).toBe(ellipsePath(20, 10));
|
||||
expect(shapeOutline({ shape: 'triangle', radius: 0 }, 20, 10, 0)).toBe('M10 0L20 10L0 10Z');
|
||||
});
|
||||
|
||||
it('leaves a shape from before paint to the old renderer', () => {
|
||||
const { container } = render(
|
||||
<SpreadView
|
||||
spread={{ ...painted(), backgroundPaint: undefined, elements: [shape('o', { fill: '#336699' })] }}
|
||||
page={page}
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector('svg')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the geometry helpers', () => {
|
||||
const box = { x: 0, y: 0, w: 100, h: 50 };
|
||||
|
||||
it('runs a linear gradient the way CSS does: 0 towards the top, 90 towards the right', () => {
|
||||
expect(linearLine(0, box)).toEqual({ x1: 50, y1: 50, x2: 50, y2: 0 });
|
||||
expect(linearLine(90, box)).toEqual({ x1: 0, y1: 25, x2: 100, y2: 25 });
|
||||
expect(linearLine(180, box)).toEqual({ x1: 50, y1: 0, x2: 50, y2: 50 });
|
||||
const diag = linearLine(45, { x: 0, y: 0, w: 10, h: 10 });
|
||||
expect(diag).toEqual({ x1: 0, y1: 10, x2: 10, y2: 0 });
|
||||
});
|
||||
|
||||
it('rounds a rectangle only as far as its sides allow, and draws an ellipse in two arcs', () => {
|
||||
expect(roundedRectPath(10, 6, 0)).toBe('M0 0H10V6H0Z');
|
||||
expect(roundedRectPath(10, 6, 50)).toContain('A3 3 0 0 1');
|
||||
expect(ellipsePath(10, 6)).toBe('M0 3A5 3 0 1 0 10 3A5 3 0 1 0 0 3Z');
|
||||
});
|
||||
|
||||
it('dashes in proportion to the stroke', () => {
|
||||
expect(strokeDash('solid', 1)).toBeUndefined();
|
||||
expect(strokeDash('dashed', 2)).toBe('6 4');
|
||||
expect(strokeDash('dotted', 2)).toBe('0.02 4');
|
||||
});
|
||||
|
||||
it('draws a see-through solid with fill-opacity and no paint as an outline alone', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<PaintedPath d="M0 0H1V1Z" paint={{ type: 'solid', color: '#ff0000', alpha: 0.5 }} box={box} idPrefix="a" />
|
||||
<PaintedPath d="M0 0H1V1Z" paint={null} box={box} idPrefix="b" stroke="#000000" />
|
||||
</svg>
|
||||
);
|
||||
const [solid, none] = [...container.querySelectorAll('path')];
|
||||
expect([solid.getAttribute('fill'), solid.getAttribute('fill-opacity')]).toEqual(['#ff0000', '0.5']);
|
||||
expect([none.getAttribute('fill'), none.getAttribute('stroke')]).toEqual(['none', '#000000']);
|
||||
expect(container.querySelector('defs')).toBeNull();
|
||||
});
|
||||
|
||||
it('places the page paint from minus the bleed, left to right whatever the language', () => {
|
||||
const { container } = render(<SpreadPaint paint={radial} width={210} height={210} bleed={5} idPrefix="x" />);
|
||||
const svg = need(container.querySelector('svg'));
|
||||
expect([svg.style.direction, svg.style.insetInlineStart, svg.style.top]).toEqual(['ltr', '-5mm', '-5mm']);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import type { BookDocument, BookRecord } from '@trek/shared'
|
||||
import { BOOK_SCHEMA_REV } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Autosaving a Studio book (#1973).
|
||||
@@ -852,13 +851,13 @@ describe('a book a newer Studio saved', () => {
|
||||
expect(result.current.state.status).toBe('newer')
|
||||
})
|
||||
|
||||
/* A book goes out stamped with the revision this build writes, so an older Studio opens it read-only. */
|
||||
it('FE-STUDIO-REV-006: sends a book stamped with the revision this build writes', async () => {
|
||||
/* A book of today goes out exactly as it went out before the revision existed. */
|
||||
it('FE-STUDIO-REV-006: sends a book of today without a revision in it', async () => {
|
||||
const { result } = await mount()
|
||||
|
||||
act(() => { result.current.queueSave(doc('a'), 'T') })
|
||||
await tick()
|
||||
|
||||
expect(api.saveBook.mock.calls[0][1].document.schemaRev).toBe(BOOK_SCHEMA_REV)
|
||||
expect(api.saveBook.mock.calls[0][1].document).not.toHaveProperty('schemaRev')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,6 +64,7 @@ const REFUSAL_STATUS: Record<AiRefusalCode, number> = {
|
||||
empty_answer: 502,
|
||||
offline: 503,
|
||||
no_bookings_found: 422,
|
||||
no_tracks_found: 422,
|
||||
import_unavailable: 503,
|
||||
decide_first: 409,
|
||||
too_many_requests: 429,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { AssistantErrorCode } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* A file the user attaches in the trip chat is read by the server itself,
|
||||
* not by the model: a booking file into bookings, a route file into tracks
|
||||
* (P7). Each kind of file has its reader; AssistantService.attach runs it the
|
||||
* same way for every kind: the read step as a call of its own, then one held
|
||||
* change per thing the file holds, decided like any other change.
|
||||
*/
|
||||
|
||||
/** One change a file proposes: the server's write it is held as, and its arguments. */
|
||||
export interface FileProposal {
|
||||
toolName: string;
|
||||
/** What the change runs with, kept on the held call only. */
|
||||
args: Record<string, unknown>;
|
||||
/**
|
||||
* What the message shows of the call where that is less than it runs with
|
||||
* (a GPX text stays on the held call, out of the thread and the history).
|
||||
*/
|
||||
shownArgs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** What reading a file found: the read step's answer, and the changes it proposes. */
|
||||
export interface FileRead {
|
||||
found: Record<string, unknown>;
|
||||
proposals: FileProposal[];
|
||||
}
|
||||
|
||||
export interface AttachedFileReader {
|
||||
/** The name the read step goes by in the thread. */
|
||||
readonly readTool: string;
|
||||
/** The error a file that holds nothing to propose ends the answer with. */
|
||||
readonly emptyCode: AssistantErrorCode;
|
||||
/** Refuses a file nobody here can read, before a turn begins. */
|
||||
ensureReadable(userId: number, file: Express.Multer.File): Promise<void>;
|
||||
/** What the file holds, at most a step's worth. Persists nothing. */
|
||||
propose(userId: number, tripId: number, file: Express.Multer.File): Promise<FileRead>;
|
||||
}
|
||||
@@ -23,13 +23,13 @@ import {
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { LLM_PHOTO_EXTENSIONS } from '@trek/shared';
|
||||
import { ASSISTANT_TOUR_FILE_EXTENSIONS, LLM_PHOTO_EXTENSIONS } from '@trek/shared';
|
||||
|
||||
import type { Response } from 'express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { extname } from 'node:path';
|
||||
|
||||
/** What the assistant reads bookings from: the booking import's formats, plus calendar files. */
|
||||
/** What the assistant reads: the booking import's formats, calendar files, and route files taken in as tours (P7). */
|
||||
const ACCEPTED_EXTENSIONS: ReadonlySet<string> = new Set([
|
||||
'.eml',
|
||||
'.pdf',
|
||||
@@ -39,10 +39,11 @@ const ACCEPTED_EXTENSIONS: ReadonlySet<string> = new Set([
|
||||
'.htm',
|
||||
'.txt',
|
||||
...LLM_PHOTO_EXTENSIONS,
|
||||
...ASSISTANT_TOUR_FILE_EXTENSIONS,
|
||||
]);
|
||||
|
||||
const PHOTO_LABEL = LLM_PHOTO_EXTENSIONS.map((ext) => ext.slice(1).toUpperCase()).join(', ');
|
||||
const ACCEPTED_LABEL = `EML, PDF, PKPass, ICS, HTML, TXT, ${PHOTO_LABEL}`;
|
||||
const ACCEPTED_LABEL = `EML, PDF, PKPass, ICS, HTML, TXT, ${PHOTO_LABEL}, GPX, KML, KMZ`;
|
||||
|
||||
const UPLOAD = {
|
||||
storage: memoryStorage(),
|
||||
|
||||
@@ -3,7 +3,8 @@ import { BookingImportService } from '../booking-import/booking-import.service';
|
||||
import { DemoService } from '../common/demo.service';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
import { TripAccessService } from '../trip-membership/trip-access.service';
|
||||
import { MAX_WRITES_PER_STEP } from './assistant-tools.constants';
|
||||
import type { AttachedFileReader, FileRead } from './assistant-attached-file';
|
||||
import { IMPORT_BOOKING_TOOL, MAX_WRITES_PER_STEP, READ_BOOKING_FILE_TOOL } from './assistant-tools.constants';
|
||||
import type { ToolRun } from './assistant-tools.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { bookingImportPreviewItemSchema, LLM_PHOTO_EXTENSIONS, type BookingImportPreviewItem } from '@trek/shared';
|
||||
@@ -24,7 +25,10 @@ const BROKEN = 'This booking can no longer be read.';
|
||||
* and the `reservation_edit` right, taken when it is applied.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AssistantBookingImportService {
|
||||
export class AssistantBookingImportService implements AttachedFileReader {
|
||||
readonly readTool = READ_BOOKING_FILE_TOOL;
|
||||
readonly emptyCode = 'no_bookings_found' as const;
|
||||
|
||||
constructor(
|
||||
private readonly bookingImport: BookingImportService,
|
||||
private readonly trips: TripAccessService,
|
||||
@@ -58,6 +62,15 @@ export class AssistantBookingImportService {
|
||||
return preview.items.slice(0, MAX_WRITES_PER_STEP);
|
||||
}
|
||||
|
||||
/** The bookings of the file as the changes the chat holds, one import_booking each. */
|
||||
async propose(userId: number, _tripId: number, file: Express.Multer.File): Promise<FileRead> {
|
||||
const items = await this.read(userId, file);
|
||||
return {
|
||||
found: { bookings: items.map((item) => item.title) },
|
||||
proposals: items.map((item) => ({ toolName: IMPORT_BOOKING_TOOL, args: item as Record<string, unknown> })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the booking the user applied, from the arguments the server stored
|
||||
* when it proposed it. The answer is shaped like a tool's: the created
|
||||
|
||||
@@ -33,13 +33,17 @@ import { AssistantStreamRegistry } from './assistant-stream.registry';
|
||||
import { ADD_FOUND_PLACE_TOOL, TURNS_PER_MINUTE } from './assistant-tools.constants';
|
||||
import { AssistantToolsService } from './assistant-tools.service';
|
||||
import { AssistantCardContext } from './cards/card-context';
|
||||
import { recordOf } from './cards/card-values';
|
||||
import { defaultAfterKeyOf } from './cards/tour/tour-stop.cards';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
categoryIdForKind,
|
||||
TOUR_STOP_TYPES,
|
||||
type AssistantAddFoundPlaceRequest,
|
||||
type AssistantAddFoundPlaceResponse,
|
||||
type AssistantFoundPlace,
|
||||
type AssistantInsertFoundPlaceRequest,
|
||||
type AssistantMessage,
|
||||
type AssistantMessagePart,
|
||||
} from '@trek/shared';
|
||||
@@ -47,7 +51,22 @@ import {
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
/** What a suggestion reads of the trip: its days and places, and the categories a new place is filed in. */
|
||||
const TRIP_READS: ReadonlySet<string> = new Set(['get_trip_summary', 'list_places', 'list_categories']);
|
||||
const TRIP_READS: ReadonlySet<string> = new Set([
|
||||
'get_trip_summary',
|
||||
'list_places',
|
||||
'list_categories',
|
||||
'get_tour_plan',
|
||||
]);
|
||||
|
||||
/** The tour types a found place is taken into as a stop ("+ Tour"), as insert_tour_stop takes them. */
|
||||
const STOP_TOURS: ReadonlySet<unknown> = new Set(TOUR_STOP_TYPES);
|
||||
|
||||
/** What a suggestion reads of the trip: its card, its categories and, for "+ Tour", the tour. */
|
||||
interface TripRead {
|
||||
card: TripCard;
|
||||
categories: CardCategory[];
|
||||
tour: unknown;
|
||||
}
|
||||
|
||||
/** Message rows searched for the place a suggestion names; the history a turn sends the provider. */
|
||||
const SEARCHED_ROWS = 40;
|
||||
@@ -104,6 +123,38 @@ function suggestionArgs(
|
||||
};
|
||||
}
|
||||
|
||||
/** What either suggestion names: the place by its ref, and the user's language and zone for its card. */
|
||||
type SuggestionRequest = Pick<AssistantAddFoundPlaceRequest, 'ref' | 'locale' | 'timeZone'>;
|
||||
|
||||
/** The arguments a suggestion holds, from the place and what was read of the trip; throws to refuse. */
|
||||
type ArgsOf = (place: AssistantFoundPlace, trip: TripRead) => Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* The arguments a found place is taken into a saved tour with ("+ Tour"):
|
||||
* insert_tour_stop's, against the revision read now, after the last point
|
||||
* before the place along the way, with the tour's name for the history. A
|
||||
* tour of another trip, one not read, or one of a type that takes no stop
|
||||
* here is refused.
|
||||
*/
|
||||
export function stopArgs(place: AssistantFoundPlace, tourId: number, read: unknown): Record<string, unknown> {
|
||||
const plan = recordOf(read);
|
||||
const tour = recordOf(plan.tour);
|
||||
if (tour.place_id !== tourId || typeof tour.revision !== 'number') {
|
||||
throw aiRefusal('generic', 'Tour not found in this trip');
|
||||
}
|
||||
if (!STOP_TOURS.has(tour.tour_type)) throw aiRefusal('generic', 'Only a hike or a city walk takes a stop here');
|
||||
const spot = { lat: place.lat, lng: place.lng };
|
||||
const afterKey = defaultAfterKeyOf(plan, spot);
|
||||
return {
|
||||
via: 'insert_tour_stop',
|
||||
placeId: tourId,
|
||||
expected_revision: tour.revision,
|
||||
stop: { ...spot, name: place.name },
|
||||
...(afterKey ? { after_key: afterKey } : {}),
|
||||
tourName: typeof tour.name === 'string' ? tour.name : '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "+ Day 3" on a place a search found: the place is proposed for the day as a
|
||||
* change card, held and decided like one the model proposed, but without a
|
||||
@@ -137,6 +188,39 @@ export class AssistantFoundPlacesService {
|
||||
tripId: number,
|
||||
threadId: number,
|
||||
req: AssistantAddFoundPlaceRequest,
|
||||
): Promise<AssistantAddFoundPlaceResponse> {
|
||||
return this.hold(userId, tripId, threadId, req, null, (place, trip) => {
|
||||
if (!trip.card.days.some((day) => day.id === req.dayId)) throw aiRefusal('generic', 'Day not found in this trip');
|
||||
return suggestionArgs(place, req.dayId, trip.card, trip.categories);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* "+ Tour" on a place a search found, while a saved hike or city walk is
|
||||
* open in the planner: the place is proposed as a stop of that tour, after
|
||||
* the last point before it along the way, against the revision the tour has
|
||||
* now. Held and decided like "+ Day 3"; applying it runs insert_tour_stop
|
||||
* with the user's own rights, which checks the tour again.
|
||||
*/
|
||||
async insertIntoTour(
|
||||
userId: number,
|
||||
tripId: number,
|
||||
threadId: number,
|
||||
req: AssistantInsertFoundPlaceRequest,
|
||||
): Promise<AssistantAddFoundPlaceResponse> {
|
||||
return this.hold(userId, tripId, threadId, req, req.tourId, (place, trip) =>
|
||||
stopArgs(place, req.tourId, trip.tour),
|
||||
);
|
||||
}
|
||||
|
||||
/** Holds the change `argsOf` builds for a found place, under the rules both suggestions share. */
|
||||
private async hold(
|
||||
userId: number,
|
||||
tripId: number,
|
||||
threadId: number,
|
||||
req: SuggestionRequest,
|
||||
tourId: number | null,
|
||||
argsOf: ArgsOf,
|
||||
): Promise<AssistantAddFoundPlaceResponse> {
|
||||
if (!(await this.threads.findOwned(threadId, userId, tripId))) {
|
||||
throw aiRefusal('not_found', 'Conversation not found');
|
||||
@@ -149,7 +233,7 @@ export class AssistantFoundPlacesService {
|
||||
throw aiRefusal('busy', 'The assistant is already answering in another window');
|
||||
}
|
||||
try {
|
||||
await this.propose(userId, tripId, threadId, req);
|
||||
await this.propose(userId, tripId, threadId, req, tourId, argsOf);
|
||||
} finally {
|
||||
await this.streams.release(userId, controller);
|
||||
}
|
||||
@@ -160,20 +244,21 @@ export class AssistantFoundPlacesService {
|
||||
userId: number,
|
||||
tripId: number,
|
||||
threadId: number,
|
||||
req: AssistantAddFoundPlaceRequest,
|
||||
req: SuggestionRequest,
|
||||
tourId: number | null,
|
||||
argsOf: ArgsOf,
|
||||
): Promise<void> {
|
||||
if ((await this.toolCalls.listOpen(threadId)).length > 0) {
|
||||
throw aiRefusal('decide_first', 'Decide on the changes that are waiting first');
|
||||
}
|
||||
const place = foundPlace(await this.messages.listLatest(threadId, SEARCHED_ROWS), req.ref);
|
||||
if (!place) throw aiRefusal('generic', 'This place is not among the search results of the conversation');
|
||||
const { card, categories } = await this.tripOf(userId, tripId);
|
||||
if (!card.days.some((day) => day.id === req.dayId)) throw aiRefusal('generic', 'Day not found in this trip');
|
||||
const trip = await this.tripOf(userId, tripId, tourId);
|
||||
const args = argsOf(place, trip);
|
||||
|
||||
const id = randomUUID();
|
||||
const args = suggestionArgs(place, req.dayId, card, categories);
|
||||
const names = new Map(categories.map((category): [number, string] => [category.id, category.name]));
|
||||
const ctx = this.cardContext.contextOf(card, names, req.locale, todayIn(req.timeZone, new Date()));
|
||||
const names = new Map(trip.categories.map((category): [number, string] => [category.id, category.name]));
|
||||
const ctx = this.cardContext.contextOf(trip.card, names, req.locale, todayIn(req.timeZone, new Date()));
|
||||
const [{ preview }] = await this.cardContext.describeProposed(userId, tripId, ctx, [
|
||||
{ id, toolName: ADD_FOUND_PLACE_TOOL, args },
|
||||
]);
|
||||
@@ -208,16 +293,21 @@ export class AssistantFoundPlacesService {
|
||||
});
|
||||
}
|
||||
|
||||
/** The trip card and the categories, read through the trip tools with the user's rights. */
|
||||
private async tripOf(userId: number, tripId: number): Promise<{ card: TripCard; categories: CardCategory[] }> {
|
||||
/**
|
||||
* The trip card and the categories, read through the trip tools with the
|
||||
* user's rights, and with `tourId` the saved tour a stop goes into.
|
||||
*/
|
||||
private async tripOf(userId: number, tripId: number, tourId: number | null): Promise<TripRead> {
|
||||
const session = await this.tools.open(userId, tripId, TRIP_READS);
|
||||
try {
|
||||
const summary = await session.read('get_trip_summary');
|
||||
const places = await session.read('list_places');
|
||||
const listed = await session.read('list_categories');
|
||||
const tour = tourId === null ? null : await session.read('get_tour_plan', { placeId: tourId });
|
||||
return {
|
||||
card: buildTripCard(tripId, userId, summary.ok ? summary.value : null, places.ok ? places.value : null, null),
|
||||
categories: categoriesFrom(listed.ok ? listed.value : null),
|
||||
tour: tour?.ok ? tour.value : null,
|
||||
};
|
||||
} finally {
|
||||
await session.close();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { AssistantMessagePart } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The server's own tour steps as the model is told of them (P6, P7): a found
|
||||
* place the user took into a tour with "+ Tour", a route file read, a track
|
||||
* of it taken in. Like the booking file's notes in assistant-history.ts, in
|
||||
* plain words, since none of them is a call the model made.
|
||||
*/
|
||||
|
||||
type ToolCallPart = Extract<AssistantMessagePart, { type: 'tool-call' }>;
|
||||
|
||||
function quoted(value: unknown): string {
|
||||
return `"${typeof value === 'string' && value !== '' ? value : '?'}"`;
|
||||
}
|
||||
|
||||
/** What became of a decided change, in the words of what it was. */
|
||||
function decided(part: ToolCallPart, what: string, done: string, waiting: string): string {
|
||||
if (part.approval?.approved === true) return part.isError ? `${what} could not be ${done}.` : `${what} was ${done}.`;
|
||||
if (part.approval?.approved === false) return `The user discarded ${what}.`;
|
||||
if (part.approval?.resolution) return `${what} was not ${done}; the user moved on without deciding.`;
|
||||
return waiting;
|
||||
}
|
||||
|
||||
/** A found place the user took into a saved tour as a stop. */
|
||||
export function tourStopOutcome(part: ToolCallPart): string {
|
||||
const stop = part.args.stop && typeof part.args.stop === 'object' ? (part.args.stop as Record<string, unknown>) : {};
|
||||
const what = `${quoted(stop.name)} as a stop of the tour ${quoted(part.args.tourName)}`;
|
||||
const waiting = `The user picked ${what} from the search results; it waits for them to add or discard it.`;
|
||||
return decided(part, what, 'added', waiting);
|
||||
}
|
||||
|
||||
/** A route file read into its tracks. */
|
||||
export function tourFileReadNote(part: ToolCallPart): string {
|
||||
const file = quoted(part.args.name);
|
||||
if (part.isError) return `TREK could not read the attached route file ${file}.`;
|
||||
const result = (part.result ?? {}) as { tracks?: unknown };
|
||||
const tracks = Array.isArray(result.tracks) ? result.tracks : [];
|
||||
if (tracks.length === 0) return `TREK read the attached route file ${file} and found no track or route in it.`;
|
||||
return `TREK read the attached route file ${file} and found ${tracks.length} track(s): ${JSON.stringify(tracks)}.`;
|
||||
}
|
||||
|
||||
/** One track of a route file taken in as a hike. */
|
||||
export function tourFileOutcome(part: ToolCallPart): string {
|
||||
const what = `The track ${quoted(part.args.track_name)} of ${quoted(part.args.file_name)}`;
|
||||
const waiting = `${what} waits for the user to take it in as a hike or discard it.`;
|
||||
return decided(part, what, 'taken in as a hike', waiting);
|
||||
}
|
||||
@@ -4,13 +4,16 @@ import type { AssistantToolCallRow } from '../../db/repositories/AssistantToolCa
|
||||
import { parseDbTimestamp, utcSuffix } from '../../db/types';
|
||||
import { decodeJson } from '../../utils/json-column';
|
||||
import type { AiChatMessage, AiToolCall } from '../ai-connections/ai-chat-client.interface';
|
||||
import { tourFileOutcome, tourFileReadNote, tourStopOutcome } from './assistant-history-tours';
|
||||
import { fitToSize } from './assistant-output';
|
||||
import {
|
||||
ADD_FOUND_PLACE_TOOL,
|
||||
APPROVAL_TTL_MS,
|
||||
ASSISTANT_SERVER_TOOLS,
|
||||
IMPORT_BOOKING_TOOL,
|
||||
IMPORT_TOUR_FILE_TOOL,
|
||||
READ_BOOKING_FILE_TOOL,
|
||||
READ_TOUR_FILE_TOOL,
|
||||
TAKE_PROPOSAL_TOOL,
|
||||
} from './assistant-tools.constants';
|
||||
import {
|
||||
@@ -105,6 +108,14 @@ function takenProposalOutcome(part: ToolCallPart): string {
|
||||
return `The user took the proposed ${what} ${name} to add it; it waits for them to add or discard it.`;
|
||||
}
|
||||
|
||||
/** The note of a server step about tours (P6, P7), null for any other. */
|
||||
function tourStepNote(part: ToolCallPart): string | null {
|
||||
if (part.toolName === ADD_FOUND_PLACE_TOOL && part.args.via === 'insert_tour_stop') return tourStopOutcome(part);
|
||||
if (part.toolName === READ_TOUR_FILE_TOOL) return tourFileReadNote(part);
|
||||
if (part.toolName === IMPORT_TOUR_FILE_TOOL) return tourFileOutcome(part);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's own steps of a message (reading an attached file, the bookings
|
||||
* it found) as one note in plain words. They are no tools the provider knows,
|
||||
@@ -114,7 +125,10 @@ function takenProposalOutcome(part: ToolCallPart): string {
|
||||
export function serverStepsNote(calls: readonly ToolCallPart[]): string {
|
||||
const lines: string[] = [];
|
||||
for (const part of calls) {
|
||||
if (part.toolName === READ_BOOKING_FILE_TOOL) {
|
||||
const tourNote = tourStepNote(part);
|
||||
if (tourNote !== null) {
|
||||
lines.push(tourNote);
|
||||
} else if (part.toolName === READ_BOOKING_FILE_TOOL) {
|
||||
const result = (part.result ?? {}) as { bookings?: unknown };
|
||||
const titles = Array.isArray(result.bookings) ? result.bookings.map(quoted) : [];
|
||||
const file = quoted(part.args.name);
|
||||
|
||||
@@ -241,6 +241,7 @@ const CORE_TOOL_GROUPS: ToolGroups = {
|
||||
'TourActivitiesMcp',
|
||||
'TourActivityStatsMcp',
|
||||
'TourWalkImportMcp',
|
||||
'TourStopsMcp',
|
||||
'CollectionToursMcp',
|
||||
'PaddleRoutingMcp',
|
||||
'SkiRoutingMcp',
|
||||
|
||||
@@ -112,6 +112,18 @@ export const READ_BOOKING_FILE_TOOL = 'read_booking_file';
|
||||
/** A booking read from an attached file, held as a change card like any write. */
|
||||
export const IMPORT_BOOKING_TOOL = 'import_booking';
|
||||
|
||||
/**
|
||||
* P7: the step that reads a route file the user attached (GPX, KML, KMZ),
|
||||
* run by the server itself like the booking read, and each track or route it
|
||||
* found held as a change that takes it in as a tour through the model's
|
||||
* import_tour_gpx, named in its `via` argument.
|
||||
*/
|
||||
export const READ_TOUR_FILE_TOOL = 'read_tour_file';
|
||||
export const IMPORT_TOUR_FILE_TOOL = 'import_tour_file';
|
||||
|
||||
/** What a held track of a route file runs as. */
|
||||
const TOUR_FILE_VIA = 'import_tour_gpx';
|
||||
|
||||
/**
|
||||
* A place from the search results of an answer, suggested for a day by the
|
||||
* user ("+ Day 3"). Held as a change card like any write, without a model turn.
|
||||
@@ -143,7 +155,12 @@ export const REMEMBER_TOOL = 'remember_for_trip';
|
||||
export const PREVIEW_BOOK_PAGE_TOOL = 'preview_book_page';
|
||||
|
||||
/** The model's tools a found place is added through: a new place for the day, or one the trip has already. */
|
||||
const FOUND_PLACE_VIA: ReadonlySet<string> = new Set(['create_and_assign_place', 'assign_place_to_day']);
|
||||
const FOUND_PLACE_VIA: ReadonlySet<string> = new Set([
|
||||
'create_and_assign_place',
|
||||
'assign_place_to_day',
|
||||
// "+ Tour": the place taken into the saved tour open in the planner as a stop (P6).
|
||||
'insert_tour_stop',
|
||||
]);
|
||||
|
||||
/** The model's tools a taken proposal is created through: a hike, a city walk. */
|
||||
const PROPOSAL_VIA: ReadonlySet<string> = new Set(['create_hike', 'create_city_walk']);
|
||||
@@ -159,6 +176,7 @@ const PROPOSAL_VIA: ReadonlySet<string> = new Set(['create_hike', 'create_city_w
|
||||
*/
|
||||
export const ASSISTANT_SERVER_WRITE_TOOLS: Readonly<Record<string, AssistantWriteTool>> = {
|
||||
[IMPORT_BOOKING_TOOL]: { verbKey: 'importBooking', entity: 'reservation' },
|
||||
[IMPORT_TOUR_FILE_TOOL]: { verbKey: 'importTour', entity: 'tour' },
|
||||
// The card names the verb of the `via` tool; these are its fallbacks.
|
||||
[ADD_FOUND_PLACE_TOOL]: { verbKey: 'addPlaceToDay', entity: 'place', quiet: true },
|
||||
[TAKE_PROPOSAL_TOOL]: { verbKey: 'addTour', entity: 'tour', quiet: true },
|
||||
@@ -175,12 +193,22 @@ export function writeToolOf(toolName: string, args: Record<string, unknown>): st
|
||||
const via = typeof args.via === 'string' ? args.via : '';
|
||||
if (toolName === ADD_FOUND_PLACE_TOOL) return FOUND_PLACE_VIA.has(via) ? via : toolName;
|
||||
if (toolName === TAKE_PROPOSAL_TOOL) return PROPOSAL_VIA.has(via) ? via : toolName;
|
||||
if (toolName === IMPORT_TOUR_FILE_TOOL) return via === TOUR_FILE_VIA ? via : toolName;
|
||||
return toolName;
|
||||
}
|
||||
|
||||
/** The arguments a found place or a taken proposal runs its `via` tool with: what that tool takes, nothing else. */
|
||||
export function foundPlaceArgs(args: Record<string, unknown>): Record<string, unknown> {
|
||||
if (args.via === 'assign_place_to_day') return { dayId: args.dayId, placeId: args.placeId };
|
||||
if (args.via === TOUR_FILE_VIA) {
|
||||
// The track's own name stays with the card; the import takes the file's text, its name and the type.
|
||||
const { gpx, file_name, tour_type } = args;
|
||||
return { gpx, ...(file_name !== undefined ? { file_name } : {}), tour_type };
|
||||
}
|
||||
if (args.via === 'insert_tour_stop') {
|
||||
const { placeId, expected_revision, stop, after_key } = args;
|
||||
return { placeId, expected_revision, stop, ...(after_key !== undefined ? { after_key } : {}) };
|
||||
}
|
||||
const { via: _via, ...rest } = args;
|
||||
return rest;
|
||||
}
|
||||
@@ -188,6 +216,7 @@ export function foundPlaceArgs(args: Record<string, unknown>): Record<string, un
|
||||
/** Calls the server makes on its own: replayed to the model as a note, never as a call of an unknown tool. */
|
||||
export const ASSISTANT_SERVER_TOOLS: ReadonlySet<string> = new Set([
|
||||
READ_BOOKING_FILE_TOOL,
|
||||
READ_TOUR_FILE_TOOL,
|
||||
...Object.keys(ASSISTANT_SERVER_WRITE_TOOLS),
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { aiRefusal } from '../ai-connections/ai-connections.constants';
|
||||
import { TourFileReadService, type TourFileTrack } from '../tours/tour-file-read.service';
|
||||
import type { AttachedFileReader, FileProposal, FileRead } from './assistant-attached-file';
|
||||
import { IMPORT_TOUR_FILE_TOOL, MAX_WRITES_PER_STEP, READ_TOUR_FILE_TOOL } from './assistant-tools.constants';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
/** The longest file name an import keeps, as import_tour_gpx takes it. */
|
||||
const FILE_NAME_CHARS = 200;
|
||||
|
||||
/** Kilometres to a hundred metres: enough for the model to tell the tracks apart. */
|
||||
function roundedKm(km: number): number {
|
||||
return Math.round(km * 10) / 10;
|
||||
}
|
||||
|
||||
/** A track as the read step tells the model of it: what it is, and why it is not proposed where it is not. */
|
||||
function trackFact(track: TourFileTrack) {
|
||||
return {
|
||||
name: track.name,
|
||||
distance_km: roundedKm(track.distance_km),
|
||||
points: track.point_count,
|
||||
has_heights: track.has_heights,
|
||||
...(track.duplicate ? { skipped: 'already_in_trip' } : {}),
|
||||
...(track.gpx === null ? { skipped: 'too_large' } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* P7: a GPX, KML or KMZ file the user attached in the trip chat, read into
|
||||
* its tracks and routes (TourFileReadService, the Tours import's reader) and
|
||||
* proposed as one held change per track, which takes it in as a hike through
|
||||
* import_tour_gpx with the user's rights when they apply it (the type can be
|
||||
* changed in the planner, or by asking the assistant afterwards). A track the
|
||||
* trip has already, or one longer than one import takes, is named in the read
|
||||
* step and not proposed. Only the held call keeps the track's GPX text; the
|
||||
* thread shows its name and the file's.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AssistantTourFileService implements AttachedFileReader {
|
||||
readonly readTool = READ_TOUR_FILE_TOOL;
|
||||
readonly emptyCode = 'no_tracks_found' as const;
|
||||
|
||||
constructor(
|
||||
private readonly files: TourFileReadService,
|
||||
private readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
/** Refuses a route file where tours are off: nothing could take it in. */
|
||||
async ensureReadable(_userId: number, _file: Express.Multer.File): Promise<void> {
|
||||
if (!(await this.addons.isAddonEnabled(ADDON_IDS.TOURS))) {
|
||||
throw aiRefusal('import_unavailable', 'Tours are not enabled on this server');
|
||||
}
|
||||
}
|
||||
|
||||
async propose(_userId: number, tripId: number, file: Express.Multer.File): Promise<FileRead> {
|
||||
const read = await this.files.read(tripId, file.buffer, file.originalname);
|
||||
const fileName = file.originalname.trim().slice(0, FILE_NAME_CHARS) || 'import.gpx';
|
||||
const taken = read.tracks.filter((track) => !track.duplicate && track.gpx !== null).slice(0, MAX_WRITES_PER_STEP);
|
||||
const proposals = taken.map((track): FileProposal => {
|
||||
const shown = { via: 'import_tour_gpx', file_name: fileName, tour_type: 'hike', track_name: track.name };
|
||||
return { toolName: IMPORT_TOUR_FILE_TOOL, args: { ...shown, gpx: track.gpx }, shownArgs: shown };
|
||||
});
|
||||
return {
|
||||
found: {
|
||||
tracks: read.tracks.map(trackFact),
|
||||
// A GPX file that could not be cut into its tracks comes in as one import of all of them.
|
||||
...(read.whole ? { whole_file: true } : {}),
|
||||
...(read.left_out ? { left_out: read.left_out } : {}),
|
||||
...(read.tracks.length > taken.length ? { proposed: taken.length } : {}),
|
||||
},
|
||||
proposals,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
AssistantAddFoundPlaceDto,
|
||||
AssistantChatDto,
|
||||
AssistantDecisionsDto,
|
||||
AssistantInsertFoundPlaceDto,
|
||||
AssistantShareDto,
|
||||
AssistantStopDto,
|
||||
AssistantTakeProposalDto,
|
||||
@@ -133,6 +134,23 @@ export class AssistantController {
|
||||
return this.foundPlaces.add(user.id, Number(tripId), idParam(threadId), body);
|
||||
}
|
||||
|
||||
/**
|
||||
* "+ Tour" on a place a search found, while a saved tour is open: a change card that takes it in as a
|
||||
* stop, no model turn. It changes a tour, so it asks for the right the tour routes ask for.
|
||||
*/
|
||||
@Post('threads/:threadId/found-places/tour')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('place_edit')
|
||||
@ResponseContract(assistantAddFoundPlaceResponseSchema)
|
||||
insertFoundPlace(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('threadId') threadId: string,
|
||||
@Body() body: AssistantInsertFoundPlaceDto,
|
||||
) {
|
||||
return this.foundPlaces.insertIntoTour(user.id, Number(tripId), idParam(threadId), body);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Plan this" on a hike or a city walk an answer proposed: a change card that creates it, no model turn.
|
||||
* It holds a new tour, so it asks for the right the tour routes ask for.
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
assistantAttachmentRequestSchema,
|
||||
assistantChatRequestSchema,
|
||||
assistantDecisionsRequestSchema,
|
||||
assistantInsertFoundPlaceRequestSchema,
|
||||
assistantRenderUploadRequestSchema,
|
||||
assistantShareRequestSchema,
|
||||
assistantStopRequestSchema,
|
||||
@@ -21,6 +22,7 @@ export class AssistantAddFoundPlaceDto extends createZodDto(assistantAddFoundPla
|
||||
export class AssistantAttachmentDto extends createZodDto(assistantAttachmentRequestSchema) {}
|
||||
export class AssistantChatDto extends createZodDto(assistantChatRequestSchema) {}
|
||||
export class AssistantDecisionsDto extends createZodDto(assistantDecisionsRequestSchema) {}
|
||||
export class AssistantInsertFoundPlaceDto extends createZodDto(assistantInsertFoundPlaceRequestSchema) {}
|
||||
export class AssistantRenderUploadDto extends createZodDto(assistantRenderUploadRequestSchema) {}
|
||||
export class AssistantShareDto extends createZodDto(assistantShareRequestSchema) {}
|
||||
export class AssistantStopDto extends createZodDto(assistantStopRequestSchema) {}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { PhotoSignalsModule } from '../photos/photo-signals.module';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
import { TourFilesModule } from '../tours/tour-files.module';
|
||||
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
|
||||
import { AssistantAttachmentsController } from './assistant-attachments.controller';
|
||||
import { AssistantBookContext } from './assistant-book-context';
|
||||
@@ -29,6 +30,7 @@ import { AssistantScopes } from './assistant-scope';
|
||||
import { AssistantShareService } from './assistant-share.service';
|
||||
import { AssistantStreamRegistry, InMemoryAssistantStreamRegistry } from './assistant-stream.registry';
|
||||
import { AssistantToolsService } from './assistant-tools.service';
|
||||
import { AssistantTourFileService } from './assistant-tour-file.service';
|
||||
import { AssistantVision } from './assistant-vision';
|
||||
import { AssistantController } from './assistant.controller';
|
||||
import { AssistantService } from './assistant.service';
|
||||
@@ -74,6 +76,8 @@ import { Module } from '@nestjs/common';
|
||||
SchedulingModule,
|
||||
SettingsModule,
|
||||
TripMembershipModule,
|
||||
// P7: a route file attached in the chat, read into its tracks.
|
||||
TourFilesModule,
|
||||
MikroOrmModule.forFeature([AssistantThreads, AssistantMessages, AssistantToolCalls]),
|
||||
],
|
||||
controllers: [
|
||||
@@ -89,6 +93,7 @@ import { Module } from '@nestjs/common';
|
||||
AssistantService,
|
||||
AssistantToolsService,
|
||||
AssistantBookingImportService,
|
||||
AssistantTourFileService,
|
||||
AssistantFoundPlacesService,
|
||||
AssistantProposalsService,
|
||||
AssistantShareService,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { withRequestContext } from '../database/request-context';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
import { runAgentLoop, type AgentStore, type HeldCall, type StepRecord } from './assistant-agent';
|
||||
import type { AttachedFileReader } from './assistant-attached-file';
|
||||
import { AssistantBookingImportService } from './assistant-booking-import.service';
|
||||
import { mentionItems } from './assistant-cards.helpers';
|
||||
import { todayIn } from './assistant-context';
|
||||
@@ -48,13 +49,13 @@ import {
|
||||
ASSISTANT_SERVER_WRITE_TOOLS,
|
||||
foundPlaceArgs,
|
||||
IMPORT_BOOKING_TOOL,
|
||||
READ_BOOKING_FILE_TOOL,
|
||||
REMEMBER_TOOL,
|
||||
TURNS_PER_MINUTE,
|
||||
UNDO_WINDOW_MS,
|
||||
writeToolOf,
|
||||
} from './assistant-tools.constants';
|
||||
import { AssistantToolsService, type AssistantToolSession, type ToolRun } from './assistant-tools.service';
|
||||
import { AssistantTourFileService } from './assistant-tour-file.service';
|
||||
import { readTripCard } from './assistant-trip-scope';
|
||||
import { unchangedSinceApplied, undoSnapshotOf, undoTargetsOf } from './assistant-undo.helpers';
|
||||
import { AssistantVision } from './assistant-vision';
|
||||
@@ -78,8 +79,8 @@ import type {
|
||||
AssistantThreadListResponse,
|
||||
AssistantThreadResponse,
|
||||
AssistantThreadSummary,
|
||||
BookingImportPreviewItem,
|
||||
} from '@trek/shared';
|
||||
import { isAssistantTourFile } from '@trek/shared';
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
@@ -171,6 +172,7 @@ export class AssistantService {
|
||||
private readonly usage: AiUsageService,
|
||||
private readonly tools: AssistantToolsService,
|
||||
private readonly bookings: AssistantBookingImportService,
|
||||
private readonly tourFiles: AssistantTourFileService,
|
||||
private readonly settings: SettingsService,
|
||||
private readonly rateLimit: RateLimitService,
|
||||
private readonly streams: AssistantStreamRegistry,
|
||||
@@ -251,9 +253,9 @@ export class AssistantService {
|
||||
|
||||
/**
|
||||
* A file the user attached, in a thread of theirs on this trip or (no
|
||||
* threadId) a new one, answered as a stream: the file is read into bookings
|
||||
* and each is proposed as a change card, applied or discarded like any
|
||||
* other. No model is asked here; the decision on the cards starts the next
|
||||
* threadId) a new one, answered as a stream: the file is read into bookings,
|
||||
* or a route file (GPX, KML, KMZ) into its tracks, and each is proposed as a
|
||||
* change card, applied or discarded like any other. No model is asked here; the decision on the cards starts the next
|
||||
* answer. Refused like a chat turn, and also when nothing here reads files.
|
||||
*
|
||||
* @txIndependent the opening rows are one transaction and the proposals
|
||||
@@ -267,7 +269,8 @@ export class AssistantService {
|
||||
channel: TurnChannel,
|
||||
): Promise<void> {
|
||||
await this.admit(userId);
|
||||
await this.bookings.ensureReadable(userId, file);
|
||||
const reader: AttachedFileReader = isAssistantTourFile(file.originalname) ? this.tourFiles : this.bookings;
|
||||
await reader.ensureReadable(userId, file);
|
||||
const connection = await this.connections.resolveForUser(userId);
|
||||
await this.withinDailyLimit(userId, connection);
|
||||
await this.claimed(userId, channel.signal, async (signal) => {
|
||||
@@ -283,7 +286,7 @@ export class AssistantService {
|
||||
try {
|
||||
const { locale, timeZone } = req;
|
||||
const turn = { userId, tripId, threadId: begun.threadId, rowId: begun.rowId, locale, timeZone, name };
|
||||
await this.proposeBookings(turn, file, w, signal);
|
||||
await this.proposeFromFile(turn, file, reader, w, signal);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
logError(`[assistant] attachment in thread ${begun.threadId} failed: ${reason}`);
|
||||
@@ -539,16 +542,18 @@ export class AssistantService {
|
||||
|
||||
/**
|
||||
* Reads the attached file and proposes what it found, in the answer's first
|
||||
* row: the read step, then one held `import_booking` change per booking,
|
||||
* each with its card. A file without bookings ends the answer with
|
||||
* `no_bookings_found`; one that cannot be read, with `generic`.
|
||||
* row: the read step, then one held change per booking (`import_booking`)
|
||||
* or track (`import_tour_file`), each with its card. A file without any
|
||||
* ends the answer with the reader's code; one that cannot be read, with
|
||||
* `generic`.
|
||||
*
|
||||
* @txIndependent the row is written once the file was read, in a
|
||||
* transaction of its own.
|
||||
*/
|
||||
private async proposeBookings(
|
||||
private async proposeFromFile(
|
||||
turn: { userId: number; tripId: number; threadId: number; rowId: number; name: string } & AssistantClientCtx,
|
||||
file: Express.Multer.File,
|
||||
reader: AttachedFileReader,
|
||||
w: UiStreamWriter,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
@@ -557,17 +562,17 @@ export class AssistantService {
|
||||
const readPart = (result: unknown, isError = false): AssistantMessagePart => ({
|
||||
type: 'tool-call',
|
||||
toolCallId: readId,
|
||||
toolName: READ_BOOKING_FILE_TOOL,
|
||||
toolName: reader.readTool,
|
||||
args: readArgs,
|
||||
result,
|
||||
...(isError ? { isError: true } : {}),
|
||||
});
|
||||
const fieldsOf = (parts: AssistantMessagePart[]) => ({ parts: encodeParts(parts), providerState: null });
|
||||
w.startStep();
|
||||
w.toolInput(readId, READ_BOOKING_FILE_TOOL, readArgs);
|
||||
let items: BookingImportPreviewItem[];
|
||||
w.toolInput(readId, reader.readTool, readArgs);
|
||||
let read: Awaited<ReturnType<AttachedFileReader['propose']>>;
|
||||
try {
|
||||
items = await this.inContext(() => this.bookings.read(turn.userId, file));
|
||||
read = await this.inContext(() => reader.propose(turn.userId, turn.tripId, file));
|
||||
} catch (err) {
|
||||
logError(`[assistant] reading ${turn.name} failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
await this.saveRow(turn.threadId, turn.rowId, fieldsOf([readPart(FILE_UNREADABLE, true)]), 'error');
|
||||
@@ -576,27 +581,25 @@ export class AssistantService {
|
||||
w.finish('error');
|
||||
return;
|
||||
}
|
||||
const found = { bookings: items.map((item) => item.title) };
|
||||
const { found, proposals } = read;
|
||||
if (signal.aborted) {
|
||||
await this.saveRow(turn.threadId, turn.rowId, fieldsOf([readPart(found)]), 'aborted');
|
||||
w.abort();
|
||||
return;
|
||||
}
|
||||
w.toolOutput(readId, found);
|
||||
if (items.length === 0) {
|
||||
if (proposals.length === 0) {
|
||||
await this.saveRow(turn.threadId, turn.rowId, fieldsOf([readPart(found)]), 'error');
|
||||
w.finishStep();
|
||||
w.error('no_bookings_found');
|
||||
w.error(reader.emptyCode);
|
||||
w.finish('error');
|
||||
return;
|
||||
}
|
||||
const card = await readTripCard(this.tools, turn.userId, turn.tripId);
|
||||
const ctx = this.cardContext.contextOf(card, new Map(), turn.locale, todayIn(turn.timeZone, new Date()));
|
||||
const proposed = items.map((item) => ({
|
||||
id: randomUUID(),
|
||||
toolName: IMPORT_BOOKING_TOOL,
|
||||
args: item as Record<string, unknown>,
|
||||
}));
|
||||
const proposed = proposals.map((proposal) => ({ ...proposal, id: randomUUID() }));
|
||||
// The thread shows a call as the reader shows it; the held call keeps everything it runs with.
|
||||
const shown = new Map<string, Record<string, unknown>>(proposed.map((c) => [c.id, c.shownArgs ?? c.args]));
|
||||
const described = await this.cardContext.describeProposed(turn.userId, turn.tripId, ctx, proposed);
|
||||
const held: HeldCall[] = described.map((change) => ({ ...change, providerCallId: `import_${change.id}` }));
|
||||
const parts: AssistantMessagePart[] = [
|
||||
@@ -605,14 +608,14 @@ export class AssistantService {
|
||||
type: 'tool-call',
|
||||
toolCallId: change.id,
|
||||
toolName: change.toolName,
|
||||
args: change.args,
|
||||
args: shown.get(change.id) ?? change.args,
|
||||
approval: { id: change.id },
|
||||
})),
|
||||
...held.map((change): AssistantMessagePart => ({ type: 'data-trek-change', data: change.preview })),
|
||||
];
|
||||
await this.holdChanges(turn.threadId, turn.rowId, fieldsOf(parts), held);
|
||||
for (const change of held) {
|
||||
w.toolInput(change.id, change.toolName, change.args);
|
||||
w.toolInput(change.id, change.toolName, shown.get(change.id) ?? change.args);
|
||||
w.approvalRequest(change.id);
|
||||
w.change(change.preview);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { TOUR_DESCRIBERS } from './tour-describe';
|
||||
import { tourListCard } from './tour-lists.cards';
|
||||
import { proposalCard, savedTourCard } from './tour-route.cards';
|
||||
import { cityWalkCard, paddleCard, skiCard } from './tour-sport.cards';
|
||||
import { TOUR_STOP_CARDS } from './tour-stop.cards';
|
||||
import { DELETE_CREATED_TOUR, DELETE_IMPORTED_TOUR } from './tour-undo';
|
||||
import { describeVisit, loadVisit, SET_VISIT_UNDO } from './tour-visit.cards';
|
||||
import { TOUR_WRITE_CARDS } from './tour-writes.cards';
|
||||
@@ -87,6 +88,7 @@ export const TOUR_CARDS: DomainCards = {
|
||||
describe: TOUR_DESCRIBERS.import_tour_gpx,
|
||||
undo: DELETE_IMPORTED_TOUR,
|
||||
},
|
||||
...TOUR_STOP_CARDS,
|
||||
take_over_tour: {
|
||||
write: { verbKey: 'addTour', entity: 'tour' },
|
||||
describe: TOUR_DESCRIBERS.take_over_tour,
|
||||
|
||||
@@ -95,6 +95,8 @@ export const TOUR_DESCRIBERS = {
|
||||
take_over_tour: (_ctx, args) => ({ subject: text(args.name), rows: new Rows(), dayId: null, placeIds: [] }),
|
||||
import_tour_gpx: (_ctx, args) => {
|
||||
const rows = new Rows().add('type', text(args.tour_type));
|
||||
return { subject: text(args.file_name) || gpxName(args.gpx) || 'GPX', rows, dayId: null, placeIds: [] };
|
||||
// A track of a file attached in the chat names itself (track_name); the model's import names the file.
|
||||
const subject = text(args.track_name) || text(args.file_name) || gpxName(args.gpx) || 'GPX';
|
||||
return { subject, rows, dayId: null, placeIds: [] };
|
||||
},
|
||||
} satisfies Readonly<Record<string, (ctx: PreviewContext, args: ToolArgs) => Described>>;
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { CardReader, Described, PreviewContext, ToolArgs, ToolCards, UndoCall, UndoSnapshot } from '../card-types';
|
||||
import { argId, readKey, recordOf, type Row } from '../card-values';
|
||||
import { Rows, text } from '../preview-format';
|
||||
import { rowsIn } from './tour-card-values';
|
||||
import { alongRouteM, defaultAfterId, type InsertionPoint } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* P6: a place taken into a saved hike or city walk as one stop
|
||||
* (insert_tour_stop), and a stop taken out again (remove_tour_stop). Each is
|
||||
* the other's way back: an insert is undone by taking out the stop it added,
|
||||
* a removal by putting the stop back after the point it followed, both while
|
||||
* the tour is still at the revision the change left it at. The card names the
|
||||
* tour, the stop and where it goes, between which two points, read from the
|
||||
* tour as saved now.
|
||||
*/
|
||||
|
||||
/** The saved tour a held change is about, read once for its card. */
|
||||
async function loadStopTour(args: ToolArgs, read: CardReader): Promise<unknown> {
|
||||
const placeId = argId(args, 'placeId');
|
||||
return placeId === null ? null : read('get_tour_plan', { placeId });
|
||||
}
|
||||
|
||||
/** The points of the tour as read, as the planner's insertion rule takes them. */
|
||||
function pointsOf(plan: Row): Array<InsertionPoint & { name: string }> {
|
||||
return rowsIn(plan.waypoints).flatMap((point, index) => {
|
||||
if (typeof point.lat !== 'number' || typeof point.lng !== 'number') return [];
|
||||
return [
|
||||
{
|
||||
id: typeof point.key === 'string' ? point.key : `#${index}`,
|
||||
lat: point.lat,
|
||||
lng: point.lng,
|
||||
role: typeof point.role === 'string' ? point.role : 'via',
|
||||
fixedLeg: point.fixed_leg ? (point.fixed_leg as InsertionPoint['fixedLeg']) : null,
|
||||
...(point.shaping === true ? { shaping: true } : {}),
|
||||
name: text(point.name) || String(index + 1),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** The saved line in brief, for where along it a stop lies. */
|
||||
function previewOf(plan: Row): Array<[number, number]> | null {
|
||||
const preview = recordOf(plan.line).preview;
|
||||
if (!Array.isArray(preview)) return null;
|
||||
const line = preview.flatMap((point): Array<[number, number]> => {
|
||||
const [lat, lng] = Array.isArray(point) ? point : [];
|
||||
return typeof lat === 'number' && typeof lng === 'number' ? [[lat, lng]] : [];
|
||||
});
|
||||
return line.length > 1 ? line : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key of the point a stop at `spot` goes after where none is named: the
|
||||
* last point before it along the saved line, as insert_tour_stop picks it.
|
||||
*/
|
||||
export function defaultAfterKeyOf(plan: Row, spot: { lat: number; lng: number }): string | null {
|
||||
const points = pointsOf(plan);
|
||||
const line = previewOf(plan);
|
||||
const id = defaultAfterId(points, line, alongRouteM(points, line, spot));
|
||||
return id !== null && !id.startsWith('#') ? id : null;
|
||||
}
|
||||
|
||||
/** "Parking → Hut": the two points a stop goes between, or the one it follows as the new end. */
|
||||
function betweenOf(plan: Row, args: ToolArgs): string {
|
||||
const points = pointsOf(plan);
|
||||
const stop = recordOf(args.stop);
|
||||
let afterId = typeof args.after_key === 'string' ? args.after_key : null;
|
||||
if (afterId === null && typeof stop.lat === 'number' && typeof stop.lng === 'number') {
|
||||
afterId = defaultAfterKeyOf(plan, { lat: stop.lat, lng: stop.lng });
|
||||
}
|
||||
const index = points.findIndex((point) => point.id === afterId);
|
||||
const after = points[index];
|
||||
if (!after) return '';
|
||||
const next = points.slice(index + 1).find((point) => point.shaping !== true);
|
||||
return next ? `${after.name} → ${next.name}` : `${after.name} →`;
|
||||
}
|
||||
|
||||
function tourName(ctx: PreviewContext, args: ToolArgs, plan: Row): string {
|
||||
const placeId = argId(args, 'placeId');
|
||||
return text(recordOf(plan.tour).name) || (placeId === null ? '' : (ctx.card.places.get(placeId)?.name ?? ''));
|
||||
}
|
||||
|
||||
function described(ctx: PreviewContext, args: ToolArgs, plan: Row, rows: Rows, verbKey: Described['verbKey']) {
|
||||
const placeId = argId(args, 'placeId');
|
||||
return {
|
||||
subject: tourName(ctx, args, plan),
|
||||
rows,
|
||||
dayId: null,
|
||||
placeIds: placeId !== null && ctx.card.places.has(placeId) ? [placeId] : [],
|
||||
verbKey,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether the tour is still at the revision the change left it at, read before the way back. */
|
||||
function atRevision(result: unknown, snapshot: UndoSnapshot, call?: UndoCall): boolean {
|
||||
if (!call) return false;
|
||||
const plan = recordOf(snapshot.reads.get(readKey('get_tour_plan', { placeId: call.args.placeId })));
|
||||
const revision = recordOf(recordOf(result).tour).revision;
|
||||
return typeof revision === 'number' && recordOf(plan.tour).revision === revision;
|
||||
}
|
||||
|
||||
const readsPlan = (args: ToolArgs) => [{ tool: 'get_tour_plan', args: { placeId: args.placeId } }];
|
||||
|
||||
const insertStop: ToolCards<unknown> = {
|
||||
write: { verbKey: 'addTourStop', entity: 'tour' },
|
||||
load: loadStopTour,
|
||||
describe: (ctx, args, loaded) => {
|
||||
const plan = recordOf(loaded);
|
||||
const rows = new Rows().add('name', text(recordOf(args.stop).name)).add('position', betweenOf(plan, args));
|
||||
return described(ctx, args, plan, rows, 'addTourStop');
|
||||
},
|
||||
undo: {
|
||||
tool: 'remove_tour_stop',
|
||||
args: (_args, result) => {
|
||||
const answer = recordOf(result);
|
||||
const tour = recordOf(answer.tour);
|
||||
const key = recordOf(answer.stop).key;
|
||||
if (typeof key !== 'string' || typeof tour.revision !== 'number' || typeof tour.place_id !== 'number') {
|
||||
return null;
|
||||
}
|
||||
return { placeId: tour.place_id, key, expected_revision: tour.revision };
|
||||
},
|
||||
reads: readsPlan,
|
||||
unchanged: atRevision,
|
||||
},
|
||||
};
|
||||
|
||||
const removeStop: ToolCards<unknown> = {
|
||||
write: { verbKey: 'removeTourStop', entity: 'tour' },
|
||||
load: loadStopTour,
|
||||
describe: (ctx, args, loaded) => {
|
||||
const plan = recordOf(loaded);
|
||||
const stop = rowsIn(plan.waypoints).find((point) => point.key === args.key);
|
||||
return described(ctx, args, plan, new Rows().add('name', text(stop?.name)), 'removeTourStop');
|
||||
},
|
||||
undo: {
|
||||
tool: 'insert_tour_stop',
|
||||
args: (_args, result) => {
|
||||
const answer = recordOf(result);
|
||||
const tour = recordOf(answer.tour);
|
||||
const removed = recordOf(answer.removed);
|
||||
if (typeof tour.revision !== 'number' || typeof tour.place_id !== 'number') return null;
|
||||
if (typeof removed.lat !== 'number' || typeof removed.lng !== 'number') return null;
|
||||
const stop: Row = { lat: removed.lat, lng: removed.lng };
|
||||
if (typeof removed.name === 'string') stop.name = removed.name;
|
||||
if (typeof removed.poi_category === 'string') stop.poi_category = removed.poi_category;
|
||||
const after = typeof removed.after_key === 'string' ? { after_key: removed.after_key } : {};
|
||||
return { placeId: tour.place_id, expected_revision: tour.revision, stop, ...after };
|
||||
},
|
||||
reads: readsPlan,
|
||||
unchanged: atRevision,
|
||||
},
|
||||
};
|
||||
|
||||
export const TOUR_STOP_CARDS: Readonly<Record<string, ToolCards>> = {
|
||||
insert_tour_stop: insertStop,
|
||||
remove_tour_stop: removeStop,
|
||||
};
|
||||
@@ -91,6 +91,7 @@ export const MANAGED_ROUTE_ALLOW_LIST: string[] = [
|
||||
'AssistantController.chat',
|
||||
'AssistantController.decide',
|
||||
'AssistantController.deleteThread',
|
||||
'AssistantController.insertFoundPlace',
|
||||
'AssistantController.share',
|
||||
'AssistantController.stop',
|
||||
'AssistantController.takeProposal',
|
||||
|
||||
@@ -177,7 +177,7 @@ export class JourneyBookMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'edit_book_page',
|
||||
description: `Change one page with 1 to ${BOOK_OP_LIMITS.opsPerCall} operations, run in order, all or nothing: layout (a template, grid, saved layout, or auto to lay the page out again from its entry), place_photo (a photo of the journey's gallery into a frame by elementId, by its slot among the page's frames, or into a new frame), photo_look (the crop as fit and focal point, mask, frame style, filter, radius), swap_photos, text (by elementId, or by role heading, body or meta, which creates the text when the page has none), caption (under a photo, bound to its caption in the journal unless words are given), frame (move, resize, turn), remove (parked with the page by default, park false deletes), background (a colour, or a paint: a colour with alpha, a linear or radial gradient, or one of twelve patterns; null is the paper), add (a new photo frame, text, shape, icon, map, figures, country outlines, mark or list; the journey's own values such as the route, figures and countries come from the journey, never from you), style (the look of one element of its kind: opacity, colours, a shape's paint, fonts, strokes and the like), arrange (to front, forward, backward, to back), align and distribute (several elements by their edges or centres, against each other, the page or the safe area), lock, duplicate, fill (a photo to the bleed of its page or the spread, behind everything), copy_from (elements of another page), refresh_travel (travel elements to the journey's current figures) and add_grid (a photo grid as empty frames in a box). A locked element refuses any operation that would change it but lock. The road a map draws between stops is a straight or bowed line; roads are not routed. ${LAYOUT_NOTES.templates} ${LAYOUT_NOTES.grids} ${UNITS} With returnPrevious the answer also holds the page as it was, under previous_spreads, and a digest of each page as the write left it under page_digests (for restore_book_page expectedPages). ${WRITE_ANSWER}`,
|
||||
description: `Change one page with 1 to ${BOOK_OP_LIMITS.opsPerCall} operations, run in order, all or nothing: layout (a template, grid, saved layout, or auto to lay the page out again from its entry), place_photo (a photo of the journey's gallery into a frame by elementId, by its slot among the page's frames, or into a new frame), photo_look (the crop as fit and focal point, mask, frame style, filter, radius), swap_photos, text (by elementId, or by role heading, body or meta, which creates the text when the page has none), caption (under a photo, bound to its caption in the journal unless words are given), frame (move, resize, turn), remove (parked with the page by default, park false deletes), background (null is the paper), add (a new photo frame, text, shape, icon, map, figures, country outlines, mark or list; the journey's own values such as the route, figures and countries come from the journey, never from you), style (the look of one element of its kind: opacity, colours, fonts, strokes and the like), arrange (to front, forward, backward, to back), align and distribute (several elements by their edges or centres, against each other, the page or the safe area), lock, duplicate, fill (a photo to the bleed of its page or the spread, behind everything), copy_from (elements of another page), refresh_travel (travel elements to the journey's current figures) and add_grid (a photo grid as empty frames in a box). A locked element refuses any operation that would change it but lock. The road a map draws between stops is a straight or bowed line; roads are not routed. ${LAYOUT_NOTES.templates} ${LAYOUT_NOTES.grids} ${UNITS} With returnPrevious the answer also holds the page as it was, under previous_spreads, and a digest of each page as the write left it under page_digests (for restore_book_page expectedPages). ${WRITE_ANSWER}`,
|
||||
inputSchema: { journeyId: idSchema, ...editBookPageArgs },
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: journeyAddonOn,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { PlaceImportService } from '../place-import/place-import.service';
|
||||
import type { PreparedGpxPlace } from '../place-import/place-import.types';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { readTourFile } from './tour-file-import.helpers';
|
||||
import { gpxOfLine, splitGpxTracks } from './tour-file-split.helpers';
|
||||
import { computeTourMetrics, parseRouteGeometry } from './tours.helpers';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { TOUR_GPX_TEXT_MAX, type TourFileLeftOut } from '@trek/shared';
|
||||
|
||||
/** One track or route of an attached file, ready for import_tour_gpx. */
|
||||
export interface TourFileTrack {
|
||||
name: string;
|
||||
/** Its own GPX text; null where it is longer than one import takes. */
|
||||
gpx: string | null;
|
||||
point_count: number;
|
||||
distance_km: number;
|
||||
has_heights: boolean;
|
||||
/** A track already in the trip under the same name and start, which the import would skip. */
|
||||
duplicate: boolean;
|
||||
}
|
||||
|
||||
/** What an attached route file holds, track by track. */
|
||||
interface TourFileTracks {
|
||||
tracks: TourFileTrack[];
|
||||
/**
|
||||
* True where a GPX file could not be cut into its tracks: its one entry is
|
||||
* then the whole file, which comes in as one import of all its tracks.
|
||||
*/
|
||||
whole: boolean;
|
||||
/** What a KML or KMZ file holds besides its lines (pins, areas), never taken in. */
|
||||
left_out?: TourFileLeftOut;
|
||||
}
|
||||
|
||||
const GPX_OPTIONS = { importWaypoints: false, importRoutes: true, importTracks: true } as const;
|
||||
|
||||
function isGpxName(fileName: string): boolean {
|
||||
const ext = fileName.toLowerCase().split('.').pop();
|
||||
return ext !== 'kml' && ext !== 'kmz';
|
||||
}
|
||||
|
||||
/**
|
||||
* P7: a route file the user attached in the assistant's chat, read the way
|
||||
* the Tours import reads it (readTourFile: GPX by its tracks and routes, KML
|
||||
* and KMZ by their lines) and cut into one GPX text per track, so each one is
|
||||
* proposed and taken in as a tour of its own and taken back on its own.
|
||||
* Reads only: nothing is stored here, the import is import_tour_gpx's.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TourFileReadService {
|
||||
constructor(
|
||||
private readonly placeImport: PlaceImportService,
|
||||
private readonly places: PlacesService,
|
||||
) {}
|
||||
|
||||
async read(tripId: number, buffer: Buffer, fileName: string): Promise<TourFileTracks> {
|
||||
const { rows, leftOut } = await readTourFile(this.placeImport, buffer, fileName);
|
||||
if (rows.length === 0) return { tracks: [], whole: false };
|
||||
const duplicates = await this.places.gpxDuplicates(String(tripId), rows);
|
||||
const texts = isGpxName(fileName) ? this.cutGpx(buffer.toString('utf8'), rows.length, fileName) : null;
|
||||
const extra = leftOut ? { left_out: leftOut } : {};
|
||||
if (isGpxName(fileName) && !texts) {
|
||||
return { tracks: [this.wholeFile(buffer.toString('utf8'), rows, fileName)], whole: true, ...extra };
|
||||
}
|
||||
const tracks = rows.map((row, index) => {
|
||||
const own = texts?.[index] ?? gpxOfLine(row.name, row.description, parseRouteGeometry(row.routeGeometry));
|
||||
return this.trackOf(row, own, duplicates[index] ?? false);
|
||||
});
|
||||
return { tracks, whole: false, ...extra };
|
||||
}
|
||||
|
||||
/** A GPX text cut into its tracks; null where a piece does not read back as exactly the one it should. */
|
||||
private cutGpx(text: string, count: number, fileName: string): string[] | null {
|
||||
const pieces = splitGpxTracks(text);
|
||||
if (pieces.length !== count) return null;
|
||||
const readable = pieces.every((piece) => {
|
||||
const rows = this.placeImport.readGpx(Buffer.from(piece, 'utf8'), { ...GPX_OPTIONS, defaultName: fileName });
|
||||
return rows.length === 1;
|
||||
});
|
||||
return readable ? pieces : null;
|
||||
}
|
||||
|
||||
private trackOf(row: PreparedGpxPlace, gpx: string, duplicate: boolean): TourFileTrack {
|
||||
const line = parseRouteGeometry(row.routeGeometry);
|
||||
const metrics = computeTourMetrics(line);
|
||||
return {
|
||||
name: row.name,
|
||||
gpx: gpx.length <= TOUR_GPX_TEXT_MAX ? gpx : null,
|
||||
point_count: line.length,
|
||||
distance_km: metrics.distanceKm,
|
||||
has_heights: metrics.hasElevation,
|
||||
duplicate,
|
||||
};
|
||||
}
|
||||
|
||||
/** The whole GPX file as one entry, named by the file and measured over all its tracks. */
|
||||
private wholeFile(text: string, rows: readonly PreparedGpxPlace[], fileName: string): TourFileTrack {
|
||||
const lines = rows.map((row) => parseRouteGeometry(row.routeGeometry));
|
||||
const metrics = lines.map((line) => computeTourMetrics(line));
|
||||
return {
|
||||
name: fileName,
|
||||
gpx: text.length <= TOUR_GPX_TEXT_MAX ? text : null,
|
||||
point_count: lines.reduce((sum, line) => sum + line.length, 0),
|
||||
distance_km: metrics.reduce((sum, metric) => sum + metric.distanceKm, 0),
|
||||
has_heights: metrics.every((metric) => metric.hasElevation),
|
||||
duplicate: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { escapeHtml, type GeometryPoint } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* P7: a route file the user attached in the chat, cut into one GPX text per
|
||||
* track or route, so each comes in as a tour of its own and can be taken back
|
||||
* on its own. A GPX file is cut out of its own text, so every point keeps
|
||||
* what it carried (times, heights, extensions); a KML or KMZ line, which
|
||||
* holds no times, is written as GPX from its points.
|
||||
*/
|
||||
|
||||
/** Where every `<trk>` and `<rte>` element of a GPX text starts and ends, in order. */
|
||||
function trackBlocks(gpx: string): Array<{ from: number; to: number }> {
|
||||
const blocks: Array<{ from: number; to: number }> = [];
|
||||
let at = 0;
|
||||
for (;;) {
|
||||
const trk = openingAt(gpx, 'trk', at);
|
||||
const rte = openingAt(gpx, 'rte', at);
|
||||
const next = [trk, rte].filter((found) => found >= 0).sort((a, b) => a - b)[0];
|
||||
if (next === undefined) return blocks;
|
||||
const tag = next === trk ? 'trk' : 'rte';
|
||||
const close = gpx.indexOf(`</${tag}>`, next);
|
||||
if (close < 0) return blocks;
|
||||
const to = close + tag.length + 3;
|
||||
blocks.push({ from: next, to });
|
||||
at = to;
|
||||
}
|
||||
}
|
||||
|
||||
/** The first `<tag>` or `<tag ...>` from `at`, not a longer tag such as `<trkseg>`. */
|
||||
function openingAt(text: string, tag: string, at: number): number {
|
||||
let from = at;
|
||||
for (;;) {
|
||||
const found = text.indexOf(`<${tag}`, from);
|
||||
if (found < 0) return -1;
|
||||
const after = text.charAt(found + tag.length + 1);
|
||||
if (after === '>' || after === ' ' || after === '\n' || after === '\r' || after === '\t') return found;
|
||||
from = found + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One GPX text per track and route of a GPX file: the file's head (its
|
||||
* declaration, the `<gpx>` element and its metadata) around each element.
|
||||
* Empty where the file holds none the cut can find (a namespace prefix, say).
|
||||
*/
|
||||
export function splitGpxTracks(gpx: string): string[] {
|
||||
const blocks = trackBlocks(gpx);
|
||||
const first = blocks[0];
|
||||
if (!first) return [];
|
||||
const head = gpx.slice(0, first.from);
|
||||
// The head ends before the first waypoint as well: an import never takes loose points.
|
||||
const wpt = openingAt(head, 'wpt', 0);
|
||||
const kept = wpt >= 0 ? head.slice(0, wpt) : head;
|
||||
return blocks.map(({ from, to }) => `${kept}${gpx.slice(from, to)}\n</gpx>\n`);
|
||||
}
|
||||
|
||||
/** A line read from a KML or KMZ file as a GPX text of one track. */
|
||||
export function gpxOfLine(name: string, description: string | null, line: readonly GeometryPoint[]): string {
|
||||
const points = line
|
||||
.map(([lat, lng, ele]) => {
|
||||
const height = typeof ele === 'number' && Number.isFinite(ele) ? `<ele>${ele}</ele>` : '';
|
||||
return `<trkpt lat="${lat}" lon="${lng}">${height}</trkpt>`;
|
||||
})
|
||||
.join('');
|
||||
const desc = description ? `<desc>${escapeHtml(description)}</desc>` : '';
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n' +
|
||||
'<gpx version="1.1" creator="TREK" xmlns="http://www.topografix.com/GPX/1/1">\n' +
|
||||
`<trk><name>${escapeHtml(name)}</name>${desc}<trkseg>${points}</trkseg></trk>\n</gpx>\n`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { PlaceImportModule } from '../place-import/place-import.module';
|
||||
import { PlacesModule } from '../places/places.module';
|
||||
import { TourFileReadService } from './tour-file-read.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* P7: a route file read into tours without storing anything, for the
|
||||
* assistant's chat (a GPX, KML or KMZ file the user attaches). It reads the
|
||||
* file as the Tours import does, through PlaceImportModule's readers, and
|
||||
* marks the tracks the trip has already through PlacesModule; the import
|
||||
* itself stays import_tour_gpx's. A module of its own so the assistant takes
|
||||
* this one reader without the whole Tours module.
|
||||
*/
|
||||
@Module({
|
||||
imports: [PlaceImportModule, PlacesModule],
|
||||
providers: [TourFileReadService],
|
||||
exports: [TourFileReadService],
|
||||
})
|
||||
export class TourFilesModule {}
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
alongRouteM,
|
||||
defaultAfterId,
|
||||
haversineMetres,
|
||||
insertionBlock,
|
||||
isClosedLoop,
|
||||
withPointAfter,
|
||||
type GeometryPoint,
|
||||
type HikeStop,
|
||||
type InsertionPoint,
|
||||
type TourStop,
|
||||
type TourWaypoint,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* P6: the pure half of a stop taken into a saved tour or out of it (see
|
||||
* TourStopsService). The points of the tour are read as the planner reads
|
||||
* them for a found place (poi-insertion.ts in @trek/shared), so the stop goes
|
||||
* where the planner would put it and a leg drawn by hand is never cut.
|
||||
*/
|
||||
|
||||
/** How near a saved point must lie to the stop to be the one the save made of it, in metres. */
|
||||
const SAME_SPOT_M = 30;
|
||||
|
||||
/** A saved point as the insertion rule reads it, keeping the point itself. */
|
||||
export interface StopPoint extends InsertionPoint {
|
||||
saved: TourWaypoint;
|
||||
}
|
||||
|
||||
/** The id a point goes by: its key, or its place in the order where it has none yet. */
|
||||
function idOf(point: TourWaypoint, index: number): string {
|
||||
return point.key ?? `#${index}`;
|
||||
}
|
||||
|
||||
export function stopPointsOf(waypoints: readonly TourWaypoint[]): StopPoint[] {
|
||||
return waypoints.map((saved, index) => ({
|
||||
id: idOf(saved, index),
|
||||
lat: saved.lat,
|
||||
lng: saved.lng,
|
||||
role: saved.role,
|
||||
fixedLeg: saved.fixed_leg ?? null,
|
||||
...(saved.shaping ? { shaping: true } : {}),
|
||||
saved,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Why the stop cannot go after the point named, in words an assistant can pass on. */
|
||||
const BLOCKED: Readonly<Record<'fixed' | 'free' | 'none', string>> = {
|
||||
fixed: 'The leg after that point is a fixed line (a via ferrata, a ferry or a portage): a stop there would cut it.',
|
||||
free: 'The leg after that point is drawn by hand: a stop there would redraw it. Put the stop in the planner.',
|
||||
none: 'Nothing comes after that point: it is the way back of a loop. Name the point before it.',
|
||||
};
|
||||
|
||||
/**
|
||||
* The point the stop goes after: the one named by its key, or the last point
|
||||
* before the stop along the saved line. A string says why it cannot go in.
|
||||
*/
|
||||
export function afterPointOf(
|
||||
points: readonly StopPoint[],
|
||||
line: readonly GeometryPoint[],
|
||||
stop: Pick<TourStop, 'lat' | 'lng'>,
|
||||
afterKey: string | undefined,
|
||||
): StopPoint | string {
|
||||
if (afterKey !== undefined) {
|
||||
const named = points.find((point) => point.id === afterKey);
|
||||
if (!named) return `This tour has no point with the key ${afterKey}: read it again with get_tour_plan.`;
|
||||
if (named.shaping) return 'That point only shapes the line and is no stop: name a stop of the tour.';
|
||||
const block = insertionBlock(points, named.id);
|
||||
return block ? BLOCKED[block] : named;
|
||||
}
|
||||
const route = line.length > 1 ? line : null;
|
||||
const id = defaultAfterId(points, route, alongRouteM(points, route, stop));
|
||||
const chosen = points.find((point) => point.id === id);
|
||||
return chosen ?? 'No leg of this tour can take a stop: every one is drawn by hand or a fixed line.';
|
||||
}
|
||||
|
||||
/** A saved point as a hike's update takes it: its spot, and its name, kind and stage end where it has them. */
|
||||
function hikeStopOf(point: TourWaypoint): HikeStop {
|
||||
return {
|
||||
lat: point.lat,
|
||||
lng: point.lng,
|
||||
...(point.name ? { name: point.name } : {}),
|
||||
...(point.poi_category ? { poi_category: point.poi_category } : {}),
|
||||
...(point.stage_end ? { stage_end: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The stop as a new saved point would carry it; the server gives it its key on the save. */
|
||||
function waypointOfStop(stop: TourStop): TourWaypoint {
|
||||
return {
|
||||
lat: stop.lat,
|
||||
lng: stop.lng,
|
||||
role: 'via',
|
||||
sequence: 0,
|
||||
...(stop.name ? { name: stop.name } : {}),
|
||||
...(stop.poi_category ? { poi_category: stop.poi_category } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A hike's points with the stop after `afterId`, as update_tour takes them; null where it cannot go there. */
|
||||
export function hikePointsWithStop(points: readonly StopPoint[], afterId: string, stop: TourStop): HikeStop[] | null {
|
||||
const added: StopPoint = { id: '#new', lat: stop.lat, lng: stop.lng, role: 'via', saved: waypointOfStop(stop) };
|
||||
const next = withPointAfter(points, afterId, added);
|
||||
return next ? next.map((point) => hikeStopOf(point.saved)) : null;
|
||||
}
|
||||
|
||||
/** Why a stop cannot be taken out of these points, or null when it can. */
|
||||
export function removalRefusal(points: readonly StopPoint[], key: string): string | null {
|
||||
const index = points.findIndex((point) => point.id === key);
|
||||
if (index < 0) return `This tour has no point with the key ${key}: read it again with get_tour_plan.`;
|
||||
if (points.length <= 2) return 'A tour keeps at least its start and its end: this point cannot be taken out.';
|
||||
if (index === 0) return 'The start of a tour is not taken out here: change the start in the planner.';
|
||||
if (isClosedLoop(points) && index === points.length - 1)
|
||||
return 'The way back of a loop is not taken out here: use the planner.';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A hike's points without the one keyed `key`. */
|
||||
export function hikePointsWithout(points: readonly StopPoint[], key: string): HikeStop[] {
|
||||
return points.filter((point) => point.id !== key).map((point) => hikeStopOf(point.saved));
|
||||
}
|
||||
|
||||
/** The key the save gave the stop: the saved point nearest its spot, within a few metres of it. */
|
||||
export function keyNear(waypoints: readonly TourWaypoint[], spot: { lat: number; lng: number }): string | null {
|
||||
let best: { key: string; metres: number } | null = null;
|
||||
for (const point of waypoints) {
|
||||
if (!point.key) continue;
|
||||
const metres = haversineMetres(point.lat, point.lng, spot.lat, spot.lng);
|
||||
if (metres <= SAME_SPOT_M && (!best || metres < best.metres)) best = { key: point.key, metres };
|
||||
}
|
||||
return best?.key ?? null;
|
||||
}
|
||||
|
||||
/** The key of the point before the one keyed `key`, where it has one. */
|
||||
export function keyBefore(points: readonly StopPoint[], key: string): string | null {
|
||||
const index = points.findIndex((point) => point.id === key);
|
||||
return index > 0 ? (points[index - 1]?.saved.key ?? null) : null;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import { McpController, Tool, TOOL_ANNOTATIONS_WRITE, errorResult, ok, type McpContext } from '../../nest-mcp';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
import { TripAccessService } from '../trip-membership/trip-access.service';
|
||||
import { TourStopsService } from './tour-stops.service';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
idSchema,
|
||||
tourStopInsertRequestSchema,
|
||||
tourStopRemoveRequestSchema,
|
||||
type TourStopInsertRequest,
|
||||
type TourStopRemoveRequest,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** A refusal for a tour this trip does not have, the same text every tool of the tours answers. */
|
||||
function tourNotFound(err: unknown) {
|
||||
if (err instanceof NotFoundException) return errorResult('Tour not found.');
|
||||
throw err;
|
||||
}
|
||||
|
||||
/**
|
||||
* P6: a place taken into a saved hike or city walk as one stop, and taken out
|
||||
* again. Both write with 'place_edit' on the trip, as the planner's save over
|
||||
* REST asks (ToursController's update) and as update_tour and
|
||||
* save_tour_change ask, whose paths they save through; the trip hears of the
|
||||
* change as from those (tours:changed and place:updated).
|
||||
*/
|
||||
@McpController()
|
||||
export class TourStopsMcp {
|
||||
constructor(
|
||||
private readonly stops: TourStopsService,
|
||||
private readonly access: TripAccessService,
|
||||
private readonly guards: McpToolGuardsService,
|
||||
// Read by the addon gate of every tool here.
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
name: 'insert_tour_stop',
|
||||
description:
|
||||
'Take a place into a saved hike or city walk as one stop, against the revision it was read at (expected_revision is tour.revision from get_tour_plan or list_tours): a hut, a café, a viewpoint the traveller found along it, from search_tour_pois or any search. stop is where it is, its name and on a hike its poi_category, on a city walk its opening_hours. With after_key (the key of a point from get_tour_plan) the stop comes after that point; left out, after the last point before it along the route, as the planner puts a found place. A leg drawn by hand or a fixed line (a via ferrata, a ferry, a portage) is never cut, and nothing goes after the way back of a loop: such a place is refused with the reason, name another point. A hike plans its way anew through the points, as update_tour does (where it cannot be planned nothing is saved and the error says why), and keeps its stages and the links of its points; a city walk saves the stop as a new station as save_tour_change does, the visits of its other stations kept. Any other type, and a recorded track without points of its own, is refused: change those in the planner. A tour changed since that revision is refused as a conflict: read it again. The answer: tour (place_id, name, tour_type, the new revision, distance_km and minutes as now stored, route_pending), stop (its key, where it is, after_key and after_name, the point it follows) and before (distance_km and minutes as stored before). Take it out again with remove_tour_stop. Needs place editing permission on the trip. Take a place in only after the traveller said yes.',
|
||||
inputSchema: { tripId: idSchema, ...tourStopInsertRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'write' },
|
||||
})
|
||||
async insertTourStop({ tripId, ...request }: { tripId: number } & TourStopInsertRequest, ctx: McpContext) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
try {
|
||||
return ok(await this.stops.insert(ctx.userId, tripId, request));
|
||||
} catch (err: unknown) {
|
||||
return tourNotFound(err);
|
||||
}
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'remove_tour_stop',
|
||||
description:
|
||||
'Take one stop out of a saved hike or city walk by its key (from get_tour_plan or insert_tour_stop), against the revision it was read at (expected_revision). A hike plans its way anew through the points left, as update_tour does; a city walk removes the station as save_tour_change does, and refuses a station protected, or visited or skipped on a day. The start of a tour, the way back of a loop and a tour of two points keep their points: those are refused. Any other type is refused: change it in the planner. A tour changed since that revision is refused as a conflict: read it again. The answer: tour (place_id, name, tour_type, the new revision, distance_km and minutes as now stored, route_pending) and removed (the stop with its key, where it was, its name and after_key, the point it followed). Needs place editing permission on the trip. Take a stop out only after the traveller said yes.',
|
||||
inputSchema: { tripId: idSchema, ...tourStopRemoveRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'write' },
|
||||
})
|
||||
async removeTourStop({ tripId, ...request }: { tripId: number } & TourStopRemoveRequest, ctx: McpContext) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
try {
|
||||
return ok(await this.stops.remove(ctx.userId, tripId, request));
|
||||
} catch (err: unknown) {
|
||||
return tourNotFound(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { badRequest } from '../common/domain-error';
|
||||
import { HikePlanService } from './hike-plan.service';
|
||||
import { proposalIdOf } from './tour-change-proposal';
|
||||
import { TourChangeSaveService } from './tour-change-save.service';
|
||||
import { tourRevisionConflict } from './tour-revision';
|
||||
import {
|
||||
afterPointOf,
|
||||
hikePointsWithStop,
|
||||
hikePointsWithout,
|
||||
keyBefore,
|
||||
keyNear,
|
||||
removalRefusal,
|
||||
stopPointsOf,
|
||||
type StopPoint,
|
||||
} from './tour-stops.helpers';
|
||||
import { ToursService } from './tours.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
TOUR_STOP_TYPES,
|
||||
type HikeStop,
|
||||
type TourChange,
|
||||
type TourDetailResponse,
|
||||
type TourStopInsertRequest,
|
||||
type TourStopInsertResult,
|
||||
type TourStopRemoveRequest,
|
||||
type TourStopRemoveResult,
|
||||
} from '@trek/shared';
|
||||
|
||||
type StopTourType = (typeof TOUR_STOP_TYPES)[number];
|
||||
|
||||
/** The tour as an answer names it, from the tour as saved now. */
|
||||
function tourOf(detail: TourDetailResponse, tourType: StopTourType): TourStopInsertResult['tour'] {
|
||||
const { tour } = detail;
|
||||
return {
|
||||
place_id: tour.place_id,
|
||||
name: tour.name,
|
||||
tour_type: tourType,
|
||||
revision: tour.revision ?? 1,
|
||||
distance_km: tour.distance ?? null,
|
||||
minutes: tour.duration ?? null,
|
||||
route_pending: tour.route_pending === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** The type of a tour a stop is taken in or out of here; any other is refused with where to change it. */
|
||||
function stopTypeOf(detail: TourDetailResponse): StopTourType {
|
||||
const type = detail.tour.tour_type;
|
||||
if (type === 'hike' || type === 'walk') {
|
||||
if (detail.tour.has_waypoints !== true) {
|
||||
throw badRequest(
|
||||
'This tour is a recorded track without points of its own: a stop would plan it over. Plan it in the planner first.',
|
||||
);
|
||||
}
|
||||
return type;
|
||||
}
|
||||
throw badRequest('Only a hike or a city walk takes a stop here: change any other tour in the planner.');
|
||||
}
|
||||
|
||||
/**
|
||||
* P6: a place taken into a saved hike or city walk as one stop, and the same
|
||||
* stop taken out again, which is how the assistant's insert is undone. Where
|
||||
* the stop goes is the planner's own rule for a found place (poi-insertion.ts
|
||||
* in @trek/shared): after the point named or after the last one before it
|
||||
* along the route, never into a leg drawn by hand or a fixed line.
|
||||
*
|
||||
* Nothing here writes on its own: a hike is saved through update_tour's path
|
||||
* (HikePlanService.update: its way planned anew through the points, its
|
||||
* stages and the links of its points kept), a city walk through
|
||||
* save_tour_change's (TourChangeSaveService.save: one recorded step, the
|
||||
* visits of the other stations kept). Both refuse a tour changed since the
|
||||
* revision it was read at, and both tell the trip about the change.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TourStopsService {
|
||||
constructor(
|
||||
private readonly tours: ToursService,
|
||||
private readonly hikes: HikePlanService,
|
||||
private readonly walkChanges: TourChangeSaveService,
|
||||
) {}
|
||||
|
||||
/** The tour as saved, of a type that takes a stop here, at the revision the request read it at. */
|
||||
private async current(tripId: number, placeId: number, expected: number) {
|
||||
const detail = await this.tours.getTour(String(tripId), String(placeId));
|
||||
const type = stopTypeOf(detail);
|
||||
const revision = detail.tour.revision ?? null;
|
||||
if (revision !== expected) throw tourRevisionConflict(revision);
|
||||
return { detail, type, points: stopPointsOf(detail.waypoints) };
|
||||
}
|
||||
|
||||
async insert(userId: number, tripId: number, request: TourStopInsertRequest): Promise<TourStopInsertResult> {
|
||||
const { placeId, stop } = request;
|
||||
const { detail, type, points } = await this.current(tripId, placeId, request.expected_revision);
|
||||
const line = await this.tours.savedLine(tripId, placeId);
|
||||
const after = afterPointOf(points, line, stop, request.after_key);
|
||||
if (typeof after === 'string') throw badRequest(after);
|
||||
const saved =
|
||||
type === 'hike'
|
||||
? await this.saveHike(
|
||||
userId,
|
||||
tripId,
|
||||
placeId,
|
||||
request.expected_revision,
|
||||
hikePointsWithStop(points, after.id, stop),
|
||||
)
|
||||
: await this.saveWalk(userId, tripId, placeId, request.expected_revision, {
|
||||
op: 'add',
|
||||
after_key: this.walkKeyOf(after),
|
||||
station: {
|
||||
lat: stop.lat,
|
||||
lng: stop.lng,
|
||||
...(stop.name ? { name: stop.name } : {}),
|
||||
...(stop.opening_hours ? { opening_hours: stop.opening_hours } : {}),
|
||||
},
|
||||
});
|
||||
return {
|
||||
tour: tourOf(saved, type),
|
||||
stop: {
|
||||
key: keyNear(saved.waypoints, stop),
|
||||
lat: stop.lat,
|
||||
lng: stop.lng,
|
||||
name: stop.name ?? null,
|
||||
poi_category: stop.poi_category ?? null,
|
||||
after_key: after.saved.key ?? null,
|
||||
after_name: after.saved.name ?? null,
|
||||
},
|
||||
before: { distance_km: detail.tour.distance ?? null, minutes: detail.tour.duration ?? null },
|
||||
};
|
||||
}
|
||||
|
||||
async remove(userId: number, tripId: number, request: TourStopRemoveRequest): Promise<TourStopRemoveResult> {
|
||||
const { placeId, key } = request;
|
||||
const { type, points } = await this.current(tripId, placeId, request.expected_revision);
|
||||
const refusal = removalRefusal(points, key);
|
||||
const gone = points.find((point) => point.id === key);
|
||||
if (refusal || !gone) throw badRequest(refusal ?? 'This stop is not on the tour.');
|
||||
const saved =
|
||||
type === 'hike'
|
||||
? await this.saveHike(userId, tripId, placeId, request.expected_revision, hikePointsWithout(points, key))
|
||||
: await this.saveWalk(userId, tripId, placeId, request.expected_revision, { op: 'remove', key });
|
||||
return {
|
||||
tour: tourOf(saved, type),
|
||||
removed: {
|
||||
key,
|
||||
lat: gone.lat,
|
||||
lng: gone.lng,
|
||||
name: gone.saved.name ?? null,
|
||||
poi_category: gone.saved.poi_category ?? null,
|
||||
after_key: keyBefore(points, key),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A city walk names the station a stop comes after by its key; a station without one is not named yet. */
|
||||
private walkKeyOf(point: StopPoint): string {
|
||||
if (!point.saved.key)
|
||||
throw badRequest('The stations of this walk have no keys yet: open and save it in the planner.');
|
||||
return point.saved.key;
|
||||
}
|
||||
|
||||
/** A hike saved through new points, its way planned anew as update_tour plans it. */
|
||||
private async saveHike(
|
||||
userId: number,
|
||||
tripId: number,
|
||||
placeId: number,
|
||||
expectedRevision: number,
|
||||
waypoints: HikeStop[] | null,
|
||||
): Promise<TourDetailResponse> {
|
||||
if (!waypoints) throw badRequest('The stop cannot go in there: name another point of the tour.');
|
||||
return this.hikes.update(userId, tripId, { placeId, expected_revision: expectedRevision, waypoints });
|
||||
}
|
||||
|
||||
/** A city walk saved through one change, as save_tour_change saves a confirmed proposal, then read as it is now. */
|
||||
private async saveWalk(
|
||||
userId: number,
|
||||
tripId: number,
|
||||
placeId: number,
|
||||
expectedRevision: number,
|
||||
change: TourChange,
|
||||
): Promise<TourDetailResponse> {
|
||||
const changes = [change];
|
||||
const proposalId = proposalIdOf({ tripId, placeId, revision: expectedRevision, changes });
|
||||
await this.walkChanges.save(tripId, placeId, userId, {
|
||||
changes,
|
||||
expected_revision: expectedRevision,
|
||||
proposal_id: proposalId,
|
||||
confirmed: true,
|
||||
});
|
||||
return this.tours.getTour(String(tripId), String(placeId));
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,8 @@ import { TourPoiSearchController } from './tour-poi-search.controller';
|
||||
import { TourPoiSearchService } from './tour-poi-search.service';
|
||||
import { TourSkiController } from './tour-ski.controller';
|
||||
import { TourSkiService } from './tour-ski.service';
|
||||
import { TourStopsMcp } from './tour-stops.mcp';
|
||||
import { TourStopsService } from './tour-stops.service';
|
||||
import { TourTrailGradeController } from './tour-trail-grade.controller';
|
||||
import { TourTrailGradeService } from './tour-trail-grade.service';
|
||||
import { TourVisitsController } from './tour-visits.controller';
|
||||
@@ -215,6 +217,7 @@ import { Module } from '@nestjs/common';
|
||||
TourChangePreviewService,
|
||||
TourChangeSaveService,
|
||||
TourChangeUndoService,
|
||||
TourStopsService,
|
||||
TourDraftsService,
|
||||
TourActivitiesService,
|
||||
TourActivityStatsService,
|
||||
@@ -233,6 +236,8 @@ import { Module } from '@nestjs/common';
|
||||
TourActivityStatsMcp,
|
||||
CollectionToursMcp,
|
||||
TourPackingMcp,
|
||||
// P6: a found place taken into a saved hike or city walk, and out again.
|
||||
TourStopsMcp,
|
||||
],
|
||||
})
|
||||
export class ToursModule {}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Trip assistant e2e for P6 and P7, the whole app (buildApp, every global
|
||||
* guard, the Zod pipe, the response contracts, the real MCP registry with
|
||||
* its addon gates) with an instance connection to a fake OpenAI-compatible
|
||||
* server on loopback, scripted per case.
|
||||
*
|
||||
* Covered: a GPX file attached in the chat is read into its tracks without a
|
||||
* model turn, each held as a change card that takes it in as a hike, the GPX
|
||||
* text kept off the thread; applying one imports exactly that track through
|
||||
* import_tour_gpx and undoing it deletes the tour again; a file without a
|
||||
* track ends with no_tracks_found. A stop the model takes into a saved hike
|
||||
* with insert_tour_stop is held with its card, saved through update_tour's
|
||||
* write when applied (the planner stubbed on the container, as in
|
||||
* assistant-tours.e2e), its receipt offering the way back.
|
||||
*/
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { Places } from '../../src/db/entities/Places.entity';
|
||||
import { HikePlanService } from '../../src/nest/tours/hike-plan.service';
|
||||
import { makePlace } from '../helpers/factories/places';
|
||||
import { countRows, findRow } from '../helpers/factories/rows';
|
||||
import { setAddonEnabled } from '../helpers/factories/settings';
|
||||
import { addTourWaypoints, makeTour } from '../helpers/factories/tours';
|
||||
import { makeTrip } from '../helpers/factories/trips';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { resetRateLimits } from '../helpers/test-db';
|
||||
import { sessionCookie } from './harness';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import type { AssistantMessagePart, UiStreamChunk } from '@trek/shared';
|
||||
|
||||
import { createServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// ASTE2E-TOURFILE-001 to ASTE2E-TOURFILE-004
|
||||
|
||||
// ── The fake provider ─────────────────────────────────────────────────────────
|
||||
|
||||
type Step = () => { chunks: object[] };
|
||||
|
||||
const provider = { requests: [] as object[], script: [] as Step[] };
|
||||
|
||||
const say =
|
||||
(text: string): Step =>
|
||||
() => ({
|
||||
chunks: [
|
||||
{ choices: [{ index: 0, delta: { content: text } }] },
|
||||
{ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] },
|
||||
{ choices: [], usage: { prompt_tokens: 12, completion_tokens: 4 } },
|
||||
],
|
||||
});
|
||||
|
||||
const callTool =
|
||||
(id: string, name: string, args: Record<string, unknown>): Step =>
|
||||
() => ({
|
||||
chunks: [
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{ index: 0, id, type: 'function', function: { name, arguments: JSON.stringify(args) } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] },
|
||||
],
|
||||
});
|
||||
|
||||
async function readBody(req: IncomingMessage): Promise<string> {
|
||||
let text = '';
|
||||
for await (const chunk of req) text += String(chunk);
|
||||
return text;
|
||||
}
|
||||
|
||||
async function answer(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
if (req.method === 'GET' && req.url?.startsWith('/api/tags')) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ models: [{ name: 'fake-model', size: 1 }] }));
|
||||
return;
|
||||
}
|
||||
if (req.method !== 'POST' || req.url !== '/v1/chat/completions') {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
provider.requests.push(JSON.parse(await readBody(req)) as object);
|
||||
const reply = (provider.script.shift() ?? say('Nothing more.'))();
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
||||
for (const chunk of reply.chunks) res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||
res.end('data: [DONE]\n\n');
|
||||
}
|
||||
|
||||
// ── The app ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let orm: MikroORM;
|
||||
let fake: HttpServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
fake = createServer((req, res) => {
|
||||
answer(req, res).catch(() => res.destroy());
|
||||
});
|
||||
await new Promise<void>((resolve) => fake.listen(0, '127.0.0.1', resolve));
|
||||
nestApp = await buildApp();
|
||||
orm = nestApp.get(MikroORM);
|
||||
await setAddonEnabled(orm, 'ai_assistant', true);
|
||||
await setAddonEnabled(orm, 'tours', true);
|
||||
const admin = (await makeUser(orm, { role: 'admin' })).user.id;
|
||||
const { port } = fake.address() as AddressInfo;
|
||||
const res = await request(nestApp.getHttpServer())
|
||||
.put('/api/admin/ai/connection')
|
||||
.set('Cookie', sessionCookie(admin))
|
||||
.send({ kind: 'custom', baseUrl: `http://127.0.0.1:${port}/v1`, model: 'fake-model' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetRateLimits(nestApp);
|
||||
provider.requests = [];
|
||||
provider.script = [];
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
await new Promise<void>((resolve) => fake.close(() => resolve()));
|
||||
});
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const CTX = { locale: 'en', timeZone: 'Europe/Berlin' };
|
||||
|
||||
interface Answer {
|
||||
status: number;
|
||||
chunks: UiStreamChunk[];
|
||||
json: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function parseAnswer(status: number, type: string, text: string): Answer {
|
||||
if (!type.includes('text/event-stream')) {
|
||||
return { status, chunks: [], json: text ? (JSON.parse(text) as Record<string, unknown>) : null };
|
||||
}
|
||||
const events = text
|
||||
.split('\n\n')
|
||||
.filter((event) => event.startsWith('data: '))
|
||||
.map((event) => event.slice('data: '.length));
|
||||
expect(events.at(-1)).toBe('[DONE]');
|
||||
return { status, chunks: events.slice(0, -1).map((event) => JSON.parse(event) as UiStreamChunk), json: null };
|
||||
}
|
||||
|
||||
function asText(req: request.Test) {
|
||||
return req.buffer(true).parse((stream, done) => {
|
||||
let text = '';
|
||||
stream.setEncoding('utf8');
|
||||
stream.on('data', (chunk: string) => (text += chunk));
|
||||
stream.on('end', () => done(null, text));
|
||||
});
|
||||
}
|
||||
|
||||
async function post(path: string, userId: number, body: object): Promise<Answer> {
|
||||
const res = await asText(request(nestApp.getHttpServer()).post(path).set('Cookie', sessionCookie(userId)).send(body));
|
||||
return parseAnswer(res.status, String(res.headers['content-type']), res.body as string);
|
||||
}
|
||||
|
||||
async function upload(path: string, userId: number, fileName: string, text: string): Promise<Answer> {
|
||||
const req = request(nestApp.getHttpServer())
|
||||
.post(path)
|
||||
.set('Cookie', sessionCookie(userId))
|
||||
.field('locale', CTX.locale)
|
||||
.field('timeZone', CTX.timeZone)
|
||||
.attach('file', Buffer.from(text, 'utf8'), { filename: fileName, contentType: 'application/gpx+xml' });
|
||||
const res = await asText(req);
|
||||
return parseAnswer(res.status, String(res.headers['content-type']), res.body as string);
|
||||
}
|
||||
|
||||
const ofType = <T extends UiStreamChunk['type']>(chunks: UiStreamChunk[], type: T) =>
|
||||
chunks.filter((chunk): chunk is Extract<UiStreamChunk, { type: T }> => chunk.type === type);
|
||||
|
||||
async function traveller() {
|
||||
const { user } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id, { title: 'Bavaria' });
|
||||
return { userId: user.id, tripId: trip.id, base: `/api/trips/${trip.id}/assistant` };
|
||||
}
|
||||
|
||||
const WEEKEND = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<trk><name>Saturday</name><trkseg>
|
||||
<trkpt lat="47.5" lon="11.1"><ele>800</ele></trkpt>
|
||||
<trkpt lat="47.51" lon="11.1"><ele>1000</ele></trkpt>
|
||||
</trkseg></trk>
|
||||
<trk><name>Sunday</name><trkseg>
|
||||
<trkpt lat="47.6" lon="11.2"><ele>700</ele></trkpt>
|
||||
<trkpt lat="47.61" lon="11.2"><ele>900</ele></trkpt>
|
||||
</trkseg></trk>
|
||||
</gpx>`;
|
||||
|
||||
// ── Cases ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('a route file attached in the chat (P7)', () => {
|
||||
it('ASTE2E-TOURFILE-001: is read into its tracks without a model turn, each held as a hike, the GPX kept off the thread', async () => {
|
||||
const t = await traveller();
|
||||
const read = await upload(`${t.base}/attachments`, t.userId, 'weekend.gpx', WEEKEND);
|
||||
expect(read.status).toBe(200);
|
||||
expect(provider.requests).toHaveLength(0);
|
||||
const inputs = ofType(read.chunks, 'tool-input-available');
|
||||
expect(inputs.map((chunk) => chunk.toolName)).toEqual(['read_tour_file', 'import_tour_file', 'import_tour_file']);
|
||||
expect(JSON.stringify(read.chunks)).not.toContain('<trkpt');
|
||||
const changes = ofType(read.chunks, 'data-trek-change').map((chunk) => chunk.data);
|
||||
expect(changes.map((change) => [change.verbKey, change.subject, change.undoable])).toEqual([
|
||||
['importTour', 'Saturday', true],
|
||||
['importTour', 'Sunday', true],
|
||||
]);
|
||||
const threadId = ofType(read.chunks, 'data-trek-thread')[0]?.data.threadId;
|
||||
const shown = await request(nestApp.getHttpServer())
|
||||
.get(`${t.base}/threads/${threadId}`)
|
||||
.set('Cookie', sessionCookie(t.userId));
|
||||
expect(JSON.stringify(shown.body)).not.toContain('<trkpt');
|
||||
const parts = (shown.body.messages as Array<{ parts: AssistantMessagePart[] }>).flatMap((m) => m.parts);
|
||||
expect(parts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'tool-call',
|
||||
toolName: 'import_tour_file',
|
||||
args: { via: 'import_tour_gpx', file_name: 'weekend.gpx', tour_type: 'hike', track_name: 'Saturday' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('ASTE2E-TOURFILE-002: applying one track imports exactly that track as a hike, and its undo deletes it again', async () => {
|
||||
const t = await traveller();
|
||||
const read = await upload(`${t.base}/attachments`, t.userId, 'weekend.gpx', WEEKEND);
|
||||
const threadId = ofType(read.chunks, 'data-trek-thread')[0]?.data.threadId;
|
||||
const [saturday, sunday] = ofType(read.chunks, 'data-trek-change').map((chunk) => chunk.data.toolCallId);
|
||||
provider.script = [say('Saturday is in the trip now.')];
|
||||
const decided = await post(`${t.base}/threads/${threadId}/decisions`, t.userId, {
|
||||
...CTX,
|
||||
decisions: [
|
||||
{ toolCallId: saturday, approved: true },
|
||||
{ toolCallId: sunday, approved: false },
|
||||
],
|
||||
});
|
||||
expect(decided.status).toBe(200);
|
||||
const [applied] = ofType(decided.chunks, 'data-trek-applied');
|
||||
expect(applied?.data).toMatchObject({ toolCallId: saturday, entity: 'tour' });
|
||||
const placeId = applied?.data.ids[0] ?? 0;
|
||||
expect((await findRow(orm, Places, { id: placeId }))?.name).toBe('Saturday');
|
||||
expect(await countRows(orm, Places, { trip: t.tripId })).toBe(1);
|
||||
|
||||
const undone = await request(nestApp.getHttpServer())
|
||||
.post(`${t.base}/tool-calls/${saturday}/undo`)
|
||||
.set('Cookie', sessionCookie(t.userId))
|
||||
.send({});
|
||||
expect(undone.status).toBe(200);
|
||||
expect(await findRow(orm, Places, { id: placeId })).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTE2E-TOURFILE-003: a route file without a track ends with no_tracks_found and holds nothing', async () => {
|
||||
const t = await traveller();
|
||||
const pins = '<gpx version="1.1"><wpt lat="47" lon="11"><name>Pin</name></wpt></gpx>';
|
||||
const read = await upload(`${t.base}/attachments`, t.userId, 'pins.gpx', pins);
|
||||
expect(ofType(read.chunks, 'error')[0]).toMatchObject({ errorText: 'no_tracks_found' });
|
||||
expect(ofType(read.chunks, 'data-trek-change')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a stop the model takes into a saved hike (P6)', () => {
|
||||
it('ASTE2E-TOURFILE-004: is held with its card, saved when applied, and its receipt offers the way back', async () => {
|
||||
const t = await traveller();
|
||||
const place = await makePlace(orm, t.tripId, { name: 'Herzogstand', category: null });
|
||||
await makeTour(orm, place.id, { revision: 3 });
|
||||
await addTourWaypoints(orm, place.id, [
|
||||
{ lat: 47.5, lng: 11.1, station_key: 'start1', name: 'Car park' },
|
||||
{ lat: 47.505, lng: 11.1, station_key: 'alm111', name: 'Alm' },
|
||||
{ lat: 47.51, lng: 11.1, station_key: 'summit', name: 'Summit' },
|
||||
]);
|
||||
const hikes = nestApp.get(HikePlanService);
|
||||
const saved = (revision: number, keys: string[]) =>
|
||||
({
|
||||
tour: { place_id: place.id, name: 'Herzogstand', tour_type: 'hike', revision, has_waypoints: true },
|
||||
waypoints: keys.map((key, index) => ({
|
||||
key,
|
||||
lat: key === 'spring' ? 47.507 : [47.5, 47.505, 47.51][Math.min(index, 2)],
|
||||
lng: 11.1,
|
||||
role: 'via',
|
||||
sequence: index,
|
||||
})),
|
||||
route: { planned: true },
|
||||
replanned: true,
|
||||
}) as unknown as Awaited<ReturnType<HikePlanService['update']>>;
|
||||
const update = vi.spyOn(hikes, 'update').mockResolvedValue(saved(4, ['start1', 'alm111', 'spring', 'summit']));
|
||||
|
||||
provider.script = [
|
||||
callTool('call_tours', 'use_addon_tools', { groups: ['tours'] }),
|
||||
callTool('call_stop', 'insert_tour_stop', {
|
||||
placeId: place.id,
|
||||
expected_revision: 3,
|
||||
stop: { lat: 47.507, lng: 11.1, name: 'Spring' },
|
||||
}),
|
||||
say('Shall I add the spring?'),
|
||||
];
|
||||
const turn = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'Add the spring to the hike' });
|
||||
const threadId = ofType(turn.chunks, 'data-trek-thread')[0]?.data.threadId;
|
||||
const [change] = ofType(turn.chunks, 'data-trek-change');
|
||||
expect(change?.data).toMatchObject({ verbKey: 'addTourStop', subject: 'Herzogstand', undoable: true });
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
|
||||
provider.script = [say('Added.')];
|
||||
const decided = await post(`${t.base}/threads/${threadId}/decisions`, t.userId, {
|
||||
...CTX,
|
||||
decisions: [{ toolCallId: change?.data.toolCallId, approved: true }],
|
||||
});
|
||||
expect(update.mock.calls[0]?.[2]).toMatchObject({ placeId: place.id, expected_revision: 3 });
|
||||
expect(update.mock.calls[0]?.[2].waypoints?.map((point) => point.name)).toEqual([
|
||||
'Car park',
|
||||
'Alm',
|
||||
'Spring',
|
||||
'Summit',
|
||||
]);
|
||||
const [applied] = ofType(decided.chunks, 'data-trek-applied');
|
||||
expect(applied?.data).toMatchObject({ entity: 'tour', ids: [place.id] });
|
||||
expect(applied?.data.undoUntil).toEqual(expect.any(String));
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ import { MikroORM } from '@mikro-orm/core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { BOOK_SCHEMA_REV, bookWriteResultSchema } from '@trek/shared';
|
||||
import { bookWriteResultSchema } from '@trek/shared';
|
||||
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
@@ -209,7 +209,7 @@ describe('Studio book over MCP and REST', () => {
|
||||
.get(`/api/journeys/${journey.id}/book`)
|
||||
.set('Cookie', authCookie(owner.id));
|
||||
const row = await findRow(orm, JourneyBooks, { journey: journey.id });
|
||||
const newer = JSON.stringify({ ...JSON.parse(String(row?.document)), schemaRev: BOOK_SCHEMA_REV + 1 });
|
||||
const newer = JSON.stringify({ ...JSON.parse(String(row?.document)), schemaRev: 2 });
|
||||
await updateRows(orm, JourneyBooks, { journey: journey.id }, { document: newer });
|
||||
|
||||
const saved = await request(app.getHttpServer())
|
||||
@@ -220,7 +220,7 @@ describe('Studio book over MCP and REST', () => {
|
||||
expect(saved.body).toEqual({
|
||||
error: 'Book was saved by a newer version of TREK Studio',
|
||||
code: 'book_schema_newer',
|
||||
schemaRev: BOOK_SCHEMA_REV + 1,
|
||||
schemaRev: 2,
|
||||
});
|
||||
|
||||
const tool = await callTool(owner.id, 'edit_book_page', {
|
||||
@@ -249,16 +249,12 @@ describe('Studio book over MCP and REST', () => {
|
||||
const saved = await request(app.getHttpServer())
|
||||
.put(`/api/journeys/${journey.id}/book`)
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({
|
||||
title: before.body.book.title,
|
||||
document: { ...document, schemaRev: BOOK_SCHEMA_REV + 1 },
|
||||
baseVersion: 1,
|
||||
});
|
||||
.send({ title: before.body.book.title, document: { ...document, schemaRev: 2 }, baseVersion: 1 });
|
||||
expect(saved.status).toBe(409);
|
||||
expect(saved.body).toEqual({
|
||||
error: 'Book was saved by a newer version of TREK Studio',
|
||||
code: 'book_schema_newer',
|
||||
schemaRev: BOOK_SCHEMA_REV + 1,
|
||||
schemaRev: 2,
|
||||
});
|
||||
expect(sent()).toEqual([]);
|
||||
const after = await findRow(orm, JourneyBooks, { journey: journey.id });
|
||||
|
||||
@@ -382,6 +382,32 @@ export const toursRoadtrip: CaseGroup = {
|
||||
stub: routerDown,
|
||||
expect: ['tours:changed', 'place:updated'],
|
||||
},
|
||||
{
|
||||
// P6: a found place taken into the walk as a station, saved as save_tour_change saves one change.
|
||||
tool: 'insert_tour_stop',
|
||||
needs: ['tour'],
|
||||
setup: planWalk,
|
||||
args: (s) => ({
|
||||
tripId: s.trip.id,
|
||||
placeId: s.tourPlace.id,
|
||||
expected_revision: 1,
|
||||
stop: { lat: 48.106, lng: 11.5, name: 'Fountain' },
|
||||
after_key: 'walk-market',
|
||||
}),
|
||||
// The planner takes a found place in as a point of its draft and saves the walk through the same route write.
|
||||
rest: { method: 'put', path: tourPath, body: () => walkBody(1) },
|
||||
stub: routerDown,
|
||||
expect: ['tours:changed', 'place:updated'],
|
||||
},
|
||||
{
|
||||
tool: 'remove_tour_stop',
|
||||
needs: ['tour'],
|
||||
setup: planWalk,
|
||||
args: (s) => ({ tripId: s.trip.id, placeId: s.tourPlace.id, expected_revision: 1, key: 'walk-market' }),
|
||||
rest: { method: 'put', path: tourPath, body: () => walkBody(1) },
|
||||
stub: routerDown,
|
||||
expect: ['tours:changed', 'place:updated'],
|
||||
},
|
||||
{
|
||||
tool: 'undo_tour_change',
|
||||
needs: ['tour'],
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* P6, P7: the server's own tour steps as the model is told of them, a found
|
||||
* place taken into a tour, a route file read and a track of it taken in,
|
||||
* through the note the history replays in place of a call.
|
||||
*/
|
||||
import { serverStepsNote } from '../../../../src/nest/assistant/assistant-history';
|
||||
import {
|
||||
ADD_FOUND_PLACE_TOOL,
|
||||
ASSISTANT_SERVER_TOOLS,
|
||||
IMPORT_TOUR_FILE_TOOL,
|
||||
READ_TOUR_FILE_TOOL,
|
||||
} from '../../../../src/nest/assistant/assistant-tools.constants';
|
||||
import type { AssistantMessagePart } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// AST-HISTORY-TOURS-001 to AST-HISTORY-TOURS-004
|
||||
|
||||
type Call = Extract<AssistantMessagePart, { type: 'tool-call' }>;
|
||||
|
||||
const call = (toolName: string, args: Record<string, unknown>, over: Partial<Call> = {}): Call => ({
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c1',
|
||||
toolName,
|
||||
args,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('the tour steps the model is told of', () => {
|
||||
it('AST-HISTORY-TOURS-001: the route file steps are the server own, never replayed as calls', () => {
|
||||
expect(ASSISTANT_SERVER_TOOLS.has(READ_TOUR_FILE_TOOL)).toBe(true);
|
||||
expect(ASSISTANT_SERVER_TOOLS.has(IMPORT_TOUR_FILE_TOOL)).toBe(true);
|
||||
});
|
||||
|
||||
it('AST-HISTORY-TOURS-002: a found place taken into a tour, as it waits and once decided', () => {
|
||||
const args = { via: 'insert_tour_stop', placeId: 64, stop: { name: 'Spring' }, tourName: 'Herzogstand' };
|
||||
expect(serverStepsNote([call(ADD_FOUND_PLACE_TOOL, args, { approval: { id: 'c1' } })])).toBe(
|
||||
'The user picked "Spring" as a stop of the tour "Herzogstand" from the search results; it waits for them to add or discard it.',
|
||||
);
|
||||
expect(serverStepsNote([call(ADD_FOUND_PLACE_TOOL, args, { approval: { id: 'c1', approved: true } })])).toBe(
|
||||
'"Spring" as a stop of the tour "Herzogstand" was added.',
|
||||
);
|
||||
expect(serverStepsNote([call(ADD_FOUND_PLACE_TOOL, args, { approval: { id: 'c1', approved: false } })])).toBe(
|
||||
'The user discarded "Spring" as a stop of the tour "Herzogstand".',
|
||||
);
|
||||
});
|
||||
|
||||
it('AST-HISTORY-TOURS-003: a route file read, with its tracks, without any, or not at all', () => {
|
||||
const tracks = [{ name: 'Saturday', distance_km: 8.2, points: 120, has_heights: true }];
|
||||
expect(serverStepsNote([call(READ_TOUR_FILE_TOOL, { name: 'weekend.gpx' }, { result: { tracks } })])).toBe(
|
||||
`TREK read the attached route file "weekend.gpx" and found 1 track(s): ${JSON.stringify(tracks)}.`,
|
||||
);
|
||||
expect(serverStepsNote([call(READ_TOUR_FILE_TOOL, { name: 'pins.kml' }, { result: { tracks: [] } })])).toBe(
|
||||
'TREK read the attached route file "pins.kml" and found no track or route in it.',
|
||||
);
|
||||
expect(serverStepsNote([call(READ_TOUR_FILE_TOOL, { name: 'x.kmz' }, { isError: true })])).toBe(
|
||||
'TREK could not read the attached route file "x.kmz".',
|
||||
);
|
||||
});
|
||||
|
||||
it('AST-HISTORY-TOURS-004: a track taken in as a hike, or not', () => {
|
||||
const args = { via: 'import_tour_gpx', file_name: 'weekend.gpx', track_name: 'Saturday', tour_type: 'hike' };
|
||||
expect(serverStepsNote([call(IMPORT_TOUR_FILE_TOOL, args, { approval: { id: 'c1', approved: true } })])).toBe(
|
||||
'The track "Saturday" of "weekend.gpx" was taken in as a hike.',
|
||||
);
|
||||
expect(
|
||||
serverStepsNote([call(IMPORT_TOUR_FILE_TOOL, args, { approval: { id: 'c1', approved: true }, isError: true })]),
|
||||
).toBe('The track "Saturday" of "weekend.gpx" could not be taken in as a hike.');
|
||||
expect(
|
||||
serverStepsNote([call(IMPORT_TOUR_FILE_TOOL, args, { approval: { id: 'c1', resolution: 'cancelled' } })]),
|
||||
).toBe('The track "Saturday" of "weekend.gpx" was not taken in as a hike; the user moved on without deciding.');
|
||||
expect(serverStepsNote([call(IMPORT_TOUR_FILE_TOOL, args, { approval: { id: 'c1' } })])).toBe(
|
||||
'The track "Saturday" of "weekend.gpx" waits for the user to take it in as a hike or discard it.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* P7: a route file attached in the trip chat, as the assistant proposes it:
|
||||
* refused where tours are off, one held import per track (the GPX text on the
|
||||
* held call only), the tracks the trip has or no import takes named in the
|
||||
* read step and not proposed, at most a step's worth.
|
||||
*/
|
||||
import { AddonsService } from '../../../../src/nest/addons/addons.service';
|
||||
import {
|
||||
foundPlaceArgs,
|
||||
IMPORT_TOUR_FILE_TOOL,
|
||||
MAX_WRITES_PER_STEP,
|
||||
READ_TOUR_FILE_TOOL,
|
||||
writeToolOf,
|
||||
} from '../../../../src/nest/assistant/assistant-tools.constants';
|
||||
import { AssistantTourFileService } from '../../../../src/nest/assistant/assistant-tour-file.service';
|
||||
import { DomainError } from '../../../../src/nest/common/domain-error';
|
||||
import { TourFileReadService, type TourFileTrack } from '../../../../src/nest/tours/tour-file-read.service';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// AST-TOUR-FILE-001 to AST-TOUR-FILE-005
|
||||
|
||||
const track = (name: string, over: Partial<TourFileTrack> = {}): TourFileTrack => ({
|
||||
name,
|
||||
gpx: `<gpx><trk><name>${name}</name></trk></gpx>`,
|
||||
point_count: 120,
|
||||
distance_km: 8.24,
|
||||
has_heights: true,
|
||||
duplicate: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
function file(name: string): Express.Multer.File {
|
||||
return { originalname: name, buffer: Buffer.from('<gpx/>'), size: 6 } as Express.Multer.File;
|
||||
}
|
||||
|
||||
async function make(tracks: TourFileTrack[], opts: { tours?: boolean; whole?: boolean } = {}) {
|
||||
const read = vi.fn(async () => ({ tracks, whole: opts.whole === true }));
|
||||
const isAddonEnabled = vi.fn(async () => opts.tours !== false);
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
AssistantTourFileService,
|
||||
{ provide: TourFileReadService, useValue: { read } },
|
||||
{ provide: AddonsService, useValue: { isAddonEnabled } },
|
||||
],
|
||||
}).compile();
|
||||
return { service: moduleRef.get(AssistantTourFileService), read, isAddonEnabled };
|
||||
}
|
||||
|
||||
describe('AssistantTourFileService', () => {
|
||||
it('AST-TOUR-FILE-001: names its read step and the error of a file without a track', async () => {
|
||||
const { service } = await make([]);
|
||||
expect(service.readTool).toBe(READ_TOUR_FILE_TOOL);
|
||||
expect(service.emptyCode).toBe('no_tracks_found');
|
||||
});
|
||||
|
||||
it('AST-TOUR-FILE-002: refuses a route file where tours are off', async () => {
|
||||
const { service, isAddonEnabled } = await make([], { tours: false });
|
||||
await expect(service.ensureReadable(7, file('a.gpx'))).rejects.toBeInstanceOf(DomainError);
|
||||
expect(isAddonEnabled).toHaveBeenCalledWith('tours');
|
||||
await expect((await make([])).service.ensureReadable(7, file('a.gpx'))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('AST-TOUR-FILE-003: proposes one hike per track, the GPX text kept on the held call only', async () => {
|
||||
const { service, read } = await make([track('Saturday'), track('Sunday')]);
|
||||
const { found, proposals } = await service.propose(7, 5, file('weekend.gpx'));
|
||||
expect(read).toHaveBeenCalledWith(5, expect.any(Buffer), 'weekend.gpx');
|
||||
expect(proposals).toHaveLength(2);
|
||||
expect(proposals[0]).toEqual({
|
||||
toolName: IMPORT_TOUR_FILE_TOOL,
|
||||
args: {
|
||||
via: 'import_tour_gpx',
|
||||
file_name: 'weekend.gpx',
|
||||
tour_type: 'hike',
|
||||
track_name: 'Saturday',
|
||||
gpx: '<gpx><trk><name>Saturday</name></trk></gpx>',
|
||||
},
|
||||
shownArgs: { via: 'import_tour_gpx', file_name: 'weekend.gpx', tour_type: 'hike', track_name: 'Saturday' },
|
||||
});
|
||||
expect(found).toEqual({
|
||||
tracks: [
|
||||
{ name: 'Saturday', distance_km: 8.2, points: 120, has_heights: true },
|
||||
{ name: 'Sunday', distance_km: 8.2, points: 120, has_heights: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('AST-TOUR-FILE-004: names a track the trip has or no import takes, and proposes neither', async () => {
|
||||
const { service } = await make([track('Known', { duplicate: true }), track('Huge', { gpx: null }), track('New')]);
|
||||
const { found, proposals } = await service.propose(7, 5, file('mixed.kml'));
|
||||
expect(proposals.map((proposal) => proposal.args.track_name)).toEqual(['New']);
|
||||
expect(found.tracks).toEqual([
|
||||
expect.objectContaining({ name: 'Known', skipped: 'already_in_trip' }),
|
||||
expect.objectContaining({ name: 'Huge', skipped: 'too_large' }),
|
||||
expect.objectContaining({ name: 'New' }),
|
||||
]);
|
||||
expect(found.proposed).toBe(1);
|
||||
});
|
||||
|
||||
it('AST-TOUR-FILE-005: holds at most a step of tracks, runs each through import_tour_gpx with what it takes', async () => {
|
||||
const many = Array.from({ length: MAX_WRITES_PER_STEP + 3 }, (_, index) => track(`Leg ${index}`));
|
||||
const { service } = await make(many, { whole: true });
|
||||
const { found, proposals } = await service.propose(7, 5, file('legs.gpx'));
|
||||
expect(proposals).toHaveLength(MAX_WRITES_PER_STEP);
|
||||
expect(found.whole_file).toBe(true);
|
||||
const args = proposals[0]?.args ?? {};
|
||||
expect(writeToolOf(IMPORT_TOUR_FILE_TOOL, args)).toBe('import_tour_gpx');
|
||||
expect(foundPlaceArgs(args)).toEqual({ gpx: args.gpx, file_name: 'legs.gpx', tour_type: 'hike' });
|
||||
expect(writeToolOf(IMPORT_TOUR_FILE_TOOL, { ...args, via: 'delete_place' })).toBe(IMPORT_TOUR_FILE_TOOL);
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,10 @@ function make() {
|
||||
undo: vi.fn(async () => undefined),
|
||||
stop: vi.fn(async () => undefined),
|
||||
};
|
||||
const foundPlaces = { add: vi.fn(async () => ({ message: { id: 'msg-3' } })) };
|
||||
const foundPlaces = {
|
||||
add: vi.fn(async () => ({ message: { id: 'msg-3' } })),
|
||||
insertIntoTour: vi.fn(async () => ({ message: { id: 'msg-4' } })),
|
||||
};
|
||||
const shares = { share: vi.fn(async () => ({ message: { id: 12, origin: 'assistant' } })) };
|
||||
const proposals = { take: vi.fn(async () => ({ message: { id: 'msg-4' } })) };
|
||||
const controller = new AssistantController(
|
||||
@@ -112,6 +115,11 @@ describe('AssistantController', () => {
|
||||
path: 'threads/:threadId/found-places',
|
||||
httpCode: 200,
|
||||
});
|
||||
expect(route('insertFoundPlace')).toEqual({
|
||||
method: RequestMethod.POST,
|
||||
path: 'threads/:threadId/found-places/tour',
|
||||
httpCode: 200,
|
||||
});
|
||||
expect(route('takeProposal')).toEqual({
|
||||
method: RequestMethod.POST,
|
||||
path: 'threads/:threadId/proposals',
|
||||
@@ -148,6 +156,9 @@ describe('AssistantController', () => {
|
||||
});
|
||||
expect(shares.share).toHaveBeenCalledWith(user, 5, 9, 'msg-3', 'Hi all', 'sock-1');
|
||||
expect(() => controller.addFoundPlace(user, '5', 'x', body)).toThrow(DomainError);
|
||||
const stop = { locale: 'en', timeZone: 'UTC', ref: 'call_1:0', tourId: 64 };
|
||||
expect(await controller.insertFoundPlace(user, '5', '9', stop)).toEqual({ message: { id: 'msg-4' } });
|
||||
expect(foundPlaces.insertIntoTour).toHaveBeenCalledWith(7, 5, 9, stop);
|
||||
});
|
||||
|
||||
it('ASTCTL-008: sharing asks for the trip chat right, "Plan this" and "+ Day" for editing places; nothing else asks', () => {
|
||||
@@ -155,6 +166,7 @@ describe('AssistantController', () => {
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handlers.share)).toBe('collab_edit');
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handlers.takeProposal)).toBe('place_edit');
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handlers.addFoundPlace)).toBe('place_edit');
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handlers.insertFoundPlace)).toBe('place_edit');
|
||||
for (const name of ['chat', 'decide', 'undo']) {
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handlers[name]), name).toBeUndefined();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* ASTCARD-TOUR-080 to ASTCARD-TOUR-087
|
||||
*
|
||||
* P6 in the chat: a found place taken into a saved tour as a stop
|
||||
* (insert_tour_stop), by the model or with "+ Tour" on a found place, and a
|
||||
* stop taken out (remove_tour_stop). The card names the tour, the stop and
|
||||
* the two points it goes between; each change goes back by the other while
|
||||
* the tour is still at the revision the change left it at.
|
||||
*/
|
||||
import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context';
|
||||
import { stopArgs } from '../../../../../../src/nest/assistant/assistant-found-places.service';
|
||||
import {
|
||||
ADD_FOUND_PLACE_TOOL,
|
||||
foundPlaceArgs,
|
||||
writeToolOf,
|
||||
} from '../../../../../../src/nest/assistant/assistant-tools.constants';
|
||||
import { unchangedSinceApplied } from '../../../../../../src/nest/assistant/assistant-undo.helpers';
|
||||
import { loadFor } from '../../../../../../src/nest/assistant/cards/card-context';
|
||||
import type { PreviewContext } from '../../../../../../src/nest/assistant/cards/card-types';
|
||||
import { readKey } from '../../../../../../src/nest/assistant/cards/card-values';
|
||||
import { ASSISTANT_UNDO_MAP, ASSISTANT_WRITE_TOOLS } from '../../../../../../src/nest/assistant/cards/registry';
|
||||
import { defaultAfterKeyOf } from '../../../../../../src/nest/assistant/cards/tour/tour-stop.cards';
|
||||
import { describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
|
||||
import { DomainError } from '../../../../../../src/nest/common/domain-error';
|
||||
import { assistantChangePreviewSchema, type AssistantFoundPlace } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const logMock = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
|
||||
vi.mock('../../../../../../src/nest/audit/audit-log.logger', () => logMock);
|
||||
|
||||
const card = buildTripCard(
|
||||
1,
|
||||
7,
|
||||
{ trip: { title: 'Alps' }, days: [] },
|
||||
{ places: [{ id: 64, name: 'Herzogstand', lat: 47.5, lng: 11.1 }] },
|
||||
null,
|
||||
);
|
||||
const ctx: PreviewContext = { card, categories: new Map(), locale: 'en-GB', today: '2026-06-01', airport: () => null };
|
||||
|
||||
/** get_tour_plan of the hike: three points and its line in brief. */
|
||||
const PLAN = {
|
||||
tour: { place_id: 64, name: 'Herzogstand', tour_type: 'hike', revision: 3 },
|
||||
waypoints: [
|
||||
{ key: 'start1', lat: 47.5, lng: 11.1, role: 'start', name: 'Car park' },
|
||||
{ key: 'alm111', lat: 47.505, lng: 11.1, role: 'via', name: 'Alm' },
|
||||
{ key: 'summit', lat: 47.51, lng: 11.1, role: 'end', name: 'Summit' },
|
||||
],
|
||||
line: {
|
||||
point_count: 3,
|
||||
has_heights: true,
|
||||
preview: [
|
||||
[47.5, 11.1, 800],
|
||||
[47.505, 11.1, 900],
|
||||
[47.51, 11.1, 1000],
|
||||
],
|
||||
},
|
||||
};
|
||||
const STOP = { lat: 47.507, lng: 11.1, name: 'Spring' };
|
||||
|
||||
function holding(value: unknown) {
|
||||
return {
|
||||
summary: { days: [] },
|
||||
places: { places: [] },
|
||||
reads: new Map([[readKey('get_tour_plan', { placeId: 64 }), value]]),
|
||||
};
|
||||
}
|
||||
|
||||
describe('a stop taken into a tour in the chat', () => {
|
||||
it('ASTCARD-TOUR-080: reads the tour, and its card names the tour, the stop and the two points it goes between', async () => {
|
||||
const read = vi.fn(async () => PLAN);
|
||||
const args = { placeId: 64, expected_revision: 3, stop: STOP };
|
||||
const loaded = await loadFor('insert_tour_stop', args, read);
|
||||
expect(read).toHaveBeenCalledWith('get_tour_plan', { placeId: 64 });
|
||||
const preview = describeToolCall('insert_tour_stop', 'tc-1', args, ctx, loaded);
|
||||
expect(assistantChangePreviewSchema.safeParse(preview).success).toBe(true);
|
||||
expect(preview).toMatchObject({ subject: 'Herzogstand', verbKey: 'addTourStop', placeIds: [64], undoable: true });
|
||||
expect(preview.rows).toEqual([
|
||||
{ op: 'add', labelKey: 'name', before: null, after: 'Spring' },
|
||||
{ op: 'add', labelKey: 'position', before: null, after: 'Alm → Summit' },
|
||||
]);
|
||||
expect(ASSISTANT_WRITE_TOOLS.insert_tour_stop).toMatchObject({ verbKey: 'addTourStop', entity: 'tour' });
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-081: a point named keeps its place in the card; a new end has nothing after it', () => {
|
||||
const named = describeToolCall(
|
||||
'insert_tour_stop',
|
||||
'tc-1',
|
||||
{ placeId: 64, stop: STOP, after_key: 'start1' },
|
||||
ctx,
|
||||
PLAN,
|
||||
);
|
||||
expect(named.rows[1]?.after).toBe('Car park → Alm');
|
||||
const end = describeToolCall(
|
||||
'insert_tour_stop',
|
||||
'tc-1',
|
||||
{ placeId: 64, stop: STOP, after_key: 'summit' },
|
||||
ctx,
|
||||
PLAN,
|
||||
);
|
||||
expect(end.rows[1]?.after).toBe('Summit →');
|
||||
// Without the tour read, the card still names the tour from the trip.
|
||||
expect(describeToolCall('insert_tour_stop', 'tc-1', { placeId: 64, stop: STOP }, ctx, null).subject).toBe(
|
||||
'Herzogstand',
|
||||
);
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-082: goes back by taking the stop out, while the tour is at the revision it left', () => {
|
||||
const undo = ASSISTANT_UNDO_MAP.insert_tour_stop;
|
||||
const result = { tour: { place_id: 64, revision: 4 }, stop: { key: 'spring', name: 'Spring' } };
|
||||
expect(undo.tool).toBe('remove_tour_stop');
|
||||
expect(undo.args({}, result, null)).toEqual({ placeId: 64, key: 'spring', expected_revision: 4 });
|
||||
expect(undo.args({}, { ...result, stop: { key: null } }, null)).toBeNull();
|
||||
const call = { args: { placeId: 64 }, preview: null };
|
||||
const at = (revision: number) => holding({ tour: { revision } });
|
||||
expect(unchangedSinceApplied('insert_tour_stop', result, at(4), call)).toBe(true);
|
||||
expect(unchangedSinceApplied('insert_tour_stop', result, at(5), call)).toBe(false);
|
||||
expect(unchangedSinceApplied('insert_tour_stop', result, at(4))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a stop taken out in the chat', () => {
|
||||
it('ASTCARD-TOUR-083: names the tour and the stop, and goes back by putting it after the point it followed', () => {
|
||||
const preview = describeToolCall('remove_tour_stop', 'tc-2', { placeId: 64, key: 'alm111' }, ctx, PLAN);
|
||||
expect(preview).toMatchObject({ subject: 'Herzogstand', verbKey: 'removeTourStop', undoable: true });
|
||||
expect(preview.rows).toEqual([{ op: 'add', labelKey: 'name', before: null, after: 'Alm' }]);
|
||||
const undo = ASSISTANT_UNDO_MAP.remove_tour_stop;
|
||||
const result = {
|
||||
tour: { place_id: 64, revision: 5 },
|
||||
removed: { key: 'alm111', lat: 47.505, lng: 11.1, name: 'Alm', poi_category: 'shelter', after_key: 'start1' },
|
||||
};
|
||||
expect(undo.tool).toBe('insert_tour_stop');
|
||||
expect(undo.args({}, result, null)).toEqual({
|
||||
placeId: 64,
|
||||
expected_revision: 5,
|
||||
stop: { lat: 47.505, lng: 11.1, name: 'Alm', poi_category: 'shelter' },
|
||||
after_key: 'start1',
|
||||
});
|
||||
expect(undo.args({}, { tour: result.tour, removed: { key: 'x' } }, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('"+ Tour" on a found place', () => {
|
||||
const place: AssistantFoundPlace = {
|
||||
ref: 'tc-9:0',
|
||||
name: 'Spring',
|
||||
address: null,
|
||||
lat: 47.507,
|
||||
lng: 11.1,
|
||||
kind: null,
|
||||
rating: null,
|
||||
openNow: null,
|
||||
googlePlaceId: null,
|
||||
osmId: null,
|
||||
photoKey: null,
|
||||
inTripPlaceId: null,
|
||||
};
|
||||
|
||||
it('ASTCARD-TOUR-084: holds insert_tour_stop after the last point before the place, against the revision read', () => {
|
||||
const args = stopArgs(place, 64, PLAN);
|
||||
expect(args).toEqual({
|
||||
via: 'insert_tour_stop',
|
||||
placeId: 64,
|
||||
expected_revision: 3,
|
||||
stop: { lat: 47.507, lng: 11.1, name: 'Spring' },
|
||||
after_key: 'alm111',
|
||||
tourName: 'Herzogstand',
|
||||
});
|
||||
expect(writeToolOf(ADD_FOUND_PLACE_TOOL, args)).toBe('insert_tour_stop');
|
||||
expect(foundPlaceArgs(args)).toEqual({
|
||||
placeId: 64,
|
||||
expected_revision: 3,
|
||||
stop: { lat: 47.507, lng: 11.1, name: 'Spring' },
|
||||
after_key: 'alm111',
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-085: refuses a tour of another trip, one not read and a type that takes no stop', () => {
|
||||
expect(() => stopArgs(place, 65, PLAN)).toThrow(DomainError);
|
||||
expect(() => stopArgs(place, 64, null)).toThrow(/Tour not found/);
|
||||
expect(() => stopArgs(place, 64, { ...PLAN, tour: { ...PLAN.tour, tour_type: 'bike' } })).toThrow(
|
||||
/hike or a city walk/,
|
||||
);
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-086: the card of a held "+ Tour" reads as the stop it adds', () => {
|
||||
const args = stopArgs(place, 64, PLAN);
|
||||
const preview = describeToolCall(ADD_FOUND_PLACE_TOOL, 'tc-3', args, ctx, PLAN);
|
||||
expect(preview).toMatchObject({ verbKey: 'addTourStop', subject: 'Herzogstand' });
|
||||
expect(preview.rows[1]?.after).toBe('Alm → Summit');
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-087: the point a place goes after skips a point the tour gave no key yet', () => {
|
||||
const unkeyed = { ...PLAN, waypoints: PLAN.waypoints.map((point) => ({ ...point, key: undefined })) };
|
||||
expect(defaultAfterKeyOf(PLAN, STOP)).toBe('alm111');
|
||||
expect(defaultAfterKeyOf(unkeyed, STOP)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import { createTestJourneyBooksRepo } from '../../helpers/journey-share-repos';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestUnitOfWork, sharedTestOrm, createTestTripsRepo, createTestPlacesRepo } from '../../helpers/test-uow';
|
||||
import { BOOK_SCHEMA_REV, normalizeBookDocument } from '@trek/shared';
|
||||
import { normalizeBookDocument } from '@trek/shared';
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
@@ -561,11 +561,9 @@ describe('editBook', () => {
|
||||
const journey = createJourney(testDb, user.id);
|
||||
await books.saveBook(journey.id, user.id, { title: 'T', document: doc('one') });
|
||||
|
||||
// The Studio stamps its revision on every save, as the tool's write does, so the race is about the version alone.
|
||||
const fromStudio = { ...doc('studio'), schemaRev: BOOK_SCHEMA_REV };
|
||||
const [tool, studio] = await Promise.all([
|
||||
catchDomainError(() => books.editBook(journey.id, user.id, edited('tool'), { expectedVersion: 1 })),
|
||||
books.saveBook(journey.id, user.id, { title: 'T', document: fromStudio, baseVersion: 1 }),
|
||||
books.saveBook(journey.id, user.id, { title: 'T', document: doc('studio'), baseVersion: 1 }),
|
||||
]);
|
||||
|
||||
const toolLost = tool instanceof DomainError && tool.getStatus() === 409;
|
||||
@@ -699,7 +697,7 @@ describe('format revisions', () => {
|
||||
});
|
||||
|
||||
it('JBOOK-014: the book tools leave a book this server cannot read whole alone, before any edit runs', async () => {
|
||||
const { user, journey } = await storedAt(BOOK_SCHEMA_REV + 1);
|
||||
const { user, journey } = await storedAt(2);
|
||||
const rec = await recordSends();
|
||||
try {
|
||||
const edit = vi.fn(() => ({ title: 'T', document: normalizeBookDocument(doc('tool')), result: null }));
|
||||
@@ -718,7 +716,7 @@ describe('format revisions', () => {
|
||||
const { user, journey } = await storedAt(1);
|
||||
const rec = await recordSends();
|
||||
try {
|
||||
const ahead = normalizeBookDocument({ ...doc('ahead'), schemaRev: BOOK_SCHEMA_REV + 1 });
|
||||
const ahead = normalizeBookDocument({ ...doc('ahead'), schemaRev: 2 });
|
||||
const thrown = await catchDomainError(() =>
|
||||
books.editBook(journey.id, user.id, () => ({ title: 'T', document: ahead, result: null })),
|
||||
);
|
||||
|
||||
@@ -34,12 +34,7 @@ import { makeUser } from '../../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../../helpers/test-module';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import {
|
||||
BOOK_SCHEMA_REV,
|
||||
bookDesignAnswerSchema,
|
||||
bookDesignApplyResultSchema,
|
||||
type BookDesignAnswer,
|
||||
} from '@trek/shared';
|
||||
import { bookDesignAnswerSchema, bookDesignApplyResultSchema, type BookDesignAnswer } from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -332,7 +327,7 @@ describe('taking a design back', () => {
|
||||
const applied = await designs.apply(journey.id, owner.id, { designId: answer.design_id });
|
||||
const revisionId = applied.revision_id ?? 0;
|
||||
const kept = await findRow(orm, JourneyBookRevisions, { id: revisionId });
|
||||
const newer = JSON.stringify({ ...JSON.parse(String(kept?.document)), schemaRev: BOOK_SCHEMA_REV + 1 });
|
||||
const newer = JSON.stringify({ ...JSON.parse(String(kept?.document)), schemaRev: 2 });
|
||||
await updateRows(orm, JourneyBookRevisions, { id: revisionId }, { document: newer });
|
||||
t.realtime.reset();
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* P7: a route file attached in the assistant's chat, read into one GPX text
|
||||
* per track (TourFileReadService over the Tours import's own reader, with the
|
||||
* real place import over a snapshot test db), and the cut itself
|
||||
* (tour-file-split.helpers): a GPX file keeps every point as it came, a KML
|
||||
* line is written as GPX.
|
||||
*/
|
||||
import { PlaceImportService } from '../../../src/nest/place-import/place-import.service';
|
||||
import { TourFileReadService } from '../../../src/nest/tours/tour-file-read.service';
|
||||
import { gpxOfLine, splitGpxTracks } from '../../../src/nest/tours/tour-file-split.helpers';
|
||||
import { TourFilesModule } from '../../../src/nest/tours/tour-files.module';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { makeTrip } from '../../helpers/factories/trips';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { TOUR_GPX_TEXT_MAX } from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
// TOUR-FILE-READ-001 to TOUR-FILE-READ-008
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
let t: TestModule;
|
||||
let files: TourFileReadService;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({ db, imports: [TourFilesModule] });
|
||||
files = t.get(TourFileReadService);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
const TWO_TRACKS = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<metadata><name>Weekend</name></metadata>
|
||||
<wpt lat="47.0" lon="11.0"><name>Loose pin</name></wpt>
|
||||
<trk><name>Saturday</name><trkseg>
|
||||
<trkpt lat="47.5" lon="11.1"><ele>800</ele><time>2026-10-17T08:00:00Z</time></trkpt>
|
||||
<trkpt lat="47.51" lon="11.1"><ele>1000</ele><time>2026-10-17T09:00:00Z</time></trkpt>
|
||||
</trkseg></trk>
|
||||
<rte><name>Sunday</name>
|
||||
<rtept lat="47.6" lon="11.2"></rtept>
|
||||
<rtept lat="47.61" lon="11.2"></rtept>
|
||||
</rte>
|
||||
</gpx>`;
|
||||
|
||||
const KML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
|
||||
<Placemark><name>Ridge & lake</name><LineString><coordinates>11.1,47.5,800 11.1,47.51,1000</coordinates></LineString></Placemark>
|
||||
<Placemark><name>Pin</name><Point><coordinates>11.0,47.0</coordinates></Point></Placemark>
|
||||
</Document></kml>`;
|
||||
|
||||
describe('cutting a GPX file into its tracks', () => {
|
||||
it('TOUR-FILE-READ-001: one text per track and route, each with the head and without the loose points', () => {
|
||||
const pieces = splitGpxTracks(TWO_TRACKS);
|
||||
expect(pieces).toHaveLength(2);
|
||||
expect(pieces[0]).toContain('<metadata><name>Weekend</name></metadata>');
|
||||
expect(pieces[0]).toContain('<time>2026-10-17T08:00:00Z</time>');
|
||||
expect(pieces[0]).not.toContain('Sunday');
|
||||
expect(pieces[0]).not.toContain('Loose pin');
|
||||
expect(pieces[1]).toContain('<rte><name>Sunday</name>');
|
||||
expect(pieces.every((piece) => piece.trimEnd().endsWith('</gpx>'))).toBe(true);
|
||||
});
|
||||
|
||||
it('TOUR-FILE-READ-002: a <trkseg> is no track of its own, and a file without any gives nothing', () => {
|
||||
expect(splitGpxTracks('<gpx><trkseg></trkseg></gpx>')).toEqual([]);
|
||||
expect(splitGpxTracks('<gpx><trk><name>Open</name>')).toEqual([]);
|
||||
});
|
||||
|
||||
it('TOUR-FILE-READ-003: a KML line is written as one GPX track, its name escaped', () => {
|
||||
const gpx = gpxOfLine('Ridge & lake', 'Up <and> down', [
|
||||
[47.5, 11.1, 800],
|
||||
[47.51, 11.1],
|
||||
]);
|
||||
expect(gpx).toContain('<name>Ridge & lake</name>');
|
||||
expect(gpx).toContain('<desc>Up <and> down</desc>');
|
||||
expect(gpx).toContain('<trkpt lat="47.5" lon="11.1"><ele>800</ele></trkpt>');
|
||||
expect(gpx).toContain('<trkpt lat="47.51" lon="11.1"></trkpt>');
|
||||
const rows = t.get(PlaceImportService).readGpx(Buffer.from(gpx, 'utf8'), { importTracks: true });
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TourFileReadService', () => {
|
||||
async function tripId() {
|
||||
const { user } = await makeUser(t.get(MikroORM));
|
||||
return (await makeTrip(t.get(MikroORM), user.id)).id;
|
||||
}
|
||||
|
||||
it('TOUR-FILE-READ-004: a GPX file comes back track by track, each one reading back as itself', async () => {
|
||||
const read = await files.read(await tripId(), Buffer.from(TWO_TRACKS, 'utf8'), 'weekend.gpx');
|
||||
expect(read.whole).toBe(false);
|
||||
expect(read.tracks.map((track) => [track.name, track.point_count, track.duplicate])).toEqual([
|
||||
['Saturday', 2, false],
|
||||
['Sunday', 2, false],
|
||||
]);
|
||||
expect(read.tracks[0]?.has_heights).toBe(true);
|
||||
expect(read.tracks[0]?.distance_km).toBeGreaterThan(1);
|
||||
expect(read.tracks[0]?.gpx).toContain('2026-10-17T08:00:00Z');
|
||||
});
|
||||
|
||||
it('TOUR-FILE-READ-005: a KML file gives its lines as GPX and counts the pins it leaves out', async () => {
|
||||
const read = await files.read(await tripId(), Buffer.from(KML, 'utf8'), 'ridge.kml');
|
||||
expect(read.tracks).toHaveLength(1);
|
||||
expect(read.tracks[0]?.name).toBe('Ridge & lake');
|
||||
expect(read.tracks[0]?.gpx).toContain('<trkseg>');
|
||||
expect(read.left_out).toBeDefined();
|
||||
});
|
||||
|
||||
it('TOUR-FILE-READ-006: a GPX file whose tracks cannot be cut comes back whole, as one import', async () => {
|
||||
const prefixed = TWO_TRACKS.replace(/<(\/?)(trk|rte)\b/g, '<$1gpx:$2').replace(
|
||||
'xmlns="http://www.topografix.com/GPX/1/1"',
|
||||
'xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpx="http://www.topografix.com/GPX/1/1"',
|
||||
);
|
||||
const read = await files.read(await tripId(), Buffer.from(prefixed, 'utf8'), 'weekend.gpx');
|
||||
if (read.tracks.length === 0) return; // a reader that takes no prefixed tags finds nothing to cut
|
||||
expect(read.whole).toBe(true);
|
||||
expect(read.tracks).toHaveLength(1);
|
||||
expect(read.tracks[0]?.name).toBe('weekend.gpx');
|
||||
});
|
||||
|
||||
it('TOUR-FILE-READ-007: a file without a line gives no track', async () => {
|
||||
const empty = '<gpx version="1.1"><wpt lat="47" lon="11"><name>Pin</name></wpt></gpx>';
|
||||
expect(await files.read(await tripId(), Buffer.from(empty, 'utf8'), 'pins.gpx')).toEqual({
|
||||
tracks: [],
|
||||
whole: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('TOUR-FILE-READ-008: a track longer than one import takes is named but carries no text', async () => {
|
||||
const points = Array.from(
|
||||
{ length: 20_000 },
|
||||
(_, i) =>
|
||||
`<trkpt lat="${47 + i / 100_000}" lon="11.1"><ele>800.123456</ele><time>2026-10-17T08:00:00Z</time></trkpt>`,
|
||||
).join('');
|
||||
const big = `<gpx version="1.1"><trk><name>Long</name><trkseg>${points}</trkseg></trk></gpx>`;
|
||||
expect(big.length).toBeGreaterThan(TOUR_GPX_TEXT_MAX);
|
||||
const read = await files.read(await tripId(), Buffer.from(big, 'utf8'), 'long.gpx');
|
||||
expect(read.tracks[0]).toMatchObject({ name: 'Long', gpx: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* P6: the pure half of a stop taken into a saved tour or out of it: where it
|
||||
* goes, what update_tour is handed for a hike, which stops may be taken out,
|
||||
* and the key the save gave the new stop.
|
||||
*/
|
||||
import {
|
||||
afterPointOf,
|
||||
hikePointsWithStop,
|
||||
hikePointsWithout,
|
||||
keyBefore,
|
||||
keyNear,
|
||||
removalRefusal,
|
||||
stopPointsOf,
|
||||
} from '../../../src/nest/tours/tour-stops.helpers';
|
||||
import type { GeometryPoint, TourWaypoint } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// TOUR-STOPS-HELPERS-001 to TOUR-STOPS-HELPERS-008
|
||||
|
||||
const wp = (key: string, lat: number, role: TourWaypoint['role'], over: Partial<TourWaypoint> = {}): TourWaypoint => ({
|
||||
key,
|
||||
lat,
|
||||
lng: 11.0,
|
||||
role,
|
||||
sequence: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
const START = wp('start1', 47.0, 'start', { name: 'Parking' });
|
||||
const HUT = wp('hut111', 47.01, 'via', { name: 'Hut', poi_category: 'shelter', stage_end: true });
|
||||
const END = wp('summit', 47.02, 'end', { name: 'Summit' });
|
||||
const LINE: GeometryPoint[] = [
|
||||
[47.0, 11.0],
|
||||
[47.01, 11.0],
|
||||
[47.02, 11.0],
|
||||
];
|
||||
|
||||
describe('where a stop goes', () => {
|
||||
it('TOUR-STOPS-HELPERS-001: after the last point before it along the line, or after the point named', () => {
|
||||
const points = stopPointsOf([START, HUT, END]);
|
||||
const between = afterPointOf(points, LINE, { lat: 47.015, lng: 11.0 }, undefined);
|
||||
expect(typeof between === 'string' ? between : between.id).toBe('hut111');
|
||||
const named = afterPointOf(points, LINE, { lat: 47.015, lng: 11.0 }, 'start1');
|
||||
expect(typeof named === 'string' ? named : named.id).toBe('start1');
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-HELPERS-002: refuses a key the tour does not have, a shaping point and a leg drawn by hand', () => {
|
||||
const free = {
|
||||
...HUT,
|
||||
fixed_leg: {
|
||||
kind: 'free' as const,
|
||||
geometry: [[47.01, 11.0] as [number, number], [47.02, 11.0] as [number, number]],
|
||||
},
|
||||
};
|
||||
const shaping = wp('shape1', 47.005, 'via', { shaping: true });
|
||||
expect(afterPointOf(stopPointsOf([START, HUT, END]), LINE, START, 'nope00')).toMatch(
|
||||
/no point with the key nope00/,
|
||||
);
|
||||
expect(afterPointOf(stopPointsOf([START, shaping, END]), LINE, START, 'shape1')).toMatch(/only shapes the line/);
|
||||
expect(afterPointOf(stopPointsOf([START, free, END]), LINE, START, 'hut111')).toMatch(/drawn by hand/);
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-HELPERS-003: a loop takes nothing after its way back', () => {
|
||||
const back = wp('back11', 47.0, 'end');
|
||||
expect(afterPointOf(stopPointsOf([START, HUT, back]), LINE, HUT, 'back11')).toMatch(/way back of a loop/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a hike handed to update_tour', () => {
|
||||
it('TOUR-STOPS-HELPERS-004: keeps names, kinds and stage ends, the stop in its place', () => {
|
||||
const points = stopPointsOf([START, HUT, END]);
|
||||
const next = hikePointsWithStop(points, 'hut111', {
|
||||
lat: 47.015,
|
||||
lng: 11.0,
|
||||
name: 'Spring',
|
||||
poi_category: 'drinking_water',
|
||||
});
|
||||
expect(next).toEqual([
|
||||
{ lat: 47.0, lng: 11.0, name: 'Parking' },
|
||||
{ lat: 47.01, lng: 11.0, name: 'Hut', poi_category: 'shelter', stage_end: true },
|
||||
{ lat: 47.015, lng: 11.0, name: 'Spring', poi_category: 'drinking_water' },
|
||||
{ lat: 47.02, lng: 11.0, name: 'Summit' },
|
||||
]);
|
||||
expect(hikePointsWithStop(points, 'nope00', { lat: 47.015, lng: 11.0 })).toBeNull();
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-HELPERS-005: takes the stop out and leaves the rest as it was', () => {
|
||||
expect(hikePointsWithout(stopPointsOf([START, HUT, END]), 'hut111')).toEqual([
|
||||
{ lat: 47.0, lng: 11.0, name: 'Parking' },
|
||||
{ lat: 47.02, lng: 11.0, name: 'Summit' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('which stop may be taken out', () => {
|
||||
it('TOUR-STOPS-HELPERS-006: never the start, the way back of a loop, or a point of a tour of two', () => {
|
||||
const back = wp('back11', 47.0, 'end');
|
||||
expect(removalRefusal(stopPointsOf([START, HUT, END]), 'hut111')).toBeNull();
|
||||
expect(removalRefusal(stopPointsOf([START, HUT, END]), 'summit')).toBeNull();
|
||||
expect(removalRefusal(stopPointsOf([START, HUT, END]), 'start1')).toMatch(/start/);
|
||||
expect(removalRefusal(stopPointsOf([START, END]), 'summit')).toMatch(/at least its start and its end/);
|
||||
expect(removalRefusal(stopPointsOf([START, HUT, back]), 'back11')).toMatch(/way back of a loop/);
|
||||
expect(removalRefusal(stopPointsOf([START, HUT, END]), 'nope00')).toMatch(/no point with the key/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the keys around a stop', () => {
|
||||
it('TOUR-STOPS-HELPERS-007: the key the save gave the stop is the nearest keyed point within a few metres', () => {
|
||||
expect(keyNear([START, HUT, END], { lat: 47.01, lng: 11.0001 })).toBe('hut111');
|
||||
expect(keyNear([START, HUT, END], { lat: 47.03, lng: 11.0 })).toBeNull();
|
||||
expect(keyNear([{ ...HUT, key: undefined }], { lat: 47.01, lng: 11.0 })).toBeNull();
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-HELPERS-008: the point a stop followed, by its key; none before the start', () => {
|
||||
const points = stopPointsOf([START, HUT, END]);
|
||||
expect(keyBefore(points, 'hut111')).toBe('start1');
|
||||
expect(keyBefore(points, 'start1')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* P6: TourStopsMcp over a snapshot test db: a found place taken into a saved
|
||||
* hike as a stop with insert_tour_stop and taken out again with
|
||||
* remove_tour_stop, through the write update_tour saves with. The hike is
|
||||
* created through create_hike; its way is planned by a stub of
|
||||
* HikeRoutingService, as in the hike suite. The gates are the tours': trip
|
||||
* access, then place_edit, on the Tours addon.
|
||||
*/
|
||||
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../../src/db/entities/Tours.entity';
|
||||
import type { McpContext } from '../../../src/nest-mcp';
|
||||
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
|
||||
import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
|
||||
import { HikePlanMcp } from '../../../src/nest/tours/hike-plan.mcp';
|
||||
import { HikeRoutingService, type PlannedHike } from '../../../src/nest/tours/hike-routing.service';
|
||||
import { TourPoiSearchService } from '../../../src/nest/tours/tour-poi-search.service';
|
||||
import { TourStopsMcp } from '../../../src/nest/tours/tour-stops.mcp';
|
||||
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
||||
import { WeatherService } from '../../../src/nest/weather/weather.service';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { countRows, findRow } from '../../helpers/factories/rows';
|
||||
import { makeTrip } from '../../helpers/factories/trips';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import type { GeometryPoint, HikePlanRequest } from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// TOUR-STOPS-MCP-001 to TOUR-STOPS-MCP-005
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
const plan = vi.fn<HikeRoutingService['plan']>();
|
||||
let t: TestModule;
|
||||
let stops: TourStopsMcp;
|
||||
let hikes: HikePlanMcp;
|
||||
const orm = () => t.get(MikroORM);
|
||||
|
||||
const LINE: GeometryPoint[] = [
|
||||
[47.5, 11.1, 800],
|
||||
[47.505, 11.1, 900],
|
||||
[47.51, 11.1, 1000],
|
||||
];
|
||||
|
||||
/** The stub's plan: the way above under the limit asked for, whatever the points. */
|
||||
function routed(request: HikePlanRequest): PlannedHike {
|
||||
return {
|
||||
plan: {
|
||||
kind: 'routed',
|
||||
max_hiking_difficulty: request.max_hiking_difficulty ?? 2,
|
||||
hiking_preferences: request.hiking_preferences ?? null,
|
||||
distance_m: 1112,
|
||||
ascent_m: 200,
|
||||
descent_m: 0,
|
||||
duration_minutes: 52,
|
||||
duration_basis: 'din33466',
|
||||
has_heights: true,
|
||||
line: LINE,
|
||||
profile: [
|
||||
[0, 800],
|
||||
[1112, 1000],
|
||||
],
|
||||
grades: null,
|
||||
},
|
||||
line: LINE,
|
||||
snapped: request.waypoints.map(() => 5),
|
||||
legs: request.waypoints.slice(1).map(() => 300),
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({
|
||||
db,
|
||||
imports: [ToursModule],
|
||||
overrides: [
|
||||
{ provide: HikeRoutingService, useValue: { plan } },
|
||||
{ provide: TourPoiSearchService, useValue: { search: vi.fn() } },
|
||||
{ provide: WeatherService, useValue: { getDetailed: vi.fn() } },
|
||||
],
|
||||
});
|
||||
stops = t.get(TourStopsMcp);
|
||||
hikes = t.get(HikePlanMcp);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(db);
|
||||
plan.mockReset().mockImplementation(async (_userId, _tripId, request) => routed(request));
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
const ctx = (userId: number) => ({ userId }) as McpContext;
|
||||
|
||||
function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) {
|
||||
const text = result.content[0]?.text ?? '';
|
||||
return result.isError ? { error: text } : JSON.parse(text);
|
||||
}
|
||||
|
||||
const WAYPOINTS = [
|
||||
{ lat: 47.5, lng: 11.1, name: 'Car park' },
|
||||
{ lat: 47.505, lng: 11.1, name: 'Alm' },
|
||||
{ lat: 47.51, lng: 11.1, name: 'Summit' },
|
||||
];
|
||||
const SPRING = { lat: 47.507, lng: 11.1, name: 'Spring', poi_category: 'drinking_water' as const };
|
||||
|
||||
async function fixture() {
|
||||
const { user } = await makeUser(orm());
|
||||
const { user: outsider } = await makeUser(orm());
|
||||
const trip = await makeTrip(orm(), user.id);
|
||||
const hike = read(
|
||||
await hikes.createHike({ tripId: trip.id, name: 'Herzogstand', waypoints: WAYPOINTS }, ctx(user.id)),
|
||||
);
|
||||
return { user, outsider, trip, placeId: hike.tour.place_id as number };
|
||||
}
|
||||
|
||||
describe('TourStopsMcp', () => {
|
||||
it('TOUR-STOPS-MCP-001: both tools write on the Tours addon, in the trips group', async () => {
|
||||
const options = (method: string) => {
|
||||
const entry = getEntry(TourStopsMcp as unknown as ClassRef, method);
|
||||
if (!entry || entry.kind !== 'tool') throw new Error(`${method} is no MCP tool`);
|
||||
return entry.options;
|
||||
};
|
||||
const tools = ['insertTourStop', 'removeTourStop'].map(options);
|
||||
expect(tools.map((tool) => [tool.name, tool.annotations?.readOnlyHint, tool.access])).toEqual([
|
||||
['insert_tour_stop', false, { group: 'trips', mode: 'write' }],
|
||||
['remove_tour_stop', false, { group: 'trips', mode: 'write' }],
|
||||
]);
|
||||
expect(tools[0].description).toContain('after_key');
|
||||
const isAddonEnabled = vi.fn(async (id: string) => id === 'tours');
|
||||
const gated = await Promise.all(tools.map((tool) => tool.when?.(ctx(1), { addons: { isAddonEnabled } })));
|
||||
expect(gated).toEqual([true, true]);
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-MCP-002: takes a place in after the last point before it, plans the hike anew and raises its revision', async () => {
|
||||
const f = await fixture();
|
||||
plan.mockClear();
|
||||
const answer = read(
|
||||
await stops.insertTourStop(
|
||||
{ tripId: f.trip.id, placeId: f.placeId, expected_revision: 1, stop: SPRING },
|
||||
ctx(f.user.id),
|
||||
),
|
||||
);
|
||||
expect(plan).toHaveBeenCalledTimes(1);
|
||||
expect(plan.mock.calls[0]?.[2].waypoints.map((point) => point.lat)).toEqual([47.5, 47.505, 47.507, 47.51]);
|
||||
expect(answer.tour).toMatchObject({ place_id: f.placeId, tour_type: 'hike', revision: 2 });
|
||||
expect(answer.stop).toMatchObject({ name: 'Spring', after_name: 'Alm' });
|
||||
expect(typeof answer.stop.key).toBe('string');
|
||||
expect(await countRows(orm(), TourWaypoints, { place: f.placeId })).toBe(4);
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-MCP-003: takes the stop out again by its key, back to the points it had', async () => {
|
||||
const f = await fixture();
|
||||
const own = ctx(f.user.id);
|
||||
const inserted = read(
|
||||
await stops.insertTourStop({ tripId: f.trip.id, placeId: f.placeId, expected_revision: 1, stop: SPRING }, own),
|
||||
);
|
||||
const removed = read(
|
||||
await stops.removeTourStop(
|
||||
{ tripId: f.trip.id, placeId: f.placeId, expected_revision: 2, key: inserted.stop.key },
|
||||
own,
|
||||
),
|
||||
);
|
||||
expect(removed.tour.revision).toBe(3);
|
||||
expect(removed.removed).toMatchObject({ key: inserted.stop.key, name: 'Spring', lat: 47.507 });
|
||||
expect(await countRows(orm(), TourWaypoints, { place: f.placeId })).toBe(3);
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-MCP-004: a stale revision is refused and the hike stays as it was', async () => {
|
||||
const f = await fixture();
|
||||
await expect(
|
||||
stops.insertTourStop(
|
||||
{ tripId: f.trip.id, placeId: f.placeId, expected_revision: 4, stop: SPRING },
|
||||
ctx(f.user.id),
|
||||
),
|
||||
).rejects.toThrow('This tour was changed in the meantime');
|
||||
expect((await findRow(orm(), Tours, { place: f.placeId }))?.revision).toBe(1);
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-MCP-005: an outsider is refused, and a member without place_edit, as REST refuses them', async () => {
|
||||
const f = await fixture();
|
||||
const request = { tripId: f.trip.id, placeId: f.placeId, expected_revision: 1, stop: SPRING };
|
||||
expect(read(await stops.insertTourStop(request, ctx(f.outsider.id)))).toEqual({
|
||||
error: 'Trip not found or access denied.',
|
||||
});
|
||||
const denied = vi.spyOn(t.get(McpToolGuardsService), 'hasTripPermission').mockResolvedValue(false);
|
||||
expect((await stops.insertTourStop(request, ctx(f.user.id))).isError).toBe(true);
|
||||
const removal = { tripId: f.trip.id, placeId: f.placeId, expected_revision: 1, key: 'abcdef' };
|
||||
expect((await stops.removeTourStop(removal, ctx(f.user.id))).isError).toBe(true);
|
||||
expect(denied).toHaveBeenCalledWith('place_edit', f.trip.id, f.user.id);
|
||||
denied.mockRestore();
|
||||
expect(await countRows(orm(), TourWaypoints, { place: f.placeId })).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* P6: TourStopsService takes a stop into a saved hike or city walk, and out
|
||||
* again, through the paths update_tour and save_tour_change save with. Those
|
||||
* are stubs here (their own suites run them against a db): this suite pins
|
||||
* what they are handed, the refusals before them and the answer.
|
||||
*/
|
||||
import { DomainError } from '../../../src/nest/common/domain-error';
|
||||
import { HikePlanService } from '../../../src/nest/tours/hike-plan.service';
|
||||
import { proposalIdOf } from '../../../src/nest/tours/tour-change-proposal';
|
||||
import { TourChangeSaveService } from '../../../src/nest/tours/tour-change-save.service';
|
||||
import { TourStopsService } from '../../../src/nest/tours/tour-stops.service';
|
||||
import { ToursService } from '../../../src/nest/tours/tours.service';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { TourDetailResponse, TourWaypoint } from '@trek/shared';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// TOUR-STOPS-SVC-001 to TOUR-STOPS-SVC-009
|
||||
|
||||
const wp = (key: string, lat: number, role: TourWaypoint['role'], name: string): TourWaypoint => ({
|
||||
key,
|
||||
lat,
|
||||
lng: 11.0,
|
||||
role,
|
||||
sequence: 0,
|
||||
name,
|
||||
});
|
||||
const START = wp('start1', 47.0, 'start', 'Parking');
|
||||
const HUT = wp('hut111', 47.01, 'via', 'Hut');
|
||||
const END = wp('summit', 47.02, 'end', 'Summit');
|
||||
const SPRING = wp('spring', 47.015, 'via', 'Spring');
|
||||
|
||||
function detail(tourType: string, waypoints: TourWaypoint[], revision = 3, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
tour: {
|
||||
place_id: 64,
|
||||
name: 'Herzogstand',
|
||||
tour_type: tourType,
|
||||
revision,
|
||||
distance: 8.2,
|
||||
duration: 190,
|
||||
has_waypoints: true,
|
||||
...extra,
|
||||
},
|
||||
waypoints,
|
||||
} as unknown as TourDetailResponse;
|
||||
}
|
||||
|
||||
const getTour = vi.fn<(tripId: string, placeId: string) => Promise<TourDetailResponse>>();
|
||||
const savedLine = vi.fn(async () => [
|
||||
[47.0, 11.0],
|
||||
[47.01, 11.0],
|
||||
[47.02, 11.0],
|
||||
]);
|
||||
const update = vi.fn();
|
||||
const save = vi.fn();
|
||||
let service: TourStopsService;
|
||||
|
||||
beforeEach(async () => {
|
||||
getTour.mockReset();
|
||||
update.mockReset().mockResolvedValue({ ...detail('hike', [START, HUT, SPRING, END], 4), route: { planned: true } });
|
||||
save.mockReset().mockResolvedValue({ saved: true });
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
TourStopsService,
|
||||
{ provide: ToursService, useValue: { getTour, savedLine } },
|
||||
{ provide: HikePlanService, useValue: { update } },
|
||||
{ provide: TourChangeSaveService, useValue: { save } },
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(TourStopsService);
|
||||
});
|
||||
|
||||
const stop = { lat: 47.015, lng: 11.0, name: 'Spring', poi_category: 'drinking_water' as const };
|
||||
|
||||
describe('a stop taken into a hike', () => {
|
||||
it('TOUR-STOPS-SVC-001: goes after the last point before it, and the hike is planned through the new points', async () => {
|
||||
getTour.mockResolvedValue(detail('hike', [START, HUT, END]));
|
||||
const answer = await service.insert(7, 5, { placeId: 64, expected_revision: 3, stop });
|
||||
expect(update).toHaveBeenCalledWith(7, 5, {
|
||||
placeId: 64,
|
||||
expected_revision: 3,
|
||||
waypoints: [
|
||||
{ lat: 47.0, lng: 11.0, name: 'Parking' },
|
||||
{ lat: 47.01, lng: 11.0, name: 'Hut' },
|
||||
{ lat: 47.015, lng: 11.0, name: 'Spring', poi_category: 'drinking_water' },
|
||||
{ lat: 47.02, lng: 11.0, name: 'Summit' },
|
||||
],
|
||||
});
|
||||
expect(answer.tour).toMatchObject({ place_id: 64, tour_type: 'hike', revision: 4 });
|
||||
expect(answer.stop).toMatchObject({ key: 'spring', after_key: 'hut111', after_name: 'Hut' });
|
||||
expect(answer.before).toEqual({ distance_km: 8.2, minutes: 190 });
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-SVC-002: is refused against a revision the hike moved past, before anything is planned', async () => {
|
||||
getTour.mockResolvedValue(detail('hike', [START, HUT, END], 5));
|
||||
await expect(service.insert(7, 5, { placeId: 64, expected_revision: 3, stop })).rejects.toThrow(
|
||||
/changed in the meantime/,
|
||||
);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-SVC-003: refuses another tour type and a recorded track without points of its own', async () => {
|
||||
getTour.mockResolvedValue(detail('bike', [START, END]));
|
||||
await expect(service.insert(7, 5, { placeId: 64, expected_revision: 3, stop })).rejects.toThrow(
|
||||
/hike or a city walk/,
|
||||
);
|
||||
getTour.mockResolvedValue(detail('hike', [START, END], 3, { has_waypoints: false }));
|
||||
await expect(service.insert(7, 5, { placeId: 64, expected_revision: 3, stop })).rejects.toThrow(/recorded track/);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-SVC-004: names the reason when the point named takes no stop', async () => {
|
||||
getTour.mockResolvedValue(detail('hike', [START, HUT, END]));
|
||||
const refused = service.insert(7, 5, { placeId: 64, expected_revision: 3, stop, after_key: 'nope00' });
|
||||
await expect(refused).rejects.toBeInstanceOf(DomainError);
|
||||
await expect(refused).rejects.toThrow(/no point with the key/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a stop taken into a city walk', () => {
|
||||
it('TOUR-STOPS-SVC-005: is saved as one add after the station, with the proposal id a save checks', async () => {
|
||||
getTour
|
||||
.mockResolvedValueOnce(detail('walk', [START, HUT, END]))
|
||||
.mockResolvedValueOnce(detail('walk', [START, HUT, SPRING, END], 4));
|
||||
const answer = await service.insert(7, 5, {
|
||||
placeId: 64,
|
||||
expected_revision: 3,
|
||||
stop: { lat: 47.015, lng: 11.0, name: 'Spring', opening_hours: 'Mo-Su 09:00-18:00' },
|
||||
after_key: 'hut111',
|
||||
});
|
||||
const changes = [
|
||||
{
|
||||
op: 'add',
|
||||
after_key: 'hut111',
|
||||
station: { lat: 47.015, lng: 11.0, name: 'Spring', opening_hours: 'Mo-Su 09:00-18:00' },
|
||||
},
|
||||
];
|
||||
expect(save).toHaveBeenCalledWith(5, 64, 7, {
|
||||
changes,
|
||||
expected_revision: 3,
|
||||
proposal_id: proposalIdOf({ tripId: 5, placeId: 64, revision: 3, changes: changes as never }),
|
||||
confirmed: true,
|
||||
});
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(answer.tour).toMatchObject({ tour_type: 'walk', revision: 4 });
|
||||
expect(answer.stop.key).toBe('spring');
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-SVC-006: refuses a walk whose stations have no keys yet', async () => {
|
||||
getTour.mockResolvedValue(detail('walk', [START, { ...HUT, key: undefined }, END]));
|
||||
const refused = service.insert(7, 5, { placeId: 64, expected_revision: 3, stop });
|
||||
await expect(refused).rejects.toThrow(/no keys yet/);
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a stop taken out', () => {
|
||||
it('TOUR-STOPS-SVC-007: plans a hike through the points left and names what it took out', async () => {
|
||||
getTour.mockResolvedValue(detail('hike', [START, HUT, SPRING, END], 4));
|
||||
update.mockResolvedValue({ ...detail('hike', [START, HUT, END], 5), route: { planned: true } });
|
||||
const answer = await service.remove(7, 5, { placeId: 64, expected_revision: 4, key: 'spring' });
|
||||
expect(update.mock.calls[0]?.[2]).toMatchObject({ placeId: 64, expected_revision: 4 });
|
||||
expect(update.mock.calls[0]?.[2].waypoints).toHaveLength(3);
|
||||
expect(answer.removed).toEqual({
|
||||
key: 'spring',
|
||||
lat: 47.015,
|
||||
lng: 11.0,
|
||||
name: 'Spring',
|
||||
poi_category: null,
|
||||
after_key: 'hut111',
|
||||
});
|
||||
expect(answer.tour.revision).toBe(5);
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-SVC-008: removes a station of a city walk through the save of one change', async () => {
|
||||
getTour
|
||||
.mockResolvedValueOnce(detail('walk', [START, HUT, SPRING, END], 4))
|
||||
.mockResolvedValueOnce(detail('walk', [START, HUT, END], 5));
|
||||
await service.remove(7, 5, { placeId: 64, expected_revision: 4, key: 'spring' });
|
||||
expect(save.mock.calls[0]?.[3]).toMatchObject({ changes: [{ op: 'remove', key: 'spring' }], expected_revision: 4 });
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-SVC-009: keeps the start of the tour', async () => {
|
||||
getTour.mockResolvedValue(detail('hike', [START, HUT, END]));
|
||||
await expect(service.remove(7, 5, { placeId: 64, expected_revision: 3, key: 'start1' })).rejects.toThrow(/start/);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,10 +9,12 @@ import {
|
||||
assistantChatRequestSchema,
|
||||
assistantDecisionsRequestSchema,
|
||||
assistantFoundPlaceSchema,
|
||||
assistantInsertFoundPlaceRequestSchema,
|
||||
assistantMessageSchema,
|
||||
assistantPlacesSchema,
|
||||
assistantStopRequestSchema,
|
||||
assistantWeatherSchema,
|
||||
isAssistantTourFile,
|
||||
UI_STREAM_DONE,
|
||||
uiStreamChunkSchema,
|
||||
type UiStreamChunk,
|
||||
@@ -263,6 +265,32 @@ describe('assistantAddFoundPlaceRequestSchema', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistantInsertFoundPlaceRequestSchema', () => {
|
||||
const parse = (body: Record<string, unknown>) =>
|
||||
assistantInsertFoundPlaceRequestSchema.safeParse({ ...ctx, ...body });
|
||||
|
||||
it('names a found place by its ref and the saved tour it goes into', () => {
|
||||
expect(parse({ ref: 'toolu_01:2', tourId: 64 }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a bad tour, a day instead of a tour and anything about the stop itself', () => {
|
||||
expect(parse({ ref: 'a:1', tourId: 0 }).success).toBe(false);
|
||||
expect(parse({ ref: 'a:1', dayId: 3 }).success).toBe(false);
|
||||
expect(parse({ ref: 'a:1', tourId: 64, after_key: 'abc123' }).success).toBe(false);
|
||||
expect(assistantInsertFoundPlaceRequestSchema.safeParse({ ref: 'a:1', tourId: 64 }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('route files attached in the chat', () => {
|
||||
it('tells a GPX, KML or KMZ file by its name, whatever its case', () => {
|
||||
expect(isAssistantTourFile('Herzogstand.GPX')).toBe(true);
|
||||
expect(isAssistantTourFile('map.kml')).toBe(true);
|
||||
expect(isAssistantTourFile('map.kmz')).toBe(true);
|
||||
expect(isAssistantTourFile('ticket.pdf')).toBe(false);
|
||||
expect(isAssistantTourFile('gpx')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the effect of a change', () => {
|
||||
const preview = {
|
||||
toolCallId: 'c1',
|
||||
|
||||
@@ -68,6 +68,8 @@ export const ASSISTANT_ERROR_CODES = [
|
||||
'empty_answer',
|
||||
'offline',
|
||||
'no_bookings_found',
|
||||
// An attached GPX, KML or KMZ file without a single track or route to take in as a tour.
|
||||
'no_tracks_found',
|
||||
'import_unavailable',
|
||||
// A found place was suggested while the thread still holds changes nobody decided on.
|
||||
'decide_first',
|
||||
@@ -291,6 +293,30 @@ export const assistantAddFoundPlaceRequestSchema = assistantClientCtxSchema
|
||||
.strict();
|
||||
export type AssistantAddFoundPlaceRequest = z.infer<typeof assistantAddFoundPlaceRequestSchema>;
|
||||
|
||||
/**
|
||||
* POST /threads/:threadId/found-places/tour: "+ Tour" on a place a search
|
||||
* found, while a saved tour is open in the planner. Held like "+ Day 3": the
|
||||
* place comes from the stored search results, the tour must be one of this
|
||||
* trip's, and the server picks where along it the stop goes.
|
||||
*/
|
||||
export const assistantInsertFoundPlaceRequestSchema = assistantClientCtxSchema
|
||||
.extend({
|
||||
ref: z.string().min(1).max(80),
|
||||
tourId: idSchema,
|
||||
})
|
||||
.strict();
|
||||
export type AssistantInsertFoundPlaceRequest = z.infer<typeof assistantInsertFoundPlaceRequestSchema>;
|
||||
|
||||
/** The route files the chat takes in as tours (P7), beside the booking files. */
|
||||
export const ASSISTANT_TOUR_FILE_EXTENSIONS = ['.gpx', '.kml', '.kmz'] as const;
|
||||
|
||||
/** Whether a file the user attached is a route file, by its name. */
|
||||
export function isAssistantTourFile(fileName: string): boolean {
|
||||
const dot = fileName.lastIndexOf('.');
|
||||
const ext = dot < 0 ? '' : fileName.slice(dot).toLowerCase();
|
||||
return (ASSISTANT_TOUR_FILE_EXTENSIONS as readonly string[]).includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /threads/:threadId/proposals: take a proposal an answer showed as a
|
||||
* card ("Plan this"). `toolCallId` names the read whose card it was; the
|
||||
|
||||
@@ -27,6 +27,9 @@ export const TOUR_VERB_KEYS = [
|
||||
// A tour pointed at its trip's packing list, or let go of it.
|
||||
'linkPacking',
|
||||
'unlinkPacking',
|
||||
// A found place taken into a saved tour as a stop, or taken out again (P6).
|
||||
'addTourStop',
|
||||
'removeTourStop',
|
||||
] as const;
|
||||
/** 'tour' is a core entity already. */
|
||||
export const TOUR_APPLIED_ENTITIES = [] as const;
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
bookTypesetFontSchema,
|
||||
} from './book-op-enums';
|
||||
import { bookElementIdSchema, bookGridIdSchema, bookSpreadIdSchema } from './book-op-ids';
|
||||
import { bookPaintInSchema } from './book-paint.schema';
|
||||
import {
|
||||
MAX_BOOK_COUNTRIES,
|
||||
bookBadgeElementSchema,
|
||||
@@ -112,8 +111,6 @@ const SHAPE_PROPS = {
|
||||
...COMMON,
|
||||
fill: shape.fill.unwrap().optional(),
|
||||
gradient: bookShapeGradientSchema.optional(),
|
||||
/** Overrides fill and gradient when given; null takes the fill away. A plain fill or gradient clears it. */
|
||||
paint: bookPaintInSchema.nullable().optional(),
|
||||
stroke: shape.stroke.unwrap().optional(),
|
||||
strokeWidth: z.number().finite().min(0).max(20).optional(),
|
||||
strokeStyle: bookStrokeStyleSchema.optional(),
|
||||
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
unlockedElement,
|
||||
type BookOpsContext,
|
||||
} from './book-ops-core';
|
||||
import { withShapePaint } from './book-paint';
|
||||
import type { BookPaint } from './book-paint.schema';
|
||||
import {
|
||||
bookBadgeValue,
|
||||
bookCountryMark,
|
||||
@@ -35,7 +33,6 @@ import type {
|
||||
BookFrame,
|
||||
BookPageSetup,
|
||||
BookPhotoElement,
|
||||
BookShapeElement,
|
||||
BookSpread,
|
||||
BookTextElement,
|
||||
} from './book.schema';
|
||||
@@ -147,24 +144,10 @@ function withProps(el: BookElement, props: Record<string, unknown>): BookElement
|
||||
const next: Record<string, unknown> = { ...el };
|
||||
for (const key of keys) {
|
||||
const value = props[key];
|
||||
if (value === undefined || key === 'paint') continue;
|
||||
if (value === undefined) continue;
|
||||
next[key] = (key === 'radius' || key === 'strokeWidth') && typeof value === 'number' ? round2(value) : value;
|
||||
}
|
||||
const made = next as unknown as BookElement;
|
||||
return made.kind === 'shape' ? withPaintProps(made, props) : made;
|
||||
}
|
||||
|
||||
/**
|
||||
* A shape's paint, kept in step with its fill: a paint given is written
|
||||
* through withShapePaint, and a plain fill or fade given without one replaces
|
||||
* the paint the shape had, since the newer instruction is the one meant.
|
||||
*/
|
||||
function withPaintProps(el: BookShapeElement, props: Record<string, unknown>): BookShapeElement {
|
||||
const paint = props.paint as BookPaint | null | undefined;
|
||||
if (paint !== undefined) return withShapePaint(el, paint);
|
||||
if (props.fill === undefined && props.gradient === undefined) return el;
|
||||
const { paint: _replaced, ...rest } = el;
|
||||
return rest;
|
||||
return next as unknown as BookElement;
|
||||
}
|
||||
|
||||
// ── add ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
bookPhotoMaskSchema,
|
||||
} from './book-op-enums';
|
||||
import { bookElementIdSchema, bookGridIdSchema, bookSpreadIdSchema } from './book-op-ids';
|
||||
import { bookPaintInSchema } from './book-paint.schema';
|
||||
import {
|
||||
MAX_BOOK_TITLE,
|
||||
MAX_SPREADS,
|
||||
@@ -189,14 +188,8 @@ export const bookPageOpSchema = z.discriminatedUnion('op', [
|
||||
elementIds: z.array(bookElementIdSchema).min(1).max(BOOK_OP_LIMITS.removePerOp),
|
||||
park: z.boolean().default(true),
|
||||
}),
|
||||
/** The page colour or paint; null is the paper. Exactly one of the two. */
|
||||
z
|
||||
.strictObject({
|
||||
op: z.literal('background'),
|
||||
color: bookSpreadSchema.shape.background.unwrap().optional(),
|
||||
paint: bookPaintInSchema.nullable().optional(),
|
||||
})
|
||||
.refine((v) => (v.color === undefined) !== (v.paint === undefined), { message: 'give the color or the paint' }),
|
||||
/** The page colour; null is the paper. */
|
||||
z.strictObject({ op: z.literal('background'), color: bookSpreadSchema.shape.background.unwrap() }),
|
||||
...BOOK_ELEMENT_OP_SCHEMAS,
|
||||
]);
|
||||
export type BookPageOp = z.infer<typeof bookPageOpSchema>;
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
} from './book-ops-core';
|
||||
import { applyElementOp, autoRelayout } from './book-ops-elements';
|
||||
import { BOOK_OP_LIMITS, bookPageOpSchema, type BookPageOp } from './book-ops.schema';
|
||||
import { withSpreadPaint } from './book-paint';
|
||||
import { applySavedBookLayout, bookLayoutRole } from './book-saved-layouts';
|
||||
import {
|
||||
MAX_SPREAD_ELEMENTS,
|
||||
@@ -119,10 +118,7 @@ function applyOne(spread: BookSpread, op: BookPageOp, env: Env): BookSpread {
|
||||
case 'remove':
|
||||
return opRemove(spread, op, env);
|
||||
case 'background':
|
||||
return withSpreadPaint(
|
||||
spread,
|
||||
op.paint !== undefined ? op.paint : op.color ? { type: 'solid', color: op.color } : null,
|
||||
);
|
||||
return { ...spread, background: op.color };
|
||||
default:
|
||||
return applyElementOp(spread, op, env);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user