mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
9e0fb19e79
Auto layout wrote journey data straight into the document without holding it to the caps the contract sets, and the save path sends the document unvalidated - so the route refused the whole body with a 400 and the editor reported "couldn't save" for the rest of the session, because autosave kept offering the same rejected book. The everyday way in was the itinerary list: thirteen stops a page over two pages is eighty elements on a spread capped at sixty, so a run of twenty bare stops was unsavable and a longer one put two spreads over. Sixty was below what this app's own layouts draw, which makes it a bug with a constant in front of it rather than a limit, so the cap goes to ninety and the page keeps the design somebody chose. It stays a serialisation limit: the largest document the contract now admits is under four megabytes against the book route's eight. A long journal entry, a long place name, a journey past eighty countries and a long title each did the same thing on their own, and a bound story did it on open without anyone touching Auto layout. The caps become named exports on the contract and every producer builds against them, so the two cannot drift apart again - which is also why the stations chunk is derived from the cap and the salvage fixture counts off it. Fixes #2085
241 lines
9.3 KiB
TypeScript
241 lines
9.3 KiB
TypeScript
import type { BookElement, BookPageSetup, BookSpread, JourneyStats } from '@trek/shared'
|
|
import { MAX_TEXT_LENGTH } 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.
|
|
*
|
|
* ── Why this exists ──────────────────────────────────────────────────────
|
|
*
|
|
* The layouts written as code in autoLayout.ts are what a function can reason
|
|
* its way to: a picture here, words there, margins that add up. What they are
|
|
* not is *designed* — a panel bled off one edge, a shape running off the
|
|
* corner, a strip of pictures at two sizes with the coordinates set under them.
|
|
* Those decisions come from looking at a page, and the editor is where looking
|
|
* at a page happens.
|
|
*
|
|
* So the templates are drawn in Studio and read back out (see
|
|
* bookTemplates.data.ts). This is the other half: taking one of those spreads
|
|
* and putting an entry into it.
|
|
*
|
|
* ── What it fills, and what it leaves alone ──────────────────────────────
|
|
*
|
|
* Only what the entry can answer for:
|
|
*
|
|
* - text carrying a `binding` — the title, the story, a caption
|
|
* - empty photo frames, taking the entry's photographs in order
|
|
* - the day, coordinate, date and country marks
|
|
* - the stats and countries elements, from the journey's figures
|
|
*
|
|
* Everything else is the design and is copied as drawn. A panel stays where it
|
|
* was put; a decorative shape keeps its colour. That division is the whole
|
|
* contract between the person who draws a template and the code that uses it.
|
|
*
|
|
* ── Sizes ────────────────────────────────────────────────────────────────
|
|
*
|
|
* The template's numbers are fractions of the page it was drawn on, so they are
|
|
* multiplied back up here. A template built on a 210mm square lays out on an A4
|
|
* landscape book without being redrawn.
|
|
*/
|
|
|
|
export interface TemplateEntry {
|
|
id: number
|
|
title: string | null
|
|
story: string | null
|
|
location: string | null
|
|
date: string | null
|
|
photos: { photoId: number; width: number | null; height: number | null; caption?: string | null }[]
|
|
lat?: number | null
|
|
lng?: number | null
|
|
country?: string | null
|
|
dayNumber?: number | null
|
|
dayCount?: number | null
|
|
}
|
|
|
|
export interface TemplateContext {
|
|
page: BookPageSetup
|
|
locale: string
|
|
stats: JourneyStats | null
|
|
/** Already translated, because this module has no translation context. */
|
|
dayLabel: string
|
|
}
|
|
|
|
let seq = 0
|
|
const uid = (p: string) => `${p}-${(seq++).toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`
|
|
|
|
/** How well a template suits an entry: more is better, -1 means unusable. */
|
|
export function templateFit(template: SpreadTemplate, entry: TemplateEntry): number {
|
|
const frames = template.elements.filter(e => e.kind === 'photo').length
|
|
const photos = entry.photos.length
|
|
const wantsStory = template.elements.some(e => e.kind === 'text' && e.binding?.source === 'entry.story')
|
|
const hasStory = !!(entry.story || '').trim()
|
|
|
|
/*
|
|
* A template is a template *for an entry* when it uses one.
|
|
*
|
|
* The distinction has to come out of the drawing rather than from a label
|
|
* somebody remembers to set: a summary spread is all figures and country
|
|
* outlines and reads perfectly well on its own, and dropping a day's
|
|
* photographs into it would be nonsense. Bindings are how a page says it
|
|
* expects an entry.
|
|
*/
|
|
const usesEntry = template.elements.some(
|
|
e => ('binding' in e && e.binding) || (e.kind === 'photo' && e.photoId == null),
|
|
)
|
|
if (!usesEntry) return -1
|
|
|
|
/*
|
|
* A template with three frames and an entry with one picture leaves two
|
|
* placeholders on a finished page, which is worse than a simpler layout.
|
|
* More pictures than frames is fine — the extra ones are parked.
|
|
*/
|
|
if (frames > photos + 1) return -1
|
|
if (wantsStory && !hasStory) return -1
|
|
|
|
// Closest match wins: a two-frame template for two pictures beats a one-frame
|
|
// one, and both beat a template that would leave a hole.
|
|
return 100 - Math.abs(frames - photos) * 10 - (wantsStory === hasStory ? 0 : 5)
|
|
}
|
|
|
|
/** Fill a template with an entry, and hand back the spread it makes. */
|
|
export function applyTemplate(
|
|
template: SpreadTemplate,
|
|
entry: TemplateEntry,
|
|
ctx: TemplateContext,
|
|
): BookSpread {
|
|
const { page } = ctx
|
|
const PW = page.pageWidth
|
|
const PH = page.pageHeight
|
|
|
|
let nextPhoto = 0
|
|
// A journal entry has no length limit and a text element does — see the note
|
|
// in the contract. Poured in whole, one long day is a book that will not save.
|
|
const story = (entry.story || '').trim().slice(0, MAX_TEXT_LENGTH)
|
|
const heading = (entry.title || entry.location || '').slice(0, MAX_TEXT_LENGTH)
|
|
|
|
const elements = template.elements.map(el => {
|
|
const out = {
|
|
...el,
|
|
id: uid(el.kind[0]),
|
|
frame: {
|
|
x: round2(el.frame.x * PW),
|
|
y: round2(el.frame.y * PH),
|
|
w: round2(el.frame.w * PW),
|
|
h: round2(el.frame.h * PH),
|
|
},
|
|
} as BookElement
|
|
|
|
// Sizes came out as fractions of the page height, so they go back up.
|
|
if ('size' in out && typeof out.size === 'number') out.size = round2(out.size * PH)
|
|
if ('radius' in out && typeof out.radius === 'number') out.radius = round2(out.radius * PW)
|
|
if ('strokeWidth' in out && typeof out.strokeWidth === 'number') {
|
|
out.strokeWidth = round2(out.strokeWidth * PW)
|
|
}
|
|
|
|
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 = 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) {
|
|
const photo = entry.photos[nextPhoto++]
|
|
if (photo) out.photoId = photo.photoId
|
|
}
|
|
|
|
if (out.kind === 'badge') fillBadge(out, entry, ctx)
|
|
|
|
if (out.kind === 'countries' && ctx.stats) {
|
|
out.codes = ctx.stats.countries.map(c => c.code)
|
|
out.names = ctx.stats.countries.map(c => c.name)
|
|
}
|
|
|
|
// The figures live in one map on the element, keyed by metric — the same
|
|
// shape the panels write when a stats element is placed by hand.
|
|
if (out.kind === 'stats' && ctx.stats) {
|
|
out.values = {
|
|
distance: ctx.stats.distance,
|
|
days: ctx.stats.days,
|
|
steps: ctx.stats.steps,
|
|
photos: ctx.stats.photos,
|
|
places: ctx.stats.places,
|
|
countries: ctx.stats.countries.length,
|
|
furthest: ctx.stats.furthest,
|
|
}
|
|
}
|
|
|
|
return out
|
|
})
|
|
|
|
return {
|
|
id: uid('sp'),
|
|
role: 'inner',
|
|
background: template.background,
|
|
elements,
|
|
parked: [],
|
|
entryId: entry.id,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The small marks, filled from the stop.
|
|
*
|
|
* A badge whose value the entry cannot answer keeps the one it was drawn with:
|
|
* a template showing "DAY 1" on an entry with no date is a template that reads
|
|
* as a placeholder, which is exactly what it is until a date exists.
|
|
*/
|
|
function fillBadge(el: BookElement & { kind: 'badge' }, entry: TemplateEntry, ctx: TemplateContext) {
|
|
if (el.variant === 'day' && entry.dayNumber) {
|
|
el.text = `${ctx.dayLabel} ${entry.dayNumber}`
|
|
}
|
|
if (el.variant === 'coords' && entry.lat != null && entry.lng != null) {
|
|
el.text = formatBookCoords(entry.lat, entry.lng)
|
|
}
|
|
if ((el.variant === 'flag' || el.variant === 'country') && entry.country) {
|
|
el.code = entry.country
|
|
el.text = countryName(entry.country, ctx).toUpperCase()
|
|
}
|
|
if (el.variant === 'date' && entry.date) {
|
|
const d = new Date(`${entry.date}T00:00:00`)
|
|
if (!Number.isNaN(d.getTime())) {
|
|
el.text = String(d.getDate())
|
|
el.sub = d.toLocaleDateString(ctx.locale, { month: 'long' }).toUpperCase()
|
|
}
|
|
}
|
|
if (el.variant === 'distance' && ctx.stats?.distance) {
|
|
el.text = `${Math.round(ctx.stats.distance / 1000).toLocaleString(ctx.locale)} km`
|
|
}
|
|
}
|
|
|
|
function countryName(code: string, ctx: TemplateContext): string {
|
|
const known = ctx.stats?.countries.find(c => c.code === code)
|
|
try {
|
|
return new Intl.DisplayNames([ctx.locale], { type: 'region' }).of(code) ?? known?.name ?? code
|
|
} catch {
|
|
return known?.name ?? code
|
|
}
|
|
}
|
|
|
|
const round2 = (n: number) => Math.round(n * 100) / 100
|