mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
eae8480728
* 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.
804 lines
36 KiB
TypeScript
804 lines
36 KiB
TypeScript
import { z } from 'zod';
|
|
|
|
/**
|
|
* The TREK Studio book document — what a photo book *is*, independent of how it
|
|
* is edited or rendered.
|
|
*
|
|
* Two decisions carry the rest of the format:
|
|
*
|
|
* 1. **Everything geometric is in millimetres.** Not pixels: a pixel means
|
|
* nothing to a print shop, and it changes meaning with the device pixel
|
|
* ratio and the browser zoom. CSS maps 1in onto 96px, 25.4mm and 72pt with a
|
|
* fixed ratio, so the editor can render the document at `transform: scale()`
|
|
* and the print renderer at 1:1 and land on the same page. Values are stored
|
|
* rounded to two decimals — 10µm, far below any imagesetter — so a JSON round
|
|
* trip cannot accumulate drift.
|
|
*
|
|
* 2. **The paint order is the array order.** `elements[0]` is at the back. No
|
|
* `zIndex` field that could disagree with itself, "bring to front" is a
|
|
* splice, and because the DOM paints in the same order the editor and the
|
|
* renderer agree for free.
|
|
*
|
|
* A text element may be *bound* to a piece of the journey (an entry's title, its
|
|
* story, a photo caption). A bound element re-reads its source when the book is
|
|
* opened, so fixing a typo in the journal fixes it in the book — until someone
|
|
* edits the text in Studio, which sets `overridden` and stops the sync.
|
|
*/
|
|
|
|
/** Two decimals of a millimetre. Anything finer is below print resolution. */
|
|
const mm = z.number().finite().transform(v => Math.round(v * 100) / 100);
|
|
|
|
const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected #rrggbb');
|
|
|
|
/**
|
|
* Every shape the book can draw — and, because a picture frame *is* a shape with
|
|
* a photograph inside it, every mask a photo can be cut to.
|
|
*
|
|
* One list rather than two. Canva keeps "shapes" and "frames" in separate
|
|
* panels, but underneath they are the same geometry, and duplicating it would
|
|
* mean a heart that exists as a frame but not as a shape, or the two drifting
|
|
* apart the first time one of them is adjusted.
|
|
*
|
|
* The first four entries are the shapes that existed before this list did. They
|
|
* stay at the front and keep their names, so a document saved by the earlier
|
|
* editor still parses.
|
|
*/
|
|
export const BOOK_SHAPES = [
|
|
'rect', 'ellipse', 'line', 'triangle',
|
|
|
|
'triangle-down', 'diamond', 'parallelogram', 'trapezoid',
|
|
'pentagon', 'hexagon', 'hexagon-flat', 'heptagon', 'octagon',
|
|
'arch', 'half-circle', 'quarter-circle', 'capsule', 'squircle',
|
|
|
|
'star-4', 'star-5', 'star-6', 'star-8', 'star-12', 'burst', 'seal', 'sparkle',
|
|
|
|
'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'arrow-both',
|
|
'chevron-right', 'chevron-left', 'arrow-bent',
|
|
|
|
'bubble-round', 'bubble-square', 'bubble-oval', 'bubble-think',
|
|
|
|
'heart', 'cloud', 'cloud-puffy', 'drop', 'moon', 'sun',
|
|
'flower-5', 'flower-6', 'leaf', 'cross', 'plus', 'shield', 'gear',
|
|
'ticket', 'wave', 'mountain', 'compass', 'pin',
|
|
|
|
'blob-1', 'blob-2', 'blob-3', 'blob-4',
|
|
|
|
'banner-ribbon', 'banner-pennant', 'banner-bookmark', 'banner-flag',
|
|
] as const;
|
|
export type BookShapeId = (typeof BOOK_SHAPES)[number];
|
|
|
|
/**
|
|
* The families a book may be set in.
|
|
*
|
|
* `sans`, `serif` and `display` are the three the format started with and are
|
|
* kept by name, so every document written so far still parses — they now point
|
|
* at bundled faces rather than at whatever the rendering machine happened to
|
|
* have installed. See client/src/components/Studio/bookFonts.ts.
|
|
*/
|
|
export const BOOK_FONTS_IDS = [
|
|
'sans', 'inter', 'serif', 'garamond', 'playfair', 'display', 'bebas',
|
|
] as const;
|
|
export type BookFontFamily = (typeof BOOK_FONTS_IDS)[number];
|
|
|
|
export const bookFrameSchema = z.object({
|
|
/** Millimetres from the left edge of the spread. Negative means bleed. */
|
|
x: mm,
|
|
/** Millimetres from the top edge of the spread. */
|
|
y: mm,
|
|
w: mm.refine(v => v > 0, 'width must be positive'),
|
|
h: mm.refine(v => v > 0, 'height must be positive'),
|
|
});
|
|
export type BookFrame = z.infer<typeof bookFrameSchema>;
|
|
|
|
const elementBase = {
|
|
id: z.string().min(1),
|
|
frame: bookFrameSchema,
|
|
/** Degrees, clockwise. */
|
|
rotation: z.number().finite().default(0),
|
|
opacity: z.number().min(0).max(1).default(1),
|
|
locked: z.boolean().default(false),
|
|
};
|
|
|
|
export const bookPhotoElementSchema = z.object({
|
|
...elementBase,
|
|
kind: z.literal('photo'),
|
|
/**
|
|
* `trek_photos.id` — never a resolved URL, or the document would not survive a
|
|
* provider change and Studio could not warn about print resolution.
|
|
*
|
|
* Null is an empty frame: a template lays out where the pictures go before it
|
|
* knows which pictures those are, and a placeholder you can drop a photo onto
|
|
* is the whole point of a template.
|
|
*/
|
|
photoId: z.number().int().positive().nullable().default(null),
|
|
fit: z.enum(['cover', 'contain']).default('cover'),
|
|
/** Where the interesting part of the picture is, 0..1 of each edge. */
|
|
focalX: z.number().min(0).max(1).default(0.5),
|
|
focalY: z.number().min(0).max(1).default(0.5),
|
|
radius: mm.default(0),
|
|
filter: z.enum(['none', 'bw', 'warm', 'cool', 'fade', 'contrast']).default('none'),
|
|
/**
|
|
* Cut the picture to a shape. Null is the plain rectangle every photo starts
|
|
* as — and stays, unless someone asks for a heart.
|
|
*
|
|
* The mask is stretched to the frame rather than kept square, which is what
|
|
* makes a frame usable: you place a wide star across a spread and it is wide.
|
|
* A shape you cannot resize freely is an ornament, not a frame.
|
|
*/
|
|
mask: z.enum(BOOK_SHAPES).nullable().default(null),
|
|
/**
|
|
* Decoration *around* the picture — a Polaroid's thick chin, a print's white
|
|
* border, the shadow of a photo lying on a page.
|
|
*
|
|
* Not a mask and not a shape: it adds to the frame instead of cutting into it,
|
|
* and it is the one thing you cannot express with either.
|
|
*/
|
|
frameStyle: z.enum(['none', 'polaroid', 'white', 'shadow', 'film', 'tape']).default('none'),
|
|
});
|
|
|
|
export const bookTextElementSchema = z.object({
|
|
...elementBase,
|
|
kind: z.literal('text'),
|
|
text: z.string().max(8000).default(''),
|
|
font: z.enum(BOOK_FONTS_IDS).default('sans'),
|
|
/** Points. */
|
|
size: z.number().min(4).max(200).default(11),
|
|
weight: z.union([z.literal(400), z.literal(500), z.literal(600), z.literal(700)]).default(400),
|
|
italic: z.boolean().default(false),
|
|
align: z.enum(['left', 'center', 'right', 'justify']).default('left'),
|
|
/** Multiple of the font size. */
|
|
leading: z.number().min(0.7).max(3).default(1.45),
|
|
/** Ems, may be negative. */
|
|
tracking: z.number().min(-0.2).max(1).default(0),
|
|
color: hex.default('#1a1a1a'),
|
|
/** Where this text came from, when it came from the journey. */
|
|
binding: 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),
|
|
/** True once a human edited the text; stops it re-reading its source. */
|
|
overridden: z.boolean().default(false),
|
|
});
|
|
|
|
export const bookShapeElementSchema = z.object({
|
|
...elementBase,
|
|
kind: z.literal('shape'),
|
|
shape: z.enum(BOOK_SHAPES).default('rect'),
|
|
fill: hex.nullable().default('#111827'),
|
|
/**
|
|
* Fade the fill out towards one edge. A cover almost always needs this: a
|
|
* flat panel behind the title cuts the photograph in half, while a fade lets
|
|
* the picture keep going and still leaves the words readable.
|
|
*/
|
|
gradient: z.enum(['none', 'up', 'down']).default('none'),
|
|
stroke: hex.nullable().default(null),
|
|
strokeWidth: mm.default(0),
|
|
/**
|
|
* A dashed rule is a different thing from a solid one — it reads as a fold, a
|
|
* route or a cut mark rather than as a line under a heading. The dash length
|
|
* follows the stroke width, so it stays proportional at any size.
|
|
*/
|
|
strokeStyle: z.enum(['solid', 'dashed', 'dotted']).default('solid'),
|
|
radius: mm.default(0),
|
|
});
|
|
|
|
/**
|
|
* ── The travel elements ────────────────────────────────────────────────────
|
|
*
|
|
* A photo book of a journey is not a photo album: the trip carries facts —
|
|
* which countries, how far, how long, which way round — and those facts make
|
|
* pages that photographs cannot. This is what Polarsteps' printed books do
|
|
* better than a generic book maker, and it is the one thing TREK is in a
|
|
* position to do well, because the data is already in the trips hanging off the
|
|
* journey.
|
|
*
|
|
* **Every one of them carries its own values.** The numbers are resolved when
|
|
* the element is placed and stored in the document, and the renderer reads them
|
|
* from there — it never fetches. Three reasons, and the third is the one that
|
|
* decides it:
|
|
*
|
|
* 1. The print renderer is headless Chromium loading a document. A page that
|
|
* needs a logged-in API call to know what it says is a page that prints
|
|
* blank on the day the token expires.
|
|
* 2. A book is a record of a trip as it was. Adding a stop next year should not
|
|
* silently rewrite the distance printed in last year's book.
|
|
* 3. It makes the elements ordinary. They move, resize, lock, undo and park like
|
|
* everything else, because they *are* like everything else.
|
|
*
|
|
* `stale` is how the editor offers the other behaviour without giving up any of
|
|
* that: it marks a snapshot that no longer matches the journey, and the
|
|
* inspector shows a refresh button. Updating is a choice, not a surprise.
|
|
*/
|
|
|
|
/** What the travel elements share with text: they are typeset, not drawn. */
|
|
const typeset = {
|
|
font: z.enum(BOOK_FONTS_IDS).default('sans'),
|
|
color: hex.default('#1a1a1a'),
|
|
/**
|
|
* The one colour that carries emphasis — the figure in a stat, the route on
|
|
* a map, the fill of a chip.
|
|
*
|
|
* Black by default, not the app's orange. A book is printed and a printed
|
|
* page is ink on paper; a colour that arrives without being chosen turns
|
|
* every travel element into the same accent whether or not the book wants
|
|
* one. Picking a colour is one click and is what the swatches are for — and
|
|
* the map's dark style overrides it anyway, because black lines on a dark
|
|
* map are lines nobody can see.
|
|
*/
|
|
accent: hex.default('#111111'),
|
|
/** Scales every piece of type in the element at once. 1 is the drawn default. */
|
|
textScale: z.number().min(0.4).max(3).default(1),
|
|
/**
|
|
* How heavy the type is set.
|
|
*
|
|
* The travel elements draw at several sizes at once — a figure over its
|
|
* label, a country name over its outline — and this moves all of them
|
|
* together, the way `textScale` moves their size. Without it the only way to
|
|
* lighten a stats block was to not use one.
|
|
*/
|
|
weight: z.union([z.literal(400), z.literal(500), z.literal(600), z.literal(700)]).default(700),
|
|
/** Set when the journey has moved on since these values were taken. */
|
|
stale: z.boolean().default(false),
|
|
};
|
|
|
|
/** Kilometres or miles. Stored metric; the element converts when it draws. */
|
|
export const bookUnitsSchema = z.enum(['metric', 'imperial']);
|
|
|
|
export const bookMapElementSchema = z.object({
|
|
...elementBase,
|
|
...typeset,
|
|
kind: z.literal('map'),
|
|
/**
|
|
* Where the map underneath comes from.
|
|
*
|
|
* `vector` draws country outlines from the bundled boundaries: it carries no
|
|
* licence, fetches nothing at render time, and prints sharp at any size,
|
|
* which is why it is the default and why a book can be exported offline.
|
|
*
|
|
* `tiles` and `static` use the map provider the instance is already
|
|
* configured with, for a book that wants real geography — streets, terrain,
|
|
* coastline detail an outline cannot give. Both fetch at render time and both
|
|
* carry an attribution the element prints, because the licence requires it
|
|
* and a book is a published work. A tiled map is also raster: it is cut for a
|
|
* zoom level, so printing it much larger than it was fetched goes soft.
|
|
*/
|
|
source: z.enum(['vector', 'tiles', 'static']).default('vector'),
|
|
/**
|
|
* The tile template, frozen when the element was placed.
|
|
*
|
|
* Kept in the document rather than read from settings at render time for the
|
|
* same reason the figures are: a page that resolves its own contents from an
|
|
* account's current settings is a page that changes when someone else edits
|
|
* a preference.
|
|
*/
|
|
tileUrl: z.string().max(500).default(''),
|
|
/** What the licence requires the page to say. Printed small, in a corner. */
|
|
attribution: z.string().max(200).default(''),
|
|
/**
|
|
* Zoom for the tiled sources. Null lets the element choose one that fits the
|
|
* route, which is right almost always and wrong when someone wants the
|
|
* street detail of a single city.
|
|
*/
|
|
zoom: z.number().int().min(0).max(19).nullable().default(null),
|
|
style: z.enum(['minimal', 'outline', 'dark', 'paper']).default('minimal'),
|
|
/** Country silhouettes under the route, drawn from the bundled boundaries. */
|
|
showLand: z.boolean().default(true),
|
|
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([]),
|
|
/**
|
|
* The route, in order. Capped where a printed line stops gaining from more
|
|
* points — a book page cannot resolve four hundred stops anyway.
|
|
*/
|
|
points: z
|
|
.array(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([]),
|
|
/**
|
|
* The way the trip was actually travelled, where the trip knows it.
|
|
*
|
|
* `points` are the stops, and a line drawn straight between them is a claim
|
|
* about the journey that is almost always false: it crosses mountains,
|
|
* water and borders that the trip went around. TREK already stores routed
|
|
* geometry per place from the planner and from imported GPX, so where that
|
|
* exists the line follows the road instead of the ruler.
|
|
*
|
|
* Segments rather than one list, because two tracks that do not join must
|
|
* not be drawn as though they did: the gap between the end of one day's
|
|
* driving and the start of the next is a gap, not a leg.
|
|
*
|
|
* `[lat, lng]`, matching the order the tracks endpoint answers in, and thinned
|
|
* when it is frozen into the document: a printed line gains nothing from a
|
|
* point every fifteen metres, and a book carrying every GPS fix of a
|
|
* three-week trip is a document nobody can save.
|
|
*/
|
|
path: z
|
|
.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.
|
|
*
|
|
* The view is fitted to the stops, not to the countries: a trip that stayed
|
|
* in Berlin is a map of Berlin, not a map of Germany with two dots on it.
|
|
* Country outlines are still drawn, they simply run off the edge like the
|
|
* geography they are.
|
|
*/
|
|
/*
|
|
* 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.
|
|
*
|
|
* The default, because it is the picture a travel book usually wants: the
|
|
* country, with the route inside it. Fitting to the stops is the other half
|
|
* of the choice and matters as soon as the trip is smaller than the country
|
|
* it happened in — a week in Berlin drawn as the whole of Germany is two
|
|
* dots in a large empty shape.
|
|
*/
|
|
fitToCountries: z.boolean().default(true),
|
|
/**
|
|
* The shape the map is cut to.
|
|
*
|
|
* `rect` is a map in a box, which is what a map usually is and what a full
|
|
* page wants. `country` cuts it to the outline of the countries themselves,
|
|
* so the picture *is* the country: no frame, no box, the page showing through
|
|
* around the coastline. That is the version worth putting next to a
|
|
* photograph, and it is the one thing a rectangle can never be.
|
|
*
|
|
* Cutting only makes sense against the whole outline, so it fits to the
|
|
* countries whatever `fitToCountries` says: a city-sized view cut to the
|
|
* 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;
|
|
export type BookMetric = (typeof BOOK_METRICS)[number];
|
|
|
|
export const bookStatsElementSchema = z.object({
|
|
...elementBase,
|
|
...typeset,
|
|
kind: z.literal('stats'),
|
|
/** Which figures, in the order they are drawn. */
|
|
metrics: z.array(z.enum(BOOK_METRICS)).max(7).default(['distance', 'days', 'steps', 'photos']),
|
|
layout: z.enum(['grid', 'row', 'column']).default('grid'),
|
|
showIcons: z.boolean().default(true),
|
|
units: bookUnitsSchema.default('metric'),
|
|
/** Metric to value. Distance is metres, everything else a plain count. */
|
|
values: z.record(z.string(), z.number().finite()).default({}),
|
|
});
|
|
|
|
export const bookCountriesElementSchema = z.object({
|
|
...elementBase,
|
|
...typeset,
|
|
kind: z.literal('countries'),
|
|
/** ISO-3166-1 alpha-2, in visit order. */
|
|
codes: z.array(z.string().length(2)).max(80).default([]),
|
|
/** Names as resolved when placed, so the page does not depend on a lookup. */
|
|
names: z.array(z.string().max(80)).max(80).default([]),
|
|
layout: z.enum(['list', 'grid', 'column']).default('list'),
|
|
/** The silhouette behind each name — the thing that makes the page read as a map. */
|
|
showOutline: z.boolean().default(true),
|
|
showFlag: z.boolean().default(false),
|
|
showName: z.boolean().default(true),
|
|
align: z.enum(['left', 'center', 'right']).default('center'),
|
|
});
|
|
|
|
export const BOOK_BADGES = [
|
|
'flag', 'date', 'day', 'coords', 'country', 'distance', 'weather', 'altitude',
|
|
'mood',
|
|
] as const;
|
|
export type BookBadgeVariant = (typeof BOOK_BADGES)[number];
|
|
|
|
/**
|
|
* The small marks: a flag, a date set as a numeral, a "DAY 5" chip, a line of
|
|
* coordinates. Individually trivial, and collectively most of what makes a
|
|
* printed travel page look composed rather than typed.
|
|
*/
|
|
export const bookBadgeElementSchema = z.object({
|
|
...elementBase,
|
|
...typeset,
|
|
kind: z.literal('badge'),
|
|
variant: z.enum(BOOK_BADGES).default('date'),
|
|
/** The resolved value — "13", "48°51'N 2°21'E", "ICELAND". */
|
|
text: z.string().max(200).default(''),
|
|
/** The line under it — a month, a place, a unit. */
|
|
sub: z.string().max(200).default(''),
|
|
/**
|
|
* 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.
|
|
*
|
|
* The same bargain the page numbers make: automatic until somebody chooses,
|
|
* and choosing is what turns it off. A chip is a filled capsule, so its words
|
|
* have to answer to the fill rather than to the page: white on ink, ink on a
|
|
* pale accent. Doing that by hand means changing two colours every time you
|
|
* change one, and getting it wrong means a black day counter on a black chip,
|
|
* 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),
|
|
});
|
|
|
|
/**
|
|
* A journal entry's pros and cons, set as a page.
|
|
*
|
|
* TREK's entries already carry them as two lists, and two lists is exactly what
|
|
* a text element cannot express: run together as a paragraph they lose the
|
|
* pairing, and run as one column they lose which side each line is on. Two
|
|
* marked columns is how a travel diary has always printed this.
|
|
*/
|
|
export const bookListElementSchema = z.object({
|
|
...elementBase,
|
|
...typeset,
|
|
kind: z.literal('list'),
|
|
items: z
|
|
.array(z.object({
|
|
text: z.string().max(400),
|
|
/** Which column the line belongs to, and which mark it gets. */
|
|
tone: z.enum(['pro', 'con', 'plain']).default('plain'),
|
|
}))
|
|
.max(60)
|
|
.default([]),
|
|
/** Pros beside cons, or one column with the marks inline. */
|
|
layout: z.enum(['columns', 'stacked']).default('columns'),
|
|
showMarks: z.boolean().default(true),
|
|
/** A heading over each column — empty for none. */
|
|
proLabel: z.string().max(80).default(''),
|
|
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,
|
|
bookShapeElementSchema,
|
|
bookMapElementSchema,
|
|
bookStatsElementSchema,
|
|
bookCountriesElementSchema,
|
|
bookBadgeElementSchema,
|
|
bookListElementSchema,
|
|
bookIconElementSchema,
|
|
]);
|
|
export type BookElement = z.infer<typeof bookElementSchema>;
|
|
export type BookIconElement = z.infer<typeof bookIconElementSchema>;
|
|
export type BookPhotoElement = z.infer<typeof bookPhotoElementSchema>;
|
|
export type BookTextElement = z.infer<typeof bookTextElementSchema>;
|
|
export type BookShapeElement = z.infer<typeof bookShapeElementSchema>;
|
|
export type BookMapElement = z.infer<typeof bookMapElementSchema>;
|
|
export type BookStatsElement = z.infer<typeof bookStatsElementSchema>;
|
|
export type BookCountriesElement = z.infer<typeof bookCountriesElementSchema>;
|
|
export type BookBadgeElement = z.infer<typeof bookBadgeElementSchema>;
|
|
export type BookListElement = z.infer<typeof bookListElementSchema>;
|
|
export type BookUnits = z.infer<typeof bookUnitsSchema>;
|
|
|
|
export const bookSpreadSchema = z.object({
|
|
id: z.string().min(1),
|
|
/** 'cover' and 'back' are single pages; everything else is a double spread. */
|
|
role: z.enum(['cover', 'back', 'inner']).default('inner'),
|
|
background: hex.nullable().default(null),
|
|
/** Back to front. */
|
|
elements: z.array(bookElementSchema).max(MAX_SPREAD_ELEMENTS).default([]),
|
|
/**
|
|
* Content that belongs to this spread but is not currently placed.
|
|
*
|
|
* Changing to a layout with fewer frames must not destroy the pictures the old
|
|
* one held — the user is trying an arrangement, not deleting their photographs,
|
|
* and a single wrong click on a text-only layout would otherwise cost them
|
|
* everything on the page. Parked elements come back the moment a layout with
|
|
* room for them is applied.
|
|
*/
|
|
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),
|
|
});
|
|
export type BookSpread = z.infer<typeof bookSpreadSchema>;
|
|
|
|
/**
|
|
* Page numbers.
|
|
*
|
|
* Set here, on the document, and filled by the renderer — which is the only
|
|
* place that knows what number a given page is. Letting pdf-lib stamp them
|
|
* afterwards would put a different number in the PDF than the one the editor
|
|
* showed, and a book you cannot proofread on screen is not much of an editor.
|
|
*
|
|
* `startAt` exists because the first inner spread is rarely page 1 in the
|
|
* finished object: the cover is a separate sheet, and some binders count it and
|
|
* some do not.
|
|
*/
|
|
export const bookPageNumbersSchema = z.object({
|
|
show: z.boolean().default(false),
|
|
/** Which number the first inner spread's left page carries. */
|
|
startAt: z.number().int().min(0).max(9999).default(2),
|
|
position: z.enum(['outer', 'inner', 'centre']).default('outer'),
|
|
/** Millimetres from the trim edge. */
|
|
margin: mm.default(12),
|
|
size: z.number().min(4).max(48).default(8),
|
|
/**
|
|
* Pick the folio colour from what it lands on, rather than using `color`.
|
|
*
|
|
* A book has dark pages and light ones, and one fixed colour is wrong on half
|
|
* of them — a grey folio disappears into a full-bleed night photograph and
|
|
* again into a black page. The renderer decides per page; `color` is what it
|
|
* uses when this is off, and choosing a colour turns it off.
|
|
*/
|
|
autoColor: z.boolean().default(true),
|
|
color: hex.default('#8a8578'),
|
|
font: z.enum(BOOK_FONTS_IDS).default('sans'),
|
|
});
|
|
export type BookPageNumbers = z.infer<typeof bookPageNumbersSchema>;
|
|
|
|
export const bookPageSetupSchema = z.object({
|
|
preset: z.enum(['square-210', 'square-300', 'a4-landscape', 'a4-portrait', 'a5-landscape', 'custom']).default('square-210'),
|
|
/** A single page. A double spread is drawn twice this wide. */
|
|
pageWidth: mm.default(210),
|
|
pageHeight: mm.default(210),
|
|
bleed: mm.default(3),
|
|
safe: mm.default(5),
|
|
pageNumbers: bookPageNumbersSchema.default(() => bookPageNumbersSchema.parse({})),
|
|
});
|
|
export type BookPageSetup = z.infer<typeof bookPageSetupSchema>;
|
|
|
|
export const bookDocumentSchema = z.object({
|
|
/** In the document, not in a column: a format bump must not need a migration. */
|
|
version: z.literal(1).catch(1).default(1),
|
|
title: z.string().max(200).default(''),
|
|
page: bookPageSetupSchema.default(() => bookPageSetupSchema.parse({})),
|
|
spreads: z.array(bookSpreadSchema).max(150).default([]),
|
|
});
|
|
export type BookDocument = z.infer<typeof bookDocumentSchema>;
|
|
|
|
/**
|
|
* Read a stored document without ever throwing.
|
|
*
|
|
* 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) };
|
|
}),
|
|
};
|
|
}
|