mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26a6147e8d | |||
| a7b53988ff | |||
| 3f3e2f0bcf | |||
| f956ab07f3 | |||
| 736594696e | |||
| 1be997f7d8 | |||
| 5a8ff16afa | |||
| 4435dc32b8 | |||
| f5a33e0c1e | |||
| a8326b6f5a | |||
| 1e999d968a | |||
| d0f74c9f02 | |||
| f14f1e41b9 | |||
| 9138e722bb | |||
| 9695e269e3 | |||
| 82bdf62c7b | |||
| 8893e3afe0 | |||
| cd1e7d8fa8 | |||
| 059e72df69 | |||
| dc519b3ea7 | |||
| ad4074f107 | |||
| 0534d7b3a7 | |||
| a0554c2346 | |||
| bc5af23896 | |||
| 92be8a7ca5 | |||
| 44ea1ea0ba | |||
| 3bee600096 | |||
| 5b4030f86b | |||
| e2c00ba18b | |||
| 1050029842 | |||
| 64edf899ac | |||
| 0a9075af73 | |||
| c3a5383f5e | |||
| e96813edcf | |||
| 5e36e38b72 |
@@ -1300,7 +1300,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.locator('.st-back'),
|
||||
target: p => p.locator('.st-head-close'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 385,
|
||||
"@typescript-eslint/no-non-null-assertion": 384,
|
||||
"@typescript-eslint/no-misused-promises": 849,
|
||||
"no-console": 88,
|
||||
"complexity": 222,
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 490,
|
||||
"@typescript-eslint/no-non-null-assertion": 1931,
|
||||
"@typescript-eslint/no-non-null-assertion": 1929,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
|
||||
@@ -146,6 +146,13 @@ export const DYNAMIC_ALLOWED = [
|
||||
'id is a sticker id of the library in @trek/shared/stickers; stickerLibrary.test.ts resolves the key of every ' +
|
||||
'sticker the library holds against en',
|
||||
},
|
||||
{
|
||||
file: 'components/Studio/shapes.ts',
|
||||
template: 'journey.studio.shapeName.${shape}',
|
||||
because:
|
||||
'shapeName takes a BookShapeId; shared/src/book/book-shape-catalogue.spec.ts holds an en name for every ' +
|
||||
'shape of BOOK_SHAPE_GROUPS',
|
||||
},
|
||||
];
|
||||
|
||||
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
|
||||
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
|
||||
"src/components/Studio/StudioShell.tsx": 1,
|
||||
"src/components/Studio/TravelElements.tsx": 1,
|
||||
"src/components/Studio/sidebar/StudioEntryCard.tsx": 1,
|
||||
"src/components/Studio/travel/BadgeIcon.tsx": 1,
|
||||
"src/components/Todo/useTodoList.ts": 1,
|
||||
"src/components/shared/ConfirmDialog.tsx": 1,
|
||||
"src/components/shared/CountryFlag.tsx": 1,
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2953,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2936,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1157,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1290,
|
||||
"src/components/Studio/TravelElements.tsx": 1738,
|
||||
"src/components/Studio/TravelElements.tsx": 1559,
|
||||
"src/components/Studio/countryShapes.ts": 2595,
|
||||
"src/index.css": 1636,
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
@@ -26,5 +26,5 @@
|
||||
"src/pages/atlas/useAtlas.ts": 1211,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
|
||||
"src/styles/studio.css": 2009
|
||||
"src/styles/studio.css": 1918
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"components/Journey/moodConfig.ts": 5,
|
||||
"components/Layout/BottomNav.tsx": 3,
|
||||
"components/Layout/DemoBanner.tsx": 25,
|
||||
"components/Layout/InAppNotificationBell.tsx": 8,
|
||||
"components/Layout/InAppNotificationBell.tsx": 3,
|
||||
"components/Layout/MobileTopBar.tsx": 51,
|
||||
"components/Layout/Navbar.tsx": 21,
|
||||
"components/Layout/NewVersionNotice.tsx": 3,
|
||||
@@ -72,7 +72,7 @@
|
||||
"components/Map/poiCategories.ts": 7,
|
||||
"components/Map/ratingBadge.ts": 2,
|
||||
"components/Map/reservationsMapbox.ts": 1,
|
||||
"components/Notifications/InAppNotificationItem.tsx": 8,
|
||||
"components/Notifications/InAppNotificationItem.tsx": 1,
|
||||
"components/PDF/TripPDF.tsx": 53,
|
||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||
@@ -295,7 +295,7 @@
|
||||
"pages/FilesPage.tsx": 11,
|
||||
"pages/ForgotPasswordPage.tsx": 18,
|
||||
"pages/HelpPage.tsx": 21,
|
||||
"pages/InAppNotificationsPage.tsx": 9,
|
||||
"pages/InAppNotificationsPage.tsx": 2,
|
||||
"pages/JoinTripPage.tsx": 4,
|
||||
"pages/JourneyDetailPage.tsx": 53,
|
||||
"pages/JourneyPage.tsx": 64,
|
||||
|
||||
@@ -44,9 +44,10 @@
|
||||
"components/Studio/SpreadView.tsx": 1,
|
||||
"components/Studio/StudioElementsPanel.tsx": 2,
|
||||
"components/Studio/StudioSwatches.tsx": 1,
|
||||
"components/Studio/TravelElements.tsx": 6,
|
||||
"components/Studio/TravelElements.tsx": 3,
|
||||
"components/Studio/peerColour.ts": 1,
|
||||
"components/Studio/printSheets.ts": 4,
|
||||
"components/Studio/travel/badgeLook.ts": 2,
|
||||
"components/Trips/TripMemberAvatar.tsx": 2,
|
||||
"components/Vacay/VacayPersons.tsx": 1,
|
||||
"components/shared/DialogShell.tsx": 2,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-TZHOOK-001 to -007: the world clocks both dashboards share.
|
||||
// FE-COMP-TZHOOK-001 to -009: the world clocks both dashboards share.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -97,4 +97,27 @@ describe('useDashboardTimezones', () => {
|
||||
expect(localStorage.getItem('trek_dashboard_tz')).toBeNull();
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// #2694: Chrome on Windows names no zone for a system zone it cannot map.
|
||||
it('FE-COMP-TZHOOK-008: a browser that names no zone still gets the default clocks', () => {
|
||||
const original = Intl.DateTimeFormat.prototype.resolvedOptions;
|
||||
const spy = vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockImplementation(function (
|
||||
this: Intl.DateTimeFormat
|
||||
) {
|
||||
return { ...original.call(this), timeZone: undefined as unknown as string };
|
||||
});
|
||||
try {
|
||||
const { result } = renderHook(() => useDashboardTimezones('en'));
|
||||
expect(result.current.zones).toEqual(['Europe/London', 'Asia/Tokyo']);
|
||||
expect(() => result.current.zones.map((tz) => result.current.timeIn(tz))).not.toThrow();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-COMP-TZHOOK-009: a stored zone this browser does not know is left out instead of crashing the clocks', () => {
|
||||
seedSettings(['Europe/Berlin', 'Mars/Olympus_Mons', '']);
|
||||
const { result } = renderHook(() => useDashboardTimezones('en'));
|
||||
expect(result.current.zones).toEqual(['Europe/Berlin']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { browserTimeZone, isValidTimeZone } from '../../utils/localDate';
|
||||
|
||||
const DEFAULT_ZONES = ['Europe/London', 'Asia/Tokyo'];
|
||||
|
||||
@@ -38,13 +39,15 @@ export function shortZone(tz: string): string {
|
||||
* clocks tick every 30 seconds, `adding` toggles the zone picker.
|
||||
*/
|
||||
export function useDashboardTimezones(locale: string) {
|
||||
const home = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const home = browserTimeZone();
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const isLoaded = useSettingsStore((s) => s.isLoaded);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const stored = useSettingsStore((s) => s.settings.dashboard_timezones);
|
||||
// Unset (never chosen) falls back to home + defaults; an explicit list is honoured.
|
||||
const zones = stored ?? [home, ...DEFAULT_ZONES];
|
||||
// A browser that names no zone (#2694) or a zone this browser does not know
|
||||
// would throw while rendering a clock, so such entries are left out.
|
||||
const zones = (stored ?? [home, ...DEFAULT_ZONES]).filter(isValidTimeZone);
|
||||
const setZones = (next: string[]) => {
|
||||
updateSetting('dashboard_timezones', next).catch(() => {});
|
||||
};
|
||||
|
||||
@@ -68,12 +68,12 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
{/* Click-away catcher only — the bell button itself closes the panel again from the keyboard. */}
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setOpen(false)} />
|
||||
<div
|
||||
className="rounded-xl shadow-xl border overflow-hidden bg-surface-card border-edge"
|
||||
className="rounded-2xl shadow-xl border overflow-hidden bg-surface-card border-edge"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 'var(--nav-h)',
|
||||
insetInlineEnd: 8,
|
||||
width: 360,
|
||||
width: 380,
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
|
||||
zIndex: 9999,
|
||||
@@ -81,14 +81,15 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 flex-shrink-0 border-b border-edge-secondary"
|
||||
>
|
||||
<span className="text-sm font-semibold text-content">
|
||||
{/* Header: the dialogs' head, a tile, the name and how many are unread, the tools at the end. */}
|
||||
<div className="flex items-center gap-3 px-4 pt-4 pb-3 flex-shrink-0">
|
||||
<span className="grid place-items-center w-9 h-9 rounded-xl bg-accent-subtle text-accent flex-shrink-0">
|
||||
<Bell className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 flex items-center gap-2 text-subtitle font-semibold text-content">
|
||||
{t('notifications.title')}
|
||||
{unreadCount > 0 && (
|
||||
<span className="ms-2 px-1.5 py-0.5 rounded-full text-xs font-medium bg-content text-surface">
|
||||
<span className="px-2 h-5 inline-flex items-center rounded-full text-caption font-semibold tabular-nums bg-accent text-accent-text">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -98,34 +99,38 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
<button type="button"
|
||||
onClick={markAllRead}
|
||||
title={t('notifications.markAllRead')}
|
||||
className="p-1.5 rounded-lg transition-colors text-content-muted hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
aria-label={t('notifications.markAllRead')}
|
||||
className="grid place-items-center w-8 h-8 rounded-lg transition-colors text-content-muted bg-surface-secondary hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover"
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
<CheckCheck className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<button type="button"
|
||||
onClick={() => setConfirmingDeleteAll(true)}
|
||||
title={t('notifications.deleteAll')}
|
||||
className="p-1.5 rounded-lg transition-colors text-content-muted hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
aria-label={t('notifications.deleteAll')}
|
||||
className="grid place-items-center w-8 h-8 rounded-lg transition-colors text-content-muted bg-surface-secondary hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification list */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
<div className="overflow-y-auto flex-1 py-1 border-t border-edge-secondary">
|
||||
{isLoading && notifications.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div className="w-5 h-5 border-2 rounded-full animate-spin border-edge border-t-content" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 px-4 text-center gap-2">
|
||||
<Bell className="w-8 h-8 text-content-faint" />
|
||||
<p className="text-sm font-medium text-content-muted">{t('notifications.empty')}</p>
|
||||
<p className="text-xs text-content-faint">{t('notifications.emptyDescription')}</p>
|
||||
<div className="flex flex-col items-center justify-center py-10 px-6 text-center gap-2">
|
||||
<span className="grid place-items-center w-12 h-12 rounded-2xl bg-surface-secondary text-content-faint">
|
||||
<Bell className="w-5 h-5" />
|
||||
</span>
|
||||
<p className="text-body font-semibold text-content">{t('notifications.empty')}</p>
|
||||
<p className="text-caption text-content-muted">{t('notifications.emptyDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.slice(0, 10).map(n => (
|
||||
@@ -135,12 +140,14 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<button type="button"
|
||||
onClick={handleShowAll}
|
||||
className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0 border-t border-edge-secondary text-content hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
>
|
||||
{t('notifications.showAll')}
|
||||
</button>
|
||||
<div className="p-2 flex-shrink-0 border-t border-edge-secondary">
|
||||
<button type="button"
|
||||
onClick={handleShowAll}
|
||||
className="w-full h-9 rounded-xl text-body font-medium transition-colors bg-surface-secondary text-content hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
>
|
||||
{t('notifications.showAll')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
|
||||
@@ -13,6 +13,18 @@ interface NotificationItemProps {
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/** The round tool beside a notification, quiet until it is pointed at. */
|
||||
const TOOL = 'grid place-items-center w-7 h-7 rounded-lg transition-colors text-content-faint hover:text-content hover:bg-surface-card focus-visible:bg-surface-card focus-visible:text-content'
|
||||
|
||||
/** An answer or a way on, as a pill under the words. */
|
||||
const PILL = 'inline-flex items-center gap-1.5 h-7 px-3 rounded-full text-caption font-medium transition-colors'
|
||||
|
||||
/**
|
||||
* One notification, as a card in the list: who sent it, what it says, and what
|
||||
* can be done with it. Unread ones carry a dot in the accent and a faint
|
||||
* ground; the tools to mark and delete come forward when the card is pointed
|
||||
* at or focused, so a list reads as messages rather than as rows of buttons.
|
||||
*/
|
||||
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const { responding, handleRespond, handleNavigate, markRead, markUnread, deleteNotification } =
|
||||
@@ -22,29 +34,25 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
const bodyText = t(notification.text_key, notification.text_params)
|
||||
const hasUnknownTitle = titleText === notification.title_key
|
||||
const hasUnknownBody = bodyText === notification.text_key
|
||||
const unread = !notification.is_read
|
||||
const answered = notification.response !== null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative px-4 py-3 transition-colors border-b border-edge-secondary ${notification.is_read ? '' : 'bg-accent-subtle'}`}
|
||||
className={`group relative mx-2 my-1 rounded-xl px-3 py-3 transition-colors hover:bg-surface-hover focus-within:bg-surface-hover ${unread ? 'bg-surface-secondary' : ''}`}
|
||||
>
|
||||
{unread && <span aria-hidden className="absolute start-1 top-5 w-1.5 h-1.5 rounded-full bg-accent" />}
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
{/* Sender avatar */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<div className="flex-shrink-0">
|
||||
{notification.sender_avatar ? (
|
||||
<img
|
||||
src={notification.sender_avatar}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
<img src={notification.sender_avatar} alt="" className="w-9 h-9 rounded-xl object-cover" />
|
||||
) : (
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-content-muted bg-surface-tertiary"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-xl flex items-center justify-center text-body font-semibold text-accent bg-accent-subtle">
|
||||
{notification.sender_username
|
||||
? notification.sender_username.charAt(0).toUpperCase()
|
||||
: <User className="w-4 h-4" />
|
||||
}
|
||||
: <User className="w-4 h-4" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -52,72 +60,67 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium leading-snug text-content">
|
||||
<p className={`text-body leading-snug text-content ${unread ? 'font-semibold' : 'font-medium'}`}>
|
||||
{hasUnknownTitle ? notification.title_key : titleText}
|
||||
</p>
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<span className="text-xs me-1 text-content-faint">
|
||||
<div className="relative flex-shrink-0 h-7 flex items-center">
|
||||
<span className="text-caption tabular-nums text-content-faint transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
|
||||
{relativeTime(notification.created_at, locale)}
|
||||
</span>
|
||||
{notification.is_read ? (
|
||||
<button type="button"
|
||||
onClick={() => markUnread(notification.id)}
|
||||
title={t('notifications.markUnread')}
|
||||
aria-label={t('notifications.markUnread')}
|
||||
className="p-1 rounded transition-colors text-content-faint hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover focus-visible:text-content"
|
||||
<div className="absolute end-0 top-0 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
{notification.is_read ? (
|
||||
<button type="button" onClick={() => markUnread(notification.id)} title={t('notifications.markUnread')} aria-label={t('notifications.markUnread')} className={TOOL}>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => markRead(notification.id)} title={t('notifications.markRead')} aria-label={t('notifications.markRead')} className={TOOL}>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteNotification(notification.id)}
|
||||
title={t('notifications.delete')}
|
||||
aria-label={t('notifications.delete')}
|
||||
className={`${TOOL} hover:text-danger focus-visible:text-danger`}
|
||||
>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button"
|
||||
onClick={() => markRead(notification.id)}
|
||||
title={t('notifications.markRead')}
|
||||
aria-label={t('notifications.markRead')}
|
||||
className="p-1 rounded transition-colors text-content-faint hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover focus-visible:text-content"
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button type="button"
|
||||
onClick={() => deleteNotification(notification.id)}
|
||||
title={t('notifications.delete')}
|
||||
aria-label={t('notifications.delete')}
|
||||
className="p-1 rounded transition-colors text-content-faint hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft focus-visible:text-danger"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs mt-0.5 leading-relaxed text-content-muted">
|
||||
<p className="text-caption mt-0.5 leading-relaxed text-content-muted">
|
||||
{hasUnknownBody ? notification.text_key : bodyText}
|
||||
</p>
|
||||
|
||||
{/* Boolean actions */}
|
||||
{notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button type="button"
|
||||
<div className="flex gap-2 mt-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRespond('positive')}
|
||||
disabled={responding || notification.response !== null}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
|
||||
disabled={responding || answered}
|
||||
className={`${PILL} ${
|
||||
notification.response === 'positive'
|
||||
? 'bg-content text-surface'
|
||||
: `bg-surface-tertiary ${notification.response === 'negative' ? 'text-content-faint opacity-50' : 'text-content-secondary'}`
|
||||
} ${notification.response !== null || responding ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
? 'bg-accent text-accent-text'
|
||||
: `bg-surface-card shadow-sm ${notification.response === 'negative' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
|
||||
} ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{t(notification.positive_text_key)}
|
||||
</button>
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRespond('negative')}
|
||||
disabled={responding || notification.response !== null}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
|
||||
disabled={responding || answered}
|
||||
className={`${PILL} ${
|
||||
notification.response === 'negative'
|
||||
? 'bg-danger text-white'
|
||||
: `bg-surface-tertiary ${notification.response === 'positive' ? 'text-content-faint opacity-50' : 'text-content-secondary'}`
|
||||
} ${notification.response !== null || responding ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
: `bg-surface-card shadow-sm ${notification.response === 'positive' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
|
||||
} ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
<X className="w-3.5 h-3.5" />
|
||||
{t(notification.negative_text_key)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -125,12 +128,13 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
|
||||
{/* Navigate action */}
|
||||
{notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && (
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNavigate}
|
||||
className="flex items-center gap-1 mt-2 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors bg-surface-tertiary text-content-secondary hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
className={`${PILL} mt-2.5 bg-surface-card shadow-sm text-content hover:bg-accent hover:text-accent-text focus-visible:bg-accent focus-visible:text-accent-text`}
|
||||
>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
{t(notification.navigate_text_key)}
|
||||
<ArrowRight className="w-3.5 h-3.5 rtl:-scale-x-100" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -397,6 +397,19 @@ describe('downloadTripPDF', () => {
|
||||
expect(iframe!.srcdoc).toContain('€')
|
||||
})
|
||||
|
||||
// #2692: a place's description and notes print as the planner shows them, not as raw markdown.
|
||||
it('FE-COMP-TRIPPDF-016c: renders markdown in a place description, sanitized', async () => {
|
||||
const description = ['**Colosseum** tickets', '', '| Gate | Time |', '| --- | --- |', '| A | 9:00 |', '', '<script>alert(1)</script>'].join('\n')
|
||||
const place = { ...placeWithDetails, description }
|
||||
const md = { ...richArgs, places: [place], assignments: { '10': [{ ...assignmentForDay, place }] } as typeof richArgs.assignments }
|
||||
await downloadTripPDF(md)
|
||||
const html = getIframe()?.srcdoc ?? ''
|
||||
expect(html).toContain('<strong>Colosseum</strong>')
|
||||
expect(html).toContain('<td>9:00</td>')
|
||||
expect(html).not.toContain('**Colosseum**')
|
||||
expect(html).not.toContain('<script>alert(1)</script>')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-016b: formats price chip and totals in the trip currency, not EUR', async () => {
|
||||
const usdArgs = {
|
||||
...richArgs,
|
||||
@@ -796,7 +809,7 @@ describe('downloadTripPDF remaining branches', () => {
|
||||
}]))
|
||||
const html = srcdoc()
|
||||
|
||||
expect(html).toContain('ICE 599 · Gl. 7 · Seat 21A · FRA → BER')
|
||||
expect(html).toContain('ICE 599 · reservations.meta.platform 7 · reservations.meta.seat 21A · FRA → BER')
|
||||
})
|
||||
|
||||
it('FE-W5PDF-006: a multi-leg train renders one line per leg', async () => {
|
||||
@@ -811,7 +824,7 @@ describe('downloadTripPDF remaining branches', () => {
|
||||
}]))
|
||||
const html = srcdoc()
|
||||
|
||||
expect(html).toContain('IC 1 · Gl. 3 · BER → MUC')
|
||||
expect(html).toContain('IC 1 · reservations.meta.platform 3 · BER → MUC')
|
||||
expect(html).toContain('EC 2 · MUC → ZRH')
|
||||
})
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { fetchExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../utils/flightLegs'
|
||||
import { isServiceStopType } from '../Roadtrip/roadtripModel'
|
||||
import { onlyMyPlan } from './pdfScope'
|
||||
import { createPdfMarkdown, PDF_MARKDOWN_CSS } from './pdfMarkdown'
|
||||
|
||||
/**
|
||||
* Every day starts a new page by default. On a trip of short days that prints
|
||||
@@ -231,6 +232,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
let hasTransportNotes = false
|
||||
const loc = _locale || undefined
|
||||
const tr = _t || (k => k)
|
||||
const markdown = createPdfMarkdown()
|
||||
// The store read is the fallback, not the source: a caller that forgets the
|
||||
// prop still prints the reader's own format instead of silently reverting.
|
||||
const is12h = (_timeFormat || useSettingsStore.getState().settings.time_format || '24h') === '12h'
|
||||
@@ -439,8 +441,8 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
const source = (usesStationRoute(r.type) ? getTrainLegs(r) : getFlightLegs(r))[l.index]
|
||||
subtitleLines = [[
|
||||
l.airline, l.flight_number, l.train_number,
|
||||
l.platform ? `Gl. ${l.platform}` : '',
|
||||
l.seat ? `Seat ${l.seat}` : '',
|
||||
l.platform ? `${tr('reservations.meta.platform')} ${l.platform}` : '',
|
||||
l.seat ? `${tr('reservations.meta.seat')} ${l.seat}` : '',
|
||||
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
|
||||
source?.confirmation_number,
|
||||
].filter(Boolean).join(' · ')].filter(Boolean)
|
||||
@@ -471,7 +473,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
// Multi-leg: one line per leg so every train number + segment route shows,
|
||||
// with the segment's own booking code where it has one (#1943).
|
||||
subtitleLines = legs.map(l =>
|
||||
[l.train_number, l.platform ? `Gl. ${l.platform}` : '',
|
||||
[l.train_number, l.platform ? `${tr('reservations.meta.platform')} ${l.platform}` : '',
|
||||
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
|
||||
l.confirmation_number]
|
||||
.filter(Boolean).join(' · '))
|
||||
@@ -479,7 +481,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
} else {
|
||||
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
|
||||
const route = stops.length >= 2 ? stops.join(' → ') : ''
|
||||
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : '', route].filter(Boolean).join(' · ')
|
||||
subtitle = [meta.train_number, meta.platform ? `${tr('reservations.meta.platform')} ${meta.platform}` : '', meta.seat ? `${tr('reservations.meta.seat')} ${meta.seat}` : '', route].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
else if (r.type === 'car') {
|
||||
@@ -587,10 +589,10 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
</div>
|
||||
${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''}
|
||||
${(place.lat != null && place.lng != null) ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted">${Number(place.lat).toFixed(5)}, ${Number(place.lng).toFixed(5)}</span></div>` : ''}
|
||||
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
|
||||
${place.description ? markdown.row(place.description) : ''}
|
||||
${chips ? `<div class="chips">${chips}</div>` : ''}
|
||||
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
|
||||
${item.data.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(item.data.notes)}</span></div>` : ''}
|
||||
${place.notes ? markdown.row(place.notes) : ''}
|
||||
${item.data.notes ? markdown.row(item.data.notes) : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
@@ -836,7 +838,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
.info-spacer { width: 13px; flex-shrink: 0; }
|
||||
.info-text { font-size: 9px; color: #64748b; line-height: 1.5; }
|
||||
.info-text.muted { color: #94a3b8; }
|
||||
.info-text.italic { font-style: italic; }
|
||||
${PDF_MARKDOWN_CSS}
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 4px; padding-left: 21px; margin-top: 4px; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 3px; font-size: 8px; font-weight: 600; background: #f1f5f9; color: #374151; border-radius: 99px; padding: 2px 7px; white-space: nowrap; }
|
||||
@@ -930,6 +932,7 @@ ${tripMapHtml}
|
||||
${daysHtml}
|
||||
${pluginSectionsHtml}
|
||||
</body></html>`
|
||||
markdown.dispose()
|
||||
|
||||
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
|
||||
const overlay = document.createElement('div')
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createMarkdownHtml } from '../shared/markdownHtml';
|
||||
|
||||
/**
|
||||
* A place's description and notes are markdown in the app, so the export
|
||||
* renders them the same way instead of printing the raw syntax (#2692): one
|
||||
* info row per text, the HTML from the app's own sanitized pipeline.
|
||||
*/
|
||||
export function createPdfMarkdown(): { row: (text: string) => string; dispose: () => void } {
|
||||
const markdown = createMarkdownHtml();
|
||||
return {
|
||||
row: (text) =>
|
||||
`<div class="info-row"><span class="info-spacer"></span><div class="info-text muted md">${markdown.html(text)}</div></div>`,
|
||||
dispose: markdown.dispose,
|
||||
};
|
||||
}
|
||||
|
||||
/** The markdown's own spacing, lists, tables and links at the export's small size. */
|
||||
export const PDF_MARKDOWN_CSS = `
|
||||
.info-text.md { min-width: 0; overflow-wrap: anywhere; }
|
||||
.md > :first-child { margin-top: 0; }
|
||||
.md > :last-child { margin-bottom: 0; }
|
||||
.md p, .md ul, .md ol, .md table, .md blockquote, .md pre { margin: 0 0 4px; }
|
||||
.md ul, .md ol { padding-inline-start: 14px; }
|
||||
.md h1, .md h2, .md h3, .md h4, .md h5, .md h6 { font-size: 1.1em; font-weight: 700; margin: 4px 0 2px; }
|
||||
.md table { border-collapse: collapse; }
|
||||
.md th, .md td { border: 1px solid #e2e8f0; padding: 2px 5px; text-align: start; }
|
||||
.md code { font-family: ui-monospace, monospace; font-size: 8.5px; }
|
||||
.md a { color: inherit; text-decoration: underline; }
|
||||
.md blockquote { border-inline-start: 2px solid #e2e8f0; padding-inline-start: 6px; }
|
||||
`;
|
||||
@@ -43,6 +43,7 @@ import './dayPlanRows.css'
|
||||
|
||||
import { TypeTile } from './bookings/bookingParts'
|
||||
import { typeInfo } from './bookings/bookingsModel'
|
||||
import { transportSubtitle } from './transportDetailModel'
|
||||
import { dayHeadStays } from './dayHeadStays'
|
||||
import { getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
|
||||
import {
|
||||
@@ -2166,24 +2167,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
let meta: any = {}
|
||||
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
|
||||
|
||||
// Subtitle aus Metadaten zusammensetzen
|
||||
let subtitle = ''
|
||||
if (res.__leg) {
|
||||
// One leg of a multi-leg flight/train — show this segment's own detail.
|
||||
const parts = res.type === 'train'
|
||||
? [res.__leg.train_number, res.__leg.platform ? `${t('reservations.meta.platform')} ${res.__leg.platform}` : '', res.__leg.seat ? `${t('reservations.meta.seat')} ${res.__leg.seat}` : ''].filter(Boolean)
|
||||
: [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
|
||||
if (res.__leg.from || res.__leg.to)
|
||||
parts.push([res.__leg.from, res.__leg.to].filter(Boolean).join(' → '))
|
||||
subtitle = parts.join(', ')
|
||||
} else if (res.type === 'flight') {
|
||||
const parts = [meta.airline, meta.flight_number].filter(Boolean)
|
||||
if (meta.departure_airport || meta.arrival_airport)
|
||||
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
|
||||
subtitle = parts.join(', ')
|
||||
} else if (res.type === 'train') {
|
||||
subtitle = [meta.train_number, meta.platform ? `${t('reservations.meta.platform')} ${meta.platform}` : '', meta.seat ? `${t('reservations.meta.seat')} ${meta.seat}` : ''].filter(Boolean).join(', ')
|
||||
}
|
||||
// A leg of a multi-leg flight or train shows its own detail (#2701).
|
||||
const subtitle = transportSubtitle(res, t) ?? ''
|
||||
|
||||
// A transit journey (#1065) renders its itinerary inline —
|
||||
// line badges in their colors instead of a plain subtitle,
|
||||
|
||||
@@ -23,3 +23,65 @@ export function bookingDayLabel(date: string, locale: string): string {
|
||||
export function segmentCodeLabel(leg: { from?: string | null; to?: string | null }, t: Translate): string {
|
||||
return [leg.from, leg.to].filter(Boolean).join(' → ') || t('reservations.confirmationCode');
|
||||
}
|
||||
|
||||
/** The per-leg detail the day plan's leg rows carry (utils/dayMerge's `__leg`). */
|
||||
export interface TransportLegDetail {
|
||||
index: number;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
airline?: string | null;
|
||||
flight_number?: string | null;
|
||||
train_number?: string | null;
|
||||
platform?: string | null;
|
||||
seat?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subtitle of a transport row in the day plan, desktop and phone alike. A
|
||||
* leg of a multi-leg train or flight names its own train or flight, platform
|
||||
* and seat (#2701); a whole booking reads them off its metadata. Null for the
|
||||
* other transport types, which each view labels its own way.
|
||||
*/
|
||||
export function transportSubtitle(
|
||||
res: { type: string; metadata?: unknown; __leg?: TransportLegDetail | null },
|
||||
t: Translate
|
||||
): string | null {
|
||||
const meta = parseMeta(res.metadata);
|
||||
const leg = res.__leg;
|
||||
const trainParts = (d: { train_number?: unknown; platform?: unknown; seat?: unknown }) => [
|
||||
str(d.train_number),
|
||||
str(d.platform) ? `${t('reservations.meta.platform')} ${str(d.platform)}` : '',
|
||||
str(d.seat) ? `${t('reservations.meta.seat')} ${str(d.seat)}` : '',
|
||||
];
|
||||
if (leg) {
|
||||
const parts = res.type === 'train' ? trainParts(leg) : [str(leg.airline), str(leg.flight_number)];
|
||||
if (leg.from || leg.to) parts.push([leg.from, leg.to].filter(Boolean).join(' → '));
|
||||
return parts.filter(Boolean).join(', ');
|
||||
}
|
||||
if (res.type === 'flight') {
|
||||
const parts = [str(meta.airline), str(meta.flight_number)];
|
||||
if (meta.departure_airport || meta.arrival_airport) {
|
||||
parts.push([str(meta.departure_airport), str(meta.arrival_airport)].filter(Boolean).join(' → '));
|
||||
}
|
||||
return parts.filter(Boolean).join(', ');
|
||||
}
|
||||
if (res.type === 'train') return trainParts(meta).filter(Boolean).join(', ');
|
||||
return null;
|
||||
}
|
||||
|
||||
function str(value: unknown): string {
|
||||
return typeof value === 'string' || typeof value === 'number' ? String(value) : '';
|
||||
}
|
||||
|
||||
function parseMeta(metadata: unknown): Record<string, unknown> {
|
||||
let meta = metadata;
|
||||
// An earlier bug could double-encode the metadata; reading it twice heals that.
|
||||
for (let i = 0; i < 2 && typeof meta === 'string'; i++) {
|
||||
try {
|
||||
meta = JSON.parse(meta || '{}');
|
||||
} catch {
|
||||
meta = {};
|
||||
}
|
||||
}
|
||||
return meta && typeof meta === 'object' ? (meta as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { browserTimeZone } from '../../utils/localDate'
|
||||
|
||||
// The design-token contract handed to plugins (#4 richer context): non-secret CSS
|
||||
// values, resolved for the CURRENT theme, so a plugin can match TREK exactly (and
|
||||
@@ -379,7 +380,7 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
|
||||
timeFormat: settings.time_format,
|
||||
distanceUnit: settings.distance_unit,
|
||||
temperatureUnit: settings.temperature_unit,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
timezone: browserTimeZone() ?? 'UTC',
|
||||
blurBookingCodes: Boolean(settings.blur_booking_codes),
|
||||
},
|
||||
tokens: readThemeTokens(),
|
||||
|
||||
@@ -15,15 +15,13 @@ import type { SaveState } from './useBookStore'
|
||||
* which is exactly the wrong habit for the one time it says something else.
|
||||
*/
|
||||
export function SaveIndicator({
|
||||
state, t, onAcceptTheirs, onKeepMine, onRetry, onReload = reloadStudio,
|
||||
state, t, onAcceptTheirs, onKeepMine, onRetry,
|
||||
}: {
|
||||
state: SaveState
|
||||
t: (k: string) => string
|
||||
onAcceptTheirs: (current: BookRecord) => void
|
||||
onKeepMine: (current: BookRecord) => void
|
||||
onRetry: () => void
|
||||
/** Load this Studio again, for a book a newer one saved. A full reload by default. */
|
||||
onReload?: () => void
|
||||
}) {
|
||||
if (state.status === 'conflict') {
|
||||
return (
|
||||
@@ -45,23 +43,8 @@ export function SaveIndicator({
|
||||
)
|
||||
}
|
||||
|
||||
if (state.status === 'newer') {
|
||||
/*
|
||||
* The one state with a way out the user has to take themselves: a newer
|
||||
* Studio saved this book, this tab cannot write it without losing what
|
||||
* that one added, and only a reload brings the newer Studio here. Loud
|
||||
* like a conflict, because until then nothing done here is kept.
|
||||
*/
|
||||
return (
|
||||
<div className="st-save is-conflict" role="alert">
|
||||
<AlertTriangle size={13} />
|
||||
<span>{t('journey.studio.saveNewer')}</span>
|
||||
<button type="button" onClick={onReload}>
|
||||
{t('journey.studio.saveReload')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// A book a newer Studio saved is the shell's to say, over everything (NewerBookOverlay).
|
||||
if (state.status === 'newer') return null
|
||||
|
||||
if (state.status === 'readonly') {
|
||||
/*
|
||||
@@ -107,6 +90,32 @@ export function SaveIndicator({
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* A book a newer Studio saved, said over the whole Studio rather than in the
|
||||
* bar: this tab cannot write the book without losing what the newer one
|
||||
* added, and only a reload brings the newer Studio here. Until then nothing
|
||||
* done here would be kept, so nothing can be done: the overlay takes every
|
||||
* click and offers the one way out there is.
|
||||
*/
|
||||
export function NewerBookOverlay({ t, onReload = reloadStudio }: {
|
||||
t: (k: string) => string
|
||||
onReload?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="st-newer" role="alertdialog" aria-modal="true" aria-labelledby="st-newer-text">
|
||||
<div className="st-newer-card">
|
||||
<span className="st-newer-icon"><RefreshCw size={22} strokeWidth={2.2} /></span>
|
||||
<p id="st-newer-text" className="st-newer-text">{t('journey.studio.saveNewer')}</p>
|
||||
{/* Focused on arrival, so Enter reloads and nothing behind the overlay takes the keyboard. */}
|
||||
<button type="button" className="st-newer-reload" onClick={onReload} autoFocus>
|
||||
<RefreshCw size={15} />
|
||||
{t('journey.studio.saveReload')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The default way back to a current Studio: the page again, which loads the newest build. */
|
||||
function reloadStudio() {
|
||||
window.location.reload()
|
||||
|
||||
@@ -18,6 +18,12 @@ interface SpreadThumbProps {
|
||||
className?: string;
|
||||
/** Pixels per millimetre the caller draws at; CSS's own unless the caller zooms. */
|
||||
pxPerMm?: number;
|
||||
/**
|
||||
* Draw the first and the last page beside the blank inside of their cover,
|
||||
* as the bound book opens on them, so the rail shows why they stand alone.
|
||||
* The words are the half's name, read out and shown on hover.
|
||||
*/
|
||||
coverInside?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,16 +43,31 @@ export function SpreadThumb({
|
||||
fold = false,
|
||||
className,
|
||||
pxPerMm = PX_PER_MM,
|
||||
coverInside,
|
||||
}: SpreadThumbProps) {
|
||||
const single = spread.role !== 'inner';
|
||||
const wMm = single ? page.pageWidth : page.pageWidth * 2;
|
||||
// The first page opens on the right of the cover's inside, the last on the left of the back's.
|
||||
const opens = coverInside && (spread.role === 'first' || spread.role === 'last') ? spread.role : null;
|
||||
const wMm = single && !opens ? page.pageWidth : page.pageWidth * 2;
|
||||
const scale = width / (wMm * pxPerMm);
|
||||
const pageLeft = opens === 'first' ? page.pageWidth * pxPerMm * scale : 0;
|
||||
return (
|
||||
<div dir="ltr" className={className} style={{ width, height: page.pageHeight * pxPerMm * scale }}>
|
||||
{opens && (
|
||||
<span
|
||||
className="st-thumb-inside"
|
||||
title={coverInside}
|
||||
aria-label={coverInside}
|
||||
style={{
|
||||
insetInlineStart: opens === 'first' ? 0 : page.pageWidth * pxPerMm * scale,
|
||||
width: page.pageWidth * pxPerMm * scale,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: 0,
|
||||
insetInlineStart: pageLeft,
|
||||
top: 0,
|
||||
width: `${wMm}mm`,
|
||||
height: `${page.pageHeight}mm`,
|
||||
@@ -56,7 +77,7 @@ export function SpreadThumb({
|
||||
>
|
||||
<SpreadView spread={spread} page={page} folios={folios} />
|
||||
</div>
|
||||
{fold && !single && <SpreadFold page={page} scaled={pxPerMm * scale} />}
|
||||
{fold && (!single || opens) && <SpreadFold page={page} scaled={pxPerMm * scale} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
Ungroup,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import '../../styles/studio-panels.css';
|
||||
@@ -118,7 +117,8 @@ export function StudioArrangeSection({
|
||||
const copyStyle = useStudioStore((s) => s.copyStyle);
|
||||
const pasteStyle = useStudioStore((s) => s.pasteStyle);
|
||||
const keys = useStudioShortcut();
|
||||
const [chosenTo, setChosenTo] = useState<BookAlignTo | null>(null);
|
||||
const chosenTo = useStudioStore((s) => s.alignTo);
|
||||
const setChosenTo = useStudioStore((s) => s.setAlignTo);
|
||||
|
||||
if (!spread || !selection.length) return null;
|
||||
const sel = spread.elements.filter((e) => selection.includes(e.id));
|
||||
|
||||
@@ -215,13 +215,13 @@ export function StudioAssistantToggle({ assistant }: { assistant: StudioAssistan
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`st-tool st-assistant-toggle ${assistant.open ? 'is-on' : ''}`}
|
||||
className={`st-tool is-icon st-head-solo st-assistant-toggle ${assistant.open ? 'is-on' : ''}`}
|
||||
onClick={assistant.toggle}
|
||||
aria-pressed={assistant.open}
|
||||
aria-label={t('journey.studio.assistant.toggle')}
|
||||
title={t('journey.studio.assistant.toggleHint')}
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
<span className="st-tool-label">{t('journey.studio.assistant.toggle')}</span>
|
||||
<MessageSquare size={15} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useId, useState } from 'react'
|
||||
import { Frame, Minus, Quote, Shapes, Spline, Sparkles, Square, Sticker, Type, type LucideIcon } from 'lucide-react'
|
||||
import type { BookElement, BookPageSetup, BookShapeId } from '@trek/shared'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS } from './shapes'
|
||||
import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS, shapeName } from './shapes'
|
||||
import {
|
||||
BOOK_GRIDS as GRIDS, MAX_SPREAD_ELEMENTS, bookElementId, bookShapeSize, bookGridElements as gridElements,
|
||||
defaultBookGridBox as defaultGridBox,
|
||||
@@ -195,7 +195,11 @@ function useElementAdders(page: BookPageSetup) {
|
||||
const els = gridElements(grid, defaultGridBox(page, single))
|
||||
// The page's own cap, as a duplicate keeps it: frames that would make the book unsaveable are not added.
|
||||
const on = useStudioStore.getState().doc?.spreads[active]
|
||||
if (!on || on.elements.length + els.length > MAX_SPREAD_ELEMENTS) return
|
||||
if (!on) return
|
||||
if (on.elements.length + els.length > MAX_SPREAD_ELEMENTS) {
|
||||
useStudioStore.getState().refuseFull()
|
||||
return
|
||||
}
|
||||
commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => (i !== active ? sp : { ...sp, elements: [...sp.elements, ...els] })),
|
||||
@@ -276,7 +280,8 @@ function ShapesTab({ addShape, t }: { addShape: Adders['addShape']; t: (k: strin
|
||||
key={shape}
|
||||
className="st-shape-btn is-glyph"
|
||||
onClick={() => addShape(shape)}
|
||||
aria-label={shape}
|
||||
title={shapeName(shape, t)}
|
||||
aria-label={shapeName(shape, t)}
|
||||
>
|
||||
<ShapeGlyph shape={shape} />
|
||||
</button>
|
||||
@@ -315,7 +320,8 @@ function FramesTab({ adders, t }: { adders: Adders; t: (k: string) => string })
|
||||
key={shape}
|
||||
className="st-shape-btn is-glyph"
|
||||
onClick={() => adders.addFrame(shape === 'rect' ? null : shape)}
|
||||
aria-label={shape}
|
||||
title={shapeName(shape, t)}
|
||||
aria-label={shapeName(shape, t)}
|
||||
>
|
||||
<ShapeGlyph shape={shape} photo />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { StudioHeader } from './StudioHeader';
|
||||
import type { Studio } from './StudioShell';
|
||||
import type { StudioAssistant } from './useStudioAssistant';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-HEAD-001 to FE-STUDIO-HEAD-007: the Studio's head band, with the
|
||||
* hook's answer stubbed to what the band reads.
|
||||
*/
|
||||
|
||||
function studio(over: Partial<Studio> = {}): Studio {
|
||||
return {
|
||||
t: (key: string) => key,
|
||||
journey: { title: 'Japan 2026' },
|
||||
coverUrl: '/uploads/cover.jpg',
|
||||
saveState: { status: 'idle' },
|
||||
peers: [],
|
||||
canUndo: true,
|
||||
canRedo: false,
|
||||
canEdit: true,
|
||||
activeSpread: 0,
|
||||
doc: { spreads: [] },
|
||||
preset: 'square-210',
|
||||
page: { pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
|
||||
canRelayoutSpread: true,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
close: vi.fn(),
|
||||
setPreset: vi.fn(),
|
||||
setPageSize: vi.fn(),
|
||||
setPageEdge: vi.fn(),
|
||||
relayoutCurrentSpread: vi.fn(),
|
||||
relayoutBook: vi.fn(),
|
||||
loadDoc: vi.fn(),
|
||||
acceptTheirs: vi.fn(),
|
||||
keepMine: vi.fn(),
|
||||
saveNow: vi.fn(),
|
||||
...over,
|
||||
} as unknown as Studio;
|
||||
}
|
||||
|
||||
const noAssistant = { available: false } as unknown as StudioAssistant;
|
||||
|
||||
function head(s = studio(), onExport = vi.fn()) {
|
||||
render(<StudioHeader s={s} onExport={onExport} assistant={noAssistant} />);
|
||||
return { s, onExport };
|
||||
}
|
||||
|
||||
describe('StudioHeader', () => {
|
||||
it("FE-STUDIO-HEAD-001: names the journey under the Studio's mark, with its cover as the tile", () => {
|
||||
head();
|
||||
expect(screen.getByRole('heading', { name: 'Japan 2026' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: 'TREK Studio' })).toBeInTheDocument();
|
||||
expect(document.querySelector('.st-head-tile img')?.getAttribute('src')).toBe('/uploads/cover.jpg');
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-002: the round button closes the Studio, export hands over', () => {
|
||||
const { s, onExport } = head();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.backToJourney' }));
|
||||
expect(s.close).toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: /journey.studio.export/ }));
|
||||
expect(onExport).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-003: undo runs, redo waits for something to redo, paste for something copied', () => {
|
||||
const { s } = head();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.undo' }));
|
||||
expect(s.undo).toHaveBeenCalled();
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.redo' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.paste' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-004: the format chip names the format and opens the list, a pick sets it', () => {
|
||||
const { s } = head();
|
||||
const chip = screen.getByRole('button', { name: 'journey.studio.format' });
|
||||
expect(chip).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(chip);
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options.length).toBeGreaterThan(1);
|
||||
fireEvent.click(options[options.length - 1]);
|
||||
expect(s.setPreset).toHaveBeenCalled();
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-005: a custom format reads its size, and Escape or a press outside closes the list', () => {
|
||||
head(
|
||||
studio({ preset: 'custom', page: { pageWidth: 180.4, pageHeight: 240, bleed: 3, safe: 5 } } as Partial<Studio>)
|
||||
);
|
||||
const chip = screen.getByRole('button', { name: 'journey.studio.format' });
|
||||
expect(chip).toHaveTextContent('180 × 240 mm');
|
||||
fireEvent.click(chip);
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
fireEvent.click(chip);
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-006: auto layout offers this spread and the whole book', () => {
|
||||
const { s } = head();
|
||||
fireEvent.click(screen.getByRole('button', { name: /journey.studio.autoLayout/ }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /^journey\.studio\.relayoutSpread/ }));
|
||||
expect(s.relayoutCurrentSpread).toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: /journey.studio.autoLayout/ }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /^journey\.studio\.relayoutBook/ }));
|
||||
expect(s.relayoutBook).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-007: with something copied, paste puts it on the page on screen', () => {
|
||||
const paste = vi.fn();
|
||||
useStudioStore.setState({ clipboard: { elements: [] } as never, paste } as never);
|
||||
head(studio({ activeSpread: 2 } as Partial<Studio>));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.paste' }));
|
||||
expect(paste).toHaveBeenCalledWith(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
import { Check, ChevronDown, ClipboardPaste, Download, Redo2, Sparkles, Undo2, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import '../../styles/studio-head.css';
|
||||
import HelpButton from '../Help/HelpButton';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets';
|
||||
import { PeerBadges } from './PeerBadges';
|
||||
import { SaveIndicator } from './SaveIndicator';
|
||||
import { StudioAssistantToggle } from './StudioAssistantPanel';
|
||||
import type { Studio } from './StudioShell';
|
||||
import { StudioWordmark } from './StudioWordmark';
|
||||
import { TrimField } from './TrimField';
|
||||
import type { StudioAssistant } from './useStudioAssistant';
|
||||
|
||||
/**
|
||||
* The Studio's head band, in the shape every TREK dialog has: a raised tile on
|
||||
* the left, the Studio's mark above the title, the title, a line under it, and
|
||||
* the close button at the far end.
|
||||
*
|
||||
* The tile is the journey's cover and the title its name, so it is always clear
|
||||
* whose book this is. The tools on the right are grouped by what they do:
|
||||
* who else is here and whether the book is saved, what the book is printed as, editing (undo, redo, paste), auto
|
||||
* layout, the assistant, help, and getting the book out. Every piece of
|
||||
* the row is one raised tile of one height, export alone in the accent. How
|
||||
* the page is looked at, the book view, lives with the zoom under the page.
|
||||
*/
|
||||
export function StudioHeader({
|
||||
s,
|
||||
onExport,
|
||||
assistant,
|
||||
}: {
|
||||
s: Studio;
|
||||
onExport: () => void;
|
||||
assistant: StudioAssistant;
|
||||
}) {
|
||||
return (
|
||||
<header className="st-bar st-head">
|
||||
<span className="st-head-tile">{s.coverUrl ? <img src={s.coverUrl} alt="" /> : null}</span>
|
||||
|
||||
<div className="st-head-text">
|
||||
<div className="st-head-eyebrow">
|
||||
<StudioWordmark style={{ height: 17 }} />
|
||||
</div>
|
||||
<h1 className="st-head-title">{s.journey?.title || s.t('journey.title')}</h1>
|
||||
</div>
|
||||
|
||||
<div className="st-head-tools">
|
||||
<PeerBadges peers={s.peers} t={s.t} />
|
||||
{/* Whether the book is safe, as a tile beside who else is here: both say how the book stands. */}
|
||||
<SaveIndicator
|
||||
state={s.saveState}
|
||||
t={s.t}
|
||||
onAcceptTheirs={(current) => s.loadDoc(s.acceptTheirs(current))}
|
||||
onKeepMine={s.keepMine}
|
||||
onRetry={() => {
|
||||
void s.saveNow();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="st-head-group">
|
||||
<FormatPicker s={s} />
|
||||
</div>
|
||||
|
||||
<div className="st-head-group">
|
||||
<IconTool label={s.t('journey.studio.undo')} disabled={!s.canUndo} onClick={s.undo}>
|
||||
<Undo2 size={15} />
|
||||
</IconTool>
|
||||
<IconTool label={s.t('journey.studio.redo')} disabled={!s.canRedo} onClick={s.redo}>
|
||||
<Redo2 size={15} />
|
||||
</IconTool>
|
||||
<PasteButton s={s} />
|
||||
</div>
|
||||
|
||||
<div className="st-head-group">
|
||||
<AutoLayoutButton s={s} />
|
||||
</div>
|
||||
|
||||
{/* The book's assistant, a tile of its own like help; only where it can run for this user. */}
|
||||
<StudioAssistantToggle assistant={assistant} />
|
||||
{/* Studio has no navbar, so the help center's button rides on its own bar. */}
|
||||
<HelpButton className="st-tool is-icon st-head-solo" />
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool is-primary st-head-solo"
|
||||
onClick={onExport}
|
||||
disabled={!s.doc}
|
||||
title={s.t('journey.studio.export')}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.export')}</span>
|
||||
</button>
|
||||
|
||||
<Tooltip label={s.t('journey.studio.backToJourney')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={s.close}
|
||||
className="st-head-close"
|
||||
aria-label={s.t('journey.studio.backToJourney')}
|
||||
>
|
||||
<X size={16} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function IconTool({
|
||||
label,
|
||||
disabled,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<button type="button" className="st-tool is-icon" disabled={disabled} onClick={onClick} aria-label={label}>
|
||||
{children}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes a popover on a pointer press outside it and on Escape. Escape is
|
||||
* swallowed, or the shell's own handler would close Studio itself.
|
||||
*/
|
||||
function useDismiss(box: RefObject<HTMLElement | null>, open: boolean, setOpen: (open: boolean) => void) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', onDown);
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown);
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
};
|
||||
}, [box, open, setOpen]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste what was copied onto the page on screen (#2316). Beside undo and redo
|
||||
* because it is the same kind of thing, and there so the keyboard shortcut is
|
||||
* not the only way to find it.
|
||||
*/
|
||||
function PasteButton({ s }: { s: Studio }) {
|
||||
const clipboard = useStudioStore((st) => st.clipboard);
|
||||
const paste = useStudioStore((st) => st.paste);
|
||||
const label = s.t('journey.studio.paste');
|
||||
return (
|
||||
<Tooltip label={clipboard ? label : s.t('journey.studio.pasteEmpty')}>
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool is-icon"
|
||||
disabled={!clipboard || !s.canEdit}
|
||||
onClick={() => paste(s.activeSpread)}
|
||||
aria-label={label}
|
||||
>
|
||||
<ClipboardPaste size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out again: this spread, or the whole book.
|
||||
*
|
||||
* A split button rather than two: they are the same action at two scopes, and
|
||||
* the spread is the one you want nine times out of ten. Rebuilding the whole
|
||||
* book is behind the chevron, where it cannot be hit by accident, and both are
|
||||
* ordinary undo steps.
|
||||
*/
|
||||
function AutoLayoutButton({ s }: { s: Studio }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
useDismiss(box, open, setOpen);
|
||||
|
||||
return (
|
||||
<div className="st-picker" ref={box}>
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={s.t('journey.studio.autoLayout')}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.autoLayout')}</span>
|
||||
<ChevronDown size={13} style={{ opacity: 0.5 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu" role="menu">
|
||||
<button
|
||||
type="button"
|
||||
className="st-menu-item"
|
||||
role="menuitem"
|
||||
disabled={!s.canRelayoutSpread}
|
||||
onClick={() => {
|
||||
s.relayoutCurrentSpread();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t('journey.studio.relayoutSpread')}</span>
|
||||
<span className="st-menu-dim">
|
||||
{s.t(s.canRelayoutSpread ? 'journey.studio.relayoutSpreadHint' : 'journey.studio.relayoutSpreadNone')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="st-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
s.relayoutBook();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t('journey.studio.relayoutBook')}</span>
|
||||
<span className="st-menu-dim">{s.t('journey.studio.relayoutBookHint')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The format, as a tool of the head row: it says what the book is printed as,
|
||||
* and opening it changes that.
|
||||
*
|
||||
* A native <select> was the first draft and it was wrong: the browser draws the
|
||||
* list in its own chrome, so it ignores the theme entirely and drops a list that
|
||||
* runs off the bottom of the screen. This is a small popover instead, themed,
|
||||
* anchored under the chip, and it shows each format's proportions rather than
|
||||
* only naming them.
|
||||
*/
|
||||
function FormatPicker({ s }: { s: Studio }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
useDismiss(box, open, setOpen);
|
||||
|
||||
const active = PAGE_PRESETS[s.preset] ?? PAGE_PRESETS['square-210'];
|
||||
const width = s.preset === 'custom' ? s.page.pageWidth : active.pageWidthMm;
|
||||
const height = s.preset === 'custom' ? s.page.pageHeight : active.pageHeightMm;
|
||||
|
||||
return (
|
||||
<div className="st-picker" ref={box}>
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={s.t('journey.studio.format')}
|
||||
>
|
||||
<span
|
||||
className="st-head-chip-shape"
|
||||
style={
|
||||
width >= height
|
||||
? { width: 12, height: Math.max(6, Math.round((12 * height) / width)) }
|
||||
: { height: 12, width: Math.max(6, Math.round((12 * width) / height)) }
|
||||
}
|
||||
/>
|
||||
<span className="st-tool-label">
|
||||
{s.preset === 'custom'
|
||||
? `${Math.round(s.page.pageWidth)} × ${Math.round(s.page.pageHeight)} mm`
|
||||
: s.t(active.labelKey)}
|
||||
</span>
|
||||
<ChevronDown size={13} style={{ opacity: 0.5 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu" role="listbox">
|
||||
{PAGE_PRESET_ORDER.map((id) => {
|
||||
const p = PAGE_PRESETS[id];
|
||||
const on = id === s.preset;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
className={`st-menu-item ${on ? 'is-active' : ''}`}
|
||||
onClick={() => {
|
||||
s.setPreset(id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="st-menu-tile">
|
||||
<span
|
||||
className="st-menu-shape"
|
||||
style={
|
||||
p.pageWidthMm >= p.pageHeightMm
|
||||
? { width: 24, height: Math.round((24 * p.pageHeightMm) / p.pageWidthMm) }
|
||||
: { height: 24, width: Math.round((24 * p.pageWidthMm) / p.pageHeightMm) }
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t(p.labelKey)}</span>
|
||||
<span className="st-menu-dim">
|
||||
{p.pageWidthMm} × {p.pageHeightMm} mm
|
||||
</span>
|
||||
</span>
|
||||
{on && <Check size={14} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/*
|
||||
The two fields live under the list rather than behind a "custom"
|
||||
entry that opens a second popover: picking the custom row and then
|
||||
typing into the fields it reveals is two steps for one decision, and
|
||||
typing into them *is* picking custom; the hook switches the preset
|
||||
for you.
|
||||
*/}
|
||||
<div className="st-menu-sep" />
|
||||
<div className="st-menu-custom">
|
||||
<TrimField
|
||||
label={s.t('journey.studio.width')}
|
||||
value={s.page.pageWidth}
|
||||
onCommit={(v) => s.setPageSize('w', v)}
|
||||
/>
|
||||
<span className="st-menu-times">×</span>
|
||||
<TrimField
|
||||
label={s.t('journey.studio.height')}
|
||||
value={s.page.pageHeight}
|
||||
onCommit={(v) => s.setPageSize('h', v)}
|
||||
/>
|
||||
<span className="st-menu-unit">mm</span>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
What the press takes off, and what it must not print into. Under the
|
||||
trim size because they answer the same question, how big is this book
|
||||
really, and because a vendor states all three in one breath.
|
||||
*/}
|
||||
<div className="st-menu-custom">
|
||||
<TrimField
|
||||
label={s.t('journey.studio.bleed')}
|
||||
value={s.page.bleed}
|
||||
min={0}
|
||||
max={20}
|
||||
step={0.5}
|
||||
onCommit={(v) => s.setPageEdge('bleed', v)}
|
||||
/>
|
||||
<span className="st-menu-times" />
|
||||
<TrimField
|
||||
label={s.t('journey.studio.safeArea')}
|
||||
value={s.page.safe}
|
||||
min={0}
|
||||
max={40}
|
||||
step={0.5}
|
||||
onCommit={(v) => s.setPageEdge('safe', v)}
|
||||
/>
|
||||
<span className="st-menu-unit">mm</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { useToast } from '../shared/Toast';
|
||||
|
||||
/**
|
||||
* What the Studio says about a click it could not carry out. Today one thing:
|
||||
* the page already holds as many elements as a page can, so a sticker, a frame,
|
||||
* a paste or a duplicate was not added. Every path that refuses counts it in the
|
||||
* store, and this says it once per count rather than each path in its own words.
|
||||
*/
|
||||
export function StudioNotices() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const refused = useStudioStore((s) => s.refusedFull);
|
||||
const seen = useRef(refused);
|
||||
useEffect(() => {
|
||||
if (refused === seen.current) return;
|
||||
seen.current = refused;
|
||||
toast.warning(t('journey.studio.pageFull'));
|
||||
}, [refused, toast, t]);
|
||||
return null;
|
||||
}
|
||||
@@ -64,12 +64,19 @@ export function StudioPaintPicker({
|
||||
channel,
|
||||
t,
|
||||
noneLabel,
|
||||
kinds = KINDS,
|
||||
}: {
|
||||
value: BookPaint | null;
|
||||
channel: PaintChannel;
|
||||
t: StudioT;
|
||||
/** What "no paint" is called here: no fill on a shape, the paper on a page. */
|
||||
noneLabel: string;
|
||||
/**
|
||||
* The kinds offered as tabs; all four by default. With one, the row of tabs
|
||||
* is left out: the letters of a text pick gradient or pattern beside their
|
||||
* other fills and only need that kind's controls here.
|
||||
*/
|
||||
kinds?: readonly PaintKind[];
|
||||
}) {
|
||||
const kind = kindOf(value);
|
||||
// The last paint of each kind shown here, so a kind visited again comes back as it was left.
|
||||
@@ -96,28 +103,30 @@ export function StudioPaintPicker({
|
||||
|
||||
return (
|
||||
<div className="st-paint">
|
||||
<div className="st-paint-tabs" role="tablist" aria-label={t('journey.studio.paint.kind')}>
|
||||
{KINDS.map((k) => (
|
||||
<button
|
||||
type="button"
|
||||
key={k}
|
||||
role="tab"
|
||||
aria-selected={k === kind}
|
||||
className={`st-paint-tab ${k === kind ? 'is-on' : ''}`}
|
||||
onClick={() => choose(k)}
|
||||
>
|
||||
<span
|
||||
className={`st-paint-tab-chip ${k === 'none' ? 'is-none' : ''} ${k === 'pattern' ? 'is-pattern' : ''}`}
|
||||
// A pattern's chip keeps its strokes (studio-paint.css) over the ground colour.
|
||||
style={k === 'pattern' ? { backgroundColor: tabPreview(k) } : { background: tabPreview(k) }}
|
||||
aria-hidden
|
||||
{kinds.length > 1 && (
|
||||
<div className="st-paint-tabs" role="tablist" aria-label={t('journey.studio.paint.kind')}>
|
||||
{kinds.map((k) => (
|
||||
<button
|
||||
type="button"
|
||||
key={k}
|
||||
role="tab"
|
||||
aria-selected={k === kind}
|
||||
className={`st-paint-tab ${k === kind ? 'is-on' : ''}`}
|
||||
onClick={() => choose(k)}
|
||||
>
|
||||
{k === 'none' && <Ban size={11} strokeWidth={2.2} />}
|
||||
</span>
|
||||
<span className="st-paint-tab-label">{k === 'none' ? noneLabel : kindLabel(k, t)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span
|
||||
className={`st-paint-tab-chip ${k === 'none' ? 'is-none' : ''} ${k === 'pattern' ? 'is-pattern' : ''}`}
|
||||
// A pattern's chip keeps its strokes (studio-paint.css) over the ground colour.
|
||||
style={k === 'pattern' ? { backgroundColor: tabPreview(k) } : { background: tabPreview(k) }}
|
||||
aria-hidden
|
||||
>
|
||||
{k === 'none' && <Ban size={11} strokeWidth={2.2} />}
|
||||
</span>
|
||||
<span className="st-paint-tab-label">{k === 'none' ? noneLabel : kindLabel(k, t)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{value?.type === 'solid' && (
|
||||
<Swatches
|
||||
|
||||
@@ -35,10 +35,10 @@ export function StudioSavedLayouts({
|
||||
const scale = width / (wMm * pxPerMm)
|
||||
|
||||
return (
|
||||
<div className="st-saved-layouts">
|
||||
<div className="st-section st-saved-layouts">
|
||||
<div className="st-section-label">{t('journey.studio.myLayouts')}</div>
|
||||
{fitting.length === 0 && <p className="st-hint">{t('journey.studio.myLayoutsEmpty')}</p>}
|
||||
<div className="st-thumbs">
|
||||
<div className={`st-thumbs st-tpl-grid ${single ? 'is-pages' : 'is-spreads'}`}>
|
||||
{fitting.map(layout => (
|
||||
<div className="st-thumb-row" key={layout.id}>
|
||||
<button type="button" className="st-thumb" onClick={() => applyLayout(active, layout.id)}>
|
||||
@@ -79,7 +79,6 @@ export function StudioSavedLayouts({
|
||||
{t('journey.studio.saveLayout')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className="st-section-label st-builtin-label">{t('journey.studio.builtInLayouts')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useNavigate } from 'react-router'
|
||||
import {
|
||||
ArrowLeft, BookOpen, Check, ChevronDown, ClipboardPaste, Download, Maximize2, Minus, Plus,
|
||||
Redo2, Sparkles, Undo2,
|
||||
} from 'lucide-react'
|
||||
import { BookOpen, Download, Maximize2, Minus, Plus } from 'lucide-react'
|
||||
import { useJourneyStudio } from '../../pages/journeyStudio/useJourneyStudio'
|
||||
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { foliosOf } from './bookSheets'
|
||||
import { StudioSidebar } from './StudioSidebar'
|
||||
import type { RailSection } from './sidebar/railSections'
|
||||
@@ -15,21 +12,17 @@ import { useAuthStore } from '../../store/authStore'
|
||||
import { StudioCanvas } from './StudioCanvas'
|
||||
import { StudioInspector } from './StudioInspector'
|
||||
import { StudioPaletteScope } from './paint/StudioPalette'
|
||||
import { StudioWordmark } from './StudioWordmark'
|
||||
import { SaveIndicator } from './SaveIndicator'
|
||||
import { downloadSpread } from './spreadFile'
|
||||
import { StudioExport } from './StudioExport'
|
||||
import { PeerBadges } from './PeerBadges'
|
||||
import { TrimField } from './TrimField'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { StudioAssistantColumn, StudioAssistantToggle, StudioPageStage } from './StudioAssistantPanel'
|
||||
import { useStudioAssistant, type StudioAssistant } from './useStudioAssistant'
|
||||
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'
|
||||
import './bookFontFaces'
|
||||
import { useWarmBookFaces } from './bookFontWarm'
|
||||
import HelpButton from '../Help/HelpButton'
|
||||
|
||||
/**
|
||||
* The Studio shell: top bar, page rail, workbench, inspector.
|
||||
@@ -93,13 +86,10 @@ export default function StudioShell() {
|
||||
aria-modal="true"
|
||||
aria-label={s.t('journey.studio.title')}
|
||||
>
|
||||
<StudioBar
|
||||
s={s}
|
||||
bookView={bookView}
|
||||
setBookView={setBookView}
|
||||
onExport={() => setExporting(true)}
|
||||
assistant={assistant}
|
||||
/>
|
||||
<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
|
||||
@@ -132,7 +122,7 @@ export default function StudioShell() {
|
||||
section={railSection}
|
||||
onSection={setRailSection}
|
||||
/>
|
||||
<Workbench s={s} bookView={bookView} />
|
||||
<Workbench s={s} bookView={bookView} setBookView={setBookView} />
|
||||
<StudioPaletteScope spreadIndex={s.activeSpread} t={s.t}>
|
||||
<StudioInspector
|
||||
spreadIndex={s.activeSpread}
|
||||
@@ -164,339 +154,10 @@ export default function StudioShell() {
|
||||
)
|
||||
}
|
||||
|
||||
type Studio = ReturnType<typeof useJourneyStudio>
|
||||
export type Studio = ReturnType<typeof useJourneyStudio>
|
||||
|
||||
function StudioBar({
|
||||
s, bookView, setBookView, onExport, assistant,
|
||||
}: {
|
||||
s: Studio; bookView: boolean; setBookView: (v: boolean) => void; onExport: () => void
|
||||
assistant: StudioAssistant
|
||||
}) {
|
||||
return (
|
||||
<div className="st-bar">
|
||||
<button type="button" onClick={s.close} className="st-back" title={s.t('journey.studio.backToJourney')}>
|
||||
<ArrowLeft size={16} />
|
||||
{s.coverUrl
|
||||
? <img src={s.coverUrl} alt="" className="st-cover" />
|
||||
: <span className="st-cover" />}
|
||||
<span className="st-back-label">{s.journey?.title || s.t('journey.title')}</span>
|
||||
</button>
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
{/*
|
||||
The lockup, not a name field. The book is the journey's, and it was
|
||||
already titled after it — a second place to type the same name is a
|
||||
field whose only job is to be left alone. What the bar is short of is
|
||||
somewhere to say what this thing *is*.
|
||||
*/}
|
||||
<div className="st-brand">
|
||||
<StudioWordmark style={{ height: 26 }} />
|
||||
<span className="st-beta">{s.t('journey.studio.beta')}</span>
|
||||
</div>
|
||||
|
||||
<PeerBadges peers={s.peers} t={s.t} />
|
||||
|
||||
<SaveIndicator
|
||||
state={s.saveState}
|
||||
t={s.t}
|
||||
onAcceptTheirs={current => s.loadDoc(s.acceptTheirs(current))}
|
||||
onKeepMine={s.keepMine}
|
||||
onRetry={() => { void s.saveNow() }}
|
||||
/>
|
||||
|
||||
<div className="st-bar-group">
|
||||
<button type="button"
|
||||
className={`st-tool ${bookView ? 'is-on' : ''}`}
|
||||
onClick={() => setBookView(!bookView)}
|
||||
title={s.t('journey.studio.bookView')}
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.bookView')}</span>
|
||||
</button>
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
<FormatPicker s={s} />
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
<button type="button"
|
||||
className="st-tool is-icon"
|
||||
disabled={!s.canUndo}
|
||||
onClick={s.undo}
|
||||
title={s.t('journey.studio.undo')}
|
||||
aria-label={s.t('journey.studio.undo')}
|
||||
>
|
||||
<Undo2 size={15} />
|
||||
</button>
|
||||
<button type="button"
|
||||
className="st-tool is-icon"
|
||||
disabled={!s.canRedo}
|
||||
onClick={s.redo}
|
||||
title={s.t('journey.studio.redo')}
|
||||
aria-label={s.t('journey.studio.redo')}
|
||||
>
|
||||
<Redo2 size={15} />
|
||||
</button>
|
||||
<PasteButton s={s} />
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
<AutoLayoutButton s={s} />
|
||||
{/* The book's assistant, as a column beside the page; only where it can run for this user. */}
|
||||
<StudioAssistantToggle assistant={assistant} />
|
||||
<button type="button"
|
||||
className="st-tool is-primary"
|
||||
onClick={onExport}
|
||||
disabled={!s.doc}
|
||||
title={s.t('journey.studio.export')}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.export')}</span>
|
||||
</button>
|
||||
{/* Studio has no navbar, so the help center's button rides on its own bar. */}
|
||||
<HelpButton className="st-tool is-icon" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out again — this spread, or the whole book.
|
||||
*
|
||||
* A split button rather than two: they are the same action at two scopes, and
|
||||
* the spread is the one you want nine times out of ten. Rebuilding the whole
|
||||
* book is behind the chevron, where it cannot be hit by accident, and both are
|
||||
* ordinary undo steps.
|
||||
*/
|
||||
/**
|
||||
* Paste what was copied onto the page on screen (#2316). Beside undo and redo
|
||||
* because it is the same kind of thing, and there so the keyboard shortcut is
|
||||
* not the only way to find it.
|
||||
*/
|
||||
function PasteButton({ s }: { s: Studio }) {
|
||||
const clipboard = useStudioStore(st => st.clipboard)
|
||||
const paste = useStudioStore(st => st.paste)
|
||||
const label = s.t('journey.studio.paste')
|
||||
return (
|
||||
<Tooltip label={clipboard ? label : s.t('journey.studio.pasteEmpty')}>
|
||||
<button type="button"
|
||||
className="st-tool is-icon"
|
||||
disabled={!clipboard || !s.canEdit}
|
||||
onClick={() => paste(s.activeSpread)}
|
||||
aria-label={label}
|
||||
>
|
||||
<ClipboardPaste size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function AutoLayoutButton({ s }: { s: Studio }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) }
|
||||
}
|
||||
document.addEventListener('pointerdown', onDown)
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown)
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="st-picker" ref={box}>
|
||||
<button type="button"
|
||||
className="st-tool"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={s.t('journey.studio.autoLayout')}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.autoLayout')}</span>
|
||||
<ChevronDown size={13} style={{ opacity: .5 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu" role="menu">
|
||||
<button type="button"
|
||||
className="st-menu-item"
|
||||
role="menuitem"
|
||||
disabled={!s.canRelayoutSpread}
|
||||
onClick={() => { s.relayoutCurrentSpread(); setOpen(false) }}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t('journey.studio.relayoutSpread')}</span>
|
||||
<span className="st-menu-dim">
|
||||
{s.t(s.canRelayoutSpread ? 'journey.studio.relayoutSpreadHint' : 'journey.studio.relayoutSpreadNone')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="st-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => { s.relayoutBook(); setOpen(false) }}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t('journey.studio.relayoutBook')}</span>
|
||||
<span className="st-menu-dim">{s.t('journey.studio.relayoutBookHint')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The format picker.
|
||||
*
|
||||
* A native <select> was the first draft and it was wrong: the browser draws the
|
||||
* list in its own chrome, so it ignores the theme entirely and drops a list that
|
||||
* runs off the bottom of the screen. This is a small popover instead — themed,
|
||||
* anchored under the bar, and it shows each format's proportions rather than
|
||||
* only naming them.
|
||||
*/
|
||||
function FormatPicker({ s }: { s: Studio }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// Swallow it, or the shell's Escape handler would close Studio itself.
|
||||
if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) }
|
||||
}
|
||||
document.addEventListener('pointerdown', onDown)
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown)
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const active = PAGE_PRESETS[s.preset] ?? PAGE_PRESETS['square-210']
|
||||
|
||||
return (
|
||||
<div className="st-picker" ref={box}>
|
||||
<button type="button"
|
||||
className="st-tool"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={s.t('journey.studio.format')}
|
||||
>
|
||||
<span className="st-tool-label">
|
||||
{s.preset === 'custom'
|
||||
? `${Math.round(s.page.pageWidth)} × ${Math.round(s.page.pageHeight)} mm`
|
||||
: s.t(active.labelKey)}
|
||||
</span>
|
||||
<ChevronDown size={13} style={{ opacity: .5 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu" role="listbox">
|
||||
{PAGE_PRESET_ORDER.map(id => {
|
||||
const p = PAGE_PRESETS[id]
|
||||
const on = id === s.preset
|
||||
return (
|
||||
<button type="button"
|
||||
key={id}
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
className={`st-menu-item ${on ? 'is-active' : ''}`}
|
||||
onClick={() => { s.setPreset(id); setOpen(false) }}
|
||||
>
|
||||
<span className="st-menu-tile">
|
||||
<span
|
||||
className="st-menu-shape"
|
||||
style={
|
||||
p.pageWidthMm >= p.pageHeightMm
|
||||
? { width: 24, height: Math.round(24 * p.pageHeightMm / p.pageWidthMm) }
|
||||
: { height: 24, width: Math.round(24 * p.pageWidthMm / p.pageHeightMm) }
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t(p.labelKey)}</span>
|
||||
<span className="st-menu-dim">{p.pageWidthMm} × {p.pageHeightMm} mm</span>
|
||||
</span>
|
||||
{on && <Check size={14} />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/*
|
||||
The two fields live under the list rather than behind a "custom"
|
||||
entry that opens a second popover: picking the custom row and then
|
||||
typing into the fields it reveals is two steps for one decision, and
|
||||
typing into them *is* picking custom — the hook switches the preset
|
||||
for you.
|
||||
*/}
|
||||
<div className="st-menu-sep" />
|
||||
<div className="st-menu-custom">
|
||||
<TrimField
|
||||
label={s.t('journey.studio.width')}
|
||||
value={s.page.pageWidth}
|
||||
onCommit={v => s.setPageSize('w', v)}
|
||||
/>
|
||||
<span className="st-menu-times">×</span>
|
||||
<TrimField
|
||||
label={s.t('journey.studio.height')}
|
||||
value={s.page.pageHeight}
|
||||
onCommit={v => s.setPageSize('h', v)}
|
||||
/>
|
||||
<span className="st-menu-unit">mm</span>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
What the press takes off, and what it must not print into.
|
||||
|
||||
Under the trim size because they answer the same question — how big
|
||||
is this book, really — and because a vendor states all three in one
|
||||
breath. They were fixed at 3 and 5, which is what most photo-book
|
||||
printers ask for and is useless to the one that asks for 5 and 10.
|
||||
*/}
|
||||
<div className="st-menu-custom">
|
||||
<TrimField
|
||||
label={s.t('journey.studio.bleed')}
|
||||
value={s.page.bleed}
|
||||
min={0}
|
||||
max={20}
|
||||
step={0.5}
|
||||
onCommit={v => s.setPageEdge('bleed', v)}
|
||||
/>
|
||||
<span className="st-menu-times" />
|
||||
<TrimField
|
||||
label={s.t('journey.studio.safeArea')}
|
||||
value={s.page.safe}
|
||||
min={0}
|
||||
max={40}
|
||||
step={0.5}
|
||||
onCommit={v => s.setPageEdge('safe', v)}
|
||||
/>
|
||||
<span className="st-menu-unit">mm</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
|
||||
function Workbench({ s, bookView, setBookView }: { s: Studio; bookView: boolean; setBookView: (on: boolean) => void }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="st-work-wrap">
|
||||
<div className="st-work" ref={s.workRef}>
|
||||
@@ -538,6 +199,16 @@ function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
|
||||
<button type="button" onClick={s.zoomToFit} aria-label={s.t('journey.studio.zoomFit')} title={s.t('journey.studio.zoomFit')}>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
{/* How the page is looked at, so with the zoom rather than in the head band. */}
|
||||
<button type="button"
|
||||
onClick={() => setBookView(!bookView)}
|
||||
aria-pressed={bookView}
|
||||
aria-label={t('journey.studio.bookView')}
|
||||
title={t('journey.studio.bookView')}
|
||||
className={bookView ? 'is-on' : undefined}
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
</button>
|
||||
{/*
|
||||
The page, as a file.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { StudioTabPanel, StudioTabs } from './StudioTabs';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-TABS-001 to FE-STUDIO-TABS-004: the tab rows inside Studio's
|
||||
* FE-STUDIO-TABS-001 to FE-STUDIO-TABS-005: the tab rows inside Studio's
|
||||
* panels, as a screen reader and a keyboard meet them, and as a long word
|
||||
* meets them.
|
||||
*/
|
||||
@@ -69,6 +69,20 @@ describe('StudioTabs', () => {
|
||||
expect(three?.tabIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-TABS-005: in a grid of two lines, up and down move to the tab above or below', () => {
|
||||
render(<Row />);
|
||||
const [one, two, three] = screen.getAllByRole('tab');
|
||||
one?.focus();
|
||||
fireEvent.keyDown(one as HTMLElement, { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(three);
|
||||
fireEvent.keyDown(three as HTMLElement, { key: 'ArrowUp' });
|
||||
expect(document.activeElement).toBe(one);
|
||||
two?.focus();
|
||||
// Nothing below the second tab: the focus stays.
|
||||
fireEvent.keyDown(two as HTMLElement, { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(two);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-TABS-003: keeps a long word in a cell that breaks it, and the count as plain figures', () => {
|
||||
const { container } = render(<Row />);
|
||||
const label = screen.getByText('Klistermärken');
|
||||
|
||||
@@ -15,7 +15,8 @@ import { rovingTarget, runsRtl } from './studioRoving';
|
||||
* label's own colour, quieter than the word, never a pill inside the tab.
|
||||
*
|
||||
* The keyboard is the rail's (studioRoving.ts): the arrows move along the row,
|
||||
* Enter or Space opens the tab.
|
||||
* Enter or Space opens the tab. In a grid of more than one line, up and down
|
||||
* move to the tab above or below.
|
||||
*/
|
||||
|
||||
interface StudioTab<T extends string> {
|
||||
@@ -45,8 +46,15 @@ export function StudioTabs<T extends string>({
|
||||
columns?: number;
|
||||
}) {
|
||||
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const across = Math.max(1, Math.min(columns, tabs.length));
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
const next = rovingTarget(e.key, index, tabs.length, { vertical: false, rtl: runsRtl(e.currentTarget) });
|
||||
const below = e.key === 'ArrowDown' ? index + across : e.key === 'ArrowUp' ? index - across : null;
|
||||
const next =
|
||||
below !== null && across < tabs.length
|
||||
? below >= 0 && below < tabs.length
|
||||
? below
|
||||
: null
|
||||
: rovingTarget(e.key, index, tabs.length, { vertical: false, rtl: runsRtl(e.currentTarget) });
|
||||
if (next === null) return;
|
||||
e.preventDefault();
|
||||
buttons.current[next]?.focus();
|
||||
@@ -56,7 +64,7 @@ export function StudioTabs<T extends string>({
|
||||
className={`st-subtabs ${tabs.some((tab) => tab.icon) ? 'has-icons' : ''}`}
|
||||
role="tablist"
|
||||
aria-label={label}
|
||||
style={{ gridTemplateColumns: `repeat(${Math.max(1, Math.min(columns, tabs.length))}, minmax(0, 1fr))` }}
|
||||
style={{ gridTemplateColumns: `repeat(${across}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{tabs.map((tab, i) => {
|
||||
const on = tab.id === value;
|
||||
|
||||
@@ -189,6 +189,32 @@ describe('the fill of the letters', () => {
|
||||
expect(screen.getByText('journey.studio.fx.focalHint')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('turns the letters into a gradient or a pattern from their own colour, edited as a shape is', () => {
|
||||
const plain = mount(text({ shadow: { dx: 1, dy: 1, color: '#111111' }, color: '#0f766e' }));
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.paint.gradient' }));
|
||||
const gradient = plain.set.mock.lastCall?.[0]?.fillPaint;
|
||||
expect(gradient?.type).toBe('linear');
|
||||
expect(gradient?.stops[0]?.color).toBe('#0f766e');
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.paint.pattern' }));
|
||||
expect(plain.set.mock.lastCall?.[0]?.fillPaint?.type).toBe('pattern');
|
||||
plain.unmount();
|
||||
|
||||
const patterned = {
|
||||
type: 'pattern' as const,
|
||||
pattern: 'dots' as const,
|
||||
fg: '#d62828',
|
||||
bg: '#ffffff',
|
||||
scale: 1,
|
||||
angle: 0,
|
||||
};
|
||||
mount(text({ fillPaint: patterned }));
|
||||
expect(screen.getByRole('radio', { name: 'journey.studio.paint.pattern' }).getAttribute('aria-checked')).toBe(
|
||||
'true'
|
||||
);
|
||||
// Only the pattern's own controls: the kind is picked in the row above, so the paint's tabs stay away.
|
||||
expect(screen.queryByRole('tablist')).toBeNull();
|
||||
});
|
||||
|
||||
it('says so, and offers no photo fill, when the journey has no photos', () => {
|
||||
mount(text({ shadow: { dx: 1, dy: 1, color: '#111111' } }), []);
|
||||
expect(screen.getByText('journey.studio.fx.noPhotos')).toBeTruthy();
|
||||
|
||||
@@ -8,13 +8,16 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { BookElement, BookSpread, BookTextElement } from '@trek/shared';
|
||||
import { bookTextIsDrawn } from '@trek/shared';
|
||||
import { bookTextIsDrawn, bookTextPaintFill } from '@trek/shared';
|
||||
|
||||
import '../../styles/studio-text.css';
|
||||
import { photoSrc } from './bookRender';
|
||||
import { FocalPad } from './inspector/FocalPad';
|
||||
import { textEffectsInUse, textPlacementInUse } from './inspector/inspectorModel';
|
||||
import { paintAs } from './paint/paintEdit';
|
||||
import type { PaintChannel } from './paint/usePaintChannel';
|
||||
import { Choice, Line, NumField, Section, Switch } from './StudioControls';
|
||||
import { StudioPaintPicker } from './StudioPaintPicker';
|
||||
import { Swatches } from './StudioSwatches';
|
||||
import { defaultBand, defaultOutline, defaultShadow, firstFillPhoto, isHollow } from './textEffects';
|
||||
|
||||
@@ -275,10 +278,13 @@ function CurveControls({ el, set, t }: Pick<Props, 'el' | 'set' | 't'>) {
|
||||
);
|
||||
}
|
||||
|
||||
type FillKind = 'colour' | 'hollow' | 'photo';
|
||||
type FillKind = 'colour' | 'hollow' | 'photo' | 'gradient' | 'pattern';
|
||||
|
||||
function fillKindOf(el: BookTextElement): FillKind {
|
||||
if (el.fillPaint?.type === 'photo') return 'photo';
|
||||
const type = el.fillPaint?.type;
|
||||
if (type === 'photo') return 'photo';
|
||||
if (type === 'linear' || type === 'radial') return 'gradient';
|
||||
if (type === 'pattern') return 'pattern';
|
||||
return isHollow(el) ? 'hollow' : 'colour';
|
||||
}
|
||||
|
||||
@@ -294,9 +300,18 @@ function FillControls({ el, set, live, photos, spread, t }: Props) {
|
||||
outline: el.outline ?? { ...defaultOutline(el), color: el.color },
|
||||
} as Patch);
|
||||
}
|
||||
if (next === 'gradient' || next === 'pattern') {
|
||||
// From the text's own colour, as a shape's paint turns from its solid fill.
|
||||
return set({ fillPaint: paintAs(next, { type: 'solid', color: el.color }) } as Patch);
|
||||
}
|
||||
const photoId = firstFillPhoto(spread, photos);
|
||||
if (photoId != null) set({ fillPaint: { type: 'photo', photoId, focalX: 0.5, focalY: 0.5, zoom: 1 } } as Patch);
|
||||
};
|
||||
const letterPaint = bookTextPaintFill(el);
|
||||
const channel: PaintChannel = {
|
||||
live: (paint) => live({ fillPaint: paint } as Patch),
|
||||
commit: (paint) => set({ fillPaint: paint } as Patch),
|
||||
};
|
||||
return (
|
||||
<div className="st-fx-group">
|
||||
<Line label={t('journey.studio.fx.letters')}>
|
||||
@@ -307,11 +322,22 @@ function FillControls({ el, set, live, photos, spread, t }: Props) {
|
||||
{ value: 'colour' as const, label: t('journey.studio.colour') },
|
||||
{ value: 'hollow' as const, label: t('journey.studio.fx.hollow') },
|
||||
{ value: 'photo' as const, label: t('journey.studio.kind.photo'), disabled: !photos.length },
|
||||
{ value: 'gradient' as const, label: t('journey.studio.paint.gradient') },
|
||||
{ value: 'pattern' as const, label: t('journey.studio.paint.pattern') },
|
||||
]}
|
||||
onPick={pick}
|
||||
/>
|
||||
</Line>
|
||||
{!photos.length && <p className="st-hint st-fx-hint">{t('journey.studio.fx.noPhotos')}</p>}
|
||||
{letterPaint && (kind === 'gradient' || kind === 'pattern') && (
|
||||
<StudioPaintPicker
|
||||
value={letterPaint}
|
||||
channel={channel}
|
||||
t={t}
|
||||
noneLabel={t('journey.studio.colour')}
|
||||
kinds={[kind]}
|
||||
/>
|
||||
)}
|
||||
{photo && (
|
||||
<div className="st-fx-body">
|
||||
<div className="st-fx-photos" role="radiogroup" aria-label={t('journey.studio.fx.pickPhoto')}>
|
||||
|
||||
@@ -99,7 +99,11 @@ export function StudioTextStyles({
|
||||
const place = (lockup: BookLockup) => {
|
||||
const els = placeLockup(lockup, { page, spread, words, gallery: source.photos, newId: bookElementId });
|
||||
// The page's own cap, as a duplicate keeps it: a set that would make the book unsaveable is not placed.
|
||||
if (!els.length || !spread || spread.elements.length + els.length > MAX_SPREAD_ELEMENTS) return;
|
||||
if (!els.length || !spread) return;
|
||||
if (spread.elements.length + els.length > MAX_SPREAD_ELEMENTS) {
|
||||
useStudioStore.getState().refuseFull();
|
||||
return;
|
||||
}
|
||||
commit((d) => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => (i === active ? addBookLockup(sp, els, bookElementId('g')) : sp)),
|
||||
|
||||
@@ -3,7 +3,9 @@ import type {
|
||||
BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement,
|
||||
BookMetric, BookStatsElement, JourneyStats,
|
||||
} from '@trek/shared'
|
||||
import { BOOK_METRICS, MAX_PATH_POINTS, MAX_PATH_SEGMENTS, isStale, refreshPatch } from '@trek/shared'
|
||||
import {
|
||||
BOOK_BADGE_STYLES, BOOK_COUNTRY_LOOKS, BOOK_METRICS, BOOK_STATS_LOOKS, MAX_PATH_POINTS, MAX_PATH_SEGMENTS, isStale, refreshPatch,
|
||||
} from '@trek/shared'
|
||||
import { Swatches } from './StudioSwatches'
|
||||
import { StudioFontPicker } from './StudioFontPicker'
|
||||
import { WeightLine } from './StudioTypeControls'
|
||||
@@ -547,6 +549,8 @@ function MapLook({ el, set, t }: Props<BookMapElement>) {
|
||||
options={[
|
||||
{ value: 'dot' as const, label: t('journey.studio.pinDot') },
|
||||
{ value: 'photo' as const, label: t('journey.studio.pinPhoto') },
|
||||
{ value: 'numbered' as const, label: t('journey.studio.pinNumbered') },
|
||||
{ value: 'ends' as const, label: t('journey.studio.pinEnds') },
|
||||
]}
|
||||
onPick={pinStyle => set({ pinStyle } as Partial<BookElement>)}
|
||||
/>
|
||||
@@ -597,16 +601,26 @@ function StatsProps({ el, set, t }: Props<BookStatsElement>) {
|
||||
/>
|
||||
</Section>
|
||||
<Section label={t('journey.studio.layout')}>
|
||||
<Line label={t('journey.studio.layout')}>
|
||||
{/* The look first: the arrangement below belongs to the classic one alone. */}
|
||||
<Line label={t('journey.studio.look')} wide>
|
||||
<Choice
|
||||
value={el.layout}
|
||||
options={(['grid', 'row', 'column'] as const).map(layout => ({
|
||||
value: layout,
|
||||
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
|
||||
}))}
|
||||
onPick={layout => set({ layout } as Partial<BookElement>)}
|
||||
value={el.look ?? 'classic'}
|
||||
options={BOOK_STATS_LOOKS.map(look => ({ value: look, label: t(`journey.studio.statsLook.${look}`) }))}
|
||||
onPick={look => set({ look } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
{(el.look ?? 'classic') === 'classic' && (
|
||||
<Line label={t('journey.studio.layout')}>
|
||||
<Choice
|
||||
value={el.layout}
|
||||
options={(['grid', 'row', 'column'] as const).map(layout => ({
|
||||
value: layout,
|
||||
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
|
||||
}))}
|
||||
onPick={layout => set({ layout } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
)}
|
||||
<Line label={t('journey.studio.units')}>
|
||||
<Choice
|
||||
value={el.units}
|
||||
@@ -634,16 +648,25 @@ function StatsProps({ el, set, t }: Props<BookStatsElement>) {
|
||||
function CountriesProps({ el, set, t }: Props<BookCountriesElement>) {
|
||||
return (
|
||||
<Section label={t('journey.studio.countries')}>
|
||||
<Line label={t('journey.studio.layout')}>
|
||||
<Line label={t('journey.studio.look')} wide>
|
||||
<Choice
|
||||
value={el.layout}
|
||||
options={(['list', 'grid', 'column'] as const).map(layout => ({
|
||||
value: layout,
|
||||
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
|
||||
}))}
|
||||
onPick={layout => set({ layout } as Partial<BookElement>)}
|
||||
value={el.look ?? 'classic'}
|
||||
options={BOOK_COUNTRY_LOOKS.map(look => ({ value: look, label: t(`journey.studio.countryLook.${look}`) }))}
|
||||
onPick={look => set({ look } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
{(el.look ?? 'classic') === 'classic' && el.codes.length > 1 && (
|
||||
<Line label={t('journey.studio.layout')}>
|
||||
<Choice
|
||||
value={el.layout}
|
||||
options={(['list', 'grid', 'column'] as const).map(layout => ({
|
||||
value: layout,
|
||||
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
|
||||
}))}
|
||||
onPick={layout => set({ layout } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
)}
|
||||
{/* These three read as raw English out of the code before: `left`,
|
||||
`center`, `right`, straight from the value. */}
|
||||
<Line label={t('journey.studio.align')}>
|
||||
@@ -734,7 +757,7 @@ function BadgeProps({ el, set, t }: Props<BookBadgeElement>) {
|
||||
<Line label={t('journey.studio.style')} wide>
|
||||
<Choice
|
||||
value={el.style}
|
||||
options={(['plain', 'chip', 'outline', 'stacked'] as const).map(style => ({
|
||||
options={BOOK_BADGE_STYLES.map(style => ({
|
||||
value: style, label: t(`journey.studio.markStyle.${style}`),
|
||||
}))}
|
||||
onPick={style => set({ style } as Partial<BookElement>)}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, RouteOff } from 'lucide-react'
|
||||
import type {
|
||||
BookBadgeVariant, BookElement, BookMapScope, BookMetric, BookPageSetup, JourneyStats,
|
||||
} from '@trek/shared'
|
||||
import {
|
||||
BOOK_METRICS, bookBadgeValue, bookCountryMark, bookCountryName, bookElementId as uid, bookTravelBadge,
|
||||
bookTravelCountries, bookTravelMap, bookTravelSize, bookTravelStats,
|
||||
} from '@trek/shared'
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
|
||||
import { Globe, Hash, Map as MapIcon, Stamp } from 'lucide-react'
|
||||
import type { BookElement, BookPageSetup, JourneyStats } from '@trek/shared'
|
||||
import { bookElementId as uid } from '@trek/shared'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { PanelHead, Shelf } from './StudioPanelHead'
|
||||
import { studioTravelContext } from './studioTravelContext'
|
||||
import { Section } from './StudioControls'
|
||||
import { TravelPreview } from './TravelPreview'
|
||||
import { StudioTabPanel, StudioTabs } from './StudioTabs'
|
||||
import { fetchRoads } from './roadRoute'
|
||||
import { TravelCard } from './travel/TravelCard'
|
||||
import { TravelStops } from './travel/TravelStops'
|
||||
import {
|
||||
COUNTRY_LOOKS, FIGURE_SHELVES, MAP_CARDS, MARK_STYLES, MARK_VARIANTS, SUMMARY_LOOKS, travelBuilders,
|
||||
} from './travel/travelBuilders'
|
||||
|
||||
/**
|
||||
* The journey's own figures, as things you can put on a page.
|
||||
@@ -22,90 +20,39 @@ import { fetchRoads } from './roadRoute'
|
||||
* distance, the countries and the dates are already in TREK; the work is
|
||||
* offering them as objects rather than as numbers someone has to retype.
|
||||
*
|
||||
* ── Why every tile is a real preview ──────────────────────────────────────
|
||||
* Four tabs, the way the Elements panel sorts what it adds: the figures, the
|
||||
* maps with the stops they are drawn from, the countries, and the marks. Every
|
||||
* card is the element its click places, built by the book engine and drawn by
|
||||
* the page's own renderer with this journey's numbers in it, in the newer
|
||||
* looks and in the book's theme (travel/travelBuilders.ts). The looks books
|
||||
* had before these still draw on the pages that hold them; they are no longer
|
||||
* offered here.
|
||||
*
|
||||
* These elements differ from one another by what they *say*, not by their
|
||||
* shape. Four map styles are four maps; three summary layouts are the same six
|
||||
* numbers arranged three ways. A named row with the value beside it — which is
|
||||
* what this panel was first — tells you none of that: you cannot tell the dark
|
||||
* map from the paper one, or a row of figures from a grid of them, until you
|
||||
* have placed one and undone it.
|
||||
*
|
||||
* So each tile builds the element it would place and renders it through the
|
||||
* page's own renderer, with this journey's real numbers in it. You are picking
|
||||
* from the things themselves.
|
||||
*
|
||||
* Every button resolves its values *now* and writes them into the element, so
|
||||
* Every card resolves its values now and writes them into the element, so
|
||||
* what lands on the page is finished rather than a placeholder that needs the
|
||||
* server to mean anything. The reasoning is in TravelElements.tsx, and the
|
||||
* short version is that the print renderer must never depend on a fetch.
|
||||
* server to mean anything: the print renderer must never depend on a fetch.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One row of the stops list: a point of the route or a stop switched off.
|
||||
*
|
||||
* `entryId` is null for a stop the server took straight from a trip's places,
|
||||
* which is what a journey has before anyone wrote an entry. Such a stop has no
|
||||
* switch, because there is no entry to put it on.
|
||||
*/
|
||||
type StopRow = {
|
||||
entryId: number | null
|
||||
label: string
|
||||
date: string | null
|
||||
country: string | null
|
||||
excluded: boolean
|
||||
}
|
||||
type Tab = 'figures' | 'maps' | 'countries' | 'marks'
|
||||
|
||||
/*
|
||||
* How many cards share a row.
|
||||
*
|
||||
* A stats panel is the width of a spread; shown two-up its figures scale down
|
||||
* past reading, so it gets the row to itself. Maps, country lists and marks are
|
||||
* compact enough that two-up still tells them apart, and two-up is what makes
|
||||
* the section scannable. The tile measures its own width — see TravelPreview.
|
||||
*/
|
||||
|
||||
/** The words a mark carries, as the book engine resolves them. */
|
||||
type BadgeValue = NonNullable<ReturnType<typeof bookBadgeValue>>
|
||||
const TABS: { id: Tab; icon: typeof Hash; labelKey: string }[] = [
|
||||
{ id: 'figures', icon: Hash, labelKey: 'journey.studio.travelFigures' },
|
||||
{ id: 'maps', icon: MapIcon, labelKey: 'journey.studio.travelMaps' },
|
||||
{ id: 'countries', icon: Globe, labelKey: 'journey.studio.countries' },
|
||||
{ id: 'marks', icon: Stamp, labelKey: 'journey.studio.marks' },
|
||||
]
|
||||
|
||||
/*
|
||||
* The two imagery maps, satellite first: the recommendation should not be the
|
||||
* second thing read. Their tiles and credit are the book engine's own
|
||||
* (BOOK_MAP_SOURCES), so a satellite map placed here is the one the assistant
|
||||
* places.
|
||||
* second thing read. Relief stops at about six hundred metres to the pixel, a
|
||||
* smudge at country size; satellite is the one that prints for the page most
|
||||
* people make, and the ring says so.
|
||||
*/
|
||||
const IMAGERY = [
|
||||
{ id: 'satellite', labelKey: 'journey.studio.mapSourceSatellite' },
|
||||
{ id: 'relief', labelKey: 'journey.studio.mapSourceRelief' },
|
||||
] as const
|
||||
|
||||
function Card({ label, onClick, children, recommended = false, title }: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
/**
|
||||
* Marked as the one to reach for, with a ring rather than with words.
|
||||
*
|
||||
* The label sits in a grid cell about eighty pixels wide, where "Satellite,
|
||||
* recommended" would take both of its lines. A ring says the same thing in
|
||||
* no space at all, and the tooltip carries the word for anyone who wonders
|
||||
* why this card looks different.
|
||||
*/
|
||||
recommended?: boolean
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button type="button"
|
||||
className={`st-travel-card ${recommended ? 'is-recommended' : ''}`}
|
||||
onClick={onClick}
|
||||
title={title ? `${label}: ${title}` : label}
|
||||
>
|
||||
{children}
|
||||
<span className="st-travel-card-label">{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function StudioTravelPanel({
|
||||
page, stats, path, t, locale, canEdit, onToggleStop,
|
||||
}: {
|
||||
@@ -123,16 +70,16 @@ export function StudioTravelPanel({
|
||||
const addElement = useStudioStore(s => s.addElement)
|
||||
const updateElement = useStudioStore(s => s.updateElement)
|
||||
const active = useStudioStore(s => s.activeSpread)
|
||||
const doc = useStudioStore(s => s.doc)
|
||||
const spread = doc?.spreads[active]
|
||||
const single = !!spread && spread.role !== 'inner'
|
||||
const role = useStudioStore(s => s.doc?.spreads[active]?.role)
|
||||
const theme = useStudioStore(s => s.doc?.theme)
|
||||
const [tab, setTab] = useState<Tab>('figures')
|
||||
const tabsId = useId()
|
||||
|
||||
/*
|
||||
* The road lookups still running when the panel goes away.
|
||||
*
|
||||
* fetchRoads walks the legs at a deliberate pace, so a twenty-stop map keeps
|
||||
* asking a public router for minutes after Studio is closed, and the answer
|
||||
* lands in whatever document the store holds by then.
|
||||
* The road lookups still running when the panel goes away. fetchRoads walks
|
||||
* the legs at a deliberate pace, so a twenty-stop map keeps asking a public
|
||||
* router for minutes, and the answer would land in whatever document the
|
||||
* store holds by then.
|
||||
*/
|
||||
const roadJobs = useRef(new Set<AbortController>())
|
||||
useEffect(() => () => {
|
||||
@@ -140,22 +87,6 @@ export function StudioTravelPanel({
|
||||
roadJobs.current.clear()
|
||||
}, [])
|
||||
|
||||
/*
|
||||
* The stops whose switch is on its way to the server.
|
||||
*
|
||||
* No optimistic flip: which stop is furthest and which countries remain is
|
||||
* the server's sum, so the row reads its state from the stats the hook
|
||||
* fetches once the change has landed. Until then the chip is only held,
|
||||
* so a second click cannot send the opposite of what the first one is
|
||||
* still delivering.
|
||||
*/
|
||||
const [pending, setPending] = useState<Set<number>>(() => new Set())
|
||||
|
||||
const centre = (w: number, h: number) => {
|
||||
const W = single ? page.pageWidth : page.pageWidth * 2
|
||||
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<>
|
||||
@@ -167,146 +98,17 @@ export function StudioTravelPanel({
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* What the book engine's factories read (studioTravelContext.ts). Every tile
|
||||
* below is built by the factory the engine's `add` calls. Where it lands is
|
||||
* the panel's own: centred across the spread, where `add` takes the right page.
|
||||
*/
|
||||
const travel = studioTravelContext(stats, path, locale, t)
|
||||
const at = (size: { w: number; h: number }) => centre(size.w, size.h)
|
||||
const build = travelBuilders({ page, stats, path, locale, t, theme, single: !!role && role !== 'inner' })
|
||||
|
||||
/**
|
||||
* Country names in the reader's language, not the server's.
|
||||
*
|
||||
* The API answers in English, because it has no idea who will read the book.
|
||||
* `Intl.DisplayNames` is the same CLDR data Atlas already uses for regions, so
|
||||
* a German book says "Island" and an English one says "Iceland" — from one set
|
||||
* of codes, with no translation table to keep in step.
|
||||
*/
|
||||
const countryName = (code: string): string => bookCountryName(code, locale, stats)
|
||||
|
||||
/*
|
||||
* Each builder returns the finished element. The tile renders it and the
|
||||
* click places it, so the preview and the result are the same object, and
|
||||
* the object is the engine's, so there is no second description of what a
|
||||
* card means.
|
||||
*/
|
||||
/** The linked trips that actually put stops on the route. */
|
||||
const tripsWithRoute = (stats.trips ?? []).filter(t => t.points > 0)
|
||||
|
||||
/*
|
||||
* The route as rows, counting and left-out stops together.
|
||||
*
|
||||
* Every point is listed, so the list reads as the route. The left-out
|
||||
* stops sit among the counting ones in date order rather than in a list of
|
||||
* their own, because the question the section answers is "which of these
|
||||
* count?", and that is only answerable with all of them in one sequence.
|
||||
* Undated stops go last, then by name.
|
||||
*/
|
||||
const stops: StopRow[] = [
|
||||
...stats.points.map(p => ({
|
||||
entryId: p.entryId, label: p.label, date: p.date, country: p.country, excluded: false,
|
||||
})),
|
||||
...(stats.excluded ?? []).map(x => ({
|
||||
entryId: x.entryId, label: x.label, date: x.date, country: null, excluded: true,
|
||||
})),
|
||||
].sort((a, b) => {
|
||||
if (a.date !== b.date) {
|
||||
if (a.date == null) return 1
|
||||
if (b.date == null) return -1
|
||||
return a.date < b.date ? -1 : 1
|
||||
}
|
||||
return a.label.localeCompare(b.label)
|
||||
})
|
||||
const excludedCount = stops.filter(s => s.excluded).length
|
||||
/** Whether the country is worth a badge, or the same word on every row. */
|
||||
const manyCountries = stats.countries.length > 1
|
||||
|
||||
/**
|
||||
* The day of a stop, without the weekday.
|
||||
*
|
||||
* `formatDate` is the journal's format and carries "Tue," because a journal
|
||||
* entry is about a day somebody lived. A row here is about a leg of a route,
|
||||
* and it has 236px to hold a place name in as well: the weekday costs a
|
||||
* third of the badge and answers nothing anybody asks of this list.
|
||||
*/
|
||||
const stopDay = (iso: string) =>
|
||||
new Date(`${iso}T00:00:00Z`).toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
|
||||
const toggleStop = async (entryId: number, excluded: boolean) => {
|
||||
setPending(prev => new Set(prev).add(entryId))
|
||||
// A refusal is the hook's to report; here it only frees the chip again.
|
||||
await onToggleStop(entryId, excluded)
|
||||
setPending(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(entryId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* One country, as the entry a list would have made of it.
|
||||
*
|
||||
* The list and the grid are gone: they were compositions somebody else made,
|
||||
* deciding the order, the spacing and the type size, and the only way to
|
||||
* change any of that was to not use them. What is left is the same entry —
|
||||
* name over silhouette, set the same way — as an element you place one at a
|
||||
* time. Three of them down a page is the list, with the spacing yours; one of
|
||||
* them at 80mm is a chapter opener, which the list could never be.
|
||||
*
|
||||
* The name is editable afterwards, so numbering them "1. Germany",
|
||||
* "2. Netherlands" is typing rather than a feature.
|
||||
*/
|
||||
const countryEl = (code: string): BookElement =>
|
||||
bookTravelCountries(travel, { id: uid('co'), frame: at(bookTravelSize('countries', page)), codes: [code] })
|
||||
|
||||
/**
|
||||
* A map as the engine's `add` places it, in the style the card offers.
|
||||
*
|
||||
* The scope is the journey or one trip. A trip map carries no recorded way:
|
||||
* the trail is the journey's, and a map fitted to it would frame the whole
|
||||
* journey around one trip and lose the bow its legs are drawn with.
|
||||
*/
|
||||
const mapEl = (
|
||||
style: 'minimal' | 'outline' | 'dark' | 'paper',
|
||||
scope: BookMapScope = 'journey',
|
||||
source: 'vector' | 'relief' | 'satellite' = 'vector',
|
||||
): BookElement => ({
|
||||
...bookTravelMap(travel, stats, { id: uid('mp'), frame: at(bookTravelSize('map', page)), scope, source }),
|
||||
style,
|
||||
})
|
||||
|
||||
/** A mark at the size that suits what it holds, which is the engine's size for its variant. */
|
||||
const badgeEl = (variant: BookBadgeVariant, value: BadgeValue): BookElement =>
|
||||
bookTravelBadge({ id: uid('bd'), frame: at(bookTravelSize('badge', page, { variant })), variant, value })
|
||||
|
||||
/**
|
||||
* One figure on its own.
|
||||
*
|
||||
* A summary panel is a composition; a single figure is a mark you drop next
|
||||
* to a photograph. Both are the same element with a different metric list,
|
||||
* and offering only the composition meant anyone wanting "14 DAYS" beside a
|
||||
* picture had to place all four and delete three.
|
||||
*/
|
||||
const singleEl = (metric: BookMetric): BookElement =>
|
||||
bookTravelStats(stats, { id: uid('st'), frame: at(bookTravelSize('stats', page)), metrics: [metric] })
|
||||
|
||||
/** Place a copy — the previewed element keeps its own id for React. */
|
||||
/** Place a copy; the previewed element keeps its own id for React. */
|
||||
const place = (el: BookElement) => {
|
||||
const placed = { ...el, id: uid(el.kind[0]) } as BookElement
|
||||
const placed = { ...el, id: uid(el.kind[0] ?? 'e') } as BookElement
|
||||
addElement(active, placed)
|
||||
|
||||
/*
|
||||
* A map arrives following the roads.
|
||||
*
|
||||
* Asked for after the element is on the page rather than before, so the map
|
||||
* appears at once and the line firms up a moment later: a panel that sat
|
||||
* there for fifteen seconds after a click would read as broken, and the
|
||||
* straight line it starts with is the one it would have had anyway.
|
||||
*
|
||||
* Legs long enough to have been flights are never sent (see roadRoute.ts),
|
||||
* the router's own cache answers a second placement of the same journey for
|
||||
* free, and a failure leaves the leg exactly as it was.
|
||||
* A map arrives following the roads, asked for after it is on the page so
|
||||
* it appears at once and the line firms up a moment later. Legs long enough
|
||||
* to have been flights are never sent (roadRoute.ts), and a failure leaves
|
||||
* the leg as it was.
|
||||
*/
|
||||
if (placed.kind === 'map' && placed.points.length > 1) {
|
||||
const job = new AbortController()
|
||||
@@ -314,266 +116,228 @@ export function StudioTravelPanel({
|
||||
void fetchRoads(placed.points.map(pt => ({ lat: pt.lat, lng: pt.lng })), { signal: job.signal })
|
||||
.then(roads => {
|
||||
if (job.signal.aborted) return
|
||||
if (roads.some(r => r && r.length > 1)) {
|
||||
updateElement(active, placed.id, { roads } as Partial<BookElement>)
|
||||
}
|
||||
if (roads.some(r => r && r.length > 1)) updateElement(active, placed.id, { roads } as Partial<BookElement>)
|
||||
})
|
||||
.catch(() => { /* No roads is a state the map already draws. */ })
|
||||
.finally(() => { roadJobs.current.delete(job) })
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The marks of the journey, with the words the engine resolves for each. A
|
||||
* mark the journey cannot fill (no start date, no stop, no country) is not
|
||||
* offered.
|
||||
*/
|
||||
const marks: { el: BookElement; label: string }[] = ([
|
||||
['date', t('journey.studio.dateMark')],
|
||||
['day', t('journey.studio.dayMark')],
|
||||
['coords', t('journey.studio.coordsMark')],
|
||||
['flag', t('journey.studio.flagMark')],
|
||||
['distance', t('journey.studio.distanceMark')],
|
||||
] as const).flatMap(([variant, label]) => {
|
||||
const value = bookBadgeValue(travel, variant, null)
|
||||
return value ? [{ el: badgeEl(variant, value), label }] : []
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelHead label={t('journey.studio.travel')} />
|
||||
<div className="st-panel-scroll">
|
||||
<Shelf label={t('journey.studio.singleFigures')}>
|
||||
<div className="st-travel-grid">
|
||||
{BOOK_METRICS.map(metric => {
|
||||
const el = singleEl(metric)
|
||||
return (
|
||||
<Card key={metric} label={t(`journey.studio.metric.${metric}`)} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={44} maxHeight={62} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Shelf>
|
||||
|
||||
{/*
|
||||
The switch is here, beside the maps and the figures it changes, and
|
||||
not only in the entry editor: the moment somebody notices the home
|
||||
airport on the printed route is the moment they are looking at this
|
||||
panel. The rows carry no preview because they are not things to
|
||||
place; they are the input every tile below reads.
|
||||
*/}
|
||||
{stops.length > 0 && (
|
||||
<Section
|
||||
label={t('journey.studio.stops')}
|
||||
hint={t('journey.studio.stopsHint')}
|
||||
/*
|
||||
* Folded to begin with, and saying how many count while it is.
|
||||
*
|
||||
* A fortnight is fourteen rows, which is longer than everything
|
||||
* else in this panel put together, and most books never need to
|
||||
* touch it. The figure in the head is what anyone opening it came
|
||||
* to check anyway, so the closed section still answers the
|
||||
* question and the maps below stay in reach.
|
||||
*/
|
||||
defaultOpen={false}
|
||||
badge={`${stops.length - excludedCount}/${stops.length}`}
|
||||
>
|
||||
<ul className="st-route-stops">
|
||||
{stops.map((stop, i) => {
|
||||
const entryId = stop.entryId
|
||||
const on = !stop.excluded
|
||||
const state = t(on ? 'journey.studio.stopOn' : 'journey.studio.stopOff')
|
||||
const chip = `st-chip is-pick is-icon ${on ? 'is-on' : ''}`
|
||||
/*
|
||||
* A mark rather than the word.
|
||||
*
|
||||
* "Counts" and "Left out" are different lengths, so a column of
|
||||
* them jitters, and together they cost a third of a row that
|
||||
* has a place name to fit. The tick and the crossed-out route
|
||||
* say the same thing at a glance, and the word is still there
|
||||
* for anyone who hovers or reads with a screen reader.
|
||||
*/
|
||||
const mark = on ? <Check size={13} strokeWidth={2.4} /> : <RouteOff size={13} strokeWidth={2.2} />
|
||||
return (
|
||||
<li key={entryId ?? `place-${i}`} className={`st-route-stop ${on ? '' : 'is-off'}`}>
|
||||
{/*
|
||||
One line: the day, then the name, then the switch. The
|
||||
name and the day were stacked, which doubled the height
|
||||
of a list whose whole job is being read down.
|
||||
*/}
|
||||
{stop.date && <span className="st-badge">{stopDay(stop.date)}</span>}
|
||||
<span className="st-route-stop-label">{stop.label || t('journey.studio.untitled')}</span>
|
||||
{/*
|
||||
The country only where there is more than one to tell
|
||||
apart. On a journey inside one country it is the same
|
||||
word fourteen times, in the row that has least room.
|
||||
*/}
|
||||
{manyCountries && stop.country && (
|
||||
<span className="st-badge is-quiet">{countryName(stop.country)}</span>
|
||||
)}
|
||||
{canEdit && entryId != null ? (
|
||||
<button type="button"
|
||||
className={chip}
|
||||
title={`${state}: ${t('journey.studio.stopToggle')}`}
|
||||
aria-label={t('journey.studio.stopToggle')}
|
||||
aria-pressed={on}
|
||||
disabled={pending.has(entryId)}
|
||||
onClick={() => void toggleStop(entryId, !stop.excluded)}
|
||||
>
|
||||
{mark}
|
||||
</button>
|
||||
) : (
|
||||
<span className={chip} title={state} aria-label={state}>{mark}</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Shelf label={t('journey.studio.routeMap')}>
|
||||
{stats.points.length ? (
|
||||
<>
|
||||
<div className="st-travel-grid">
|
||||
{(['minimal', 'outline', 'paper', 'dark'] as const).map(style => {
|
||||
const el = mapEl(style)
|
||||
return (
|
||||
<Card key={style} label={t(`journey.studio.mapStyle.${style}`)} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={62} maxHeight={80} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
{/*
|
||||
The two imagery maps, as cards rather than as settings.
|
||||
|
||||
They were reachable only by placing an outline map and then
|
||||
changing its source in the inspector, which is a thing nobody
|
||||
finds. They are different pictures, not variants of one: relief
|
||||
is land shaded in its own colours against a dark sea, drawn at
|
||||
a scale that suits a continent, and satellite is the ground
|
||||
itself, which is what a page about one country wants.
|
||||
*/}
|
||||
{IMAGERY.map(src => (
|
||||
<Card
|
||||
key={src.id}
|
||||
/*
|
||||
* Satellite is marked rather than merely listed. The three
|
||||
* imagery options are not equals: relief stops at about six
|
||||
* hundred metres to the pixel, which is a continent's worth
|
||||
* of detail and a smudge at country size, and the outlines
|
||||
* are a diagram. For the page most people are making —
|
||||
* a route across one or two countries — this is the one
|
||||
* that prints, and saying so beats letting them find out
|
||||
* after the book is bound.
|
||||
*/
|
||||
label={t(src.labelKey)}
|
||||
recommended={src.id === 'satellite'}
|
||||
title={src.id === 'satellite' ? t('journey.studio.recommended') : undefined}
|
||||
onClick={() => place(mapEl('minimal', 'journey', src.id))}
|
||||
>
|
||||
<TravelPreview
|
||||
el={mapEl('minimal', 'journey', src.id)}
|
||||
minHeight={62}
|
||||
maxHeight={80}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
A map per trip, for a journey made of more than one.
|
||||
|
||||
Two trips printed as a single route draw a line from the last
|
||||
stop of the first to the first stop of the second, which is
|
||||
usually the longest leg on the page and one nobody travelled.
|
||||
Offered rather than imposed: some journeys really are one route
|
||||
across several trips, and those still get the map above.
|
||||
|
||||
Only trips that have stops on the route are listed — a linked
|
||||
trip nobody wrote about would place an empty frame.
|
||||
*/}
|
||||
{tripsWithRoute.length > 1 && (
|
||||
<div className="st-shelf-sub">
|
||||
<h4 className="st-section-label">{t('journey.studio.mapPerTrip')}</h4>
|
||||
<div className="st-travel-grid">
|
||||
{tripsWithRoute.map(trip => {
|
||||
const el = mapEl('minimal', { tripId: trip.id })
|
||||
return (
|
||||
<Card
|
||||
key={trip.id}
|
||||
label={trip.title || t('journey.studio.untitled')}
|
||||
onClick={() => place(el)}
|
||||
>
|
||||
<TravelPreview el={el} minHeight={62} maxHeight={80} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="st-hint">{t('journey.studio.noRoute')}</p>
|
||||
<StudioTabs<Tab>
|
||||
idBase={tabsId}
|
||||
label={t('journey.studio.travel')}
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
columns={2}
|
||||
tabs={TABS.map(x => ({ id: x.id, icon: x.icon, label: t(x.labelKey) }))}
|
||||
/>
|
||||
<StudioTabPanel idBase={tabsId} value={tab} className="st-tab-body">
|
||||
<div className="st-panel-scroll">
|
||||
{tab === 'figures' && <FiguresTab build={build} t={t} place={place} />}
|
||||
{tab === 'maps' && (
|
||||
<MapsTab build={build} stats={stats} t={t} place={place}>
|
||||
<TravelStops
|
||||
stats={stats} t={t} locale={locale} canEdit={canEdit}
|
||||
onToggleStop={onToggleStop} countryName={build.countryName}
|
||||
/>
|
||||
</MapsTab>
|
||||
)}
|
||||
</Shelf>
|
||||
|
||||
<Shelf label={t('journey.studio.countries')}>
|
||||
{stats.countries.length ? (
|
||||
<>
|
||||
{/*
|
||||
One country per element, rather than a block listing them all.
|
||||
|
||||
A list of countries is a composition somebody else made: it
|
||||
decides the order, the spacing and the type size, and the only
|
||||
way to change any of it is to not use it. Placed one at a time
|
||||
they are ordinary elements — numbered, moved, set at different
|
||||
sizes, put on different pages — which is what a book of a trip
|
||||
through several countries actually wants.
|
||||
*/}
|
||||
<div className="st-travel-grid">
|
||||
{stats.countries.slice(0, 12).map(c => {
|
||||
const el = countryEl(c.code)
|
||||
return (
|
||||
<Card key={c.code} label={countryName(c.code)} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={44} maxHeight={64} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
And the same country as a mark: the outline beside the name
|
||||
rather than under it, at the size of a line of type. It belongs
|
||||
next to a date or a coordinate, not on a page of its own.
|
||||
*/}
|
||||
<div className="st-travel-grid" style={{ marginTop: 6 }}>
|
||||
{stats.countries.slice(0, 12).map(c => {
|
||||
const el = badgeEl('country', bookCountryMark('country', c.code, countryName(c.code)))
|
||||
return (
|
||||
<Card key={`mark-${c.code}`} label={countryName(c.code)} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={30} maxHeight={44} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="st-hint">{t('journey.studio.noCountries')}</p>
|
||||
)}
|
||||
</Shelf>
|
||||
|
||||
<Shelf label={t('journey.studio.marks')}>
|
||||
<div className="st-travel-grid">
|
||||
{marks.map(({ el, label }) => (
|
||||
<Card key={el.id} label={label} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={34} maxHeight={52} />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
</div>
|
||||
{tab === 'countries' && <CountriesTab build={build} stats={stats} t={t} place={place} />}
|
||||
{tab === 'marks' && <MarksTab build={build} t={t} place={place} />}
|
||||
</div>
|
||||
</StudioTabPanel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type Build = ReturnType<typeof travelBuilders>
|
||||
|
||||
interface TabProps {
|
||||
build: Build
|
||||
t: (k: string) => string
|
||||
place: (el: BookElement) => void
|
||||
}
|
||||
|
||||
/** The summary in its four looks, full width, and every figure on its own as a medal. */
|
||||
function FiguresTab({ build, t, place }: TabProps) {
|
||||
return (
|
||||
<>
|
||||
<Shelf label={t('journey.studio.summary')}>
|
||||
<div className="st-travel-grid is-wide">
|
||||
{SUMMARY_LOOKS.map(look => (
|
||||
<TravelCard
|
||||
key={look}
|
||||
el={build.summaryEl(look)}
|
||||
label={t(`journey.studio.statsLook.${look}`)}
|
||||
onPlace={place}
|
||||
minHeight={look === 'band' ? 30 : 70}
|
||||
maxHeight={look === 'band' ? 40 : 120}
|
||||
inset={look === 'note' || look === 'sketch' ? 0.05 : 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
{/* Each figure on its own, in the looks that suit a single figure: a medal, an odometer, a ring drawn by hand. */}
|
||||
{FIGURE_SHELVES.map(({ look, metrics }) => (
|
||||
<Shelf key={look} label={`${t('journey.studio.singleFigures')}: ${t(`journey.studio.statsLook.${look}`)}`}>
|
||||
<div className={`st-travel-grid ${look === 'odometer' ? 'is-wide' : ''}`}>
|
||||
{metrics.map(metric => (
|
||||
<TravelCard
|
||||
key={metric}
|
||||
el={build.figureEl(metric, look)}
|
||||
label={t(`journey.studio.metric.${metric}`)}
|
||||
onPlace={place}
|
||||
minHeight={look === 'odometer' ? 50 : 80}
|
||||
maxHeight={look === 'odometer' ? 64 : 104}
|
||||
inset={look === 'odometer' ? 0 : 0.06}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The route: drawn with its photographs, its stops numbered, its two ends
|
||||
* marked or on a dark ground, then over satellite and relief imagery; a map
|
||||
* per trip for a journey of several; and the stops it is drawn from.
|
||||
*/
|
||||
function MapsTab({ build, stats, t, place, children }: TabProps & { stats: JourneyStats; children: ReactNode }) {
|
||||
if (!stats.points.length) return <p className="st-hint">{t('journey.studio.noRoute')}</p>
|
||||
// Only the trips that put stops on the route: a linked trip nobody wrote about would place an empty frame.
|
||||
const trips = (stats.trips ?? []).filter(trip => trip.points > 0)
|
||||
return (
|
||||
<>
|
||||
<Shelf label={t('journey.studio.routeMap')}>
|
||||
<div className="st-travel-grid">
|
||||
{MAP_CARDS.map(card => (
|
||||
<TravelCard key={card.id} el={build.mapEl(card)} label={t(card.labelKey)} onPlace={place} minHeight={70} maxHeight={92} />
|
||||
))}
|
||||
{IMAGERY.map(src => (
|
||||
<TravelCard
|
||||
key={src.id}
|
||||
el={build.mapEl({ style: 'minimal' }, 'journey', src.id)}
|
||||
label={t(src.labelKey)}
|
||||
onPlace={place}
|
||||
minHeight={70}
|
||||
maxHeight={92}
|
||||
recommended={src.id === 'satellite'}
|
||||
title={src.id === 'satellite' ? t('journey.studio.recommended') : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/*
|
||||
A map per trip: two trips printed as one route draw a line from the
|
||||
last stop of one to the first of the next, a leg nobody travelled.
|
||||
*/}
|
||||
{trips.length > 1 && (
|
||||
<div className="st-shelf-sub">
|
||||
<h4 className="st-section-label">{t('journey.studio.mapPerTrip')}</h4>
|
||||
<div className="st-travel-grid">
|
||||
{trips.map(trip => (
|
||||
<TravelCard
|
||||
key={trip.id}
|
||||
el={build.mapEl({ style: 'minimal', pins: 'numbered' }, { tripId: trip.id })}
|
||||
label={trip.title || t('journey.studio.untitled')}
|
||||
onPlace={place}
|
||||
minHeight={70}
|
||||
maxHeight={92}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Shelf>
|
||||
{/* Each country on its own, its stops alone, cut to its shape or in a box. */}
|
||||
{stats.countries.slice(0, 6).map(c => (
|
||||
<Shelf key={c.code} label={build.countryName(c.code)}>
|
||||
<div className="st-travel-grid">
|
||||
{COUNTRY_MAPS.map(m => (
|
||||
<TravelCard
|
||||
key={m.kind}
|
||||
el={build.countryMapEl(c.code, m.kind)}
|
||||
label={t(m.labelKey)}
|
||||
onPlace={place}
|
||||
minHeight={80}
|
||||
maxHeight={104}
|
||||
inset={m.kind === 'box' ? 0 : 0.04}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
))}
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** The maps of one country: cut to its shape on the paper, cut to it over the satellite picture, and in a box. */
|
||||
const COUNTRY_MAPS = [
|
||||
{ kind: 'shape', labelKey: 'journey.studio.mapClipCountry' },
|
||||
{ kind: 'satellite', labelKey: 'journey.studio.mapSourceSatellite' },
|
||||
{ kind: 'box', labelKey: 'journey.studio.mapClipRect' },
|
||||
] as const
|
||||
|
||||
/** Each country in every look, one shelf per country: a stamp, a visa, a postcard, its flag, a pin, a label, a sketch. */
|
||||
function CountriesTab({ build, stats, t, place }: TabProps & { stats: JourneyStats }) {
|
||||
if (!stats.countries.length) return <p className="st-hint">{t('journey.studio.noCountries')}</p>
|
||||
return (
|
||||
<>
|
||||
{stats.countries.slice(0, 12).map(c => (
|
||||
<Shelf key={c.code} label={build.countryName(c.code)}>
|
||||
<div className="st-travel-grid">
|
||||
{COUNTRY_LOOKS.map(look => (
|
||||
<TravelCard
|
||||
key={look}
|
||||
el={build.countryEl(c.code, look)}
|
||||
label={t(`journey.studio.countryLook.${look}`)}
|
||||
onPlace={place}
|
||||
minHeight={84}
|
||||
maxHeight={100}
|
||||
inset={look === 'postcard' ? 0.04 : 0.09}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** The marks the journey fills, as stamps, tags and pills. A mark it cannot fill is not offered. */
|
||||
function MarksTab({ build, t, place }: TabProps) {
|
||||
const label = (variant: (typeof MARK_VARIANTS)[number]) => t(`journey.studio.${variant}Mark`)
|
||||
return (
|
||||
<>
|
||||
{MARK_STYLES.map(({ style, variants }) => {
|
||||
const cards = variants.flatMap(variant => {
|
||||
const el = build.markEl(variant, style)
|
||||
return el ? [{ el, variant }] : []
|
||||
})
|
||||
if (!cards.length) return null
|
||||
return (
|
||||
<Shelf key={style} label={t(`journey.studio.markStyle.${style}`)}>
|
||||
<div className="st-travel-grid">
|
||||
{cards.map(({ el, variant }) => (
|
||||
<TravelCard
|
||||
key={variant}
|
||||
el={el}
|
||||
label={label(variant)}
|
||||
onPlace={place}
|
||||
minHeight={style === 'stamp' ? 56 : 38}
|
||||
maxHeight={style === 'stamp' ? 76 : 54}
|
||||
inset={style === 'stamp' || style === 'sticker' ? 0.12 : 0.08}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -108,6 +108,40 @@ describe('a text drawn as SVG', () => {
|
||||
expect(Array.from(clip?.querySelectorAll('tspan') ?? []).map((t) => t.textContent)).toEqual(['LIS', 'BON']);
|
||||
});
|
||||
|
||||
it('fills the letters with a gradient through a gradient of their own frame', () => {
|
||||
const el = text({
|
||||
fillPaint: {
|
||||
type: 'linear',
|
||||
angle: 90,
|
||||
stops: [
|
||||
{ at: 0, color: '#ff6600' },
|
||||
{ at: 1, color: '#ffcc00' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const { container } = render(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
|
||||
const gradient = container.querySelector('linearGradient');
|
||||
expect(gradient?.getAttribute('gradientUnits')).toBe('userSpaceOnUse');
|
||||
expect(Array.from(gradient?.querySelectorAll('stop') ?? []).map((s) => s.getAttribute('stop-color'))).toEqual([
|
||||
'#ff6600',
|
||||
'#ffcc00',
|
||||
]);
|
||||
const letters = container.querySelector('svg > text');
|
||||
expect(letters?.getAttribute('fill')).toBe(`url(#${gradient?.getAttribute('id')})`);
|
||||
});
|
||||
|
||||
it('fills the letters with a pattern, its ground and its marks clipped to them', () => {
|
||||
const el = text({
|
||||
fillPaint: { type: 'pattern', pattern: 'stripes', fg: '#d62828', bg: '#ffffff', scale: 1, angle: 0 },
|
||||
});
|
||||
const { container } = render(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
|
||||
const clip = container.getElementsByTagName('clipPath')[0];
|
||||
const group = container.querySelector('g[clip-path]');
|
||||
expect(group?.getAttribute('clip-path')).toBe(`url(#${clip?.getAttribute('id')})`);
|
||||
expect(group?.querySelector('rect')?.getAttribute('fill')).toBe('#ffffff');
|
||||
expect(group?.querySelectorAll('path[fill="#d62828"]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('gives every copy of a page its own ids, so a page drawn twice never clips to the other copy', () => {
|
||||
const el = text({ fillPaint: { type: 'photo', photoId: 12 }, curve: { type: 'circle' } });
|
||||
const { container } = render(
|
||||
|
||||
@@ -2,10 +2,11 @@ import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useId } from 'react';
|
||||
|
||||
import type { BookTextElement } from '@trek/shared';
|
||||
import { bookDrawnLines, bookTextCurvePath, bookTextIsDrawn } from '@trek/shared';
|
||||
import { bookDrawnLines, bookTextCurvePath, bookTextIsDrawn, bookTextPaintFill } from '@trek/shared';
|
||||
|
||||
import { fontStack } from './bookFonts';
|
||||
import { photoSrc } from './bookRender';
|
||||
import { paintParts, type PaintParts } from './paint/paintParts';
|
||||
import {
|
||||
caseStyle,
|
||||
curveAnchor,
|
||||
@@ -145,15 +146,46 @@ interface GlyphPaint {
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
/** A gradient or pattern in the letters, laid out over the frame as a shape's paint is over its box. */
|
||||
function lettersPaint(el: BookTextElement, id: string): PaintParts | null {
|
||||
const paint = bookTextPaintFill(el);
|
||||
return paint ? paintParts(paint, { x: 0, y: 0, w: el.frame.w, h: el.frame.h }, id) : null;
|
||||
}
|
||||
|
||||
/** What the letters are filled with: the paint's fill, else their own colour. */
|
||||
function lettersFill(el: BookTextElement, paint: PaintParts | null): string {
|
||||
if (paint) return paint.fill;
|
||||
return letterFill(el) ?? el.color;
|
||||
}
|
||||
|
||||
/** A pattern's ground and its marks, clipped to the letters. */
|
||||
function PatternLetters({ parts, w, h, clipId }: { parts: PaintParts; w: number; h: number; clipId: string }) {
|
||||
const pattern = parts.pattern;
|
||||
if (!pattern) return null;
|
||||
return (
|
||||
<g clipPath={`url(#${clipId})`}>
|
||||
{parts.fill !== 'none' && <rect width={w} height={h} fill={parts.fill} />}
|
||||
<g transform={pattern.transform}>
|
||||
{pattern.layers.map((layer, i) => (
|
||||
<path key={i} d={layer.d} fill={pattern.ink} fillOpacity={layer.opacity < 1 ? layer.opacity : undefined} />
|
||||
))}
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: CSSProperties; big: boolean }) {
|
||||
const reactId = useId();
|
||||
const pathId = svgId('tp-', reactId);
|
||||
const clipId = svgId('tc-', reactId);
|
||||
const paintId = svgId('tf-', reactId);
|
||||
const { w, h } = el.frame;
|
||||
const fontMm = ptToMm(el.size);
|
||||
const lines = bookDrawnLines(el);
|
||||
const curve = el.curve ? bookTextCurvePath(el.curve, w, h, fontMm) : null;
|
||||
const photo = el.fillPaint?.type === 'photo' ? el.fillPaint : null;
|
||||
const letterPaint = lettersPaint(el, paintId);
|
||||
const pattern = letterPaint ? letterPaint.pattern : null;
|
||||
const hollow = isHollow(el);
|
||||
const { outline, shadow } = el;
|
||||
|
||||
@@ -211,8 +243,10 @@ function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: C
|
||||
if (photo) {
|
||||
const cover = photoCover(w, h, photo);
|
||||
layers.push(<image key="photo" href={photoSrc(photo.photoId, big)} {...cover} clipPath={`url(#${clipId})`} />);
|
||||
} else if (pattern && letterPaint) {
|
||||
layers.push(<PatternLetters key="pattern" parts={letterPaint} w={w} h={h} clipId={clipId} />);
|
||||
} else {
|
||||
layers.push(glyphs({ fill: letterFill(el) ?? el.color }, 'fill'));
|
||||
layers.push(glyphs({ fill: lettersFill(el, letterPaint) }, 'fill'));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -227,7 +261,8 @@ function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: C
|
||||
>
|
||||
<defs>
|
||||
{curve && <path id={pathId} d={curve.d} fill="none" />}
|
||||
{photo && (
|
||||
{letterPaint?.defs}
|
||||
{(photo || pattern) && (
|
||||
<clipPath id={clipId} clipPathUnits="userSpaceOnUse">
|
||||
{glyphs(null, 'clip')}
|
||||
</clipPath>
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import type {
|
||||
BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement,
|
||||
BookMetric, BookStatsElement, BookUnits,
|
||||
BookStatsElement,
|
||||
} from '@trek/shared'
|
||||
import {
|
||||
Camera, CalendarDays, Footprints, Globe, MapPin, Navigation, Route,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { fontStack, lighterWeight, nearestWeight } from './bookFonts'
|
||||
import { fontStack } from './bookFonts'
|
||||
import { heavy, inkAt as rgba, light, METRIC_ICONS, metricValue, round2 } from './travel/travelInk'
|
||||
import { BadgeLookView } from './travel/BadgeLooks'
|
||||
import { CountryLookView } from './travel/CountryLooks'
|
||||
import { StatsLookView } from './travel/StatsLooks'
|
||||
import { badgeIconShare, badgeInk, inkOn, isBadgeLook } from './travel/badgeLook'
|
||||
import { BadgeIcon } from './travel/BadgeIcon'
|
||||
import { EndMarker } from './travel/EndMarker'
|
||||
import { FlagMark } from './travel/FlagMark'
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import { brightness } from './folioColour'
|
||||
import { COUNTRY_SHAPES, countryParts, countryWorldPath, projectMercator, unprojectMercator } from './countryShapes'
|
||||
import { projectOntoTiles, tileView, usableStaticUrl } from './mapTiles'
|
||||
import { useCartoApiKey } from '../../hooks/useTileUrl'
|
||||
import { FLAG_H, FLAG_W, flagBands, flagDisc, flagSpec } from './flags'
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
/**
|
||||
@@ -71,8 +72,6 @@ function typeSize(el: TravelElement, base: number): number {
|
||||
return Math.max(1.2, el.frame.h * base * el.textScale)
|
||||
}
|
||||
|
||||
/** Two decimals of a millimetre, matching what the document stores. */
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100
|
||||
/** Tile geometry, kept finer than the rest: see the note where it is used. */
|
||||
const round3 = (n: number) => Math.round(n * 1000) / 1000
|
||||
|
||||
@@ -100,12 +99,6 @@ const round3 = (n: number) => Math.round(n * 1000) / 1000
|
||||
*/
|
||||
const tileOverlapMm = (size: number) => Math.max(0.8, size * 0.04)
|
||||
|
||||
/** #rrggbb plus an alpha. */
|
||||
function rgba(hex: string, alpha: number): string {
|
||||
const n = Number.parseInt(hex.slice(1), 16)
|
||||
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`
|
||||
}
|
||||
|
||||
export function TravelElementView({ el, frameStyle, big = false }: {
|
||||
el: BookElement
|
||||
frameStyle: CSSProperties
|
||||
@@ -113,9 +106,22 @@ export function TravelElementView({ el, frameStyle, big = false }: {
|
||||
big?: boolean
|
||||
}) {
|
||||
if (el.kind === 'map') return <MapView el={el} frameStyle={frameStyle} big={big} />
|
||||
if (el.kind === 'stats') return <StatsView el={el} frameStyle={frameStyle} />
|
||||
if (el.kind === 'countries') return <CountriesView el={el} frameStyle={frameStyle} />
|
||||
if (el.kind === 'badge') return <BadgeView el={el} frameStyle={frameStyle} />
|
||||
// The newer looks draw in files of their own; `classic` is what every book had before them.
|
||||
if (el.kind === 'stats') {
|
||||
return (el.look ?? 'classic') === 'classic'
|
||||
? <StatsView el={el} frameStyle={frameStyle} />
|
||||
: <StatsLookView el={el} frameStyle={frameStyle} />
|
||||
}
|
||||
if (el.kind === 'countries') {
|
||||
return (el.look ?? 'classic') === 'classic'
|
||||
? <CountriesView el={el} frameStyle={frameStyle} />
|
||||
: <CountryLookView el={el} frameStyle={frameStyle} />
|
||||
}
|
||||
if (el.kind === 'badge') {
|
||||
return isBadgeLook(el.style)
|
||||
? <BadgeLookView el={el} frameStyle={frameStyle} />
|
||||
: <BadgeView el={el} frameStyle={frameStyle} />
|
||||
}
|
||||
if (el.kind === 'list') return <ListView el={el} frameStyle={frameStyle} />
|
||||
return null
|
||||
}
|
||||
@@ -132,17 +138,6 @@ export function TravelElementView({ el, frameStyle, big = false }: {
|
||||
* a slab of ink for no reason. What distinguishes the four styles is how the
|
||||
* land is drawn, which is enough.
|
||||
*/
|
||||
/**
|
||||
* How heavy the type is set, and one step down for the labels under it.
|
||||
*
|
||||
* These elements draw at two levels at once — a figure over its caption, a
|
||||
* country's name over its outline — and the pair has to move together, the way
|
||||
* `textScale` moves their size. Hard-coded 700s meant the only way to lighten a
|
||||
* stats block was to not use one.
|
||||
*/
|
||||
const heavy = (el: { font: string; weight: number }) => nearestWeight(el.font, el.weight)
|
||||
const light = (el: { font: string; weight: number }) => lighterWeight(el.font, el.weight)
|
||||
|
||||
const MAP_PALETTES = {
|
||||
minimal: { land: '#e9e5dc', border: '#d6d0c4', onDark: false },
|
||||
outline: { land: 'transparent', border: '#b9b3a6', onDark: false },
|
||||
@@ -160,28 +155,6 @@ const MAP_PALETTES = {
|
||||
*/
|
||||
const DEFAULT_ACCENT = '#111111' // theme-lint-disable — the contract's default, matched here
|
||||
|
||||
/** Ink for a mark that picks its own, and its opposite. */
|
||||
const BADGE_DARK_INK = '#1c1b19' // theme-lint-disable — ink on a printed page, not a UI token
|
||||
const BADGE_LIGHT_INK = '#ffffff' // theme-lint-disable — paper white on a filled chip
|
||||
|
||||
/**
|
||||
* What colour a mark's words are.
|
||||
*
|
||||
* Automatic unless the element says otherwise, and what automatic means depends
|
||||
* on what the mark is: a chip carries its words on its own fill, so they answer
|
||||
* to the fill's brightness; a day or a date drawn plain takes the accent,
|
||||
* because that is the figure the page is built around. Anything else is
|
||||
* ordinary text in the text colour.
|
||||
*
|
||||
* The moment somebody picks a colour this steps aside completely. That is the
|
||||
* point of the flag: automatic is a good default and a bad cage.
|
||||
*/
|
||||
function badgeInk(el: BookBadgeElement): string {
|
||||
if (!el.autoColor) return el.color
|
||||
if (el.style === 'chip') return brightness(el.accent) > 0.55 ? BADGE_DARK_INK : BADGE_LIGHT_INK
|
||||
if (el.variant === 'day' || el.variant === 'date') return el.accent
|
||||
return el.color
|
||||
}
|
||||
function routeInk(accent: string, onDark: boolean): string {
|
||||
if (!onDark) return accent
|
||||
return accent.toLowerCase() === DEFAULT_ACCENT ? '#f2efe9' : accent // theme-lint-disable — paper on a dark map
|
||||
@@ -512,6 +485,8 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
const S = Math.min(W, H)
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
|
||||
const drawn = el.routeStyle === 'drawn'
|
||||
// Numbered stops and marked ends are markers of the drawn kind, whatever the line is drawn as.
|
||||
const drawnPins = drawn || el.pinStyle === 'numbered' || el.pinStyle === 'ends'
|
||||
const coreW = clamp(S * 0.0055, 0.32, 1.3)
|
||||
const casingW = coreW * 1.9
|
||||
const beadR = clamp(coreW * 3.1, 1.7, 4.8)
|
||||
@@ -528,7 +503,7 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
* so it inverts with it: dark on a light line, light on a dark one. Measured
|
||||
* the same way the marks decide their ink.
|
||||
*/
|
||||
const numInk = brightness(el.accent) > 0.55 ? '#1c1b19' : '#ffffff' // theme-lint-disable — print ink
|
||||
const numInk = inkOn(el.accent)
|
||||
const dashOn = coreW * 2.4
|
||||
const dashOff = coreW * 1.8
|
||||
|
||||
@@ -748,16 +723,16 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
*/
|
||||
const beads: { i: number; x: number; y: number; photo: number | null }[] = []
|
||||
let numbered = false
|
||||
if (drawn) {
|
||||
if (drawnPins) {
|
||||
// Too small to carry photographs at all: a bead wider than a twelfth of the
|
||||
// frame is a picture with a map around it.
|
||||
const roomForPhotos = el.pinStyle === 'photo' && big && beadR * 2 <= S * 0.16
|
||||
/*
|
||||
* Numbers stand in for the photographs that are not there, so they belong
|
||||
* to the photo treatment alone. A map asked for dots gets dots, and a
|
||||
* thumbnail gets dots too: two digits at 130px is a smudge.
|
||||
* Numbers stand in for the photographs that are not there, and a map asked
|
||||
* for numbers numbers every stop. A map asked for dots gets dots, and a
|
||||
* photo map's thumbnail gets dots too: two digits at 130px is a smudge.
|
||||
*/
|
||||
numbered = el.pinStyle === 'photo' && big && numR * 2 <= S * 0.12
|
||||
numbered = (el.pinStyle === 'numbered' || (el.pinStyle === 'photo' && big)) && numR * 2 <= S * 0.12
|
||||
let last: { x: number; y: number } | null = null
|
||||
route.forEach((pt, i) => {
|
||||
const photoId = (el.points[i] as { photoId?: number | null } | undefined)?.photoId ?? null
|
||||
@@ -979,7 +954,7 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
</>
|
||||
)}
|
||||
|
||||
{el.showPins && !drawn && route.map((p, i) => (
|
||||
{el.showPins && !drawnPins && route.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={p.x}
|
||||
@@ -1003,8 +978,10 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
is also what a photograph that fails to load degrades to, so the paper
|
||||
shows a deliberate marker rather than a hole.
|
||||
*/}
|
||||
{el.showPins && drawn && beads.map(m => (
|
||||
m.photo != null ? (
|
||||
{el.showPins && drawnPins && beads.map(m => (
|
||||
el.pinStyle === 'ends' && (m.i === 0 || m.i === route.length - 1) ? (
|
||||
<EndMarker key={m.i} x={m.x} y={m.y} r={numR} finish={m.i > 0} ink={coreInk} paper={numInk} />
|
||||
) : m.photo != null ? (
|
||||
<g key={m.i}>
|
||||
{/* The shadow, as two translucent discs rather than a filter. */}
|
||||
<circle cx={m.x} cy={m.y + coreW * 0.3} r={beadR + coreW * 0.55} fill="#000000" fillOpacity={0.1} />
|
||||
@@ -1127,49 +1104,6 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
|
||||
/* ── Stats ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The icon beside each figure.
|
||||
*
|
||||
* lucide, like every other icon in TREK — a hand-drawn set was the first pass
|
||||
* and it was the wrong call twice over: it put a second drawing vocabulary next
|
||||
* to the one the whole app already uses, and it meant seven paths nobody would
|
||||
* ever revisit when lucide gained a better glyph.
|
||||
*
|
||||
* They size in millimetres despite lucide taking a pixel `size`, because an
|
||||
* inline width and height override the attributes it writes while the viewBox
|
||||
* stays 24×24 — so the stroke scales with the drawing instead of thinning out
|
||||
* on a large panel.
|
||||
*/
|
||||
const METRIC_ICONS: Record<BookMetric, LucideIcon> = {
|
||||
distance: Route,
|
||||
days: CalendarDays,
|
||||
steps: Footprints,
|
||||
photos: Camera,
|
||||
countries: Globe,
|
||||
places: MapPin,
|
||||
furthest: Navigation,
|
||||
}
|
||||
|
||||
function metricValue(
|
||||
metric: BookMetric,
|
||||
values: Record<string, number>,
|
||||
units: BookUnits,
|
||||
locale: string,
|
||||
): { value: string; unit: string } {
|
||||
const raw = values[metric] ?? 0
|
||||
const n = (v: number, digits = 0) =>
|
||||
new Intl.NumberFormat(locale, { maximumFractionDigits: digits }).format(v)
|
||||
|
||||
if (metric === 'distance' || metric === 'furthest') {
|
||||
// Stored in metres and converted here, so switching units never rounds a
|
||||
// second time on top of a rounded number.
|
||||
const km = raw / 1000
|
||||
const shown = units === 'imperial' ? km * 0.621371 : km
|
||||
return { value: n(shown, shown < 100 ? 1 : 0), unit: units === 'imperial' ? 'mi' : 'km' }
|
||||
}
|
||||
return { value: n(raw), unit: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip summary — the page Polarsteps' books open on, and the one that makes
|
||||
* a book feel like a record of a journey rather than a folder of pictures.
|
||||
@@ -1404,75 +1338,6 @@ function CountriesView({ el, frameStyle }: { el: BookCountriesElement; frameStyl
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A flag, drawn.
|
||||
*
|
||||
* The first version set a regional-indicator pair and let the platform draw it.
|
||||
* That is the standard trick and it is wrong for most of the people who will
|
||||
* use this: Chrome on Windows ships no flag glyphs, so it renders the two
|
||||
* letters, and "DE" printed in a photo book is not a flag.
|
||||
*
|
||||
* So flags are constructed from `flags.ts` — exact for the stripe, cross and
|
||||
* disc constructions listed there, and absent for anything with an emblem
|
||||
* rather than approximated. A country with no construction falls back to its
|
||||
* silhouette, which is correct for all 198 of them.
|
||||
*/
|
||||
function FlagMark({ code, size }: { code: string; size: number }) {
|
||||
const spec = flagSpec(code)
|
||||
const height = size
|
||||
const width = (height * FLAG_W) / FLAG_H
|
||||
|
||||
if (spec) {
|
||||
const disc = flagDisc(spec)
|
||||
return (
|
||||
<svg
|
||||
width={`${round2(width)}mm`}
|
||||
height={`${round2(height)}mm`}
|
||||
viewBox={`0 0 ${FLAG_W} ${FLAG_H}`}
|
||||
style={{ display: 'block', flex: '0 0 auto', borderRadius: `${round2(height * 0.08)}mm` }}
|
||||
aria-label={code.toUpperCase()}
|
||||
>
|
||||
{flagBands(spec).map((b, i) => (
|
||||
<rect key={i} x={b.x} y={b.y} width={b.w} height={b.h} fill={b.fill} />
|
||||
))}
|
||||
{disc && <circle cx={disc.cx} cy={disc.cy} r={disc.r} fill={disc.fill} />}
|
||||
{/* A hairline keeps a white-edged flag off a white page. */}
|
||||
<rect x="0" y="0" width={FLAG_W} height={FLAG_H} fill="none" stroke="rgba(0,0,0,.18)" strokeWidth="0.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const shape = COUNTRY_SHAPES[code.toUpperCase()]
|
||||
if (!shape) {
|
||||
// Neither a construction nor an outline: the code, set as a mark rather
|
||||
// than as two stray capitals.
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${round2(size * 0.62)}mm`,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
lineHeight: 1,
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
{code.toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
width={`${round2((height * shape.w) / shape.h)}mm`}
|
||||
height={`${round2(height)}mm`}
|
||||
viewBox={`0 0 ${shape.w} ${shape.h}`}
|
||||
style={{ display: 'block', flex: '0 0 auto' }}
|
||||
aria-label={code.toUpperCase()}
|
||||
>
|
||||
<path d={shape.d} fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Badges ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
@@ -1495,7 +1360,6 @@ function BadgeView({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSPr
|
||||
*/
|
||||
const big = typeSize(el, el.variant === 'date' ? 0.52 : 0.42)
|
||||
const small = typeSize(el, 0.2)
|
||||
const shape = el.code ? COUNTRY_SHAPES[el.code.toUpperCase()] : null
|
||||
const stacked = el.style === 'stacked' || el.variant === 'date'
|
||||
|
||||
const chip = el.style === 'chip'
|
||||
@@ -1511,7 +1375,6 @@ function BadgeView({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSPr
|
||||
*/
|
||||
const showIcon = el.showIcon !== false
|
||||
const showLabel = el.showLabel !== false
|
||||
const iconTint = (automatic: string) => (el.autoIconColor === false ? el.iconColor : automatic)
|
||||
/*
|
||||
* A mark with no words is a pictogram, and a pictogram should fill its box.
|
||||
* Beside a label the icon is a share of the height; on its own it takes four
|
||||
@@ -1544,54 +1407,12 @@ function BadgeView({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSPr
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{el.variant === 'flag' && el.code && showIcon && (
|
||||
<FlagMark code={el.code} size={el.frame.h * (showLabel ? 0.5 : iconScale)} />
|
||||
)}
|
||||
|
||||
{/*
|
||||
Mood and weather come from the journal entry, and they are drawn with
|
||||
the *same* icon and the same palette the journey page uses — imported
|
||||
rather than re-listed, so a mood added to TREK appears here without
|
||||
anyone remembering to add it twice.
|
||||
The picture: a flag, an outline, or the journal's own mood and weather
|
||||
icons, imported rather than re-listed so a mood added to TREK appears
|
||||
here without anyone adding it twice.
|
||||
*/}
|
||||
{(el.variant === 'mood' || el.variant === 'weather') && el.code && showIcon && (() => {
|
||||
// Kept apart rather than narrowed out of one union: only a mood has a
|
||||
// colour of its own, and `'text' in config` types it as unknown.
|
||||
const mood = el.variant === 'mood' ? MOOD_CONFIG[el.code] : null
|
||||
const config = mood ?? WEATHER_CONFIG[el.code]
|
||||
if (!config) return null
|
||||
const Icon = config.icon
|
||||
const size = el.frame.h * iconScale
|
||||
/*
|
||||
* Automatic is the journal's own palette for a mood and the element's
|
||||
* accent for the weather. Note the colour has to be passed rather than
|
||||
* inherited: lucide writes it onto the SVG's stroke, so `currentColor`
|
||||
* from the wrapper never reaches it — which is why the Colour swatch
|
||||
* appeared to do nothing to a mood icon.
|
||||
*/
|
||||
const tint = iconTint(mood ? mood.text : el.accent)
|
||||
return (
|
||||
<Icon
|
||||
color={tint}
|
||||
strokeWidth={1.7}
|
||||
style={{ width: `${round2(size)}mm`, height: `${round2(size)}mm`, display: 'block', flex: '0 0 auto' }}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
{el.variant === 'country' && shape && showIcon && (() => {
|
||||
const h = el.frame.h * (showLabel ? 0.72 : iconScale)
|
||||
return (
|
||||
<svg
|
||||
width={`${round2((h * shape.w) / shape.h)}mm`}
|
||||
height={`${round2(h)}mm`}
|
||||
viewBox={`0 0 ${shape.w} ${shape.h}`}
|
||||
style={{ display: 'block', flex: '0 0 auto' }}
|
||||
>
|
||||
<path d={shape.d} fill={iconTint(el.accent)} />
|
||||
</svg>
|
||||
)
|
||||
})()}
|
||||
{showIcon && <BadgeIcon el={el} height={el.frame.h * (showLabel ? badgeIconShare(el.variant) : iconScale)} />}
|
||||
|
||||
{el.text && showLabel && (
|
||||
<span
|
||||
|
||||
@@ -27,10 +27,16 @@ import { ElementView } from './SpreadView'
|
||||
/** CSS defines 1in as 96px and 25.4mm, so this factor is exact. */
|
||||
const PX_PER_MM = 96 / 25.4
|
||||
|
||||
export function TravelPreview({ el, minHeight = 30, maxHeight = 90 }: {
|
||||
export function TravelPreview({ el, minHeight = 30, maxHeight = 90, inset = 0 }: {
|
||||
el: BookElement
|
||||
minHeight?: number
|
||||
maxHeight?: number
|
||||
/**
|
||||
* Room kept round the element, as a share of the tile: a stamp is set a few
|
||||
* degrees off square and a medal's ring runs to its edge, and drawn edge to
|
||||
* edge they would be clipped by the tile.
|
||||
*/
|
||||
inset?: number
|
||||
}) {
|
||||
const box = useElementSize<HTMLSpanElement>()
|
||||
const width = box.width
|
||||
@@ -48,7 +54,8 @@ export function TravelPreview({ el, minHeight = 30, maxHeight = 90 }: {
|
||||
const drawnW = el.frame.w * PX_PER_MM
|
||||
const drawnH = el.frame.h * PX_PER_MM
|
||||
const height = Math.round(Math.min(maxHeight, Math.max(minHeight, (width * drawnH) / drawnW || minHeight)))
|
||||
const scale = width > 0 ? Math.min(width / drawnW, height / drawnH) : 0
|
||||
const room = 1 - 2 * inset
|
||||
const scale = width > 0 ? Math.min((width * room) / drawnW, (height * room) / drawnH) : 0
|
||||
|
||||
return (
|
||||
<span className="st-travel-preview" style={{ height }} ref={box.ref} aria-hidden>
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ReactNode } from 'react';
|
||||
|
||||
import { useStudioStore } from '../../../store/studioStore';
|
||||
import { PhotoCropExtras, PhotoLookSections } from '../photo/StudioPhotoLook';
|
||||
import { FRAME_SHAPES } from '../shapes';
|
||||
import { FRAME_SHAPES, shapeName } from '../shapes';
|
||||
import { Choice, Line, NumField, Section } from '../StudioControls';
|
||||
import { EdgeSection } from '../StudioEdgeControls';
|
||||
import { ShapeGlyph } from '../StudioElementsPanel';
|
||||
@@ -178,7 +178,8 @@ export function StudioPhotoInspector({
|
||||
key={sh}
|
||||
className={`st-mini-shape ${el.mask === sh ? 'is-on' : ''}`}
|
||||
onClick={() => set({ mask: sh })}
|
||||
aria-label={sh}
|
||||
title={shapeName(sh, t)}
|
||||
aria-label={shapeName(sh, t)}
|
||||
aria-pressed={el.mask === sh}
|
||||
>
|
||||
<ShapeGlyph shape={sh} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BookElement, BookShapeElement, BookShapeId, BookTheme } from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { SHAPE_GROUPS } from '../shapes';
|
||||
import { SHAPE_GROUPS, shapeName } from '../shapes';
|
||||
import { NumField, Section } from '../StudioControls';
|
||||
import { EdgeSection } from '../StudioEdgeControls';
|
||||
import { ShapeGlyph } from '../StudioElementsPanel';
|
||||
@@ -41,7 +41,8 @@ export function StudioShapeInspector({
|
||||
key={sh}
|
||||
className={`st-mini-shape ${el.shape === sh ? 'is-on' : ''}`}
|
||||
onClick={() => set({ shape: sh })}
|
||||
aria-label={sh}
|
||||
title={shapeName(sh, t)}
|
||||
aria-label={shapeName(sh, t)}
|
||||
aria-pressed={el.shape === sh}
|
||||
>
|
||||
<ShapeGlyph shape={sh} />
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { sortedStops, stopAlpha, type BookPaint, type BookPaintStop } from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { linearLine, r2 } from './paintGeometry';
|
||||
import { patternGeometry, type PatternBox } from './patterns';
|
||||
|
||||
/**
|
||||
* A paint as SVG parts over a box (paintRender.tsx explains why the ids come
|
||||
* from useId): kept apart from the components that draw them, so a shape and
|
||||
* the letters of a text share it without the component file exporting a helper.
|
||||
*/
|
||||
|
||||
/** A gradient's stops as SVG elements, in order. */
|
||||
function stopElements(stops: readonly BookPaintStop[]): ReactNode {
|
||||
// Two stops may share an offset (a hard edge), so the index keys them.
|
||||
return sortedStops(stops).map((s, i) => (
|
||||
<stop key={i} offset={r2(s.at)} stopColor={s.color} stopOpacity={stopAlpha(s) < 1 ? stopAlpha(s) : undefined} />
|
||||
));
|
||||
}
|
||||
|
||||
/** What a painted outline needs: the defs to put in the SVG, and how to fill the outline. */
|
||||
export interface PaintParts {
|
||||
defs: ReactNode;
|
||||
fill: string;
|
||||
fillOpacity?: number;
|
||||
/** A pattern's geometry, to draw clipped to the outline over its fill. */
|
||||
pattern: { clipId: string; layers: { d: string; opacity: number }[]; transform?: string; ink: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What drawing a paint over a box takes: the gradient definition and the fill
|
||||
* to point at it, or a pattern's geometry to draw clipped to the outline over
|
||||
* its ground. A shape's outline and the letters of a text both draw from it.
|
||||
*/
|
||||
export function paintParts(paint: BookPaint, box: PatternBox, id: string): PaintParts {
|
||||
switch (paint.type) {
|
||||
case 'solid':
|
||||
return {
|
||||
defs: null,
|
||||
fill: paint.color,
|
||||
fillOpacity: stopAlpha(paint) < 1 ? stopAlpha(paint) : undefined,
|
||||
pattern: null,
|
||||
};
|
||||
case 'linear': {
|
||||
const line = linearLine(paint.angle, box);
|
||||
return {
|
||||
defs: (
|
||||
<linearGradient id={id} gradientUnits="userSpaceOnUse" {...line}>
|
||||
{stopElements(paint.stops)}
|
||||
</linearGradient>
|
||||
),
|
||||
fill: `url(#${id})`,
|
||||
pattern: null,
|
||||
};
|
||||
}
|
||||
case 'radial':
|
||||
return {
|
||||
defs: (
|
||||
<radialGradient
|
||||
id={id}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx={r2(box.x + paint.cx * box.w)}
|
||||
cy={r2(box.y + paint.cy * box.h)}
|
||||
r={r2((paint.r * Math.hypot(box.w, box.h)) / 2)}
|
||||
>
|
||||
{stopElements(paint.stops)}
|
||||
</radialGradient>
|
||||
),
|
||||
fill: `url(#${id})`,
|
||||
pattern: null,
|
||||
};
|
||||
case 'pattern': {
|
||||
const geo = patternGeometry(paint, box);
|
||||
return {
|
||||
defs: null,
|
||||
fill: paint.bg ?? 'none',
|
||||
pattern: { clipId: `${id}-clip`, layers: geo.layers, transform: geo.transform, ink: paint.fg },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { sortedStops, stopAlpha, type BookPaint, type BookPaintStop } from '@trek/shared';
|
||||
import { useMemo, type ReactNode, type SVGProps } from 'react';
|
||||
import { linearLine, r2 } from './paintGeometry';
|
||||
import { patternGeometry, type PatternBox } from './patterns';
|
||||
import type { BookPaint } from '@trek/shared';
|
||||
import { useMemo, type SVGProps } from 'react';
|
||||
import { r2 } from './paintGeometry';
|
||||
import { paintParts } from './paintParts';
|
||||
import type { PatternBox } from './patterns';
|
||||
import { usePaintId } from './usePaintId';
|
||||
|
||||
/**
|
||||
@@ -25,79 +26,6 @@ import { usePaintId } from './usePaintId';
|
||||
* element's own id is kept in front only to make the markup readable.
|
||||
*/
|
||||
|
||||
function Stops({ stops }: { stops: readonly BookPaintStop[] }) {
|
||||
return (
|
||||
<>
|
||||
{sortedStops(stops).map((s, i) => (
|
||||
<stop
|
||||
// Two stops may share an offset (a hard edge), so the index keys them.
|
||||
key={i}
|
||||
offset={r2(s.at)}
|
||||
stopColor={s.color}
|
||||
stopOpacity={stopAlpha(s) < 1 ? stopAlpha(s) : undefined}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a painted outline needs: the defs to put in the SVG, and how to fill the outline. */
|
||||
interface PaintParts {
|
||||
defs: ReactNode;
|
||||
fill: string;
|
||||
fillOpacity?: number;
|
||||
/** A pattern's geometry, to draw clipped to the outline over its fill. */
|
||||
pattern: { clipId: string; layers: { d: string; opacity: number }[]; transform?: string; ink: string } | null;
|
||||
}
|
||||
|
||||
function paintParts(paint: BookPaint, box: PatternBox, id: string): PaintParts {
|
||||
switch (paint.type) {
|
||||
case 'solid':
|
||||
return {
|
||||
defs: null,
|
||||
fill: paint.color,
|
||||
fillOpacity: stopAlpha(paint) < 1 ? stopAlpha(paint) : undefined,
|
||||
pattern: null,
|
||||
};
|
||||
case 'linear': {
|
||||
const line = linearLine(paint.angle, box);
|
||||
return {
|
||||
defs: (
|
||||
<linearGradient id={id} gradientUnits="userSpaceOnUse" {...line}>
|
||||
<Stops stops={paint.stops} />
|
||||
</linearGradient>
|
||||
),
|
||||
fill: `url(#${id})`,
|
||||
pattern: null,
|
||||
};
|
||||
}
|
||||
case 'radial':
|
||||
return {
|
||||
defs: (
|
||||
<radialGradient
|
||||
id={id}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx={r2(box.x + paint.cx * box.w)}
|
||||
cy={r2(box.y + paint.cy * box.h)}
|
||||
r={r2((paint.r * Math.hypot(box.w, box.h)) / 2)}
|
||||
>
|
||||
<Stops stops={paint.stops} />
|
||||
</radialGradient>
|
||||
),
|
||||
fill: `url(#${id})`,
|
||||
pattern: null,
|
||||
};
|
||||
case 'pattern': {
|
||||
const geo = patternGeometry(paint, box);
|
||||
return {
|
||||
defs: null,
|
||||
fill: paint.bg ?? 'none',
|
||||
pattern: { clipId: `${id}-clip`, layers: geo.layers, transform: geo.transform, ink: paint.fg },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type OutlineProps = Omit<SVGProps<SVGPathElement>, 'fill' | 'd'>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -46,6 +46,11 @@ interface PatternGeometry {
|
||||
const r2 = (v: number) => Math.round(v * 100) / 100 || 0;
|
||||
const f = (v: number) => String(r2(v));
|
||||
|
||||
/** Threads per cell each way in linen: fine enough to read as cloth, few enough for a whole page under the cap. */
|
||||
const LINEN_THREADS = 6;
|
||||
/** Specks per cell in grain. */
|
||||
const GRAIN_SPECKS = 3;
|
||||
|
||||
/**
|
||||
* How many primitives a repeat needs over a region: `area` primitives per
|
||||
* cell squared, `lin` per cell along the width and the height. Read before
|
||||
@@ -66,6 +71,8 @@ const DENSITY: Record<BookPattern, { area: number; lin: number }> = {
|
||||
scales: { area: 2, lin: 0 },
|
||||
sunburst: { area: 0, lin: 0 },
|
||||
confetti: { area: 1.2, lin: 0 },
|
||||
linen: { area: 0, lin: 2 * LINEN_THREADS },
|
||||
grain: { area: GRAIN_SPECKS, lin: 0 },
|
||||
};
|
||||
|
||||
/** A primitive count estimate for a repeat over a region at a cell size. */
|
||||
@@ -269,6 +276,24 @@ const DRAW: Record<Exclude<BookPattern, 'sunburst'>, Draw> = {
|
||||
]);
|
||||
}
|
||||
},
|
||||
// Threads of uneven weight both ways, on two half-clear layers: where they cross the cloth reads denser.
|
||||
linen: (pen, g, s, alt) => {
|
||||
const rand = mulberry32(Math.round(s * 1000) + 31);
|
||||
const pitch = s / LINEN_THREADS;
|
||||
for (const y of steps(g.y0, g.y1, pitch)) pen.rect(g.x0, y, g.x1 - g.x0, pitch * (0.2 + rand() * 0.35));
|
||||
for (const x of steps(g.x0, g.x1, pitch)) alt.rect(x, g.y0, pitch * (0.2 + rand() * 0.35), g.y1 - g.y0);
|
||||
},
|
||||
// Specks of a few sizes, the larger ones fainter, scattered the same way on every render.
|
||||
grain: (pen, g, s, alt) => {
|
||||
const rand = mulberry32(Math.round(s * 1000) + 43);
|
||||
const n = Math.round((((g.x1 - g.x0) * (g.y1 - g.y0)) / (s * s)) * GRAIN_SPECKS);
|
||||
for (let i = 0; i < n && !pen.full && !alt.full; i++) {
|
||||
const x = g.x0 + rand() * (g.x1 - g.x0);
|
||||
const y = g.y0 + rand() * (g.y1 - g.y0);
|
||||
if (rand() < 0.6) pen.circle(x, y, s * (0.012 + rand() * 0.012));
|
||||
else alt.circle(x, y, s * (0.025 + rand() * 0.02));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** A seeded generator, so a confetti page draws the same scatter on every render and in print. */
|
||||
@@ -307,6 +332,13 @@ function sunburst(pen: Pen, box: PatternBox, cell: number, angle: number) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The repeats drawn on two layers, and how clear each layer is: textures stay quiet over their ground. */
|
||||
const LAYER_OPACITY: Partial<Record<BookPattern, readonly [number, number]>> = {
|
||||
gingham: [0.5, 0.5],
|
||||
linen: [0.32, 0.32],
|
||||
grain: [0.5, 0.28],
|
||||
};
|
||||
|
||||
/**
|
||||
* A repeat over a box. The geometry covers the box however it is turned: the
|
||||
* region drawn is the square around the box's circumscribed circle, and the
|
||||
@@ -338,17 +370,18 @@ export function patternGeometry(
|
||||
return { layers: [{ d: pen.d, opacity: 1 }], count: pen.count, cell };
|
||||
}
|
||||
|
||||
// The second layer only ever holds gingham's cross bands; the cell fitted above keeps both under the cap together.
|
||||
// The second layer holds gingham's and linen's cross bands and grain's larger specks;
|
||||
// the cell fitted above keeps both under the cap together.
|
||||
const alt = new Pen(cap);
|
||||
DRAW[paint.pattern](pen, region, cell, alt);
|
||||
const count = pen.count + alt.count;
|
||||
const layers =
|
||||
paint.pattern === 'gingham'
|
||||
? [
|
||||
{ d: pen.d, opacity: 0.5 },
|
||||
{ d: alt.d, opacity: 0.5 },
|
||||
]
|
||||
: [{ d: pen.d, opacity: 1 }];
|
||||
const two = LAYER_OPACITY[paint.pattern];
|
||||
const layers = two
|
||||
? [
|
||||
{ d: pen.d, opacity: two[0] },
|
||||
{ d: alt.d, opacity: two[1] },
|
||||
]
|
||||
: [{ d: pen.d, opacity: 1 }];
|
||||
return {
|
||||
layers: layers.filter((l) => l.d),
|
||||
count,
|
||||
|
||||
@@ -330,3 +330,8 @@ export const HOLED_SHAPES = new Set<BookShapeId>(['gear', 'postmark', 'label-tab
|
||||
*/
|
||||
export const SHAPE_GROUPS = BOOK_SHAPE_GROUPS
|
||||
export const FRAME_SHAPES = BOOK_FRAME_SHAPES
|
||||
|
||||
/** A shape's name in the reader's language, for its tooltip and a screen reader. */
|
||||
export function shapeName(shape: BookShapeId, t: (k: string) => string): string {
|
||||
return t(`journey.studio.shapeName.${shape}`)
|
||||
}
|
||||
|
||||
@@ -106,6 +106,9 @@ export function StudioPagesPanel({
|
||||
folios={folios}
|
||||
fold={bookView}
|
||||
className="st-thumb-sheet"
|
||||
coverInside={t(
|
||||
sp.role === 'last' ? 'journey.studio.backCoverInside' : 'journey.studio.coverInside'
|
||||
)}
|
||||
/>
|
||||
<span className="st-thumb-label">
|
||||
{sp.role === 'cover'
|
||||
|
||||
@@ -117,7 +117,7 @@ function BuiltInLayouts({
|
||||
const scale = width / (page.pageWidth * (single ? 1 : 2) * pxPerMm);
|
||||
const templates = single ? COVER_TEMPLATES : TEMPLATES;
|
||||
return (
|
||||
<div className="st-thumbs">
|
||||
<div className={`st-thumbs st-tpl-grid ${single ? 'is-pages' : 'is-spreads'}`}>
|
||||
{templates.map((tpl) => (
|
||||
<button type="button" key={tpl.id} className="st-thumb" onClick={() => onPick(tpl)}>
|
||||
{/* A page is laid out from its top left in either reading direction. */}
|
||||
@@ -183,7 +183,7 @@ export function StudioTemplatesPanel({
|
||||
if (!doc || !spread || !ctx) return null;
|
||||
const single = spread.role !== 'inner';
|
||||
const templates = templatesForSpread(spread, filter);
|
||||
const { full, card: cardWidth } = previewWidths(list.width, single);
|
||||
const { card: cardWidth } = previewWidths(list.width, single);
|
||||
|
||||
/**
|
||||
* One undo step: the page laid out, nothing selected, and the photos at hand
|
||||
@@ -247,8 +247,11 @@ export function StudioTemplatesPanel({
|
||||
</div>
|
||||
<p className="st-hint">{t('studio.templates.hint')}</p>
|
||||
</div>
|
||||
<StudioSavedLayouts page={page} width={full} pxPerMm={pxPerMm} t={t} />
|
||||
<BuiltInLayouts spread={spread} page={page} pxPerMm={pxPerMm} width={full} t={t} onPick={pickBuiltIn} />
|
||||
<StudioSavedLayouts page={page} width={cardWidth} pxPerMm={pxPerMm} t={t} />
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.builtInLayouts')}</div>
|
||||
<BuiltInLayouts spread={spread} page={page} pxPerMm={pxPerMm} width={cardWidth} t={t} onPick={pickBuiltIn} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,7 @@ export function withAlpha(hex: string, alpha = 1): string {
|
||||
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${Math.max(0, alpha)})`;
|
||||
}
|
||||
|
||||
/** The colour the letters are filled with, or null when a photograph fills them. */
|
||||
/** The colour the letters are filled with, or null when a photograph, a gradient or a pattern fills them. */
|
||||
export function letterFill(el: BookTextElement): string | null {
|
||||
const fill = el.fillPaint;
|
||||
if (!fill) return el.color;
|
||||
|
||||
@@ -67,7 +67,11 @@ function ThemeCard({
|
||||
);
|
||||
}
|
||||
|
||||
/** One palette entry: its colour, which is the picker, and its name. */
|
||||
/**
|
||||
* One palette entry: its colour, which is the picker, and its name. The three
|
||||
* accents share one name under them, so each shows its colour alone and says
|
||||
* its own name to a screen reader and on hover.
|
||||
*/
|
||||
function PaletteEntry({
|
||||
name,
|
||||
hex,
|
||||
@@ -79,13 +83,14 @@ function PaletteEntry({
|
||||
onPick: (hex: string) => void;
|
||||
t: StudioT;
|
||||
}) {
|
||||
const accent = name.startsWith('accent');
|
||||
const label = t(`studio.theme.token.${name}`);
|
||||
return (
|
||||
<label className="st-theme-colour">
|
||||
<label className="st-theme-colour" title={label}>
|
||||
<span className="st-theme-chip" style={{ background: hex }}>
|
||||
<input type="color" value={hex} aria-label={label} onChange={(e) => onPick(e.target.value)} />
|
||||
</span>
|
||||
<span className="st-theme-colour-name">{label}</span>
|
||||
{!accent && <span className="st-theme-colour-name">{label}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -181,6 +186,7 @@ export function StudioThemePanel({ t }: { t: StudioT }) {
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
<span className="st-theme-colour-name st-theme-accents-name">{t('studio.theme.accents')}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { BookBadgeElement, BookBadgeVariant } from '@trek/shared';
|
||||
import { CalendarDays, Compass, Flag, MapPin, Mountain, Route, type LucideIcon } from 'lucide-react';
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../../pages/journeyDetail/JourneyDetailPage.constants';
|
||||
import { COUNTRY_SHAPES } from '../countryShapes';
|
||||
import { FlagMark } from './FlagMark';
|
||||
import { mm } from './travelInk';
|
||||
|
||||
/**
|
||||
* The picture of a mark: a flag, a country's outline, or the journal's own
|
||||
* mood and weather icons. Drawn the same in every style a mark can take, so a
|
||||
* flag set as a stamp is the flag set as a chip.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A drawing for the marks that have no picture of their own. A pill always
|
||||
* opens on a disc, and a date in a disc with nothing in it reads as missing.
|
||||
*/
|
||||
const GENERIC: Partial<Record<BookBadgeVariant, LucideIcon>> = {
|
||||
date: CalendarDays,
|
||||
day: Flag,
|
||||
coords: Compass,
|
||||
distance: Route,
|
||||
altitude: Mountain,
|
||||
country: MapPin,
|
||||
flag: Flag,
|
||||
};
|
||||
|
||||
export function BadgeIcon({
|
||||
el,
|
||||
height,
|
||||
generic = false,
|
||||
ink,
|
||||
}: {
|
||||
el: BookBadgeElement;
|
||||
/** The drawn height in millimetres; the width follows the picture's own shape. */
|
||||
height: number;
|
||||
/** Draw a plain icon for a mark that has no picture of its own. */
|
||||
generic?: boolean;
|
||||
/** The automatic colour, for a picture drawn on a fill rather than on the page. */
|
||||
ink?: string;
|
||||
}) {
|
||||
/*
|
||||
* Automatic is the journal's palette for a mood and the accent for anything
|
||||
* else. It is passed rather than inherited: lucide writes the colour onto the
|
||||
* SVG's stroke, so `currentColor` from the wrapper never reaches it.
|
||||
*/
|
||||
const tint = (automatic: string) => (el.autoIconColor === false ? el.iconColor : (ink ?? automatic));
|
||||
const code = el.code;
|
||||
const iconStyle = { width: mm(height), height: mm(height), display: 'block', flex: '0 0 auto' } as const;
|
||||
|
||||
if (el.variant === 'flag' && code) return <FlagMark code={code} size={height} />;
|
||||
|
||||
if ((el.variant === 'mood' || el.variant === 'weather') && code) {
|
||||
// Kept apart rather than narrowed out of one union: only a mood has a colour of its own.
|
||||
const mood = el.variant === 'mood' ? MOOD_CONFIG[code] : null;
|
||||
const config = mood ?? WEATHER_CONFIG[code];
|
||||
if (config) {
|
||||
const Icon = config.icon;
|
||||
return <Icon color={tint(mood ? mood.text : el.accent)} strokeWidth={1.7} style={iconStyle} />;
|
||||
}
|
||||
}
|
||||
|
||||
const shape = el.variant === 'country' && code ? COUNTRY_SHAPES[code.toUpperCase()] : null;
|
||||
if (shape) {
|
||||
return (
|
||||
<svg
|
||||
width={mm((height * shape.w) / shape.h)}
|
||||
height={mm(height)}
|
||||
viewBox={`0 0 ${shape.w} ${shape.h}`}
|
||||
style={{ display: 'block', flex: '0 0 auto' }}
|
||||
>
|
||||
<path d={shape.d} fill={tint(el.accent)} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = generic ? GENERIC[el.variant] : undefined;
|
||||
return Icon ? <Icon color={tint(el.accent)} strokeWidth={1.8} style={iconStyle} /> : null;
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import type { BookBadgeElement, BookBadgeStyle } from '@trek/shared';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { fontStack } from '../bookFonts';
|
||||
import { BadgeIcon } from './BadgeIcon';
|
||||
import { badgeIconShare, badgeInk, inkOn } from './badgeLook';
|
||||
import { hatch, roughBox, seeded } from './sketch';
|
||||
import { heavy, inkAt, light, mm } from './travelInk';
|
||||
|
||||
/**
|
||||
* The newer styles of a mark: an inked passport stamp, a luggage tag with its
|
||||
* eyelet, and a pill that opens on its icon in a disc. The four styles every
|
||||
* mark had before these stay in TravelElements.tsx, so a mark placed then
|
||||
* prints as it always has.
|
||||
*/
|
||||
|
||||
/** The words of a mark, the value over or beside the line under it. */
|
||||
function Words({
|
||||
el,
|
||||
big,
|
||||
small,
|
||||
ink,
|
||||
stacked,
|
||||
start = false,
|
||||
}: {
|
||||
el: BookBadgeElement;
|
||||
big: number;
|
||||
small: number;
|
||||
ink: string;
|
||||
stacked: boolean;
|
||||
/** Stacked lines aligned to the start rather than centred, as in a tag or a pill. */
|
||||
start?: boolean;
|
||||
}) {
|
||||
if (el.showLabel === false) return null;
|
||||
// A mark with no value of its own (a flag, a country) carries its name as the value.
|
||||
const main = el.text || el.sub;
|
||||
const sub = el.text ? el.sub : '';
|
||||
const named = !el.text;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: stacked ? 'column' : 'row',
|
||||
alignItems: stacked ? (start ? 'flex-start' : 'center') : 'baseline',
|
||||
gap: mm(small * (stacked ? 0.3 : 0.6)),
|
||||
minWidth: 0,
|
||||
color: ink,
|
||||
}}
|
||||
>
|
||||
{main && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: mm(el.variant === 'coords' ? big * 0.62 : named ? big * 0.72 : big),
|
||||
fontWeight: heavy(el),
|
||||
lineHeight: 1,
|
||||
letterSpacing: el.variant === 'coords' || named ? '0.08em' : '-0.01em',
|
||||
textTransform: named ? 'uppercase' : undefined,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{main}
|
||||
</span>
|
||||
)}
|
||||
{sub && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: mm(small),
|
||||
fontWeight: light(el),
|
||||
letterSpacing: '0.16em',
|
||||
textTransform: 'uppercase',
|
||||
lineHeight: 1.2,
|
||||
opacity: 0.8,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{sub}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Coordinates carry a long value and the place under it: set on two lines, both stay legible. */
|
||||
const twoLines = (el: BookBadgeElement) => el.variant === 'coords' && !!el.text && !!el.sub;
|
||||
|
||||
/** The frame every style shares: the element's box, its face, centred content. */
|
||||
function Shell({
|
||||
el,
|
||||
frameStyle,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
el: BookBadgeElement;
|
||||
frameStyle: CSSProperties;
|
||||
style: CSSProperties;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...frameStyle,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxSizing: 'border-box',
|
||||
fontFamily: fontStack(el.font),
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A passport stamp: a rounded box ruled twice in the accent, the words in
|
||||
* capitals inside it, turned a few degrees and a shade short of solid, the
|
||||
* way an inked stamp lands.
|
||||
*/
|
||||
function StampMark({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSProperties }) {
|
||||
const { h } = el.frame;
|
||||
const line = Math.max(0.25, h * 0.045);
|
||||
const ink = el.autoColor === false ? el.color : el.accent;
|
||||
return (
|
||||
<Shell
|
||||
el={el}
|
||||
frameStyle={frameStyle}
|
||||
style={{
|
||||
flexDirection: 'column',
|
||||
gap: mm(h * 0.05),
|
||||
border: `${mm(line)} solid ${ink}`,
|
||||
outline: `${mm(line * 0.5)} solid ${ink}`,
|
||||
outlineOffset: mm(-line * 2.6),
|
||||
borderRadius: mm(h * 0.14),
|
||||
padding: `${mm(h * 0.14)} ${mm(h * 0.3)}`,
|
||||
textTransform: 'uppercase',
|
||||
// An inked stamp is a shade short of solid, on top of whatever the element asks.
|
||||
opacity: Number(frameStyle.opacity ?? 1) * 0.9,
|
||||
transform: `${frameStyle.transform ?? ''} rotate(-4deg)`.trim(),
|
||||
}}
|
||||
>
|
||||
{el.showIcon !== false && <BadgeIcon el={el} height={h * 0.26} ink={ink} />}
|
||||
<Words el={el} big={h * 0.3 * el.textScale} small={h * 0.13 * el.textScale} ink={ink} stacked />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A luggage tag: filled in the accent, its leading end cut to a point, with an
|
||||
* eyelet punched through so the page shows in the hole.
|
||||
*/
|
||||
function TagMark({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSProperties }) {
|
||||
const { w, h } = el.frame;
|
||||
const point = Math.min(h * 0.4, w * 0.18);
|
||||
const hole = h * 0.11;
|
||||
const holeX = point * 0.8;
|
||||
const ink = badgeInk(el);
|
||||
const lines = twoLines(el);
|
||||
const mask = `radial-gradient(circle at ${mm(holeX)} 50%, transparent ${mm(hole)}, black ${mm(hole + 0.05)})`;
|
||||
return (
|
||||
<Shell
|
||||
el={el}
|
||||
frameStyle={frameStyle}
|
||||
style={{
|
||||
gap: mm(h * 0.14),
|
||||
background: el.accent,
|
||||
clipPath: `polygon(${mm(point)} 0, 100% 0, 100% 100%, ${mm(point)} 100%, 0 50%)`,
|
||||
WebkitMaskImage: mask,
|
||||
maskImage: mask,
|
||||
paddingInlineStart: mm(point + hole * 1.6),
|
||||
paddingInlineEnd: mm(h * 0.3),
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{el.showIcon !== false && <BadgeIcon el={el} height={h * badgeIconShare(el.variant)} ink={ink} />}
|
||||
<Words
|
||||
el={el}
|
||||
big={h * (lines ? 0.34 : 0.38) * el.textScale}
|
||||
small={h * 0.16 * el.textScale}
|
||||
ink={ink}
|
||||
stacked={lines}
|
||||
start
|
||||
/>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/** A pill: a tinted capsule that opens on the mark's icon in a disc of the accent. */
|
||||
function PillMark({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSProperties }) {
|
||||
const { h } = el.frame;
|
||||
const disc = h * 0.78;
|
||||
const pad = (h - disc) / 2;
|
||||
const lines = twoLines(el);
|
||||
return (
|
||||
<Shell
|
||||
el={el}
|
||||
frameStyle={frameStyle}
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
gap: mm(h * 0.22),
|
||||
padding: mm(pad),
|
||||
paddingInlineEnd: mm(h * 0.42),
|
||||
borderRadius: mm(h / 2),
|
||||
background: inkAt(el.accent, 0.12),
|
||||
}}
|
||||
>
|
||||
{el.showIcon !== false && (
|
||||
<span
|
||||
style={{
|
||||
width: mm(disc),
|
||||
height: mm(disc),
|
||||
flex: '0 0 auto',
|
||||
borderRadius: '50%',
|
||||
background: el.accent,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<BadgeIcon el={el} height={disc * 0.52} ink={inkOn(el.accent)} generic />
|
||||
</span>
|
||||
)}
|
||||
<Words
|
||||
el={el}
|
||||
big={h * (lines ? 0.34 : 0.4) * el.textScale}
|
||||
small={h * 0.17 * el.textScale}
|
||||
ink={el.color}
|
||||
stacked={lines}
|
||||
start
|
||||
/>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A ribbon: a band in the accent across the top with its two ends folded
|
||||
* behind it and cut into swallowtails, the words in capitals on the band.
|
||||
*/
|
||||
function RibbonMark({ el, frameStyle }: MarkProps) {
|
||||
const { w, h } = el.frame;
|
||||
const tail = Math.min(h * 0.7, w * 0.16);
|
||||
const bandH = h * 0.72;
|
||||
const drop = h - bandH;
|
||||
const ink = inkOn(el.accent);
|
||||
const back = inkAt(el.color, 0.6);
|
||||
const end = (x: number, dir: 1 | -1) =>
|
||||
`M${x} ${drop} H${x + dir * tail * 1.4} L${x + dir * tail * 0.9} ${drop + bandH / 2} L${x + dir * tail * 1.4} ${h} H${x} Z`;
|
||||
return (
|
||||
<Shell el={el} frameStyle={frameStyle} style={{ overflow: 'visible', alignItems: 'flex-start' }}>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0, overflow: 'visible' }}
|
||||
aria-hidden
|
||||
>
|
||||
<path d={end(tail * 1.4, -1)} fill={back} />
|
||||
<path d={end(w - tail * 1.4, 1)} fill={back} />
|
||||
<path d={`M${tail * 0.7} ${bandH} L${tail * 1.4} ${bandH} L${tail * 1.4} ${h} Z`} fill={inkAt(el.color, 0.8)} />
|
||||
<path
|
||||
d={`M${w - tail * 0.7} ${bandH} L${w - tail * 1.4} ${bandH} L${w - tail * 1.4} ${h} Z`}
|
||||
fill={inkAt(el.color, 0.8)}
|
||||
/>
|
||||
<rect x={tail * 0.7} y={0} width={w - tail * 1.4} height={bandH} fill={el.accent} />
|
||||
</svg>
|
||||
<span
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: mm(bandH),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: mm(h * 0.14),
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{el.showIcon !== false && <BadgeIcon el={el} height={bandH * 0.5} ink={ink} />}
|
||||
<Words el={el} big={bandH * 0.46 * el.textScale} small={bandH * 0.2 * el.textScale} ink={ink} stacked={false} />
|
||||
</span>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/** A die-cut sticker: the mark in the accent inside its white cut edge, lifted off the page by a soft shadow. */
|
||||
function StickerMark({ el, frameStyle }: MarkProps) {
|
||||
const { h } = el.frame;
|
||||
const edge = h * 0.09;
|
||||
const ink = inkOn(el.accent);
|
||||
return (
|
||||
<Shell
|
||||
el={el}
|
||||
frameStyle={frameStyle}
|
||||
style={{
|
||||
gap: mm(h * 0.16),
|
||||
padding: `0 ${mm(h * 0.34)}`,
|
||||
borderRadius: mm(h * 0.3),
|
||||
background: el.accent,
|
||||
boxShadow: `0 0 0 ${mm(edge)} ${inkOn(el.color)}, 0 ${mm(edge * 0.8)} ${mm(edge * 2.2)} ${inkAt(el.color, 0.28)}`,
|
||||
transform: `${frameStyle.transform ?? ''} rotate(-3deg)`.trim(),
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
{el.showIcon !== false && <BadgeIcon el={el} height={h * badgeIconShare(el.variant)} ink={ink} />}
|
||||
<Words el={el} big={h * 0.4 * el.textScale} small={h * 0.17 * el.textScale} ink={ink} stacked={false} />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An embroidered patch: woven in the accent, a heavy rim round its edge and a
|
||||
* line of stitching inside it, the words sewn in the paper's colour.
|
||||
*/
|
||||
function PatchMark({ el, frameStyle }: MarkProps) {
|
||||
const { h } = el.frame;
|
||||
const ink = inkOn(el.accent);
|
||||
const rim = h * 0.07;
|
||||
return (
|
||||
<Shell
|
||||
el={el}
|
||||
frameStyle={frameStyle}
|
||||
style={{
|
||||
gap: mm(h * 0.16),
|
||||
padding: `0 ${mm(h * 0.4)}`,
|
||||
borderRadius: mm(h * 0.34),
|
||||
background: `repeating-linear-gradient(45deg, ${inkAt(ink, 0.08)} 0 ${mm(h * 0.02)}, transparent ${mm(h * 0.02)} ${mm(h * 0.055)}), ${el.accent}`,
|
||||
boxShadow: `inset 0 0 0 ${mm(rim)} ${inkAt(el.color, 0.35)}, 0 ${mm(rim * 0.4)} ${mm(rim)} ${inkAt(el.color, 0.25)}`,
|
||||
outline: `${mm(h * 0.025)} dashed ${inkAt(ink, 0.85)}`,
|
||||
outlineOffset: mm(-rim * 1.9),
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
{el.showIcon !== false && <BadgeIcon el={el} height={h * badgeIconShare(el.variant) * 0.9} ink={ink} />}
|
||||
<Words el={el} big={h * 0.36 * el.textScale} small={h * 0.15 * el.textScale} ink={ink} stacked={false} />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/** A frame drawn by hand: a freehand box traced twice over pale hatching, the words in the element's face. */
|
||||
function SketchMark({ el, frameStyle }: MarkProps) {
|
||||
const { w, h } = el.frame;
|
||||
const rand = seeded(`${el.variant}:${el.text}:${el.sub}`);
|
||||
const pen = Math.max(0.2, h * 0.03);
|
||||
const wob = h * 0.05;
|
||||
const hand = { rand, wobble: wob };
|
||||
const frame = `${roughBox({ x: pen * 2, y: pen * 2, w: w - pen * 4, h: h - pen * 4 }, hand)} ${roughBox({ x: pen * 3, y: pen * 1.2, w: w - pen * 5, h: h - pen * 3.4 }, hand)}`;
|
||||
const shade = hatch({ x: pen * 3, y: pen * 3, w: w - pen * 6, h: h - pen * 6 }, h * 0.12, {
|
||||
rand,
|
||||
wobble: wob * 0.3,
|
||||
});
|
||||
return (
|
||||
<Shell
|
||||
el={el}
|
||||
frameStyle={frameStyle}
|
||||
style={{ gap: mm(h * 0.14), padding: `0 ${mm(h * 0.3)}`, overflow: 'visible' }}
|
||||
>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0, overflow: 'visible' }}
|
||||
aria-hidden
|
||||
>
|
||||
<path d={shade} fill="none" stroke={el.accent} strokeWidth={pen * 0.6} strokeLinecap="round" opacity={0.35} />
|
||||
<path d={frame} fill="none" stroke={el.color} strokeWidth={pen} strokeLinecap="round" opacity={0.85} />
|
||||
</svg>
|
||||
{el.showIcon !== false && (
|
||||
<span style={{ position: 'relative' }}>
|
||||
<BadgeIcon el={el} height={h * badgeIconShare(el.variant)} generic />
|
||||
</span>
|
||||
)}
|
||||
<span style={{ position: 'relative' }}>
|
||||
<Words el={el} big={h * 0.42 * el.textScale} small={h * 0.18 * el.textScale} ink={el.color} stacked={false} />
|
||||
</span>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkProps {
|
||||
el: BookBadgeElement;
|
||||
frameStyle: CSSProperties;
|
||||
}
|
||||
|
||||
const MARKS: Partial<Record<BookBadgeStyle, (props: MarkProps) => ReactNode>> = {
|
||||
stamp: StampMark,
|
||||
tag: TagMark,
|
||||
pill: PillMark,
|
||||
ribbon: RibbonMark,
|
||||
sticker: StickerMark,
|
||||
patch: PatchMark,
|
||||
sketch: SketchMark,
|
||||
};
|
||||
|
||||
export function BadgeLookView({ el, frameStyle }: MarkProps) {
|
||||
const Mark = MARKS[el.style] ?? PillMark;
|
||||
return <Mark el={el} frameStyle={frameStyle} />;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import type { BookCountriesElement } from '@trek/shared';
|
||||
import { useId, type CSSProperties, type ReactNode } from 'react';
|
||||
import { fontStack } from '../bookFonts';
|
||||
import { FlagCloth, Label, OutlinePath, Pin, SketchCountry, Visa, type CountryArt } from './CountryLooksMore';
|
||||
import { FlagMark } from './FlagMark';
|
||||
import { heavy, inkAt, light, mm } from './travelInk';
|
||||
|
||||
/**
|
||||
* The newer looks of a country: a passport stamp and a postcard here, an entry
|
||||
* stamp, the flag in cloth, a map pin, a luggage label and a sketch in
|
||||
* CountryLooksMore.tsx. The classic
|
||||
* look, the name over its silhouette, stays in TravelElements.tsx, so every
|
||||
* country placed before these existed prints as it always has.
|
||||
*
|
||||
* The panel places one country per element; an element that holds several, as
|
||||
* the old lists did, draws one stamp or card for each, side by side.
|
||||
*/
|
||||
|
||||
interface Country {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A passport stamp: two rings, the name running round the top, the country
|
||||
* code round the bottom between two stars, and the outline in the middle. Set
|
||||
* a few degrees off square and a shade short of solid, the way an inked stamp
|
||||
* lands on a page.
|
||||
*/
|
||||
function Stamp({ el, c, size }: { el: BookCountriesElement; c: Country; size: number }) {
|
||||
const id = useId().replace(/:/g, '');
|
||||
const r = size / 2;
|
||||
const ring = Math.max(0.25, size * 0.025);
|
||||
const textR = r * 0.74;
|
||||
const type = Math.max(1.2, size * 0.125 * el.textScale);
|
||||
const top = `M ${r - textR} ${r} A ${textR} ${textR} 0 0 1 ${r + textR} ${r}`;
|
||||
const bottom = `M ${r - textR * 0.9} ${r} A ${textR * 0.9} ${textR * 0.9} 0 0 0 ${r + textR * 0.9} ${r}`;
|
||||
const name = c.name.toUpperCase();
|
||||
return (
|
||||
<svg
|
||||
width={mm(size)}
|
||||
height={mm(size)}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
style={{ display: 'block', transform: 'rotate(-7deg)', opacity: 0.9 }}
|
||||
>
|
||||
<defs>
|
||||
<path id={`${id}-top`} d={top} />
|
||||
<path id={`${id}-bottom`} d={bottom} />
|
||||
</defs>
|
||||
<circle cx={r} cy={r} r={r - ring} fill="none" stroke={el.accent} strokeWidth={ring} />
|
||||
<circle cx={r} cy={r} r={r * 0.86} fill="none" stroke={el.accent} strokeWidth={ring * 0.45} />
|
||||
<circle cx={r} cy={r} r={r * 0.5} fill="none" stroke={el.accent} strokeWidth={ring * 0.45} />
|
||||
{el.showOutline && <OutlinePath code={c.code} box={r * 0.66} x={r} y={r} fill={el.accent} />}
|
||||
{el.showName && (
|
||||
<text
|
||||
fill={el.accent}
|
||||
style={{
|
||||
fontFamily: fontStack(el.font),
|
||||
fontWeight: heavy(el),
|
||||
fontSize: `${type}px`,
|
||||
letterSpacing: `${type * 0.18}px`,
|
||||
}}
|
||||
>
|
||||
<textPath
|
||||
href={`#${id}-top`}
|
||||
startOffset="50%"
|
||||
textAnchor="middle"
|
||||
lengthAdjust="spacingAndGlyphs"
|
||||
textLength={name.length * type * 0.85 > Math.PI * textR * 0.9 ? Math.PI * textR * 0.9 : undefined}
|
||||
>
|
||||
{name}
|
||||
</textPath>
|
||||
</text>
|
||||
)}
|
||||
<text
|
||||
fill={el.accent}
|
||||
style={{
|
||||
fontFamily: fontStack(el.font),
|
||||
fontWeight: light(el),
|
||||
fontSize: `${type * 0.8}px`,
|
||||
letterSpacing: `${type * 0.3}px`,
|
||||
}}
|
||||
>
|
||||
<textPath href={`#${id}-bottom`} startOffset="50%" textAnchor="middle" dominantBaseline="hanging">
|
||||
{`★ ${c.code.toUpperCase()} ★`}
|
||||
</textPath>
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A postcard: an airmail border of diagonal stripes in the accent and the
|
||||
* ink, the country's outline large on the card, a postage stamp in the corner
|
||||
* with the flag or the code on it, a postmark struck across it, and the name
|
||||
* along the foot.
|
||||
*/
|
||||
function Postcard({ el, c, w, h }: { el: BookCountriesElement; c: Country; w: number; h: number }) {
|
||||
const id = useId().replace(/:/g, '');
|
||||
const edge = Math.min(w, h) * 0.055;
|
||||
const radius = Math.min(w, h) * 0.04;
|
||||
const stripe = edge * 0.9;
|
||||
const stampW = w * 0.2;
|
||||
const stampH = stampW * 1.18;
|
||||
const stampX = w - edge * 2 - stampW;
|
||||
const stampY = edge * 2;
|
||||
const type = Math.max(1.2, Math.min(h * 0.11, (w * 0.6) / Math.max(4, c.name.length * 0.78)) * el.textScale);
|
||||
const outlineBox = Math.min(w * 0.5, h * 0.6);
|
||||
const wave = (y: number) => {
|
||||
const x0 = stampX - stampW * 0.7;
|
||||
const seg = stampW * 0.35;
|
||||
return `M${x0} ${y} q${seg / 2} ${-edge * 0.5} ${seg} 0 t${seg} 0 t${seg} 0 t${seg} 0`;
|
||||
};
|
||||
return (
|
||||
<div style={{ position: 'relative', width: mm(w), height: mm(h) }}>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0 }}
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id={`${id}-air`}
|
||||
patternUnits="userSpaceOnUse"
|
||||
width={stripe * 4}
|
||||
height={stripe * 4}
|
||||
patternTransform="rotate(45)"
|
||||
>
|
||||
<rect x={0} y={0} width={stripe} height={stripe * 4} fill={el.accent} />
|
||||
<rect x={stripe * 2} y={0} width={stripe} height={stripe * 4} fill={el.color} />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect x={0} y={0} width={w} height={h} rx={radius} fill={inkAt(el.accent, 0.08)} />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
fill={`url(#${id}-air)`}
|
||||
d={`M${radius} 0 H${w - radius} Q${w} 0 ${w} ${radius} V${h - radius} Q${w} ${h} ${w - radius} ${h} H${radius} Q0 ${h} 0 ${h - radius} V${radius} Q0 0 ${radius} 0 Z M${edge} ${edge} V${h - edge} H${w - edge} V${edge} Z`}
|
||||
/>
|
||||
{el.showOutline && (
|
||||
<OutlinePath code={c.code} box={outlineBox} x={edge + w * 0.32} y={h * 0.46} fill={el.accent} />
|
||||
)}
|
||||
<rect
|
||||
x={stampX}
|
||||
y={stampY}
|
||||
width={stampW}
|
||||
height={stampH}
|
||||
fill="none"
|
||||
stroke={el.accent}
|
||||
strokeWidth={edge * 0.18}
|
||||
strokeDasharray={`${edge * 0.22} ${edge * 0.22}`}
|
||||
/>
|
||||
<rect
|
||||
x={stampX + edge * 0.35}
|
||||
y={stampY + edge * 0.35}
|
||||
width={stampW - edge * 0.7}
|
||||
height={stampH - edge * 0.7}
|
||||
fill={inkAt(el.accent, 0.14)}
|
||||
/>
|
||||
<circle
|
||||
cx={stampX}
|
||||
cy={stampY + stampH * 0.75}
|
||||
r={stampW * 0.42}
|
||||
fill="none"
|
||||
stroke={inkAt(el.color, 0.45)}
|
||||
strokeWidth={edge * 0.12}
|
||||
/>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<path
|
||||
key={i}
|
||||
d={wave(stampY + stampH * 0.58 + i * edge * 0.8)}
|
||||
fill="none"
|
||||
stroke={inkAt(el.color, 0.45)}
|
||||
strokeWidth={edge * 0.12}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: mm(stampX + edge * 0.35),
|
||||
top: mm(stampY + edge * 0.35),
|
||||
width: mm(stampW - edge * 0.7),
|
||||
height: mm(stampH - edge * 0.7),
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{el.showFlag ? (
|
||||
<FlagMark code={c.code} size={(stampW - edge * 0.7) * 0.5} />
|
||||
) : (
|
||||
<span
|
||||
style={{ fontSize: mm(stampW * 0.3), fontWeight: heavy(el), color: el.accent, letterSpacing: '0.04em' }}
|
||||
>
|
||||
{c.code.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{el.showName && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: mm(edge * 2.2),
|
||||
bottom: mm(edge * 1.9),
|
||||
maxWidth: mm(w * 0.62),
|
||||
fontSize: mm(type),
|
||||
fontWeight: heavy(el),
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
lineHeight: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
color: el.color,
|
||||
}}
|
||||
>
|
||||
{c.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The looks drawn in CountryLooksMore.tsx, by name. */
|
||||
const MORE: Record<string, (props: CountryArt) => ReactNode> = {
|
||||
visa: Visa,
|
||||
flag: FlagCloth,
|
||||
pin: Pin,
|
||||
label: Label,
|
||||
sketch: SketchCountry,
|
||||
};
|
||||
|
||||
export function CountryLookView({ el, frameStyle }: { el: BookCountriesElement; frameStyle: CSSProperties }) {
|
||||
const countries: Country[] = el.codes.map((code, i) => ({ code, name: el.names[i] || code.toUpperCase() }));
|
||||
const n = Math.max(1, countries.length);
|
||||
const gap = Math.min(el.frame.w, el.frame.h) * 0.05;
|
||||
const cellW = (el.frame.w - gap * (n - 1)) / n;
|
||||
const cellH = el.frame.h;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...frameStyle,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(gap),
|
||||
fontFamily: fontStack(el.font),
|
||||
color: el.color,
|
||||
}}
|
||||
>
|
||||
{countries.map((c, i) => {
|
||||
const key = `${c.code}-${i}`;
|
||||
if (el.look === 'stamp') return <Stamp key={key} el={el} c={c} size={Math.min(cellW, cellH)} />;
|
||||
if (el.look === 'postcard') return <Postcard key={key} el={el} c={c} w={cellW} h={cellH} />;
|
||||
const Look = MORE[el.look ?? 'visa'] ?? Visa;
|
||||
return <Look key={key} el={el} code={c.code} name={c.name} w={cellW} h={cellH} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import type { BookCountriesElement } from '@trek/shared';
|
||||
import { useId } from 'react';
|
||||
import { fontStack } from '../bookFonts';
|
||||
import { COUNTRY_SHAPES } from '../countryShapes';
|
||||
import { inkOn } from './badgeLook';
|
||||
import { FlagMark } from './FlagMark';
|
||||
import { hatch, roughLine, seeded } from './sketch';
|
||||
import { heavy, inkAt, light, mm } from './travelInk';
|
||||
|
||||
/**
|
||||
* The country looks drawn after real things: an entry stamp, the flag in
|
||||
* cloth, a map pin, an old luggage label and a pencil sketch. CountryLooks.tsx
|
||||
* picks the look; each draws one country in the element's ink and accent.
|
||||
*/
|
||||
|
||||
export interface CountryArt {
|
||||
el: BookCountriesElement;
|
||||
code: string;
|
||||
name: string;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** A country's outline fitted into a box, centred, keeping its proportions. */
|
||||
export function OutlinePath({
|
||||
code,
|
||||
box,
|
||||
x,
|
||||
y,
|
||||
fill,
|
||||
stroke,
|
||||
strokeWidth,
|
||||
}: {
|
||||
code: string;
|
||||
box: number;
|
||||
x: number;
|
||||
y: number;
|
||||
fill: string;
|
||||
stroke?: string;
|
||||
strokeWidth?: number;
|
||||
}) {
|
||||
const shape = COUNTRY_SHAPES[code.toUpperCase()];
|
||||
if (!shape) return null;
|
||||
const scale = box / Math.max(shape.w, shape.h);
|
||||
const ox = x - (shape.w * scale) / 2;
|
||||
const oy = y - (shape.h * scale) / 2;
|
||||
return (
|
||||
<path
|
||||
d={shape.d}
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth ? strokeWidth / scale : undefined}
|
||||
strokeLinejoin="round"
|
||||
transform={`translate(${ox} ${oy}) scale(${scale})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The name as an element sets it: heavy, tracked capitals, cut short rather than spilling. */
|
||||
function Name({
|
||||
el,
|
||||
name,
|
||||
size,
|
||||
color,
|
||||
spacing = '0.14em',
|
||||
}: {
|
||||
el: BookCountriesElement;
|
||||
name: string;
|
||||
size: number;
|
||||
color: string;
|
||||
spacing?: string;
|
||||
}) {
|
||||
if (!el.showName) return null;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: mm(size),
|
||||
fontWeight: heavy(el),
|
||||
letterSpacing: spacing,
|
||||
textTransform: 'uppercase',
|
||||
lineHeight: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const nameSize = (name: string, width: number, cap: number) =>
|
||||
Math.max(1.2, Math.min(cap, width / Math.max(4, name.length * 0.82)));
|
||||
|
||||
/**
|
||||
* An entry stamp: a box with its corners cut, ruled twice in the accent, a
|
||||
* row of stars over the name and the code under it, set a few degrees off
|
||||
* square and a shade short of solid the way it lands in a passport.
|
||||
*/
|
||||
export function Visa({ el, code, name, w, h }: CountryArt) {
|
||||
const c = Math.min(w, h) * 0.16;
|
||||
const line = Math.max(0.25, Math.min(w, h) * 0.03);
|
||||
const cut = (i: number) => {
|
||||
const o = i * line * 2.2;
|
||||
return `M${c + o} ${o} H${w - c - o} L${w - o} ${c + o} V${h - c - o} L${w - c - o} ${h - o} H${c + o} L${o} ${h - c - o} V${c + o} Z`;
|
||||
};
|
||||
const type = nameSize(name, w * 0.74, h * 0.2) * el.textScale;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: mm(w),
|
||||
height: mm(h),
|
||||
transform: 'rotate(3deg)',
|
||||
opacity: 0.92,
|
||||
color: el.accent,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0, overflow: 'visible' }}
|
||||
aria-hidden
|
||||
>
|
||||
<path d={cut(0.5)} fill="none" stroke={el.accent} strokeWidth={line} />
|
||||
<path d={cut(1.4)} fill="none" stroke={el.accent} strokeWidth={line * 0.45} />
|
||||
{el.showOutline && (
|
||||
<OutlinePath
|
||||
code={code}
|
||||
box={Math.min(w, h) * 0.5}
|
||||
x={w * 0.84 - c * 0.4}
|
||||
y={h * 0.5}
|
||||
fill={inkAt(el.accent, 0.16)}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(h * 0.06),
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: mm(type * 0.5), letterSpacing: '0.3em' }}>★ ★ ★</span>
|
||||
<Name el={el} name={name} size={type} color={el.accent} />
|
||||
<span style={{ fontSize: mm(type * 0.42), fontWeight: light(el), letterSpacing: '0.4em' }}>
|
||||
{code.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The flag in cloth: the flag drawn by flags.ts with light and shade laid
|
||||
* over it in folds, so it reads as fabric hanging from its pole, and the name
|
||||
* under it.
|
||||
*/
|
||||
export function FlagCloth({ el, code, name, w, h }: CountryArt) {
|
||||
const flagH = h * 0.62;
|
||||
const flagW = Math.min(w * 0.82, flagH * 1.5);
|
||||
const type = nameSize(name, w * 0.86, h * 0.14) * el.textScale;
|
||||
const fold = (a: number) => inkAt(el.color, a);
|
||||
const sheen = inkAt(inkOn(el.color), 0.32);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: mm(w),
|
||||
height: mm(h),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(h * 0.07),
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'stretch' }}>
|
||||
<span
|
||||
style={{
|
||||
width: mm(flagH * 0.035),
|
||||
borderRadius: mm(flagH),
|
||||
background: el.color,
|
||||
marginInlineEnd: mm(flagH * 0.01),
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: mm(flagW),
|
||||
height: mm(flagH),
|
||||
overflow: 'hidden',
|
||||
borderRadius: `0 ${mm(flagH * 0.04)} ${mm(flagH * 0.04)} 0`,
|
||||
boxShadow: `0 ${mm(flagH * 0.04)} ${mm(flagH * 0.1)} ${inkAt(el.color, 0.25)}`,
|
||||
transform: 'perspective(60mm) rotateY(-8deg)',
|
||||
transformOrigin: 'left center',
|
||||
}}
|
||||
>
|
||||
<span style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'stretch' }}>
|
||||
<FlagStretch code={code} w={flagW} h={flagH} />
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: `linear-gradient(90deg, ${fold(0.18)} 0%, ${sheen} 14%, ${fold(0.05)} 30%, ${fold(0.22)} 46%, ${sheen} 62%, ${fold(0.08)} 78%, ${fold(0.24)} 100%)`,
|
||||
mixBlendMode: 'soft-light',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<Name el={el} name={name} size={type} color={el.color} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The flag filling its cloth: flags.ts draws it at its own ratio, so it is scaled to cover. */
|
||||
function FlagStretch({ code, w, h }: { code: string; w: number; h: number }) {
|
||||
return (
|
||||
<span style={{ width: mm(w), height: mm(h), display: 'grid', placeItems: 'center', overflow: 'hidden' }}>
|
||||
<span style={{ transform: `scale(${Math.max(1, w / h / 1.5)})` }}>
|
||||
<FlagMark code={code} size={h} />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A map pin: the teardrop in the accent standing on its shadow, the flag in
|
||||
* a disc in its head, and the name under it.
|
||||
*/
|
||||
export function Pin({ el, code, name, w, h }: CountryArt) {
|
||||
const pinH = h * 0.7;
|
||||
const r = pinH * 0.36;
|
||||
const cx = w / 2;
|
||||
const cy = r * 1.05;
|
||||
const tip = pinH;
|
||||
const type = nameSize(name, w * 0.9, h * 0.14) * el.textScale;
|
||||
const id = useId().replace(/:/g, '');
|
||||
return (
|
||||
<div style={{ position: 'relative', width: mm(w), height: mm(h) }}>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0 }}
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id={`${id}-shine`} cx="35%" cy="30%" r="70%">
|
||||
<stop offset="0%" stopColor={inkOn(el.accent)} stopOpacity={0.35} />
|
||||
<stop offset="60%" stopColor={inkOn(el.accent)} stopOpacity={0} />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<ellipse cx={cx} cy={tip + r * 0.12} rx={r * 0.6} ry={r * 0.16} fill={inkAt(el.color, 0.22)} />
|
||||
<path
|
||||
d={`M${cx} ${tip} C${cx - r * 0.35} ${cy + r * 1.1} ${cx - r} ${cy + r * 0.6} ${cx - r} ${cy} A${r} ${r} 0 1 1 ${cx + r} ${cy} C${cx + r} ${cy + r * 0.6} ${cx + r * 0.35} ${cy + r * 1.1} ${cx} ${tip} Z`}
|
||||
fill={el.accent}
|
||||
/>
|
||||
<path
|
||||
d={`M${cx} ${tip} C${cx - r * 0.35} ${cy + r * 1.1} ${cx - r} ${cy + r * 0.6} ${cx - r} ${cy} A${r} ${r} 0 1 1 ${cx + r} ${cy} C${cx + r} ${cy + r * 0.6} ${cx + r * 0.35} ${cy + r * 1.1} ${cx} ${tip} Z`}
|
||||
fill={`url(#${id}-shine)`}
|
||||
/>
|
||||
<circle cx={cx} cy={cy} r={r * 0.66} fill={inkOn(el.accent)} />
|
||||
</svg>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: mm(cx - r * 0.56),
|
||||
top: mm(cy - r * 0.56),
|
||||
width: mm(r * 1.12),
|
||||
height: mm(r * 1.12),
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{el.showFlag ? (
|
||||
<FlagMark code={code} size={r * 1.12} />
|
||||
) : (
|
||||
<OutlineBadge code={code} size={r * 1.0} fill={el.accent} />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: 0,
|
||||
insetInlineEnd: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Name el={el} name={name} size={type} color={el.color} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OutlineBadge({ code, size, fill }: { code: string; size: number; fill: string }) {
|
||||
return (
|
||||
<svg width={mm(size)} height={mm(size)} viewBox={`0 0 ${size} ${size}`} aria-hidden>
|
||||
<OutlinePath code={code} box={size * 0.86} x={size / 2} y={size / 2} fill={fill} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An old luggage label: an oval ruled twice, tinted inside, the outline in
|
||||
* the middle under a sweep of stars, and the name on a ribbon across it with
|
||||
* its ends folded back.
|
||||
*/
|
||||
export function Label({ el, code, name, w, h }: CountryArt) {
|
||||
const rx = w / 2;
|
||||
const ry = h * 0.42;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const line = Math.max(0.25, Math.min(w, h) * 0.022);
|
||||
const bandH = h * 0.22;
|
||||
const bandW = w * 0.92;
|
||||
const bx = cx - bandW / 2;
|
||||
const by = cy + ry * 0.18;
|
||||
const fold = bandH * 0.5;
|
||||
const type = nameSize(name, bandW * 0.78, bandH * 0.56) * el.textScale;
|
||||
const ink = inkOn(el.accent);
|
||||
return (
|
||||
<div style={{ position: 'relative', width: mm(w), height: mm(h) }}>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0, overflow: 'visible' }}
|
||||
aria-hidden
|
||||
>
|
||||
<ellipse
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
rx={rx - line}
|
||||
ry={ry}
|
||||
fill={inkAt(el.accent, 0.12)}
|
||||
stroke={el.accent}
|
||||
strokeWidth={line}
|
||||
/>
|
||||
<ellipse
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
rx={rx - line * 3.2}
|
||||
ry={ry - line * 2.2}
|
||||
fill="none"
|
||||
stroke={el.accent}
|
||||
strokeWidth={line * 0.45}
|
||||
/>
|
||||
{el.showOutline && (
|
||||
<OutlinePath code={code} box={Math.min(w * 0.36, ry * 1.1)} x={cx} y={cy - ry * 0.28} fill={el.accent} />
|
||||
)}
|
||||
<path
|
||||
d={`M${bx - fold} ${by + fold * 0.5} H${bx + fold} V${by + bandH + fold * 0.5} H${bx - fold} L${bx - fold * 0.4} ${by + bandH / 2 + fold * 0.5} Z`}
|
||||
fill={inkAt(el.color, 0.55)}
|
||||
/>
|
||||
<path
|
||||
d={`M${bx + bandW + fold} ${by + fold * 0.5} H${bx + bandW - fold} V${by + bandH + fold * 0.5} H${bx + bandW + fold} L${bx + bandW + fold * 0.4} ${by + bandH / 2 + fold * 0.5} Z`}
|
||||
fill={inkAt(el.color, 0.55)}
|
||||
/>
|
||||
<rect x={bx} y={by} width={bandW} height={bandH} fill={el.accent} />
|
||||
</svg>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: mm(bx),
|
||||
top: mm(by),
|
||||
width: mm(bandW),
|
||||
height: mm(bandH),
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Name el={el} name={name} size={type} color={ink} spacing="0.2em" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The country drawn by hand: its outline traced twice in pencil, slightly
|
||||
* apart, hatched inside in the accent, and its name written under it in the
|
||||
* element's face, a script one when it comes from the panel.
|
||||
*/
|
||||
export function SketchCountry({ el, code, name, w, h }: CountryArt) {
|
||||
const id = useId().replace(/:/g, '');
|
||||
const rand = seeded(`${code}:${name}`);
|
||||
const box = Math.min(w * 0.86, h * 0.68);
|
||||
const cx = w / 2;
|
||||
const cy = h * 0.4;
|
||||
const pen = Math.max(0.2, box * 0.012);
|
||||
const shade = hatch({ x: cx - box / 2, y: cy - box / 2, w: box, h: box }, box * 0.045, { rand, wobble: box * 0.008 });
|
||||
const type = nameSize(name, w * 0.9, h * 0.16) * el.textScale;
|
||||
const under = roughLine(w * 0.2, h * 0.93, w * 0.8, h * 0.91, { rand, wobble: h * 0.012 });
|
||||
return (
|
||||
<div style={{ position: 'relative', width: mm(w), height: mm(h) }}>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0, overflow: 'visible' }}
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<clipPath id={`${id}-land`}>
|
||||
<OutlinePath code={code} box={box} x={cx} y={cy} fill={el.accent} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<path
|
||||
d={shade}
|
||||
fill="none"
|
||||
stroke={el.accent}
|
||||
strokeWidth={pen * 0.8}
|
||||
strokeLinecap="round"
|
||||
opacity={0.75}
|
||||
clipPath={`url(#${id}-land)`}
|
||||
/>
|
||||
{el.showOutline && (
|
||||
<>
|
||||
<OutlinePath code={code} box={box} x={cx} y={cy} fill="none" stroke={el.color} strokeWidth={pen} />
|
||||
<g transform={`translate(${pen * 0.9} ${-pen * 0.6})`} opacity={0.55}>
|
||||
<OutlinePath
|
||||
code={code}
|
||||
box={box * 1.01}
|
||||
x={cx}
|
||||
y={cy}
|
||||
fill="none"
|
||||
stroke={el.color}
|
||||
strokeWidth={pen * 0.7}
|
||||
/>
|
||||
</g>
|
||||
</>
|
||||
)}
|
||||
<path d={under} fill="none" stroke={el.accent} strokeWidth={pen * 1.4} strokeLinecap="round" />
|
||||
</svg>
|
||||
{el.showName && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: 0,
|
||||
insetInlineEnd: 0,
|
||||
bottom: mm(h * 0.1),
|
||||
textAlign: 'center',
|
||||
fontFamily: fontStack(el.font),
|
||||
fontSize: mm(type),
|
||||
fontWeight: heavy(el),
|
||||
color: el.color,
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Where a journey began and where it ended, for a map whose stops are marked
|
||||
* by their ends. The start is a ring around a dot, the way a route planner
|
||||
* marks its origin; the finish is a disc carrying a pennant. Both sit on a
|
||||
* soft shadow so they stand clear of the line through them.
|
||||
*/
|
||||
export function EndMarker({
|
||||
x,
|
||||
y,
|
||||
r,
|
||||
finish,
|
||||
ink,
|
||||
paper,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
/** The marker's radius in millimetres, the size a numbered stop has. */
|
||||
r: number;
|
||||
finish: boolean;
|
||||
/** The line's colour, and the colour that reads on it. */
|
||||
ink: string;
|
||||
paper: string;
|
||||
}) {
|
||||
const R = r * 1.15;
|
||||
return (
|
||||
<g>
|
||||
<circle cx={x} cy={y + R * 0.12} r={R * 1.12} fill="#000000" fillOpacity={0.14} />
|
||||
{finish ? (
|
||||
<>
|
||||
<circle cx={x} cy={y} r={R} fill={ink} stroke={paper} strokeWidth={R * 0.16} />
|
||||
<line
|
||||
x1={x - R * 0.28}
|
||||
y1={y + R * 0.5}
|
||||
x2={x - R * 0.28}
|
||||
y2={y - R * 0.52}
|
||||
stroke={paper}
|
||||
strokeWidth={R * 0.14}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d={`M${x - R * 0.28} ${y - R * 0.52} L${x + R * 0.5} ${y - R * 0.27} L${x - R * 0.28} ${y - R * 0.02} Z`}
|
||||
fill={paper}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<circle cx={x} cy={y} r={R} fill={paper} stroke={ink} strokeWidth={R * 0.3} />
|
||||
<circle cx={x} cy={y} r={R * 0.34} fill={ink} />
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { COUNTRY_SHAPES } from '../countryShapes';
|
||||
import { FLAG_H, FLAG_W, flagBands, flagDisc, flagSpec } from '../flags';
|
||||
import { round2 } from './travelInk';
|
||||
|
||||
/**
|
||||
* A flag, drawn.
|
||||
*
|
||||
* The first version set a regional-indicator pair and let the platform draw it.
|
||||
* That is the standard trick and it is wrong for most of the people who will
|
||||
* use this: Chrome on Windows ships no flag glyphs, so it renders the two
|
||||
* letters, and "DE" printed in a photo book is not a flag.
|
||||
*
|
||||
* So flags are constructed from `flags.ts` — exact for the stripe, cross and
|
||||
* disc constructions listed there, and absent for anything with an emblem
|
||||
* rather than approximated. A country with no construction falls back to its
|
||||
* silhouette, which is correct for all 198 of them.
|
||||
*/
|
||||
export function FlagMark({ code, size }: { code: string; size: number }) {
|
||||
const spec = flagSpec(code);
|
||||
const height = size;
|
||||
const width = (height * FLAG_W) / FLAG_H;
|
||||
|
||||
if (spec) {
|
||||
const disc = flagDisc(spec);
|
||||
return (
|
||||
<svg
|
||||
width={`${round2(width)}mm`}
|
||||
height={`${round2(height)}mm`}
|
||||
viewBox={`0 0 ${FLAG_W} ${FLAG_H}`}
|
||||
style={{ display: 'block', flex: '0 0 auto', borderRadius: `${round2(height * 0.08)}mm` }}
|
||||
aria-label={code.toUpperCase()}
|
||||
>
|
||||
{flagBands(spec).map((b, i) => (
|
||||
<rect key={i} x={b.x} y={b.y} width={b.w} height={b.h} fill={b.fill} />
|
||||
))}
|
||||
{disc && <circle cx={disc.cx} cy={disc.cy} r={disc.r} fill={disc.fill} />}
|
||||
{/* A hairline keeps a white-edged flag off a white page. */}
|
||||
<rect x="0" y="0" width={FLAG_W} height={FLAG_H} fill="none" stroke="rgba(0,0,0,.18)" strokeWidth="0.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const shape = COUNTRY_SHAPES[code.toUpperCase()];
|
||||
if (!shape) {
|
||||
// Neither a construction nor an outline: the code, set as a mark rather
|
||||
// than as two stray capitals.
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${round2(size * 0.62)}mm`,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
lineHeight: 1,
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
{code.toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
width={`${round2((height * shape.w) / shape.h)}mm`}
|
||||
height={`${round2(height)}mm`}
|
||||
viewBox={`0 0 ${shape.w} ${shape.h}`}
|
||||
style={{ display: 'block', flex: '0 0 auto' }}
|
||||
aria-label={code.toUpperCase()}
|
||||
>
|
||||
<path d={shape.d} fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
import type { BookStatsElement } from '@trek/shared';
|
||||
import { useId, type CSSProperties, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { fontStack } from '../bookFonts';
|
||||
import { inkOn } from './badgeLook';
|
||||
import { chars, fitAll, fitCaption, fitType, gridOf, type Figure } from './statsFit';
|
||||
import { NoteLook, OdometerLook, SignLook, SketchLook } from './StatsLooksMore';
|
||||
import { Caption, FigureText, IconDisc, IconDiscOn } from './statsParts';
|
||||
import { heavy, inkAt, METRIC_ICONS, metricValue, mm } from './travelInk';
|
||||
|
||||
/**
|
||||
* The newer looks of a journey summary: a poster, a ticket, tiles, a band and
|
||||
* a medal. The classic look, icon over figure over caption, stays in
|
||||
* TravelElements.tsx untouched, so every summary placed before these existed
|
||||
* prints as it always has.
|
||||
*
|
||||
* They are drawn in the element's two colours: the ink for the words and the
|
||||
* accent for what carries them, a disc, a stub, a tile, a ring. Placed from
|
||||
* the panel both are linked to the book's theme, so a summary takes the
|
||||
* theme's colours and follows them. Everything is sized from the frame and the
|
||||
* length of what it prints, never from a fixed point size, so a summary
|
||||
* dragged from a corner to a whole page grows its figures with it.
|
||||
*/
|
||||
|
||||
export interface LookProps {
|
||||
el: BookStatsElement;
|
||||
figures: Figure[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The poster: the first figure set as large as the frame allows, over a bar in
|
||||
* the accent, with its name and icon above it; the others in a column beside
|
||||
* it, each with its own disc, between hairlines. The order of the figures is
|
||||
* the order of importance, so the one somebody put first is the page's.
|
||||
*/
|
||||
function PosterLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const [hero, ...rest] = figures;
|
||||
if (!hero) return null;
|
||||
const heroW = rest.length ? w * 0.58 : w;
|
||||
const big = fitType(chars(hero), heroW * 0.92, h * 0.5) * el.textScale;
|
||||
const row = h / Math.max(1, rest.length);
|
||||
const disc = Math.min(row * 0.46, (w - heroW) * 0.16);
|
||||
const small = fitAll(rest, (w - heroW) * 0.62, row * 0.36) * el.textScale;
|
||||
const hair = `${mm(Math.max(0.18, h * 0.005))} solid ${inkAt(el.color, 0.16)}`;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
gap: mm(h * 0.045),
|
||||
width: mm(heroW),
|
||||
minWidth: 0,
|
||||
paddingInlineEnd: mm(w * 0.03),
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: mm(big * 0.12) }}>
|
||||
{el.showIcons && <IconDisc metric={hero.metric} size={big * 0.32} el={el} solid />}
|
||||
<Caption text={hero.label} size={Math.max(1.2, big * 0.13)} el={el} color={el.accent} opacity={1} />
|
||||
</span>
|
||||
<FigureText f={hero} size={big} el={el} color={el.color} unitColor={el.accent} />
|
||||
<span
|
||||
style={{
|
||||
width: mm(Math.min(heroW * 0.22, h * 0.3)),
|
||||
height: mm(Math.max(0.6, h * 0.028)),
|
||||
borderRadius: mm(h),
|
||||
background: el.accent,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{rest.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
borderInlineStart: hair,
|
||||
paddingInlineStart: mm(w * 0.045),
|
||||
}}
|
||||
>
|
||||
{rest.map((f, i) => (
|
||||
<div
|
||||
key={f.metric}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: mm(disc * 0.45),
|
||||
minHeight: 0,
|
||||
borderTop: i ? hair : undefined,
|
||||
}}
|
||||
>
|
||||
{el.showIcons && <IconDisc metric={f.metric} size={disc} el={el} />}
|
||||
<span style={{ display: 'flex', flexDirection: 'column', gap: mm(small * 0.1), minWidth: 0 }}>
|
||||
<FigureText f={f} size={small} el={el} color={el.color} unitColor={el.accent} />
|
||||
<Caption text={f.label} size={Math.max(1.2, small * 0.3)} el={el} />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A ticket: the outline with a notch bitten out either side of the tear, the
|
||||
* stub solid in the accent with the first figure on it, and the others as the
|
||||
* fields of a boarding pass, name over value, over a barcode drawn from the
|
||||
* figures themselves, so two journeys never print the same one.
|
||||
*/
|
||||
function TicketLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const id = useId().replace(/:/g, '');
|
||||
const [hero, ...rest] = figures;
|
||||
if (!hero) return null;
|
||||
const stubW = rest.length ? w * 0.34 : w;
|
||||
const notch = Math.min(h * 0.075, w * 0.035);
|
||||
const radius = Math.min(h * 0.09, w * 0.045);
|
||||
const cols = Math.min(3, Math.max(1, rest.length));
|
||||
const rows = Math.ceil(rest.length / cols);
|
||||
const fieldW = ((w - stubW) * 0.86) / cols;
|
||||
const fieldH = (h * 0.56) / Math.max(1, rows);
|
||||
const value = fitAll(rest, fieldW * 0.92, fieldH * 0.56) * el.textScale;
|
||||
const caption = fitCaption(
|
||||
rest.map((f) => f.label),
|
||||
fieldW,
|
||||
value * 0.3
|
||||
);
|
||||
const big = fitType(chars(hero), stubW * 0.78, h * 0.36) * el.textScale;
|
||||
const onStub = inkOn(el.accent);
|
||||
return (
|
||||
<>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0 }}
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<clipPath id={`${id}-ticket`} clipPathUnits="userSpaceOnUse">
|
||||
<path d={ticketPath(w, h, rest.length ? stubW : null, notch, radius)} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clipPath={`url(#${id}-ticket)`}>
|
||||
<rect x={0} y={0} width={w} height={h} fill={inkAt(el.accent, 0.1)} />
|
||||
<rect x={0} y={0} width={stubW} height={h} fill={el.accent} />
|
||||
</g>
|
||||
{rest.length > 0 && (
|
||||
<line
|
||||
x1={stubW}
|
||||
x2={stubW}
|
||||
y1={notch * 1.7}
|
||||
y2={h - notch * 1.7}
|
||||
stroke={inkAt(el.color, 0.32)}
|
||||
strokeWidth={Math.max(0.2, h * 0.008)}
|
||||
strokeDasharray={`${h * 0.025} ${h * 0.022}`}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: mm(stubW),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(h * 0.035),
|
||||
}}
|
||||
>
|
||||
{el.showIcons &&
|
||||
(() => {
|
||||
const Icon = METRIC_ICONS[hero.metric];
|
||||
return (
|
||||
<Icon
|
||||
color={onStub}
|
||||
strokeWidth={2}
|
||||
style={{ width: mm(big * 0.34), height: mm(big * 0.34), display: 'block', opacity: 0.85 }}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<FigureText f={hero} size={big} el={el} color={onStub} />
|
||||
<Caption
|
||||
text={hero.label}
|
||||
size={fitCaption([hero.label], stubW * 0.8, big * 0.18)}
|
||||
el={el}
|
||||
color={onStub}
|
||||
opacity={0.82}
|
||||
/>
|
||||
</div>
|
||||
{rest.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
padding: `0 ${mm(w * 0.045)}`,
|
||||
gap: mm(h * 0.07),
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||
rowGap: mm(h * 0.06),
|
||||
columnGap: mm(w * 0.02),
|
||||
}}
|
||||
>
|
||||
{rest.map((f) => (
|
||||
<div key={f.metric} style={{ display: 'flex', flexDirection: 'column', gap: mm(h * 0.02), minWidth: 0 }}>
|
||||
<Caption text={f.label} size={caption} el={el} color={el.accent} opacity={1} />
|
||||
<FigureText f={f} size={value} el={el} color={el.color} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Barcode figures={figures} color={el.color} width={(w - stubW) * 0.56} height={h * 0.13} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ticket's outline: a rounded rectangle with a half circle bitten out of
|
||||
* the top and the bottom edge where the stub tears off. One path, so the page
|
||||
* shows through the notches whatever lies under the ticket.
|
||||
*/
|
||||
function ticketPath(w: number, h: number, stubX: number | null, n: number, r: number): string {
|
||||
const top = stubX == null ? '' : ` L${stubX - n} 0 A${n} ${n} 0 0 0 ${stubX + n} 0`;
|
||||
const bottom = stubX == null ? '' : ` L${stubX + n} ${h} A${n} ${n} 0 0 0 ${stubX - n} ${h}`;
|
||||
return [
|
||||
`M${r} 0`,
|
||||
top,
|
||||
` L${w - r} 0 Q${w} 0 ${w} ${r}`,
|
||||
` L${w} ${h - r} Q${w} ${h} ${w - r} ${h}`,
|
||||
bottom,
|
||||
` L${r} ${h} Q0 ${h} 0 ${h - r}`,
|
||||
` L0 ${r} Q0 0 ${r} 0 Z`,
|
||||
].join('');
|
||||
}
|
||||
|
||||
/** Bars whose widths come from the figures' digits: decoration that is still the journey's own. */
|
||||
function Barcode({
|
||||
figures,
|
||||
color,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
figures: Figure[];
|
||||
color: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}) {
|
||||
const digits = figures.map((f) => f.value.replace(/\D/g, '')).join('') || '0';
|
||||
const bars = Array.from({ length: 30 }, (_, i) => 1 + ((Number(digits[i % digits.length]) + i * 7) % 3));
|
||||
const unit = width / bars.reduce((sum, b) => sum + b + 1, 0);
|
||||
const starts = bars.reduce<number[]>(
|
||||
(acc, b, i) => [...acc, i ? (acc[i - 1] ?? 0) + ((bars[i - 1] ?? 0) + 1) * unit : 0],
|
||||
[]
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
width={mm(width)}
|
||||
height={mm(height)}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
aria-hidden
|
||||
style={{ display: 'block', opacity: 0.8 }}
|
||||
>
|
||||
{bars.map((b, i) => (
|
||||
<rect key={i} x={starts[i]} y={0} width={b * unit} height={height} fill={color} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A tile per figure, rounded and tinted in the accent, the first one solid so
|
||||
* the eye lands there; each with its icon in a disc in the corner and the
|
||||
* figure set large along the foot.
|
||||
*/
|
||||
function TilesLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const { cols, rows } = gridOf(figures.length);
|
||||
const gap = Math.min(w, h) * 0.04;
|
||||
const tileW = (w - gap * (cols - 1)) / cols;
|
||||
const tileH = (h - gap * (rows - 1)) / rows;
|
||||
const pad = Math.min(tileW, tileH) * 0.12;
|
||||
const size = fitAll(figures, tileW - pad * 2, tileH * 0.34) * el.textScale;
|
||||
const caption = fitCaption(
|
||||
figures.map((f) => f.label),
|
||||
tileW - pad * 2,
|
||||
size * 0.24
|
||||
);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||
gridAutoRows: '1fr',
|
||||
gap: mm(gap),
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{figures.map((f, i) => {
|
||||
const solid = i === 0;
|
||||
const ink = solid ? inkOn(el.accent) : el.color;
|
||||
return (
|
||||
<div
|
||||
key={f.metric}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
padding: mm(pad),
|
||||
borderRadius: mm(Math.min(tileW, tileH) * 0.16),
|
||||
background: solid ? el.accent : inkAt(el.accent, 0.12),
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{el.showIcons ? <IconDiscOn metric={f.metric} size={size * 0.62} el={el} solid={solid} /> : <span />}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: mm(size * 0.1), minWidth: 0 }}>
|
||||
<FigureText f={f} size={size} el={el} color={ink} unitColor={solid ? ink : el.accent} />
|
||||
<Caption text={f.label} size={caption} el={el} color={ink} opacity={solid ? 0.85 : 0.6} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A band: the figures in one line under a rule in the accent and over a
|
||||
* hairline, each in its own column with its icon beside its name, divided by
|
||||
* hairlines. The strip a page of photographs can carry along its foot.
|
||||
*/
|
||||
function BandLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const cellW = w / Math.max(1, figures.length);
|
||||
const size = fitAll(figures, cellW * 0.8, h * 0.42) * el.textScale;
|
||||
const caption = fitCaption(
|
||||
figures.map((f) => f.label),
|
||||
cellW * 0.62,
|
||||
size * 0.26
|
||||
);
|
||||
const hair = `${mm(Math.max(0.18, h * 0.01))} solid ${inkAt(el.color, 0.18)}`;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderTop: `${mm(Math.max(0.6, h * 0.045))} solid ${el.accent}`,
|
||||
borderBottom: hair,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{figures.map((f, i) => (
|
||||
<div
|
||||
key={f.metric}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
gap: mm(h * 0.06),
|
||||
paddingInline: mm(cellW * 0.08),
|
||||
borderInlineStart: i ? hair : undefined,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<FigureText f={f} size={size} el={el} color={el.color} unitColor={el.accent} />
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: mm(caption * 0.5), minWidth: 0 }}>
|
||||
{el.showIcons && <IconDisc metric={f.metric} size={caption * 2} el={el} />}
|
||||
<Caption text={f.label} size={caption} el={el} />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A medal per figure: a ring in the accent round a tinted disc, the figure in
|
||||
* the middle with its icon above it, and its name running round the lower rim
|
||||
* between two dots, the way a medal or a seal is lettered.
|
||||
*/
|
||||
function MedalLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const gap = Math.min(w, h) * 0.06;
|
||||
const d = Math.min(h, (w - gap * (figures.length - 1)) / Math.max(1, figures.length));
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(gap),
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{figures.map((f) => (
|
||||
<Medal key={f.metric} f={f} d={d} el={el} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Medal({ f, d, el }: { f: Figure; d: number; el: BookStatsElement }) {
|
||||
const id = useId().replace(/:/g, '');
|
||||
const r = d / 2;
|
||||
const ring = d * 0.035;
|
||||
const size = fitType(chars(f), d * 0.56, d * 0.26) * el.textScale;
|
||||
const rim = r * 0.8;
|
||||
const label = f.label.toUpperCase();
|
||||
const type = Math.min(d * 0.075, (Math.PI * rim * 0.8) / Math.max(4, label.length * 0.78));
|
||||
const Icon = METRIC_ICONS[f.metric];
|
||||
return (
|
||||
<div style={{ position: 'relative', width: mm(d), height: mm(d), flex: '0 0 auto' }}>
|
||||
<svg
|
||||
width={mm(d)}
|
||||
height={mm(d)}
|
||||
viewBox={`0 0 ${d} ${d}`}
|
||||
style={{ position: 'absolute', inset: 0 }}
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<path id={`${id}-rim`} d={`M ${r - rim} ${r} A ${rim} ${rim} 0 0 0 ${r + rim} ${r}`} />
|
||||
</defs>
|
||||
<circle cx={r} cy={r} r={r - ring / 2} fill={inkAt(el.accent, 0.1)} stroke={el.accent} strokeWidth={ring} />
|
||||
<circle
|
||||
cx={r}
|
||||
cy={r}
|
||||
r={r * 0.68}
|
||||
fill="none"
|
||||
stroke={el.accent}
|
||||
strokeWidth={ring * 0.4}
|
||||
strokeDasharray={`${ring * 0.8} ${ring * 1.2}`}
|
||||
/>
|
||||
<text
|
||||
fill={el.accent}
|
||||
style={{
|
||||
fontFamily: fontStack(el.font),
|
||||
fontWeight: heavy(el),
|
||||
fontSize: `${type}px`,
|
||||
letterSpacing: `${type * 0.22}px`,
|
||||
}}
|
||||
>
|
||||
<textPath href={`#${id}-rim`} startOffset="50%" textAnchor="middle" dominantBaseline="middle">
|
||||
{label}
|
||||
</textPath>
|
||||
</text>
|
||||
<circle cx={r - rim} cy={r} r={ring * 0.7} fill={el.accent} />
|
||||
<circle cx={r + rim} cy={r} r={ring * 0.7} fill={el.accent} />
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(d * 0.02),
|
||||
paddingBottom: mm(d * 0.08),
|
||||
}}
|
||||
>
|
||||
{el.showIcons && (
|
||||
<Icon
|
||||
color={el.accent}
|
||||
strokeWidth={2}
|
||||
style={{ width: mm(d * 0.13), height: mm(d * 0.13), display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
<FigureText f={f} size={size} el={el} color={el.color} unitColor={el.accent} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LOOKS: Record<string, (props: LookProps) => ReactNode> = {
|
||||
poster: PosterLook,
|
||||
ticket: TicketLook,
|
||||
tiles: TilesLook,
|
||||
band: BandLook,
|
||||
medal: MedalLook,
|
||||
odometer: OdometerLook,
|
||||
sign: SignLook,
|
||||
note: NoteLook,
|
||||
sketch: SketchLook,
|
||||
};
|
||||
|
||||
export function StatsLookView({ el, frameStyle }: { el: BookStatsElement; frameStyle: CSSProperties }) {
|
||||
const { t, locale } = useTranslation();
|
||||
const figures: Figure[] = el.metrics.map((metric) => ({
|
||||
metric,
|
||||
...metricValue(metric, el.values, el.units, locale),
|
||||
label: t(`journey.studio.metric.${metric}`),
|
||||
}));
|
||||
const Look = LOOKS[el.look ?? 'poster'] ?? PosterLook;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...frameStyle,
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
fontFamily: fontStack(el.font),
|
||||
color: el.color,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<Look el={el} figures={figures} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
import { inkOn } from './badgeLook';
|
||||
import { hatch, roughBox, roughCircle, roughLine, seeded } from './sketch';
|
||||
import { fitAll, fitCaption, fitType, gridOf, type Figure } from './statsFit';
|
||||
import type { LookProps } from './StatsLooks';
|
||||
import { Caption, FigureText } from './statsParts';
|
||||
import { heavy, inkAt, light, METRIC_ICONS, mm } from './travelInk';
|
||||
|
||||
/**
|
||||
* The summary looks drawn after real things: the drums of an odometer, a road
|
||||
* sign, a note written on ruled paper, and a pencil sketch. Each is the same
|
||||
* figures as the other looks, in the element's ink and accent, sized from the
|
||||
* frame; StatsLooks.tsx picks the look.
|
||||
*/
|
||||
|
||||
/**
|
||||
* An odometer: each figure on its own row of drums in a dark housing, the
|
||||
* digits printed on pale drums shaded where they turn away, padded with the
|
||||
* zeros a counter shows, the last drum in the accent the way the tenths are.
|
||||
*/
|
||||
export function OdometerLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const { cols, rows } = gridOf(figures.length);
|
||||
const cellW = w / cols;
|
||||
const cellH = h / rows;
|
||||
const paper = inkOn(el.color);
|
||||
const drumsOf = (f: Figure) => {
|
||||
const digits = f.value.replace(/\D/g, '');
|
||||
return digits.padStart(Math.max(4, digits.length), '0').split('');
|
||||
};
|
||||
const most = Math.max(...figures.map((f) => drumsOf(f).length));
|
||||
const drumW = Math.min((cellW * 0.82) / (most + 0.6), cellH * 0.34);
|
||||
const drumH = drumW * 1.45;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: mm(Math.min(w, h) * 0.06),
|
||||
background: `linear-gradient(180deg, ${inkAt(el.color, 0.88)}, ${el.color})`,
|
||||
boxShadow: `inset 0 0 0 ${mm(Math.min(w, h) * 0.012)} ${inkAt(paper, 0.12)}`,
|
||||
padding: mm(Math.min(w, h) * 0.04),
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{figures.map((f) => {
|
||||
const drums = drumsOf(f);
|
||||
const lead = drums.length - f.value.replace(/\D/g, '').length;
|
||||
return (
|
||||
<div
|
||||
key={f.metric}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(drumH * 0.22),
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: mm(drumW * 0.08) }}>
|
||||
{drums.map((d, i) => {
|
||||
const last = i === drums.length - 1 && drums.length > 1;
|
||||
const face = last ? el.accent : paper;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
width: mm(drumW),
|
||||
height: mm(drumH),
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
borderRadius: mm(drumW * 0.12),
|
||||
background: `linear-gradient(180deg, ${inkAt(el.color, 0.45)} 0%, ${face} 26%, ${face} 74%, ${inkAt(el.color, 0.45)} 100%)`,
|
||||
color: last ? inkOn(el.accent) : el.color,
|
||||
opacity: i < lead ? 0.55 : 1,
|
||||
fontSize: mm(drumH * 0.66),
|
||||
fontWeight: heavy(el),
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{d}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{f.unit && (
|
||||
<span
|
||||
style={{
|
||||
marginInlineStart: mm(drumW * 0.25),
|
||||
color: paper,
|
||||
fontSize: mm(drumH * 0.4),
|
||||
fontWeight: light(el),
|
||||
}}
|
||||
>
|
||||
{f.unit}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Caption
|
||||
text={f.label}
|
||||
size={fitCaption([f.label], cellW * 0.8, drumH * 0.26)}
|
||||
el={el}
|
||||
color={paper}
|
||||
opacity={0.75}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A road sign: a panel in the accent with a rule inset round its edge, an
|
||||
* arrow in its corner, and the figures listed the way a sign lists places,
|
||||
* the name on the left and the figure on the right, between hairlines.
|
||||
*/
|
||||
export function SignLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const ink = inkOn(el.accent);
|
||||
const inset = Math.min(w, h) * 0.035;
|
||||
const rowH = (h - inset * 4) / Math.max(1, figures.length);
|
||||
const value = fitAll(figures, w * 0.36, rowH * 0.62) * el.textScale;
|
||||
const label = fitCaption(
|
||||
figures.map((f) => f.label),
|
||||
w * 0.42,
|
||||
value * 0.62
|
||||
);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: mm(inset * 1.6),
|
||||
background: el.accent,
|
||||
padding: mm(inset * 2),
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
boxShadow: `inset 0 0 0 ${mm(inset * 0.6)} ${el.accent}, inset 0 0 0 ${mm(inset * 0.9)} ${ink}`,
|
||||
color: ink,
|
||||
}}
|
||||
>
|
||||
<ArrowUpRight
|
||||
color={ink}
|
||||
strokeWidth={2.6}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: mm(inset * 1.6),
|
||||
insetInlineEnd: mm(inset * 1.6),
|
||||
width: mm(rowH * 0.5),
|
||||
height: mm(rowH * 0.5),
|
||||
opacity: 0.9,
|
||||
}}
|
||||
/>
|
||||
{figures.map((f, i) => (
|
||||
<div
|
||||
key={f.metric}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: mm(w * 0.04),
|
||||
paddingInlineEnd: mm(i === 0 ? rowH * 0.6 : 0),
|
||||
borderTop: i ? `${mm(Math.max(0.2, inset * 0.18))} solid ${inkAt(ink, 0.45)}` : undefined,
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: mm(label),
|
||||
fontWeight: heavy(el),
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
</span>
|
||||
<FigureText f={f} size={value} el={el} color={ink} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A note: a sheet of ruled paper with a margin in the accent, two strips of
|
||||
* tape holding it a degree or two off square, and the figures written on the
|
||||
* lines in the element's face, a script one when it comes from the panel.
|
||||
*/
|
||||
export function NoteLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const paper = inkOn(el.color);
|
||||
const line = h / (figures.length + 2);
|
||||
const size =
|
||||
fitAll(
|
||||
figures.map((f) => ({ ...f, value: `${f.value} ${f.label}` })),
|
||||
w * 0.74,
|
||||
line * 0.72
|
||||
) * el.textScale;
|
||||
const tapeW = w * 0.22;
|
||||
const rule = Math.max(0.15, h * 0.004);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transform: 'rotate(-1.6deg)',
|
||||
background: `repeating-linear-gradient(180deg, transparent 0 ${mm(line - rule)}, ${inkAt(el.accent, 0.28)} ${mm(line - rule)} ${mm(line)}), ${paper}`,
|
||||
backgroundPosition: `0 ${mm(line * 0.62)}`,
|
||||
boxShadow: `0 ${mm(h * 0.015)} ${mm(h * 0.05)} ${inkAt(el.color, 0.22)}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
paddingInlineStart: mm(w * 0.16),
|
||||
paddingTop: mm(line * 1.05),
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
insetInlineStart: mm(w * 0.11),
|
||||
width: mm(rule * 1.6),
|
||||
background: inkAt(el.accent, 0.55),
|
||||
}}
|
||||
/>
|
||||
{[-1, 1].map((side) => (
|
||||
<span
|
||||
key={side}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: mm(-line * 0.25),
|
||||
insetInlineStart: side < 0 ? mm(w * 0.04) : undefined,
|
||||
insetInlineEnd: side > 0 ? mm(w * 0.04) : undefined,
|
||||
width: mm(tapeW),
|
||||
height: mm(line * 0.62),
|
||||
background: inkAt(el.accent, 0.32),
|
||||
transform: `rotate(${side * 7}deg)`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{figures.map((f) => (
|
||||
<span
|
||||
key={f.metric}
|
||||
style={{
|
||||
height: mm(line),
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
gap: mm(size * 0.3),
|
||||
paddingBottom: mm(line * 0.12),
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: mm(size), fontWeight: heavy(el), color: el.accent, lineHeight: 1 }}>
|
||||
{f.value}
|
||||
{f.unit && ` ${f.unit}`}
|
||||
</span>
|
||||
<span style={{ fontSize: mm(size * 0.78), fontWeight: light(el), color: el.color, lineHeight: 1 }}>
|
||||
{f.label}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A pencil sketch: a frame drawn freehand twice over, each figure ringed by a
|
||||
* loop in the accent with hatching behind its icon, and its name underlined
|
||||
* with a quick stroke. Seeded from the element, so it draws the same each time.
|
||||
*/
|
||||
export function SketchLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const rand = seeded(`${el.id}:${figures.map((f) => f.value).join()}`);
|
||||
const { cols, rows } = gridOf(figures.length);
|
||||
const cellW = w / cols;
|
||||
const cellH = h / rows;
|
||||
const pen = Math.max(0.2, Math.min(w, h) * 0.008);
|
||||
const wob = Math.min(w, h) * 0.012;
|
||||
const ringR = Math.min(cellW * 0.3, cellH * 0.28);
|
||||
const size = fitAll(figures, ringR * 1.5, ringR * 0.6) * el.textScale;
|
||||
const frame = `${roughBox({ x: pen * 3, y: pen * 3, w: w - pen * 6, h: h - pen * 6 }, { rand, wobble: wob })} ${roughBox({ x: pen * 4, y: pen * 2, w: w - pen * 7, h: h - pen * 5 }, { rand, wobble: wob })}`;
|
||||
const marks = figures.map((f, i) => {
|
||||
const cx = (i % cols) * cellW + cellW / 2;
|
||||
const cy = Math.floor(i / cols) * cellH + cellH * 0.4;
|
||||
const r = ringR;
|
||||
const icon = size * 0.5;
|
||||
return {
|
||||
f,
|
||||
ring: roughCircle(cx, cy, r, { rand, wobble: wob * 0.9 }),
|
||||
shade: hatch({ x: cx - r * 0.95, y: cy - r * 1.05, w: icon * 1.2, h: icon * 1.2 }, icon * 0.22, {
|
||||
rand,
|
||||
wobble: wob * 0.3,
|
||||
}),
|
||||
under: roughLine(cx - r * 0.7, cy + r * 1.22, cx + r * 0.7, cy + r * 1.18, { rand, wobble: wob * 0.6 }),
|
||||
at: { x: cx, y: cy, r },
|
||||
};
|
||||
});
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0, overflow: 'visible' }}
|
||||
aria-hidden
|
||||
>
|
||||
<path d={frame} fill="none" stroke={el.color} strokeWidth={pen} strokeLinecap="round" opacity={0.8} />
|
||||
{marks.map((m) => (
|
||||
<g key={m.f.metric}>
|
||||
<path
|
||||
d={m.shade}
|
||||
fill="none"
|
||||
stroke={el.accent}
|
||||
strokeWidth={pen * 0.7}
|
||||
strokeLinecap="round"
|
||||
opacity={0.5}
|
||||
/>
|
||||
<path
|
||||
d={m.ring}
|
||||
fill="none"
|
||||
stroke={el.accent}
|
||||
strokeWidth={pen * 1.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d={m.under} fill="none" stroke={el.color} strokeWidth={pen} strokeLinecap="round" opacity={0.7} />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
{marks.map(({ f, at }) => {
|
||||
const Icon = METRIC_ICONS[f.metric];
|
||||
const box = { position: 'absolute', insetInlineStart: mm(at.x - at.r * 1.4), width: mm(at.r * 2.8) } as const;
|
||||
return (
|
||||
<div key={f.metric}>
|
||||
<div
|
||||
style={{
|
||||
...box,
|
||||
top: mm(at.y - at.r * 0.8),
|
||||
height: mm(at.r * 1.6),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(at.r * 0.04),
|
||||
}}
|
||||
>
|
||||
{el.showIcons && (
|
||||
<Icon
|
||||
color={el.color}
|
||||
strokeWidth={1.6}
|
||||
style={{ width: mm(size * 0.4), height: mm(size * 0.4), display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
<FigureText f={f} size={size} el={el} color={el.color} unitColor={el.accent} />
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
...box,
|
||||
top: mm(at.y + at.r * 1.3),
|
||||
textAlign: 'center',
|
||||
fontSize: mm(fitType(f.label.length, at.r * 2.4, size * 0.4)),
|
||||
fontWeight: light(el),
|
||||
color: el.color,
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { BookElement } from '@trek/shared';
|
||||
import { TravelPreview } from '../TravelPreview';
|
||||
|
||||
/**
|
||||
* One card of the Travel panel: the element its click places, drawn through
|
||||
* the page's own renderer with this journey's numbers in it, and its name
|
||||
* under it. These elements differ by what they say rather than by their
|
||||
* shape, so a named row could not tell two of them apart; the thing itself
|
||||
* can.
|
||||
*/
|
||||
export function TravelCard({
|
||||
el,
|
||||
label,
|
||||
onPlace,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
inset = 0,
|
||||
recommended = false,
|
||||
title,
|
||||
}: {
|
||||
el: BookElement;
|
||||
label: string;
|
||||
onPlace: (el: BookElement) => void;
|
||||
minHeight: number;
|
||||
maxHeight: number;
|
||||
/** Room kept round the element in its tile, for one that reaches past its own edges. */
|
||||
inset?: number;
|
||||
/**
|
||||
* Marked as the one to reach for, with a ring rather than with words: the
|
||||
* label has about eighty pixels, and the tooltip carries the word.
|
||||
*/
|
||||
recommended?: boolean;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`st-travel-card ${recommended ? 'is-recommended' : ''}`}
|
||||
onClick={() => onPlace(el)}
|
||||
title={title ? `${label}: ${title}` : label}
|
||||
>
|
||||
<TravelPreview el={el} minHeight={minHeight} maxHeight={maxHeight} inset={inset} />
|
||||
<span className="st-travel-card-label">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { JourneyStats } from '@trek/shared';
|
||||
import { Check, RouteOff } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Section } from '../StudioControls';
|
||||
|
||||
/**
|
||||
* The stops of the route, each switchable in or out of the figures.
|
||||
*
|
||||
* Beside the maps it changes, and not only in the entry editor: the moment
|
||||
* somebody notices the home airport on the printed route is the moment they
|
||||
* are looking at this panel. The rows carry no preview because they are not
|
||||
* things to place; they are the input every card reads.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One row: a point of the route or a stop switched off. `entryId` is null for
|
||||
* a stop the server took straight from a trip's places, which has no entry to
|
||||
* put a switch on.
|
||||
*/
|
||||
interface StopRow {
|
||||
entryId: number | null;
|
||||
label: string;
|
||||
date: string | null;
|
||||
country: string | null;
|
||||
excluded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every point and every left-out stop in one sequence, by date: the question
|
||||
* the list answers is "which of these count?", and that needs them together.
|
||||
* Undated stops go last, then by name.
|
||||
*/
|
||||
function stopRows(stats: JourneyStats): StopRow[] {
|
||||
return [
|
||||
...stats.points.map((p) => ({
|
||||
entryId: p.entryId,
|
||||
label: p.label,
|
||||
date: p.date,
|
||||
country: p.country,
|
||||
excluded: false,
|
||||
})),
|
||||
...(stats.excluded ?? []).map((x) => ({
|
||||
entryId: x.entryId,
|
||||
label: x.label,
|
||||
date: x.date,
|
||||
country: null,
|
||||
excluded: true,
|
||||
})),
|
||||
].sort((a, b) => {
|
||||
if (a.date !== b.date) {
|
||||
if (a.date == null) return 1;
|
||||
if (b.date == null) return -1;
|
||||
return a.date < b.date ? -1 : 1;
|
||||
}
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
}
|
||||
|
||||
export function TravelStops({
|
||||
stats,
|
||||
t,
|
||||
locale,
|
||||
canEdit,
|
||||
onToggleStop,
|
||||
countryName,
|
||||
}: {
|
||||
stats: JourneyStats;
|
||||
t: (k: string) => string;
|
||||
locale: string;
|
||||
canEdit: boolean;
|
||||
onToggleStop: (entryId: number, excluded: boolean) => Promise<boolean>;
|
||||
countryName: (code: string) => string;
|
||||
}) {
|
||||
/*
|
||||
* The stops whose switch is on its way to the server. No optimistic flip:
|
||||
* which stop is furthest and which countries remain is the server's sum, so
|
||||
* a row reads its state from the stats fetched once the change has landed,
|
||||
* and the chip is only held until then.
|
||||
*/
|
||||
const [pending, setPending] = useState<Set<number>>(() => new Set());
|
||||
const stops = stopRows(stats);
|
||||
if (!stops.length) return null;
|
||||
const excludedCount = stops.filter((s) => s.excluded).length;
|
||||
// The country only where there is more than one to tell apart.
|
||||
const manyCountries = stats.countries.length > 1;
|
||||
// The day without the weekday: a row has a place name to fit as well.
|
||||
const stopDay = (iso: string) =>
|
||||
new Date(`${iso}T00:00:00Z`).toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' });
|
||||
|
||||
const toggle = async (entryId: number, excluded: boolean) => {
|
||||
setPending((prev) => new Set(prev).add(entryId));
|
||||
// A refusal is the hook's to report; here it only frees the chip again.
|
||||
await onToggleStop(entryId, excluded);
|
||||
setPending((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(entryId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
label={t('journey.studio.stops')}
|
||||
hint={t('journey.studio.stopsHint')}
|
||||
// Folded, saying how many count: a fortnight is fourteen rows, and most books never touch it.
|
||||
defaultOpen={false}
|
||||
badge={`${stops.length - excludedCount}/${stops.length}`}
|
||||
>
|
||||
<ul className="st-route-stops">
|
||||
{stops.map((stop, i) => {
|
||||
const entryId = stop.entryId;
|
||||
const on = !stop.excluded;
|
||||
const state = t(on ? 'journey.studio.stopOn' : 'journey.studio.stopOff');
|
||||
const chip = `st-chip is-pick is-icon ${on ? 'is-on' : ''}`;
|
||||
// A mark rather than the word, so the column does not jitter; the word is in the title.
|
||||
const mark = on ? <Check size={13} strokeWidth={2.4} /> : <RouteOff size={13} strokeWidth={2.2} />;
|
||||
return (
|
||||
<li key={entryId ?? `place-${i}`} className={`st-route-stop ${on ? '' : 'is-off'}`}>
|
||||
{stop.date && <span className="st-badge">{stopDay(stop.date)}</span>}
|
||||
<span className="st-route-stop-label">{stop.label || t('journey.studio.untitled')}</span>
|
||||
{manyCountries && stop.country && <span className="st-badge is-quiet">{countryName(stop.country)}</span>}
|
||||
{canEdit && entryId != null ? (
|
||||
<button
|
||||
type="button"
|
||||
className={chip}
|
||||
title={`${state}: ${t('journey.studio.stopToggle')}`}
|
||||
aria-label={t('journey.studio.stopToggle')}
|
||||
aria-pressed={on}
|
||||
disabled={pending.has(entryId)}
|
||||
onClick={() => void toggle(entryId, !stop.excluded)}
|
||||
>
|
||||
{mark}
|
||||
</button>
|
||||
) : (
|
||||
<span className={chip} title={state} aria-label={state}>
|
||||
{mark}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { BookBadgeElement, BookBadgeStyle, BookBadgeVariant } from '@trek/shared';
|
||||
import { brightness } from '../folioColour';
|
||||
|
||||
/**
|
||||
* How a mark is coloured and which of its styles are the newer looks.
|
||||
*
|
||||
* Moved out of TravelElements.tsx so the looks drawn in BadgeLooks.tsx answer
|
||||
* the same question the same way: a filled mark takes its words' colour from
|
||||
* its fill, a plain one from the page.
|
||||
*/
|
||||
|
||||
/** The styles BadgeLooks.tsx draws; the first four are TravelElements.tsx's. */
|
||||
const LOOKS: ReadonlySet<BookBadgeStyle> = new Set(['stamp', 'tag', 'pill', 'ribbon', 'sticker', 'patch', 'sketch']);
|
||||
|
||||
export const isBadgeLook = (style: BookBadgeStyle | undefined): boolean => !!style && LOOKS.has(style);
|
||||
|
||||
/** Ink for a mark that picks its own, and its opposite. */
|
||||
const BADGE_DARK_INK = '#1c1b19'; // theme-lint-disable — ink on a printed page, not a UI token
|
||||
const BADGE_LIGHT_INK = '#ffffff'; // theme-lint-disable — paper white on a filled chip
|
||||
|
||||
/** The ink that reads on a fill: dark on a pale one, paper on a deep one. */
|
||||
export const inkOn = (fill: string): string => (brightness(fill) > 0.55 ? BADGE_DARK_INK : BADGE_LIGHT_INK);
|
||||
|
||||
/**
|
||||
* What colour a mark's words are.
|
||||
*
|
||||
* Automatic unless the element says otherwise, and what automatic means depends
|
||||
* on what the mark is: a chip carries its words on its own fill, so they answer
|
||||
* to the fill's brightness; a day or a date drawn plain takes the accent,
|
||||
* because that is the figure the page is built around. Anything else is
|
||||
* ordinary text in the text colour.
|
||||
*
|
||||
* The moment somebody picks a colour this steps aside completely. That is the
|
||||
* point of the flag: automatic is a good default and a bad cage.
|
||||
*/
|
||||
export function badgeInk(el: BookBadgeElement): string {
|
||||
if (!el.autoColor) return el.color;
|
||||
if (el.style === 'chip' || el.style === 'tag') return inkOn(el.accent);
|
||||
if (el.variant === 'day' || el.variant === 'date') return el.accent;
|
||||
return el.color;
|
||||
}
|
||||
|
||||
/** How tall the picture sits beside the words, as a share of the mark's height. */
|
||||
export function badgeIconShare(variant: BookBadgeVariant): number {
|
||||
if (variant === 'country') return 0.72;
|
||||
if (variant === 'flag') return 0.5;
|
||||
return 0.46;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Lines that look drawn by hand: a frame, a rule, a circle, hatching, each a
|
||||
* little off true the way a pen goes, for the sketch looks of the travel
|
||||
* elements.
|
||||
*
|
||||
* The wobble is seeded from what the element says, never from the clock or
|
||||
* Math.random, so a page draws the same every time it is opened and the same
|
||||
* in print as on screen. Every function returns an SVG path in the caller's
|
||||
* units (the element's millimetres).
|
||||
*/
|
||||
|
||||
/** A small fast generator seeded from a string: the same words always wobble the same way. */
|
||||
export function seeded(text: string): () => number {
|
||||
let h = 1779033703 ^ text.length;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
h = Math.imul(h ^ text.charCodeAt(i), 3432918353);
|
||||
h = (h << 13) | (h >>> 19);
|
||||
}
|
||||
let a = h >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const r2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
/** The hand that draws: where its wobble comes from, and how far off true a stroke may run. */
|
||||
export interface Pen {
|
||||
rand: () => number;
|
||||
wobble: number;
|
||||
}
|
||||
|
||||
/** A box in the caller's units. */
|
||||
export interface Box {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** A point nudged by up to `w` either way. */
|
||||
const nudge = (rand: () => number, w: number) => (rand() - 0.5) * 2 * w;
|
||||
|
||||
/**
|
||||
* A stroke from one point to another that bows a little and overshoots its
|
||||
* ends, the way a ruled line drawn freehand does.
|
||||
*/
|
||||
export function roughLine(x1: number, y1: number, x2: number, y2: number, { rand, wobble }: Pen): string {
|
||||
const len = Math.hypot(x2 - x1, y2 - y1) || 1;
|
||||
const ux = (x2 - x1) / len;
|
||||
const uy = (y2 - y1) / len;
|
||||
const over = wobble * 0.8;
|
||||
const sx = x1 - ux * over * rand() + nudge(rand, wobble * 0.4);
|
||||
const sy = y1 - uy * over * rand() + nudge(rand, wobble * 0.4);
|
||||
const ex = x2 + ux * over * rand() + nudge(rand, wobble * 0.4);
|
||||
const ey = y2 + uy * over * rand() + nudge(rand, wobble * 0.4);
|
||||
const mx = (sx + ex) / 2 - uy * nudge(rand, wobble) + nudge(rand, wobble * 0.3);
|
||||
const my = (sy + ey) / 2 + ux * nudge(rand, wobble) + nudge(rand, wobble * 0.3);
|
||||
return `M${r2(sx)} ${r2(sy)} Q${r2(mx)} ${r2(my)} ${r2(ex)} ${r2(ey)}`;
|
||||
}
|
||||
|
||||
/** A box drawn as four freehand strokes, each running a little past the corner. */
|
||||
export function roughBox({ x, y, w, h }: Box, pen: Pen): string {
|
||||
return [
|
||||
roughLine(x, y, x + w, y, pen),
|
||||
roughLine(x + w, y, x + w, y + h, pen),
|
||||
roughLine(x + w, y + h, x, y + h, pen),
|
||||
roughLine(x, y + h, x, y, pen),
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/** A loop that does not quite close, as a circle drawn in one stroke does. */
|
||||
export function roughCircle(cx: number, cy: number, r: number, { rand, wobble }: Pen): string {
|
||||
const steps = 18;
|
||||
const start = rand() * Math.PI * 2;
|
||||
const sweep = Math.PI * 2 * (1.04 + rand() * 0.06);
|
||||
const pts = Array.from({ length: steps + 1 }, (_, i) => {
|
||||
const a = start + (sweep * i) / steps;
|
||||
const rr = r + nudge(rand, wobble);
|
||||
return [cx + Math.cos(a) * rr, cy + Math.sin(a) * rr] as const;
|
||||
});
|
||||
const [first, ...rest] = pts;
|
||||
if (!first) return '';
|
||||
return `M${r2(first[0])} ${r2(first[1])} ${rest.map(([px, py]) => `L${r2(px)} ${r2(py)}`).join(' ')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagonal hatching across a box, each stroke freehand, for a fill drawn the
|
||||
* way a pencil shades rather than flat.
|
||||
*/
|
||||
export function hatch({ x, y, w, h }: Box, gap: number, pen: Pen): string {
|
||||
const lines: string[] = [];
|
||||
for (let d = gap; d < w + h; d += gap) {
|
||||
const x1 = x + Math.max(0, d - h);
|
||||
const y1 = y + Math.min(h, d);
|
||||
const x2 = x + Math.min(w, d);
|
||||
const y2 = y + Math.max(0, d - w);
|
||||
lines.push(roughLine(x1, y1, x2, y2, pen));
|
||||
}
|
||||
return lines.join(' ');
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { BookMetric } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* How the figures of a summary are measured: the size a line of them fits a
|
||||
* box at, and how a set divides into rows and columns. Shared by every look,
|
||||
* so a figure reads the same size whichever look it is set in.
|
||||
*/
|
||||
|
||||
/** One figure as it is printed. */
|
||||
export interface Figure {
|
||||
metric: BookMetric;
|
||||
value: string;
|
||||
unit: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The size a line of figures can be set at to fit a box: as tall as the box
|
||||
* allows and no wider than it. A figure is about six tenths of an em wide per
|
||||
* character in the faces Studio offers, which is what the estimate rests on.
|
||||
*/
|
||||
export function fitType(chars: number, boxW: number, boxH: number): number {
|
||||
return Math.max(1.2, Math.min(boxH, boxW / (0.6 * Math.max(2, chars))));
|
||||
}
|
||||
|
||||
export const chars = (f: Figure) => f.value.length + (f.unit ? f.unit.length * 0.45 + 0.3 : 0);
|
||||
|
||||
/** The size every figure of a set fits at, so a set reads as one size. */
|
||||
export const fitAll = (figures: Figure[], boxW: number, boxH: number) =>
|
||||
figures.reduce((size, f) => Math.min(size, fitType(chars(f), boxW, boxH)), boxH);
|
||||
|
||||
/** The size a caption fits at: bold small capitals with their tracking need almost an em a letter. */
|
||||
export const fitCaption = (labels: string[], boxW: number, cap: number) =>
|
||||
Math.max(1, Math.min(cap, boxW / (0.98 * Math.max(4, ...labels.map((l) => l.length)))));
|
||||
|
||||
/** The rows and columns a set of figures divides into: a row of three, a square of four, two rows of three. */
|
||||
export function gridOf(n: number): { cols: number; rows: number } {
|
||||
const cols = n <= 3 ? n : n === 4 ? 2 : n <= 6 ? 3 : 4;
|
||||
return { cols: Math.max(1, cols), rows: Math.max(1, Math.ceil(n / Math.max(1, cols))) };
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { BookMetric, BookStatsElement } from '@trek/shared';
|
||||
import { inkOn } from './badgeLook';
|
||||
import type { Figure } from './statsFit';
|
||||
import { heavy, inkAt, light, METRIC_ICONS, mm } from './travelInk';
|
||||
|
||||
/** The pieces every summary look is set from: a figure with its unit, its caption, its icon in a disc. */
|
||||
|
||||
/** The figure with its unit riding beside it at a fraction of its size, in the accent. */
|
||||
export function FigureText({
|
||||
f,
|
||||
size,
|
||||
el,
|
||||
color,
|
||||
unitColor,
|
||||
}: {
|
||||
f: Figure;
|
||||
size: number;
|
||||
el: BookStatsElement;
|
||||
color: string;
|
||||
unitColor?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: mm(size),
|
||||
fontWeight: heavy(el),
|
||||
lineHeight: 0.92,
|
||||
letterSpacing: '-0.04em',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
whiteSpace: 'nowrap',
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{f.value}
|
||||
{f.unit && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: mm(size * 0.38),
|
||||
fontWeight: light(el),
|
||||
letterSpacing: '0.02em',
|
||||
marginInlineStart: '0.14em',
|
||||
color: unitColor ?? color,
|
||||
}}
|
||||
>
|
||||
{f.unit}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The caption, in tracked small capitals. */
|
||||
export function Caption({
|
||||
text,
|
||||
size,
|
||||
el,
|
||||
color,
|
||||
opacity = 0.62,
|
||||
}: {
|
||||
text: string;
|
||||
size: number;
|
||||
el: BookStatsElement;
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: mm(size),
|
||||
fontWeight: heavy(el),
|
||||
letterSpacing: '0.18em',
|
||||
textTransform: 'uppercase',
|
||||
lineHeight: 1.2,
|
||||
opacity,
|
||||
color,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A figure's icon in a disc: tinted in the accent, or solid with the icon cut out of it. */
|
||||
export function IconDisc({
|
||||
metric,
|
||||
size,
|
||||
el,
|
||||
solid = false,
|
||||
}: {
|
||||
metric: BookMetric;
|
||||
size: number;
|
||||
el: BookStatsElement;
|
||||
solid?: boolean;
|
||||
}) {
|
||||
const Icon = METRIC_ICONS[metric];
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
width: mm(size),
|
||||
height: mm(size),
|
||||
flex: '0 0 auto',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
borderRadius: '50%',
|
||||
background: solid ? el.accent : inkAt(el.accent, 0.14),
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
color={solid ? inkOn(el.accent) : el.accent}
|
||||
strokeWidth={2}
|
||||
style={{ width: mm(size * 0.52), height: mm(size * 0.52), display: 'block' }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The disc on a tile: on the solid tile it is the paper's colour faintly, elsewhere the accent's. */
|
||||
export function IconDiscOn({
|
||||
metric,
|
||||
size,
|
||||
el,
|
||||
solid,
|
||||
}: {
|
||||
metric: BookMetric;
|
||||
size: number;
|
||||
el: BookStatsElement;
|
||||
solid: boolean;
|
||||
}) {
|
||||
if (!solid) return <IconDisc metric={metric} size={size} el={el} />;
|
||||
const Icon = METRIC_ICONS[metric];
|
||||
const ink = inkOn(el.accent);
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
width: mm(size),
|
||||
height: mm(size),
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
borderRadius: '50%',
|
||||
background: inkAt(ink, 0.18),
|
||||
}}
|
||||
>
|
||||
<Icon color={ink} strokeWidth={2} style={{ width: mm(size * 0.52), height: mm(size * 0.52), display: 'block' }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import type {
|
||||
BookBadgeStyle,
|
||||
BookBadgeVariant,
|
||||
BookColorToken,
|
||||
BookCountryLook,
|
||||
BookElement,
|
||||
BookMapElement,
|
||||
BookMapScope,
|
||||
BookMetric,
|
||||
BookPageSetup,
|
||||
BookStatsLook,
|
||||
BookTheme,
|
||||
JourneyStats,
|
||||
} from '@trek/shared';
|
||||
import {
|
||||
bookBadgeValue,
|
||||
bookCountryName,
|
||||
bookTravelBadge,
|
||||
bookTravelCountries,
|
||||
bookTravelMap,
|
||||
bookTravelSize,
|
||||
bookTravelStats,
|
||||
themedElement,
|
||||
bookElementId as uid,
|
||||
} from '@trek/shared';
|
||||
import { studioTravelContext } from '../studioTravelContext';
|
||||
|
||||
/**
|
||||
* What the Travel panel offers, built: every card is the element its click
|
||||
* places, made by the book engine's own factories (book-travel.ts) so a card
|
||||
* and what the assistant places are one thing, then set in one of the newer
|
||||
* looks and dressed in the book's theme.
|
||||
*
|
||||
* Pure, so the panel stays a list of shelves: it asks for an element and
|
||||
* renders it through the page's own renderer, and the preview and the result
|
||||
* are the same object.
|
||||
*/
|
||||
|
||||
/** The figures a summary offers: how far, how long, where, and how much was kept. */
|
||||
const SUMMARY_METRICS: BookMetric[] = ['distance', 'days', 'countries', 'photos'];
|
||||
|
||||
/** The looks of a summary, in the order the panel offers them. */
|
||||
export const SUMMARY_LOOKS: BookStatsLook[] = [
|
||||
'poster',
|
||||
'ticket',
|
||||
'tiles',
|
||||
'sign',
|
||||
'odometer',
|
||||
'note',
|
||||
'sketch',
|
||||
'band',
|
||||
];
|
||||
|
||||
/** The marks the journey fills, in the order a page is read: when, which day, how far, where. */
|
||||
export const MARK_VARIANTS = ['date', 'day', 'distance', 'coords', 'flag'] as const;
|
||||
export type MarkVariant = (typeof MARK_VARIANTS)[number];
|
||||
|
||||
/** The newer styles a mark comes in, and which of them each mark is offered in. */
|
||||
export const MARK_STYLES: { style: BookBadgeStyle; variants: readonly MarkVariant[] }[] = [
|
||||
{ style: 'stamp', variants: ['date', 'day', 'flag'] },
|
||||
{ style: 'tag', variants: ['day', 'distance', 'coords', 'flag'] },
|
||||
{ style: 'pill', variants: ['date', 'day', 'distance', 'coords', 'flag'] },
|
||||
{ style: 'ribbon', variants: ['date', 'day', 'distance', 'flag'] },
|
||||
{ style: 'sticker', variants: ['date', 'day', 'distance', 'flag'] },
|
||||
{ style: 'patch', variants: ['day', 'distance', 'flag'] },
|
||||
{ style: 'sketch', variants: ['date', 'day', 'distance', 'coords', 'flag'] },
|
||||
];
|
||||
|
||||
/** The single figures, one shelf per look, with the figures each look suits. */
|
||||
export const FIGURE_SHELVES: { look: BookStatsLook; metrics: readonly BookMetric[] }[] = [
|
||||
{ look: 'medal', metrics: ['distance', 'days', 'steps', 'photos', 'countries', 'places', 'furthest'] },
|
||||
{ look: 'odometer', metrics: ['distance', 'furthest', 'days', 'photos'] },
|
||||
{ look: 'sketch', metrics: ['distance', 'days', 'countries', 'photos'] },
|
||||
];
|
||||
|
||||
/** The looks a country is offered in. */
|
||||
export const COUNTRY_LOOKS: BookCountryLook[] = ['stamp', 'visa', 'postcard', 'flag', 'pin', 'label', 'sketch'];
|
||||
|
||||
/** The looks written by hand take the theme's script face rather than its heading. */
|
||||
const HANDWRITTEN = new Set<string>(['note', 'sketch']);
|
||||
|
||||
/** Which of the theme's accents each country look takes, so a shelf of them is not one colour. */
|
||||
const COUNTRY_ACCENT: Partial<Record<BookCountryLook, BookColorToken>> = {
|
||||
stamp: 'accent2',
|
||||
visa: 'accent3',
|
||||
pin: 'accent',
|
||||
label: 'accent2',
|
||||
};
|
||||
|
||||
/** Which of the theme's accents each mark takes, so a page of them is not one colour. */
|
||||
const MARK_ACCENT: Record<MarkVariant, BookColorToken> = {
|
||||
date: 'accent',
|
||||
day: 'accent2',
|
||||
distance: 'accent3',
|
||||
coords: 'accent',
|
||||
flag: 'accent2',
|
||||
};
|
||||
|
||||
/** The map cards: the photographed route, its stops numbered, its two ends marked, and the dark ground. */
|
||||
export const MAP_CARDS: {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
style: BookMapElement['style'];
|
||||
pins?: BookMapElement['pinStyle'];
|
||||
}[] = [
|
||||
{ id: 'photos', labelKey: 'journey.studio.pinPhoto', style: 'minimal' },
|
||||
{ id: 'numbered', labelKey: 'journey.studio.pinNumbered', style: 'minimal', pins: 'numbered' },
|
||||
{ id: 'ends', labelKey: 'journey.studio.pinEnds', style: 'minimal', pins: 'ends' },
|
||||
{ id: 'dark', labelKey: 'journey.studio.mapStyle.dark', style: 'dark', pins: 'numbered' },
|
||||
];
|
||||
|
||||
interface BuilderInput {
|
||||
page: BookPageSetup;
|
||||
stats: JourneyStats;
|
||||
/** The travelled way, already thinned. */
|
||||
path: [number, number][][];
|
||||
locale: string;
|
||||
t: (k: string) => string;
|
||||
theme: BookTheme | null | undefined;
|
||||
/** The spread on screen is one page rather than two, so a new element centres on it. */
|
||||
single: boolean;
|
||||
}
|
||||
|
||||
export function travelBuilders({ page, stats, path, locale, t, theme, single }: BuilderInput) {
|
||||
const travel = studioTravelContext(stats, path, locale, t);
|
||||
|
||||
/** Centred across the spread on screen, where the engine's `add` takes the right page. */
|
||||
const at = (size: { w: number; h: number }) => {
|
||||
const W = single ? page.pageWidth : page.pageWidth * 2;
|
||||
return { x: (W - size.w) / 2, y: (page.pageHeight - size.h) / 2, w: size.w, h: size.h };
|
||||
};
|
||||
|
||||
/**
|
||||
* An element dressed in the book's theme: its words in the theme's ink and
|
||||
* heading face, its colour one of the theme's accents, all three linked so
|
||||
* it follows when the theme changes. A book without a theme keeps the
|
||||
* engine's own colours.
|
||||
*/
|
||||
const dress = (el: BookElement, accent: BookColorToken = 'accent', hand = false): BookElement =>
|
||||
theme
|
||||
? themedElement(
|
||||
{ ...el, token: 'ink', accentToken: accent, fontRole: hand ? 'script' : 'heading' } as BookElement,
|
||||
theme
|
||||
)
|
||||
: el;
|
||||
|
||||
/** A summary in one of the newer looks; a band is a strip, so it is set a third as tall. */
|
||||
const summaryEl = (look: BookStatsLook): BookElement => {
|
||||
const w = page.pageWidth * (look === 'note' || look === 'sign' ? 0.56 : 0.72);
|
||||
const h =
|
||||
look === 'band' ? page.pageHeight * 0.13 : look === 'ticket' ? w * 0.42 : look === 'note' ? w * 0.72 : w * 0.5;
|
||||
const el = { ...bookTravelStats(stats, { id: uid('st'), frame: at({ w, h }), metrics: SUMMARY_METRICS }), look };
|
||||
return dress(el, look === 'sign' ? 'accent3' : 'accent', HANDWRITTEN.has(look));
|
||||
};
|
||||
|
||||
/**
|
||||
* One figure on its own, the mark to drop beside a photograph: as a medal, on
|
||||
* the drums of an odometer, or ringed by hand.
|
||||
*/
|
||||
const figureEl = (metric: BookMetric, look: BookStatsLook = 'medal'): BookElement => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.3;
|
||||
const size = look === 'odometer' ? { w: side * 1.6, h: side * 0.62 } : { w: side, h: side };
|
||||
const el = { ...bookTravelStats(stats, { id: uid('st'), frame: at(size), metrics: [metric] }), look };
|
||||
return dress(el, 'accent', HANDWRITTEN.has(look));
|
||||
};
|
||||
|
||||
/**
|
||||
* A map as the engine places it, on the card's ground and with the card's
|
||||
* stops. The scope is the journey or one trip; the ground is the outlines
|
||||
* or one of the two imagery sources.
|
||||
*/
|
||||
const mapEl = (
|
||||
card: { style: BookMapElement['style']; pins?: BookMapElement['pinStyle'] },
|
||||
scope: BookMapScope = 'journey',
|
||||
source: 'vector' | 'relief' | 'satellite' = 'vector'
|
||||
): BookElement => {
|
||||
const el = bookTravelMap(travel, stats, { id: uid('mp'), frame: at(bookTravelSize('map', page)), scope, source });
|
||||
const shaped = { ...el, style: card.style, ...(card.pins ? { pinStyle: card.pins } : {}) };
|
||||
// Over imagery the line is white on purpose (book-travel.ts); only the outline maps take the theme.
|
||||
return source === 'vector' ? dress(shaped) : shaped;
|
||||
};
|
||||
|
||||
/**
|
||||
* A map of one country: its stops alone, without the way there. A journey's
|
||||
* recorded way often starts with the flight from home, and fitted to all of
|
||||
* it the country is a speck at the edge of a continent; the page a travel
|
||||
* book wants is the country, cut to its own shape on the paper or over the
|
||||
* satellite picture, or the country in a box.
|
||||
*/
|
||||
const countryMapEl = (code: string, kind: 'shape' | 'satellite' | 'box'): BookElement => {
|
||||
const own = {
|
||||
...stats,
|
||||
points: stats.points.filter((p) => p.country === code),
|
||||
countries: stats.countries.filter((c) => c.code === code),
|
||||
};
|
||||
const ctx = studioTravelContext(own, [], locale, t);
|
||||
const source = kind === 'satellite' ? 'satellite' : 'vector';
|
||||
const el = bookTravelMap(ctx, own, {
|
||||
id: uid('mp'),
|
||||
frame: at(bookTravelSize('map', page)),
|
||||
scope: 'journey',
|
||||
source,
|
||||
});
|
||||
// Cut to its shape on the paper, the land is dark, so the silhouette reads and the route in the accent stands on it.
|
||||
const style = kind === 'shape' ? 'dark' : 'minimal';
|
||||
const shaped = {
|
||||
...el,
|
||||
style,
|
||||
clip: kind === 'box' ? 'rect' : 'country',
|
||||
pinStyle: 'numbered',
|
||||
fitToCountries: true,
|
||||
} as const;
|
||||
return source === 'vector' ? dress(shaped) : shaped;
|
||||
};
|
||||
|
||||
/** A mark in one of the newer styles, at the size its variant suits; a stamp is set taller for its two rules. */
|
||||
const markEl = (variant: MarkVariant, style: BookBadgeStyle): BookElement | null => {
|
||||
const value = bookBadgeValue(travel, variant, null);
|
||||
if (!value) return null;
|
||||
const frame = at(markSize(style, value.text, value.sub));
|
||||
const el = { ...bookTravelBadge({ id: uid('bd'), frame, variant: variant as BookBadgeVariant, value }), style };
|
||||
return dress(el, MARK_ACCENT[variant], style === 'sketch');
|
||||
};
|
||||
|
||||
/**
|
||||
* How large a mark is set: as wide as its words need. A stamp stacks them in
|
||||
* a box with room for its two rules; a tag or a pill sets them in a line
|
||||
* after its eyelet or its disc, so its length follows the words.
|
||||
*/
|
||||
const markSize = (style: BookBadgeStyle, text: string, sub: string) => {
|
||||
const twoLines = !!text && !!sub && text.length > 14;
|
||||
const h = page.pageHeight * (style === 'stamp' ? 0.12 : twoLines ? 0.1 : 0.066);
|
||||
const main = text || sub;
|
||||
if (style === 'stamp') return { w: Math.max(h * 1.3, h * 0.2 * Math.max(main.length * 1.4, sub.length)), h };
|
||||
// Two lines are set smaller than one, so they need less width a letter.
|
||||
if (twoLines)
|
||||
return { w: Math.min(page.pageWidth * 0.6, h * (1.5 + 0.13 * Math.max(text.length, sub.length * 0.7))), h };
|
||||
const chars = main.length * (text ? 1 : 0.85) + (text ? sub.length * 0.5 : 0);
|
||||
return { w: Math.min(page.pageWidth * 0.6, h * (1.6 + 0.3 * chars)), h };
|
||||
};
|
||||
|
||||
/** One country as a square stamp, or as a card in the proportions of a postcard. */
|
||||
const countryEl = (code: string, look: BookCountryLook): BookElement => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.36;
|
||||
const ratio = look === 'stamp' || look === 'pin' || look === 'sketch' ? 1 : look === 'postcard' ? 1.36 : 1.4;
|
||||
const frame = at({ w: side * ratio, h: side });
|
||||
const el = {
|
||||
...bookTravelCountries(travel, { id: uid('co'), frame, codes: [code] }),
|
||||
look,
|
||||
showFlag: look === 'pin' || look === 'postcard',
|
||||
};
|
||||
return dress(el, COUNTRY_ACCENT[look] ?? 'accent', look === 'sketch');
|
||||
};
|
||||
|
||||
/** A country's name in the reader's language, from the same CLDR data Atlas uses. */
|
||||
const countryName = (code: string): string => bookCountryName(code, locale, stats);
|
||||
|
||||
return { summaryEl, figureEl, mapEl, countryMapEl, markEl, countryEl, countryName };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { BookMetric, BookUnits } from '@trek/shared';
|
||||
import { CalendarDays, Camera, Footprints, Globe, MapPin, Navigation, Route, type LucideIcon } from 'lucide-react';
|
||||
import { lighterWeight, nearestWeight } from '../bookFonts';
|
||||
|
||||
/**
|
||||
* What every drawing of the journey's elements shares: millimetres rounded the
|
||||
* way the document stores them, an ink at a strength, the two weights a figure
|
||||
* and its caption are set in, and how a figure is written.
|
||||
*
|
||||
* TravelElements.tsx draws the looks every book has had since the start, and
|
||||
* the files beside this one draw the newer ones. Both take these from here, so
|
||||
* a figure reads the same whichever look it is set in.
|
||||
*/
|
||||
|
||||
/** Two decimals of a millimetre, matching what the document stores. */
|
||||
export const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
/** A length for a style, in millimetres. */
|
||||
export const mm = (n: number) => `${round2(n)}mm`;
|
||||
|
||||
/** #rrggbb at a strength, for the hairlines, tints and quiet labels of a drawing. */
|
||||
export function inkAt(hex: string, alpha: number): string {
|
||||
const n = Number.parseInt(hex.slice(1), 16);
|
||||
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* How heavy the type is set, and one step down for the labels under it. These
|
||||
* elements draw at two levels at once, a figure over its caption, and the pair
|
||||
* moves together the way `textScale` moves their size.
|
||||
*/
|
||||
export const heavy = (el: { font: string; weight: number }) => nearestWeight(el.font, el.weight);
|
||||
export const light = (el: { font: string; weight: number }) => lighterWeight(el.font, el.weight);
|
||||
|
||||
/**
|
||||
* The icon beside each figure, from lucide like every other icon in TREK. The
|
||||
* stroke is the drawing's own, so it scales with the figure on a large panel.
|
||||
*/
|
||||
export const METRIC_ICONS: Record<BookMetric, LucideIcon> = {
|
||||
distance: Route,
|
||||
days: CalendarDays,
|
||||
steps: Footprints,
|
||||
photos: Camera,
|
||||
countries: Globe,
|
||||
places: MapPin,
|
||||
furthest: Navigation,
|
||||
};
|
||||
|
||||
/**
|
||||
* A figure as it is printed: the number in the reader's locale and the unit
|
||||
* that rides with it. Distances are stored in metres and converted here, so
|
||||
* switching units never rounds a second time on top of a rounded number.
|
||||
*/
|
||||
export function metricValue(
|
||||
metric: BookMetric,
|
||||
values: Record<string, number>,
|
||||
units: BookUnits,
|
||||
locale: string
|
||||
): { value: string; unit: string } {
|
||||
const raw = values[metric] ?? 0;
|
||||
const n = (v: number, digits = 0) => new Intl.NumberFormat(locale, { maximumFractionDigits: digits }).format(v);
|
||||
|
||||
if (metric === 'distance' || metric === 'furthest') {
|
||||
const km = raw / 1000;
|
||||
const shown = units === 'imperial' ? km * 0.621371 : km;
|
||||
return { value: n(shown, shown < 100 ? 1 : 0), unit: units === 'imperial' ? 'mi' : 'km' };
|
||||
}
|
||||
return { value: n(raw), unit: '' };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createMarkdownHtml } from './markdownHtml';
|
||||
import { SafeMarkdown } from './SafeMarkdown';
|
||||
|
||||
describe('SafeMarkdown (#2692)', () => {
|
||||
it('FE-COMP-SAFEMD-001: renders GitHub markdown with kept line breaks and safe links', () => {
|
||||
render(<SafeMarkdown>{'# Title\nfirst\nsecond\n\n[site](https://example.com)'}</SafeMarkdown>);
|
||||
expect(screen.getByRole('heading', { name: 'Title' })).toBeInTheDocument();
|
||||
expect(document.querySelector('br')).not.toBeNull();
|
||||
const link = screen.getByRole('link', { name: 'site' });
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow');
|
||||
});
|
||||
|
||||
it('FE-COMP-SAFEMD-002: shows raw HTML as text and never as an element', () => {
|
||||
render(<SafeMarkdown>{'Room <TBD> <img src=x onerror=alert(1)>'}</SafeMarkdown>);
|
||||
expect(document.querySelector('img')).toBeNull();
|
||||
expect(screen.getByText(/Room <TBD>/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMarkdownHtml (#2692)', () => {
|
||||
it('FE-COMP-SAFEMD-003: returns the sanitized HTML, the same for the same text', () => {
|
||||
const markdown = createMarkdownHtml();
|
||||
try {
|
||||
const html = markdown.html('**bold** and <script>alert(1)</script>');
|
||||
expect(html).toContain('<strong>bold</strong>');
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(markdown.html('**bold** and <script>alert(1)</script>')).toBe(html);
|
||||
expect(markdown.html('- a\n- b')).toContain('<li>a</li>');
|
||||
} finally {
|
||||
markdown.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { sanitizedMarkdownComponents, sanitizedMarkdownPlugins } from './markdownSanitize';
|
||||
|
||||
/**
|
||||
* Text a user wrote in markdown (a place's description, its notes), rendered
|
||||
* the way the planner shows it: GitHub flavour, line breaks kept, raw HTML
|
||||
* shown as text, the rest sanitized, links in a new tab without an opener.
|
||||
* Safe for pages strangers see, like a shared trip (#2692).
|
||||
*/
|
||||
export function SafeMarkdown({ children }: { children: string }) {
|
||||
return (
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
rehypePlugins={sanitizedMarkdownPlugins}
|
||||
components={sanitizedMarkdownComponents}
|
||||
>
|
||||
{children}
|
||||
</Markdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { flushSync } from 'react-dom';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { SafeMarkdown } from './SafeMarkdown';
|
||||
|
||||
/**
|
||||
* Turns markdown into the HTML {@link SafeMarkdown} renders, for output built as
|
||||
* a string such as the PDF export (#2692). It renders through the same
|
||||
* pipeline into a detached node, so the sanitizer is the one the app uses and
|
||||
* no second markdown library is bundled. Call it outside a render and dispose
|
||||
* of it when done; the same text is rendered once.
|
||||
*/
|
||||
export function createMarkdownHtml(): { html: (text: string) => string; dispose: () => void } {
|
||||
const host = document.createElement('div');
|
||||
const root = createRoot(host);
|
||||
const done = new Map<string, string>();
|
||||
return {
|
||||
html: (text) => {
|
||||
const known = done.get(text);
|
||||
if (known !== undefined) return known;
|
||||
flushSync(() => root.render(<SafeMarkdown>{text}</SafeMarkdown>));
|
||||
done.set(text, host.innerHTML);
|
||||
return host.innerHTML;
|
||||
},
|
||||
dispose: () => root.unmount(),
|
||||
};
|
||||
}
|
||||
@@ -224,7 +224,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
|
||||
drag={dragFor(row)}
|
||||
onOpen={() => {
|
||||
if (editing) tl.editTransport(row.res)
|
||||
else shell.openSheet('transport', { reservationId: row.res.id })
|
||||
else shell.openSheet('transport', { reservationId: row.res.id, legIndex: row.res.__leg?.index })
|
||||
}}
|
||||
/>
|
||||
{dayScheduleFor('reservation', row.res.id)}
|
||||
|
||||
@@ -265,7 +265,7 @@ export function TransportRow({ res, dayId, chrome, reorder, drag, onOpen }: {
|
||||
const time = start
|
||||
? `${fmtTime(start, chrome)}${phase === 'single' && end ? ` – ${fmtTime(end, chrome)}` : ''}`
|
||||
: ''
|
||||
const sub = transportSubtitle(res)
|
||||
const sub = transportSubtitle(res, chrome.t)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { getDayBookendHotels, isDayInAccommodationRange } from '../../../../utils/dayOrder'
|
||||
import type { MergedItem } from '../../../../utils/dayMerge'
|
||||
import type { TransitLegDisplay } from '../../../../components/Planner/transitDisplay'
|
||||
import { transportSubtitle as dayPlanTransportSubtitle, type TransportLegDetail } from '../../../../components/Planner/transportDetailModel'
|
||||
import { projectDayItinerary } from '../../../../components/Map/dayTourProjection'
|
||||
import type { Accommodation, Assignment, Day, DayNote, Place, Reservation, RouteSegment, TranslationFn } from '../../../../types'
|
||||
|
||||
@@ -16,7 +17,7 @@ import type { Accommodation, Assignment, Day, DayNote, Place, Reservation, Route
|
||||
|
||||
/** A day-timeline transport row can be a synthetic per-leg expansion of a multi-leg booking. */
|
||||
export type TransportEntry = Reservation & {
|
||||
__leg?: { index: number; total: number; from: string | null; to: string | null }
|
||||
__leg?: TransportLegDetail & { total: number }
|
||||
}
|
||||
|
||||
export interface TransitMeta {
|
||||
@@ -48,26 +49,8 @@ export function getTransitMeta(res: Reservation): TransitMeta | null {
|
||||
}
|
||||
|
||||
/** Same subtitle language as the desktop day plan: airline/number, train/platform/seat, leg detail. */
|
||||
export function transportSubtitle(res: TransportEntry): string {
|
||||
const meta = parseReservationMeta(res) as Record<string, string | undefined>
|
||||
if (res.__leg) {
|
||||
const parts = [res.__leg.from, res.__leg.to].filter(Boolean).length
|
||||
? [[res.__leg.from, res.__leg.to].filter(Boolean).join(' → ')]
|
||||
: []
|
||||
return parts.join(' · ')
|
||||
}
|
||||
if (res.type === 'flight') {
|
||||
const parts = [meta.airline, meta.flight_number].filter(Boolean) as string[]
|
||||
if (meta.departure_airport || meta.arrival_airport) {
|
||||
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
|
||||
}
|
||||
return parts.join(' · ')
|
||||
}
|
||||
if (res.type === 'train') {
|
||||
return [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : '']
|
||||
.filter(Boolean).join(' · ')
|
||||
}
|
||||
return res.location || ''
|
||||
export function transportSubtitle(res: TransportEntry, t: TranslationFn): string {
|
||||
return dayPlanTransportSubtitle(res, t) ?? (res.location || '')
|
||||
}
|
||||
|
||||
const sameCoord = (a: [number, number], b: [number, number]): boolean =>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from '../../../../i18n'
|
||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||
import { RES_ICONS } from '../../../../components/Planner/DayPlanSidebar.constants'
|
||||
import { splitReservationDateTime } from '../../../../utils/formatters'
|
||||
import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../../../utils/flightLegs'
|
||||
import { getFlightLegs, getTrainLegs, usesStationRoute, type FlightLeg, type TrainLeg } from '../../../../utils/flightLegs'
|
||||
import { openFile } from '../../../../utils/fileDownload'
|
||||
import { runsOnDay } from '../../../../utils/reservationRoutes'
|
||||
import { filesFor } from '../../../../utils/reservationFiles'
|
||||
@@ -19,6 +19,8 @@ import { Eyebrow, INNER_CLS, StatBox, TileHeader, displayTime } from './MTripShe
|
||||
|
||||
interface TransportSheetPayload {
|
||||
reservationId?: number
|
||||
/** The leg of a multi-leg train or flight the day plan row stood for (#2701). */
|
||||
legIndex?: number
|
||||
}
|
||||
|
||||
interface TransitLeg {
|
||||
@@ -118,24 +120,38 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
const to = (res.endpoints || []).find(e => e.role === 'to')
|
||||
const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||
const depTime = from?.local_time || startTime
|
||||
const arrTime = to?.local_time || endTime
|
||||
|
||||
const subParts: string[] = []
|
||||
if (meta.airline) subParts.push(meta.airline)
|
||||
if (meta.flight_number) subParts.push(meta.flight_number)
|
||||
if (meta.train_number) subParts.push(meta.train_number)
|
||||
if (from?.name && to?.name) subParts.push(`${from.name} → ${to.name}`)
|
||||
else if (date) subParts.push(bookingDayLabel(date, locale))
|
||||
|
||||
const seat = meta.seat
|
||||
const platform = meta.platform
|
||||
const transitLegs: TransitLeg[] = Array.isArray(meta.transit?.legs) ? meta.transit.legs : []
|
||||
const bookingDepTime = from?.local_time || startTime
|
||||
const bookingArrTime = to?.local_time || endTime
|
||||
|
||||
// Per-segment booking codes (#1943), only on a real stopover booking: the
|
||||
// single-leg fallback would just echo the booking's own code shown below.
|
||||
const routeLegs = res.type === 'flight' ? getFlightLegs(res) : usesStationRoute(res.type) ? getTrainLegs(res) : []
|
||||
const isFlight = res.type === 'flight'
|
||||
const routeLegs: (FlightLeg | TrainLeg)[] = isFlight ? getFlightLegs(res) : usesStationRoute(res.type) ? getTrainLegs(res) : []
|
||||
const legCodes = routeLegs.length > 1 ? routeLegs.filter(l => l.confirmation_number) : []
|
||||
// Opened from one leg's row, the sheet shows that leg's own number, seat,
|
||||
// platform and times rather than the first leg's (#2701).
|
||||
const leg = routeLegs.length > 1 && payload.legIndex != null ? routeLegs[payload.legIndex] ?? null : null
|
||||
const legDetail = (leg ?? {}) as Partial<FlightLeg & TrainLeg>
|
||||
const depTime = leg ? leg.dep_time || '' : bookingDepTime
|
||||
const arrTime = leg ? leg.arr_time || '' : bookingArrTime
|
||||
const depLabel = (leg ? leg.from : from?.name) || t('reservations.time')
|
||||
const arrLabel = (leg ? leg.to : to?.name) || t('reservations.time')
|
||||
|
||||
const subParts: string[] = []
|
||||
const airline = leg ? legDetail.airline : meta.airline
|
||||
const flightNumber = leg ? legDetail.flight_number : meta.flight_number
|
||||
const trainNumber = leg ? legDetail.train_number : meta.train_number
|
||||
if (airline) subParts.push(airline)
|
||||
if (flightNumber) subParts.push(flightNumber)
|
||||
if (trainNumber) subParts.push(trainNumber)
|
||||
if (leg && (leg.from || leg.to)) subParts.push([leg.from, leg.to].filter(Boolean).join(' → '))
|
||||
else if (from?.name && to?.name) subParts.push(`${from.name} → ${to.name}`)
|
||||
else if (date) subParts.push(bookingDayLabel(date, locale))
|
||||
|
||||
// A flight's legs carry no seat or platform of their own, so those stay the booking's.
|
||||
const seat = leg && !isFlight ? legDetail.seat : meta.seat
|
||||
const platform = leg && !isFlight ? legDetail.platform : meta.platform
|
||||
const transitLegs: TransitLeg[] = Array.isArray(meta.transit?.legs) ? meta.transit.legs : []
|
||||
|
||||
const resFiles = filesFor(res, planner.files || [])
|
||||
|
||||
@@ -186,10 +202,10 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
{(depTime || arrTime || seat || platform) && (
|
||||
<div className="mt-[14px] flex gap-[6px]">
|
||||
{depTime && (
|
||||
<StatBox value={displayTime(depTime, locale, timeFormat)} label={from?.name || t('reservations.time')} />
|
||||
<StatBox value={displayTime(depTime, locale, timeFormat)} label={depLabel} />
|
||||
)}
|
||||
{arrTime && (
|
||||
<StatBox value={displayTime(arrTime, locale, timeFormat)} label={to?.name || t('reservations.time')} />
|
||||
<StatBox value={displayTime(arrTime, locale, timeFormat)} label={arrLabel} />
|
||||
)}
|
||||
{platform && <StatBox value={platform} label={t('reservations.meta.platform')} />}
|
||||
{seat && <StatBox value={seat} label={t('reservations.meta.seat')} />}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { CheckCheck, Trash2 } from 'lucide-react'
|
||||
import { Bell, CheckCheck, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from '../i18n'
|
||||
import PageShell from '../components/Layout/PageShell'
|
||||
import HelpAnchor from '../components/Help/HelpAnchor'
|
||||
@@ -28,18 +28,21 @@ function InAppNotificationsPageDesktop(): React.ReactElement {
|
||||
<PageShell background="var(--bg-primary)">
|
||||
<HelpAnchor id="notifications" />
|
||||
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-content">
|
||||
{/* Header: a tile, the name with how many are unread, the count under it, the bulk tools at the end. */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<span className="grid place-items-center w-12 h-12 rounded-2xl bg-accent-subtle text-accent flex-shrink-0">
|
||||
<Bell className="w-5 h-5" />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="flex items-center gap-2 text-title font-semibold text-content">
|
||||
{t('notifications.title')}
|
||||
{unreadCount > 0 && (
|
||||
<span className="ms-2 px-2 py-0.5 rounded-full text-xs font-medium align-middle inline-flex items-center justify-center bg-content text-surface">
|
||||
<span className="px-2 h-6 inline-flex items-center rounded-full text-caption font-semibold tabular-nums bg-accent text-accent-text">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-sm mt-0.5 text-content-muted">
|
||||
<p className="text-body mt-0.5 text-content-muted">
|
||||
{total} {t('notifications.countLabel', { count: total })}
|
||||
</p>
|
||||
</div>
|
||||
@@ -49,7 +52,7 @@ function InAppNotificationsPageDesktop(): React.ReactElement {
|
||||
{unreadCount > 0 && (
|
||||
<button type="button"
|
||||
onClick={markAllRead}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors text-content-secondary bg-surface-hover hover:bg-surface-tertiary focus-visible:bg-surface-tertiary"
|
||||
className="flex items-center gap-1.5 h-9 px-3 rounded-xl text-body font-medium transition-colors text-content bg-surface-card border border-edge hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
>
|
||||
<CheckCheck className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('notifications.markAllRead')}</span>
|
||||
@@ -58,7 +61,7 @@ function InAppNotificationsPageDesktop(): React.ReactElement {
|
||||
{notifications.length > 0 && (
|
||||
<button type="button"
|
||||
onClick={requestDeleteAll}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors text-danger hover:bg-danger-soft focus-visible:bg-danger-soft"
|
||||
className="flex items-center gap-1.5 h-9 px-3 rounded-xl text-body font-medium transition-colors text-danger bg-surface-card border border-edge hover:bg-danger-soft focus-visible:bg-danger-soft"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('notifications.deleteAll')}</span>
|
||||
@@ -67,24 +70,26 @@ function InAppNotificationsPageDesktop(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{/* Filter: one segmented control rather than two loose buttons. */}
|
||||
<div className="inline-flex gap-1 p-1 mb-4 rounded-xl bg-surface-secondary" role="group" aria-label={t('notifications.title')}>
|
||||
<button type="button"
|
||||
onClick={() => setUnreadOnly(false)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${!unreadOnly ? 'bg-content text-surface' : 'bg-surface-hover text-content-secondary'}`}
|
||||
aria-pressed={!unreadOnly}
|
||||
className={`h-8 px-3.5 rounded-lg text-body font-medium transition-colors ${!unreadOnly ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'}`}
|
||||
>
|
||||
{t('notifications.all')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => setUnreadOnly(true)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${unreadOnly ? 'bg-content text-surface' : 'bg-surface-hover text-content-secondary'}`}
|
||||
aria-pressed={unreadOnly}
|
||||
className={`h-8 px-3.5 rounded-lg text-body font-medium transition-colors ${unreadOnly ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'}`}
|
||||
>
|
||||
{t('notifications.unreadOnly')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Notification list */}
|
||||
<div className="rounded-xl border overflow-hidden border-edge bg-surface-card">
|
||||
<div className="rounded-2xl border overflow-hidden border-edge bg-surface-card py-1">
|
||||
{isLoading && displayed.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Spinner className="w-6 h-6 border-2 border-slate-200 border-t-current" />
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import type { Journey } from '../store/journeyStore'
|
||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||
import { useJourney } from './journey/useJourney'
|
||||
import { journeyYearLabel } from './journey/journeyModel'
|
||||
import HelpAnchor from '../components/Help/HelpAnchor'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../components/shared/DialogShell'
|
||||
import { EditorField, INPUT } from '../components/shared/dialogParts'
|
||||
@@ -438,7 +439,7 @@ function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?:
|
||||
<span className="absolute top-3.5 start-3.5 z-[2] inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-white text-[11px] font-semibold"
|
||||
style={{ background: 'rgba(255,255,255,0.18)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid rgba(255,255,255,0.26)' }}>
|
||||
<Calendar size={12} strokeWidth={2.2} />
|
||||
{new Date(j.created_at).getFullYear()}
|
||||
{journeyYearLabel(j)}
|
||||
</span>
|
||||
{lifecycle !== 'live' && (
|
||||
<span className="absolute top-3.5 end-3.5 z-[2] px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wide text-white"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { journeyYearLabel } from './journeyModel';
|
||||
|
||||
describe('journeyYearLabel (#2685)', () => {
|
||||
it('FE-PAGE-JOURNEY-YEAR-001: names the year of the trips, not the year the journey was written', () => {
|
||||
expect(
|
||||
journeyYearLabel({ trip_date_min: '2024-09-12', trip_date_max: '2024-09-20', created_at: '2026-03-01 10:00:00' })
|
||||
).toBe('2024');
|
||||
});
|
||||
|
||||
it('FE-PAGE-JOURNEY-YEAR-002: a trip over New Year names both years', () => {
|
||||
expect(
|
||||
journeyYearLabel({ trip_date_min: '2024-12-28', trip_date_max: '2025-01-04', created_at: '2026-03-01 10:00:00' })
|
||||
).toBe('2024/2025');
|
||||
});
|
||||
|
||||
it('FE-PAGE-JOURNEY-YEAR-003: one known date is enough, and without any it falls back to when it was written', () => {
|
||||
expect(
|
||||
journeyYearLabel({ trip_date_min: null, trip_date_max: '2023-05-02', created_at: '2026-03-01 10:00:00' })
|
||||
).toBe('2023');
|
||||
expect(journeyYearLabel({ trip_date_min: null, trip_date_max: null, created_at: '2026-03-01 10:00:00' })).toBe(
|
||||
'2026'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/** The year a journey card names: the years its trips cover, not the year it was written (#2685). */
|
||||
export function journeyYearLabel(journey: {
|
||||
trip_date_min?: string | null;
|
||||
trip_date_max?: string | null;
|
||||
created_at?: string | number | null;
|
||||
}): string {
|
||||
// The dates are calendar dates, so the year is read from the text, not through a Date in some zone.
|
||||
const from = journey.trip_date_min?.slice(0, 4);
|
||||
const to = journey.trip_date_max?.slice(0, 4);
|
||||
if (from && to && to !== from) return `${from}/${to}`;
|
||||
if (from || to) return (from || to) as string;
|
||||
return journey.created_at ? String(new Date(journey.created_at).getFullYear()) : '';
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { SharedPlaceDetails } from './SharedPlaceDetails';
|
||||
|
||||
/** FE-PAGE-SHARED-PLACE-001: a stop's description and notes on a shared trip, read as the planner shows them (#2692). */
|
||||
|
||||
describe('SharedPlaceDetails', () => {
|
||||
it('FE-PAGE-SHARED-PLACE-001: renders markdown in the description and notes, and keeps raw HTML as text', () => {
|
||||
render(
|
||||
<SharedPlaceDetails
|
||||
place={{
|
||||
name: 'Market',
|
||||
address: 'Market Square 1',
|
||||
description: [
|
||||
'## Opening',
|
||||
'',
|
||||
'| Day | Hours |',
|
||||
'| --- | --- |',
|
||||
'| Sat | 8 to 14 |',
|
||||
'',
|
||||
'<img src=x onerror=alert(1)>',
|
||||
].join('\n'),
|
||||
notes: ['- fruit', '- bread'].join('\n'),
|
||||
}}
|
||||
assignmentNotes="**Bring** cash"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole('heading', { name: 'Opening' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('cell', { name: '8 to 14' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Bring').tagName).toBe('STRONG');
|
||||
expect(screen.getByText('fruit').tagName).toBe('LI');
|
||||
expect(document.querySelector('img[src="x"]')).toBeNull();
|
||||
expect(screen.queryByText('## Opening')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from '../../i18n'
|
||||
import { fs } from '../../components/shared/DialogShell'
|
||||
import { Tooltip } from '../../components/shared/Tooltip'
|
||||
import { WhiteBadge } from '../../components/Planner/planParts'
|
||||
import { SafeMarkdown } from '../../components/shared/SafeMarkdown'
|
||||
import { getGoogleMapsUrlForPlace } from '../../components/Planner/placeGoogleMaps'
|
||||
import { formatDurationMinutes, isHttpUrl, plannedStayMinutes } from './sharedTripModel'
|
||||
|
||||
@@ -61,7 +62,7 @@ export function SharedPlaceDetails({ place, assignmentNotes }: { place: SharedPl
|
||||
</div>
|
||||
)}
|
||||
{place.description && (
|
||||
<div className="whitespace-pre-wrap text-content-secondary [overflow-wrap:anywhere]" style={fs(12, 'body')}>{place.description}</div>
|
||||
<div className="collab-note-md-full text-content-secondary [overflow-wrap:anywhere]" style={fs(12, 'body')}><SafeMarkdown>{place.description}</SafeMarkdown></div>
|
||||
)}
|
||||
{dayNote && <NoteLine label={t('places.assignmentNotes')} text={dayNote} strong />}
|
||||
{placeNote && <NoteLine label={t('places.formNotes')} text={placeNote} />}
|
||||
@@ -98,7 +99,7 @@ function NoteLine({ label, text, strong = false }: { label: string; text: string
|
||||
<Tooltip label={label} placement="top">
|
||||
<div className={`flex items-start gap-1.5 rounded-lg bg-surface-secondary px-2 py-1.5 ${strong ? 'text-content-secondary' : 'text-content-muted'}`} style={fs(11.5, 'body')}>
|
||||
<FileText size={11} strokeWidth={2} className="mt-0.5 flex-none text-content-faint" aria-label={label} />
|
||||
<span className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere]">{text}</span>
|
||||
<div className="collab-note-md-full min-w-0 [overflow-wrap:anywhere]"><SafeMarkdown>{text}</SafeMarkdown></div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ import { aiConnectionsRepo } from '../repo/aiConnectionsRepo';
|
||||
import { assistantRepo } from '../repo/assistantRepo';
|
||||
import { OnlineOnlyError } from '../repo/onlineOnly';
|
||||
import type { Assignment } from '../types';
|
||||
import { browserTimeZone } from '../utils/localDate';
|
||||
import { randomId } from '../utils/randomId';
|
||||
import type { RouteProfileFocus } from '../utils/routeGeometry';
|
||||
import { assistantScopeOf, assistantTargetOf, type AssistantChatKey } from './assistantChatKey';
|
||||
@@ -331,7 +332,7 @@ function errorCodeOf(err: unknown): AssistantErrorCode {
|
||||
function clientContext() {
|
||||
return {
|
||||
locale: useSettingsStore.getState().settings.language || 'en',
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
timeZone: browserTimeZone() ?? 'UTC',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { forgetServerLanguage } from './settingsStore'
|
||||
import { forgetResumeRoute } from '../utils/resumeRoute'
|
||||
import { markSignedOut, clearSignedOut } from '../utils/signedOut'
|
||||
import { forgetPushDeviceOnLogout, resyncPushSubscription } from '../push/webPush'
|
||||
import { browserTimeZone } from '../utils/localDate'
|
||||
|
||||
/** How long a logout waits for the queued changes to go out. */
|
||||
const LOGOUT_FLUSH_MS = 5000
|
||||
@@ -154,7 +155,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
hasMapsKey: false,
|
||||
hasAmapKey: false,
|
||||
placesProvider: 'auto',
|
||||
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
serverTimezone: browserTimeZone() ?? '',
|
||||
appRequireMfa: false,
|
||||
tripRemindersEnabled: false,
|
||||
placesPhotosEnabled: true,
|
||||
|
||||
@@ -54,6 +54,7 @@ interface Host {
|
||||
styleClip: BookCopiedStyle | null;
|
||||
commit: (fn: (doc: BookDocument) => BookDocument) => void;
|
||||
apply: (fn: (doc: BookDocument) => BookDocument) => void;
|
||||
refuseFull: () => void;
|
||||
}
|
||||
|
||||
type Set = (partial: Partial<Host> | ((s: Host) => Partial<Host>)) => void;
|
||||
@@ -370,7 +371,11 @@ function panelActions(
|
||||
if (!doc) return;
|
||||
const chosen = spread.elements.filter((e) => ids.includes(e.id));
|
||||
// The page's own cap: copies that would make the book unsaveable are not made.
|
||||
if (!chosen.length || spread.elements.length + chosen.length > MAX_SPREAD_ELEMENTS) return;
|
||||
if (!chosen.length) return;
|
||||
if (spread.elements.length + chosen.length > MAX_SPREAD_ELEMENTS) {
|
||||
get().refuseFull();
|
||||
return;
|
||||
}
|
||||
const copies = chosen.map(
|
||||
(e) =>
|
||||
({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand'
|
||||
import type { BookDocument, BookElement, BookFrame, BookLayout, BookSpread } from '@trek/shared'
|
||||
import type { BookAlignTo, BookDocument, BookElement, BookFrame, BookLayout, BookSpread } from '@trek/shared'
|
||||
import {
|
||||
MAX_BOOK_LAYOUTS, MAX_SPREAD_ELEMENTS, applySavedBookLayout as applySavedLayout,
|
||||
bookLayoutFromSpread as layoutFromSpread, canEditSpread as isMovableSpread, fitBookTexts, innerSpreadBounds as innerBounds,
|
||||
@@ -35,6 +35,21 @@ interface StudioState extends StudioLayerActions {
|
||||
gestureBase: BookDocument | null
|
||||
/** Elements copied for pasting onto any page of the book (#2316). Kept across page switches. */
|
||||
clipboard: StudioClipboard | null
|
||||
/**
|
||||
* Counts the times something was not added because the page already holds
|
||||
* MAX_SPREAD_ELEMENTS. The Studio says so once per count (StudioNotices), so
|
||||
* a full page no longer swallows a click without a word.
|
||||
*/
|
||||
refusedFull: number
|
||||
/** Notes that an addition was refused because the page is full. */
|
||||
refuseFull: () => void
|
||||
/**
|
||||
* What the arrange section lines things up with, as last picked: kept for the
|
||||
* session, so a new selection or another kind of element does not reset it.
|
||||
* Null until somebody picks; the section then chooses by how much is selected.
|
||||
*/
|
||||
alignTo: BookAlignTo | null
|
||||
setAlignTo: (to: BookAlignTo) => void
|
||||
/**
|
||||
* The page a chat card points at, outlined in the page rail (journey-studio.md
|
||||
* 4.2). Not part of the book or its history; opening a book clears it.
|
||||
@@ -147,6 +162,10 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
future: [],
|
||||
gestureBase: null,
|
||||
clipboard: null,
|
||||
refusedFull: 0,
|
||||
refuseFull: () => set(st => ({ refusedFull: st.refusedFull + 1 })),
|
||||
alignTo: null,
|
||||
setAlignTo: to => set({ alignTo: to }),
|
||||
hintSpread: null,
|
||||
readOnly: false,
|
||||
|
||||
@@ -228,7 +247,11 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
addElement: (spreadIndex, el) => {
|
||||
// The page's own cap, as paste and duplicate keep it: what would make the book unsaveable is not added.
|
||||
const target = get().doc?.spreads[spreadIndex]
|
||||
if (!target || target.elements.length >= MAX_SPREAD_ELEMENTS) return false
|
||||
if (!target) return false
|
||||
if (target.elements.length >= MAX_SPREAD_ELEMENTS) {
|
||||
get().refuseFull()
|
||||
return false
|
||||
}
|
||||
get().commit(doc =>
|
||||
replaceSpread(doc, spreadIndex, sp => ({ ...sp, elements: [...sp.elements, el] })))
|
||||
/*
|
||||
@@ -268,6 +291,10 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
const room = Math.max(0, MAX_SPREAD_ELEMENTS - target.elements.length)
|
||||
const source = doc.spreads.find(sp => sp.id === clipboard.fromSpreadId)
|
||||
// A copied group arrives as a group of its own, never as part of one already on the page.
|
||||
if (room === 0) {
|
||||
get().refuseFull()
|
||||
return
|
||||
}
|
||||
const copies = regroupCopies(pastedElements(clipboard, target, doc.page).slice(0, room), source, bookElementId)
|
||||
if (!copies.elements.length) return
|
||||
get().commit(d => replaceSpread(d, spreadIndex, sp => withCopies(sp, copies)))
|
||||
|
||||
@@ -2,25 +2,10 @@
|
||||
Studio in the planner's window (assistantFrame.tsx), so the page and the
|
||||
inspector keep all their room. */
|
||||
|
||||
/* The toggle is the bar's newest tool, so it gives up its label first: from 1360
|
||||
to 1519 it is an icon while the others keep theirs, and the bar fits where it
|
||||
did before. The journey's name gives way too rather than push the bar past
|
||||
the window. */
|
||||
@media (max-width: 1519px) {
|
||||
.trek-dash .st-assistant-toggle .st-tool-label {
|
||||
display: none;
|
||||
}
|
||||
.trek-dash .st-assistant-toggle {
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.trek-dash .st-bar .st-back {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trek-dash .st-bar .st-back > svg {
|
||||
flex: none;
|
||||
/* The toggle is an icon tile of the head row; open, it reads as pressed. */
|
||||
.trek-dash .st-tool.st-head-solo.st-assistant-toggle.is-on {
|
||||
background: color-mix(in oklch, var(--accent) 14%, var(--surface));
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.trek-dash .st-assistant-notice {
|
||||
|
||||
@@ -71,6 +71,19 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* In the closed picker the face's name comes first: the kind gives way, cut
|
||||
short and then gone, before a letter of the name is lost. */
|
||||
.trek-dash .st-fontpick-current .st-fontpick-face {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.trek-dash .st-fontpick-current .st-fontpick-span {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.trek-dash .st-fontpick-caret {
|
||||
flex: none;
|
||||
color: var(--ink-3);
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/* ----------------- head band (StudioHeader.tsx) ----------------- *
|
||||
* The shape every TREK dialog head has: a tinted band, a raised tile, a small
|
||||
* line above the title and the close button at the end. The band keeps the
|
||||
* glass of the old bar under its tint, so the journey still shows through.
|
||||
*/
|
||||
.trek-dash .st-head {
|
||||
/* Its own stacking context, above the rails: the panels below carry z-index 1,
|
||||
and without this the format menu would open behind them. */
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding-inline: 14px 16px;
|
||||
background:
|
||||
linear-gradient(
|
||||
color-mix(in srgb, var(--accent) 7%, transparent),
|
||||
color-mix(in srgb, var(--accent) 7%, transparent)
|
||||
),
|
||||
var(--glass-bg);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
|
||||
/* The journey's cover, so it is always clear whose book this is. */
|
||||
.trek-dash .st-head-tile {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
border-radius: 13px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-sm);
|
||||
}
|
||||
.trek-dash .st-head-tile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.trek-dash .st-head-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* The wordmark: what this screen is, above its name, in the title's own ink. */
|
||||
.trek-dash .st-head-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 17px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.trek-dash .st-head-eyebrow svg {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.trek-dash .st-head-title {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.trek-dash .st-head-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
/* Every piece of the row is one raised tile of one height and one corner:
|
||||
the groups, who is here, help, export and close. */
|
||||
.trek-dash .st-head {
|
||||
--st-head-h: 36px;
|
||||
}
|
||||
|
||||
/* Tools that belong together share one raised group, like a segmented control. */
|
||||
.trek-dash .st-head-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: var(--st-head-h);
|
||||
padding: 3px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-xs);
|
||||
}
|
||||
|
||||
/* A tool standing alone is a group of one: help, and export in the accent. */
|
||||
.trek-dash .st-tool.st-head-solo {
|
||||
height: var(--st-head-h);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.trek-dash .st-tool.is-icon.st-head-solo {
|
||||
width: var(--st-head-h);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-xs);
|
||||
}
|
||||
.trek-dash .st-tool.is-icon.st-head-solo:hover:not(:disabled) {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-sm);
|
||||
}
|
||||
.trek-dash .st-tool.is-primary.st-head-solo {
|
||||
padding-inline: 16px;
|
||||
box-shadow: var(--sh-xs);
|
||||
}
|
||||
|
||||
/* Whether the book is saved, as a tile beside who is here; a conflict keeps its warning tint. */
|
||||
.trek-dash .st-head .st-save {
|
||||
height: var(--st-head-h);
|
||||
padding-inline: 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-xs);
|
||||
color: var(--ink-2);
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.trek-dash .st-head .st-save.is-conflict {
|
||||
background: color-mix(in oklch, var(--warning) 14%, var(--surface));
|
||||
border: 0;
|
||||
}
|
||||
.trek-dash .st-head .st-save.is-error {
|
||||
background: color-mix(in oklch, var(--danger) 10%, var(--surface));
|
||||
}
|
||||
|
||||
/* A book a newer Studio saved: the whole Studio under a veil, one card, one way out. */
|
||||
.trek-dash .st-newer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: color-mix(in oklch, var(--surface) 55%, transparent);
|
||||
backdrop-filter: blur(10px) saturate(1.1);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(1.1);
|
||||
animation: st-newer-in 0.22s ease-out both;
|
||||
}
|
||||
.trek-dash .st-newer-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
width: min(380px, 100%);
|
||||
padding: 28px 28px 24px;
|
||||
border-radius: 20px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-lg);
|
||||
text-align: center;
|
||||
}
|
||||
.trek-dash .st-newer-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.trek-dash .st-newer-text {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.trek-dash .st-newer-reload {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
margin-top: 4px;
|
||||
border-radius: 12px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
transition: filter 0.16s;
|
||||
}
|
||||
.trek-dash .st-newer-reload:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.trek-dash .st-newer-reload:focus-visible {
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 0 0 2px var(--surface),
|
||||
0 0 0 4px var(--accent);
|
||||
}
|
||||
@keyframes st-newer-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trek-dash .st-newer {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Who is here, as a tile like the tools beside it. */
|
||||
.trek-dash .st-head .st-peers {
|
||||
height: var(--st-head-h);
|
||||
padding-inline: 8px 12px;
|
||||
margin-inline-start: 0;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-xs);
|
||||
}
|
||||
.trek-dash .st-head .st-peer-dot {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-color: var(--surface);
|
||||
}
|
||||
.trek-dash .st-head .st-peer-label {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.trek-dash .st-head-close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: var(--st-head-h);
|
||||
height: var(--st-head-h);
|
||||
flex: none;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-xs);
|
||||
color: var(--ink-3);
|
||||
transition:
|
||||
color 0.16s,
|
||||
box-shadow 0.16s;
|
||||
}
|
||||
.trek-dash .st-head-close:hover {
|
||||
color: var(--ink);
|
||||
box-shadow: var(--sh-sm);
|
||||
}
|
||||
.trek-dash .st-head-close:focus {
|
||||
outline: none;
|
||||
}
|
||||
.trek-dash .st-head-close:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--accent-soft),
|
||||
0 0 0 3px var(--accent);
|
||||
}
|
||||
|
||||
/* The format's shape, drawn in the tool's own ink. */
|
||||
.trek-dash .st-head-chip-shape {
|
||||
flex: none;
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 2px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* The book view moved to the zoom, where the page's view lives; on reads as pressed. */
|
||||
.trek-dash .st-zoom button.is-on {
|
||||
background: color-mix(in oklch, var(--accent) 14%, transparent);
|
||||
color: var(--ink);
|
||||
}
|
||||
@@ -332,3 +332,18 @@
|
||||
.trek-dash .st-route-stop > span.st-chip {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* The blank inside of a cover beside the first and the last page in the page
|
||||
* rail (SpreadThumb's coverInside): the bound book opens on those pages with
|
||||
* the cover's inside opposite, which is why they stand alone. Drawn as plain
|
||||
* card under a faint hatch, so it reads as a page nothing is printed on.
|
||||
*/
|
||||
.trek-dash .st-thumb-inside {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
repeating-linear-gradient(135deg, color-mix(in oklch, var(--ink) 7%, transparent) 0 1px, transparent 1px 6px),
|
||||
color-mix(in oklch, var(--surface-2) 80%, transparent);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
.trek-dash .st-tpl-grid.is-spreads {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
/* The book's own layouts and the built-in ones lie in the same grid as the designed pages. */
|
||||
.trek-dash .st-thumbs.st-tpl-grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.trek-dash .st-tpl-card {
|
||||
min-width: 0;
|
||||
|
||||
@@ -65,10 +65,13 @@
|
||||
line-height: 1;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
/* The accents sit in the sheet's top corner, clear of a wide "Aa" below. */
|
||||
.trek-dash .st-theme-dots {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
inset-inline-end: 8px;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.trek-dash .st-theme-dots > span {
|
||||
width: 11px;
|
||||
@@ -127,6 +130,28 @@
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
/* The accents' one name spans their three columns, level with the others'
|
||||
names; every entry is placed, so the name does not push them a row down. */
|
||||
.trek-dash .st-theme-palette > .st-theme-colour {
|
||||
grid-row: 1;
|
||||
}
|
||||
.trek-dash .st-theme-palette > :nth-child(2) {
|
||||
grid-column: 2;
|
||||
}
|
||||
.trek-dash .st-theme-palette > :nth-child(3) {
|
||||
grid-column: 3;
|
||||
}
|
||||
.trek-dash .st-theme-palette > :nth-child(4) {
|
||||
grid-column: 4;
|
||||
}
|
||||
.trek-dash .st-theme-palette > :nth-child(5) {
|
||||
grid-column: 5;
|
||||
}
|
||||
.trek-dash .st-theme-accents-name {
|
||||
grid-row: 1;
|
||||
grid-column: 3 / span 3;
|
||||
align-self: end;
|
||||
}
|
||||
.trek-dash .st-theme-colour {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -154,10 +179,14 @@
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* On one line: broken inside a word, "Accent" read as "Accen t". A name the
|
||||
column cannot hold is cut, and the entry's title says it whole. */
|
||||
.trek-dash .st-theme-colour-name {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
letter-spacing: -0.01em;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
font-size: calc(10.5px * var(--fs-scale-caption, 1));
|
||||
@@ -210,13 +239,12 @@
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* The last section's own rule already sets this apart; a second one doubled it. */
|
||||
.trek-dash .st-theme-apply {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line-2);
|
||||
padding-top: 14px;
|
||||
}
|
||||
.trek-dash .st-theme-apply .st-hint {
|
||||
padding: 0 2px;
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
z-index: 9999;
|
||||
inset: 16px;
|
||||
display: grid;
|
||||
grid-template-rows: 56px minmax(0, 1fr);
|
||||
grid-template-rows: 72px minmax(0, 1fr);
|
||||
border-radius: var(--r-xl);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--glass-border);
|
||||
@@ -88,66 +88,7 @@
|
||||
.st-exit { animation-name: st-fade-out; }
|
||||
}
|
||||
|
||||
/* ----------------- top bar ----------------- */
|
||||
.trek-dash .st-bar {
|
||||
/* Its own stacking context, above the rails: the panels below carry z-index 1,
|
||||
and without this the format menu would open behind them. */
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding-block: 0;
|
||||
padding-inline: 10px 18px;
|
||||
min-width: 0;
|
||||
background: var(--glass-bg);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
|
||||
.trek-dash .st-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding: 0 12px 0 8px;
|
||||
border-radius: 999px;
|
||||
color: var(--ink-2);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
max-width: 240px;
|
||||
transition: background .18s, color .18s;
|
||||
}
|
||||
.trek-dash .st-back:hover { background: var(--accent-soft); color: var(--ink); }
|
||||
.trek-dash .st-back .st-back-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The journey's own cover, so you never lose track of whose book this is. */
|
||||
.trek-dash .st-cover {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 7px;
|
||||
object-fit: cover;
|
||||
flex: none;
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.trek-dash .st-bar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex: none;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
/* The undo/redo pair belongs together; everything else in the group breathes. */
|
||||
.trek-dash .st-bar-group .st-tool.is-icon + .st-tool.is-icon { margin-inline-start: -5px; }
|
||||
|
||||
/* ----------------- tools (head band and elsewhere) ----------------- */
|
||||
.trek-dash .st-tool {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1506,38 +1447,6 @@
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ----------------- brand ----------------- *
|
||||
* The lockup takes the bar's ink through `currentColor`, so it is dark on a
|
||||
* light theme and light on a dark one from one copy of the artwork.
|
||||
*/
|
||||
.trek-dash .st-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--ink);
|
||||
min-width: 0;
|
||||
}
|
||||
.trek-dash .st-brand svg { flex: none; }
|
||||
|
||||
/* Small, quiet and set apart — a beta mark states a fact about the feature; it
|
||||
is not a call to action and should not read as one. */
|
||||
.trek-dash .st-beta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 16px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
background: color-mix(in oklch, var(--accent) 13%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--accent) 26%, transparent);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ----------------- custom trim ----------------- */
|
||||
.trek-dash .st-menu-sep {
|
||||
height: 1px;
|
||||
@@ -1991,9 +1900,9 @@
|
||||
* The book's own layouts sit above the built-in ones, with the save button
|
||||
* closing their group, so what a click keeps and where it goes read as one.
|
||||
*/
|
||||
.trek-dash .st-saved-layouts { display: flex; flex-direction: column; margin-bottom: 14px; }
|
||||
.trek-dash .st-saved-layouts { display: flex; flex-direction: column; }
|
||||
.trek-dash .st-saved-layouts .st-hint { margin: 0 0 4px; }
|
||||
.trek-dash .st-saved-layouts .st-thumbs:empty { display: none; }
|
||||
.trek-dash .st-saved-layouts .st-add-page { margin-top: 8px; }
|
||||
.trek-dash .st-saved-layouts .st-add-page:disabled { opacity: .5; cursor: default; }
|
||||
.trek-dash .st-saved-layouts .st-add-page:disabled:hover { background: none; border-color: var(--line-2); color: var(--ink-3); }
|
||||
.trek-dash .st-saved-layouts .st-builtin-label { margin-top: 18px; }
|
||||
|
||||
@@ -44,10 +44,26 @@ const VECTOR_CACHE = 'gl-map-offline'
|
||||
*/
|
||||
const GLYPH_RANGES = ['0-255', '256-511', '512-767', '768-1023', '1024-1279', '1280-1535', '1536-1791', '8192-8447', '8448-8703']
|
||||
|
||||
interface StyleSource {
|
||||
type?: string
|
||||
url?: string
|
||||
tiles?: string[]
|
||||
minzoom?: number
|
||||
maxzoom?: number
|
||||
}
|
||||
|
||||
/** One tiled source of the style, with the zooms it actually serves. */
|
||||
interface TileSource {
|
||||
template: string
|
||||
minZoom: number
|
||||
maxZoom: number
|
||||
vector: boolean
|
||||
}
|
||||
|
||||
interface StyleDoc {
|
||||
sprite?: string
|
||||
glyphs?: string
|
||||
sources?: Record<string, { url?: string; tiles?: string[] }>
|
||||
sources?: Record<string, StyleSource>
|
||||
layers?: { layout?: { 'text-font'?: string[] } }[]
|
||||
}
|
||||
|
||||
@@ -100,28 +116,73 @@ async function openVectorCache(): Promise<Cache | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Tile coordinates covering the box, low zoom first so a truncated run is still useful. */
|
||||
function enumerateVectorTiles(bbox: TileBbox, minZoom: number, maxZoom: number): [number, number, number][] {
|
||||
const out: [number, number, number][] = []
|
||||
for (let z = minZoom; z <= maxZoom; z++) {
|
||||
// Same screen-widened rectangle as the raster side: the opening fitBounds
|
||||
// view shows more than the places extent on the non-limiting axis (#2180).
|
||||
const { minX, maxX, minY, maxY } = tileRange(bbox, z)
|
||||
for (let x = minX; x <= maxX; x++) {
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
out.push([z, x, y])
|
||||
if (out.length >= MAX_VECTOR_TILES) return out
|
||||
}
|
||||
/**
|
||||
* Tile URLs covering the box, vector sources first and low zoom first within
|
||||
* each, so a truncated run is still useful. A source is only asked for the
|
||||
* zooms it serves: OpenFreeMap's shaded relief stops at z6 and answers 404
|
||||
* above it.
|
||||
*/
|
||||
function enumerateTileUrls(bbox: TileBbox, sources: TileSource[]): string[] {
|
||||
const out: string[] = []
|
||||
for (const source of sources) {
|
||||
for (let z = Math.max(source.minZoom, VECTOR_MIN_ZOOM); z <= Math.min(source.maxZoom, VECTOR_MAX_ZOOM); z++) {
|
||||
if (!pushZoomTiles(out, source.template, bbox, z)) return out
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** One zoom's tiles of a source onto `out`; false once the run is full. */
|
||||
function pushZoomTiles(out: string[], template: string, bbox: TileBbox, z: number): boolean {
|
||||
// Same screen-widened rectangle as the raster side: the opening fitBounds
|
||||
// view shows more than the places extent on the non-limiting axis (#2180).
|
||||
const { minX, maxX, minY, maxY } = tileRange(bbox, z)
|
||||
for (let x = minX; x <= maxX; x++) {
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
out.push(template.replace('{z}', String(z)).replace('{x}', String(x)).replace('{y}', String(y)))
|
||||
if (out.length >= MAX_VECTOR_TILES) return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The style's tiled sources, vector first. A source given as a TileJSON
|
||||
* reference gets its template and zooms from there: the tile path carries a
|
||||
* planet version, and asking without that segment answers 200 with an empty
|
||||
* body, so it cannot be guessed. A source without tiles (GeoJSON) is skipped.
|
||||
*/
|
||||
async function readTileSources(style: StyleDoc, cache: Cache | null): Promise<TileSource[]> {
|
||||
const out: TileSource[] = []
|
||||
for (const source of Object.values(style.sources ?? {})) {
|
||||
let { tiles, minzoom, maxzoom } = source
|
||||
if (!tiles?.[0] && source.url) {
|
||||
await warm(cache, source.url)
|
||||
const tileJson = await fetchJson<{ tiles?: string[]; minzoom?: number; maxzoom?: number }>(source.url)
|
||||
tiles = tileJson?.tiles
|
||||
minzoom ??= tileJson?.minzoom
|
||||
maxzoom ??= tileJson?.maxzoom
|
||||
}
|
||||
if (!tiles?.[0]) continue
|
||||
out.push({
|
||||
template: tiles[0],
|
||||
minZoom: minzoom ?? VECTOR_MIN_ZOOM,
|
||||
maxZoom: maxzoom ?? VECTOR_MAX_ZOOM,
|
||||
vector: source.type !== 'raster' && source.type !== 'raster-dem',
|
||||
})
|
||||
}
|
||||
return out.sort((a, b) => Number(b.vector) - Number(a.vector))
|
||||
}
|
||||
|
||||
/**
|
||||
* The device-wide half: the style, its TileJSON, the sprite and the glyphs.
|
||||
* Returns the tile URL template, or null when the style could not be read.
|
||||
* Returns the vector tile URL template, or null when the style could not be read.
|
||||
*/
|
||||
export async function prefetchStyleAssets(styleUrl: string): Promise<string | null> {
|
||||
return (await prefetchStyle(styleUrl))?.[0]?.template ?? null
|
||||
}
|
||||
|
||||
async function prefetchStyle(styleUrl: string): Promise<TileSource[] | null> {
|
||||
const cache = await openVectorCache()
|
||||
const style = await fetchJson<StyleDoc>(styleUrl)
|
||||
if (!style) return null
|
||||
@@ -148,19 +209,7 @@ export async function prefetchStyleAssets(styleUrl: string): Promise<string | nu
|
||||
)
|
||||
}
|
||||
|
||||
// The vector source is a TileJSON reference, and the tile path it hands back
|
||||
// carries a planet version. Asking without that segment answers 200 with an
|
||||
// empty body, so the template has to come from the TileJSON rather than be
|
||||
// guessed.
|
||||
for (const source of Object.values(style.sources ?? {})) {
|
||||
if (source.tiles?.[0]) return source.tiles[0]
|
||||
if (source.url) {
|
||||
await warm(cache, source.url)
|
||||
const tileJson = await fetchJson<{ tiles?: string[] }>(source.url)
|
||||
if (tileJson?.tiles?.[0]) return tileJson.tiles[0]
|
||||
}
|
||||
}
|
||||
return null
|
||||
return readTileSources(style, cache)
|
||||
}
|
||||
|
||||
export interface VectorPrefetchResult {
|
||||
@@ -184,25 +233,21 @@ export async function prefetchVectorForPlaces(
|
||||
const bbox = computeBbox(places)
|
||||
if (!bbox) return { tiles: 0, template: null }
|
||||
|
||||
const template = await prefetchStyleAssets(styleUrl)
|
||||
if (!template || isCancelled()) return { tiles: 0, template }
|
||||
const sources = await prefetchStyle(styleUrl)
|
||||
const template = sources?.[0]?.template ?? null
|
||||
if (!sources?.length || isCancelled()) return { tiles: 0, template }
|
||||
|
||||
const cache = await openVectorCache()
|
||||
const coords = enumerateVectorTiles(bbox, VECTOR_MIN_ZOOM, VECTOR_MAX_ZOOM)
|
||||
const urls = enumerateTileUrls(bbox, sources)
|
||||
|
||||
let cursor = 0
|
||||
let fetched = 0
|
||||
|
||||
async function worker(): Promise<void> {
|
||||
while (cursor < coords.length) {
|
||||
while (cursor < urls.length) {
|
||||
if (isCancelled() || !navigator.onLine) return
|
||||
const next = coords[cursor++]
|
||||
if (!next) return
|
||||
const [z, x, y] = next
|
||||
const url = template!
|
||||
.replace('{z}', String(z))
|
||||
.replace('{x}', String(x))
|
||||
.replace('{y}', String(y))
|
||||
const url = urls[cursor++]
|
||||
if (!url) return
|
||||
if (await warm(cache, url)) fetched++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,11 @@ async function syncTrip(tripId: number): Promise<void> {
|
||||
// first sync is in. Ahead of the writes and rethrowing anything but a 404, it
|
||||
// threw the whole downloaded bundle away for that trip while the run still
|
||||
// reported it stored, so Settings said "N trips ready" over an empty database.
|
||||
await roadtripPreferencesRepo.read(tripId).catch(() => { /* optional addon, optional cache */ })
|
||||
// Asked only while the addon is on, like the tours below: with it off, every
|
||||
// trip of a dashboard load spent a request on a 404.
|
||||
if (useAddonStore.getState().isEnabled('roadtrip')) {
|
||||
await roadtripPreferencesRepo.read(tripId).catch(() => { /* optional cache */ })
|
||||
}
|
||||
|
||||
// Tours, the same way: optional, and never fatal. Without them every Tour
|
||||
// reads as a plain place offline. Asked only while the addon is on, so a
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { isLocalToday, localIsoDate, localMinutes } from './localDate'
|
||||
import { browserTimeZone, isLocalToday, isValidTimeZone, localIsoDate, localMinutes } from './localDate'
|
||||
|
||||
/**
|
||||
* "Today" must be the user's local calendar date. `toISOString()` is the UTC
|
||||
@@ -66,3 +66,26 @@ describe('isLocalToday', () => {
|
||||
expect(isLocalToday(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('browserTimeZone and isValidTimeZone (#2694)', () => {
|
||||
it('names the browser zone, and nothing when the browser names none', () => {
|
||||
expect(browserTimeZone()).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone)
|
||||
const original = Intl.DateTimeFormat.prototype.resolvedOptions
|
||||
const spy = vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockImplementation(function (this: Intl.DateTimeFormat) {
|
||||
return { ...original.call(this), timeZone: undefined as unknown as string }
|
||||
})
|
||||
try {
|
||||
expect(browserTimeZone()).toBeUndefined()
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts the zones Intl knows and refuses the rest', () => {
|
||||
expect(isValidTimeZone('Europe/Berlin')).toBe(true)
|
||||
expect(isValidTimeZone('UTC')).toBe(true)
|
||||
expect(isValidTimeZone('Mars/Olympus_Mons')).toBe(false)
|
||||
expect(isValidTimeZone('')).toBe(false)
|
||||
expect(isValidTimeZone(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,3 +27,24 @@ export function localMinutes(d: Date = new Date()): number {
|
||||
export function isLocalToday(date: string | null | undefined): boolean {
|
||||
return !!date && date.slice(0, 10) === localIsoDate()
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser's IANA time zone, or undefined when it cannot name one. Chrome
|
||||
* on Windows answers undefined for a system zone it cannot map, such as
|
||||
* Alberta's permanent UTC-6 (#2694), so every reader picks its own fallback.
|
||||
*/
|
||||
export function browserTimeZone(): string | undefined {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone as string | undefined
|
||||
return typeof tz === 'string' && tz ? tz : undefined
|
||||
}
|
||||
|
||||
/** Whether `Intl` accepts the zone; formatting with one it rejects throws a RangeError. */
|
||||
export function isValidTimeZone(tz: unknown): tz is string {
|
||||
if (typeof tz !== 'string' || !tz) return false
|
||||
try {
|
||||
new Intl.DateTimeFormat(undefined, { timeZone: tz })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ describe('TransportRow', () => {
|
||||
|
||||
expect(screen.getByText('LH 714 to Tokyo')).toBeInTheDocument()
|
||||
expect(container.textContent).toContain('08:15 – 18:40')
|
||||
expect(screen.getByText('Lufthansa · LH714 · MUC → HND')).toBeInTheDocument()
|
||||
expect(screen.getByText('Lufthansa, LH714, MUC → HND')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-014: a multi-day span shows only the day-relevant time', () => {
|
||||
@@ -234,7 +234,7 @@ describe('TransportRow', () => {
|
||||
} as unknown as TransportEntry
|
||||
render(<TransportRow {...base} res={train} />)
|
||||
|
||||
expect(screen.getByText('ICE 599 · Gl. 7 · Sitz 21A')).toBeInTheDocument()
|
||||
expect(screen.getByText('ICE 599, reservations.meta.platform 7, reservations.meta.seat 21A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-018: an untimed booking of an unknown type still renders its title', () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-TRSH-001 to FE-MOB-TRSH-033
|
||||
// FE-MOB-TRSH-001 to FE-MOB-TRSH-035
|
||||
|
||||
vi.mock('../../../../src/utils/fileDownload', () => ({ openFile: vi.fn() }))
|
||||
|
||||
@@ -442,4 +442,34 @@ describe('MTransportSheet', () => {
|
||||
expect(planner.handleSelectDay).toHaveBeenCalledWith(3, false)
|
||||
})
|
||||
})
|
||||
|
||||
// #2701: a leg row opens the sheet on that leg, not on the booking's first one.
|
||||
describe('opened from one leg of a multi-leg train', () => {
|
||||
const TRAIN = {
|
||||
...FLIGHT, id: 8, type: 'train', title: 'Takayama to Kyoto', confirmation_number: null,
|
||||
metadata: JSON.stringify({
|
||||
train_number: 'Hida 6', seat: '10-4C',
|
||||
legs: [
|
||||
{ from: 'Takayama', to: 'Nagoya', train_number: 'Hida 6', seat: '10-4C', platform: '1', dep_time: '09:40', arr_time: '12:10' },
|
||||
{ from: 'Nagoya', to: 'Kyoto', train_number: 'Nozomi 67', seat: '4-5A', platform: '14', dep_time: '12:35', arr_time: '13:10' },
|
||||
],
|
||||
}),
|
||||
} as unknown as Reservation
|
||||
|
||||
it("FE-MOB-TRSH-034: shows the second leg's own train, seat, platform and times", () => {
|
||||
render(<MTransportSheet planner={makePlanner({ reservations: [TRAIN] })} shell={makeShell({ sheet: { id: 'transport', payload: { reservationId: 8, legIndex: 1 } } })} />)
|
||||
expect(screen.getByText(/Nozomi 67/)).toBeInTheDocument()
|
||||
expect(screen.getByText('4-5A')).toBeInTheDocument()
|
||||
expect(screen.getByText('14')).toBeInTheDocument()
|
||||
expect(screen.getByText('12:35')).toBeInTheDocument()
|
||||
expect(screen.getByText('13:10')).toBeInTheDocument()
|
||||
expect(screen.queryByText('10-4C')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-035: without a leg the sheet still shows the booking as a whole', () => {
|
||||
render(<MTransportSheet planner={makePlanner({ reservations: [TRAIN] })} shell={makeShell({ sheet: { id: 'transport', payload: { reservationId: 8 } } })} />)
|
||||
expect(screen.getByText('10-4C')).toBeInTheDocument()
|
||||
expect(screen.queryByText('4-5A')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -128,14 +128,17 @@ describe('planTimelineModel — metadata parsing', () => {
|
||||
})
|
||||
|
||||
describe('planTimelineModel — transportSubtitle', () => {
|
||||
// The labels stay keys, so the assertions read what the subtitle puts where.
|
||||
const tr: TranslationFn = (key) => key
|
||||
|
||||
it('FE-MOB-PTLM-006: renders a synthetic leg as from → to', () => {
|
||||
const res = { ...buildReservation({ type: 'flight' }), __leg: { index: 0, total: 2, from: 'FRA', to: 'IST' } } as TransportEntry
|
||||
expect(transportSubtitle(res)).toBe('FRA → IST')
|
||||
expect(transportSubtitle(res, tr)).toBe('FRA → IST')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-007: renders a leg without endpoints as an empty subtitle', () => {
|
||||
const res = { ...buildReservation({ type: 'flight' }), __leg: { index: 1, total: 2, from: null, to: null } } as TransportEntry
|
||||
expect(transportSubtitle(res)).toBe('')
|
||||
expect(transportSubtitle(res, tr)).toBe('')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-008: joins airline, flight number and airports for a flight', () => {
|
||||
@@ -143,25 +146,42 @@ describe('planTimelineModel — transportSubtitle', () => {
|
||||
type: 'flight',
|
||||
metadata: JSON.stringify({ airline: 'LH', flight_number: 'LH123', departure_airport: 'FRA', arrival_airport: 'HND' }),
|
||||
})
|
||||
expect(transportSubtitle(res)).toBe('LH · LH123 · FRA → HND')
|
||||
expect(transportSubtitle(res, tr)).toBe('LH, LH123, FRA → HND')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-009: omits the airport pair when neither airport is known', () => {
|
||||
const res = buildReservation({ type: 'flight', metadata: JSON.stringify({ airline: 'LH', flight_number: 'LH123' }) })
|
||||
expect(transportSubtitle(res)).toBe('LH · LH123')
|
||||
expect(transportSubtitle(res, tr)).toBe('LH, LH123')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-010: labels platform and seat for a train', () => {
|
||||
const full = buildReservation({
|
||||
type: 'train', metadata: JSON.stringify({ train_number: 'ICE 599', platform: '7', seat: '32A' }),
|
||||
})
|
||||
expect(transportSubtitle(full)).toBe('ICE 599 · Gl. 7 · Sitz 32A')
|
||||
expect(transportSubtitle(buildReservation({ type: 'train', metadata: '{"train_number":"ICE 599"}' }))).toBe('ICE 599')
|
||||
expect(transportSubtitle(full, tr)).toBe('ICE 599, reservations.meta.platform 7, reservations.meta.seat 32A')
|
||||
expect(transportSubtitle(buildReservation({ type: 'train', metadata: '{"train_number":"ICE 599"}' }), tr)).toBe('ICE 599')
|
||||
})
|
||||
|
||||
// #2701: a leg names its own train, platform and seat, not the first leg's.
|
||||
it('FE-MOB-PTLM-011b: a train leg carries its own number, platform and seat', () => {
|
||||
const res = {
|
||||
...buildReservation({ type: 'train', metadata: JSON.stringify({ train_number: 'Hida 6', seat: '10-4C' }) }),
|
||||
__leg: { index: 1, total: 2, from: 'Nagoya', to: 'Kyoto', train_number: 'Nozomi 67', platform: '14', seat: '4-5A' },
|
||||
} as TransportEntry
|
||||
expect(transportSubtitle(res, tr)).toBe('Nozomi 67, reservations.meta.platform 14, reservations.meta.seat 4-5A, Nagoya → Kyoto')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-011c: a flight leg carries its own airline and flight number', () => {
|
||||
const res = {
|
||||
...buildReservation({ type: 'flight' }),
|
||||
__leg: { index: 1, total: 2, from: 'IST', to: 'HND', airline: 'TK', flight_number: 'TK52' },
|
||||
} as TransportEntry
|
||||
expect(transportSubtitle(res, tr)).toBe('TK, TK52, IST → HND')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-011: falls back to the location for any other type', () => {
|
||||
expect(transportSubtitle(buildReservation({ type: 'bus', location: 'Central Station' }))).toBe('Central Station')
|
||||
expect(transportSubtitle(buildReservation({ type: 'bus', location: null }))).toBe('')
|
||||
expect(transportSubtitle(buildReservation({ type: 'bus', location: 'Central Station' }), tr)).toBe('Central Station')
|
||||
expect(transportSubtitle(buildReservation({ type: 'bus', location: null }), tr)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import type { BookRecord } from '@trek/shared'
|
||||
import { fireEvent, render, screen } from '../../helpers/render'
|
||||
import { SaveIndicator } from '../../../src/components/Studio/SaveIndicator'
|
||||
import { NewerBookOverlay, SaveIndicator } from '../../../src/components/Studio/SaveIndicator'
|
||||
|
||||
/**
|
||||
* The save status (#1973).
|
||||
@@ -102,16 +102,22 @@ describe('a conflict', () => {
|
||||
|
||||
describe('a book a newer Studio saved', () => {
|
||||
/*
|
||||
* Nothing done in this tab is kept until it reloads, so the state is loud
|
||||
* like a conflict and offers the one way out there is.
|
||||
* Nothing done in this tab is kept until it reloads, so the bar says nothing
|
||||
* of its own and the overlay over the whole Studio offers the one way out.
|
||||
*/
|
||||
it('is announced, says why, and reloads on request', () => {
|
||||
const onReload = vi.fn()
|
||||
draw({ status: 'newer' }, { onReload })
|
||||
it('leaves the bar to the overlay, which says why, takes the focus and reloads on request', () => {
|
||||
const { container } = render(
|
||||
<SaveIndicator state={{ status: 'newer' }} t={t} onAcceptTheirs={vi.fn()} onKeepMine={vi.fn()} onRetry={vi.fn()} />,
|
||||
)
|
||||
expect(container.textContent).toBe('')
|
||||
|
||||
expect(screen.getByRole('alert')).toBeTruthy()
|
||||
const onReload = vi.fn()
|
||||
render(<NewerBookOverlay t={t} onReload={onReload} />)
|
||||
expect(screen.getByRole('alertdialog')).toBeTruthy()
|
||||
expect(screen.getByText('journey.studio.saveNewer')).toBeTruthy()
|
||||
fireEvent.click(screen.getByText('journey.studio.saveReload'))
|
||||
const reload = screen.getByRole('button', { name: 'journey.studio.saveReload' })
|
||||
expect(document.activeElement).toBe(reload)
|
||||
fireEvent.click(reload)
|
||||
expect(onReload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -487,7 +487,7 @@ describe('a shape', () => {
|
||||
load([shape({ shape: 'rect', fill: '#c2410c' })])
|
||||
const { container } = draw()
|
||||
|
||||
fireEvent.click(container.querySelector('.st-mini-shapes [aria-label="ellipse"]')!)
|
||||
fireEvent.click(container.querySelector('.st-mini-shapes [aria-label="journey.studio.shapeName.ellipse"]')!)
|
||||
|
||||
expect(el()).toMatchObject({ shape: 'ellipse', fill: '#c2410c', frame: FRAME })
|
||||
})
|
||||
|
||||
@@ -245,13 +245,14 @@ describe('placing a map from the panel', () => {
|
||||
return new Promise(() => {}) as never
|
||||
})
|
||||
|
||||
const { getByTitle, unmount } = render(
|
||||
const { getByTitle, getByRole, unmount } = render(
|
||||
<StudioTravelPanel
|
||||
page={page} stats={journeyStats} path={[]} t={k => k} locale="en"
|
||||
canEdit={false} onToggleStop={async () => true}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(getByTitle('journey.studio.mapStyle.minimal'))
|
||||
fireEvent.click(getByRole('tab', { name: 'journey.studio.travelMaps' }))
|
||||
fireEvent.click(getByTitle('journey.studio.pinPhoto'))
|
||||
await vi.waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalled())
|
||||
|
||||
unmount()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BookPaint } from '@trek/shared';
|
||||
import { BOOK_PATTERNS, type BookPaint } from '@trek/shared';
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { StudioPaintPicker } from '../../../src/components/Studio/StudioPaintPicker';
|
||||
@@ -172,7 +172,7 @@ describe('a pattern', () => {
|
||||
it('draws a tile for each repeat, turns stripes to the diagonal, and lets the ground go', () => {
|
||||
const channel = spy();
|
||||
const { container } = render(<Harness start={start} channel={channel} />);
|
||||
expect(container.querySelectorAll('.st-pattern-tile')).toHaveLength(12);
|
||||
expect(container.querySelectorAll('.st-pattern-tile')).toHaveLength(BOOK_PATTERNS.length);
|
||||
expect(need(container.querySelector('.st-pattern-tile[aria-checked="true"]')).getAttribute('aria-label')).toBe(
|
||||
'journey.studio.pattern.dots'
|
||||
);
|
||||
@@ -182,7 +182,7 @@ describe('a pattern', () => {
|
||||
|
||||
fireEvent.click(need(container.querySelector('[role="switch"]')));
|
||||
expect(last(channel.commit)).toMatchObject({ bg: null });
|
||||
expect(container.querySelectorAll('.st-pattern-tile.is-clear')).toHaveLength(12);
|
||||
expect(container.querySelectorAll('.st-pattern-tile.is-clear')).toHaveLength(BOOK_PATTERNS.length);
|
||||
fireEvent.click(need(container.querySelector('[role="switch"]')));
|
||||
expect(last(channel.commit)).toMatchObject({ bg: '#ffffff' });
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user