import { relayoutSpread } from './auto/auto-book'; import type { AutoEntry, AutoInput } from './auto/auto-types'; import { bookElementPropsSchema, type BOOK_ELEMENT_OP_SCHEMAS, type BookNewElement } from './book-element-ops.schema'; import { bookGridElements, defaultBookGridBox, findBookGrid } from './book-grids'; import { bookBoundWords, failOp, galleryPhoto, journeyEntry, pageElement, roomFor, round2, unlockedElement, type BookOpsContext, } from './book-ops-core'; import { bookBadgeValue, bookCountryMark, bookCountryName, bookEntryListItems, bookTravelBadge, bookTravelCountries, bookTravelList, bookTravelMap, bookTravelSize, bookTravelStats, refreshPatch, type BookTravelContext, } from './book-travel'; import type { BookDocument, BookElement, BookFrame, BookPageSetup, BookPhotoElement, BookSpread, BookTextElement, } from './book.schema'; import type { z } from 'zod'; /** * The element operations of the book engine: what `add`, `style`, `arrange`, * `align`, `distribute`, `lock`, `duplicate`, `fill`, `copy_from`, * `refresh_travel` and `add_grid` do to a page, and the `auto` layout. Called * from `applyBookOps` (book-ops.ts), which parses, runs a call all or nothing * and checks the page it hands back against the document's schema. * * Each does what the Studio's own control does: the sizes an element arrives * at are the panels', `fill` is the inspector's, `duplicate` offsets by 4 mm * like the Studio's. Where the assistant may do more than the Studio * (aligning and distributing a group), it is said in the operation's schema. */ interface ElementOpEnv { doc: BookDocument; page: BookPageSetup; ctx: BookOpsContext; newId: (prefix: string) => string; opIndex: number; } type ElementOp = z.infer<(typeof BOOK_ELEMENT_OP_SCHEMAS)[number]>; type Op = Extract; export function applyElementOp(spread: BookSpread, op: ElementOp, env: ElementOpEnv): BookSpread { switch (op.op) { case 'add': return opAdd(spread, op, env); case 'style': return opStyle(spread, op, env); case 'arrange': return opArrange(spread, op, env); case 'align': return opAlign(spread, op, env); case 'distribute': return opDistribute(spread, op, env); case 'lock': return opLock(spread, op, env); case 'duplicate': return opDuplicate(spread, op, env); case 'fill': return opFill(spread, op, env); case 'copy_from': return opCopyFrom(spread, op, env); case 'refresh_travel': return opRefreshTravel(spread, op, env); case 'add_grid': return opAddGrid(spread, op, env); } } // ── helpers ──────────────────────────────────────────────────────────────── const isInner = (spread: BookSpread) => spread.role === 'inner'; /** A size centred on the page: the right-hand page of a spread, so a new element never lands in the fold. */ function centred(spread: BookSpread, page: BookPageSetup, size: { w: number; h: number }): BookFrame { const origin = isInner(spread) ? page.pageWidth : 0; return { x: round2(origin + (page.pageWidth - size.w) / 2), y: round2((page.pageHeight - size.h) / 2), w: round2(size.w), h: round2(size.h), }; } const roundFrame = (f: BookFrame): BookFrame => ({ x: round2(f.x), y: round2(f.y), w: round2(f.w), h: round2(f.h) }); /** The unlocked elements an operation names, each on the page, in the order named, each once. */ function namedElements(spread: BookSpread, ids: readonly string[], env: ElementOpEnv): BookElement[] { return [...new Set(ids)].map((id) => unlockedElement(pageElement(spread, id, env.opIndex), env.opIndex)); } function replaceAll(spread: BookSpread, next: readonly BookElement[]): BookSpread { const byId = new Map(next.map((e) => [e.id, e])); return { ...spread, elements: spread.elements.map((e) => byId.get(e.id) ?? e) }; } function travelOf(env: ElementOpEnv): BookTravelContext { const travel = env.ctx.travel; if (!travel) throw failOp('travel_unavailable', "the journey's figures are not available here", env.opIndex); return travel; } function statsOf(env: ElementOpEnv) { const stats = travelOf(env).stats; if (!stats) { throw failOp('travel_unavailable', 'this journey has no figures yet (no stops with places)', env.opIndex); } return stats; } /** The look fields a kind takes, from the props schema, so `add` and `style` set exactly what `style` may set. */ const PROP_KEYS = new Map(bookElementPropsSchema.options.map((o) => [o.shape.kind.value, lookKeys(o.shape)])); function lookKeys(shape: object): string[] { return Object.keys(shape).filter((k) => k !== 'kind'); } /** The element with the given look fields set; millimetre fields rounded as the document stores them. */ function withProps(el: BookElement, props: Record): BookElement { const keys = PROP_KEYS.get(el.kind) ?? []; const next: Record = { ...el }; for (const key of keys) { const value = props[key]; if (value === undefined) continue; next[key] = (key === 'radius' || key === 'strokeWidth') && typeof value === 'number' ? round2(value) : value; } return next as unknown as BookElement; } // ── add ──────────────────────────────────────────────────────────────────── function opAdd(spread: BookSpread, op: Op<'add'>, env: ElementOpEnv): BookSpread { roomFor(spread, 1, env.opIndex); const made = withProps(created(spread, op.element, op.frame ? roundFrame(op.frame) : null, env), lookOf(op.element)); const el = op.rotation !== undefined ? { ...made, rotation: op.rotation } : made; const parked = el.kind === 'photo' && el.photoId != null ? spread.parked.filter((p) => !(p.kind === 'photo' && p.photoId === el.photoId)) : spread.parked; return { ...spread, elements: [...spread.elements, el], parked }; } /** The look fields of a new element. A shape's name is `created`'s: a rule arrives as a hairline rect, never `line`. */ function lookOf(spec: BookNewElement): Record { const look: Record = { ...spec }; if (spec.kind === 'shape') delete look.shape; return look; } const BASE = { rotation: 0, opacity: 1, locked: false }; function created(spread: BookSpread, spec: BookNewElement, frame: BookFrame | null, env: ElementOpEnv): BookElement { const at = (size: { w: number; h: number }) => frame ?? centred(spread, env.page, size); switch (spec.kind) { case 'photo': return newPhotoFrame(spec, at, env); case 'text': return newText(spec, at, env); case 'shape': return newShape(spec, frame, spread, env); case 'icon': { const side = Math.min(env.page.pageWidth, env.page.pageHeight) * 0.18; return { ...BASE, id: env.newId('i'), kind: 'icon', frame: at({ w: side, h: side }), name: spec.name, color: '#111827', lineWidth: 2, }; } default: return newTravel(spec, at, env); } } type At = (size: { w: number; h: number }) => BookFrame; function newPhotoFrame(spec: Extract, at: At, env: ElementOpEnv): BookPhotoElement { if (spec.photoId !== undefined) galleryPhoto(spec.photoId, env.ctx, env.opIndex); const side = Math.min(env.page.pageWidth, env.page.pageHeight) * 0.45; // A Polaroid is taller than it is wide, because of the chin. const frame = at({ w: side, h: spec.frameStyle === 'polaroid' ? side * 1.16 : side }); return { ...BASE, id: env.newId('p'), kind: 'photo', frame, photoId: spec.photoId ?? null, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none', mask: null, frameStyle: 'none', }; } /** The Studio's text presets: size, weight and the colour a caption and a quotation mark take. */ const TEXT_PRESETS = { heading: { size: 30, weight: 700 }, subheading: { size: 16, weight: 600 }, body: { size: 10, weight: 400 }, caption: { size: 7.5, weight: 600, tracking: 0.14, color: '#8a8578' }, quote: { size: 46, weight: 700, color: '#c9c2b4', leading: 0.9 }, } as const satisfies Record>; function newText(spec: Extract, at: At, env: ElementOpEnv): BookTextElement { const binding = spec.binding ?? null; if (binding?.photoId !== undefined) galleryPhoto(binding.photoId, env.ctx, env.opIndex); if (binding?.entryId !== undefined) journeyEntry(binding.entryId, env.ctx, env.opIndex); const read = binding ? bookBoundWords(binding, env.ctx) : undefined; const words = spec.text ?? read; if (words === undefined || !words.trim()) { throw failOp('text_required', 'give the words for the new text; its binding cannot be read here', env.opIndex); } const preset: Partial = TEXT_PRESETS[spec.preset ?? 'body']; const size = preset.size ?? 10; return { ...BASE, id: env.newId('t'), kind: 'text', frame: at({ w: env.page.pageWidth * 0.7, h: size * 0.5 + 8 }), text: words, font: 'sans', size, weight: preset.weight ?? 400, italic: false, align: 'left', leading: preset.leading ?? (size > 16 ? 1.1 : 1.5), tracking: preset.tracking ?? (size > 16 ? -0.02 : 0), color: preset.color ?? '#1a1a1a', binding, overridden: binding !== null && spec.text !== undefined && read !== spec.text, }; } const WIDE_SHAPES: readonly string[] = ['ticket', 'wave', 'arrow-right', 'arrow-left', 'arrow-both', 'capsule']; const TALL_SHAPES: readonly string[] = ['pin', 'drop', 'arrow-up', 'arrow-down']; /** Placed at a size that suits the shape: a banner wide, a pin tall, a rule a hairline across half a page. */ function newShape( spec: Extract, frame: BookFrame | null, spread: BookSpread, env: ElementOpEnv, ): BookElement { const s = spec.shape; const side = Math.min(env.page.pageWidth, env.page.pageHeight) * 0.3; const wide = s.startsWith('banner') || WIDE_SHAPES.includes(s); const tall = TALL_SHAPES.includes(s); const rule = s === 'line'; const size = rule ? { w: env.page.pageWidth * 0.5, h: 0.5 } : { w: wide ? side * 1.7 : side, h: tall ? side * 1.35 : wide ? side * 0.55 : side }; return { ...BASE, id: env.newId('s'), kind: 'shape', frame: frame ?? centred(spread, env.page, size), // A rule is a hairline box rather than its own element type, as in the Studio. shape: rule ? 'rect' : s, fill: '#141414', gradient: 'none', stroke: null, strokeWidth: 0, strokeStyle: 'solid', radius: 0, }; } type TravelSpec = Extract; function newTravel(spec: TravelSpec, at: At, env: ElementOpEnv): BookElement { const id = env.newId(spec.kind === 'stats' ? 'st' : spec.kind.slice(0, 2)); const page = env.page; switch (spec.kind) { case 'map': { const frame = at(bookTravelSize('map', page)); const el = bookTravelMap(travelOf(env), statsOf(env), { id, frame, scope: spec.scope, source: spec.source }); if (el.points.length === 0) { throw failOp('travel_unavailable', 'no stops of the journey fall in that scope', env.opIndex); } return el; } case 'stats': return bookTravelStats(statsOf(env), { id, frame: at(bookTravelSize('stats', page)), metrics: spec.metrics }); case 'countries': { const travel = travelOf(env); const el = bookTravelCountries(travel, { id, frame: at(bookTravelSize('countries', page)), codes: spec.codes }); if (el.codes.length === 0) { throw failOp('travel_unavailable', 'the journey has no countries to show', env.opIndex); } return el; } case 'badge': return newBadge(spec, id, at, env); case 'list': return newList(spec, id, at, env); } } /** The journey's figures when an entry is named (an entry needs them to be found), else whatever is known. */ const travelFor = (entryId: number | undefined, env: ElementOpEnv) => entryId !== undefined ? travelOf(env) : env.ctx.travel; function newBadge(spec: Extract, id: string, at: At, env: ElementOpEnv): BookElement { const travel = travelFor(spec.entryId, env); const entry = spec.entryId !== undefined ? (travel?.entries.find((e) => e.id === spec.entryId) ?? null) : null; if (spec.entryId !== undefined && !entry) { throw failOp('entry_not_found', `entry ${spec.entryId} is not in this journey`, env.opIndex); } const resolved = travel ? badgeValue(spec, travel, entry) : null; const ownCode = spec.variant === 'mood' ? entry?.mood : spec.variant === 'weather' ? entry?.weather : undefined; const text = spec.text ?? resolved?.text; if (text === undefined || (!text && !resolved)) { const why = travel ? 'nothing in the journey says what it would show' : "the journey's figures are not available"; throw failOp('text_required', `give the words for the ${spec.variant} mark; ${why}`, env.opIndex); } const value = { text, sub: spec.sub ?? resolved?.sub ?? '', code: spec.code ?? ownCode ?? resolved?.code ?? null, }; const frame = at(bookTravelSize('badge', env.page, { variant: spec.variant })); return bookTravelBadge({ id, frame, variant: spec.variant, value }); } /** * The words the journey gives a mark. A flag or country mark asked for by its * own code is labelled with that country, its name where the mark's rule puts * it (`bookCountryMark`); without a code it shows the entry's country or the * journey's first. */ function badgeValue(spec: Extract, travel: BookTravelContext, entry: AutoEntry | null) { const { variant, code } = spec; if (code && (variant === 'flag' || variant === 'country')) { return bookCountryMark(variant, code, bookCountryName(code, travel.locale, travel.stats)); } return bookBadgeValue(travel, variant, entry); } function newList(spec: Extract, id: string, at: At, env: ElementOpEnv): BookElement { const travel = travelFor(spec.entryId, env); let items = spec.items ?? []; if (spec.entryId !== undefined) { const entry = travel?.entries.find((e) => e.id === spec.entryId); if (!entry) throw failOp('entry_not_found', `entry ${spec.entryId} is not in this journey`, env.opIndex); items = bookEntryListItems(entry); if (!items.length) { throw failOp('text_required', `entry ${spec.entryId} has no pros or cons; give the items`, env.opIndex); } } const labels = { pros: travel?.labels.pros ?? '', cons: travel?.labels.cons ?? '' }; const rows = Math.max(items.filter((i) => i.tone === 'pro').length, items.filter((i) => i.tone !== 'pro').length); const frame = at(bookTravelSize('list', env.page, { rows })); return bookTravelList(labels, { id, frame, items }); } // ── style, lock ──────────────────────────────────────────────────────────── function opStyle(spread: BookSpread, op: Op<'style'>, env: ElementOpEnv): BookSpread { const el = unlockedElement(pageElement(spread, op.elementId, env.opIndex), env.opIndex); if (op.set.kind !== el.kind) { throw failOp('wrong_kind', `${el.id} is a ${el.kind}; set.kind says ${op.set.kind}`, env.opIndex, el.id); } return replaceAll(spread, [withProps(el, op.set)]); } function opLock(spread: BookSpread, op: Op<'lock'>, env: ElementOpEnv): BookSpread { const els = [...new Set(op.elementIds)].map((id) => pageElement(spread, id, env.opIndex)); return replaceAll( spread, els.map((e) => ({ ...e, locked: op.locked })), ); } // ── arrange ──────────────────────────────────────────────────────────────── function opArrange(spread: BookSpread, op: Op<'arrange'>, env: ElementOpEnv): BookSpread { const chosen = new Set(namedElements(spread, op.elementIds, env).map((e) => e.id)); const pick = (want: boolean) => spread.elements.filter((e) => chosen.has(e.id) === want); if (op.to === 'front') return { ...spread, elements: [...pick(false), ...pick(true)] }; if (op.to === 'back') return { ...spread, elements: [...pick(true), ...pick(false)] }; const els = spread.elements.slice(); const step = op.to === 'forward' ? 1 : -1; const order = els.map((_, i) => i); if (step === 1) order.reverse(); for (const i of order) { const here = els[i]; const there = els[i + step]; if (!here || !there || !chosen.has(here.id) || chosen.has(there.id)) continue; els[i] = there; els[i + step] = here; } return { ...spread, elements: els }; } // ── align, distribute ────────────────────────────────────────────────────── /** A frame in millimetres, as the book types it, so a zod-inferred `el.frame` passes in every compile mode. */ type Box = BookFrame; function boundsOf(frames: readonly Box[]): Box { const x = Math.min(...frames.map((f) => f.x)); const y = Math.min(...frames.map((f) => f.y)); const right = Math.max(...frames.map((f) => f.x + f.w)); const bottom = Math.max(...frames.map((f) => f.y + f.h)); return { x, y, w: right - x, h: bottom - y }; } /** The page an element's centre is on, or its safe area. */ function pageBox(spread: BookSpread, page: BookPageSetup, f: Box, safe: boolean): Box { const onRight = isInner(spread) && f.x + f.w / 2 >= page.pageWidth; const x = onRight ? page.pageWidth : 0; const inset = safe ? page.safe : 0; return { x: x + inset, y: inset, w: page.pageWidth - inset * 2, h: page.pageHeight - inset * 2 }; } function alignedFrame(f: Box, box: Box, edge: Op<'align'>['edge']): BookFrame { switch (edge) { case 'left': return { ...f, x: round2(box.x) }; case 'center': return { ...f, x: round2(box.x + (box.w - f.w) / 2) }; case 'right': return { ...f, x: round2(box.x + box.w - f.w) }; case 'top': return { ...f, y: round2(box.y) }; case 'middle': return { ...f, y: round2(box.y + (box.h - f.h) / 2) }; case 'bottom': return { ...f, y: round2(box.y + box.h - f.h) }; } } function opAlign(spread: BookSpread, op: Op<'align'>, env: ElementOpEnv): BookSpread { const els = namedElements(spread, op.elementIds, env); const selection = boundsOf(els.map((e) => e.frame)); const next = els.map((e) => { const box = op.to === 'selection' ? selection : pageBox(spread, env.page, e.frame, op.to === 'safe'); return { ...e, frame: alignedFrame(e.frame, box, op.edge) } as BookElement; }); return replaceAll(spread, next); } function opDistribute(spread: BookSpread, op: Op<'distribute'>, env: ElementOpEnv): BookSpread { const els = namedElements(spread, op.elementIds, env); if (els.length < 3) throw failOp('not_enough_elements', 'distribute needs three different elements', env.opIndex); const across = op.axis === 'horizontal'; const start = (f: Box) => (across ? f.x : f.y); const size = (f: Box) => (across ? f.w : f.h); const sorted = els.slice().sort((a, b) => start(a.frame) - start(b.frame)); const first = sorted[0] as BookElement; const last = sorted[sorted.length - 1] as BookElement; const total = sorted.reduce((sum, e) => sum + size(e.frame), 0); const span = start(last.frame) + size(last.frame) - start(first.frame); const gap = op.gap ?? (span - total) / (sorted.length - 1); let cursor = start(first.frame); const next = sorted.map((e) => { const at = round2(cursor); cursor += size(e.frame) + gap; return { ...e, frame: across ? { ...e.frame, x: at } : { ...e.frame, y: at } } as BookElement; }); return replaceAll(spread, next); } // ── duplicate, fill, copy_from ───────────────────────────────────────────── const prefixOf = (el: BookElement) => el.kind.charAt(0); function opDuplicate(spread: BookSpread, op: Op<'duplicate'>, env: ElementOpEnv): BookSpread { const els = [...new Set(op.elementIds)].map((id) => pageElement(spread, id, env.opIndex)); roomFor(spread, els.length, env.opIndex); const dx = op.dx ?? 4; const dy = op.dy ?? 4; const copies = els.map((e) => { const frame = { ...e.frame, x: round2(e.frame.x + dx), y: round2(e.frame.y + dy) }; return { ...e, id: env.newId(prefixOf(e)), frame } as BookElement; }); return { ...spread, elements: [...spread.elements, ...copies] }; } /** The inspector's fill: the photo to the bleed of the page its centre is on, or of the spread, at the back. */ function opFill(spread: BookSpread, op: Op<'fill'>, env: ElementOpEnv): BookSpread { const el = unlockedElement(pageElement(spread, op.elementId, env.opIndex), env.opIndex); if (el.kind !== 'photo') { throw failOp('wrong_kind', `${el.id} is a ${el.kind}; fill takes a photo`, env.opIndex, el.id); } const { bleed, pageWidth, pageHeight } = env.page; const onRight = isInner(spread) && op.scope === 'page' && el.frame.x + el.frame.w / 2 >= pageWidth; const spanning = op.scope === 'spread' && isInner(spread); const frame = { x: onRight ? pageWidth - bleed : -bleed, y: -bleed, w: (spanning ? 2 : 1) * pageWidth + 2 * bleed, h: pageHeight + 2 * bleed, }; const filled: BookElement = { ...el, frame, rotation: 0, fit: 'cover' }; return { ...spread, elements: [filled, ...spread.elements.filter((e) => e.id !== el.id)] }; } /** Which page of its spread an element is on: the left (or only) page is 0, the right one starts at the fold. */ const pageOrigin = (spread: BookSpread, page: BookPageSetup, f: Box) => isInner(spread) && f.x + f.w / 2 >= page.pageWidth ? page.pageWidth : 0; function opCopyFrom(spread: BookSpread, op: Op<'copy_from'>, env: ElementOpEnv): BookSpread { const source = env.doc.spreads.find((s) => s.id === op.spreadId); if (!source) throw failOp('spread_not_found', `no page ${op.spreadId}`, env.opIndex); const els = [...new Set(op.elementIds)].map((id) => pageElement(source, id, env.opIndex)); roomFor(spread, els.length, env.opIndex); const sameKind = isInner(source) === isInner(spread); const target = isInner(spread) ? env.page.pageWidth : 0; const copies = els.map((e) => { if (e.kind === 'photo' && e.photoId != null) galleryPhoto(e.photoId, env.ctx, env.opIndex); const x = sameKind ? e.frame.x : e.frame.x - pageOrigin(source, env.page, e.frame) + target; return { ...e, id: env.newId(prefixOf(e)), frame: { ...e.frame, x: round2(x) } } as BookElement; }); return { ...spread, elements: [...spread.elements, ...copies] }; } // ── refresh_travel, add_grid ─────────────────────────────────────────────── const carriesFigures = (el: BookElement) => el.kind === 'stats' || el.kind === 'countries' || el.kind === 'map'; function refreshTargets(spread: BookSpread, ids: readonly string[] | undefined, env: ElementOpEnv): BookElement[] { if (!ids) return spread.elements.filter((el) => carriesFigures(el) && !el.locked); const named = namedElements(spread, ids, env); const other = named.find((el) => !carriesFigures(el)); if (other) { throw failOp('wrong_kind', `${other.id} is a ${other.kind}; it carries no journey figures`, env.opIndex, other.id); } return named; } function refreshed(el: BookElement, patch: Partial | null): BookElement { return { ...el, ...patch } as BookElement; } function opRefreshTravel(spread: BookSpread, op: Op<'refresh_travel'>, env: ElementOpEnv): BookSpread { const stats = statsOf(env); const next = refreshTargets(spread, op.elementIds, env).map((el) => refreshed(el, refreshPatch(el, stats))); return replaceAll(spread, next); } function opAddGrid(spread: BookSpread, op: Op<'add_grid'>, env: ElementOpEnv): BookSpread { const grid = findBookGrid(op.grid); if (!grid) throw failOp('layout_not_found', `no grid ${op.grid}`, env.opIndex); const box = op.box ?? defaultBookGridBox(env.page, !isInner(spread)); const cells = bookGridElements(grid, box).map((c) => ({ ...c, id: env.newId('p'), frame: roundFrame(c.frame) })); roomFor(spread, cells.length, env.opIndex); return { ...spread, elements: [...spread.elements, ...cells] }; } // ── layout auto ──────────────────────────────────────────────────────────── /** * The Studio's "lay this page out again": rebuilt from the entry the page was * made from, with the journey's figures. The page keeps its id and what it had * parked; the caller keeps locked elements where they were. */ export function autoRelayout(spread: BookSpread, env: ElementOpEnv): BookSpread { const travel = travelOf(env); if (spread.entryId == null || !isInner(spread)) { throw failOp('layout_not_found', 'this page was not made from an entry; auto layout needs one', env.opIndex); } if (!travel.entries.some((e) => e.id === spread.entryId)) { throw failOp('entry_not_found', `entry ${spread.entryId} is not in this journey`, env.opIndex); } const journey = env.ctx.journey; const input: AutoInput = { locale: travel.locale, title: journey?.title ?? env.doc.title, subtitle: journey?.subtitle ?? null, coverPhotoId: journey?.coverPhotoId ?? null, entries: [...travel.entries], page: env.page, stats: travel.stats, stationsLabel: travel.labels.stations, dayLabel: travel.labels.day, path: travel.path, summaryLabel: travel.labels.summary, countriesLabel: travel.labels.countries, newId: env.newId, }; const next = relayoutSpread(spread, input); if (!next) throw failOp('layout_not_found', 'this page cannot be laid out again from its entry', env.opIndex); return { ...next, parked: spread.parked }; }