diff --git a/server/src/nest/assistant/assistant-book-gate.ts b/server/src/nest/assistant/assistant-book-gate.ts index a6a7cb27d..c0a93a0ca 100644 --- a/server/src/nest/assistant/assistant-book-gate.ts +++ b/server/src/nest/assistant/assistant-book-gate.ts @@ -13,7 +13,7 @@ import type { ToolArgs } from './cards/card-types'; import { readKey, recordOf } from './cards/card-values'; import { MikroORM } from '@mikro-orm/core'; import { Injectable } from '@nestjs/common'; -import type { BookFinding } from '@trek/shared'; +import { compareCodeUnits, type BookFinding } from '@trek/shared'; /** * The check a Studio turn runs on every book write the model proposes, before @@ -71,7 +71,7 @@ function pageOf(tool: string, args: ToolArgs): string { const page = args.spreadId ?? args.afterSpreadId; if (typeof page === 'string') return `${tool}:${page}`; const ids = Array.isArray(args.spreadIds) ? args.spreadIds.filter((id): id is string => typeof id === 'string') : []; - return ids.length > 0 ? `${tool}:${[...ids].sort().join(',')}` : tool; + return ids.length > 0 ? `${tool}:${[...ids].sort(compareCodeUnits).join(',')}` : tool; } function reason(err: unknown): string { diff --git a/shared/src/book/auto/auto-book-spreads.ts b/shared/src/book/auto/auto-book-spreads.ts index 94ed336d4..9c0502d13 100644 --- a/shared/src/book/auto/auto-book-spreads.ts +++ b/shared/src/book/auto/auto-book-spreads.ts @@ -1,3 +1,4 @@ +import { compareCodeUnits } from '../../common/compare'; import { formatBookDate } from '../book-text'; import { MAX_PATH_POINTS, @@ -153,7 +154,7 @@ function formatKm(metres: number, locale: string): string { } /** Which figures are worth the space: everything the journey actually has, at most six. */ -function summaryMetrics(stats: JourneyStats): BookMetric[] { +export function summaryMetrics(stats: JourneyStats): BookMetric[] { const all: [BookMetric, number][] = [ ['distance', stats.distance], ['days', stats.days], @@ -262,7 +263,7 @@ export function backSpread(input: AutoInput, newId: NewId): BookSpread { const dates = input.entries .map((e) => e.date) .filter((d): d is string => !!d) - .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + .sort(compareCodeUnits); const first = dates[0]; const last = dates[dates.length - 1]; const ends = first && last ? [formatBookDate(first, input.locale), formatBookDate(last, input.locale)] : []; diff --git a/shared/src/book/book-element-ops.spec.ts b/shared/src/book/book-element-ops.spec.ts index 6cc8a034c..56098ed61 100644 --- a/shared/src/book/book-element-ops.spec.ts +++ b/shared/src/book/book-element-ops.spec.ts @@ -181,6 +181,16 @@ describe('add', () => { expect(addRefusal({ kind: 'list', entryId: 7 }, bare)).toBe('travel_unavailable'); expect(added({ kind: 'countries', codes: ['pt'] })).toMatchObject({ kind: 'countries', codes: ['PT'] }); }); + + it('BOOKEL-017: a flag or country mark asked for by its own code is labelled with that country', () => { + expect(added({ kind: 'badge', variant: 'flag' })).toMatchObject({ code: 'IS', sub: 'Iceland' }); + expect(added({ kind: 'badge', variant: 'flag', code: 'PT' })).toMatchObject({ code: 'PT', sub: 'Portugal' }); + const norway = added({ kind: 'badge', variant: 'country', entryId: 7, code: 'NO' }); + expect(norway).toMatchObject({ code: 'NO', sub: 'Norway' }); + expect(added({ kind: 'badge', variant: 'flag', code: 'PT', sub: 'Lisboa' })).toMatchObject({ sub: 'Lisboa' }); + // Another mark's code is its own (a mood, the weather) and names no country. + expect(added({ kind: 'badge', variant: 'mood', code: 'good', text: 'Good' })).toMatchObject({ sub: '' }); + }); }); describe('style, lock, arrange', () => { diff --git a/shared/src/book/book-findings-design.ts b/shared/src/book/book-findings-design.ts index 927040b32..eab59e0e0 100644 --- a/shared/src/book/book-findings-design.ts +++ b/shared/src/book/book-findings-design.ts @@ -1,3 +1,4 @@ +import { compareCodeUnits } from '../common/compare'; import { contrastOfLuminance, deltaE, greyLuminance, luminance, parseHex, requiredContrast } from './book-colour'; import type { BookPhotoInfo } from './book-ops-core'; import { BOOK_OP_LIMITS, type BookFinding } from './book-ops.schema'; @@ -314,7 +315,7 @@ function familiesOf(doc: BookDocument): string[] { } } if (doc.page.pageNumbers.show) used.add(doc.page.pageNumbers.font); - return [...used].sort(); + return [...used].sort(compareCodeUnits); } /** The colours a book paints with: its type and its filled shapes, with how often each is used. */ diff --git a/shared/src/book/book-findings.ts b/shared/src/book/book-findings.ts index 4cde98426..bf92b6ff0 100644 --- a/shared/src/book/book-findings.ts +++ b/shared/src/book/book-findings.ts @@ -1,6 +1,6 @@ -import { bookVisiblePart } from './book-findings-design'; +import { bookVisiblePart, sortBookFindings } from './book-findings-design'; import type { BookPhotoInfo } from './book-ops-core'; -import { BOOK_OP_LIMITS, type BookFinding } from './book-ops.schema'; +import type { BookFinding } from './book-ops.schema'; import type { BookElement, BookPageSetup, BookPhotoElement, BookSpread, BookTextElement } from './book.schema'; /** @@ -378,8 +378,6 @@ function parked(ctx: Ctx): void { }); } -const SEVERITY_ORDER = { severe: 0, warn: 1, info: 2 } as const; - /** Every finding on one page, severe first, at most `BOOK_OP_LIMITS.findings`. */ export function spreadFindings( spread: BookSpread, @@ -401,9 +399,5 @@ export function spreadFindings( overflow(ctx); photoChecks(ctx); parked(ctx); - return ctx.out - .map((f, i) => ({ f, i })) - .sort((a, b) => SEVERITY_ORDER[a.f.severity] - SEVERITY_ORDER[b.f.severity] || a.i - b.i) - .map(({ f }) => f) - .slice(0, BOOK_OP_LIMITS.findings); + return sortBookFindings(ctx.out); } diff --git a/shared/src/book/book-ops-elements.ts b/shared/src/book/book-ops-elements.ts index 6b21de0e4..a4a89d21b 100644 --- a/shared/src/book/book-ops-elements.ts +++ b/shared/src/book/book-ops-elements.ts @@ -15,6 +15,7 @@ import { } from './book-ops-core'; import { bookBadgeValue, + bookCountryName, bookEntryListItems, bookTravelBadge, bookTravelCountries, @@ -342,13 +343,31 @@ function newBadge(spec: Extract, id: string, at: } const value = { text, - sub: spec.sub ?? resolved?.sub ?? '', + sub: spec.sub ?? badgeSub(spec, resolved?.sub, resolved?.code, travel) ?? '', 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 marks whose code names a country. */ +const COUNTRY_MARKS: ReadonlySet = new Set(['flag', 'country']); + +/** + * A flag or country mark asked for by its own code is labelled with that + * country; the journey's value names its first country, the one a mark + * without a code shows. + */ +function badgeSub( + spec: Extract, + sub: string | undefined, + code: string | null | undefined, + travel: BookTravelContext | undefined, +): string | undefined { + if (!travel || !spec.code || !COUNTRY_MARKS.has(spec.variant)) return sub; + return spec.code === code ? sub : bookCountryName(spec.code, travel.locale, travel.stats); +} + function newList(spec: Extract, id: string, at: At, env: ElementOpEnv): BookElement { const travel = travelFor(spec.entryId, env); let items = spec.items ?? []; diff --git a/shared/src/book/book-ops.ts b/shared/src/book/book-ops.ts index ae10590af..0764d3e18 100644 --- a/shared/src/book/book-ops.ts +++ b/shared/src/book/book-ops.ts @@ -11,7 +11,10 @@ import { galleryPhoto, invalidOp, journeyEntry, + pageElement, + roomFor, round2, + unlockedElement, type BookEditResult, type BookOpsContext, type BookPhotoInfo, @@ -125,24 +128,10 @@ type Op = Extract; // ── Lookups ──────────────────────────────────────────────────────────────── -function element(spread: BookSpread, id: string, env: Env): BookElement { - const el = spread.elements.find((e) => e.id === id); - if (el) return el; - const parked = spread.parked.some((e) => e.id === id); - throw failOp( - 'element_not_found', - parked - ? `${id} is parked, not on the page; lay the page out again to bring it back` - : `no element ${id} on this page`, - env.opIndex, - id, - ); -} +// The refusals' words live once, in book-ops-core, for the page ops and the element ops alike. +const element = (spread: BookSpread, id: string, env: Env): BookElement => pageElement(spread, id, env.opIndex); -function unlocked(el: E, env: Env): E { - if (el.locked) throw failOp('locked', `${el.id} is locked; it is left as it is`, env.opIndex, el.id); - return el; -} +const unlocked = (el: E, env: Env): E => unlockedElement(el, env.opIndex); function photoEl(spread: BookSpread, id: string, env: Env): BookPhotoElement { const el = element(spread, id, env); @@ -161,11 +150,7 @@ const replaceEl = (spread: BookSpread, next: BookElement): BookSpread => ({ elements: spread.elements.map((e) => (e.id === next.id ? next : e)), }); -function withRoom(spread: BookSpread, add: number, env: Env): void { - if (spread.elements.length + add > MAX_SPREAD_ELEMENTS) { - throw failOp('too_many_elements', `a page holds at most ${MAX_SPREAD_ELEMENTS} elements`, env.opIndex); - } -} +const withRoom = (spread: BookSpread, add: number, env: Env): void => roomFor(spread, add, env.opIndex); const spreadWidth = (spread: BookSpread, page: BookPageSetup) => page.pageWidth * (spread.role === 'inner' ? 2 : 1); diff --git a/shared/src/book/describe-spread.spec.ts b/shared/src/book/describe-spread.spec.ts index 7d78db8e0..266b3947b 100644 --- a/shared/src/book/describe-spread.spec.ts +++ b/shared/src/book/describe-spread.spec.ts @@ -4,8 +4,14 @@ import type { BookPhotoInfo } from './book-ops-core'; import { BOOK_OP_LIMITS, bookPageDescriptionSchema, bookOverviewSchema, type BookFinding } from './book-ops.schema'; import { blankBook } from './book-pages'; import { + bookBadgeElementSchema, + bookCountriesElementSchema, + bookIconElementSchema, + bookListElementSchema, + bookMapElementSchema, bookPhotoElementSchema, bookShapeElementSchema, + bookStatsElementSchema, bookTextElementSchema, type BookDocument, type BookElement, @@ -238,6 +244,87 @@ describe('describeSpread', () => { expect(d?.text).toMatch(/Parked off the page \(1\): photo 3/); expect(d?.elements[0]).toMatchObject({ id: 't-b', binding: 'journey.title', text: 'Iceland' }); }); + + it('BOOKDESC-024: says how a styled photo, a shape, an icon and every travel element are set', () => { + const f = (x: number, y: number) => ({ x, y, w: 40, h: 30 }); + const els: BookElement[] = [ + photo('p-s', 1, f(230, 10), { filter: 'bw', mask: 'ellipse', frameStyle: 'polaroid', radius: 2 }), + bookShapeElementSchema.parse({ + id: 's-1', + kind: 'shape', + frame: f(280, 10), + gradient: 'down', + stroke: '#ff0000', + strokeWidth: 0.5, + strokeStyle: 'dashed', + radius: 2, + rotation: 5, + opacity: 0.5, + locked: true, + }) as BookElement, + bookIconElementSchema.parse({ id: 'ic-1', kind: 'icon', frame: f(330, 10), name: 'Compass' }) as BookElement, + bookMapElementSchema.parse({ + id: 'm-1', + kind: 'map', + frame: f(230, 50), + tripId: 3, + path: [ + [ + [64.1, -21.9], + [64.2, -21.8], + ], + [[65.6, -18.1]], + ], + }) as BookElement, + bookMapElementSchema.parse({ id: 'm-2', kind: 'map', frame: f(280, 50) }) as BookElement, + bookStatsElementSchema.parse({ + id: 'st-1', + kind: 'stats', + frame: f(330, 50), + metrics: ['distance', 'days'], + values: { distance: 1200 }, + stale: true, + }) as BookElement, + bookCountriesElementSchema.parse({ + id: 'c-1', + kind: 'countries', + frame: f(230, 90), + codes: ['IS', 'NO'], + }) as BookElement, + bookBadgeElementSchema.parse({ + id: 'b-1', + kind: 'badge', + frame: f(280, 90), + text: '13', + sub: 'June', + }) as BookElement, + bookListElementSchema.parse({ + id: 'l-1', + kind: 'list', + frame: f(330, 90), + items: [ + { text: 'Hot springs', tone: 'pro' }, + { text: 'Wind', tone: 'con' }, + ], + }) as BookElement, + ]; + const t = describeSpread(book(els), 'sp-in', gallery)?.text ?? ''; + expect(t).toMatch(/1\. photo p-s .*, filter bw, mask ellipse, frame style polaroid, radius 2/); + expect(t).toMatch( + /2\. shape s-1 at .*: rect, fill #111827, fading down, stroke #ff0000 0\.5 mm dashed, radius 2; turned 5 deg, opacity 0\.5, locked/, + ); + expect(t).toMatch(/3\. icon ic-1 at .*: icon Compass, #111827, line 2/); + expect(t).toMatch( + /4\. map m-1 at .*: minimal vector map of trip 3, 0 stops, 0 countries, a recorded track of 2 segments; sans bold, ink #1a1a1a, accent #111111, type x1/, + ); + expect(t).toMatch(/5\. map m-2 at .*: minimal vector map of the whole journey, 0 stops, 0 countries; sans bold/); + expect(t).toMatch( + /6\. stats st-1 at .*: figures \(grid\): distance 1200, days 0; .*; its figures are out of date with the journey/, + ); + expect(t).toMatch(/7\. countries c-1 at .*: 2 countries \(IS, NO\), list; /); + expect(t).toMatch(/8\. badge b-1 at .*: date mark "13" over "June", plain; /); + expect(t).toMatch(/9\. list l-1 at .*: list of 2 \(1 pros, 1 others\), columns; /); + }); }); describe('summaries', () => { diff --git a/shared/src/book/design/design-book.spec.ts b/shared/src/book/design/design-book.spec.ts index 2e69a7048..d2b9f0a8c 100644 --- a/shared/src/book/design/design-book.spec.ts +++ b/shared/src/book/design/design-book.spec.ts @@ -1,5 +1,8 @@ +import type { AutoEntry, AutoPhoto } from '../auto/auto-types'; import { bookLevelFindings } from '../book-findings-design'; +import type { BookPhotoInfo } from '../book-ops-core'; import { blankBook } from '../book-pages'; +import { isStale } from '../book-travel'; import { MAX_SPREADS, MAX_SPREAD_ELEMENTS, @@ -7,11 +10,16 @@ import { bookTextElementSchema, type BookDocument, type BookElement, + type BookListElement, + type BookMapElement, type BookSpread, } from '../book.schema'; import { pageFindings } from '../describe-spread'; +import type { PhotoPickCandidate } from '../photo-pick'; import { designBook, type BookDesignInput, type BookDesignResult } from './design-book'; -import type { BookDesignDirectivesInput } from './design-directives.schema'; +import { bookDesignDirectivesSchema, type BookDesignDirectivesInput } from './design-directives.schema'; +import { newRhythm, planChapter, type DesignChapter } from './design-pacing'; +import { burstOf, type BookDesignLeftOut } from './design-photos'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; @@ -23,11 +31,16 @@ import { describe, expect, it } from 'vitest'; * scope, chapters by day and by place, a palette from the photos, captions * and excerpts, chapter maps, a locked page kept. Each design is checked for * its structure (the framing in order, every chapter there, the rhythm, the - * budget, the caps, a document the save route takes) and for being clean: - * no severe and no warn finding left on any page or on the book. + * budget, the caps, a document the save route takes), for being clean (no + * severe and no warn finding left on any page or on the book) and for being + * current (no travel element out of date with the journey once placed). + * The pacing of a chapter (its bursts, the photo beats) is also read straight + * from the plan. */ type Name = 'rich' | 'sparse' | 'nostats'; +/** A fixture journey, or the rich one with its stops on two trips. */ +type Journey = Name | 'rich-trips'; const here = (file: string) => fileURLToPath(new URL(file, import.meta.url)); const fixtures = JSON.parse(readFileSync(here('./design-book.fixtures.json'), 'utf8')) as Record< @@ -36,12 +49,20 @@ const fixtures = JSON.parse(readFileSync(here('./design-book.fixtures.json'), 'u >; /** The journey with ids minted by a counter, so a design is the same every run. */ -function input(name: Name): BookDesignInput { +function input(name: Journey): BookDesignInput { let n = 0; - return { ...fixtures[name].input, newId: (p) => `${p}-${++n}` }; + const journey = name === 'rich-trips' ? withTrips(fixtures.rich.input) : fixtures[name].input; + return { ...journey, newId: (p) => `${p}-${++n}` }; } -const VARIANTS: [string, Name, BookDesignDirectivesInput][] = [ +/** The journey with its stops on two trips: the first four stops on trip 7, the others on trip 8. */ +function withTrips(journey: BookDesignInput): BookDesignInput { + if (!journey.stats) return journey; + const points = journey.stats.points.map((p) => ({ ...p, tripId: (p.entryId ?? 0) <= 4 ? 7 : 8 })); + return { ...journey, stats: { ...journey.stats, points } }; +} + +const VARIANTS: [string, Journey, BookDesignDirectivesInput][] = [ ['minimal', 'rich', { style: 'minimal' }], ['editorial', 'rich', { style: 'editorial' }], ['journal', 'rich', { style: 'journal' }], @@ -56,14 +77,13 @@ const VARIANTS: [string, Name, BookDesignDirectivesInput][] = [ ['places', 'rich', { style: 'bold', chapters: 'place' }], ['palette', 'rich', { style: 'classic', palette: 'from_photos', type: { scale: 0.8 } }], ['captions', 'rich', { style: 'journal', text: { captions: 'photo', story: 'excerpt', excerptChars: 300 } }], - ['maps', 'rich', { style: 'editorial', travel: { chapterMaps: true, dayBadges: false } }], + ['maps', 'rich-trips', { style: 'editorial', travel: { chapterMaps: true, dayBadges: false } }], ['sparse', 'sparse', { style: 'scrapbook' }], ['sparse-all', 'sparse', { style: 'minimal', photos: { pick: 'all' } }], ['nostats', 'nostats', { style: 'editorial' }], ['nostats-a5', 'nostats', { style: 'bold', preset: 'a5-landscape' }], ]; -const FRAMING_ROLES = ['cover', 'first', 'last', 'back']; const inner = (doc: BookDocument) => doc.spreads.filter((s) => s.role === 'inner'); const notInfo = (list: { severity: string }[]) => list.filter((f) => f.severity !== 'info'); @@ -89,7 +109,11 @@ function checkClean(result: BookDesignResult, photos: BookDesignInput['photos']) expect(notInfo(bookLevelFindings(result.doc))).toEqual([]); } -/** A loud spread (a hero, a panorama) is followed by a calm one, and heroes are four spreads apart. */ +/** + * A loud spread (a hero, a panorama) is followed by a calm one, heroes are + * four spreads apart, and no photo beat (two, three or a grid) comes three + * spreads running. + */ function checkRhythm(result: BookDesignResult): void { const beats = result.plan.filter((r) => r.action !== 'drop').map((r) => r.beat); const loud = (b: string) => b === 'hero' || b === 'pano'; @@ -100,6 +124,18 @@ function checkRhythm(result: BookDesignResult): void { heroes.forEach((at, i) => { if (i > 0) expect(at - (heroes[i - 1] as number)).toBeGreaterThanOrEqual(4); }); + const photoBeat = (b: string) => b === 'duo' || b === 'trio' || b === 'grid'; + beats.forEach((b, i) => { + if (i > 1 && photoBeat(b)) expect({ at: i, run: beats.slice(i - 2, i + 1) }).not.toEqual({ at: i, run: [b, b, b] }); + }); +} + +/** Every travel element carries the journey's figures as they are: none is out of date the moment it is placed. */ +function checkCurrent(result: BookDesignResult, stats: BookDesignInput['stats']): void { + for (const spread of result.doc.spreads) { + const stale = spread.elements.filter((e) => isStale(e, stats)).map((e) => e.id); + expect({ spread: spread.id, stale }).toEqual({ spread: spread.id, stale: [] }); + } } /** Every entry with something to show opens a spread of its own (or is listed among the stations). */ @@ -126,6 +162,7 @@ describe('design_book', () => { checkStructure(result); checkRhythm(result); checkClean(result, fx.photos); + checkCurrent(result, fx.stats); }); } @@ -286,6 +323,194 @@ describe('design_book', () => { expect(result.notes.length).toBeGreaterThan(0); } }); + + it('BOOKDSN-022: never sets one photo beat three spreads running, a rich chapter of sixteen photos included', () => { + const entry = { ...(input('rich').entries[1] as AutoEntry), photos: tall(900, 16) }; + const leftOut: BookDesignLeftOut[] = []; + const plans = planChapter(chapterOf(entry, []), 4, newRhythm(), planOf('rich'), leftOut); + expect(plans.map((p) => p.beat)).toEqual(['opener', 'grid', 'grid', 'trio']); + expect(plans.map((p) => p.photos.length)).toEqual([1, 5, 5, 3]); + expect(leftOut).toEqual([ + { photoId: 914, reason: 'page_budget' }, + { photoId: 915, reason: 'page_budget' }, + ]); + }); + + it('BOOKDSN-023: sets a burst as one grid, at an airy density too, and five of it after a loud spread', () => { + const quiet = input('rich').entries[0] as AutoEntry; + const fivePhotos = { ...quiet, photos: wide(1, 5) }; + const opened = planChapter(chapterOf(fivePhotos, [2, 3, 4, 5]), 3, newRhythm(), planOf('airy'), []); + expect(opened.map((p) => p.beat)).toEqual(['opener', 'grid']); + expect(opened[1]?.photos.map((p) => p.photoId)).toEqual([2, 3, 4, 5]); + + const hero: AutoPhoto = { photoId: 10, width: 4000, height: 3000 }; + const eightPhotos = { ...quiet, photos: [hero, ...wide(11, 7)] }; + const leftOut: BookDesignLeftOut[] = []; + const burst = [11, 12, 13, 14, 15, 16, 17]; + const loud = planChapter(chapterOf(eightPhotos, burst), 3, newRhythm(), planOf('airy'), leftOut); + expect(loud.map((p) => p.beat)).toEqual(['hero', 'grid', 'duo']); + expect(loud[1]?.photos.map((p) => p.photoId)).toEqual([11, 12, 13, 14, 15]); + expect(leftOut).toEqual([]); + + const shot = (photoId: number, takenAt: string | null): PhotoPickCandidate => ({ + ...(input('rich').candidates[0] as PhotoPickCandidate), + photoId, + takenAt, + }); + const pool = [ + shot(1, '2026-05-01T09:00:00'), + shot(2, '2026-05-01T12:00:00'), + shot(3, '2026-05-01T12:05:00'), + shot(4, '2026-05-01T12:09:00'), + shot(5, '2026-05-01T12:14:00'), + shot(6, null), + ]; + expect(burstOf(wide(1, 6), pool)).toEqual([2, 3, 4, 5]); + expect(burstOf(wide(1, 4), pool)).toEqual([]); + }); + + it('BOOKDSN-024: puts a map of its trip on a chapter opener, current when placed, and says which stops on no trip get none', () => { + // The structure, the rhythm and a clean page with chapter maps are the maps variant of BOOKDSN-001. + const fx = input('rich-trips'); + const directives: BookDesignDirectivesInput = { + style: 'editorial', + travel: { chapterMaps: true, dayBadges: false }, + }; + const result = designBook(null, fx, directives); + checkCurrent(result, fx.stats); + const maps = result.doc.spreads + .filter((s) => s.entryId != null) + .flatMap((s) => s.elements.filter((e): e is BookMapElement => e.kind === 'map')); + expect(maps.length).toBeGreaterThan(1); + const points = fx.stats?.points ?? []; + for (const map of maps) { + expect([7, 8]).toContain(map.tripId); + const stops = points.filter((p) => p.tripId === map.tripId).map((p) => p.label); + expect(map.points.map((p) => p.label)).toEqual(stops); + } + expect(result.notes.filter((n) => n.includes('on no trip'))).toEqual([]); + // The stop of entry 2 on no trip: the note names that entry alone. + const loose = points.map((p) => (p.entryId === 2 ? { ...p, tripId: null } : p)); + const oneOff = designBook(null, { ...fx, stats: fx.stats && { ...fx.stats, points: loose } }, directives); + expect(oneOff.notes).toContain('entry 2 is on no trip, so its chapter has no map'); + const untripped = designBook(null, input('rich'), directives); + const anyMap = untripped.doc.spreads.some((s) => s.entryId != null && s.elements.some((e) => e.kind === 'map')); + expect(anyMap).toBe(false); + expect(untripped.notes).toContain( + 'entries 2, 3, 4, 5, 6, 7, 8, 9, 10 and 11 are on no trip, so their chapters have no map', + ); + }); + + it("BOOKDSN-025: sets an entry's pros and cons as a list under its words, and says when they find no room", () => { + const spreadsOf = (result: BookDesignResult, entryId: number) => + result.doc.spreads.filter((s) => s.entryId === entryId); + const listsOf = (result: BookDesignResult, entryId: number) => + spreadsOf(result, entryId).flatMap((s) => s.elements.filter((e): e is BookListElement => e.kind === 'list')); + const asked: BookDesignDirectivesInput[] = [ + { style: 'editorial' }, + { style: 'minimal' }, + { style: 'journal', text: { story: 'excerpt' } }, + { style: 'editorial', scope: { entryIds: [3, 4, 5, 6] } }, + ]; + for (const directives of asked) { + const result = designBook(null, input('rich'), directives); + // The list takes no spread of its own: entry 3 keeps two, each with more on it than the list. + const pages = spreadsOf(result, 3); + expect(pages).toHaveLength(2); + expect(pages.every((s) => s.elements.some((e) => e.kind !== 'list'))).toBe(true); + const lists = listsOf(result, 3); + expect(lists).toHaveLength(1); + expect(lists[0]).toMatchObject({ + items: [ + { text: 'The light', tone: 'pro' }, + { text: 'The wind', tone: 'con' }, + ], + proLabel: 'Pros', + conLabel: 'Cons', + }); + } + // Entry 2 opens on a hero and has no quiet page; entry 4 opens on a panorama or on a hero with its quiet + // page; entry 12 has no photo, so its long story runs on across both pages and the list goes under it. + const more: Partial>> = { + 2: { pros: ['Tram 28'] }, + 4: { story: 'Over the bridge at dusk.', pros: ['The bridge'], cons: ['The climb'] }, + 12: { story: 'We walked the old town. '.repeat(130), cons: ['The rain'] }, + }; + const entries = input('rich').entries.map((e): AutoEntry => ({ ...e, ...more[e.id] })); + for (const directives of [{ style: 'editorial', travel: { dayBadges: false } }, { style: 'editorial' }] as const) { + const fx = { ...input('rich'), entries }; + const result = designBook(null, fx, directives); + checkStructure(result); + checkRhythm(result); + checkClean(result, fx.photos); + expect(listsOf(result, 4)).toHaveLength(1); + expect(listsOf(result, 12)).toHaveLength(1); + expect(listsOf(result, 2)).toEqual([]); + expect(result.notes).toContain('the pros and cons of entry 2 found no room on its pages and are left out'); + } + }); + + it('BOOKDSN-026: gives the quiet page of an entry with pros and cons a photo, and names the pros and cons left out', () => { + const listed = input('rich').entries[2] as AutoEntry; + const plain: AutoEntry = { ...listed, pros: [], cons: [] }; + const beats = (entry: AutoEntry, burst: number[]) => + planChapter(chapterOf(entry, burst), 3, newRhythm(), planOf('balanced'), []).map((p) => [ + p.beat, + p.photos.map((f) => f.photoId), + ]); + expect(beats({ ...listed, photos: tall(920, 4) }, [])).toEqual([ + ['opener', [920]], + ['quiet', [921]], + ['duo', [922, 923]], + ]); + expect(beats({ ...plain, photos: tall(920, 4) }, [])).toEqual([ + ['opener', [920]], + ['quiet', []], + ['trio', [921, 922, 923]], + ]); + // The quiet page's photo comes from outside the burst, which stays whole for its grid. + expect(beats({ ...listed, photos: tall(930, 6) }, [931, 932, 933, 934])).toEqual([ + ['opener', [930]], + ['quiet', [935]], + ['grid', [931, 932, 933, 934]], + ]); + + // Entry 1 is a bare stop among the stations; entry 4 shares its day with entry 3, whose chapter it is. + const more: Partial>> = { + 1: { pros: ['The tram'] }, + 4: { date: '2026-05-03', pros: ['The bridge'] }, + }; + const entries = input('rich').entries.map((e): AutoEntry => ({ ...e, ...more[e.id] })); + const days = designBook(null, { ...input('rich'), entries }, { style: 'minimal', chapters: 'day' }); + checkStructure(days); + const onThree = days.doc.spreads.filter((s) => s.entryId === 3).flatMap((s) => s.elements); + expect(onThree.filter((e) => e.kind === 'list')).toHaveLength(1); + expect(days.notes).toContain('the pros and cons of entries 1 and 4 found no room on their pages and are left out'); + }); +}); + +/** Photos of one shape, ids counted from `from`. */ +const photosOf = (from: number, n: number, width: number, height: number): AutoPhoto[] => + Array.from({ length: n }, (_, i) => ({ photoId: from + i, width, height })); +const tall = (from: number, n: number) => photosOf(from, n, 3000, 4000); +/** Wider than tall, too small to run across a spread. */ +const wide = (from: number, n: number) => photosOf(from, n, 3000, 2000); + +/** One entry's chapter, with the photos of its burst. */ +const chapterOf = (entry: AutoEntry, burst: number[]): DesignChapter => ({ + key: `e${entry.id}`, + label: null, + entries: [entry], + photos: entry.photos, + stations: false, + burst, +}); + +/** What a chapter is planned with: the density, the rich journey's page, no photo signals. */ +const planOf = (density: 'airy' | 'balanced' | 'rich') => ({ + directives: bookDesignDirectivesSchema.parse({ density }), + page: input('rich').page, + infos: new Map(), }); /** A stored book: a page locked by hand, a page of entry 3, and a page made by hand. */ diff --git a/shared/src/book/design/design-framing.ts b/shared/src/book/design/design-framing.ts index bdd420d57..50a2670de 100644 --- a/shared/src/book/design/design-framing.ts +++ b/shared/src/book/design/design-framing.ts @@ -1,3 +1,5 @@ +import { compareCodeUnits } from '../../common/compare'; +import { summaryMetrics } from '../auto/auto-book-spreads'; import type { AutoEntry, AutoPhoto } from '../auto/auto-types'; import { BOOK_COVER_TEMPLATES, applyBookTemplate } from '../book-layouts'; import { formatBookDate } from '../book-text'; @@ -95,7 +97,7 @@ function withCoverTemplate(c: ComposeCtx, cover: BookSpread): BookSpread { /** The dates a journey ran, as two lines (the first day and the last), or none. */ function spanLines(entries: readonly AutoEntry[], locale: string): string[] { const dated = entries.map((e) => e.date).filter((d): d is string => !!d); - const dates = [...dated].sort(); + const dates = [...dated].sort(compareCodeUnits); const first = dates[0]; const last = dates[dates.length - 1]; if (!first || !last) return []; @@ -131,22 +133,10 @@ export function composeTitlePage(c: ComposeCtx, words: CoverWords, entries: read return single(c, 'first', els); } -/** The figures worth printing: the ones asked for, else everything the journey actually has, at most six. */ +/** The figures worth printing: the ones asked for, else the Auto layout's (all the journey has, at most six). */ function metricsOf(c: ComposeCtx, stats: JourneyStats): BookMetric[] { const asked = c.directives.travel?.metrics; - if (asked) return [...asked]; - const all: [BookMetric, number][] = [ - ['distance', stats.distance], - ['days', stats.days], - ['steps', stats.steps], - ['photos', stats.photos], - ['countries', stats.countries.length], - ['furthest', stats.furthest], - ]; - return all - .filter(([, v]) => v > 0) - .map(([k]) => k) - .slice(0, 6); + return asked ? [...asked] : summaryMetrics(stats); } /** A label over a page: small, in the ink, with a rule in the accent above it. */ diff --git a/shared/src/book/design/design-inner.ts b/shared/src/book/design/design-inner.ts index edb3f436f..dffac1259 100644 --- a/shared/src/book/design/design-inner.ts +++ b/shared/src/book/design/design-inner.ts @@ -1,4 +1,5 @@ import type { AutoEntry } from '../auto/auto-types'; +import { bookEntryListItems } from '../book-travel'; import type { BookElement, BookSpread } from '../book.schema'; import type { BookDesignInput } from './design-book'; import type { ComposeCtx } from './design-compose'; @@ -26,6 +27,7 @@ import { composeTrio, innerSpread, panoFits, + tripOf, type Composed, } from './design-spreads'; @@ -131,8 +133,18 @@ function stationsSpreads(c: ComposeCtx, chapter: DesignChapter, input: BookDesig const chapterName = (chapter: DesignChapter) => chapter.label ?? chapter.entries[0]?.title ?? null; -/** One planned spread, composed; the story still to set is carried from the opener to its quiet page. */ -function composePlanned(c: ComposeCtx, plan: PlannedSpread, hand: 'left' | 'right', carry: string): Composed { +/** + * One planned spread, composed; the story still to set is carried from the + * opener to its quiet page, and a quiet page takes the pros and cons of + * `list`, the chapter's entry, when the pages before it had no room for them. + */ +function composePlanned( + c: ComposeCtx, + plan: PlannedSpread, + hand: 'left' | 'right', + carry: string, + list: AutoEntry | null, +): Composed { const entry = plan.entry; const entryId = plan.chapter.entries[0]?.id ?? null; const [first, ...more] = plan.photos; @@ -144,7 +156,7 @@ function composePlanned(c: ComposeCtx, plan: PlannedSpread, hand: 'left' | 'righ if (entry && first && panoFits(c, first)) return composePano(c, entry, first, more, hand); break; case 'quiet': - return composeQuiet(c, entry, carry, first ?? null, hand); + return composeQuiet(c, entry, carry, first ?? null, hand, list); case 'duo': return composeDuo(c, plan.photos, hand, entryId); case 'trio': @@ -161,23 +173,61 @@ function composePlanned(c: ComposeCtx, plan: PlannedSpread, hand: 'left' | 'righ /** A chapter's spreads, composed from its plan, the photo page alternating across the book. */ function chapterSpreads(c: ComposeCtx, chapter: DesignChapter, plans: readonly PlannedSpread[], start: number) { const out: DesignedSpread[] = []; + const entry = chapter.entries[0]; let carry = ''; + // The entry whose pros and cons still look for a page: none once they are set, or when it has none. + let unlisted = entry && bookEntryListItems(entry).length ? entry : null; plans.forEach((plan, i) => { - // A quiet page with no words left to set and no photo would be an empty spread: it is left out. + // A quiet page with no words left to set and no photo would be an empty spread, or one holding only a list: + // it is left out (the planner gives the quiet page of an entry with pros and cons a photo for them). if (plan.beat === 'quiet' && !carry && !plan.photos.length) return; const hand = (start + i + (c.directives.seed ?? 0)) % 2 === 0 ? 'left' : 'right'; - const done = composePlanned(c, plan, hand, carry); + const done = composePlanned(c, plan, hand, carry, unlisted); carry = done.rest; - const spread: BookSpread = innerSpread(c, done.elements, chapter.entries[0]?.id ?? null); + if (done.listed) unlisted = null; + const spread: BookSpread = innerSpread(c, done.elements, entry?.id ?? null); out.push({ spread, beat: plan.beat, chapter: chapterName(chapter), findings: [] }); }); - const entry = chapter.entries[0]; if (carry && entry && (c.directives.text?.story ?? 'full') === 'full') { c.notes.push(`part of the story of entry ${entry.id} found no page within the budget and is left out`); } return out; } +/** "entry 3", or "entries 3, 4 and 6", in a note. */ +function entriesNamed(ids: readonly number[]): string { + if (ids.length < 2) return `entry ${ids.join('')}`; + return `entries ${ids.slice(0, -1).join(', ')} and ${ids.slice(-1).join('')}`; +} + +/** + * What the pages leave out of the entries, said in the notes: the pros and + * cons that found no page (an entry among the stations, one after the first + * of its day's chapter, one whose pages had no room or fell past the budget), + * and with chapter maps asked for, the chapters that have none because their + * entry is on no trip. + */ +function leftUnsaid(c: ComposeCtx, entries: readonly AutoEntry[], body: readonly DesignedSpread[]): string[] { + const out: string[] = []; + const listed = new Set( + body.flatMap((d) => (d.spread.elements.some((e) => e.kind === 'list') ? [d.spread.entryId] : [])), + ); + const unlisted = entries.filter((e) => bookEntryListItems(e).length > 0 && !listed.has(e.id)).map((e) => e.id); + if (unlisted.length) { + const their = unlisted.length === 1 ? 'its' : 'their'; + out.push(`the pros and cons of ${entriesNamed(unlisted)} found no room on ${their} pages and are left out`); + } + const stats = c.travel.stats; + if (!c.directives.travel?.chapterMaps || !stats) return out; + const told = new Set(body.map((d) => d.spread.entryId)); + const mapless = entries.filter((e) => told.has(e.id) && tripOf(stats, e) === null).map((e) => e.id); + if (mapless.length) { + const said = mapless.length === 1 ? 'is on no trip, so its chapter has' : 'are on no trip, so their chapters have'; + out.push(`${entriesNamed(mapless)} ${said} no map`); + } + return out; +} + /** Every inner page of the designed book, in order. */ export function composeDesignedInner( c: ComposeCtx, @@ -224,5 +274,6 @@ export function composeDesignedInner( const head = framing.filter((k) => k !== 'closing').map((k) => framingSpread(c, k, input)); const tail = framing.filter((k) => k === 'closing').map((k) => framingSpread(c, k, input)); const fitted = withinCap(body, cap - head.length - tail.length, leftOut, notes); + notes.push(...leftUnsaid(c, entries, fitted)); return { spreads: [...head, ...fitted, ...tail], leftOut, notes }; } diff --git a/shared/src/book/design/design-pacing.ts b/shared/src/book/design/design-pacing.ts index 88f5ff6f7..69ceb6f77 100644 --- a/shared/src/book/design/design-pacing.ts +++ b/shared/src/book/design/design-pacing.ts @@ -1,16 +1,18 @@ import type { AutoEntry, AutoPhoto } from '../auto/auto-types'; import type { BookPhotoInfo } from '../book-ops-core'; +import { bookEntryListItems } from '../book-travel'; import type { BookPageSetup } from '../book.schema'; import { pickBookPhotos, type PhotoPickCandidate } from '../photo-pick'; import type { BookDesignDirectives } from './design-directives.schema'; -import { hasBurst, heroWorthy, isPanorama, pickEntriesOf, type BookDesignLeftOut } from './design-photos'; +import { burstOf, heroWorthy, isPanorama, pickEntriesOf, type BookDesignLeftOut } from './design-photos'; /** * The story of the book before its pages: which chapters, how many spreads * each gets, and the beat of every spread (opener or hero, a quiet page for a - * long story, two, three or a grid of photos, a panorama), paced so the book - * breathes: never one beat three times running, a loud spread followed by a - * calm one, at most one hero in four spreads. + * long story, two, three or a grid of photos, a burst as one grid, a + * panorama), paced so the book breathes: never one photo beat three times + * running, a loud spread followed by a calm one, at most one hero in four + * spreads. */ export const BOOK_DESIGN_BEATS = [ @@ -43,7 +45,8 @@ export interface DesignChapter { photos: AutoPhoto[]; /** Runs of bare stops, set as a list. */ stations: boolean; - burst: boolean; + /** The photos of its burst (four or more within fifteen minutes), set as one grid; empty when it has none. */ + burst: number[]; } export interface PlannedSpread { @@ -57,7 +60,7 @@ export interface PlannedSpread { const storyChars = (entries: readonly AutoEntry[]) => entries.reduce((n, e) => n + (e.story ?? '').trim().length, 0); function entryChapter(entry: AutoEntry, photos: AutoPhoto[], pool: readonly PhotoPickCandidate[]): DesignChapter { - const burst = hasBurst(photos, pool); + const burst = burstOf(photos, pool); return { key: `e${entry.id}`, label: null, entries: [entry], photos, stations: false, burst }; } @@ -68,7 +71,7 @@ const stationsChapter = (run: AutoEntry[]): DesignChapter => ({ entries: run, photos: [], stations: true, - burst: false, + burst: [], }); /** Entries with substance each open a chapter; runs of bare ones become a list of stations. */ @@ -116,7 +119,7 @@ function byDay( entries: list, photos, stations: !substance, - burst: hasBurst(photos, candidates), + burst: burstOf(photos, candidates), }; }); } @@ -146,7 +149,7 @@ function byPlace( entries: entry ? [entry] : [], photos, stations: false, - burst: hasBurst(photos, candidates), + burst: burstOf(photos, candidates), }; out.push({ day: group.day, chapter }); } @@ -243,7 +246,8 @@ export function fitChapters(chapters: readonly DesignChapter[], total: number): entries: [...a.entries, ...b.entries], photos: [...a.photos, ...b.photos], stations: a.stations && b.stations, - burst: a.burst || b.burst, + // The longer burst of the two: together they would make a grid past six. + burst: a.burst.length >= b.burst.length ? a.burst : b.burst, }; list.splice(best, 2, merged); } @@ -292,11 +296,51 @@ function opening(chapter: DesignChapter, photos: AutoPhoto[], rhythm: Rhythm, in return { beat: first ? 'opener' : 'story', chapter, entry, photos: first ? [first] : [] }; } +/** + * The chapter's burst as one grid, taken out of `photos`: up to six of its + * photos, five after a loud spread (six make a loud grid), at any density; + * null when fewer than four of them are left once the chapter has opened. + */ +function burstGrid(chapter: DesignChapter, photos: AutoPhoto[], rhythm: Rhythm): PlannedSpread | null { + const most = loudAllowed(rhythm, 'grid') ? 6 : 5; + const take = photos.filter((p) => chapter.burst.includes(p.photoId)).slice(0, most); + if (take.length < 4) return null; + for (const p of take) photos.splice(photos.indexOf(p), 1); + return { beat: 'grid', chapter, entry: null, photos: take }; +} + +/** + * The photos a quiet page takes, out of `photos`, with `left` spreads still + * to plan: none while more follow, so the words stand alone; one beside them + * on the chapter's last spread, or when the entry has pros and cons, which + * then still have a page should the opener hold the whole story. The photo is + * the first outside the burst, which stays whole for its grid. + */ +function quietPhotos(chapter: DesignChapter, photos: AutoPhoto[], left: number): AutoPhoto[] { + const entry = chapter.entries[0]; + if (left > 1 && !(entry && bookEntryListItems(entry).length > 0)) return []; + const at = photos.findIndex((p) => !chapter.burst.includes(p.photoId)); + return photos.splice(Math.max(0, at), 1); +} + +/** + * How many of `count` photos the next photo spread takes: five for a grid of + * six after a loud spread, and fewer until its beat is not the third alike + * running (a grid of five is still a grid). + */ +function photoTake(rhythm: Rhythm, count: number): number { + let take = count >= 6 && !loudAllowed(rhythm, 'grid') ? 5 : count; + const [before, last] = rhythm.beats.slice(-2); + const twice = before === last ? last : null; + while (take > 1 && photoBeat(take) === twice) take--; + return take; +} + /** * The spreads of one chapter for its share of the budget: the opening, a - * quiet page when the words run long, then the photos spread evenly over - * what is left, at most the density's maximum a spread; photos that find no - * spread are left out and said. + * quiet page when the words run long, its burst as one grid, then the photos + * spread evenly over what is left, at most the density's maximum a spread; + * photos that find no spread are left out and said. */ export function planChapter( chapter: DesignChapter, @@ -318,15 +362,17 @@ export function planChapter( const wantsWords = long && (input.directives.text?.story ?? 'full') === 'full'; let left = spreads - 1; if (wantsWords && left > 0 && storyChars(chapter.entries.slice(0, 1)) > 0) { - push({ beat: 'quiet', chapter, entry: chapter.entries[0] ?? null, photos: photos.splice(0, left > 1 ? 0 : 1) }); + push({ beat: 'quiet', chapter, entry: chapter.entries[0] ?? null, photos: quietPhotos(chapter, photos, left) }); + left--; + } + const grid = left > 0 ? burstGrid(chapter, photos, rhythm) : null; + if (grid) { + push(grid); left--; } const max = DENSITY[input.directives.density].max; while (left > 0 && photos.length) { - const count = Math.min(max, Math.ceil(photos.length / left)); - let take = count >= 6 && !loudAllowed(rhythm, 'grid') ? 5 : count; - const prev = rhythm.beats.slice(-2); - if (take > 1 && prev.length === 2 && prev.every((b) => b === photoBeat(take))) take = Math.max(1, take - 1); + const take = photoTake(rhythm, Math.min(max, Math.ceil(photos.length / left))); push({ beat: photoBeat(take), chapter, entry: null, photos: photos.splice(0, take) }); left--; } diff --git a/shared/src/book/design/design-photos.ts b/shared/src/book/design/design-photos.ts index 1df975024..da01e2cb7 100644 --- a/shared/src/book/design/design-photos.ts +++ b/shared/src/book/design/design-photos.ts @@ -164,20 +164,29 @@ export function heroWorthy(photo: AutoPhoto, info: BookPhotoInfo | undefined, pa return sx === undefined || sx < 0.4 || sx > 0.6; } +/** Photos taken within this long of the first of them make one burst. */ +const BURST_MS = 15 * 60 * 1000; + /** - * Whether a chapter has a burst: four or more photos taken within fifteen - * minutes of each other, which reads best as a grid. + * A chapter's burst: the longest run of four or more of its photos taken + * within fifteen minutes of the run's first, which reads best as one grid; + * empty when it has none. A photo without a capture time is in no burst. + * Only the time is read: a near-duplicate set kept with `pick: 'all'` is a + * burst when it was taken within the window, as such sets usually are. */ -export function hasBurst(photos: readonly AutoPhoto[], candidates: readonly PhotoPickCandidate[]): boolean { - const times = photos - .map((p) => candidates.find((c) => c.photoId === p.photoId)?.takenAt ?? null) - .map((t) => (t ? Date.parse(t) : Number.NaN)) - .filter((t) => Number.isFinite(t)) - .sort((a, b) => a - b); - for (let i = 0; i + 3 < times.length; i++) { - const a = times[i] as number; - const b = times[i + 3] as number; - if (b - a <= 15 * 60 * 1000) return true; - } - return false; +export function burstOf(photos: readonly AutoPhoto[], candidates: readonly PhotoPickCandidate[]): number[] { + const takenAt = new Map(candidates.map((c): [number, string | null] => [c.photoId, c.takenAt])); + // The sort is stable: photos taken at the same moment keep the chapter's order. + const timed = photos + .map((p) => ({ photoId: p.photoId, at: Date.parse(takenAt.get(p.photoId) ?? '') })) + .filter((p) => Number.isFinite(p.at)) + .sort((a, b) => a.at - b.at); + const times = timed.map((p) => p.at); + let best: number[] = []; + let end = 0; + timed.forEach((first, i) => { + while (end < times.length && (times[end] as number) - first.at <= BURST_MS) end++; + if (end - i >= 4 && end - i > best.length) best = timed.slice(i, end).map((p) => p.photoId); + }); + return best; } diff --git a/shared/src/book/design/design-spreads.ts b/shared/src/book/design/design-spreads.ts index 7e19088a5..e2dae30f5 100644 --- a/shared/src/book/design/design-spreads.ts +++ b/shared/src/book/design/design-spreads.ts @@ -1,5 +1,7 @@ import type { AutoEntry, AutoPhoto } from '../auto/auto-types'; -import type { BookElement, BookFrame, BookSpread } from '../book.schema'; +import { bookEntryListItems, bookTravelList, bookTravelMap, bookTravelSize } from '../book-travel'; +import type { BookElement, BookFrame, BookSpread, BookTextElement } from '../book.schema'; +import type { JourneyStats } from '../journey-stats.schema'; import { bleedPage, bleedSpread, @@ -26,13 +28,17 @@ import { paperBackground } from './design-style'; * quiet page for a long story, two, three and four to six photos, and a * panorama across the fold. Each takes the photos the plan gave it and the * page (left or right) its photos lead on, and hands back the elements and - * whatever of the story it could not set. + * whatever of the story it could not set. An entry's pros and cons go under + * its words where they leave room: on the opener, the panorama or a quiet + * page, which also sets them beside its photo when it has no words. */ export interface Composed { elements: BookElement[]; /** The part of the story still to be set, for the next quiet page. */ rest: string; + /** Whether the entry's pros and cons were set on this page. */ + listed?: boolean; } type Hand = 'left' | 'right'; @@ -89,6 +95,35 @@ function cellsFor(photos: readonly AutoPhoto[], box: BookFrame, g: number, room: return [frameOf(box.x, box.y, box.w, top), frameOf(box.x, y2, w, low), frameOf(box.x + w + g, y2, w, low)]; } +/** Where a column has room left: from under the words set in it (a gutter apart) to its foot. */ +interface Foot { + x: number; + w: number; + top: number; + bottom: number; +} + +function footUnder(c: ComposeCtx, words: BookTextElement | null, column: BookFrame): Foot { + const top = words ? words.frame.y + words.frame.h + c.grid.gutter : column.y; + return { x: column.x, w: column.w, top, bottom: column.y + column.h }; +} + +/** + * An entry's pros and cons as a list in the room under its words, headed in + * the book's language and set in its look; null when the entry has none or + * the room is too small. The words are never moved for it. + */ +function listUnder(c: ComposeCtx, entry: AutoEntry, foot: Foot): BookElement | null { + const items = bookEntryListItems(entry); + if (!items.length) return null; + const pros = items.filter((i) => i.tone === 'pro').length; + const { h } = bookTravelSize('list', c.page, { rows: Math.max(pros, items.length - pros) }); + if (foot.top + h > foot.bottom) return null; + const labels = { pros: c.travel.labels.pros, cons: c.travel.labels.cons }; + const list = bookTravelList(labels, { id: c.newId('li'), frame: frameOf(foot.x, foot.top, foot.w, h), items }); + return { ...list, font: c.look.heading, color: c.look.ink, accent: c.look.accent }; +} + /** * The chapter opens: the title block and the story on one page, a photo to * the bleed on the other. Without a photo the story runs on across both pages. @@ -108,57 +143,46 @@ export function composeOpener(c: ComposeCtx, entry: AutoEntry, photo: AutoPhoto const first = storyEl(c, entry, (entry.story ?? '').trim(), storyBox, true); if (first.el) els.push(first.el); let rest = first.el ? first.rest : (entry.story ?? '').trim(); + let foot = footUnder(c, first.el, storyBox); if (photo) { els.unshift(photoEl(c, photo, bleedPage(c, hand), { bleed: { side: hand } })); } else if (rest) { - const next = storyEl(c, entry, rest, liveBox(c, 'right'), false); - if (next.el) els.push(next.el); + const right = liveBox(c, 'right'); + const next = storyEl(c, entry, rest, right, false); + if (next.el) { + els.push(next.el); + foot = footUnder(c, next.el, right); + } rest = next.el ? next.rest : rest; } - return { elements: els, rest }; + const list = listUnder(c, entry, foot); + if (list) els.push(list); + return { elements: els, rest, listed: list !== null }; } -/** A small map of the chapter's stop at the foot of the title page, when the directives ask for one. */ +/** The trip a chapter's stop is on: the stop read from its entry, else the one at its place; null for none. */ +export function tripOf(stats: JourneyStats, entry: AutoEntry): number | null { + const own = stats.points.find((p) => p.entryId === entry.id); + const stop = own ?? stats.points.find((p) => p.lat === entry.lat && p.lng === entry.lng); + return stop?.tripId ?? null; +} + +/** + * A small map of the chapter's trip at the foot of the title page, when the + * directives ask for one. A stored map is of the journey or of one trip, so a + * map of the trip is current when placed and stays the trip's when refreshed; + * a stop on no trip gets none (the journey's map is the summary's). + */ function chapterMap(c: ComposeCtx, entry: AutoEntry, box: BookFrame): BookElement | null { const stats = c.travel.stats; - if (!c.directives.travel?.chapterMaps || !stats || entry.lat == null || entry.lng == null) return null; + if (!c.directives.travel?.chapterMaps || !stats) return null; + const tripId = tripOf(stats, entry); + if (tripId === null) return null; const h = box.h * 0.3; - const point = { lat: entry.lat, lng: entry.lng, label: (entry.location ?? '').slice(0, 120), photoId: null }; - return { - id: c.newId('mp'), - kind: 'map', - frame: frameOf(box.x, box.y + box.h - h, box.w, h), - rotation: 0, - opacity: 1, - locked: false, - font: c.look.heading, - color: c.look.ink, - accent: c.look.accent, - textScale: 1, - weight: 700, - stale: false, - source: 'vector', - tileUrl: '', - attribution: '', - zoom: null, - style: c.look.mapStyle, - showLand: true, - showRoute: false, - showPins: true, - showLabels: false, - routeStyle: 'plain', - routeArc: 'straight', - routeDash: 'solid', - pinStyle: 'dot', - countries: entry.country ? [entry.country] : [], - points: [point], - path: [], - roads: [], - fitPadding: 0.5, - fitToCountries: true, - clip: 'rect', - tripId: null, - }; + const frame = frameOf(box.x, box.y + box.h - h, box.w, h); + const map = bookTravelMap(c.travel, stats, { id: c.newId('mp'), frame, scope: { tripId } }); + const look = { style: c.look.mapStyle, font: c.look.heading, color: c.look.ink, accent: c.look.accent }; + return { ...map, ...look, pinStyle: 'dot' }; } /** @@ -178,13 +202,19 @@ export function composeHero(c: ComposeCtx, entry: AutoEntry, photo: AutoPhoto, h return { elements: els, rest: (entry.story ?? '').trim() }; } -/** A long story's next page: the words in a narrow column, a photo on the other page when there is one. */ +/** + * A long story's next page: the words in a narrow column, a photo on the + * other page when there is one, and the pros and cons of `list` (the + * chapter's entry, when they have not found a page yet) under the words, or + * at the head of the column on a photo's page that sets none. + */ export function composeQuiet( c: ComposeCtx, entry: AutoEntry | null, words: string, photo: AutoPhoto | null, hand: Hand, + list: AutoEntry | null = null, ): Composed { const textSide = photo ? other(hand) : 'left'; const box = liveBox(c, textSide); @@ -192,13 +222,17 @@ export function composeQuiet( const column = frameOf(box.x + inset, box.y, box.w - inset * 2, box.h); const els: BookElement[] = []; let rest = words; + let set: BookTextElement | null = null; if (entry && words) { - const set = storyEl(c, entry, words, column, false); - if (set.el) els.push(set.el); - rest = set.el ? set.rest : words; + const story = storyEl(c, entry, words, column, false); + set = story.el; + if (set) els.push(set); + rest = set ? story.rest : words; } + const pros = list ? listUnder(c, list, footUnder(c, set, column)) : null; + if (pros) els.push(pros); if (photo) els.push(...pagePhotos(c, [photo], hand, entry?.id ?? null)); - return { elements: els, rest }; + return { elements: els, rest, listed: pros !== null }; } /** Two photos: one to the bleed of its page, the other framed on the facing page. */ @@ -251,6 +285,8 @@ export function composePano( const storyBox = frameOf(box.x, head.bottom + 2, box.w, box.y + box.h - head.bottom - 2); const set = storyEl(c, entry, (entry.story ?? '').trim(), storyBox, true); if (set.el) els.push(set.el); + const list = listUnder(c, entry, footUnder(c, set.el, storyBox)); + if (list) els.push(list); const row = others.slice(0, 2); const cell = frameOf(pageX(c, hand) + m, top, PW - m * 2, H - m - top); const w = row.length > 1 ? (cell.w - g) / 2 : cell.w; @@ -258,7 +294,7 @@ export function composePano( const frame = frameOf(cell.x + i * (w + g), cell.y, w, cell.h); els.push(photoEl(c, p, frame, { turn: turnOf(c, i) })); }); - return { elements: els, rest: set.el ? set.rest : (entry.story ?? '').trim() }; + return { elements: els, rest: set.el ? set.rest : (entry.story ?? '').trim(), listed: list !== null }; } /** Whether a panorama can run across the top of the spread at print resolution. */