Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 280e55bbc9 | |||
| e84060cdf7 | |||
| fbc85b05a1 | |||
| 01333c6ff3 | |||
| aecef03090 | |||
| 8dacd0a50c | |||
| 3c73da4427 | |||
| cff2e55db7 | |||
| 429a25faa9 | |||
| 486a2eeca3 | |||
| 26a6147e8d | |||
| a7b53988ff |
@@ -460,6 +460,20 @@ const exportDialog = (page: Page) => page.getByRole('dialog', { name: 'Export' }
|
||||
const thumbs = (page: Page) => page.locator('.st-thumb-row')
|
||||
/** The hit targets over the spread, one per element. */
|
||||
const sheetElements = (page: Page) => page.locator('.st-sheet > div[style*="left:"]')
|
||||
/**
|
||||
* The hit target of the first text on the spread on screen. The targets above the page are empty
|
||||
* boxes, so the one over the drawn words is found by where the words are and marked for the locator.
|
||||
*/
|
||||
async function markTextTarget(page: Page): Promise<void> {
|
||||
const words = page.locator('.st-sheet').getByText(/\p{L}{3,}/u).first()
|
||||
await expect(words).toBeVisible()
|
||||
await words.evaluate(el => {
|
||||
const r = el.getBoundingClientRect()
|
||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
|
||||
hit?.setAttribute('data-help-text', '')
|
||||
})
|
||||
}
|
||||
const textTarget = (page: Page) => page.locator('.st-sheet [data-help-text]')
|
||||
|
||||
async function studioSection(page: Page, name: string): Promise<void> {
|
||||
await railButton(page, name).click()
|
||||
@@ -1458,16 +1472,162 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
],
|
||||
},
|
||||
|
||||
'studio-my-layouts': {
|
||||
guide: guide('studio-my-layouts'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => railButton(p, 'Layouts'),
|
||||
act: async p => { await studioSection(p, 'Layouts') },
|
||||
},
|
||||
{
|
||||
// Pointed at rather than pressed, so the seed book keeps no layout of its own for the guides after this one.
|
||||
prepare: async p => {
|
||||
await sidePanel(p).getByRole('button', { name: 'Save this page as a layout' }).scrollIntoViewIfNeeded()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => sidePanel(p).locator('.st-saved-layouts'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-theme': {
|
||||
guide: guide('studio-theme'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
target: p => railButton(p, 'Theme'),
|
||||
act: async p => { await studioSection(p, 'Theme') },
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-theme-grid'),
|
||||
act: async p => {
|
||||
await sidePanel(p).locator('.st-theme-card').nth(1).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await sidePanel(p).getByRole('button', { name: 'Apply to the book' }).scrollIntoViewIfNeeded()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => sidePanel(p).locator('.st-theme-apply'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-stickers': {
|
||||
guide: guide('studio-stickers'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => { await studioSection(p, 'Elements') },
|
||||
target: p => sidePanel(p).getByRole('tab', { name: 'Stickers' }),
|
||||
act: async p => {
|
||||
await sidePanel(p).getByRole('tab', { name: 'Stickers' }).click()
|
||||
await expect(sidePanel(p).locator('.st-shape-grid').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-shape-grid').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-travel': {
|
||||
guide: guide('studio-travel'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
target: p => railButton(p, 'Travel'),
|
||||
act: async p => { await studioSection(p, 'Travel') },
|
||||
act: async p => {
|
||||
await studioSection(p, 'Travel')
|
||||
await expect(sidePanel(p).locator('.st-travel-card').first()).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: sidePanel,
|
||||
target: p => sidePanel(p).locator('.st-travel-grid').first(),
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-travel-card').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-text-effects': {
|
||||
guide: guide('studio-text-effects'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
await markTextTarget(p)
|
||||
},
|
||||
target: p => textTarget(p),
|
||||
act: async p => {
|
||||
await textTarget(p).click()
|
||||
await expect(inspector(p).getByRole('button', { name: 'Effects' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
const head = inspector(p).getByRole('button', { name: 'Effects' })
|
||||
if ((await head.getAttribute('aria-expanded')) !== 'true') await head.click()
|
||||
await head.scrollIntoViewIfNeeded()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => inspector(p).locator('.st-section').filter({ has: p.getByRole('button', { name: 'Effects' }) }),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-background': {
|
||||
guide: guide('studio-background'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => inspector(p).locator('.st-section').filter({ hasText: 'Page background' }).first(),
|
||||
},
|
||||
{
|
||||
target: p => inspector(p).locator('.st-paint-tabs').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-layers': {
|
||||
guide: guide('studio-layers'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => railButton(p, 'Layers'),
|
||||
act: async p => {
|
||||
await studioSection(p, 'Layers')
|
||||
await expect(sidePanel(p).locator('.st-layer').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-layer').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 29 KiB |
@@ -3,7 +3,7 @@
|
||||
"@typescript-eslint/no-non-null-assertion": 384,
|
||||
"@typescript-eslint/no-misused-promises": 849,
|
||||
"no-console": 88,
|
||||
"complexity": 222,
|
||||
"complexity": 221,
|
||||
"@typescript-eslint/no-explicit-any": 146,
|
||||
"trek/no-swallowed-catch": 130,
|
||||
"max-params": 32,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,6 @@
|
||||
"components/Map/poiCategories.ts": 7,
|
||||
"components/Map/ratingBadge.ts": 2,
|
||||
"components/Map/reservationsMapbox.ts": 1,
|
||||
"components/Notifications/InAppNotificationItem.tsx": 8,
|
||||
"components/PDF/TripPDF.tsx": 53,
|
||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||
@@ -295,7 +294,6 @@
|
||||
"pages/FilesPage.tsx": 11,
|
||||
"pages/ForgotPasswordPage.tsx": 18,
|
||||
"pages/HelpPage.tsx": 21,
|
||||
"pages/InAppNotificationsPage.tsx": 9,
|
||||
"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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ActionBarPrimitive, AuiIf, MessagePrimitive } from '@assistant-ui/react';
|
||||
import type { AssistantMessage as StoredMessage } from '@trek/shared';
|
||||
import { Check, Copy, FileText, LoaderCircle, Share2 } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { Fragment, useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
@@ -16,7 +16,7 @@ import AssistantShareDialog from './AssistantShareDialog';
|
||||
import AssistantSources from './AssistantSources';
|
||||
import AssistantToolSteps from './AssistantToolSteps';
|
||||
import AssistantWeatherStrip from './AssistantWeatherStrip';
|
||||
import { messageBlocks } from './assistantParts';
|
||||
import { type MessageBlock, messageBlocks, tailStart } from './assistantParts';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import AssistantCardBlock from './cards/AssistantCardBlock';
|
||||
import { useAssistantShare } from './useAssistantShare';
|
||||
@@ -108,6 +108,28 @@ function Question({ message }: { message: StoredMessage }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** One block of an answer, in the shape its kind takes. */
|
||||
function AnswerBlock({
|
||||
block,
|
||||
message,
|
||||
writing,
|
||||
streaming,
|
||||
}: {
|
||||
block: MessageBlock;
|
||||
message: StoredMessage;
|
||||
writing: boolean;
|
||||
streaming: boolean;
|
||||
}) {
|
||||
if (block.kind === 'text') return <AssistantMarkdown text={block.text} writing={streaming} />;
|
||||
if (block.kind === 'steps') return <AssistantToolSteps steps={block.steps} />;
|
||||
if (block.kind === 'places') return <AssistantPlaceStrip found={block.found} message={message} />;
|
||||
if (block.kind === 'weather') return <AssistantWeatherStrip days={block.days} />;
|
||||
if (block.kind === 'sources') return <AssistantSources sources={block.sources} />;
|
||||
if (block.kind === 'remembered') return <AssistantRemembered note={block.note} live={writing} />;
|
||||
if (block.kind === 'cards') return <AssistantCardBlock block={block} message={message} />;
|
||||
return <AssistantChangeCard items={block.items} />;
|
||||
}
|
||||
|
||||
function Answer({ message }: { message: StoredMessage }) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId, variant } = useAssistantView();
|
||||
@@ -119,25 +141,16 @@ function Answer({ message }: { message: StoredMessage }) {
|
||||
const share = useAssistantShare(tripId, message, writing);
|
||||
const last = blocks[blocks.length - 1];
|
||||
const hasText = blocks.some((b) => b.kind === 'text');
|
||||
const tail = tailStart(blocks);
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col gap-2.5 py-2">
|
||||
{blocks.map((block) => {
|
||||
if (block.kind === 'text') {
|
||||
return <AssistantMarkdown key={block.key} text={block.text} writing={writing && block === last} />;
|
||||
}
|
||||
if (block.kind === 'steps') return <AssistantToolSteps key={block.key} steps={block.steps} />;
|
||||
if (block.kind === 'places') {
|
||||
return <AssistantPlaceStrip key={block.key} found={block.found} message={message} />;
|
||||
}
|
||||
if (block.kind === 'weather') return <AssistantWeatherStrip key={block.key} days={block.days} />;
|
||||
if (block.kind === 'sources') return <AssistantSources key={block.key} sources={block.sources} />;
|
||||
if (block.kind === 'remembered') {
|
||||
return <AssistantRemembered key={block.key} note={block.note} live={writing} />;
|
||||
}
|
||||
if (block.kind === 'cards') return <AssistantCardBlock key={block.key} block={block} message={message} />;
|
||||
return <AssistantChangeCard key={block.key} items={block.items} />;
|
||||
})}
|
||||
{blocks.map((block, i) => (
|
||||
<Fragment key={block.key}>
|
||||
{i === tail && i > 0 && <span data-assistant-tail aria-hidden="true" className="-mb-2.5 block h-0" />}
|
||||
<AnswerBlock block={block} message={message} writing={writing} streaming={writing && block === last} />
|
||||
</Fragment>
|
||||
))}
|
||||
{/* Nothing below says the answer goes on: no words being written, no step running. */}
|
||||
{writing && last?.kind !== 'text' && last?.kind !== 'steps' && (
|
||||
<p role="status" className="m-0 text-caption">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AuiIf, ThreadPrimitive } from '@assistant-ui/react';
|
||||
import { ArrowDown, FileUp, MapPin } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
@@ -15,13 +15,15 @@ import { useAssistantAttach } from './useAssistantAttach';
|
||||
import { useFollowSelectedDay } from './useAssistantFocus';
|
||||
import { joinDropZones, useAssistantPlaceDrop } from './useAssistantPlaceDrop';
|
||||
import { useAssistantSuggestions } from './useAssistantSuggestions';
|
||||
import { useRevealAnswerTail } from './useRevealAnswerTail';
|
||||
|
||||
/**
|
||||
* The chat itself: the messages, and under them the line that says what went
|
||||
* wrong, the privacy note and the composer, held at the bottom while the
|
||||
* messages scroll. A new question moves up to the top of the view, so its
|
||||
* answer reads from its first line instead of chasing the newest one. A
|
||||
* booking file or a place of the trip can be dropped anywhere on it.
|
||||
* answer reads from its first line instead of chasing the newest one, and once
|
||||
* the answer is written its end comes into view. A booking file or a place of
|
||||
* the trip can be dropped anywhere on it.
|
||||
*/
|
||||
export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -31,6 +33,8 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
|
||||
const byId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
|
||||
const { suggestions, canEdit } = useAssistantSuggestions(tripId);
|
||||
useFollowSelectedDay(tripId);
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
useRevealAnswerTail(viewport, isRunning);
|
||||
|
||||
const phone = variant === 'fullscreen';
|
||||
const attach = useAssistantAttach(tripId);
|
||||
@@ -52,7 +56,11 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ThreadPrimitive.Viewport turnAnchor="top" className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
|
||||
<ThreadPrimitive.Viewport
|
||||
ref={viewport}
|
||||
turnAnchor="top"
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4"
|
||||
>
|
||||
<AuiIf condition={(s) => s.thread.isEmpty}>
|
||||
<AssistantWelcome suggestions={suggestions} canEdit={canEdit} withSuggestions={!phone} />
|
||||
{mentionable && (
|
||||
|
||||
@@ -61,7 +61,7 @@ export interface ChangeItem {
|
||||
/** The answer cards of one group, side by side. */
|
||||
export type CardsBlock = { kind: 'cards'; key: string; group: string; entries: CardEntry[] };
|
||||
|
||||
type MessageBlock =
|
||||
export type MessageBlock =
|
||||
| { kind: 'text'; key: string; text: string }
|
||||
| { kind: 'steps'; key: string; steps: ToolStep[] }
|
||||
| { kind: 'change'; key: string; items: ChangeItem[] }
|
||||
@@ -464,3 +464,14 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
|
||||
packing: { labelKey: 'assistant.change.label.packing' },
|
||||
breaks: { labelKey: 'assistant.change.label.breaks' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Where an answer's closing words and changes begin: the block after its last
|
||||
* step, strip or card. That end is what the thread brings into view once the
|
||||
* answer is written.
|
||||
*/
|
||||
export function tailStart(blocks: MessageBlock[]): number {
|
||||
let i = blocks.length;
|
||||
while (i > 0 && (blocks[i - 1].kind === 'text' || blocks[i - 1].kind === 'change')) i -= 1;
|
||||
return i;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* FE-AI-REVEAL-001 to FE-AI-REVEAL-007
|
||||
*
|
||||
* Once an answer is written, its closing words and changes come into view,
|
||||
* unless the user scrolled the thread themselves while it was written; and
|
||||
* where that end begins in an answer's blocks.
|
||||
*/
|
||||
import { useRef } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render } from '../../../tests/helpers/render';
|
||||
import { type MessageBlock, tailStart } from './assistantParts';
|
||||
import { tailScrollTop, useRevealAnswerTail } from './useRevealAnswerTail';
|
||||
|
||||
/** A viewport of 400px holding 1200px, its tail mark 900px down, scrolled to `scrollTop`. */
|
||||
function lay(viewport: HTMLElement, { tailAt = 900, scrollTop = 0, height = 1200 } = {}) {
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 400 });
|
||||
Object.defineProperty(viewport, 'scrollHeight', { configurable: true, value: height });
|
||||
viewport.scrollTop = scrollTop;
|
||||
viewport.getBoundingClientRect = () => ({ top: 100 }) as DOMRect;
|
||||
const tail = viewport.querySelector('[data-assistant-tail]') as HTMLElement | null;
|
||||
if (tail) tail.getBoundingClientRect = () => ({ top: 100 + tailAt - scrollTop }) as DOMRect;
|
||||
}
|
||||
|
||||
function Thread({ running }: { running: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useRevealAnswerTail(ref, running);
|
||||
return (
|
||||
<div ref={ref} data-testid="viewport" tabIndex={0}>
|
||||
<span data-assistant-tail />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const block = (kind: MessageBlock['kind']) => ({ kind, key: kind }) as MessageBlock;
|
||||
|
||||
describe('useRevealAnswerTail', () => {
|
||||
let scrollTo: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
scrollTo = vi.fn();
|
||||
HTMLElement.prototype.scrollTo = scrollTo as unknown as HTMLElement['scrollTo'];
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 1;
|
||||
});
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('FE-AI-REVEAL-001: brings the end of the answer into view once it is written', () => {
|
||||
const view = render(<Thread running />);
|
||||
lay(view.getByTestId('viewport'), { height: 2000 });
|
||||
view.rerender(<Thread running={false} />);
|
||||
expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 888 }));
|
||||
});
|
||||
|
||||
it('FE-AI-REVEAL-002: leaves the thread where the user scrolled it while the answer was written', () => {
|
||||
const view = render(<Thread running />);
|
||||
const viewport = view.getByTestId('viewport');
|
||||
lay(viewport);
|
||||
fireEvent.wheel(viewport, { deltaY: 120 });
|
||||
view.rerender(<Thread running={false} />);
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-AI-REVEAL-003: does nothing on the first render or while nothing runs', () => {
|
||||
const view = render(<Thread running={false} />);
|
||||
lay(view.getByTestId('viewport'));
|
||||
view.rerender(<Thread running={false} />);
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-AI-REVEAL-004: a new answer reveals its end again after an earlier one was scrolled by hand', () => {
|
||||
const view = render(<Thread running />);
|
||||
const viewport = view.getByTestId('viewport');
|
||||
lay(viewport);
|
||||
fireEvent.wheel(viewport, { deltaY: 120 });
|
||||
view.rerender(<Thread running={false} />);
|
||||
view.rerender(<Thread running />);
|
||||
view.rerender(<Thread running={false} />);
|
||||
expect(scrollTo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailScrollTop', () => {
|
||||
it('FE-AI-REVEAL-005: stops at the bottom when the end is shorter than the view, and never scrolls up', () => {
|
||||
const viewport = document.createElement('div');
|
||||
viewport.innerHTML = '<span data-assistant-tail></span>';
|
||||
lay(viewport, { tailAt: 1100 });
|
||||
expect(tailScrollTop(viewport)).toBe(800);
|
||||
lay(viewport, { tailAt: 300, scrollTop: 500 });
|
||||
expect(tailScrollTop(viewport)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-REVEAL-006: without a mark there is nothing to reveal', () => {
|
||||
const viewport = document.createElement('div');
|
||||
lay(viewport);
|
||||
expect(tailScrollTop(viewport)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailStart', () => {
|
||||
it('FE-AI-REVEAL-007: the end begins after the last step, strip or card', () => {
|
||||
expect(tailStart([block('steps'), block('places'), block('text'), block('change')])).toBe(2);
|
||||
expect(tailStart([block('text'), block('places')])).toBe(2);
|
||||
expect(tailStart([block('text'), block('change')])).toBe(0);
|
||||
expect(tailStart([])).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { type RefObject, useEffect, useRef } from 'react';
|
||||
|
||||
import { prefersReducedMotion } from '../../utils/reducedMotion';
|
||||
|
||||
/** Room left above the tail, so its first line does not sit flush on the edge. */
|
||||
const TAIL_GAP = 12;
|
||||
/** What reading the thread by hand looks like while the answer is written. */
|
||||
const OWN_SCROLL = ['wheel', 'touchmove', 'keydown'] as const;
|
||||
|
||||
/**
|
||||
* Where to scroll so the end of the latest answer is in view: the start of its
|
||||
* closing text and proposed changes (the `data-assistant-tail` mark), or the
|
||||
* very bottom when that tail is shorter than the view. Null when it is in view
|
||||
* already, so the thread never moves up.
|
||||
*/
|
||||
export function tailScrollTop(viewport: HTMLElement): number | null {
|
||||
const marks = viewport.querySelectorAll('[data-assistant-tail]');
|
||||
const tail = marks[marks.length - 1];
|
||||
if (!tail) return null;
|
||||
const offset = tail.getBoundingClientRect().top - viewport.getBoundingClientRect().top + viewport.scrollTop;
|
||||
const target = Math.min(offset - TAIL_GAP, viewport.scrollHeight - viewport.clientHeight);
|
||||
return target > viewport.scrollTop + 1 ? target : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A new question moves to the top of the view, so its answer reads from the
|
||||
* first line. A long answer (steps, a strip of places found, cards) then ends
|
||||
* below the view, and its closing words and the changes to apply were never
|
||||
* seen. Once the answer is written, this brings that end into view, unless the
|
||||
* user scrolled the thread themselves while it was written.
|
||||
*/
|
||||
export function useRevealAnswerTail(viewport: RefObject<HTMLElement | null>, isRunning: boolean): void {
|
||||
const scrolledByHand = useRef(false);
|
||||
const wasRunning = useRef(isRunning);
|
||||
|
||||
useEffect(() => {
|
||||
const el = viewport.current;
|
||||
if (!el || !isRunning) return;
|
||||
scrolledByHand.current = false;
|
||||
const mark = () => {
|
||||
scrolledByHand.current = true;
|
||||
};
|
||||
for (const type of OWN_SCROLL) el.addEventListener(type, mark, { passive: true });
|
||||
return () => {
|
||||
for (const type of OWN_SCROLL) el.removeEventListener(type, mark);
|
||||
};
|
||||
}, [viewport, isRunning]);
|
||||
|
||||
useEffect(() => {
|
||||
const ended = wasRunning.current && !isRunning;
|
||||
wasRunning.current = isRunning;
|
||||
const el = viewport.current;
|
||||
if (!ended || !el || scrolledByHand.current) return;
|
||||
// A frame later, so the answer's last blocks have their height.
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const top = tailScrollTop(el);
|
||||
if (top !== null) el.scrollTo({ top, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [viewport, isRunning]);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Book, Palette, Map, Bell, Plug, CloudOff, User, Lock, ShieldCheck, Fingerprint, KeyRound,
|
||||
Route, Users, UserRound, Crown, PanelLeftClose, Undo2, Ticket, Train, Plane, ListChecks, Wallet, FolderOpen, MessagesSquare,
|
||||
ArrowUpDown, Backpack, BatteryCharging, BedDouble, BellRing, Bike, Blocks, CableCar, Car, CloudDownload, CopyPlus, FileDown, FilePen, FileSpreadsheet, FolderInput, Footprints, History, Info, Layers, MailCheck, MapPinned, Moon, Mountain, MountainSnow, MoveRight, NotebookPen, PackagePlus, Pin, Receipt, Rotate3d, RouteOff, Shuffle, Spline, TriangleAlert, Waves, Wind,
|
||||
PaintBucket, Sticker, Type,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import type { HelpIcon } from '../../help/types'
|
||||
@@ -13,6 +14,9 @@ import type { HelpIcon } from '../../help/types'
|
||||
/** The registry names icons; the components draw them. Unknown names fall back to the compass. */
|
||||
const ICONS: Record<HelpIcon, LucideIcon> = {
|
||||
plus: Plus,
|
||||
sticker: Sticker,
|
||||
type: Type,
|
||||
paintBucket: PaintBucket,
|
||||
pencil: Pencil,
|
||||
image: Image,
|
||||
copy: Copy,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Bell, Trash2, CheckCheck } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx'
|
||||
import NotificationDayList from '../Notifications/NotificationDayList'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
|
||||
export default function InAppNotificationBell(): React.ReactElement {
|
||||
@@ -68,27 +68,28 @@ 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: 400,
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
|
||||
maxHeight: 'min(560px, calc(100vh - var(--nav-h) - 16px))',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
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,49 +99,53 @@ 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 pb-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 => (
|
||||
<InAppNotificationItem key={n.id} notification={n} onClose={() => setOpen(false)} />
|
||||
))
|
||||
<NotificationDayList notifications={notifications.slice(0, 10)} onClose={() => setOpen(false)} compact />
|
||||
)}
|
||||
</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
|
||||
|
||||
@@ -1,18 +1,54 @@
|
||||
import React from 'react'
|
||||
import { User, Check, X, ArrowRight, Trash2, CheckCheck, Mail } from 'lucide-react'
|
||||
import { Check, X, ArrowRight, Trash2, CheckCheck, Mail } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { InAppNotification } from '../../store/inAppNotificationStore'
|
||||
import { compactTime, useNotificationItemActions } from './useNotificationItemActions'
|
||||
|
||||
function relativeTime(dateStr: string, locale: string): string {
|
||||
return compactTime(dateStr, locale === 'ar' ? 'الآن' : 'just now')
|
||||
}
|
||||
import { KIND_LOOK, createdAt, kindOf } from './notificationModel'
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: InAppNotification
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/** The quiet tool beside a notification, forward only when 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-secondary focus-visible:bg-surface-secondary focus-visible:text-content'
|
||||
|
||||
/** An answer or a way on, as a button under the words. */
|
||||
const ACTION = 'inline-flex items-center gap-1.5 h-8 px-3 rounded-lg text-caption font-semibold transition-colors'
|
||||
|
||||
/** Under an hour it reads "5m"; after that the clock time, since the list is headed by its day. */
|
||||
function timeOf(value: string, locale: string, justNow: string): string {
|
||||
const date = createdAt(value)
|
||||
if (Date.now() - date.getTime() < 3_600_000) return compactTime(date.toISOString(), justNow)
|
||||
return new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(date)
|
||||
}
|
||||
|
||||
/** The tile on the start side: the kind's symbol on its colour, and who sent it as a small face on its corner. */
|
||||
function KindTile({ notification }: { notification: InAppNotification }) {
|
||||
const { icon: Icon, tone } = KIND_LOOK[kindOf(notification.title_key)]
|
||||
const initial = notification.sender_username?.charAt(0).toUpperCase()
|
||||
return (
|
||||
<span className="relative block self-start flex-shrink-0">
|
||||
<span className={`grid place-items-center w-10 h-10 rounded-xl ${tone}`}>
|
||||
<Icon className="w-[18px] h-[18px]" aria-hidden />
|
||||
</span>
|
||||
{notification.sender_avatar ? (
|
||||
<img src={notification.sender_avatar} alt="" className="absolute -bottom-1 -end-1 w-5 h-5 rounded-full object-cover ring-2 ring-surface-card" />
|
||||
) : initial ? (
|
||||
<span aria-hidden className="absolute -bottom-1 -end-1 grid place-items-center w-5 h-5 rounded-full text-caption leading-none font-bold bg-accent text-accent-text ring-2 ring-surface-card">
|
||||
{initial}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One notification: a tile that says at a glance what it is about and who sent
|
||||
* it, the message itself, and what can be done with it. Unread ones are set in
|
||||
* bold with a dot in the accent; marking and deleting come forward when the row
|
||||
* is pointed at or focused, so a list reads as messages rather than as buttons.
|
||||
*/
|
||||
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const { responding, handleRespond, handleNavigate, markRead, markUnread, deleteNotification } =
|
||||
@@ -20,120 +56,96 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
|
||||
const titleText = t(notification.title_key, notification.title_params)
|
||||
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
|
||||
const { positive_text_key: yesKey, negative_text_key: noKey, navigate_text_key: goKey } = notification
|
||||
const asks = notification.type === 'boolean' && yesKey && noKey
|
||||
const leads = notification.type === 'navigate' && goKey && notification.navigate_target
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative px-4 py-3 transition-colors border-b border-edge-secondary ${notification.is_read ? '' : 'bg-accent-subtle'}`}
|
||||
>
|
||||
<div className="group relative flex gap-3.5 px-4 py-3.5 transition-colors hover:bg-surface-hover focus-within:bg-surface-hover">
|
||||
<KindTile notification={notification} />
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
{/* Sender avatar */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{notification.sender_avatar ? (
|
||||
<img
|
||||
src={notification.sender_avatar}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full 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"
|
||||
>
|
||||
{notification.sender_username
|
||||
? notification.sender_username.charAt(0).toUpperCase()
|
||||
: <User className="w-4 h-4" />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{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">
|
||||
{relativeTime(notification.created_at, locale)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className={`text-body leading-snug text-content ${unread ? 'font-semibold' : 'font-medium'}`}>{titleText}</p>
|
||||
<div className="relative flex-shrink-0 h-6 flex items-center gap-2">
|
||||
<span className="text-caption tabular-nums text-content-faint transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
|
||||
{timeOf(notification.created_at, locale, t('common.justNow'))}
|
||||
</span>
|
||||
{unread && (
|
||||
<span aria-hidden className="w-2 h-2 rounded-full bg-accent transition-opacity group-hover:opacity-0 group-focus-within:opacity-0" />
|
||||
)}
|
||||
<div className="absolute end-0 top-1/2 -translate-y-1/2 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="p-1 rounded transition-colors text-content-faint hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover focus-visible:text-content"
|
||||
>
|
||||
<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="p-1 rounded transition-colors text-content-faint hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover focus-visible:text-content"
|
||||
>
|
||||
<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"
|
||||
<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"
|
||||
className={`${TOOL} hover:text-danger focus-visible:text-danger`}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs 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"
|
||||
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 ${
|
||||
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'}`}
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
{t(notification.positive_text_key)}
|
||||
</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 ${
|
||||
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'}`}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
{t(notification.negative_text_key)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigate action */}
|
||||
{notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && (
|
||||
<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"
|
||||
>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
{t(notification.navigate_text_key)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-body mt-0.5 leading-relaxed text-content-muted">{bodyText}</p>
|
||||
|
||||
{asks && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRespond('positive')}
|
||||
disabled={responding || answered}
|
||||
className={`${ACTION} ${
|
||||
notification.response === 'positive'
|
||||
? 'bg-success-soft text-success'
|
||||
: answered
|
||||
? 'border border-edge text-content-faint'
|
||||
: 'bg-accent text-accent-text hover:bg-accent-hover'
|
||||
} disabled:cursor-default`}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{t(yesKey)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRespond('negative')}
|
||||
disabled={responding || answered}
|
||||
className={`${ACTION} ${
|
||||
notification.response === 'negative'
|
||||
? 'bg-danger-soft text-danger'
|
||||
: answered
|
||||
? 'border border-edge text-content-faint'
|
||||
: 'border border-edge text-content hover:bg-surface-secondary'
|
||||
} disabled:cursor-default`}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
{t(noKey)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leads && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNavigate}
|
||||
className={`${ACTION} mt-3 border border-edge text-content hover:bg-accent hover:text-accent-text hover:border-transparent focus-visible:bg-accent focus-visible:text-accent-text`}
|
||||
>
|
||||
{t(goKey)}
|
||||
<ArrowRight className="w-3.5 h-3.5 rtl:-scale-x-100" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { InAppNotification } from '../../store/inAppNotificationStore';
|
||||
import InAppNotificationItem from './InAppNotificationItem';
|
||||
import { groupByDay } from './notificationModel';
|
||||
|
||||
interface NotificationDayListProps {
|
||||
notifications: InAppNotification[];
|
||||
/** Closes the bell's panel after a notification led somewhere. */
|
||||
onClose?: () => void;
|
||||
/** The bell's panel: headings stick to its top and the days need no cards of their own. */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The notifications cut into days, each under its heading (Today, Yesterday,
|
||||
* a weekday, then the date). On the page every day is a card of its own; in
|
||||
* the bell's panel the headings stay at the top while the day scrolls by.
|
||||
*/
|
||||
export default function NotificationDayList({
|
||||
notifications,
|
||||
onClose,
|
||||
compact = false,
|
||||
}: NotificationDayListProps): React.ReactElement {
|
||||
const { locale } = useTranslation();
|
||||
const groups = useMemo(() => groupByDay(notifications, locale), [notifications, locale]);
|
||||
|
||||
return (
|
||||
<div className={compact ? '' : 'flex flex-col gap-6'}>
|
||||
{groups.map((group) => (
|
||||
<section key={group.key} aria-label={group.label}>
|
||||
<h2
|
||||
className={
|
||||
compact
|
||||
? 'sticky top-0 z-10 bg-surface-card px-4 pb-1.5 pt-3 text-caption font-semibold text-content-muted'
|
||||
: 'mb-2 px-1 text-caption font-semibold text-content-muted'
|
||||
}
|
||||
>
|
||||
{group.label}
|
||||
</h2>
|
||||
<div
|
||||
className={
|
||||
compact
|
||||
? 'divide-y divide-edge-secondary'
|
||||
: 'divide-y divide-edge-secondary overflow-hidden rounded-2xl border border-edge bg-surface-card'
|
||||
}
|
||||
>
|
||||
{group.items.map((n) => (
|
||||
<InAppNotificationItem key={n.id} notification={n} onClose={onClose} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* FE-NOTIF-MODEL-001 to FE-NOTIF-MODEL-007
|
||||
*
|
||||
* What a notification's tile shows, how the server's time is read, and how the
|
||||
* list is cut into days under headings in the reader's language.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import type { InAppNotification } from '../../store/inAppNotificationStore';
|
||||
import NotificationDayList from './NotificationDayList';
|
||||
import { KIND_LOOK, createdAt, dayLabel, groupByDay, kindOf } from './notificationModel';
|
||||
|
||||
const note = (id: number, created_at: string, title_key = 'notif.trip_invite.title'): InAppNotification => ({
|
||||
id,
|
||||
type: 'simple',
|
||||
scope: 'trip',
|
||||
target: 1,
|
||||
sender_id: null,
|
||||
sender_username: null,
|
||||
sender_avatar: null,
|
||||
recipient_id: 1,
|
||||
title_key,
|
||||
title_params: {},
|
||||
text_key: 'notifications.empty',
|
||||
text_params: {},
|
||||
positive_text_key: null,
|
||||
negative_text_key: null,
|
||||
response: null,
|
||||
navigate_text_key: null,
|
||||
navigate_target: null,
|
||||
is_read: false,
|
||||
created_at,
|
||||
});
|
||||
|
||||
const NOW = new Date(2026, 9, 24, 15, 0);
|
||||
const at = (days: number, hour = 10) => new Date(2026, 9, 24 - days, hour, 0);
|
||||
|
||||
describe('notificationModel', () => {
|
||||
it('FE-NOTIF-MODEL-001: every known title has its kind, anything else is the plain bell', () => {
|
||||
expect(kindOf('notif.collab_message.title')).toBe('message');
|
||||
expect(kindOf('notif.replica_failure.title')).toBe('alert');
|
||||
expect(kindOf('plugin.something.title')).toBe('other');
|
||||
for (const look of Object.values(KIND_LOOK)) expect(look.tone).toMatch(/^bg-/);
|
||||
});
|
||||
|
||||
it("FE-NOTIF-MODEL-002: the server's UTC text is read as UTC, an ISO string as it is", () => {
|
||||
expect(createdAt('2026-10-24 08:30:00').toISOString()).toBe('2026-10-24T08:30:00.000Z');
|
||||
expect(createdAt('2026-10-24T08:30:00.000Z').toISOString()).toBe('2026-10-24T08:30:00.000Z');
|
||||
});
|
||||
|
||||
it('FE-NOTIF-MODEL-003: today and yesterday by name, the week by weekday, older by date', () => {
|
||||
expect(dayLabel(at(0), 'en', NOW)).toBe('Today');
|
||||
expect(dayLabel(at(1), 'en', NOW)).toBe('Yesterday');
|
||||
expect(dayLabel(at(3), 'en', NOW)).toBe('Wednesday');
|
||||
expect(dayLabel(at(20), 'en', NOW)).toBe('October 4');
|
||||
expect(dayLabel(new Date(2025, 11, 31), 'en', NOW)).toBe('December 31, 2025');
|
||||
});
|
||||
|
||||
it('FE-NOTIF-MODEL-004: the headings follow the language', () => {
|
||||
expect(dayLabel(at(0), 'de', NOW)).toBe('Heute');
|
||||
expect(dayLabel(at(1), 'de', NOW)).toBe('Gestern');
|
||||
});
|
||||
|
||||
it('FE-NOTIF-MODEL-005: the list is cut into days in the order it came', () => {
|
||||
const groups = groupByDay(
|
||||
[note(1, at(0, 12).toISOString()), note(2, at(0, 9).toISOString()), note(3, at(2).toISOString())],
|
||||
'en',
|
||||
NOW
|
||||
);
|
||||
expect(groups.map((g) => [g.label, g.items.map((n) => n.id)])).toEqual([
|
||||
['Today', [1, 2]],
|
||||
['Thursday', [3]],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-NOTIF-MODEL-006: an empty list has no days', () => {
|
||||
expect(groupByDay([], 'en', NOW)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationDayList', () => {
|
||||
it('FE-NOTIF-MODEL-007: each day is a section under its heading', () => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 12);
|
||||
render(<NotificationDayList notifications={[note(1, now.toISOString()), note(2, yesterday.toISOString())]} />);
|
||||
expect(screen.getByRole('heading', { name: 'Today' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Yesterday' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('region')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
Backpack,
|
||||
Bell,
|
||||
BookmarkPlus,
|
||||
CalendarDays,
|
||||
Images,
|
||||
ListChecks,
|
||||
MessageCircle,
|
||||
Mountain,
|
||||
Plane,
|
||||
Sparkles,
|
||||
Ticket,
|
||||
TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { InAppNotification } from '../../store/inAppNotificationStore';
|
||||
|
||||
/** What a notification is about, read from its title key: it picks the symbol and colour of its tile. */
|
||||
export type NotificationKind =
|
||||
| 'trip'
|
||||
| 'message'
|
||||
| 'booking'
|
||||
| 'packing'
|
||||
| 'todo'
|
||||
| 'vacay'
|
||||
| 'collection'
|
||||
| 'photos'
|
||||
| 'tour'
|
||||
| 'update'
|
||||
| 'alert'
|
||||
| 'other';
|
||||
|
||||
const KIND_BY_TITLE: Record<string, NotificationKind> = {
|
||||
'notif.trip_invite.title': 'trip',
|
||||
'notif.trip_reminder.title': 'trip',
|
||||
'notif.collab_message.title': 'message',
|
||||
'notif.booking_change.title': 'booking',
|
||||
'notif.packing_tagged.title': 'packing',
|
||||
'notif.todo_due.title': 'todo',
|
||||
'notif.vacay_invite.title': 'vacay',
|
||||
'notif.vacay_share.title': 'vacay',
|
||||
'notif.collection_invite.title': 'collection',
|
||||
'notif.photos_shared.title': 'photos',
|
||||
'notif.wanderer_source_hint.title': 'tour',
|
||||
'notif.paddle_region_requested.title': 'tour',
|
||||
'notif.version_available.title': 'update',
|
||||
'notifications.versionAvailable.title': 'update',
|
||||
'notif.replica_failure.title': 'alert',
|
||||
'notifications.synologySessionCleared.title': 'alert',
|
||||
};
|
||||
|
||||
export function kindOf(titleKey: string): NotificationKind {
|
||||
return KIND_BY_TITLE[titleKey] ?? 'other';
|
||||
}
|
||||
|
||||
/** The symbol of each kind and the tinted tile it sits on. */
|
||||
export const KIND_LOOK: Record<NotificationKind, { icon: LucideIcon; tone: string }> = {
|
||||
trip: { icon: Plane, tone: 'bg-info-soft text-info' },
|
||||
message: { icon: MessageCircle, tone: 'bg-success-soft text-success' },
|
||||
booking: { icon: Ticket, tone: 'bg-warning-soft text-warning' },
|
||||
packing: { icon: Backpack, tone: 'bg-warning-soft text-warning' },
|
||||
todo: { icon: ListChecks, tone: 'bg-success-soft text-success' },
|
||||
vacay: { icon: CalendarDays, tone: 'bg-info-soft text-info' },
|
||||
collection: { icon: BookmarkPlus, tone: 'bg-accent-subtle text-accent' },
|
||||
photos: { icon: Images, tone: 'bg-warning-soft text-warning' },
|
||||
tour: { icon: Mountain, tone: 'bg-success-soft text-success' },
|
||||
update: { icon: Sparkles, tone: 'bg-accent-subtle text-accent' },
|
||||
alert: { icon: TriangleAlert, tone: 'bg-danger-soft text-danger' },
|
||||
other: { icon: Bell, tone: 'bg-surface-secondary text-content-secondary' },
|
||||
};
|
||||
|
||||
/**
|
||||
* When a notification was made. The server writes `YYYY-MM-DD HH:MM:SS` in UTC,
|
||||
* which a bare `new Date()` would read as local time.
|
||||
*/
|
||||
export function createdAt(value: string): Date {
|
||||
return new Date(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(value) ? `${value.replace(' ', 'T')}Z` : value);
|
||||
}
|
||||
|
||||
const dayStart = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
|
||||
/**
|
||||
* The heading of the day a notification belongs to, in the reader's language:
|
||||
* today and yesterday by name, the rest of the week by weekday, anything older
|
||||
* by its date (with the year when it is not this one).
|
||||
*/
|
||||
export function dayLabel(date: Date, locale: string, now: Date = new Date()): string {
|
||||
const days = Math.round((dayStart(now) - dayStart(date)) / 86_400_000);
|
||||
if (days <= 1) {
|
||||
const word = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(-Math.max(days, 0), 'day');
|
||||
return word.charAt(0).toLocaleUpperCase(locale) + word.slice(1);
|
||||
}
|
||||
if (days < 7) return new Intl.DateTimeFormat(locale, { weekday: 'long' }).format(date);
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }),
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export interface NotificationGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
items: InAppNotification[];
|
||||
}
|
||||
|
||||
/** The list cut into days, in the order it came (newest first). */
|
||||
export function groupByDay(list: InAppNotification[], locale: string, now: Date = new Date()): NotificationGroup[] {
|
||||
const groups: NotificationGroup[] = [];
|
||||
for (const n of list) {
|
||||
const date = createdAt(n.created_at);
|
||||
const key = String(dayStart(date));
|
||||
const current = groups[groups.length - 1];
|
||||
if (current?.key === key) current.items.push(n);
|
||||
else groups.push({ key, label: dayLabel(date, locale, now), items: [n] });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,9 +19,8 @@ import type { StudioAssistant } from './useStudioAssistant';
|
||||
* 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. Whether it is saved sits under the title, because it
|
||||
* describes it. The tools on the right are grouped by what they do: who else
|
||||
* is here, what the book is printed as, editing (undo, redo, paste), auto
|
||||
* 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.
|
||||
@@ -44,21 +43,20 @@ export function StudioHeader({
|
||||
<StudioWordmark style={{ height: 17 }} />
|
||||
</div>
|
||||
<h1 className="st-head-title">{s.journey?.title || s.t('journey.title')}</h1>
|
||||
<div className="st-head-sub">
|
||||
<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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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: '' };
|
||||
}
|
||||
@@ -65,16 +65,22 @@ export const { context: studioContext, guides: studioGuides } = defineScreen({
|
||||
id: 'journey-studio',
|
||||
parent: 'journey',
|
||||
icon: 'book',
|
||||
bullets: 6,
|
||||
bullets: 8,
|
||||
docs: [STUDIO],
|
||||
guides: [
|
||||
['studio-auto-layout', 'sparkles', 'guide', 3, 2, STUDIO, ['studio-pages', 'studio-layouts'], true],
|
||||
['studio-pages', 'book', 'guide', 3, 2, STUDIO, ['studio-layouts', 'studio-spread-file'], true],
|
||||
['studio-layouts', 'layout', 'quick', 2, 1, STUDIO, ['studio-auto-layout', 'studio-content'], true],
|
||||
['studio-layouts', 'layout', 'quick', 2, 1, STUDIO, ['studio-my-layouts', 'studio-auto-layout', 'studio-content'], true],
|
||||
['studio-my-layouts', 'bookmark', 'quick', 2, 1, STUDIO, ['studio-layouts', 'studio-spread-file'], true],
|
||||
['studio-content', 'image', 'guide', 3, 2, STUDIO, ['studio-properties', 'studio-elements'], true],
|
||||
['studio-elements', 'plus', 'quick', 2, 1, STUDIO, ['studio-travel', 'studio-properties']],
|
||||
['studio-travel', 'globe', 'quick', 2, 1, STUDIO, ['studio-elements']],
|
||||
['studio-properties', 'panelRight', 'guide', 3, 2, STUDIO, ['studio-content', 'studio-layouts']],
|
||||
['studio-theme', 'palette', 'guide', 3, 2, STUDIO, ['studio-background', 'studio-text-effects'], true],
|
||||
['studio-elements', 'plus', 'quick', 2, 1, STUDIO, ['studio-stickers', 'studio-travel', 'studio-properties']],
|
||||
['studio-stickers', 'sticker', 'quick', 2, 1, STUDIO, ['studio-elements', 'studio-layers'], true],
|
||||
['studio-travel', 'globe', 'guide', 3, 2, STUDIO, ['studio-elements', 'studio-theme'], true],
|
||||
['studio-text-effects', 'type', 'quick', 2, 1, STUDIO, ['studio-theme', 'studio-properties'], true],
|
||||
['studio-background', 'paintBucket', 'quick', 2, 1, STUDIO, ['studio-theme', 'studio-layouts'], true],
|
||||
['studio-layers', 'layers', 'quick', 2, 2, STUDIO, ['studio-properties', 'studio-stickers'], true],
|
||||
['studio-properties', 'panelRight', 'guide', 3, 2, STUDIO, ['studio-content', 'studio-layers']],
|
||||
['studio-format', 'filter', 'quick', 2, 2, STUDIO, ['studio-export']],
|
||||
['studio-export', 'upload', 'guide', 3, 2, STUDIO, ['studio-format', 'studio-spread-file']],
|
||||
['studio-spread-file', 'fileInput', 'quick', 2, 1, STUDIO, ['studio-export', 'studio-pages']],
|
||||
|
||||
@@ -36,6 +36,9 @@ export interface HelpGuideMedia {
|
||||
/** Icon names the help components know how to draw (see components/Help/helpIcons.ts). */
|
||||
export type HelpIcon =
|
||||
| 'plus'
|
||||
| 'sticker'
|
||||
| 'type'
|
||||
| 'paintBucket'
|
||||
| 'pencil'
|
||||
| 'image'
|
||||
| 'copy'
|
||||
|
||||