From eae8480728b4579c0b2741df7b1bf97b2770c316 Mon Sep 17 00:00:00 2001
From: Maurice <61554723+mauriceboe@users.noreply.github.com>
Date: Thu, 20 Aug 2026 15:43:27 +0200
Subject: [PATCH] feat(studio): follow-ups from using Studio on a real journey
(#1984)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(studio): widen the document, and stop a save from losing it
The schema gains what the elements coming after it need: an icon element, a
badge that can be restyled rather than only placed, a date and a pair of
coordinates on a text binding, and the map's route, pin and road settings.
Widening it turned up two ways a book could fail to come back.
The mood badge writes its code into the document, and the schema said that
code was exactly two characters, from a first draft where it was a country.
The real codes are words, so every save was rejected and the read that
followed parsed to an empty document: seventeen spreads in, nothing out.
Widened to what the badge actually writes, and normalizeBookDocument now
drops the elements it cannot read rather than the whole book, so one bad
element costs a picture frame instead of a year of work.
The other was the body limit. The parser allowed 100kb for everything, and a
book with a routed map measures past that, so the save came back 413 and the
editor sat on "not saved" with no way forward. Book writes get their own
parser at 8mb; everything else keeps the limit it had.
* feat(studio): a date, a pair of coordinates and an icon library
Three things that had to be drawn by hand before.
A journal entry knows when it happened and where, and neither could be put
on a page: you read the date off the entry and typed it into a text box,
where it then stayed while the entry moved. Both are chips now, next to the
title and the story, and both follow their entry the way the others do.
The formatting lives in one place so the chip, the template and the resolver
cannot drift by a character — coordinates in degrees or decimals, the date
in the reader's language.
The icons are the whole Lucide set, searchable, placed as a real element
with its own colour and stroke rather than as a character in a font. The
stroke is deliberately not called strokeWidth: three separate places rescale
a field of that name by page width, and an icon whose line thins as the book
grows is not what anyone asked for.
And the badges finally have their settings. A mood or weather badge could be
placed and then only moved: the icon's colour was fixed, the icon could not
be turned off, and there was no way to keep the icon and drop the words. All
three are switches now, with the colour following the badge unless you say
otherwise.
* fix(studio): let a placed entry field follow its journal entry again
The inspector says a bound field follows the journal entry and that editing
it here breaks the link. The first half was not true: nothing ever re-read
the entry, so renaming a title in Journey left the old words on the page for
good, and the same went for every other bound field.
The resolver runs where the journey is loaded and re-reads the fields that
still point at an entry. What it will not do is as important as what it
does. A field somebody has typed over stays typed over, an entry that is
gone leaves its element alone rather than blanking it, and an entry that
answers with nothing never wipes what is already on the page.
Formatted fields — the date, the coordinates — are only re-read when the
format itself changed, which is what keeps this from quietly rewriting
books that already exist: an entry whose date was placed as a heading and
then set in capitals keeps its capitals. Checked against the real
database before it shipped, where the first version overwrote three of them.
* feat(studio): start a new book empty, with six layouts to choose from
A new journey opened onto a book that had already been laid out, which is
the wrong first move: it decides the shape of somebody's book before they
have seen it, and undoing it means deleting pages one at a time. A new book
starts empty now, and the auto layout runs when it is asked to.
The layouts themselves went from two to six, so choosing one is a real
choice: what was there covered a photo-heavy spread and a text-heavy one and
nothing in between.
* fix(studio): hyphenate the export the way the editor does, and set the bleed
Two things the press sees and the editor did not.
A paragraph broke in different places in the PDF than on screen. The print
document is written from scratch and never carried a language, so the
browser hyphenated the editor's German and the export's nothing-in-
particular. It takes the page's own language now. The wait for the sheet to
be ready also covers the images inside SVG, with a ceiling, so a map that is
slow to draw does not print half-finished.
And the bleed and the safe margin are typeable. Both were baked into every
page preset at what a photo-book vendor usually asks for, 3mm and 5mm, with
no control anywhere. That is fine until a printer asks for 5 and 10, at
which point the book cannot be made here at all: the export carries the wrong
bleed and the crop marks sit in the wrong place, which nobody finds until
the guillotine has run.
* feat(studio): rebuild the inspector
The panel on the right had grown by accretion: every element type had added
its own controls in whatever shape suited it that day, so the same idea —
a colour, a size, a toggle — looked different depending on what you had
selected, and long panels were a wall of unlabelled fields.
It is one set of controls now, used by every element: foldable sections, a
line that holds a label and its control, radio choices, checkboxes, the
app's own toggle, and a number field that holds a draft so typing "500" is
not read as "5", clamped and written back under the cursor. Duplicated
controls were merged rather than deleted; nothing an element could do before
it cannot do now.
The head carries the element's identity and its actions, so what you have
selected is readable without counting fields, and the sections have room to
breathe: what was there was correct and unreadable.
* feat(studio): a route map worth printing
The map in a book was a straight grey line between grey dots on a tile that
was chosen for a screen. At print size those tiles are read at roughly four
times the resolution they were fetched for, which is why it looked soft next
to everything around it.
The imagery comes from sources that hold up on paper: NASA's shaded relief
and EOX's cloudless Sentinel-2, both licensed for it, picked at a zoom that
matches the printer rather than the screen. Tiles are fetched at twice the
density where the source offers it, and the seam between them scales with
the tile instead of sitting at a fixed fraction of a millimetre, which under
editor zoom fell below one device pixel and showed as white threads.
The route is drawn rather than plotted. A leg that was never recorded bows,
by an amount that grows with its length and turns away from the pole, and it
dashes, because an inferred line should not claim to be a track — a recorded
track never bows. Both strokes are laid down in two passes, casing then core,
so the line stays legible over dark sea and bright desert alike. Legs can
follow roads instead, routed leg by leg with the geometry thinned before it
is stored, which is the setting a road trip wants and the one a flight
cannot have.
Stops carry their own first photograph, cut to a circle by a stencil that
exists — the first one was written into a defs block that only appears on a
map clipped to a coastline, so on an ordinary map it clipped nothing and the
photographs sat square on the page. A stop with no photograph of its own is
numbered rather than handed one from elsewhere in the journey: a marker at
one place showing a picture taken at another is a caption that lies.
Whatever the frame, the whole route is inside it. The window takes the
frame's aspect, so the fit and the imagery agree instead of one containing
what the other covers, and a map dragged narrow no longer cuts off its end.
Each linked trip can carry its own map, and a map notices a photograph added
to a stop afterwards rather than having to be placed again.
* i18n(journey): the new Studio strings, in all 23 locales
* test(journey): pin the coverage the map work brought with it
The journey domain measures higher than its floor after this round, so the
floor follows it up. Only journey moved; the generator's suggestion would
have taken memories' functions down a point, and a ratchet that can fall is
not a ratchet.
* test(studio): cover the thinning that keeps a road route saveable
The one path in roadRoute nothing exercised, and the one that matters most:
a router answers a long drive with thousands of points at a precision no
press can resolve, and all of it goes into the document. Keeping a leg whole
is what pushed a real book past the body limit.
Both ends have to survive so the line still meets its stops, and the spacing
has to stay even, because cutting a run out of the middle would straighten
exactly the bends somebody wanted to see.
---
client/src/components/Studio/SpreadView.tsx | 46 +-
.../src/components/Studio/StudioControls.tsx | 200 +++++
.../components/Studio/StudioElementsPanel.tsx | 128 ++-
.../src/components/Studio/StudioInspector.tsx | 664 +++++++++-----
client/src/components/Studio/StudioShell.tsx | 30 +
.../src/components/Studio/StudioSidebar.tsx | 65 +-
.../Studio/StudioTravelInspector.tsx | 801 +++++++++++------
.../components/Studio/StudioTravelPanel.tsx | 192 +++-
.../src/components/Studio/TravelElements.tsx | 667 +++++++++++++-
client/src/components/Studio/TrimField.tsx | 22 +-
client/src/components/Studio/applyTemplate.ts | 45 +-
client/src/components/Studio/autoLayout.ts | 98 +-
.../components/Studio/bookTemplates.data.ts | 840 ++++++++++++++++--
client/src/components/Studio/entryText.ts | 63 ++
client/src/components/Studio/iconLibrary.ts | 118 +++
client/src/components/Studio/mapSources.ts | 167 +++-
client/src/components/Studio/mapTiles.ts | 138 ++-
client/src/components/Studio/printSheets.ts | 55 +-
.../src/components/Studio/resolveBindings.ts | 147 +++
client/src/components/Studio/roadRoute.ts | 133 +++
client/src/components/Studio/travelRefresh.ts | 51 +-
.../pages/journeyStudio/useJourneyStudio.ts | 143 ++-
client/src/styles/studio.css | 201 ++++-
.../unit/studio/autoLayout.travel.test.ts | 57 +-
client/tests/unit/studio/badgeIcon.test.tsx | 210 +++++
client/tests/unit/studio/entryChips.test.tsx | 212 +++++
client/tests/unit/studio/entryText.test.ts | 65 ++
client/tests/unit/studio/iconElement.test.tsx | 208 +++++
client/tests/unit/studio/inspector.test.tsx | 552 ++++++++++++
.../unit/studio/inspectorTravel.test.tsx | 558 ++++++++++++
client/tests/unit/studio/mapMarkers.test.tsx | 154 ++++
.../unit/studio/mapPrintResolution.test.ts | 127 +++
client/tests/unit/studio/mapRoads.test.tsx | 218 +++++
.../tests/unit/studio/mapRouteFits.test.tsx | 137 +++
client/tests/unit/studio/mapScope.test.ts | 152 ++++
client/tests/unit/studio/mapTiles.test.ts | 9 +-
client/tests/unit/studio/pageEdges.test.tsx | 68 ++
client/tests/unit/studio/printSheets.test.ts | 18 +
.../tests/unit/studio/resolveBindings.test.ts | 209 +++++
.../tests/unit/studio/travelRefresh.test.ts | 11 +-
server/src/bootstrap.ts | 21 +
.../nest/journey/journey-domain.service.ts | 86 +-
server/src/nest/journey/journey-stats.ts | 22 +-
.../tests/integration/book-body-size.test.ts | 60 ++
.../unit/nest/journey-stats-trips.test.ts | 100 +++
server/tests/unit/nest/journey-stats.test.ts | 6 +-
server/vitest.config.ts | 2 +-
shared/src/book/book.schema.spec.ts | 194 ++++
shared/src/book/book.schema.ts | 245 ++++-
shared/src/book/journey-stats.schema.ts | 42 +
shared/src/i18n/ar/journey.ts | 73 ++
shared/src/i18n/br/journey.ts | 73 ++
shared/src/i18n/ca/journey.ts | 73 ++
shared/src/i18n/cs/journey.ts | 73 ++
shared/src/i18n/de/journey.ts | 73 ++
shared/src/i18n/en/journey.ts | 73 ++
shared/src/i18n/es/journey.ts | 73 ++
shared/src/i18n/fr/journey.ts | 73 ++
shared/src/i18n/gr/journey.ts | 73 ++
shared/src/i18n/hu/journey.ts | 73 ++
shared/src/i18n/id/journey.ts | 73 ++
shared/src/i18n/it/journey.ts | 73 ++
shared/src/i18n/ja/journey.ts | 73 ++
shared/src/i18n/ko/journey.ts | 73 ++
shared/src/i18n/nl/journey.ts | 73 ++
shared/src/i18n/pl/journey.ts | 73 ++
shared/src/i18n/ru/journey.ts | 73 ++
shared/src/i18n/sv/journey.ts | 73 ++
shared/src/i18n/tr/journey.ts | 73 ++
shared/src/i18n/uk/journey.ts | 73 ++
shared/src/i18n/vi/journey.ts | 73 ++
shared/src/i18n/zh-TW/journey.ts | 73 ++
shared/src/i18n/zh/journey.ts | 73 ++
73 files changed, 9631 insertions(+), 805 deletions(-)
create mode 100644 client/src/components/Studio/StudioControls.tsx
create mode 100644 client/src/components/Studio/entryText.ts
create mode 100644 client/src/components/Studio/iconLibrary.ts
create mode 100644 client/src/components/Studio/resolveBindings.ts
create mode 100644 client/src/components/Studio/roadRoute.ts
create mode 100644 client/tests/unit/studio/badgeIcon.test.tsx
create mode 100644 client/tests/unit/studio/entryChips.test.tsx
create mode 100644 client/tests/unit/studio/entryText.test.ts
create mode 100644 client/tests/unit/studio/iconElement.test.tsx
create mode 100644 client/tests/unit/studio/inspector.test.tsx
create mode 100644 client/tests/unit/studio/inspectorTravel.test.tsx
create mode 100644 client/tests/unit/studio/mapMarkers.test.tsx
create mode 100644 client/tests/unit/studio/mapPrintResolution.test.ts
create mode 100644 client/tests/unit/studio/mapRoads.test.tsx
create mode 100644 client/tests/unit/studio/mapRouteFits.test.tsx
create mode 100644 client/tests/unit/studio/mapScope.test.ts
create mode 100644 client/tests/unit/studio/pageEdges.test.tsx
create mode 100644 client/tests/unit/studio/resolveBindings.test.ts
create mode 100644 server/tests/integration/book-body-size.test.ts
create mode 100644 server/tests/unit/nest/journey-stats-trips.test.ts
create mode 100644 shared/src/book/book.schema.spec.ts
diff --git a/client/src/components/Studio/SpreadView.tsx b/client/src/components/Studio/SpreadView.tsx
index da6d8c032..d5440e853 100644
--- a/client/src/components/Studio/SpreadView.tsx
+++ b/client/src/components/Studio/SpreadView.tsx
@@ -1,8 +1,9 @@
import type { CSSProperties } from 'react'
import type {
- BookElement, BookPageSetup, BookPhotoElement, BookShapeElement, BookSpread,
+ BookElement, BookIconElement, BookPageSetup, BookPhotoElement, BookShapeElement, BookSpread,
} from '@trek/shared'
import { FONT_STACKS, photoSrc } from './bookRender'
+import { iconComponent } from './iconLibrary'
import { HOLED_SHAPES, SHAPE_PATHS, scalePath, unitPath } from './shapes'
import { TravelElementView } from './TravelElements'
import { PageNumbers } from './PageNumbers'
@@ -197,8 +198,18 @@ function PhotoView({ el, big, print, dropLabel }: {
style={{
position: 'absolute',
inset: 0,
+ /*
+ * Opaque, and that is the point.
+ *
+ * The hatch used to be laid over nothing, so an empty frame showed
+ * whatever was behind it and every attempt to arrange a shape *under*
+ * one looked like it had done nothing: the frame appeared to sit at the
+ * bottom of the stack no matter where it was, right up until a
+ * photograph landed in it and it suddenly covered things. A frame is a
+ * promise that a picture goes here, and a picture is not see-through.
+ */
background:
- 'repeating-linear-gradient(45deg, rgba(0,0,0,.045) 0 6px, rgba(0,0,0,.02) 6px 12px)',
+ 'repeating-linear-gradient(45deg, rgba(0,0,0,.055) 0 6px, rgba(0,0,0,.025) 6px 12px), #ffffff', // theme-lint-disable — paper, not app chrome
// A dashed border would be cut in half lengthwise by the clip, so a
// masked placeholder shows its shape through the hatch alone.
border: clipped ? undefined : '1px dashed rgba(0,0,0,.16)',
@@ -338,6 +349,31 @@ function PhotoView({ el, big, print, dropLabel }: {
)
}
+/**
+ * A lucide drawing, at the size the frame gives it.
+ *
+ * Kept proportional rather than stretched to the box. A shape is scaled by
+ * moving its points and looks deliberate at any ratio; a stroked icon does not
+ * — a stretched compass reads as a mistake — so it sits centred in whatever
+ * rectangle it was given, which is also what the handles then appear to do.
+ *
+ * The stroke follows the drawing rather than staying a fixed width, because an
+ * icon set 60mm across with a hairline is a diagram, not a mark. `lineWidth` is
+ * the weight against lucide's own 24-unit grid, exactly as the icons are drawn.
+ */
+function IconView({ el }: { el: BookIconElement }) {
+ const Icon = iconComponent(el.name)
+ return (
+
+
+
+ )
+}
+
export function ElementView({
el, big, print = false, dropLabel = '',
}: { el: BookElement; big: boolean; print?: boolean; dropLabel?: string }) {
@@ -347,12 +383,16 @@ export function ElementView({
if (el.kind === 'shape') return
+ // 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.
+ if (el.kind === 'icon') return
+
if (el.kind !== 'text') {
// map, stats, countries, badge — the elements drawn from the journey's own
// figures. They live in their own file because they are a different kind of
// drawing: type and vector graphics laid out from data, rather than one box
// with one property set.
- return
+ return
}
return (
diff --git a/client/src/components/Studio/StudioControls.tsx b/client/src/components/Studio/StudioControls.tsx
new file mode 100644
index 000000000..95ca427f4
--- /dev/null
+++ b/client/src/components/Studio/StudioControls.tsx
@@ -0,0 +1,200 @@
+import { useState, type ReactNode } from 'react'
+import { Check, ChevronDown } from 'lucide-react'
+import ToggleSwitch from '../Settings/ToggleSwitch'
+
+/**
+ * The inspector's vocabulary.
+ *
+ * ── Why this file exists ─────────────────────────────────────────────────
+ *
+ * The panel had grown four ways of saying "this one is on" — a filled pill, a
+ * ring, a tinted border, ink on a tint — and used the *same* pill for a choice
+ * between four things and for a switch that turns one thing off. A map showed
+ * seventy controls in ten sections that were all open at once, all labelled in
+ * the same small capitals, so nothing on the panel looked more important than
+ * anything else. None of that was a decision; it was what happens when a panel
+ * grows one control at a time.
+ *
+ * So there are now exactly three ways to answer a question here, and each looks
+ * like what it is:
+ *
+ * **Choose one** is a row of chips, and the chosen one is filled.
+ * **Choose several** is a row of chips that carry a tick when they are on,
+ * because a set with three of seven picked has to be readable at a glance.
+ * **On or off** is the app's switch, on its own line beside the words —
+ * the one control in TREK that already means exactly that.
+ *
+ * A section can be folded away, which is the other half of the answer: a
+ * property panel is a reference, and a reference nobody can collapse is a wall.
+ */
+
+/**
+ * One group of properties.
+ *
+ * `defaultOpen={false}` is for the groups you set once and rarely return to —
+ * the frame's numbers, mostly. The state is per mounted section rather than
+ * remembered: coming back to an element and finding its panel folded up
+ * differently from last time is worse than opening one group again.
+ */
+export function Section({ label, children, defaultOpen = true, hint }: {
+ label: string
+ children: ReactNode
+ defaultOpen?: boolean
+ /** A line under the label, for a group whose name cannot carry the whole idea. */
+ hint?: string
+}) {
+ const [open, setOpen] = useState(defaultOpen)
+ return (
+
+
setOpen(!open)} aria-expanded={open}>
+ {label}
+
+
+ {open && (
+
+ {hint &&
{hint}
}
+ {children}
+
+ )}
+
+ )
+}
+
+/** A labelled line. The label sits left, the control fills what is left. */
+export function Line({ label, children, wide = false }: {
+ label: string
+ children: ReactNode
+ /** Put the control under the label rather than beside it, when it needs the width. */
+ wide?: boolean
+}) {
+ return (
+
+ )
+}
+
+/** Choose one of a few. The chosen one is filled in. */
+export function Choice({ value, options, onPick, ariaLabel }: {
+ value: T
+ options: { value: T; label: ReactNode; title?: string; disabled?: boolean }[]
+ onPick: (v: T) => void
+ ariaLabel?: string
+}) {
+ return (
+
+ {options.map(o => (
+ onPick(o.value)}
+ >
+ {o.label}
+
+ ))}
+
+ )
+}
+
+/**
+ * Choose several. A tick marks what is in the set.
+ *
+ * Not switches: seven metrics as seven switched lines is a column of switches
+ * taller than the element it configures, and what matters about a set is which
+ * of it is in — a question a row answers and a list does not.
+ */
+export function Picks({ value, options, onToggle }: {
+ value: readonly T[]
+ options: { value: T; label: ReactNode; disabled?: boolean }[]
+ onToggle: (v: T) => void
+}) {
+ return (
+
+ {options.map(o => {
+ const on = value.includes(o.value)
+ return (
+ onToggle(o.value)}
+ >
+ {on && }
+ {o.label}
+
+ )
+ })}
+
+ )
+}
+
+/** On or off, said the way the rest of TREK says it. */
+export function Switch({ label, on, onToggle }: { label: string; on: boolean; onToggle: () => void }) {
+ return (
+
+ {label}
+
+
+ )
+}
+
+/**
+ * A number, with its unit.
+ *
+ * Held as text while it is being typed and only read back on blur or Enter,
+ * which fixes three things at once: "0." no longer snaps to "0" the moment the
+ * point is typed, an emptied field no longer collapses the element to zero,
+ * and a value is one undo step rather than one per keystroke.
+ */
+export function NumField({
+ label, value, onChange, min, max, step = 0.5, unit,
+}: {
+ label: string
+ value: number
+ onChange: (v: number) => void
+ min?: number
+ max?: number
+ step?: number
+ unit?: string
+}) {
+ const shown = String(Math.round(value * 100) / 100)
+ const [draft, setDraft] = useState(null)
+
+ const commit = (raw: string) => {
+ setDraft(null)
+ const v = Number(raw)
+ if (raw.trim() === '' || !Number.isFinite(v)) return
+ const clamped = Math.min(max ?? Infinity, Math.max(min ?? -Infinity, v))
+ if (clamped !== value) onChange(clamped)
+ }
+
+ return (
+
+ {label}
+
+ setDraft(e.target.value)}
+ onBlur={e => commit(e.target.value)}
+ onKeyDown={e => {
+ if (e.key === 'Enter') (e.target as HTMLInputElement).blur()
+ if (e.key === 'Escape') setDraft(null)
+ }}
+ />
+ {unit && {unit} }
+
+
+ )
+}
diff --git a/client/src/components/Studio/StudioElementsPanel.tsx b/client/src/components/Studio/StudioElementsPanel.tsx
index 9f1154364..c74ba99f2 100644
--- a/client/src/components/Studio/StudioElementsPanel.tsx
+++ b/client/src/components/Studio/StudioElementsPanel.tsx
@@ -1,22 +1,26 @@
-import { useState } from 'react'
-import { Minus, Quote, Square } from 'lucide-react'
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { Minus, Quote, Search, Square, X } from 'lucide-react'
import type { BookElement, BookPageSetup, BookShapeId } from '@trek/shared'
import { useStudioStore } from '../../store/studioStore'
import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS, SHAPE_PATHS } from './shapes'
import { GRIDS, defaultGridBox, gridElements } from './grids'
import { PanelHead } from './StudioPanelHead'
+import { FEATURED_ICONS, iconComponent, iconLabel, searchIcons } from './iconLibrary'
/**
* Everything you can add that does not come from the journey: type, shapes,
- * picture frames and photo grids.
+ * picture frames, photo grids and the icon set.
*
- * Three tabs rather than three more entries on the rail. They are all the same
+ * Four tabs rather than four more entries on the rail. They are all the same
* kind of answer to "what do I put on this page", and a rail long enough to
* hold them separately stops being faster to hit than a word would be.
*/
const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}`
+/** How many icons the grid draws at a time. Two screens' worth at four across. */
+const ICON_PAGE = 120
+
type FrameStyle = 'none' | 'polaroid' | 'white' | 'shadow' | 'film' | 'tape'
/**
@@ -74,8 +78,35 @@ function FrameStyleGlyph({ style }: { style: FrameStyle }) {
)
}
+function IconCell({ name, onPick }: { name: string; onPick: (name: string) => void }) {
+ const Icon = iconComponent(name)
+ return (
+ onPick(name)}
+ aria-label={name}
+ title={iconLabel(name)}
+ >
+
+
+ )
+}
+
export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: string) => string }) {
- const [tab, setTab] = useState<'shapes' | 'frames' | 'grids'>('shapes')
+ const [tab, setTab] = useState<'shapes' | 'frames' | 'grids' | 'icons'>('shapes')
+ const [iconQuery, setIconQuery] = useState('')
+ /*
+ * How much of the library is on screen.
+ *
+ * Fourteen hundred icons is fourteen hundred React components, each a
+ * forwardRef around several SVG nodes, and rendering them all costs about a
+ * second of blocked main thread on the first click of the tab — for a grid
+ * whose first two rows are all anybody sees. So it grows as it is scrolled,
+ * and a search resets it: the results of a search are short, and starting
+ * them part-drawn would be wrong.
+ */
+ const [iconLimit, setIconLimit] = useState(ICON_PAGE)
+ const iconTail = useRef(null)
const addElement = useStudioStore(s => s.addElement)
const commit = useStudioStore(s => s.commit)
const select = useStudioStore(s => s.select)
@@ -130,6 +161,22 @@ export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: s
} as BookElement)
}
+ /**
+ * An icon, placed as a square.
+ *
+ * Square because that is what it is: unlike a shape, an icon keeps its
+ * proportions inside whatever frame it is given, so a rectangle would only
+ * pad it with air on two sides.
+ */
+ const addIcon = (name: string) => {
+ const side = Math.min(page.pageWidth, page.pageHeight) * 0.18
+ addElement(active, {
+ id: uid('i'), kind: 'icon', frame: centre(side, side),
+ rotation: 0, opacity: 1, locked: false,
+ name, color: '#111827', lineWidth: 2,
+ } as BookElement)
+ }
+
/** An empty frame, cut to a shape and waiting for a picture. */
const addFrame = (mask: BookShapeId | null, frameStyle: FrameStyle = 'none') => {
const side = Math.min(page.pageWidth, page.pageHeight) * 0.45
@@ -144,6 +191,26 @@ export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: s
} as BookElement)
}
+ const matchedIcons = useMemo(() => searchIcons(iconQuery), [iconQuery])
+ const shownIcons = useMemo(() => matchedIcons.slice(0, iconLimit), [matchedIcons, iconLimit])
+
+ /*
+ * Grow the grid when its foot comes into view.
+ *
+ * An observer rather than a scroll handler: the panel is the scroller, the
+ * foot is the thing that matters, and asking the browser to tell us when they
+ * meet costs nothing per frame.
+ */
+ useEffect(() => {
+ const tail = iconTail.current
+ if (!tail) return
+ const io = new IntersectionObserver(entries => {
+ if (entries.some(e => e.isIntersecting)) setIconLimit(n => n + ICON_PAGE)
+ })
+ io.observe(tail)
+ return () => io.disconnect()
+ }, [shownIcons.length, matchedIcons.length])
+
return (
<>
@@ -158,6 +225,9 @@ export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: s
setTab('grids')}>
{t('journey.studio.grids')}
+ setTab('icons')}>
+ {t('journey.studio.icons')}
+
@@ -319,6 +389,54 @@ export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: s
{t('journey.studio.gridHint')}
)}
+
+ {tab === 'icons' && (
+
+ {/*
+ The search sits inside the scroller rather than above the tab
+ strip, unlike the Content panel's: that one filters both of its
+ tabs, this one belongs to a single tab, and hanging it above the
+ strip would make the strip move when you switch away from it.
+ */}
+
+
+ { setIconQuery(e.target.value); setIconLimit(ICON_PAGE) }}
+ placeholder={t('journey.studio.searchIcons')}
+ aria-label={t('journey.studio.searchIcons')}
+ spellCheck={false}
+ />
+ {iconQuery && (
+ { setIconQuery(''); setIconLimit(ICON_PAGE) }} aria-label={t('common.clear')}>
+
+
+ )}
+
+ {/* The shelf, until somebody searches. */}
+ {!iconQuery && (
+ <>
+
{t('journey.studio.iconsForTravel')}
+
+ {FEATURED_ICONS.map(name => (
+
+ ))}
+
+
{t('journey.studio.iconsAll')}
+ >
+ )}
+
+ {shownIcons.map(name => (
+
+ ))}
+
+ {/* What the observer below watches for. */}
+ {shownIcons.length < matchedIcons.length &&
}
+ {!matchedIcons.length && (
+
{t('journey.studio.noMatches')}
+ )}
+
+ )}
>
)
diff --git a/client/src/components/Studio/StudioInspector.tsx b/client/src/components/Studio/StudioInspector.tsx
index baf96bd60..82d1f27d3 100644
--- a/client/src/components/Studio/StudioInspector.tsx
+++ b/client/src/components/Studio/StudioInspector.tsx
@@ -1,9 +1,9 @@
import type { BookElement, BookPageNumbers, BookPageSetup, BookShapeId, JourneyStats } from '@trek/shared'
import {
AlignCenter, AlignJustify, AlignLeft, AlignRight, ArrowDown, ArrowUp,
- ChevronsDown, ChevronsUp, Lock, Trash2, Unlock,
+ ChevronsDown, ChevronsUp, Copy, Italic, Lock, Trash2, Unlock,
} from 'lucide-react'
-import { useRef } from 'react'
+import { useMemo, useRef, useState } from 'react'
import { useStudioStore } from '../../store/studioStore'
import { photoSrc } from './bookRender'
import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts'
@@ -11,19 +11,47 @@ import { FRAME_SHAPES, SHAPE_GROUPS } from './shapes'
import { ShapeGlyph } from './StudioElementsPanel'
import { TravelInspector } from './StudioTravelInspector'
import { Swatches } from './StudioSwatches'
+import { formatBookCoords } from './entryText'
+import { iconComponent, iconLabel, searchIcons } from './iconLibrary'
+import { Choice, Line, NumField, Section, Switch } from './StudioControls'
+import type { JourneySource } from './StudioSidebar'
+
+/** How many icons the swap grid offers at once. A search narrows it below this. */
+const ICON_CHOICES = 60
/**
* Properties of whatever is selected.
*
- * Deliberately narrow: it shows what the selected thing actually has, and
- * nothing else. A panel that lists every property of every element type with
- * most of them greyed out reads as a settings dialog, not as a tool.
+ * ── What it shows, and in what order ─────────────────────────────────────
+ *
+ * Deliberately narrow: what the selected thing actually has, and nothing else.
+ * A panel that lists every property of every element with most of them greyed
+ * out reads as a settings dialog, not as a tool.
+ *
+ * The order is the same for every kind, because the panel is used by muscle
+ * memory more than by reading: **what it says** first, then **how it looks**,
+ * then **where it sits**, then **what is in front of what**. A photo's crop and
+ * a mark's words are the same step in that sequence, so they sit in the same
+ * place on the panel.
+ *
+ * The frame's numbers are folded away by default. They are the least-used group
+ * in the panel — dragging is how anyone actually positions an element — and
+ * four number fields left open at the top pushed everything worth looking at
+ * below the fold.
+ *
+ * ── What is not here any more ────────────────────────────────────────────
+ *
+ * Duplicate, lock and delete are in the panel head rather than in a section of
+ * their own: they act on the element rather than describing it, and a group
+ * called "Arrange" that held two of those next to the stacking buttons was the
+ * one group in the panel whose name did not say what was inside it.
*/
export function StudioInspector({
spreadIndex,
page,
stats,
+ source,
setPageNumbers,
t,
locale,
@@ -33,14 +61,23 @@ export function StudioInspector({
setPageNumbers: (patch: Partial) => void
/** Live journey figures, so a travel element can be brought up to date. */
stats: JourneyStats | null
+ /** The journal itself, so a bound mark can be re-set from it. */
+ source: JourneySource
t: (k: string) => string
locale: string
}) {
+ const [iconQuery, setIconQuery] = useState('')
+ /*
+ * A page of results, memoised because the inspector re-renders on every
+ * pointer move of a drag and the library is fourteen hundred names long.
+ */
+ const iconChoices = useMemo(() => searchIcons(iconQuery).slice(0, ICON_CHOICES), [iconQuery])
const doc = useStudioStore(s => s.doc)
const selection = useStudioStore(s => s.selection)
const update = useStudioStore(s => s.updateElement)
const commit = useStudioStore(s => s.commit)
const raise = useStudioStore(s => s.raise)
+ const duplicate = useStudioStore(s => s.duplicate)
const removeElements = useStudioStore(s => s.removeElements)
const spread = doc?.spreads[spreadIndex]
@@ -59,30 +96,27 @@ export function StudioInspector({
{t('journey.studio.document')}
-
- setPageNumbers({ show: !folios.show })}
- >
- {t(folios.show ? 'journey.studio.pageNumbersOn' : 'journey.studio.pageNumbersOff')}
-
-
+ setPageNumbers({ show: !folios.show })}
+ />
{folios.show && (
<>
-
- {(['outer', 'inner', 'centre'] as const).map(pos => (
-
setPageNumbers({ position: pos })}
- >
- {t(`journey.studio.folio.${pos}`)}
-
- ))}
+
+
+ ({
+ value: pos, label: t(`journey.studio.folio.${pos}`),
+ }))}
+ onPick={position => setPageNumbers({ position })}
+ />
+
-
-
+ setPageNumbers({ startAt: Math.round(v) })}
/>
- setPageNumbers({ size: v })}
/>
-
-
+ setPageNumbers({ margin: v })}
/>
- {/*
- Automatic first, and picking a colour is what turns it off —
- no separate switch to find and no state where a chosen colour
- is quietly ignored.
- */}
-
- setPageNumbers({ autoColor: true })}
- aria-pressed={folios.autoColor}
- >
- {t('journey.studio.folioAuto')}
-
-
-
-
setPageNumbers({ color: c, autoColor: false })}
+
+ setPageNumbers({ autoColor: !folios.autoColor })}
/>
+ {/* Picking a colour is the other way to turn automatic off, so
+ the row stays reachable and simply reads as inactive. */}
+
+
+ setPageNumbers({ color: c, autoColor: false })}
+ />
+
+
>
)}
- {t('journey.studio.inspectorEmpty')}
+
+ {t('journey.studio.inspectorEmpty')}
+
+
+ {t('journey.studio.pageHint')} {page.pageWidth} × {page.pageHeight} mm
+
)
@@ -155,23 +193,46 @@ export function StudioInspector({
return (
-
-
{one ? t(`journey.studio.kind.${el.kind}`) : t('journey.studio.multiple')}
-
{sel.length > 1 ? sel.length : ''}
+ {/*
+ The head says what is selected and carries the three things you do TO
+ it. It used to say only the kind, in the same small capitals as every
+ section label below it, which left the panel with no top.
+ */}
+
+
+ {one ? t(`journey.studio.kind.${el.kind}`) : t('journey.studio.multiple')}
+ {sel.length > 1 && {sel.length} }
+
+
+ duplicate(spreadIndex, selection)}
+ title={t('journey.studio.duplicate')}
+ aria-label={t('journey.studio.duplicate')}
+ >
+
+
+ set({ locked: !el.locked })}
+ title={t(el.locked ? 'journey.studio.unlock' : 'journey.studio.lock')}
+ aria-label={t(el.locked ? 'journey.studio.unlock' : 'journey.studio.lock')}
+ aria-pressed={el.locked}
+ >
+ {el.locked ? : }
+
+ removeElements(spreadIndex, selection)}
+ title={t('journey.studio.delete')}
+ aria-label={t('journey.studio.delete')}
+ >
+
+
+
- {one && (
-
-
- set({ frame: { ...el.frame, x: v } })} />
- set({ frame: { ...el.frame, y: v } })} />
- set({ frame: { ...el.frame, w: v } })} />
- set({ frame: { ...el.frame, h: v } })} />
-
-
- )}
-
{el.kind === 'text' && (
<>
@@ -185,6 +246,33 @@ export function StudioInspector({
{el.binding && !el.overridden && (
{t('journey.studio.boundHint')}
)}
+ {/*
+ How a point is set. Only a coordinate binding has the question,
+ and answering it re-sets the words on the spot rather than at
+ the next open — switching a notation and seeing nothing happen
+ reads as a broken control. Deliberately not `overridden`:
+ choosing a format is not writing the text.
+ */}
+ {el.binding?.source === 'entry.location' && el.binding.format && (
+
+
+ {
+ const entry = source.entries.find(e => e.id === el.binding!.entryId)
+ const text = entry?.lat != null && entry.lng != null
+ ? formatBookCoords(entry.lat, entry.lng, f)
+ : el.text
+ set({ binding: { ...el.binding!, format: f }, text })
+ }}
+ />
+
+
+ )}
@@ -215,34 +303,72 @@ export function StudioInspector({
)
})}
-
-
set({ size: v })} />
- set({ leading: v })} />
+
+
+ ({
+ value: w,
+ label: String(w),
+ disabled: !hasWeight(el.font, w),
+ title: hasWeight(el.font, w) ? undefined : t('journey.studio.weightMissing'),
+ }))}
+ onPick={weight => set({ weight })}
+ />
+
+
+
+ {([['left', AlignLeft], ['center', AlignCenter], ['right', AlignRight], ['justify', AlignJustify]] as const).map(([a, Icon]) => (
+ set({ align: a })}
+ aria-label={a}
+ >
+
+
+ ))}
+ {/* Italic sits with the alignment because both are things
+ you do to a line rather than to a typeface. It had no
+ control at all before, though the renderer drew it. */}
+ set({ italic: !el.italic })}
+ title={t('journey.studio.italic')}
+ aria-label={t('journey.studio.italic')}
+ aria-pressed={el.italic}
+ >
+
+
+
+
-
- {([400, 500, 600, 700] as const).map(w => (
-
set({ weight: w })}
- >
- {w}
-
- ))}
+
+ set({ size: v })} />
+ set({ leading: v })} />
-
- {([['left', AlignLeft], ['center', AlignCenter], ['right', AlignRight], ['justify', AlignJustify]] as const).map(([a, Icon]) => (
-
set({ align: a })} aria-label={a}>
-
-
- ))}
+
+ {/*
+ Letter spacing was rendered but had no control, so type the
+ layout had tracked out could be edited in every way except the
+ one that made it look like that.
+ */}
+ set({ tracking: v })}
+ />
- set({ color: c })} />
+
+ set({ color: c })} />
+
>
)}
@@ -250,66 +376,79 @@ export function StudioInspector({
{el.kind === 'photo' && (
<>
-
- {(['cover', 'contain'] as const).map(f => (
- set({ fit: f })}>
- {t(`journey.studio.fit.${f}`)}
-
- ))}
-
- live({ focalX: x, focalY: y })}
- onCommit={(x, y) => set({ focalX: x, focalY: y })}
- hint={t('journey.studio.focalHint')}
- />
-
-
-
- {(['none', 'bw', 'warm', 'cool', 'fade', 'contrast'] as const).map(f => (
- set({ filter: f })}>
- {t(`journey.studio.filter.${f}`)}
-
- ))}
-
-
- {/* A corner radius and a mask are the same idea at different
- resolutions, so the radius steps aside once a shape is cut. */}
- set({ radius: v })}
+
+ ({
+ value: f, label: t(`journey.studio.fit.${f}`),
+ }))}
+ onPick={fit => set({ fit })}
/>
-
+
+ {/*
+ An empty frame has no picture to aim at, and asking for one by
+ id produced a request for /api/photos/null/thumbnail and a
+ broken image under a caption telling you to drag it.
+ */}
+ {el.photoId != null ? (
+ live({ focalX: x, focalY: y })}
+ onCommit={(x, y) => set({ focalX: x, focalY: y })}
+ hint={t('journey.studio.focalHint')}
+ />
+ ) : (
+ {t('journey.studio.emptyFrame')}
+ )}
-
-
- {([
- ['none', 'plainFrame'],
- ['polaroid', 'polaroidFrame'],
- ['white', 'whiteFrame'],
- ['shadow', 'shadowFrame'],
- ['film', 'filmFrame'],
- ['tape', 'tapeFrame'],
- ] as const).map(([style, key]) => (
-
set({ frameStyle: style })}
- >
- {t(`journey.studio.${key}`)}
-
- ))}
+
+
+ ({
+ value: f, label: t(`journey.studio.filter.${f}`),
+ }))}
+ onPick={filter => set({ filter })}
+ />
+
+
+
+ ({ value: style, label: t(`journey.studio.${key}`) }))}
+ onPick={frameStyle => set({ frameStyle })}
+ />
+
+ {/* A corner radius and a mask are the same idea at different
+ resolutions, so the radius steps aside once a shape is cut.
+ It did not, and edited a property the renderer ignored. */}
+ {(!el.mask || el.mask === 'rect') && (
+
+ set({ radius: v })}
+ />
+
+ )}
-
+
))}
-
-
set({ fill: c })} />
+
+ {/* A photo had a corner radius and a shape did not, although
+ both store the same field and the renderer draws both. */}
+ set({ radius: v })}
+ />
-
-
- {([
- ['solid', 'strokeSolid'],
- ['dashed', 'strokeDashed'],
- ['dotted', 'strokeDotted'],
- ] as const).map(([style, key]) => (
-
set({
- strokeStyle: style,
- // A stroke style with no stroke is invisible, so asking
- // for dashes turns the outline on rather than doing
- // nothing and looking broken.
- ...(el.stroke ? {} : { stroke: el.fill ?? '#141414', strokeWidth: el.strokeWidth || 0.5 }),
- })}
- >
- {t(`journey.studio.${key}`)}
-
- ))}
+
+ {/*
+ 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.
+ */}
+
+ set({ fill: c })} />
+
+
+ set({ stroke: c, strokeWidth: el.strokeWidth || 0.5 })}
+ />
+
+
+ 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 } : {}),
+ })}
+ />
+ set({ gradient: el.gradient === 'none' ? 'down' : 'none' })}
+ />
-
-
+
+ set({ gradient })}
+ />
+
+
+ )}
+
+
+
+
+ ({ value: style, label: t(`journey.studio.${key}`) }))}
+ onPick={strokeStyle => set({
+ strokeStyle,
+ // A stroke style with no stroke is invisible, so asking for
+ // dashes turns the outline on rather than doing nothing.
+ ...(el.stroke ? {} : { stroke: el.fill ?? '#141414', strokeWidth: el.strokeWidth || 0.5 }),
+ })}
+ />
+
+
+ set({ strokeWidth: v, ...(v > 0 && !el.stroke ? { stroke: '#141414' } : {}) })}
/>
@@ -392,9 +586,102 @@ export function StudioInspector({
>
)}
-
+ {el.kind === 'icon' && (
+ <>
+
-
+
+
+ set({ color: c })} />
+
+
+ set({ lineWidth: v })}
+ />
+
+
+ >
+ )}
+
+
+
+ {/*
+ Where it sits, folded away.
+
+ Everything in here can be done by dragging, which is how it is
+ actually done; the numbers are for the one time in ten you need the
+ same margin on two pages. Opacity and rotation live here too — both
+ were painted by the renderer and neither had a control anywhere.
+ */}
+ {one && (
+
+
+ set({ frame: { ...el.frame, x: v } })} />
+ set({ frame: { ...el.frame, y: v } })} />
+ set({ frame: { ...el.frame, w: v } })} />
+ set({ frame: { ...el.frame, h: v } })} />
+
+
+ set({ rotation: v })}
+ />
+ set({ opacity: Math.min(1, Math.max(0, v / 100)) })}
+ />
+
+
+ )}
+
+
raise(spreadIndex, el.id, 'front')} title={t('journey.studio.toFront')}>
@@ -409,57 +696,12 @@ export function StudioInspector({
-
- set({ locked: !el.locked })}>
- {el.locked ? : }
- {t(el.locked ? 'journey.studio.unlock' : 'journey.studio.lock')}
-
- removeElements(spreadIndex, selection)}>
-
- {t('journey.studio.delete')}
-
-
-
-
- {t('journey.studio.pageHint')} {page.pageWidth} × {page.pageHeight} mm
-
)
}
-function Section({ label, children }: { label: string; children: React.ReactNode }) {
- return (
-
- )
-}
-
-function Num({
- label, value, onChange, min, max, step = 0.5,
-}: { label: string; value: number; onChange: (v: number) => void; min?: number; max?: number; step?: number }) {
- return (
-
- {label}
- {
- const v = Number(e.target.value)
- if (Number.isFinite(v)) onChange(v)
- }}
- />
-
- )
-}
-
/**
* Where the picture is anchored inside its frame.
*
diff --git a/client/src/components/Studio/StudioShell.tsx b/client/src/components/Studio/StudioShell.tsx
index 93619b2f0..383bb012e 100644
--- a/client/src/components/Studio/StudioShell.tsx
+++ b/client/src/components/Studio/StudioShell.tsx
@@ -94,6 +94,7 @@ export default function StudioShell() {
spreadIndex={s.activeSpread}
page={s.page}
stats={s.stats}
+ source={s.source}
setPageNumbers={s.setPageNumbers}
t={s.t}
locale={s.locale}
@@ -380,6 +381,35 @@ function FormatPicker({ s }: { s: Studio }) {
/>
mm
+
+ {/*
+ What the press takes off, and what it must not print into.
+
+ Under the trim size because they answer the same question — how big
+ is this book, really — and because a vendor states all three in one
+ breath. They were fixed at 3 and 5, which is what most photo-book
+ printers ask for and is useless to the one that asks for 5 and 10.
+ */}
+
+ s.setPageEdge('bleed', v)}
+ />
+
+ s.setPageEdge('safe', v)}
+ />
+ mm
+
)}
diff --git a/client/src/components/Studio/StudioSidebar.tsx b/client/src/components/Studio/StudioSidebar.tsx
index dc526e6d5..d3068071d 100644
--- a/client/src/components/Studio/StudioSidebar.tsx
+++ b/client/src/components/Studio/StudioSidebar.tsx
@@ -9,6 +9,8 @@ import { useStudioStore } from '../../store/studioStore'
import { formatDate } from '../../utils/formatters'
import { SpreadFold, SpreadView } from './SpreadView'
import { photoSrc } from './bookRender'
+import { formatBookCoords, formatBookDate, type CoordFormat } from './entryText'
+import { coordValue } from './resolveBindings'
import { COVER_TEMPLATES, TEMPLATES, applyTemplate } from './templates'
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
import { PanelHead as Head } from './StudioPanelHead'
@@ -36,6 +38,9 @@ export interface JourneySource {
story: string | null
location: string | null
date: string | null
+ /** Where the stop is, when the entry recorded one. Null on a written-only entry. */
+ lat: number | null
+ lng: number | null
mood: string | null
weather: string | null
pros: string[]
@@ -338,14 +343,39 @@ function ContentPanel({
} as BookElement)
}
- const dropText = (value: string, size: number, weight: 400 | 700, entryId: number, kind: 'entry.title' | 'entry.story' | 'entry.location' | 'entry.date') => {
- const w = page.pageWidth - 32
+ /**
+ * One piece of the entry, dropped on the page and still attached to it.
+ *
+ * `format` is what separates a place name from a set of coordinates: both
+ * read the entry's location, and which of the two gets printed is a property
+ * of the binding rather than a second source — see the contract. `width`
+ * exists because a line of coordinates arriving inside a full-column
+ * selection rectangle looks like a bug; only the short marks pass it.
+ */
+ const dropText = (
+ value: string,
+ size: number,
+ weight: 400 | 700,
+ entryId: number,
+ kind: 'entry.title' | 'entry.story' | 'entry.location' | 'entry.date',
+ opts: { format?: CoordFormat; width?: number; value?: string } = {},
+ ) => {
+ const w = opts.width ?? page.pageWidth - 32
addElement(active, {
id: uid('t'), kind: 'text', frame: centre(w, size * 0.4 + 10),
rotation: 0, opacity: 1, locked: false,
text: value, font: 'sans', size, weight, italic: false,
align: 'left', leading: weight === 700 ? 1.1 : 1.55, tracking: weight === 700 ? -0.02 : 0,
- color: '#1a1a1a', binding: { source: kind, entryId }, overridden: false,
+ color: '#1a1a1a',
+ binding: {
+ source: kind,
+ entryId,
+ ...(opts.format ? { format: opts.format } : {}),
+ // The fact behind the words, for the marks whose words are formatted —
+ // see `value` in the contract.
+ ...(opts.value ? { value: opts.value } : {}),
+ },
+ overridden: false,
} as BookElement)
}
@@ -364,6 +394,8 @@ function ContentPanel({
const dropMark = (variant: 'mood' | 'weather', code: string, label: string) =>
addElement(active, {
...base, id: uid('bd'), kind: 'badge', autoColor: true,
+ // Set here as well as in the contract: this object is cast, not parsed.
+ showIcon: true, showLabel: true, autoIconColor: true, iconColor: '#111111',
frame: centre(page.pageWidth * 0.22, page.pageHeight * 0.062),
variant, text: label, sub: '', code, style: 'plain',
} as BookElement)
@@ -473,6 +505,33 @@ function ContentPanel({
{t('journey.studio.addPlace')}
)}
+ {/*
+ The day and the point. Both are set the way the rest of the
+ book sets them — the long date, coordinates in degrees — so a
+ mark placed here and one the auto layout placed read alike.
+ */}
+ {e.date && (
+
dropText(
+ formatBookDate(e.date!, locale), 8, 700, e.id, 'entry.date',
+ { width: page.pageWidth * 0.36, value: e.date! },
+ )}
+ >
+ {t('journey.studio.dateMark')}
+
+ )}
+ {e.lat != null && e.lng != null && (
+
dropText(
+ formatBookCoords(e.lat!, e.lng!, 'dms'), 8, 700, e.id, 'entry.location',
+ { format: 'dms', width: page.pageWidth * 0.36, value: coordValue(e.lat!, e.lng!) },
+ )}
+ >
+ {t('journey.studio.coordsMark')}
+
+ )}
{/*
What the entry recorded besides its story. Each chip shows
the value rather than the field name — "Sunny" tells you
diff --git a/client/src/components/Studio/StudioTravelInspector.tsx b/client/src/components/Studio/StudioTravelInspector.tsx
index 313a946dd..b848fbc3b 100644
--- a/client/src/components/Studio/StudioTravelInspector.tsx
+++ b/client/src/components/Studio/StudioTravelInspector.tsx
@@ -1,3 +1,4 @@
+import { useState } from 'react'
import type {
BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement,
BookMetric, BookStatsElement, JourneyStats,
@@ -6,7 +7,11 @@ import { BOOK_METRICS } from '@trek/shared'
import { isStale, refreshPatch } from './travelRefresh'
import { Swatches } from './StudioSwatches'
import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts'
-import { useMapSources } from './mapSources'
+import { sourceIdOf, useMapSources } from './mapSources'
+import { printDpi, tileView } from './mapTiles'
+import { routeFor } from './travelRefresh'
+import { Choice, Line, Picks, Section, Switch } from './StudioControls'
+import { fetchRoads } from './roadRoute'
/**
* Properties of the travel elements.
@@ -24,18 +29,15 @@ export interface TravelInspectorProps {
set: (patch: Partial
) => void
t: (k: string) => string
locale: string
- Section: (p: { label: string; children: React.ReactNode }) => React.JSX.Element
}
-function Toggle({ on, label, onClick }: { on: boolean; label: string; onClick: () => void }) {
- return (
-
- {label}
-
- )
+interface Props {
+ el: T
+ set: (patch: Partial) => void
+ t: (k: string) => string
}
-export function TravelInspector({ el, stats, set, t, Section }: TravelInspectorProps) {
+export function TravelInspector({ el, stats, set, t }: TravelInspectorProps) {
if (
el.kind !== 'map' && el.kind !== 'stats' && el.kind !== 'countries'
&& el.kind !== 'badge' && el.kind !== 'list'
@@ -63,11 +65,11 @@ export function TravelInspector({ el, stats, set, t, Section }: TravelInspectorP
)}
- {el.kind === 'map' && }
- {el.kind === 'stats' && }
- {el.kind === 'countries' && }
- {el.kind === 'badge' && }
- {el.kind === 'list' && }
+ {el.kind === 'map' && }
+ {el.kind === 'stats' && }
+ {el.kind === 'countries' && }
+ {el.kind === 'badge' && }
+ {el.kind === 'list' && }
{/*
* Type, for all five alike.
@@ -78,7 +80,7 @@ export function TravelInspector({ el, stats, set, t, Section }: TravelInspectorP
* stats block in the default sans is a book with one element in the
* wrong typeface and no way to fix it.
*/}
-
+
{BOOK_FONT_ORDER.map(id => {
const font = BOOK_FONTS[id]
@@ -101,102 +103,91 @@ export function TravelInspector({ el, stats, set, t, Section }: TravelInspectorP
)
})}
-
- {([400, 500, 600, 700] as const).map(w => (
-
set({ weight: w } as Partial)}
- >
- {w}
-
- ))}
+
+
+ ({
+ value: w,
+ label: String(w),
+ disabled: !hasWeight(el.font, w),
+ title: hasWeight(el.font, w) ? undefined : t('journey.studio.weightMissing'),
+ }))}
+ onPick={weight => set({ weight } as Partial)}
+ />
+
+
+ ({
+ value: scale, label: `${scale}×`,
+ }))}
+ onPick={textScale => set({ textScale } as Partial)}
+ />
+
{/*
- * Colour, for all five alike.
+ * Colour, for all five alike, and in one group.
+ *
+ * These were two sections called Accent and Colour, one under the other,
+ * each ten unlabelled swatches wrapping onto two rows: twenty squares and
+ * no way to tell which of them painted what. The role now sits beside the
+ * row and says it — the accent is the mark's own colour, the ink is what
+ * is written on it.
*
* They ship with one warm accent so a book made without touching anything
- * still reads as designed — but that is a starting point, not a house
- * style. A book printed to match a cover, or one that simply should not be
- * orange, needs both of these, and they are the same two properties on
- * every travel element.
+ * still reads as designed. That is a starting point, not a house style: a
+ * book printed to match a cover, or one that simply should not be orange,
+ * needs both of these.
*/}
-
- set({ accent: c } as Partial)} />
-
- {/*
- Picking a colour is what turns automatic off.
-
- A chip works out its own text colour from the fill it carries, which
- is right until somebody wants something else — and then a separate
- switch to find first is one step too many. So the swatch does both,
- and the Automatic chip beside it is the way back rather than the way
- in. Only marks have this; the other elements have no fill of their
- own to answer to.
- */}
- set(
- (el.kind === 'badge' ? { color: c, autoColor: false } : { color: c }) as Partial,
- )}
- />
+
+ set({ accent: c } as Partial)} />
+
+
+ {/*
+ Picking a colour is what turns automatic off, so the swatch does
+ both and the switch below is the way back rather than the way in.
+ Only marks have it; nothing else here has a fill of its own for the
+ words to answer to.
+ */}
+ set(
+ (el.kind === 'badge' ? { color: c, autoColor: false } : { color: c }) as Partial,
+ )}
+ />
+
{el.kind === 'badge' && (
-
-
+ set({ autoColor: !el.autoColor } as Partial)}
+ on={el.autoColor}
+ onToggle={() => set({ autoColor: !el.autoColor } as Partial)}
/>
)}
-
-
- {([0.7, 0.85, 1, 1.2, 1.5] as const).map(scale => (
- set({ textScale: scale } as Partial)}
- >
- {scale === 1 ? '1×' : `${scale}×`}
-
- ))}
-
-
>
)
}
-function ListProps({ el, set, t, Section }: {
- el: BookListElement
- set: (patch: Partial
) => void
- t: (k: string) => string
- Section: TravelInspectorProps['Section']
-}) {
+function ListProps({ el, set, t }: Props) {
return (
-
- {(['columns', 'stacked'] as const).map(layout => (
- set({ layout } as Partial)}
- >
- {t(layout === 'columns' ? 'journey.studio.layoutGrid' : 'journey.studio.layoutColumn')}
-
- ))}
- set({ showMarks: !el.showMarks } as Partial)}
+
+ set({ layout } as Partial)}
/>
-
-
+
+
{t('journey.editor.pros')}
+
+ set({ showMarks: !el.showMarks } as Partial)}
+ />
+
)
}
-function MapProps({ el, set, t, Section }: {
- el: BookMapElement
- set: (patch: Partial
) => void
- t: (k: string) => string
- Section: TravelInspectorProps['Section']
-}) {
- const sources = useMapSources(el.frame, el.points)
+function MapProps({ el, set, t, stats }: Props & { stats: JourneyStats | null }) {
+ const [roadState, setRoadState] = useState<'idle' | 'busy'>('idle')
+ const [roadDone, setRoadDone] = useState(0)
+ const hasRoads = (el.roads ?? []).some(r => r && r.length > 1)
+
+ /*
+ * One leg at a time, paced, and written into the document when it is done
+ * rather than leg by leg: a hundred commits would be a hundred undo steps and
+ * a hundred autosaves for one action.
+ */
+ const loadRoads = async () => {
+ setRoadState('busy')
+ setRoadDone(0)
+ try {
+ const roads = await fetchRoads(
+ el.points.map(p => ({ lat: p.lat, lng: p.lng })),
+ { onProgress: done => setRoadDone(done) },
+ )
+ set({ roads } as Partial)
+ } finally {
+ setRoadState('idle')
+ }
+ }
+
+ const sources = useMapSources(el.frame, el.points, { source: el.source, tileUrl: el.tileUrl })
+ /** Only trips that put stops on the route: a map of the others is a blank. */
+ const tripsWithRoute = (stats?.trips ?? []).filter(t => t.points > 0)
+ /*
+ * The grid this element will really fetch, asked for here so the panel can
+ * say what it will print at. Same call the renderer makes, same answer.
+ */
+ const view = el.source === 'tiles' && el.tileUrl
+ ? tileView(el.points, { w: el.frame.w, h: el.frame.h }, el.tileUrl, el.zoom)
+ : null
+ const dpi = view ? printDpi(view, el.tileUrl) : null
+
return (
<>
+ {/*
+ Which trip, when the journey is made of more than one.
+
+ A journey of two trips drawn as one route puts a line from the last
+ stop of the first to the first stop of the second: usually the longest
+ leg on the page, and one nobody travelled. Switching here re-cuts the
+ route on the spot rather than at the next refresh, because a control
+ that changes nothing visible reads as broken.
+ */}
+ {tripsWithRoute.length > 1 && (
+
+ ({
+ value: trip.id,
+ label: trip.title || t('journey.studio.untitled'),
+ })),
+ ]}
+ onPick={id => {
+ if (!stats) return
+ const tripId = id === 0 ? null : id
+ const route = routeFor(stats, tripId)
+ set({
+ 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 })),
+ } as Partial)
+ }}
+ />
+
+ )}
+
+ {/* Where the picture comes from and what it looks like: one question
+ with two halves, which is one group rather than two. */}
-
- {(['minimal', 'outline', 'paper', 'dark'] as const).map(style => (
- set({ style } as Partial)}
- >
- {t(`journey.studio.mapStyle.${style}`)}
-
- ))}
-
-
-
-
- {sources.map(src => (
-
set({
- source: src.id,
+
+ ({ value: src.id, label: t(src.labelKey) }))}
+ onPick={id => {
+ const src = sources.find(x => x.id === id)
+ if (!src) return
+ set({
+ source: src.source,
tileUrl: src.url,
// The credit travels with the source. Switching to imagery and
// leaving the previous attribution would print the wrong one.
attribution: src.attribution,
- } as Partial)}
- >
- {t(src.labelKey)}
-
- ))}
+ } as Partial
)
+ }}
+ />
+
+
+
+ ({
+ value: style, label: t(`journey.studio.mapStyle.${style}`),
+ }))}
+ onPick={style => set({ style } as Partial)}
+ />
+
{el.source !== 'vector' && (
- {t('journey.studio.mapSourceHint')}
+ <>
+
+ {t(SOURCE_HINT[sourceIdOf(el, sources)] ?? 'journey.studio.mapSourceHint')}
+
+ {/*
+ What this map will actually print at, at the size it is now.
+ A number rather than a warning icon: 300 means nothing to argue
+ with, and somebody who wants a soft map on purpose can have one.
+ */}
+ {dpi != null && (
+
+ {t('journey.studio.mapPrintDpi')} {dpi} dpi
+ {dpi < 150 && ` — ${t('journey.studio.mapPrintDpiLow')}`}
+
+ )}
+ >
)}
@@ -274,108 +352,201 @@ function MapProps({ el, set, t, Section }: {
two dots in the middle of it.
*/}
-
- set({ fitToCountries: false } as Partial)}
- >
- {t('journey.studio.mapFitStops')}
-
- set({ fitToCountries: true } as Partial)}
- >
- {t('journey.studio.mapFitCountry')}
-
-
+
+ set({ fitToCountries: v === 'country' } as Partial)}
+ />
+
{/*
Room around what is drawn, as a share of it: the same setting has to
look right around a walk across a city and a drive across a
continent, and millimetres cannot do that.
*/}
-
- {([
- ['journey.studio.mapPadTight', 0.04],
- ['journey.studio.mapPadNormal', 0.18],
- ['journey.studio.mapPadWide', 0.5],
- ['journey.studio.mapPadFar', 1.2],
- ] as const).map(([key, value]) => (
- set({ fitPadding: value } as Partial)}
- >
- {t(key)}
-
- ))}
-
+
+ Math.abs(el.fitPadding - v) < 0.001) ?? 0.18}
+ options={[
+ { value: 0.04, label: t('journey.studio.mapPadTight') },
+ { value: 0.18, label: t('journey.studio.mapPadNormal') },
+ { value: 0.5, label: t('journey.studio.mapPadWide') },
+ { value: 1.2, label: t('journey.studio.mapPadFar') },
+ ]}
+ onPick={fitPadding => set({ fitPadding } as Partial)}
+ />
+
{/*
And the shape. A rectangle is a map in a box, which is what a full
page wants; cut to the coastline it stops being a figure and becomes
an illustration that can sit next to anything.
*/}
-
- set({ clip: 'rect' } as Partial)}
- >
- {t('journey.studio.mapClipRect')}
-
- set({ clip: 'country' } as Partial)}
- >
- {t('journey.studio.mapClipCountry')}
-
-
+
+ set({ clip } as Partial)}
+ />
+
{el.source === 'tiles' && (
-
-
- set({ zoom: null } as Partial)}
- >
- {t('journey.studio.mapZoomAuto')}
-
- {[3, 5, 7, 9, 11, 13, 15, 17].map(z => (
- set({ zoom: z } as Partial)}
- >
- {z}
-
- ))}
-
+
+ ({ value: z, label: String(z) })),
+ ]}
+ onPick={z => set({ zoom: z === -1 ? null : z } as Partial)}
+ />
)}
-
-
- {el.source !== 'tiles' && el.source !== 'static' && (
-
set({ showLand: !el.showLand } as Partial)} />
+ {/*
+ How the line itself is drawn, and whether it follows real roads.
+
+ Open by default, unlike the groups below it. It was folded, on the
+ reasoning that a look is chosen once per book — but a control nobody can
+ see is a control nobody has, and the first thing anyone wants from a map
+ of a journey is the line across it.
+ */}
+
+
+ set({ routeStyle } as Partial)}
+ />
+
+ {/*
+ The roads, fetched once and kept.
+
+ Not a switch, because it is not free: it asks a public routing service
+ one question per leg, from this browser, and the answer is then frozen
+ into the document so the book prints the same line offline and next
+ year. A switch would imply it could simply be turned back on later,
+ on a page that is being exported, which is exactly when it cannot.
+ */}
+
+ {/*
+ Which way the line goes between two stops.
+
+ A choice rather than a button, because that is what it is from where
+ the user sits: the roads, or the way a map has always drawn a leg it
+ does not know. That the roads have to be fetched once and are then
+ kept in the book is an implementation detail, and the only place it
+ shows is the counter while it works.
+ */}
+
+ {roadState === 'busy' ? (
+
+
+ {t('journey.studio.roadsBusy')} {roadDone}/{Math.max(1, el.points.length - 1)}
+
+
+ ) : (
+ {
+ if (v === 'direct') {
+ set({ roads: [] } as Partial)
+ } else {
+ void loadRoads()
+ }
+ }}
+ />
+ )}
+
+ {hasRoads && roadState !== 'busy' && (
+
+ void loadRoads()}>
+ {t('journey.studio.roadsAgain')}
+
+
)}
-
set({ showRoute: !el.showRoute } as Partial)} />
- set({ showPins: !el.showPins } as Partial)} />
- set({ showLabels: !el.showLabels } as Partial)} />
+
+ {t(hasRoads ? 'journey.studio.roadsHave' : 'journey.studio.roadsHint')}
+
+
+ {el.routeStyle === 'drawn' && (
+ <>
+
+ set({ routeArc } as Partial)}
+ />
+
+ {el.routeArc === 'bow' && (
+
+ set({
+ routeDash: el.routeDash === 'arcs' ? 'solid' : 'arcs',
+ } as Partial)}
+ />
+
+ )}
+
+
+ set({ pinStyle } as Partial)}
+ />
+
+
+ {el.pinStyle === 'photo' && !el.points.some(p => p.photoId != null) && (
+ {t('journey.studio.pinPhotoNone')}
+ )}
+ >
+ )}
+
+
+ {/* Four things that are either drawn or not. They used to be the same
+ pill as the style choice above, so a layer looked like an option. */}
+
+ {el.source !== 'tiles' && el.source !== 'static' && (
+ set({ showLand: !el.showLand } as Partial)} />
+ )}
+ set({ showRoute: !el.showRoute } as Partial)} />
+ set({ showPins: !el.showPins } as Partial)} />
+ set({ showLabels: !el.showLabels } as Partial)} />
>
)
}
-function StatsProps({ el, set, t, Section }: {
- el: BookStatsElement
- set: (patch: Partial) => void
- t: (k: string) => string
- Section: TravelInspectorProps['Section']
-}) {
+function StatsProps({ el, set, t }: Props) {
const toggleMetric = (m: BookMetric) => {
const next = el.metrics.includes(m) ? el.metrics.filter(x => x !== m) : [...el.metrics, m]
// An empty panel would be a blank rectangle nobody could get back out of,
@@ -386,44 +557,46 @@ function StatsProps({ el, set, t, Section }: {
return (
<>
+ {/*
+ Which figures, out of seven. A set rather than a row of choices, and it
+ looks like one now: the picked ones carry a tick instead of being the
+ same filled pill the layout below uses for "this one".
+ */}
-
- {BOOK_METRICS.map(m => (
- toggleMetric(m)}
- />
- ))}
-
+ ({ value: m, label: t(`journey.studio.metric.${m}`) }))}
+ onToggle={toggleMetric}
+ />
-
- {(['grid', 'row', 'column'] as const).map(layout => (
- set({ layout } as Partial)}
- >
- {t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`)}
-
- ))}
-
-
- {(['metric', 'imperial'] as const).map(units => (
-
set({ units } as Partial)}
- >
- {units === 'metric' ? 'km' : 'mi'}
-
- ))}
-
+ ({
+ value: layout,
+ label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
+ }))}
+ onPick={layout => set({ layout } as Partial)}
+ />
+
+
+ set({ units } as Partial)}
+ />
+
+
+ {/* Its label used to be "Elements", which is the name of a panel on
+ the other side of the screen and not what this draws. */}
+ set({ showIcons: !el.showIcons } as Partial)}
+ onToggle={() => set({ showIcons: !el.showIcons } as Partial)}
/>
@@ -431,40 +604,34 @@ function StatsProps({ el, set, t, Section }: {
)
}
-function CountriesProps({ el, set, t, Section }: {
- el: BookCountriesElement
- set: (patch: Partial) => void
- t: (k: string) => string
- Section: TravelInspectorProps['Section']
-}) {
+function CountriesProps({ el, set, t }: Props) {
return (
-
- {(['list', 'grid', 'column'] as const).map(layout => (
- set({ layout } as Partial)}
- >
- {t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`)}
-
- ))}
-
-
- set({ showOutline: !el.showOutline } as Partial)} />
- set({ showFlag: !el.showFlag } as Partial)} />
- set({ showName: !el.showName } as Partial)} />
-
-
- {(['left', 'center', 'right'] as const).map(align => (
-
set({ align } as Partial)}
- >
- {align}
-
- ))}
+
+ ({
+ value: layout,
+ label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
+ }))}
+ onPick={layout => set({ layout } as Partial)}
+ />
+
+ {/* These three read as raw English out of the code before: `left`,
+ `center`, `right`, straight from the value. */}
+
+ ({
+ value: align, label: t(`journey.studio.align.${align}`),
+ }))}
+ onPick={align => set({ align } as Partial)}
+ />
+
+
+ set({ showOutline: !el.showOutline } as Partial)} />
+ set({ showFlag: !el.showFlag } as Partial)} />
+ set({ showName: !el.showName } as Partial)} />
{/*
@@ -503,41 +670,97 @@ function CountriesProps({ el, set, t, Section }: {
)
}
-function BadgeProps({ el, set, t, Section }: {
- el: BookBadgeElement
- set: (patch: Partial
) => void
- t: (k: string) => string
- Section: TravelInspectorProps['Section']
-}) {
+/** What each imagery source is for, said once where it is chosen. */
+const SOURCE_HINT: Record = {
+ relief: 'journey.studio.mapSourceReliefHint',
+ satellite: 'journey.studio.mapSourceSatelliteHint',
+}
+
+/** The variants that draw something as well as saying something. */
+const ICON_VARIANTS = ['flag', 'country', 'mood', 'weather']
+
+function BadgeProps({ el, set, t }: Props) {
+ const showIcon = el.showIcon !== false
+ const showLabel = el.showLabel !== false
+ const parts = (icon: boolean, label: boolean) =>
+ set({ showIcon: icon, showLabel: label } as Partial)
+
return (
+ <>
+
+ {/*
+ The icon, for the marks that have one.
+
+ Three combinations rather than two switches: a mark with neither its
+ picture nor its words is an empty box, and offering it as a state people
+ can reach means offering them a way to lose an element on the page.
+ */}
+ {ICON_VARIANTS.includes(el.variant) && (
+
+
+ parts(v !== 'label', v !== 'icon')}
+ />
+
+ {showIcon && (
+ <>
+ {/*
+ A mood's icon carries the journal's own colour and the others
+ carry the element's accent — which is right until it is not, and
+ until now there was no way past it: the swatch below is the only
+ thing that reaches the icon, because lucide takes its colour as a
+ prop rather than inheriting it.
+ */}
+
+ set({ iconColor: c, autoIconColor: false } as Partial)}
+ />
+
+
+ set({ autoIconColor: el.autoIconColor === false } as Partial)}
+ />
+
+ >
+ )}
+
+ )}
+ >
)
}
diff --git a/client/src/components/Studio/StudioTravelPanel.tsx b/client/src/components/Studio/StudioTravelPanel.tsx
index d73e7feba..29c0594cb 100644
--- a/client/src/components/Studio/StudioTravelPanel.tsx
+++ b/client/src/components/Studio/StudioTravelPanel.tsx
@@ -3,6 +3,10 @@ import { BOOK_METRICS } from '@trek/shared'
import { useStudioStore } from '../../store/studioStore'
import { PanelHead } from './StudioPanelHead'
import { TravelPreview } from './TravelPreview'
+import { formatBookCoords } from './entryText'
+import { routeFor } from './travelRefresh'
+import { useMapSources } from './mapSources'
+import { fetchRoads } from './roadRoute'
/**
* The journey's own figures, as things you can put on a page.
@@ -47,27 +51,27 @@ function formatDistance(metres: number, locale: string): string {
return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(metres / 1000)} km`
}
-/**
- * Degrees and minutes with a hemisphere letter, the way a chart labels a
- * position. The decimal form is what a machine reads; this is for a page
- * someone is holding.
- */
-function formatCoords(lat: number, lng: number): string {
- const part = (v: number, pos: string, neg: string) => {
- const deg = Math.floor(Math.abs(v))
- const min = Math.round((Math.abs(v) - deg) * 60)
- return `${deg}°${String(min).padStart(2, '0')}'${v >= 0 ? pos : neg}`
- }
- return `${part(lat, 'N', 'S')} ${part(lng, 'E', 'W')}`
-}
-
-function Card({ label, onClick, children }: {
+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" is two lines of ellipsis. 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 (
-
+
{children}
{label}
@@ -85,6 +89,7 @@ export function StudioTravelPanel({
locale: string
}) {
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]
@@ -95,6 +100,23 @@ 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 (
<>
@@ -144,7 +166,13 @@ export function StudioTravelPanel({
* click places it, so the preview and the result are the same object — there
* is no second description of what a card means.
*/
- const mapSide = Math.min(page.pageWidth, page.pageHeight) * 0.72
+ /** The linked trips that actually put stops on the route. */
+ const tripsWithRoute = (stats.trips ?? []).filter(t => t.points > 0)
+
+ /* 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.
@@ -172,22 +200,44 @@ export function StudioTravelPanel({
url = '',
attribution = '',
clip: 'rect' | 'country' = 'rect',
- ): BookElement => ({
+ /** 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,
+ 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,
- countries: stats.countries.map(c => c.code),
- points: stats.points.map(p => ({ lat: p.lat, lng: p.lng, label: p.label })),
+ /*
+ * 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,
- fitPadding: 0.18,
+ // Empty until somebody asks for the roads; see roadRoute.ts.
+ roads: [],
+ fitPadding: 0.5,
fitToCountries: true,
- } as BookElement)
+ } as BookElement
+ }
/*
* A mark is placed at the size that suits what it holds.
@@ -216,6 +266,7 @@ export function StudioTravelPanel({
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
@@ -236,7 +287,32 @@ export function StudioTravelPanel({
} as BookElement)
/** Place a copy — the previewed element keeps its own id for React. */
- const place = (el: BookElement) => addElement(active, { ...el, id: uid(el.kind[0]) } as BookElement)
+ 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) {
+ void fetchRoads(placed.points.map(pt => ({ lat: pt.lat, lng: pt.lng })))
+ .then(roads => {
+ if (roads.some(r => r && r.length > 1)) {
+ updateElement(active, placed.id, { roads } as Partial)
+ }
+ })
+ .catch(() => { /* No roads is a state the map already draws. */ })
+ }
+ }
const first = stats.points[0] ?? null
const firstCountry = stats.countries[0] ?? null
@@ -256,7 +332,7 @@ export function StudioTravelPanel({
})
if (first) {
marks.push({
- el: badgeEl('coords', formatCoords(first.lat, first.lng), first.label, null),
+ el: badgeEl('coords', formatBookCoords(first.lat, first.lng), first.label, null),
label: t('journey.studio.coordsMark'),
})
}
@@ -302,8 +378,74 @@ export function StudioTravelPanel({
)
})}
+ {/*
+ 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 => (
+ place(mapEl('minimal', 'tiles', src.url, src.attribution))}
+ >
+
+
+ ))}
+ {/*
+ 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 && (
+
+
{t('journey.studio.mapPerTrip')}
+
+ {tripsWithRoute.map(trip => {
+ const el = mapEl('minimal', 'vector', '', '', 'rect', trip.id)
+ return (
+ place(el)}
+ >
+
+
+ )
+ })}
+
+
+ )}
>
) : (
{t('journey.studio.noRoute')}
diff --git a/client/src/components/Studio/TravelElements.tsx b/client/src/components/Studio/TravelElements.tsx
index 93eccec83..07e09d933 100644
--- a/client/src/components/Studio/TravelElements.tsx
+++ b/client/src/components/Studio/TravelElements.tsx
@@ -73,7 +73,30 @@ function typeSize(el: TravelElement, base: number): number {
const round2 = (n: number) => Math.round(n * 100) / 100
/** Tile geometry, kept finer than the rest: see the note where it is used. */
const round3 = (n: number) => Math.round(n * 1000) / 1000
-const TILE_OVERLAP = 0.12
+
+/**
+ * How far each tile is drawn past its own square, in millimetres.
+ *
+ * ── Why it is this large, and why it is a fraction ───────────────────────
+ *
+ * A tile boundary that lands mid-pixel leaves a hairline of paper showing
+ * through the map, and the eye finds it immediately: it reads as a fold or a
+ * scratch rather than as rounding. Drawing each tile slightly larger than its
+ * spacing closes that, and costs nothing, because the strip that overlaps is
+ * the same picture the neighbour draws there anyway.
+ *
+ * It used to be a flat twelve hundredths of a millimetre, which is about half a
+ * device pixel at 1:1 — and the editor does not draw at 1:1. The page is
+ * scaled, so at a third of full size that overlap is a sixth of a pixel and the
+ * seams come back, which is exactly when a map is full of them: a page-sized
+ * satellite map is a hundred and fifty tiles, so it has some three hundred
+ * internal edges to get wrong.
+ *
+ * Four percent of a tile survives every zoom the canvas offers, and the floor
+ * covers the case of a tile drawn very small. The element clips, so the
+ * overhang at the last row and column never shows.
+ */
+const tileOverlapMm = (size: number) => Math.max(0.8, size * 0.04)
/** #rrggbb plus an alpha. */
function rgba(hex: string, alpha: number): string {
@@ -81,8 +104,13 @@ function rgba(hex: string, alpha: number): string {
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`
}
-export function TravelElementView({ el, frameStyle }: { el: BookElement; frameStyle: CSSProperties }) {
- if (el.kind === 'map') return
+export function TravelElementView({ el, frameStyle, big = false }: {
+ el: BookElement
+ frameStyle: CSSProperties
+ /** Drawn at a size worth fetching real imagery for, rather than as a thumbnail. */
+ big?: boolean
+}) {
+ if (el.kind === 'map') return
if (el.kind === 'stats') return
if (el.kind === 'countries') return
if (el.kind === 'badge') return
@@ -166,18 +194,24 @@ function routeInk(accent: string, onDark: boolean): string {
* than a fixed number of millimetres, which keeps the margin looking the same
* whether the map is 40mm across or 300.
*/
-function MapView({ el, frameStyle }: { el: BookMapElement; frameStyle: CSSProperties }) {
+function MapView({ el, frameStyle, big = false }: {
+ el: BookMapElement
+ frameStyle: CSSProperties
+ /** False in a thumbnail, where a page of imagery would be a page of waste. */
+ big?: boolean
+}) {
const palette = MAP_PALETTES[el.style]
// Per element: two cut maps on one spread sharing a stencil would both be cut
// to whichever of them rendered last.
const clipId = `st-clip-${el.id}`
const ink = routeInk(el.accent, palette.onDark)
+ /** Whether the ground under the line is a photograph rather than paper. */
+ const imagery = el.source === 'tiles' || el.source === 'static'
/*
* Country outlines are the vector map's *subject*; over real imagery they are
* a second coastline drawn on top of the one in the picture. So they are only
* drawn when there is no picture underneath.
*/
- const imagery = el.source === 'tiles' || el.source === 'static'
/*
* The outlines are needed for two different jobs, and only one of them is
* drawing. As a picture they are the vector map's subject, and over real
@@ -208,6 +242,13 @@ function MapView({ el, frameStyle }: { el: BookMapElement; frameStyle: CSSProper
* down with it, in a renderer that also has to run for print.
*/
const path = el.path ?? []
+ /*
+ * The roads, one per leg, `null` where a leg has none. Read the same
+ * defensive way `path` is: an element built by hand and cast rather than
+ * parsed genuinely has the field missing, and `.length` on undefined takes
+ * the whole spread down in a renderer that also has to run for print.
+ */
+ const roads = el.roads ?? []
const trail = path.map(seg => seg.map(([lat, lng]) => projectMercator(lng, lat)))
/*
@@ -245,6 +286,109 @@ function MapView({ el, frameStyle }: { el: BookMapElement; frameStyle: CSSProper
maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y)
}
}
+ /*
+ * A road wanders well outside the box around the two stops it joins — that is
+ * the whole point of drawing it. Fitted to the stops alone, the interesting
+ * part of it would be outside the frame.
+ */
+ for (const road of roads) {
+ if (!road) continue
+ for (const [lat, lng] of road) {
+ const q = projectMercator(lng, lat)
+ minX = Math.min(minX, q.x); minY = Math.min(minY, q.y)
+ maxX = Math.max(maxX, q.x); maxY = Math.max(maxY, q.y)
+ }
+ }
+
+ /*
+ * ── The bow ───────────────────────────────────────────────────────────
+ *
+ * A straight line between two stops a continent apart is a claim about the
+ * journey that is plainly false, and a shallow curve is how printed maps have
+ * always said "this part was a flight". Two gates decide how much:
+ *
+ * **How far it was, in kilometres.** A fact about the journey rather than
+ * about the zoom, so two cafes four kilometres apart stay straight at every
+ * size and Tokyo to Los Angeles bows at every size. Ramped smoothly from
+ * 150km to 15000km rather than switched at a threshold, or a route would have
+ * one leg visibly curved and its neighbour dead straight.
+ *
+ * **Which way it runs.** A meridian is a great circle *and* a straight line
+ * in Mercator, so bowing a due north-south leg draws a detour that never
+ * happened. The east-west share of the leg damps it.
+ *
+ * The direction is poleward from the route's MEAN latitude, decided once for
+ * the element: per leg, a chain crossing the equator would flip sides halfway
+ * along and read as random rather than as drawn.
+ */
+ const bowed = el.routeArc === 'bow' && trail.length === 0 && points.length > 1
+
+ /** Kilometres between two coordinates, for the ramp. */
+ const km = (a: { lat: number; lng: number }, b: { lat: number; lng: number }) => {
+ const r = (d: number) => (d * Math.PI) / 180
+ const dLat = r(b.lat - a.lat)
+ const dLng = r(b.lng - a.lng)
+ const h = Math.sin(dLat / 2) ** 2
+ + Math.cos(r(a.lat)) * Math.cos(r(b.lat)) * Math.sin(dLng / 2) ** 2
+ return 12742 * Math.asin(Math.min(1, Math.sqrt(h)))
+ }
+
+ const meanLat = el.points.length
+ ? el.points.reduce((sum, p) => sum + p.lat, 0) / el.points.length
+ : 0
+
+ /**
+ * How far each leg bows, as a share of its own chord, and which way.
+ *
+ * A fraction rather than a distance, so the same numbers are right on a 40mm
+ * inset and a 400mm spread — what the eye reads is the angle the line leaves
+ * its marker at, and that is scale free.
+ */
+ const bows: { k: number; nx: number; ny: number }[] = bowed
+ ? el.points.slice(0, -1).map((a, i) => {
+ const b = el.points[i + 1]
+ const pa = projectMercator(a.lng, a.lat)
+ const pb = projectMercator(b.lng, b.lat)
+ const dx = pb.x - pa.x
+ const dy = pb.y - pa.y
+ const d = Math.hypot(dx, dy)
+ if (d < 1e-6) return { k: 0, nx: 0, ny: 0 }
+
+ const u = Math.min(1, Math.max(0, Math.log(km(a, b) / 150) / Math.log(10)))
+ const ramp = u * u * (3 - 2 * u)
+ // How much of the leg runs east-west, which is where a bow is truthful.
+ const eastWest = 0.35 + 0.65 * Math.abs(dx / d)
+
+ // The normal, turned to point away from the equator side the route is on.
+ let nx = -dy / d
+ let ny = dx / d
+ const poleward = meanLat >= 0 ? -1 : 1
+ if (Math.abs(ny) < 1e-9) {
+ // A due north-south leg has no poleward side; break the tie east.
+ nx = Math.abs(nx)
+ } else if (Math.sign(ny) !== poleward) {
+ nx = -nx
+ ny = -ny
+ }
+ return { k: 0.07 * ramp * eastWest, nx, ny }
+ })
+ : []
+
+ /*
+ * The apexes join the extent before it is fitted. Without this the tile
+ * window and the vector fit are both cut to the straight chords, and a bowed
+ * leg is clipped by the very frame it was fitted to.
+ */
+ bows.forEach((bow, i) => {
+ if (!bow.k) return
+ const a = points[i]
+ const b = points[i + 1]
+ const d = Math.hypot(b.x - a.x, b.y - a.y)
+ const ax = (a.x + b.x) / 2 + bow.k * d * bow.nx
+ const ay = (a.y + b.y) / 2 + bow.k * d * bow.ny
+ minX = Math.min(minX, ax); maxX = Math.max(maxX, ax)
+ minY = Math.min(minY, ay); maxY = Math.max(maxY, ay)
+ })
const empty = !Number.isFinite(minX)
if (empty) { minX = -1; minY = -1; maxX = 1; maxY = 1 }
@@ -280,6 +424,36 @@ function MapView({ el, frameStyle }: { el: BookMapElement; frameStyle: CSSProper
minX = cx - spanX / 2; maxX = cx + spanX / 2
minY = cy - spanY / 2; maxY = cy + spanY / 2
+ /*
+ * ── The window takes the frame's proportions ─────────────────────────
+ *
+ * Two fits used to disagree about what the map was of. The outlines and the
+ * route were *contained* in the frame — fitted whole, with air on the long
+ * side — while the imagery *covered* it, because a map with a strip of paper
+ * down one edge is not a map. On a frame whose shape did not match the
+ * route's, those are different windows, and the tiles won: the picture filled
+ * the box and the route ran off the top of it. Drag a map narrow enough and
+ * the line simply left the page.
+ *
+ * Widening the window to the frame's own proportions makes the two the same
+ * window. Cover and contain agree by construction, the route is inside
+ * whatever shape the element is dragged to, and the imagery still reaches
+ * every edge — it just shows a little more ground on the short side, which is
+ * what the extra room was always going to be filled with.
+ */
+ const aspect = W / H
+ const haveW = maxX - minX
+ const haveH = maxY - minY
+ if (haveW / haveH < aspect) {
+ const want = haveH * aspect
+ minX = cx - want / 2
+ maxX = cx + want / 2
+ } else {
+ const want = haveW / aspect
+ minY = cy - want / 2
+ maxY = cy + want / 2
+ }
+
const scale = Math.min(W / (maxX - minX), H / (maxY - minY))
const offX = (W - (maxX - minX) * scale) / 2 - minX * scale
const offY = (H - (maxY - minY) * scale) / 2 - minY * scale
@@ -296,6 +470,68 @@ function MapView({ el, frameStyle }: { el: BookMapElement; frameStyle: CSSProper
const pin = Math.max(0.35, Math.min(W, H) * 0.0085)
const label = typeSize(el, 0.035)
+ /*
+ * ── The drawn treatment ───────────────────────────────────────────────
+ *
+ * A separate scale from the plain one above, because it is a different
+ * drawing rather than the same drawing recoloured. Fractions of the shorter
+ * side with a floor and a ceiling: the floor keeps a 40mm inset from losing
+ * the line, and the ceiling is what the plain route is missing — `routeWidth`
+ * has no upper bound and reaches 1.6mm on a page, which is part of why the
+ * current map reads as plotted rather than as drawn.
+ *
+ * The line lands at about a third of a marker's radius, which is the ratio
+ * the printed reference holds at every size.
+ */
+ const S = Math.min(W, H)
+ const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
+ const drawn = el.routeStyle === 'drawn'
+ const coreW = clamp(S * 0.0055, 0.32, 1.3)
+ const casingW = coreW * 1.9
+ const beadR = clamp(coreW * 3.1, 1.7, 4.8)
+ const beadRing = beadR * 0.2
+ const dotR = coreW * 1.5
+ /*
+ * A numbered stop is bigger than a dot and smaller than a photograph: it has
+ * to hold two digits legibly at page size without competing with the pictures
+ * beside it.
+ */
+ const numR = clamp(coreW * 2.2, 1.3, 3.4)
+ /*
+ * The digit reads against the marker's own fill rather than against the page,
+ * so it inverts with it: dark on a light line, light on a dark one. Measured
+ * the same way the marks decide their ink.
+ */
+ const numInk = brightness(el.accent) > 0.55 ? '#1c1b19' : '#ffffff' // theme-lint-disable — print ink
+ const dashOn = coreW * 2.4
+ const dashOff = coreW * 1.8
+
+ /*
+ * ── Why the colours key on the ground rather than on the accent ───────
+ *
+ * One route crosses near-black bathymetry and bright desert in the same
+ * picture, so no single colour works: what works is a light line with a dark
+ * edge under it over imagery, and the same idea inverted over pale paper.
+ * Keying it on what is underneath means any accent the user picks stays
+ * legible, instead of the panel having to warn them off half the palette.
+ */
+ const lightGround = imagery || palette.onDark
+ /*
+ * The line is the element's accent, full stop — which is why a map placed
+ * over imagery is given a white accent rather than being special-cased here.
+ * Guessing "the accent is still the default, so I will override it" cannot
+ * tell a colour somebody chose from one a constructor happened to write, and
+ * the moment it guesses wrong the user has a control that does nothing.
+ *
+ * The casing is the part that reads the ground: a dark halo under a light
+ * line over photography, a light halo under a dark line on pale paper. That
+ * is what keeps one route legible across near-black sea and bright desert in
+ * the same picture, whatever colour the line itself is.
+ */
+ const coreInk = el.accent
+ const casingInk = lightGround ? '#000000' : '#ffffff' // theme-lint-disable — a halo, not app chrome
+ const casingAlpha = lightGround ? 0.3 : 0.55
+
/*
* Real imagery, when the element was placed with a source that has some.
*
@@ -322,7 +558,7 @@ function MapView({ el, frameStyle }: { el: BookMapElement; frameStyle: CSSProper
maxLat: topLeft.lat,
maxLng: bottomRight.lng,
minLat: bottomRight.lat,
- })
+ }, big ? 'print' : 'preview')
: null
/*
@@ -348,7 +584,165 @@ function MapView({ el, frameStyle }: { el: BookMapElement; frameStyle: CSSProper
const trailOnScreen = tiled
? path.map(seg => seg.map(([lat, lng]) => projectOntoTiles(tiled, lng, lat)))
: trail.map(seg => seg.map(p => at(p.x, p.y)))
- const lines = trailOnScreen.length ? trailOnScreen : (route.length > 1 ? [route] : [])
+ /*
+ * ── What the plain line is made of ────────────────────────────────────
+ *
+ * A recorded track wins outright, as it always has. Otherwise the stop chain,
+ * cut at every leg that has a road so the road can be drawn in its place —
+ * without that cut, a journey with roads for three legs of twelve would print
+ * the three and silently drop the other nine, which is what `path` does today
+ * when it only covers part of a trip.
+ */
+ const roadOnScreen = (leg: number) => {
+ const road = roads[leg]
+ if (!road || road.length < 2) return null
+ return road.map(([lat, lng]) => {
+ const q = projectMercator(lng, lat)
+ return tiled ? projectOntoTiles(tiled, lng, lat) : at(q.x, q.y)
+ })
+ }
+
+ const chain: { x: number; y: number }[][] = []
+ if (route.length > 1) {
+ let run: { x: number; y: number }[] = [route[0]]
+ for (let i = 0; i < route.length - 1; i++) {
+ const road = roadOnScreen(i)
+ if (road) {
+ if (run.length > 1) chain.push(run)
+ chain.push(road)
+ run = [route[i + 1]]
+ } else {
+ run.push(route[i + 1])
+ }
+ }
+ if (run.length > 1) chain.push(run)
+ }
+
+ const lines = trailOnScreen.length ? trailOnScreen : chain
+
+ /*
+ * ── The drawn line, as path strings ───────────────────────────────────
+ *
+ * One strand per contiguous run. A recorded track is always straight
+ * segments and always solid: bowing a line somebody actually walked, or
+ * dashing it, would be drawing a journey that did not happen. The inferred
+ * stop chain is where the bows and the dashes live.
+ *
+ * The bow is faded out a second time here, in millimetres. `bows` decided how
+ * much a leg curves as a share of its own chord, which is a fact about the
+ * journey; this is the fact about the page, and it stops a 40mm inset from
+ * showing a hairline wobble that reads as a wobble rather than as a curve.
+ */
+ const strands: { d: string; dash?: string }[] = []
+ if (drawn) {
+ if (trailOnScreen.length) {
+ for (const seg of trailOnScreen) {
+ if (seg.length < 2) continue
+ strands.push({ d: `M${seg.map(pt => `${round2(pt.x)} ${round2(pt.y)}`).join(' L')}` })
+ }
+ } else if (route.length > 1) {
+ // Straight legs join into one strand; each bowed leg is its own, so it
+ // can be dashed without dashing its neighbours.
+ let run: string[] = []
+ const flush = () => {
+ if (run.length > 1) strands.push({ d: `M${run.join(' L')}` })
+ run = []
+ }
+ for (let i = 0; i < route.length - 1; i++) {
+ const a = route[i]
+ const b = route[i + 1]
+
+ /*
+ * A leg somebody asked the roads for is drawn as the road, solid and
+ * unbowed: it is the way that was actually taken, so neither the curve
+ * nor the dash — both of which mean "inferred" — belongs on it.
+ */
+ const road = roads[i]
+ if (road && road.length > 1) {
+ flush()
+ const onScreen = road.map(([lat, lng]) => {
+ const q = projectMercator(lng, lat)
+ return tiled ? projectOntoTiles(tiled, lng, lat) : at(q.x, q.y)
+ })
+ strands.push({ d: `M${onScreen.map(pt => `${round2(pt.x)} ${round2(pt.y)}`).join(' L')}` })
+ continue
+ }
+
+ const bow = bows[i]
+ const dmm = Math.hypot(b.x - a.x, b.y - a.y)
+ let sag = bow ? bow.k * dmm : 0
+ if (sag > 0) {
+ // Below about a stroke width the curve is not read as one.
+ const v = Math.min(1, Math.max(0, (sag - coreW * 0.6) / coreW))
+ sag *= v * v * (3 - 2 * v)
+ }
+ if (!sag) {
+ if (!run.length) run.push(`${round2(a.x)} ${round2(a.y)}`)
+ run.push(`${round2(b.x)} ${round2(b.y)}`)
+ continue
+ }
+ flush()
+ /*
+ * A quadratic reaches only half way to its control point at the middle,
+ * which is why the apex is doubled here — the same arithmetic the shape
+ * paths already rely on.
+ */
+ const cxp = (a.x + b.x) / 2 + 2 * sag * bow.nx
+ const cyp = (a.y + b.y) / 2 + 2 * sag * bow.ny
+ const d = `M${round2(a.x)} ${round2(a.y)} Q${round2(cxp)} ${round2(cyp)} ${round2(b.x)} ${round2(b.y)}`
+ if (el.routeDash === 'arcs') {
+ /*
+ * Phase-fitted, so every leg ends on a whole dash at its marker
+ * rather than on a stub. Butt caps: a round cap swells each dash by a
+ * full stroke width and closes the gaps it was drawn for.
+ */
+ const arcLen = dmm * (1 + (8 / 3) * (sag / dmm) ** 2)
+ const n = Math.max(1, Math.round(arcLen / (dashOn + dashOff)))
+ const fit = arcLen / (n * (dashOn + dashOff))
+ strands.push({ d, dash: `${round2(dashOn * fit)} ${round2(dashOff * fit)}` })
+ } else {
+ strands.push({ d })
+ }
+ }
+ flush()
+ }
+ }
+
+ /*
+ * ── Which stops carry a photograph ────────────────────────────────────
+ *
+ * Walked in order, carrying the last bead's position: a stop closer than a
+ * bead's width to the one before it becomes a dot instead. That is what
+ * produces the reference's string of beads that touch and overlap slightly,
+ * rather than a solid caterpillar of overlapping discs — and nothing is
+ * dropped, so the shape of the route survives whatever the spacing does.
+ *
+ * The two ends always keep their picture when they have one: they are where
+ * a reader looks first.
+ */
+ const beads: { i: number; x: number; y: number; photo: number | null }[] = []
+ let numbered = false
+ if (drawn) {
+ // Too small to carry photographs at all: a bead wider than a twelfth of the
+ // frame is a picture with a map around it.
+ const roomForPhotos = el.pinStyle === 'photo' && big && beadR * 2 <= S * 0.16
+ /*
+ * Numbers stand in for the photographs that are not there, so they belong
+ * to the photo treatment alone. A map asked for dots gets dots, and a
+ * thumbnail gets dots too: two digits at 130px is a smudge.
+ */
+ numbered = el.pinStyle === 'photo' && big && numR * 2 <= S * 0.12
+ let last: { x: number; y: number } | null = null
+ route.forEach((pt, i) => {
+ const photoId = (el.points[i] as { photoId?: number | null } | undefined)?.photoId ?? null
+ const isEnd = i === 0 || i === route.length - 1
+ const room = !last || Math.hypot(pt.x - last.x, pt.y - last.y) >= beadR * 1.15
+ const bead = roomForPhotos && photoId != null && (isEnd || room)
+ if (bead) last = { x: pt.x, y: pt.y }
+ beads.push({ i, x: pt.x, y: pt.y, photo: bead ? photoId : null })
+ })
+ }
+ const anyBead = beads.some(b => b.photo != null)
return (
@@ -357,14 +751,9 @@ function MapView({ el, frameStyle }: { el: BookMapElement; frameStyle: CSSProper
Each tile is placed rather than flowed: a grid of images with no gaps
between them is not something CSS layout gives you for free at
- fractional millimetre sizes.
-
- They are drawn a little larger than their spacing on purpose. A tile
- boundary that lands mid-pixel leaves a hairline of white paper showing
- through the map, and the eye finds it immediately — it reads as a fold
- or a scratch rather than as rounding. A tenth of a millimetre of
- overlap costs nothing (the overlapping strip is the same picture) and
- closes the seam at every zoom and every print size.
+ fractional millimetre sizes. They are drawn past their own square on
+ purpose — see `tileOverlapMm`, which is what keeps the seams closed at
+ every zoom the canvas offers.
*/}
{tiled && !cutToLand && tiled.tiles.map(t => (
@@ -408,6 +797,18 @@ function MapView({ el, frameStyle }: { el: BookMapElement; frameStyle: CSSProper
width={`${W}mm`}
height={`${H}mm`}
viewBox={`0 0 ${W} ${H}`}
+ /*
+ * Accuracy over speed, everywhere on this map.
+ *
+ * The default lets a browser trade geometric precision for drawing
+ * speed once a picture gets busy, and a route with two hundred stops
+ * over a hundred and fifty tiles is busy. What that trade costs is
+ * exactly what a printed line must not lose: a bowed leg goes faceted
+ * and a bead's ring goes lumpy. Nothing here is a filter, so the whole
+ * drawing stays vector into the PDF and the press resolves it, not the
+ * browser.
+ */
+ shapeRendering="geometricPrecision"
style={{ display: 'block', position: 'relative' }}
>
{/*
@@ -417,6 +818,30 @@ function MapView({ el, frameStyle }: { el: BookMapElement; frameStyle: CSSProper
else: the outlines below draw themselves normally when the map is a
rectangle, and become the edge of the picture when it is not.
*/}
+ {/*
+ A stencil per bead, in the map's own millimetres.
+
+ Its own `defs` rather than a shared one: the block below belongs to
+ cutting the map to a coastline and only exists when that is on, and a
+ stencil nested inside a condition that has nothing to do with it is
+ how the photographs came out square — the clip-path pointed at an id
+ that was never written, so `slice` showed the corners it was supposed
+ to cut off.
+
+ User space rather than the tidier fractional stencil, because a
+ fractional clip on an `
` is applied against a box browsers
+ disagree about, and a silent no-op looks exactly like the bug above.
+ */}
+ {anyBead && (
+
+ {beads.filter(m => m.photo != null).map(m => (
+
+
+
+ ))}
+
+ )}
+
{cutToLand && (
@@ -443,8 +868,8 @@ function MapView({ el, frameStyle }: { el: BookMapElement; frameStyle: CSSProper
href={t.url}
x={round3(tiled.originX + t.x * tiled.size)}
y={round3(tiled.originY + t.y * tiled.size)}
- width={round3(tiled.size + TILE_OVERLAP)}
- height={round3(tiled.size + TILE_OVERLAP)}
+ width={round3(tiled.size + tileOverlapMm(tiled.size))}
+ height={round3(tiled.size + tileOverlapMm(tiled.size))}
preserveAspectRatio="none"
/>
))}
@@ -474,7 +899,7 @@ function MapView({ el, frameStyle }: { el: BookMapElement; frameStyle: CSSProper
/>
))}
- {el.showRoute && lines.map((seg, i) => seg.length > 1 && (
+ {el.showRoute && !drawn && lines.map((seg, i) => seg.length > 1 && (
`${round2(p.x)},${round2(p.y)}`).join(' ')}
@@ -486,7 +911,49 @@ function MapView({ el, frameStyle }: { el: BookMapElement; frameStyle: CSSProper
/>
))}
- {el.showPins && route.map((p, i) => (
+ {/*
+ The drawn line: two strokes, casing first.
+
+ Not a filter. A drop shadow does survive the print round trip, but a
+ filtered subtree is rasterised into the PDF at whatever resolution the
+ browser picks, which is the one thing the millimetres-in-the-DOM design
+ exists to avoid. Two stacked paths give the same faint dark edge and
+ stay vector all the way to the press.
+
+ All the casings before any core, never leg by leg: interleaved, the
+ casing of one leg overprints the core of the last one at the joint.
+ */}
+ {el.showRoute && drawn && strands.length > 0 && (
+ <>
+ {strands.map((strand, i) => (
+
+ ))}
+ {strands.map((strand, i) => (
+
+ ))}
+ >
+ )}
+
+ {el.showPins && !drawn && route.map((p, i) => (
))}
+ {/*
+ The drawn markers: a small round photograph where there is one, a dot
+ where there is not.
+
+ The dot is the designed state rather than a failure — a printed route
+ with a few plain marks between the pictures reads as a route, while
+ one with empty rings reads as a bug. The white disc under the picture
+ is also what a photograph that fails to load degrades to, so the paper
+ shows a deliberate marker rather than a hole.
+ */}
+ {el.showPins && drawn && beads.map(m => (
+ m.photo != null ? (
+
+ {/* The shadow, as two translucent discs rather than a filter. */}
+
+
+
+ {/*
+ Always the thumbnail, never photoSrc(id, big): `big` is true in
+ print, and a full-size original to fill a seven-millimetre
+ circle is several megabytes per stop for detail no press can
+ resolve.
+ */}
+
+
+
+ ) : numbered ? (
+ /*
+ A stop with no photograph of its own, numbered.
+
+ The alternative was to hand it a picture from somewhere else in
+ the journey, which is what this did first and which is a caption
+ that lies: a marker at Akureyri showing a photograph taken in Vík
+ is wrong in the one place a reader most trusts that a picture is
+ OF what it sits on. A number says what the map actually knows —
+ this is the fourth stop — and reads as deliberate rather than as
+ something missing.
+ */
+
+
+
+
+ {m.i + 1}
+
+
+ ) : (
+
+ )
+ ))}
+
{el.showLabels && points.map((p, i) => {
if (!p.label) return null
const at2 = route[i]
@@ -914,6 +1475,26 @@ function BadgeView({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSPr
const chip = el.style === 'chip'
const outline = el.style === 'outline'
+ /*
+ * What the mark shows, and what colour its picture is.
+ *
+ * Read as `!== false` rather than as a truth test: a badge dropped from a
+ * panel is built by hand and cast, so it never passes through the contract's
+ * defaults until the book has been saved and read back. An absent flag has to
+ * mean what it meant before the flag existed.
+ */
+ const showIcon = el.showIcon !== false
+ const showLabel = el.showLabel !== false
+ const iconTint = (automatic: string) => (el.autoIconColor === false ? el.iconColor : automatic)
+ /*
+ * A mark with no words is a pictogram, and a pictogram should fill its box.
+ * Beside a label the icon is a share of the height; on its own it takes four
+ * fifths of whichever side is shorter, so a wide mark grows into the space
+ * the words left and a narrow one stays inside its own edges rather than
+ * bleeding over them.
+ */
+ const iconScale = showLabel ? 0.46 : Math.min(el.frame.w, el.frame.h) / el.frame.h * 0.8
+
return (
- {el.variant === 'flag' && el.code &&
}
+ {el.variant === 'flag' && el.code && showIcon && (
+
+ )}
{/*
Mood and weather come from the journal entry, and they are drawn with
@@ -945,15 +1528,22 @@ function BadgeView({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSPr
rather than re-listed, so a mood added to TREK appears here without
anyone remembering to add it twice.
*/}
- {(el.variant === 'mood' || el.variant === 'weather') && el.code && (() => {
+ {(el.variant === 'mood' || el.variant === 'weather') && el.code && showIcon && (() => {
// Kept apart rather than narrowed out of one union: only a mood has a
// colour of its own, and `'text' in config` types it as unknown.
const mood = el.variant === 'mood' ? MOOD_CONFIG[el.code] : null
const config = mood ?? WEATHER_CONFIG[el.code]
if (!config) return null
const Icon = config.icon
- const size = el.frame.h * 0.46
- const tint = mood ? mood.text : el.accent
+ const size = el.frame.h * iconScale
+ /*
+ * Automatic is the journal's own palette for a mood and the element's
+ * accent for the weather. Note the colour has to be passed rather than
+ * inherited: lucide writes it onto the SVG's stroke, so `currentColor`
+ * from the wrapper never reaches it — which is why the Colour swatch
+ * appeared to do nothing to a mood icon.
+ */
+ const tint = iconTint(mood ? mood.text : el.accent)
return (
-
-
- )}
+ {el.variant === 'country' && shape && showIcon && (() => {
+ const h = el.frame.h * (showLabel ? 0.72 : iconScale)
+ return (
+
+
+
+ )
+ })()}
- {el.text && (
+ {el.text && showLabel && (
)}
- {el.sub && (
+ {el.sub && showLabel && (
void
+ /**
+ * The range this particular measurement lives in.
+ *
+ * Defaulted to the page's own, which is what the field was built for. The
+ * bleed and the safe margin are millimetres of the same kind and want the
+ * same field, but a floor of 60mm would make a 3mm bleed untypeable and its
+ * arrows useless — the range is a property of the measurement, not of the
+ * control.
+ */
+ min?: number
+ max?: number
+ step?: number
}) {
const [draft, setDraft] = useState(null)
const shown = draft ?? String(Math.round(value * 10) / 10)
@@ -41,16 +53,16 @@ export function TrimField({
type="number"
inputMode="decimal"
value={shown}
- min={PAGE_MIN_MM}
- max={PAGE_MAX_MM}
- step={1}
+ min={min}
+ max={max}
+ step={step}
onChange={e => {
const raw = e.target.value
setDraft(raw)
// Anything already usable goes straight through, so the arrows and a
// fully typed number both take effect without waiting for a blur.
const n = Number(raw)
- if (raw !== '' && Number.isFinite(n) && n >= PAGE_MIN_MM && n <= PAGE_MAX_MM) {
+ if (raw !== '' && Number.isFinite(n) && n >= min && n <= max) {
setDraft(null)
onCommit(n)
}
diff --git a/client/src/components/Studio/applyTemplate.ts b/client/src/components/Studio/applyTemplate.ts
index 2dab18056..0472dba46 100644
--- a/client/src/components/Studio/applyTemplate.ts
+++ b/client/src/components/Studio/applyTemplate.ts
@@ -1,5 +1,7 @@
import type { BookElement, BookPageSetup, BookSpread, JourneyStats } from '@trek/shared'
import type { SpreadTemplate } from './bookTemplates.data'
+import { formatBookCoords, formatBookDate } from './entryText'
+import { coordValue } from './resolveBindings'
/**
* Laying an entry out on a template somebody drew.
@@ -131,10 +133,27 @@ export function applyTemplate(
if (out.kind === 'text' && out.binding) {
const source = out.binding.source
+ const format = out.binding.format
+ // What the words were made from, where they were made rather than copied.
+ let value: string | undefined
if (source === 'entry.title') out.text = heading
else if (source === 'entry.story') out.text = story
- else if (source === 'entry.date') out.text = formatDate(entry.date, ctx.locale)
- out.binding = { ...out.binding, entryId: entry.id }
+ else if (source === 'entry.date') {
+ out.text = formatBookDate(entry.date, ctx.locale)
+ value = entry.date ?? undefined
+ }
+ // The place, as its name or as its point — see the `format` note in the
+ // contract. A stop the entry cannot answer keeps the words the template
+ // was drawn with, the same bargain fillBadge makes below.
+ else if (source === 'entry.location') {
+ if (format && entry.lat != null && entry.lng != null) {
+ out.text = formatBookCoords(entry.lat, entry.lng, format)
+ value = coordValue(entry.lat, entry.lng)
+ } else if (!format && entry.location) {
+ out.text = entry.location
+ }
+ }
+ out.binding = { ...out.binding, entryId: entry.id, ...(value ? { value } : {}) }
}
if (out.kind === 'photo' && out.photoId == null) {
@@ -188,7 +207,7 @@ function fillBadge(el: BookElement & { kind: 'badge' }, entry: TemplateEntry, ct
el.text = `${ctx.dayLabel} ${entry.dayNumber}`
}
if (el.variant === 'coords' && entry.lat != null && entry.lng != null) {
- el.text = coords(entry.lat, entry.lng)
+ el.text = formatBookCoords(entry.lat, entry.lng)
}
if ((el.variant === 'flag' || el.variant === 'country') && entry.country) {
el.code = entry.country
@@ -215,24 +234,4 @@ function countryName(code: string, ctx: TemplateContext): string {
}
}
-/** Degrees, minutes and seconds, the way a place page prints them. */
-function coords(lat: number, lng: number): string {
- const dms = (v: number, pos: string, neg: string) => {
- const hemisphere = v >= 0 ? pos : neg
- const abs = Math.abs(v)
- const deg = Math.floor(abs)
- const min = Math.floor((abs - deg) * 60)
- const sec = Math.round((((abs - deg) * 60) - min) * 60)
- return `${deg}° ${min}' ${sec}" ${hemisphere}`
- }
- return `${dms(lat, 'N', 'S')} ${dms(lng, 'E', 'W')}`
-}
-
-function formatDate(iso: string | null, locale: string): string {
- if (!iso) return ''
- const d = new Date(`${iso}T00:00:00`)
- if (Number.isNaN(d.getTime())) return ''
- return d.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })
-}
-
const round2 = (n: number) => Math.round(n * 100) / 100
diff --git a/client/src/components/Studio/autoLayout.ts b/client/src/components/Studio/autoLayout.ts
index bd9892730..5d1889c13 100644
--- a/client/src/components/Studio/autoLayout.ts
+++ b/client/src/components/Studio/autoLayout.ts
@@ -3,6 +3,7 @@ import type {
} from '@trek/shared'
import { SPREAD_TEMPLATES } from './bookTemplates.data'
import { applyTemplate, templateFit } from './applyTemplate'
+import { formatBookCoords, formatBookDate } from './entryText'
/**
* The auto mode: turn a journey into a book.
@@ -84,6 +85,16 @@ export interface AutoInput {
stationsLabel: string
/** The word before a day number on a chip — "DAY 5". Already translated. */
dayLabel: string
+ /**
+ * The real geometry of the route, when the journey has any.
+ *
+ * GPX and KML imports are the only thing in TREK that stores an actual path
+ * — every other route is a straight line between stops. The editor already
+ * fetches it for the Travel panel; an auto-laid-out book used to hard-code an
+ * empty array here and throw it away, which is why the one journey with a
+ * recorded track still printed a map drawn with a ruler.
+ */
+ path?: [number, number][][]
/** "TRIP SUMMARY" over the figures. */
summaryLabel: string
/** "COUNTRIES" over the outlines. */
@@ -243,15 +254,24 @@ function mapEl(
showRoute: true,
showPins: true,
showLabels: false,
+ // The drawn line, for the same reason the panel places one: the contract
+ // stays plain so old books do not change, and new books look like a book.
+ routeStyle: 'drawn',
+ routeArc: 'bow',
+ routeDash: 'arcs',
+ pinStyle: 'photo',
countries: stats.countries.map(c => c.code),
- points: stats.points.map(pt => ({ lat: pt.lat, lng: pt.lng, label: pt.label })),
+ points: stats.points.map(pt => ({
+ lat: pt.lat, lng: pt.lng, label: pt.label, photoId: pt.photoId ?? null,
+ })),
/*
* The fields the contract would default for a parsed element, spelled out
* because this one is cast. `path` in particular is read as an array while
* the map draws, so leaving it out is not a cosmetic difference.
*/
path: [],
- fitPadding: 0.18,
+ roads: [],
+ fitPadding: 0.5,
fitToCountries: true,
clip: 'rect',
...opts,
@@ -300,6 +320,10 @@ function badgeEl(
// apply: left out, a mark reads as having been given a colour, and a chip
// draws its words in ink on an ink-coloured capsule.
autoColor: true,
+ showIcon: true,
+ showLabel: true,
+ autoIconColor: true,
+ iconColor: INK,
...opts,
} as BookElement
}
@@ -338,16 +362,8 @@ function coordsMark(
lng: number | null | undefined,
): BookElement | null {
if (lat == null || lng == null) return null
- const dms = (v: number, positive: string, negative: string) => {
- const hemisphere = v >= 0 ? positive : negative
- const abs = Math.abs(v)
- const deg = Math.floor(abs)
- const min = Math.floor((abs - deg) * 60)
- const sec = Math.round((((abs - deg) * 60) - min) * 60)
- return `${deg}° ${min}' ${sec}" ${hemisphere}`
- }
return badgeEl(frame, 'coords', {
- text: `${dms(lat, 'N', 'S')} ${dms(lng, 'E', 'W')}`,
+ text: formatBookCoords(lat, lng),
}, { style: 'plain', textScale: 0.7 })
}
@@ -425,15 +441,22 @@ function placeMap(
id: uid('mp'),
kind: 'map',
frame,
- points: [{ lat: entry.lat, lng: entry.lng, label: entry.location ?? '' }],
+ points: [{ lat: entry.lat, lng: entry.lng, label: entry.location ?? '', photoId: null }],
countries: entry.country ? [entry.country] : (stats?.countries.map(c => c.code) ?? []),
style: 'minimal',
source: 'vector',
tileUrl: '',
attribution: '',
zoom: 5,
+ // Deliberately the plain treatment: this is one dot on a country outline,
+ // and a bowed casing around a single stop would be decoration.
+ routeStyle: 'plain',
+ routeArc: 'straight',
+ routeDash: 'solid',
+ pinStyle: 'dot',
path: [],
- fitPadding: 0.18,
+ roads: [],
+ fitPadding: 0.5,
/*
* This one is the "where in the world was this" mark beside an entry, so it
* wants the country whole with a dot in it — the opposite of the route map,
@@ -466,13 +489,6 @@ function dateMark(iso: string | null, locale: string, frame: { x: number; y: num
}, { style: 'stacked' })
}
-function formatDate(iso: string | null, locale: string): string {
- if (!iso) return ''
- const d = new Date(iso + 'T00:00:00')
- if (Number.isNaN(d.getTime())) return ''
- return d.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })
-}
-
/** A full-bleed frame: out past the trim on every side the page is cut on. */
function bleedFrame(page: BookPageSetup, spread: boolean) {
const w = spread ? page.pageWidth * 2 : page.pageWidth
@@ -569,11 +585,13 @@ function stationsSpread(entries: AutoEntry[], input: AutoInput, label: string):
const w = W - m * 2
column.forEach((entry, i) => {
const y = top + i * step
- const date = formatDate(entry.date, input.locale)
+ const date = formatBookDate(entry.date, input.locale)
if (date) {
+ // Set in small caps, so the words on the page are not the words in the
+ // journal — and a bound element has to hold exactly its source or
+ // opening the book would quietly rewrite it. See resolveBindings.ts.
els.push(text({ x, y, w: w * 0.32, h: 5 }, date.toUpperCase(), {
size: 7, weight: 600, tracking: 0.1, color: '#a09a8e', // theme-lint-disable — book ink, not app chrome
- binding: { source: 'entry.date', entryId: entry.id },
}))
}
const name = entry.title || entry.location || ''
@@ -699,16 +717,17 @@ function headingBlock(
const ink = opts.ink ?? '#141414' // theme-lint-disable — book ink, not app chrome
const muted = opts.muted ?? '#8a8578' // theme-lint-disable — book ink, not app chrome
const heading = entry.title || entry.location || ''
- const meta = [formatDate(entry.date, input.locale), entry.location].filter(Boolean).join(' · ')
+ const meta = [formatBookDate(entry.date, input.locale), entry.location].filter(Boolean).join(' · ')
let cy = y
if (meta || heading) {
els.push(shape({ x, y: cy - 6, w: Math.min(14, w * 0.18), h: 0.5 }, ACCENT))
}
if (meta) {
+ // Two facts run together and upper-cased: neither the date nor the place as
+ // the journal holds them, so this line is not bound to either.
els.push(text({ x, y: cy, w, h: 5 }, meta.toUpperCase(), {
size: 7.5, weight: 600, tracking: 0.14, color: muted,
- binding: { source: 'entry.date', entryId: entry.id },
}))
cy += 8
}
@@ -1290,7 +1309,9 @@ function summarySpread(input: AutoInput): BookSpread | null {
const els: BookElement[] = []
if (stats.points.length >= 2) {
- els.push(mapEl({ x: m, y: m, w: W - m * 2, h: H - m * 2 }, stats))
+ // With the recorded track, when the journey has one: the editor fetches it
+ // and this was the line that threw it away.
+ els.push(mapEl({ x: m, y: m, w: W - m * 2, h: H - m * 2 }, stats, { path: input.path ?? [] }))
}
// Which figures are worth the space: everything the journey actually has.
@@ -1431,7 +1452,7 @@ function backSpread(input: AutoInput): BookSpread {
.filter((d): d is string => !!d)
.sort()
const span = dates.length
- ? [formatDate(dates[0], input.locale), formatDate(dates[dates.length - 1], input.locale)].filter(Boolean).join(' — ')
+ ? [formatBookDate(dates[0], input.locale), formatBookDate(dates[dates.length - 1], input.locale)].filter(Boolean).join(' — ')
: ''
els.push(shape({ x: -page.bleed, y: -page.bleed, w: page.pageWidth + page.bleed * 2, h: page.pageHeight + page.bleed * 2 }, '#141414')) // theme-lint-disable — book ink, not app chrome
@@ -1535,6 +1556,31 @@ export function buildBook(input: AutoInput): BookDocument {
}
}
+/**
+ * A book nobody has laid out yet.
+ *
+ * What a journey with no book now opens on. It used to open on the auto layout,
+ * which was generous and wrong: it decided what the book was before its author
+ * had said anything, and everything after that was undoing rather than making.
+ * The layout is still one click away, on the Auto layout menu, and it reads
+ * very differently when it is offered than when it has already happened.
+ *
+ * The covers are here rather than left out because a book has them and there is
+ * no other way to add one: the pages rail only inserts inner spreads, between
+ * the two. Empty, though, in the same way the page between them is.
+ */
+export function emptyBook(input: AutoInput): BookDocument {
+ const blank = (role: BookSpread['role']): BookSpread => ({
+ id: uid('sp'), role, background: null, elements: [], parked: [], entryId: null,
+ })
+ return {
+ version: 1,
+ title: input.title,
+ page: input.page,
+ spreads: [blank('cover'), blank('inner'), blank('back')],
+ }
+}
+
/**
* Lay one spread out again, from the entry it came from.
*
diff --git a/client/src/components/Studio/bookTemplates.data.ts b/client/src/components/Studio/bookTemplates.data.ts
index 293b6ee30..fa8bc6bac 100644
--- a/client/src/components/Studio/bookTemplates.data.ts
+++ b/client/src/components/Studio/bookTemplates.data.ts
@@ -46,7 +46,7 @@ export const SPREAD_TEMPLATES: SpreadTemplate[] = [
background: null,
elements: [
{
- "id": "p-flxgo8u",
+ "id": "p-iy13loy",
"frame": {
"x": 0.0238,
"y": 0.0238,
@@ -59,15 +59,15 @@ export const SPREAD_TEMPLATES: SpreadTemplate[] = [
"kind": "photo",
"photoId": null,
"fit": "cover",
- "focalX": 0.5,
- "focalY": 0.5,
- "radius": 0,
+ "focalX": 0.572289143475242,
+ "focalY": 0.5726791042512355,
+ "radius": 0.0238,
"filter": "none",
"mask": null,
"frameStyle": "none"
},
{
- "id": "s-iqku6eo",
+ "id": "s-kafy6t4",
"frame": {
"x": 1.5898,
"y": 0.0238,
@@ -79,7 +79,7 @@ export const SPREAD_TEMPLATES: SpreadTemplate[] = [
"locked": false,
"kind": "shape",
"shape": "rect",
- "fill": "#11224f",
+ "fill": "#111111",
"gradient": "none",
"stroke": null,
"strokeWidth": 0,
@@ -87,7 +87,7 @@ export const SPREAD_TEMPLATES: SpreadTemplate[] = [
"radius": 0
},
{
- "id": "t-nszckjj",
+ "id": "t-v4jhfu6",
"frame": {
"x": 1.5898,
"y": 0.0563,
@@ -111,7 +111,7 @@ export const SPREAD_TEMPLATES: SpreadTemplate[] = [
"overridden": true
},
{
- "id": "t-6ntyuak",
+ "id": "t-nh804rp",
"frame": {
"x": 1.1136,
"y": 0.0563,
@@ -135,7 +135,7 @@ export const SPREAD_TEMPLATES: SpreadTemplate[] = [
"overridden": true
},
{
- "id": "s-v58hbqa",
+ "id": "s-8c3kwzk",
"frame": {
"x": 1.0812,
"y": 0.1757,
@@ -148,7 +148,7 @@ export const SPREAD_TEMPLATES: SpreadTemplate[] = [
"font": "sans",
"color": "#1a1a1a",
"accent": "#111111",
- "textScale": 1.2,
+ "textScale": 1.5,
"weight": 700,
"stale": false,
"kind": "stats",
@@ -167,18 +167,18 @@ export const SPREAD_TEMPLATES: SpreadTemplate[] = [
"distance": 352677,
"days": 1,
"steps": 2,
- "photos": 0,
+ "photos": 3,
"countries": 2,
"places": 0,
"furthest": 352677
}
},
{
- "id": "b-u0di1u7",
+ "id": "b-15vswaz",
"frame": {
"x": 1.1768,
- "y": 0.7603,
- "w": 0.26,
+ "y": 0.7685,
+ "w": 0.2587,
"h": 0.066
},
"rotation": 0,
@@ -191,18 +191,22 @@ export const SPREAD_TEMPLATES: SpreadTemplate[] = [
"weight": 700,
"stale": false,
"kind": "badge",
- "autoColor": true,
"variant": "distance",
"text": "0 km",
"sub": "Distance",
"code": null,
- "style": "chip"
+ "style": "chip",
+ "autoColor": true,
+ "showIcon": true,
+ "showLabel": true,
+ "autoIconColor": true,
+ "iconColor": "#111111"
},
{
- "id": "c-roas8tw",
+ "id": "c-jdsxmvn",
"frame": {
"x": 1.628,
- "y": 0.2397,
+ "y": 0.2315,
"w": 0.31,
"h": 0.537
},
@@ -239,7 +243,7 @@ export const SPREAD_TEMPLATES: SpreadTemplate[] = [
{
"id": "s-f7yltlr",
"frame": {
- "x": -0.1277,
+ "x": -0.1264,
"y": 0.6775,
"w": 0.5595,
"h": 0.5973
@@ -249,7 +253,7 @@ export const SPREAD_TEMPLATES: SpreadTemplate[] = [
"locked": false,
"kind": "shape",
"shape": "flower-5",
- "fill": "#11254c",
+ "fill": "#111111",
"gradient": "none",
"stroke": null,
"strokeWidth": 0,
@@ -269,7 +273,7 @@ export const SPREAD_TEMPLATES: SpreadTemplate[] = [
"locked": false,
"kind": "shape",
"shape": "rect",
- "fill": "#11224f",
+ "fill": "#111111",
"gradient": "none",
"stroke": "#11224f",
"strokeWidth": 0.0024,
@@ -288,10 +292,10 @@ export const SPREAD_TEMPLATES: SpreadTemplate[] = [
"opacity": 1,
"locked": false,
"kind": "photo",
- "photoId": null,
+ "photoId": 69,
"fit": "cover",
- "focalX": 0.5,
- "focalY": 0.5,
+ "focalX": 0.42168673729754674,
+ "focalY": 0.23833399057977409,
"radius": 0,
"filter": "none",
"mask": null,
@@ -309,7 +313,7 @@ export const SPREAD_TEMPLATES: SpreadTemplate[] = [
"opacity": 1,
"locked": false,
"kind": "text",
- "text": "TEST1",
+ "text": "Lorem ipsum dolor sit amet",
"font": "sans",
"size": 0.1048,
"weight": 700,
@@ -443,7 +447,7 @@ export const SPREAD_TEMPLATES: SpreadTemplate[] = [
"opacity": 1,
"locked": false,
"kind": "photo",
- "photoId": null,
+ "photoId": 68,
"fit": "cover",
"focalX": 0.5,
"focalY": 0.5,
@@ -464,40 +468,15 @@ export const SPREAD_TEMPLATES: SpreadTemplate[] = [
"opacity": 1,
"locked": false,
"kind": "photo",
- "photoId": null,
+ "photoId": 69,
"fit": "cover",
"focalX": 0.5,
"focalY": 0.5,
"radius": 0,
"filter": "none",
- "mask": null,
+ "mask": "heart",
"frameStyle": "polaroid"
},
- {
- "id": "b-ga9zv0w",
- "frame": {
- "x": 0.0399,
- "y": 0.041,
- "w": 0.17,
- "h": 0.062
- },
- "rotation": 0,
- "opacity": 1,
- "locked": false,
- "font": "sans",
- "color": "#1a1a1a",
- "accent": "#111111",
- "textScale": 1,
- "weight": 700,
- "stale": false,
- "kind": "badge",
- "autoColor": true,
- "variant": "day",
- "text": "DAY 1",
- "sub": "",
- "code": null,
- "style": "chip"
- },
{
"id": "b-gmtenyt",
"frame": {
@@ -516,12 +495,16 @@ export const SPREAD_TEMPLATES: SpreadTemplate[] = [
"weight": 700,
"stale": false,
"kind": "badge",
- "autoColor": true,
"variant": "coords",
"text": "51°10'N 10°27'E",
"sub": "",
"code": null,
- "style": "plain"
+ "style": "plain",
+ "autoColor": true,
+ "showIcon": true,
+ "showLabel": true,
+ "autoIconColor": true,
+ "iconColor": "#111111"
},
{
"id": "b-4rd95kg",
@@ -541,12 +524,755 @@ export const SPREAD_TEMPLATES: SpreadTemplate[] = [
"weight": 700,
"stale": false,
"kind": "badge",
- "autoColor": true,
"variant": "coords",
"text": "51°10'N 10°27'E",
"sub": "",
"code": null,
- "style": "plain"
+ "style": "plain",
+ "autoColor": true,
+ "showIcon": true,
+ "showLabel": true,
+ "autoIconColor": true,
+ "iconColor": "#111111"
+ }
+ ],
+ },
+ {
+ id: 'ref-3',
+ background: null,
+ elements: [
+ {
+ "id": "p-sa5cabi",
+ "frame": {
+ "x": 0.025,
+ "y": 0.025,
+ "w": 1.9512,
+ "h": 0.5919
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "photo",
+ "photoId": null,
+ "fit": "cover",
+ "focalX": 0.5,
+ "focalY": 0.5,
+ "radius": 0,
+ "filter": "none",
+ "mask": null,
+ "frameStyle": "none"
+ },
+ {
+ "id": "t-kws7inb",
+ "frame": {
+ "x": 0.054,
+ "y": 0.688,
+ "w": 0.8919,
+ "h": 0.2294
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "text",
+ "text": "Lorem ipsum dolor sit amet",
+ "font": "sans",
+ "size": 0.2381,
+ "weight": 700,
+ "italic": false,
+ "align": "left",
+ "leading": 1.1,
+ "tracking": -0.02,
+ "color": "#1a1a1a",
+ "binding": {
+ "source": "entry.title",
+ "entryId": 62
+ },
+ "overridden": false
+ },
+ {
+ "id": "t-1zzydde",
+ "frame": {
+ "x": 1.0808,
+ "y": 0.6542,
+ "w": 0.8385,
+ "h": 0.1917
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "text",
+ "text": "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.",
+ "font": "sans",
+ "size": 0.0476,
+ "weight": 400,
+ "italic": false,
+ "align": "left",
+ "leading": 1.55,
+ "tracking": 0,
+ "color": "#1a1a1a",
+ "binding": {
+ "source": "entry.story",
+ "entryId": 62
+ },
+ "overridden": false
+ },
+ {
+ "id": "t-kht4sfu",
+ "frame": {
+ "x": 0.054,
+ "y": 0.6542,
+ "w": 0.36,
+ "h": 0.0314
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "text",
+ "text": "August 19, 2026",
+ "font": "sans",
+ "size": 0.0762,
+ "weight": 700,
+ "italic": false,
+ "align": "left",
+ "leading": 1.1,
+ "tracking": -0.02,
+ "color": "#1a1a1a",
+ "binding": {
+ "source": "entry.date",
+ "entryId": 62,
+ "value": "2026-08-19"
+ },
+ "overridden": false
+ }
+ ],
+ },
+ {
+ id: 'ref-4',
+ background: null,
+ elements: [
+ {
+ "id": "s-19c5q0x",
+ "frame": {
+ "x": -0.1119,
+ "y": 0.4164,
+ "w": 0.7143,
+ "h": 0.7143
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "shape",
+ "shape": "ellipse",
+ "fill": "#141414",
+ "gradient": "none",
+ "stroke": null,
+ "strokeWidth": 0,
+ "strokeStyle": "solid",
+ "radius": 0
+ },
+ {
+ "id": "p-e8ee9j8",
+ "frame": {
+ "x": 0.0816,
+ "y": 0.0763,
+ "w": 0.3367,
+ "h": 0.3827
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "photo",
+ "photoId": null,
+ "fit": "cover",
+ "focalX": 0.5,
+ "focalY": 0.5,
+ "radius": 0,
+ "filter": "none",
+ "mask": null,
+ "frameStyle": "polaroid"
+ },
+ {
+ "id": "p-fimcc5x",
+ "frame": {
+ "x": 0.5662,
+ "y": 0.202,
+ "w": 0.3367,
+ "h": 0.3827
+ },
+ "rotation": -10.1,
+ "opacity": 1,
+ "locked": false,
+ "kind": "photo",
+ "photoId": null,
+ "fit": "cover",
+ "focalX": 0.5,
+ "focalY": 0.5,
+ "radius": 0,
+ "filter": "none",
+ "mask": null,
+ "frameStyle": "polaroid"
+ },
+ {
+ "id": "p-nva57q1",
+ "frame": {
+ "x": 0.1739,
+ "y": 0.5,
+ "w": 0.3367,
+ "h": 0.3827
+ },
+ "rotation": 5.1,
+ "opacity": 1,
+ "locked": false,
+ "kind": "photo",
+ "photoId": null,
+ "fit": "cover",
+ "focalX": 0.5,
+ "focalY": 0.5,
+ "radius": 0,
+ "filter": "none",
+ "mask": "seal",
+ "frameStyle": "polaroid"
+ },
+ {
+ "id": "t-7zfrwms",
+ "frame": {
+ "x": 1.0762,
+ "y": 0.1605,
+ "w": 0.8476,
+ "h": 0.0895
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "text",
+ "text": "Lorem ipsum dolor sit amet",
+ "font": "sans",
+ "size": 0.1429,
+ "weight": 700,
+ "italic": false,
+ "align": "left",
+ "leading": 1.1,
+ "tracking": -0.02,
+ "color": "#1a1a1a",
+ "binding": {
+ "source": "entry.title",
+ "entryId": 63
+ },
+ "overridden": false
+ },
+ {
+ "id": "t-culm58m",
+ "frame": {
+ "x": 1.0762,
+ "y": 0.2676,
+ "w": 0.8476,
+ "h": 0.2073
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "text",
+ "text": "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.",
+ "font": "sans",
+ "size": 0.0476,
+ "weight": 400,
+ "italic": false,
+ "align": "left",
+ "leading": 1.55,
+ "tracking": 0,
+ "color": "#1a1a1a",
+ "binding": {
+ "source": "entry.story",
+ "entryId": 62
+ },
+ "overridden": false
+ },
+ {
+ "id": "li-dukbgme",
+ "frame": {
+ "x": 1.0762,
+ "y": 0.5116,
+ "w": 0.72,
+ "h": 0.0731
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "font": "sans",
+ "color": "#1a1a1a",
+ "accent": "#c2410c",
+ "textScale": 1,
+ "weight": 700,
+ "stale": false,
+ "kind": "list",
+ "items": [
+ {
+ "text": "TEST1",
+ "tone": "pro"
+ },
+ {
+ "text": "TEST1",
+ "tone": "con"
+ }
+ ],
+ "layout": "columns",
+ "showMarks": true,
+ "proLabel": "Pros",
+ "conLabel": "Cons"
+ },
+ {
+ "id": "t-x3ysuxa",
+ "frame": {
+ "x": 1.0762,
+ "y": 0.116,
+ "w": 0.36,
+ "h": 0.0314
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "text",
+ "text": "August 19, 2026",
+ "font": "sans",
+ "size": 0.069,
+ "weight": 700,
+ "italic": false,
+ "align": "left",
+ "leading": 1.1,
+ "tracking": -0.02,
+ "color": "#1a1a1a",
+ "binding": {
+ "source": "entry.date",
+ "entryId": 62,
+ "value": "2026-08-19"
+ },
+ "overridden": false
+ }
+ ],
+ },
+ {
+ id: 'ref-5',
+ background: null,
+ elements: [
+ {
+ "id": "p-adrg53f",
+ "frame": {
+ "x": 0.025,
+ "y": 0.025,
+ "w": 0.475,
+ "h": 0.9512
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "photo",
+ "photoId": null,
+ "fit": "cover",
+ "focalX": 0.5,
+ "focalY": 0.5,
+ "radius": 0,
+ "filter": "none",
+ "mask": null,
+ "frameStyle": "none"
+ },
+ {
+ "id": "t-crm4sem",
+ "frame": {
+ "x": 0.5297,
+ "y": 0.119,
+ "w": 0.4258,
+ "h": 0.8012
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "text",
+ "text": "Lorem ipsum dolor sit amet",
+ "font": "sans",
+ "size": 0.2381,
+ "weight": 700,
+ "italic": false,
+ "align": "left",
+ "leading": 1.1,
+ "tracking": -0.02,
+ "color": "#1a1a1a",
+ "binding": {
+ "source": "entry.title",
+ "entryId": 62
+ },
+ "overridden": false
+ },
+ {
+ "id": "s-3dhohc7",
+ "frame": {
+ "x": 1.0762,
+ "y": 0.3541,
+ "w": 0.5,
+ "h": 0.0024
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "shape",
+ "shape": "rect",
+ "fill": "#f2f2f2",
+ "gradient": "none",
+ "stroke": "#141414",
+ "strokeWidth": 0.0005,
+ "strokeStyle": "solid",
+ "radius": 0
+ },
+ {
+ "id": "t-0l6zeg7",
+ "frame": {
+ "x": 1.0762,
+ "y": 0.119,
+ "w": 0.8346,
+ "h": 0.2047
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "text",
+ "text": "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.",
+ "font": "sans",
+ "size": 0.0476,
+ "weight": 400,
+ "italic": false,
+ "align": "left",
+ "leading": 1.55,
+ "tracking": 0,
+ "color": "#1a1a1a",
+ "binding": {
+ "source": "entry.story",
+ "entryId": 62
+ },
+ "overridden": false
+ },
+ {
+ "id": "p-igt1irs",
+ "frame": {
+ "x": 1.0762,
+ "y": 0.4469,
+ "w": 0.368,
+ "h": 0.2495
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "photo",
+ "photoId": null,
+ "fit": "cover",
+ "focalX": 0.5,
+ "focalY": 0.5,
+ "radius": 0,
+ "filter": "none",
+ "mask": null,
+ "frameStyle": "shadow"
+ },
+ {
+ "id": "p-2akhm3w",
+ "frame": {
+ "x": 1.5105,
+ "y": 0.4469,
+ "w": 0.368,
+ "h": 0.2495
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "photo",
+ "photoId": null,
+ "fit": "cover",
+ "focalX": 0.5,
+ "focalY": 0.5,
+ "radius": 0,
+ "filter": "none",
+ "mask": null,
+ "frameStyle": "shadow"
+ },
+ {
+ "id": "bd-g6o9fvm",
+ "frame": {
+ "x": 0.53,
+ "y": 0.4577,
+ "w": 0.2604,
+ "h": 0.062
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "font": "sans",
+ "color": "#1a1a1a",
+ "accent": "#111111",
+ "textScale": 1,
+ "weight": 700,
+ "stale": false,
+ "kind": "badge",
+ "variant": "weather",
+ "text": "Partly cloudy",
+ "sub": "",
+ "code": "partly",
+ "style": "chip",
+ "autoColor": true,
+ "showIcon": true,
+ "showLabel": true,
+ "autoIconColor": false,
+ "iconColor": "#ffffff"
+ },
+ {
+ "id": "bd-ewxvw6v",
+ "frame": {
+ "x": 0.53,
+ "y": 0.5406,
+ "w": 0.2607,
+ "h": 0.062
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "font": "sans",
+ "color": "#1a1a1a",
+ "accent": "#111111",
+ "textScale": 1,
+ "weight": 700,
+ "stale": false,
+ "kind": "badge",
+ "variant": "mood",
+ "text": "Amazing",
+ "sub": "",
+ "code": "amazing",
+ "style": "chip",
+ "autoColor": true,
+ "showIcon": true,
+ "showLabel": true,
+ "autoIconColor": false,
+ "iconColor": "#ffffff"
+ },
+ {
+ "id": "t-az8f0nc",
+ "frame": {
+ "x": 0.5297,
+ "y": 0.0858,
+ "w": 0.36,
+ "h": 0.0347
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "text",
+ "text": "August 19, 2026",
+ "font": "sans",
+ "size": 0.0714,
+ "weight": 700,
+ "italic": false,
+ "align": "left",
+ "leading": 1.1,
+ "tracking": -0.02,
+ "color": "#1a1a1a",
+ "binding": {
+ "source": "entry.date",
+ "entryId": 62,
+ "value": "2026-08-19"
+ },
+ "overridden": false
+ }
+ ],
+ },
+ {
+ id: 'ref-6',
+ background: null,
+ elements: [
+ {
+ "id": "s-7go39mg",
+ "frame": {
+ "x": 0.0238,
+ "y": 0.309,
+ "w": 0.9524,
+ "h": 0.666
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "shape",
+ "shape": "triangle",
+ "fill": "#141414",
+ "gradient": "none",
+ "stroke": null,
+ "strokeWidth": 0,
+ "strokeStyle": "solid",
+ "radius": 0
+ },
+ {
+ "id": "p-a9065nh",
+ "frame": {
+ "x": 0.0238,
+ "y": 0.0238,
+ "w": 0.2745,
+ "h": 0.9512
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "photo",
+ "photoId": null,
+ "fit": "cover",
+ "focalX": 0.5,
+ "focalY": 0.5,
+ "radius": 0,
+ "filter": "none",
+ "mask": null,
+ "frameStyle": "none"
+ },
+ {
+ "id": "p-fvcm5kq",
+ "frame": {
+ "x": 0.3628,
+ "y": 0.0914,
+ "w": 0.2745,
+ "h": 0.8848
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "photo",
+ "photoId": null,
+ "fit": "cover",
+ "focalX": 0.5,
+ "focalY": 0.5,
+ "radius": 0,
+ "filter": "none",
+ "mask": null,
+ "frameStyle": "none"
+ },
+ {
+ "id": "p-aski19e",
+ "frame": {
+ "x": 0.7017,
+ "y": 0.0238,
+ "w": 0.2745,
+ "h": 0.9512
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "photo",
+ "photoId": null,
+ "fit": "cover",
+ "focalX": 0.5,
+ "focalY": 0.5,
+ "radius": 0,
+ "filter": "none",
+ "mask": null,
+ "frameStyle": "none"
+ },
+ {
+ "id": "p-tfs5cdi",
+ "frame": {
+ "x": 1.0238,
+ "y": 0.0238,
+ "w": 0.9524,
+ "h": 0.45
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "photo",
+ "photoId": null,
+ "fit": "cover",
+ "focalX": 0.5,
+ "focalY": 0.5,
+ "radius": 0,
+ "filter": "none",
+ "mask": null,
+ "frameStyle": "none"
+ },
+ {
+ "id": "t-9zjrvsn",
+ "frame": {
+ "x": 1.0592,
+ "y": 0.5,
+ "w": 0.8476,
+ "h": 0.0518
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "text",
+ "text": "Lorem ipsum dolor sit amet",
+ "font": "sans",
+ "size": 0.1048,
+ "weight": 700,
+ "italic": false,
+ "align": "left",
+ "leading": 1.1,
+ "tracking": -0.02,
+ "color": "#1a1a1a",
+ "binding": {
+ "source": "entry.title",
+ "entryId": 62
+ },
+ "overridden": false
+ },
+ {
+ "id": "t-4lgyrab",
+ "frame": {
+ "x": 1.0592,
+ "y": 0.5682,
+ "w": 0.8476,
+ "h": 0.1995
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "kind": "text",
+ "text": "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.",
+ "font": "sans",
+ "size": 0.0476,
+ "weight": 400,
+ "italic": false,
+ "align": "left",
+ "leading": 1.55,
+ "tracking": 0,
+ "color": "#1a1a1a",
+ "binding": {
+ "source": "entry.story",
+ "entryId": 62
+ },
+ "overridden": false
+ },
+ {
+ "id": "li-93gcznw",
+ "frame": {
+ "x": 1.0592,
+ "y": 0.7876,
+ "w": 0.72,
+ "h": 0.0731
+ },
+ "rotation": 0,
+ "opacity": 1,
+ "locked": false,
+ "font": "sans",
+ "color": "#1a1a1a",
+ "accent": "#c2410c",
+ "textScale": 1,
+ "weight": 700,
+ "stale": false,
+ "kind": "list",
+ "items": [
+ {
+ "text": "TEST1",
+ "tone": "pro"
+ },
+ {
+ "text": "TEST1",
+ "tone": "con"
+ }
+ ],
+ "layout": "columns",
+ "showMarks": true,
+ "proLabel": "Pros",
+ "conLabel": "Cons"
}
],
},
diff --git a/client/src/components/Studio/entryText.ts b/client/src/components/Studio/entryText.ts
new file mode 100644
index 000000000..7b9459be0
--- /dev/null
+++ b/client/src/components/Studio/entryText.ts
@@ -0,0 +1,63 @@
+/**
+ * The words a journal entry lends to a page.
+ *
+ * Three things print an entry's date or its point: the chips in the Entries
+ * tab, the template filler, and the auto layout. They used to do it with five
+ * private helpers between them — two long-date formatters that were copies of
+ * each other, and three coordinate formatters, one of which printed degrees and
+ * minutes where the other two printed seconds. A journey-wide mark and an entry
+ * mark on the same spread read as a bug when they disagree by a notation.
+ *
+ * There is a second, harder reason for one implementation. A bound element is
+ * re-read when the book is opened (see resolveBindings.ts), and re-reading is
+ * only invisible while the resolver produces exactly the string the chip
+ * produced. One character of drift and every open rewrites the element, which
+ * the autosave then writes back — a book that saves itself every time someone
+ * looks at it. Sharing the function is what holds that invariant.
+ */
+
+export type CoordFormat = 'dms' | 'decimal'
+
+/** One axis, as degrees, minutes and seconds. */
+function dms(v: number, pos: string, neg: string): string {
+ const hemisphere = v >= 0 ? pos : neg
+ const abs = Math.abs(v)
+ const deg = Math.floor(abs)
+ const min = Math.floor((abs - deg) * 60)
+ const sec = Math.round((((abs - deg) * 60) - min) * 60)
+ return `${deg}° ${min}' ${sec}" ${hemisphere}`
+}
+
+/**
+ * A point, set for print.
+ *
+ * Neither form carries a minus sign: a printed page says 33° 51' S, not
+ * -33.8688, and the hemisphere letter is both shorter and unambiguous. Decimal
+ * keeps four places — about eleven metres, which is the width of the building
+ * you were standing next to and finer than a book needs.
+ */
+export function formatBookCoords(lat: number, lng: number, format: CoordFormat = 'dms'): string {
+ if (format === 'decimal') {
+ const dec = (v: number, pos: string, neg: string) =>
+ `${Math.abs(v).toFixed(4)}° ${v >= 0 ? pos : neg}`
+ return `${dec(lat, 'N', 'S')} ${dec(lng, 'E', 'W')}`
+ }
+ return `${dms(lat, 'N', 'S')} ${dms(lng, 'E', 'W')}`
+}
+
+/**
+ * The entry's day, spelled out.
+ *
+ * Parsed at local midnight rather than as UTC, because the date on a journal
+ * entry is the day the writer had, not an instant — reading it as UTC moves it
+ * a day backwards for anyone west of Greenwich. Note this is deliberately not
+ * `utils/formatters.ts:formatDate`, which is the short weekday form the sidebar
+ * badges use and hides the year inside the current one; a page in a book that
+ * outlives the year has to say which year it was.
+ */
+export function formatBookDate(iso: string | null, locale: string): string {
+ if (!iso) return ''
+ const d = new Date(`${iso}T00:00:00`)
+ if (Number.isNaN(d.getTime())) return ''
+ return d.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })
+}
diff --git a/client/src/components/Studio/iconLibrary.ts b/client/src/components/Studio/iconLibrary.ts
new file mode 100644
index 000000000..a015263a6
--- /dev/null
+++ b/client/src/components/Studio/iconLibrary.ts
@@ -0,0 +1,118 @@
+import * as LucideIcons from 'lucide-react'
+import { resolvePluginIcon } from '../shared/PluginIcon'
+import type { LucideIcon } from 'lucide-react'
+
+/**
+ * Every drawing in lucide, as a list a picker can show.
+ *
+ * ── Why the whole set, and why it costs nothing ──────────────────────────
+ *
+ * The obvious worry with a fourteen-hundred-icon library is the bundle, and it
+ * is worth being precise about why it does not apply. TREK already pulls lucide
+ * in whole: `SystemNoticeModal` and `PluginIcon` both index the namespace with a
+ * computed key, which no bundler can tree-shake, and both sit in the eager entry
+ * graph. The library ships either way, so enumerating it here adds names, not
+ * kilobytes — and it avoids the alternative, which would be fourteen hundred
+ * lazily-imported chunks that the export path could not wait for. Studio prints
+ * by serialising the live DOM in one synchronous pass; an icon that had not
+ * finished loading would print as a hole in the book.
+ *
+ * ── The alias problem ────────────────────────────────────────────────────
+ *
+ * lucide exports every icon three times — `Plane`, `PlaneIcon`, `LucidePlane` —
+ * so the namespace holds about forty-five hundred names for about fourteen
+ * hundred drawings. A picker built from the raw keys shows every glyph three
+ * times, and the search box then looks broken. Both alias shapes are dropped.
+ */
+
+const PASCAL_CASE = /^[A-Z][A-Za-z0-9]*$/
+
+function buildNames(): string[] {
+ const all = LucideIcons as unknown as Record
+ const names = Object.keys(all).filter(k => PASCAL_CASE.test(k))
+ const present = new Set(names)
+
+ return names
+ .filter(name => {
+ // `LucidePlane` is always an alias of `Plane`.
+ if (name.startsWith('Lucide') && present.has(name.slice(6))) return false
+ // `PlaneIcon` likewise — but only when the bare name really exists, so a
+ // drawing genuinely called something-Icon is not thrown away with them.
+ if (name.endsWith('Icon') && present.has(name.slice(0, -4))) return false
+ return true
+ })
+ .sort((a, b) => a.localeCompare(b))
+}
+
+/** Built once: the namespace cannot change while the tab is open. */
+export const ICON_NAMES: string[] = buildNames()
+
+/**
+ * What the tab opens on.
+ *
+ * Alphabetical order puts `AArrowDown`, `AArrowUp` and `ALargeSmall` in the
+ * first row, which is a poor answer to "what can I put on this page" in a book
+ * about a journey. These are the drawings a travel page actually reaches for,
+ * in the order somebody would look for them; the whole library is underneath.
+ *
+ * Filtered against the real export list, so a lucide version that renames one
+ * of them quietly drops it instead of drawing a fallback in the shelf.
+ */
+const FEATURED = [
+ 'Plane', 'Train', 'TrainFront', 'Car', 'Bus', 'Ship', 'Bike', 'Footprints', 'Sailboat',
+ 'MapPin', 'Map', 'Compass', 'Navigation', 'Route', 'Globe', 'Milestone', 'Signpost',
+ 'Mountain', 'MountainSnow', 'Waves', 'TreePine', 'Palmtree', 'Flower2', 'Leaf', 'Bird', 'Fish',
+ 'Sun', 'Sunrise', 'Sunset', 'Moon', 'Cloud', 'CloudRain', 'CloudLightning', 'Snowflake',
+ 'Umbrella', 'Thermometer', 'Wind',
+ 'Tent', 'Backpack', 'Luggage', 'BedDouble', 'Hotel', 'Key', 'Anchor',
+ 'Coffee', 'UtensilsCrossed', 'Wine', 'Beer', 'IceCream', 'Croissant',
+ 'Camera', 'Image', 'Film', 'Music', 'BookOpen', 'PenLine', 'Quote', 'Sticker',
+ 'Heart', 'Star', 'Sparkles', 'Flag', 'Ticket', 'Gift', 'PartyPopper',
+ 'Clock', 'CalendarDays', 'CreditCard', 'Wallet', 'ShoppingBag',
+ 'Landmark', 'Church', 'Castle', 'Building2', 'Home', 'Binoculars', 'Telescope',
+]
+
+/** The starter shelf, minus anything this version of lucide does not have. */
+export const FEATURED_ICONS: string[] = FEATURED.filter(name => ICON_NAMES.includes(name))
+
+/**
+ * The words to match a search against.
+ *
+ * "mountainsnow" for MountainSnow as well as its parts, so both "mountain" and
+ * "snow" find it — searching an icon set is searching for a thing, and the
+ * thing's name is rarely the first word someone types.
+ */
+function searchable(name: string): string {
+ return `${name} ${name.replace(/([a-z0-9])([A-Z])/g, '$1 $2')}`.toLowerCase()
+}
+
+const SEARCH_INDEX: Record = Object.fromEntries(
+ ICON_NAMES.map(name => [name, searchable(name)]),
+)
+
+export function searchIcons(query: string): string[] {
+ const q = query.trim().toLowerCase()
+ if (!q) return ICON_NAMES
+ // Every word has to appear, so "snow mountain" finds MountainSnow too.
+ const words = q.split(/\s+/)
+ return ICON_NAMES.filter(name => {
+ const haystack = SEARCH_INDEX[name]
+ return words.every(w => haystack.includes(w))
+ })
+}
+
+/** Spaced out for a tooltip: `MountainSnow` reads as "Mountain Snow". */
+export function iconLabel(name: string): string {
+ return name.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
+}
+
+/**
+ * The component for a stored name.
+ *
+ * The same resolver the plugin system uses, so a book that outlives an icon
+ * behaves the way the rest of TREK does with a name it no longer knows: it
+ * draws the fallback rather than leaving a hole where a drawing was.
+ */
+export function iconComponent(name: string): LucideIcon {
+ return resolvePluginIcon(name)
+}
diff --git a/client/src/components/Studio/mapSources.ts b/client/src/components/Studio/mapSources.ts
index 8286cc8b4..fbf89f4e6 100644
--- a/client/src/components/Studio/mapSources.ts
+++ b/client/src/components/Studio/mapSources.ts
@@ -1,7 +1,5 @@
import type { BookFrame } from '@trek/shared'
-import { useSettingsStore } from '../../store/settingsStore'
-import { attributionFor, staticMapUrl } from './mapTiles'
-import { normalizeTileUrl } from '../../utils/tileUrl'
+import { attributionFor } from './mapTiles'
/**
* Which map sources this instance can actually offer.
@@ -17,7 +15,18 @@ import { normalizeTileUrl } from '../../utils/tileUrl'
*/
export interface MapSourceOption {
- id: 'vector' | 'tiles' | 'static'
+ /**
+ * What this option is called in the panel.
+ *
+ * Not the same thing as `source`: relief and the instance's own tiles are
+ * both raster grids and the renderer draws them identically, so they share a
+ * `source` and differ only in where the pictures come from. Widening the
+ * contract's enum to tell them apart would mean a document an older build
+ * cannot read, for a distinction only the panel cares about.
+ */
+ id: 'vector' | 'tiles' | 'static' | 'relief' | 'satellite'
+ /** What goes into the document. */
+ source: 'vector' | 'tiles' | 'static'
/** i18n key for the name. */
labelKey: string
/** The tile template or static URL to freeze into the element. */
@@ -25,37 +34,145 @@ export interface MapSourceOption {
attribution: string
}
-/** The default tile template, when the instance has not set its own. */
-const DEFAULT_TILES = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
+/** Which option a placed element came from, matched back by what it stored. */
+export function sourceIdOf(el: { source: string; tileUrl: string }, options: MapSourceOption[]): string {
+ const exact = options.find(o => o.source === el.source && o.url === el.tileUrl)
+ return exact?.id ?? el.source
+}
-export function useMapSources(frame: BookFrame, points: { lat: number; lng: number }[]): MapSourceOption[] {
- const settings = useSettingsStore(s => s.settings)
+/**
+ * NASA's shaded relief, and why it is the one worth printing.
+ *
+ * ── What it looks like ───────────────────────────────────────────────────
+ *
+ * Land in its own colours with the terrain shaded into it, sea rendered dark
+ * with the depth showing through, and no roads, no borders and no labels. It
+ * is the look printed travel books have used for decades, and it is the one
+ * the street map underneath a planner can never be: a street map is a working
+ * document, and enlarged onto a page it reads as a screenshot.
+ *
+ * ── Why this source rather than a better-known one ───────────────────────
+ *
+ * Because a book gets printed, and possibly sold. Mapbox's own documentation
+ * says to contact them before printing an exported map; Esri's imagery is
+ * licensed for use with ArcGIS and not for commercial work; the OSM tile
+ * server's policy forbids bulk fetching, which is what covering a page at
+ * print resolution is. This is US government imagery: the service's own
+ * capabilities document answers both questions in two lines, `none`
+ * and `none`, and no account or token exists to get
+ * wrong.
+ *
+ * ── Its one limit, stated rather than discovered ─────────────────────────
+ *
+ * Level 8, about 600 metres to the pixel. A journey across a country or a
+ * continent is exactly what it is for; a weekend inside one city is not, and
+ * for that the instance's own tiles are still the better answer.
+ *
+ * Note the path is `{z}/{y}/{x}` — WMTS names its axes row then column, the
+ * reverse of the slippy convention. The substitution is by name, so the order
+ * in the template is all that matters.
+ */
+const RELIEF_TILES = 'https://gibs.earthdata.nasa.gov/wmts/epsg3857/best'
+ + '/BlueMarble_ShadedRelief_Bathymetry/default/GoogleMapsCompatible_Level8'
+ + '/{z}/{y}/{x}.jpeg'
+/**
+ * Real satellite imagery, and the answer to what relief cannot do.
+ *
+ * Relief stops at about 600 metres to the pixel, which is a continent's worth
+ * of detail: it draws Europe beautifully and a fortnight in the Netherlands as
+ * a green smudge. This is the same picture at ten metres — cloud-free Sentinel-2,
+ * composited by EOX from a year of passes, so there is no weather in the way and
+ * no seams between scenes.
+ *
+ * ── Why the 2016 layer specifically ──────────────────────────────────────
+ *
+ * EOX publish a layer per year, and only this one is usable in a book somebody
+ * might sell: the service's own capabilities document releases it under CC BY
+ * 4.0, while 2018 onwards are CC BY-NC-SA, which rules out a printed product.
+ * The imagery being a decade old costs nothing here — a coastline, a mountain
+ * range and a desert do not move, and the map is a backdrop for a route rather
+ * than a survey.
+ *
+ * Attribution is not optional under that licence, which is why it travels into
+ * the document with the source.
+ */
+const SATELLITE_TILES = 'https://tiles.maps.eox.at/wmts/1.0.0'
+ + '/s2cloudless_3857/default/g/{z}/{y}/{x}.jpg'
+
+/**
+ * What a book may be made of.
+ *
+ * ── Why the planner's own tiles are not on this list ─────────────────────
+ *
+ * They are a street map, and a street map enlarged onto a page reads as a
+ * screenshot of a working document rather than as an illustration — which is
+ * the whole difference between the reference books and what this drew before.
+ * Worse, covering a page at print resolution means fetching a few hundred tiles
+ * at once, and the OSM tile server's usage policy names exactly that as
+ * something not to do. Mapbox is off the list for a different reason: its own
+ * documentation says to contact them before printing an exported map, so it
+ * cannot be offered as a thing to put in a book somebody might sell.
+ *
+ * What is left is the three that are honestly printable: outlines drawn from
+ * boundaries this server already ships, NASA's relief, and Sentinel-2.
+ *
+ * ── But a book that already uses one keeps working ───────────────────────
+ *
+ * Removing an option must not remove a page. An element placed with the
+ * instance's tiles or with Mapbox still renders exactly as it did, and its own
+ * source is added back to the list below so the panel can show it as the chosen
+ * one — otherwise the map would draw correctly while the panel claimed it was
+ * something else, and one stray click would silently convert it.
+ */
+export function useMapSources(
+ frame: BookFrame,
+ points: { lat: number; lng: number }[],
+ /** What the element being inspected already uses, so it stays reachable. */
+ current?: { source: string; tileUrl: string },
+): MapSourceOption[] {
// Through the same normaliser the planner's map uses: a template saved
// before OSM dropped its shards still names a host that no longer exists.
- const tiles = normalizeTileUrl((settings.map_tile_url || '').trim()) || DEFAULT_TILES
const out: MapSourceOption[] = [
- { id: 'vector', labelKey: 'journey.studio.mapSourceVector', url: '', attribution: '' },
- { id: 'tiles', labelKey: 'journey.studio.mapSourceTiles', url: tiles, attribution: attributionFor(tiles) },
+ { id: 'vector', source: 'vector', labelKey: 'journey.studio.mapSourceVector', url: '', attribution: '' },
+ {
+ id: 'relief',
+ source: 'tiles',
+ labelKey: 'journey.studio.mapSourceRelief',
+ url: RELIEF_TILES,
+ attribution: attributionFor(RELIEF_TILES),
+ },
+ {
+ id: 'satellite',
+ source: 'tiles',
+ labelKey: 'journey.studio.mapSourceSatellite',
+ url: SATELLITE_TILES,
+ attribution: attributionFor(SATELLITE_TILES),
+ },
]
- // Millimetres to pixels at roughly 200dpi — enough for print without asking
- // the API for more than it will give.
- const token = (settings.mapbox_access_token || '').trim()
- if (token) {
- const url = staticMapUrl({
- points,
- style: settings.mapbox_style || '',
- token,
- width: frame.w * 7.9,
- height: frame.h * 7.9,
- })
- if (url) {
+ /*
+ * The one the element is already on, when it is not one of the three above.
+ * Listed last, so the recommended sources read as the answer and this reads
+ * as what happens to be in place.
+ */
+ if (current && !out.some(o => o.source === current.source && o.url === current.tileUrl)) {
+ if (current.source === 'tiles' && current.tileUrl) {
+ out.push({
+ id: 'tiles',
+ source: 'tiles',
+ labelKey: 'journey.studio.mapSourceTiles',
+ url: current.tileUrl,
+ attribution: attributionFor(current.tileUrl),
+ })
+ }
+ if (current.source === 'static' && current.tileUrl) {
out.push({
id: 'static',
+ source: 'static',
labelKey: 'journey.studio.mapSourceStatic',
- url,
- attribution: attributionFor(url),
+ url: current.tileUrl,
+ attribution: attributionFor(current.tileUrl),
})
}
}
diff --git a/client/src/components/Studio/mapTiles.ts b/client/src/components/Studio/mapTiles.ts
index e6b5057ed..34c3d8e5b 100644
--- a/client/src/components/Studio/mapTiles.ts
+++ b/client/src/components/Studio/mapTiles.ts
@@ -68,12 +68,78 @@ export function projectOntoTiles(view: TileView, lng: number, lat: number): { x:
}
}
-/** How many tiles across a printed map may use, before it is not worth it. */
-const MAX_TILES = 8 * 8
+/**
+ * How many tiles a printed map may use.
+ *
+ * Sixteen across rather than eight, and the number is chosen by arithmetic
+ * rather than by taste. Each zoom level quarters the ground a tile covers, so
+ * the grid quadruples: a page-sized map lands at 7x9 tiles, and the step that
+ * would make it sharp enough to print needs 13x17 — 221. A budget of 144 stops
+ * one level short of a printable page, every time, which is the worst place to
+ * stop.
+ *
+ * What it costs is requests, once, cached by the browser between the editor
+ * and the print run. What it buys is the element most likely to fill a whole
+ * page, at a resolution a print shop will take.
+ */
+const MAX_TILES = 16 * 16
+
+/**
+ * What a thumbnail is worth fetching.
+ *
+ * The same map is drawn in three places at three sizes: the page, the sheet the
+ * press gets, and a 130px tile in the pages rail or the travel panel. Cutting
+ * the small ones to print resolution would mean a couple of hundred requests
+ * for a picture the size of a postage stamp, every time the panel opens — and
+ * against NASA or OpenStreetMap that is not merely wasteful, it is the bulk
+ * fetching their policies ask people not to do.
+ *
+ * `big` already travels down the render tree for exactly this kind of decision;
+ * the photo views use it to pick a thumbnail over a full-size image.
+ */
+const PREVIEW_TILES = 4 * 4
+const PREVIEW_TILE_MM = 90
+
+/**
+ * What a tile is worth drawing at, in millimetres.
+ *
+ * ── The bug this constant exists to fix ──────────────────────────────────
+ *
+ * The zoom used to be chosen so the route spanned the frame and nothing else,
+ * which by construction put about two tiles across the picture whatever its
+ * physical size. A route across Iceland on a 210mm page came back as 256x512
+ * pixels stretched over the sheet: seventeen dots per inch, on paper that
+ * holds three hundred. It looked exactly like what it was — a screenshot of a
+ * web map, enlarged.
+ *
+ * A tile is 256 pixels, or 512 when the source offers `{r}`. Holding one to
+ * about 22mm therefore asks for roughly 300dpi, which is what a print shop
+ * wants and the point past which more tiles buy nothing a reader can see.
+ */
+const TILE_MM_AT_PRINT = 22
/** Shard letters for templates that still carry `{s}`. */
const TILE_SHARDS = ['a', 'b', 'c']
+/**
+ * How far in a source can actually be asked to go.
+ *
+ * Matched on the host, the same way the attribution is: a template carries no
+ * capabilities of its own, and asking a source for a zoom it does not have
+ * answers with an error image rather than with a picture. NASA's imagery stops
+ * at 8, which is about 600 metres to the pixel — plenty for a country and not
+ * enough for a city, which is the honest limit of that source.
+ */
+export function maxZoomFor(template: string): number {
+ const url = template.toLowerCase()
+ if (url.includes('gibs.earthdata.nasa.gov')) return 8
+ // Sentinel-2 is a ten-metre sensor; past 16 the tiles are enlargements of
+ // pixels rather than more picture.
+ if (url.includes('tiles.maps.eox.at')) return 16
+ if (url.includes('arcgisonline')) return 17
+ return 19
+}
+
/**
* Which tiles cover a set of points, and where to draw them.
*
@@ -97,6 +163,11 @@ export function tileView(
* wrong towns.
*/
extent?: { minLng: number; minLat: number; maxLng: number; maxLat: number },
+ /**
+ * How much this drawing is worth. `preview` is a thumbnail: a handful of
+ * tiles, deliberately soft, because nobody prints the pages rail.
+ */
+ quality: 'print' | 'preview' = 'print',
): TileView | null {
if (!template || (!points.length && !extent)) return null
@@ -178,10 +249,38 @@ export function tileView(
y1 = Math.ceil(cy * scale + halfH) - 1
}
- // Back off until the grid is a size worth printing. A 12x12 grid is 144
- // requests for a picture 60mm across.
layOut()
- while (zoom > 0 && (x1 - x0 + 1) * (y1 - y0 + 1) > MAX_TILES) {
+
+ /*
+ * Up, then down.
+ *
+ * Up first: while a tile is being drawn larger than it is worth, and the
+ * source has a level left, and the extra level still fits inside the grid
+ * budget, take it. Each level halves the millimetres a tile covers, so this
+ * converges in a handful of steps.
+ *
+ * Then the old back-off, unchanged, because a fixed zoom or a very long
+ * route can still ask for more tiles than are worth fetching.
+ */
+ const budget = quality === 'preview' ? PREVIEW_TILES : MAX_TILES
+ const targetTileMm = quality === 'preview' ? PREVIEW_TILE_MM : TILE_MM_AT_PRINT
+
+ const ceiling = fixedZoom ?? maxZoomFor(template)
+ while (zoom < ceiling && size > targetTileMm) {
+ const before = { zoom, size, x0, x1, y0, y1 }
+ zoom += 1
+ layOut()
+ if ((x1 - x0 + 1) * (y1 - y0 + 1) > budget) {
+ // One level too far: put it back rather than leaving the grid over budget.
+ zoom = before.zoom
+ size = before.size
+ x0 = before.x0; x1 = before.x1; y0 = before.y0; y1 = before.y1
+ scale = 2 ** zoom
+ break
+ }
+ }
+
+ while (zoom > 0 && (x1 - x0 + 1) * (y1 - y0 + 1) > budget) {
zoom -= 1
layOut()
}
@@ -205,7 +304,13 @@ export function tileView(
// Longitude wraps, so a route across the antimeridian keeps its tiles.
.replace('{x}', String(wrap(x)))
.replace('{y}', String(y))
- .replace('{r}', '')
+ /*
+ * `{r}` is the retina placeholder, and a template only carries it
+ * when its source really serves 512px tiles. It used to be replaced
+ * with nothing, which threw that away and asked for a quarter of the
+ * pixels for the same number of requests.
+ */
+ .replace('{r}', '@2x')
/*
* The shard letter, chosen from the tile's own coordinates.
*
@@ -224,6 +329,24 @@ export function tileView(
return { tiles, originX, originY, size, zoom, tileX0: x0, tileY0: y0 }
}
+/**
+ * How sharp this grid will actually print, in dots per inch.
+ *
+ * Worth showing rather than hiding. Every raster source has a level past which
+ * no more detail exists — NASA's relief stops at about 600 metres to the pixel
+ * — and when a map is asked for a smaller area than its source can resolve,
+ * the picture does not fail, it just goes soft. On screen that is invisible.
+ * On a printed page it is the difference between a map and a smudge, and by
+ * then the book is bound.
+ *
+ * A print shop wants 300. Anything from about 150 is passable on matte stock.
+ * Below that, the honest answer is a different source or a wider view.
+ */
+export function printDpi(view: TileView, template: string): number {
+ const px = template.includes('{r}') ? 512 : 256
+ return Math.round(px / (view.size / 25.4))
+}
+
/**
* The credit a tile source requires.
*
@@ -239,6 +362,9 @@ export function attributionFor(template: string): string {
if (url.includes('stadiamaps')) return '© Stadia Maps © OpenStreetMap'
if (url.includes('carto')) return '© CARTO © OpenStreetMap'
if (url.includes('esri') || url.includes('arcgisonline')) return '© Esri'
+ if (url.includes('gibs.earthdata.nasa.gov')) return 'NASA EOSDIS GIBS'
+ // The wording CC BY 4.0 asks for: who made it, and what it is made of.
+ if (url.includes('tiles.maps.eox.at')) return 'Sentinel-2 cloudless © EOX (CC BY 4.0)'
return ''
}
diff --git a/client/src/components/Studio/printSheets.ts b/client/src/components/Studio/printSheets.ts
index b358007de..099e3299b 100644
--- a/client/src/components/Studio/printSheets.ts
+++ b/client/src/components/Studio/printSheets.ts
@@ -113,8 +113,21 @@ function escapeText(value: string): string {
* photographs were.
*/
export function printSheets(input: PrintSheetsInput): () => void {
+ /*
+ * The document's language, carried over from the editor.
+ *
+ * Not decoration: `hyphens: auto` does nothing at all until the browser knows
+ * which language's rules to break the words by, and it reads that from the
+ * nearest `lang`. Without it a heading the editor had set as "Lorem ip-sum"
+ * came out unhyphenated in the export, ran past its own frame and was clipped
+ * — the one place where what you proofread and what you print disagreed.
+ * TranslationContext sets this on the app, so taking it from there is what
+ * makes the two agree by construction rather than by a second setting.
+ */
+ const lang = document.documentElement.lang || 'en'
+
const doc = `
-
+
${escapeText(input.title)}
@@ -254,16 +267,34 @@ async function whenReady(iframe: HTMLIFrameElement): Promise {
const doc = iframe.contentDocument
if (!win || !doc) return
- const images = Array.from(doc.images).map(img =>
- img.complete
- ? Promise.resolve()
- : new Promise(resolve => {
- // A picture that will not load must not hold the button hostage —
- // better a book with one gap than a dialog that never finishes.
- img.addEventListener('load', () => resolve(), { once: true })
- img.addEventListener('error', () => resolve(), { once: true })
- }),
- )
+ const settle = (el: Element) => new Promise(resolve => {
+ // A picture that will not load must not hold the button hostage — better a
+ // book with one gap than a dialog that never finishes.
+ el.addEventListener('load', () => resolve(), { once: true })
+ el.addEventListener('error', () => resolve(), { once: true })
+ })
- await Promise.all([...images, doc.fonts?.ready ?? Promise.resolve()])
+ const images = Array.from(doc.images).map(img => (img.complete ? Promise.resolve() : settle(img)))
+
+ /*
+ * The photographs inside the map's markers are SVG ``, which
+ * `doc.images` does not collect and which has no `complete` to ask, so there
+ * is nothing to do but wait on the events. Missing them meant a book could go
+ * to print with its route beads still blank.
+ */
+ const svgImages = Array.from(doc.querySelectorAll?.('image') ?? []).map(settle)
+
+ /*
+ * And a ceiling over the lot. Without `complete` to fall back on there is no
+ * way to tell a picture that is still coming from one whose events already
+ * fired before this ran, and a Save button that never enables is worse than a
+ * marker that prints blank.
+ */
+ const wait = win.setTimeout?.bind(win) ?? setTimeout
+ const ceiling = new Promise(resolve => { wait(resolve, 8000) })
+
+ await Promise.race([
+ Promise.all([...images, ...svgImages, doc.fonts?.ready ?? Promise.resolve()]),
+ ceiling,
+ ])
}
diff --git a/client/src/components/Studio/resolveBindings.ts b/client/src/components/Studio/resolveBindings.ts
new file mode 100644
index 000000000..76b0bc862
--- /dev/null
+++ b/client/src/components/Studio/resolveBindings.ts
@@ -0,0 +1,147 @@
+import type { BookDocument } from '@trek/shared'
+import { formatBookCoords, formatBookDate } from './entryText'
+
+/**
+ * Reading a book's bound text back out of the journey.
+ *
+ * The contract has promised this since the format was written — "a bound
+ * element re-reads its source when the book is opened, so fixing a typo in the
+ * journal fixes it in the book" — and until now nothing did it. An element
+ * placed from the Entries tab carried a `binding` that only `applyTemplate`
+ * ever looked at, so correcting an entry's title left every page that quoted it
+ * showing the old words, while the inspector went on telling people the link
+ * existed. This is that link.
+ *
+ * ── Three rules, all of them load-bearing ────────────────────────────────
+ *
+ * **A missing value never blanks a page.** An entry that has been deleted, a
+ * title that has been cleared, a photo whose caption is gone: the element keeps
+ * the words it has. Overwriting them with an empty string would turn a data
+ * change in the journal into a hole in a printed book, and nobody would connect
+ * the two.
+ *
+ * **`overridden` wins.** Editing the text in Studio is a decision, and after it
+ * the journal stops being the source. That flag is set by the canvas editor and
+ * by the inspector, and read here — its only reader.
+ *
+ * **A formatted source is only re-read when the fact behind it moved.** A title
+ * is its own value, so comparing the words is enough. A date is not: the same
+ * day is "28. Mai 2026" here and "MAY 28, 2026" on a page set in English and in
+ * small caps, and a resolver that compared the words would rewrite every such
+ * line the moment somebody with another language opened the book — and the two
+ * of them would then take turns doing it to each other. So date and coordinate
+ * bindings carry the raw value they were set from and are compared against
+ * that. An element written before that field existed has none, and is left
+ * exactly as it is.
+ *
+ * **Nothing changed means the same object.** Not an optimisation: `useBookStore`
+ * recognises the document it has agreed with the server by identity, so a
+ * resolver that always returned a fresh object would make every open count as
+ * an edit, and the autosave would write the book back every time somebody
+ * looked at it. Spreads and elements are shared through untouched, too.
+ */
+
+/**
+ * What the journey can answer with.
+ *
+ * Declared structurally rather than imported from the sidebar: this module
+ * knows about the journal, not about the panel that browses it, and the source
+ * the editor already builds satisfies this shape as it is.
+ */
+export interface BindingSource {
+ title: string
+ subtitle: string | null
+ entries: {
+ id: number
+ title: string | null
+ story: string | null
+ location: string | null
+ date: string | null
+ lat: number | null
+ lng: number | null
+ }[]
+ photos: { photoId: number; caption?: string | null }[]
+}
+
+export function resolveBindings(doc: BookDocument, src: BindingSource, locale: string): BookDocument {
+ let documentChanged = false
+
+ const spreads = doc.spreads.map(spread => {
+ let spreadChanged = false
+
+ const elements = spread.elements.map(el => {
+ if (el.kind !== 'text' || !el.binding || el.overridden) return el
+
+ const next = resolveOne(el.binding, src, locale)
+ // An empty answer is not an answer — see the first rule above. A journey
+ // with no subtitle, an entry whose title has been cleared and a photo
+ // that lost its caption all arrive here as an empty string, and none of
+ // them is a reason to blank a line somebody has set.
+ if (!next || !next.text || next.text === el.text) return el
+
+ spreadChanged = true
+ return next.value === undefined
+ ? { ...el, text: next.text }
+ : { ...el, text: next.text, binding: { ...el.binding, value: next.value } }
+ })
+
+ if (!spreadChanged) return spread
+ documentChanged = true
+ return { ...spread, elements }
+ })
+
+ return documentChanged ? { ...doc, spreads } : doc
+}
+
+type Binding = NonNullable['binding']>
+
+/** The words, and — for a formatted source — the fact they were made from. */
+interface Resolved {
+ text: string
+ value?: string
+}
+
+function resolveOne(binding: Binding, src: BindingSource, locale: string): Resolved | null {
+ if (binding.source === 'journey.title') return { text: src.title }
+ if (binding.source === 'journey.subtitle') return { text: src.subtitle ?? '' }
+
+ if (binding.source === 'photo.caption') {
+ const photo = src.photos.find(p => p.photoId === binding.photoId)
+ return { text: photo?.caption ?? '' }
+ }
+
+ const entry = src.entries.find(e => e.id === binding.entryId)
+ if (!entry) return null
+
+ switch (binding.source) {
+ // The same fallback the layout and the template filler use: a stop nobody
+ // has written about is known by where it is.
+ case 'entry.title': return { text: entry.title || entry.location || '' }
+ case 'entry.story': return { text: (entry.story || '').trim() }
+
+ case 'entry.date':
+ // No stored value means an element older than this machinery, or one the
+ // layout composed a longer line from. Either way its words are not this
+ // function's to rewrite.
+ if (!binding.value || !entry.date || binding.value === entry.date) return null
+ return { text: formatBookDate(entry.date, locale), value: entry.date }
+
+ case 'entry.location': {
+ // With a format it is the point, without one it is the place name. A
+ // hand-built element from the auto layout has never been through Zod, so
+ // the field is genuinely absent rather than defaulted.
+ if (!binding.format) return { text: entry.location ?? '' }
+ if (entry.lat == null || entry.lng == null) return null
+ const value = coordValue(entry.lat, entry.lng)
+ if (!binding.value || binding.value === value) return null
+ return { text: formatBookCoords(entry.lat, entry.lng, binding.format), value }
+ }
+
+ default: return null
+ }
+}
+
+/** What a point is, as one string, for the comparison above. */
+export function coordValue(lat: number, lng: number): string {
+ return `${lat},${lng}`
+}
diff --git a/client/src/components/Studio/roadRoute.ts b/client/src/components/Studio/roadRoute.ts
new file mode 100644
index 000000000..59de3fa2b
--- /dev/null
+++ b/client/src/components/Studio/roadRoute.ts
@@ -0,0 +1,133 @@
+import { calculateRouteWithLegs } from '../Map/RouteCalculator'
+
+/**
+ * The roads between a book's stops.
+ *
+ * ── Why this is fetched here and frozen, rather than stored ──────────────
+ *
+ * TREK already asks OSRM for road geometry every time the planner draws a
+ * driving day, and then throws it away: it lives in React state and a tab-local
+ * cache, is never persisted, and no endpoint accepts it. Rather than build the
+ * table, the write path, the read path and the migration that would keep it,
+ * this asks the same router the planner asks, once, when somebody turns the
+ * option on — and writes the answer into the document like every other travel
+ * figure. The book then prints the same line offline, next year, on a machine
+ * that has never heard of OSRM.
+ *
+ * ── What it refuses to route ─────────────────────────────────────────────
+ *
+ * A leg is only sent if a road answer is plausible. Two reasons, and both are
+ * about honesty rather than about cost: a router asked for Reykjavík to Lisbon
+ * either fails or answers with three days of driving and a ferry, and neither
+ * is the flight that was actually taken. Past the ceiling the leg keeps the
+ * bow, which is how a printed map has always said "this part was flown".
+ *
+ * ── And why it is polite about it ────────────────────────────────────────
+ *
+ * The public router is a community service, asked from the designer's own
+ * browser with no identifying header, one request per leg. So the legs go one
+ * at a time with a pause between them, the way the transport-booking hook
+ * already paces itself, and a failure is an answer rather than an error: that
+ * leg simply has no road, and the map draws the line it drew before.
+ */
+
+/** Past this, a leg is a flight rather than a drive. */
+const ROAD_CEILING_KM = 1200
+
+/** A pause between legs, so a long journey is not a burst. */
+const PACE_MS = 120
+
+/**
+ * What a printed leg can resolve.
+ *
+ * A hundred and twenty, not four hundred, and the reason is bytes rather than
+ * looks: the whole book travels in one request on every autosave, and four
+ * hundred points a leg puts a thirteen-leg journey over a hundred kilobytes on
+ * its own. At page size a road with this many points is already smoother than
+ * the press can resolve — the extra detail was costing the save, not the map.
+ */
+const MAX_POINTS_PER_LEG = 120
+
+/**
+ * Five decimals, which is about a metre.
+ *
+ * OSRM answers with six or seven, and the last two describe a position more
+ * precisely than the road is wide. Rounding here rather than at draw time is
+ * what keeps them out of the document, where they would be paid for on every
+ * save for the rest of the book's life.
+ */
+const round5 = (n: number): number => Math.round(n * 1e5) / 1e5
+
+export interface RoadStop {
+ lat: number
+ lng: number
+}
+
+function km(a: RoadStop, b: RoadStop): number {
+ const r = (d: number) => (d * Math.PI) / 180
+ const dLat = r(b.lat - a.lat)
+ const dLng = r(b.lng - a.lng)
+ const h = Math.sin(dLat / 2) ** 2
+ + Math.cos(r(a.lat)) * Math.cos(r(b.lat)) * Math.sin(dLng / 2) ** 2
+ return 12742 * Math.asin(Math.min(1, Math.sqrt(h)))
+}
+
+/**
+ * Thin a leg to what a printed line can show.
+ *
+ * Evenly spaced and keeping both ends, the same rule the journey's own route
+ * thinning uses: dropping a run out of the middle would cut the shape of the
+ * road out, while every n-th point keeps it and loses only detail no press can
+ * resolve.
+ */
+function thin(points: [number, number][]): [number, number][] {
+ if (points.length <= MAX_POINTS_PER_LEG) return points
+ const out: [number, number][] = []
+ const step = (points.length - 1) / (MAX_POINTS_PER_LEG - 1)
+ for (let i = 0; i < MAX_POINTS_PER_LEG; i++) out.push(points[Math.round(i * step)])
+ return out
+}
+
+/**
+ * The road for every leg, or null where there is none.
+ *
+ * Always as long as `stops.length - 1`, so entry `i` is unambiguously the way
+ * from stop `i` to stop `i + 1` — a shorter array would leave the renderer
+ * guessing which legs it had answers for.
+ */
+export async function fetchRoads(
+ stops: RoadStop[],
+ opts: { signal?: AbortSignal; onProgress?: (done: number, total: number) => void } = {},
+): Promise<([number, number][] | null)[]> {
+ const legs = Math.max(0, stops.length - 1)
+ const out: ([number, number][] | null)[] = new Array(legs).fill(null)
+
+ for (let i = 0; i < legs; i++) {
+ if (opts.signal?.aborted) break
+ const a = stops[i]
+ const b = stops[i + 1]
+
+ if (km(a, b) > ROAD_CEILING_KM) {
+ opts.onProgress?.(i + 1, legs)
+ continue
+ }
+
+ try {
+ const r = await calculateRouteWithLegs([a, b], { signal: opts.signal, profile: 'driving' })
+ // Two points back is the straight line the caller already draws, so it is
+ // not a road and storing it would only cost bytes.
+ if (r.coordinates.length > 2) {
+ out[i] = thin(r.coordinates).map(([lat, lng]) => [round5(lat), round5(lng)])
+ }
+ } catch {
+ // A leg with no road keeps the line it had. Not an error worth a message:
+ // an island, a closed border and a router having a bad day all look the
+ // same from here, and all three mean the same thing for the drawing.
+ }
+
+ opts.onProgress?.(i + 1, legs)
+ if (i < legs - 1 && PACE_MS) await new Promise(resolve => { setTimeout(resolve, PACE_MS) })
+ }
+
+ return out
+}
diff --git a/client/src/components/Studio/travelRefresh.ts b/client/src/components/Studio/travelRefresh.ts
index 24b5de0ad..7a2f1a31c 100644
--- a/client/src/components/Studio/travelRefresh.ts
+++ b/client/src/components/Studio/travelRefresh.ts
@@ -49,21 +49,54 @@ export function isStale(el: BookElement, stats: JourneyStats | null): boolean {
return el.codes.join(',') !== stats.countries.map(c => c.code).join(',')
}
if (el.kind === 'map') {
+ // Against the route this map is of, which is not always the whole journey.
+ const live = routeFor(stats, el.tripId)
// The route is compared by shape rather than element-by-element: a
// four-hundred-point deep-equal on every render would cost more than it
// could ever catch, and a changed route almost always changes its length or
// one of its ends.
- if (el.points.length !== stats.points.length) return true
+ if (el.points.length !== live.length) return true
if (!el.points.length) return false
const a = el.points[0]
- const b = stats.points[0]
+ const b = live[0]
const y = el.points[el.points.length - 1]
- const z = stats.points[stats.points.length - 1]
- return a.lat !== b.lat || a.lng !== b.lng || y.lat !== z.lat || y.lng !== z.lng
+ const z = live[live.length - 1]
+ if (a.lat !== b.lat || a.lng !== b.lng || y.lat !== z.lat || y.lng !== z.lng) return true
+
+ /*
+ * And whether the photographs moved.
+ *
+ * A marker draws the entry's own first picture, so adding one to an entry
+ * changes the map without moving a single stop — the shape test above would
+ * never notice, and the only way to get the picture onto the page was to
+ * delete the map and place it again.
+ *
+ * Compared in full rather than by its ends, unlike the coordinates: a photo
+ * added to the middle of a journey is the ordinary case, where a stop added
+ * to the middle is not. It is a few hundred numbers joined into a string,
+ * which costs nothing next to the render it happens in.
+ */
+ const shown = el.points.map(p => p.photoId ?? 0).join(',')
+ const now = live.map(p => p.photoId ?? 0).join(',')
+ return shown !== now
}
return false
}
+/**
+ * The stops a map of this scope should draw.
+ *
+ * `null` is the journey entire. A trip id narrows it to that trip's own stops,
+ * which is the difference between a book with one map of everywhere and a book
+ * with a page per trip — and, on a journey made of two trips, the difference
+ * between a route and a route with an eight-hundred-kilometre ruler line across
+ * the middle of it joining two places nobody travelled between.
+ */
+export function routeFor(stats: JourneyStats, tripId: number | null): JourneyStats['points'] {
+ if (tripId == null) return stats.points
+ return stats.points.filter(p => p.tripId === tripId)
+}
+
/**
* The patch that takes an element's values from the journey as it stands now.
*
@@ -92,10 +125,16 @@ export function refreshPatch(el: BookElement, stats: JourneyStats): Partial c.code),
- points: stats.points.map(p => ({ lat: p.lat, lng: p.lng, label: p.label })),
+ // Only the countries this map's own stops fall in: a map of one trip that
+ // listed every country in the journey would outline places it does not
+ // show.
+ countries: el.tripId == null
+ ? stats.countries.map(c => c.code)
+ : [...new Set(live.map(p => p.country).filter((c): c is string => !!c))],
+ points: live.map(p => ({ lat: p.lat, lng: p.lng, label: p.label, photoId: p.photoId ?? null })),
} as Partial
}
diff --git a/client/src/pages/journeyStudio/useJourneyStudio.ts b/client/src/pages/journeyStudio/useJourneyStudio.ts
index 8940a67b9..655fe6f55 100644
--- a/client/src/pages/journeyStudio/useJourneyStudio.ts
+++ b/client/src/pages/journeyStudio/useJourneyStudio.ts
@@ -11,9 +11,10 @@ import { useTranslation } from '../../i18n'
import { useIsMobile } from '../../hooks/useIsMobile'
import { PAGE_PRESETS, clampPageSize, type PagePresetId } from '../../components/Studio/pagePresets'
import {
- buildBook, distributeGallery, relayoutSpread,
+ buildBook, distributeGallery, emptyBook, relayoutSpread,
type AutoEntry, type AutoInput, type AutoPhoto,
} from '../../components/Studio/autoLayout'
+import { resolveBindings } from '../../components/Studio/resolveBindings'
/** CSS defines 1in as 96px and 25.4mm, so this factor is exact, not a guess. */
const PX_PER_MM = 96 / 25.4
@@ -165,6 +166,47 @@ export function useJourneyStudio() {
const journey = current && current.id === journeyId ? current : null
+ /** The journey's own material, for the content browser. */
+ const source = useMemo(() => ({
+ entries: (journey?.entries || [])
+ .filter((e: JourneyEntry) => !!(
+ e.title || e.story || e.location_name || e.mood || e.weather
+ || e.pros_cons?.pros?.length || e.pros_cons?.cons?.length
+ // A stop that was only checked into still has two things a page can
+ // use: the day it happened and where on earth it was.
+ || e.entry_date || (e.location_lat != null && e.location_lng != null)
+ ))
+ .map((e: JourneyEntry) => ({
+ id: e.id,
+ title: e.title ?? null,
+ story: e.story ?? null,
+ location: e.location_name ?? null,
+ date: e.entry_date ?? null,
+ lat: e.location_lat ?? null,
+ lng: e.location_lng ?? null,
+ // What the entry recorded beyond its story: how the day felt, what the
+ // weather did, and what was worth and not worth it.
+ mood: e.mood ?? null,
+ weather: e.weather ?? null,
+ pros: e.pros_cons?.pros?.filter(Boolean) ?? [],
+ cons: e.pros_cons?.cons?.filter(Boolean) ?? [],
+ })),
+ photos: (journey?.gallery || []).map((p: GalleryPhoto) => ({
+ photoId: p.photo_id,
+ caption: p.caption ?? null,
+ })),
+ // Which entry a photo hangs on, so a search for a place finds its pictures
+ // even though a picture carries no words of its own.
+ photoEntries: (() => {
+ const map: Record = {}
+ for (const e of (journey?.entries || []) as JourneyEntry[]) {
+ const words = [e.title, e.location_name].filter(Boolean).join(' ').toLowerCase()
+ for (const p of e.photos || []) map[p.photo_id] = words
+ }
+ return map
+ })(),
+ }), [journey])
+
useEffect(() => {
if (!Number.isFinite(journeyId)) return
if (!current || current.id !== journeyId) void loadJourney(journeyId)
@@ -186,10 +228,30 @@ export function useJourneyStudio() {
* button needs it.
*/
if (book.record) {
- // Already normalised by useBookStore, and deliberately the same object it
- // holds — see `synced` there, which is what keeps opening a book from
- // counting as an edit.
- loadDoc(book.record.document)
+ /*
+ * Already normalised by useBookStore, and deliberately the same object it
+ * holds — see `synced` there, which is what keeps opening a book from
+ * counting as an edit.
+ *
+ * Opening is also where a bound element catches up with the journal, and
+ * the only place it can be: resolving inside useBookStore would rewrite
+ * the very object `synced` compares against, and every open — every
+ * incoming remote save, every conflict — would become a save. Here it
+ * runs once per journey, and when it did change something the ordinary
+ * autosave writes that back exactly once. resolveBindings returns the
+ * document it was given when nothing moved, which is what keeps the
+ * common case free.
+ */
+ loadDoc(resolveBindings(
+ book.record.document,
+ {
+ title: journey.title || '',
+ subtitle: journey.subtitle ?? null,
+ entries: source.entries,
+ photos: source.photos,
+ },
+ locale,
+ ))
}
// A skeleton entry is a place pulled in from a trip that nobody has written
@@ -268,18 +330,30 @@ export function useJourneyStudio() {
pageNumbers: bookPageSetupSchema.shape.pageNumbers.parse({}),
},
stats,
+ // The recorded track, when the journey has one. It is fetched in the same
+ // hook and used to stop here, which left auto-laid books drawing a ruler
+ // line over a route somebody had actually walked.
+ path,
stationsLabel: t('journey.studio.stations'),
dayLabel: t('journey.studio.day'),
summaryLabel: t('journey.studio.summary'),
countriesLabel: t('journey.studio.countries'),
}
- if (!book.record) loadDoc(buildBook(autoInput.current))
+ /*
+ * A journey with no book yet gets an empty one, not a laid-out one.
+ *
+ * `autoInput` is still built above, because the Auto layout menu needs it
+ * the moment somebody asks — which is the point: the layout is now offered
+ * rather than applied, and a first page somebody chose beats a whole book
+ * they have to take apart.
+ */
+ if (!book.record) loadDoc(emptyBook(autoInput.current))
// `stats` is deliberately not a dependency: the book is laid out once, from
// the figures as they stood at that moment. Re-running on a later fetch
// would throw away the user's work — which is the same reason `builtFor`
// exists for the journey itself.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [journey, statsSettled, book.loaded, book.record, loadDoc, locale])
+ }, [journey, statsSettled, book.loaded, book.record, loadDoc, locale, source])
/*
* What the journey adds up to, for the travel elements.
@@ -358,6 +432,25 @@ export function useJourneyStudio() {
* that is 210 by 240 is not "square 21" with a typo in it, and leaving the
* preset alone would have the picker naming a format the page no longer is.
*/
+ /**
+ * What the press needs around the page.
+ *
+ * Both were fixed at the values a photo-book vendor most often asks for, 3mm
+ * of bleed and a 5mm safe margin, and neither had a control — which is fine
+ * right up until somebody's printer asks for 5 and 10, and then the book
+ * cannot be made here at all. The trim size already answers that question for
+ * the page itself; this answers it for the edges.
+ *
+ * The ceilings are generous rather than typical: 20mm of bleed is more than
+ * any press asks for, and a safe margin worth a third of a small page is
+ * already a design decision rather than a printer's requirement.
+ */
+ const setPageEdge = useCallback((which: 'bleed' | 'safe', value: number) => {
+ const limit = which === 'bleed' ? 20 : 40
+ const clamped = Math.min(limit, Math.max(0, Math.round(value * 10) / 10))
+ commit(d => ({ ...d, page: { ...d.page, [which]: clamped } }))
+ }, [commit])
+
const setPageSize = useCallback((axis: 'w' | 'h', value: number) => {
commit(d => ({
...d,
@@ -518,41 +611,6 @@ export function useJourneyStudio() {
return () => window.removeEventListener('keydown', onKey)
}, [selection, select, close, undo, redo])
- /** The journey's own material, for the content browser. */
- const source = useMemo(() => ({
- entries: (journey?.entries || [])
- .filter((e: JourneyEntry) => !!(
- e.title || e.story || e.location_name || e.mood || e.weather
- || e.pros_cons?.pros?.length || e.pros_cons?.cons?.length
- ))
- .map((e: JourneyEntry) => ({
- id: e.id,
- title: e.title ?? null,
- story: e.story ?? null,
- location: e.location_name ?? null,
- date: e.entry_date ?? null,
- // What the entry recorded beyond its story: how the day felt, what the
- // weather did, and what was worth and not worth it.
- mood: e.mood ?? null,
- weather: e.weather ?? null,
- pros: e.pros_cons?.pros?.filter(Boolean) ?? [],
- cons: e.pros_cons?.cons?.filter(Boolean) ?? [],
- })),
- photos: (journey?.gallery || []).map((p: GalleryPhoto) => ({
- photoId: p.photo_id,
- caption: p.caption ?? null,
- })),
- // Which entry a photo hangs on, so a search for a place finds its pictures
- // even though a picture carries no words of its own.
- photoEntries: (() => {
- const map: Record = {}
- for (const e of (journey?.entries || []) as JourneyEntry[]) {
- const words = [e.title, e.location_name].filter(Boolean).join(' ').toLowerCase()
- for (const p of e.photos || []) map[p.photo_id] = words
- }
- return map
- })(),
- }), [journey])
const coverUrl = journey?.cover_image
? (journey.cover_image.startsWith('/uploads/') ? journey.cover_image : `/uploads/${journey.cover_image}`)
@@ -580,6 +638,7 @@ export function useJourneyStudio() {
preset,
setPreset,
setPageSize,
+ setPageEdge,
setPageNumbers,
relayoutBook,
relayoutCurrentSpread,
diff --git a/client/src/styles/studio.css b/client/src/styles/studio.css
index a3e66e3d0..ff2561e26 100644
--- a/client/src/styles/studio.css
+++ b/client/src/styles/studio.css
@@ -327,6 +327,56 @@
}
.trek-dash .st-panel-scroll { overflow-y: auto; overflow-x: hidden; padding: 0 12px 14px; min-height: 0; }
+/* ----------------- inspector: the head ----------------- */
+/*
+ * The panel's own title, and the three things you do to what it is about.
+ *
+ * It is set larger and darker than a section label on purpose: those were the
+ * same small capitals in the same grey, so a panel with eight groups had nine
+ * headings that all looked alike and no top.
+ */
+.trek-dash .st-inspector-head { padding: 12px 12px 10px; text-transform: none; letter-spacing: 0; }
+.trek-dash .st-inspector-head .st-what {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ min-width: 0;
+ font-size: 13px;
+ font-weight: 650;
+ letter-spacing: -0.01em;
+ color: var(--ink);
+}
+.trek-dash .st-inspector-head .st-what em {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 18px;
+ height: 18px;
+ padding: 0 5px;
+ border-radius: 999px;
+ background: color-mix(in oklch, var(--ink) 8%, transparent);
+ font-size: 10.5px;
+ font-style: normal;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ color: var(--ink-2);
+}
+.trek-dash .st-head-acts { display: flex; align-items: center; gap: 2px; flex: none; }
+.trek-dash .st-act {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 26px;
+ height: 26px;
+ border-radius: 8px;
+ color: var(--ink-3);
+ transition: background .14s, color .14s;
+}
+.trek-dash .st-act:hover { background: color-mix(in oklch, var(--ink) 8%, transparent); color: var(--ink); }
+.trek-dash .st-act.is-on { color: var(--accent); background: color-mix(in oklch, var(--accent) 12%, transparent); }
+.trek-dash .st-act.is-danger:hover { background: color-mix(in oklch, #e11d48 16%, transparent); color: #e11d48; } /* theme-lint-disable — destructive, not a scheme colour */
+.trek-dash .st-act:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--accent-soft), 0 0 0 3px var(--accent); }
+
/* ----------------- page thumbnails ----------------- */
.trek-dash .st-thumbs { display: flex; flex-direction: column; gap: 10px; }
.trek-dash .st-thumb {
@@ -863,16 +913,117 @@
}
/* ----------------- inspector controls ----------------- */
-.trek-dash .st-section { padding: 12px 2px 14px; border-bottom: 1px solid var(--line); }
+/* ----------------- inspector: sections ----------------- */
+/*
+ * A section is a question, and its head is the way to fold the answer away.
+ * The whole head is the target rather than a caret nobody can hit, and the
+ * label is a shade darker than it was: it used to be set in exactly the same
+ * small capitals as the panel's own title, which left the panel with two
+ * levels of heading that looked like one.
+ */
+/*
+ * Two shapes share this class, and they carry their padding differently.
+ *
+ * A plain section is a heading with things under it, written as markup in the
+ * panels — the shape browser, the travel cards — and it has always padded
+ * itself. A folding section moves that padding onto its own head and body, so
+ * a folded one collapses to the height of its heading instead of leaving an
+ * empty band behind.
+ *
+ * The rebuild moved the padding onto the head and body and forgot the twelve
+ * plain sections still written by hand, which lost their air and left every
+ * heading in the panels sitting on the block above it.
+ */
+.trek-dash .st-section { padding: 14px 2px 18px; border-bottom: 1px solid var(--line); }
.trek-dash .st-section:last-of-type { border-bottom: 0; }
+.trek-dash .st-section.is-fold { padding: 0 2px; }
+.trek-dash .st-section-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ width: 100%;
+ padding: 13px 2px 11px;
+ color: var(--ink-3);
+ transition: color .14s;
+}
+.trek-dash .st-section-head:hover { color: var(--ink); }
+.trek-dash .st-section-head:hover .st-section-label { color: inherit; }
.trek-dash .st-section-label {
font-size: 10.5px;
font-weight: 600;
text-transform: uppercase;
- letter-spacing: 0.13em;
+ letter-spacing: 0.11em;
+ /*
+ * Quieter than what it heads, and quieter than the panel's own title.
+ * `inherit` was wrong twice over: in a folding head it happened to pick up
+ * the right grey, and in the twelve hand-written sections it picked up full
+ * ink, which made every group heading in the left-hand panels louder than
+ * the title above them.
+ */
color: var(--ink-3);
- margin-bottom: 9px;
+ /*
+ * A heading needs room before what it heads. In a folding section the head's
+ * own padding provides it, so that one takes it back off below — otherwise
+ * the gap would be counted twice and the panel would drift the other way.
+ */
+ margin-bottom: 10px;
}
+.trek-dash .st-section-head .st-section-label { margin-bottom: 0; }
+.trek-dash .st-section-caret { flex: none; opacity: 0.5; transition: transform .18s, opacity .14s; }
+.trek-dash .st-section-head:hover .st-section-caret { opacity: 1; }
+.trek-dash .st-section.is-folded .st-section-caret { transform: rotate(-90deg); }
+.trek-dash .st-section-body { padding: 0 2px 17px; }
+/* Specific enough to beat `.st-hint`, which is declared later in this file
+ and was quietly winning with its own 6px top padding. */
+.trek-dash .st-section-body > .st-section-hint { padding: 0 0 8px; }
+
+/* ----------------- inspector: one labelled line ----------------- */
+/*
+ * The label carries the *role* — accent, text, icon — which is the thing three
+ * identical swatch rows under three identical headings could never say.
+ */
+.trek-dash .st-line { display: flex; align-items: center; gap: 10px; min-width: 0; }
+.trek-dash .st-line + .st-line { margin-top: 8px; }
+.trek-dash .st-line.is-wide { display: block; }
+.trek-dash .st-line-label {
+ flex: none;
+ width: 52px;
+ font-size: 11px;
+ color: var(--ink-3);
+ line-height: 1.25;
+}
+.trek-dash .st-line.is-wide .st-line-label { display: block; margin-bottom: 7px; width: auto; }
+.trek-dash .st-line-body { flex: 1; min-width: 0; }
+
+/* ----------------- inspector: a switch on its own line ----------------- */
+.trek-dash .st-switch {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 4px 0;
+}
+.trek-dash .st-switch + .st-switch { border-top: 1px solid var(--line); }
+.trek-dash .st-switch-label { font-size: 12px; color: var(--ink-2); min-width: 0; }
+
+/* ----------------- inspector: a number with its unit ----------------- */
+.trek-dash .st-num { display: flex; align-items: center; gap: 8px; min-width: 0; }
+.trek-dash .st-num-label { flex: none; font-size: 11px; color: var(--ink-3); min-width: 13px; }
+.trek-dash .st-num-box { position: relative; flex: 1; min-width: 0; display: flex; }
+.trek-dash .st-num-box .st-input { padding-right: 26px; }
+/* The unit sits inside the field, so the number and what it is in read as one
+ value rather than as a field with a word next to it. */
+.trek-dash .st-num-unit {
+ position: absolute;
+ right: 9px;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 10.5px;
+ color: var(--ink-3);
+ pointer-events: none;
+}
+
.trek-dash .st-grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.trek-dash .st-row { display: flex; flex-wrap: wrap; gap: 5px; }
@@ -926,14 +1077,36 @@
.trek-dash .st-chip:hover { background: color-mix(in oklch, var(--ink) 10%, transparent); color: var(--ink); }
.trek-dash .st-chip.is-on { background: var(--accent); color: var(--accent-text, #fff); }
.trek-dash .st-chip.is-icon { width: 27px; padding: 0; }
+/*
+ * Choose-several, which is a different question from choose-one and has to
+ * look like it: an outline that fills only with the tick, so a set of seven
+ * with three in it reads as three ticks rather than as three of something.
+ */
+.trek-dash .st-chip.is-pick {
+ background: transparent;
+ box-shadow: inset 0 0 0 1px var(--line-2);
+ padding: 0 10px 0 9px;
+}
+.trek-dash .st-chip.is-pick:hover { background: color-mix(in oklch, var(--ink) 6%, transparent); }
+.trek-dash .st-chip.is-pick.is-on {
+ background: color-mix(in oklch, var(--accent) 12%, transparent);
+ box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--accent) 45%, transparent);
+ color: var(--ink);
+}
.trek-dash .st-chip.is-danger:hover { background: color-mix(in oklch, #e11d48 18%, transparent); color: #e11d48; }
.trek-dash .st-chip:focus { outline: none; }
.trek-dash .st-chip:focus-visible { box-shadow: 0 0 0 2px var(--accent-soft), 0 0 0 3px var(--accent); }
-.trek-dash .st-swatches { display: flex; flex-wrap: wrap; gap: 6px; }
+/*
+ * Ten swatches on one line, which needs the whole panel width rather than what
+ * is left beside a label — so the colour rows are `wide`, with the role above
+ * them. Measured rather than guessed: the body is 261px, and 10 at 22px with
+ * 4px between them is 256.
+ */
+.trek-dash .st-swatches { display: flex; flex-wrap: wrap; gap: 4px; }
.trek-dash .st-swatch {
- width: 24px;
- height: 24px;
+ width: 22px;
+ height: 22px;
border-radius: 7px;
border: 1px solid var(--line-2);
box-shadow: var(--sh-xs);
@@ -1178,6 +1351,22 @@
transition: background .16s, border-color .16s, transform .14s;
min-width: 0;
}
+/*
+ * The one to reach for, said with a ring.
+ *
+ * The label sits in a grid cell about eighty pixels wide, where a word like
+ * "recommended" is two lines of ellipsis and reads as noise. Green because it
+ * is the one colour a reader already takes as "this one is fine" without being
+ * told, and it is deliberately not the accent: the accent is what the user
+ * picked for their book, and it changes.
+ */
+.trek-dash .st-travel-card.is-recommended {
+ border-color: color-mix(in oklch, #16a34a 55%, transparent); /* theme-lint-disable — a recommendation, not a scheme colour */
+ box-shadow: 0 0 0 1px color-mix(in oklch, #16a34a 30%, transparent); /* theme-lint-disable */
+}
+.trek-dash .st-travel-card.is-recommended:hover {
+ border-color: color-mix(in oklch, #16a34a 80%, transparent); /* theme-lint-disable */
+}
.trek-dash .st-travel-card:hover {
background: var(--surface);
border-color: color-mix(in oklch, var(--accent) 40%, var(--line-2));
diff --git a/client/tests/unit/studio/autoLayout.travel.test.ts b/client/tests/unit/studio/autoLayout.travel.test.ts
index bacfeeaf6..b3d8b8302 100644
--- a/client/tests/unit/studio/autoLayout.travel.test.ts
+++ b/client/tests/unit/studio/autoLayout.travel.test.ts
@@ -8,7 +8,7 @@ import { describe, it, expect, vi } from 'vitest'
*/
vi.mock('../../../src/components/Studio/bookTemplates.data', () => ({ SPREAD_TEMPLATES: [] }))
import type { BookPageSetup, BookSpread, JourneyStats } from '@trek/shared'
-import { buildBook, type AutoEntry, type AutoInput } from '../../../src/components/Studio/autoLayout'
+import { buildBook, emptyBook, type AutoEntry, type AutoInput } from '../../../src/components/Studio/autoLayout'
import { bookPageSetupSchema } from '@trek/shared'
/**
@@ -39,9 +39,10 @@ const stats = (over: Partial = {}): JourneyStats => ({
furthest: 408_000,
countries: [{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' }],
points: [
- { lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS' },
- { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS' },
+ { lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: null, photoId: null },
+ { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: null, photoId: null },
],
+ trips: [],
start: '2026-06-02',
end: '2026-06-15',
...over,
@@ -277,3 +278,53 @@ describe('the document as a whole', () => {
expect(doc.spreads.filter(sp => sp.role === 'back')).toHaveLength(1)
})
})
+
+/**
+ * What a journey with no book opens on (#1973).
+ *
+ * The auto layout used to run on first open, which decided what the book was
+ * before its author had said anything. It is now on the Auto layout menu and
+ * this is what takes its place: a book with nothing on its pages, and both
+ * covers present because the pages rail can only ever insert between them.
+ */
+describe('an empty book', () => {
+ const input: AutoInput = {
+ locale: 'en',
+ title: 'Iceland',
+ subtitle: null,
+ coverPhotoId: null,
+ stationsLabel: 'Stations',
+ dayLabel: 'DAY',
+ summaryLabel: 'Trip summary',
+ countriesLabel: 'Countries',
+ entries: [entry()],
+ page,
+ stats: stats(),
+ }
+
+ it('has a cover, a page and a back cover, and nothing on any of them', () => {
+ const doc = emptyBook(input)
+ expect(doc.spreads.map(s => s.role)).toEqual(['cover', 'inner', 'back'])
+ expect(doc.spreads.every(s => s.elements.length === 0)).toBe(true)
+ expect(doc.spreads.every(s => s.background === null)).toBe(true)
+ })
+
+ it('carries the journey title and the page setup it was given', () => {
+ const doc = emptyBook(input)
+ expect(doc.title).toBe('Iceland')
+ expect(doc.page.pageWidth).toBe(page.pageWidth)
+ })
+
+ it('gives every spread its own id', () => {
+ const ids = emptyBook(input).spreads.map(s => s.id)
+ expect(new Set(ids).size).toBe(ids.length)
+ })
+
+ /*
+ * The layout is still there, on the menu. This is the difference between
+ * offering it and having already applied it.
+ */
+ it('is what the auto layout replaces when somebody asks for it', () => {
+ expect(buildBook(input).spreads.length).toBeGreaterThan(emptyBook(input).spreads.length)
+ })
+})
diff --git a/client/tests/unit/studio/badgeIcon.test.tsx b/client/tests/unit/studio/badgeIcon.test.tsx
new file mode 100644
index 000000000..2122c20ad
--- /dev/null
+++ b/client/tests/unit/studio/badgeIcon.test.tsx
@@ -0,0 +1,210 @@
+import { describe, it, expect } from 'vitest'
+import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared'
+import { bookBadgeElementSchema, bookPageSetupSchema, normalizeBookDocument } from '@trek/shared'
+import { render } from '../../helpers/render'
+import { SpreadView } from '../../../src/components/Studio/SpreadView'
+
+/**
+ * The picture on a mood or weather mark (#1973).
+ *
+ * These are the only marks whose drawing comes from the journal rather than from
+ * the book, and the field that says which mood it is used to be capped at two
+ * characters, from back when nothing but a country code went in it. A mark
+ * reading "amazing" therefore failed the contract, which failed its spread,
+ * which failed the document, and what the editor handed back was an empty book.
+ * The first thing kept here is that a mood key survives a round trip with the
+ * book still around it.
+ *
+ * The rest is the controls built on the widened field: whether the mark draws
+ * its picture, whether it draws its words, and what colour the picture is. The
+ * default reading is the one that matters most. Every book written before these
+ * fields existed carries none of them, and every element the panels build is
+ * cast rather than parsed, so the renderer has to read an absent flag as what it
+ * always meant: draw it.
+ */
+
+const page: BookPageSetup = bookPageSetupSchema.parse({
+ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5,
+})
+
+const typeset = {
+ font: 'sans' as const, color: '#1a1a1a', accent: '#c2410c', textScale: 1, weight: 700, stale: false,
+}
+const common = { rotation: 0, opacity: 1, locked: false, frame: { x: 10, y: 20, w: 90, h: 40 } }
+
+/**
+ * A badge built the way the panels build one: cast, not parsed, so nothing the
+ * contract would default is filled in unless a test fills it. That is also what
+ * a mark out of a book written before the icon fields existed looks like, which
+ * is why the absent case below is worth a test at all.
+ */
+const badge = (over: Record): BookElement => ({
+ ...common, ...typeset, id: 'b1', kind: 'badge',
+ variant: 'mood', text: '', sub: '', code: null, style: 'plain', ...over,
+} as unknown as BookElement)
+
+function draw(elements: BookElement[]) {
+ const spread: BookSpread = {
+ id: 's1', role: 'inner', background: null, elements, parked: [], entryId: null,
+ }
+ return render( )
+}
+
+/** A document around a single mark, as it arrives from storage. */
+function documentAround(el: Record, title = 'Iceland') {
+ return {
+ version: 1,
+ title,
+ page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
+ spreads: [{
+ id: 's1', role: 'inner', entryId: null, background: null, parked: [],
+ elements: [
+ { id: 'p1', kind: 'photo', frame: { x: 0, y: 0, w: 50, h: 50 }, photoId: 3 },
+ el,
+ ],
+ }],
+ }
+}
+
+describe('a mark made before the icon controls existed', () => {
+ it('still draws its icon in the mood palette the journal uses', () => {
+ const { container } = draw([badge({ variant: 'mood', code: 'amazing', text: 'Amazing' })])
+ const icon = container.querySelector('svg')
+ expect(icon).not.toBeNull()
+ // The pink of "amazing" on the journey page, not the element's accent.
+ expect(icon!.getAttribute('stroke')).toBe('#BE185D')
+ })
+
+ it('still draws its words and the line under them', () => {
+ const { container } = draw([badge({
+ variant: 'mood', code: 'amazing', text: 'Amazing', sub: 'Reykjavik',
+ })])
+ expect(container.textContent).toContain('Amazing')
+ expect(container.textContent).toContain('Reykjavik')
+ })
+
+ /*
+ * A parsed badge carries the default of `iconColor` whether or not anybody
+ * chose it, so reading the colour on its own rather than the flag beside it
+ * would repaint every mood mark in the field to a flat near-black.
+ */
+ it('ignores the icon colour while Automatic is still on', () => {
+ const { container } = draw([badge({
+ variant: 'mood', code: 'amazing', text: 'Amazing',
+ autoIconColor: true, iconColor: '#111111',
+ })])
+ expect(container.querySelector('svg')!.getAttribute('stroke')).toBe('#BE185D')
+ })
+})
+
+describe('choosing the icon colour', () => {
+ /*
+ * Asserted on the stroke because that is where it has to land: lucide takes
+ * its colour as a prop and writes it onto the SVG, so `currentColor` from the
+ * wrapper never reaches it, which is why the swatch used to look dead.
+ */
+ it('paints a mood icon that colour instead of the journal palette', () => {
+ const { container } = draw([badge({
+ variant: 'mood', code: 'amazing', text: 'Amazing',
+ autoIconColor: false, iconColor: '#0EA5E9',
+ })])
+ expect(container.querySelector('svg')!.getAttribute('stroke')).toBe('#0EA5E9')
+ })
+
+ it('paints a weather icon that colour instead of the element accent', () => {
+ const { container } = draw([badge({
+ variant: 'weather', code: 'partly', text: 'Partly cloudy',
+ autoIconColor: false, iconColor: '#0EA5E9',
+ })])
+ expect(container.querySelector('svg')!.getAttribute('stroke')).toBe('#0EA5E9')
+ })
+
+ it('leaves a weather icon on the accent when Automatic is left alone', () => {
+ const { container } = draw([badge({ variant: 'weather', code: 'partly', text: 'Partly cloudy' })])
+ expect(container.querySelector('svg')!.getAttribute('stroke')).toBe('#c2410c')
+ })
+})
+
+describe('showing the picture and the words', () => {
+ it('drops the icon and keeps the words when the icon is switched off', () => {
+ const { container } = draw([badge({
+ variant: 'mood', code: 'amazing', text: 'Amazing', sub: 'Reykjavik', showIcon: false,
+ })])
+ expect(container.querySelector('svg')).toBeNull()
+ expect(container.textContent).toContain('Amazing')
+ expect(container.textContent).toContain('Reykjavik')
+ })
+
+ it('drops the words and the line under them when the label is switched off', () => {
+ const { container } = draw([badge({
+ variant: 'mood', code: 'amazing', text: 'Amazing', sub: 'Reykjavik', showLabel: false,
+ })])
+ expect(container.querySelector('svg')).not.toBeNull()
+ expect(container.textContent).not.toContain('Amazing')
+ expect(container.textContent).not.toContain('Reykjavik')
+ })
+
+ /*
+ * A mark with no words is a pictogram, and a pictogram should fill the box it
+ * was given. The frame here is 90 by 40, so beside a label the icon is 0.46 of
+ * the height and on its own it is 0.8 of the shorter side: 18.4mm against
+ * 32mm. Left at the label size it would float in the middle of a frame that
+ * was measured for words no longer on the page.
+ */
+ it('draws the icon larger once there are no words beside it', () => {
+ const withLabel = draw([badge({ variant: 'mood', code: 'amazing', text: 'Amazing' })])
+ expect(withLabel.container.querySelector('svg')!.style.width).toBe('18.4mm')
+
+ const alone = draw([badge({ variant: 'mood', code: 'amazing', text: 'Amazing', showLabel: false })])
+ const icon = alone.container.querySelector('svg')!
+ expect(icon.style.width).toBe('32mm')
+ expect(icon.style.height).toBe('32mm')
+ })
+})
+
+describe('the badge contract', () => {
+ it('defaults a mark that predates the icon fields to drawing both parts', () => {
+ const parsed = bookBadgeElementSchema.parse({
+ id: 'b1', kind: 'badge', frame: { x: 0, y: 0, w: 40, h: 20 },
+ variant: 'mood', code: 'amazing',
+ })
+ expect(parsed.showIcon).toBe(true)
+ expect(parsed.showLabel).toBe(true)
+ expect(parsed.autoIconColor).toBe(true)
+ })
+
+ /*
+ * The bug this all started from. A mood key is longer than a country code, the
+ * field was capped at two characters, and the element failing the union took
+ * its spread, its document and the whole book down with it.
+ */
+ it('keeps the book when a mark carries a mood key rather than a country code', () => {
+ const doc = normalizeBookDocument(documentAround({
+ id: 'b1', kind: 'badge', frame: { x: 10, y: 20, w: 90, h: 40 },
+ variant: 'mood', code: 'amazing', text: 'Amazing',
+ }))
+
+ expect(doc.title).toBe('Iceland')
+ expect(doc.spreads).toHaveLength(1)
+ // Both elements, in order: the mark was not quietly salvaged away either.
+ expect(doc.spreads[0].elements.map(e => e.kind)).toEqual(['photo', 'badge'])
+ const mark = doc.spreads[0].elements[1]
+ expect(mark.kind === 'badge' && mark.code).toBe('amazing')
+ })
+
+ it('carries all four icon fields through a round trip with the spread intact', () => {
+ const doc = normalizeBookDocument(documentAround({
+ id: 'b1', kind: 'badge', frame: { x: 10, y: 20, w: 90, h: 40 },
+ variant: 'weather', code: 'partly', text: 'Partly cloudy',
+ showIcon: true, showLabel: false, autoIconColor: false, iconColor: '#0EA5E9',
+ }))
+
+ expect(doc.spreads[0].elements).toHaveLength(2)
+ const mark = doc.spreads[0].elements[1]
+ expect(mark.kind).toBe('badge')
+ expect(mark.kind === 'badge' && mark.showIcon).toBe(true)
+ expect(mark.kind === 'badge' && mark.showLabel).toBe(false)
+ expect(mark.kind === 'badge' && mark.autoIconColor).toBe(false)
+ expect(mark.kind === 'badge' && mark.iconColor).toBe('#0EA5E9')
+ })
+})
diff --git a/client/tests/unit/studio/entryChips.test.tsx b/client/tests/unit/studio/entryChips.test.tsx
new file mode 100644
index 000000000..6c4afec59
--- /dev/null
+++ b/client/tests/unit/studio/entryChips.test.tsx
@@ -0,0 +1,212 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import type { BookDocument, BookPageSetup, BookTextElement } from '@trek/shared'
+import { bookPageSetupSchema, normalizeBookDocument } from '@trek/shared'
+import { fireEvent, render, screen } from '../../helpers/render'
+import { StudioSidebar, type JourneySource } from '../../../src/components/Studio/StudioSidebar'
+import { useStudioStore } from '../../../src/store/studioStore'
+
+/**
+ * The date and coordinate chips in Content > Entries (#1973).
+ *
+ * An entry knows two things nobody wants to re-type onto a page: which day it
+ * was and where it happened. What these check is that both arrive still
+ * attached to the entry, carrying the raw fact alongside the printed words,
+ * because that pair is what lets a book re-read its own text when it opens
+ * without rewriting a line that was only ever set in another language.
+ *
+ * The last test is the one that earns its place. Placing an element is half of
+ * it; the document has to survive the contract afterwards. A mood mark once
+ * failed the union over a two-character cap and took the whole book with it,
+ * normalising to an empty document that the autosave then wrote down, so a new
+ * binding field that quietly fails to parse is not a cosmetic problem.
+ */
+
+const page: BookPageSetup = bookPageSetupSchema.parse({
+ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5,
+})
+
+type Entry = JourneySource['entries'][number]
+
+const entry = (over: Partial = {}): Entry => ({
+ id: 7,
+ title: 'Kirkjufell',
+ story: null,
+ location: 'Grundarfjordur',
+ date: '2026-06-04',
+ lat: 64.9275,
+ lng: -23.3072,
+ mood: null,
+ weather: null,
+ pros: [],
+ cons: [],
+ ...over,
+})
+
+const sourceOf = (...entries: Entry[]): JourneySource => ({ entries, photos: [], photoEntries: {} })
+
+/** One empty inner spread, parsed rather than cast, so the store holds a real document. */
+const emptyBook = (): BookDocument => normalizeBookDocument({
+ version: 1,
+ title: 'Iceland',
+ page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
+ spreads: [{ id: 'sp1', role: 'inner', entryId: null, background: null, elements: [], parked: [] }],
+})
+
+beforeEach(() => {
+ useStudioStore.getState().load(emptyBook())
+})
+
+/**
+ * Render the sidebar and walk to the panel the chips live in.
+ *
+ * ContentPanel is private to the sidebar, so the way in is the way a person
+ * takes: the rail, then the Entries tab. `t` is the identity function, which is
+ * why every label below is a raw key.
+ */
+function openEntries(source: JourneySource) {
+ const view = render(
+ k}
+ locale="en-US"
+ />,
+ )
+ fireEvent.click(screen.getByLabelText('journey.studio.content'))
+ fireEvent.click(screen.getByRole('button', { name: /journey\.studio\.entries/ }))
+ return view
+}
+
+/** The element the chip just dropped, narrowed so its binding can be read. */
+function lastText(): BookTextElement {
+ const els = useStudioStore.getState().doc!.spreads[0].elements
+ const el = els[els.length - 1]
+ if (!el || el.kind !== 'text') throw new Error(`expected a text element, found ${el?.kind ?? 'nothing'}`)
+ return el
+}
+
+describe('which chips an entry offers', () => {
+ it('offers no coordinates for an entry that never recorded a point', () => {
+ openEntries(sourceOf(entry({ lat: null, lng: null })))
+
+ expect(screen.queryByText('journey.studio.coordsMark')).toBeNull()
+ // The place name is still on offer: a written location is not a point.
+ expect(screen.getByText('journey.studio.addPlace')).toBeInTheDocument()
+ })
+
+ it('offers no coordinates when only one half of the point survived', () => {
+ openEntries(sourceOf(entry({ lng: null })))
+
+ expect(screen.queryByText('journey.studio.coordsMark')).toBeNull()
+ })
+
+ it('offers coordinates once the entry has both halves', () => {
+ openEntries(sourceOf(entry()))
+
+ expect(screen.getByText('journey.studio.coordsMark')).toBeInTheDocument()
+ })
+
+ it('offers no date for an undated entry', () => {
+ openEntries(sourceOf(entry({ date: null })))
+
+ expect(screen.queryByText('journey.studio.dateMark')).toBeNull()
+ })
+})
+
+describe('the date chip', () => {
+ it('prints the day spelled out, with its year', () => {
+ openEntries(sourceOf(entry()))
+ fireEvent.click(screen.getByText('journey.studio.dateMark'))
+
+ // Not the sidebar's short weekday form: a book outlives the year it was
+ // made in, so the year belongs on the page.
+ expect(lastText().text).toBe('June 4, 2026')
+ })
+
+ it('stays attached to the entry it came from, and unedited', () => {
+ openEntries(sourceOf(entry()))
+ fireEvent.click(screen.getByText('journey.studio.dateMark'))
+ const el = lastText()
+
+ expect(el.binding).toMatchObject({ source: 'entry.date', entryId: 7 })
+ expect(el.overridden).toBe(false)
+ })
+
+ /*
+ * The words are a rendering of the date, so they cannot answer "has the
+ * journal changed?" on their own. The ISO date rides along to answer it.
+ */
+ it('carries the entry ISO date as the fact behind the words', () => {
+ openEntries(sourceOf(entry()))
+ fireEvent.click(screen.getByText('journey.studio.dateMark'))
+
+ expect(lastText().binding!.value).toBe('2026-06-04')
+ // A date has one printed form, so it claims no coordinate format.
+ expect(lastText().binding!.format).toBeUndefined()
+ })
+})
+
+describe('the coordinates chip', () => {
+ it('prints the point in degrees, minutes and seconds', () => {
+ openEntries(sourceOf(entry()))
+ fireEvent.click(screen.getByText('journey.studio.coordsMark'))
+
+ expect(lastText().text).toBe(`64° 55' 39" N 23° 18' 26" W`)
+ // A printed page says W, never a minus sign.
+ expect(lastText().text).not.toContain('-')
+ })
+
+ it('binds to the entry location, marked as the coordinate reading of it', () => {
+ openEntries(sourceOf(entry()))
+ fireEvent.click(screen.getByText('journey.studio.coordsMark'))
+
+ expect(lastText().binding).toMatchObject({
+ source: 'entry.location',
+ entryId: 7,
+ format: 'dms',
+ value: '64.9275,-23.3072',
+ })
+ })
+
+ it('lands on a short line rather than across the whole column', () => {
+ openEntries(sourceOf(entry()))
+ fireEvent.click(screen.getByText('journey.studio.addPlace'))
+ const placeWidth = lastText().frame.w
+
+ fireEvent.click(screen.getByText('journey.studio.coordsMark'))
+
+ expect(lastText().frame.w).toBeLessThan(placeWidth)
+ })
+})
+
+describe('a spread holding both marks', () => {
+ /*
+ * Normalising is not a formality here: the client's load seam and both of the
+ * server's paths run through it, and it used to answer a single unreadable
+ * element with an empty book. Whatever the chips write has to come back out.
+ */
+ it('survives normalizeBookDocument with the spread and both bindings intact', () => {
+ openEntries(sourceOf(entry()))
+ fireEvent.click(screen.getByText('journey.studio.dateMark'))
+ fireEvent.click(screen.getByText('journey.studio.coordsMark'))
+
+ const doc = normalizeBookDocument(useStudioStore.getState().doc)
+
+ expect(doc.title).toBe('Iceland')
+ expect(doc.spreads).toHaveLength(1)
+ expect(doc.spreads[0].elements).toHaveLength(2)
+
+ const [date, coords] = doc.spreads[0].elements
+ expect(date.kind === 'text' && date.text).toBe('June 4, 2026')
+ expect(date.kind === 'text' && date.binding).toMatchObject({
+ source: 'entry.date', entryId: 7, value: '2026-06-04',
+ })
+ expect(coords.kind === 'text' && coords.binding).toMatchObject({
+ source: 'entry.location', entryId: 7, format: 'dms', value: '64.9275,-23.3072',
+ })
+ })
+})
diff --git a/client/tests/unit/studio/entryText.test.ts b/client/tests/unit/studio/entryText.test.ts
new file mode 100644
index 000000000..1ef1d903c
--- /dev/null
+++ b/client/tests/unit/studio/entryText.test.ts
@@ -0,0 +1,65 @@
+import { describe, it, expect } from 'vitest'
+import { formatBookCoords, formatBookDate } from '../../../src/components/Studio/entryText'
+
+/**
+ * The strings a journal entry lends to a page (#1973).
+ *
+ * These are pinned rather than sampled, because three call sites have to agree
+ * on them character for character: the chip that places a mark, the template
+ * filler that fills one that was drawn, and the resolver that re-reads it when
+ * the book is opened. If the chip and the resolver ever disagree by a space,
+ * opening a book rewrites the element and the autosave writes it back, which
+ * looks like a book that edits itself.
+ *
+ * The degrees form is also the one already printed by the auto layout and by
+ * the Travel panel, so changing it here changes what is on pages people have
+ * already made.
+ */
+
+describe('coordinates', () => {
+ it('sets degrees, minutes and seconds with a hemisphere letter', () => {
+ expect(formatBookCoords(52.5163, 13.3777)).toBe(`52° 30' 59" N 13° 22' 40" E`)
+ })
+
+ it('says south and west rather than using a minus sign', () => {
+ // A printed page has no room for a sign somebody has to decode, and the
+ // letter is unambiguous in a way -33.8688 is not.
+ expect(formatBookCoords(-33.8688, -70.6693)).toBe(`33° 52' 8" S 70° 40' 9" W`)
+ })
+
+ it('sets the decimal form to four places, which is about eleven metres', () => {
+ expect(formatBookCoords(52.5163, 13.3777, 'decimal')).toBe('52.5163° N 13.3777° E')
+ })
+
+ it('keeps the hemisphere letter in the decimal form too', () => {
+ expect(formatBookCoords(-33.8688, -70.6693, 'decimal')).toBe('33.8688° S 70.6693° W')
+ })
+
+ it('defaults to degrees, which is what the rest of Studio prints', () => {
+ expect(formatBookCoords(10, 20)).toBe(formatBookCoords(10, 20, 'dms'))
+ })
+})
+
+describe('the entry date', () => {
+ it('spells the month out, in the app language', () => {
+ expect(formatBookDate('2026-06-12', 'de-DE')).toBe('12. Juni 2026')
+ expect(formatBookDate('2026-06-12', 'en-GB')).toBe('12 June 2026')
+ })
+
+ /*
+ * Local midnight, not UTC. The date on an entry is the day the writer had,
+ * and reading it as an instant moves it backwards for everyone west of
+ * Greenwich, which is a photo book dated the day before the photographs.
+ */
+ it('reads the day as a day rather than as a moment in time', () => {
+ expect(formatBookDate('2026-01-01', 'en-GB')).toBe('1 January 2026')
+ })
+
+ it('gives nothing back for an entry with no date', () => {
+ expect(formatBookDate(null, 'en-GB')).toBe('')
+ })
+
+ it('gives nothing back rather than printing Invalid Date', () => {
+ expect(formatBookDate('not-a-date', 'en-GB')).toBe('')
+ })
+})
diff --git a/client/tests/unit/studio/iconElement.test.tsx b/client/tests/unit/studio/iconElement.test.tsx
new file mode 100644
index 000000000..9d401a5c8
--- /dev/null
+++ b/client/tests/unit/studio/iconElement.test.tsx
@@ -0,0 +1,208 @@
+import { describe, it, expect } from 'vitest'
+import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared'
+import { bookIconElementSchema, bookPageSetupSchema } from '@trek/shared'
+import { render } from '../../helpers/render'
+import { SpreadView } from '../../../src/components/Studio/SpreadView'
+import {
+ FEATURED_ICONS, ICON_NAMES, iconLabel, searchIcons,
+} from '../../../src/components/Studio/iconLibrary'
+
+/**
+ * The icon element (#1973).
+ *
+ * An icon is the one thing on a Studio page that is not drawn in this repo. The
+ * document stores a name and trusts lucide to still know it, which is what these
+ * protect at both ends: that the mark reaches the page with the weight and the
+ * colour somebody chose, and that a name the library has since renamed costs a
+ * drawing rather than the page it stood on.
+ *
+ * The list the picker is built from matters for the same reason. lucide exports
+ * every drawing three times, so a list taken straight from the namespace shows
+ * every glyph three times and makes the search box look broken.
+ */
+
+const page: BookPageSetup = bookPageSetupSchema.parse({
+ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5,
+})
+
+/* Deliberately not square: an icon has to keep its shape in a frame that has not. */
+const icon = (over: Record = {}): BookElement => bookIconElementSchema.parse({
+ id: 'i1', kind: 'icon', frame: { x: 10, y: 20, w: 80, h: 60 }, name: 'Compass', ...over,
+})
+
+function draw(elements: BookElement[]) {
+ const spread: BookSpread = {
+ id: 's1', role: 'inner', background: null, elements, parked: [], entryId: null,
+ }
+ return render( )
+}
+
+/** The sheet is a div inside Testing Library's container; the elements sit inside it. */
+function firstElement(container: HTMLElement): HTMLElement {
+ return container.firstElementChild!.firstElementChild as HTMLElement
+}
+
+describe('an icon on the page', () => {
+ it('draws the named lucide glyph as an SVG', () => {
+ const { container } = draw([icon({ name: 'Compass' })])
+ const svg = container.querySelector('svg')!
+ expect(svg).not.toBeNull()
+ // lucide names the drawing it rendered, which is how we know the stored
+ // name was resolved rather than some default standing in for it.
+ expect(svg.getAttribute('class')).toContain('lucide-compass')
+ expect(svg.children.length).toBeGreaterThan(0)
+ })
+
+ it('strokes the drawing in the colour the element carries', () => {
+ const { container } = draw([icon({ color: '#c81e4a' })])
+ expect(container.querySelector('svg')!.getAttribute('stroke')).toBe('#c81e4a')
+ })
+
+ /*
+ * The weight is against lucide's own 24-unit grid, not millimetres, so it has
+ * to arrive unscaled: an icon set 60mm across with a hairline is a diagram
+ * rather than a mark, and a stroke silently converted to a length would be
+ * exactly that.
+ */
+ it('sets the stroke to the stored line width, against lucide\'s own grid', () => {
+ const { container } = draw([icon({ lineWidth: 3.5 })])
+ const svg = container.querySelector('svg')!
+ expect(svg.getAttribute('stroke-width')).toBe('3.5')
+ expect(svg.getAttribute('viewBox')).toBe('0 0 24 24')
+ })
+
+ it('carries a hairline through just as faithfully as a heavy stroke', () => {
+ const { container } = draw([icon({ lineWidth: 0.25 })])
+ expect(container.querySelector('svg')!.getAttribute('stroke-width')).toBe('0.25')
+ })
+
+ /*
+ * A shape is scaled by moving its points and looks deliberate at any ratio.
+ * A stroked drawing does not: a compass pulled to twice its width reads as a
+ * mistake. So the drawing fills the frame but is never told to ignore its
+ * aspect ratio, and the box centres what is left over.
+ */
+ it('fills the frame without being stretched out of shape', () => {
+ const { container } = draw([icon()])
+ const svg = container.querySelector('svg')!
+ expect(svg.style.width).toBe('100%')
+ expect(svg.style.height).toBe('100%')
+ expect(svg.getAttribute('preserveAspectRatio')).toBeNull()
+
+ const box = firstElement(container)
+ expect(box.style.alignItems).toBe('center')
+ expect(box.style.justifyContent).toBe('center')
+ })
+
+ it('sits where the frame puts it, in millimetres like everything else', () => {
+ const box = firstElement(draw([icon()]).container)
+ expect(box.style.left).toBe('10mm')
+ expect(box.style.top).toBe('20mm')
+ expect(box.style.width).toBe('80mm')
+ expect(box.style.height).toBe('60mm')
+ })
+
+ /*
+ * A book outlives the library it was made with. Whatever happens when a name
+ * goes, the page has to keep printing, so the missing drawing becomes the same
+ * fallback the plugin list uses rather than an empty rectangle.
+ */
+ it('draws the fallback, not a hole, when lucide no longer knows the name', () => {
+ expect(ICON_NAMES).not.toContain('CarrierPigeon')
+
+ const { container } = draw([icon({ name: 'CarrierPigeon', color: '#0f766e', lineWidth: 1.5 })])
+ const svg = container.querySelector('svg')
+ expect(svg).not.toBeNull()
+ expect(svg!.children.length).toBeGreaterThan(0)
+ expect(svg!.getAttribute('class')).toContain('lucide-blocks')
+ // Still the element's own mark: the fallback inherits the choices made for
+ // the drawing it stands in for.
+ expect(svg!.getAttribute('stroke')).toBe('#0f766e')
+ expect(svg!.getAttribute('stroke-width')).toBe('1.5')
+ })
+})
+
+describe('the icon list the picker is built from', () => {
+ it('holds the whole of lucide, over a thousand drawings', () => {
+ expect(ICON_NAMES.length).toBeGreaterThan(1000)
+ expect(ICON_NAMES).toContain('Plane')
+ expect(ICON_NAMES).toContain('MountainSnow')
+ })
+
+ it('leaves out the Lucide-prefixed alias of a name it already has', () => {
+ expect(ICON_NAMES).not.toContain('LucidePlane')
+
+ const present = new Set(ICON_NAMES)
+ const aliases = ICON_NAMES.filter(n => n.startsWith('Lucide') && present.has(n.slice(6)))
+ expect(aliases).toEqual([])
+ })
+
+ it('leaves out the Icon-suffixed alias of a name it already has', () => {
+ expect(ICON_NAMES).not.toContain('PlaneIcon')
+
+ const present = new Set(ICON_NAMES)
+ const aliases = ICON_NAMES.filter(n => n.endsWith('Icon') && present.has(n.slice(0, -4)))
+ expect(aliases).toEqual([])
+ })
+
+ it('comes out sorted, so the grid does not have to sort it again', () => {
+ const outOfOrder = ICON_NAMES.filter((name, i) => i > 0 && ICON_NAMES[i - 1].localeCompare(name) > 0)
+ expect(outOfOrder).toEqual([])
+ })
+})
+
+describe('searching the icon list', () => {
+ it('finds a compound name by its first word', () => {
+ expect(searchIcons('mountain')).toContain('MountainSnow')
+ })
+
+ /* The second half is the part somebody types when they want the snowy one. */
+ it('finds a compound name by its second word', () => {
+ expect(searchIcons('snow')).toContain('MountainSnow')
+ })
+
+ it('finds it from both words, in either order', () => {
+ expect(searchIcons('snow mountain')).toContain('MountainSnow')
+ expect(searchIcons('mountain snow')).toContain('MountainSnow')
+ })
+
+ it('ignores case and stray spacing', () => {
+ expect(searchIcons('MOUNTAIN')).toContain('MountainSnow')
+ expect(searchIcons(' Snow ')).toContain('MountainSnow')
+ })
+
+ it('narrows as words are added rather than widening', () => {
+ const snow = searchIcons('snow')
+ const both = searchIcons('snow mountain')
+ expect(snow.length).toBeGreaterThan(both.length)
+ expect(both.every(name => snow.includes(name))).toBe(true)
+ })
+
+ it('returns the whole library when nothing has been typed', () => {
+ expect(searchIcons('')).toEqual(ICON_NAMES)
+ expect(searchIcons(' ')).toEqual(ICON_NAMES)
+ })
+
+ it('returns nothing for a word no drawing has', () => {
+ expect(searchIcons('zzzznotathing')).toEqual([])
+ })
+})
+
+describe('names and the starter shelf', () => {
+ it('spaces a PascalCase name out for the tooltip', () => {
+ expect(iconLabel('MountainSnow')).toBe('Mountain Snow')
+ expect(iconLabel('CloudRain')).toBe('Cloud Rain')
+ expect(iconLabel('Plane')).toBe('Plane')
+ })
+
+ /*
+ * The shelf is a hand-written list, so it is the one place a lucide rename
+ * would show up as a row of fallback glyphs. It is filtered against the real
+ * exports, and this is what keeps that filter honest.
+ */
+ it('opens on drawings this version of lucide really has', () => {
+ expect(FEATURED_ICONS.length).toBeGreaterThan(0)
+ const missing = FEATURED_ICONS.filter(name => !ICON_NAMES.includes(name))
+ expect(missing).toEqual([])
+ })
+})
diff --git a/client/tests/unit/studio/inspector.test.tsx b/client/tests/unit/studio/inspector.test.tsx
new file mode 100644
index 000000000..1a8cee512
--- /dev/null
+++ b/client/tests/unit/studio/inspector.test.tsx
@@ -0,0 +1,552 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import type { BookDocument, BookElement, BookPageNumbers, BookPageSetup } from '@trek/shared'
+import {
+ bookIconElementSchema, bookPageSetupSchema, bookPhotoElementSchema,
+ bookShapeElementSchema, bookTextElementSchema,
+} from '@trek/shared'
+import { fireEvent, render } from '../../helpers/render'
+import { StudioInspector } from '../../../src/components/Studio/StudioInspector'
+import { useStudioStore } from '../../../src/store/studioStore'
+import type { JourneySource } from '../../../src/components/Studio/StudioSidebar'
+
+/**
+ * The inspector, control by control (#1973).
+ *
+ * The panel was rebuilt: same job, new shape. Duplicate, lock and delete moved
+ * out of a section and into the head, half the groups now start folded, every
+ * boolean became the app's switch, and the number fields stopped writing on
+ * every keystroke. Nothing in the suite rendered this panel, so any one of
+ * those controls could have come out of the rebuild writing nothing at all and
+ * the only thing that would have noticed was somebody laying out a book.
+ *
+ * So the assertions here are about the document rather than about the markup:
+ * what is protected is that the control still writes the field it used to
+ * write. Where a control has a rule of its own, that rule is pinned too: a
+ * shape cannot be made invisible, choosing a coordinate notation is not an
+ * override, an emptied number field leaves the value alone.
+ */
+
+const FRAME = { x: 20, y: 30, w: 60, h: 40 }
+
+const text = (over: Record = {}): BookElement =>
+ bookTextElementSchema.parse({ id: 't1', kind: 'text', frame: FRAME, text: 'Harbour', ...over }) as BookElement
+
+const photo = (over: Record = {}): BookElement =>
+ bookPhotoElementSchema.parse({ id: 'p1', kind: 'photo', frame: FRAME, ...over }) as BookElement
+
+const shape = (over: Record = {}): BookElement =>
+ bookShapeElementSchema.parse({ id: 's1', kind: 'shape', frame: FRAME, ...over }) as BookElement
+
+const icon = (over: Record = {}): BookElement =>
+ bookIconElementSchema.parse({ id: 'i1', kind: 'icon', frame: FRAME, name: 'Plane', ...over }) as BookElement
+
+const setup = (folios: Partial = {}): BookPageSetup => bookPageSetupSchema.parse({
+ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5, pageNumbers: folios,
+})
+
+/** One entry with a point on it, so a coordinate mark has something to re-read. */
+const source: JourneySource = {
+ entries: [{
+ id: 7, title: 'Bergen', story: null, location: 'Bergen', date: '2026-05-01',
+ lat: 60.3913, lng: 5.3221, mood: null, weather: null, pros: [], cons: [],
+ }],
+ photos: [],
+ photoEntries: {},
+}
+
+function load(elements: BookElement[], selection = elements.slice(0, 1).map(e => e.id)) {
+ useStudioStore.getState().load({
+ version: 1, title: 'T', page: setup(), spreads: [
+ { id: 'sp1', role: 'inner', background: null, elements, parked: [], entryId: null },
+ ],
+ } as BookDocument)
+ useStudioStore.getState().select(selection)
+}
+
+function draw(over: { page?: BookPageSetup; setPageNumbers?: (p: Partial) => void } = {}) {
+ return render(
+ {})}
+ t={(k: string) => k}
+ locale="en"
+ />,
+ )
+}
+
+/** What the store holds now, which is the thing every assertion here is about. */
+const el = (i = 0) => useStudioStore.getState().doc!.spreads[0].elements[i]
+const elements = () => useStudioStore.getState().doc!.spreads[0].elements
+
+// ── Reaching the controls ──────────────────────────────────────────────────
+
+function head(c: HTMLElement, label: string): HTMLElement {
+ const found = [...c.querySelectorAll('.st-section-head')]
+ .find(b => b.querySelector('.st-section-label')?.textContent === label)
+ return found as HTMLElement
+}
+
+/** Half the panel starts folded, so a control inside one has to be uncovered. */
+function open(c: HTMLElement, label: string) {
+ const button = head(c, label)
+ if (button.getAttribute('aria-expanded') === 'false') fireEvent.click(button)
+}
+
+function num(c: HTMLElement, label: string): HTMLInputElement {
+ const span = [...c.querySelectorAll('.st-num-label')].find(s => s.textContent === label)
+ return span!.closest('.st-num')!.querySelector('input') as HTMLInputElement
+}
+
+/** Every number field the panel is currently showing, by label. */
+const numLabels = (c: HTMLElement) => [...c.querySelectorAll('.st-num-label')].map(s => s.textContent)
+
+/** Type and leave, which is the only thing that commits a number now. */
+function typeNum(c: HTMLElement, label: string, value: string) {
+ const input = num(c, label)
+ fireEvent.change(input, { target: { value } })
+ fireEvent.blur(input)
+}
+
+function line(c: HTMLElement, label: string): HTMLElement {
+ const found = [...c.querySelectorAll('.st-line')]
+ .find(l => l.querySelector('.st-line-label')?.textContent === label)
+ return found as HTMLElement
+}
+
+/** The swatch row on one named line, so fill and stroke stay told apart. */
+function pickSwatch(c: HTMLElement, lineLabel: string, hex: string) {
+ fireEvent.click(line(c, lineLabel).querySelector(`[aria-label="${hex}"]`)!)
+}
+
+/** One of several: a chip that reads as chosen. */
+function pick(c: HTMLElement, label: string) {
+ const chip = [...c.querySelectorAll('[role="radio"]')].find(b => b.textContent === label)
+ fireEvent.click(chip!)
+}
+
+/** 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')]
+ .find(s => s.querySelector('.st-switch-label')?.textContent === label)
+ fireEvent.click(row!.querySelector('button')!)
+}
+
+beforeEach(() => {
+ useStudioStore.getState().load({
+ version: 1, title: 'T', page: setup(), spreads: [],
+ } as unknown as BookDocument)
+})
+
+// ── The head ───────────────────────────────────────────────────────────────
+
+describe('the panel head', () => {
+ it('says what is selected', () => {
+ load([text()])
+ const { container } = draw()
+ expect(container.querySelector('.st-what')!.textContent).toContain('journey.studio.kind.text')
+ })
+
+ it('says how many, once there is more than one', () => {
+ load([text(), shape()], ['t1', 's1'])
+ const { container } = draw()
+ const what = container.querySelector('.st-what')!.textContent!
+ expect(what).toContain('journey.studio.multiple')
+ expect(what).toContain('2')
+ })
+
+ it('duplicates the selection into the spread', () => {
+ load([text()])
+ const { container } = draw()
+
+ fireEvent.click(container.querySelector('[aria-label="journey.studio.duplicate"]')!)
+
+ expect(elements()).toHaveLength(2)
+ // Offset, so the copy is a second thing rather than an exact overlay.
+ expect(elements()[1].frame).toMatchObject({ x: 24, y: 34 })
+ })
+
+ it('locks and unlocks, and the button says which it is', () => {
+ load([text()])
+ const { container } = draw()
+
+ fireEvent.click(container.querySelector('[aria-label="journey.studio.lock"]')!)
+ expect(el()).toMatchObject({ locked: true })
+
+ const unlock = container.querySelector('[aria-label="journey.studio.unlock"]')!
+ expect(unlock.getAttribute('aria-pressed')).toBe('true')
+
+ fireEvent.click(unlock)
+ expect(el()).toMatchObject({ locked: false })
+ })
+
+ it('deletes the selection and nothing else', () => {
+ load([text(), shape()], ['t1'])
+ const { container } = draw()
+
+ fireEvent.click(container.querySelector('[aria-label="journey.studio.delete"]')!)
+
+ expect(elements().map(e => e.id)).toEqual(['s1'])
+ expect(useStudioStore.getState().selection).toEqual([])
+ })
+})
+
+// ── Text ───────────────────────────────────────────────────────────────────
+
+describe('a text element', () => {
+ it('takes the words typed into it, and stops re-reading its source', () => {
+ load([text()])
+ const { container } = draw()
+
+ fireEvent.change(container.querySelector('.st-textarea')!, { target: { value: 'Bergen harbour' } })
+
+ expect(el()).toMatchObject({ text: 'Bergen harbour', overridden: true })
+ })
+
+ it('sets the weight', () => {
+ load([text()])
+ const { container } = draw()
+ pick(container, '600')
+ expect(el()).toMatchObject({ weight: 600 })
+ })
+
+ it('sets the alignment', () => {
+ load([text()])
+ const { container } = draw()
+ fireEvent.click(container.querySelector('[aria-label="right"]')!)
+ expect(el()).toMatchObject({ align: 'right' })
+ })
+
+ /* Italic was drawn by the renderer and had no control at all before. */
+ it('sets italic, and the button reflects it', () => {
+ load([text()])
+ const { container } = draw()
+
+ fireEvent.click(container.querySelector('[aria-label="journey.studio.italic"]')!)
+
+ expect(el()).toMatchObject({ italic: true })
+ expect(container.querySelector('[aria-label="journey.studio.italic"]')!.getAttribute('aria-pressed'))
+ .toBe('true')
+ })
+
+ it('sets the size, the leading and the tracking', () => {
+ load([text()])
+ const { container } = draw()
+
+ typeNum(container, 'journey.studio.size', '18')
+ typeNum(container, 'journey.studio.leading', '1.8')
+ typeNum(container, 'journey.studio.tracking', '0.12')
+
+ expect(el()).toMatchObject({ size: 18, leading: 1.8, tracking: 0.12 })
+ })
+
+ it('sets the colour', () => {
+ load([text()])
+ const { container } = draw()
+ pickSwatch(container, 'journey.studio.text', '#9f1239')
+ expect(el()).toMatchObject({ color: '#9f1239' })
+ })
+
+ it('asks nothing about notation when the text is not a point', () => {
+ load([text()])
+ const { container } = draw()
+ expect(line(container, 'journey.studio.coordsMark')).toBeUndefined()
+ })
+})
+
+describe('a text element bound to a stop', () => {
+ const bound = () => text({
+ text: '60 deg N, 5 deg E',
+ binding: { source: 'entry.location', entryId: 7, format: 'dms' },
+ })
+
+ it('offers the two notations', () => {
+ load([bound()])
+ const { container } = draw()
+ expect(line(container, 'journey.studio.coordsMark')).toBeDefined()
+ })
+
+ /*
+ * Both halves, in one step. Rewriting only the binding leaves the old words
+ * on the page until the next open, which reads as a control that does
+ * nothing, and choosing a notation is not writing the text: `overridden` has
+ * to stay off or the mark would stop following the journal.
+ */
+ it('rewrites the binding and the words together, without claiming an override', () => {
+ load([bound()])
+ const { container } = draw()
+
+ pick(container, 'journey.studio.coordsDecimal')
+
+ expect(el()).toMatchObject({
+ binding: { source: 'entry.location', entryId: 7, format: 'decimal' },
+ text: expect.stringContaining('60.3913° N'),
+ overridden: false,
+ })
+ })
+})
+
+// ── Photo ──────────────────────────────────────────────────────────────────
+
+describe('a photo frame', () => {
+ it('sets the crop, the look and the frame style', () => {
+ load([photo({ photoId: 12 })])
+ const { container } = draw()
+
+ pick(container, 'journey.studio.fit.contain')
+ pick(container, 'journey.studio.filter.bw')
+ pick(container, 'journey.studio.polaroidFrame')
+
+ expect(el()).toMatchObject({ fit: 'contain', filter: 'bw', frameStyle: 'polaroid' })
+ })
+
+ /*
+ * An empty frame has no picture to aim at, and asking for one by id produced
+ * a request for /api/photos/null/thumbnail under a caption telling you to
+ * drag the point around.
+ */
+ it('offers no focal point while the frame is empty', () => {
+ load([photo({ photoId: null })])
+ const { container } = draw()
+
+ expect(container.querySelector('.st-focal')).toBeNull()
+ expect(container.textContent).toContain('journey.studio.emptyFrame')
+ })
+
+ it('offers one once there is a picture in it', () => {
+ load([photo({ photoId: 12 })])
+ const { container } = draw()
+
+ expect(container.querySelector('.st-focal')).not.toBeNull()
+ expect(container.querySelector('.st-focal img')!.getAttribute('src'))
+ .toBe('/api/photos/12/thumbnail')
+ })
+
+ /* A radius and a mask are the same idea at two resolutions, and a radius the
+ renderer ignores once a shape is cut is a control that lies. */
+ it('drops the corner radius once the picture is cut to a shape', () => {
+ load([photo({ photoId: 12, mask: null })])
+ const plain = draw()
+ expect(numLabels(plain.container)).toContain('journey.studio.radius')
+ plain.unmount()
+
+ load([photo({ photoId: 12, mask: 'heart' })])
+ expect(numLabels(draw().container)).not.toContain('journey.studio.radius')
+ })
+})
+
+// ── Shape ──────────────────────────────────────────────────────────────────
+
+describe('a shape', () => {
+ /* Swapping rather than deleting and re-adding is the whole point of the
+ grid: the thing on the page keeps where it is and what colour it is. */
+ it('keeps its position and its colour when the shape is swapped', () => {
+ load([shape({ shape: 'rect', fill: '#c2410c' })])
+ const { container } = draw()
+
+ fireEvent.click(container.querySelector('.st-mini-shapes [aria-label="ellipse"]')!)
+
+ expect(el()).toMatchObject({ shape: 'ellipse', fill: '#c2410c', frame: FRAME })
+ })
+
+ it('writes the fill and the outline separately', () => {
+ load([shape({ fill: '#111827', stroke: null, strokeWidth: 0 })])
+ const { container } = draw()
+
+ pickSwatch(container, 'journey.studio.fill', '#0f766e')
+ expect(el()).toMatchObject({ fill: '#0f766e', stroke: null })
+
+ pickSwatch(container, 'journey.studio.stroke', '#1e3a8a')
+ // A colour with no width would draw nothing, so the line gets one.
+ expect(el()).toMatchObject({ fill: '#0f766e', stroke: '#1e3a8a', strokeWidth: 0.5 })
+ })
+
+ /*
+ * The rule that matters here: a shape with neither fill nor stroke is a
+ * 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 switching the fill off', () => {
+ load([shape({ fill: '#111827', stroke: null, strokeWidth: 0 })])
+ const { container } = draw()
+
+ toggle(container, 'journey.studio.fillOn')
+
+ expect(el()).toMatchObject({ fill: null, stroke: '#141414', strokeWidth: 0.5 })
+ })
+
+ it('brings the fill back when it is switched on again', () => {
+ load([shape({ fill: null, stroke: '#141414', strokeWidth: 0.5 })])
+ const { container } = draw()
+
+ toggle(container, 'journey.studio.fillOn')
+
+ expect(el()).toMatchObject({ fill: '#111111', stroke: '#141414' })
+ })
+
+ it('fades the fill, and takes a direction once it does', () => {
+ load([shape()])
+ const { container } = draw()
+
+ toggle(container, 'journey.studio.gradient')
+ expect(el()).toMatchObject({ gradient: 'down' })
+
+ pick(container, 'journey.studio.gradientUp')
+ expect(el()).toMatchObject({ gradient: 'up' })
+ })
+})
+
+// ── Icon ───────────────────────────────────────────────────────────────────
+
+describe('an icon', () => {
+ it('narrows the swap grid to what was searched for', () => {
+ load([icon()])
+ const { container } = draw()
+ const all = container.querySelectorAll('.st-mini-shape').length
+
+ fireEvent.change(container.querySelector('[aria-label="journey.studio.searchIcons"]')!, {
+ target: { value: 'compass' },
+ })
+
+ const found = container.querySelectorAll('.st-mini-shape').length
+ expect(found).toBeGreaterThan(0)
+ expect(found).toBeLessThan(all)
+ expect(container.querySelector('[aria-label="Compass"]')).not.toBeNull()
+ })
+
+ it('swaps the drawing, keeping everything else about the element', () => {
+ load([icon({ name: 'Plane', color: '#9f1239' })])
+ const { container } = draw()
+
+ fireEvent.change(container.querySelector('[aria-label="journey.studio.searchIcons"]')!, {
+ target: { value: 'compass' },
+ })
+ fireEvent.click(container.querySelector('[aria-label="Compass"]')!)
+
+ expect(el()).toMatchObject({ name: 'Compass', color: '#9f1239', frame: FRAME })
+ })
+
+ it('sets how heavy the drawing is', () => {
+ load([icon()])
+ const { container } = draw()
+ typeNum(container, 'journey.studio.lineWidth', '3')
+ expect(el()).toMatchObject({ lineWidth: 3 })
+ })
+})
+
+// ── The frame's numbers ────────────────────────────────────────────────────
+
+describe('the position group', () => {
+ it('is folded away until it is asked for', () => {
+ load([shape()])
+ const { container } = draw()
+
+ expect(head(container, 'journey.studio.position').getAttribute('aria-expanded')).toBe('false')
+ expect(numLabels(container)).not.toContain('journey.studio.width')
+
+ open(container, 'journey.studio.position')
+ expect(numLabels(container)).toContain('journey.studio.width')
+ })
+
+ /* One value is one undo step. It used to be one per keystroke, so typing
+ "120" buried the previous state three steps down the stack. */
+ it('writes a width once, when the field is left rather than while it is typed', () => {
+ load([shape()])
+ const { container } = draw()
+ open(container, 'journey.studio.position')
+
+ const input = num(container, 'journey.studio.width')
+ fireEvent.change(input, { target: { value: '120' } })
+ expect(el().frame.w).toBe(60)
+
+ fireEvent.blur(input)
+ expect(el().frame.w).toBe(120)
+ expect(useStudioStore.getState().past).toHaveLength(1)
+ })
+
+ it('clamps a width below the minimum rather than taking it', () => {
+ load([shape()])
+ const { container } = draw()
+ open(container, 'journey.studio.position')
+
+ typeNum(container, 'journey.studio.width', '1')
+
+ expect(el().frame.w).toBe(4)
+ })
+
+ /* An emptied field used to collapse the element to zero on the keystroke
+ that emptied it, which typing the number back does not undo. */
+ it('leaves the width alone when the field is emptied', () => {
+ load([shape()])
+ const { container } = draw()
+ open(container, 'journey.studio.position')
+
+ typeNum(container, 'journey.studio.width', '')
+
+ expect(el().frame.w).toBe(60)
+ expect(useStudioStore.getState().past).toHaveLength(0)
+ })
+
+ it('turns and fades the element from the same group', () => {
+ load([shape()])
+ const { container } = draw()
+ open(container, 'journey.studio.position')
+
+ typeNum(container, 'journey.studio.rotation', '15')
+ typeNum(container, 'journey.studio.opacity', '40')
+
+ expect(el()).toMatchObject({ rotation: 15, opacity: 0.4 })
+ })
+})
+
+// ── Nothing selected ───────────────────────────────────────────────────────
+
+describe('with nothing selected', () => {
+ it('turns page numbers on', () => {
+ load([])
+ const setPageNumbers = vi.fn()
+ const { container } = draw({ setPageNumbers })
+
+ toggle(container, 'journey.studio.pageNumbers')
+
+ expect(setPageNumbers).toHaveBeenCalledWith({ show: true })
+ })
+
+ it('sets where they sit', () => {
+ load([])
+ const setPageNumbers = vi.fn()
+ const { container } = draw({ page: setup({ show: true }), setPageNumbers })
+
+ pick(container, 'journey.studio.folio.inner')
+
+ expect(setPageNumbers).toHaveBeenCalledWith({ position: 'inner' })
+ })
+
+ it('sets the first number, the size and the margin', () => {
+ load([])
+ const setPageNumbers = vi.fn()
+ const { container } = draw({ page: setup({ show: true }), setPageNumbers })
+
+ typeNum(container, 'journey.studio.folioStart', '12')
+ typeNum(container, 'journey.studio.size', '10')
+ typeNum(container, 'journey.studio.folioMargin', '20')
+
+ expect(setPageNumbers).toHaveBeenCalledWith({ startAt: 12 })
+ expect(setPageNumbers).toHaveBeenCalledWith({ size: 10 })
+ expect(setPageNumbers).toHaveBeenCalledWith({ margin: 20 })
+ })
+
+ it('turns the automatic colour off, and picking one turns it off too', () => {
+ load([])
+ const setPageNumbers = vi.fn()
+ const { container } = draw({ page: setup({ show: true }), setPageNumbers })
+
+ toggle(container, 'journey.studio.folioAuto')
+ expect(setPageNumbers).toHaveBeenCalledWith({ autoColor: false })
+
+ pickSwatch(container, 'journey.studio.colour', '#111111')
+ expect(setPageNumbers).toHaveBeenCalledWith({ color: '#111111', autoColor: false })
+ })
+})
diff --git a/client/tests/unit/studio/inspectorTravel.test.tsx b/client/tests/unit/studio/inspectorTravel.test.tsx
new file mode 100644
index 000000000..9fc860863
--- /dev/null
+++ b/client/tests/unit/studio/inspectorTravel.test.tsx
@@ -0,0 +1,558 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import type { BookDocument, BookElement, JourneyStats } from '@trek/shared'
+import {
+ bookBadgeElementSchema, bookCountriesElementSchema, bookListElementSchema,
+ bookMapElementSchema, bookPageSetupSchema, bookStatsElementSchema, journeyStatsSchema,
+} from '@trek/shared'
+import { fireEvent, render, screen, within } from '../../helpers/render'
+import { StudioInspector } from '../../../src/components/Studio/StudioInspector'
+import { useStudioStore } from '../../../src/store/studioStore'
+
+/**
+ * The inspector, on the five travel elements (#1973).
+ *
+ * The panel was rebuilt: same functions, new structure. Sections fold, booleans
+ * are the app's switch instead of a chip, a set of several carries a tick, and
+ * duplicate, lock and delete moved out of "Arrange" and into the panel head.
+ * Nothing in the suite rendered the inspector at all, so a control that came
+ * out of that rebuild wired to nothing would have been found in a printed book
+ * rather than here.
+ *
+ * Two rules for what follows. The whole inspector is mounted rather than the
+ * travel half on its own, because the tail every travel element shares, type,
+ * colour, position, arrange, is drawn by the outer panel. And what is asserted
+ * is the document the store holds, not the markup: the question is whether a
+ * control still writes the field it used to write, whichever section it now
+ * lives in.
+ */
+
+const page = bookPageSetupSchema.parse({
+ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5,
+})
+
+const frame = { x: 10, y: 10, w: 120, h: 90 }
+
+/** A journey with figures no element below starts out agreeing with. */
+const stats: JourneyStats = journeyStatsSchema.parse({
+ journeyId: 1,
+ distance: 1234, days: 5, steps: 7, photos: 12, places: 9, furthest: 400,
+ countries: [{ code: 'IS', name: 'Iceland', places: 9, firstVisit: '2026-01-01' }],
+ points: [
+ { lat: 64.14, lng: -21.94, label: 'Reykjavik', date: '2026-01-01', country: 'IS' },
+ { lat: 65.68, lng: -18.09, label: 'Akureyri', date: '2026-01-04', country: 'IS' },
+ ],
+ start: '2026-01-01', end: '2026-01-05',
+})
+
+const mapEl = (over: Record = {}): BookElement => bookMapElementSchema.parse({
+ id: 'm1', kind: 'map', frame, countries: ['IS'],
+ points: [{ lat: 64.14, lng: -21.94, label: 'Reykjavik' }],
+ ...over,
+}) as BookElement
+
+const statsEl = (over: Record = {}): BookElement => bookStatsElementSchema.parse({
+ id: 's1', kind: 'stats', frame, ...over,
+}) as BookElement
+
+const countriesEl = (over: Record = {}): BookElement => bookCountriesElementSchema.parse({
+ id: 'c1', kind: 'countries', frame, codes: ['IS', 'NO'], names: ['Iceland', 'Norway'], ...over,
+}) as BookElement
+
+const badgeEl = (over: Record = {}): BookElement => bookBadgeElementSchema.parse({
+ id: 'b1', kind: 'badge', frame, variant: 'flag', code: 'IS', text: 'ICELAND', ...over,
+}) as BookElement
+
+const listEl = (over: Record = {}): BookElement => bookListElementSchema.parse({
+ id: 'l1', kind: 'list', frame,
+ items: [{ text: 'Warm all week', tone: 'pro' }, { text: 'Ferry was late', tone: 'con' }],
+ proLabel: 'Pros', conLabel: 'Cons',
+ ...over,
+}) as BookElement
+
+function show(el: BookElement, live: JourneyStats | null = null) {
+ useStudioStore.getState().load({
+ version: 1, title: 'T', page,
+ spreads: [{ id: 'sp1', role: 'inner', background: null, elements: [el], parked: [], entryId: null }],
+ } as BookDocument)
+ useStudioStore.getState().select([el.id])
+ return render(
+ {}}
+ t={k => k}
+ locale="en"
+ />,
+ )
+}
+
+/** The element as the store holds it. Every assertion below reads this. */
+function saved(): Record {
+ return useStudioStore.getState().doc!.spreads[0].elements[0] as unknown as Record
+}
+
+/** One of a few. */
+const pick = (name: string) => fireEvent.click(screen.getByRole('radio', { name }))
+/** One of a set, which carries a tick. */
+const tick = (name: string) => fireEvent.click(screen.getByRole('checkbox', { name }))
+/** A switch, or any plain button: both answer to their accessible name. */
+const press = (name: string) => fireEvent.click(screen.getByRole('button', { name }))
+
+/** A section by its heading, since a label can repeat across sections. */
+function section(container: HTMLElement, label: string): HTMLElement {
+ const found = [...container.querySelectorAll('.st-section')]
+ .find(s => s.querySelector('.st-section-label')?.textContent === label)
+ if (!found) throw new Error(`no section headed ${label}`)
+ return found
+}
+
+/** Some sections start folded, so a control inside one has to be uncovered. */
+function open(container: HTMLElement, label: string) {
+ fireEvent.click(section(container, label).querySelector('.st-section-head')!)
+}
+
+/** The control side of one labelled line inside one section. */
+function lineIn(container: HTMLElement, sectionLabel: string, lineLabel: string): HTMLElement {
+ const row = [...section(container, sectionLabel).querySelectorAll('.st-line')]
+ .find(l => l.querySelector('.st-line-label')?.textContent === lineLabel)
+ if (!row) throw new Error(`no line labelled ${lineLabel} under ${sectionLabel}`)
+ return row.querySelector('.st-line-body')!
+}
+
+beforeEach(() => {
+ useStudioStore.getState().load({ version: 1, title: 'T', page, spreads: [] } as unknown as BookDocument)
+})
+
+describe('the route map', () => {
+ it('takes the tiles and the credit from the source it is switched to', () => {
+ show(mapEl())
+
+ pick('journey.studio.mapSourceSatellite')
+
+ expect(saved().source).toBe('tiles')
+ expect(saved().tileUrl).toContain('tiles.maps.eox.at')
+ // The credit travels with the source, or the page prints the wrong one —
+ // and this one is CC BY, so printing it uncredited is a licence breach
+ // rather than a discourtesy.
+ expect(saved().attribution).toContain('EOX')
+ })
+
+ /*
+ * The planner's own street tiles and Mapbox are no longer offered: one is a
+ * street map that reads as a screenshot on paper and whose server asks people
+ * not to bulk-fetch, the other needs written permission before anything is
+ * printed. Removing an option must not remove a page, though.
+ */
+ it('still offers the source a map already uses, even one no longer recommended', () => {
+ show(mapEl({
+ source: 'tiles',
+ tileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
+ attribution: '© OpenStreetMap contributors',
+ }))
+
+ const chip = screen.getByRole('radio', { name: 'journey.studio.mapSourceTiles' })
+ expect(chip.getAttribute('aria-checked')).toBe('true')
+ })
+
+ it('does not offer it on a map that never used it', () => {
+ show(mapEl())
+ expect(screen.queryByRole('radio', { name: 'journey.studio.mapSourceTiles' })).toBeNull()
+ expect(screen.queryByRole('radio', { name: 'journey.studio.mapSourceStatic' })).toBeNull()
+ })
+
+ it('writes the drawn style', () => {
+ show(mapEl())
+
+ pick('journey.studio.mapStyle.paper')
+
+ expect(saved().style).toBe('paper')
+ })
+
+ it('fits the stops rather than the countries, and back again', () => {
+ show(mapEl())
+ expect(saved().fitToCountries).toBe(true)
+
+ pick('journey.studio.mapFitStops')
+ expect(saved().fitToCountries).toBe(false)
+
+ pick('journey.studio.mapFitCountry')
+ expect(saved().fitToCountries).toBe(true)
+ })
+
+ it('writes how much room is left around what is drawn', () => {
+ show(mapEl())
+
+ pick('journey.studio.mapPadWide')
+ expect(saved().fitPadding).toBe(0.5)
+
+ pick('journey.studio.mapPadTight')
+ expect(saved().fitPadding).toBe(0.04)
+ })
+
+ it('cuts the picture to the coastline when the shape is switched', () => {
+ show(mapEl())
+
+ pick('journey.studio.mapClipCountry')
+
+ expect(saved().clip).toBe('country')
+ })
+
+ /* A country-shaped hole with no country to cut against is a filled box. */
+ it('refuses the country shape on a map that knows no countries', () => {
+ show(mapEl({ countries: [] }))
+
+ const chip = screen.getByRole('radio', { name: 'journey.studio.mapClipCountry' })
+ expect(chip).toBeDisabled()
+
+ fireEvent.click(chip)
+ expect(saved().clip).toBe('rect')
+ })
+
+ it('writes a zoom from the folded group, and hands it back automatically', () => {
+ const { container } = show(mapEl({ source: 'tiles', tileUrl: 'https://tile.example/{z}/{x}/{y}.png' }))
+ open(container, 'journey.studio.mapZoom')
+
+ pick('11')
+ expect(saved().zoom).toBe(11)
+
+ // Automatic is null rather than a number, which is what lets the element
+ // choose one that fits the route.
+ pick('journey.studio.mapZoomAuto')
+ expect(saved().zoom).toBeNull()
+ })
+
+ it('draws each layer, or does not', () => {
+ show(mapEl())
+
+ press('journey.studio.showLand')
+ expect(saved().showLand).toBe(false)
+
+ press('journey.studio.showRoute')
+ expect(saved().showRoute).toBe(false)
+
+ press('journey.studio.showPins')
+ expect(saved().showPins).toBe(false)
+
+ press('journey.studio.showLabels')
+ expect(saved().showLabels).toBe(true)
+ })
+})
+
+describe('the figures', () => {
+ it('adds a figure to the set', () => {
+ show(statsEl())
+
+ tick('journey.studio.metric.countries')
+
+ expect(saved().metrics).toEqual(['distance', 'days', 'steps', 'photos', 'countries'])
+ })
+
+ it('takes one back out', () => {
+ show(statsEl())
+
+ tick('journey.studio.metric.days')
+
+ expect(saved().metrics).toEqual(['distance', 'steps', 'photos'])
+ })
+
+ /* An empty panel is a blank rectangle nobody could get back out of. */
+ it('keeps the last figure', () => {
+ show(statsEl({ metrics: ['days'] }))
+
+ tick('journey.studio.metric.days')
+
+ expect(saved().metrics).toEqual(['days'])
+ })
+
+ it('writes the layout and the units', () => {
+ show(statsEl())
+
+ pick('journey.studio.layoutRow')
+ expect(saved().layout).toBe('row')
+
+ pick('mi')
+ expect(saved().units).toBe('imperial')
+ })
+
+ it('writes the icons switch', () => {
+ show(statsEl())
+
+ press('journey.studio.showIcons')
+
+ expect(saved().showIcons).toBe(false)
+ })
+})
+
+describe('the countries', () => {
+ it('writes the layout and the alignment', () => {
+ show(countriesEl())
+
+ pick('journey.studio.layoutColumn')
+ expect(saved().layout).toBe('column')
+
+ pick('journey.studio.align.right')
+ expect(saved().align).toBe('right')
+ })
+
+ it('writes the three things it draws', () => {
+ show(countriesEl())
+
+ press('journey.studio.showOutline')
+ expect(saved().showOutline).toBe(false)
+
+ press('journey.studio.showFlag')
+ expect(saved().showFlag).toBe(true)
+
+ press('journey.studio.showName')
+ expect(saved().showName).toBe(false)
+ })
+
+ it('keeps one name per code when a line is edited', () => {
+ show(countriesEl())
+
+ fireEvent.change(screen.getByLabelText('journey.studio.countryNames'), {
+ target: { value: 'Island\nNoreg' },
+ })
+
+ expect(saved().names).toEqual(['Island', 'Noreg'])
+ })
+
+ /*
+ * The reason the textarea does not simply store its own lines: a removed line
+ * must not shift every name after it onto the wrong outline, and a line
+ * nobody has a country for must not be printed at all.
+ */
+ it('ignores a line past the last country, and keeps a name whose line is gone', () => {
+ show(countriesEl())
+ const box = screen.getByLabelText('journey.studio.countryNames')
+
+ fireEvent.change(box, { target: { value: 'Island\nNoreg\nSverige' } })
+ expect(saved().names).toEqual(['Island', 'Noreg'])
+
+ fireEvent.change(box, { target: { value: 'Island' } })
+ expect(saved().names).toEqual(['Island', 'Noreg'])
+ })
+})
+
+describe('the mark', () => {
+ it('writes the words and the caption', () => {
+ const { container } = show(badgeEl())
+
+ fireEvent.change(
+ within(lineIn(container, 'journey.studio.marks', 'journey.studio.text')).getByRole('textbox'),
+ { target: { value: 'REYKJAVIK' } },
+ )
+ expect(saved().text).toBe('REYKJAVIK')
+
+ fireEvent.change(
+ within(lineIn(container, 'journey.studio.marks', 'journey.studio.styleCaption')).getByRole('textbox'),
+ { target: { value: 'Day 1' } },
+ )
+ expect(saved().sub).toBe('Day 1')
+ })
+
+ it('writes the style, in the panel language rather than in the raw value', () => {
+ show(badgeEl())
+
+ pick('journey.studio.markStyle.chip')
+ expect(saved().style).toBe('chip')
+
+ pick('journey.studio.markStyle.stacked')
+ expect(saved().style).toBe('stacked')
+
+ // These four printed `plain`, `chip` and the rest in all twenty-three
+ // languages before, straight out of the value.
+ expect(screen.getByRole('radio', { name: 'journey.studio.markStyle.outline' })).toBeTruthy()
+ expect(screen.queryByRole('radio', { name: 'outline' })).toBeNull()
+ })
+
+ it('writes the three combinations of picture and words', () => {
+ show(badgeEl())
+
+ pick('journey.studio.iconOnly')
+ expect(saved().showIcon).toBe(true)
+ expect(saved().showLabel).toBe(false)
+
+ pick('journey.studio.labelOnly')
+ expect(saved().showIcon).toBe(false)
+ expect(saved().showLabel).toBe(true)
+
+ pick('journey.studio.iconAndLabel')
+ expect(saved().showIcon).toBe(true)
+ expect(saved().showLabel).toBe(true)
+ })
+
+ /* A mark with neither its picture nor its words is a way to lose an element. */
+ it('offers no way to switch both off', () => {
+ const { container } = show(badgeEl())
+ const shows = within(section(container, 'journey.studio.icon')).getAllByRole('radio')
+ expect(shows).toHaveLength(3)
+
+ for (const chip of shows) {
+ fireEvent.click(chip)
+ expect(saved().showIcon || saved().showLabel).toBe(true)
+ }
+ })
+
+ it('writes the icon colour, and stops the icon choosing its own', () => {
+ const { container } = show(badgeEl())
+
+ fireEvent.click(
+ within(lineIn(container, 'journey.studio.icon', 'journey.studio.colour'))
+ .getByRole('button', { name: '#0f766e' }),
+ )
+
+ expect(saved().iconColor).toBe('#0f766e')
+ expect(saved().autoIconColor).toBe(false)
+ })
+
+ it('hands the icon colour back with the automatic switch', () => {
+ const { container } = show(badgeEl({ autoIconColor: false, iconColor: '#0f766e' }))
+
+ fireEvent.click(
+ within(section(container, 'journey.studio.icon'))
+ .getByRole('button', { name: 'journey.studio.autoColour' }),
+ )
+
+ expect(saved().autoIconColor).toBe(true)
+ })
+})
+
+describe('the pros and cons list', () => {
+ it('writes the layout', () => {
+ show(listEl())
+
+ pick('journey.studio.layoutColumn')
+ expect(saved().layout).toBe('stacked')
+
+ pick('journey.studio.layoutGrid')
+ expect(saved().layout).toBe('columns')
+ })
+
+ it('writes the marks switch', () => {
+ show(listEl())
+
+ press('journey.studio.showMarks')
+
+ expect(saved().showMarks).toBe(false)
+ })
+
+ it('writes both headings', () => {
+ show(listEl())
+
+ fireEvent.change(screen.getByLabelText('journey.editor.pros'), { target: { value: 'Worth it' } })
+ expect(saved().proLabel).toBe('Worth it')
+
+ fireEvent.change(screen.getByLabelText('journey.editor.cons'), { target: { value: 'Less so' } })
+ expect(saved().conLabel).toBe('Less so')
+ })
+})
+
+describe('the tail all five share', () => {
+ it('writes the family, and moves the weight to one that family really has', () => {
+ const { container } = show(statsEl({ font: 'sans', weight: 700 }))
+ open(container, 'journey.studio.typography')
+
+ // Bebas ships one weight; a bold it does not have prints as a smeared
+ // regular, so the request moves rather than being passed through.
+ press('Bebas Neue')
+
+ expect(saved().font).toBe('bebas')
+ expect(saved().weight).toBe(400)
+ })
+
+ it('keeps a weight the new family does have', () => {
+ const { container } = show(statsEl({ font: 'sans', weight: 500 }))
+ open(container, 'journey.studio.typography')
+
+ press('Lora')
+
+ expect(saved().font).toBe('serif')
+ expect(saved().weight).toBe(500)
+ })
+
+ it('writes the weight', () => {
+ const { container } = show(countriesEl())
+ open(container, 'journey.studio.typography')
+
+ pick('500')
+
+ expect(saved().weight).toBe(500)
+ })
+
+ it('writes the text scale', () => {
+ const { container } = show(listEl())
+ open(container, 'journey.studio.typography')
+
+ pick('1.2×')
+
+ expect(saved().textScale).toBe(1.2)
+ })
+
+ it('writes the accent and the ink, which are two different rows', () => {
+ const { container } = show(statsEl())
+
+ fireEvent.click(
+ within(lineIn(container, 'journey.studio.colour', 'journey.studio.accent'))
+ .getByRole('button', { name: '#c2410c' }),
+ )
+ expect(saved().accent).toBe('#c2410c')
+ expect(saved().color).toBe('#1a1a1a')
+
+ fireEvent.click(
+ within(lineIn(container, 'journey.studio.colour', 'journey.studio.text'))
+ .getByRole('button', { name: '#1e3a8a' }),
+ )
+ expect(saved().color).toBe('#1e3a8a')
+ expect(saved().accent).toBe('#c2410c')
+ })
+
+ /* A chip's words answer to its fill, until somebody chooses otherwise. */
+ it('takes a mark off automatic when its ink is picked, and the switch puts it back', () => {
+ const { container } = show(badgeEl())
+
+ fireEvent.click(
+ within(lineIn(container, 'journey.studio.colour', 'journey.studio.text'))
+ .getByRole('button', { name: '#ffffff' }),
+ )
+ expect(saved().color).toBe('#ffffff')
+ expect(saved().autoColor).toBe(false)
+
+ fireEvent.click(
+ within(section(container, 'journey.studio.colour'))
+ .getByRole('button', { name: 'journey.studio.autoColour' }),
+ )
+ expect(saved().autoColor).toBe(true)
+ })
+})
+
+describe('an element the journey has moved past', () => {
+ it('says so, and offers to bring it up to date', () => {
+ show(statsEl(), stats)
+
+ expect(screen.getByText('journey.studio.staleHint')).toBeTruthy()
+ expect(screen.getByRole('button', { name: 'journey.studio.refresh' })).toBeTruthy()
+ })
+
+ it('writes the journey figures as they stand now, and stops asking', () => {
+ show(statsEl(), stats)
+
+ press('journey.studio.refresh')
+
+ expect(saved().values).toMatchObject({ distance: 1234, days: 5, steps: 7, photos: 12 })
+ expect(saved().stale).toBe(false)
+ expect(screen.queryByText('journey.studio.staleHint')).toBeNull()
+ })
+
+ /*
+ * Compared rather than timestamped: a page showing distance and days does not
+ * go stale because somebody fixed a typo in a caption.
+ */
+ it('says nothing while the figures it shows still match', () => {
+ show(statsEl({ values: { distance: 1234, days: 5, steps: 7, photos: 12 } }), stats)
+
+ expect(screen.queryByText('journey.studio.staleHint')).toBeNull()
+ })
+})
diff --git a/client/tests/unit/studio/mapMarkers.test.tsx b/client/tests/unit/studio/mapMarkers.test.tsx
new file mode 100644
index 000000000..7ffc5f3e2
--- /dev/null
+++ b/client/tests/unit/studio/mapMarkers.test.tsx
@@ -0,0 +1,154 @@
+import { describe, it, expect } from 'vitest'
+import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared'
+import { bookPageSetupSchema } from '@trek/shared'
+import { render } from '../../helpers/render'
+import { SpreadView } from '../../../src/components/Studio/SpreadView'
+
+/**
+ * The drawn route: photo markers, a bowed line and a casing (#1973).
+ *
+ * The stencil that rounds a photograph is the fragile part, and it failed
+ * silently the first time: it was written inside the `defs` that belongs to
+ * cutting a map to a coastline, so on an ordinary map the id it pointed at was
+ * never written. A clip-path pointing at nothing is not an error, it is simply
+ * no clip, and because the image is drawn with `slice` what showed was the
+ * photograph standing proud of its circle with all four corners on the map.
+ *
+ * So these cases check that the id on the image and an id in the defs are the
+ * same id, rather than checking that an image exists. The plain treatment is
+ * checked too: every book made before this feature has to keep drawing exactly
+ * as it did.
+ */
+
+const page: BookPageSetup = bookPageSetupSchema.parse({ preset: 'square-210' })
+
+const STOPS = [
+ { lat: 64.14, lng: -21.94, label: 'Reykjavík', photoId: 11 },
+ { lat: 65.68, lng: -18.12, label: 'Akureyri', photoId: 12 },
+ { lat: 38.72, lng: -9.14, label: 'Lisboa', photoId: null },
+]
+
+function map(over: Record = {}): BookElement {
+ return {
+ id: 'mp1', kind: 'map', rotation: 0, opacity: 1, locked: false,
+ font: 'sans', color: '#1a1a1a', accent: '#ffffff', textScale: 1, weight: 400, stale: false,
+ frame: { x: 0, y: 0, w: 180, h: 140 },
+ style: 'minimal', source: 'vector', tileUrl: '', attribution: '', zoom: null, clip: 'rect',
+ showLand: true, showRoute: true, showPins: true, showLabels: false,
+ routeStyle: 'drawn', routeArc: 'bow', routeDash: 'arcs', pinStyle: 'photo',
+ countries: [], path: [], fitPadding: 0.5, fitToCountries: false, tripId: null,
+ points: STOPS,
+ ...over,
+ } as unknown as BookElement
+}
+
+function draw(el: BookElement, big = true) {
+ const spread: BookSpread = {
+ id: 's1', role: 'inner', background: null, elements: [el], parked: [], entryId: null,
+ }
+ return render( )
+}
+
+describe('a stop with a photograph', () => {
+ it('is cut to a circle by a stencil that really exists', () => {
+ const { container } = draw(map())
+ const images = Array.from(container.querySelectorAll('image'))
+ expect(images.length).toBe(2)
+
+ const ids = new Set(
+ Array.from(container.querySelectorAll('clipPath')).map(c => c.getAttribute('id')),
+ )
+ for (const im of images) {
+ const ref = im.getAttribute('clip-path')!
+ const id = ref.replace(/^url\(#/, '').replace(/\)$/, '')
+ // The assertion that would have caught the square photographs.
+ expect(ids.has(id), `no stencil for ${ref}`).toBe(true)
+ }
+ })
+
+ it('asks for the thumbnail rather than the original, at every print size', () => {
+ const { container } = draw(map())
+ for (const im of Array.from(container.querySelectorAll('image'))) {
+ expect(im.getAttribute('href')).toMatch(/^\/api\/photos\/\d+\/thumbnail$/)
+ }
+ })
+
+ it('crops the picture to fill its circle rather than letterboxing it', () => {
+ const { container } = draw(map())
+ expect(container.querySelector('image')!.getAttribute('preserveAspectRatio')).toBe('xMidYMid slice')
+ })
+
+ /*
+ * A stop with no photograph of its own is numbered rather than handed one
+ * from somewhere else in the journey. That was the first design and it was a
+ * caption that lies: a marker at the third stop showing a picture taken at
+ * the seventh is wrong in the one place a reader most trusts that a picture
+ * is OF what it sits on.
+ */
+ it('numbers a stop that has no photograph of its own', () => {
+ const { container } = draw(map())
+ expect(container.querySelectorAll('image').length).toBe(STOPS.filter(s => s.photoId).length)
+
+ const numbers = Array.from(container.querySelectorAll('text')).map(t => t.textContent)
+ // The third stop carries no photo id, so it prints its position.
+ expect(numbers).toContain('3')
+ })
+
+ it('numbers nothing when the map was asked for dots', () => {
+ const { container } = draw(map({ pinStyle: 'dot' }))
+ expect(container.querySelectorAll('text').length).toBe(0)
+ expect(container.querySelectorAll('image').length).toBe(0)
+ })
+
+ /*
+ * Nobody prints the pages rail, and a thumbnail that fetched a photograph per
+ * stop would cost a request per stop every time a panel opened.
+ */
+ it('costs nothing in a thumbnail', () => {
+ const { container } = draw(map(), false)
+ expect(container.querySelectorAll('image').length).toBe(0)
+ expect(container.querySelectorAll('clipPath').length).toBe(0)
+ })
+})
+
+describe('the drawn line', () => {
+ it('is two strokes, so it stays legible over dark sea and bright desert alike', () => {
+ const { container } = draw(map({ source: 'tiles', tileUrl: 'https://example.test/{z}/{x}/{y}.png' }))
+ const strokes = Array.from(container.querySelectorAll('path'))
+ .filter(p => p.getAttribute('fill') === 'none')
+ expect(strokes.length).toBeGreaterThanOrEqual(2)
+ // The casing is the wider of the pair, and it is the translucent one.
+ const widths = strokes.map(p => Number(p.getAttribute('stroke-width')))
+ expect(Math.max(...widths)).toBeGreaterThan(Math.min(...widths))
+ expect(strokes.some(p => p.getAttribute('stroke-opacity'))).toBe(true)
+ })
+
+ it('bows a long leg and dashes it, since it is inferred rather than recorded', () => {
+ const { container } = draw(map())
+ const paths = Array.from(container.querySelectorAll('path'))
+ expect(paths.some(p => (p.getAttribute('d') ?? '').includes('Q'))).toBe(true)
+ expect(paths.some(p => p.getAttribute('stroke-dasharray'))).toBe(true)
+ })
+
+ it('never bows a recorded track, which would draw a journey that did not happen', () => {
+ const { container } = draw(map({
+ path: [[[64.14, -21.94], [64.5, -21.0], [65.68, -18.12]]],
+ }))
+ const ds = Array.from(container.querySelectorAll('path'))
+ .filter(p => p.getAttribute('fill') === 'none')
+ .map(p => p.getAttribute('d') ?? '')
+ expect(ds.length).toBeGreaterThan(0)
+ for (const d of ds) expect(d).not.toContain('Q')
+ })
+})
+
+describe('a book made before any of this', () => {
+ it('still draws one flat polyline and plain circles', () => {
+ const { container } = draw(map({
+ routeStyle: 'plain', routeArc: 'straight', routeDash: 'solid', pinStyle: 'dot',
+ }))
+ expect(container.querySelectorAll('polyline').length).toBe(1)
+ expect(container.querySelectorAll('image').length).toBe(0)
+ expect(container.querySelectorAll('circle').length).toBe(STOPS.length)
+ })
+})
diff --git a/client/tests/unit/studio/mapPrintResolution.test.ts b/client/tests/unit/studio/mapPrintResolution.test.ts
new file mode 100644
index 000000000..960c2f267
--- /dev/null
+++ b/client/tests/unit/studio/mapPrintResolution.test.ts
@@ -0,0 +1,127 @@
+import { describe, it, expect } from 'vitest'
+import { maxZoomFor, tileView } from '../../../src/components/Studio/mapTiles'
+
+/**
+ * How sharp a tiled map comes out on paper (#1973).
+ *
+ * The zoom used to be chosen so the route spanned the frame and nothing more,
+ * which put about two tiles across the picture whatever its physical size: a
+ * route across Iceland on a 210mm page arrived as 256 by 512 pixels stretched
+ * over the sheet. Seventeen dots to the inch, on paper that holds three
+ * hundred. It did not look like a bad map, it looked like a screenshot, which
+ * is exactly what it was.
+ *
+ * These cases pin the sum rather than the picture: how many tile pixels end up
+ * covering how many millimetres. A change that quietly puts the old behaviour
+ * back would show up here as a number falling by a factor of ten.
+ */
+
+const OSM = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
+const RELIEF = 'https://gibs.earthdata.nasa.gov/wmts/epsg3857/best'
+ + '/BlueMarble_ShadedRelief_Bathymetry/default/GoogleMapsCompatible_Level8/{z}/{y}/{x}.jpeg'
+
+/** A tile is 256 CSS pixels, or 512 when the template asked for retina. */
+const tilePixels = (template: string) => (template.includes('{r}') ? 512 : 256)
+
+/** Dots per inch the grid actually delivers over the frame it covers. */
+function dpi(view: { size: number }, template: string): number {
+ return tilePixels(template) / (view.size / 25.4)
+}
+
+const ICELAND = [
+ { lat: 64.1466, lng: -21.9426 },
+ { lat: 65.6885, lng: -18.1262 },
+ { lat: 64.2539, lng: -15.2082 },
+ { lat: 63.4194, lng: -19.0672 },
+]
+
+const BERLIN = [
+ { lat: 52.5163, lng: 13.3777 },
+ { lat: 52.5200, lng: 13.4050 },
+ { lat: 52.5074, lng: 13.3904 },
+]
+
+describe('a full page of map', () => {
+ it('is drawn at print resolution rather than at screen resolution', () => {
+ const view = tileView(ICELAND, { w: 210, h: 297 }, OSM, null)!
+ // The old sum landed at 17. Anything in this range is a printable page.
+ expect(dpi(view, OSM)).toBeGreaterThan(250)
+ })
+
+ it('spends the tiles on the page it has, not on a fixed grid', () => {
+ const small = tileView(ICELAND, { w: 60, h: 60 }, OSM, null)!
+ const page = tileView(ICELAND, { w: 210, h: 297 }, OSM, null)!
+ // Same route, bigger paper, more pictures. That is the whole idea.
+ expect(page.tiles.length).toBeGreaterThan(small.tiles.length)
+ })
+
+ it('stays inside the request budget however large the page is', () => {
+ const spread = tileView(ICELAND, { w: 420, h: 297 }, OSM, null)!
+ expect(spread.tiles.length).toBeLessThanOrEqual(256)
+ })
+
+ it('asks for the retina tile a template offers, which is free resolution', () => {
+ const retina = 'https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
+ const view = tileView(BERLIN, { w: 210, h: 210 }, retina, null)!
+ expect(view.tiles[0].url).toContain('@2x')
+ expect(view.tiles[0].url).not.toContain('{r}')
+ })
+
+ it('leaves a template with no retina placeholder alone', () => {
+ const view = tileView(BERLIN, { w: 210, h: 210 }, OSM, null)!
+ expect(view.tiles[0].url).not.toContain('@2x')
+ })
+})
+
+describe('a source with a ceiling', () => {
+ /*
+ * NASA's imagery stops at level 8. Asking for 9 answers with an error image,
+ * which arrives as a page-sized grey rectangle and no explanation.
+ */
+ it('never asks NASA for a zoom it does not have', () => {
+ expect(maxZoomFor(RELIEF)).toBe(8)
+ const view = tileView(BERLIN, { w: 420, h: 297 }, RELIEF, null)!
+ expect(view.zoom).toBeLessThanOrEqual(8)
+ })
+
+ it('lets an ordinary tile source go further', () => {
+ expect(maxZoomFor(OSM)).toBe(19)
+ const relief = tileView(BERLIN, { w: 210, h: 210 }, RELIEF, null)!
+ const tiles = tileView(BERLIN, { w: 210, h: 210 }, OSM, null)!
+ expect(tiles.zoom).toBeGreaterThan(relief.zoom)
+ })
+
+ it('still honours a zoom the document asked for', () => {
+ const view = tileView(ICELAND, { w: 210, h: 297 }, OSM, 6)!
+ expect(view.zoom).toBe(6)
+ })
+})
+
+describe('the credit', () => {
+ it('names NASA for its imagery, because a printed book cannot add a footnote later', async () => {
+ const { attributionFor } = await import('../../../src/components/Studio/mapTiles')
+ expect(attributionFor(RELIEF)).toBe('NASA EOSDIS GIBS')
+ })
+})
+
+describe('a thumbnail of the same map', () => {
+ /*
+ * The pages rail and the travel panel draw the same element at about 130px.
+ * Cutting those to print resolution would mean a couple of hundred requests
+ * for a picture the size of a stamp, every time a panel opens — which
+ * against NASA or OpenStreetMap is the bulk fetching their policies ask
+ * people not to do.
+ */
+ it('costs a handful of tiles rather than a page of them', () => {
+ const page = tileView(ICELAND, { w: 210, h: 297 }, OSM, null)!
+ const thumb = tileView(ICELAND, { w: 210, h: 297 }, OSM, null, undefined, 'preview')!
+ expect(thumb.tiles.length).toBeLessThanOrEqual(16)
+ expect(thumb.tiles.length).toBeLessThan(page.tiles.length / 4)
+ })
+
+ it('still covers the same ground, just less finely', () => {
+ const thumb = tileView(ICELAND, { w: 210, h: 297 }, OSM, null, undefined, 'preview')!
+ expect(thumb.tiles.length).toBeGreaterThan(0)
+ expect(thumb.zoom).toBeLessThan(tileView(ICELAND, { w: 210, h: 297 }, OSM, null)!.zoom)
+ })
+})
diff --git a/client/tests/unit/studio/mapRoads.test.tsx b/client/tests/unit/studio/mapRoads.test.tsx
new file mode 100644
index 000000000..660a7351b
--- /dev/null
+++ b/client/tests/unit/studio/mapRoads.test.tsx
@@ -0,0 +1,218 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared'
+import { bookPageSetupSchema } from '@trek/shared'
+import { render } from '../../helpers/render'
+import { SpreadView } from '../../../src/components/Studio/SpreadView'
+import { fetchRoads } from '../../../src/components/Studio/roadRoute'
+
+vi.mock('../../../src/components/Map/RouteCalculator', () => ({
+ calculateRouteWithLegs: vi.fn(),
+}))
+const { calculateRouteWithLegs } = await import('../../../src/components/Map/RouteCalculator')
+
+/**
+ * The line following real roads, for the legs that have them (#1973).
+ *
+ * The trap this is built around: when the map element carries a track, the
+ * renderer draws that INSTEAD of the stop chain, whole. A road route that only
+ * covers some legs would therefore have printed those legs and silently dropped
+ * the rest — three legs of twelve, and the book looks like the journey stopped.
+ * So the roads are per leg and the chain is cut around them, and the case that
+ * proves it is the one below with a road on the middle leg only.
+ *
+ * The second thing worth protecting is what is NOT asked: a leg long enough to
+ * have been a flight is never sent to a router, because the answer would be a
+ * three-day drive and a ferry, and that is not the journey anybody made.
+ */
+
+const page: BookPageSetup = bookPageSetupSchema.parse({ preset: 'square-210' })
+
+const STOPS = [
+ { lat: 64.14, lng: -21.94, label: 'Reykjavík', photoId: null },
+ { lat: 63.42, lng: -19.07, label: 'Vík', photoId: null },
+ { lat: 65.68, lng: -18.12, label: 'Akureyri', photoId: null },
+]
+
+function map(over: Record = {}): BookElement {
+ return {
+ id: 'mp1', kind: 'map', rotation: 0, opacity: 1, locked: false,
+ font: 'sans', color: '#1a1a1a', accent: '#ffffff', textScale: 1, weight: 400, stale: false,
+ frame: { x: 0, y: 0, w: 180, h: 140 },
+ style: 'minimal', source: 'vector', tileUrl: '', attribution: '', zoom: null, clip: 'rect',
+ showLand: true, showRoute: true, showPins: true, showLabels: false,
+ routeStyle: 'drawn', routeArc: 'bow', routeDash: 'arcs', pinStyle: 'dot',
+ countries: [], path: [], roads: [], fitPadding: 0.5, fitToCountries: false, tripId: null,
+ points: STOPS,
+ ...over,
+ } as unknown as BookElement
+}
+
+function draw(el: BookElement) {
+ const spread: BookSpread = {
+ id: 's1', role: 'inner', background: null, elements: [el], parked: [], entryId: null,
+ }
+ return render( )
+}
+
+const strokes = (c: HTMLElement) =>
+ Array.from(c.querySelectorAll('path')).filter(p => p.getAttribute('fill') === 'none')
+
+describe('a leg that has a road', () => {
+ it('is drawn as the road rather than as a line between its ends', () => {
+ const road: [number, number][] = [
+ [64.14, -21.94], [64.0, -21.5], [63.8, -20.6], [63.5, -19.6], [63.42, -19.07],
+ ]
+ const { container } = draw(map({ roads: [road, null] }))
+ const ds = strokes(container).map(p => p.getAttribute('d') ?? '')
+ // A road is many points; a bow is one Q; a straight leg is one L.
+ expect(ds.some(d => (d.match(/L/g) ?? []).length >= 3)).toBe(true)
+ })
+
+ it('is drawn solid and unbowed, because it is the way that was taken', () => {
+ const road: [number, number][] = [
+ [64.14, -21.94], [64.0, -21.5], [63.8, -20.6], [63.42, -19.07],
+ ]
+ const { container } = draw(map({ roads: [road, null] }))
+ const roadPaths = strokes(container).filter(p => (p.getAttribute('d') ?? '').match(/L/g))
+ for (const p of roadPaths) {
+ expect(p.getAttribute('d')).not.toContain('Q')
+ expect(p.getAttribute('stroke-dasharray')).toBeNull()
+ }
+ })
+
+ /*
+ * The bug this whole shape exists to avoid: `path` replaces the entire line,
+ * so a partial answer used to mean a partial journey.
+ */
+ it('does not swallow the legs that have none', () => {
+ const middle: [number, number][] = [
+ [63.42, -19.07], [64.0, -18.5], [65.0, -18.2], [65.68, -18.12],
+ ]
+ const { container } = draw(map({ roads: [null, middle] }))
+ const ds = strokes(container).map(p => p.getAttribute('d') ?? '')
+ // The first leg is still drawn: either bowed or straight, but drawn.
+ const hasFirstLeg = ds.some(d => d.includes('Q') || (d.match(/L/g) ?? []).length === 1)
+ expect(hasFirstLeg).toBe(true)
+ expect(ds.some(d => (d.match(/L/g) ?? []).length >= 2)).toBe(true)
+ })
+
+ it('is inside the frame, road and all', () => {
+ // A road that wanders well north of both its ends.
+ const road: [number, number][] = [
+ [64.14, -21.94], [66.5, -21.0], [66.6, -19.5], [63.42, -19.07],
+ ]
+ const { container } = draw(map({ roads: [road, null] }))
+ const numbers = strokes(container)
+ .flatMap(p => (p.getAttribute('d') ?? '').match(/-?\d+(\.\d+)?/g) ?? [])
+ .map(Number)
+ // Coordinates come in pairs; none of them may leave the 180x140 element.
+ for (let i = 0; i < numbers.length; i += 2) {
+ expect(numbers[i]).toBeGreaterThanOrEqual(-0.01)
+ expect(numbers[i]).toBeLessThanOrEqual(180.01)
+ expect(numbers[i + 1]).toBeGreaterThanOrEqual(-0.01)
+ expect(numbers[i + 1]).toBeLessThanOrEqual(140.01)
+ }
+ })
+})
+
+describe('what gets asked for', () => {
+ beforeEach(() => {
+ vi.mocked(calculateRouteWithLegs).mockReset()
+ })
+
+ it('asks once per leg and keeps the answer in leg order', async () => {
+ vi.mocked(calculateRouteWithLegs).mockResolvedValue({
+ coordinates: [[1, 1], [1.1, 1.1], [1.2, 1.2]],
+ distance: 1, duration: 1, legs: [],
+ } as never)
+
+ const roads = await fetchRoads([
+ { lat: 52.5, lng: 13.4 },
+ { lat: 52.4, lng: 13.1 },
+ { lat: 52.3, lng: 13.0 },
+ ])
+ expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
+ expect(roads).toHaveLength(2)
+ expect(roads[0]).toHaveLength(3)
+ })
+
+ it('never asks about a leg long enough to have been a flight', async () => {
+ const roads = await fetchRoads([
+ { lat: 64.14, lng: -21.94 },
+ { lat: -33.87, lng: 151.21 },
+ ])
+ expect(calculateRouteWithLegs).not.toHaveBeenCalled()
+ expect(roads).toEqual([null])
+ })
+
+ it('leaves a leg alone when the router refuses, rather than failing the lot', async () => {
+ vi.mocked(calculateRouteWithLegs)
+ .mockRejectedValueOnce(new Error('no route'))
+ .mockResolvedValueOnce({
+ coordinates: [[1, 1], [1.1, 1.1], [1.2, 1.2]], distance: 1, duration: 1, legs: [],
+ } as never)
+
+ const roads = await fetchRoads([
+ { lat: 52.5, lng: 13.4 },
+ { lat: 52.4, lng: 13.1 },
+ { lat: 52.3, lng: 13.0 },
+ ])
+ expect(roads[0]).toBeNull()
+ expect(roads[1]).not.toBeNull()
+ })
+
+ it('keeps nothing when the answer is just the straight line back', async () => {
+ vi.mocked(calculateRouteWithLegs).mockResolvedValue({
+ coordinates: [[1, 1], [1.2, 1.2]], distance: 1, duration: 1, legs: [],
+ } as never)
+ const roads = await fetchRoads([{ lat: 52.5, lng: 13.4 }, { lat: 52.4, lng: 13.1 }])
+ expect(roads).toEqual([null])
+ })
+
+ /*
+ * A router answers a long drive with thousands of points, at a precision no
+ * press can resolve, and all of it is stored in the document. That is not a
+ * tidiness problem: the book is saved as one body, and a route kept whole is
+ * what pushed a real book past the body limit and left the editor showing
+ * "not saved" with no way forward.
+ *
+ * Thinned evenly rather than by cutting a run out of the middle, because a
+ * road's shape is the reason to draw it at all — losing the middle would
+ * straighten exactly the bends somebody wanted to see.
+ */
+ it('thins a long answer, keeping both ends and the shape between them', async () => {
+ const many: [number, number][] = Array.from({ length: 900 }, (_, i) => [
+ 52.5 - i * 0.001,
+ 13.4 + i * 0.001,
+ ])
+ vi.mocked(calculateRouteWithLegs).mockResolvedValue({
+ coordinates: many, distance: 1, duration: 1, legs: [],
+ } as never)
+
+ const roads = await fetchRoads([{ lat: 52.5, lng: 13.4 }, { lat: 51.6, lng: 14.3 }])
+ const leg = roads[0]!
+ expect(leg).not.toBeNull()
+ expect(leg.length).toBe(120)
+
+ // Both ends survive, so the line still meets the stops it runs between.
+ expect(leg[0]).toEqual([52.5, 13.4])
+ expect(leg[leg.length - 1]).toEqual([51.601, 14.299])
+
+ // Evenly spaced: no two consecutive points sit further apart than one step.
+ const gaps = leg.slice(1).map((p, i) => Math.abs(p[0] - leg[i][0]))
+ expect(Math.max(...gaps) - Math.min(...gaps)).toBeLessThan(0.002)
+ })
+
+ it('leaves a leg the press can already resolve exactly as it came', async () => {
+ const few: [number, number][] = Array.from({ length: 40 }, (_, i) => [
+ 52.5 - i * 0.01,
+ 13.4 + i * 0.01,
+ ])
+ vi.mocked(calculateRouteWithLegs).mockResolvedValue({
+ coordinates: few, distance: 1, duration: 1, legs: [],
+ } as never)
+
+ const roads = await fetchRoads([{ lat: 52.5, lng: 13.4 }, { lat: 52.1, lng: 13.8 }])
+ expect(roads[0]!.length).toBe(40)
+ })
+})
diff --git a/client/tests/unit/studio/mapRouteFits.test.tsx b/client/tests/unit/studio/mapRouteFits.test.tsx
new file mode 100644
index 000000000..6e9b7ecee
--- /dev/null
+++ b/client/tests/unit/studio/mapRouteFits.test.tsx
@@ -0,0 +1,137 @@
+import { describe, it, expect } from 'vitest'
+import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared'
+import { bookPageSetupSchema } from '@trek/shared'
+import { render } from '../../helpers/render'
+import { SpreadView } from '../../../src/components/Studio/SpreadView'
+
+/**
+ * The route stays on the map, whatever shape the map is dragged to (#1973).
+ *
+ * Two fits used to disagree about what the map was of. The outlines and the
+ * route were contained in the frame, fitted whole with air on the long side;
+ * the imagery covered it, because a map with a strip of paper down one edge is
+ * not a map. On a frame whose proportions did not match the route's those are
+ * different windows, and the tiles won — so a map dragged narrow kept its
+ * picture and lost the end of its line.
+ *
+ * These cases drag the same route into deliberately awkward shapes and check
+ * every drawn point is still inside the box. They read the SVG rather than the
+ * document, because this is the one thing about a map that only goes wrong at
+ * draw time.
+ */
+
+const page: BookPageSetup = bookPageSetupSchema.parse({ preset: 'square-210' })
+
+const OSM = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
+
+/** A route that is far wider than it is tall: Lisbon to Kyiv. */
+const WIDE = [
+ { lat: 38.72, lng: -9.14, label: 'Lisboa' },
+ { lat: 48.85, lng: 2.35, label: 'Paris' },
+ { lat: 52.52, lng: 13.40, label: 'Berlin' },
+ { lat: 50.45, lng: 30.52, label: 'Kyiv' },
+]
+
+/** And one far taller than it is wide: Tromso down to Palermo. */
+const TALL = [
+ { lat: 69.65, lng: 18.95, label: 'Tromso' },
+ { lat: 59.33, lng: 18.07, label: 'Stockholm' },
+ { lat: 47.37, lng: 8.54, label: 'Zurich' },
+ { lat: 38.12, lng: 13.36, label: 'Palermo' },
+]
+
+function map(over: Record = {}): BookElement {
+ return {
+ id: 'mp1', kind: 'map', rotation: 0, opacity: 1, locked: false,
+ font: 'sans', color: '#1a1a1a', accent: '#c2410c', textScale: 1, weight: 400, stale: false,
+ frame: { x: 0, y: 0, w: 120, h: 90 },
+ style: 'minimal', source: 'vector', tileUrl: '', attribution: '', zoom: null, clip: 'rect',
+ showLand: true, showRoute: true, showPins: true, showLabels: false,
+ countries: [], points: WIDE, path: [],
+ fitPadding: 0.18, fitToCountries: false, tripId: null,
+ ...over,
+ } as unknown as BookElement
+}
+
+function draw(el: BookElement) {
+ const spread: BookSpread = {
+ id: 's1', role: 'inner', background: null, elements: [el], parked: [], entryId: null,
+ }
+ return render( )
+}
+
+/** Every stop the map drew, in the element's own millimetres. */
+function pins(container: HTMLElement): { x: number; y: number }[] {
+ return Array.from(container.querySelectorAll('circle')).map(c => ({
+ x: Number(c.getAttribute('cx')),
+ y: Number(c.getAttribute('cy')),
+ })).filter(p => Number.isFinite(p.x) && Number.isFinite(p.y))
+}
+
+const shapes = [
+ { name: 'a wide letterbox', w: 180, h: 40 },
+ { name: 'a tall column', w: 40, h: 180 },
+ { name: 'a small square', w: 30, h: 30 },
+ { name: 'a full spread', w: 420, h: 297 },
+]
+
+describe('however the map is dragged', () => {
+ for (const points of [WIDE, TALL]) {
+ for (const shape of shapes) {
+ it(`keeps every stop inside ${shape.name}`, () => {
+ const el = map({ points, frame: { x: 0, y: 0, w: shape.w, h: shape.h } })
+ const { container } = draw(el)
+ const drawn = pins(container)
+ expect(drawn).toHaveLength(points.length)
+ for (const p of drawn) {
+ expect(p.x).toBeGreaterThanOrEqual(0)
+ expect(p.x).toBeLessThanOrEqual(shape.w)
+ expect(p.y).toBeGreaterThanOrEqual(0)
+ expect(p.y).toBeLessThanOrEqual(shape.h)
+ }
+ })
+ }
+ }
+
+ /*
+ * The case that was actually broken: with imagery the route is projected onto
+ * the tile grid rather than onto the vector fit, and the tile grid covers.
+ */
+ it('keeps every stop inside a narrow map drawn from tiles', () => {
+ const el = map({
+ points: WIDE,
+ source: 'tiles',
+ tileUrl: OSM,
+ frame: { x: 0, y: 0, w: 40, h: 170 },
+ })
+ const { container } = draw(el)
+ const drawn = pins(container)
+ expect(drawn).toHaveLength(WIDE.length)
+ for (const p of drawn) {
+ expect(p.x).toBeGreaterThanOrEqual(0)
+ expect(p.x).toBeLessThanOrEqual(40)
+ expect(p.y).toBeGreaterThanOrEqual(0)
+ expect(p.y).toBeLessThanOrEqual(170)
+ }
+ })
+
+ it('still fills the frame with imagery rather than leaving paper down one side', () => {
+ const el = map({
+ points: WIDE,
+ source: 'tiles',
+ tileUrl: OSM,
+ frame: { x: 0, y: 0, w: 180, h: 40 },
+ })
+ const { container } = draw(el)
+ const tiles = Array.from(container.querySelectorAll('img'))
+ expect(tiles.length).toBeGreaterThan(0)
+ // The grid has to reach past both edges, which is what cover means.
+ const rights = tiles.map(t => {
+ const left = parseFloat(t.style.left)
+ const width = parseFloat(t.style.width)
+ return left + width
+ })
+ expect(Math.max(...rights)).toBeGreaterThanOrEqual(180)
+ expect(Math.min(...tiles.map(t => parseFloat(t.style.left)))).toBeLessThanOrEqual(0)
+ })
+})
diff --git a/client/tests/unit/studio/mapScope.test.ts b/client/tests/unit/studio/mapScope.test.ts
new file mode 100644
index 000000000..88ef1e3c0
--- /dev/null
+++ b/client/tests/unit/studio/mapScope.test.ts
@@ -0,0 +1,152 @@
+import { describe, it, expect } from 'vitest'
+import type { BookElement, JourneyStats } from '@trek/shared'
+import { isStale, refreshPatch, routeFor } from '../../../src/components/Studio/travelRefresh'
+
+/**
+ * A map of one trip, out of a journey made of several (#1973).
+ *
+ * A journey is a collection of trips, and printing two of them as one route
+ * draws a line from the last stop of the first to the first stop of the second.
+ * On a journey that went to Iceland in June and Portugal in September that leg
+ * is the longest line on the page and nobody travelled it.
+ *
+ * The scope is stored on the element rather than worked out at draw time,
+ * because the stops are frozen into it when it is placed. These cases pin the
+ * part that would otherwise rot quietly: bringing a map up to date has to keep
+ * the scope, or a map of Iceland turns back into a map of everywhere the first
+ * time somebody clicks refresh.
+ */
+
+const ICELAND = 11
+const PORTUGAL = 22
+
+const stats: JourneyStats = {
+ journeyId: 5,
+ distance: 4_000_000,
+ days: 21,
+ steps: 6,
+ photos: 40,
+ places: 12,
+ furthest: 3_000_000,
+ countries: [
+ { code: 'IS', name: 'Iceland', places: 2, firstVisit: '2026-06-02' },
+ { code: 'PT', name: 'Portugal', places: 2, firstVisit: '2026-09-04' },
+ ],
+ points: [
+ { lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: ICELAND, photoId: null },
+ { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: ICELAND, photoId: null },
+ { lat: 38.72, lng: -9.14, label: 'Lisboa', date: '2026-09-04', country: 'PT', tripId: PORTUGAL, photoId: null },
+ { lat: 41.15, lng: -8.61, label: 'Porto', date: '2026-09-08', country: 'PT', tripId: PORTUGAL, photoId: null },
+ ],
+ trips: [
+ { id: ICELAND, title: 'Iceland', start: '2026-06-02', end: '2026-06-14', points: 2 },
+ { id: PORTUGAL, title: 'Portugal', start: '2026-09-04', end: '2026-09-12', points: 2 },
+ ],
+ start: '2026-06-02',
+ end: '2026-09-12',
+}
+
+const map = (over: Record = {}): BookElement => ({
+ id: 'mp1', kind: 'map', frame: { x: 10, y: 10, w: 180, h: 140 },
+ rotation: 0, opacity: 1, locked: false,
+ font: 'sans', color: '#1a1a1a', accent: '#c2410c', textScale: 1, weight: 400, stale: false,
+ style: 'minimal', source: 'vector', tileUrl: '', attribution: '', zoom: null, clip: 'rect',
+ showLand: true, showRoute: true, showPins: true, showLabels: false,
+ countries: ['IS', 'PT'],
+ points: stats.points.map(p => ({ lat: p.lat, lng: p.lng, label: p.label })),
+ path: [],
+ fitPadding: 0.18,
+ fitToCountries: true,
+ tripId: null,
+ ...over,
+} as unknown as BookElement)
+
+describe('which stops a map is of', () => {
+ it('is the whole journey when no trip is named', () => {
+ expect(routeFor(stats, null)).toHaveLength(4)
+ })
+
+ it('is one trip when one is', () => {
+ const route = routeFor(stats, ICELAND)
+ expect(route.map(p => p.label)).toEqual(['Reykjavík', 'Akureyri'])
+ })
+
+ it('is empty for a trip with nothing on the route, rather than everything', () => {
+ expect(routeFor(stats, 999)).toEqual([])
+ })
+})
+
+describe('bringing a map up to date', () => {
+ it('keeps a journey map showing the journey', () => {
+ const patch = refreshPatch(map(), stats) as { points: unknown[]; countries: string[] }
+ expect(patch.points).toHaveLength(4)
+ expect(patch.countries).toEqual(['IS', 'PT'])
+ })
+
+ /*
+ * The case this test exists for: refresh used to read `stats.points` flat,
+ * so one click turned a map of Iceland back into a map of both trips.
+ */
+ it('keeps a trip map showing that trip', () => {
+ const el = map({
+ tripId: ICELAND,
+ countries: ['IS'],
+ points: [{ lat: 64.14, lng: -21.94, label: 'Reykjavík' }],
+ })
+ const patch = refreshPatch(el, stats) as { points: { label: string }[]; countries: string[] }
+ expect(patch.points.map(p => p.label)).toEqual(['Reykjavík', 'Akureyri'])
+ expect(patch.countries).toEqual(['IS'])
+ })
+
+ it('notices a trip map is behind, against its own trip rather than the journey', () => {
+ const behind = map({
+ tripId: ICELAND,
+ points: [{ lat: 64.14, lng: -21.94, label: 'Reykjavík' }],
+ })
+ expect(isStale(behind, stats)).toBe(true)
+
+ const current = map({
+ tripId: ICELAND,
+ points: [
+ { lat: 64.14, lng: -21.94, label: 'Reykjavík' },
+ { lat: 65.68, lng: -18.12, label: 'Akureyri' },
+ ],
+ })
+ expect(isStale(current, stats)).toBe(false)
+ })
+
+ it('does not call a journey map stale merely because a trip map would be', () => {
+ expect(isStale(map(), stats)).toBe(false)
+ })
+
+ /*
+ * A marker draws the entry's own first photograph, so adding one to an entry
+ * changes the map without moving a single stop. The shape test — same number
+ * of stops, same two ends — cannot see that, and until it did the only way to
+ * get the picture onto the page was to delete the map and place it again.
+ */
+ it('notices a photograph added to a stop, though nothing moved', () => {
+ const el = map({
+ points: stats.points.map(p => ({ lat: p.lat, lng: p.lng, label: p.label, photoId: null })),
+ })
+ expect(isStale(el, stats)).toBe(false)
+
+ const withPhoto: JourneyStats = {
+ ...stats,
+ points: stats.points.map((p, i) => (i === 1 ? { ...p, photoId: 42 } : p)),
+ }
+ expect(isStale(el, withPhoto)).toBe(true)
+
+ const patch = refreshPatch(el, withPhoto) as { points: { photoId: number | null }[] }
+ expect(patch.points[1].photoId).toBe(42)
+ })
+
+ it('notices one taken away again', () => {
+ const el = map({
+ points: stats.points.map((p, i) => ({
+ lat: p.lat, lng: p.lng, label: p.label, photoId: i === 0 ? 7 : null,
+ })),
+ })
+ expect(isStale(el, stats)).toBe(true)
+ })
+})
diff --git a/client/tests/unit/studio/mapTiles.test.ts b/client/tests/unit/studio/mapTiles.test.ts
index 0bab474d1..d392ee8df 100644
--- a/client/tests/unit/studio/mapTiles.test.ts
+++ b/client/tests/unit/studio/mapTiles.test.ts
@@ -49,13 +49,16 @@ describe('tileView', () => {
})
/*
- * A 12×12 grid is 144 requests for a picture 60mm across. The zoom backs off
- * until the grid is a size worth printing.
+ * The budget is 16×16. It was 8×8, which sounds thriftier and was not: every
+ * zoom level quarters the ground a tile covers, so a page-sized map lands at
+ * 7×9 and the step that makes it printable needs 13×17. A budget of 64, or
+ * even 144, stops one level short of a page worth printing — see
+ * mapPrintResolution.test.ts, which pins the dots per inch this buys.
*/
it('never asks for more tiles than a printed map can use', () => {
for (const z of [null, 19]) {
const view = tileView(iceland, frame, OSM, z)!
- expect(view.tiles.length, `zoom ${z}`).toBeLessThanOrEqual(64)
+ expect(view.tiles.length, `zoom ${z}`).toBeLessThanOrEqual(256)
}
})
diff --git a/client/tests/unit/studio/pageEdges.test.tsx b/client/tests/unit/studio/pageEdges.test.tsx
new file mode 100644
index 000000000..e46145466
--- /dev/null
+++ b/client/tests/unit/studio/pageEdges.test.tsx
@@ -0,0 +1,68 @@
+import { describe, it, expect, vi } from 'vitest'
+import { fireEvent, render, screen } from '../../helpers/render'
+import { TrimField } from '../../../src/components/Studio/TrimField'
+import { PAGE_MAX_MM, PAGE_MIN_MM } from '../../../src/components/Studio/pagePresets'
+
+/**
+ * The bleed and the safe margin, which the press decides and not us (#1973).
+ *
+ * Both were hard-coded into every page preset at the values a photo-book vendor
+ * most often asks for, 3mm and 5mm, and neither had a control anywhere. That is
+ * fine until a printer asks for 5 and 10, at which point the book cannot be made
+ * here at all — the export would carry the wrong bleed to the press and the
+ * crop marks would sit in the wrong place, which is the kind of mistake nobody
+ * finds until the guillotine has already run.
+ *
+ * They share the trim size's field, which is right — they are millimetres of
+ * the same kind — but that field's floor was the smallest page a book may be.
+ * A 3mm bleed against a 60mm floor is untypeable, so the range had to become a
+ * property of the measurement rather than of the control. That is what these
+ * cases pin.
+ */
+
+describe('a measurement field', () => {
+ it('still keeps the page inside what a press takes, by default', () => {
+ const onCommit = vi.fn()
+ render( )
+ const input = screen.getByRole('spinbutton')
+ expect(input.getAttribute('min')).toBe(String(PAGE_MIN_MM))
+ expect(input.getAttribute('max')).toBe(String(PAGE_MAX_MM))
+ })
+
+ it('takes a range of its own, so a 3mm bleed can be typed at all', () => {
+ const onCommit = vi.fn()
+ render( )
+ const input = screen.getByRole('spinbutton')
+ expect(input.getAttribute('min')).toBe('0')
+ expect(input.getAttribute('max')).toBe('20')
+
+ fireEvent.change(input, { target: { value: '5' } })
+ expect(onCommit).toHaveBeenCalledWith(5)
+ })
+
+ /*
+ * The reason the field holds a draft at all: on the page size, typing "500"
+ * used to be seen as "5", clamped to the floor and written back under the
+ * cursor. The same trap applies to a bleed typed as "10".
+ */
+ it('lets a number be typed through a value it would otherwise refuse', () => {
+ const onCommit = vi.fn()
+ render( )
+ const input = screen.getByRole('spinbutton')
+
+ fireEvent.change(input, { target: { value: '1' } })
+ expect(onCommit).not.toHaveBeenCalled()
+ fireEvent.change(input, { target: { value: '10' } })
+ expect(onCommit).toHaveBeenCalledWith(10)
+ })
+
+ it('commits what is there when the field is left', () => {
+ const onCommit = vi.fn()
+ render( )
+ const input = screen.getByRole('spinbutton')
+
+ fireEvent.change(input, { target: { value: '7.5' } })
+ fireEvent.blur(input)
+ expect(onCommit).toHaveBeenLastCalledWith(7.5)
+ })
+})
diff --git a/client/tests/unit/studio/printSheets.test.ts b/client/tests/unit/studio/printSheets.test.ts
index 1ae7f4219..3f4b13537 100644
--- a/client/tests/unit/studio/printSheets.test.ts
+++ b/client/tests/unit/studio/printSheets.test.ts
@@ -46,6 +46,24 @@ describe('the document', () => {
expect(frame().srcdoc).toContain('@page { size: 224mm 303mm; margin: 0; }')
})
+ /*
+ * Hyphenation is off in every browser until the language is known, so a
+ * heading the editor had broken as "Lorem ip-sum" printed unbroken, ran past
+ * its frame and was clipped. The editor's own lang is what makes the export
+ * agree with what was proofread.
+ */
+ it('carries the app language over, which is what lets the export hyphenate', () => {
+ document.documentElement.lang = 'de'
+ open()
+ expect(frame().srcdoc).toContain('')
+ })
+
+ it('falls back to English rather than leaving the language unsaid', () => {
+ document.documentElement.lang = ''
+ open()
+ expect(frame().srcdoc).toContain('')
+ })
+
it('puts the sheets in the body', () => {
open({ html: 'page one
' })
expect(frame().srcdoc).toContain('page one
')
diff --git a/client/tests/unit/studio/resolveBindings.test.ts b/client/tests/unit/studio/resolveBindings.test.ts
new file mode 100644
index 000000000..ecc39f63b
--- /dev/null
+++ b/client/tests/unit/studio/resolveBindings.test.ts
@@ -0,0 +1,209 @@
+import { describe, it, expect } from 'vitest'
+import type { BookDocument, BookElement } from '@trek/shared'
+import { normalizeBookDocument } from '@trek/shared'
+import {
+ resolveBindings, coordValue, type BindingSource,
+} from '../../../src/components/Studio/resolveBindings'
+
+/**
+ * A bound element catching up with the journal (#1973).
+ *
+ * The contract has promised since the format was written that fixing a typo in
+ * an entry fixes it in the book, and nothing did it: the words placed from the
+ * Entries tab were a copy taken once. What is protected here is that the link
+ * now works AND that it is not eager — a resolver that rewrote more than it
+ * should would repaint pages people have already set, and because the editor
+ * autosaves, it would write those repaints down.
+ *
+ * The identity assertion at the end is the one to keep. `useBookStore` decides
+ * whether a document is an edit by comparing object references, so a resolver
+ * that always returned a new object would turn opening a book into saving it.
+ */
+
+const source: BindingSource = {
+ title: 'Iceland, all the way round',
+ subtitle: 'Two weeks in June',
+ entries: [{
+ id: 7,
+ title: 'The black beach',
+ story: ' Wind, and then more wind. ',
+ location: 'Reynisfjara',
+ date: '2026-06-12',
+ lat: 63.4027,
+ lng: -19.0448,
+ }],
+ photos: [{ photoId: 31, caption: 'Basalt columns' }],
+}
+
+const common = { rotation: 0, opacity: 1, locked: false, frame: { x: 10, y: 10, w: 80, h: 12 } }
+
+function text(over: Record): BookElement {
+ return {
+ ...common,
+ id: 't1', kind: 'text', text: '', font: 'sans', size: 11, weight: 400, italic: false,
+ align: 'left', leading: 1.45, tracking: 0, color: '#1a1a1a',
+ binding: null, overridden: false, ...over,
+ } as unknown as BookElement
+}
+
+function doc(elements: BookElement[]): BookDocument {
+ return normalizeBookDocument({
+ version: 1,
+ title: 'A book',
+ spreads: [{ id: 's1', role: 'inner', background: null, elements, parked: [], entryId: null }],
+ })
+}
+
+const firstText = (d: BookDocument) => d.spreads[0].elements[0] as Extract
+
+describe('what a bound element picks up', () => {
+ it('takes the entry title someone has since corrected', () => {
+ const before = doc([text({ text: 'TEST1', binding: { source: 'entry.title', entryId: 7 } })])
+ expect(firstText(resolveBindings(before, source, 'en-GB')).text).toBe('The black beach')
+ })
+
+ it('takes the story, trimmed the way the layout trims it', () => {
+ const before = doc([text({ text: 'old', binding: { source: 'entry.story', entryId: 7 } })])
+ expect(firstText(resolveBindings(before, source, 'en-GB')).text).toBe('Wind, and then more wind.')
+ })
+
+ it('falls back to the place name for a stop nobody has titled', () => {
+ const src = { ...source, entries: [{ ...source.entries[0], title: null }] }
+ const before = doc([text({ text: 'old', binding: { source: 'entry.title', entryId: 7 } })])
+ expect(firstText(resolveBindings(before, src, 'en-GB')).text).toBe('Reynisfjara')
+ })
+
+ it('follows a renamed journey on the cover', () => {
+ const before = doc([text({ text: 'Untitled', binding: { source: 'journey.title' } })])
+ expect(firstText(resolveBindings(before, source, 'en-GB')).text).toBe('Iceland, all the way round')
+ })
+
+ it('follows a photo caption', () => {
+ const before = doc([text({ text: 'old', binding: { source: 'photo.caption', entryId: 7, photoId: 31 } })])
+ expect(firstText(resolveBindings(before, source, 'en-GB')).text).toBe('Basalt columns')
+ })
+})
+
+describe('what it refuses to touch', () => {
+ it('leaves an element alone once a human has edited it', () => {
+ const before = doc([text({
+ text: 'My own words', overridden: true, binding: { source: 'entry.title', entryId: 7 },
+ })])
+ expect(resolveBindings(before, source, 'en-GB')).toBe(before)
+ })
+
+ /*
+ * A journal entry that has been deleted must not take a page down with it.
+ * Blanking the words would turn a change in the journal into a hole in a book
+ * nobody would connect back to it.
+ */
+ it('leaves an element whose entry is gone', () => {
+ const before = doc([text({ text: 'The black beach', binding: { source: 'entry.title', entryId: 999 } })])
+ expect(resolveBindings(before, source, 'en-GB')).toBe(before)
+ })
+
+ it('leaves a heading standing when the title has been cleared', () => {
+ const cleared = { ...source, entries: [{ ...source.entries[0], title: null, location: null }] }
+ const before = doc([text({ text: 'A heading somebody set', binding: { source: 'entry.title', entryId: 7 } })])
+ // The same element does follow a title that still exists, so the case
+ // below is the empty answer being refused rather than nothing happening.
+ expect(firstText(resolveBindings(before, source, 'en-GB')).text).toBe('The black beach')
+ expect(resolveBindings(before, cleared, 'en-GB')).toBe(before)
+ })
+})
+
+describe('a date, which is formatted rather than copied', () => {
+ it('is left alone when the element predates the stored raw value', () => {
+ // Every date in every book made so far is in this state, and most of them
+ // are set in another language or in small caps by the auto layout. Reading
+ // them would rewrite pages that are finished.
+ const before = doc([text({ text: 'MAY 28, 2026', binding: { source: 'entry.date', entryId: 7 } })])
+ expect(resolveBindings(before, source, 'de-DE')).toBe(before)
+ })
+
+ it('is left alone when the day has not moved, whatever language the reader has', () => {
+ const before = doc([text({
+ text: '12 June 2026',
+ binding: { source: 'entry.date', entryId: 7, value: '2026-06-12' },
+ })])
+ expect(resolveBindings(before, source, 'de-DE')).toBe(before)
+ })
+
+ it('is re-set when the day itself moved', () => {
+ const src = { ...source, entries: [{ ...source.entries[0], date: '2026-06-14' }] }
+ const before = doc([text({
+ text: '12 June 2026',
+ binding: { source: 'entry.date', entryId: 7, value: '2026-06-12' },
+ })])
+ const after = firstText(resolveBindings(before, src, 'en-GB'))
+ expect(after.text).toBe('14 June 2026')
+ // And the raw value moves with it, or the next open would do it all again.
+ expect(after.binding?.value).toBe('2026-06-14')
+ })
+})
+
+describe('coordinates', () => {
+ it('are re-set when the stop moved, in the format the element was placed with', () => {
+ const src = { ...source, entries: [{ ...source.entries[0], lat: 64.1466, lng: -21.9426 }] }
+ const before = doc([text({
+ text: `63° 24' 10" S 19° 2' 41" W`,
+ binding: {
+ source: 'entry.location', entryId: 7, format: 'decimal', value: coordValue(63.4027, -19.0448),
+ },
+ })])
+ const after = firstText(resolveBindings(before, src, 'en-GB'))
+ expect(after.text).toBe('64.1466° N 21.9426° W')
+ expect(after.binding?.value).toBe('64.1466,-21.9426')
+ })
+
+ it('are left alone when the stop has not moved', () => {
+ const before = doc([text({
+ text: 'anything at all',
+ binding: {
+ source: 'entry.location', entryId: 7, format: 'dms', value: coordValue(63.4027, -19.0448),
+ },
+ })])
+ expect(resolveBindings(before, source, 'en-GB')).toBe(before)
+ })
+
+ /*
+ * The same source without a format is the place *name* — that is what the
+ * "Place" chip has always placed, and it is words rather than a reading, so
+ * it follows the journal directly.
+ */
+ it('are not what an unformatted location binding means', () => {
+ const before = doc([text({ text: 'Vík', binding: { source: 'entry.location', entryId: 7 } })])
+ expect(firstText(resolveBindings(before, source, 'en-GB')).text).toBe('Reynisfjara')
+ })
+})
+
+describe('the document it hands back', () => {
+ it('is the very same object when nothing resolved differently', () => {
+ const before = doc([
+ text({ id: 't1', text: 'The black beach', binding: { source: 'entry.title', entryId: 7 } }),
+ text({ id: 't2', text: 'Iceland, all the way round', binding: { source: 'journey.title' } }),
+ ])
+ expect(resolveBindings(before, source, 'en-GB')).toBe(before)
+ })
+
+ it('shares through every spread and element it did not change', () => {
+ const before = normalizeBookDocument({
+ version: 1,
+ title: 'A book',
+ spreads: [
+ {
+ id: 's1', role: 'inner', background: null, entryId: null, parked: [],
+ elements: [text({ id: 'keep', text: 'Iceland, all the way round', binding: { source: 'journey.title' } })],
+ },
+ {
+ id: 's2', role: 'inner', background: null, entryId: null, parked: [],
+ elements: [text({ id: 'move', text: 'TEST1', binding: { source: 'entry.title', entryId: 7 } })],
+ },
+ ],
+ })
+ const after = resolveBindings(before, source, 'en-GB')
+ expect(after).not.toBe(before)
+ expect(after.spreads[0]).toBe(before.spreads[0])
+ expect(after.spreads[1]).not.toBe(before.spreads[1])
+ })
+})
diff --git a/client/tests/unit/studio/travelRefresh.test.ts b/client/tests/unit/studio/travelRefresh.test.ts
index bb10bac80..9133054b3 100644
--- a/client/tests/unit/studio/travelRefresh.test.ts
+++ b/client/tests/unit/studio/travelRefresh.test.ts
@@ -23,9 +23,10 @@ const stats: JourneyStats = {
{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' },
],
points: [
- { lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS' },
- { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS' },
+ { lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: null, photoId: null },
+ { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: null, photoId: null },
],
+ trips: [],
start: '2026-06-02',
end: '2026-06-15',
}
@@ -193,10 +194,12 @@ describe('refreshPatch', () => {
expect((patch as { countries: string[] }).countries).toEqual(['IS'])
})
- it('carries only lat, lng and label onto the map, not the whole stats point', () => {
+ it('carries only what a map draws with, not the whole stats point', () => {
const patch = refreshPatch(mapEl({ points: [] }), stats)!
+ // The date, the country and the trip stay behind; the photograph comes
+ // along because a marker can be a picture of the stop.
expect((patch as { points: object[] }).points[0]).toEqual({
- lat: 64.14, lng: -21.94, label: 'Reykjavík',
+ lat: 64.14, lng: -21.94, label: 'Reykjavík', photoId: null,
})
})
diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts
index 246761ee2..70b5d2d9e 100644
--- a/server/src/bootstrap.ts
+++ b/server/src/bootstrap.ts
@@ -111,10 +111,31 @@ export async function buildApp(): Promise {
const rawBodyKeeper = (req: Request, _res: Response, buffer: Buffer) => {
if (Buffer.isBuffer(buffer)) (req as Request & { rawBody?: Buffer }).rawBody = buffer;
};
+ /*
+ * ── Why one route gets a larger body than the rest ───────────────────
+ *
+ * A hundred kilobytes is the right ceiling for an API of forms and ids, and
+ * it is the wrong one for a photo book. A Studio document is sent WHOLE on
+ * every autosave — deliberately, see book-store.schema.ts, because a patch
+ * protocol would need an ordering guarantee and a merge rule per field — and
+ * the contract already caps it at 150 spreads of 60 elements. A real book of
+ * a fortnight's journey is well past a hundred kilobytes before anybody asks
+ * for road geometry, and what the ceiling produces is not an error message
+ * but a save that quietly fails and an editor that says "not saved" without
+ * saying why.
+ *
+ * So the book route, and only the book route, is measured against the size a
+ * book can actually be. Everything else keeps the tighter limit.
+ */
+ const bookBody = express.json({ limit: '8mb', verify: rawBodyKeeper });
const json = express.json({ limit: '100kb', verify: rawBodyKeeper });
const urlencoded = express.urlencoded({ limit: '100kb', extended: true, verify: rawBodyKeeper });
const isMcp = (req: Request) => req.path === '/mcp' || req.path === '/mcp/';
+ const isBookWrite = (req: Request) =>
+ req.method === 'PUT' && /^\/api\/journeys\/\d+\/book$/.test(req.path);
+
instance.use(function jsonParser(req: Request, res: Response, next: NextFunction) {
+ if (isBookWrite(req)) return bookBody(req, res, next);
return isMcp(req) ? next() : json(req, res, next);
});
instance.use(function urlencodedParser(req: Request, res: Response, next: NextFunction) {
diff --git a/server/src/nest/journey/journey-domain.service.ts b/server/src/nest/journey/journey-domain.service.ts
index f076150cc..e59a9d82d 100644
--- a/server/src/nest/journey/journey-domain.service.ts
+++ b/server/src/nest/journey/journey-domain.service.ts
@@ -889,20 +889,32 @@ export class JourneyDomainService {
if (!this.canAccessJourney(journeyId, userId)) return null;
const entryRows = this.db.prepare(`
- SELECT id, title, location_name, location_lat, location_lng, entry_date
+ SELECT id, title, location_name, location_lat, location_lng, entry_date, source_trip_id
FROM journey_entries
WHERE journey_id = ?
ORDER BY entry_date ASC, sort_order ASC, id ASC
`).all(journeyId) as {
id: number; title: string | null; location_name: string | null;
location_lat: number | null; location_lng: number | null; entry_date: string | null;
+ source_trip_id: number | null;
}[];
- const tripDates = this.db.prepare(`
- SELECT t.start_date AS start, t.end_date AS end
+ /*
+ * The trips themselves, named and dated.
+ *
+ * Ordered by the trip's own start date: `journey_trips` had a `sort_order`
+ * for one migration and has not had one since 87, so the link row carries
+ * no order to read. Undated trips sort last rather than first, where an
+ * empty string would otherwise put them.
+ */
+ const tripRows = this.db.prepare(`
+ SELECT t.id, t.title, t.start_date AS start, t.end_date AS end
FROM journey_trips jt JOIN trips t ON t.id = jt.trip_id
WHERE jt.journey_id = ?
- `).all(journeyId) as { start: string | null; end: string | null }[];
+ ORDER BY t.start_date IS NULL, t.start_date ASC, t.id ASC
+ `).all(journeyId) as { id: number; title: string | null; start: string | null; end: string | null }[];
+
+ const tripDates = tripRows.map(t => ({ start: t.start, end: t.end }));
// Ordered the way the trip is walked: by day, then by the order within it.
// A place can be assigned to more than one day (a hotel across three nights
@@ -910,7 +922,7 @@ export class JourneyDomainService {
// one row per place at its earliest day — otherwise the route would visit
// the hotel three times and the distance would count those legs.
const placeRows = this.db.prepare(`
- SELECT p.id, p.name, p.lat, p.lng,
+ SELECT p.id, p.name, p.lat, p.lng, p.trip_id AS tripId,
MIN(d.date) AS day,
MIN(da.order_index) AS ord
FROM journey_trips jt
@@ -921,7 +933,7 @@ export class JourneyDomainService {
GROUP BY p.id
ORDER BY day IS NULL, day ASC, ord ASC, p.id ASC
`).all(journeyId) as {
- id: number; name: string | null; lat: number | null; lng: number | null;
+ id: number; name: string | null; lat: number | null; lng: number | null; tripId: number | null;
day: string | null; ord: number | null;
}[];
@@ -935,6 +947,41 @@ export class JourneyDomainService {
.prepare('SELECT COUNT(*) AS n FROM journey_photos WHERE journey_id = ?')
.get(journeyId) as { n: number };
+ /*
+ * ── A photograph per stop, for a map that marks them with pictures ───
+ *
+ * The truthful source is the junction: the photos somebody actually
+ * attached to that entry, earliest first, videos excluded because a video
+ * poster inside a four-millimetre circle is not a photograph.
+ */
+ const entryPhotoRows = this.db.prepare(`
+ SELECT jep.entry_id AS entryId, gp.photo_id AS photoId
+ FROM journey_entry_photos jep
+ JOIN journey_photos gp ON gp.id = jep.journey_photo_id
+ JOIN trek_photos tp ON tp.id = gp.photo_id
+ WHERE gp.journey_id = ?
+ AND (tp.media_type IS NULL OR tp.media_type = 'image')
+ ORDER BY jep.entry_id ASC, jep.sort_order ASC, gp.sort_order ASC, gp.id ASC
+ `).all(journeyId) as { entryId: number; photoId: number }[];
+
+ const photoByEntry = new Map();
+ for (const r of entryPhotoRows) if (!photoByEntry.has(r.entryId)) photoByEntry.set(r.entryId, r.photoId);
+
+ /*
+ * ── And no second tier, deliberately ─────────────────────────────────
+ *
+ * The obvious fallback is to hand the journey's gallery out in blocks, the
+ * way the auto layout hands photographs to pages, so that a journey with
+ * fifty-seven pictures and an empty junction still gets pictures on its
+ * map. It was written that way first and it was wrong: a marker at
+ * Akureyri showing a photograph taken in Vík is a caption that lies, and a
+ * map is exactly where a reader trusts that a picture is OF the place it
+ * sits on.
+ *
+ * So a stop shows its own first photograph or it shows a number. The
+ * number is not a degraded state; it is the honest one.
+ */
+
// The cached country per place, for whichever of them Atlas has seen.
const cachedCountry = new Map();
const placeIds = placeRows.map(p => p.id);
@@ -963,6 +1010,8 @@ export class JourneyDomainService {
label: e.title || e.location_name || '',
date: e.entry_date ?? null,
country: countryAt(e.location_lat as number, e.location_lng as number),
+ tripId: e.source_trip_id ?? null,
+ photoId: photoByEntry.get(e.id) ?? null,
}));
const points: StatsInputPoint[] = fromEntries.length
@@ -975,8 +1024,26 @@ export class JourneyDomainService {
label: p.name || '',
date: p.day ?? null,
country: countryAt(p.lat as number, p.lng as number, p.id),
+ tripId: p.tripId ?? null,
+ // A trip place carries `image_url`, which is a provider photo behind
+ // its own attribution rather than a trek_photos id. It must not be
+ // smuggled into a field the book will print as one of the journey's
+ // own pictures.
+ photoId: null,
}));
+ /*
+ * How many of the route's stops each trip owns.
+ *
+ * Counted rather than assumed: a linked trip whose entries nobody wrote,
+ * or whose places carry no coordinates, contributes nothing to the line and
+ * offering a map of it would produce an empty frame.
+ */
+ const perTrip = new Map();
+ for (const p of points) {
+ if (p.tripId != null) perTrip.set(p.tripId, (perTrip.get(p.tripId) ?? 0) + 1);
+ }
+
return computeJourneyStats({
journeyId,
points,
@@ -985,6 +1052,13 @@ export class JourneyDomainService {
places: placeCount?.n ?? 0,
tripDates,
countryNames: countryNamesFor(points),
+ trips: tripRows.map(t => ({
+ id: t.id,
+ title: t.title || '',
+ start: t.start,
+ end: t.end,
+ points: perTrip.get(t.id) ?? 0,
+ })),
});
}
diff --git a/server/src/nest/journey/journey-stats.ts b/server/src/nest/journey/journey-stats.ts
index 845f92ec3..1a66feb72 100644
--- a/server/src/nest/journey/journey-stats.ts
+++ b/server/src/nest/journey/journey-stats.ts
@@ -1,4 +1,4 @@
-import type { JourneyStats, JourneyStatsCountry, JourneyStatsPoint } from '@trek/shared';
+import type { JourneyStats, JourneyStatsCountry, JourneyStatsPoint, JourneyStatsTrip } from '@trek/shared';
/**
* What a journey adds up to — the arithmetic, with no database and no Nest.
@@ -23,6 +23,10 @@ export interface StatsInputPoint {
/** ISO date, when the stop has one. */
date: string | null;
country: string | null;
+ /** The trip this stop belongs to, when the journey knows. */
+ tripId?: number | null;
+ /** One photograph from this stop, for a map that marks it with a picture. */
+ photoId?: number | null;
}
const rad = (deg: number) => (deg * Math.PI) / 180;
@@ -160,6 +164,8 @@ export interface StatsInput {
/** Dates from the trips, used when the entries carry none. */
tripDates: { start: string | null; end: string | null }[];
countryNames: Record;
+ /** The trips themselves, so a book can print a map of one of them. */
+ trips?: JourneyStatsTrip[];
}
/**
@@ -198,6 +204,20 @@ export function computeJourneyStats(input: StatsInput): JourneyStats {
label: p.label,
date: p.date,
country: p.country,
+ tripId: p.tripId ?? null,
+ photoId: p.photoId ?? null,
+ })),
+ /*
+ * Counted after thinning, not before.
+ *
+ * `thinRoute` drops every n-th stop on a long route, so a trip that had
+ * forty stops and kept nine has nine on the map. A panel offering "Iceland
+ * (40)" and then drawing nine would be counting something the reader
+ * cannot see.
+ */
+ trips: (input.trips ?? []).map(t => ({
+ ...t,
+ points: points.filter(p => p.tripId === t.id).length,
})),
start,
end,
diff --git a/server/tests/integration/book-body-size.test.ts b/server/tests/integration/book-body-size.test.ts
new file mode 100644
index 000000000..1d8525ab5
--- /dev/null
+++ b/server/tests/integration/book-body-size.test.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import request from 'supertest';
+import type { INestApplication } from '@nestjs/common';
+import { buildApp } from '../../src/bootstrap';
+
+/**
+ * A book is bigger than a form post (#1973).
+ *
+ * The API's body ceiling is a hundred kilobytes, which is right for an
+ * interface of forms and ids and wrong for a photo book. A Studio document is
+ * sent WHOLE on every autosave, on purpose, and a fortnight's journey passes a
+ * hundred kilobytes before anybody asks for road geometry: an Iceland book with
+ * roads measured 101KB against a 100KB limit.
+ *
+ * What that produced was not an error anybody could act on. The save failed
+ * with a 413, the editor said "not saved", and nothing anywhere said why —
+ * which is the same shape of bug as the mood mark that could not be stored, and
+ * it deserves the same kind of guard.
+ */
+
+describe('the size of a book', () => {
+ let app: INestApplication;
+
+ beforeAll(async () => {
+ app = await buildApp();
+ await app.init();
+ });
+
+ afterAll(async () => {
+ await app?.close();
+ });
+
+ /** A body far past the ordinary ceiling, shaped like a save. */
+ const bigBody = () => ({
+ title: 'A long journey',
+ baseVersion: 1,
+ document: { version: 1, title: 'A long journey', spreads: [], filler: 'x'.repeat(400_000) },
+ });
+
+ it('takes a document larger than the API-wide limit rather than refusing it', async () => {
+ const res = await request(app.getHttpServer())
+ .put('/api/journeys/1/book')
+ .send(bigBody());
+
+ /*
+ * Unauthenticated, so 401 is the expected answer and the point of the test
+ * is what it is NOT: a 413 means the body never reached the router, which
+ * is exactly the failure being guarded against.
+ */
+ expect(res.status).not.toBe(413);
+ });
+
+ it('keeps the tighter limit on every other route', async () => {
+ const res = await request(app.getHttpServer())
+ .post('/api/journeys')
+ .send({ title: 'x'.repeat(400_000) });
+
+ expect(res.status).toBe(413);
+ });
+});
diff --git a/server/tests/unit/nest/journey-stats-trips.test.ts b/server/tests/unit/nest/journey-stats-trips.test.ts
new file mode 100644
index 000000000..4c66fa644
--- /dev/null
+++ b/server/tests/unit/nest/journey-stats-trips.test.ts
@@ -0,0 +1,100 @@
+import { describe, it, expect } from 'vitest';
+import { computeJourneyStats, type StatsInputPoint } from '../../../src/nest/journey/journey-stats';
+
+/**
+ * Which trip each stop belongs to, and what that lets a book print (#1973).
+ *
+ * A journey is a collection of trips. Until now the route was one flat list, so
+ * a book could only ever print a single map of everything — and on a journey
+ * that went to Iceland in June and Portugal in September, "everything" means a
+ * line across the Atlantic that nobody travelled, usually the longest one on
+ * the page.
+ *
+ * The count per trip is the load-bearing part: a panel offers a map per trip
+ * and has to know which of them would come out empty, and it has to count what
+ * survives thinning rather than what went in.
+ */
+
+const point = (over: Partial = {}): StatsInputPoint => ({
+ lat: 64, lng: -22, label: '', date: null, country: null, tripId: null, ...over,
+});
+
+const base = {
+ journeyId: 1,
+ entries: 0,
+ photos: 0,
+ places: 0,
+ tripDates: [],
+ countryNames: { IS: 'Iceland', PT: 'Portugal' },
+};
+
+describe('the trips behind a journey', () => {
+ it('carries the trip onto every stop of the route', () => {
+ const stats = computeJourneyStats({
+ ...base,
+ points: [
+ point({ lat: 64, lng: -22, tripId: 11 }),
+ point({ lat: 38, lng: -9, tripId: 22 }),
+ ],
+ });
+ expect(stats.points.map(p => p.tripId)).toEqual([11, 22]);
+ });
+
+ it('leaves the trip null for a stop nobody linked to one', () => {
+ const stats = computeJourneyStats({ ...base, points: [point()] });
+ expect(stats.points[0].tripId).toBeNull();
+ });
+
+ it('counts how many stops each trip put on the route', () => {
+ const stats = computeJourneyStats({
+ ...base,
+ points: [
+ point({ lat: 64, lng: -22, tripId: 11 }),
+ point({ lat: 65, lng: -18, tripId: 11 }),
+ point({ lat: 38, lng: -9, tripId: 22 }),
+ ],
+ trips: [
+ { id: 11, title: 'Iceland', start: '2026-06-02', end: '2026-06-14', points: 0 },
+ { id: 22, title: 'Portugal', start: '2026-09-04', end: '2026-09-12', points: 0 },
+ ],
+ });
+ expect(stats.trips.map(t => [t.title, t.points])).toEqual([['Iceland', 2], ['Portugal', 1]]);
+ });
+
+ /*
+ * A linked trip whose places carry no coordinates contributes nothing to the
+ * line, and a panel that offered a map of it would place an empty frame.
+ */
+ it('reports zero for a linked trip that put nothing on the route', () => {
+ const stats = computeJourneyStats({
+ ...base,
+ points: [point({ tripId: 11 })],
+ trips: [
+ { id: 11, title: 'Iceland', start: null, end: null, points: 0 },
+ { id: 33, title: 'A trip nobody wrote about', start: null, end: null, points: 0 },
+ ],
+ });
+ expect(stats.trips.find(t => t.id === 33)!.points).toBe(0);
+ });
+
+ /*
+ * Counted after thinning rather than before. A trip with four hundred and
+ * one stops keeps four hundred, and a panel saying 401 would be counting
+ * something the map does not draw.
+ */
+ it('counts the stops that survive the route budget, not the ones that went in', () => {
+ const many = Array.from({ length: 500 }, (_, i) => point({ lat: 60 + i * 0.01, lng: -20, tripId: 11 }));
+ const stats = computeJourneyStats({
+ ...base,
+ points: many,
+ trips: [{ id: 11, title: 'A long one', start: null, end: null, points: 0 }],
+ });
+ expect(stats.points).toHaveLength(400);
+ expect(stats.trips[0].points).toBe(400);
+ });
+
+ it('has an empty trip list when it was given none', () => {
+ const stats = computeJourneyStats({ ...base, points: [point()] });
+ expect(stats.trips).toEqual([]);
+ });
+});
diff --git a/server/tests/unit/nest/journey-stats.test.ts b/server/tests/unit/nest/journey-stats.test.ts
index 2e49ee9ee..d3c394d1b 100644
--- a/server/tests/unit/nest/journey-stats.test.ts
+++ b/server/tests/unit/nest/journey-stats.test.ts
@@ -329,7 +329,10 @@ describe('computeJourneyStats', () => {
points: [point({ lat: 64, lng: -22, label: 'Reykjavík', date: '2026-06-02', country: 'IS' })],
});
expect(stats.points[0]).toEqual({
- lat: 64, lng: -22, label: 'Reykjavík', date: '2026-06-02', country: 'IS',
+ // `tripId` is null when the stop came from an entry nobody linked to a
+ // trip, which is every stop here — the field is what lets a book print a
+ // map of one trip out of several.
+ lat: 64, lng: -22, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: null, photoId: null,
});
});
@@ -357,6 +360,7 @@ describe('computeJourneyStats', () => {
furthest: 0,
countries: [],
points: [],
+ trips: [],
start: null,
end: null,
});
diff --git a/server/vitest.config.ts b/server/vitest.config.ts
index 5cecf247c..3c1f3e246 100644
--- a/server/vitest.config.ts
+++ b/server/vitest.config.ts
@@ -88,7 +88,7 @@ export default defineConfig({
'src/nest/health/**/*.ts': { statements: 99, branches: 65, functions: 99, lines: 99 },
'src/nest/help/**/*.ts': { statements: 81, branches: 70, functions: 99, lines: 86 },
'src/nest/integrations/**/*.ts': { statements: 72, branches: 56, functions: 76, lines: 74 },
- 'src/nest/journey/**/*.ts': { statements: 90, branches: 84, functions: 84, lines: 91 },
+ 'src/nest/journey/**/*.ts': { statements: 91, branches: 84, functions: 88, lines: 93 },
'src/nest/llm-parse/**/*.ts': { statements: 91, branches: 85, functions: 85, lines: 94 },
'src/nest/maps/**/*.ts': { statements: 93, branches: 86, functions: 97, lines: 96 },
'src/nest/memories/**/*.ts': { statements: 88, branches: 81, functions: 97, lines: 90 },
diff --git a/shared/src/book/book.schema.spec.ts b/shared/src/book/book.schema.spec.ts
new file mode 100644
index 000000000..1f29fc7b7
--- /dev/null
+++ b/shared/src/book/book.schema.spec.ts
@@ -0,0 +1,194 @@
+import {
+ MAX_SPREAD_ELEMENTS,
+ bookBadgeElementSchema,
+ bookIconElementSchema,
+ bookSpreadSchema,
+ bookTextElementSchema,
+ normalizeBookDocument,
+} from './book.schema';
+
+import { describe, expect, it } from 'vitest';
+
+/**
+ * What a stored book is allowed to be, checked from the side that has to cope
+ * with somebody else's data.
+ *
+ * Every book is read back through `normalizeBookDocument`: the editor calls it
+ * when it opens one, and the server calls it on the way in and again on the way
+ * out. So an element this contract cannot read is not a decoration that quietly
+ * goes missing, it is a book that opens blank and is then saved back blank.
+ * That is precisely what a mood mark did while badge codes were capped at two
+ * characters, which is why most of what follows asks what happened to the rest
+ * of the document rather than whether the bad element parsed.
+ *
+ * The narrower cases pin the fields the entry chips and the icon element added,
+ * in the shape old documents turn up in, which is without them.
+ */
+
+const frame = { x: 12, y: 18, w: 60, h: 40 };
+
+const text = (over: Record = {}) => ({ id: 't1', kind: 'text', frame, ...over });
+const badge = (over: Record = {}) => ({ id: 'b1', kind: 'badge', frame, ...over });
+const icon = (over: Record = {}) => ({ id: 'i1', kind: 'icon', frame, name: 'Compass', ...over });
+
+/** An element with a `kind` no version of the contract has ever known. */
+const unreadable = { id: 'x1', kind: 'sticker', frame };
+
+const doc = (elements: unknown[], parked: unknown[] = []) => ({
+ version: 1,
+ title: 'Iceland, end to end',
+ page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
+ spreads: [{ id: 's1', role: 'inner', background: null, entryId: 4, elements, parked }],
+});
+
+describe('a text element bound to the journey', () => {
+ it('carries the notation a coordinate chip was set in, and the value it read', () => {
+ const dms = bookTextElementSchema.parse(
+ text({ binding: { source: 'entry.location', entryId: 7, format: 'dms', value: '64.1466,-21.9426' } }),
+ );
+ expect(dms.binding!.format).toBe('dms');
+ expect(dms.binding!.value).toBe('64.1466,-21.9426');
+
+ const decimal = bookTextElementSchema.parse(
+ text({ binding: { source: 'entry.location', entryId: 7, format: 'decimal' } }),
+ );
+ expect(decimal.binding!.format).toBe('decimal');
+ });
+
+ it('refuses a notation nothing knows how to set the coordinates in', () => {
+ const parsed = bookTextElementSchema.safeParse(text({ binding: { source: 'entry.location', format: 'utm' } }));
+ expect(parsed.success).toBe(false);
+ });
+
+ it('leaves both keys off a binding written before they existed', () => {
+ const parsed = bookTextElementSchema.parse(text({ binding: { source: 'entry.date', entryId: 7 } }));
+ expect(parsed.binding!.source).toBe('entry.date');
+ // Absent has to stay absent, because absent is the answer to both
+ // questions: the place as the journal writes it, and a date the resolver
+ // must leave exactly as somebody set it.
+ expect('format' in parsed.binding!).toBe(false);
+ expect('value' in parsed.binding!).toBe(false);
+ });
+});
+
+describe('the icon element', () => {
+ it('defaults its colour and its weight so a name is enough to place one', () => {
+ const parsed = bookIconElementSchema.parse(icon());
+ expect(parsed.name).toBe('Compass');
+ expect(parsed.color).toBe('#111827');
+ expect(parsed.lineWidth).toBe(2);
+ });
+
+ it('keeps a chosen weight and holds it to the drawable range', () => {
+ expect(bookIconElementSchema.parse(icon({ lineWidth: 0.75 })).lineWidth).toBe(0.75);
+ expect(bookIconElementSchema.safeParse(icon({ lineWidth: 0.1 })).success).toBe(false);
+ expect(bookIconElementSchema.safeParse(icon({ lineWidth: 6 })).success).toBe(false);
+ });
+
+ it('takes a lucide export name and nothing that could not be one', () => {
+ expect(bookIconElementSchema.safeParse(icon({ name: 'MountainSnow' })).success).toBe(true);
+ expect(bookIconElementSchema.safeParse(icon({ name: 'compass' })).success).toBe(false);
+ expect(bookIconElementSchema.safeParse(icon({ name: 'mountain-snow' })).success).toBe(false);
+ expect(bookIconElementSchema.safeParse(icon({ name: '' })).success).toBe(false);
+ });
+});
+
+describe('a badge code', () => {
+ it('takes the journal key a mood or weather mark is drawn from', () => {
+ expect(bookBadgeElementSchema.parse(badge({ variant: 'mood', code: 'amazing' })).code).toBe('amazing');
+ expect(bookBadgeElementSchema.parse(badge({ variant: 'weather', code: 'partly' })).code).toBe('partly');
+ });
+
+ it('still takes a two-letter country code, which is what it was for', () => {
+ expect(bookBadgeElementSchema.parse(badge({ variant: 'flag', code: 'IS' })).code).toBe('IS');
+ expect(bookBadgeElementSchema.parse(badge({ variant: 'flag' })).code).toBeNull();
+ });
+
+ it('stops short of a code no mark could be keyed by', () => {
+ expect(bookBadgeElementSchema.safeParse(badge({ code: 'x'.repeat(25) })).success).toBe(false);
+ });
+
+ it('keeps a whole book that has a mood mark on a page', () => {
+ // The regression itself: this document refused to save, and the refusal
+ // was answered with an empty book rather than with an error.
+ const out = normalizeBookDocument(doc([badge({ variant: 'mood', code: 'amazing', text: 'Amazing' })]));
+ expect(out.spreads[0]!.elements).toHaveLength(1);
+ const mark = out.spreads[0]!.elements[0]!;
+ expect(mark.kind === 'badge' && mark.code).toBe('amazing');
+ });
+
+ it('draws its icon and its words for a badge written before the switches existed', () => {
+ const parsed = bookBadgeElementSchema.parse(badge({ variant: 'mood', code: 'good' }));
+ expect(parsed.showIcon).toBe(true);
+ expect(parsed.showLabel).toBe(true);
+ expect(parsed.autoIconColor).toBe(true);
+ expect(parsed.iconColor).toBe('#111111');
+ });
+
+ it('remembers a mark set to icon only in a chosen colour', () => {
+ const parsed = bookBadgeElementSchema.parse(
+ badge({ variant: 'mood', showLabel: false, autoIconColor: false, iconColor: '#c2410c' }),
+ );
+ expect(parsed.showIcon).toBe(true);
+ expect(parsed.showLabel).toBe(false);
+ expect(parsed.autoIconColor).toBe(false);
+ expect(parsed.iconColor).toBe('#c2410c');
+ });
+});
+
+describe('normalizeBookDocument', () => {
+ it('keeps the nine elements it can read when the tenth is from another version', () => {
+ const nine = Array.from({ length: 9 }, (_, i) => text({ id: `t${i + 1}`, text: `line ${i + 1}` }));
+ const out = normalizeBookDocument(doc([...nine, unreadable]));
+
+ expect(out.title).toBe('Iceland, end to end');
+ expect(out.spreads).toHaveLength(1);
+ expect(out.spreads[0]!.entryId).toBe(4);
+ expect(out.spreads[0]!.elements.map((el) => el.id)).toEqual(['t1', 't2', 't3', 't4', 't5', 't6', 't7', 't8', 't9']);
+ });
+
+ it('salvages the parked elements the same way it salvages the placed ones', () => {
+ const out = normalizeBookDocument(doc([text({ id: 'kept' })], [text({ id: 'parked-kept' }), unreadable]));
+
+ expect(out.spreads[0]!.elements.map((el) => el.id)).toEqual(['kept']);
+ expect(out.spreads[0]!.parked.map((el) => el.id)).toEqual(['parked-kept']);
+ });
+
+ it('trims a spread of seventy elements to the cap rather than losing the spread', () => {
+ const seventy = Array.from({ length: 70 }, (_, i) => text({ id: `t${i}` }));
+ const out = normalizeBookDocument(doc(seventy));
+
+ expect(out.spreads[0]!.elements).toHaveLength(MAX_SPREAD_ELEMENTS);
+ // Back to front, so the ones that go are the ones on top.
+ expect(out.spreads[0]!.elements[0]!.id).toBe('t0');
+ expect(out.title).toBe('Iceland, end to end');
+ });
+
+ it('leaves a document it can read alone', () => {
+ const input = doc([text({ id: 'a' }), badge({ id: 'b', variant: 'day', text: '5' })]);
+ const out = normalizeBookDocument(input);
+
+ expect(out.spreads[0]!.elements.map((el) => el.id)).toEqual(['a', 'b']);
+ expect(out.page.pageWidth).toBe(210);
+ });
+
+ it('hands back an empty book for something that is not a document at all', () => {
+ for (const junk of [undefined, null, 'a book, honestly', 42, [], { spreads: 'nope' }]) {
+ const out = normalizeBookDocument(junk);
+ expect(out.spreads).toEqual([]);
+ expect(out.title).toBe('');
+ expect(out.version).toBe(1);
+ }
+ });
+});
+
+describe('MAX_SPREAD_ELEMENTS', () => {
+ it('is the cap both element arrays are held to, so neither can drift from it', () => {
+ const many = (n: number) => Array.from({ length: n }, (_, i) => text({ id: `t${i}` }));
+
+ expect(bookSpreadSchema.safeParse({ id: 's1', elements: many(MAX_SPREAD_ELEMENTS) }).success).toBe(true);
+ expect(bookSpreadSchema.safeParse({ id: 's1', elements: many(MAX_SPREAD_ELEMENTS + 1) }).success).toBe(false);
+ expect(bookSpreadSchema.safeParse({ id: 's1', parked: many(MAX_SPREAD_ELEMENTS) }).success).toBe(true);
+ expect(bookSpreadSchema.safeParse({ id: 's1', parked: many(MAX_SPREAD_ELEMENTS + 1) }).success).toBe(false);
+ });
+});
diff --git a/shared/src/book/book.schema.ts b/shared/src/book/book.schema.ts
index 412cc6695..f1c603c24 100644
--- a/shared/src/book/book.schema.ts
+++ b/shared/src/book/book.schema.ts
@@ -157,6 +157,33 @@ export const bookTextElementSchema = z.object({
source: z.enum(['journey.title', 'journey.subtitle', 'entry.title', 'entry.story', 'entry.location', 'entry.date', 'photo.caption']),
entryId: z.number().int().optional(),
photoId: z.number().int().optional(),
+ /**
+ * How to set the source, where it can be set more than one way.
+ *
+ * Only `entry.location` uses it, and only because a stop is both a name
+ * and a point: absent means the place as it is written in the journal,
+ * `dms` and `decimal` mean its coordinates. It is a property of the
+ * binding rather than a second `source`, because both read the one field
+ * the journal actually records — and because a document written with an
+ * unknown *source* would fail the union and normalise to an empty book,
+ * while an unknown key inside the binding is dropped and the page
+ * survives. A print format is not worth a lost book.
+ */
+ format: z.enum(['dms', 'decimal']).optional(),
+ /**
+ * The raw value this element was last set from — an ISO date, a
+ * `lat,lng` pair — for the sources whose words are *formatted* rather
+ * than copied.
+ *
+ * Without it, re-reading a date cannot tell a journal that changed from a
+ * page that was set in another language, or in small caps, or as part of
+ * a longer line: it would rewrite all four alike, in the reader's locale,
+ * on open. With it the question is exactly the right one — has the entry
+ * moved? — and an element from before this existed carries no value and
+ * is therefore left alone, which is the only safe thing to do to a book
+ * somebody has already printed.
+ */
+ value: z.string().max(200).optional(),
})
.nullable()
.default(null),
@@ -290,6 +317,45 @@ export const bookMapElementSchema = z.object({
showRoute: z.boolean().default(true),
showPins: z.boolean().default(true),
showLabels: z.boolean().default(false),
+ /**
+ * ── How the line is drawn ────────────────────────────────────────────
+ *
+ * `plain` is one stroke in the element's ink: what every map in every book
+ * made so far already draws, and the default for exactly that reason.
+ *
+ * `drawn` is the printed-atlas treatment — a light line over a dark casing,
+ * or the reverse on pale paper — which is what keeps a single route legible
+ * across near-black bathymetry and bright desert in the same picture. It
+ * cannot be the default: a stored book would change the day it was opened.
+ */
+ routeStyle: z.enum(['plain', 'drawn']).default('plain'),
+ /**
+ * Whether a long leg bows.
+ *
+ * A straight line between two stops a continent apart is a claim about the
+ * journey that is obviously false, and drawing it as a shallow curve is how
+ * printed maps have always said "this part was a flight". Only ever applied
+ * to the inferred stop-to-stop line: bowing a recorded GPS track would draw
+ * a journey that did not happen.
+ */
+ routeArc: z.enum(['straight', 'bow']).default('straight'),
+ /**
+ * Whether the bowed legs are dashed.
+ *
+ * The dash then MEANS something — this leg is inferred, not recorded — which
+ * is why it applies to the bowed legs alone rather than to the whole line.
+ */
+ routeDash: z.enum(['solid', 'arcs']).default('solid'),
+ /**
+ * What a stop looks like.
+ *
+ * `photo` draws the stop as a small round picture with a white ring, from the
+ * photograph the point carries, and falls back to a dot where there is none.
+ * That fallback is the designed state rather than a failure: a printed page
+ * with a few plain dots between the pictures reads as a route, and one with
+ * empty rings reads as a bug.
+ */
+ pinStyle: z.enum(['dot', 'photo']).default('dot'),
/** ISO-3166-1 alpha-2, the countries whose outlines to draw. */
countries: z.array(z.string().length(2)).max(80).default([]),
/**
@@ -301,6 +367,14 @@ export const bookMapElementSchema = z.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
label: z.string().max(120).default(''),
+ /**
+ * One photograph from this stop, for a marker that shows it.
+ *
+ * `trek_photos.id`, never a resolved URL, for the same reason a photo
+ * element stores an id: a book has to survive a provider change. Null is
+ * ordinary and means the marker is a dot.
+ */
+ photoId: z.number().int().positive().nullable().default(null),
}))
.max(400)
.default([]),
@@ -326,6 +400,29 @@ export const bookMapElementSchema = z.object({
.array(z.array(z.tuple([z.number().min(-90).max(90), z.number().min(-180).max(180)])).max(1200))
.max(40)
.default([]),
+ /**
+ * The roads each leg actually took, when somebody asked for them.
+ *
+ * ── Why this is not `path` ───────────────────────────────────────────
+ *
+ * `path` is a recorded track: a GPS trace of where somebody went, which has
+ * no idea which stop it started at and is drawn instead of the stop chain.
+ * This is per leg — entry `i` is the way from stop `i` to stop `i + 1`, and
+ * `null` is a leg with no road answer, which is most journeys' flights.
+ *
+ * Per leg rather than one line, because a partial road route drawn as one
+ * would be a lie by omission: the renderer replaces the whole stop chain the
+ * moment it has a track, so a journey with roads for three legs out of twelve
+ * would print three legs and drop the other nine.
+ *
+ * Frozen into the document like every other travel figure. A page that asks a
+ * routing service for its line at print time is a page that changes when a
+ * road is rerouted, and prints empty when the export runs offline.
+ */
+ roads: z
+ .array(z.array(z.tuple([z.number().min(-90).max(90), z.number().min(-180).max(180)])).max(200).nullable())
+ .max(400)
+ .default([]),
/**
* How much room to leave around what is drawn, as a share of its own extent.
*
@@ -334,7 +431,15 @@ export const bookMapElementSchema = z.object({
* Country outlines are still drawn, they simply run off the edge like the
* geography they are.
*/
- fitPadding: z.number().min(0).max(4).default(0.18),
+ /*
+ * Half again the extent of the route, rather than a fifth.
+ *
+ * A map fitted tightly to its own stops is a diagram; the air around it is
+ * what makes it a place. The reference books all leave room for the coast to
+ * arrive and the neighbouring country to show, and a route that touches its
+ * own frame on two sides reads as cropped rather than as composed.
+ */
+ fitPadding: z.number().min(0).max(4).default(0.5),
/**
* Fit to the countries rather than to the stops.
*
@@ -359,6 +464,19 @@ export const bookMapElementSchema = z.object({
* shape of a country is an evenly coloured rectangle with a route on it.
*/
clip: z.enum(['rect', 'country']).default('rect'),
+ /**
+ * Which trip this map is of, when it is of one.
+ *
+ * Null is the whole journey, which is what a map has always been. A journey
+ * is a collection of trips, though, and printing two of them as one route
+ * draws a line from the last stop of the first to the first stop of the
+ * second: a leg nobody travelled, usually the longest one on the page.
+ *
+ * Stored rather than derived because the points are frozen into the element
+ * when it is placed. Without it, bringing a map up to date would quietly turn
+ * a map of Iceland back into a map of everywhere.
+ */
+ tripId: z.number().int().positive().nullable().default(null),
});
export const BOOK_METRICS = ['distance', 'days', 'steps', 'photos', 'countries', 'places', 'furthest'] as const;
@@ -413,8 +531,20 @@ export const bookBadgeElementSchema = z.object({
text: z.string().max(200).default(''),
/** The line under it — a month, a place, a unit. */
sub: z.string().max(200).default(''),
- /** ISO-3166-1 alpha-2 for the flag and country variants. */
- code: z.string().length(2).nullable().default(null),
+ /**
+ * What the mark is *of*, when the drawing needs more than the words.
+ *
+ * ISO-3166-1 alpha-2 for a flag or a country silhouette, and the journal's
+ * own key for a mood or a weather mark — `amazing`, `partly` — which is what
+ * lets the icon and the palette come from the journey page rather than being
+ * listed a second time here.
+ *
+ * It was two characters once, back when only flags used it, and a mood mark
+ * placed against that contract took the whole book down with it: the save was
+ * refused and the document normalised to nothing. A country code is still two
+ * characters; the field is simply no longer only that.
+ */
+ code: z.string().max(24).nullable().default(null),
style: z.enum(['plain', 'chip', 'outline', 'stacked']).default('plain'),
/**
* Let the mark pick its own text colour.
@@ -427,6 +557,59 @@ export const bookBadgeElementSchema = z.object({
* which is what happened before this existed.
*/
autoColor: z.boolean().default(true),
+ /**
+ * Whether the mark draws its icon, and whether it draws its words.
+ *
+ * Two flags rather than one three-valued mode, because that is what the
+ * renderer actually asks — and because a document written before they
+ * existed reads `undefined` as "draw it", which is what it always did. The
+ * panel offers the three combinations that mean something (both, icon only,
+ * words only); an empty mark is not one of them.
+ */
+ showIcon: z.boolean().default(true),
+ showLabel: z.boolean().default(true),
+ /**
+ * The icon's own colour, and whether it picks it.
+ *
+ * Automatic is a mood's palette from the journal — the pink of "amazing",
+ * the amber of "good" — and the element's accent for everything else, which
+ * is what these marks have always drawn. It has to stay a flag rather than a
+ * nullable colour: every badge already in a book carries `iconColor`'s
+ * default once it is parsed, and reading that as a choice would repaint every
+ * mood mark in the field to a flat near-black.
+ */
+ autoIconColor: z.boolean().default(true),
+ iconColor: hex.default('#111111'),
+});
+
+/**
+ * A drawing from the icon set the app is already built out of.
+ *
+ * Not a member of BOOK_SHAPES, and deliberately: a shape is a filled outline
+ * the editor scales by moving its points, while an icon is a stroked drawing of
+ * several parts that has to keep its proportions to stay legible — a compass
+ * stretched to twice its width stops reading as a compass. They also come from
+ * opposite places. A shape is drawn here and lives in this file; an icon is one
+ * of about fourteen hundred names in lucide, and listing them in the contract
+ * would mean a document that stops parsing the day the library renames one.
+ *
+ * So the name travels as a bounded string and the renderer resolves it, falling
+ * back the way the rest of TREK already does for a plugin's icon. A book that
+ * outlives an icon loses that drawing, not the page it was on.
+ */
+export const bookIconElementSchema = z.object({
+ ...elementBase,
+ kind: z.literal('icon'),
+ /** A lucide export name, PascalCase — "Compass", "Plane", "MountainSnow". */
+ name: z.string().regex(/^[A-Z][A-Za-z0-9]*$/, 'expected a lucide icon name').max(60),
+ color: hex.default('#111827'),
+ /**
+ * How heavy the drawing is, against lucide's own 24-unit grid — not
+ * millimetres, which is why it is not called `strokeWidth`: three separate
+ * places in the client rescale a field of that name by the page width when a
+ * spread moves between books, and an icon's weight is not a length.
+ */
+ lineWidth: z.number().min(0.25).max(4).default(2),
});
/**
@@ -457,6 +640,14 @@ export const bookListElementSchema = z.object({
conLabel: z.string().max(80).default(''),
});
+/**
+ * How much may stand on one spread.
+ *
+ * A limit rather than a taste: the document is saved whole on every autosave,
+ * and a page nobody could design is a page somebody's browser has to serialise.
+ */
+export const MAX_SPREAD_ELEMENTS = 60;
+
export const bookElementSchema = z.discriminatedUnion('kind', [
bookPhotoElementSchema,
bookTextElementSchema,
@@ -466,8 +657,10 @@ export const bookElementSchema = z.discriminatedUnion('kind', [
bookCountriesElementSchema,
bookBadgeElementSchema,
bookListElementSchema,
+ bookIconElementSchema,
]);
export type BookElement = z.infer;
+export type BookIconElement = z.infer;
export type BookPhotoElement = z.infer;
export type BookTextElement = z.infer;
export type BookShapeElement = z.infer;
@@ -484,7 +677,7 @@ export const bookSpreadSchema = z.object({
role: z.enum(['cover', 'back', 'inner']).default('inner'),
background: hex.nullable().default(null),
/** Back to front. */
- elements: z.array(bookElementSchema).max(60).default([]),
+ elements: z.array(bookElementSchema).max(MAX_SPREAD_ELEMENTS).default([]),
/**
* Content that belongs to this spread but is not currently placed.
*
@@ -494,7 +687,7 @@ export const bookSpreadSchema = z.object({
* everything on the page. Parked elements come back the moment a layout with
* room for them is applied.
*/
- parked: z.array(bookElementSchema).max(60).default([]),
+ parked: z.array(bookElementSchema).max(MAX_SPREAD_ELEMENTS).default([]),
/** The journey entry this spread was generated from, if any. */
entryId: z.number().int().nullable().default(null),
});
@@ -560,9 +753,51 @@ export type BookDocument = z.infer;
* Same shape as `normalizeAppearance`: a book that cannot be parsed must still
* open — an editor that refuses to load someone's work because one field drifted
* is worse than one that drops the field.
+ *
+ * ── Why there are two attempts ───────────────────────────────────────────
+ *
+ * Dropping *the field* was the intent; dropping the whole book was what the
+ * code did. `elements` is an array of a discriminated union, so one element the
+ * contract cannot read fails its spread, fails the array, fails the document —
+ * and this function then handed back an empty book. It is not hypothetical: a
+ * mood mark carried the journal's key in a field capped at two characters, so
+ * every book with one on a page refused to save, and would have opened blank.
+ * Both the client's load seam and the server's read *and* write path run
+ * through here, which means the empty book was also what got written down.
+ *
+ * So a document that fails whole is tried again with the unreadable elements
+ * removed, and only a document that fails even then is given up on. Losing one
+ * decoration to a version skew is a shrug; losing the book is not.
*/
export function normalizeBookDocument(raw: unknown): BookDocument {
const parsed = bookDocumentSchema.safeParse(raw);
if (parsed.success) return parsed.data;
+
+ const salvaged = bookDocumentSchema.safeParse(withoutUnreadableElements(raw));
+ if (salvaged.success) return salvaged.data;
+
return bookDocumentSchema.parse({});
}
+
+function withoutUnreadableElements(raw: unknown): unknown {
+ if (!raw || typeof raw !== 'object') return raw;
+ const doc = raw as { spreads?: unknown };
+ if (!Array.isArray(doc.spreads)) return raw;
+
+ const readable = (list: unknown) =>
+ (Array.isArray(list)
+ // Trimmed to the cap as well as filtered: a spread that grew past 60
+ // fails the array the same way an unreadable element fails the union, and
+ // losing the sixty-first decoration beats losing the book.
+ ? list.filter(el => bookElementSchema.safeParse(el).success).slice(0, MAX_SPREAD_ELEMENTS)
+ : list);
+
+ return {
+ ...doc,
+ spreads: doc.spreads.map(sp => {
+ if (!sp || typeof sp !== 'object') return sp;
+ const spread = sp as { elements?: unknown; parked?: unknown };
+ return { ...spread, elements: readable(spread.elements), parked: readable(spread.parked) };
+ }),
+ };
+}
diff --git a/shared/src/book/journey-stats.schema.ts b/shared/src/book/journey-stats.schema.ts
index 7e72f66d6..79059a548 100644
--- a/shared/src/book/journey-stats.schema.ts
+++ b/shared/src/book/journey-stats.schema.ts
@@ -33,9 +33,49 @@ export const journeyStatsPointSchema = z.object({
date: z.string().nullable(),
/** ISO-3166-1 alpha-2, resolved from the coordinates. */
country: z.string().length(2).nullable(),
+ /**
+ * Which trip this stop belongs to, when the journey knows.
+ *
+ * A journey is a collection of trips, and a book of one is not always a book
+ * of all of them: three countries over two trips printed as a single route
+ * draws a line between the last stop of one and the first of the next, which
+ * is a journey nobody made. With the trip on every point, Studio can offer a
+ * map per trip as well as a map of the whole thing, and the line stops where
+ * the trip stopped.
+ *
+ * Null for a stop that came from an entry nobody linked to a trip.
+ */
+ tripId: z.number().int().positive().nullable().default(null),
+ /**
+ * One representative photograph from this stop, when it has one.
+ *
+ * For a map that marks its stops with pictures rather than with dots. Null is
+ * the ordinary case rather than a failure — a printed route with a few plain
+ * dots between the photographs reads correctly, and it is what a stop nobody
+ * photographed honestly is.
+ */
+ photoId: z.number().int().positive().nullable().default(null),
});
export type JourneyStatsPoint = z.infer;
+/**
+ * The trips behind a journey, named.
+ *
+ * Only what a panel needs to offer a choice: which trips there are, what they
+ * are called, and when they ran. Ordered the way the journey reads, by start
+ * date, because `journey_trips` has carried no order of its own since the
+ * column was dropped in migration 87.
+ */
+export const journeyStatsTripSchema = z.object({
+ id: z.number().int().positive(),
+ title: z.string(),
+ start: z.string().nullable(),
+ end: z.string().nullable(),
+ /** How many of the route's stops fall on this trip. */
+ points: z.number().int().nonnegative(),
+});
+export type JourneyStatsTrip = z.infer;
+
export const journeyStatsCountrySchema = z.object({
code: z.string().length(2),
/** English name. The client translates where it has a translation. */
@@ -69,6 +109,8 @@ export const journeyStatsSchema = z.object({
countries: z.array(journeyStatsCountrySchema).max(200),
/** The route, in order, capped at what a printed line can resolve. */
points: z.array(journeyStatsPointSchema).max(400),
+ /** The trips this journey is made of, for a map of one of them. */
+ trips: z.array(journeyStatsTripSchema).max(200).default([]),
/** ISO dates, null when nothing in the journey is dated. */
start: z.string().nullable(),
end: z.string().nullable(),
diff --git a/shared/src/i18n/ar/journey.ts b/shared/src/i18n/ar/journey.ts
index e75d39444..0a5e6c537 100644
--- a/shared/src/i18n/ar/journey.ts
+++ b/shared/src/i18n/ar/journey.ts
@@ -410,6 +410,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': 'عدّاد الأيام',
'journey.studio.dayWord': 'اليوم',
'journey.studio.coordsMark': 'الإحداثيات',
+ 'journey.studio.coordsDms': 'درجات',
+ 'journey.studio.coordsDecimal': 'عشرية',
'journey.studio.flagMark': 'العلم',
'journey.studio.distanceMark': 'المسافة',
'journey.studio.metric.distance': 'المسافة',
@@ -423,6 +425,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': 'أرقام',
'journey.studio.kind.countries': 'دول',
'journey.studio.kind.badge': 'علامة',
+ 'journey.studio.kind.list': 'قائمة',
+ 'journey.studio.kind.icon': 'أيقونة',
+ 'journey.studio.duplicate': 'تكرار',
+ 'journey.studio.style': 'النمط',
+ 'journey.studio.shows': 'المعروض',
+ 'journey.studio.size': 'الحجم',
+ 'journey.studio.weight': 'الوزن',
+ 'journey.studio.italic': 'مائل',
+ 'journey.studio.tracking': 'تباعد الأحرف',
+ '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': 'الهامش',
+ 'journey.studio.mapShape': 'الشكل',
+ 'journey.studio.align.left': 'يسار',
+ 'journey.studio.align.center': 'وسط',
+ 'journey.studio.align.right': 'يمين',
+ 'journey.studio.markStyle.plain': 'بسيط',
+ 'journey.studio.markStyle.chip': 'شريحة',
+ 'journey.studio.markStyle.outline': 'مفرّغ',
+ 'journey.studio.markStyle.stacked': 'عمودي',
+ 'journey.studio.icon': 'الأيقونة',
+ 'journey.studio.iconAndLabel': 'أيقونة ونص',
+ 'journey.studio.iconOnly': 'أيقونة فقط',
+ 'journey.studio.labelOnly': 'نص فقط',
+ 'journey.studio.icons': 'أيقونات',
+ 'journey.studio.iconsForTravel': 'للسفر',
+ 'journey.studio.iconsAll': 'كل الأيقونات',
+ 'journey.studio.searchIcons': 'بحث عن أيقونات',
+ 'journey.studio.lineWidth': 'السماكة',
'journey.studio.mask': 'قص حسب الشكل',
'journey.studio.maskNone': 'بدون',
'journey.studio.frameStyle': 'إطار',
@@ -491,6 +531,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': 'هذا الخط لا يوفر هذا الوزن',
'journey.studio.mapSource': 'مصدر الخريطة',
'journey.studio.mapSourceVector': 'المخططات',
+ 'journey.studio.mapSourceRelief': 'تضاريس',
+ 'journey.studio.mapSourceSatellite': 'قمر صناعي',
+ 'journey.studio.mapSourceSatelliteHint': 'صور Sentinel-2 خالية من السحب، يمكن طباعتها بحرية مع ذكر المصدر. حادة حتى مستوى شارع في المدينة.',
+ 'journey.studio.routeLook': 'الخط',
+ 'journey.studio.routeStyle': 'الشكل',
+ 'journey.studio.routePlain': 'عادي',
+ 'journey.studio.routeDrawn': 'مرسوم',
+ 'journey.studio.routeArc': 'المراحل الطويلة',
+ 'journey.studio.routeStraight': 'مستقيمة',
+ 'journey.studio.routeBow': 'مقوّسة',
+ 'journey.studio.routeDashArcs': 'المقوّسة بخط متقطع',
+ 'journey.studio.mapStops': 'المحطات',
+ 'journey.studio.pinDot': 'نقاط',
+ 'journey.studio.pinPhoto': 'صور',
+ 'journey.studio.pinPhotoNone': 'لا توجد صور بعد على هذه المحطات، لذا تُرسم كنقاط.',
+ 'journey.studio.roads': 'الطرق',
+ 'journey.studio.roadsFetch': 'اتبع الطرق',
+ 'journey.studio.roadsFollow': 'عبر الطرق',
+ 'journey.studio.roadsDirect': 'خط مباشر',
+ 'journey.studio.recommended': 'موصى به',
+ 'journey.studio.bleed': 'الفيض',
+ 'journey.studio.safeArea': 'الآمنة',
+ 'journey.studio.roadsAgain': 'جلب من جديد',
+ 'journey.studio.roadsClear': 'مسح',
+ 'journey.studio.roadsBusy': 'جارٍ الجلب',
+ 'journey.studio.roadsHint': 'اطلب من خدمة توجيه الطريق الذي قُطعت به كل مرحلة. المراحل الطويلة تبقى كما هي.',
+ 'journey.studio.roadsHave': 'تُحفظ الطرق في هذا الكتاب، فيُطبع الخط نفسه دون اتصال.',
+ 'journey.studio.mapSourceReliefHint': 'تضاريس ناسا المظللة، يمكن طباعتها بحرية. تناسب بلدًا أو قارة، وهي خشنة جدًا لمدينة واحدة.',
+ 'journey.studio.mapPrintDpi': 'تُطبع بنحو',
+ 'journey.studio.mapPrintDpiLow': 'غير حادة بهذا الحجم، جرّب عرضًا أوسع أو مصدرًا آخر',
+ 'journey.studio.mapPerTrip': 'رحلة واحدة في كل مرة',
+ 'journey.studio.mapWholeJourney': 'الرحلة كاملة',
+ 'journey.studio.mapScope': 'المعروض',
'journey.studio.mapSourceTiles': 'بلاطات الخريطة',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': 'تُجلب عند العرض وتُطبع مع إسنادها',
diff --git a/shared/src/i18n/br/journey.ts b/shared/src/i18n/br/journey.ts
index deec3dcbe..ba8139b5b 100644
--- a/shared/src/i18n/br/journey.ts
+++ b/shared/src/i18n/br/journey.ts
@@ -411,6 +411,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': 'Contador de dias',
'journey.studio.dayWord': 'DIA',
'journey.studio.coordsMark': 'Coordenadas',
+ 'journey.studio.coordsDms': 'Graus',
+ 'journey.studio.coordsDecimal': 'Decimal',
'journey.studio.flagMark': 'Bandeira',
'journey.studio.distanceMark': 'Distância',
'journey.studio.metric.distance': 'Distância',
@@ -424,6 +426,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': 'Números',
'journey.studio.kind.countries': 'Países',
'journey.studio.kind.badge': 'Marca',
+ 'journey.studio.kind.list': 'Lista',
+ 'journey.studio.kind.icon': 'Ícone',
+ 'journey.studio.duplicate': 'Duplicar',
+ 'journey.studio.style': 'Estilo',
+ 'journey.studio.shows': 'Mostrar',
+ 'journey.studio.size': 'Tamanho',
+ 'journey.studio.weight': 'Peso',
+ 'journey.studio.italic': 'Itálico',
+ 'journey.studio.tracking': 'Espaçamento',
+ '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',
+ 'journey.studio.mapShape': 'Forma',
+ 'journey.studio.align.left': 'Esquerda',
+ 'journey.studio.align.center': 'Centro',
+ 'journey.studio.align.right': 'Direita',
+ 'journey.studio.markStyle.plain': 'Simples',
+ 'journey.studio.markStyle.chip': 'Etiqueta',
+ 'journey.studio.markStyle.outline': 'Contornado',
+ 'journey.studio.markStyle.stacked': 'Empilhado',
+ 'journey.studio.icon': 'Ícone',
+ 'journey.studio.iconAndLabel': 'Ícone e texto',
+ 'journey.studio.iconOnly': 'Só ícone',
+ 'journey.studio.labelOnly': 'Só texto',
+ 'journey.studio.icons': 'Ícones',
+ 'journey.studio.iconsForTravel': 'Para viagem',
+ 'journey.studio.iconsAll': 'Todos os ícones',
+ 'journey.studio.searchIcons': 'Buscar ícones',
+ 'journey.studio.lineWidth': 'Espessura',
'journey.studio.mask': 'Recortar na forma',
'journey.studio.maskNone': 'Nenhuma',
'journey.studio.frameStyle': 'Moldura',
@@ -492,6 +532,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': 'Esta fonte não tem esse peso',
'journey.studio.mapSource': 'Origem do mapa',
'journey.studio.mapSourceVector': 'Contornos',
+ 'journey.studio.mapSourceRelief': 'Relevo',
+ 'journey.studio.routeLook': 'A linha',
+ 'journey.studio.roads': 'Estradas',
+ 'journey.studio.roadsFetch': 'Seguir as estradas',
+ 'journey.studio.roadsFollow': 'Pela estrada',
+ 'journey.studio.roadsDirect': 'Direta',
+ 'journey.studio.recommended': 'recomendado',
+ 'journey.studio.bleed': 'Sangria',
+ 'journey.studio.safeArea': 'Segurança',
+ 'journey.studio.roadsAgain': 'Buscar de novo',
+ 'journey.studio.roadsClear': 'Limpar',
+ 'journey.studio.roadsBusy': 'Consultando',
+ 'journey.studio.roadsHint': 'Pede a um serviço de rotas o caminho percorrido em cada trecho. Os trechos longos ficam como estão.',
+ 'journey.studio.roadsHave': 'As estradas ficam salvas neste livro, então ele imprime a mesma linha offline.',
+ 'journey.studio.routeStyle': 'Aparência',
+ 'journey.studio.routePlain': 'Lisa',
+ 'journey.studio.routeDrawn': 'Desenhada',
+ 'journey.studio.routeArc': 'Trechos longos',
+ 'journey.studio.routeStraight': 'Retos',
+ 'journey.studio.routeBow': 'Curvados',
+ 'journey.studio.routeDashArcs': 'Tracejar os trechos curvados',
+ 'journey.studio.mapStops': 'Paradas',
+ 'journey.studio.pinDot': 'Pontos',
+ 'journey.studio.pinPhoto': 'Fotos',
+ 'journey.studio.pinPhotoNone': 'Ainda não há fotos nessas paradas, então elas aparecem como pontos.',
+ 'journey.studio.mapSourceSatellite': 'Satélite',
+ 'journey.studio.mapSourceSatelliteHint': 'Sentinel-2 sem nuvens, livre para impressão com o crédito. Nítido até o nível da rua.',
+ 'journey.studio.mapSourceReliefHint': 'Relevo sombreado da NASA, livre para impressão. Ideal para um país ou um continente, grosseiro demais para uma cidade.',
+ 'journey.studio.mapPrintDpi': 'Imprime com cerca de',
+ 'journey.studio.mapPrintDpiLow': 'sem nitidez neste tamanho, use um enquadramento mais amplo ou outra origem',
+ 'journey.studio.mapPerTrip': 'Uma viagem por vez',
+ 'journey.studio.mapWholeJourney': 'Jornada inteira',
+ 'journey.studio.mapScope': 'Mostrar',
'journey.studio.mapSourceTiles': 'Blocos de mapa',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': 'Baixado ao renderizar e impresso com o crédito',
diff --git a/shared/src/i18n/ca/journey.ts b/shared/src/i18n/ca/journey.ts
index 7a95c08f7..e402fa075 100644
--- a/shared/src/i18n/ca/journey.ts
+++ b/shared/src/i18n/ca/journey.ts
@@ -411,6 +411,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': 'Comptador de dies',
'journey.studio.dayWord': 'DIA',
'journey.studio.coordsMark': 'Coordenades',
+ 'journey.studio.coordsDms': 'Graus',
+ 'journey.studio.coordsDecimal': 'Decimal',
'journey.studio.flagMark': 'Bandera',
'journey.studio.distanceMark': 'Distància',
'journey.studio.metric.distance': 'Distància',
@@ -424,6 +426,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': 'Xifres',
'journey.studio.kind.countries': 'Països',
'journey.studio.kind.badge': 'Marca',
+ 'journey.studio.kind.list': 'Llista',
+ 'journey.studio.kind.icon': 'Icona',
+ 'journey.studio.duplicate': 'Duplica',
+ 'journey.studio.style': 'Estil',
+ 'journey.studio.shows': 'Mostra',
+ 'journey.studio.size': 'Mida',
+ 'journey.studio.weight': 'Pes',
+ 'journey.studio.italic': 'Cursiva',
+ 'journey.studio.tracking': 'Interlletratge',
+ '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',
+ 'journey.studio.mapShape': 'Forma',
+ 'journey.studio.align.left': 'Esquerra',
+ 'journey.studio.align.center': 'Centre',
+ 'journey.studio.align.right': 'Dreta',
+ 'journey.studio.markStyle.plain': 'Simple',
+ 'journey.studio.markStyle.chip': 'Càpsula',
+ 'journey.studio.markStyle.outline': 'Contorn',
+ 'journey.studio.markStyle.stacked': 'Apilat',
+ 'journey.studio.icon': 'Icona',
+ 'journey.studio.iconAndLabel': 'Icona i text',
+ 'journey.studio.iconOnly': 'Només icona',
+ 'journey.studio.labelOnly': 'Només text',
+ 'journey.studio.icons': 'Icones',
+ 'journey.studio.iconsForTravel': 'Per al viatge',
+ 'journey.studio.iconsAll': 'Totes les icones',
+ 'journey.studio.searchIcons': 'Cerca icones',
+ 'journey.studio.lineWidth': 'Gruix',
'journey.studio.mask': 'Retallar en forma',
'journey.studio.maskNone': 'Cap',
'journey.studio.frameStyle': 'Marc',
@@ -492,6 +532,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': 'Aquesta tipografia no té aquest gruix',
'journey.studio.mapSource': 'Origen del mapa',
'journey.studio.mapSourceVector': 'Contorns',
+ 'journey.studio.mapSourceRelief': 'Relleu',
+ 'journey.studio.mapSourceSatellite': 'Satèl·lit',
+ 'journey.studio.mapSourceSatelliteHint': 'Sentinel-2 sense núvols, lliure per imprimir amb l’atribució. Nítid fins al carrer.',
+ 'journey.studio.routeLook': 'La línia',
+ 'journey.studio.routeStyle': 'Traç',
+ 'journey.studio.routePlain': 'Simple',
+ 'journey.studio.routeDrawn': 'Dibuixat',
+ 'journey.studio.routeArc': 'Trams llargs',
+ 'journey.studio.routeStraight': 'Rectes',
+ 'journey.studio.routeBow': 'Corbats',
+ 'journey.studio.routeDashArcs': 'Corbes discontínues',
+ 'journey.studio.mapStops': 'Parades',
+ 'journey.studio.pinDot': 'Punts',
+ 'journey.studio.pinPhoto': 'Fotos',
+ 'journey.studio.pinPhotoNone': 'Encara no hi ha fotos en aquestes parades, així que es dibuixen com a punts.',
+ 'journey.studio.roads': 'Carreteres',
+ 'journey.studio.roadsFetch': 'Segueix les carreteres',
+ 'journey.studio.roadsFollow': 'Per carretera',
+ 'journey.studio.roadsDirect': 'Directa',
+ 'journey.studio.recommended': 'recomanat',
+ 'journey.studio.bleed': 'Sagnat',
+ 'journey.studio.safeArea': 'Seguretat',
+ 'journey.studio.roadsAgain': 'Torna-ho a demanar',
+ 'journey.studio.roadsClear': 'Esborra',
+ 'journey.studio.roadsBusy': 'Demanant',
+ 'journey.studio.roadsHint': 'Demana a un servei de rutes el camí que es va fer a cada tram. Els trams llargs es queden com estan.',
+ 'journey.studio.roadsHave': 'Les carreteres queden desades en aquest llibre, així imprimeix la mateixa línia sense connexió.',
+ 'journey.studio.mapSourceReliefHint': 'Relleu ombrejat de la NASA, lliure per imprimir. Ideal per a un país o un continent, massa bast per a una ciutat.',
+ 'journey.studio.mapPrintDpi': 'S’imprimeix a uns',
+ 'journey.studio.mapPrintDpiLow': 'poc nítid a aquesta mida, prova una vista més ampla o un altre origen',
+ 'journey.studio.mapPerTrip': 'Un viatge cada vegada',
+ 'journey.studio.mapWholeJourney': 'Tota la travesia',
+ 'journey.studio.mapScope': 'Mostra',
'journey.studio.mapSourceTiles': 'Tessel·les',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': 'Es descarrega en renderitzar i s’imprimeix amb l’atribució',
diff --git a/shared/src/i18n/cs/journey.ts b/shared/src/i18n/cs/journey.ts
index b92cc051d..79d2d16d6 100644
--- a/shared/src/i18n/cs/journey.ts
+++ b/shared/src/i18n/cs/journey.ts
@@ -411,6 +411,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': 'Počítadlo dní',
'journey.studio.dayWord': 'DEN',
'journey.studio.coordsMark': 'Souřadnice',
+ 'journey.studio.coordsDms': 'Stupně',
+ 'journey.studio.coordsDecimal': 'Desetinné',
'journey.studio.flagMark': 'Vlajka',
'journey.studio.distanceMark': 'Vzdálenost',
'journey.studio.metric.distance': 'Vzdálenost',
@@ -424,6 +426,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': 'Čísla',
'journey.studio.kind.countries': 'Země',
'journey.studio.kind.badge': 'Značka',
+ 'journey.studio.kind.list': 'Seznam',
+ 'journey.studio.kind.icon': 'Ikona',
+ 'journey.studio.duplicate': 'Duplikovat',
+ 'journey.studio.style': 'Styl',
+ 'journey.studio.shows': 'Zobrazení',
+ 'journey.studio.size': 'Velikost',
+ 'journey.studio.weight': 'Řez',
+ 'journey.studio.italic': 'Kurzíva',
+ 'journey.studio.tracking': 'Prostrkání',
+ 'journey.studio.rotation': 'Otočení',
+ 'journey.studio.opacity': 'Krytí',
+ 'journey.studio.fill': 'Výplň',
+ 'journey.studio.fillOn': 'Vyplnit',
+ 'journey.studio.stroke': 'Obrys',
+ 'journey.studio.strokeWidth': 'Tloušťka',
+ 'journey.studio.gradient': 'Přechod',
+ 'journey.studio.gradientDown': 'Dolů',
+ 'journey.studio.gradientUp': 'Nahoru',
+ 'journey.studio.showIcons': 'Ikony',
+ 'journey.studio.mapFit': 'Rozsah',
+ 'journey.studio.mapPadding': 'Prostor',
+ 'journey.studio.mapShape': 'Tvar',
+ 'journey.studio.align.left': 'Vlevo',
+ 'journey.studio.align.center': 'Na střed',
+ 'journey.studio.align.right': 'Vpravo',
+ 'journey.studio.markStyle.plain': 'Prostý',
+ 'journey.studio.markStyle.chip': 'Vyplněný',
+ 'journey.studio.markStyle.outline': 'Obrysový',
+ 'journey.studio.markStyle.stacked': 'Nad sebou',
+ 'journey.studio.icon': 'Ikona',
+ 'journey.studio.iconAndLabel': 'Ikona a text',
+ 'journey.studio.iconOnly': 'Jen ikona',
+ 'journey.studio.labelOnly': 'Jen text',
+ 'journey.studio.icons': 'Ikony',
+ 'journey.studio.iconsForTravel': 'Na cesty',
+ 'journey.studio.iconsAll': 'Všechny ikony',
+ 'journey.studio.searchIcons': 'Hledat ikony',
+ 'journey.studio.lineWidth': 'Tloušťka',
'journey.studio.mask': 'Oříznout do tvaru',
'journey.studio.maskNone': 'Žádný',
'journey.studio.frameStyle': 'Rámeček',
@@ -492,6 +532,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': 'Toto písmo tento řez nemá',
'journey.studio.mapSource': 'Zdroj mapy',
'journey.studio.mapSourceVector': 'Obrysy',
+ 'journey.studio.mapSourceRelief': 'Reliéf',
+ 'journey.studio.routeLook': 'Čára',
+ 'journey.studio.roads': 'Silnice',
+ 'journey.studio.roadsFetch': 'Vést po silnicích',
+ 'journey.studio.roadsFollow': 'Po silnicích',
+ 'journey.studio.roadsDirect': 'Vzdušnou čarou',
+ 'journey.studio.recommended': 'doporučeno',
+ 'journey.studio.bleed': 'Spadávka',
+ 'journey.studio.safeArea': 'Bezpečná',
+ 'journey.studio.roadsAgain': 'Načíst znovu',
+ 'journey.studio.roadsClear': 'Vymazat',
+ 'journey.studio.roadsBusy': 'Dotazování',
+ 'journey.studio.roadsHint': 'Zeptá se plánovače tras, kudy každý úsek vedl. Dlouhé úseky zůstanou beze změny.',
+ 'journey.studio.roadsHave': 'Silnice jsou uložené v této knize, takže se stejná čára vytiskne i offline.',
+ 'journey.studio.routeStyle': 'Provedení',
+ 'journey.studio.routePlain': 'Jednoduchá',
+ 'journey.studio.routeDrawn': 'Kreslená',
+ 'journey.studio.routeArc': 'Dlouhé úseky',
+ 'journey.studio.routeStraight': 'Přímé',
+ 'journey.studio.routeBow': 'Prohnuté',
+ 'journey.studio.routeDashArcs': 'Prohnuté úseky čárkovaně',
+ 'journey.studio.mapStops': 'Zastávky',
+ 'journey.studio.pinDot': 'Tečky',
+ 'journey.studio.pinPhoto': 'Fotky',
+ 'journey.studio.pinPhotoNone': 'U těchto zastávek zatím nejsou žádné fotky, kreslí se proto jako tečky.',
+ 'journey.studio.mapSourceSatellite': 'Satelit',
+ 'journey.studio.mapSourceSatelliteHint': 'Sentinel-2 bez oblačnosti, volný k tisku s uvedením zdroje. Ostrý až na úroveň ulice.',
+ 'journey.studio.mapSourceReliefHint': 'Stínovaný reliéf od NASA, volný k tisku. Vhodný pro zemi nebo kontinent, pro jedno město je příliš hrubý.',
+ 'journey.studio.mapPrintDpi': 'Tiskne se přibližně v',
+ 'journey.studio.mapPrintDpiLow': 'v této velikosti bude neostrá, zkuste širší výřez nebo jiný zdroj',
+ 'journey.studio.mapPerTrip': 'Každá cesta zvlášť',
+ 'journey.studio.mapWholeJourney': 'Celá cesta',
+ 'journey.studio.mapScope': 'Zobrazení',
'journey.studio.mapSourceTiles': 'Dlaždice',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': 'Načte se při vykreslení a vytiskne se s uvedením zdroje',
diff --git a/shared/src/i18n/de/journey.ts b/shared/src/i18n/de/journey.ts
index f78f2ce60..c112d6613 100644
--- a/shared/src/i18n/de/journey.ts
+++ b/shared/src/i18n/de/journey.ts
@@ -411,6 +411,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': 'Tageszähler',
'journey.studio.dayWord': 'TAG',
'journey.studio.coordsMark': 'Koordinaten',
+ 'journey.studio.coordsDms': 'Grad',
+ 'journey.studio.coordsDecimal': 'Dezimal',
'journey.studio.flagMark': 'Flagge',
'journey.studio.distanceMark': 'Distanz',
'journey.studio.metric.distance': 'Distanz',
@@ -424,6 +426,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': 'Zahlen',
'journey.studio.kind.countries': 'Länder',
'journey.studio.kind.badge': 'Marke',
+ 'journey.studio.kind.list': 'Liste',
+ 'journey.studio.kind.icon': 'Symbol',
+ 'journey.studio.duplicate': 'Duplizieren',
+ 'journey.studio.style': 'Stil',
+ 'journey.studio.shows': 'Anzeige',
+ 'journey.studio.size': 'Größe',
+ 'journey.studio.weight': 'Schnitt',
+ 'journey.studio.italic': 'Kursiv',
+ 'journey.studio.tracking': 'Laufweite',
+ 'journey.studio.rotation': 'Drehung',
+ 'journey.studio.opacity': 'Deckkraft',
+ 'journey.studio.fill': 'Füllung',
+ 'journey.studio.fillOn': 'Gefüllt',
+ 'journey.studio.stroke': 'Umriss',
+ 'journey.studio.strokeWidth': 'Stärke',
+ 'journey.studio.gradient': 'Verlauf',
+ 'journey.studio.gradientDown': 'Nach unten',
+ 'journey.studio.gradientUp': 'Nach oben',
+ 'journey.studio.showIcons': 'Symbole',
+ 'journey.studio.mapFit': 'Bereich',
+ 'journey.studio.mapPadding': 'Rand',
+ 'journey.studio.mapShape': 'Form',
+ 'journey.studio.align.left': 'Links',
+ 'journey.studio.align.center': 'Zentriert',
+ 'journey.studio.align.right': 'Rechts',
+ 'journey.studio.markStyle.plain': 'Schlicht',
+ 'journey.studio.markStyle.chip': 'Gefüllt',
+ 'journey.studio.markStyle.outline': 'Umrandet',
+ 'journey.studio.markStyle.stacked': 'Gestapelt',
+ 'journey.studio.icon': 'Symbol',
+ 'journey.studio.iconAndLabel': 'Symbol & Text',
+ 'journey.studio.iconOnly': 'Nur Symbol',
+ 'journey.studio.labelOnly': 'Nur Text',
+ 'journey.studio.icons': 'Symbole',
+ 'journey.studio.iconsForTravel': 'Fürs Reisen',
+ 'journey.studio.iconsAll': 'Alle Symbole',
+ 'journey.studio.searchIcons': 'Symbole suchen',
+ 'journey.studio.lineWidth': 'Stärke',
'journey.studio.mask': 'In Form schneiden',
'journey.studio.maskNone': 'Keine',
'journey.studio.frameStyle': 'Rahmen',
@@ -492,6 +532,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': 'Diese Schrift hat diesen Schnitt nicht',
'journey.studio.mapSource': 'Kartenquelle',
'journey.studio.mapSourceVector': 'Umrisse',
+ 'journey.studio.mapSourceRelief': 'Relief',
+ 'journey.studio.mapSourceSatellite': 'Satellit',
+ 'journey.studio.mapSourceSatelliteHint': 'Wolkenfreies Sentinel-2, mit Quellenangabe frei druckbar. Scharf bis auf die einzelne Straße in der Stadt.',
+ 'journey.studio.routeLook': 'Die Linie',
+ 'journey.studio.routeStyle': 'Linienstil',
+ 'journey.studio.routePlain': 'Einfach',
+ 'journey.studio.routeDrawn': 'Gezeichnet',
+ 'journey.studio.routeArc': 'Lange Etappen',
+ 'journey.studio.routeStraight': 'Gerade',
+ 'journey.studio.routeBow': 'Gebogen',
+ 'journey.studio.routeDashArcs': 'Gebogene Etappen stricheln',
+ 'journey.studio.mapStops': 'Stationen',
+ 'journey.studio.pinDot': 'Punkte',
+ 'journey.studio.pinPhoto': 'Fotos',
+ 'journey.studio.pinPhotoNone': 'Diese Stationen haben noch keine Fotos, deshalb werden sie als Punkte gezeichnet.',
+ 'journey.studio.roads': 'Straßen',
+ 'journey.studio.roadsFetch': 'Den Straßen folgen',
+ 'journey.studio.roadsFollow': 'Auf Straßen',
+ 'journey.studio.roadsDirect': 'Luftlinie',
+ 'journey.studio.recommended': 'empfohlen',
+ 'journey.studio.bleed': 'Anschnitt',
+ 'journey.studio.safeArea': 'Schutzzone',
+ 'journey.studio.roadsAgain': 'Neu abrufen',
+ 'journey.studio.roadsClear': 'Verwerfen',
+ 'journey.studio.roadsBusy': 'Abfrage',
+ 'journey.studio.roadsHint': 'Fragt einen Routendienst nach der gefahrenen Strecke jeder Etappe. Lange Etappen bleiben, wie sie sind.',
+ 'journey.studio.roadsHave': 'Die Straßen sind in diesem Buch gespeichert, es druckt offline dieselbe Linie.',
+ 'journey.studio.mapPerTrip': 'Ein Trip pro Karte',
+ 'journey.studio.mapWholeJourney': 'Ganze Journey',
+ 'journey.studio.mapScope': 'Anzeige',
+ 'journey.studio.mapPrintDpi': 'Druckauflösung etwa',
+ 'journey.studio.mapPrintDpiLow': 'bei dieser Größe unscharf, probier einen größeren Ausschnitt oder eine andere Quelle',
+ 'journey.studio.mapSourceReliefHint': 'NASA-Reliefschattierung, frei druckbar. Gut für ein Land oder einen Kontinent, zu grob für eine Stadt.',
'journey.studio.mapSourceTiles': 'Kartenkacheln',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': 'Wird beim Rendern geladen und mit Quellenangabe gedruckt',
diff --git a/shared/src/i18n/en/journey.ts b/shared/src/i18n/en/journey.ts
index 13b3c2e75..e220f968d 100644
--- a/shared/src/i18n/en/journey.ts
+++ b/shared/src/i18n/en/journey.ts
@@ -411,6 +411,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': 'Day counter',
'journey.studio.dayWord': 'DAY',
'journey.studio.coordsMark': 'Coordinates',
+ 'journey.studio.coordsDms': 'Degrees',
+ 'journey.studio.coordsDecimal': 'Decimal',
'journey.studio.flagMark': 'Flag',
'journey.studio.distanceMark': 'Distance',
'journey.studio.metric.distance': 'Distance',
@@ -424,6 +426,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': 'Figures',
'journey.studio.kind.countries': 'Countries',
'journey.studio.kind.badge': 'Mark',
+ 'journey.studio.kind.list': 'List',
+ 'journey.studio.kind.icon': 'Icon',
+ 'journey.studio.duplicate': 'Duplicate',
+ 'journey.studio.style': 'Style',
+ 'journey.studio.shows': 'Shows',
+ 'journey.studio.size': 'Size',
+ 'journey.studio.weight': 'Weight',
+ 'journey.studio.italic': 'Italic',
+ 'journey.studio.tracking': 'Tracking',
+ 'journey.studio.rotation': 'Rotation',
+ 'journey.studio.opacity': 'Opacity',
+ 'journey.studio.fill': 'Fill',
+ 'journey.studio.fillOn': 'Filled',
+ 'journey.studio.stroke': 'Outline',
+ 'journey.studio.strokeWidth': 'Width',
+ 'journey.studio.gradient': 'Fade',
+ 'journey.studio.gradientDown': 'Downwards',
+ 'journey.studio.gradientUp': 'Upwards',
+ 'journey.studio.showIcons': 'Icons',
+ 'journey.studio.mapFit': 'Fit',
+ 'journey.studio.mapPadding': 'Room',
+ 'journey.studio.mapShape': 'Shape',
+ 'journey.studio.align.left': 'Left',
+ 'journey.studio.align.center': 'Centre',
+ 'journey.studio.align.right': 'Right',
+ 'journey.studio.markStyle.plain': 'Plain',
+ 'journey.studio.markStyle.chip': 'Chip',
+ 'journey.studio.markStyle.outline': 'Outline',
+ 'journey.studio.markStyle.stacked': 'Stacked',
+ 'journey.studio.icon': 'Icon',
+ 'journey.studio.iconAndLabel': 'Icon & text',
+ 'journey.studio.iconOnly': 'Icon only',
+ 'journey.studio.labelOnly': 'Text only',
+ 'journey.studio.icons': 'Icons',
+ 'journey.studio.iconsForTravel': 'For travel',
+ 'journey.studio.iconsAll': 'All icons',
+ 'journey.studio.searchIcons': 'Search icons',
+ 'journey.studio.lineWidth': 'Thickness',
'journey.studio.mask': 'Cut to shape',
'journey.studio.maskNone': 'None',
'journey.studio.frameStyle': 'Frame',
@@ -492,6 +532,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': 'This typeface does not ship that weight',
'journey.studio.mapSource': 'Map source',
'journey.studio.mapSourceVector': 'Outlines',
+ 'journey.studio.mapSourceRelief': 'Relief',
+ 'journey.studio.routeLook': 'The line',
+ 'journey.studio.roads': 'Roads',
+ 'journey.studio.roadsFetch': 'Follow the roads',
+ 'journey.studio.roadsFollow': 'Roads',
+ 'journey.studio.roadsDirect': 'Direct',
+ 'journey.studio.roadsAgain': 'Fetch again',
+ 'journey.studio.roadsClear': 'Clear',
+ 'journey.studio.roadsBusy': 'Asking',
+ 'journey.studio.roadsHint': 'Ask a routing service for the way each leg was driven. Long legs stay as they are.',
+ 'journey.studio.roadsHave': 'The roads are saved in this book, so it prints the same line offline.',
+ 'journey.studio.routeStyle': 'Style',
+ 'journey.studio.routePlain': 'Plain',
+ 'journey.studio.routeDrawn': 'Drawn',
+ 'journey.studio.routeArc': 'Long legs',
+ 'journey.studio.routeStraight': 'Straight',
+ 'journey.studio.routeBow': 'Bowed',
+ 'journey.studio.routeDashArcs': 'Dash the bowed legs',
+ 'journey.studio.mapStops': 'Stops',
+ 'journey.studio.pinDot': 'Dots',
+ 'journey.studio.pinPhoto': 'Photos',
+ 'journey.studio.pinPhotoNone': 'No photographs on these stops yet, so they are drawn as dots.',
+ 'journey.studio.mapSourceSatellite': 'Satellite',
+ 'journey.studio.recommended': 'recommended',
+ 'journey.studio.bleed': 'Bleed',
+ 'journey.studio.safeArea': 'Safe',
+ 'journey.studio.mapSourceSatelliteHint': 'Cloud-free Sentinel-2, free to print with credit. Sharp down to a city street.',
+ 'journey.studio.mapPerTrip': 'One trip at a time',
+ 'journey.studio.mapWholeJourney': 'Whole journey',
+ 'journey.studio.mapScope': 'Shows',
+ 'journey.studio.mapPrintDpi': 'Prints at about',
+ 'journey.studio.mapPrintDpiLow': 'soft at this size, try a wider view or another source',
+ 'journey.studio.mapSourceReliefHint': 'NASA shaded relief, free to print. Best for a country or a continent, too coarse for one city.',
'journey.studio.mapSourceTiles': 'Map tiles',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': 'Fetched when the page is rendered, and printed with its credit',
diff --git a/shared/src/i18n/es/journey.ts b/shared/src/i18n/es/journey.ts
index b6063650c..5cb849a02 100644
--- a/shared/src/i18n/es/journey.ts
+++ b/shared/src/i18n/es/journey.ts
@@ -411,6 +411,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': 'Contador de días',
'journey.studio.dayWord': 'DÍA',
'journey.studio.coordsMark': 'Coordenadas',
+ 'journey.studio.coordsDms': 'Grados',
+ 'journey.studio.coordsDecimal': 'Decimal',
'journey.studio.flagMark': 'Bandera',
'journey.studio.distanceMark': 'Distancia',
'journey.studio.metric.distance': 'Distancia',
@@ -424,6 +426,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': 'Cifras',
'journey.studio.kind.countries': 'Países',
'journey.studio.kind.badge': 'Marca',
+ 'journey.studio.kind.list': 'Lista',
+ 'journey.studio.kind.icon': 'Icono',
+ 'journey.studio.duplicate': 'Duplicar',
+ 'journey.studio.style': 'Estilo',
+ 'journey.studio.shows': 'Mostrar',
+ 'journey.studio.size': 'Tamaño',
+ 'journey.studio.weight': 'Peso',
+ 'journey.studio.italic': 'Cursiva',
+ 'journey.studio.tracking': 'Interletraje',
+ 'journey.studio.rotation': 'Rotación',
+ 'journey.studio.opacity': 'Opacidad',
+ 'journey.studio.fill': 'Relleno',
+ 'journey.studio.fillOn': 'Con relleno',
+ 'journey.studio.stroke': 'Contorno',
+ 'journey.studio.strokeWidth': 'Grosor',
+ 'journey.studio.gradient': 'Degradado',
+ 'journey.studio.gradientDown': 'Hacia abajo',
+ 'journey.studio.gradientUp': 'Hacia arriba',
+ 'journey.studio.showIcons': 'Iconos',
+ 'journey.studio.mapFit': 'Ajustar a',
+ 'journey.studio.mapPadding': 'Espacio',
+ 'journey.studio.mapShape': 'Forma',
+ 'journey.studio.align.left': 'Izquierda',
+ 'journey.studio.align.center': 'Centro',
+ 'journey.studio.align.right': 'Derecha',
+ 'journey.studio.markStyle.plain': 'Simple',
+ 'journey.studio.markStyle.chip': 'Cápsula',
+ 'journey.studio.markStyle.outline': 'Contorno',
+ 'journey.studio.markStyle.stacked': 'Apilado',
+ 'journey.studio.icon': 'Icono',
+ 'journey.studio.iconAndLabel': 'Icono y texto',
+ 'journey.studio.iconOnly': 'Solo icono',
+ 'journey.studio.labelOnly': 'Solo texto',
+ 'journey.studio.icons': 'Iconos',
+ 'journey.studio.iconsForTravel': 'Para viajar',
+ 'journey.studio.iconsAll': 'Todos los iconos',
+ 'journey.studio.searchIcons': 'Buscar iconos',
+ 'journey.studio.lineWidth': 'Grosor',
'journey.studio.mask': 'Recortar en forma',
'journey.studio.maskNone': 'Ninguna',
'journey.studio.frameStyle': 'Marco',
@@ -492,6 +532,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': 'Esta tipografía no tiene ese grosor',
'journey.studio.mapSource': 'Origen del mapa',
'journey.studio.mapSourceVector': 'Contornos',
+ 'journey.studio.mapSourceRelief': 'Relieve',
+ 'journey.studio.mapSourceSatellite': 'Satélite',
+ 'journey.studio.mapSourceSatelliteHint': 'Sentinel-2 sin nubes, libre para imprimir con su atribución. Nítido hasta el nivel de calle.',
+ 'journey.studio.routeLook': 'La línea',
+ 'journey.studio.routeStyle': 'Trazo',
+ 'journey.studio.routePlain': 'Simple',
+ 'journey.studio.routeDrawn': 'Dibujado',
+ 'journey.studio.routeArc': 'Tramos largos',
+ 'journey.studio.routeStraight': 'Rectos',
+ 'journey.studio.routeBow': 'Curvos',
+ 'journey.studio.routeDashArcs': 'Tramos curvos discontinuos',
+ 'journey.studio.mapStops': 'Paradas',
+ 'journey.studio.pinDot': 'Puntos',
+ 'journey.studio.pinPhoto': 'Fotos',
+ 'journey.studio.pinPhotoNone': 'Todavía no hay fotos en estas paradas, así que se dibujan como puntos.',
+ 'journey.studio.roads': 'Carreteras',
+ 'journey.studio.roadsFetch': 'Seguir las carreteras',
+ 'journey.studio.roadsFollow': 'Por carretera',
+ 'journey.studio.roadsDirect': 'Directo',
+ 'journey.studio.recommended': 'recomendado',
+ 'journey.studio.bleed': 'Sangrado',
+ 'journey.studio.safeArea': 'Seguridad',
+ 'journey.studio.roadsAgain': 'Volver a pedir',
+ 'journey.studio.roadsClear': 'Borrar',
+ 'journey.studio.roadsBusy': 'Pidiendo',
+ 'journey.studio.roadsHint': 'Pide a un servicio de rutas el camino que se recorrió en cada tramo. Los tramos largos se quedan como están.',
+ 'journey.studio.roadsHave': 'Las carreteras se guardan en este libro, así que imprime la misma línea sin conexión.',
+ 'journey.studio.mapSourceReliefHint': 'Relieve sombreado de la NASA, libre para imprimir. Ideal para un país o un continente, demasiado tosco para una ciudad.',
+ 'journey.studio.mapPrintDpi': 'Se imprime a unos',
+ 'journey.studio.mapPrintDpiLow': 'poco nítido a este tamaño, prueba una vista más amplia u otro origen',
+ 'journey.studio.mapPerTrip': 'Un viaje cada vez',
+ 'journey.studio.mapWholeJourney': 'Toda la travesía',
+ 'journey.studio.mapScope': 'Mostrar',
'journey.studio.mapSourceTiles': 'Teselas',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': 'Se descarga al renderizar y se imprime con su atribución',
diff --git a/shared/src/i18n/fr/journey.ts b/shared/src/i18n/fr/journey.ts
index 397ab4e7a..626c79cf5 100644
--- a/shared/src/i18n/fr/journey.ts
+++ b/shared/src/i18n/fr/journey.ts
@@ -412,6 +412,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': 'Compteur de jours',
'journey.studio.dayWord': 'JOUR',
'journey.studio.coordsMark': 'Coordonnées',
+ 'journey.studio.coordsDms': 'Degrés',
+ 'journey.studio.coordsDecimal': 'Décimal',
'journey.studio.flagMark': 'Drapeau',
'journey.studio.distanceMark': 'Distance',
'journey.studio.metric.distance': 'Distance',
@@ -425,6 +427,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': 'Chiffres',
'journey.studio.kind.countries': 'Pays',
'journey.studio.kind.badge': 'Repère',
+ 'journey.studio.kind.list': 'Liste',
+ 'journey.studio.kind.icon': 'Icône',
+ 'journey.studio.duplicate': 'Dupliquer',
+ 'journey.studio.style': 'Style',
+ 'journey.studio.shows': 'Affichage',
+ 'journey.studio.size': 'Taille',
+ 'journey.studio.weight': 'Graisse',
+ 'journey.studio.italic': 'Italique',
+ 'journey.studio.tracking': 'Interlettrage',
+ 'journey.studio.rotation': 'Rotation',
+ 'journey.studio.opacity': 'Opacité',
+ 'journey.studio.fill': 'Fond',
+ 'journey.studio.fillOn': 'Rempli',
+ 'journey.studio.stroke': 'Contour',
+ 'journey.studio.strokeWidth': 'Épaisseur',
+ 'journey.studio.gradient': 'Fondu',
+ 'journey.studio.gradientDown': 'Vers le bas',
+ 'journey.studio.gradientUp': 'Vers le haut',
+ 'journey.studio.showIcons': 'Icônes',
+ 'journey.studio.mapFit': 'Cadrer sur',
+ 'journey.studio.mapPadding': 'Espace',
+ 'journey.studio.mapShape': 'Forme',
+ 'journey.studio.align.left': 'Gauche',
+ 'journey.studio.align.center': 'Centre',
+ 'journey.studio.align.right': 'Droite',
+ 'journey.studio.markStyle.plain': 'Simple',
+ 'journey.studio.markStyle.chip': 'Pastille',
+ 'journey.studio.markStyle.outline': 'Contour',
+ 'journey.studio.markStyle.stacked': 'Empilé',
+ 'journey.studio.icon': 'Icône',
+ 'journey.studio.iconAndLabel': 'Icône et texte',
+ 'journey.studio.iconOnly': 'Icône seule',
+ 'journey.studio.labelOnly': 'Texte seul',
+ 'journey.studio.icons': 'Icônes',
+ 'journey.studio.iconsForTravel': 'Pour le voyage',
+ 'journey.studio.iconsAll': 'Toutes les icônes',
+ 'journey.studio.searchIcons': 'Rechercher une icône',
+ 'journey.studio.lineWidth': 'Épaisseur',
'journey.studio.mask': 'Découper en forme',
'journey.studio.maskNone': 'Aucune',
'journey.studio.frameStyle': 'Cadre',
@@ -493,6 +533,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': 'Cette police n’a pas cette graisse',
'journey.studio.mapSource': 'Source de la carte',
'journey.studio.mapSourceVector': 'Contours',
+ 'journey.studio.mapSourceRelief': 'Relief',
+ 'journey.studio.mapSourceSatellite': 'Satellite',
+ 'journey.studio.mapSourceSatelliteHint': 'Sentinel-2 sans nuages, libre à l’impression avec son crédit. Net jusqu’à la rue.',
+ 'journey.studio.routeLook': 'Le tracé',
+ 'journey.studio.routeStyle': 'Trait',
+ 'journey.studio.routePlain': 'Simple',
+ 'journey.studio.routeDrawn': 'Dessiné',
+ 'journey.studio.routeArc': 'Longs trajets',
+ 'journey.studio.routeStraight': 'Droits',
+ 'journey.studio.routeBow': 'Courbes',
+ 'journey.studio.routeDashArcs': 'Courbes en pointillés',
+ 'journey.studio.mapStops': 'Étapes',
+ 'journey.studio.pinDot': 'Points',
+ 'journey.studio.pinPhoto': 'Photos',
+ 'journey.studio.pinPhotoNone': 'Pas encore de photo sur ces étapes, elles sont donc dessinées en points.',
+ 'journey.studio.roads': 'Routes',
+ 'journey.studio.roadsFetch': 'Suivre les routes',
+ 'journey.studio.roadsFollow': 'Par la route',
+ 'journey.studio.roadsDirect': 'Direct',
+ 'journey.studio.recommended': 'recommandé',
+ 'journey.studio.bleed': 'Fond perdu',
+ 'journey.studio.safeArea': 'Sécurité',
+ 'journey.studio.roadsAgain': 'Relancer',
+ 'journey.studio.roadsClear': 'Effacer',
+ 'journey.studio.roadsBusy': 'Recherche',
+ 'journey.studio.roadsHint': 'Demande à un service d’itinéraires le chemin parcouru sur chaque trajet. Les longs trajets restent tels quels.',
+ 'journey.studio.roadsHave': 'Les routes sont enregistrées dans ce livre, il imprime donc le même tracé hors ligne.',
+ 'journey.studio.mapSourceReliefHint': 'Relief ombré de la NASA, libre à l’impression. Parfait pour un pays ou un continent, trop grossier pour une ville.',
+ 'journey.studio.mapPrintDpi': 'Impression à environ',
+ 'journey.studio.mapPrintDpiLow': 'flou à cette taille, essayez une vue plus large ou une autre source',
+ 'journey.studio.mapPerTrip': 'Un voyage à la fois',
+ 'journey.studio.mapWholeJourney': 'Tout le journal',
+ 'journey.studio.mapScope': 'Affichage',
'journey.studio.mapSourceTiles': 'Tuiles',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': 'Téléchargée au rendu et imprimée avec son crédit',
diff --git a/shared/src/i18n/gr/journey.ts b/shared/src/i18n/gr/journey.ts
index 48785a8ae..58d04d4e9 100644
--- a/shared/src/i18n/gr/journey.ts
+++ b/shared/src/i18n/gr/journey.ts
@@ -413,6 +413,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': 'Μετρητής ημερών',
'journey.studio.dayWord': 'ΗΜΕΡΑ',
'journey.studio.coordsMark': 'Συντεταγμένες',
+ 'journey.studio.coordsDms': 'Μοίρες',
+ 'journey.studio.coordsDecimal': 'Δεκαδικές',
'journey.studio.flagMark': 'Σημαία',
'journey.studio.distanceMark': 'Απόσταση',
'journey.studio.metric.distance': 'Απόσταση',
@@ -426,6 +428,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': 'Στοιχεία',
'journey.studio.kind.countries': 'Χώρες',
'journey.studio.kind.badge': 'Σήμανση',
+ 'journey.studio.kind.list': 'Λίστα',
+ 'journey.studio.kind.icon': 'Εικονίδιο',
+ 'journey.studio.duplicate': 'Διπλασιασμός',
+ 'journey.studio.style': 'Στυλ',
+ 'journey.studio.shows': 'Εμφάνιση',
+ 'journey.studio.size': 'Μέγεθος',
+ 'journey.studio.weight': 'Βάρος',
+ 'journey.studio.italic': 'Πλάγια',
+ 'journey.studio.tracking': 'Αραίωση',
+ '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': 'Περιθώριο',
+ 'journey.studio.mapShape': 'Σχήμα',
+ 'journey.studio.align.left': 'Αριστερά',
+ 'journey.studio.align.center': 'Κέντρο',
+ 'journey.studio.align.right': 'Δεξιά',
+ 'journey.studio.markStyle.plain': 'Απλό',
+ 'journey.studio.markStyle.chip': 'Πλακίδιο',
+ 'journey.studio.markStyle.outline': 'Περίγραμμα',
+ 'journey.studio.markStyle.stacked': 'Σε στήλη',
+ 'journey.studio.icon': 'Εικονίδιο',
+ 'journey.studio.iconAndLabel': 'Εικονίδιο & κείμενο',
+ 'journey.studio.iconOnly': 'Μόνο εικονίδιο',
+ 'journey.studio.labelOnly': 'Μόνο κείμενο',
+ 'journey.studio.icons': 'Εικονίδια',
+ 'journey.studio.iconsForTravel': 'Για ταξίδια',
+ 'journey.studio.iconsAll': 'Όλα τα εικονίδια',
+ 'journey.studio.searchIcons': 'Αναζήτηση εικονιδίων',
+ 'journey.studio.lineWidth': 'Πάχος',
'journey.studio.mask': 'Κοπή σε σχήμα',
'journey.studio.maskNone': 'Κανένα',
'journey.studio.frameStyle': 'Πλαίσιο',
@@ -494,6 +534,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': 'Αυτή η γραμματοσειρά δεν έχει αυτό το βάρος',
'journey.studio.mapSource': 'Πηγή χάρτη',
'journey.studio.mapSourceVector': 'Περιγράμματα',
+ 'journey.studio.mapSourceRelief': 'Ανάγλυφο',
+ 'journey.studio.mapSourceSatellite': 'Δορυφόρος',
+ 'journey.studio.mapSourceSatelliteHint': 'Sentinel-2 χωρίς σύννεφα, ελεύθερο για εκτύπωση με αναφορά. Ευκρινές μέχρι και τον δρόμο μιας πόλης.',
+ 'journey.studio.routeLook': 'Η γραμμή',
+ 'journey.studio.routeStyle': 'Μορφή',
+ 'journey.studio.routePlain': 'Λιτή',
+ 'journey.studio.routeDrawn': 'Σχεδιασμένη',
+ 'journey.studio.routeArc': 'Μεγάλα σκέλη',
+ 'journey.studio.routeStraight': 'Ευθεία',
+ 'journey.studio.routeBow': 'Καμπύλη',
+ 'journey.studio.routeDashArcs': 'Διακεκομμένα τα καμπύλα σκέλη',
+ 'journey.studio.mapStops': 'Στάσεις',
+ 'journey.studio.pinDot': 'Κουκκίδες',
+ 'journey.studio.pinPhoto': 'Φωτογραφίες',
+ 'journey.studio.pinPhotoNone': 'Δεν υπάρχουν ακόμη φωτογραφίες σε αυτές τις στάσεις, οπότε σχεδιάζονται ως κουκκίδες.',
+ 'journey.studio.roads': 'Δρόμοι',
+ 'journey.studio.roadsFetch': 'Ακολούθηση δρόμων',
+ 'journey.studio.roadsFollow': 'Οδικά',
+ 'journey.studio.roadsDirect': 'Απευθείας',
+ 'journey.studio.recommended': 'συνιστάται',
+ 'journey.studio.bleed': 'Διαφυγή',
+ 'journey.studio.safeArea': 'Ασφαλής',
+ 'journey.studio.roadsAgain': 'Νέα λήψη',
+ 'journey.studio.roadsClear': 'Καθαρισμός',
+ 'journey.studio.roadsBusy': 'Λήψη',
+ 'journey.studio.roadsHint': 'Ζητήστε από μια υπηρεσία δρομολόγησης τον δρόμο που ακολούθησε κάθε σκέλος. Τα μεγάλα σκέλη μένουν ως έχουν.',
+ 'journey.studio.roadsHave': 'Οι δρόμοι αποθηκεύονται σε αυτό το βιβλίο, ώστε να τυπώνεται η ίδια γραμμή και εκτός σύνδεσης.',
+ 'journey.studio.mapSourceReliefHint': 'Σκιασμένο ανάγλυφο NASA, ελεύθερο για εκτύπωση. Ιδανικό για μια χώρα ή μια ήπειρο, πολύ αδρό για μία πόλη.',
+ 'journey.studio.mapPrintDpi': 'Εκτύπωση περίπου',
+ 'journey.studio.mapPrintDpiLow': 'θολό σε αυτό το μέγεθος, δοκιμάστε ευρύτερη προβολή ή άλλη πηγή',
+ 'journey.studio.mapPerTrip': 'Ένα ταξίδι τη φορά',
+ 'journey.studio.mapWholeJourney': 'Όλο το ταξίδι',
+ 'journey.studio.mapScope': 'Εμφάνιση',
'journey.studio.mapSourceTiles': 'Πλακίδια',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': 'Λαμβάνεται κατά την απόδοση και τυπώνεται με την αναφορά της',
diff --git a/shared/src/i18n/hu/journey.ts b/shared/src/i18n/hu/journey.ts
index 40c3b8a94..1d4c72961 100644
--- a/shared/src/i18n/hu/journey.ts
+++ b/shared/src/i18n/hu/journey.ts
@@ -411,6 +411,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': 'Napszámláló',
'journey.studio.dayWord': 'NAP',
'journey.studio.coordsMark': 'Koordináták',
+ 'journey.studio.coordsDms': 'Fok',
+ 'journey.studio.coordsDecimal': 'Tizedes',
'journey.studio.flagMark': 'Zászló',
'journey.studio.distanceMark': 'Távolság',
'journey.studio.metric.distance': 'Távolság',
@@ -424,6 +426,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': 'Számok',
'journey.studio.kind.countries': 'Országok',
'journey.studio.kind.badge': 'Jel',
+ 'journey.studio.kind.list': 'Lista',
+ 'journey.studio.kind.icon': 'Ikon',
+ 'journey.studio.duplicate': 'Duplikálás',
+ 'journey.studio.style': 'Stílus',
+ 'journey.studio.shows': 'Megjelenítés',
+ 'journey.studio.size': 'Méret',
+ 'journey.studio.weight': 'Betűvastagság',
+ 'journey.studio.italic': 'Dőlt',
+ 'journey.studio.tracking': 'Betűköz',
+ 'journey.studio.rotation': 'Forgatás',
+ 'journey.studio.opacity': 'Átlátszatlanság',
+ 'journey.studio.fill': 'Kitöltés',
+ 'journey.studio.fillOn': 'Kitöltve',
+ 'journey.studio.stroke': 'Körvonal',
+ 'journey.studio.strokeWidth': 'Vastagság',
+ 'journey.studio.gradient': 'Színátmenet',
+ 'journey.studio.gradientDown': 'Lefelé',
+ 'journey.studio.gradientUp': 'Felfelé',
+ 'journey.studio.showIcons': 'Ikonok',
+ 'journey.studio.mapFit': 'Terület',
+ 'journey.studio.mapPadding': 'Ráhagyás',
+ 'journey.studio.mapShape': 'Alak',
+ 'journey.studio.align.left': 'Balra',
+ 'journey.studio.align.center': 'Középre',
+ 'journey.studio.align.right': 'Jobbra',
+ 'journey.studio.markStyle.plain': 'Egyszerű',
+ 'journey.studio.markStyle.chip': 'Címke',
+ 'journey.studio.markStyle.outline': 'Körvonalas',
+ 'journey.studio.markStyle.stacked': 'Egymás alatt',
+ 'journey.studio.icon': 'Ikon',
+ 'journey.studio.iconAndLabel': 'Ikon és szöveg',
+ 'journey.studio.iconOnly': 'Csak ikon',
+ 'journey.studio.labelOnly': 'Csak szöveg',
+ 'journey.studio.icons': 'Ikonok',
+ 'journey.studio.iconsForTravel': 'Utazáshoz',
+ 'journey.studio.iconsAll': 'Összes ikon',
+ 'journey.studio.searchIcons': 'Ikonok keresése',
+ 'journey.studio.lineWidth': 'Vastagság',
'journey.studio.mask': 'Formára vágás',
'journey.studio.maskNone': 'Nincs',
'journey.studio.frameStyle': 'Keret',
@@ -492,6 +532,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': 'Ennek a betűtípusnak nincs ilyen vastagsága',
'journey.studio.mapSource': 'Térkép forrása',
'journey.studio.mapSourceVector': 'Körvonalak',
+ 'journey.studio.mapSourceRelief': 'Domborzat',
+ 'journey.studio.routeLook': 'A vonal',
+ 'journey.studio.roads': 'Utak',
+ 'journey.studio.roadsFetch': 'Kövesse az utakat',
+ 'journey.studio.roadsFollow': 'Utakon',
+ 'journey.studio.roadsDirect': 'Légvonalban',
+ 'journey.studio.recommended': 'ajánlott',
+ 'journey.studio.bleed': 'Kifutó',
+ 'journey.studio.safeArea': 'Védőzóna',
+ 'journey.studio.roadsAgain': 'Kérje le újra',
+ 'journey.studio.roadsClear': 'Törlés',
+ 'journey.studio.roadsBusy': 'Lekérés',
+ 'journey.studio.roadsHint': 'Megkérdezi egy útvonaltervező szolgáltatástól, merre vezet az egyes szakaszok útja. A hosszú szakaszok változatlanok maradnak.',
+ 'journey.studio.roadsHave': 'Az utak ebben a könyvben vannak elmentve, így offline is ugyanaz a vonal nyomtatódik.',
+ 'journey.studio.routeStyle': 'Megjelenés',
+ 'journey.studio.routePlain': 'Sima',
+ 'journey.studio.routeDrawn': 'Rajzolt',
+ 'journey.studio.routeArc': 'Hosszú szakaszok',
+ 'journey.studio.routeStraight': 'Egyenes',
+ 'journey.studio.routeBow': 'Ívelt',
+ 'journey.studio.routeDashArcs': 'Ívelt szakaszok szaggatva',
+ 'journey.studio.mapStops': 'Állomások',
+ 'journey.studio.pinDot': 'Pontok',
+ 'journey.studio.pinPhoto': 'Fotók',
+ 'journey.studio.pinPhotoNone': 'Ezeken az állomásokon még nincs fénykép, ezért pontként jelennek meg.',
+ 'journey.studio.mapSourceSatellite': 'Műhold',
+ 'journey.studio.mapSourceSatelliteHint': 'Felhőmentes Sentinel-2, forrásmegjelöléssel szabadon nyomtatható. Utcaszintig éles.',
+ 'journey.studio.mapSourceReliefHint': 'NASA árnyékolt domborzat, szabadon nyomtatható. Országhoz vagy kontinenshez ideális, egyetlen városhoz túl durva.',
+ 'journey.studio.mapPrintDpi': 'Nyomtatásban körülbelül',
+ 'journey.studio.mapPrintDpiLow': 'ebben a méretben életlen, próbáljon tágabb nézetet vagy másik forrást',
+ 'journey.studio.mapPerTrip': 'Egyszerre egy út',
+ 'journey.studio.mapWholeJourney': 'Teljes utazás',
+ 'journey.studio.mapScope': 'Megjelenítés',
'journey.studio.mapSourceTiles': 'Térképcsempék',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': 'Rendereléskor töltődik le, és forrásmegjelöléssel nyomtatódik',
diff --git a/shared/src/i18n/id/journey.ts b/shared/src/i18n/id/journey.ts
index 094a7729f..660ace585 100644
--- a/shared/src/i18n/id/journey.ts
+++ b/shared/src/i18n/id/journey.ts
@@ -410,6 +410,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': 'Penghitung hari',
'journey.studio.dayWord': 'HARI',
'journey.studio.coordsMark': 'Koordinat',
+ 'journey.studio.coordsDms': 'Derajat',
+ 'journey.studio.coordsDecimal': 'Desimal',
'journey.studio.flagMark': 'Bendera',
'journey.studio.distanceMark': 'Jarak',
'journey.studio.metric.distance': 'Jarak',
@@ -423,6 +425,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': 'Angka',
'journey.studio.kind.countries': 'Negara',
'journey.studio.kind.badge': 'Tanda',
+ 'journey.studio.kind.list': 'Daftar',
+ 'journey.studio.kind.icon': 'Ikon',
+ 'journey.studio.duplicate': 'Duplikat',
+ 'journey.studio.style': 'Gaya',
+ 'journey.studio.shows': 'Tampilkan',
+ 'journey.studio.size': 'Ukuran',
+ 'journey.studio.weight': 'Ketebalan',
+ 'journey.studio.italic': 'Miring',
+ 'journey.studio.tracking': 'Jarak huruf',
+ 'journey.studio.rotation': 'Rotasi',
+ 'journey.studio.opacity': 'Opasitas',
+ 'journey.studio.fill': 'Isi',
+ 'journey.studio.fillOn': 'Terisi',
+ 'journey.studio.stroke': 'Garis luar',
+ 'journey.studio.strokeWidth': 'Tebal',
+ 'journey.studio.gradient': 'Pudar',
+ 'journey.studio.gradientDown': 'Ke bawah',
+ 'journey.studio.gradientUp': 'Ke atas',
+ 'journey.studio.showIcons': 'Ikon',
+ 'journey.studio.mapFit': 'Cakupan',
+ 'journey.studio.mapPadding': 'Ruang',
+ 'journey.studio.mapShape': 'Bentuk',
+ 'journey.studio.align.left': 'Kiri',
+ 'journey.studio.align.center': 'Tengah',
+ 'journey.studio.align.right': 'Kanan',
+ 'journey.studio.markStyle.plain': 'Polos',
+ 'journey.studio.markStyle.chip': 'Kapsul',
+ 'journey.studio.markStyle.outline': 'Kapsul garis',
+ 'journey.studio.markStyle.stacked': 'Bertumpuk',
+ 'journey.studio.icon': 'Ikon',
+ 'journey.studio.iconAndLabel': 'Ikon & teks',
+ 'journey.studio.iconOnly': 'Hanya ikon',
+ 'journey.studio.labelOnly': 'Hanya teks',
+ 'journey.studio.icons': 'Ikon',
+ 'journey.studio.iconsForTravel': 'Untuk perjalanan',
+ 'journey.studio.iconsAll': 'Semua ikon',
+ 'journey.studio.searchIcons': 'Cari ikon',
+ 'journey.studio.lineWidth': 'Tebal garis',
'journey.studio.mask': 'Potong ke bentuk',
'journey.studio.maskNone': 'Tidak ada',
'journey.studio.frameStyle': 'Bingkai',
@@ -491,6 +531,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': 'Huruf ini tidak punya ketebalan itu',
'journey.studio.mapSource': 'Sumber peta',
'journey.studio.mapSourceVector': 'Garis luar',
+ 'journey.studio.mapSourceRelief': 'Relief',
+ 'journey.studio.mapSourceSatellite': 'Satelit',
+ 'journey.studio.mapSourceSatelliteHint': 'Sentinel-2 bebas awan, bebas dicetak dengan atribusi. Tajam sampai ke jalan kota.',
+ 'journey.studio.routeLook': 'Garis rute',
+ 'journey.studio.routeStyle': 'Gaya garis',
+ 'journey.studio.routePlain': 'Biasa',
+ 'journey.studio.routeDrawn': 'Digambar',
+ 'journey.studio.routeArc': 'Segmen panjang',
+ 'journey.studio.routeStraight': 'Lurus',
+ 'journey.studio.routeBow': 'Lengkung',
+ 'journey.studio.routeDashArcs': 'Segmen lengkung putus-putus',
+ 'journey.studio.mapStops': 'Perhentian',
+ 'journey.studio.pinDot': 'Titik',
+ 'journey.studio.pinPhoto': 'Foto',
+ 'journey.studio.pinPhotoNone': 'Belum ada foto di perhentian ini, jadi digambar sebagai titik.',
+ 'journey.studio.roads': 'Jalan',
+ 'journey.studio.roadsFetch': 'Ikuti jalan',
+ 'journey.studio.roadsFollow': 'Lewat jalan',
+ 'journey.studio.roadsDirect': 'Tanpa jalan',
+ 'journey.studio.recommended': 'disarankan',
+ 'journey.studio.bleed': 'Bleed',
+ 'journey.studio.safeArea': 'Aman',
+ 'journey.studio.roadsAgain': 'Ambil lagi',
+ 'journey.studio.roadsClear': 'Hapus',
+ 'journey.studio.roadsBusy': 'Meminta',
+ 'journey.studio.roadsHint': 'Minta layanan rute untuk jalur yang dilalui tiap segmen. Segmen panjang dibiarkan apa adanya.',
+ 'journey.studio.roadsHave': 'Jalannya tersimpan di buku ini, jadi garis yang tercetak sama walau offline.',
+ 'journey.studio.mapPerTrip': 'Perjalanan satu per satu',
+ 'journey.studio.mapWholeJourney': 'Seluruh Journey',
+ 'journey.studio.mapScope': 'Tampilkan',
+ 'journey.studio.mapPrintDpi': 'Hasil cetak sekitar',
+ 'journey.studio.mapPrintDpiLow': 'kurang tajam pada ukuran ini, coba tampilan lebih lebar atau sumber lain',
+ 'journey.studio.mapSourceReliefHint': 'Relief berbayang NASA, bebas dicetak. Cocok untuk negara atau benua, terlalu kasar untuk satu kota.',
'journey.studio.mapSourceTiles': 'Ubin peta',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': 'Diambil saat render dan dicetak dengan atribusinya',
diff --git a/shared/src/i18n/it/journey.ts b/shared/src/i18n/it/journey.ts
index b0ca8dc25..fc613b6c3 100644
--- a/shared/src/i18n/it/journey.ts
+++ b/shared/src/i18n/it/journey.ts
@@ -411,6 +411,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': 'Contagiorni',
'journey.studio.dayWord': 'GIORNO',
'journey.studio.coordsMark': 'Coordinate',
+ 'journey.studio.coordsDms': 'Gradi',
+ 'journey.studio.coordsDecimal': 'Decimale',
'journey.studio.flagMark': 'Bandiera',
'journey.studio.distanceMark': 'Distanza',
'journey.studio.metric.distance': 'Distanza',
@@ -424,6 +426,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': 'Dati',
'journey.studio.kind.countries': 'Paesi',
'journey.studio.kind.badge': 'Segno',
+ 'journey.studio.kind.list': 'Elenco',
+ 'journey.studio.kind.icon': 'Icona',
+ 'journey.studio.duplicate': 'Duplica',
+ 'journey.studio.style': 'Stile',
+ 'journey.studio.shows': 'Mostra',
+ 'journey.studio.size': 'Dimensione',
+ 'journey.studio.weight': 'Peso',
+ 'journey.studio.italic': 'Corsivo',
+ 'journey.studio.tracking': 'Spaziatura',
+ 'journey.studio.rotation': 'Rotazione',
+ 'journey.studio.opacity': 'Opacità',
+ 'journey.studio.fill': 'Riempimento',
+ 'journey.studio.fillOn': 'Pieno',
+ 'journey.studio.stroke': 'Contorno',
+ 'journey.studio.strokeWidth': 'Spessore',
+ 'journey.studio.gradient': 'Sfumatura',
+ 'journey.studio.gradientDown': 'Verso il basso',
+ 'journey.studio.gradientUp': 'Verso l’alto',
+ 'journey.studio.showIcons': 'Icone',
+ 'journey.studio.mapFit': 'Adatta a',
+ 'journey.studio.mapPadding': 'Aria',
+ 'journey.studio.mapShape': 'Forma',
+ 'journey.studio.align.left': 'Sinistra',
+ 'journey.studio.align.center': 'Centro',
+ 'journey.studio.align.right': 'Destra',
+ 'journey.studio.markStyle.plain': 'Semplice',
+ 'journey.studio.markStyle.chip': 'Capsula',
+ 'journey.studio.markStyle.outline': 'Contorno',
+ 'journey.studio.markStyle.stacked': 'Impilato',
+ 'journey.studio.icon': 'Icona',
+ 'journey.studio.iconAndLabel': 'Icona e testo',
+ 'journey.studio.iconOnly': 'Solo icona',
+ 'journey.studio.labelOnly': 'Solo testo',
+ 'journey.studio.icons': 'Icone',
+ 'journey.studio.iconsForTravel': 'Per il viaggio',
+ 'journey.studio.iconsAll': 'Tutte le icone',
+ 'journey.studio.searchIcons': 'Cerca icone',
+ 'journey.studio.lineWidth': 'Spessore',
'journey.studio.mask': 'Ritaglia a forma',
'journey.studio.maskNone': 'Nessuna',
'journey.studio.frameStyle': 'Cornice',
@@ -492,6 +532,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': 'Questo carattere non ha questo peso',
'journey.studio.mapSource': 'Origine mappa',
'journey.studio.mapSourceVector': 'Contorni',
+ 'journey.studio.mapSourceRelief': 'Rilievo',
+ 'journey.studio.mapSourceSatellite': 'Satellite',
+ 'journey.studio.mapSourceSatelliteHint': 'Sentinel-2 senza nuvole, libero da stampare con la sua attribuzione. Nitido fino alla via.',
+ 'journey.studio.routeLook': 'La linea',
+ 'journey.studio.routeStyle': 'Tratto',
+ 'journey.studio.routePlain': 'Semplice',
+ 'journey.studio.routeDrawn': 'Disegnato',
+ 'journey.studio.routeArc': 'Lunghe distanze',
+ 'journey.studio.routeStraight': 'Dritta',
+ 'journey.studio.routeBow': 'Curva',
+ 'journey.studio.routeDashArcs': 'Curve tratteggiate',
+ 'journey.studio.mapStops': 'Tappe',
+ 'journey.studio.pinDot': 'Punti',
+ 'journey.studio.pinPhoto': 'Foto',
+ 'journey.studio.pinPhotoNone': 'Per ora nessuna foto su queste tappe, quindi sono disegnate come punti.',
+ 'journey.studio.roads': 'Strade',
+ 'journey.studio.roadsFetch': 'Segui le strade',
+ 'journey.studio.roadsFollow': 'Su strada',
+ 'journey.studio.roadsDirect': 'Linea d’aria',
+ 'journey.studio.recommended': 'consigliato',
+ 'journey.studio.bleed': 'Abbondanza',
+ 'journey.studio.safeArea': 'Sicurezza',
+ 'journey.studio.roadsAgain': 'Richiedi di nuovo',
+ 'journey.studio.roadsClear': 'Cancella',
+ 'journey.studio.roadsBusy': 'Richiesta',
+ 'journey.studio.roadsHint': 'Chiede a un servizio di itinerari il percorso fatto su ogni tratta. Le lunghe distanze restano come sono.',
+ 'journey.studio.roadsHave': 'Le strade sono salvate in questo libro, così stampa la stessa linea anche offline.',
+ 'journey.studio.mapSourceReliefHint': 'Rilievo ombreggiato della NASA, libero da stampare. Ideale per un paese o un continente, troppo grossolano per una città.',
+ 'journey.studio.mapPrintDpi': 'Stampa a circa',
+ 'journey.studio.mapPrintDpiLow': 'poco nitida a questa dimensione, prova una vista più ampia o un’altra origine',
+ 'journey.studio.mapPerTrip': 'Un viaggio alla volta',
+ 'journey.studio.mapWholeJourney': 'Tutto il diario',
+ 'journey.studio.mapScope': 'Mostra',
'journey.studio.mapSourceTiles': 'Tessere',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': 'Scaricata al rendering e stampata con la sua attribuzione',
diff --git a/shared/src/i18n/ja/journey.ts b/shared/src/i18n/ja/journey.ts
index 629ec8900..babef51c2 100644
--- a/shared/src/i18n/ja/journey.ts
+++ b/shared/src/i18n/ja/journey.ts
@@ -412,6 +412,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': '日数カウンター',
'journey.studio.dayWord': 'DAY',
'journey.studio.coordsMark': '座標',
+ 'journey.studio.coordsDms': '度分秒',
+ 'journey.studio.coordsDecimal': '十進度',
'journey.studio.flagMark': '国旗',
'journey.studio.distanceMark': '距離',
'journey.studio.metric.distance': '距離',
@@ -425,6 +427,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': '数値',
'journey.studio.kind.countries': '国',
'journey.studio.kind.badge': 'マーク',
+ 'journey.studio.kind.list': 'リスト',
+ 'journey.studio.kind.icon': 'アイコン',
+ 'journey.studio.duplicate': '複製',
+ 'journey.studio.style': 'スタイル',
+ 'journey.studio.shows': '表示',
+ 'journey.studio.size': 'サイズ',
+ 'journey.studio.weight': 'ウェイト',
+ 'journey.studio.italic': '斜体',
+ 'journey.studio.tracking': '字間',
+ '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': '余白',
+ 'journey.studio.mapShape': '形',
+ 'journey.studio.align.left': '左',
+ 'journey.studio.align.center': '中央',
+ 'journey.studio.align.right': '右',
+ 'journey.studio.markStyle.plain': '標準',
+ 'journey.studio.markStyle.chip': 'チップ',
+ 'journey.studio.markStyle.outline': '枠線',
+ 'journey.studio.markStyle.stacked': '縦並び',
+ 'journey.studio.icon': 'アイコン',
+ 'journey.studio.iconAndLabel': 'アイコンと文字',
+ 'journey.studio.iconOnly': 'アイコンのみ',
+ 'journey.studio.labelOnly': '文字のみ',
+ 'journey.studio.icons': 'アイコン',
+ 'journey.studio.iconsForTravel': '旅向け',
+ 'journey.studio.iconsAll': 'すべてのアイコン',
+ 'journey.studio.searchIcons': 'アイコンを検索',
+ 'journey.studio.lineWidth': '太さ',
'journey.studio.mask': '形で切り抜く',
'journey.studio.maskNone': 'なし',
'journey.studio.frameStyle': 'フレーム',
@@ -493,6 +533,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': 'この書体にこのウェイトはありません',
'journey.studio.mapSource': '地図のソース',
'journey.studio.mapSourceVector': '輪郭',
+ 'journey.studio.mapSourceRelief': '陰影起伏',
+ 'journey.studio.mapSourceSatellite': '衛星写真',
+ 'journey.studio.mapSourceSatelliteHint': '雲のない Sentinel-2。出典を付ければ無料で印刷できます。街の通りまで鮮明です。',
+ 'journey.studio.routeLook': 'ルートの線',
+ 'journey.studio.routeStyle': '描き方',
+ 'journey.studio.routePlain': '一本線',
+ 'journey.studio.routeDrawn': '地図帳風',
+ 'journey.studio.routeArc': '長い区間',
+ 'journey.studio.routeStraight': '直線',
+ 'journey.studio.routeBow': '曲線',
+ 'journey.studio.routeDashArcs': '曲線を破線にする',
+ 'journey.studio.mapStops': '立ち寄り先',
+ 'journey.studio.pinDot': '点',
+ 'journey.studio.pinPhoto': '写真',
+ 'journey.studio.pinPhotoNone': 'これらの立ち寄り先にはまだ写真がないため、点で表示します。',
+ 'journey.studio.roads': '道路',
+ 'journey.studio.roadsFetch': '道路に沿わせる',
+ 'journey.studio.roadsFollow': '道なり',
+ 'journey.studio.roadsDirect': '直行',
+ 'journey.studio.recommended': '推奨',
+ 'journey.studio.bleed': '塗り足し',
+ 'journey.studio.safeArea': '安全領域',
+ 'journey.studio.roadsAgain': '取得し直す',
+ 'journey.studio.roadsClear': '破棄',
+ 'journey.studio.roadsBusy': '取得中',
+ 'journey.studio.roadsHint': 'ルート検索サービスに各区間の走行経路を問い合わせます。長い区間はそのままです。',
+ 'journey.studio.roadsHave': '道路はこの本に保存されるので、オフラインでも同じ線で印刷されます。',
+ 'journey.studio.mapSourceReliefHint': 'NASA の陰影起伏図。ライセンス不要で印刷できます。国や大陸には最適ですが、都市単位には粗すぎます。',
+ 'journey.studio.mapPrintDpi': '印刷解像度は約',
+ 'journey.studio.mapPrintDpiLow': 'このサイズではぼやけます。範囲を広げるか別のソースをお試しください',
+ 'journey.studio.mapPerTrip': '旅行ごと',
+ 'journey.studio.mapWholeJourney': '日記全体',
+ 'journey.studio.mapScope': '表示',
'journey.studio.mapSourceTiles': '地図タイル',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': 'レンダリング時に取得し、出典を付けて印刷します',
diff --git a/shared/src/i18n/ko/journey.ts b/shared/src/i18n/ko/journey.ts
index 23396a3e6..e5a78e348 100644
--- a/shared/src/i18n/ko/journey.ts
+++ b/shared/src/i18n/ko/journey.ts
@@ -411,6 +411,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': '일수 카운터',
'journey.studio.dayWord': 'DAY',
'journey.studio.coordsMark': '좌표',
+ 'journey.studio.coordsDms': '도분초',
+ 'journey.studio.coordsDecimal': '십진도',
'journey.studio.flagMark': '국기',
'journey.studio.distanceMark': '거리',
'journey.studio.metric.distance': '거리',
@@ -424,6 +426,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': '수치',
'journey.studio.kind.countries': '국가',
'journey.studio.kind.badge': '표시',
+ 'journey.studio.kind.list': '목록',
+ 'journey.studio.kind.icon': '아이콘',
+ 'journey.studio.duplicate': '복제',
+ 'journey.studio.style': '스타일',
+ 'journey.studio.shows': '표시 내용',
+ 'journey.studio.size': '크기',
+ 'journey.studio.weight': '굵기',
+ 'journey.studio.italic': '기울임',
+ 'journey.studio.tracking': '자간',
+ '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': '여백',
+ 'journey.studio.mapShape': '모양',
+ 'journey.studio.align.left': '왼쪽',
+ 'journey.studio.align.center': '가운데',
+ 'journey.studio.align.right': '오른쪽',
+ 'journey.studio.markStyle.plain': '기본',
+ 'journey.studio.markStyle.chip': '칩',
+ 'journey.studio.markStyle.outline': '테두리',
+ 'journey.studio.markStyle.stacked': '세로',
+ 'journey.studio.icon': '아이콘',
+ 'journey.studio.iconAndLabel': '아이콘과 텍스트',
+ 'journey.studio.iconOnly': '아이콘만',
+ 'journey.studio.labelOnly': '텍스트만',
+ 'journey.studio.icons': '아이콘',
+ 'journey.studio.iconsForTravel': '여행용',
+ 'journey.studio.iconsAll': '모든 아이콘',
+ 'journey.studio.searchIcons': '아이콘 검색',
+ 'journey.studio.lineWidth': '두께',
'journey.studio.mask': '모양으로 자르기',
'journey.studio.maskNone': '없음',
'journey.studio.frameStyle': '프레임',
@@ -492,6 +532,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': '이 서체에는 해당 굵기가 없습니다',
'journey.studio.mapSource': '지도 소스',
'journey.studio.mapSourceVector': '윤곽선',
+ 'journey.studio.mapSourceRelief': '음영 지형',
+ 'journey.studio.mapSourceSatellite': '위성 사진',
+ 'journey.studio.mapSourceSatelliteHint': '구름 없는 Sentinel-2 이미지로, 출처를 밝히면 무료로 인쇄할 수 있습니다. 도시의 거리까지 선명합니다.',
+ 'journey.studio.routeLook': '경로 선',
+ 'journey.studio.routeStyle': '그리기 방식',
+ 'journey.studio.routePlain': '단선',
+ 'journey.studio.routeDrawn': '지도책',
+ 'journey.studio.routeArc': '긴 구간',
+ 'journey.studio.routeStraight': '직선',
+ 'journey.studio.routeBow': '곡선',
+ 'journey.studio.routeDashArcs': '곡선 구간을 파선으로',
+ 'journey.studio.mapStops': '경유지',
+ 'journey.studio.pinDot': '점',
+ 'journey.studio.pinPhoto': '사진',
+ 'journey.studio.pinPhotoNone': '이 경유지에는 아직 사진이 없어서 점으로 표시합니다.',
+ 'journey.studio.roads': '도로',
+ 'journey.studio.roadsFetch': '도로를 따라가기',
+ 'journey.studio.roadsFollow': '길 따라',
+ 'journey.studio.roadsDirect': '바로 잇기',
+ 'journey.studio.recommended': '권장',
+ 'journey.studio.bleed': '도련',
+ 'journey.studio.safeArea': '안전 영역',
+ 'journey.studio.roadsAgain': '다시 가져오기',
+ 'journey.studio.roadsClear': '지우기',
+ 'journey.studio.roadsBusy': '요청 중',
+ 'journey.studio.roadsHint': '경로 안내 서비스에 각 구간을 실제로 달린 길을 요청합니다. 긴 구간은 그대로 둡니다.',
+ 'journey.studio.roadsHave': '도로는 이 책에 저장되므로 오프라인에서도 같은 선으로 인쇄됩니다.',
+ 'journey.studio.mapSourceReliefHint': 'NASA 음영 기복 이미지로 라이선스 없이 인쇄할 수 있습니다. 국가나 대륙에 적합하고 도시 하나에는 너무 거칩니다.',
+ 'journey.studio.mapPrintDpi': '인쇄 해상도 약',
+ 'journey.studio.mapPrintDpiLow': '이 크기에서는 흐릿합니다. 범위를 넓히거나 다른 소스를 사용해 보세요',
+ 'journey.studio.mapPerTrip': '여행별',
+ 'journey.studio.mapWholeJourney': 'Journey 전체',
+ 'journey.studio.mapScope': '표시 내용',
'journey.studio.mapSourceTiles': '지도 타일',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': '렌더링할 때 가져오며 출처와 함께 인쇄됩니다',
diff --git a/shared/src/i18n/nl/journey.ts b/shared/src/i18n/nl/journey.ts
index 42018e879..a25f686e0 100644
--- a/shared/src/i18n/nl/journey.ts
+++ b/shared/src/i18n/nl/journey.ts
@@ -412,6 +412,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': 'Dagteller',
'journey.studio.dayWord': 'DAG',
'journey.studio.coordsMark': 'Coördinaten',
+ 'journey.studio.coordsDms': 'Graden',
+ 'journey.studio.coordsDecimal': 'Decimaal',
'journey.studio.flagMark': 'Vlag',
'journey.studio.distanceMark': 'Afstand',
'journey.studio.metric.distance': 'Afstand',
@@ -425,6 +427,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': 'Cijfers',
'journey.studio.kind.countries': 'Landen',
'journey.studio.kind.badge': 'Markering',
+ 'journey.studio.kind.list': 'Lijst',
+ 'journey.studio.kind.icon': 'Icoon',
+ 'journey.studio.duplicate': 'Dupliceren',
+ 'journey.studio.style': 'Stijl',
+ 'journey.studio.shows': 'Weergave',
+ 'journey.studio.size': 'Grootte',
+ 'journey.studio.weight': 'Gewicht',
+ 'journey.studio.italic': 'Cursief',
+ 'journey.studio.tracking': 'Spatiëring',
+ 'journey.studio.rotation': 'Rotatie',
+ 'journey.studio.opacity': 'Dekking',
+ 'journey.studio.fill': 'Vulling',
+ 'journey.studio.fillOn': 'Gevuld',
+ 'journey.studio.stroke': 'Omtrek',
+ 'journey.studio.strokeWidth': 'Dikte',
+ 'journey.studio.gradient': 'Verloop',
+ 'journey.studio.gradientDown': 'Naar beneden',
+ 'journey.studio.gradientUp': 'Naar boven',
+ 'journey.studio.showIcons': 'Iconen',
+ 'journey.studio.mapFit': 'Bereik',
+ 'journey.studio.mapPadding': 'Ruimte',
+ 'journey.studio.mapShape': 'Vorm',
+ 'journey.studio.align.left': 'Links',
+ 'journey.studio.align.center': 'Midden',
+ 'journey.studio.align.right': 'Rechts',
+ 'journey.studio.markStyle.plain': 'Gewoon',
+ 'journey.studio.markStyle.chip': 'Gevuld',
+ 'journey.studio.markStyle.outline': 'Omlijnd',
+ 'journey.studio.markStyle.stacked': 'Gestapeld',
+ 'journey.studio.icon': 'Icoon',
+ 'journey.studio.iconAndLabel': 'Icoon & tekst',
+ 'journey.studio.iconOnly': 'Alleen icoon',
+ 'journey.studio.labelOnly': 'Alleen tekst',
+ 'journey.studio.icons': 'Iconen',
+ 'journey.studio.iconsForTravel': 'Voor reizen',
+ 'journey.studio.iconsAll': 'Alle iconen',
+ 'journey.studio.searchIcons': 'Iconen zoeken',
+ 'journey.studio.lineWidth': 'Dikte',
'journey.studio.mask': 'Uitsnijden in vorm',
'journey.studio.maskNone': 'Geen',
'journey.studio.frameStyle': 'Kader',
@@ -493,6 +533,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': 'Dit lettertype heeft die dikte niet',
'journey.studio.mapSource': 'Kaartbron',
'journey.studio.mapSourceVector': 'Omtrekken',
+ 'journey.studio.mapSourceRelief': 'Reliëf',
+ 'journey.studio.mapSourceSatellite': 'Satelliet',
+ 'journey.studio.mapSourceSatelliteHint': 'Wolkenvrije Sentinel-2, met bronvermelding vrij te drukken. Scherp tot op de straat in een stad.',
+ 'journey.studio.routeLook': 'De lijn',
+ 'journey.studio.routeStyle': 'Lijnstijl',
+ 'journey.studio.routePlain': 'Effen',
+ 'journey.studio.routeDrawn': 'Getekend',
+ 'journey.studio.routeArc': 'Lange trajecten',
+ 'journey.studio.routeStraight': 'Recht',
+ 'journey.studio.routeBow': 'Gebogen',
+ 'journey.studio.routeDashArcs': 'Gebogen trajecten gestreept',
+ 'journey.studio.mapStops': 'Stops',
+ 'journey.studio.pinDot': 'Stippen',
+ 'journey.studio.pinPhoto': 'Foto’s',
+ 'journey.studio.pinPhotoNone': 'Deze stops hebben nog geen foto’s, dus worden ze als stippen getekend.',
+ 'journey.studio.roads': 'Wegen',
+ 'journey.studio.roadsFetch': 'De wegen volgen',
+ 'journey.studio.roadsFollow': 'Via wegen',
+ 'journey.studio.roadsDirect': 'Hemelsbreed',
+ 'journey.studio.recommended': 'aanbevolen',
+ 'journey.studio.bleed': 'Afloop',
+ 'journey.studio.safeArea': 'Veilig',
+ 'journey.studio.roadsAgain': 'Opnieuw ophalen',
+ 'journey.studio.roadsClear': 'Wissen',
+ 'journey.studio.roadsBusy': 'Ophalen',
+ 'journey.studio.roadsHint': 'Vraagt een routedienst welke weg er per traject is gereden. Lange trajecten blijven zoals ze zijn.',
+ 'journey.studio.roadsHave': 'De wegen staan opgeslagen in dit boek, dus offline drukt het dezelfde lijn.',
+ 'journey.studio.mapPerTrip': 'Eén reis per keer',
+ 'journey.studio.mapWholeJourney': 'Hele reisverslag',
+ 'journey.studio.mapScope': 'Weergave',
+ 'journey.studio.mapPrintDpi': 'Drukt af op ongeveer',
+ 'journey.studio.mapPrintDpiLow': 'onscherp op dit formaat, probeer een ruimere uitsnede of een andere bron',
+ 'journey.studio.mapSourceReliefHint': 'NASA-schaduwreliëf, rechtenvrij te drukken. Goed voor een land of een continent, te grof voor één stad.',
'journey.studio.mapSourceTiles': 'Kaarttegels',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': 'Wordt bij het renderen opgehaald en met bronvermelding gedrukt',
diff --git a/shared/src/i18n/pl/journey.ts b/shared/src/i18n/pl/journey.ts
index e4ec9ea26..97db3eec4 100644
--- a/shared/src/i18n/pl/journey.ts
+++ b/shared/src/i18n/pl/journey.ts
@@ -411,6 +411,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': 'Licznik dni',
'journey.studio.dayWord': 'DZIEŃ',
'journey.studio.coordsMark': 'Współrzędne',
+ 'journey.studio.coordsDms': 'Stopnie',
+ 'journey.studio.coordsDecimal': 'Dziesiętne',
'journey.studio.flagMark': 'Flaga',
'journey.studio.distanceMark': 'Dystans',
'journey.studio.metric.distance': 'Dystans',
@@ -424,6 +426,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': 'Dane',
'journey.studio.kind.countries': 'Kraje',
'journey.studio.kind.badge': 'Znacznik',
+ 'journey.studio.kind.list': 'Lista',
+ 'journey.studio.kind.icon': 'Ikona',
+ 'journey.studio.duplicate': 'Duplikuj',
+ 'journey.studio.style': 'Styl',
+ 'journey.studio.shows': 'Pokaż',
+ 'journey.studio.size': 'Rozmiar',
+ 'journey.studio.weight': 'Waga',
+ 'journey.studio.italic': 'Kursywa',
+ 'journey.studio.tracking': 'Światło',
+ 'journey.studio.rotation': 'Obrót',
+ 'journey.studio.opacity': 'Krycie',
+ 'journey.studio.fill': 'Wypełnienie',
+ 'journey.studio.fillOn': 'Wypełniony',
+ 'journey.studio.stroke': 'Obrys',
+ 'journey.studio.strokeWidth': 'Grubość',
+ 'journey.studio.gradient': 'Gradient',
+ 'journey.studio.gradientDown': 'W dół',
+ 'journey.studio.gradientUp': 'W górę',
+ 'journey.studio.showIcons': 'Ikony',
+ 'journey.studio.mapFit': 'Zakres',
+ 'journey.studio.mapPadding': 'Margines',
+ 'journey.studio.mapShape': 'Kształt',
+ 'journey.studio.align.left': 'Do lewej',
+ 'journey.studio.align.center': 'Do środka',
+ 'journey.studio.align.right': 'Do prawej',
+ 'journey.studio.markStyle.plain': 'Zwykły',
+ 'journey.studio.markStyle.chip': 'Pełny',
+ 'journey.studio.markStyle.outline': 'Obrysowany',
+ 'journey.studio.markStyle.stacked': 'Piętrowy',
+ 'journey.studio.icon': 'Ikona',
+ 'journey.studio.iconAndLabel': 'Ikona i tekst',
+ 'journey.studio.iconOnly': 'Tylko ikona',
+ 'journey.studio.labelOnly': 'Tylko tekst',
+ 'journey.studio.icons': 'Ikony',
+ 'journey.studio.iconsForTravel': 'Podróżnicze',
+ 'journey.studio.iconsAll': 'Wszystkie ikony',
+ 'journey.studio.searchIcons': 'Szukaj ikon',
+ 'journey.studio.lineWidth': 'Grubość',
'journey.studio.mask': 'Przytnij do kształtu',
'journey.studio.maskNone': 'Brak',
'journey.studio.frameStyle': 'Ramka',
@@ -492,6 +532,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': 'Ten krój nie ma tej grubości',
'journey.studio.mapSource': 'Źródło mapy',
'journey.studio.mapSourceVector': 'Kontury',
+ 'journey.studio.mapSourceRelief': 'Relief',
+ 'journey.studio.mapSourceSatellite': 'Satelita',
+ 'journey.studio.mapSourceSatelliteHint': 'Bezchmurne Sentinel-2, można drukować bez opłat z podaniem źródła. Ostre aż do pojedynczej ulicy w mieście.',
+ 'journey.studio.routeLook': 'Linia',
+ 'journey.studio.routeStyle': 'Styl linii',
+ 'journey.studio.routePlain': 'Gładka',
+ 'journey.studio.routeDrawn': 'Rysowana',
+ 'journey.studio.routeArc': 'Długie odcinki',
+ 'journey.studio.routeStraight': 'Prosta',
+ 'journey.studio.routeBow': 'Łukowa',
+ 'journey.studio.routeDashArcs': 'Przerywane łuki',
+ 'journey.studio.mapStops': 'Przystanki',
+ 'journey.studio.pinDot': 'Kropki',
+ 'journey.studio.pinPhoto': 'Zdjęcia',
+ 'journey.studio.pinPhotoNone': 'Te przystanki nie mają jeszcze zdjęć, więc są rysowane jako kropki.',
+ 'journey.studio.roads': 'Drogi',
+ 'journey.studio.roadsFetch': 'Poprowadź po drogach',
+ 'journey.studio.roadsFollow': 'Po drogach',
+ 'journey.studio.roadsDirect': 'Bezpośrednio',
+ 'journey.studio.recommended': 'zalecane',
+ 'journey.studio.bleed': 'Spad',
+ 'journey.studio.safeArea': 'Strefa',
+ 'journey.studio.roadsAgain': 'Pobierz ponownie',
+ 'journey.studio.roadsClear': 'Wyczyść',
+ 'journey.studio.roadsBusy': 'Pobieranie',
+ 'journey.studio.roadsHint': 'Zapyta usługę tras o rzeczywisty przebieg każdego odcinka. Długie odcinki zostają bez zmian.',
+ 'journey.studio.roadsHave': 'Drogi są zapisane w tej książce, więc offline drukuje tę samą linię.',
+ 'journey.studio.mapPerTrip': 'Jedna podróż naraz',
+ 'journey.studio.mapWholeJourney': 'Cały dziennik',
+ 'journey.studio.mapScope': 'Pokaż',
+ 'journey.studio.mapPrintDpi': 'Rozdzielczość druku około',
+ 'journey.studio.mapPrintDpiLow': 'przy tym rozmiarze wyjdzie nieostro, spróbuj szerszego kadru lub innego źródła',
+ 'journey.studio.mapSourceReliefHint': 'Cieniowany relief NASA, można drukować bez licencji. Dobry dla kraju lub kontynentu, za mało dokładny dla miasta.',
'journey.studio.mapSourceTiles': 'Kafelki',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': 'Pobierane przy renderowaniu i drukowane z informacją o źródle',
diff --git a/shared/src/i18n/ru/journey.ts b/shared/src/i18n/ru/journey.ts
index 90c9c7761..62953e566 100644
--- a/shared/src/i18n/ru/journey.ts
+++ b/shared/src/i18n/ru/journey.ts
@@ -411,6 +411,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': 'Счётчик дней',
'journey.studio.dayWord': 'ДЕНЬ',
'journey.studio.coordsMark': 'Координаты',
+ 'journey.studio.coordsDms': 'Градусы',
+ 'journey.studio.coordsDecimal': 'Десятичные',
'journey.studio.flagMark': 'Флаг',
'journey.studio.distanceMark': 'Расстояние',
'journey.studio.metric.distance': 'Расстояние',
@@ -424,6 +426,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': 'Числа',
'journey.studio.kind.countries': 'Страны',
'journey.studio.kind.badge': 'Метка',
+ 'journey.studio.kind.list': 'Список',
+ 'journey.studio.kind.icon': 'Иконка',
+ 'journey.studio.duplicate': 'Дублировать',
+ 'journey.studio.style': 'Стиль',
+ 'journey.studio.shows': 'Показывать',
+ 'journey.studio.size': 'Размер',
+ 'journey.studio.weight': 'Начертание',
+ 'journey.studio.italic': 'Курсив',
+ 'journey.studio.tracking': 'Трекинг',
+ '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': 'Поля',
+ 'journey.studio.mapShape': 'Форма',
+ 'journey.studio.align.left': 'Слева',
+ 'journey.studio.align.center': 'По центру',
+ 'journey.studio.align.right': 'Справа',
+ 'journey.studio.markStyle.plain': 'Простой',
+ 'journey.studio.markStyle.chip': 'Плашка',
+ 'journey.studio.markStyle.outline': 'Контурный',
+ 'journey.studio.markStyle.stacked': 'Столбиком',
+ 'journey.studio.icon': 'Иконка',
+ 'journey.studio.iconAndLabel': 'Иконка и текст',
+ 'journey.studio.iconOnly': 'Только иконка',
+ 'journey.studio.labelOnly': 'Только текст',
+ 'journey.studio.icons': 'Иконки',
+ 'journey.studio.iconsForTravel': 'Для путешествий',
+ 'journey.studio.iconsAll': 'Все иконки',
+ 'journey.studio.searchIcons': 'Поиск иконок',
+ 'journey.studio.lineWidth': 'Толщина',
'journey.studio.mask': 'Обрезать по форме',
'journey.studio.maskNone': 'Нет',
'journey.studio.frameStyle': 'Рамка',
@@ -492,6 +532,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': 'У этой гарнитуры нет такого начертания',
'journey.studio.mapSource': 'Источник карты',
'journey.studio.mapSourceVector': 'Контуры',
+ 'journey.studio.mapSourceRelief': 'Рельеф',
+ 'journey.studio.mapSourceSatellite': 'Спутник',
+ 'journey.studio.mapSourceSatelliteHint': 'Sentinel-2 без облаков, печатать можно свободно с указанием источника. Чёткость вплоть до городской улицы.',
+ 'journey.studio.routeLook': 'Линия',
+ 'journey.studio.routeStyle': 'Вид',
+ 'journey.studio.routePlain': 'Обычная',
+ 'journey.studio.routeDrawn': 'Рисованная',
+ 'journey.studio.routeArc': 'Длинные участки',
+ 'journey.studio.routeStraight': 'Прямые',
+ 'journey.studio.routeBow': 'Дугой',
+ 'journey.studio.routeDashArcs': 'Дуги пунктиром',
+ 'journey.studio.mapStops': 'Остановки',
+ 'journey.studio.pinDot': 'Точки',
+ 'journey.studio.pinPhoto': 'Фото',
+ 'journey.studio.pinPhotoNone': 'На этих остановках пока нет фотографий, поэтому они показаны точками.',
+ 'journey.studio.roads': 'Дороги',
+ 'journey.studio.roadsFetch': 'Проложить по дорогам',
+ 'journey.studio.roadsFollow': 'По дорогам',
+ 'journey.studio.roadsDirect': 'Напрямую',
+ 'journey.studio.recommended': 'рекомендуется',
+ 'journey.studio.bleed': 'Вылет',
+ 'journey.studio.safeArea': 'Безоп. зона',
+ 'journey.studio.roadsAgain': 'Проложить заново',
+ 'journey.studio.roadsClear': 'Сбросить',
+ 'journey.studio.roadsBusy': 'Запрос',
+ 'journey.studio.roadsHint': 'Спросить у сервиса маршрутов, по каким дорогам пройден каждый участок. Длинные участки остаются как есть.',
+ 'journey.studio.roadsHave': 'Дороги сохранены в этой книге, поэтому та же линия печатается и без сети.',
+ 'journey.studio.mapSourceReliefHint': 'Затенённый рельеф NASA, печатать можно свободно. Подходит для страны или континента, для одного города слишком груб.',
+ 'journey.studio.mapPrintDpi': 'Печать около',
+ 'journey.studio.mapPrintDpiLow': 'при таком размере размыто, попробуйте шире охват или другой источник',
+ 'journey.studio.mapPerTrip': 'По одной поездке',
+ 'journey.studio.mapWholeJourney': 'Всё путешествие',
+ 'journey.studio.mapScope': 'Показывать',
'journey.studio.mapSourceTiles': 'Тайлы',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': 'Загружается при отрисовке и печатается с указанием источника',
diff --git a/shared/src/i18n/sv/journey.ts b/shared/src/i18n/sv/journey.ts
index 962ddad52..dcacf83fb 100644
--- a/shared/src/i18n/sv/journey.ts
+++ b/shared/src/i18n/sv/journey.ts
@@ -411,6 +411,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': 'Dagräknare',
'journey.studio.dayWord': 'DAG',
'journey.studio.coordsMark': 'Koordinater',
+ 'journey.studio.coordsDms': 'Grader',
+ 'journey.studio.coordsDecimal': 'Decimal',
'journey.studio.flagMark': 'Flagga',
'journey.studio.distanceMark': 'Avstånd',
'journey.studio.metric.distance': 'Avstånd',
@@ -424,6 +426,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': 'Siffror',
'journey.studio.kind.countries': 'Länder',
'journey.studio.kind.badge': 'Märke',
+ 'journey.studio.kind.list': 'Lista',
+ 'journey.studio.kind.icon': 'Ikon',
+ 'journey.studio.duplicate': 'Duplicera',
+ 'journey.studio.style': 'Stil',
+ 'journey.studio.shows': 'Visning',
+ 'journey.studio.size': 'Storlek',
+ 'journey.studio.weight': 'Vikt',
+ 'journey.studio.italic': 'Kursiv',
+ 'journey.studio.tracking': 'Teckenavstånd',
+ 'journey.studio.rotation': 'Rotation',
+ 'journey.studio.opacity': 'Opacitet',
+ 'journey.studio.fill': 'Fyllning',
+ 'journey.studio.fillOn': 'Fylld',
+ 'journey.studio.stroke': 'Kontur',
+ 'journey.studio.strokeWidth': 'Tjocklek',
+ 'journey.studio.gradient': 'Toning',
+ 'journey.studio.gradientDown': 'Nedåt',
+ 'journey.studio.gradientUp': 'Uppåt',
+ 'journey.studio.showIcons': 'Ikoner',
+ 'journey.studio.mapFit': 'Område',
+ 'journey.studio.mapPadding': 'Marginal',
+ 'journey.studio.mapShape': 'Form',
+ 'journey.studio.align.left': 'Vänster',
+ 'journey.studio.align.center': 'Centrerat',
+ 'journey.studio.align.right': 'Höger',
+ 'journey.studio.markStyle.plain': 'Enkel',
+ 'journey.studio.markStyle.chip': 'Fylld',
+ 'journey.studio.markStyle.outline': 'Konturerad',
+ 'journey.studio.markStyle.stacked': 'Staplad',
+ 'journey.studio.icon': 'Ikon',
+ 'journey.studio.iconAndLabel': 'Ikon & text',
+ 'journey.studio.iconOnly': 'Bara ikon',
+ 'journey.studio.labelOnly': 'Bara text',
+ 'journey.studio.icons': 'Ikoner',
+ 'journey.studio.iconsForTravel': 'För resor',
+ 'journey.studio.iconsAll': 'Alla ikoner',
+ 'journey.studio.searchIcons': 'Sök ikoner',
+ 'journey.studio.lineWidth': 'Tjocklek',
'journey.studio.mask': 'Beskär till form',
'journey.studio.maskNone': 'Ingen',
'journey.studio.frameStyle': 'Ram',
@@ -492,6 +532,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': 'Det här typsnittet har inte den vikten',
'journey.studio.mapSource': 'Kartkälla',
'journey.studio.mapSourceVector': 'Konturer',
+ 'journey.studio.mapSourceRelief': 'Relief',
+ 'journey.studio.mapSourceSatellite': 'Satellit',
+ 'journey.studio.mapSourceSatelliteHint': 'Molnfri Sentinel-2, får tryckas fritt med källhänvisning. Skarp ända ner till en gata i staden.',
+ 'journey.studio.routeLook': 'Linjen',
+ 'journey.studio.routeStyle': 'Linjestil',
+ 'journey.studio.routePlain': 'Vanlig',
+ 'journey.studio.routeDrawn': 'Tecknad',
+ 'journey.studio.routeArc': 'Långa etapper',
+ 'journey.studio.routeStraight': 'Rak',
+ 'journey.studio.routeBow': 'Böjd',
+ 'journey.studio.routeDashArcs': 'Strecka de böjda etapperna',
+ 'journey.studio.mapStops': 'Stopp',
+ 'journey.studio.pinDot': 'Punkter',
+ 'journey.studio.pinPhoto': 'Foton',
+ 'journey.studio.pinPhotoNone': 'Inga foton på de här stoppen än, så de ritas som punkter.',
+ 'journey.studio.roads': 'Vägar',
+ 'journey.studio.roadsFetch': 'Följ vägarna',
+ 'journey.studio.roadsFollow': 'Via vägar',
+ 'journey.studio.roadsDirect': 'Fågelvägen',
+ 'journey.studio.recommended': 'rekommenderas',
+ 'journey.studio.bleed': 'Utfall',
+ 'journey.studio.safeArea': 'Skyddszon',
+ 'journey.studio.roadsAgain': 'Hämta igen',
+ 'journey.studio.roadsClear': 'Rensa',
+ 'journey.studio.roadsBusy': 'Hämtar',
+ 'journey.studio.roadsHint': 'Fråga en ruttjänst om vilken väg varje etapp kördes. Långa etapper lämnas som de är.',
+ 'journey.studio.roadsHave': 'Vägarna sparas i den här boken, så den trycker samma linje offline.',
+ 'journey.studio.mapPerTrip': 'En resa i taget',
+ 'journey.studio.mapWholeJourney': 'Hela Journey',
+ 'journey.studio.mapScope': 'Visning',
+ 'journey.studio.mapPrintDpi': 'Trycks i ungefär',
+ 'journey.studio.mapPrintDpiLow': 'oskarpt i den här storleken, prova ett vidare utsnitt eller en annan källa',
+ 'journey.studio.mapSourceReliefHint': 'NASA:s reliefskuggning, får tryckas fritt. Bra för ett land eller en kontinent, för grov för en stad.',
'journey.studio.mapSourceTiles': 'Kartrutor',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': 'Hämtas vid rendering och trycks med sin källhänvisning',
diff --git a/shared/src/i18n/tr/journey.ts b/shared/src/i18n/tr/journey.ts
index 45220336e..fa856a00c 100644
--- a/shared/src/i18n/tr/journey.ts
+++ b/shared/src/i18n/tr/journey.ts
@@ -411,6 +411,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': 'Gün sayacı',
'journey.studio.dayWord': 'GÜN',
'journey.studio.coordsMark': 'Koordinatlar',
+ 'journey.studio.coordsDms': 'Derece',
+ 'journey.studio.coordsDecimal': 'Ondalık',
'journey.studio.flagMark': 'Bayrak',
'journey.studio.distanceMark': 'Mesafe',
'journey.studio.metric.distance': 'Mesafe',
@@ -424,6 +426,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': 'Rakamlar',
'journey.studio.kind.countries': 'Ülkeler',
'journey.studio.kind.badge': 'İşaret',
+ 'journey.studio.kind.list': 'Liste',
+ 'journey.studio.kind.icon': 'Simge',
+ 'journey.studio.duplicate': 'Çoğalt',
+ 'journey.studio.style': 'Stil',
+ 'journey.studio.shows': 'Gösterim',
+ 'journey.studio.size': 'Boyut',
+ 'journey.studio.weight': 'Yazı kalınlığı',
+ 'journey.studio.italic': 'İtalik',
+ 'journey.studio.tracking': 'Harf aralığı',
+ 'journey.studio.rotation': 'Döndürme',
+ 'journey.studio.opacity': 'Opaklık',
+ 'journey.studio.fill': 'Dolgu',
+ 'journey.studio.fillOn': 'Dolgulu',
+ 'journey.studio.stroke': 'Dış çizgi',
+ 'journey.studio.strokeWidth': 'Kalınlık',
+ 'journey.studio.gradient': 'Degrade',
+ 'journey.studio.gradientDown': 'Aşağı',
+ 'journey.studio.gradientUp': 'Yukarı',
+ 'journey.studio.showIcons': 'Simgeler',
+ 'journey.studio.mapFit': 'Kapsam',
+ 'journey.studio.mapPadding': 'Boşluk',
+ 'journey.studio.mapShape': 'Şekil',
+ 'journey.studio.align.left': 'Sol',
+ 'journey.studio.align.center': 'Orta',
+ 'journey.studio.align.right': 'Sağ',
+ 'journey.studio.markStyle.plain': 'Sade',
+ 'journey.studio.markStyle.chip': 'Rozet',
+ 'journey.studio.markStyle.outline': 'Konturlu',
+ 'journey.studio.markStyle.stacked': 'Üst üste',
+ 'journey.studio.icon': 'Simge',
+ 'journey.studio.iconAndLabel': 'Simge ve yazı',
+ 'journey.studio.iconOnly': 'Sadece simge',
+ 'journey.studio.labelOnly': 'Sadece yazı',
+ 'journey.studio.icons': 'Simgeler',
+ 'journey.studio.iconsForTravel': 'Seyahat için',
+ 'journey.studio.iconsAll': 'Tüm simgeler',
+ 'journey.studio.searchIcons': 'Simge ara',
+ 'journey.studio.lineWidth': 'Kalınlık',
'journey.studio.mask': 'Şekle kırp',
'journey.studio.maskNone': 'Yok',
'journey.studio.frameStyle': 'Çerçeve',
@@ -492,6 +532,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': 'Bu yazı tipinde bu kalınlık yok',
'journey.studio.mapSource': 'Harita kaynağı',
'journey.studio.mapSourceVector': 'Dış hatlar',
+ 'journey.studio.mapSourceRelief': 'Kabartma',
+ 'journey.studio.routeLook': 'Çizgi',
+ 'journey.studio.roads': 'Yollar',
+ 'journey.studio.roadsFetch': 'Yolları takip et',
+ 'journey.studio.roadsFollow': 'Yoldan',
+ 'journey.studio.roadsDirect': 'Kuş uçuşu',
+ 'journey.studio.recommended': 'önerilen',
+ 'journey.studio.bleed': 'Taşma',
+ 'journey.studio.safeArea': 'Güvenli',
+ 'journey.studio.roadsAgain': 'Yeniden getir',
+ 'journey.studio.roadsClear': 'Temizle',
+ 'journey.studio.roadsBusy': 'Soruluyor',
+ 'journey.studio.roadsHint': 'Her etabın hangi yoldan gidildiğini bir rota servisine sorar. Uzun etaplar olduğu gibi kalır.',
+ 'journey.studio.roadsHave': 'Yollar bu kitaba kaydedilir, böylece çevrimdışıyken de aynı çizgi basılır.',
+ 'journey.studio.routeStyle': 'Biçim',
+ 'journey.studio.routePlain': 'Yalın',
+ 'journey.studio.routeDrawn': 'Çizilmiş',
+ 'journey.studio.routeArc': 'Uzun etaplar',
+ 'journey.studio.routeStraight': 'Düz',
+ 'journey.studio.routeBow': 'Kavisli',
+ 'journey.studio.routeDashArcs': 'Kavisli etapları kesik çiz',
+ 'journey.studio.mapStops': 'Duraklar',
+ 'journey.studio.pinDot': 'Noktalar',
+ 'journey.studio.pinPhoto': 'Fotoğraflar',
+ 'journey.studio.pinPhotoNone': 'Bu duraklarda henüz fotoğraf yok, bu yüzden nokta olarak çizilirler.',
+ 'journey.studio.mapSourceSatellite': 'Uydu',
+ 'journey.studio.mapSourceSatelliteHint': 'Bulutsuz Sentinel-2, kaynak bilgisiyle ücretsiz basılır. Şehir sokaklarına kadar net.',
+ 'journey.studio.mapSourceReliefHint': 'NASA gölgeli kabartma, baskısı ücretsiz. Ülke veya kıta için ideal, tek bir şehir için fazla kaba.',
+ 'journey.studio.mapPrintDpi': 'Baskı çözünürlüğü yaklaşık',
+ 'journey.studio.mapPrintDpiLow': 'bu boyutta bulanık olur, daha geniş bir görünüm veya başka bir kaynak deneyin',
+ 'journey.studio.mapPerTrip': 'Her seyahat ayrı ayrı',
+ 'journey.studio.mapWholeJourney': 'Tüm yolculuk',
+ 'journey.studio.mapScope': 'Gösterim',
'journey.studio.mapSourceTiles': 'Harita karoları',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': 'Sayfa işlenirken indirilir ve kaynak bilgisiyle basılır',
diff --git a/shared/src/i18n/uk/journey.ts b/shared/src/i18n/uk/journey.ts
index cd0cf46c3..cb1dd6a3c 100644
--- a/shared/src/i18n/uk/journey.ts
+++ b/shared/src/i18n/uk/journey.ts
@@ -412,6 +412,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': 'Лічильник днів',
'journey.studio.dayWord': 'ДЕНЬ',
'journey.studio.coordsMark': 'Координати',
+ 'journey.studio.coordsDms': 'Градуси',
+ 'journey.studio.coordsDecimal': 'Десяткові',
'journey.studio.flagMark': 'Прапор',
'journey.studio.distanceMark': 'Відстань',
'journey.studio.metric.distance': 'Відстань',
@@ -425,6 +427,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': 'Числа',
'journey.studio.kind.countries': 'Країни',
'journey.studio.kind.badge': 'Позначка',
+ 'journey.studio.kind.list': 'Список',
+ 'journey.studio.kind.icon': 'Іконка',
+ 'journey.studio.duplicate': 'Дублювати',
+ 'journey.studio.style': 'Стиль',
+ 'journey.studio.shows': 'Показувати',
+ 'journey.studio.size': 'Розмір',
+ 'journey.studio.weight': 'Накреслення',
+ 'journey.studio.italic': 'Курсив',
+ 'journey.studio.tracking': 'Трекінг',
+ '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': 'Поля',
+ 'journey.studio.mapShape': 'Форма',
+ 'journey.studio.align.left': 'Ліворуч',
+ 'journey.studio.align.center': 'По центру',
+ 'journey.studio.align.right': 'Праворуч',
+ 'journey.studio.markStyle.plain': 'Простий',
+ 'journey.studio.markStyle.chip': 'Плашка',
+ 'journey.studio.markStyle.outline': 'Контурний',
+ 'journey.studio.markStyle.stacked': 'Стовпчиком',
+ 'journey.studio.icon': 'Іконка',
+ 'journey.studio.iconAndLabel': 'Іконка й текст',
+ 'journey.studio.iconOnly': 'Лише іконка',
+ 'journey.studio.labelOnly': 'Лише текст',
+ 'journey.studio.icons': 'Іконки',
+ 'journey.studio.iconsForTravel': 'Для подорожей',
+ 'journey.studio.iconsAll': 'Усі іконки',
+ 'journey.studio.searchIcons': 'Пошук іконок',
+ 'journey.studio.lineWidth': 'Товщина',
'journey.studio.mask': 'Обрізати за формою',
'journey.studio.maskNone': 'Немає',
'journey.studio.frameStyle': 'Рамка',
@@ -493,6 +533,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': 'У цієї гарнітури немає такого накреслення',
'journey.studio.mapSource': 'Джерело карти',
'journey.studio.mapSourceVector': 'Контури',
+ 'journey.studio.mapSourceRelief': 'Рельєф',
+ 'journey.studio.mapSourceSatellite': 'Супутник',
+ 'journey.studio.mapSourceSatelliteHint': 'Sentinel-2 без хмар, друкувати можна вільно із зазначенням джерела. Чіткість аж до міської вулиці.',
+ 'journey.studio.routeLook': 'Лінія',
+ 'journey.studio.routeStyle': 'Вигляд',
+ 'journey.studio.routePlain': 'Звичайна',
+ 'journey.studio.routeDrawn': 'Мальована',
+ 'journey.studio.routeArc': 'Довгі ділянки',
+ 'journey.studio.routeStraight': 'Прямі',
+ 'journey.studio.routeBow': 'Дугою',
+ 'journey.studio.routeDashArcs': 'Дуги пунктиром',
+ 'journey.studio.mapStops': 'Зупинки',
+ 'journey.studio.pinDot': 'Крапки',
+ 'journey.studio.pinPhoto': 'Фото',
+ 'journey.studio.pinPhotoNone': 'На цих зупинках поки немає світлин, тож вони позначені крапками.',
+ 'journey.studio.roads': 'Дороги',
+ 'journey.studio.roadsFetch': 'Прокласти дорогами',
+ 'journey.studio.roadsFollow': 'Дорогами',
+ 'journey.studio.roadsDirect': 'Навпростець',
+ 'journey.studio.recommended': 'рекомендовано',
+ 'journey.studio.bleed': 'Виліт',
+ 'journey.studio.safeArea': 'Безп. зона',
+ 'journey.studio.roadsAgain': 'Прокласти знову',
+ 'journey.studio.roadsClear': 'Скинути',
+ 'journey.studio.roadsBusy': 'Запит',
+ 'journey.studio.roadsHint': 'Запитати сервіс маршрутів, якими дорогами пройдено кожну ділянку. Довгі ділянки залишаються як є.',
+ 'journey.studio.roadsHave': 'Дороги збережено в цій книзі, тож та сама лінія друкується й без мережі.',
+ 'journey.studio.mapSourceReliefHint': 'Затінений рельєф NASA, друкувати можна вільно. Підходить для країни чи континенту, для одного міста надто грубий.',
+ 'journey.studio.mapPrintDpi': 'Друк близько',
+ 'journey.studio.mapPrintDpiLow': 'за такого розміру розмито, спробуйте ширший огляд або інше джерело',
+ 'journey.studio.mapPerTrip': 'По одній поїздці',
+ 'journey.studio.mapWholeJourney': 'Уся подорож',
+ 'journey.studio.mapScope': 'Показувати',
'journey.studio.mapSourceTiles': 'Тайли',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': 'Завантажується під час рендерингу й друкується із зазначенням джерела',
diff --git a/shared/src/i18n/vi/journey.ts b/shared/src/i18n/vi/journey.ts
index 13827908c..83f48c7cf 100644
--- a/shared/src/i18n/vi/journey.ts
+++ b/shared/src/i18n/vi/journey.ts
@@ -411,6 +411,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': 'Bộ đếm ngày',
'journey.studio.dayWord': 'NGÀY',
'journey.studio.coordsMark': 'Tọa độ',
+ 'journey.studio.coordsDms': 'Độ',
+ 'journey.studio.coordsDecimal': 'Thập phân',
'journey.studio.flagMark': 'Cờ',
'journey.studio.distanceMark': 'Khoảng cách',
'journey.studio.metric.distance': 'Khoảng cách',
@@ -424,6 +426,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': 'Số liệu',
'journey.studio.kind.countries': 'Quốc gia',
'journey.studio.kind.badge': 'Dấu',
+ 'journey.studio.kind.list': 'Danh sách',
+ 'journey.studio.kind.icon': 'Icon',
+ 'journey.studio.duplicate': 'Nhân bản',
+ 'journey.studio.style': 'Kiểu',
+ 'journey.studio.shows': 'Hiển thị',
+ 'journey.studio.size': 'Cỡ chữ',
+ 'journey.studio.weight': 'Độ đậm',
+ 'journey.studio.italic': 'Nghiêng',
+ 'journey.studio.tracking': 'Giãn chữ',
+ 'journey.studio.rotation': 'Xoay',
+ 'journey.studio.opacity': 'Độ mờ',
+ 'journey.studio.fill': 'Màu tô',
+ 'journey.studio.fillOn': 'Có tô màu',
+ 'journey.studio.stroke': 'Màu viền',
+ 'journey.studio.strokeWidth': 'Độ dày',
+ 'journey.studio.gradient': 'Mờ dần',
+ 'journey.studio.gradientDown': 'Xuống dưới',
+ 'journey.studio.gradientUp': 'Lên trên',
+ 'journey.studio.showIcons': 'Icon',
+ 'journey.studio.mapFit': 'Phạm vi',
+ 'journey.studio.mapPadding': 'Khoảng trống',
+ 'journey.studio.mapShape': 'Hình dạng',
+ 'journey.studio.align.left': 'Trái',
+ 'journey.studio.align.center': 'Giữa',
+ 'journey.studio.align.right': 'Phải',
+ 'journey.studio.markStyle.plain': 'Trơn',
+ 'journey.studio.markStyle.chip': 'Nền màu',
+ 'journey.studio.markStyle.outline': 'Viền',
+ 'journey.studio.markStyle.stacked': 'Xếp chồng',
+ 'journey.studio.icon': 'Icon',
+ 'journey.studio.iconAndLabel': 'Icon & chữ',
+ 'journey.studio.iconOnly': 'Chỉ icon',
+ 'journey.studio.labelOnly': 'Chỉ chữ',
+ 'journey.studio.icons': 'Icon',
+ 'journey.studio.iconsForTravel': 'Cho chuyến đi',
+ 'journey.studio.iconsAll': 'Tất cả icon',
+ 'journey.studio.searchIcons': 'Tìm icon',
+ 'journey.studio.lineWidth': 'Độ dày nét',
'journey.studio.mask': 'Cắt theo hình',
'journey.studio.maskNone': 'Không',
'journey.studio.frameStyle': 'Khung',
@@ -492,6 +532,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': 'Phông chữ này không có độ đậm đó',
'journey.studio.mapSource': 'Nguồn bản đồ',
'journey.studio.mapSourceVector': 'Đường viền',
+ 'journey.studio.mapSourceRelief': 'Địa hình',
+ 'journey.studio.mapSourceSatellite': 'Vệ tinh',
+ 'journey.studio.mapSourceSatelliteHint': 'Ảnh Sentinel-2 không mây, được in miễn phí nếu ghi công. Rõ đến từng con phố.',
+ 'journey.studio.routeLook': 'Nét lộ trình',
+ 'journey.studio.routeStyle': 'Kiểu nét',
+ 'journey.studio.routePlain': 'Đơn giản',
+ 'journey.studio.routeDrawn': 'Nét vẽ',
+ 'journey.studio.routeArc': 'Chặng dài',
+ 'journey.studio.routeStraight': 'Thẳng',
+ 'journey.studio.routeBow': 'Cong',
+ 'journey.studio.routeDashArcs': 'Gạch đứt chặng cong',
+ 'journey.studio.mapStops': 'Điểm dừng',
+ 'journey.studio.pinDot': 'Chấm tròn',
+ 'journey.studio.pinPhoto': 'Ảnh',
+ 'journey.studio.pinPhotoNone': 'Các điểm dừng này chưa có ảnh nào nên được vẽ thành chấm tròn.',
+ 'journey.studio.roads': 'Đường bộ',
+ 'journey.studio.roadsFetch': 'Bám theo đường bộ',
+ 'journey.studio.roadsFollow': 'Theo đường',
+ 'journey.studio.roadsDirect': 'Đường chim bay',
+ 'journey.studio.recommended': 'khuyên dùng',
+ 'journey.studio.bleed': 'Tràn lề',
+ 'journey.studio.safeArea': 'An toàn',
+ 'journey.studio.roadsAgain': 'Tải lại',
+ 'journey.studio.roadsClear': 'Xóa',
+ 'journey.studio.roadsBusy': 'Đang hỏi',
+ 'journey.studio.roadsHint': 'Hỏi dịch vụ chỉ đường xem mỗi chặng đã đi theo lối nào. Chặng dài giữ nguyên.',
+ 'journey.studio.roadsHave': 'Đường bộ đã lưu trong cuốn sách này nên in ngoại tuyến vẫn ra đúng nét đó.',
+ 'journey.studio.mapPerTrip': 'Từng chuyến đi một',
+ 'journey.studio.mapWholeJourney': 'Toàn bộ hành trình',
+ 'journey.studio.mapScope': 'Hiển thị',
+ 'journey.studio.mapPrintDpi': 'In ở khoảng',
+ 'journey.studio.mapPrintDpiLow': 'hơi mờ ở kích thước này, thử khung nhìn rộng hơn hoặc nguồn khác',
+ 'journey.studio.mapSourceReliefHint': 'Ảnh địa hình đổ bóng của NASA, được in miễn phí. Hợp cho một quốc gia hay châu lục, quá thô cho một thành phố.',
'journey.studio.mapSourceTiles': 'Ô bản đồ',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': 'Tải khi kết xuất và in kèm ghi công',
diff --git a/shared/src/i18n/zh-TW/journey.ts b/shared/src/i18n/zh-TW/journey.ts
index c152b967b..c9b752827 100644
--- a/shared/src/i18n/zh-TW/journey.ts
+++ b/shared/src/i18n/zh-TW/journey.ts
@@ -409,6 +409,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': '天數計數',
'journey.studio.dayWord': 'DAY',
'journey.studio.coordsMark': '座標',
+ 'journey.studio.coordsDms': '度分秒',
+ 'journey.studio.coordsDecimal': '十進位',
'journey.studio.flagMark': '國旗',
'journey.studio.distanceMark': '距離',
'journey.studio.metric.distance': '距離',
@@ -422,6 +424,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': '數據',
'journey.studio.kind.countries': '國家',
'journey.studio.kind.badge': '標記',
+ 'journey.studio.kind.list': '清單',
+ 'journey.studio.kind.icon': '圖示',
+ 'journey.studio.duplicate': '複製',
+ 'journey.studio.style': '樣式',
+ 'journey.studio.shows': '顯示',
+ 'journey.studio.size': '字級',
+ 'journey.studio.weight': '字重',
+ 'journey.studio.italic': '斜體',
+ 'journey.studio.tracking': '字距',
+ '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': '留白',
+ 'journey.studio.mapShape': '形狀',
+ 'journey.studio.align.left': '靠左',
+ 'journey.studio.align.center': '置中',
+ 'journey.studio.align.right': '靠右',
+ 'journey.studio.markStyle.plain': '一般',
+ 'journey.studio.markStyle.chip': '實心',
+ 'journey.studio.markStyle.outline': '線框',
+ 'journey.studio.markStyle.stacked': '堆疊',
+ 'journey.studio.icon': '圖示',
+ 'journey.studio.iconAndLabel': '圖示與文字',
+ 'journey.studio.iconOnly': '僅圖示',
+ 'journey.studio.labelOnly': '僅文字',
+ 'journey.studio.icons': '圖示',
+ 'journey.studio.iconsForTravel': '旅行常用',
+ 'journey.studio.iconsAll': '全部圖示',
+ 'journey.studio.searchIcons': '搜尋圖示',
+ 'journey.studio.lineWidth': '粗細',
'journey.studio.mask': '依形狀裁切',
'journey.studio.maskNone': '無',
'journey.studio.frameStyle': '相框',
@@ -490,6 +530,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': '此字體沒有這個字重',
'journey.studio.mapSource': '地圖來源',
'journey.studio.mapSourceVector': '輪廓',
+ 'journey.studio.mapSourceRelief': '地形暈渲',
+ 'journey.studio.mapSourceSatellite': '衛星影像',
+ 'journey.studio.mapSourceSatelliteHint': '無雲的 Sentinel-2 影像,標註來源即可免費列印。清晰到城市街道。',
+ 'journey.studio.routeLook': '路線線條',
+ 'journey.studio.routeStyle': '畫法',
+ 'journey.studio.routePlain': '單線',
+ 'journey.studio.routeDrawn': '地圖冊',
+ 'journey.studio.routeArc': '長途路段',
+ 'journey.studio.routeStraight': '直線',
+ 'journey.studio.routeBow': '弧線',
+ 'journey.studio.routeDashArcs': '弧線段用虛線',
+ 'journey.studio.mapStops': '停留點',
+ 'journey.studio.pinDot': '圓點',
+ 'journey.studio.pinPhoto': '照片',
+ 'journey.studio.pinPhotoNone': '這些停留點還沒有照片,因此以圓點顯示。',
+ 'journey.studio.roads': '道路',
+ 'journey.studio.roadsFetch': '沿道路繪製',
+ 'journey.studio.roadsFollow': '沿道路',
+ 'journey.studio.roadsDirect': '直連',
+ 'journey.studio.recommended': '推薦',
+ 'journey.studio.bleed': '出血',
+ 'journey.studio.safeArea': '安全區',
+ 'journey.studio.roadsAgain': '重新取得',
+ 'journey.studio.roadsClear': '清除',
+ 'journey.studio.roadsBusy': '取得中',
+ 'journey.studio.roadsHint': '向路線服務查詢每段行程實際行駛的路線。長途路段維持原樣。',
+ 'journey.studio.roadsHave': '道路資料已儲存在這本書裡,離線也能列印出同樣的線條。',
+ 'journey.studio.mapSourceReliefHint': 'NASA 地形暈渲影像,可免費列印。適合國家或大洲,用於單一城市則過於粗糙。',
+ 'journey.studio.mapPrintDpi': '列印解析度約',
+ 'journey.studio.mapPrintDpiLow': '這個尺寸下會模糊,可擴大取景範圍或改用其他來源',
+ 'journey.studio.mapPerTrip': '依旅行分開',
+ 'journey.studio.mapWholeJourney': '整個旅程',
+ 'journey.studio.mapScope': '顯示',
'journey.studio.mapSourceTiles': '地圖圖磚',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': '算繪時取得,並附上來源標註列印',
diff --git a/shared/src/i18n/zh/journey.ts b/shared/src/i18n/zh/journey.ts
index 1e84378c5..32451c3dd 100644
--- a/shared/src/i18n/zh/journey.ts
+++ b/shared/src/i18n/zh/journey.ts
@@ -409,6 +409,8 @@ const journey: TranslationStrings = {
'journey.studio.dayMark': '天数计数',
'journey.studio.dayWord': 'DAY',
'journey.studio.coordsMark': '坐标',
+ 'journey.studio.coordsDms': '度分秒',
+ 'journey.studio.coordsDecimal': '十进制',
'journey.studio.flagMark': '国旗',
'journey.studio.distanceMark': '距离',
'journey.studio.metric.distance': '距离',
@@ -422,6 +424,44 @@ const journey: TranslationStrings = {
'journey.studio.kind.stats': '数据',
'journey.studio.kind.countries': '国家',
'journey.studio.kind.badge': '标记',
+ 'journey.studio.kind.list': '列表',
+ 'journey.studio.kind.icon': '图标',
+ 'journey.studio.duplicate': '复制',
+ 'journey.studio.style': '样式',
+ 'journey.studio.shows': '显示',
+ 'journey.studio.size': '字号',
+ 'journey.studio.weight': '字重',
+ 'journey.studio.italic': '斜体',
+ 'journey.studio.tracking': '字距',
+ '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': '留白',
+ 'journey.studio.mapShape': '形状',
+ 'journey.studio.align.left': '靠左',
+ 'journey.studio.align.center': '居中',
+ 'journey.studio.align.right': '靠右',
+ 'journey.studio.markStyle.plain': '普通',
+ 'journey.studio.markStyle.chip': '实心',
+ 'journey.studio.markStyle.outline': '线框',
+ 'journey.studio.markStyle.stacked': '堆叠',
+ 'journey.studio.icon': '图标',
+ 'journey.studio.iconAndLabel': '图标和文字',
+ 'journey.studio.iconOnly': '仅图标',
+ 'journey.studio.labelOnly': '仅文字',
+ 'journey.studio.icons': '图标',
+ 'journey.studio.iconsForTravel': '旅行常用',
+ 'journey.studio.iconsAll': '全部图标',
+ 'journey.studio.searchIcons': '搜索图标',
+ 'journey.studio.lineWidth': '粗细',
'journey.studio.mask': '按形状裁剪',
'journey.studio.maskNone': '无',
'journey.studio.frameStyle': '相框',
@@ -490,6 +530,39 @@ const journey: TranslationStrings = {
'journey.studio.weightMissing': '该字体没有此字重',
'journey.studio.mapSource': '地图来源',
'journey.studio.mapSourceVector': '轮廓',
+ 'journey.studio.mapSourceRelief': '地形晕渲',
+ 'journey.studio.mapSourceSatellite': '卫星影像',
+ 'journey.studio.mapSourceSatelliteHint': '无云的 Sentinel-2 影像,标注来源即可免费打印。清晰到城市街道。',
+ 'journey.studio.routeLook': '路线线条',
+ 'journey.studio.routeStyle': '画法',
+ 'journey.studio.routePlain': '单线',
+ 'journey.studio.routeDrawn': '地图册',
+ 'journey.studio.routeArc': '长途路段',
+ 'journey.studio.routeStraight': '直线',
+ 'journey.studio.routeBow': '弧线',
+ 'journey.studio.routeDashArcs': '弧线段用虚线',
+ 'journey.studio.mapStops': '停留点',
+ 'journey.studio.pinDot': '圆点',
+ 'journey.studio.pinPhoto': '照片',
+ 'journey.studio.pinPhotoNone': '这些停留点还没有照片,因此以圆点显示。',
+ 'journey.studio.roads': '道路',
+ 'journey.studio.roadsFetch': '沿道路绘制',
+ 'journey.studio.roadsFollow': '沿道路',
+ 'journey.studio.roadsDirect': '直连',
+ 'journey.studio.recommended': '推荐',
+ 'journey.studio.bleed': '出血',
+ 'journey.studio.safeArea': '安全区',
+ 'journey.studio.roadsAgain': '重新获取',
+ 'journey.studio.roadsClear': '清除',
+ 'journey.studio.roadsBusy': '获取中',
+ 'journey.studio.roadsHint': '向路线服务查询每段行程实际行驶的路线。长途路段保持原样。',
+ 'journey.studio.roadsHave': '道路数据已保存在这本书里,离线也能打印出同样的线条。',
+ 'journey.studio.mapSourceReliefHint': 'NASA 地形晕渲影像,可免费打印。适合国家或大洲,用于单个城市则过于粗糙。',
+ 'journey.studio.mapPrintDpi': '打印分辨率约',
+ 'journey.studio.mapPrintDpiLow': '这个尺寸下会发虚,可扩大取景范围或改用其他来源',
+ 'journey.studio.mapPerTrip': '按旅行分开',
+ 'journey.studio.mapWholeJourney': '整个旅程',
+ 'journey.studio.mapScope': '显示',
'journey.studio.mapSourceTiles': '地图瓦片',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': '渲染时获取,并附带来源标注打印',