Files
TREK/client/src/components/Studio/StudioTravelPanel.tsx
T
Maurice bdf5767a5f feat(studio): rebuild both side panels around one head, one row and one order
The rail lists its sections in the order a book is made: Pages, Content,
Layouts, Elements and Travel, then Theme and Layers set apart below a rule.
Stickers are a tab of Elements beside the icons instead of a rail section of
their own, and Elements has six tabs (text, shapes, lines, frames with the
grids, icons, stickers). The quotation mark moved to the text styles, the
outline rectangle to the basic shapes and the plain rule to the lines. The rail
and every tab row are tab lists the arrow keys walk through, and a long name
wraps instead of being cut. The icon and sticker searches say in the field how
many match, which the sticker library's own head used to.

Every panel on both sides opens on the same head, and every labelled control
uses one row that moves the control under its label when the two do not fit,
so no label sits in a fixed column any more. Number fields, chips, sliders,
tab labels, theme names and travel cards wrap the same way, a country beside
a stop wraps under itself, and an upload cell holds its words to two lines
with the whole label in its tooltip. The heads, shelves and groups are real
headings, so a screen reader can walk the panels by them.

The inspector shows every kind in one order: what the element is and says,
its position and size, its look, its effects, then Arrange. Align and the
stacking buttons are one Arrange group with a button that opens the page in
Layers; its rows sit under their labels, each in two sets that move to a
second line whole. Rarely used groups start folded and open on an element
that already uses them (a turned element, a map held at a zoom, a summary set
in another face), and every group mounts afresh for the next selection. A
sticker's duplicate angle and opacity are gone in favour of the shared
position group, which now has the turn buttons for every kind. With several
elements selected it keeps their angle, opacity and turn, and the mirror when
all of them are stickers, and leaves the frame's numbers to one element. A
photo's fill page and fill spread sit under its crop. With nothing selected
the inspector shows the page background, the book's theme with the way to the
Theme panel, and the page numbers.

Seventeen translation keys nothing has used since before the rebuild (the old
inspector title, the empty spread note, the summary and country list names,
the roads buttons and others) are gone from every language. The broad key
template the old panels used had counted them as reached; the format names,
the empty photo grid messages and the map source hints are now named where the
key check can read them.

Also fixed on the way: the travel stops list shared its class with the
gradient stops and was laid out as sixteen pixel handles; the alignment
buttons were named in raw English; the map's print resolution note joined its
two halves with a dash.
2026-10-24 03:08:27 +00:00

580 lines
25 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import { Check, RouteOff } from 'lucide-react'
import type {
BookBadgeVariant, BookElement, BookMapScope, BookMetric, BookPageSetup, JourneyStats,
} from '@trek/shared'
import {
BOOK_METRICS, bookBadgeValue, bookCountryMark, bookCountryName, bookElementId as uid, bookTravelBadge,
bookTravelCountries, bookTravelMap, bookTravelSize, bookTravelStats,
} from '@trek/shared'
import { useStudioStore } from '../../store/studioStore'
import { PanelHead, Shelf } from './StudioPanelHead'
import { studioTravelContext } from './studioTravelContext'
import { Section } from './StudioControls'
import { TravelPreview } from './TravelPreview'
import { fetchRoads } from './roadRoute'
/**
* The journey's own figures, as things you can put on a page.
*
* This is the panel that makes Studio a *travel* book designer rather than a
* layout tool that happens to be full of holiday photographs. The route, the
* distance, the countries and the dates are already in TREK; the work is
* offering them as objects rather than as numbers someone has to retype.
*
* ── Why every tile is a real preview ──────────────────────────────────────
*
* These elements differ from one another by what they *say*, not by their
* shape. Four map styles are four maps; three summary layouts are the same six
* numbers arranged three ways. A named row with the value beside it — which is
* what this panel was first — tells you none of that: you cannot tell the dark
* map from the paper one, or a row of figures from a grid of them, until you
* have placed one and undone it.
*
* So each tile builds the element it would place and renders it through the
* page's own renderer, with this journey's real numbers in it. You are picking
* from the things themselves.
*
* Every button resolves its values *now* and writes them into the element, so
* what lands on the page is finished rather than a placeholder that needs the
* server to mean anything. The reasoning is in TravelElements.tsx, and the
* short version is that the print renderer must never depend on a fetch.
*/
/**
* One row of the stops list: a point of the route or a stop switched off.
*
* `entryId` is null for a stop the server took straight from a trip's places,
* which is what a journey has before anyone wrote an entry. Such a stop has no
* switch, because there is no entry to put it on.
*/
type StopRow = {
entryId: number | null
label: string
date: string | null
country: string | null
excluded: boolean
}
/*
* How many cards share a row.
*
* A stats panel is the width of a spread; shown two-up its figures scale down
* past reading, so it gets the row to itself. Maps, country lists and marks are
* compact enough that two-up still tells them apart, and two-up is what makes
* the section scannable. The tile measures its own width — see TravelPreview.
*/
/** 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
onClick: () => void
/**
* Marked as the one to reach for, with a ring rather than with words.
*
* The label sits in a grid cell about eighty pixels wide, where "Satellite,
* recommended" would take both of its lines. A ring says the same thing in
* no space at all, and the tooltip carries the word for anyone who wonders
* why this card looks different.
*/
recommended?: boolean
title?: string
children: React.ReactNode
}) {
return (
<button type="button"
className={`st-travel-card ${recommended ? 'is-recommended' : ''}`}
onClick={onClick}
title={title ? `${label}: ${title}` : label}
>
{children}
<span className="st-travel-card-label">{label}</span>
</button>
)
}
export function StudioTravelPanel({
page, stats, path, t, locale, canEdit, onToggleStop,
}: {
page: BookPageSetup
stats: JourneyStats | null
/** The travelled way, already thinned. Empty when the trip has no geometry. */
path: [number, number][][]
t: (k: string) => string
locale: string
/** False for a viewer: the stops are still listed, none of them can be switched. */
canEdit: boolean
/** Switch one stop on or off in the figures. Resolves false when it did not land. */
onToggleStop: (entryId: number, excluded: boolean) => Promise<boolean>
}) {
const addElement = useStudioStore(s => s.addElement)
const updateElement = useStudioStore(s => s.updateElement)
const active = useStudioStore(s => s.activeSpread)
const doc = useStudioStore(s => s.doc)
const spread = doc?.spreads[active]
const single = !!spread && spread.role !== 'inner'
/*
* The road lookups still running when the panel goes away.
*
* fetchRoads walks the legs at a deliberate pace, so a twenty-stop map keeps
* asking a public router for minutes after Studio is closed, and the answer
* lands in whatever document the store holds by then.
*/
const roadJobs = useRef(new Set<AbortController>())
useEffect(() => () => {
roadJobs.current.forEach(c => c.abort())
roadJobs.current.clear()
}, [])
/*
* The stops whose switch is on its way to the server.
*
* No optimistic flip: which stop is furthest and which countries remain is
* the server's sum, so the row reads its state from the stats the hook
* fetches once the change has landed. Until then the chip is only held,
* so a second click cannot send the opposite of what the first one is
* still delivering.
*/
const [pending, setPending] = useState<Set<number>>(() => new Set())
const centre = (w: number, h: number) => {
const W = single ? page.pageWidth : page.pageWidth * 2
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
}
if (!stats) {
return (
<>
<PanelHead label={t('journey.studio.travel')} />
<div className="st-panel-scroll">
<p className="st-hint">{t('journey.studio.travelEmpty')}</p>
</div>
</>
)
}
/*
* What the book engine's factories read (studioTravelContext.ts). 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.
*/
const travel = studioTravelContext(stats, path, locale, t)
const at = (size: { w: number; h: number }) => centre(size.w, size.h)
/**
* Country names in the reader's language, not the server's.
*
* The API answers in English, because it has no idea who will read the book.
* `Intl.DisplayNames` is the same CLDR data Atlas already uses for regions, so
* 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 => 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, 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)
/*
* The route as rows, counting and left-out stops together.
*
* Every point is listed, so the list reads as the route. The left-out
* stops sit among the counting ones in date order rather than in a list of
* their own, because the question the section answers is "which of these
* count?", and that is only answerable with all of them in one sequence.
* Undated stops go last, then by name.
*/
const stops: StopRow[] = [
...stats.points.map(p => ({
entryId: p.entryId, label: p.label, date: p.date, country: p.country, excluded: false,
})),
...(stats.excluded ?? []).map(x => ({
entryId: x.entryId, label: x.label, date: x.date, country: null, excluded: true,
})),
].sort((a, b) => {
if (a.date !== b.date) {
if (a.date == null) return 1
if (b.date == null) return -1
return a.date < b.date ? -1 : 1
}
return a.label.localeCompare(b.label)
})
const excludedCount = stops.filter(s => s.excluded).length
/** Whether the country is worth a badge, or the same word on every row. */
const manyCountries = stats.countries.length > 1
/**
* The day of a stop, without the weekday.
*
* `formatDate` is the journal's format and carries "Tue," because a journal
* entry is about a day somebody lived. A row here is about a leg of a route,
* and it has 236px to hold a place name in as well: the weekday costs a
* third of the badge and answers nothing anybody asks of this list.
*/
const stopDay = (iso: string) =>
new Date(`${iso}T00:00:00Z`).toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
const toggleStop = async (entryId: number, excluded: boolean) => {
setPending(prev => new Set(prev).add(entryId))
// A refusal is the hook's to report; here it only frees the chip again.
await onToggleStop(entryId, excluded)
setPending(prev => {
const next = new Set(prev)
next.delete(entryId)
return next
})
}
/**
* One country, as the entry a list would have made of it.
*
* The list and the grid are gone: they were compositions somebody else made,
* deciding the order, the spacing and the type size, and the only way to
* change any of that was to not use them. What is left is the same entry —
* name over silhouette, set the same way — as an element you place one at a
* time. Three of them down a page is the list, with the spacing yours; one of
* them at 80mm is a chapter opener, which the list could never be.
*
* The name is editable afterwards, so numbering them "1. Germany",
* "2. Netherlands" is typing rather than a feature.
*/
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',
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 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.
*
* A summary panel is a composition; a single figure is a mark you drop next
* to a photograph. Both are the same element with a different metric list,
* 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 =>
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) => {
const placed = { ...el, id: uid(el.kind[0]) } as BookElement
addElement(active, placed)
/*
* A map arrives following the roads.
*
* Asked for after the element is on the page rather than before, so the map
* appears at once and the line firms up a moment later: a panel that sat
* there for fifteen seconds after a click would read as broken, and the
* straight line it starts with is the one it would have had anyway.
*
* Legs long enough to have been flights are never sent (see roadRoute.ts),
* the router's own cache answers a second placement of the same journey for
* free, and a failure leaves the leg exactly as it was.
*/
if (placed.kind === 'map' && placed.points.length > 1) {
const job = new AbortController()
roadJobs.current.add(job)
void fetchRoads(placed.points.map(pt => ({ lat: pt.lat, lng: pt.lng })), { signal: job.signal })
.then(roads => {
if (job.signal.aborted) return
if (roads.some(r => r && r.length > 1)) {
updateElement(active, placed.id, { roads } as Partial<BookElement>)
}
})
.catch(() => { /* No roads is a state the map already draws. */ })
.finally(() => { roadJobs.current.delete(job) })
}
}
/*
* 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 (
<>
<PanelHead label={t('journey.studio.travel')} />
<div className="st-panel-scroll">
<Shelf label={t('journey.studio.singleFigures')}>
<div className="st-travel-grid">
{BOOK_METRICS.map(metric => {
const el = singleEl(metric)
return (
<Card key={metric} label={t(`journey.studio.metric.${metric}`)} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={44} maxHeight={62} />
</Card>
)
})}
</div>
</Shelf>
{/*
The switch is here, beside the maps and the figures it changes, and
not only in the entry editor: the moment somebody notices the home
airport on the printed route is the moment they are looking at this
panel. The rows carry no preview because they are not things to
place; they are the input every tile below reads.
*/}
{stops.length > 0 && (
<Section
label={t('journey.studio.stops')}
hint={t('journey.studio.stopsHint')}
/*
* Folded to begin with, and saying how many count while it is.
*
* A fortnight is fourteen rows, which is longer than everything
* else in this panel put together, and most books never need to
* touch it. The figure in the head is what anyone opening it came
* to check anyway, so the closed section still answers the
* question and the maps below stay in reach.
*/
defaultOpen={false}
badge={`${stops.length - excludedCount}/${stops.length}`}
>
<ul className="st-route-stops">
{stops.map((stop, i) => {
const entryId = stop.entryId
const on = !stop.excluded
const state = t(on ? 'journey.studio.stopOn' : 'journey.studio.stopOff')
const chip = `st-chip is-pick is-icon ${on ? 'is-on' : ''}`
/*
* A mark rather than the word.
*
* "Counts" and "Left out" are different lengths, so a column of
* them jitters, and together they cost a third of a row that
* has a place name to fit. The tick and the crossed-out route
* say the same thing at a glance, and the word is still there
* for anyone who hovers or reads with a screen reader.
*/
const mark = on ? <Check size={13} strokeWidth={2.4} /> : <RouteOff size={13} strokeWidth={2.2} />
return (
<li key={entryId ?? `place-${i}`} className={`st-route-stop ${on ? '' : 'is-off'}`}>
{/*
One line: the day, then the name, then the switch. The
name and the day were stacked, which doubled the height
of a list whose whole job is being read down.
*/}
{stop.date && <span className="st-badge">{stopDay(stop.date)}</span>}
<span className="st-route-stop-label">{stop.label || t('journey.studio.untitled')}</span>
{/*
The country only where there is more than one to tell
apart. On a journey inside one country it is the same
word fourteen times, in the row that has least room.
*/}
{manyCountries && stop.country && (
<span className="st-badge is-quiet">{countryName(stop.country)}</span>
)}
{canEdit && entryId != null ? (
<button type="button"
className={chip}
title={`${state}: ${t('journey.studio.stopToggle')}`}
aria-label={t('journey.studio.stopToggle')}
aria-pressed={on}
disabled={pending.has(entryId)}
onClick={() => void toggleStop(entryId, !stop.excluded)}
>
{mark}
</button>
) : (
<span className={chip} title={state} aria-label={state}>{mark}</span>
)}
</li>
)
})}
</ul>
</Section>
)}
<Shelf label={t('journey.studio.routeMap')}>
{stats.points.length ? (
<>
<div className="st-travel-grid">
{(['minimal', 'outline', 'paper', 'dark'] as const).map(style => {
const el = mapEl(style)
return (
<Card key={style} label={t(`journey.studio.mapStyle.${style}`)} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={62} maxHeight={80} />
</Card>
)
})}
{/*
The two imagery maps, as cards rather than as settings.
They were reachable only by placing an outline map and then
changing its source in the inspector, which is a thing nobody
finds. They are different pictures, not variants of one: relief
is land shaded in its own colours against a dark sea, drawn at
a scale that suits a continent, and satellite is the ground
itself, which is what a page about one country wants.
*/}
{IMAGERY.map(src => (
<Card
key={src.id}
/*
* Satellite is marked rather than merely listed. The three
* imagery options are not equals: relief stops at about six
* hundred metres to the pixel, which is a continent's worth
* of detail and a smudge at country size, and the outlines
* are a diagram. For the page most people are making —
* a route across one or two countries — this is the one
* that prints, and saying so beats letting them find out
* after the book is bound.
*/
label={t(src.labelKey)}
recommended={src.id === 'satellite'}
title={src.id === 'satellite' ? t('journey.studio.recommended') : undefined}
onClick={() => place(mapEl('minimal', 'journey', src.id))}
>
<TravelPreview
el={mapEl('minimal', 'journey', src.id)}
minHeight={62}
maxHeight={80}
/>
</Card>
))}
</div>
{/*
A map per trip, for a journey made of more than one.
Two trips printed as a single route draw a line from the last
stop of the first to the first stop of the second, which is
usually the longest leg on the page and one nobody travelled.
Offered rather than imposed: some journeys really are one route
across several trips, and those still get the map above.
Only trips that have stops on the route are listed — a linked
trip nobody wrote about would place an empty frame.
*/}
{tripsWithRoute.length > 1 && (
<div className="st-shelf-sub">
<h4 className="st-section-label">{t('journey.studio.mapPerTrip')}</h4>
<div className="st-travel-grid">
{tripsWithRoute.map(trip => {
const el = mapEl('minimal', { tripId: trip.id })
return (
<Card
key={trip.id}
label={trip.title || t('journey.studio.untitled')}
onClick={() => place(el)}
>
<TravelPreview el={el} minHeight={62} maxHeight={80} />
</Card>
)
})}
</div>
</div>
)}
</>
) : (
<p className="st-hint">{t('journey.studio.noRoute')}</p>
)}
</Shelf>
<Shelf label={t('journey.studio.countries')}>
{stats.countries.length ? (
<>
{/*
One country per element, rather than a block listing them all.
A list of countries is a composition somebody else made: it
decides the order, the spacing and the type size, and the only
way to change any of it is to not use it. Placed one at a time
they are ordinary elements — numbered, moved, set at different
sizes, put on different pages — which is what a book of a trip
through several countries actually wants.
*/}
<div className="st-travel-grid">
{stats.countries.slice(0, 12).map(c => {
const el = countryEl(c.code)
return (
<Card key={c.code} label={countryName(c.code)} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={44} maxHeight={64} />
</Card>
)
})}
</div>
{/*
And the same country as a mark: the outline beside the name
rather than under it, at the size of a line of type. It belongs
next to a date or a coordinate, not on a page of its own.
*/}
<div className="st-travel-grid" style={{ marginTop: 6 }}>
{stats.countries.slice(0, 12).map(c => {
const el = badgeEl('country', bookCountryMark('country', c.code, countryName(c.code)))
return (
<Card key={`mark-${c.code}`} label={countryName(c.code)} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={30} maxHeight={44} />
</Card>
)
})}
</div>
</>
) : (
<p className="st-hint">{t('journey.studio.noCountries')}</p>
)}
</Shelf>
<Shelf label={t('journey.studio.marks')}>
<div className="st-travel-grid">
{marks.map(({ el, label }) => (
<Card key={el.id} label={label} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={34} maxHeight={52} />
</Card>
))}
</div>
</Shelf>
</div>
</>
)
}