mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-25 00:13:00 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09f286fbfe | |||
| 4cc62f4c4e | |||
| cb34c46226 | |||
| e777118272 | |||
| 3f2da7a867 | |||
| 9c3649f011 | |||
| 73fdbdc0c3 | |||
| 0879b5aecc | |||
| 9729b0b575 | |||
| 35acdb8ba1 | |||
| a19fdf6161 | |||
| 813dd7a7eb | |||
| d51b9de2b0 | |||
| 6254510e0f |
@@ -62,7 +62,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<br />
|
||||
|
||||
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Vacay and Atlas ship on; Journey, Collections, MCP, AI Parsing and AirTrail ship off and are marked below.
|
||||
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Tours, Vacay and Atlas ship on; Journey, Collections, MCP, the AI assistant, AI Parsing, Road trip, Dawarich and AirTrail ship off and are marked below.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -70,76 +70,110 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
|
||||
#### 🧭 Planning
|
||||
|
||||
- **Day plans**: drag places between days and reorder inside a day, with undo. Notes and bookings drag the same way, and a map marker drops straight onto a day
|
||||
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token), with clustering, photo markers and route lines. 3D buildings and terrain are Mapbox only
|
||||
- **Place search**: Google Places when a key is set (photos, ratings, opening hours), otherwise OpenStreetMap with no key
|
||||
- **Place enrichment**: descriptions, facts, hours and photo candidates from OpenStreetMap, Wikipedia, Wikidata and Wikimedia Commons
|
||||
- **POI explore**: pull OpenStreetMap POIs by category for the current viewport over Overpass
|
||||
- **Import**: shared Google Maps and Naver Maps lists, plus GPX, KML and KMZ files
|
||||
- **Export**: GPX of a trip's places and tracks, and an ICS feed per trip or across all of them
|
||||
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotel anchors stay put), driving, walking or cycling profiles over OSRM, then open it in Google Maps or CoMaps
|
||||
- **Public transport**: door-to-door itineraries over Transitous
|
||||
- **Weather**: 16-day forecast from Open-Meteo, no key. Dates outside that window read the archive for the same date instead
|
||||
- **Day notes**: markdown body with an icon and a colour, reordered by drag and drop or moved to another day
|
||||
- **Trip dates**: move a trip and the days re-date themselves, either dragging the bookings along or re-anchoring them. Trips also copy and archive
|
||||
- **Day plans**: drag places, notes and bookings between days with undo, or drop a map marker straight onto a day
|
||||
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token) with clustering, photo markers, route lines and a whole-trip route overview
|
||||
- **Places**: search the TREK Places index and OpenStreetMap with no key, or Google Places and Amap with one. Descriptions, hours and photos from Wikipedia, Wikidata and Wikimedia Commons, plus a rating and a custom image per place
|
||||
- **Explore**: nearby places by category for the map view, from the TREK Places index with Overpass as fallback
|
||||
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotels stay put), a travel mode per leg over OSRM or Valhalla, then open the day in Google Maps or CoMaps and a place in seven navigation apps
|
||||
- **Public transport**: door-to-door itineraries over Transitous, or Google where Transitous has no data
|
||||
- **Import and export**: Google Maps and Naver Maps lists, Google Maps directions links, GPX, KML and KMZ in; GPX and an ICS feed per trip or across all trips out
|
||||
- **Weather**: 16-day forecast from Open-Meteo, no key, and the archive for dates beyond it
|
||||
- **Trip dates**: move a trip and its days re-date themselves, with the bookings along or re-anchored. Trips also copy and archive
|
||||
- **Road trip** (off by default): drives with stops, day windows and arrival times that recompute, route alternatives, hazards and EV charging with range
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧳 Bookings and money
|
||||
#### 🥾 Tours
|
||||
|
||||
- **Reservations**: 16 booking types with status, confirmation code, travellers and attached files
|
||||
- **Flights and trains**: several legs with stopovers, per-leg times and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
|
||||
- **Accommodation**: a stay spans a range of days with a check-in window, and shows on every night it covers
|
||||
- **Booking import**: EML, PDF, PKPass, HTML and TXT confirmations through [KItinerary](https://invent.kde.org/pim/kitinerary). Needs the `kitinerary-extractor` binary, which ships in the Docker image
|
||||
- **AirTrail** (off by default): link a self-hosted AirTrail instance to import flights as reservations and keep them in sync
|
||||
- **Costs**: split expenses in integer cents with equal or custom shares, several payers per expense, settle-up suggestions, a settlement log, and CSV export
|
||||
- **Currencies**: a currency per expense with the rate frozen at entry. Rates come from Frankfurter, no key
|
||||
- **Packing lists**: categories, admin-managed templates, assignees, three visibility tiers, and a packed-of-total line. Bags with weight roll-up are a separate admin switch, off by default
|
||||
- **To-dos**: assignee, due date, priority, and a reminder before one falls due
|
||||
- **Files**: attach to a trip, place, day entry or reservation. 50 MB each, 500 MB for video, with trash and restore
|
||||
- **PDF export**: cover page, place photos, day notes, bookings and costs, with an optional page break per day
|
||||
- **Seven tour types**: hike, city walk, run, bike (touring, road, gravel, MTB), paddle (kayak, canoe, SUP), horse ride, and ski and winter (alpine, cross-country, touring, snowshoe, winter hike)
|
||||
- **Routing**: legs routed over Valhalla with shaping points, hand-drawn legs, route variants, round hikes of a wished length and trip places as stops, all with undo
|
||||
- **Hikes**: routed within a maximum SAC grade (T1 to T6), the trail grades along the way, and via ferratas on the map
|
||||
- **Terrain**: elevation profile, ascent and descent, and a 3D terrain map with grade colouring and a flyover, no token needed. Topo, hiking, cycling and satellite bases with sea marks and ski pistes as overlays
|
||||
- **Multi-day tours**: one route cut into stages and spread across the trip's days
|
||||
- **Along the way**: viewpoints, huts, water, food and more within a corridor of the route, inserted as a stop with the detour it adds
|
||||
- **Ski, paddle and city walks**: a piste router by difficulty and lift type; a water router with portages, locks and a wind forecast; city walks with stays, opening hours and transit between stops
|
||||
- **Plan against reality**: link a recorded GPX to a planned day and compare distance, ascent and time. Import trails from a wanderer instance, save tours to a collection and take them into another trip
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧳 Bookings and money
|
||||
|
||||
- **Reservations**: 18 booking types with status, confirmation code, travellers and attached files
|
||||
- **Flights and trains**: several legs with stopovers and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
|
||||
- **Accommodation**: a stay spans a range of nights with a check-in window
|
||||
- **Booking import**: EML, PDF, PKPass, HTML, TXT and photos of confirmations, through [KItinerary](https://invent.kde.org/pim/kitinerary) or an AI model, with a review step before anything is saved. Prices become linked costs
|
||||
- **AirTrail** (off by default): import flights from a self-hosted AirTrail instance and keep them in sync
|
||||
- **Costs**: split in integer cents with equal or custom shares, several payers, settle-up suggestions, a per-person summary, a settlement log and CSV export. A receipt photo can fill in an expense
|
||||
- **Currencies**: one per expense with the rate frozen at entry, from Frankfurter with no key
|
||||
- **Packing lists and to-dos**: categories, templates, assignees and visibility tiers; bags with weights as an admin switch; to-dos with due dates, priorities and reminders
|
||||
- **Files**: attach to a trip, place, day or booking, 50 MB each and 500 MB for video, with trash and restore
|
||||
- **PDF export**: cover, place photos, day notes, bookings and costs, with an optional page break per day
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 👥 Collaboration
|
||||
|
||||
- **Real-time sync** (WebSocket): edits land live for everyone who has that trip open
|
||||
- **Members**: add by email or username, hand ownership to someone else, or add guests who have no login at all
|
||||
- **Permissions**: an admin maps each of 16 trip actions to admin, trip owner, trip member or everybody
|
||||
- **Invite links**: one reusable link per trip with an optional expiry. Admins can also issue signup invites with a use limit that drop the new account straight into a trip
|
||||
- **Public share**: a read-only trip page anyone can open without an account
|
||||
- **Collab**: group chat with replies, reactions and link previews, shared notes with attachments, polls, and a What's Next list of upcoming activities. Each of the four switches on its own
|
||||
- **Real-time sync** (WebSocket): edits land live for everyone who has the trip open
|
||||
- **Members**: add by email or username, hand over ownership, or add guests who have no login at all
|
||||
- **Permissions**: an admin maps each of 17 trip actions to admin, trip owner, trip member or everybody
|
||||
- **Invites**: one reusable link per trip with an optional expiry, and admin signup invites that drop a new account straight into a trip
|
||||
- **Public share**: a read-only trip page without an account, with switches for map, bookings, packing, costs and chat
|
||||
- **Collab**: group chat with replies, reactions and link previews, shared notes, links, polls and a What's Next list, each one its own switch
|
||||
- **Notifications**: an inbox grouped by day with answers right from the notification, and a per-user matrix of events against in-app, email, webhook, ntfy, Web Push and plugin channels
|
||||
|
||||
#### 📔 Journal, Atlas and Vacay
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads or a linked [Immich](https://immich.app) or Synology Photos library, map views, co-authors, and a public share link
|
||||
- **Atlas**: mark countries and sub-national regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, plus a bucket list, travel stats and a consecutive-years-travelled count
|
||||
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holiday overlays for 16 European countries, carry-over between years, and joint or read-only sharing of a plan
|
||||
- **Collections** (off by default): a place library outside any trip, with labels, ratings, a custom image per place, copy-into-a-trip, and sharing by invite
|
||||
#### 📔 Journey and TREK Studio
|
||||
|
||||
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads, [Immich](https://immich.app) or Synology Photos, map views, co-authors, drafts and a public share link
|
||||
- **Linked journal**: a trip can show one journal as its own, with the members picked on the trip as viewers or editors
|
||||
- **Recorded days** (with Dawarich): keep a day's recorded route and turn its stays into entries
|
||||
- **TREK Studio**: turn a journal into a printable photo book. 34 print-licensed typefaces, 17 themes, 108 designed pages in twelve styles, 160 recolourable stickers, text effects, layers and a Travel tab with route maps, country stamps and trip figures
|
||||
- **Auto layout**: lays the whole book on the designed pages in the style of its theme, every photo in a frame, or rebuilds one spread
|
||||
- **Print and share**: PDF export as pages or spreads with crop marks, several people editing at once, and design files to move a spread into another book
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🗺️ Atlas, Vacay and Collections
|
||||
|
||||
- **Atlas**: countries and regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, a bucket list, travel stats, and a tour activities tab with planned against recorded totals
|
||||
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holidays for 16 European countries, company holidays, carry-over between years, and joint or read-only sharing
|
||||
- **Collections** (off by default): a place library outside any trip with lists, labels, ratings, a status per place, cover images, GPX and file export, copy into a trip, and sharing by invite
|
||||
- **Dawarich** (off by default): link a Dawarich instance for recorded tracks, suggested stays and countries for the Atlas
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🤖 AI assistant and MCP
|
||||
|
||||
- **AI assistant** (off by default): a chat that plans with you in a trip, across all your trips, in a journal and in Studio, and works with tours, costs, bookings, packing, Vacay and the Atlas
|
||||
- **Your own model**: an API key for Anthropic, OpenAI, Gemini or OpenRouter, a ChatGPT plan, or any OpenAI-compatible endpoint, set for the instance or per user
|
||||
- **You stay in charge**: reads run at once, every change waits as a card to apply or discard and can be undone for 15 minutes. Nothing leaves before you agree to the provider, and a file's text only with your consent
|
||||
- **Attachments**: drop a booking confirmation, a GPX file or a photo into the chat, and the assistant offers what to do with it
|
||||
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration, 410 tools, 30 resources and 4 prompts, 35 scopes in 17 groups ticked on the consent screen, and every call in the audit log
|
||||
- **Addon-aware**: twelve addon gates decide which tools a session sees, and flipping one re-registers live sessions
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧩 Plugins
|
||||
|
||||
- **Third-party plugins**: install from the TREK registry or sideload a zip, switch on per instance, with their own pages under `/plugins/<id>`
|
||||
- **Sandboxed**: one child process per plugin, 63 grantable permissions, an admin-edited outbound host allowlist, memory and RPC caps, and daily caps on AI and notification calls
|
||||
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key. A sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
|
||||
- **Extension points**: map markers and layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, calendar sources, route profiles and notification channels. Plugin pages run in an opaque-origin iframe
|
||||
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) on npm, with a manifest validator, a mock host, and a dev-link mode that runs a local build against real data
|
||||
|
||||
#### 🤖 AI and MCP
|
||||
|
||||
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration. 199 tools, 30 resources, 4 prompts
|
||||
- **Scopes**: 29 scopes in 14 groups, each one tickable on the consent screen. Tokens are bound to the `/mcp` resource, and every tool call lands in the audit log
|
||||
- **Reach**: create trips and days, edit places, packing lists, to-dos, costs, reservations, collections and journeys, mark countries visited, all inside the scopes the token holds
|
||||
- **Prompts**: `trip-summary`, plus `packing-list` and `budget-overview` when those addons are on
|
||||
- **Addon-aware**: seven addon gates decide which tools and resources a session sees. Flipping one drops live sessions so the surface re-registers
|
||||
- **Booking extraction** (off by default): read a confirmation with a local Ollama server, any OpenAI-compatible endpoint, or Anthropic, configured instance-wide or per user
|
||||
- **Third-party plugins**: install from the TREK registry or sideload a zip, with their own pages under `/plugins/<id>`
|
||||
- **Sandboxed**: one child process per plugin, 66 grantable permissions, an admin-edited outbound host allowlist, memory and call caps
|
||||
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key, a sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
|
||||
- **Extension points**: map layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, search and photo providers, route profiles, notification channels and MCP tools
|
||||
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) with a CLI to create, validate, run against real data, sign and publish
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -148,30 +182,26 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
|
||||
#### 📱 Mobile and offline
|
||||
|
||||
- **Installable**: iOS and Android straight from the browser, no App Store. Runs standalone without browser chrome, with the status bar tinted per theme
|
||||
- **Phone layouts**: a separate shell below 768px with its own tokens, a bottom bar and safe-area insets. Which items sit in that bar is up to the user
|
||||
- **Offline reads**: the app shell and every route chunk are precached, and trips, places and file blobs live in a per-user IndexedDB store, so a trip opens with no network
|
||||
- **Offline writes**: mutations queue and replay with an `X-Idempotency-Key`, so a reconnect cannot double-apply. A stale edit is parked for a keep-mine or keep-theirs choice
|
||||
- **Offline maps**: pre-download a trip's raster tiles, and clear them again
|
||||
- **API responses are never cached** by the service worker. They vary per session, so they always come from the network
|
||||
- **Installable**: iOS and Android straight from the browser, no App Store, standalone with a themed status bar
|
||||
- **Phone layouts**: their own screens for every part of the app, with a bottom bar and dashboard widgets the user orders
|
||||
- **Offline reads**: the app shell is precached and trips, places, tours, files and nearby places live in a per-user IndexedDB store, so a trip opens with no network
|
||||
- **Offline writes**: changes queue and replay with an `X-Idempotency-Key`, and a stale edit waits for a keep-mine or keep-theirs choice
|
||||
- **Offline maps**: raster and vector tiles of a trip downloaded ahead, and a switch that takes the whole app offline on purpose
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### ⚙️ Admin, accounts and security
|
||||
|
||||
- **Login methods**: password login, password registration, OIDC login, OIDC registration and passkey login each switch on and off on their own. `OIDC_ONLY` leaves SSO as the only way in
|
||||
- **SSO (OIDC)**: one provider by discovery, with PKCE and `id_token` verification (Authentik, Keycloak, Google and the like)
|
||||
- **2FA**: TOTP with ten single-use backup codes, and an admin can require it instance-wide
|
||||
- **Passkeys**: WebAuthn login by fingerprint, face, PIN or security key, off until an admin enables it. A passkey also satisfies the 2FA requirement
|
||||
- **Hardening**: per-IP limits on login, password reset and 2FA attempts, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
|
||||
- **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, storage, audit log, and GitHub releases
|
||||
- **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets
|
||||
- **Storage**: pluggable storage backends per content category — keep everything on local disk, or add S3-compatible backends and replicate any category to them, configured entirely from the admin panel
|
||||
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook, ntfy and Web Push (on every device you turn it on for, even with TREK closed), plus any channel a plugin registers
|
||||
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, compact density, reduce motion, and text size per tier
|
||||
- **Sign-in**: password, OIDC and passkeys each switch on and off on their own, `OIDC_ONLY` leaves SSO as the only way in, and an OIDC claim can grant admin
|
||||
- **SSO and 2FA**: one OIDC provider by discovery with PKCE (Authentik, Keycloak, Google and the like), TOTP with backup codes that an admin can require, and passkeys that count as the second factor
|
||||
- **Sessions**: every sign-in is a session you can see and end, one by one or all others at once
|
||||
- **Hardening**: rate limits on sign-in, reset and 2FA, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
|
||||
- **Admin panel**: users and the permission matrix, defaults for new users, categories and templates, addons, plugins, storage, notifications, MCP tokens, backups, audit log and releases
|
||||
- **Backups and storage**: scheduled backups with retention that restore their own secrets, and local or S3-compatible storage per content category with replicas
|
||||
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, density, reduced motion and text size
|
||||
- **27 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca, az, et, sk, th
|
||||
- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
|
||||
- **Help center**: a ? in the top bar with 381 step-by-step guides with screenshots, searched together with the wiki that ships in the image
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"trek/no-swallowed-catch": 130,
|
||||
"max-params": 32,
|
||||
"trek/store-without-selector": 67,
|
||||
"react-hooks/refs": 556,
|
||||
"react-hooks/refs": 555,
|
||||
"react-hooks/set-state-in-effect": 194,
|
||||
"@typescript-eslint/no-floating-promises": 165,
|
||||
"react-hooks/exhaustive-deps": 116,
|
||||
|
||||
@@ -1,100 +1,56 @@
|
||||
import type {
|
||||
BookBadgeStyle,
|
||||
BookBadgeVariant,
|
||||
BookColorToken,
|
||||
BookCountryLook,
|
||||
BookElement,
|
||||
BookMapElement,
|
||||
BookMapScope,
|
||||
BookMetric,
|
||||
BookPageSetup,
|
||||
BookStatsLook,
|
||||
BookTheme,
|
||||
BookTravelMapCard,
|
||||
BookTravelMarkVariant,
|
||||
JourneyStats,
|
||||
} from '@trek/shared';
|
||||
import {
|
||||
bookBadgeValue,
|
||||
bookCountryName,
|
||||
bookTravelBadge,
|
||||
bookTravelCountries,
|
||||
bookTravelMap,
|
||||
bookTravelSize,
|
||||
bookTravelStats,
|
||||
themedElement,
|
||||
BOOK_TRAVEL_COUNTRY_LOOKS,
|
||||
BOOK_TRAVEL_FIGURE_SHELVES,
|
||||
BOOK_TRAVEL_MARK_STYLES,
|
||||
BOOK_TRAVEL_MARK_VARIANTS,
|
||||
BOOK_TRAVEL_SUMMARY_LOOKS,
|
||||
bookTravelLooks,
|
||||
bookElementId as uid,
|
||||
} from '@trek/shared';
|
||||
import { studioTravelContext } from '../studioTravelContext';
|
||||
|
||||
/**
|
||||
* What the Travel panel offers, built: every card is the element its click
|
||||
* places, made by the book engine's own factories (book-travel.ts) so a card
|
||||
* and what the assistant places are one thing, then set in one of the newer
|
||||
* looks and dressed in the book's theme.
|
||||
* places, made by the book's own travel looks (book-travel-looks.ts in
|
||||
* shared), the same factory the auto layout builds its framing pages with,
|
||||
* so a card and a designed page are one thing.
|
||||
*
|
||||
* Pure, so the panel stays a list of shelves: it asks for an element and
|
||||
* renders it through the page's own renderer, and the preview and the result
|
||||
* are the same object.
|
||||
* are the same object. What is the panel's own here is where a new element
|
||||
* lands: centred on the spread on screen.
|
||||
*/
|
||||
|
||||
/** The figures a summary offers: how far, how long, where, and how much was kept. */
|
||||
const SUMMARY_METRICS: BookMetric[] = ['distance', 'days', 'countries', 'photos'];
|
||||
|
||||
/** The looks of a summary, in the order the panel offers them. */
|
||||
export const SUMMARY_LOOKS: BookStatsLook[] = [
|
||||
'poster',
|
||||
'ticket',
|
||||
'tiles',
|
||||
'sign',
|
||||
'odometer',
|
||||
'note',
|
||||
'sketch',
|
||||
'band',
|
||||
];
|
||||
export const SUMMARY_LOOKS: readonly BookStatsLook[] = BOOK_TRAVEL_SUMMARY_LOOKS;
|
||||
|
||||
/** The marks the journey fills, in the order a page is read: when, which day, how far, where. */
|
||||
export const MARK_VARIANTS = ['date', 'day', 'distance', 'coords', 'flag'] as const;
|
||||
export type MarkVariant = (typeof MARK_VARIANTS)[number];
|
||||
export const MARK_VARIANTS = BOOK_TRAVEL_MARK_VARIANTS;
|
||||
export type MarkVariant = BookTravelMarkVariant;
|
||||
|
||||
/** The newer styles a mark comes in, and which of them each mark is offered in. */
|
||||
export const MARK_STYLES: { style: BookBadgeStyle; variants: readonly MarkVariant[] }[] = [
|
||||
{ style: 'stamp', variants: ['date', 'day', 'flag'] },
|
||||
{ style: 'tag', variants: ['day', 'distance', 'coords', 'flag'] },
|
||||
{ style: 'pill', variants: ['date', 'day', 'distance', 'coords', 'flag'] },
|
||||
{ style: 'ribbon', variants: ['date', 'day', 'distance', 'flag'] },
|
||||
{ style: 'sticker', variants: ['date', 'day', 'distance', 'flag'] },
|
||||
{ style: 'patch', variants: ['day', 'distance', 'flag'] },
|
||||
{ style: 'sketch', variants: ['date', 'day', 'distance', 'coords', 'flag'] },
|
||||
];
|
||||
export const MARK_STYLES: readonly { style: BookBadgeStyle; variants: readonly MarkVariant[] }[] =
|
||||
BOOK_TRAVEL_MARK_STYLES;
|
||||
|
||||
/** The single figures, one shelf per look, with the figures each look suits. */
|
||||
export const FIGURE_SHELVES: { look: BookStatsLook; metrics: readonly BookMetric[] }[] = [
|
||||
{ look: 'medal', metrics: ['distance', 'days', 'steps', 'photos', 'countries', 'places', 'furthest'] },
|
||||
{ look: 'odometer', metrics: ['distance', 'furthest', 'days', 'photos'] },
|
||||
{ look: 'sketch', metrics: ['distance', 'days', 'countries', 'photos'] },
|
||||
];
|
||||
export const FIGURE_SHELVES: readonly { look: BookStatsLook; metrics: readonly BookMetric[] }[] =
|
||||
BOOK_TRAVEL_FIGURE_SHELVES;
|
||||
|
||||
/** The looks a country is offered in. */
|
||||
export const COUNTRY_LOOKS: BookCountryLook[] = ['stamp', 'visa', 'postcard', 'flag', 'pin', 'label', 'sketch'];
|
||||
|
||||
/** The looks written by hand take the theme's script face rather than its heading. */
|
||||
const HANDWRITTEN = new Set<string>(['note', 'sketch']);
|
||||
|
||||
/** Which of the theme's accents each country look takes, so a shelf of them is not one colour. */
|
||||
const COUNTRY_ACCENT: Partial<Record<BookCountryLook, BookColorToken>> = {
|
||||
stamp: 'accent2',
|
||||
visa: 'accent3',
|
||||
pin: 'accent',
|
||||
label: 'accent2',
|
||||
};
|
||||
|
||||
/** Which of the theme's accents each mark takes, so a page of them is not one colour. */
|
||||
const MARK_ACCENT: Record<MarkVariant, BookColorToken> = {
|
||||
date: 'accent',
|
||||
day: 'accent2',
|
||||
distance: 'accent3',
|
||||
coords: 'accent',
|
||||
flag: 'accent2',
|
||||
};
|
||||
export const COUNTRY_LOOKS: readonly BookCountryLook[] = BOOK_TRAVEL_COUNTRY_LOOKS;
|
||||
|
||||
/** The map cards: the photographed route, its stops numbered, its two ends marked, and the dark ground. */
|
||||
export const MAP_CARDS: {
|
||||
@@ -130,130 +86,19 @@ export function travelBuilders({ page, stats, path, locale, t, theme, single }:
|
||||
return { x: (W - size.w) / 2, y: (page.pageHeight - size.h) / 2, w: size.w, h: size.h };
|
||||
};
|
||||
|
||||
/**
|
||||
* An element dressed in the book's theme: its words in the theme's ink and
|
||||
* heading face, its colour one of the theme's accents, all three linked so
|
||||
* it follows when the theme changes. A book without a theme keeps the
|
||||
* engine's own colours.
|
||||
*/
|
||||
const dress = (el: BookElement, accent: BookColorToken = 'accent', hand = false): BookElement =>
|
||||
theme
|
||||
? themedElement(
|
||||
{ ...el, token: 'ink', accentToken: accent, fontRole: hand ? 'script' : 'heading' } as BookElement,
|
||||
theme
|
||||
)
|
||||
: el;
|
||||
const looks = bookTravelLooks({ page, stats, travel, theme, newId: uid, place: at });
|
||||
|
||||
/** A summary in one of the newer looks; a band is a strip, so it is set a third as tall. */
|
||||
const summaryEl = (look: BookStatsLook): BookElement => {
|
||||
const w = page.pageWidth * (look === 'note' || look === 'sign' ? 0.56 : 0.72);
|
||||
const h =
|
||||
look === 'band' ? page.pageHeight * 0.13 : look === 'ticket' ? w * 0.42 : look === 'note' ? w * 0.72 : w * 0.5;
|
||||
const el = { ...bookTravelStats(stats, { id: uid('st'), frame: at({ w, h }), metrics: SUMMARY_METRICS }), look };
|
||||
return dress(el, look === 'sign' ? 'accent3' : 'accent', HANDWRITTEN.has(look));
|
||||
return {
|
||||
summaryEl: (look: BookStatsLook) => looks.summaryEl(look),
|
||||
figureEl: (metric: BookMetric, look: BookStatsLook = 'medal') => looks.figureEl(metric, look),
|
||||
mapEl: (
|
||||
card: BookTravelMapCard,
|
||||
scope: BookMapScope = 'journey',
|
||||
source: 'vector' | 'relief' | 'satellite' = 'vector'
|
||||
) => looks.mapEl(card, scope, source),
|
||||
countryMapEl: (code: string, kind: 'shape' | 'satellite' | 'box') => looks.countryMapEl(code, kind),
|
||||
markEl: (variant: MarkVariant, style: BookBadgeStyle) => looks.markEl(variant, style),
|
||||
countryEl: (code: string, look: BookCountryLook) => looks.countryEl(code, look),
|
||||
countryName: looks.countryName,
|
||||
};
|
||||
|
||||
/**
|
||||
* One figure on its own, the mark to drop beside a photograph: as a medal, on
|
||||
* the drums of an odometer, or ringed by hand.
|
||||
*/
|
||||
const figureEl = (metric: BookMetric, look: BookStatsLook = 'medal'): BookElement => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.3;
|
||||
const size = look === 'odometer' ? { w: side * 1.6, h: side * 0.62 } : { w: side, h: side };
|
||||
const el = { ...bookTravelStats(stats, { id: uid('st'), frame: at(size), metrics: [metric] }), look };
|
||||
return dress(el, 'accent', HANDWRITTEN.has(look));
|
||||
};
|
||||
|
||||
/**
|
||||
* A map as the engine places it, on the card's ground and with the card's
|
||||
* stops. The scope is the journey or one trip; the ground is the outlines
|
||||
* or one of the two imagery sources.
|
||||
*/
|
||||
const mapEl = (
|
||||
card: { style: BookMapElement['style']; pins?: BookMapElement['pinStyle'] },
|
||||
scope: BookMapScope = 'journey',
|
||||
source: 'vector' | 'relief' | 'satellite' = 'vector'
|
||||
): BookElement => {
|
||||
const el = bookTravelMap(travel, stats, { id: uid('mp'), frame: at(bookTravelSize('map', page)), scope, source });
|
||||
const shaped = { ...el, style: card.style, ...(card.pins ? { pinStyle: card.pins } : {}) };
|
||||
// Over imagery the line is white on purpose (book-travel.ts); only the outline maps take the theme.
|
||||
return source === 'vector' ? dress(shaped) : shaped;
|
||||
};
|
||||
|
||||
/**
|
||||
* A map of one country: its stops alone, without the way there. A journey's
|
||||
* recorded way often starts with the flight from home, and fitted to all of
|
||||
* it the country is a speck at the edge of a continent; the page a travel
|
||||
* book wants is the country, cut to its own shape on the paper or over the
|
||||
* satellite picture, or the country in a box.
|
||||
*/
|
||||
const countryMapEl = (code: string, kind: 'shape' | 'satellite' | 'box'): BookElement => {
|
||||
const own = {
|
||||
...stats,
|
||||
points: stats.points.filter((p) => p.country === code),
|
||||
countries: stats.countries.filter((c) => c.code === code),
|
||||
};
|
||||
const ctx = studioTravelContext(own, [], locale, t);
|
||||
const source = kind === 'satellite' ? 'satellite' : 'vector';
|
||||
const el = bookTravelMap(ctx, own, {
|
||||
id: uid('mp'),
|
||||
frame: at(bookTravelSize('map', page)),
|
||||
scope: 'journey',
|
||||
source,
|
||||
});
|
||||
// Cut to its shape on the paper, the land is dark, so the silhouette reads and the route in the accent stands on it.
|
||||
const style = kind === 'shape' ? 'dark' : 'minimal';
|
||||
const shaped = {
|
||||
...el,
|
||||
style,
|
||||
clip: kind === 'box' ? 'rect' : 'country',
|
||||
pinStyle: 'numbered',
|
||||
fitToCountries: true,
|
||||
} as const;
|
||||
return source === 'vector' ? dress(shaped) : shaped;
|
||||
};
|
||||
|
||||
/** A mark in one of the newer styles, at the size its variant suits; a stamp is set taller for its two rules. */
|
||||
const markEl = (variant: MarkVariant, style: BookBadgeStyle): BookElement | null => {
|
||||
const value = bookBadgeValue(travel, variant, null);
|
||||
if (!value) return null;
|
||||
const frame = at(markSize(style, value.text, value.sub));
|
||||
const el = { ...bookTravelBadge({ id: uid('bd'), frame, variant: variant as BookBadgeVariant, value }), style };
|
||||
return dress(el, MARK_ACCENT[variant], style === 'sketch');
|
||||
};
|
||||
|
||||
/**
|
||||
* How large a mark is set: as wide as its words need. A stamp stacks them in
|
||||
* a box with room for its two rules; a tag or a pill sets them in a line
|
||||
* after its eyelet or its disc, so its length follows the words.
|
||||
*/
|
||||
const markSize = (style: BookBadgeStyle, text: string, sub: string) => {
|
||||
const twoLines = !!text && !!sub && text.length > 14;
|
||||
const h = page.pageHeight * (style === 'stamp' ? 0.12 : twoLines ? 0.1 : 0.066);
|
||||
const main = text || sub;
|
||||
if (style === 'stamp') return { w: Math.max(h * 1.3, h * 0.2 * Math.max(main.length * 1.4, sub.length)), h };
|
||||
// Two lines are set smaller than one, so they need less width a letter.
|
||||
if (twoLines)
|
||||
return { w: Math.min(page.pageWidth * 0.6, h * (1.5 + 0.13 * Math.max(text.length, sub.length * 0.7))), h };
|
||||
const chars = main.length * (text ? 1 : 0.85) + (text ? sub.length * 0.5 : 0);
|
||||
return { w: Math.min(page.pageWidth * 0.6, h * (1.6 + 0.3 * chars)), h };
|
||||
};
|
||||
|
||||
/** One country as a square stamp, or as a card in the proportions of a postcard. */
|
||||
const countryEl = (code: string, look: BookCountryLook): BookElement => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.36;
|
||||
const ratio = look === 'stamp' || look === 'pin' || look === 'sketch' ? 1 : look === 'postcard' ? 1.36 : 1.4;
|
||||
const frame = at({ w: side * ratio, h: side });
|
||||
const el = {
|
||||
...bookTravelCountries(travel, { id: uid('co'), frame, codes: [code] }),
|
||||
look,
|
||||
showFlag: look === 'pin' || look === 'postcard',
|
||||
};
|
||||
return dress(el, COUNTRY_ACCENT[look] ?? 'accent', look === 'sketch');
|
||||
};
|
||||
|
||||
/** A country's name in the reader's language, from the same CLDR data Atlas uses. */
|
||||
const countryName = (code: string): string => bookCountryName(code, locale, stats);
|
||||
|
||||
return { summaryEl, figureEl, mapEl, countryMapEl, markEl, countryEl, countryName };
|
||||
}
|
||||
|
||||
@@ -37,10 +37,13 @@ vi.mock('../../api/websocket', async importOriginal => ({
|
||||
|
||||
// The auto layout lives in @trek/shared now; the hook's call into it is watched through a partial mock.
|
||||
const shared = await vi.importActual<typeof import('@trek/shared')>('@trek/shared')
|
||||
const buildBook = vi.fn(shared.buildBook)
|
||||
const autoDesignedBook = vi.fn(shared.autoDesignedBook)
|
||||
vi.mock('@trek/shared', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('@trek/shared')>()
|
||||
return { ...actual, buildBook: (...args: Parameters<typeof actual.buildBook>) => buildBook(...args) }
|
||||
return {
|
||||
...actual,
|
||||
autoDesignedBook: (...args: Parameters<typeof actual.autoDesignedBook>) => autoDesignedBook(...args),
|
||||
}
|
||||
})
|
||||
|
||||
import { useJourneyStudio } from './useJourneyStudio'
|
||||
@@ -67,7 +70,7 @@ function seedJourney() {
|
||||
|
||||
beforeEach(() => {
|
||||
seedJourney()
|
||||
buildBook.mockClear()
|
||||
autoDesignedBook.mockClear()
|
||||
useStudioStore.setState({ doc: null, selection: [], activeSpread: 0, past: [], future: [], readOnly: false })
|
||||
vi.spyOn(journeyApi, 'getBook').mockResolvedValue({ book: null } as never)
|
||||
vi.spyOn(journeyApi, 'saveBook').mockResolvedValue({} as never)
|
||||
@@ -140,8 +143,8 @@ describe('the auto layout input', () => {
|
||||
|
||||
act(() => { result.current.relayoutBook() })
|
||||
|
||||
expect(buildBook).toHaveBeenCalled()
|
||||
const input = buildBook.mock.calls[buildBook.mock.calls.length - 1][0]
|
||||
expect(autoDesignedBook).toHaveBeenCalled()
|
||||
const input = autoDesignedBook.mock.calls[autoDesignedBook.mock.calls.length - 1][1]
|
||||
expect(input.path).toEqual([TRACK])
|
||||
})
|
||||
|
||||
@@ -176,9 +179,51 @@ describe('the auto layout input', () => {
|
||||
|
||||
act(() => { result.current.relayoutBook() })
|
||||
|
||||
const input = buildBook.mock.calls[buildBook.mock.calls.length - 1][0]
|
||||
const input = autoDesignedBook.mock.calls[autoDesignedBook.mock.calls.length - 1][1]
|
||||
expect(input.path).toEqual([])
|
||||
})
|
||||
|
||||
it("lays the whole book on the designed pages in the book's own page format, theme and title", async () => {
|
||||
const { result } = await mountStudio()
|
||||
const theme = shared.bookThemeOf('midnight')
|
||||
const page = { ...shared.bookPageSetupSchema.parse({}), pageWidth: 200 }
|
||||
act(() => {
|
||||
useStudioStore.getState().commit(d => ({ ...d, title: 'My own title', page, theme }))
|
||||
})
|
||||
|
||||
act(() => { result.current.relayoutBook() })
|
||||
|
||||
const [doc, input, opts] = autoDesignedBook.mock.calls[autoDesignedBook.mock.calls.length - 1]
|
||||
expect(input.page).toEqual(page)
|
||||
expect(opts?.theme).toEqual(theme)
|
||||
expect(doc?.title).toBe('My own title')
|
||||
const laid = useStudioStore.getState().doc
|
||||
expect(laid?.title).toBe('My own title')
|
||||
expect(laid?.theme).toEqual(theme)
|
||||
expect(laid?.page.pageWidth).toBe(200)
|
||||
expect(laid?.spreads.some(s => s.role === 'inner' && s.entryId === 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('lays this spread on another design, its id kept, and knows the cover but not the back', async () => {
|
||||
const { result } = await mountStudio()
|
||||
act(() => { result.current.relayoutBook() })
|
||||
const before = useStudioStore.getState().doc
|
||||
if (!before) throw new Error('a laid book')
|
||||
const at = before.spreads.findIndex(s => s.role === 'inner' && s.entryId === 1)
|
||||
act(() => { useStudioStore.getState().setActiveSpread(at) })
|
||||
expect(result.current.canRelayoutSpread).toBe(true)
|
||||
const was = shared.matchDecoratedTemplate(before.spreads[at], before.page)?.id
|
||||
|
||||
act(() => { result.current.relayoutCurrentSpread() })
|
||||
|
||||
const after = useStudioStore.getState().doc?.spreads[at]
|
||||
expect(after?.id).toBe(before.spreads[at].id)
|
||||
expect(shared.matchDecoratedTemplate(after as never, before.page)?.id).not.toBe(was)
|
||||
act(() => { useStudioStore.getState().setActiveSpread(0) })
|
||||
expect(result.current.canRelayoutSpread).toBe(true)
|
||||
act(() => { useStudioStore.getState().setActiveSpread(before.spreads.length - 1) })
|
||||
expect(result.current.canRelayoutSpread).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router'
|
||||
import type { AutoEntry, AutoInput, AutoPhoto, BookElement, BookPageNumbers, JourneyStats } from '@trek/shared'
|
||||
import type { AutoDesignedInput, AutoEntry, AutoPhoto, BookElement, BookPageNumbers, JourneyStats } from '@trek/shared'
|
||||
import {
|
||||
autoEntriesWithStops, bookElementId as elementId, bookPageSetupSchema, bookPath, buildBook, distributeGallery,
|
||||
emptyBook, layoutAroundLocks, relayoutSpread,
|
||||
autoDesignedBook, autoEntriesWithStops, autoRelayoutKind, autoRelayoutSpread, bookElementId as elementId,
|
||||
bookPageSetupSchema, bookPath, emptyBook,
|
||||
} from '@trek/shared'
|
||||
import { journeyApi } from '../../api/client'
|
||||
import { useJourneyStore, type GalleryPhoto, type JourneyEntry, type JourneyPhoto } from '../../store/journeyStore'
|
||||
@@ -153,7 +153,7 @@ export function useJourneyStudio() {
|
||||
* possible; rebuilding it from the journey on every click would be the same
|
||||
* work plus a chance of the two disagreeing.
|
||||
*/
|
||||
const autoInput = useRef<AutoInput | null>(null)
|
||||
const autoInput = useRef<AutoDesignedInput | null>(null)
|
||||
|
||||
const journey = current && current.id === journeyId ? current : null
|
||||
|
||||
@@ -274,15 +274,15 @@ export function useJourneyStudio() {
|
||||
})),
|
||||
}))
|
||||
|
||||
// When each loose photo was taken: the auto layout gives it to the entry of its day first.
|
||||
const gallery: AutoPhoto[] = (journey.gallery || []).map((p: GalleryPhoto) => ({
|
||||
photoId: p.photo_id,
|
||||
width: p.width ?? null,
|
||||
height: p.height ?? null,
|
||||
caption: p.caption ?? null,
|
||||
takenAt: p.taken_at ?? null,
|
||||
}))
|
||||
|
||||
const withPhotos = distributeGallery(entries, gallery)
|
||||
|
||||
/*
|
||||
* Give every entry what the journey's figures know about its stop: the
|
||||
* coordinates and country its place resolved to and which day it is, so a
|
||||
@@ -290,7 +290,7 @@ export function useJourneyStudio() {
|
||||
* is shared (autoEntriesWithStops) so the server's design tool reads the
|
||||
* same stops.
|
||||
*/
|
||||
const placed = autoEntriesWithStops(withPhotos, stats)
|
||||
const placed = autoEntriesWithStops(entries, stats)
|
||||
|
||||
const preset = PAGE_PRESETS['square-210']
|
||||
|
||||
@@ -300,6 +300,8 @@ export function useJourneyStudio() {
|
||||
subtitle: journey.subtitle ?? null,
|
||||
coverPhotoId: gallery[0]?.photoId ?? null,
|
||||
entries: placed,
|
||||
// The loose photos stay loose here: the layout shares them out itself, by day, and loses none.
|
||||
gallery,
|
||||
page: {
|
||||
preset: preset.id,
|
||||
pageWidth: preset.pageWidthMm,
|
||||
@@ -375,7 +377,7 @@ export function useJourneyStudio() {
|
||||
*
|
||||
* The build effect runs as soon as the book and the figures are in, which is
|
||||
* usually before this answers, and what it froze is what the Auto layout
|
||||
* buttons hand to buildBook afterwards. Without the patch a relayout drew
|
||||
* buttons hand to the layout afterwards. Without the patch a relayout drew
|
||||
* the straight line between stops over a route somebody had walked.
|
||||
*/
|
||||
const applyPath = (p: [number, number][][]) => {
|
||||
@@ -471,8 +473,10 @@ export function useJourneyStudio() {
|
||||
const relayoutBook = useCallback(() => {
|
||||
const input = autoInput.current
|
||||
if (!input) return
|
||||
// A new seed every press: "the whole book" twice gives two books, each one undo away.
|
||||
const seed = Date.now() % 2 ** 31
|
||||
commit(d => ({
|
||||
...buildBook({ ...input, page: d.page }),
|
||||
...autoDesignedBook(d, { ...input, page: d.page }, { theme: d.theme, seed }),
|
||||
title: d.title,
|
||||
}))
|
||||
setActiveSpread(0)
|
||||
@@ -481,22 +485,20 @@ export function useJourneyStudio() {
|
||||
const relayoutCurrentSpread = useCallback(() => {
|
||||
const input = autoInput.current
|
||||
if (!input) return
|
||||
const seed = Date.now() % 2 ** 31
|
||||
commit(d => {
|
||||
const sp = d.spreads[activeSpread]
|
||||
if (!sp) return d
|
||||
// Laid out around what is locked, as the assistant's auto layout leaves it.
|
||||
const next = layoutAroundLocks(sp, free => relayoutSpread(free, { ...input, page: d.page }))
|
||||
// Laid out around what is locked, its id kept, on another template than the one on screen.
|
||||
const next = autoRelayoutSpread(d, activeSpread, { ...input, page: d.page }, { seed })
|
||||
if (!next) return d
|
||||
return { ...d, spreads: d.spreads.map((x, i) => (i === activeSpread ? next : x)) }
|
||||
})
|
||||
select([])
|
||||
}, [commit, activeSpread, select])
|
||||
|
||||
/** Whether the spread on screen came from an entry, and so can be redone. */
|
||||
/** Whether the auto layout knows the spread on screen (an entry's page, the cover, the summary, the countries). */
|
||||
const canRelayoutSpread = !!(
|
||||
autoInput.current
|
||||
&& doc?.spreads[activeSpread]?.entryId != null
|
||||
&& doc.spreads[activeSpread].role === 'inner'
|
||||
&& autoRelayoutKind(doc?.spreads[activeSpread]) !== null
|
||||
)
|
||||
|
||||
/** Page numbers and anything else that belongs to the book rather than a page. */
|
||||
|
||||
@@ -61,7 +61,7 @@ export class JourneyBookDesignMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'design_book',
|
||||
description: `Design the whole photo book of a journey as a draft: chapters, pacing, the pages, the photos picked for each, type and colours, with the engine's own check run on every page and repaired where it can. directives says what the person wants (style, density, page count, scope, chapters, photos, cover, type, palette, words, travel pages, what to keep) and the art direction: the brief (energy, decoration, collages, theme), the cover's archetype and lockup, and pages.overrides giving single pages of the plan another beat or layout. It answers the draft's design_id, the plan page by page (what each page becomes and its findings), the look, the photos left out and why, and the findings left. ${DRAFT_FLOW} ${LIMITS} Only the book's owner and editors can design it.`,
|
||||
description: `Design the whole photo book of a journey as a draft: chapters, pacing, the pages, the photos picked for each, type and colours, with the engine's own check run on every page and repaired where it can. directives says what the person wants (style, density, page count, scope, chapters, photos, cover, type, palette, words, travel pages, what to keep) and the art direction: the brief (energy, decoration, collages, theme), the cover's archetype and lockup, and pages.overrides giving single pages of the plan another beat or layout. With layouts: 'templates' every page is laid on the Studio's designed templates in the archetype of the book's theme, as the Studio's Auto layout does, and each plan row names its template; style classic alone is the old built-in Auto layout. It answers the draft's design_id, the plan page by page (what each page becomes and its findings), the look, the photos left out and why, and the findings left. ${DRAFT_FLOW} ${LIMITS} Only the book's owner and editors can design it.`,
|
||||
inputSchema: { journeyId: idSchema, ...designBookArgs },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: journeyAddonOn,
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
bookDesignAnswerSchema,
|
||||
bookDesignApplyResultSchema,
|
||||
bookDesignDraftResponseSchema,
|
||||
bookDocumentSchema,
|
||||
bookWriteResultSchema,
|
||||
} from '@trek/shared';
|
||||
|
||||
@@ -197,4 +198,21 @@ describe('the whole-book design over MCP and REST', () => {
|
||||
expect(names.filter((name) => DESIGN_TOOLS.includes(name))).toEqual([]);
|
||||
expect(names.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('JBDSNE2E-004: a design on the designed templates applies as a valid book GET reads back', async () => {
|
||||
const { editor, journey } = await journeyWithBook();
|
||||
const directives = { style: 'field-notes', layouts: 'templates', seed: 2 };
|
||||
const draft = bookDesignAnswerSchema.parse(
|
||||
await callTool(editor.id, 'design_book', { journeyId: journey.id, directives }),
|
||||
);
|
||||
expect(draft.plan.filter((row) => row.template).length).toBeGreaterThan(0);
|
||||
const applied = bookDesignApplyResultSchema.parse(
|
||||
await callTool(editor.id, 'apply_book_design', { journeyId: journey.id, designId: draft.design_id }),
|
||||
);
|
||||
const after = await book(journey.id, editor.id);
|
||||
expect(after.body.book.version).toBe(applied.version);
|
||||
expect(bookDocumentSchema.safeParse(after.body.book.document).success).toBe(true);
|
||||
// Applied over a stored book, the design brings its pages; the book keeps its own theme.
|
||||
expect(after.body.book.document.spreads.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
bookDesignRevisionAnswerSchema,
|
||||
bookPageDescriptionSchema,
|
||||
bookWriteResultSchema,
|
||||
designBookRequestSchema,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -236,4 +237,14 @@ describe('the design tools', () => {
|
||||
await expect(books.restoreBookLayout(putBack, ctx(viewer.id))).rejects.toThrow('Not allowed');
|
||||
expect(t.realtime.broadcastToUserMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('JBDMCP-006: design_book takes layouts templates and answers the template of each page', async () => {
|
||||
const { journey, editor } = await crew();
|
||||
const request = designBookRequestSchema.parse({ directives: { style: 'postcard', layouts: 'templates', seed: 1 } });
|
||||
const designed = await designs.designBook({ journeyId: journey.id, ...request }, ctx(editor.id));
|
||||
const draft = bookDesignAnswerSchema.parse(answered(designed));
|
||||
expect(draft.plan.some((row) => row.template !== undefined)).toBe(true);
|
||||
expect(draft.style.style).toBe('postcard');
|
||||
expect(t.realtime.broadcastToUserMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -418,3 +418,26 @@ describe('the art direction', () => {
|
||||
expect(free.findings.map((f) => f.code)).not.toContain('energy_mismatch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the designed templates', () => {
|
||||
it("JBDSN-014: layouts templates lays the draft on the designed templates and names each page's template", async () => {
|
||||
const { journey, owner, created } = await crew();
|
||||
const answer = await designs.design(journey.id, owner.id, { style: 'riso', layouts: 'templates', seed: 3 });
|
||||
|
||||
expect(bookDesignAnswerSchema.parse(answer)).toEqual(answer);
|
||||
const named = answer.plan.filter((row) => row.template);
|
||||
expect(named.length).toBeGreaterThan(1);
|
||||
expect(answer.plan[0]?.beat).toBe('cover');
|
||||
const { document } = await designs.draftView(journey.id, owner.id, answer.design_id);
|
||||
expect(document.theme?.id).toBe('riso');
|
||||
expect(answer.findings.filter((f) => f.severity === 'severe')).toEqual([]);
|
||||
expect(await version(journey.id, owner.id)).toBe(created?.version);
|
||||
expect(told()).toEqual([]);
|
||||
});
|
||||
|
||||
it('JBDSN-015: without layouts the plan names no template, as every design answered before', async () => {
|
||||
const { journey, owner } = await crew();
|
||||
const answer = await designs.design(journey.id, owner.id, { style: 'riso', seed: 3 });
|
||||
expect(answer.plan.every((row) => !('template' in row))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { bookSpreadSchema, type BookDocument, type BookSpread } from '../book.schema';
|
||||
import { matchDecoratedTemplate } from '../templates/template-match';
|
||||
import { bookThemeOf } from '../theme/book-theme';
|
||||
import { emptyBook } from './auto-book';
|
||||
import {
|
||||
AUTO_DEFAULT_THEME,
|
||||
autoDesignedBook,
|
||||
autoRelayoutKind,
|
||||
autoRelayoutSpread,
|
||||
type AutoDesignedInput,
|
||||
} from './auto-designed';
|
||||
import type { AutoPhoto } from './auto-types';
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/** The Studio's Auto layout on the designed templates: the whole book, and one spread again. */
|
||||
|
||||
interface Fixture {
|
||||
input: AutoDesignedInput;
|
||||
gallery?: AutoPhoto[];
|
||||
}
|
||||
|
||||
const here = (file: string) => fileURLToPath(new URL(file, import.meta.url));
|
||||
const fixtures = JSON.parse(readFileSync(here('./auto-layout.parity-inputs.json'), 'utf8')) as Record<string, Fixture>;
|
||||
const fx = fixtures.A as Fixture;
|
||||
|
||||
const counter = () => {
|
||||
let n = 0;
|
||||
return (p: string) => `${p}-a${++n}`;
|
||||
};
|
||||
const input = (): AutoDesignedInput => ({ ...fx.input, gallery: fx.gallery, newId: counter() });
|
||||
const book = (seed = 1, theme = bookThemeOf('riso')) => autoDesignedBook(emptyBook(input()), input(), { theme, seed });
|
||||
|
||||
/** The index of the first page of a kind, or -1. */
|
||||
const indexOf = (doc: BookDocument, kind: ReturnType<typeof autoRelayoutKind>) =>
|
||||
doc.spreads.findIndex((s) => autoRelayoutKind(s) === kind);
|
||||
|
||||
describe('the Studio auto layout on the designed templates', () => {
|
||||
it("AUTODES-001: keeps the book's page format and its theme object, one of its own included", () => {
|
||||
const own = {
|
||||
...bookThemeOf('postcard'),
|
||||
id: null,
|
||||
palette: { ...bookThemeOf('postcard').palette, ink: '#203040' },
|
||||
};
|
||||
const start = { ...emptyBook(input()), page: { ...fx.input.page, pageWidth: 200, pageHeight: 250 } };
|
||||
const doc = autoDesignedBook(start, input(), { theme: own, seed: 3 });
|
||||
expect(doc.page.pageWidth).toBe(200);
|
||||
expect(doc.page.pageHeight).toBe(250);
|
||||
expect(doc.theme).toEqual(own);
|
||||
expect(autoDesignedBook(null, input()).theme?.id).toBe(AUTO_DEFAULT_THEME);
|
||||
});
|
||||
|
||||
it('AUTODES-002: another seed lays another book, the same seed the same', () => {
|
||||
const ids = (doc: BookDocument) => doc.spreads.map((s) => matchDecoratedTemplate(s, doc.page)?.id ?? '-').join();
|
||||
expect(ids(book(1))).toBe(ids(book(1)));
|
||||
expect([2, 3, 4, 5].some((seed) => ids(book(seed)) !== ids(book(1)))).toBe(true);
|
||||
});
|
||||
|
||||
it("AUTODES-003: this spread keeps the page's id and lays an entry's page on another template than its own and its neighbours'", () => {
|
||||
const doc = book();
|
||||
const at = doc.spreads.findIndex(
|
||||
(s, i) => autoRelayoutKind(s) === 'entry' && matchDecoratedTemplate(doc.spreads[i] as BookSpread, doc.page),
|
||||
);
|
||||
const before = doc.spreads[at] as BookSpread;
|
||||
const next = autoRelayoutSpread(doc, at, input(), { seed: 1 });
|
||||
expect(next?.id).toBe(before.id);
|
||||
expect(next?.entryId).toBe(before.entryId);
|
||||
expect(bookSpreadSchema.safeParse(next).success).toBe(true);
|
||||
const was = [at - 1, at, at + 1].map(
|
||||
(i) => doc.spreads[i] && matchDecoratedTemplate(doc.spreads[i] as BookSpread, doc.page)?.id,
|
||||
);
|
||||
expect(was).not.toContain(matchDecoratedTemplate(next as BookSpread, doc.page)?.id);
|
||||
const photos = (s: BookSpread) =>
|
||||
[...s.elements, ...s.parked].flatMap((e) => (e.kind === 'photo' && e.photoId != null ? [e.photoId] : [])).sort();
|
||||
expect(photos(next as BookSpread)).toEqual(photos(before));
|
||||
});
|
||||
|
||||
it('AUTODES-004: pressed again and again, an entry page keeps moving on', () => {
|
||||
let doc = book();
|
||||
const at = indexOf(doc, 'entry');
|
||||
const seen = new Set<string>();
|
||||
for (let n = 0; n < 4; n++) {
|
||||
const next = autoRelayoutSpread(doc, at, input(), { seed: n });
|
||||
if (!next) throw new Error('an entry page lays again');
|
||||
seen.add(matchDecoratedTemplate(next, doc.page)?.id ?? 'composed');
|
||||
doc = { ...doc, spreads: doc.spreads.map((s, i) => (i === at ? next : s)) };
|
||||
}
|
||||
expect(seen.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('AUTODES-005: cycles the cover through the covers of its archetype and then its kin', () => {
|
||||
let doc = book();
|
||||
const seen: string[] = [];
|
||||
for (let n = 0; n < 6; n++) {
|
||||
const next = autoRelayoutSpread(doc, 0, input(), { seed: 1 });
|
||||
if (!next) throw new Error('the cover lays again');
|
||||
expect(next.id).toBe(doc.spreads[0]?.id);
|
||||
seen.push(matchDecoratedTemplate(next, doc.page)?.id ?? '-');
|
||||
doc = { ...doc, spreads: doc.spreads.map((s, i) => (i === 0 ? next : s)) };
|
||||
}
|
||||
expect(new Set(seen).size).toBeGreaterThan(3);
|
||||
expect(seen.some((id) => id.startsWith('riso-'))).toBe(true);
|
||||
});
|
||||
|
||||
it('AUTODES-006: cycles the summary and the countries through their looks', () => {
|
||||
const doc = book();
|
||||
const summary = indexOf(doc, 'summary');
|
||||
const looks: string[] = [];
|
||||
let cur = doc;
|
||||
for (let n = 0; n < 3; n++) {
|
||||
const next = autoRelayoutSpread(cur, summary, input()) as BookSpread;
|
||||
const stats = next.elements.find((e) => e.kind === 'stats' && e.metrics.length > 2);
|
||||
looks.push(stats?.kind === 'stats' ? (stats.look ?? 'classic') : '-');
|
||||
cur = { ...cur, spreads: cur.spreads.map((s, i) => (i === summary ? next : s)) };
|
||||
}
|
||||
expect(new Set(looks).size).toBe(3);
|
||||
expect(looks).not.toContain('classic');
|
||||
const countries = indexOf(doc, 'countries');
|
||||
const again = autoRelayoutSpread(doc, countries, input()) as BookSpread;
|
||||
const look = (s: BookSpread) => s.elements.flatMap((e) => (e.kind === 'countries' ? [e.look] : []))[0];
|
||||
expect(look(again)).not.toBe(look(doc.spreads[countries] as BookSpread));
|
||||
});
|
||||
|
||||
it('AUTODES-007: a page the engine did not make is not laid again', () => {
|
||||
const doc = book();
|
||||
const back = doc.spreads.findIndex((s) => s.role === 'back');
|
||||
expect(autoRelayoutSpread(doc, back, input())).toBeNull();
|
||||
const stations = doc.spreads.findIndex(
|
||||
(s) => s.role === 'inner' && s.entryId === null && autoRelayoutKind(s) === null,
|
||||
);
|
||||
if (stations >= 0) expect(autoRelayoutSpread(doc, stations, input())).toBeNull();
|
||||
expect(autoRelayoutSpread(doc, 999, input())).toBeNull();
|
||||
});
|
||||
|
||||
it('AUTODES-008: keeps a locked element where it is', () => {
|
||||
const doc = book();
|
||||
const at = indexOf(doc, 'entry');
|
||||
const page = doc.spreads[at] as BookSpread;
|
||||
const lockedEl = { ...(page.elements[0] as BookSpread['elements'][number]), locked: true };
|
||||
const locked = { ...page, elements: [lockedEl, ...page.elements.slice(1)] };
|
||||
const withLock = { ...doc, spreads: doc.spreads.map((s, i) => (i === at ? locked : s)) };
|
||||
const next = autoRelayoutSpread(withLock, at, input());
|
||||
expect(next?.elements).toContainEqual(lockedEl);
|
||||
});
|
||||
|
||||
it('AUTODES-009: the whole book keeps a stored page that holds a lock', () => {
|
||||
const doc = book();
|
||||
const at = indexOf(doc, 'entry');
|
||||
const page = doc.spreads[at] as BookSpread;
|
||||
const locked = { ...page, elements: page.elements.map((e, i) => (i === 0 ? { ...e, locked: true } : e)) };
|
||||
const again = autoDesignedBook({ ...doc, spreads: doc.spreads.map((s, i) => (i === at ? locked : s)) }, input(), {
|
||||
theme: doc.theme,
|
||||
seed: 9,
|
||||
});
|
||||
expect(again.spreads).toContainEqual(locked);
|
||||
});
|
||||
|
||||
it('AUTODES-010: an old Auto layout summary is laid again in a new look', () => {
|
||||
const doc = book();
|
||||
const summary = indexOf(doc, 'summary');
|
||||
const old = doc.spreads[summary] as BookSpread;
|
||||
const classic = {
|
||||
...old,
|
||||
elements: old.elements.map((e) => (e.kind === 'stats' ? { ...e, look: undefined } : e)),
|
||||
} as BookSpread;
|
||||
const next = autoRelayoutSpread(
|
||||
{ ...doc, spreads: doc.spreads.map((s, i) => (i === summary ? classic : s)) },
|
||||
summary,
|
||||
input(),
|
||||
);
|
||||
const looks = next?.elements.flatMap((e) => (e.kind === 'stats' ? [e.look] : []));
|
||||
expect(looks?.length).toBeGreaterThan(0);
|
||||
expect(looks?.every((l) => l && l !== 'classic')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { layoutAroundLocks } from '../book-layout-locks';
|
||||
import type { BookCountryLook, BookDocument, BookElement, BookSpread, BookStatsLook } from '../book.schema';
|
||||
import { designBook, templatedSetting, type BookDesignInput, type TemplatedSetting } from '../design/design-book';
|
||||
import type { BookDesignDirectivesInput } from '../design/design-directives.schema';
|
||||
import { ARCHETYPE_KIN, type TemplatePickCtx } from '../design/design-template-pick';
|
||||
import { layEntryPage, type LaidPage, type PageStory } from '../design/design-templated';
|
||||
import {
|
||||
COUNTRY_CYCLE,
|
||||
SUMMARY_CYCLE,
|
||||
composeTravelCountries,
|
||||
composeTravelSummary,
|
||||
coverOnTemplate,
|
||||
} from '../design/design-templated-framing';
|
||||
import { decoratedTemplatesFor } from '../templates/template-catalogue';
|
||||
import { templatePhotoSlots } from '../templates/template-format';
|
||||
import { matchDecoratedTemplate } from '../templates/template-match';
|
||||
import { bookThemeOf } from '../theme/book-theme';
|
||||
import type { BookTheme, BookThemeId } from '../theme/book-theme.schema';
|
||||
import { isPortrait } from './auto-elements';
|
||||
import type { AutoEntry, AutoInput, AutoPhoto } from './auto-types';
|
||||
|
||||
/**
|
||||
* The Studio's Auto layout on the designed templates.
|
||||
*
|
||||
* "The whole book" is the design engine laying the book on the Studio's
|
||||
* decorated templates (`designBook` with `layouts: 'templates'`), in the
|
||||
* book's own theme, keeping every page that holds a locked element. "This
|
||||
* spread" lays one page again on another template than the one on screen and
|
||||
* its neighbours: an entry's page on the next best spread for its photos and
|
||||
* words, the cover on the next cover of its archetype and then of its kin,
|
||||
* the summary and the countries in their next look. The old Auto layout
|
||||
* (`buildBook`, `relayoutSpread`) stays as it was, the design engine's
|
||||
* classic style.
|
||||
*/
|
||||
|
||||
/** The theme a book without one is laid in: calm, linen, serif. */
|
||||
export const AUTO_DEFAULT_THEME: BookThemeId = 'fabric-classic';
|
||||
|
||||
/** The journey as the Studio's Auto layout takes it: the entries with their stops, and the loose gallery. */
|
||||
export interface AutoDesignedInput extends AutoInput {
|
||||
gallery?: readonly AutoPhoto[];
|
||||
}
|
||||
|
||||
export interface AutoDesignedOptions {
|
||||
/** The book's theme, a recoloured one of its own included; the default theme without one. */
|
||||
theme?: BookTheme | null;
|
||||
/** Another seed lays another book; the same one the same. */
|
||||
seed?: number;
|
||||
}
|
||||
|
||||
const designInput = (input: AutoDesignedInput, theme: BookTheme | null | undefined): BookDesignInput => ({
|
||||
...input,
|
||||
candidates: [],
|
||||
photos: [],
|
||||
theme: theme ?? bookThemeOf(AUTO_DEFAULT_THEME),
|
||||
});
|
||||
|
||||
const directivesOf = (theme: BookTheme | null | undefined, seed: number | undefined): BookDesignDirectivesInput => ({
|
||||
style: theme?.id ?? AUTO_DEFAULT_THEME,
|
||||
layouts: 'templates',
|
||||
keep: 'locked',
|
||||
...(seed === undefined ? {} : { seed }),
|
||||
});
|
||||
|
||||
/** The whole book laid on the designed templates over the stored one, its page format kept. */
|
||||
export function autoDesignedBook(
|
||||
doc: BookDocument | null,
|
||||
input: AutoDesignedInput,
|
||||
opts: AutoDesignedOptions = {},
|
||||
): BookDocument {
|
||||
const page = doc?.page ?? input.page;
|
||||
return designBook(doc, designInput({ ...input, page }, opts.theme), directivesOf(opts.theme, opts.seed)).doc;
|
||||
}
|
||||
|
||||
/** What "this spread" can lay again: an entry's page, the cover, the summary, the countries; null for the rest. */
|
||||
export type AutoRelayoutKind = 'entry' | 'cover' | 'summary' | 'countries';
|
||||
|
||||
/** The kind of page a spread of the book is for "this spread", or null for one the engine did not make. */
|
||||
export function autoRelayoutKind(spread: BookSpread | undefined): AutoRelayoutKind | null {
|
||||
if (!spread) return null;
|
||||
if (spread.role === 'cover') return 'cover';
|
||||
if (spread.role !== 'inner') return null;
|
||||
if (spread.entryId != null) return 'entry';
|
||||
const has = (kind: BookElement['kind']) => spread.elements.some((e) => e.kind === kind);
|
||||
if (has('stats') && has('map')) return 'summary';
|
||||
if (has('countries') && !has('stats')) return 'countries';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The photos a page holds, on it and parked with it, in order. */
|
||||
function photosOn(spread: BookSpread, entries: readonly AutoEntry[]): AutoPhoto[] {
|
||||
const known = new Map(entries.flatMap((e) => e.photos.map((p) => [p.photoId, p] as const)));
|
||||
return [...spread.elements, ...spread.parked].flatMap((e) =>
|
||||
e.kind === 'photo' && e.photoId != null
|
||||
? [known.get(e.photoId) ?? { photoId: e.photoId, width: null, height: null }]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
/** The story a page of this entry sets, and whether it is the whole of it. */
|
||||
function storyOn(spread: BookSpread, entryId: number): PageStory {
|
||||
for (const e of spread.elements) {
|
||||
if (e.kind === 'text' && e.binding?.source === 'entry.story' && e.binding.entryId === entryId && e.text.trim()) {
|
||||
return { words: e.text, whole: !e.overridden };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The templates of the page before and after this one, never to be laid here. */
|
||||
function neighbours(doc: BookDocument, index: number): string[] {
|
||||
return [doc.spreads[index - 1], doc.spreads[index + 1]].flatMap((s) =>
|
||||
s ? (matchDecoratedTemplate(s, doc.page)?.id ?? []) : [],
|
||||
);
|
||||
}
|
||||
|
||||
/** Templates tried for a page whose story would be cut, before one that cuts it is taken. */
|
||||
const STORY_TRIES = 4;
|
||||
|
||||
/** An entry's page on the best template but the one on screen and its neighbours'. */
|
||||
function relayEntry(s: TemplatedSetting, doc: BookDocument, index: number, free: BookSpread): BookSpread | null {
|
||||
const entry = s.placed.find((e) => e.id === free.entryId);
|
||||
const own = doc.spreads[index];
|
||||
if (!entry || !own) return null;
|
||||
const current = matchDecoratedTemplate(own, doc.page)?.id;
|
||||
const around = neighbours(doc, index);
|
||||
const avoid = [...(current ? [current] : []), ...around];
|
||||
const page = { photos: photosOn(free, s.placed), story: storyOn(free, entry.id), entries: s.placed };
|
||||
const pick = (more: readonly string[]): TemplatePickCtx => ({
|
||||
archetype: s.archetype,
|
||||
page: s.c.page,
|
||||
theme: s.c.look.theme,
|
||||
history: around.slice(0, 1),
|
||||
calm: s.calm,
|
||||
seed: s.c.directives.seed ?? 0,
|
||||
key: `relay:${entry.id}`,
|
||||
avoid: [...avoid, ...more],
|
||||
});
|
||||
let laid: LaidPage | null = null;
|
||||
const tried: string[] = [];
|
||||
for (let n = 0; n < STORY_TRIES; n++) {
|
||||
const next = layEntryPage(s.c, entry, page, pick(tried));
|
||||
laid ??= next;
|
||||
if (!next.rest || !next.template) {
|
||||
laid = next;
|
||||
break;
|
||||
}
|
||||
tried.push(next.template.id);
|
||||
}
|
||||
if (!laid) return null;
|
||||
return { ...s.finish(laid.spread, !!laid.template).spread, entryId: entry.id };
|
||||
}
|
||||
|
||||
/** The cover on the next cover template of its archetype, then of its kin, for the photos there are. */
|
||||
function relayCover(s: TemplatedSetting, doc: BookDocument, index: number, input: AutoDesignedInput) {
|
||||
const own = doc.spreads[index];
|
||||
if (!own) return null;
|
||||
const others = doc.spreads.flatMap((sp) => (sp === own ? [] : photosOn(sp, s.placed)));
|
||||
const photos = [...new Map([...photosOn(own, s.placed), ...others].map((p) => [p.photoId, p])).values()].slice(0, 8);
|
||||
const order = [s.archetype, ...ARCHETYPE_KIN[s.archetype]].flatMap((a) => decoratedTemplatesFor('single', a));
|
||||
const fits = order.filter(
|
||||
(t) => templatePhotoSlots(t) <= photos.length && (photos.length > 0 || !templatePhotoSlots(t)),
|
||||
);
|
||||
if (!fits.length) return null;
|
||||
const current = matchDecoratedTemplate(own, doc.page)?.id;
|
||||
const at = fits.findIndex((t) => t.id === current);
|
||||
// A cover the engine cannot read takes the first of the archetype's, a portrait one for a tall photo first.
|
||||
const tall = photos[0] && isPortrait(photos[0]) ? fits.find((t) => /-portrait$|^cutout-/.test(t.id)) : undefined;
|
||||
const tpl = at >= 0 ? fits[(at + 1) % fits.length] : (tall ?? fits[0]);
|
||||
if (!tpl) return null;
|
||||
const ownTitle = doc.title !== input.title ? doc.title : undefined;
|
||||
const words = { title: input.title, subtitle: input.subtitle, ownTitle };
|
||||
return s.finish(coverOnTemplate(s.c, words, { photos, entries: s.placed }, tpl), true).spread;
|
||||
}
|
||||
|
||||
/** The look after this one in its cycle; the first for a look outside it (the classic one). */
|
||||
function nextLook<T extends string>(cycle: readonly T[], look: string | undefined): T {
|
||||
const at = look ? cycle.indexOf(look as T) : -1;
|
||||
return cycle[(at + 1) % cycle.length] as T;
|
||||
}
|
||||
|
||||
/** The summary or the countries in their next look. */
|
||||
function relayTravel(s: TemplatedSetting, free: BookSpread, kind: 'summary' | 'countries'): BookSpread | null {
|
||||
if (kind === 'summary') {
|
||||
const stats = free.elements.find((e) => e.kind === 'stats' && e.metrics.length > 2);
|
||||
const look = stats?.kind === 'stats' ? stats.look : undefined;
|
||||
const next = composeTravelSummary(s.c, nextLook<BookStatsLook>(SUMMARY_CYCLE, look));
|
||||
return next ? s.finish(next).spread : null;
|
||||
}
|
||||
const country = free.elements.find((e) => e.kind === 'countries');
|
||||
const look = country?.kind === 'countries' ? country.look : undefined;
|
||||
const next = composeTravelCountries(s.c, nextLook<BookCountryLook>(COUNTRY_CYCLE, look));
|
||||
return next ? s.finish(next).spread : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of the book laid again on the designed templates, around its
|
||||
* locked elements, its id kept: null for a page the engine did not make
|
||||
* (stops, the first and last page, the back, a page made by hand), or when
|
||||
* the page and its locks would not fit.
|
||||
*/
|
||||
export function autoRelayoutSpread(
|
||||
doc: BookDocument,
|
||||
index: number,
|
||||
input: AutoDesignedInput,
|
||||
opts: Pick<AutoDesignedOptions, 'seed'> = {},
|
||||
): BookSpread | null {
|
||||
const spread = doc.spreads[index];
|
||||
const kind = autoRelayoutKind(spread);
|
||||
if (!spread || !kind) return null;
|
||||
const setting = templatedSetting(
|
||||
doc,
|
||||
designInput({ ...input, page: doc.page }, doc.theme),
|
||||
directivesOf(doc.theme, opts.seed),
|
||||
);
|
||||
return layoutAroundLocks(spread, (free) => {
|
||||
const next =
|
||||
kind === 'entry'
|
||||
? relayEntry(setting, doc, index, free)
|
||||
: kind === 'cover'
|
||||
? relayCover(setting, doc, index, input)
|
||||
: relayTravel(setting, free, kind);
|
||||
return next ? { ...next, id: spread.id } : null;
|
||||
});
|
||||
}
|
||||
@@ -16,6 +16,8 @@ export interface AutoPhoto {
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
caption?: string | null;
|
||||
/** When it was taken (ISO), where the gallery knows: the templated auto layout shares loose photos out by day. */
|
||||
takenAt?: string | null;
|
||||
}
|
||||
|
||||
export interface AutoEntry {
|
||||
|
||||
@@ -93,6 +93,8 @@ export const bookDesignPlanRowSchema = z.object({
|
||||
entryId: z.number().int().positive().nullable(),
|
||||
photoIds: z.array(z.number().int().positive()).max(MAX_SPREAD_ELEMENTS),
|
||||
findings: z.object({ severe: z.number().int().nonnegative(), warn: z.number().int().nonnegative() }),
|
||||
/** The designed template the page was laid on, for a design with `layouts: 'templates'`. */
|
||||
template: z.string().max(40).optional(),
|
||||
});
|
||||
export type BookDesignPlanRowOut = z.infer<typeof bookDesignPlanRowSchema>;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { buildBook, distributeGallery } from '../auto/auto-book';
|
||||
import type { AutoEntry, AutoInput, AutoPhoto } from '../auto/auto-types';
|
||||
import type { BookCoverArchetype } from '../book-archetypes';
|
||||
import { briefFindings, type BookBriefArt } from '../book-findings-art';
|
||||
import { bookLevelFindings, sortBookFindings } from '../book-findings-design';
|
||||
import { withRealWeight } from '../book-fonts';
|
||||
@@ -11,6 +12,8 @@ import type { BookTravelContext } from '../book-travel';
|
||||
import { MAX_BOOK_TITLE, MAX_SPREADS, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
|
||||
import { pageFindings } from '../describe-spread';
|
||||
import type { PhotoPickCandidate } from '../photo-pick';
|
||||
import { isBookFontFamily, mutedOn, readableOn } from '../theme/book-theme';
|
||||
import type { BookTheme } from '../theme/book-theme.schema';
|
||||
import { designArt, livelyLook, type BookDesignSticker } from './design-art';
|
||||
import type { ComposeCtx } from './design-compose';
|
||||
import type { CoverMaterial } from './design-cover-kit';
|
||||
@@ -25,9 +28,18 @@ import { isLivelyBeat } from './design-lively-pacing';
|
||||
import { keepsStored, mergeDesign, type BookDesignPlanRow, type DesignedSpread } from './design-merge';
|
||||
import { applyPageOverrides } from './design-overrides';
|
||||
import type { BookDesignBeat } from './design-pacing';
|
||||
import { choosePhotos, type BookDesignLeftOut } from './design-photos';
|
||||
import {
|
||||
TEMPLATED_ENTRY_PHOTOS,
|
||||
choosePhotos,
|
||||
shareGalleryByDay,
|
||||
type BookDesignLeftOut,
|
||||
type DesignPhotoChoice,
|
||||
} from './design-photos';
|
||||
import { repairSpread } from './design-repair';
|
||||
import { designGrid, designLook, paperBackground, type BookDesignLook } from './design-style';
|
||||
import { archetypeOfTheme } from './design-template-pick';
|
||||
import { composeTemplatedInner } from './design-templated';
|
||||
import { composeTemplatedCover, composeTravelBack } from './design-templated-framing';
|
||||
import { themeDesignedSpread, unlinkDrifted } from './design-theme';
|
||||
|
||||
/**
|
||||
@@ -64,6 +76,12 @@ export interface BookDesignInput extends AutoInput {
|
||||
* for the stickers a decorated design places; without it nothing is stuck on.
|
||||
*/
|
||||
stickers?: readonly BookDesignSticker[];
|
||||
/**
|
||||
* The book's own theme, recoloured or not, for a book laid on the designed
|
||||
* templates (`layouts: 'templates'`): it is kept whole in place of the
|
||||
* style's preset. The composed pages ignore it.
|
||||
*/
|
||||
theme?: BookTheme;
|
||||
}
|
||||
|
||||
export interface BookDesignResult {
|
||||
@@ -117,6 +135,7 @@ function pageFor(doc: BookDocument | null, input: BookDesignInput, directives: B
|
||||
*/
|
||||
function classicOnly(d: BookDesignDirectives): boolean {
|
||||
if (d.style !== 'classic' || d.photos || d.palette || d.type || d.brief) return false;
|
||||
if (d.layouts === 'templates') return false;
|
||||
if (d.pages || d.cover || d.text || d.travel) return false;
|
||||
return d.chapters === 'entry' && d.density === 'balanced';
|
||||
}
|
||||
@@ -230,6 +249,7 @@ export function designBook(
|
||||
const directives = withBriefTheme(bookDesignDirectivesSchema.parse(raw));
|
||||
const entries = inScope(input.entries, directives.scope);
|
||||
if (classicOnly(directives)) return classicDesign(doc, input, directives, entries);
|
||||
if (directives.layouts === 'templates') return templatedDesign(doc, input, raw);
|
||||
|
||||
const newId = input.newId ?? bookElementId;
|
||||
const page = pageFor(doc, input, directives);
|
||||
@@ -381,3 +401,202 @@ const TITLE_PAGE_STYLES: ReadonlySet<string> = new Set(['editorial', 'journal',
|
||||
function blank(c: ComposeCtx, role: 'first' | 'last'): BookSpread {
|
||||
return { id: c.newId('sp'), role, background: paperBackground(c.look), elements: [], parked: [], entryId: null };
|
||||
}
|
||||
|
||||
// ── the book laid on the designed templates ────────────────────────────────
|
||||
|
||||
/** The look with the book's own theme kept whole: its palette, its faces, its frames. */
|
||||
function withOwnTheme(look: BookDesignLook, theme: BookTheme | undefined): BookDesignLook {
|
||||
if (!theme) return look;
|
||||
const paper = theme.palette.paper.toLowerCase();
|
||||
const ink = readableOn(paper, theme.palette.ink);
|
||||
const face = (f: string, fallback: BookDesignLook['heading']) => (isBookFontFamily(f) ? f : fallback);
|
||||
return {
|
||||
...look,
|
||||
paper,
|
||||
ink,
|
||||
muted: mutedOn(paper, ink),
|
||||
accent: theme.palette.accent,
|
||||
heading: face(theme.fonts.heading.font, look.heading),
|
||||
body: face(theme.fonts.body.font, look.body),
|
||||
frameStyle: theme.frameStyle,
|
||||
tilt: theme.tilt,
|
||||
theme,
|
||||
};
|
||||
}
|
||||
|
||||
/** The directives with each entry's photos held to what its pages on the templates take. */
|
||||
const cappedPhotos = (d: BookDesignDirectives): BookDesignDirectives => ({
|
||||
...d,
|
||||
photos: { pick: 'best', ...d.photos, perChapterMax: d.photos?.perChapterMax ?? TEMPLATED_ENTRY_PHOTOS },
|
||||
});
|
||||
|
||||
/** The cover, the first page, the inner pages, the last page and the back of a templated book, in order. */
|
||||
function templatedPages(
|
||||
c: ComposeCtx,
|
||||
input: BookDesignInput,
|
||||
parts: { placed: AutoEntry[]; usedPhotos: AutoPhoto[]; inner: DesignedSpread[]; archetype: BookCoverArchetype },
|
||||
): DesignedSpread[] {
|
||||
const { directives } = c;
|
||||
const words = {
|
||||
title: input.title,
|
||||
subtitle: input.subtitle,
|
||||
ownTitle: directives.cover?.title,
|
||||
ownSubtitle: directives.cover?.subtitle,
|
||||
};
|
||||
const cover = coverPhoto(input, directives, parts.usedPhotos);
|
||||
const material = coverMaterial(cover, parts.usedPhotos, parts.placed);
|
||||
const front = directives.cover?.template
|
||||
? { spread: composeCover(c, words, cover, material), template: null }
|
||||
: composeTemplatedCover(c, words, material, parts.archetype);
|
||||
const titlePage = TITLE_PAGE_STYLES.has(c.look.theme.id ?? directives.style);
|
||||
const first = titlePage ? composeTitlePage(c, words, parts.placed) : blank(c, 'first');
|
||||
const page = (spread: BookSpread, beat: BookDesignBeat): DesignedSpread => ({
|
||||
spread,
|
||||
beat,
|
||||
chapter: null,
|
||||
findings: [],
|
||||
});
|
||||
return [
|
||||
{ ...page(front.spread, 'cover'), ...(front.template ? { template: front.template } : {}) },
|
||||
page(first, 'first'),
|
||||
...parts.inner,
|
||||
page(blank(c, 'last'), 'last'),
|
||||
page(composeTravelBack(c, words, parts.placed), 'back'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The book laid on the Studio's designed templates (`layouts: 'templates'`):
|
||||
* the cover on a cover template of the theme's archetype, every chapter on
|
||||
* its spreads, the summary, the countries and the back cover in the newer
|
||||
* travel looks. The rest is the composer's: the scope, the photo picker, the
|
||||
* page budget, the stored pages kept, the repair and the findings.
|
||||
*/
|
||||
/** What a book on the designed templates is laid with: its context, archetype, entries and photos. */
|
||||
export interface TemplatedSetting {
|
||||
c: ComposeCtx;
|
||||
archetype: BookCoverArchetype;
|
||||
/** The book counts as calm for template choice. */
|
||||
calm: boolean;
|
||||
/** The entries in date order, the gallery shared out to them. */
|
||||
placed: AutoEntry[];
|
||||
/** Loose photos no entry had room for. */
|
||||
rest: AutoPhoto[];
|
||||
choice: DesignPhotoChoice;
|
||||
/** A page finished as the design finishes it: a templated one repaired, a composed one linked to the theme too. */
|
||||
finish: (spread: BookSpread, templated?: boolean) => { spread: BookSpread; findings: BookFinding[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The setting of a book laid on the designed templates, read from the
|
||||
* stored book, the journey and the directives: what the whole book is laid
|
||||
* with, and what laying one of its pages again needs.
|
||||
*/
|
||||
export function templatedSetting(
|
||||
doc: BookDocument | null,
|
||||
input: BookDesignInput,
|
||||
raw: BookDesignDirectivesInput,
|
||||
): TemplatedSetting {
|
||||
const directives = withBriefTheme(bookDesignDirectivesSchema.parse(raw));
|
||||
const entries = inScope(input.entries, directives.scope);
|
||||
const newId = input.newId ?? bookElementId;
|
||||
const page = pageFor(doc, input, directives);
|
||||
const shared = shareGalleryByDay(byDate(entries), input.gallery ?? [], input.candidates);
|
||||
const placed = shared.entries;
|
||||
const choice = choosePhotos(placed, input.candidates, cappedPhotos(directives), input.photos);
|
||||
const usedPhotos = [...choice.byEntry.values()].flat();
|
||||
const usedInfos = input.photos.filter((p) => usedPhotos.some((u) => u.photoId === p.photoId));
|
||||
const plain = withOwnTheme(designLook(directives, page, usedInfos), input.theme);
|
||||
const art = designArt(directives, plain.theme, input.stickers);
|
||||
const look = livelyLook(plain, art, directives);
|
||||
const c: ComposeCtx = {
|
||||
look,
|
||||
page,
|
||||
grid: designGrid(page),
|
||||
newId,
|
||||
locale: input.locale,
|
||||
infos: new Map(input.photos.map((p) => [p.photoId, p])),
|
||||
travel: travelOf(input, placed),
|
||||
directives,
|
||||
notes: [],
|
||||
art,
|
||||
};
|
||||
const archetype = directives.cover?.archetype ?? archetypeOfTheme(look.theme);
|
||||
// A rich theme designed without a brief is lively for its templates, so a poster book gets its loud pages at all.
|
||||
const calm = art.energy === 'calm' && !(look.theme.decoration === 'rich' && !art.briefed);
|
||||
const repairCtx = { page, photos: input.photos, look, newId };
|
||||
// A templated page has its colours linked to the theme by its template, so it is repaired, not relinked.
|
||||
const finish = (spread: BookSpread, templated = false) => {
|
||||
const fixed = repairSpread(templated ? spread : themeDesignedSpread(spread, look), repairCtx);
|
||||
const weighted = { ...fixed.spread, elements: fixed.spread.elements.map(withRealWeight) };
|
||||
return { spread: templated ? weighted : unlinkDrifted(weighted, look.theme), findings: fixed.findings };
|
||||
};
|
||||
return { c, archetype, calm, placed, rest: shared.rest, choice, finish };
|
||||
}
|
||||
|
||||
/**
|
||||
* The book laid on the Studio's designed templates (`layouts: 'templates'`):
|
||||
* the cover on a cover template of the theme's archetype, every chapter on
|
||||
* its spreads, the summary, the countries and the back cover in the newer
|
||||
* travel looks. The rest is the composer's: the scope, the photo picker, the
|
||||
* page budget, the stored pages kept, the repair and the findings.
|
||||
*/
|
||||
function templatedDesign(
|
||||
doc: BookDocument | null,
|
||||
input: BookDesignInput,
|
||||
raw: BookDesignDirectivesInput,
|
||||
): BookDesignResult {
|
||||
const { c, archetype, calm, placed, rest, choice, finish } = templatedSetting(doc, input, raw);
|
||||
const needs: FramingNeeds = { room: roomFor(doc, c.directives.keep), perStations: stationsPerSpread(c) };
|
||||
const inner = composeTemplatedInner(c, placed, choice, input, needs, { archetype, calm, rest });
|
||||
const usedPhotos = [...choice.byEntry.values()].flat();
|
||||
const designed = templatedPages(c, input, { placed, usedPhotos, inner: inner.spreads, archetype });
|
||||
const done = designed.map((d): DesignedSpread => ({ ...d, ...finish(d.spread, !!d.template) }));
|
||||
const leftOut = [...choice.leftOut, ...inner.leftOut];
|
||||
return templatedResult(doc, input, { c, done, finish, placed, leftOut, notes: inner.notes });
|
||||
}
|
||||
|
||||
interface TemplatedParts {
|
||||
c: ComposeCtx;
|
||||
done: DesignedSpread[];
|
||||
finish: (spread: BookSpread) => { spread: BookSpread; findings: BookFinding[] };
|
||||
placed: AutoEntry[];
|
||||
leftOut: BookDesignLeftOut[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** The templated pages over the stored book, the overrides laid on them, and what is left to say. */
|
||||
function templatedResult(doc: BookDocument | null, input: BookDesignInput, parts: TemplatedParts): BookDesignResult {
|
||||
const { c, finish, placed, notes } = parts;
|
||||
const { look, directives, page } = c;
|
||||
const designed = withinRoom(parts.done, roomFor(doc, directives.keep), notes);
|
||||
const laid = mergeDesign(doc, designed, directives.keep);
|
||||
const built: BookDocument = {
|
||||
version: 1,
|
||||
title: (directives.cover?.title ?? input.title).slice(0, MAX_BOOK_TITLE),
|
||||
page: { ...page, pageNumbers: { ...page.pageNumbers, font: look.body, startAt: 1 } },
|
||||
spreads: laid.spreads,
|
||||
...(doc?.layouts ? { layouts: doc.layouts } : {}),
|
||||
theme: look.theme,
|
||||
};
|
||||
const overrides = directives.pages?.overrides ?? [];
|
||||
const env = { c, entries: placed, finish, ops: { photos: input.photos, newId: c.newId } };
|
||||
const merged = overrides.length
|
||||
? applyPageOverrides(built, laid.plan, overrides, env)
|
||||
: { doc: built, plan: laid.plan, notes: [] };
|
||||
const residual = overrides.length
|
||||
? redesignedFindings(merged.doc, merged.plan, input.photos)
|
||||
: designed.flatMap((d) => d.findings.filter((f) => f.severity !== 'info'));
|
||||
const judged = briefOf(c);
|
||||
const brief = judged ? briefFindings(merged.doc, judged, briefScope(merged.plan)) : [];
|
||||
const used = [...new Set(merged.plan.flatMap((r) => (r.action === 'drop' ? [] : r.photoIds)))];
|
||||
return {
|
||||
doc: merged.doc,
|
||||
plan: merged.plan,
|
||||
style: { paper: look.paper, ink: look.ink, accent: look.accent, heading: look.heading, body: look.body },
|
||||
photos: { used, left_out: parts.leftOut },
|
||||
findings: sortBookFindings([...residual, ...bookLevelFindings(merged.doc), ...brief]),
|
||||
notes: [...new Set([...notes, ...c.notes, ...merged.notes])],
|
||||
...(judged ? { brief: judged } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,6 +158,17 @@ export const bookDesignDirectivesSchema = z.strictObject({
|
||||
/** Only honoured for a blank or a new book. */
|
||||
preset: bookPagePresetSchema.optional(),
|
||||
seed: z.number().int().min(0).max(2147483647).optional(),
|
||||
/**
|
||||
* What the pages are laid on: the engine's own composed pages (the default),
|
||||
* or the Studio's designed templates in the archetype of the book's theme,
|
||||
* with the summary, countries and marks in the newer travel looks.
|
||||
*/
|
||||
layouts: z
|
||||
.enum(['composed', 'templates'])
|
||||
.describe(
|
||||
"composed: the engine's own pages (default). templates: every page laid on the Studio's designed templates in the archetype of the book's theme.",
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
export type BookDesignDirectives = z.infer<typeof bookDesignDirectivesSchema>;
|
||||
export type BookDesignDirectivesInput = z.input<typeof bookDesignDirectivesSchema>;
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface BookDesignPlanRow {
|
||||
entryId: number | null;
|
||||
photoIds: number[];
|
||||
findings: { severe: number; warn: number };
|
||||
/** The decorated template the page was laid on, when the design laid it on one. */
|
||||
template?: string;
|
||||
}
|
||||
|
||||
export interface DesignedSpread {
|
||||
@@ -30,6 +32,8 @@ export interface DesignedSpread {
|
||||
beat: BookDesignBeat;
|
||||
chapter: string | null;
|
||||
findings: BookFinding[];
|
||||
/** The decorated template the page was laid on (`layouts: 'templates'`). */
|
||||
template?: string;
|
||||
}
|
||||
|
||||
const photoIdsOf = (spread: BookSpread) =>
|
||||
@@ -55,6 +59,7 @@ interface Placed {
|
||||
beat: BookDesignBeat;
|
||||
chapter: string | null;
|
||||
findings: BookFinding[];
|
||||
template?: string;
|
||||
}
|
||||
|
||||
const placedOf = (d: DesignedSpread, action: BookDesignAction, id?: string): Placed => ({
|
||||
@@ -63,6 +68,7 @@ const placedOf = (d: DesignedSpread, action: BookDesignAction, id?: string): Pla
|
||||
beat: d.beat,
|
||||
chapter: d.chapter,
|
||||
findings: d.findings,
|
||||
...(d.template ? { template: d.template } : {}),
|
||||
});
|
||||
|
||||
const keptOf = (spread: BookSpread, beat: BookDesignBeat): Placed => ({
|
||||
@@ -133,6 +139,7 @@ export function mergeDesign(
|
||||
entryId: p.spread.entryId,
|
||||
photoIds: photoIdsOf(p.spread),
|
||||
findings: { severe: count(p.findings, 'severe'), warn: count(p.findings, 'warn') },
|
||||
...(p.template ? { template: p.template } : {}),
|
||||
}));
|
||||
for (const s of dropped) {
|
||||
plan.push({
|
||||
|
||||
@@ -190,3 +190,63 @@ export function burstOf(photos: readonly AutoPhoto[], candidates: readonly Photo
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The most photos one entry's pages take on the designed templates; the rest are left out as `chapter_max`. */
|
||||
export const TEMPLATED_ENTRY_PHOTOS = 12;
|
||||
|
||||
/** The day a photo was taken on, from the photo or from the picker's facts of it. */
|
||||
function dayOf(photo: AutoPhoto, candidates: readonly PhotoPickCandidate[]): string | null {
|
||||
const at = photo.takenAt ?? candidates.find((c) => c.photoId === photo.photoId)?.takenAt ?? null;
|
||||
return at ? at.slice(0, 10) : null;
|
||||
}
|
||||
|
||||
/** Days between two ISO dates, for the nearest entry to a photo; far for an undated one. */
|
||||
function daysApart(a: string | null, b: string | null): number {
|
||||
if (!a || !b) return 1e6;
|
||||
return Math.abs(Date.parse(a) - Date.parse(b)) / 86_400_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* The journey's loose photos shared out to its entries, for a book laid on
|
||||
* the designed templates: each first to the entry it is linked to, then to
|
||||
* the entry of the day it was taken, then, in the order they were taken, to
|
||||
* the entry that has the fewest photos (the nearest in time of those), never
|
||||
* past `cap` photos an entry. What no entry has room for comes back as
|
||||
* `rest`, for the book's closing pages of more moments. Unlike
|
||||
* `distributeGallery` no photo is dropped on the way.
|
||||
*/
|
||||
export function shareGalleryByDay(
|
||||
entries: readonly AutoEntry[],
|
||||
gallery: readonly AutoPhoto[],
|
||||
candidates: readonly PhotoPickCandidate[],
|
||||
cap = TEMPLATED_ENTRY_PHOTOS,
|
||||
): { entries: AutoEntry[]; rest: AutoPhoto[] } {
|
||||
const out = entries.map((e) => ({ ...e, photos: [...e.photos] }));
|
||||
const placed = new Set(out.flatMap((e) => e.photos.map((p) => p.photoId)));
|
||||
const loose = gallery.filter((p) => !placed.has(p.photoId));
|
||||
const room = (e: AutoEntry) => e.photos.length < cap;
|
||||
const later: AutoPhoto[] = [];
|
||||
for (const photo of loose) {
|
||||
const linked = candidates.find((c) => c.photoId === photo.photoId)?.entryIds ?? [];
|
||||
const day = dayOf(photo, candidates);
|
||||
const home =
|
||||
out.find((e) => linked.includes(e.id) && room(e)) ?? out.find((e) => day !== null && e.date === day && room(e));
|
||||
if (home) home.photos.push(photo);
|
||||
else later.push(photo);
|
||||
}
|
||||
const dated = [...later].sort((a, b) =>
|
||||
(dayOf(a, candidates) ?? '9999').localeCompare(dayOf(b, candidates) ?? '9999'),
|
||||
);
|
||||
const rest: AutoPhoto[] = [];
|
||||
for (const photo of dated) {
|
||||
const day = dayOf(photo, candidates);
|
||||
const open = out.filter(room);
|
||||
const fewest = Math.min(...open.map((e) => e.photos.length));
|
||||
const home = open
|
||||
.filter((e) => e.photos.length === fewest)
|
||||
.sort((a, b) => daysApart(a.date, day) - daysApart(b.date, day))[0];
|
||||
if (home) home.photos.push(photo);
|
||||
else rest.push(photo);
|
||||
}
|
||||
return { entries: out, rest };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import type { AutoPhoto } from '../auto/auto-types';
|
||||
import { BOOK_COVER_ARCHETYPES } from '../book-archetypes';
|
||||
import { bookPageSetupForPreset } from '../book-pages';
|
||||
import { decoratedTemplatesFor, findDecoratedTemplate } from '../templates/template-catalogue';
|
||||
import { templatePhotoSlots, templateRoles, type BookDecoratedTemplate } from '../templates/template-format';
|
||||
import { bookThemeOf } from '../theme/book-theme';
|
||||
import { BOOK_THEME_IDS } from '../theme/book-theme.schema';
|
||||
import {
|
||||
ARCHETYPE_KIN,
|
||||
THEME_ARCHETYPE,
|
||||
archetypeOfTheme,
|
||||
bodyCapacity,
|
||||
chunkPhotos,
|
||||
chunkSizesOf,
|
||||
innerPool,
|
||||
pickCoverTemplate,
|
||||
pickInnerTemplate,
|
||||
templateEnergy,
|
||||
type TemplateChunk,
|
||||
type TemplatePickCtx,
|
||||
} from './design-template-pick';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/** Picking a decorated spread for a page's photos and words. */
|
||||
|
||||
const page = bookPageSetupForPreset('square-210');
|
||||
const wide = (photoId: number): AutoPhoto => ({ photoId, width: 4000, height: 3000 });
|
||||
const tall = (photoId: number): AutoPhoto => ({ photoId, width: 3000, height: 4000 });
|
||||
const photos = (n: number, at = 1) => Array.from({ length: n }, (_, i) => (i % 3 === 2 ? tall(at + i) : wide(at + i)));
|
||||
const STORY = 'We walked up from the harbour in the morning and found the old town still asleep. ';
|
||||
|
||||
const ctxOf = (over: Partial<TemplatePickCtx> = {}): TemplatePickCtx => ({
|
||||
archetype: 'spine-band',
|
||||
page,
|
||||
theme: bookThemeOf('fabric-classic'),
|
||||
history: [],
|
||||
calm: false,
|
||||
seed: 7,
|
||||
key: 'k',
|
||||
...over,
|
||||
});
|
||||
|
||||
const chunk = (n: number, story = '', mappable = false): TemplateChunk => ({ photos: photos(n), story, mappable });
|
||||
const roles = (t: BookDecoratedTemplate) => templateRoles(t) as readonly string[];
|
||||
|
||||
/** A book of pages picked one after the other, as the auto layout picks them. */
|
||||
function book(chunks: readonly TemplateChunk[], over: Partial<TemplatePickCtx> = {}): BookDecoratedTemplate[] {
|
||||
const out: BookDecoratedTemplate[] = [];
|
||||
chunks.forEach((c, i) => {
|
||||
const pick = pickInnerTemplate(c, ctxOf({ ...over, history: out.map((t) => t.id), key: `e${i}` }));
|
||||
if (pick) out.push(pick);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
const varied = Array.from({ length: 20 }, (_, i) =>
|
||||
chunk(i % 9, i % 3 === 0 ? STORY.repeat(1 + (i % 4)) : '', i % 5 === 0),
|
||||
);
|
||||
|
||||
describe('the template picker', () => {
|
||||
it('AUTOTPL-001: never picks a template that would leave a photo frame empty', () => {
|
||||
for (const archetype of BOOK_COVER_ARCHETYPES) {
|
||||
for (let n = 0; n <= 9; n++) {
|
||||
for (const story of ['', STORY]) {
|
||||
const pick = pickInnerTemplate(chunk(n, story), ctxOf({ archetype }));
|
||||
if (pick) expect(templatePhotoSlots(pick), `${archetype} ${n}`).toBeLessThanOrEqual(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTOTPL-002: a story always lands on a template with a story frame', () => {
|
||||
for (const archetype of BOOK_COVER_ARCHETYPES) {
|
||||
for (let n = 0; n <= 8; n++) {
|
||||
const pick = pickInnerTemplate(chunk(n, STORY), ctxOf({ archetype }));
|
||||
expect(pick, `${archetype} ${n}`).toBeDefined();
|
||||
expect(roles(pick as BookDecoratedTemplate)).toContain('body');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTOTPL-003: a map template only where there is a place to draw', () => {
|
||||
for (const archetype of BOOK_COVER_ARCHETYPES) {
|
||||
for (let n = 1; n <= 8; n++) {
|
||||
const pick = pickInnerTemplate(chunk(n), ctxOf({ archetype }));
|
||||
if (pick) expect(roles(pick), `${archetype} ${n}`).not.toContain('map');
|
||||
}
|
||||
}
|
||||
const mapped = [1, 2, 3, 4, 5].map((n) =>
|
||||
pickInnerTemplate(chunk(n, '', true), ctxOf({ archetype: 'field-notes' })),
|
||||
);
|
||||
expect(mapped.every((t) => t !== undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('AUTOTPL-004: never the same template twice running, never loud after busy, unless only that keeps every photo framed in the style', () => {
|
||||
const busy = (t: BookDecoratedTemplate) => ['loud', 'lively'].includes(templateEnergy(t));
|
||||
for (const archetype of BOOK_COVER_ARCHETYPES) {
|
||||
const picks = book(varied, { archetype, theme: null });
|
||||
expect(picks.length, archetype).toBe(varied.length);
|
||||
picks.forEach((t, i) => {
|
||||
const before = picks[i - 1];
|
||||
const page = varied[i];
|
||||
if (!before || !page) return;
|
||||
const keeps = (x: BookDecoratedTemplate) =>
|
||||
x.id !== before.id && !(templateEnergy(x) === 'loud' && busy(before));
|
||||
if (keeps(t)) return;
|
||||
// The rhythm gives way only where no template of the book's styles frames every photo and keeps it.
|
||||
const fits = (x: BookDecoratedTemplate) =>
|
||||
templatePhotoSlots(x) === page.photos.length &&
|
||||
(!page.story || roles(x).includes('body')) &&
|
||||
(!roles(x).includes('map') || page.mappable);
|
||||
expect(
|
||||
innerPool(archetype)
|
||||
.filter((x) => fits(x) && keeps(x))
|
||||
.map((x) => x.id),
|
||||
`${archetype} at ${i}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTOTPL-005: twenty varied entries are laid on at least eight different templates', () => {
|
||||
for (const archetype of BOOK_COVER_ARCHETYPES) {
|
||||
const picks = book(varied, { archetype });
|
||||
expect(new Set(picks.map((t) => t.id)).size, archetype).toBeGreaterThanOrEqual(8);
|
||||
}
|
||||
});
|
||||
|
||||
it("AUTOTPL-006: the theme's own archetype wins over a kin one that fits as well", () => {
|
||||
for (const archetype of BOOK_COVER_ARCHETYPES) {
|
||||
for (const tpl of decoratedTemplatesFor('inner', archetype)) {
|
||||
if (roles(tpl).includes('map')) continue;
|
||||
const n = templatePhotoSlots(tpl);
|
||||
const pick = pickInnerTemplate(
|
||||
{ photos: photos(n), story: '', mappable: true },
|
||||
ctxOf({ archetype, theme: null }),
|
||||
);
|
||||
expect(pick?.archetype, `${archetype} ${n}`).toBe(archetype);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTOTPL-007: the same seed picks the same templates, another seed others', () => {
|
||||
const one = book(varied, { seed: 1 }).map((t) => t.id);
|
||||
expect(book(varied, { seed: 1 }).map((t) => t.id)).toEqual(one);
|
||||
const others = [2, 3, 4, 5, 6].map((seed) =>
|
||||
book(varied, { seed })
|
||||
.map((t) => t.id)
|
||||
.join(),
|
||||
);
|
||||
expect(others.some((o) => o !== one.join())).toBe(true);
|
||||
});
|
||||
|
||||
it('AUTOTPL-008: every theme has an archetype, and every archetype kin of its own kind', () => {
|
||||
for (const id of BOOK_THEME_IDS) expect(BOOK_COVER_ARCHETYPES).toContain(THEME_ARCHETYPE[id]);
|
||||
expect(THEME_ARCHETYPE).toMatchObject({
|
||||
riso: 'riso',
|
||||
magazine: 'editorial',
|
||||
'retro-travel': 'vintage-poster',
|
||||
midnight: 'night-film',
|
||||
'field-notes': 'field-notes',
|
||||
});
|
||||
for (const a of BOOK_COVER_ARCHETYPES) {
|
||||
expect(ARCHETYPE_KIN[a].length).toBeGreaterThan(0);
|
||||
expect(ARCHETYPE_KIN[a]).not.toContain(a);
|
||||
}
|
||||
expect(ARCHETYPE_KIN['night-film']).toEqual(['editorial', 'minimal-serif']);
|
||||
});
|
||||
|
||||
it('AUTOTPL-009: a story frame holds more the larger it is drawn', () => {
|
||||
const base = findDecoratedTemplate('story-quote') as BookDecoratedTemplate;
|
||||
const grown = (k: number): BookDecoratedTemplate => ({
|
||||
...base,
|
||||
id: `story-quote-x${k}`,
|
||||
elements: base.elements.map((e) => (e.role === 'body' ? { ...e, frame: { ...e.frame, h: e.frame.h * k } } : e)),
|
||||
});
|
||||
const sizes = [0.5, 1, 1.5, 2].map((k) => bodyCapacity(grown(k), page));
|
||||
for (let i = 1; i < sizes.length; i++) expect(sizes[i] as number).toBeGreaterThanOrEqual(sizes[i - 1] as number);
|
||||
expect(sizes[3] as number).toBeGreaterThan(sizes[0] as number);
|
||||
expect(bodyCapacity(findDecoratedTemplate('grid-tight') as BookDecoratedTemplate, page)).toBe(0);
|
||||
});
|
||||
|
||||
it('AUTOTPL-010: photos are cut into pages that leave no frame empty and park as few as there can be', () => {
|
||||
const sizes = { any: new Set([0, 1, 2, 3, 4, 5, 6, 7, 8]), story: new Set([0, 1, 2, 3, 4, 6]) };
|
||||
for (let n = 1; n <= 20; n++) {
|
||||
for (const story of [false, true]) {
|
||||
const cut = chunkPhotos(n, story, sizes);
|
||||
expect(cut.sizes.reduce((a, b) => a + b, 0) + cut.parked).toBe(n);
|
||||
expect(cut.parked, `${n}`).toBe(0);
|
||||
if (story) expect(sizes.story.has(cut.sizes[0] as number)).toBe(true);
|
||||
}
|
||||
}
|
||||
expect(chunkPhotos(12, false, sizes).sizes).toEqual([6, 6]);
|
||||
expect(chunkPhotos(5, true, sizes).sizes).toEqual([2, 3]);
|
||||
// A story too long for a small page's frame goes on a page of words of its own.
|
||||
expect(chunkPhotos(2, true, { ...sizes, pageCost: (s, first) => (first && s > 0 ? 30 : 0) }).sizes).toEqual([0, 2]);
|
||||
expect(chunkPhotos(3, false, { any: new Set([2]), story: new Set() })).toEqual({ sizes: [2], parked: 1 });
|
||||
});
|
||||
|
||||
it('AUTOTPL-011: a cover template never leaves a frame empty, and with no photo only one drawn for none', () => {
|
||||
for (const archetype of BOOK_COVER_ARCHETYPES) {
|
||||
for (let n = 0; n <= 5; n++) {
|
||||
const pick = pickCoverTemplate({ archetype, photos: n, portrait: false, seed: 3 });
|
||||
if (pick) expect(templatePhotoSlots(pick)).toBeLessThanOrEqual(n);
|
||||
if (n >= 4) expect(pick, `${archetype} ${n}`).toBeDefined();
|
||||
}
|
||||
}
|
||||
expect(pickCoverTemplate({ archetype: 'postcard', photos: 0, portrait: false, seed: 1 })?.id).toBe(
|
||||
'postcard-coast',
|
||||
);
|
||||
expect(pickCoverTemplate({ archetype: 'riso', photos: 0, portrait: false, seed: 1 })).toBeUndefined();
|
||||
expect(pickCoverTemplate({ archetype: 'minimal-serif', photos: 1, portrait: true, seed: 1 })?.id).toBe(
|
||||
'minimal-serif-portrait',
|
||||
);
|
||||
});
|
||||
|
||||
it("AUTOTPL-012: a theme of the book's own takes the archetype of the theme it still sets the faces of", () => {
|
||||
expect(archetypeOfTheme({ ...bookThemeOf('midnight'), id: null })).toBe('night-film');
|
||||
const odd = bookThemeOf('riso');
|
||||
const own = { ...odd, id: null, fonts: { ...odd.fonts, display: { font: 'bodoni' }, heading: { font: 'bodoni' } } };
|
||||
expect(archetypeOfTheme(own)).toBe('spine-band');
|
||||
expect(archetypeOfTheme(null)).toBe('spine-band');
|
||||
expect(innerPool('riso').every((t) => ['riso', 'poster-pop', 'cutout'].includes(t.archetype))).toBe(true);
|
||||
expect([...chunkSizesOf(innerPool('riso')).story]).toContain(0);
|
||||
});
|
||||
|
||||
it("AUTOTPL-013: a page the book's own styles can frame every photo of stays in them, whatever the rhythm", () => {
|
||||
for (const archetype of BOOK_COVER_ARCHETYPES) {
|
||||
const kin = [archetype, ...ARCHETYPE_KIN[archetype]];
|
||||
for (let n = 0; n <= 12; n++) {
|
||||
for (const story of ['', STORY]) {
|
||||
const frames = (t: BookDecoratedTemplate) =>
|
||||
templatePhotoSlots(t) === n && (!story || roles(t).includes('body')) && !roles(t).includes('map');
|
||||
if (!innerPool(archetype).some(frames)) continue;
|
||||
const pick = pickInnerTemplate(chunk(n, story), ctxOf({ archetype, theme: null }));
|
||||
expect(pick && templatePhotoSlots(pick), `${archetype} ${n}`).toBe(n);
|
||||
expect(kin, `${archetype} ${n} ${story ? 'story' : ''}`).toContain(pick?.archetype);
|
||||
// The page before on its own is given up rather than a frame or the style.
|
||||
const again = pickInnerTemplate(chunk(n, story), ctxOf({ archetype, theme: null, history: [pick!.id] }));
|
||||
expect(again && templatePhotoSlots(again), `${archetype} ${n} again`).toBe(n);
|
||||
expect(kin).toContain(again?.archetype);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
import { isPortrait } from '../auto/auto-elements';
|
||||
import type { AutoPhoto } from '../auto/auto-types';
|
||||
import type { BookCoverArchetype } from '../book-archetypes';
|
||||
import { DESIGN_TEXT_HEADROOM, textHeightNeeded } from '../book-findings';
|
||||
import type { BookPageSetup, BookTextElement } from '../book.schema';
|
||||
import { layDecoratedTemplate } from '../templates/template-apply';
|
||||
import { decoratedTemplatesFor } from '../templates/template-catalogue';
|
||||
import { templatePhotoSlots, templateRoles, type BookDecoratedTemplate } from '../templates/template-format';
|
||||
import { themedElement } from '../theme/book-theme-apply';
|
||||
import type { BookTheme, BookThemeId } from '../theme/book-theme.schema';
|
||||
import { BOOK_THEME_PRESETS } from '../theme/book-themes.data';
|
||||
import { isLandscape } from './design-photos';
|
||||
import { designRng } from './design-random';
|
||||
|
||||
/**
|
||||
* Which decorated template a page of the auto layout is laid on.
|
||||
*
|
||||
* A book is in a theme, and a theme belongs to one of the twelve archetypes
|
||||
* the templates are drawn in (`THEME_ARCHETYPE`). Its pages are picked from
|
||||
* that archetype's templates and from those of the archetypes that sit well
|
||||
* beside it (`ARCHETYPE_KIN`), by what a page holds: how many photos, whether
|
||||
* a story has to fit, whether the stop can draw a map. Templates that would
|
||||
* leave an empty frame, lose the story or repeat the page before are never
|
||||
* candidates; of the rest the best is taken, the theme's own first, its
|
||||
* story fitting its body, its first frame the shape of the first photo, not
|
||||
* one used a few pages back, and calm where the book should breathe. Equal
|
||||
* ones are chosen by the design's seed, so the same journey and seed give the
|
||||
* same book and another seed another.
|
||||
*/
|
||||
|
||||
/** The archetype each built-in theme's pages are drawn in. */
|
||||
export const THEME_ARCHETYPE: Readonly<Record<BookThemeId, BookCoverArchetype>> = {
|
||||
'poster-pop': 'poster-pop',
|
||||
tropical: 'poster-pop',
|
||||
cutout: 'cutout',
|
||||
postcard: 'postcard',
|
||||
riviera: 'postcard',
|
||||
'fabric-classic': 'spine-band',
|
||||
alpine: 'spine-band',
|
||||
'minimal-serif': 'minimal-serif',
|
||||
nordic: 'minimal-serif',
|
||||
'grid-badge': 'grid-badge',
|
||||
'scrapbook-script': 'stamp-collage',
|
||||
'india-stamp': 'stamp-collage',
|
||||
riso: 'riso',
|
||||
magazine: 'editorial',
|
||||
'retro-travel': 'vintage-poster',
|
||||
midnight: 'night-film',
|
||||
'field-notes': 'field-notes',
|
||||
};
|
||||
|
||||
/** The archetypes whose spreads sit well in a book of another, nearest first. */
|
||||
export const ARCHETYPE_KIN: Readonly<Record<BookCoverArchetype, readonly BookCoverArchetype[]>> = {
|
||||
'spine-band': ['minimal-serif', 'postcard'],
|
||||
'poster-pop': ['cutout', 'riso', 'stamp-collage'],
|
||||
cutout: ['poster-pop', 'riso'],
|
||||
postcard: ['vintage-poster', 'spine-band'],
|
||||
'grid-badge': ['minimal-serif', 'stamp-collage'],
|
||||
'minimal-serif': ['editorial', 'spine-band'],
|
||||
'stamp-collage': ['field-notes', 'postcard'],
|
||||
riso: ['poster-pop', 'cutout'],
|
||||
editorial: ['minimal-serif', 'grid-badge'],
|
||||
'vintage-poster': ['postcard', 'stamp-collage'],
|
||||
'night-film': ['editorial', 'minimal-serif'],
|
||||
'field-notes': ['stamp-collage', 'spine-band'],
|
||||
};
|
||||
|
||||
/** The archetype a book without a recognisable theme takes. */
|
||||
const FALLBACK_ARCHETYPE: BookCoverArchetype = 'spine-band';
|
||||
|
||||
/**
|
||||
* The archetype of a book's theme: a built-in theme's own; a theme of the
|
||||
* book's own (no id) that of the built-in one whose faces it still sets,
|
||||
* else the calm bookish one.
|
||||
*/
|
||||
export function archetypeOfTheme(theme: BookTheme | null | undefined): BookCoverArchetype {
|
||||
if (!theme) return FALLBACK_ARCHETYPE;
|
||||
if (theme.id) return THEME_ARCHETYPE[theme.id];
|
||||
const kin = BOOK_THEME_PRESETS.find(
|
||||
(p) => p.fonts.display.font === theme.fonts.display.font && p.fonts.heading.font === theme.fonts.heading.font,
|
||||
);
|
||||
return kin ? THEME_ARCHETYPE[kin.id] : FALLBACK_ARCHETYPE;
|
||||
}
|
||||
|
||||
/** How loud a template is, from its tags: a loud poster, a lively collage, a graphic grid, a calm page. */
|
||||
type TemplateEnergy = 'loud' | 'lively' | 'medium' | 'calm';
|
||||
|
||||
/** A template's energy. Graphic pages, and the few drawn without a word for it, count as medium. */
|
||||
export function templateEnergy(tpl: Pick<BookDecoratedTemplate, 'tags'>): TemplateEnergy {
|
||||
if (tpl.tags.includes('loud')) return 'loud';
|
||||
if (tpl.tags.includes('lively')) return 'lively';
|
||||
if (tpl.tags.includes('calm') && !tpl.tags.includes('graphic')) return 'calm';
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
const isLoud = (e: TemplateEnergy | undefined) => e === 'loud';
|
||||
const isBusy = (e: TemplateEnergy | undefined) => e === 'loud' || e === 'lively';
|
||||
|
||||
/** A page format and theme as a cache key: what a laid template's measures depend on. */
|
||||
const formatKey = (page: BookPageSetup, theme: BookTheme | null | undefined) =>
|
||||
`${page.pageWidth}x${page.pageHeight}|${theme?.fonts.body.font ?? '-'}|${theme?.fonts.body.weight ?? '-'}`;
|
||||
|
||||
/** Words of an ordinary length, to measure a body frame by. */
|
||||
const FILLER =
|
||||
'We left early and walked along the water while the town woke up, stopped for coffee, ' +
|
||||
'and found the road that runs up the hill to the old walls and the view over the bay. ';
|
||||
|
||||
const laidBodies = new Map<string, BookTextElement | null>();
|
||||
|
||||
/** The body text of a template as it is laid on this format in this theme, empty; null without one. */
|
||||
function laidBody(tpl: BookDecoratedTemplate, page: BookPageSetup, theme: BookTheme | null | undefined) {
|
||||
const key = `${tpl.id}|${formatKey(page, theme)}`;
|
||||
if (laidBodies.has(key)) return laidBodies.get(key) ?? null;
|
||||
let n = 0;
|
||||
const laid = layDecoratedTemplate(tpl, page, theme ?? null, (p) => `${p}${++n}`);
|
||||
const found = laid.find((l) => l.role === 'body' && l.el.kind === 'text')?.el;
|
||||
const body = found?.kind === 'text' ? (theme ? themedElement(found, theme) : found) : null;
|
||||
laidBodies.set(key, body);
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Whether these words fit a laid body frame as it is set. */
|
||||
function fitsBody(body: BookTextElement, words: string): boolean {
|
||||
return textHeightNeeded({ ...body, text: words }, DESIGN_TEXT_HEADROOM) <= body.frame.h;
|
||||
}
|
||||
|
||||
const capacities = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* How many characters of ordinary running text a template's story frame
|
||||
* holds on this page format in this theme's body face: 0 for a template
|
||||
* without one. Measured once per template, format and face.
|
||||
*/
|
||||
export function bodyCapacity(tpl: BookDecoratedTemplate, page: BookPageSetup, theme?: BookTheme | null): number {
|
||||
const key = `${tpl.id}|${formatKey(page, theme)}`;
|
||||
const known = capacities.get(key);
|
||||
if (known !== undefined) return known;
|
||||
const body = laidBody(tpl, page, theme);
|
||||
let lo = 0;
|
||||
if (body) {
|
||||
let hi = 4000;
|
||||
while (lo < hi) {
|
||||
const mid = Math.ceil((lo + hi) / 2);
|
||||
if (fitsBody(body, FILLER.repeat(Math.ceil(mid / FILLER.length)).slice(0, mid))) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
}
|
||||
capacities.set(key, lo);
|
||||
return lo;
|
||||
}
|
||||
|
||||
/** The parts a page of photos is cut into: how many photos each takes, the first one carrying the words. */
|
||||
interface PhotoChunking {
|
||||
sizes: number[];
|
||||
/** Photos that find no frame: noted and parked, never lost. */
|
||||
parked: number;
|
||||
}
|
||||
|
||||
/** What a chunking may use: the photo counts of the templates there are, those with a story frame apart. */
|
||||
export interface ChunkSizes {
|
||||
any: ReadonlySet<number>;
|
||||
story: ReadonlySet<number>;
|
||||
/**
|
||||
* What a page of this many photos costs beyond being a page: its story cut
|
||||
* short when it is the first, a template from outside the book's own pool.
|
||||
*/
|
||||
pageCost?: (size: number, first: boolean) => number;
|
||||
}
|
||||
|
||||
/** A spread costs this much, and every photo left without a frame more. */
|
||||
const SPREAD_COST = 10;
|
||||
const PARKED_COST = 15;
|
||||
/** The most spreads one entry's photos are cut into. */
|
||||
const MOST_CHUNKS = 3;
|
||||
|
||||
/**
|
||||
* An entry's photos cut into page-sized parts that leave no frame empty,
|
||||
* as few pages and as few photos without a frame as there can be: every
|
||||
* spread costs 10, every photo left over 15, and the first part, which
|
||||
* carries the story, is one a story template takes. Of two equal cuts the
|
||||
* more even one is kept, so twelve photos make six and six rather than ten
|
||||
* and two.
|
||||
*/
|
||||
export function chunkPhotos(n: number, hasStory: boolean, sizes: ChunkSizes): PhotoChunking {
|
||||
type Plan = { sizes: number[]; cost: number; parked: number };
|
||||
const firsts = [...(hasStory ? sizes.story : sizes.any)].filter((s) => s <= n);
|
||||
const rest = [...sizes.any].filter((s) => s > 0);
|
||||
let best: Plan = { sizes: [], cost: Infinity, parked: n };
|
||||
const better = (a: Plan, b: Plan) =>
|
||||
a.cost < b.cost ||
|
||||
(a.cost === b.cost && Math.max(...a.sizes) - Math.min(...a.sizes) < Math.max(...b.sizes) - Math.min(...b.sizes));
|
||||
const walk = (taken: number[], left: number, cost: number) => {
|
||||
const plan: Plan = { sizes: taken, cost: cost + left * PARKED_COST, parked: left };
|
||||
if (taken.length && (!best.sizes.length || better(plan, best))) best = plan;
|
||||
if (taken.length >= MOST_CHUNKS) return;
|
||||
const options = taken.length ? rest : firsts;
|
||||
for (const s of options) {
|
||||
if (s > left || (taken.length && s === 0)) continue;
|
||||
const extra = sizes.pageCost?.(s, !taken.length) ?? 0;
|
||||
walk([...taken, s], left - s, cost + SPREAD_COST + extra);
|
||||
}
|
||||
};
|
||||
walk([], n, 0);
|
||||
return best.sizes.length ? { sizes: best.sizes, parked: best.parked } : { sizes: [], parked: n };
|
||||
}
|
||||
|
||||
/** What a page to be laid holds, as the picker reads it. */
|
||||
export interface TemplateChunk {
|
||||
photos: readonly AutoPhoto[];
|
||||
/** The words a story frame must take; empty when the page sets none. */
|
||||
story: string;
|
||||
/** The stop or the journey has a place a map can draw. */
|
||||
mappable: boolean;
|
||||
}
|
||||
|
||||
/** What the picker knows of the book around the page. */
|
||||
export interface TemplatePickCtx {
|
||||
archetype: BookCoverArchetype;
|
||||
page: BookPageSetup;
|
||||
theme: BookTheme | null | undefined;
|
||||
/** Template ids of the spreads laid so far, in order. */
|
||||
history: readonly string[];
|
||||
/** The book's energy for template choice: a calm book keeps its busy pages few. */
|
||||
calm: boolean;
|
||||
seed: number;
|
||||
/** Names the decision for the seed: the same page of the same journey picks the same. */
|
||||
key: string;
|
||||
/** Ids never to take here (the page's own on "this spread", its neighbours'). */
|
||||
avoid?: readonly string[];
|
||||
}
|
||||
|
||||
const hasRole = (tpl: BookDecoratedTemplate, role: string) => (templateRoles(tpl) as readonly string[]).includes(role);
|
||||
|
||||
const slotsOf = new Map<string, number>();
|
||||
const slots = (tpl: BookDecoratedTemplate) => {
|
||||
const known = slotsOf.get(tpl.id);
|
||||
if (known !== undefined) return known;
|
||||
const n = templatePhotoSlots(tpl);
|
||||
slotsOf.set(tpl.id, n);
|
||||
return n;
|
||||
};
|
||||
|
||||
/** The inner templates of an archetype and of its kin, the own ones first. */
|
||||
export function innerPool(archetype: BookCoverArchetype): BookDecoratedTemplate[] {
|
||||
return [archetype, ...ARCHETYPE_KIN[archetype]].flatMap((a) => decoratedTemplatesFor('inner', a));
|
||||
}
|
||||
|
||||
/** The photo counts a pool of templates takes, and those of its templates with a story frame. */
|
||||
export function chunkSizesOf(pool: readonly BookDecoratedTemplate[]): { any: Set<number>; story: Set<number> } {
|
||||
return {
|
||||
any: new Set(pool.map(slots)),
|
||||
story: new Set(pool.filter((t) => hasRole(t, 'body')).map(slots)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a template can take this page at all, after the one before it. */
|
||||
function admits(
|
||||
tpl: BookDecoratedTemplate,
|
||||
chunk: TemplateChunk,
|
||||
ctx: TemplatePickCtx,
|
||||
before: TemplateEnergy | undefined,
|
||||
) {
|
||||
return fitsAtAll(tpl, chunk, ctx) && !keepsApart(tpl, ctx, before);
|
||||
}
|
||||
|
||||
/** Whether a template can hold the page: no frame left empty, the story and the map where it has them. */
|
||||
function fitsAtAll(tpl: BookDecoratedTemplate, chunk: TemplateChunk, ctx: TemplatePickCtx) {
|
||||
if (tpl.kind !== 'inner' || slots(tpl) > chunk.photos.length || ctx.avoid?.includes(tpl.id)) return false;
|
||||
if (chunk.story && !hasRole(tpl, 'body')) return false;
|
||||
return !hasRole(tpl, 'map') || chunk.mappable;
|
||||
}
|
||||
|
||||
/** The rhythm a book keeps where it can: never the page before again, never loud after loud or lively. */
|
||||
function keepsApart(tpl: BookDecoratedTemplate, ctx: TemplatePickCtx, before: TemplateEnergy | undefined) {
|
||||
return ctx.history[ctx.history.length - 1] === tpl.id || (isLoud(templateEnergy(tpl)) && isBusy(before));
|
||||
}
|
||||
|
||||
/** The orientation of a template's first frame on this format: wide, tall or square. */
|
||||
function firstFrameShape(tpl: BookDecoratedTemplate, page: BookPageSetup): 'wide' | 'tall' | 'square' {
|
||||
const el = tpl.elements.find((e) => e.role === 'photo:1');
|
||||
if (!el) return 'square';
|
||||
const across = el.frame.w * page.pageWidth;
|
||||
const down = el.frame.h * page.pageHeight;
|
||||
if (across >= down * 1.2) return 'wide';
|
||||
return down >= across * 1.1 ? 'tall' : 'square';
|
||||
}
|
||||
|
||||
/** Points for the story: it fits the frame, or it loses in proportion to how far it runs over. */
|
||||
function storyScore(tpl: BookDecoratedTemplate, chunk: TemplateChunk, ctx: TemplatePickCtx): number {
|
||||
if (!chunk.story) return 0;
|
||||
const room = bodyCapacity(tpl, ctx.page, ctx.theme);
|
||||
if (room >= chunk.story.length) return 10;
|
||||
return -Math.min(40, (20 * (chunk.story.length - room)) / Math.max(room, 1));
|
||||
}
|
||||
|
||||
/** What a photo left without a frame costs a template. */
|
||||
const UNUSED_PHOTO = 60;
|
||||
/** What a template of an archetype neither the book's nor its kin costs, where only such can take the page. */
|
||||
const FOREIGN = 25;
|
||||
|
||||
/** How well a template suits the page, the higher the better. */
|
||||
function scoreOf(
|
||||
tpl: BookDecoratedTemplate,
|
||||
chunk: TemplateChunk,
|
||||
ctx: TemplatePickCtx,
|
||||
before: TemplateEnergy | undefined,
|
||||
) {
|
||||
// A photo the page has no frame for waits in the tray.
|
||||
let score = 100 - UNUSED_PHOTO * (chunk.photos.length - slots(tpl));
|
||||
if (tpl.archetype === ctx.archetype) score += 20;
|
||||
else if (ARCHETYPE_KIN[ctx.archetype].includes(tpl.archetype)) score += 8;
|
||||
else score -= FOREIGN;
|
||||
const first = chunk.photos[0];
|
||||
const shape = firstFrameShape(tpl, ctx.page);
|
||||
if (first && ((shape === 'wide' && isLandscape(first)) || (shape === 'tall' && isPortrait(first)))) score += 6;
|
||||
score += storyScore(tpl, chunk, ctx);
|
||||
const recent = ctx.history.slice(-3);
|
||||
if (recent.includes(tpl.id)) score -= 40;
|
||||
score -= 8 * ctx.history.slice(0, -3).filter((id) => id === tpl.id).length;
|
||||
const energy = templateEnergy(tpl);
|
||||
if ((ctx.calm || isBusy(before)) && isBusy(energy)) score -= 30;
|
||||
return score;
|
||||
}
|
||||
|
||||
/** Scores this close count as equal, and the seed chooses among them. */
|
||||
const TIE = 5;
|
||||
|
||||
/** The best of scored templates, equals chosen by the seed. */
|
||||
function bestOf(scored: { tpl: BookDecoratedTemplate; score: number }[], seed: number, key: string) {
|
||||
if (!scored.length) return undefined;
|
||||
const top = Math.max(...scored.map((s) => s.score));
|
||||
const near = scored.filter((s) => s.score >= top - TIE).map((s) => s.tpl);
|
||||
return designRng(seed, key).pick(near);
|
||||
}
|
||||
|
||||
/** The energy of the template a page was laid on, by its id. */
|
||||
function energyOfId(pool: readonly BookDecoratedTemplate[], id: string | undefined): TemplateEnergy | undefined {
|
||||
const tpl = id ? pool.find((t) => t.id === id) : undefined;
|
||||
return tpl ? templateEnergy(tpl) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The template a page is laid on. A frame for every photo comes first, then
|
||||
* the book's own styles (its archetype and its kin), then the rhythm:
|
||||
*
|
||||
* 1. the own styles, every photo framed, the rhythm kept;
|
||||
* 2. the own styles, every photo framed, the rhythm given up for this page;
|
||||
* 3. another archetype, every photo framed, the rhythm kept;
|
||||
* 4. the own styles with a photo left to wait in the tray;
|
||||
* 5. whatever can take the page at all.
|
||||
*
|
||||
* Undefined when nothing can.
|
||||
*/
|
||||
export function pickInnerTemplate(chunk: TemplateChunk, ctx: TemplatePickCtx): BookDecoratedTemplate | undefined {
|
||||
const all = decoratedTemplatesFor('inner');
|
||||
const own = innerPool(ctx.archetype);
|
||||
const before = energyOfId(all, ctx.history[ctx.history.length - 1]);
|
||||
const full = (t: BookDecoratedTemplate) => slots(t) === chunk.photos.length;
|
||||
const kept = (t: BookDecoratedTemplate) => admits(t, chunk, ctx, before);
|
||||
const held = (t: BookDecoratedTemplate) => fitsAtAll(t, chunk, ctx);
|
||||
const tiers: [readonly BookDecoratedTemplate[], (t: BookDecoratedTemplate) => boolean][] = [
|
||||
[own, (t) => full(t) && kept(t)],
|
||||
[own, (t) => full(t) && held(t)],
|
||||
[all, (t) => full(t) && kept(t)],
|
||||
[own, kept],
|
||||
[all, held],
|
||||
];
|
||||
for (const [pool, takes] of tiers) {
|
||||
const scored = pool.filter(takes).map((tpl) => ({ tpl, score: scoreOf(tpl, chunk, ctx, before) }));
|
||||
if (scored.length) return bestOf(scored, ctx.seed, ctx.key);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** What a cover may be laid with: how many photos there are, and whether the cover photo stands tall. */
|
||||
interface CoverPick {
|
||||
archetype: BookCoverArchetype;
|
||||
photos: number;
|
||||
portrait: boolean;
|
||||
seed: number;
|
||||
/** Ids never to take (the cover on screen, on "this spread"). */
|
||||
avoid?: readonly string[];
|
||||
}
|
||||
|
||||
const PORTRAIT_COVER = /-portrait$|^cutout-/;
|
||||
|
||||
/**
|
||||
* The cover template of an archetype for the photos there are: one that
|
||||
* leaves no frame empty and fills the most of them, a tall one for a tall
|
||||
* cover photo, equals chosen by the seed. Undefined when the archetype has
|
||||
* none to take them, for the composed cover to stand in.
|
||||
*/
|
||||
export function pickCoverTemplate(p: CoverPick): BookDecoratedTemplate | undefined {
|
||||
const scored = decoratedTemplatesFor('single', p.archetype)
|
||||
.filter((t) => slots(t) <= p.photos && !p.avoid?.includes(t.id))
|
||||
// Without a photo only a cover drawn for none stands; a photo frame left empty does not.
|
||||
.filter((t) => p.photos > 0 || slots(t) === 0)
|
||||
.map((tpl) => {
|
||||
const tall = PORTRAIT_COVER.test(tpl.id);
|
||||
return { tpl, score: slots(tpl) * 10 + (tall === p.portrait ? 8 : 0) };
|
||||
});
|
||||
return bestOf(scored, p.seed, `cover:${p.archetype}`);
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { summaryMetrics } from '../auto/auto-book-spreads';
|
||||
import { isPortrait } from '../auto/auto-elements';
|
||||
import type { AutoEntry } from '../auto/auto-types';
|
||||
import type { BookCoverArchetype } from '../book-archetypes';
|
||||
import {
|
||||
MAX_BOOK_COUNTRIES,
|
||||
type BookCountryLook,
|
||||
type BookElement,
|
||||
type BookFrame,
|
||||
type BookSpread,
|
||||
type BookStatsLook,
|
||||
} from '../book.schema';
|
||||
import type { JourneyStats } from '../journey-stats.schema';
|
||||
import { applyDecoratedTemplate } from '../templates/template-apply';
|
||||
import type { BookDecoratedTemplate } from '../templates/template-format';
|
||||
import { bookTemplateWords, type BookTemplateWords } from '../templates/template-words';
|
||||
import { themedElement } from '../theme/book-theme-apply';
|
||||
import { bookTravelLooks, BOOK_TRAVEL_COUNTRY_ACCENT, type BookTravelLooks } from '../travel/book-travel-looks';
|
||||
import { frameOf, liveBox, photoEl, scaled, textEl, type ComposeCtx } from './design-compose';
|
||||
import { composeArchetypeCover } from './design-cover';
|
||||
import type { CoverMaterial } from './design-cover-kit';
|
||||
import { titleWords, type CoverWords } from './design-cover-words';
|
||||
import { composeBack } from './design-framing';
|
||||
import { innerSpread } from './design-spreads';
|
||||
import { pickCoverTemplate } from './design-template-pick';
|
||||
|
||||
/**
|
||||
* The pages around the chapters when the book is laid on the designed
|
||||
* templates: a cover laid on one of the archetype's cover templates, and the
|
||||
* journey at a glance, its countries and the back cover built from the
|
||||
* travel looks the Travel panel offers (a summary as a poster, tiles or a
|
||||
* sign, countries as stamps, visas or labels, marks as stamps and tags).
|
||||
* Nothing here sets a summary, a country or a mark in the classic look.
|
||||
*/
|
||||
|
||||
/** The travel looks of this book, an element of a size centred on the right page unless a frame is named. */
|
||||
function looksOf(c: ComposeCtx): BookTravelLooks | null {
|
||||
const stats = c.travel.stats;
|
||||
if (!stats) return null;
|
||||
const box = liveBox(c, 'right');
|
||||
const place = (size: { w: number; h: number }) =>
|
||||
frameOf(box.x + (box.w - size.w) / 2, box.y + (box.h - size.h) / 2, size.w, size.h);
|
||||
return bookTravelLooks({ page: c.page, stats, travel: c.travel, theme: c.look.theme, newId: c.newId, place });
|
||||
}
|
||||
|
||||
/** A size scaled down to fit a box, its proportions kept, centred in it. */
|
||||
function fitIn(size: { w: number; h: number }, box: BookFrame): BookFrame {
|
||||
const k = Math.min(1, box.w / size.w, box.h / size.h);
|
||||
const w = size.w * k;
|
||||
const h = size.h * k;
|
||||
return frameOf(box.x + (box.w - w) / 2, box.y + (box.h - h) / 2, w, h);
|
||||
}
|
||||
|
||||
// ── the cover ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The cover's own words and pictures as a page, for a template to pour:
|
||||
* the title bound to the journey's (unless the person gave their own), the
|
||||
* subtitle bound the same way, the year it was travelled, and the photos.
|
||||
* Poured from a page, the title keeps its binding, which the template's
|
||||
* words for a cover would not give it.
|
||||
*/
|
||||
function coverSeed(c: ComposeCtx, words: CoverWords, material: CoverMaterial, slots: number): BookSpread {
|
||||
const box = liveBox(c, 'single');
|
||||
const els: BookElement[] = [];
|
||||
const title = titleWords(words.ownTitle, words.title);
|
||||
if (title.words.trim()) {
|
||||
const binding = title.bound ? { source: 'journey.title' as const } : null;
|
||||
els.push(textEl(c, box, title.words, 'heading', { binding, size: c.look.sizes.display }));
|
||||
}
|
||||
const sub = titleWords(words.ownSubtitle, words.subtitle ?? '');
|
||||
if (sub.words.trim()) {
|
||||
const binding = sub.bound ? { source: 'journey.subtitle' as const } : null;
|
||||
els.push(textEl(c, box, sub.words, 'sub', { binding }));
|
||||
}
|
||||
const year = material.entries.find((e) => e.date && /^\d{4}/.test(e.date))?.date?.slice(0, 4);
|
||||
if (year) els.push(textEl(c, box, year, 'meta'));
|
||||
els.push(...material.photos.slice(0, slots).map((p) => photoEl(c, p, box)));
|
||||
return { id: c.newId('sp'), role: 'cover', background: null, elements: els, parked: [], entryId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* A page laid on a decorated template in the book's theme, its empty roles
|
||||
* taking the journey's words, or the words `given` for a role by name.
|
||||
*/
|
||||
export function pourTemplate(
|
||||
c: ComposeCtx,
|
||||
seed: BookSpread,
|
||||
tpl: BookDecoratedTemplate,
|
||||
entries: readonly AutoEntry[],
|
||||
given: BookTemplateWords = {},
|
||||
) {
|
||||
const words = { ...bookTemplateWords(seed, { bookTitle: '', locale: c.locale, entries }), ...given };
|
||||
return applyDecoratedTemplate(seed, tpl, c.page, {
|
||||
theme: c.look.theme,
|
||||
travel: c.travel,
|
||||
locale: c.locale,
|
||||
newId: c.newId,
|
||||
words,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The cover on one of the archetype's cover templates: one that leaves no
|
||||
* frame empty and fills the most, a tall one for a tall cover photo. An
|
||||
* archetype with none for the photos there are (none at all, or fewer than
|
||||
* its grid takes) has its cover composed instead (design-cover.ts).
|
||||
*/
|
||||
export function composeTemplatedCover(
|
||||
c: ComposeCtx,
|
||||
words: CoverWords,
|
||||
material: CoverMaterial,
|
||||
archetype: BookCoverArchetype,
|
||||
avoid: readonly string[] = [],
|
||||
): { spread: BookSpread; template: string | null } {
|
||||
const first = material.photos[0];
|
||||
const seed = c.directives.seed ?? 0;
|
||||
const tpl = pickCoverTemplate({
|
||||
archetype,
|
||||
photos: material.photos.length,
|
||||
portrait: !!first && isPortrait(first),
|
||||
seed,
|
||||
avoid,
|
||||
});
|
||||
if (!tpl) return { spread: composeArchetypeCover(c, archetype, words, material), template: null };
|
||||
return { spread: coverOnTemplate(c, words, material, tpl), template: tpl.id };
|
||||
}
|
||||
|
||||
/** The cover laid on this cover template, its title and subtitle bound to the journey's. */
|
||||
export function coverOnTemplate(
|
||||
c: ComposeCtx,
|
||||
words: CoverWords,
|
||||
material: CoverMaterial,
|
||||
tpl: BookDecoratedTemplate,
|
||||
): BookSpread {
|
||||
const laid = pourTemplate(c, coverSeed(c, words, material, material.photos.length), tpl, material.entries);
|
||||
// The photos the cover has no frame for are the book's own pages', not the cover's to park.
|
||||
return { ...laid, parked: [] };
|
||||
}
|
||||
|
||||
// ── the journey at a glance ───────────────────────────────────────────────
|
||||
|
||||
/** The summary's look for an archetype: tiles for the collage and grid books, a sign for the postcard ones. */
|
||||
export function summaryLookFor(archetype: BookCoverArchetype): BookStatsLook {
|
||||
if (archetype === 'stamp-collage' || archetype === 'grid-badge') return 'tiles';
|
||||
return archetype === 'postcard' ? 'sign' : 'poster';
|
||||
}
|
||||
|
||||
/** The summary looks "this spread" goes through, in turn. */
|
||||
export const SUMMARY_CYCLE: readonly BookStatsLook[] = ['poster', 'tiles', 'sign'];
|
||||
|
||||
/** Whether the journey adds up to a summary worth a spread: a way of two stops at least. */
|
||||
const hasSummary = (stats: JourneyStats | null): stats is JourneyStats => !!stats && stats.points.length >= 2;
|
||||
|
||||
/**
|
||||
* The journey at a glance: its map with a photo at every stop on the left
|
||||
* page; on the right the figures in the look's panel, a ticket stub of how
|
||||
* far and how long below it, and the furthest point on an odometer when the
|
||||
* journey went anywhere. Null when there is nothing to sum up.
|
||||
*/
|
||||
export function composeTravelSummary(c: ComposeCtx, look: BookStatsLook): BookSpread | null {
|
||||
const stats = c.travel.stats;
|
||||
const looks = looksOf(c);
|
||||
if (!hasSummary(stats) || !looks) return null;
|
||||
const els: BookElement[] = [looks.mapEl({ style: 'minimal' }, 'journey', 'vector', liveBox(c, 'left'))];
|
||||
const box = liveBox(c, 'right');
|
||||
const parts: { size: { w: number; h: number }; make: (f: BookFrame) => BookElement }[] = [];
|
||||
const metrics = summaryMetrics(stats);
|
||||
const main = looks.summarySize(look);
|
||||
parts.push({ size: { w: box.w, h: (box.w * main.h) / main.w }, make: (f) => looks.summaryEl(look, f, metrics) });
|
||||
const stub = looks.summarySize('ticket');
|
||||
parts.push({
|
||||
size: { w: box.w * 0.72, h: (box.w * 0.72 * stub.h) / stub.w },
|
||||
make: (f) => looks.summaryEl('ticket', f, ['distance', 'days']),
|
||||
});
|
||||
if (stats.furthest > 0) {
|
||||
const drums = looks.figureSize('odometer');
|
||||
parts.push({
|
||||
size: { w: box.w * 0.5, h: (box.w * 0.5 * drums.h) / drums.w },
|
||||
make: (f) => looks.figureEl('furthest', 'odometer', f),
|
||||
});
|
||||
}
|
||||
els.push(...stacked(c, parts, box));
|
||||
return innerSpread(c, els, null);
|
||||
}
|
||||
|
||||
/** Parts stacked down a box with a gap between, all scaled alike to fit it, each centred across. */
|
||||
function stacked(
|
||||
c: ComposeCtx,
|
||||
parts: readonly { size: { w: number; h: number }; make: (f: BookFrame) => BookElement }[],
|
||||
box: BookFrame,
|
||||
): BookElement[] {
|
||||
const gap = scaled(c, 8);
|
||||
const tall = parts.reduce((h, p) => h + p.size.h, 0);
|
||||
const k = Math.min(1, (box.h - gap * (parts.length - 1)) / tall);
|
||||
let y = box.y + (box.h - (tall * k + gap * (parts.length - 1))) / 2;
|
||||
return parts.map((p) => {
|
||||
const w = p.size.w * k;
|
||||
const h = p.size.h * k;
|
||||
const el = p.make(frameOf(box.x + (box.w - w) / 2, y, w, h));
|
||||
y += h + gap;
|
||||
return el;
|
||||
});
|
||||
}
|
||||
|
||||
// ── the countries ─────────────────────────────────────────────────────────
|
||||
|
||||
/** The country look for an archetype: stamps for the collage books, postcards, labels for the quiet ones, else visas. */
|
||||
export function countryLookFor(archetype: BookCoverArchetype): BookCountryLook {
|
||||
if (archetype === 'stamp-collage') return 'stamp';
|
||||
if (archetype === 'postcard') return 'postcard';
|
||||
return archetype === 'spine-band' || archetype === 'minimal-serif' ? 'label' : 'visa';
|
||||
}
|
||||
|
||||
/** The country looks "this spread" goes through, in turn. */
|
||||
export const COUNTRY_CYCLE: readonly BookCountryLook[] = ['stamp', 'visa', 'label', 'postcard'];
|
||||
|
||||
/** Past this many countries one panel of stamps holds them all, rather than a stamp each. */
|
||||
const MOST_COUNTRY_CARDS = 12;
|
||||
|
||||
/** A country in its look, its accent the look's or the theme's first by turns, so a grid is not one colour. */
|
||||
function countryCard(
|
||||
c: ComposeCtx,
|
||||
looks: BookTravelLooks,
|
||||
code: string,
|
||||
look: BookCountryLook,
|
||||
at: number,
|
||||
frame: BookFrame,
|
||||
) {
|
||||
const el = looks.countryEl(code, look, frame);
|
||||
const own = BOOK_TRAVEL_COUNTRY_ACCENT[look] ?? 'accent';
|
||||
if (at % 2 === 0 || own === 'accent') return el;
|
||||
return themedElement({ ...el, accentToken: 'accent' } as BookElement, c.look.theme);
|
||||
}
|
||||
|
||||
/** The cells of a grid of `n` on one page's live box, at most three across. */
|
||||
function grid(box: BookFrame, n: number, gap: number): BookFrame[] {
|
||||
const cols = Math.min(3, n);
|
||||
const rows = Math.ceil(n / cols);
|
||||
const w = (box.w - gap * (cols - 1)) / cols;
|
||||
const h = Math.min(w, (box.h - gap * (rows - 1)) / rows);
|
||||
const top = box.y + (box.h - (h * rows + gap * (rows - 1))) / 2;
|
||||
return Array.from({ length: n }, (_, i) =>
|
||||
frameOf(box.x + (i % cols) * (w + gap), top + Math.floor(i / cols) * (h + gap), w, h),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The countries of the journey: one country as its map cut to its shape
|
||||
* beside its stamp; up to twelve as a card each over both pages; more as one
|
||||
* panel of stamps. Null for a journey that crossed no border it knows of.
|
||||
*/
|
||||
export function composeTravelCountries(c: ComposeCtx, look: BookCountryLook): BookSpread | null {
|
||||
const stats = c.travel.stats;
|
||||
const looks = looksOf(c);
|
||||
const codes = stats?.countries.map((k) => k.code) ?? [];
|
||||
if (!looks || !codes.length) return null;
|
||||
const card = looks.countrySize(look);
|
||||
const [only] = codes;
|
||||
if (codes.length === 1 && only) {
|
||||
const els = [
|
||||
looks.countryMapEl(only, 'shape', liveBox(c, 'left')),
|
||||
countryCard(c, looks, only, look, 0, fitIn({ w: card.w * 1.4, h: card.h * 1.4 }, liveBox(c, 'right'))),
|
||||
];
|
||||
return innerSpread(c, els, null);
|
||||
}
|
||||
if (codes.length > MOST_COUNTRY_CARDS) {
|
||||
// One panel on the right page, every country a stamp in it, beside the journey's map.
|
||||
const map = looks.mapEl({ style: 'minimal', pins: 'numbered' }, 'journey', 'vector', liveBox(c, 'left'));
|
||||
return innerSpread(
|
||||
c,
|
||||
[map, withCodes(looks.countryEl(only ?? '', 'stamp', liveBox(c, 'right')), looks, codes)],
|
||||
null,
|
||||
);
|
||||
}
|
||||
const left = Math.ceil(codes.length / 2);
|
||||
const gap = scaled(c, 6);
|
||||
const cells = [...grid(liveBox(c, 'left'), left, gap), ...grid(liveBox(c, 'right'), codes.length - left, gap)];
|
||||
const els = codes.map((code, i) => countryCard(c, looks, code, look, i, fitIn(card, cells[i] as BookFrame)));
|
||||
return innerSpread(c, els, null);
|
||||
}
|
||||
|
||||
/** A countries panel holding every one of the journey's countries, named in the book's language. */
|
||||
function withCodes(el: BookElement, looks: BookTravelLooks, codes: readonly string[]): BookElement {
|
||||
if (el.kind !== 'countries') return el;
|
||||
const all = codes.slice(0, MAX_BOOK_COUNTRIES);
|
||||
return { ...el, codes: all.map((k) => k.toUpperCase()), names: all.map((k) => looks.countryName(k)), layout: 'grid' };
|
||||
}
|
||||
|
||||
// ── the back cover ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The back cover: the title and when it was, as the composed back sets
|
||||
* them, then a band of how long, how many places and how many photos, and a
|
||||
* row of up to four country stamps. One page: everything stays inside its
|
||||
* live area.
|
||||
*/
|
||||
export function composeTravelBack(c: ComposeCtx, words: CoverWords, entries: readonly AutoEntry[]): BookSpread {
|
||||
const back = composeBack(c, words, entries);
|
||||
const looks = looksOf(c);
|
||||
const stats = c.travel.stats;
|
||||
if (!looks || !stats) return back;
|
||||
const box = liveBox(c, 'single');
|
||||
const els: BookElement[] = [...back.elements];
|
||||
const bandH = Math.min(c.page.pageHeight * 0.13, box.h * 0.16);
|
||||
els.push(looks.summaryEl('band', frameOf(box.x, box.y + box.h * 0.66, box.w, bandH), ['days', 'places', 'photos']));
|
||||
const codes = stats.countries.slice(0, 4).map((k) => k.code);
|
||||
if (codes.length) {
|
||||
const gap = scaled(c, 4);
|
||||
const side = Math.min((box.w - gap * 3) / 4, box.y + box.h - (box.y + box.h * 0.66 + bandH + gap));
|
||||
const row = codes.length * side + gap * (codes.length - 1);
|
||||
const y = box.y + box.h - side;
|
||||
codes.forEach((code, i) => {
|
||||
els.push(looks.countryEl(code, 'stamp', frameOf(box.x + (box.w - row) / 2 + i * (side + gap), y, side, side)));
|
||||
});
|
||||
}
|
||||
return { ...back, elements: els };
|
||||
}
|
||||
|
||||
// ── a page of words ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A page of an entry's words where no template takes them (a story with no
|
||||
* photo, or the rest of one that ran over its template's frame): the story
|
||||
* in a column on the left page, and on the right the stop's map with its
|
||||
* stops numbered, its day as a stamp and its country as a tag.
|
||||
*/
|
||||
export function composeWordsPage(c: ComposeCtx, entry: AutoEntry, story: BookElement | null): BookSpread {
|
||||
const looks = looksOf(c);
|
||||
const els: BookElement[] = story ? [story] : [];
|
||||
const right = liveBox(c, 'right');
|
||||
if (looks && c.travel.stats && c.travel.stats.points.length) {
|
||||
const map = frameOf(right.x, right.y, right.w, right.h * 0.62);
|
||||
els.push(looks.mapEl({ style: 'minimal', pins: 'numbered' }, 'journey', 'vector', map));
|
||||
const marks = [looks.markEl('day', 'stamp', { entry }), looks.markEl('flag', 'tag', { entry })];
|
||||
let x = right.x;
|
||||
for (const mark of marks) {
|
||||
if (!mark) continue;
|
||||
const { w = 0, h = 0 } = mark.frame;
|
||||
const frame = fitIn({ w, h }, frameOf(x, right.y + right.h * 0.7, right.w / 2 - scaled(c, 4), right.h * 0.3));
|
||||
els.push({ ...mark, frame: frameOf(x, frame.y, frame.w, frame.h) });
|
||||
x += frame.w + scaled(c, 8);
|
||||
}
|
||||
}
|
||||
return innerSpread(c, els, entry.id);
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
{
|
||||
"D": {
|
||||
"input": {
|
||||
"locale": "en-GB",
|
||||
"title": "North by the coast",
|
||||
"subtitle": null,
|
||||
"coverPhotoId": null,
|
||||
"entries": [
|
||||
{
|
||||
"id": 101,
|
||||
"title": "Stop 1",
|
||||
"story": "We walked out along the harbour wall before the light came up, and the town was still asleep behind us.",
|
||||
"location": "Bergen",
|
||||
"date": "2026-06-01",
|
||||
"photos": [],
|
||||
"lat": 60,
|
||||
"lng": 5,
|
||||
"country": "NO",
|
||||
"dayNumber": 1,
|
||||
"dayCount": 8
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"title": "Stop 2",
|
||||
"story": null,
|
||||
"location": "Flam",
|
||||
"date": "2026-06-02",
|
||||
"photos": [
|
||||
{
|
||||
"photoId": 500,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
}
|
||||
],
|
||||
"lat": 61,
|
||||
"lng": 6,
|
||||
"country": "NO",
|
||||
"dayNumber": 2,
|
||||
"dayCount": 8
|
||||
},
|
||||
{
|
||||
"id": 103,
|
||||
"title": "Stop 3",
|
||||
"story": "We walked out along the harbour wall before the light came up, and the town was still asleep behind us. We walked out along the harbour wall before the light came up, and the town was still asleep behind us. We walked out along the harbour wall before the light came up, and the town was still asleep behind us.",
|
||||
"location": "Alesund",
|
||||
"date": "2026-06-03",
|
||||
"photos": [
|
||||
{
|
||||
"photoId": 501,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 502,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 503,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
}
|
||||
],
|
||||
"lat": 62,
|
||||
"lng": 7,
|
||||
"country": "NO",
|
||||
"dayNumber": 3,
|
||||
"dayCount": 8
|
||||
},
|
||||
{
|
||||
"id": 104,
|
||||
"title": "Stop 4",
|
||||
"story": null,
|
||||
"location": "Geiranger",
|
||||
"date": "2026-06-04",
|
||||
"photos": [
|
||||
{
|
||||
"photoId": 504,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 505,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 506,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 507,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 508,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
}
|
||||
],
|
||||
"lat": 63,
|
||||
"lng": 8,
|
||||
"country": "NO",
|
||||
"dayNumber": 4,
|
||||
"dayCount": 8
|
||||
},
|
||||
{
|
||||
"id": 105,
|
||||
"title": "Stop 5",
|
||||
"story": "We walked out along the harbour wall before the light came up, and the town was still asleep behind us.",
|
||||
"location": "Trondheim",
|
||||
"date": "2026-06-05",
|
||||
"photos": [
|
||||
{
|
||||
"photoId": 509,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 510,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 511,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 512,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 513,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 514,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 515,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 516,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 517,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 518,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 519,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 520,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
}
|
||||
],
|
||||
"lat": 64,
|
||||
"lng": 9,
|
||||
"country": "NO",
|
||||
"dayNumber": 5,
|
||||
"dayCount": 8
|
||||
},
|
||||
{
|
||||
"id": 106,
|
||||
"title": "Stop 6",
|
||||
"story": null,
|
||||
"location": "Bodo",
|
||||
"date": "2026-06-06",
|
||||
"photos": [
|
||||
{
|
||||
"photoId": 521,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 522,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 523,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 524,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 525,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 526,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 527,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 528,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 529,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 530,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 531,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 532,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 533,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 534,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 535,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 536,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 537,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 538,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 539,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 540,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
}
|
||||
],
|
||||
"lat": 65,
|
||||
"lng": 10,
|
||||
"country": "NO",
|
||||
"dayNumber": 6,
|
||||
"dayCount": 8
|
||||
},
|
||||
{
|
||||
"id": 107,
|
||||
"title": "Stop 7",
|
||||
"story": null,
|
||||
"location": "Tromso",
|
||||
"date": "2026-06-07",
|
||||
"photos": [],
|
||||
"lat": 66,
|
||||
"lng": 11,
|
||||
"country": "NO",
|
||||
"dayNumber": 7,
|
||||
"dayCount": 8
|
||||
},
|
||||
{
|
||||
"id": 108,
|
||||
"title": "Stop 8",
|
||||
"story": "We walked out along the harbour wall before the light came up, and the town was still asleep behind us. We walked out along the harbour wall before the light came up, and the town was still asleep behind us. We walked out along the harbour wall before the light came up, and the town was still asleep behind us. We walked out along the harbour wall before the light came up, and the town was still asleep behind us.",
|
||||
"location": "Alta",
|
||||
"date": "2026-06-08",
|
||||
"photos": [
|
||||
{
|
||||
"photoId": 541,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 542,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
}
|
||||
],
|
||||
"lat": 67,
|
||||
"lng": 12,
|
||||
"country": "NO",
|
||||
"dayNumber": 8,
|
||||
"dayCount": 8
|
||||
}
|
||||
],
|
||||
"page": {
|
||||
"preset": "square-210",
|
||||
"pageWidth": 210,
|
||||
"pageHeight": 210,
|
||||
"bleed": 3,
|
||||
"safe": 5,
|
||||
"pageNumbers": {
|
||||
"show": false,
|
||||
"startAt": 2,
|
||||
"position": "outer",
|
||||
"margin": 12,
|
||||
"size": 8,
|
||||
"autoColor": true,
|
||||
"color": "#8a8578",
|
||||
"font": "sans"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"journeyId": 9,
|
||||
"distance": 2100000,
|
||||
"days": 8,
|
||||
"steps": 20,
|
||||
"photos": 80,
|
||||
"places": 8,
|
||||
"furthest": 1500000,
|
||||
"countries": [
|
||||
{
|
||||
"code": "NO",
|
||||
"name": "Norway",
|
||||
"places": 8,
|
||||
"firstVisit": "2026-06-01"
|
||||
}
|
||||
],
|
||||
"points": [
|
||||
{
|
||||
"lat": 60,
|
||||
"lng": 5,
|
||||
"label": "Bergen",
|
||||
"date": "2026-06-01",
|
||||
"country": "NO",
|
||||
"tripId": null,
|
||||
"photoId": null,
|
||||
"entryId": 101
|
||||
},
|
||||
{
|
||||
"lat": 61,
|
||||
"lng": 6,
|
||||
"label": "Flam",
|
||||
"date": "2026-06-02",
|
||||
"country": "NO",
|
||||
"tripId": null,
|
||||
"photoId": null,
|
||||
"entryId": 102
|
||||
},
|
||||
{
|
||||
"lat": 62,
|
||||
"lng": 7,
|
||||
"label": "Alesund",
|
||||
"date": "2026-06-03",
|
||||
"country": "NO",
|
||||
"tripId": null,
|
||||
"photoId": null,
|
||||
"entryId": 103
|
||||
},
|
||||
{
|
||||
"lat": 63,
|
||||
"lng": 8,
|
||||
"label": "Geiranger",
|
||||
"date": "2026-06-04",
|
||||
"country": "NO",
|
||||
"tripId": null,
|
||||
"photoId": null,
|
||||
"entryId": 104
|
||||
},
|
||||
{
|
||||
"lat": 64,
|
||||
"lng": 9,
|
||||
"label": "Trondheim",
|
||||
"date": "2026-06-05",
|
||||
"country": "NO",
|
||||
"tripId": null,
|
||||
"photoId": null,
|
||||
"entryId": 105
|
||||
},
|
||||
{
|
||||
"lat": 65,
|
||||
"lng": 10,
|
||||
"label": "Bodo",
|
||||
"date": "2026-06-06",
|
||||
"country": "NO",
|
||||
"tripId": null,
|
||||
"photoId": null,
|
||||
"entryId": 106
|
||||
},
|
||||
{
|
||||
"lat": 66,
|
||||
"lng": 11,
|
||||
"label": "Tromso",
|
||||
"date": "2026-06-07",
|
||||
"country": "NO",
|
||||
"tripId": null,
|
||||
"photoId": null,
|
||||
"entryId": 107
|
||||
},
|
||||
{
|
||||
"lat": 67,
|
||||
"lng": 12,
|
||||
"label": "Alta",
|
||||
"date": "2026-06-08",
|
||||
"country": "NO",
|
||||
"tripId": null,
|
||||
"photoId": null,
|
||||
"entryId": 108
|
||||
}
|
||||
],
|
||||
"trips": [],
|
||||
"excluded": [],
|
||||
"start": "2026-06-01",
|
||||
"end": "2026-06-08"
|
||||
},
|
||||
"path": [],
|
||||
"stationsLabel": "Stations",
|
||||
"dayLabel": "DAY {day}",
|
||||
"summaryLabel": "Trip summary",
|
||||
"countriesLabel": "Countries"
|
||||
},
|
||||
"gallery": [
|
||||
{
|
||||
"photoId": 900,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-01T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 901,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-02T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 902,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-03T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 903,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-04T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 904,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-05T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 905,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-06T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 906,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-07T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 907,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-08T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 908,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-09T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 909,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-01T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 910,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-02T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 911,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-03T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 912,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-04T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 913,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-05T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 914,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-06T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 915,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-07T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 916,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-08T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 917,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-09T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 918,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-01T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 919,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-02T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 920,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-03T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 921,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-04T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 922,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-05T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 923,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-06T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 924,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-07T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 925,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-08T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 926,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-09T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 927,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-01T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 928,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-02T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 929,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-03T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
{
|
||||
"A/fabric-classic/head": "74bb0439e4575ed7",
|
||||
"A/fabric-classic/plan": "95b55b052b9ff07b",
|
||||
"A/fabric-classic/spread/0": "a4d090166287cbe5",
|
||||
"A/fabric-classic/spread/1": "aa637b0de2518bf5",
|
||||
"A/fabric-classic/spread/10": "febf8fa0b4cd3aef",
|
||||
"A/fabric-classic/spread/11": "26253da4f1f688a0",
|
||||
"A/fabric-classic/spread/12": "4dbf29b2c63f9231",
|
||||
"A/fabric-classic/spread/13": "5deb8ece6f27e514",
|
||||
"A/fabric-classic/spread/14": "db4f2ede14bb6023",
|
||||
"A/fabric-classic/spread/15": "7d011356ea8fc70f",
|
||||
"A/fabric-classic/spread/16": "205c5f0f86f90cb4",
|
||||
"A/fabric-classic/spread/17": "de849a3986a487c1",
|
||||
"A/fabric-classic/spread/18": "22b9cb0c78e0ff66",
|
||||
"A/fabric-classic/spread/19": "9df44ff280b76f64",
|
||||
"A/fabric-classic/spread/2": "8278d3c2507527f9",
|
||||
"A/fabric-classic/spread/20": "972976260c457cd3",
|
||||
"A/fabric-classic/spread/21": "9102a5fa147693fe",
|
||||
"A/fabric-classic/spread/22": "0bc9c54f6ee8b3e9",
|
||||
"A/fabric-classic/spread/23": "b11b5b3fb34a34eb",
|
||||
"A/fabric-classic/spread/24": "a55189e7bfd3934d",
|
||||
"A/fabric-classic/spread/3": "2ceb4890ba95c249",
|
||||
"A/fabric-classic/spread/4": "7743a5784df1d3e5",
|
||||
"A/fabric-classic/spread/5": "dbbbfb7bedd90da2",
|
||||
"A/fabric-classic/spread/6": "c36b6c75a4254fad",
|
||||
"A/fabric-classic/spread/7": "b7694f72f7cbe2bb",
|
||||
"A/fabric-classic/spread/8": "bdafe534fcbe56f0",
|
||||
"A/fabric-classic/spread/9": "c8a03b3fbdb60114",
|
||||
"A/midnight/head": "ce0d82281cbc3e1f",
|
||||
"A/midnight/plan": "7fde812131c9b570",
|
||||
"A/midnight/spread/0": "409beb5fbb1accce",
|
||||
"A/midnight/spread/1": "d9f5d78659332b45",
|
||||
"A/midnight/spread/10": "53f13a2ec3a3c5f4",
|
||||
"A/midnight/spread/11": "888c0c66730d32dc",
|
||||
"A/midnight/spread/12": "8d6c62bc4ee5ed1d",
|
||||
"A/midnight/spread/13": "fc0673a56f5b4d4b",
|
||||
"A/midnight/spread/14": "33db0be88fd469a7",
|
||||
"A/midnight/spread/15": "72105c668088bc4c",
|
||||
"A/midnight/spread/16": "bd11f4dc18e03108",
|
||||
"A/midnight/spread/17": "2e7a0f09822c3556",
|
||||
"A/midnight/spread/18": "f4264c3ee121b8d1",
|
||||
"A/midnight/spread/19": "4f27a48a457d95bb",
|
||||
"A/midnight/spread/2": "b00839cd0dc1451b",
|
||||
"A/midnight/spread/20": "dff6e523732c8481",
|
||||
"A/midnight/spread/21": "26d666dde7be6641",
|
||||
"A/midnight/spread/22": "eba329fd185958b3",
|
||||
"A/midnight/spread/23": "9664fbd24505a632",
|
||||
"A/midnight/spread/3": "742dfb4105263101",
|
||||
"A/midnight/spread/4": "ea9613c918951560",
|
||||
"A/midnight/spread/5": "8aa571b4d51a5234",
|
||||
"A/midnight/spread/6": "2275359a3c03f457",
|
||||
"A/midnight/spread/7": "3ef3c265b38598e2",
|
||||
"A/midnight/spread/8": "3a77eec4f6c9f860",
|
||||
"A/midnight/spread/9": "5bdff7027bea103c",
|
||||
"A/riso/head": "7d43d0900fbe0f57",
|
||||
"A/riso/plan": "f802ad819f1fb262",
|
||||
"A/riso/spread/0": "1f4502d29eca9017",
|
||||
"A/riso/spread/1": "8de92788de29277b",
|
||||
"A/riso/spread/10": "2a92b79192575cd0",
|
||||
"A/riso/spread/11": "5785a9cc59ddd735",
|
||||
"A/riso/spread/12": "cd4f6004153fe694",
|
||||
"A/riso/spread/13": "b1f87a92cb7f775c",
|
||||
"A/riso/spread/14": "873bf5f7a8404fe4",
|
||||
"A/riso/spread/15": "c1aab0214328ef3b",
|
||||
"A/riso/spread/16": "d58f70c381d9f6ae",
|
||||
"A/riso/spread/17": "00caa407cb2a0677",
|
||||
"A/riso/spread/18": "ed41aca064456614",
|
||||
"A/riso/spread/19": "36647f06c594e715",
|
||||
"A/riso/spread/2": "5c9a93de9e6467d6",
|
||||
"A/riso/spread/20": "8518ca61c110fd8a",
|
||||
"A/riso/spread/21": "d4ba78b2ffd5ae04",
|
||||
"A/riso/spread/22": "de5116307e12ce61",
|
||||
"A/riso/spread/23": "a748ada7a028818e",
|
||||
"A/riso/spread/24": "9abf456403732463",
|
||||
"A/riso/spread/3": "20a199a47535dea7",
|
||||
"A/riso/spread/4": "93b84be6e48097b5",
|
||||
"A/riso/spread/5": "6d5ae8e80c2f0d21",
|
||||
"A/riso/spread/6": "e7f5c928785be48d",
|
||||
"A/riso/spread/7": "b672fd04b61ea7b9",
|
||||
"A/riso/spread/8": "d9e84bbad744e450",
|
||||
"A/riso/spread/9": "017d695be27dc988",
|
||||
"B/fabric-classic/head": "6af0d11dd09eae78",
|
||||
"B/fabric-classic/plan": "cae85517611abbca",
|
||||
"B/fabric-classic/spread/0": "87325aa216fe9c6a",
|
||||
"B/fabric-classic/spread/1": "b3f8e9c5043f8302",
|
||||
"B/fabric-classic/spread/2": "5951c4e8d72b4b3f",
|
||||
"B/fabric-classic/spread/3": "110c2e11d8079468",
|
||||
"B/fabric-classic/spread/4": "6f02c19ed62524d1",
|
||||
"B/fabric-classic/spread/5": "5b0cb3424c4b894c",
|
||||
"B/fabric-classic/spread/6": "28fec4ed0f8fd482",
|
||||
"B/fabric-classic/spread/7": "9d68ef6e7d8b5f5a",
|
||||
"B/fabric-classic/spread/8": "b11b5b3fb34a34eb",
|
||||
"B/fabric-classic/spread/9": "3194c096e5c7d16f",
|
||||
"B/midnight/head": "cfd309bdf9e41811",
|
||||
"B/midnight/plan": "b3e4cb03c2d620a4",
|
||||
"B/midnight/spread/0": "7fdd20dcda48881f",
|
||||
"B/midnight/spread/1": "d9f5d78659332b45",
|
||||
"B/midnight/spread/10": "db97ebe8475e0371",
|
||||
"B/midnight/spread/2": "a094503b82019808",
|
||||
"B/midnight/spread/3": "6e3e57121d8b8683",
|
||||
"B/midnight/spread/4": "468100c3bebad30d",
|
||||
"B/midnight/spread/5": "ca669ba76ca4d9bd",
|
||||
"B/midnight/spread/6": "60b4cb974e35c55d",
|
||||
"B/midnight/spread/7": "46fe0841298e4f7b",
|
||||
"B/midnight/spread/8": "25e4510c5fb81817",
|
||||
"B/midnight/spread/9": "eba329fd185958b3",
|
||||
"B/riso/head": "69a6c3112de2a047",
|
||||
"B/riso/plan": "d8906a53b3532db8",
|
||||
"B/riso/spread/0": "2bbd6e6b4e6d1b4d",
|
||||
"B/riso/spread/1": "8de92788de29277b",
|
||||
"B/riso/spread/2": "40f8d2b093d0fc8d",
|
||||
"B/riso/spread/3": "1fc8aedc7486cbeb",
|
||||
"B/riso/spread/4": "311c9fcde888d578",
|
||||
"B/riso/spread/5": "51562d8f08f46011",
|
||||
"B/riso/spread/6": "c114a720b5c5beff",
|
||||
"B/riso/spread/7": "6f4c43c7b5f1dbc3",
|
||||
"B/riso/spread/8": "a748ada7a028818e",
|
||||
"B/riso/spread/9": "877657cfa95e3cac",
|
||||
"C/fabric-classic/head": "ddd21691c506a600",
|
||||
"C/fabric-classic/plan": "c31dcaa9b3603acc",
|
||||
"C/fabric-classic/spread/0": "92d21ac9d8e866df",
|
||||
"C/fabric-classic/spread/1": "7094bacb28cc210a",
|
||||
"C/fabric-classic/spread/10": "b11b5b3fb34a34eb",
|
||||
"C/fabric-classic/spread/11": "f7d9814c6b8649fd",
|
||||
"C/fabric-classic/spread/2": "3e514b7dd87c7d49",
|
||||
"C/fabric-classic/spread/3": "a964aa1e7f057294",
|
||||
"C/fabric-classic/spread/4": "4a275bcefa9a8eb0",
|
||||
"C/fabric-classic/spread/5": "10ed185ab3fd9756",
|
||||
"C/fabric-classic/spread/6": "7bfbef523f58c3fc",
|
||||
"C/fabric-classic/spread/7": "71330d722ab54efd",
|
||||
"C/fabric-classic/spread/8": "a7f0ed591793b5e1",
|
||||
"C/fabric-classic/spread/9": "31665bce59abeafe",
|
||||
"C/midnight/head": "f1e6077175cb2e20",
|
||||
"C/midnight/plan": "14fb454d02caf6f9",
|
||||
"C/midnight/spread/0": "79eae9202a85d430",
|
||||
"C/midnight/spread/1": "d9f5d78659332b45",
|
||||
"C/midnight/spread/10": "6694684925255e94",
|
||||
"C/midnight/spread/11": "247394453c45c63c",
|
||||
"C/midnight/spread/12": "abe0197797669d57",
|
||||
"C/midnight/spread/13": "eba329fd185958b3",
|
||||
"C/midnight/spread/14": "ebbba57a4116faa3",
|
||||
"C/midnight/spread/2": "7b99390522f39fe5",
|
||||
"C/midnight/spread/3": "7391268691e50ede",
|
||||
"C/midnight/spread/4": "4f08a3fc6da821a5",
|
||||
"C/midnight/spread/5": "6d06a2d469c2d5e2",
|
||||
"C/midnight/spread/6": "6238c8bc28c5964e",
|
||||
"C/midnight/spread/7": "8a5cc2233372bb47",
|
||||
"C/midnight/spread/8": "a445fe1b9090293d",
|
||||
"C/midnight/spread/9": "e2af9bfdb8789b4b",
|
||||
"C/riso/head": "d19997b07778d1dc",
|
||||
"C/riso/plan": "175dadeb8bb730a6",
|
||||
"C/riso/spread/0": "8453dd7b94afe68d",
|
||||
"C/riso/spread/1": "8de92788de29277b",
|
||||
"C/riso/spread/10": "6b5aa1dac73ab777",
|
||||
"C/riso/spread/11": "c20262603c86d43e",
|
||||
"C/riso/spread/12": "8e263c445c0418d2",
|
||||
"C/riso/spread/13": "a748ada7a028818e",
|
||||
"C/riso/spread/14": "81e4b4a0dcc6961a",
|
||||
"C/riso/spread/2": "5e43386b045d926c",
|
||||
"C/riso/spread/3": "9587f2fe2fc9cd04",
|
||||
"C/riso/spread/4": "edb0ebe3a843a937",
|
||||
"C/riso/spread/5": "f8a935188537c224",
|
||||
"C/riso/spread/6": "d2ca03597f3732c6",
|
||||
"C/riso/spread/7": "7a4374ec7906855f",
|
||||
"C/riso/spread/8": "2dd867bf81ca7901",
|
||||
"C/riso/spread/9": "aea9baf94aa13bc8",
|
||||
"D/fabric-classic/head": "54fa8987fea87ce4",
|
||||
"D/fabric-classic/plan": "a2d0423833838dcc",
|
||||
"D/fabric-classic/spread/0": "1136daea45c98c7d",
|
||||
"D/fabric-classic/spread/1": "931682bc5827ad8a",
|
||||
"D/fabric-classic/spread/10": "98449436f064fc7c",
|
||||
"D/fabric-classic/spread/11": "e58c5ea28b6e5d14",
|
||||
"D/fabric-classic/spread/12": "a422c04ae2d9e32f",
|
||||
"D/fabric-classic/spread/13": "b512aff5192eae71",
|
||||
"D/fabric-classic/spread/14": "b92cc5c3c532f2cb",
|
||||
"D/fabric-classic/spread/15": "b11b5b3fb34a34eb",
|
||||
"D/fabric-classic/spread/16": "c40cdcb18e2ef0bc",
|
||||
"D/fabric-classic/spread/2": "86dd9a101af346f4",
|
||||
"D/fabric-classic/spread/3": "afe19cb105dfefa0",
|
||||
"D/fabric-classic/spread/4": "80d31e5986b670e2",
|
||||
"D/fabric-classic/spread/5": "664cd81373c14243",
|
||||
"D/fabric-classic/spread/6": "c1d232a5aed51318",
|
||||
"D/fabric-classic/spread/7": "8d08e54aa79c8c74",
|
||||
"D/fabric-classic/spread/8": "18f531e0a85ff3de",
|
||||
"D/fabric-classic/spread/9": "a7ddf2eb33bb75f6",
|
||||
"D/midnight/head": "da510cf5f95bad40",
|
||||
"D/midnight/plan": "3bd0d52ed4e476c7",
|
||||
"D/midnight/spread/0": "440b7d69daa18a7a",
|
||||
"D/midnight/spread/1": "d9f5d78659332b45",
|
||||
"D/midnight/spread/10": "824d62135a0fe827",
|
||||
"D/midnight/spread/11": "03bbcf527fcbce9f",
|
||||
"D/midnight/spread/12": "d2e1ae86b69d8a5c",
|
||||
"D/midnight/spread/13": "7025aef32f444ab2",
|
||||
"D/midnight/spread/14": "581f0008497c1143",
|
||||
"D/midnight/spread/15": "f86784f4b061a31e",
|
||||
"D/midnight/spread/16": "ec6c98d1713ff353",
|
||||
"D/midnight/spread/17": "03888512d15a5895",
|
||||
"D/midnight/spread/18": "eba329fd185958b3",
|
||||
"D/midnight/spread/19": "6e6e6c877443c5e7",
|
||||
"D/midnight/spread/2": "cef4235690d44e53",
|
||||
"D/midnight/spread/3": "cd34cb85119476ee",
|
||||
"D/midnight/spread/4": "f9782906a14ae092",
|
||||
"D/midnight/spread/5": "597d729fb1cfb962",
|
||||
"D/midnight/spread/6": "0023fe5672ad9141",
|
||||
"D/midnight/spread/7": "f79ae699d0684ce5",
|
||||
"D/midnight/spread/8": "ea1a0157b2405cff",
|
||||
"D/midnight/spread/9": "5d38d38d1226fe1e",
|
||||
"D/riso/head": "1c8f99d99acdea28",
|
||||
"D/riso/plan": "f2a8704019582093",
|
||||
"D/riso/spread/0": "7b0d816ffda1dd01",
|
||||
"D/riso/spread/1": "8de92788de29277b",
|
||||
"D/riso/spread/10": "c85c0634205e3232",
|
||||
"D/riso/spread/11": "f97635aee763f93e",
|
||||
"D/riso/spread/12": "37f5d0dc381cfdb4",
|
||||
"D/riso/spread/13": "7083d486cc5556c5",
|
||||
"D/riso/spread/14": "711a3787fce72914",
|
||||
"D/riso/spread/15": "27d0db240d32de9a",
|
||||
"D/riso/spread/16": "a748ada7a028818e",
|
||||
"D/riso/spread/17": "ed87c0a4c77f241c",
|
||||
"D/riso/spread/2": "78f24755fb7ad38a",
|
||||
"D/riso/spread/3": "047741da3e1adc20",
|
||||
"D/riso/spread/4": "294d0c2a0600ed4a",
|
||||
"D/riso/spread/5": "b1f6c2832a5f5737",
|
||||
"D/riso/spread/6": "a898defea41081ec",
|
||||
"D/riso/spread/7": "027026c49ea68a27",
|
||||
"D/riso/spread/8": "4ff93cb4d24f729c",
|
||||
"D/riso/spread/9": "988a20c009c9d562"
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import type { AutoEntry, AutoInput, AutoPhoto } from '../auto/auto-types';
|
||||
import { normalizeBookDocument } from '../book-normalize';
|
||||
import {
|
||||
MAX_SPREADS,
|
||||
MAX_SPREAD_ELEMENTS,
|
||||
bookDocumentSchema,
|
||||
type BookDocument,
|
||||
type BookSpread,
|
||||
} from '../book.schema';
|
||||
import { pageFindings } from '../describe-spread';
|
||||
import { findDecoratedTemplate } from '../templates/template-catalogue';
|
||||
import { templatePhotoSlots } from '../templates/template-format';
|
||||
import { matchDecoratedTemplate } from '../templates/template-match';
|
||||
import { bookThemeColor, bookThemeOf } from '../theme/book-theme';
|
||||
import { applyBookTheme } from '../theme/book-theme-apply';
|
||||
import { designBook, type BookDesignInput, type BookDesignResult } from './design-book';
|
||||
import type { BookDesignDirectivesInput } from './design-directives.schema';
|
||||
import { ARCHETYPE_KIN, THEME_ARCHETYPE } from './design-template-pick';
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* A book laid on the designed templates (`layouts: 'templates'`): the
|
||||
* Studio's Auto layout and `design_book` with the templates.
|
||||
*
|
||||
* AUTODSN-020 pins a digest of every page on the auto layout's three parity
|
||||
* journeys and on a fourth (`design-templated.parity-inputs.json`: entries of
|
||||
* 0, 1, 2, 3, 5, 12 and 20 photos and a gallery), in three themes. A
|
||||
* deliberate change to a layout changes its digests: say so in the change
|
||||
* and write the new ones (the failure prints the whole map as it is now).
|
||||
*/
|
||||
|
||||
interface Fixture {
|
||||
input: AutoInput & Partial<BookDesignInput>;
|
||||
gallery?: AutoPhoto[];
|
||||
}
|
||||
|
||||
const here = (file: string) => fileURLToPath(new URL(file, import.meta.url));
|
||||
const read = <T>(file: string) => JSON.parse(readFileSync(here(file), 'utf8')) as T;
|
||||
const parity = {
|
||||
...read<Record<string, Fixture>>('../auto/auto-layout.parity-inputs.json'),
|
||||
...read<Record<string, Fixture>>('./design-templated.parity-inputs.json'),
|
||||
};
|
||||
const designFixtures = read<Record<string, { input: BookDesignInput }>>('./design-book.fixtures.json');
|
||||
const expected = read<Record<string, string>>('./design-templated.parity.json');
|
||||
|
||||
/** Every journey the specs lay out, as designBook takes it, with ids from a counter. */
|
||||
function inputs(): [string, BookDesignInput][] {
|
||||
const out: [string, BookDesignInput][] = [];
|
||||
for (const [name, fx] of Object.entries(parity)) {
|
||||
out.push([name, { ...fx.input, gallery: fx.gallery, candidates: [], photos: [] }]);
|
||||
}
|
||||
for (const [name, fx] of Object.entries(designFixtures)) out.push([name, fx.input]);
|
||||
return out;
|
||||
}
|
||||
|
||||
const counter = () => {
|
||||
let n = 0;
|
||||
return (p: string) => `${p}-${++n}`;
|
||||
};
|
||||
|
||||
const THEMES = ['fabric-classic', 'riso', 'midnight'] as const;
|
||||
|
||||
function laid(input: BookDesignInput, style: string, more: BookDesignDirectivesInput = {}): BookDesignResult {
|
||||
const directives = { style, layouts: 'templates', seed: 1, ...more } as BookDesignDirectivesInput;
|
||||
return designBook(null, { ...input, newId: counter() }, directives);
|
||||
}
|
||||
|
||||
const photoIdsOn = (spreads: readonly BookSpread[], where: 'elements' | 'parked') =>
|
||||
spreads.flatMap((s) => s[where].flatMap((e) => (e.kind === 'photo' && e.photoId != null ? [e.photoId] : [])));
|
||||
|
||||
/** Every photo the journey has, on its entries and loose in its gallery. */
|
||||
const journeyPhotos = (input: BookDesignInput) => [
|
||||
...input.entries.flatMap((e) => e.photos.map((p) => p.photoId)),
|
||||
...(input.gallery ?? []).map((p) => p.photoId),
|
||||
];
|
||||
|
||||
const STORY =
|
||||
'The ferry left in the rain and we stood on deck anyway, watching the islands go by one after the other. ';
|
||||
|
||||
/** A journey of one entry with this many photos, a story or none. */
|
||||
function single(photos: number, story: boolean): BookDesignInput {
|
||||
const base = parity.A as Fixture;
|
||||
const entry: AutoEntry = {
|
||||
id: 1,
|
||||
title: 'Islands',
|
||||
story: story ? STORY.repeat(3).trim() : null,
|
||||
location: 'Lofoten',
|
||||
date: '2026-06-02',
|
||||
photos: Array.from({ length: photos }, (_, i) => ({ photoId: 700 + i, width: 4000, height: 3000 })),
|
||||
lat: 68.2,
|
||||
lng: 13.6,
|
||||
country: 'NO',
|
||||
dayNumber: 1,
|
||||
dayCount: 1,
|
||||
};
|
||||
return { ...base.input, entries: [entry], candidates: [], photos: [] };
|
||||
}
|
||||
|
||||
describe('the book on the designed templates', () => {
|
||||
it('AUTODSN-001: is a valid book, as normalised, within the spread and element caps', () => {
|
||||
for (const [name, input] of inputs()) {
|
||||
for (const theme of THEMES) {
|
||||
const { doc } = laid(input, theme);
|
||||
expect(bookDocumentSchema.safeParse(doc).success, `${name} ${theme}`).toBe(true);
|
||||
expect(normalizeBookDocument(doc), `${name} ${theme}`).toEqual(doc);
|
||||
expect(doc.spreads.length).toBeLessThanOrEqual(MAX_SPREADS);
|
||||
for (const s of doc.spreads) expect(s.elements.length).toBeLessThanOrEqual(MAX_SPREAD_ELEMENTS);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("AUTODSN-002: the cover is a cover template of the theme's archetype", () => {
|
||||
for (const [name, input] of inputs()) {
|
||||
for (const theme of THEMES) {
|
||||
const result = laid(input, theme);
|
||||
const cover = result.doc.spreads[0] as BookSpread;
|
||||
const id = result.plan[0]?.template;
|
||||
if (!id) continue; // a journey without photos for any of its covers is composed
|
||||
expect(findDecoratedTemplate(id)?.archetype, `${name} ${theme}`).toBe(THEME_ARCHETYPE[theme]);
|
||||
expect(matchDecoratedTemplate(cover, result.doc.page)?.id, `${name} ${theme}`).toBe(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTODSN-003: every entry with something to show has a page, in the order of the journey', () => {
|
||||
for (const [name, input] of inputs()) {
|
||||
const { doc } = laid(input, 'fabric-classic');
|
||||
const told = [...new Set(doc.spreads.flatMap((s) => (s.entryId == null ? [] : [s.entryId])))];
|
||||
const substance = input.entries.filter((e) => e.photos.length || (e.story ?? '').trim()).map((e) => e.id);
|
||||
for (const id of substance) expect(told, `${name} ${id}`).toContain(id);
|
||||
const dated = [...input.entries].filter((e) => told.includes(e.id) && e.date);
|
||||
const order = told.filter((id) => dated.some((e) => e.id === id));
|
||||
const byDate = [...dated].sort((a, b) => (a.date ?? '').localeCompare(b.date ?? '')).map((e) => e.id);
|
||||
expect(order, name).toEqual(byDate);
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTODSN-004: no photo vanishes: it is on a page, parked with one, or said to be left out', () => {
|
||||
for (const [name, input] of inputs()) {
|
||||
for (const theme of THEMES) {
|
||||
const result = laid(input, theme);
|
||||
const on = new Set([
|
||||
...photoIdsOn(result.doc.spreads, 'elements'),
|
||||
...photoIdsOn(result.doc.spreads, 'parked'),
|
||||
]);
|
||||
const left = new Set(result.photos.left_out.map((l) => l.photoId));
|
||||
const lost = journeyPhotos(input).filter((id) => !on.has(id) && !left.has(id));
|
||||
expect(lost, `${name} ${theme}`).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTODSN-005: never a classic summary, countries or mark', () => {
|
||||
for (const [name, input] of inputs()) {
|
||||
for (const theme of THEMES) {
|
||||
for (const s of laid(input, theme).doc.spreads) {
|
||||
for (const e of s.elements) {
|
||||
if (e.kind === 'stats' || e.kind === 'countries') expect(e.look ?? 'classic', name).not.toBe('classic');
|
||||
if (e.kind === 'badge') expect(['plain', 'chip'], name).not.toContain(e.style);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTODSN-006: is kept in its theme, and another theme recolours the templated pages', () => {
|
||||
const input = inputs()[0]?.[1] as BookDesignInput;
|
||||
const { doc, plan } = laid(input, 'riso');
|
||||
expect(doc.theme?.id).toBe('riso');
|
||||
const night = bookThemeOf('midnight');
|
||||
const again = applyBookTheme(doc, night);
|
||||
const templated = plan.flatMap((r, i) => (r.template ? [i] : []));
|
||||
expect(templated.length).toBeGreaterThan(3);
|
||||
for (const i of templated) {
|
||||
const linked = (again.spreads[i] as BookSpread).elements.filter((e) => e.kind === 'text' && e.token === 'ink');
|
||||
for (const e of linked) if (e.kind === 'text') expect(e.color).toBe(bookThemeColor(night, 'ink'));
|
||||
}
|
||||
expect(again.spreads.some((s, i) => JSON.stringify(s) !== JSON.stringify(doc.spreads[i]))).toBe(true);
|
||||
});
|
||||
|
||||
it("AUTODSN-007: titles follow the entry's title and stories its story, a cut story going on over the next page", () => {
|
||||
const input = parity.A as Fixture;
|
||||
const { doc, notes } = laid({ ...input.input, candidates: [], photos: [] }, 'fabric-classic');
|
||||
const pages = doc.spreads.filter((s) => s.entryId != null);
|
||||
for (const s of pages) {
|
||||
const bound = s.elements.some(
|
||||
(e) => e.kind === 'text' && (e.binding?.source === 'entry.title' || e.binding?.source === 'entry.location'),
|
||||
);
|
||||
expect(bound, s.id).toBe(true);
|
||||
}
|
||||
const stories = pages.flatMap((s) =>
|
||||
s.elements.filter((e) => e.kind === 'text' && e.binding?.source === 'entry.story'),
|
||||
);
|
||||
expect(stories.length).toBeGreaterThan(0);
|
||||
const cut = stories.filter((e) => e.kind === 'text' && e.overridden);
|
||||
if (cut.length) expect(notes.some((n) => n.includes('cut to fit'))).toBe(true);
|
||||
for (const e of stories) if (e.kind === 'text' && !e.overridden) expect(e.text.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('AUTODSN-008: an entry of 0 to 20 photos, with a story or none, fills every frame it is laid on', () => {
|
||||
for (const n of [0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 20]) {
|
||||
for (const story of [false, true]) {
|
||||
if (!n && !story) continue;
|
||||
const input = single(n, story);
|
||||
const result = laid(input, 'fabric-classic');
|
||||
const pages = result.doc.spreads.filter((s) => s.entryId === 1);
|
||||
expect(pages.length, `${n} ${story}`).toBeGreaterThan(0);
|
||||
for (const s of pages) {
|
||||
for (const e of s.elements) if (e.kind === 'photo') expect(e.photoId, `${n} ${story}`).not.toBeNull();
|
||||
}
|
||||
const placed = photoIdsOn(pages, 'elements').length + photoIdsOn(pages, 'parked').length;
|
||||
const capped = result.photos.left_out.filter((l) => l.reason === 'chapter_max').length;
|
||||
expect(placed + capped, `${n} ${story}`).toBe(n);
|
||||
expect(capped).toBe(Math.max(0, n - 12));
|
||||
if (story) {
|
||||
const told = pages.some((s) =>
|
||||
s.elements.some((e) => e.kind === 'text' && e.binding?.source === 'entry.story'),
|
||||
);
|
||||
expect(told, `${n}`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("AUTODSN-009: the gallery's photos go to the entry of their day first, the rest to closing pages", () => {
|
||||
const fx = parity.D as Fixture;
|
||||
const input: BookDesignInput = { ...fx.input, gallery: fx.gallery, candidates: [], photos: [] };
|
||||
const { doc } = laid(input, 'fabric-classic');
|
||||
const of = (entryId: number) =>
|
||||
photoIdsOn(
|
||||
doc.spreads.filter((s) => s.entryId === entryId),
|
||||
'elements',
|
||||
);
|
||||
// 900 was taken on 1 June, the day of entry 101, which had no photos of its own.
|
||||
expect(of(101)).toContain(900);
|
||||
// An entry already holding all it takes leaves the loose photos to the closing pages of more moments.
|
||||
const full = single(12, false);
|
||||
const loose = Array.from({ length: 10 }, (_, i) => ({
|
||||
photoId: 800 + i,
|
||||
width: 4000,
|
||||
height: 3000,
|
||||
takenAt: null,
|
||||
}));
|
||||
const more = laid({ ...full, gallery: loose }, 'fabric-classic');
|
||||
const closing = more.doc.spreads.filter((s) => s.role === 'inner' && s.entryId === null);
|
||||
const shown = photoIdsOn(closing, 'elements');
|
||||
expect(shown.length).toBeGreaterThan(0);
|
||||
expect(shown.every((id) => id >= 800)).toBe(true);
|
||||
const left = more.photos.left_out.map((l) => l.photoId);
|
||||
expect(loose.every((p) => shown.includes(p.photoId) || left.includes(p.photoId))).toBe(true);
|
||||
});
|
||||
|
||||
it('AUTODSN-010: keeps a stored page that holds a locked element', () => {
|
||||
const input = inputs()[0]?.[1] as BookDesignInput;
|
||||
const first = laid(input, 'fabric-classic').doc;
|
||||
const page = first.spreads[5] as BookSpread;
|
||||
const locked: BookSpread = {
|
||||
...page,
|
||||
elements: page.elements.map((e, i) => (i === 0 ? { ...e, locked: true } : e)),
|
||||
};
|
||||
const stored: BookDocument = { ...first, spreads: first.spreads.map((s) => (s.id === page.id ? locked : s)) };
|
||||
const again = designBook(
|
||||
stored,
|
||||
{ ...input, newId: counter() },
|
||||
{ style: 'fabric-classic', layouts: 'templates', seed: 2 },
|
||||
);
|
||||
expect(again.doc.spreads).toContainEqual(locked);
|
||||
expect(again.plan.find((r) => r.spreadId === page.id)?.action).toBe('keep');
|
||||
});
|
||||
|
||||
it('AUTODSN-011: no page has a severe finding', () => {
|
||||
for (const [name, input] of inputs()) {
|
||||
for (const theme of THEMES) {
|
||||
const { doc } = laid(input, theme);
|
||||
doc.spreads.forEach((s, i) => {
|
||||
const severe = pageFindings(s, doc.page, input.photos).filter((f) => f.severity === 'severe');
|
||||
expect(
|
||||
severe.map((f) => f.code),
|
||||
`${name} ${theme} page ${i}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTODSN-012: the plan names the template every templated page came from', () => {
|
||||
const { plan, doc } = laid(inputs()[0]?.[1] as BookDesignInput, 'midnight');
|
||||
const named = plan.filter((r) => r.template);
|
||||
expect(named.length).toBeGreaterThan(3);
|
||||
for (const r of named) {
|
||||
expect(findDecoratedTemplate(r.template ?? '')).toBeDefined();
|
||||
const s = doc.spreads.find((x) => x.id === r.spreadId) as BookSpread;
|
||||
expect(matchDecoratedTemplate(s, doc.page)?.id, r.template).toBe(r.template);
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTODSN-013: the same seed lays the same book, another seed another', () => {
|
||||
const input = inputs()[0]?.[1] as BookDesignInput;
|
||||
const ids = (seed: number) =>
|
||||
laid(input, 'riso', { seed })
|
||||
.plan.map((r) => r.template ?? r.beat)
|
||||
.join();
|
||||
expect(ids(4)).toBe(ids(4));
|
||||
expect([5, 6, 7, 8].some((seed) => ids(seed) !== ids(4))).toBe(true);
|
||||
});
|
||||
|
||||
it("AUTODSN-014: a theme of the book's own is kept whole", () => {
|
||||
const own = {
|
||||
...bookThemeOf('postcard'),
|
||||
id: null,
|
||||
palette: { ...bookThemeOf('postcard').palette, accent: '#123456' },
|
||||
};
|
||||
const input = inputs()[0]?.[1] as BookDesignInput;
|
||||
const { doc } = designBook(
|
||||
null,
|
||||
{ ...input, theme: own, newId: counter() },
|
||||
{ style: 'postcard', layouts: 'templates' },
|
||||
);
|
||||
expect(doc.theme).toEqual(own);
|
||||
});
|
||||
|
||||
it('AUTODSN-015: without layouts the design is composed as before, with no template named', () => {
|
||||
const input = inputs()[0]?.[1] as BookDesignInput;
|
||||
const composed = designBook(null, { ...input, newId: counter() }, { style: 'riso', seed: 1 });
|
||||
expect(composed.plan.every((r) => r.template === undefined)).toBe(true);
|
||||
expect(composed.doc).toEqual(
|
||||
designBook(null, { ...input, newId: counter() }, { style: 'riso', seed: 1, layouts: 'composed' }).doc,
|
||||
);
|
||||
});
|
||||
|
||||
it("AUTODSN-016: bare stops are listed on a words-only template of the book's styles, every name in its story", () => {
|
||||
const base = parity.A as Fixture;
|
||||
const entries = base.input.entries.map((e) => ({ ...e, story: null, photos: [] }));
|
||||
const input = { ...base.input, entries, coverPhotoId: null, candidates: [], photos: [] };
|
||||
for (const theme of THEMES) {
|
||||
const { plan, doc } = laid(input, theme);
|
||||
const lists = plan.filter((r) => r.beat === 'stations');
|
||||
expect(lists.length, theme).toBeGreaterThan(0);
|
||||
const words: string[] = [];
|
||||
for (const r of lists) {
|
||||
const tpl = findDecoratedTemplate(r.template ?? '');
|
||||
expect(tpl && templatePhotoSlots(tpl), `${theme} ${r.template}`).toBe(0);
|
||||
expect([THEME_ARCHETYPE[theme], ...ARCHETYPE_KIN[THEME_ARCHETYPE[theme]]]).toContain(tpl?.archetype);
|
||||
const s = doc.spreads.find((x) => x.id === r.spreadId) as BookSpread;
|
||||
const texts = s.elements.flatMap((e) => (e.kind === 'text' ? [e.text] : []));
|
||||
expect(texts, theme).toContain(input.stationsLabel);
|
||||
words.push(...texts);
|
||||
}
|
||||
const listed = words.join(' ');
|
||||
for (const e of entries) {
|
||||
const name = e.title || e.location || '';
|
||||
if (name) expect(listed, `${theme} ${name}`).toContain(name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTODSN-020: lays the parity journeys exactly as pinned', () => {
|
||||
const actual = digests();
|
||||
const differ = [...actual].filter(([k, v]) => expected[k] !== v).map(([k]) => k);
|
||||
const missing = Object.keys(expected).filter((k) => !actual.has(k));
|
||||
const message = `the templated auto layout changed; if that is deliberate, write design-templated.parity.json as:\n${JSON.stringify(Object.fromEntries([...actual].sort()), null, 1)}`;
|
||||
expect(differ.length + missing.length, message).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── the digests ──────────────────────────────────────────────────────────
|
||||
|
||||
/** The canonical form auto-layout.parity.spec.ts digests: keys sorted, every id read as `#new`. */
|
||||
function canonical(value: unknown): string {
|
||||
const walk = (v: unknown, key?: string): unknown => {
|
||||
if (Array.isArray(v)) return v.map((x) => walk(x));
|
||||
if (v && typeof v === 'object') {
|
||||
const o = v as Record<string, unknown>;
|
||||
return Object.fromEntries(
|
||||
Object.keys(o)
|
||||
.sort()
|
||||
.map((k) => [k, walk(o[k], k)]),
|
||||
);
|
||||
}
|
||||
if (key === 'id' && typeof v === 'string') return '#new';
|
||||
return v;
|
||||
};
|
||||
return JSON.stringify(walk(value));
|
||||
}
|
||||
|
||||
const digest = (v: unknown) => createHash('sha256').update(canonical(v)).digest('hex').slice(0, 16);
|
||||
|
||||
/** Every page of every parity journey in every theme, digested. */
|
||||
function digests(): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const [name, fx] of Object.entries(parity)) {
|
||||
const input = { ...fx.input, gallery: fx.gallery, candidates: [], photos: [] };
|
||||
for (const theme of THEMES) {
|
||||
const result = laid(input, theme);
|
||||
const doc = result.doc;
|
||||
const put = (key: string, v: unknown) => out.set(`${name}/${theme}/${key}`, digest(v));
|
||||
put('head', {
|
||||
version: doc.version,
|
||||
title: doc.title,
|
||||
page: doc.page,
|
||||
theme: doc.theme,
|
||||
count: doc.spreads.length,
|
||||
});
|
||||
put(
|
||||
'plan',
|
||||
result.plan.map((r) => [r.beat, r.template ?? null, r.entryId, r.photoIds]),
|
||||
);
|
||||
doc.spreads.forEach((s, i) => put(`spread/${i}`, s));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
import type { AutoEntry, AutoPhoto } from '../auto/auto-types';
|
||||
import type { BookCoverArchetype } from '../book-archetypes';
|
||||
import { DESIGN_TEXT_HEADROOM, textHeightNeeded } from '../book-findings';
|
||||
import { formatBookDate } from '../book-text';
|
||||
import type { BookElement, BookSpread, BookTextElement } from '../book.schema';
|
||||
import { decoratedTemplatesFor } from '../templates/template-catalogue';
|
||||
import { templatePhotoSlots, templateRoles, type BookDecoratedTemplate } from '../templates/template-format';
|
||||
import type { BookTemplateWord } from '../templates/template-words';
|
||||
import type { BookDesignInput } from './design-book';
|
||||
import { cutWords, liveBox, photoEl, textEl, type ComposeCtx } from './design-compose';
|
||||
import { composeStations } from './design-framing';
|
||||
import type { FramingNeeds } from './design-inner';
|
||||
import type { DesignedSpread } from './design-merge';
|
||||
import type { BookDesignBeat } from './design-pacing';
|
||||
import { buildChapters, type DesignChapter } from './design-pacing';
|
||||
import type { BookDesignLeftOut, DesignPhotoChoice } from './design-photos';
|
||||
import { innerSpread } from './design-spreads';
|
||||
import {
|
||||
bodyCapacity,
|
||||
chunkPhotos,
|
||||
chunkSizesOf,
|
||||
innerPool,
|
||||
type ChunkSizes,
|
||||
pickInnerTemplate,
|
||||
templateEnergy,
|
||||
type TemplatePickCtx,
|
||||
} from './design-template-pick';
|
||||
import {
|
||||
composeTravelCountries,
|
||||
composeTravelSummary,
|
||||
composeWordsPage,
|
||||
countryLookFor,
|
||||
pourTemplate,
|
||||
summaryLookFor,
|
||||
} from './design-templated-framing';
|
||||
|
||||
/**
|
||||
* The inner pages of a book laid on the designed templates.
|
||||
*
|
||||
* The journey at a glance and its countries open it, in the travel looks.
|
||||
* Then every chapter: its photos cut into page-sized parts that leave no
|
||||
* frame empty (`chunkPhotos`), the first part carrying the chapter's words,
|
||||
* and each part laid on the decorated template that suits it best
|
||||
* (`pickInnerTemplate`), in the book's theme, its title, place, date and
|
||||
* story bound to the journal. A story too long for its frame is cut at a
|
||||
* sentence and goes on over a page of words. Runs of stops nobody wrote
|
||||
* about share a list as in every design. Photos of the journey's gallery that
|
||||
* found no entry with room close the book on pages of more moments.
|
||||
*/
|
||||
|
||||
interface TemplatedInnerResult {
|
||||
spreads: DesignedSpread[];
|
||||
leftOut: BookDesignLeftOut[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** What the inner pages are laid with beside the composer's context. */
|
||||
interface TemplatedPlan {
|
||||
archetype: BookCoverArchetype;
|
||||
/** The book counts as calm for template choice: its busy pages stay few. */
|
||||
calm: boolean;
|
||||
/** Loose photos of the journey no entry had room for, for the closing pages of more moments. */
|
||||
rest: readonly AutoPhoto[];
|
||||
}
|
||||
|
||||
/** The closing pages of more moments a book takes at most. */
|
||||
const MORE_MOMENTS = 3;
|
||||
/** Pages of words one story runs on to at most past its own. */
|
||||
const MOST_CONTINUED = 2;
|
||||
|
||||
/** The classic mark styles: never set by the auto layout, which sets a date mark as a stamp. */
|
||||
const CLASSIC_MARKS = new Set(['plain', 'chip', 'outline', 'stacked']);
|
||||
|
||||
/** The beat a templated page plays in the plan, read from its template's energy and photos. */
|
||||
function beatOf(tpl: BookDecoratedTemplate | null, photos: number): BookDesignBeat {
|
||||
if (!tpl) return photos ? 'grid' : 'story';
|
||||
const energy = templateEnergy(tpl);
|
||||
if (energy === 'loud') return 'hero';
|
||||
if (energy === 'lively' || energy === 'medium') return photos >= 4 ? 'grid' : photos >= 2 ? 'duo' : 'quiet';
|
||||
return photos === 0 ? 'story' : 'quiet';
|
||||
}
|
||||
|
||||
/** The words of a story the directives let a book set: all, an excerpt, or none. */
|
||||
function storyOf(c: ComposeCtx, entry: AutoEntry | undefined): string {
|
||||
const words = (entry?.story ?? '').trim();
|
||||
const mode = c.directives.text?.story ?? 'full';
|
||||
if (!words || mode === 'none') return '';
|
||||
return mode === 'excerpt' ? cutWords(words, c.directives.text?.excerptChars ?? 600) : words;
|
||||
}
|
||||
|
||||
/**
|
||||
* A page holding an entry's content for a template to pour: the story (bound
|
||||
* when it is the whole of it), the first photo's caption when captions are
|
||||
* asked of each photo, and the photos in order. The title, place and date the
|
||||
* template takes from the journal itself, bound to the entry.
|
||||
*/
|
||||
function entrySeedSpread(
|
||||
c: ComposeCtx,
|
||||
entry: AutoEntry,
|
||||
photos: readonly AutoPhoto[],
|
||||
story: { words: string; whole: boolean } | null,
|
||||
): BookSpread {
|
||||
const box = liveBox(c, 'right');
|
||||
const els: BookElement[] = [];
|
||||
if (story?.words) {
|
||||
const binding = { source: 'entry.story' as const, entryId: entry.id };
|
||||
els.push(textEl(c, box, story.words, 'body', { binding, overridden: !story.whole }));
|
||||
}
|
||||
const caption = photos[0]?.caption?.trim();
|
||||
const first = photos[0];
|
||||
if (caption && first && c.directives.text?.captions === 'photo') {
|
||||
const binding = { source: 'photo.caption' as const, photoId: first.photoId };
|
||||
els.push(textEl(c, box, caption, 'caption', { binding }));
|
||||
}
|
||||
els.push(...photos.map((p) => photoEl(c, p, box)));
|
||||
return innerSpread(c, els, entry.id);
|
||||
}
|
||||
|
||||
/** The longest start of `words` its laid frame holds, cut at a sentence where one is near. */
|
||||
function cutToFrame(el: BookTextElement, words: string): string {
|
||||
const fits = (s: string) => textHeightNeeded({ ...el, text: s }, DESIGN_TEXT_HEADROOM) <= el.frame.h;
|
||||
if (fits(words)) return words;
|
||||
let lo = 0;
|
||||
let hi = words.length;
|
||||
while (lo < hi) {
|
||||
const mid = Math.ceil((lo + hi) / 2);
|
||||
if (fits(`${words.slice(0, mid)}…`)) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
return cutWords(words, lo);
|
||||
}
|
||||
|
||||
const isStory = (e: BookElement): e is BookTextElement => e.kind === 'text' && e.binding?.source === 'entry.story';
|
||||
|
||||
/**
|
||||
* A laid page with its story cut to its frame where it runs over: marked as
|
||||
* no longer following the journal and said in the notes, the rest handed
|
||||
* back for the next page. A story that fits stays as it is.
|
||||
*/
|
||||
function fittedStory(c: ComposeCtx, spread: BookSpread, entry: AutoEntry): { spread: BookSpread; rest: string } {
|
||||
const story = spread.elements.find(isStory);
|
||||
if (!story) return { spread, rest: '' };
|
||||
const fitting = cutToFrame(story, story.text);
|
||||
if (fitting === story.text) return { spread, rest: '' };
|
||||
c.notes.push(`the story of entry ${entry.id} is cut to fit its page and no longer follows the journal`);
|
||||
const rest = story.text.slice(fitting.replace(/…$/, '').length).trim();
|
||||
const elements = fitting
|
||||
? spread.elements.map((e) => (e === story ? { ...story, text: fitting, overridden: true } : e))
|
||||
: spread.elements.filter((e) => e !== story);
|
||||
return { spread: { ...spread, elements }, rest };
|
||||
}
|
||||
|
||||
/** A laid page as the auto layout leaves it: date marks as stamps, and only its photos parked. */
|
||||
function tidied(spread: BookSpread): BookSpread {
|
||||
const elements = spread.elements.map((e) =>
|
||||
e.kind === 'badge' && CLASSIC_MARKS.has(e.style) ? { ...e, style: 'stamp' as const } : e,
|
||||
);
|
||||
// The words a template has no role for are the journal's own, bound to it: nothing is lost leaving them.
|
||||
return { ...spread, elements, parked: spread.parked.filter((e) => e.kind === 'photo') };
|
||||
}
|
||||
|
||||
/** The pick context for the next page of the book. */
|
||||
type PickBase = Omit<TemplatePickCtx, 'history' | 'key'>;
|
||||
|
||||
interface Laying {
|
||||
c: ComposeCtx;
|
||||
base: PickBase;
|
||||
/** Template ids laid so far, in order, for the picker's variety and rhythm. */
|
||||
history: string[];
|
||||
entries: readonly AutoEntry[];
|
||||
out: DesignedSpread[];
|
||||
}
|
||||
|
||||
/** A story to set on a page: its words, and whether they are the whole of the entry's. */
|
||||
export type PageStory = { words: string; whole: boolean } | null;
|
||||
|
||||
/** One page as it is laid: the spread, the template it was laid on (none for a composed one) and the story's rest. */
|
||||
export interface LaidPage {
|
||||
spread: BookSpread;
|
||||
template: BookDecoratedTemplate | null;
|
||||
rest: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of an entry laid on the template that suits it best, in the
|
||||
* book's theme, or composed as a page of words where none takes it; the
|
||||
* story cut to its frame, its rest handed back for the next page.
|
||||
*/
|
||||
export function layEntryPage(
|
||||
c: ComposeCtx,
|
||||
entry: AutoEntry,
|
||||
page: { photos: readonly AutoPhoto[]; story: PageStory; entries: readonly AutoEntry[] },
|
||||
pick: TemplatePickCtx,
|
||||
): LaidPage {
|
||||
const mappable = (entry.lat != null && entry.lng != null) || !!c.travel.stats?.points.length;
|
||||
const chunk = { photos: page.photos, story: page.story?.words ?? '', mappable };
|
||||
const tpl = pickInnerTemplate(chunk, pick);
|
||||
if (!tpl) {
|
||||
const fitted = fittedStory(c, composeWordsPage(c, entry, wordsColumn(c, entry, page.story)), entry);
|
||||
return { ...fitted, template: null };
|
||||
}
|
||||
const laid = tidied(pourTemplate(c, entrySeedSpread(c, entry, page.photos, page.story), tpl, page.entries));
|
||||
return { ...fittedStory(c, laid, entry), template: tpl };
|
||||
}
|
||||
|
||||
/** One page of the book laid and added; the rest of its story handed back. */
|
||||
function layPage(
|
||||
l: Laying,
|
||||
entry: AutoEntry,
|
||||
photos: readonly AutoPhoto[],
|
||||
story: PageStory,
|
||||
chapter: string | null,
|
||||
): string {
|
||||
const key = `e${entry.id}:${l.out.length}`;
|
||||
const laid = layEntryPage(l.c, entry, { photos, story, entries: l.entries }, { ...l.base, history: l.history, key });
|
||||
if (laid.template) l.history.push(laid.template.id);
|
||||
l.out.push({
|
||||
spread: laid.spread,
|
||||
beat: laid.template ? beatOf(laid.template, photos.length) : 'story',
|
||||
chapter,
|
||||
findings: [],
|
||||
...(laid.template ? { template: laid.template.id } : {}),
|
||||
});
|
||||
return laid.rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* A story set in a column down the left page, where no template takes it:
|
||||
* bound to the entry while it is the whole of it, for `fittedStory` to cut.
|
||||
*/
|
||||
function wordsColumn(c: ComposeCtx, entry: AutoEntry, story: PageStory) {
|
||||
if (!story?.words) return null;
|
||||
const binding = { source: 'entry.story' as const, entryId: entry.id };
|
||||
const el = textEl(c, liveBox(c, 'left'), story.words, 'body', { binding, overridden: !story.whole });
|
||||
return { ...el, frame: liveBox(c, 'left') };
|
||||
}
|
||||
|
||||
/** The best capacity of the pool's story templates for each photo count, for what a long story costs a page. */
|
||||
function storyRoom(c: ComposeCtx, pool: readonly BookDecoratedTemplate[]): Map<number, number> {
|
||||
const room = new Map<number, number>();
|
||||
for (const t of pool) {
|
||||
const cap = bodyCapacity(t, c.page, c.look.theme);
|
||||
if (cap > 0) room.set(templatePhotoSlots(t), Math.max(room.get(templatePhotoSlots(t)) ?? 0, cap));
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
/** A first page whose story frame cannot hold the story costs this much more: a page of words follows. */
|
||||
const SHORT_STORY_PAGE = 12;
|
||||
/** A page only another archetype has a template for costs this much more, so as few as can be are. */
|
||||
const FOREIGN_PAGE = 8;
|
||||
|
||||
/**
|
||||
* The photo counts a chapter's pages may take: those of the book's own pool
|
||||
* of templates, the pool the picker lays them from. `wide` adds those only
|
||||
* another archetype has, at a price, for a chapter the own pool would leave
|
||||
* a photo of without a frame.
|
||||
*/
|
||||
function chunkSizesFor(c: ComposeCtx, archetype: BookCoverArchetype, words: string, wide = false): ChunkSizes {
|
||||
const pool = innerPool(archetype);
|
||||
const own = chunkSizesOf(pool);
|
||||
const sizes = wide ? chunkSizesOf(decoratedTemplatesFor('inner')) : own;
|
||||
const room = storyRoom(c, words ? (wide ? decoratedTemplatesFor('inner') : pool) : []);
|
||||
const pageCost = (s: number, first: boolean) => {
|
||||
const foreign = !(first && words ? own.story : own.any).has(s);
|
||||
const short = first && words && (room.get(s) ?? 0) < words.length;
|
||||
return (foreign ? FOREIGN_PAGE : 0) + (short ? SHORT_STORY_PAGE : 0);
|
||||
};
|
||||
return { any: sizes.any, story: sizes.story, pageCost };
|
||||
}
|
||||
|
||||
/** A chapter's photos cut into pages of the own pool, or of any where the own pool leaves one without a frame. */
|
||||
function cutChapter(c: ComposeCtx, archetype: BookCoverArchetype, words: string, photos: number) {
|
||||
const own = chunkPhotos(photos, !!words, chunkSizesFor(c, archetype, words));
|
||||
if (!own.parked) return own;
|
||||
const wide = chunkPhotos(photos, !!words, chunkSizesFor(c, archetype, words, true));
|
||||
return wide.parked < own.parked ? wide : own;
|
||||
}
|
||||
|
||||
/** A chapter's pages: its photos cut into parts, the first taking its first entry's words, then the rest of them. */
|
||||
function chapterPages(l: Laying, chapter: DesignChapter, notes: string[]) {
|
||||
const entry = chapter.entries[0];
|
||||
if (!entry) return;
|
||||
const name = chapter.label ?? entry.title ?? null;
|
||||
const words = storyOf(l.c, entry);
|
||||
const cut = cutChapter(l.c, l.base.archetype, words, chapter.photos.length);
|
||||
let at = 0;
|
||||
let rest = '';
|
||||
cut.sizes.forEach((size, i) => {
|
||||
const story = i === 0 && words ? { words, whole: words === (entry.story ?? '').trim() } : null;
|
||||
rest = layPage(l, entry, chapter.photos.slice(at, at + size), story, name) || rest;
|
||||
at += size;
|
||||
});
|
||||
const parked = chapter.photos.slice(at);
|
||||
const last = l.out[l.out.length - 1];
|
||||
if (parked.length && last) {
|
||||
last.spread = {
|
||||
...last.spread,
|
||||
parked: [...last.spread.parked, ...parked.map((p) => photoEl(l.c, p, liveBox(l.c, 'right')))],
|
||||
};
|
||||
const what = parked.length === 1 ? '1 photo' : `${parked.length} photos`;
|
||||
notes.push(`${what} of entry ${entry.id} found no frame and wait with its last page`);
|
||||
}
|
||||
for (let n = 0; rest && n < MOST_CONTINUED; n++) rest = layPage(l, entry, [], { words: rest, whole: false }, name);
|
||||
if (rest) notes.push(`part of the story of entry ${entry.id} found no room on its pages and is left out`);
|
||||
// The further entries of a chapter of a day or a place each tell their own story on a page of words.
|
||||
for (const more of chapter.entries.slice(1)) {
|
||||
const told = storyOf(l.c, more);
|
||||
if (told) layPage(l, more, [], { words: told, whole: told === (more.story ?? '').trim() }, name);
|
||||
}
|
||||
}
|
||||
|
||||
/** The most stops one page of the list takes, so a page of them still reads as a list. */
|
||||
const STOPS_PER_PAGE = 10;
|
||||
|
||||
/** Bare stops as a list: under each date the names of the stops of that day, a blank line between days. */
|
||||
function stopList(c: ComposeCtx, stops: readonly AutoEntry[], dated: boolean): string {
|
||||
const days: { date: string; names: string[] }[] = [];
|
||||
for (const e of stops) {
|
||||
const date = e.date && dated ? formatBookDate(e.date, c.locale) : '';
|
||||
const name = (e.title || e.location || '').trim();
|
||||
if (!name) continue;
|
||||
const day = days[days.length - 1];
|
||||
if (day && day.date === date) day.names.push(name);
|
||||
else days.push({ date, names: [name] });
|
||||
}
|
||||
return days.map((d) => [d.date, ...d.names].filter(Boolean).join('\n')).join('\n\n');
|
||||
}
|
||||
|
||||
/** The one day all these stops were on, bound to the first of them, as a date role takes it; null without one. */
|
||||
function oneDay(c: ComposeCtx, stops: readonly AutoEntry[]): BookTemplateWord | null {
|
||||
const days = new Set(stops.map((e) => e.date ?? ''));
|
||||
const [day] = days;
|
||||
const first = stops[0];
|
||||
if (days.size !== 1 || !day || !first) return null;
|
||||
return { text: formatBookDate(day, c.locale), binding: { source: 'entry.date', entryId: first.id, value: day } };
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of bare stops on a words-only template of the book's styles: the
|
||||
* list's label as its title, the stops as its story (given by role, since a
|
||||
* short list that opens on a date would read as a date line), as many as its
|
||||
* story frame holds. Stops of one day set that day in the template's date
|
||||
* and only their names in the list. Undefined when no template takes a list.
|
||||
*/
|
||||
function stationsPage(l: Laying, stops: readonly AutoEntry[], label: string) {
|
||||
for (let n = Math.min(stops.length, STOPS_PER_PAGE); n > 0; n--) {
|
||||
const some = stops.slice(0, n);
|
||||
const key = `stations:${l.out.length}`;
|
||||
const pick = (list: string) =>
|
||||
pickInnerTemplate({ photos: [], story: list, mappable: false }, { ...l.base, history: l.history, key });
|
||||
const tpl = pick(stopList(l.c, some, true));
|
||||
if (!tpl) return undefined;
|
||||
const day = templateRoles(tpl).includes('date') ? oneDay(l.c, some) : null;
|
||||
const list = stopList(l.c, some, !day);
|
||||
if (bodyCapacity(tpl, l.c.page, l.c.look.theme) < list.length && n > 1) continue;
|
||||
const words = {
|
||||
title: { text: label },
|
||||
body: { text: list },
|
||||
...(day ? { date: day } : {}),
|
||||
};
|
||||
return { spread: tidied(pourTemplate(l.c, innerSpread(l.c, [], null), tpl, l.entries, words)), tpl, placed: n };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The pages of a list of bare stops: as many as the stops need, each on a words-only template where one takes it. */
|
||||
function stationsPages(l: Laying, chapter: DesignChapter, input: BookDesignInput) {
|
||||
let rest = chapter.entries;
|
||||
while (rest.length) {
|
||||
const page = stationsPage(l, rest, input.stationsLabel);
|
||||
if (page) {
|
||||
l.history.push(page.tpl.id);
|
||||
l.out.push({ spread: page.spread, beat: 'stations', chapter: null, findings: [], template: page.tpl.id });
|
||||
rest = rest.slice(page.placed);
|
||||
continue;
|
||||
}
|
||||
const set = composeStations(l.c, rest, input.stationsLabel);
|
||||
if (!set.placed) break;
|
||||
l.out.push({ spread: innerSpread(l.c, set.elements, null), beat: 'stations', chapter: null, findings: [] });
|
||||
rest = rest.slice(set.placed);
|
||||
}
|
||||
}
|
||||
|
||||
/** The closing pages of more moments: the photos no entry had room for, on grids, at most three pages. */
|
||||
function moreMoments(l: Laying, rest: readonly AutoPhoto[], leftOut: BookDesignLeftOut[]) {
|
||||
let left = [...rest];
|
||||
const sizes = chunkSizesOf(innerPool(l.base.archetype)).any;
|
||||
const biggest = Math.max(...[...sizes].filter((s) => s > 0), 1);
|
||||
for (let n = 0; left.length && n < MORE_MOMENTS; n++) {
|
||||
const take = left.slice(0, biggest);
|
||||
const tpl = pickInnerTemplate(
|
||||
{ photos: take, story: '', mappable: false },
|
||||
{ ...l.base, history: l.history, key: `more:${n}` },
|
||||
);
|
||||
if (!tpl) break;
|
||||
const used = take.slice(0, templatePhotoSlots(tpl));
|
||||
const seed = innerSpread(
|
||||
l.c,
|
||||
used.map((p) => photoEl(l.c, p, liveBox(l.c, 'right'))),
|
||||
null,
|
||||
);
|
||||
const laid = tidied(pourTemplate(l.c, seed, tpl, l.entries));
|
||||
l.history.push(tpl.id);
|
||||
l.out.push({ spread: laid, beat: beatOf(tpl, used.length), chapter: null, findings: [], template: tpl.id });
|
||||
left = left.slice(used.length);
|
||||
}
|
||||
for (const p of left) leftOut.push({ photoId: p.photoId, reason: 'page_budget' });
|
||||
}
|
||||
|
||||
/** The pages past the budget are left out from the end, their photos said to be left out for it. */
|
||||
function withinCap(body: DesignedSpread[], keep: number, leftOut: BookDesignLeftOut[], notes: string[]) {
|
||||
if (body.length <= keep) return body;
|
||||
const cut = body.slice(Math.max(0, keep));
|
||||
for (const d of cut) {
|
||||
for (const e of [...d.spread.elements, ...d.spread.parked]) {
|
||||
if (e.kind === 'photo' && e.photoId != null) leftOut.push({ photoId: e.photoId, reason: 'page_budget' });
|
||||
}
|
||||
}
|
||||
notes.push(`${cut.length} pages past the page budget are left out`);
|
||||
return body.slice(0, Math.max(0, keep));
|
||||
}
|
||||
|
||||
/** The journey at a glance and its countries, as the directives allow them. */
|
||||
function framingPages(c: ComposeCtx, archetype: BookCoverArchetype): DesignedSpread[] {
|
||||
const travel = c.directives.travel;
|
||||
const out: DesignedSpread[] = [];
|
||||
const summary = (travel?.summary ?? true) ? composeTravelSummary(c, summaryLookFor(archetype)) : null;
|
||||
if (summary) out.push({ spread: summary, beat: 'summary', chapter: null, findings: [] });
|
||||
const countries = (travel?.countries ?? true) ? composeTravelCountries(c, countryLookFor(archetype)) : null;
|
||||
if (countries) out.push({ spread: countries, beat: 'countries', chapter: null, findings: [] });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every inner page of a book laid on the designed templates, in order. */
|
||||
export function composeTemplatedInner(
|
||||
c: ComposeCtx,
|
||||
entries: readonly AutoEntry[],
|
||||
choice: DesignPhotoChoice,
|
||||
input: BookDesignInput,
|
||||
needs: FramingNeeds,
|
||||
plan: TemplatedPlan,
|
||||
): TemplatedInnerResult {
|
||||
const notes: string[] = [];
|
||||
const leftOut: BookDesignLeftOut[] = [];
|
||||
const cap = Math.max(0, Math.min(c.directives.pages?.max ?? needs.room, needs.room));
|
||||
const head = framingPages(c, plan.archetype).slice(0, Math.max(0, cap - 1));
|
||||
const base: PickBase = {
|
||||
archetype: plan.archetype,
|
||||
page: c.page,
|
||||
theme: c.look.theme,
|
||||
calm: plan.calm,
|
||||
seed: c.directives.seed ?? 0,
|
||||
};
|
||||
const l: Laying = { c, base, history: [], entries, out: [] };
|
||||
for (const chapter of buildChapters(entries, choice.byEntry, c.directives, input.candidates)) {
|
||||
if (chapter.stations) stationsPages(l, chapter, input);
|
||||
else chapterPages(l, chapter, notes);
|
||||
}
|
||||
moreMoments(l, plan.rest, leftOut);
|
||||
const body = withinCap(l.out, cap - head.length, leftOut, notes);
|
||||
return { spreads: [...head, ...body], leftOut, notes };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { bookPageSetupForPreset } from '../book-pages';
|
||||
import { bookPhotoElementSchema, type BookSpread } from '../book.schema';
|
||||
import { bookThemeOf } from '../theme/book-theme';
|
||||
import { applyDecoratedTemplate } from './template-apply';
|
||||
import { BOOK_DECORATED_TEMPLATES } from './template-catalogue';
|
||||
import { matchDecoratedTemplate } from './template-match';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/** A laid page knows which decorated template it is. */
|
||||
|
||||
const words = {
|
||||
title: { text: 'Portugal' },
|
||||
subtitle: { text: 'Lisbon to the Algarve' },
|
||||
date: { text: '2026', binding: { source: 'entry.date' as const, entryId: 7, value: '2026-05-14' } },
|
||||
body: { text: 'We took the early train out of Lisbon and walked up through the fog to the palace.' },
|
||||
caption: { text: 'Pena Palace in the fog' },
|
||||
};
|
||||
|
||||
const photo = (id: string, photoId: number) =>
|
||||
bookPhotoElementSchema.parse({ id, kind: 'photo', frame: { x: 0, y: 0, w: 10, h: 10 }, photoId });
|
||||
|
||||
const page = (role: BookSpread['role'], photos: number): BookSpread => ({
|
||||
id: 'sp',
|
||||
role,
|
||||
background: null,
|
||||
elements: Array.from({ length: photos }, (_, i) => photo(`p${i}`, i + 1)),
|
||||
parked: [],
|
||||
entryId: null,
|
||||
});
|
||||
|
||||
let n = 0;
|
||||
const newId = (p: string) => `${p}-${++n}`;
|
||||
|
||||
describe('matchDecoratedTemplate', () => {
|
||||
it('TPLMATCH-001: recognises every shipped template laid on every page format', () => {
|
||||
for (const preset of ['square-210', 'a4-landscape', 'a4-portrait', 'a5-landscape'] as const) {
|
||||
const format = bookPageSetupForPreset(preset);
|
||||
for (const tpl of BOOK_DECORATED_TEMPLATES) {
|
||||
const laid = applyDecoratedTemplate(page(tpl.kind === 'single' ? 'cover' : 'inner', 8), tpl, format, {
|
||||
words,
|
||||
newId,
|
||||
});
|
||||
expect(matchDecoratedTemplate(laid, format)?.id, `${tpl.id} on ${preset}`).toBe(tpl.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("TPLMATCH-002: recognises it in a theme's colours and with fewer words and photos than it takes", () => {
|
||||
const format = bookPageSetupForPreset('square-210');
|
||||
const theme = bookThemeOf('midnight');
|
||||
for (const tpl of BOOK_DECORATED_TEMPLATES) {
|
||||
const laid = applyDecoratedTemplate(page(tpl.kind === 'single' ? 'cover' : 'inner', 1), tpl, format, {
|
||||
theme,
|
||||
words: { title: words.title },
|
||||
newId,
|
||||
});
|
||||
expect(matchDecoratedTemplate(laid, format)?.id, tpl.id).toBe(tpl.id);
|
||||
}
|
||||
});
|
||||
|
||||
it('TPLMATCH-003: a page rearranged by hand is no template', () => {
|
||||
const format = bookPageSetupForPreset('square-210');
|
||||
const tpl = BOOK_DECORATED_TEMPLATES.find((t) => t.id === 'stamp-row');
|
||||
if (!tpl) throw new Error('stamp-row is shipped');
|
||||
const laid = applyDecoratedTemplate(page('inner', 4), tpl, format, { words, newId });
|
||||
const moved = { ...laid, elements: laid.elements.map((e) => ({ ...e, frame: { ...e.frame, x: e.frame.x + 9 } })) };
|
||||
expect(matchDecoratedTemplate(moved, format)).toBeUndefined();
|
||||
expect(matchDecoratedTemplate({ ...laid, elements: [] }, format)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('TPLMATCH-004: a cover is matched among the covers, a spread among the spreads', () => {
|
||||
const format = bookPageSetupForPreset('square-210');
|
||||
for (const tpl of BOOK_DECORATED_TEMPLATES) {
|
||||
const laid = applyDecoratedTemplate(page(tpl.kind === 'single' ? 'back' : 'inner', 3), tpl, format, { newId });
|
||||
expect(matchDecoratedTemplate(laid, format)?.kind ?? tpl.kind).toBe(tpl.kind);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { BookElement, BookPageSetup, BookSpread } from '../book.schema';
|
||||
import { layDecoratedTemplate } from './template-apply';
|
||||
import { BOOK_DECORATED_TEMPLATES } from './template-catalogue';
|
||||
import type { BookDecoratedTemplate } from './template-format';
|
||||
|
||||
/**
|
||||
* Which decorated template a laid page is, read from the page alone.
|
||||
*
|
||||
* A template keeps its frames where it pours: the photo frames, the role
|
||||
* texts and the design (panels, shapes, lines, stickers) all land where the
|
||||
* template put them on this page's format. So a page is fingerprinted by its
|
||||
* elements' kinds and frames, rounded to the millimetre, and compared with
|
||||
* every template laid on the same format: a template is the page's when
|
||||
* nearly all of its design is there, and of those the one that explains the
|
||||
* most of the page wins. A page somebody rearranged by hand matches none.
|
||||
*
|
||||
* Nothing depends on the answer being right: the auto layout's "this spread"
|
||||
* uses it to offer another template than the one on screen, and at worst
|
||||
* offers that one again.
|
||||
*/
|
||||
|
||||
/** The share of a template's design elements a page must hold to be that template. */
|
||||
const DESIGN_SHARE = 0.8;
|
||||
/** The share of the page's own elements the template must explain: a template of little design is no excuse. */
|
||||
const PAGE_SHARE = 0.6;
|
||||
|
||||
/** A photo frame, and a map laid as one when it had nothing to draw, read alike. */
|
||||
const kindKey = (kind: BookElement['kind']) => (kind === 'map' ? 'photo' : kind);
|
||||
|
||||
const keyOf = (el: BookElement) => {
|
||||
const f = el.frame;
|
||||
return [kindKey(el.kind), Math.round(f.x), Math.round(f.y), Math.round(f.w), Math.round(f.h)].join(':');
|
||||
};
|
||||
|
||||
interface Fingerprint {
|
||||
/** Every laid element, its role's ones included. */
|
||||
all: string[];
|
||||
/** The design alone: what no page's content changes. */
|
||||
design: string[];
|
||||
}
|
||||
|
||||
const laidPrints = new Map<string, Fingerprint>();
|
||||
|
||||
/** A template's fingerprint on a page format, laid once per format. */
|
||||
function printOf(tpl: BookDecoratedTemplate, page: BookPageSetup): Fingerprint {
|
||||
const key = `${tpl.id}|${page.pageWidth}x${page.pageHeight}|${page.bleed}`;
|
||||
const known = laidPrints.get(key);
|
||||
if (known) return known;
|
||||
let n = 0;
|
||||
const laid = layDecoratedTemplate(tpl, page, null, (p) => `${p}${++n}`);
|
||||
const print = { all: laid.map((l) => keyOf(l.el)), design: laid.filter((l) => !l.role).map((l) => keyOf(l.el)) };
|
||||
laidPrints.set(key, print);
|
||||
return print;
|
||||
}
|
||||
|
||||
/** How many of `want` the multiset `have` holds. */
|
||||
function held(want: readonly string[], have: ReadonlyMap<string, number>): number {
|
||||
const left = new Map(have);
|
||||
let found = 0;
|
||||
for (const k of want) {
|
||||
const n = left.get(k) ?? 0;
|
||||
if (n > 0) {
|
||||
left.set(k, n - 1);
|
||||
found++;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The decorated template a page was laid on, or undefined when it reads as none. */
|
||||
export function matchDecoratedTemplate(spread: BookSpread, page: BookPageSetup): BookDecoratedTemplate | undefined {
|
||||
const own = spread.elements.filter((e) => !e.locked);
|
||||
if (!own.length) return undefined;
|
||||
const have = new Map<string, number>();
|
||||
for (const el of own) have.set(keyOf(el), (have.get(keyOf(el)) ?? 0) + 1);
|
||||
const kind = spread.role === 'inner' ? 'inner' : 'single';
|
||||
let best: { tpl: BookDecoratedTemplate; explained: number; found: number } | undefined;
|
||||
for (const tpl of BOOK_DECORATED_TEMPLATES) {
|
||||
if (tpl.kind !== kind) continue;
|
||||
const print = printOf(tpl, page);
|
||||
const design = held(print.design, have);
|
||||
if (design < print.design.length * DESIGN_SHARE) continue;
|
||||
const found = held(print.all, have);
|
||||
const explained = found / own.length;
|
||||
if (explained < PAGE_SHARE) continue;
|
||||
if (!best || explained > best.explained || (explained === best.explained && found > best.found)) {
|
||||
best = { tpl, explained, found };
|
||||
}
|
||||
}
|
||||
return best?.tpl;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { AutoEntry } from '../auto/auto-types';
|
||||
import type { BookTravelContext } from '../book-travel';
|
||||
import { bookElementSchema, bookPageSetupSchema, type BookElement } from '../book.schema';
|
||||
import type { JourneyStats } from '../journey-stats.schema';
|
||||
import { bookThemeColor, bookThemeOf } from '../theme/book-theme';
|
||||
import {
|
||||
BOOK_TRAVEL_COUNTRY_LOOKS,
|
||||
BOOK_TRAVEL_FIGURE_SHELVES,
|
||||
BOOK_TRAVEL_MARK_STYLES,
|
||||
BOOK_TRAVEL_SUMMARY_LOOKS,
|
||||
bookTravelLooks,
|
||||
} from './book-travel-looks';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/** The travel elements in their newer looks, as the Travel panel and the auto layout both build them. */
|
||||
|
||||
type Point = JourneyStats['points'][number];
|
||||
const pt = (label: string, at: [number, number], country: string, entryId: number): Point => ({
|
||||
lat: at[0],
|
||||
lng: at[1],
|
||||
label,
|
||||
date: null,
|
||||
country,
|
||||
tripId: 1,
|
||||
photoId: null,
|
||||
entryId,
|
||||
});
|
||||
|
||||
const stats: JourneyStats = {
|
||||
journeyId: 5,
|
||||
distance: 4_000_000,
|
||||
days: 21,
|
||||
steps: 6,
|
||||
photos: 40,
|
||||
places: 12,
|
||||
furthest: 3_000_000,
|
||||
countries: [
|
||||
{ code: 'IS', name: 'Iceland', places: 2, firstVisit: '2026-06-02' },
|
||||
{ code: 'PT', name: 'Portugal', places: 1, firstVisit: '2026-09-04' },
|
||||
],
|
||||
points: [
|
||||
pt('Reykjavík', [64.14, -21.94], 'IS', 1),
|
||||
pt('Akureyri', [65.68, -18.12], 'IS', 2),
|
||||
pt('Lisboa', [38.72, -9.14], 'PT', 3),
|
||||
],
|
||||
trips: [],
|
||||
excluded: [],
|
||||
start: '2026-06-02',
|
||||
end: '2026-09-12',
|
||||
};
|
||||
|
||||
const entry: AutoEntry = {
|
||||
id: 2,
|
||||
title: 'North',
|
||||
story: null,
|
||||
location: 'Akureyri',
|
||||
date: '2026-06-06',
|
||||
photos: [],
|
||||
lat: 65.68,
|
||||
lng: -18.12,
|
||||
country: 'IS',
|
||||
dayNumber: 5,
|
||||
dayCount: 21,
|
||||
};
|
||||
|
||||
const travel: BookTravelContext = {
|
||||
locale: 'en',
|
||||
stats,
|
||||
path: [],
|
||||
entries: [entry],
|
||||
labels: {
|
||||
day: 'DAY {day}',
|
||||
stations: 'Stops',
|
||||
summary: 'Summary',
|
||||
countries: 'Countries',
|
||||
distance: 'km',
|
||||
pros: '+',
|
||||
cons: '-',
|
||||
},
|
||||
};
|
||||
|
||||
const page = bookPageSetupSchema.parse({});
|
||||
const theme = bookThemeOf('riso');
|
||||
let n = 0;
|
||||
const newId = (p: string) => `${p}-${++n}`;
|
||||
const place = (size: { w: number; h: number }) => ({ x: 1, y: 2, w: size.w, h: size.h });
|
||||
const looks = bookTravelLooks({ page, stats, travel, theme, newId, place });
|
||||
const plain = bookTravelLooks({ page, stats, travel, theme: null, newId, place });
|
||||
|
||||
const valid = (el: BookElement | null) => {
|
||||
expect(el).not.toBeNull();
|
||||
expect(bookElementSchema.safeParse(el).success).toBe(true);
|
||||
};
|
||||
|
||||
/** Every element of every shelf, as the panel offers them. */
|
||||
function everyElement(of: typeof looks): BookElement[] {
|
||||
const out: BookElement[] = [];
|
||||
for (const look of BOOK_TRAVEL_SUMMARY_LOOKS) out.push(of.summaryEl(look));
|
||||
for (const shelf of BOOK_TRAVEL_FIGURE_SHELVES) for (const m of shelf.metrics) out.push(of.figureEl(m, shelf.look));
|
||||
for (const code of ['IS', 'PT']) for (const look of BOOK_TRAVEL_COUNTRY_LOOKS) out.push(of.countryEl(code, look));
|
||||
for (const kind of ['shape', 'satellite', 'box'] as const) out.push(of.countryMapEl('IS', kind));
|
||||
out.push(of.mapEl({ style: 'minimal' }), of.mapEl({ style: 'dark', pins: 'numbered' }));
|
||||
for (const { style, variants } of BOOK_TRAVEL_MARK_STYLES) {
|
||||
for (const v of variants)
|
||||
out.push(...[of.markEl(v, style), of.markEl(v, style, { entry })].flatMap((e) => (e ? [e] : [])));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('the travel looks', () => {
|
||||
it('TRVLOOK-001: every factory answers an element the document takes, in every look it offers', () => {
|
||||
const all = everyElement(looks);
|
||||
expect(all.length).toBeGreaterThan(60);
|
||||
for (const el of all) valid(el);
|
||||
});
|
||||
|
||||
it('TRVLOOK-002: never in the classic look or a classic mark style', () => {
|
||||
for (const el of [...everyElement(looks), ...everyElement(plain)]) {
|
||||
if (el.kind === 'stats' || el.kind === 'countries') expect(el.look).toBeDefined();
|
||||
if (el.kind === 'stats' || el.kind === 'countries') expect(el.look).not.toBe('classic');
|
||||
if (el.kind === 'badge') expect(['plain', 'chip', 'outline', 'stacked']).not.toContain(el.style);
|
||||
}
|
||||
});
|
||||
|
||||
it("TRVLOOK-003: dressed in the book's theme, its links kept", () => {
|
||||
const stamp = looks.countryEl('IS', 'stamp');
|
||||
expect(stamp).toMatchObject({ token: 'ink', accentToken: 'accent2', fontRole: 'heading' });
|
||||
if (stamp.kind !== 'countries') throw new Error('a country element');
|
||||
expect(stamp.color).toBe(bookThemeColor(theme, 'ink'));
|
||||
expect(stamp.accent).toBe(bookThemeColor(theme, 'accent2'));
|
||||
expect(looks.summaryEl('note')).toMatchObject({ fontRole: 'script' });
|
||||
// Without a theme the engine's own colours stay, unlinked.
|
||||
expect(plain.countryEl('IS', 'stamp')).not.toHaveProperty('token');
|
||||
});
|
||||
|
||||
it('TRVLOOK-004: lands in the frame given, else where the caller places one of its size', () => {
|
||||
const frame = { x: 10, y: 20, w: 30, h: 40 };
|
||||
expect(looks.summaryEl('poster', frame).frame).toEqual(frame);
|
||||
expect(looks.countryEl('PT', 'visa', frame).frame).toEqual(frame);
|
||||
expect(looks.figureEl('distance', 'odometer', frame).frame).toEqual(frame);
|
||||
expect(looks.mapEl({ style: 'minimal' }, 'journey', 'vector', frame).frame).toEqual(frame);
|
||||
expect(looks.markEl('day', 'stamp', { entry, frame })?.frame).toEqual(frame);
|
||||
const placed = looks.summaryEl('ticket');
|
||||
expect(placed.frame).toMatchObject({ x: 1, y: 2, ...looks.summarySize('ticket') });
|
||||
});
|
||||
|
||||
it("TRVLOOK-005: a mark takes an entry's value when given one, the journey's when not", () => {
|
||||
const day = looks.markEl('day', 'stamp', { entry });
|
||||
const first = looks.markEl('day', 'stamp');
|
||||
expect(day).toMatchObject({ kind: 'badge', text: 'DAY 5' });
|
||||
expect(first).toMatchObject({ kind: 'badge', text: 'DAY 1' });
|
||||
expect(looks.markEl('flag', 'tag', { entry })).toMatchObject({ code: 'IS' });
|
||||
const lost = bookTravelLooks({ page, stats, travel: { ...travel, stats: null }, theme, newId, place });
|
||||
expect(lost.markEl('distance', 'pill')).toBeNull();
|
||||
});
|
||||
|
||||
it("TRVLOOK-006: a country's map holds that country's stops alone, cut to its shape", () => {
|
||||
const map = looks.countryMapEl('IS', 'shape');
|
||||
if (map.kind !== 'map') throw new Error('a map');
|
||||
expect(map.points).toHaveLength(2);
|
||||
expect(map).toMatchObject({ clip: 'country', style: 'dark', pinStyle: 'numbered' });
|
||||
expect(looks.countryMapEl('IS', 'box')).toMatchObject({ clip: 'rect', style: 'minimal' });
|
||||
expect(looks.countryName('PT')).toBe('Portugal');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
import type { AutoEntry } from '../auto/auto-types';
|
||||
import {
|
||||
bookBadgeValue,
|
||||
bookCountryName,
|
||||
bookTravelBadge,
|
||||
bookTravelCountries,
|
||||
bookTravelMap,
|
||||
bookTravelSize,
|
||||
bookTravelStats,
|
||||
type BookMapScope,
|
||||
type BookTravelContext,
|
||||
} from '../book-travel';
|
||||
import type {
|
||||
BookBadgeStyle,
|
||||
BookBadgeVariant,
|
||||
BookCountryLook,
|
||||
BookElement,
|
||||
BookFrame,
|
||||
BookMapElement,
|
||||
BookMetric,
|
||||
BookPageSetup,
|
||||
BookStatsLook,
|
||||
} from '../book.schema';
|
||||
import type { JourneyStats } from '../journey-stats.schema';
|
||||
import { themedElement } from '../theme/book-theme-apply';
|
||||
import type { BookColorToken, BookTheme } from '../theme/book-theme.schema';
|
||||
|
||||
/**
|
||||
* The travel elements in their newer looks, dressed in the book's theme: a
|
||||
* summary as a poster, a ticket or a sign, a single figure on a medal or an
|
||||
* odometer, a map of the journey or of one country, a mark as a stamp or a
|
||||
* tag, a country as a stamp, a visa or a postcard.
|
||||
*
|
||||
* One factory for the Studio's Travel panel, whose cards are the elements
|
||||
* their click places, and for the auto layout, which builds its framing pages
|
||||
* from the same looks. Both take the book engine's own factories
|
||||
* (book-travel.ts), so a card, a designed page and what the assistant places
|
||||
* are one thing. Where an element lands is the caller's: the panel centres it
|
||||
* on the spread on screen, a designed page names its frame.
|
||||
*/
|
||||
|
||||
/** The figures a summary offers: how far, how long, where, and how much was kept. */
|
||||
const SUMMARY_METRICS: readonly BookMetric[] = ['distance', 'days', 'countries', 'photos'];
|
||||
|
||||
/** The looks of a summary, in the order the panel offers them. */
|
||||
export const BOOK_TRAVEL_SUMMARY_LOOKS: readonly BookStatsLook[] = [
|
||||
'poster',
|
||||
'ticket',
|
||||
'tiles',
|
||||
'sign',
|
||||
'odometer',
|
||||
'note',
|
||||
'sketch',
|
||||
'band',
|
||||
];
|
||||
|
||||
/** The marks the journey fills, in the order a page is read: when, which day, how far, where. */
|
||||
export const BOOK_TRAVEL_MARK_VARIANTS = ['date', 'day', 'distance', 'coords', 'flag'] as const;
|
||||
export type BookTravelMarkVariant = (typeof BOOK_TRAVEL_MARK_VARIANTS)[number];
|
||||
|
||||
/** The newer styles a mark comes in, and which of them each mark is offered in. */
|
||||
export const BOOK_TRAVEL_MARK_STYLES: readonly {
|
||||
style: BookBadgeStyle;
|
||||
variants: readonly BookTravelMarkVariant[];
|
||||
}[] = [
|
||||
{ style: 'stamp', variants: ['date', 'day', 'flag'] },
|
||||
{ style: 'tag', variants: ['day', 'distance', 'coords', 'flag'] },
|
||||
{ style: 'pill', variants: ['date', 'day', 'distance', 'coords', 'flag'] },
|
||||
{ style: 'ribbon', variants: ['date', 'day', 'distance', 'flag'] },
|
||||
{ style: 'sticker', variants: ['date', 'day', 'distance', 'flag'] },
|
||||
{ style: 'patch', variants: ['day', 'distance', 'flag'] },
|
||||
{ style: 'sketch', variants: ['date', 'day', 'distance', 'coords', 'flag'] },
|
||||
];
|
||||
|
||||
/** The single figures, one shelf per look, with the figures each look suits. */
|
||||
export const BOOK_TRAVEL_FIGURE_SHELVES: readonly { look: BookStatsLook; metrics: readonly BookMetric[] }[] = [
|
||||
{ look: 'medal', metrics: ['distance', 'days', 'steps', 'photos', 'countries', 'places', 'furthest'] },
|
||||
{ look: 'odometer', metrics: ['distance', 'furthest', 'days', 'photos'] },
|
||||
{ look: 'sketch', metrics: ['distance', 'days', 'countries', 'photos'] },
|
||||
];
|
||||
|
||||
/** The looks a country is offered in. */
|
||||
export const BOOK_TRAVEL_COUNTRY_LOOKS: readonly BookCountryLook[] = [
|
||||
'stamp',
|
||||
'visa',
|
||||
'postcard',
|
||||
'flag',
|
||||
'pin',
|
||||
'label',
|
||||
'sketch',
|
||||
];
|
||||
|
||||
/** The looks written by hand take the theme's script face rather than its heading. */
|
||||
const HANDWRITTEN = new Set<string>(['note', 'sketch']);
|
||||
|
||||
/** Which of the theme's accents each country look takes, so a shelf of them is not one colour. */
|
||||
export const BOOK_TRAVEL_COUNTRY_ACCENT: Partial<Record<BookCountryLook, BookColorToken>> = {
|
||||
stamp: 'accent2',
|
||||
visa: 'accent3',
|
||||
pin: 'accent',
|
||||
label: 'accent2',
|
||||
};
|
||||
|
||||
/** Which of the theme's accents each mark takes, so a page of them is not one colour. */
|
||||
const MARK_ACCENT: Record<BookTravelMarkVariant, BookColorToken> = {
|
||||
date: 'accent',
|
||||
day: 'accent2',
|
||||
distance: 'accent3',
|
||||
coords: 'accent',
|
||||
flag: 'accent2',
|
||||
};
|
||||
|
||||
/** A map card: its ground and the pins it marks its stops with. */
|
||||
export interface BookTravelMapCard {
|
||||
style: BookMapElement['style'];
|
||||
pins?: BookMapElement['pinStyle'];
|
||||
}
|
||||
|
||||
interface BookTravelLooksInput {
|
||||
page: BookPageSetup;
|
||||
stats: JourneyStats;
|
||||
/** The journey's figures, way and words, as the engine's factories read them. */
|
||||
travel: BookTravelContext;
|
||||
/** The book's theme; without one an element keeps the engine's own colours. */
|
||||
theme: BookTheme | null | undefined;
|
||||
newId: (prefix: string) => string;
|
||||
/** Where an element of this size lands when the caller names no frame of its own. */
|
||||
place: (size: { w: number; h: number }) => BookFrame;
|
||||
}
|
||||
|
||||
/** The travel elements of one book, each in the frame given or where `place` puts one of its size. */
|
||||
export function bookTravelLooks({ page, stats, travel, theme, newId, place }: BookTravelLooksInput) {
|
||||
const frameFor = (size: { w: number; h: number }, frame?: BookFrame) => frame ?? place(size);
|
||||
|
||||
/**
|
||||
* An element dressed in the book's theme: its words in the theme's ink and
|
||||
* heading face, its colour one of the theme's accents, all three linked so
|
||||
* it follows when the theme changes. A book without a theme keeps the
|
||||
* engine's own colours.
|
||||
*/
|
||||
const dress = (el: BookElement, accent: BookColorToken = 'accent', hand = false): BookElement =>
|
||||
theme
|
||||
? themedElement(
|
||||
{ ...el, token: 'ink', accentToken: accent, fontRole: hand ? 'script' : 'heading' } as BookElement,
|
||||
theme,
|
||||
)
|
||||
: el;
|
||||
|
||||
/** The size a summary is set at in a look; a band is a strip, so it is set a third as tall. */
|
||||
const summarySize = (look: BookStatsLook) => {
|
||||
const w = page.pageWidth * (look === 'note' || look === 'sign' ? 0.56 : 0.72);
|
||||
const h =
|
||||
look === 'band' ? page.pageHeight * 0.13 : look === 'ticket' ? w * 0.42 : look === 'note' ? w * 0.72 : w * 0.5;
|
||||
return { w, h };
|
||||
};
|
||||
|
||||
/** A summary in one of the newer looks, of the figures given or the four a summary offers. */
|
||||
const summaryEl = (look: BookStatsLook, frame?: BookFrame, metrics: readonly BookMetric[] = SUMMARY_METRICS) => {
|
||||
const at = frameFor(summarySize(look), frame);
|
||||
const el = { ...bookTravelStats(stats, { id: newId('st'), frame: at, metrics }), look };
|
||||
return dress(el, look === 'sign' ? 'accent3' : 'accent', HANDWRITTEN.has(look));
|
||||
};
|
||||
|
||||
/** The size a single figure is set at: a square, or the drums of an odometer. */
|
||||
const figureSize = (look: BookStatsLook) => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.3;
|
||||
return look === 'odometer' ? { w: side * 1.6, h: side * 0.62 } : { w: side, h: side };
|
||||
};
|
||||
|
||||
/**
|
||||
* One figure on its own, the mark to drop beside a photograph: as a medal, on
|
||||
* the drums of an odometer, or ringed by hand.
|
||||
*/
|
||||
const figureEl = (metric: BookMetric, look: BookStatsLook = 'medal', frame?: BookFrame): BookElement => {
|
||||
const at = frameFor(figureSize(look), frame);
|
||||
const el = { ...bookTravelStats(stats, { id: newId('st'), frame: at, metrics: [metric] }), look };
|
||||
return dress(el, 'accent', HANDWRITTEN.has(look));
|
||||
};
|
||||
|
||||
/**
|
||||
* A map as the engine places it, on the card's ground and with the card's
|
||||
* stops. The scope is the journey or one trip; the ground is the outlines
|
||||
* or one of the two imagery sources.
|
||||
*/
|
||||
const mapEl = (
|
||||
card: BookTravelMapCard,
|
||||
scope: BookMapScope = 'journey',
|
||||
source: 'vector' | 'relief' | 'satellite' = 'vector',
|
||||
frame?: BookFrame,
|
||||
): BookElement => {
|
||||
const at = frameFor(bookTravelSize('map', page), frame);
|
||||
const el = bookTravelMap(travel, stats, { id: newId('mp'), frame: at, scope, source });
|
||||
const shaped = { ...el, style: card.style, ...(card.pins ? { pinStyle: card.pins } : {}) };
|
||||
// Over imagery the line is white on purpose (book-travel.ts); only the outline maps take the theme.
|
||||
return source === 'vector' ? dress(shaped) : shaped;
|
||||
};
|
||||
|
||||
/**
|
||||
* A map of one country: its stops alone, without the way there. A journey's
|
||||
* recorded way often starts with the flight from home, and fitted to all of
|
||||
* it the country is a speck at the edge of a continent; the page a travel
|
||||
* book wants is the country, cut to its own shape on the paper or over the
|
||||
* satellite picture, or the country in a box.
|
||||
*/
|
||||
const countryMapEl = (code: string, kind: 'shape' | 'satellite' | 'box', frame?: BookFrame): BookElement => {
|
||||
const own: JourneyStats = {
|
||||
...stats,
|
||||
points: stats.points.filter((p) => p.country === code),
|
||||
countries: stats.countries.filter((c) => c.code === code),
|
||||
};
|
||||
const ctx: BookTravelContext = { ...travel, stats: own, path: [], entries: [] };
|
||||
const source = kind === 'satellite' ? 'satellite' : 'vector';
|
||||
const at = frameFor(bookTravelSize('map', page), frame);
|
||||
const el = bookTravelMap(ctx, own, { id: newId('mp'), frame: at, scope: 'journey', source });
|
||||
// Cut to its shape on the paper, the land is dark, so the silhouette reads and the route in the accent stands on it.
|
||||
const style = kind === 'shape' ? 'dark' : 'minimal';
|
||||
const shaped = {
|
||||
...el,
|
||||
style,
|
||||
clip: kind === 'box' ? 'rect' : 'country',
|
||||
pinStyle: 'numbered',
|
||||
fitToCountries: true,
|
||||
} as const;
|
||||
return source === 'vector' ? dress(shaped) : shaped;
|
||||
};
|
||||
|
||||
/**
|
||||
* How large a mark is set: as wide as its words need. A stamp stacks them in
|
||||
* a box with room for its two rules; a tag or a pill sets them in a line
|
||||
* after its eyelet or its disc, so its length follows the words.
|
||||
*/
|
||||
const markSize = (style: BookBadgeStyle, text: string, sub: string) => {
|
||||
const twoLines = !!text && !!sub && text.length > 14;
|
||||
const h = page.pageHeight * (style === 'stamp' ? 0.12 : twoLines ? 0.1 : 0.066);
|
||||
const main = text || sub;
|
||||
if (style === 'stamp') return { w: Math.max(h * 1.3, h * 0.2 * Math.max(main.length * 1.4, sub.length)), h };
|
||||
// Two lines are set smaller than one, so they need less width a letter.
|
||||
if (twoLines)
|
||||
return { w: Math.min(page.pageWidth * 0.6, h * (1.5 + 0.13 * Math.max(text.length, sub.length * 0.7))), h };
|
||||
const chars = main.length * (text ? 1 : 0.85) + (text ? sub.length * 0.5 : 0);
|
||||
return { w: Math.min(page.pageWidth * 0.6, h * (1.6 + 0.3 * chars)), h };
|
||||
};
|
||||
|
||||
/**
|
||||
* A mark in one of the newer styles, at the size its variant suits; a stamp
|
||||
* is set taller for its two rules. The journey's value, or an entry's when
|
||||
* one is given; null when nothing says what it would show.
|
||||
*/
|
||||
const markEl = (
|
||||
variant: BookTravelMarkVariant,
|
||||
style: BookBadgeStyle,
|
||||
of: { entry?: AutoEntry | null; frame?: BookFrame } = {},
|
||||
): BookElement | null => {
|
||||
const value = bookBadgeValue(travel, variant, of.entry ?? null);
|
||||
if (!value) return null;
|
||||
const at = frameFor(markSize(style, value.text, value.sub), of.frame);
|
||||
const el = {
|
||||
...bookTravelBadge({ id: newId('bd'), frame: at, variant: variant as BookBadgeVariant, value }),
|
||||
style,
|
||||
};
|
||||
return dress(el, MARK_ACCENT[variant], style === 'sketch');
|
||||
};
|
||||
|
||||
/** The size a country is set at: a square stamp, or a card in the proportions of a postcard. */
|
||||
const countrySize = (look: BookCountryLook) => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.36;
|
||||
const ratio = look === 'stamp' || look === 'pin' || look === 'sketch' ? 1 : look === 'postcard' ? 1.36 : 1.4;
|
||||
return { w: side * ratio, h: side };
|
||||
};
|
||||
|
||||
/** One country as a square stamp, or as a card in the proportions of a postcard. */
|
||||
const countryEl = (code: string, look: BookCountryLook, frame?: BookFrame): BookElement => {
|
||||
const at = frameFor(countrySize(look), frame);
|
||||
const el = {
|
||||
...bookTravelCountries(travel, { id: newId('co'), frame: at, codes: [code] }),
|
||||
look,
|
||||
showFlag: look === 'pin' || look === 'postcard',
|
||||
};
|
||||
return dress(el, BOOK_TRAVEL_COUNTRY_ACCENT[look] ?? 'accent', look === 'sketch');
|
||||
};
|
||||
|
||||
/** A country's name in the reader's language, from the same CLDR data Atlas uses. */
|
||||
const countryName = (code: string): string => bookCountryName(code, travel.locale, stats);
|
||||
|
||||
return {
|
||||
summaryEl,
|
||||
summarySize,
|
||||
figureEl,
|
||||
figureSize,
|
||||
mapEl,
|
||||
countryMapEl,
|
||||
markEl,
|
||||
markSize,
|
||||
countryEl,
|
||||
countrySize,
|
||||
countryName,
|
||||
};
|
||||
}
|
||||
|
||||
/** The travel elements one book builds: what `bookTravelLooks` answers. */
|
||||
export type BookTravelLooks = ReturnType<typeof bookTravelLooks>;
|
||||
@@ -614,8 +614,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'يبدأ من',
|
||||
'journey.studio.folioMargin': 'الهامش',
|
||||
'journey.studio.relayoutSpread': 'هذه الصفحة',
|
||||
'journey.studio.relayoutSpreadHint': 'إعادة بنائها من مدخلها',
|
||||
'journey.studio.relayoutSpreadNone': 'هذه الصفحة ليست من مدخل',
|
||||
'journey.studio.relayoutSpreadHint': 'ضعها على تصميم آخر',
|
||||
'journey.studio.relayoutSpreadNone': 'لم ينشئ التخطيط التلقائي هذه الصفحة',
|
||||
'journey.studio.relayoutBook': 'الكتاب كامل',
|
||||
'journey.studio.relayoutBookHint': 'يستبدل كل الصفحات — يمكن التراجع',
|
||||
'journey.studio.tpl.coverFull': 'بلا حواف',
|
||||
|
||||
@@ -640,8 +640,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioMargin': 'Kənar boşluğu',
|
||||
|
||||
'journey.studio.relayoutSpread': 'Bu açılım',
|
||||
'journey.studio.relayoutSpreadHint': 'Onu qeydindən yenidən qur',
|
||||
'journey.studio.relayoutSpreadNone': 'Bu səhifə qeyddən yaradılmayıb',
|
||||
'journey.studio.relayoutSpreadHint': 'Başqa dizayna yerləşdir',
|
||||
'journey.studio.relayoutSpreadNone': 'Bu səhifəni avtomatik tərtibat yaratmayıb',
|
||||
'journey.studio.relayoutBook': 'Bütün kitab',
|
||||
'journey.studio.relayoutBookHint': 'Bütün səhifələri əvəz edir — geri qaytarmaq mümkündür',
|
||||
|
||||
|
||||
@@ -589,8 +589,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Começa em',
|
||||
'journey.studio.folioMargin': 'Margem',
|
||||
'journey.studio.relayoutSpread': 'Esta página',
|
||||
'journey.studio.relayoutSpreadHint': 'Refazer a partir da entrada',
|
||||
'journey.studio.relayoutSpreadNone': 'Esta página não vem de uma entrada',
|
||||
'journey.studio.relayoutSpreadHint': 'Usar outro design',
|
||||
'journey.studio.relayoutSpreadNone': 'O layout automático não criou esta página',
|
||||
'journey.studio.relayoutBook': 'O livro inteiro',
|
||||
'journey.studio.relayoutBookHint': 'Substitui todas as páginas — reversível',
|
||||
'journey.studio.tpl.coverFull': 'Sangria total',
|
||||
|
||||
@@ -588,8 +588,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Comença a',
|
||||
'journey.studio.folioMargin': 'Marge',
|
||||
'journey.studio.relayoutSpread': 'Aquesta pàgina',
|
||||
'journey.studio.relayoutSpreadHint': 'Refer-la des de l’entrada',
|
||||
'journey.studio.relayoutSpreadNone': 'Aquesta pàgina no ve d’una entrada',
|
||||
'journey.studio.relayoutSpreadHint': 'Posar-la en un altre disseny',
|
||||
'journey.studio.relayoutSpreadNone': 'El disseny automàtic no ha fet aquesta pàgina',
|
||||
'journey.studio.relayoutBook': 'Tot el llibre',
|
||||
'journey.studio.relayoutBookHint': 'Reemplaça totes les pàgines — reversible',
|
||||
'journey.studio.tpl.coverFull': 'A sang',
|
||||
|
||||
@@ -593,8 +593,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Začíná na',
|
||||
'journey.studio.folioMargin': 'Okraj',
|
||||
'journey.studio.relayoutSpread': 'Tato strana',
|
||||
'journey.studio.relayoutSpreadHint': 'Znovu sestavit ze záznamu',
|
||||
'journey.studio.relayoutSpreadNone': 'Tato strana nevznikla ze záznamu',
|
||||
'journey.studio.relayoutSpreadHint': 'Použít jiný návrh',
|
||||
'journey.studio.relayoutSpreadNone': 'Tuto stranu nevytvořilo automatické rozvržení',
|
||||
'journey.studio.relayoutBook': 'Celá kniha',
|
||||
'journey.studio.relayoutBookHint': 'Nahradí všechny strany — lze vrátit',
|
||||
'journey.studio.tpl.coverFull': 'Na spad',
|
||||
|
||||
@@ -590,8 +590,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Beginnt bei',
|
||||
'journey.studio.folioMargin': 'Rand',
|
||||
'journey.studio.relayoutSpread': 'Diese Seite',
|
||||
'journey.studio.relayoutSpreadHint': 'Aus ihrem Eintrag neu bauen',
|
||||
'journey.studio.relayoutSpreadNone': 'Diese Seite stammt aus keinem Eintrag',
|
||||
'journey.studio.relayoutSpreadHint': 'Auf ein anderes Design legen',
|
||||
'journey.studio.relayoutSpreadNone': 'Diese Seite stammt nicht vom automatischen Anordnen',
|
||||
'journey.studio.relayoutBook': 'Das ganze Buch',
|
||||
'journey.studio.relayoutBookHint': 'Ersetzt alle Seiten — widerrufbar',
|
||||
'journey.studio.tpl.coverFull': 'Randlos',
|
||||
|
||||
@@ -585,8 +585,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Starts at',
|
||||
'journey.studio.folioMargin': 'Margin',
|
||||
'journey.studio.relayoutSpread': 'This spread',
|
||||
'journey.studio.relayoutSpreadHint': 'Rebuild it from its entry',
|
||||
'journey.studio.relayoutSpreadNone': 'This page was not built from an entry',
|
||||
'journey.studio.relayoutSpreadHint': 'Lay it on another design',
|
||||
'journey.studio.relayoutSpreadNone': 'The auto layout did not make this page',
|
||||
'journey.studio.relayoutBook': 'The whole book',
|
||||
'journey.studio.relayoutBookHint': 'Replaces every page — undoable',
|
||||
'journey.studio.tpl.coverFull': 'Full bleed',
|
||||
|
||||
@@ -588,8 +588,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Empieza en',
|
||||
'journey.studio.folioMargin': 'Margen',
|
||||
'journey.studio.relayoutSpread': 'Esta página',
|
||||
'journey.studio.relayoutSpreadHint': 'Rehacerla desde su entrada',
|
||||
'journey.studio.relayoutSpreadNone': 'Esta página no viene de una entrada',
|
||||
'journey.studio.relayoutSpreadHint': 'Ponerla en otro diseño',
|
||||
'journey.studio.relayoutSpreadNone': 'El diseño automático no hizo esta página',
|
||||
'journey.studio.relayoutBook': 'Todo el libro',
|
||||
'journey.studio.relayoutBookHint': 'Reemplaza todas las páginas — reversible',
|
||||
'journey.studio.tpl.coverFull': 'A sangre',
|
||||
|
||||
@@ -586,8 +586,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Algab numbrist',
|
||||
'journey.studio.folioMargin': 'Veeris',
|
||||
'journey.studio.relayoutSpread': 'See lehepaar',
|
||||
'journey.studio.relayoutSpreadHint': 'Loo uuesti sissekande põhjal',
|
||||
'journey.studio.relayoutSpreadNone': 'See leht pole loodud sissekandest',
|
||||
'journey.studio.relayoutSpreadHint': 'Paiguta teise kujundusse',
|
||||
'journey.studio.relayoutSpreadNone': 'Seda lehte ei loonud automaatne paigutus',
|
||||
'journey.studio.relayoutBook': 'Kogu raamat',
|
||||
'journey.studio.relayoutBookHint': 'Asendab kõik lehed — saab tagasi võtta',
|
||||
'journey.studio.tpl.coverFull': 'Servani',
|
||||
|
||||
@@ -588,8 +588,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Commence à',
|
||||
'journey.studio.folioMargin': 'Marge',
|
||||
'journey.studio.relayoutSpread': 'Cette page',
|
||||
'journey.studio.relayoutSpreadHint': 'La refaire depuis son entrée',
|
||||
'journey.studio.relayoutSpreadNone': 'Cette page ne vient pas d’une entrée',
|
||||
'journey.studio.relayoutSpreadHint': 'La poser sur une autre maquette',
|
||||
'journey.studio.relayoutSpreadNone': 'La mise en page auto n’a pas fait cette page',
|
||||
'journey.studio.relayoutBook': 'Tout le livre',
|
||||
'journey.studio.relayoutBookHint': 'Remplace toutes les pages — annulable',
|
||||
'journey.studio.tpl.coverFull': 'Pleine page',
|
||||
|
||||
@@ -594,8 +594,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Ξεκινά από',
|
||||
'journey.studio.folioMargin': 'Περιθώριο',
|
||||
'journey.studio.relayoutSpread': 'Αυτή η σελίδα',
|
||||
'journey.studio.relayoutSpreadHint': 'Ξαναχτίστε την από την καταχώριση',
|
||||
'journey.studio.relayoutSpreadNone': 'Αυτή η σελίδα δεν προήλθε από καταχώριση',
|
||||
'journey.studio.relayoutSpreadHint': 'Τοποθέτησέ την σε άλλο σχέδιο',
|
||||
'journey.studio.relayoutSpreadNone': 'Αυτή η σελίδα δεν φτιάχτηκε από την αυτόματη διάταξη',
|
||||
'journey.studio.relayoutBook': 'Όλο το βιβλίο',
|
||||
'journey.studio.relayoutBookHint': 'Αντικαθιστά κάθε σελίδα — αναιρέσιμο',
|
||||
'journey.studio.tpl.coverFull': 'Ολοσέλιδο',
|
||||
|
||||
@@ -585,8 +585,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Kezdete',
|
||||
'journey.studio.folioMargin': 'Margó',
|
||||
'journey.studio.relayoutSpread': 'Ez az oldal',
|
||||
'journey.studio.relayoutSpreadHint': 'Újraépítés a bejegyzésből',
|
||||
'journey.studio.relayoutSpreadNone': 'Ez az oldal nem bejegyzésből készült',
|
||||
'journey.studio.relayoutSpreadHint': 'Másik tervre helyezés',
|
||||
'journey.studio.relayoutSpreadNone': 'Ezt az oldalt nem az automatikus elrendezés készítette',
|
||||
'journey.studio.relayoutBook': 'A teljes könyv',
|
||||
'journey.studio.relayoutBookHint': 'Minden oldalt lecserél — visszavonható',
|
||||
'journey.studio.tpl.coverFull': 'Kifutó',
|
||||
|
||||
@@ -578,8 +578,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Mulai dari',
|
||||
'journey.studio.folioMargin': 'Margin', // same-as-en
|
||||
'journey.studio.relayoutSpread': 'Halaman ini',
|
||||
'journey.studio.relayoutSpreadHint': 'Bangun ulang dari catatannya',
|
||||
'journey.studio.relayoutSpreadNone': 'Halaman ini bukan dari catatan',
|
||||
'journey.studio.relayoutSpreadHint': 'Pasang pada desain lain',
|
||||
'journey.studio.relayoutSpreadNone': 'Halaman ini tidak dibuat oleh tata letak otomatis',
|
||||
'journey.studio.relayoutBook': 'Seluruh buku',
|
||||
'journey.studio.relayoutBookHint': 'Mengganti semua halaman — bisa dibatalkan',
|
||||
'journey.studio.tpl.coverFull': 'Penuh',
|
||||
|
||||
@@ -587,8 +587,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Inizia da',
|
||||
'journey.studio.folioMargin': 'Margine',
|
||||
'journey.studio.relayoutSpread': 'Questa pagina',
|
||||
'journey.studio.relayoutSpreadHint': 'Rifarla dalla sua voce',
|
||||
'journey.studio.relayoutSpreadNone': 'Questa pagina non viene da una voce',
|
||||
'journey.studio.relayoutSpreadHint': 'Metterla su un altro design',
|
||||
'journey.studio.relayoutSpreadNone': 'Questa pagina non l’ha fatta il layout automatico',
|
||||
'journey.studio.relayoutBook': 'Tutto il libro',
|
||||
'journey.studio.relayoutBookHint': 'Sostituisce ogni pagina — annullabile',
|
||||
'journey.studio.tpl.coverFull': 'Al vivo',
|
||||
|
||||
@@ -577,8 +577,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': '開始番号',
|
||||
'journey.studio.folioMargin': '余白',
|
||||
'journey.studio.relayoutSpread': 'このページ',
|
||||
'journey.studio.relayoutSpreadHint': 'エントリーから作り直す',
|
||||
'journey.studio.relayoutSpreadNone': 'このページはエントリー由来ではありません',
|
||||
'journey.studio.relayoutSpreadHint': '別のデザインに配置する',
|
||||
'journey.studio.relayoutSpreadNone': 'このページは自動レイアウトで作られていません',
|
||||
'journey.studio.relayoutBook': '本全体',
|
||||
'journey.studio.relayoutBookHint': 'すべてのページを差し替え — 取り消せます',
|
||||
'journey.studio.tpl.coverFull': '裁ち落とし',
|
||||
|
||||
@@ -573,8 +573,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': '시작 번호',
|
||||
'journey.studio.folioMargin': '여백',
|
||||
'journey.studio.relayoutSpread': '이 페이지',
|
||||
'journey.studio.relayoutSpreadHint': '항목에서 다시 만들기',
|
||||
'journey.studio.relayoutSpreadNone': '이 페이지는 항목에서 만든 것이 아닙니다',
|
||||
'journey.studio.relayoutSpreadHint': '다른 디자인에 배치하기',
|
||||
'journey.studio.relayoutSpreadNone': '이 페이지는 자동 레이아웃으로 만든 것이 아닙니다',
|
||||
'journey.studio.relayoutBook': '책 전체',
|
||||
'journey.studio.relayoutBookHint': '모든 페이지 교체 — 되돌릴 수 있음',
|
||||
'journey.studio.tpl.coverFull': '재단 여백',
|
||||
|
||||
@@ -589,8 +589,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Begint bij',
|
||||
'journey.studio.folioMargin': 'Marge',
|
||||
'journey.studio.relayoutSpread': 'Deze pagina',
|
||||
'journey.studio.relayoutSpreadHint': 'Opnieuw opbouwen uit de notitie',
|
||||
'journey.studio.relayoutSpreadNone': 'Deze pagina komt niet uit een notitie',
|
||||
'journey.studio.relayoutSpreadHint': 'Op een ander ontwerp leggen',
|
||||
'journey.studio.relayoutSpreadNone': 'Deze pagina komt niet uit de automatische opmaak',
|
||||
'journey.studio.relayoutBook': 'Het hele boek',
|
||||
'journey.studio.relayoutBookHint': 'Vervangt alle pagina’s — ongedaan te maken',
|
||||
'journey.studio.tpl.coverFull': 'Aflopend',
|
||||
|
||||
@@ -605,8 +605,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Zaczyna od',
|
||||
'journey.studio.folioMargin': 'Margines',
|
||||
'journey.studio.relayoutSpread': 'Ta strona',
|
||||
'journey.studio.relayoutSpreadHint': 'Zbuduj ponownie z wpisu',
|
||||
'journey.studio.relayoutSpreadNone': 'Ta strona nie pochodzi z wpisu',
|
||||
'journey.studio.relayoutSpreadHint': 'Ułóż na innym projekcie',
|
||||
'journey.studio.relayoutSpreadNone': 'Tej strony nie utworzył układ automatyczny',
|
||||
'journey.studio.relayoutBook': 'Cała książka',
|
||||
'journey.studio.relayoutBookHint': 'Zastępuje wszystkie strony — cofalne',
|
||||
'journey.studio.tpl.coverFull': 'Na spad',
|
||||
|
||||
@@ -605,8 +605,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Начинать с',
|
||||
'journey.studio.folioMargin': 'Отступ',
|
||||
'journey.studio.relayoutSpread': 'Этот разворот',
|
||||
'journey.studio.relayoutSpreadHint': 'Собрать заново из записи',
|
||||
'journey.studio.relayoutSpreadNone': 'Этот разворот не из записи',
|
||||
'journey.studio.relayoutSpreadHint': 'Разложить по другому дизайну',
|
||||
'journey.studio.relayoutSpreadNone': 'Этот разворот сделан не автомакетом',
|
||||
'journey.studio.relayoutBook': 'Вся книга',
|
||||
'journey.studio.relayoutBookHint': 'Заменит все страницы — обратимо',
|
||||
'journey.studio.tpl.coverFull': 'В обрез',
|
||||
|
||||
@@ -597,8 +597,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Začína na',
|
||||
'journey.studio.folioMargin': 'Okraj',
|
||||
'journey.studio.relayoutSpread': 'Táto dvojstrana',
|
||||
'journey.studio.relayoutSpreadHint': 'Znova zostaviť zo záznamu',
|
||||
'journey.studio.relayoutSpreadNone': 'Táto strana nevznikla zo záznamu',
|
||||
'journey.studio.relayoutSpreadHint': 'Použiť iný návrh',
|
||||
'journey.studio.relayoutSpreadNone': 'Túto stranu nevytvorilo automatické rozloženie',
|
||||
'journey.studio.relayoutBook': 'Celá kniha',
|
||||
'journey.studio.relayoutBookHint': 'Nahradí všetky strany — možno vrátiť späť',
|
||||
'journey.studio.tpl.coverFull': 'Na spad',
|
||||
|
||||
@@ -585,8 +585,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Börjar på',
|
||||
'journey.studio.folioMargin': 'Marginal',
|
||||
'journey.studio.relayoutSpread': 'Detta uppslag',
|
||||
'journey.studio.relayoutSpreadHint': 'Bygg om från dess anteckning',
|
||||
'journey.studio.relayoutSpreadNone': 'Detta uppslag kommer inte från en anteckning',
|
||||
'journey.studio.relayoutSpreadHint': 'Lägg den på en annan design',
|
||||
'journey.studio.relayoutSpreadNone': 'Det här uppslaget gjordes inte av den automatiska layouten',
|
||||
'journey.studio.relayoutBook': 'Hela boken',
|
||||
'journey.studio.relayoutBookHint': 'Ersätter alla sidor — kan ångras',
|
||||
'journey.studio.tpl.coverFull': 'Utfallande',
|
||||
|
||||
@@ -571,8 +571,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'เริ่มต้นที่',
|
||||
'journey.studio.folioMargin': 'ขอบ',
|
||||
'journey.studio.relayoutSpread': 'หน้าคู่นี้',
|
||||
'journey.studio.relayoutSpreadHint': 'สร้างใหม่จากรายการนี้',
|
||||
'journey.studio.relayoutSpreadNone': 'หน้านี้ไม่ได้ถูกสร้างขึ้นจากรายการ',
|
||||
'journey.studio.relayoutSpreadHint': 'จัดวางด้วยดีไซน์อื่น',
|
||||
'journey.studio.relayoutSpreadNone': 'หน้านี้ไม่ได้สร้างโดยเค้าโครงอัตโนมัติ',
|
||||
'journey.studio.relayoutBook': 'หนังสือทั้งเล่ม',
|
||||
'journey.studio.relayoutBookHint': 'แทนที่ทุกหน้า — เลิกทำได้',
|
||||
'journey.studio.tpl.coverFull': 'เต็มขอบ',
|
||||
|
||||
@@ -588,8 +588,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Başlangıç',
|
||||
'journey.studio.folioMargin': 'Kenar boşluğu',
|
||||
'journey.studio.relayoutSpread': 'Bu sayfa',
|
||||
'journey.studio.relayoutSpreadHint': 'Kaydından yeniden oluştur',
|
||||
'journey.studio.relayoutSpreadNone': 'Bu sayfa bir kayıttan gelmiyor',
|
||||
'journey.studio.relayoutSpreadHint': 'Başka bir tasarıma yerleştir',
|
||||
'journey.studio.relayoutSpreadNone': 'Bu sayfayı otomatik yerleşim oluşturmadı',
|
||||
'journey.studio.relayoutBook': 'Tüm kitap',
|
||||
'journey.studio.relayoutBookHint': 'Tüm sayfaları değiştirir — geri alınabilir',
|
||||
'journey.studio.tpl.coverFull': 'Taşmalı',
|
||||
|
||||
@@ -608,8 +608,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Починати з',
|
||||
'journey.studio.folioMargin': 'Відступ',
|
||||
'journey.studio.relayoutSpread': 'Цей розворот',
|
||||
'journey.studio.relayoutSpreadHint': 'Зібрати заново із запису',
|
||||
'journey.studio.relayoutSpreadNone': 'Цей розворот не з запису',
|
||||
'journey.studio.relayoutSpreadHint': 'Розкласти за іншим дизайном',
|
||||
'journey.studio.relayoutSpreadNone': 'Цей розворот зроблено не автомакетом',
|
||||
'journey.studio.relayoutBook': 'Уся книга',
|
||||
'journey.studio.relayoutBookHint': 'Замінить усі сторінки — оборотно',
|
||||
'journey.studio.tpl.coverFull': 'Під обріз',
|
||||
|
||||
@@ -577,8 +577,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Bắt đầu từ',
|
||||
'journey.studio.folioMargin': 'Lề',
|
||||
'journey.studio.relayoutSpread': 'Trang này',
|
||||
'journey.studio.relayoutSpreadHint': 'Dựng lại từ mục nhật ký',
|
||||
'journey.studio.relayoutSpreadNone': 'Trang này không từ mục nhật ký',
|
||||
'journey.studio.relayoutSpreadHint': 'Đặt vào một thiết kế khác',
|
||||
'journey.studio.relayoutSpreadNone': 'Trang này không do bố cục tự động tạo',
|
||||
'journey.studio.relayoutBook': 'Toàn bộ sách',
|
||||
'journey.studio.relayoutBookHint': 'Thay mọi trang — có thể hoàn tác',
|
||||
'journey.studio.tpl.coverFull': 'Tràn lề',
|
||||
|
||||
@@ -567,8 +567,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': '起始頁碼',
|
||||
'journey.studio.folioMargin': '頁邊距',
|
||||
'journey.studio.relayoutSpread': '本頁',
|
||||
'journey.studio.relayoutSpreadHint': '依條目重新排版',
|
||||
'journey.studio.relayoutSpreadNone': '此頁並非由條目產生',
|
||||
'journey.studio.relayoutSpreadHint': '換一種設計排版',
|
||||
'journey.studio.relayoutSpreadNone': '此頁並非由自動排版產生',
|
||||
'journey.studio.relayoutBook': '整本書',
|
||||
'journey.studio.relayoutBookHint': '取代所有頁面 — 可復原',
|
||||
'journey.studio.tpl.coverFull': '出血滿版',
|
||||
|
||||
@@ -567,8 +567,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': '起始页码',
|
||||
'journey.studio.folioMargin': '页边距',
|
||||
'journey.studio.relayoutSpread': '本页',
|
||||
'journey.studio.relayoutSpreadHint': '根据条目重新排版',
|
||||
'journey.studio.relayoutSpreadNone': '此页不是由条目生成的',
|
||||
'journey.studio.relayoutSpreadHint': '换一种设计排版',
|
||||
'journey.studio.relayoutSpreadNone': '此页不是由自动排版生成的',
|
||||
'journey.studio.relayoutBook': '整本书',
|
||||
'journey.studio.relayoutBookHint': '替换所有页面 — 可撤销',
|
||||
'journey.studio.tpl.coverFull': '出血满版',
|
||||
|
||||
@@ -163,6 +163,7 @@ export * from './book/photo-pick';
|
||||
export * from './book/book-element-ops.schema';
|
||||
export * from './book/book-doc-ops';
|
||||
export * from './book/book-travel';
|
||||
export * from './book/travel/book-travel-looks';
|
||||
export * from './book/book-text';
|
||||
export * from './book/book-path';
|
||||
export * from './book/book-icons';
|
||||
@@ -193,6 +194,7 @@ export * from './book/book-archetypes';
|
||||
export * from './book/book-lockups.data';
|
||||
export * from './book/auto/auto-types';
|
||||
export * from './book/auto/auto-book';
|
||||
export * from './book/auto/auto-designed';
|
||||
export * from './book/auto/auto-templates.data';
|
||||
export * from './book/auto/auto-template-apply';
|
||||
export * from './book/design/design-brief.schema';
|
||||
@@ -213,6 +215,7 @@ export * from './book/theme/book-theme-photos';
|
||||
export { bookThemeCatalogue, bookThemeLine } from './book/theme/book-theme-describe';
|
||||
export * from './book/templates/template-format';
|
||||
export * from './book/templates/template-catalogue';
|
||||
export * from './book/templates/template-match';
|
||||
export * from './book/templates/template-apply';
|
||||
export * from './book/templates/template-words';
|
||||
export * from './book/templates/template-extract';
|
||||
|
||||
Reference in New Issue
Block a user