mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 186dd94b61 | |||
| ad721a1ac6 | |||
| 87441f269d | |||
| a0943f8226 | |||
| 6741e8ca0d | |||
| c14f179e39 | |||
| 3bfef73a46 | |||
| 7e790c3fb7 | |||
| 228db8541c | |||
| a729a6935f |
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 5.0.0
|
||||
version: 5.0.1
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "5.0.0"
|
||||
appVersion: "5.0.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"no-useless-escape": 1
|
||||
},
|
||||
"suppressed": {
|
||||
"@typescript-eslint/no-explicit-any": 40,
|
||||
"@typescript-eslint/no-explicit-any": 38,
|
||||
"react-hooks/exhaustive-deps": 46
|
||||
},
|
||||
"tests": {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1385,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2290,
|
||||
"src/components/Map/MapViewGL.tsx": 2218,
|
||||
"src/components/Map/RouteCalculator.ts": 1063,
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
|
||||
@@ -102,16 +102,20 @@ export default function HelpPanel(): React.ReactElement | null {
|
||||
<HelpNav context={context} currentId={contextId} activeGuideId={!searching && guide ? guide.id : null} />
|
||||
|
||||
<div className="relative flex flex-col min-h-0 bg-surface" role="main">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeHelp}
|
||||
aria-label={t('help.center.close')}
|
||||
className="absolute top-4 end-4 z-10 w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
{/* The close button sits in a row of its own above the scrolling content rather
|
||||
than floating over it, so no card ever runs up against it. */}
|
||||
<div className="flex shrink-0 justify-end px-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeHelp}
|
||||
aria-label={t('help.center.close')}
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div ref={scrollRef} className="trek-help-scroll flex-1 min-h-0 overflow-y-auto overscroll-contain">
|
||||
<div className="max-w-[1080px] mx-auto px-6 sm:px-10 py-8">
|
||||
<div className="max-w-[1080px] mx-auto px-6 sm:px-10 pt-4 pb-8">
|
||||
{searching ? (
|
||||
<HelpSearchResults key="search" />
|
||||
) : guide ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-007
|
||||
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-008
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
@@ -119,3 +119,28 @@ describe('DatePicker week start (#2029)', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('DatePicker inside a clipping dialog (#2858)', () => {
|
||||
it('FE-COMP-JOURNEYDATEPICKER-008: the calendar floats over the page, stays inside the viewport and closes on a click away', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(
|
||||
<div style={{ overflow: 'hidden' }}>
|
||||
<DatePicker value="2026-03-15" onChange={vi.fn()} />
|
||||
</div>,
|
||||
)
|
||||
const trigger = screen.getByText(/Mar 15, 2026/).closest('button') as HTMLButtonElement
|
||||
// A trigger at the right edge, where the end date of a range sits.
|
||||
trigger.getBoundingClientRect = () => ({ top: 100, bottom: 136, left: window.innerWidth - 120, right: window.innerWidth - 8, width: 112, height: 36, x: 0, y: 0, toJSON: () => ({}) })
|
||||
await user.click(trigger)
|
||||
|
||||
const day31 = screen.getByRole('button', { name: '31' })
|
||||
// Rendered outside the dialog that would clip it.
|
||||
expect(container.contains(day31)).toBe(false)
|
||||
const panel = day31.closest('.fixed') as HTMLElement
|
||||
expect(panel.style.top).toBe('140px')
|
||||
expect(parseFloat(panel.style.left)).toBe(window.innerWidth - 280 - 8)
|
||||
|
||||
await user.click(document.querySelector('[role="presentation"]') as HTMLElement)
|
||||
expect(screen.queryByRole('button', { name: '31' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { localIsoDate } from '../../utils/localDate'
|
||||
import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useWeekStartDay } from '../../hooks/useWeekStartDay'
|
||||
import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek'
|
||||
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
|
||||
|
||||
/**
|
||||
* The header steps up a level on each click, days → months → years, and a
|
||||
@@ -14,6 +16,9 @@ import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek'
|
||||
*/
|
||||
type CalendarView = 'days' | 'months' | 'years'
|
||||
const YEAR_PAGE_SIZE = 12
|
||||
/** The calendar's width; `w-[280px]` below. */
|
||||
const PANEL_WIDTH = 280
|
||||
const VIEWPORT_PAD = 8
|
||||
|
||||
export function DatePicker({ value, onChange, tripDates }: {
|
||||
value: string
|
||||
@@ -23,6 +28,13 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
const { t, locale } = useTranslation()
|
||||
const weekStart = useWeekStartDay()
|
||||
const [open, setOpen] = useState(false)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
// The calendar used to hang absolutely under its trigger, inside dialogs that clip
|
||||
// their content: the photo picker cut off the last week and, for the end date in
|
||||
// the right-hand column, the last weekday (#2858). It now floats over the page,
|
||||
// pinned to the trigger, flipped above it when below is too short and kept inside
|
||||
// the viewport sideways.
|
||||
const anchored = useAnchoredPosition(triggerRef, open, { estimatedHeight: 340, matchWidth: false })
|
||||
const [view, setView] = useState<CalendarView>('days')
|
||||
const [yearPageStart, setYearPageStart] = useState(0)
|
||||
const [viewMonth, setViewMonth] = useState(() => {
|
||||
@@ -107,6 +119,7 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={toggleOpen}
|
||||
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-start flex items-center justify-between"
|
||||
@@ -122,12 +135,20 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
{open && anchored && createPortal(
|
||||
<>
|
||||
{/* Click-away catcher — no semantics of its own; the trigger button
|
||||
above closes the popover again from the keyboard. */}
|
||||
<div role="presentation" className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
|
||||
<div className="absolute top-full start-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
|
||||
<div role="presentation" className="fixed inset-0 z-[var(--z-toast)]" onClick={() => setOpen(false)} />
|
||||
<div
|
||||
className="fixed z-[var(--z-toast)] overflow-y-auto bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]"
|
||||
style={{
|
||||
top: anchored.top,
|
||||
bottom: anchored.bottom,
|
||||
left: Math.max(VIEWPORT_PAD, Math.min(anchored.left, window.innerWidth - PANEL_WIDTH - VIEWPORT_PAD)),
|
||||
maxHeight: anchored.maxHeight,
|
||||
}}
|
||||
>
|
||||
{/* Header: arrows step the current view, the label climbs to the next one up */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button type="button" onClick={handlePrev} aria-label={prevLabel} className={navButton}>
|
||||
@@ -200,7 +221,7 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
>
|
||||
{day}
|
||||
{isTrip && !isSelected && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
|
||||
<span className="absolute bottom-1 inset-x-0 mx-auto w-1 h-1 rounded-full bg-indigo-500" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
@@ -209,7 +230,8 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -375,11 +375,12 @@ describe('ProviderPicker', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Search' }))
|
||||
expect(ranges).toHaveLength(0)
|
||||
|
||||
// The calendar opens in a portal, so its days are looked up on the page, not under the trigger.
|
||||
const [fromPicker, toPicker] = Array.from(baseElement.querySelectorAll('.flex-1 > .relative')) as HTMLElement[]
|
||||
await user.click(within(fromPicker).getByRole('button'))
|
||||
await user.click(within(fromPicker).getByRole('button', { name: '3' }))
|
||||
await user.click(screen.getByRole('button', { name: '3' }))
|
||||
await user.click(within(toPicker).getByRole('button'))
|
||||
await user.click(within(toPicker).getByRole('button', { name: '9' }))
|
||||
await user.click(screen.getByRole('button', { name: '9' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Search' }))
|
||||
|
||||
await waitFor(() => expect(ranges).toHaveLength(1))
|
||||
|
||||
@@ -50,16 +50,10 @@ import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
|
||||
import { selectedPlaceTarget } from './selectedPlaceTarget'
|
||||
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers'
|
||||
|
||||
// Marker grouping for the GL map (#1385): MapLibre/Mapbox can't show the rich
|
||||
// HTML photo markers *and* cluster them natively, so we feed the place points
|
||||
// into a clustered GeoJSON source. The cluster bubbles render as GL circles +
|
||||
// a count label; the individual rich HTML markers are then only drawn for the
|
||||
// points the source reports as currently unclustered. Grouping is always on,
|
||||
// matching the Leaflet map's MarkerClusterGroup.
|
||||
const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters'
|
||||
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
|
||||
import {
|
||||
PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID, PLACE_CLUSTER_SOURCE_ID,
|
||||
addPlaceClusterLayers, drawClusterBubbles, expandCluster,
|
||||
} from './placeClusterLayers'
|
||||
|
||||
type PlaceWithCoords = Place & { lat: number; lng: number }
|
||||
|
||||
@@ -112,72 +106,6 @@ interface Props extends MapViewProps {
|
||||
gl: any
|
||||
}
|
||||
|
||||
/**
|
||||
* How eagerly place markers merge into a cluster.
|
||||
*
|
||||
* The overview band the Leaflet map clusters in as well (`CLUSTER_UNTIL_ZOOM` there),
|
||||
* kept as plain numbers rather than shared constants so the GL bundle does not have to
|
||||
* carry Leaflet for them. Above the cutoff supercluster hands every point back on its
|
||||
* own, and the stops that share a coordinate are folded together at the pin level
|
||||
* instead — see the reconcile below.
|
||||
*/
|
||||
const CLUSTER_RADIUS = 20
|
||||
const CLUSTER_MAX_ZOOM = 8
|
||||
|
||||
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
|
||||
* change and the initial build cannot drift apart. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function addPlaceClusterLayers(map: any): void {
|
||||
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
cluster: true,
|
||||
clusterRadius: CLUSTER_RADIUS,
|
||||
clusterMaxZoom: CLUSTER_MAX_ZOOM,
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
'circle-color': '#111827',
|
||||
'circle-opacity': 0.97,
|
||||
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
|
||||
'circle-stroke-width': 2.5,
|
||||
'circle-stroke-color': 'rgba(255,255,255,0.9)',
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_COUNT_LAYER_ID,
|
||||
type: 'symbol',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
'text-field': ['get', 'point_count_abbreviated'],
|
||||
'text-size': 12,
|
||||
'text-allow-overlap': true,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': 'rgba(17,24,39,0.35)',
|
||||
'text-halo-width': 1,
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_UNCLUSTERED_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['!', ['has', 'point_count']],
|
||||
paint: {
|
||||
'circle-radius': 24,
|
||||
'circle-opacity': 0,
|
||||
'circle-stroke-opacity': 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||
// A stop that interrupts the drive gets its own small disc, decided before the photo
|
||||
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
|
||||
@@ -612,6 +540,8 @@ export function MapViewGL({
|
||||
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const markersRef = useRef<Map<number, PlacePin>>(new Map())
|
||||
/** The count bubbles, one per cluster the source currently reports. */
|
||||
const clusterPinsRef = useRef<PlacePin[]>([])
|
||||
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
|
||||
const pinLayerRef = useRef<HTMLDivElement | null>(null)
|
||||
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
|
||||
@@ -642,6 +572,7 @@ export function MapViewGL({
|
||||
// in the same frame the canvas draws, or the ones left out swim against the ones in.
|
||||
const repositionPins = useCallback(() => {
|
||||
markersRef.current.forEach(pin => pin.reposition())
|
||||
clusterPinsRef.current.forEach(pin => pin.reposition())
|
||||
poiMarkersRef.current.forEach(pin => pin.reposition())
|
||||
pluginMarkersRef.current.forEach(pin => pin.reposition())
|
||||
routeViaMarkersRef.current.forEach(pin => pin.reposition())
|
||||
@@ -1304,21 +1235,7 @@ export function MapViewGL({
|
||||
? map.queryRenderedFeatures(e.point, { layers: [PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID] })
|
||||
: []
|
||||
const feature = features?.[0]
|
||||
const clusterId = feature?.properties?.cluster_id
|
||||
const coordinates = feature?.geometry?.coordinates
|
||||
if (clusterId == null || !Array.isArray(coordinates)) return
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const source = map.getSource(PLACE_CLUSTER_SOURCE_ID) as any
|
||||
const easeToZoom = (nextZoom: number) => {
|
||||
try { map.easeTo({ center: coordinates, zoom: nextZoom, duration: 350 }) } catch { /* noop */ }
|
||||
}
|
||||
try {
|
||||
const maybeZoom = source?.getClusterExpansionZoom?.(clusterId, (err: Error | null, nextZoom: number) => {
|
||||
if (!err && typeof nextZoom === 'number') easeToZoom(nextZoom)
|
||||
})
|
||||
if (typeof maybeZoom === 'number') easeToZoom(maybeZoom)
|
||||
else if (maybeZoom && typeof maybeZoom.then === 'function') maybeZoom.then(easeToZoom).catch(() => {})
|
||||
} catch { /* noop */ }
|
||||
expandCluster(map, feature?.properties?.cluster_id, feature?.geometry?.coordinates)
|
||||
}
|
||||
const setClusterCursor = () => {
|
||||
const canvas = typeof map.getCanvas === 'function' ? map.getCanvas() : null
|
||||
@@ -1704,8 +1621,17 @@ export function MapViewGL({
|
||||
source.setData(buildPlaceClusterData(places) as any)
|
||||
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
|
||||
let raf: number | null = null
|
||||
const dropClusterPins = () => {
|
||||
clusterPinsRef.current.forEach(pin => pin.remove())
|
||||
clusterPinsRef.current = []
|
||||
}
|
||||
const reconcileClusterPins = () => {
|
||||
dropClusterPins()
|
||||
clusterPinsRef.current = drawClusterBubbles(map, (el, lng, lat) => attachPin(map, gl, pinLayerRef.current, el, lng, lat))
|
||||
}
|
||||
const runReconcile = () => {
|
||||
raf = null
|
||||
reconcileClusterPins()
|
||||
const features = map.querySourceFeatures(PLACE_CLUSTER_SOURCE_ID, { filter: ['!', ['has', 'point_count']] }) || []
|
||||
const seen = new Set<number>()
|
||||
const visiblePlaces: PlaceWithCoords[] = []
|
||||
@@ -1752,6 +1678,7 @@ export function MapViewGL({
|
||||
|
||||
return () => {
|
||||
if (raf !== null) cancelAnimationFrame(raf)
|
||||
dropClusterPins()
|
||||
map.off('moveend', scheduleReconcile)
|
||||
map.off('zoomend', scheduleReconcile)
|
||||
map.off('idle', scheduleReconcile)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import L from 'leaflet'
|
||||
import { STACK_RADIUS_PX } from './coincidentPlaces'
|
||||
import { clusterBubbleSize } from './placeClusterLayers'
|
||||
|
||||
/** Up to here the map is an overview, where stops merge to keep the world readable. */
|
||||
export const CLUSTER_UNTIL_ZOOM = 9
|
||||
@@ -66,7 +67,7 @@ export interface ClusterLike {
|
||||
/** A count bubble sized by its count, styled by `.marker-cluster-custom` in index.css. */
|
||||
export function createClusterIcon(cluster: ClusterLike) {
|
||||
const count = cluster.getChildCount()
|
||||
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
||||
const size = clusterBubbleSize(count)
|
||||
return L.divIcon({
|
||||
html: `<div class="marker-cluster-custom" style="width:${size}px;height:${size}px;"><span>${count}</span></div>`,
|
||||
className: 'marker-cluster-wrapper',
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
type ClusterMap,
|
||||
PLACE_CLUSTER_SOURCE_ID,
|
||||
addPlaceClusterLayers,
|
||||
clusterBubbleSize,
|
||||
createClusterBubble,
|
||||
drawClusterBubbles,
|
||||
} from './placeClusterLayers';
|
||||
|
||||
function fakeMap(features: unknown[], expansionZoom: unknown = 11) {
|
||||
const source = { getClusterExpansionZoom: vi.fn(() => expansionZoom) };
|
||||
const map = {
|
||||
addSource: vi.fn(),
|
||||
addLayer: vi.fn(),
|
||||
getSource: vi.fn((id: string) => (id === PLACE_CLUSTER_SOURCE_ID ? source : undefined)),
|
||||
easeTo: vi.fn(),
|
||||
querySourceFeatures: vi.fn((_id: string, query: { filter: unknown[] }) =>
|
||||
query.filter[0] === 'has' ? features : []
|
||||
),
|
||||
};
|
||||
return { map: map as unknown as ClusterMap & typeof map, source };
|
||||
}
|
||||
|
||||
describe('cluster bubbles', () => {
|
||||
it('FE-COMP-PLACECLUSTER-001: a bubble grows in three steps with its count', () => {
|
||||
expect([1, 9, 10, 49, 50, 500].map(clusterBubbleSize)).toEqual([36, 36, 42, 42, 48, 48]);
|
||||
});
|
||||
|
||||
it('FE-COMP-PLACECLUSTER-002: the GL bubble is the Leaflet markup inside a bare wrapper the pin can move', () => {
|
||||
const wrapper = createClusterBubble(38);
|
||||
expect(wrapper.className).toBe('');
|
||||
const bubble = wrapper.firstElementChild as HTMLElement;
|
||||
expect(bubble.className).toBe('marker-cluster-custom');
|
||||
expect(bubble.style.width).toBe('42px');
|
||||
expect(bubble.style.height).toBe('42px');
|
||||
expect(bubble.textContent).toBe('38');
|
||||
});
|
||||
|
||||
it('FE-COMP-PLACECLUSTER-003: the canvas circle and count stay as an invisible hit area', () => {
|
||||
const { map } = fakeMap([]);
|
||||
addPlaceClusterLayers(map);
|
||||
expect(map.addSource).toHaveBeenCalledWith(PLACE_CLUSTER_SOURCE_ID, expect.objectContaining({ cluster: true }));
|
||||
const layers = map.addLayer.mock.calls.map(([layer]) => layer as { id: string; paint: Record<string, unknown> });
|
||||
expect(layers.find((l) => l.id === 'trip-place-clusters-circle')?.paint['circle-opacity']).toBe(0);
|
||||
expect(layers.find((l) => l.id === 'trip-place-clusters-count')?.paint['text-opacity']).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-PLACECLUSTER-004: one bubble per cluster, placed at its centre, that zooms in on a click', () => {
|
||||
const cluster = { properties: { cluster_id: 5, point_count: 12 }, geometry: { coordinates: [139.7, 35.6] } };
|
||||
const { map, source } = fakeMap([
|
||||
cluster,
|
||||
cluster, // the same cluster crossing a tile edge
|
||||
{ properties: { cluster_id: 6 }, geometry: { coordinates: [1, 1] } }, // no count
|
||||
{ properties: { cluster_id: 7, point_count: 3 } }, // no geometry
|
||||
]);
|
||||
const placed: { el: HTMLElement; lng: number; lat: number }[] = [];
|
||||
const pins = drawClusterBubbles(map, (el, lng, lat) => {
|
||||
placed.push({ el, lng, lat });
|
||||
return lng;
|
||||
});
|
||||
|
||||
expect(pins).toEqual([139.7]);
|
||||
expect(placed[0].el.textContent).toBe('12');
|
||||
expect(placed[0].lat).toBe(35.6);
|
||||
|
||||
const onMapClick = vi.fn();
|
||||
document.body.appendChild(placed[0].el);
|
||||
document.body.addEventListener('click', onMapClick);
|
||||
placed[0].el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
document.body.removeEventListener('click', onMapClick);
|
||||
placed[0].el.remove();
|
||||
|
||||
// The click is the bubble's: the map underneath never sees it.
|
||||
expect(onMapClick).not.toHaveBeenCalled();
|
||||
expect(source.getClusterExpansionZoom).toHaveBeenCalledWith(5, expect.any(Function));
|
||||
expect(map.easeTo).toHaveBeenCalledWith({ center: [139.7, 35.6], zoom: 11, duration: 350 });
|
||||
});
|
||||
|
||||
it('FE-COMP-PLACECLUSTER-005: a source that reports nothing draws nothing', () => {
|
||||
const { map } = fakeMap([]);
|
||||
map.querySourceFeatures.mockReturnValue(null as never);
|
||||
expect(drawClusterBubbles(map, () => 1)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -5,3 +5,169 @@
|
||||
*/
|
||||
export const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle';
|
||||
export const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count';
|
||||
|
||||
// Marker grouping for the GL map (#1385): MapLibre/Mapbox can't show the rich
|
||||
// HTML photo markers *and* cluster them natively, so we feed the place points
|
||||
// into a clustered GeoJSON source. The source decides what is clustered; the
|
||||
// bubbles are drawn as DOM (`drawClusterBubbles`) and the individual rich HTML
|
||||
// markers only for the points the source reports as currently unclustered.
|
||||
// Grouping is always on, matching the Leaflet map's MarkerClusterGroup.
|
||||
export const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters';
|
||||
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit';
|
||||
|
||||
/**
|
||||
* How eagerly place markers merge into a cluster.
|
||||
*
|
||||
* The overview band the Leaflet map clusters in as well (`CLUSTER_UNTIL_ZOOM` there),
|
||||
* kept as plain numbers rather than shared constants so the GL bundle does not have to
|
||||
* carry Leaflet for them. Above the cutoff supercluster hands every point back on its
|
||||
* own, and the stops that share a coordinate are folded together at the pin level
|
||||
* instead — see the reconcile in MapViewGL.
|
||||
*/
|
||||
const CLUSTER_RADIUS = 20;
|
||||
const CLUSTER_MAX_ZOOM = 8;
|
||||
|
||||
/** The part of either SDK's map the cluster code calls. */
|
||||
export interface ClusterMap {
|
||||
addSource: (id: string, source: object) => void;
|
||||
addLayer: (layer: object) => void;
|
||||
getSource: (id: string) => unknown;
|
||||
easeTo: (options: { center: unknown[]; zoom: number; duration: number }) => void;
|
||||
querySourceFeatures: (id: string, options: { filter: unknown[] }) => ClusterFeature[] | null | undefined;
|
||||
}
|
||||
|
||||
interface ClusterFeature {
|
||||
properties?: { cluster_id?: unknown; point_count?: unknown } | null;
|
||||
geometry?: { coordinates?: unknown };
|
||||
}
|
||||
|
||||
interface ClusterSource {
|
||||
getClusterExpansionZoom?: (id: unknown, done: (err: Error | null, zoom: number) => void) => unknown;
|
||||
}
|
||||
|
||||
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
|
||||
* change and the initial build cannot drift apart. */
|
||||
export function addPlaceClusterLayers(map: Pick<ClusterMap, 'addSource' | 'addLayer'>): void {
|
||||
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
cluster: true,
|
||||
clusterRadius: CLUSTER_RADIUS,
|
||||
clusterMaxZoom: CLUSTER_MAX_ZOOM,
|
||||
});
|
||||
// Invisible: the bubble the user sees is DOM (createClusterBubble), these two
|
||||
// layers stay as the hit area the track and route layers check a click against.
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
'circle-opacity': 0,
|
||||
'circle-stroke-opacity': 0,
|
||||
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
|
||||
},
|
||||
});
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_COUNT_LAYER_ID,
|
||||
type: 'symbol',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
'text-field': ['get', 'point_count_abbreviated'],
|
||||
'text-size': 12,
|
||||
'text-allow-overlap': true,
|
||||
},
|
||||
paint: { 'text-opacity': 0 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: PLACE_UNCLUSTERED_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['!', ['has', 'point_count']],
|
||||
paint: {
|
||||
'circle-radius': 24,
|
||||
'circle-opacity': 0,
|
||||
'circle-stroke-opacity': 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Eases the map in to the zoom a cluster falls apart at. Both SDKs answer that
|
||||
* question, one through a callback and one through a promise. */
|
||||
export function expandCluster(
|
||||
map: Pick<ClusterMap, 'getSource' | 'easeTo'>,
|
||||
clusterId: unknown,
|
||||
coordinates: unknown
|
||||
): void {
|
||||
if (clusterId == null || !Array.isArray(coordinates)) return;
|
||||
const source = map.getSource(PLACE_CLUSTER_SOURCE_ID) as ClusterSource | undefined;
|
||||
const easeToZoom = (nextZoom: number) => {
|
||||
try {
|
||||
map.easeTo({ center: coordinates, zoom: nextZoom, duration: 350 });
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
try {
|
||||
const maybeZoom = source?.getClusterExpansionZoom?.(clusterId, (err, nextZoom) => {
|
||||
if (!err && typeof nextZoom === 'number') easeToZoom(nextZoom);
|
||||
});
|
||||
if (typeof maybeZoom === 'number') easeToZoom(maybeZoom);
|
||||
else if (maybeZoom instanceof Promise) maybeZoom.then(easeToZoom).catch(() => {});
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts one count bubble on the map per cluster the source currently reports, through
|
||||
* `place` (the caller's pin factory), and returns what `place` handed back. A cluster
|
||||
* crossing a tile edge comes back once per tile, so bubbles are keyed by cluster id.
|
||||
*/
|
||||
export function drawClusterBubbles<T>(map: ClusterMap, place: (el: HTMLElement, lng: number, lat: number) => T): T[] {
|
||||
const pins: T[] = [];
|
||||
const seen = new Set<unknown>();
|
||||
for (const feature of map.querySourceFeatures(PLACE_CLUSTER_SOURCE_ID, { filter: ['has', 'point_count'] }) ?? []) {
|
||||
const clusterId = feature?.properties?.cluster_id;
|
||||
const count = feature?.properties?.point_count;
|
||||
const coordinates = feature?.geometry?.coordinates;
|
||||
if (typeof count !== 'number' || !Array.isArray(coordinates) || seen.has(clusterId)) continue;
|
||||
seen.add(clusterId);
|
||||
const el = createClusterBubble(count);
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
expandCluster(map, clusterId, coordinates);
|
||||
});
|
||||
pins.push(place(el, coordinates[0], coordinates[1]));
|
||||
}
|
||||
return pins;
|
||||
}
|
||||
|
||||
/** A count bubble's diameter in pixels: both renderers size their bubbles by it. */
|
||||
export function clusterBubbleSize(count: number): number {
|
||||
return count < 10 ? 36 : count < 50 ? 42 : 48;
|
||||
}
|
||||
|
||||
/**
|
||||
* A count bubble as DOM, styled by `.marker-cluster-custom` in index.css.
|
||||
*
|
||||
* The GL map draws its bubbles with this element rather than as canvas text, so they
|
||||
* read in the app's own font and get the same hover the Leaflet map has. A canvas
|
||||
* symbol layer can only use the glyphs the map style ships and knows no `:hover`.
|
||||
* The bubble sits in a bare wrapper because the pin positions its element through
|
||||
* `transform`, which would otherwise override the hover's scale.
|
||||
*/
|
||||
export function createClusterBubble(count: number): HTMLDivElement {
|
||||
const size = clusterBubbleSize(count);
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'marker-cluster-custom';
|
||||
bubble.style.width = `${size}px`;
|
||||
bubble.style.height = `${size}px`;
|
||||
const label = document.createElement('span');
|
||||
label.textContent = String(count);
|
||||
bubble.appendChild(label);
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.appendChild(bubble);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ import { studioLayerActions, type StudioLayerActions } from './studioLayers'
|
||||
* The book being edited.
|
||||
*
|
||||
* History is snapshot-based rather than patch-based, and deliberately so: a
|
||||
* document is a few hundred kilobytes of plain JSON at most (the schema caps it
|
||||
* at 150 spreads of 60 elements), and a snapshot stack that cannot possibly
|
||||
* disagree with the document beats a patch stack that can. It is capped so a
|
||||
* snapshot shares every page it did not change with the one before it, so even
|
||||
* a book of hundreds of pages costs one page per step, and a snapshot stack
|
||||
* that cannot possibly disagree with the document beats a patch stack that can. It is capped so a
|
||||
* long session does not grow without limit.
|
||||
*
|
||||
* Granularity is the part that matters for how it *feels*: a drag is one undo
|
||||
|
||||
@@ -268,9 +268,11 @@ describe('the entry spreads', () => {
|
||||
})
|
||||
|
||||
describe('the document as a whole', () => {
|
||||
it('stays within the schema cap on spreads', () => {
|
||||
it('lays out a long journey whole, its last page and back cover after the entries', () => {
|
||||
const many = Array.from({ length: 200 }, (_, i) => entry({ id: i + 1 }))
|
||||
expect(build({ entries: many }).spreads.length).toBeLessThanOrEqual(150)
|
||||
const doc = build({ entries: many })
|
||||
expect(doc.spreads.length).toBeGreaterThan(150)
|
||||
expect(doc.spreads.slice(-2).map(sp => sp.role)).toEqual(['last', 'back'])
|
||||
})
|
||||
|
||||
it('gives every spread and every element an id of its own', () => {
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@trek/root",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"server",
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"client": {
|
||||
"name": "@trek/client",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
"@fontsource/abril-fatface": "^5.3.0",
|
||||
@@ -25005,7 +25005,7 @@
|
||||
},
|
||||
"server": {
|
||||
"name": "@trek/server",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"dependencies": {
|
||||
"@aws-lite/client": "^0.23.7",
|
||||
"@aws-lite/s3": "^0.2.7",
|
||||
@@ -25384,7 +25384,7 @@
|
||||
},
|
||||
"shared": {
|
||||
"name": "@trek/shared",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"dependencies": {
|
||||
"isomorphic-dompurify": "^3.23.0",
|
||||
"zod": "^4.6.7"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"private": true,
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/server",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
|
||||
@@ -177,13 +177,15 @@ export async function buildApp(): Promise<INestApplication> {
|
||||
* it is the wrong one for a photo book. A Studio document is sent WHOLE on
|
||||
* every autosave — deliberately, see book-store.schema.ts, because a patch
|
||||
* protocol would need an ordering guarantee and a merge rule per field — and
|
||||
* the contract already caps it at 150 spreads of 90 elements. A real book of
|
||||
* a fortnight's journey is well past a hundred kilobytes before anybody asks
|
||||
* a book has as many pages as its journey needs. A real book of a
|
||||
* fortnight's journey is well past a hundred kilobytes before anybody asks
|
||||
* for road geometry, and what the ceiling produces is not an error message
|
||||
* but a save that quietly fails and an editor that says "not saved" without
|
||||
* saying why.
|
||||
*
|
||||
* So the book route is measured against the size a book can actually be.
|
||||
* An auto laid page is two to five kilobytes, so eight megabytes hold a
|
||||
* book of a few thousand pages: the page count has no limit of its own.
|
||||
*
|
||||
* A list file is the same story at a smaller scale. Its contract allows a
|
||||
* megabyte and the import posts it whole, yet a list of a few hundred places
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { safeFetch, safeFetchLlm } from '../../utils/ssrfGuard';
|
||||
import type { AiChatClient } from './ai-chat-client.interface';
|
||||
import { AI_BASE_URLS } from './ai-connections.constants';
|
||||
import { AI_BASE_URLS, ownServerChatBase } from './ai-connections.constants';
|
||||
import type { ResolvedAiConnection } from './ai-connections.types';
|
||||
import { ChatgptAuthService } from './chatgpt-auth.service';
|
||||
import { AnthropicMessagesClient } from './providers/anthropic-messages.client';
|
||||
@@ -67,7 +67,7 @@ export class AiChatClientService {
|
||||
return new OpenAiChatClient(
|
||||
kind,
|
||||
model,
|
||||
(connection.baseUrl ?? '').replace(/(?<!\/)\/+$/, ''),
|
||||
ownServerChatBase(connection.baseUrl),
|
||||
credential.apiKey,
|
||||
adminServerFetch,
|
||||
);
|
||||
|
||||
@@ -22,6 +22,26 @@ export function aiHostOf(kind: AiConnectionKind, baseUrl: string | null): string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an own server's Chat Completions live. An address with a path, such as
|
||||
* `https://api.groq.com/openai/v1` or `http://ollama.lan:11434/v1`, is used as
|
||||
* entered. A bare server address gets `/v1`: Ollama, LM Studio, vLLM,
|
||||
* llama.cpp and LiteLLM all answer there, while Ollama answers
|
||||
* `/chat/completions` at its root with a 404. Connecting already found the
|
||||
* model list of such an address under the root, so the chat failed where the
|
||||
* connection had worked.
|
||||
*/
|
||||
export function ownServerChatBase(baseUrl: string | null): string {
|
||||
const entered = (baseUrl ?? '').trim().replace(/(?<!\/)\/+$/, '');
|
||||
try {
|
||||
const url = new URL(entered);
|
||||
if (url.pathname === '/' && !url.search && !url.hash) return `${entered}/v1`;
|
||||
} catch {
|
||||
// An address that does not parse fails on the request, as it did before.
|
||||
}
|
||||
return entered;
|
||||
}
|
||||
|
||||
/** Whether OpenRouter's OAuth can send the browser back here: it only returns to an https address. */
|
||||
export function openrouterOAuthAvailable(): boolean {
|
||||
return getAppUrl().startsWith('https://');
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
bookSpreadPhotoIds,
|
||||
compareCodeUnits,
|
||||
MAX_SPREADS,
|
||||
type BookDesignPlanRow,
|
||||
type BookDesignSkipped,
|
||||
type BookDocument,
|
||||
@@ -176,7 +175,7 @@ export function applyDesignDraft(
|
||||
if (at < 0 || !samePage(spreads[at], base.get(row.spreadId))) return skip(row.spreadId, 'changed_since');
|
||||
spreads[at] = page;
|
||||
} else {
|
||||
if (at >= 0 || spreads.length >= MAX_SPREADS) return skip(row.spreadId, 'changed_since');
|
||||
if (at >= 0) return skip(row.spreadId, 'changed_since');
|
||||
spreads.splice(insertAt(spreads, draft.plan, i, page), 0, page);
|
||||
}
|
||||
applied.push(row.spreadId);
|
||||
|
||||
@@ -178,7 +178,7 @@ export class JourneyBookDesignService {
|
||||
baseVersion: draft.baseVersion,
|
||||
expiresAt: new Date(draft.expiresAt).toISOString(),
|
||||
document: draft.doc,
|
||||
plan: draft.plan.slice(0, BOOK_DESIGN_LIMITS.selection).map(planRowOut),
|
||||
plan: draft.plan.map(planRowOut),
|
||||
selection: draft.selection,
|
||||
};
|
||||
}
|
||||
@@ -376,7 +376,7 @@ function answerOf(draft: BookDesignDraft): BookDesignAnswer {
|
||||
expires_at: new Date(draft.expiresAt).toISOString(),
|
||||
plan: rows,
|
||||
more_rows: draft.plan.length - rows.length,
|
||||
selected: draft.selection.slice(0, BOOK_DESIGN_LIMITS.selection),
|
||||
selected: draft.selection,
|
||||
style: draft.style,
|
||||
photos: {
|
||||
used: draft.usedPhotos,
|
||||
|
||||
@@ -59,7 +59,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
titleKey: 'system_notice.release_notes.headline',
|
||||
bodyKey: 'system_notice.release_notes.intro',
|
||||
release: {
|
||||
version: '5.0.0',
|
||||
version: '5.0.1',
|
||||
eyebrowKey: 'system_notice.release_notes.eyebrow',
|
||||
headlineKey: 'system_notice.release_notes.headline',
|
||||
introKey: 'system_notice.release_notes.intro',
|
||||
@@ -95,7 +95,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
footnoteKey: 'system_notice.release_notes.footnote',
|
||||
notes: {
|
||||
labelKey: 'system_notice.release_notes.notes_label',
|
||||
href: 'https://github.com/liketrek/TREK/releases/tag/v5.0.0',
|
||||
href: 'https://github.com/liketrek/TREK/releases/tag/v5.0.1',
|
||||
},
|
||||
note: {
|
||||
eyebrowKey: 'system_notice.release_notes.note_eyebrow',
|
||||
|
||||
@@ -100,6 +100,16 @@ async function answer(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
let nestApp: INestApplication;
|
||||
let orm: MikroORM;
|
||||
let fake: HttpServer;
|
||||
let adminId: number;
|
||||
|
||||
/** The instance connection, pointed at the fake under `path`: `/v1`, or nothing as an Ollama address is often entered. */
|
||||
function connectAt(path: string) {
|
||||
const { port } = fake.address() as AddressInfo;
|
||||
return request(nestApp.getHttpServer())
|
||||
.put('/api/admin/ai/connection')
|
||||
.set('Cookie', sessionCookie(adminId))
|
||||
.send({ kind: 'custom', baseUrl: `http://127.0.0.1:${port}${path}`, model: 'fake-model' });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
fake = createServer((req, res) => {
|
||||
@@ -110,13 +120,8 @@ beforeAll(async () => {
|
||||
orm = nestApp.get(MikroORM);
|
||||
await setAddonEnabled(orm, 'ai_assistant', true);
|
||||
await setAddonEnabled(orm, 'atlas', true);
|
||||
const adminId = (await makeUser(orm, { role: 'admin' })).user.id;
|
||||
const { port } = fake.address() as AddressInfo;
|
||||
const res = await request(nestApp.getHttpServer())
|
||||
.put('/api/admin/ai/connection')
|
||||
.set('Cookie', sessionCookie(adminId))
|
||||
.send({ kind: 'custom', baseUrl: `http://127.0.0.1:${port}/v1`, model: 'fake-model' });
|
||||
expect(res.status).toBe(200);
|
||||
adminId = (await makeUser(orm, { role: 'admin' })).user.id;
|
||||
expect((await connectAt('/v1')).status).toBe(200);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -342,4 +347,23 @@ describe('Home assistant e2e', () => {
|
||||
expect(await countRows(orm, AssistantThreads, { id: homeThread })).toBe(0);
|
||||
expect(await countRows(orm, AssistantThreads, { id: tripThread })).toBe(1);
|
||||
});
|
||||
|
||||
it('ASTHE2E-007: an Ollama address entered without /v1 chats at /v1/chat/completions, where Ollama answers', async () => {
|
||||
const h = await ownerWithTrips();
|
||||
expect((await connectAt('')).status).toBe(200);
|
||||
try {
|
||||
provider.script = [say('Hello from Ollama.')];
|
||||
const res = await post(`${BASE}/chat`, h.userId, { ...CTX, threadId: null, text: 'hi' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(provider.requests).toHaveLength(1);
|
||||
expect(
|
||||
ofType(res.chunks, 'text-delta')
|
||||
.map((c) => c.delta)
|
||||
.join(''),
|
||||
).toBe('Hello from Ollama.');
|
||||
expect(ofType(res.chunks, 'error')).toEqual([]);
|
||||
} finally {
|
||||
expect((await connectAt('/v1')).status).toBe(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
aiHostOf,
|
||||
aiRefusal,
|
||||
openrouterOAuthAvailable,
|
||||
ownServerChatBase,
|
||||
providerRefusal,
|
||||
} from '../../../../src/nest/ai-connections/ai-connections.constants';
|
||||
import { InMemoryAiRefreshLock } from '../../../../src/nest/ai-connections/ai-refresh-lock';
|
||||
@@ -145,6 +146,16 @@ describe('constants', () => {
|
||||
expect(aiHostOf('custom', null)).toBe('');
|
||||
});
|
||||
|
||||
it('AIHLP-032b: a bare own server address chats under /v1, an address with a path as entered', () => {
|
||||
expect(ownServerChatBase('http://ollama.lan:11434')).toBe('http://ollama.lan:11434/v1');
|
||||
expect(ownServerChatBase(' http://ollama.lan:11434/ ')).toBe('http://ollama.lan:11434/v1');
|
||||
expect(ownServerChatBase('http://ollama.lan:11434/v1/')).toBe('http://ollama.lan:11434/v1');
|
||||
expect(ownServerChatBase('https://api.groq.com/openai/v1')).toBe('https://api.groq.com/openai/v1');
|
||||
expect(ownServerChatBase('https://llm.example.org/proxy')).toBe('https://llm.example.org/proxy');
|
||||
expect(ownServerChatBase('not a url')).toBe('not a url');
|
||||
expect(ownServerChatBase(null)).toBe('');
|
||||
});
|
||||
|
||||
it('AIHLP-033: OpenRouter sign-in is offered only on an https app URL', () => {
|
||||
vi.stubEnv('APP_URL', 'https://trip.example.org');
|
||||
expect(openrouterOAuthAvailable()).toBe(true);
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
bookPhotoElementSchema,
|
||||
bookShapeElementSchema,
|
||||
bookTextElementSchema,
|
||||
MAX_SPREADS,
|
||||
type BookDesignPlanRow,
|
||||
type BookDocument,
|
||||
type BookSpread,
|
||||
@@ -310,15 +309,15 @@ describe('applyDesignDraft', () => {
|
||||
expect(out.doc.spreads).toEqual(base.spreads);
|
||||
});
|
||||
|
||||
it('JBAPPLY-014: a book at the page limit takes no new page, the page named as changed since', () => {
|
||||
const inner = Array.from({ length: MAX_SPREADS - 1 }, (_, i) => page(`i${i}`, 'inner'));
|
||||
const full = book([page('c', 'cover'), ...inner]);
|
||||
const draft: DraftToApply = { base: full, doc: book(pages('n')), plan: [row('n', 'insert')], selection: ['n'] };
|
||||
it('JBAPPLY-014: a book of hundreds of pages still takes a new page', () => {
|
||||
const inner = Array.from({ length: 400 }, (_, i) => page(`i${i}`, 'inner'));
|
||||
const long = book([page('c', 'cover'), ...inner]);
|
||||
const draft: DraftToApply = { base: long, doc: book(pages('n')), plan: [row('n', 'insert')], selection: ['n'] };
|
||||
|
||||
const out = applyDesignDraft(full, draft, new Set());
|
||||
const out = applyDesignDraft(long, draft, new Set());
|
||||
|
||||
expect(out.skipped).toEqual([{ spreadId: 'n', reason: 'changed_since' }]);
|
||||
expect(out.doc.spreads).toHaveLength(MAX_SPREADS);
|
||||
expect([out.applied, out.skipped]).toEqual([['n'], []]);
|
||||
expect(out.doc.spreads).toHaveLength(402);
|
||||
});
|
||||
|
||||
it('JBAPPLY-015: a page whose letters are filled with a photo gone since stays out, as a frame of it would', () => {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/shared",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
bookPageOpSchema,
|
||||
bookSpreadIdSchema,
|
||||
} from '../../book/book-ops.schema';
|
||||
import { bookSpreadSchema, MAX_BOOK_TITLE, MAX_SPREADS } from '../../book/book.schema';
|
||||
import { bookSpreadSchema, MAX_BOOK_TITLE } from '../../book/book.schema';
|
||||
import { idSchema } from '../../common/primitives.schema';
|
||||
import { cardCountSchema, cardText } from './card-parts';
|
||||
|
||||
@@ -33,14 +33,10 @@ export const BOOK_PAGE_CARD_CHANGES = ['new', 'changed', 'moved', 'willDelete']
|
||||
export const bookCardPageSchema = z.object({
|
||||
spreadId: bookSpreadIdSchema,
|
||||
/** Where it stands in the book, the cover at 0. */
|
||||
index: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(MAX_SPREADS - 1),
|
||||
index: z.number().int().min(0),
|
||||
role: bookSpreadSchema.shape.role.unwrap(),
|
||||
/** Its place among the double pages, from 1; null for a single page (the covers, the first and the last). */
|
||||
number: z.number().int().min(1).max(MAX_SPREADS).nullable(),
|
||||
number: z.number().int().min(1).nullable(),
|
||||
/** Photos placed on it. */
|
||||
photos: cardCountSchema,
|
||||
});
|
||||
@@ -85,7 +81,7 @@ export const bookPageCardSchema = z.object({
|
||||
/** How many pages the change adds or deletes; for a new book, its pages. */
|
||||
count: cardCountSchema,
|
||||
/** Where a moved page lands among the double pages, from 1. */
|
||||
movedTo: z.number().int().min(1).max(MAX_SPREADS).nullable(),
|
||||
movedTo: z.number().int().min(1).nullable(),
|
||||
/**
|
||||
* `edit_book_page`'s operations, to draw the page after where the card has
|
||||
* no `after` or the Studio's book moved on from `baseVersion`; empty for
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { bookElementId } from '../book-ids';
|
||||
import { blankBook, blankSpread } from '../book-pages';
|
||||
import { MAX_BOOK_TITLE, MAX_SPREADS, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
|
||||
import { MAX_BOOK_TITLE, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
|
||||
import type { JourneyStats } from '../journey-stats.schema';
|
||||
import {
|
||||
STATIONS_PER_PAGE,
|
||||
@@ -155,7 +155,7 @@ export function buildBook(input: AutoInput): BookDocument {
|
||||
version: 1,
|
||||
title: input.title.slice(0, MAX_BOOK_TITLE),
|
||||
page: withFirstPage(input.page),
|
||||
spreads: spreads.slice(0, MAX_SPREADS),
|
||||
spreads,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,21 @@ describe('the auto layout moved from the Studio', () => {
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('AUTOPAR-005: lays out every entry of a long journey, the last page and the back cover after them', () => {
|
||||
const fx = fixtures.A as Fixture;
|
||||
const entries = Array.from({ length: 40 }, (_, round) =>
|
||||
fx.input.entries.map((e) => ({ ...e, id: e.id + 100000 * (round + 1) })),
|
||||
).flat();
|
||||
let n = 0;
|
||||
const doc = buildBook({ ...fx.input, entries, newId: (p) => `${p}-c${++n}` });
|
||||
expect(doc.spreads.length).toBeGreaterThan(300);
|
||||
expect(doc.spreads.slice(-2).map((s) => s.role)).toEqual(['last', 'back']);
|
||||
const laid = new Set(doc.spreads.map((s) => s.entryId));
|
||||
const withPages = new Set(buildBook({ ...fx.input, newId: (p) => `${p}-d${++n}` }).spreads.map((s) => s.entryId));
|
||||
const expected = entries.filter((e) => withPages.has(e.id % 100000));
|
||||
expect(expected.every((e) => laid.has(e.id))).toBe(true);
|
||||
});
|
||||
|
||||
it('AUTOPAR-004: counts the day of an entry in calendar days, a change to summer time between', () => {
|
||||
const stats: JourneyStats = {
|
||||
journeyId: 1,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
BOOK_DESIGN_LIMITS,
|
||||
applyBookDesignRequestSchema,
|
||||
bookDesignAnswerSchema,
|
||||
bookDesignApplyResultSchema,
|
||||
@@ -70,10 +69,9 @@ describe('the design tools', () => {
|
||||
expect(removeBookLayoutRequestSchema.safeParse({ layoutId: 'ly-1', expectedVersion: 4 }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('BKDTOOL-004: the selection is a closed list of page ids, as long as a book and its drops', () => {
|
||||
const ids = Array.from({ length: BOOK_DESIGN_LIMITS.selection }, (_, i) => `sp-${i}`);
|
||||
it('BKDTOOL-004: the selection is a closed list of page ids, as long as the book it picks from', () => {
|
||||
const ids = Array.from({ length: 1200 }, (_, i) => `sp-${i}`);
|
||||
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: ids }).success).toBe(true);
|
||||
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: [...ids, 'sp-x'] }).success).toBe(false);
|
||||
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: ['sp 1'] }).success).toBe(false);
|
||||
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: [], all: true }).success).toBe(false);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
bookWriteResultSchema,
|
||||
editBookPageArgs,
|
||||
} from './book-ops.schema';
|
||||
import { BOOK_FONTS_IDS, MAX_SPREADS, MAX_SPREAD_ELEMENTS, bookDocumentSchema } from './book.schema';
|
||||
import { BOOK_FONTS_IDS, MAX_SPREAD_ELEMENTS, bookDocumentSchema } from './book.schema';
|
||||
import { BOOK_DESIGN_STYLES, bookDesignDirectivesSchema } from './design/design-directives.schema';
|
||||
import { BOOK_DESIGN_ACTIONS } from './design/design-merge';
|
||||
import { BOOK_DESIGN_BEATS } from './design/design-pacing';
|
||||
@@ -34,8 +34,6 @@ export const BOOK_DESIGN_LIMITS = {
|
||||
notes: 20,
|
||||
/** Pages of the book an apply answers with; every applied id is still named. */
|
||||
pages: 24,
|
||||
/** Pages a selection names at most: every page of a book and every page it drops. */
|
||||
selection: MAX_SPREADS * 2,
|
||||
/** Revisions a journey keeps, the newest first. */
|
||||
revisions: 5,
|
||||
} as const;
|
||||
@@ -82,11 +80,7 @@ export type RestoreBookLayoutRequest = z.infer<typeof restoreBookLayoutRequestSc
|
||||
/** One page of a design's plan, as an answer and the draft route carry it. */
|
||||
export const bookDesignPlanRowSchema = z.object({
|
||||
spreadId: bookSpreadIdSchema,
|
||||
index: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(MAX_SPREADS * 2),
|
||||
index: z.number().int().min(0),
|
||||
action: z.enum(BOOK_DESIGN_ACTIONS),
|
||||
beat: z.enum(BOOK_DESIGN_BEATS),
|
||||
chapter: z.string().max(80).nullable(),
|
||||
@@ -121,7 +115,7 @@ export const bookDesignAnswerSchema = z.object({
|
||||
/** Plan rows past those listed. */
|
||||
more_rows: z.number().int().nonnegative(),
|
||||
/** The pages apply_book_design writes unless the person takes some out. */
|
||||
selected: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
|
||||
selected: z.array(bookSpreadIdSchema),
|
||||
style: bookDesignStyleSchema,
|
||||
photos: z.object({
|
||||
used: z.number().int().nonnegative(),
|
||||
@@ -159,19 +153,19 @@ export const bookDesignApplyResultSchema = bookWriteResultSchema.extend({
|
||||
design_id: bookDesignIdSchema,
|
||||
/** The revision that takes the apply back; null when the journey had no book before it. */
|
||||
revision_id: idSchema.nullable(),
|
||||
applied: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
|
||||
skipped: z.array(bookDesignSkippedSchema).max(BOOK_DESIGN_LIMITS.selection),
|
||||
applied: z.array(bookSpreadIdSchema),
|
||||
skipped: z.array(bookDesignSkippedSchema),
|
||||
});
|
||||
export type BookDesignApplyResult = z.infer<typeof bookDesignApplyResultSchema>;
|
||||
|
||||
/** PUT /api/journeys/:journeyId/book-designs/:designId/selection: the pages the apply writes. */
|
||||
export const bookDesignSelectionRequestSchema = z.strictObject({
|
||||
spreadIds: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
|
||||
spreadIds: z.array(bookSpreadIdSchema),
|
||||
});
|
||||
export type BookDesignSelectionRequest = z.infer<typeof bookDesignSelectionRequestSchema>;
|
||||
|
||||
export const bookDesignSelectionResponseSchema = z.object({
|
||||
selection: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
|
||||
selection: z.array(bookSpreadIdSchema),
|
||||
});
|
||||
export type BookDesignSelectionResponse = z.infer<typeof bookDesignSelectionResponseSchema>;
|
||||
|
||||
@@ -181,7 +175,7 @@ export const bookDesignDraftResponseSchema = z.object({
|
||||
baseVersion: version.nullable(),
|
||||
expiresAt: z.string(),
|
||||
document: bookDocumentSchema,
|
||||
plan: z.array(bookDesignPlanRowSchema).max(BOOK_DESIGN_LIMITS.selection),
|
||||
selection: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
|
||||
plan: z.array(bookDesignPlanRowSchema),
|
||||
selection: z.array(bookSpreadIdSchema),
|
||||
});
|
||||
export type BookDesignDraftResponse = z.infer<typeof bookDesignDraftResponseSchema>;
|
||||
|
||||
@@ -21,7 +21,6 @@ import { bookElementPhotoIds, checkRestorablePhotos } from './book-photo-refs';
|
||||
import { bookLayoutFromSpread, nextBookLayoutName } from './book-saved-layouts';
|
||||
import {
|
||||
MAX_BOOK_LAYOUTS,
|
||||
MAX_SPREADS,
|
||||
bookLayoutSchema,
|
||||
type BookDocument,
|
||||
type BookLayout,
|
||||
@@ -107,9 +106,6 @@ export function duplicateBookPage(doc: BookDocument, args: unknown, ctx: BookOps
|
||||
if (!canEditSpread(source)) {
|
||||
throw failOp('protected_spread', `${source.id} is the ${source.role} page and cannot be copied`, null);
|
||||
}
|
||||
if (doc.spreads.length + 1 > MAX_SPREADS) {
|
||||
throw failOp('too_many_spreads', `a book holds at most ${MAX_SPREADS} pages`, null);
|
||||
}
|
||||
const fresh = <E extends { id: string; kind: string }>(e: E): E => ({ ...e, id: newId(e.kind.charAt(0)) });
|
||||
const copy = checkedSpread(
|
||||
{ ...source, id: newId('sp'), elements: source.elements.map(fresh), parked: source.parked.map(fresh) },
|
||||
|
||||
@@ -32,7 +32,6 @@ export const BOOK_OP_ERROR_CODES = [
|
||||
'layout_not_found',
|
||||
'layout_role',
|
||||
'too_many_elements',
|
||||
'too_many_spreads',
|
||||
'parked_full',
|
||||
'empty_frame',
|
||||
'caption_missing',
|
||||
|
||||
@@ -20,7 +20,6 @@ import { bookElementIdSchema, bookGridIdSchema, bookSpreadIdSchema } from './boo
|
||||
import { bookPaintInSchema } from './book-paint.schema';
|
||||
import {
|
||||
MAX_BOOK_TITLE,
|
||||
MAX_SPREADS,
|
||||
MAX_SPREAD_ELEMENTS,
|
||||
bookBadgeElementSchema,
|
||||
bookCountriesElementSchema,
|
||||
@@ -273,11 +272,7 @@ export const addBookPagesArgs = {
|
||||
};
|
||||
export const moveBookPageArgs = {
|
||||
spreadId: bookSpreadIdSchema,
|
||||
toIndex: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(MAX_SPREADS - 1),
|
||||
toIndex: z.number().int().min(0),
|
||||
expectedVersion,
|
||||
};
|
||||
export const deleteBookPagesArgs = {
|
||||
@@ -302,11 +297,7 @@ export const createBookArgs = {
|
||||
* it goes back when it is gone, a page still in the book is replaced where it is.
|
||||
*/
|
||||
export const bookPreviousSpreadSchema = z.object({
|
||||
index: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(MAX_SPREADS - 1),
|
||||
index: z.number().int().min(0),
|
||||
spread: bookSpreadSchema,
|
||||
});
|
||||
export type BookPreviousSpread = z.infer<typeof bookPreviousSpreadSchema>;
|
||||
@@ -625,7 +616,7 @@ export const bookWriteResultSchema = z.object({
|
||||
dropped: z.array(z.string()).max(MAX_SPREAD_ELEMENTS * 2),
|
||||
previous_spreads: z.array(bookPreviousSpreadSchema).optional(),
|
||||
/** The pages the write touched as it left them, for an undo that checks only those (`expectedPages`). */
|
||||
page_digests: z.array(bookPageDigestSchema).max(MAX_SPREADS).optional(),
|
||||
page_digests: z.array(bookPageDigestSchema).optional(),
|
||||
from_index: z.number().int().nonnegative().optional(),
|
||||
/** From `set_book_setup`: the setup as it was, to set it back. */
|
||||
previous_setup: bookPageSetupSchema.optional(),
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { BookEditResult } from './book-ops-core';
|
||||
import { addBookPages, deleteBookPages, moveBookPage, restoreBookPage, setBookCover } from './book-page-ops';
|
||||
import { blankBook, blankSpread, bookPageSetupForPreset, canEditSpread, innerSpreadBounds } from './book-pages';
|
||||
import {
|
||||
MAX_SPREADS,
|
||||
bookDocumentSchema,
|
||||
bookPageSetupSchema,
|
||||
bookPhotoElementSchema,
|
||||
@@ -129,10 +128,13 @@ describe('add, move and delete', () => {
|
||||
expect(refused(stray)).toBe('entry_not_found');
|
||||
});
|
||||
|
||||
it('BOOKPG-007: refuses an unknown page, more than ten pages, and a book at its page cap', () => {
|
||||
it('BOOKPG-007: refuses an unknown page and more than ten pages, and grows a book of any length', () => {
|
||||
expect(refused(addBookPages(book(1), { afterSpreadId: 'sp-nope' }))).toBe('spread_not_found');
|
||||
expect(refused(addBookPages(book(1), { afterSpreadId: 'sp-in1', count: 11 }))).toBe('invalid_op');
|
||||
expect(refused(addBookPages(book(MAX_SPREADS - 4), { afterSpreadId: 'sp-in1' }))).toBe('too_many_spreads');
|
||||
const long = book(400);
|
||||
const grown = ok(addBookPages(long, { afterSpreadId: 'sp-in400', count: 10 }, { newId }));
|
||||
expect(grown.spreads).toHaveLength(long.spreads.length + 10);
|
||||
expect(grown.spreads.slice(-2).map((s) => s.role)).toEqual(['last', 'back']);
|
||||
});
|
||||
|
||||
it('BOOKPG-008: moves an inner spread within the inner range', () => {
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
import { blankSpread, canEditSpread, innerSpreadBounds } from './book-pages';
|
||||
import { bookSpreadPhotoIds, checkRestorablePhotos } from './book-photo-refs';
|
||||
import {
|
||||
MAX_SPREADS,
|
||||
bookSpreadSchema,
|
||||
type BookDocument,
|
||||
type BookElement,
|
||||
@@ -57,9 +56,6 @@ export function addBookPages(doc: BookDocument, args: unknown, ctx: BookOpsConte
|
||||
return caught(() => {
|
||||
const after = indexOf(doc, afterSpreadId);
|
||||
if (entryId !== undefined) journeyEntry(entryId, ctx, null);
|
||||
if (doc.spreads.length + count > MAX_SPREADS) {
|
||||
throw failOp('too_many_spreads', `a book holds at most ${MAX_SPREADS} pages`, null);
|
||||
}
|
||||
const { first, limit } = innerSpreadBounds(doc.spreads);
|
||||
const at = Math.min(Math.max(after + 1, first), limit);
|
||||
const added = Array.from({ length: count }, () =>
|
||||
@@ -184,9 +180,6 @@ function putBack(doc: BookDocument, spread: BookSpread, index: number): BookDocu
|
||||
if (spread.role !== 'inner' && doc.spreads.some((s) => s.role === spread.role)) {
|
||||
throw failOp('protected_spread', `the book already has its ${spread.role} page`, null);
|
||||
}
|
||||
if (doc.spreads.length >= MAX_SPREADS) {
|
||||
throw failOp('too_many_spreads', `a book holds at most ${MAX_SPREADS} pages`, null);
|
||||
}
|
||||
const { first, limit } = innerSpreadBounds(doc.spreads);
|
||||
const to = spread.role === 'inner' ? Math.min(Math.max(index, first), limit) : Math.min(index, doc.spreads.length);
|
||||
return { ...doc, spreads: [...doc.spreads.slice(0, to), spread, ...doc.spreads.slice(to)] };
|
||||
|
||||
@@ -20,10 +20,10 @@ import { z } from 'zod';
|
||||
*
|
||||
* ── Why the whole document ───────────────────────────────────────────────
|
||||
*
|
||||
* Not a patch. A book is a few hundred kilobytes of JSON at most — the schema
|
||||
* caps it at 150 spreads of 60 elements — and a patch protocol would need an
|
||||
* ordering guarantee, a merge rule per field, and a way to recover when a
|
||||
* client misses one. Sending the document costs bandwidth that autosave already
|
||||
* Not a patch. A book is a few kilobytes of JSON a page, a few megabytes for
|
||||
* the longest journey's, and a patch protocol would need an ordering
|
||||
* guarantee, a merge rule per field, and a way to recover when a client
|
||||
* misses one. Sending the document costs bandwidth that autosave already
|
||||
* amortises and removes an entire class of bug.
|
||||
*/
|
||||
|
||||
|
||||
@@ -65,7 +65,6 @@ const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected #rrggbb');
|
||||
* `MAX_SPREAD_ELEMENTS` belongs to this set and is declared with the spread it
|
||||
* bounds, where the salvage pass reads it too.
|
||||
*/
|
||||
export const MAX_SPREADS = 150;
|
||||
export const MAX_BOOK_TITLE = 200;
|
||||
export const MAX_TEXT_LENGTH = 8000;
|
||||
/** Codes on a map or a country page, and the names printed beside them. */
|
||||
@@ -1122,7 +1121,11 @@ const bookDocumentObject = z.object({
|
||||
schemaRev: z.number().int().min(1).optional().catch(undefined),
|
||||
title: z.string().max(MAX_BOOK_TITLE).default(''),
|
||||
page: bookPageSetupSchema.default(() => bookPageSetupSchema.parse({})),
|
||||
spreads: z.array(bookSpreadSchema).max(MAX_SPREADS).default([]),
|
||||
/**
|
||||
* As many as the journey needs: a long trip's book runs to hundreds of pages,
|
||||
* and the save route's body limit is the only bound on its size.
|
||||
*/
|
||||
spreads: z.array(bookSpreadSchema).default([]),
|
||||
/** The book's own layouts (#2316). Optional so a document from before still reads. */
|
||||
layouts: z.array(bookLayoutSchema).max(MAX_BOOK_LAYOUTS).optional(),
|
||||
/** Palette, typefaces and motifs; elements link to it through the fields of theme/book-theme.schema.ts. */
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { BookPhotoInfo } from '../book-ops-core';
|
||||
import { blankBook } from '../book-pages';
|
||||
import { isStale } from '../book-travel';
|
||||
import {
|
||||
MAX_SPREADS,
|
||||
MAX_SPREAD_ELEMENTS,
|
||||
bookDocumentSchema,
|
||||
bookPhotoElementSchema,
|
||||
@@ -367,10 +366,10 @@ describe('design_book', () => {
|
||||
expect(spread.notes.some((n) => n.includes('hero-script-word'))).toBe(true);
|
||||
});
|
||||
|
||||
it('BOOKDSN-014: stays within the spread cap beside many kept pages, the closing pages kept', () => {
|
||||
it('BOOKDSN-014: designs every page beside hundreds of kept pages, the closing pages kept', () => {
|
||||
const stored = storedBook();
|
||||
const lockedPage = stored.spreads[2] as BookSpread;
|
||||
const many = Array.from({ length: MAX_SPREADS - 6 }, (_, i): BookSpread => ({
|
||||
const many = Array.from({ length: 300 }, (_, i): BookSpread => ({
|
||||
...lockedPage,
|
||||
id: `sp-l${i}`,
|
||||
elements: lockedPage.elements.map((e) => ({ ...e, id: `t-l${i}` })),
|
||||
@@ -383,10 +382,10 @@ describe('design_book', () => {
|
||||
for (const style of ['minimal', 'classic'] as const) {
|
||||
const result = designBook(full, input('rich'), { style });
|
||||
checkStructure(result);
|
||||
expect(result.doc.spreads.length).toBeLessThanOrEqual(MAX_SPREADS);
|
||||
expect(inner(result.doc).length).toBeLessThanOrEqual(MAX_SPREADS - 4);
|
||||
expect(inner(result.doc).length).toBeGreaterThan(many.length);
|
||||
expect(many.every((p) => result.doc.spreads.some((s) => s.id === p.id))).toBe(true);
|
||||
expect(result.notes.length).toBeGreaterThan(0);
|
||||
expect(result.doc.spreads.slice(-2).map((s) => s.role)).toEqual(['last', 'back']);
|
||||
expect(result.notes.some((n) => n.includes('left out'))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { BookPhotoInfo } from '../book-ops-core';
|
||||
import type { BookFinding } from '../book-ops.schema';
|
||||
import { bookPageSetupForPreset } from '../book-pages';
|
||||
import type { BookTravelContext } from '../book-travel';
|
||||
import { MAX_BOOK_TITLE, MAX_SPREADS, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
|
||||
import { MAX_BOOK_TITLE, 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';
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import { composeBack, composeCover, composeTitlePage, stationsPerSpread } from './design-framing';
|
||||
import { composeDesignedInner, type FramingNeeds } from './design-inner';
|
||||
import { isLivelyBeat } from './design-lively-pacing';
|
||||
import { keepsStored, mergeDesign, type BookDesignPlanRow, type DesignedSpread } from './design-merge';
|
||||
import { mergeDesign, type BookDesignPlanRow, type DesignedSpread } from './design-merge';
|
||||
import { applyPageOverrides } from './design-overrides';
|
||||
import type { BookDesignBeat } from './design-pacing';
|
||||
import {
|
||||
@@ -140,23 +140,6 @@ function classicOnly(d: BookDesignDirectives): boolean {
|
||||
return d.chapters === 'entry' && d.density === 'balanced';
|
||||
}
|
||||
|
||||
/**
|
||||
* The designed pages within the spread cap: inner pages past `room` are left
|
||||
* off the end (the framing pages always stay), and the note says how many.
|
||||
*/
|
||||
function withinRoom(designed: readonly DesignedSpread[], room: number, notes: string[]): DesignedSpread[] {
|
||||
const inner = designed.filter((d) => d.spread.role === 'inner');
|
||||
const over = inner.length - Math.max(0, room);
|
||||
if (over <= 0) return [...designed];
|
||||
const cut = new Set(inner.slice(inner.length - over).map((d) => d.spread.id));
|
||||
notes.push(`the book holds ${MAX_SPREADS} spreads, so the last ${over} designed pages are left out`);
|
||||
return designed.filter((d) => !cut.has(d.spread.id));
|
||||
}
|
||||
|
||||
/** Inner spreads the book has room for once the stored pages it keeps are counted. */
|
||||
const roomFor = (doc: BookDocument | null, keep: BookDesignDirectives['keep']) =>
|
||||
MAX_SPREADS - 4 - (doc?.spreads ?? []).filter((s) => s.role === 'inner' && keepsStored(s, keep)).length;
|
||||
|
||||
/** The travel context the travel elements read: the journey's figures, route, entries and labels. */
|
||||
function travelOf(input: BookDesignInput, entries: readonly AutoEntry[]): BookTravelContext {
|
||||
return {
|
||||
@@ -205,14 +188,13 @@ function classicDesign(
|
||||
chapter: entries.find((e) => e.id === spread.entryId)?.title ?? null,
|
||||
findings: pageFindings(spread, page, input.photos),
|
||||
}));
|
||||
const designed = withinRoom(all, roomFor(doc, directives.keep), notes);
|
||||
const merged = mergeDesign(doc, designed, directives.keep);
|
||||
const merged = mergeDesign(doc, all, directives.keep);
|
||||
const finalDoc: BookDocument = {
|
||||
...built,
|
||||
spreads: merged.spreads,
|
||||
...(doc?.layouts ? { layouts: doc.layouts } : {}),
|
||||
};
|
||||
const residual = designed.flatMap((d) => d.findings.filter((f) => f.severity !== 'info'));
|
||||
const residual = all.flatMap((d) => d.findings.filter((f) => f.severity !== 'info'));
|
||||
return {
|
||||
doc: finalDoc,
|
||||
plan: merged.plan,
|
||||
@@ -274,7 +256,7 @@ export function designBook(
|
||||
notes: [],
|
||||
art,
|
||||
};
|
||||
const needs: FramingNeeds = { room: roomFor(doc, directives.keep), perStations: stationsPerSpread(c) };
|
||||
const needs: FramingNeeds = { perStations: stationsPerSpread(c) };
|
||||
const inner = composeDesignedInner(c, placed, choice, input, needs);
|
||||
|
||||
const words = {
|
||||
@@ -547,8 +529,7 @@ function templatedDesign(
|
||||
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 inner = composeTemplatedInner(c, placed, choice, input, { 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) }));
|
||||
@@ -569,8 +550,7 @@ interface TemplatedParts {
|
||||
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 laid = mergeDesign(doc, parts.done, directives.keep);
|
||||
const built: BookDocument = {
|
||||
version: 1,
|
||||
title: (directives.cover?.title ?? input.title).slice(0, MAX_BOOK_TITLE),
|
||||
@@ -586,7 +566,7 @@ function templatedResult(doc: BookDocument | null, input: BookDesignInput, parts
|
||||
: { 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'));
|
||||
: parts.done.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)))];
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BOOK_COVER_ARCHETYPES, type BookCoverArchetype } from '../book-archetypes';
|
||||
import { bookPageOpSchema } from '../book-ops.schema';
|
||||
import { bookLockupIdSchema } from '../book-text-ops.schema';
|
||||
import { MAX_SPREADS } from '../book.schema';
|
||||
import { BOOK_THEME_DECORATIONS, BOOK_THEME_IDS } from '../theme/book-theme.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
@@ -97,12 +96,7 @@ const overrideTemplateSchema = z
|
||||
|
||||
export const bookDesignPageOverrideSchema = z
|
||||
.strictObject({
|
||||
index: z
|
||||
.number()
|
||||
.int()
|
||||
.min(2)
|
||||
.max(MAX_SPREADS - 1)
|
||||
.describe('The page by its index in the plan design_book answered; the cover is 0.'),
|
||||
index: z.number().int().min(2).describe('The page by its index in the plan design_book answered; the cover is 0.'),
|
||||
beat: z
|
||||
.enum(BOOK_DESIGN_OVERRIDE_BEATS)
|
||||
.describe(
|
||||
|
||||
@@ -41,8 +41,6 @@ import { decorateSpread } from './design-stickers';
|
||||
*/
|
||||
|
||||
export interface FramingNeeds {
|
||||
/** Inner spreads the book has room for, the kept stored pages taken off. */
|
||||
room: number;
|
||||
/** Bare stops one stations spread holds. */
|
||||
perStations: number;
|
||||
}
|
||||
@@ -70,9 +68,8 @@ function framingInner(c: ComposeCtx): FramingKind[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The most inner spreads the design may make: `pages.max`, and never past the room the book has. */
|
||||
const innerCap = (c: ComposeCtx, needs: FramingNeeds) =>
|
||||
Math.max(0, Math.min(c.directives.pages?.max ?? needs.room, needs.room));
|
||||
/** The most inner spreads the design may make: `pages.max` when the directives set one, otherwise as many as it needs. */
|
||||
const innerCap = (c: ComposeCtx) => Math.max(0, c.directives.pages?.max ?? Number.POSITIVE_INFINITY);
|
||||
|
||||
/**
|
||||
* The framing pages that fit beside at least one chapter spread: past the cap
|
||||
@@ -316,7 +313,7 @@ export function composeDesignedInner(
|
||||
): InnerResult {
|
||||
const notes: string[] = [];
|
||||
const leftOut: BookDesignLeftOut[] = [];
|
||||
const cap = innerCap(c, needs);
|
||||
const cap = innerCap(c);
|
||||
const wanted = framingInner(c);
|
||||
const framing = fittingFraming(wanted, cap);
|
||||
if (framing.length < wanted.length) {
|
||||
|
||||
@@ -46,7 +46,7 @@ const hasLock = (spread: BookSpread) => spread.elements.some((e) => e.locked);
|
||||
* inner page only while the person keeps locked pages); with `edited` also a
|
||||
* page made by hand (no entry behind it, not blank).
|
||||
*/
|
||||
export function keepsStored(spread: BookSpread, keep: BookDesignDirectives['keep']): boolean {
|
||||
function keepsStored(spread: BookSpread, keep: BookDesignDirectives['keep']): boolean {
|
||||
if (spread.role !== 'inner') return hasLock(spread);
|
||||
if (keep === 'nothing') return false;
|
||||
if (hasLock(spread)) return true;
|
||||
|
||||
@@ -1,228 +1,228 @@
|
||||
{
|
||||
"A/fabric-classic/head": "74bb0439e4575ed7",
|
||||
"A/fabric-classic/plan": "95b55b052b9ff07b",
|
||||
"A/fabric-classic/spread/0": "a4d090166287cbe5",
|
||||
"A/fabric-classic/spread/0": "36ce28aa969ee78e",
|
||||
"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/10": "f6f5a5bfbb97c674",
|
||||
"A/fabric-classic/spread/11": "c1e942bd73455a86",
|
||||
"A/fabric-classic/spread/12": "c390827640a1bc65",
|
||||
"A/fabric-classic/spread/13": "f49a2ec83099a556",
|
||||
"A/fabric-classic/spread/14": "f13bb412f61a778c",
|
||||
"A/fabric-classic/spread/15": "7ec17a9b9e9955f8",
|
||||
"A/fabric-classic/spread/16": "029964006e41bbdb",
|
||||
"A/fabric-classic/spread/17": "78ae60620ae287a3",
|
||||
"A/fabric-classic/spread/18": "c73e813888a28cd1",
|
||||
"A/fabric-classic/spread/19": "d98cc9a891bf2141",
|
||||
"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/20": "e1dfa3eeb056189b",
|
||||
"A/fabric-classic/spread/21": "b18be5f5e1696caf",
|
||||
"A/fabric-classic/spread/22": "98a8cab370aeb8fa",
|
||||
"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/fabric-classic/spread/4": "d0eba1475d8d9f04",
|
||||
"A/fabric-classic/spread/5": "468dc734d5aae63f",
|
||||
"A/fabric-classic/spread/6": "776a926951bfdbe5",
|
||||
"A/fabric-classic/spread/7": "16f3ec7630e52f5d",
|
||||
"A/fabric-classic/spread/8": "f64487735a6b5be1",
|
||||
"A/fabric-classic/spread/9": "70dba74ad0223875",
|
||||
"A/midnight/head": "ce0d82281cbc3e1f",
|
||||
"A/midnight/plan": "7fde812131c9b570",
|
||||
"A/midnight/spread/0": "409beb5fbb1accce",
|
||||
"A/midnight/spread/0": "1ea6071685899f8f",
|
||||
"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/10": "b42ae04363ff591c",
|
||||
"A/midnight/spread/11": "a7271ec809fb43f3",
|
||||
"A/midnight/spread/12": "e2a46e3248f9f7f2",
|
||||
"A/midnight/spread/13": "674814c68bdfc64e",
|
||||
"A/midnight/spread/14": "9131f08ae5ee964d",
|
||||
"A/midnight/spread/15": "516667160a8ea844",
|
||||
"A/midnight/spread/16": "7f572ace66e550eb",
|
||||
"A/midnight/spread/17": "f75b6c80be848ac6",
|
||||
"A/midnight/spread/18": "029d7d07ce5cb3c3",
|
||||
"A/midnight/spread/19": "14c04aa51da497cd",
|
||||
"A/midnight/spread/2": "b00839cd0dc1451b",
|
||||
"A/midnight/spread/20": "dff6e523732c8481",
|
||||
"A/midnight/spread/21": "26d666dde7be6641",
|
||||
"A/midnight/spread/20": "ea8aa8da12cad00a",
|
||||
"A/midnight/spread/21": "68057d81fd4903b4",
|
||||
"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/midnight/spread/4": "6d89cdaeac29b8b6",
|
||||
"A/midnight/spread/5": "25f6a66be27c2415",
|
||||
"A/midnight/spread/6": "14c828e117eb72a4",
|
||||
"A/midnight/spread/7": "8fac481cb6c1d93d",
|
||||
"A/midnight/spread/8": "8730ae9ad717bbe5",
|
||||
"A/midnight/spread/9": "9792ae63b36eff3e",
|
||||
"A/riso/head": "7d43d0900fbe0f57",
|
||||
"A/riso/plan": "f802ad819f1fb262",
|
||||
"A/riso/spread/0": "1f4502d29eca9017",
|
||||
"A/riso/spread/0": "50a4cc5fafbfad6e",
|
||||
"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/10": "d468165710581556",
|
||||
"A/riso/spread/11": "1997a6c3ae1c8570",
|
||||
"A/riso/spread/12": "655767248f707020",
|
||||
"A/riso/spread/13": "15e7ddcf0d397509",
|
||||
"A/riso/spread/14": "88cf12b3fa6b6be9",
|
||||
"A/riso/spread/15": "d2bb88a721cc8320",
|
||||
"A/riso/spread/16": "d58f70c381d9f6ae",
|
||||
"A/riso/spread/17": "00caa407cb2a0677",
|
||||
"A/riso/spread/18": "ed41aca064456614",
|
||||
"A/riso/spread/19": "36647f06c594e715",
|
||||
"A/riso/spread/18": "def2a9dde53f3e26",
|
||||
"A/riso/spread/19": "3cea577870cdc23d",
|
||||
"A/riso/spread/2": "5c9a93de9e6467d6",
|
||||
"A/riso/spread/20": "8518ca61c110fd8a",
|
||||
"A/riso/spread/21": "d4ba78b2ffd5ae04",
|
||||
"A/riso/spread/22": "de5116307e12ce61",
|
||||
"A/riso/spread/20": "c851d704ccca068e",
|
||||
"A/riso/spread/21": "be8c5194461daa01",
|
||||
"A/riso/spread/22": "dcd4a1dfde54cef9",
|
||||
"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",
|
||||
"A/riso/spread/4": "1b46e4d88f0a0db5",
|
||||
"A/riso/spread/5": "dd07d5451021f0f5",
|
||||
"A/riso/spread/6": "ae0f0bbad2f029e3",
|
||||
"A/riso/spread/7": "5029fc77bc6a7bca",
|
||||
"A/riso/spread/8": "6eecfd5fd8319fe6",
|
||||
"A/riso/spread/9": "3ba63e382035e668",
|
||||
"B/fabric-classic/head": "6af0d11dd09eae78",
|
||||
"B/fabric-classic/plan": "cae85517611abbca",
|
||||
"B/fabric-classic/spread/0": "87325aa216fe9c6a",
|
||||
"B/fabric-classic/spread/0": "9da4b641b0f81264",
|
||||
"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/3": "bdb01dc38f94e0d0",
|
||||
"B/fabric-classic/spread/4": "4e949ebeb4098598",
|
||||
"B/fabric-classic/spread/5": "fa7289a87111f6a2",
|
||||
"B/fabric-classic/spread/6": "e1367526818066fb",
|
||||
"B/fabric-classic/spread/7": "a5c72a73d9305e41",
|
||||
"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/0": "ee90988cd45ab2c4",
|
||||
"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/3": "b9fb3f915cffb57d",
|
||||
"B/midnight/spread/4": "9f0467b6921de638",
|
||||
"B/midnight/spread/5": "7e3d7de6ef19180c",
|
||||
"B/midnight/spread/6": "d42de16c6d174c6b",
|
||||
"B/midnight/spread/7": "dba5f8de6c557d9e",
|
||||
"B/midnight/spread/8": "835870aa7e88f095",
|
||||
"B/midnight/spread/9": "eba329fd185958b3",
|
||||
"B/riso/head": "69a6c3112de2a047",
|
||||
"B/riso/plan": "d8906a53b3532db8",
|
||||
"B/riso/spread/0": "2bbd6e6b4e6d1b4d",
|
||||
"B/riso/spread/0": "867edf084f05a873",
|
||||
"B/riso/spread/1": "8de92788de29277b",
|
||||
"B/riso/spread/2": "40f8d2b093d0fc8d",
|
||||
"B/riso/spread/3": "1fc8aedc7486cbeb",
|
||||
"B/riso/spread/4": "311c9fcde888d578",
|
||||
"B/riso/spread/4": "cedfcf22d3331d4a",
|
||||
"B/riso/spread/5": "51562d8f08f46011",
|
||||
"B/riso/spread/6": "c114a720b5c5beff",
|
||||
"B/riso/spread/6": "68783b660e0ffa6a",
|
||||
"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/0": "2ab15684c1a8df5a",
|
||||
"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/2": "d0f1544330a8501f",
|
||||
"C/fabric-classic/spread/3": "3600be0a98413583",
|
||||
"C/fabric-classic/spread/4": "d551cdf96c385341",
|
||||
"C/fabric-classic/spread/5": "177741ca8b5c1758",
|
||||
"C/fabric-classic/spread/6": "2c42a8276092879e",
|
||||
"C/fabric-classic/spread/7": "fd5861b762afd36b",
|
||||
"C/fabric-classic/spread/8": "a7f0ed591793b5e1",
|
||||
"C/fabric-classic/spread/9": "31665bce59abeafe",
|
||||
"C/fabric-classic/spread/9": "2a5631a4b55fd15a",
|
||||
"C/midnight/head": "f1e6077175cb2e20",
|
||||
"C/midnight/plan": "14fb454d02caf6f9",
|
||||
"C/midnight/spread/0": "79eae9202a85d430",
|
||||
"C/midnight/spread/0": "10706dcfb990de4c",
|
||||
"C/midnight/spread/1": "d9f5d78659332b45",
|
||||
"C/midnight/spread/10": "6694684925255e94",
|
||||
"C/midnight/spread/11": "247394453c45c63c",
|
||||
"C/midnight/spread/12": "abe0197797669d57",
|
||||
"C/midnight/spread/10": "29566f40839aa633",
|
||||
"C/midnight/spread/11": "063bb3a68427c348",
|
||||
"C/midnight/spread/12": "68223f7894a7f842",
|
||||
"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/midnight/spread/2": "894a97de13c021ed",
|
||||
"C/midnight/spread/3": "97fb835ff2105cdc",
|
||||
"C/midnight/spread/4": "87961fc9d8aa3915",
|
||||
"C/midnight/spread/5": "4c4a134559a3516a",
|
||||
"C/midnight/spread/6": "3358ec65f7d7240a",
|
||||
"C/midnight/spread/7": "b7abfc765f918ddb",
|
||||
"C/midnight/spread/8": "52a02b23cbe1e01a",
|
||||
"C/midnight/spread/9": "9ccf347142b626ca",
|
||||
"C/riso/head": "d19997b07778d1dc",
|
||||
"C/riso/plan": "175dadeb8bb730a6",
|
||||
"C/riso/spread/0": "8453dd7b94afe68d",
|
||||
"C/riso/spread/0": "11bb39722782ce9d",
|
||||
"C/riso/spread/1": "8de92788de29277b",
|
||||
"C/riso/spread/10": "6b5aa1dac73ab777",
|
||||
"C/riso/spread/11": "c20262603c86d43e",
|
||||
"C/riso/spread/12": "8e263c445c0418d2",
|
||||
"C/riso/spread/10": "df3b209ca6a45afb",
|
||||
"C/riso/spread/11": "48d8e38b39721c2b",
|
||||
"C/riso/spread/12": "2326a58882d7c326",
|
||||
"C/riso/spread/13": "a748ada7a028818e",
|
||||
"C/riso/spread/14": "81e4b4a0dcc6961a",
|
||||
"C/riso/spread/2": "5e43386b045d926c",
|
||||
"C/riso/spread/3": "9587f2fe2fc9cd04",
|
||||
"C/riso/spread/2": "b25ef24c052d72a3",
|
||||
"C/riso/spread/3": "1edc86f0be5c9422",
|
||||
"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",
|
||||
"C/riso/spread/5": "1c86ab49df7921b5",
|
||||
"C/riso/spread/6": "66fe84b4051bbb45",
|
||||
"C/riso/spread/7": "05ed926557fdc48d",
|
||||
"C/riso/spread/8": "1599830b16195a51",
|
||||
"C/riso/spread/9": "dafb4c953a1c6693",
|
||||
"D/fabric-classic/head": "54fa8987fea87ce4",
|
||||
"D/fabric-classic/plan": "a2d0423833838dcc",
|
||||
"D/fabric-classic/spread/0": "1136daea45c98c7d",
|
||||
"D/fabric-classic/spread/0": "58ae71edfa473b8c",
|
||||
"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/10": "f53e366c56f5026d",
|
||||
"D/fabric-classic/spread/11": "36816b8e9c3e9745",
|
||||
"D/fabric-classic/spread/12": "1f512d9c851fd489",
|
||||
"D/fabric-classic/spread/13": "8e885c24ae9c4796",
|
||||
"D/fabric-classic/spread/14": "8ed5b5e96e4f5696",
|
||||
"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/fabric-classic/spread/4": "9fcb7ff2c09e7e05",
|
||||
"D/fabric-classic/spread/5": "512f64afa749875d",
|
||||
"D/fabric-classic/spread/6": "d909172ca9306881",
|
||||
"D/fabric-classic/spread/7": "b267abc83d4faf93",
|
||||
"D/fabric-classic/spread/8": "29c43d7551fdcedb",
|
||||
"D/fabric-classic/spread/9": "932c06500ebaf54c",
|
||||
"D/midnight/head": "da510cf5f95bad40",
|
||||
"D/midnight/plan": "3bd0d52ed4e476c7",
|
||||
"D/midnight/spread/0": "440b7d69daa18a7a",
|
||||
"D/midnight/spread/0": "cc12862834fdbf96",
|
||||
"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/10": "d25808fa155f157e",
|
||||
"D/midnight/spread/11": "b984e665960f60b6",
|
||||
"D/midnight/spread/12": "a0985fcab9c614a2",
|
||||
"D/midnight/spread/13": "3f2670ca15c1797c",
|
||||
"D/midnight/spread/14": "b49cc00919f7a621",
|
||||
"D/midnight/spread/15": "67a512730030f450",
|
||||
"D/midnight/spread/16": "90864ccfce090575",
|
||||
"D/midnight/spread/17": "69924ede00c784f9",
|
||||
"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/midnight/spread/4": "f332e826ccb518b8",
|
||||
"D/midnight/spread/5": "545524d900a6d68e",
|
||||
"D/midnight/spread/6": "ea5db0eab1087429",
|
||||
"D/midnight/spread/7": "da860dcfdcffc997",
|
||||
"D/midnight/spread/8": "d4a871126c6b324e",
|
||||
"D/midnight/spread/9": "533ceb3478922532",
|
||||
"D/riso/head": "1c8f99d99acdea28",
|
||||
"D/riso/plan": "f2a8704019582093",
|
||||
"D/riso/spread/0": "7b0d816ffda1dd01",
|
||||
"D/riso/spread/0": "7cded5c557f2e1ee",
|
||||
"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/10": "1d403f4e5dc74e1e",
|
||||
"D/riso/spread/11": "7b563aff540a0a2c",
|
||||
"D/riso/spread/12": "c6cf876fbc756d21",
|
||||
"D/riso/spread/13": "735d4882d5230331",
|
||||
"D/riso/spread/14": "8df18b93ab68235d",
|
||||
"D/riso/spread/15": "4be16c00138d027d",
|
||||
"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/4": "42d13c4cc22154a9",
|
||||
"D/riso/spread/5": "c2dcc1ba31041587",
|
||||
"D/riso/spread/6": "93846db9c48faa0f",
|
||||
"D/riso/spread/7": "42f55e08792400e9",
|
||||
"D/riso/spread/8": "4ff93cb4d24f729c",
|
||||
"D/riso/spread/9": "988a20c009c9d562"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
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 { 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';
|
||||
@@ -101,13 +95,12 @@ function single(photos: number, story: boolean): BookDesignInput {
|
||||
}
|
||||
|
||||
describe('the book on the designed templates', () => {
|
||||
it('AUTODSN-001: is a valid book, as normalised, within the spread and element caps', () => {
|
||||
it('AUTODSN-001: is a valid book, as normalised, within the element cap', () => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ 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';
|
||||
@@ -438,12 +437,11 @@ export function composeTemplatedInner(
|
||||
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 cap = Math.max(0, c.directives.pages?.max ?? Number.POSITIVE_INFINITY);
|
||||
const head = framingPages(c, plan.archetype).slice(0, Math.max(0, cap - 1));
|
||||
const base: PickBase = {
|
||||
archetype: plan.archetype,
|
||||
|
||||
Reference in New Issue
Block a user