mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
fix(book): the designed book paces its bursts, lists and maps the way the design asks
A burst of four or more photos within a quarter of an hour becomes one grid, no beat runs three times in a row, and an entry's pros and cons go into the room under its words on the opener, the panorama or the quiet page, with a note when they find none. A chapter map is the map of the chapter's trip, and entries on no trip are named in the notes. The sorts compare by code unit, the summary metrics and the page lookups exist once, and a flag or country mark asked for by its own code is labelled with that country.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)] : [];
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from './book-ops-core';
|
||||
import {
|
||||
bookBadgeValue,
|
||||
bookCountryName,
|
||||
bookEntryListItems,
|
||||
bookTravelBadge,
|
||||
bookTravelCountries,
|
||||
@@ -342,13 +343,31 @@ function newBadge(spec: Extract<TravelSpec, { kind: 'badge' }>, 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<string> = 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<TravelSpec, { kind: 'badge' }>,
|
||||
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<TravelSpec, { kind: 'list' }>, id: string, at: At, env: ElementOpEnv): BookElement {
|
||||
const travel = travelFor(spec.entryId, env);
|
||||
let items = spec.items ?? [];
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
galleryPhoto,
|
||||
invalidOp,
|
||||
journeyEntry,
|
||||
pageElement,
|
||||
roomFor,
|
||||
round2,
|
||||
unlockedElement,
|
||||
type BookEditResult,
|
||||
type BookOpsContext,
|
||||
type BookPhotoInfo,
|
||||
@@ -125,24 +128,10 @@ type Op<K extends BookPageOp['op']> = Extract<BookPageOp, { op: K }>;
|
||||
|
||||
// ── 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<E extends BookElement>(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 = <E extends BookElement>(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);
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<Record<number, Partial<AutoEntry>>> = {
|
||||
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<Record<number, Partial<AutoEntry>>> = {
|
||||
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<number, BookPhotoInfo>(),
|
||||
});
|
||||
|
||||
/** A stored book: a page locked by hand, a page of entry 3, and a page made by hand. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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--;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user