mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c3cd63e34 | |||
| f5ad78d988 |
@@ -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,
|
||||
|
||||
@@ -127,6 +127,10 @@ function namedFirst(places: readonly AssistantFoundPlace[], text: string): Assis
|
||||
const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([
|
||||
'propose_hike',
|
||||
'create_hike',
|
||||
'propose_tour',
|
||||
'create_tour',
|
||||
'create_paddle_tour',
|
||||
'create_ski_tour',
|
||||
'update_tour',
|
||||
'propose_city_walk',
|
||||
'create_city_walk',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from '../../../../i18n';
|
||||
import { useSettingsStore } from '../../../../store/settingsStore';
|
||||
import { formatClockTime, formatDate, formatMoney } from '../../../../utils/formatters';
|
||||
import { formatDistance, formatElevation } from '../../../../utils/units';
|
||||
import { formatRunPace, runPaceUnitText } from '../../../Tours/run/runPace';
|
||||
|
||||
/**
|
||||
* How a card writes its numbers, the way the rest of TREK does for this
|
||||
@@ -13,7 +14,7 @@ import { formatDistance, formatElevation } from '../../../../utils/units';
|
||||
* units. A card hands these to its words as `{value}`, never a raw number.
|
||||
*/
|
||||
export function useCardFormat() {
|
||||
const { locale } = useTranslation();
|
||||
const { locale, t } = useTranslation();
|
||||
const twelveHour = useSettingsStore((s) => s.settings.time_format === '12h');
|
||||
const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
|
||||
return useMemo(() => {
|
||||
@@ -37,6 +38,8 @@ export function useCardFormat() {
|
||||
return m === 0 ? hours.format(h) : `${hours.format(h)} ${mins.format(m)}`;
|
||||
},
|
||||
count: (value: number) => number.format(value),
|
||||
/** "5:30 min/km" from seconds per kilometre, per mile with distances in miles. */
|
||||
pace: (secondsPerKm: number) => `${formatRunPace(secondsPerKm, unit)} ${runPaceUnitText(unit, t)}`,
|
||||
};
|
||||
}, [locale, twelveHour, unit]);
|
||||
}, [locale, twelveHour, unit, t]);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* FE-AI-TOUR-021 to FE-AI-TOUR-028, FE-AI-TOUR-037 to FE-AI-TOUR-042
|
||||
* FE-AI-TOUR-021 to FE-AI-TOUR-028, FE-AI-TOUR-037 to FE-AI-TOUR-042, FE-AI-TOUR-061 to FE-AI-TOUR-062
|
||||
*
|
||||
* A route in the chat as the Tours tab shows a hike: its figures and grade,
|
||||
* a profile only where the heights are known, the profile, the stop chips
|
||||
@@ -387,4 +387,44 @@ describe('TourRouteCard', () => {
|
||||
expect(container.querySelectorAll('[data-testid="tour-day"] .bg-danger')).toHaveLength(2);
|
||||
expect(screen.getAllByText('Start by 10:30')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-061: a bike tour names its profile and its riding time, a run its pace, and waits for one it lacks', () => {
|
||||
const bike: Route = {
|
||||
...HIKE,
|
||||
activity: 'bike',
|
||||
routingProfile: 'gravel',
|
||||
minutesBasis: 'router',
|
||||
limit: null,
|
||||
grades: null,
|
||||
};
|
||||
const { container } = renderCard(bike);
|
||||
expect(screen.getByText('Gravel')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('tour-grade')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Plan this' })).toBeEnabled();
|
||||
expect(container.querySelector('[data-field="pace"]')).toBeNull();
|
||||
|
||||
const run: Route = {
|
||||
...bike,
|
||||
activity: 'run',
|
||||
routingProfile: 'trail',
|
||||
minutesBasis: 'pace',
|
||||
paceSecondsPerKm: 330,
|
||||
};
|
||||
const paced = renderCard(run);
|
||||
expect(screen.getByText('Trail running')).toBeInTheDocument();
|
||||
expect(paced.container.querySelector('[data-field="pace"]')).toHaveTextContent('5:30 min/km');
|
||||
|
||||
renderCard({ ...run, paceSecondsPerKm: null, minutes: null });
|
||||
expect(screen.getByText('Tell the assistant your pace to save this run.')).toBeInTheDocument();
|
||||
const buttons = screen.getAllByRole('button', { name: 'Plan this' });
|
||||
expect(buttons[buttons.length - 1]).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-062: a whole paddle plan offers "Plan this"; one with a leg that failed does not', () => {
|
||||
const plan: Route = { ...HIKE, activity: 'kayak', source: 'plan', limit: null, grades: null, schedule: null };
|
||||
renderCard(plan);
|
||||
expect(screen.getByRole('button', { name: 'Plan this' })).toBeInTheDocument();
|
||||
renderCard({ ...plan, name: 'Broken', warnings: [{ kind: 'legs_failed', at: null, count: 1 }] });
|
||||
expect(screen.getAllByRole('button', { name: 'Plan this' })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ import RouteSketch from '../parts/RouteSketch';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { factKeysOf, factValue, percentText, type FactKey } from './tourRouteModel';
|
||||
import { sketchOf } from './tourRouteSketchModel';
|
||||
import { routeName, SAC_WORDS, timeLabelKey, TOUR_TYPE_WORDS } from './tourRouteWords';
|
||||
import { PROFILE_WORDS, routeName, SAC_WORDS, timeLabelKey, TOUR_TYPE_WORDS } from './tourRouteWords';
|
||||
|
||||
/**
|
||||
* The head of a route card: the route drawn small beside its name, the
|
||||
@@ -63,14 +63,16 @@ export function TourRouteFigures({ card }: { card: TourRouteCard }) {
|
||||
/**
|
||||
* The grade of a hike, as the hike's own card shows it: the hardest SAC step
|
||||
* along the way, uncertain when too little of it is graded, with what it
|
||||
* means, the routing limit and where grades come from in its tooltip. Any
|
||||
* other tour says what kind it is instead.
|
||||
* means, the routing limit and where grades come from in its tooltip. A bike
|
||||
* tour or a run names its profile instead, any other tour what kind it is.
|
||||
*/
|
||||
export function TourGrade({ card }: { card: TourRouteCard }) {
|
||||
const { t } = useTranslation();
|
||||
if (card.activity !== 'hike' || !card.grades) {
|
||||
const icon = createElement(tourDayRowIcon(card.activity), { size: 10, strokeWidth: 2.2, 'aria-hidden': true });
|
||||
return <SoftPill icon={icon}>{t(TOUR_TYPE_WORDS[card.activity].labelKey)}</SoftPill>;
|
||||
// A bike tour or a run says what it was planned for: "Gravel", "Trail running".
|
||||
const words = card.routingProfile ? PROFILE_WORDS[card.routingProfile] : TOUR_TYPE_WORDS[card.activity];
|
||||
return <SoftPill icon={icon}>{t(words.labelKey)}</SoftPill>;
|
||||
}
|
||||
const { highest, unknownShare } = card.grades;
|
||||
const uncertain = unknownShare >= 0.5;
|
||||
@@ -138,7 +140,7 @@ export function TourRouteSketch({
|
||||
export function TourRouteFields({ card }: { card: TourRouteCard }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const fields = factKeysOf(card).flatMap((key) => {
|
||||
const fields: Array<{ key: string; label: string; value: string }> = factKeysOf(card).flatMap((key) => {
|
||||
const value = factValue(card, key);
|
||||
if (value === null) return [];
|
||||
// A field's label is short; how the time was reckoned is the figure badges' tooltip.
|
||||
@@ -148,6 +150,10 @@ export function TourRouteFields({ card }: { card: TourRouteCard }) {
|
||||
else text = key === 'distance' ? format.distance(value / 1000) : format.elevation(value);
|
||||
return [{ key, label, value: text }];
|
||||
});
|
||||
// A run's time is its distance at the runner's pace: the pace stands beside it.
|
||||
if (card.paceSecondsPerKm != null) {
|
||||
fields.push({ key: 'pace', label: t('tours.run.pace'), value: format.pace(card.paceSecondsPerKm) });
|
||||
}
|
||||
return <CardFields fields={fields} />;
|
||||
}
|
||||
|
||||
@@ -173,9 +179,9 @@ export default function TourRouteHead({ card, onShow }: { card: TourRouteCard; o
|
||||
title={name}
|
||||
meta={card.label ?? undefined}
|
||||
>
|
||||
{((card.activity === 'hike' && card.grades) || card.schedule) && (
|
||||
{((card.activity === 'hike' && card.grades) || card.routingProfile || card.schedule) && (
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
|
||||
{card.activity === 'hike' && card.grades && <TourGrade card={card} />}
|
||||
{((card.activity === 'hike' && card.grades) || card.routingProfile) && <TourGrade card={card} />}
|
||||
<DayPill card={card} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* FE-AI-TOUR-033 to FE-AI-TOUR-034
|
||||
* FE-AI-TOUR-033 to FE-AI-TOUR-034, FE-AI-TOUR-063
|
||||
*
|
||||
* A held tour change in its row: the route drawn small with its figures and
|
||||
* grade and a small profile, the kind of tour as the row's tile; a tour whose
|
||||
@@ -71,4 +71,10 @@ describe('TourRouteInRow', () => {
|
||||
expect(delta).toHaveTextContent('+1.6 km');
|
||||
expect(delta).toHaveTextContent('+20 min');
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-063: a held bike tour or run names its profile in its row, never a hike grade', () => {
|
||||
renderRow({ ...PENDING, activity: 'bike', routingProfile: 'road', grades: null, limit: null });
|
||||
expect(screen.getByText('Road')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('tour-grade')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function TourRouteInRow({ card, item }: { card: TourRouteCard; it
|
||||
<span className="min-w-0 flex-1">
|
||||
<WayFigures way={legVariantOf(card, 'current')} unit={unit} />
|
||||
</span>
|
||||
{grades && grades.highest !== null && <TourGrade card={card} />}
|
||||
{((grades && grades.highest !== null) || card.routingProfile) && <TourGrade card={card} />}
|
||||
</span>
|
||||
{previous && (
|
||||
<VariantDelta
|
||||
|
||||
@@ -18,6 +18,7 @@ import CardHeader from '../parts/CardHeader';
|
||||
import { CardSection } from '../parts/CardSection';
|
||||
import WayFigures from './TourFigureLine';
|
||||
import { TourRouteBody } from './TourRouteCard';
|
||||
import { TourGrade } from './TourRouteHead';
|
||||
import { legVariantOf, profileSamples, type RouteEntry, WAY_KEYS } from './tourRouteModel';
|
||||
import { routeName, TOUR_TYPE_WORDS } from './tourRouteWords';
|
||||
import { useLineHover, useTourRouteCard } from './useTourRouteCard';
|
||||
@@ -114,6 +115,8 @@ function WayOption({
|
||||
{grades && grades.highest !== null && (
|
||||
<SacStepChip step={grades.highest} uncertain={grades.unknownShare >= 0.5} />
|
||||
)}
|
||||
{/* Ways of a ride or a run may differ by profile: each says its own. */}
|
||||
{entry.card.routingProfile && <TourGrade card={entry.card} />}
|
||||
</span>
|
||||
<WayFigures way={way} unit={unit} />
|
||||
{!chosen && <WayDelta way={way} chosen={chosenWay} letter={chosenLetter} unit={unit} />}
|
||||
|
||||
@@ -99,6 +99,7 @@ export const TOUR_LOOK = {
|
||||
tools: {},
|
||||
steps: {
|
||||
propose_city_walk: { stepKey: 'assistant.step.planningWalk' },
|
||||
propose_tour: { stepKey: 'assistant.step.planningRoute' },
|
||||
plan_paddle_route: { stepKey: 'assistant.step.planningRoute' },
|
||||
plan_ski_route: { stepKey: 'assistant.step.planningRoute' },
|
||||
get_tour_plan: { stepKey: 'assistant.step.readingTour' },
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
sacAt,
|
||||
sacPaint,
|
||||
steepStretches,
|
||||
takeWaitsKey,
|
||||
wayGroups,
|
||||
} from './tourRouteModel';
|
||||
import { sketchOf } from './tourRouteSketchModel';
|
||||
@@ -136,15 +137,33 @@ describe('tourRouteModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-009: "Plan this" takes a planned hike or a city walk, nothing else', () => {
|
||||
it('FE-AI-TOUR-009: "Plan this" takes a planned way of every type, a city walk, a whole paddle or ski plan', () => {
|
||||
expect(canTake(HIKE)).toBe(true);
|
||||
expect(canTake({ ...HIKE, routed: false })).toBe(false);
|
||||
expect(canTake({ ...HIKE, activity: 'walk', routed: false })).toBe(true);
|
||||
expect(canTake({ ...HIKE, activity: 'bike', routingProfile: 'gravel' })).toBe(true);
|
||||
expect(canTake({ ...HIKE, activity: 'run', routingProfile: 'trail', routed: false })).toBe(false);
|
||||
expect(canTake(SAVED)).toBe(false);
|
||||
expect(canTake({ ...HIKE, activity: 'kayak', source: 'plan' })).toBe(false);
|
||||
expect(canTake({ ...HIKE, activity: 'kayak', source: 'plan' })).toBe(true);
|
||||
expect(canTake({ ...HIKE, activity: 'ski', source: 'plan' })).toBe(true);
|
||||
// A plan with a leg that could not be planned is no tour yet; a hike is never a plan.
|
||||
const failed = [{ kind: 'legs_failed' as const, at: null, count: 1 }];
|
||||
expect(canTake({ ...HIKE, activity: 'ski', source: 'plan', warnings: failed })).toBe(false);
|
||||
expect(canTake({ ...HIKE, source: 'plan' })).toBe(false);
|
||||
expect(canTake({ ...HIKE, source: 'pending' })).toBe(false);
|
||||
});
|
||||
|
||||
it("FE-AI-TOUR-060: a run waits for the runner's pace; a bike tour, a hike and a run with a pace wait for nothing", () => {
|
||||
expect(takeWaitsKey({ ...HIKE, activity: 'run', routingProfile: 'running' })).toBe('assistant.card.tour.needsPace');
|
||||
expect(takeWaitsKey({ ...HIKE, activity: 'run', paceSecondsPerKm: 330 })).toBeNull();
|
||||
expect(takeWaitsKey({ ...HIKE, activity: 'bike' })).toBeNull();
|
||||
expect(takeWaitsKey(HIKE)).toBeNull();
|
||||
// A ride's router time is its riding time; a run's time is at its pace.
|
||||
expect(timeLabelKey({ activity: 'bike', minutesBasis: 'router' })).toBe('tours.bike.timeEstimated');
|
||||
expect(timeLabelKey({ activity: 'run', minutesBasis: 'pace' })).toBe('tours.run.timeEstimated');
|
||||
expect(timeLabelKey({ activity: 'hike', minutesBasis: 'router' })).toBe('tours.scope.routed');
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-010: proposals of one answer form ways of three at most; anything else stands alone', () => {
|
||||
const entry = (toolCallId: string, card: TourRouteCard) => ({ toolCallId, card });
|
||||
const routes = routeEntries([entry('a', HIKE), { toolCallId: 'list', card: TOUR_LIST }, entry('b', GENTLE)]);
|
||||
|
||||
@@ -226,14 +226,29 @@ export function routePoints(card: TourRouteCard): [number, number][] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether "Plan this" can take the proposal: a hike whose way was planned,
|
||||
* or a city walk. A paddle or ski plan, a saved tour and a held change cannot.
|
||||
* Whether "Plan this" can take the proposal: a city walk, a hike, a bike
|
||||
* tour or a run whose way was planned, or a paddle or ski plan whose every
|
||||
* leg was planned. A saved tour and a held change cannot.
|
||||
*/
|
||||
export function canTake(card: TourRouteCard): boolean {
|
||||
if (card.source === 'plan') {
|
||||
const whole = card.warnings.every((warning) => warning.kind !== 'legs_failed');
|
||||
return (card.activity === 'kayak' || card.activity === 'ski') && card.routed && whole;
|
||||
}
|
||||
if (card.source !== 'proposal') return false;
|
||||
return card.activity === 'walk' || (card.activity === 'hike' && card.routed);
|
||||
return card.activity === 'walk' || (LAND_PLANS.has(card.activity) && card.routed);
|
||||
}
|
||||
|
||||
/** The proposals on land "Plan this" takes once their way is planned. */
|
||||
const LAND_PLANS: ReadonlySet<TourRouteCard['activity']> = new Set(['hike', 'bike', 'run']);
|
||||
|
||||
/** Why "Plan this" waits on a card it can take: a run is saved with the runner's pace only. Null when nothing is missing. */
|
||||
export function takeWaitsKey(card: TourRouteCard): string | null {
|
||||
return card.activity === 'run' && card.paceSecondsPerKm == null ? NEEDS_PACE.labelKey : null;
|
||||
}
|
||||
|
||||
const NEEDS_PACE = { labelKey: 'assistant.card.tour.needsPace' } as const;
|
||||
|
||||
/** A share as words take it, "40 %". */
|
||||
export function percentText(share: number): string {
|
||||
return `${Math.round(share * 100)} %`;
|
||||
|
||||
@@ -92,10 +92,25 @@ const TIME_WORDS = {
|
||||
planned: { labelKey: 'tours.scope.planned' },
|
||||
stations: { labelKey: 'tours.scope.plannedWithStays' },
|
||||
piste: { labelKey: 'tours.ski.time' },
|
||||
pace: { labelKey: 'tours.run.timeEstimated' },
|
||||
} as const satisfies Record<NonNullable<TourRouteCard['minutesBasis']>, { labelKey: string }>;
|
||||
|
||||
/** The name of a route's time: by how it was reckoned, else a paddle's own, else a walking time. */
|
||||
/** What each bike and run profile is called, as the Tours tab names it. */
|
||||
export const PROFILE_WORDS = {
|
||||
touring: { labelKey: 'tours.bike.profile.touring' },
|
||||
road: { labelKey: 'tours.bike.profile.road' },
|
||||
gravel: { labelKey: 'tours.bike.profile.gravel' },
|
||||
mtb: { labelKey: 'tours.bike.profile.mtb' },
|
||||
running: { labelKey: 'tours.run.profile.running' },
|
||||
trail: { labelKey: 'tours.run.profile.trail' },
|
||||
} as const satisfies Record<NonNullable<TourRouteCard['routingProfile']>, { labelKey: string }>;
|
||||
|
||||
/**
|
||||
* The name of a route's time: by how it was reckoned (a bike tour's router
|
||||
* time is its riding time), else a paddle's own, else a walking time.
|
||||
*/
|
||||
export function timeLabelKey(card: Pick<TourRouteCard, 'minutesBasis' | 'activity'>): string {
|
||||
if (card.minutesBasis === 'router' && card.activity === 'bike') return 'tours.bike.timeEstimated';
|
||||
if (card.minutesBasis) return TIME_WORDS[card.minutesBasis].labelKey;
|
||||
return card.activity === 'kayak' ? 'tours.paddle.timeEstimated' : 'tours.planner.walkingEstimated';
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { hasUndecided } from '../../assistantParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import { useChangeHover } from '../../useChangeHover';
|
||||
import { tourTarget } from './tourLook';
|
||||
import { canTake, routePoints } from './tourRouteModel';
|
||||
import { canTake, routePoints, takeWaitsKey } from './tourRouteModel';
|
||||
import { takeInThread, takeOfProposal } from './tourTakeModel';
|
||||
|
||||
/**
|
||||
@@ -104,7 +104,8 @@ function stateAfter(outcome: HoldOutcome, error: string | null): TakeState {
|
||||
}
|
||||
|
||||
/**
|
||||
* "Plan this" on a proposed hike or city walk: one tap adds the tour. The
|
||||
* "Plan this" on a proposed tour (a hike, a bike tour, a run, a city walk)
|
||||
* or a paddle or ski plan: one tap adds the tour. The
|
||||
* server holds the tour it creates as a change, without a model turn, and
|
||||
* the tap decides it at once, since tapping "Plan this" already is the
|
||||
* decision; the assistant then answers to what was added. Once the tour is
|
||||
@@ -148,9 +149,10 @@ export function useTakeProposal(toolCallId: string, card: TourRouteCard) {
|
||||
}, []);
|
||||
|
||||
const visible = canTake(card) && !!trip && can('place_edit', trip);
|
||||
let reasonKey: string | null = null;
|
||||
let reasonKey: string | null;
|
||||
if (offline) reasonKey = 'assistant.error.offline';
|
||||
else if (waiting && state !== 'taken') reasonKey = 'assistant.composer.decideFirst';
|
||||
else reasonKey = takeWaitsKey(card);
|
||||
const disabled = reasonKey !== null || isRunning || threadId === null || state === 'busy';
|
||||
|
||||
const take = useCallback(async () => {
|
||||
|
||||
@@ -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, '')}`;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
bikeCosting,
|
||||
hikingCosting,
|
||||
runCosting,
|
||||
TOURS_MAX_HIKING_DIFFICULTY,
|
||||
type BikeProfile,
|
||||
type HikingPreferences,
|
||||
@@ -78,28 +80,6 @@ const FIXED_LEG_KM_PER_HOUR = { ferrata: 1.5, free: 4, portage: 2 } as const
|
||||
/** A leg drawn by hand on a bike tour is reckoned at an easy riding pace: no router saw it. */
|
||||
const FREE_LEG_RIDING_KM_PER_HOUR = 15
|
||||
|
||||
/**
|
||||
* BIK-01 to BIK-04: what each bike profile asks Valhalla's bicycle costing for.
|
||||
* Every one was checked against the public instance with trace_attributes over
|
||||
* the routes it gave, before it was offered (shares of the length):
|
||||
* - touring, Hybrid with bad surfaces avoided: Garmisch station to the Eckbauer
|
||||
* 92 % asphalt and no gravel, where gravel took 18 % gravel on farm tracks.
|
||||
* - road, Road at full surface avoidance: Herrsching to Andechs 100 % asphalt
|
||||
* over 6.2 km, where touring took 4.98 km with 7 % dirt and 10 % compacted.
|
||||
* - gravel, Cross with surfaces barely avoided: Munich Marienplatz to the
|
||||
* Englischer Garten 39 % compacted paths along the Isar, touring 9 %.
|
||||
* - mtb, Mountain with no surface avoidance and roads least wanted: Garmisch
|
||||
* to the Eckbauer 63 % tracks and 38 % gravel.
|
||||
* `use_roads` and the bicycle type also set the router's riding speed, so the
|
||||
* time it gives is the profile's. None of it says a way is rideable or safe.
|
||||
*/
|
||||
const BIKE_COSTING: Record<BikeProfile, Readonly<CostingOptions>> = {
|
||||
touring: { bicycle_type: 'Hybrid', use_roads: 0.2, avoid_bad_surfaces: 1 },
|
||||
road: { bicycle_type: 'Road', use_roads: 0.5, avoid_bad_surfaces: 1 },
|
||||
gravel: { bicycle_type: 'Cross', use_roads: 0.1, avoid_bad_surfaces: 0.25 },
|
||||
mtb: { bicycle_type: 'Mountain', use_roads: 0, avoid_bad_surfaces: 0 },
|
||||
}
|
||||
|
||||
type LandWaypoint = Pick<Waypoint, 'lat' | 'lng'> & { fixedLeg?: TourFixedLeg | null }
|
||||
|
||||
/**
|
||||
@@ -137,43 +117,13 @@ export async function routeCityWalk(
|
||||
const CITY_WALK_COSTING: Readonly<CostingOptions> = { use_ferry: 0 }
|
||||
const CITY_WALK_BINDING: Readonly<SearchFilter> = { exclude_ferry: true }
|
||||
|
||||
/**
|
||||
* RUN-01, RUN-02: what each run profile asks Valhalla's pedestrian costing
|
||||
* for. A run is run, so a ferry only where no way on land exists
|
||||
* (`use_ferry` 0). Checked with trace_attributes against the public instance
|
||||
* on 20.10.2026 (shares of the length):
|
||||
* - running keeps to runnable, mostly paved ways: farm and forest tracks only
|
||||
* where there is no other way (`use_tracks` 0), and no hiking limit, so the
|
||||
* router keeps to the ways it takes for walking anyway (T1). Berlin S
|
||||
* Grunewald into the forest went from 95 % tracks with 3 % paved to 67 %
|
||||
* smooth paved paths over 3.58 km instead of 3.50 km; Munich Großhesseloher
|
||||
* Brücke to Pullach from 79 % to 88 % smooth asphalt and no track left;
|
||||
* Marienplatz to the Englischer Garten stayed as it was.
|
||||
* - trail takes tracks wherever they serve (`use_tracks` 1) and admits
|
||||
* mountain paths up to T2 (`max_hiking_difficulty` 2), never T3 and above.
|
||||
* Above Freiburg Günterstal up to the Schauinsland it went from 82 % roads
|
||||
* and 83 % paved (running) to 78 % forest tracks and 81 % compacted, gravel
|
||||
* and dirt over 9.57 km instead of 10.15 km; Rathen to the Bastei took 28 % of T2
|
||||
* path that running leaves out; Heidelberg's old town, all streets, stayed
|
||||
* as it was. Leaning on footways (`walkway_factor`) was tried and left out:
|
||||
* along the Isar it moved the run onto paved footways, from 33 % to 18 %
|
||||
* unpaved.
|
||||
* The router's own time is a walker's and is never used: a run's time is its
|
||||
* distance at the runner's pace, whatever the profile. Nothing here says a
|
||||
* way can be run.
|
||||
*/
|
||||
const RUN_COSTING: Record<RunProfile, Readonly<CostingOptions>> = {
|
||||
running: { use_tracks: 0, use_ferry: 0 },
|
||||
trail: { use_tracks: 1, use_ferry: 0, max_hiking_difficulty: 2 },
|
||||
}
|
||||
|
||||
/** RUN-01, RUN-02: a run, on foot for its profile; a leg drawn by hand is kept as drawn. */
|
||||
export async function routeRun(
|
||||
waypoints: LandWaypoint[],
|
||||
profile: RunProfile,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TourRouteResult | TourRouteUnreachable | null> {
|
||||
return routeOnLand(waypoints, 'walking', RUN_COSTING[profile], signal)
|
||||
return routeOnLand(waypoints, 'walking', runCosting(profile), signal)
|
||||
}
|
||||
|
||||
/** BIK-01: a bike tour, routed for its profile; a leg drawn by hand is kept as drawn. */
|
||||
@@ -182,7 +132,7 @@ export async function routeBikeTour(
|
||||
profile: BikeProfile,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TourRouteResult | TourRouteUnreachable | null> {
|
||||
return routeOnLand(waypoints, 'cycling', BIKE_COSTING[profile], signal)
|
||||
return routeOnLand(waypoints, 'cycling', bikeCosting(profile), signal)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -244,7 +244,7 @@ function lookOf(
|
||||
const centre = area ? nearbyName(card, (area.south + area.north) / 2, (area.west + area.east) / 2) : null;
|
||||
return { label: centre, area };
|
||||
}
|
||||
if (toolName === 'propose_hike') return hikeLook(args);
|
||||
if (toolName === 'propose_hike' || toolName === 'propose_tour') return hikeLook(args);
|
||||
if (toolName === 'search_place') {
|
||||
const bias = recordOf(args.locationBias);
|
||||
const area = pointArea(bias.lat, bias.lng, bias.radius, BIAS_RADIUS_M);
|
||||
|
||||
@@ -93,10 +93,20 @@ function foundPlaceOutcome(part: ToolCallPart): string {
|
||||
return `The user picked ${place} from the search results for ${day}; it waits for them to add or discard it.`;
|
||||
}
|
||||
|
||||
/** What a taken proposal created, by the tool it went through; a hike for any other. */
|
||||
const TAKEN_WORDS: Readonly<Record<string, string>> = {
|
||||
create_city_walk: 'city walk',
|
||||
'create_tour:bike': 'bike tour',
|
||||
'create_tour:run': 'run',
|
||||
create_paddle_tour: 'paddle tour',
|
||||
create_ski_tour: 'ski tour',
|
||||
};
|
||||
|
||||
/** What became of a proposal the user took from an answer to add it to the trip, as its stored part says. */
|
||||
function takenProposalOutcome(part: ToolCallPart): string {
|
||||
const name = quoted(part.args.name);
|
||||
const what = part.args.via === 'create_city_walk' ? 'city walk' : 'hike';
|
||||
const via = part.args.via === 'create_tour' ? `create_tour:${String(part.args.activity)}` : String(part.args.via);
|
||||
const what = TAKEN_WORDS[via] ?? 'hike';
|
||||
if (part.approval?.approved === true) {
|
||||
return part.isError ? `The ${what} ${name} could not be added.` : `The ${what} ${name} was added to the trip.`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { recordOf, type Row } from './cards/card-values';
|
||||
import { haversineMetres } from '@trek/shared';
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared/i18n';
|
||||
|
||||
/**
|
||||
* "Plan this" on a paddle or ski plan an answer showed (plan_paddle_route,
|
||||
* plan_ski_route): the create it holds, from the plan's own arguments and
|
||||
* its answer read again whole. The legs of a plan must run on from one to
|
||||
* the next to be one tour; its points are the first leg's start and every
|
||||
* leg's end. A paddle tour's water is read from the plan (a river where any
|
||||
* stretch is on running water, else a lake), its name from the waters it
|
||||
* runs on, a ski tour's from its ski areas; where neither names one, the
|
||||
* tour type's own word in the user's language.
|
||||
*/
|
||||
|
||||
/** The plans a tour can be taken from, and the create each one is saved through. */
|
||||
export const SPORT_CREATES = {
|
||||
plan_paddle_route: 'create_paddle_tour',
|
||||
plan_ski_route: 'create_ski_tour',
|
||||
} as const;
|
||||
export type SportPlanTool = keyof typeof SPORT_CREATES;
|
||||
|
||||
/** Whether a tool is one of the plans a tour can be taken from. */
|
||||
export function isSportPlan(toolName: string): toolName is SportPlanTool {
|
||||
return Object.hasOwn(SPORT_CREATES, toolName);
|
||||
}
|
||||
|
||||
/** How near one leg's end must lie to the next leg's start for the two to be one tour, in metres. */
|
||||
const JOINED_M = 5;
|
||||
/** The longest name a tour is given from its waters or areas, well inside a tour's name. */
|
||||
const NAME_CHARS = 120;
|
||||
|
||||
/** A point as the plan names it, or null. */
|
||||
function pointOf(value: unknown): { lat: number; lng: number } | null {
|
||||
const point = recordOf(value);
|
||||
return typeof point.lat === 'number' && typeof point.lng === 'number' ? { lat: point.lat, lng: point.lng } : null;
|
||||
}
|
||||
|
||||
/** The points of a plan's legs, the first leg's start then every leg's end; null where the legs do not join. */
|
||||
export function waypointsOfLegs(legs: unknown): Array<{ lat: number; lng: number }> | null {
|
||||
const rows = Array.isArray(legs) ? legs.map(recordOf) : [];
|
||||
const first = pointOf(rows[0]?.from);
|
||||
if (!first) return null;
|
||||
const points = [first];
|
||||
for (const [index, leg] of rows.entries()) {
|
||||
const from = pointOf(leg.from);
|
||||
const to = pointOf(leg.to);
|
||||
const before = points[index];
|
||||
if (!from || !to || !before || haversineMetres(before.lat, before.lng, from.lat, from.lng) > JOINED_M) return null;
|
||||
points.push(to);
|
||||
}
|
||||
return points.length >= 2 ? points : null;
|
||||
}
|
||||
|
||||
/** Each planned leg of an answer; null where one failed, which takes the whole plan out of reach. */
|
||||
function plannedLegs(result: Row): Row[] | null {
|
||||
const legs = Array.isArray(result.legs) ? result.legs.map(recordOf) : [];
|
||||
return legs.length > 0 && legs.every((leg) => leg.status === 'ok') ? legs : null;
|
||||
}
|
||||
|
||||
/** "Isar → Ammersee": the first and last names given, each once, clipped. */
|
||||
function namedAlong(names: readonly unknown[]): string {
|
||||
const kept = names.flatMap((name) => (typeof name === 'string' && name.trim() !== '' ? [name.trim()] : []));
|
||||
const ends = [...new Set([kept[0], kept.at(-1)])].filter((name): name is string => !!name);
|
||||
const name = ends.join(' → ');
|
||||
return name.length > NAME_CHARS ? `${name.slice(0, NAME_CHARS - 1)}…` : name;
|
||||
}
|
||||
|
||||
/** The water a paddle plan runs on: a river where any stretch is on running water, else a lake. */
|
||||
function waterOf(legs: readonly Row[]): 'river' | 'lake' {
|
||||
const kinds = legs.flatMap((leg) => (Array.isArray(leg.segments) ? leg.segments.map((s) => recordOf(s).kind) : []));
|
||||
return kinds.some((kind) => kind === 'water' || kind === 'rapids' || kind === 'pass') ? 'river' : 'lake';
|
||||
}
|
||||
|
||||
/**
|
||||
* The create a taken paddle or ski plan holds: its points, the plan's own
|
||||
* settings and a name; null where a leg failed or the legs do not join.
|
||||
*/
|
||||
export function sportTakeArgsOf(toolName: SportPlanTool, args: Row, result: Row, fallbackName: string): Row | null {
|
||||
const legs = plannedLegs(result);
|
||||
const waypoints = waypointsOfLegs(args.legs);
|
||||
if (!legs || !waypoints) return null;
|
||||
const via = SPORT_CREATES[toolName];
|
||||
if (via === 'create_paddle_tour') {
|
||||
const waters = legs.flatMap((leg) =>
|
||||
Array.isArray(leg.segments) ? leg.segments.map((s) => recordOf(s).name) : [],
|
||||
);
|
||||
const take: Row = { via, name: namedAlong(waters) || fallbackName, waypoints, water_context: waterOf(legs) };
|
||||
if (args.boat !== undefined) take.boat_type = args.boat;
|
||||
if (args.lake_style !== undefined) take.lake_style = args.lake_style;
|
||||
if (args.lock_policy !== undefined) take.lock_policy = args.lock_policy;
|
||||
return take;
|
||||
}
|
||||
const areas = Array.isArray(result.area_names) ? result.area_names : [];
|
||||
const take: Row = { via, name: namedAlong(areas) || fallbackName, waypoints, discipline: args.discipline };
|
||||
if (args.technique !== undefined && args.technique !== null) take.technique = args.technique;
|
||||
if (args.options !== undefined) take.options = args.options;
|
||||
return take;
|
||||
}
|
||||
|
||||
/** The locale key each plan's tour type is named by. */
|
||||
const TYPE_WORD_KEYS: Readonly<Record<SportPlanTool, string>> = {
|
||||
plan_paddle_route: 'tourTypes.kayak',
|
||||
plan_ski_route: 'tourTypes.ski',
|
||||
};
|
||||
|
||||
/** The words of one locale's table, or none when it cannot be read. */
|
||||
async function wordsOf(language: string): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
// A supported code only, so the path is one of the package's own locale entries.
|
||||
const loaded: unknown = await import(`@trek/shared/i18n/${language}`);
|
||||
const words = (loaded as { default?: unknown } | null)?.default;
|
||||
return words !== null && typeof words === 'object' ? (words as Record<string, unknown>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** A plan's tour type in the user's language ("Paddeltour"), English where the language has no table. */
|
||||
export async function tourTypeWord(locale: string, toolName: SportPlanTool): Promise<string> {
|
||||
const language = locale.split('-')[0] ?? 'en';
|
||||
const code = SUPPORTED_LANGUAGE_CODES.includes(locale)
|
||||
? locale
|
||||
: SUPPORTED_LANGUAGE_CODES.includes(language)
|
||||
? language
|
||||
: 'en';
|
||||
const key = TYPE_WORD_KEYS[toolName];
|
||||
const own = (await wordsOf(code))[key];
|
||||
if (typeof own === 'string') return own;
|
||||
const english = (await wordsOf('en'))[key];
|
||||
return typeof english === 'string' ? english : 'Tour';
|
||||
}
|
||||
@@ -22,13 +22,14 @@ import { RateLimitService } from '../common/rate-limit.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { buildTripCard, categoriesFrom, todayIn, type TripCard } from './assistant-context';
|
||||
import { partsOf, toThreadMessages } from './assistant-history';
|
||||
import { isSportPlan, SPORT_CREATES, sportTakeArgsOf, tourTypeWord } from './assistant-proposal-sports';
|
||||
import { AssistantStreamRegistry } from './assistant-stream.registry';
|
||||
import { TAKE_PROPOSAL_TOOL, TURNS_PER_MINUTE } from './assistant-tools.constants';
|
||||
import { AssistantToolsService, type ToolRun } from './assistant-tools.service';
|
||||
import { AssistantCardContext } from './cards/card-context';
|
||||
import { recordOf, type Row } from './cards/card-values';
|
||||
import { argText } from './cards/tour/tour-card-values';
|
||||
import { cityWalkCard } from './cards/tour/tour-sport.cards';
|
||||
import { cityWalkCard, paddleCard, skiCard } from './cards/tour/tour-sport.cards';
|
||||
import { describeToolCall } from './tool-call-preview.helpers';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
@@ -49,8 +50,17 @@ import { randomUUID } from 'node:crypto';
|
||||
*/
|
||||
const WALK_READ = 'propose_city_walk';
|
||||
|
||||
/** What a taken proposal reads: the trip's days and places, the category names its card writes with, a walk again. */
|
||||
const TRIP_READS: ReadonlySet<string> = new Set(['get_trip_summary', 'list_places', 'list_categories', WALK_READ]);
|
||||
/**
|
||||
* What a taken proposal reads: the trip's days and places, the category names
|
||||
* its card writes with, a walk or a paddle or ski plan again.
|
||||
*/
|
||||
const TRIP_READS: ReadonlySet<string> = new Set([
|
||||
'get_trip_summary',
|
||||
'list_places',
|
||||
'list_categories',
|
||||
WALK_READ,
|
||||
...Object.keys(SPORT_CREATES),
|
||||
]);
|
||||
|
||||
/** Message rows searched for the proposal; the history a turn sends the provider. */
|
||||
const SEARCHED_ROWS = 40;
|
||||
@@ -69,9 +79,11 @@ interface StoredProposal {
|
||||
}
|
||||
|
||||
/** The proposing reads a card can be taken from, and the tool each one is created through. */
|
||||
const CREATES: Readonly<Record<string, 'create_hike' | 'create_city_walk'>> = {
|
||||
const CREATES: Readonly<Record<string, string>> = {
|
||||
propose_hike: 'create_hike',
|
||||
propose_tour: 'create_tour',
|
||||
propose_city_walk: 'create_city_walk',
|
||||
...SPORT_CREATES,
|
||||
};
|
||||
const PROPOSING: ReadonlySet<string> = new Set(Object.keys(CREATES));
|
||||
|
||||
@@ -103,6 +115,26 @@ function walkName(stations: readonly Row[], result: Row): string {
|
||||
return name.length > WALK_NAME_CHARS ? `${name.slice(0, WALK_NAME_CHARS - 1)}…` : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* A paddle or ski plan asked again with the arguments it was planned with:
|
||||
* the stored answer is the model's shortened copy, and its legs' stretches
|
||||
* name the water or the areas the tour is called after. The card is drawn
|
||||
* again from the whole answer (the stored one when none can be drawn). Null
|
||||
* when the plan cannot be read now.
|
||||
*/
|
||||
async function planAgain(
|
||||
read: (name: string, args: Row) => Promise<ToolRun>,
|
||||
proposal: StoredProposal,
|
||||
trip: TripCard,
|
||||
today: string,
|
||||
): Promise<StoredProposal | null> {
|
||||
const run = await read(proposal.toolName, proposal.args);
|
||||
if (!run.ok) return null;
|
||||
const input = { toolCallId: '', args: proposal.args, value: run.value, card: trip, today };
|
||||
const drawn = proposal.toolName === 'plan_ski_route' ? skiCard(input) : paddleCard(input);
|
||||
return { ...proposal, result: recordOf(run.value), card: drawn ?? proposal.card };
|
||||
}
|
||||
|
||||
/**
|
||||
* A walk asked again with the arguments it was proposed with: the stations to
|
||||
* create from the tool's own answer, and the card drawn from that answer (the
|
||||
@@ -133,7 +165,7 @@ export function pendingCardOf(card: TourRouteCard, args: Row): TourRouteCard {
|
||||
return { ...card, source: 'pending', name: argText(args.name, 120) ?? card.name, label: null, schedule };
|
||||
}
|
||||
|
||||
/** The fields of a propose_hike create_hike takes as they are. */
|
||||
/** The fields of a propose_hike create_hike takes as they are, and those of a propose_tour create_tour takes. */
|
||||
const HIKE_FIELDS = [
|
||||
'name',
|
||||
'waypoints',
|
||||
@@ -143,21 +175,42 @@ const HIKE_FIELDS = [
|
||||
'day_id',
|
||||
'start_time',
|
||||
] as const;
|
||||
const TOUR_FIELDS = [
|
||||
'name',
|
||||
'activity',
|
||||
'bike_profile',
|
||||
'run_profile',
|
||||
'run_pace_seconds_per_km',
|
||||
'waypoints',
|
||||
'round_trip',
|
||||
'day_id',
|
||||
'start_time',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The change a taken proposal holds: create_hike with the proposal's own
|
||||
* arguments for a hike whose way was planned, create_city_walk with the
|
||||
* The change a taken proposal holds: create_hike (create_tour for a bike
|
||||
* tour or a run) with the proposal's own arguments for a way that was
|
||||
* planned, create_paddle_tour or create_ski_tour with a plan's points and
|
||||
* settings, create_city_walk with the
|
||||
* stations it answered for a walk that can be created. Null for a proposal
|
||||
* nothing can be created from.
|
||||
*/
|
||||
export function takeArgsOf(proposal: StoredProposal): Row | null {
|
||||
export function takeArgsOf(proposal: StoredProposal, fallbackName = ''): Row | null {
|
||||
const via = CREATES[proposal.toolName];
|
||||
if (via === 'create_hike') {
|
||||
if (via === 'create_hike' || via === 'create_tour') {
|
||||
if (!proposal.card.routed) return null;
|
||||
// A run is saved with the runner's pace only, as the planner saves one.
|
||||
if (via === 'create_tour' && proposal.args.activity === 'run' && proposal.args.run_pace_seconds_per_km == null) {
|
||||
return null;
|
||||
}
|
||||
const args: Row = { via };
|
||||
for (const key of HIKE_FIELDS) if (proposal.args[key] !== undefined) args[key] = proposal.args[key];
|
||||
for (const key of via === 'create_hike' ? HIKE_FIELDS : TOUR_FIELDS) {
|
||||
if (proposal.args[key] !== undefined) args[key] = proposal.args[key];
|
||||
}
|
||||
return args;
|
||||
}
|
||||
if (isSportPlan(proposal.toolName))
|
||||
return sportTakeArgsOf(proposal.toolName, proposal.args, proposal.result, fallbackName);
|
||||
const stations = Array.isArray(proposal.result.stations_for_create) ? proposal.result.stations_for_create : [];
|
||||
if (proposal.result.can_create === false || stations.length < 2) return null;
|
||||
return { via, name: walkName(stations.map(recordOf), proposal.result), stations };
|
||||
@@ -227,7 +280,8 @@ export class AssistantProposalsService {
|
||||
const id = randomUUID();
|
||||
const today = todayIn(req.timeZone, new Date());
|
||||
const { card, categories, proposal } = await this.tripOf(userId, tripId, stored, today);
|
||||
const args = proposal ? takeArgsOf(proposal) : null;
|
||||
const fallback = isSportPlan(stored.toolName) ? await tourTypeWord(req.locale, stored.toolName) : '';
|
||||
const args = proposal ? takeArgsOf(proposal, fallback) : null;
|
||||
if (!proposal || !args) throw aiRefusal('generic', 'Nothing can be created from this proposal: ask for it again');
|
||||
const ctx = this.cardContext.contextOf(card, categories, req.locale, today);
|
||||
// The proposal it came from, so the thread tells which card was taken, whatever way it creates.
|
||||
@@ -294,13 +348,24 @@ export class AssistantProposalsService {
|
||||
return {
|
||||
card,
|
||||
categories: new Map(categories.map((category): [number, string] => [category.id, category.name])),
|
||||
proposal: stored.toolName === WALK_READ ? await walkAgain(read, stored, card, today) : stored,
|
||||
proposal: await this.again(read, stored, card, today),
|
||||
};
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** The proposal as it is taken: a walk or a paddle or ski plan asked again, a hike or tour as it was stored. */
|
||||
private async again(
|
||||
read: (name: string, args: Row) => Promise<ToolRun>,
|
||||
stored: StoredProposal,
|
||||
card: TripCard,
|
||||
today: string,
|
||||
): Promise<StoredProposal | null> {
|
||||
if (stored.toolName === WALK_READ) return await walkAgain(read, stored, card, today);
|
||||
return isSportPlan(stored.toolName) ? await planAgain(read, stored, card, today) : stored;
|
||||
}
|
||||
|
||||
/** The newest answer of the thread as the client shows it, with what became of its changes. */
|
||||
private async answerOf(threadId: number): Promise<AssistantMessage> {
|
||||
const rows = await this.messages.listLatest(threadId, THREAD_ROWS);
|
||||
|
||||
@@ -226,14 +226,16 @@ type ToolGroups = Readonly<Record<string, { about: string; controllers: readonly
|
||||
const CORE_TOOL_GROUPS: ToolGroups = {
|
||||
tours: {
|
||||
about:
|
||||
'Tours: hikes (also over several days in stages), city walks, bike tours, runs, rides, paddling and ski ' +
|
||||
'routes; plan, create, change and export them (hikes with their heights and SAC grades), search along a ' +
|
||||
'Tours: hikes (also over several days in stages), city walks, bike tours (road, gravel, mountain bike), ' +
|
||||
'runs (road and trail), rides, paddling and ski and winter tours; plan and save every one of them, change ' +
|
||||
'and export them (hikes with their heights and SAC grades), search along a ' +
|
||||
'tour, check off a city walk under way, the recorded tracks and tour stats, their packing, tours in ' +
|
||||
'collections, the tour library and imports from GPX files and Google Maps walks.',
|
||||
controllers: [
|
||||
'ToursMcp',
|
||||
'CityWalkMcp',
|
||||
'HikePlanMcp',
|
||||
'TourPlanMcp',
|
||||
'TourChangeMcp',
|
||||
'TourChangeUndoMcp',
|
||||
'TourLibraryMcp',
|
||||
|
||||
@@ -145,8 +145,14 @@ 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']);
|
||||
|
||||
/** 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']);
|
||||
/** The model's tools a taken proposal is created through: a hike, a bike tour or a run, a city walk, a paddle or ski tour. */
|
||||
const PROPOSAL_VIA: ReadonlySet<string> = new Set([
|
||||
'create_hike',
|
||||
'create_tour',
|
||||
'create_city_walk',
|
||||
'create_paddle_tour',
|
||||
'create_ski_tour',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Changes the server applies itself: a booking from an attached file, a search
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createHikeCard,
|
||||
createHikeEffect,
|
||||
loadCreateHike,
|
||||
loadCreateTour,
|
||||
loadUpdateTour,
|
||||
updateTourCard,
|
||||
type TourLoaded,
|
||||
@@ -13,6 +14,13 @@ import {
|
||||
import { TOUR_DESCRIBERS } from './tour-describe';
|
||||
import { tourListCard } from './tour-lists.cards';
|
||||
import { proposalCard, savedTourCard } from './tour-route.cards';
|
||||
import {
|
||||
createPaddleCard,
|
||||
createSkiCard,
|
||||
loadCreatePaddle,
|
||||
loadCreateSki,
|
||||
type SportLoaded,
|
||||
} from './tour-sport-change.cards';
|
||||
import { cityWalkCard, paddleCard, skiCard } from './tour-sport.cards';
|
||||
import { DELETE_CREATED_TOUR, DELETE_IMPORTED_TOUR } from './tour-undo';
|
||||
import { describeVisit, loadVisit, SET_VISIT_UNDO } from './tour-visit.cards';
|
||||
@@ -47,6 +55,34 @@ const createHike: ToolCards<TourLoaded> = {
|
||||
undo: DELETE_CREATED_TOUR,
|
||||
};
|
||||
|
||||
/** create_tour: a bike tour or a run, drawn and taken back as a hike is, its effect on the day too. */
|
||||
const createTour: ToolCards<TourLoaded> = {
|
||||
write: { verbKey: 'addTour', entity: 'tour' },
|
||||
describe: TOUR_DESCRIBERS.create_tour,
|
||||
load: loadCreateTour,
|
||||
change: createHikeCard,
|
||||
effect: createHikeEffect,
|
||||
applied: appliedTourCard,
|
||||
undo: DELETE_CREATED_TOUR,
|
||||
};
|
||||
|
||||
/** A paddle or ski tour saved from its plan: the plan's card waiting, the receipt opening the tour, its delete the way back. */
|
||||
function sportCreate(
|
||||
describe: ToolCards<SportLoaded>['describe'],
|
||||
load: NonNullable<ToolCards<SportLoaded>['load']>,
|
||||
change: NonNullable<ToolCards<SportLoaded>['change']>,
|
||||
): ToolCards<SportLoaded> {
|
||||
return {
|
||||
write: { verbKey: 'addTour', entity: 'tour' },
|
||||
describe,
|
||||
load,
|
||||
change,
|
||||
effect: createHikeEffect,
|
||||
applied: appliedTourCard,
|
||||
undo: DELETE_CREATED_TOUR,
|
||||
};
|
||||
}
|
||||
|
||||
const updateTour: ToolCards<TourLoaded> = {
|
||||
write: { verbKey: 'updateTour', entity: 'tour' },
|
||||
describe: TOUR_DESCRIBERS.update_tour,
|
||||
@@ -60,6 +96,7 @@ export const TOUR_CARDS: DomainCards = {
|
||||
id: 'tour',
|
||||
tools: {
|
||||
propose_hike: { argPolicy: WITH_GEOMETRY, answer: proposalCard },
|
||||
propose_tour: { argPolicy: WITH_GEOMETRY, answer: proposalCard },
|
||||
propose_city_walk: { argPolicy: WITH_GEOMETRY, answer: cityWalkCard },
|
||||
plan_paddle_route: { argPolicy: WITH_GEOMETRY, answer: paddleCard },
|
||||
plan_ski_route: { argPolicy: WITH_GEOMETRY, answer: skiCard },
|
||||
@@ -67,6 +104,9 @@ export const TOUR_CARDS: DomainCards = {
|
||||
list_tours: { answer: tourListCard },
|
||||
find_tours_for_day: { answer: tourListCard },
|
||||
create_hike: createHike,
|
||||
create_tour: createTour,
|
||||
create_paddle_tour: sportCreate(TOUR_DESCRIBERS.create_paddle_tour, loadCreatePaddle, createPaddleCard),
|
||||
create_ski_tour: sportCreate(TOUR_DESCRIBERS.create_ski_tour, loadCreateSki, createSkiCard),
|
||||
create_city_walk: {
|
||||
write: { verbKey: 'addTour', entity: 'tour' },
|
||||
describe: TOUR_DESCRIBERS.create_city_walk,
|
||||
@@ -94,7 +134,7 @@ export const TOUR_CARDS: DomainCards = {
|
||||
},
|
||||
},
|
||||
prompt: [
|
||||
'Hikes: call propose_hike with a name, and day_id and start_time when known; to compare ways, call it up to three times in one step, each with a variant_label and along false, the way you recommend first (the card shows the first one chosen). Call create_hike only once the user chose a way. A hike over several days is one hike: stage_end on each stop a day ends at (no day_id), then each stage on its day with assign_place_to_day and tourStageKey.',
|
||||
"Every tour type can be planned and saved. Pick the tool by what the user does: a hike (also a trek over several days) propose_hike then create_hike; a bike tour or a run propose_tour (activity bike or run) then create_tour; a city walk propose_city_walk then create_city_walk; a paddle tour plan_paddle_route then create_paddle_tour (it needs water_context river or lake); a ski or winter tour (alpine, snowboard, cross-country, ski touring, snowshoe, winter hike) plan_ski_route then create_ski_tour with the same points, discipline and options, passing the plan's points in order (the from of the first leg and the to of every leg). Profiles: a bike tour is touring (city or trekking bike, the default), road (asphalt only), gravel (gravel and farm tracks) or mtb (rough tracks and trails); a run is running (mostly paved, the default) or trail; ask when it is not clear. A run needs the runner's pace (run_pace_seconds_per_km, 330 for 5:30 min/km): ask for it, never guess one. A hike: propose_hike with a name, and day_id and start_time when known; to compare ways call it up to three times in one step, each with a variant_label and along false, the way you recommend first; create_hike only once the user chose a way. A hike over several days is one hike: stage_end on each stop a day ends at (no day_id), then each stage on its day with assign_place_to_day and tourStageKey.",
|
||||
'The app draws a proposed, planned or saved tour (route, profile, stops, times, weather, warnings) and lists of tours as cards: do not repeat their figures or list them again, say in one sentence what matters. Once a tour is planned, offer what goes with it: places along it (search_tour_pois), what to pack (get_tour_packing, link_tour_packing), the GPX file (export_tour_gpx).',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
straightMetres,
|
||||
whole,
|
||||
} from './tour-card-values';
|
||||
import { hikeCard } from './tour-route.cards';
|
||||
import { hikeCard, landSportOf } from './tour-route.cards';
|
||||
import {
|
||||
hikingPreferencesOf,
|
||||
TOUR_ROUTE_LIMITS,
|
||||
@@ -118,8 +118,12 @@ function heldProblem(card: TourRouteCard | null): TourRouteCard['problem'] {
|
||||
return refuses && card?.problem ? card.problem : { why: 'not_planned', position: null, snappedM: null };
|
||||
}
|
||||
|
||||
/** The points of a held hike or walk joined straight, as stops along the straight line. */
|
||||
function straightCard(points: readonly Row[], activity: 'hike' | 'walk', fields: Partial<TourRouteCard>) {
|
||||
/** The points of a held tour joined straight, as stops along the straight line. */
|
||||
export function straightCard(
|
||||
points: readonly Row[],
|
||||
activity: TourRouteCard['activity'],
|
||||
fields: Partial<TourRouteCard>,
|
||||
): TourRouteCard | null {
|
||||
const stops = points.flatMap((point) => {
|
||||
const at = latLngOf(point);
|
||||
return at ? [{ ...at, point }] : [];
|
||||
@@ -155,19 +159,45 @@ function hikePoints(args: ToolArgs): Row[] {
|
||||
return args.round_trip === true && points[0] ? [...points, points[0]] : points;
|
||||
}
|
||||
|
||||
/** create_hike: the planned way as the proposal drew it, else the points straight. */
|
||||
/**
|
||||
* create_hike and create_tour: the planned way as the proposal drew it, else
|
||||
* the points straight, a hike's under its limit, a bike tour's or a run's
|
||||
* under its profile.
|
||||
*/
|
||||
export function createHikeCard(ctx: PreviewContext, args: ToolArgs, loaded: TourLoaded | null): TourRouteCard | null {
|
||||
const name = argText(args.name, 120);
|
||||
const planned = loaded ? hikeCard(loaded.plan, args, ctx.card, 'pending') : null;
|
||||
if (planned?.routed) return fitted({ ...planned, name: name ?? planned.name, label: null });
|
||||
const straight = straightCard(hikePoints(args), 'hike', {
|
||||
const sport = landSportOf({}, args);
|
||||
const own = sport ?? { limit: sacStepOf(args.max_hiking_difficulty) ?? TOURS_MAX_HIKING_DIFFICULTY };
|
||||
const straight = straightCard(hikePoints(args), sport?.activity ?? 'hike', {
|
||||
name,
|
||||
limit: sacStepOf(args.max_hiking_difficulty) ?? TOURS_MAX_HIKING_DIFFICULTY,
|
||||
...own,
|
||||
problem: heldProblem(planned),
|
||||
});
|
||||
return straight ? fitted(straight) : null;
|
||||
}
|
||||
|
||||
/** The fields propose_tour takes of a create_tour, as the planner's memo keys its way. */
|
||||
const TOUR_PLAN_FIELDS = [
|
||||
'name',
|
||||
'activity',
|
||||
'bike_profile',
|
||||
'run_profile',
|
||||
'run_pace_seconds_per_km',
|
||||
'waypoints',
|
||||
'round_trip',
|
||||
'day_id',
|
||||
'start_time',
|
||||
] as const;
|
||||
|
||||
/** create_tour: plans the way through the points as propose_tour did (the planner's memo answers a recent one). */
|
||||
export async function loadCreateTour(args: ToolArgs, read: CardReader): Promise<TourLoaded> {
|
||||
const plan: ToolArgs = { along: false };
|
||||
for (const key of TOUR_PLAN_FIELDS) if (args[key] !== undefined) plan[key] = args[key];
|
||||
return { plan: await read('propose_tour', plan), saved: null };
|
||||
}
|
||||
|
||||
/** create_city_walk: the stations joined straight; the way on foot is planned when the walk is saved. */
|
||||
export function createCityWalkCard(_ctx: PreviewContext, args: ToolArgs): TourRouteCard | null {
|
||||
const straight = straightCard(rowsIn(args.stations), 'walk', { name: argText(args.name, 120) });
|
||||
|
||||
@@ -5,8 +5,8 @@ import { TOURS_MAX_HIKING_DIFFICULTY } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The change cards of the tours an assistant creates and changes: a hike, a
|
||||
* city walk, a change to a saved hike, tours taken in from a GPX file. The
|
||||
* route reads as the names of its points in order, a hike's limit as its SAC
|
||||
* bike tour, a run, a paddle or ski tour, a city walk, a change to a saved
|
||||
* hike, tours taken in from a GPX file. The route reads as the names of its points in order, a hike's limit as its SAC
|
||||
* grade (T1 to T6), a planned time in hours and minutes.
|
||||
*/
|
||||
|
||||
@@ -58,6 +58,17 @@ function gpxName(gpx: unknown): string {
|
||||
return close < 0 ? '' : text(gpx.slice(open + '<name>'.length, close));
|
||||
}
|
||||
|
||||
/** A tour of another type than a hike created from its plan: its points in order, its day, its planned time and words. */
|
||||
function describeSportCreate(ctx: PreviewContext, args: ToolArgs): Described {
|
||||
const day = onDay(ctx, id(args, 'day_id'), args.start_time);
|
||||
const rows = new Rows()
|
||||
.add('route', routeOf(ctx, args.waypoints, args.round_trip))
|
||||
.add('day', day)
|
||||
.add('time', hours(args.planned_duration_minutes))
|
||||
.add('description', text(args.description));
|
||||
return { subject: text(args.name), rows, dayId: day ? id(args, 'day_id') : null, placeIds: [] };
|
||||
}
|
||||
|
||||
export const TOUR_DESCRIBERS = {
|
||||
create_hike: (ctx, args) => {
|
||||
const day = onDay(ctx, id(args, 'day_id'), args.start_time);
|
||||
@@ -69,6 +80,10 @@ export const TOUR_DESCRIBERS = {
|
||||
.add('description', text(args.description));
|
||||
return { subject: text(args.name), rows, dayId: day ? id(args, 'day_id') : null, placeIds: [] };
|
||||
},
|
||||
// A bike tour, a run, a paddle or ski tour: its points and day; its profile and figures are on the route card below.
|
||||
create_tour: describeSportCreate,
|
||||
create_paddle_tour: describeSportCreate,
|
||||
create_ski_tour: describeSportCreate,
|
||||
create_city_walk: (ctx, args) => {
|
||||
const rows = new Rows().add('route', routeOf(ctx, args.stations, false)).add('description', text(args.description));
|
||||
return { subject: text(args.name), rows, dayId: null, placeIds: linkedPlaceIds(ctx, args.stations) };
|
||||
|
||||
@@ -28,10 +28,12 @@ import {
|
||||
type RoutePoint,
|
||||
} from './tour-card-values';
|
||||
import {
|
||||
BIKE_PROFILES,
|
||||
cumulativeMetres,
|
||||
HIKE_UNROUTED_REASONS,
|
||||
legMinutes,
|
||||
nearestAlongM,
|
||||
RUN_PROFILES,
|
||||
TOUR_ROUTE_LIMITS,
|
||||
TOUR_WARNING_KINDS,
|
||||
tourTypeKeySchema,
|
||||
@@ -42,7 +44,8 @@ import {
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The cards of a hike the assistant proposed (propose_hike) and of a saved
|
||||
* The cards of a hike, a bike tour or a run the assistant proposed
|
||||
* (propose_hike, propose_tour) and of a saved
|
||||
* tour it read (get_tour_plan), built from the tool's raw answer. A proposal
|
||||
* draws its line only when the tool added it for drawing (the assistant
|
||||
* always asks), so without `line_geometry` there is no card. A way that was
|
||||
@@ -56,6 +59,35 @@ const SAME_SUMMIT_M = 150;
|
||||
const LOOP_END_M = 60;
|
||||
|
||||
const UNROUTED: ReadonlySet<string> = new Set(HIKE_UNROUTED_REASONS);
|
||||
const BIKE: ReadonlySet<string> = new Set(BIKE_PROFILES);
|
||||
const RUN: ReadonlySet<string> = new Set(RUN_PROFILES);
|
||||
|
||||
/** A planned way's activity on land beyond a hike: a bike tour or a run, with its profile and a run's pace. */
|
||||
type RoutingProfile = NonNullable<TourRouteCard['routingProfile']>;
|
||||
type LandSport = Pick<TourRouteCard, 'activity' | 'routingProfile' | 'paceSecondsPerKm'>;
|
||||
|
||||
/**
|
||||
* The bike tour or run a proposal (propose_tour) or its arguments name, with
|
||||
* its profile (the planner's own where none is named) and a run's pace; null
|
||||
* for a hike, whose card names none of them.
|
||||
*/
|
||||
export function landSportOf(plan: Row, args: Row): LandSport | null {
|
||||
const activity = plan.activity ?? args.activity;
|
||||
if (activity === 'bike') {
|
||||
const profile = plan.bike_profile ?? args.bike_profile;
|
||||
return {
|
||||
activity,
|
||||
routingProfile: typeof profile === 'string' && BIKE.has(profile) ? (profile as RoutingProfile) : 'touring',
|
||||
};
|
||||
}
|
||||
if (activity !== 'run') return null;
|
||||
const profile = plan.run_profile ?? args.run_profile;
|
||||
return {
|
||||
activity,
|
||||
routingProfile: typeof profile === 'string' && RUN.has(profile) ? (profile as RoutingProfile) : 'running',
|
||||
paceSecondsPerKm: whole(plan.run_pace_seconds_per_km ?? args.run_pace_seconds_per_km, 60, 3_600),
|
||||
};
|
||||
}
|
||||
const WARNINGS: ReadonlySet<string> = new Set(TOUR_WARNING_KINDS);
|
||||
|
||||
/** Graded stretches merged where the step repeats, at most the card's share, the hardest kept. */
|
||||
@@ -197,9 +229,11 @@ function unroutedHike(value: Row, args: Row, source: TourRouteCard['source']): T
|
||||
const unreachable = recordOf(value.unreachable);
|
||||
const why = typeof route.why === 'string' && UNROUTED.has(route.why) ? route.why : 'not_planned';
|
||||
const along = straightMetres(points);
|
||||
const sport = landSportOf(value, args);
|
||||
return routeCard({
|
||||
source,
|
||||
activity: 'hike',
|
||||
...sport,
|
||||
name: clipped(value.name ?? args.name, 120),
|
||||
label: clipped(value.variant_label ?? args.variant_label, 40),
|
||||
limit: sacStepOf(value.max_hiking_difficulty ?? args.max_hiking_difficulty),
|
||||
@@ -235,11 +269,16 @@ export function hikeCard(
|
||||
const sac = plan.sac === null || plan.sac === undefined ? null : recordOf(plan.sac);
|
||||
const start = clockOf(plan.start_time);
|
||||
const own = waypointStops(rowsIn(plan.waypoints), start);
|
||||
const basis = plan.duration_basis === 'din33466' || plan.duration_basis === 'router' ? plan.duration_basis : null;
|
||||
const basis =
|
||||
plan.duration_basis === 'din33466' || plan.duration_basis === 'router' || plan.duration_basis === 'pace'
|
||||
? plan.duration_basis
|
||||
: null;
|
||||
const unknown = finite(sac?.unknown_share);
|
||||
const sport = landSportOf(plan, args);
|
||||
return routeCard({
|
||||
source,
|
||||
activity: 'hike',
|
||||
...sport,
|
||||
name: clipped(plan.name, 120),
|
||||
label: clipped(plan.variant_label, 40),
|
||||
distanceM: metresOf(plan.distance_m),
|
||||
@@ -261,7 +300,7 @@ export function hikeCard(
|
||||
});
|
||||
}
|
||||
|
||||
/** propose_hike: the proposal as a card. */
|
||||
/** propose_hike and propose_tour: the proposal as a card, a hike's or a bike tour's or a run's. */
|
||||
export function proposalCard({ value, args, card }: AnswerInput): TourRouteCard | null {
|
||||
const proposal = hikeCard(value, args, card, 'proposal');
|
||||
return proposal ? fitted(proposal) : null;
|
||||
@@ -347,6 +386,13 @@ export function savedTourCard({ value }: AnswerInput): TourRouteCard | null {
|
||||
line,
|
||||
stops: sortedStops(stops),
|
||||
stages: whole(tour.stage_count, 2, 60),
|
||||
...savedSportOf(activity.data, tour),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** A saved bike tour's or run's profile and a run's pace, as its card names them; nothing for any other type. */
|
||||
function savedSportOf(activity: TourRouteCard['activity'], tour: Row): Partial<TourRouteCard> {
|
||||
if (activity !== 'bike' && activity !== 'run') return {};
|
||||
return landSportOf({ activity, ...tour }, {}) ?? {};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { CardReader, PreviewContext, ToolArgs } from '../card-types';
|
||||
import { recordOf, type Row } from '../card-values';
|
||||
import { argText, fitted, latLngOf, rowsIn } from './tour-card-values';
|
||||
import { straightCard } from './tour-change.cards';
|
||||
import { paddleCard, skiCard } from './tour-sport.cards';
|
||||
import type { TourRouteCard } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The change cards of a paddle or ski tour the assistant saves from its plan
|
||||
* (create_paddle_tour, create_ski_tour): the legs between its points planned
|
||||
* again as the plan planned them, drawn as the plan's card was, waiting, under
|
||||
* the name the tour is saved with. Where the plan cannot be read now, the
|
||||
* points are drawn straight and the way is planned when the change is applied.
|
||||
*/
|
||||
|
||||
/** What a held paddle or ski tour looked up: the plan of its legs, null where it could not be read. */
|
||||
export interface SportLoaded {
|
||||
plan: unknown;
|
||||
}
|
||||
|
||||
/** The legs a tour's points make, each from one to the next, as the planning tools take them. */
|
||||
export function legArgsOf(
|
||||
args: ToolArgs,
|
||||
): Array<{ from: { lat: number; lng: number }; to: { lat: number; lng: number } }> {
|
||||
const points = rowsIn(args.waypoints).flatMap((point) => latLngOf(point) ?? []);
|
||||
return points.slice(1).map((to, index) => ({ from: points[index] ?? to, to }));
|
||||
}
|
||||
|
||||
/** The fields of a create a plan was asked with besides its legs, as the planning tool names them. */
|
||||
function planArgs(args: ToolArgs, fields: Readonly<Record<string, string>>): ToolArgs {
|
||||
const plan: ToolArgs = { legs: legArgsOf(args) };
|
||||
for (const [from, to] of Object.entries(fields)) if (args[from] !== undefined) plan[to] = args[from];
|
||||
return plan;
|
||||
}
|
||||
|
||||
/** create_paddle_tour: its legs planned on the water as plan_paddle_route planned them. */
|
||||
export async function loadCreatePaddle(args: ToolArgs, read: CardReader): Promise<SportLoaded | null> {
|
||||
const plan = planArgs(args, { boat_type: 'boat', lake_style: 'lake_style', lock_policy: 'lock_policy' });
|
||||
if (rowsIn(plan.legs).length === 0) return null;
|
||||
return { plan: await read('plan_paddle_route', plan) };
|
||||
}
|
||||
|
||||
/** create_ski_tour: its legs planned on the piste data as plan_ski_route planned them. */
|
||||
export async function loadCreateSki(args: ToolArgs, read: CardReader): Promise<SportLoaded | null> {
|
||||
const plan = planArgs(args, { discipline: 'discipline', technique: 'technique', options: 'options' });
|
||||
if (rowsIn(plan.legs).length === 0) return null;
|
||||
return { plan: await read('plan_ski_route', plan) };
|
||||
}
|
||||
|
||||
/** A plan's card, waiting under the name the tour is saved with; else the points straight. */
|
||||
function heldCard(
|
||||
ctx: PreviewContext,
|
||||
args: ToolArgs,
|
||||
loaded: SportLoaded | null,
|
||||
activity: 'kayak' | 'ski',
|
||||
): TourRouteCard | null {
|
||||
const name = argText(args.name, 120);
|
||||
const input = { toolCallId: '', args: { legs: legArgsOf(args) } as Row, card: ctx.card, today: ctx.today };
|
||||
const value = loaded ? recordOf(loaded.plan) : null;
|
||||
const drawn = value ? (activity === 'kayak' ? paddleCard : skiCard)({ ...input, value }) : null;
|
||||
if (drawn && drawn.warnings.every((warning) => warning.kind !== 'legs_failed')) {
|
||||
return fitted({ ...drawn, source: 'pending', name });
|
||||
}
|
||||
const straight = straightCard(rowsIn(args.waypoints), activity, { name });
|
||||
return straight ? fitted(straight) : null;
|
||||
}
|
||||
|
||||
export function createPaddleCard(
|
||||
ctx: PreviewContext,
|
||||
args: ToolArgs,
|
||||
loaded: SportLoaded | null,
|
||||
): TourRouteCard | null {
|
||||
return heldCard(ctx, args, loaded, 'kayak');
|
||||
}
|
||||
|
||||
export function createSkiCard(ctx: PreviewContext, args: ToolArgs, loaded: SportLoaded | null): TourRouteCard | null {
|
||||
return heldCard(ctx, args, loaded, 'ski');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -101,14 +101,16 @@ export interface RouteOptions {
|
||||
*/
|
||||
footOnly?: boolean;
|
||||
/**
|
||||
* A hike's own pedestrian costing (its hiking limit and preferences, as
|
||||
* `hikingCosting` builds them), sent to Valhalla as the planner in the
|
||||
* browser sends it. Only Valhalla knows a hiking limit: OSRM's foot profile
|
||||
* takes a T5 ridge as readily as a forest road. So a walk asked with one is
|
||||
* asked of Valhalla alone and fails rather than fall back to OSRM, with a
|
||||
* `RouteRefusal` of `no_valhalla` where there is no Valhalla to ask.
|
||||
* A tour's own costing, sent to Valhalla as the planner in the browser
|
||||
* sends it: on foot a hike's (its hiking limit and preferences, as
|
||||
* `hikingCosting` builds them) or a run's (`runCosting`), by bike the
|
||||
* bicycle costing of its profile (`bikeCosting`). Only Valhalla knows these:
|
||||
* OSRM's foot profile takes a T5 ridge as readily as a forest road, and its
|
||||
* bike profile knows no road bike from a mountain bike. So a way asked with
|
||||
* one is asked of Valhalla alone and fails rather than fall back to OSRM,
|
||||
* with a `RouteRefusal` of `no_valhalla` where there is no Valhalla to ask.
|
||||
*/
|
||||
costingOptions?: Readonly<Record<string, number>>;
|
||||
costingOptions?: Readonly<Record<string, number | string>>;
|
||||
}
|
||||
|
||||
export type { TracedWay };
|
||||
@@ -304,7 +306,8 @@ export class RoadtripRouterService {
|
||||
): Promise<RoadtripRoute> {
|
||||
const settings = await this.settings.getUserSettings(userId);
|
||||
const footOnly = options.footOnly === true && profile === 'walking';
|
||||
const hiking = profile === 'walking' ? options.costingOptions : undefined;
|
||||
// A tour's own costing goes with a way on foot or by bike; a drive has its avoidances instead.
|
||||
const hiking = profile === 'walking' || profile === 'cycling' ? options.costingOptions : undefined;
|
||||
const key = JSON.stringify([
|
||||
userId,
|
||||
tripId,
|
||||
@@ -348,8 +351,11 @@ export class RoadtripRouterService {
|
||||
const { base, valhalla } = routersOf(settings);
|
||||
let weighted = false;
|
||||
if (hiking) {
|
||||
if (!valhalla) throw new RouteRefusal('no_valhalla', 'No Valhalla is set to plan a hike with');
|
||||
const legs = await this.valhallaLegs(valhalla, points, 'pedestrian', { ...hiking });
|
||||
const cycling = mode === 'cycling';
|
||||
if (!valhalla) {
|
||||
throw new RouteRefusal('no_valhalla', `No Valhalla is set to plan a ${cycling ? 'bike tour' : 'hike'} with`);
|
||||
}
|
||||
const legs = await this.valhallaLegs(valhalla, points, cycling ? 'bicycle' : 'pedestrian', { ...hiking });
|
||||
({ line, distance, duration, parts, snapped } = routeOfLegs(points, legs));
|
||||
weighted = true;
|
||||
} else if (footOnly && valhalla) {
|
||||
@@ -473,8 +479,8 @@ export class RoadtripRouterService {
|
||||
private async valhallaLegs(
|
||||
valhalla: string,
|
||||
points: Point[],
|
||||
costing: 'auto' | 'pedestrian',
|
||||
options: Record<string, number>,
|
||||
costing: 'auto' | 'pedestrian' | 'bicycle',
|
||||
options: Record<string, number | string>,
|
||||
offBoatLines = false,
|
||||
): Promise<ValhallaLegs> {
|
||||
const legs: ValhallaLegs = [];
|
||||
|
||||
@@ -38,5 +38,7 @@ function routerRunner() {
|
||||
TrekPlacesWinterService,
|
||||
{ provide: SKI_ROUTER_RUNNER, useFactory: routerRunner },
|
||||
],
|
||||
// A ski tour the assistant planned is planned again when it is saved (ToursModule).
|
||||
exports: [SkiRoutingService],
|
||||
})
|
||||
export class SkiRoutingModule {}
|
||||
|
||||
@@ -321,6 +321,19 @@ export interface HikeProposalContext {
|
||||
along: HikeAlongPlace[] | null;
|
||||
/** Null where none was asked or it could not be had. */
|
||||
weather: WeatherResult | null;
|
||||
/**
|
||||
* A bike tour's or a run's own time on the move, in minutes, which stands
|
||||
* for the plan's and is shared out over the legs by their length: a ride's
|
||||
* is the router's for its profile, a run's its distance at the pace (null
|
||||
* without one). A hike leaves it out and keeps the signposts' rule.
|
||||
*/
|
||||
movingMinutes?: number | null;
|
||||
}
|
||||
|
||||
/** The minutes from the start to each point, the whole time shared out by how far along the way each lies. */
|
||||
function minutesByLength(positions: readonly number[], total: number): number[] {
|
||||
const length = positions.at(-1) ?? 0;
|
||||
return positions.map((at) => (length > 0 ? Math.round((total * at) / length) : 0));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -341,16 +354,21 @@ export function hikeProposalOf(
|
||||
const routed = plan.kind === 'routed' ? plan : null;
|
||||
const positions = routed ? waypointPositionsOf(planned, points) : null;
|
||||
const heights = routed?.has_heights && line ? heightRangeOf(line) : null;
|
||||
const minutes = routed && line && positions ? waypointMinutesOf(line, positions, routed.duration_minutes) : null;
|
||||
const own = context.movingMinutes !== undefined;
|
||||
const total = routed ? (own ? (context.movingMinutes ?? null) : routed.duration_minutes) : null;
|
||||
let minutes: number[] | null = null;
|
||||
if (line && positions && total !== null) {
|
||||
minutes = own ? minutesByLength(positions, total) : waypointMinutesOf(line, positions, total);
|
||||
}
|
||||
const arrivals = arrivalsOf(request.start_time ?? null, minutes ?? points.map(() => null));
|
||||
// One day's sun and hours say nothing about a hike over several days.
|
||||
const day =
|
||||
routed && context.date && hikeStagesOf(points) === 1
|
||||
total !== null && context.date && hikeStagesOf(points) === 1
|
||||
? hikeScheduleOf({
|
||||
date: context.date,
|
||||
dayId: context.dayId,
|
||||
start: request.start_time ?? null,
|
||||
minutes: routed.duration_minutes,
|
||||
minutes: total,
|
||||
weather: context.weather,
|
||||
})
|
||||
: null;
|
||||
@@ -362,7 +380,7 @@ export function hikeProposalOf(
|
||||
distance_m: routed?.distance_m ?? null,
|
||||
ascent_m: routed?.ascent_m ?? null,
|
||||
descent_m: routed?.descent_m ?? null,
|
||||
duration_minutes: routed?.duration_minutes ?? null,
|
||||
duration_minutes: total,
|
||||
duration_basis: routed?.duration_basis ?? null,
|
||||
heights_known: heights !== null,
|
||||
min_ele_m: heights?.min ?? null,
|
||||
|
||||
@@ -17,8 +17,20 @@ import {
|
||||
weatherPointOf,
|
||||
type KeptWay,
|
||||
} from './hike-plan.helpers';
|
||||
import { HikeRoutingService, type PlannedHike } from './hike-routing.service';
|
||||
import { HikeRoutingService, type PlannedActivity, type PlannedHike } from './hike-routing.service';
|
||||
import { noTrackFound } from './tour-file-import.helpers';
|
||||
import {
|
||||
ALONG_CATEGORIES,
|
||||
asHikeCreate,
|
||||
asHikeProposal,
|
||||
checkedRequest,
|
||||
movingMinutesOf,
|
||||
plannedActivityOf,
|
||||
refusesSave,
|
||||
tourCreateCommandOf,
|
||||
tourProposalOf,
|
||||
unplannedTourMessage,
|
||||
} from './tour-plan.helpers';
|
||||
import { TourPoiSearchService } from './tour-poi-search.service';
|
||||
import { tourRevisionConflict } from './tour-revision';
|
||||
import { thinnedLine } from './tours.helpers';
|
||||
@@ -26,13 +38,14 @@ import { ToursService, type ImportGpxAsTourResult, type TourCreateCommand } from
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
HIKE_ALONG_CATEGORIES,
|
||||
HIKE_ALONG_CORRIDOR_M,
|
||||
HIKE_PLAN_MAX_WAYPOINTS,
|
||||
hikeStageIssues,
|
||||
hikingPreferencesOf,
|
||||
TOUR_POI_MAX_LINE_POINTS,
|
||||
tourGpxImportFieldsSchema,
|
||||
tourPlanCreateRequestSchema,
|
||||
tourProposeRequestSchema,
|
||||
TOURS_MAX_HIKING_DIFFICULTY,
|
||||
type GeometryPoint,
|
||||
type HikeAlongPlace,
|
||||
@@ -45,6 +58,9 @@ import {
|
||||
type TourCreateResponse,
|
||||
type TourDetailResponse,
|
||||
type TourGpxImportRequest,
|
||||
type TourPlanCreateRequest,
|
||||
type TourProposal,
|
||||
type TourProposeRequest,
|
||||
type HikeRouteOutcome,
|
||||
type TourUpdateRequest,
|
||||
type TourWaypoint,
|
||||
@@ -157,6 +173,7 @@ export class HikePlanService {
|
||||
limit: number | undefined,
|
||||
preferences: HikingPreferences | null | undefined,
|
||||
grades: boolean,
|
||||
activity: PlannedActivity | null = null,
|
||||
): Promise<PlannedHike> {
|
||||
// The router takes the points only: where a stage ends is the hike's, not the way's.
|
||||
const waypoints = points.map(({ lat, lng, name, poi_category }) => ({
|
||||
@@ -169,7 +186,8 @@ export class HikePlanService {
|
||||
userId,
|
||||
tripId,
|
||||
{ waypoints, max_hiking_difficulty: limit, hiking_preferences: preferences },
|
||||
{ grades },
|
||||
// A hike asks as it always has; a ride or a run names its activity and reads no grades.
|
||||
activity ? { grades: false, activity } : { grades },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -181,17 +199,41 @@ export class HikePlanService {
|
||||
* best effort: without them the proposal still answers.
|
||||
*/
|
||||
async propose(userId: number, tripId: number, request: HikeProposeRequest): Promise<HikeProposal> {
|
||||
return await this.proposed(userId, tripId, request, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* A bike tour or a run proposed the way a hike is: the same points, day,
|
||||
* places along the way and weather, planned for its profile and timed by
|
||||
* its own time (a ride the router's, a run its distance at the pace).
|
||||
*/
|
||||
async proposeTour(userId: number, tripId: number, request: TourProposeRequest): Promise<TourProposal> {
|
||||
const asked = checkedRequest<TourProposeRequest>(tourProposeRequestSchema, request);
|
||||
const activity = plannedActivityOf(asked);
|
||||
const base = await this.proposed(userId, tripId, asHikeProposal(asked), activity, asked.run_pace_seconds_per_km);
|
||||
return tourProposalOf(base, asked, activity);
|
||||
}
|
||||
|
||||
/** The proposal of a hike, or with `activity` of a ride or a run, timed by its own time. */
|
||||
private async proposed(
|
||||
userId: number,
|
||||
tripId: number,
|
||||
request: HikeProposeRequest,
|
||||
activity: PlannedActivity | null,
|
||||
pace: number | null | undefined,
|
||||
): Promise<HikeProposal> {
|
||||
refuseBadStages(request.waypoints, request.round_trip);
|
||||
const day = await this.dayOf(tripId, request.day_id, request.date);
|
||||
const points = hikePointsOf(request.waypoints, request.round_trip);
|
||||
const { max_hiking_difficulty: limit, hiking_preferences: preferences } = request;
|
||||
const planned = await this.plan(userId, tripId, points, limit, preferences, true);
|
||||
const planned = await this.plan(userId, tripId, points, limit, preferences, activity === null, activity);
|
||||
const line = planned.plan.kind === 'routed' ? planned.line : null;
|
||||
const [along, weather] = await Promise.all([
|
||||
line && request.along !== false ? this.alongOf(line) : null,
|
||||
line && request.along !== false ? this.alongOf(line, activity?.activity ?? 'hike') : null,
|
||||
line && day.date ? this.weatherOf(weatherPointOf(points, planned), day.date) : null,
|
||||
]);
|
||||
return hikeProposalOf(request, points, planned, { ...day, along, weather });
|
||||
const timed = activity ? { movingMinutes: movingMinutesOf(activity, pace, planned) } : {};
|
||||
return hikeProposalOf(request, points, planned, { ...day, along, weather, ...timed });
|
||||
}
|
||||
|
||||
/** The trip's day a hike is planned on and its date, or the date given without a day. */
|
||||
@@ -206,17 +248,24 @@ export class HikePlanService {
|
||||
return { dayId: day.id, date: day.date };
|
||||
}
|
||||
|
||||
/** Summits, huts, water and viewpoints near the way, or null where the search could not be made. */
|
||||
private async alongOf(line: readonly GeometryPoint[]): Promise<HikeAlongPlace[] | null> {
|
||||
/**
|
||||
* The places near the way the activity stops for (summits, huts, water and
|
||||
* viewpoints on a hike), or null where the search could not be made.
|
||||
*/
|
||||
private async alongOf(
|
||||
line: readonly GeometryPoint[],
|
||||
type: keyof typeof ALONG_CATEGORIES,
|
||||
): Promise<HikeAlongPlace[] | null> {
|
||||
const categories = ALONG_CATEGORIES[type];
|
||||
try {
|
||||
const found = await this.poiSearch.search({
|
||||
tour_type: 'hike',
|
||||
categories: [...HIKE_ALONG_CATEGORIES],
|
||||
tour_type: type,
|
||||
categories: [...categories],
|
||||
corridor_m: HIKE_ALONG_CORRIDOR_M,
|
||||
line: thinnedLine(line, TOUR_POI_MAX_LINE_POINTS).map(([lat, lng]): [number, number] => [lat, lng]),
|
||||
});
|
||||
// Not one kind could be searched: nothing found would read as nothing there.
|
||||
if (found.unavailable.length === HIKE_ALONG_CATEGORIES.length) return null;
|
||||
if (found.unavailable.length === categories.length) return null;
|
||||
return alongPlacesOf(found.pois, line);
|
||||
} catch (err: unknown) {
|
||||
logWarn(`[Hike] no places along the way: ${reasonOf(err)}`);
|
||||
@@ -258,17 +307,53 @@ export class HikePlanService {
|
||||
throw notRouted(unplannedCreateMessage(way.route));
|
||||
}
|
||||
const command = hikeCreateCommandOf(request, points, way, planned.plan.max_hiking_difficulty);
|
||||
if (day?.dayId == null) {
|
||||
return await this.saveOnDay(tripId, command, day?.dayId ?? null, request.start_time, way.route);
|
||||
}
|
||||
|
||||
/**
|
||||
* A bike tour or a run created the way a hike is (create): planned again
|
||||
* through its points for its profile, saved with its line and heights, its
|
||||
* time (a ride the router's, a run its distance at the pace), its profile
|
||||
* and pace, put on its day in the same write. A router that did not answer
|
||||
* saves its points only; any other reason refuses it, and nothing is saved.
|
||||
*/
|
||||
async createTour(userId: number, tripId: number, request: TourPlanCreateRequest): Promise<HikeCreated> {
|
||||
const asked = checkedRequest<TourPlanCreateRequest>(tourPlanCreateRequestSchema, request);
|
||||
const activity = plannedActivityOf(asked);
|
||||
const hike = asHikeCreate(asked);
|
||||
const day = hike.day_id === undefined ? null : await this.dayOf(tripId, hike.day_id, undefined);
|
||||
const points = hikePointsOf(hike.waypoints, hike.round_trip);
|
||||
const planned = await this.plan(userId, tripId, points, undefined, undefined, false, activity);
|
||||
const way = keptWayOf(planned);
|
||||
if (refusesSave(way.route)) throw notRouted(unplannedTourMessage(way.route, activity.activity));
|
||||
const minutes = movingMinutesOf(activity, asked.run_pace_seconds_per_km, planned);
|
||||
const command = tourCreateCommandOf(asked, points, way, activity, minutes);
|
||||
return await this.saveOnDay(tripId, command, day?.dayId ?? null, hike.start_time, way.route);
|
||||
}
|
||||
|
||||
/**
|
||||
* A planned tour saved through the creation the planner's save uses. With
|
||||
* a day it is put on that day in the same write, its start kept as the
|
||||
* visit's, and the trip is told of the tour and the visit once both are
|
||||
* saved, as the planner's save and its drop onto the day tell it.
|
||||
*/
|
||||
async saveOnDay(
|
||||
tripId: number,
|
||||
command: TourCreateCommand,
|
||||
dayId: number | null,
|
||||
startTime: string | undefined,
|
||||
route: HikeRouteOutcome,
|
||||
): Promise<HikeCreated> {
|
||||
if (dayId === null) {
|
||||
const created = await this.tours.createTour(String(tripId), command);
|
||||
return { ...created, route: way.route, assignment: null };
|
||||
return { ...created, route, assignment: null };
|
||||
}
|
||||
const dayId = day.dayId;
|
||||
const written = await this.uow.transactional(async () => {
|
||||
const placeId = await this.tours.insertTour(tripId, command);
|
||||
const added = await this.assignments.createAssignment(dayId, placeId, null);
|
||||
if (!added) throw new Error('Created visit could not be loaded');
|
||||
if (!request.start_time) return { placeId, assignment: added, reordered: null, vias: null };
|
||||
const timed = await this.assignments.updateTime(added.id, request.start_time, null);
|
||||
if (!startTime) return { placeId, assignment: added, reordered: null, vias: null };
|
||||
const timed = await this.assignments.updateTime(added.id, startTime, null);
|
||||
return { placeId, ...timed, assignment: timed.assignment ?? added };
|
||||
});
|
||||
const trip = String(tripId);
|
||||
@@ -278,7 +363,7 @@ export class HikePlanService {
|
||||
if (written.reordered) this.assignments.broadcast(trip, 'assignment:reordered', written.reordered, undefined);
|
||||
if (written.vias) this.assignments.broadcast(trip, 'roadtripVia:changed', written.vias, undefined);
|
||||
await this.assignments.reconcile(tripId);
|
||||
return { ...created, route: way.route, assignment: { id: written.assignment.id, day_id: dayId } };
|
||||
return { ...created, route, assignment: { id: written.assignment.id, day_id: dayId } };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TourTrailGradeService } from './tour-trail-grade.service';
|
||||
import { thinnedLine } from './tours.helpers';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
bikeCosting,
|
||||
computeTourMetrics,
|
||||
HIKE_MAX_LINE_POINTS,
|
||||
HIKE_PLAN_LINE_POINTS,
|
||||
@@ -14,14 +15,18 @@ import {
|
||||
hikingCosting,
|
||||
hikingPreferencesOf,
|
||||
hikingTimeMinutes,
|
||||
runCosting,
|
||||
sacStepOf,
|
||||
TOUR_MAX_SNAP_M,
|
||||
TOUR_TRAIL_GRADE_MAX_WAYS,
|
||||
TOURS_MAX_HIKING_DIFFICULTY,
|
||||
type BikeProfile,
|
||||
type GeometryPoint,
|
||||
type HikePlan,
|
||||
type HikePlanRequest,
|
||||
type HikingPreferences,
|
||||
type HikeTrailGrades,
|
||||
type RunProfile,
|
||||
type SacStep,
|
||||
type UnroutedHikePlan,
|
||||
} from '@trek/shared';
|
||||
@@ -79,14 +84,26 @@ interface KeptPlan {
|
||||
planned: PlannedHike;
|
||||
}
|
||||
|
||||
/** The request as the memo keys it: who asked in which trip, the points to about a metre, the limit, the preferences. */
|
||||
function memoKeyOf(userId: number, tripId: number, request: HikePlanRequest): string {
|
||||
/**
|
||||
* A tour planned through the same way as a hike but for another activity: a
|
||||
* bike tour by bike for its profile, a run on foot for its profile. Neither
|
||||
* takes a hiking limit, preferences or SAC grades.
|
||||
*/
|
||||
export type PlannedActivity = { activity: 'bike'; profile: BikeProfile } | { activity: 'run'; profile: RunProfile };
|
||||
|
||||
/**
|
||||
* The request as the memo keys it: who asked in which trip, the points to
|
||||
* about a metre, the limit, the preferences, and for a bike tour or a run its
|
||||
* activity and profile.
|
||||
*/
|
||||
function memoKeyOf(userId: number, tripId: number, request: HikePlanRequest, activity: PlannedActivity | null): string {
|
||||
const points = request.waypoints.map(({ lat, lng }) => [
|
||||
Number(lat.toFixed(PLAN_MEMO_DECIMALS)),
|
||||
Number(lng.toFixed(PLAN_MEMO_DECIMALS)),
|
||||
]);
|
||||
const limit = request.max_hiking_difficulty ?? TOURS_MAX_HIKING_DIFFICULTY;
|
||||
return JSON.stringify([userId, tripId, points, limit, hikingPreferencesOf(request.hiking_preferences)]);
|
||||
const key = [userId, tripId, points, limit, hikingPreferencesOf(request.hiking_preferences)];
|
||||
return JSON.stringify(activity ? [...key, activity.activity, activity.profile] : key);
|
||||
}
|
||||
|
||||
/** What a plan asks for besides the way and its heights. */
|
||||
@@ -96,6 +113,27 @@ export interface HikePlanOptions {
|
||||
* trail grades): a proposal shows them, a hike being saved keeps none.
|
||||
*/
|
||||
grades: boolean;
|
||||
/**
|
||||
* A bike tour or a run, planned with its own costing and timed by the
|
||||
* router; a hike when left out. Its limit and preferences are a hike's and
|
||||
* go unread.
|
||||
*/
|
||||
activity?: PlannedActivity;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a way is asked of the router: on foot with a hike's limit and
|
||||
* preferences, by bike with its profile's bicycle costing, on foot with a
|
||||
* run's pedestrian costing.
|
||||
*/
|
||||
function costingOf(
|
||||
activity: PlannedActivity | null,
|
||||
limit: number,
|
||||
preferences: HikingPreferences | null,
|
||||
): ['walking' | 'cycling', Readonly<Record<string, number | string>>] {
|
||||
if (activity?.activity === 'bike') return ['cycling', bikeCosting(activity.profile)];
|
||||
if (activity?.activity === 'run') return ['walking', runCosting(activity.profile)];
|
||||
return ['walking', hikingCosting(limit, preferences)];
|
||||
}
|
||||
|
||||
/** What went wrong, for the log. */
|
||||
@@ -139,14 +177,16 @@ export class HikeRoutingService {
|
||||
request: HikePlanRequest,
|
||||
options?: HikePlanOptions,
|
||||
): Promise<PlannedHike> {
|
||||
const withGrades = options?.grades ?? true;
|
||||
const activity = options?.activity ?? null;
|
||||
// SAC grades are a hike's: a bike tour or a run never reads them.
|
||||
const withGrades = activity === null && (options?.grades ?? true);
|
||||
// The tools check their own requests; a change plans through the points a hike has saved, held here to the same.
|
||||
const checked = hikePlanRequestSchema.safeParse(request);
|
||||
if (!checked.success) throw badRequest(checked.error.issues.map((issue) => issue.message).join('; '));
|
||||
const key = memoKeyOf(userId, tripId, request);
|
||||
const key = memoKeyOf(userId, tripId, request, activity);
|
||||
const kept = this.recall(key, withGrades);
|
||||
if (kept) return kept;
|
||||
const planned = await this.planned(userId, tripId, request, withGrades);
|
||||
const planned = await this.planned(userId, tripId, request, withGrades, activity);
|
||||
// Only a way with every height is kept: one without is asked again, and its heights with it.
|
||||
if (planned.plan.kind === 'routed' && planned.plan.has_heights && planned.line) {
|
||||
const grades = withGrades && planned.plan.grades !== null;
|
||||
@@ -193,6 +233,7 @@ export class HikeRoutingService {
|
||||
tripId: number,
|
||||
request: HikePlanRequest,
|
||||
withGrades: boolean,
|
||||
activity: PlannedActivity | null,
|
||||
): Promise<PlannedHike> {
|
||||
const limit = request.max_hiking_difficulty ?? TOURS_MAX_HIKING_DIFFICULTY;
|
||||
const preferences = hikingPreferencesOf(request.hiking_preferences);
|
||||
@@ -205,8 +246,8 @@ export class HikeRoutingService {
|
||||
let route: RoadtripRoute;
|
||||
try {
|
||||
const points = request.waypoints.map(({ lat, lng }) => ({ lat, lng }));
|
||||
const costingOptions = hikingCosting(limit, preferences);
|
||||
route = await this.router.route(userId, tripId, ANY_DAY, points, 'walking', [], { costingOptions });
|
||||
const [mode, costingOptions] = costingOf(activity, limit, preferences);
|
||||
route = await this.router.route(userId, tripId, ANY_DAY, points, mode, [], { costingOptions });
|
||||
} catch (err: unknown) {
|
||||
if (!(err instanceof RouteRefusal)) logWarn(`[Hike] no way for trip ${tripId}: ${reasonOf(err)}`);
|
||||
return unrouted(whyNot(err));
|
||||
@@ -226,6 +267,8 @@ export class HikeRoutingService {
|
||||
const ascent = metrics.elevationGainM;
|
||||
const descent = metrics.elevationLossM;
|
||||
const heights = ascent !== null && descent !== null;
|
||||
// The signposts' rule is a walker's: a ride keeps the router's time for its profile, and a run's pace is set later.
|
||||
const byRule = heights && activity === null;
|
||||
return {
|
||||
plan: {
|
||||
kind: 'routed',
|
||||
@@ -234,10 +277,10 @@ export class HikeRoutingService {
|
||||
distance_m: Math.round(lengthM),
|
||||
ascent_m: heights ? Math.round(ascent) : null,
|
||||
descent_m: heights ? Math.round(descent) : null,
|
||||
duration_minutes: heights
|
||||
duration_minutes: byRule
|
||||
? hikingTimeMinutes(metrics.distanceKm, ascent, descent)
|
||||
: Math.round(route.leg.seg.duration / 60),
|
||||
duration_basis: heights ? 'din33466' : 'router',
|
||||
duration_basis: byRule ? 'din33466' : 'router',
|
||||
has_heights: heights,
|
||||
line: thinnedLine(line, HIKE_PLAN_LINE_POINTS),
|
||||
profile: profileOf(line, HIKE_PLAN_PROFILE_SAMPLES),
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { TourCreateCommand } from './tours.service';
|
||||
import {
|
||||
cumulativeMetres,
|
||||
estimatePaddleTime,
|
||||
paddleLayoutOfTrack,
|
||||
paddleMovingSeconds,
|
||||
recommendedPaddleSpeedKmh,
|
||||
skiOptionsForDiscipline,
|
||||
type GeometryPoint,
|
||||
type PaddleRouteLeg,
|
||||
type PaddleTourCreateRequest,
|
||||
type SkiRouteLeg,
|
||||
type SkiTourCreateRequest,
|
||||
type SportTourPoint,
|
||||
type TourWaypoint,
|
||||
type TrackMarks,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The pure halves of a paddle or ski tour saved from a plan
|
||||
* (SportTourCreateService): the legs between its points, the portages and
|
||||
* locks the paddle router found laid along the whole line, the joined line of
|
||||
* a ski tour with its time, and the write each one makes, as the planner's
|
||||
* save writes it.
|
||||
*/
|
||||
|
||||
/** What a save keeps of a plan: the whole line with its heights (null without them) and a paddle tour's marks. */
|
||||
export interface SportPlanned {
|
||||
line: GeometryPoint[] | null;
|
||||
marks: TrackMarks | null;
|
||||
}
|
||||
|
||||
/** The legs a tour's points make, each from one point to the next. */
|
||||
export function legsOf(points: readonly SportTourPoint[]): Array<{ from: SportTourPoint; to: SportTourPoint }> {
|
||||
return points.slice(1).map((to, index) => ({ from: points[index] as SportTourPoint, to }));
|
||||
}
|
||||
|
||||
type PlannedPaddleLeg = Extract<PaddleRouteLeg, { status: 'ok' }>;
|
||||
|
||||
/**
|
||||
* The portages and locks of the planned legs, in metres along the whole
|
||||
* line as the legs are joined: each leg's stretches placed by its own line,
|
||||
* after the length of the legs before it. A lock has no time of its own
|
||||
* entered, which the estimate names as unknown rather than zero.
|
||||
*/
|
||||
export function paddleMarksOf(legs: readonly PlannedPaddleLeg[]): TrackMarks {
|
||||
const marks: TrackMarks = { portages: [], locks: [] };
|
||||
let offset = 0;
|
||||
for (const leg of legs) {
|
||||
const along = cumulativeMetres(leg.geometry.map(([lat, lng]) => [lat, lng] as const));
|
||||
for (const segment of leg.segments) {
|
||||
const from = offset + (along[segment.from] ?? 0);
|
||||
const to = offset + (along[segment.to] ?? 0);
|
||||
if (segment.kind === 'portage' && to > from) marks.portages.push({ from_m: from, to_m: to, extra_minutes: null });
|
||||
if (segment.kind === 'lock') {
|
||||
marks.locks.push({ at_m: (from + to) / 2, wait_minutes: null, transit_minutes: null });
|
||||
}
|
||||
}
|
||||
offset += along.at(-1) ?? 0;
|
||||
}
|
||||
return marks;
|
||||
}
|
||||
|
||||
/** The length of a line in metres. */
|
||||
function lengthOf(line: readonly GeometryPoint[]): number {
|
||||
return cumulativeMetres(line.map(([lat, lng]) => [lat, lng] as const)).at(-1) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The write of a paddle tour, as the planner's save writes one: its boat and
|
||||
* water, the speeds its time is reckoned with (the boat's starting speed where
|
||||
* none is given) and that time, the moving part of the planner's estimate.
|
||||
*/
|
||||
export function paddleCommandOf(
|
||||
request: PaddleTourCreateRequest,
|
||||
waypoints: TourWaypoint[],
|
||||
planned: SportPlanned,
|
||||
): TourCreateCommand {
|
||||
const boat = request.boat_type ?? 'kayak';
|
||||
const speed = request.paddle_speed_kmh ?? recommendedPaddleSpeedKmh(boat);
|
||||
const portageSpeed = request.portage_speed_kmh ?? null;
|
||||
const estimate = planned.line
|
||||
? estimatePaddleTime(paddleLayoutOfTrack(lengthOf(planned.line), planned.marks), {
|
||||
paddleSpeedKmh: speed,
|
||||
portageSpeedKmh: portageSpeed,
|
||||
breakMinutes: null,
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
tour_type: 'kayak',
|
||||
route_geometry: planned.line,
|
||||
duration_seconds: estimate ? paddleMovingSeconds(estimate) : null,
|
||||
max_hiking_difficulty: null,
|
||||
hiking_preferences: null,
|
||||
boat_type: boat,
|
||||
water_context: request.water_context,
|
||||
paddle_speed_kmh: speed,
|
||||
portage_speed_kmh: portageSpeed,
|
||||
paddle_lake_style: request.lake_style ?? null,
|
||||
...(request.planned_duration_minutes !== undefined
|
||||
? { planned_duration_minutes: request.planned_duration_minutes }
|
||||
: {}),
|
||||
waypoints,
|
||||
};
|
||||
}
|
||||
|
||||
type PlannedSkiLeg = Extract<SkiRouteLeg, { status: 'ok' }>;
|
||||
|
||||
/**
|
||||
* The planned legs of a ski tour as one line, each point with its height
|
||||
* where the piste data has one, a point repeating the one before it left out,
|
||||
* and the piste router's time: skiing, lifts, waiting and walking together.
|
||||
*/
|
||||
export function skiLineOf(legs: readonly PlannedSkiLeg[]): { line: GeometryPoint[]; seconds: number } {
|
||||
const line: GeometryPoint[] = [];
|
||||
for (const [lat, lng, height] of legs.flatMap((leg) => leg.geometry)) {
|
||||
const last = line.at(-1);
|
||||
if (last && last[0] === lat && last[1] === lng) continue;
|
||||
line.push(height === null ? [lat, lng] : [lat, lng, height]);
|
||||
}
|
||||
const seconds = legs.reduce((sum, { seconds: parts }) => sum + parts.ski + parts.lift + parts.wait + parts.walk, 0);
|
||||
return { line, seconds };
|
||||
}
|
||||
|
||||
/**
|
||||
* The write of a ski tour, as the planner's save writes one: its discipline
|
||||
* (and a cross-country technique), the options the piste router was asked
|
||||
* with as the discipline reads them, the build its legs were planned on, every
|
||||
* leg as planned by it, and its time, given only with the line it belongs to.
|
||||
*/
|
||||
export function skiCommandOf(
|
||||
request: SkiTourCreateRequest,
|
||||
waypoints: TourWaypoint[],
|
||||
planned: SportPlanned,
|
||||
plan: { build: string | null; seconds: number; legs: number },
|
||||
): TourCreateCommand {
|
||||
const crossCountry = request.discipline === 'cross_country';
|
||||
return {
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
tour_type: 'ski',
|
||||
route_geometry: planned.line,
|
||||
duration_seconds: planned.line ? Math.round(plan.seconds) : null,
|
||||
max_hiking_difficulty: null,
|
||||
hiking_preferences: null,
|
||||
ski_discipline: request.discipline,
|
||||
ski_technique: crossCountry ? (request.technique ?? null) : null,
|
||||
ski_options: skiOptionsForDiscipline(request.discipline, request.options ?? {}),
|
||||
ski_route_build: plan.build,
|
||||
ski_planned_legs: Array.from({ length: plan.legs }, (_, index) => index),
|
||||
...(request.planned_duration_minutes !== undefined
|
||||
? { planned_duration_minutes: request.planned_duration_minutes }
|
||||
: {}),
|
||||
waypoints,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import type { ToursRepository } from '../../db/repositories/Tours.repository';
|
||||
import { logWarn } from '../audit/audit-log.logger';
|
||||
import { DomainError, notFound } from '../common/domain-error';
|
||||
import { PaddleRoutingService } from '../paddle-routing/paddle-routing.service';
|
||||
import { RoadtripRouterService } from '../roadtrip/roadtrip-router.service';
|
||||
import { SkiRoutingService } from '../ski-routing/ski-routing.service';
|
||||
import { hikeWaypointsOf } from './hike-plan.helpers';
|
||||
import { HikePlanService, type HikeCreated } from './hike-plan.service';
|
||||
import { heightsOrNone, joinedLine } from './hike-routing.helpers';
|
||||
import {
|
||||
legsOf,
|
||||
paddleCommandOf,
|
||||
paddleMarksOf,
|
||||
skiCommandOf,
|
||||
skiLineOf,
|
||||
type SportPlanned,
|
||||
} from './sport-tour-create.helpers';
|
||||
import { checkedRequest } from './tour-plan.helpers';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
paddleRouteRequestSchema,
|
||||
paddleTourCreateRequestSchema,
|
||||
skiRouteRequestSchema,
|
||||
skiTourCreateRequestSchema,
|
||||
type GeometryPoint,
|
||||
type HikeRouteOutcome,
|
||||
type PaddleTourCreateRequest,
|
||||
type SkiTourCreateRequest,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** A leg the router could not plan: the tour is refused, as a 422 the tools answer with its text. */
|
||||
function legRefused(index: number, code: string, where: string): DomainError {
|
||||
return new DomainError(
|
||||
422,
|
||||
`Leg ${index + 1} could not be planned ${where} (${code}): move its points or plan it again. Nothing was saved.`,
|
||||
{ code: 'tour_not_routed' },
|
||||
);
|
||||
}
|
||||
|
||||
function reasonOf(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* A paddle tour or a ski tour the assistant planned (plan_paddle_route,
|
||||
* plan_ski_route) saved once the traveller said yes, the way the planner's
|
||||
* save writes one. Its legs run from each point to the next and are planned
|
||||
* again by the same router, on the water or on the piste data; a leg that
|
||||
* cannot be planned refuses the tour and nothing is saved. The legs are
|
||||
* joined into one line with the height of every point (the piste data's
|
||||
* where it has them all, else Valhalla's, as for a hike); where the heights
|
||||
* cannot be read the tour is saved with its points only (route_pending), and
|
||||
* the planner plans it when it is opened. A paddle tour is timed with the
|
||||
* planner's estimate (water at the paddling speed, each portage the router
|
||||
* carries round at the carrying speed), a ski tour with the piste router's
|
||||
* time. With a day it is put on that day in the same write (HikePlanService's
|
||||
* save, so a tour of any type lands on its day alike).
|
||||
*/
|
||||
@Injectable()
|
||||
export class SportTourCreateService {
|
||||
constructor(
|
||||
private readonly paddle: PaddleRoutingService,
|
||||
private readonly ski: SkiRoutingService,
|
||||
private readonly router: RoadtripRouterService,
|
||||
private readonly hikes: HikePlanService,
|
||||
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
|
||||
) {}
|
||||
|
||||
async createPaddle(userId: number, tripId: number, request: PaddleTourCreateRequest): Promise<HikeCreated> {
|
||||
const asked = checkedRequest<PaddleTourCreateRequest>(paddleTourCreateRequestSchema, request);
|
||||
const dayId = await this.dayOf(tripId, asked.day_id);
|
||||
const planned = await this.paddle.route(
|
||||
paddleRouteRequestSchema.parse({
|
||||
legs: legsOf(asked.waypoints),
|
||||
boat: asked.boat_type ?? 'kayak',
|
||||
lake_style: asked.lake_style,
|
||||
lock_policy: asked.lock_policy,
|
||||
}),
|
||||
);
|
||||
const legs = planned.legs.map((leg, index) => {
|
||||
if (leg.status === 'failed') throw legRefused(index, leg.code, 'on the water');
|
||||
return leg;
|
||||
});
|
||||
const flat = joinedLine(legs.flatMap((leg) => leg.geometry.map(([lat, lng]): [number, number] => [lat, lng])));
|
||||
const line = await this.withHeights(userId, flat);
|
||||
const saved = this.planned(line, paddleMarksOf(legs));
|
||||
return await this.save(
|
||||
tripId,
|
||||
paddleCommandOf(asked, hikeWaypointsOf(asked.waypoints), saved),
|
||||
dayId,
|
||||
asked,
|
||||
saved,
|
||||
);
|
||||
}
|
||||
|
||||
async createSki(userId: number, tripId: number, request: SkiTourCreateRequest): Promise<HikeCreated> {
|
||||
const asked = checkedRequest<SkiTourCreateRequest>(skiTourCreateRequestSchema, request);
|
||||
const dayId = await this.dayOf(tripId, asked.day_id);
|
||||
const planned = await this.ski.route(
|
||||
skiRouteRequestSchema.parse({
|
||||
legs: legsOf(asked.waypoints),
|
||||
discipline: asked.discipline,
|
||||
technique: asked.discipline === 'cross_country' ? (asked.technique ?? null) : null,
|
||||
options: asked.options ?? {},
|
||||
}),
|
||||
);
|
||||
const legs = planned.legs.map((leg, index) => {
|
||||
if (leg.status === 'failed') throw legRefused(index, leg.code, 'on the piste data');
|
||||
return leg;
|
||||
});
|
||||
const { line, seconds } = skiLineOf(legs);
|
||||
// The piste data's heights where it has one for every point, else Valhalla's, as the planner reads them.
|
||||
const measured = line.every((point) => point.length === 3)
|
||||
? line
|
||||
: await this.withHeights(
|
||||
userId,
|
||||
line.map(([lat, lng]): [number, number] => [lat, lng]),
|
||||
);
|
||||
const saved = this.planned(measured, null);
|
||||
const command = skiCommandOf(asked, hikeWaypointsOf(asked.waypoints), saved, {
|
||||
build: planned.build,
|
||||
seconds,
|
||||
legs: legs.length,
|
||||
});
|
||||
return await this.save(tripId, command, dayId, asked, saved);
|
||||
}
|
||||
|
||||
/** The joined line as it is kept: whole with its heights, or none where a height is missing. */
|
||||
private planned(line: GeometryPoint[], marks: SportPlanned['marks']): SportPlanned {
|
||||
const heights = line.length >= 2 && line.every((point) => point.length === 3);
|
||||
return { line: heights ? line : null, marks };
|
||||
}
|
||||
|
||||
/** The line with the height of every point, or as it was where the heights could not all be read. */
|
||||
private async withHeights(userId: number, flat: Array<[number, number]>): Promise<GeometryPoint[]> {
|
||||
try {
|
||||
return heightsOrNone(flat, await this.router.heights(userId, flat));
|
||||
} catch (err: unknown) {
|
||||
logWarn(`[SportTour] no heights: ${reasonOf(err)}`);
|
||||
return heightsOrNone(flat, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** The trip's day a tour is put on; a day of another trip is not found. */
|
||||
private async dayOf(tripId: number, dayId: number | undefined): Promise<number | null> {
|
||||
if (dayId === undefined) return null;
|
||||
const day = await this.toursRepo.dayOfTrip(tripId, dayId);
|
||||
if (!day) throw notFound('Day not found');
|
||||
return day.id;
|
||||
}
|
||||
|
||||
private async save(
|
||||
tripId: number,
|
||||
command: Parameters<HikePlanService['saveOnDay']>[1],
|
||||
dayId: number | null,
|
||||
asked: { start_time?: string },
|
||||
saved: SportPlanned,
|
||||
): Promise<HikeCreated> {
|
||||
// Without its heights the tour waits for its way, as a hike does when the router did not answer.
|
||||
const route: HikeRouteOutcome = saved.line
|
||||
? { planned: true }
|
||||
: { planned: false, why: 'router_unavailable', position: null };
|
||||
return await this.hikes.saveOnDay(tripId, command, dayId, asked.start_time, route);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { badRequest } from '../common/domain-error';
|
||||
import { hikeWaypointsOf, type KeptWay } from './hike-plan.helpers';
|
||||
import type { PlannedActivity, PlannedHike } from './hike-routing.service';
|
||||
import type { TourCreateCommand } from './tours.service';
|
||||
import {
|
||||
HIKE_ALONG_CATEGORIES,
|
||||
runMinutesAtPace,
|
||||
TOUR_MAX_SNAP_M,
|
||||
type HikeCreateRequest,
|
||||
type HikeProposal,
|
||||
type HikeProposeRequest,
|
||||
type HikeRouteOutcome,
|
||||
type HikeStop,
|
||||
type TourPlanActivity,
|
||||
type TourPlanCreateRequest,
|
||||
type TourPoiCategory,
|
||||
type TourProposal,
|
||||
type TourProposeRequest,
|
||||
type UnroutedHikePlan,
|
||||
} from '@trek/shared';
|
||||
|
||||
import type { z } from 'zod';
|
||||
|
||||
/**
|
||||
* The pure halves of a bike tour or a run an assistant plans over MCP
|
||||
* (propose_tour, create_tour), on top of a hike's (hike-plan.helpers): the
|
||||
* profile it is planned for, the places looked for along it, its time on the
|
||||
* move, the proposal it answers and the write it makes. One plan and one save
|
||||
* for every activity; only these differ.
|
||||
*/
|
||||
|
||||
/** The activity and profile a request plans for, the planner's own profile where none is named. */
|
||||
export function plannedActivityOf(request: {
|
||||
activity: TourPlanActivity;
|
||||
bike_profile?: PlannedActivity['profile'];
|
||||
run_profile?: PlannedActivity['profile'];
|
||||
}): PlannedActivity {
|
||||
if (request.activity === 'bike') {
|
||||
const profile = request.bike_profile;
|
||||
return {
|
||||
activity: 'bike',
|
||||
profile: profile === 'road' || profile === 'gravel' || profile === 'mtb' ? profile : 'touring',
|
||||
};
|
||||
}
|
||||
return { activity: 'run', profile: request.run_profile === 'trail' ? 'trail' : 'running' };
|
||||
}
|
||||
|
||||
/** What a proposal looks for along the way, by activity: a hike its summits and huts, a ride and a run what they stop for. */
|
||||
export const ALONG_CATEGORIES: Readonly<Record<'hike' | TourPlanActivity, readonly TourPoiCategory[]>> = {
|
||||
hike: HIKE_ALONG_CATEGORIES,
|
||||
bike: ['drinking_water', 'bike_repair', 'food', 'viewpoint'],
|
||||
run: ['drinking_water', 'toilets', 'viewpoint', 'park'],
|
||||
};
|
||||
|
||||
/** A request of propose_tour as a hike's proposal reads it: the same points, day and words, no hiking limit. */
|
||||
export function asHikeProposal({
|
||||
activity: _activity,
|
||||
bike_profile: _bike,
|
||||
run_profile: _run,
|
||||
run_pace_seconds_per_km: _pace,
|
||||
...request
|
||||
}: TourProposeRequest): HikeProposeRequest {
|
||||
return request;
|
||||
}
|
||||
|
||||
/** A request of create_tour as a hike's create reads it: the same points, day, words and planned time. */
|
||||
export function asHikeCreate({
|
||||
activity: _activity,
|
||||
bike_profile: _bike,
|
||||
run_profile: _run,
|
||||
run_pace_seconds_per_km: _pace,
|
||||
...request
|
||||
}: TourPlanCreateRequest): HikeCreateRequest {
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bike tour's or a run's time on the move, in whole minutes: a ride the
|
||||
* router's for its profile, a run its distance at the runner's pace and
|
||||
* nothing else (null without a pace, never the router's walking time). Null
|
||||
* where no way was planned.
|
||||
*/
|
||||
export function movingMinutesOf(
|
||||
activity: PlannedActivity,
|
||||
pace: number | null | undefined,
|
||||
{ plan }: PlannedHike,
|
||||
): number | null {
|
||||
if (plan.kind !== 'routed') return null;
|
||||
if (activity.activity === 'bike') return plan.duration_minutes;
|
||||
return pace == null ? null : runMinutesAtPace(plan.distance_m / 1000, pace);
|
||||
}
|
||||
|
||||
/**
|
||||
* The proposal of a bike tour or a run, from a hike's proposal of the same
|
||||
* points timed by its own time: its activity, profile and pace, how its time
|
||||
* was reckoned, and nothing of a hike's limit, preferences or grades.
|
||||
*/
|
||||
export function tourProposalOf(
|
||||
base: HikeProposal,
|
||||
request: TourProposeRequest,
|
||||
activity: PlannedActivity,
|
||||
): TourProposal {
|
||||
const bike = activity.activity === 'bike';
|
||||
const pace = bike ? null : (request.run_pace_seconds_per_km ?? null);
|
||||
let basis: TourProposal['duration_basis'] = null;
|
||||
if (base.duration_minutes !== null) basis = bike ? 'router' : 'pace';
|
||||
return {
|
||||
...base,
|
||||
activity: activity.activity,
|
||||
bike_profile: bike ? activity.profile : null,
|
||||
run_profile: bike ? null : activity.profile,
|
||||
run_pace_seconds_per_km: pace,
|
||||
duration_basis: basis,
|
||||
max_hiking_difficulty: null,
|
||||
hiking_preferences: null,
|
||||
sac: null,
|
||||
} as TourProposal;
|
||||
}
|
||||
|
||||
/**
|
||||
* The write of a bike tour or a run created from its points, as the
|
||||
* planner's save writes one: the way it keeps with its heights, its time on
|
||||
* the move, its profile (and a run's pace), and nothing of a hike's.
|
||||
*/
|
||||
export function tourCreateCommandOf(
|
||||
request: TourPlanCreateRequest,
|
||||
points: readonly HikeStop[],
|
||||
way: KeptWay,
|
||||
activity: PlannedActivity,
|
||||
minutes: number | null,
|
||||
): TourCreateCommand {
|
||||
const bike = activity.activity === 'bike';
|
||||
return {
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
tour_type: activity.activity,
|
||||
route_geometry: way.line,
|
||||
duration_seconds: way.line && minutes !== null ? minutes * 60 : null,
|
||||
max_hiking_difficulty: null,
|
||||
hiking_preferences: null,
|
||||
bike_profile: bike ? activity.profile : null,
|
||||
run_profile: bike ? null : activity.profile,
|
||||
run_pace_seconds_per_km: bike ? null : (request.run_pace_seconds_per_km ?? null),
|
||||
...(request.planned_duration_minutes !== undefined
|
||||
? { planned_duration_minutes: request.planned_duration_minutes }
|
||||
: {}),
|
||||
waypoints: hikeWaypointsOf(points),
|
||||
};
|
||||
}
|
||||
|
||||
/** Why no way was planned for a ride or a run, in words that say what to do about it. */
|
||||
const UNPLANNED: Readonly<Record<UnroutedHikePlan['why'], (named: string, position: number | null) => string>> = {
|
||||
no_valhalla: (named) => `TREK has no Valhalla router here to plan ${named} with its profile.`,
|
||||
router_unavailable: () => 'The router did not answer, for the way or for its heights; try again in a moment.',
|
||||
no_way: (named) => `No way for ${named} of this profile joins these points: try another profile or move the points.`,
|
||||
point_off_the_way: (_named, position) =>
|
||||
`Point ${position ?? ''} is further than ${TOUR_MAX_SNAP_M} m from any way: move it onto a way.`,
|
||||
too_long: (named) => `The way is too long to plan as ${named}: split it into shorter tours.`,
|
||||
};
|
||||
|
||||
/** The refusal of a bike tour or a run whose way could not be planned: nothing is saved. */
|
||||
export function unplannedTourMessage(
|
||||
{ why, position }: Pick<UnroutedHikePlan, 'why' | 'position'>,
|
||||
activity: TourPlanActivity,
|
||||
): string {
|
||||
const named = activity === 'bike' ? 'a bike tour' : 'a run';
|
||||
return `${UNPLANNED[why](named, position)} Nothing was saved.`;
|
||||
}
|
||||
|
||||
/** Whether a way that was not planned refuses the save: every reason but a router that did not answer. */
|
||||
export function refusesSave(route: HikeRouteOutcome): route is Extract<HikeRouteOutcome, { planned: false }> {
|
||||
return 'why' in route && route.why !== 'router_unavailable';
|
||||
}
|
||||
|
||||
/** A request checked whole, its refinements included: an MCP tool reads only its schema's shape. */
|
||||
export function checkedRequest<T>(schema: z.ZodType<T>, value: unknown): T {
|
||||
const result = schema.safeParse(value);
|
||||
if (result.error) throw badRequest(result.error.issues.map((issue) => issue.message).join('; '));
|
||||
return result.data as T;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import {
|
||||
McpController,
|
||||
Tool,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
TOOL_ANNOTATIONS_READONLY,
|
||||
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 { HikePlanService } from './hike-plan.service';
|
||||
import { SportTourCreateService } from './sport-tour-create.service';
|
||||
import {
|
||||
idSchema,
|
||||
paddleTourCreateRequestSchema,
|
||||
skiTourCreateRequestSchema,
|
||||
tourPlanCreateRequestSchema,
|
||||
tourProposeRequestSchema,
|
||||
type PaddleTourCreateRequest,
|
||||
type SkiTourCreateRequest,
|
||||
type TourPlanCreateRequest,
|
||||
type TourProposeRequest,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** What every create here says of the day it may be put on, and of where to go on from it. */
|
||||
const ON_A_DAY =
|
||||
'With day_id the tour is put on that day of the trip in the same write, start_time kept as the start of that visit (which needs day editing permission too); assignment names the visit, null without a day. The answer is the tour as list_tours reads it (tour.place_id is the tour id, its revision 1), route and assignment. Put it on a day later with assign_place_to_day, link it to the packing list with link_tour_packing, export it with export_tour_gpx. Needs place editing permission on the trip. Create it only after the traveller said yes.';
|
||||
|
||||
/**
|
||||
* Every tour type besides a hike and a city walk planned and saved with the
|
||||
* traveller over MCP: a bike tour or a run proposed and created through the
|
||||
* hike's own plan and save (HikePlanService, one path for every activity on
|
||||
* land), a paddle or ski tour planned with plan_paddle_route or
|
||||
* plan_ski_route saved through SportTourCreateService. A proposal reads only;
|
||||
* a create writes through the creation the planner's save uses, with
|
||||
* 'place_edit' on the trip as REST asks (ToursController), and 'day_edit' as
|
||||
* well for a tour put on a day (AssignmentsController). A refusal of the
|
||||
* service is a DomainError, which the registry answers as an error result
|
||||
* with the text REST sends.
|
||||
*/
|
||||
@McpController()
|
||||
export class TourPlanMcp {
|
||||
constructor(
|
||||
private readonly hikes: HikePlanService,
|
||||
private readonly sports: SportTourCreateService,
|
||||
private readonly access: TripAccessService,
|
||||
private readonly guards: McpToolGuardsService,
|
||||
// Read by the addon gate of every tool here.
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
/** Whether the user may create a tour in the trip, and put it on a day where one is named. */
|
||||
private async mayCreate(tripId: number, userId: number, dayId: number | undefined): Promise<boolean> {
|
||||
if (!(await this.guards.hasTripPermission('place_edit', tripId, userId))) return false;
|
||||
// Putting it on a day is what REST's assignment route asks 'day_edit' for.
|
||||
return dayId === undefined || (await this.guards.hasTripPermission('day_edit', tripId, userId));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'propose_tour',
|
||||
description:
|
||||
"Plan a bike tour (activity bike) or a run (activity run) for a trip and show it to the traveller, without saving anything; give it a name. A hike is planned with propose_hike, a city walk with propose_city_walk, a paddle tour with plan_paddle_route, a ski or winter tour with plan_ski_route. The way runs through waypoints in order (the start, the points on the way, the end; with round_trip back to the start), planned by TREK's Valhalla router as the tour planner plans it for the profile: a bike tour's bike_profile touring (paved ways, cycle paths and quiet roads, for a city or trekking bike, the default), road (asphalt only, a road bike), gravel (gravel and farm tracks) or mtb (rough tracks and trails, a mountain bike); a run's run_profile running (runnable, mostly paved ways, the default) or trail (forest tracks and natural paths up to T2). Pick the profile from what the traveller rides or how they like to run, and ask when it is not clear. A bike tour's time (duration_minutes, duration_basis router) is the router's riding time for its profile; a run's is its distance at run_pace_seconds_per_km, the runner's own pace (duration_basis pace), and a run without a pace has no time: ask for the pace. The answer gives distance_m, ascent_m, descent_m, min_ele_m, max_ele_m, high_point, every point with at_m, snapped_m, minutes and, with start_time, its arrival, and along: water, food, toilets, viewpoints (a bike repair shop on a ride, a park on a run) near the way. With day_id or date, schedule gives the day with sunrise, sunset and the weather by the hour, and warnings name a start in the dark, an arrival after sunset, thunder or rain. Where no way was planned, route says why as propose_hike does (no_valhalla, router_unavailable, no_way, point_off_the_way, too_long). To compare ways, propose each with its own variant_label and along false, the one you recommend first. The chat shows the route, its profile, times and weather as a card: never list coordinates or the figures, sum it up in a sentence. Create it with create_tour only after the traveller said yes.",
|
||||
inputSchema: { tripId: idSchema, ...tourProposeRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
})
|
||||
async proposeTour({ tripId, ...request }: { tripId: number } & TourProposeRequest, ctx: McpContext) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
return ok(await this.hikes.proposeTour(ctx.userId, tripId, request));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'create_tour',
|
||||
description: `Create a bike tour or a run in a trip from points the traveller confirmed, usually the name, activity, profile and waypoints of a proposal of propose_tour with its round_trip. A run is created only with run_pace_seconds_per_km, the runner's pace. It is saved the way the planner saves one: a tour of type bike or run with its points in order, the way planned by the same router for the same profile and kept whole with its heights, its distance, ascent, descent and time (a bike tour the router's riding time, a run its distance at the pace), its bike_profile or run_profile and pace, and planned_duration_minutes (the time out, breaks included) where given. Where the router did not answer, for the way or its heights (route planned false, why router_unavailable), the tour is saved with its points only (route_pending true) and TREK plans the way when someone opens it in the planner. Any other reason refuses the tour with why, and nothing is saved. ${ON_A_DAY}`,
|
||||
inputSchema: { tripId: idSchema.describe('The trip to create the tour in'), ...tourPlanCreateRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'write' },
|
||||
})
|
||||
async createTour({ tripId, ...request }: { tripId: number } & TourPlanCreateRequest, ctx: McpContext) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.mayCreate(tripId, ctx.userId, request.day_id))) return permissionDenied();
|
||||
return ok(await this.hikes.createTour(ctx.userId, tripId, request));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'create_paddle_tour',
|
||||
description: `Create a paddle tour in a trip from a plan of plan_paddle_route the traveller confirmed: waypoints are the put-in, the points on the way and the take-out (the from of the first leg and the to of every leg, in order), with the boat_type, lake_style and lock_policy the plan was asked with, and water_context (river or lake), which the tour needs. Each leg is planned again on the water by the same router and the tour is saved as the planner saves one: its points, the whole line with its heights, its distance and its time on the move by the planner's estimate (water at paddle_speed_kmh, the boat's starting speed when left out, each portage the router carries round at portage_speed_kmh, locks without a time of their own). A leg that cannot be planned on the water refuses the tour and nothing is saved. Where the heights could not be read the tour is saved with its points only (route_pending true). ${ON_A_DAY}`,
|
||||
inputSchema: {
|
||||
tripId: idSchema.describe('The trip to create the tour in'),
|
||||
...paddleTourCreateRequestSchema.shape,
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'write' },
|
||||
})
|
||||
async createPaddleTour({ tripId, ...request }: { tripId: number } & PaddleTourCreateRequest, ctx: McpContext) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.mayCreate(tripId, ctx.userId, request.day_id))) return permissionDenied();
|
||||
return ok(await this.sports.createPaddle(ctx.userId, tripId, request));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'create_ski_tour',
|
||||
description: `Create a ski or winter tour in a trip from a plan of plan_ski_route the traveller confirmed: waypoints are the start, the points on the way and the end (the from of the first leg and the to of every leg, in order), with the discipline, technique (cross_country only, and needed there) and options the plan was asked with. Each leg is planned again on the piste data by the same router and the tour is saved as the planner saves one: its points, the whole line with its heights, its distance, climb and descent, the piste router's time (skiing, lifts, waiting and walking), its discipline and options and the piste data build it was planned on. A leg that cannot be planned refuses the tour and nothing is saved (loading: the ski area is still being downloaded, try again in a moment). Where the heights could not be read the tour is saved with its points only (route_pending true). ${ON_A_DAY}`,
|
||||
inputSchema: { tripId: idSchema.describe('The trip to create the tour in'), ...skiTourCreateRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'write' },
|
||||
})
|
||||
async createSkiTour({ tripId, ...request }: { tripId: number } & SkiTourCreateRequest, ctx: McpContext) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.mayCreate(tripId, ctx.userId, request.day_id))) return permissionDenied();
|
||||
return ok(await this.sports.createSki(ctx.userId, tripId, request));
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,13 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { OverpassModule } from '../overpass/overpass.module';
|
||||
import { PackingModule } from '../packing/packing.module';
|
||||
import { PaddleObstaclesModule } from '../paddle-obstacles/paddle-obstacles.module';
|
||||
import { PaddleRoutingModule } from '../paddle-routing/paddle-routing.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { PlaceImportModule } from '../place-import/place-import.module';
|
||||
import { PlacesModule } from '../places/places.module';
|
||||
import { RoadtripModule } from '../roadtrip/roadtrip.module';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
import { SkiRoutingModule } from '../ski-routing/ski-routing.module';
|
||||
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
|
||||
import { WandererModule } from '../wanderer/wanderer.module';
|
||||
import { WeatherModule } from '../weather/weather.module';
|
||||
@@ -38,6 +40,7 @@ import { CollectionToursService } from './collection-tours.service';
|
||||
import { HikePlanMcp } from './hike-plan.mcp';
|
||||
import { HikePlanService } from './hike-plan.service';
|
||||
import { HikeRoutingService } from './hike-routing.service';
|
||||
import { SportTourCreateService } from './sport-tour-create.service';
|
||||
import { TourActivitiesController } from './tour-activities.controller';
|
||||
import { TourActivitiesMcp } from './tour-activities.mcp';
|
||||
import { TourActivitiesService } from './tour-activities.service';
|
||||
@@ -64,6 +67,7 @@ import { TourPackingMcp } from './tour-packing.mcp';
|
||||
import { PaddleCheckCache } from './tour-paddle-check.cache';
|
||||
import { TourPaddleCheckService } from './tour-paddle-check.service';
|
||||
import { TourPaddlePlanService } from './tour-paddle-plan.service';
|
||||
import { TourPlanMcp } from './tour-plan.mcp';
|
||||
import { TourPoiSearchController } from './tour-poi-search.controller';
|
||||
import { TourPoiSearchService } from './tour-poi-search.service';
|
||||
import { TourSkiController } from './tour-ski.controller';
|
||||
@@ -102,7 +106,9 @@ import { Module } from '@nestjs/common';
|
||||
* the same writes, its way planned on the server by HikeRoutingService, its
|
||||
* day read from WeatherModule's detailed weather and the places along it from
|
||||
* the tours' own search; one created on a day is put there through
|
||||
* AssignmentsService in the same write. A
|
||||
* AssignmentsService in the same write. A bike tour or a run goes the same
|
||||
* way for its profile, and a paddle or ski tour an assistant planned is saved
|
||||
* after PaddleRoutingModule's or SkiRoutingModule's router planned its legs again. A
|
||||
* change to a saved city walk an assistant prepares for review is laid onto
|
||||
* its saved points and planned on foot by the same router, and saves nothing
|
||||
* (MCP-T02); once the traveller
|
||||
@@ -155,6 +161,9 @@ import { Module } from '@nestjs/common';
|
||||
SchedulingModule,
|
||||
// A proposed hike's day reads the detailed weather at its highest point: sun, hours and warnings.
|
||||
WeatherModule,
|
||||
// A paddle or ski tour the assistant planned is planned again by its own router when it is saved.
|
||||
PaddleRoutingModule,
|
||||
SkiRoutingModule,
|
||||
MikroOrmModule.forFeature([
|
||||
CollectionTours,
|
||||
Tours,
|
||||
@@ -212,6 +221,7 @@ import { Module } from '@nestjs/common';
|
||||
CityWalkBriefingService,
|
||||
HikeRoutingService,
|
||||
HikePlanService,
|
||||
SportTourCreateService,
|
||||
TourChangePreviewService,
|
||||
TourChangeSaveService,
|
||||
TourChangeUndoService,
|
||||
@@ -227,6 +237,7 @@ import { Module } from '@nestjs/common';
|
||||
TourWalkImportMcp,
|
||||
CityWalkMcp,
|
||||
HikePlanMcp,
|
||||
TourPlanMcp,
|
||||
TourChangeMcp,
|
||||
TourChangeUndoMcp,
|
||||
TourActivitiesMcp,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Tours e2e for every tour type besides a hike planned with an assistant over
|
||||
* MCP: a bike tour proposed and created, a run created at its pace, a paddle
|
||||
* tour and a ski tour saved from their plans, every tool called through a
|
||||
* real MCP session on the whole app (buildApp, the real registry with its
|
||||
* addon gates, scopes and error mapper, the real services and a migrated temp
|
||||
* SQLite). The way on land is planned by the road trip's router with its
|
||||
* paced queue; only the routing host behind safeFetchAdminConfigured is a
|
||||
* stub answering /route and /height as Valhalla does. The legs on the water
|
||||
* and on the piste data come from spies on the container's
|
||||
* PaddleRoutingService and SkiRoutingService, the places along the way from
|
||||
* one on TourPoiSearchService, so nothing leaves the process.
|
||||
*/
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { Places } from '../../src/db/entities/Places.entity';
|
||||
import { Tours } from '../../src/db/entities/Tours.entity';
|
||||
import { createLocalMcpServer, McpRegistryService } from '../../src/nest-mcp';
|
||||
import { withRequestContext } from '../../src/nest/database/request-context';
|
||||
import { PaddleRoutingService } from '../../src/nest/paddle-routing/paddle-routing.service';
|
||||
import { SkiRoutingService } from '../../src/nest/ski-routing/ski-routing.service';
|
||||
import { TourPoiSearchService } from '../../src/nest/tours/tour-poi-search.service';
|
||||
import { safeFetchAdminConfigured } from '../../src/utils/ssrfGuard';
|
||||
import { countRows, findRow } from '../helpers/factories/rows';
|
||||
import { setAddonEnabled } from '../helpers/factories/settings';
|
||||
import { makeTrip } from '../helpers/factories/trips';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { spyOnRealtime, type RealtimeSpies } from '../helpers/fake-realtime';
|
||||
import { valhallaRoute } from '../helpers/valhalla';
|
||||
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 { BIKE_COSTING, tourProposalSchema } from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../src/utils/ssrfGuard', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../src/utils/ssrfGuard')>()),
|
||||
safeFetchAdminConfigured: vi.fn(),
|
||||
}));
|
||||
|
||||
let app: INestApplication;
|
||||
let orm: MikroORM;
|
||||
let broadcasts: RealtimeSpies;
|
||||
|
||||
/** A point `k` steps of 0.0005° north of 47.9° N on 11.1° E, about 56 m a step. */
|
||||
const at = (k: number): [number, number] => [47.9 + k / 2000, 11.1];
|
||||
const stepsOf = (from: number, to: number) => Array.from({ length: to - from + 1 }, (_, index) => at(from + index));
|
||||
|
||||
const WAYPOINTS = [
|
||||
{ lat: at(0)[0], lng: 11.1, name: 'Station' },
|
||||
{ lat: at(20)[0], lng: 11.1, name: 'Lake' },
|
||||
];
|
||||
|
||||
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
|
||||
|
||||
/** The routing host: one leg of 1.1 km ridden in four minutes, the height of every point. */
|
||||
function valhalla() {
|
||||
vi.mocked(safeFetchAdminConfigured).mockImplementation(async (url, init) => {
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as { shape?: Array<{ lat: number; lon: number }> };
|
||||
if (url.endsWith('/route')) return json(valhallaRoute([{ line: stepsOf(0, 20), km: 1.112, seconds: 240 }]));
|
||||
if (url.endsWith('/height')) {
|
||||
return json({ range_height: (body.shape ?? []).map((point, index) => [index, 600 + index]) });
|
||||
}
|
||||
return json({}, 404);
|
||||
});
|
||||
}
|
||||
|
||||
async function moveOn(done: () => boolean): Promise<void> {
|
||||
if (done()) return;
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await moveOn(done);
|
||||
}
|
||||
|
||||
/** A call moved through to its end on the test's clock, which the router's pacing waits on. */
|
||||
async function settled<T>(pending: Promise<T>): Promise<T> {
|
||||
let done = false;
|
||||
const outcome = pending.then(
|
||||
(value) => {
|
||||
done = true;
|
||||
return { value };
|
||||
},
|
||||
(error: unknown) => {
|
||||
done = true;
|
||||
return { error };
|
||||
},
|
||||
);
|
||||
await moveOn(() => done);
|
||||
const result = await outcome;
|
||||
if ('error' in result) throw result.error;
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/** One tool call as `userId` over an MCP session of its own, with every scope; the JSON it answered or its error. */
|
||||
async function callTool(userId: number, name: string, args: Record<string, unknown>) {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
||||
try {
|
||||
return await settled(callOnce(userId, name, args));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
}
|
||||
|
||||
async function callOnce(userId: number, name: string, args: Record<string, unknown>) {
|
||||
const server = createLocalMcpServer('TREK tour types e2e');
|
||||
const around = (_info: unknown, call: () => unknown): unknown => withRequestContext(orm, call);
|
||||
await withRequestContext(orm, () =>
|
||||
app
|
||||
.get(McpRegistryService)
|
||||
.attach(
|
||||
server,
|
||||
{ userId, scopes: null, isStaticToken: false },
|
||||
{ include: (kind, tool) => kind === 'tool' && tool === name, around },
|
||||
),
|
||||
);
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const client = new Client({ name: 'tour-types-e2e', version: '1.0.0' });
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
try {
|
||||
const result = await client.callTool({ name, arguments: args });
|
||||
const content = Array.isArray(result.content) ? (result.content as Array<{ text?: string }>) : [];
|
||||
const text = content[0]?.text ?? '';
|
||||
return result.isError === true ? { error: text } : JSON.parse(text);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** The events the trip was sent, emptied after each read. */
|
||||
function sent(): string[] {
|
||||
const events = broadcasts.broadcast.mock.calls.map((call) => String(call[1]));
|
||||
broadcasts.broadcast.mockClear();
|
||||
return events;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
orm = app.get(MikroORM);
|
||||
broadcasts = spyOnRealtime(app);
|
||||
await setAddonEnabled(orm, 'tours', true);
|
||||
vi.spyOn(app.get(TourPoiSearchService), 'search').mockResolvedValue({
|
||||
pois: [],
|
||||
truncated: false,
|
||||
partial: false,
|
||||
capped: false,
|
||||
unavailable: [],
|
||||
});
|
||||
vi.spyOn(app.get(PaddleRoutingService), 'route').mockResolvedValue({
|
||||
legs: [
|
||||
{
|
||||
status: 'ok',
|
||||
geometry: stepsOf(0, 20),
|
||||
segments: [{ kind: 'water', from: 0, to: 20, name: 'Isar', source: null, osm_ref: null, grade: null }],
|
||||
distance_m: 1112,
|
||||
snapped_m: { from: 1, to: 1 },
|
||||
},
|
||||
],
|
||||
graph_rev: 'r1',
|
||||
});
|
||||
vi.spyOn(app.get(SkiRoutingService), 'route').mockResolvedValue({
|
||||
legs: [
|
||||
{
|
||||
status: 'ok',
|
||||
geometry: stepsOf(0, 20).map(([lat, lng], index): [number, number, number] => [lat, lng, 1800 - index * 10]),
|
||||
segments: [],
|
||||
distance_m: 1112,
|
||||
climb_m: 0,
|
||||
descent_m: 200,
|
||||
seconds: { ski: 300, lift: 0, wait: 0, walk: 0 },
|
||||
snapped_m: { from: 1, to: 1 },
|
||||
area_ids: ['a1'],
|
||||
why: [],
|
||||
alternatives: [],
|
||||
},
|
||||
],
|
||||
build: 'b1',
|
||||
stale: false,
|
||||
area_names: ['Garmisch'],
|
||||
colour_scheme: 'europe',
|
||||
attribution: '',
|
||||
});
|
||||
valhalla();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
describe('every tour type planned with an assistant over MCP', () => {
|
||||
it('a bike tour is proposed for its profile and created as the planner saves one, the trip told', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id, { title: 'Lakes' });
|
||||
const proposal = await callTool(user.id, 'propose_tour', {
|
||||
tripId: trip.id,
|
||||
name: 'Lake ride',
|
||||
activity: 'bike',
|
||||
bike_profile: 'road',
|
||||
waypoints: WAYPOINTS,
|
||||
});
|
||||
expect(tourProposalSchema.parse(proposal)).toEqual(proposal);
|
||||
expect(proposal).toMatchObject({
|
||||
activity: 'bike',
|
||||
bike_profile: 'road',
|
||||
duration_minutes: 4,
|
||||
duration_basis: 'router',
|
||||
});
|
||||
const routeCall = vi.mocked(safeFetchAdminConfigured).mock.calls.find(([url]) => String(url).endsWith('/route'));
|
||||
expect(JSON.parse(String(routeCall?.[1]?.body))).toMatchObject({
|
||||
costing: 'bicycle',
|
||||
costing_options: { bicycle: BIKE_COSTING.road },
|
||||
});
|
||||
expect(await countRows(orm, Places, { trip: trip.id })).toBe(0);
|
||||
|
||||
const created = await callTool(user.id, 'create_tour', {
|
||||
tripId: trip.id,
|
||||
name: 'Lake ride',
|
||||
activity: 'bike',
|
||||
bike_profile: 'road',
|
||||
waypoints: WAYPOINTS,
|
||||
});
|
||||
expect(created.route).toEqual({ planned: true });
|
||||
const tour = await findRow(orm, Tours, { place: created.tour.place_id });
|
||||
expect(tour).toMatchObject({ bike_profile: 'road', duration: 4 });
|
||||
expect(sent()).toEqual(['tours:changed', 'place:created']);
|
||||
});
|
||||
|
||||
it("a run is created only with the runner's pace, and timed at it", async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id, { title: 'Run' });
|
||||
const args = { tripId: trip.id, name: 'Isar run', activity: 'run', run_profile: 'trail', waypoints: WAYPOINTS };
|
||||
const refused = await callTool(user.id, 'create_tour', args);
|
||||
expect(refused.error).toContain("a run is saved with the runner's pace");
|
||||
const created = await callTool(user.id, 'create_tour', { ...args, run_pace_seconds_per_km: 360 });
|
||||
expect(created.tour).toMatchObject({ tour_type: 'run', run_profile: 'trail', duration: 7 });
|
||||
});
|
||||
|
||||
it('a paddle tour and a ski tour are saved from their plans with their heights and times', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id, { title: 'Water and snow' });
|
||||
const points = [
|
||||
{ lat: at(0)[0], lng: 11.1 },
|
||||
{ lat: at(20)[0], lng: 11.1 },
|
||||
];
|
||||
const paddle = await callTool(user.id, 'create_paddle_tour', {
|
||||
tripId: trip.id,
|
||||
name: 'Isar',
|
||||
waypoints: points,
|
||||
water_context: 'river',
|
||||
});
|
||||
expect(paddle.tour).toMatchObject({ tour_type: 'kayak', boat_type: 'kayak', water_context: 'river' });
|
||||
// 1112 m at a kayak's 4 km/h.
|
||||
expect(paddle.tour.duration).toBe(17);
|
||||
const ski = await callTool(user.id, 'create_ski_tour', {
|
||||
tripId: trip.id,
|
||||
name: 'Garmisch',
|
||||
waypoints: points,
|
||||
discipline: 'alpine',
|
||||
});
|
||||
expect(ski.tour).toMatchObject({ tour_type: 'ski', ski_discipline: 'alpine', duration: 5 });
|
||||
const place = await findRow(orm, Places, { id: ski.tour.place_id });
|
||||
expect(JSON.parse(String(place?.route_geometry))[0]).toEqual([...at(0), 1800]);
|
||||
const refused = await callTool(user.id, 'create_paddle_tour', { tripId: trip.id, name: 'X', waypoints: points });
|
||||
expect(refused.error).toBeTruthy();
|
||||
expect(await countRows(orm, Places, { trip: trip.id })).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,9 @@ import { Places } from '../../../src/db/entities/Places.entity';
|
||||
import { TourChangeSteps } from '../../../src/db/entities/TourChangeSteps.entity';
|
||||
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../../src/db/entities/Tours.entity';
|
||||
import { PaddleRoutingService } from '../../../src/nest/paddle-routing/paddle-routing.service';
|
||||
import { RoadtripRouterService } from '../../../src/nest/roadtrip/roadtrip-router.service';
|
||||
import { SkiRoutingService } from '../../../src/nest/ski-routing/ski-routing.service';
|
||||
import { proposalIdOf } from '../../../src/nest/tours/tour-change-proposal';
|
||||
import { makeDayAssignment } from '../../helpers/factories/itinerary';
|
||||
import { makePlace } from '../../helpers/factories/places';
|
||||
@@ -41,6 +43,64 @@ function routerDown(app: INestApplication): () => void {
|
||||
return () => route.mockRestore();
|
||||
}
|
||||
|
||||
/**
|
||||
* No water or piste data in a test: the paddle and piste routers plan the
|
||||
* one leg asked as a straight stretch, and no height host answers, so each
|
||||
* tour is saved with its points only, as both tools save one then.
|
||||
*/
|
||||
function sportRouters(app: INestApplication): () => void {
|
||||
const geometry: Array<[number, number]> = [
|
||||
[47, 11],
|
||||
[47.02, 11.02],
|
||||
];
|
||||
const paddle = vi.spyOn(app.get(PaddleRoutingService), 'route').mockResolvedValue({
|
||||
legs: [
|
||||
{
|
||||
status: 'ok',
|
||||
geometry,
|
||||
segments: [{ kind: 'water', from: 0, to: 1, name: null, source: null, osm_ref: null, grade: null }],
|
||||
distance_m: 2700,
|
||||
snapped_m: { from: 0, to: 0 },
|
||||
},
|
||||
],
|
||||
graph_rev: null,
|
||||
});
|
||||
const ski = vi.spyOn(app.get(SkiRoutingService), 'route').mockResolvedValue({
|
||||
legs: [
|
||||
{
|
||||
status: 'ok',
|
||||
geometry: geometry.map(([lat, lng]): [number, number, null] => [lat, lng, null]),
|
||||
segments: [],
|
||||
distance_m: 2700,
|
||||
climb_m: 0,
|
||||
descent_m: 0,
|
||||
seconds: { ski: 600, lift: 0, wait: 0, walk: 0 },
|
||||
snapped_m: { from: 0, to: 0 },
|
||||
area_ids: [],
|
||||
why: [],
|
||||
alternatives: [],
|
||||
},
|
||||
],
|
||||
build: null,
|
||||
stale: false,
|
||||
area_names: [],
|
||||
colour_scheme: 'europe',
|
||||
attribution: '',
|
||||
});
|
||||
const heights = vi.spyOn(app.get(RoadtripRouterService), 'heights').mockResolvedValue(null);
|
||||
return () => {
|
||||
paddle.mockRestore();
|
||||
ski.mockRestore();
|
||||
heights.mockRestore();
|
||||
};
|
||||
}
|
||||
|
||||
/** The two points of every tour the parity cases create. */
|
||||
const TWO_POINTS = [
|
||||
{ lat: 47, lng: 11 },
|
||||
{ lat: 47.02, lng: 11.02 },
|
||||
];
|
||||
|
||||
/** A hike's line with its heights, as the planner saves one. */
|
||||
const HIKE_LINE: [number, number, number][] = [
|
||||
[47, 11, 600],
|
||||
@@ -343,6 +403,56 @@ export const toursRoadtrip: CaseGroup = {
|
||||
stub: routerDown,
|
||||
expect: ['tours:changed', 'place:created'],
|
||||
},
|
||||
{
|
||||
tool: 'create_tour',
|
||||
args: (s) => ({ tripId: s.trip.id, name: 'Lake ride', activity: 'bike', waypoints: TWO_POINTS }),
|
||||
// The planner saves a new bike tour through the tours route, with the line it planned in the browser.
|
||||
rest: {
|
||||
method: 'post',
|
||||
path: (s) => `/api/trips/${s.trip.id}/tours`,
|
||||
body: () => ({
|
||||
...hikeBody('Lake ride'),
|
||||
tour_type: 'bike',
|
||||
bike_profile: 'touring',
|
||||
max_hiking_difficulty: null,
|
||||
}),
|
||||
},
|
||||
stub: routerDown,
|
||||
expect: ['tours:changed', 'place:created'],
|
||||
},
|
||||
{
|
||||
tool: 'create_paddle_tour',
|
||||
args: (s) => ({ tripId: s.trip.id, name: 'River day', waypoints: TWO_POINTS, water_context: 'river' }),
|
||||
rest: {
|
||||
method: 'post',
|
||||
path: (s) => `/api/trips/${s.trip.id}/tours`,
|
||||
body: () => ({
|
||||
...hikeBody('River day'),
|
||||
tour_type: 'kayak',
|
||||
boat_type: 'kayak',
|
||||
water_context: 'river',
|
||||
max_hiking_difficulty: null,
|
||||
}),
|
||||
},
|
||||
stub: sportRouters,
|
||||
expect: ['tours:changed', 'place:created'],
|
||||
},
|
||||
{
|
||||
tool: 'create_ski_tour',
|
||||
args: (s) => ({ tripId: s.trip.id, name: 'Piste day', waypoints: TWO_POINTS, discipline: 'alpine' }),
|
||||
rest: {
|
||||
method: 'post',
|
||||
path: (s) => `/api/trips/${s.trip.id}/tours`,
|
||||
body: () => ({
|
||||
...hikeBody('Piste day'),
|
||||
tour_type: 'ski',
|
||||
ski_discipline: 'alpine',
|
||||
max_hiking_difficulty: null,
|
||||
}),
|
||||
},
|
||||
stub: sportRouters,
|
||||
expect: ['tours:changed', 'place:created'],
|
||||
},
|
||||
{
|
||||
// Only the name changes, so the hike keeps its way and the router is not asked.
|
||||
tool: 'update_tour',
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* ASTPROP-SPORT-001 to ASTPROP-SPORT-007
|
||||
*
|
||||
* "Plan this" on every tour type: what a proposed bike tour or run and a
|
||||
* paddle or ski plan are taken as, the points a plan's legs make, the water
|
||||
* and the name a paddle tour gets, and the tour type's word in the user's
|
||||
* language where nothing names the tour.
|
||||
*/
|
||||
import {
|
||||
isSportPlan,
|
||||
sportTakeArgsOf,
|
||||
tourTypeWord,
|
||||
waypointsOfLegs,
|
||||
} from '../../../../src/nest/assistant/assistant-proposal-sports';
|
||||
import { takeArgsOf } from '../../../../src/nest/assistant/assistant-proposals.service';
|
||||
import { writeToolOf } from '../../../../src/nest/assistant/assistant-tools.constants';
|
||||
import { tourRouteCard } from '../../../helpers/assistant-tour-cards';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const A = { lat: 48.1, lng: 11.6 };
|
||||
const B = { lat: 48.11, lng: 11.6 };
|
||||
const C = { lat: 48.12, lng: 11.6 };
|
||||
|
||||
const ok = (segments: Array<{ kind: string; name: string | null }>) => ({ status: 'ok', segments });
|
||||
|
||||
describe('the points of a plan', () => {
|
||||
it('ASTPROP-SPORT-001: are the first start and every end, where each leg goes on from the one before', () => {
|
||||
expect(
|
||||
waypointsOfLegs([
|
||||
{ from: A, to: B },
|
||||
{ from: B, to: C },
|
||||
]),
|
||||
).toEqual([A, B, C]);
|
||||
// A leg that starts elsewhere is no part of one tour.
|
||||
expect(
|
||||
waypointsOfLegs([
|
||||
{ from: A, to: B },
|
||||
{ from: C, to: A },
|
||||
]),
|
||||
).toBeNull();
|
||||
expect(waypointsOfLegs([])).toBeNull();
|
||||
expect(waypointsOfLegs('legs')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a paddle or ski plan taken', () => {
|
||||
it('ASTPROP-SPORT-002: a paddle plan is created with its boat, styles, water and the waters it runs on', () => {
|
||||
const args = {
|
||||
legs: [
|
||||
{ from: A, to: B },
|
||||
{ from: B, to: C },
|
||||
],
|
||||
boat: 'canoe',
|
||||
lake_style: 'shore',
|
||||
lock_policy: 'use',
|
||||
};
|
||||
const result = {
|
||||
legs: [ok([{ kind: 'water', name: 'Isar' }]), ok([{ kind: 'lake', name: 'Ammersee' }])],
|
||||
};
|
||||
expect(sportTakeArgsOf('plan_paddle_route', args, result, 'Paddle tour')).toEqual({
|
||||
via: 'create_paddle_tour',
|
||||
name: 'Isar → Ammersee',
|
||||
waypoints: [A, B, C],
|
||||
water_context: 'river',
|
||||
boat_type: 'canoe',
|
||||
lake_style: 'shore',
|
||||
lock_policy: 'use',
|
||||
});
|
||||
const lake = sportTakeArgsOf(
|
||||
'plan_paddle_route',
|
||||
{ legs: [{ from: A, to: B }] },
|
||||
{ legs: [ok([{ kind: 'lake', name: null }])] },
|
||||
'Paddeltour',
|
||||
);
|
||||
expect(lake).toMatchObject({ name: 'Paddeltour', water_context: 'lake' });
|
||||
});
|
||||
|
||||
it('ASTPROP-SPORT-003: a ski plan is created with its discipline, technique and options, named by its areas', () => {
|
||||
const args = {
|
||||
legs: [{ from: A, to: B }],
|
||||
discipline: 'cross_country',
|
||||
technique: 'classic',
|
||||
options: { lit_only: true },
|
||||
};
|
||||
expect(sportTakeArgsOf('plan_ski_route', args, { legs: [ok([])], area_names: ['Seefeld'] }, 'Ski')).toEqual({
|
||||
via: 'create_ski_tour',
|
||||
name: 'Seefeld',
|
||||
waypoints: [A, B],
|
||||
discipline: 'cross_country',
|
||||
technique: 'classic',
|
||||
options: { lit_only: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTPROP-SPORT-004: a plan with a failed leg, or legs that do not join, cannot be taken', () => {
|
||||
const args = { legs: [{ from: A, to: B }], discipline: 'alpine' };
|
||||
expect(sportTakeArgsOf('plan_ski_route', args, { legs: [{ status: 'failed' }] }, 'Ski')).toBeNull();
|
||||
expect(sportTakeArgsOf('plan_ski_route', { ...args, legs: [] }, { legs: [] }, 'Ski')).toBeNull();
|
||||
expect(isSportPlan('plan_ski_route')).toBe(true);
|
||||
expect(isSportPlan('propose_hike')).toBe(false);
|
||||
});
|
||||
|
||||
it("ASTPROP-SPORT-005: a tour nothing names is called by its type in the user's language, English for one TREK has none of", async () => {
|
||||
expect(await tourTypeWord('de-DE', 'plan_paddle_route')).toBe('Paddeltour');
|
||||
expect(await tourTypeWord('en', 'plan_ski_route')).toBe('Ski & winter');
|
||||
expect(await tourTypeWord('xx', 'plan_paddle_route')).toBe('Paddle tour');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a proposed bike tour or run taken', () => {
|
||||
const card = tourRouteCard({ activity: 'bike', routingProfile: 'road' });
|
||||
const args = { name: 'Lake', activity: 'bike', bike_profile: 'road', waypoints: [A, B], day_id: 3, along: false };
|
||||
|
||||
it('ASTPROP-SPORT-006: is created as create_tour with the arguments it was proposed with, what create_tour takes', () => {
|
||||
expect(takeArgsOf({ toolName: 'propose_tour', args, result: {}, card })).toEqual({
|
||||
via: 'create_tour',
|
||||
name: 'Lake',
|
||||
activity: 'bike',
|
||||
bike_profile: 'road',
|
||||
waypoints: [A, B],
|
||||
day_id: 3,
|
||||
});
|
||||
expect(writeToolOf('take_proposal', { via: 'create_tour' })).toBe('create_tour');
|
||||
expect(writeToolOf('take_proposal', { via: 'create_paddle_tour' })).toBe('create_paddle_tour');
|
||||
expect(writeToolOf('take_proposal', { via: 'create_ski_tour' })).toBe('create_ski_tour');
|
||||
});
|
||||
|
||||
it("ASTPROP-SPORT-007: a run without the runner's pace, or a way not planned, cannot be taken", () => {
|
||||
const run = { ...args, activity: 'run', bike_profile: undefined, run_profile: 'trail' };
|
||||
expect(takeArgsOf({ toolName: 'propose_tour', args: run, result: {}, card })).toBeNull();
|
||||
expect(
|
||||
takeArgsOf({ toolName: 'propose_tour', args: { ...run, run_pace_seconds_per_km: 330 }, result: {}, card }),
|
||||
).toMatchObject({ via: 'create_tour', run_pace_seconds_per_km: 330 });
|
||||
expect(takeArgsOf({ toolName: 'propose_tour', args, result: {}, card: { ...card, routed: false } })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* ASTCARD-TOUR-080 to ASTCARD-TOUR-088
|
||||
*
|
||||
* The cards of every tour type besides a hike and a city walk: a proposed
|
||||
* bike tour or run (propose_tour) with its profile, time and pace, a held
|
||||
* create_tour drawn as its proposal was, a held create_paddle_tour or
|
||||
* create_ski_tour drawn as its plan was, the words of each create, the way
|
||||
* back from each and what the tours tell the model. Every card goes through
|
||||
* the registry's guarded call, so it also fits the shared card union.
|
||||
*/
|
||||
import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context';
|
||||
import { systemPrompt } from '../../../../../../src/nest/assistant/assistant.prompt';
|
||||
import { loadFor } from '../../../../../../src/nest/assistant/cards/card-context';
|
||||
import type { PreviewContext } from '../../../../../../src/nest/assistant/cards/card-types';
|
||||
import {
|
||||
answerCardOf,
|
||||
ASSISTANT_ARG_POLICY,
|
||||
ASSISTANT_UNDO_MAP,
|
||||
ASSISTANT_WRITE_TOOLS,
|
||||
CARD_PROMPT_LINES,
|
||||
} from '../../../../../../src/nest/assistant/cards/registry';
|
||||
import { landSportOf } from '../../../../../../src/nest/assistant/cards/tour/tour-route.cards';
|
||||
import { legArgsOf } from '../../../../../../src/nest/assistant/cards/tour/tour-sport-change.cards';
|
||||
import { describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
|
||||
import { assistantChangePreviewSchema, trekCardSchema, type TourRouteCard, type TrekCard } from '@trek/shared';
|
||||
|
||||
import { beforeEach, 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);
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const card = buildTripCard(
|
||||
1,
|
||||
7,
|
||||
{ trip: { title: 'Bavaria' }, days: [{ id: 31, day_number: 1, date: '2026-06-11', assignments: [] }] },
|
||||
{ places: [] },
|
||||
null,
|
||||
);
|
||||
const ctx: PreviewContext = { card, categories: new Map(), locale: 'en-GB', today: '2026-06-01', airport: () => null };
|
||||
|
||||
const POINTS = [
|
||||
{ lat: 47.9, lng: 11.1, name: 'Station' },
|
||||
{ lat: 47.91, lng: 11.1, name: 'Lake' },
|
||||
];
|
||||
|
||||
/** What propose_tour answers for a gravel ride of 1.1 km. */
|
||||
function proposal(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: 'Lake loop',
|
||||
variant_label: null,
|
||||
route: { planned: true },
|
||||
unreachable: null,
|
||||
distance_m: 1112,
|
||||
ascent_m: 10,
|
||||
descent_m: 0,
|
||||
duration_minutes: 5,
|
||||
duration_basis: 'router',
|
||||
heights_known: true,
|
||||
min_ele_m: 600,
|
||||
max_ele_m: 610,
|
||||
line_geometry: [
|
||||
[47.9, 11.1, 600],
|
||||
[47.91, 11.1, 610],
|
||||
],
|
||||
sac: null,
|
||||
high_point: null,
|
||||
waypoints: [
|
||||
{ ...POINTS[0], poi_category: null, at_m: 0, snapped_m: 1, minutes: 0, arrival: null, stage_end: false },
|
||||
{ ...POINTS[1], poi_category: null, at_m: 1112, snapped_m: 1, minutes: 5, arrival: null, stage_end: false },
|
||||
],
|
||||
along: null,
|
||||
activity: 'bike',
|
||||
bike_profile: 'gravel',
|
||||
run_profile: null,
|
||||
run_pace_seconds_per_km: null,
|
||||
max_hiking_difficulty: null,
|
||||
hiking_preferences: null,
|
||||
round_trip: false,
|
||||
day_id: null,
|
||||
date: null,
|
||||
start_time: null,
|
||||
schedule: null,
|
||||
warnings: [],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const input = (tool: string, value: unknown, args: Record<string, unknown> = {}) =>
|
||||
[tool, { toolCallId: 'tc-1', args, value, card, today: '2026-06-01' }] as const;
|
||||
|
||||
function route(shown: TrekCard | null | undefined): TourRouteCard {
|
||||
if (shown?.kind !== 'tourRoute') throw new Error('no route card');
|
||||
expect(trekCardSchema.safeParse(shown).success).toBe(true);
|
||||
return shown;
|
||||
}
|
||||
|
||||
/** What plan_paddle_route answers for one leg on the river. */
|
||||
const PADDLE_PLAN = {
|
||||
legs: [
|
||||
{
|
||||
status: 'ok',
|
||||
distance_m: 1112,
|
||||
snapped_m: { from: 1, to: 1 },
|
||||
points: 2,
|
||||
segments: [{ kind: 'water', name: 'Isar', source: null, osm_ref: null, from_m: 0, to_m: 1112 }],
|
||||
line_geometry: [
|
||||
[48.1, 11.6],
|
||||
[48.11, 11.6],
|
||||
],
|
||||
},
|
||||
],
|
||||
graph_rev: 'r1',
|
||||
};
|
||||
|
||||
describe('a proposed bike tour or run', () => {
|
||||
it('ASTCARD-TOUR-080: a ride names its profile and its riding time, no hiking limit or grade', () => {
|
||||
const shown = route(answerCardOf(...input('propose_tour', proposal())));
|
||||
expect(shown).toMatchObject({
|
||||
source: 'proposal',
|
||||
activity: 'bike',
|
||||
routingProfile: 'gravel',
|
||||
minutes: 5,
|
||||
minutesBasis: 'router',
|
||||
limit: null,
|
||||
grades: null,
|
||||
routed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-081: a run names its profile and pace, its time at that pace; one without a pace has none', () => {
|
||||
const run = { activity: 'run', bike_profile: null, run_profile: 'trail', run_pace_seconds_per_km: 330 };
|
||||
const shown = route(answerCardOf(...input('propose_tour', proposal({ ...run, duration_basis: 'pace' }))));
|
||||
expect(shown).toMatchObject({
|
||||
activity: 'run',
|
||||
routingProfile: 'trail',
|
||||
paceSecondsPerKm: 330,
|
||||
minutesBasis: 'pace',
|
||||
});
|
||||
const unpaced = { ...run, run_pace_seconds_per_km: null, duration_minutes: null, duration_basis: null };
|
||||
expect(route(answerCardOf(...input('propose_tour', proposal(unpaced))))).toMatchObject({
|
||||
paceSecondsPerKm: null,
|
||||
minutes: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-082: a way that was not planned is drawn straight as the ride it is; a hike names no profile', () => {
|
||||
const value = proposal({ route: { planned: false, why: 'no_way', position: null } });
|
||||
expect(route(answerCardOf(...input('propose_tour', value)))).toMatchObject({
|
||||
activity: 'bike',
|
||||
routingProfile: 'gravel',
|
||||
routed: false,
|
||||
problem: { why: 'no_way' },
|
||||
});
|
||||
expect(landSportOf({}, {})).toBeNull();
|
||||
expect(landSportOf({ activity: 'run' }, { run_profile: 'nonsense' })).toEqual({
|
||||
activity: 'run',
|
||||
routingProfile: 'running',
|
||||
paceSecondsPerKm: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-083: a saved bike tour or run names its profile on its card', () => {
|
||||
const saved = {
|
||||
tour: {
|
||||
place_id: 9,
|
||||
name: 'Lake',
|
||||
tour_type: 'run',
|
||||
distance: 1.1,
|
||||
duration: 7,
|
||||
run_profile: 'trail',
|
||||
run_pace_seconds_per_km: 360,
|
||||
},
|
||||
waypoints: POINTS,
|
||||
line: { preview: proposal().line_geometry },
|
||||
};
|
||||
expect(route(answerCardOf(...input('get_tour_plan', saved)))).toMatchObject({
|
||||
activity: 'run',
|
||||
routingProfile: 'trail',
|
||||
paceSecondsPerKm: 360,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('a held new bike tour or run', () => {
|
||||
const CREATE = { name: 'Lake loop', activity: 'bike', bike_profile: 'gravel', waypoints: POINTS, day_id: 31 };
|
||||
|
||||
it('ASTCARD-TOUR-084: reads its proposal again and shows its way, on its day, taken back by deleting it', async () => {
|
||||
const read = vi.fn(async (tool: string) => (tool === 'propose_tour' ? proposal() : null));
|
||||
const loaded = await loadFor('create_tour', CREATE, read);
|
||||
expect(read).toHaveBeenCalledWith('propose_tour', {
|
||||
along: false,
|
||||
name: 'Lake loop',
|
||||
activity: 'bike',
|
||||
bike_profile: 'gravel',
|
||||
waypoints: POINTS,
|
||||
day_id: 31,
|
||||
});
|
||||
const preview = describeToolCall('create_tour', 'tc-2', CREATE, ctx, loaded);
|
||||
expect(assistantChangePreviewSchema.safeParse(preview).success).toBe(true);
|
||||
expect(preview).toMatchObject({ verbKey: 'addTour', subject: 'Lake loop', dayId: 31, undoable: true });
|
||||
expect(preview.effect).toMatchObject({ kind: 'add', dayId: 31 });
|
||||
expect(route(preview.card)).toMatchObject({ source: 'pending', activity: 'bike', routingProfile: 'gravel' });
|
||||
// Without its plan the points are drawn straight, still as the ride it is.
|
||||
const straight = route(describeToolCall('create_tour', 'tc-3', CREATE, ctx, null).card);
|
||||
expect(straight).toMatchObject({ activity: 'bike', routingProfile: 'gravel', routed: false, limit: null });
|
||||
expect(ASSISTANT_WRITE_TOOLS.create_tour).toEqual({ verbKey: 'addTour', entity: 'tour' });
|
||||
expect(ASSISTANT_UNDO_MAP.create_tour?.args({}, { tour: { place_id: 9 } }, null)).toEqual({ placeId: 9 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a held paddle or ski tour', () => {
|
||||
const PADDLE = {
|
||||
name: 'Isar',
|
||||
waypoints: [
|
||||
{ lat: 48.1, lng: 11.6 },
|
||||
{ lat: 48.11, lng: 11.6 },
|
||||
],
|
||||
water_context: 'river',
|
||||
boat_type: 'canoe',
|
||||
};
|
||||
|
||||
it('ASTCARD-TOUR-085: a paddle tour reads its plan again with its legs and boat and shows it waiting', async () => {
|
||||
const read = vi.fn(async (tool: string) => (tool === 'plan_paddle_route' ? PADDLE_PLAN : null));
|
||||
const loaded = await loadFor('create_paddle_tour', PADDLE, read);
|
||||
expect(read).toHaveBeenCalledWith('plan_paddle_route', { legs: legArgsOf(PADDLE), boat: 'canoe' });
|
||||
const preview = describeToolCall('create_paddle_tour', 'tc-4', PADDLE, ctx, loaded);
|
||||
expect(assistantChangePreviewSchema.safeParse(preview).success).toBe(true);
|
||||
expect(preview).toMatchObject({ verbKey: 'addTour', subject: 'Isar', undoable: true });
|
||||
expect(route(preview.card)).toMatchObject({ source: 'pending', activity: 'kayak', name: 'Isar', routed: true });
|
||||
expect(ASSISTANT_UNDO_MAP.create_paddle_tour?.tool).toBe('delete_place');
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-086: a plan with a failed leg or none at all is drawn straight, planned when the tour is added', async () => {
|
||||
const failed = { legs: [{ status: 'failed', code: 'not_on_water', at: null }], graph_rev: null };
|
||||
const drawn = route(describeToolCall('create_paddle_tour', 'tc-5', PADDLE, ctx, { plan: failed }).card);
|
||||
expect(drawn).toMatchObject({ activity: 'kayak', routed: false, problem: { why: 'not_planned' } });
|
||||
const SKI = { ...PADDLE, discipline: 'alpine', water_context: undefined, boat_type: undefined };
|
||||
const read = vi.fn(async () => null);
|
||||
const loaded = await loadFor('create_ski_tour', SKI, read);
|
||||
expect(read).toHaveBeenCalledWith('plan_ski_route', { legs: legArgsOf(SKI), discipline: 'alpine' });
|
||||
expect(route(describeToolCall('create_ski_tour', 'tc-6', SKI, ctx, loaded).card)).toMatchObject({
|
||||
activity: 'ski',
|
||||
routed: false,
|
||||
});
|
||||
// Too few points draw nothing, and there are no legs to plan.
|
||||
expect(await loadFor('create_ski_tour', { name: 'X' }, read)).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-087: legs run from each point to the next', () => {
|
||||
expect(
|
||||
legArgsOf({
|
||||
waypoints: [
|
||||
{ lat: 1, lng: 2 },
|
||||
{ lat: 3, lng: 4 },
|
||||
{ lat: 5, lng: 6 },
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{ from: { lat: 1, lng: 2 }, to: { lat: 3, lng: 4 } },
|
||||
{ from: { lat: 3, lng: 4 }, to: { lat: 5, lng: 6 } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what the tours tell the model about every type', () => {
|
||||
it('ASTCARD-TOUR-088: propose_tour draws its line and the prompt names a tool and profile for every type', () => {
|
||||
expect(ASSISTANT_ARG_POLICY.propose_tour?.forced).toEqual({ include_geometry: true });
|
||||
const every = CARD_PROMPT_LINES.find((line) => line.startsWith('Every tour type'));
|
||||
for (const tool of ['propose_tour', 'create_tour', 'create_paddle_tour', 'create_ski_tour', 'plan_ski_route']) {
|
||||
expect(every).toContain(tool);
|
||||
}
|
||||
// The profiles share the planning line: a package may add two prompt lines at most.
|
||||
for (const word of ['Profiles:', 'road', 'gravel', 'mtb', 'trail', 'run_pace_seconds_per_km'])
|
||||
expect(every).toContain(word);
|
||||
for (const line of CARD_PROMPT_LINES) expect(systemPrompt('English')).toContain(line);
|
||||
});
|
||||
});
|
||||
@@ -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,262 @@
|
||||
/**
|
||||
* The pure halves of a bike tour, a run, a paddle or ski tour saved from its
|
||||
* plan: the profile planned for, the time on the move, the proposal and the
|
||||
* writes, as the planner's save writes each type.
|
||||
*/
|
||||
import type { PlannedHike } from '../../../src/nest/tours/hike-routing.service';
|
||||
import {
|
||||
legsOf,
|
||||
paddleCommandOf,
|
||||
paddleMarksOf,
|
||||
skiCommandOf,
|
||||
skiLineOf,
|
||||
} from '../../../src/nest/tours/sport-tour-create.helpers';
|
||||
import {
|
||||
movingMinutesOf,
|
||||
plannedActivityOf,
|
||||
refusesSave,
|
||||
tourCreateCommandOf,
|
||||
unplannedTourMessage,
|
||||
} from '../../../src/nest/tours/tour-plan.helpers';
|
||||
import type { GeometryPoint, PaddleRouteLeg, SkiRouteLeg } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// SPORT-HELP-001 to SPORT-HELP-008
|
||||
|
||||
const routed = (distance: number, minutes: number): PlannedHike => ({
|
||||
plan: {
|
||||
kind: 'routed',
|
||||
max_hiking_difficulty: 2,
|
||||
hiking_preferences: null,
|
||||
distance_m: distance,
|
||||
ascent_m: 0,
|
||||
descent_m: 0,
|
||||
duration_minutes: minutes,
|
||||
duration_basis: 'router',
|
||||
has_heights: true,
|
||||
line: [
|
||||
[47, 11, 500],
|
||||
[47.01, 11, 500],
|
||||
],
|
||||
profile: [],
|
||||
grades: null,
|
||||
},
|
||||
line: null,
|
||||
snapped: null,
|
||||
legs: null,
|
||||
});
|
||||
|
||||
describe('tour-plan.helpers', () => {
|
||||
it("SPORT-HELP-001: a request plans for its profile, the planner's own where none is named", () => {
|
||||
expect(plannedActivityOf({ activity: 'bike' })).toEqual({ activity: 'bike', profile: 'touring' });
|
||||
expect(plannedActivityOf({ activity: 'bike', bike_profile: 'mtb' })).toEqual({ activity: 'bike', profile: 'mtb' });
|
||||
expect(plannedActivityOf({ activity: 'run' })).toEqual({ activity: 'run', profile: 'running' });
|
||||
expect(plannedActivityOf({ activity: 'run', run_profile: 'trail' })).toEqual({ activity: 'run', profile: 'trail' });
|
||||
});
|
||||
|
||||
it("SPORT-HELP-002: a ride keeps the router's time, a run its distance at the pace, and none without one", () => {
|
||||
const plan = routed(10_000, 40);
|
||||
expect(movingMinutesOf({ activity: 'bike', profile: 'road' }, null, plan)).toBe(40);
|
||||
expect(movingMinutesOf({ activity: 'run', profile: 'running' }, 330, plan)).toBe(55);
|
||||
expect(movingMinutesOf({ activity: 'run', profile: 'running' }, null, plan)).toBeNull();
|
||||
const unrouted: PlannedHike = {
|
||||
plan: { kind: 'unrouted', max_hiking_difficulty: 2, why: 'no_way', position: null, snapped_m: null },
|
||||
line: null,
|
||||
snapped: null,
|
||||
legs: null,
|
||||
};
|
||||
expect(movingMinutesOf({ activity: 'bike', profile: 'road' }, null, unrouted)).toBeNull();
|
||||
});
|
||||
|
||||
it('SPORT-HELP-003: the write names the type, its profile and pace, and nothing of a hike', () => {
|
||||
const line: GeometryPoint[] = [
|
||||
[47, 11, 500],
|
||||
[47.01, 11, 510],
|
||||
];
|
||||
const points = [
|
||||
{ lat: 47, lng: 11 },
|
||||
{ lat: 47.01, lng: 11 },
|
||||
];
|
||||
const request = { name: 'Isar', activity: 'run' as const, waypoints: points, run_pace_seconds_per_km: 330 };
|
||||
const command = tourCreateCommandOf(
|
||||
request,
|
||||
points,
|
||||
{ line, seconds: 600, route: { planned: true } },
|
||||
{ activity: 'run', profile: 'trail' },
|
||||
55,
|
||||
);
|
||||
expect(command).toMatchObject({
|
||||
tour_type: 'run',
|
||||
run_profile: 'trail',
|
||||
run_pace_seconds_per_km: 330,
|
||||
bike_profile: null,
|
||||
max_hiking_difficulty: null,
|
||||
duration_seconds: 3300,
|
||||
});
|
||||
// Without its line the tour waits for its way, and so does its time.
|
||||
const waiting = tourCreateCommandOf(
|
||||
request,
|
||||
points,
|
||||
{ line: null, seconds: null, route: { planned: false, why: 'router_unavailable', position: null } },
|
||||
{ activity: 'run', profile: 'trail' },
|
||||
55,
|
||||
);
|
||||
expect(waiting.duration_seconds).toBeNull();
|
||||
});
|
||||
|
||||
it('SPORT-HELP-004: only a router that did not answer saves a tour without its way, in words of its type', () => {
|
||||
expect(refusesSave({ planned: true })).toBe(false);
|
||||
expect(refusesSave({ planned: false, why: 'router_unavailable', position: null })).toBe(false);
|
||||
expect(refusesSave({ planned: false, why: 'no_way', position: null })).toBe(true);
|
||||
expect(unplannedTourMessage({ why: 'too_long', position: null }, 'run')).toBe(
|
||||
'The way is too long to plan as a run: split it into shorter tours. Nothing was saved.',
|
||||
);
|
||||
expect(unplannedTourMessage({ why: 'point_off_the_way', position: 2 }, 'bike')).toContain('Point 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sport-tour-create.helpers', () => {
|
||||
const water = (from: number, to: number, kind: 'water' | 'portage' | 'lock') => ({
|
||||
kind,
|
||||
from,
|
||||
to,
|
||||
name: null,
|
||||
source: null,
|
||||
osm_ref: null,
|
||||
grade: null,
|
||||
});
|
||||
|
||||
it('SPORT-HELP-005: the legs run from each point to the next', () => {
|
||||
const points = [
|
||||
{ lat: 1, lng: 1 },
|
||||
{ lat: 2, lng: 2 },
|
||||
{ lat: 3, lng: 3 },
|
||||
];
|
||||
expect(legsOf(points)).toEqual([
|
||||
{ from: points[0], to: points[1] },
|
||||
{ from: points[1], to: points[2] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('SPORT-HELP-006: portages and locks lie along the whole line, after the legs before them', () => {
|
||||
const legs: Array<Extract<PaddleRouteLeg, { status: 'ok' }>> = [
|
||||
{
|
||||
status: 'ok',
|
||||
geometry: [
|
||||
[48, 11],
|
||||
[48.01, 11],
|
||||
],
|
||||
segments: [water(0, 1, 'water')],
|
||||
distance_m: 1112,
|
||||
snapped_m: { from: 0, to: 0 },
|
||||
},
|
||||
{
|
||||
status: 'ok',
|
||||
geometry: [
|
||||
[48.01, 11],
|
||||
[48.02, 11],
|
||||
[48.03, 11],
|
||||
],
|
||||
segments: [water(0, 1, 'portage'), water(1, 1, 'lock'), water(1, 2, 'water')],
|
||||
distance_m: 2224,
|
||||
snapped_m: { from: 0, to: 0 },
|
||||
},
|
||||
];
|
||||
const marks = paddleMarksOf(legs);
|
||||
expect(marks.portages).toHaveLength(1);
|
||||
expect(Math.round(marks.portages[0]?.from_m ?? 0)).toBe(1112);
|
||||
expect(Math.round(marks.portages[0]?.to_m ?? 0)).toBe(2224);
|
||||
expect(Math.round(marks.locks[0]?.at_m ?? 0)).toBe(2224);
|
||||
expect(marks.locks[0]).toMatchObject({ wait_minutes: null, transit_minutes: null });
|
||||
|
||||
const line: GeometryPoint[] = [
|
||||
[48, 11, 500],
|
||||
[48.01, 11, 500],
|
||||
[48.02, 11, 500],
|
||||
[48.03, 11, 500],
|
||||
];
|
||||
const request = {
|
||||
name: 'Isar',
|
||||
waypoints: [
|
||||
{ lat: 48, lng: 11 },
|
||||
{ lat: 48.03, lng: 11 },
|
||||
],
|
||||
water_context: 'river' as const,
|
||||
};
|
||||
const command = paddleCommandOf(request, [], { line, marks });
|
||||
// 2224 m on the water at a kayak's 4 km/h, 1112 m carried at 2 km/h and ten minutes to get out and in.
|
||||
expect(command).toMatchObject({
|
||||
tour_type: 'kayak',
|
||||
boat_type: 'kayak',
|
||||
paddle_speed_kmh: 4,
|
||||
duration_seconds: 4620,
|
||||
});
|
||||
expect(paddleCommandOf(request, [], { line: null, marks }).duration_seconds).toBeNull();
|
||||
});
|
||||
|
||||
it('SPORT-HELP-007: a ski tour is one line with the piste heights and the router time, a missing height kept missing', () => {
|
||||
const leg = (geometry: Array<[number, number, number | null]>): Extract<SkiRouteLeg, { status: 'ok' }> => ({
|
||||
status: 'ok',
|
||||
geometry,
|
||||
segments: [],
|
||||
distance_m: 1000,
|
||||
climb_m: 0,
|
||||
descent_m: 100,
|
||||
seconds: { ski: 120, lift: 300, wait: 60, walk: 20 },
|
||||
snapped_m: { from: 0, to: 0 },
|
||||
area_ids: [],
|
||||
why: [],
|
||||
alternatives: [],
|
||||
});
|
||||
const { line, seconds } = skiLineOf([
|
||||
leg([
|
||||
[47, 11, 1800],
|
||||
[47.01, 11, 1700],
|
||||
]),
|
||||
leg([
|
||||
[47.01, 11, 1700],
|
||||
[47.02, 11, null],
|
||||
]),
|
||||
]);
|
||||
expect(line).toEqual([
|
||||
[47, 11, 1800],
|
||||
[47.01, 11, 1700],
|
||||
[47.02, 11],
|
||||
]);
|
||||
expect(seconds).toBe(1000);
|
||||
});
|
||||
|
||||
it('SPORT-HELP-008: the ski write keeps the discipline, its options as the discipline reads them and every leg planned', () => {
|
||||
const request = {
|
||||
name: 'Loipe',
|
||||
waypoints: [
|
||||
{ lat: 47, lng: 11 },
|
||||
{ lat: 47.01, lng: 11 },
|
||||
],
|
||||
discipline: 'cross_country' as const,
|
||||
technique: 'skating' as const,
|
||||
};
|
||||
const line: GeometryPoint[] = [
|
||||
[47, 11, 900],
|
||||
[47.01, 11, 910],
|
||||
];
|
||||
const command = skiCommandOf(request, [], { line, marks: null }, { build: 'b1', seconds: 1799.6, legs: 3 });
|
||||
expect(command).toMatchObject({
|
||||
tour_type: 'ski',
|
||||
ski_discipline: 'cross_country',
|
||||
ski_technique: 'skating',
|
||||
ski_route_build: 'b1',
|
||||
ski_planned_legs: [0, 1, 2],
|
||||
duration_seconds: 1800,
|
||||
});
|
||||
expect(command.ski_options).toBeTruthy();
|
||||
const alpine = skiCommandOf(
|
||||
{ ...request, discipline: 'alpine', technique: undefined },
|
||||
[],
|
||||
{ line: null, marks: null },
|
||||
{ build: null, seconds: 60, legs: 1 },
|
||||
);
|
||||
expect(alpine).toMatchObject({ ski_technique: null, duration_seconds: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* HikeRoutingService for a bike tour and a run: the way planned through the
|
||||
* same router a hike is, with the costing of the tour's profile from
|
||||
* `@trek/shared` (the one the planner in the browser sends), timed by the
|
||||
* router and without SAC grades. The router is the real one with its paced
|
||||
* queue; only the routing host behind safeFetchAdminConfigured is a stub,
|
||||
* answering /route and /height as Valhalla does.
|
||||
*/
|
||||
import { SettingsService } from '../../../src/nest/settings/settings.service';
|
||||
import {
|
||||
HikeRoutingService,
|
||||
type PlannedActivity,
|
||||
type PlannedHike,
|
||||
} from '../../../src/nest/tours/hike-routing.service';
|
||||
import { TourTrailGradeService } from '../../../src/nest/tours/tour-trail-grade.service';
|
||||
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
||||
import { safeFetchAdminConfigured } from '../../../src/utils/ssrfGuard';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { valhallaRoute } from '../../helpers/valhalla';
|
||||
import { BIKE_COSTING, RUN_COSTING, type RoutedHikePlan } from '@trek/shared';
|
||||
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../src/utils/ssrfGuard', () => ({ safeFetchAdminConfigured: vi.fn() }));
|
||||
|
||||
// TOUR-ROUTE-001 to TOUR-ROUTE-004
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
const grades = vi.fn<TourTrailGradeService['grades']>();
|
||||
const getUserSettings = vi.fn<(userId: number) => Promise<Record<string, unknown>>>();
|
||||
let t: TestModule;
|
||||
let routing: HikeRoutingService;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({
|
||||
db,
|
||||
imports: [ToursModule],
|
||||
overrides: [
|
||||
{ provide: TourTrailGradeService, useValue: { grades } },
|
||||
{ provide: SettingsService, useValue: { getUserSettings } },
|
||||
],
|
||||
});
|
||||
routing = t.get(HikeRoutingService);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
const at = (k: number): [number, number] => [47 + k / 1e4, 11];
|
||||
const stepsOf = (from: number, to: number) => Array.from({ length: to - from + 1 }, (_, index) => at(from + index));
|
||||
const WAYPOINTS = [0, 10, 20].map((k) => ({ lat: at(k)[0], lng: 11 }));
|
||||
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
|
||||
|
||||
function valhalla() {
|
||||
vi.mocked(safeFetchAdminConfigured)
|
||||
.mockReset()
|
||||
.mockImplementation(async (url, init) => {
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as { shape?: Array<{ lat: number; lon: number }> };
|
||||
if (url.endsWith('/route')) {
|
||||
return json(
|
||||
valhallaRoute([
|
||||
{ line: stepsOf(0, 10), km: 0.111, seconds: 150 },
|
||||
{ line: stepsOf(10, 20), km: 0.111, seconds: 180 },
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (url.endsWith('/height')) {
|
||||
return json({ range_height: (body.shape ?? []).map((point, index) => [index, 600 + index]) });
|
||||
}
|
||||
return json({}, 404);
|
||||
});
|
||||
}
|
||||
|
||||
const asked = (path: string) =>
|
||||
vi.mocked(safeFetchAdminConfigured).mock.calls.filter(([url]) => String(url).endsWith(path));
|
||||
const bodyOf = (path: string, index = 0) =>
|
||||
JSON.parse(String(asked(path)[index]?.[1]?.body)) as Record<string, unknown>;
|
||||
|
||||
let trip = 0;
|
||||
async function plan(activity?: PlannedActivity, sameTrip = false): Promise<PlannedHike> {
|
||||
if (!sameTrip) trip += 1;
|
||||
const pending = routing.plan(
|
||||
trip,
|
||||
trip,
|
||||
{ waypoints: WAYPOINTS },
|
||||
activity ? { grades: false, activity } : undefined,
|
||||
);
|
||||
await vi.runAllTimersAsync();
|
||||
return pending;
|
||||
}
|
||||
function routed({ plan: planned }: PlannedHike): RoutedHikePlan {
|
||||
if (planned.kind !== 'routed') throw new Error(`expected a routed plan, got ${planned.why}`);
|
||||
return planned;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
getUserSettings.mockReset().mockResolvedValue({});
|
||||
grades.mockReset();
|
||||
valhalla();
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('HikeRoutingService: a bike tour and a run', () => {
|
||||
it("TOUR-ROUTE-001: a bike tour asks the bicycle costing of its profile and keeps the router's riding time", async () => {
|
||||
const planned = routed(await plan({ activity: 'bike', profile: 'gravel' }));
|
||||
expect(bodyOf('/route')).toMatchObject({
|
||||
costing: 'bicycle',
|
||||
costing_options: { bicycle: BIKE_COSTING.gravel },
|
||||
});
|
||||
// 330 s by the router for the profile, never the signposts' rule a walker is timed by.
|
||||
expect(planned).toMatchObject({ duration_minutes: 6, duration_basis: 'router', has_heights: true, grades: null });
|
||||
expect(asked('/trace_attributes')).toHaveLength(0);
|
||||
expect(grades).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOUR-ROUTE-002: a run asks the pedestrian costing of its profile, without a hiking limit of its own', async () => {
|
||||
routed(await plan({ activity: 'run', profile: 'trail' }));
|
||||
expect(bodyOf('/route')).toMatchObject({
|
||||
costing: 'pedestrian',
|
||||
costing_options: { pedestrian: RUN_COSTING.trail },
|
||||
});
|
||||
routed(await plan({ activity: 'run', profile: 'running' }));
|
||||
expect(bodyOf('/route', 1)).toMatchObject({ costing_options: { pedestrian: { use_tracks: 0, use_ferry: 0 } } });
|
||||
});
|
||||
|
||||
it('TOUR-ROUTE-003: a ride, a run and a hike through the same points are each planned on their own', async () => {
|
||||
await plan();
|
||||
await plan({ activity: 'bike', profile: 'road' }, true);
|
||||
await plan({ activity: 'run', profile: 'running' }, true);
|
||||
await plan({ activity: 'bike', profile: 'road' }, true);
|
||||
// Three ways asked; the ride asked again comes from the memo.
|
||||
expect(asked('/route').map((call) => (JSON.parse(String(call[1]?.body)) as { costing: string }).costing)).toEqual([
|
||||
'pedestrian',
|
||||
'bicycle',
|
||||
'pedestrian',
|
||||
]);
|
||||
});
|
||||
|
||||
it('TOUR-ROUTE-004: where an own OSRM has no Valhalla a bike tour is refused as no_valhalla, never ridden by OSRM', async () => {
|
||||
getUserSettings.mockResolvedValue({ routing_base_url: 'http://osrm.internal' });
|
||||
const planned = await plan({ activity: 'bike', profile: 'touring' });
|
||||
expect(planned.plan.kind === 'unrouted' && planned.plan.why).toBe('no_valhalla');
|
||||
expect(asked('/route')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* TourPlanMcp: every tour type besides a hike and a city walk planned and
|
||||
* saved over MCP. A bike tour or a run is proposed and created through the
|
||||
* hike's own plan and save (HikePlanService), a paddle or ski tour from its
|
||||
* plan through SportTourCreateService, all real here over a snapshot test
|
||||
* db. The way on land is planned by HikeRoutingService, the legs on the water
|
||||
* and on the piste data by PaddleRoutingService and SkiRoutingService, and the
|
||||
* heights by RoadtripRouterService: stubs in this suite. The places along the
|
||||
* way and the day's weather are stubs too. The suite takes its subject from
|
||||
* the container and seeds through the ORM factories.
|
||||
*/
|
||||
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
|
||||
import { Places } from '../../../src/db/entities/Places.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 { PaddleRoutingService } from '../../../src/nest/paddle-routing/paddle-routing.service';
|
||||
import { RoadtripRouterService } from '../../../src/nest/roadtrip/roadtrip-router.service';
|
||||
import { SkiRoutingService } from '../../../src/nest/ski-routing/ski-routing.service';
|
||||
import { HikeRoutingService, type PlannedHike } from '../../../src/nest/tours/hike-routing.service';
|
||||
import { TourPlanMcp } from '../../../src/nest/tours/tour-plan.mcp';
|
||||
import { TourPoiSearchService } from '../../../src/nest/tours/tour-poi-search.service';
|
||||
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 { makeDay, 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 {
|
||||
tourProposalSchema,
|
||||
type GeometryPoint,
|
||||
type PaddleRouteResponse,
|
||||
type SkiRouteResponse,
|
||||
type SkiRouteSegment,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// TOUR-PLAN-MCP-001 to TOUR-PLAN-MCP-012
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
const plan = vi.fn<HikeRoutingService['plan']>();
|
||||
const search = vi.fn<TourPoiSearchService['search']>();
|
||||
const getDetailed = vi.fn<WeatherService['getDetailed']>();
|
||||
const paddleRoute = vi.fn<PaddleRoutingService['route']>();
|
||||
const skiRoute = vi.fn<SkiRoutingService['route']>();
|
||||
const heights = vi.fn<RoadtripRouterService['heights']>();
|
||||
let t: TestModule;
|
||||
let mcp: TourPlanMcp;
|
||||
const orm = () => t.get(MikroORM);
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({
|
||||
db,
|
||||
imports: [ToursModule],
|
||||
overrides: [
|
||||
{ provide: HikeRoutingService, useValue: { plan } },
|
||||
{ provide: TourPoiSearchService, useValue: { search } },
|
||||
{ provide: WeatherService, useValue: { getDetailed } },
|
||||
{ provide: PaddleRoutingService, useValue: { route: paddleRoute } },
|
||||
{ provide: SkiRoutingService, useValue: { route: skiRoute } },
|
||||
],
|
||||
});
|
||||
mcp = t.get(TourPlanMcp);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(db);
|
||||
plan.mockReset().mockResolvedValue(routed());
|
||||
search.mockReset().mockResolvedValue({ pois: [], truncated: false, partial: false, capped: false, unavailable: [] });
|
||||
getDetailed.mockReset().mockRejectedValue(new Error('no weather in this suite'));
|
||||
paddleRoute.mockReset().mockResolvedValue(paddled());
|
||||
skiRoute.mockReset().mockResolvedValue(skied());
|
||||
heights
|
||||
.mockReset()
|
||||
.mockImplementation(async (_userId, line) => line.map(([lat, lng]): GeometryPoint => [lat, lng, 500]));
|
||||
vi.spyOn(t.get(RoadtripRouterService), 'heights').mockImplementation(heights);
|
||||
t.realtime.broadcastMock.mockReset();
|
||||
});
|
||||
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 LINE: GeometryPoint[] = [
|
||||
[47.9, 11.1, 600],
|
||||
[47.905, 11.1, 610],
|
||||
[47.91, 11.1, 620],
|
||||
];
|
||||
|
||||
/** The stub's plan: a way of 1112 m, 25 minutes by the router, every height read. */
|
||||
function routed(): PlannedHike {
|
||||
return {
|
||||
plan: {
|
||||
kind: 'routed',
|
||||
max_hiking_difficulty: 2,
|
||||
hiking_preferences: null,
|
||||
distance_m: 1112,
|
||||
ascent_m: 20,
|
||||
descent_m: 0,
|
||||
duration_minutes: 25,
|
||||
duration_basis: 'router',
|
||||
has_heights: true,
|
||||
line: LINE,
|
||||
profile: [
|
||||
[0, 600],
|
||||
[1112, 620],
|
||||
],
|
||||
grades: null,
|
||||
},
|
||||
line: LINE,
|
||||
snapped: [3, 4, 5],
|
||||
legs: [556, 556],
|
||||
};
|
||||
}
|
||||
|
||||
const WAYPOINTS = [
|
||||
{ lat: 47.9, lng: 11.1, name: 'Station' },
|
||||
{ lat: 47.905, lng: 11.1 },
|
||||
{ lat: 47.91, lng: 11.1, name: 'Lake' },
|
||||
];
|
||||
|
||||
/** Two legs on the water, the second carried round a weir halfway. */
|
||||
function paddled(): PaddleRouteResponse {
|
||||
const segment = (kind: 'water' | 'portage', from: number, to: number, name: string | null = 'Isar') => ({
|
||||
kind,
|
||||
from,
|
||||
to,
|
||||
name,
|
||||
source: kind === 'portage' ? ('tagged' as const) : null,
|
||||
osm_ref: null,
|
||||
grade: null,
|
||||
});
|
||||
return {
|
||||
legs: [
|
||||
{
|
||||
status: 'ok',
|
||||
geometry: [
|
||||
[48.1, 11.6],
|
||||
[48.11, 11.6],
|
||||
],
|
||||
segments: [segment('water', 0, 1)],
|
||||
distance_m: 1112,
|
||||
snapped_m: { from: 2, to: 3 },
|
||||
},
|
||||
{
|
||||
status: 'ok',
|
||||
geometry: [
|
||||
[48.11, 11.6],
|
||||
[48.115, 11.6],
|
||||
[48.12, 11.6],
|
||||
],
|
||||
segments: [segment('water', 0, 1), segment('portage', 1, 2, null)],
|
||||
distance_m: 1112,
|
||||
snapped_m: { from: 1, to: 2 },
|
||||
},
|
||||
],
|
||||
graph_rev: 'r1',
|
||||
};
|
||||
}
|
||||
|
||||
const PADDLE_POINTS = [
|
||||
{ lat: 48.1, lng: 11.6, name: 'Put-in' },
|
||||
{ lat: 48.11, lng: 11.6 },
|
||||
{ lat: 48.12, lng: 11.6, name: 'Take-out' },
|
||||
];
|
||||
|
||||
/** One run down, its heights from the piste data, four minutes skied and a minute at the lift. */
|
||||
function skied(): SkiRouteResponse {
|
||||
const segment: SkiRouteSegment = {
|
||||
kind: 'run',
|
||||
from: 0,
|
||||
to: 1,
|
||||
name: 'Kandahar',
|
||||
ref: null,
|
||||
difficulty: 'intermediate',
|
||||
lift_type: null,
|
||||
grooming: [],
|
||||
seconds: 240,
|
||||
wait_seconds: 0,
|
||||
length_m: 1112,
|
||||
flags: [],
|
||||
osm_way: null,
|
||||
};
|
||||
return {
|
||||
legs: [
|
||||
{
|
||||
status: 'ok',
|
||||
geometry: [
|
||||
[47.4, 11.0, 1800],
|
||||
[47.41, 11.0, 1500],
|
||||
],
|
||||
segments: [segment],
|
||||
distance_m: 1112,
|
||||
climb_m: 0,
|
||||
descent_m: 300,
|
||||
seconds: { ski: 240, lift: 0, wait: 60, walk: 0 },
|
||||
snapped_m: { from: 1, to: 1 },
|
||||
area_ids: ['a1'],
|
||||
why: [],
|
||||
alternatives: [],
|
||||
},
|
||||
],
|
||||
build: 'b7',
|
||||
stale: false,
|
||||
area_names: ['Garmisch-Classic'],
|
||||
colour_scheme: 'europe',
|
||||
attribution: 'OpenStreetMap',
|
||||
};
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const { user } = await makeUser(orm());
|
||||
const { user: outsider } = await makeUser(orm());
|
||||
const trip = await makeTrip(orm(), user.id);
|
||||
return { user, outsider, trip };
|
||||
}
|
||||
|
||||
describe('TourPlanMcp: the tools and their gates', () => {
|
||||
it('TOUR-PLAN-MCP-001: the proposal reads only, the creates write, all on the Tours addon', async () => {
|
||||
const options = (method: string) => {
|
||||
const entry = getEntry(TourPlanMcp as unknown as ClassRef, method);
|
||||
if (!entry || entry.kind !== 'tool') throw new Error(`${method} is no MCP tool`);
|
||||
return entry.options;
|
||||
};
|
||||
const tools = ['proposeTour', 'createTour', 'createPaddleTour', 'createSkiTour'].map(options);
|
||||
expect(tools.map((tool) => [tool.name, tool.annotations?.readOnlyHint, tool.access])).toEqual([
|
||||
['propose_tour', true, { group: 'trips', mode: 'read' }],
|
||||
['create_tour', false, { group: 'trips', mode: 'write' }],
|
||||
['create_paddle_tour', false, { group: 'trips', mode: 'write' }],
|
||||
['create_ski_tour', false, { group: 'trips', mode: 'write' }],
|
||||
]);
|
||||
// The model is told which tool plans which type, and which profile to pick.
|
||||
expect(tools[0].description).toContain('propose_hike');
|
||||
expect(tools[0].description).toContain('gravel');
|
||||
expect(tools[0].description).toContain('trail');
|
||||
expect(tools[1].description).toContain('run_pace_seconds_per_km');
|
||||
expect(tools[2].description).toContain('water_context');
|
||||
expect(tools[3].description).toContain('discipline');
|
||||
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, true, true]);
|
||||
});
|
||||
|
||||
it('TOUR-PLAN-MCP-002: an outsider is refused every tool, the creates without place_edit or, on a day, day_edit', async () => {
|
||||
const f = await fixture();
|
||||
const outsider = ctx(f.outsider.id);
|
||||
const noAccess = { error: 'Trip not found or access denied.' };
|
||||
const bike = { tripId: f.trip.id, name: 'Lake', activity: 'bike' as const, waypoints: WAYPOINTS };
|
||||
const paddle = { tripId: f.trip.id, name: 'Isar', waypoints: PADDLE_POINTS, water_context: 'river' as const };
|
||||
const ski = { tripId: f.trip.id, name: 'Kandahar', waypoints: PADDLE_POINTS, discipline: 'alpine' as const };
|
||||
expect(read(await mcp.proposeTour(bike, outsider))).toEqual(noAccess);
|
||||
expect(read(await mcp.createTour(bike, outsider))).toEqual(noAccess);
|
||||
expect(read(await mcp.createPaddleTour(paddle, outsider))).toEqual(noAccess);
|
||||
expect(read(await mcp.createSkiTour(ski, outsider))).toEqual(noAccess);
|
||||
|
||||
const own = ctx(f.user.id);
|
||||
const guards = t.get(McpToolGuardsService);
|
||||
const denied = vi.spyOn(guards, 'hasTripPermission').mockResolvedValue(false);
|
||||
try {
|
||||
for (const result of [
|
||||
await mcp.createTour(bike, own),
|
||||
await mcp.createPaddleTour(paddle, own),
|
||||
await mcp.createSkiTour(ski, own),
|
||||
]) {
|
||||
expect(result.isError).toBe(true);
|
||||
}
|
||||
expect(denied).toHaveBeenCalledWith('place_edit', f.trip.id, f.user.id);
|
||||
} finally {
|
||||
denied.mockRestore();
|
||||
}
|
||||
const day = await makeDay(orm(), f.trip.id, { day_number: 1, date: '2027-06-01' });
|
||||
const noDayEdit = vi.spyOn(guards, 'hasTripPermission').mockImplementation(async (action) => action !== 'day_edit');
|
||||
try {
|
||||
expect((await mcp.createTour({ ...bike, day_id: day.id }, own)).isError).toBe(true);
|
||||
expect(noDayEdit).toHaveBeenCalledWith('day_edit', f.trip.id, f.user.id);
|
||||
} finally {
|
||||
noDayEdit.mockRestore();
|
||||
}
|
||||
expect(await countRows(orm(), Places, { trip: f.trip.id })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TourPlanMcp: propose_tour', () => {
|
||||
it('TOUR-PLAN-MCP-003: a bike tour is planned for its profile and timed by the router, nothing of a hike in it', async () => {
|
||||
const f = await fixture();
|
||||
const proposal = read(
|
||||
await mcp.proposeTour(
|
||||
{ tripId: f.trip.id, name: 'Lake', activity: 'bike', bike_profile: 'gravel', waypoints: WAYPOINTS },
|
||||
ctx(f.user.id),
|
||||
),
|
||||
);
|
||||
expect(tourProposalSchema.parse(proposal)).toEqual(proposal);
|
||||
expect(proposal).toMatchObject({
|
||||
activity: 'bike',
|
||||
bike_profile: 'gravel',
|
||||
run_profile: null,
|
||||
duration_minutes: 25,
|
||||
duration_basis: 'router',
|
||||
max_hiking_difficulty: null,
|
||||
hiking_preferences: null,
|
||||
sac: null,
|
||||
});
|
||||
// Shared out over the legs by their length: halfway at the middle point.
|
||||
expect(proposal.waypoints.map((point: { minutes: number | null }) => point.minutes)).toEqual([0, 13, 25]);
|
||||
expect(plan.mock.calls[0][3]).toEqual({ grades: false, activity: { activity: 'bike', profile: 'gravel' } });
|
||||
// The places a ride stops for are looked for along it, a bike repair shop among them.
|
||||
expect(search).toHaveBeenCalledWith(expect.objectContaining({ tour_type: 'bike' }));
|
||||
expect(search.mock.calls[0][0].categories).toContain('bike_repair');
|
||||
});
|
||||
|
||||
it("TOUR-PLAN-MCP-004: a run is timed at the runner's pace, and has no time without one", async () => {
|
||||
const f = await fixture();
|
||||
const base = { tripId: f.trip.id, name: 'Isar', activity: 'run' as const, waypoints: WAYPOINTS, along: false };
|
||||
const paced = read(await mcp.proposeTour({ ...base, run_pace_seconds_per_km: 360 }, ctx(f.user.id)));
|
||||
expect(paced).toMatchObject({
|
||||
run_profile: 'running',
|
||||
run_pace_seconds_per_km: 360,
|
||||
duration_minutes: 7,
|
||||
duration_basis: 'pace',
|
||||
});
|
||||
const unpaced = read(await mcp.proposeTour({ ...base, run_profile: 'trail' }, ctx(f.user.id)));
|
||||
expect(unpaced).toMatchObject({ run_profile: 'trail', duration_minutes: null, duration_basis: null });
|
||||
expect(unpaced.waypoints.every((point: { minutes: number | null }) => point.minutes === null)).toBe(true);
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOUR-PLAN-MCP-005: refuses a profile of the other kind, which the tool shape alone would let through', async () => {
|
||||
const f = await fixture();
|
||||
await expect(
|
||||
mcp.proposeTour(
|
||||
{ tripId: f.trip.id, name: 'X', activity: 'bike', run_profile: 'trail', waypoints: WAYPOINTS },
|
||||
ctx(f.user.id),
|
||||
),
|
||||
).rejects.toThrow('run_profile belongs to a run');
|
||||
expect(plan).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TourPlanMcp: create_tour', () => {
|
||||
it('TOUR-PLAN-MCP-006: a bike tour is saved as the planner saves one, its profile and riding time kept', async () => {
|
||||
const f = await fixture();
|
||||
const created = read(
|
||||
await mcp.createTour(
|
||||
{ tripId: f.trip.id, name: 'Lake', activity: 'bike', bike_profile: 'road', waypoints: WAYPOINTS },
|
||||
ctx(f.user.id),
|
||||
),
|
||||
);
|
||||
expect(created.route).toEqual({ planned: true });
|
||||
expect(created.tour).toMatchObject({
|
||||
tour_type: 'bike',
|
||||
bike_profile: 'road',
|
||||
max_hiking_difficulty: null,
|
||||
duration: 25,
|
||||
route_pending: false,
|
||||
});
|
||||
const place = await findRow(orm(), Places, { id: created.tour.place_id });
|
||||
expect(JSON.parse(String(place?.route_geometry))).toEqual(LINE);
|
||||
expect(created.assignment).toBeNull();
|
||||
});
|
||||
|
||||
it('TOUR-PLAN-MCP-007: a run is saved with its pace and the time at that pace, put on its day with its start', async () => {
|
||||
const f = await fixture();
|
||||
const day = await makeDay(orm(), f.trip.id, { day_number: 1, date: '2027-06-01' });
|
||||
const created = read(
|
||||
await mcp.createTour(
|
||||
{
|
||||
tripId: f.trip.id,
|
||||
name: 'Isar',
|
||||
activity: 'run',
|
||||
run_profile: 'trail',
|
||||
run_pace_seconds_per_km: 360,
|
||||
waypoints: WAYPOINTS,
|
||||
day_id: day.id,
|
||||
start_time: '07:30',
|
||||
},
|
||||
ctx(f.user.id),
|
||||
),
|
||||
);
|
||||
expect(created.tour).toMatchObject({
|
||||
tour_type: 'run',
|
||||
run_profile: 'trail',
|
||||
run_pace_seconds_per_km: 360,
|
||||
duration: 7,
|
||||
});
|
||||
expect(created.assignment).toEqual({ id: expect.any(Number), day_id: day.id });
|
||||
const visit = await findRow(orm(), DayAssignments, { id: created.assignment.id });
|
||||
expect(visit?.assignment_time).toBe('07:30');
|
||||
});
|
||||
|
||||
it("TOUR-PLAN-MCP-008: refuses a run without the runner's pace and a way no profile joins; nothing is saved", async () => {
|
||||
const f = await fixture();
|
||||
await expect(
|
||||
mcp.createTour({ tripId: f.trip.id, name: 'Isar', activity: 'run', waypoints: WAYPOINTS }, ctx(f.user.id)),
|
||||
).rejects.toThrow("a run is saved with the runner's pace");
|
||||
plan.mockResolvedValue({
|
||||
plan: { kind: 'unrouted', max_hiking_difficulty: 2, why: 'no_way', position: null, snapped_m: null },
|
||||
line: null,
|
||||
snapped: null,
|
||||
legs: null,
|
||||
});
|
||||
await expect(
|
||||
mcp.createTour({ tripId: f.trip.id, name: 'Lake', activity: 'bike', waypoints: WAYPOINTS }, ctx(f.user.id)),
|
||||
).rejects.toThrow('No way for a bike tour of this profile joins these points');
|
||||
expect(await countRows(orm(), Places, { trip: f.trip.id })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TourPlanMcp: create_paddle_tour', () => {
|
||||
it('TOUR-PLAN-MCP-009: saves the legs planned again on the water with their heights and the estimate with the portage', async () => {
|
||||
const f = await fixture();
|
||||
const created = read(
|
||||
await mcp.createPaddleTour(
|
||||
{ tripId: f.trip.id, name: 'Isar', waypoints: PADDLE_POINTS, water_context: 'river', boat_type: 'canoe' },
|
||||
ctx(f.user.id),
|
||||
),
|
||||
);
|
||||
expect(paddleRoute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
boat: 'canoe',
|
||||
legs: [
|
||||
{ from: PADDLE_POINTS[0], to: PADDLE_POINTS[1] },
|
||||
{ from: PADDLE_POINTS[1], to: PADDLE_POINTS[2] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(created.route).toEqual({ planned: true });
|
||||
expect(created.tour).toMatchObject({ tour_type: 'kayak', boat_type: 'canoe', water_context: 'river' });
|
||||
// Water at the canoe's speed, the portage carried at 2 km/h with its ten minutes for getting out and in.
|
||||
expect(created.tour.duration).toBeGreaterThan(10);
|
||||
const place = await findRow(orm(), Places, { id: created.tour.place_id });
|
||||
const line = JSON.parse(String(place?.route_geometry)) as GeometryPoint[];
|
||||
// The legs joined into one line, the point they share once.
|
||||
expect(line).toHaveLength(4);
|
||||
expect(line.every((point) => point[2] === 500)).toBe(true);
|
||||
});
|
||||
|
||||
it('TOUR-PLAN-MCP-010: refuses a leg the water router cannot plan, and waits for its way without heights', async () => {
|
||||
const f = await fixture();
|
||||
const request = { tripId: f.trip.id, name: 'Isar', waypoints: PADDLE_POINTS, water_context: 'lake' as const };
|
||||
paddleRoute.mockResolvedValueOnce({
|
||||
legs: [paddled().legs[0], { status: 'failed', code: 'not_on_water', at: null }],
|
||||
graph_rev: null,
|
||||
});
|
||||
await expect(mcp.createPaddleTour(request, ctx(f.user.id))).rejects.toThrow(
|
||||
'Leg 2 could not be planned on the water (not_on_water)',
|
||||
);
|
||||
expect(await countRows(orm(), Places, { trip: f.trip.id })).toBe(0);
|
||||
heights.mockRejectedValueOnce(new Error('height host down'));
|
||||
const waiting = read(await mcp.createPaddleTour(request, ctx(f.user.id)));
|
||||
expect(waiting.route).toEqual({ planned: false, why: 'router_unavailable', position: null });
|
||||
expect(waiting.tour.route_pending).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TourPlanMcp: create_ski_tour', () => {
|
||||
it('TOUR-PLAN-MCP-011: saves the legs planned again on the piste data with its heights, its time, build and options', async () => {
|
||||
const f = await fixture();
|
||||
const points = PADDLE_POINTS.slice(0, 2);
|
||||
const created = read(
|
||||
await mcp.createSkiTour(
|
||||
{ tripId: f.trip.id, name: 'Kandahar', waypoints: points, discipline: 'alpine' },
|
||||
ctx(f.user.id),
|
||||
),
|
||||
);
|
||||
expect(created.tour).toMatchObject({ tour_type: 'ski', ski_discipline: 'alpine', duration: 5 });
|
||||
// The piste data had a height for every point: Valhalla is not asked.
|
||||
expect(heights).not.toHaveBeenCalled();
|
||||
const place = await findRow(orm(), Places, { id: created.tour.place_id });
|
||||
expect(JSON.parse(String(place?.route_geometry))).toEqual([
|
||||
[47.4, 11.0, 1800],
|
||||
[47.41, 11.0, 1500],
|
||||
]);
|
||||
});
|
||||
|
||||
it('TOUR-PLAN-MCP-012: refuses a cross-country tour without its technique and a leg the piste router cannot plan', async () => {
|
||||
const f = await fixture();
|
||||
const base = { tripId: f.trip.id, name: 'Loipe', waypoints: PADDLE_POINTS.slice(0, 2) };
|
||||
await expect(mcp.createSkiTour({ ...base, discipline: 'cross_country' }, ctx(f.user.id))).rejects.toThrow(
|
||||
'a cross-country tour needs a technique',
|
||||
);
|
||||
skiRoute.mockResolvedValueOnce({
|
||||
...skied(),
|
||||
legs: [{ status: 'failed', code: 'loading', at: null, hint: null, detail: null }],
|
||||
});
|
||||
await expect(mcp.createSkiTour({ ...base, discipline: 'alpine' }, ctx(f.user.id))).rejects.toThrow(
|
||||
'Leg 1 could not be planned on the piste data (loading)',
|
||||
);
|
||||
expect(await countRows(orm(), Places, { trip: f.trip.id })).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -157,6 +157,24 @@ describe('tourRouteCardSchema', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('tourRouteCardSchema: bike tours and runs', () => {
|
||||
it('TOURCARD-008: a bike tour or a run names its profile, a run its pace; a card drawn before reads without both', () => {
|
||||
const bike = hike({
|
||||
activity: 'bike',
|
||||
routingProfile: 'gravel',
|
||||
minutesBasis: 'router',
|
||||
limit: null,
|
||||
grades: null,
|
||||
});
|
||||
expect(tourRouteCardSchema.parse(bike).routingProfile).toBe('gravel');
|
||||
const run = hike({ activity: 'run', routingProfile: 'trail', minutesBasis: 'pace', paceSecondsPerKm: 330 });
|
||||
expect(tourRouteCardSchema.parse(run).paceSecondsPerKm).toBe(330);
|
||||
expect(tourRouteCardSchema.parse(hike()).routingProfile).toBeUndefined();
|
||||
expect(tourRouteCardSchema.safeParse(hike({ routingProfile: 'tandem' as never })).success).toBe(false);
|
||||
expect(tourRouteCardSchema.safeParse(hike({ paceSecondsPerKm: 5 })).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tourListCardSchema', () => {
|
||||
const row = {
|
||||
placeId: 61,
|
||||
|
||||
@@ -3,7 +3,12 @@ import { skiColourSchemeSchema, skiRunDifficultySchema, skiSegmentKindSchema } f
|
||||
import { HIKE_UNROUTED_REASONS } from '../../tours/hike-plan.schema';
|
||||
import { HIKE_WARNING_KINDS } from '../../tours/hike-schedule';
|
||||
import { tourPoiCategorySchema } from '../../tours/tour-poi-category';
|
||||
import { tourMaxHikingDifficultySchema, tourTypeKeySchema } from '../../tours/tours.schema';
|
||||
import {
|
||||
BIKE_PROFILES,
|
||||
RUN_PROFILES,
|
||||
tourMaxHikingDifficultySchema,
|
||||
tourTypeKeySchema,
|
||||
} from '../../tours/tours.schema';
|
||||
import { cardClockSchema, cardDateSchema, cardLatSchema, cardLngSchema, cardPointSchema, cardText } from './card-parts';
|
||||
|
||||
import { z } from 'zod';
|
||||
@@ -95,6 +100,9 @@ export const TOUR_WARNING_KINDS = [...HIKE_WARNING_KINDS, 'legs_failed'] as cons
|
||||
*/
|
||||
export const TOUR_ROUTE_PROBLEMS = [...HIKE_UNROUTED_REASONS, 'ferry_only', 'not_planned'] as const;
|
||||
|
||||
/** The profile a bike tour or a run is planned for, as the Tours tab names it. */
|
||||
export const TOUR_ROUTE_PROFILES = [...BIKE_PROFILES, ...RUN_PROFILES] as const;
|
||||
|
||||
export const tourRouteScheduleSchema = z.object({
|
||||
date: cardDateSchema,
|
||||
dayId: idSchema.nullable(),
|
||||
@@ -136,7 +144,15 @@ export const tourRouteCardSchema = z.object({
|
||||
minEleM: height.nullable(),
|
||||
maxEleM: height.nullable(),
|
||||
minutes: minutes.nullable(),
|
||||
minutesBasis: z.enum(['din33466', 'router', 'planned', 'stations', 'piste']).nullable(),
|
||||
/** pace: a run's distance at the runner's pace. */
|
||||
minutesBasis: z.enum(['din33466', 'router', 'planned', 'stations', 'piste', 'pace']).nullable(),
|
||||
/**
|
||||
* A bike tour's or a run's profile (road, gravel, trail ...); null for every
|
||||
* other activity. Left out by a card drawn before it was sent.
|
||||
*/
|
||||
routingProfile: z.enum(TOUR_ROUTE_PROFILES).nullable().optional(),
|
||||
/** A run's pace in seconds per kilometre; null without one. Left out like the profile. */
|
||||
paceSecondsPerKm: z.number().int().min(60).max(3_600).nullable().optional(),
|
||||
/** The hiking limit the way was planned under, a setting rather than a grade of the path. */
|
||||
limit: tourMaxHikingDifficultySchema.nullable(),
|
||||
grades: z.object({ highest: sacStep.nullable(), unknownShare: z.number().min(0).max(1) }).nullable(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Paint: what fills a shape or a page when one flat colour is not enough.
|
||||
*
|
||||
* Four kinds, and each is something a printed page can carry as vector:
|
||||
*
|
||||
* - `solid`: one colour, with an alpha of its own. The colour stays `#rrggbb`
|
||||
* and the alpha is a separate number, so every reader that knows hex keeps
|
||||
* reading the colour, and print gets `fill-opacity` rather than a colour
|
||||
* format some RIP does not know.
|
||||
* - `linear`: two to six stops along a line at `angle` degrees. The angle
|
||||
* reads the way CSS reads it: 0 runs towards the top, 90 towards the right.
|
||||
* - `radial`: the same stops from a centre (`cx`, `cy` as fractions of the
|
||||
* box) out to `r`, a fraction of half the box's diagonal, so 1 reaches the
|
||||
* corners from the middle whatever the box's proportions.
|
||||
* - `pattern`: one of the twelve repeats, `fg` drawn over `bg` (null lets what
|
||||
* is underneath show through), its cell `scale` times the natural 8 mm and
|
||||
* turned by `angle`.
|
||||
*
|
||||
* Paint is additive. A shape keeps its `fill` and `gradient` and a page its
|
||||
* `background`, set to the colour that stands for the paint (book-paint.ts),
|
||||
* so a reader from before paint existed still draws something close and a
|
||||
* finding that measures contrast still has a colour to measure.
|
||||
*/
|
||||
|
||||
const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected #rrggbb');
|
||||
const alpha = z.number().min(0).max(1);
|
||||
const angle = z.number().finite().min(-360).max(360);
|
||||
|
||||
/** The repeats a pattern paint draws, each as explicit geometry clipped to the box. */
|
||||
export const BOOK_PATTERNS = [
|
||||
'checker',
|
||||
'gingham',
|
||||
'stripes',
|
||||
'pinstripe',
|
||||
'dots',
|
||||
'polka',
|
||||
'zigzag',
|
||||
'waves',
|
||||
'grid',
|
||||
'scales',
|
||||
'sunburst',
|
||||
'confetti',
|
||||
] as const;
|
||||
export type BookPattern = (typeof BOOK_PATTERNS)[number];
|
||||
|
||||
/** A gradient has at least two stops and at most this many. */
|
||||
export const MAX_PAINT_STOPS = 6;
|
||||
/** The scale range of a pattern, against its natural 8 mm cell. */
|
||||
export const PATTERN_SCALE = { min: 0.25, max: 4 } as const;
|
||||
/** The radius range of a radial gradient, as a fraction of half the box's diagonal. */
|
||||
export const RADIAL_RADIUS = { min: 0.05, max: 2 } as const;
|
||||
|
||||
type ObjectFactory = typeof z.object | typeof z.strictObject;
|
||||
|
||||
/**
|
||||
* The union, built once for the document (plain objects, which drop a field
|
||||
* they do not know, as the rest of the document does) and once for the
|
||||
* operations (strict, as every operation is), so the two cannot drift.
|
||||
*/
|
||||
function paintSchemas(obj: ObjectFactory) {
|
||||
const stop = obj({ at: z.number().min(0).max(1), color: hex, alpha: alpha.optional() });
|
||||
const stops = z.array(stop).min(2).max(MAX_PAINT_STOPS);
|
||||
const paint = z.discriminatedUnion('type', [
|
||||
obj({ type: z.literal('solid'), color: hex, alpha: alpha.optional() }),
|
||||
obj({ type: z.literal('linear'), angle, stops }),
|
||||
obj({
|
||||
type: z.literal('radial'),
|
||||
cx: z.number().min(0).max(1),
|
||||
cy: z.number().min(0).max(1),
|
||||
r: z.number().min(RADIAL_RADIUS.min).max(RADIAL_RADIUS.max),
|
||||
stops,
|
||||
}),
|
||||
obj({
|
||||
type: z.literal('pattern'),
|
||||
pattern: z.enum(BOOK_PATTERNS),
|
||||
fg: hex,
|
||||
bg: hex.nullable(),
|
||||
scale: z.number().min(PATTERN_SCALE.min).max(PATTERN_SCALE.max),
|
||||
angle,
|
||||
}),
|
||||
]);
|
||||
return { stop, paint };
|
||||
}
|
||||
|
||||
const docSchemas = paintSchemas(z.object);
|
||||
|
||||
export const bookPaintStopSchema = docSchemas.stop;
|
||||
export const bookPaintSchema = docSchemas.paint;
|
||||
export type BookPaint = z.infer<typeof bookPaintSchema>;
|
||||
export type BookPaintStop = z.infer<typeof bookPaintStopSchema>;
|
||||
export type BookGradientPaint = Extract<BookPaint, { type: 'linear' | 'radial' }>;
|
||||
export type BookPatternPaint = Extract<BookPaint, { type: 'pattern' }>;
|
||||
|
||||
/**
|
||||
* Paint as the page operations take it. Described once, here, because the
|
||||
* tool schema is all a model reads of it and it appears in three places.
|
||||
*/
|
||||
export const bookPaintInSchema = paintSchemas(z.strictObject).paint.describe(
|
||||
'solid (alpha 0..1), linear (angle: 0 towards the top, 90 towards the right) or radial (cx, cy 0..1 of the box, r of half its diagonal) with 2 to 6 stops at 0..1, or a pattern: fg on bg (null shows through), scale 1 is an 8 mm cell.',
|
||||
);
|
||||
|
||||
/**
|
||||
* The optional field a shape and a page carry. A paint the reader cannot read
|
||||
* falls back to nothing, which leaves the colour beside it to draw, rather
|
||||
* than failing the element or the page it sits on.
|
||||
*/
|
||||
export const bookPaintFieldSchema = bookPaintSchema.optional().catch(undefined);
|
||||
@@ -1,290 +0,0 @@
|
||||
import { normalizeBookDocument } from './book-normalize';
|
||||
import { applyBookOps } from './book-ops';
|
||||
import { bookPageOpSchema } from './book-ops.schema';
|
||||
import {
|
||||
bookColours,
|
||||
legacyShapePaint,
|
||||
paintColours,
|
||||
paintFallbackHex,
|
||||
paintWords,
|
||||
shapePaint,
|
||||
sortedStops,
|
||||
spreadPaint,
|
||||
withShapePaint,
|
||||
withSpreadPaint,
|
||||
} from './book-paint';
|
||||
import { BOOK_PATTERNS, bookPaintInSchema, bookPaintSchema, type BookPaint } from './book-paint.schema';
|
||||
import { applySavedBookLayout, bookLayoutFromSpread, bookLayoutPreview } from './book-saved-layouts';
|
||||
import {
|
||||
bookLayoutSchema,
|
||||
bookPageSetupSchema,
|
||||
bookShapeElementSchema,
|
||||
bookSpreadSchema,
|
||||
type BookDocument,
|
||||
type BookShapeElement,
|
||||
type BookSpread,
|
||||
} from './book.schema';
|
||||
import { describeSpread } from './describe-spread';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// BKPAINT-001 to BKPAINT-016
|
||||
|
||||
/**
|
||||
* Paint: the document's gradients, alpha and patterns, how they stay readable
|
||||
* for a reader from before them, and what the page operations and the page
|
||||
* description make of them.
|
||||
*/
|
||||
|
||||
const frame = { x: 10, y: 10, w: 60, h: 40 };
|
||||
const shape = (over: Record<string, unknown> = {}): BookShapeElement =>
|
||||
bookShapeElementSchema.parse({ id: 's1', kind: 'shape', frame, ...over });
|
||||
|
||||
const linear: BookPaint = {
|
||||
type: 'linear',
|
||||
angle: 45,
|
||||
stops: [
|
||||
{ at: 0, color: '#ff0000' },
|
||||
{ at: 1, color: '#0000ff' },
|
||||
],
|
||||
};
|
||||
const radial: BookPaint = {
|
||||
type: 'radial',
|
||||
cx: 0.3,
|
||||
cy: 0.4,
|
||||
r: 0.8,
|
||||
stops: [
|
||||
{ at: 0, color: '#ffffff', alpha: 0.5 },
|
||||
{ at: 1, color: '#000000' },
|
||||
],
|
||||
};
|
||||
const stripes: BookPaint = { type: 'pattern', pattern: 'stripes', fg: '#112233', bg: '#ffeedd', scale: 1, angle: 45 };
|
||||
|
||||
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
|
||||
const doc = (spreads: unknown[]): BookDocument => normalizeBookDocument({ version: 1, title: 'T', page, spreads });
|
||||
const spread = (over: Record<string, unknown> = {}): BookSpread =>
|
||||
bookSpreadSchema.parse({ id: 'sp1', role: 'inner', elements: [], ...over });
|
||||
|
||||
describe('the paint contract', () => {
|
||||
it('BKPAINT-001: reads the four kinds and the twelve patterns', () => {
|
||||
for (const p of [{ type: 'solid', color: '#abcdef', alpha: 0.4 }, linear, radial, stripes]) {
|
||||
expect(bookPaintSchema.parse(p)).toEqual(p);
|
||||
}
|
||||
expect(BOOK_PATTERNS).toHaveLength(12);
|
||||
for (const pattern of BOOK_PATTERNS) expect(bookPaintSchema.safeParse({ ...stripes, pattern }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('BKPAINT-002: refuses a gradient of one stop or of seven, a colour that is not hex and an alpha past 1', () => {
|
||||
const stop = { at: 0, color: '#000000' };
|
||||
expect(bookPaintSchema.safeParse({ ...linear, stops: [stop] }).success).toBe(false);
|
||||
expect(bookPaintSchema.safeParse({ ...linear, stops: Array(7).fill(stop) }).success).toBe(false);
|
||||
expect(bookPaintSchema.safeParse({ type: 'solid', color: 'red' }).success).toBe(false);
|
||||
expect(bookPaintSchema.safeParse({ type: 'solid', color: '#000000', alpha: 1.5 }).success).toBe(false);
|
||||
expect(bookPaintSchema.safeParse({ ...stripes, scale: 9 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('BKPAINT-003: an operation paint is strict, a stored one drops what it does not know', () => {
|
||||
expect(bookPaintInSchema.safeParse({ type: 'solid', color: '#000000', glow: 1 }).success).toBe(false);
|
||||
expect(bookPaintSchema.parse({ type: 'solid', color: '#000000', glow: 1 })).toEqual({
|
||||
type: 'solid',
|
||||
color: '#000000',
|
||||
});
|
||||
});
|
||||
|
||||
it('BKPAINT-004: a paint the reader cannot read falls back to the fill beside it, and the shape still reads', () => {
|
||||
const el = bookShapeElementSchema.parse({
|
||||
id: 's1',
|
||||
kind: 'shape',
|
||||
frame,
|
||||
fill: '#123456',
|
||||
paint: { type: 'plaid' },
|
||||
});
|
||||
expect(el.paint).toBeUndefined();
|
||||
expect(el.fill).toBe('#123456');
|
||||
const sp = bookSpreadSchema.parse({
|
||||
id: 'sp',
|
||||
background: '#eeeeee',
|
||||
backgroundPaint: { type: 'linear', stops: [] },
|
||||
});
|
||||
expect(sp.backgroundPaint).toBeUndefined();
|
||||
expect(sp.background).toBe('#eeeeee');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the colour that stands for a paint', () => {
|
||||
it('BKPAINT-005: is the colour of a solid, a see-through solid as it looks on the paper', () => {
|
||||
expect(paintFallbackHex({ type: 'solid', color: '#FF0000' })).toBe('#ff0000');
|
||||
expect(paintFallbackHex({ type: 'solid', color: '#000000', alpha: 0.5 })).toBe('#808080');
|
||||
});
|
||||
|
||||
it('BKPAINT-006: mixes a gradient by the stretch each stop holds, and takes a pattern by its ground', () => {
|
||||
expect(paintFallbackHex(linear)).toBe('#800080');
|
||||
const weighted: BookPaint = {
|
||||
type: 'linear',
|
||||
angle: 0,
|
||||
stops: [
|
||||
{ at: 0.5, color: '#000000' },
|
||||
{ at: 0, color: '#ffffff' },
|
||||
],
|
||||
};
|
||||
expect(sortedStops(weighted.type === 'linear' ? weighted.stops : []).map((s) => s.at)).toEqual([0, 0.5]);
|
||||
// White holds a quarter of the line, black the other three.
|
||||
expect(paintFallbackHex(weighted)).toBe('#404040');
|
||||
expect(paintFallbackHex(stripes)).toBe('#ffeedd');
|
||||
expect(paintFallbackHex({ ...stripes, bg: null } as BookPaint)).toBe('#112233');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a shape and its paint', () => {
|
||||
it('BKPAINT-007: reads its old fill and fade as the paint they draw', () => {
|
||||
expect(legacyShapePaint(shape({ fill: null }))).toBeNull();
|
||||
expect(shapePaint(shape({ fill: '#ABCDEF' }))).toEqual({ type: 'solid', color: '#abcdef' });
|
||||
const up = shapePaint(shape({ fill: '#102030', gradient: 'up' }));
|
||||
expect(up).toMatchObject({ type: 'linear', angle: 0 });
|
||||
expect(up?.type === 'linear' && up.stops.map((s) => s.alpha)).toEqual([0, 0.55, 1]);
|
||||
expect(shapePaint(shape({ gradient: 'down' }))).toMatchObject({ angle: 180 });
|
||||
expect(shapePaint(shape({ paint: stripes }))).toEqual(stripes);
|
||||
});
|
||||
|
||||
it('BKPAINT-008: stores one opaque colour and the cover fade in the old fields alone, anything else beside them', () => {
|
||||
const plain = withShapePaint(shape({ paint: stripes }), { type: 'solid', color: '#C2410C' });
|
||||
expect(plain).toMatchObject({ fill: '#c2410c', gradient: 'none' });
|
||||
expect('paint' in plain).toBe(false);
|
||||
|
||||
const fade = withShapePaint(shape(), legacyShapePaint(shape({ fill: '#102030', gradient: 'down' })));
|
||||
expect(fade).toMatchObject({ fill: '#102030', gradient: 'down' });
|
||||
expect('paint' in fade).toBe(false);
|
||||
|
||||
const painted = withShapePaint(shape({ gradient: 'up' }), linear);
|
||||
expect(painted).toMatchObject({ paint: linear, fill: '#800080', gradient: 'none' });
|
||||
|
||||
expect(withShapePaint(shape({ paint: linear }), null)).toMatchObject({ fill: null, gradient: 'none' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a page and its paint', () => {
|
||||
it('BKPAINT-009: keeps background as the colour that stands for the paint, and the paper as null', () => {
|
||||
expect(spreadPaint(spread())).toBeNull();
|
||||
expect(spreadPaint(spread({ background: '#F4EFE6' }))).toEqual({ type: 'solid', color: '#f4efe6' });
|
||||
const painted = withSpreadPaint(spread(), radial);
|
||||
expect(painted.backgroundPaint).toEqual(radial);
|
||||
expect(painted.background).toMatch(/^#[0-9a-f]{6}$/);
|
||||
expect(spreadPaint(painted)).toEqual(radial);
|
||||
const flat = withSpreadPaint(painted, { type: 'solid', color: '#202020' });
|
||||
expect([flat.background, 'backgroundPaint' in flat]).toEqual(['#202020', false]);
|
||||
expect(withSpreadPaint(painted, null)).toMatchObject({ background: null });
|
||||
});
|
||||
|
||||
it('BKPAINT-010: a saved layout carries the paint of the page it was taken from, and gives it back', () => {
|
||||
const painted = withSpreadPaint(spread({ elements: [shape()] }), stripes);
|
||||
const layout = bookLayoutSchema.parse(bookLayoutFromSpread(painted, page, 'Stripes'));
|
||||
expect(layout.backgroundPaint).toEqual(stripes);
|
||||
expect(bookLayoutPreview(layout, page).backgroundPaint).toEqual(stripes);
|
||||
const applied = applySavedBookLayout(spread({ backgroundPaint: linear, background: '#800080' }), layout, page);
|
||||
expect(applied.backgroundPaint).toEqual(stripes);
|
||||
|
||||
const plainLayout = bookLayoutSchema.parse(bookLayoutFromSpread(spread({ background: '#111111' }), page, 'Ink'));
|
||||
const replaced = applySavedBookLayout(spread({ backgroundPaint: linear }), plainLayout, page);
|
||||
expect(['backgroundPaint' in replaced, replaced.background]).toEqual([false, '#111111']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a paint is in words', () => {
|
||||
it('BKPAINT-011: names the kind, the colours and the numbers that place them', () => {
|
||||
expect(paintWords({ type: 'solid', color: '#ABCDEF' })).toBe('#abcdef');
|
||||
expect(paintWords({ type: 'solid', color: '#abcdef', alpha: 0.25 })).toBe('#abcdef at 25%');
|
||||
expect(paintWords(linear)).toBe('linear gradient towards 45 deg: #ff0000 at 0%, #0000ff at 100%');
|
||||
expect(paintWords(radial)).toBe('radial gradient from 30% 40%, radius 0.8: #ffffff at 50% at 0%, #000000 at 100%');
|
||||
expect(paintWords(stripes)).toBe('stripes pattern, #112233 on #ffeedd, scale 1, turned 45 deg');
|
||||
expect(paintWords({ ...stripes, bg: null } as BookPaint)).toContain('on nothing (see-through)');
|
||||
expect(paintColours(stripes)).toEqual(['#112233', '#ffeedd']);
|
||||
expect(paintColours(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('BKPAINT-012: the book palette counts every colour a page is drawn in, most used first', () => {
|
||||
const book = doc([
|
||||
{ id: 'a', background: '#ffeedd', elements: [shape({ fill: '#112233' }), shape({ id: 's2', paint: stripes })] },
|
||||
{ id: 'b', backgroundPaint: linear, background: '#800080', elements: [] },
|
||||
]);
|
||||
const colours = bookColours(book);
|
||||
expect(colours.slice(0, 2)).toEqual(['#112233', '#ffeedd']);
|
||||
expect(colours).toEqual(expect.arrayContaining(['#ff0000', '#0000ff']));
|
||||
expect(bookColours(null)).toEqual([]);
|
||||
expect(bookColours(book, 1)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the assistant on the same paint', () => {
|
||||
const book = () =>
|
||||
doc([{ id: 'sp1', role: 'inner', elements: [{ id: 's1', kind: 'shape', frame, fill: '#111111' }], parked: [] }]);
|
||||
const edit = (ops: unknown[]) => {
|
||||
const result = applyBookOps(book(), { spreadId: 'sp1', ops }, { newId: (p) => `${p}-new` });
|
||||
if (!result.ok) throw new Error(result.error.message);
|
||||
return result.doc.spreads[0] as BookSpread;
|
||||
};
|
||||
|
||||
it('BKPAINT-013: the background operation takes a paint or a colour, one of the two', () => {
|
||||
expect(bookPageOpSchema.safeParse({ op: 'background', color: '#ffffff' }).success).toBe(true);
|
||||
expect(bookPageOpSchema.safeParse({ op: 'background', paint: linear }).success).toBe(true);
|
||||
expect(bookPageOpSchema.safeParse({ op: 'background', color: null }).success).toBe(true);
|
||||
expect(bookPageOpSchema.safeParse({ op: 'background' }).success).toBe(false);
|
||||
expect(bookPageOpSchema.safeParse({ op: 'background', color: '#ffffff', paint: linear }).success).toBe(false);
|
||||
|
||||
expect(edit([{ op: 'background', paint: stripes }])).toMatchObject({
|
||||
backgroundPaint: stripes,
|
||||
background: '#ffeedd',
|
||||
});
|
||||
const back = edit([
|
||||
{ op: 'background', paint: stripes },
|
||||
{ op: 'background', color: '#202020' },
|
||||
]);
|
||||
expect([back.background, 'backgroundPaint' in back]).toEqual(['#202020', false]);
|
||||
expect(edit([{ op: 'background', color: null }]).background).toBeNull();
|
||||
});
|
||||
|
||||
it('BKPAINT-014: style and add set a shape paint, and a plain fill given later replaces it', () => {
|
||||
const painted = edit([{ op: 'style', elementId: 's1', set: { kind: 'shape', paint: radial } }]);
|
||||
expect(painted.elements[0]).toMatchObject({ paint: radial, gradient: 'none' });
|
||||
|
||||
const flat = edit([
|
||||
{ op: 'style', elementId: 's1', set: { kind: 'shape', paint: radial } },
|
||||
{ op: 'style', elementId: 's1', set: { kind: 'shape', fill: '#00ff00' } },
|
||||
]);
|
||||
expect(flat.elements[0]).toMatchObject({ fill: '#00ff00' });
|
||||
expect('paint' in (flat.elements[0] ?? {})).toBe(false);
|
||||
|
||||
const added = edit([{ op: 'add', element: { kind: 'shape', shape: 'heart', paint: stripes } }]);
|
||||
expect(added.elements[1]).toMatchObject({ kind: 'shape', shape: 'heart', paint: stripes, fill: '#ffeedd' });
|
||||
|
||||
const none = edit([{ op: 'style', elementId: 's1', set: { kind: 'shape', paint: null } }]);
|
||||
expect(none.elements[0]).toMatchObject({ fill: null });
|
||||
});
|
||||
|
||||
it('BKPAINT-015: describe_book_page reads the paint of a shape and of the page', () => {
|
||||
const painted = doc([
|
||||
{
|
||||
id: 'sp1',
|
||||
role: 'inner',
|
||||
background: '#ffeedd',
|
||||
backgroundPaint: stripes,
|
||||
elements: [{ id: 's1', kind: 'shape', frame, fill: '#800080', paint: linear }],
|
||||
},
|
||||
]);
|
||||
const text = describeSpread(painted, 'sp1')?.text ?? '';
|
||||
expect(text).toContain('Background stripes pattern, #112233 on #ffeedd');
|
||||
expect(text).toContain('(reads as #ffeedd)');
|
||||
expect(text).toContain('rect, paint linear gradient towards 45 deg');
|
||||
const plain = describeSpread(doc([{ id: 'sp1', elements: [{ id: 's1', kind: 'shape', frame }] }]), 'sp1')?.text;
|
||||
expect(plain).toContain('Background paper (none set)');
|
||||
expect(plain).toContain('rect, fill #111827');
|
||||
});
|
||||
|
||||
it('BKPAINT-016: a book with paint survives the trip through the document contract whole', () => {
|
||||
const painted = doc([
|
||||
{ id: 'sp1', backgroundPaint: radial, background: '#c0c0c0', elements: [shape({ paint: stripes })] },
|
||||
]);
|
||||
const again = normalizeBookDocument(JSON.parse(JSON.stringify(painted)));
|
||||
expect(again.spreads[0]?.backgroundPaint).toEqual(radial);
|
||||
expect(again.spreads[0]?.elements[0]).toMatchObject({ paint: stripes });
|
||||
});
|
||||
});
|
||||
@@ -1,210 +0,0 @@
|
||||
import { parseHex, toHex, type Rgb } from './book-colour';
|
||||
import type { BookPaint, BookPaintStop } from './book-paint.schema';
|
||||
import type { BookDocument, BookElement, BookShapeElement, BookSpread } from './book.schema';
|
||||
|
||||
/**
|
||||
* Reading and writing paint, the one place the old colour fields and the new
|
||||
* paint are kept in step.
|
||||
*
|
||||
* Every writer goes through `withShapePaint` and `withSpreadPaint`, the
|
||||
* Studio and the page operations alike, so a shape's `fill` and a page's
|
||||
* `background` always hold the colour that stands for its paint, and a paint
|
||||
* that the old fields can say whole (one opaque colour, the cover fade) is
|
||||
* stored in them alone, as it was before paint existed.
|
||||
*/
|
||||
|
||||
/** The cover fade, as the stops `gradient: 'up' | 'down'` has always drawn. */
|
||||
const FADE_STOPS = [
|
||||
{ at: 0, alpha: 0 },
|
||||
{ at: 0.46, alpha: 0.55 },
|
||||
{ at: 1, alpha: 1 },
|
||||
] as const;
|
||||
|
||||
const lower = (hex: string) => hex.toLowerCase();
|
||||
|
||||
/** A paint stop's alpha, 1 when it says none. */
|
||||
export const stopAlpha = (stop: { alpha?: number }): number => stop.alpha ?? 1;
|
||||
|
||||
/** Stops in order along the gradient, so a renderer and a reader never meet them shuffled. */
|
||||
export function sortedStops(stops: readonly BookPaintStop[]): BookPaintStop[] {
|
||||
return [...stops].sort((a, b) => a.at - b.at);
|
||||
}
|
||||
|
||||
/** The paint a shape's old fields describe: its fill, faded towards one edge when `gradient` says so. */
|
||||
export function legacyShapePaint(el: Pick<BookShapeElement, 'fill' | 'gradient'>): BookPaint | null {
|
||||
if (el.fill === null) return null;
|
||||
if (el.gradient === 'none') return { type: 'solid', color: lower(el.fill) };
|
||||
const color = lower(el.fill);
|
||||
return {
|
||||
type: 'linear',
|
||||
// 'up' is solid at the top, so the line runs towards the top: CSS's 0deg.
|
||||
angle: el.gradient === 'up' ? 0 : 180,
|
||||
stops: FADE_STOPS.map((s) => ({ at: s.at, color, alpha: s.alpha })),
|
||||
};
|
||||
}
|
||||
|
||||
/** What fills a shape: its paint, or what its old fields say. Null is no fill. */
|
||||
export function shapePaint(el: BookShapeElement): BookPaint | null {
|
||||
return el.paint ?? legacyShapePaint(el);
|
||||
}
|
||||
|
||||
/** What fills a page: its paint, or its background colour. Null is the paper. */
|
||||
export function spreadPaint(spread: Pick<BookSpread, 'background' | 'backgroundPaint'>): BookPaint | null {
|
||||
if (spread.backgroundPaint) return spread.backgroundPaint;
|
||||
return spread.background ? { type: 'solid', color: lower(spread.background) } : null;
|
||||
}
|
||||
|
||||
/** The paper a see-through paint is read against. */
|
||||
const PAPER: Rgb = { r: 255, g: 255, b: 255 };
|
||||
|
||||
/** A colour at an alpha, as it looks laid on the paper. */
|
||||
function onPaper(hex: string, alpha: number): Rgb | null {
|
||||
const rgb = parseHex(hex);
|
||||
if (!rgb) return null;
|
||||
const a = Math.max(0, Math.min(1, alpha));
|
||||
const mix = (c: number, p: number) => p + (c - p) * a;
|
||||
return { r: mix(rgb.r, PAPER.r), g: mix(rgb.g, PAPER.g), b: mix(rgb.b, PAPER.b) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The one opaque colour that stands for a paint: what an old reader draws,
|
||||
* and what a contrast check measures against.
|
||||
*
|
||||
* A see-through colour is read as it looks on the paper. A gradient gives the
|
||||
* mix of its stops, each weighted by the stretch of the line it holds; a
|
||||
* pattern gives its ground, or its ink where it has none, since the ground is
|
||||
* what most of the box shows.
|
||||
*/
|
||||
export function paintFallbackHex(paint: BookPaint): string {
|
||||
switch (paint.type) {
|
||||
case 'solid':
|
||||
return stopAlpha(paint) >= 1 ? lower(paint.color) : toHex(onPaper(paint.color, stopAlpha(paint)) ?? PAPER);
|
||||
case 'pattern':
|
||||
return lower(paint.bg ?? paint.fg);
|
||||
default:
|
||||
return mixStops(sortedStops(paint.stops));
|
||||
}
|
||||
}
|
||||
|
||||
function mixStops(stops: readonly BookPaintStop[]): string {
|
||||
let sum: Rgb = { r: 0, g: 0, b: 0 };
|
||||
let total = 0;
|
||||
stops.forEach((stop, i) => {
|
||||
const rgb = onPaper(stop.color, stopAlpha(stop));
|
||||
if (!rgb) return;
|
||||
const before = i === 0 ? stop.at : (stop.at - (stops[i - 1]?.at ?? 0)) / 2;
|
||||
const after = i === stops.length - 1 ? 1 - stop.at : ((stops[i + 1]?.at ?? 1) - stop.at) / 2;
|
||||
// A gradient whose stops all sit on one point still has a colour: theirs.
|
||||
const weight = before + after || 1;
|
||||
sum = { r: sum.r + rgb.r * weight, g: sum.g + rgb.g * weight, b: sum.b + rgb.b * weight };
|
||||
total += weight;
|
||||
});
|
||||
return toHex(total > 0 ? { r: sum.r / total, g: sum.g / total, b: sum.b / total } : PAPER);
|
||||
}
|
||||
|
||||
/** Whether a paint is exactly the cover fade the old `gradient` field can say. */
|
||||
function legacyFade(paint: BookPaint): { color: string; gradient: 'up' | 'down' } | null {
|
||||
if (paint.type !== 'linear' || (paint.angle !== 0 && paint.angle !== 180)) return null;
|
||||
const stops = sortedStops(paint.stops);
|
||||
const color = stops[0]?.color.toLowerCase();
|
||||
if (!color || stops.length !== FADE_STOPS.length) return null;
|
||||
const same = stops.every((s, i) => {
|
||||
const want = FADE_STOPS[i];
|
||||
return !!want && s.color.toLowerCase() === color && s.at === want.at && stopAlpha(s) === want.alpha;
|
||||
});
|
||||
return same ? { color, gradient: paint.angle === 0 ? 'up' : 'down' } : null;
|
||||
}
|
||||
|
||||
const opaqueSolid = (paint: BookPaint): paint is Extract<BookPaint, { type: 'solid' }> =>
|
||||
paint.type === 'solid' && stopAlpha(paint) >= 1;
|
||||
|
||||
/**
|
||||
* A shape with this paint. Null takes the fill away. One opaque colour and the
|
||||
* cover fade go into `fill` and `gradient` alone; anything else is kept as
|
||||
* `paint`, with `fill` set to the colour that stands for it.
|
||||
*/
|
||||
export function withShapePaint<T extends BookShapeElement>(el: T, paint: BookPaint | null): T {
|
||||
const { paint: _old, ...rest } = el;
|
||||
if (paint === null) return { ...rest, fill: null, gradient: 'none' } as T;
|
||||
if (opaqueSolid(paint)) return { ...rest, fill: lower(paint.color), gradient: 'none' } as T;
|
||||
const fade = legacyFade(paint);
|
||||
if (fade) return { ...rest, fill: fade.color, gradient: fade.gradient } as T;
|
||||
return { ...rest, paint, fill: paintFallbackHex(paint), gradient: 'none' } as T;
|
||||
}
|
||||
|
||||
/** A page with this paint. Null is the paper; one opaque colour is `background` alone, as before. */
|
||||
export function withSpreadPaint<T extends BookSpread>(spread: T, paint: BookPaint | null): T {
|
||||
const { backgroundPaint: _old, ...rest } = spread;
|
||||
if (paint === null) return { ...rest, background: null } as T;
|
||||
if (opaqueSolid(paint)) return { ...rest, background: lower(paint.color) } as T;
|
||||
return { ...rest, backgroundPaint: paint, background: paintFallbackHex(paint) } as T;
|
||||
}
|
||||
|
||||
/** Every colour a paint is made of, in the order it names them. */
|
||||
export function paintColours(paint: BookPaint | null | undefined): string[] {
|
||||
if (!paint) return [];
|
||||
switch (paint.type) {
|
||||
case 'solid':
|
||||
return [lower(paint.color)];
|
||||
case 'pattern':
|
||||
return paint.bg ? [lower(paint.fg), lower(paint.bg)] : [lower(paint.fg)];
|
||||
default:
|
||||
return sortedStops(paint.stops).map((s) => lower(s.color));
|
||||
}
|
||||
}
|
||||
|
||||
const pct = (v: number) => `${Math.round(v * 100)}%`;
|
||||
const deg = (v: number) => `${Math.round(v)} deg`;
|
||||
const colourWord = (color: string, alpha: number | undefined) =>
|
||||
alpha !== undefined && alpha < 1 ? `${lower(color)} at ${pct(alpha)}` : lower(color);
|
||||
|
||||
/** A paint in words, for describe_book_page: the kind, its colours and the numbers that place them. */
|
||||
export function paintWords(paint: BookPaint): string {
|
||||
switch (paint.type) {
|
||||
case 'solid':
|
||||
return colourWord(paint.color, paint.alpha);
|
||||
case 'pattern':
|
||||
return `${paint.pattern} pattern, ${lower(paint.fg)} on ${paint.bg ? lower(paint.bg) : 'nothing (see-through)'}, scale ${paint.scale}, turned ${deg(paint.angle)}`;
|
||||
default: {
|
||||
const stops = sortedStops(paint.stops)
|
||||
.map((s) => `${colourWord(s.color, s.alpha)} at ${pct(s.at)}`)
|
||||
.join(', ');
|
||||
return paint.type === 'linear'
|
||||
? `linear gradient towards ${deg(paint.angle)}: ${stops}`
|
||||
: `radial gradient from ${pct(paint.cx)} ${pct(paint.cy)}, radius ${paint.r}: ${stops}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The colours an element is drawn in, as they are stored. */
|
||||
function elementColours(el: BookElement): string[] {
|
||||
switch (el.kind) {
|
||||
case 'photo':
|
||||
return [];
|
||||
case 'shape':
|
||||
return [...paintColours(shapePaint(el)), ...(el.stroke ? [lower(el.stroke)] : [])];
|
||||
case 'text':
|
||||
case 'icon':
|
||||
return [lower(el.color)];
|
||||
default:
|
||||
return [lower(el.color), lower(el.accent)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The book's own palette: the colours its pages and elements are drawn in,
|
||||
* most used first, so a colour chosen once is one click away on every page.
|
||||
*/
|
||||
export function bookColours(doc: BookDocument | null | undefined, limit = 8): string[] {
|
||||
const counts = new Map<string, number>();
|
||||
const add = (hex: string) => counts.set(hex, (counts.get(hex) ?? 0) + 1);
|
||||
for (const spread of doc?.spreads ?? []) {
|
||||
paintColours(spreadPaint(spread)).forEach(add);
|
||||
for (const el of spread.elements) elementColours(el).forEach(add);
|
||||
}
|
||||
return [...counts]
|
||||
.filter(([hex]) => parseHex(hex))
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, limit)
|
||||
.map(([hex]) => hex);
|
||||
}
|
||||
@@ -33,7 +33,6 @@ export function bookLayoutFromSpread(spread: BookSpread, page: BookPageSetup, na
|
||||
pageWidth: page.pageWidth,
|
||||
pageHeight: page.pageHeight,
|
||||
background: spread.background,
|
||||
...(spread.backgroundPaint ? { backgroundPaint: spread.backgroundPaint } : {}),
|
||||
// The design, not the pictures: every frame comes out empty.
|
||||
elements: spread.elements.map((el) => (el.kind === 'photo' ? { ...el, photoId: null } : el)),
|
||||
};
|
||||
@@ -124,9 +123,7 @@ export function applySavedBookLayout(spread: BookSpread, layout: BookLayout, pag
|
||||
const used = new Set(wordsFor.values());
|
||||
const parked = [...photos.slice(pi), ...texts.filter((t) => !used.has(t))].slice(0, MAX_SPREAD_ELEMENTS);
|
||||
|
||||
const { backgroundPaint: _old, ...rest } = spread;
|
||||
const backgroundPaint = layout.backgroundPaint ? { backgroundPaint: layout.backgroundPaint } : {};
|
||||
return { ...rest, background: layout.background, ...backgroundPaint, elements: out, parked };
|
||||
return { ...spread, background: layout.background, elements: out, parked };
|
||||
}
|
||||
|
||||
/** A saved layout drawn as a spread on this book's page, for its card. Nothing is filled in. */
|
||||
@@ -137,7 +134,6 @@ export function bookLayoutPreview(layout: BookLayout, page: BookPageSetup): Book
|
||||
id: layout.id,
|
||||
role: layout.role === 'inner' ? 'inner' : 'first',
|
||||
background: layout.background,
|
||||
...(layout.backgroundPaint ? { backgroundPaint: layout.backgroundPaint } : {}),
|
||||
elements: layout.elements.map(
|
||||
(el) =>
|
||||
({
|
||||
|
||||
@@ -20,8 +20,6 @@ import { describe, expect, it } from 'vitest';
|
||||
*/
|
||||
|
||||
const frame = { x: 12, y: 18, w: 60, h: 40 };
|
||||
/** A revision newer than this build reads. */
|
||||
const NEWER = BOOK_SCHEMA_REV + 1;
|
||||
|
||||
const doc = (over: Record<string, unknown> = {}) => ({
|
||||
version: 1,
|
||||
@@ -42,7 +40,7 @@ const doc = (over: Record<string, unknown> = {}) => ({
|
||||
|
||||
describe('the revision a document was written by', () => {
|
||||
it('BKREV-001: reads 1 for every document from before the field, and for anything that is not a whole number', () => {
|
||||
expect(BOOK_SCHEMA_REV).toBeGreaterThanOrEqual(2);
|
||||
expect(BOOK_SCHEMA_REV).toBe(1);
|
||||
expect(bookSchemaRev(doc())).toBe(1);
|
||||
expect(bookSchemaRev(null)).toBe(1);
|
||||
expect(bookSchemaRev('{"schemaRev":4}')).toBe(1);
|
||||
@@ -58,35 +56,33 @@ describe('the revision a document was written by', () => {
|
||||
expect(odd.success && odd.data.spreads[0]?.elements).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('BKREV-003: a writer of revision 1 leaves a book as it was, and this build stamps its own revision', () => {
|
||||
it('BKREV-003: leaves a book saved today as it was, with no revision written into it', () => {
|
||||
const today = normalizeBookDocument(doc());
|
||||
expect('schemaRev' in today).toBe(false);
|
||||
expect(stampBookSchemaRev(today, 1)).toBe(today);
|
||||
expect(JSON.stringify(stampBookSchemaRev(today, 1))).toBe(JSON.stringify(today));
|
||||
expect(stampBookSchemaRev(today).schemaRev).toBe(BOOK_SCHEMA_REV);
|
||||
expect(stampBookSchemaRev(today)).toBe(today);
|
||||
expect(JSON.stringify(stampBookSchemaRev(today))).toBe(JSON.stringify(today));
|
||||
});
|
||||
});
|
||||
|
||||
describe('a reader older than the document', () => {
|
||||
it('BKREV-004: knows the document is newer than it, read from the raw value even when the rest will not parse', () => {
|
||||
expect(isBookNewerThanReader(doc({ schemaRev: NEWER }))).toBe(true);
|
||||
expect(isBookNewerThanReader(doc({ schemaRev: BOOK_SCHEMA_REV }))).toBe(false);
|
||||
expect(isBookNewerThanReader(doc({ schemaRev: 2 }))).toBe(true);
|
||||
expect(isBookNewerThanReader(doc({ schemaRev: 1 }))).toBe(false);
|
||||
expect(isBookNewerThanReader(doc())).toBe(false);
|
||||
// A reader of a later revision reads the same document as its own.
|
||||
expect(isBookNewerThanReader(doc({ schemaRev: NEWER }), NEWER)).toBe(false);
|
||||
const unreadable = doc({ schemaRev: NEWER + 3, page: 'not a page' });
|
||||
expect(isBookNewerThanReader(doc({ schemaRev: 2 }), 2)).toBe(false);
|
||||
const unreadable = doc({ schemaRev: 5, page: 'not a page' });
|
||||
expect(isBookNewerThanReader(unreadable)).toBe(true);
|
||||
});
|
||||
|
||||
it('BKREV-005: reads a newer book as far as it can and says so, so nothing it dropped is saved', () => {
|
||||
const sticker = { id: 'x1', kind: 'sticker', frame, stickerId: 'lemon' };
|
||||
const raw = doc({ schemaRev: NEWER });
|
||||
const raw = doc({ schemaRev: 2 });
|
||||
const spread = (raw.spreads as { elements: unknown[] }[])[0];
|
||||
if (spread) spread.elements.push(sticker);
|
||||
const read = readBookDocument(raw);
|
||||
expect(read.newer).toBe(true);
|
||||
expect(read.document.schemaRev).toBe(NEWER);
|
||||
expect(read.document.schemaRev).toBe(2);
|
||||
expect(read.document.spreads[0]?.elements.map((e) => e.id)).toEqual(['t1']);
|
||||
expect(readBookDocument(doc()).newer).toBe(false);
|
||||
});
|
||||
@@ -103,10 +99,10 @@ describe('a reader older than the document', () => {
|
||||
describe('a save request carrying a newer book', () => {
|
||||
it('BKREV-007: reaches the server as its revision alone, so the save is refused there rather than at the pipe', () => {
|
||||
const sticker = { id: 'x1', kind: 'sticker', frame, stickerId: 'lemon' };
|
||||
const raw = doc({ schemaRev: NEWER });
|
||||
const raw = doc({ schemaRev: 2 });
|
||||
const spread = (raw.spreads as { elements: unknown[] }[])[0];
|
||||
if (spread) spread.elements.push(sticker);
|
||||
expect(bookSaveRequestSchema.parse({ title: 'T', document: raw }).document).toEqual({ schemaRev: NEWER });
|
||||
expect(bookSaveRequestSchema.parse({ title: 'T', document: raw }).document).toEqual({ schemaRev: 2 });
|
||||
// A book of today still reads whole, revision or none.
|
||||
const today = bookSaveRequestSchema.parse({ title: 'T', document: doc() }).document;
|
||||
expect(today).toEqual(bookDocumentSchema.parse(doc()));
|
||||
|
||||
@@ -28,12 +28,7 @@ import type { BookDocument } from './book.schema';
|
||||
* saves only once that is above 1, so a book saved today stores exactly what
|
||||
* it stored before this existed.
|
||||
*/
|
||||
/**
|
||||
* The revisions so far:
|
||||
*
|
||||
* - 2: paint on shapes and pages (`paint`, `backgroundPaint`, book-paint.schema.ts).
|
||||
*/
|
||||
export const BOOK_SCHEMA_REV = 2;
|
||||
export const BOOK_SCHEMA_REV = 1;
|
||||
|
||||
/** The `code` a refused save carries in its body when the stored book is newer than the writer. */
|
||||
export const BOOK_SCHEMA_NEWER_CODE = 'book_schema_newer';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { bookPaintFieldSchema } from './book-paint.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
@@ -299,8 +297,6 @@ export const bookShapeElementSchema = z.object({
|
||||
* the picture keep going and still leaves the words readable.
|
||||
*/
|
||||
gradient: z.enum(['none', 'up', 'down']).default('none'),
|
||||
/** A fill beyond one flat colour (book-paint.schema.ts); `fill` keeps the colour that stands for it. */
|
||||
paint: bookPaintFieldSchema,
|
||||
stroke: hex.nullable().default(null),
|
||||
strokeWidth: mm.default(0),
|
||||
/**
|
||||
@@ -829,8 +825,6 @@ export const bookSpreadSchema = z.object({
|
||||
*/
|
||||
role: z.enum(['cover', 'first', 'inner', 'last', 'back']).default('inner'),
|
||||
background: hex.nullable().default(null),
|
||||
/** A page painted beyond one flat colour; `background` keeps the colour that stands for it. */
|
||||
backgroundPaint: bookPaintFieldSchema,
|
||||
/** Back to front. */
|
||||
elements: z.array(bookElementSchema).max(MAX_SPREAD_ELEMENTS).default([]),
|
||||
/**
|
||||
@@ -935,7 +929,6 @@ export const bookLayoutSchema = z.object({
|
||||
pageWidth: z.number().positive().max(2000),
|
||||
pageHeight: z.number().positive().max(2000),
|
||||
background: hex.nullable().default(null),
|
||||
backgroundPaint: bookPaintFieldSchema,
|
||||
elements: z.array(bookElementSchema).max(MAX_SPREAD_ELEMENTS).default([]),
|
||||
});
|
||||
export type BookLayout = z.infer<typeof bookLayoutSchema>;
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
BookPageDescription,
|
||||
BookSpreadSummary,
|
||||
} from './book-ops.schema';
|
||||
import { paintWords } from './book-paint';
|
||||
import {
|
||||
bookPageSetupSchema,
|
||||
type BookDocument,
|
||||
@@ -100,8 +99,8 @@ function elementWords(el: BookElement, photos: readonly BookPhotoInfo[] | undefi
|
||||
}
|
||||
|
||||
function shapeWords(el: Extract<BookElement, { kind: 'shape' }>): string {
|
||||
const parts = el.paint ? [`${el.shape}, paint ${paintWords(el.paint)}`] : [`${el.shape}, fill ${el.fill ?? 'none'}`];
|
||||
if (!el.paint && el.gradient !== 'none') parts.push(`fading ${el.gradient}`);
|
||||
const parts = [`${el.shape}, fill ${el.fill ?? 'none'}`];
|
||||
if (el.gradient !== 'none') parts.push(`fading ${el.gradient}`);
|
||||
if (el.stroke) parts.push(`stroke ${el.stroke} ${n(el.strokeWidth)} mm ${el.strokeStyle}`);
|
||||
if (el.radius > 0) parts.push(`radius ${n(el.radius)}`);
|
||||
return parts.join(', ');
|
||||
@@ -171,12 +170,7 @@ function pageLine(doc: BookDocument, spread: BookSpread, index: number): string
|
||||
? `${n(p.pageWidth)} x ${n(p.pageHeight)} mm`
|
||||
: `${n(p.pageWidth * 2)} x ${n(p.pageHeight)} mm (two pages of ${n(p.pageWidth)} x ${n(p.pageHeight)}), fold at x ${n(p.pageWidth)}`;
|
||||
const entry = spread.entryId != null ? ` From entry ${spread.entryId}.` : '';
|
||||
return `Page ${index + 1} of ${doc.spreads.length} (${spread.id}): ${ROLE_WORDS[spread.role]}, ${size}; bleed ${n(p.bleed)}, safe ${n(p.safe)}. Background ${backgroundWords(spread)}.${entry}`;
|
||||
}
|
||||
|
||||
function backgroundWords(spread: BookSpread): string {
|
||||
if (spread.backgroundPaint) return `${paintWords(spread.backgroundPaint)} (reads as ${spread.background ?? 'paper'})`;
|
||||
return spread.background ?? 'paper (none set)';
|
||||
return `Page ${index + 1} of ${doc.spreads.length} (${spread.id}): ${ROLE_WORDS[spread.role]}, ${size}; bleed ${n(p.bleed)}, safe ${n(p.safe)}. Background ${spread.background ?? 'paper (none set)'}.${entry}`;
|
||||
}
|
||||
|
||||
function findingLine(f: BookFinding): string {
|
||||
|
||||
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.adding': 'جارٍ التخطيط…',
|
||||
'assistant.card.tour.added': 'أُضيفت إلى الرحلة',
|
||||
'assistant.card.tour.askAgain': 'لم يعد بالإمكان التخطيط لهذا من هنا. اطلبه مرة أخرى.',
|
||||
'assistant.card.tour.needsPace': 'أخبر المساعد بوتيرتك لحفظ هذا الجري.',
|
||||
'assistant.card.tour.open': 'فتح في الجولات',
|
||||
'assistant.card.tour.openPhone': 'فتح الجولة',
|
||||
'assistant.card.tour.unsaved': 'احفظ الجولة التي تعدّلها أو تجاهلها أولًا',
|
||||
|
||||
@@ -58,7 +58,6 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioPaint from './studioPaint';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -115,7 +114,6 @@ const locale = {
|
||||
...todo,
|
||||
...notif,
|
||||
...journey,
|
||||
...studioPaint,
|
||||
...oauth,
|
||||
...system_notice,
|
||||
...transport,
|
||||
|
||||
@@ -520,8 +520,12 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotation': 'التدوير',
|
||||
'journey.studio.opacity': 'العتامة',
|
||||
'journey.studio.fill': 'التعبئة',
|
||||
'journey.studio.fillOn': 'معبّأ',
|
||||
'journey.studio.stroke': 'الإطار',
|
||||
'journey.studio.strokeWidth': 'السماكة',
|
||||
'journey.studio.gradient': 'التدرّج',
|
||||
'journey.studio.gradientDown': 'لأسفل',
|
||||
'journey.studio.gradientUp': 'لأعلى',
|
||||
'journey.studio.showIcons': 'الأيقونات',
|
||||
'journey.studio.mapFit': 'الملاءمة',
|
||||
'journey.studio.mapPadding': 'الهامش',
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** TREK Studio: paint, gradients, patterns and the page background. */
|
||||
const studioPaint: TranslationStrings = {
|
||||
'journey.studio.paint.kind': 'نوع التعبئة',
|
||||
'journey.studio.paint.none': 'بلا',
|
||||
'journey.studio.paint.solid': 'لون واحد',
|
||||
'journey.studio.paint.gradient': 'تدرج',
|
||||
'journey.studio.paint.pattern': 'نقش',
|
||||
'journey.studio.paint.linear': 'خطي',
|
||||
'journey.studio.paint.radial': 'دائري',
|
||||
'journey.studio.paint.reverse': 'اعكس التدرج',
|
||||
'journey.studio.paint.addStopHint': 'انقر على الشريط لإضافة نقطة لون',
|
||||
'journey.studio.paint.stopAt': 'نقطة عند {pos}%',
|
||||
'journey.studio.paint.removeStop': 'أزل النقطة',
|
||||
'journey.studio.paint.angle': 'الزاوية',
|
||||
'journey.studio.paint.centre': 'المركز',
|
||||
'journey.studio.paint.radius': 'نصف القطر',
|
||||
'journey.studio.paint.ink': 'لون النقش',
|
||||
'journey.studio.paint.ground': 'لون الخلفية',
|
||||
'journey.studio.paint.seeThrough': 'شفاف',
|
||||
'journey.studio.paint.scale': 'الحجم',
|
||||
'journey.studio.paint.opacity': 'العتامة',
|
||||
'journey.studio.paint.bookColours': 'ألوان هذا الكتاب',
|
||||
'journey.studio.paint.photoColours': 'ألوان من الصور',
|
||||
'journey.studio.paint.recentColours': 'ألوان حديثة',
|
||||
'journey.studio.paint.background': 'خلفية الصفحة',
|
||||
'journey.studio.paint.paper': 'الورق',
|
||||
'journey.studio.paint.applyAll': 'طبّق على كل الصفحات',
|
||||
'journey.studio.paint.appliedAll': 'طُبّق على كل الصفحات',
|
||||
'journey.studio.paint.photoBackground': 'صورة كخلفية',
|
||||
'journey.studio.paint.photoBackgroundHint':
|
||||
'تضع الصورة على الصفحة كلها مع هامش القص، مقفلة في الخلف. اخترها مرة أخرى لإزالتها.',
|
||||
'journey.studio.paint.removePhotoBackground': 'أزل صورة الخلفية',
|
||||
'journey.studio.paint.noPhotos': 'أضف صورًا إلى الرحلة لتستخدم إحداها خلفية.',
|
||||
'journey.studio.pattern.checker': 'مربعات',
|
||||
'journey.studio.pattern.gingham': 'قماش كاروهات',
|
||||
'journey.studio.pattern.stripes': 'خطوط',
|
||||
'journey.studio.pattern.pinstripe': 'خطوط رفيعة',
|
||||
'journey.studio.pattern.dots': 'نقاط',
|
||||
'journey.studio.pattern.polka': 'دوائر منقطة',
|
||||
'journey.studio.pattern.zigzag': 'متعرج',
|
||||
'journey.studio.pattern.waves': 'أمواج',
|
||||
'journey.studio.pattern.grid': 'شبكة',
|
||||
'journey.studio.pattern.scales': 'حراشف',
|
||||
'journey.studio.pattern.sunburst': 'أشعة الشمس',
|
||||
'journey.studio.pattern.confetti': 'قصاصات ملونة',
|
||||
};
|
||||
export default studioPaint;
|
||||
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.adding': 'Planlaşdırılır…',
|
||||
'assistant.card.tour.added': 'Səyahətə əlavə edildi',
|
||||
'assistant.card.tour.askAgain': 'Bunu artıq buradan planlaşdırmaq olmur. Yenidən soruşun.',
|
||||
'assistant.card.tour.needsPace': 'Bu qaçışı yadda saxlamaq üçün köməkçiyə tempinizi deyin.',
|
||||
'assistant.card.tour.open': 'Turlarda aç',
|
||||
'assistant.card.tour.openPhone': 'Turu aç',
|
||||
'assistant.card.tour.unsaved': 'Əvvəlcə redaktə etdiyiniz turu yadda saxlayın və ya ləğv edin',
|
||||
|
||||
@@ -58,7 +58,6 @@ import settings from './settings';
|
||||
import share from './share';
|
||||
import shared from './shared';
|
||||
import storage from './storage';
|
||||
import studioPaint from './studioPaint';
|
||||
import system_notice from './system_notice';
|
||||
import todo from './todo';
|
||||
import tourCollection from './tourCollection';
|
||||
@@ -115,7 +114,6 @@ const locale = {
|
||||
...todo,
|
||||
...notif,
|
||||
...journey,
|
||||
...studioPaint,
|
||||
...oauth,
|
||||
...system_notice,
|
||||
...transport,
|
||||
|
||||
@@ -538,8 +538,12 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotation': 'Fırlanma',
|
||||
'journey.studio.opacity': 'Şəffaflıq',
|
||||
'journey.studio.fill': 'Dolğu',
|
||||
'journey.studio.fillOn': 'Doldurulmuş',
|
||||
'journey.studio.stroke': 'Kontur',
|
||||
'journey.studio.strokeWidth': 'Qalınlıq',
|
||||
'journey.studio.gradient': 'Keçid',
|
||||
'journey.studio.gradientDown': 'Aşağıya',
|
||||
'journey.studio.gradientUp': 'Yuxarıya',
|
||||
|
||||
'journey.studio.showIcons': 'İkonlar',
|
||||
'journey.studio.mapFit': 'Sığdırma',
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
/** TREK Studio: paint, gradients, patterns and the page background. */
|
||||
const studioPaint: TranslationStrings = {
|
||||
'journey.studio.paint.kind': 'Doldurma növü',
|
||||
'journey.studio.paint.none': 'Yoxdur',
|
||||
'journey.studio.paint.solid': 'Bərk',
|
||||
'journey.studio.paint.gradient': 'Keçid',
|
||||
'journey.studio.paint.pattern': 'Naxış',
|
||||
'journey.studio.paint.linear': 'Xətti',
|
||||
'journey.studio.paint.radial': 'Radial', // same-as-en
|
||||
'journey.studio.paint.reverse': 'Keçidi tərsinə çevir',
|
||||
'journey.studio.paint.addStopHint': 'Rəng nöqtəsi əlavə etmək üçün zolağa klikləyin',
|
||||
'journey.studio.paint.stopAt': '{pos}% nöqtəsində',
|
||||
'journey.studio.paint.removeStop': 'Nöqtəni sil',
|
||||
'journey.studio.paint.angle': 'Bucaq',
|
||||
'journey.studio.paint.centre': 'Mərkəz',
|
||||
'journey.studio.paint.radius': 'Radius', // same-as-en
|
||||
'journey.studio.paint.ink': 'Naxışın rəngi',
|
||||
'journey.studio.paint.ground': 'Fon rəngi',
|
||||
'journey.studio.paint.seeThrough': 'Şəffaf',
|
||||
'journey.studio.paint.scale': 'Ölçü',
|
||||
'journey.studio.paint.opacity': 'Qeyri-şəffaflıq',
|
||||
'journey.studio.paint.bookColours': 'Bu kitabdakı rənglər',
|
||||
'journey.studio.paint.photoColours': 'Fotolardan rənglər',
|
||||
'journey.studio.paint.recentColours': 'Son rənglər',
|
||||
'journey.studio.paint.background': 'Səhifə fonu',
|
||||
'journey.studio.paint.paper': 'Kağız',
|
||||
'journey.studio.paint.applyAll': 'Bütün səhifələrə tətbiq et',
|
||||
'journey.studio.paint.appliedAll': 'Bütün səhifələrə tətbiq edildi',
|
||||
'journey.studio.paint.photoBackground': 'Fon kimi foto',
|
||||
'journey.studio.paint.photoBackgroundHint':
|
||||
'Fotonu kəsim payı daxil olmaqla bütün səhifəyə yayır, arxada kilidli saxlayır. Götürmək üçün onu yenidən seçin.',
|
||||
'journey.studio.paint.removePhotoBackground': 'Fon fotosunu sil',
|
||||
'journey.studio.paint.noPhotos': 'Birini fon kimi istifadə etmək üçün səyahətə foto əlavə edin.',
|
||||
'journey.studio.pattern.checker': 'Şahmat',
|
||||
'journey.studio.pattern.gingham': 'Damalı',
|
||||
'journey.studio.pattern.stripes': 'Zolaqlar',
|
||||
'journey.studio.pattern.pinstripe': 'İncə zolaqlar',
|
||||
'journey.studio.pattern.dots': 'Nöqtələr',
|
||||
'journey.studio.pattern.polka': 'Xallar',
|
||||
'journey.studio.pattern.zigzag': 'Ziqzaq',
|
||||
'journey.studio.pattern.waves': 'Dalğalar',
|
||||
'journey.studio.pattern.grid': 'Tor',
|
||||
'journey.studio.pattern.scales': 'Pulcuqlar',
|
||||
'journey.studio.pattern.sunburst': 'Şüalar',
|
||||
'journey.studio.pattern.confetti': 'Konfeti',
|
||||
};
|
||||
export default studioPaint;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user