fix(studio): the travel panel builds its tiles with the book engine's factories

Figures, countries, maps and marks placed from the panel are the elements the
assistant adds, built by the shared factories; the panel keeps only their
placement. A trip map no longer carries the whole journey's trail, switching a
map's scope re-cuts it the way the engine does and clears the roads of the old
legs, and switching back to the whole journey restores the trail.
This commit is contained in:
Maurice
2026-10-23 01:45:16 +00:00
parent 0095f2105a
commit a11c8bfaa7
9 changed files with 418 additions and 210 deletions
@@ -52,6 +52,7 @@ export function StudioInspector({
spreadIndex,
page,
stats,
path,
source,
setPageNumbers,
t,
@@ -62,6 +63,8 @@ export function StudioInspector({
setPageNumbers: (patch: Partial<BookPageNumbers>) => void
/** Live journey figures, so a travel element can be brought up to date. */
stats: JourneyStats | null
/** The journey's recorded way, thinned, so a map of the whole journey can be given it again. */
path: [number, number][][]
/** The journal itself, so a bound mark can be re-set from it. */
source: JourneySource
t: (k: string) => string
@@ -647,7 +650,7 @@ export function StudioInspector({
</>
)}
<TravelInspector el={el} stats={stats} set={set} t={t} locale={locale} />
<TravelInspector el={el} stats={stats} path={path} set={set} t={t} locale={locale} />
{/*
Where it sits, folded away.
+5 -20
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router'
import {
ArrowLeft, BookOpen, Check, ChevronDown, ClipboardPaste, Download, Maximize2, MessageSquare, Minus, Plus,
ArrowLeft, BookOpen, Check, ChevronDown, ClipboardPaste, Download, Maximize2, Minus, Plus,
Redo2, Sparkles, Undo2,
} from 'lucide-react'
import { useJourneyStudio } from '../../pages/journeyStudio/useJourneyStudio'
@@ -21,7 +21,7 @@ import { PeerBadges } from './PeerBadges'
import { TrimField } from './TrimField'
import { Tooltip } from '../shared/Tooltip'
import { useStudioStore } from '../../store/studioStore'
import StudioAssistantPanel, { StudioPageStage } from './StudioAssistantPanel'
import { StudioAssistantColumn, StudioAssistantToggle, StudioPageStage } from './StudioAssistantPanel'
import { useStudioAssistant, type StudioAssistant } from './useStudioAssistant'
import '../../styles/dashboard.css'
import '../../styles/studio.css'
@@ -128,12 +128,13 @@ export default function StudioShell() {
spreadIndex={s.activeSpread}
page={s.page}
stats={s.stats}
path={s.path}
source={s.source}
setPageNumbers={s.setPageNumbers}
t={s.t}
locale={s.locale}
/>
<AssistantColumn s={s} assistant={assistant} />
<StudioAssistantColumn assistant={assistant} title={s.journey?.title} />
</div>
<StudioPageStage assistant={assistant} />
@@ -153,12 +154,6 @@ export default function StudioShell() {
type Studio = ReturnType<typeof useJourneyStudio>
/** The chat column at the end of the body, while it is open. */
function AssistantColumn({ s, assistant }: { s: Studio; assistant: StudioAssistant }) {
if (!assistant.open) return null
return <StudioAssistantPanel assistant={assistant} title={s.journey?.title || s.t('journey.title')} />
}
function StudioBar({
s, bookView, setBookView, onExport, assistant,
}: {
@@ -238,17 +233,7 @@ function StudioBar({
<AutoLayoutButton s={s} />
{/* The book's assistant, as a column beside the page; only where it can run for this user. */}
{assistant.available && (
<button type="button"
className={`st-tool ${assistant.open ? 'is-on' : ''}`}
onClick={assistant.toggle}
aria-pressed={assistant.open}
title={s.t('journey.studio.assistant.toggleHint')}
>
<MessageSquare size={14} />
<span className="st-tool-label">{s.t('journey.studio.assistant.toggle')}</span>
</button>
)}
<StudioAssistantToggle assistant={assistant} />
<button type="button"
className="st-tool is-primary"
onClick={onExport}
@@ -3,7 +3,7 @@ import type {
BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement,
BookMetric, BookStatsElement, JourneyStats,
} from '@trek/shared'
import { BOOK_METRICS, isStale, refreshPatch, routeFor } from '@trek/shared'
import { BOOK_METRICS, MAX_PATH_POINTS, MAX_PATH_SEGMENTS, isStale, refreshPatch } from '@trek/shared'
import { Swatches } from './StudioSwatches'
import { FontButtons, WeightLine } from './StudioTypeControls'
import { sourceIdOf, useMapSources } from './mapSources'
@@ -25,6 +25,8 @@ export interface TravelInspectorProps {
el: BookElement
/** Live figures, for the refresh button. Null while loading or on failure. */
stats: JourneyStats | null
/** The journey's recorded way, thinned, which a map switched back to the whole journey draws again. */
path: [number, number][][]
set: (patch: Partial<BookElement>) => void
t: (k: string) => string
locale: string
@@ -36,7 +38,7 @@ interface Props<T> {
t: (k: string) => string
}
export function TravelInspector({ el, stats, set, t }: TravelInspectorProps) {
export function TravelInspector({ el, stats, path, set, t }: TravelInspectorProps) {
if (
el.kind !== 'map' && el.kind !== 'stats' && el.kind !== 'countries'
&& el.kind !== 'badge' && el.kind !== 'list'
@@ -64,7 +66,7 @@ export function TravelInspector({ el, stats, set, t }: TravelInspectorProps) {
</div>
)}
{el.kind === 'map' && <MapProps el={el} set={set} t={t} stats={stats} />}
{el.kind === 'map' && <MapProps el={el} set={set} t={t} stats={stats} journeyPath={path} />}
{el.kind === 'stats' && <StatsProps el={el} set={set} t={t} />}
{el.kind === 'countries' && <CountriesProps el={el} set={set} t={t} />}
{el.kind === 'badge' && <BadgeProps el={el} set={set} t={t} />}
@@ -188,7 +190,13 @@ function ListProps({ el, set, t }: Props<BookListElement>) {
)
}
function MapProps({ el, set, t, stats }: Props<BookMapElement> & { stats: JourneyStats | null }) {
/** A map's panel reads the journey too: its live figures, and its recorded way for a map of all of it. */
type MapPropsInput = Props<BookMapElement> & {
stats: JourneyStats | null
journeyPath: [number, number][][]
}
function MapProps({ el, set, t, stats, journeyPath }: MapPropsInput) {
const cartoKey = useCartoApiKey()
const [roadState, setRoadState] = useState<'idle' | 'busy'>('idle')
const [roadDone, setRoadDone] = useState(0)
@@ -248,15 +256,26 @@ function MapProps({ el, set, t, stats }: Props<BookMapElement> & { stats: Journe
})),
]}
onPick={id => {
if (!stats) return
const tripId = id === 0 ? null : id
const route = routeFor(stats, tripId)
// The scope it already has: a re-cut would only drop the roads loaded for these legs.
if (!stats || tripId === (el.tripId ?? null)) return
/*
* Re-cut by the engine's own refresh at the new scope, so the
* stops and countries are what refresh_travel and a newly placed
* map of that scope hold. The recorded way is the whole
* journey's: a trip map draws none, as the engine places none,
* and a map of the whole journey gets it back from the journey
* at the engine's caps, so a switch there and back loses
* nothing. The roads go with the old legs, since entry i is the
* way from stop i to stop i + 1 of the route that is gone.
*/
set({
...refreshPatch({ ...el, tripId }, stats),
tripId,
countries: tripId == null
? stats.countries.map(c => c.code)
: [...new Set(route.map(p => p.country).filter((c): c is string => !!c))],
points: route.map(p => ({ lat: p.lat, lng: p.lng, label: p.label, photoId: p.photoId ?? null })),
path: tripId == null
? journeyPath.slice(0, MAX_PATH_SEGMENTS).map(seg => seg.slice(0, MAX_PATH_POINTS))
: [],
roads: [],
} as Partial<BookElement>)
}}
/>
@@ -1,13 +1,16 @@
import { useEffect, useRef, useState } from 'react'
import { Check, RouteOff } from 'lucide-react'
import type { BookElement, BookMetric, BookPageSetup, JourneyStats } from '@trek/shared'
import { BOOK_METRICS, bookElementId as uid } from '@trek/shared'
import type {
BookBadgeVariant, BookElement, BookMapScope, BookMetric, BookPageSetup, BookTravelContext, JourneyStats,
} from '@trek/shared'
import {
BOOK_METRICS, bookBadgeValue, bookCountryName, bookElementId as uid, bookTravelBadge, bookTravelCountries,
bookTravelMap, bookTravelSize, bookTravelStats,
} from '@trek/shared'
import { useStudioStore } from '../../store/studioStore'
import { PanelHead } from './StudioPanelHead'
import { Section } from './StudioControls'
import { TravelPreview } from './TravelPreview'
import { formatBookCoords, routeFor } from '@trek/shared'
import { useMapSources } from './mapSources'
import { fetchRoads } from './roadRoute'
/**
@@ -61,10 +64,19 @@ type StopRow = {
* the section scannable. The tile measures its own width — see TravelPreview.
*/
/** Kilometres, grouped for the reader's locale. */
function formatDistance(metres: number, locale: string): string {
return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(metres / 1000)} km`
}
/** The words a mark carries, as the book engine resolves them. */
type BadgeValue = NonNullable<ReturnType<typeof bookBadgeValue>>
/*
* The two imagery maps, satellite first: the recommendation should not be the
* second thing read. Their tiles and credit are the book engine's own
* (BOOK_MAP_SOURCES), so a satellite map placed here is the one the assistant
* places.
*/
const IMAGERY = [
{ id: 'satellite', labelKey: 'journey.studio.mapSourceSatellite' },
{ id: 'relief', labelKey: 'journey.studio.mapSourceRelief' },
] as const
function Card({ label, onClick, children, recommended = false, title }: {
label: string
@@ -143,23 +155,6 @@ export function StudioTravelPanel({
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
}
const mapSide = Math.min(page.pageWidth, page.pageHeight) * 0.72
/*
* The imagery this instance can reach, so the relief card below places a map
* that already has its template and its credit rather than an empty one the
* user then has to point at a source.
*
* Asked for above the empty-state return, and not beside the card that reads
* it: a journey whose figures are still loading renders this panel once with
* no stats at all, and a hook that only runs on the second render is a hook
* count that changes between them.
*/
const sources = useMapSources(
{ x: 0, y: 0, w: mapSide, h: mapSide * 0.78 },
(stats?.points ?? []).map(p => ({ lat: p.lat, lng: p.lng })),
)
if (!stats) {
return (
<>
@@ -171,11 +166,25 @@ export function StudioTravelPanel({
)
}
const base = {
rotation: 0, opacity: 1, locked: false,
font: 'sans' as const, color: '#1a1a1a', accent: '#111111', textScale: 1, weight: 700 as const,
stale: false,
/*
* What the book engine's factories read: the journey, its recorded way and
* the words in the reader's language. Every tile below is built by the
* factory the engine's `add` calls. Where it lands is the panel's own:
* centred across the spread, where `add` takes the right page. Two words are
* still the panel's too, so a mark placed here and one the assistant adds
* can read differently: the country mark carries the name in `text`, where
* the engine's value has it in `sub`, and the day mark says `dayWord`, where
* the engine says `day` (the two differ in ja, ko, zh and zh-TW).
*/
const travel: BookTravelContext = {
locale, stats, path, entries: [],
labels: {
day: t('journey.studio.dayWord'), stations: t('journey.studio.stations'), summary: t('journey.studio.summary'),
countries: t('journey.studio.countries'), distance: t('journey.studio.metric.distance'),
pros: t('journey.editor.pros'), cons: t('journey.editor.cons'),
},
}
const at = (size: { w: number; h: number }) => centre(size.w, size.h)
/**
* Country names in the reader's language, not the server's.
@@ -185,29 +194,13 @@ export function StudioTravelPanel({
* a German book says "Island" and an English one says "Iceland" — from one set
* of codes, with no translation table to keep in step.
*/
const countryName = (code: string): string => {
try {
const display = new Intl.DisplayNames([locale], { type: 'region' })
return display.of(code.toUpperCase()) || code.toUpperCase()
} catch {
return stats.countries.find(c => c.code === code)?.name || code.toUpperCase()
}
}
const values: Record<string, number> = {
distance: stats.distance,
days: stats.days,
steps: stats.steps,
photos: stats.photos,
countries: stats.countries.length,
places: stats.places,
furthest: stats.furthest,
}
const countryName = (code: string): string => bookCountryName(code, locale, stats)
/*
* Each builder returns the finished element. The tile renders it and the
* click places it, so the preview and the result are the same object — there
* is no second description of what a card means.
* click places it, so the preview and the result are the same object, and
* the object is the engine's, so there is no second description of what a
* card means.
*/
/** The linked trips that actually put stops on the route. */
const tripsWithRoute = (stats.trips ?? []).filter(t => t.points > 0)
@@ -262,11 +255,6 @@ export function StudioTravelPanel({
})
}
/* Satellite first: the recommendation should not be the second thing read. */
const imagery = sources
.filter(s => s.id === 'relief' || s.id === 'satellite')
.sort((a, b) => (a.id === 'satellite' ? -1 : b.id === 'satellite' ? 1 : 0))
/**
* One country, as the entry a list would have made of it.
*
@@ -280,90 +268,28 @@ export function StudioTravelPanel({
* The name is editable afterwards, so numbering them "1. Germany",
* "2. Netherlands" is typing rather than a feature.
*/
const countryEl = (code: string): BookElement => ({
...base, id: uid('co'), kind: 'countries',
frame: centre(page.pageWidth * 0.34, 34),
codes: [code], names: [countryName(code)],
layout: 'list', showOutline: true, showFlag: false, showName: true, align: 'center',
} as BookElement)
const countryEl = (code: string): BookElement =>
bookTravelCountries(travel, { id: uid('co'), frame: at(bookTravelSize('countries', page)), codes: [code] })
/**
* A map as the engine's `add` places it, in the style the card offers.
*
* The scope is the journey or one trip. A trip map carries no recorded way:
* the trail is the journey's, and a map fitted to it would frame the whole
* journey around one trip and lose the bow its legs are drawn with.
*/
const mapEl = (
style: 'minimal' | 'outline' | 'dark' | 'paper',
source: 'vector' | 'tiles' | 'static' = 'vector',
url = '',
attribution = '',
clip: 'rect' | 'country' = 'rect',
/** Null is the whole journey; a trip id is a map of that trip alone. */
tripId: number | null = null,
): BookElement => {
const route = routeFor(stats, tripId)
return {
...base, id: uid('mp'), kind: 'map',
frame: centre(mapSide, mapSide * 0.78),
style, source, tileUrl: url, attribution, zoom: null, clip, tripId,
/*
* White over a photograph, ink on paper. The accent IS the line, so this is
* how a satellite map arrives drawn the way the reference draws it, while
* the colour stays something the user can change afterwards.
*/
accent: source === 'tiles' || source === 'static' ? '#ffffff' : base.accent,
showLand: true, showRoute: true, showPins: true, showLabels: false,
/*
* A map placed today gets the drawn treatment; the contract defaults to the
* plain line so that every book made before this existed opens unchanged.
* Set explicitly rather than inherited because this object is cast, not
* parsed.
*/
routeStyle: 'drawn', routeArc: 'bow', routeDash: 'arcs', pinStyle: 'photo',
countries: tripId == null
? stats.countries.map(c => c.code)
: [...new Set(route.map(p => p.country).filter((c): c is string => !!c))],
points: route.map(p => ({ lat: p.lat, lng: p.lng, label: p.label, photoId: p.photoId ?? null })),
/*
* Frozen into the element, like every other travel figure: a page that
* fetches its own route at print time is a page that changes when someone
* edits the trip, and prints empty when the export runs signed out.
*/
path,
// Empty until somebody asks for the roads; see roadRoute.ts.
roads: [],
fitPadding: 0.5,
fitToCountries: true,
} as BookElement
}
scope: BookMapScope = 'journey',
source: 'vector' | 'relief' | 'satellite' = 'vector',
): BookElement => ({
...bookTravelMap(travel, stats, { id: uid('mp'), frame: at(bookTravelSize('map', page)), scope, source }),
style,
})
/*
* A mark is placed at the size that suits what it holds.
*
* One size for all six put a flag and a country name into a box built for a
* line of coordinates, which is how a small mark ends up floating inside a
* large selection rectangle. Fractions of the page, so the proportions hold
* on any format.
*/
const MARK_SIZE: Record<string, [number, number]> = {
flag: [0.25, 0.062],
date: [0.16, 0.14],
day: [0.17, 0.062],
coords: [0.36, 0.05],
country: [0.22, 0.078],
distance: [0.26, 0.066],
}
const badgeEl = (
variant: 'flag' | 'date' | 'day' | 'coords' | 'country' | 'distance',
text: string,
sub: string,
code: string | null,
style: 'plain' | 'chip' | 'outline' | 'stacked' = 'plain',
): BookElement => {
const [fw, fh] = MARK_SIZE[variant] ?? [0.24, 0.08]
return {
...base, id: uid('bd'), kind: 'badge', autoColor: true,
showIcon: true, showLabel: true, autoIconColor: true, iconColor: '#111111',
frame: centre(page.pageWidth * fw, page.pageHeight * fh),
variant, text, sub, code, style,
} as BookElement
}
/** A mark at the size that suits what it holds, which is the engine's size for its variant. */
const badgeEl = (variant: BookBadgeVariant, value: BadgeValue): BookElement =>
bookTravelBadge({ id: uid('bd'), frame: at(bookTravelSize('badge', page, { variant })), variant, value })
/**
* One figure on its own.
@@ -373,11 +299,8 @@ export function StudioTravelPanel({
* and offering only the composition meant anyone wanting "14 DAYS" beside a
* picture had to place all four and delete three.
*/
const singleEl = (metric: BookMetric): BookElement => ({
...base, id: uid('st'), kind: 'stats',
frame: centre(page.pageWidth * 0.22, page.pageHeight * 0.13),
metrics: [metric], layout: 'grid', showIcons: true, units: 'metric', values,
} as BookElement)
const singleEl = (metric: BookMetric): BookElement =>
bookTravelStats(stats, { id: uid('st'), frame: at(bookTravelSize('stats', page)), metrics: [metric] })
/** Place a copy — the previewed element keeps its own id for React. */
const place = (el: BookElement) => {
@@ -411,37 +334,20 @@ export function StudioTravelPanel({
}
}
const first = stats.points[0] ?? null
const firstCountry = stats.countries[0] ?? null
const startDay = stats.start ? new Date(`${stats.start}T00:00:00`) : null
const marks: { el: BookElement; label: string }[] = []
if (startDay) {
marks.push({
el: badgeEl('date', String(startDay.getDate()),
startDay.toLocaleDateString(locale, { month: 'long' }).toUpperCase(), null, 'stacked'),
label: t('journey.studio.dateMark'),
})
}
marks.push({
el: badgeEl('day', `${t('journey.studio.dayWord')} 1`, '', null, 'chip'),
label: t('journey.studio.dayMark'),
})
if (first) {
marks.push({
el: badgeEl('coords', formatBookCoords(first.lat, first.lng), first.label, null),
label: t('journey.studio.coordsMark'),
})
}
if (firstCountry) {
marks.push({
el: badgeEl('flag', '', countryName(firstCountry.code), firstCountry.code),
label: t('journey.studio.flagMark'),
})
}
marks.push({
el: badgeEl('distance', formatDistance(stats.distance, locale), t('journey.studio.metric.distance'), null, 'outline'),
label: t('journey.studio.distanceMark'),
/*
* The marks of the journey, with the words the engine resolves for each. A
* mark the journey cannot fill (no start date, no stop, no country) is not
* offered.
*/
const marks: { el: BookElement; label: string }[] = ([
['date', t('journey.studio.dateMark')],
['day', t('journey.studio.dayMark')],
['coords', t('journey.studio.coordsMark')],
['flag', t('journey.studio.flagMark')],
['distance', t('journey.studio.distanceMark')],
] as const).flatMap(([variant, label]) => {
const value = bookBadgeValue(travel, variant, null)
return value ? [{ el: badgeEl(variant, value), label }] : []
})
return (
@@ -562,7 +468,7 @@ export function StudioTravelPanel({
a scale that suits a continent, and satellite is the ground
itself, which is what a page about one country wants.
*/}
{imagery.map(src => (
{IMAGERY.map(src => (
<Card
key={src.id}
/*
@@ -578,10 +484,10 @@ export function StudioTravelPanel({
label={t(src.labelKey)}
recommended={src.id === 'satellite'}
title={src.id === 'satellite' ? t('journey.studio.recommended') : undefined}
onClick={() => place(mapEl('minimal', 'tiles', src.url, src.attribution))}
onClick={() => place(mapEl('minimal', 'journey', src.id))}
>
<TravelPreview
el={mapEl('minimal', 'tiles', src.url, src.attribution)}
el={mapEl('minimal', 'journey', src.id)}
minHeight={62}
maxHeight={80}
/>
@@ -606,7 +512,7 @@ export function StudioTravelPanel({
<div className="st-section-label">{t('journey.studio.mapPerTrip')}</div>
<div className="st-travel-grid">
{tripsWithRoute.map(trip => {
const el = mapEl('minimal', 'vector', '', '', 'rect', trip.id)
const el = mapEl('minimal', { tripId: trip.id })
return (
<Card
key={trip.id}
@@ -658,7 +564,7 @@ export function StudioTravelPanel({
*/}
<div className="st-travel-grid" style={{ marginTop: 6 }}>
{stats.countries.slice(0, 12).map(c => {
const el = badgeEl('country', countryName(c.code), '', c.code)
const el = badgeEl('country', { text: countryName(c.code), sub: '', code: c.code })
return (
<Card key={`mark-${c.code}`} label={countryName(c.code)} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={30} maxHeight={44} />