mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-27 09:08:30 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 429a25faa9 | |||
| 486a2eeca3 | |||
| 26a6147e8d | |||
| a7b53988ff | |||
| 3f3e2f0bcf | |||
| f956ab07f3 | |||
| 736594696e | |||
| 1be997f7d8 | |||
| 5a8ff16afa | |||
| 4435dc32b8 | |||
| f5a33e0c1e | |||
| a8326b6f5a | |||
| 1e999d968a | |||
| d0f74c9f02 | |||
| f14f1e41b9 | |||
| 9138e722bb | |||
| 9695e269e3 | |||
| 82bdf62c7b | |||
| 8893e3afe0 |
@@ -146,6 +146,13 @@ export const DYNAMIC_ALLOWED = [
|
||||
'id is a sticker id of the library in @trek/shared/stickers; stickerLibrary.test.ts resolves the key of every ' +
|
||||
'sticker the library holds against en',
|
||||
},
|
||||
{
|
||||
file: 'components/Studio/shapes.ts',
|
||||
template: 'journey.studio.shapeName.${shape}',
|
||||
because:
|
||||
'shapeName takes a BookShapeId; shared/src/book/book-shape-catalogue.spec.ts holds an en name for every ' +
|
||||
'shape of BOOK_SHAPE_GROUPS',
|
||||
},
|
||||
];
|
||||
|
||||
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
|
||||
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
|
||||
"src/components/Studio/StudioShell.tsx": 1,
|
||||
"src/components/Studio/TravelElements.tsx": 1,
|
||||
"src/components/Studio/sidebar/StudioEntryCard.tsx": 1,
|
||||
"src/components/Studio/travel/BadgeIcon.tsx": 1,
|
||||
"src/components/Todo/useTodoList.ts": 1,
|
||||
"src/components/shared/ConfirmDialog.tsx": 1,
|
||||
"src/components/shared/CountryFlag.tsx": 1,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1157,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1290,
|
||||
"src/components/Studio/TravelElements.tsx": 1738,
|
||||
"src/components/Studio/TravelElements.tsx": 1559,
|
||||
"src/components/Studio/countryShapes.ts": 2595,
|
||||
"src/index.css": 1636,
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
@@ -26,5 +26,5 @@
|
||||
"src/pages/atlas/useAtlas.ts": 1211,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
|
||||
"src/styles/studio.css": 1936
|
||||
"src/styles/studio.css": 1918
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"components/Journey/moodConfig.ts": 5,
|
||||
"components/Layout/BottomNav.tsx": 3,
|
||||
"components/Layout/DemoBanner.tsx": 25,
|
||||
"components/Layout/InAppNotificationBell.tsx": 8,
|
||||
"components/Layout/InAppNotificationBell.tsx": 3,
|
||||
"components/Layout/MobileTopBar.tsx": 51,
|
||||
"components/Layout/Navbar.tsx": 21,
|
||||
"components/Layout/NewVersionNotice.tsx": 3,
|
||||
@@ -72,7 +72,7 @@
|
||||
"components/Map/poiCategories.ts": 7,
|
||||
"components/Map/ratingBadge.ts": 2,
|
||||
"components/Map/reservationsMapbox.ts": 1,
|
||||
"components/Notifications/InAppNotificationItem.tsx": 8,
|
||||
"components/Notifications/InAppNotificationItem.tsx": 1,
|
||||
"components/PDF/TripPDF.tsx": 53,
|
||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||
@@ -295,7 +295,7 @@
|
||||
"pages/FilesPage.tsx": 11,
|
||||
"pages/ForgotPasswordPage.tsx": 18,
|
||||
"pages/HelpPage.tsx": 21,
|
||||
"pages/InAppNotificationsPage.tsx": 9,
|
||||
"pages/InAppNotificationsPage.tsx": 2,
|
||||
"pages/JoinTripPage.tsx": 4,
|
||||
"pages/JourneyDetailPage.tsx": 53,
|
||||
"pages/JourneyPage.tsx": 64,
|
||||
|
||||
@@ -44,9 +44,10 @@
|
||||
"components/Studio/SpreadView.tsx": 1,
|
||||
"components/Studio/StudioElementsPanel.tsx": 2,
|
||||
"components/Studio/StudioSwatches.tsx": 1,
|
||||
"components/Studio/TravelElements.tsx": 6,
|
||||
"components/Studio/TravelElements.tsx": 3,
|
||||
"components/Studio/peerColour.ts": 1,
|
||||
"components/Studio/printSheets.ts": 4,
|
||||
"components/Studio/travel/badgeLook.ts": 2,
|
||||
"components/Trips/TripMemberAvatar.tsx": 2,
|
||||
"components/Vacay/VacayPersons.tsx": 1,
|
||||
"components/shared/DialogShell.tsx": 2,
|
||||
|
||||
@@ -712,4 +712,33 @@ describe('AddonManager', () => {
|
||||
expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument();
|
||||
expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-045: finds an addon by name or description and shows the ones on or off', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
addonsRoute([
|
||||
buildAddon({ id: 'x-lists', name: 'Todo List', description: 'Track tasks', type: 'trip', enabled: true }),
|
||||
buildAddon({ id: 'x-costs', name: 'Budget', description: 'Split costs', type: 'trip', enabled: false }),
|
||||
buildAddon({ id: 'x-holiday', name: 'Vacay', description: 'Holiday planner', type: 'global', enabled: true }),
|
||||
])
|
||||
);
|
||||
render(<AddonManager />);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'costs');
|
||||
expect(screen.getByText('Budget')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Todo List')).toBeNull();
|
||||
expect(screen.queryByText('Vacay')).toBeNull();
|
||||
|
||||
await user.clear(screen.getByRole('searchbox', { name: 'Search addons' }));
|
||||
await user.click(screen.getByRole('button', { name: 'On' }));
|
||||
expect(screen.getByText('Todo List')).toBeInTheDocument();
|
||||
expect(screen.getByText('Vacay')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Budget')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Off' }));
|
||||
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'nothing like this');
|
||||
expect(screen.getByText('No addon matches the search or the filter.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { useState, type ComponentType } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront, Search } from 'lucide-react'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import AirTrailIcon from '../shared/AirTrailIcon'
|
||||
@@ -76,6 +76,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
} = useAddonManager()
|
||||
const [query, setQuery] = useState('')
|
||||
const [view, setView] = useState<AddonView>('all')
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -194,10 +196,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
)
|
||||
}
|
||||
|
||||
/* One column per type, side by side, instead of three stacked sections. Stacked, each
|
||||
section opened its own set of columns and the tall tiles (Collab, Journey) set a
|
||||
section's height while its neighbours ran out early. Side by side the column's
|
||||
head carries the type, so the tiles need no badge of their own. */
|
||||
/* A card per type, the way every other admin tab sets its cards: Trip in the
|
||||
left column, Global and Integration in the right one, each card with the
|
||||
dialogs' head band carrying the type, its count and what it means. Above
|
||||
them one card says how many are on and finds an addon by name. */
|
||||
const groups = [
|
||||
{ key: 'trip', addons: tripAddons },
|
||||
{ key: 'global', addons: globalAddons },
|
||||
@@ -214,68 +216,145 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
icon={Puzzle}
|
||||
title={t('admin.addons.title')}
|
||||
hint={subtitle}
|
||||
badge={totalCount > 0 ? (
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
>
|
||||
{addons.length === 0 ? (
|
||||
<EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{groups.map(g => {
|
||||
const meta = TYPE_META[g.key]
|
||||
return (
|
||||
<section key={g.key} className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
|
||||
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons} t={t} />
|
||||
<div className="divide-y divide-edge-faint">
|
||||
{/* The AI addons share a connection and a settings dialog, so they head the column as one tile. */}
|
||||
{g.key === 'integration' && aiAddons.length > 0 && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
|
||||
{g.addons.map(tile)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
/** Whether an addon is shown under the search and the filter. */
|
||||
const shown = (addon: Addon) => {
|
||||
if (view === 'on' && !addon.enabled) return false
|
||||
if (view === 'off' && addon.enabled) return false
|
||||
const q = query.trim().toLocaleLowerCase()
|
||||
if (!q) return true
|
||||
const label = getAddonLabel(t, addon)
|
||||
return `${label.name} ${label.description ?? ''}`.toLocaleLowerCase().includes(q)
|
||||
}
|
||||
|
||||
const card = (g: (typeof groups)[number]) => {
|
||||
const meta = TYPE_META[g.key]
|
||||
const all = g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons
|
||||
const visible = g.addons.filter(shown)
|
||||
const ai = g.key === 'integration' && aiAddons.some(shown)
|
||||
if (!visible.length && !ai) return null
|
||||
const on = all.filter(a => a.enabled).length
|
||||
return (
|
||||
<SettingsCard
|
||||
key={g.key}
|
||||
icon={meta.icon}
|
||||
title={t(meta.labelKey)}
|
||||
hint={t(meta.hintKey)}
|
||||
badge={(
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled: on, total: all.length })}>
|
||||
<span className="inline-flex"><StatusPill tone={on > 0 ? 'success' : 'neutral'}>{on}/{all.length}</StatusPill></span>
|
||||
</Tooltip>
|
||||
)}
|
||||
bodyClassName="p-3"
|
||||
>
|
||||
<div className="divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
|
||||
{/* The AI addons share a connection and a settings dialog, so they head the card as one tile. */}
|
||||
{ai && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
|
||||
{visible.map(tile)}
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
const [first, ...rest] = groups
|
||||
const cards = { start: first ? card(first) : null, end: rest.map(card).filter(Boolean) }
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsCard
|
||||
icon={Puzzle}
|
||||
title={t('admin.addons.title')}
|
||||
hint={subtitle}
|
||||
badge={totalCount > 0 ? (
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
>
|
||||
{addons.length === 0
|
||||
? <EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
|
||||
: <AddonToolbar groups={groups.map(g => ({ key: g.key, addons: g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons }))} query={query} onQuery={setQuery} view={view} onView={setView} t={t} />}
|
||||
</SettingsCard>
|
||||
|
||||
{addons.length > 0 && (cards.start || cards.end.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-2 xl:items-start">
|
||||
<div className="min-w-0">{cards.start}</div>
|
||||
<div className="min-w-0">{cards.end}</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="m-0 rounded-2xl border border-dashed border-edge-faint px-4 py-8 text-center text-content-muted" style={fs(13, 'body')}>
|
||||
{t('admin.addons.noMatch')}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** The head band of one type's column: icon tile, the type, its count and what it means. */
|
||||
function GroupHead({ icon: Icon, label, hint, addons, t }: {
|
||||
icon: ComponentType<{ size?: number; className?: string; strokeWidth?: number }>
|
||||
label: string
|
||||
hint: string
|
||||
addons: Addon[]
|
||||
type AddonView = 'all' | 'on' | 'off'
|
||||
|
||||
/**
|
||||
* The head card's body: a tile per type with how many of it are on, then a
|
||||
* search over the addons' names and descriptions and a switch between all,
|
||||
* the ones on and the ones off.
|
||||
*/
|
||||
function AddonToolbar({ groups, query, onQuery, view, onView, t }: {
|
||||
groups: { key: string; addons: Addon[] }[]
|
||||
query: string
|
||||
onQuery: (q: string) => void
|
||||
view: AddonView
|
||||
onView: (v: AddonView) => void
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
}) {
|
||||
const enabled = addons.filter(a => a.enabled).length
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 border-b border-edge-faint bg-surface-tertiary px-3.5 py-2.5">
|
||||
<span className="mt-px grid h-7 w-7 flex-none place-items-center rounded-[9px] bg-surface-card text-content-secondary shadow-sm">
|
||||
<Icon size={13} strokeWidth={2} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="m-0 min-w-0 flex-1 truncate font-bold text-content" style={fs(13, 'body')}>{label}</h3>
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled, total: addons.length })}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill>{enabled}/{addons.length}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* Says what the type means: the only place that still explains it. */}
|
||||
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11)}>{hint}</p>
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{groups.map(g => {
|
||||
const meta = TYPE_META[g.key]
|
||||
const Icon = meta.icon
|
||||
const on = g.addons.filter(a => a.enabled).length
|
||||
return (
|
||||
<div key={g.key} className="flex items-center gap-3 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5">
|
||||
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-secondary text-content-secondary">
|
||||
<Icon size={15} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-content" style={fs(12.5, 'body')}>{t(meta.labelKey)}</span>
|
||||
<span className="block text-content-faint" style={fs(11)}>{t('admin.addons.group.count', { enabled: on, total: g.addons.length })}</span>
|
||||
</span>
|
||||
<span className={`font-geist font-bold tabular-nums ${on > 0 ? 'text-content' : 'text-content-faint'}`} style={fs(18, 'body')} aria-hidden>{on}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<label className="relative flex min-w-0 flex-1 items-center">
|
||||
<Search size={14} className="pointer-events-none absolute start-3 text-content-faint" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={e => onQuery(e.target.value)}
|
||||
placeholder={t('admin.addons.search')}
|
||||
aria-label={t('admin.addons.search')}
|
||||
className="h-9 w-full rounded-[10px] border border-edge-faint bg-surface-card ps-8 pe-3 text-content outline-none transition-colors placeholder:text-content-faint focus:border-accent"
|
||||
style={fs(13, 'body')}
|
||||
/>
|
||||
</label>
|
||||
<div className="inline-flex flex-none gap-1 rounded-[10px] bg-surface-tertiary p-1" role="group" aria-label={t('admin.addons.filter')}>
|
||||
{(['all', 'on', 'off'] as const).map(v => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => onView(v)}
|
||||
aria-pressed={view === v}
|
||||
className={`h-7 rounded-[8px] px-3 font-medium transition-colors ${view === v ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t(`admin.addons.filter.${v}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,12 +68,12 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
{/* Click-away catcher only — the bell button itself closes the panel again from the keyboard. */}
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setOpen(false)} />
|
||||
<div
|
||||
className="rounded-xl shadow-xl border overflow-hidden bg-surface-card border-edge"
|
||||
className="rounded-2xl shadow-xl border overflow-hidden bg-surface-card border-edge"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 'var(--nav-h)',
|
||||
insetInlineEnd: 8,
|
||||
width: 360,
|
||||
width: 380,
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
|
||||
zIndex: 9999,
|
||||
@@ -81,14 +81,15 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 flex-shrink-0 border-b border-edge-secondary"
|
||||
>
|
||||
<span className="text-sm font-semibold text-content">
|
||||
{/* Header: the dialogs' head, a tile, the name and how many are unread, the tools at the end. */}
|
||||
<div className="flex items-center gap-3 px-4 pt-4 pb-3 flex-shrink-0">
|
||||
<span className="grid place-items-center w-9 h-9 rounded-xl bg-accent-subtle text-accent flex-shrink-0">
|
||||
<Bell className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 flex items-center gap-2 text-subtitle font-semibold text-content">
|
||||
{t('notifications.title')}
|
||||
{unreadCount > 0 && (
|
||||
<span className="ms-2 px-1.5 py-0.5 rounded-full text-xs font-medium bg-content text-surface">
|
||||
<span className="px-2 h-5 inline-flex items-center rounded-full text-caption font-semibold tabular-nums bg-accent text-accent-text">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -98,34 +99,38 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
<button type="button"
|
||||
onClick={markAllRead}
|
||||
title={t('notifications.markAllRead')}
|
||||
className="p-1.5 rounded-lg transition-colors text-content-muted hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
aria-label={t('notifications.markAllRead')}
|
||||
className="grid place-items-center w-8 h-8 rounded-lg transition-colors text-content-muted bg-surface-secondary hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover"
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
<CheckCheck className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<button type="button"
|
||||
onClick={() => setConfirmingDeleteAll(true)}
|
||||
title={t('notifications.deleteAll')}
|
||||
className="p-1.5 rounded-lg transition-colors text-content-muted hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
aria-label={t('notifications.deleteAll')}
|
||||
className="grid place-items-center w-8 h-8 rounded-lg transition-colors text-content-muted bg-surface-secondary hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification list */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
<div className="overflow-y-auto flex-1 py-1 border-t border-edge-secondary">
|
||||
{isLoading && notifications.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div className="w-5 h-5 border-2 rounded-full animate-spin border-edge border-t-content" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 px-4 text-center gap-2">
|
||||
<Bell className="w-8 h-8 text-content-faint" />
|
||||
<p className="text-sm font-medium text-content-muted">{t('notifications.empty')}</p>
|
||||
<p className="text-xs text-content-faint">{t('notifications.emptyDescription')}</p>
|
||||
<div className="flex flex-col items-center justify-center py-10 px-6 text-center gap-2">
|
||||
<span className="grid place-items-center w-12 h-12 rounded-2xl bg-surface-secondary text-content-faint">
|
||||
<Bell className="w-5 h-5" />
|
||||
</span>
|
||||
<p className="text-body font-semibold text-content">{t('notifications.empty')}</p>
|
||||
<p className="text-caption text-content-muted">{t('notifications.emptyDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.slice(0, 10).map(n => (
|
||||
@@ -135,12 +140,14 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<button type="button"
|
||||
onClick={handleShowAll}
|
||||
className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0 border-t border-edge-secondary text-content hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
>
|
||||
{t('notifications.showAll')}
|
||||
</button>
|
||||
<div className="p-2 flex-shrink-0 border-t border-edge-secondary">
|
||||
<button type="button"
|
||||
onClick={handleShowAll}
|
||||
className="w-full h-9 rounded-xl text-body font-medium transition-colors bg-surface-secondary text-content hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
>
|
||||
{t('notifications.showAll')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
|
||||
@@ -13,6 +13,18 @@ interface NotificationItemProps {
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/** The round tool beside a notification, quiet until it is pointed at. */
|
||||
const TOOL = 'grid place-items-center w-7 h-7 rounded-lg transition-colors text-content-faint hover:text-content hover:bg-surface-card focus-visible:bg-surface-card focus-visible:text-content'
|
||||
|
||||
/** An answer or a way on, as a pill under the words. */
|
||||
const PILL = 'inline-flex items-center gap-1.5 h-7 px-3 rounded-full text-caption font-medium transition-colors'
|
||||
|
||||
/**
|
||||
* One notification, as a card in the list: who sent it, what it says, and what
|
||||
* can be done with it. Unread ones carry a dot in the accent and a faint
|
||||
* ground; the tools to mark and delete come forward when the card is pointed
|
||||
* at or focused, so a list reads as messages rather than as rows of buttons.
|
||||
*/
|
||||
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const { responding, handleRespond, handleNavigate, markRead, markUnread, deleteNotification } =
|
||||
@@ -22,29 +34,25 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
const bodyText = t(notification.text_key, notification.text_params)
|
||||
const hasUnknownTitle = titleText === notification.title_key
|
||||
const hasUnknownBody = bodyText === notification.text_key
|
||||
const unread = !notification.is_read
|
||||
const answered = notification.response !== null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative px-4 py-3 transition-colors border-b border-edge-secondary ${notification.is_read ? '' : 'bg-accent-subtle'}`}
|
||||
className={`group relative mx-2 my-1 rounded-xl px-3 py-3 transition-colors hover:bg-surface-hover focus-within:bg-surface-hover ${unread ? 'bg-surface-secondary' : ''}`}
|
||||
>
|
||||
{unread && <span aria-hidden className="absolute start-1 top-5 w-1.5 h-1.5 rounded-full bg-accent" />}
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
{/* Sender avatar */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<div className="flex-shrink-0">
|
||||
{notification.sender_avatar ? (
|
||||
<img
|
||||
src={notification.sender_avatar}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
<img src={notification.sender_avatar} alt="" className="w-9 h-9 rounded-xl object-cover" />
|
||||
) : (
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-content-muted bg-surface-tertiary"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-xl flex items-center justify-center text-body font-semibold text-accent bg-accent-subtle">
|
||||
{notification.sender_username
|
||||
? notification.sender_username.charAt(0).toUpperCase()
|
||||
: <User className="w-4 h-4" />
|
||||
}
|
||||
: <User className="w-4 h-4" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -52,72 +60,67 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium leading-snug text-content">
|
||||
<p className={`text-body leading-snug text-content ${unread ? 'font-semibold' : 'font-medium'}`}>
|
||||
{hasUnknownTitle ? notification.title_key : titleText}
|
||||
</p>
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<span className="text-xs me-1 text-content-faint">
|
||||
<div className="relative flex-shrink-0 h-7 flex items-center">
|
||||
<span className="text-caption tabular-nums text-content-faint transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
|
||||
{relativeTime(notification.created_at, locale)}
|
||||
</span>
|
||||
{notification.is_read ? (
|
||||
<button type="button"
|
||||
onClick={() => markUnread(notification.id)}
|
||||
title={t('notifications.markUnread')}
|
||||
aria-label={t('notifications.markUnread')}
|
||||
className="p-1 rounded transition-colors text-content-faint hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover focus-visible:text-content"
|
||||
<div className="absolute end-0 top-0 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
{notification.is_read ? (
|
||||
<button type="button" onClick={() => markUnread(notification.id)} title={t('notifications.markUnread')} aria-label={t('notifications.markUnread')} className={TOOL}>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => markRead(notification.id)} title={t('notifications.markRead')} aria-label={t('notifications.markRead')} className={TOOL}>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteNotification(notification.id)}
|
||||
title={t('notifications.delete')}
|
||||
aria-label={t('notifications.delete')}
|
||||
className={`${TOOL} hover:text-danger focus-visible:text-danger`}
|
||||
>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button"
|
||||
onClick={() => markRead(notification.id)}
|
||||
title={t('notifications.markRead')}
|
||||
aria-label={t('notifications.markRead')}
|
||||
className="p-1 rounded transition-colors text-content-faint hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover focus-visible:text-content"
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button type="button"
|
||||
onClick={() => deleteNotification(notification.id)}
|
||||
title={t('notifications.delete')}
|
||||
aria-label={t('notifications.delete')}
|
||||
className="p-1 rounded transition-colors text-content-faint hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft focus-visible:text-danger"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs mt-0.5 leading-relaxed text-content-muted">
|
||||
<p className="text-caption mt-0.5 leading-relaxed text-content-muted">
|
||||
{hasUnknownBody ? notification.text_key : bodyText}
|
||||
</p>
|
||||
|
||||
{/* Boolean actions */}
|
||||
{notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button type="button"
|
||||
<div className="flex gap-2 mt-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRespond('positive')}
|
||||
disabled={responding || notification.response !== null}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
|
||||
disabled={responding || answered}
|
||||
className={`${PILL} ${
|
||||
notification.response === 'positive'
|
||||
? 'bg-content text-surface'
|
||||
: `bg-surface-tertiary ${notification.response === 'negative' ? 'text-content-faint opacity-50' : 'text-content-secondary'}`
|
||||
} ${notification.response !== null || responding ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
? 'bg-accent text-accent-text'
|
||||
: `bg-surface-card shadow-sm ${notification.response === 'negative' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
|
||||
} ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{t(notification.positive_text_key)}
|
||||
</button>
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRespond('negative')}
|
||||
disabled={responding || notification.response !== null}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
|
||||
disabled={responding || answered}
|
||||
className={`${PILL} ${
|
||||
notification.response === 'negative'
|
||||
? 'bg-danger text-white'
|
||||
: `bg-surface-tertiary ${notification.response === 'positive' ? 'text-content-faint opacity-50' : 'text-content-secondary'}`
|
||||
} ${notification.response !== null || responding ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
: `bg-surface-card shadow-sm ${notification.response === 'positive' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
|
||||
} ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
<X className="w-3.5 h-3.5" />
|
||||
{t(notification.negative_text_key)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -125,12 +128,13 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
|
||||
{/* Navigate action */}
|
||||
{notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && (
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNavigate}
|
||||
className="flex items-center gap-1 mt-2 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors bg-surface-tertiary text-content-secondary hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
className={`${PILL} mt-2.5 bg-surface-card shadow-sm text-content hover:bg-accent hover:text-accent-text focus-visible:bg-accent focus-visible:text-accent-text`}
|
||||
>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
{t(notification.navigate_text_key)}
|
||||
<ArrowRight className="w-3.5 h-3.5 rtl:-scale-x-100" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,15 +15,13 @@ import type { SaveState } from './useBookStore'
|
||||
* which is exactly the wrong habit for the one time it says something else.
|
||||
*/
|
||||
export function SaveIndicator({
|
||||
state, t, onAcceptTheirs, onKeepMine, onRetry, onReload = reloadStudio,
|
||||
state, t, onAcceptTheirs, onKeepMine, onRetry,
|
||||
}: {
|
||||
state: SaveState
|
||||
t: (k: string) => string
|
||||
onAcceptTheirs: (current: BookRecord) => void
|
||||
onKeepMine: (current: BookRecord) => void
|
||||
onRetry: () => void
|
||||
/** Load this Studio again, for a book a newer one saved. A full reload by default. */
|
||||
onReload?: () => void
|
||||
}) {
|
||||
if (state.status === 'conflict') {
|
||||
return (
|
||||
@@ -45,23 +43,8 @@ export function SaveIndicator({
|
||||
)
|
||||
}
|
||||
|
||||
if (state.status === 'newer') {
|
||||
/*
|
||||
* The one state with a way out the user has to take themselves: a newer
|
||||
* Studio saved this book, this tab cannot write it without losing what
|
||||
* that one added, and only a reload brings the newer Studio here. Loud
|
||||
* like a conflict, because until then nothing done here is kept.
|
||||
*/
|
||||
return (
|
||||
<div className="st-save is-conflict" role="alert">
|
||||
<AlertTriangle size={13} />
|
||||
<span>{t('journey.studio.saveNewer')}</span>
|
||||
<button type="button" onClick={onReload}>
|
||||
{t('journey.studio.saveReload')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// A book a newer Studio saved is the shell's to say, over everything (NewerBookOverlay).
|
||||
if (state.status === 'newer') return null
|
||||
|
||||
if (state.status === 'readonly') {
|
||||
/*
|
||||
@@ -107,6 +90,32 @@ export function SaveIndicator({
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* A book a newer Studio saved, said over the whole Studio rather than in the
|
||||
* bar: this tab cannot write the book without losing what the newer one
|
||||
* added, and only a reload brings the newer Studio here. Until then nothing
|
||||
* done here would be kept, so nothing can be done: the overlay takes every
|
||||
* click and offers the one way out there is.
|
||||
*/
|
||||
export function NewerBookOverlay({ t, onReload = reloadStudio }: {
|
||||
t: (k: string) => string
|
||||
onReload?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="st-newer" role="alertdialog" aria-modal="true" aria-labelledby="st-newer-text">
|
||||
<div className="st-newer-card">
|
||||
<span className="st-newer-icon"><RefreshCw size={22} strokeWidth={2.2} /></span>
|
||||
<p id="st-newer-text" className="st-newer-text">{t('journey.studio.saveNewer')}</p>
|
||||
{/* Focused on arrival, so Enter reloads and nothing behind the overlay takes the keyboard. */}
|
||||
<button type="button" className="st-newer-reload" onClick={onReload} autoFocus>
|
||||
<RefreshCw size={15} />
|
||||
{t('journey.studio.saveReload')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The default way back to a current Studio: the page again, which loads the newest build. */
|
||||
function reloadStudio() {
|
||||
window.location.reload()
|
||||
|
||||
@@ -18,6 +18,12 @@ interface SpreadThumbProps {
|
||||
className?: string;
|
||||
/** Pixels per millimetre the caller draws at; CSS's own unless the caller zooms. */
|
||||
pxPerMm?: number;
|
||||
/**
|
||||
* Draw the first and the last page beside the blank inside of their cover,
|
||||
* as the bound book opens on them, so the rail shows why they stand alone.
|
||||
* The words are the half's name, read out and shown on hover.
|
||||
*/
|
||||
coverInside?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,16 +43,31 @@ export function SpreadThumb({
|
||||
fold = false,
|
||||
className,
|
||||
pxPerMm = PX_PER_MM,
|
||||
coverInside,
|
||||
}: SpreadThumbProps) {
|
||||
const single = spread.role !== 'inner';
|
||||
const wMm = single ? page.pageWidth : page.pageWidth * 2;
|
||||
// The first page opens on the right of the cover's inside, the last on the left of the back's.
|
||||
const opens = coverInside && (spread.role === 'first' || spread.role === 'last') ? spread.role : null;
|
||||
const wMm = single && !opens ? page.pageWidth : page.pageWidth * 2;
|
||||
const scale = width / (wMm * pxPerMm);
|
||||
const pageLeft = opens === 'first' ? page.pageWidth * pxPerMm * scale : 0;
|
||||
return (
|
||||
<div dir="ltr" className={className} style={{ width, height: page.pageHeight * pxPerMm * scale }}>
|
||||
{opens && (
|
||||
<span
|
||||
className="st-thumb-inside"
|
||||
title={coverInside}
|
||||
aria-label={coverInside}
|
||||
style={{
|
||||
insetInlineStart: opens === 'first' ? 0 : page.pageWidth * pxPerMm * scale,
|
||||
width: page.pageWidth * pxPerMm * scale,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: 0,
|
||||
insetInlineStart: pageLeft,
|
||||
top: 0,
|
||||
width: `${wMm}mm`,
|
||||
height: `${page.pageHeight}mm`,
|
||||
@@ -56,7 +77,7 @@ export function SpreadThumb({
|
||||
>
|
||||
<SpreadView spread={spread} page={page} folios={folios} />
|
||||
</div>
|
||||
{fold && !single && <SpreadFold page={page} scaled={pxPerMm * scale} />}
|
||||
{fold && (!single || opens) && <SpreadFold page={page} scaled={pxPerMm * scale} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
Ungroup,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import '../../styles/studio-panels.css';
|
||||
@@ -118,7 +117,8 @@ export function StudioArrangeSection({
|
||||
const copyStyle = useStudioStore((s) => s.copyStyle);
|
||||
const pasteStyle = useStudioStore((s) => s.pasteStyle);
|
||||
const keys = useStudioShortcut();
|
||||
const [chosenTo, setChosenTo] = useState<BookAlignTo | null>(null);
|
||||
const chosenTo = useStudioStore((s) => s.alignTo);
|
||||
const setChosenTo = useStudioStore((s) => s.setAlignTo);
|
||||
|
||||
if (!spread || !selection.length) return null;
|
||||
const sel = spread.elements.filter((e) => selection.includes(e.id));
|
||||
|
||||
@@ -215,13 +215,13 @@ export function StudioAssistantToggle({ assistant }: { assistant: StudioAssistan
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`st-tool st-assistant-toggle ${assistant.open ? 'is-on' : ''}`}
|
||||
className={`st-tool is-icon st-head-solo st-assistant-toggle ${assistant.open ? 'is-on' : ''}`}
|
||||
onClick={assistant.toggle}
|
||||
aria-pressed={assistant.open}
|
||||
aria-label={t('journey.studio.assistant.toggle')}
|
||||
title={t('journey.studio.assistant.toggleHint')}
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
<span className="st-tool-label">{t('journey.studio.assistant.toggle')}</span>
|
||||
<MessageSquare size={15} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useId, useState } from 'react'
|
||||
import { Frame, Minus, Quote, Shapes, Spline, Sparkles, Square, Sticker, Type, type LucideIcon } from 'lucide-react'
|
||||
import type { BookElement, BookPageSetup, BookShapeId } from '@trek/shared'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS } from './shapes'
|
||||
import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS, shapeName } from './shapes'
|
||||
import {
|
||||
BOOK_GRIDS as GRIDS, MAX_SPREAD_ELEMENTS, bookElementId, bookShapeSize, bookGridElements as gridElements,
|
||||
defaultBookGridBox as defaultGridBox,
|
||||
@@ -280,7 +280,8 @@ function ShapesTab({ addShape, t }: { addShape: Adders['addShape']; t: (k: strin
|
||||
key={shape}
|
||||
className="st-shape-btn is-glyph"
|
||||
onClick={() => addShape(shape)}
|
||||
aria-label={shape}
|
||||
title={shapeName(shape, t)}
|
||||
aria-label={shapeName(shape, t)}
|
||||
>
|
||||
<ShapeGlyph shape={shape} />
|
||||
</button>
|
||||
@@ -319,7 +320,8 @@ function FramesTab({ adders, t }: { adders: Adders; t: (k: string) => string })
|
||||
key={shape}
|
||||
className="st-shape-btn is-glyph"
|
||||
onClick={() => adders.addFrame(shape === 'rect' ? null : shape)}
|
||||
aria-label={shape}
|
||||
title={shapeName(shape, t)}
|
||||
aria-label={shapeName(shape, t)}
|
||||
>
|
||||
<ShapeGlyph shape={shape} photo />
|
||||
</button>
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('StudioHeader', () => {
|
||||
it("FE-STUDIO-HEAD-001: names the journey under the Studio's mark, with its cover as the tile", () => {
|
||||
head();
|
||||
expect(screen.getByRole('heading', { name: 'Japan 2026' })).toBeInTheDocument();
|
||||
expect(screen.getByText('journey.studio.beta')).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: 'TREK Studio' })).toBeInTheDocument();
|
||||
expect(document.querySelector('.st-head-tile img')?.getAttribute('src')).toBe('/uploads/cover.jpg');
|
||||
});
|
||||
|
||||
|
||||
@@ -15,14 +15,14 @@ import type { StudioAssistant } from './useStudioAssistant';
|
||||
|
||||
/**
|
||||
* The Studio's head band, in the shape every TREK dialog has: a raised tile on
|
||||
* the left, a small line above the title, the title, a line under it, and the
|
||||
* round close button at the far end.
|
||||
* the left, the Studio's mark above the title, the title, a line under it, and
|
||||
* the close button at the far end.
|
||||
*
|
||||
* The tile is the journey's cover and the title its name, so it is always clear
|
||||
* whose book this is. What the book is printed as and whether it is saved sit
|
||||
* under the title, because they describe it. The tools on the right are grouped
|
||||
* by what they do: who else is here, editing (undo, redo, paste), making
|
||||
* (auto layout, the assistant), and getting the book out (help, export). How
|
||||
* whose book this is. The tools on the right are grouped by what they do:
|
||||
* who else is here and whether the book is saved, what the book is printed as, editing (undo, redo, paste), auto
|
||||
* layout, the assistant, help, and getting the book out. Every piece of
|
||||
* the row is one raised tile of one height, export alone in the accent. How
|
||||
* the page is looked at, the book view, lives with the zoom under the page.
|
||||
*/
|
||||
export function StudioHeader({
|
||||
@@ -41,25 +41,26 @@ export function StudioHeader({
|
||||
<div className="st-head-text">
|
||||
<div className="st-head-eyebrow">
|
||||
<StudioWordmark style={{ height: 17 }} />
|
||||
<span className="st-beta">{s.t('journey.studio.beta')}</span>
|
||||
</div>
|
||||
<h1 className="st-head-title">{s.journey?.title || s.t('journey.title')}</h1>
|
||||
<div className="st-head-sub">
|
||||
<FormatPicker s={s} />
|
||||
<SaveIndicator
|
||||
state={s.saveState}
|
||||
t={s.t}
|
||||
onAcceptTheirs={(current) => s.loadDoc(s.acceptTheirs(current))}
|
||||
onKeepMine={s.keepMine}
|
||||
onRetry={() => {
|
||||
void s.saveNow();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="st-head-tools">
|
||||
<PeerBadges peers={s.peers} t={s.t} />
|
||||
{/* Whether the book is safe, as a tile beside who else is here: both say how the book stands. */}
|
||||
<SaveIndicator
|
||||
state={s.saveState}
|
||||
t={s.t}
|
||||
onAcceptTheirs={(current) => s.loadDoc(s.acceptTheirs(current))}
|
||||
onKeepMine={s.keepMine}
|
||||
onRetry={() => {
|
||||
void s.saveNow();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="st-head-group">
|
||||
<FormatPicker s={s} />
|
||||
</div>
|
||||
|
||||
<div className="st-head-group">
|
||||
<IconTool label={s.t('journey.studio.undo')} disabled={!s.canUndo} onClick={s.undo}>
|
||||
@@ -73,15 +74,15 @@ export function StudioHeader({
|
||||
|
||||
<div className="st-head-group">
|
||||
<AutoLayoutButton s={s} />
|
||||
{/* The book's assistant; only where it can run for this user. */}
|
||||
<StudioAssistantToggle assistant={assistant} />
|
||||
</div>
|
||||
|
||||
{/* The book's assistant, a tile of its own like help; only where it can run for this user. */}
|
||||
<StudioAssistantToggle assistant={assistant} />
|
||||
{/* Studio has no navbar, so the help center's button rides on its own bar. */}
|
||||
<HelpButton className="st-tool is-icon" />
|
||||
<HelpButton className="st-tool is-icon st-head-solo" />
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool is-primary"
|
||||
className="st-tool is-primary st-head-solo"
|
||||
onClick={onExport}
|
||||
disabled={!s.doc}
|
||||
title={s.t('journey.studio.export')}
|
||||
@@ -242,8 +243,8 @@ function AutoLayoutButton({ s }: { s: Studio }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The format, as a chip under the book's name: it says what the book is
|
||||
* printed as, and opening it changes that.
|
||||
* The format, as a tool of the head row: it says what the book is printed as,
|
||||
* and opening it changes that.
|
||||
*
|
||||
* A native <select> was the first draft and it was wrong: the browser draws the
|
||||
* list in its own chrome, so it ignores the theme entirely and drops a list that
|
||||
@@ -264,7 +265,7 @@ function FormatPicker({ s }: { s: Studio }) {
|
||||
<div className="st-picker" ref={box}>
|
||||
<button
|
||||
type="button"
|
||||
className="st-head-chip"
|
||||
className="st-tool"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
@@ -278,16 +279,16 @@ function FormatPicker({ s }: { s: Studio }) {
|
||||
: { height: 12, width: Math.max(6, Math.round((12 * width) / height)) }
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<span className="st-tool-label">
|
||||
{s.preset === 'custom'
|
||||
? `${Math.round(s.page.pageWidth)} × ${Math.round(s.page.pageHeight)} mm`
|
||||
: s.t(active.labelKey)}
|
||||
</span>
|
||||
<ChevronDown size={12} style={{ opacity: 0.55 }} />
|
||||
<ChevronDown size={13} style={{ opacity: 0.5 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu st-head-menu" role="listbox">
|
||||
<div className="st-menu" role="listbox">
|
||||
{PAGE_PRESET_ORDER.map((id) => {
|
||||
const p = PAGE_PRESETS[id];
|
||||
const on = id === s.preset;
|
||||
|
||||
@@ -64,12 +64,19 @@ export function StudioPaintPicker({
|
||||
channel,
|
||||
t,
|
||||
noneLabel,
|
||||
kinds = KINDS,
|
||||
}: {
|
||||
value: BookPaint | null;
|
||||
channel: PaintChannel;
|
||||
t: StudioT;
|
||||
/** What "no paint" is called here: no fill on a shape, the paper on a page. */
|
||||
noneLabel: string;
|
||||
/**
|
||||
* The kinds offered as tabs; all four by default. With one, the row of tabs
|
||||
* is left out: the letters of a text pick gradient or pattern beside their
|
||||
* other fills and only need that kind's controls here.
|
||||
*/
|
||||
kinds?: readonly PaintKind[];
|
||||
}) {
|
||||
const kind = kindOf(value);
|
||||
// The last paint of each kind shown here, so a kind visited again comes back as it was left.
|
||||
@@ -96,28 +103,30 @@ export function StudioPaintPicker({
|
||||
|
||||
return (
|
||||
<div className="st-paint">
|
||||
<div className="st-paint-tabs" role="tablist" aria-label={t('journey.studio.paint.kind')}>
|
||||
{KINDS.map((k) => (
|
||||
<button
|
||||
type="button"
|
||||
key={k}
|
||||
role="tab"
|
||||
aria-selected={k === kind}
|
||||
className={`st-paint-tab ${k === kind ? 'is-on' : ''}`}
|
||||
onClick={() => choose(k)}
|
||||
>
|
||||
<span
|
||||
className={`st-paint-tab-chip ${k === 'none' ? 'is-none' : ''} ${k === 'pattern' ? 'is-pattern' : ''}`}
|
||||
// A pattern's chip keeps its strokes (studio-paint.css) over the ground colour.
|
||||
style={k === 'pattern' ? { backgroundColor: tabPreview(k) } : { background: tabPreview(k) }}
|
||||
aria-hidden
|
||||
{kinds.length > 1 && (
|
||||
<div className="st-paint-tabs" role="tablist" aria-label={t('journey.studio.paint.kind')}>
|
||||
{kinds.map((k) => (
|
||||
<button
|
||||
type="button"
|
||||
key={k}
|
||||
role="tab"
|
||||
aria-selected={k === kind}
|
||||
className={`st-paint-tab ${k === kind ? 'is-on' : ''}`}
|
||||
onClick={() => choose(k)}
|
||||
>
|
||||
{k === 'none' && <Ban size={11} strokeWidth={2.2} />}
|
||||
</span>
|
||||
<span className="st-paint-tab-label">{k === 'none' ? noneLabel : kindLabel(k, t)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span
|
||||
className={`st-paint-tab-chip ${k === 'none' ? 'is-none' : ''} ${k === 'pattern' ? 'is-pattern' : ''}`}
|
||||
// A pattern's chip keeps its strokes (studio-paint.css) over the ground colour.
|
||||
style={k === 'pattern' ? { backgroundColor: tabPreview(k) } : { background: tabPreview(k) }}
|
||||
aria-hidden
|
||||
>
|
||||
{k === 'none' && <Ban size={11} strokeWidth={2.2} />}
|
||||
</span>
|
||||
<span className="st-paint-tab-label">{k === 'none' ? noneLabel : kindLabel(k, t)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{value?.type === 'solid' && (
|
||||
<Swatches
|
||||
|
||||
@@ -35,10 +35,10 @@ export function StudioSavedLayouts({
|
||||
const scale = width / (wMm * pxPerMm)
|
||||
|
||||
return (
|
||||
<div className="st-saved-layouts">
|
||||
<div className="st-section st-saved-layouts">
|
||||
<div className="st-section-label">{t('journey.studio.myLayouts')}</div>
|
||||
{fitting.length === 0 && <p className="st-hint">{t('journey.studio.myLayoutsEmpty')}</p>}
|
||||
<div className="st-thumbs">
|
||||
<div className={`st-thumbs st-tpl-grid ${single ? 'is-pages' : 'is-spreads'}`}>
|
||||
{fitting.map(layout => (
|
||||
<div className="st-thumb-row" key={layout.id}>
|
||||
<button type="button" className="st-thumb" onClick={() => applyLayout(active, layout.id)}>
|
||||
@@ -79,7 +79,6 @@ export function StudioSavedLayouts({
|
||||
{t('journey.studio.saveLayout')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className="st-section-label st-builtin-label">{t('journey.studio.builtInLayouts')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { StudioExport } from './StudioExport'
|
||||
import { StudioAssistantColumn, StudioPageStage } from './StudioAssistantPanel'
|
||||
import { useStudioAssistant } from './useStudioAssistant'
|
||||
import { StudioHeader } from './StudioHeader'
|
||||
import { NewerBookOverlay } from './SaveIndicator'
|
||||
import { StudioNotices } from './StudioNotices'
|
||||
import '../../styles/dashboard.css'
|
||||
import '../../styles/studio.css'
|
||||
@@ -87,6 +88,8 @@ export default function StudioShell() {
|
||||
>
|
||||
<StudioHeader s={s} onExport={() => setExporting(true)} assistant={assistant} />
|
||||
<StudioNotices />
|
||||
{/* A newer Studio saved this book: nothing done here would be kept, so the whole Studio waits for a reload. */}
|
||||
{s.saveState.status === 'newer' && <NewerBookOverlay t={s.t} />}
|
||||
{/* The journey gallery's picker, inside Studio's own layer (#2271). */}
|
||||
{s.providerPick && s.journey && (
|
||||
<ProviderPicker
|
||||
|
||||
@@ -6,7 +6,7 @@ import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { StudioTabPanel, StudioTabs } from './StudioTabs';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-TABS-001 to FE-STUDIO-TABS-004: the tab rows inside Studio's
|
||||
* FE-STUDIO-TABS-001 to FE-STUDIO-TABS-005: the tab rows inside Studio's
|
||||
* panels, as a screen reader and a keyboard meet them, and as a long word
|
||||
* meets them.
|
||||
*/
|
||||
@@ -69,6 +69,20 @@ describe('StudioTabs', () => {
|
||||
expect(three?.tabIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-TABS-005: in a grid of two lines, up and down move to the tab above or below', () => {
|
||||
render(<Row />);
|
||||
const [one, two, three] = screen.getAllByRole('tab');
|
||||
one?.focus();
|
||||
fireEvent.keyDown(one as HTMLElement, { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(three);
|
||||
fireEvent.keyDown(three as HTMLElement, { key: 'ArrowUp' });
|
||||
expect(document.activeElement).toBe(one);
|
||||
two?.focus();
|
||||
// Nothing below the second tab: the focus stays.
|
||||
fireEvent.keyDown(two as HTMLElement, { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(two);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-TABS-003: keeps a long word in a cell that breaks it, and the count as plain figures', () => {
|
||||
const { container } = render(<Row />);
|
||||
const label = screen.getByText('Klistermärken');
|
||||
|
||||
@@ -15,7 +15,8 @@ import { rovingTarget, runsRtl } from './studioRoving';
|
||||
* label's own colour, quieter than the word, never a pill inside the tab.
|
||||
*
|
||||
* The keyboard is the rail's (studioRoving.ts): the arrows move along the row,
|
||||
* Enter or Space opens the tab.
|
||||
* Enter or Space opens the tab. In a grid of more than one line, up and down
|
||||
* move to the tab above or below.
|
||||
*/
|
||||
|
||||
interface StudioTab<T extends string> {
|
||||
@@ -45,8 +46,15 @@ export function StudioTabs<T extends string>({
|
||||
columns?: number;
|
||||
}) {
|
||||
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const across = Math.max(1, Math.min(columns, tabs.length));
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
const next = rovingTarget(e.key, index, tabs.length, { vertical: false, rtl: runsRtl(e.currentTarget) });
|
||||
const below = e.key === 'ArrowDown' ? index + across : e.key === 'ArrowUp' ? index - across : null;
|
||||
const next =
|
||||
below !== null && across < tabs.length
|
||||
? below >= 0 && below < tabs.length
|
||||
? below
|
||||
: null
|
||||
: rovingTarget(e.key, index, tabs.length, { vertical: false, rtl: runsRtl(e.currentTarget) });
|
||||
if (next === null) return;
|
||||
e.preventDefault();
|
||||
buttons.current[next]?.focus();
|
||||
@@ -56,7 +64,7 @@ export function StudioTabs<T extends string>({
|
||||
className={`st-subtabs ${tabs.some((tab) => tab.icon) ? 'has-icons' : ''}`}
|
||||
role="tablist"
|
||||
aria-label={label}
|
||||
style={{ gridTemplateColumns: `repeat(${Math.max(1, Math.min(columns, tabs.length))}, minmax(0, 1fr))` }}
|
||||
style={{ gridTemplateColumns: `repeat(${across}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{tabs.map((tab, i) => {
|
||||
const on = tab.id === value;
|
||||
|
||||
@@ -189,6 +189,32 @@ describe('the fill of the letters', () => {
|
||||
expect(screen.getByText('journey.studio.fx.focalHint')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('turns the letters into a gradient or a pattern from their own colour, edited as a shape is', () => {
|
||||
const plain = mount(text({ shadow: { dx: 1, dy: 1, color: '#111111' }, color: '#0f766e' }));
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.paint.gradient' }));
|
||||
const gradient = plain.set.mock.lastCall?.[0]?.fillPaint;
|
||||
expect(gradient?.type).toBe('linear');
|
||||
expect(gradient?.stops[0]?.color).toBe('#0f766e');
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.paint.pattern' }));
|
||||
expect(plain.set.mock.lastCall?.[0]?.fillPaint?.type).toBe('pattern');
|
||||
plain.unmount();
|
||||
|
||||
const patterned = {
|
||||
type: 'pattern' as const,
|
||||
pattern: 'dots' as const,
|
||||
fg: '#d62828',
|
||||
bg: '#ffffff',
|
||||
scale: 1,
|
||||
angle: 0,
|
||||
};
|
||||
mount(text({ fillPaint: patterned }));
|
||||
expect(screen.getByRole('radio', { name: 'journey.studio.paint.pattern' }).getAttribute('aria-checked')).toBe(
|
||||
'true'
|
||||
);
|
||||
// Only the pattern's own controls: the kind is picked in the row above, so the paint's tabs stay away.
|
||||
expect(screen.queryByRole('tablist')).toBeNull();
|
||||
});
|
||||
|
||||
it('says so, and offers no photo fill, when the journey has no photos', () => {
|
||||
mount(text({ shadow: { dx: 1, dy: 1, color: '#111111' } }), []);
|
||||
expect(screen.getByText('journey.studio.fx.noPhotos')).toBeTruthy();
|
||||
|
||||
@@ -8,13 +8,16 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { BookElement, BookSpread, BookTextElement } from '@trek/shared';
|
||||
import { bookTextIsDrawn } from '@trek/shared';
|
||||
import { bookTextIsDrawn, bookTextPaintFill } from '@trek/shared';
|
||||
|
||||
import '../../styles/studio-text.css';
|
||||
import { photoSrc } from './bookRender';
|
||||
import { FocalPad } from './inspector/FocalPad';
|
||||
import { textEffectsInUse, textPlacementInUse } from './inspector/inspectorModel';
|
||||
import { paintAs } from './paint/paintEdit';
|
||||
import type { PaintChannel } from './paint/usePaintChannel';
|
||||
import { Choice, Line, NumField, Section, Switch } from './StudioControls';
|
||||
import { StudioPaintPicker } from './StudioPaintPicker';
|
||||
import { Swatches } from './StudioSwatches';
|
||||
import { defaultBand, defaultOutline, defaultShadow, firstFillPhoto, isHollow } from './textEffects';
|
||||
|
||||
@@ -275,10 +278,13 @@ function CurveControls({ el, set, t }: Pick<Props, 'el' | 'set' | 't'>) {
|
||||
);
|
||||
}
|
||||
|
||||
type FillKind = 'colour' | 'hollow' | 'photo';
|
||||
type FillKind = 'colour' | 'hollow' | 'photo' | 'gradient' | 'pattern';
|
||||
|
||||
function fillKindOf(el: BookTextElement): FillKind {
|
||||
if (el.fillPaint?.type === 'photo') return 'photo';
|
||||
const type = el.fillPaint?.type;
|
||||
if (type === 'photo') return 'photo';
|
||||
if (type === 'linear' || type === 'radial') return 'gradient';
|
||||
if (type === 'pattern') return 'pattern';
|
||||
return isHollow(el) ? 'hollow' : 'colour';
|
||||
}
|
||||
|
||||
@@ -294,9 +300,18 @@ function FillControls({ el, set, live, photos, spread, t }: Props) {
|
||||
outline: el.outline ?? { ...defaultOutline(el), color: el.color },
|
||||
} as Patch);
|
||||
}
|
||||
if (next === 'gradient' || next === 'pattern') {
|
||||
// From the text's own colour, as a shape's paint turns from its solid fill.
|
||||
return set({ fillPaint: paintAs(next, { type: 'solid', color: el.color }) } as Patch);
|
||||
}
|
||||
const photoId = firstFillPhoto(spread, photos);
|
||||
if (photoId != null) set({ fillPaint: { type: 'photo', photoId, focalX: 0.5, focalY: 0.5, zoom: 1 } } as Patch);
|
||||
};
|
||||
const letterPaint = bookTextPaintFill(el);
|
||||
const channel: PaintChannel = {
|
||||
live: (paint) => live({ fillPaint: paint } as Patch),
|
||||
commit: (paint) => set({ fillPaint: paint } as Patch),
|
||||
};
|
||||
return (
|
||||
<div className="st-fx-group">
|
||||
<Line label={t('journey.studio.fx.letters')}>
|
||||
@@ -307,11 +322,22 @@ function FillControls({ el, set, live, photos, spread, t }: Props) {
|
||||
{ value: 'colour' as const, label: t('journey.studio.colour') },
|
||||
{ value: 'hollow' as const, label: t('journey.studio.fx.hollow') },
|
||||
{ value: 'photo' as const, label: t('journey.studio.kind.photo'), disabled: !photos.length },
|
||||
{ value: 'gradient' as const, label: t('journey.studio.paint.gradient') },
|
||||
{ value: 'pattern' as const, label: t('journey.studio.paint.pattern') },
|
||||
]}
|
||||
onPick={pick}
|
||||
/>
|
||||
</Line>
|
||||
{!photos.length && <p className="st-hint st-fx-hint">{t('journey.studio.fx.noPhotos')}</p>}
|
||||
{letterPaint && (kind === 'gradient' || kind === 'pattern') && (
|
||||
<StudioPaintPicker
|
||||
value={letterPaint}
|
||||
channel={channel}
|
||||
t={t}
|
||||
noneLabel={t('journey.studio.colour')}
|
||||
kinds={[kind]}
|
||||
/>
|
||||
)}
|
||||
{photo && (
|
||||
<div className="st-fx-body">
|
||||
<div className="st-fx-photos" role="radiogroup" aria-label={t('journey.studio.fx.pickPhoto')}>
|
||||
|
||||
@@ -3,7 +3,9 @@ import type {
|
||||
BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement,
|
||||
BookMetric, BookStatsElement, JourneyStats,
|
||||
} from '@trek/shared'
|
||||
import { BOOK_METRICS, MAX_PATH_POINTS, MAX_PATH_SEGMENTS, isStale, refreshPatch } from '@trek/shared'
|
||||
import {
|
||||
BOOK_BADGE_STYLES, BOOK_COUNTRY_LOOKS, BOOK_METRICS, BOOK_STATS_LOOKS, MAX_PATH_POINTS, MAX_PATH_SEGMENTS, isStale, refreshPatch,
|
||||
} from '@trek/shared'
|
||||
import { Swatches } from './StudioSwatches'
|
||||
import { StudioFontPicker } from './StudioFontPicker'
|
||||
import { WeightLine } from './StudioTypeControls'
|
||||
@@ -547,6 +549,8 @@ function MapLook({ el, set, t }: Props<BookMapElement>) {
|
||||
options={[
|
||||
{ value: 'dot' as const, label: t('journey.studio.pinDot') },
|
||||
{ value: 'photo' as const, label: t('journey.studio.pinPhoto') },
|
||||
{ value: 'numbered' as const, label: t('journey.studio.pinNumbered') },
|
||||
{ value: 'ends' as const, label: t('journey.studio.pinEnds') },
|
||||
]}
|
||||
onPick={pinStyle => set({ pinStyle } as Partial<BookElement>)}
|
||||
/>
|
||||
@@ -597,16 +601,26 @@ function StatsProps({ el, set, t }: Props<BookStatsElement>) {
|
||||
/>
|
||||
</Section>
|
||||
<Section label={t('journey.studio.layout')}>
|
||||
<Line label={t('journey.studio.layout')}>
|
||||
{/* The look first: the arrangement below belongs to the classic one alone. */}
|
||||
<Line label={t('journey.studio.look')} wide>
|
||||
<Choice
|
||||
value={el.layout}
|
||||
options={(['grid', 'row', 'column'] as const).map(layout => ({
|
||||
value: layout,
|
||||
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
|
||||
}))}
|
||||
onPick={layout => set({ layout } as Partial<BookElement>)}
|
||||
value={el.look ?? 'classic'}
|
||||
options={BOOK_STATS_LOOKS.map(look => ({ value: look, label: t(`journey.studio.statsLook.${look}`) }))}
|
||||
onPick={look => set({ look } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
{(el.look ?? 'classic') === 'classic' && (
|
||||
<Line label={t('journey.studio.layout')}>
|
||||
<Choice
|
||||
value={el.layout}
|
||||
options={(['grid', 'row', 'column'] as const).map(layout => ({
|
||||
value: layout,
|
||||
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
|
||||
}))}
|
||||
onPick={layout => set({ layout } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
)}
|
||||
<Line label={t('journey.studio.units')}>
|
||||
<Choice
|
||||
value={el.units}
|
||||
@@ -634,16 +648,25 @@ function StatsProps({ el, set, t }: Props<BookStatsElement>) {
|
||||
function CountriesProps({ el, set, t }: Props<BookCountriesElement>) {
|
||||
return (
|
||||
<Section label={t('journey.studio.countries')}>
|
||||
<Line label={t('journey.studio.layout')}>
|
||||
<Line label={t('journey.studio.look')} wide>
|
||||
<Choice
|
||||
value={el.layout}
|
||||
options={(['list', 'grid', 'column'] as const).map(layout => ({
|
||||
value: layout,
|
||||
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
|
||||
}))}
|
||||
onPick={layout => set({ layout } as Partial<BookElement>)}
|
||||
value={el.look ?? 'classic'}
|
||||
options={BOOK_COUNTRY_LOOKS.map(look => ({ value: look, label: t(`journey.studio.countryLook.${look}`) }))}
|
||||
onPick={look => set({ look } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
{(el.look ?? 'classic') === 'classic' && el.codes.length > 1 && (
|
||||
<Line label={t('journey.studio.layout')}>
|
||||
<Choice
|
||||
value={el.layout}
|
||||
options={(['list', 'grid', 'column'] as const).map(layout => ({
|
||||
value: layout,
|
||||
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
|
||||
}))}
|
||||
onPick={layout => set({ layout } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
)}
|
||||
{/* These three read as raw English out of the code before: `left`,
|
||||
`center`, `right`, straight from the value. */}
|
||||
<Line label={t('journey.studio.align')}>
|
||||
@@ -734,7 +757,7 @@ function BadgeProps({ el, set, t }: Props<BookBadgeElement>) {
|
||||
<Line label={t('journey.studio.style')} wide>
|
||||
<Choice
|
||||
value={el.style}
|
||||
options={(['plain', 'chip', 'outline', 'stacked'] as const).map(style => ({
|
||||
options={BOOK_BADGE_STYLES.map(style => ({
|
||||
value: style, label: t(`journey.studio.markStyle.${style}`),
|
||||
}))}
|
||||
onPick={style => set({ style } as Partial<BookElement>)}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, RouteOff } from 'lucide-react'
|
||||
import type {
|
||||
BookBadgeVariant, BookElement, BookMapScope, BookMetric, BookPageSetup, JourneyStats,
|
||||
} from '@trek/shared'
|
||||
import {
|
||||
BOOK_METRICS, bookBadgeValue, bookCountryMark, bookCountryName, bookElementId as uid, bookTravelBadge,
|
||||
bookTravelCountries, bookTravelMap, bookTravelSize, bookTravelStats,
|
||||
} from '@trek/shared'
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
|
||||
import { Globe, Hash, Map as MapIcon, Stamp } from 'lucide-react'
|
||||
import type { BookElement, BookPageSetup, JourneyStats } from '@trek/shared'
|
||||
import { bookElementId as uid } from '@trek/shared'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { PanelHead, Shelf } from './StudioPanelHead'
|
||||
import { studioTravelContext } from './studioTravelContext'
|
||||
import { Section } from './StudioControls'
|
||||
import { TravelPreview } from './TravelPreview'
|
||||
import { StudioTabPanel, StudioTabs } from './StudioTabs'
|
||||
import { fetchRoads } from './roadRoute'
|
||||
import { TravelCard } from './travel/TravelCard'
|
||||
import { TravelStops } from './travel/TravelStops'
|
||||
import {
|
||||
COUNTRY_LOOKS, FIGURE_SHELVES, MAP_CARDS, MARK_STYLES, MARK_VARIANTS, SUMMARY_LOOKS, travelBuilders,
|
||||
} from './travel/travelBuilders'
|
||||
|
||||
/**
|
||||
* The journey's own figures, as things you can put on a page.
|
||||
@@ -22,90 +20,39 @@ import { fetchRoads } from './roadRoute'
|
||||
* distance, the countries and the dates are already in TREK; the work is
|
||||
* offering them as objects rather than as numbers someone has to retype.
|
||||
*
|
||||
* ── Why every tile is a real preview ──────────────────────────────────────
|
||||
* Four tabs, the way the Elements panel sorts what it adds: the figures, the
|
||||
* maps with the stops they are drawn from, the countries, and the marks. Every
|
||||
* card is the element its click places, built by the book engine and drawn by
|
||||
* the page's own renderer with this journey's numbers in it, in the newer
|
||||
* looks and in the book's theme (travel/travelBuilders.ts). The looks books
|
||||
* had before these still draw on the pages that hold them; they are no longer
|
||||
* offered here.
|
||||
*
|
||||
* These elements differ from one another by what they *say*, not by their
|
||||
* shape. Four map styles are four maps; three summary layouts are the same six
|
||||
* numbers arranged three ways. A named row with the value beside it — which is
|
||||
* what this panel was first — tells you none of that: you cannot tell the dark
|
||||
* map from the paper one, or a row of figures from a grid of them, until you
|
||||
* have placed one and undone it.
|
||||
*
|
||||
* So each tile builds the element it would place and renders it through the
|
||||
* page's own renderer, with this journey's real numbers in it. You are picking
|
||||
* from the things themselves.
|
||||
*
|
||||
* Every button resolves its values *now* and writes them into the element, so
|
||||
* Every card resolves its values now and writes them into the element, so
|
||||
* what lands on the page is finished rather than a placeholder that needs the
|
||||
* server to mean anything. The reasoning is in TravelElements.tsx, and the
|
||||
* short version is that the print renderer must never depend on a fetch.
|
||||
* server to mean anything: the print renderer must never depend on a fetch.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One row of the stops list: a point of the route or a stop switched off.
|
||||
*
|
||||
* `entryId` is null for a stop the server took straight from a trip's places,
|
||||
* which is what a journey has before anyone wrote an entry. Such a stop has no
|
||||
* switch, because there is no entry to put it on.
|
||||
*/
|
||||
type StopRow = {
|
||||
entryId: number | null
|
||||
label: string
|
||||
date: string | null
|
||||
country: string | null
|
||||
excluded: boolean
|
||||
}
|
||||
type Tab = 'figures' | 'maps' | 'countries' | 'marks'
|
||||
|
||||
/*
|
||||
* How many cards share a row.
|
||||
*
|
||||
* A stats panel is the width of a spread; shown two-up its figures scale down
|
||||
* past reading, so it gets the row to itself. Maps, country lists and marks are
|
||||
* compact enough that two-up still tells them apart, and two-up is what makes
|
||||
* the section scannable. The tile measures its own width — see TravelPreview.
|
||||
*/
|
||||
|
||||
/** The words a mark carries, as the book engine resolves them. */
|
||||
type BadgeValue = NonNullable<ReturnType<typeof bookBadgeValue>>
|
||||
const TABS: { id: Tab; icon: typeof Hash; labelKey: string }[] = [
|
||||
{ id: 'figures', icon: Hash, labelKey: 'journey.studio.travelFigures' },
|
||||
{ id: 'maps', icon: MapIcon, labelKey: 'journey.studio.travelMaps' },
|
||||
{ id: 'countries', icon: Globe, labelKey: 'journey.studio.countries' },
|
||||
{ id: 'marks', icon: Stamp, labelKey: 'journey.studio.marks' },
|
||||
]
|
||||
|
||||
/*
|
||||
* The two imagery maps, satellite first: the recommendation should not be the
|
||||
* second thing read. Their tiles and credit are the book engine's own
|
||||
* (BOOK_MAP_SOURCES), so a satellite map placed here is the one the assistant
|
||||
* places.
|
||||
* second thing read. Relief stops at about six hundred metres to the pixel, a
|
||||
* smudge at country size; satellite is the one that prints for the page most
|
||||
* people make, and the ring says so.
|
||||
*/
|
||||
const IMAGERY = [
|
||||
{ id: 'satellite', labelKey: 'journey.studio.mapSourceSatellite' },
|
||||
{ id: 'relief', labelKey: 'journey.studio.mapSourceRelief' },
|
||||
] as const
|
||||
|
||||
function Card({ label, onClick, children, recommended = false, title }: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
/**
|
||||
* Marked as the one to reach for, with a ring rather than with words.
|
||||
*
|
||||
* The label sits in a grid cell about eighty pixels wide, where "Satellite,
|
||||
* recommended" would take both of its lines. A ring says the same thing in
|
||||
* no space at all, and the tooltip carries the word for anyone who wonders
|
||||
* why this card looks different.
|
||||
*/
|
||||
recommended?: boolean
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button type="button"
|
||||
className={`st-travel-card ${recommended ? 'is-recommended' : ''}`}
|
||||
onClick={onClick}
|
||||
title={title ? `${label}: ${title}` : label}
|
||||
>
|
||||
{children}
|
||||
<span className="st-travel-card-label">{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function StudioTravelPanel({
|
||||
page, stats, path, t, locale, canEdit, onToggleStop,
|
||||
}: {
|
||||
@@ -123,16 +70,16 @@ export function StudioTravelPanel({
|
||||
const addElement = useStudioStore(s => s.addElement)
|
||||
const updateElement = useStudioStore(s => s.updateElement)
|
||||
const active = useStudioStore(s => s.activeSpread)
|
||||
const doc = useStudioStore(s => s.doc)
|
||||
const spread = doc?.spreads[active]
|
||||
const single = !!spread && spread.role !== 'inner'
|
||||
const role = useStudioStore(s => s.doc?.spreads[active]?.role)
|
||||
const theme = useStudioStore(s => s.doc?.theme)
|
||||
const [tab, setTab] = useState<Tab>('figures')
|
||||
const tabsId = useId()
|
||||
|
||||
/*
|
||||
* The road lookups still running when the panel goes away.
|
||||
*
|
||||
* fetchRoads walks the legs at a deliberate pace, so a twenty-stop map keeps
|
||||
* asking a public router for minutes after Studio is closed, and the answer
|
||||
* lands in whatever document the store holds by then.
|
||||
* The road lookups still running when the panel goes away. fetchRoads walks
|
||||
* the legs at a deliberate pace, so a twenty-stop map keeps asking a public
|
||||
* router for minutes, and the answer would land in whatever document the
|
||||
* store holds by then.
|
||||
*/
|
||||
const roadJobs = useRef(new Set<AbortController>())
|
||||
useEffect(() => () => {
|
||||
@@ -140,22 +87,6 @@ export function StudioTravelPanel({
|
||||
roadJobs.current.clear()
|
||||
}, [])
|
||||
|
||||
/*
|
||||
* The stops whose switch is on its way to the server.
|
||||
*
|
||||
* No optimistic flip: which stop is furthest and which countries remain is
|
||||
* the server's sum, so the row reads its state from the stats the hook
|
||||
* fetches once the change has landed. Until then the chip is only held,
|
||||
* so a second click cannot send the opposite of what the first one is
|
||||
* still delivering.
|
||||
*/
|
||||
const [pending, setPending] = useState<Set<number>>(() => new Set())
|
||||
|
||||
const centre = (w: number, h: number) => {
|
||||
const W = single ? page.pageWidth : page.pageWidth * 2
|
||||
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<>
|
||||
@@ -167,146 +98,17 @@ export function StudioTravelPanel({
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* What the book engine's factories read (studioTravelContext.ts). Every tile
|
||||
* below is built by the factory the engine's `add` calls. Where it lands is
|
||||
* the panel's own: centred across the spread, where `add` takes the right page.
|
||||
*/
|
||||
const travel = studioTravelContext(stats, path, locale, t)
|
||||
const at = (size: { w: number; h: number }) => centre(size.w, size.h)
|
||||
const build = travelBuilders({ page, stats, path, locale, t, theme, single: !!role && role !== 'inner' })
|
||||
|
||||
/**
|
||||
* Country names in the reader's language, not the server's.
|
||||
*
|
||||
* The API answers in English, because it has no idea who will read the book.
|
||||
* `Intl.DisplayNames` is the same CLDR data Atlas already uses for regions, so
|
||||
* a German book says "Island" and an English one says "Iceland" — from one set
|
||||
* of codes, with no translation table to keep in step.
|
||||
*/
|
||||
const countryName = (code: string): string => bookCountryName(code, locale, stats)
|
||||
|
||||
/*
|
||||
* Each builder returns the finished element. The tile renders it and the
|
||||
* click places it, so the preview and the result are the same object, and
|
||||
* the object is the engine's, so there is no second description of what a
|
||||
* card means.
|
||||
*/
|
||||
/** The linked trips that actually put stops on the route. */
|
||||
const tripsWithRoute = (stats.trips ?? []).filter(t => t.points > 0)
|
||||
|
||||
/*
|
||||
* The route as rows, counting and left-out stops together.
|
||||
*
|
||||
* Every point is listed, so the list reads as the route. The left-out
|
||||
* stops sit among the counting ones in date order rather than in a list of
|
||||
* their own, because the question the section answers is "which of these
|
||||
* count?", and that is only answerable with all of them in one sequence.
|
||||
* Undated stops go last, then by name.
|
||||
*/
|
||||
const stops: StopRow[] = [
|
||||
...stats.points.map(p => ({
|
||||
entryId: p.entryId, label: p.label, date: p.date, country: p.country, excluded: false,
|
||||
})),
|
||||
...(stats.excluded ?? []).map(x => ({
|
||||
entryId: x.entryId, label: x.label, date: x.date, country: null, excluded: true,
|
||||
})),
|
||||
].sort((a, b) => {
|
||||
if (a.date !== b.date) {
|
||||
if (a.date == null) return 1
|
||||
if (b.date == null) return -1
|
||||
return a.date < b.date ? -1 : 1
|
||||
}
|
||||
return a.label.localeCompare(b.label)
|
||||
})
|
||||
const excludedCount = stops.filter(s => s.excluded).length
|
||||
/** Whether the country is worth a badge, or the same word on every row. */
|
||||
const manyCountries = stats.countries.length > 1
|
||||
|
||||
/**
|
||||
* The day of a stop, without the weekday.
|
||||
*
|
||||
* `formatDate` is the journal's format and carries "Tue," because a journal
|
||||
* entry is about a day somebody lived. A row here is about a leg of a route,
|
||||
* and it has 236px to hold a place name in as well: the weekday costs a
|
||||
* third of the badge and answers nothing anybody asks of this list.
|
||||
*/
|
||||
const stopDay = (iso: string) =>
|
||||
new Date(`${iso}T00:00:00Z`).toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
|
||||
const toggleStop = async (entryId: number, excluded: boolean) => {
|
||||
setPending(prev => new Set(prev).add(entryId))
|
||||
// A refusal is the hook's to report; here it only frees the chip again.
|
||||
await onToggleStop(entryId, excluded)
|
||||
setPending(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(entryId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* One country, as the entry a list would have made of it.
|
||||
*
|
||||
* The list and the grid are gone: they were compositions somebody else made,
|
||||
* deciding the order, the spacing and the type size, and the only way to
|
||||
* change any of that was to not use them. What is left is the same entry —
|
||||
* name over silhouette, set the same way — as an element you place one at a
|
||||
* time. Three of them down a page is the list, with the spacing yours; one of
|
||||
* them at 80mm is a chapter opener, which the list could never be.
|
||||
*
|
||||
* The name is editable afterwards, so numbering them "1. Germany",
|
||||
* "2. Netherlands" is typing rather than a feature.
|
||||
*/
|
||||
const countryEl = (code: string): BookElement =>
|
||||
bookTravelCountries(travel, { id: uid('co'), frame: at(bookTravelSize('countries', page)), codes: [code] })
|
||||
|
||||
/**
|
||||
* A map as the engine's `add` places it, in the style the card offers.
|
||||
*
|
||||
* The scope is the journey or one trip. A trip map carries no recorded way:
|
||||
* the trail is the journey's, and a map fitted to it would frame the whole
|
||||
* journey around one trip and lose the bow its legs are drawn with.
|
||||
*/
|
||||
const mapEl = (
|
||||
style: 'minimal' | 'outline' | 'dark' | 'paper',
|
||||
scope: BookMapScope = 'journey',
|
||||
source: 'vector' | 'relief' | 'satellite' = 'vector',
|
||||
): BookElement => ({
|
||||
...bookTravelMap(travel, stats, { id: uid('mp'), frame: at(bookTravelSize('map', page)), scope, source }),
|
||||
style,
|
||||
})
|
||||
|
||||
/** A mark at the size that suits what it holds, which is the engine's size for its variant. */
|
||||
const badgeEl = (variant: BookBadgeVariant, value: BadgeValue): BookElement =>
|
||||
bookTravelBadge({ id: uid('bd'), frame: at(bookTravelSize('badge', page, { variant })), variant, value })
|
||||
|
||||
/**
|
||||
* One figure on its own.
|
||||
*
|
||||
* A summary panel is a composition; a single figure is a mark you drop next
|
||||
* to a photograph. Both are the same element with a different metric list,
|
||||
* and offering only the composition meant anyone wanting "14 DAYS" beside a
|
||||
* picture had to place all four and delete three.
|
||||
*/
|
||||
const singleEl = (metric: BookMetric): BookElement =>
|
||||
bookTravelStats(stats, { id: uid('st'), frame: at(bookTravelSize('stats', page)), metrics: [metric] })
|
||||
|
||||
/** Place a copy — the previewed element keeps its own id for React. */
|
||||
/** Place a copy; the previewed element keeps its own id for React. */
|
||||
const place = (el: BookElement) => {
|
||||
const placed = { ...el, id: uid(el.kind[0]) } as BookElement
|
||||
const placed = { ...el, id: uid(el.kind[0] ?? 'e') } as BookElement
|
||||
addElement(active, placed)
|
||||
|
||||
/*
|
||||
* A map arrives following the roads.
|
||||
*
|
||||
* Asked for after the element is on the page rather than before, so the map
|
||||
* appears at once and the line firms up a moment later: a panel that sat
|
||||
* there for fifteen seconds after a click would read as broken, and the
|
||||
* straight line it starts with is the one it would have had anyway.
|
||||
*
|
||||
* Legs long enough to have been flights are never sent (see roadRoute.ts),
|
||||
* the router's own cache answers a second placement of the same journey for
|
||||
* free, and a failure leaves the leg exactly as it was.
|
||||
* A map arrives following the roads, asked for after it is on the page so
|
||||
* it appears at once and the line firms up a moment later. Legs long enough
|
||||
* to have been flights are never sent (roadRoute.ts), and a failure leaves
|
||||
* the leg as it was.
|
||||
*/
|
||||
if (placed.kind === 'map' && placed.points.length > 1) {
|
||||
const job = new AbortController()
|
||||
@@ -314,266 +116,228 @@ export function StudioTravelPanel({
|
||||
void fetchRoads(placed.points.map(pt => ({ lat: pt.lat, lng: pt.lng })), { signal: job.signal })
|
||||
.then(roads => {
|
||||
if (job.signal.aborted) return
|
||||
if (roads.some(r => r && r.length > 1)) {
|
||||
updateElement(active, placed.id, { roads } as Partial<BookElement>)
|
||||
}
|
||||
if (roads.some(r => r && r.length > 1)) updateElement(active, placed.id, { roads } as Partial<BookElement>)
|
||||
})
|
||||
.catch(() => { /* No roads is a state the map already draws. */ })
|
||||
.finally(() => { roadJobs.current.delete(job) })
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The marks of the journey, with the words the engine resolves for each. A
|
||||
* mark the journey cannot fill (no start date, no stop, no country) is not
|
||||
* offered.
|
||||
*/
|
||||
const marks: { el: BookElement; label: string }[] = ([
|
||||
['date', t('journey.studio.dateMark')],
|
||||
['day', t('journey.studio.dayMark')],
|
||||
['coords', t('journey.studio.coordsMark')],
|
||||
['flag', t('journey.studio.flagMark')],
|
||||
['distance', t('journey.studio.distanceMark')],
|
||||
] as const).flatMap(([variant, label]) => {
|
||||
const value = bookBadgeValue(travel, variant, null)
|
||||
return value ? [{ el: badgeEl(variant, value), label }] : []
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelHead label={t('journey.studio.travel')} />
|
||||
<div className="st-panel-scroll">
|
||||
<Shelf label={t('journey.studio.singleFigures')}>
|
||||
<div className="st-travel-grid">
|
||||
{BOOK_METRICS.map(metric => {
|
||||
const el = singleEl(metric)
|
||||
return (
|
||||
<Card key={metric} label={t(`journey.studio.metric.${metric}`)} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={44} maxHeight={62} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Shelf>
|
||||
|
||||
{/*
|
||||
The switch is here, beside the maps and the figures it changes, and
|
||||
not only in the entry editor: the moment somebody notices the home
|
||||
airport on the printed route is the moment they are looking at this
|
||||
panel. The rows carry no preview because they are not things to
|
||||
place; they are the input every tile below reads.
|
||||
*/}
|
||||
{stops.length > 0 && (
|
||||
<Section
|
||||
label={t('journey.studio.stops')}
|
||||
hint={t('journey.studio.stopsHint')}
|
||||
/*
|
||||
* Folded to begin with, and saying how many count while it is.
|
||||
*
|
||||
* A fortnight is fourteen rows, which is longer than everything
|
||||
* else in this panel put together, and most books never need to
|
||||
* touch it. The figure in the head is what anyone opening it came
|
||||
* to check anyway, so the closed section still answers the
|
||||
* question and the maps below stay in reach.
|
||||
*/
|
||||
defaultOpen={false}
|
||||
badge={`${stops.length - excludedCount}/${stops.length}`}
|
||||
>
|
||||
<ul className="st-route-stops">
|
||||
{stops.map((stop, i) => {
|
||||
const entryId = stop.entryId
|
||||
const on = !stop.excluded
|
||||
const state = t(on ? 'journey.studio.stopOn' : 'journey.studio.stopOff')
|
||||
const chip = `st-chip is-pick is-icon ${on ? 'is-on' : ''}`
|
||||
/*
|
||||
* A mark rather than the word.
|
||||
*
|
||||
* "Counts" and "Left out" are different lengths, so a column of
|
||||
* them jitters, and together they cost a third of a row that
|
||||
* has a place name to fit. The tick and the crossed-out route
|
||||
* say the same thing at a glance, and the word is still there
|
||||
* for anyone who hovers or reads with a screen reader.
|
||||
*/
|
||||
const mark = on ? <Check size={13} strokeWidth={2.4} /> : <RouteOff size={13} strokeWidth={2.2} />
|
||||
return (
|
||||
<li key={entryId ?? `place-${i}`} className={`st-route-stop ${on ? '' : 'is-off'}`}>
|
||||
{/*
|
||||
One line: the day, then the name, then the switch. The
|
||||
name and the day were stacked, which doubled the height
|
||||
of a list whose whole job is being read down.
|
||||
*/}
|
||||
{stop.date && <span className="st-badge">{stopDay(stop.date)}</span>}
|
||||
<span className="st-route-stop-label">{stop.label || t('journey.studio.untitled')}</span>
|
||||
{/*
|
||||
The country only where there is more than one to tell
|
||||
apart. On a journey inside one country it is the same
|
||||
word fourteen times, in the row that has least room.
|
||||
*/}
|
||||
{manyCountries && stop.country && (
|
||||
<span className="st-badge is-quiet">{countryName(stop.country)}</span>
|
||||
)}
|
||||
{canEdit && entryId != null ? (
|
||||
<button type="button"
|
||||
className={chip}
|
||||
title={`${state}: ${t('journey.studio.stopToggle')}`}
|
||||
aria-label={t('journey.studio.stopToggle')}
|
||||
aria-pressed={on}
|
||||
disabled={pending.has(entryId)}
|
||||
onClick={() => void toggleStop(entryId, !stop.excluded)}
|
||||
>
|
||||
{mark}
|
||||
</button>
|
||||
) : (
|
||||
<span className={chip} title={state} aria-label={state}>{mark}</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Shelf label={t('journey.studio.routeMap')}>
|
||||
{stats.points.length ? (
|
||||
<>
|
||||
<div className="st-travel-grid">
|
||||
{(['minimal', 'outline', 'paper', 'dark'] as const).map(style => {
|
||||
const el = mapEl(style)
|
||||
return (
|
||||
<Card key={style} label={t(`journey.studio.mapStyle.${style}`)} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={62} maxHeight={80} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
{/*
|
||||
The two imagery maps, as cards rather than as settings.
|
||||
|
||||
They were reachable only by placing an outline map and then
|
||||
changing its source in the inspector, which is a thing nobody
|
||||
finds. They are different pictures, not variants of one: relief
|
||||
is land shaded in its own colours against a dark sea, drawn at
|
||||
a scale that suits a continent, and satellite is the ground
|
||||
itself, which is what a page about one country wants.
|
||||
*/}
|
||||
{IMAGERY.map(src => (
|
||||
<Card
|
||||
key={src.id}
|
||||
/*
|
||||
* Satellite is marked rather than merely listed. The three
|
||||
* imagery options are not equals: relief stops at about six
|
||||
* hundred metres to the pixel, which is a continent's worth
|
||||
* of detail and a smudge at country size, and the outlines
|
||||
* are a diagram. For the page most people are making —
|
||||
* a route across one or two countries — this is the one
|
||||
* that prints, and saying so beats letting them find out
|
||||
* after the book is bound.
|
||||
*/
|
||||
label={t(src.labelKey)}
|
||||
recommended={src.id === 'satellite'}
|
||||
title={src.id === 'satellite' ? t('journey.studio.recommended') : undefined}
|
||||
onClick={() => place(mapEl('minimal', 'journey', src.id))}
|
||||
>
|
||||
<TravelPreview
|
||||
el={mapEl('minimal', 'journey', src.id)}
|
||||
minHeight={62}
|
||||
maxHeight={80}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
A map per trip, for a journey made of more than one.
|
||||
|
||||
Two trips printed as a single route draw a line from the last
|
||||
stop of the first to the first stop of the second, which is
|
||||
usually the longest leg on the page and one nobody travelled.
|
||||
Offered rather than imposed: some journeys really are one route
|
||||
across several trips, and those still get the map above.
|
||||
|
||||
Only trips that have stops on the route are listed — a linked
|
||||
trip nobody wrote about would place an empty frame.
|
||||
*/}
|
||||
{tripsWithRoute.length > 1 && (
|
||||
<div className="st-shelf-sub">
|
||||
<h4 className="st-section-label">{t('journey.studio.mapPerTrip')}</h4>
|
||||
<div className="st-travel-grid">
|
||||
{tripsWithRoute.map(trip => {
|
||||
const el = mapEl('minimal', { tripId: trip.id })
|
||||
return (
|
||||
<Card
|
||||
key={trip.id}
|
||||
label={trip.title || t('journey.studio.untitled')}
|
||||
onClick={() => place(el)}
|
||||
>
|
||||
<TravelPreview el={el} minHeight={62} maxHeight={80} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="st-hint">{t('journey.studio.noRoute')}</p>
|
||||
<StudioTabs<Tab>
|
||||
idBase={tabsId}
|
||||
label={t('journey.studio.travel')}
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
columns={2}
|
||||
tabs={TABS.map(x => ({ id: x.id, icon: x.icon, label: t(x.labelKey) }))}
|
||||
/>
|
||||
<StudioTabPanel idBase={tabsId} value={tab} className="st-tab-body">
|
||||
<div className="st-panel-scroll">
|
||||
{tab === 'figures' && <FiguresTab build={build} t={t} place={place} />}
|
||||
{tab === 'maps' && (
|
||||
<MapsTab build={build} stats={stats} t={t} place={place}>
|
||||
<TravelStops
|
||||
stats={stats} t={t} locale={locale} canEdit={canEdit}
|
||||
onToggleStop={onToggleStop} countryName={build.countryName}
|
||||
/>
|
||||
</MapsTab>
|
||||
)}
|
||||
</Shelf>
|
||||
|
||||
<Shelf label={t('journey.studio.countries')}>
|
||||
{stats.countries.length ? (
|
||||
<>
|
||||
{/*
|
||||
One country per element, rather than a block listing them all.
|
||||
|
||||
A list of countries is a composition somebody else made: it
|
||||
decides the order, the spacing and the type size, and the only
|
||||
way to change any of it is to not use it. Placed one at a time
|
||||
they are ordinary elements — numbered, moved, set at different
|
||||
sizes, put on different pages — which is what a book of a trip
|
||||
through several countries actually wants.
|
||||
*/}
|
||||
<div className="st-travel-grid">
|
||||
{stats.countries.slice(0, 12).map(c => {
|
||||
const el = countryEl(c.code)
|
||||
return (
|
||||
<Card key={c.code} label={countryName(c.code)} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={44} maxHeight={64} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
And the same country as a mark: the outline beside the name
|
||||
rather than under it, at the size of a line of type. It belongs
|
||||
next to a date or a coordinate, not on a page of its own.
|
||||
*/}
|
||||
<div className="st-travel-grid" style={{ marginTop: 6 }}>
|
||||
{stats.countries.slice(0, 12).map(c => {
|
||||
const el = badgeEl('country', bookCountryMark('country', c.code, countryName(c.code)))
|
||||
return (
|
||||
<Card key={`mark-${c.code}`} label={countryName(c.code)} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={30} maxHeight={44} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="st-hint">{t('journey.studio.noCountries')}</p>
|
||||
)}
|
||||
</Shelf>
|
||||
|
||||
<Shelf label={t('journey.studio.marks')}>
|
||||
<div className="st-travel-grid">
|
||||
{marks.map(({ el, label }) => (
|
||||
<Card key={el.id} label={label} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={34} maxHeight={52} />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
</div>
|
||||
{tab === 'countries' && <CountriesTab build={build} stats={stats} t={t} place={place} />}
|
||||
{tab === 'marks' && <MarksTab build={build} t={t} place={place} />}
|
||||
</div>
|
||||
</StudioTabPanel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type Build = ReturnType<typeof travelBuilders>
|
||||
|
||||
interface TabProps {
|
||||
build: Build
|
||||
t: (k: string) => string
|
||||
place: (el: BookElement) => void
|
||||
}
|
||||
|
||||
/** The summary in its four looks, full width, and every figure on its own as a medal. */
|
||||
function FiguresTab({ build, t, place }: TabProps) {
|
||||
return (
|
||||
<>
|
||||
<Shelf label={t('journey.studio.summary')}>
|
||||
<div className="st-travel-grid is-wide">
|
||||
{SUMMARY_LOOKS.map(look => (
|
||||
<TravelCard
|
||||
key={look}
|
||||
el={build.summaryEl(look)}
|
||||
label={t(`journey.studio.statsLook.${look}`)}
|
||||
onPlace={place}
|
||||
minHeight={look === 'band' ? 30 : 70}
|
||||
maxHeight={look === 'band' ? 40 : 120}
|
||||
inset={look === 'note' || look === 'sketch' ? 0.05 : 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
{/* Each figure on its own, in the looks that suit a single figure: a medal, an odometer, a ring drawn by hand. */}
|
||||
{FIGURE_SHELVES.map(({ look, metrics }) => (
|
||||
<Shelf key={look} label={`${t('journey.studio.singleFigures')}: ${t(`journey.studio.statsLook.${look}`)}`}>
|
||||
<div className={`st-travel-grid ${look === 'odometer' ? 'is-wide' : ''}`}>
|
||||
{metrics.map(metric => (
|
||||
<TravelCard
|
||||
key={metric}
|
||||
el={build.figureEl(metric, look)}
|
||||
label={t(`journey.studio.metric.${metric}`)}
|
||||
onPlace={place}
|
||||
minHeight={look === 'odometer' ? 50 : 80}
|
||||
maxHeight={look === 'odometer' ? 64 : 104}
|
||||
inset={look === 'odometer' ? 0 : 0.06}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The route: drawn with its photographs, its stops numbered, its two ends
|
||||
* marked or on a dark ground, then over satellite and relief imagery; a map
|
||||
* per trip for a journey of several; and the stops it is drawn from.
|
||||
*/
|
||||
function MapsTab({ build, stats, t, place, children }: TabProps & { stats: JourneyStats; children: ReactNode }) {
|
||||
if (!stats.points.length) return <p className="st-hint">{t('journey.studio.noRoute')}</p>
|
||||
// Only the trips that put stops on the route: a linked trip nobody wrote about would place an empty frame.
|
||||
const trips = (stats.trips ?? []).filter(trip => trip.points > 0)
|
||||
return (
|
||||
<>
|
||||
<Shelf label={t('journey.studio.routeMap')}>
|
||||
<div className="st-travel-grid">
|
||||
{MAP_CARDS.map(card => (
|
||||
<TravelCard key={card.id} el={build.mapEl(card)} label={t(card.labelKey)} onPlace={place} minHeight={70} maxHeight={92} />
|
||||
))}
|
||||
{IMAGERY.map(src => (
|
||||
<TravelCard
|
||||
key={src.id}
|
||||
el={build.mapEl({ style: 'minimal' }, 'journey', src.id)}
|
||||
label={t(src.labelKey)}
|
||||
onPlace={place}
|
||||
minHeight={70}
|
||||
maxHeight={92}
|
||||
recommended={src.id === 'satellite'}
|
||||
title={src.id === 'satellite' ? t('journey.studio.recommended') : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/*
|
||||
A map per trip: two trips printed as one route draw a line from the
|
||||
last stop of one to the first of the next, a leg nobody travelled.
|
||||
*/}
|
||||
{trips.length > 1 && (
|
||||
<div className="st-shelf-sub">
|
||||
<h4 className="st-section-label">{t('journey.studio.mapPerTrip')}</h4>
|
||||
<div className="st-travel-grid">
|
||||
{trips.map(trip => (
|
||||
<TravelCard
|
||||
key={trip.id}
|
||||
el={build.mapEl({ style: 'minimal', pins: 'numbered' }, { tripId: trip.id })}
|
||||
label={trip.title || t('journey.studio.untitled')}
|
||||
onPlace={place}
|
||||
minHeight={70}
|
||||
maxHeight={92}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Shelf>
|
||||
{/* Each country on its own, its stops alone, cut to its shape or in a box. */}
|
||||
{stats.countries.slice(0, 6).map(c => (
|
||||
<Shelf key={c.code} label={build.countryName(c.code)}>
|
||||
<div className="st-travel-grid">
|
||||
{COUNTRY_MAPS.map(m => (
|
||||
<TravelCard
|
||||
key={m.kind}
|
||||
el={build.countryMapEl(c.code, m.kind)}
|
||||
label={t(m.labelKey)}
|
||||
onPlace={place}
|
||||
minHeight={80}
|
||||
maxHeight={104}
|
||||
inset={m.kind === 'box' ? 0 : 0.04}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
))}
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** The maps of one country: cut to its shape on the paper, cut to it over the satellite picture, and in a box. */
|
||||
const COUNTRY_MAPS = [
|
||||
{ kind: 'shape', labelKey: 'journey.studio.mapClipCountry' },
|
||||
{ kind: 'satellite', labelKey: 'journey.studio.mapSourceSatellite' },
|
||||
{ kind: 'box', labelKey: 'journey.studio.mapClipRect' },
|
||||
] as const
|
||||
|
||||
/** Each country in every look, one shelf per country: a stamp, a visa, a postcard, its flag, a pin, a label, a sketch. */
|
||||
function CountriesTab({ build, stats, t, place }: TabProps & { stats: JourneyStats }) {
|
||||
if (!stats.countries.length) return <p className="st-hint">{t('journey.studio.noCountries')}</p>
|
||||
return (
|
||||
<>
|
||||
{stats.countries.slice(0, 12).map(c => (
|
||||
<Shelf key={c.code} label={build.countryName(c.code)}>
|
||||
<div className="st-travel-grid">
|
||||
{COUNTRY_LOOKS.map(look => (
|
||||
<TravelCard
|
||||
key={look}
|
||||
el={build.countryEl(c.code, look)}
|
||||
label={t(`journey.studio.countryLook.${look}`)}
|
||||
onPlace={place}
|
||||
minHeight={84}
|
||||
maxHeight={100}
|
||||
inset={look === 'postcard' ? 0.04 : 0.09}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** The marks the journey fills, as stamps, tags and pills. A mark it cannot fill is not offered. */
|
||||
function MarksTab({ build, t, place }: TabProps) {
|
||||
const label = (variant: (typeof MARK_VARIANTS)[number]) => t(`journey.studio.${variant}Mark`)
|
||||
return (
|
||||
<>
|
||||
{MARK_STYLES.map(({ style, variants }) => {
|
||||
const cards = variants.flatMap(variant => {
|
||||
const el = build.markEl(variant, style)
|
||||
return el ? [{ el, variant }] : []
|
||||
})
|
||||
if (!cards.length) return null
|
||||
return (
|
||||
<Shelf key={style} label={t(`journey.studio.markStyle.${style}`)}>
|
||||
<div className="st-travel-grid">
|
||||
{cards.map(({ el, variant }) => (
|
||||
<TravelCard
|
||||
key={variant}
|
||||
el={el}
|
||||
label={label(variant)}
|
||||
onPlace={place}
|
||||
minHeight={style === 'stamp' ? 56 : 38}
|
||||
maxHeight={style === 'stamp' ? 76 : 54}
|
||||
inset={style === 'stamp' || style === 'sticker' ? 0.12 : 0.08}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -108,6 +108,40 @@ describe('a text drawn as SVG', () => {
|
||||
expect(Array.from(clip?.querySelectorAll('tspan') ?? []).map((t) => t.textContent)).toEqual(['LIS', 'BON']);
|
||||
});
|
||||
|
||||
it('fills the letters with a gradient through a gradient of their own frame', () => {
|
||||
const el = text({
|
||||
fillPaint: {
|
||||
type: 'linear',
|
||||
angle: 90,
|
||||
stops: [
|
||||
{ at: 0, color: '#ff6600' },
|
||||
{ at: 1, color: '#ffcc00' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const { container } = render(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
|
||||
const gradient = container.querySelector('linearGradient');
|
||||
expect(gradient?.getAttribute('gradientUnits')).toBe('userSpaceOnUse');
|
||||
expect(Array.from(gradient?.querySelectorAll('stop') ?? []).map((s) => s.getAttribute('stop-color'))).toEqual([
|
||||
'#ff6600',
|
||||
'#ffcc00',
|
||||
]);
|
||||
const letters = container.querySelector('svg > text');
|
||||
expect(letters?.getAttribute('fill')).toBe(`url(#${gradient?.getAttribute('id')})`);
|
||||
});
|
||||
|
||||
it('fills the letters with a pattern, its ground and its marks clipped to them', () => {
|
||||
const el = text({
|
||||
fillPaint: { type: 'pattern', pattern: 'stripes', fg: '#d62828', bg: '#ffffff', scale: 1, angle: 0 },
|
||||
});
|
||||
const { container } = render(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
|
||||
const clip = container.getElementsByTagName('clipPath')[0];
|
||||
const group = container.querySelector('g[clip-path]');
|
||||
expect(group?.getAttribute('clip-path')).toBe(`url(#${clip?.getAttribute('id')})`);
|
||||
expect(group?.querySelector('rect')?.getAttribute('fill')).toBe('#ffffff');
|
||||
expect(group?.querySelectorAll('path[fill="#d62828"]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('gives every copy of a page its own ids, so a page drawn twice never clips to the other copy', () => {
|
||||
const el = text({ fillPaint: { type: 'photo', photoId: 12 }, curve: { type: 'circle' } });
|
||||
const { container } = render(
|
||||
|
||||
@@ -2,10 +2,11 @@ import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useId } from 'react';
|
||||
|
||||
import type { BookTextElement } from '@trek/shared';
|
||||
import { bookDrawnLines, bookTextCurvePath, bookTextIsDrawn } from '@trek/shared';
|
||||
import { bookDrawnLines, bookTextCurvePath, bookTextIsDrawn, bookTextPaintFill } from '@trek/shared';
|
||||
|
||||
import { fontStack } from './bookFonts';
|
||||
import { photoSrc } from './bookRender';
|
||||
import { paintParts, type PaintParts } from './paint/paintParts';
|
||||
import {
|
||||
caseStyle,
|
||||
curveAnchor,
|
||||
@@ -145,15 +146,46 @@ interface GlyphPaint {
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
/** A gradient or pattern in the letters, laid out over the frame as a shape's paint is over its box. */
|
||||
function lettersPaint(el: BookTextElement, id: string): PaintParts | null {
|
||||
const paint = bookTextPaintFill(el);
|
||||
return paint ? paintParts(paint, { x: 0, y: 0, w: el.frame.w, h: el.frame.h }, id) : null;
|
||||
}
|
||||
|
||||
/** What the letters are filled with: the paint's fill, else their own colour. */
|
||||
function lettersFill(el: BookTextElement, paint: PaintParts | null): string {
|
||||
if (paint) return paint.fill;
|
||||
return letterFill(el) ?? el.color;
|
||||
}
|
||||
|
||||
/** A pattern's ground and its marks, clipped to the letters. */
|
||||
function PatternLetters({ parts, w, h, clipId }: { parts: PaintParts; w: number; h: number; clipId: string }) {
|
||||
const pattern = parts.pattern;
|
||||
if (!pattern) return null;
|
||||
return (
|
||||
<g clipPath={`url(#${clipId})`}>
|
||||
{parts.fill !== 'none' && <rect width={w} height={h} fill={parts.fill} />}
|
||||
<g transform={pattern.transform}>
|
||||
{pattern.layers.map((layer, i) => (
|
||||
<path key={i} d={layer.d} fill={pattern.ink} fillOpacity={layer.opacity < 1 ? layer.opacity : undefined} />
|
||||
))}
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: CSSProperties; big: boolean }) {
|
||||
const reactId = useId();
|
||||
const pathId = svgId('tp-', reactId);
|
||||
const clipId = svgId('tc-', reactId);
|
||||
const paintId = svgId('tf-', reactId);
|
||||
const { w, h } = el.frame;
|
||||
const fontMm = ptToMm(el.size);
|
||||
const lines = bookDrawnLines(el);
|
||||
const curve = el.curve ? bookTextCurvePath(el.curve, w, h, fontMm) : null;
|
||||
const photo = el.fillPaint?.type === 'photo' ? el.fillPaint : null;
|
||||
const letterPaint = lettersPaint(el, paintId);
|
||||
const pattern = letterPaint ? letterPaint.pattern : null;
|
||||
const hollow = isHollow(el);
|
||||
const { outline, shadow } = el;
|
||||
|
||||
@@ -211,8 +243,10 @@ function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: C
|
||||
if (photo) {
|
||||
const cover = photoCover(w, h, photo);
|
||||
layers.push(<image key="photo" href={photoSrc(photo.photoId, big)} {...cover} clipPath={`url(#${clipId})`} />);
|
||||
} else if (pattern && letterPaint) {
|
||||
layers.push(<PatternLetters key="pattern" parts={letterPaint} w={w} h={h} clipId={clipId} />);
|
||||
} else {
|
||||
layers.push(glyphs({ fill: letterFill(el) ?? el.color }, 'fill'));
|
||||
layers.push(glyphs({ fill: lettersFill(el, letterPaint) }, 'fill'));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -227,7 +261,8 @@ function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: C
|
||||
>
|
||||
<defs>
|
||||
{curve && <path id={pathId} d={curve.d} fill="none" />}
|
||||
{photo && (
|
||||
{letterPaint?.defs}
|
||||
{(photo || pattern) && (
|
||||
<clipPath id={clipId} clipPathUnits="userSpaceOnUse">
|
||||
{glyphs(null, 'clip')}
|
||||
</clipPath>
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import type {
|
||||
BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement,
|
||||
BookMetric, BookStatsElement, BookUnits,
|
||||
BookStatsElement,
|
||||
} from '@trek/shared'
|
||||
import {
|
||||
Camera, CalendarDays, Footprints, Globe, MapPin, Navigation, Route,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { fontStack, lighterWeight, nearestWeight } from './bookFonts'
|
||||
import { fontStack } from './bookFonts'
|
||||
import { heavy, inkAt as rgba, light, METRIC_ICONS, metricValue, round2 } from './travel/travelInk'
|
||||
import { BadgeLookView } from './travel/BadgeLooks'
|
||||
import { CountryLookView } from './travel/CountryLooks'
|
||||
import { StatsLookView } from './travel/StatsLooks'
|
||||
import { badgeIconShare, badgeInk, inkOn, isBadgeLook } from './travel/badgeLook'
|
||||
import { BadgeIcon } from './travel/BadgeIcon'
|
||||
import { EndMarker } from './travel/EndMarker'
|
||||
import { FlagMark } from './travel/FlagMark'
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import { brightness } from './folioColour'
|
||||
import { COUNTRY_SHAPES, countryParts, countryWorldPath, projectMercator, unprojectMercator } from './countryShapes'
|
||||
import { projectOntoTiles, tileView, usableStaticUrl } from './mapTiles'
|
||||
import { useCartoApiKey } from '../../hooks/useTileUrl'
|
||||
import { FLAG_H, FLAG_W, flagBands, flagDisc, flagSpec } from './flags'
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
/**
|
||||
@@ -71,8 +72,6 @@ function typeSize(el: TravelElement, base: number): number {
|
||||
return Math.max(1.2, el.frame.h * base * el.textScale)
|
||||
}
|
||||
|
||||
/** Two decimals of a millimetre, matching what the document stores. */
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100
|
||||
/** Tile geometry, kept finer than the rest: see the note where it is used. */
|
||||
const round3 = (n: number) => Math.round(n * 1000) / 1000
|
||||
|
||||
@@ -100,12 +99,6 @@ const round3 = (n: number) => Math.round(n * 1000) / 1000
|
||||
*/
|
||||
const tileOverlapMm = (size: number) => Math.max(0.8, size * 0.04)
|
||||
|
||||
/** #rrggbb plus an alpha. */
|
||||
function rgba(hex: string, alpha: number): string {
|
||||
const n = Number.parseInt(hex.slice(1), 16)
|
||||
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`
|
||||
}
|
||||
|
||||
export function TravelElementView({ el, frameStyle, big = false }: {
|
||||
el: BookElement
|
||||
frameStyle: CSSProperties
|
||||
@@ -113,9 +106,22 @@ export function TravelElementView({ el, frameStyle, big = false }: {
|
||||
big?: boolean
|
||||
}) {
|
||||
if (el.kind === 'map') return <MapView el={el} frameStyle={frameStyle} big={big} />
|
||||
if (el.kind === 'stats') return <StatsView el={el} frameStyle={frameStyle} />
|
||||
if (el.kind === 'countries') return <CountriesView el={el} frameStyle={frameStyle} />
|
||||
if (el.kind === 'badge') return <BadgeView el={el} frameStyle={frameStyle} />
|
||||
// The newer looks draw in files of their own; `classic` is what every book had before them.
|
||||
if (el.kind === 'stats') {
|
||||
return (el.look ?? 'classic') === 'classic'
|
||||
? <StatsView el={el} frameStyle={frameStyle} />
|
||||
: <StatsLookView el={el} frameStyle={frameStyle} />
|
||||
}
|
||||
if (el.kind === 'countries') {
|
||||
return (el.look ?? 'classic') === 'classic'
|
||||
? <CountriesView el={el} frameStyle={frameStyle} />
|
||||
: <CountryLookView el={el} frameStyle={frameStyle} />
|
||||
}
|
||||
if (el.kind === 'badge') {
|
||||
return isBadgeLook(el.style)
|
||||
? <BadgeLookView el={el} frameStyle={frameStyle} />
|
||||
: <BadgeView el={el} frameStyle={frameStyle} />
|
||||
}
|
||||
if (el.kind === 'list') return <ListView el={el} frameStyle={frameStyle} />
|
||||
return null
|
||||
}
|
||||
@@ -132,17 +138,6 @@ export function TravelElementView({ el, frameStyle, big = false }: {
|
||||
* a slab of ink for no reason. What distinguishes the four styles is how the
|
||||
* land is drawn, which is enough.
|
||||
*/
|
||||
/**
|
||||
* How heavy the type is set, and one step down for the labels under it.
|
||||
*
|
||||
* These elements draw at two levels at once — a figure over its caption, a
|
||||
* country's name over its outline — and the pair has to move together, the way
|
||||
* `textScale` moves their size. Hard-coded 700s meant the only way to lighten a
|
||||
* stats block was to not use one.
|
||||
*/
|
||||
const heavy = (el: { font: string; weight: number }) => nearestWeight(el.font, el.weight)
|
||||
const light = (el: { font: string; weight: number }) => lighterWeight(el.font, el.weight)
|
||||
|
||||
const MAP_PALETTES = {
|
||||
minimal: { land: '#e9e5dc', border: '#d6d0c4', onDark: false },
|
||||
outline: { land: 'transparent', border: '#b9b3a6', onDark: false },
|
||||
@@ -160,28 +155,6 @@ const MAP_PALETTES = {
|
||||
*/
|
||||
const DEFAULT_ACCENT = '#111111' // theme-lint-disable — the contract's default, matched here
|
||||
|
||||
/** Ink for a mark that picks its own, and its opposite. */
|
||||
const BADGE_DARK_INK = '#1c1b19' // theme-lint-disable — ink on a printed page, not a UI token
|
||||
const BADGE_LIGHT_INK = '#ffffff' // theme-lint-disable — paper white on a filled chip
|
||||
|
||||
/**
|
||||
* What colour a mark's words are.
|
||||
*
|
||||
* Automatic unless the element says otherwise, and what automatic means depends
|
||||
* on what the mark is: a chip carries its words on its own fill, so they answer
|
||||
* to the fill's brightness; a day or a date drawn plain takes the accent,
|
||||
* because that is the figure the page is built around. Anything else is
|
||||
* ordinary text in the text colour.
|
||||
*
|
||||
* The moment somebody picks a colour this steps aside completely. That is the
|
||||
* point of the flag: automatic is a good default and a bad cage.
|
||||
*/
|
||||
function badgeInk(el: BookBadgeElement): string {
|
||||
if (!el.autoColor) return el.color
|
||||
if (el.style === 'chip') return brightness(el.accent) > 0.55 ? BADGE_DARK_INK : BADGE_LIGHT_INK
|
||||
if (el.variant === 'day' || el.variant === 'date') return el.accent
|
||||
return el.color
|
||||
}
|
||||
function routeInk(accent: string, onDark: boolean): string {
|
||||
if (!onDark) return accent
|
||||
return accent.toLowerCase() === DEFAULT_ACCENT ? '#f2efe9' : accent // theme-lint-disable — paper on a dark map
|
||||
@@ -512,6 +485,8 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
const S = Math.min(W, H)
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
|
||||
const drawn = el.routeStyle === 'drawn'
|
||||
// Numbered stops and marked ends are markers of the drawn kind, whatever the line is drawn as.
|
||||
const drawnPins = drawn || el.pinStyle === 'numbered' || el.pinStyle === 'ends'
|
||||
const coreW = clamp(S * 0.0055, 0.32, 1.3)
|
||||
const casingW = coreW * 1.9
|
||||
const beadR = clamp(coreW * 3.1, 1.7, 4.8)
|
||||
@@ -528,7 +503,7 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
* so it inverts with it: dark on a light line, light on a dark one. Measured
|
||||
* the same way the marks decide their ink.
|
||||
*/
|
||||
const numInk = brightness(el.accent) > 0.55 ? '#1c1b19' : '#ffffff' // theme-lint-disable — print ink
|
||||
const numInk = inkOn(el.accent)
|
||||
const dashOn = coreW * 2.4
|
||||
const dashOff = coreW * 1.8
|
||||
|
||||
@@ -748,16 +723,16 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
*/
|
||||
const beads: { i: number; x: number; y: number; photo: number | null }[] = []
|
||||
let numbered = false
|
||||
if (drawn) {
|
||||
if (drawnPins) {
|
||||
// Too small to carry photographs at all: a bead wider than a twelfth of the
|
||||
// frame is a picture with a map around it.
|
||||
const roomForPhotos = el.pinStyle === 'photo' && big && beadR * 2 <= S * 0.16
|
||||
/*
|
||||
* Numbers stand in for the photographs that are not there, so they belong
|
||||
* to the photo treatment alone. A map asked for dots gets dots, and a
|
||||
* thumbnail gets dots too: two digits at 130px is a smudge.
|
||||
* Numbers stand in for the photographs that are not there, and a map asked
|
||||
* for numbers numbers every stop. A map asked for dots gets dots, and a
|
||||
* photo map's thumbnail gets dots too: two digits at 130px is a smudge.
|
||||
*/
|
||||
numbered = el.pinStyle === 'photo' && big && numR * 2 <= S * 0.12
|
||||
numbered = (el.pinStyle === 'numbered' || (el.pinStyle === 'photo' && big)) && numR * 2 <= S * 0.12
|
||||
let last: { x: number; y: number } | null = null
|
||||
route.forEach((pt, i) => {
|
||||
const photoId = (el.points[i] as { photoId?: number | null } | undefined)?.photoId ?? null
|
||||
@@ -979,7 +954,7 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
</>
|
||||
)}
|
||||
|
||||
{el.showPins && !drawn && route.map((p, i) => (
|
||||
{el.showPins && !drawnPins && route.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={p.x}
|
||||
@@ -1003,8 +978,10 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
is also what a photograph that fails to load degrades to, so the paper
|
||||
shows a deliberate marker rather than a hole.
|
||||
*/}
|
||||
{el.showPins && drawn && beads.map(m => (
|
||||
m.photo != null ? (
|
||||
{el.showPins && drawnPins && beads.map(m => (
|
||||
el.pinStyle === 'ends' && (m.i === 0 || m.i === route.length - 1) ? (
|
||||
<EndMarker key={m.i} x={m.x} y={m.y} r={numR} finish={m.i > 0} ink={coreInk} paper={numInk} />
|
||||
) : m.photo != null ? (
|
||||
<g key={m.i}>
|
||||
{/* The shadow, as two translucent discs rather than a filter. */}
|
||||
<circle cx={m.x} cy={m.y + coreW * 0.3} r={beadR + coreW * 0.55} fill="#000000" fillOpacity={0.1} />
|
||||
@@ -1127,49 +1104,6 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
|
||||
/* ── Stats ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The icon beside each figure.
|
||||
*
|
||||
* lucide, like every other icon in TREK — a hand-drawn set was the first pass
|
||||
* and it was the wrong call twice over: it put a second drawing vocabulary next
|
||||
* to the one the whole app already uses, and it meant seven paths nobody would
|
||||
* ever revisit when lucide gained a better glyph.
|
||||
*
|
||||
* They size in millimetres despite lucide taking a pixel `size`, because an
|
||||
* inline width and height override the attributes it writes while the viewBox
|
||||
* stays 24×24 — so the stroke scales with the drawing instead of thinning out
|
||||
* on a large panel.
|
||||
*/
|
||||
const METRIC_ICONS: Record<BookMetric, LucideIcon> = {
|
||||
distance: Route,
|
||||
days: CalendarDays,
|
||||
steps: Footprints,
|
||||
photos: Camera,
|
||||
countries: Globe,
|
||||
places: MapPin,
|
||||
furthest: Navigation,
|
||||
}
|
||||
|
||||
function metricValue(
|
||||
metric: BookMetric,
|
||||
values: Record<string, number>,
|
||||
units: BookUnits,
|
||||
locale: string,
|
||||
): { value: string; unit: string } {
|
||||
const raw = values[metric] ?? 0
|
||||
const n = (v: number, digits = 0) =>
|
||||
new Intl.NumberFormat(locale, { maximumFractionDigits: digits }).format(v)
|
||||
|
||||
if (metric === 'distance' || metric === 'furthest') {
|
||||
// Stored in metres and converted here, so switching units never rounds a
|
||||
// second time on top of a rounded number.
|
||||
const km = raw / 1000
|
||||
const shown = units === 'imperial' ? km * 0.621371 : km
|
||||
return { value: n(shown, shown < 100 ? 1 : 0), unit: units === 'imperial' ? 'mi' : 'km' }
|
||||
}
|
||||
return { value: n(raw), unit: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip summary — the page Polarsteps' books open on, and the one that makes
|
||||
* a book feel like a record of a journey rather than a folder of pictures.
|
||||
@@ -1404,75 +1338,6 @@ function CountriesView({ el, frameStyle }: { el: BookCountriesElement; frameStyl
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A flag, drawn.
|
||||
*
|
||||
* The first version set a regional-indicator pair and let the platform draw it.
|
||||
* That is the standard trick and it is wrong for most of the people who will
|
||||
* use this: Chrome on Windows ships no flag glyphs, so it renders the two
|
||||
* letters, and "DE" printed in a photo book is not a flag.
|
||||
*
|
||||
* So flags are constructed from `flags.ts` — exact for the stripe, cross and
|
||||
* disc constructions listed there, and absent for anything with an emblem
|
||||
* rather than approximated. A country with no construction falls back to its
|
||||
* silhouette, which is correct for all 198 of them.
|
||||
*/
|
||||
function FlagMark({ code, size }: { code: string; size: number }) {
|
||||
const spec = flagSpec(code)
|
||||
const height = size
|
||||
const width = (height * FLAG_W) / FLAG_H
|
||||
|
||||
if (spec) {
|
||||
const disc = flagDisc(spec)
|
||||
return (
|
||||
<svg
|
||||
width={`${round2(width)}mm`}
|
||||
height={`${round2(height)}mm`}
|
||||
viewBox={`0 0 ${FLAG_W} ${FLAG_H}`}
|
||||
style={{ display: 'block', flex: '0 0 auto', borderRadius: `${round2(height * 0.08)}mm` }}
|
||||
aria-label={code.toUpperCase()}
|
||||
>
|
||||
{flagBands(spec).map((b, i) => (
|
||||
<rect key={i} x={b.x} y={b.y} width={b.w} height={b.h} fill={b.fill} />
|
||||
))}
|
||||
{disc && <circle cx={disc.cx} cy={disc.cy} r={disc.r} fill={disc.fill} />}
|
||||
{/* A hairline keeps a white-edged flag off a white page. */}
|
||||
<rect x="0" y="0" width={FLAG_W} height={FLAG_H} fill="none" stroke="rgba(0,0,0,.18)" strokeWidth="0.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const shape = COUNTRY_SHAPES[code.toUpperCase()]
|
||||
if (!shape) {
|
||||
// Neither a construction nor an outline: the code, set as a mark rather
|
||||
// than as two stray capitals.
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${round2(size * 0.62)}mm`,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
lineHeight: 1,
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
{code.toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
width={`${round2((height * shape.w) / shape.h)}mm`}
|
||||
height={`${round2(height)}mm`}
|
||||
viewBox={`0 0 ${shape.w} ${shape.h}`}
|
||||
style={{ display: 'block', flex: '0 0 auto' }}
|
||||
aria-label={code.toUpperCase()}
|
||||
>
|
||||
<path d={shape.d} fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Badges ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
@@ -1495,7 +1360,6 @@ function BadgeView({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSPr
|
||||
*/
|
||||
const big = typeSize(el, el.variant === 'date' ? 0.52 : 0.42)
|
||||
const small = typeSize(el, 0.2)
|
||||
const shape = el.code ? COUNTRY_SHAPES[el.code.toUpperCase()] : null
|
||||
const stacked = el.style === 'stacked' || el.variant === 'date'
|
||||
|
||||
const chip = el.style === 'chip'
|
||||
@@ -1511,7 +1375,6 @@ function BadgeView({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSPr
|
||||
*/
|
||||
const showIcon = el.showIcon !== false
|
||||
const showLabel = el.showLabel !== false
|
||||
const iconTint = (automatic: string) => (el.autoIconColor === false ? el.iconColor : automatic)
|
||||
/*
|
||||
* A mark with no words is a pictogram, and a pictogram should fill its box.
|
||||
* Beside a label the icon is a share of the height; on its own it takes four
|
||||
@@ -1544,54 +1407,12 @@ function BadgeView({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSPr
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{el.variant === 'flag' && el.code && showIcon && (
|
||||
<FlagMark code={el.code} size={el.frame.h * (showLabel ? 0.5 : iconScale)} />
|
||||
)}
|
||||
|
||||
{/*
|
||||
Mood and weather come from the journal entry, and they are drawn with
|
||||
the *same* icon and the same palette the journey page uses — imported
|
||||
rather than re-listed, so a mood added to TREK appears here without
|
||||
anyone remembering to add it twice.
|
||||
The picture: a flag, an outline, or the journal's own mood and weather
|
||||
icons, imported rather than re-listed so a mood added to TREK appears
|
||||
here without anyone adding it twice.
|
||||
*/}
|
||||
{(el.variant === 'mood' || el.variant === 'weather') && el.code && showIcon && (() => {
|
||||
// Kept apart rather than narrowed out of one union: only a mood has a
|
||||
// colour of its own, and `'text' in config` types it as unknown.
|
||||
const mood = el.variant === 'mood' ? MOOD_CONFIG[el.code] : null
|
||||
const config = mood ?? WEATHER_CONFIG[el.code]
|
||||
if (!config) return null
|
||||
const Icon = config.icon
|
||||
const size = el.frame.h * iconScale
|
||||
/*
|
||||
* Automatic is the journal's own palette for a mood and the element's
|
||||
* accent for the weather. Note the colour has to be passed rather than
|
||||
* inherited: lucide writes it onto the SVG's stroke, so `currentColor`
|
||||
* from the wrapper never reaches it — which is why the Colour swatch
|
||||
* appeared to do nothing to a mood icon.
|
||||
*/
|
||||
const tint = iconTint(mood ? mood.text : el.accent)
|
||||
return (
|
||||
<Icon
|
||||
color={tint}
|
||||
strokeWidth={1.7}
|
||||
style={{ width: `${round2(size)}mm`, height: `${round2(size)}mm`, display: 'block', flex: '0 0 auto' }}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
{el.variant === 'country' && shape && showIcon && (() => {
|
||||
const h = el.frame.h * (showLabel ? 0.72 : iconScale)
|
||||
return (
|
||||
<svg
|
||||
width={`${round2((h * shape.w) / shape.h)}mm`}
|
||||
height={`${round2(h)}mm`}
|
||||
viewBox={`0 0 ${shape.w} ${shape.h}`}
|
||||
style={{ display: 'block', flex: '0 0 auto' }}
|
||||
>
|
||||
<path d={shape.d} fill={iconTint(el.accent)} />
|
||||
</svg>
|
||||
)
|
||||
})()}
|
||||
{showIcon && <BadgeIcon el={el} height={el.frame.h * (showLabel ? badgeIconShare(el.variant) : iconScale)} />}
|
||||
|
||||
{el.text && showLabel && (
|
||||
<span
|
||||
|
||||
@@ -27,10 +27,16 @@ import { ElementView } from './SpreadView'
|
||||
/** CSS defines 1in as 96px and 25.4mm, so this factor is exact. */
|
||||
const PX_PER_MM = 96 / 25.4
|
||||
|
||||
export function TravelPreview({ el, minHeight = 30, maxHeight = 90 }: {
|
||||
export function TravelPreview({ el, minHeight = 30, maxHeight = 90, inset = 0 }: {
|
||||
el: BookElement
|
||||
minHeight?: number
|
||||
maxHeight?: number
|
||||
/**
|
||||
* Room kept round the element, as a share of the tile: a stamp is set a few
|
||||
* degrees off square and a medal's ring runs to its edge, and drawn edge to
|
||||
* edge they would be clipped by the tile.
|
||||
*/
|
||||
inset?: number
|
||||
}) {
|
||||
const box = useElementSize<HTMLSpanElement>()
|
||||
const width = box.width
|
||||
@@ -48,7 +54,8 @@ export function TravelPreview({ el, minHeight = 30, maxHeight = 90 }: {
|
||||
const drawnW = el.frame.w * PX_PER_MM
|
||||
const drawnH = el.frame.h * PX_PER_MM
|
||||
const height = Math.round(Math.min(maxHeight, Math.max(minHeight, (width * drawnH) / drawnW || minHeight)))
|
||||
const scale = width > 0 ? Math.min(width / drawnW, height / drawnH) : 0
|
||||
const room = 1 - 2 * inset
|
||||
const scale = width > 0 ? Math.min((width * room) / drawnW, (height * room) / drawnH) : 0
|
||||
|
||||
return (
|
||||
<span className="st-travel-preview" style={{ height }} ref={box.ref} aria-hidden>
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ReactNode } from 'react';
|
||||
|
||||
import { useStudioStore } from '../../../store/studioStore';
|
||||
import { PhotoCropExtras, PhotoLookSections } from '../photo/StudioPhotoLook';
|
||||
import { FRAME_SHAPES } from '../shapes';
|
||||
import { FRAME_SHAPES, shapeName } from '../shapes';
|
||||
import { Choice, Line, NumField, Section } from '../StudioControls';
|
||||
import { EdgeSection } from '../StudioEdgeControls';
|
||||
import { ShapeGlyph } from '../StudioElementsPanel';
|
||||
@@ -178,7 +178,8 @@ export function StudioPhotoInspector({
|
||||
key={sh}
|
||||
className={`st-mini-shape ${el.mask === sh ? 'is-on' : ''}`}
|
||||
onClick={() => set({ mask: sh })}
|
||||
aria-label={sh}
|
||||
title={shapeName(sh, t)}
|
||||
aria-label={shapeName(sh, t)}
|
||||
aria-pressed={el.mask === sh}
|
||||
>
|
||||
<ShapeGlyph shape={sh} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BookElement, BookShapeElement, BookShapeId, BookTheme } from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { SHAPE_GROUPS } from '../shapes';
|
||||
import { SHAPE_GROUPS, shapeName } from '../shapes';
|
||||
import { NumField, Section } from '../StudioControls';
|
||||
import { EdgeSection } from '../StudioEdgeControls';
|
||||
import { ShapeGlyph } from '../StudioElementsPanel';
|
||||
@@ -41,7 +41,8 @@ export function StudioShapeInspector({
|
||||
key={sh}
|
||||
className={`st-mini-shape ${el.shape === sh ? 'is-on' : ''}`}
|
||||
onClick={() => set({ shape: sh })}
|
||||
aria-label={sh}
|
||||
title={shapeName(sh, t)}
|
||||
aria-label={shapeName(sh, t)}
|
||||
aria-pressed={el.shape === sh}
|
||||
>
|
||||
<ShapeGlyph shape={sh} />
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { sortedStops, stopAlpha, type BookPaint, type BookPaintStop } from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { linearLine, r2 } from './paintGeometry';
|
||||
import { patternGeometry, type PatternBox } from './patterns';
|
||||
|
||||
/**
|
||||
* A paint as SVG parts over a box (paintRender.tsx explains why the ids come
|
||||
* from useId): kept apart from the components that draw them, so a shape and
|
||||
* the letters of a text share it without the component file exporting a helper.
|
||||
*/
|
||||
|
||||
/** A gradient's stops as SVG elements, in order. */
|
||||
function stopElements(stops: readonly BookPaintStop[]): ReactNode {
|
||||
// Two stops may share an offset (a hard edge), so the index keys them.
|
||||
return sortedStops(stops).map((s, i) => (
|
||||
<stop key={i} offset={r2(s.at)} stopColor={s.color} stopOpacity={stopAlpha(s) < 1 ? stopAlpha(s) : undefined} />
|
||||
));
|
||||
}
|
||||
|
||||
/** What a painted outline needs: the defs to put in the SVG, and how to fill the outline. */
|
||||
export interface PaintParts {
|
||||
defs: ReactNode;
|
||||
fill: string;
|
||||
fillOpacity?: number;
|
||||
/** A pattern's geometry, to draw clipped to the outline over its fill. */
|
||||
pattern: { clipId: string; layers: { d: string; opacity: number }[]; transform?: string; ink: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What drawing a paint over a box takes: the gradient definition and the fill
|
||||
* to point at it, or a pattern's geometry to draw clipped to the outline over
|
||||
* its ground. A shape's outline and the letters of a text both draw from it.
|
||||
*/
|
||||
export function paintParts(paint: BookPaint, box: PatternBox, id: string): PaintParts {
|
||||
switch (paint.type) {
|
||||
case 'solid':
|
||||
return {
|
||||
defs: null,
|
||||
fill: paint.color,
|
||||
fillOpacity: stopAlpha(paint) < 1 ? stopAlpha(paint) : undefined,
|
||||
pattern: null,
|
||||
};
|
||||
case 'linear': {
|
||||
const line = linearLine(paint.angle, box);
|
||||
return {
|
||||
defs: (
|
||||
<linearGradient id={id} gradientUnits="userSpaceOnUse" {...line}>
|
||||
{stopElements(paint.stops)}
|
||||
</linearGradient>
|
||||
),
|
||||
fill: `url(#${id})`,
|
||||
pattern: null,
|
||||
};
|
||||
}
|
||||
case 'radial':
|
||||
return {
|
||||
defs: (
|
||||
<radialGradient
|
||||
id={id}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx={r2(box.x + paint.cx * box.w)}
|
||||
cy={r2(box.y + paint.cy * box.h)}
|
||||
r={r2((paint.r * Math.hypot(box.w, box.h)) / 2)}
|
||||
>
|
||||
{stopElements(paint.stops)}
|
||||
</radialGradient>
|
||||
),
|
||||
fill: `url(#${id})`,
|
||||
pattern: null,
|
||||
};
|
||||
case 'pattern': {
|
||||
const geo = patternGeometry(paint, box);
|
||||
return {
|
||||
defs: null,
|
||||
fill: paint.bg ?? 'none',
|
||||
pattern: { clipId: `${id}-clip`, layers: geo.layers, transform: geo.transform, ink: paint.fg },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { sortedStops, stopAlpha, type BookPaint, type BookPaintStop } from '@trek/shared';
|
||||
import { useMemo, type ReactNode, type SVGProps } from 'react';
|
||||
import { linearLine, r2 } from './paintGeometry';
|
||||
import { patternGeometry, type PatternBox } from './patterns';
|
||||
import type { BookPaint } from '@trek/shared';
|
||||
import { useMemo, type SVGProps } from 'react';
|
||||
import { r2 } from './paintGeometry';
|
||||
import { paintParts } from './paintParts';
|
||||
import type { PatternBox } from './patterns';
|
||||
import { usePaintId } from './usePaintId';
|
||||
|
||||
/**
|
||||
@@ -25,79 +26,6 @@ import { usePaintId } from './usePaintId';
|
||||
* element's own id is kept in front only to make the markup readable.
|
||||
*/
|
||||
|
||||
function Stops({ stops }: { stops: readonly BookPaintStop[] }) {
|
||||
return (
|
||||
<>
|
||||
{sortedStops(stops).map((s, i) => (
|
||||
<stop
|
||||
// Two stops may share an offset (a hard edge), so the index keys them.
|
||||
key={i}
|
||||
offset={r2(s.at)}
|
||||
stopColor={s.color}
|
||||
stopOpacity={stopAlpha(s) < 1 ? stopAlpha(s) : undefined}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a painted outline needs: the defs to put in the SVG, and how to fill the outline. */
|
||||
interface PaintParts {
|
||||
defs: ReactNode;
|
||||
fill: string;
|
||||
fillOpacity?: number;
|
||||
/** A pattern's geometry, to draw clipped to the outline over its fill. */
|
||||
pattern: { clipId: string; layers: { d: string; opacity: number }[]; transform?: string; ink: string } | null;
|
||||
}
|
||||
|
||||
function paintParts(paint: BookPaint, box: PatternBox, id: string): PaintParts {
|
||||
switch (paint.type) {
|
||||
case 'solid':
|
||||
return {
|
||||
defs: null,
|
||||
fill: paint.color,
|
||||
fillOpacity: stopAlpha(paint) < 1 ? stopAlpha(paint) : undefined,
|
||||
pattern: null,
|
||||
};
|
||||
case 'linear': {
|
||||
const line = linearLine(paint.angle, box);
|
||||
return {
|
||||
defs: (
|
||||
<linearGradient id={id} gradientUnits="userSpaceOnUse" {...line}>
|
||||
<Stops stops={paint.stops} />
|
||||
</linearGradient>
|
||||
),
|
||||
fill: `url(#${id})`,
|
||||
pattern: null,
|
||||
};
|
||||
}
|
||||
case 'radial':
|
||||
return {
|
||||
defs: (
|
||||
<radialGradient
|
||||
id={id}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx={r2(box.x + paint.cx * box.w)}
|
||||
cy={r2(box.y + paint.cy * box.h)}
|
||||
r={r2((paint.r * Math.hypot(box.w, box.h)) / 2)}
|
||||
>
|
||||
<Stops stops={paint.stops} />
|
||||
</radialGradient>
|
||||
),
|
||||
fill: `url(#${id})`,
|
||||
pattern: null,
|
||||
};
|
||||
case 'pattern': {
|
||||
const geo = patternGeometry(paint, box);
|
||||
return {
|
||||
defs: null,
|
||||
fill: paint.bg ?? 'none',
|
||||
pattern: { clipId: `${id}-clip`, layers: geo.layers, transform: geo.transform, ink: paint.fg },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type OutlineProps = Omit<SVGProps<SVGPathElement>, 'fill' | 'd'>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -46,6 +46,11 @@ interface PatternGeometry {
|
||||
const r2 = (v: number) => Math.round(v * 100) / 100 || 0;
|
||||
const f = (v: number) => String(r2(v));
|
||||
|
||||
/** Threads per cell each way in linen: fine enough to read as cloth, few enough for a whole page under the cap. */
|
||||
const LINEN_THREADS = 6;
|
||||
/** Specks per cell in grain. */
|
||||
const GRAIN_SPECKS = 3;
|
||||
|
||||
/**
|
||||
* How many primitives a repeat needs over a region: `area` primitives per
|
||||
* cell squared, `lin` per cell along the width and the height. Read before
|
||||
@@ -66,6 +71,8 @@ const DENSITY: Record<BookPattern, { area: number; lin: number }> = {
|
||||
scales: { area: 2, lin: 0 },
|
||||
sunburst: { area: 0, lin: 0 },
|
||||
confetti: { area: 1.2, lin: 0 },
|
||||
linen: { area: 0, lin: 2 * LINEN_THREADS },
|
||||
grain: { area: GRAIN_SPECKS, lin: 0 },
|
||||
};
|
||||
|
||||
/** A primitive count estimate for a repeat over a region at a cell size. */
|
||||
@@ -269,6 +276,24 @@ const DRAW: Record<Exclude<BookPattern, 'sunburst'>, Draw> = {
|
||||
]);
|
||||
}
|
||||
},
|
||||
// Threads of uneven weight both ways, on two half-clear layers: where they cross the cloth reads denser.
|
||||
linen: (pen, g, s, alt) => {
|
||||
const rand = mulberry32(Math.round(s * 1000) + 31);
|
||||
const pitch = s / LINEN_THREADS;
|
||||
for (const y of steps(g.y0, g.y1, pitch)) pen.rect(g.x0, y, g.x1 - g.x0, pitch * (0.2 + rand() * 0.35));
|
||||
for (const x of steps(g.x0, g.x1, pitch)) alt.rect(x, g.y0, pitch * (0.2 + rand() * 0.35), g.y1 - g.y0);
|
||||
},
|
||||
// Specks of a few sizes, the larger ones fainter, scattered the same way on every render.
|
||||
grain: (pen, g, s, alt) => {
|
||||
const rand = mulberry32(Math.round(s * 1000) + 43);
|
||||
const n = Math.round((((g.x1 - g.x0) * (g.y1 - g.y0)) / (s * s)) * GRAIN_SPECKS);
|
||||
for (let i = 0; i < n && !pen.full && !alt.full; i++) {
|
||||
const x = g.x0 + rand() * (g.x1 - g.x0);
|
||||
const y = g.y0 + rand() * (g.y1 - g.y0);
|
||||
if (rand() < 0.6) pen.circle(x, y, s * (0.012 + rand() * 0.012));
|
||||
else alt.circle(x, y, s * (0.025 + rand() * 0.02));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** A seeded generator, so a confetti page draws the same scatter on every render and in print. */
|
||||
@@ -307,6 +332,13 @@ function sunburst(pen: Pen, box: PatternBox, cell: number, angle: number) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The repeats drawn on two layers, and how clear each layer is: textures stay quiet over their ground. */
|
||||
const LAYER_OPACITY: Partial<Record<BookPattern, readonly [number, number]>> = {
|
||||
gingham: [0.5, 0.5],
|
||||
linen: [0.32, 0.32],
|
||||
grain: [0.5, 0.28],
|
||||
};
|
||||
|
||||
/**
|
||||
* A repeat over a box. The geometry covers the box however it is turned: the
|
||||
* region drawn is the square around the box's circumscribed circle, and the
|
||||
@@ -338,17 +370,18 @@ export function patternGeometry(
|
||||
return { layers: [{ d: pen.d, opacity: 1 }], count: pen.count, cell };
|
||||
}
|
||||
|
||||
// The second layer only ever holds gingham's cross bands; the cell fitted above keeps both under the cap together.
|
||||
// The second layer holds gingham's and linen's cross bands and grain's larger specks;
|
||||
// the cell fitted above keeps both under the cap together.
|
||||
const alt = new Pen(cap);
|
||||
DRAW[paint.pattern](pen, region, cell, alt);
|
||||
const count = pen.count + alt.count;
|
||||
const layers =
|
||||
paint.pattern === 'gingham'
|
||||
? [
|
||||
{ d: pen.d, opacity: 0.5 },
|
||||
{ d: alt.d, opacity: 0.5 },
|
||||
]
|
||||
: [{ d: pen.d, opacity: 1 }];
|
||||
const two = LAYER_OPACITY[paint.pattern];
|
||||
const layers = two
|
||||
? [
|
||||
{ d: pen.d, opacity: two[0] },
|
||||
{ d: alt.d, opacity: two[1] },
|
||||
]
|
||||
: [{ d: pen.d, opacity: 1 }];
|
||||
return {
|
||||
layers: layers.filter((l) => l.d),
|
||||
count,
|
||||
|
||||
@@ -330,3 +330,8 @@ export const HOLED_SHAPES = new Set<BookShapeId>(['gear', 'postmark', 'label-tab
|
||||
*/
|
||||
export const SHAPE_GROUPS = BOOK_SHAPE_GROUPS
|
||||
export const FRAME_SHAPES = BOOK_FRAME_SHAPES
|
||||
|
||||
/** A shape's name in the reader's language, for its tooltip and a screen reader. */
|
||||
export function shapeName(shape: BookShapeId, t: (k: string) => string): string {
|
||||
return t(`journey.studio.shapeName.${shape}`)
|
||||
}
|
||||
|
||||
@@ -106,6 +106,9 @@ export function StudioPagesPanel({
|
||||
folios={folios}
|
||||
fold={bookView}
|
||||
className="st-thumb-sheet"
|
||||
coverInside={t(
|
||||
sp.role === 'last' ? 'journey.studio.backCoverInside' : 'journey.studio.coverInside'
|
||||
)}
|
||||
/>
|
||||
<span className="st-thumb-label">
|
||||
{sp.role === 'cover'
|
||||
|
||||
@@ -117,7 +117,7 @@ function BuiltInLayouts({
|
||||
const scale = width / (page.pageWidth * (single ? 1 : 2) * pxPerMm);
|
||||
const templates = single ? COVER_TEMPLATES : TEMPLATES;
|
||||
return (
|
||||
<div className="st-thumbs">
|
||||
<div className={`st-thumbs st-tpl-grid ${single ? 'is-pages' : 'is-spreads'}`}>
|
||||
{templates.map((tpl) => (
|
||||
<button type="button" key={tpl.id} className="st-thumb" onClick={() => onPick(tpl)}>
|
||||
{/* A page is laid out from its top left in either reading direction. */}
|
||||
@@ -183,7 +183,7 @@ export function StudioTemplatesPanel({
|
||||
if (!doc || !spread || !ctx) return null;
|
||||
const single = spread.role !== 'inner';
|
||||
const templates = templatesForSpread(spread, filter);
|
||||
const { full, card: cardWidth } = previewWidths(list.width, single);
|
||||
const { card: cardWidth } = previewWidths(list.width, single);
|
||||
|
||||
/**
|
||||
* One undo step: the page laid out, nothing selected, and the photos at hand
|
||||
@@ -247,8 +247,11 @@ export function StudioTemplatesPanel({
|
||||
</div>
|
||||
<p className="st-hint">{t('studio.templates.hint')}</p>
|
||||
</div>
|
||||
<StudioSavedLayouts page={page} width={full} pxPerMm={pxPerMm} t={t} />
|
||||
<BuiltInLayouts spread={spread} page={page} pxPerMm={pxPerMm} width={full} t={t} onPick={pickBuiltIn} />
|
||||
<StudioSavedLayouts page={page} width={cardWidth} pxPerMm={pxPerMm} t={t} />
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.builtInLayouts')}</div>
|
||||
<BuiltInLayouts spread={spread} page={page} pxPerMm={pxPerMm} width={cardWidth} t={t} onPick={pickBuiltIn} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,7 @@ export function withAlpha(hex: string, alpha = 1): string {
|
||||
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${Math.max(0, alpha)})`;
|
||||
}
|
||||
|
||||
/** The colour the letters are filled with, or null when a photograph fills them. */
|
||||
/** The colour the letters are filled with, or null when a photograph, a gradient or a pattern fills them. */
|
||||
export function letterFill(el: BookTextElement): string | null {
|
||||
const fill = el.fillPaint;
|
||||
if (!fill) return el.color;
|
||||
|
||||
@@ -67,7 +67,11 @@ function ThemeCard({
|
||||
);
|
||||
}
|
||||
|
||||
/** One palette entry: its colour, which is the picker, and its name. */
|
||||
/**
|
||||
* One palette entry: its colour, which is the picker, and its name. The three
|
||||
* accents share one name under them, so each shows its colour alone and says
|
||||
* its own name to a screen reader and on hover.
|
||||
*/
|
||||
function PaletteEntry({
|
||||
name,
|
||||
hex,
|
||||
@@ -79,13 +83,14 @@ function PaletteEntry({
|
||||
onPick: (hex: string) => void;
|
||||
t: StudioT;
|
||||
}) {
|
||||
const accent = name.startsWith('accent');
|
||||
const label = t(`studio.theme.token.${name}`);
|
||||
return (
|
||||
<label className="st-theme-colour">
|
||||
<label className="st-theme-colour" title={label}>
|
||||
<span className="st-theme-chip" style={{ background: hex }}>
|
||||
<input type="color" value={hex} aria-label={label} onChange={(e) => onPick(e.target.value)} />
|
||||
</span>
|
||||
<span className="st-theme-colour-name">{label}</span>
|
||||
{!accent && <span className="st-theme-colour-name">{label}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -181,6 +186,7 @@ export function StudioThemePanel({ t }: { t: StudioT }) {
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
<span className="st-theme-colour-name st-theme-accents-name">{t('studio.theme.accents')}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { BookBadgeElement, BookBadgeVariant } from '@trek/shared';
|
||||
import { CalendarDays, Compass, Flag, MapPin, Mountain, Route, type LucideIcon } from 'lucide-react';
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../../pages/journeyDetail/JourneyDetailPage.constants';
|
||||
import { COUNTRY_SHAPES } from '../countryShapes';
|
||||
import { FlagMark } from './FlagMark';
|
||||
import { mm } from './travelInk';
|
||||
|
||||
/**
|
||||
* The picture of a mark: a flag, a country's outline, or the journal's own
|
||||
* mood and weather icons. Drawn the same in every style a mark can take, so a
|
||||
* flag set as a stamp is the flag set as a chip.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A drawing for the marks that have no picture of their own. A pill always
|
||||
* opens on a disc, and a date in a disc with nothing in it reads as missing.
|
||||
*/
|
||||
const GENERIC: Partial<Record<BookBadgeVariant, LucideIcon>> = {
|
||||
date: CalendarDays,
|
||||
day: Flag,
|
||||
coords: Compass,
|
||||
distance: Route,
|
||||
altitude: Mountain,
|
||||
country: MapPin,
|
||||
flag: Flag,
|
||||
};
|
||||
|
||||
export function BadgeIcon({
|
||||
el,
|
||||
height,
|
||||
generic = false,
|
||||
ink,
|
||||
}: {
|
||||
el: BookBadgeElement;
|
||||
/** The drawn height in millimetres; the width follows the picture's own shape. */
|
||||
height: number;
|
||||
/** Draw a plain icon for a mark that has no picture of its own. */
|
||||
generic?: boolean;
|
||||
/** The automatic colour, for a picture drawn on a fill rather than on the page. */
|
||||
ink?: string;
|
||||
}) {
|
||||
/*
|
||||
* Automatic is the journal's palette for a mood and the accent for anything
|
||||
* else. It is passed rather than inherited: lucide writes the colour onto the
|
||||
* SVG's stroke, so `currentColor` from the wrapper never reaches it.
|
||||
*/
|
||||
const tint = (automatic: string) => (el.autoIconColor === false ? el.iconColor : (ink ?? automatic));
|
||||
const code = el.code;
|
||||
const iconStyle = { width: mm(height), height: mm(height), display: 'block', flex: '0 0 auto' } as const;
|
||||
|
||||
if (el.variant === 'flag' && code) return <FlagMark code={code} size={height} />;
|
||||
|
||||
if ((el.variant === 'mood' || el.variant === 'weather') && code) {
|
||||
// Kept apart rather than narrowed out of one union: only a mood has a colour of its own.
|
||||
const mood = el.variant === 'mood' ? MOOD_CONFIG[code] : null;
|
||||
const config = mood ?? WEATHER_CONFIG[code];
|
||||
if (config) {
|
||||
const Icon = config.icon;
|
||||
return <Icon color={tint(mood ? mood.text : el.accent)} strokeWidth={1.7} style={iconStyle} />;
|
||||
}
|
||||
}
|
||||
|
||||
const shape = el.variant === 'country' && code ? COUNTRY_SHAPES[code.toUpperCase()] : null;
|
||||
if (shape) {
|
||||
return (
|
||||
<svg
|
||||
width={mm((height * shape.w) / shape.h)}
|
||||
height={mm(height)}
|
||||
viewBox={`0 0 ${shape.w} ${shape.h}`}
|
||||
style={{ display: 'block', flex: '0 0 auto' }}
|
||||
>
|
||||
<path d={shape.d} fill={tint(el.accent)} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = generic ? GENERIC[el.variant] : undefined;
|
||||
return Icon ? <Icon color={tint(el.accent)} strokeWidth={1.8} style={iconStyle} /> : null;
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import type { BookBadgeElement, BookBadgeStyle } from '@trek/shared';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { fontStack } from '../bookFonts';
|
||||
import { BadgeIcon } from './BadgeIcon';
|
||||
import { badgeIconShare, badgeInk, inkOn } from './badgeLook';
|
||||
import { hatch, roughBox, seeded } from './sketch';
|
||||
import { heavy, inkAt, light, mm } from './travelInk';
|
||||
|
||||
/**
|
||||
* The newer styles of a mark: an inked passport stamp, a luggage tag with its
|
||||
* eyelet, and a pill that opens on its icon in a disc. The four styles every
|
||||
* mark had before these stay in TravelElements.tsx, so a mark placed then
|
||||
* prints as it always has.
|
||||
*/
|
||||
|
||||
/** The words of a mark, the value over or beside the line under it. */
|
||||
function Words({
|
||||
el,
|
||||
big,
|
||||
small,
|
||||
ink,
|
||||
stacked,
|
||||
start = false,
|
||||
}: {
|
||||
el: BookBadgeElement;
|
||||
big: number;
|
||||
small: number;
|
||||
ink: string;
|
||||
stacked: boolean;
|
||||
/** Stacked lines aligned to the start rather than centred, as in a tag or a pill. */
|
||||
start?: boolean;
|
||||
}) {
|
||||
if (el.showLabel === false) return null;
|
||||
// A mark with no value of its own (a flag, a country) carries its name as the value.
|
||||
const main = el.text || el.sub;
|
||||
const sub = el.text ? el.sub : '';
|
||||
const named = !el.text;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: stacked ? 'column' : 'row',
|
||||
alignItems: stacked ? (start ? 'flex-start' : 'center') : 'baseline',
|
||||
gap: mm(small * (stacked ? 0.3 : 0.6)),
|
||||
minWidth: 0,
|
||||
color: ink,
|
||||
}}
|
||||
>
|
||||
{main && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: mm(el.variant === 'coords' ? big * 0.62 : named ? big * 0.72 : big),
|
||||
fontWeight: heavy(el),
|
||||
lineHeight: 1,
|
||||
letterSpacing: el.variant === 'coords' || named ? '0.08em' : '-0.01em',
|
||||
textTransform: named ? 'uppercase' : undefined,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{main}
|
||||
</span>
|
||||
)}
|
||||
{sub && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: mm(small),
|
||||
fontWeight: light(el),
|
||||
letterSpacing: '0.16em',
|
||||
textTransform: 'uppercase',
|
||||
lineHeight: 1.2,
|
||||
opacity: 0.8,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{sub}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Coordinates carry a long value and the place under it: set on two lines, both stay legible. */
|
||||
const twoLines = (el: BookBadgeElement) => el.variant === 'coords' && !!el.text && !!el.sub;
|
||||
|
||||
/** The frame every style shares: the element's box, its face, centred content. */
|
||||
function Shell({
|
||||
el,
|
||||
frameStyle,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
el: BookBadgeElement;
|
||||
frameStyle: CSSProperties;
|
||||
style: CSSProperties;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...frameStyle,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxSizing: 'border-box',
|
||||
fontFamily: fontStack(el.font),
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A passport stamp: a rounded box ruled twice in the accent, the words in
|
||||
* capitals inside it, turned a few degrees and a shade short of solid, the
|
||||
* way an inked stamp lands.
|
||||
*/
|
||||
function StampMark({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSProperties }) {
|
||||
const { h } = el.frame;
|
||||
const line = Math.max(0.25, h * 0.045);
|
||||
const ink = el.autoColor === false ? el.color : el.accent;
|
||||
return (
|
||||
<Shell
|
||||
el={el}
|
||||
frameStyle={frameStyle}
|
||||
style={{
|
||||
flexDirection: 'column',
|
||||
gap: mm(h * 0.05),
|
||||
border: `${mm(line)} solid ${ink}`,
|
||||
outline: `${mm(line * 0.5)} solid ${ink}`,
|
||||
outlineOffset: mm(-line * 2.6),
|
||||
borderRadius: mm(h * 0.14),
|
||||
padding: `${mm(h * 0.14)} ${mm(h * 0.3)}`,
|
||||
textTransform: 'uppercase',
|
||||
// An inked stamp is a shade short of solid, on top of whatever the element asks.
|
||||
opacity: Number(frameStyle.opacity ?? 1) * 0.9,
|
||||
transform: `${frameStyle.transform ?? ''} rotate(-4deg)`.trim(),
|
||||
}}
|
||||
>
|
||||
{el.showIcon !== false && <BadgeIcon el={el} height={h * 0.26} ink={ink} />}
|
||||
<Words el={el} big={h * 0.3 * el.textScale} small={h * 0.13 * el.textScale} ink={ink} stacked />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A luggage tag: filled in the accent, its leading end cut to a point, with an
|
||||
* eyelet punched through so the page shows in the hole.
|
||||
*/
|
||||
function TagMark({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSProperties }) {
|
||||
const { w, h } = el.frame;
|
||||
const point = Math.min(h * 0.4, w * 0.18);
|
||||
const hole = h * 0.11;
|
||||
const holeX = point * 0.8;
|
||||
const ink = badgeInk(el);
|
||||
const lines = twoLines(el);
|
||||
const mask = `radial-gradient(circle at ${mm(holeX)} 50%, transparent ${mm(hole)}, black ${mm(hole + 0.05)})`;
|
||||
return (
|
||||
<Shell
|
||||
el={el}
|
||||
frameStyle={frameStyle}
|
||||
style={{
|
||||
gap: mm(h * 0.14),
|
||||
background: el.accent,
|
||||
clipPath: `polygon(${mm(point)} 0, 100% 0, 100% 100%, ${mm(point)} 100%, 0 50%)`,
|
||||
WebkitMaskImage: mask,
|
||||
maskImage: mask,
|
||||
paddingInlineStart: mm(point + hole * 1.6),
|
||||
paddingInlineEnd: mm(h * 0.3),
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{el.showIcon !== false && <BadgeIcon el={el} height={h * badgeIconShare(el.variant)} ink={ink} />}
|
||||
<Words
|
||||
el={el}
|
||||
big={h * (lines ? 0.34 : 0.38) * el.textScale}
|
||||
small={h * 0.16 * el.textScale}
|
||||
ink={ink}
|
||||
stacked={lines}
|
||||
start
|
||||
/>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/** A pill: a tinted capsule that opens on the mark's icon in a disc of the accent. */
|
||||
function PillMark({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSProperties }) {
|
||||
const { h } = el.frame;
|
||||
const disc = h * 0.78;
|
||||
const pad = (h - disc) / 2;
|
||||
const lines = twoLines(el);
|
||||
return (
|
||||
<Shell
|
||||
el={el}
|
||||
frameStyle={frameStyle}
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
gap: mm(h * 0.22),
|
||||
padding: mm(pad),
|
||||
paddingInlineEnd: mm(h * 0.42),
|
||||
borderRadius: mm(h / 2),
|
||||
background: inkAt(el.accent, 0.12),
|
||||
}}
|
||||
>
|
||||
{el.showIcon !== false && (
|
||||
<span
|
||||
style={{
|
||||
width: mm(disc),
|
||||
height: mm(disc),
|
||||
flex: '0 0 auto',
|
||||
borderRadius: '50%',
|
||||
background: el.accent,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<BadgeIcon el={el} height={disc * 0.52} ink={inkOn(el.accent)} generic />
|
||||
</span>
|
||||
)}
|
||||
<Words
|
||||
el={el}
|
||||
big={h * (lines ? 0.34 : 0.4) * el.textScale}
|
||||
small={h * 0.17 * el.textScale}
|
||||
ink={el.color}
|
||||
stacked={lines}
|
||||
start
|
||||
/>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A ribbon: a band in the accent across the top with its two ends folded
|
||||
* behind it and cut into swallowtails, the words in capitals on the band.
|
||||
*/
|
||||
function RibbonMark({ el, frameStyle }: MarkProps) {
|
||||
const { w, h } = el.frame;
|
||||
const tail = Math.min(h * 0.7, w * 0.16);
|
||||
const bandH = h * 0.72;
|
||||
const drop = h - bandH;
|
||||
const ink = inkOn(el.accent);
|
||||
const back = inkAt(el.color, 0.6);
|
||||
const end = (x: number, dir: 1 | -1) =>
|
||||
`M${x} ${drop} H${x + dir * tail * 1.4} L${x + dir * tail * 0.9} ${drop + bandH / 2} L${x + dir * tail * 1.4} ${h} H${x} Z`;
|
||||
return (
|
||||
<Shell el={el} frameStyle={frameStyle} style={{ overflow: 'visible', alignItems: 'flex-start' }}>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0, overflow: 'visible' }}
|
||||
aria-hidden
|
||||
>
|
||||
<path d={end(tail * 1.4, -1)} fill={back} />
|
||||
<path d={end(w - tail * 1.4, 1)} fill={back} />
|
||||
<path d={`M${tail * 0.7} ${bandH} L${tail * 1.4} ${bandH} L${tail * 1.4} ${h} Z`} fill={inkAt(el.color, 0.8)} />
|
||||
<path
|
||||
d={`M${w - tail * 0.7} ${bandH} L${w - tail * 1.4} ${bandH} L${w - tail * 1.4} ${h} Z`}
|
||||
fill={inkAt(el.color, 0.8)}
|
||||
/>
|
||||
<rect x={tail * 0.7} y={0} width={w - tail * 1.4} height={bandH} fill={el.accent} />
|
||||
</svg>
|
||||
<span
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: mm(bandH),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: mm(h * 0.14),
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{el.showIcon !== false && <BadgeIcon el={el} height={bandH * 0.5} ink={ink} />}
|
||||
<Words el={el} big={bandH * 0.46 * el.textScale} small={bandH * 0.2 * el.textScale} ink={ink} stacked={false} />
|
||||
</span>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/** A die-cut sticker: the mark in the accent inside its white cut edge, lifted off the page by a soft shadow. */
|
||||
function StickerMark({ el, frameStyle }: MarkProps) {
|
||||
const { h } = el.frame;
|
||||
const edge = h * 0.09;
|
||||
const ink = inkOn(el.accent);
|
||||
return (
|
||||
<Shell
|
||||
el={el}
|
||||
frameStyle={frameStyle}
|
||||
style={{
|
||||
gap: mm(h * 0.16),
|
||||
padding: `0 ${mm(h * 0.34)}`,
|
||||
borderRadius: mm(h * 0.3),
|
||||
background: el.accent,
|
||||
boxShadow: `0 0 0 ${mm(edge)} ${inkOn(el.color)}, 0 ${mm(edge * 0.8)} ${mm(edge * 2.2)} ${inkAt(el.color, 0.28)}`,
|
||||
transform: `${frameStyle.transform ?? ''} rotate(-3deg)`.trim(),
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
{el.showIcon !== false && <BadgeIcon el={el} height={h * badgeIconShare(el.variant)} ink={ink} />}
|
||||
<Words el={el} big={h * 0.4 * el.textScale} small={h * 0.17 * el.textScale} ink={ink} stacked={false} />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An embroidered patch: woven in the accent, a heavy rim round its edge and a
|
||||
* line of stitching inside it, the words sewn in the paper's colour.
|
||||
*/
|
||||
function PatchMark({ el, frameStyle }: MarkProps) {
|
||||
const { h } = el.frame;
|
||||
const ink = inkOn(el.accent);
|
||||
const rim = h * 0.07;
|
||||
return (
|
||||
<Shell
|
||||
el={el}
|
||||
frameStyle={frameStyle}
|
||||
style={{
|
||||
gap: mm(h * 0.16),
|
||||
padding: `0 ${mm(h * 0.4)}`,
|
||||
borderRadius: mm(h * 0.34),
|
||||
background: `repeating-linear-gradient(45deg, ${inkAt(ink, 0.08)} 0 ${mm(h * 0.02)}, transparent ${mm(h * 0.02)} ${mm(h * 0.055)}), ${el.accent}`,
|
||||
boxShadow: `inset 0 0 0 ${mm(rim)} ${inkAt(el.color, 0.35)}, 0 ${mm(rim * 0.4)} ${mm(rim)} ${inkAt(el.color, 0.25)}`,
|
||||
outline: `${mm(h * 0.025)} dashed ${inkAt(ink, 0.85)}`,
|
||||
outlineOffset: mm(-rim * 1.9),
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
{el.showIcon !== false && <BadgeIcon el={el} height={h * badgeIconShare(el.variant) * 0.9} ink={ink} />}
|
||||
<Words el={el} big={h * 0.36 * el.textScale} small={h * 0.15 * el.textScale} ink={ink} stacked={false} />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/** A frame drawn by hand: a freehand box traced twice over pale hatching, the words in the element's face. */
|
||||
function SketchMark({ el, frameStyle }: MarkProps) {
|
||||
const { w, h } = el.frame;
|
||||
const rand = seeded(`${el.variant}:${el.text}:${el.sub}`);
|
||||
const pen = Math.max(0.2, h * 0.03);
|
||||
const wob = h * 0.05;
|
||||
const hand = { rand, wobble: wob };
|
||||
const frame = `${roughBox({ x: pen * 2, y: pen * 2, w: w - pen * 4, h: h - pen * 4 }, hand)} ${roughBox({ x: pen * 3, y: pen * 1.2, w: w - pen * 5, h: h - pen * 3.4 }, hand)}`;
|
||||
const shade = hatch({ x: pen * 3, y: pen * 3, w: w - pen * 6, h: h - pen * 6 }, h * 0.12, {
|
||||
rand,
|
||||
wobble: wob * 0.3,
|
||||
});
|
||||
return (
|
||||
<Shell
|
||||
el={el}
|
||||
frameStyle={frameStyle}
|
||||
style={{ gap: mm(h * 0.14), padding: `0 ${mm(h * 0.3)}`, overflow: 'visible' }}
|
||||
>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0, overflow: 'visible' }}
|
||||
aria-hidden
|
||||
>
|
||||
<path d={shade} fill="none" stroke={el.accent} strokeWidth={pen * 0.6} strokeLinecap="round" opacity={0.35} />
|
||||
<path d={frame} fill="none" stroke={el.color} strokeWidth={pen} strokeLinecap="round" opacity={0.85} />
|
||||
</svg>
|
||||
{el.showIcon !== false && (
|
||||
<span style={{ position: 'relative' }}>
|
||||
<BadgeIcon el={el} height={h * badgeIconShare(el.variant)} generic />
|
||||
</span>
|
||||
)}
|
||||
<span style={{ position: 'relative' }}>
|
||||
<Words el={el} big={h * 0.42 * el.textScale} small={h * 0.18 * el.textScale} ink={el.color} stacked={false} />
|
||||
</span>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkProps {
|
||||
el: BookBadgeElement;
|
||||
frameStyle: CSSProperties;
|
||||
}
|
||||
|
||||
const MARKS: Partial<Record<BookBadgeStyle, (props: MarkProps) => ReactNode>> = {
|
||||
stamp: StampMark,
|
||||
tag: TagMark,
|
||||
pill: PillMark,
|
||||
ribbon: RibbonMark,
|
||||
sticker: StickerMark,
|
||||
patch: PatchMark,
|
||||
sketch: SketchMark,
|
||||
};
|
||||
|
||||
export function BadgeLookView({ el, frameStyle }: MarkProps) {
|
||||
const Mark = MARKS[el.style] ?? PillMark;
|
||||
return <Mark el={el} frameStyle={frameStyle} />;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import type { BookCountriesElement } from '@trek/shared';
|
||||
import { useId, type CSSProperties, type ReactNode } from 'react';
|
||||
import { fontStack } from '../bookFonts';
|
||||
import { FlagCloth, Label, OutlinePath, Pin, SketchCountry, Visa, type CountryArt } from './CountryLooksMore';
|
||||
import { FlagMark } from './FlagMark';
|
||||
import { heavy, inkAt, light, mm } from './travelInk';
|
||||
|
||||
/**
|
||||
* The newer looks of a country: a passport stamp and a postcard here, an entry
|
||||
* stamp, the flag in cloth, a map pin, a luggage label and a sketch in
|
||||
* CountryLooksMore.tsx. The classic
|
||||
* look, the name over its silhouette, stays in TravelElements.tsx, so every
|
||||
* country placed before these existed prints as it always has.
|
||||
*
|
||||
* The panel places one country per element; an element that holds several, as
|
||||
* the old lists did, draws one stamp or card for each, side by side.
|
||||
*/
|
||||
|
||||
interface Country {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A passport stamp: two rings, the name running round the top, the country
|
||||
* code round the bottom between two stars, and the outline in the middle. Set
|
||||
* a few degrees off square and a shade short of solid, the way an inked stamp
|
||||
* lands on a page.
|
||||
*/
|
||||
function Stamp({ el, c, size }: { el: BookCountriesElement; c: Country; size: number }) {
|
||||
const id = useId().replace(/:/g, '');
|
||||
const r = size / 2;
|
||||
const ring = Math.max(0.25, size * 0.025);
|
||||
const textR = r * 0.74;
|
||||
const type = Math.max(1.2, size * 0.125 * el.textScale);
|
||||
const top = `M ${r - textR} ${r} A ${textR} ${textR} 0 0 1 ${r + textR} ${r}`;
|
||||
const bottom = `M ${r - textR * 0.9} ${r} A ${textR * 0.9} ${textR * 0.9} 0 0 0 ${r + textR * 0.9} ${r}`;
|
||||
const name = c.name.toUpperCase();
|
||||
return (
|
||||
<svg
|
||||
width={mm(size)}
|
||||
height={mm(size)}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
style={{ display: 'block', transform: 'rotate(-7deg)', opacity: 0.9 }}
|
||||
>
|
||||
<defs>
|
||||
<path id={`${id}-top`} d={top} />
|
||||
<path id={`${id}-bottom`} d={bottom} />
|
||||
</defs>
|
||||
<circle cx={r} cy={r} r={r - ring} fill="none" stroke={el.accent} strokeWidth={ring} />
|
||||
<circle cx={r} cy={r} r={r * 0.86} fill="none" stroke={el.accent} strokeWidth={ring * 0.45} />
|
||||
<circle cx={r} cy={r} r={r * 0.5} fill="none" stroke={el.accent} strokeWidth={ring * 0.45} />
|
||||
{el.showOutline && <OutlinePath code={c.code} box={r * 0.66} x={r} y={r} fill={el.accent} />}
|
||||
{el.showName && (
|
||||
<text
|
||||
fill={el.accent}
|
||||
style={{
|
||||
fontFamily: fontStack(el.font),
|
||||
fontWeight: heavy(el),
|
||||
fontSize: `${type}px`,
|
||||
letterSpacing: `${type * 0.18}px`,
|
||||
}}
|
||||
>
|
||||
<textPath
|
||||
href={`#${id}-top`}
|
||||
startOffset="50%"
|
||||
textAnchor="middle"
|
||||
lengthAdjust="spacingAndGlyphs"
|
||||
textLength={name.length * type * 0.85 > Math.PI * textR * 0.9 ? Math.PI * textR * 0.9 : undefined}
|
||||
>
|
||||
{name}
|
||||
</textPath>
|
||||
</text>
|
||||
)}
|
||||
<text
|
||||
fill={el.accent}
|
||||
style={{
|
||||
fontFamily: fontStack(el.font),
|
||||
fontWeight: light(el),
|
||||
fontSize: `${type * 0.8}px`,
|
||||
letterSpacing: `${type * 0.3}px`,
|
||||
}}
|
||||
>
|
||||
<textPath href={`#${id}-bottom`} startOffset="50%" textAnchor="middle" dominantBaseline="hanging">
|
||||
{`★ ${c.code.toUpperCase()} ★`}
|
||||
</textPath>
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A postcard: an airmail border of diagonal stripes in the accent and the
|
||||
* ink, the country's outline large on the card, a postage stamp in the corner
|
||||
* with the flag or the code on it, a postmark struck across it, and the name
|
||||
* along the foot.
|
||||
*/
|
||||
function Postcard({ el, c, w, h }: { el: BookCountriesElement; c: Country; w: number; h: number }) {
|
||||
const id = useId().replace(/:/g, '');
|
||||
const edge = Math.min(w, h) * 0.055;
|
||||
const radius = Math.min(w, h) * 0.04;
|
||||
const stripe = edge * 0.9;
|
||||
const stampW = w * 0.2;
|
||||
const stampH = stampW * 1.18;
|
||||
const stampX = w - edge * 2 - stampW;
|
||||
const stampY = edge * 2;
|
||||
const type = Math.max(1.2, Math.min(h * 0.11, (w * 0.6) / Math.max(4, c.name.length * 0.78)) * el.textScale);
|
||||
const outlineBox = Math.min(w * 0.5, h * 0.6);
|
||||
const wave = (y: number) => {
|
||||
const x0 = stampX - stampW * 0.7;
|
||||
const seg = stampW * 0.35;
|
||||
return `M${x0} ${y} q${seg / 2} ${-edge * 0.5} ${seg} 0 t${seg} 0 t${seg} 0 t${seg} 0`;
|
||||
};
|
||||
return (
|
||||
<div style={{ position: 'relative', width: mm(w), height: mm(h) }}>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0 }}
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id={`${id}-air`}
|
||||
patternUnits="userSpaceOnUse"
|
||||
width={stripe * 4}
|
||||
height={stripe * 4}
|
||||
patternTransform="rotate(45)"
|
||||
>
|
||||
<rect x={0} y={0} width={stripe} height={stripe * 4} fill={el.accent} />
|
||||
<rect x={stripe * 2} y={0} width={stripe} height={stripe * 4} fill={el.color} />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect x={0} y={0} width={w} height={h} rx={radius} fill={inkAt(el.accent, 0.08)} />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
fill={`url(#${id}-air)`}
|
||||
d={`M${radius} 0 H${w - radius} Q${w} 0 ${w} ${radius} V${h - radius} Q${w} ${h} ${w - radius} ${h} H${radius} Q0 ${h} 0 ${h - radius} V${radius} Q0 0 ${radius} 0 Z M${edge} ${edge} V${h - edge} H${w - edge} V${edge} Z`}
|
||||
/>
|
||||
{el.showOutline && (
|
||||
<OutlinePath code={c.code} box={outlineBox} x={edge + w * 0.32} y={h * 0.46} fill={el.accent} />
|
||||
)}
|
||||
<rect
|
||||
x={stampX}
|
||||
y={stampY}
|
||||
width={stampW}
|
||||
height={stampH}
|
||||
fill="none"
|
||||
stroke={el.accent}
|
||||
strokeWidth={edge * 0.18}
|
||||
strokeDasharray={`${edge * 0.22} ${edge * 0.22}`}
|
||||
/>
|
||||
<rect
|
||||
x={stampX + edge * 0.35}
|
||||
y={stampY + edge * 0.35}
|
||||
width={stampW - edge * 0.7}
|
||||
height={stampH - edge * 0.7}
|
||||
fill={inkAt(el.accent, 0.14)}
|
||||
/>
|
||||
<circle
|
||||
cx={stampX}
|
||||
cy={stampY + stampH * 0.75}
|
||||
r={stampW * 0.42}
|
||||
fill="none"
|
||||
stroke={inkAt(el.color, 0.45)}
|
||||
strokeWidth={edge * 0.12}
|
||||
/>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<path
|
||||
key={i}
|
||||
d={wave(stampY + stampH * 0.58 + i * edge * 0.8)}
|
||||
fill="none"
|
||||
stroke={inkAt(el.color, 0.45)}
|
||||
strokeWidth={edge * 0.12}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: mm(stampX + edge * 0.35),
|
||||
top: mm(stampY + edge * 0.35),
|
||||
width: mm(stampW - edge * 0.7),
|
||||
height: mm(stampH - edge * 0.7),
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{el.showFlag ? (
|
||||
<FlagMark code={c.code} size={(stampW - edge * 0.7) * 0.5} />
|
||||
) : (
|
||||
<span
|
||||
style={{ fontSize: mm(stampW * 0.3), fontWeight: heavy(el), color: el.accent, letterSpacing: '0.04em' }}
|
||||
>
|
||||
{c.code.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{el.showName && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: mm(edge * 2.2),
|
||||
bottom: mm(edge * 1.9),
|
||||
maxWidth: mm(w * 0.62),
|
||||
fontSize: mm(type),
|
||||
fontWeight: heavy(el),
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
lineHeight: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
color: el.color,
|
||||
}}
|
||||
>
|
||||
{c.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The looks drawn in CountryLooksMore.tsx, by name. */
|
||||
const MORE: Record<string, (props: CountryArt) => ReactNode> = {
|
||||
visa: Visa,
|
||||
flag: FlagCloth,
|
||||
pin: Pin,
|
||||
label: Label,
|
||||
sketch: SketchCountry,
|
||||
};
|
||||
|
||||
export function CountryLookView({ el, frameStyle }: { el: BookCountriesElement; frameStyle: CSSProperties }) {
|
||||
const countries: Country[] = el.codes.map((code, i) => ({ code, name: el.names[i] || code.toUpperCase() }));
|
||||
const n = Math.max(1, countries.length);
|
||||
const gap = Math.min(el.frame.w, el.frame.h) * 0.05;
|
||||
const cellW = (el.frame.w - gap * (n - 1)) / n;
|
||||
const cellH = el.frame.h;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...frameStyle,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(gap),
|
||||
fontFamily: fontStack(el.font),
|
||||
color: el.color,
|
||||
}}
|
||||
>
|
||||
{countries.map((c, i) => {
|
||||
const key = `${c.code}-${i}`;
|
||||
if (el.look === 'stamp') return <Stamp key={key} el={el} c={c} size={Math.min(cellW, cellH)} />;
|
||||
if (el.look === 'postcard') return <Postcard key={key} el={el} c={c} w={cellW} h={cellH} />;
|
||||
const Look = MORE[el.look ?? 'visa'] ?? Visa;
|
||||
return <Look key={key} el={el} code={c.code} name={c.name} w={cellW} h={cellH} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import type { BookCountriesElement } from '@trek/shared';
|
||||
import { useId } from 'react';
|
||||
import { fontStack } from '../bookFonts';
|
||||
import { COUNTRY_SHAPES } from '../countryShapes';
|
||||
import { inkOn } from './badgeLook';
|
||||
import { FlagMark } from './FlagMark';
|
||||
import { hatch, roughLine, seeded } from './sketch';
|
||||
import { heavy, inkAt, light, mm } from './travelInk';
|
||||
|
||||
/**
|
||||
* The country looks drawn after real things: an entry stamp, the flag in
|
||||
* cloth, a map pin, an old luggage label and a pencil sketch. CountryLooks.tsx
|
||||
* picks the look; each draws one country in the element's ink and accent.
|
||||
*/
|
||||
|
||||
export interface CountryArt {
|
||||
el: BookCountriesElement;
|
||||
code: string;
|
||||
name: string;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** A country's outline fitted into a box, centred, keeping its proportions. */
|
||||
export function OutlinePath({
|
||||
code,
|
||||
box,
|
||||
x,
|
||||
y,
|
||||
fill,
|
||||
stroke,
|
||||
strokeWidth,
|
||||
}: {
|
||||
code: string;
|
||||
box: number;
|
||||
x: number;
|
||||
y: number;
|
||||
fill: string;
|
||||
stroke?: string;
|
||||
strokeWidth?: number;
|
||||
}) {
|
||||
const shape = COUNTRY_SHAPES[code.toUpperCase()];
|
||||
if (!shape) return null;
|
||||
const scale = box / Math.max(shape.w, shape.h);
|
||||
const ox = x - (shape.w * scale) / 2;
|
||||
const oy = y - (shape.h * scale) / 2;
|
||||
return (
|
||||
<path
|
||||
d={shape.d}
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth ? strokeWidth / scale : undefined}
|
||||
strokeLinejoin="round"
|
||||
transform={`translate(${ox} ${oy}) scale(${scale})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The name as an element sets it: heavy, tracked capitals, cut short rather than spilling. */
|
||||
function Name({
|
||||
el,
|
||||
name,
|
||||
size,
|
||||
color,
|
||||
spacing = '0.14em',
|
||||
}: {
|
||||
el: BookCountriesElement;
|
||||
name: string;
|
||||
size: number;
|
||||
color: string;
|
||||
spacing?: string;
|
||||
}) {
|
||||
if (!el.showName) return null;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: mm(size),
|
||||
fontWeight: heavy(el),
|
||||
letterSpacing: spacing,
|
||||
textTransform: 'uppercase',
|
||||
lineHeight: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const nameSize = (name: string, width: number, cap: number) =>
|
||||
Math.max(1.2, Math.min(cap, width / Math.max(4, name.length * 0.82)));
|
||||
|
||||
/**
|
||||
* An entry stamp: a box with its corners cut, ruled twice in the accent, a
|
||||
* row of stars over the name and the code under it, set a few degrees off
|
||||
* square and a shade short of solid the way it lands in a passport.
|
||||
*/
|
||||
export function Visa({ el, code, name, w, h }: CountryArt) {
|
||||
const c = Math.min(w, h) * 0.16;
|
||||
const line = Math.max(0.25, Math.min(w, h) * 0.03);
|
||||
const cut = (i: number) => {
|
||||
const o = i * line * 2.2;
|
||||
return `M${c + o} ${o} H${w - c - o} L${w - o} ${c + o} V${h - c - o} L${w - c - o} ${h - o} H${c + o} L${o} ${h - c - o} V${c + o} Z`;
|
||||
};
|
||||
const type = nameSize(name, w * 0.74, h * 0.2) * el.textScale;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: mm(w),
|
||||
height: mm(h),
|
||||
transform: 'rotate(3deg)',
|
||||
opacity: 0.92,
|
||||
color: el.accent,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0, overflow: 'visible' }}
|
||||
aria-hidden
|
||||
>
|
||||
<path d={cut(0.5)} fill="none" stroke={el.accent} strokeWidth={line} />
|
||||
<path d={cut(1.4)} fill="none" stroke={el.accent} strokeWidth={line * 0.45} />
|
||||
{el.showOutline && (
|
||||
<OutlinePath
|
||||
code={code}
|
||||
box={Math.min(w, h) * 0.5}
|
||||
x={w * 0.84 - c * 0.4}
|
||||
y={h * 0.5}
|
||||
fill={inkAt(el.accent, 0.16)}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(h * 0.06),
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: mm(type * 0.5), letterSpacing: '0.3em' }}>★ ★ ★</span>
|
||||
<Name el={el} name={name} size={type} color={el.accent} />
|
||||
<span style={{ fontSize: mm(type * 0.42), fontWeight: light(el), letterSpacing: '0.4em' }}>
|
||||
{code.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The flag in cloth: the flag drawn by flags.ts with light and shade laid
|
||||
* over it in folds, so it reads as fabric hanging from its pole, and the name
|
||||
* under it.
|
||||
*/
|
||||
export function FlagCloth({ el, code, name, w, h }: CountryArt) {
|
||||
const flagH = h * 0.62;
|
||||
const flagW = Math.min(w * 0.82, flagH * 1.5);
|
||||
const type = nameSize(name, w * 0.86, h * 0.14) * el.textScale;
|
||||
const fold = (a: number) => inkAt(el.color, a);
|
||||
const sheen = inkAt(inkOn(el.color), 0.32);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: mm(w),
|
||||
height: mm(h),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(h * 0.07),
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'stretch' }}>
|
||||
<span
|
||||
style={{
|
||||
width: mm(flagH * 0.035),
|
||||
borderRadius: mm(flagH),
|
||||
background: el.color,
|
||||
marginInlineEnd: mm(flagH * 0.01),
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: mm(flagW),
|
||||
height: mm(flagH),
|
||||
overflow: 'hidden',
|
||||
borderRadius: `0 ${mm(flagH * 0.04)} ${mm(flagH * 0.04)} 0`,
|
||||
boxShadow: `0 ${mm(flagH * 0.04)} ${mm(flagH * 0.1)} ${inkAt(el.color, 0.25)}`,
|
||||
transform: 'perspective(60mm) rotateY(-8deg)',
|
||||
transformOrigin: 'left center',
|
||||
}}
|
||||
>
|
||||
<span style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'stretch' }}>
|
||||
<FlagStretch code={code} w={flagW} h={flagH} />
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: `linear-gradient(90deg, ${fold(0.18)} 0%, ${sheen} 14%, ${fold(0.05)} 30%, ${fold(0.22)} 46%, ${sheen} 62%, ${fold(0.08)} 78%, ${fold(0.24)} 100%)`,
|
||||
mixBlendMode: 'soft-light',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<Name el={el} name={name} size={type} color={el.color} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The flag filling its cloth: flags.ts draws it at its own ratio, so it is scaled to cover. */
|
||||
function FlagStretch({ code, w, h }: { code: string; w: number; h: number }) {
|
||||
return (
|
||||
<span style={{ width: mm(w), height: mm(h), display: 'grid', placeItems: 'center', overflow: 'hidden' }}>
|
||||
<span style={{ transform: `scale(${Math.max(1, w / h / 1.5)})` }}>
|
||||
<FlagMark code={code} size={h} />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A map pin: the teardrop in the accent standing on its shadow, the flag in
|
||||
* a disc in its head, and the name under it.
|
||||
*/
|
||||
export function Pin({ el, code, name, w, h }: CountryArt) {
|
||||
const pinH = h * 0.7;
|
||||
const r = pinH * 0.36;
|
||||
const cx = w / 2;
|
||||
const cy = r * 1.05;
|
||||
const tip = pinH;
|
||||
const type = nameSize(name, w * 0.9, h * 0.14) * el.textScale;
|
||||
const id = useId().replace(/:/g, '');
|
||||
return (
|
||||
<div style={{ position: 'relative', width: mm(w), height: mm(h) }}>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0 }}
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id={`${id}-shine`} cx="35%" cy="30%" r="70%">
|
||||
<stop offset="0%" stopColor={inkOn(el.accent)} stopOpacity={0.35} />
|
||||
<stop offset="60%" stopColor={inkOn(el.accent)} stopOpacity={0} />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<ellipse cx={cx} cy={tip + r * 0.12} rx={r * 0.6} ry={r * 0.16} fill={inkAt(el.color, 0.22)} />
|
||||
<path
|
||||
d={`M${cx} ${tip} C${cx - r * 0.35} ${cy + r * 1.1} ${cx - r} ${cy + r * 0.6} ${cx - r} ${cy} A${r} ${r} 0 1 1 ${cx + r} ${cy} C${cx + r} ${cy + r * 0.6} ${cx + r * 0.35} ${cy + r * 1.1} ${cx} ${tip} Z`}
|
||||
fill={el.accent}
|
||||
/>
|
||||
<path
|
||||
d={`M${cx} ${tip} C${cx - r * 0.35} ${cy + r * 1.1} ${cx - r} ${cy + r * 0.6} ${cx - r} ${cy} A${r} ${r} 0 1 1 ${cx + r} ${cy} C${cx + r} ${cy + r * 0.6} ${cx + r * 0.35} ${cy + r * 1.1} ${cx} ${tip} Z`}
|
||||
fill={`url(#${id}-shine)`}
|
||||
/>
|
||||
<circle cx={cx} cy={cy} r={r * 0.66} fill={inkOn(el.accent)} />
|
||||
</svg>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: mm(cx - r * 0.56),
|
||||
top: mm(cy - r * 0.56),
|
||||
width: mm(r * 1.12),
|
||||
height: mm(r * 1.12),
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{el.showFlag ? (
|
||||
<FlagMark code={code} size={r * 1.12} />
|
||||
) : (
|
||||
<OutlineBadge code={code} size={r * 1.0} fill={el.accent} />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: 0,
|
||||
insetInlineEnd: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Name el={el} name={name} size={type} color={el.color} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OutlineBadge({ code, size, fill }: { code: string; size: number; fill: string }) {
|
||||
return (
|
||||
<svg width={mm(size)} height={mm(size)} viewBox={`0 0 ${size} ${size}`} aria-hidden>
|
||||
<OutlinePath code={code} box={size * 0.86} x={size / 2} y={size / 2} fill={fill} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An old luggage label: an oval ruled twice, tinted inside, the outline in
|
||||
* the middle under a sweep of stars, and the name on a ribbon across it with
|
||||
* its ends folded back.
|
||||
*/
|
||||
export function Label({ el, code, name, w, h }: CountryArt) {
|
||||
const rx = w / 2;
|
||||
const ry = h * 0.42;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const line = Math.max(0.25, Math.min(w, h) * 0.022);
|
||||
const bandH = h * 0.22;
|
||||
const bandW = w * 0.92;
|
||||
const bx = cx - bandW / 2;
|
||||
const by = cy + ry * 0.18;
|
||||
const fold = bandH * 0.5;
|
||||
const type = nameSize(name, bandW * 0.78, bandH * 0.56) * el.textScale;
|
||||
const ink = inkOn(el.accent);
|
||||
return (
|
||||
<div style={{ position: 'relative', width: mm(w), height: mm(h) }}>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0, overflow: 'visible' }}
|
||||
aria-hidden
|
||||
>
|
||||
<ellipse
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
rx={rx - line}
|
||||
ry={ry}
|
||||
fill={inkAt(el.accent, 0.12)}
|
||||
stroke={el.accent}
|
||||
strokeWidth={line}
|
||||
/>
|
||||
<ellipse
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
rx={rx - line * 3.2}
|
||||
ry={ry - line * 2.2}
|
||||
fill="none"
|
||||
stroke={el.accent}
|
||||
strokeWidth={line * 0.45}
|
||||
/>
|
||||
{el.showOutline && (
|
||||
<OutlinePath code={code} box={Math.min(w * 0.36, ry * 1.1)} x={cx} y={cy - ry * 0.28} fill={el.accent} />
|
||||
)}
|
||||
<path
|
||||
d={`M${bx - fold} ${by + fold * 0.5} H${bx + fold} V${by + bandH + fold * 0.5} H${bx - fold} L${bx - fold * 0.4} ${by + bandH / 2 + fold * 0.5} Z`}
|
||||
fill={inkAt(el.color, 0.55)}
|
||||
/>
|
||||
<path
|
||||
d={`M${bx + bandW + fold} ${by + fold * 0.5} H${bx + bandW - fold} V${by + bandH + fold * 0.5} H${bx + bandW + fold} L${bx + bandW + fold * 0.4} ${by + bandH / 2 + fold * 0.5} Z`}
|
||||
fill={inkAt(el.color, 0.55)}
|
||||
/>
|
||||
<rect x={bx} y={by} width={bandW} height={bandH} fill={el.accent} />
|
||||
</svg>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: mm(bx),
|
||||
top: mm(by),
|
||||
width: mm(bandW),
|
||||
height: mm(bandH),
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Name el={el} name={name} size={type} color={ink} spacing="0.2em" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The country drawn by hand: its outline traced twice in pencil, slightly
|
||||
* apart, hatched inside in the accent, and its name written under it in the
|
||||
* element's face, a script one when it comes from the panel.
|
||||
*/
|
||||
export function SketchCountry({ el, code, name, w, h }: CountryArt) {
|
||||
const id = useId().replace(/:/g, '');
|
||||
const rand = seeded(`${code}:${name}`);
|
||||
const box = Math.min(w * 0.86, h * 0.68);
|
||||
const cx = w / 2;
|
||||
const cy = h * 0.4;
|
||||
const pen = Math.max(0.2, box * 0.012);
|
||||
const shade = hatch({ x: cx - box / 2, y: cy - box / 2, w: box, h: box }, box * 0.045, { rand, wobble: box * 0.008 });
|
||||
const type = nameSize(name, w * 0.9, h * 0.16) * el.textScale;
|
||||
const under = roughLine(w * 0.2, h * 0.93, w * 0.8, h * 0.91, { rand, wobble: h * 0.012 });
|
||||
return (
|
||||
<div style={{ position: 'relative', width: mm(w), height: mm(h) }}>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0, overflow: 'visible' }}
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<clipPath id={`${id}-land`}>
|
||||
<OutlinePath code={code} box={box} x={cx} y={cy} fill={el.accent} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<path
|
||||
d={shade}
|
||||
fill="none"
|
||||
stroke={el.accent}
|
||||
strokeWidth={pen * 0.8}
|
||||
strokeLinecap="round"
|
||||
opacity={0.75}
|
||||
clipPath={`url(#${id}-land)`}
|
||||
/>
|
||||
{el.showOutline && (
|
||||
<>
|
||||
<OutlinePath code={code} box={box} x={cx} y={cy} fill="none" stroke={el.color} strokeWidth={pen} />
|
||||
<g transform={`translate(${pen * 0.9} ${-pen * 0.6})`} opacity={0.55}>
|
||||
<OutlinePath
|
||||
code={code}
|
||||
box={box * 1.01}
|
||||
x={cx}
|
||||
y={cy}
|
||||
fill="none"
|
||||
stroke={el.color}
|
||||
strokeWidth={pen * 0.7}
|
||||
/>
|
||||
</g>
|
||||
</>
|
||||
)}
|
||||
<path d={under} fill="none" stroke={el.accent} strokeWidth={pen * 1.4} strokeLinecap="round" />
|
||||
</svg>
|
||||
{el.showName && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: 0,
|
||||
insetInlineEnd: 0,
|
||||
bottom: mm(h * 0.1),
|
||||
textAlign: 'center',
|
||||
fontFamily: fontStack(el.font),
|
||||
fontSize: mm(type),
|
||||
fontWeight: heavy(el),
|
||||
color: el.color,
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Where a journey began and where it ended, for a map whose stops are marked
|
||||
* by their ends. The start is a ring around a dot, the way a route planner
|
||||
* marks its origin; the finish is a disc carrying a pennant. Both sit on a
|
||||
* soft shadow so they stand clear of the line through them.
|
||||
*/
|
||||
export function EndMarker({
|
||||
x,
|
||||
y,
|
||||
r,
|
||||
finish,
|
||||
ink,
|
||||
paper,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
/** The marker's radius in millimetres, the size a numbered stop has. */
|
||||
r: number;
|
||||
finish: boolean;
|
||||
/** The line's colour, and the colour that reads on it. */
|
||||
ink: string;
|
||||
paper: string;
|
||||
}) {
|
||||
const R = r * 1.15;
|
||||
return (
|
||||
<g>
|
||||
<circle cx={x} cy={y + R * 0.12} r={R * 1.12} fill="#000000" fillOpacity={0.14} />
|
||||
{finish ? (
|
||||
<>
|
||||
<circle cx={x} cy={y} r={R} fill={ink} stroke={paper} strokeWidth={R * 0.16} />
|
||||
<line
|
||||
x1={x - R * 0.28}
|
||||
y1={y + R * 0.5}
|
||||
x2={x - R * 0.28}
|
||||
y2={y - R * 0.52}
|
||||
stroke={paper}
|
||||
strokeWidth={R * 0.14}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d={`M${x - R * 0.28} ${y - R * 0.52} L${x + R * 0.5} ${y - R * 0.27} L${x - R * 0.28} ${y - R * 0.02} Z`}
|
||||
fill={paper}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<circle cx={x} cy={y} r={R} fill={paper} stroke={ink} strokeWidth={R * 0.3} />
|
||||
<circle cx={x} cy={y} r={R * 0.34} fill={ink} />
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { COUNTRY_SHAPES } from '../countryShapes';
|
||||
import { FLAG_H, FLAG_W, flagBands, flagDisc, flagSpec } from '../flags';
|
||||
import { round2 } from './travelInk';
|
||||
|
||||
/**
|
||||
* A flag, drawn.
|
||||
*
|
||||
* The first version set a regional-indicator pair and let the platform draw it.
|
||||
* That is the standard trick and it is wrong for most of the people who will
|
||||
* use this: Chrome on Windows ships no flag glyphs, so it renders the two
|
||||
* letters, and "DE" printed in a photo book is not a flag.
|
||||
*
|
||||
* So flags are constructed from `flags.ts` — exact for the stripe, cross and
|
||||
* disc constructions listed there, and absent for anything with an emblem
|
||||
* rather than approximated. A country with no construction falls back to its
|
||||
* silhouette, which is correct for all 198 of them.
|
||||
*/
|
||||
export function FlagMark({ code, size }: { code: string; size: number }) {
|
||||
const spec = flagSpec(code);
|
||||
const height = size;
|
||||
const width = (height * FLAG_W) / FLAG_H;
|
||||
|
||||
if (spec) {
|
||||
const disc = flagDisc(spec);
|
||||
return (
|
||||
<svg
|
||||
width={`${round2(width)}mm`}
|
||||
height={`${round2(height)}mm`}
|
||||
viewBox={`0 0 ${FLAG_W} ${FLAG_H}`}
|
||||
style={{ display: 'block', flex: '0 0 auto', borderRadius: `${round2(height * 0.08)}mm` }}
|
||||
aria-label={code.toUpperCase()}
|
||||
>
|
||||
{flagBands(spec).map((b, i) => (
|
||||
<rect key={i} x={b.x} y={b.y} width={b.w} height={b.h} fill={b.fill} />
|
||||
))}
|
||||
{disc && <circle cx={disc.cx} cy={disc.cy} r={disc.r} fill={disc.fill} />}
|
||||
{/* A hairline keeps a white-edged flag off a white page. */}
|
||||
<rect x="0" y="0" width={FLAG_W} height={FLAG_H} fill="none" stroke="rgba(0,0,0,.18)" strokeWidth="0.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const shape = COUNTRY_SHAPES[code.toUpperCase()];
|
||||
if (!shape) {
|
||||
// Neither a construction nor an outline: the code, set as a mark rather
|
||||
// than as two stray capitals.
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${round2(size * 0.62)}mm`,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
lineHeight: 1,
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
{code.toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
width={`${round2((height * shape.w) / shape.h)}mm`}
|
||||
height={`${round2(height)}mm`}
|
||||
viewBox={`0 0 ${shape.w} ${shape.h}`}
|
||||
style={{ display: 'block', flex: '0 0 auto' }}
|
||||
aria-label={code.toUpperCase()}
|
||||
>
|
||||
<path d={shape.d} fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
import type { BookStatsElement } from '@trek/shared';
|
||||
import { useId, type CSSProperties, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { fontStack } from '../bookFonts';
|
||||
import { inkOn } from './badgeLook';
|
||||
import { chars, fitAll, fitCaption, fitType, gridOf, type Figure } from './statsFit';
|
||||
import { NoteLook, OdometerLook, SignLook, SketchLook } from './StatsLooksMore';
|
||||
import { Caption, FigureText, IconDisc, IconDiscOn } from './statsParts';
|
||||
import { heavy, inkAt, METRIC_ICONS, metricValue, mm } from './travelInk';
|
||||
|
||||
/**
|
||||
* The newer looks of a journey summary: a poster, a ticket, tiles, a band and
|
||||
* a medal. The classic look, icon over figure over caption, stays in
|
||||
* TravelElements.tsx untouched, so every summary placed before these existed
|
||||
* prints as it always has.
|
||||
*
|
||||
* They are drawn in the element's two colours: the ink for the words and the
|
||||
* accent for what carries them, a disc, a stub, a tile, a ring. Placed from
|
||||
* the panel both are linked to the book's theme, so a summary takes the
|
||||
* theme's colours and follows them. Everything is sized from the frame and the
|
||||
* length of what it prints, never from a fixed point size, so a summary
|
||||
* dragged from a corner to a whole page grows its figures with it.
|
||||
*/
|
||||
|
||||
export interface LookProps {
|
||||
el: BookStatsElement;
|
||||
figures: Figure[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The poster: the first figure set as large as the frame allows, over a bar in
|
||||
* the accent, with its name and icon above it; the others in a column beside
|
||||
* it, each with its own disc, between hairlines. The order of the figures is
|
||||
* the order of importance, so the one somebody put first is the page's.
|
||||
*/
|
||||
function PosterLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const [hero, ...rest] = figures;
|
||||
if (!hero) return null;
|
||||
const heroW = rest.length ? w * 0.58 : w;
|
||||
const big = fitType(chars(hero), heroW * 0.92, h * 0.5) * el.textScale;
|
||||
const row = h / Math.max(1, rest.length);
|
||||
const disc = Math.min(row * 0.46, (w - heroW) * 0.16);
|
||||
const small = fitAll(rest, (w - heroW) * 0.62, row * 0.36) * el.textScale;
|
||||
const hair = `${mm(Math.max(0.18, h * 0.005))} solid ${inkAt(el.color, 0.16)}`;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
gap: mm(h * 0.045),
|
||||
width: mm(heroW),
|
||||
minWidth: 0,
|
||||
paddingInlineEnd: mm(w * 0.03),
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: mm(big * 0.12) }}>
|
||||
{el.showIcons && <IconDisc metric={hero.metric} size={big * 0.32} el={el} solid />}
|
||||
<Caption text={hero.label} size={Math.max(1.2, big * 0.13)} el={el} color={el.accent} opacity={1} />
|
||||
</span>
|
||||
<FigureText f={hero} size={big} el={el} color={el.color} unitColor={el.accent} />
|
||||
<span
|
||||
style={{
|
||||
width: mm(Math.min(heroW * 0.22, h * 0.3)),
|
||||
height: mm(Math.max(0.6, h * 0.028)),
|
||||
borderRadius: mm(h),
|
||||
background: el.accent,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{rest.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
borderInlineStart: hair,
|
||||
paddingInlineStart: mm(w * 0.045),
|
||||
}}
|
||||
>
|
||||
{rest.map((f, i) => (
|
||||
<div
|
||||
key={f.metric}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: mm(disc * 0.45),
|
||||
minHeight: 0,
|
||||
borderTop: i ? hair : undefined,
|
||||
}}
|
||||
>
|
||||
{el.showIcons && <IconDisc metric={f.metric} size={disc} el={el} />}
|
||||
<span style={{ display: 'flex', flexDirection: 'column', gap: mm(small * 0.1), minWidth: 0 }}>
|
||||
<FigureText f={f} size={small} el={el} color={el.color} unitColor={el.accent} />
|
||||
<Caption text={f.label} size={Math.max(1.2, small * 0.3)} el={el} />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A ticket: the outline with a notch bitten out either side of the tear, the
|
||||
* stub solid in the accent with the first figure on it, and the others as the
|
||||
* fields of a boarding pass, name over value, over a barcode drawn from the
|
||||
* figures themselves, so two journeys never print the same one.
|
||||
*/
|
||||
function TicketLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const id = useId().replace(/:/g, '');
|
||||
const [hero, ...rest] = figures;
|
||||
if (!hero) return null;
|
||||
const stubW = rest.length ? w * 0.34 : w;
|
||||
const notch = Math.min(h * 0.075, w * 0.035);
|
||||
const radius = Math.min(h * 0.09, w * 0.045);
|
||||
const cols = Math.min(3, Math.max(1, rest.length));
|
||||
const rows = Math.ceil(rest.length / cols);
|
||||
const fieldW = ((w - stubW) * 0.86) / cols;
|
||||
const fieldH = (h * 0.56) / Math.max(1, rows);
|
||||
const value = fitAll(rest, fieldW * 0.92, fieldH * 0.56) * el.textScale;
|
||||
const caption = fitCaption(
|
||||
rest.map((f) => f.label),
|
||||
fieldW,
|
||||
value * 0.3
|
||||
);
|
||||
const big = fitType(chars(hero), stubW * 0.78, h * 0.36) * el.textScale;
|
||||
const onStub = inkOn(el.accent);
|
||||
return (
|
||||
<>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0 }}
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<clipPath id={`${id}-ticket`} clipPathUnits="userSpaceOnUse">
|
||||
<path d={ticketPath(w, h, rest.length ? stubW : null, notch, radius)} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clipPath={`url(#${id}-ticket)`}>
|
||||
<rect x={0} y={0} width={w} height={h} fill={inkAt(el.accent, 0.1)} />
|
||||
<rect x={0} y={0} width={stubW} height={h} fill={el.accent} />
|
||||
</g>
|
||||
{rest.length > 0 && (
|
||||
<line
|
||||
x1={stubW}
|
||||
x2={stubW}
|
||||
y1={notch * 1.7}
|
||||
y2={h - notch * 1.7}
|
||||
stroke={inkAt(el.color, 0.32)}
|
||||
strokeWidth={Math.max(0.2, h * 0.008)}
|
||||
strokeDasharray={`${h * 0.025} ${h * 0.022}`}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: mm(stubW),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(h * 0.035),
|
||||
}}
|
||||
>
|
||||
{el.showIcons &&
|
||||
(() => {
|
||||
const Icon = METRIC_ICONS[hero.metric];
|
||||
return (
|
||||
<Icon
|
||||
color={onStub}
|
||||
strokeWidth={2}
|
||||
style={{ width: mm(big * 0.34), height: mm(big * 0.34), display: 'block', opacity: 0.85 }}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<FigureText f={hero} size={big} el={el} color={onStub} />
|
||||
<Caption
|
||||
text={hero.label}
|
||||
size={fitCaption([hero.label], stubW * 0.8, big * 0.18)}
|
||||
el={el}
|
||||
color={onStub}
|
||||
opacity={0.82}
|
||||
/>
|
||||
</div>
|
||||
{rest.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
padding: `0 ${mm(w * 0.045)}`,
|
||||
gap: mm(h * 0.07),
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||
rowGap: mm(h * 0.06),
|
||||
columnGap: mm(w * 0.02),
|
||||
}}
|
||||
>
|
||||
{rest.map((f) => (
|
||||
<div key={f.metric} style={{ display: 'flex', flexDirection: 'column', gap: mm(h * 0.02), minWidth: 0 }}>
|
||||
<Caption text={f.label} size={caption} el={el} color={el.accent} opacity={1} />
|
||||
<FigureText f={f} size={value} el={el} color={el.color} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Barcode figures={figures} color={el.color} width={(w - stubW) * 0.56} height={h * 0.13} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ticket's outline: a rounded rectangle with a half circle bitten out of
|
||||
* the top and the bottom edge where the stub tears off. One path, so the page
|
||||
* shows through the notches whatever lies under the ticket.
|
||||
*/
|
||||
function ticketPath(w: number, h: number, stubX: number | null, n: number, r: number): string {
|
||||
const top = stubX == null ? '' : ` L${stubX - n} 0 A${n} ${n} 0 0 0 ${stubX + n} 0`;
|
||||
const bottom = stubX == null ? '' : ` L${stubX + n} ${h} A${n} ${n} 0 0 0 ${stubX - n} ${h}`;
|
||||
return [
|
||||
`M${r} 0`,
|
||||
top,
|
||||
` L${w - r} 0 Q${w} 0 ${w} ${r}`,
|
||||
` L${w} ${h - r} Q${w} ${h} ${w - r} ${h}`,
|
||||
bottom,
|
||||
` L${r} ${h} Q0 ${h} 0 ${h - r}`,
|
||||
` L0 ${r} Q0 0 ${r} 0 Z`,
|
||||
].join('');
|
||||
}
|
||||
|
||||
/** Bars whose widths come from the figures' digits: decoration that is still the journey's own. */
|
||||
function Barcode({
|
||||
figures,
|
||||
color,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
figures: Figure[];
|
||||
color: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}) {
|
||||
const digits = figures.map((f) => f.value.replace(/\D/g, '')).join('') || '0';
|
||||
const bars = Array.from({ length: 30 }, (_, i) => 1 + ((Number(digits[i % digits.length]) + i * 7) % 3));
|
||||
const unit = width / bars.reduce((sum, b) => sum + b + 1, 0);
|
||||
const starts = bars.reduce<number[]>(
|
||||
(acc, b, i) => [...acc, i ? (acc[i - 1] ?? 0) + ((bars[i - 1] ?? 0) + 1) * unit : 0],
|
||||
[]
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
width={mm(width)}
|
||||
height={mm(height)}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
aria-hidden
|
||||
style={{ display: 'block', opacity: 0.8 }}
|
||||
>
|
||||
{bars.map((b, i) => (
|
||||
<rect key={i} x={starts[i]} y={0} width={b * unit} height={height} fill={color} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A tile per figure, rounded and tinted in the accent, the first one solid so
|
||||
* the eye lands there; each with its icon in a disc in the corner and the
|
||||
* figure set large along the foot.
|
||||
*/
|
||||
function TilesLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const { cols, rows } = gridOf(figures.length);
|
||||
const gap = Math.min(w, h) * 0.04;
|
||||
const tileW = (w - gap * (cols - 1)) / cols;
|
||||
const tileH = (h - gap * (rows - 1)) / rows;
|
||||
const pad = Math.min(tileW, tileH) * 0.12;
|
||||
const size = fitAll(figures, tileW - pad * 2, tileH * 0.34) * el.textScale;
|
||||
const caption = fitCaption(
|
||||
figures.map((f) => f.label),
|
||||
tileW - pad * 2,
|
||||
size * 0.24
|
||||
);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||
gridAutoRows: '1fr',
|
||||
gap: mm(gap),
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{figures.map((f, i) => {
|
||||
const solid = i === 0;
|
||||
const ink = solid ? inkOn(el.accent) : el.color;
|
||||
return (
|
||||
<div
|
||||
key={f.metric}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
padding: mm(pad),
|
||||
borderRadius: mm(Math.min(tileW, tileH) * 0.16),
|
||||
background: solid ? el.accent : inkAt(el.accent, 0.12),
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{el.showIcons ? <IconDiscOn metric={f.metric} size={size * 0.62} el={el} solid={solid} /> : <span />}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: mm(size * 0.1), minWidth: 0 }}>
|
||||
<FigureText f={f} size={size} el={el} color={ink} unitColor={solid ? ink : el.accent} />
|
||||
<Caption text={f.label} size={caption} el={el} color={ink} opacity={solid ? 0.85 : 0.6} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A band: the figures in one line under a rule in the accent and over a
|
||||
* hairline, each in its own column with its icon beside its name, divided by
|
||||
* hairlines. The strip a page of photographs can carry along its foot.
|
||||
*/
|
||||
function BandLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const cellW = w / Math.max(1, figures.length);
|
||||
const size = fitAll(figures, cellW * 0.8, h * 0.42) * el.textScale;
|
||||
const caption = fitCaption(
|
||||
figures.map((f) => f.label),
|
||||
cellW * 0.62,
|
||||
size * 0.26
|
||||
);
|
||||
const hair = `${mm(Math.max(0.18, h * 0.01))} solid ${inkAt(el.color, 0.18)}`;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderTop: `${mm(Math.max(0.6, h * 0.045))} solid ${el.accent}`,
|
||||
borderBottom: hair,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{figures.map((f, i) => (
|
||||
<div
|
||||
key={f.metric}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
gap: mm(h * 0.06),
|
||||
paddingInline: mm(cellW * 0.08),
|
||||
borderInlineStart: i ? hair : undefined,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<FigureText f={f} size={size} el={el} color={el.color} unitColor={el.accent} />
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: mm(caption * 0.5), minWidth: 0 }}>
|
||||
{el.showIcons && <IconDisc metric={f.metric} size={caption * 2} el={el} />}
|
||||
<Caption text={f.label} size={caption} el={el} />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A medal per figure: a ring in the accent round a tinted disc, the figure in
|
||||
* the middle with its icon above it, and its name running round the lower rim
|
||||
* between two dots, the way a medal or a seal is lettered.
|
||||
*/
|
||||
function MedalLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const gap = Math.min(w, h) * 0.06;
|
||||
const d = Math.min(h, (w - gap * (figures.length - 1)) / Math.max(1, figures.length));
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(gap),
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{figures.map((f) => (
|
||||
<Medal key={f.metric} f={f} d={d} el={el} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Medal({ f, d, el }: { f: Figure; d: number; el: BookStatsElement }) {
|
||||
const id = useId().replace(/:/g, '');
|
||||
const r = d / 2;
|
||||
const ring = d * 0.035;
|
||||
const size = fitType(chars(f), d * 0.56, d * 0.26) * el.textScale;
|
||||
const rim = r * 0.8;
|
||||
const label = f.label.toUpperCase();
|
||||
const type = Math.min(d * 0.075, (Math.PI * rim * 0.8) / Math.max(4, label.length * 0.78));
|
||||
const Icon = METRIC_ICONS[f.metric];
|
||||
return (
|
||||
<div style={{ position: 'relative', width: mm(d), height: mm(d), flex: '0 0 auto' }}>
|
||||
<svg
|
||||
width={mm(d)}
|
||||
height={mm(d)}
|
||||
viewBox={`0 0 ${d} ${d}`}
|
||||
style={{ position: 'absolute', inset: 0 }}
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<path id={`${id}-rim`} d={`M ${r - rim} ${r} A ${rim} ${rim} 0 0 0 ${r + rim} ${r}`} />
|
||||
</defs>
|
||||
<circle cx={r} cy={r} r={r - ring / 2} fill={inkAt(el.accent, 0.1)} stroke={el.accent} strokeWidth={ring} />
|
||||
<circle
|
||||
cx={r}
|
||||
cy={r}
|
||||
r={r * 0.68}
|
||||
fill="none"
|
||||
stroke={el.accent}
|
||||
strokeWidth={ring * 0.4}
|
||||
strokeDasharray={`${ring * 0.8} ${ring * 1.2}`}
|
||||
/>
|
||||
<text
|
||||
fill={el.accent}
|
||||
style={{
|
||||
fontFamily: fontStack(el.font),
|
||||
fontWeight: heavy(el),
|
||||
fontSize: `${type}px`,
|
||||
letterSpacing: `${type * 0.22}px`,
|
||||
}}
|
||||
>
|
||||
<textPath href={`#${id}-rim`} startOffset="50%" textAnchor="middle" dominantBaseline="middle">
|
||||
{label}
|
||||
</textPath>
|
||||
</text>
|
||||
<circle cx={r - rim} cy={r} r={ring * 0.7} fill={el.accent} />
|
||||
<circle cx={r + rim} cy={r} r={ring * 0.7} fill={el.accent} />
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(d * 0.02),
|
||||
paddingBottom: mm(d * 0.08),
|
||||
}}
|
||||
>
|
||||
{el.showIcons && (
|
||||
<Icon
|
||||
color={el.accent}
|
||||
strokeWidth={2}
|
||||
style={{ width: mm(d * 0.13), height: mm(d * 0.13), display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
<FigureText f={f} size={size} el={el} color={el.color} unitColor={el.accent} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LOOKS: Record<string, (props: LookProps) => ReactNode> = {
|
||||
poster: PosterLook,
|
||||
ticket: TicketLook,
|
||||
tiles: TilesLook,
|
||||
band: BandLook,
|
||||
medal: MedalLook,
|
||||
odometer: OdometerLook,
|
||||
sign: SignLook,
|
||||
note: NoteLook,
|
||||
sketch: SketchLook,
|
||||
};
|
||||
|
||||
export function StatsLookView({ el, frameStyle }: { el: BookStatsElement; frameStyle: CSSProperties }) {
|
||||
const { t, locale } = useTranslation();
|
||||
const figures: Figure[] = el.metrics.map((metric) => ({
|
||||
metric,
|
||||
...metricValue(metric, el.values, el.units, locale),
|
||||
label: t(`journey.studio.metric.${metric}`),
|
||||
}));
|
||||
const Look = LOOKS[el.look ?? 'poster'] ?? PosterLook;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...frameStyle,
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
fontFamily: fontStack(el.font),
|
||||
color: el.color,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<Look el={el} figures={figures} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
import { inkOn } from './badgeLook';
|
||||
import { hatch, roughBox, roughCircle, roughLine, seeded } from './sketch';
|
||||
import { fitAll, fitCaption, fitType, gridOf, type Figure } from './statsFit';
|
||||
import type { LookProps } from './StatsLooks';
|
||||
import { Caption, FigureText } from './statsParts';
|
||||
import { heavy, inkAt, light, METRIC_ICONS, mm } from './travelInk';
|
||||
|
||||
/**
|
||||
* The summary looks drawn after real things: the drums of an odometer, a road
|
||||
* sign, a note written on ruled paper, and a pencil sketch. Each is the same
|
||||
* figures as the other looks, in the element's ink and accent, sized from the
|
||||
* frame; StatsLooks.tsx picks the look.
|
||||
*/
|
||||
|
||||
/**
|
||||
* An odometer: each figure on its own row of drums in a dark housing, the
|
||||
* digits printed on pale drums shaded where they turn away, padded with the
|
||||
* zeros a counter shows, the last drum in the accent the way the tenths are.
|
||||
*/
|
||||
export function OdometerLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const { cols, rows } = gridOf(figures.length);
|
||||
const cellW = w / cols;
|
||||
const cellH = h / rows;
|
||||
const paper = inkOn(el.color);
|
||||
const drumsOf = (f: Figure) => {
|
||||
const digits = f.value.replace(/\D/g, '');
|
||||
return digits.padStart(Math.max(4, digits.length), '0').split('');
|
||||
};
|
||||
const most = Math.max(...figures.map((f) => drumsOf(f).length));
|
||||
const drumW = Math.min((cellW * 0.82) / (most + 0.6), cellH * 0.34);
|
||||
const drumH = drumW * 1.45;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: mm(Math.min(w, h) * 0.06),
|
||||
background: `linear-gradient(180deg, ${inkAt(el.color, 0.88)}, ${el.color})`,
|
||||
boxShadow: `inset 0 0 0 ${mm(Math.min(w, h) * 0.012)} ${inkAt(paper, 0.12)}`,
|
||||
padding: mm(Math.min(w, h) * 0.04),
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{figures.map((f) => {
|
||||
const drums = drumsOf(f);
|
||||
const lead = drums.length - f.value.replace(/\D/g, '').length;
|
||||
return (
|
||||
<div
|
||||
key={f.metric}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(drumH * 0.22),
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: mm(drumW * 0.08) }}>
|
||||
{drums.map((d, i) => {
|
||||
const last = i === drums.length - 1 && drums.length > 1;
|
||||
const face = last ? el.accent : paper;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
width: mm(drumW),
|
||||
height: mm(drumH),
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
borderRadius: mm(drumW * 0.12),
|
||||
background: `linear-gradient(180deg, ${inkAt(el.color, 0.45)} 0%, ${face} 26%, ${face} 74%, ${inkAt(el.color, 0.45)} 100%)`,
|
||||
color: last ? inkOn(el.accent) : el.color,
|
||||
opacity: i < lead ? 0.55 : 1,
|
||||
fontSize: mm(drumH * 0.66),
|
||||
fontWeight: heavy(el),
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{d}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{f.unit && (
|
||||
<span
|
||||
style={{
|
||||
marginInlineStart: mm(drumW * 0.25),
|
||||
color: paper,
|
||||
fontSize: mm(drumH * 0.4),
|
||||
fontWeight: light(el),
|
||||
}}
|
||||
>
|
||||
{f.unit}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Caption
|
||||
text={f.label}
|
||||
size={fitCaption([f.label], cellW * 0.8, drumH * 0.26)}
|
||||
el={el}
|
||||
color={paper}
|
||||
opacity={0.75}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A road sign: a panel in the accent with a rule inset round its edge, an
|
||||
* arrow in its corner, and the figures listed the way a sign lists places,
|
||||
* the name on the left and the figure on the right, between hairlines.
|
||||
*/
|
||||
export function SignLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const ink = inkOn(el.accent);
|
||||
const inset = Math.min(w, h) * 0.035;
|
||||
const rowH = (h - inset * 4) / Math.max(1, figures.length);
|
||||
const value = fitAll(figures, w * 0.36, rowH * 0.62) * el.textScale;
|
||||
const label = fitCaption(
|
||||
figures.map((f) => f.label),
|
||||
w * 0.42,
|
||||
value * 0.62
|
||||
);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: mm(inset * 1.6),
|
||||
background: el.accent,
|
||||
padding: mm(inset * 2),
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
boxShadow: `inset 0 0 0 ${mm(inset * 0.6)} ${el.accent}, inset 0 0 0 ${mm(inset * 0.9)} ${ink}`,
|
||||
color: ink,
|
||||
}}
|
||||
>
|
||||
<ArrowUpRight
|
||||
color={ink}
|
||||
strokeWidth={2.6}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: mm(inset * 1.6),
|
||||
insetInlineEnd: mm(inset * 1.6),
|
||||
width: mm(rowH * 0.5),
|
||||
height: mm(rowH * 0.5),
|
||||
opacity: 0.9,
|
||||
}}
|
||||
/>
|
||||
{figures.map((f, i) => (
|
||||
<div
|
||||
key={f.metric}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: mm(w * 0.04),
|
||||
paddingInlineEnd: mm(i === 0 ? rowH * 0.6 : 0),
|
||||
borderTop: i ? `${mm(Math.max(0.2, inset * 0.18))} solid ${inkAt(ink, 0.45)}` : undefined,
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: mm(label),
|
||||
fontWeight: heavy(el),
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
</span>
|
||||
<FigureText f={f} size={value} el={el} color={ink} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A note: a sheet of ruled paper with a margin in the accent, two strips of
|
||||
* tape holding it a degree or two off square, and the figures written on the
|
||||
* lines in the element's face, a script one when it comes from the panel.
|
||||
*/
|
||||
export function NoteLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const paper = inkOn(el.color);
|
||||
const line = h / (figures.length + 2);
|
||||
const size =
|
||||
fitAll(
|
||||
figures.map((f) => ({ ...f, value: `${f.value} ${f.label}` })),
|
||||
w * 0.74,
|
||||
line * 0.72
|
||||
) * el.textScale;
|
||||
const tapeW = w * 0.22;
|
||||
const rule = Math.max(0.15, h * 0.004);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transform: 'rotate(-1.6deg)',
|
||||
background: `repeating-linear-gradient(180deg, transparent 0 ${mm(line - rule)}, ${inkAt(el.accent, 0.28)} ${mm(line - rule)} ${mm(line)}), ${paper}`,
|
||||
backgroundPosition: `0 ${mm(line * 0.62)}`,
|
||||
boxShadow: `0 ${mm(h * 0.015)} ${mm(h * 0.05)} ${inkAt(el.color, 0.22)}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
paddingInlineStart: mm(w * 0.16),
|
||||
paddingTop: mm(line * 1.05),
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
insetInlineStart: mm(w * 0.11),
|
||||
width: mm(rule * 1.6),
|
||||
background: inkAt(el.accent, 0.55),
|
||||
}}
|
||||
/>
|
||||
{[-1, 1].map((side) => (
|
||||
<span
|
||||
key={side}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: mm(-line * 0.25),
|
||||
insetInlineStart: side < 0 ? mm(w * 0.04) : undefined,
|
||||
insetInlineEnd: side > 0 ? mm(w * 0.04) : undefined,
|
||||
width: mm(tapeW),
|
||||
height: mm(line * 0.62),
|
||||
background: inkAt(el.accent, 0.32),
|
||||
transform: `rotate(${side * 7}deg)`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{figures.map((f) => (
|
||||
<span
|
||||
key={f.metric}
|
||||
style={{
|
||||
height: mm(line),
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
gap: mm(size * 0.3),
|
||||
paddingBottom: mm(line * 0.12),
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: mm(size), fontWeight: heavy(el), color: el.accent, lineHeight: 1 }}>
|
||||
{f.value}
|
||||
{f.unit && ` ${f.unit}`}
|
||||
</span>
|
||||
<span style={{ fontSize: mm(size * 0.78), fontWeight: light(el), color: el.color, lineHeight: 1 }}>
|
||||
{f.label}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A pencil sketch: a frame drawn freehand twice over, each figure ringed by a
|
||||
* loop in the accent with hatching behind its icon, and its name underlined
|
||||
* with a quick stroke. Seeded from the element, so it draws the same each time.
|
||||
*/
|
||||
export function SketchLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const rand = seeded(`${el.id}:${figures.map((f) => f.value).join()}`);
|
||||
const { cols, rows } = gridOf(figures.length);
|
||||
const cellW = w / cols;
|
||||
const cellH = h / rows;
|
||||
const pen = Math.max(0.2, Math.min(w, h) * 0.008);
|
||||
const wob = Math.min(w, h) * 0.012;
|
||||
const ringR = Math.min(cellW * 0.3, cellH * 0.28);
|
||||
const size = fitAll(figures, ringR * 1.5, ringR * 0.6) * el.textScale;
|
||||
const frame = `${roughBox({ x: pen * 3, y: pen * 3, w: w - pen * 6, h: h - pen * 6 }, { rand, wobble: wob })} ${roughBox({ x: pen * 4, y: pen * 2, w: w - pen * 7, h: h - pen * 5 }, { rand, wobble: wob })}`;
|
||||
const marks = figures.map((f, i) => {
|
||||
const cx = (i % cols) * cellW + cellW / 2;
|
||||
const cy = Math.floor(i / cols) * cellH + cellH * 0.4;
|
||||
const r = ringR;
|
||||
const icon = size * 0.5;
|
||||
return {
|
||||
f,
|
||||
ring: roughCircle(cx, cy, r, { rand, wobble: wob * 0.9 }),
|
||||
shade: hatch({ x: cx - r * 0.95, y: cy - r * 1.05, w: icon * 1.2, h: icon * 1.2 }, icon * 0.22, {
|
||||
rand,
|
||||
wobble: wob * 0.3,
|
||||
}),
|
||||
under: roughLine(cx - r * 0.7, cy + r * 1.22, cx + r * 0.7, cy + r * 1.18, { rand, wobble: wob * 0.6 }),
|
||||
at: { x: cx, y: cy, r },
|
||||
};
|
||||
});
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0, overflow: 'visible' }}
|
||||
aria-hidden
|
||||
>
|
||||
<path d={frame} fill="none" stroke={el.color} strokeWidth={pen} strokeLinecap="round" opacity={0.8} />
|
||||
{marks.map((m) => (
|
||||
<g key={m.f.metric}>
|
||||
<path
|
||||
d={m.shade}
|
||||
fill="none"
|
||||
stroke={el.accent}
|
||||
strokeWidth={pen * 0.7}
|
||||
strokeLinecap="round"
|
||||
opacity={0.5}
|
||||
/>
|
||||
<path
|
||||
d={m.ring}
|
||||
fill="none"
|
||||
stroke={el.accent}
|
||||
strokeWidth={pen * 1.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d={m.under} fill="none" stroke={el.color} strokeWidth={pen} strokeLinecap="round" opacity={0.7} />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
{marks.map(({ f, at }) => {
|
||||
const Icon = METRIC_ICONS[f.metric];
|
||||
const box = { position: 'absolute', insetInlineStart: mm(at.x - at.r * 1.4), width: mm(at.r * 2.8) } as const;
|
||||
return (
|
||||
<div key={f.metric}>
|
||||
<div
|
||||
style={{
|
||||
...box,
|
||||
top: mm(at.y - at.r * 0.8),
|
||||
height: mm(at.r * 1.6),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(at.r * 0.04),
|
||||
}}
|
||||
>
|
||||
{el.showIcons && (
|
||||
<Icon
|
||||
color={el.color}
|
||||
strokeWidth={1.6}
|
||||
style={{ width: mm(size * 0.4), height: mm(size * 0.4), display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
<FigureText f={f} size={size} el={el} color={el.color} unitColor={el.accent} />
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
...box,
|
||||
top: mm(at.y + at.r * 1.3),
|
||||
textAlign: 'center',
|
||||
fontSize: mm(fitType(f.label.length, at.r * 2.4, size * 0.4)),
|
||||
fontWeight: light(el),
|
||||
color: el.color,
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { BookElement } from '@trek/shared';
|
||||
import { TravelPreview } from '../TravelPreview';
|
||||
|
||||
/**
|
||||
* One card of the Travel panel: the element its click places, drawn through
|
||||
* the page's own renderer with this journey's numbers in it, and its name
|
||||
* under it. These elements differ by what they say rather than by their
|
||||
* shape, so a named row could not tell two of them apart; the thing itself
|
||||
* can.
|
||||
*/
|
||||
export function TravelCard({
|
||||
el,
|
||||
label,
|
||||
onPlace,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
inset = 0,
|
||||
recommended = false,
|
||||
title,
|
||||
}: {
|
||||
el: BookElement;
|
||||
label: string;
|
||||
onPlace: (el: BookElement) => void;
|
||||
minHeight: number;
|
||||
maxHeight: number;
|
||||
/** Room kept round the element in its tile, for one that reaches past its own edges. */
|
||||
inset?: number;
|
||||
/**
|
||||
* Marked as the one to reach for, with a ring rather than with words: the
|
||||
* label has about eighty pixels, and the tooltip carries the word.
|
||||
*/
|
||||
recommended?: boolean;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`st-travel-card ${recommended ? 'is-recommended' : ''}`}
|
||||
onClick={() => onPlace(el)}
|
||||
title={title ? `${label}: ${title}` : label}
|
||||
>
|
||||
<TravelPreview el={el} minHeight={minHeight} maxHeight={maxHeight} inset={inset} />
|
||||
<span className="st-travel-card-label">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { JourneyStats } from '@trek/shared';
|
||||
import { Check, RouteOff } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Section } from '../StudioControls';
|
||||
|
||||
/**
|
||||
* The stops of the route, each switchable in or out of the figures.
|
||||
*
|
||||
* Beside the maps it changes, and not only in the entry editor: the moment
|
||||
* somebody notices the home airport on the printed route is the moment they
|
||||
* are looking at this panel. The rows carry no preview because they are not
|
||||
* things to place; they are the input every card reads.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One row: a point of the route or a stop switched off. `entryId` is null for
|
||||
* a stop the server took straight from a trip's places, which has no entry to
|
||||
* put a switch on.
|
||||
*/
|
||||
interface StopRow {
|
||||
entryId: number | null;
|
||||
label: string;
|
||||
date: string | null;
|
||||
country: string | null;
|
||||
excluded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every point and every left-out stop in one sequence, by date: the question
|
||||
* the list answers is "which of these count?", and that needs them together.
|
||||
* Undated stops go last, then by name.
|
||||
*/
|
||||
function stopRows(stats: JourneyStats): StopRow[] {
|
||||
return [
|
||||
...stats.points.map((p) => ({
|
||||
entryId: p.entryId,
|
||||
label: p.label,
|
||||
date: p.date,
|
||||
country: p.country,
|
||||
excluded: false,
|
||||
})),
|
||||
...(stats.excluded ?? []).map((x) => ({
|
||||
entryId: x.entryId,
|
||||
label: x.label,
|
||||
date: x.date,
|
||||
country: null,
|
||||
excluded: true,
|
||||
})),
|
||||
].sort((a, b) => {
|
||||
if (a.date !== b.date) {
|
||||
if (a.date == null) return 1;
|
||||
if (b.date == null) return -1;
|
||||
return a.date < b.date ? -1 : 1;
|
||||
}
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
}
|
||||
|
||||
export function TravelStops({
|
||||
stats,
|
||||
t,
|
||||
locale,
|
||||
canEdit,
|
||||
onToggleStop,
|
||||
countryName,
|
||||
}: {
|
||||
stats: JourneyStats;
|
||||
t: (k: string) => string;
|
||||
locale: string;
|
||||
canEdit: boolean;
|
||||
onToggleStop: (entryId: number, excluded: boolean) => Promise<boolean>;
|
||||
countryName: (code: string) => string;
|
||||
}) {
|
||||
/*
|
||||
* The stops whose switch is on its way to the server. No optimistic flip:
|
||||
* which stop is furthest and which countries remain is the server's sum, so
|
||||
* a row reads its state from the stats fetched once the change has landed,
|
||||
* and the chip is only held until then.
|
||||
*/
|
||||
const [pending, setPending] = useState<Set<number>>(() => new Set());
|
||||
const stops = stopRows(stats);
|
||||
if (!stops.length) return null;
|
||||
const excludedCount = stops.filter((s) => s.excluded).length;
|
||||
// The country only where there is more than one to tell apart.
|
||||
const manyCountries = stats.countries.length > 1;
|
||||
// The day without the weekday: a row has a place name to fit as well.
|
||||
const stopDay = (iso: string) =>
|
||||
new Date(`${iso}T00:00:00Z`).toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' });
|
||||
|
||||
const toggle = async (entryId: number, excluded: boolean) => {
|
||||
setPending((prev) => new Set(prev).add(entryId));
|
||||
// A refusal is the hook's to report; here it only frees the chip again.
|
||||
await onToggleStop(entryId, excluded);
|
||||
setPending((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(entryId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
label={t('journey.studio.stops')}
|
||||
hint={t('journey.studio.stopsHint')}
|
||||
// Folded, saying how many count: a fortnight is fourteen rows, and most books never touch it.
|
||||
defaultOpen={false}
|
||||
badge={`${stops.length - excludedCount}/${stops.length}`}
|
||||
>
|
||||
<ul className="st-route-stops">
|
||||
{stops.map((stop, i) => {
|
||||
const entryId = stop.entryId;
|
||||
const on = !stop.excluded;
|
||||
const state = t(on ? 'journey.studio.stopOn' : 'journey.studio.stopOff');
|
||||
const chip = `st-chip is-pick is-icon ${on ? 'is-on' : ''}`;
|
||||
// A mark rather than the word, so the column does not jitter; the word is in the title.
|
||||
const mark = on ? <Check size={13} strokeWidth={2.4} /> : <RouteOff size={13} strokeWidth={2.2} />;
|
||||
return (
|
||||
<li key={entryId ?? `place-${i}`} className={`st-route-stop ${on ? '' : 'is-off'}`}>
|
||||
{stop.date && <span className="st-badge">{stopDay(stop.date)}</span>}
|
||||
<span className="st-route-stop-label">{stop.label || t('journey.studio.untitled')}</span>
|
||||
{manyCountries && stop.country && <span className="st-badge is-quiet">{countryName(stop.country)}</span>}
|
||||
{canEdit && entryId != null ? (
|
||||
<button
|
||||
type="button"
|
||||
className={chip}
|
||||
title={`${state}: ${t('journey.studio.stopToggle')}`}
|
||||
aria-label={t('journey.studio.stopToggle')}
|
||||
aria-pressed={on}
|
||||
disabled={pending.has(entryId)}
|
||||
onClick={() => void toggle(entryId, !stop.excluded)}
|
||||
>
|
||||
{mark}
|
||||
</button>
|
||||
) : (
|
||||
<span className={chip} title={state} aria-label={state}>
|
||||
{mark}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { BookBadgeElement, BookBadgeStyle, BookBadgeVariant } from '@trek/shared';
|
||||
import { brightness } from '../folioColour';
|
||||
|
||||
/**
|
||||
* How a mark is coloured and which of its styles are the newer looks.
|
||||
*
|
||||
* Moved out of TravelElements.tsx so the looks drawn in BadgeLooks.tsx answer
|
||||
* the same question the same way: a filled mark takes its words' colour from
|
||||
* its fill, a plain one from the page.
|
||||
*/
|
||||
|
||||
/** The styles BadgeLooks.tsx draws; the first four are TravelElements.tsx's. */
|
||||
const LOOKS: ReadonlySet<BookBadgeStyle> = new Set(['stamp', 'tag', 'pill', 'ribbon', 'sticker', 'patch', 'sketch']);
|
||||
|
||||
export const isBadgeLook = (style: BookBadgeStyle | undefined): boolean => !!style && LOOKS.has(style);
|
||||
|
||||
/** Ink for a mark that picks its own, and its opposite. */
|
||||
const BADGE_DARK_INK = '#1c1b19'; // theme-lint-disable — ink on a printed page, not a UI token
|
||||
const BADGE_LIGHT_INK = '#ffffff'; // theme-lint-disable — paper white on a filled chip
|
||||
|
||||
/** The ink that reads on a fill: dark on a pale one, paper on a deep one. */
|
||||
export const inkOn = (fill: string): string => (brightness(fill) > 0.55 ? BADGE_DARK_INK : BADGE_LIGHT_INK);
|
||||
|
||||
/**
|
||||
* What colour a mark's words are.
|
||||
*
|
||||
* Automatic unless the element says otherwise, and what automatic means depends
|
||||
* on what the mark is: a chip carries its words on its own fill, so they answer
|
||||
* to the fill's brightness; a day or a date drawn plain takes the accent,
|
||||
* because that is the figure the page is built around. Anything else is
|
||||
* ordinary text in the text colour.
|
||||
*
|
||||
* The moment somebody picks a colour this steps aside completely. That is the
|
||||
* point of the flag: automatic is a good default and a bad cage.
|
||||
*/
|
||||
export function badgeInk(el: BookBadgeElement): string {
|
||||
if (!el.autoColor) return el.color;
|
||||
if (el.style === 'chip' || el.style === 'tag') return inkOn(el.accent);
|
||||
if (el.variant === 'day' || el.variant === 'date') return el.accent;
|
||||
return el.color;
|
||||
}
|
||||
|
||||
/** How tall the picture sits beside the words, as a share of the mark's height. */
|
||||
export function badgeIconShare(variant: BookBadgeVariant): number {
|
||||
if (variant === 'country') return 0.72;
|
||||
if (variant === 'flag') return 0.5;
|
||||
return 0.46;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Lines that look drawn by hand: a frame, a rule, a circle, hatching, each a
|
||||
* little off true the way a pen goes, for the sketch looks of the travel
|
||||
* elements.
|
||||
*
|
||||
* The wobble is seeded from what the element says, never from the clock or
|
||||
* Math.random, so a page draws the same every time it is opened and the same
|
||||
* in print as on screen. Every function returns an SVG path in the caller's
|
||||
* units (the element's millimetres).
|
||||
*/
|
||||
|
||||
/** A small fast generator seeded from a string: the same words always wobble the same way. */
|
||||
export function seeded(text: string): () => number {
|
||||
let h = 1779033703 ^ text.length;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
h = Math.imul(h ^ text.charCodeAt(i), 3432918353);
|
||||
h = (h << 13) | (h >>> 19);
|
||||
}
|
||||
let a = h >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const r2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
/** The hand that draws: where its wobble comes from, and how far off true a stroke may run. */
|
||||
export interface Pen {
|
||||
rand: () => number;
|
||||
wobble: number;
|
||||
}
|
||||
|
||||
/** A box in the caller's units. */
|
||||
export interface Box {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** A point nudged by up to `w` either way. */
|
||||
const nudge = (rand: () => number, w: number) => (rand() - 0.5) * 2 * w;
|
||||
|
||||
/**
|
||||
* A stroke from one point to another that bows a little and overshoots its
|
||||
* ends, the way a ruled line drawn freehand does.
|
||||
*/
|
||||
export function roughLine(x1: number, y1: number, x2: number, y2: number, { rand, wobble }: Pen): string {
|
||||
const len = Math.hypot(x2 - x1, y2 - y1) || 1;
|
||||
const ux = (x2 - x1) / len;
|
||||
const uy = (y2 - y1) / len;
|
||||
const over = wobble * 0.8;
|
||||
const sx = x1 - ux * over * rand() + nudge(rand, wobble * 0.4);
|
||||
const sy = y1 - uy * over * rand() + nudge(rand, wobble * 0.4);
|
||||
const ex = x2 + ux * over * rand() + nudge(rand, wobble * 0.4);
|
||||
const ey = y2 + uy * over * rand() + nudge(rand, wobble * 0.4);
|
||||
const mx = (sx + ex) / 2 - uy * nudge(rand, wobble) + nudge(rand, wobble * 0.3);
|
||||
const my = (sy + ey) / 2 + ux * nudge(rand, wobble) + nudge(rand, wobble * 0.3);
|
||||
return `M${r2(sx)} ${r2(sy)} Q${r2(mx)} ${r2(my)} ${r2(ex)} ${r2(ey)}`;
|
||||
}
|
||||
|
||||
/** A box drawn as four freehand strokes, each running a little past the corner. */
|
||||
export function roughBox({ x, y, w, h }: Box, pen: Pen): string {
|
||||
return [
|
||||
roughLine(x, y, x + w, y, pen),
|
||||
roughLine(x + w, y, x + w, y + h, pen),
|
||||
roughLine(x + w, y + h, x, y + h, pen),
|
||||
roughLine(x, y + h, x, y, pen),
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/** A loop that does not quite close, as a circle drawn in one stroke does. */
|
||||
export function roughCircle(cx: number, cy: number, r: number, { rand, wobble }: Pen): string {
|
||||
const steps = 18;
|
||||
const start = rand() * Math.PI * 2;
|
||||
const sweep = Math.PI * 2 * (1.04 + rand() * 0.06);
|
||||
const pts = Array.from({ length: steps + 1 }, (_, i) => {
|
||||
const a = start + (sweep * i) / steps;
|
||||
const rr = r + nudge(rand, wobble);
|
||||
return [cx + Math.cos(a) * rr, cy + Math.sin(a) * rr] as const;
|
||||
});
|
||||
const [first, ...rest] = pts;
|
||||
if (!first) return '';
|
||||
return `M${r2(first[0])} ${r2(first[1])} ${rest.map(([px, py]) => `L${r2(px)} ${r2(py)}`).join(' ')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagonal hatching across a box, each stroke freehand, for a fill drawn the
|
||||
* way a pencil shades rather than flat.
|
||||
*/
|
||||
export function hatch({ x, y, w, h }: Box, gap: number, pen: Pen): string {
|
||||
const lines: string[] = [];
|
||||
for (let d = gap; d < w + h; d += gap) {
|
||||
const x1 = x + Math.max(0, d - h);
|
||||
const y1 = y + Math.min(h, d);
|
||||
const x2 = x + Math.min(w, d);
|
||||
const y2 = y + Math.max(0, d - w);
|
||||
lines.push(roughLine(x1, y1, x2, y2, pen));
|
||||
}
|
||||
return lines.join(' ');
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { BookMetric } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* How the figures of a summary are measured: the size a line of them fits a
|
||||
* box at, and how a set divides into rows and columns. Shared by every look,
|
||||
* so a figure reads the same size whichever look it is set in.
|
||||
*/
|
||||
|
||||
/** One figure as it is printed. */
|
||||
export interface Figure {
|
||||
metric: BookMetric;
|
||||
value: string;
|
||||
unit: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The size a line of figures can be set at to fit a box: as tall as the box
|
||||
* allows and no wider than it. A figure is about six tenths of an em wide per
|
||||
* character in the faces Studio offers, which is what the estimate rests on.
|
||||
*/
|
||||
export function fitType(chars: number, boxW: number, boxH: number): number {
|
||||
return Math.max(1.2, Math.min(boxH, boxW / (0.6 * Math.max(2, chars))));
|
||||
}
|
||||
|
||||
export const chars = (f: Figure) => f.value.length + (f.unit ? f.unit.length * 0.45 + 0.3 : 0);
|
||||
|
||||
/** The size every figure of a set fits at, so a set reads as one size. */
|
||||
export const fitAll = (figures: Figure[], boxW: number, boxH: number) =>
|
||||
figures.reduce((size, f) => Math.min(size, fitType(chars(f), boxW, boxH)), boxH);
|
||||
|
||||
/** The size a caption fits at: bold small capitals with their tracking need almost an em a letter. */
|
||||
export const fitCaption = (labels: string[], boxW: number, cap: number) =>
|
||||
Math.max(1, Math.min(cap, boxW / (0.98 * Math.max(4, ...labels.map((l) => l.length)))));
|
||||
|
||||
/** The rows and columns a set of figures divides into: a row of three, a square of four, two rows of three. */
|
||||
export function gridOf(n: number): { cols: number; rows: number } {
|
||||
const cols = n <= 3 ? n : n === 4 ? 2 : n <= 6 ? 3 : 4;
|
||||
return { cols: Math.max(1, cols), rows: Math.max(1, Math.ceil(n / Math.max(1, cols))) };
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { BookMetric, BookStatsElement } from '@trek/shared';
|
||||
import { inkOn } from './badgeLook';
|
||||
import type { Figure } from './statsFit';
|
||||
import { heavy, inkAt, light, METRIC_ICONS, mm } from './travelInk';
|
||||
|
||||
/** The pieces every summary look is set from: a figure with its unit, its caption, its icon in a disc. */
|
||||
|
||||
/** The figure with its unit riding beside it at a fraction of its size, in the accent. */
|
||||
export function FigureText({
|
||||
f,
|
||||
size,
|
||||
el,
|
||||
color,
|
||||
unitColor,
|
||||
}: {
|
||||
f: Figure;
|
||||
size: number;
|
||||
el: BookStatsElement;
|
||||
color: string;
|
||||
unitColor?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: mm(size),
|
||||
fontWeight: heavy(el),
|
||||
lineHeight: 0.92,
|
||||
letterSpacing: '-0.04em',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
whiteSpace: 'nowrap',
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{f.value}
|
||||
{f.unit && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: mm(size * 0.38),
|
||||
fontWeight: light(el),
|
||||
letterSpacing: '0.02em',
|
||||
marginInlineStart: '0.14em',
|
||||
color: unitColor ?? color,
|
||||
}}
|
||||
>
|
||||
{f.unit}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The caption, in tracked small capitals. */
|
||||
export function Caption({
|
||||
text,
|
||||
size,
|
||||
el,
|
||||
color,
|
||||
opacity = 0.62,
|
||||
}: {
|
||||
text: string;
|
||||
size: number;
|
||||
el: BookStatsElement;
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: mm(size),
|
||||
fontWeight: heavy(el),
|
||||
letterSpacing: '0.18em',
|
||||
textTransform: 'uppercase',
|
||||
lineHeight: 1.2,
|
||||
opacity,
|
||||
color,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A figure's icon in a disc: tinted in the accent, or solid with the icon cut out of it. */
|
||||
export function IconDisc({
|
||||
metric,
|
||||
size,
|
||||
el,
|
||||
solid = false,
|
||||
}: {
|
||||
metric: BookMetric;
|
||||
size: number;
|
||||
el: BookStatsElement;
|
||||
solid?: boolean;
|
||||
}) {
|
||||
const Icon = METRIC_ICONS[metric];
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
width: mm(size),
|
||||
height: mm(size),
|
||||
flex: '0 0 auto',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
borderRadius: '50%',
|
||||
background: solid ? el.accent : inkAt(el.accent, 0.14),
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
color={solid ? inkOn(el.accent) : el.accent}
|
||||
strokeWidth={2}
|
||||
style={{ width: mm(size * 0.52), height: mm(size * 0.52), display: 'block' }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The disc on a tile: on the solid tile it is the paper's colour faintly, elsewhere the accent's. */
|
||||
export function IconDiscOn({
|
||||
metric,
|
||||
size,
|
||||
el,
|
||||
solid,
|
||||
}: {
|
||||
metric: BookMetric;
|
||||
size: number;
|
||||
el: BookStatsElement;
|
||||
solid: boolean;
|
||||
}) {
|
||||
if (!solid) return <IconDisc metric={metric} size={size} el={el} />;
|
||||
const Icon = METRIC_ICONS[metric];
|
||||
const ink = inkOn(el.accent);
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
width: mm(size),
|
||||
height: mm(size),
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
borderRadius: '50%',
|
||||
background: inkAt(ink, 0.18),
|
||||
}}
|
||||
>
|
||||
<Icon color={ink} strokeWidth={2} style={{ width: mm(size * 0.52), height: mm(size * 0.52), display: 'block' }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import type {
|
||||
BookBadgeStyle,
|
||||
BookBadgeVariant,
|
||||
BookColorToken,
|
||||
BookCountryLook,
|
||||
BookElement,
|
||||
BookMapElement,
|
||||
BookMapScope,
|
||||
BookMetric,
|
||||
BookPageSetup,
|
||||
BookStatsLook,
|
||||
BookTheme,
|
||||
JourneyStats,
|
||||
} from '@trek/shared';
|
||||
import {
|
||||
bookBadgeValue,
|
||||
bookCountryName,
|
||||
bookTravelBadge,
|
||||
bookTravelCountries,
|
||||
bookTravelMap,
|
||||
bookTravelSize,
|
||||
bookTravelStats,
|
||||
themedElement,
|
||||
bookElementId as uid,
|
||||
} from '@trek/shared';
|
||||
import { studioTravelContext } from '../studioTravelContext';
|
||||
|
||||
/**
|
||||
* What the Travel panel offers, built: every card is the element its click
|
||||
* places, made by the book engine's own factories (book-travel.ts) so a card
|
||||
* and what the assistant places are one thing, then set in one of the newer
|
||||
* looks and dressed in the book's theme.
|
||||
*
|
||||
* Pure, so the panel stays a list of shelves: it asks for an element and
|
||||
* renders it through the page's own renderer, and the preview and the result
|
||||
* are the same object.
|
||||
*/
|
||||
|
||||
/** The figures a summary offers: how far, how long, where, and how much was kept. */
|
||||
const SUMMARY_METRICS: BookMetric[] = ['distance', 'days', 'countries', 'photos'];
|
||||
|
||||
/** The looks of a summary, in the order the panel offers them. */
|
||||
export const SUMMARY_LOOKS: BookStatsLook[] = [
|
||||
'poster',
|
||||
'ticket',
|
||||
'tiles',
|
||||
'sign',
|
||||
'odometer',
|
||||
'note',
|
||||
'sketch',
|
||||
'band',
|
||||
];
|
||||
|
||||
/** The marks the journey fills, in the order a page is read: when, which day, how far, where. */
|
||||
export const MARK_VARIANTS = ['date', 'day', 'distance', 'coords', 'flag'] as const;
|
||||
export type MarkVariant = (typeof MARK_VARIANTS)[number];
|
||||
|
||||
/** The newer styles a mark comes in, and which of them each mark is offered in. */
|
||||
export const MARK_STYLES: { style: BookBadgeStyle; variants: readonly MarkVariant[] }[] = [
|
||||
{ style: 'stamp', variants: ['date', 'day', 'flag'] },
|
||||
{ style: 'tag', variants: ['day', 'distance', 'coords', 'flag'] },
|
||||
{ style: 'pill', variants: ['date', 'day', 'distance', 'coords', 'flag'] },
|
||||
{ style: 'ribbon', variants: ['date', 'day', 'distance', 'flag'] },
|
||||
{ style: 'sticker', variants: ['date', 'day', 'distance', 'flag'] },
|
||||
{ style: 'patch', variants: ['day', 'distance', 'flag'] },
|
||||
{ style: 'sketch', variants: ['date', 'day', 'distance', 'coords', 'flag'] },
|
||||
];
|
||||
|
||||
/** The single figures, one shelf per look, with the figures each look suits. */
|
||||
export const FIGURE_SHELVES: { look: BookStatsLook; metrics: readonly BookMetric[] }[] = [
|
||||
{ look: 'medal', metrics: ['distance', 'days', 'steps', 'photos', 'countries', 'places', 'furthest'] },
|
||||
{ look: 'odometer', metrics: ['distance', 'furthest', 'days', 'photos'] },
|
||||
{ look: 'sketch', metrics: ['distance', 'days', 'countries', 'photos'] },
|
||||
];
|
||||
|
||||
/** The looks a country is offered in. */
|
||||
export const COUNTRY_LOOKS: BookCountryLook[] = ['stamp', 'visa', 'postcard', 'flag', 'pin', 'label', 'sketch'];
|
||||
|
||||
/** The looks written by hand take the theme's script face rather than its heading. */
|
||||
const HANDWRITTEN = new Set<string>(['note', 'sketch']);
|
||||
|
||||
/** Which of the theme's accents each country look takes, so a shelf of them is not one colour. */
|
||||
const COUNTRY_ACCENT: Partial<Record<BookCountryLook, BookColorToken>> = {
|
||||
stamp: 'accent2',
|
||||
visa: 'accent3',
|
||||
pin: 'accent',
|
||||
label: 'accent2',
|
||||
};
|
||||
|
||||
/** Which of the theme's accents each mark takes, so a page of them is not one colour. */
|
||||
const MARK_ACCENT: Record<MarkVariant, BookColorToken> = {
|
||||
date: 'accent',
|
||||
day: 'accent2',
|
||||
distance: 'accent3',
|
||||
coords: 'accent',
|
||||
flag: 'accent2',
|
||||
};
|
||||
|
||||
/** The map cards: the photographed route, its stops numbered, its two ends marked, and the dark ground. */
|
||||
export const MAP_CARDS: {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
style: BookMapElement['style'];
|
||||
pins?: BookMapElement['pinStyle'];
|
||||
}[] = [
|
||||
{ id: 'photos', labelKey: 'journey.studio.pinPhoto', style: 'minimal' },
|
||||
{ id: 'numbered', labelKey: 'journey.studio.pinNumbered', style: 'minimal', pins: 'numbered' },
|
||||
{ id: 'ends', labelKey: 'journey.studio.pinEnds', style: 'minimal', pins: 'ends' },
|
||||
{ id: 'dark', labelKey: 'journey.studio.mapStyle.dark', style: 'dark', pins: 'numbered' },
|
||||
];
|
||||
|
||||
interface BuilderInput {
|
||||
page: BookPageSetup;
|
||||
stats: JourneyStats;
|
||||
/** The travelled way, already thinned. */
|
||||
path: [number, number][][];
|
||||
locale: string;
|
||||
t: (k: string) => string;
|
||||
theme: BookTheme | null | undefined;
|
||||
/** The spread on screen is one page rather than two, so a new element centres on it. */
|
||||
single: boolean;
|
||||
}
|
||||
|
||||
export function travelBuilders({ page, stats, path, locale, t, theme, single }: BuilderInput) {
|
||||
const travel = studioTravelContext(stats, path, locale, t);
|
||||
|
||||
/** Centred across the spread on screen, where the engine's `add` takes the right page. */
|
||||
const at = (size: { w: number; h: number }) => {
|
||||
const W = single ? page.pageWidth : page.pageWidth * 2;
|
||||
return { x: (W - size.w) / 2, y: (page.pageHeight - size.h) / 2, w: size.w, h: size.h };
|
||||
};
|
||||
|
||||
/**
|
||||
* An element dressed in the book's theme: its words in the theme's ink and
|
||||
* heading face, its colour one of the theme's accents, all three linked so
|
||||
* it follows when the theme changes. A book without a theme keeps the
|
||||
* engine's own colours.
|
||||
*/
|
||||
const dress = (el: BookElement, accent: BookColorToken = 'accent', hand = false): BookElement =>
|
||||
theme
|
||||
? themedElement(
|
||||
{ ...el, token: 'ink', accentToken: accent, fontRole: hand ? 'script' : 'heading' } as BookElement,
|
||||
theme
|
||||
)
|
||||
: el;
|
||||
|
||||
/** A summary in one of the newer looks; a band is a strip, so it is set a third as tall. */
|
||||
const summaryEl = (look: BookStatsLook): BookElement => {
|
||||
const w = page.pageWidth * (look === 'note' || look === 'sign' ? 0.56 : 0.72);
|
||||
const h =
|
||||
look === 'band' ? page.pageHeight * 0.13 : look === 'ticket' ? w * 0.42 : look === 'note' ? w * 0.72 : w * 0.5;
|
||||
const el = { ...bookTravelStats(stats, { id: uid('st'), frame: at({ w, h }), metrics: SUMMARY_METRICS }), look };
|
||||
return dress(el, look === 'sign' ? 'accent3' : 'accent', HANDWRITTEN.has(look));
|
||||
};
|
||||
|
||||
/**
|
||||
* One figure on its own, the mark to drop beside a photograph: as a medal, on
|
||||
* the drums of an odometer, or ringed by hand.
|
||||
*/
|
||||
const figureEl = (metric: BookMetric, look: BookStatsLook = 'medal'): BookElement => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.3;
|
||||
const size = look === 'odometer' ? { w: side * 1.6, h: side * 0.62 } : { w: side, h: side };
|
||||
const el = { ...bookTravelStats(stats, { id: uid('st'), frame: at(size), metrics: [metric] }), look };
|
||||
return dress(el, 'accent', HANDWRITTEN.has(look));
|
||||
};
|
||||
|
||||
/**
|
||||
* A map as the engine places it, on the card's ground and with the card's
|
||||
* stops. The scope is the journey or one trip; the ground is the outlines
|
||||
* or one of the two imagery sources.
|
||||
*/
|
||||
const mapEl = (
|
||||
card: { style: BookMapElement['style']; pins?: BookMapElement['pinStyle'] },
|
||||
scope: BookMapScope = 'journey',
|
||||
source: 'vector' | 'relief' | 'satellite' = 'vector'
|
||||
): BookElement => {
|
||||
const el = bookTravelMap(travel, stats, { id: uid('mp'), frame: at(bookTravelSize('map', page)), scope, source });
|
||||
const shaped = { ...el, style: card.style, ...(card.pins ? { pinStyle: card.pins } : {}) };
|
||||
// Over imagery the line is white on purpose (book-travel.ts); only the outline maps take the theme.
|
||||
return source === 'vector' ? dress(shaped) : shaped;
|
||||
};
|
||||
|
||||
/**
|
||||
* A map of one country: its stops alone, without the way there. A journey's
|
||||
* recorded way often starts with the flight from home, and fitted to all of
|
||||
* it the country is a speck at the edge of a continent; the page a travel
|
||||
* book wants is the country, cut to its own shape on the paper or over the
|
||||
* satellite picture, or the country in a box.
|
||||
*/
|
||||
const countryMapEl = (code: string, kind: 'shape' | 'satellite' | 'box'): BookElement => {
|
||||
const own = {
|
||||
...stats,
|
||||
points: stats.points.filter((p) => p.country === code),
|
||||
countries: stats.countries.filter((c) => c.code === code),
|
||||
};
|
||||
const ctx = studioTravelContext(own, [], locale, t);
|
||||
const source = kind === 'satellite' ? 'satellite' : 'vector';
|
||||
const el = bookTravelMap(ctx, own, {
|
||||
id: uid('mp'),
|
||||
frame: at(bookTravelSize('map', page)),
|
||||
scope: 'journey',
|
||||
source,
|
||||
});
|
||||
// Cut to its shape on the paper, the land is dark, so the silhouette reads and the route in the accent stands on it.
|
||||
const style = kind === 'shape' ? 'dark' : 'minimal';
|
||||
const shaped = {
|
||||
...el,
|
||||
style,
|
||||
clip: kind === 'box' ? 'rect' : 'country',
|
||||
pinStyle: 'numbered',
|
||||
fitToCountries: true,
|
||||
} as const;
|
||||
return source === 'vector' ? dress(shaped) : shaped;
|
||||
};
|
||||
|
||||
/** A mark in one of the newer styles, at the size its variant suits; a stamp is set taller for its two rules. */
|
||||
const markEl = (variant: MarkVariant, style: BookBadgeStyle): BookElement | null => {
|
||||
const value = bookBadgeValue(travel, variant, null);
|
||||
if (!value) return null;
|
||||
const frame = at(markSize(style, value.text, value.sub));
|
||||
const el = { ...bookTravelBadge({ id: uid('bd'), frame, variant: variant as BookBadgeVariant, value }), style };
|
||||
return dress(el, MARK_ACCENT[variant], style === 'sketch');
|
||||
};
|
||||
|
||||
/**
|
||||
* How large a mark is set: as wide as its words need. A stamp stacks them in
|
||||
* a box with room for its two rules; a tag or a pill sets them in a line
|
||||
* after its eyelet or its disc, so its length follows the words.
|
||||
*/
|
||||
const markSize = (style: BookBadgeStyle, text: string, sub: string) => {
|
||||
const twoLines = !!text && !!sub && text.length > 14;
|
||||
const h = page.pageHeight * (style === 'stamp' ? 0.12 : twoLines ? 0.1 : 0.066);
|
||||
const main = text || sub;
|
||||
if (style === 'stamp') return { w: Math.max(h * 1.3, h * 0.2 * Math.max(main.length * 1.4, sub.length)), h };
|
||||
// Two lines are set smaller than one, so they need less width a letter.
|
||||
if (twoLines)
|
||||
return { w: Math.min(page.pageWidth * 0.6, h * (1.5 + 0.13 * Math.max(text.length, sub.length * 0.7))), h };
|
||||
const chars = main.length * (text ? 1 : 0.85) + (text ? sub.length * 0.5 : 0);
|
||||
return { w: Math.min(page.pageWidth * 0.6, h * (1.6 + 0.3 * chars)), h };
|
||||
};
|
||||
|
||||
/** One country as a square stamp, or as a card in the proportions of a postcard. */
|
||||
const countryEl = (code: string, look: BookCountryLook): BookElement => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.36;
|
||||
const ratio = look === 'stamp' || look === 'pin' || look === 'sketch' ? 1 : look === 'postcard' ? 1.36 : 1.4;
|
||||
const frame = at({ w: side * ratio, h: side });
|
||||
const el = {
|
||||
...bookTravelCountries(travel, { id: uid('co'), frame, codes: [code] }),
|
||||
look,
|
||||
showFlag: look === 'pin' || look === 'postcard',
|
||||
};
|
||||
return dress(el, COUNTRY_ACCENT[look] ?? 'accent', look === 'sketch');
|
||||
};
|
||||
|
||||
/** A country's name in the reader's language, from the same CLDR data Atlas uses. */
|
||||
const countryName = (code: string): string => bookCountryName(code, locale, stats);
|
||||
|
||||
return { summaryEl, figureEl, mapEl, countryMapEl, markEl, countryEl, countryName };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { BookMetric, BookUnits } from '@trek/shared';
|
||||
import { CalendarDays, Camera, Footprints, Globe, MapPin, Navigation, Route, type LucideIcon } from 'lucide-react';
|
||||
import { lighterWeight, nearestWeight } from '../bookFonts';
|
||||
|
||||
/**
|
||||
* What every drawing of the journey's elements shares: millimetres rounded the
|
||||
* way the document stores them, an ink at a strength, the two weights a figure
|
||||
* and its caption are set in, and how a figure is written.
|
||||
*
|
||||
* TravelElements.tsx draws the looks every book has had since the start, and
|
||||
* the files beside this one draw the newer ones. Both take these from here, so
|
||||
* a figure reads the same whichever look it is set in.
|
||||
*/
|
||||
|
||||
/** Two decimals of a millimetre, matching what the document stores. */
|
||||
export const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
/** A length for a style, in millimetres. */
|
||||
export const mm = (n: number) => `${round2(n)}mm`;
|
||||
|
||||
/** #rrggbb at a strength, for the hairlines, tints and quiet labels of a drawing. */
|
||||
export function inkAt(hex: string, alpha: number): string {
|
||||
const n = Number.parseInt(hex.slice(1), 16);
|
||||
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* How heavy the type is set, and one step down for the labels under it. These
|
||||
* elements draw at two levels at once, a figure over its caption, and the pair
|
||||
* moves together the way `textScale` moves their size.
|
||||
*/
|
||||
export const heavy = (el: { font: string; weight: number }) => nearestWeight(el.font, el.weight);
|
||||
export const light = (el: { font: string; weight: number }) => lighterWeight(el.font, el.weight);
|
||||
|
||||
/**
|
||||
* The icon beside each figure, from lucide like every other icon in TREK. The
|
||||
* stroke is the drawing's own, so it scales with the figure on a large panel.
|
||||
*/
|
||||
export const METRIC_ICONS: Record<BookMetric, LucideIcon> = {
|
||||
distance: Route,
|
||||
days: CalendarDays,
|
||||
steps: Footprints,
|
||||
photos: Camera,
|
||||
countries: Globe,
|
||||
places: MapPin,
|
||||
furthest: Navigation,
|
||||
};
|
||||
|
||||
/**
|
||||
* A figure as it is printed: the number in the reader's locale and the unit
|
||||
* that rides with it. Distances are stored in metres and converted here, so
|
||||
* switching units never rounds a second time on top of a rounded number.
|
||||
*/
|
||||
export function metricValue(
|
||||
metric: BookMetric,
|
||||
values: Record<string, number>,
|
||||
units: BookUnits,
|
||||
locale: string
|
||||
): { value: string; unit: string } {
|
||||
const raw = values[metric] ?? 0;
|
||||
const n = (v: number, digits = 0) => new Intl.NumberFormat(locale, { maximumFractionDigits: digits }).format(v);
|
||||
|
||||
if (metric === 'distance' || metric === 'furthest') {
|
||||
const km = raw / 1000;
|
||||
const shown = units === 'imperial' ? km * 0.621371 : km;
|
||||
return { value: n(shown, shown < 100 ? 1 : 0), unit: units === 'imperial' ? 'mi' : 'km' };
|
||||
}
|
||||
return { value: n(raw), unit: '' };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { CheckCheck, Trash2 } from 'lucide-react'
|
||||
import { Bell, CheckCheck, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from '../i18n'
|
||||
import PageShell from '../components/Layout/PageShell'
|
||||
import HelpAnchor from '../components/Help/HelpAnchor'
|
||||
@@ -28,18 +28,21 @@ function InAppNotificationsPageDesktop(): React.ReactElement {
|
||||
<PageShell background="var(--bg-primary)">
|
||||
<HelpAnchor id="notifications" />
|
||||
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-content">
|
||||
{/* Header: a tile, the name with how many are unread, the count under it, the bulk tools at the end. */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<span className="grid place-items-center w-12 h-12 rounded-2xl bg-accent-subtle text-accent flex-shrink-0">
|
||||
<Bell className="w-5 h-5" />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="flex items-center gap-2 text-title font-semibold text-content">
|
||||
{t('notifications.title')}
|
||||
{unreadCount > 0 && (
|
||||
<span className="ms-2 px-2 py-0.5 rounded-full text-xs font-medium align-middle inline-flex items-center justify-center bg-content text-surface">
|
||||
<span className="px-2 h-6 inline-flex items-center rounded-full text-caption font-semibold tabular-nums bg-accent text-accent-text">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-sm mt-0.5 text-content-muted">
|
||||
<p className="text-body mt-0.5 text-content-muted">
|
||||
{total} {t('notifications.countLabel', { count: total })}
|
||||
</p>
|
||||
</div>
|
||||
@@ -49,7 +52,7 @@ function InAppNotificationsPageDesktop(): React.ReactElement {
|
||||
{unreadCount > 0 && (
|
||||
<button type="button"
|
||||
onClick={markAllRead}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors text-content-secondary bg-surface-hover hover:bg-surface-tertiary focus-visible:bg-surface-tertiary"
|
||||
className="flex items-center gap-1.5 h-9 px-3 rounded-xl text-body font-medium transition-colors text-content bg-surface-card border border-edge hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
>
|
||||
<CheckCheck className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('notifications.markAllRead')}</span>
|
||||
@@ -58,7 +61,7 @@ function InAppNotificationsPageDesktop(): React.ReactElement {
|
||||
{notifications.length > 0 && (
|
||||
<button type="button"
|
||||
onClick={requestDeleteAll}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors text-danger hover:bg-danger-soft focus-visible:bg-danger-soft"
|
||||
className="flex items-center gap-1.5 h-9 px-3 rounded-xl text-body font-medium transition-colors text-danger bg-surface-card border border-edge hover:bg-danger-soft focus-visible:bg-danger-soft"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('notifications.deleteAll')}</span>
|
||||
@@ -67,24 +70,26 @@ function InAppNotificationsPageDesktop(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{/* Filter: one segmented control rather than two loose buttons. */}
|
||||
<div className="inline-flex gap-1 p-1 mb-4 rounded-xl bg-surface-secondary" role="group" aria-label={t('notifications.title')}>
|
||||
<button type="button"
|
||||
onClick={() => setUnreadOnly(false)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${!unreadOnly ? 'bg-content text-surface' : 'bg-surface-hover text-content-secondary'}`}
|
||||
aria-pressed={!unreadOnly}
|
||||
className={`h-8 px-3.5 rounded-lg text-body font-medium transition-colors ${!unreadOnly ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'}`}
|
||||
>
|
||||
{t('notifications.all')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => setUnreadOnly(true)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${unreadOnly ? 'bg-content text-surface' : 'bg-surface-hover text-content-secondary'}`}
|
||||
aria-pressed={unreadOnly}
|
||||
className={`h-8 px-3.5 rounded-lg text-body font-medium transition-colors ${unreadOnly ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'}`}
|
||||
>
|
||||
{t('notifications.unreadOnly')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Notification list */}
|
||||
<div className="rounded-xl border overflow-hidden border-edge bg-surface-card">
|
||||
<div className="rounded-2xl border overflow-hidden border-edge bg-surface-card py-1">
|
||||
{isLoading && displayed.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Spinner className="w-6 h-6 border-2 border-slate-200 border-t-current" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand'
|
||||
import type { BookDocument, BookElement, BookFrame, BookLayout, BookSpread } from '@trek/shared'
|
||||
import type { BookAlignTo, BookDocument, BookElement, BookFrame, BookLayout, BookSpread } from '@trek/shared'
|
||||
import {
|
||||
MAX_BOOK_LAYOUTS, MAX_SPREAD_ELEMENTS, applySavedBookLayout as applySavedLayout,
|
||||
bookLayoutFromSpread as layoutFromSpread, canEditSpread as isMovableSpread, fitBookTexts, innerSpreadBounds as innerBounds,
|
||||
@@ -43,6 +43,13 @@ interface StudioState extends StudioLayerActions {
|
||||
refusedFull: number
|
||||
/** Notes that an addition was refused because the page is full. */
|
||||
refuseFull: () => void
|
||||
/**
|
||||
* What the arrange section lines things up with, as last picked: kept for the
|
||||
* session, so a new selection or another kind of element does not reset it.
|
||||
* Null until somebody picks; the section then chooses by how much is selected.
|
||||
*/
|
||||
alignTo: BookAlignTo | null
|
||||
setAlignTo: (to: BookAlignTo) => void
|
||||
/**
|
||||
* The page a chat card points at, outlined in the page rail (journey-studio.md
|
||||
* 4.2). Not part of the book or its history; opening a book clears it.
|
||||
@@ -157,6 +164,8 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
clipboard: null,
|
||||
refusedFull: 0,
|
||||
refuseFull: () => set(st => ({ refusedFull: st.refusedFull + 1 })),
|
||||
alignTo: null,
|
||||
setAlignTo: to => set({ alignTo: to }),
|
||||
hintSpread: null,
|
||||
readOnly: false,
|
||||
|
||||
|
||||
@@ -2,16 +2,10 @@
|
||||
Studio in the planner's window (assistantFrame.tsx), so the page and the
|
||||
inspector keep all their room. */
|
||||
|
||||
/* The toggle is the bar's newest tool, so it gives up its label first: from 1360
|
||||
to 1519 it is an icon while the others keep theirs, and the head band fits
|
||||
where it did before. */
|
||||
@media (max-width: 1519px) {
|
||||
.trek-dash .st-assistant-toggle .st-tool-label {
|
||||
display: none;
|
||||
}
|
||||
.trek-dash .st-assistant-toggle {
|
||||
padding: 0 8px;
|
||||
}
|
||||
/* The toggle is an icon tile of the head row; open, it reads as pressed. */
|
||||
.trek-dash .st-tool.st-head-solo.st-assistant-toggle.is-on {
|
||||
background: color-mix(in oklch, var(--accent) 14%, var(--surface));
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.trek-dash .st-assistant-notice {
|
||||
|
||||
@@ -71,6 +71,19 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* In the closed picker the face's name comes first: the kind gives way, cut
|
||||
short and then gone, before a letter of the name is lost. */
|
||||
.trek-dash .st-fontpick-current .st-fontpick-face {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.trek-dash .st-fontpick-current .st-fontpick-span {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.trek-dash .st-fontpick-caret {
|
||||
flex: none;
|
||||
color: var(--ink-3);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* ----------------- head band (StudioHeader.tsx) ----------------- *
|
||||
* The shape every TREK dialog head has: a tinted band, a raised tile, a small
|
||||
* line above the title and a round close button at the end. The band keeps the
|
||||
* line above the title and the close button at the end. The band keeps the
|
||||
* glass of the old bar under its tint, so the journey still shows through.
|
||||
*/
|
||||
.trek-dash .st-head {
|
||||
@@ -49,13 +49,13 @@
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* The wordmark and the beta mark: what this screen is, quietly, above its name. */
|
||||
/* The wordmark: what this screen is, above its name, in the title's own ink. */
|
||||
.trek-dash .st-head-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 17px;
|
||||
color: var(--ink-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
.trek-dash .st-head-eyebrow svg {
|
||||
flex: none;
|
||||
@@ -73,55 +73,6 @@
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.trek-dash .st-head-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The format, as a chip that opens the format menu. */
|
||||
.trek-dash .st-head-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 22px;
|
||||
padding-inline: 7px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-xs);
|
||||
color: var(--ink-2);
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
color 0.16s,
|
||||
box-shadow 0.16s;
|
||||
}
|
||||
.trek-dash .st-head-chip:hover {
|
||||
color: var(--ink);
|
||||
box-shadow: var(--sh-sm);
|
||||
}
|
||||
.trek-dash .st-head-chip:focus {
|
||||
outline: none;
|
||||
}
|
||||
.trek-dash .st-head-chip:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--accent-soft),
|
||||
0 0 0 3px var(--accent);
|
||||
}
|
||||
.trek-dash .st-head-chip-shape {
|
||||
flex: none;
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 2px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
/* The chip sits on the left, so its menu opens towards the page, not off the window. */
|
||||
.trek-dash .st-head-menu {
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: auto;
|
||||
}
|
||||
|
||||
.trek-dash .st-head-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -130,32 +81,175 @@
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
/* Every piece of the row is one raised tile of one height and one corner:
|
||||
the groups, who is here, help, export and close. */
|
||||
.trek-dash .st-head {
|
||||
--st-head-h: 36px;
|
||||
}
|
||||
|
||||
/* Tools that belong together share one raised group, like a segmented control. */
|
||||
.trek-dash .st-head-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: var(--st-head-h);
|
||||
padding: 3px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-xs);
|
||||
}
|
||||
|
||||
/* A tool standing alone is a group of one: help, and export in the accent. */
|
||||
.trek-dash .st-tool.st-head-solo {
|
||||
height: var(--st-head-h);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.trek-dash .st-tool.is-icon.st-head-solo {
|
||||
width: var(--st-head-h);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-xs);
|
||||
}
|
||||
.trek-dash .st-tool.is-icon.st-head-solo:hover:not(:disabled) {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-sm);
|
||||
}
|
||||
.trek-dash .st-tool.is-primary.st-head-solo {
|
||||
padding-inline: 16px;
|
||||
box-shadow: var(--sh-xs);
|
||||
}
|
||||
|
||||
/* Whether the book is saved, as a tile beside who is here; a conflict keeps its warning tint. */
|
||||
.trek-dash .st-head .st-save {
|
||||
height: var(--st-head-h);
|
||||
padding-inline: 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-xs);
|
||||
color: var(--ink-2);
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.trek-dash .st-head .st-save.is-conflict {
|
||||
background: color-mix(in oklch, var(--warning) 14%, var(--surface));
|
||||
border: 0;
|
||||
}
|
||||
.trek-dash .st-head .st-save.is-error {
|
||||
background: color-mix(in oklch, var(--danger) 10%, var(--surface));
|
||||
}
|
||||
|
||||
/* A book a newer Studio saved: the whole Studio under a veil, one card, one way out. */
|
||||
.trek-dash .st-newer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: color-mix(in oklch, var(--surface) 55%, transparent);
|
||||
backdrop-filter: blur(10px) saturate(1.1);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(1.1);
|
||||
animation: st-newer-in 0.22s ease-out both;
|
||||
}
|
||||
.trek-dash .st-newer-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
width: min(380px, 100%);
|
||||
padding: 28px 28px 24px;
|
||||
border-radius: 20px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-lg);
|
||||
text-align: center;
|
||||
}
|
||||
.trek-dash .st-newer-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.trek-dash .st-newer-text {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.trek-dash .st-newer-reload {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
margin-top: 4px;
|
||||
border-radius: 12px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
transition: filter 0.16s;
|
||||
}
|
||||
.trek-dash .st-newer-reload:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.trek-dash .st-newer-reload:focus-visible {
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 0 0 2px var(--surface),
|
||||
0 0 0 4px var(--accent);
|
||||
}
|
||||
@keyframes st-newer-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trek-dash .st-newer {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Who is here, as a tile like the tools beside it. */
|
||||
.trek-dash .st-head .st-peers {
|
||||
height: var(--st-head-h);
|
||||
padding-inline: 8px 12px;
|
||||
margin-inline-start: 0;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-xs);
|
||||
}
|
||||
.trek-dash .st-head .st-peer-dot {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-color: var(--surface);
|
||||
}
|
||||
.trek-dash .st-head .st-peer-label {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.trek-dash .st-head-close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: var(--st-head-h);
|
||||
height: var(--st-head-h);
|
||||
flex: none;
|
||||
margin-inline-start: 2px;
|
||||
border-radius: 999px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-sm);
|
||||
box-shadow: var(--sh-xs);
|
||||
color: var(--ink-3);
|
||||
transition: color 0.16s;
|
||||
transition:
|
||||
color 0.16s,
|
||||
box-shadow 0.16s;
|
||||
}
|
||||
.trek-dash .st-head-close:hover {
|
||||
color: var(--ink);
|
||||
box-shadow: var(--sh-sm);
|
||||
}
|
||||
.trek-dash .st-head-close:focus {
|
||||
outline: none;
|
||||
@@ -166,6 +260,14 @@
|
||||
0 0 0 3px var(--accent);
|
||||
}
|
||||
|
||||
/* The format's shape, drawn in the tool's own ink. */
|
||||
.trek-dash .st-head-chip-shape {
|
||||
flex: none;
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 2px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* The book view moved to the zoom, where the page's view lives; on reads as pressed. */
|
||||
.trek-dash .st-zoom button.is-on {
|
||||
background: color-mix(in oklch, var(--accent) 14%, transparent);
|
||||
|
||||
@@ -332,3 +332,18 @@
|
||||
.trek-dash .st-route-stop > span.st-chip {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* The blank inside of a cover beside the first and the last page in the page
|
||||
* rail (SpreadThumb's coverInside): the bound book opens on those pages with
|
||||
* the cover's inside opposite, which is why they stand alone. Drawn as plain
|
||||
* card under a faint hatch, so it reads as a page nothing is printed on.
|
||||
*/
|
||||
.trek-dash .st-thumb-inside {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
repeating-linear-gradient(135deg, color-mix(in oklch, var(--ink) 7%, transparent) 0 1px, transparent 1px 6px),
|
||||
color-mix(in oklch, var(--surface-2) 80%, transparent);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
.trek-dash .st-tpl-grid.is-spreads {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
/* The book's own layouts and the built-in ones lie in the same grid as the designed pages. */
|
||||
.trek-dash .st-thumbs.st-tpl-grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.trek-dash .st-tpl-card {
|
||||
min-width: 0;
|
||||
|
||||
@@ -65,10 +65,13 @@
|
||||
line-height: 1;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
/* The accents sit in the sheet's top corner, clear of a wide "Aa" below. */
|
||||
.trek-dash .st-theme-dots {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
inset-inline-end: 8px;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.trek-dash .st-theme-dots > span {
|
||||
width: 11px;
|
||||
@@ -127,6 +130,28 @@
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
/* The accents' one name spans their three columns, level with the others'
|
||||
names; every entry is placed, so the name does not push them a row down. */
|
||||
.trek-dash .st-theme-palette > .st-theme-colour {
|
||||
grid-row: 1;
|
||||
}
|
||||
.trek-dash .st-theme-palette > :nth-child(2) {
|
||||
grid-column: 2;
|
||||
}
|
||||
.trek-dash .st-theme-palette > :nth-child(3) {
|
||||
grid-column: 3;
|
||||
}
|
||||
.trek-dash .st-theme-palette > :nth-child(4) {
|
||||
grid-column: 4;
|
||||
}
|
||||
.trek-dash .st-theme-palette > :nth-child(5) {
|
||||
grid-column: 5;
|
||||
}
|
||||
.trek-dash .st-theme-accents-name {
|
||||
grid-row: 1;
|
||||
grid-column: 3 / span 3;
|
||||
align-self: end;
|
||||
}
|
||||
.trek-dash .st-theme-colour {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -154,10 +179,14 @@
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* On one line: broken inside a word, "Accent" read as "Accen t". A name the
|
||||
column cannot hold is cut, and the entry's title says it whole. */
|
||||
.trek-dash .st-theme-colour-name {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
letter-spacing: -0.01em;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
font-size: calc(10.5px * var(--fs-scale-caption, 1));
|
||||
@@ -210,13 +239,12 @@
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* The last section's own rule already sets this apart; a second one doubled it. */
|
||||
.trek-dash .st-theme-apply {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line-2);
|
||||
padding-top: 14px;
|
||||
}
|
||||
.trek-dash .st-theme-apply .st-hint {
|
||||
padding: 0 2px;
|
||||
|
||||
@@ -1447,24 +1447,6 @@
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Small, quiet and set apart — a beta mark states a fact about the feature; it
|
||||
is not a call to action and should not read as one. */
|
||||
.trek-dash .st-beta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 16px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
background: color-mix(in oklch, var(--accent) 13%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--accent) 26%, transparent);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ----------------- custom trim ----------------- */
|
||||
.trek-dash .st-menu-sep {
|
||||
height: 1px;
|
||||
@@ -1918,9 +1900,9 @@
|
||||
* The book's own layouts sit above the built-in ones, with the save button
|
||||
* closing their group, so what a click keeps and where it goes read as one.
|
||||
*/
|
||||
.trek-dash .st-saved-layouts { display: flex; flex-direction: column; margin-bottom: 14px; }
|
||||
.trek-dash .st-saved-layouts { display: flex; flex-direction: column; }
|
||||
.trek-dash .st-saved-layouts .st-hint { margin: 0 0 4px; }
|
||||
.trek-dash .st-saved-layouts .st-thumbs:empty { display: none; }
|
||||
.trek-dash .st-saved-layouts .st-add-page { margin-top: 8px; }
|
||||
.trek-dash .st-saved-layouts .st-add-page:disabled { opacity: .5; cursor: default; }
|
||||
.trek-dash .st-saved-layouts .st-add-page:disabled:hover { background: none; border-color: var(--line-2); color: var(--ink-3); }
|
||||
.trek-dash .st-saved-layouts .st-builtin-label { margin-top: 18px; }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import type { BookRecord } from '@trek/shared'
|
||||
import { fireEvent, render, screen } from '../../helpers/render'
|
||||
import { SaveIndicator } from '../../../src/components/Studio/SaveIndicator'
|
||||
import { NewerBookOverlay, SaveIndicator } from '../../../src/components/Studio/SaveIndicator'
|
||||
|
||||
/**
|
||||
* The save status (#1973).
|
||||
@@ -102,16 +102,22 @@ describe('a conflict', () => {
|
||||
|
||||
describe('a book a newer Studio saved', () => {
|
||||
/*
|
||||
* Nothing done in this tab is kept until it reloads, so the state is loud
|
||||
* like a conflict and offers the one way out there is.
|
||||
* Nothing done in this tab is kept until it reloads, so the bar says nothing
|
||||
* of its own and the overlay over the whole Studio offers the one way out.
|
||||
*/
|
||||
it('is announced, says why, and reloads on request', () => {
|
||||
const onReload = vi.fn()
|
||||
draw({ status: 'newer' }, { onReload })
|
||||
it('leaves the bar to the overlay, which says why, takes the focus and reloads on request', () => {
|
||||
const { container } = render(
|
||||
<SaveIndicator state={{ status: 'newer' }} t={t} onAcceptTheirs={vi.fn()} onKeepMine={vi.fn()} onRetry={vi.fn()} />,
|
||||
)
|
||||
expect(container.textContent).toBe('')
|
||||
|
||||
expect(screen.getByRole('alert')).toBeTruthy()
|
||||
const onReload = vi.fn()
|
||||
render(<NewerBookOverlay t={t} onReload={onReload} />)
|
||||
expect(screen.getByRole('alertdialog')).toBeTruthy()
|
||||
expect(screen.getByText('journey.studio.saveNewer')).toBeTruthy()
|
||||
fireEvent.click(screen.getByText('journey.studio.saveReload'))
|
||||
const reload = screen.getByRole('button', { name: 'journey.studio.saveReload' })
|
||||
expect(document.activeElement).toBe(reload)
|
||||
fireEvent.click(reload)
|
||||
expect(onReload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -487,7 +487,7 @@ describe('a shape', () => {
|
||||
load([shape({ shape: 'rect', fill: '#c2410c' })])
|
||||
const { container } = draw()
|
||||
|
||||
fireEvent.click(container.querySelector('.st-mini-shapes [aria-label="ellipse"]')!)
|
||||
fireEvent.click(container.querySelector('.st-mini-shapes [aria-label="journey.studio.shapeName.ellipse"]')!)
|
||||
|
||||
expect(el()).toMatchObject({ shape: 'ellipse', fill: '#c2410c', frame: FRAME })
|
||||
})
|
||||
|
||||
@@ -245,13 +245,14 @@ describe('placing a map from the panel', () => {
|
||||
return new Promise(() => {}) as never
|
||||
})
|
||||
|
||||
const { getByTitle, unmount } = render(
|
||||
const { getByTitle, getByRole, unmount } = render(
|
||||
<StudioTravelPanel
|
||||
page={page} stats={journeyStats} path={[]} t={k => k} locale="en"
|
||||
canEdit={false} onToggleStop={async () => true}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(getByTitle('journey.studio.mapStyle.minimal'))
|
||||
fireEvent.click(getByRole('tab', { name: 'journey.studio.travelMaps' }))
|
||||
fireEvent.click(getByTitle('journey.studio.pinPhoto'))
|
||||
await vi.waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalled())
|
||||
|
||||
unmount()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BookPaint } from '@trek/shared';
|
||||
import { BOOK_PATTERNS, type BookPaint } from '@trek/shared';
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { StudioPaintPicker } from '../../../src/components/Studio/StudioPaintPicker';
|
||||
@@ -172,7 +172,7 @@ describe('a pattern', () => {
|
||||
it('draws a tile for each repeat, turns stripes to the diagonal, and lets the ground go', () => {
|
||||
const channel = spy();
|
||||
const { container } = render(<Harness start={start} channel={channel} />);
|
||||
expect(container.querySelectorAll('.st-pattern-tile')).toHaveLength(12);
|
||||
expect(container.querySelectorAll('.st-pattern-tile')).toHaveLength(BOOK_PATTERNS.length);
|
||||
expect(need(container.querySelector('.st-pattern-tile[aria-checked="true"]')).getAttribute('aria-label')).toBe(
|
||||
'journey.studio.pattern.dots'
|
||||
);
|
||||
@@ -182,7 +182,7 @@ describe('a pattern', () => {
|
||||
|
||||
fireEvent.click(need(container.querySelector('[role="switch"]')));
|
||||
expect(last(channel.commit)).toMatchObject({ bg: null });
|
||||
expect(container.querySelectorAll('.st-pattern-tile.is-clear')).toHaveLength(12);
|
||||
expect(container.querySelectorAll('.st-pattern-tile.is-clear')).toHaveLength(BOOK_PATTERNS.length);
|
||||
fireEvent.click(need(container.querySelector('[role="switch"]')));
|
||||
expect(last(channel.commit)).toMatchObject({ bg: '#ffffff' });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { useStudioStore } from '../../../src/store/studioStore';
|
||||
|
||||
/** FE-STUDIO-ALIGNTO-001: what the arrange section lines things up with stays as picked across selections. */
|
||||
describe('the align target', () => {
|
||||
it('FE-STUDIO-ALIGNTO-001: is kept when the selection changes', () => {
|
||||
useStudioStore.setState({ alignTo: null, selection: [] });
|
||||
useStudioStore.getState().setAlignTo('safe');
|
||||
useStudioStore.getState().select(['a']);
|
||||
useStudioStore.getState().select(['b', 'c']);
|
||||
expect(useStudioStore.getState().alignTo).toBe('safe');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
// STLOOK-001 to STLOOK-012: the newer looks of the travel elements. Each look is drawn from the element alone, in its
|
||||
// ink and accent; these cases check that every look draws its figures, names and marks, and that the classic looks
|
||||
// every stored book holds still go through the renderer they always went through.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
BOOK_BADGE_STYLES,
|
||||
BOOK_COUNTRY_LOOKS,
|
||||
BOOK_STATS_LOOKS,
|
||||
bookBadgeElementSchema,
|
||||
bookCountriesElementSchema,
|
||||
bookMapElementSchema,
|
||||
bookStatsElementSchema,
|
||||
type BookElement,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { TravelElementView } from '../../../src/components/Studio/TravelElements';
|
||||
import { inkOn, isBadgeLook } from '../../../src/components/Studio/travel/badgeLook';
|
||||
import { hatch, roughBox, roughCircle, roughLine, seeded } from '../../../src/components/Studio/travel/sketch';
|
||||
import { render } from '../../helpers/render';
|
||||
|
||||
const frame = (w: number, h: number) => ({ x: 0, y: 0, w, h });
|
||||
|
||||
const stats = (look: string, metrics = ['distance', 'days', 'countries', 'photos']) =>
|
||||
bookStatsElementSchema.parse({
|
||||
id: `st-${look}`,
|
||||
kind: 'stats',
|
||||
frame: frame(150, 75),
|
||||
look,
|
||||
metrics,
|
||||
accent: '#c2185b',
|
||||
values: { distance: 966000, days: 20, countries: 1, photos: 4, steps: 102, places: 128, furthest: 122000 },
|
||||
});
|
||||
|
||||
const country = (look: string, over: Record<string, unknown> = {}) =>
|
||||
bookCountriesElementSchema.parse({
|
||||
id: `co-${look}`,
|
||||
kind: 'countries',
|
||||
frame: frame(80, 60),
|
||||
look,
|
||||
codes: ['JP'],
|
||||
names: ['Japan'],
|
||||
accent: '#ef6c00',
|
||||
...over,
|
||||
});
|
||||
|
||||
const badge = (style: string, over: Record<string, unknown> = {}) =>
|
||||
bookBadgeElementSchema.parse({
|
||||
id: `bd-${style}`,
|
||||
kind: 'badge',
|
||||
frame: frame(60, 14),
|
||||
style,
|
||||
variant: 'distance',
|
||||
text: '966 km',
|
||||
sub: 'Distance',
|
||||
accent: '#00796b',
|
||||
...over,
|
||||
});
|
||||
|
||||
function draw(el: BookElement) {
|
||||
return render(
|
||||
<TravelElementView
|
||||
el={el}
|
||||
frameStyle={{ position: 'absolute', width: `${el.frame.w}mm`, height: `${el.frame.h}mm` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('the newer summary looks', () => {
|
||||
it('STLOOK-001: every look draws every figure with its value', () => {
|
||||
for (const look of BOOK_STATS_LOOKS.filter((l) => l !== 'classic')) {
|
||||
const { container, unmount } = draw(stats(look));
|
||||
const text = container.textContent ?? '';
|
||||
// The odometer sets each digit on its own drum, so its figures read padded with zeros.
|
||||
const figures = look === 'odometer' ? ['0966', '0020', '0001', '0004'] : ['966', '20', '1', '4'];
|
||||
for (const figure of figures) expect(text.replace(/\s/g, '')).toContain(figure);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('STLOOK-002: a ticket cuts its notches and prints a barcode drawn from the figures', () => {
|
||||
const { container } = draw(stats('ticket'));
|
||||
expect(container.querySelector('clipPath path')?.getAttribute('d')).toContain('A');
|
||||
expect(container.querySelectorAll('rect').length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it('STLOOK-003: a medal letters its name round the rim, and a single figure fills the frame', () => {
|
||||
const { container } = draw(stats('medal', ['days']));
|
||||
const rim = container.querySelector('textPath')?.textContent ?? '';
|
||||
expect(rim.length).toBeGreaterThan(0);
|
||||
expect(rim).toBe(rim.toUpperCase());
|
||||
expect(container.querySelectorAll('circle').length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('STLOOK-004: a summary placed before looks existed still draws the classic way', () => {
|
||||
const { container } = draw(stats('classic'));
|
||||
expect(container.querySelector('textPath')).toBeNull();
|
||||
expect(container.textContent).toContain('966');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the newer country looks', () => {
|
||||
it('STLOOK-005: every look names the country, and the outline or the flag shows where', () => {
|
||||
for (const look of BOOK_COUNTRY_LOOKS.filter((l) => l !== 'classic')) {
|
||||
const { container, unmount } = draw(country(look, { showFlag: true }));
|
||||
expect((container.textContent ?? '').toUpperCase()).toContain('JAPAN');
|
||||
expect(container.querySelector('svg')).not.toBeNull();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('STLOOK-006: a name switched off is left off in every look', () => {
|
||||
for (const look of ['stamp', 'visa', 'postcard', 'flag', 'pin', 'label', 'sketch']) {
|
||||
const { container, unmount } = draw(country(look, { showName: false }));
|
||||
expect(container.textContent ?? '').not.toContain('Japan');
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('STLOOK-007: an element holding several countries draws one of each, side by side', () => {
|
||||
const { container } = draw(country('stamp', { codes: ['JP', 'KR'], names: ['Japan', 'Korea'] }));
|
||||
expect(container.querySelectorAll('textPath')).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the newer mark styles', () => {
|
||||
it('STLOOK-008: every style sets the value and its line, and the four first ones stay classic', () => {
|
||||
for (const style of BOOK_BADGE_STYLES) {
|
||||
const { container, unmount } = draw(badge(style));
|
||||
expect(container.textContent).toContain('966 km');
|
||||
expect(isBadgeLook(style)).toBe(!['plain', 'chip', 'outline', 'stacked'].includes(style));
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('STLOOK-009: a mark without a value carries its name instead, and coordinates go on two lines', () => {
|
||||
const flag = draw(badge('pill', { variant: 'flag', text: '', sub: 'Japan', code: 'JP' }));
|
||||
expect(flag.container.textContent).toContain('Japan');
|
||||
flag.unmount();
|
||||
|
||||
const coords = draw(badge('tag', { variant: 'coords', text: '35°40′N 139°45′E', sub: 'Tokyo' }));
|
||||
const lines = [...coords.container.querySelectorAll('span')].find((s) => s.style.flexDirection === 'column');
|
||||
expect(lines?.textContent).toContain('Tokyo');
|
||||
});
|
||||
|
||||
it('STLOOK-010: a filled mark takes the ink that reads on its fill', () => {
|
||||
expect(inkOn('#ffffff')).not.toBe(inkOn('#000000'));
|
||||
const { container } = draw(badge('pill', { variant: 'date', text: '4', sub: 'May' }));
|
||||
expect(container.querySelector('svg')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the map stops', () => {
|
||||
const route = [
|
||||
{ lat: 35.68, lng: 139.76 },
|
||||
{ lat: 35.01, lng: 135.77 },
|
||||
{ lat: 34.69, lng: 135.5 },
|
||||
];
|
||||
|
||||
it('STLOOK-011: numbers every stop, or marks the start and the finish', () => {
|
||||
const numbered = draw(
|
||||
bookMapElementSchema.parse({
|
||||
id: 'm1',
|
||||
kind: 'map',
|
||||
frame: frame(120, 90),
|
||||
countries: ['JP'],
|
||||
points: route,
|
||||
pinStyle: 'numbered',
|
||||
})
|
||||
);
|
||||
expect([...numbered.container.querySelectorAll('text')].map((t) => t.textContent)).toEqual(['1', '2', '3']);
|
||||
numbered.unmount();
|
||||
|
||||
const ends = draw(
|
||||
bookMapElementSchema.parse({
|
||||
id: 'm2',
|
||||
kind: 'map',
|
||||
frame: frame(120, 90),
|
||||
countries: ['JP'],
|
||||
points: route,
|
||||
pinStyle: 'ends',
|
||||
})
|
||||
);
|
||||
// The finish carries a pennant, the start a ring round a dot.
|
||||
expect(ends.container.querySelectorAll('line').length).toBeGreaterThan(0);
|
||||
expect(ends.container.querySelectorAll('text')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawn by hand', () => {
|
||||
it('STLOOK-012: the same words wobble the same way every time, and every stroke is a path', () => {
|
||||
const a = seeded('Japan');
|
||||
const b = seeded('Japan');
|
||||
expect([a(), a(), a()]).toEqual([b(), b(), b()]);
|
||||
expect(seeded('Korea')()).not.toBe(seeded('Japan')());
|
||||
|
||||
const pen = { rand: seeded('x'), wobble: 1 };
|
||||
for (const d of [
|
||||
roughLine(0, 0, 10, 0, pen),
|
||||
roughBox({ x: 0, y: 0, w: 10, h: 5 }, pen),
|
||||
roughCircle(5, 5, 3, pen),
|
||||
hatch({ x: 0, y: 0, w: 10, h: 10 }, 2, pen),
|
||||
]) {
|
||||
expect(d.startsWith('M')).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// STTRV-001 to STTRV-006 and STTRV-009: the Travel panel places what the book engine's factories build, so a figure,
|
||||
// a map, a country or a mark added by hand is the element the assistant's `add` adds. Only the frame is the panel's
|
||||
// own: centred across the spread rather than on the right-hand page.
|
||||
// STTRV-001 to STTRV-009: the Travel panel places what the book engine's factories build, set in one of the newer
|
||||
// looks, so a figure, a map, a country or a mark added by hand is the element the assistant's `add` adds in that look.
|
||||
// Only the frame and the look are the panel's own.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { BookDocument, BookElement, BookFrame, BookTravelContext, JourneyStats } from '@trek/shared';
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
BOOK_SATELLITE_TILES,
|
||||
bookBadgeValue,
|
||||
bookPageSetupSchema,
|
||||
bookThemeOf,
|
||||
bookTravelBadge,
|
||||
bookTravelCountries,
|
||||
bookTravelMap,
|
||||
@@ -151,107 +152,156 @@ beforeEach(() => {
|
||||
} as unknown as BookDocument);
|
||||
});
|
||||
|
||||
/** Open one of the panel's tabs by its name. */
|
||||
const tab = (key: string) => fireEvent.click(screen.getByRole('tab', { name: key }));
|
||||
|
||||
describe('the Travel panel builds through the book engine', () => {
|
||||
it('STTRV-001: places a single figure as the engine builds it, centred across the spread', () => {
|
||||
it('STTRV-001: places a summary in the look of its card, with the figures the engine resolves', () => {
|
||||
open();
|
||||
|
||||
fireEvent.click(card('journey.studio.metric.days'));
|
||||
fireEvent.click(card('journey.studio.statsLook.poster'));
|
||||
|
||||
const size = bookTravelSize('stats', page);
|
||||
expect(content(placed())).toEqual(
|
||||
content(bookTravelStats(stats, { id: 'st', frame: ANY_FRAME, metrics: ['days'] }))
|
||||
);
|
||||
expect(placed().frame).toEqual(spreadCentre(size));
|
||||
const engine = bookTravelStats(stats, {
|
||||
id: 'st',
|
||||
frame: ANY_FRAME,
|
||||
metrics: ['distance', 'days', 'countries', 'photos'],
|
||||
});
|
||||
expect(content(placed())).toEqual(content({ ...engine, look: 'poster' as const }));
|
||||
});
|
||||
|
||||
it('STTRV-002: places a journey map with the recorded way, in the style of its card', () => {
|
||||
it('STTRV-002: offers each figure on its own as a medal, an odometer and a sketch', () => {
|
||||
open();
|
||||
|
||||
fireEvent.click(card('journey.studio.mapStyle.dark'));
|
||||
const days = screen.getAllByRole('button', { name: 'journey.studio.metric.days' });
|
||||
expect(days).toHaveLength(3);
|
||||
const looks = days.map((button) => {
|
||||
fireEvent.click(button);
|
||||
return placed() as { look?: string; metrics?: string[] };
|
||||
});
|
||||
expect(looks.map((el) => el.look)).toEqual(['medal', 'odometer', 'sketch']);
|
||||
expect(looks.every((el) => el.metrics?.join() === 'days')).toBe(true);
|
||||
});
|
||||
|
||||
it('STTRV-003: places the journey map with its stops numbered, its ends marked or on the dark ground', () => {
|
||||
open();
|
||||
tab('journey.studio.travelMaps');
|
||||
|
||||
fireEvent.click(card('journey.studio.pinNumbered'));
|
||||
const engine = bookTravelMap(travel, stats, { id: 'mp', frame: ANY_FRAME, scope: 'journey' });
|
||||
expect(content(placed())).toEqual(content({ ...engine, style: 'dark' as const }));
|
||||
expect(placed()).toMatchObject({ kind: 'map', style: 'dark', source: 'vector', tripId: null, path: PATH });
|
||||
expect(content(placed())).toEqual(content({ ...engine, style: 'minimal' as const, pinStyle: 'numbered' as const }));
|
||||
expect(placed()).toMatchObject({ kind: 'map', source: 'vector', tripId: null, path: PATH });
|
||||
expect(placed().frame).toEqual(spreadCentre(bookTravelSize('map', page)));
|
||||
|
||||
fireEvent.click(card('journey.studio.pinEnds'));
|
||||
expect(placed()).toMatchObject({ pinStyle: 'ends' });
|
||||
fireEvent.click(card('journey.studio.mapStyle.dark'));
|
||||
expect(placed()).toMatchObject({ style: 'dark', pinStyle: 'numbered' });
|
||||
});
|
||||
|
||||
it('STTRV-003: places a map per trip of that trip alone, with its own countries and no journey trail', () => {
|
||||
it('STTRV-004: places a map per trip of that trip alone, numbered, with no journey trail', () => {
|
||||
open();
|
||||
tab('journey.studio.travelMaps');
|
||||
|
||||
fireEvent.click(card('June'));
|
||||
|
||||
const engine = bookTravelMap(travel, stats, { id: 'mp', frame: ANY_FRAME, scope: { tripId: ICELAND } });
|
||||
expect(content(placed())).toEqual(content(engine));
|
||||
expect(placed()).toMatchObject({
|
||||
tripId: ICELAND,
|
||||
countries: ['IS'],
|
||||
path: [],
|
||||
pinStyle: 'numbered',
|
||||
points: [{ label: 'Reykjavík', photoId: 3 }, { label: 'Akureyri' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('STTRV-004: places the satellite map the engine places, with its tiles, its credit and a white line', () => {
|
||||
it('STTRV-005: places the satellite map the engine places, with its tiles, its credit and a white line', () => {
|
||||
open();
|
||||
tab('journey.studio.travelMaps');
|
||||
|
||||
fireEvent.click(card('journey.studio.mapSourceSatellite'));
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'journey.studio.mapSourceSatellite' })[0] as HTMLElement);
|
||||
|
||||
const engine = bookTravelMap(travel, stats, { id: 'mp', frame: ANY_FRAME, scope: 'journey', source: 'satellite' });
|
||||
expect(content(placed())).toEqual(content(engine));
|
||||
expect(content(placed())).toEqual(content({ ...engine, style: 'minimal' as const }));
|
||||
expect(placed()).toMatchObject({ source: 'tiles', tileUrl: BOOK_SATELLITE_TILES, accent: '#ffffff' });
|
||||
});
|
||||
|
||||
it('STTRV-005: places each mark with the words, size and style the engine gives it', () => {
|
||||
it('STTRV-006: places a map of one country with its stops alone, cut to its shape on a dark ground', () => {
|
||||
open();
|
||||
tab('journey.studio.travelMaps');
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'journey.studio.mapClipCountry' })[0] as HTMLElement);
|
||||
|
||||
expect(placed()).toMatchObject({
|
||||
clip: 'country',
|
||||
style: 'dark',
|
||||
pinStyle: 'numbered',
|
||||
countries: ['IS'],
|
||||
path: [],
|
||||
points: [{ label: 'Reykjavík' }, { label: 'Akureyri' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('STTRV-007: offers every country in every look, each placing that country alone', () => {
|
||||
open();
|
||||
tab('journey.studio.countries');
|
||||
|
||||
const looks = ['stamp', 'visa', 'postcard', 'flag', 'pin', 'label', 'sketch'] as const;
|
||||
for (const look of looks) {
|
||||
const [iceland] = screen.getAllByRole('button', { name: `journey.studio.countryLook.${look}` });
|
||||
fireEvent.click(iceland as HTMLElement);
|
||||
const engine = bookTravelCountries(travel, { id: 'co', frame: ANY_FRAME, codes: ['IS'] });
|
||||
expect(placed()).toMatchObject({ kind: 'countries', codes: engine.codes, names: engine.names, look });
|
||||
}
|
||||
});
|
||||
|
||||
it('STTRV-008: places each mark in its style with the words the engine gives it', () => {
|
||||
open();
|
||||
tab('journey.studio.marks');
|
||||
|
||||
const [stamp] = screen.getAllByRole('button', { name: 'journey.studio.dateMark' });
|
||||
fireEvent.click(stamp as HTMLElement);
|
||||
const value = bookBadgeValue(travel, 'date', null);
|
||||
if (!value) throw new Error('the engine has no date mark for this journey');
|
||||
expect(content(placed())).toEqual(
|
||||
content({ ...bookTravelBadge({ id: 'bd', frame: ANY_FRAME, variant: 'date', value }), style: 'stamp' as const })
|
||||
);
|
||||
|
||||
const styles = screen.getAllByRole('button', { name: 'journey.studio.distanceMark' }).map((button) => {
|
||||
fireEvent.click(button);
|
||||
return (placed() as { style?: string }).style;
|
||||
});
|
||||
expect(styles).toEqual(['tag', 'pill', 'ribbon', 'sticker', 'patch', 'sketch']);
|
||||
expect(placed()).toMatchObject({ variant: 'distance', text: '4,000 km' });
|
||||
});
|
||||
|
||||
it('STTRV-009: offers no date mark without a start, and the day where its word puts it', () => {
|
||||
open({ ...stats, start: null }, (k) => (k === 'journey.studio.day' ? '{day}日目' : k));
|
||||
tab('journey.studio.marks');
|
||||
|
||||
expect(screen.queryAllByRole('button', { name: 'journey.studio.dateMark' })).toHaveLength(0);
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'journey.studio.dayMark' })[0] as HTMLElement);
|
||||
expect(placed()).toMatchObject({ variant: 'day', text: '1日目', style: 'stamp' });
|
||||
});
|
||||
|
||||
it("STTRV-010: dresses what it places in the book's theme, linked so it follows a change of theme", () => {
|
||||
const theme = bookThemeOf('nordic');
|
||||
useStudioStore.getState().load({
|
||||
version: 1,
|
||||
title: 'T',
|
||||
page,
|
||||
theme,
|
||||
spreads: [{ id: 's1', role: 'inner', background: null, elements: [], parked: [], entryId: null }],
|
||||
} as unknown as BookDocument);
|
||||
open();
|
||||
|
||||
const marks = [
|
||||
['journey.studio.dateMark', 'date'],
|
||||
['journey.studio.dayMark', 'day'],
|
||||
['journey.studio.coordsMark', 'coords'],
|
||||
['journey.studio.flagMark', 'flag'],
|
||||
['journey.studio.distanceMark', 'distance'],
|
||||
] as const;
|
||||
for (const [name, variant] of marks) {
|
||||
fireEvent.click(card(name));
|
||||
const value = bookBadgeValue(travel, variant, null);
|
||||
if (!value) throw new Error(`the engine has no ${variant} mark for this journey`);
|
||||
expect(content(placed())).toEqual(content(bookTravelBadge({ id: 'bd', frame: ANY_FRAME, variant, value })));
|
||||
expect(placed().frame).toEqual(spreadCentre(bookTravelSize('badge', page, { variant })));
|
||||
}
|
||||
// The last one, read back as words: the distance in an outline.
|
||||
expect(placed()).toMatchObject({ variant: 'distance', text: '4,000 km', style: 'outline' });
|
||||
});
|
||||
fireEvent.click(card('journey.studio.statsLook.tiles'));
|
||||
expect(placed()).toMatchObject({
|
||||
accent: theme.palette.accent,
|
||||
accentToken: 'accent',
|
||||
token: 'ink',
|
||||
fontRole: 'heading',
|
||||
});
|
||||
|
||||
it('STTRV-006: offers no date mark without a start, and places a country and its mark by the name', () => {
|
||||
open({ ...stats, start: null });
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'journey.studio.dateMark' })).toBeNull();
|
||||
fireEvent.click(card('journey.studio.dayMark'));
|
||||
expect(placed()).toMatchObject({ variant: 'day', text: 'journey.studio.day 1', style: 'chip' });
|
||||
|
||||
const [outline, mark] = screen.getAllByRole('button', { name: 'Iceland' });
|
||||
fireEvent.click(outline);
|
||||
expect(content(placed())).toEqual(
|
||||
content(bookTravelCountries(travel, { id: 'co', frame: ANY_FRAME, codes: ['IS'] }))
|
||||
);
|
||||
expect(placed()).toMatchObject({ codes: ['IS'], names: ['Iceland'] });
|
||||
|
||||
fireEvent.click(mark);
|
||||
expect(placed()).toMatchObject({ kind: 'badge', variant: 'country', text: 'Iceland', sub: '', code: 'IS' });
|
||||
});
|
||||
|
||||
it('STTRV-009: places a country mark as the engine makes one, and the day where its word puts it', () => {
|
||||
open(stats, (k) => (k === 'journey.studio.day' ? '{day}日目' : k));
|
||||
|
||||
const [, mark] = screen.getAllByRole('button', { name: 'Iceland' });
|
||||
fireEvent.click(mark);
|
||||
const value = bookBadgeValue(travel, 'country', null);
|
||||
expect(value).toEqual({ text: 'Iceland', sub: '', code: 'IS' });
|
||||
if (!value) throw new Error('the engine has no country mark for this journey');
|
||||
const engine = bookTravelBadge({ id: 'bd', frame: ANY_FRAME, variant: 'country', value });
|
||||
expect(content(placed())).toEqual(content(engine));
|
||||
expect(placed().frame).toEqual(spreadCentre(bookTravelSize('badge', page, { variant: 'country' })));
|
||||
|
||||
fireEvent.click(card('journey.studio.dayMark'));
|
||||
expect(placed()).toMatchObject({ variant: 'day', text: '1日目' });
|
||||
fireEvent.click(card('journey.studio.statsLook.note'));
|
||||
expect(placed()).toMatchObject({ fontRole: 'script' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,8 @@ function open(journeyStats: JourneyStats, over: { canEdit?: boolean; onToggleSto
|
||||
canEdit={over.canEdit ?? true} onToggleStop={onToggleStop}
|
||||
/>,
|
||||
)
|
||||
// The stops sit under the maps they are drawn from.
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'journey.studio.travelMaps' }))
|
||||
// The section opens folded, since a fortnight of rows is longer than the
|
||||
// rest of the panel together. Every case below is about those rows, so
|
||||
// unfold unless the case is about the fold itself.
|
||||
|
||||
@@ -160,7 +160,7 @@ export class JourneyBookMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'add_book_pages',
|
||||
description: `Add 1 to ${BOOK_OP_LIMITS.pagesPerCall} empty double spreads after afterSpreadId, always kept between the first and the last page, laid out on template when one is named and tied to the journey entry entryId when given. ${WRITE_ANSWER}`,
|
||||
description: `Add 1 to ${BOOK_OP_LIMITS.pagesPerCall} empty double spreads after afterSpreadId, always kept between the first and the last page, laid out on template when one is named (a built-in or decorated double-spread layout from list_book_layouts) and tied to the journey entry entryId when given. ${WRITE_ANSWER}`,
|
||||
inputSchema: { journeyId: idSchema, ...addBookPagesArgs },
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: journeyAddonOn,
|
||||
@@ -208,7 +208,7 @@ export class JourneyBookMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'set_book_cover',
|
||||
description: `The cover of a journey's book: its layout (a single page template), its photo (from the journey's gallery, into a free frame, else a full bleed one behind everything), its title and its subtitle. A title or subtitle bound to the journey's own stays bound unless other words are given. With returnPrevious the answer also holds the cover as it was, under previous_spreads, and a digest of the cover as the write left it under page_digests (for restore_book_page expectedPages). ${WRITE_ANSWER}`,
|
||||
description: `The cover of a journey's book: its layout (a built-in or decorated single-page template from list_book_layouts), its photo (from the journey's gallery, into a free frame, else a full bleed one behind everything), its title and its subtitle. A title or subtitle bound to the journey's own stays bound unless other words are given. With returnPrevious the answer also holds the cover as it was, under previous_spreads, and a digest of the cover as the write left it under page_digests (for restore_book_page expectedPages). ${WRITE_ANSWER}`,
|
||||
inputSchema: { journeyId: idSchema, ...setBookCoverArgs },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: journeyAddonOn,
|
||||
|
||||
@@ -66,10 +66,6 @@ function metresApart(a: Pick<TourPoi, 'lat' | 'lng'>, b: { lat: number; lng: num
|
||||
return Math.hypot(dLat, dLng);
|
||||
}
|
||||
|
||||
function withinSamePlace(a: Pick<TourPoi, 'lat' | 'lng'>, b: { lat: number; lng: number }): boolean {
|
||||
return metresApart(a, b) <= SAME_PLACE_M;
|
||||
}
|
||||
|
||||
/** A name as compared: without case, accents or punctuation. */
|
||||
function nameKey(name: string): string {
|
||||
return name
|
||||
@@ -202,21 +198,82 @@ function tourCategoryOf(mapsCategory: string, asked: readonly TourPoiCategory[])
|
||||
}
|
||||
|
||||
/**
|
||||
* The places found, each once and within the corridor, with where they lie.
|
||||
* OpenStreetMap often maps one place twice, as a point and as its outline, and
|
||||
* the index may hold it beside OpenStreetMap: one name in one spot is one
|
||||
* place. An unnamed place (a tap, a bench) is never merged with another.
|
||||
* Within this many metres, a summit of the index and another of a like name
|
||||
* are one mountain. The index holds a mountain as many pins, placed by whoever
|
||||
* listed it, at its foot as much as on top, so they lie a mountain's breadth
|
||||
* apart. Two OpenStreetMap summits are two peaks, as the Watzmann and the
|
||||
* Kleiner Watzmann are.
|
||||
*/
|
||||
const SAME_MOUNTAIN_M = 3000;
|
||||
|
||||
/**
|
||||
* Scripts written without spaces between words: there one name inside another
|
||||
* is a word of it, as 高尾山 is of 八王子高尾山, where in Latin "Dom" is not
|
||||
* "Domplatz".
|
||||
*/
|
||||
const UNSPACED_SCRIPT = /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}]{2,}$/u;
|
||||
|
||||
/** {@link similarNames}, or one word written without spaces inside the other name. */
|
||||
function likeNames(a: string, b: string): boolean {
|
||||
if (similarNames(a, b)) return true;
|
||||
const inside = (word: string, name: string) => UNSPACED_SCRIPT.test(word) && name.replaceAll(' ', '').includes(word);
|
||||
return a.split(' ').some((word) => inside(word, b)) || b.split(' ').some((word) => inside(word, a));
|
||||
}
|
||||
|
||||
/**
|
||||
* Two rows that are one place: the same name in one spot, as OpenStreetMap
|
||||
* maps a place twice, as a point and as its outline, or the index holds it
|
||||
* beside OpenStreetMap; a like name in one spot; or, for a summit of the
|
||||
* index, a like name anywhere on the mountain. An unnamed place (a tap, a
|
||||
* bench) is never one with another.
|
||||
*/
|
||||
function samePlace(a: TourPoi, b: TourPoi): boolean {
|
||||
const name = nameKey(a.name);
|
||||
const other = nameKey(b.name);
|
||||
if (!name || !other || a.category !== b.category) return false;
|
||||
const apart = metresApart(a, b);
|
||||
const pinned = a.source !== 'openstreetmap' || b.source !== 'openstreetmap';
|
||||
if (a.category === 'peak' && pinned && apart <= SAME_MOUNTAIN_M) return likeNames(name, other);
|
||||
return apart <= SAME_PLACE_M && (name === other || likeNames(name, other));
|
||||
}
|
||||
|
||||
/**
|
||||
* The row a place mapped several times keeps: an OpenStreetMap object over an
|
||||
* index pin, then the plainest name ("高尾山" rather than "Top Of Mt.Takao"),
|
||||
* then the first found.
|
||||
*/
|
||||
function keptOf(rows: readonly TourPoi[]): TourPoi {
|
||||
const plainness = (row: TourPoi) => [row.source === 'openstreetmap' ? 0 : 1, nameKey(row.name).length];
|
||||
return rows.reduce((best, row) => {
|
||||
const [a, b] = [plainness(row), plainness(best)];
|
||||
return a[0] < b[0] || (a[0] === b[0] && a[1] < b[1]) ? row : best;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows that are one place, each once: a row joins every group it is one
|
||||
* place with, so a bridge like "高尾山 (Mt. Takao)" joins "Mt.Takao" and
|
||||
* "高尾山" although those two share no word.
|
||||
*/
|
||||
function oncePerPlace(rows: readonly TourPoi[]): TourPoi[] {
|
||||
const groups: TourPoi[][] = [];
|
||||
for (const row of rows) {
|
||||
const joined = groups.filter((group) => group.some((other) => samePlace(row, other)));
|
||||
const merged = [...joined.flat(), row];
|
||||
groups.splice(0, groups.length, ...groups.filter((group) => !joined.includes(group)), merged);
|
||||
}
|
||||
const kept = new Set(groups.map(keptOf));
|
||||
// In the order found, so the first of equals stays first.
|
||||
return rows.filter((row) => kept.has(row));
|
||||
}
|
||||
|
||||
/** The places found, each once and within the corridor, with where they lie. */
|
||||
function placesWithin(found: MapsPoi[], body: TourPoiSearchRequest, line: Array<[number, number]>): TourPoi[] {
|
||||
const kept = new Map<string, TourPoi>();
|
||||
const byName = new Map<string, TourPoi[]>();
|
||||
for (const poi of found) {
|
||||
const category = tourCategoryOf(poi.category, body.categories);
|
||||
const near = nearnessToLine({ lat: poi.lat, lng: poi.lng }, line);
|
||||
if (!category || !near || near.distanceM > body.corridor_m || kept.has(poi.osm_id)) continue;
|
||||
const name = poi.name.trim().toLocaleLowerCase();
|
||||
const namesakes = name ? (byName.get(name) ?? []) : [];
|
||||
if (namesakes.some((other) => withinSamePlace(other, poi))) continue;
|
||||
const place: TourPoi = {
|
||||
id: poi.osm_id,
|
||||
name: poi.name,
|
||||
@@ -234,9 +291,8 @@ function placesWithin(found: MapsPoi[], body: TourPoiSearchRequest, line: Array<
|
||||
...bikeChargingOf(poi),
|
||||
};
|
||||
kept.set(poi.osm_id, place);
|
||||
if (name) byName.set(name, [...namesakes, place]);
|
||||
}
|
||||
return [...kept.values()];
|
||||
return oncePerPlace([...kept.values()]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -139,6 +139,28 @@ describe('TourPoiSearchService', () => {
|
||||
expect(result.pois.map((found) => found.id)).toEqual(['node:9', 'node:10']);
|
||||
});
|
||||
|
||||
it('TOUR-POI-019: the pins of one mountain under several names are one summit, two mapped summits stay two', async () => {
|
||||
const pin = (id: string, name: string, lat: number) =>
|
||||
poi(id, lat, 11.1003, 'peak', { name, source: 'trek-places', poi_type: 'mountain' });
|
||||
poisAlong.mockResolvedValue(
|
||||
answer([
|
||||
// Pins at the foot of the mountain and on top, named in two scripts and bridged by one that has both.
|
||||
pin('gers:1', 'Mt.Takao', 47.401),
|
||||
pin('gers:2', '東京都 高尾山', 47.4011),
|
||||
pin('gers:3', '高尾山 (Mt. Takao)', 47.403),
|
||||
pin('gers:4', '八王子高尾山', 47.405),
|
||||
pin('gers:5', '高尾山', 47.419),
|
||||
pin('gers:6', 'Top Of Mt.Takao', 47.4191),
|
||||
// Another mountain on the way, and two summits of OpenStreetMap a kilometre apart.
|
||||
pin('gers:7', 'Mt.Kagenobu', 47.41),
|
||||
poi('node:1', 47.406, 11.1003, 'peak', { name: 'Watzmann' }),
|
||||
poi('node:2', 47.415, 11.1003, 'peak', { name: 'Kleiner Watzmann' }),
|
||||
]),
|
||||
);
|
||||
const result = await service.search({ tour_type: 'hike', categories: ['peak'], corridor_m: 500, line: LINE });
|
||||
expect(result.pois.map((found) => found.name)).toEqual(['Watzmann', 'Mt.Kagenobu', 'Kleiner Watzmann', '高尾山']);
|
||||
});
|
||||
|
||||
it('TOUR-POI-002: asks the map search along the line for what each category means, and names a hit by the category asked', async () => {
|
||||
poisAlong.mockResolvedValue(answer([poi('node:cafe', 47.41, 11.1, 'cafe')]));
|
||||
const result = await service.search({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
BOOK_FINDING_CODES,
|
||||
BOOK_OP_LIMITS,
|
||||
bookCoverTemplateIdSchema,
|
||||
bookAnyTemplateIdSchema,
|
||||
bookPageOpSchema,
|
||||
bookSpreadIdSchema,
|
||||
} from '../../book/book-ops.schema';
|
||||
@@ -63,7 +63,7 @@ export type BookCardLayout = z.infer<typeof bookCardLayoutSchema>;
|
||||
|
||||
/** `set_book_cover`'s arguments, null where the call leaves a part of the cover as it is. */
|
||||
export const bookCardCoverSchema = z.object({
|
||||
template: bookCoverTemplateIdSchema.nullable(),
|
||||
template: bookAnyTemplateIdSchema.nullable(),
|
||||
photoId: idSchema.nullable(),
|
||||
title: z.string().max(MAX_BOOK_TITLE).nullable(),
|
||||
subtitle: z.string().max(MAX_BOOK_TITLE).nullable(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
bookBadgeStyleSchema,
|
||||
bookBadgeVariantSchema,
|
||||
bookCountriesLayoutSchema,
|
||||
bookCountryLookSchema,
|
||||
bookFontIdSchema,
|
||||
bookFrameStyleSchema,
|
||||
bookIconNameSchema,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
bookShapeIdRefSchema,
|
||||
bookShapeIdSchema,
|
||||
bookStatsLayoutSchema,
|
||||
bookStatsLookSchema,
|
||||
bookStrokeStyleSchema,
|
||||
bookTextAlignSchema,
|
||||
bookTypesetFontSchema,
|
||||
@@ -179,6 +181,7 @@ const STATS_PROPS = {
|
||||
...COMMON,
|
||||
...TYPESET,
|
||||
layout: bookStatsLayoutSchema.optional(),
|
||||
look: bookStatsLookSchema.optional(),
|
||||
showIcons: stats.showIcons.unwrap().optional(),
|
||||
units: stats.units.unwrap().describe('Kilometres (metric) or miles (imperial).').optional(),
|
||||
};
|
||||
@@ -186,6 +189,7 @@ const COUNTRIES_PROPS = {
|
||||
...COMMON,
|
||||
...TYPESET,
|
||||
layout: bookCountriesLayoutSchema.optional(),
|
||||
look: bookCountryLookSchema.optional(),
|
||||
showOutline: countries.showOutline.unwrap().optional(),
|
||||
showFlag: countries.showFlag.unwrap().optional(),
|
||||
showName: countries.showName.unwrap().optional(),
|
||||
|
||||
@@ -76,7 +76,12 @@ export function photoDpi(el: BookPhotoElement, info: BookPhotoInfo | undefined):
|
||||
}
|
||||
|
||||
/** Average letter width per em at weight 400, per family: the catalogue's measure (book-fonts.data.ts). */
|
||||
const letterEm = (font: string) => bookFontFace(font)?.letterEm ?? 0.5;
|
||||
/** A family's average letter width per em at a weight: its measured bold from 600 on, else a guess a little wider. */
|
||||
function letterEm(font: string, weight: number): number {
|
||||
const face = bookFontFace(font);
|
||||
const regular = face?.letterEm ?? 0.5;
|
||||
return weight >= 600 ? (face?.boldEm ?? regular * 1.06) : regular;
|
||||
}
|
||||
|
||||
/**
|
||||
* The headroom on letter width the design engine sizes a text frame with. A
|
||||
@@ -124,7 +129,7 @@ export function bookLetterMm(el: BookTextElement, headroom = 1): number {
|
||||
const sizeMm = (orDefault(el.size, 11) * 25.4) / 72;
|
||||
const tracking = orDefault(el.tracking, 0);
|
||||
const caps = bookFontFace(el.font)?.caps ? 1 : (CASE_WIDTH[el.transform ?? 'none'] ?? 1);
|
||||
return sizeMm * (letterEm(el.font) * (el.weight >= 600 ? 1.06 : 1) * caps * headroom + tracking);
|
||||
return sizeMm * (letterEm(el.font, el.weight) * caps * headroom + tracking);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -58,6 +58,11 @@ export interface BookFontFace {
|
||||
* the face's own advance widths, for the text measure in book-findings.ts.
|
||||
*/
|
||||
letterEm: number;
|
||||
/**
|
||||
* The same at weight 700, measured the same way, for a family that ships a
|
||||
* real bold: a condensed bold sets up to a sixth wider than its regular.
|
||||
*/
|
||||
boldEm?: number;
|
||||
/** Set in capitals whatever is typed. */
|
||||
caps?: true;
|
||||
}
|
||||
@@ -81,6 +86,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: ALL,
|
||||
italics: [],
|
||||
letterEm: 0.507,
|
||||
boldEm: 0.519,
|
||||
},
|
||||
inter: {
|
||||
name: 'Inter',
|
||||
@@ -91,6 +97,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: ALL,
|
||||
italics: [],
|
||||
letterEm: 0.478,
|
||||
boldEm: 0.491,
|
||||
},
|
||||
montserrat: {
|
||||
name: 'Montserrat',
|
||||
@@ -101,6 +108,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: ALL,
|
||||
italics: [],
|
||||
letterEm: 0.507,
|
||||
boldEm: 0.536,
|
||||
},
|
||||
// ── serif ──
|
||||
serif: {
|
||||
@@ -112,6 +120,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: MID,
|
||||
italics: MID,
|
||||
letterEm: 0.469,
|
||||
boldEm: 0.481,
|
||||
},
|
||||
garamond: {
|
||||
name: 'EB Garamond',
|
||||
@@ -122,6 +131,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: [400, 500, 600, 700, 800],
|
||||
italics: [400, 500, 600, 700, 800],
|
||||
letterEm: 0.383,
|
||||
boldEm: 0.424,
|
||||
},
|
||||
// ── elegant display serifs ──
|
||||
playfair: {
|
||||
@@ -133,6 +143,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: [400, 500, 600, 700, 800, 900],
|
||||
italics: [400, 500, 600, 700, 800, 900],
|
||||
letterEm: 0.454,
|
||||
boldEm: 0.462,
|
||||
},
|
||||
fraunces: {
|
||||
name: 'Fraunces',
|
||||
@@ -143,6 +154,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: ALL,
|
||||
italics: ALL,
|
||||
letterEm: 0.467,
|
||||
boldEm: 0.497,
|
||||
},
|
||||
cormorant: {
|
||||
name: 'Cormorant Garamond',
|
||||
@@ -153,6 +165,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: [300, 400, 500, 600, 700],
|
||||
italics: [300, 400, 500, 600, 700],
|
||||
letterEm: 0.393,
|
||||
boldEm: 0.398,
|
||||
},
|
||||
dmserif: {
|
||||
name: 'DM Serif Display',
|
||||
@@ -173,6 +186,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: [400, 500, 600, 700, 800, 900],
|
||||
italics: [400, 500, 600, 700, 800, 900],
|
||||
letterEm: 0.463,
|
||||
boldEm: 0.488,
|
||||
},
|
||||
// ── condensed and poster ──
|
||||
bebas: {
|
||||
@@ -205,6 +219,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: [200, 300, 400, 500, 600, 700],
|
||||
italics: [],
|
||||
letterEm: 0.365,
|
||||
boldEm: 0.412,
|
||||
},
|
||||
league: {
|
||||
name: 'League Gothic',
|
||||
@@ -225,6 +240,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: ALL,
|
||||
italics: [],
|
||||
letterEm: 0.304,
|
||||
boldEm: 0.355,
|
||||
},
|
||||
archivo: {
|
||||
name: 'Archivo Black',
|
||||
@@ -246,6 +262,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: ALL,
|
||||
italics: [],
|
||||
letterEm: 0.492,
|
||||
boldEm: 0.5,
|
||||
},
|
||||
shrikhand: {
|
||||
name: 'Shrikhand',
|
||||
@@ -276,6 +293,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: [200, 300, 400, 500, 600, 700, 800],
|
||||
italics: [],
|
||||
letterEm: 0.471,
|
||||
boldEm: 0.49,
|
||||
},
|
||||
abril: {
|
||||
name: 'Abril Fatface',
|
||||
@@ -297,6 +315,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: MID,
|
||||
italics: [],
|
||||
letterEm: 0.344,
|
||||
boldEm: 0.349,
|
||||
},
|
||||
dancing: {
|
||||
name: 'Dancing Script',
|
||||
@@ -307,6 +326,7 @@ export const BOOK_FONT_FACES = {
|
||||
weights: MID,
|
||||
italics: [],
|
||||
letterEm: 0.363,
|
||||
boldEm: 0.372,
|
||||
},
|
||||
pacifico: {
|
||||
name: 'Pacifico',
|
||||
|
||||
@@ -71,6 +71,12 @@ describe('the catalogue', () => {
|
||||
for (const w of face.italics) expect(face.weights, `${id} italic ${w}`).toContain(w);
|
||||
expect(face.letterEm, id).toBeGreaterThan(0.2);
|
||||
expect(face.letterEm, id).toBeLessThan(0.7);
|
||||
// A measured bold only where the family ships one, and never narrower than its regular.
|
||||
const bold = (face as { boldEm?: number }).boldEm;
|
||||
if (bold !== undefined) {
|
||||
expect(face.weights as readonly number[], id).toContain(700);
|
||||
expect(bold, id).toBeGreaterThanOrEqual(face.letterEm);
|
||||
}
|
||||
expect(face.roles.length, id).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -110,7 +110,7 @@ export const bookRouteArcSchema = map.routeArc
|
||||
.unwrap()
|
||||
.describe('bow draws the long legs as a curve, the way a flight is shown.');
|
||||
export const bookRouteDashSchema = map.routeDash.unwrap().describe('arcs dashes the bowed legs; solid dashes nothing.');
|
||||
export const bookPinStyleSchema = map.pinStyle.unwrap().describe('A stop as a dot, or as a small round photo of it.');
|
||||
export const bookPinStyleSchema = map.pinStyle.unwrap().describe('How the stops are marked.');
|
||||
export const bookMapClipSchema = map.clip
|
||||
.unwrap()
|
||||
.describe('rect is a map in a box; country cuts it to the outline of its countries.');
|
||||
@@ -122,12 +122,12 @@ export const bookMapSourceSchema = z
|
||||
export const bookStatsLayoutSchema = bookStatsElementSchema.shape.layout
|
||||
.unwrap()
|
||||
.describe('The figures in a grid, one row or one column.');
|
||||
export const bookStatsLookSchema = bookStatsElementSchema.shape.look.unwrap().describe('How the figures are drawn.');
|
||||
export const bookCountriesLayoutSchema = bookCountriesElementSchema.shape.layout
|
||||
.unwrap()
|
||||
.describe('The countries as a list, a grid or one column.');
|
||||
export const bookBadgeStyleSchema = bookBadgeElementSchema.shape.style
|
||||
.unwrap()
|
||||
.describe('plain words, a chip (filled capsule), an outline, or stacked over its line.');
|
||||
export const bookCountryLookSchema = bookCountriesElementSchema.shape.look.unwrap().describe('How a country is drawn.');
|
||||
export const bookBadgeStyleSchema = bookBadgeElementSchema.shape.style.unwrap().describe('How the mark is set.');
|
||||
export const bookBadgeVariantSchema = z
|
||||
.enum(BOOK_BADGES)
|
||||
.describe(
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
bookAddOpInSchema,
|
||||
} from './book-element-ops.schema';
|
||||
import { BOOK_GROUP_OP_SCHEMAS } from './book-group-ops.schema';
|
||||
import { BOOK_COVER_TEMPLATES, BOOK_TEMPLATES } from './book-layouts';
|
||||
import {
|
||||
BOOK_BINDING_HINT,
|
||||
bookFontIdSchema,
|
||||
@@ -76,16 +75,6 @@ export const BOOK_OP_LIMITS = {
|
||||
findings: 40,
|
||||
} as const;
|
||||
|
||||
const idsOf = (list: readonly { id: string }[]) => list.map((t) => t.id) as [string, ...string[]];
|
||||
|
||||
/** The spread layouts, by id. */
|
||||
export const bookTemplateIdSchema = z
|
||||
.enum(idsOf(BOOK_TEMPLATES))
|
||||
.describe('A built-in double page layout by id; list_book_layouts says what each one holds.');
|
||||
/** The single-page (cover and back) layouts, by id. */
|
||||
export const bookCoverTemplateIdSchema = z
|
||||
.enum(idsOf(BOOK_COVER_TEMPLATES))
|
||||
.describe('A built-in cover or back layout by id; list_book_layouts says what each one holds.');
|
||||
/**
|
||||
* A layout of either set, built-in or decorated: which one fits is the page's
|
||||
* question, answered when the operation runs. A string checked against the
|
||||
@@ -278,7 +267,8 @@ export const addBookPagesArgs = {
|
||||
/** The new pages go after this one, kept inside the covers. */
|
||||
afterSpreadId: bookSpreadIdSchema,
|
||||
count: z.number().int().min(1).max(BOOK_OP_LIMITS.pagesPerCall).optional(),
|
||||
template: bookTemplateIdSchema.optional(),
|
||||
/** A double-spread layout, built-in or decorated; a single-page one is refused when the page is laid. */
|
||||
template: bookAnyTemplateIdSchema.optional(),
|
||||
entryId: idSchema.optional(),
|
||||
};
|
||||
export const moveBookPageArgs = {
|
||||
@@ -296,7 +286,8 @@ export const deleteBookPagesArgs = {
|
||||
expectedVersion,
|
||||
};
|
||||
export const setBookCoverArgs = {
|
||||
template: bookCoverTemplateIdSchema.optional(),
|
||||
/** A single-page layout, built-in or decorated; a double-spread one is refused when the cover is laid. */
|
||||
template: bookAnyTemplateIdSchema.optional(),
|
||||
photoId: idSchema.optional(),
|
||||
title: z.string().max(MAX_BOOK_TITLE).optional(),
|
||||
subtitle: z.string().max(MAX_BOOK_TITLE).optional(),
|
||||
|
||||
@@ -108,6 +108,19 @@ describe('add, move and delete', () => {
|
||||
expect(doc.spreads[3]?.elements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('BOOKPG-029: lays added pages out on a decorated template, and refuses a cover template for a double spread', () => {
|
||||
const doc = ok(
|
||||
addBookPages(book(1), { afterSpreadId: 'sp-in1', count: 2, template: 'hero-script-word' }, { newId }),
|
||||
);
|
||||
expect(doc.spreads[3]?.elements.length).toBeGreaterThan(0);
|
||||
expect(doc.spreads[4]?.elements.length).toBe(doc.spreads[3]?.elements.length);
|
||||
const wrong = addBookPages(book(1), { afterSpreadId: 'sp-in1', template: 'poster-pop-sunburst' }, { newId });
|
||||
expect(refused(wrong)).toBe('layout_role');
|
||||
expect(refused(addBookPages(book(1), { afterSpreadId: 'sp-in1', template: 'no-such' }, { newId }))).toBe(
|
||||
'invalid_op',
|
||||
);
|
||||
});
|
||||
|
||||
it('BOOKPG-020: ties an added page only to an entry the journey has, when the journey is known', () => {
|
||||
const journey = { title: 'Iceland', entries: [{ id: 7 }] };
|
||||
const added = ok(addBookPages(book(1), { afterSpreadId: 'sp-in1', entryId: 7 }, { newId, journey }));
|
||||
@@ -336,6 +349,13 @@ describe('the cover', () => {
|
||||
expect(title).toBeDefined();
|
||||
});
|
||||
|
||||
it('BOOKPG-030: lays the cover out on a decorated cover template, and refuses a double-spread one', () => {
|
||||
const doc = ok(setBookCover(book(1), { template: 'poster-pop-sunburst', title: 'Iceland 2026' }, { newId }));
|
||||
expect(coverOf(doc).elements.length).toBeGreaterThan(1);
|
||||
expect(textsOf(doc).some((t) => t.text === 'Iceland 2026')).toBe(true);
|
||||
expect(refused(setBookCover(book(1), { template: 'hero-script-word' }, { newId }))).toBe('layout_role');
|
||||
});
|
||||
|
||||
it('BOOKPG-016: keeps a bound title bound when the words are what the journey says, and the subtitle apart', () => {
|
||||
const base = book(1);
|
||||
const bound = bookTextElementSchema.parse({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { bookElementId } from './book-ids';
|
||||
import { applyBookTemplate, classifyBookTexts, findBookTemplate } from './book-layouts';
|
||||
import { classifyBookTexts } from './book-layouts';
|
||||
import { applyBookOps } from './book-ops';
|
||||
import {
|
||||
caught,
|
||||
@@ -62,17 +62,23 @@ export function addBookPages(doc: BookDocument, args: unknown, ctx: BookOpsConte
|
||||
}
|
||||
const { first, limit } = innerSpreadBounds(doc.spreads);
|
||||
const at = Math.min(Math.max(after + 1, first), limit);
|
||||
const tpl = template === undefined ? undefined : findBookTemplate(template);
|
||||
const added = Array.from({ length: count }, () => {
|
||||
const empty = { ...blankSpread('inner', ctx.newId ?? bookElementId), entryId: entryId ?? null };
|
||||
return checkedSpread(tpl ? applyBookTemplate(empty, tpl, doc.page) : empty, null);
|
||||
});
|
||||
const spreads = [...doc.spreads.slice(0, at), ...added, ...doc.spreads.slice(at)];
|
||||
const added = Array.from({ length: count }, () =>
|
||||
checkedSpread({ ...blankSpread('inner', ctx.newId ?? bookElementId), entryId: entryId ?? null }, null),
|
||||
);
|
||||
let next: BookDocument = { ...doc, spreads: [...doc.spreads.slice(0, at), ...added, ...doc.spreads.slice(at)] };
|
||||
// Laid out as edit_book_page lays a page, so a decorated template takes the book's theme and words too.
|
||||
if (template !== undefined) {
|
||||
for (const spread of added) {
|
||||
const r = applyBookOps(next, { spreadId: spread.id, ops: [{ op: 'layout', template }] }, ctx);
|
||||
if (r.ok === false) throw failOp(r.error.code, r.error.message, null, r.error.elementId);
|
||||
next = r.doc;
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
doc: { ...doc, spreads },
|
||||
doc: next,
|
||||
spreadIds: added.map((s) => s.id),
|
||||
created: createdIds(doc.spreads, spreads),
|
||||
created: createdIds(doc.spreads, next.spreads),
|
||||
dropped: [],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -42,6 +42,8 @@ export const BOOK_PATTERNS = [
|
||||
'scales',
|
||||
'sunburst',
|
||||
'confetti',
|
||||
'linen',
|
||||
'grain',
|
||||
] as const;
|
||||
export type BookPattern = (typeof BOOK_PATTERNS)[number];
|
||||
|
||||
@@ -68,32 +70,39 @@ type ObjectFactory = typeof z.object | typeof z.strictObject;
|
||||
function paintSchemas(obj: ObjectFactory, pattern: z.ZodType<BookPattern, string>) {
|
||||
const stop = obj({ at: z.number().min(0).max(1), color: hex, alpha: alpha.optional() });
|
||||
const stops = z.array(stop).min(2).max(MAX_PAINT_STOPS);
|
||||
const linear = obj({ type: z.literal('linear'), angle, stops });
|
||||
const radial = obj({
|
||||
type: z.literal('radial'),
|
||||
cx: z.number().min(0).max(1),
|
||||
cy: z.number().min(0).max(1),
|
||||
r: z.number().min(RADIAL_RADIUS.min).max(RADIAL_RADIUS.max),
|
||||
stops,
|
||||
});
|
||||
const patterned = obj({
|
||||
type: z.literal('pattern'),
|
||||
pattern,
|
||||
fg: hex,
|
||||
bg: hex.nullable(),
|
||||
scale: z.number().min(PATTERN_SCALE.min).max(PATTERN_SCALE.max),
|
||||
angle,
|
||||
});
|
||||
const paint = z.discriminatedUnion('type', [
|
||||
obj({ type: z.literal('solid'), color: hex, alpha: alpha.optional() }),
|
||||
obj({ type: z.literal('linear'), angle, stops }),
|
||||
obj({
|
||||
type: z.literal('radial'),
|
||||
cx: z.number().min(0).max(1),
|
||||
cy: z.number().min(0).max(1),
|
||||
r: z.number().min(RADIAL_RADIUS.min).max(RADIAL_RADIUS.max),
|
||||
stops,
|
||||
}),
|
||||
obj({
|
||||
type: z.literal('pattern'),
|
||||
pattern,
|
||||
fg: hex,
|
||||
bg: hex.nullable(),
|
||||
scale: z.number().min(PATTERN_SCALE.min).max(PATTERN_SCALE.max),
|
||||
angle,
|
||||
}),
|
||||
linear,
|
||||
radial,
|
||||
patterned,
|
||||
]);
|
||||
return { stop, paint };
|
||||
return { stop, paint, linear, radial, patterned };
|
||||
}
|
||||
|
||||
const docSchemas = paintSchemas(z.object, z.string().pipe(z.enum(BOOK_PATTERNS)));
|
||||
|
||||
export const bookPaintStopSchema = docSchemas.stop;
|
||||
export const bookPaintSchema = docSchemas.paint;
|
||||
/** The paints that are more than one colour, as the letters of a text take them too (book-text-effects.schema.ts). */
|
||||
export const bookLinearPaintSchema = docSchemas.linear;
|
||||
export const bookRadialPaintSchema = docSchemas.radial;
|
||||
export const bookPatternPaintSchema = docSchemas.patterned;
|
||||
export type BookPaint = z.infer<typeof bookPaintSchema>;
|
||||
export type BookPaintStop = z.infer<typeof bookPaintStopSchema>;
|
||||
export type BookGradientPaint = Extract<BookPaint, { type: 'linear' | 'radial' }>;
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('the paint contract', () => {
|
||||
for (const p of [{ type: 'solid', color: '#abcdef', alpha: 0.4 }, linear, radial, stripes]) {
|
||||
expect(bookPaintSchema.parse(p)).toEqual(p);
|
||||
}
|
||||
expect(BOOK_PATTERNS).toHaveLength(12);
|
||||
expect(BOOK_PATTERNS).toHaveLength(14);
|
||||
for (const pattern of BOOK_PATTERNS) expect(bookPaintSchema.safeParse({ ...stripes, pattern }).success).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -43,8 +43,13 @@ import type { BookDocument } from './book.schema';
|
||||
* - 9: text effects and fitting (`transform`, `vAlign`, `outline`, `shadow`, `highlight`,
|
||||
* `curve`, `fillPaint`, `fit`, `fitMax`, book-text-effects.schema.ts), text up to 400 pt and
|
||||
* tracking up to 1.5 em.
|
||||
* - 10: letters filled with a gradient or a pattern (`fillPaint` of type `linear`, `radial`,
|
||||
* `pattern`, book-text-effects.schema.ts).
|
||||
* - 11: the newer travel looks: a summary's `look`, a country's `look`, the stamp, tag,
|
||||
* pill, ribbon, sticker, patch and sketch marks, and the `numbered` and `ends` stops of
|
||||
* a map.
|
||||
*/
|
||||
export const BOOK_SCHEMA_REV = 9;
|
||||
export const BOOK_SCHEMA_REV = 11;
|
||||
|
||||
/** The `code` a refused save carries in its body when the stored book is newer than the writer. */
|
||||
export const BOOK_SCHEMA_NEWER_CODE = 'book_schema_newer';
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import en from '../i18n/en';
|
||||
import { BOOK_FRAME_SHAPES, BOOK_SHAPE_GROUPS } from './book-shape-catalogue';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Every shape the Studio offers has a name in en, which the client's
|
||||
* `shapeName` reads for the tooltip and the screen reader of the shape's
|
||||
* button (client/scripts/i18n-keys.mjs allows the key built from the id).
|
||||
*/
|
||||
describe('the shape catalogue', () => {
|
||||
it('BKSHAPE-001: names every shape of the shelves and every frame shape in en', () => {
|
||||
const ids = new Set([...BOOK_SHAPE_GROUPS.flatMap((g) => g.shapes), ...BOOK_FRAME_SHAPES]);
|
||||
const strings = en as Record<string, string>;
|
||||
for (const id of ids) expect(strings[`journey.studio.shapeName.${id}`], id).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -10,10 +10,19 @@ const n = (v: number) => `${Math.round(v * 10) / 10}`;
|
||||
|
||||
const CASES: Record<string, string> = { upper: 'capitals', lower: 'lower case', smallcaps: 'small caps' };
|
||||
|
||||
function stopWords(stops: readonly { color: string }[]): string {
|
||||
return `from ${stops[0]?.color ?? ''} to ${stops[stops.length - 1]?.color ?? ''}`;
|
||||
}
|
||||
|
||||
function fillWords(fill: NonNullable<BookTextEffects['fillPaint']>): string {
|
||||
if (fill.type === 'photo') {
|
||||
return `letters filled with photo ${fill.photoId} (focal ${fill.focalX.toFixed(2)}/${fill.focalY.toFixed(2)}, zoom ${n(fill.zoom)})`;
|
||||
}
|
||||
if (fill.type === 'linear') return `letters in a linear gradient at ${n(fill.angle)} deg, ${stopWords(fill.stops)}`;
|
||||
if (fill.type === 'radial') return `letters in a radial gradient, ${stopWords(fill.stops)}`;
|
||||
if (fill.type === 'pattern') {
|
||||
return `letters patterned ${fill.pattern} in ${fill.fg} on ${fill.bg ?? 'clear'} at scale ${n(fill.scale)}`;
|
||||
}
|
||||
return fill.alpha === 0 ? 'hollow letters (outline only)' : `fill ${fill.color} at alpha ${n(fill.alpha)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { bookTextIsDrawn } from './book-text-effects.schema';
|
||||
import { bookTextEffectWords } from './book-text-describe';
|
||||
import { bookTextIsDrawn, bookTextPaintFill } from './book-text-effects.schema';
|
||||
import { bookTextHasOwnGround } from './book-text-findings';
|
||||
import { bookTextElementSchema } from './book.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
@@ -75,3 +77,36 @@ describe('the effect fields of a text', () => {
|
||||
expect(bookTextIsDrawn(parse({ fillPaint: { type: 'photo', photoId: 3 } }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('letters filled with a gradient or a pattern', () => {
|
||||
const gradient = {
|
||||
type: 'linear',
|
||||
angle: 90,
|
||||
stops: [
|
||||
{ at: 0, color: '#ff6600' },
|
||||
{ at: 1, color: '#ffcc00' },
|
||||
],
|
||||
};
|
||||
const gingham = { type: 'pattern', pattern: 'gingham', fg: '#d62828', bg: '#ffffff', scale: 1, angle: 0 };
|
||||
|
||||
it('BKFX-005: reads a gradient and a pattern in the shape a shape takes them, and draws them as SVG', () => {
|
||||
const lin = parse({ fillPaint: gradient });
|
||||
const pat = parse({ fillPaint: gingham });
|
||||
expect(bookTextPaintFill(lin)).toEqual(gradient);
|
||||
expect(bookTextPaintFill(pat)).toEqual(gingham);
|
||||
expect(bookTextIsDrawn(lin)).toBe(true);
|
||||
expect(bookTextIsDrawn(pat)).toBe(true);
|
||||
expect(bookTextPaintFill(parse({ fillPaint: { type: 'photo', photoId: 3 } }))).toBeNull();
|
||||
expect(() => parse({ fillPaint: { ...gingham, pattern: 'plaid' } })).toThrow();
|
||||
});
|
||||
|
||||
it('BKFX-006: names the paint for a model that cannot see the page, and judges no contrast against it', () => {
|
||||
expect(bookTextEffectWords({ ...parse({ fillPaint: gradient }), size: 40 })).toContain(
|
||||
'linear gradient at 90 deg, from #ff6600 to #ffcc00',
|
||||
);
|
||||
expect(bookTextEffectWords({ ...parse({ fillPaint: gingham }), size: 40 })).toContain(
|
||||
'patterned gingham in #d62828 on #ffffff',
|
||||
);
|
||||
expect(bookTextHasOwnGround(parse({ fillPaint: gingham }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
bookLinearPaintSchema,
|
||||
bookPatternPaintSchema,
|
||||
bookRadialPaintSchema,
|
||||
type BookPaint,
|
||||
} from './book-paint.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
@@ -27,10 +34,11 @@ import { z } from 'zod';
|
||||
*
|
||||
* ── The fill and the paint system ──────────────────────────────────────────
|
||||
*
|
||||
* `fillPaint` takes a solid colour with its own alpha or a photograph today.
|
||||
* Its solid variant has the shape the book's paint type gives a solid fill, so
|
||||
* gradients and patterns join this union as further members, without a
|
||||
* reader of today's fields changing.
|
||||
* `fillPaint` takes a solid colour with its own alpha, a photograph, or one
|
||||
* of the book's paints of more than one colour: a linear or radial gradient or
|
||||
* a pattern, in exactly the shape a shape or a page takes them, so one picker
|
||||
* and one renderer serve all three. A gradient or pattern fill is drawn as SVG
|
||||
* like a photo fill, the paint clipped to the letters.
|
||||
*/
|
||||
|
||||
const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected #rrggbb');
|
||||
@@ -84,7 +92,10 @@ export const bookTextCurveSchema = z.object({
|
||||
});
|
||||
export type BookTextCurve = z.infer<typeof bookTextCurveSchema>;
|
||||
|
||||
/** What fills the letters in place of `color`: a colour with its own alpha (0 leaves an outline only), or a photograph. */
|
||||
/**
|
||||
* What fills the letters in place of `color`: a colour with its own alpha (0
|
||||
* leaves an outline only), a photograph, or a gradient or pattern.
|
||||
*/
|
||||
export const bookTextFillSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('solid'), color: hex, alpha: alpha.default(1) }),
|
||||
z.object({
|
||||
@@ -95,9 +106,18 @@ export const bookTextFillSchema = z.discriminatedUnion('type', [
|
||||
focalY: z.number().min(0).max(1).default(0.5),
|
||||
zoom: z.number().min(1).max(4).default(1),
|
||||
}),
|
||||
bookLinearPaintSchema,
|
||||
bookRadialPaintSchema,
|
||||
bookPatternPaintSchema,
|
||||
]);
|
||||
export type BookTextFill = z.infer<typeof bookTextFillSchema>;
|
||||
|
||||
/** The letters' gradient or pattern, as the paint a shape would take, or null for any other fill. */
|
||||
export function bookTextPaintFill(el: { fillPaint?: BookTextFill | null }): BookPaint | null {
|
||||
const fill = el.fillPaint;
|
||||
return fill && (fill.type === 'linear' || fill.type === 'radial' || fill.type === 'pattern') ? fill : null;
|
||||
}
|
||||
|
||||
/** `shrink` sets the text at the largest size up to `fitMax` that holds the frame, solved on edit and stored in `size`. */
|
||||
export const BOOK_TEXT_FITS = ['none', 'shrink'] as const;
|
||||
|
||||
@@ -133,10 +153,10 @@ export interface BookTextEffects {
|
||||
|
||||
/**
|
||||
* Whether a text is drawn as SVG rather than as a box of HTML type: on a
|
||||
* curve, or filled with a photograph. Such a text is set line by line as
|
||||
* written, never wrapped, which is what word art wants and what the checks
|
||||
* measure it by.
|
||||
* curve, or filled with a photograph, a gradient or a pattern. Such a text is
|
||||
* set line by line as written, never wrapped, which is what word art wants and
|
||||
* what the checks measure it by.
|
||||
*/
|
||||
export function bookTextIsDrawn(el: BookTextEffects): boolean {
|
||||
return !!el.curve || el.fillPaint?.type === 'photo';
|
||||
return !!el.curve || el.fillPaint?.type === 'photo' || !!bookTextPaintFill(el);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ const MIN_FILL_PX_PER_MM = 200 / 25.4;
|
||||
*/
|
||||
export function bookTextHasOwnGround(el: BookTextElement): boolean {
|
||||
if (el.highlight) return true;
|
||||
if (el.fillPaint && (el.fillPaint.type === 'photo' || el.fillPaint.alpha < 0.5)) return true;
|
||||
// A photo, a gradient or a pattern in the letters is no one colour the page could be judged against.
|
||||
if (el.fillPaint && (el.fillPaint.type !== 'solid' || el.fillPaint.alpha < 0.5)) return true;
|
||||
return !!el.outline && el.outline.width >= ptToMm(el.size) * 0.04;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { textHeightNeeded } from './book-findings';
|
||||
import { bookLetterMm, textHeightNeeded } from './book-findings';
|
||||
import { blankBook } from './book-pages';
|
||||
import {
|
||||
bookCasedText,
|
||||
@@ -138,4 +138,12 @@ describe('fitting a page', () => {
|
||||
expect((next.spreads[0]?.elements[0] as BookTextElement).size).toBeLessThan(60);
|
||||
expect(next.spreads[1]).toBe(doc.spreads[1]);
|
||||
});
|
||||
|
||||
it('BKFIT-011: a bold condensed face is measured at its own width, not the regular widened by a guess', () => {
|
||||
const oswald = (weight: number) => bookLetterMm(text({ font: 'oswald', weight, size: 20 }));
|
||||
// Oswald's 700 sets about 13% wider than its 400; a flat 6% would let a bold heading overrun its frame.
|
||||
expect(oswald(700) / oswald(400)).toBeGreaterThan(1.1);
|
||||
const vibes = (weight: number) => bookLetterMm(text({ font: 'vibes', weight, size: 20 }));
|
||||
expect(vibes(700) / vibes(400)).toBeCloseTo(1.06, 6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,9 @@ export const TEXT_EFFECT_PROPS = {
|
||||
shadow: f.shadow.describe('Hard, never blurred; dx, dy in mm.'),
|
||||
highlight: f.highlight.describe('A marker band behind each line; pads in mm.'),
|
||||
curve: f.curve.describe('One line on an arc (bend > 0 arches up) or round a circle (start: degrees, 0 top).'),
|
||||
fillPaint: f.fillPaint.describe('Replaces color: solid (alpha 0 is hollow, with an outline) or a gallery photo.'),
|
||||
fillPaint: f.fillPaint.describe(
|
||||
'Replaces color: solid (alpha 0 is hollow, with an outline), a gallery photo, or a linear/radial gradient or pattern shaped as on a shape.',
|
||||
),
|
||||
fit: f.fit.describe('shrink: the largest size up to size that holds the frame.'),
|
||||
};
|
||||
|
||||
|
||||
@@ -504,8 +504,12 @@ export const bookMapElementSchema = z.object({
|
||||
* That fallback is the designed state rather than a failure: a printed page
|
||||
* with a few plain dots between the pictures reads as a route, and one with
|
||||
* empty rings reads as a bug.
|
||||
*
|
||||
* `numbered` draws every stop as its number in the order travelled, the way
|
||||
* a guidebook's map refers to its text. `ends` marks where the journey began
|
||||
* and where it ended, and leaves the stops between them as small dots.
|
||||
*/
|
||||
pinStyle: z.enum(['dot', 'photo']).default('dot'),
|
||||
pinStyle: z.enum(['dot', 'photo', 'numbered', 'ends']).default('dot'),
|
||||
/** ISO-3166-1 alpha-2, the countries whose outlines to draw. */
|
||||
countries: z.array(z.string().length(2)).max(MAX_BOOK_COUNTRIES).default([]),
|
||||
/**
|
||||
@@ -636,6 +640,47 @@ export const bookMapElementSchema = z.object({
|
||||
tripId: z.number().int().positive().nullable().default(null),
|
||||
});
|
||||
|
||||
/** The looks of a summary, `classic` first: what every summary drew before there were looks. */
|
||||
export const BOOK_STATS_LOOKS = [
|
||||
'classic',
|
||||
'poster',
|
||||
'ticket',
|
||||
'tiles',
|
||||
'band',
|
||||
'medal',
|
||||
'odometer',
|
||||
'sign',
|
||||
'note',
|
||||
'sketch',
|
||||
] as const;
|
||||
export type BookStatsLook = (typeof BOOK_STATS_LOOKS)[number];
|
||||
|
||||
/** The looks of a country, `classic` first for the same reason. */
|
||||
export const BOOK_COUNTRY_LOOKS = ['classic', 'stamp', 'postcard', 'visa', 'flag', 'pin', 'label', 'sketch'] as const;
|
||||
export type BookCountryLook = (typeof BOOK_COUNTRY_LOOKS)[number];
|
||||
|
||||
/**
|
||||
* How a mark is set. The first four are what marks have always been; `stamp`
|
||||
* is an inked passport stamp, `tag` a luggage label with its eyelet, `pill`
|
||||
* the icon in a disc at the start of a capsule, `ribbon` a banner with forked
|
||||
* tails, `sticker` a die-cut sticker with its white edge, and `patch` an
|
||||
* embroidered patch with its stitching, and `sketch` a frame drawn by hand.
|
||||
*/
|
||||
export const BOOK_BADGE_STYLES = [
|
||||
'plain',
|
||||
'chip',
|
||||
'outline',
|
||||
'stacked',
|
||||
'stamp',
|
||||
'tag',
|
||||
'pill',
|
||||
'ribbon',
|
||||
'sticker',
|
||||
'patch',
|
||||
'sketch',
|
||||
] as const;
|
||||
export type BookBadgeStyle = (typeof BOOK_BADGE_STYLES)[number];
|
||||
|
||||
export const BOOK_METRICS = ['distance', 'days', 'steps', 'photos', 'countries', 'places', 'furthest'] as const;
|
||||
export type BookMetric = (typeof BOOK_METRICS)[number];
|
||||
|
||||
@@ -646,6 +691,17 @@ export const bookStatsElementSchema = z.object({
|
||||
/** Which figures, in the order they are drawn. */
|
||||
metrics: z.array(z.enum(BOOK_METRICS)).max(7).default(['distance', 'days', 'steps', 'photos']),
|
||||
layout: z.enum(['grid', 'row', 'column']).default('grid'),
|
||||
/**
|
||||
* How the figures are drawn. Absent, or `classic`, is the icon, figure and
|
||||
* caption every summary placed so far draws: optional rather than defaulted,
|
||||
* so a stored book reads and saves exactly as it did. The others are newer
|
||||
* compositions: one figure set as a poster with the rest beside it, a ticket
|
||||
* stub, a tile per figure, a band of figures between hairlines, a round
|
||||
* medal per figure with its name running round the rim, the drums of an
|
||||
* odometer, a road sign listing them, a handwritten note, and a pencil
|
||||
* sketch with a hand-drawn frame.
|
||||
*/
|
||||
look: z.enum(BOOK_STATS_LOOKS).optional(),
|
||||
showIcons: z.boolean().default(true),
|
||||
units: bookUnitsSchema.default('metric'),
|
||||
/**
|
||||
@@ -681,6 +737,16 @@ export const bookCountriesElementSchema = z.object({
|
||||
showFlag: z.boolean().default(false),
|
||||
showName: z.boolean().default(true),
|
||||
align: z.enum(['left', 'center', 'right']).default('center'),
|
||||
/**
|
||||
* How a country is drawn. Absent, or `classic`, is the name over its
|
||||
* silhouette that every country placed so far draws, so a stored book stays
|
||||
* as it was. `stamp` sets it as a round passport stamp, `postcard` as
|
||||
* an airmail card, `visa` as an entry stamp with cut corners, `flag` as the
|
||||
* flag in cloth, `pin` as a map pin carrying the flag, and `label` as an old
|
||||
* luggage label with the name on a ribbon, `sketch` as the outline drawn by
|
||||
* hand and hatched, with the name written under it.
|
||||
*/
|
||||
look: z.enum(BOOK_COUNTRY_LOOKS).optional(),
|
||||
});
|
||||
|
||||
export const BOOK_BADGES = [
|
||||
@@ -724,7 +790,7 @@ export const bookBadgeElementSchema = z.object({
|
||||
* characters; the field is simply no longer only that.
|
||||
*/
|
||||
code: z.string().max(24).nullable().default(null),
|
||||
style: z.enum(['plain', 'chip', 'outline', 'stacked']).default('plain'),
|
||||
style: z.enum(BOOK_BADGE_STYLES).default('plain'),
|
||||
/**
|
||||
* Let the mark pick its own text colour.
|
||||
*
|
||||
|
||||
@@ -138,4 +138,6 @@ export const BOOK_PATTERN_LINES: Readonly<Record<BookPattern, string>> = {
|
||||
scales: 'Overlapping half circles, fish scales or roof tiles.',
|
||||
sunburst: 'Rays from a centre, a sunburst.',
|
||||
confetti: 'Scattered confetti.',
|
||||
linen: 'Fine woven threads, the texture of a cloth-bound book, best in a colour close to its ground.',
|
||||
grain: 'Fine scattered specks, the grain of paper or kraft card, best in a colour close to its ground.',
|
||||
};
|
||||
|
||||
@@ -356,6 +356,17 @@ describe('design_book', () => {
|
||||
expect(JSON.stringify(again.doc)).toBe(JSON.stringify(banded.doc));
|
||||
});
|
||||
|
||||
it('BOOKDSN-032: lays the cover out on a decorated cover template, and leaves a double-spread one out with a note', () => {
|
||||
const poster = designBook(null, input('rich'), { style: 'minimal', cover: { template: 'poster-pop-sunburst' } });
|
||||
const cover = poster.doc.spreads[0] as BookSpread;
|
||||
expect(cover.elements.some((e) => e.kind === 'photo')).toBe(true);
|
||||
expect(cover.elements.some((e) => e.kind === 'text' && e.binding?.source === 'journey.title')).toBe(true);
|
||||
const again = designBook(null, input('rich'), { style: 'minimal', cover: { template: 'poster-pop-sunburst' } });
|
||||
expect(JSON.stringify(again.doc)).toBe(JSON.stringify(poster.doc));
|
||||
const spread = designBook(null, input('rich'), { style: 'minimal', cover: { template: 'hero-script-word' } });
|
||||
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', () => {
|
||||
const stored = storedBook();
|
||||
const lockedPage = stored.spreads[2] as BookSpread;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { idSchema } from '../../common/primitives.schema';
|
||||
import { bookFontIdSchema, bookMetricSchema, bookPagePresetSchema } from '../book-op-enums';
|
||||
import { bookCoverTemplateIdSchema } from '../book-ops.schema';
|
||||
import { bookAnyTemplateIdSchema } from '../book-ops.schema';
|
||||
import { MAX_BOOK_TITLE } from '../book.schema';
|
||||
import { bookThemeCatalogue } from '../theme/book-theme-describe';
|
||||
import { BOOK_THEME_IDS } from '../theme/book-theme.schema';
|
||||
@@ -84,7 +84,7 @@ export const bookDesignDirectivesSchema = z.strictObject({
|
||||
.optional(),
|
||||
cover: z
|
||||
.strictObject({
|
||||
template: bookCoverTemplateIdSchema.optional(),
|
||||
template: bookAnyTemplateIdSchema.optional(),
|
||||
archetype: bookCoverArchetypeSchema.optional(),
|
||||
lockup: bookCoverLockupSchema.optional(),
|
||||
title: z.string().max(MAX_BOOK_TITLE).optional(),
|
||||
|
||||
@@ -6,6 +6,8 @@ import { formatBookDate } from '../book-text';
|
||||
import { bookTravelCountries, bookTravelMap, bookTravelStats } from '../book-travel';
|
||||
import type { BookElement, BookMetric, BookSpread, BookTextElement } from '../book.schema';
|
||||
import type { JourneyStats } from '../journey-stats.schema';
|
||||
import { applyDecoratedTemplate } from '../templates/template-apply';
|
||||
import { findDecoratedTemplate } from '../templates/template-catalogue';
|
||||
import {
|
||||
bleedPage,
|
||||
frameOf,
|
||||
@@ -90,8 +92,22 @@ export function composeCover(
|
||||
*/
|
||||
function withCoverTemplate(c: ComposeCtx, cover: BookSpread): BookSpread {
|
||||
const wanted = c.directives.cover?.template;
|
||||
const tpl = wanted ? BOOK_COVER_TEMPLATES.find((t) => t.id === wanted) : undefined;
|
||||
if (!tpl) return cover;
|
||||
if (!wanted) return cover;
|
||||
const decorated = findDecoratedTemplate(wanted);
|
||||
if (decorated) {
|
||||
if (decorated.kind !== 'single') {
|
||||
c.notes.push(`${wanted} is a double-spread template, so cover.template is left out`);
|
||||
return cover;
|
||||
}
|
||||
// A designed page keeps its own faces and colours, in the book's theme, and takes the cover's photo and words.
|
||||
const opts = { theme: c.look.theme, travel: c.travel, locale: c.locale, newId: c.newId };
|
||||
return { ...applyDecoratedTemplate(cover, decorated, c.page, opts), parked: [] };
|
||||
}
|
||||
const tpl = BOOK_COVER_TEMPLATES.find((t) => t.id === wanted);
|
||||
if (!tpl) {
|
||||
c.notes.push(`${wanted} is a double-spread layout, so cover.template is left out`);
|
||||
return cover;
|
||||
}
|
||||
const laid = applyBookTemplate(cover, tpl, c.page);
|
||||
const elements = laid.elements.map((e): BookElement => {
|
||||
if (e.kind === 'text') return { ...e, id: c.newId('t'), color: c.look.ink };
|
||||
|
||||
@@ -614,4 +614,250 @@ export const ASIA_STICKERS: readonly BookStickerDef[] = [
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
{
|
||||
id: 'tiger',
|
||||
theme: 'asia',
|
||||
tags: ['tiger', 'bengal tiger', 'india', 'jungle', 'safari', 'wildlife', 'animal', 'big cat'],
|
||||
descr:
|
||||
'A friendly Bengal tiger walking left with its face turned to the viewer, orange fur with black stripes, white cheeks and belly and a curled striped tail.',
|
||||
box: [110, 80],
|
||||
slots: [
|
||||
{ name: 'fur', color: '#f08a24' },
|
||||
{ name: 'belly', color: '#fbf6ee' },
|
||||
{ name: 'stripes', color: '#262229' },
|
||||
{ name: 'nose', color: '#e8828a' },
|
||||
],
|
||||
layers: [
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M18 75.5C18 76.94 36.8 78.1 60 78.1C83.2 78.1 102 76.94 102 75.5C102 74.06 83.2 72.9 60 72.9C36.8 72.9 18 74.06 18 75.5Z',
|
||||
o: 0.12,
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M46 46L48 70C48 73 49.5 74.5 52.5 74.5L57.5 74.5C59.5 74.5 59.5 71.5 57.5 70.5L56 46ZM80 44L86 70C86.6 73 88 74.5 91 74.5L95.5 74.5C97.5 74.5 97.5 71.5 95.5 70.5L91 44Z',
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M46 46L48 70C48 73 49.5 74.5 52.5 74.5L57.5 74.5C59.5 74.5 59.5 71.5 57.5 70.5L56 46ZM80 44L86 70C86.6 73 88 74.5 91 74.5L95.5 74.5C97.5 74.5 97.5 71.5 95.5 70.5L91 44Z',
|
||||
o: 0.28,
|
||||
},
|
||||
{ p: 0, d: 'M88 37C99 34 101 22 99 14C97.8 9 101 5.5 105 7', w: 4.4 },
|
||||
{
|
||||
p: 2,
|
||||
d: 'M96.9 30.4L97.3 29.7L97.6 29.1L98 28.4M99.3 24.1L99.4 23.4L99.5 22.7L99.6 21.9M99.6 17.6L99.5 16.9L99.4 16.2L99.3 15.5M100.1 8.1L100.8 7.4L101.7 6.9L102.7 6.7L103.8 6.7L105 7',
|
||||
w: 4.4,
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M32 43C32 51.01 45.43 57.5 62 57.5C78.57 57.5 92 51.01 92 43C92 34.99 78.57 28.5 62 28.5C45.43 28.5 32 34.99 32 43Z',
|
||||
},
|
||||
{ p: 0, d: 'M34 36C38 31 45 30 50 31.5L50 52L35 52Z' },
|
||||
{
|
||||
p: 0,
|
||||
d: 'M72 36C84 31 95 38 93 50C92.5 53 91 56 89 58L91.5 70C92 73 90.5 74.5 88 74.5L82 74.5C79.5 74.5 79.5 71.5 82 70.5L80.5 58C74 56 69 46 72 36Z',
|
||||
},
|
||||
{ p: 0, d: 'M33 42L30.5 70C30.3 73 31.8 74.5 34.8 74.5L41.3 74.5C43.3 74.5 43.3 71.5 41.3 70.5L44.5 44Z' },
|
||||
{
|
||||
p: 1,
|
||||
d: 'M40 52.3L43 53.6L46 54.7L49 55.5L52 56.1L55 56.5L58 56.8L61 56.9L64 56.9L67 56.7L70 56.4L73 55.9L76 55.2L79 54.3L82 53.2L82 53.2L79 53.4L76 53.4L73 53.3L70 53.1L67 52.9L64 52.8L61 52.7L58 52.7L55 52.7L52 52.8L49 52.8L46 52.8L43 52.7L40 52.3Z',
|
||||
},
|
||||
{ p: 'light', d: 'M48 33.2C56 31 66 30.8 74 32', w: 1.4, o: 0.3 },
|
||||
{
|
||||
p: 2,
|
||||
d: 'M43.6 32.3L46.4 31.4C45.4 34.53 44.27 37.77 43 41.1C43.2 37.77 43.4 34.83 43.6 32.3ZM49.1 30.7L52.9 30C51.7 34.67 50.53 39.5 49.4 44.5C49.47 39.5 49.37 34.9 49.1 30.7ZM55.2 29.7L57.8 29.4C57.2 31.93 56.43 34.7 55.5 37.7C55.37 34.7 55.27 32.03 55.2 29.7ZM60 29.3L64 29.3C63.13 34.1 62.33 39.17 61.6 44.5C61.27 39.17 60.73 34.1 60 29.3ZM66.1 29.4L68.9 29.7C68.7 32.37 68.37 35.37 67.9 38.7C67.3 35.37 66.7 32.27 66.1 29.4ZM71.1 30L74.9 30.7C74.57 34.57 74.27 38.83 74 43.5C73.2 38.83 72.23 34.33 71.1 30ZM77.2 31.3L79.8 32.1C80 34.3 80.03 36.9 79.9 39.9C78.97 36.9 78.07 34.03 77.2 31.3ZM82.4 33.2L85.6 34.8C85.8 37 85.93 39.77 86 43.1C84.87 39.77 83.67 36.47 82.4 33.2ZM87.9 36.5L90.1 38.7C90.5 39.37 90.67 40.7 90.6 42.7C89.6 40.7 88.7 38.63 87.9 36.5ZM47 54.6C48.6 51.6 50.4 49.6 53 48.6C51.6 51 51 53 51 55.6ZM58 56.6C59.4 53 61.4 50.6 64.4 49.4C62.8 52.2 62.2 54.6 62.2 57.4ZM69 56.4C70.2 53.2 72 51.2 74.6 50C73.2 52.4 72.8 54.4 72.8 56.8ZM86 40.5C82 43 80 47 80.5 51.5C82 47.5 84.5 44 88.2 42ZM90.4 46C86.8 48.4 85 51.6 85 55C86.8 52 88.6 50 91.6 49ZM32.4 56L37.4 56.6L32.3 58.4ZM31.8 62L37 62.6L31.7 64.4ZM43.6 52L39.6 53L43.5 54.6ZM80.9 62L85.5 62.4L81.1 64.2ZM81.3 66.4L85.4 66.8L81.4 68.4ZM90.3 61L86.6 62L90.6 63.4Z',
|
||||
},
|
||||
{ p: 'shade', d: 'M33.6 50C44 57.6 70 59 86.6 52.4C80 56.2 74 57.4 62 57.5C50 57.5 40 55 33.6 50Z', o: 0.12 },
|
||||
{ p: 'shade', d: 'M35.3 72.6L35.3 74M38.3 72.6L38.3 74M84 72.6L84 74M87 72.6L87 74', w: 0.7, o: 0.35 },
|
||||
{
|
||||
p: 0,
|
||||
d: 'M7.4 23.5C7.4 26.59 9.91 29.1 13 29.1C16.09 29.1 18.6 26.59 18.6 23.5C18.6 20.41 16.09 17.9 13 17.9C9.91 17.9 7.4 20.41 7.4 23.5ZM25.4 23.5C25.4 26.59 27.91 29.1 31 29.1C34.09 29.1 36.6 26.59 36.6 23.5C36.6 20.41 34.09 17.9 31 17.9C27.91 17.9 25.4 20.41 25.4 23.5Z',
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M9.6 23.5C9.6 24.71 10.25 25.84 11.3 26.44C12.35 27.05 13.65 27.05 14.7 26.44C15.75 25.84 16.4 24.71 16.4 23.5C16.4 22.29 15.75 21.16 14.7 20.56C13.65 19.95 12.35 19.95 11.3 20.56C10.25 21.16 9.6 22.29 9.6 23.5ZM27.6 23.5C27.6 24.71 28.25 25.84 29.3 26.44C30.35 27.05 31.65 27.05 32.7 26.44C33.75 25.84 34.4 24.71 34.4 23.5C34.4 22.29 33.75 21.16 32.7 20.56C31.65 19.95 30.35 19.95 29.3 20.56C28.25 21.16 27.6 22.29 27.6 23.5Z',
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
d: 'M11.4 24C11.4 25.1 12.3 26 13.4 26C14.5 26 15.4 25.1 15.4 24C15.4 22.9 14.5 22 13.4 22C12.3 22 11.4 22.9 11.4 24ZM28.6 24C28.6 25.1 29.5 26 30.6 26C31.7 26 32.6 25.1 32.6 24C32.6 22.9 31.7 22 30.6 22C29.5 22 28.6 22.9 28.6 24Z',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M22 20C30.5 20 36 25.5 36 33C36 35 37.5 37 39 38.4C36.6 38.6 35.6 39.4 35.4 40.6C37 41.8 37.2 43.4 36.4 44.6C34 49 28.6 51.4 22 51.4C15.4 51.4 10 49 7.6 44.6C6.8 43.4 7 41.8 8.6 40.6C8.4 39.4 7.4 38.6 5 38.4C6.5 37 8 35 8 33C8 25.5 13.5 20 22 20Z',
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
d: 'M22 38C25.4 34.8 31.6 36.2 32 41C32.4 46.6 27.8 50.6 22 50.6C16.2 50.6 11.6 46.6 12 41C12.4 36.2 18.6 34.8 22 38Z',
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
d: 'M12 30.2C12 30.99 12.69 31.71 13.8 32.11C14.91 32.5 16.29 32.5 17.4 32.11C18.51 31.71 19.2 30.99 19.2 30.2C19.2 29.41 18.51 28.69 17.4 28.29C16.29 27.9 14.91 27.9 13.8 28.29C12.69 28.69 12 29.41 12 30.2ZM24.8 30.2C24.8 30.99 25.49 31.71 26.6 32.11C27.71 32.5 29.09 32.5 30.2 32.11C31.31 31.71 32 30.99 32 30.2C32 29.41 31.31 28.69 30.2 28.29C29.09 27.9 27.71 27.9 26.6 28.29C25.49 28.69 24.8 29.41 24.8 30.2Z',
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M19.2 21.5L22 28L24.8 21.5C22.93 21.17 21.07 21.17 19.2 21.5ZM15.4 22.6L18.4 26.4L18.6 21.6ZM28.6 22.6L25.6 26.4L25.4 21.6ZM8.2 31.2L13.6 33.6L8.3 34.4ZM35.8 31.2L30.4 33.6L35.7 34.4ZM8.6 37.4L12.6 38.6L9 39.8ZM35.4 37.4L31.4 38.6L35 39.8Z',
|
||||
},
|
||||
{
|
||||
p: 'ink',
|
||||
d: 'M14.5 33C14.5 34.05 15.35 34.9 16.4 34.9C17.45 34.9 18.3 34.05 18.3 33C18.3 31.95 17.45 31.1 16.4 31.1C15.35 31.1 14.5 31.95 14.5 33ZM25.7 33C25.7 34.05 26.55 34.9 27.6 34.9C28.65 34.9 29.5 34.05 29.5 33C29.5 31.95 28.65 31.1 27.6 31.1C26.55 31.1 25.7 31.95 25.7 33Z',
|
||||
},
|
||||
{
|
||||
p: 'paper',
|
||||
d: 'M16.3 32.3C16.3 32.55 16.43 32.78 16.65 32.91C16.87 33.03 17.13 33.03 17.35 32.91C17.57 32.78 17.7 32.55 17.7 32.3C17.7 32.05 17.57 31.82 17.35 31.69C17.13 31.57 16.87 31.57 16.65 31.69C16.43 31.82 16.3 32.05 16.3 32.3ZM27.5 32.3C27.5 32.55 27.63 32.78 27.85 32.91C28.07 33.03 28.33 33.03 28.55 32.91C28.77 32.78 28.9 32.55 28.9 32.3C28.9 32.05 28.77 31.82 28.55 31.69C28.33 31.57 28.07 31.57 27.85 31.69C27.63 31.82 27.5 32.05 27.5 32.3Z',
|
||||
},
|
||||
{ p: 3, d: 'M18.6 39.6C18.6 38.4 25.4 38.4 25.4 39.6C25.4 41 23.4 42.6 22 42.6C20.6 42.6 18.6 41 18.6 39.6Z' },
|
||||
{ p: 'ink', d: 'M22 42.6L22 44.4M22 44.4C21 46 18.6 46 18 44.6M22 44.4C23 46 25.4 46 26 44.6', w: 0.8 },
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M15.1 44.5C15.1 44.72 15.28 44.9 15.5 44.9C15.72 44.9 15.9 44.72 15.9 44.5C15.9 44.28 15.72 44.1 15.5 44.1C15.28 44.1 15.1 44.28 15.1 44.5ZM13.6 43C13.6 43.22 13.78 43.4 14 43.4C14.22 43.4 14.4 43.22 14.4 43C14.4 42.78 14.22 42.6 14 42.6C13.78 42.6 13.6 42.78 13.6 43ZM28.1 44.5C28.1 44.64 28.18 44.77 28.3 44.85C28.42 44.92 28.58 44.92 28.7 44.85C28.82 44.77 28.9 44.64 28.9 44.5C28.9 44.36 28.82 44.23 28.7 44.15C28.58 44.08 28.42 44.08 28.3 44.15C28.18 44.23 28.1 44.36 28.1 44.5ZM29.6 43C29.6 43.14 29.68 43.27 29.8 43.35C29.92 43.42 30.08 43.42 30.2 43.35C30.32 43.27 30.4 43.14 30.4 43C30.4 42.86 30.32 42.73 30.2 42.65C30.08 42.58 29.92 42.58 29.8 42.65C29.68 42.73 29.6 42.86 29.6 43Z',
|
||||
o: 0.35,
|
||||
},
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
{
|
||||
id: 'monkey',
|
||||
theme: 'asia',
|
||||
tags: ['monkey', 'macaque', 'india', 'temple', 'jungle', 'wildlife', 'animal', 'banana'],
|
||||
descr:
|
||||
'A cheerful brown macaque sitting with its knees up, holding a yellow banana in both hands, its long tail curling out in a spiral beside it.',
|
||||
box: [100, 105],
|
||||
slots: [
|
||||
{ name: 'fur', color: '#9c6b45' },
|
||||
{ name: 'belly', color: '#d9b58c' },
|
||||
{ name: 'skin', color: '#f2c4a8' },
|
||||
{ name: 'banana', color: '#f5cf45' },
|
||||
],
|
||||
layers: [
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M18 98.6C18 99.46 24.86 100.25 36 100.68C47.14 101.11 60.86 101.11 72 100.68C83.14 100.25 90 99.46 90 98.6C90 97.74 83.14 96.95 72 96.52C60.86 96.09 47.14 96.09 36 96.52C24.86 96.95 18 97.74 18 98.6Z',
|
||||
o: 0.12,
|
||||
},
|
||||
{ p: 0, d: 'M60 92C76 98 92 94 93 78C94 66 84 60 77 64C71 68 73 77 80 76', w: 4.4 },
|
||||
{ p: 'shade', d: 'M60 92C76 98 92 94 93 78C94 66 84 60 77 64C71 68 73 77 80 76', w: 4.4, o: 0.12 },
|
||||
{ p: 0, d: 'M32 94C25 84 26 64 36 55C42 50 54 50 60 55C70 64 71 84 64 94C56 97 40 97 32 94Z' },
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M60 55C70 64 71 84 64 94C61 95 58 95.6 56 96C62 86 63 68 56 54C57.6 54 59 54.4 60 55Z',
|
||||
o: 0.14,
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
d: 'M38 75C38 82.46 42.48 88.5 48 88.5C53.52 88.5 58 82.46 58 75C58 67.54 53.52 61.5 48 61.5C42.48 61.5 38 67.54 38 75Z',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M25.5 84C25.5 89.25 29.75 93.5 35 93.5C40.25 93.5 44.5 89.25 44.5 84C44.5 78.75 40.25 74.5 35 74.5C29.75 74.5 25.5 78.75 25.5 84Z',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M51.5 84C51.5 89.25 55.75 93.5 61 93.5C66.25 93.5 70.5 89.25 70.5 84C70.5 78.75 66.25 74.5 61 74.5C55.75 74.5 51.5 78.75 51.5 84Z',
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M27 88C29 93 33 94 37 93.4C41 93 44 89.6 44.4 86C42 90 37 91.6 33 91C30 90.6 28 89.4 27 88Z',
|
||||
o: 0.16,
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M52 88C54 93 58 94 62 93.4C66 93 69 89.6 70.4 86C68 90 63 91.6 59 91C56 90.6 54 89.4 52 88Z',
|
||||
o: 0.16,
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M24 95C24 96.29 25.33 97.47 27.5 98.12C29.67 98.76 32.33 98.76 34.5 98.12C36.67 97.47 38 96.29 38 95C38 93.71 36.67 92.53 34.5 91.88C32.33 91.24 29.67 91.24 27.5 91.88C25.33 92.53 24 93.71 24 95ZM58 95C58 96.29 59.33 97.47 61.5 98.12C63.67 98.76 66.33 98.76 68.5 98.12C70.67 97.47 72 96.29 72 95C72 93.71 70.67 92.53 68.5 91.88C66.33 91.24 63.67 91.24 61.5 91.88C59.33 92.53 58 93.71 58 95Z',
|
||||
},
|
||||
{ p: 'shade', d: 'M26.5 94.6L26.5 97M29.5 95L29.5 97.6M62.5 95L62.5 97.6M65.5 94.6L65.5 97', w: 0.7, o: 0.3 },
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M27.4 80C29 76.4 32 75 35 75C38.4 75 41.6 77 43.2 80.4M52.8 80.4C54.4 77 57.6 75 61 75C64 75 67 76.4 68.6 80',
|
||||
w: 1,
|
||||
o: 0.2,
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M39.4 60.4C33.4 65.4 34.6 72.6 42.6 71.6M56.6 60.4C62.6 65.4 61.4 72.6 53.4 71.6',
|
||||
w: 8.6,
|
||||
o: 0.22,
|
||||
},
|
||||
{ p: 0, d: 'M39.4 60.4C33.4 65.4 34.6 72.6 42.6 71.6M56.6 60.4C62.6 65.4 61.4 72.6 53.4 71.6', w: 7 },
|
||||
{ p: 3, d: 'M35.4 63.4C39.4 74.6 56.4 76.4 62.4 62.4C58.4 67.6 42.4 69 35.4 63.4Z' },
|
||||
{ p: 'shade', d: 'M37.2 67.6C42 73.6 53.6 74.8 59.6 68.4C54.6 75.8 41.6 74.6 37.2 67.6Z', o: 0.2 },
|
||||
{ p: '#6b4a2b', d: 'M62 62.9L63.6 60.4', w: 1.5 },
|
||||
{
|
||||
p: '#6b4a2b',
|
||||
d: 'M35 63.9C35 64.22 35.17 64.52 35.45 64.68C35.73 64.84 36.07 64.84 36.35 64.68C36.63 64.52 36.8 64.22 36.8 63.9C36.8 63.58 36.63 63.28 36.35 63.12C36.07 62.96 35.73 62.96 35.45 63.12C35.17 63.28 35 63.58 35 63.9Z',
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M39.4 71C39.4 72.99 41.01 74.6 43 74.6C44.99 74.6 46.6 72.99 46.6 71C46.6 69.01 44.99 67.4 43 67.4C41.01 67.4 39.4 69.01 39.4 71Z',
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M49.4 71C49.4 72.99 51.01 74.6 53 74.6C54.99 74.6 56.6 72.99 56.6 71C56.6 69.01 54.99 67.4 53 67.4C51.01 67.4 49.4 69.01 49.4 71Z',
|
||||
},
|
||||
{ p: 'shade', d: 'M41 69.4L44.6 69.8M41 71.8L44.6 72M51.4 69.8L55 69.4M51.4 72L55 71.8', w: 0.6, o: 0.3 },
|
||||
{
|
||||
p: 0,
|
||||
d: 'M23.7 37C23.7 39.43 25 41.67 27.1 42.89C29.2 44.1 31.8 44.1 33.9 42.89C36 41.67 37.3 39.43 37.3 37C37.3 34.57 36 32.33 33.9 31.11C31.8 29.9 29.2 29.9 27.1 31.11C25 32.33 23.7 34.57 23.7 37ZM58.7 37C58.7 39.43 60 41.67 62.1 42.89C64.2 44.1 66.8 44.1 68.9 42.89C71 41.67 72.3 39.43 72.3 37C72.3 34.57 71 32.33 68.9 31.11C66.8 29.9 64.2 29.9 62.1 31.11C60 32.33 58.7 34.57 58.7 37Z',
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M26.6 37.2C26.6 38.7 27.4 40.09 28.7 40.84C30 41.59 31.6 41.59 32.9 40.84C34.2 40.09 35 38.7 35 37.2C35 35.7 34.2 34.31 32.9 33.56C31.6 32.81 30 32.81 28.7 33.56C27.4 34.31 26.6 35.7 26.6 37.2ZM61 37.2C61 39.52 62.88 41.4 65.2 41.4C67.52 41.4 69.4 39.52 69.4 37.2C69.4 34.88 67.52 33 65.2 33C62.88 33 61 34.88 61 37.2Z',
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M29.4 37.6C29.4 38.7 30.3 39.6 31.4 39.6C32.5 39.6 33.4 38.7 33.4 37.6C33.4 36.5 32.5 35.6 31.4 35.6C30.3 35.6 29.4 36.5 29.4 37.6ZM62.6 37.6C62.6 38.31 62.98 38.97 63.6 39.33C64.22 39.69 64.98 39.69 65.6 39.33C66.22 38.97 66.6 38.31 66.6 37.6C66.6 36.89 66.22 36.23 65.6 35.87C64.98 35.51 64.22 35.51 63.6 35.87C62.98 36.23 62.6 36.89 62.6 37.6Z',
|
||||
o: 0.15,
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M42.6 20.6C43.4 17 45.8 15.4 47 13.6C47.6 15.6 48 16.6 49.6 16.4C50.6 14.8 52.6 14 54.4 14.6C53 16.4 54 18.6 54.4 20.6Z',
|
||||
},
|
||||
{
|
||||
p: 0,
|
||||
d: 'M31 36.5C31 45.89 38.61 53.5 48 53.5C57.39 53.5 65 45.89 65 36.5C65 27.11 57.39 19.5 48 19.5C38.61 19.5 31 27.11 31 36.5Z',
|
||||
},
|
||||
{ p: 'light', d: 'M37 26.6C39.4 23.6 42.8 22 46 21.6', w: 1.6, o: 0.3 },
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M60.6 25C67.6 33 65.6 47.6 54.4 52.2C50.6 53.8 46 53.8 42 52.6C52 52.6 62 44 60.6 25Z',
|
||||
o: 0.12,
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M35 35C35 37.57 36.37 39.95 38.6 41.24C40.83 42.52 43.57 42.52 45.8 41.24C48.03 39.95 49.4 37.57 49.4 35C49.4 32.43 48.03 30.05 45.8 28.76C43.57 27.48 40.83 27.48 38.6 28.76C36.37 30.05 35 32.43 35 35Z',
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M46.6 35C46.6 37.57 47.97 39.95 50.2 41.24C52.43 42.52 55.17 42.52 57.4 41.24C59.63 39.95 61 37.57 61 35C61 32.43 59.63 30.05 57.4 28.76C55.17 27.48 52.43 27.48 50.2 28.76C47.97 30.05 46.6 32.43 46.6 35Z',
|
||||
},
|
||||
{
|
||||
p: 2,
|
||||
d: 'M38 44.2C38 48.51 42.48 52 48 52C53.52 52 58 48.51 58 44.2C58 39.89 53.52 36.4 48 36.4C42.48 36.4 38 39.89 38 44.2Z',
|
||||
},
|
||||
{
|
||||
p: '#e98f8f',
|
||||
d: 'M36.8 43.6C36.8 44.48 37.96 45.2 39.4 45.2C40.84 45.2 42 44.48 42 43.6C42 42.72 40.84 42 39.4 42C37.96 42 36.8 42.72 36.8 43.6ZM54 43.6C54 44.48 55.16 45.2 56.6 45.2C58.04 45.2 59.2 44.48 59.2 43.6C59.2 42.72 58.04 42 56.6 42C55.16 42 54 42.72 54 43.6Z',
|
||||
o: 0.45,
|
||||
},
|
||||
{
|
||||
p: 'ink',
|
||||
d: 'M40.3 35.6C40.3 36.87 41.33 37.9 42.6 37.9C43.87 37.9 44.9 36.87 44.9 35.6C44.9 34.33 43.87 33.3 42.6 33.3C41.33 33.3 40.3 34.33 40.3 35.6ZM51.1 35.6C51.1 36.87 52.13 37.9 53.4 37.9C54.67 37.9 55.7 36.87 55.7 35.6C55.7 34.33 54.67 33.3 53.4 33.3C52.13 33.3 51.1 34.33 51.1 35.6Z',
|
||||
},
|
||||
{
|
||||
p: 'paper',
|
||||
d: 'M42.5 34.8C42.5 35.24 42.86 35.6 43.3 35.6C43.74 35.6 44.1 35.24 44.1 34.8C44.1 34.36 43.74 34 43.3 34C42.86 34 42.5 34.36 42.5 34.8ZM53.3 34.8C53.3 35.24 53.66 35.6 54.1 35.6C54.54 35.6 54.9 35.24 54.9 34.8C54.9 34.36 54.54 34 54.1 34C53.66 34 53.3 34.36 53.3 34.8Z',
|
||||
},
|
||||
{ p: 0, d: 'M38.8 30.2C40.8 28.6 43.6 28.4 45.6 29.6M50.4 29.6C52.4 28.4 55.2 28.6 57.2 30.2', w: 1.1 },
|
||||
{
|
||||
p: 'ink',
|
||||
d: 'M45.8 42.2C45.8 42.81 46.16 43.3 46.6 43.3C47.04 43.3 47.4 42.81 47.4 42.2C47.4 41.59 47.04 41.1 46.6 41.1C46.16 41.1 45.8 41.59 45.8 42.2ZM48.6 42.2C48.6 42.81 48.96 43.3 49.4 43.3C49.84 43.3 50.2 42.81 50.2 42.2C50.2 41.59 49.84 41.1 49.4 41.1C48.96 41.1 48.6 41.59 48.6 42.2Z',
|
||||
o: 0.8,
|
||||
},
|
||||
{ p: 'ink', d: 'M43.4 46.4C46 49.4 50 49.4 52.6 46.4', w: 0.9 },
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user