mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c418eef7ce |
@@ -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,
|
||||
|
||||
@@ -455,4 +455,5 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
|
||||
difficulty: { labelKey: 'assistant.change.label.difficulty' },
|
||||
track: { labelKey: 'assistant.change.label.track' },
|
||||
packing: { labelKey: 'assistant.change.label.packing' },
|
||||
breaks: { labelKey: 'assistant.change.label.breaks' },
|
||||
};
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import { type AssistantApplied, cumulativeMetres, type TourRouteCard, type TrekCard } from '@trek/shared';
|
||||
import { ChartNoAxesColumn, Circle, CircleCheck, CircleSlash, Footprints, Luggage, Route } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
ChartNoAxesColumn,
|
||||
Circle,
|
||||
CircleCheck,
|
||||
CircleSlash,
|
||||
Copy,
|
||||
Footprints,
|
||||
History,
|
||||
Luggage,
|
||||
Palette,
|
||||
Repeat,
|
||||
Route,
|
||||
Snowflake,
|
||||
TriangleAlert,
|
||||
Waves,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { RouteLineSpec, RouteUse } from '../../../../types/assistantMap';
|
||||
import type { EntityTarget, PackLook } from '../cardTypes';
|
||||
@@ -95,6 +111,14 @@ export const TOUR_LOOK = {
|
||||
countTour: { Icon: ChartNoAxesColumn, tone: 'success', labelKey: 'assistant.change.verb.countTour' },
|
||||
linkPacking: { Icon: Luggage, tone: 'success', labelKey: 'assistant.change.verb.linkPacking' },
|
||||
unlinkPacking: { Icon: Luggage, tone: 'info', labelKey: 'assistant.change.verb.unlinkPacking' },
|
||||
reverseTour: { Icon: ArrowLeftRight, tone: 'info', labelKey: 'assistant.change.verb.reverseTour' },
|
||||
closeLoop: { Icon: Repeat, tone: 'info', labelKey: 'assistant.change.verb.closeLoop' },
|
||||
copyTour: { Icon: Copy, tone: 'success', labelKey: 'assistant.change.verb.copyTour' },
|
||||
setRouteColor: { Icon: Palette, tone: 'info', labelKey: 'assistant.change.verb.setRouteColor' },
|
||||
classifySki: { Icon: Snowflake, tone: 'info', labelKey: 'assistant.change.verb.classifySki' },
|
||||
setPaddlePlan: { Icon: Waves, tone: 'info', labelKey: 'assistant.change.verb.setPaddlePlan' },
|
||||
decideObstacles: { Icon: TriangleAlert, tone: 'info', labelKey: 'assistant.change.verb.decideObstacles' },
|
||||
restoreTrack: { Icon: History, tone: 'info', labelKey: 'assistant.change.verb.restoreTrack' },
|
||||
},
|
||||
tools: {},
|
||||
steps: {
|
||||
@@ -106,6 +130,7 @@ export const TOUR_LOOK = {
|
||||
find_tours_for_day: { stepKey: 'assistant.step.readingTours' },
|
||||
search_tour_pois: { stepKey: 'assistant.step.tourPois' },
|
||||
get_city_walk_progress: { stepKey: 'assistant.step.readingTour' },
|
||||
get_tour_trail_details: { stepKey: 'assistant.step.readingTour' },
|
||||
},
|
||||
entities: {
|
||||
tour: {
|
||||
|
||||
@@ -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,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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -162,6 +162,12 @@ export interface TourInsert {
|
||||
}
|
||||
|
||||
/** What a route edit replaces on an existing tour: the type and everything that belongs to it. */
|
||||
/** The planned times of a tour as P8 writes them, null where there is none. */
|
||||
interface TourTimesUpdate {
|
||||
planned_duration_minutes: number | null;
|
||||
break_additional_minutes: number | null;
|
||||
}
|
||||
|
||||
export type TourUpdate = Omit<
|
||||
TourInsert,
|
||||
| 'place_id'
|
||||
@@ -883,6 +889,23 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
return Number(result.numUpdatedRows ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* P8: a tour's planned times alone, as update_tour_details changes them;
|
||||
* nothing else of the tour is touched. Returns whether a row of that trip was updated.
|
||||
*/
|
||||
async updateTimesInTrip(trip_id: number, place_id: number, input: TourTimesUpdate): Promise<boolean> {
|
||||
const result = await this.db()
|
||||
.updateTable('tours')
|
||||
.set({
|
||||
planned_duration_minutes: input.planned_duration_minutes,
|
||||
break_additional_minutes: input.break_additional_minutes,
|
||||
})
|
||||
.where('place_id', '=', place_id)
|
||||
.where('place_id', 'in', (eb) => eb.selectFrom('places').select('id').where('trip_id', '=', trip_id))
|
||||
.executeTakeFirst();
|
||||
return Number(result.numUpdatedRows ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The paddle plan of a recorded track of the trip: its speeds, the marks
|
||||
* laid onto it and the moving time they make. The track itself is not
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type TourLoaded,
|
||||
} from './tour-change.cards';
|
||||
import { TOUR_DESCRIBERS } from './tour-describe';
|
||||
import { TOUR_EDIT_CARDS } from './tour-edit.cards';
|
||||
import { tourListCard } from './tour-lists.cards';
|
||||
import { proposalCard, savedTourCard } from './tour-route.cards';
|
||||
import { cityWalkCard, paddleCard, skiCard } from './tour-sport.cards';
|
||||
@@ -76,6 +77,13 @@ export const TOUR_CARDS: DomainCards = {
|
||||
},
|
||||
update_tour: updateTour,
|
||||
...TOUR_WRITE_CARDS,
|
||||
...TOUR_EDIT_CARDS,
|
||||
// A copy is a tour of its own, taken back by deleting it while it is as it was made.
|
||||
copy_tour: {
|
||||
write: { verbKey: 'copyTour', entity: 'tour' },
|
||||
describe: TOUR_DESCRIBERS.copy_tour,
|
||||
undo: DELETE_CREATED_TOUR,
|
||||
},
|
||||
set_city_walk_visit: {
|
||||
write: { verbKey: 'markVisited', entity: 'tour' },
|
||||
load: loadVisit,
|
||||
@@ -95,6 +103,8 @@ 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.',
|
||||
'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).',
|
||||
"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). To change a saved tour: a city walk's stations (name, stay, importance, protection, opening hours), its direction or its loop through prepare_tour_change and save_tour_change (edit, reverse, close_loop); a hike's direction or loop with update_tour and reshape; breaks, website, words and planned time of any tour with update_tour_details; its colour with set_tour_route_color; a variant with copy_tour first.",
|
||||
],
|
||||
// P8, P9: edits besides the route, and the writes and reads a ski, paddle or horse tour has of its own.
|
||||
toolControllers: { tours: ['TourEditMcp', 'TourSportWritesMcp'] },
|
||||
};
|
||||
|
||||
@@ -81,12 +81,21 @@ export function replans(args: ToolArgs, saved: unknown): boolean {
|
||||
: hikingPreferencesOf(recordOf(args.hiking_preferences) as HikingPreferences);
|
||||
return (
|
||||
args.waypoints !== undefined ||
|
||||
args.reshape !== undefined ||
|
||||
plan.line === null ||
|
||||
limit !== kept.limit ||
|
||||
canonicalJson(preferences) !== canonicalJson(kept.preferences)
|
||||
);
|
||||
}
|
||||
|
||||
/** The points update_tour plans a reshape through: the saved ones turned round, or with the way back to the start. */
|
||||
export function reshapedPoints(reshape: unknown, points: readonly Row[]): Row[] | null {
|
||||
const first = points[0];
|
||||
if (reshape === 'reverse') return [...points].reverse();
|
||||
if (reshape === 'close_loop' && first) return [...points, { lat: first.lat, lng: first.lng }];
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* update_tour: the saved tour, and where the change plans the way anew the
|
||||
* way it will plan, through the new points or the saved ones under the saved
|
||||
@@ -100,7 +109,8 @@ export async function loadUpdateTour(args: ToolArgs, read: CardReader): Promise<
|
||||
if (!saved || !replans(args, saved)) return { plan: null, saved };
|
||||
const tour = recordOf(recordOf(saved).tour);
|
||||
const kept = keptSettings(tour);
|
||||
const points = args.waypoints ?? rowsIn(recordOf(saved).waypoints).map(({ lat, lng }) => ({ lat, lng }));
|
||||
const savedPoints = rowsIn(recordOf(saved).waypoints).map(({ lat, lng }) => ({ lat, lng }));
|
||||
const points = args.waypoints ?? reshapedPoints(args.reshape, savedPoints) ?? savedPoints;
|
||||
const plan = await read('propose_hike', {
|
||||
name: args.name ?? tour.name,
|
||||
waypoints: points,
|
||||
@@ -198,7 +208,8 @@ export function updateTourCard(ctx: PreviewContext, args: ToolArgs, loaded: Tour
|
||||
const previous = previousOf(loaded.saved);
|
||||
const planned = hikeCard(loaded.plan, args, ctx.card, 'pending');
|
||||
if (planned?.routed) return fitted({ ...planned, placeId, name, label: null, previous });
|
||||
const points = args.waypoints === undefined ? rowsIn(recordOf(loaded.saved).waypoints) : rowsIn(args.waypoints);
|
||||
const saved = rowsIn(recordOf(loaded.saved).waypoints);
|
||||
const points = args.waypoints === undefined ? (reshapedPoints(args.reshape, saved) ?? saved) : rowsIn(args.waypoints);
|
||||
const straight = straightCard(points, 'hike', { placeId, name, previous, problem: heldProblem(planned) });
|
||||
return straight ? fitted(straight) : null;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,20 @@ export const TOUR_DESCRIBERS = {
|
||||
rows,
|
||||
dayId: null,
|
||||
placeIds: placeId === null ? [] : [placeId],
|
||||
// PR1-01, PR1-02: a hike turned round or taken back to its start says so as its verb.
|
||||
...(args.reshape === 'reverse' ? { verbKey: 'reverseTour' as const } : {}),
|
||||
...(args.reshape === 'close_loop' ? { verbKey: 'closeLoop' as const } : {}),
|
||||
};
|
||||
},
|
||||
// A tour of the trip saved again as a new one: the copy's name, the tour it is made from.
|
||||
copy_tour: (ctx, args) => {
|
||||
const source = id(args, 'placeId');
|
||||
const from = source === null ? '' : (ctx.card.places.get(source)?.name ?? '');
|
||||
return {
|
||||
subject: text(args.name),
|
||||
rows: new Rows().add('route', from),
|
||||
dayId: null,
|
||||
placeIds: source !== null && ctx.card.places.has(source) ? [source] : [],
|
||||
};
|
||||
},
|
||||
// A tour of another trip copied in; its own name stays unless the copy is given one.
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { Described, PreviewContext, ToolArgs, ToolCards, UndoCall, UndoSnapshot } from '../card-types';
|
||||
import { argId, readKey, recordOf, type Row } from '../card-values';
|
||||
import { Rows, text, UNCHECKED } from '../preview-format';
|
||||
import { byId, snapshotPlaces } from '../undo-snapshot';
|
||||
import type { AssistantChangeVerbKey } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* P8 and P9 in the chat: the edits of a saved tour besides its route (its
|
||||
* words and times, the colour of its line, a copy), and the writes a ski or
|
||||
* paddle tour has of its own. A card names the tour by the trip's place.
|
||||
* Where the answer keeps what was there before, the way back writes that
|
||||
* again, while the tour is still at the revision the change raised it to
|
||||
* (the colour, which raises none, while the place still has it).
|
||||
*/
|
||||
|
||||
/** A place of the trip by its id, '' where the trip card does not hold it. */
|
||||
function placeName(ctx: PreviewContext, id: number | null): string {
|
||||
return id === null ? '' : (ctx.card.places.get(id)?.name ?? '');
|
||||
}
|
||||
|
||||
function described(ctx: PreviewContext, args: ToolArgs, rows: Rows, verbKey: AssistantChangeVerbKey): Described {
|
||||
const placeId = argId(args, 'placeId');
|
||||
return {
|
||||
subject: placeName(ctx, placeId),
|
||||
rows,
|
||||
dayId: null,
|
||||
placeIds: placeId === null || !ctx.card.places.has(placeId) ? [] : [placeId],
|
||||
verbKey,
|
||||
};
|
||||
}
|
||||
|
||||
/** Minutes as hours and minutes, "1:30 h"; '' for none. */
|
||||
function hours(value: unknown): string {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) return '';
|
||||
return `${Math.floor(value / 60)}:${String(value % 60).padStart(2, '0')} h`;
|
||||
}
|
||||
|
||||
/** The plan of the tour read for an undo, by the call's own arguments. */
|
||||
function planRead(snapshot: UndoSnapshot, call: UndoCall | undefined): Row {
|
||||
return recordOf(snapshot.reads.get(readKey('get_tour_plan', { placeId: call?.args.placeId })));
|
||||
}
|
||||
|
||||
/** The tour is still at the revision the change raised it to: nothing was saved on top of it since. */
|
||||
function stillAtRevision(result: unknown, snapshot: UndoSnapshot, call?: UndoCall): boolean {
|
||||
if (!call) return false;
|
||||
const revision = recordOf(recordOf(result).tour).revision;
|
||||
return typeof revision === 'number' && recordOf(planRead(snapshot, call).tour).revision === revision;
|
||||
}
|
||||
|
||||
const READ_PLAN = (args: ToolArgs) => [{ tool: 'get_tour_plan', args: { placeId: args.placeId } }];
|
||||
|
||||
/** The revision the change raised the tour to, the one its way back names. */
|
||||
function raisedRevision(result: unknown): number | null {
|
||||
const revision = recordOf(recordOf(result).tour).revision;
|
||||
return typeof revision === 'number' && Number.isInteger(revision) && revision > 0 ? revision : null;
|
||||
}
|
||||
|
||||
// update_tour_details
|
||||
|
||||
const DETAIL_FIELDS = [
|
||||
'name',
|
||||
'description',
|
||||
'website',
|
||||
'break_additional_minutes',
|
||||
'planned_duration_minutes',
|
||||
] as const;
|
||||
|
||||
const updateTourDetails: ToolCards = {
|
||||
write: { verbKey: 'updateTour', entity: 'tour' },
|
||||
describe: (ctx, args) => {
|
||||
const current = ctx.card.places.get(argId(args, 'placeId') ?? 0);
|
||||
const rows = new Rows();
|
||||
if (args.name !== undefined) rows.change('name', current?.name ?? null, text(args.name));
|
||||
if (args.description !== undefined)
|
||||
rows.change('description', current?.description ?? null, text(args.description));
|
||||
// A field cleared reads as cleared, not as left out.
|
||||
const cleared = (value: string) => value || UNCHECKED;
|
||||
if (args.website !== undefined) rows.add('website', cleared(text(args.website)));
|
||||
if (args.break_additional_minutes !== undefined) rows.add('breaks', cleared(hours(args.break_additional_minutes)));
|
||||
if (args.planned_duration_minutes !== undefined) rows.add('time', cleared(hours(args.planned_duration_minutes)));
|
||||
return described(ctx, args, rows, 'updateTour');
|
||||
},
|
||||
undo: {
|
||||
tool: 'update_tour_details',
|
||||
args: (args, result) => {
|
||||
const revision = raisedRevision(result);
|
||||
const previous = recordOf(recordOf(result).previous);
|
||||
const changed = DETAIL_FIELDS.filter((field) => args[field] !== undefined && field in previous);
|
||||
if (revision === null || changed.length === 0) return null;
|
||||
return {
|
||||
placeId: args.placeId,
|
||||
expected_revision: revision,
|
||||
...Object.fromEntries(changed.map((field) => [field, previous[field]])),
|
||||
};
|
||||
},
|
||||
reads: READ_PLAN,
|
||||
unchanged: stillAtRevision,
|
||||
},
|
||||
};
|
||||
|
||||
// set_tour_route_color
|
||||
|
||||
const setRouteColor: ToolCards = {
|
||||
write: { verbKey: 'setRouteColor', entity: 'tour' },
|
||||
describe: (ctx, args) => described(ctx, args, new Rows().add('color', text(args.route_color)), 'setRouteColor'),
|
||||
undo: {
|
||||
tool: 'set_tour_route_color',
|
||||
args: (args, result) => {
|
||||
const answer = recordOf(result);
|
||||
const previous = answer.previous_route_color;
|
||||
return previous === undefined ? null : { placeId: args.placeId, route_color: previous };
|
||||
},
|
||||
unchanged: (result, snapshot) => {
|
||||
const answer = recordOf(result);
|
||||
const place = byId(snapshotPlaces(snapshot), answer.place_id);
|
||||
return place !== undefined && (place.route_color ?? null) === (answer.route_color ?? null);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// classify_ski_track
|
||||
|
||||
const classifySki: ToolCards = {
|
||||
write: { verbKey: 'classifySki', entity: 'tour' },
|
||||
describe: (ctx, args) => {
|
||||
const technique = text(args.ski_technique);
|
||||
const type = technique ? `${text(args.ski_discipline)} (${technique})` : text(args.ski_discipline);
|
||||
return described(ctx, args, new Rows().add('type', type), 'classifySki');
|
||||
},
|
||||
undo: {
|
||||
tool: 'classify_ski_track',
|
||||
args: (args, result) => {
|
||||
const revision = raisedRevision(result);
|
||||
const previous = recordOf(recordOf(result).previous);
|
||||
if (revision === null || typeof previous.ski_discipline !== 'string') return null;
|
||||
return {
|
||||
placeId: args.placeId,
|
||||
ski_discipline: previous.ski_discipline,
|
||||
ski_technique: previous.ski_technique ?? null,
|
||||
expected_revision: revision,
|
||||
};
|
||||
},
|
||||
reads: READ_PLAN,
|
||||
unchanged: stillAtRevision,
|
||||
},
|
||||
};
|
||||
|
||||
// set_paddle_plan
|
||||
|
||||
const setPaddlePlan: ToolCards = {
|
||||
write: { verbKey: 'setPaddlePlan', entity: 'tour' },
|
||||
// The portages and locks are drawn on the track's own plan card; the change names the tour.
|
||||
describe: (ctx, args) => described(ctx, args, new Rows(), 'setPaddlePlan'),
|
||||
undo: {
|
||||
tool: 'set_paddle_plan',
|
||||
args: (args, result) => {
|
||||
const revision = raisedRevision(result);
|
||||
const previous = recordOf(recordOf(result).previous);
|
||||
if (revision === null || !previous.track_marks) return null;
|
||||
return {
|
||||
placeId: args.placeId,
|
||||
paddle_speed_kmh: previous.paddle_speed_kmh ?? null,
|
||||
portage_speed_kmh: previous.portage_speed_kmh ?? null,
|
||||
track_marks: previous.track_marks,
|
||||
expected_revision: revision,
|
||||
};
|
||||
},
|
||||
reads: READ_PLAN,
|
||||
unchanged: stillAtRevision,
|
||||
},
|
||||
};
|
||||
|
||||
/** A write on one tour that the card names and nothing takes back on its own. */
|
||||
function plainWrite(verbKey: AssistantChangeVerbKey): ToolCards {
|
||||
return { write: { verbKey, entity: 'tour' }, describe: (ctx, args) => described(ctx, args, new Rows(), verbKey) };
|
||||
}
|
||||
|
||||
export const TOUR_EDIT_CARDS: Readonly<Record<string, ToolCards>> = {
|
||||
update_tour_details: updateTourDetails,
|
||||
set_tour_route_color: setRouteColor,
|
||||
classify_ski_track: classifySki,
|
||||
set_paddle_plan: setPaddlePlan,
|
||||
// The decisions are bound to the data the travellers saw; taking them back is a decision of its own.
|
||||
decide_paddle_obstacles: plainWrite('decideObstacles'),
|
||||
// The planning it drops is gone; copy_tour first keeps it.
|
||||
restore_recorded_track: plainWrite('restoreTrack'),
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -88,7 +88,7 @@ export class HikePlanMcp {
|
||||
@Tool({
|
||||
name: 'update_tour',
|
||||
description:
|
||||
"Change a saved hike (tour_type hike) against the revision it was read at: expected_revision is tour.revision from get_tour_plan or list_tours. A new name, description or planned_duration_minutes keeps the hike's way. New waypoints (start to end; a round trip ends on its start), a new max_hiking_difficulty or new hiking_preferences plan the way anew as propose_hike plans it, and so does a hike still waiting for its way (route_pending); propose the new way to the traveller first. A hike changed since that revision is refused as a conflict: read it again and show the traveller what changed. Where the new way cannot be planned, or not with its heights, nothing is saved and the hike keeps its way; the error says why. A new point standing where a saved one stood (within 25 m) keeps that point's link to a place of the trip and its name. A hike with a section drawn by hand (a via ferrata, a free line, a ferry) takes new points, a new limit or new preferences only in the planner: those changes are refused here. A hike split into stages takes new points only with stage_end given again on each stop a day ends at, and its stages keep their days where a stop stays where it was. A recorded track without points of its own (an import) takes only new waypoints, which plan it over and keep the recording. Any other type is refused: change a city walk with prepare_tour_change and save_tour_change. The hike stays on its days. The answer is the tour as saved, with its new revision, route and replanned. Needs place editing permission on the trip. Change it only after the traveller said yes.",
|
||||
"Change a saved hike (tour_type hike) against the revision it was read at: expected_revision is tour.revision from get_tour_plan or list_tours. A new name, description or planned_duration_minutes keeps the hike's way. New waypoints (start to end; a round trip ends on its start), a new max_hiking_difficulty or new hiking_preferences plan the way anew as propose_hike plans it, and so does a hike still waiting for its way (route_pending); propose the new way to the traveller first. reshape turns the saved points instead of new ones: reverse runs the hike the other way round (its last point starts it), close_loop adds the way back from its last point to its start; either plans the way anew and keeps each point's name and link (a hike split into stages is not reversed here, nor one that already ends at its start). A hike changed since that revision is refused as a conflict: read it again and show the traveller what changed. Where the new way cannot be planned, or not with its heights, nothing is saved and the hike keeps its way; the error says why. A new point standing where a saved one stood (within 25 m) keeps that point's link to a place of the trip and its name. A hike with a section drawn by hand (a via ferrata, a free line, a ferry) takes new points, a new limit or new preferences only in the planner: those changes are refused here. A hike split into stages takes new points only with stage_end given again on each stop a day ends at, and its stages keep their days where a stop stays where it was. A recorded track without points of its own (an import) takes only new waypoints, which plan it over and keep the recording. Any other type is refused: change a city walk with prepare_tour_change and save_tour_change (which reverse it and close its loop too), its words, breaks and website with update_tour_details. The hike stays on its days. The answer is the tour as saved, with its new revision, route and replanned. Needs place editing permission on the trip. Change it only after the traveller said yes.",
|
||||
inputSchema: { tripId: idSchema, ...tourUpdateRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { HikeRoutingService, type PlannedHike } from './hike-routing.service';
|
||||
import { noTrackFound } from './tour-file-import.helpers';
|
||||
import { TourPoiSearchService } from './tour-poi-search.service';
|
||||
import { reshapedHikePoints } from './tour-reshape.helpers';
|
||||
import { tourRevisionConflict } from './tour-revision';
|
||||
import { thinnedLine } from './tours.helpers';
|
||||
import { ToursService, type ImportGpxAsTourResult, type TourCreateCommand } from './tours.service';
|
||||
@@ -120,6 +121,15 @@ function repointingRefusal(saved: readonly TourWaypoint[], points: readonly Hike
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The change with the saved points reshaped as its new points, or refused with why (a hike only). */
|
||||
function withReshape(request: TourUpdateRequest, saved: readonly TourWaypoint[], type: string): TourUpdateRequest {
|
||||
const { reshape, ...rest } = request;
|
||||
if (!reshape || type !== 'hike') return rest;
|
||||
const reshaped = reshapedHikePoints(saved, reshape);
|
||||
if ('refused' in reshaped) throw badRequest(reshaped.refused);
|
||||
return { ...rest, waypoints: reshaped.points };
|
||||
}
|
||||
|
||||
/** Stage ends that do not hold (at the start or the end, too many stages): refused with their reason. */
|
||||
function refuseBadStages(points: readonly HikeStop[], roundTrip: boolean | undefined): void {
|
||||
const issues = hikeStageIssues(points, roundTrip);
|
||||
@@ -296,9 +306,11 @@ export class HikePlanService {
|
||||
* changed in the planner (a city walk through prepare_tour_change and
|
||||
* save_tour_change).
|
||||
*/
|
||||
async update(userId: number, tripId: number, request: TourUpdateRequest): Promise<HikeChanged> {
|
||||
const { placeId } = request;
|
||||
async update(userId: number, tripId: number, asked: TourUpdateRequest): Promise<HikeChanged> {
|
||||
const { placeId } = asked;
|
||||
const saved = await this.tours.getTour(String(tripId), String(placeId));
|
||||
// PR1-01, PR1-02: a reshape is the saved points turned or closed, then planned as new points are.
|
||||
const request = asked.reshape ? withReshape(asked, saved.waypoints, saved.tour.tour_type) : asked;
|
||||
const { tour } = saved;
|
||||
if (tour.tour_type !== 'hike') {
|
||||
throw badRequest(
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import {
|
||||
CITY_WALK_MAX_STATIONS,
|
||||
isClosedLoop,
|
||||
TOUR_STATION_EDIT_FIELDS,
|
||||
type CityWalkStation,
|
||||
type TourChange,
|
||||
type TourChangeOp,
|
||||
@@ -36,7 +37,7 @@ import {
|
||||
const CHANGEABLE_TYPES: ReadonlySet<string> = new Set(['walk']);
|
||||
|
||||
/** How a point of the changed plan came to be: kept from the saved tour, or brought in by a change. */
|
||||
type DraftOrigin = 'saved' | 'added' | 'replacing' | 'moved';
|
||||
type DraftOrigin = 'saved' | 'added' | 'replacing' | 'moved' | 'edited' | 'loop';
|
||||
|
||||
/** A point of the plan as the changes leave it. */
|
||||
export type DraftPoint = TourWaypoint & {
|
||||
@@ -73,7 +74,10 @@ type RefusalReason =
|
||||
| 'fixed_leg'
|
||||
| 'place_not_linkable'
|
||||
| 'too_few_stations'
|
||||
| 'too_many_stations';
|
||||
| 'too_many_stations'
|
||||
| 'reshape_not_alone'
|
||||
| 'walk_under_way'
|
||||
| 'already_loop';
|
||||
|
||||
/** Why a change, or the whole proposal (change null), cannot be prepared. */
|
||||
interface ChangeRefusal {
|
||||
@@ -130,26 +134,32 @@ function newStation(station: CityWalkStation, change: number): DraftPoint {
|
||||
|
||||
/** The work in progress: the points without the way back of a loop, and what the changes did so far. */
|
||||
class ChangeRun {
|
||||
readonly body: DraftPoint[];
|
||||
private body: DraftPoint[];
|
||||
readonly removed: DraftPoint[] = [];
|
||||
readonly replaced: DraftPoint[] = [];
|
||||
readonly shapingDropped: DraftPoint[] = [];
|
||||
readonly refused: ChangeRefusal[] = [];
|
||||
/** The saved stations a change removed or replaced, which no later change can name. */
|
||||
private readonly gone = new Set<string>();
|
||||
/** The way back of a loop as the walk ends: the saved one, turned by a reverse, or the one a close_loop adds. */
|
||||
private back: DraftPoint | null;
|
||||
|
||||
constructor(
|
||||
private readonly basis: ChangeBasis,
|
||||
private readonly loop: boolean,
|
||||
private loop: boolean,
|
||||
) {
|
||||
const points = basis.points.map(savedPoint);
|
||||
this.body = loop ? points.slice(0, -1) : points;
|
||||
this.back = loop ? (points[points.length - 1] ?? null) : null;
|
||||
}
|
||||
|
||||
apply(change: TourChange, index: number): void {
|
||||
const number = index + 1;
|
||||
const refuse = (reason: RefusalReason, key: string | null, days?: StationDone[]) =>
|
||||
this.refused.push({ change: number, op: change.op, key, reason, ...(days ? { days } : {}) });
|
||||
if (change.op === 'edit') return this.edit(change, number, refuse);
|
||||
if (change.op === 'reverse') return this.reverse(number, refuse);
|
||||
if (change.op === 'close_loop') return this.closeLoop(number, refuse);
|
||||
const before = this.refused.length;
|
||||
if ('station' in change) this.checkLink(change.station, (reason) => refuse(reason, null));
|
||||
if (change.op === 'add') {
|
||||
@@ -173,6 +183,72 @@ class ChangeRun {
|
||||
(change.op === 'remove' ? this.removed : this.replaced).push({ ...point, change: number });
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields an edit names written onto its station, which stays where it
|
||||
* is: an edit moves nothing, so a protected station or one already done
|
||||
* takes it too.
|
||||
*/
|
||||
private edit(
|
||||
change: Extract<TourChange, { op: 'edit' }>,
|
||||
number: number,
|
||||
refuse: (reason: RefusalReason, key: string) => void,
|
||||
): void {
|
||||
const index = this.indexOfStation(change.key, refuse);
|
||||
if (index === null) return;
|
||||
const point = this.body[index] as DraftPoint;
|
||||
const edited: DraftPoint = { ...point, origin: point.origin === 'saved' ? 'edited' : point.origin, change: number };
|
||||
for (const field of TOUR_STATION_EDIT_FIELDS) {
|
||||
const value = change[field];
|
||||
if (value !== undefined) Object.assign(edited, { [field]: value });
|
||||
}
|
||||
this.body[index] = edited;
|
||||
}
|
||||
|
||||
/**
|
||||
* PR1-02 for a city walk: the walk the other way round, as the planner
|
||||
* turns it. A loop stays a loop from the same start, its start the station
|
||||
* there; a leg drawn by hand stays on the stretch it covers, its line turned
|
||||
* with it. Refused once the travellers checked a station off on a day.
|
||||
*/
|
||||
private reverse(number: number, refuse: (reason: RefusalReason, key: string | null) => void): void {
|
||||
if ([...this.basis.done.values()].some((days) => days.length > 0)) {
|
||||
refuse('walk_under_way', null);
|
||||
return;
|
||||
}
|
||||
// A protected station keeps its place; turned round, every station but the start of a loop moves.
|
||||
const kept = this.body.filter((point, at) => point.protected === true && !(this.back && at === 0));
|
||||
for (const point of kept) refuse('protected', point.key ?? null);
|
||||
if (kept.length > 0) return;
|
||||
const whole = this.back ? [...this.body, this.back] : this.body;
|
||||
const turned = reversedPoints(whole).map((point, at): DraftPoint => {
|
||||
const was = whole.findIndex((one) => one.ref === point.ref);
|
||||
return was !== at && point.origin === 'saved' ? { ...point, origin: 'moved', change: number } : point;
|
||||
});
|
||||
this.body = this.back ? turned.slice(0, -1) : turned;
|
||||
this.back = this.back ? (turned[turned.length - 1] ?? null) : null;
|
||||
}
|
||||
|
||||
/** PR1-01 for a city walk: a way back from its last station to its start, no station of its own. */
|
||||
private closeLoop(number: number, refuse: (reason: RefusalReason, key: string | null) => void): void {
|
||||
const first = this.body[0];
|
||||
if (this.loop || !first) {
|
||||
refuse('already_loop', null);
|
||||
return;
|
||||
}
|
||||
this.loop = true;
|
||||
this.back = {
|
||||
lat: first.lat,
|
||||
lng: first.lng,
|
||||
role: 'end',
|
||||
sequence: 0,
|
||||
ref: `new:${number}`,
|
||||
origin: 'loop',
|
||||
change: number,
|
||||
after: null,
|
||||
replaces: null,
|
||||
};
|
||||
}
|
||||
|
||||
private replacing(station: CityWalkStation, change: number, key: string): DraftPoint {
|
||||
return { ...newStation(station, change), origin: 'replacing', replaces: key };
|
||||
}
|
||||
@@ -294,8 +370,7 @@ class ChangeRun {
|
||||
/** The changed points in order with their roles and sequence, the way back of a loop at its start again. */
|
||||
finished(): DraftPoint[] {
|
||||
const first = this.body[0];
|
||||
const last = this.basis.points[this.basis.points.length - 1];
|
||||
const back = this.loop && first && last ? [{ ...savedPoint(last), lat: first.lat, lng: first.lng }] : [];
|
||||
const back = this.loop && first && this.back ? [{ ...this.back, lat: first.lat, lng: first.lng }] : [];
|
||||
const points = [...this.body, ...back];
|
||||
const end = points.length - 1;
|
||||
return points.map((point, sequence): DraftPoint => ({
|
||||
@@ -306,6 +381,74 @@ class ChangeRun {
|
||||
}
|
||||
}
|
||||
|
||||
type FixedLeg = NonNullable<TourWaypoint['fixed_leg']>;
|
||||
|
||||
/** A fixed leg run the other way: its line turned, a crossing by boat between the same points of it. */
|
||||
export function turnedLeg(leg: FixedLeg): FixedLeg {
|
||||
const geometry = [...leg.geometry].reverse();
|
||||
if (leg.kind !== 'ferry') return { ...leg, geometry };
|
||||
const last = geometry.length - 1;
|
||||
const crossings = leg.crossings.map(([from, to]): [number, number] => [last - to, last - from]).reverse();
|
||||
return { ...leg, geometry, crossings };
|
||||
}
|
||||
|
||||
/** What makes a point a station, kept apart so the start of a loop stays the station there. */
|
||||
const STATION_PARTS = [
|
||||
'key',
|
||||
'name',
|
||||
'stay_minutes',
|
||||
'importance',
|
||||
'protected',
|
||||
'opening_hours',
|
||||
'poi_category',
|
||||
] as const;
|
||||
|
||||
function stationPartsOf(point: DraftPoint): Partial<DraftPoint> {
|
||||
return Object.fromEntries(STATION_PARTS.flatMap((part) => (point[part] === undefined ? [] : [[part, point[part]]])));
|
||||
}
|
||||
|
||||
function withoutStationParts(point: DraftPoint): DraftPoint {
|
||||
const rest = { ...point };
|
||||
for (const part of STATION_PARTS) delete rest[part];
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* The points the other way round: the leg after a point is the one that led
|
||||
* into it before, turned. In a loop the way back and the start trade what
|
||||
* makes each a station (and the ref the changes know it by), so the start
|
||||
* keeps its key, name and stay (CW-03).
|
||||
*/
|
||||
export function reversedPoints(points: readonly DraftPoint[]): DraftPoint[] {
|
||||
const reversed = points.map((_, index): DraftPoint => {
|
||||
const point = points[points.length - 1 - index] as DraftPoint;
|
||||
const before = points[points.length - 2 - index];
|
||||
const { fixed_leg: _old, ...rest } = point;
|
||||
const leg = before?.fixed_leg;
|
||||
return leg && index < points.length - 1 ? { ...rest, fixed_leg: turnedLeg(leg) } : rest;
|
||||
});
|
||||
if (!isClosedLoop(points) || reversed.length < 2) return reversed;
|
||||
const start = reversed[0] as DraftPoint;
|
||||
const back = reversed[reversed.length - 1] as DraftPoint;
|
||||
return [
|
||||
{ ...withoutStationParts(start), ...stationPartsOf(back), ref: back.ref, origin: back.origin },
|
||||
...reversed.slice(1, -1),
|
||||
{ ...withoutStationParts(back), ...stationPartsOf(start), ref: start.ref, origin: start.origin },
|
||||
];
|
||||
}
|
||||
|
||||
/** A reverse or a close_loop goes with edits only: anything else would be laid onto points it turned. */
|
||||
function reshapeNotAlone(changes: readonly TourChange[]): boolean {
|
||||
const reshapes = changes.filter((change) => change.op === 'reverse' || change.op === 'close_loop').length;
|
||||
const others = changes.filter((change) => change.op !== 'edit').length - reshapes;
|
||||
return reshapes > 1 || (reshapes === 1 && others > 0);
|
||||
}
|
||||
|
||||
/** Whether every change only edits stations, so the walk keeps its way as saved. */
|
||||
export function editsOnly(changes: readonly TourChange[]): boolean {
|
||||
return changes.every((change) => change.op === 'edit');
|
||||
}
|
||||
|
||||
/** The whole numbers from `from` up to, not including, `to`. */
|
||||
function range(from: number, to: number): number[] {
|
||||
return Array.from({ length: Math.max(0, to - from) }, (_, offset) => from + offset);
|
||||
@@ -323,12 +466,13 @@ export function applyChanges(basis: ChangeBasis, changes: readonly TourChange[])
|
||||
});
|
||||
if (!CHANGEABLE_TYPES.has(basis.tourType)) return whole('type_not_supported');
|
||||
if (basis.recorded) return whole('recorded_track');
|
||||
if (reshapeNotAlone(changes)) return whole('reshape_not_alone');
|
||||
const loop = isClosedLoop(basis.points);
|
||||
const run = new ChangeRun(basis, loop);
|
||||
changes.forEach((change, index) => run.apply(change, index));
|
||||
if (run.refused.length > 0) return { kind: 'refused', refused: run.refused };
|
||||
const points = run.finished();
|
||||
const count = stationCount(points, loop);
|
||||
const count = stationCount(points, isClosedLoop(points));
|
||||
if (count < 2) return whole('too_few_stations');
|
||||
if (count > CITY_WALK_MAX_STATIONS && count > stationCount(basis.points.map(savedPoint), loop)) {
|
||||
return whole('too_many_stations');
|
||||
@@ -359,12 +503,22 @@ const REFUSAL_TEXT: Record<RefusalReason, string> = {
|
||||
place_not_linkable: 'the new station names a place that is not a place of this trip, or is a tour',
|
||||
too_few_stations: 'a city walk keeps at least two stations',
|
||||
too_many_stations: `a city walk holds at most ${CITY_WALK_MAX_STATIONS} stations`,
|
||||
reshape_not_alone:
|
||||
'a reverse or a close_loop is the only change of its proposal besides edits; prepare the other changes apart',
|
||||
walk_under_way:
|
||||
'the travellers already visited or skipped stations of this walk; it is not turned round while it is under way',
|
||||
already_loop: 'this walk already ends where it starts',
|
||||
};
|
||||
|
||||
/** How a refusal names the change it is about: its op and the station it names, where it names one. */
|
||||
function changeLabel(refusal: ChangeRefusal): string {
|
||||
if (refusal.key) return `${refusal.op} ${refusal.key}`;
|
||||
return refusal.op === 'reverse' || refusal.op === 'close_loop' ? refusal.op : `${refusal.op} station`;
|
||||
}
|
||||
|
||||
/** One refusal as a sentence the assistant can pass on. */
|
||||
function refusalLine(refusal: ChangeRefusal): string {
|
||||
const about =
|
||||
refusal.change === null ? 'The proposal' : `Change ${refusal.change} (${refusal.op} ${refusal.key ?? 'station'})`;
|
||||
const about = refusal.change === null ? 'The proposal' : `Change ${refusal.change} (${changeLabel(refusal)})`;
|
||||
const days = refusal.days ? `, day ids ${refusal.days.map((day) => `${day.day_id} ${day.status}`).join(', ')}` : '';
|
||||
return `${about}: ${REFUSAL_TEXT[refusal.reason]}${days}.`;
|
||||
}
|
||||
|
||||
@@ -24,13 +24,16 @@ interface ChangeEffectsInput {
|
||||
stored: { distance_km: number | null; walking_minutes: number | null };
|
||||
}
|
||||
|
||||
type StationChange = 'kept' | 'added' | 'replaced' | 'moved';
|
||||
type StationChange = 'kept' | 'added' | 'replaced' | 'moved' | 'edited';
|
||||
|
||||
const CHANGE_OF: Record<DraftPoint['origin'], StationChange> = {
|
||||
saved: 'kept',
|
||||
added: 'added',
|
||||
replacing: 'replaced',
|
||||
moved: 'moved',
|
||||
edited: 'edited',
|
||||
// The way back a close_loop adds is no station, so no station row ever names it.
|
||||
loop: 'added',
|
||||
};
|
||||
|
||||
/** The key a point is saved under; null for a station a change brings in, which gets one when saved. */
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { TourStationVisitsRepository } from '../../db/repositories/TourStat
|
||||
import type { TourWaypointsRepository } from '../../db/repositories/TourWaypoints.repository';
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { CityWalkFootWayService } from './city-walk-foot-way.service';
|
||||
import { applyChanges, refusalText, type RefusedAction, type StationDone } from './tour-change-apply';
|
||||
import { applyChanges, editsOnly, refusalText, type RefusedAction, type StationDone } from './tour-change-apply';
|
||||
import { describeChange } from './tour-change-effects';
|
||||
import { proposalIdOf, staleProposal } from './tour-change-proposal';
|
||||
import { ToursService } from './tours.service';
|
||||
@@ -96,10 +96,13 @@ export class TourChangePreviewService {
|
||||
|
||||
async prepare(tripId: number, placeId: number, userId: number, { changes }: TourChangeRequest) {
|
||||
const { tour, waypoints, outcome } = await this.lay(tripId, placeId, changes, { action: 'prepared' });
|
||||
const [wayBefore, wayAfter] = await Promise.all([
|
||||
this.footWays.plan(userId, tripId, waypoints),
|
||||
this.footWays.plan(userId, tripId, outcome.points),
|
||||
]);
|
||||
// Edits alone move no station, so the way after is the way before and the router is asked once.
|
||||
const [wayBefore, wayAfter] = editsOnly(changes)
|
||||
? await this.footWays.plan(userId, tripId, waypoints).then((way) => [way, way] as const)
|
||||
: await Promise.all([
|
||||
this.footWays.plan(userId, tripId, waypoints),
|
||||
this.footWays.plan(userId, tripId, outcome.points),
|
||||
]);
|
||||
const revision = tour.revision ?? null;
|
||||
return {
|
||||
prepared_only: true,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DomainError } from '../common/domain-error';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import type { FootWay } from './city-walk-briefing.helpers';
|
||||
import { CityWalkFootWayService } from './city-walk-foot-way.service';
|
||||
import type { DraftPoint } from './tour-change-apply';
|
||||
import { editsOnly, type DraftPoint } from './tour-change-apply';
|
||||
import { asWaypoint } from './tour-change-effects';
|
||||
import { TourChangePreviewService } from './tour-change-preview.service';
|
||||
import { proposalIdOf } from './tour-change-proposal';
|
||||
@@ -14,7 +14,13 @@ import { tourWriteOf, writtenStations, writtenTour } from './tour-change-write';
|
||||
import { ToursService, type TourCreateCommand, type TourRouteDrops } from './tours.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { TourChangeSaveRequest, TourDetailResponse, TourListItem, TourWaypoint } from '@trek/shared';
|
||||
import type {
|
||||
GeometryPoint,
|
||||
TourChangeSaveRequest,
|
||||
TourDetailResponse,
|
||||
TourListItem,
|
||||
TourWaypoint,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** The refusal of a save naming a proposal other than the one these changes were prepared as. */
|
||||
export const TOUR_CHANGE_NOT_PREPARED = 'tour_change_not_prepared';
|
||||
@@ -25,6 +31,12 @@ interface ChangedTour {
|
||||
points: readonly DraftPoint[];
|
||||
way: FootWay;
|
||||
preparedOn: number;
|
||||
/**
|
||||
* Edits alone move no station: the walk keeps its saved line (heights and
|
||||
* all) and its walking time, and the router is not asked, so a router that
|
||||
* is down never takes the way of a walk whose stays were changed.
|
||||
*/
|
||||
kept?: { line: GeometryPoint[] | null; seconds: number | null };
|
||||
}
|
||||
|
||||
/** The step a save recorded: its id and the revision it raised the tour to. */
|
||||
@@ -42,11 +54,11 @@ type SaveOutcome = { earlier: TourChangeStepRow } | ({ step: SavedStep } & TourR
|
||||
* only, as a created one does (CW-11): no line, no distance and no walking
|
||||
* time until the planner plans the way.
|
||||
*/
|
||||
function writeOf({ tour, points, way, preparedOn }: ChangedTour): TourCreateCommand {
|
||||
function writeOf({ tour, points, way, preparedOn, kept }: ChangedTour): TourCreateCommand {
|
||||
const routed = way.kind === 'routed' ? way : null;
|
||||
return tourWriteOf(tour, {
|
||||
route_geometry: routed?.line ?? null,
|
||||
duration_seconds: routed?.seconds ?? null,
|
||||
route_geometry: kept ? kept.line : (routed?.line ?? null),
|
||||
duration_seconds: kept ? kept.seconds : (routed?.seconds ?? null),
|
||||
waypoints: points.map(asWaypoint),
|
||||
expected_revision: preparedOn,
|
||||
});
|
||||
@@ -129,8 +141,14 @@ export class TourChangeSaveService {
|
||||
const earlier = await this.stepsRepo.findSavedProposal(placeId, proposalId, preparedOn);
|
||||
if (earlier) return alreadySavedAnswer(earlier);
|
||||
const laid = await this.preview.lay(tripId, placeId, changes, { action: 'saved', preparedOn });
|
||||
const way = await this.footWays.plan(userId, tripId, laid.outcome.points);
|
||||
const changed: ChangedTour = { tour: laid.tour, points: laid.outcome.points, way, preparedOn };
|
||||
const changed: ChangedTour = editsOnly(changes)
|
||||
? await this.keptWay(tripId, placeId, laid.tour, laid.outcome.points, preparedOn)
|
||||
: {
|
||||
tour: laid.tour,
|
||||
points: laid.outcome.points,
|
||||
way: await this.footWays.plan(userId, tripId, laid.outcome.points),
|
||||
preparedOn,
|
||||
};
|
||||
const write = await this.tours.routeWrite(writeOf(changed));
|
||||
const saved = await this.uow.transactional(async (): Promise<SaveOutcome> => {
|
||||
// The same proposal saved by a save sent alongside this one while the way was planned.
|
||||
@@ -159,6 +177,23 @@ export class TourChangeSaveService {
|
||||
return savedAnswer(detail, saved.step, changed);
|
||||
}
|
||||
|
||||
/** A change of edits only, written with the walk's saved line and walking time. */
|
||||
private async keptWay(
|
||||
tripId: number,
|
||||
placeId: number,
|
||||
tour: TourListItem,
|
||||
points: readonly DraftPoint[],
|
||||
preparedOn: number,
|
||||
): Promise<ChangedTour> {
|
||||
const line = await this.tours.savedLine(tripId, placeId);
|
||||
const way: FootWay =
|
||||
line.length >= 2
|
||||
? { kind: 'routed', line: line.map(([lat, lng]): [number, number] => [lat, lng]), legs: [], seconds: 0 }
|
||||
: { kind: 'unrouted', why: 'nothing_to_route', position: null };
|
||||
const seconds = tour.duration == null ? null : tour.duration * 60;
|
||||
return { tour, points, way, preparedOn, kept: { line: line.length >= 2 ? line : null, seconds } };
|
||||
}
|
||||
|
||||
/** The tour as it is before the change, as the step keeps it: its line, its points and its measures. */
|
||||
private async beforeState(
|
||||
tripId: number,
|
||||
|
||||
@@ -24,7 +24,9 @@ export interface TourLineWrite {
|
||||
|
||||
/**
|
||||
* The write of a tour's line and points, everything else of the tour as it
|
||||
* is: a change of stations, or taking one back, changes nothing else.
|
||||
* is: a change of stations, or taking one back, changes nothing else. Every
|
||||
* setting of every type goes along, so a type that is no city walk keeps its
|
||||
* profile, pace, discipline or boat through such a write as well.
|
||||
*/
|
||||
export function tourWriteOf(tour: TourListItem, line: TourLineWrite): TourCreateCommand {
|
||||
return {
|
||||
@@ -35,6 +37,13 @@ export function tourWriteOf(tour: TourListItem, line: TourLineWrite): TourCreate
|
||||
max_hiking_difficulty: tour.max_hiking_difficulty,
|
||||
hiking_preferences: tour.hiking_preferences,
|
||||
bike_profile: tour.bike_profile,
|
||||
run_profile: tour.run_profile,
|
||||
run_pace_seconds_per_km: tour.run_pace_seconds_per_km,
|
||||
ski_discipline: tour.ski_discipline,
|
||||
ski_technique: tour.ski_technique,
|
||||
ski_options: tour.ski_options,
|
||||
ski_route_build: tour.ski_route_build,
|
||||
ski_planned_legs: tour.ski_planned_legs,
|
||||
boat_type: tour.boat_type,
|
||||
water_context: tour.water_context,
|
||||
paddle_speed_kmh: tour.paddle_speed_kmh,
|
||||
|
||||
@@ -54,7 +54,7 @@ export class TourChangeMcp {
|
||||
@Tool({
|
||||
name: 'prepare_tour_change',
|
||||
description:
|
||||
"Prepare a change to a saved tour for the traveller to review, without saving anything: no tour, station, visit or day plan changes, and turning it down keeps the plan as it is. Read the tour with get_tour_plan first. A city walk (tour_type walk) takes changes so far; every other type, and a walk that is a recorded track without stations of its own, is refused with the reason. changes apply in order, each naming saved stations by their key: add (a new station after after_key; a spontaneous stop is an add after the station the traveller is at), remove, replace (a new station in the place of key) and move (key to after after_key). Stops put after the same station keep the order given. A station protected, or visited or skipped on any day of the walk, is never removed, replaced or moved, nothing is put before the last station already done, and a leg drawn by hand stays as drawn (the closing leg of a loop included); such a change is refused, and so is the whole proposal, with every reason in the error. The answer: prepared_only (always true), base (the tour and the revision it was prepared on: a save has to name it, and a tour changed since is refused rather than overwritten, so prepare again), stations_after (each with its key, null for a new one, change kept, added, replaced or moved, by_change, was_position and what it replaces), removed, effects (walking: the whole way on foot before and after planned by TREK's router and the change in metres and minutes, the distance and walking time stored with the walk apart; stays: known minutes and unknown stays before and after; legs: each leg after, new ones marked), data_gaps (what could not be worked out and why: no way from the router, stays not known, no arrival times, shaping points dropped because the leg they steered changed), warnings (a must station removed or replaced), for_later (each new station not yet a place of the trip as create_place takes it, to keep it as a place instead; a station with linked_place_id already is one and is left out) and waypoints_after. proposal_id names this very proposal for save_tour_change. Show every change and its effects to the traveller and save nothing without their yes. can_save says whether they may change the trip's places.",
|
||||
"Prepare a change to a saved tour for the traveller to review, without saving anything: no tour, station, visit or day plan changes, and turning it down keeps the plan as it is. Read the tour with get_tour_plan first. A city walk (tour_type walk) takes changes so far; every other type, and a walk that is a recorded track without stations of its own, is refused with the reason. changes apply in order, each naming saved stations by their key: add (a new station after after_key; a spontaneous stop is an add after the station the traveller is at), remove, replace (a new station in the place of key), move (key to after after_key) and edit (what station key says of itself: name, stay_minutes with 0 for a point passed through and null for not known, importance optional or must with null for normal, protected, opening_hours; only the fields given change, the station stays where it is, and a protected or already visited station takes an edit too). reverse turns the whole walk round (a loop stays a loop from the same start) and close_loop adds the way back from its last station to its start; each of them is the only change of its proposal besides edits, a walk under way (a station visited or skipped) is not reversed, nor one with a protected station off its start. Stops put after the same station keep the order given. Edits alone keep the walk's saved way and walking time. A station protected, or visited or skipped on any day of the walk, is never removed, replaced or moved, nothing is put before the last station already done, and a leg drawn by hand stays as drawn (the closing leg of a loop included); such a change is refused, and so is the whole proposal, with every reason in the error. The answer: prepared_only (always true), base (the tour and the revision it was prepared on: a save has to name it, and a tour changed since is refused rather than overwritten, so prepare again), stations_after (each with its key, null for a new one, change kept, added, replaced or moved, by_change, was_position and what it replaces), removed, effects (walking: the whole way on foot before and after planned by TREK's router and the change in metres and minutes, the distance and walking time stored with the walk apart; stays: known minutes and unknown stays before and after; legs: each leg after, new ones marked), data_gaps (what could not be worked out and why: no way from the router, stays not known, no arrival times, shaping points dropped because the leg they steered changed), warnings (a must station removed or replaced), for_later (each new station not yet a place of the trip as create_place takes it, to keep it as a place instead; a station with linked_place_id already is one and is left out) and waypoints_after. proposal_id names this very proposal for save_tour_change. Show every change and its effects to the traveller and save nothing without their yes. can_save says whether they may change the trip's places.",
|
||||
inputSchema: {
|
||||
tripId: idSchema,
|
||||
placeId: idSchema.describe('The place id of the tour, as list_tours gives it'),
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import type { ToursRepository } from '../../db/repositories/Tours.repository';
|
||||
import { isUpdateConflict } from '../common/conflictResult';
|
||||
import { badRequest } from '../common/domain-error';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { advanceTourRevision } from './tour-revision';
|
||||
import { ToursService } from './tours.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
maxTourMinutesFor,
|
||||
plannedTimeLimitMessage,
|
||||
tourStageCountOf,
|
||||
type TourDetailsUpdateRequest,
|
||||
type TourListItem,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** A tour's words and planned times, as update_tour_details reads and writes them. */
|
||||
interface TourDetails {
|
||||
name: string;
|
||||
description: string | null;
|
||||
website: string | null;
|
||||
break_additional_minutes: number | null;
|
||||
planned_duration_minutes: number | null;
|
||||
}
|
||||
|
||||
function detailsOf(tour: TourListItem): TourDetails {
|
||||
return {
|
||||
name: tour.name,
|
||||
description: tour.description ?? null,
|
||||
website: tour.website ?? null,
|
||||
break_additional_minutes: tour.break_additional_minutes ?? null,
|
||||
planned_duration_minutes: tour.planned_duration_minutes ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The value a change gives a field, or the one it keeps. */
|
||||
function next<K extends keyof TourDetails>(request: TourDetailsUpdateRequest, before: TourDetails, key: K) {
|
||||
const value = request[key];
|
||||
return (value === undefined ? before[key] : value) as TourDetails[K];
|
||||
}
|
||||
|
||||
/**
|
||||
* P8: the edits of a saved tour besides its route, for every type, a
|
||||
* recorded track included. Its words (name, description, website) are the
|
||||
* place's, written through the places' own update; its planned times are
|
||||
* the tour's, held to the limit the planner's save holds them to (the moving
|
||||
* time and the breaks within a day, a day a stage for a tour over several
|
||||
* days, unless a planned total is set). Both go in one transaction that
|
||||
* raises the tour's revision against the one it was read at, and the trip
|
||||
* hears tours:changed and place:updated as after the planner's save. The
|
||||
* route, the points and the days stay as they are.
|
||||
*
|
||||
* Also the two writes the tour detail makes on the place: the colour of its
|
||||
* line, and the copy of the tour as a new one of the trip.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TourDetailsService {
|
||||
constructor(
|
||||
private readonly uow: UnitOfWork,
|
||||
private readonly tours: ToursService,
|
||||
private readonly places: PlacesService,
|
||||
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
|
||||
) {}
|
||||
|
||||
async update(tripId: number, request: TourDetailsUpdateRequest) {
|
||||
const { placeId } = request;
|
||||
const detail = await this.tours.getTour(String(tripId), String(placeId));
|
||||
const before = detailsOf(detail.tour);
|
||||
const after: TourDetails = {
|
||||
name: next(request, before, 'name'),
|
||||
description: next(request, before, 'description'),
|
||||
website: next(request, before, 'website'),
|
||||
break_additional_minutes: next(request, before, 'break_additional_minutes'),
|
||||
planned_duration_minutes: next(request, before, 'planned_duration_minutes'),
|
||||
};
|
||||
const stages = tourStageCountOf(detail.waypoints);
|
||||
const moving = detail.tour.duration;
|
||||
if (
|
||||
after.planned_duration_minutes == null &&
|
||||
moving != null &&
|
||||
after.break_additional_minutes != null &&
|
||||
moving + after.break_additional_minutes > maxTourMinutesFor(stages)
|
||||
) {
|
||||
throw badRequest(plannedTimeLimitMessage(stages));
|
||||
}
|
||||
const words = request.name !== undefined || request.description !== undefined || request.website !== undefined;
|
||||
const times = request.break_additional_minutes !== undefined || request.planned_duration_minutes !== undefined;
|
||||
const revision = await this.uow.transactional(async () => {
|
||||
const raised = await advanceTourRevision(this.toursRepo, placeId, request.expected_revision);
|
||||
if (words) {
|
||||
// The places' update keeps a field it is not handed; website and description clear with null.
|
||||
const place = await this.places.update(String(tripId), String(placeId), {
|
||||
name: after.name,
|
||||
description: after.description,
|
||||
website: after.website,
|
||||
} as Parameters<PlacesService['update']>[2]);
|
||||
if (!place) throw new NotFoundException('Tour not found');
|
||||
}
|
||||
if (times) {
|
||||
const { planned_duration_minutes, break_additional_minutes } = after;
|
||||
await this.toursRepo.updateTimesInTrip(tripId, placeId, { planned_duration_minutes, break_additional_minutes });
|
||||
}
|
||||
return raised;
|
||||
});
|
||||
const saved = await this.tours.announceRouteUpdate(String(tripId), placeId, {
|
||||
visitsDropped: [],
|
||||
stagesDropped: [],
|
||||
});
|
||||
return {
|
||||
tour: { place_id: placeId, tour_type: saved.tour.tour_type, revision, ...detailsOf(saved.tour) },
|
||||
previous: { revision: detail.tour.revision ?? null, ...before },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour the tour's line is drawn in, as the tour detail sets it on the
|
||||
* place: through the places' update and its place:updated, without raising
|
||||
* the tour's revision (the route is not touched). Null draws it in the
|
||||
* colour of its type again.
|
||||
*/
|
||||
async setRouteColor(tripId: number, placeId: number, color: string | null) {
|
||||
if (!(await this.toursRepo.findInTrip(tripId, placeId))) throw new NotFoundException('Tour not found');
|
||||
const current = await this.places.get(String(tripId), String(placeId));
|
||||
if (!current) throw new NotFoundException('Tour not found');
|
||||
const place = await this.places.update(String(tripId), String(placeId), { route_color: color });
|
||||
// No base version is sent, so the update never answers a conflict; the check only narrows the type.
|
||||
if (!place || isUpdateConflict(place)) throw new NotFoundException('Tour not found');
|
||||
this.places.broadcast(String(tripId), 'place:updated', { place }, undefined);
|
||||
await this.places.onUpdated(place.id);
|
||||
return { place_id: placeId, route_color: color, previous_route_color: current.route_color ?? null };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import {
|
||||
McpController,
|
||||
Tool,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
TOOL_ANNOTATIONS_WRITE,
|
||||
errorResult,
|
||||
ok,
|
||||
type McpContext,
|
||||
} from '../../nest-mcp';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
import { TripAccessService } from '../trip-membership/trip-access.service';
|
||||
import { TourDetailsService } from './tour-details.service';
|
||||
import { ToursService } from './tours.service';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
idSchema,
|
||||
tourCopyToolRequestSchema,
|
||||
tourDetailsUpdateRequestSchema,
|
||||
tourRouteColorRequestSchema,
|
||||
type TourCopyToolRequest,
|
||||
type TourDetailsUpdateRequest,
|
||||
type TourRouteColorRequest,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** A tour the trip does not hold answers as not found, as REST answers its 404. */
|
||||
async function orNotFound<T>(write: () => Promise<T>) {
|
||||
try {
|
||||
return ok(await write());
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof NotFoundException) return errorResult('Tour not found.');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P8: the edits of a saved tour besides its route, for every type: its words
|
||||
* and planned times (the planner's editor fields, saved over REST with the
|
||||
* tour's PUT), the colour of its line (the tour detail's colour picker, the
|
||||
* places' PUT) and a copy of it as a new tour of the trip (POST .../copy).
|
||||
* Each asks 'place_edit' on the trip as those routes do, and a refusal of the
|
||||
* service (a time past the day's limit, a tour changed since it was read) is
|
||||
* a DomainError the registry answers with the text REST sends.
|
||||
*/
|
||||
@McpController()
|
||||
export class TourEditMcp {
|
||||
constructor(
|
||||
private readonly details: TourDetailsService,
|
||||
private readonly tours: ToursService,
|
||||
private readonly access: TripAccessService,
|
||||
private readonly guards: McpToolGuardsService,
|
||||
// Read by the addon gate of every tool here.
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
/** Trip access, then 'place_edit', as the REST routes check them; null when both hold. */
|
||||
private async refusal(tripId: number, userId: number) {
|
||||
if (!(await this.access.findAccessible(tripId, userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('place_edit', tripId, userId))) return permissionDenied();
|
||||
return null;
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'update_tour_details',
|
||||
description:
|
||||
'Change the words and planned times of a saved tour of any type (a hike, a city walk, a bike tour, a run, a paddle, ski or horse tour, a recorded track too) without touching its route, its points or its days: name, description, website (an https URL), break_additional_minutes (the breaks on top of the moving time) and planned_duration_minutes (the time out in all the travellers set themselves, breaks included); only the fields given change, null clears one. expected_revision is tour.revision from get_tour_plan or list_tours: a tour changed since is refused as a conflict, read it again. The moving time and the breaks stay within a day (a day a stage for a tour over several days) unless a planned total is set, as in the planner. The answer gives the tour as now saved with its new revision, and previous, the values before. Needs place editing permission on the trip. Change it only after the traveller said yes.',
|
||||
inputSchema: { tripId: idSchema, ...tourDetailsUpdateRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'write' },
|
||||
})
|
||||
async updateTourDetails({ tripId, ...request }: { tripId: number } & TourDetailsUpdateRequest, ctx: McpContext) {
|
||||
const refused = await this.refusal(tripId, ctx.userId);
|
||||
if (refused) return refused;
|
||||
const checked = tourDetailsUpdateRequestSchema.safeParse(request);
|
||||
if (!checked.success) return errorResult(checked.error.issues.map((issue) => issue.message).join('; '));
|
||||
return await orNotFound(() => this.details.update(tripId, checked.data));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'set_tour_route_color',
|
||||
description:
|
||||
'Draw the line of a saved tour in another colour on the map, as the colour picker of the tour detail does: route_color is a hex colour such as "#e11d48", null draws it in the colour of its tour type again. Only the colour changes: the route, the revision and the days stay as they are. The answer gives route_color and previous_route_color. Needs place editing permission on the trip.',
|
||||
inputSchema: { tripId: idSchema, ...tourRouteColorRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'write' },
|
||||
})
|
||||
async setTourRouteColor(
|
||||
{ tripId, placeId, route_color }: { tripId: number } & TourRouteColorRequest,
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const refused = await this.refusal(tripId, ctx.userId);
|
||||
if (refused) return refused;
|
||||
return await orNotFound(() => this.details.setRouteColor(tripId, placeId, route_color));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'copy_tour',
|
||||
description:
|
||||
'Save a tour of the trip again as a new, independent tour of the same trip under its own name, as the planner\'s "save as new tour" does: the copy has its own id, line and control points, the same type, measures and settings, its stops keep their links to the trip\'s places, and it is on no day yet (put it on one with assign_place_to_day). Use it to try a variant without losing the original: copy first, then change the copy. The answer is the copy as list_tours reads it (tour.place_id is its id, revision 1). Needs place editing permission on the trip. Copy only when the traveller asked for it.',
|
||||
inputSchema: { tripId: idSchema, ...tourCopyToolRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'write' },
|
||||
})
|
||||
async copyTour({ tripId, placeId, name }: { tripId: number } & TourCopyToolRequest, ctx: McpContext) {
|
||||
const refused = await this.refusal(tripId, ctx.userId);
|
||||
if (refused) return refused;
|
||||
return await orNotFound(() => this.tours.copyTour(String(tripId), String(placeId), name));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* PR1-01 and PR1-02 for a saved hike changed over MCP: its points the other
|
||||
* way round, or taken back to its start, as the planner's route tools turn
|
||||
* them. Only the points are worked out here; update_tour plans the way
|
||||
* through them anew, under the hike's limit and preferences, and keeps each
|
||||
* point's name, key and link where it stands where a saved one stood.
|
||||
*/
|
||||
import { isClosedLoop, type HikeStop, type TourReshape, type TourWaypoint } from '@trek/shared';
|
||||
|
||||
/** The points a reshaped hike is planned through, or why it cannot be reshaped. */
|
||||
type Reshaped = { points: HikeStop[] } | { refused: string };
|
||||
|
||||
/** A saved point as update_tour takes a new one: where it is, what it is, and whether a stage ends there. */
|
||||
function stopOf(point: TourWaypoint): HikeStop {
|
||||
return {
|
||||
lat: point.lat,
|
||||
lng: point.lng,
|
||||
...(point.name ? { name: point.name } : {}),
|
||||
...(point.poi_category ? { poi_category: point.poi_category } : {}),
|
||||
...(point.stage_end ? { stage_end: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function reshapedHikePoints(saved: readonly TourWaypoint[], reshape: TourReshape): Reshaped {
|
||||
if (saved.length < 2) {
|
||||
return {
|
||||
refused:
|
||||
'This hike is a recorded track without points of its own, so there is nothing to turn: give waypoints to plan it over first.',
|
||||
};
|
||||
}
|
||||
if (reshape === 'reverse') {
|
||||
if (saved.some((point) => point.stage_end)) {
|
||||
return {
|
||||
refused:
|
||||
'This hike is split into stages over several days, which keep their days by the stop each starts at: turn it round in the planner, where its stages are put on their days again.',
|
||||
};
|
||||
}
|
||||
return { points: [...saved].reverse().map(stopOf) };
|
||||
}
|
||||
if (isClosedLoop(saved)) return { refused: 'This hike already ends where it starts.' };
|
||||
const first = saved[0] as TourWaypoint;
|
||||
return { points: [...saved.map(stopOf), { lat: first.lat, lng: first.lng }] };
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import {
|
||||
McpController,
|
||||
Tool,
|
||||
TOOL_ANNOTATIONS_READONLY,
|
||||
TOOL_ANNOTATIONS_WRITE,
|
||||
errorResult,
|
||||
ok,
|
||||
type McpContext,
|
||||
} from '../../nest-mcp';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
import { TripAccessService } from '../trip-membership/trip-access.service';
|
||||
import { parseHorseRoutesQuery } from './tour-horse-routes.controller';
|
||||
import { TourHorseRoutesService } from './tour-horse-routes.service';
|
||||
import { TourObstacleReviewsService } from './tour-obstacle-reviews.service';
|
||||
import { TourPaddlePlanService } from './tour-paddle-plan.service';
|
||||
import { TourSkiService } from './tour-ski.service';
|
||||
import { TourTrailDetailsService } from './tour-trail-details.service';
|
||||
import { ToursService } from './tours.service';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
horseRoutesToolRequestSchema,
|
||||
idSchema,
|
||||
obstacleDecisionsToolRequestSchema,
|
||||
skiClassifyToolRequestSchema,
|
||||
tourObstacleReviewsRequestSchema,
|
||||
tourPaddlePlanRequestSchema,
|
||||
tourRefToolRequestSchema,
|
||||
tourSkiClassificationRequestSchema,
|
||||
type HorseRoute,
|
||||
type HorseRoutesToolRequest,
|
||||
type ObstacleDecisionsToolRequest,
|
||||
type SkiClassifyToolRequest,
|
||||
type TourDetailResponse,
|
||||
type TourPaddlePlanRequest,
|
||||
type TourRefToolRequest,
|
||||
} from '@trek/shared';
|
||||
|
||||
import type { ZodError } from 'zod';
|
||||
|
||||
const plan = tourPaddlePlanRequestSchema.shape;
|
||||
|
||||
/** A request the shared contract refuses, answered with every reason as REST's pipe gives them. */
|
||||
function refusedBy(error: ZodError) {
|
||||
return errorResult(error.issues.map((issue) => issue.message).join('; '));
|
||||
}
|
||||
|
||||
/** The tour of a write's answer without what only the planner draws: its line, its points, its checklist. */
|
||||
function tourOf(detail: TourDetailResponse) {
|
||||
return detail.tour;
|
||||
}
|
||||
|
||||
/** A horse route as the assistant reads it: each section's length, access and signposting, and where it starts and ends. */
|
||||
function routeInBrief(route: HorseRoute) {
|
||||
return {
|
||||
id: route.id,
|
||||
name: route.name,
|
||||
ref: route.ref,
|
||||
osm_url: route.osmUrl,
|
||||
continues: route.continues,
|
||||
sections: route.sections.map(({ geometry, ...section }) => ({
|
||||
...section,
|
||||
from: geometry[0] ?? null,
|
||||
to: geometry[geometry.length - 1] ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* P9: the writes and reads of the Tours tab that are a type's own, over MCP
|
||||
* with the services and checks of their REST routes: a ski track classified
|
||||
* again (PUT .../ski), the paddle plan of a recorded paddle track (PUT
|
||||
* .../paddle-plan) and the decisions about a paddle tour's obstacles (PUT
|
||||
* .../obstacle-reviews), an import planned over taken back to its recording
|
||||
* (POST .../restore-recorded), each with 'place_edit' on the trip; and two
|
||||
* reads anyone who sees the trip may make, the trail details along a saved
|
||||
* tour (the planner's trail grades) and the signposted horse routes in a
|
||||
* map view (GET .../horse-routes). A refusal of a service is a DomainError
|
||||
* the registry answers with the text REST sends.
|
||||
*/
|
||||
@McpController()
|
||||
export class TourSportWritesMcp {
|
||||
constructor(
|
||||
private readonly tours: ToursService,
|
||||
private readonly ski: TourSkiService,
|
||||
private readonly paddlePlans: TourPaddlePlanService,
|
||||
private readonly obstacleReviews: TourObstacleReviewsService,
|
||||
private readonly trailDetails: TourTrailDetailsService,
|
||||
private readonly horseRoutes: TourHorseRoutesService,
|
||||
private readonly access: TripAccessService,
|
||||
private readonly guards: McpToolGuardsService,
|
||||
// Read by the addon gate of every tool here.
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
/** Trip access, then 'place_edit', as the REST routes check them; null when both hold. */
|
||||
private async editRefusal(tripId: number, userId: number) {
|
||||
if (!(await this.access.findAccessible(tripId, userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('place_edit', tripId, userId))) return permissionDenied();
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The write, answered with a tour the trip does not hold as not found, as REST answers its 404. */
|
||||
private async orNotFound<T>(write: () => Promise<T>) {
|
||||
try {
|
||||
return ok(await write());
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof NotFoundException) return errorResult('Tour not found.');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'classify_ski_track',
|
||||
description:
|
||||
'Classify a ski and winter tour (tour_type ski, usually an imported track) again when its discipline is not the right one: ski_discipline alpine, snowboard, cross_country, touring for ski touring, snowshoe or winter_hike, and for cross_country only ski_technique (classic, skating, or unknown when the travellers do not know). Only the classification changes: the track, its measures, its days and where it came from stay as they are, and nothing is planned. Any other type is refused. expected_revision (tour.revision of get_tour_plan) refuses it once the tour changed since. The answer gives the tour as now saved and previous, the discipline and technique before. Needs place editing permission on the trip. Change it only after the traveller said yes.',
|
||||
inputSchema: { tripId: idSchema, ...skiClassifyToolRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'write' },
|
||||
})
|
||||
async classifySkiTrack(
|
||||
{ tripId, placeId, ...request }: { tripId: number } & SkiClassifyToolRequest,
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const refused = await this.editRefusal(tripId, ctx.userId);
|
||||
if (refused) return refused;
|
||||
const checked = tourSkiClassificationRequestSchema.safeParse(request);
|
||||
if (!checked.success) return refusedBy(checked.error);
|
||||
return await this.orNotFound(async () => {
|
||||
const before = await this.tours.getTour(String(tripId), String(placeId));
|
||||
const after = await this.ski.classify(String(tripId), String(placeId), checked.data);
|
||||
return {
|
||||
tour: tourOf(after),
|
||||
previous: {
|
||||
revision: before.tour.revision ?? null,
|
||||
ski_discipline: before.tour.ski_discipline ?? null,
|
||||
ski_technique: before.tour.ski_technique ?? null,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'set_paddle_plan',
|
||||
description:
|
||||
"Lay the paddle plan onto a recorded paddle track (tour_type kayak without control points of its own, an import), as the track's plan card does: the portages (from_m and to_m, metres from the start of the track, and extra_minutes for getting out and back in, null for TREK's starting value) and locks (at_m with wait_minutes and transit_minutes, null where not known) in track_marks, the paddle_speed_kmh (null for the boat's suggested speed) and the portage_speed_kmh (null for the starting value). The track is never redrawn; its moving time is reckoned anew from the plan. The plan given replaces the whole plan: send every portage and lock to keep. A planned paddle tour saves its portages and locks with its route in the planner and is refused here, and so is a mark past the end of the track. The answer gives the tour as now saved and previous, the plan before. Needs place editing permission on the trip. Change it only after the traveller said yes.",
|
||||
inputSchema: {
|
||||
tripId: idSchema,
|
||||
placeId: tourRefToolRequestSchema.shape.placeId,
|
||||
paddle_speed_kmh: plan.paddle_speed_kmh,
|
||||
portage_speed_kmh: plan.portage_speed_kmh,
|
||||
track_marks: plan.track_marks,
|
||||
expected_revision: plan.expected_revision,
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'write' },
|
||||
})
|
||||
async setPaddlePlan(
|
||||
{ tripId, placeId, ...request }: { tripId: number; placeId: number } & TourPaddlePlanRequest,
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const refused = await this.editRefusal(tripId, ctx.userId);
|
||||
if (refused) return refused;
|
||||
const checked = tourPaddlePlanRequestSchema.safeParse(request);
|
||||
if (!checked.success) return refusedBy(checked.error);
|
||||
return await this.orNotFound(async () => {
|
||||
const before = await this.tours.getTour(String(tripId), String(placeId));
|
||||
const after = await this.paddlePlans.update(String(tripId), String(placeId), checked.data);
|
||||
return {
|
||||
tour: tourOf(after),
|
||||
previous: {
|
||||
revision: before.tour.revision ?? null,
|
||||
paddle_speed_kmh: before.tour.paddle_speed_kmh ?? null,
|
||||
portage_speed_kmh: before.tour.portage_speed_kmh ?? null,
|
||||
track_marks: before.tour.track_marks ?? { portages: [], locks: [] },
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'decide_paddle_obstacles',
|
||||
description:
|
||||
"Record what the paddlers decided about the obstacles of a paddle tour (tour_type kayak), as its obstacle checklist does: read get_tour_plan first and take each entry's key and basis from its obstacle_check, since a decision is bound to the route and the data the travellers saw. op decide stores decision pass, portage, reroute or not_affected with that basis (and a note), note changes the note only, clear takes the decision back, keep keeps a decision about an object the data no longer holds; each obstacle at most once. A decision is the paddlers' own planning, never a safety or legal clearance, and a ban in the data counts whatever was decided. The answer is the checklist as it now stands (obstacle_check) with the tour. Refused while the obstacle data cannot be read. Needs place editing permission on the trip. Decide only what the traveller decided.",
|
||||
inputSchema: { tripId: idSchema, ...obstacleDecisionsToolRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'write' },
|
||||
})
|
||||
async decidePaddleObstacles(
|
||||
{ tripId, placeId, ops }: { tripId: number } & ObstacleDecisionsToolRequest,
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const refused = await this.editRefusal(tripId, ctx.userId);
|
||||
if (refused) return refused;
|
||||
const checked = tourObstacleReviewsRequestSchema.safeParse({ ops });
|
||||
if (!checked.success) return refusedBy(checked.error);
|
||||
return await this.orNotFound(async () => {
|
||||
const after = await this.obstacleReviews.save(String(tripId), String(placeId), checked.data, ctx.userId);
|
||||
return { tour: tourOf(after), obstacle_check: after.obstacle_check ?? null };
|
||||
});
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'restore_recorded_track',
|
||||
description:
|
||||
'Take an import that was planned over in place (tour.has_recorded_track true) back to the track it was recorded with: its line, the distance and heights read from it and the grade its source gave, without control points; the planning is dropped and cannot be brought back here (copy_tour it first to keep it). The tour keeps its id, days and notes; a split into stages ends, so a day that held a stage no longer does. A tour without a recorded track to go back to is refused. The answer is the tour as now saved. Needs place editing permission on the trip. Do it only after the traveller said yes.',
|
||||
inputSchema: { tripId: idSchema, ...tourRefToolRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'write' },
|
||||
})
|
||||
async restoreRecordedTrack({ tripId, placeId }: { tripId: number } & TourRefToolRequest, ctx: McpContext) {
|
||||
const refused = await this.editRefusal(tripId, ctx.userId);
|
||||
if (refused) return refused;
|
||||
return await this.orNotFound(async () => ({
|
||||
tour: tourOf(await this.tours.restoreRecordedTrack(String(tripId), String(placeId))),
|
||||
}));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_tour_trail_details',
|
||||
description:
|
||||
"Read what OpenStreetMap records for the ways a saved tour runs on, along its saved line, as the planner's trail details show it: sac (the SAC hiking grades: highest from 1 for T1 to 6 for T6, the graded stretches in metres along the line, unknown_share), mtb (the mountain bike scale mtb:scale, its hardest value and the metres of each), surface and highway (the metres and share of each value as OpenStreetMap writes it, such as asphalt, gravel, path or track), unknown_share (the part whose way carries none of these or could not be asked about) and length_m. A way without a value is unknown, never easy, paved or rideable; ways_unanswered counts ways the data could not be asked about this time, truncated says a very long line was only read in part. A tour saved with its points only has no line yet and is refused. Reading changes nothing.",
|
||||
inputSchema: { tripId: idSchema, ...tourRefToolRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
})
|
||||
async getTourTrailDetails({ tripId, placeId }: { tripId: number } & TourRefToolRequest, ctx: McpContext) {
|
||||
// A read, like the REST route: seeing the trip is enough.
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
return await this.orNotFound(() => this.trailDetails.details(ctx.userId, tripId, placeId));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'find_horse_routes',
|
||||
description:
|
||||
'Find the signposted horse routes OpenStreetMap knows inside a map view, as the horse tour planner shows them: each route (id relation/<n>, name, ref, osm_url, continues when it runs on past the view) with its sections, the stretches a ride can take whole (number, length_m, from and to as [lat, lng], access: the metres of each horse=* value its ways carry, unknown where untagged, and signposted: with or against the direction it is listed in, null both ways). bbox is the view as south,west,north,east; a view too large is refused with the advice to zoom in. Being on a horse route is no permission to ride it. truncated says the view held more than one answer carries. Reading changes nothing; nothing about the trip goes out.',
|
||||
inputSchema: { tripId: idSchema, ...horseRoutesToolRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
})
|
||||
async findHorseRoutes({ tripId, bbox }: { tripId: number } & HorseRoutesToolRequest, ctx: McpContext) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
const found = await this.horseRoutes.list(parseHorseRoutesQuery({ bbox }).bbox);
|
||||
return ok({ ...found, routes: found.routes.map(routeInBrief) });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* BIK-05, MAP-03 and MAP-04 for a saved tour read over MCP: what
|
||||
* OpenStreetMap records for the ways under its line, summed along it. The
|
||||
* ways come from the router's description of the saved line and their tags
|
||||
* from the trail grades the planner asks for; this half only lays them onto
|
||||
* the line. A way without a value is unknown, never easy or paved.
|
||||
*/
|
||||
import type { TracedWay } from '../roadtrip/roadtrip-router.service';
|
||||
import { gradesAlong } from './hike-routing.helpers';
|
||||
import { sacStepOf, type HikeTrailGrades, type SacStep, type TourTrailGrade } from '@trek/shared';
|
||||
|
||||
/** How many metres of the line one written value covers, and its share of the whole line. */
|
||||
interface ValueShare {
|
||||
value: string;
|
||||
metres: number;
|
||||
share: number;
|
||||
}
|
||||
|
||||
/** Everything the trail details answer, worked out from the ways and their tags. */
|
||||
export interface TrailDetails {
|
||||
length_m: number;
|
||||
/** The SAC grades along the line, as a proposed hike carries them. */
|
||||
sac: HikeTrailGrades;
|
||||
/** The mountain bike scale (mtb:scale), the hardest step found and the metres of each; unknown where untagged. */
|
||||
mtb: { highest: string | null; values: ValueShare[] };
|
||||
surface: ValueShare[];
|
||||
highway: ValueShare[];
|
||||
/** The part of the line whose way has none of these tags or could not be asked about, 0 to 1. */
|
||||
unknown_share: number;
|
||||
}
|
||||
|
||||
const roundShare = (value: number) => Math.min(1, Math.round(value * 1000) / 1000);
|
||||
|
||||
/** The metres each value covers, the most first, scaled onto the saved length like the SAC stretches. */
|
||||
function sharesOf(
|
||||
ways: readonly TracedWay[],
|
||||
valueOf: (grade: TourTrailGrade) => string | null,
|
||||
grades: ReadonlyMap<number, TourTrailGrade>,
|
||||
scale: number,
|
||||
total: number,
|
||||
): ValueShare[] {
|
||||
const metres = new Map<string, number>();
|
||||
for (const way of ways) {
|
||||
const grade = way.wayId === null ? undefined : grades.get(way.wayId);
|
||||
const value = grade ? valueOf(grade) : null;
|
||||
if (value) metres.set(value, (metres.get(value) ?? 0) + way.metres * scale);
|
||||
}
|
||||
return [...metres]
|
||||
.map(([value, along]) => ({ value, metres: Math.round(along), share: total > 0 ? roundShare(along / total) : 0 }))
|
||||
.sort((a, b) => b.metres - a.metres || a.value.localeCompare(b.value));
|
||||
}
|
||||
|
||||
/** The harder of two mtb:scale values, by their step (a + or - only between equal steps). */
|
||||
function harderMtb(a: string | null, b: string): string {
|
||||
if (a === null) return b;
|
||||
const step = (value: string) => Number(value[0]) * 3 + (value.endsWith('+') ? 2 : value.endsWith('-') ? 0 : 1);
|
||||
return step(b) > step(a) ? b : a;
|
||||
}
|
||||
|
||||
/** The ways under the saved line with their tags, laid onto its length. */
|
||||
export function trailDetailsOf(
|
||||
ways: readonly TracedWay[],
|
||||
grades: ReadonlyMap<number, TourTrailGrade>,
|
||||
lengthM: number,
|
||||
): TrailDetails {
|
||||
const traced = ways.reduce((sum, way) => sum + way.metres, 0);
|
||||
const scale = traced > 0 && lengthM > 0 ? lengthM / traced : 1;
|
||||
const total = traced * scale;
|
||||
const steps = new Map<number, SacStep>();
|
||||
for (const [id, grade] of grades) if (grade.sac_scale) steps.set(id, sacStepOf(grade.sac_scale));
|
||||
const mtb = sharesOf(ways, (grade) => grade.mtb_scale, grades, scale, total);
|
||||
const known = ways
|
||||
.filter((way) => way.wayId !== null && grades.has(way.wayId))
|
||||
.reduce((sum, way) => sum + way.metres * scale, 0);
|
||||
return {
|
||||
length_m: Math.round(total),
|
||||
sac: gradesAlong(ways, steps, lengthM),
|
||||
mtb: { highest: mtb.reduce<string | null>((hardest, { value }) => harderMtb(hardest, value), null), values: mtb },
|
||||
surface: sharesOf(ways, (grade) => grade.surface, grades, scale, total),
|
||||
highway: sharesOf(ways, (grade) => grade.highway, grades, scale, total),
|
||||
unknown_share: total > 0 ? roundShare(1 - known / total) : 1,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { logWarn } from '../audit/audit-log.logger';
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { RoadtripRouterService, type TracedWay } from '../roadtrip/roadtrip-router.service';
|
||||
import { gradableWayIds } from './hike-routing.helpers';
|
||||
import { trailDetailsOf, type TrailDetails } from './tour-trail-details.helpers';
|
||||
import { TourTrailGradeService } from './tour-trail-grade.service';
|
||||
import { computeTourMetrics } from './tours.helpers';
|
||||
import { ToursService } from './tours.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { TOUR_TRAIL_GRADE_MAX_WAYS, type TourTrailGrade } from '@trek/shared';
|
||||
|
||||
/** The refusal of a tour without a saved line to read the ways under. */
|
||||
const TOUR_TRAIL_NO_LINE = 'tour_trail_no_line';
|
||||
/** The refusal while the router or the OpenStreetMap data cannot be asked. */
|
||||
const TOUR_TRAIL_UNAVAILABLE = 'tour_trail_unavailable';
|
||||
|
||||
/** What get_tour_trail_details answers: the tour it read, and what its ways carry. */
|
||||
interface TourTrailDetailsAnswer extends TrailDetails {
|
||||
place_id: number;
|
||||
tour_type: string;
|
||||
/** Ways the data could not be asked about this time: their values are unknown, not absent. */
|
||||
ways_unanswered: number;
|
||||
/** The line ran over more ways than one question names; the rest were not read. */
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
function reasonOf(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* BIK-05, MAP-03 and MAP-04 for a saved tour: the ways under its saved line,
|
||||
* as the router describes it (the planner's own description of a draft), and
|
||||
* what OpenStreetMap records for them, through the trail grades the planner's
|
||||
* POST /api/trips/:tripId/tours/trail-grades reads. A read: nothing about
|
||||
* the tour changes. A tour saved with its points only has no line to read.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TourTrailDetailsService {
|
||||
constructor(
|
||||
private readonly tours: ToursService,
|
||||
private readonly router: RoadtripRouterService,
|
||||
private readonly trailGrades: TourTrailGradeService,
|
||||
) {}
|
||||
|
||||
async details(userId: number, tripId: number, placeId: number): Promise<TourTrailDetailsAnswer> {
|
||||
const { tour } = await this.tours.getTour(String(tripId), String(placeId));
|
||||
const line = await this.tours.savedLine(tripId, placeId);
|
||||
if (line.length < 2) {
|
||||
throw new DomainError(422, 'This tour has no saved line yet: open it in the planner to plan its way first.', {
|
||||
code: TOUR_TRAIL_NO_LINE,
|
||||
});
|
||||
}
|
||||
const flat = line.map(([lat, lng]): [number, number] => [lat, lng]);
|
||||
let ways: TracedWay[];
|
||||
try {
|
||||
ways = await this.router.wayIds(userId, flat);
|
||||
} catch (err: unknown) {
|
||||
logWarn(`[Tours] no ways under tour ${placeId}: ${reasonOf(err)}`);
|
||||
throw new DomainError(503, 'The ways under this tour cannot be read right now: ask again later.', {
|
||||
code: TOUR_TRAIL_UNAVAILABLE,
|
||||
});
|
||||
}
|
||||
const ids = gradableWayIds(ways, TOUR_TRAIL_GRADE_MAX_WAYS);
|
||||
const answer = ids.length > 0 ? await this.trailGrades.grades({ way_ids: ids }) : { ways: [], unanswered: [] };
|
||||
const grades = new Map<number, TourTrailGrade>(answer.ways.map((way) => [way.id, way]));
|
||||
const lengthM = computeTourMetrics(line).distanceKm * 1000;
|
||||
const distinct = new Set(ways.flatMap((way) => (way.wayId === null ? [] : [way.wayId])));
|
||||
return {
|
||||
place_id: tour.place_id,
|
||||
tour_type: tour.tour_type,
|
||||
...trailDetailsOf(ways, grades, lengthM),
|
||||
ways_unanswered: answer.unanswered.length,
|
||||
truncated: distinct.size > ids.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -49,8 +49,10 @@ import { TourChangeSaveService } from './tour-change-save.service';
|
||||
import { TourChangeUndoMcp } from './tour-change-undo.mcp';
|
||||
import { TourChangeUndoService } from './tour-change-undo.service';
|
||||
import { TourChangeMcp } from './tour-change.mcp';
|
||||
import { TourDetailsService } from './tour-details.service';
|
||||
import { TourDraftsController } from './tour-drafts.controller';
|
||||
import { TourDraftsService } from './tour-drafts.service';
|
||||
import { TourEditMcp } from './tour-edit.mcp';
|
||||
import { TourGpxExportService } from './tour-gpx-export.service';
|
||||
import { TourHorseRoutesController } from './tour-horse-routes.controller';
|
||||
import { TourHorseRoutesService } from './tour-horse-routes.service';
|
||||
@@ -68,6 +70,8 @@ import { TourPoiSearchController } from './tour-poi-search.controller';
|
||||
import { TourPoiSearchService } from './tour-poi-search.service';
|
||||
import { TourSkiController } from './tour-ski.controller';
|
||||
import { TourSkiService } from './tour-ski.service';
|
||||
import { TourSportWritesMcp } from './tour-sport-writes.mcp';
|
||||
import { TourTrailDetailsService } from './tour-trail-details.service';
|
||||
import { TourTrailGradeController } from './tour-trail-grade.controller';
|
||||
import { TourTrailGradeService } from './tour-trail-grade.service';
|
||||
import { TourVisitsController } from './tour-visits.controller';
|
||||
@@ -206,6 +210,9 @@ import { Module } from '@nestjs/common';
|
||||
TourPoiSearchService,
|
||||
TourSkiService,
|
||||
TourTrailGradeService,
|
||||
// P8, P9: a tour's words, times and colour, and the trail details along a saved one.
|
||||
TourDetailsService,
|
||||
TourTrailDetailsService,
|
||||
TourVisitsService,
|
||||
TourWalkImportService,
|
||||
CityWalkFootWayService,
|
||||
@@ -233,6 +240,8 @@ import { Module } from '@nestjs/common';
|
||||
TourActivityStatsMcp,
|
||||
CollectionToursMcp,
|
||||
TourPackingMcp,
|
||||
TourEditMcp,
|
||||
TourSportWritesMcp,
|
||||
],
|
||||
})
|
||||
export class ToursModule {}
|
||||
|
||||
@@ -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,274 @@
|
||||
/**
|
||||
* Tours e2e for the assistant's edits of a saved tour (P8, P9): a hike saved
|
||||
* over REST, its words and breaks, its colour and a copy changed over MCP and
|
||||
* read back over REST; a city walk's station edited through a confirmed
|
||||
* change without the router; the writes refused without 'place_edit'. Through
|
||||
* the real JwtAuthGuard, AddonGuard and TripAccessGuard against a migrated and
|
||||
* seeded temp SQLite db; the MCP tools run in a request context as the
|
||||
* transport runs them, and the broadcast goes to a FakeRealtimeService.
|
||||
*
|
||||
* Fixtures: user 1 owns trip 5, user 2 is a member of it whom the permission
|
||||
* spy denies 'place_edit'.
|
||||
*/
|
||||
import { Places } from '../../src/db/entities/Places.entity';
|
||||
import { Trips } from '../../src/db/entities/Trips.entity';
|
||||
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../src/mcp/nest-mcp-policy';
|
||||
import { createTestRegistry, type McpContext } from '../../src/nest-mcp';
|
||||
import { AssignmentsModule } from '../../src/nest/assignments/assignments.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
import { withRequestContext } from '../../src/nest/database/request-context';
|
||||
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
|
||||
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
||||
import { PlacesModule } from '../../src/nest/places/places.module';
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
|
||||
import { RoadtripRouterService } from '../../src/nest/roadtrip/roadtrip-router.service';
|
||||
import { proposalIdOf } from '../../src/nest/tours/tour-change-proposal';
|
||||
import { TourChangeMcp } from '../../src/nest/tours/tour-change.mcp';
|
||||
import { TourEditMcp } from '../../src/nest/tours/tour-edit.mcp';
|
||||
import { TourSportWritesMcp } from '../../src/nest/tours/tour-sport-writes.mcp';
|
||||
import { ToursModule } from '../../src/nest/tours/tours.module';
|
||||
import { createSnapshotTestDb } from '../helpers/db-mock';
|
||||
import { deleteRows, insertRows } from '../helpers/factories/rows';
|
||||
import { setAddonEnabled } from '../helpers/factories/settings';
|
||||
import { addTripMember } from '../helpers/factories/trips';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { FakeRealtimeService } from '../helpers/fake-realtime';
|
||||
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
import { sessionCookie } from './harness';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { TourChange } from '@trek/shared';
|
||||
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
|
||||
|
||||
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
|
||||
LOG_LEVEL: 'error',
|
||||
logInfo: vi.fn(),
|
||||
logDebug: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
const { journeyHooks } = vi.hoisted(() => ({
|
||||
journeyHooks: {
|
||||
reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined),
|
||||
onPlaceCreated: vi.fn().mockResolvedValue(undefined),
|
||||
onPlaceUpdated: vi.fn().mockResolvedValue(undefined),
|
||||
onPlaceDeleted: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
const realtime = new FakeRealtimeService();
|
||||
|
||||
let orm: TestOrm;
|
||||
let checkPermission: MockInstance;
|
||||
let routeOnFoot: MockInstance;
|
||||
|
||||
const OWNER = 1;
|
||||
const VIEWER = 2;
|
||||
|
||||
const LINE = [
|
||||
[47, 11, 600],
|
||||
[47.01, 11.01, 700],
|
||||
[47.02, 11.02, 650],
|
||||
];
|
||||
|
||||
const hikeBody = () => ({
|
||||
name: 'Ridge',
|
||||
route_geometry: LINE,
|
||||
waypoints: [
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 1 },
|
||||
],
|
||||
max_hiking_difficulty: 2,
|
||||
duration_seconds: 3600,
|
||||
});
|
||||
|
||||
const walkBody = () => ({
|
||||
name: 'Old town',
|
||||
tour_type: 'walk',
|
||||
route_geometry: [
|
||||
[48.1, 11.5, 520],
|
||||
[48.108, 11.5, 525],
|
||||
],
|
||||
waypoints: [
|
||||
{ lat: 48.1, lng: 11.5, role: 'start', sequence: 0, key: 'walk-gate', name: 'Gate' },
|
||||
{ lat: 48.108, lng: 11.5, role: 'end', sequence: 1, key: 'walk-park', name: 'Park' },
|
||||
],
|
||||
duration_seconds: 900,
|
||||
});
|
||||
|
||||
type ToolResult = { content: Array<{ type: string; text: string }>; isError?: boolean };
|
||||
|
||||
describe('Tours e2e: the assistant edits a saved tour (P8, P9)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [
|
||||
await TestUnitOfWorkModule.forRoot(db),
|
||||
await createTestMikroOrmModule(db),
|
||||
RealtimeModule,
|
||||
ToursModule,
|
||||
PlacesModule,
|
||||
AssignmentsModule,
|
||||
],
|
||||
})
|
||||
.overrideProvider(RealtimeService)
|
||||
.useValue(realtime)
|
||||
.overrideProvider(JourneyDomainService)
|
||||
.useValue(journeyHooks)
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalPipes(new ZodValidationPipe());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
orm = await createTestOrm(db);
|
||||
await makeUser(orm, { id: 1, username: 'owner', email: 'owner@example.test', role: 'user', password_version: 0 });
|
||||
await makeUser(orm, { id: 2, username: 'viewer', email: 'viewer@example.test', role: 'user', password_version: 0 });
|
||||
await insertRows(orm, Trips, [{ id: 5, user: 1, title: 'Alps' }]);
|
||||
await addTripMember(orm, 5, 2);
|
||||
app = await build();
|
||||
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
|
||||
routeOnFoot = vi.spyOn(app.get(RoadtripRouterService), 'route');
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
checkPermission.mockImplementation(
|
||||
(action: string, _role: string, _owner: number, userId: number) =>
|
||||
!(action === 'place_edit' && userId === VIEWER),
|
||||
);
|
||||
routeOnFoot.mockReset().mockRejectedValue(new Error('no routing host in this test'));
|
||||
await setAddonEnabled(orm, 'tours', true);
|
||||
realtime.broadcastMock.mockClear();
|
||||
await deleteRows(orm, Places);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await orm.close();
|
||||
});
|
||||
|
||||
const cookie = (userId = OWNER) => sessionCookie(userId);
|
||||
|
||||
/** A tool run as the transport runs it: in a request context of its own. */
|
||||
const run = (call: () => Promise<ToolResult>) =>
|
||||
withRequestContext(app.get(MikroORM), async () => {
|
||||
const result = await call();
|
||||
const text = result.content[0]?.text ?? '';
|
||||
return result.isError ? { error: text } : JSON.parse(text);
|
||||
});
|
||||
const as = (userId: number) => ({ userId }) as McpContext;
|
||||
|
||||
const saveTour = async (body: Record<string, unknown>) => {
|
||||
const res = await request(server).post('/api/trips/5/tours').set('Cookie', cookie()).send(body);
|
||||
expect(res.status).toBe(201);
|
||||
return res.body.tour.place_id as number;
|
||||
};
|
||||
const tourOf = async (placeId: number) => {
|
||||
const res = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie());
|
||||
expect(res.status).toBe(200);
|
||||
return res.body;
|
||||
};
|
||||
|
||||
it('registers both controllers under the trip access policy', () => {
|
||||
expect(() =>
|
||||
createTestRegistry([app.get(TourEditMcp), app.get(TourSportWritesMcp)], {
|
||||
accessPolicy: trekMcpAccessPolicy,
|
||||
validateAccess: trekMcpValidateAccess,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('the words and breaks changed over MCP are what the planner reads, and its stale save is refused', async () => {
|
||||
const placeId = await saveTour(hikeBody());
|
||||
const changed = await run(() =>
|
||||
app.get(TourEditMcp).updateTourDetails(
|
||||
{
|
||||
tripId: 5,
|
||||
placeId,
|
||||
expected_revision: 1,
|
||||
website: 'https://ridge.example/',
|
||||
break_additional_minutes: 40,
|
||||
},
|
||||
as(OWNER),
|
||||
),
|
||||
);
|
||||
expect(changed.tour).toMatchObject({ revision: 2, website: 'https://ridge.example/' });
|
||||
const detail = await tourOf(placeId);
|
||||
expect(detail.tour).toMatchObject({ website: 'https://ridge.example/', break_additional_minutes: 40, revision: 2 });
|
||||
expect(detail.waypoints).toHaveLength(2);
|
||||
const stale = await request(server)
|
||||
.put(`/api/trips/5/tours/${placeId}`)
|
||||
.set('Cookie', cookie())
|
||||
.send({ ...hikeBody(), expected_revision: 1 });
|
||||
expect(stale.status).toBe(409);
|
||||
});
|
||||
|
||||
it('a colour set and a copy made over MCP show over REST', async () => {
|
||||
const placeId = await saveTour(hikeBody());
|
||||
await run(() => app.get(TourEditMcp).setTourRouteColor({ tripId: 5, placeId, route_color: '#e11d48' }, as(OWNER)));
|
||||
const place = await request(server).get(`/api/trips/5/places/${placeId}`).set('Cookie', cookie());
|
||||
expect(place.body.place.route_color).toBe('#e11d48');
|
||||
const copy = await run(() =>
|
||||
app.get(TourEditMcp).copyTour({ tripId: 5, placeId, name: 'Ridge (short)' }, as(OWNER)),
|
||||
);
|
||||
const list = await request(server).get('/api/trips/5/tours').set('Cookie', cookie());
|
||||
expect(list.body.tours.map((tour: { name: string }) => tour.name).sort()).toEqual(['Ridge', 'Ridge (short)']);
|
||||
expect(copy.tour.derived_from_place_id).toBe(placeId);
|
||||
});
|
||||
|
||||
it("a station's stay edited through a confirmed change keeps the walk's way without the router", async () => {
|
||||
const placeId = await saveTour(walkBody());
|
||||
const changes: TourChange[] = [{ op: 'edit', key: 'walk-park', stay_minutes: 30, importance: 'must' }];
|
||||
const saved = await run(() =>
|
||||
app.get(TourChangeMcp).saveTourChange(
|
||||
{
|
||||
tripId: 5,
|
||||
placeId,
|
||||
changes,
|
||||
expected_revision: 1,
|
||||
proposal_id: proposalIdOf({ tripId: 5, placeId, revision: 1, changes }),
|
||||
confirmed: true,
|
||||
},
|
||||
as(OWNER),
|
||||
),
|
||||
);
|
||||
expect(saved).toMatchObject({ saved: true, tour: { route_pending: false, walking_minutes: 15 } });
|
||||
expect(routeOnFoot).not.toHaveBeenCalled();
|
||||
const detail = await tourOf(placeId);
|
||||
expect(detail.waypoints[1]).toMatchObject({ key: 'walk-park', stay_minutes: 30, importance: 'must' });
|
||||
});
|
||||
|
||||
it('a member without place_edit changes nothing, over MCP as over REST', async () => {
|
||||
const placeId = await saveTour(hikeBody());
|
||||
const refused = await run(() =>
|
||||
app.get(TourEditMcp).updateTourDetails({ tripId: 5, placeId, expected_revision: 1, name: 'Mine' }, as(VIEWER)),
|
||||
);
|
||||
expect(refused).toHaveProperty('error');
|
||||
const ski = await run(() =>
|
||||
app.get(TourSportWritesMcp).classifySkiTrack({ tripId: 5, placeId, ski_discipline: 'snowshoe' }, as(VIEWER)),
|
||||
);
|
||||
expect(ski).toHaveProperty('error');
|
||||
const rest = await request(server)
|
||||
.put(`/api/trips/5/tours/${placeId}/ski`)
|
||||
.set('Cookie', cookie(VIEWER))
|
||||
.send({ ski_discipline: 'snowshoe' });
|
||||
expect(rest.status).toBe(403);
|
||||
expect((await tourOf(placeId)).tour).toMatchObject({ name: 'Ridge', revision: 1 });
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,10 @@ import { bookings } from './bookings.cases';
|
||||
import { collabTrip } from './collab-trip.cases';
|
||||
import { money } from './money.cases';
|
||||
import { plan } from './plan.cases';
|
||||
import { toursEdit } from './tours-edit.cases';
|
||||
import { toursRoadtrip } from './tours-roadtrip.cases';
|
||||
import type { CaseGroup } from './types';
|
||||
import { user } from './user.cases';
|
||||
|
||||
/** Every group, imported statically, so the registry test sees each row without running a case. */
|
||||
export const ALL_GROUPS: readonly CaseGroup[] = [plan, bookings, toursRoadtrip, money, collabTrip, user];
|
||||
export const ALL_GROUPS: readonly CaseGroup[] = [plan, bookings, toursRoadtrip, toursEdit, money, collabTrip, user];
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Places } from '../../../src/db/entities/Places.entity';
|
||||
import { Tours } from '../../../src/db/entities/Tours.entity';
|
||||
import { updateRows } from '../../helpers/factories/rows';
|
||||
import { addTourWaypoints } from '../../helpers/factories/tours';
|
||||
import type { CaseGroup, Scene } from './types';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const ormOf = (app: INestApplication) => app.get(MikroORM);
|
||||
|
||||
/** A hike's line with its heights, as the planner saves one. */
|
||||
const LINE: [number, number, number][] = [
|
||||
[47, 11, 600],
|
||||
[47.01, 11.01, 700],
|
||||
[47.02, 11.02, 650],
|
||||
];
|
||||
|
||||
/** The planner's save of the scene's hike, for the edit route. */
|
||||
const hikeBody = (extra: Record<string, unknown>) => ({
|
||||
name: 'Ridge',
|
||||
route_geometry: LINE,
|
||||
waypoints: [
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 1 },
|
||||
],
|
||||
max_hiking_difficulty: 2,
|
||||
duration_seconds: 3600,
|
||||
expected_revision: 1,
|
||||
...extra,
|
||||
});
|
||||
|
||||
/** The scene's hike with its line and its start and end, as the planner saves one. */
|
||||
async function planHike(s: Scene, app: INestApplication): Promise<void> {
|
||||
const orm = ormOf(app);
|
||||
await updateRows(orm, Places, { id: s.tourPlace.id }, { route_geometry: JSON.stringify(LINE) });
|
||||
await addTourWaypoints(orm, s.tourPlace.id, [
|
||||
{ lat: 47, lng: 11 },
|
||||
{ lat: 47.02, lng: 11.02 },
|
||||
]);
|
||||
}
|
||||
|
||||
/** The scene's tour as an imported ski track, classified alpine. */
|
||||
async function skiTrack(s: Scene, app: INestApplication): Promise<void> {
|
||||
const orm = ormOf(app);
|
||||
await updateRows(orm, Places, { id: s.tourPlace.id }, { route_geometry: JSON.stringify(LINE) });
|
||||
await updateRows(
|
||||
orm,
|
||||
Tours,
|
||||
{ place: s.tourPlace.id },
|
||||
{ tourTypeRef: 'ski', origin: 'gpx', ski_discipline: 'alpine' },
|
||||
);
|
||||
}
|
||||
|
||||
/** The scene's tour as a recorded paddle track; with `line` false it has no line yet. */
|
||||
function paddleTrack(line: boolean) {
|
||||
return async (s: Scene, app: INestApplication): Promise<void> => {
|
||||
const orm = ormOf(app);
|
||||
if (line) await updateRows(orm, Places, { id: s.tourPlace.id }, { route_geometry: JSON.stringify(LINE) });
|
||||
await updateRows(
|
||||
orm,
|
||||
Tours,
|
||||
{ place: s.tourPlace.id },
|
||||
{ tourTypeRef: 'kayak', origin: 'gpx', boat_type: 'kayak', water_context: 'river', max_hiking_difficulty: null },
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** The scene's hike planned over an import: it still keeps the track it was recorded with. */
|
||||
async function plannedImport(s: Scene, app: INestApplication): Promise<void> {
|
||||
await planHike(s, app);
|
||||
await updateRows(ormOf(app), Tours, { place: s.tourPlace.id }, { recorded_route_geometry: JSON.stringify(LINE) });
|
||||
}
|
||||
|
||||
const tourPath = (s: Scene) => `/api/trips/${s.trip.id}/tours/${s.tourPlace.id}`;
|
||||
const MARKS = { portages: [{ from_m: 100, to_m: 300, extra_minutes: null }], locks: [] };
|
||||
const CLEAR = [{ key: 'node/12', op: 'clear', decision: null, note: null, basis: null }];
|
||||
|
||||
/**
|
||||
* P8 and P9: the edits of a saved tour besides its route (its words and
|
||||
* times, the colour of its line, a copy) and the writes a ski or paddle tour
|
||||
* has of its own, each with the REST route the Tours tab saves it through.
|
||||
*/
|
||||
export const toursEdit: CaseGroup = {
|
||||
name: 'tours-edit',
|
||||
prefix: 'BCP-TEDIT',
|
||||
cases: [
|
||||
{
|
||||
tool: 'update_tour_details',
|
||||
needs: ['tour'],
|
||||
setup: planHike,
|
||||
args: (s) => ({
|
||||
tripId: s.trip.id,
|
||||
placeId: s.tourPlace.id,
|
||||
expected_revision: 1,
|
||||
website: 'https://a.example/',
|
||||
}),
|
||||
// The planner saves the tour's words and times with its route.
|
||||
rest: { method: 'put', path: tourPath, body: () => hikeBody({ website: 'https://a.example/' }) },
|
||||
expect: ['tours:changed', 'place:updated'],
|
||||
},
|
||||
{
|
||||
tool: 'set_tour_route_color',
|
||||
needs: ['tour'],
|
||||
args: (s) => ({ tripId: s.trip.id, placeId: s.tourPlace.id, route_color: '#e11d48' }),
|
||||
// The tour detail's colour picker writes the place.
|
||||
rest: {
|
||||
method: 'put',
|
||||
path: (s) => `/api/trips/${s.trip.id}/places/${s.tourPlace.id}`,
|
||||
body: () => ({ route_color: '#e11d48' }),
|
||||
},
|
||||
expect: ['place:updated'],
|
||||
},
|
||||
{
|
||||
tool: 'copy_tour',
|
||||
needs: ['tour'],
|
||||
setup: planHike,
|
||||
args: (s) => ({ tripId: s.trip.id, placeId: s.tourPlace.id, name: 'Ridge (short)' }),
|
||||
rest: { method: 'post', path: (s) => `${tourPath(s)}/copy`, body: () => ({ name: 'Ridge (short)' }) },
|
||||
expect: ['tours:changed', 'place:created'],
|
||||
},
|
||||
{
|
||||
tool: 'classify_ski_track',
|
||||
needs: ['tour'],
|
||||
setup: skiTrack,
|
||||
args: (s) => ({ tripId: s.trip.id, placeId: s.tourPlace.id, ski_discipline: 'snowshoe' }),
|
||||
rest: { method: 'put', path: (s) => `${tourPath(s)}/ski`, body: () => ({ ski_discipline: 'snowshoe' }) },
|
||||
expect: ['tours:changed'],
|
||||
},
|
||||
{
|
||||
tool: 'set_paddle_plan',
|
||||
needs: ['tour'],
|
||||
setup: paddleTrack(true),
|
||||
args: (s) => ({
|
||||
tripId: s.trip.id,
|
||||
placeId: s.tourPlace.id,
|
||||
paddle_speed_kmh: 5,
|
||||
portage_speed_kmh: null,
|
||||
track_marks: MARKS,
|
||||
}),
|
||||
rest: {
|
||||
method: 'put',
|
||||
path: (s) => `${tourPath(s)}/paddle-plan`,
|
||||
body: () => ({ paddle_speed_kmh: 5, portage_speed_kmh: null, track_marks: MARKS }),
|
||||
},
|
||||
expect: ['tours:changed'],
|
||||
},
|
||||
{
|
||||
// A track without a line has nothing to read against the obstacle data, so no data is asked for.
|
||||
tool: 'decide_paddle_obstacles',
|
||||
needs: ['tour'],
|
||||
setup: paddleTrack(false),
|
||||
args: (s) => ({ tripId: s.trip.id, placeId: s.tourPlace.id, ops: CLEAR }),
|
||||
rest: { method: 'put', path: (s) => `${tourPath(s)}/obstacle-reviews`, body: () => ({ ops: CLEAR }) },
|
||||
expect: ['tours:changed'],
|
||||
},
|
||||
{
|
||||
tool: 'restore_recorded_track',
|
||||
needs: ['tour'],
|
||||
setup: plannedImport,
|
||||
args: (s) => ({ tripId: s.trip.id, placeId: s.tourPlace.id }),
|
||||
rest: { method: 'post', path: (s) => `${tourPath(s)}/restore-recorded` },
|
||||
expect: ['tours:changed', 'place:updated'],
|
||||
},
|
||||
],
|
||||
exempt: [],
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { runGroup } from './run-group';
|
||||
import { toursEdit } from './tours-edit.cases';
|
||||
|
||||
runGroup(toursEdit);
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* ASTCARD-TOUR-080 to ASTCARD-TOUR-088
|
||||
*
|
||||
* P8 and P9 in the chat: a saved tour's words and times, the colour of its
|
||||
* line, a copy, a hike turned round or taken back to its start, and the
|
||||
* writes a ski or paddle tour has of its own. Each card names the tour; where
|
||||
* the answer keeps what was there before, the way back writes it again while
|
||||
* the tour is still at the revision the change raised it to.
|
||||
*/
|
||||
import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context';
|
||||
import { unchangedSinceApplied } from '../../../../../../src/nest/assistant/assistant-undo.helpers';
|
||||
import type { PreviewContext } from '../../../../../../src/nest/assistant/cards/card-types';
|
||||
import { readKey } from '../../../../../../src/nest/assistant/cards/card-values';
|
||||
import { ASSISTANT_UNDO_MAP, ASSISTANT_WRITE_TOOLS } from '../../../../../../src/nest/assistant/cards/registry';
|
||||
import { replans, reshapedPoints } from '../../../../../../src/nest/assistant/cards/tour/tour-change.cards';
|
||||
import { describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
|
||||
import { assistantChangePreviewSchema } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const logMock = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
|
||||
vi.mock('../../../../../../src/nest/audit/audit-log.logger', () => logMock);
|
||||
|
||||
const card = buildTripCard(
|
||||
1,
|
||||
7,
|
||||
{ trip: { title: 'Alps' }, days: [] },
|
||||
{ places: [{ id: 61, name: 'Ridge walk', lat: 47, lng: 11, description: 'Up and down' }] },
|
||||
null,
|
||||
);
|
||||
const ctx: PreviewContext = { card, categories: new Map(), locale: 'en-GB', today: '2026-06-01', airport: () => null };
|
||||
|
||||
/** A snapshot holding the plan of the tour at a revision. */
|
||||
function atRevision(revision: number) {
|
||||
return {
|
||||
summary: { days: [] },
|
||||
places: { places: [] },
|
||||
reads: new Map([[readKey('get_tour_plan', { placeId: 61 }), { tour: { place_id: 61, revision } }]]),
|
||||
};
|
||||
}
|
||||
|
||||
describe('the edits of a saved tour in the chat', () => {
|
||||
it('ASTCARD-TOUR-080: new words and times name the tour and each field, and go back to the values before', () => {
|
||||
const args = { placeId: 61, expected_revision: 3, name: 'High ridge', break_additional_minutes: 90, website: null };
|
||||
const preview = describeToolCall('update_tour_details', 'tc-1', args, ctx, null);
|
||||
expect(assistantChangePreviewSchema.safeParse(preview).success).toBe(true);
|
||||
expect(preview).toMatchObject({ subject: 'Ridge walk', verbKey: 'updateTour', placeIds: [61] });
|
||||
expect(preview.rows.map((row) => row.labelKey)).toEqual(['name', 'website', 'breaks']);
|
||||
expect(preview.rows.find((row) => row.labelKey === 'breaks')?.after).toBe('1:30 h');
|
||||
const result = {
|
||||
tour: { place_id: 61, revision: 4 },
|
||||
previous: { revision: 3, name: 'Ridge walk', website: 'https://a.example/', break_additional_minutes: null },
|
||||
};
|
||||
const undo = ASSISTANT_UNDO_MAP.update_tour_details;
|
||||
expect(undo.args(args, result, null)).toEqual({
|
||||
placeId: 61,
|
||||
expected_revision: 4,
|
||||
name: 'Ridge walk',
|
||||
website: 'https://a.example/',
|
||||
break_additional_minutes: null,
|
||||
});
|
||||
expect(undo.args(args, { tour: {}, previous: {} }, null)).toBeNull();
|
||||
const call = { args, preview: null };
|
||||
expect(unchangedSinceApplied('update_tour_details', result, atRevision(4), call)).toBe(true);
|
||||
expect(unchangedSinceApplied('update_tour_details', result, atRevision(5), call)).toBe(false);
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-081: a new colour goes back to the colour before while the place still has the new one', () => {
|
||||
const args = { placeId: 61, route_color: '#e11d48' };
|
||||
expect(describeToolCall('set_tour_route_color', 'tc-2', args, ctx, null)).toMatchObject({
|
||||
verbKey: 'setRouteColor',
|
||||
rows: [expect.objectContaining({ labelKey: 'color', after: '#e11d48' })],
|
||||
});
|
||||
const result = { place_id: 61, route_color: '#e11d48', previous_route_color: null };
|
||||
expect(ASSISTANT_UNDO_MAP.set_tour_route_color.args(args, result, null)).toEqual({
|
||||
placeId: 61,
|
||||
route_color: null,
|
||||
});
|
||||
const places = (color: string | null) => ({
|
||||
summary: { days: [] },
|
||||
places: { places: [{ id: 61, route_color: color }] },
|
||||
reads: new Map(),
|
||||
});
|
||||
expect(unchangedSinceApplied('set_tour_route_color', result, places('#e11d48'))).toBe(true);
|
||||
expect(unchangedSinceApplied('set_tour_route_color', result, places('#000000'))).toBe(false);
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-082: a copy names its new name and the tour it is made from, and goes back by deleting it', () => {
|
||||
const preview = describeToolCall('copy_tour', 'tc-3', { placeId: 61, name: 'Ridge (short)' }, ctx, null);
|
||||
expect(preview).toMatchObject({ subject: 'Ridge (short)', verbKey: 'copyTour', placeIds: [61] });
|
||||
expect(preview.rows).toEqual([expect.objectContaining({ labelKey: 'route', after: 'Ridge walk' })]);
|
||||
expect(ASSISTANT_UNDO_MAP.copy_tour.tool).toBe('delete_place');
|
||||
expect(ASSISTANT_UNDO_MAP.copy_tour.args({}, { tour: { place_id: 90 } }, null)).toEqual({ placeId: 90 });
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-083: a hike turned round or closed to a loop says so, and is planned anew through the turned points', () => {
|
||||
const reverse = describeToolCall(
|
||||
'update_tour',
|
||||
'tc-4',
|
||||
{ placeId: 61, expected_revision: 1, reshape: 'reverse' },
|
||||
ctx,
|
||||
null,
|
||||
);
|
||||
expect(reverse.verbKey).toBe('reverseTour');
|
||||
const loop = describeToolCall(
|
||||
'update_tour',
|
||||
'tc-5',
|
||||
{ placeId: 61, expected_revision: 1, reshape: 'close_loop' },
|
||||
ctx,
|
||||
null,
|
||||
);
|
||||
expect(loop.verbKey).toBe('closeLoop');
|
||||
const saved = { tour: { max_hiking_difficulty: 2 }, line: { point_count: 2 } };
|
||||
expect(replans({ reshape: 'reverse' }, saved)).toBe(true);
|
||||
expect(replans({ name: 'X' }, saved)).toBe(false);
|
||||
const points = [
|
||||
{ lat: 47, lng: 11 },
|
||||
{ lat: 47.1, lng: 11 },
|
||||
];
|
||||
expect(reshapedPoints('reverse', points)).toEqual([...points].reverse());
|
||||
expect(reshapedPoints('close_loop', points)).toEqual([...points, { lat: 47, lng: 11 }]);
|
||||
expect(reshapedPoints(undefined, points)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the writes of a ski or paddle tour in the chat', () => {
|
||||
it('ASTCARD-TOUR-084: a ski classification names the discipline and goes back to the one before', () => {
|
||||
const args = { placeId: 61, ski_discipline: 'cross_country', ski_technique: 'classic' };
|
||||
expect(describeToolCall('classify_ski_track', 'tc-6', args, ctx, null)).toMatchObject({
|
||||
verbKey: 'classifySki',
|
||||
rows: [expect.objectContaining({ labelKey: 'type', after: 'cross_country (classic)' })],
|
||||
});
|
||||
const result = { tour: { revision: 2 }, previous: { ski_discipline: 'alpine', ski_technique: null } };
|
||||
expect(ASSISTANT_UNDO_MAP.classify_ski_track.args(args, result, null)).toEqual({
|
||||
placeId: 61,
|
||||
ski_discipline: 'alpine',
|
||||
ski_technique: null,
|
||||
expected_revision: 2,
|
||||
});
|
||||
expect(unchangedSinceApplied('classify_ski_track', result, atRevision(2), { args, preview: null })).toBe(true);
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-085: a paddle plan goes back to the plan before', () => {
|
||||
const args = {
|
||||
placeId: 61,
|
||||
paddle_speed_kmh: 5,
|
||||
portage_speed_kmh: null,
|
||||
track_marks: { portages: [], locks: [] },
|
||||
};
|
||||
expect(describeToolCall('set_paddle_plan', 'tc-7', args, ctx, null)).toMatchObject({ verbKey: 'setPaddlePlan' });
|
||||
const previous = { paddle_speed_kmh: null, portage_speed_kmh: 2, track_marks: { portages: [], locks: [] } };
|
||||
expect(ASSISTANT_UNDO_MAP.set_paddle_plan.args(args, { tour: { revision: 7 }, previous }, null)).toEqual({
|
||||
placeId: 61,
|
||||
...previous,
|
||||
expected_revision: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-086: decisions about obstacles and a track restored have a card and no way back', () => {
|
||||
for (const [tool, verbKey] of [
|
||||
['decide_paddle_obstacles', 'decideObstacles'],
|
||||
['restore_recorded_track', 'restoreTrack'],
|
||||
] as const) {
|
||||
expect(describeToolCall(tool, 'tc-8', { placeId: 61 }, ctx, null)).toMatchObject({
|
||||
subject: 'Ridge walk',
|
||||
verbKey,
|
||||
});
|
||||
expect(ASSISTANT_UNDO_MAP[tool]).toBeUndefined();
|
||||
expect(ASSISTANT_WRITE_TOOLS[tool]).toMatchObject({ entity: 'tour' });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* update_tour with reshape (P8, PR1-01 and PR1-02 for a saved hike): the saved
|
||||
* points turned round or taken back to their start, then planned anew as new
|
||||
* points are. The way is planned by a stub of HikeRoutingService; the write
|
||||
* is the real one over a snapshot test db.
|
||||
*/
|
||||
import type { McpContext } from '../../../src/nest-mcp';
|
||||
import { HikePlanMcp } from '../../../src/nest/tours/hike-plan.mcp';
|
||||
import { HikeRoutingService, type PlannedHike } from '../../../src/nest/tours/hike-routing.service';
|
||||
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { makePlace } from '../../helpers/factories/places';
|
||||
import { addTourWaypoints, makeTour } from '../../helpers/factories/tours';
|
||||
import { makeTrip } from '../../helpers/factories/trips';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import type { GeometryPoint, HikePlanRequest } from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// HIKE-RESHAPE-001 to HIKE-RESHAPE-004
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
const plan = vi.fn<HikeRoutingService['plan']>();
|
||||
let t: TestModule;
|
||||
let mcp: HikePlanMcp;
|
||||
const orm = () => t.get(MikroORM);
|
||||
|
||||
/** The stub's plan: a straight way through the points asked for, with heights. */
|
||||
function routed(request: HikePlanRequest): PlannedHike {
|
||||
const line: GeometryPoint[] = request.waypoints.map(({ lat, lng }, index) => [lat, lng, 800 + index * 50]);
|
||||
return {
|
||||
plan: {
|
||||
kind: 'routed',
|
||||
max_hiking_difficulty: request.max_hiking_difficulty ?? 2,
|
||||
hiking_preferences: request.hiking_preferences ?? null,
|
||||
distance_m: 2000,
|
||||
ascent_m: 100,
|
||||
descent_m: 0,
|
||||
duration_minutes: 40,
|
||||
duration_basis: 'din33466',
|
||||
has_heights: true,
|
||||
line,
|
||||
profile: [],
|
||||
grades: null,
|
||||
},
|
||||
line,
|
||||
snapped: line.map(() => 0),
|
||||
legs: line.slice(1).map(() => 1000),
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({
|
||||
db,
|
||||
imports: [ToursModule],
|
||||
overrides: [{ provide: HikeRoutingService, useValue: { plan } }],
|
||||
});
|
||||
mcp = t.get(HikePlanMcp);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(db);
|
||||
plan.mockReset().mockImplementation(async (_userId, _tripId, request) => routed(request));
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
const ctx = (userId: number) => ({ userId }) as McpContext;
|
||||
|
||||
function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) {
|
||||
const text = result.content[0]?.text ?? '';
|
||||
return result.isError ? { error: text } : JSON.parse(text);
|
||||
}
|
||||
|
||||
const POINTS = [
|
||||
{ lat: 47.5, lng: 11.1, station_key: 'stop-carpark', name: 'Car park' },
|
||||
{ lat: 47.51, lng: 11.1, station_key: 'stop-hut0001', name: 'Hut' },
|
||||
{ lat: 47.52, lng: 11.1, station_key: 'stop-summit1', name: 'Summit' },
|
||||
];
|
||||
|
||||
async function hike(points = POINTS, type: 'hike' | 'walk' = 'hike') {
|
||||
const { user } = await makeUser(orm());
|
||||
const trip = await makeTrip(orm(), user.id);
|
||||
const place = await makePlace(orm(), trip.id, {
|
||||
name: 'Herzogstand',
|
||||
route_geometry: JSON.stringify(points.map(({ lat, lng }) => [lat, lng, 800])),
|
||||
});
|
||||
await makeTour(orm(), place.id, { tourTypeRef: type, max_hiking_difficulty: type === 'hike' ? 2 : null });
|
||||
await addTourWaypoints(orm(), place.id, points);
|
||||
return { user, trip, place };
|
||||
}
|
||||
|
||||
describe('update_tour with reshape', () => {
|
||||
it('HIKE-RESHAPE-001: reverse plans the saved points the other way round, each keeping its name and key', async () => {
|
||||
const { user, trip, place } = await hike();
|
||||
const changed = read(
|
||||
await mcp.updateTour(
|
||||
{ tripId: trip.id, placeId: place.id, expected_revision: 1, reshape: 'reverse' },
|
||||
ctx(user.id),
|
||||
),
|
||||
);
|
||||
expect(plan.mock.calls[0]?.[2].waypoints.map(({ lat }) => lat)).toEqual([47.52, 47.51, 47.5]);
|
||||
expect(changed).toMatchObject({ replanned: true, tour: { revision: 2 } });
|
||||
expect(changed.waypoints.map((point: { name: string; key: string }) => [point.name, point.key])).toEqual([
|
||||
['Summit', 'stop-summit1'],
|
||||
['Hut', 'stop-hut0001'],
|
||||
['Car park', 'stop-carpark'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('HIKE-RESHAPE-002: close_loop adds the way back to the start, and a second one is refused', async () => {
|
||||
const { user, trip, place } = await hike();
|
||||
const closed = read(
|
||||
await mcp.updateTour(
|
||||
{ tripId: trip.id, placeId: place.id, expected_revision: 1, reshape: 'close_loop' },
|
||||
ctx(user.id),
|
||||
),
|
||||
);
|
||||
expect(closed.waypoints).toHaveLength(4);
|
||||
expect(closed.waypoints[3]).toMatchObject({ lat: 47.5, lng: 11.1, role: 'end' });
|
||||
await expect(
|
||||
mcp.updateTour({ tripId: trip.id, placeId: place.id, expected_revision: 2, reshape: 'close_loop' }, ctx(user.id)),
|
||||
).rejects.toThrow('This hike already ends where it starts.');
|
||||
});
|
||||
|
||||
it('HIKE-RESHAPE-003: a hike split into stages is not turned round, and nothing is planned', async () => {
|
||||
const staged = [POINTS[0], { ...POINTS[1], stage_end: 1 }, POINTS[2]] as typeof POINTS;
|
||||
const { user, trip, place } = await hike(staged);
|
||||
await expect(
|
||||
mcp.updateTour({ tripId: trip.id, placeId: place.id, expected_revision: 1, reshape: 'reverse' }, ctx(user.id)),
|
||||
).rejects.toThrow('split into stages over several days');
|
||||
expect(plan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('HIKE-RESHAPE-004: any other type keeps being refused here', async () => {
|
||||
const { user, trip, place } = await hike(POINTS, 'walk');
|
||||
await expect(
|
||||
mcp.updateTour({ tripId: trip.id, placeId: place.id, expected_revision: 1, reshape: 'reverse' }, ctx(user.id)),
|
||||
).rejects.toThrow('Only a hike is changed here');
|
||||
});
|
||||
});
|
||||
@@ -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,201 @@
|
||||
import {
|
||||
applyChanges,
|
||||
editsOnly,
|
||||
refusalText,
|
||||
reversedPoints,
|
||||
savedPoint,
|
||||
turnedLeg,
|
||||
type ChangeBasis,
|
||||
type DraftPoint,
|
||||
} from '../../../src/nest/tours/tour-change-apply';
|
||||
import type { TourChange, TourWaypoint } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// TCHG-EDIT-001 to TCHG-EDIT-012: P8, a city walk's stations edited, the walk reversed and closed to a loop.
|
||||
|
||||
const point = (key: string, sequence: number, extra: Partial<TourWaypoint> = {}): TourWaypoint => ({
|
||||
lat: 48 + sequence * 0.01,
|
||||
lng: 11,
|
||||
role: 'via',
|
||||
sequence,
|
||||
key,
|
||||
name: key.replace('stop-', ''),
|
||||
...extra,
|
||||
});
|
||||
|
||||
/** A walk from the gate past the museum to the park. */
|
||||
const walk = (): TourWaypoint[] => [
|
||||
point('stop-gate', 0, { role: 'start', stay_minutes: 5 }),
|
||||
point('stop-museum', 1, { stay_minutes: 45, importance: 'must' }),
|
||||
point('stop-park', 2, { role: 'end' }),
|
||||
];
|
||||
|
||||
/** The same walk as a loop: the way back stands on the gate again. */
|
||||
const loop = (): TourWaypoint[] => [
|
||||
...walk().slice(0, 2),
|
||||
point('stop-park', 2),
|
||||
{ lat: 48, lng: 11, role: 'end', sequence: 3, key: 'back-0001' },
|
||||
];
|
||||
|
||||
const basis = (over: Partial<ChangeBasis> = {}): ChangeBasis => ({
|
||||
tourType: 'walk',
|
||||
recorded: false,
|
||||
points: walk(),
|
||||
done: new Map(),
|
||||
linkable: new Set(),
|
||||
...over,
|
||||
});
|
||||
|
||||
function ready(outcome: ReturnType<typeof applyChanges>) {
|
||||
if (outcome.kind !== 'ready') throw new Error(`refused: ${JSON.stringify(outcome.refused)}`);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
function refused(outcome: ReturnType<typeof applyChanges>) {
|
||||
if (outcome.kind !== 'refused') throw new Error('was laid');
|
||||
return outcome.refused;
|
||||
}
|
||||
|
||||
const refs = (points: readonly DraftPoint[]) => points.map((p) => p.ref);
|
||||
|
||||
describe('edits of a station (P8)', () => {
|
||||
it('TCHG-EDIT-001: an edit writes only the fields it names, null clears one, and the station stays where it is', () => {
|
||||
const changes: TourChange[] = [
|
||||
{ op: 'edit', key: 'stop-museum', name: 'Art museum', stay_minutes: null, importance: null, protected: true },
|
||||
{ op: 'edit', key: 'stop-park', opening_hours: 'Mo-Su 06:00-22:00' },
|
||||
];
|
||||
const { points } = ready(applyChanges(basis(), changes));
|
||||
expect(refs(points)).toEqual(['stop-gate', 'stop-museum', 'stop-park']);
|
||||
expect(points[1]).toMatchObject({
|
||||
name: 'Art museum',
|
||||
stay_minutes: null,
|
||||
importance: null,
|
||||
protected: true,
|
||||
origin: 'edited',
|
||||
change: 1,
|
||||
});
|
||||
expect(points[2]).toMatchObject({ name: 'park', opening_hours: 'Mo-Su 06:00-22:00', origin: 'edited', change: 2 });
|
||||
expect(points[0]).toMatchObject({ stay_minutes: 5, origin: 'saved' });
|
||||
});
|
||||
|
||||
it('TCHG-EDIT-002: a protected or already visited station takes an edit; an unknown key is refused', () => {
|
||||
const points = walk().map((one) => (one.key === 'stop-museum' ? { ...one, protected: true } : one));
|
||||
const done = new Map([['stop-museum', [{ day_id: 3, status: 'visited' as const }]]]);
|
||||
const edited = ready(applyChanges(basis({ points, done }), [{ op: 'edit', key: 'stop-museum', stay_minutes: 60 }]));
|
||||
expect(edited.points[1]?.stay_minutes).toBe(60);
|
||||
const unknown = refused(applyChanges(basis(), [{ op: 'edit', key: 'stop-nowhere', name: 'X' }]));
|
||||
expect(unknown).toEqual([{ change: 1, op: 'edit', key: 'stop-nowhere', reason: 'unknown_station' }]);
|
||||
});
|
||||
|
||||
it('TCHG-EDIT-003: editsOnly tells a proposal of edits from one that moves stations', () => {
|
||||
expect(editsOnly([{ op: 'edit', key: 'stop-park', name: 'P' }])).toBe(true);
|
||||
expect(editsOnly([{ op: 'edit', key: 'stop-park', name: 'P' }, { op: 'reverse' }])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a walk turned round (PR1-02)', () => {
|
||||
it('TCHG-EDIT-004: the last station starts it, every station is marked moved but one that keeps its place', () => {
|
||||
const { points } = ready(applyChanges(basis(), [{ op: 'reverse' }]));
|
||||
expect(refs(points)).toEqual(['stop-park', 'stop-museum', 'stop-gate']);
|
||||
expect(points.map((p) => [p.role, p.sequence])).toEqual([
|
||||
['start', 0],
|
||||
['via', 1],
|
||||
['end', 2],
|
||||
]);
|
||||
expect(points.map((p) => p.origin)).toEqual(['moved', 'saved', 'moved']);
|
||||
});
|
||||
|
||||
it('TCHG-EDIT-005: a loop stays a loop from the same start, which keeps its key and stay', () => {
|
||||
const { points } = ready(applyChanges(basis({ points: loop() }), [{ op: 'reverse' }]));
|
||||
expect(points.map((p) => p.key)).toEqual(['stop-gate', 'stop-park', 'stop-museum', 'back-0001']);
|
||||
expect(points[0]).toMatchObject({ lat: 48, lng: 11, stay_minutes: 5, role: 'start' });
|
||||
expect(points[3]).toMatchObject({ lat: 48, lng: 11, role: 'end' });
|
||||
expect(points[3]?.name).toBeUndefined();
|
||||
});
|
||||
|
||||
it('TCHG-EDIT-006: a walk under way or with a protected station is not turned round', () => {
|
||||
const done = new Map([['stop-gate', [{ day_id: 2, status: 'skipped' as const }]]]);
|
||||
expect(refused(applyChanges(basis({ done }), [{ op: 'reverse' }]))).toEqual([
|
||||
{ change: 1, op: 'reverse', key: null, reason: 'walk_under_way' },
|
||||
]);
|
||||
const points = walk().map((one) => (one.key === 'stop-museum' ? { ...one, protected: true } : one));
|
||||
expect(refused(applyChanges(basis({ points }), [{ op: 'reverse' }]))).toEqual([
|
||||
{ change: 1, op: 'reverse', key: 'stop-museum', reason: 'protected' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('TCHG-EDIT-007: a reverse goes with edits only, and only once', () => {
|
||||
const withMove = refused(
|
||||
applyChanges(basis(), [{ op: 'reverse' }, { op: 'move', key: 'stop-gate', after_key: 'stop-museum' }]),
|
||||
);
|
||||
expect(withMove).toEqual([{ change: null, op: null, key: null, reason: 'reshape_not_alone' }]);
|
||||
expect(refused(applyChanges(basis(), [{ op: 'reverse' }, { op: 'close_loop' }]))[0]?.reason).toBe(
|
||||
'reshape_not_alone',
|
||||
);
|
||||
const withEdit = ready(applyChanges(basis(), [{ op: 'reverse' }, { op: 'edit', key: 'stop-park', name: 'Park' }]));
|
||||
expect(withEdit.points[0]).toMatchObject({ key: 'stop-park', name: 'Park' });
|
||||
});
|
||||
|
||||
it('TCHG-EDIT-008: a leg drawn by hand stays on its stretch, its line turned; a crossing by boat keeps its points', () => {
|
||||
const free = { kind: 'free' as const, geometry: [[48, 11] as [number, number], [48.01, 11] as [number, number]] };
|
||||
const ferry = {
|
||||
kind: 'ferry' as const,
|
||||
geometry: [
|
||||
[48.01, 11],
|
||||
[48.012, 11],
|
||||
[48.016, 11],
|
||||
[48.02, 11],
|
||||
] as Array<[number, number]>,
|
||||
crossings: [[1, 2]] as Array<[number, number]>,
|
||||
minutes: 10,
|
||||
walk_minutes: 4,
|
||||
};
|
||||
const points = [
|
||||
point('stop-gate', 0, { role: 'start', fixed_leg: free }),
|
||||
point('stop-museum', 1, { fixed_leg: ferry }),
|
||||
point('stop-park', 2, { role: 'end' }),
|
||||
];
|
||||
const turned = reversedPoints(points.map(savedPoint));
|
||||
expect(turned.map((p) => p.fixed_leg?.kind ?? null)).toEqual(['ferry', 'free', null]);
|
||||
expect(turned[0]?.fixed_leg).toMatchObject({ geometry: [...ferry.geometry].reverse(), crossings: [[1, 2]] });
|
||||
expect(turned[1]?.fixed_leg?.geometry).toEqual([...free.geometry].reverse());
|
||||
expect(turnedLeg({ ...ferry, crossings: [[0, 1]] })).toMatchObject({ crossings: [[2, 3]] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a walk taken back to its start (PR1-01)', () => {
|
||||
it('TCHG-EDIT-009: a way back on the start is added, no station of its own and nothing to keep as a place', () => {
|
||||
const { points } = ready(applyChanges(basis(), [{ op: 'close_loop' }]));
|
||||
expect(points).toHaveLength(4);
|
||||
expect(points[3]).toMatchObject({ lat: 48, lng: 11, role: 'end', origin: 'loop', ref: 'new:1' });
|
||||
expect(points[3]?.key).toBeUndefined();
|
||||
expect(points[2]).toMatchObject({ key: 'stop-park', role: 'via' });
|
||||
});
|
||||
|
||||
it('TCHG-EDIT-010: a walk that already ends at its start is refused, with the reason in words', () => {
|
||||
const outcome = refused(applyChanges(basis({ points: loop() }), [{ op: 'close_loop' }]));
|
||||
expect(outcome).toEqual([{ change: 1, op: 'close_loop', key: null, reason: 'already_loop' }]);
|
||||
expect(refusalText(outcome, 'prepared')).toBe(
|
||||
'Nothing was prepared and nothing changed.\nChange 1 (close_loop): this walk already ends where it starts.',
|
||||
);
|
||||
});
|
||||
|
||||
it('TCHG-EDIT-011: the refusal names a reverse without a station and an edit with its key', () => {
|
||||
const text = refusalText(
|
||||
[
|
||||
{ change: 1, op: 'reverse', key: null, reason: 'walk_under_way' },
|
||||
{ change: 2, op: 'edit', key: 'stop-x', reason: 'unknown_station' },
|
||||
],
|
||||
'saved',
|
||||
);
|
||||
expect(text).toContain('Change 1 (reverse): the travellers already visited or skipped stations');
|
||||
expect(text).toContain('Change 2 (edit stop-x): no station');
|
||||
});
|
||||
|
||||
it('TCHG-EDIT-012: a hike still takes no prepared change at all', () => {
|
||||
expect(refused(applyChanges(basis({ tourType: 'hike' }), [{ op: 'close_loop' }]))[0]?.reason).toBe(
|
||||
'type_not_supported',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* prepare_tour_change and save_tour_change with the changes P8 brings: a
|
||||
* station edited (its name, stay, importance, protection, opening hours), the
|
||||
* walk turned round and closed to a loop. Edits alone keep the walk's saved
|
||||
* way and walking time and never ask the router; a reverse or a loop plans
|
||||
* the way on foot anew. The road trip's router is a stub; the suite takes its
|
||||
* subject from the container over a snapshot test db.
|
||||
*/
|
||||
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 type { McpContext } from '../../../src/nest-mcp';
|
||||
import { RoadtripRouterService } from '../../../src/nest/roadtrip/roadtrip-router.service';
|
||||
import { proposalIdOf } from '../../../src/nest/tours/tour-change-proposal';
|
||||
import { TourChangeMcp } from '../../../src/nest/tours/tour-change.mcp';
|
||||
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { makePlace } from '../../helpers/factories/places';
|
||||
import { countRows, findRow, findRows } from '../../helpers/factories/rows';
|
||||
import { addTourWaypoints, makeTour } from '../../helpers/factories/tours';
|
||||
import { makeTrip } from '../../helpers/factories/trips';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import type { TourChange } from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// TCHG-EDIT-MCP-001 to TCHG-EDIT-MCP-004
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
const route = vi.fn();
|
||||
let t: TestModule;
|
||||
let mcp: TourChangeMcp;
|
||||
const orm = () => t.get(MikroORM);
|
||||
|
||||
/** Every leg is 500 m and 400 s on foot, along the points asked. */
|
||||
async function routedOnFoot(_u: number, _t: number, _d: number, points: Array<{ lat: number; lng: number }>) {
|
||||
return {
|
||||
leg: { line: points.map((point): [number, number] => [point.lat, point.lng]) },
|
||||
parts: points.slice(1).map(() => ({ distance: 500, duration: 400 })),
|
||||
snapped: points.map(() => ({ meters: 4 })),
|
||||
avoidMissed: [],
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({
|
||||
db,
|
||||
imports: [ToursModule],
|
||||
overrides: [{ provide: RoadtripRouterService, useValue: { route } }],
|
||||
});
|
||||
mcp = t.get(TourChangeMcp);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(db);
|
||||
route.mockReset().mockImplementation(routedOnFoot);
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
/** The walk's saved line, with heights the router on foot never gives. */
|
||||
const LINE = [
|
||||
[48.1, 11.5, 520],
|
||||
[48.104, 11.5, 522],
|
||||
[48.108, 11.5, 525],
|
||||
];
|
||||
|
||||
async function walk() {
|
||||
const { user } = await makeUser(orm());
|
||||
const trip = await makeTrip(orm(), user.id);
|
||||
const place = await makePlace(orm(), trip.id, { name: 'Old town', route_geometry: JSON.stringify(LINE) });
|
||||
await makeTour(orm(), place.id, { tourTypeRef: 'walk', duration: 25 });
|
||||
await addTourWaypoints(orm(), place.id, [
|
||||
{ lat: 48.1, lng: 11.5, station_key: 'walk-gate', name: 'Gate' },
|
||||
{ lat: 48.104, lng: 11.5, station_key: 'walk-market', name: 'Market', stay_minutes: 20 },
|
||||
{ lat: 48.108, lng: 11.5, station_key: 'walk-park', name: 'Park' },
|
||||
]);
|
||||
return { user, trip, place };
|
||||
}
|
||||
|
||||
async function saved(f: Awaited<ReturnType<typeof walk>>, changes: TourChange[]) {
|
||||
const args = { tripId: f.trip.id, placeId: f.place.id };
|
||||
return read(
|
||||
await mcp.saveTourChange(
|
||||
{
|
||||
...args,
|
||||
changes,
|
||||
expected_revision: 1,
|
||||
proposal_id: proposalIdOf({ ...args, revision: 1, changes }),
|
||||
confirmed: true,
|
||||
},
|
||||
ctx(f.user.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('P8 changes of a saved city walk', () => {
|
||||
it('TCHG-EDIT-MCP-001: an edit is prepared with the router asked once, the station marked edited', async () => {
|
||||
const f = await walk();
|
||||
const changes: TourChange[] = [{ op: 'edit', key: 'walk-market', stay_minutes: 45, importance: 'must' }];
|
||||
const proposal = read(
|
||||
await mcp.prepareTourChange({ tripId: f.trip.id, placeId: f.place.id, changes }, ctx(f.user.id)),
|
||||
);
|
||||
expect(route).toHaveBeenCalledTimes(1);
|
||||
expect(proposal.stations_after[1]).toMatchObject({
|
||||
key: 'walk-market',
|
||||
change: 'edited',
|
||||
stay_minutes: 45,
|
||||
importance: 'must',
|
||||
});
|
||||
expect(proposal.effects.stays.change_minutes_known).toBe(25);
|
||||
});
|
||||
|
||||
it('TCHG-EDIT-MCP-002: edits alone keep the saved line with its heights and the walking time, and record a step', async () => {
|
||||
const f = await walk();
|
||||
route.mockClear();
|
||||
const answer = await saved(f, [{ op: 'edit', key: 'walk-gate', name: 'Old gate', protected: true }]);
|
||||
expect(route).not.toHaveBeenCalled();
|
||||
expect(answer).toMatchObject({ saved: true, tour: { revision: 2, walking_minutes: 25, route_pending: false } });
|
||||
const place = await findRow(orm(), Places, { id: f.place.id });
|
||||
expect(JSON.parse(String(place?.route_geometry))).toEqual(LINE);
|
||||
const gate = await findRow(orm(), TourWaypoints, { place: f.place.id, station_key: 'walk-gate' });
|
||||
expect(gate?.name).toBe('Old gate');
|
||||
expect(Boolean(gate?.protected)).toBe(true);
|
||||
expect(await countRows(orm(), TourChangeSteps)).toBe(1);
|
||||
});
|
||||
|
||||
it('TCHG-EDIT-MCP-003: a reverse plans the walk anew from its last station, keeping every key', async () => {
|
||||
const f = await walk();
|
||||
route.mockClear();
|
||||
const answer = await saved(f, [{ op: 'reverse' }]);
|
||||
expect(route).toHaveBeenCalledTimes(1);
|
||||
expect(answer.stations.map((station: { key: string }) => station.key)).toEqual([
|
||||
'walk-park',
|
||||
'walk-market',
|
||||
'walk-gate',
|
||||
]);
|
||||
});
|
||||
|
||||
it('TCHG-EDIT-MCP-004: a close_loop adds the way back, which no station counts', async () => {
|
||||
const f = await walk();
|
||||
const answer = await saved(f, [{ op: 'close_loop' }]);
|
||||
expect(answer.stations).toHaveLength(3);
|
||||
const points = await findRows(orm(), TourWaypoints, { place: f.place.id });
|
||||
expect(points).toHaveLength(4);
|
||||
expect(points.find((point) => point.sequence === 3)).toMatchObject({ lat: 48.1, lng: 11.5, role: 'end' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* TourEditMcp (P8): a saved tour's words and planned times, the colour of its
|
||||
* line and a copy of it, over MCP with the services and checks of their REST
|
||||
* routes (trip access, then 'place_edit'). The suite takes its subject from
|
||||
* the container over a snapshot test db.
|
||||
*/
|
||||
import { Places } from '../../../src/db/entities/Places.entity';
|
||||
import { Tours } from '../../../src/db/entities/Tours.entity';
|
||||
import type { McpContext } from '../../../src/nest-mcp';
|
||||
import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
|
||||
import { TourEditMcp } from '../../../src/nest/tours/tour-edit.mcp';
|
||||
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { makePlace } from '../../helpers/factories/places';
|
||||
import { countRows, findRow } from '../../helpers/factories/rows';
|
||||
import { addTourWaypoints, makeTour } from '../../helpers/factories/tours';
|
||||
import { makeTrip } from '../../helpers/factories/trips';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
let t: TestModule;
|
||||
let mcp: TourEditMcp;
|
||||
const orm = () => t.get(MikroORM);
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({ db, imports: [ToursModule] });
|
||||
mcp = t.get(TourEditMcp);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(db);
|
||||
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 = JSON.stringify([
|
||||
[47, 11, 600],
|
||||
[47.01, 11, 700],
|
||||
]);
|
||||
|
||||
async function fixture(duration = 120) {
|
||||
const { user } = await makeUser(orm());
|
||||
const { user: outsider } = await makeUser(orm());
|
||||
const trip = await makeTrip(orm(), user.id);
|
||||
const hike = await makePlace(orm(), trip.id, { name: 'Ridge', route_geometry: LINE, website: 'https://old.example' });
|
||||
await makeTour(orm(), hike.id, { duration });
|
||||
await addTourWaypoints(orm(), hike.id, [
|
||||
{ lat: 47, lng: 11, station_key: 'stop-start1', name: 'Car park' },
|
||||
{ lat: 47.01, lng: 11, station_key: 'stop-summit', name: 'Summit' },
|
||||
]);
|
||||
const plain = await makePlace(orm(), trip.id, { name: 'Bakery' });
|
||||
return { user, outsider, trip, hike, plain };
|
||||
}
|
||||
|
||||
const events = () => t.realtime.broadcastMock.mock.calls.map(([, event]) => event);
|
||||
|
||||
describe('update_tour_details', () => {
|
||||
it('TEDIT-MCP-001: changes the website and the breaks against the revision, answering the values before', async () => {
|
||||
const { user, trip, hike } = await fixture();
|
||||
const result = read(
|
||||
await mcp.updateTourDetails(
|
||||
{
|
||||
tripId: trip.id,
|
||||
placeId: hike.id,
|
||||
expected_revision: 1,
|
||||
website: 'https://new.example/ridge',
|
||||
break_additional_minutes: 30,
|
||||
},
|
||||
ctx(user.id),
|
||||
),
|
||||
);
|
||||
expect(result.tour).toMatchObject({
|
||||
place_id: hike.id,
|
||||
revision: 2,
|
||||
name: 'Ridge',
|
||||
website: 'https://new.example/ridge',
|
||||
break_additional_minutes: 30,
|
||||
});
|
||||
expect(result.previous).toMatchObject({
|
||||
revision: 1,
|
||||
website: 'https://old.example',
|
||||
break_additional_minutes: null,
|
||||
});
|
||||
expect(await findRow(orm(), Tours, { place: hike.id })).toMatchObject({
|
||||
break_additional_minutes: 30,
|
||||
revision: 2,
|
||||
});
|
||||
expect(events()).toEqual(expect.arrayContaining(['tours:changed', 'place:updated']));
|
||||
});
|
||||
|
||||
it('TEDIT-MCP-002: clears a description with null and keeps the route and its points', async () => {
|
||||
const { user, trip, hike } = await fixture();
|
||||
await mcp.updateTourDetails(
|
||||
{ tripId: trip.id, placeId: hike.id, expected_revision: 1, description: null, name: 'High ridge' },
|
||||
ctx(user.id),
|
||||
);
|
||||
const place = await findRow(orm(), Places, { id: hike.id });
|
||||
expect(place).toMatchObject({ name: 'High ridge', description: null, route_geometry: LINE });
|
||||
});
|
||||
|
||||
it('TEDIT-MCP-003: refuses a tour changed since, breaks past the day and a change of nothing', async () => {
|
||||
const { user, trip, hike } = await fixture(1400);
|
||||
await expect(
|
||||
mcp.updateTourDetails({ tripId: trip.id, placeId: hike.id, expected_revision: 5, name: 'X' }, ctx(user.id)),
|
||||
).rejects.toThrow('This tour was changed in the meantime');
|
||||
await expect(
|
||||
mcp.updateTourDetails(
|
||||
{ tripId: trip.id, placeId: hike.id, expected_revision: 1, break_additional_minutes: 60 },
|
||||
ctx(user.id),
|
||||
),
|
||||
).rejects.toThrow('walking time plus breaks must be 1440 minutes or less');
|
||||
// With a planned total the breaks are the travellers' own.
|
||||
const planned = read(
|
||||
await mcp.updateTourDetails(
|
||||
{
|
||||
tripId: trip.id,
|
||||
placeId: hike.id,
|
||||
expected_revision: 1,
|
||||
break_additional_minutes: 60,
|
||||
planned_duration_minutes: 1440,
|
||||
},
|
||||
ctx(user.id),
|
||||
),
|
||||
);
|
||||
expect(planned.tour.break_additional_minutes).toBe(60);
|
||||
expect(
|
||||
read(await mcp.updateTourDetails({ tripId: trip.id, placeId: hike.id, expected_revision: 2 }, ctx(user.id))),
|
||||
).toEqual({ error: 'give at least one field to change' });
|
||||
});
|
||||
|
||||
it('TEDIT-MCP-004: a place that is no tour is not found', async () => {
|
||||
const { user, trip, plain } = await fixture();
|
||||
expect(
|
||||
read(
|
||||
await mcp.updateTourDetails(
|
||||
{ tripId: trip.id, placeId: plain.id, expected_revision: 1, name: 'X' },
|
||||
ctx(user.id),
|
||||
),
|
||||
),
|
||||
).toEqual({ error: 'Tour not found.' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('set_tour_route_color', () => {
|
||||
it('TEDIT-MCP-005: draws the line in another colour and back, without raising the revision', async () => {
|
||||
const { user, trip, hike } = await fixture();
|
||||
const set = read(
|
||||
await mcp.setTourRouteColor({ tripId: trip.id, placeId: hike.id, route_color: '#e11d48' }, ctx(user.id)),
|
||||
);
|
||||
expect(set).toEqual({ place_id: hike.id, route_color: '#e11d48', previous_route_color: null });
|
||||
const back = read(
|
||||
await mcp.setTourRouteColor({ tripId: trip.id, placeId: hike.id, route_color: null }, ctx(user.id)),
|
||||
);
|
||||
expect(back.previous_route_color).toBe('#e11d48');
|
||||
expect((await findRow(orm(), Places, { id: hike.id }))?.route_color).toBeNull();
|
||||
expect((await findRow(orm(), Tours, { place: hike.id }))?.revision).toBe(1);
|
||||
expect(events()).toEqual(['place:updated', 'place:updated']);
|
||||
});
|
||||
|
||||
it('TEDIT-MCP-006: a place that is no tour is not found and keeps its colour', async () => {
|
||||
const { user, trip, plain } = await fixture();
|
||||
expect(
|
||||
read(await mcp.setTourRouteColor({ tripId: trip.id, placeId: plain.id, route_color: '#000000' }, ctx(user.id))),
|
||||
).toEqual({ error: 'Tour not found.' });
|
||||
expect((await findRow(orm(), Places, { id: plain.id }))?.route_color).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('copy_tour', () => {
|
||||
it('TEDIT-MCP-007: saves the tour again as a new one of the trip under its own name', async () => {
|
||||
const { user, trip, hike } = await fixture();
|
||||
const before = await countRows(orm(), Tours);
|
||||
const copy = read(await mcp.copyTour({ tripId: trip.id, placeId: hike.id, name: 'Ridge (short)' }, ctx(user.id)));
|
||||
expect(copy.tour).toMatchObject({ name: 'Ridge (short)', tour_type: 'hike', derived_from_place_id: hike.id });
|
||||
expect(copy.tour.place_id).not.toBe(hike.id);
|
||||
expect(copy.waypoints).toHaveLength(2);
|
||||
expect(await countRows(orm(), Tours)).toBe(before + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the checks of the REST routes', () => {
|
||||
it('TEDIT-MCP-008: refuses a trip the user cannot open, and without place_edit writes nothing', async () => {
|
||||
const { user, outsider, trip, hike } = await fixture();
|
||||
const name = { tripId: trip.id, placeId: hike.id, name: 'X' };
|
||||
expect(read(await mcp.copyTour(name, ctx(outsider.id)))).toEqual({ error: 'Trip not found or access denied.' });
|
||||
const denied = vi.spyOn(t.get(McpToolGuardsService), 'hasTripPermission').mockResolvedValue(false);
|
||||
const results = [
|
||||
await mcp.updateTourDetails({ ...name, expected_revision: 1 }, ctx(user.id)),
|
||||
await mcp.setTourRouteColor({ tripId: trip.id, placeId: hike.id, route_color: '#ffffff' }, ctx(user.id)),
|
||||
await mcp.copyTour(name, ctx(user.id)),
|
||||
];
|
||||
expect(results.every((result) => result.isError === true)).toBe(true);
|
||||
expect(denied).toHaveBeenCalledWith('place_edit', trip.id, user.id);
|
||||
expect(await findRow(orm(), Places, { id: hike.id })).toMatchObject({ name: 'Ridge', route_color: null });
|
||||
expect(await countRows(orm(), Tours)).toBe(1);
|
||||
denied.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* TourSportWritesMcp (P9): the writes a ski or paddle tour has of its own, an
|
||||
* import taken back to its recording, and the reads beside them (the trail
|
||||
* details along a saved tour, the horse routes in a map view), over MCP with
|
||||
* the services and checks of their REST routes. The router, the trail grades
|
||||
* and the horse routes stand in for the network. The suite takes its subject
|
||||
* from the container over a snapshot test db.
|
||||
*/
|
||||
import { Tours } from '../../../src/db/entities/Tours.entity';
|
||||
import type { McpContext } from '../../../src/nest-mcp';
|
||||
import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
|
||||
import { RoadtripRouterService } from '../../../src/nest/roadtrip/roadtrip-router.service';
|
||||
import { TourHorseRoutesService } from '../../../src/nest/tours/tour-horse-routes.service';
|
||||
import { TourObstacleReviewsService } from '../../../src/nest/tours/tour-obstacle-reviews.service';
|
||||
import { TourSportWritesMcp } from '../../../src/nest/tours/tour-sport-writes.mcp';
|
||||
import { TourTrailGradeService } from '../../../src/nest/tours/tour-trail-grade.service';
|
||||
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
||||
import { ToursService } from '../../../src/nest/tours/tours.service';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { makePlace } from '../../helpers/factories/places';
|
||||
import { findRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { addTourWaypoints, makeTour } from '../../helpers/factories/tours';
|
||||
import { makeTrip } from '../../helpers/factories/trips';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { HORSE_ROUTES_ATTRIBUTION } from '@trek/shared';
|
||||
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
let t: TestModule;
|
||||
let mcp: TourSportWritesMcp;
|
||||
const orm = () => t.get(MikroORM);
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({ db, imports: [ToursModule] });
|
||||
mcp = t.get(TourSportWritesMcp);
|
||||
});
|
||||
beforeEach(() => resetTestDb(db));
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
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);
|
||||
}
|
||||
|
||||
/** About 1.1 km north, with heights. */
|
||||
const LINE = JSON.stringify([
|
||||
[47, 11, 600],
|
||||
[47.01, 11, 700],
|
||||
]);
|
||||
|
||||
async function fixture() {
|
||||
const { user } = await makeUser(orm());
|
||||
const { user: outsider } = await makeUser(orm());
|
||||
const trip = await makeTrip(orm(), user.id);
|
||||
const ski = await makePlace(orm(), trip.id, { name: 'Morning run', route_geometry: LINE });
|
||||
await makeTour(orm(), ski.id, { tourTypeRef: 'ski', origin: 'gpx', ski_discipline: 'alpine' });
|
||||
const paddle = await makePlace(orm(), trip.id, { name: 'River day', route_geometry: LINE });
|
||||
await makeTour(orm(), paddle.id, { tourTypeRef: 'kayak', origin: 'gpx', boat_type: 'kayak', water_context: 'river' });
|
||||
const hike = await makePlace(orm(), trip.id, { name: 'Ridge', route_geometry: LINE });
|
||||
await makeTour(orm(), hike.id);
|
||||
await addTourWaypoints(orm(), hike.id, [
|
||||
{ lat: 47, lng: 11 },
|
||||
{ lat: 47.01, lng: 11 },
|
||||
]);
|
||||
return { user, outsider, trip, ski, paddle, hike };
|
||||
}
|
||||
|
||||
describe('classify_ski_track', () => {
|
||||
it('TSPORT-MCP-001: classifies a ski track again and answers the classification before', async () => {
|
||||
const { user, trip, ski } = await fixture();
|
||||
const result = read(
|
||||
await mcp.classifySkiTrack(
|
||||
{ tripId: trip.id, placeId: ski.id, ski_discipline: 'cross_country', ski_technique: 'skating' },
|
||||
ctx(user.id),
|
||||
),
|
||||
);
|
||||
expect(result.tour).toMatchObject({ ski_discipline: 'cross_country', ski_technique: 'skating', revision: 2 });
|
||||
expect(result.previous).toEqual({ revision: 1, ski_discipline: 'alpine', ski_technique: null });
|
||||
});
|
||||
|
||||
it('TSPORT-MCP-002: refuses what the REST contract refuses, and a tour that is no ski track', async () => {
|
||||
const { user, trip, ski, hike } = await fixture();
|
||||
const technique = await mcp.classifySkiTrack(
|
||||
{ tripId: trip.id, placeId: ski.id, ski_discipline: 'alpine', ski_technique: 'classic' },
|
||||
ctx(user.id),
|
||||
);
|
||||
expect(technique.isError).toBe(true);
|
||||
await expect(
|
||||
mcp.classifySkiTrack({ tripId: trip.id, placeId: hike.id, ski_discipline: 'alpine' }, ctx(user.id)),
|
||||
).rejects.toThrow('Only a ski track has a ski classification');
|
||||
expect((await findRow(orm(), Tours, { place: ski.id }))?.ski_discipline).toBe('alpine');
|
||||
});
|
||||
});
|
||||
|
||||
describe('set_paddle_plan', () => {
|
||||
const marks = { portages: [{ from_m: 100, to_m: 300, extra_minutes: null }], locks: [] };
|
||||
|
||||
it('TSPORT-MCP-003: lays a portage onto a recorded paddle track and answers the plan before', async () => {
|
||||
const { user, trip, paddle } = await fixture();
|
||||
const result = read(
|
||||
await mcp.setPaddlePlan(
|
||||
{ tripId: trip.id, placeId: paddle.id, paddle_speed_kmh: 5, portage_speed_kmh: null, track_marks: marks },
|
||||
ctx(user.id),
|
||||
),
|
||||
);
|
||||
expect(result.tour).toMatchObject({ paddle_speed_kmh: 5, track_marks: marks, revision: 2 });
|
||||
expect(result.previous).toEqual({
|
||||
revision: 1,
|
||||
paddle_speed_kmh: null,
|
||||
portage_speed_kmh: null,
|
||||
track_marks: { portages: [], locks: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it('TSPORT-MCP-004: a planned tour, a mark past the track and a mark the contract refuses are refused', async () => {
|
||||
const { user, trip, paddle, hike } = await fixture();
|
||||
await expect(
|
||||
mcp.setPaddlePlan(
|
||||
{ tripId: trip.id, placeId: hike.id, paddle_speed_kmh: null, portage_speed_kmh: null, track_marks: marks },
|
||||
ctx(user.id),
|
||||
),
|
||||
).rejects.toThrow('Only a paddle tour has a paddle plan');
|
||||
const far = { portages: [{ from_m: 100, to_m: 90_000, extra_minutes: null }], locks: [] };
|
||||
await expect(
|
||||
mcp.setPaddlePlan(
|
||||
{ tripId: trip.id, placeId: paddle.id, paddle_speed_kmh: null, portage_speed_kmh: null, track_marks: far },
|
||||
ctx(user.id),
|
||||
),
|
||||
).rejects.toThrow('A mark lies beyond the end of the track');
|
||||
const backwards = { portages: [{ from_m: 300, to_m: 100, extra_minutes: null }], locks: [] };
|
||||
const refused = await mcp.setPaddlePlan(
|
||||
{ tripId: trip.id, placeId: paddle.id, paddle_speed_kmh: null, portage_speed_kmh: null, track_marks: backwards },
|
||||
ctx(user.id),
|
||||
);
|
||||
expect(read(refused)).toEqual({ error: 'a portage ends after it begins' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('decide_paddle_obstacles', () => {
|
||||
const op = { key: 'node/12', op: 'note' as const, decision: null, note: 'carry left', basis: null };
|
||||
|
||||
it('TSPORT-MCP-005: saves through the checklist service as the user and answers the checklist', async () => {
|
||||
const { user, trip, paddle } = await fixture();
|
||||
const detail = await t.get(ToursService).getTour(String(trip.id), String(paddle.id));
|
||||
const check = { revision: 'r1', evaluated_at: '2026-10-23T08:00:00Z', entries: [], own: [] };
|
||||
const save = vi
|
||||
.spyOn(t.get(TourObstacleReviewsService), 'save')
|
||||
.mockResolvedValue({ ...detail, obstacle_check: check });
|
||||
const result = read(
|
||||
await mcp.decidePaddleObstacles({ tripId: trip.id, placeId: paddle.id, ops: [op] }, ctx(user.id)),
|
||||
);
|
||||
expect(save).toHaveBeenCalledWith(String(trip.id), String(paddle.id), { ops: [op] }, user.id);
|
||||
expect(result).toMatchObject({ tour: { place_id: paddle.id }, obstacle_check: check });
|
||||
});
|
||||
|
||||
it('TSPORT-MCP-006: one obstacle twice is refused before anything is saved', async () => {
|
||||
const { user, trip, paddle } = await fixture();
|
||||
const save = vi.spyOn(t.get(TourObstacleReviewsService), 'save');
|
||||
const result = await mcp.decidePaddleObstacles(
|
||||
{ tripId: trip.id, placeId: paddle.id, ops: [op, op] },
|
||||
ctx(user.id),
|
||||
);
|
||||
expect(read(result)).toEqual({ error: 'each obstacle at most once' });
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('restore_recorded_track', () => {
|
||||
it('TSPORT-MCP-007: takes a planned import back to its recording; one without a recording is refused', async () => {
|
||||
const { user, trip, hike, paddle } = await fixture();
|
||||
const recorded = JSON.stringify([
|
||||
[47, 11, 600],
|
||||
[47.005, 11.002, 650],
|
||||
[47.01, 11, 700],
|
||||
]);
|
||||
await updateRows(orm(), Tours, { place: hike.id }, { recorded_route_geometry: recorded });
|
||||
const result = read(await mcp.restoreRecordedTrack({ tripId: trip.id, placeId: hike.id }, ctx(user.id)));
|
||||
expect(result.tour).toMatchObject({ place_id: hike.id, has_waypoints: false });
|
||||
await expect(mcp.restoreRecordedTrack({ tripId: trip.id, placeId: paddle.id }, ctx(user.id))).rejects.toThrow(
|
||||
'This tour keeps no recorded track to go back to',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get_tour_trail_details', () => {
|
||||
it('TSPORT-MCP-008: lays the grades of the ways under the saved line onto it', async () => {
|
||||
const { user, trip, hike } = await fixture();
|
||||
const ways = vi.spyOn(t.get(RoadtripRouterService), 'wayIds').mockResolvedValue([
|
||||
{ wayId: 1, metres: 600 },
|
||||
{ wayId: 2, metres: 400 },
|
||||
]);
|
||||
vi.spyOn(t.get(TourTrailGradeService), 'grades').mockResolvedValue({
|
||||
ways: [
|
||||
{
|
||||
id: 1,
|
||||
mtb_scale: '1',
|
||||
mtb_scale_uphill: null,
|
||||
mtb_scale_imba: null,
|
||||
sac_scale: 'mountain_hiking',
|
||||
sac_scale_unclear: null,
|
||||
surface: 'gravel',
|
||||
highway: 'track',
|
||||
},
|
||||
],
|
||||
unanswered: [2],
|
||||
});
|
||||
const result = read(await mcp.getTourTrailDetails({ tripId: trip.id, placeId: hike.id }, ctx(user.id)));
|
||||
expect(ways).toHaveBeenCalledWith(user.id, [
|
||||
[47, 11],
|
||||
[47.01, 11],
|
||||
]);
|
||||
expect(result).toMatchObject({
|
||||
place_id: hike.id,
|
||||
tour_type: 'hike',
|
||||
sac: { highest: 2 },
|
||||
mtb: { highest: '1' },
|
||||
surface: [{ value: 'gravel', share: 0.6 }],
|
||||
highway: [{ value: 'track', share: 0.6 }],
|
||||
unknown_share: 0.4,
|
||||
ways_unanswered: 1,
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('TSPORT-MCP-009: a router that does not answer is said so; reading needs only the trip', async () => {
|
||||
const { user, outsider, trip, hike } = await fixture();
|
||||
vi.spyOn(t.get(RoadtripRouterService), 'wayIds').mockRejectedValue(new Error('down'));
|
||||
await expect(mcp.getTourTrailDetails({ tripId: trip.id, placeId: hike.id }, ctx(user.id))).rejects.toThrow(
|
||||
'The ways under this tour cannot be read right now',
|
||||
);
|
||||
expect(read(await mcp.getTourTrailDetails({ tripId: trip.id, placeId: hike.id }, ctx(outsider.id)))).toEqual({
|
||||
error: 'Trip not found or access denied.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('find_horse_routes', () => {
|
||||
it('TSPORT-MCP-010: answers each section without its line, its ends instead; a box too large is refused', async () => {
|
||||
const { user, trip } = await fixture();
|
||||
const list = vi.spyOn(t.get(TourHorseRoutesService), 'list').mockResolvedValue({
|
||||
routes: [
|
||||
{
|
||||
id: 'relation/5',
|
||||
name: 'Reitweg',
|
||||
ref: null,
|
||||
osmUrl: 'https://www.openstreetmap.org/relation/5',
|
||||
continues: false,
|
||||
sections: [
|
||||
{
|
||||
number: 1,
|
||||
geometry: [
|
||||
[47.5, 11.2],
|
||||
[47.51, 11.21],
|
||||
[47.52, 11.22],
|
||||
],
|
||||
length_m: 2600,
|
||||
access: [{ value: 'designated', length_m: 2600 }],
|
||||
signposted: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
source: 'openstreetmap',
|
||||
attribution: HORSE_ROUTES_ATTRIBUTION,
|
||||
});
|
||||
const result = read(await mcp.findHorseRoutes({ tripId: trip.id, bbox: '47.5,11.2,47.6,11.3' }, ctx(user.id)));
|
||||
expect(list).toHaveBeenCalledWith({ south: 47.5, west: 11.2, north: 47.6, east: 11.3 });
|
||||
expect(result.routes[0].sections[0]).toEqual({
|
||||
number: 1,
|
||||
length_m: 2600,
|
||||
access: [{ value: 'designated', length_m: 2600 }],
|
||||
signposted: null,
|
||||
from: [47.5, 11.2],
|
||||
to: [47.52, 11.22],
|
||||
});
|
||||
await expect(mcp.findHorseRoutes({ tripId: trip.id, bbox: '40,0,50,20' }, ctx(user.id))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the checks of the REST routes', () => {
|
||||
it('TSPORT-MCP-011: without place_edit no write runs', async () => {
|
||||
const { user, trip, ski, paddle, hike } = await fixture();
|
||||
const denied = vi.spyOn(t.get(McpToolGuardsService), 'hasTripPermission').mockResolvedValue(false);
|
||||
const results = [
|
||||
await mcp.classifySkiTrack({ tripId: trip.id, placeId: ski.id, ski_discipline: 'snowshoe' }, ctx(user.id)),
|
||||
await mcp.setPaddlePlan(
|
||||
{
|
||||
tripId: trip.id,
|
||||
placeId: paddle.id,
|
||||
paddle_speed_kmh: 5,
|
||||
portage_speed_kmh: null,
|
||||
track_marks: { portages: [], locks: [] },
|
||||
},
|
||||
ctx(user.id),
|
||||
),
|
||||
await mcp.decidePaddleObstacles(
|
||||
{
|
||||
tripId: trip.id,
|
||||
placeId: paddle.id,
|
||||
ops: [{ key: 'node/1', op: 'clear', decision: null, note: null, basis: null }],
|
||||
},
|
||||
ctx(user.id),
|
||||
),
|
||||
await mcp.restoreRecordedTrack({ tripId: trip.id, placeId: hike.id }, ctx(user.id)),
|
||||
];
|
||||
expect(results.every((result) => result.isError === true)).toBe(true);
|
||||
expect(denied).toHaveBeenCalledWith('place_edit', trip.id, user.id);
|
||||
expect((await findRow(orm(), Tours, { place: ski.id }))?.ski_discipline).toBe('alpine');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { reshapedHikePoints } from '../../../src/nest/tours/tour-reshape.helpers';
|
||||
import { trailDetailsOf } from '../../../src/nest/tours/tour-trail-details.helpers';
|
||||
import type { TourTrailGrade, TourWaypoint } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// TRAIL-DETAILS-001 to TRAIL-DETAILS-003, RESHAPE-HELPER-001 to RESHAPE-HELPER-003
|
||||
|
||||
const grade = (id: number, over: Partial<TourTrailGrade> = {}): TourTrailGrade => ({
|
||||
id,
|
||||
mtb_scale: null,
|
||||
mtb_scale_uphill: null,
|
||||
mtb_scale_imba: null,
|
||||
sac_scale: null,
|
||||
sac_scale_unclear: null,
|
||||
surface: null,
|
||||
highway: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('the trail details laid onto a saved line', () => {
|
||||
it('TRAIL-DETAILS-001: sums each value by the metres of its ways, scaled onto the saved length', () => {
|
||||
const ways = [
|
||||
{ wayId: 1, metres: 500 },
|
||||
{ wayId: 2, metres: 300 },
|
||||
{ wayId: null, metres: 200 },
|
||||
];
|
||||
const grades = new Map([
|
||||
[1, grade(1, { surface: 'asphalt', highway: 'residential' })],
|
||||
[2, grade(2, { surface: 'gravel', highway: 'track', sac_scale: 'hiking', mtb_scale: '2+' })],
|
||||
]);
|
||||
const details = trailDetailsOf(ways, grades, 2000);
|
||||
expect(details.length_m).toBe(2000);
|
||||
expect(details.surface).toEqual([
|
||||
{ value: 'asphalt', metres: 1000, share: 0.5 },
|
||||
{ value: 'gravel', metres: 600, share: 0.3 },
|
||||
]);
|
||||
expect(details.highway.map(({ value }) => value)).toEqual(['residential', 'track']);
|
||||
expect(details.sac).toMatchObject({ highest: 1, segments: [{ from_m: 1000, to_m: 1600, sac: 1 }] });
|
||||
expect(details.mtb).toEqual({ highest: '2+', values: [{ value: '2+', metres: 600, share: 0.3 }] });
|
||||
expect(details.unknown_share).toBe(0.2);
|
||||
});
|
||||
|
||||
it('TRAIL-DETAILS-002: the hardest mountain bike step wins, a plus above its plain step', () => {
|
||||
const ways = [1, 2, 3].map((wayId) => ({ wayId, metres: 100 }));
|
||||
const grades = new Map([
|
||||
[1, grade(1, { mtb_scale: '1' })],
|
||||
[2, grade(2, { mtb_scale: '2' })],
|
||||
[3, grade(3, { mtb_scale: '2+' })],
|
||||
]);
|
||||
expect(trailDetailsOf(ways, grades, 300).mtb.highest).toBe('2+');
|
||||
expect(trailDetailsOf(ways.slice(0, 2), grades, 200).mtb.highest).toBe('2');
|
||||
});
|
||||
|
||||
it('TRAIL-DETAILS-003: without ways the whole line is unknown, never easy', () => {
|
||||
const details = trailDetailsOf([], new Map(), 1000);
|
||||
expect(details).toMatchObject({ length_m: 0, surface: [], highway: [], unknown_share: 1, mtb: { highest: null } });
|
||||
expect(details.sac.highest).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const saved = (): TourWaypoint[] => [
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0, name: 'Car park', key: 'stop-carpark' },
|
||||
{ lat: 47.1, lng: 11, role: 'via', sequence: 1, poi_category: 'shelter', key: 'stop-hut0001' },
|
||||
{ lat: 47.2, lng: 11, role: 'end', sequence: 2, key: 'stop-summit1' },
|
||||
];
|
||||
|
||||
describe('a saved hike reshaped', () => {
|
||||
it('RESHAPE-HELPER-001: reverse turns the points round, each with its name and kind of place', () => {
|
||||
expect(reshapedHikePoints(saved(), 'reverse')).toEqual({
|
||||
points: [
|
||||
{ lat: 47.2, lng: 11 },
|
||||
{ lat: 47.1, lng: 11, poi_category: 'shelter' },
|
||||
{ lat: 47, lng: 11, name: 'Car park' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('RESHAPE-HELPER-002: close_loop adds the start again at the end, once', () => {
|
||||
const closed = reshapedHikePoints(saved(), 'close_loop');
|
||||
expect(closed).toMatchObject({ points: [{ name: 'Car park' }, {}, {}, { lat: 47, lng: 11 }] });
|
||||
const loop = [...saved(), { lat: 47, lng: 11, role: 'end' as const, sequence: 3 }];
|
||||
expect(reshapedHikePoints(loop, 'close_loop')).toEqual({ refused: 'This hike already ends where it starts.' });
|
||||
});
|
||||
|
||||
it('RESHAPE-HELPER-003: a recorded track and a hike in stages are refused with why', () => {
|
||||
expect(reshapedHikePoints([], 'reverse')).toMatchObject({ refused: expect.stringContaining('recorded track') });
|
||||
const staged = saved().map((point, index) => (index === 1 ? { ...point, stage_end: true } : point));
|
||||
expect(reshapedHikePoints(staged, 'reverse')).toMatchObject({ refused: expect.stringContaining('stages') });
|
||||
expect(reshapedHikePoints(staged, 'close_loop')).toMatchObject({
|
||||
points: expect.arrayContaining([expect.objectContaining({ stage_end: true })]),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -170,6 +170,8 @@ export const ASSISTANT_CHANGE_LABEL_KEYS = [
|
||||
// The recorded track a tour was done on; the part of the packing list a tour points at.
|
||||
'track',
|
||||
'packing',
|
||||
// The minutes of breaks a tour plans on top of its moving time.
|
||||
'breaks',
|
||||
// A new order of a day's places or of the days, as the names in that order.
|
||||
'order',
|
||||
// An argument of a tool without a card of its own; the row names it in `field`.
|
||||
|
||||
@@ -27,6 +27,16 @@ export const TOUR_VERB_KEYS = [
|
||||
// A tour pointed at its trip's packing list, or let go of it.
|
||||
'linkPacking',
|
||||
'unlinkPacking',
|
||||
// A saved tour turned round or taken back to its start, copied, or its line drawn in another colour.
|
||||
'reverseTour',
|
||||
'closeLoop',
|
||||
'copyTour',
|
||||
'setRouteColor',
|
||||
// The writes of a ski or paddle tour of its own, and a planned import taken back to its recording.
|
||||
'classifySki',
|
||||
'setPaddlePlan',
|
||||
'decideObstacles',
|
||||
'restoreTrack',
|
||||
] as const;
|
||||
/** 'tour' is a core entity already. */
|
||||
export const TOUR_APPLIED_ENTITIES = [] as const;
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
bookTypesetFontSchema,
|
||||
} from './book-op-enums';
|
||||
import { bookElementIdSchema, bookGridIdSchema, bookSpreadIdSchema } from './book-op-ids';
|
||||
import { bookPaintInSchema } from './book-paint.schema';
|
||||
import {
|
||||
MAX_BOOK_COUNTRIES,
|
||||
bookBadgeElementSchema,
|
||||
@@ -112,8 +111,6 @@ const SHAPE_PROPS = {
|
||||
...COMMON,
|
||||
fill: shape.fill.unwrap().optional(),
|
||||
gradient: bookShapeGradientSchema.optional(),
|
||||
/** Overrides fill and gradient when given; null takes the fill away. A plain fill or gradient clears it. */
|
||||
paint: bookPaintInSchema.nullable().optional(),
|
||||
stroke: shape.stroke.unwrap().optional(),
|
||||
strokeWidth: z.number().finite().min(0).max(20).optional(),
|
||||
strokeStyle: bookStrokeStyleSchema.optional(),
|
||||
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
unlockedElement,
|
||||
type BookOpsContext,
|
||||
} from './book-ops-core';
|
||||
import { withShapePaint } from './book-paint';
|
||||
import type { BookPaint } from './book-paint.schema';
|
||||
import {
|
||||
bookBadgeValue,
|
||||
bookCountryMark,
|
||||
@@ -35,7 +33,6 @@ import type {
|
||||
BookFrame,
|
||||
BookPageSetup,
|
||||
BookPhotoElement,
|
||||
BookShapeElement,
|
||||
BookSpread,
|
||||
BookTextElement,
|
||||
} from './book.schema';
|
||||
@@ -147,24 +144,10 @@ function withProps(el: BookElement, props: Record<string, unknown>): BookElement
|
||||
const next: Record<string, unknown> = { ...el };
|
||||
for (const key of keys) {
|
||||
const value = props[key];
|
||||
if (value === undefined || key === 'paint') continue;
|
||||
if (value === undefined) continue;
|
||||
next[key] = (key === 'radius' || key === 'strokeWidth') && typeof value === 'number' ? round2(value) : value;
|
||||
}
|
||||
const made = next as unknown as BookElement;
|
||||
return made.kind === 'shape' ? withPaintProps(made, props) : made;
|
||||
}
|
||||
|
||||
/**
|
||||
* A shape's paint, kept in step with its fill: a paint given is written
|
||||
* through withShapePaint, and a plain fill or fade given without one replaces
|
||||
* the paint the shape had, since the newer instruction is the one meant.
|
||||
*/
|
||||
function withPaintProps(el: BookShapeElement, props: Record<string, unknown>): BookShapeElement {
|
||||
const paint = props.paint as BookPaint | null | undefined;
|
||||
if (paint !== undefined) return withShapePaint(el, paint);
|
||||
if (props.fill === undefined && props.gradient === undefined) return el;
|
||||
const { paint: _replaced, ...rest } = el;
|
||||
return rest;
|
||||
return next as unknown as BookElement;
|
||||
}
|
||||
|
||||
// ── add ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
bookPhotoMaskSchema,
|
||||
} from './book-op-enums';
|
||||
import { bookElementIdSchema, bookGridIdSchema, bookSpreadIdSchema } from './book-op-ids';
|
||||
import { bookPaintInSchema } from './book-paint.schema';
|
||||
import {
|
||||
MAX_BOOK_TITLE,
|
||||
MAX_SPREADS,
|
||||
@@ -189,14 +188,8 @@ export const bookPageOpSchema = z.discriminatedUnion('op', [
|
||||
elementIds: z.array(bookElementIdSchema).min(1).max(BOOK_OP_LIMITS.removePerOp),
|
||||
park: z.boolean().default(true),
|
||||
}),
|
||||
/** The page colour or paint; null is the paper. Exactly one of the two. */
|
||||
z
|
||||
.strictObject({
|
||||
op: z.literal('background'),
|
||||
color: bookSpreadSchema.shape.background.unwrap().optional(),
|
||||
paint: bookPaintInSchema.nullable().optional(),
|
||||
})
|
||||
.refine((v) => (v.color === undefined) !== (v.paint === undefined), { message: 'give the color or the paint' }),
|
||||
/** The page colour; null is the paper. */
|
||||
z.strictObject({ op: z.literal('background'), color: bookSpreadSchema.shape.background.unwrap() }),
|
||||
...BOOK_ELEMENT_OP_SCHEMAS,
|
||||
]);
|
||||
export type BookPageOp = z.infer<typeof bookPageOpSchema>;
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
} from './book-ops-core';
|
||||
import { applyElementOp, autoRelayout } from './book-ops-elements';
|
||||
import { BOOK_OP_LIMITS, bookPageOpSchema, type BookPageOp } from './book-ops.schema';
|
||||
import { withSpreadPaint } from './book-paint';
|
||||
import { applySavedBookLayout, bookLayoutRole } from './book-saved-layouts';
|
||||
import {
|
||||
MAX_SPREAD_ELEMENTS,
|
||||
@@ -119,10 +118,7 @@ function applyOne(spread: BookSpread, op: BookPageOp, env: Env): BookSpread {
|
||||
case 'remove':
|
||||
return opRemove(spread, op, env);
|
||||
case 'background':
|
||||
return withSpreadPaint(
|
||||
spread,
|
||||
op.paint !== undefined ? op.paint : op.color ? { type: 'solid', color: op.color } : null,
|
||||
);
|
||||
return { ...spread, background: op.color };
|
||||
default:
|
||||
return applyElementOp(spread, op, env);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -498,11 +498,20 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.countTour': 'احتسابها في الإحصاءات مجددًا',
|
||||
'assistant.change.verb.linkPacking': 'ربط قائمة التعبئة',
|
||||
'assistant.change.verb.unlinkPacking': 'فك ربط قائمة التعبئة',
|
||||
'assistant.change.verb.reverseTour': 'عكس اتجاه الجولة',
|
||||
'assistant.change.verb.closeLoop': 'إغلاق الحلقة',
|
||||
'assistant.change.verb.copyTour': 'نسخ الجولة',
|
||||
'assistant.change.verb.setRouteColor': 'تغيير لون المسار',
|
||||
'assistant.change.verb.classifySki': 'تغيير نوع التزلج',
|
||||
'assistant.change.verb.setPaddlePlan': 'تغيير خطة التجديف',
|
||||
'assistant.change.verb.decideObstacles': 'اتخاذ قرار بشأن العوائق',
|
||||
'assistant.change.verb.restoreTrack': 'العودة إلى المسار المسجل',
|
||||
'assistant.change.verb.importTour': 'إضافة جولة من ملف',
|
||||
'assistant.change.label.field': 'القيمة',
|
||||
'assistant.change.label.difficulty': 'أقصى مستوى لصعوبة الدرب',
|
||||
'assistant.change.label.track': 'مسار مسجل',
|
||||
'assistant.change.label.packing': 'قائمة التعبئة',
|
||||
'assistant.change.label.breaks': 'الاستراحات',
|
||||
'assistant.domain.tours': 'الجولات',
|
||||
'assistant.domain.roadtrip': 'رحلة برية',
|
||||
'assistant.domain.journey': 'يوميات السفر',
|
||||
|
||||
@@ -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;
|
||||
@@ -421,11 +421,20 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.countTour': 'Yenidən statistikaya daxil et',
|
||||
'assistant.change.verb.linkPacking': 'Əşya siyahısını bağla',
|
||||
'assistant.change.verb.unlinkPacking': 'Əşya siyahısını ayır',
|
||||
'assistant.change.verb.reverseTour': 'Turu tərsinə çevir',
|
||||
'assistant.change.verb.closeLoop': 'Dairəni bağla',
|
||||
'assistant.change.verb.copyTour': 'Turu kopyala',
|
||||
'assistant.change.verb.setRouteColor': 'Marşrutun rəngini dəyiş',
|
||||
'assistant.change.verb.classifySki': 'Xizək növünü dəyiş',
|
||||
'assistant.change.verb.setPaddlePlan': 'Avarçəkmə planını dəyiş',
|
||||
'assistant.change.verb.decideObstacles': 'Maneələr barədə qərar ver',
|
||||
'assistant.change.verb.restoreTrack': 'Yazılmış treke qayıt',
|
||||
'assistant.change.verb.importTour': 'Fayldan tur əlavə et',
|
||||
'assistant.change.label.field': 'Dəyər',
|
||||
'assistant.change.label.difficulty': 'Maksimum cığır çətinliyi',
|
||||
'assistant.change.label.track': 'Qeydə alınmış marşrut',
|
||||
'assistant.change.label.packing': 'Əşya siyahısı',
|
||||
'assistant.change.label.breaks': 'Fasilələr',
|
||||
'assistant.domain.tours': 'Turlar',
|
||||
'assistant.domain.roadtrip': 'Avtosəyahət',
|
||||
'assistant.domain.journey': 'Səyahət gündəliyi',
|
||||
|
||||
@@ -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;
|
||||
@@ -421,11 +421,20 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.countTour': 'Contar nas estatísticas de novo',
|
||||
'assistant.change.verb.linkPacking': 'Vincular lista de bagagem',
|
||||
'assistant.change.verb.unlinkPacking': 'Desvincular lista de bagagem',
|
||||
'assistant.change.verb.reverseTour': 'Inverter passeio',
|
||||
'assistant.change.verb.closeLoop': 'Fechar o circuito',
|
||||
'assistant.change.verb.copyTour': 'Copiar passeio',
|
||||
'assistant.change.verb.setRouteColor': 'Alterar cor da rota',
|
||||
'assistant.change.verb.classifySki': 'Alterar modalidade de esqui',
|
||||
'assistant.change.verb.setPaddlePlan': 'Alterar plano de remada',
|
||||
'assistant.change.verb.decideObstacles': 'Decidir sobre obstáculos',
|
||||
'assistant.change.verb.restoreTrack': 'Voltar ao trajeto gravado',
|
||||
'assistant.change.verb.importTour': 'Adicionar passeio a partir de arquivo',
|
||||
'assistant.change.label.field': 'Valor',
|
||||
'assistant.change.label.difficulty': 'Dificuldade máxima da trilha',
|
||||
'assistant.change.label.track': 'Trilha gravada',
|
||||
'assistant.change.label.packing': 'Lista de bagagem',
|
||||
'assistant.change.label.breaks': 'Pausas',
|
||||
'assistant.domain.tours': 'Passeios',
|
||||
'assistant.domain.roadtrip': 'Viagem de carro',
|
||||
'assistant.domain.journey': 'Jornada',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -494,8 +494,12 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotation': 'Rotação',
|
||||
'journey.studio.opacity': 'Opacidade',
|
||||
'journey.studio.fill': 'Preenchimento',
|
||||
'journey.studio.fillOn': 'Preenchido',
|
||||
'journey.studio.stroke': 'Contorno',
|
||||
'journey.studio.strokeWidth': 'Espessura',
|
||||
'journey.studio.gradient': 'Degradê',
|
||||
'journey.studio.gradientDown': 'Para baixo',
|
||||
'journey.studio.gradientUp': 'Para cima',
|
||||
'journey.studio.showIcons': 'Ícones',
|
||||
'journey.studio.mapFit': 'Área',
|
||||
'journey.studio.mapPadding': 'Espaço',
|
||||
|
||||
@@ -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': 'Tipo de preenchimento',
|
||||
'journey.studio.paint.none': 'Nenhum',
|
||||
'journey.studio.paint.solid': 'Sólido',
|
||||
'journey.studio.paint.gradient': 'Gradiente',
|
||||
'journey.studio.paint.pattern': 'Padrão',
|
||||
'journey.studio.paint.linear': 'Linear', // same-as-en
|
||||
'journey.studio.paint.radial': 'Radial', // same-as-en
|
||||
'journey.studio.paint.reverse': 'Inverter o gradiente',
|
||||
'journey.studio.paint.addStopHint': 'Clique na barra para adicionar uma parada de cor',
|
||||
'journey.studio.paint.stopAt': 'Parada em {pos}%',
|
||||
'journey.studio.paint.removeStop': 'Remover parada',
|
||||
'journey.studio.paint.angle': 'Ângulo',
|
||||
'journey.studio.paint.centre': 'Centro',
|
||||
'journey.studio.paint.radius': 'Raio',
|
||||
'journey.studio.paint.ink': 'Cor do padrão',
|
||||
'journey.studio.paint.ground': 'Cor de fundo',
|
||||
'journey.studio.paint.seeThrough': 'Transparente',
|
||||
'journey.studio.paint.scale': 'Tamanho',
|
||||
'journey.studio.paint.opacity': 'Opacidade',
|
||||
'journey.studio.paint.bookColours': 'Cores deste livro',
|
||||
'journey.studio.paint.photoColours': 'Cores das fotos',
|
||||
'journey.studio.paint.recentColours': 'Cores recentes',
|
||||
'journey.studio.paint.background': 'Fundo da página',
|
||||
'journey.studio.paint.paper': 'Papel',
|
||||
'journey.studio.paint.applyAll': 'Aplicar a todas as páginas',
|
||||
'journey.studio.paint.appliedAll': 'Aplicado a todas as páginas',
|
||||
'journey.studio.paint.photoBackground': 'Foto como fundo',
|
||||
'journey.studio.paint.photoBackgroundHint':
|
||||
'Coloca a foto sobre a página inteira, sangria incluída, travada ao fundo. Escolha de novo para tirá-la.',
|
||||
'journey.studio.paint.removePhotoBackground': 'Remover a foto de fundo',
|
||||
'journey.studio.paint.noPhotos': 'Adicione fotos à viagem para usar uma como fundo.',
|
||||
'journey.studio.pattern.checker': 'Xadrez',
|
||||
'journey.studio.pattern.gingham': 'Vichy',
|
||||
'journey.studio.pattern.stripes': 'Listras',
|
||||
'journey.studio.pattern.pinstripe': 'Risca de giz',
|
||||
'journey.studio.pattern.dots': 'Pontos',
|
||||
'journey.studio.pattern.polka': 'Bolinhas',
|
||||
'journey.studio.pattern.zigzag': 'Zigue-zague',
|
||||
'journey.studio.pattern.waves': 'Ondas',
|
||||
'journey.studio.pattern.grid': 'Grade',
|
||||
'journey.studio.pattern.scales': 'Escamas',
|
||||
'journey.studio.pattern.sunburst': 'Raios de sol',
|
||||
'journey.studio.pattern.confetti': 'Confete',
|
||||
};
|
||||
export default studioPaint;
|
||||
@@ -424,11 +424,20 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.countTour': 'Torna a comptar a les estadístiques',
|
||||
'assistant.change.verb.linkPacking': 'Enllaça la llista d’equipatge',
|
||||
'assistant.change.verb.unlinkPacking': 'Desenllaça la llista d’equipatge',
|
||||
'assistant.change.verb.reverseTour': "Invertir l'excursió",
|
||||
'assistant.change.verb.closeLoop': 'Tancar el circuit',
|
||||
'assistant.change.verb.copyTour': "Copiar l'excursió",
|
||||
'assistant.change.verb.setRouteColor': 'Canviar el color de la ruta',
|
||||
'assistant.change.verb.classifySki': "Canviar la modalitat d'esquí",
|
||||
'assistant.change.verb.setPaddlePlan': 'Canviar el pla de rem',
|
||||
'assistant.change.verb.decideObstacles': 'Decidir sobre els obstacles',
|
||||
'assistant.change.verb.restoreTrack': 'Tornar a la traça gravada',
|
||||
'assistant.change.verb.importTour': 'Afegeix una excursió des d’un fitxer',
|
||||
'assistant.change.label.field': 'Valor',
|
||||
'assistant.change.label.difficulty': 'Dificultat màxima del sender',
|
||||
'assistant.change.label.track': 'Traç enregistrat',
|
||||
'assistant.change.label.packing': 'Llista d’equipatge',
|
||||
'assistant.change.label.breaks': 'Pauses',
|
||||
'assistant.domain.tours': 'Rutes',
|
||||
'assistant.domain.roadtrip': 'Viatge per carretera',
|
||||
'assistant.domain.journey': 'Diari de viatge',
|
||||
|
||||
@@ -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';
|
||||
@@ -116,7 +115,6 @@ const locale = {
|
||||
...todo,
|
||||
...notif,
|
||||
...journey,
|
||||
...studioPaint,
|
||||
...oauth,
|
||||
...system_notice,
|
||||
...transport,
|
||||
|
||||
@@ -494,8 +494,12 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotation': 'Rotació',
|
||||
'journey.studio.opacity': 'Opacitat',
|
||||
'journey.studio.fill': 'Emplenat',
|
||||
'journey.studio.fillOn': 'Ple',
|
||||
'journey.studio.stroke': 'Contorn',
|
||||
'journey.studio.strokeWidth': 'Gruix',
|
||||
'journey.studio.gradient': 'Degradat',
|
||||
'journey.studio.gradientDown': 'Cap avall',
|
||||
'journey.studio.gradientUp': 'Cap amunt',
|
||||
'journey.studio.showIcons': 'Icones',
|
||||
'journey.studio.mapFit': 'Ajusta a',
|
||||
'journey.studio.mapPadding': 'Espai',
|
||||
|
||||
@@ -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': 'Tipus d’emplenament',
|
||||
'journey.studio.paint.none': 'Cap',
|
||||
'journey.studio.paint.solid': 'Sòlid',
|
||||
'journey.studio.paint.gradient': 'Degradat',
|
||||
'journey.studio.paint.pattern': 'Patró',
|
||||
'journey.studio.paint.linear': 'Lineal',
|
||||
'journey.studio.paint.radial': 'Radial', // same-as-en
|
||||
'journey.studio.paint.reverse': 'Inverteix el degradat',
|
||||
'journey.studio.paint.addStopHint': 'Fes clic a la barra per afegir una parada de color',
|
||||
'journey.studio.paint.stopAt': 'Parada al {pos} %',
|
||||
'journey.studio.paint.removeStop': 'Treu la parada',
|
||||
'journey.studio.paint.angle': 'Angle', // same-as-en
|
||||
'journey.studio.paint.centre': 'Centre', // same-as-en
|
||||
'journey.studio.paint.radius': 'Radi',
|
||||
'journey.studio.paint.ink': 'Color del patró',
|
||||
'journey.studio.paint.ground': 'Color de fons',
|
||||
'journey.studio.paint.seeThrough': 'Transparent',
|
||||
'journey.studio.paint.scale': 'Mida',
|
||||
'journey.studio.paint.opacity': 'Opacitat',
|
||||
'journey.studio.paint.bookColours': 'Colors d’aquest llibre',
|
||||
'journey.studio.paint.photoColours': 'Colors de les fotos',
|
||||
'journey.studio.paint.recentColours': 'Colors recents',
|
||||
'journey.studio.paint.background': 'Fons de pàgina',
|
||||
'journey.studio.paint.paper': 'Paper', // same-as-en
|
||||
'journey.studio.paint.applyAll': 'Aplica a totes les pàgines',
|
||||
'journey.studio.paint.appliedAll': 'Aplicat a totes les pàgines',
|
||||
'journey.studio.paint.photoBackground': 'Foto de fons',
|
||||
'journey.studio.paint.photoBackgroundHint':
|
||||
'Posa la foto sobre tota la pàgina, sagnat inclòs, bloquejada al fons. Tria-la un altre cop per treure-la.',
|
||||
'journey.studio.paint.removePhotoBackground': 'Treu la foto de fons',
|
||||
'journey.studio.paint.noPhotos': 'Afegeix fotos al viatge per fer-ne servir una de fons.',
|
||||
'journey.studio.pattern.checker': 'Escacs',
|
||||
'journey.studio.pattern.gingham': 'Vichy',
|
||||
'journey.studio.pattern.stripes': 'Ratlles',
|
||||
'journey.studio.pattern.pinstripe': 'Ratlla diplomàtica',
|
||||
'journey.studio.pattern.dots': 'Punts',
|
||||
'journey.studio.pattern.polka': 'Pics',
|
||||
'journey.studio.pattern.zigzag': 'Ziga-zaga',
|
||||
'journey.studio.pattern.waves': 'Ones',
|
||||
'journey.studio.pattern.grid': 'Quadrícula',
|
||||
'journey.studio.pattern.scales': 'Escates',
|
||||
'journey.studio.pattern.sunburst': 'Raigs',
|
||||
'journey.studio.pattern.confetti': 'Confeti',
|
||||
};
|
||||
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