mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26a6147e8d | |||
| a7b53988ff | |||
| 3f3e2f0bcf | |||
| f956ab07f3 | |||
| 736594696e |
@@ -38,8 +38,8 @@
|
||||
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
|
||||
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
|
||||
"src/components/Studio/StudioShell.tsx": 1,
|
||||
"src/components/Studio/TravelElements.tsx": 1,
|
||||
"src/components/Studio/sidebar/StudioEntryCard.tsx": 1,
|
||||
"src/components/Studio/travel/BadgeIcon.tsx": 1,
|
||||
"src/components/Todo/useTodoList.ts": 1,
|
||||
"src/components/shared/ConfirmDialog.tsx": 1,
|
||||
"src/components/shared/CountryFlag.tsx": 1,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1157,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1290,
|
||||
"src/components/Studio/TravelElements.tsx": 1738,
|
||||
"src/components/Studio/TravelElements.tsx": 1559,
|
||||
"src/components/Studio/countryShapes.ts": 2595,
|
||||
"src/index.css": 1636,
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
@@ -26,5 +26,5 @@
|
||||
"src/pages/atlas/useAtlas.ts": 1211,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
|
||||
"src/styles/studio.css": 1936
|
||||
"src/styles/studio.css": 1918
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"components/Journey/moodConfig.ts": 5,
|
||||
"components/Layout/BottomNav.tsx": 3,
|
||||
"components/Layout/DemoBanner.tsx": 25,
|
||||
"components/Layout/InAppNotificationBell.tsx": 8,
|
||||
"components/Layout/InAppNotificationBell.tsx": 3,
|
||||
"components/Layout/MobileTopBar.tsx": 51,
|
||||
"components/Layout/Navbar.tsx": 21,
|
||||
"components/Layout/NewVersionNotice.tsx": 3,
|
||||
@@ -72,7 +72,7 @@
|
||||
"components/Map/poiCategories.ts": 7,
|
||||
"components/Map/ratingBadge.ts": 2,
|
||||
"components/Map/reservationsMapbox.ts": 1,
|
||||
"components/Notifications/InAppNotificationItem.tsx": 8,
|
||||
"components/Notifications/InAppNotificationItem.tsx": 1,
|
||||
"components/PDF/TripPDF.tsx": 53,
|
||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||
@@ -295,7 +295,7 @@
|
||||
"pages/FilesPage.tsx": 11,
|
||||
"pages/ForgotPasswordPage.tsx": 18,
|
||||
"pages/HelpPage.tsx": 21,
|
||||
"pages/InAppNotificationsPage.tsx": 9,
|
||||
"pages/InAppNotificationsPage.tsx": 2,
|
||||
"pages/JoinTripPage.tsx": 4,
|
||||
"pages/JourneyDetailPage.tsx": 53,
|
||||
"pages/JourneyPage.tsx": 64,
|
||||
|
||||
@@ -44,9 +44,10 @@
|
||||
"components/Studio/SpreadView.tsx": 1,
|
||||
"components/Studio/StudioElementsPanel.tsx": 2,
|
||||
"components/Studio/StudioSwatches.tsx": 1,
|
||||
"components/Studio/TravelElements.tsx": 6,
|
||||
"components/Studio/TravelElements.tsx": 3,
|
||||
"components/Studio/peerColour.ts": 1,
|
||||
"components/Studio/printSheets.ts": 4,
|
||||
"components/Studio/travel/badgeLook.ts": 2,
|
||||
"components/Trips/TripMemberAvatar.tsx": 2,
|
||||
"components/Vacay/VacayPersons.tsx": 1,
|
||||
"components/shared/DialogShell.tsx": 2,
|
||||
|
||||
@@ -68,12 +68,12 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
{/* Click-away catcher only — the bell button itself closes the panel again from the keyboard. */}
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setOpen(false)} />
|
||||
<div
|
||||
className="rounded-xl shadow-xl border overflow-hidden bg-surface-card border-edge"
|
||||
className="rounded-2xl shadow-xl border overflow-hidden bg-surface-card border-edge"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 'var(--nav-h)',
|
||||
insetInlineEnd: 8,
|
||||
width: 360,
|
||||
width: 380,
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
|
||||
zIndex: 9999,
|
||||
@@ -81,14 +81,15 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 flex-shrink-0 border-b border-edge-secondary"
|
||||
>
|
||||
<span className="text-sm font-semibold text-content">
|
||||
{/* Header: the dialogs' head, a tile, the name and how many are unread, the tools at the end. */}
|
||||
<div className="flex items-center gap-3 px-4 pt-4 pb-3 flex-shrink-0">
|
||||
<span className="grid place-items-center w-9 h-9 rounded-xl bg-accent-subtle text-accent flex-shrink-0">
|
||||
<Bell className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 flex items-center gap-2 text-subtitle font-semibold text-content">
|
||||
{t('notifications.title')}
|
||||
{unreadCount > 0 && (
|
||||
<span className="ms-2 px-1.5 py-0.5 rounded-full text-xs font-medium bg-content text-surface">
|
||||
<span className="px-2 h-5 inline-flex items-center rounded-full text-caption font-semibold tabular-nums bg-accent text-accent-text">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -98,34 +99,38 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
<button type="button"
|
||||
onClick={markAllRead}
|
||||
title={t('notifications.markAllRead')}
|
||||
className="p-1.5 rounded-lg transition-colors text-content-muted hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
aria-label={t('notifications.markAllRead')}
|
||||
className="grid place-items-center w-8 h-8 rounded-lg transition-colors text-content-muted bg-surface-secondary hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover"
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
<CheckCheck className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<button type="button"
|
||||
onClick={() => setConfirmingDeleteAll(true)}
|
||||
title={t('notifications.deleteAll')}
|
||||
className="p-1.5 rounded-lg transition-colors text-content-muted hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
aria-label={t('notifications.deleteAll')}
|
||||
className="grid place-items-center w-8 h-8 rounded-lg transition-colors text-content-muted bg-surface-secondary hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification list */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
<div className="overflow-y-auto flex-1 py-1 border-t border-edge-secondary">
|
||||
{isLoading && notifications.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div className="w-5 h-5 border-2 rounded-full animate-spin border-edge border-t-content" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 px-4 text-center gap-2">
|
||||
<Bell className="w-8 h-8 text-content-faint" />
|
||||
<p className="text-sm font-medium text-content-muted">{t('notifications.empty')}</p>
|
||||
<p className="text-xs text-content-faint">{t('notifications.emptyDescription')}</p>
|
||||
<div className="flex flex-col items-center justify-center py-10 px-6 text-center gap-2">
|
||||
<span className="grid place-items-center w-12 h-12 rounded-2xl bg-surface-secondary text-content-faint">
|
||||
<Bell className="w-5 h-5" />
|
||||
</span>
|
||||
<p className="text-body font-semibold text-content">{t('notifications.empty')}</p>
|
||||
<p className="text-caption text-content-muted">{t('notifications.emptyDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.slice(0, 10).map(n => (
|
||||
@@ -135,12 +140,14 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<button type="button"
|
||||
onClick={handleShowAll}
|
||||
className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0 border-t border-edge-secondary text-content hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
>
|
||||
{t('notifications.showAll')}
|
||||
</button>
|
||||
<div className="p-2 flex-shrink-0 border-t border-edge-secondary">
|
||||
<button type="button"
|
||||
onClick={handleShowAll}
|
||||
className="w-full h-9 rounded-xl text-body font-medium transition-colors bg-surface-secondary text-content hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
>
|
||||
{t('notifications.showAll')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
|
||||
@@ -13,6 +13,18 @@ interface NotificationItemProps {
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/** The round tool beside a notification, quiet until it is pointed at. */
|
||||
const TOOL = 'grid place-items-center w-7 h-7 rounded-lg transition-colors text-content-faint hover:text-content hover:bg-surface-card focus-visible:bg-surface-card focus-visible:text-content'
|
||||
|
||||
/** An answer or a way on, as a pill under the words. */
|
||||
const PILL = 'inline-flex items-center gap-1.5 h-7 px-3 rounded-full text-caption font-medium transition-colors'
|
||||
|
||||
/**
|
||||
* One notification, as a card in the list: who sent it, what it says, and what
|
||||
* can be done with it. Unread ones carry a dot in the accent and a faint
|
||||
* ground; the tools to mark and delete come forward when the card is pointed
|
||||
* at or focused, so a list reads as messages rather than as rows of buttons.
|
||||
*/
|
||||
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const { responding, handleRespond, handleNavigate, markRead, markUnread, deleteNotification } =
|
||||
@@ -22,29 +34,25 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
const bodyText = t(notification.text_key, notification.text_params)
|
||||
const hasUnknownTitle = titleText === notification.title_key
|
||||
const hasUnknownBody = bodyText === notification.text_key
|
||||
const unread = !notification.is_read
|
||||
const answered = notification.response !== null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative px-4 py-3 transition-colors border-b border-edge-secondary ${notification.is_read ? '' : 'bg-accent-subtle'}`}
|
||||
className={`group relative mx-2 my-1 rounded-xl px-3 py-3 transition-colors hover:bg-surface-hover focus-within:bg-surface-hover ${unread ? 'bg-surface-secondary' : ''}`}
|
||||
>
|
||||
{unread && <span aria-hidden className="absolute start-1 top-5 w-1.5 h-1.5 rounded-full bg-accent" />}
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
{/* Sender avatar */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<div className="flex-shrink-0">
|
||||
{notification.sender_avatar ? (
|
||||
<img
|
||||
src={notification.sender_avatar}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
<img src={notification.sender_avatar} alt="" className="w-9 h-9 rounded-xl object-cover" />
|
||||
) : (
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-content-muted bg-surface-tertiary"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-xl flex items-center justify-center text-body font-semibold text-accent bg-accent-subtle">
|
||||
{notification.sender_username
|
||||
? notification.sender_username.charAt(0).toUpperCase()
|
||||
: <User className="w-4 h-4" />
|
||||
}
|
||||
: <User className="w-4 h-4" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -52,72 +60,67 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium leading-snug text-content">
|
||||
<p className={`text-body leading-snug text-content ${unread ? 'font-semibold' : 'font-medium'}`}>
|
||||
{hasUnknownTitle ? notification.title_key : titleText}
|
||||
</p>
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<span className="text-xs me-1 text-content-faint">
|
||||
<div className="relative flex-shrink-0 h-7 flex items-center">
|
||||
<span className="text-caption tabular-nums text-content-faint transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
|
||||
{relativeTime(notification.created_at, locale)}
|
||||
</span>
|
||||
{notification.is_read ? (
|
||||
<button type="button"
|
||||
onClick={() => markUnread(notification.id)}
|
||||
title={t('notifications.markUnread')}
|
||||
aria-label={t('notifications.markUnread')}
|
||||
className="p-1 rounded transition-colors text-content-faint hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover focus-visible:text-content"
|
||||
<div className="absolute end-0 top-0 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
{notification.is_read ? (
|
||||
<button type="button" onClick={() => markUnread(notification.id)} title={t('notifications.markUnread')} aria-label={t('notifications.markUnread')} className={TOOL}>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => markRead(notification.id)} title={t('notifications.markRead')} aria-label={t('notifications.markRead')} className={TOOL}>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteNotification(notification.id)}
|
||||
title={t('notifications.delete')}
|
||||
aria-label={t('notifications.delete')}
|
||||
className={`${TOOL} hover:text-danger focus-visible:text-danger`}
|
||||
>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button"
|
||||
onClick={() => markRead(notification.id)}
|
||||
title={t('notifications.markRead')}
|
||||
aria-label={t('notifications.markRead')}
|
||||
className="p-1 rounded transition-colors text-content-faint hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover focus-visible:text-content"
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button type="button"
|
||||
onClick={() => deleteNotification(notification.id)}
|
||||
title={t('notifications.delete')}
|
||||
aria-label={t('notifications.delete')}
|
||||
className="p-1 rounded transition-colors text-content-faint hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft focus-visible:text-danger"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs mt-0.5 leading-relaxed text-content-muted">
|
||||
<p className="text-caption mt-0.5 leading-relaxed text-content-muted">
|
||||
{hasUnknownBody ? notification.text_key : bodyText}
|
||||
</p>
|
||||
|
||||
{/* Boolean actions */}
|
||||
{notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button type="button"
|
||||
<div className="flex gap-2 mt-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRespond('positive')}
|
||||
disabled={responding || notification.response !== null}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
|
||||
disabled={responding || answered}
|
||||
className={`${PILL} ${
|
||||
notification.response === 'positive'
|
||||
? 'bg-content text-surface'
|
||||
: `bg-surface-tertiary ${notification.response === 'negative' ? 'text-content-faint opacity-50' : 'text-content-secondary'}`
|
||||
} ${notification.response !== null || responding ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
? 'bg-accent text-accent-text'
|
||||
: `bg-surface-card shadow-sm ${notification.response === 'negative' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
|
||||
} ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{t(notification.positive_text_key)}
|
||||
</button>
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRespond('negative')}
|
||||
disabled={responding || notification.response !== null}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
|
||||
disabled={responding || answered}
|
||||
className={`${PILL} ${
|
||||
notification.response === 'negative'
|
||||
? 'bg-danger text-white'
|
||||
: `bg-surface-tertiary ${notification.response === 'positive' ? 'text-content-faint opacity-50' : 'text-content-secondary'}`
|
||||
} ${notification.response !== null || responding ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
: `bg-surface-card shadow-sm ${notification.response === 'positive' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
|
||||
} ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
<X className="w-3.5 h-3.5" />
|
||||
{t(notification.negative_text_key)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -125,12 +128,13 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
|
||||
{/* Navigate action */}
|
||||
{notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && (
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNavigate}
|
||||
className="flex items-center gap-1 mt-2 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors bg-surface-tertiary text-content-secondary hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
className={`${PILL} mt-2.5 bg-surface-card shadow-sm text-content hover:bg-accent hover:text-accent-text focus-visible:bg-accent focus-visible:text-accent-text`}
|
||||
>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
{t(notification.navigate_text_key)}
|
||||
<ArrowRight className="w-3.5 h-3.5 rtl:-scale-x-100" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,15 +15,13 @@ import type { SaveState } from './useBookStore'
|
||||
* which is exactly the wrong habit for the one time it says something else.
|
||||
*/
|
||||
export function SaveIndicator({
|
||||
state, t, onAcceptTheirs, onKeepMine, onRetry, onReload = reloadStudio,
|
||||
state, t, onAcceptTheirs, onKeepMine, onRetry,
|
||||
}: {
|
||||
state: SaveState
|
||||
t: (k: string) => string
|
||||
onAcceptTheirs: (current: BookRecord) => void
|
||||
onKeepMine: (current: BookRecord) => void
|
||||
onRetry: () => void
|
||||
/** Load this Studio again, for a book a newer one saved. A full reload by default. */
|
||||
onReload?: () => void
|
||||
}) {
|
||||
if (state.status === 'conflict') {
|
||||
return (
|
||||
@@ -45,23 +43,8 @@ export function SaveIndicator({
|
||||
)
|
||||
}
|
||||
|
||||
if (state.status === 'newer') {
|
||||
/*
|
||||
* The one state with a way out the user has to take themselves: a newer
|
||||
* Studio saved this book, this tab cannot write it without losing what
|
||||
* that one added, and only a reload brings the newer Studio here. Loud
|
||||
* like a conflict, because until then nothing done here is kept.
|
||||
*/
|
||||
return (
|
||||
<div className="st-save is-conflict" role="alert">
|
||||
<AlertTriangle size={13} />
|
||||
<span>{t('journey.studio.saveNewer')}</span>
|
||||
<button type="button" onClick={onReload}>
|
||||
{t('journey.studio.saveReload')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// A book a newer Studio saved is the shell's to say, over everything (NewerBookOverlay).
|
||||
if (state.status === 'newer') return null
|
||||
|
||||
if (state.status === 'readonly') {
|
||||
/*
|
||||
@@ -107,6 +90,32 @@ export function SaveIndicator({
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* A book a newer Studio saved, said over the whole Studio rather than in the
|
||||
* bar: this tab cannot write the book without losing what the newer one
|
||||
* added, and only a reload brings the newer Studio here. Until then nothing
|
||||
* done here would be kept, so nothing can be done: the overlay takes every
|
||||
* click and offers the one way out there is.
|
||||
*/
|
||||
export function NewerBookOverlay({ t, onReload = reloadStudio }: {
|
||||
t: (k: string) => string
|
||||
onReload?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="st-newer" role="alertdialog" aria-modal="true" aria-labelledby="st-newer-text">
|
||||
<div className="st-newer-card">
|
||||
<span className="st-newer-icon"><RefreshCw size={22} strokeWidth={2.2} /></span>
|
||||
<p id="st-newer-text" className="st-newer-text">{t('journey.studio.saveNewer')}</p>
|
||||
{/* Focused on arrival, so Enter reloads and nothing behind the overlay takes the keyboard. */}
|
||||
<button type="button" className="st-newer-reload" onClick={onReload} autoFocus>
|
||||
<RefreshCw size={15} />
|
||||
{t('journey.studio.saveReload')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The default way back to a current Studio: the page again, which loads the newest build. */
|
||||
function reloadStudio() {
|
||||
window.location.reload()
|
||||
|
||||
@@ -18,6 +18,12 @@ interface SpreadThumbProps {
|
||||
className?: string;
|
||||
/** Pixels per millimetre the caller draws at; CSS's own unless the caller zooms. */
|
||||
pxPerMm?: number;
|
||||
/**
|
||||
* Draw the first and the last page beside the blank inside of their cover,
|
||||
* as the bound book opens on them, so the rail shows why they stand alone.
|
||||
* The words are the half's name, read out and shown on hover.
|
||||
*/
|
||||
coverInside?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,16 +43,31 @@ export function SpreadThumb({
|
||||
fold = false,
|
||||
className,
|
||||
pxPerMm = PX_PER_MM,
|
||||
coverInside,
|
||||
}: SpreadThumbProps) {
|
||||
const single = spread.role !== 'inner';
|
||||
const wMm = single ? page.pageWidth : page.pageWidth * 2;
|
||||
// The first page opens on the right of the cover's inside, the last on the left of the back's.
|
||||
const opens = coverInside && (spread.role === 'first' || spread.role === 'last') ? spread.role : null;
|
||||
const wMm = single && !opens ? page.pageWidth : page.pageWidth * 2;
|
||||
const scale = width / (wMm * pxPerMm);
|
||||
const pageLeft = opens === 'first' ? page.pageWidth * pxPerMm * scale : 0;
|
||||
return (
|
||||
<div dir="ltr" className={className} style={{ width, height: page.pageHeight * pxPerMm * scale }}>
|
||||
{opens && (
|
||||
<span
|
||||
className="st-thumb-inside"
|
||||
title={coverInside}
|
||||
aria-label={coverInside}
|
||||
style={{
|
||||
insetInlineStart: opens === 'first' ? 0 : page.pageWidth * pxPerMm * scale,
|
||||
width: page.pageWidth * pxPerMm * scale,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: 0,
|
||||
insetInlineStart: pageLeft,
|
||||
top: 0,
|
||||
width: `${wMm}mm`,
|
||||
height: `${page.pageHeight}mm`,
|
||||
@@ -56,7 +77,7 @@ export function SpreadThumb({
|
||||
>
|
||||
<SpreadView spread={spread} page={page} folios={folios} />
|
||||
</div>
|
||||
{fold && !single && <SpreadFold page={page} scaled={pxPerMm * scale} />}
|
||||
{fold && (!single || opens) && <SpreadFold page={page} scaled={pxPerMm * scale} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('StudioHeader', () => {
|
||||
it("FE-STUDIO-HEAD-001: names the journey under the Studio's mark, with its cover as the tile", () => {
|
||||
head();
|
||||
expect(screen.getByRole('heading', { name: 'Japan 2026' })).toBeInTheDocument();
|
||||
expect(screen.getByText('journey.studio.beta')).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: 'TREK Studio' })).toBeInTheDocument();
|
||||
expect(document.querySelector('.st-head-tile img')?.getAttribute('src')).toBe('/uploads/cover.jpg');
|
||||
});
|
||||
|
||||
|
||||
@@ -15,14 +15,14 @@ import type { StudioAssistant } from './useStudioAssistant';
|
||||
|
||||
/**
|
||||
* The Studio's head band, in the shape every TREK dialog has: a raised tile on
|
||||
* the left, a small line above the title, the title, a line under it, and the
|
||||
* round close button at the far end.
|
||||
* the left, the Studio's mark above the title, the title, a line under it, and
|
||||
* the close button at the far end.
|
||||
*
|
||||
* The tile is the journey's cover and the title its name, so it is always clear
|
||||
* whose book this is. What the book is printed as and whether it is saved sit
|
||||
* under the title, because they describe it. The tools on the right are grouped
|
||||
* by what they do: who else is here, editing (undo, redo, paste), making
|
||||
* (auto layout, the assistant), and getting the book out (help, export). How
|
||||
* whose book this is. The tools on the right are grouped by what they do:
|
||||
* who else is here and whether the book is saved, what the book is printed as, editing (undo, redo, paste), auto
|
||||
* layout, the assistant, help, and getting the book out. Every piece of
|
||||
* the row is one raised tile of one height, export alone in the accent. How
|
||||
* the page is looked at, the book view, lives with the zoom under the page.
|
||||
*/
|
||||
export function StudioHeader({
|
||||
@@ -41,25 +41,26 @@ export function StudioHeader({
|
||||
<div className="st-head-text">
|
||||
<div className="st-head-eyebrow">
|
||||
<StudioWordmark style={{ height: 17 }} />
|
||||
<span className="st-beta">{s.t('journey.studio.beta')}</span>
|
||||
</div>
|
||||
<h1 className="st-head-title">{s.journey?.title || s.t('journey.title')}</h1>
|
||||
<div className="st-head-sub">
|
||||
<FormatPicker s={s} />
|
||||
<SaveIndicator
|
||||
state={s.saveState}
|
||||
t={s.t}
|
||||
onAcceptTheirs={(current) => s.loadDoc(s.acceptTheirs(current))}
|
||||
onKeepMine={s.keepMine}
|
||||
onRetry={() => {
|
||||
void s.saveNow();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="st-head-tools">
|
||||
<PeerBadges peers={s.peers} t={s.t} />
|
||||
{/* Whether the book is safe, as a tile beside who else is here: both say how the book stands. */}
|
||||
<SaveIndicator
|
||||
state={s.saveState}
|
||||
t={s.t}
|
||||
onAcceptTheirs={(current) => s.loadDoc(s.acceptTheirs(current))}
|
||||
onKeepMine={s.keepMine}
|
||||
onRetry={() => {
|
||||
void s.saveNow();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="st-head-group">
|
||||
<FormatPicker s={s} />
|
||||
</div>
|
||||
|
||||
<div className="st-head-group">
|
||||
<IconTool label={s.t('journey.studio.undo')} disabled={!s.canUndo} onClick={s.undo}>
|
||||
@@ -73,15 +74,15 @@ export function StudioHeader({
|
||||
|
||||
<div className="st-head-group">
|
||||
<AutoLayoutButton s={s} />
|
||||
{/* The book's assistant; only where it can run for this user. */}
|
||||
<StudioAssistantToggle assistant={assistant} />
|
||||
</div>
|
||||
|
||||
{/* The book's assistant, a tile of its own like help; only where it can run for this user. */}
|
||||
<StudioAssistantToggle assistant={assistant} />
|
||||
{/* Studio has no navbar, so the help center's button rides on its own bar. */}
|
||||
<HelpButton className="st-tool is-icon" />
|
||||
<HelpButton className="st-tool is-icon st-head-solo" />
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool is-primary"
|
||||
className="st-tool is-primary st-head-solo"
|
||||
onClick={onExport}
|
||||
disabled={!s.doc}
|
||||
title={s.t('journey.studio.export')}
|
||||
@@ -242,8 +243,8 @@ function AutoLayoutButton({ s }: { s: Studio }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The format, as a chip under the book's name: it says what the book is
|
||||
* printed as, and opening it changes that.
|
||||
* The format, as a tool of the head row: it says what the book is printed as,
|
||||
* and opening it changes that.
|
||||
*
|
||||
* A native <select> was the first draft and it was wrong: the browser draws the
|
||||
* list in its own chrome, so it ignores the theme entirely and drops a list that
|
||||
@@ -264,7 +265,7 @@ function FormatPicker({ s }: { s: Studio }) {
|
||||
<div className="st-picker" ref={box}>
|
||||
<button
|
||||
type="button"
|
||||
className="st-head-chip"
|
||||
className="st-tool"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
@@ -278,16 +279,16 @@ function FormatPicker({ s }: { s: Studio }) {
|
||||
: { height: 12, width: Math.max(6, Math.round((12 * width) / height)) }
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<span className="st-tool-label">
|
||||
{s.preset === 'custom'
|
||||
? `${Math.round(s.page.pageWidth)} × ${Math.round(s.page.pageHeight)} mm`
|
||||
: s.t(active.labelKey)}
|
||||
</span>
|
||||
<ChevronDown size={12} style={{ opacity: 0.55 }} />
|
||||
<ChevronDown size={13} style={{ opacity: 0.5 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu st-head-menu" role="listbox">
|
||||
<div className="st-menu" role="listbox">
|
||||
{PAGE_PRESET_ORDER.map((id) => {
|
||||
const p = PAGE_PRESETS[id];
|
||||
const on = id === s.preset;
|
||||
|
||||
@@ -35,10 +35,10 @@ export function StudioSavedLayouts({
|
||||
const scale = width / (wMm * pxPerMm)
|
||||
|
||||
return (
|
||||
<div className="st-saved-layouts">
|
||||
<div className="st-section st-saved-layouts">
|
||||
<div className="st-section-label">{t('journey.studio.myLayouts')}</div>
|
||||
{fitting.length === 0 && <p className="st-hint">{t('journey.studio.myLayoutsEmpty')}</p>}
|
||||
<div className="st-thumbs">
|
||||
<div className={`st-thumbs st-tpl-grid ${single ? 'is-pages' : 'is-spreads'}`}>
|
||||
{fitting.map(layout => (
|
||||
<div className="st-thumb-row" key={layout.id}>
|
||||
<button type="button" className="st-thumb" onClick={() => applyLayout(active, layout.id)}>
|
||||
@@ -79,7 +79,6 @@ export function StudioSavedLayouts({
|
||||
{t('journey.studio.saveLayout')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className="st-section-label st-builtin-label">{t('journey.studio.builtInLayouts')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { StudioExport } from './StudioExport'
|
||||
import { StudioAssistantColumn, StudioPageStage } from './StudioAssistantPanel'
|
||||
import { useStudioAssistant } from './useStudioAssistant'
|
||||
import { StudioHeader } from './StudioHeader'
|
||||
import { NewerBookOverlay } from './SaveIndicator'
|
||||
import { StudioNotices } from './StudioNotices'
|
||||
import '../../styles/dashboard.css'
|
||||
import '../../styles/studio.css'
|
||||
@@ -87,6 +88,8 @@ export default function StudioShell() {
|
||||
>
|
||||
<StudioHeader s={s} onExport={() => setExporting(true)} assistant={assistant} />
|
||||
<StudioNotices />
|
||||
{/* A newer Studio saved this book: nothing done here would be kept, so the whole Studio waits for a reload. */}
|
||||
{s.saveState.status === 'newer' && <NewerBookOverlay t={s.t} />}
|
||||
{/* The journey gallery's picker, inside Studio's own layer (#2271). */}
|
||||
{s.providerPick && s.journey && (
|
||||
<ProviderPicker
|
||||
|
||||
@@ -3,7 +3,9 @@ import type {
|
||||
BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement,
|
||||
BookMetric, BookStatsElement, JourneyStats,
|
||||
} from '@trek/shared'
|
||||
import { BOOK_METRICS, MAX_PATH_POINTS, MAX_PATH_SEGMENTS, isStale, refreshPatch } from '@trek/shared'
|
||||
import {
|
||||
BOOK_BADGE_STYLES, BOOK_COUNTRY_LOOKS, BOOK_METRICS, BOOK_STATS_LOOKS, MAX_PATH_POINTS, MAX_PATH_SEGMENTS, isStale, refreshPatch,
|
||||
} from '@trek/shared'
|
||||
import { Swatches } from './StudioSwatches'
|
||||
import { StudioFontPicker } from './StudioFontPicker'
|
||||
import { WeightLine } from './StudioTypeControls'
|
||||
@@ -547,6 +549,8 @@ function MapLook({ el, set, t }: Props<BookMapElement>) {
|
||||
options={[
|
||||
{ value: 'dot' as const, label: t('journey.studio.pinDot') },
|
||||
{ value: 'photo' as const, label: t('journey.studio.pinPhoto') },
|
||||
{ value: 'numbered' as const, label: t('journey.studio.pinNumbered') },
|
||||
{ value: 'ends' as const, label: t('journey.studio.pinEnds') },
|
||||
]}
|
||||
onPick={pinStyle => set({ pinStyle } as Partial<BookElement>)}
|
||||
/>
|
||||
@@ -597,16 +601,26 @@ function StatsProps({ el, set, t }: Props<BookStatsElement>) {
|
||||
/>
|
||||
</Section>
|
||||
<Section label={t('journey.studio.layout')}>
|
||||
<Line label={t('journey.studio.layout')}>
|
||||
{/* The look first: the arrangement below belongs to the classic one alone. */}
|
||||
<Line label={t('journey.studio.look')} wide>
|
||||
<Choice
|
||||
value={el.layout}
|
||||
options={(['grid', 'row', 'column'] as const).map(layout => ({
|
||||
value: layout,
|
||||
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
|
||||
}))}
|
||||
onPick={layout => set({ layout } as Partial<BookElement>)}
|
||||
value={el.look ?? 'classic'}
|
||||
options={BOOK_STATS_LOOKS.map(look => ({ value: look, label: t(`journey.studio.statsLook.${look}`) }))}
|
||||
onPick={look => set({ look } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
{(el.look ?? 'classic') === 'classic' && (
|
||||
<Line label={t('journey.studio.layout')}>
|
||||
<Choice
|
||||
value={el.layout}
|
||||
options={(['grid', 'row', 'column'] as const).map(layout => ({
|
||||
value: layout,
|
||||
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
|
||||
}))}
|
||||
onPick={layout => set({ layout } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
)}
|
||||
<Line label={t('journey.studio.units')}>
|
||||
<Choice
|
||||
value={el.units}
|
||||
@@ -634,16 +648,25 @@ function StatsProps({ el, set, t }: Props<BookStatsElement>) {
|
||||
function CountriesProps({ el, set, t }: Props<BookCountriesElement>) {
|
||||
return (
|
||||
<Section label={t('journey.studio.countries')}>
|
||||
<Line label={t('journey.studio.layout')}>
|
||||
<Line label={t('journey.studio.look')} wide>
|
||||
<Choice
|
||||
value={el.layout}
|
||||
options={(['list', 'grid', 'column'] as const).map(layout => ({
|
||||
value: layout,
|
||||
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
|
||||
}))}
|
||||
onPick={layout => set({ layout } as Partial<BookElement>)}
|
||||
value={el.look ?? 'classic'}
|
||||
options={BOOK_COUNTRY_LOOKS.map(look => ({ value: look, label: t(`journey.studio.countryLook.${look}`) }))}
|
||||
onPick={look => set({ look } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
{(el.look ?? 'classic') === 'classic' && el.codes.length > 1 && (
|
||||
<Line label={t('journey.studio.layout')}>
|
||||
<Choice
|
||||
value={el.layout}
|
||||
options={(['list', 'grid', 'column'] as const).map(layout => ({
|
||||
value: layout,
|
||||
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
|
||||
}))}
|
||||
onPick={layout => set({ layout } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
)}
|
||||
{/* These three read as raw English out of the code before: `left`,
|
||||
`center`, `right`, straight from the value. */}
|
||||
<Line label={t('journey.studio.align')}>
|
||||
@@ -734,7 +757,7 @@ function BadgeProps({ el, set, t }: Props<BookBadgeElement>) {
|
||||
<Line label={t('journey.studio.style')} wide>
|
||||
<Choice
|
||||
value={el.style}
|
||||
options={(['plain', 'chip', 'outline', 'stacked'] as const).map(style => ({
|
||||
options={BOOK_BADGE_STYLES.map(style => ({
|
||||
value: style, label: t(`journey.studio.markStyle.${style}`),
|
||||
}))}
|
||||
onPick={style => set({ style } as Partial<BookElement>)}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, RouteOff } from 'lucide-react'
|
||||
import type {
|
||||
BookBadgeVariant, BookElement, BookMapScope, BookMetric, BookPageSetup, JourneyStats,
|
||||
} from '@trek/shared'
|
||||
import {
|
||||
BOOK_METRICS, bookBadgeValue, bookCountryMark, bookCountryName, bookElementId as uid, bookTravelBadge,
|
||||
bookTravelCountries, bookTravelMap, bookTravelSize, bookTravelStats,
|
||||
} from '@trek/shared'
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
|
||||
import { Globe, Hash, Map as MapIcon, Stamp } from 'lucide-react'
|
||||
import type { BookElement, BookPageSetup, JourneyStats } from '@trek/shared'
|
||||
import { bookElementId as uid } from '@trek/shared'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { PanelHead, Shelf } from './StudioPanelHead'
|
||||
import { studioTravelContext } from './studioTravelContext'
|
||||
import { Section } from './StudioControls'
|
||||
import { TravelPreview } from './TravelPreview'
|
||||
import { StudioTabPanel, StudioTabs } from './StudioTabs'
|
||||
import { fetchRoads } from './roadRoute'
|
||||
import { TravelCard } from './travel/TravelCard'
|
||||
import { TravelStops } from './travel/TravelStops'
|
||||
import {
|
||||
COUNTRY_LOOKS, FIGURE_SHELVES, MAP_CARDS, MARK_STYLES, MARK_VARIANTS, SUMMARY_LOOKS, travelBuilders,
|
||||
} from './travel/travelBuilders'
|
||||
|
||||
/**
|
||||
* The journey's own figures, as things you can put on a page.
|
||||
@@ -22,90 +20,39 @@ import { fetchRoads } from './roadRoute'
|
||||
* distance, the countries and the dates are already in TREK; the work is
|
||||
* offering them as objects rather than as numbers someone has to retype.
|
||||
*
|
||||
* ── Why every tile is a real preview ──────────────────────────────────────
|
||||
* Four tabs, the way the Elements panel sorts what it adds: the figures, the
|
||||
* maps with the stops they are drawn from, the countries, and the marks. Every
|
||||
* card is the element its click places, built by the book engine and drawn by
|
||||
* the page's own renderer with this journey's numbers in it, in the newer
|
||||
* looks and in the book's theme (travel/travelBuilders.ts). The looks books
|
||||
* had before these still draw on the pages that hold them; they are no longer
|
||||
* offered here.
|
||||
*
|
||||
* These elements differ from one another by what they *say*, not by their
|
||||
* shape. Four map styles are four maps; three summary layouts are the same six
|
||||
* numbers arranged three ways. A named row with the value beside it — which is
|
||||
* what this panel was first — tells you none of that: you cannot tell the dark
|
||||
* map from the paper one, or a row of figures from a grid of them, until you
|
||||
* have placed one and undone it.
|
||||
*
|
||||
* So each tile builds the element it would place and renders it through the
|
||||
* page's own renderer, with this journey's real numbers in it. You are picking
|
||||
* from the things themselves.
|
||||
*
|
||||
* Every button resolves its values *now* and writes them into the element, so
|
||||
* Every card resolves its values now and writes them into the element, so
|
||||
* what lands on the page is finished rather than a placeholder that needs the
|
||||
* server to mean anything. The reasoning is in TravelElements.tsx, and the
|
||||
* short version is that the print renderer must never depend on a fetch.
|
||||
* server to mean anything: the print renderer must never depend on a fetch.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One row of the stops list: a point of the route or a stop switched off.
|
||||
*
|
||||
* `entryId` is null for a stop the server took straight from a trip's places,
|
||||
* which is what a journey has before anyone wrote an entry. Such a stop has no
|
||||
* switch, because there is no entry to put it on.
|
||||
*/
|
||||
type StopRow = {
|
||||
entryId: number | null
|
||||
label: string
|
||||
date: string | null
|
||||
country: string | null
|
||||
excluded: boolean
|
||||
}
|
||||
type Tab = 'figures' | 'maps' | 'countries' | 'marks'
|
||||
|
||||
/*
|
||||
* How many cards share a row.
|
||||
*
|
||||
* A stats panel is the width of a spread; shown two-up its figures scale down
|
||||
* past reading, so it gets the row to itself. Maps, country lists and marks are
|
||||
* compact enough that two-up still tells them apart, and two-up is what makes
|
||||
* the section scannable. The tile measures its own width — see TravelPreview.
|
||||
*/
|
||||
|
||||
/** The words a mark carries, as the book engine resolves them. */
|
||||
type BadgeValue = NonNullable<ReturnType<typeof bookBadgeValue>>
|
||||
const TABS: { id: Tab; icon: typeof Hash; labelKey: string }[] = [
|
||||
{ id: 'figures', icon: Hash, labelKey: 'journey.studio.travelFigures' },
|
||||
{ id: 'maps', icon: MapIcon, labelKey: 'journey.studio.travelMaps' },
|
||||
{ id: 'countries', icon: Globe, labelKey: 'journey.studio.countries' },
|
||||
{ id: 'marks', icon: Stamp, labelKey: 'journey.studio.marks' },
|
||||
]
|
||||
|
||||
/*
|
||||
* The two imagery maps, satellite first: the recommendation should not be the
|
||||
* second thing read. Their tiles and credit are the book engine's own
|
||||
* (BOOK_MAP_SOURCES), so a satellite map placed here is the one the assistant
|
||||
* places.
|
||||
* second thing read. Relief stops at about six hundred metres to the pixel, a
|
||||
* smudge at country size; satellite is the one that prints for the page most
|
||||
* people make, and the ring says so.
|
||||
*/
|
||||
const IMAGERY = [
|
||||
{ id: 'satellite', labelKey: 'journey.studio.mapSourceSatellite' },
|
||||
{ id: 'relief', labelKey: 'journey.studio.mapSourceRelief' },
|
||||
] as const
|
||||
|
||||
function Card({ label, onClick, children, recommended = false, title }: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
/**
|
||||
* Marked as the one to reach for, with a ring rather than with words.
|
||||
*
|
||||
* The label sits in a grid cell about eighty pixels wide, where "Satellite,
|
||||
* recommended" would take both of its lines. A ring says the same thing in
|
||||
* no space at all, and the tooltip carries the word for anyone who wonders
|
||||
* why this card looks different.
|
||||
*/
|
||||
recommended?: boolean
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button type="button"
|
||||
className={`st-travel-card ${recommended ? 'is-recommended' : ''}`}
|
||||
onClick={onClick}
|
||||
title={title ? `${label}: ${title}` : label}
|
||||
>
|
||||
{children}
|
||||
<span className="st-travel-card-label">{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function StudioTravelPanel({
|
||||
page, stats, path, t, locale, canEdit, onToggleStop,
|
||||
}: {
|
||||
@@ -123,16 +70,16 @@ export function StudioTravelPanel({
|
||||
const addElement = useStudioStore(s => s.addElement)
|
||||
const updateElement = useStudioStore(s => s.updateElement)
|
||||
const active = useStudioStore(s => s.activeSpread)
|
||||
const doc = useStudioStore(s => s.doc)
|
||||
const spread = doc?.spreads[active]
|
||||
const single = !!spread && spread.role !== 'inner'
|
||||
const role = useStudioStore(s => s.doc?.spreads[active]?.role)
|
||||
const theme = useStudioStore(s => s.doc?.theme)
|
||||
const [tab, setTab] = useState<Tab>('figures')
|
||||
const tabsId = useId()
|
||||
|
||||
/*
|
||||
* The road lookups still running when the panel goes away.
|
||||
*
|
||||
* fetchRoads walks the legs at a deliberate pace, so a twenty-stop map keeps
|
||||
* asking a public router for minutes after Studio is closed, and the answer
|
||||
* lands in whatever document the store holds by then.
|
||||
* The road lookups still running when the panel goes away. fetchRoads walks
|
||||
* the legs at a deliberate pace, so a twenty-stop map keeps asking a public
|
||||
* router for minutes, and the answer would land in whatever document the
|
||||
* store holds by then.
|
||||
*/
|
||||
const roadJobs = useRef(new Set<AbortController>())
|
||||
useEffect(() => () => {
|
||||
@@ -140,22 +87,6 @@ export function StudioTravelPanel({
|
||||
roadJobs.current.clear()
|
||||
}, [])
|
||||
|
||||
/*
|
||||
* The stops whose switch is on its way to the server.
|
||||
*
|
||||
* No optimistic flip: which stop is furthest and which countries remain is
|
||||
* the server's sum, so the row reads its state from the stats the hook
|
||||
* fetches once the change has landed. Until then the chip is only held,
|
||||
* so a second click cannot send the opposite of what the first one is
|
||||
* still delivering.
|
||||
*/
|
||||
const [pending, setPending] = useState<Set<number>>(() => new Set())
|
||||
|
||||
const centre = (w: number, h: number) => {
|
||||
const W = single ? page.pageWidth : page.pageWidth * 2
|
||||
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<>
|
||||
@@ -167,146 +98,17 @@ export function StudioTravelPanel({
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* What the book engine's factories read (studioTravelContext.ts). Every tile
|
||||
* below is built by the factory the engine's `add` calls. Where it lands is
|
||||
* the panel's own: centred across the spread, where `add` takes the right page.
|
||||
*/
|
||||
const travel = studioTravelContext(stats, path, locale, t)
|
||||
const at = (size: { w: number; h: number }) => centre(size.w, size.h)
|
||||
const build = travelBuilders({ page, stats, path, locale, t, theme, single: !!role && role !== 'inner' })
|
||||
|
||||
/**
|
||||
* Country names in the reader's language, not the server's.
|
||||
*
|
||||
* The API answers in English, because it has no idea who will read the book.
|
||||
* `Intl.DisplayNames` is the same CLDR data Atlas already uses for regions, so
|
||||
* a German book says "Island" and an English one says "Iceland" — from one set
|
||||
* of codes, with no translation table to keep in step.
|
||||
*/
|
||||
const countryName = (code: string): string => bookCountryName(code, locale, stats)
|
||||
|
||||
/*
|
||||
* Each builder returns the finished element. The tile renders it and the
|
||||
* click places it, so the preview and the result are the same object, and
|
||||
* the object is the engine's, so there is no second description of what a
|
||||
* card means.
|
||||
*/
|
||||
/** The linked trips that actually put stops on the route. */
|
||||
const tripsWithRoute = (stats.trips ?? []).filter(t => t.points > 0)
|
||||
|
||||
/*
|
||||
* The route as rows, counting and left-out stops together.
|
||||
*
|
||||
* Every point is listed, so the list reads as the route. The left-out
|
||||
* stops sit among the counting ones in date order rather than in a list of
|
||||
* their own, because the question the section answers is "which of these
|
||||
* count?", and that is only answerable with all of them in one sequence.
|
||||
* Undated stops go last, then by name.
|
||||
*/
|
||||
const stops: StopRow[] = [
|
||||
...stats.points.map(p => ({
|
||||
entryId: p.entryId, label: p.label, date: p.date, country: p.country, excluded: false,
|
||||
})),
|
||||
...(stats.excluded ?? []).map(x => ({
|
||||
entryId: x.entryId, label: x.label, date: x.date, country: null, excluded: true,
|
||||
})),
|
||||
].sort((a, b) => {
|
||||
if (a.date !== b.date) {
|
||||
if (a.date == null) return 1
|
||||
if (b.date == null) return -1
|
||||
return a.date < b.date ? -1 : 1
|
||||
}
|
||||
return a.label.localeCompare(b.label)
|
||||
})
|
||||
const excludedCount = stops.filter(s => s.excluded).length
|
||||
/** Whether the country is worth a badge, or the same word on every row. */
|
||||
const manyCountries = stats.countries.length > 1
|
||||
|
||||
/**
|
||||
* The day of a stop, without the weekday.
|
||||
*
|
||||
* `formatDate` is the journal's format and carries "Tue," because a journal
|
||||
* entry is about a day somebody lived. A row here is about a leg of a route,
|
||||
* and it has 236px to hold a place name in as well: the weekday costs a
|
||||
* third of the badge and answers nothing anybody asks of this list.
|
||||
*/
|
||||
const stopDay = (iso: string) =>
|
||||
new Date(`${iso}T00:00:00Z`).toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
|
||||
const toggleStop = async (entryId: number, excluded: boolean) => {
|
||||
setPending(prev => new Set(prev).add(entryId))
|
||||
// A refusal is the hook's to report; here it only frees the chip again.
|
||||
await onToggleStop(entryId, excluded)
|
||||
setPending(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(entryId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* One country, as the entry a list would have made of it.
|
||||
*
|
||||
* The list and the grid are gone: they were compositions somebody else made,
|
||||
* deciding the order, the spacing and the type size, and the only way to
|
||||
* change any of that was to not use them. What is left is the same entry —
|
||||
* name over silhouette, set the same way — as an element you place one at a
|
||||
* time. Three of them down a page is the list, with the spacing yours; one of
|
||||
* them at 80mm is a chapter opener, which the list could never be.
|
||||
*
|
||||
* The name is editable afterwards, so numbering them "1. Germany",
|
||||
* "2. Netherlands" is typing rather than a feature.
|
||||
*/
|
||||
const countryEl = (code: string): BookElement =>
|
||||
bookTravelCountries(travel, { id: uid('co'), frame: at(bookTravelSize('countries', page)), codes: [code] })
|
||||
|
||||
/**
|
||||
* A map as the engine's `add` places it, in the style the card offers.
|
||||
*
|
||||
* The scope is the journey or one trip. A trip map carries no recorded way:
|
||||
* the trail is the journey's, and a map fitted to it would frame the whole
|
||||
* journey around one trip and lose the bow its legs are drawn with.
|
||||
*/
|
||||
const mapEl = (
|
||||
style: 'minimal' | 'outline' | 'dark' | 'paper',
|
||||
scope: BookMapScope = 'journey',
|
||||
source: 'vector' | 'relief' | 'satellite' = 'vector',
|
||||
): BookElement => ({
|
||||
...bookTravelMap(travel, stats, { id: uid('mp'), frame: at(bookTravelSize('map', page)), scope, source }),
|
||||
style,
|
||||
})
|
||||
|
||||
/** A mark at the size that suits what it holds, which is the engine's size for its variant. */
|
||||
const badgeEl = (variant: BookBadgeVariant, value: BadgeValue): BookElement =>
|
||||
bookTravelBadge({ id: uid('bd'), frame: at(bookTravelSize('badge', page, { variant })), variant, value })
|
||||
|
||||
/**
|
||||
* One figure on its own.
|
||||
*
|
||||
* A summary panel is a composition; a single figure is a mark you drop next
|
||||
* to a photograph. Both are the same element with a different metric list,
|
||||
* and offering only the composition meant anyone wanting "14 DAYS" beside a
|
||||
* picture had to place all four and delete three.
|
||||
*/
|
||||
const singleEl = (metric: BookMetric): BookElement =>
|
||||
bookTravelStats(stats, { id: uid('st'), frame: at(bookTravelSize('stats', page)), metrics: [metric] })
|
||||
|
||||
/** Place a copy — the previewed element keeps its own id for React. */
|
||||
/** Place a copy; the previewed element keeps its own id for React. */
|
||||
const place = (el: BookElement) => {
|
||||
const placed = { ...el, id: uid(el.kind[0]) } as BookElement
|
||||
const placed = { ...el, id: uid(el.kind[0] ?? 'e') } as BookElement
|
||||
addElement(active, placed)
|
||||
|
||||
/*
|
||||
* A map arrives following the roads.
|
||||
*
|
||||
* Asked for after the element is on the page rather than before, so the map
|
||||
* appears at once and the line firms up a moment later: a panel that sat
|
||||
* there for fifteen seconds after a click would read as broken, and the
|
||||
* straight line it starts with is the one it would have had anyway.
|
||||
*
|
||||
* Legs long enough to have been flights are never sent (see roadRoute.ts),
|
||||
* the router's own cache answers a second placement of the same journey for
|
||||
* free, and a failure leaves the leg exactly as it was.
|
||||
* A map arrives following the roads, asked for after it is on the page so
|
||||
* it appears at once and the line firms up a moment later. Legs long enough
|
||||
* to have been flights are never sent (roadRoute.ts), and a failure leaves
|
||||
* the leg as it was.
|
||||
*/
|
||||
if (placed.kind === 'map' && placed.points.length > 1) {
|
||||
const job = new AbortController()
|
||||
@@ -314,266 +116,228 @@ export function StudioTravelPanel({
|
||||
void fetchRoads(placed.points.map(pt => ({ lat: pt.lat, lng: pt.lng })), { signal: job.signal })
|
||||
.then(roads => {
|
||||
if (job.signal.aborted) return
|
||||
if (roads.some(r => r && r.length > 1)) {
|
||||
updateElement(active, placed.id, { roads } as Partial<BookElement>)
|
||||
}
|
||||
if (roads.some(r => r && r.length > 1)) updateElement(active, placed.id, { roads } as Partial<BookElement>)
|
||||
})
|
||||
.catch(() => { /* No roads is a state the map already draws. */ })
|
||||
.finally(() => { roadJobs.current.delete(job) })
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The marks of the journey, with the words the engine resolves for each. A
|
||||
* mark the journey cannot fill (no start date, no stop, no country) is not
|
||||
* offered.
|
||||
*/
|
||||
const marks: { el: BookElement; label: string }[] = ([
|
||||
['date', t('journey.studio.dateMark')],
|
||||
['day', t('journey.studio.dayMark')],
|
||||
['coords', t('journey.studio.coordsMark')],
|
||||
['flag', t('journey.studio.flagMark')],
|
||||
['distance', t('journey.studio.distanceMark')],
|
||||
] as const).flatMap(([variant, label]) => {
|
||||
const value = bookBadgeValue(travel, variant, null)
|
||||
return value ? [{ el: badgeEl(variant, value), label }] : []
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelHead label={t('journey.studio.travel')} />
|
||||
<div className="st-panel-scroll">
|
||||
<Shelf label={t('journey.studio.singleFigures')}>
|
||||
<div className="st-travel-grid">
|
||||
{BOOK_METRICS.map(metric => {
|
||||
const el = singleEl(metric)
|
||||
return (
|
||||
<Card key={metric} label={t(`journey.studio.metric.${metric}`)} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={44} maxHeight={62} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Shelf>
|
||||
|
||||
{/*
|
||||
The switch is here, beside the maps and the figures it changes, and
|
||||
not only in the entry editor: the moment somebody notices the home
|
||||
airport on the printed route is the moment they are looking at this
|
||||
panel. The rows carry no preview because they are not things to
|
||||
place; they are the input every tile below reads.
|
||||
*/}
|
||||
{stops.length > 0 && (
|
||||
<Section
|
||||
label={t('journey.studio.stops')}
|
||||
hint={t('journey.studio.stopsHint')}
|
||||
/*
|
||||
* Folded to begin with, and saying how many count while it is.
|
||||
*
|
||||
* A fortnight is fourteen rows, which is longer than everything
|
||||
* else in this panel put together, and most books never need to
|
||||
* touch it. The figure in the head is what anyone opening it came
|
||||
* to check anyway, so the closed section still answers the
|
||||
* question and the maps below stay in reach.
|
||||
*/
|
||||
defaultOpen={false}
|
||||
badge={`${stops.length - excludedCount}/${stops.length}`}
|
||||
>
|
||||
<ul className="st-route-stops">
|
||||
{stops.map((stop, i) => {
|
||||
const entryId = stop.entryId
|
||||
const on = !stop.excluded
|
||||
const state = t(on ? 'journey.studio.stopOn' : 'journey.studio.stopOff')
|
||||
const chip = `st-chip is-pick is-icon ${on ? 'is-on' : ''}`
|
||||
/*
|
||||
* A mark rather than the word.
|
||||
*
|
||||
* "Counts" and "Left out" are different lengths, so a column of
|
||||
* them jitters, and together they cost a third of a row that
|
||||
* has a place name to fit. The tick and the crossed-out route
|
||||
* say the same thing at a glance, and the word is still there
|
||||
* for anyone who hovers or reads with a screen reader.
|
||||
*/
|
||||
const mark = on ? <Check size={13} strokeWidth={2.4} /> : <RouteOff size={13} strokeWidth={2.2} />
|
||||
return (
|
||||
<li key={entryId ?? `place-${i}`} className={`st-route-stop ${on ? '' : 'is-off'}`}>
|
||||
{/*
|
||||
One line: the day, then the name, then the switch. The
|
||||
name and the day were stacked, which doubled the height
|
||||
of a list whose whole job is being read down.
|
||||
*/}
|
||||
{stop.date && <span className="st-badge">{stopDay(stop.date)}</span>}
|
||||
<span className="st-route-stop-label">{stop.label || t('journey.studio.untitled')}</span>
|
||||
{/*
|
||||
The country only where there is more than one to tell
|
||||
apart. On a journey inside one country it is the same
|
||||
word fourteen times, in the row that has least room.
|
||||
*/}
|
||||
{manyCountries && stop.country && (
|
||||
<span className="st-badge is-quiet">{countryName(stop.country)}</span>
|
||||
)}
|
||||
{canEdit && entryId != null ? (
|
||||
<button type="button"
|
||||
className={chip}
|
||||
title={`${state}: ${t('journey.studio.stopToggle')}`}
|
||||
aria-label={t('journey.studio.stopToggle')}
|
||||
aria-pressed={on}
|
||||
disabled={pending.has(entryId)}
|
||||
onClick={() => void toggleStop(entryId, !stop.excluded)}
|
||||
>
|
||||
{mark}
|
||||
</button>
|
||||
) : (
|
||||
<span className={chip} title={state} aria-label={state}>{mark}</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Shelf label={t('journey.studio.routeMap')}>
|
||||
{stats.points.length ? (
|
||||
<>
|
||||
<div className="st-travel-grid">
|
||||
{(['minimal', 'outline', 'paper', 'dark'] as const).map(style => {
|
||||
const el = mapEl(style)
|
||||
return (
|
||||
<Card key={style} label={t(`journey.studio.mapStyle.${style}`)} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={62} maxHeight={80} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
{/*
|
||||
The two imagery maps, as cards rather than as settings.
|
||||
|
||||
They were reachable only by placing an outline map and then
|
||||
changing its source in the inspector, which is a thing nobody
|
||||
finds. They are different pictures, not variants of one: relief
|
||||
is land shaded in its own colours against a dark sea, drawn at
|
||||
a scale that suits a continent, and satellite is the ground
|
||||
itself, which is what a page about one country wants.
|
||||
*/}
|
||||
{IMAGERY.map(src => (
|
||||
<Card
|
||||
key={src.id}
|
||||
/*
|
||||
* Satellite is marked rather than merely listed. The three
|
||||
* imagery options are not equals: relief stops at about six
|
||||
* hundred metres to the pixel, which is a continent's worth
|
||||
* of detail and a smudge at country size, and the outlines
|
||||
* are a diagram. For the page most people are making —
|
||||
* a route across one or two countries — this is the one
|
||||
* that prints, and saying so beats letting them find out
|
||||
* after the book is bound.
|
||||
*/
|
||||
label={t(src.labelKey)}
|
||||
recommended={src.id === 'satellite'}
|
||||
title={src.id === 'satellite' ? t('journey.studio.recommended') : undefined}
|
||||
onClick={() => place(mapEl('minimal', 'journey', src.id))}
|
||||
>
|
||||
<TravelPreview
|
||||
el={mapEl('minimal', 'journey', src.id)}
|
||||
minHeight={62}
|
||||
maxHeight={80}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
A map per trip, for a journey made of more than one.
|
||||
|
||||
Two trips printed as a single route draw a line from the last
|
||||
stop of the first to the first stop of the second, which is
|
||||
usually the longest leg on the page and one nobody travelled.
|
||||
Offered rather than imposed: some journeys really are one route
|
||||
across several trips, and those still get the map above.
|
||||
|
||||
Only trips that have stops on the route are listed — a linked
|
||||
trip nobody wrote about would place an empty frame.
|
||||
*/}
|
||||
{tripsWithRoute.length > 1 && (
|
||||
<div className="st-shelf-sub">
|
||||
<h4 className="st-section-label">{t('journey.studio.mapPerTrip')}</h4>
|
||||
<div className="st-travel-grid">
|
||||
{tripsWithRoute.map(trip => {
|
||||
const el = mapEl('minimal', { tripId: trip.id })
|
||||
return (
|
||||
<Card
|
||||
key={trip.id}
|
||||
label={trip.title || t('journey.studio.untitled')}
|
||||
onClick={() => place(el)}
|
||||
>
|
||||
<TravelPreview el={el} minHeight={62} maxHeight={80} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="st-hint">{t('journey.studio.noRoute')}</p>
|
||||
<StudioTabs<Tab>
|
||||
idBase={tabsId}
|
||||
label={t('journey.studio.travel')}
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
columns={2}
|
||||
tabs={TABS.map(x => ({ id: x.id, icon: x.icon, label: t(x.labelKey) }))}
|
||||
/>
|
||||
<StudioTabPanel idBase={tabsId} value={tab} className="st-tab-body">
|
||||
<div className="st-panel-scroll">
|
||||
{tab === 'figures' && <FiguresTab build={build} t={t} place={place} />}
|
||||
{tab === 'maps' && (
|
||||
<MapsTab build={build} stats={stats} t={t} place={place}>
|
||||
<TravelStops
|
||||
stats={stats} t={t} locale={locale} canEdit={canEdit}
|
||||
onToggleStop={onToggleStop} countryName={build.countryName}
|
||||
/>
|
||||
</MapsTab>
|
||||
)}
|
||||
</Shelf>
|
||||
|
||||
<Shelf label={t('journey.studio.countries')}>
|
||||
{stats.countries.length ? (
|
||||
<>
|
||||
{/*
|
||||
One country per element, rather than a block listing them all.
|
||||
|
||||
A list of countries is a composition somebody else made: it
|
||||
decides the order, the spacing and the type size, and the only
|
||||
way to change any of it is to not use it. Placed one at a time
|
||||
they are ordinary elements — numbered, moved, set at different
|
||||
sizes, put on different pages — which is what a book of a trip
|
||||
through several countries actually wants.
|
||||
*/}
|
||||
<div className="st-travel-grid">
|
||||
{stats.countries.slice(0, 12).map(c => {
|
||||
const el = countryEl(c.code)
|
||||
return (
|
||||
<Card key={c.code} label={countryName(c.code)} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={44} maxHeight={64} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
And the same country as a mark: the outline beside the name
|
||||
rather than under it, at the size of a line of type. It belongs
|
||||
next to a date or a coordinate, not on a page of its own.
|
||||
*/}
|
||||
<div className="st-travel-grid" style={{ marginTop: 6 }}>
|
||||
{stats.countries.slice(0, 12).map(c => {
|
||||
const el = badgeEl('country', bookCountryMark('country', c.code, countryName(c.code)))
|
||||
return (
|
||||
<Card key={`mark-${c.code}`} label={countryName(c.code)} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={30} maxHeight={44} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="st-hint">{t('journey.studio.noCountries')}</p>
|
||||
)}
|
||||
</Shelf>
|
||||
|
||||
<Shelf label={t('journey.studio.marks')}>
|
||||
<div className="st-travel-grid">
|
||||
{marks.map(({ el, label }) => (
|
||||
<Card key={el.id} label={label} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={34} maxHeight={52} />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
</div>
|
||||
{tab === 'countries' && <CountriesTab build={build} stats={stats} t={t} place={place} />}
|
||||
{tab === 'marks' && <MarksTab build={build} t={t} place={place} />}
|
||||
</div>
|
||||
</StudioTabPanel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type Build = ReturnType<typeof travelBuilders>
|
||||
|
||||
interface TabProps {
|
||||
build: Build
|
||||
t: (k: string) => string
|
||||
place: (el: BookElement) => void
|
||||
}
|
||||
|
||||
/** The summary in its four looks, full width, and every figure on its own as a medal. */
|
||||
function FiguresTab({ build, t, place }: TabProps) {
|
||||
return (
|
||||
<>
|
||||
<Shelf label={t('journey.studio.summary')}>
|
||||
<div className="st-travel-grid is-wide">
|
||||
{SUMMARY_LOOKS.map(look => (
|
||||
<TravelCard
|
||||
key={look}
|
||||
el={build.summaryEl(look)}
|
||||
label={t(`journey.studio.statsLook.${look}`)}
|
||||
onPlace={place}
|
||||
minHeight={look === 'band' ? 30 : 70}
|
||||
maxHeight={look === 'band' ? 40 : 120}
|
||||
inset={look === 'note' || look === 'sketch' ? 0.05 : 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
{/* Each figure on its own, in the looks that suit a single figure: a medal, an odometer, a ring drawn by hand. */}
|
||||
{FIGURE_SHELVES.map(({ look, metrics }) => (
|
||||
<Shelf key={look} label={`${t('journey.studio.singleFigures')}: ${t(`journey.studio.statsLook.${look}`)}`}>
|
||||
<div className={`st-travel-grid ${look === 'odometer' ? 'is-wide' : ''}`}>
|
||||
{metrics.map(metric => (
|
||||
<TravelCard
|
||||
key={metric}
|
||||
el={build.figureEl(metric, look)}
|
||||
label={t(`journey.studio.metric.${metric}`)}
|
||||
onPlace={place}
|
||||
minHeight={look === 'odometer' ? 50 : 80}
|
||||
maxHeight={look === 'odometer' ? 64 : 104}
|
||||
inset={look === 'odometer' ? 0 : 0.06}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The route: drawn with its photographs, its stops numbered, its two ends
|
||||
* marked or on a dark ground, then over satellite and relief imagery; a map
|
||||
* per trip for a journey of several; and the stops it is drawn from.
|
||||
*/
|
||||
function MapsTab({ build, stats, t, place, children }: TabProps & { stats: JourneyStats; children: ReactNode }) {
|
||||
if (!stats.points.length) return <p className="st-hint">{t('journey.studio.noRoute')}</p>
|
||||
// Only the trips that put stops on the route: a linked trip nobody wrote about would place an empty frame.
|
||||
const trips = (stats.trips ?? []).filter(trip => trip.points > 0)
|
||||
return (
|
||||
<>
|
||||
<Shelf label={t('journey.studio.routeMap')}>
|
||||
<div className="st-travel-grid">
|
||||
{MAP_CARDS.map(card => (
|
||||
<TravelCard key={card.id} el={build.mapEl(card)} label={t(card.labelKey)} onPlace={place} minHeight={70} maxHeight={92} />
|
||||
))}
|
||||
{IMAGERY.map(src => (
|
||||
<TravelCard
|
||||
key={src.id}
|
||||
el={build.mapEl({ style: 'minimal' }, 'journey', src.id)}
|
||||
label={t(src.labelKey)}
|
||||
onPlace={place}
|
||||
minHeight={70}
|
||||
maxHeight={92}
|
||||
recommended={src.id === 'satellite'}
|
||||
title={src.id === 'satellite' ? t('journey.studio.recommended') : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/*
|
||||
A map per trip: two trips printed as one route draw a line from the
|
||||
last stop of one to the first of the next, a leg nobody travelled.
|
||||
*/}
|
||||
{trips.length > 1 && (
|
||||
<div className="st-shelf-sub">
|
||||
<h4 className="st-section-label">{t('journey.studio.mapPerTrip')}</h4>
|
||||
<div className="st-travel-grid">
|
||||
{trips.map(trip => (
|
||||
<TravelCard
|
||||
key={trip.id}
|
||||
el={build.mapEl({ style: 'minimal', pins: 'numbered' }, { tripId: trip.id })}
|
||||
label={trip.title || t('journey.studio.untitled')}
|
||||
onPlace={place}
|
||||
minHeight={70}
|
||||
maxHeight={92}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Shelf>
|
||||
{/* Each country on its own, its stops alone, cut to its shape or in a box. */}
|
||||
{stats.countries.slice(0, 6).map(c => (
|
||||
<Shelf key={c.code} label={build.countryName(c.code)}>
|
||||
<div className="st-travel-grid">
|
||||
{COUNTRY_MAPS.map(m => (
|
||||
<TravelCard
|
||||
key={m.kind}
|
||||
el={build.countryMapEl(c.code, m.kind)}
|
||||
label={t(m.labelKey)}
|
||||
onPlace={place}
|
||||
minHeight={80}
|
||||
maxHeight={104}
|
||||
inset={m.kind === 'box' ? 0 : 0.04}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
))}
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** The maps of one country: cut to its shape on the paper, cut to it over the satellite picture, and in a box. */
|
||||
const COUNTRY_MAPS = [
|
||||
{ kind: 'shape', labelKey: 'journey.studio.mapClipCountry' },
|
||||
{ kind: 'satellite', labelKey: 'journey.studio.mapSourceSatellite' },
|
||||
{ kind: 'box', labelKey: 'journey.studio.mapClipRect' },
|
||||
] as const
|
||||
|
||||
/** Each country in every look, one shelf per country: a stamp, a visa, a postcard, its flag, a pin, a label, a sketch. */
|
||||
function CountriesTab({ build, stats, t, place }: TabProps & { stats: JourneyStats }) {
|
||||
if (!stats.countries.length) return <p className="st-hint">{t('journey.studio.noCountries')}</p>
|
||||
return (
|
||||
<>
|
||||
{stats.countries.slice(0, 12).map(c => (
|
||||
<Shelf key={c.code} label={build.countryName(c.code)}>
|
||||
<div className="st-travel-grid">
|
||||
{COUNTRY_LOOKS.map(look => (
|
||||
<TravelCard
|
||||
key={look}
|
||||
el={build.countryEl(c.code, look)}
|
||||
label={t(`journey.studio.countryLook.${look}`)}
|
||||
onPlace={place}
|
||||
minHeight={84}
|
||||
maxHeight={100}
|
||||
inset={look === 'postcard' ? 0.04 : 0.09}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** The marks the journey fills, as stamps, tags and pills. A mark it cannot fill is not offered. */
|
||||
function MarksTab({ build, t, place }: TabProps) {
|
||||
const label = (variant: (typeof MARK_VARIANTS)[number]) => t(`journey.studio.${variant}Mark`)
|
||||
return (
|
||||
<>
|
||||
{MARK_STYLES.map(({ style, variants }) => {
|
||||
const cards = variants.flatMap(variant => {
|
||||
const el = build.markEl(variant, style)
|
||||
return el ? [{ el, variant }] : []
|
||||
})
|
||||
if (!cards.length) return null
|
||||
return (
|
||||
<Shelf key={style} label={t(`journey.studio.markStyle.${style}`)}>
|
||||
<div className="st-travel-grid">
|
||||
{cards.map(({ el, variant }) => (
|
||||
<TravelCard
|
||||
key={variant}
|
||||
el={el}
|
||||
label={label(variant)}
|
||||
onPlace={place}
|
||||
minHeight={style === 'stamp' ? 56 : 38}
|
||||
maxHeight={style === 'stamp' ? 76 : 54}
|
||||
inset={style === 'stamp' || style === 'sticker' ? 0.12 : 0.08}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Shelf>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import type {
|
||||
BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement,
|
||||
BookMetric, BookStatsElement, BookUnits,
|
||||
BookStatsElement,
|
||||
} from '@trek/shared'
|
||||
import {
|
||||
Camera, CalendarDays, Footprints, Globe, MapPin, Navigation, Route,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { fontStack, lighterWeight, nearestWeight } from './bookFonts'
|
||||
import { fontStack } from './bookFonts'
|
||||
import { heavy, inkAt as rgba, light, METRIC_ICONS, metricValue, round2 } from './travel/travelInk'
|
||||
import { BadgeLookView } from './travel/BadgeLooks'
|
||||
import { CountryLookView } from './travel/CountryLooks'
|
||||
import { StatsLookView } from './travel/StatsLooks'
|
||||
import { badgeIconShare, badgeInk, inkOn, isBadgeLook } from './travel/badgeLook'
|
||||
import { BadgeIcon } from './travel/BadgeIcon'
|
||||
import { EndMarker } from './travel/EndMarker'
|
||||
import { FlagMark } from './travel/FlagMark'
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import { brightness } from './folioColour'
|
||||
import { COUNTRY_SHAPES, countryParts, countryWorldPath, projectMercator, unprojectMercator } from './countryShapes'
|
||||
import { projectOntoTiles, tileView, usableStaticUrl } from './mapTiles'
|
||||
import { useCartoApiKey } from '../../hooks/useTileUrl'
|
||||
import { FLAG_H, FLAG_W, flagBands, flagDisc, flagSpec } from './flags'
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
/**
|
||||
@@ -71,8 +72,6 @@ function typeSize(el: TravelElement, base: number): number {
|
||||
return Math.max(1.2, el.frame.h * base * el.textScale)
|
||||
}
|
||||
|
||||
/** Two decimals of a millimetre, matching what the document stores. */
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100
|
||||
/** Tile geometry, kept finer than the rest: see the note where it is used. */
|
||||
const round3 = (n: number) => Math.round(n * 1000) / 1000
|
||||
|
||||
@@ -100,12 +99,6 @@ const round3 = (n: number) => Math.round(n * 1000) / 1000
|
||||
*/
|
||||
const tileOverlapMm = (size: number) => Math.max(0.8, size * 0.04)
|
||||
|
||||
/** #rrggbb plus an alpha. */
|
||||
function rgba(hex: string, alpha: number): string {
|
||||
const n = Number.parseInt(hex.slice(1), 16)
|
||||
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`
|
||||
}
|
||||
|
||||
export function TravelElementView({ el, frameStyle, big = false }: {
|
||||
el: BookElement
|
||||
frameStyle: CSSProperties
|
||||
@@ -113,9 +106,22 @@ export function TravelElementView({ el, frameStyle, big = false }: {
|
||||
big?: boolean
|
||||
}) {
|
||||
if (el.kind === 'map') return <MapView el={el} frameStyle={frameStyle} big={big} />
|
||||
if (el.kind === 'stats') return <StatsView el={el} frameStyle={frameStyle} />
|
||||
if (el.kind === 'countries') return <CountriesView el={el} frameStyle={frameStyle} />
|
||||
if (el.kind === 'badge') return <BadgeView el={el} frameStyle={frameStyle} />
|
||||
// The newer looks draw in files of their own; `classic` is what every book had before them.
|
||||
if (el.kind === 'stats') {
|
||||
return (el.look ?? 'classic') === 'classic'
|
||||
? <StatsView el={el} frameStyle={frameStyle} />
|
||||
: <StatsLookView el={el} frameStyle={frameStyle} />
|
||||
}
|
||||
if (el.kind === 'countries') {
|
||||
return (el.look ?? 'classic') === 'classic'
|
||||
? <CountriesView el={el} frameStyle={frameStyle} />
|
||||
: <CountryLookView el={el} frameStyle={frameStyle} />
|
||||
}
|
||||
if (el.kind === 'badge') {
|
||||
return isBadgeLook(el.style)
|
||||
? <BadgeLookView el={el} frameStyle={frameStyle} />
|
||||
: <BadgeView el={el} frameStyle={frameStyle} />
|
||||
}
|
||||
if (el.kind === 'list') return <ListView el={el} frameStyle={frameStyle} />
|
||||
return null
|
||||
}
|
||||
@@ -132,17 +138,6 @@ export function TravelElementView({ el, frameStyle, big = false }: {
|
||||
* a slab of ink for no reason. What distinguishes the four styles is how the
|
||||
* land is drawn, which is enough.
|
||||
*/
|
||||
/**
|
||||
* How heavy the type is set, and one step down for the labels under it.
|
||||
*
|
||||
* These elements draw at two levels at once — a figure over its caption, a
|
||||
* country's name over its outline — and the pair has to move together, the way
|
||||
* `textScale` moves their size. Hard-coded 700s meant the only way to lighten a
|
||||
* stats block was to not use one.
|
||||
*/
|
||||
const heavy = (el: { font: string; weight: number }) => nearestWeight(el.font, el.weight)
|
||||
const light = (el: { font: string; weight: number }) => lighterWeight(el.font, el.weight)
|
||||
|
||||
const MAP_PALETTES = {
|
||||
minimal: { land: '#e9e5dc', border: '#d6d0c4', onDark: false },
|
||||
outline: { land: 'transparent', border: '#b9b3a6', onDark: false },
|
||||
@@ -160,28 +155,6 @@ const MAP_PALETTES = {
|
||||
*/
|
||||
const DEFAULT_ACCENT = '#111111' // theme-lint-disable — the contract's default, matched here
|
||||
|
||||
/** Ink for a mark that picks its own, and its opposite. */
|
||||
const BADGE_DARK_INK = '#1c1b19' // theme-lint-disable — ink on a printed page, not a UI token
|
||||
const BADGE_LIGHT_INK = '#ffffff' // theme-lint-disable — paper white on a filled chip
|
||||
|
||||
/**
|
||||
* What colour a mark's words are.
|
||||
*
|
||||
* Automatic unless the element says otherwise, and what automatic means depends
|
||||
* on what the mark is: a chip carries its words on its own fill, so they answer
|
||||
* to the fill's brightness; a day or a date drawn plain takes the accent,
|
||||
* because that is the figure the page is built around. Anything else is
|
||||
* ordinary text in the text colour.
|
||||
*
|
||||
* The moment somebody picks a colour this steps aside completely. That is the
|
||||
* point of the flag: automatic is a good default and a bad cage.
|
||||
*/
|
||||
function badgeInk(el: BookBadgeElement): string {
|
||||
if (!el.autoColor) return el.color
|
||||
if (el.style === 'chip') return brightness(el.accent) > 0.55 ? BADGE_DARK_INK : BADGE_LIGHT_INK
|
||||
if (el.variant === 'day' || el.variant === 'date') return el.accent
|
||||
return el.color
|
||||
}
|
||||
function routeInk(accent: string, onDark: boolean): string {
|
||||
if (!onDark) return accent
|
||||
return accent.toLowerCase() === DEFAULT_ACCENT ? '#f2efe9' : accent // theme-lint-disable — paper on a dark map
|
||||
@@ -512,6 +485,8 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
const S = Math.min(W, H)
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
|
||||
const drawn = el.routeStyle === 'drawn'
|
||||
// Numbered stops and marked ends are markers of the drawn kind, whatever the line is drawn as.
|
||||
const drawnPins = drawn || el.pinStyle === 'numbered' || el.pinStyle === 'ends'
|
||||
const coreW = clamp(S * 0.0055, 0.32, 1.3)
|
||||
const casingW = coreW * 1.9
|
||||
const beadR = clamp(coreW * 3.1, 1.7, 4.8)
|
||||
@@ -528,7 +503,7 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
* so it inverts with it: dark on a light line, light on a dark one. Measured
|
||||
* the same way the marks decide their ink.
|
||||
*/
|
||||
const numInk = brightness(el.accent) > 0.55 ? '#1c1b19' : '#ffffff' // theme-lint-disable — print ink
|
||||
const numInk = inkOn(el.accent)
|
||||
const dashOn = coreW * 2.4
|
||||
const dashOff = coreW * 1.8
|
||||
|
||||
@@ -748,16 +723,16 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
*/
|
||||
const beads: { i: number; x: number; y: number; photo: number | null }[] = []
|
||||
let numbered = false
|
||||
if (drawn) {
|
||||
if (drawnPins) {
|
||||
// Too small to carry photographs at all: a bead wider than a twelfth of the
|
||||
// frame is a picture with a map around it.
|
||||
const roomForPhotos = el.pinStyle === 'photo' && big && beadR * 2 <= S * 0.16
|
||||
/*
|
||||
* Numbers stand in for the photographs that are not there, so they belong
|
||||
* to the photo treatment alone. A map asked for dots gets dots, and a
|
||||
* thumbnail gets dots too: two digits at 130px is a smudge.
|
||||
* Numbers stand in for the photographs that are not there, and a map asked
|
||||
* for numbers numbers every stop. A map asked for dots gets dots, and a
|
||||
* photo map's thumbnail gets dots too: two digits at 130px is a smudge.
|
||||
*/
|
||||
numbered = el.pinStyle === 'photo' && big && numR * 2 <= S * 0.12
|
||||
numbered = (el.pinStyle === 'numbered' || (el.pinStyle === 'photo' && big)) && numR * 2 <= S * 0.12
|
||||
let last: { x: number; y: number } | null = null
|
||||
route.forEach((pt, i) => {
|
||||
const photoId = (el.points[i] as { photoId?: number | null } | undefined)?.photoId ?? null
|
||||
@@ -979,7 +954,7 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
</>
|
||||
)}
|
||||
|
||||
{el.showPins && !drawn && route.map((p, i) => (
|
||||
{el.showPins && !drawnPins && route.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={p.x}
|
||||
@@ -1003,8 +978,10 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
is also what a photograph that fails to load degrades to, so the paper
|
||||
shows a deliberate marker rather than a hole.
|
||||
*/}
|
||||
{el.showPins && drawn && beads.map(m => (
|
||||
m.photo != null ? (
|
||||
{el.showPins && drawnPins && beads.map(m => (
|
||||
el.pinStyle === 'ends' && (m.i === 0 || m.i === route.length - 1) ? (
|
||||
<EndMarker key={m.i} x={m.x} y={m.y} r={numR} finish={m.i > 0} ink={coreInk} paper={numInk} />
|
||||
) : m.photo != null ? (
|
||||
<g key={m.i}>
|
||||
{/* The shadow, as two translucent discs rather than a filter. */}
|
||||
<circle cx={m.x} cy={m.y + coreW * 0.3} r={beadR + coreW * 0.55} fill="#000000" fillOpacity={0.1} />
|
||||
@@ -1127,49 +1104,6 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
|
||||
/* ── Stats ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The icon beside each figure.
|
||||
*
|
||||
* lucide, like every other icon in TREK — a hand-drawn set was the first pass
|
||||
* and it was the wrong call twice over: it put a second drawing vocabulary next
|
||||
* to the one the whole app already uses, and it meant seven paths nobody would
|
||||
* ever revisit when lucide gained a better glyph.
|
||||
*
|
||||
* They size in millimetres despite lucide taking a pixel `size`, because an
|
||||
* inline width and height override the attributes it writes while the viewBox
|
||||
* stays 24×24 — so the stroke scales with the drawing instead of thinning out
|
||||
* on a large panel.
|
||||
*/
|
||||
const METRIC_ICONS: Record<BookMetric, LucideIcon> = {
|
||||
distance: Route,
|
||||
days: CalendarDays,
|
||||
steps: Footprints,
|
||||
photos: Camera,
|
||||
countries: Globe,
|
||||
places: MapPin,
|
||||
furthest: Navigation,
|
||||
}
|
||||
|
||||
function metricValue(
|
||||
metric: BookMetric,
|
||||
values: Record<string, number>,
|
||||
units: BookUnits,
|
||||
locale: string,
|
||||
): { value: string; unit: string } {
|
||||
const raw = values[metric] ?? 0
|
||||
const n = (v: number, digits = 0) =>
|
||||
new Intl.NumberFormat(locale, { maximumFractionDigits: digits }).format(v)
|
||||
|
||||
if (metric === 'distance' || metric === 'furthest') {
|
||||
// Stored in metres and converted here, so switching units never rounds a
|
||||
// second time on top of a rounded number.
|
||||
const km = raw / 1000
|
||||
const shown = units === 'imperial' ? km * 0.621371 : km
|
||||
return { value: n(shown, shown < 100 ? 1 : 0), unit: units === 'imperial' ? 'mi' : 'km' }
|
||||
}
|
||||
return { value: n(raw), unit: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip summary — the page Polarsteps' books open on, and the one that makes
|
||||
* a book feel like a record of a journey rather than a folder of pictures.
|
||||
@@ -1404,75 +1338,6 @@ function CountriesView({ el, frameStyle }: { el: BookCountriesElement; frameStyl
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A flag, drawn.
|
||||
*
|
||||
* The first version set a regional-indicator pair and let the platform draw it.
|
||||
* That is the standard trick and it is wrong for most of the people who will
|
||||
* use this: Chrome on Windows ships no flag glyphs, so it renders the two
|
||||
* letters, and "DE" printed in a photo book is not a flag.
|
||||
*
|
||||
* So flags are constructed from `flags.ts` — exact for the stripe, cross and
|
||||
* disc constructions listed there, and absent for anything with an emblem
|
||||
* rather than approximated. A country with no construction falls back to its
|
||||
* silhouette, which is correct for all 198 of them.
|
||||
*/
|
||||
function FlagMark({ code, size }: { code: string; size: number }) {
|
||||
const spec = flagSpec(code)
|
||||
const height = size
|
||||
const width = (height * FLAG_W) / FLAG_H
|
||||
|
||||
if (spec) {
|
||||
const disc = flagDisc(spec)
|
||||
return (
|
||||
<svg
|
||||
width={`${round2(width)}mm`}
|
||||
height={`${round2(height)}mm`}
|
||||
viewBox={`0 0 ${FLAG_W} ${FLAG_H}`}
|
||||
style={{ display: 'block', flex: '0 0 auto', borderRadius: `${round2(height * 0.08)}mm` }}
|
||||
aria-label={code.toUpperCase()}
|
||||
>
|
||||
{flagBands(spec).map((b, i) => (
|
||||
<rect key={i} x={b.x} y={b.y} width={b.w} height={b.h} fill={b.fill} />
|
||||
))}
|
||||
{disc && <circle cx={disc.cx} cy={disc.cy} r={disc.r} fill={disc.fill} />}
|
||||
{/* A hairline keeps a white-edged flag off a white page. */}
|
||||
<rect x="0" y="0" width={FLAG_W} height={FLAG_H} fill="none" stroke="rgba(0,0,0,.18)" strokeWidth="0.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const shape = COUNTRY_SHAPES[code.toUpperCase()]
|
||||
if (!shape) {
|
||||
// Neither a construction nor an outline: the code, set as a mark rather
|
||||
// than as two stray capitals.
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${round2(size * 0.62)}mm`,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
lineHeight: 1,
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
{code.toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
width={`${round2((height * shape.w) / shape.h)}mm`}
|
||||
height={`${round2(height)}mm`}
|
||||
viewBox={`0 0 ${shape.w} ${shape.h}`}
|
||||
style={{ display: 'block', flex: '0 0 auto' }}
|
||||
aria-label={code.toUpperCase()}
|
||||
>
|
||||
<path d={shape.d} fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Badges ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
@@ -1495,7 +1360,6 @@ function BadgeView({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSPr
|
||||
*/
|
||||
const big = typeSize(el, el.variant === 'date' ? 0.52 : 0.42)
|
||||
const small = typeSize(el, 0.2)
|
||||
const shape = el.code ? COUNTRY_SHAPES[el.code.toUpperCase()] : null
|
||||
const stacked = el.style === 'stacked' || el.variant === 'date'
|
||||
|
||||
const chip = el.style === 'chip'
|
||||
@@ -1511,7 +1375,6 @@ function BadgeView({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSPr
|
||||
*/
|
||||
const showIcon = el.showIcon !== false
|
||||
const showLabel = el.showLabel !== false
|
||||
const iconTint = (automatic: string) => (el.autoIconColor === false ? el.iconColor : automatic)
|
||||
/*
|
||||
* A mark with no words is a pictogram, and a pictogram should fill its box.
|
||||
* Beside a label the icon is a share of the height; on its own it takes four
|
||||
@@ -1544,54 +1407,12 @@ function BadgeView({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSPr
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{el.variant === 'flag' && el.code && showIcon && (
|
||||
<FlagMark code={el.code} size={el.frame.h * (showLabel ? 0.5 : iconScale)} />
|
||||
)}
|
||||
|
||||
{/*
|
||||
Mood and weather come from the journal entry, and they are drawn with
|
||||
the *same* icon and the same palette the journey page uses — imported
|
||||
rather than re-listed, so a mood added to TREK appears here without
|
||||
anyone remembering to add it twice.
|
||||
The picture: a flag, an outline, or the journal's own mood and weather
|
||||
icons, imported rather than re-listed so a mood added to TREK appears
|
||||
here without anyone adding it twice.
|
||||
*/}
|
||||
{(el.variant === 'mood' || el.variant === 'weather') && el.code && showIcon && (() => {
|
||||
// Kept apart rather than narrowed out of one union: only a mood has a
|
||||
// colour of its own, and `'text' in config` types it as unknown.
|
||||
const mood = el.variant === 'mood' ? MOOD_CONFIG[el.code] : null
|
||||
const config = mood ?? WEATHER_CONFIG[el.code]
|
||||
if (!config) return null
|
||||
const Icon = config.icon
|
||||
const size = el.frame.h * iconScale
|
||||
/*
|
||||
* Automatic is the journal's own palette for a mood and the element's
|
||||
* accent for the weather. Note the colour has to be passed rather than
|
||||
* inherited: lucide writes it onto the SVG's stroke, so `currentColor`
|
||||
* from the wrapper never reaches it — which is why the Colour swatch
|
||||
* appeared to do nothing to a mood icon.
|
||||
*/
|
||||
const tint = iconTint(mood ? mood.text : el.accent)
|
||||
return (
|
||||
<Icon
|
||||
color={tint}
|
||||
strokeWidth={1.7}
|
||||
style={{ width: `${round2(size)}mm`, height: `${round2(size)}mm`, display: 'block', flex: '0 0 auto' }}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
{el.variant === 'country' && shape && showIcon && (() => {
|
||||
const h = el.frame.h * (showLabel ? 0.72 : iconScale)
|
||||
return (
|
||||
<svg
|
||||
width={`${round2((h * shape.w) / shape.h)}mm`}
|
||||
height={`${round2(h)}mm`}
|
||||
viewBox={`0 0 ${shape.w} ${shape.h}`}
|
||||
style={{ display: 'block', flex: '0 0 auto' }}
|
||||
>
|
||||
<path d={shape.d} fill={iconTint(el.accent)} />
|
||||
</svg>
|
||||
)
|
||||
})()}
|
||||
{showIcon && <BadgeIcon el={el} height={el.frame.h * (showLabel ? badgeIconShare(el.variant) : iconScale)} />}
|
||||
|
||||
{el.text && showLabel && (
|
||||
<span
|
||||
|
||||
@@ -27,10 +27,16 @@ import { ElementView } from './SpreadView'
|
||||
/** CSS defines 1in as 96px and 25.4mm, so this factor is exact. */
|
||||
const PX_PER_MM = 96 / 25.4
|
||||
|
||||
export function TravelPreview({ el, minHeight = 30, maxHeight = 90 }: {
|
||||
export function TravelPreview({ el, minHeight = 30, maxHeight = 90, inset = 0 }: {
|
||||
el: BookElement
|
||||
minHeight?: number
|
||||
maxHeight?: number
|
||||
/**
|
||||
* Room kept round the element, as a share of the tile: a stamp is set a few
|
||||
* degrees off square and a medal's ring runs to its edge, and drawn edge to
|
||||
* edge they would be clipped by the tile.
|
||||
*/
|
||||
inset?: number
|
||||
}) {
|
||||
const box = useElementSize<HTMLSpanElement>()
|
||||
const width = box.width
|
||||
@@ -48,7 +54,8 @@ export function TravelPreview({ el, minHeight = 30, maxHeight = 90 }: {
|
||||
const drawnW = el.frame.w * PX_PER_MM
|
||||
const drawnH = el.frame.h * PX_PER_MM
|
||||
const height = Math.round(Math.min(maxHeight, Math.max(minHeight, (width * drawnH) / drawnW || minHeight)))
|
||||
const scale = width > 0 ? Math.min(width / drawnW, height / drawnH) : 0
|
||||
const room = 1 - 2 * inset
|
||||
const scale = width > 0 ? Math.min((width * room) / drawnW, (height * room) / drawnH) : 0
|
||||
|
||||
return (
|
||||
<span className="st-travel-preview" style={{ height }} ref={box.ref} aria-hidden>
|
||||
|
||||
@@ -106,6 +106,9 @@ export function StudioPagesPanel({
|
||||
folios={folios}
|
||||
fold={bookView}
|
||||
className="st-thumb-sheet"
|
||||
coverInside={t(
|
||||
sp.role === 'last' ? 'journey.studio.backCoverInside' : 'journey.studio.coverInside'
|
||||
)}
|
||||
/>
|
||||
<span className="st-thumb-label">
|
||||
{sp.role === 'cover'
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -67,7 +67,11 @@ function ThemeCard({
|
||||
);
|
||||
}
|
||||
|
||||
/** One palette entry: its colour, which is the picker, and its name. */
|
||||
/**
|
||||
* One palette entry: its colour, which is the picker, and its name. The three
|
||||
* accents share one name under them, so each shows its colour alone and says
|
||||
* its own name to a screen reader and on hover.
|
||||
*/
|
||||
function PaletteEntry({
|
||||
name,
|
||||
hex,
|
||||
@@ -79,13 +83,14 @@ function PaletteEntry({
|
||||
onPick: (hex: string) => void;
|
||||
t: StudioT;
|
||||
}) {
|
||||
const accent = name.startsWith('accent');
|
||||
const label = t(`studio.theme.token.${name}`);
|
||||
return (
|
||||
<label className="st-theme-colour">
|
||||
<label className="st-theme-colour" title={label}>
|
||||
<span className="st-theme-chip" style={{ background: hex }}>
|
||||
<input type="color" value={hex} aria-label={label} onChange={(e) => onPick(e.target.value)} />
|
||||
</span>
|
||||
<span className="st-theme-colour-name">{label}</span>
|
||||
{!accent && <span className="st-theme-colour-name">{label}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -181,6 +186,7 @@ export function StudioThemePanel({ t }: { t: StudioT }) {
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
<span className="st-theme-colour-name st-theme-accents-name">{t('studio.theme.accents')}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { BookBadgeElement, BookBadgeVariant } from '@trek/shared';
|
||||
import { CalendarDays, Compass, Flag, MapPin, Mountain, Route, type LucideIcon } from 'lucide-react';
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../../pages/journeyDetail/JourneyDetailPage.constants';
|
||||
import { COUNTRY_SHAPES } from '../countryShapes';
|
||||
import { FlagMark } from './FlagMark';
|
||||
import { mm } from './travelInk';
|
||||
|
||||
/**
|
||||
* The picture of a mark: a flag, a country's outline, or the journal's own
|
||||
* mood and weather icons. Drawn the same in every style a mark can take, so a
|
||||
* flag set as a stamp is the flag set as a chip.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A drawing for the marks that have no picture of their own. A pill always
|
||||
* opens on a disc, and a date in a disc with nothing in it reads as missing.
|
||||
*/
|
||||
const GENERIC: Partial<Record<BookBadgeVariant, LucideIcon>> = {
|
||||
date: CalendarDays,
|
||||
day: Flag,
|
||||
coords: Compass,
|
||||
distance: Route,
|
||||
altitude: Mountain,
|
||||
country: MapPin,
|
||||
flag: Flag,
|
||||
};
|
||||
|
||||
export function BadgeIcon({
|
||||
el,
|
||||
height,
|
||||
generic = false,
|
||||
ink,
|
||||
}: {
|
||||
el: BookBadgeElement;
|
||||
/** The drawn height in millimetres; the width follows the picture's own shape. */
|
||||
height: number;
|
||||
/** Draw a plain icon for a mark that has no picture of its own. */
|
||||
generic?: boolean;
|
||||
/** The automatic colour, for a picture drawn on a fill rather than on the page. */
|
||||
ink?: string;
|
||||
}) {
|
||||
/*
|
||||
* Automatic is the journal's palette for a mood and the accent for anything
|
||||
* else. It is passed rather than inherited: lucide writes the colour onto the
|
||||
* SVG's stroke, so `currentColor` from the wrapper never reaches it.
|
||||
*/
|
||||
const tint = (automatic: string) => (el.autoIconColor === false ? el.iconColor : (ink ?? automatic));
|
||||
const code = el.code;
|
||||
const iconStyle = { width: mm(height), height: mm(height), display: 'block', flex: '0 0 auto' } as const;
|
||||
|
||||
if (el.variant === 'flag' && code) return <FlagMark code={code} size={height} />;
|
||||
|
||||
if ((el.variant === 'mood' || el.variant === 'weather') && code) {
|
||||
// Kept apart rather than narrowed out of one union: only a mood has a colour of its own.
|
||||
const mood = el.variant === 'mood' ? MOOD_CONFIG[code] : null;
|
||||
const config = mood ?? WEATHER_CONFIG[code];
|
||||
if (config) {
|
||||
const Icon = config.icon;
|
||||
return <Icon color={tint(mood ? mood.text : el.accent)} strokeWidth={1.7} style={iconStyle} />;
|
||||
}
|
||||
}
|
||||
|
||||
const shape = el.variant === 'country' && code ? COUNTRY_SHAPES[code.toUpperCase()] : null;
|
||||
if (shape) {
|
||||
return (
|
||||
<svg
|
||||
width={mm((height * shape.w) / shape.h)}
|
||||
height={mm(height)}
|
||||
viewBox={`0 0 ${shape.w} ${shape.h}`}
|
||||
style={{ display: 'block', flex: '0 0 auto' }}
|
||||
>
|
||||
<path d={shape.d} fill={tint(el.accent)} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = generic ? GENERIC[el.variant] : undefined;
|
||||
return Icon ? <Icon color={tint(el.accent)} strokeWidth={1.8} style={iconStyle} /> : null;
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import type { BookBadgeElement, BookBadgeStyle } from '@trek/shared';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { fontStack } from '../bookFonts';
|
||||
import { BadgeIcon } from './BadgeIcon';
|
||||
import { badgeIconShare, badgeInk, inkOn } from './badgeLook';
|
||||
import { hatch, roughBox, seeded } from './sketch';
|
||||
import { heavy, inkAt, light, mm } from './travelInk';
|
||||
|
||||
/**
|
||||
* The newer styles of a mark: an inked passport stamp, a luggage tag with its
|
||||
* eyelet, and a pill that opens on its icon in a disc. The four styles every
|
||||
* mark had before these stay in TravelElements.tsx, so a mark placed then
|
||||
* prints as it always has.
|
||||
*/
|
||||
|
||||
/** The words of a mark, the value over or beside the line under it. */
|
||||
function Words({
|
||||
el,
|
||||
big,
|
||||
small,
|
||||
ink,
|
||||
stacked,
|
||||
start = false,
|
||||
}: {
|
||||
el: BookBadgeElement;
|
||||
big: number;
|
||||
small: number;
|
||||
ink: string;
|
||||
stacked: boolean;
|
||||
/** Stacked lines aligned to the start rather than centred, as in a tag or a pill. */
|
||||
start?: boolean;
|
||||
}) {
|
||||
if (el.showLabel === false) return null;
|
||||
// A mark with no value of its own (a flag, a country) carries its name as the value.
|
||||
const main = el.text || el.sub;
|
||||
const sub = el.text ? el.sub : '';
|
||||
const named = !el.text;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: stacked ? 'column' : 'row',
|
||||
alignItems: stacked ? (start ? 'flex-start' : 'center') : 'baseline',
|
||||
gap: mm(small * (stacked ? 0.3 : 0.6)),
|
||||
minWidth: 0,
|
||||
color: ink,
|
||||
}}
|
||||
>
|
||||
{main && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: mm(el.variant === 'coords' ? big * 0.62 : named ? big * 0.72 : big),
|
||||
fontWeight: heavy(el),
|
||||
lineHeight: 1,
|
||||
letterSpacing: el.variant === 'coords' || named ? '0.08em' : '-0.01em',
|
||||
textTransform: named ? 'uppercase' : undefined,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{main}
|
||||
</span>
|
||||
)}
|
||||
{sub && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: mm(small),
|
||||
fontWeight: light(el),
|
||||
letterSpacing: '0.16em',
|
||||
textTransform: 'uppercase',
|
||||
lineHeight: 1.2,
|
||||
opacity: 0.8,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{sub}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Coordinates carry a long value and the place under it: set on two lines, both stay legible. */
|
||||
const twoLines = (el: BookBadgeElement) => el.variant === 'coords' && !!el.text && !!el.sub;
|
||||
|
||||
/** The frame every style shares: the element's box, its face, centred content. */
|
||||
function Shell({
|
||||
el,
|
||||
frameStyle,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
el: BookBadgeElement;
|
||||
frameStyle: CSSProperties;
|
||||
style: CSSProperties;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...frameStyle,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxSizing: 'border-box',
|
||||
fontFamily: fontStack(el.font),
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A passport stamp: a rounded box ruled twice in the accent, the words in
|
||||
* capitals inside it, turned a few degrees and a shade short of solid, the
|
||||
* way an inked stamp lands.
|
||||
*/
|
||||
function StampMark({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSProperties }) {
|
||||
const { h } = el.frame;
|
||||
const line = Math.max(0.25, h * 0.045);
|
||||
const ink = el.autoColor === false ? el.color : el.accent;
|
||||
return (
|
||||
<Shell
|
||||
el={el}
|
||||
frameStyle={frameStyle}
|
||||
style={{
|
||||
flexDirection: 'column',
|
||||
gap: mm(h * 0.05),
|
||||
border: `${mm(line)} solid ${ink}`,
|
||||
outline: `${mm(line * 0.5)} solid ${ink}`,
|
||||
outlineOffset: mm(-line * 2.6),
|
||||
borderRadius: mm(h * 0.14),
|
||||
padding: `${mm(h * 0.14)} ${mm(h * 0.3)}`,
|
||||
textTransform: 'uppercase',
|
||||
// An inked stamp is a shade short of solid, on top of whatever the element asks.
|
||||
opacity: Number(frameStyle.opacity ?? 1) * 0.9,
|
||||
transform: `${frameStyle.transform ?? ''} rotate(-4deg)`.trim(),
|
||||
}}
|
||||
>
|
||||
{el.showIcon !== false && <BadgeIcon el={el} height={h * 0.26} ink={ink} />}
|
||||
<Words el={el} big={h * 0.3 * el.textScale} small={h * 0.13 * el.textScale} ink={ink} stacked />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A luggage tag: filled in the accent, its leading end cut to a point, with an
|
||||
* eyelet punched through so the page shows in the hole.
|
||||
*/
|
||||
function TagMark({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSProperties }) {
|
||||
const { w, h } = el.frame;
|
||||
const point = Math.min(h * 0.4, w * 0.18);
|
||||
const hole = h * 0.11;
|
||||
const holeX = point * 0.8;
|
||||
const ink = badgeInk(el);
|
||||
const lines = twoLines(el);
|
||||
const mask = `radial-gradient(circle at ${mm(holeX)} 50%, transparent ${mm(hole)}, black ${mm(hole + 0.05)})`;
|
||||
return (
|
||||
<Shell
|
||||
el={el}
|
||||
frameStyle={frameStyle}
|
||||
style={{
|
||||
gap: mm(h * 0.14),
|
||||
background: el.accent,
|
||||
clipPath: `polygon(${mm(point)} 0, 100% 0, 100% 100%, ${mm(point)} 100%, 0 50%)`,
|
||||
WebkitMaskImage: mask,
|
||||
maskImage: mask,
|
||||
paddingInlineStart: mm(point + hole * 1.6),
|
||||
paddingInlineEnd: mm(h * 0.3),
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{el.showIcon !== false && <BadgeIcon el={el} height={h * badgeIconShare(el.variant)} ink={ink} />}
|
||||
<Words
|
||||
el={el}
|
||||
big={h * (lines ? 0.34 : 0.38) * el.textScale}
|
||||
small={h * 0.16 * el.textScale}
|
||||
ink={ink}
|
||||
stacked={lines}
|
||||
start
|
||||
/>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/** A pill: a tinted capsule that opens on the mark's icon in a disc of the accent. */
|
||||
function PillMark({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSProperties }) {
|
||||
const { h } = el.frame;
|
||||
const disc = h * 0.78;
|
||||
const pad = (h - disc) / 2;
|
||||
const lines = twoLines(el);
|
||||
return (
|
||||
<Shell
|
||||
el={el}
|
||||
frameStyle={frameStyle}
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
gap: mm(h * 0.22),
|
||||
padding: mm(pad),
|
||||
paddingInlineEnd: mm(h * 0.42),
|
||||
borderRadius: mm(h / 2),
|
||||
background: inkAt(el.accent, 0.12),
|
||||
}}
|
||||
>
|
||||
{el.showIcon !== false && (
|
||||
<span
|
||||
style={{
|
||||
width: mm(disc),
|
||||
height: mm(disc),
|
||||
flex: '0 0 auto',
|
||||
borderRadius: '50%',
|
||||
background: el.accent,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<BadgeIcon el={el} height={disc * 0.52} ink={inkOn(el.accent)} generic />
|
||||
</span>
|
||||
)}
|
||||
<Words
|
||||
el={el}
|
||||
big={h * (lines ? 0.34 : 0.4) * el.textScale}
|
||||
small={h * 0.17 * el.textScale}
|
||||
ink={el.color}
|
||||
stacked={lines}
|
||||
start
|
||||
/>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A ribbon: a band in the accent across the top with its two ends folded
|
||||
* behind it and cut into swallowtails, the words in capitals on the band.
|
||||
*/
|
||||
function RibbonMark({ el, frameStyle }: MarkProps) {
|
||||
const { w, h } = el.frame;
|
||||
const tail = Math.min(h * 0.7, w * 0.16);
|
||||
const bandH = h * 0.72;
|
||||
const drop = h - bandH;
|
||||
const ink = inkOn(el.accent);
|
||||
const back = inkAt(el.color, 0.6);
|
||||
const end = (x: number, dir: 1 | -1) =>
|
||||
`M${x} ${drop} H${x + dir * tail * 1.4} L${x + dir * tail * 0.9} ${drop + bandH / 2} L${x + dir * tail * 1.4} ${h} H${x} Z`;
|
||||
return (
|
||||
<Shell el={el} frameStyle={frameStyle} style={{ overflow: 'visible', alignItems: 'flex-start' }}>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0, overflow: 'visible' }}
|
||||
aria-hidden
|
||||
>
|
||||
<path d={end(tail * 1.4, -1)} fill={back} />
|
||||
<path d={end(w - tail * 1.4, 1)} fill={back} />
|
||||
<path d={`M${tail * 0.7} ${bandH} L${tail * 1.4} ${bandH} L${tail * 1.4} ${h} Z`} fill={inkAt(el.color, 0.8)} />
|
||||
<path
|
||||
d={`M${w - tail * 0.7} ${bandH} L${w - tail * 1.4} ${bandH} L${w - tail * 1.4} ${h} Z`}
|
||||
fill={inkAt(el.color, 0.8)}
|
||||
/>
|
||||
<rect x={tail * 0.7} y={0} width={w - tail * 1.4} height={bandH} fill={el.accent} />
|
||||
</svg>
|
||||
<span
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: mm(bandH),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: mm(h * 0.14),
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{el.showIcon !== false && <BadgeIcon el={el} height={bandH * 0.5} ink={ink} />}
|
||||
<Words el={el} big={bandH * 0.46 * el.textScale} small={bandH * 0.2 * el.textScale} ink={ink} stacked={false} />
|
||||
</span>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/** A die-cut sticker: the mark in the accent inside its white cut edge, lifted off the page by a soft shadow. */
|
||||
function StickerMark({ el, frameStyle }: MarkProps) {
|
||||
const { h } = el.frame;
|
||||
const edge = h * 0.09;
|
||||
const ink = inkOn(el.accent);
|
||||
return (
|
||||
<Shell
|
||||
el={el}
|
||||
frameStyle={frameStyle}
|
||||
style={{
|
||||
gap: mm(h * 0.16),
|
||||
padding: `0 ${mm(h * 0.34)}`,
|
||||
borderRadius: mm(h * 0.3),
|
||||
background: el.accent,
|
||||
boxShadow: `0 0 0 ${mm(edge)} ${inkOn(el.color)}, 0 ${mm(edge * 0.8)} ${mm(edge * 2.2)} ${inkAt(el.color, 0.28)}`,
|
||||
transform: `${frameStyle.transform ?? ''} rotate(-3deg)`.trim(),
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
{el.showIcon !== false && <BadgeIcon el={el} height={h * badgeIconShare(el.variant)} ink={ink} />}
|
||||
<Words el={el} big={h * 0.4 * el.textScale} small={h * 0.17 * el.textScale} ink={ink} stacked={false} />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An embroidered patch: woven in the accent, a heavy rim round its edge and a
|
||||
* line of stitching inside it, the words sewn in the paper's colour.
|
||||
*/
|
||||
function PatchMark({ el, frameStyle }: MarkProps) {
|
||||
const { h } = el.frame;
|
||||
const ink = inkOn(el.accent);
|
||||
const rim = h * 0.07;
|
||||
return (
|
||||
<Shell
|
||||
el={el}
|
||||
frameStyle={frameStyle}
|
||||
style={{
|
||||
gap: mm(h * 0.16),
|
||||
padding: `0 ${mm(h * 0.4)}`,
|
||||
borderRadius: mm(h * 0.34),
|
||||
background: `repeating-linear-gradient(45deg, ${inkAt(ink, 0.08)} 0 ${mm(h * 0.02)}, transparent ${mm(h * 0.02)} ${mm(h * 0.055)}), ${el.accent}`,
|
||||
boxShadow: `inset 0 0 0 ${mm(rim)} ${inkAt(el.color, 0.35)}, 0 ${mm(rim * 0.4)} ${mm(rim)} ${inkAt(el.color, 0.25)}`,
|
||||
outline: `${mm(h * 0.025)} dashed ${inkAt(ink, 0.85)}`,
|
||||
outlineOffset: mm(-rim * 1.9),
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
{el.showIcon !== false && <BadgeIcon el={el} height={h * badgeIconShare(el.variant) * 0.9} ink={ink} />}
|
||||
<Words el={el} big={h * 0.36 * el.textScale} small={h * 0.15 * el.textScale} ink={ink} stacked={false} />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/** A frame drawn by hand: a freehand box traced twice over pale hatching, the words in the element's face. */
|
||||
function SketchMark({ el, frameStyle }: MarkProps) {
|
||||
const { w, h } = el.frame;
|
||||
const rand = seeded(`${el.variant}:${el.text}:${el.sub}`);
|
||||
const pen = Math.max(0.2, h * 0.03);
|
||||
const wob = h * 0.05;
|
||||
const hand = { rand, wobble: wob };
|
||||
const frame = `${roughBox({ x: pen * 2, y: pen * 2, w: w - pen * 4, h: h - pen * 4 }, hand)} ${roughBox({ x: pen * 3, y: pen * 1.2, w: w - pen * 5, h: h - pen * 3.4 }, hand)}`;
|
||||
const shade = hatch({ x: pen * 3, y: pen * 3, w: w - pen * 6, h: h - pen * 6 }, h * 0.12, {
|
||||
rand,
|
||||
wobble: wob * 0.3,
|
||||
});
|
||||
return (
|
||||
<Shell
|
||||
el={el}
|
||||
frameStyle={frameStyle}
|
||||
style={{ gap: mm(h * 0.14), padding: `0 ${mm(h * 0.3)}`, overflow: 'visible' }}
|
||||
>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0, overflow: 'visible' }}
|
||||
aria-hidden
|
||||
>
|
||||
<path d={shade} fill="none" stroke={el.accent} strokeWidth={pen * 0.6} strokeLinecap="round" opacity={0.35} />
|
||||
<path d={frame} fill="none" stroke={el.color} strokeWidth={pen} strokeLinecap="round" opacity={0.85} />
|
||||
</svg>
|
||||
{el.showIcon !== false && (
|
||||
<span style={{ position: 'relative' }}>
|
||||
<BadgeIcon el={el} height={h * badgeIconShare(el.variant)} generic />
|
||||
</span>
|
||||
)}
|
||||
<span style={{ position: 'relative' }}>
|
||||
<Words el={el} big={h * 0.42 * el.textScale} small={h * 0.18 * el.textScale} ink={el.color} stacked={false} />
|
||||
</span>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkProps {
|
||||
el: BookBadgeElement;
|
||||
frameStyle: CSSProperties;
|
||||
}
|
||||
|
||||
const MARKS: Partial<Record<BookBadgeStyle, (props: MarkProps) => ReactNode>> = {
|
||||
stamp: StampMark,
|
||||
tag: TagMark,
|
||||
pill: PillMark,
|
||||
ribbon: RibbonMark,
|
||||
sticker: StickerMark,
|
||||
patch: PatchMark,
|
||||
sketch: SketchMark,
|
||||
};
|
||||
|
||||
export function BadgeLookView({ el, frameStyle }: MarkProps) {
|
||||
const Mark = MARKS[el.style] ?? PillMark;
|
||||
return <Mark el={el} frameStyle={frameStyle} />;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import type { BookCountriesElement } from '@trek/shared';
|
||||
import { useId, type CSSProperties, type ReactNode } from 'react';
|
||||
import { fontStack } from '../bookFonts';
|
||||
import { FlagCloth, Label, OutlinePath, Pin, SketchCountry, Visa, type CountryArt } from './CountryLooksMore';
|
||||
import { FlagMark } from './FlagMark';
|
||||
import { heavy, inkAt, light, mm } from './travelInk';
|
||||
|
||||
/**
|
||||
* The newer looks of a country: a passport stamp and a postcard here, an entry
|
||||
* stamp, the flag in cloth, a map pin, a luggage label and a sketch in
|
||||
* CountryLooksMore.tsx. The classic
|
||||
* look, the name over its silhouette, stays in TravelElements.tsx, so every
|
||||
* country placed before these existed prints as it always has.
|
||||
*
|
||||
* The panel places one country per element; an element that holds several, as
|
||||
* the old lists did, draws one stamp or card for each, side by side.
|
||||
*/
|
||||
|
||||
interface Country {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A passport stamp: two rings, the name running round the top, the country
|
||||
* code round the bottom between two stars, and the outline in the middle. Set
|
||||
* a few degrees off square and a shade short of solid, the way an inked stamp
|
||||
* lands on a page.
|
||||
*/
|
||||
function Stamp({ el, c, size }: { el: BookCountriesElement; c: Country; size: number }) {
|
||||
const id = useId().replace(/:/g, '');
|
||||
const r = size / 2;
|
||||
const ring = Math.max(0.25, size * 0.025);
|
||||
const textR = r * 0.74;
|
||||
const type = Math.max(1.2, size * 0.125 * el.textScale);
|
||||
const top = `M ${r - textR} ${r} A ${textR} ${textR} 0 0 1 ${r + textR} ${r}`;
|
||||
const bottom = `M ${r - textR * 0.9} ${r} A ${textR * 0.9} ${textR * 0.9} 0 0 0 ${r + textR * 0.9} ${r}`;
|
||||
const name = c.name.toUpperCase();
|
||||
return (
|
||||
<svg
|
||||
width={mm(size)}
|
||||
height={mm(size)}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
style={{ display: 'block', transform: 'rotate(-7deg)', opacity: 0.9 }}
|
||||
>
|
||||
<defs>
|
||||
<path id={`${id}-top`} d={top} />
|
||||
<path id={`${id}-bottom`} d={bottom} />
|
||||
</defs>
|
||||
<circle cx={r} cy={r} r={r - ring} fill="none" stroke={el.accent} strokeWidth={ring} />
|
||||
<circle cx={r} cy={r} r={r * 0.86} fill="none" stroke={el.accent} strokeWidth={ring * 0.45} />
|
||||
<circle cx={r} cy={r} r={r * 0.5} fill="none" stroke={el.accent} strokeWidth={ring * 0.45} />
|
||||
{el.showOutline && <OutlinePath code={c.code} box={r * 0.66} x={r} y={r} fill={el.accent} />}
|
||||
{el.showName && (
|
||||
<text
|
||||
fill={el.accent}
|
||||
style={{
|
||||
fontFamily: fontStack(el.font),
|
||||
fontWeight: heavy(el),
|
||||
fontSize: `${type}px`,
|
||||
letterSpacing: `${type * 0.18}px`,
|
||||
}}
|
||||
>
|
||||
<textPath
|
||||
href={`#${id}-top`}
|
||||
startOffset="50%"
|
||||
textAnchor="middle"
|
||||
lengthAdjust="spacingAndGlyphs"
|
||||
textLength={name.length * type * 0.85 > Math.PI * textR * 0.9 ? Math.PI * textR * 0.9 : undefined}
|
||||
>
|
||||
{name}
|
||||
</textPath>
|
||||
</text>
|
||||
)}
|
||||
<text
|
||||
fill={el.accent}
|
||||
style={{
|
||||
fontFamily: fontStack(el.font),
|
||||
fontWeight: light(el),
|
||||
fontSize: `${type * 0.8}px`,
|
||||
letterSpacing: `${type * 0.3}px`,
|
||||
}}
|
||||
>
|
||||
<textPath href={`#${id}-bottom`} startOffset="50%" textAnchor="middle" dominantBaseline="hanging">
|
||||
{`★ ${c.code.toUpperCase()} ★`}
|
||||
</textPath>
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A postcard: an airmail border of diagonal stripes in the accent and the
|
||||
* ink, the country's outline large on the card, a postage stamp in the corner
|
||||
* with the flag or the code on it, a postmark struck across it, and the name
|
||||
* along the foot.
|
||||
*/
|
||||
function Postcard({ el, c, w, h }: { el: BookCountriesElement; c: Country; w: number; h: number }) {
|
||||
const id = useId().replace(/:/g, '');
|
||||
const edge = Math.min(w, h) * 0.055;
|
||||
const radius = Math.min(w, h) * 0.04;
|
||||
const stripe = edge * 0.9;
|
||||
const stampW = w * 0.2;
|
||||
const stampH = stampW * 1.18;
|
||||
const stampX = w - edge * 2 - stampW;
|
||||
const stampY = edge * 2;
|
||||
const type = Math.max(1.2, Math.min(h * 0.11, (w * 0.6) / Math.max(4, c.name.length * 0.78)) * el.textScale);
|
||||
const outlineBox = Math.min(w * 0.5, h * 0.6);
|
||||
const wave = (y: number) => {
|
||||
const x0 = stampX - stampW * 0.7;
|
||||
const seg = stampW * 0.35;
|
||||
return `M${x0} ${y} q${seg / 2} ${-edge * 0.5} ${seg} 0 t${seg} 0 t${seg} 0 t${seg} 0`;
|
||||
};
|
||||
return (
|
||||
<div style={{ position: 'relative', width: mm(w), height: mm(h) }}>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0 }}
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id={`${id}-air`}
|
||||
patternUnits="userSpaceOnUse"
|
||||
width={stripe * 4}
|
||||
height={stripe * 4}
|
||||
patternTransform="rotate(45)"
|
||||
>
|
||||
<rect x={0} y={0} width={stripe} height={stripe * 4} fill={el.accent} />
|
||||
<rect x={stripe * 2} y={0} width={stripe} height={stripe * 4} fill={el.color} />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect x={0} y={0} width={w} height={h} rx={radius} fill={inkAt(el.accent, 0.08)} />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
fill={`url(#${id}-air)`}
|
||||
d={`M${radius} 0 H${w - radius} Q${w} 0 ${w} ${radius} V${h - radius} Q${w} ${h} ${w - radius} ${h} H${radius} Q0 ${h} 0 ${h - radius} V${radius} Q0 0 ${radius} 0 Z M${edge} ${edge} V${h - edge} H${w - edge} V${edge} Z`}
|
||||
/>
|
||||
{el.showOutline && (
|
||||
<OutlinePath code={c.code} box={outlineBox} x={edge + w * 0.32} y={h * 0.46} fill={el.accent} />
|
||||
)}
|
||||
<rect
|
||||
x={stampX}
|
||||
y={stampY}
|
||||
width={stampW}
|
||||
height={stampH}
|
||||
fill="none"
|
||||
stroke={el.accent}
|
||||
strokeWidth={edge * 0.18}
|
||||
strokeDasharray={`${edge * 0.22} ${edge * 0.22}`}
|
||||
/>
|
||||
<rect
|
||||
x={stampX + edge * 0.35}
|
||||
y={stampY + edge * 0.35}
|
||||
width={stampW - edge * 0.7}
|
||||
height={stampH - edge * 0.7}
|
||||
fill={inkAt(el.accent, 0.14)}
|
||||
/>
|
||||
<circle
|
||||
cx={stampX}
|
||||
cy={stampY + stampH * 0.75}
|
||||
r={stampW * 0.42}
|
||||
fill="none"
|
||||
stroke={inkAt(el.color, 0.45)}
|
||||
strokeWidth={edge * 0.12}
|
||||
/>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<path
|
||||
key={i}
|
||||
d={wave(stampY + stampH * 0.58 + i * edge * 0.8)}
|
||||
fill="none"
|
||||
stroke={inkAt(el.color, 0.45)}
|
||||
strokeWidth={edge * 0.12}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: mm(stampX + edge * 0.35),
|
||||
top: mm(stampY + edge * 0.35),
|
||||
width: mm(stampW - edge * 0.7),
|
||||
height: mm(stampH - edge * 0.7),
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{el.showFlag ? (
|
||||
<FlagMark code={c.code} size={(stampW - edge * 0.7) * 0.5} />
|
||||
) : (
|
||||
<span
|
||||
style={{ fontSize: mm(stampW * 0.3), fontWeight: heavy(el), color: el.accent, letterSpacing: '0.04em' }}
|
||||
>
|
||||
{c.code.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{el.showName && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: mm(edge * 2.2),
|
||||
bottom: mm(edge * 1.9),
|
||||
maxWidth: mm(w * 0.62),
|
||||
fontSize: mm(type),
|
||||
fontWeight: heavy(el),
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
lineHeight: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
color: el.color,
|
||||
}}
|
||||
>
|
||||
{c.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The looks drawn in CountryLooksMore.tsx, by name. */
|
||||
const MORE: Record<string, (props: CountryArt) => ReactNode> = {
|
||||
visa: Visa,
|
||||
flag: FlagCloth,
|
||||
pin: Pin,
|
||||
label: Label,
|
||||
sketch: SketchCountry,
|
||||
};
|
||||
|
||||
export function CountryLookView({ el, frameStyle }: { el: BookCountriesElement; frameStyle: CSSProperties }) {
|
||||
const countries: Country[] = el.codes.map((code, i) => ({ code, name: el.names[i] || code.toUpperCase() }));
|
||||
const n = Math.max(1, countries.length);
|
||||
const gap = Math.min(el.frame.w, el.frame.h) * 0.05;
|
||||
const cellW = (el.frame.w - gap * (n - 1)) / n;
|
||||
const cellH = el.frame.h;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...frameStyle,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(gap),
|
||||
fontFamily: fontStack(el.font),
|
||||
color: el.color,
|
||||
}}
|
||||
>
|
||||
{countries.map((c, i) => {
|
||||
const key = `${c.code}-${i}`;
|
||||
if (el.look === 'stamp') return <Stamp key={key} el={el} c={c} size={Math.min(cellW, cellH)} />;
|
||||
if (el.look === 'postcard') return <Postcard key={key} el={el} c={c} w={cellW} h={cellH} />;
|
||||
const Look = MORE[el.look ?? 'visa'] ?? Visa;
|
||||
return <Look key={key} el={el} code={c.code} name={c.name} w={cellW} h={cellH} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import type { BookCountriesElement } from '@trek/shared';
|
||||
import { useId } from 'react';
|
||||
import { fontStack } from '../bookFonts';
|
||||
import { COUNTRY_SHAPES } from '../countryShapes';
|
||||
import { inkOn } from './badgeLook';
|
||||
import { FlagMark } from './FlagMark';
|
||||
import { hatch, roughLine, seeded } from './sketch';
|
||||
import { heavy, inkAt, light, mm } from './travelInk';
|
||||
|
||||
/**
|
||||
* The country looks drawn after real things: an entry stamp, the flag in
|
||||
* cloth, a map pin, an old luggage label and a pencil sketch. CountryLooks.tsx
|
||||
* picks the look; each draws one country in the element's ink and accent.
|
||||
*/
|
||||
|
||||
export interface CountryArt {
|
||||
el: BookCountriesElement;
|
||||
code: string;
|
||||
name: string;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** A country's outline fitted into a box, centred, keeping its proportions. */
|
||||
export function OutlinePath({
|
||||
code,
|
||||
box,
|
||||
x,
|
||||
y,
|
||||
fill,
|
||||
stroke,
|
||||
strokeWidth,
|
||||
}: {
|
||||
code: string;
|
||||
box: number;
|
||||
x: number;
|
||||
y: number;
|
||||
fill: string;
|
||||
stroke?: string;
|
||||
strokeWidth?: number;
|
||||
}) {
|
||||
const shape = COUNTRY_SHAPES[code.toUpperCase()];
|
||||
if (!shape) return null;
|
||||
const scale = box / Math.max(shape.w, shape.h);
|
||||
const ox = x - (shape.w * scale) / 2;
|
||||
const oy = y - (shape.h * scale) / 2;
|
||||
return (
|
||||
<path
|
||||
d={shape.d}
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth ? strokeWidth / scale : undefined}
|
||||
strokeLinejoin="round"
|
||||
transform={`translate(${ox} ${oy}) scale(${scale})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The name as an element sets it: heavy, tracked capitals, cut short rather than spilling. */
|
||||
function Name({
|
||||
el,
|
||||
name,
|
||||
size,
|
||||
color,
|
||||
spacing = '0.14em',
|
||||
}: {
|
||||
el: BookCountriesElement;
|
||||
name: string;
|
||||
size: number;
|
||||
color: string;
|
||||
spacing?: string;
|
||||
}) {
|
||||
if (!el.showName) return null;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: mm(size),
|
||||
fontWeight: heavy(el),
|
||||
letterSpacing: spacing,
|
||||
textTransform: 'uppercase',
|
||||
lineHeight: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const nameSize = (name: string, width: number, cap: number) =>
|
||||
Math.max(1.2, Math.min(cap, width / Math.max(4, name.length * 0.82)));
|
||||
|
||||
/**
|
||||
* An entry stamp: a box with its corners cut, ruled twice in the accent, a
|
||||
* row of stars over the name and the code under it, set a few degrees off
|
||||
* square and a shade short of solid the way it lands in a passport.
|
||||
*/
|
||||
export function Visa({ el, code, name, w, h }: CountryArt) {
|
||||
const c = Math.min(w, h) * 0.16;
|
||||
const line = Math.max(0.25, Math.min(w, h) * 0.03);
|
||||
const cut = (i: number) => {
|
||||
const o = i * line * 2.2;
|
||||
return `M${c + o} ${o} H${w - c - o} L${w - o} ${c + o} V${h - c - o} L${w - c - o} ${h - o} H${c + o} L${o} ${h - c - o} V${c + o} Z`;
|
||||
};
|
||||
const type = nameSize(name, w * 0.74, h * 0.2) * el.textScale;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: mm(w),
|
||||
height: mm(h),
|
||||
transform: 'rotate(3deg)',
|
||||
opacity: 0.92,
|
||||
color: el.accent,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0, overflow: 'visible' }}
|
||||
aria-hidden
|
||||
>
|
||||
<path d={cut(0.5)} fill="none" stroke={el.accent} strokeWidth={line} />
|
||||
<path d={cut(1.4)} fill="none" stroke={el.accent} strokeWidth={line * 0.45} />
|
||||
{el.showOutline && (
|
||||
<OutlinePath
|
||||
code={code}
|
||||
box={Math.min(w, h) * 0.5}
|
||||
x={w * 0.84 - c * 0.4}
|
||||
y={h * 0.5}
|
||||
fill={inkAt(el.accent, 0.16)}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(h * 0.06),
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: mm(type * 0.5), letterSpacing: '0.3em' }}>★ ★ ★</span>
|
||||
<Name el={el} name={name} size={type} color={el.accent} />
|
||||
<span style={{ fontSize: mm(type * 0.42), fontWeight: light(el), letterSpacing: '0.4em' }}>
|
||||
{code.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The flag in cloth: the flag drawn by flags.ts with light and shade laid
|
||||
* over it in folds, so it reads as fabric hanging from its pole, and the name
|
||||
* under it.
|
||||
*/
|
||||
export function FlagCloth({ el, code, name, w, h }: CountryArt) {
|
||||
const flagH = h * 0.62;
|
||||
const flagW = Math.min(w * 0.82, flagH * 1.5);
|
||||
const type = nameSize(name, w * 0.86, h * 0.14) * el.textScale;
|
||||
const fold = (a: number) => inkAt(el.color, a);
|
||||
const sheen = inkAt(inkOn(el.color), 0.32);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: mm(w),
|
||||
height: mm(h),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(h * 0.07),
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'stretch' }}>
|
||||
<span
|
||||
style={{
|
||||
width: mm(flagH * 0.035),
|
||||
borderRadius: mm(flagH),
|
||||
background: el.color,
|
||||
marginInlineEnd: mm(flagH * 0.01),
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: mm(flagW),
|
||||
height: mm(flagH),
|
||||
overflow: 'hidden',
|
||||
borderRadius: `0 ${mm(flagH * 0.04)} ${mm(flagH * 0.04)} 0`,
|
||||
boxShadow: `0 ${mm(flagH * 0.04)} ${mm(flagH * 0.1)} ${inkAt(el.color, 0.25)}`,
|
||||
transform: 'perspective(60mm) rotateY(-8deg)',
|
||||
transformOrigin: 'left center',
|
||||
}}
|
||||
>
|
||||
<span style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'stretch' }}>
|
||||
<FlagStretch code={code} w={flagW} h={flagH} />
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: `linear-gradient(90deg, ${fold(0.18)} 0%, ${sheen} 14%, ${fold(0.05)} 30%, ${fold(0.22)} 46%, ${sheen} 62%, ${fold(0.08)} 78%, ${fold(0.24)} 100%)`,
|
||||
mixBlendMode: 'soft-light',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<Name el={el} name={name} size={type} color={el.color} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The flag filling its cloth: flags.ts draws it at its own ratio, so it is scaled to cover. */
|
||||
function FlagStretch({ code, w, h }: { code: string; w: number; h: number }) {
|
||||
return (
|
||||
<span style={{ width: mm(w), height: mm(h), display: 'grid', placeItems: 'center', overflow: 'hidden' }}>
|
||||
<span style={{ transform: `scale(${Math.max(1, w / h / 1.5)})` }}>
|
||||
<FlagMark code={code} size={h} />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A map pin: the teardrop in the accent standing on its shadow, the flag in
|
||||
* a disc in its head, and the name under it.
|
||||
*/
|
||||
export function Pin({ el, code, name, w, h }: CountryArt) {
|
||||
const pinH = h * 0.7;
|
||||
const r = pinH * 0.36;
|
||||
const cx = w / 2;
|
||||
const cy = r * 1.05;
|
||||
const tip = pinH;
|
||||
const type = nameSize(name, w * 0.9, h * 0.14) * el.textScale;
|
||||
const id = useId().replace(/:/g, '');
|
||||
return (
|
||||
<div style={{ position: 'relative', width: mm(w), height: mm(h) }}>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0 }}
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id={`${id}-shine`} cx="35%" cy="30%" r="70%">
|
||||
<stop offset="0%" stopColor={inkOn(el.accent)} stopOpacity={0.35} />
|
||||
<stop offset="60%" stopColor={inkOn(el.accent)} stopOpacity={0} />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<ellipse cx={cx} cy={tip + r * 0.12} rx={r * 0.6} ry={r * 0.16} fill={inkAt(el.color, 0.22)} />
|
||||
<path
|
||||
d={`M${cx} ${tip} C${cx - r * 0.35} ${cy + r * 1.1} ${cx - r} ${cy + r * 0.6} ${cx - r} ${cy} A${r} ${r} 0 1 1 ${cx + r} ${cy} C${cx + r} ${cy + r * 0.6} ${cx + r * 0.35} ${cy + r * 1.1} ${cx} ${tip} Z`}
|
||||
fill={el.accent}
|
||||
/>
|
||||
<path
|
||||
d={`M${cx} ${tip} C${cx - r * 0.35} ${cy + r * 1.1} ${cx - r} ${cy + r * 0.6} ${cx - r} ${cy} A${r} ${r} 0 1 1 ${cx + r} ${cy} C${cx + r} ${cy + r * 0.6} ${cx + r * 0.35} ${cy + r * 1.1} ${cx} ${tip} Z`}
|
||||
fill={`url(#${id}-shine)`}
|
||||
/>
|
||||
<circle cx={cx} cy={cy} r={r * 0.66} fill={inkOn(el.accent)} />
|
||||
</svg>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: mm(cx - r * 0.56),
|
||||
top: mm(cy - r * 0.56),
|
||||
width: mm(r * 1.12),
|
||||
height: mm(r * 1.12),
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{el.showFlag ? (
|
||||
<FlagMark code={code} size={r * 1.12} />
|
||||
) : (
|
||||
<OutlineBadge code={code} size={r * 1.0} fill={el.accent} />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: 0,
|
||||
insetInlineEnd: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Name el={el} name={name} size={type} color={el.color} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OutlineBadge({ code, size, fill }: { code: string; size: number; fill: string }) {
|
||||
return (
|
||||
<svg width={mm(size)} height={mm(size)} viewBox={`0 0 ${size} ${size}`} aria-hidden>
|
||||
<OutlinePath code={code} box={size * 0.86} x={size / 2} y={size / 2} fill={fill} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An old luggage label: an oval ruled twice, tinted inside, the outline in
|
||||
* the middle under a sweep of stars, and the name on a ribbon across it with
|
||||
* its ends folded back.
|
||||
*/
|
||||
export function Label({ el, code, name, w, h }: CountryArt) {
|
||||
const rx = w / 2;
|
||||
const ry = h * 0.42;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const line = Math.max(0.25, Math.min(w, h) * 0.022);
|
||||
const bandH = h * 0.22;
|
||||
const bandW = w * 0.92;
|
||||
const bx = cx - bandW / 2;
|
||||
const by = cy + ry * 0.18;
|
||||
const fold = bandH * 0.5;
|
||||
const type = nameSize(name, bandW * 0.78, bandH * 0.56) * el.textScale;
|
||||
const ink = inkOn(el.accent);
|
||||
return (
|
||||
<div style={{ position: 'relative', width: mm(w), height: mm(h) }}>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0, overflow: 'visible' }}
|
||||
aria-hidden
|
||||
>
|
||||
<ellipse
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
rx={rx - line}
|
||||
ry={ry}
|
||||
fill={inkAt(el.accent, 0.12)}
|
||||
stroke={el.accent}
|
||||
strokeWidth={line}
|
||||
/>
|
||||
<ellipse
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
rx={rx - line * 3.2}
|
||||
ry={ry - line * 2.2}
|
||||
fill="none"
|
||||
stroke={el.accent}
|
||||
strokeWidth={line * 0.45}
|
||||
/>
|
||||
{el.showOutline && (
|
||||
<OutlinePath code={code} box={Math.min(w * 0.36, ry * 1.1)} x={cx} y={cy - ry * 0.28} fill={el.accent} />
|
||||
)}
|
||||
<path
|
||||
d={`M${bx - fold} ${by + fold * 0.5} H${bx + fold} V${by + bandH + fold * 0.5} H${bx - fold} L${bx - fold * 0.4} ${by + bandH / 2 + fold * 0.5} Z`}
|
||||
fill={inkAt(el.color, 0.55)}
|
||||
/>
|
||||
<path
|
||||
d={`M${bx + bandW + fold} ${by + fold * 0.5} H${bx + bandW - fold} V${by + bandH + fold * 0.5} H${bx + bandW + fold} L${bx + bandW + fold * 0.4} ${by + bandH / 2 + fold * 0.5} Z`}
|
||||
fill={inkAt(el.color, 0.55)}
|
||||
/>
|
||||
<rect x={bx} y={by} width={bandW} height={bandH} fill={el.accent} />
|
||||
</svg>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: mm(bx),
|
||||
top: mm(by),
|
||||
width: mm(bandW),
|
||||
height: mm(bandH),
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Name el={el} name={name} size={type} color={ink} spacing="0.2em" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The country drawn by hand: its outline traced twice in pencil, slightly
|
||||
* apart, hatched inside in the accent, and its name written under it in the
|
||||
* element's face, a script one when it comes from the panel.
|
||||
*/
|
||||
export function SketchCountry({ el, code, name, w, h }: CountryArt) {
|
||||
const id = useId().replace(/:/g, '');
|
||||
const rand = seeded(`${code}:${name}`);
|
||||
const box = Math.min(w * 0.86, h * 0.68);
|
||||
const cx = w / 2;
|
||||
const cy = h * 0.4;
|
||||
const pen = Math.max(0.2, box * 0.012);
|
||||
const shade = hatch({ x: cx - box / 2, y: cy - box / 2, w: box, h: box }, box * 0.045, { rand, wobble: box * 0.008 });
|
||||
const type = nameSize(name, w * 0.9, h * 0.16) * el.textScale;
|
||||
const under = roughLine(w * 0.2, h * 0.93, w * 0.8, h * 0.91, { rand, wobble: h * 0.012 });
|
||||
return (
|
||||
<div style={{ position: 'relative', width: mm(w), height: mm(h) }}>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0, overflow: 'visible' }}
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<clipPath id={`${id}-land`}>
|
||||
<OutlinePath code={code} box={box} x={cx} y={cy} fill={el.accent} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<path
|
||||
d={shade}
|
||||
fill="none"
|
||||
stroke={el.accent}
|
||||
strokeWidth={pen * 0.8}
|
||||
strokeLinecap="round"
|
||||
opacity={0.75}
|
||||
clipPath={`url(#${id}-land)`}
|
||||
/>
|
||||
{el.showOutline && (
|
||||
<>
|
||||
<OutlinePath code={code} box={box} x={cx} y={cy} fill="none" stroke={el.color} strokeWidth={pen} />
|
||||
<g transform={`translate(${pen * 0.9} ${-pen * 0.6})`} opacity={0.55}>
|
||||
<OutlinePath
|
||||
code={code}
|
||||
box={box * 1.01}
|
||||
x={cx}
|
||||
y={cy}
|
||||
fill="none"
|
||||
stroke={el.color}
|
||||
strokeWidth={pen * 0.7}
|
||||
/>
|
||||
</g>
|
||||
</>
|
||||
)}
|
||||
<path d={under} fill="none" stroke={el.accent} strokeWidth={pen * 1.4} strokeLinecap="round" />
|
||||
</svg>
|
||||
{el.showName && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: 0,
|
||||
insetInlineEnd: 0,
|
||||
bottom: mm(h * 0.1),
|
||||
textAlign: 'center',
|
||||
fontFamily: fontStack(el.font),
|
||||
fontSize: mm(type),
|
||||
fontWeight: heavy(el),
|
||||
color: el.color,
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Where a journey began and where it ended, for a map whose stops are marked
|
||||
* by their ends. The start is a ring around a dot, the way a route planner
|
||||
* marks its origin; the finish is a disc carrying a pennant. Both sit on a
|
||||
* soft shadow so they stand clear of the line through them.
|
||||
*/
|
||||
export function EndMarker({
|
||||
x,
|
||||
y,
|
||||
r,
|
||||
finish,
|
||||
ink,
|
||||
paper,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
/** The marker's radius in millimetres, the size a numbered stop has. */
|
||||
r: number;
|
||||
finish: boolean;
|
||||
/** The line's colour, and the colour that reads on it. */
|
||||
ink: string;
|
||||
paper: string;
|
||||
}) {
|
||||
const R = r * 1.15;
|
||||
return (
|
||||
<g>
|
||||
<circle cx={x} cy={y + R * 0.12} r={R * 1.12} fill="#000000" fillOpacity={0.14} />
|
||||
{finish ? (
|
||||
<>
|
||||
<circle cx={x} cy={y} r={R} fill={ink} stroke={paper} strokeWidth={R * 0.16} />
|
||||
<line
|
||||
x1={x - R * 0.28}
|
||||
y1={y + R * 0.5}
|
||||
x2={x - R * 0.28}
|
||||
y2={y - R * 0.52}
|
||||
stroke={paper}
|
||||
strokeWidth={R * 0.14}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d={`M${x - R * 0.28} ${y - R * 0.52} L${x + R * 0.5} ${y - R * 0.27} L${x - R * 0.28} ${y - R * 0.02} Z`}
|
||||
fill={paper}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<circle cx={x} cy={y} r={R} fill={paper} stroke={ink} strokeWidth={R * 0.3} />
|
||||
<circle cx={x} cy={y} r={R * 0.34} fill={ink} />
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { COUNTRY_SHAPES } from '../countryShapes';
|
||||
import { FLAG_H, FLAG_W, flagBands, flagDisc, flagSpec } from '../flags';
|
||||
import { round2 } from './travelInk';
|
||||
|
||||
/**
|
||||
* A flag, drawn.
|
||||
*
|
||||
* The first version set a regional-indicator pair and let the platform draw it.
|
||||
* That is the standard trick and it is wrong for most of the people who will
|
||||
* use this: Chrome on Windows ships no flag glyphs, so it renders the two
|
||||
* letters, and "DE" printed in a photo book is not a flag.
|
||||
*
|
||||
* So flags are constructed from `flags.ts` — exact for the stripe, cross and
|
||||
* disc constructions listed there, and absent for anything with an emblem
|
||||
* rather than approximated. A country with no construction falls back to its
|
||||
* silhouette, which is correct for all 198 of them.
|
||||
*/
|
||||
export function FlagMark({ code, size }: { code: string; size: number }) {
|
||||
const spec = flagSpec(code);
|
||||
const height = size;
|
||||
const width = (height * FLAG_W) / FLAG_H;
|
||||
|
||||
if (spec) {
|
||||
const disc = flagDisc(spec);
|
||||
return (
|
||||
<svg
|
||||
width={`${round2(width)}mm`}
|
||||
height={`${round2(height)}mm`}
|
||||
viewBox={`0 0 ${FLAG_W} ${FLAG_H}`}
|
||||
style={{ display: 'block', flex: '0 0 auto', borderRadius: `${round2(height * 0.08)}mm` }}
|
||||
aria-label={code.toUpperCase()}
|
||||
>
|
||||
{flagBands(spec).map((b, i) => (
|
||||
<rect key={i} x={b.x} y={b.y} width={b.w} height={b.h} fill={b.fill} />
|
||||
))}
|
||||
{disc && <circle cx={disc.cx} cy={disc.cy} r={disc.r} fill={disc.fill} />}
|
||||
{/* A hairline keeps a white-edged flag off a white page. */}
|
||||
<rect x="0" y="0" width={FLAG_W} height={FLAG_H} fill="none" stroke="rgba(0,0,0,.18)" strokeWidth="0.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const shape = COUNTRY_SHAPES[code.toUpperCase()];
|
||||
if (!shape) {
|
||||
// Neither a construction nor an outline: the code, set as a mark rather
|
||||
// than as two stray capitals.
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${round2(size * 0.62)}mm`,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
lineHeight: 1,
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
{code.toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
width={`${round2((height * shape.w) / shape.h)}mm`}
|
||||
height={`${round2(height)}mm`}
|
||||
viewBox={`0 0 ${shape.w} ${shape.h}`}
|
||||
style={{ display: 'block', flex: '0 0 auto' }}
|
||||
aria-label={code.toUpperCase()}
|
||||
>
|
||||
<path d={shape.d} fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
import type { BookStatsElement } from '@trek/shared';
|
||||
import { useId, type CSSProperties, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { fontStack } from '../bookFonts';
|
||||
import { inkOn } from './badgeLook';
|
||||
import { chars, fitAll, fitCaption, fitType, gridOf, type Figure } from './statsFit';
|
||||
import { NoteLook, OdometerLook, SignLook, SketchLook } from './StatsLooksMore';
|
||||
import { Caption, FigureText, IconDisc, IconDiscOn } from './statsParts';
|
||||
import { heavy, inkAt, METRIC_ICONS, metricValue, mm } from './travelInk';
|
||||
|
||||
/**
|
||||
* The newer looks of a journey summary: a poster, a ticket, tiles, a band and
|
||||
* a medal. The classic look, icon over figure over caption, stays in
|
||||
* TravelElements.tsx untouched, so every summary placed before these existed
|
||||
* prints as it always has.
|
||||
*
|
||||
* They are drawn in the element's two colours: the ink for the words and the
|
||||
* accent for what carries them, a disc, a stub, a tile, a ring. Placed from
|
||||
* the panel both are linked to the book's theme, so a summary takes the
|
||||
* theme's colours and follows them. Everything is sized from the frame and the
|
||||
* length of what it prints, never from a fixed point size, so a summary
|
||||
* dragged from a corner to a whole page grows its figures with it.
|
||||
*/
|
||||
|
||||
export interface LookProps {
|
||||
el: BookStatsElement;
|
||||
figures: Figure[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The poster: the first figure set as large as the frame allows, over a bar in
|
||||
* the accent, with its name and icon above it; the others in a column beside
|
||||
* it, each with its own disc, between hairlines. The order of the figures is
|
||||
* the order of importance, so the one somebody put first is the page's.
|
||||
*/
|
||||
function PosterLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const [hero, ...rest] = figures;
|
||||
if (!hero) return null;
|
||||
const heroW = rest.length ? w * 0.58 : w;
|
||||
const big = fitType(chars(hero), heroW * 0.92, h * 0.5) * el.textScale;
|
||||
const row = h / Math.max(1, rest.length);
|
||||
const disc = Math.min(row * 0.46, (w - heroW) * 0.16);
|
||||
const small = fitAll(rest, (w - heroW) * 0.62, row * 0.36) * el.textScale;
|
||||
const hair = `${mm(Math.max(0.18, h * 0.005))} solid ${inkAt(el.color, 0.16)}`;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
gap: mm(h * 0.045),
|
||||
width: mm(heroW),
|
||||
minWidth: 0,
|
||||
paddingInlineEnd: mm(w * 0.03),
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: mm(big * 0.12) }}>
|
||||
{el.showIcons && <IconDisc metric={hero.metric} size={big * 0.32} el={el} solid />}
|
||||
<Caption text={hero.label} size={Math.max(1.2, big * 0.13)} el={el} color={el.accent} opacity={1} />
|
||||
</span>
|
||||
<FigureText f={hero} size={big} el={el} color={el.color} unitColor={el.accent} />
|
||||
<span
|
||||
style={{
|
||||
width: mm(Math.min(heroW * 0.22, h * 0.3)),
|
||||
height: mm(Math.max(0.6, h * 0.028)),
|
||||
borderRadius: mm(h),
|
||||
background: el.accent,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{rest.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
borderInlineStart: hair,
|
||||
paddingInlineStart: mm(w * 0.045),
|
||||
}}
|
||||
>
|
||||
{rest.map((f, i) => (
|
||||
<div
|
||||
key={f.metric}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: mm(disc * 0.45),
|
||||
minHeight: 0,
|
||||
borderTop: i ? hair : undefined,
|
||||
}}
|
||||
>
|
||||
{el.showIcons && <IconDisc metric={f.metric} size={disc} el={el} />}
|
||||
<span style={{ display: 'flex', flexDirection: 'column', gap: mm(small * 0.1), minWidth: 0 }}>
|
||||
<FigureText f={f} size={small} el={el} color={el.color} unitColor={el.accent} />
|
||||
<Caption text={f.label} size={Math.max(1.2, small * 0.3)} el={el} />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A ticket: the outline with a notch bitten out either side of the tear, the
|
||||
* stub solid in the accent with the first figure on it, and the others as the
|
||||
* fields of a boarding pass, name over value, over a barcode drawn from the
|
||||
* figures themselves, so two journeys never print the same one.
|
||||
*/
|
||||
function TicketLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const id = useId().replace(/:/g, '');
|
||||
const [hero, ...rest] = figures;
|
||||
if (!hero) return null;
|
||||
const stubW = rest.length ? w * 0.34 : w;
|
||||
const notch = Math.min(h * 0.075, w * 0.035);
|
||||
const radius = Math.min(h * 0.09, w * 0.045);
|
||||
const cols = Math.min(3, Math.max(1, rest.length));
|
||||
const rows = Math.ceil(rest.length / cols);
|
||||
const fieldW = ((w - stubW) * 0.86) / cols;
|
||||
const fieldH = (h * 0.56) / Math.max(1, rows);
|
||||
const value = fitAll(rest, fieldW * 0.92, fieldH * 0.56) * el.textScale;
|
||||
const caption = fitCaption(
|
||||
rest.map((f) => f.label),
|
||||
fieldW,
|
||||
value * 0.3
|
||||
);
|
||||
const big = fitType(chars(hero), stubW * 0.78, h * 0.36) * el.textScale;
|
||||
const onStub = inkOn(el.accent);
|
||||
return (
|
||||
<>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0 }}
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<clipPath id={`${id}-ticket`} clipPathUnits="userSpaceOnUse">
|
||||
<path d={ticketPath(w, h, rest.length ? stubW : null, notch, radius)} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clipPath={`url(#${id}-ticket)`}>
|
||||
<rect x={0} y={0} width={w} height={h} fill={inkAt(el.accent, 0.1)} />
|
||||
<rect x={0} y={0} width={stubW} height={h} fill={el.accent} />
|
||||
</g>
|
||||
{rest.length > 0 && (
|
||||
<line
|
||||
x1={stubW}
|
||||
x2={stubW}
|
||||
y1={notch * 1.7}
|
||||
y2={h - notch * 1.7}
|
||||
stroke={inkAt(el.color, 0.32)}
|
||||
strokeWidth={Math.max(0.2, h * 0.008)}
|
||||
strokeDasharray={`${h * 0.025} ${h * 0.022}`}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: mm(stubW),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(h * 0.035),
|
||||
}}
|
||||
>
|
||||
{el.showIcons &&
|
||||
(() => {
|
||||
const Icon = METRIC_ICONS[hero.metric];
|
||||
return (
|
||||
<Icon
|
||||
color={onStub}
|
||||
strokeWidth={2}
|
||||
style={{ width: mm(big * 0.34), height: mm(big * 0.34), display: 'block', opacity: 0.85 }}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<FigureText f={hero} size={big} el={el} color={onStub} />
|
||||
<Caption
|
||||
text={hero.label}
|
||||
size={fitCaption([hero.label], stubW * 0.8, big * 0.18)}
|
||||
el={el}
|
||||
color={onStub}
|
||||
opacity={0.82}
|
||||
/>
|
||||
</div>
|
||||
{rest.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
padding: `0 ${mm(w * 0.045)}`,
|
||||
gap: mm(h * 0.07),
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||
rowGap: mm(h * 0.06),
|
||||
columnGap: mm(w * 0.02),
|
||||
}}
|
||||
>
|
||||
{rest.map((f) => (
|
||||
<div key={f.metric} style={{ display: 'flex', flexDirection: 'column', gap: mm(h * 0.02), minWidth: 0 }}>
|
||||
<Caption text={f.label} size={caption} el={el} color={el.accent} opacity={1} />
|
||||
<FigureText f={f} size={value} el={el} color={el.color} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Barcode figures={figures} color={el.color} width={(w - stubW) * 0.56} height={h * 0.13} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ticket's outline: a rounded rectangle with a half circle bitten out of
|
||||
* the top and the bottom edge where the stub tears off. One path, so the page
|
||||
* shows through the notches whatever lies under the ticket.
|
||||
*/
|
||||
function ticketPath(w: number, h: number, stubX: number | null, n: number, r: number): string {
|
||||
const top = stubX == null ? '' : ` L${stubX - n} 0 A${n} ${n} 0 0 0 ${stubX + n} 0`;
|
||||
const bottom = stubX == null ? '' : ` L${stubX + n} ${h} A${n} ${n} 0 0 0 ${stubX - n} ${h}`;
|
||||
return [
|
||||
`M${r} 0`,
|
||||
top,
|
||||
` L${w - r} 0 Q${w} 0 ${w} ${r}`,
|
||||
` L${w} ${h - r} Q${w} ${h} ${w - r} ${h}`,
|
||||
bottom,
|
||||
` L${r} ${h} Q0 ${h} 0 ${h - r}`,
|
||||
` L0 ${r} Q0 0 ${r} 0 Z`,
|
||||
].join('');
|
||||
}
|
||||
|
||||
/** Bars whose widths come from the figures' digits: decoration that is still the journey's own. */
|
||||
function Barcode({
|
||||
figures,
|
||||
color,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
figures: Figure[];
|
||||
color: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}) {
|
||||
const digits = figures.map((f) => f.value.replace(/\D/g, '')).join('') || '0';
|
||||
const bars = Array.from({ length: 30 }, (_, i) => 1 + ((Number(digits[i % digits.length]) + i * 7) % 3));
|
||||
const unit = width / bars.reduce((sum, b) => sum + b + 1, 0);
|
||||
const starts = bars.reduce<number[]>(
|
||||
(acc, b, i) => [...acc, i ? (acc[i - 1] ?? 0) + ((bars[i - 1] ?? 0) + 1) * unit : 0],
|
||||
[]
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
width={mm(width)}
|
||||
height={mm(height)}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
aria-hidden
|
||||
style={{ display: 'block', opacity: 0.8 }}
|
||||
>
|
||||
{bars.map((b, i) => (
|
||||
<rect key={i} x={starts[i]} y={0} width={b * unit} height={height} fill={color} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A tile per figure, rounded and tinted in the accent, the first one solid so
|
||||
* the eye lands there; each with its icon in a disc in the corner and the
|
||||
* figure set large along the foot.
|
||||
*/
|
||||
function TilesLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const { cols, rows } = gridOf(figures.length);
|
||||
const gap = Math.min(w, h) * 0.04;
|
||||
const tileW = (w - gap * (cols - 1)) / cols;
|
||||
const tileH = (h - gap * (rows - 1)) / rows;
|
||||
const pad = Math.min(tileW, tileH) * 0.12;
|
||||
const size = fitAll(figures, tileW - pad * 2, tileH * 0.34) * el.textScale;
|
||||
const caption = fitCaption(
|
||||
figures.map((f) => f.label),
|
||||
tileW - pad * 2,
|
||||
size * 0.24
|
||||
);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||
gridAutoRows: '1fr',
|
||||
gap: mm(gap),
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{figures.map((f, i) => {
|
||||
const solid = i === 0;
|
||||
const ink = solid ? inkOn(el.accent) : el.color;
|
||||
return (
|
||||
<div
|
||||
key={f.metric}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
padding: mm(pad),
|
||||
borderRadius: mm(Math.min(tileW, tileH) * 0.16),
|
||||
background: solid ? el.accent : inkAt(el.accent, 0.12),
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{el.showIcons ? <IconDiscOn metric={f.metric} size={size * 0.62} el={el} solid={solid} /> : <span />}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: mm(size * 0.1), minWidth: 0 }}>
|
||||
<FigureText f={f} size={size} el={el} color={ink} unitColor={solid ? ink : el.accent} />
|
||||
<Caption text={f.label} size={caption} el={el} color={ink} opacity={solid ? 0.85 : 0.6} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A band: the figures in one line under a rule in the accent and over a
|
||||
* hairline, each in its own column with its icon beside its name, divided by
|
||||
* hairlines. The strip a page of photographs can carry along its foot.
|
||||
*/
|
||||
function BandLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const cellW = w / Math.max(1, figures.length);
|
||||
const size = fitAll(figures, cellW * 0.8, h * 0.42) * el.textScale;
|
||||
const caption = fitCaption(
|
||||
figures.map((f) => f.label),
|
||||
cellW * 0.62,
|
||||
size * 0.26
|
||||
);
|
||||
const hair = `${mm(Math.max(0.18, h * 0.01))} solid ${inkAt(el.color, 0.18)}`;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderTop: `${mm(Math.max(0.6, h * 0.045))} solid ${el.accent}`,
|
||||
borderBottom: hair,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{figures.map((f, i) => (
|
||||
<div
|
||||
key={f.metric}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
gap: mm(h * 0.06),
|
||||
paddingInline: mm(cellW * 0.08),
|
||||
borderInlineStart: i ? hair : undefined,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<FigureText f={f} size={size} el={el} color={el.color} unitColor={el.accent} />
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: mm(caption * 0.5), minWidth: 0 }}>
|
||||
{el.showIcons && <IconDisc metric={f.metric} size={caption * 2} el={el} />}
|
||||
<Caption text={f.label} size={caption} el={el} />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A medal per figure: a ring in the accent round a tinted disc, the figure in
|
||||
* the middle with its icon above it, and its name running round the lower rim
|
||||
* between two dots, the way a medal or a seal is lettered.
|
||||
*/
|
||||
function MedalLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const gap = Math.min(w, h) * 0.06;
|
||||
const d = Math.min(h, (w - gap * (figures.length - 1)) / Math.max(1, figures.length));
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(gap),
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{figures.map((f) => (
|
||||
<Medal key={f.metric} f={f} d={d} el={el} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Medal({ f, d, el }: { f: Figure; d: number; el: BookStatsElement }) {
|
||||
const id = useId().replace(/:/g, '');
|
||||
const r = d / 2;
|
||||
const ring = d * 0.035;
|
||||
const size = fitType(chars(f), d * 0.56, d * 0.26) * el.textScale;
|
||||
const rim = r * 0.8;
|
||||
const label = f.label.toUpperCase();
|
||||
const type = Math.min(d * 0.075, (Math.PI * rim * 0.8) / Math.max(4, label.length * 0.78));
|
||||
const Icon = METRIC_ICONS[f.metric];
|
||||
return (
|
||||
<div style={{ position: 'relative', width: mm(d), height: mm(d), flex: '0 0 auto' }}>
|
||||
<svg
|
||||
width={mm(d)}
|
||||
height={mm(d)}
|
||||
viewBox={`0 0 ${d} ${d}`}
|
||||
style={{ position: 'absolute', inset: 0 }}
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<path id={`${id}-rim`} d={`M ${r - rim} ${r} A ${rim} ${rim} 0 0 0 ${r + rim} ${r}`} />
|
||||
</defs>
|
||||
<circle cx={r} cy={r} r={r - ring / 2} fill={inkAt(el.accent, 0.1)} stroke={el.accent} strokeWidth={ring} />
|
||||
<circle
|
||||
cx={r}
|
||||
cy={r}
|
||||
r={r * 0.68}
|
||||
fill="none"
|
||||
stroke={el.accent}
|
||||
strokeWidth={ring * 0.4}
|
||||
strokeDasharray={`${ring * 0.8} ${ring * 1.2}`}
|
||||
/>
|
||||
<text
|
||||
fill={el.accent}
|
||||
style={{
|
||||
fontFamily: fontStack(el.font),
|
||||
fontWeight: heavy(el),
|
||||
fontSize: `${type}px`,
|
||||
letterSpacing: `${type * 0.22}px`,
|
||||
}}
|
||||
>
|
||||
<textPath href={`#${id}-rim`} startOffset="50%" textAnchor="middle" dominantBaseline="middle">
|
||||
{label}
|
||||
</textPath>
|
||||
</text>
|
||||
<circle cx={r - rim} cy={r} r={ring * 0.7} fill={el.accent} />
|
||||
<circle cx={r + rim} cy={r} r={ring * 0.7} fill={el.accent} />
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(d * 0.02),
|
||||
paddingBottom: mm(d * 0.08),
|
||||
}}
|
||||
>
|
||||
{el.showIcons && (
|
||||
<Icon
|
||||
color={el.accent}
|
||||
strokeWidth={2}
|
||||
style={{ width: mm(d * 0.13), height: mm(d * 0.13), display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
<FigureText f={f} size={size} el={el} color={el.color} unitColor={el.accent} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LOOKS: Record<string, (props: LookProps) => ReactNode> = {
|
||||
poster: PosterLook,
|
||||
ticket: TicketLook,
|
||||
tiles: TilesLook,
|
||||
band: BandLook,
|
||||
medal: MedalLook,
|
||||
odometer: OdometerLook,
|
||||
sign: SignLook,
|
||||
note: NoteLook,
|
||||
sketch: SketchLook,
|
||||
};
|
||||
|
||||
export function StatsLookView({ el, frameStyle }: { el: BookStatsElement; frameStyle: CSSProperties }) {
|
||||
const { t, locale } = useTranslation();
|
||||
const figures: Figure[] = el.metrics.map((metric) => ({
|
||||
metric,
|
||||
...metricValue(metric, el.values, el.units, locale),
|
||||
label: t(`journey.studio.metric.${metric}`),
|
||||
}));
|
||||
const Look = LOOKS[el.look ?? 'poster'] ?? PosterLook;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...frameStyle,
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
fontFamily: fontStack(el.font),
|
||||
color: el.color,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<Look el={el} figures={figures} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
import { inkOn } from './badgeLook';
|
||||
import { hatch, roughBox, roughCircle, roughLine, seeded } from './sketch';
|
||||
import { fitAll, fitCaption, fitType, gridOf, type Figure } from './statsFit';
|
||||
import type { LookProps } from './StatsLooks';
|
||||
import { Caption, FigureText } from './statsParts';
|
||||
import { heavy, inkAt, light, METRIC_ICONS, mm } from './travelInk';
|
||||
|
||||
/**
|
||||
* The summary looks drawn after real things: the drums of an odometer, a road
|
||||
* sign, a note written on ruled paper, and a pencil sketch. Each is the same
|
||||
* figures as the other looks, in the element's ink and accent, sized from the
|
||||
* frame; StatsLooks.tsx picks the look.
|
||||
*/
|
||||
|
||||
/**
|
||||
* An odometer: each figure on its own row of drums in a dark housing, the
|
||||
* digits printed on pale drums shaded where they turn away, padded with the
|
||||
* zeros a counter shows, the last drum in the accent the way the tenths are.
|
||||
*/
|
||||
export function OdometerLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const { cols, rows } = gridOf(figures.length);
|
||||
const cellW = w / cols;
|
||||
const cellH = h / rows;
|
||||
const paper = inkOn(el.color);
|
||||
const drumsOf = (f: Figure) => {
|
||||
const digits = f.value.replace(/\D/g, '');
|
||||
return digits.padStart(Math.max(4, digits.length), '0').split('');
|
||||
};
|
||||
const most = Math.max(...figures.map((f) => drumsOf(f).length));
|
||||
const drumW = Math.min((cellW * 0.82) / (most + 0.6), cellH * 0.34);
|
||||
const drumH = drumW * 1.45;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: mm(Math.min(w, h) * 0.06),
|
||||
background: `linear-gradient(180deg, ${inkAt(el.color, 0.88)}, ${el.color})`,
|
||||
boxShadow: `inset 0 0 0 ${mm(Math.min(w, h) * 0.012)} ${inkAt(paper, 0.12)}`,
|
||||
padding: mm(Math.min(w, h) * 0.04),
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{figures.map((f) => {
|
||||
const drums = drumsOf(f);
|
||||
const lead = drums.length - f.value.replace(/\D/g, '').length;
|
||||
return (
|
||||
<div
|
||||
key={f.metric}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(drumH * 0.22),
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: mm(drumW * 0.08) }}>
|
||||
{drums.map((d, i) => {
|
||||
const last = i === drums.length - 1 && drums.length > 1;
|
||||
const face = last ? el.accent : paper;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
width: mm(drumW),
|
||||
height: mm(drumH),
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
borderRadius: mm(drumW * 0.12),
|
||||
background: `linear-gradient(180deg, ${inkAt(el.color, 0.45)} 0%, ${face} 26%, ${face} 74%, ${inkAt(el.color, 0.45)} 100%)`,
|
||||
color: last ? inkOn(el.accent) : el.color,
|
||||
opacity: i < lead ? 0.55 : 1,
|
||||
fontSize: mm(drumH * 0.66),
|
||||
fontWeight: heavy(el),
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{d}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{f.unit && (
|
||||
<span
|
||||
style={{
|
||||
marginInlineStart: mm(drumW * 0.25),
|
||||
color: paper,
|
||||
fontSize: mm(drumH * 0.4),
|
||||
fontWeight: light(el),
|
||||
}}
|
||||
>
|
||||
{f.unit}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Caption
|
||||
text={f.label}
|
||||
size={fitCaption([f.label], cellW * 0.8, drumH * 0.26)}
|
||||
el={el}
|
||||
color={paper}
|
||||
opacity={0.75}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A road sign: a panel in the accent with a rule inset round its edge, an
|
||||
* arrow in its corner, and the figures listed the way a sign lists places,
|
||||
* the name on the left and the figure on the right, between hairlines.
|
||||
*/
|
||||
export function SignLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const ink = inkOn(el.accent);
|
||||
const inset = Math.min(w, h) * 0.035;
|
||||
const rowH = (h - inset * 4) / Math.max(1, figures.length);
|
||||
const value = fitAll(figures, w * 0.36, rowH * 0.62) * el.textScale;
|
||||
const label = fitCaption(
|
||||
figures.map((f) => f.label),
|
||||
w * 0.42,
|
||||
value * 0.62
|
||||
);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: mm(inset * 1.6),
|
||||
background: el.accent,
|
||||
padding: mm(inset * 2),
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
boxShadow: `inset 0 0 0 ${mm(inset * 0.6)} ${el.accent}, inset 0 0 0 ${mm(inset * 0.9)} ${ink}`,
|
||||
color: ink,
|
||||
}}
|
||||
>
|
||||
<ArrowUpRight
|
||||
color={ink}
|
||||
strokeWidth={2.6}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: mm(inset * 1.6),
|
||||
insetInlineEnd: mm(inset * 1.6),
|
||||
width: mm(rowH * 0.5),
|
||||
height: mm(rowH * 0.5),
|
||||
opacity: 0.9,
|
||||
}}
|
||||
/>
|
||||
{figures.map((f, i) => (
|
||||
<div
|
||||
key={f.metric}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: mm(w * 0.04),
|
||||
paddingInlineEnd: mm(i === 0 ? rowH * 0.6 : 0),
|
||||
borderTop: i ? `${mm(Math.max(0.2, inset * 0.18))} solid ${inkAt(ink, 0.45)}` : undefined,
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: mm(label),
|
||||
fontWeight: heavy(el),
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
</span>
|
||||
<FigureText f={f} size={value} el={el} color={ink} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A note: a sheet of ruled paper with a margin in the accent, two strips of
|
||||
* tape holding it a degree or two off square, and the figures written on the
|
||||
* lines in the element's face, a script one when it comes from the panel.
|
||||
*/
|
||||
export function NoteLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const paper = inkOn(el.color);
|
||||
const line = h / (figures.length + 2);
|
||||
const size =
|
||||
fitAll(
|
||||
figures.map((f) => ({ ...f, value: `${f.value} ${f.label}` })),
|
||||
w * 0.74,
|
||||
line * 0.72
|
||||
) * el.textScale;
|
||||
const tapeW = w * 0.22;
|
||||
const rule = Math.max(0.15, h * 0.004);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transform: 'rotate(-1.6deg)',
|
||||
background: `repeating-linear-gradient(180deg, transparent 0 ${mm(line - rule)}, ${inkAt(el.accent, 0.28)} ${mm(line - rule)} ${mm(line)}), ${paper}`,
|
||||
backgroundPosition: `0 ${mm(line * 0.62)}`,
|
||||
boxShadow: `0 ${mm(h * 0.015)} ${mm(h * 0.05)} ${inkAt(el.color, 0.22)}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
paddingInlineStart: mm(w * 0.16),
|
||||
paddingTop: mm(line * 1.05),
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
insetInlineStart: mm(w * 0.11),
|
||||
width: mm(rule * 1.6),
|
||||
background: inkAt(el.accent, 0.55),
|
||||
}}
|
||||
/>
|
||||
{[-1, 1].map((side) => (
|
||||
<span
|
||||
key={side}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: mm(-line * 0.25),
|
||||
insetInlineStart: side < 0 ? mm(w * 0.04) : undefined,
|
||||
insetInlineEnd: side > 0 ? mm(w * 0.04) : undefined,
|
||||
width: mm(tapeW),
|
||||
height: mm(line * 0.62),
|
||||
background: inkAt(el.accent, 0.32),
|
||||
transform: `rotate(${side * 7}deg)`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{figures.map((f) => (
|
||||
<span
|
||||
key={f.metric}
|
||||
style={{
|
||||
height: mm(line),
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
gap: mm(size * 0.3),
|
||||
paddingBottom: mm(line * 0.12),
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: mm(size), fontWeight: heavy(el), color: el.accent, lineHeight: 1 }}>
|
||||
{f.value}
|
||||
{f.unit && ` ${f.unit}`}
|
||||
</span>
|
||||
<span style={{ fontSize: mm(size * 0.78), fontWeight: light(el), color: el.color, lineHeight: 1 }}>
|
||||
{f.label}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A pencil sketch: a frame drawn freehand twice over, each figure ringed by a
|
||||
* loop in the accent with hatching behind its icon, and its name underlined
|
||||
* with a quick stroke. Seeded from the element, so it draws the same each time.
|
||||
*/
|
||||
export function SketchLook({ el, figures }: LookProps) {
|
||||
const { w, h } = el.frame;
|
||||
const rand = seeded(`${el.id}:${figures.map((f) => f.value).join()}`);
|
||||
const { cols, rows } = gridOf(figures.length);
|
||||
const cellW = w / cols;
|
||||
const cellH = h / rows;
|
||||
const pen = Math.max(0.2, Math.min(w, h) * 0.008);
|
||||
const wob = Math.min(w, h) * 0.012;
|
||||
const ringR = Math.min(cellW * 0.3, cellH * 0.28);
|
||||
const size = fitAll(figures, ringR * 1.5, ringR * 0.6) * el.textScale;
|
||||
const frame = `${roughBox({ x: pen * 3, y: pen * 3, w: w - pen * 6, h: h - pen * 6 }, { rand, wobble: wob })} ${roughBox({ x: pen * 4, y: pen * 2, w: w - pen * 7, h: h - pen * 5 }, { rand, wobble: wob })}`;
|
||||
const marks = figures.map((f, i) => {
|
||||
const cx = (i % cols) * cellW + cellW / 2;
|
||||
const cy = Math.floor(i / cols) * cellH + cellH * 0.4;
|
||||
const r = ringR;
|
||||
const icon = size * 0.5;
|
||||
return {
|
||||
f,
|
||||
ring: roughCircle(cx, cy, r, { rand, wobble: wob * 0.9 }),
|
||||
shade: hatch({ x: cx - r * 0.95, y: cy - r * 1.05, w: icon * 1.2, h: icon * 1.2 }, icon * 0.22, {
|
||||
rand,
|
||||
wobble: wob * 0.3,
|
||||
}),
|
||||
under: roughLine(cx - r * 0.7, cy + r * 1.22, cx + r * 0.7, cy + r * 1.18, { rand, wobble: wob * 0.6 }),
|
||||
at: { x: cx, y: cy, r },
|
||||
};
|
||||
});
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<svg
|
||||
width={mm(w)}
|
||||
height={mm(h)}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
style={{ position: 'absolute', inset: 0, overflow: 'visible' }}
|
||||
aria-hidden
|
||||
>
|
||||
<path d={frame} fill="none" stroke={el.color} strokeWidth={pen} strokeLinecap="round" opacity={0.8} />
|
||||
{marks.map((m) => (
|
||||
<g key={m.f.metric}>
|
||||
<path
|
||||
d={m.shade}
|
||||
fill="none"
|
||||
stroke={el.accent}
|
||||
strokeWidth={pen * 0.7}
|
||||
strokeLinecap="round"
|
||||
opacity={0.5}
|
||||
/>
|
||||
<path
|
||||
d={m.ring}
|
||||
fill="none"
|
||||
stroke={el.accent}
|
||||
strokeWidth={pen * 1.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d={m.under} fill="none" stroke={el.color} strokeWidth={pen} strokeLinecap="round" opacity={0.7} />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
{marks.map(({ f, at }) => {
|
||||
const Icon = METRIC_ICONS[f.metric];
|
||||
const box = { position: 'absolute', insetInlineStart: mm(at.x - at.r * 1.4), width: mm(at.r * 2.8) } as const;
|
||||
return (
|
||||
<div key={f.metric}>
|
||||
<div
|
||||
style={{
|
||||
...box,
|
||||
top: mm(at.y - at.r * 0.8),
|
||||
height: mm(at.r * 1.6),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: mm(at.r * 0.04),
|
||||
}}
|
||||
>
|
||||
{el.showIcons && (
|
||||
<Icon
|
||||
color={el.color}
|
||||
strokeWidth={1.6}
|
||||
style={{ width: mm(size * 0.4), height: mm(size * 0.4), display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
<FigureText f={f} size={size} el={el} color={el.color} unitColor={el.accent} />
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
...box,
|
||||
top: mm(at.y + at.r * 1.3),
|
||||
textAlign: 'center',
|
||||
fontSize: mm(fitType(f.label.length, at.r * 2.4, size * 0.4)),
|
||||
fontWeight: light(el),
|
||||
color: el.color,
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { BookElement } from '@trek/shared';
|
||||
import { TravelPreview } from '../TravelPreview';
|
||||
|
||||
/**
|
||||
* One card of the Travel panel: the element its click places, drawn through
|
||||
* the page's own renderer with this journey's numbers in it, and its name
|
||||
* under it. These elements differ by what they say rather than by their
|
||||
* shape, so a named row could not tell two of them apart; the thing itself
|
||||
* can.
|
||||
*/
|
||||
export function TravelCard({
|
||||
el,
|
||||
label,
|
||||
onPlace,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
inset = 0,
|
||||
recommended = false,
|
||||
title,
|
||||
}: {
|
||||
el: BookElement;
|
||||
label: string;
|
||||
onPlace: (el: BookElement) => void;
|
||||
minHeight: number;
|
||||
maxHeight: number;
|
||||
/** Room kept round the element in its tile, for one that reaches past its own edges. */
|
||||
inset?: number;
|
||||
/**
|
||||
* Marked as the one to reach for, with a ring rather than with words: the
|
||||
* label has about eighty pixels, and the tooltip carries the word.
|
||||
*/
|
||||
recommended?: boolean;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`st-travel-card ${recommended ? 'is-recommended' : ''}`}
|
||||
onClick={() => onPlace(el)}
|
||||
title={title ? `${label}: ${title}` : label}
|
||||
>
|
||||
<TravelPreview el={el} minHeight={minHeight} maxHeight={maxHeight} inset={inset} />
|
||||
<span className="st-travel-card-label">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { JourneyStats } from '@trek/shared';
|
||||
import { Check, RouteOff } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Section } from '../StudioControls';
|
||||
|
||||
/**
|
||||
* The stops of the route, each switchable in or out of the figures.
|
||||
*
|
||||
* Beside the maps it changes, and not only in the entry editor: the moment
|
||||
* somebody notices the home airport on the printed route is the moment they
|
||||
* are looking at this panel. The rows carry no preview because they are not
|
||||
* things to place; they are the input every card reads.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One row: a point of the route or a stop switched off. `entryId` is null for
|
||||
* a stop the server took straight from a trip's places, which has no entry to
|
||||
* put a switch on.
|
||||
*/
|
||||
interface StopRow {
|
||||
entryId: number | null;
|
||||
label: string;
|
||||
date: string | null;
|
||||
country: string | null;
|
||||
excluded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every point and every left-out stop in one sequence, by date: the question
|
||||
* the list answers is "which of these count?", and that needs them together.
|
||||
* Undated stops go last, then by name.
|
||||
*/
|
||||
function stopRows(stats: JourneyStats): StopRow[] {
|
||||
return [
|
||||
...stats.points.map((p) => ({
|
||||
entryId: p.entryId,
|
||||
label: p.label,
|
||||
date: p.date,
|
||||
country: p.country,
|
||||
excluded: false,
|
||||
})),
|
||||
...(stats.excluded ?? []).map((x) => ({
|
||||
entryId: x.entryId,
|
||||
label: x.label,
|
||||
date: x.date,
|
||||
country: null,
|
||||
excluded: true,
|
||||
})),
|
||||
].sort((a, b) => {
|
||||
if (a.date !== b.date) {
|
||||
if (a.date == null) return 1;
|
||||
if (b.date == null) return -1;
|
||||
return a.date < b.date ? -1 : 1;
|
||||
}
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
}
|
||||
|
||||
export function TravelStops({
|
||||
stats,
|
||||
t,
|
||||
locale,
|
||||
canEdit,
|
||||
onToggleStop,
|
||||
countryName,
|
||||
}: {
|
||||
stats: JourneyStats;
|
||||
t: (k: string) => string;
|
||||
locale: string;
|
||||
canEdit: boolean;
|
||||
onToggleStop: (entryId: number, excluded: boolean) => Promise<boolean>;
|
||||
countryName: (code: string) => string;
|
||||
}) {
|
||||
/*
|
||||
* The stops whose switch is on its way to the server. No optimistic flip:
|
||||
* which stop is furthest and which countries remain is the server's sum, so
|
||||
* a row reads its state from the stats fetched once the change has landed,
|
||||
* and the chip is only held until then.
|
||||
*/
|
||||
const [pending, setPending] = useState<Set<number>>(() => new Set());
|
||||
const stops = stopRows(stats);
|
||||
if (!stops.length) return null;
|
||||
const excludedCount = stops.filter((s) => s.excluded).length;
|
||||
// The country only where there is more than one to tell apart.
|
||||
const manyCountries = stats.countries.length > 1;
|
||||
// The day without the weekday: a row has a place name to fit as well.
|
||||
const stopDay = (iso: string) =>
|
||||
new Date(`${iso}T00:00:00Z`).toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' });
|
||||
|
||||
const toggle = async (entryId: number, excluded: boolean) => {
|
||||
setPending((prev) => new Set(prev).add(entryId));
|
||||
// A refusal is the hook's to report; here it only frees the chip again.
|
||||
await onToggleStop(entryId, excluded);
|
||||
setPending((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(entryId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
label={t('journey.studio.stops')}
|
||||
hint={t('journey.studio.stopsHint')}
|
||||
// Folded, saying how many count: a fortnight is fourteen rows, and most books never touch it.
|
||||
defaultOpen={false}
|
||||
badge={`${stops.length - excludedCount}/${stops.length}`}
|
||||
>
|
||||
<ul className="st-route-stops">
|
||||
{stops.map((stop, i) => {
|
||||
const entryId = stop.entryId;
|
||||
const on = !stop.excluded;
|
||||
const state = t(on ? 'journey.studio.stopOn' : 'journey.studio.stopOff');
|
||||
const chip = `st-chip is-pick is-icon ${on ? 'is-on' : ''}`;
|
||||
// A mark rather than the word, so the column does not jitter; the word is in the title.
|
||||
const mark = on ? <Check size={13} strokeWidth={2.4} /> : <RouteOff size={13} strokeWidth={2.2} />;
|
||||
return (
|
||||
<li key={entryId ?? `place-${i}`} className={`st-route-stop ${on ? '' : 'is-off'}`}>
|
||||
{stop.date && <span className="st-badge">{stopDay(stop.date)}</span>}
|
||||
<span className="st-route-stop-label">{stop.label || t('journey.studio.untitled')}</span>
|
||||
{manyCountries && stop.country && <span className="st-badge is-quiet">{countryName(stop.country)}</span>}
|
||||
{canEdit && entryId != null ? (
|
||||
<button
|
||||
type="button"
|
||||
className={chip}
|
||||
title={`${state}: ${t('journey.studio.stopToggle')}`}
|
||||
aria-label={t('journey.studio.stopToggle')}
|
||||
aria-pressed={on}
|
||||
disabled={pending.has(entryId)}
|
||||
onClick={() => void toggle(entryId, !stop.excluded)}
|
||||
>
|
||||
{mark}
|
||||
</button>
|
||||
) : (
|
||||
<span className={chip} title={state} aria-label={state}>
|
||||
{mark}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { BookBadgeElement, BookBadgeStyle, BookBadgeVariant } from '@trek/shared';
|
||||
import { brightness } from '../folioColour';
|
||||
|
||||
/**
|
||||
* How a mark is coloured and which of its styles are the newer looks.
|
||||
*
|
||||
* Moved out of TravelElements.tsx so the looks drawn in BadgeLooks.tsx answer
|
||||
* the same question the same way: a filled mark takes its words' colour from
|
||||
* its fill, a plain one from the page.
|
||||
*/
|
||||
|
||||
/** The styles BadgeLooks.tsx draws; the first four are TravelElements.tsx's. */
|
||||
const LOOKS: ReadonlySet<BookBadgeStyle> = new Set(['stamp', 'tag', 'pill', 'ribbon', 'sticker', 'patch', 'sketch']);
|
||||
|
||||
export const isBadgeLook = (style: BookBadgeStyle | undefined): boolean => !!style && LOOKS.has(style);
|
||||
|
||||
/** Ink for a mark that picks its own, and its opposite. */
|
||||
const BADGE_DARK_INK = '#1c1b19'; // theme-lint-disable — ink on a printed page, not a UI token
|
||||
const BADGE_LIGHT_INK = '#ffffff'; // theme-lint-disable — paper white on a filled chip
|
||||
|
||||
/** The ink that reads on a fill: dark on a pale one, paper on a deep one. */
|
||||
export const inkOn = (fill: string): string => (brightness(fill) > 0.55 ? BADGE_DARK_INK : BADGE_LIGHT_INK);
|
||||
|
||||
/**
|
||||
* What colour a mark's words are.
|
||||
*
|
||||
* Automatic unless the element says otherwise, and what automatic means depends
|
||||
* on what the mark is: a chip carries its words on its own fill, so they answer
|
||||
* to the fill's brightness; a day or a date drawn plain takes the accent,
|
||||
* because that is the figure the page is built around. Anything else is
|
||||
* ordinary text in the text colour.
|
||||
*
|
||||
* The moment somebody picks a colour this steps aside completely. That is the
|
||||
* point of the flag: automatic is a good default and a bad cage.
|
||||
*/
|
||||
export function badgeInk(el: BookBadgeElement): string {
|
||||
if (!el.autoColor) return el.color;
|
||||
if (el.style === 'chip' || el.style === 'tag') return inkOn(el.accent);
|
||||
if (el.variant === 'day' || el.variant === 'date') return el.accent;
|
||||
return el.color;
|
||||
}
|
||||
|
||||
/** How tall the picture sits beside the words, as a share of the mark's height. */
|
||||
export function badgeIconShare(variant: BookBadgeVariant): number {
|
||||
if (variant === 'country') return 0.72;
|
||||
if (variant === 'flag') return 0.5;
|
||||
return 0.46;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Lines that look drawn by hand: a frame, a rule, a circle, hatching, each a
|
||||
* little off true the way a pen goes, for the sketch looks of the travel
|
||||
* elements.
|
||||
*
|
||||
* The wobble is seeded from what the element says, never from the clock or
|
||||
* Math.random, so a page draws the same every time it is opened and the same
|
||||
* in print as on screen. Every function returns an SVG path in the caller's
|
||||
* units (the element's millimetres).
|
||||
*/
|
||||
|
||||
/** A small fast generator seeded from a string: the same words always wobble the same way. */
|
||||
export function seeded(text: string): () => number {
|
||||
let h = 1779033703 ^ text.length;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
h = Math.imul(h ^ text.charCodeAt(i), 3432918353);
|
||||
h = (h << 13) | (h >>> 19);
|
||||
}
|
||||
let a = h >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const r2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
/** The hand that draws: where its wobble comes from, and how far off true a stroke may run. */
|
||||
export interface Pen {
|
||||
rand: () => number;
|
||||
wobble: number;
|
||||
}
|
||||
|
||||
/** A box in the caller's units. */
|
||||
export interface Box {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** A point nudged by up to `w` either way. */
|
||||
const nudge = (rand: () => number, w: number) => (rand() - 0.5) * 2 * w;
|
||||
|
||||
/**
|
||||
* A stroke from one point to another that bows a little and overshoots its
|
||||
* ends, the way a ruled line drawn freehand does.
|
||||
*/
|
||||
export function roughLine(x1: number, y1: number, x2: number, y2: number, { rand, wobble }: Pen): string {
|
||||
const len = Math.hypot(x2 - x1, y2 - y1) || 1;
|
||||
const ux = (x2 - x1) / len;
|
||||
const uy = (y2 - y1) / len;
|
||||
const over = wobble * 0.8;
|
||||
const sx = x1 - ux * over * rand() + nudge(rand, wobble * 0.4);
|
||||
const sy = y1 - uy * over * rand() + nudge(rand, wobble * 0.4);
|
||||
const ex = x2 + ux * over * rand() + nudge(rand, wobble * 0.4);
|
||||
const ey = y2 + uy * over * rand() + nudge(rand, wobble * 0.4);
|
||||
const mx = (sx + ex) / 2 - uy * nudge(rand, wobble) + nudge(rand, wobble * 0.3);
|
||||
const my = (sy + ey) / 2 + ux * nudge(rand, wobble) + nudge(rand, wobble * 0.3);
|
||||
return `M${r2(sx)} ${r2(sy)} Q${r2(mx)} ${r2(my)} ${r2(ex)} ${r2(ey)}`;
|
||||
}
|
||||
|
||||
/** A box drawn as four freehand strokes, each running a little past the corner. */
|
||||
export function roughBox({ x, y, w, h }: Box, pen: Pen): string {
|
||||
return [
|
||||
roughLine(x, y, x + w, y, pen),
|
||||
roughLine(x + w, y, x + w, y + h, pen),
|
||||
roughLine(x + w, y + h, x, y + h, pen),
|
||||
roughLine(x, y + h, x, y, pen),
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/** A loop that does not quite close, as a circle drawn in one stroke does. */
|
||||
export function roughCircle(cx: number, cy: number, r: number, { rand, wobble }: Pen): string {
|
||||
const steps = 18;
|
||||
const start = rand() * Math.PI * 2;
|
||||
const sweep = Math.PI * 2 * (1.04 + rand() * 0.06);
|
||||
const pts = Array.from({ length: steps + 1 }, (_, i) => {
|
||||
const a = start + (sweep * i) / steps;
|
||||
const rr = r + nudge(rand, wobble);
|
||||
return [cx + Math.cos(a) * rr, cy + Math.sin(a) * rr] as const;
|
||||
});
|
||||
const [first, ...rest] = pts;
|
||||
if (!first) return '';
|
||||
return `M${r2(first[0])} ${r2(first[1])} ${rest.map(([px, py]) => `L${r2(px)} ${r2(py)}`).join(' ')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagonal hatching across a box, each stroke freehand, for a fill drawn the
|
||||
* way a pencil shades rather than flat.
|
||||
*/
|
||||
export function hatch({ x, y, w, h }: Box, gap: number, pen: Pen): string {
|
||||
const lines: string[] = [];
|
||||
for (let d = gap; d < w + h; d += gap) {
|
||||
const x1 = x + Math.max(0, d - h);
|
||||
const y1 = y + Math.min(h, d);
|
||||
const x2 = x + Math.min(w, d);
|
||||
const y2 = y + Math.max(0, d - w);
|
||||
lines.push(roughLine(x1, y1, x2, y2, pen));
|
||||
}
|
||||
return lines.join(' ');
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { BookMetric } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* How the figures of a summary are measured: the size a line of them fits a
|
||||
* box at, and how a set divides into rows and columns. Shared by every look,
|
||||
* so a figure reads the same size whichever look it is set in.
|
||||
*/
|
||||
|
||||
/** One figure as it is printed. */
|
||||
export interface Figure {
|
||||
metric: BookMetric;
|
||||
value: string;
|
||||
unit: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The size a line of figures can be set at to fit a box: as tall as the box
|
||||
* allows and no wider than it. A figure is about six tenths of an em wide per
|
||||
* character in the faces Studio offers, which is what the estimate rests on.
|
||||
*/
|
||||
export function fitType(chars: number, boxW: number, boxH: number): number {
|
||||
return Math.max(1.2, Math.min(boxH, boxW / (0.6 * Math.max(2, chars))));
|
||||
}
|
||||
|
||||
export const chars = (f: Figure) => f.value.length + (f.unit ? f.unit.length * 0.45 + 0.3 : 0);
|
||||
|
||||
/** The size every figure of a set fits at, so a set reads as one size. */
|
||||
export const fitAll = (figures: Figure[], boxW: number, boxH: number) =>
|
||||
figures.reduce((size, f) => Math.min(size, fitType(chars(f), boxW, boxH)), boxH);
|
||||
|
||||
/** The size a caption fits at: bold small capitals with their tracking need almost an em a letter. */
|
||||
export const fitCaption = (labels: string[], boxW: number, cap: number) =>
|
||||
Math.max(1, Math.min(cap, boxW / (0.98 * Math.max(4, ...labels.map((l) => l.length)))));
|
||||
|
||||
/** The rows and columns a set of figures divides into: a row of three, a square of four, two rows of three. */
|
||||
export function gridOf(n: number): { cols: number; rows: number } {
|
||||
const cols = n <= 3 ? n : n === 4 ? 2 : n <= 6 ? 3 : 4;
|
||||
return { cols: Math.max(1, cols), rows: Math.max(1, Math.ceil(n / Math.max(1, cols))) };
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { BookMetric, BookStatsElement } from '@trek/shared';
|
||||
import { inkOn } from './badgeLook';
|
||||
import type { Figure } from './statsFit';
|
||||
import { heavy, inkAt, light, METRIC_ICONS, mm } from './travelInk';
|
||||
|
||||
/** The pieces every summary look is set from: a figure with its unit, its caption, its icon in a disc. */
|
||||
|
||||
/** The figure with its unit riding beside it at a fraction of its size, in the accent. */
|
||||
export function FigureText({
|
||||
f,
|
||||
size,
|
||||
el,
|
||||
color,
|
||||
unitColor,
|
||||
}: {
|
||||
f: Figure;
|
||||
size: number;
|
||||
el: BookStatsElement;
|
||||
color: string;
|
||||
unitColor?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: mm(size),
|
||||
fontWeight: heavy(el),
|
||||
lineHeight: 0.92,
|
||||
letterSpacing: '-0.04em',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
whiteSpace: 'nowrap',
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{f.value}
|
||||
{f.unit && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: mm(size * 0.38),
|
||||
fontWeight: light(el),
|
||||
letterSpacing: '0.02em',
|
||||
marginInlineStart: '0.14em',
|
||||
color: unitColor ?? color,
|
||||
}}
|
||||
>
|
||||
{f.unit}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The caption, in tracked small capitals. */
|
||||
export function Caption({
|
||||
text,
|
||||
size,
|
||||
el,
|
||||
color,
|
||||
opacity = 0.62,
|
||||
}: {
|
||||
text: string;
|
||||
size: number;
|
||||
el: BookStatsElement;
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: mm(size),
|
||||
fontWeight: heavy(el),
|
||||
letterSpacing: '0.18em',
|
||||
textTransform: 'uppercase',
|
||||
lineHeight: 1.2,
|
||||
opacity,
|
||||
color,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A figure's icon in a disc: tinted in the accent, or solid with the icon cut out of it. */
|
||||
export function IconDisc({
|
||||
metric,
|
||||
size,
|
||||
el,
|
||||
solid = false,
|
||||
}: {
|
||||
metric: BookMetric;
|
||||
size: number;
|
||||
el: BookStatsElement;
|
||||
solid?: boolean;
|
||||
}) {
|
||||
const Icon = METRIC_ICONS[metric];
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
width: mm(size),
|
||||
height: mm(size),
|
||||
flex: '0 0 auto',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
borderRadius: '50%',
|
||||
background: solid ? el.accent : inkAt(el.accent, 0.14),
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
color={solid ? inkOn(el.accent) : el.accent}
|
||||
strokeWidth={2}
|
||||
style={{ width: mm(size * 0.52), height: mm(size * 0.52), display: 'block' }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The disc on a tile: on the solid tile it is the paper's colour faintly, elsewhere the accent's. */
|
||||
export function IconDiscOn({
|
||||
metric,
|
||||
size,
|
||||
el,
|
||||
solid,
|
||||
}: {
|
||||
metric: BookMetric;
|
||||
size: number;
|
||||
el: BookStatsElement;
|
||||
solid: boolean;
|
||||
}) {
|
||||
if (!solid) return <IconDisc metric={metric} size={size} el={el} />;
|
||||
const Icon = METRIC_ICONS[metric];
|
||||
const ink = inkOn(el.accent);
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
width: mm(size),
|
||||
height: mm(size),
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
borderRadius: '50%',
|
||||
background: inkAt(ink, 0.18),
|
||||
}}
|
||||
>
|
||||
<Icon color={ink} strokeWidth={2} style={{ width: mm(size * 0.52), height: mm(size * 0.52), display: 'block' }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import type {
|
||||
BookBadgeStyle,
|
||||
BookBadgeVariant,
|
||||
BookColorToken,
|
||||
BookCountryLook,
|
||||
BookElement,
|
||||
BookMapElement,
|
||||
BookMapScope,
|
||||
BookMetric,
|
||||
BookPageSetup,
|
||||
BookStatsLook,
|
||||
BookTheme,
|
||||
JourneyStats,
|
||||
} from '@trek/shared';
|
||||
import {
|
||||
bookBadgeValue,
|
||||
bookCountryName,
|
||||
bookTravelBadge,
|
||||
bookTravelCountries,
|
||||
bookTravelMap,
|
||||
bookTravelSize,
|
||||
bookTravelStats,
|
||||
themedElement,
|
||||
bookElementId as uid,
|
||||
} from '@trek/shared';
|
||||
import { studioTravelContext } from '../studioTravelContext';
|
||||
|
||||
/**
|
||||
* What the Travel panel offers, built: every card is the element its click
|
||||
* places, made by the book engine's own factories (book-travel.ts) so a card
|
||||
* and what the assistant places are one thing, then set in one of the newer
|
||||
* looks and dressed in the book's theme.
|
||||
*
|
||||
* Pure, so the panel stays a list of shelves: it asks for an element and
|
||||
* renders it through the page's own renderer, and the preview and the result
|
||||
* are the same object.
|
||||
*/
|
||||
|
||||
/** The figures a summary offers: how far, how long, where, and how much was kept. */
|
||||
const SUMMARY_METRICS: BookMetric[] = ['distance', 'days', 'countries', 'photos'];
|
||||
|
||||
/** The looks of a summary, in the order the panel offers them. */
|
||||
export const SUMMARY_LOOKS: BookStatsLook[] = [
|
||||
'poster',
|
||||
'ticket',
|
||||
'tiles',
|
||||
'sign',
|
||||
'odometer',
|
||||
'note',
|
||||
'sketch',
|
||||
'band',
|
||||
];
|
||||
|
||||
/** The marks the journey fills, in the order a page is read: when, which day, how far, where. */
|
||||
export const MARK_VARIANTS = ['date', 'day', 'distance', 'coords', 'flag'] as const;
|
||||
export type MarkVariant = (typeof MARK_VARIANTS)[number];
|
||||
|
||||
/** The newer styles a mark comes in, and which of them each mark is offered in. */
|
||||
export const MARK_STYLES: { style: BookBadgeStyle; variants: readonly MarkVariant[] }[] = [
|
||||
{ style: 'stamp', variants: ['date', 'day', 'flag'] },
|
||||
{ style: 'tag', variants: ['day', 'distance', 'coords', 'flag'] },
|
||||
{ style: 'pill', variants: ['date', 'day', 'distance', 'coords', 'flag'] },
|
||||
{ style: 'ribbon', variants: ['date', 'day', 'distance', 'flag'] },
|
||||
{ style: 'sticker', variants: ['date', 'day', 'distance', 'flag'] },
|
||||
{ style: 'patch', variants: ['day', 'distance', 'flag'] },
|
||||
{ style: 'sketch', variants: ['date', 'day', 'distance', 'coords', 'flag'] },
|
||||
];
|
||||
|
||||
/** The single figures, one shelf per look, with the figures each look suits. */
|
||||
export const FIGURE_SHELVES: { look: BookStatsLook; metrics: readonly BookMetric[] }[] = [
|
||||
{ look: 'medal', metrics: ['distance', 'days', 'steps', 'photos', 'countries', 'places', 'furthest'] },
|
||||
{ look: 'odometer', metrics: ['distance', 'furthest', 'days', 'photos'] },
|
||||
{ look: 'sketch', metrics: ['distance', 'days', 'countries', 'photos'] },
|
||||
];
|
||||
|
||||
/** The looks a country is offered in. */
|
||||
export const COUNTRY_LOOKS: BookCountryLook[] = ['stamp', 'visa', 'postcard', 'flag', 'pin', 'label', 'sketch'];
|
||||
|
||||
/** The looks written by hand take the theme's script face rather than its heading. */
|
||||
const HANDWRITTEN = new Set<string>(['note', 'sketch']);
|
||||
|
||||
/** Which of the theme's accents each country look takes, so a shelf of them is not one colour. */
|
||||
const COUNTRY_ACCENT: Partial<Record<BookCountryLook, BookColorToken>> = {
|
||||
stamp: 'accent2',
|
||||
visa: 'accent3',
|
||||
pin: 'accent',
|
||||
label: 'accent2',
|
||||
};
|
||||
|
||||
/** Which of the theme's accents each mark takes, so a page of them is not one colour. */
|
||||
const MARK_ACCENT: Record<MarkVariant, BookColorToken> = {
|
||||
date: 'accent',
|
||||
day: 'accent2',
|
||||
distance: 'accent3',
|
||||
coords: 'accent',
|
||||
flag: 'accent2',
|
||||
};
|
||||
|
||||
/** The map cards: the photographed route, its stops numbered, its two ends marked, and the dark ground. */
|
||||
export const MAP_CARDS: {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
style: BookMapElement['style'];
|
||||
pins?: BookMapElement['pinStyle'];
|
||||
}[] = [
|
||||
{ id: 'photos', labelKey: 'journey.studio.pinPhoto', style: 'minimal' },
|
||||
{ id: 'numbered', labelKey: 'journey.studio.pinNumbered', style: 'minimal', pins: 'numbered' },
|
||||
{ id: 'ends', labelKey: 'journey.studio.pinEnds', style: 'minimal', pins: 'ends' },
|
||||
{ id: 'dark', labelKey: 'journey.studio.mapStyle.dark', style: 'dark', pins: 'numbered' },
|
||||
];
|
||||
|
||||
interface BuilderInput {
|
||||
page: BookPageSetup;
|
||||
stats: JourneyStats;
|
||||
/** The travelled way, already thinned. */
|
||||
path: [number, number][][];
|
||||
locale: string;
|
||||
t: (k: string) => string;
|
||||
theme: BookTheme | null | undefined;
|
||||
/** The spread on screen is one page rather than two, so a new element centres on it. */
|
||||
single: boolean;
|
||||
}
|
||||
|
||||
export function travelBuilders({ page, stats, path, locale, t, theme, single }: BuilderInput) {
|
||||
const travel = studioTravelContext(stats, path, locale, t);
|
||||
|
||||
/** Centred across the spread on screen, where the engine's `add` takes the right page. */
|
||||
const at = (size: { w: number; h: number }) => {
|
||||
const W = single ? page.pageWidth : page.pageWidth * 2;
|
||||
return { x: (W - size.w) / 2, y: (page.pageHeight - size.h) / 2, w: size.w, h: size.h };
|
||||
};
|
||||
|
||||
/**
|
||||
* An element dressed in the book's theme: its words in the theme's ink and
|
||||
* heading face, its colour one of the theme's accents, all three linked so
|
||||
* it follows when the theme changes. A book without a theme keeps the
|
||||
* engine's own colours.
|
||||
*/
|
||||
const dress = (el: BookElement, accent: BookColorToken = 'accent', hand = false): BookElement =>
|
||||
theme
|
||||
? themedElement(
|
||||
{ ...el, token: 'ink', accentToken: accent, fontRole: hand ? 'script' : 'heading' } as BookElement,
|
||||
theme
|
||||
)
|
||||
: el;
|
||||
|
||||
/** A summary in one of the newer looks; a band is a strip, so it is set a third as tall. */
|
||||
const summaryEl = (look: BookStatsLook): BookElement => {
|
||||
const w = page.pageWidth * (look === 'note' || look === 'sign' ? 0.56 : 0.72);
|
||||
const h =
|
||||
look === 'band' ? page.pageHeight * 0.13 : look === 'ticket' ? w * 0.42 : look === 'note' ? w * 0.72 : w * 0.5;
|
||||
const el = { ...bookTravelStats(stats, { id: uid('st'), frame: at({ w, h }), metrics: SUMMARY_METRICS }), look };
|
||||
return dress(el, look === 'sign' ? 'accent3' : 'accent', HANDWRITTEN.has(look));
|
||||
};
|
||||
|
||||
/**
|
||||
* One figure on its own, the mark to drop beside a photograph: as a medal, on
|
||||
* the drums of an odometer, or ringed by hand.
|
||||
*/
|
||||
const figureEl = (metric: BookMetric, look: BookStatsLook = 'medal'): BookElement => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.3;
|
||||
const size = look === 'odometer' ? { w: side * 1.6, h: side * 0.62 } : { w: side, h: side };
|
||||
const el = { ...bookTravelStats(stats, { id: uid('st'), frame: at(size), metrics: [metric] }), look };
|
||||
return dress(el, 'accent', HANDWRITTEN.has(look));
|
||||
};
|
||||
|
||||
/**
|
||||
* A map as the engine places it, on the card's ground and with the card's
|
||||
* stops. The scope is the journey or one trip; the ground is the outlines
|
||||
* or one of the two imagery sources.
|
||||
*/
|
||||
const mapEl = (
|
||||
card: { style: BookMapElement['style']; pins?: BookMapElement['pinStyle'] },
|
||||
scope: BookMapScope = 'journey',
|
||||
source: 'vector' | 'relief' | 'satellite' = 'vector'
|
||||
): BookElement => {
|
||||
const el = bookTravelMap(travel, stats, { id: uid('mp'), frame: at(bookTravelSize('map', page)), scope, source });
|
||||
const shaped = { ...el, style: card.style, ...(card.pins ? { pinStyle: card.pins } : {}) };
|
||||
// Over imagery the line is white on purpose (book-travel.ts); only the outline maps take the theme.
|
||||
return source === 'vector' ? dress(shaped) : shaped;
|
||||
};
|
||||
|
||||
/**
|
||||
* A map of one country: its stops alone, without the way there. A journey's
|
||||
* recorded way often starts with the flight from home, and fitted to all of
|
||||
* it the country is a speck at the edge of a continent; the page a travel
|
||||
* book wants is the country, cut to its own shape on the paper or over the
|
||||
* satellite picture, or the country in a box.
|
||||
*/
|
||||
const countryMapEl = (code: string, kind: 'shape' | 'satellite' | 'box'): BookElement => {
|
||||
const own = {
|
||||
...stats,
|
||||
points: stats.points.filter((p) => p.country === code),
|
||||
countries: stats.countries.filter((c) => c.code === code),
|
||||
};
|
||||
const ctx = studioTravelContext(own, [], locale, t);
|
||||
const source = kind === 'satellite' ? 'satellite' : 'vector';
|
||||
const el = bookTravelMap(ctx, own, {
|
||||
id: uid('mp'),
|
||||
frame: at(bookTravelSize('map', page)),
|
||||
scope: 'journey',
|
||||
source,
|
||||
});
|
||||
// Cut to its shape on the paper, the land is dark, so the silhouette reads and the route in the accent stands on it.
|
||||
const style = kind === 'shape' ? 'dark' : 'minimal';
|
||||
const shaped = {
|
||||
...el,
|
||||
style,
|
||||
clip: kind === 'box' ? 'rect' : 'country',
|
||||
pinStyle: 'numbered',
|
||||
fitToCountries: true,
|
||||
} as const;
|
||||
return source === 'vector' ? dress(shaped) : shaped;
|
||||
};
|
||||
|
||||
/** A mark in one of the newer styles, at the size its variant suits; a stamp is set taller for its two rules. */
|
||||
const markEl = (variant: MarkVariant, style: BookBadgeStyle): BookElement | null => {
|
||||
const value = bookBadgeValue(travel, variant, null);
|
||||
if (!value) return null;
|
||||
const frame = at(markSize(style, value.text, value.sub));
|
||||
const el = { ...bookTravelBadge({ id: uid('bd'), frame, variant: variant as BookBadgeVariant, value }), style };
|
||||
return dress(el, MARK_ACCENT[variant], style === 'sketch');
|
||||
};
|
||||
|
||||
/**
|
||||
* How large a mark is set: as wide as its words need. A stamp stacks them in
|
||||
* a box with room for its two rules; a tag or a pill sets them in a line
|
||||
* after its eyelet or its disc, so its length follows the words.
|
||||
*/
|
||||
const markSize = (style: BookBadgeStyle, text: string, sub: string) => {
|
||||
const twoLines = !!text && !!sub && text.length > 14;
|
||||
const h = page.pageHeight * (style === 'stamp' ? 0.12 : twoLines ? 0.1 : 0.066);
|
||||
const main = text || sub;
|
||||
if (style === 'stamp') return { w: Math.max(h * 1.3, h * 0.2 * Math.max(main.length * 1.4, sub.length)), h };
|
||||
// Two lines are set smaller than one, so they need less width a letter.
|
||||
if (twoLines)
|
||||
return { w: Math.min(page.pageWidth * 0.6, h * (1.5 + 0.13 * Math.max(text.length, sub.length * 0.7))), h };
|
||||
const chars = main.length * (text ? 1 : 0.85) + (text ? sub.length * 0.5 : 0);
|
||||
return { w: Math.min(page.pageWidth * 0.6, h * (1.6 + 0.3 * chars)), h };
|
||||
};
|
||||
|
||||
/** One country as a square stamp, or as a card in the proportions of a postcard. */
|
||||
const countryEl = (code: string, look: BookCountryLook): BookElement => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.36;
|
||||
const ratio = look === 'stamp' || look === 'pin' || look === 'sketch' ? 1 : look === 'postcard' ? 1.36 : 1.4;
|
||||
const frame = at({ w: side * ratio, h: side });
|
||||
const el = {
|
||||
...bookTravelCountries(travel, { id: uid('co'), frame, codes: [code] }),
|
||||
look,
|
||||
showFlag: look === 'pin' || look === 'postcard',
|
||||
};
|
||||
return dress(el, COUNTRY_ACCENT[look] ?? 'accent', look === 'sketch');
|
||||
};
|
||||
|
||||
/** A country's name in the reader's language, from the same CLDR data Atlas uses. */
|
||||
const countryName = (code: string): string => bookCountryName(code, locale, stats);
|
||||
|
||||
return { summaryEl, figureEl, mapEl, countryMapEl, markEl, countryEl, countryName };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { BookMetric, BookUnits } from '@trek/shared';
|
||||
import { CalendarDays, Camera, Footprints, Globe, MapPin, Navigation, Route, type LucideIcon } from 'lucide-react';
|
||||
import { lighterWeight, nearestWeight } from '../bookFonts';
|
||||
|
||||
/**
|
||||
* What every drawing of the journey's elements shares: millimetres rounded the
|
||||
* way the document stores them, an ink at a strength, the two weights a figure
|
||||
* and its caption are set in, and how a figure is written.
|
||||
*
|
||||
* TravelElements.tsx draws the looks every book has had since the start, and
|
||||
* the files beside this one draw the newer ones. Both take these from here, so
|
||||
* a figure reads the same whichever look it is set in.
|
||||
*/
|
||||
|
||||
/** Two decimals of a millimetre, matching what the document stores. */
|
||||
export const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
/** A length for a style, in millimetres. */
|
||||
export const mm = (n: number) => `${round2(n)}mm`;
|
||||
|
||||
/** #rrggbb at a strength, for the hairlines, tints and quiet labels of a drawing. */
|
||||
export function inkAt(hex: string, alpha: number): string {
|
||||
const n = Number.parseInt(hex.slice(1), 16);
|
||||
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* How heavy the type is set, and one step down for the labels under it. These
|
||||
* elements draw at two levels at once, a figure over its caption, and the pair
|
||||
* moves together the way `textScale` moves their size.
|
||||
*/
|
||||
export const heavy = (el: { font: string; weight: number }) => nearestWeight(el.font, el.weight);
|
||||
export const light = (el: { font: string; weight: number }) => lighterWeight(el.font, el.weight);
|
||||
|
||||
/**
|
||||
* The icon beside each figure, from lucide like every other icon in TREK. The
|
||||
* stroke is the drawing's own, so it scales with the figure on a large panel.
|
||||
*/
|
||||
export const METRIC_ICONS: Record<BookMetric, LucideIcon> = {
|
||||
distance: Route,
|
||||
days: CalendarDays,
|
||||
steps: Footprints,
|
||||
photos: Camera,
|
||||
countries: Globe,
|
||||
places: MapPin,
|
||||
furthest: Navigation,
|
||||
};
|
||||
|
||||
/**
|
||||
* A figure as it is printed: the number in the reader's locale and the unit
|
||||
* that rides with it. Distances are stored in metres and converted here, so
|
||||
* switching units never rounds a second time on top of a rounded number.
|
||||
*/
|
||||
export function metricValue(
|
||||
metric: BookMetric,
|
||||
values: Record<string, number>,
|
||||
units: BookUnits,
|
||||
locale: string
|
||||
): { value: string; unit: string } {
|
||||
const raw = values[metric] ?? 0;
|
||||
const n = (v: number, digits = 0) => new Intl.NumberFormat(locale, { maximumFractionDigits: digits }).format(v);
|
||||
|
||||
if (metric === 'distance' || metric === 'furthest') {
|
||||
const km = raw / 1000;
|
||||
const shown = units === 'imperial' ? km * 0.621371 : km;
|
||||
return { value: n(shown, shown < 100 ? 1 : 0), unit: units === 'imperial' ? 'mi' : 'km' };
|
||||
}
|
||||
return { value: n(raw), unit: '' };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { CheckCheck, Trash2 } from 'lucide-react'
|
||||
import { Bell, CheckCheck, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from '../i18n'
|
||||
import PageShell from '../components/Layout/PageShell'
|
||||
import HelpAnchor from '../components/Help/HelpAnchor'
|
||||
@@ -28,18 +28,21 @@ function InAppNotificationsPageDesktop(): React.ReactElement {
|
||||
<PageShell background="var(--bg-primary)">
|
||||
<HelpAnchor id="notifications" />
|
||||
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-content">
|
||||
{/* Header: a tile, the name with how many are unread, the count under it, the bulk tools at the end. */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<span className="grid place-items-center w-12 h-12 rounded-2xl bg-accent-subtle text-accent flex-shrink-0">
|
||||
<Bell className="w-5 h-5" />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="flex items-center gap-2 text-title font-semibold text-content">
|
||||
{t('notifications.title')}
|
||||
{unreadCount > 0 && (
|
||||
<span className="ms-2 px-2 py-0.5 rounded-full text-xs font-medium align-middle inline-flex items-center justify-center bg-content text-surface">
|
||||
<span className="px-2 h-6 inline-flex items-center rounded-full text-caption font-semibold tabular-nums bg-accent text-accent-text">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-sm mt-0.5 text-content-muted">
|
||||
<p className="text-body mt-0.5 text-content-muted">
|
||||
{total} {t('notifications.countLabel', { count: total })}
|
||||
</p>
|
||||
</div>
|
||||
@@ -49,7 +52,7 @@ function InAppNotificationsPageDesktop(): React.ReactElement {
|
||||
{unreadCount > 0 && (
|
||||
<button type="button"
|
||||
onClick={markAllRead}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors text-content-secondary bg-surface-hover hover:bg-surface-tertiary focus-visible:bg-surface-tertiary"
|
||||
className="flex items-center gap-1.5 h-9 px-3 rounded-xl text-body font-medium transition-colors text-content bg-surface-card border border-edge hover:bg-surface-hover focus-visible:bg-surface-hover"
|
||||
>
|
||||
<CheckCheck className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('notifications.markAllRead')}</span>
|
||||
@@ -58,7 +61,7 @@ function InAppNotificationsPageDesktop(): React.ReactElement {
|
||||
{notifications.length > 0 && (
|
||||
<button type="button"
|
||||
onClick={requestDeleteAll}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors text-danger hover:bg-danger-soft focus-visible:bg-danger-soft"
|
||||
className="flex items-center gap-1.5 h-9 px-3 rounded-xl text-body font-medium transition-colors text-danger bg-surface-card border border-edge hover:bg-danger-soft focus-visible:bg-danger-soft"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('notifications.deleteAll')}</span>
|
||||
@@ -67,24 +70,26 @@ function InAppNotificationsPageDesktop(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{/* Filter: one segmented control rather than two loose buttons. */}
|
||||
<div className="inline-flex gap-1 p-1 mb-4 rounded-xl bg-surface-secondary" role="group" aria-label={t('notifications.title')}>
|
||||
<button type="button"
|
||||
onClick={() => setUnreadOnly(false)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${!unreadOnly ? 'bg-content text-surface' : 'bg-surface-hover text-content-secondary'}`}
|
||||
aria-pressed={!unreadOnly}
|
||||
className={`h-8 px-3.5 rounded-lg text-body font-medium transition-colors ${!unreadOnly ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'}`}
|
||||
>
|
||||
{t('notifications.all')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => setUnreadOnly(true)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${unreadOnly ? 'bg-content text-surface' : 'bg-surface-hover text-content-secondary'}`}
|
||||
aria-pressed={unreadOnly}
|
||||
className={`h-8 px-3.5 rounded-lg text-body font-medium transition-colors ${unreadOnly ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'}`}
|
||||
>
|
||||
{t('notifications.unreadOnly')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Notification list */}
|
||||
<div className="rounded-xl border overflow-hidden border-edge bg-surface-card">
|
||||
<div className="rounded-2xl border overflow-hidden border-edge bg-surface-card py-1">
|
||||
{isLoading && displayed.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Spinner className="w-6 h-6 border-2 border-slate-200 border-t-current" />
|
||||
|
||||
@@ -2,16 +2,10 @@
|
||||
Studio in the planner's window (assistantFrame.tsx), so the page and the
|
||||
inspector keep all their room. */
|
||||
|
||||
/* The toggle is the bar's newest tool, so it gives up its label first: from 1360
|
||||
to 1519 it is an icon while the others keep theirs, and the head band fits
|
||||
where it did before. */
|
||||
@media (max-width: 1519px) {
|
||||
.trek-dash .st-assistant-toggle .st-tool-label {
|
||||
display: none;
|
||||
}
|
||||
.trek-dash .st-assistant-toggle {
|
||||
padding: 0 8px;
|
||||
}
|
||||
/* The toggle is an icon tile of the head row; open, it reads as pressed. */
|
||||
.trek-dash .st-tool.st-head-solo.st-assistant-toggle.is-on {
|
||||
background: color-mix(in oklch, var(--accent) 14%, var(--surface));
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.trek-dash .st-assistant-notice {
|
||||
|
||||
@@ -71,6 +71,19 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* In the closed picker the face's name comes first: the kind gives way, cut
|
||||
short and then gone, before a letter of the name is lost. */
|
||||
.trek-dash .st-fontpick-current .st-fontpick-face {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.trek-dash .st-fontpick-current .st-fontpick-span {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.trek-dash .st-fontpick-caret {
|
||||
flex: none;
|
||||
color: var(--ink-3);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* ----------------- head band (StudioHeader.tsx) ----------------- *
|
||||
* The shape every TREK dialog head has: a tinted band, a raised tile, a small
|
||||
* line above the title and a round close button at the end. The band keeps the
|
||||
* line above the title and the close button at the end. The band keeps the
|
||||
* glass of the old bar under its tint, so the journey still shows through.
|
||||
*/
|
||||
.trek-dash .st-head {
|
||||
@@ -49,13 +49,13 @@
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* The wordmark and the beta mark: what this screen is, quietly, above its name. */
|
||||
/* The wordmark: what this screen is, above its name, in the title's own ink. */
|
||||
.trek-dash .st-head-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 17px;
|
||||
color: var(--ink-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
.trek-dash .st-head-eyebrow svg {
|
||||
flex: none;
|
||||
@@ -73,55 +73,6 @@
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.trek-dash .st-head-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The format, as a chip that opens the format menu. */
|
||||
.trek-dash .st-head-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 22px;
|
||||
padding-inline: 7px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-xs);
|
||||
color: var(--ink-2);
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
color 0.16s,
|
||||
box-shadow 0.16s;
|
||||
}
|
||||
.trek-dash .st-head-chip:hover {
|
||||
color: var(--ink);
|
||||
box-shadow: var(--sh-sm);
|
||||
}
|
||||
.trek-dash .st-head-chip:focus {
|
||||
outline: none;
|
||||
}
|
||||
.trek-dash .st-head-chip:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--accent-soft),
|
||||
0 0 0 3px var(--accent);
|
||||
}
|
||||
.trek-dash .st-head-chip-shape {
|
||||
flex: none;
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 2px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
/* The chip sits on the left, so its menu opens towards the page, not off the window. */
|
||||
.trek-dash .st-head-menu {
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: auto;
|
||||
}
|
||||
|
||||
.trek-dash .st-head-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -130,32 +81,175 @@
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
/* Every piece of the row is one raised tile of one height and one corner:
|
||||
the groups, who is here, help, export and close. */
|
||||
.trek-dash .st-head {
|
||||
--st-head-h: 36px;
|
||||
}
|
||||
|
||||
/* Tools that belong together share one raised group, like a segmented control. */
|
||||
.trek-dash .st-head-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: var(--st-head-h);
|
||||
padding: 3px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-xs);
|
||||
}
|
||||
|
||||
/* A tool standing alone is a group of one: help, and export in the accent. */
|
||||
.trek-dash .st-tool.st-head-solo {
|
||||
height: var(--st-head-h);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.trek-dash .st-tool.is-icon.st-head-solo {
|
||||
width: var(--st-head-h);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-xs);
|
||||
}
|
||||
.trek-dash .st-tool.is-icon.st-head-solo:hover:not(:disabled) {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-sm);
|
||||
}
|
||||
.trek-dash .st-tool.is-primary.st-head-solo {
|
||||
padding-inline: 16px;
|
||||
box-shadow: var(--sh-xs);
|
||||
}
|
||||
|
||||
/* Whether the book is saved, as a tile beside who is here; a conflict keeps its warning tint. */
|
||||
.trek-dash .st-head .st-save {
|
||||
height: var(--st-head-h);
|
||||
padding-inline: 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-xs);
|
||||
color: var(--ink-2);
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.trek-dash .st-head .st-save.is-conflict {
|
||||
background: color-mix(in oklch, var(--warning) 14%, var(--surface));
|
||||
border: 0;
|
||||
}
|
||||
.trek-dash .st-head .st-save.is-error {
|
||||
background: color-mix(in oklch, var(--danger) 10%, var(--surface));
|
||||
}
|
||||
|
||||
/* A book a newer Studio saved: the whole Studio under a veil, one card, one way out. */
|
||||
.trek-dash .st-newer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: color-mix(in oklch, var(--surface) 55%, transparent);
|
||||
backdrop-filter: blur(10px) saturate(1.1);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(1.1);
|
||||
animation: st-newer-in 0.22s ease-out both;
|
||||
}
|
||||
.trek-dash .st-newer-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
width: min(380px, 100%);
|
||||
padding: 28px 28px 24px;
|
||||
border-radius: 20px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-lg);
|
||||
text-align: center;
|
||||
}
|
||||
.trek-dash .st-newer-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.trek-dash .st-newer-text {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.trek-dash .st-newer-reload {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
margin-top: 4px;
|
||||
border-radius: 12px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
transition: filter 0.16s;
|
||||
}
|
||||
.trek-dash .st-newer-reload:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.trek-dash .st-newer-reload:focus-visible {
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 0 0 2px var(--surface),
|
||||
0 0 0 4px var(--accent);
|
||||
}
|
||||
@keyframes st-newer-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trek-dash .st-newer {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Who is here, as a tile like the tools beside it. */
|
||||
.trek-dash .st-head .st-peers {
|
||||
height: var(--st-head-h);
|
||||
padding-inline: 8px 12px;
|
||||
margin-inline-start: 0;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-xs);
|
||||
}
|
||||
.trek-dash .st-head .st-peer-dot {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-color: var(--surface);
|
||||
}
|
||||
.trek-dash .st-head .st-peer-label {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.trek-dash .st-head-close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: var(--st-head-h);
|
||||
height: var(--st-head-h);
|
||||
flex: none;
|
||||
margin-inline-start: 2px;
|
||||
border-radius: 999px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-sm);
|
||||
box-shadow: var(--sh-xs);
|
||||
color: var(--ink-3);
|
||||
transition: color 0.16s;
|
||||
transition:
|
||||
color 0.16s,
|
||||
box-shadow 0.16s;
|
||||
}
|
||||
.trek-dash .st-head-close:hover {
|
||||
color: var(--ink);
|
||||
box-shadow: var(--sh-sm);
|
||||
}
|
||||
.trek-dash .st-head-close:focus {
|
||||
outline: none;
|
||||
@@ -166,6 +260,14 @@
|
||||
0 0 0 3px var(--accent);
|
||||
}
|
||||
|
||||
/* The format's shape, drawn in the tool's own ink. */
|
||||
.trek-dash .st-head-chip-shape {
|
||||
flex: none;
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 2px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* The book view moved to the zoom, where the page's view lives; on reads as pressed. */
|
||||
.trek-dash .st-zoom button.is-on {
|
||||
background: color-mix(in oklch, var(--accent) 14%, transparent);
|
||||
|
||||
@@ -332,3 +332,18 @@
|
||||
.trek-dash .st-route-stop > span.st-chip {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* The blank inside of a cover beside the first and the last page in the page
|
||||
* rail (SpreadThumb's coverInside): the bound book opens on those pages with
|
||||
* the cover's inside opposite, which is why they stand alone. Drawn as plain
|
||||
* card under a faint hatch, so it reads as a page nothing is printed on.
|
||||
*/
|
||||
.trek-dash .st-thumb-inside {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
repeating-linear-gradient(135deg, color-mix(in oklch, var(--ink) 7%, transparent) 0 1px, transparent 1px 6px),
|
||||
color-mix(in oklch, var(--surface-2) 80%, transparent);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
.trek-dash .st-tpl-grid.is-spreads {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
/* The book's own layouts and the built-in ones lie in the same grid as the designed pages. */
|
||||
.trek-dash .st-thumbs.st-tpl-grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.trek-dash .st-tpl-card {
|
||||
min-width: 0;
|
||||
|
||||
@@ -65,10 +65,13 @@
|
||||
line-height: 1;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
/* The accents sit in the sheet's top corner, clear of a wide "Aa" below. */
|
||||
.trek-dash .st-theme-dots {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
inset-inline-end: 8px;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.trek-dash .st-theme-dots > span {
|
||||
width: 11px;
|
||||
@@ -127,6 +130,28 @@
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
/* The accents' one name spans their three columns, level with the others'
|
||||
names; every entry is placed, so the name does not push them a row down. */
|
||||
.trek-dash .st-theme-palette > .st-theme-colour {
|
||||
grid-row: 1;
|
||||
}
|
||||
.trek-dash .st-theme-palette > :nth-child(2) {
|
||||
grid-column: 2;
|
||||
}
|
||||
.trek-dash .st-theme-palette > :nth-child(3) {
|
||||
grid-column: 3;
|
||||
}
|
||||
.trek-dash .st-theme-palette > :nth-child(4) {
|
||||
grid-column: 4;
|
||||
}
|
||||
.trek-dash .st-theme-palette > :nth-child(5) {
|
||||
grid-column: 5;
|
||||
}
|
||||
.trek-dash .st-theme-accents-name {
|
||||
grid-row: 1;
|
||||
grid-column: 3 / span 3;
|
||||
align-self: end;
|
||||
}
|
||||
.trek-dash .st-theme-colour {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -154,10 +179,14 @@
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* On one line: broken inside a word, "Accent" read as "Accen t". A name the
|
||||
column cannot hold is cut, and the entry's title says it whole. */
|
||||
.trek-dash .st-theme-colour-name {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
letter-spacing: -0.01em;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
font-size: calc(10.5px * var(--fs-scale-caption, 1));
|
||||
@@ -210,13 +239,12 @@
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* The last section's own rule already sets this apart; a second one doubled it. */
|
||||
.trek-dash .st-theme-apply {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line-2);
|
||||
padding-top: 14px;
|
||||
}
|
||||
.trek-dash .st-theme-apply .st-hint {
|
||||
padding: 0 2px;
|
||||
|
||||
@@ -1447,24 +1447,6 @@
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Small, quiet and set apart — a beta mark states a fact about the feature; it
|
||||
is not a call to action and should not read as one. */
|
||||
.trek-dash .st-beta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 16px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
background: color-mix(in oklch, var(--accent) 13%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--accent) 26%, transparent);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ----------------- custom trim ----------------- */
|
||||
.trek-dash .st-menu-sep {
|
||||
height: 1px;
|
||||
@@ -1918,9 +1900,9 @@
|
||||
* The book's own layouts sit above the built-in ones, with the save button
|
||||
* closing their group, so what a click keeps and where it goes read as one.
|
||||
*/
|
||||
.trek-dash .st-saved-layouts { display: flex; flex-direction: column; margin-bottom: 14px; }
|
||||
.trek-dash .st-saved-layouts { display: flex; flex-direction: column; }
|
||||
.trek-dash .st-saved-layouts .st-hint { margin: 0 0 4px; }
|
||||
.trek-dash .st-saved-layouts .st-thumbs:empty { display: none; }
|
||||
.trek-dash .st-saved-layouts .st-add-page { margin-top: 8px; }
|
||||
.trek-dash .st-saved-layouts .st-add-page:disabled { opacity: .5; cursor: default; }
|
||||
.trek-dash .st-saved-layouts .st-add-page:disabled:hover { background: none; border-color: var(--line-2); color: var(--ink-3); }
|
||||
.trek-dash .st-saved-layouts .st-builtin-label { margin-top: 18px; }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import type { BookRecord } from '@trek/shared'
|
||||
import { fireEvent, render, screen } from '../../helpers/render'
|
||||
import { SaveIndicator } from '../../../src/components/Studio/SaveIndicator'
|
||||
import { NewerBookOverlay, SaveIndicator } from '../../../src/components/Studio/SaveIndicator'
|
||||
|
||||
/**
|
||||
* The save status (#1973).
|
||||
@@ -102,16 +102,22 @@ describe('a conflict', () => {
|
||||
|
||||
describe('a book a newer Studio saved', () => {
|
||||
/*
|
||||
* Nothing done in this tab is kept until it reloads, so the state is loud
|
||||
* like a conflict and offers the one way out there is.
|
||||
* Nothing done in this tab is kept until it reloads, so the bar says nothing
|
||||
* of its own and the overlay over the whole Studio offers the one way out.
|
||||
*/
|
||||
it('is announced, says why, and reloads on request', () => {
|
||||
const onReload = vi.fn()
|
||||
draw({ status: 'newer' }, { onReload })
|
||||
it('leaves the bar to the overlay, which says why, takes the focus and reloads on request', () => {
|
||||
const { container } = render(
|
||||
<SaveIndicator state={{ status: 'newer' }} t={t} onAcceptTheirs={vi.fn()} onKeepMine={vi.fn()} onRetry={vi.fn()} />,
|
||||
)
|
||||
expect(container.textContent).toBe('')
|
||||
|
||||
expect(screen.getByRole('alert')).toBeTruthy()
|
||||
const onReload = vi.fn()
|
||||
render(<NewerBookOverlay t={t} onReload={onReload} />)
|
||||
expect(screen.getByRole('alertdialog')).toBeTruthy()
|
||||
expect(screen.getByText('journey.studio.saveNewer')).toBeTruthy()
|
||||
fireEvent.click(screen.getByText('journey.studio.saveReload'))
|
||||
const reload = screen.getByRole('button', { name: 'journey.studio.saveReload' })
|
||||
expect(document.activeElement).toBe(reload)
|
||||
fireEvent.click(reload)
|
||||
expect(onReload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// STLOOK-001 to STLOOK-012: the newer looks of the travel elements. Each look is drawn from the element alone, in its
|
||||
// ink and accent; these cases check that every look draws its figures, names and marks, and that the classic looks
|
||||
// every stored book holds still go through the renderer they always went through.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
BOOK_BADGE_STYLES,
|
||||
BOOK_COUNTRY_LOOKS,
|
||||
BOOK_STATS_LOOKS,
|
||||
bookBadgeElementSchema,
|
||||
bookCountriesElementSchema,
|
||||
bookMapElementSchema,
|
||||
bookStatsElementSchema,
|
||||
type BookElement,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { TravelElementView } from '../../../src/components/Studio/TravelElements';
|
||||
import { inkOn, isBadgeLook } from '../../../src/components/Studio/travel/badgeLook';
|
||||
import { hatch, roughBox, roughCircle, roughLine, seeded } from '../../../src/components/Studio/travel/sketch';
|
||||
import { render } from '../../helpers/render';
|
||||
|
||||
const frame = (w: number, h: number) => ({ x: 0, y: 0, w, h });
|
||||
|
||||
const stats = (look: string, metrics = ['distance', 'days', 'countries', 'photos']) =>
|
||||
bookStatsElementSchema.parse({
|
||||
id: `st-${look}`,
|
||||
kind: 'stats',
|
||||
frame: frame(150, 75),
|
||||
look,
|
||||
metrics,
|
||||
accent: '#c2185b',
|
||||
values: { distance: 966000, days: 20, countries: 1, photos: 4, steps: 102, places: 128, furthest: 122000 },
|
||||
});
|
||||
|
||||
const country = (look: string, over: Record<string, unknown> = {}) =>
|
||||
bookCountriesElementSchema.parse({
|
||||
id: `co-${look}`,
|
||||
kind: 'countries',
|
||||
frame: frame(80, 60),
|
||||
look,
|
||||
codes: ['JP'],
|
||||
names: ['Japan'],
|
||||
accent: '#ef6c00',
|
||||
...over,
|
||||
});
|
||||
|
||||
const badge = (style: string, over: Record<string, unknown> = {}) =>
|
||||
bookBadgeElementSchema.parse({
|
||||
id: `bd-${style}`,
|
||||
kind: 'badge',
|
||||
frame: frame(60, 14),
|
||||
style,
|
||||
variant: 'distance',
|
||||
text: '966 km',
|
||||
sub: 'Distance',
|
||||
accent: '#00796b',
|
||||
...over,
|
||||
});
|
||||
|
||||
function draw(el: BookElement) {
|
||||
return render(
|
||||
<TravelElementView
|
||||
el={el}
|
||||
frameStyle={{ position: 'absolute', width: `${el.frame.w}mm`, height: `${el.frame.h}mm` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('the newer summary looks', () => {
|
||||
it('STLOOK-001: every look draws every figure with its value', () => {
|
||||
for (const look of BOOK_STATS_LOOKS.filter((l) => l !== 'classic')) {
|
||||
const { container, unmount } = draw(stats(look));
|
||||
const text = container.textContent ?? '';
|
||||
// The odometer sets each digit on its own drum, so its figures read padded with zeros.
|
||||
const figures = look === 'odometer' ? ['0966', '0020', '0001', '0004'] : ['966', '20', '1', '4'];
|
||||
for (const figure of figures) expect(text.replace(/\s/g, '')).toContain(figure);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('STLOOK-002: a ticket cuts its notches and prints a barcode drawn from the figures', () => {
|
||||
const { container } = draw(stats('ticket'));
|
||||
expect(container.querySelector('clipPath path')?.getAttribute('d')).toContain('A');
|
||||
expect(container.querySelectorAll('rect').length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it('STLOOK-003: a medal letters its name round the rim, and a single figure fills the frame', () => {
|
||||
const { container } = draw(stats('medal', ['days']));
|
||||
const rim = container.querySelector('textPath')?.textContent ?? '';
|
||||
expect(rim.length).toBeGreaterThan(0);
|
||||
expect(rim).toBe(rim.toUpperCase());
|
||||
expect(container.querySelectorAll('circle').length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('STLOOK-004: a summary placed before looks existed still draws the classic way', () => {
|
||||
const { container } = draw(stats('classic'));
|
||||
expect(container.querySelector('textPath')).toBeNull();
|
||||
expect(container.textContent).toContain('966');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the newer country looks', () => {
|
||||
it('STLOOK-005: every look names the country, and the outline or the flag shows where', () => {
|
||||
for (const look of BOOK_COUNTRY_LOOKS.filter((l) => l !== 'classic')) {
|
||||
const { container, unmount } = draw(country(look, { showFlag: true }));
|
||||
expect((container.textContent ?? '').toUpperCase()).toContain('JAPAN');
|
||||
expect(container.querySelector('svg')).not.toBeNull();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('STLOOK-006: a name switched off is left off in every look', () => {
|
||||
for (const look of ['stamp', 'visa', 'postcard', 'flag', 'pin', 'label', 'sketch']) {
|
||||
const { container, unmount } = draw(country(look, { showName: false }));
|
||||
expect(container.textContent ?? '').not.toContain('Japan');
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('STLOOK-007: an element holding several countries draws one of each, side by side', () => {
|
||||
const { container } = draw(country('stamp', { codes: ['JP', 'KR'], names: ['Japan', 'Korea'] }));
|
||||
expect(container.querySelectorAll('textPath')).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the newer mark styles', () => {
|
||||
it('STLOOK-008: every style sets the value and its line, and the four first ones stay classic', () => {
|
||||
for (const style of BOOK_BADGE_STYLES) {
|
||||
const { container, unmount } = draw(badge(style));
|
||||
expect(container.textContent).toContain('966 km');
|
||||
expect(isBadgeLook(style)).toBe(!['plain', 'chip', 'outline', 'stacked'].includes(style));
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('STLOOK-009: a mark without a value carries its name instead, and coordinates go on two lines', () => {
|
||||
const flag = draw(badge('pill', { variant: 'flag', text: '', sub: 'Japan', code: 'JP' }));
|
||||
expect(flag.container.textContent).toContain('Japan');
|
||||
flag.unmount();
|
||||
|
||||
const coords = draw(badge('tag', { variant: 'coords', text: '35°40′N 139°45′E', sub: 'Tokyo' }));
|
||||
const lines = [...coords.container.querySelectorAll('span')].find((s) => s.style.flexDirection === 'column');
|
||||
expect(lines?.textContent).toContain('Tokyo');
|
||||
});
|
||||
|
||||
it('STLOOK-010: a filled mark takes the ink that reads on its fill', () => {
|
||||
expect(inkOn('#ffffff')).not.toBe(inkOn('#000000'));
|
||||
const { container } = draw(badge('pill', { variant: 'date', text: '4', sub: 'May' }));
|
||||
expect(container.querySelector('svg')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the map stops', () => {
|
||||
const route = [
|
||||
{ lat: 35.68, lng: 139.76 },
|
||||
{ lat: 35.01, lng: 135.77 },
|
||||
{ lat: 34.69, lng: 135.5 },
|
||||
];
|
||||
|
||||
it('STLOOK-011: numbers every stop, or marks the start and the finish', () => {
|
||||
const numbered = draw(
|
||||
bookMapElementSchema.parse({
|
||||
id: 'm1',
|
||||
kind: 'map',
|
||||
frame: frame(120, 90),
|
||||
countries: ['JP'],
|
||||
points: route,
|
||||
pinStyle: 'numbered',
|
||||
})
|
||||
);
|
||||
expect([...numbered.container.querySelectorAll('text')].map((t) => t.textContent)).toEqual(['1', '2', '3']);
|
||||
numbered.unmount();
|
||||
|
||||
const ends = draw(
|
||||
bookMapElementSchema.parse({
|
||||
id: 'm2',
|
||||
kind: 'map',
|
||||
frame: frame(120, 90),
|
||||
countries: ['JP'],
|
||||
points: route,
|
||||
pinStyle: 'ends',
|
||||
})
|
||||
);
|
||||
// The finish carries a pennant, the start a ring round a dot.
|
||||
expect(ends.container.querySelectorAll('line').length).toBeGreaterThan(0);
|
||||
expect(ends.container.querySelectorAll('text')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawn by hand', () => {
|
||||
it('STLOOK-012: the same words wobble the same way every time, and every stroke is a path', () => {
|
||||
const a = seeded('Japan');
|
||||
const b = seeded('Japan');
|
||||
expect([a(), a(), a()]).toEqual([b(), b(), b()]);
|
||||
expect(seeded('Korea')()).not.toBe(seeded('Japan')());
|
||||
|
||||
const pen = { rand: seeded('x'), wobble: 1 };
|
||||
for (const d of [
|
||||
roughLine(0, 0, 10, 0, pen),
|
||||
roughBox({ x: 0, y: 0, w: 10, h: 5 }, pen),
|
||||
roughCircle(5, 5, 3, pen),
|
||||
hatch({ x: 0, y: 0, w: 10, h: 10 }, 2, pen),
|
||||
]) {
|
||||
expect(d.startsWith('M')).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// STTRV-001 to STTRV-006 and STTRV-009: the Travel panel places what the book engine's factories build, so a figure,
|
||||
// a map, a country or a mark added by hand is the element the assistant's `add` adds. Only the frame is the panel's
|
||||
// own: centred across the spread rather than on the right-hand page.
|
||||
// STTRV-001 to STTRV-009: the Travel panel places what the book engine's factories build, set in one of the newer
|
||||
// looks, so a figure, a map, a country or a mark added by hand is the element the assistant's `add` adds in that look.
|
||||
// Only the frame and the look are the panel's own.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { BookDocument, BookElement, BookFrame, BookTravelContext, JourneyStats } from '@trek/shared';
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
BOOK_SATELLITE_TILES,
|
||||
bookBadgeValue,
|
||||
bookPageSetupSchema,
|
||||
bookThemeOf,
|
||||
bookTravelBadge,
|
||||
bookTravelCountries,
|
||||
bookTravelMap,
|
||||
@@ -151,107 +152,156 @@ beforeEach(() => {
|
||||
} as unknown as BookDocument);
|
||||
});
|
||||
|
||||
/** Open one of the panel's tabs by its name. */
|
||||
const tab = (key: string) => fireEvent.click(screen.getByRole('tab', { name: key }));
|
||||
|
||||
describe('the Travel panel builds through the book engine', () => {
|
||||
it('STTRV-001: places a single figure as the engine builds it, centred across the spread', () => {
|
||||
it('STTRV-001: places a summary in the look of its card, with the figures the engine resolves', () => {
|
||||
open();
|
||||
|
||||
fireEvent.click(card('journey.studio.metric.days'));
|
||||
fireEvent.click(card('journey.studio.statsLook.poster'));
|
||||
|
||||
const size = bookTravelSize('stats', page);
|
||||
expect(content(placed())).toEqual(
|
||||
content(bookTravelStats(stats, { id: 'st', frame: ANY_FRAME, metrics: ['days'] }))
|
||||
);
|
||||
expect(placed().frame).toEqual(spreadCentre(size));
|
||||
const engine = bookTravelStats(stats, {
|
||||
id: 'st',
|
||||
frame: ANY_FRAME,
|
||||
metrics: ['distance', 'days', 'countries', 'photos'],
|
||||
});
|
||||
expect(content(placed())).toEqual(content({ ...engine, look: 'poster' as const }));
|
||||
});
|
||||
|
||||
it('STTRV-002: places a journey map with the recorded way, in the style of its card', () => {
|
||||
it('STTRV-002: offers each figure on its own as a medal, an odometer and a sketch', () => {
|
||||
open();
|
||||
|
||||
fireEvent.click(card('journey.studio.mapStyle.dark'));
|
||||
const days = screen.getAllByRole('button', { name: 'journey.studio.metric.days' });
|
||||
expect(days).toHaveLength(3);
|
||||
const looks = days.map((button) => {
|
||||
fireEvent.click(button);
|
||||
return placed() as { look?: string; metrics?: string[] };
|
||||
});
|
||||
expect(looks.map((el) => el.look)).toEqual(['medal', 'odometer', 'sketch']);
|
||||
expect(looks.every((el) => el.metrics?.join() === 'days')).toBe(true);
|
||||
});
|
||||
|
||||
it('STTRV-003: places the journey map with its stops numbered, its ends marked or on the dark ground', () => {
|
||||
open();
|
||||
tab('journey.studio.travelMaps');
|
||||
|
||||
fireEvent.click(card('journey.studio.pinNumbered'));
|
||||
const engine = bookTravelMap(travel, stats, { id: 'mp', frame: ANY_FRAME, scope: 'journey' });
|
||||
expect(content(placed())).toEqual(content({ ...engine, style: 'dark' as const }));
|
||||
expect(placed()).toMatchObject({ kind: 'map', style: 'dark', source: 'vector', tripId: null, path: PATH });
|
||||
expect(content(placed())).toEqual(content({ ...engine, style: 'minimal' as const, pinStyle: 'numbered' as const }));
|
||||
expect(placed()).toMatchObject({ kind: 'map', source: 'vector', tripId: null, path: PATH });
|
||||
expect(placed().frame).toEqual(spreadCentre(bookTravelSize('map', page)));
|
||||
|
||||
fireEvent.click(card('journey.studio.pinEnds'));
|
||||
expect(placed()).toMatchObject({ pinStyle: 'ends' });
|
||||
fireEvent.click(card('journey.studio.mapStyle.dark'));
|
||||
expect(placed()).toMatchObject({ style: 'dark', pinStyle: 'numbered' });
|
||||
});
|
||||
|
||||
it('STTRV-003: places a map per trip of that trip alone, with its own countries and no journey trail', () => {
|
||||
it('STTRV-004: places a map per trip of that trip alone, numbered, with no journey trail', () => {
|
||||
open();
|
||||
tab('journey.studio.travelMaps');
|
||||
|
||||
fireEvent.click(card('June'));
|
||||
|
||||
const engine = bookTravelMap(travel, stats, { id: 'mp', frame: ANY_FRAME, scope: { tripId: ICELAND } });
|
||||
expect(content(placed())).toEqual(content(engine));
|
||||
expect(placed()).toMatchObject({
|
||||
tripId: ICELAND,
|
||||
countries: ['IS'],
|
||||
path: [],
|
||||
pinStyle: 'numbered',
|
||||
points: [{ label: 'Reykjavík', photoId: 3 }, { label: 'Akureyri' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('STTRV-004: places the satellite map the engine places, with its tiles, its credit and a white line', () => {
|
||||
it('STTRV-005: places the satellite map the engine places, with its tiles, its credit and a white line', () => {
|
||||
open();
|
||||
tab('journey.studio.travelMaps');
|
||||
|
||||
fireEvent.click(card('journey.studio.mapSourceSatellite'));
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'journey.studio.mapSourceSatellite' })[0] as HTMLElement);
|
||||
|
||||
const engine = bookTravelMap(travel, stats, { id: 'mp', frame: ANY_FRAME, scope: 'journey', source: 'satellite' });
|
||||
expect(content(placed())).toEqual(content(engine));
|
||||
expect(content(placed())).toEqual(content({ ...engine, style: 'minimal' as const }));
|
||||
expect(placed()).toMatchObject({ source: 'tiles', tileUrl: BOOK_SATELLITE_TILES, accent: '#ffffff' });
|
||||
});
|
||||
|
||||
it('STTRV-005: places each mark with the words, size and style the engine gives it', () => {
|
||||
it('STTRV-006: places a map of one country with its stops alone, cut to its shape on a dark ground', () => {
|
||||
open();
|
||||
tab('journey.studio.travelMaps');
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'journey.studio.mapClipCountry' })[0] as HTMLElement);
|
||||
|
||||
expect(placed()).toMatchObject({
|
||||
clip: 'country',
|
||||
style: 'dark',
|
||||
pinStyle: 'numbered',
|
||||
countries: ['IS'],
|
||||
path: [],
|
||||
points: [{ label: 'Reykjavík' }, { label: 'Akureyri' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('STTRV-007: offers every country in every look, each placing that country alone', () => {
|
||||
open();
|
||||
tab('journey.studio.countries');
|
||||
|
||||
const looks = ['stamp', 'visa', 'postcard', 'flag', 'pin', 'label', 'sketch'] as const;
|
||||
for (const look of looks) {
|
||||
const [iceland] = screen.getAllByRole('button', { name: `journey.studio.countryLook.${look}` });
|
||||
fireEvent.click(iceland as HTMLElement);
|
||||
const engine = bookTravelCountries(travel, { id: 'co', frame: ANY_FRAME, codes: ['IS'] });
|
||||
expect(placed()).toMatchObject({ kind: 'countries', codes: engine.codes, names: engine.names, look });
|
||||
}
|
||||
});
|
||||
|
||||
it('STTRV-008: places each mark in its style with the words the engine gives it', () => {
|
||||
open();
|
||||
tab('journey.studio.marks');
|
||||
|
||||
const [stamp] = screen.getAllByRole('button', { name: 'journey.studio.dateMark' });
|
||||
fireEvent.click(stamp as HTMLElement);
|
||||
const value = bookBadgeValue(travel, 'date', null);
|
||||
if (!value) throw new Error('the engine has no date mark for this journey');
|
||||
expect(content(placed())).toEqual(
|
||||
content({ ...bookTravelBadge({ id: 'bd', frame: ANY_FRAME, variant: 'date', value }), style: 'stamp' as const })
|
||||
);
|
||||
|
||||
const styles = screen.getAllByRole('button', { name: 'journey.studio.distanceMark' }).map((button) => {
|
||||
fireEvent.click(button);
|
||||
return (placed() as { style?: string }).style;
|
||||
});
|
||||
expect(styles).toEqual(['tag', 'pill', 'ribbon', 'sticker', 'patch', 'sketch']);
|
||||
expect(placed()).toMatchObject({ variant: 'distance', text: '4,000 km' });
|
||||
});
|
||||
|
||||
it('STTRV-009: offers no date mark without a start, and the day where its word puts it', () => {
|
||||
open({ ...stats, start: null }, (k) => (k === 'journey.studio.day' ? '{day}日目' : k));
|
||||
tab('journey.studio.marks');
|
||||
|
||||
expect(screen.queryAllByRole('button', { name: 'journey.studio.dateMark' })).toHaveLength(0);
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'journey.studio.dayMark' })[0] as HTMLElement);
|
||||
expect(placed()).toMatchObject({ variant: 'day', text: '1日目', style: 'stamp' });
|
||||
});
|
||||
|
||||
it("STTRV-010: dresses what it places in the book's theme, linked so it follows a change of theme", () => {
|
||||
const theme = bookThemeOf('nordic');
|
||||
useStudioStore.getState().load({
|
||||
version: 1,
|
||||
title: 'T',
|
||||
page,
|
||||
theme,
|
||||
spreads: [{ id: 's1', role: 'inner', background: null, elements: [], parked: [], entryId: null }],
|
||||
} as unknown as BookDocument);
|
||||
open();
|
||||
|
||||
const marks = [
|
||||
['journey.studio.dateMark', 'date'],
|
||||
['journey.studio.dayMark', 'day'],
|
||||
['journey.studio.coordsMark', 'coords'],
|
||||
['journey.studio.flagMark', 'flag'],
|
||||
['journey.studio.distanceMark', 'distance'],
|
||||
] as const;
|
||||
for (const [name, variant] of marks) {
|
||||
fireEvent.click(card(name));
|
||||
const value = bookBadgeValue(travel, variant, null);
|
||||
if (!value) throw new Error(`the engine has no ${variant} mark for this journey`);
|
||||
expect(content(placed())).toEqual(content(bookTravelBadge({ id: 'bd', frame: ANY_FRAME, variant, value })));
|
||||
expect(placed().frame).toEqual(spreadCentre(bookTravelSize('badge', page, { variant })));
|
||||
}
|
||||
// The last one, read back as words: the distance in an outline.
|
||||
expect(placed()).toMatchObject({ variant: 'distance', text: '4,000 km', style: 'outline' });
|
||||
});
|
||||
fireEvent.click(card('journey.studio.statsLook.tiles'));
|
||||
expect(placed()).toMatchObject({
|
||||
accent: theme.palette.accent,
|
||||
accentToken: 'accent',
|
||||
token: 'ink',
|
||||
fontRole: 'heading',
|
||||
});
|
||||
|
||||
it('STTRV-006: offers no date mark without a start, and places a country and its mark by the name', () => {
|
||||
open({ ...stats, start: null });
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'journey.studio.dateMark' })).toBeNull();
|
||||
fireEvent.click(card('journey.studio.dayMark'));
|
||||
expect(placed()).toMatchObject({ variant: 'day', text: 'journey.studio.day 1', style: 'chip' });
|
||||
|
||||
const [outline, mark] = screen.getAllByRole('button', { name: 'Iceland' });
|
||||
fireEvent.click(outline);
|
||||
expect(content(placed())).toEqual(
|
||||
content(bookTravelCountries(travel, { id: 'co', frame: ANY_FRAME, codes: ['IS'] }))
|
||||
);
|
||||
expect(placed()).toMatchObject({ codes: ['IS'], names: ['Iceland'] });
|
||||
|
||||
fireEvent.click(mark);
|
||||
expect(placed()).toMatchObject({ kind: 'badge', variant: 'country', text: 'Iceland', sub: '', code: 'IS' });
|
||||
});
|
||||
|
||||
it('STTRV-009: places a country mark as the engine makes one, and the day where its word puts it', () => {
|
||||
open(stats, (k) => (k === 'journey.studio.day' ? '{day}日目' : k));
|
||||
|
||||
const [, mark] = screen.getAllByRole('button', { name: 'Iceland' });
|
||||
fireEvent.click(mark);
|
||||
const value = bookBadgeValue(travel, 'country', null);
|
||||
expect(value).toEqual({ text: 'Iceland', sub: '', code: 'IS' });
|
||||
if (!value) throw new Error('the engine has no country mark for this journey');
|
||||
const engine = bookTravelBadge({ id: 'bd', frame: ANY_FRAME, variant: 'country', value });
|
||||
expect(content(placed())).toEqual(content(engine));
|
||||
expect(placed().frame).toEqual(spreadCentre(bookTravelSize('badge', page, { variant: 'country' })));
|
||||
|
||||
fireEvent.click(card('journey.studio.dayMark'));
|
||||
expect(placed()).toMatchObject({ variant: 'day', text: '1日目' });
|
||||
fireEvent.click(card('journey.studio.statsLook.note'));
|
||||
expect(placed()).toMatchObject({ fontRole: 'script' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,8 @@ function open(journeyStats: JourneyStats, over: { canEdit?: boolean; onToggleSto
|
||||
canEdit={over.canEdit ?? true} onToggleStop={onToggleStop}
|
||||
/>,
|
||||
)
|
||||
// The stops sit under the maps they are drawn from.
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'journey.studio.travelMaps' }))
|
||||
// The section opens folded, since a fortnight of rows is longer than the
|
||||
// rest of the panel together. Every case below is about those rows, so
|
||||
// unfold unless the case is about the fold itself.
|
||||
|
||||
@@ -66,10 +66,6 @@ function metresApart(a: Pick<TourPoi, 'lat' | 'lng'>, b: { lat: number; lng: num
|
||||
return Math.hypot(dLat, dLng);
|
||||
}
|
||||
|
||||
function withinSamePlace(a: Pick<TourPoi, 'lat' | 'lng'>, b: { lat: number; lng: number }): boolean {
|
||||
return metresApart(a, b) <= SAME_PLACE_M;
|
||||
}
|
||||
|
||||
/** A name as compared: without case, accents or punctuation. */
|
||||
function nameKey(name: string): string {
|
||||
return name
|
||||
@@ -202,21 +198,82 @@ function tourCategoryOf(mapsCategory: string, asked: readonly TourPoiCategory[])
|
||||
}
|
||||
|
||||
/**
|
||||
* The places found, each once and within the corridor, with where they lie.
|
||||
* OpenStreetMap often maps one place twice, as a point and as its outline, and
|
||||
* the index may hold it beside OpenStreetMap: one name in one spot is one
|
||||
* place. An unnamed place (a tap, a bench) is never merged with another.
|
||||
* Within this many metres, a summit of the index and another of a like name
|
||||
* are one mountain. The index holds a mountain as many pins, placed by whoever
|
||||
* listed it, at its foot as much as on top, so they lie a mountain's breadth
|
||||
* apart. Two OpenStreetMap summits are two peaks, as the Watzmann and the
|
||||
* Kleiner Watzmann are.
|
||||
*/
|
||||
const SAME_MOUNTAIN_M = 3000;
|
||||
|
||||
/**
|
||||
* Scripts written without spaces between words: there one name inside another
|
||||
* is a word of it, as 高尾山 is of 八王子高尾山, where in Latin "Dom" is not
|
||||
* "Domplatz".
|
||||
*/
|
||||
const UNSPACED_SCRIPT = /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}]{2,}$/u;
|
||||
|
||||
/** {@link similarNames}, or one word written without spaces inside the other name. */
|
||||
function likeNames(a: string, b: string): boolean {
|
||||
if (similarNames(a, b)) return true;
|
||||
const inside = (word: string, name: string) => UNSPACED_SCRIPT.test(word) && name.replaceAll(' ', '').includes(word);
|
||||
return a.split(' ').some((word) => inside(word, b)) || b.split(' ').some((word) => inside(word, a));
|
||||
}
|
||||
|
||||
/**
|
||||
* Two rows that are one place: the same name in one spot, as OpenStreetMap
|
||||
* maps a place twice, as a point and as its outline, or the index holds it
|
||||
* beside OpenStreetMap; a like name in one spot; or, for a summit of the
|
||||
* index, a like name anywhere on the mountain. An unnamed place (a tap, a
|
||||
* bench) is never one with another.
|
||||
*/
|
||||
function samePlace(a: TourPoi, b: TourPoi): boolean {
|
||||
const name = nameKey(a.name);
|
||||
const other = nameKey(b.name);
|
||||
if (!name || !other || a.category !== b.category) return false;
|
||||
const apart = metresApart(a, b);
|
||||
const pinned = a.source !== 'openstreetmap' || b.source !== 'openstreetmap';
|
||||
if (a.category === 'peak' && pinned && apart <= SAME_MOUNTAIN_M) return likeNames(name, other);
|
||||
return apart <= SAME_PLACE_M && (name === other || likeNames(name, other));
|
||||
}
|
||||
|
||||
/**
|
||||
* The row a place mapped several times keeps: an OpenStreetMap object over an
|
||||
* index pin, then the plainest name ("高尾山" rather than "Top Of Mt.Takao"),
|
||||
* then the first found.
|
||||
*/
|
||||
function keptOf(rows: readonly TourPoi[]): TourPoi {
|
||||
const plainness = (row: TourPoi) => [row.source === 'openstreetmap' ? 0 : 1, nameKey(row.name).length];
|
||||
return rows.reduce((best, row) => {
|
||||
const [a, b] = [plainness(row), plainness(best)];
|
||||
return a[0] < b[0] || (a[0] === b[0] && a[1] < b[1]) ? row : best;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows that are one place, each once: a row joins every group it is one
|
||||
* place with, so a bridge like "高尾山 (Mt. Takao)" joins "Mt.Takao" and
|
||||
* "高尾山" although those two share no word.
|
||||
*/
|
||||
function oncePerPlace(rows: readonly TourPoi[]): TourPoi[] {
|
||||
const groups: TourPoi[][] = [];
|
||||
for (const row of rows) {
|
||||
const joined = groups.filter((group) => group.some((other) => samePlace(row, other)));
|
||||
const merged = [...joined.flat(), row];
|
||||
groups.splice(0, groups.length, ...groups.filter((group) => !joined.includes(group)), merged);
|
||||
}
|
||||
const kept = new Set(groups.map(keptOf));
|
||||
// In the order found, so the first of equals stays first.
|
||||
return rows.filter((row) => kept.has(row));
|
||||
}
|
||||
|
||||
/** The places found, each once and within the corridor, with where they lie. */
|
||||
function placesWithin(found: MapsPoi[], body: TourPoiSearchRequest, line: Array<[number, number]>): TourPoi[] {
|
||||
const kept = new Map<string, TourPoi>();
|
||||
const byName = new Map<string, TourPoi[]>();
|
||||
for (const poi of found) {
|
||||
const category = tourCategoryOf(poi.category, body.categories);
|
||||
const near = nearnessToLine({ lat: poi.lat, lng: poi.lng }, line);
|
||||
if (!category || !near || near.distanceM > body.corridor_m || kept.has(poi.osm_id)) continue;
|
||||
const name = poi.name.trim().toLocaleLowerCase();
|
||||
const namesakes = name ? (byName.get(name) ?? []) : [];
|
||||
if (namesakes.some((other) => withinSamePlace(other, poi))) continue;
|
||||
const place: TourPoi = {
|
||||
id: poi.osm_id,
|
||||
name: poi.name,
|
||||
@@ -234,9 +291,8 @@ function placesWithin(found: MapsPoi[], body: TourPoiSearchRequest, line: Array<
|
||||
...bikeChargingOf(poi),
|
||||
};
|
||||
kept.set(poi.osm_id, place);
|
||||
if (name) byName.set(name, [...namesakes, place]);
|
||||
}
|
||||
return [...kept.values()];
|
||||
return oncePerPlace([...kept.values()]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -139,6 +139,28 @@ describe('TourPoiSearchService', () => {
|
||||
expect(result.pois.map((found) => found.id)).toEqual(['node:9', 'node:10']);
|
||||
});
|
||||
|
||||
it('TOUR-POI-019: the pins of one mountain under several names are one summit, two mapped summits stay two', async () => {
|
||||
const pin = (id: string, name: string, lat: number) =>
|
||||
poi(id, lat, 11.1003, 'peak', { name, source: 'trek-places', poi_type: 'mountain' });
|
||||
poisAlong.mockResolvedValue(
|
||||
answer([
|
||||
// Pins at the foot of the mountain and on top, named in two scripts and bridged by one that has both.
|
||||
pin('gers:1', 'Mt.Takao', 47.401),
|
||||
pin('gers:2', '東京都 高尾山', 47.4011),
|
||||
pin('gers:3', '高尾山 (Mt. Takao)', 47.403),
|
||||
pin('gers:4', '八王子高尾山', 47.405),
|
||||
pin('gers:5', '高尾山', 47.419),
|
||||
pin('gers:6', 'Top Of Mt.Takao', 47.4191),
|
||||
// Another mountain on the way, and two summits of OpenStreetMap a kilometre apart.
|
||||
pin('gers:7', 'Mt.Kagenobu', 47.41),
|
||||
poi('node:1', 47.406, 11.1003, 'peak', { name: 'Watzmann' }),
|
||||
poi('node:2', 47.415, 11.1003, 'peak', { name: 'Kleiner Watzmann' }),
|
||||
]),
|
||||
);
|
||||
const result = await service.search({ tour_type: 'hike', categories: ['peak'], corridor_m: 500, line: LINE });
|
||||
expect(result.pois.map((found) => found.name)).toEqual(['Watzmann', 'Mt.Kagenobu', 'Kleiner Watzmann', '高尾山']);
|
||||
});
|
||||
|
||||
it('TOUR-POI-002: asks the map search along the line for what each category means, and names a hit by the category asked', async () => {
|
||||
poisAlong.mockResolvedValue(answer([poi('node:cafe', 47.41, 11.1, 'cafe')]));
|
||||
const result = await service.search({
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
bookBadgeStyleSchema,
|
||||
bookBadgeVariantSchema,
|
||||
bookCountriesLayoutSchema,
|
||||
bookCountryLookSchema,
|
||||
bookFontIdSchema,
|
||||
bookFrameStyleSchema,
|
||||
bookIconNameSchema,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
bookShapeIdRefSchema,
|
||||
bookShapeIdSchema,
|
||||
bookStatsLayoutSchema,
|
||||
bookStatsLookSchema,
|
||||
bookStrokeStyleSchema,
|
||||
bookTextAlignSchema,
|
||||
bookTypesetFontSchema,
|
||||
@@ -179,6 +181,7 @@ const STATS_PROPS = {
|
||||
...COMMON,
|
||||
...TYPESET,
|
||||
layout: bookStatsLayoutSchema.optional(),
|
||||
look: bookStatsLookSchema.optional(),
|
||||
showIcons: stats.showIcons.unwrap().optional(),
|
||||
units: stats.units.unwrap().describe('Kilometres (metric) or miles (imperial).').optional(),
|
||||
};
|
||||
@@ -186,6 +189,7 @@ const COUNTRIES_PROPS = {
|
||||
...COMMON,
|
||||
...TYPESET,
|
||||
layout: bookCountriesLayoutSchema.optional(),
|
||||
look: bookCountryLookSchema.optional(),
|
||||
showOutline: countries.showOutline.unwrap().optional(),
|
||||
showFlag: countries.showFlag.unwrap().optional(),
|
||||
showName: countries.showName.unwrap().optional(),
|
||||
|
||||
@@ -110,7 +110,7 @@ export const bookRouteArcSchema = map.routeArc
|
||||
.unwrap()
|
||||
.describe('bow draws the long legs as a curve, the way a flight is shown.');
|
||||
export const bookRouteDashSchema = map.routeDash.unwrap().describe('arcs dashes the bowed legs; solid dashes nothing.');
|
||||
export const bookPinStyleSchema = map.pinStyle.unwrap().describe('A stop as a dot, or as a small round photo of it.');
|
||||
export const bookPinStyleSchema = map.pinStyle.unwrap().describe('How the stops are marked.');
|
||||
export const bookMapClipSchema = map.clip
|
||||
.unwrap()
|
||||
.describe('rect is a map in a box; country cuts it to the outline of its countries.');
|
||||
@@ -122,12 +122,12 @@ export const bookMapSourceSchema = z
|
||||
export const bookStatsLayoutSchema = bookStatsElementSchema.shape.layout
|
||||
.unwrap()
|
||||
.describe('The figures in a grid, one row or one column.');
|
||||
export const bookStatsLookSchema = bookStatsElementSchema.shape.look.unwrap().describe('How the figures are drawn.');
|
||||
export const bookCountriesLayoutSchema = bookCountriesElementSchema.shape.layout
|
||||
.unwrap()
|
||||
.describe('The countries as a list, a grid or one column.');
|
||||
export const bookBadgeStyleSchema = bookBadgeElementSchema.shape.style
|
||||
.unwrap()
|
||||
.describe('plain words, a chip (filled capsule), an outline, or stacked over its line.');
|
||||
export const bookCountryLookSchema = bookCountriesElementSchema.shape.look.unwrap().describe('How a country is drawn.');
|
||||
export const bookBadgeStyleSchema = bookBadgeElementSchema.shape.style.unwrap().describe('How the mark is set.');
|
||||
export const bookBadgeVariantSchema = z
|
||||
.enum(BOOK_BADGES)
|
||||
.describe(
|
||||
|
||||
@@ -45,8 +45,11 @@ import type { BookDocument } from './book.schema';
|
||||
* tracking up to 1.5 em.
|
||||
* - 10: letters filled with a gradient or a pattern (`fillPaint` of type `linear`, `radial`,
|
||||
* `pattern`, book-text-effects.schema.ts).
|
||||
* - 11: the newer travel looks: a summary's `look`, a country's `look`, the stamp, tag,
|
||||
* pill, ribbon, sticker, patch and sketch marks, and the `numbered` and `ends` stops of
|
||||
* a map.
|
||||
*/
|
||||
export const BOOK_SCHEMA_REV = 10;
|
||||
export const BOOK_SCHEMA_REV = 11;
|
||||
|
||||
/** The `code` a refused save carries in its body when the stored book is newer than the writer. */
|
||||
export const BOOK_SCHEMA_NEWER_CODE = 'book_schema_newer';
|
||||
|
||||
@@ -504,8 +504,12 @@ export const bookMapElementSchema = z.object({
|
||||
* That fallback is the designed state rather than a failure: a printed page
|
||||
* with a few plain dots between the pictures reads as a route, and one with
|
||||
* empty rings reads as a bug.
|
||||
*
|
||||
* `numbered` draws every stop as its number in the order travelled, the way
|
||||
* a guidebook's map refers to its text. `ends` marks where the journey began
|
||||
* and where it ended, and leaves the stops between them as small dots.
|
||||
*/
|
||||
pinStyle: z.enum(['dot', 'photo']).default('dot'),
|
||||
pinStyle: z.enum(['dot', 'photo', 'numbered', 'ends']).default('dot'),
|
||||
/** ISO-3166-1 alpha-2, the countries whose outlines to draw. */
|
||||
countries: z.array(z.string().length(2)).max(MAX_BOOK_COUNTRIES).default([]),
|
||||
/**
|
||||
@@ -636,6 +640,47 @@ export const bookMapElementSchema = z.object({
|
||||
tripId: z.number().int().positive().nullable().default(null),
|
||||
});
|
||||
|
||||
/** The looks of a summary, `classic` first: what every summary drew before there were looks. */
|
||||
export const BOOK_STATS_LOOKS = [
|
||||
'classic',
|
||||
'poster',
|
||||
'ticket',
|
||||
'tiles',
|
||||
'band',
|
||||
'medal',
|
||||
'odometer',
|
||||
'sign',
|
||||
'note',
|
||||
'sketch',
|
||||
] as const;
|
||||
export type BookStatsLook = (typeof BOOK_STATS_LOOKS)[number];
|
||||
|
||||
/** The looks of a country, `classic` first for the same reason. */
|
||||
export const BOOK_COUNTRY_LOOKS = ['classic', 'stamp', 'postcard', 'visa', 'flag', 'pin', 'label', 'sketch'] as const;
|
||||
export type BookCountryLook = (typeof BOOK_COUNTRY_LOOKS)[number];
|
||||
|
||||
/**
|
||||
* How a mark is set. The first four are what marks have always been; `stamp`
|
||||
* is an inked passport stamp, `tag` a luggage label with its eyelet, `pill`
|
||||
* the icon in a disc at the start of a capsule, `ribbon` a banner with forked
|
||||
* tails, `sticker` a die-cut sticker with its white edge, and `patch` an
|
||||
* embroidered patch with its stitching, and `sketch` a frame drawn by hand.
|
||||
*/
|
||||
export const BOOK_BADGE_STYLES = [
|
||||
'plain',
|
||||
'chip',
|
||||
'outline',
|
||||
'stacked',
|
||||
'stamp',
|
||||
'tag',
|
||||
'pill',
|
||||
'ribbon',
|
||||
'sticker',
|
||||
'patch',
|
||||
'sketch',
|
||||
] as const;
|
||||
export type BookBadgeStyle = (typeof BOOK_BADGE_STYLES)[number];
|
||||
|
||||
export const BOOK_METRICS = ['distance', 'days', 'steps', 'photos', 'countries', 'places', 'furthest'] as const;
|
||||
export type BookMetric = (typeof BOOK_METRICS)[number];
|
||||
|
||||
@@ -646,6 +691,17 @@ export const bookStatsElementSchema = z.object({
|
||||
/** Which figures, in the order they are drawn. */
|
||||
metrics: z.array(z.enum(BOOK_METRICS)).max(7).default(['distance', 'days', 'steps', 'photos']),
|
||||
layout: z.enum(['grid', 'row', 'column']).default('grid'),
|
||||
/**
|
||||
* How the figures are drawn. Absent, or `classic`, is the icon, figure and
|
||||
* caption every summary placed so far draws: optional rather than defaulted,
|
||||
* so a stored book reads and saves exactly as it did. The others are newer
|
||||
* compositions: one figure set as a poster with the rest beside it, a ticket
|
||||
* stub, a tile per figure, a band of figures between hairlines, a round
|
||||
* medal per figure with its name running round the rim, the drums of an
|
||||
* odometer, a road sign listing them, a handwritten note, and a pencil
|
||||
* sketch with a hand-drawn frame.
|
||||
*/
|
||||
look: z.enum(BOOK_STATS_LOOKS).optional(),
|
||||
showIcons: z.boolean().default(true),
|
||||
units: bookUnitsSchema.default('metric'),
|
||||
/**
|
||||
@@ -681,6 +737,16 @@ export const bookCountriesElementSchema = z.object({
|
||||
showFlag: z.boolean().default(false),
|
||||
showName: z.boolean().default(true),
|
||||
align: z.enum(['left', 'center', 'right']).default('center'),
|
||||
/**
|
||||
* How a country is drawn. Absent, or `classic`, is the name over its
|
||||
* silhouette that every country placed so far draws, so a stored book stays
|
||||
* as it was. `stamp` sets it as a round passport stamp, `postcard` as
|
||||
* an airmail card, `visa` as an entry stamp with cut corners, `flag` as the
|
||||
* flag in cloth, `pin` as a map pin carrying the flag, and `label` as an old
|
||||
* luggage label with the name on a ribbon, `sketch` as the outline drawn by
|
||||
* hand and hatched, with the name written under it.
|
||||
*/
|
||||
look: z.enum(BOOK_COUNTRY_LOOKS).optional(),
|
||||
});
|
||||
|
||||
export const BOOK_BADGES = [
|
||||
@@ -724,7 +790,7 @@ export const bookBadgeElementSchema = z.object({
|
||||
* characters; the field is simply no longer only that.
|
||||
*/
|
||||
code: z.string().max(24).nullable().default(null),
|
||||
style: z.enum(['plain', 'chip', 'outline', 'stacked']).default('plain'),
|
||||
style: z.enum(BOOK_BADGE_STYLES).default('plain'),
|
||||
/**
|
||||
* Let the mark pick its own text colour.
|
||||
*
|
||||
|
||||
@@ -317,7 +317,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'مربع 30 × 30 سم',
|
||||
'journey.studio.pages': 'الصفحات',
|
||||
'journey.studio.cover': 'الغلاف',
|
||||
'journey.studio.inspectorEmpty': 'حدّد عنصرًا في الصفحة لتعديله.',
|
||||
'journey.studio.inspectorEmpty': 'حدّد عنصرًا لتعديله',
|
||||
'journey.studio.autoLayout': 'تخطيط تلقائي',
|
||||
'journey.studio.export': 'تصدير',
|
||||
'journey.studio.day': 'اليوم {day}',
|
||||
@@ -398,6 +398,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeKind.ellipse': 'بيضاوي',
|
||||
'journey.studio.focalHint': 'اسحب النقطة لاختيار ما يبقى داخل الإطار.',
|
||||
'journey.studio.backCover': 'الغلاف الخلفي',
|
||||
'journey.studio.coverInside': 'الوجه الداخلي للغلاف، يبقى فارغًا',
|
||||
'journey.studio.backCoverInside': 'الوجه الداخلي للغلاف الخلفي، يبقى فارغًا',
|
||||
'journey.studio.sections': 'الأقسام',
|
||||
'journey.studio.content': 'المحتوى',
|
||||
'journey.studio.elements': 'العناصر',
|
||||
@@ -525,6 +527,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': 'شريحة',
|
||||
'journey.studio.markStyle.outline': 'مفرّغ',
|
||||
'journey.studio.markStyle.stacked': 'عمودي',
|
||||
'journey.studio.statsLook.classic': 'كلاسيكي',
|
||||
'journey.studio.statsLook.poster': 'ملصق',
|
||||
'journey.studio.statsLook.ticket': 'تذكرة',
|
||||
'journey.studio.statsLook.tiles': 'مربعات',
|
||||
'journey.studio.statsLook.band': 'شريط',
|
||||
'journey.studio.statsLook.medal': 'ميدالية',
|
||||
'journey.studio.statsLook.odometer': 'عداد المسافة',
|
||||
'journey.studio.statsLook.sign': 'لافتة طريق',
|
||||
'journey.studio.statsLook.note': 'ملاحظة',
|
||||
'journey.studio.statsLook.sketch': 'رسم',
|
||||
'journey.studio.countryLook.visa': 'تأشيرة',
|
||||
'journey.studio.countryLook.flag': 'علم',
|
||||
'journey.studio.countryLook.pin': 'دبوس',
|
||||
'journey.studio.countryLook.label': 'ملصق أمتعة',
|
||||
'journey.studio.countryLook.sketch': 'رسم',
|
||||
'journey.studio.markStyle.ribbon': 'شريط',
|
||||
'journey.studio.markStyle.sticker': 'ملصق',
|
||||
'journey.studio.markStyle.patch': 'رقعة',
|
||||
'journey.studio.markStyle.sketch': 'رسم',
|
||||
'journey.studio.travelFigures': 'الأرقام',
|
||||
'journey.studio.travelMaps': 'الخرائط',
|
||||
'journey.studio.countryLook.classic': 'كلاسيكي',
|
||||
'journey.studio.countryLook.stamp': 'ختم',
|
||||
'journey.studio.countryLook.postcard': 'بطاقة بريدية',
|
||||
'journey.studio.markStyle.stamp': 'ختم',
|
||||
'journey.studio.markStyle.tag': 'بطاقة أمتعة',
|
||||
'journey.studio.markStyle.pill': 'كبسولة',
|
||||
'journey.studio.pinNumbered': 'أرقام',
|
||||
'journey.studio.pinEnds': 'البداية والنهاية',
|
||||
'journey.studio.icon': 'الأيقونة',
|
||||
'journey.studio.iconAndLabel': 'أيقونة ونص',
|
||||
'journey.studio.iconOnly': 'أيقونة فقط',
|
||||
@@ -572,7 +603,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'حذف الصفحة',
|
||||
'journey.studio.movePageUp': 'نقل للأمام',
|
||||
'journey.studio.movePageDown': 'نقل للخلف',
|
||||
'journey.studio.beta': 'تجريبي',
|
||||
'journey.studio.addProsCons': 'الإيجابيات والسلبيات',
|
||||
'journey.studio.showMarks': 'علامات',
|
||||
'journey.studio.formatCustom': 'مقاس مخصص',
|
||||
|
||||
@@ -37,6 +37,7 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.token.accent': 'لون مميز',
|
||||
'studio.theme.token.accent2': 'لون مميز 2',
|
||||
'studio.theme.token.accent3': 'لون مميز 3',
|
||||
'studio.theme.accents': 'ألوان مميزة',
|
||||
'studio.theme.fromPhotos': 'ألوان من الصور',
|
||||
'studio.theme.noPhotoColours': 'لا تحتوي الصور على لون يمكن أخذه',
|
||||
'studio.theme.fonts': 'الخطوط',
|
||||
|
||||
@@ -319,7 +319,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Kvadrat 30 × 30 sm',
|
||||
'journey.studio.pages': 'Səhifələr',
|
||||
'journey.studio.cover': 'Üzlük',
|
||||
'journey.studio.inspectorEmpty': 'Redaktə etmək üçün səhifədə bir element seçin.',
|
||||
'journey.studio.inspectorEmpty': 'Redaktə üçün nəsə seçin',
|
||||
'journey.studio.autoLayout': 'Avtomatik tərtibat',
|
||||
'journey.studio.export': 'İxrac et',
|
||||
'journey.studio.day': 'GÜN {day}',
|
||||
@@ -402,6 +402,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.focalHint': 'Kadrda qalacaq hissəni seçmək üçün nöqtəni daşıyın.',
|
||||
|
||||
'journey.studio.backCover': 'Arxa üzlük',
|
||||
'journey.studio.coverInside': 'Üz qabığının daxili tərəfi, boş qalır',
|
||||
'journey.studio.backCoverInside': 'Arxa qabığın daxili tərəfi, boş qalır',
|
||||
'journey.studio.sections': 'Bölmələr',
|
||||
'journey.studio.content': 'Məzmun',
|
||||
'journey.studio.elements': 'Elementlər',
|
||||
@@ -545,6 +547,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': 'Etiket',
|
||||
'journey.studio.markStyle.outline': 'Kontur',
|
||||
'journey.studio.markStyle.stacked': 'Üst-üstə',
|
||||
'journey.studio.statsLook.classic': 'Klassik',
|
||||
'journey.studio.statsLook.poster': 'Plakat',
|
||||
'journey.studio.statsLook.ticket': 'Bilet',
|
||||
'journey.studio.statsLook.tiles': 'Kaşılar',
|
||||
'journey.studio.statsLook.band': 'Zolaq',
|
||||
'journey.studio.statsLook.medal': 'Medal', // same-as-en
|
||||
'journey.studio.statsLook.odometer': 'Odometr',
|
||||
'journey.studio.statsLook.sign': 'Yol nişanı',
|
||||
'journey.studio.statsLook.note': 'Qeyd',
|
||||
'journey.studio.statsLook.sketch': 'Eskiz',
|
||||
'journey.studio.countryLook.visa': 'Viza',
|
||||
'journey.studio.countryLook.flag': 'Bayraq',
|
||||
'journey.studio.countryLook.pin': 'Sancaq',
|
||||
'journey.studio.countryLook.label': 'Baqaj etiketi',
|
||||
'journey.studio.countryLook.sketch': 'Eskiz',
|
||||
'journey.studio.markStyle.ribbon': 'Lent',
|
||||
'journey.studio.markStyle.sticker': 'Stiker',
|
||||
'journey.studio.markStyle.patch': 'Yamaq',
|
||||
'journey.studio.markStyle.sketch': 'Eskiz',
|
||||
'journey.studio.travelFigures': 'Rəqəmlər',
|
||||
'journey.studio.travelMaps': 'Xəritələr',
|
||||
'journey.studio.countryLook.classic': 'Klassik',
|
||||
'journey.studio.countryLook.stamp': 'Möhür',
|
||||
'journey.studio.countryLook.postcard': 'Açıqca',
|
||||
'journey.studio.markStyle.stamp': 'Möhür',
|
||||
'journey.studio.markStyle.tag': 'Etiket',
|
||||
'journey.studio.markStyle.pill': 'Kapsul',
|
||||
'journey.studio.pinNumbered': 'Nömrələr',
|
||||
'journey.studio.pinEnds': 'Başlanğıc və son',
|
||||
|
||||
'journey.studio.icon': 'İkon',
|
||||
'journey.studio.iconAndLabel': 'İkon və mətn',
|
||||
@@ -596,7 +627,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'Açılımı sil',
|
||||
'journey.studio.movePageUp': 'Əvvələ daşı',
|
||||
'journey.studio.movePageDown': 'Sonraya daşı',
|
||||
'journey.studio.beta': 'Beta', // same-as-en
|
||||
'journey.studio.addProsCons': 'Müsbət və mənfi cəhətlər',
|
||||
'journey.studio.showMarks': 'Nişanlar',
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.token.accent': 'Vurğu',
|
||||
'studio.theme.token.accent2': 'Vurğu 2',
|
||||
'studio.theme.token.accent3': 'Vurğu 3',
|
||||
'studio.theme.accents': 'Vurğular',
|
||||
'studio.theme.fromPhotos': 'Fotolardan rənglər',
|
||||
'studio.theme.noPhotoColours': 'Fotolarda götürüləcək rəng yoxdur',
|
||||
'studio.theme.fonts': 'Şriftlər',
|
||||
|
||||
@@ -296,7 +296,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Quadrado 30 × 30 cm',
|
||||
'journey.studio.pages': 'Páginas',
|
||||
'journey.studio.cover': 'Capa',
|
||||
'journey.studio.inspectorEmpty': 'Selecione algo na página para editar.',
|
||||
'journey.studio.inspectorEmpty': 'Selecione algo para editar',
|
||||
'journey.studio.autoLayout': 'Layout automático',
|
||||
'journey.studio.export': 'Exportar',
|
||||
'journey.studio.day': 'DIA {day}',
|
||||
@@ -372,6 +372,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeKind.ellipse': 'Elipse',
|
||||
'journey.studio.focalHint': 'Arraste o ponto para escolher o que fica no enquadramento.',
|
||||
'journey.studio.backCover': 'Contracapa',
|
||||
'journey.studio.coverInside': 'Verso da capa, fica em branco',
|
||||
'journey.studio.backCoverInside': 'Verso da contracapa, fica em branco',
|
||||
'journey.studio.sections': 'Seções',
|
||||
'journey.studio.content': 'Conteúdo',
|
||||
'journey.studio.elements': 'Elementos',
|
||||
@@ -500,6 +502,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': 'Etiqueta',
|
||||
'journey.studio.markStyle.outline': 'Contornado',
|
||||
'journey.studio.markStyle.stacked': 'Empilhado',
|
||||
'journey.studio.statsLook.classic': 'Clássico',
|
||||
'journey.studio.statsLook.poster': 'Pôster',
|
||||
'journey.studio.statsLook.ticket': 'Bilhete',
|
||||
'journey.studio.statsLook.tiles': 'Blocos',
|
||||
'journey.studio.statsLook.band': 'Faixa',
|
||||
'journey.studio.statsLook.medal': 'Medalha',
|
||||
'journey.studio.statsLook.odometer': 'Hodômetro',
|
||||
'journey.studio.statsLook.sign': 'Placa de estrada',
|
||||
'journey.studio.statsLook.note': 'Anotação',
|
||||
'journey.studio.statsLook.sketch': 'Esboço',
|
||||
'journey.studio.countryLook.visa': 'Visto',
|
||||
'journey.studio.countryLook.flag': 'Bandeira',
|
||||
'journey.studio.countryLook.pin': 'Alfinete',
|
||||
'journey.studio.countryLook.label': 'Etiqueta de bagagem',
|
||||
'journey.studio.countryLook.sketch': 'Esboço',
|
||||
'journey.studio.markStyle.ribbon': 'Faixa',
|
||||
'journey.studio.markStyle.sticker': 'Adesivo',
|
||||
'journey.studio.markStyle.patch': 'Patch bordado',
|
||||
'journey.studio.markStyle.sketch': 'Esboço',
|
||||
'journey.studio.travelFigures': 'Números',
|
||||
'journey.studio.travelMaps': 'Mapas',
|
||||
'journey.studio.countryLook.classic': 'Clássico',
|
||||
'journey.studio.countryLook.stamp': 'Carimbo',
|
||||
'journey.studio.countryLook.postcard': 'Cartão-postal',
|
||||
'journey.studio.markStyle.stamp': 'Carimbo',
|
||||
'journey.studio.markStyle.tag': 'Etiqueta',
|
||||
'journey.studio.markStyle.pill': 'Pílula',
|
||||
'journey.studio.pinNumbered': 'Números',
|
||||
'journey.studio.pinEnds': 'Início e fim',
|
||||
'journey.studio.icon': 'Ícone',
|
||||
'journey.studio.iconAndLabel': 'Ícone e texto',
|
||||
'journey.studio.iconOnly': 'Só ícone',
|
||||
@@ -547,7 +578,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'Excluir página',
|
||||
'journey.studio.movePageUp': 'Mover para antes',
|
||||
'journey.studio.movePageDown': 'Mover para depois',
|
||||
'journey.studio.beta': 'Beta', // same-as-en
|
||||
'journey.studio.addProsCons': 'Prós e contras',
|
||||
'journey.studio.showMarks': 'Marcas',
|
||||
'journey.studio.formatCustom': 'Tamanho próprio',
|
||||
|
||||
@@ -38,6 +38,7 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.token.accent': 'Destaque',
|
||||
'studio.theme.token.accent2': 'Destaque 2',
|
||||
'studio.theme.token.accent3': 'Destaque 3',
|
||||
'studio.theme.accents': 'Destaques',
|
||||
'studio.theme.fromPhotos': 'Cores das fotos',
|
||||
'studio.theme.noPhotoColours': 'As fotos não têm cor para aproveitar',
|
||||
'studio.theme.fonts': 'Fontes',
|
||||
|
||||
@@ -294,7 +294,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Quadrat 30 × 30 cm',
|
||||
'journey.studio.pages': 'Pàgines',
|
||||
'journey.studio.cover': 'Coberta',
|
||||
'journey.studio.inspectorEmpty': 'Selecciona alguna cosa a la pàgina per editar-la.',
|
||||
'journey.studio.inspectorEmpty': 'Selecciona alguna cosa per editar',
|
||||
'journey.studio.autoLayout': 'Disseny automàtic',
|
||||
'journey.studio.export': 'Exporta',
|
||||
'journey.studio.day': 'DIA {day}',
|
||||
@@ -370,6 +370,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeKind.ellipse': 'El·lipse',
|
||||
'journey.studio.focalHint': "Arrossega el punt per triar què es queda dins l'enquadrament.",
|
||||
'journey.studio.backCover': 'Contracoberta',
|
||||
'journey.studio.coverInside': 'Interior de la coberta, en blanc',
|
||||
'journey.studio.backCoverInside': 'Interior de la contracoberta, en blanc',
|
||||
'journey.studio.sections': 'Seccions',
|
||||
'journey.studio.content': 'Contingut',
|
||||
'journey.studio.elements': 'Elements', // same-as-en
|
||||
@@ -499,6 +501,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': 'Càpsula',
|
||||
'journey.studio.markStyle.outline': 'Contorn',
|
||||
'journey.studio.markStyle.stacked': 'Apilat',
|
||||
'journey.studio.statsLook.classic': 'Clàssic',
|
||||
'journey.studio.statsLook.poster': 'Pòster',
|
||||
'journey.studio.statsLook.ticket': 'Bitllet',
|
||||
'journey.studio.statsLook.tiles': 'Rajoles',
|
||||
'journey.studio.statsLook.band': 'Banda',
|
||||
'journey.studio.statsLook.medal': 'Medalla',
|
||||
'journey.studio.statsLook.odometer': 'Comptaquilòmetres',
|
||||
'journey.studio.statsLook.sign': 'Senyal de carretera',
|
||||
'journey.studio.statsLook.note': 'Nota',
|
||||
'journey.studio.statsLook.sketch': 'Esbós',
|
||||
'journey.studio.countryLook.visa': 'Visat',
|
||||
'journey.studio.countryLook.flag': 'Bandera',
|
||||
'journey.studio.countryLook.pin': 'Agulla',
|
||||
'journey.studio.countryLook.label': 'Etiqueta de maleta',
|
||||
'journey.studio.countryLook.sketch': 'Esbós',
|
||||
'journey.studio.markStyle.ribbon': 'Cinta',
|
||||
'journey.studio.markStyle.sticker': 'Adhesiu',
|
||||
'journey.studio.markStyle.patch': 'Pedaç',
|
||||
'journey.studio.markStyle.sketch': 'Esbós',
|
||||
'journey.studio.travelFigures': 'Xifres',
|
||||
'journey.studio.travelMaps': 'Mapes',
|
||||
'journey.studio.countryLook.classic': 'Clàssic',
|
||||
'journey.studio.countryLook.stamp': 'Segell',
|
||||
'journey.studio.countryLook.postcard': 'Postal',
|
||||
'journey.studio.markStyle.stamp': 'Segell',
|
||||
'journey.studio.markStyle.tag': 'Etiqueta',
|
||||
'journey.studio.markStyle.pill': 'Càpsula',
|
||||
'journey.studio.pinNumbered': 'Números',
|
||||
'journey.studio.pinEnds': 'Inici i final',
|
||||
'journey.studio.icon': 'Icona',
|
||||
'journey.studio.iconAndLabel': 'Icona i text',
|
||||
'journey.studio.iconOnly': 'Només icona',
|
||||
@@ -546,7 +577,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'Elimina la pàgina',
|
||||
'journey.studio.movePageUp': 'Mou abans',
|
||||
'journey.studio.movePageDown': 'Mou després',
|
||||
'journey.studio.beta': 'Beta', // same-as-en
|
||||
'journey.studio.addProsCons': 'Pros i contres',
|
||||
'journey.studio.showMarks': 'Marques',
|
||||
'journey.studio.formatCustom': 'Mida pròpia',
|
||||
|
||||
@@ -38,6 +38,7 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.token.accent': 'Accent', // same-as-en
|
||||
'studio.theme.token.accent2': 'Accent 2', // same-as-en
|
||||
'studio.theme.token.accent3': 'Accent 3', // same-as-en
|
||||
'studio.theme.accents': 'Accents', // same-as-en
|
||||
'studio.theme.fromPhotos': 'Colors de les fotos',
|
||||
'studio.theme.noPhotoColours': 'Les fotos no tenen cap color per agafar',
|
||||
'studio.theme.fonts': 'Tipografies',
|
||||
|
||||
@@ -300,7 +300,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Čtverec 30 × 30 cm',
|
||||
'journey.studio.pages': 'Stránky',
|
||||
'journey.studio.cover': 'Obálka',
|
||||
'journey.studio.inspectorEmpty': 'Vyberte něco na stránce a můžete to upravit.',
|
||||
'journey.studio.inspectorEmpty': 'Vyberte něco k úpravě',
|
||||
'journey.studio.autoLayout': 'Automatické rozvržení',
|
||||
'journey.studio.export': 'Exportovat',
|
||||
'journey.studio.day': 'DEN {day}',
|
||||
@@ -376,6 +376,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeKind.ellipse': 'Elipsa',
|
||||
'journey.studio.focalHint': 'Přetažením bodu zvolíte, co zůstane v záběru.',
|
||||
'journey.studio.backCover': 'Zadní obálka',
|
||||
'journey.studio.coverInside': 'Vnitřní strana obálky, zůstává prázdná',
|
||||
'journey.studio.backCoverInside': 'Vnitřní strana zadní obálky, zůstává prázdná',
|
||||
'journey.studio.sections': 'Sekce',
|
||||
'journey.studio.content': 'Obsah',
|
||||
'journey.studio.elements': 'Prvky',
|
||||
@@ -504,6 +506,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': 'Vyplněný',
|
||||
'journey.studio.markStyle.outline': 'Obrysový',
|
||||
'journey.studio.markStyle.stacked': 'Nad sebou',
|
||||
'journey.studio.statsLook.classic': 'Klasický',
|
||||
'journey.studio.statsLook.poster': 'Plakát',
|
||||
'journey.studio.statsLook.ticket': 'Lístek',
|
||||
'journey.studio.statsLook.tiles': 'Dlaždice',
|
||||
'journey.studio.statsLook.band': 'Pás',
|
||||
'journey.studio.statsLook.medal': 'Medaile',
|
||||
'journey.studio.statsLook.odometer': 'Tachometr',
|
||||
'journey.studio.statsLook.sign': 'Směrovka',
|
||||
'journey.studio.statsLook.note': 'Poznámka',
|
||||
'journey.studio.statsLook.sketch': 'Skica',
|
||||
'journey.studio.countryLook.visa': 'Vízum',
|
||||
'journey.studio.countryLook.flag': 'Vlajka',
|
||||
'journey.studio.countryLook.pin': 'Špendlík',
|
||||
'journey.studio.countryLook.label': 'Nálepka na kufr',
|
||||
'journey.studio.countryLook.sketch': 'Skica',
|
||||
'journey.studio.markStyle.ribbon': 'Stuha',
|
||||
'journey.studio.markStyle.sticker': 'Samolepka',
|
||||
'journey.studio.markStyle.patch': 'Nášivka',
|
||||
'journey.studio.markStyle.sketch': 'Skica',
|
||||
'journey.studio.travelFigures': 'Čísla',
|
||||
'journey.studio.travelMaps': 'Mapy',
|
||||
'journey.studio.countryLook.classic': 'Klasický',
|
||||
'journey.studio.countryLook.stamp': 'Razítko',
|
||||
'journey.studio.countryLook.postcard': 'Pohlednice',
|
||||
'journey.studio.markStyle.stamp': 'Razítko',
|
||||
'journey.studio.markStyle.tag': 'Visačka',
|
||||
'journey.studio.markStyle.pill': 'Kapsle',
|
||||
'journey.studio.pinNumbered': 'Čísla',
|
||||
'journey.studio.pinEnds': 'Start a cíl',
|
||||
'journey.studio.icon': 'Ikona',
|
||||
'journey.studio.iconAndLabel': 'Ikona a text',
|
||||
'journey.studio.iconOnly': 'Jen ikona',
|
||||
@@ -551,7 +582,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'Smazat stranu',
|
||||
'journey.studio.movePageUp': 'Posunout dopředu',
|
||||
'journey.studio.movePageDown': 'Posunout dozadu',
|
||||
'journey.studio.beta': 'Beta', // same-as-en
|
||||
'journey.studio.addProsCons': 'Klady a zápory',
|
||||
'journey.studio.showMarks': 'Značky',
|
||||
'journey.studio.formatCustom': 'Vlastní formát',
|
||||
|
||||
@@ -38,6 +38,7 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.token.accent': 'Akcent',
|
||||
'studio.theme.token.accent2': 'Akcent 2',
|
||||
'studio.theme.token.accent3': 'Akcent 3',
|
||||
'studio.theme.accents': 'Akcenty',
|
||||
'studio.theme.fromPhotos': 'Barvy z fotek',
|
||||
'studio.theme.noPhotoColours': 'Fotky nemají žádnou barvu k převzetí',
|
||||
'studio.theme.fonts': 'Písma',
|
||||
|
||||
@@ -296,7 +296,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Quadratisch 30 × 30 cm',
|
||||
'journey.studio.pages': 'Seiten',
|
||||
'journey.studio.cover': 'Cover', // same-as-en
|
||||
'journey.studio.inspectorEmpty': 'Wähle etwas auf der Seite aus, um es zu bearbeiten.',
|
||||
'journey.studio.inspectorEmpty': 'Wähle etwas zum Bearbeiten',
|
||||
'journey.studio.autoLayout': 'Automatisch anordnen',
|
||||
'journey.studio.export': 'Exportieren',
|
||||
'journey.studio.day': 'TAG {day}',
|
||||
@@ -372,6 +372,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeKind.ellipse': 'Ellipse', // same-as-en
|
||||
'journey.studio.focalHint': 'Zieh den Punkt dorthin, was im Bild bleiben soll.',
|
||||
'journey.studio.backCover': 'Rückseite',
|
||||
'journey.studio.coverInside': 'Innenseite des Covers, bleibt leer',
|
||||
'journey.studio.backCoverInside': 'Innenseite der Rückseite, bleibt leer',
|
||||
'journey.studio.sections': 'Bereiche',
|
||||
'journey.studio.content': 'Inhalte',
|
||||
'journey.studio.elements': 'Elemente',
|
||||
@@ -501,6 +503,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': 'Gefüllt',
|
||||
'journey.studio.markStyle.outline': 'Umrandet',
|
||||
'journey.studio.markStyle.stacked': 'Gestapelt',
|
||||
'journey.studio.statsLook.classic': 'Klassisch',
|
||||
'journey.studio.statsLook.poster': 'Poster', // same-as-en
|
||||
'journey.studio.statsLook.ticket': 'Ticket', // same-as-en
|
||||
'journey.studio.statsLook.tiles': 'Kacheln',
|
||||
'journey.studio.statsLook.band': 'Band', // same-as-en
|
||||
'journey.studio.statsLook.medal': 'Medaille',
|
||||
'journey.studio.statsLook.odometer': 'Kilometerzähler',
|
||||
'journey.studio.statsLook.sign': 'Wegweiser',
|
||||
'journey.studio.statsLook.note': 'Notiz',
|
||||
'journey.studio.statsLook.sketch': 'Skizze',
|
||||
'journey.studio.countryLook.visa': 'Visum',
|
||||
'journey.studio.countryLook.flag': 'Flagge',
|
||||
'journey.studio.countryLook.pin': 'Pin', // same-as-en
|
||||
'journey.studio.countryLook.label': 'Kofferaufkleber',
|
||||
'journey.studio.countryLook.sketch': 'Skizze',
|
||||
'journey.studio.markStyle.ribbon': 'Banner',
|
||||
'journey.studio.markStyle.sticker': 'Aufkleber',
|
||||
'journey.studio.markStyle.patch': 'Aufnäher',
|
||||
'journey.studio.markStyle.sketch': 'Skizze',
|
||||
'journey.studio.travelFigures': 'Zahlen',
|
||||
'journey.studio.travelMaps': 'Karten',
|
||||
'journey.studio.countryLook.classic': 'Klassisch',
|
||||
'journey.studio.countryLook.stamp': 'Stempel',
|
||||
'journey.studio.countryLook.postcard': 'Postkarte',
|
||||
'journey.studio.markStyle.stamp': 'Stempel',
|
||||
'journey.studio.markStyle.tag': 'Anhänger',
|
||||
'journey.studio.markStyle.pill': 'Pille',
|
||||
'journey.studio.pinNumbered': 'Nummern',
|
||||
'journey.studio.pinEnds': 'Start und Ziel',
|
||||
'journey.studio.icon': 'Symbol',
|
||||
'journey.studio.iconAndLabel': 'Symbol & Text',
|
||||
'journey.studio.iconOnly': 'Nur Symbol',
|
||||
@@ -548,7 +579,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'Seite löschen',
|
||||
'journey.studio.movePageUp': 'Nach vorne',
|
||||
'journey.studio.movePageDown': 'Nach hinten',
|
||||
'journey.studio.beta': 'Beta', // same-as-en
|
||||
'journey.studio.addProsCons': 'Pro & Contra',
|
||||
'journey.studio.showMarks': 'Zeichen',
|
||||
'journey.studio.formatCustom': 'Eigenes Format',
|
||||
|
||||
@@ -38,6 +38,7 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.token.accent': 'Akzent',
|
||||
'studio.theme.token.accent2': 'Akzent 2',
|
||||
'studio.theme.token.accent3': 'Akzent 3',
|
||||
'studio.theme.accents': 'Akzente',
|
||||
'studio.theme.fromPhotos': 'Farben aus den Fotos',
|
||||
'studio.theme.noPhotoColours': 'Die Fotos haben keine Farbe zum Übernehmen',
|
||||
'studio.theme.fonts': 'Schriften',
|
||||
|
||||
@@ -293,7 +293,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Square 30 × 30 cm',
|
||||
'journey.studio.pages': 'Pages',
|
||||
'journey.studio.cover': 'Cover',
|
||||
'journey.studio.inspectorEmpty': 'Select something on the page to edit it.',
|
||||
'journey.studio.inspectorEmpty': 'Select something to edit',
|
||||
'journey.studio.autoLayout': 'Auto layout',
|
||||
'journey.studio.export': 'Export',
|
||||
'journey.studio.day': 'DAY {day}',
|
||||
@@ -368,6 +368,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeKind.ellipse': 'Ellipse',
|
||||
'journey.studio.focalHint': 'Drag the point to choose what stays in frame.',
|
||||
'journey.studio.backCover': 'Back cover',
|
||||
'journey.studio.coverInside': 'Inside of the cover, left blank',
|
||||
'journey.studio.backCoverInside': 'Inside of the back cover, left blank',
|
||||
'journey.studio.sections': 'Sections',
|
||||
'journey.studio.content': 'Content',
|
||||
'journey.studio.elements': 'Elements',
|
||||
@@ -496,6 +498,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': 'Chip',
|
||||
'journey.studio.markStyle.outline': 'Outline',
|
||||
'journey.studio.markStyle.stacked': 'Stacked',
|
||||
'journey.studio.statsLook.classic': 'Classic',
|
||||
'journey.studio.statsLook.poster': 'Poster',
|
||||
'journey.studio.statsLook.ticket': 'Ticket',
|
||||
'journey.studio.statsLook.tiles': 'Tiles',
|
||||
'journey.studio.statsLook.band': 'Band',
|
||||
'journey.studio.statsLook.medal': 'Medal',
|
||||
'journey.studio.statsLook.odometer': 'Odometer',
|
||||
'journey.studio.statsLook.sign': 'Road sign',
|
||||
'journey.studio.statsLook.note': 'Note',
|
||||
'journey.studio.statsLook.sketch': 'Sketch',
|
||||
'journey.studio.countryLook.visa': 'Visa',
|
||||
'journey.studio.countryLook.flag': 'Flag',
|
||||
'journey.studio.countryLook.pin': 'Pin',
|
||||
'journey.studio.countryLook.label': 'Luggage label',
|
||||
'journey.studio.countryLook.sketch': 'Sketch',
|
||||
'journey.studio.markStyle.ribbon': 'Ribbon',
|
||||
'journey.studio.markStyle.sticker': 'Sticker',
|
||||
'journey.studio.markStyle.patch': 'Patch',
|
||||
'journey.studio.markStyle.sketch': 'Sketch',
|
||||
'journey.studio.travelFigures': 'Figures',
|
||||
'journey.studio.travelMaps': 'Maps',
|
||||
'journey.studio.countryLook.classic': 'Classic',
|
||||
'journey.studio.countryLook.stamp': 'Stamp',
|
||||
'journey.studio.countryLook.postcard': 'Postcard',
|
||||
'journey.studio.markStyle.stamp': 'Stamp',
|
||||
'journey.studio.markStyle.tag': 'Tag',
|
||||
'journey.studio.markStyle.pill': 'Pill',
|
||||
'journey.studio.pinNumbered': 'Numbers',
|
||||
'journey.studio.pinEnds': 'Start and finish',
|
||||
'journey.studio.icon': 'Icon',
|
||||
'journey.studio.iconAndLabel': 'Icon & text',
|
||||
'journey.studio.iconOnly': 'Icon only',
|
||||
@@ -543,7 +574,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'Delete spread',
|
||||
'journey.studio.movePageUp': 'Move earlier',
|
||||
'journey.studio.movePageDown': 'Move later',
|
||||
'journey.studio.beta': 'Beta',
|
||||
'journey.studio.addProsCons': 'Pros & Cons',
|
||||
'journey.studio.showMarks': 'Marks',
|
||||
'journey.studio.formatCustom': 'Custom size',
|
||||
|
||||
@@ -38,6 +38,7 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.token.accent': 'Accent',
|
||||
'studio.theme.token.accent2': 'Accent 2',
|
||||
'studio.theme.token.accent3': 'Accent 3',
|
||||
'studio.theme.accents': 'Accents',
|
||||
'studio.theme.fromPhotos': 'Colours from the photos',
|
||||
'studio.theme.noPhotoColours': 'The photos have no colour to take',
|
||||
'studio.theme.fonts': 'Typefaces',
|
||||
|
||||
@@ -295,7 +295,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Cuadrado 30 × 30 cm',
|
||||
'journey.studio.pages': 'Páginas',
|
||||
'journey.studio.cover': 'Portada',
|
||||
'journey.studio.inspectorEmpty': 'Selecciona algo en la página para editarlo.',
|
||||
'journey.studio.inspectorEmpty': 'Selecciona algo para editar',
|
||||
'journey.studio.autoLayout': 'Diseño automático',
|
||||
'journey.studio.export': 'Exportar',
|
||||
'journey.studio.day': 'DÍA {day}',
|
||||
@@ -371,6 +371,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeKind.ellipse': 'Elipse',
|
||||
'journey.studio.focalHint': 'Arrastra el punto para elegir qué se queda en el encuadre.',
|
||||
'journey.studio.backCover': 'Contraportada',
|
||||
'journey.studio.coverInside': 'Interior de la portada, en blanco',
|
||||
'journey.studio.backCoverInside': 'Interior de la contraportada, en blanco',
|
||||
'journey.studio.sections': 'Secciones',
|
||||
'journey.studio.content': 'Contenido',
|
||||
'journey.studio.elements': 'Elementos',
|
||||
@@ -499,6 +501,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': 'Cápsula',
|
||||
'journey.studio.markStyle.outline': 'Contorno',
|
||||
'journey.studio.markStyle.stacked': 'Apilado',
|
||||
'journey.studio.statsLook.classic': 'Clásico',
|
||||
'journey.studio.statsLook.poster': 'Póster',
|
||||
'journey.studio.statsLook.ticket': 'Billete',
|
||||
'journey.studio.statsLook.tiles': 'Mosaicos',
|
||||
'journey.studio.statsLook.band': 'Banda',
|
||||
'journey.studio.statsLook.medal': 'Medalla',
|
||||
'journey.studio.statsLook.odometer': 'Cuentakilómetros',
|
||||
'journey.studio.statsLook.sign': 'Señal de carretera',
|
||||
'journey.studio.statsLook.note': 'Nota',
|
||||
'journey.studio.statsLook.sketch': 'Boceto',
|
||||
'journey.studio.countryLook.visa': 'Visado',
|
||||
'journey.studio.countryLook.flag': 'Bandera',
|
||||
'journey.studio.countryLook.pin': 'Chincheta',
|
||||
'journey.studio.countryLook.label': 'Etiqueta de equipaje',
|
||||
'journey.studio.countryLook.sketch': 'Boceto',
|
||||
'journey.studio.markStyle.ribbon': 'Cinta',
|
||||
'journey.studio.markStyle.sticker': 'Pegatina',
|
||||
'journey.studio.markStyle.patch': 'Parche',
|
||||
'journey.studio.markStyle.sketch': 'Boceto',
|
||||
'journey.studio.travelFigures': 'Cifras',
|
||||
'journey.studio.travelMaps': 'Mapas',
|
||||
'journey.studio.countryLook.classic': 'Clásico',
|
||||
'journey.studio.countryLook.stamp': 'Sello',
|
||||
'journey.studio.countryLook.postcard': 'Postal',
|
||||
'journey.studio.markStyle.stamp': 'Sello',
|
||||
'journey.studio.markStyle.tag': 'Etiqueta',
|
||||
'journey.studio.markStyle.pill': 'Píldora',
|
||||
'journey.studio.pinNumbered': 'Números',
|
||||
'journey.studio.pinEnds': 'Inicio y final',
|
||||
'journey.studio.icon': 'Icono',
|
||||
'journey.studio.iconAndLabel': 'Icono y texto',
|
||||
'journey.studio.iconOnly': 'Solo icono',
|
||||
@@ -546,7 +577,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'Eliminar página',
|
||||
'journey.studio.movePageUp': 'Mover antes',
|
||||
'journey.studio.movePageDown': 'Mover después',
|
||||
'journey.studio.beta': 'Beta', // same-as-en
|
||||
'journey.studio.addProsCons': 'Pros y contras',
|
||||
'journey.studio.showMarks': 'Marcas',
|
||||
'journey.studio.formatCustom': 'Tamaño propio',
|
||||
|
||||
@@ -38,6 +38,7 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.token.accent': 'Acento',
|
||||
'studio.theme.token.accent2': 'Acento 2',
|
||||
'studio.theme.token.accent3': 'Acento 3',
|
||||
'studio.theme.accents': 'Acentos',
|
||||
'studio.theme.fromPhotos': 'Colores de las fotos',
|
||||
'studio.theme.noPhotoColours': 'Las fotos no tienen ningún color que tomar',
|
||||
'studio.theme.fonts': 'Tipografías',
|
||||
|
||||
@@ -295,7 +295,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Ruut 30 × 30 cm',
|
||||
'journey.studio.pages': 'Lehed',
|
||||
'journey.studio.cover': 'Kaas',
|
||||
'journey.studio.inspectorEmpty': 'Muutmiseks vali lehel mõni element.',
|
||||
'journey.studio.inspectorEmpty': 'Vali midagi muutmiseks',
|
||||
'journey.studio.autoLayout': 'Automaatne paigutus',
|
||||
'journey.studio.export': 'Eksport',
|
||||
'journey.studio.day': '{day}. PÄEV',
|
||||
@@ -369,6 +369,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeKind.ellipse': 'Ellips',
|
||||
'journey.studio.focalHint': 'Lohista punkti, et valida, mis jääb kaadrisse.',
|
||||
'journey.studio.backCover': 'Tagakaas',
|
||||
'journey.studio.coverInside': 'Kaane sisekülg, jääb tühjaks',
|
||||
'journey.studio.backCoverInside': 'Tagakaane sisekülg, jääb tühjaks',
|
||||
'journey.studio.sections': 'Jaotised',
|
||||
'journey.studio.content': 'Sisu',
|
||||
'journey.studio.elements': 'Elemendid',
|
||||
@@ -497,6 +499,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': 'Silt',
|
||||
'journey.studio.markStyle.outline': 'Kontuur',
|
||||
'journey.studio.markStyle.stacked': 'Virnastatud',
|
||||
'journey.studio.statsLook.classic': 'Klassikaline',
|
||||
'journey.studio.statsLook.poster': 'Plakat',
|
||||
'journey.studio.statsLook.ticket': 'Pilet',
|
||||
'journey.studio.statsLook.tiles': 'Plaadid',
|
||||
'journey.studio.statsLook.band': 'Riba',
|
||||
'journey.studio.statsLook.medal': 'Medal', // same-as-en
|
||||
'journey.studio.statsLook.odometer': 'Odomeeter',
|
||||
'journey.studio.statsLook.sign': 'Teeviit',
|
||||
'journey.studio.statsLook.note': 'Märge',
|
||||
'journey.studio.statsLook.sketch': 'Visand',
|
||||
'journey.studio.countryLook.visa': 'Viisa',
|
||||
'journey.studio.countryLook.flag': 'Lipp',
|
||||
'journey.studio.countryLook.pin': 'Nööpnõel',
|
||||
'journey.studio.countryLook.label': 'Pagasisilt',
|
||||
'journey.studio.countryLook.sketch': 'Visand',
|
||||
'journey.studio.markStyle.ribbon': 'Pael',
|
||||
'journey.studio.markStyle.sticker': 'Kleebis',
|
||||
'journey.studio.markStyle.patch': 'Embleem',
|
||||
'journey.studio.markStyle.sketch': 'Visand',
|
||||
'journey.studio.travelFigures': 'Arvud',
|
||||
'journey.studio.travelMaps': 'Kaardid',
|
||||
'journey.studio.countryLook.classic': 'Klassikaline',
|
||||
'journey.studio.countryLook.stamp': 'Tempel',
|
||||
'journey.studio.countryLook.postcard': 'Postkaart',
|
||||
'journey.studio.markStyle.stamp': 'Tempel',
|
||||
'journey.studio.markStyle.tag': 'Silt',
|
||||
'journey.studio.markStyle.pill': 'Kapsel',
|
||||
'journey.studio.pinNumbered': 'Numbrid',
|
||||
'journey.studio.pinEnds': 'Algus ja lõpp',
|
||||
'journey.studio.icon': 'Ikoon',
|
||||
'journey.studio.iconAndLabel': 'Ikoon ja tekst',
|
||||
'journey.studio.iconOnly': 'Ainult ikoon',
|
||||
@@ -544,7 +575,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'Kustuta lehepaar',
|
||||
'journey.studio.movePageUp': 'Liiguta ettepoole',
|
||||
'journey.studio.movePageDown': 'Liiguta tahapoole',
|
||||
'journey.studio.beta': 'Beeta',
|
||||
'journey.studio.addProsCons': 'Plussid ja miinused',
|
||||
'journey.studio.showMarks': 'Märgised',
|
||||
'journey.studio.formatCustom': 'Kohandatud suurus',
|
||||
|
||||
@@ -38,6 +38,7 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.token.accent': 'Aktsent',
|
||||
'studio.theme.token.accent2': 'Aktsent 2',
|
||||
'studio.theme.token.accent3': 'Aktsent 3',
|
||||
'studio.theme.accents': 'Aktsendid',
|
||||
'studio.theme.fromPhotos': 'Värvid fotodelt',
|
||||
'studio.theme.noPhotoColours': 'Fotodel pole värvi, mida võtta',
|
||||
'studio.theme.fonts': 'Kirjatüübid',
|
||||
|
||||
@@ -295,7 +295,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Carré 30 × 30 cm',
|
||||
'journey.studio.pages': 'Pages', // same-as-en
|
||||
'journey.studio.cover': 'Couverture',
|
||||
'journey.studio.inspectorEmpty': 'Sélectionnez un élément de la page pour le modifier.',
|
||||
'journey.studio.inspectorEmpty': 'Sélectionnez un élément',
|
||||
'journey.studio.autoLayout': 'Mise en page auto',
|
||||
'journey.studio.export': 'Exporter',
|
||||
'journey.studio.day': 'JOUR {day}',
|
||||
@@ -370,6 +370,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeKind.ellipse': 'Ellipse',
|
||||
'journey.studio.focalHint': 'Faites glisser le point pour choisir ce qui reste dans le cadre.',
|
||||
'journey.studio.backCover': 'Quatrième de couverture',
|
||||
'journey.studio.coverInside': 'Intérieur de la couverture, laissé vierge',
|
||||
'journey.studio.backCoverInside': 'Intérieur de la quatrième de couverture, laissé vierge',
|
||||
'journey.studio.sections': 'Sections',
|
||||
'journey.studio.content': 'Contenu',
|
||||
'journey.studio.elements': 'Éléments',
|
||||
@@ -499,6 +501,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': 'Pastille',
|
||||
'journey.studio.markStyle.outline': 'Contour',
|
||||
'journey.studio.markStyle.stacked': 'Empilé',
|
||||
'journey.studio.statsLook.classic': 'Classique',
|
||||
'journey.studio.statsLook.poster': 'Affiche',
|
||||
'journey.studio.statsLook.ticket': 'Billet',
|
||||
'journey.studio.statsLook.tiles': 'Tuiles',
|
||||
'journey.studio.statsLook.band': 'Bandeau',
|
||||
'journey.studio.statsLook.medal': 'Médaille',
|
||||
'journey.studio.statsLook.odometer': 'Compteur',
|
||||
'journey.studio.statsLook.sign': 'Panneau routier',
|
||||
'journey.studio.statsLook.note': 'Note', // same-as-en
|
||||
'journey.studio.statsLook.sketch': 'Croquis',
|
||||
'journey.studio.countryLook.visa': 'Visa', // same-as-en
|
||||
'journey.studio.countryLook.flag': 'Drapeau',
|
||||
'journey.studio.countryLook.pin': 'Épingle',
|
||||
'journey.studio.countryLook.label': 'Étiquette de valise',
|
||||
'journey.studio.countryLook.sketch': 'Croquis',
|
||||
'journey.studio.markStyle.ribbon': 'Ruban',
|
||||
'journey.studio.markStyle.sticker': 'Autocollant',
|
||||
'journey.studio.markStyle.patch': 'Écusson',
|
||||
'journey.studio.markStyle.sketch': 'Croquis',
|
||||
'journey.studio.travelFigures': 'Chiffres',
|
||||
'journey.studio.travelMaps': 'Cartes',
|
||||
'journey.studio.countryLook.classic': 'Classique',
|
||||
'journey.studio.countryLook.stamp': 'Tampon',
|
||||
'journey.studio.countryLook.postcard': 'Carte postale',
|
||||
'journey.studio.markStyle.stamp': 'Tampon',
|
||||
'journey.studio.markStyle.tag': 'Étiquette',
|
||||
'journey.studio.markStyle.pill': 'Pastille',
|
||||
'journey.studio.pinNumbered': 'Numéros',
|
||||
'journey.studio.pinEnds': 'Départ et arrivée',
|
||||
'journey.studio.icon': 'Icône',
|
||||
'journey.studio.iconAndLabel': 'Icône et texte',
|
||||
'journey.studio.iconOnly': 'Icône seule',
|
||||
@@ -546,7 +577,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'Supprimer la page',
|
||||
'journey.studio.movePageUp': 'Déplacer avant',
|
||||
'journey.studio.movePageDown': 'Déplacer après',
|
||||
'journey.studio.beta': 'Bêta',
|
||||
'journey.studio.addProsCons': 'Pour et contre',
|
||||
'journey.studio.showMarks': 'Repères',
|
||||
'journey.studio.formatCustom': 'Format libre',
|
||||
|
||||
@@ -38,6 +38,7 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.token.accent': 'Accent', // same-as-en
|
||||
'studio.theme.token.accent2': 'Accent 2', // same-as-en
|
||||
'studio.theme.token.accent3': 'Accent 3', // same-as-en
|
||||
'studio.theme.accents': 'Accents', // same-as-en
|
||||
'studio.theme.fromPhotos': 'Couleurs des photos',
|
||||
'studio.theme.noPhotoColours': 'Les photos n’ont aucune couleur à reprendre',
|
||||
'studio.theme.fonts': 'Polices',
|
||||
|
||||
@@ -298,7 +298,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Τετράγωνο 30 × 30 εκ.',
|
||||
'journey.studio.pages': 'Σελίδες',
|
||||
'journey.studio.cover': 'Εξώφυλλο',
|
||||
'journey.studio.inspectorEmpty': 'Επιλέξτε κάτι στη σελίδα για να το επεξεργαστείτε.',
|
||||
'journey.studio.inspectorEmpty': 'Επιλέξτε κάτι για επεξεργασία',
|
||||
'journey.studio.autoLayout': 'Αυτόματη διάταξη',
|
||||
'journey.studio.export': 'Εξαγωγή',
|
||||
'journey.studio.day': 'ΗΜΕΡΑ {day}',
|
||||
@@ -375,6 +375,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeKind.ellipse': 'Έλλειψη',
|
||||
'journey.studio.focalHint': 'Σύρετε το σημείο για να επιλέξετε τι μένει στο κάδρο.',
|
||||
'journey.studio.backCover': 'Οπισθόφυλλο',
|
||||
'journey.studio.coverInside': 'Εσωτερικό του εξωφύλλου, μένει κενό',
|
||||
'journey.studio.backCoverInside': 'Εσωτερικό του οπισθόφυλλου, μένει κενό',
|
||||
'journey.studio.sections': 'Ενότητες',
|
||||
'journey.studio.content': 'Περιεχόμενο',
|
||||
'journey.studio.elements': 'Στοιχεία',
|
||||
@@ -505,6 +507,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': 'Πλακίδιο',
|
||||
'journey.studio.markStyle.outline': 'Περίγραμμα',
|
||||
'journey.studio.markStyle.stacked': 'Σε στήλη',
|
||||
'journey.studio.statsLook.classic': 'Κλασική',
|
||||
'journey.studio.statsLook.poster': 'Αφίσα',
|
||||
'journey.studio.statsLook.ticket': 'Εισιτήριο',
|
||||
'journey.studio.statsLook.tiles': 'Πλακίδια',
|
||||
'journey.studio.statsLook.band': 'Ταινία',
|
||||
'journey.studio.statsLook.medal': 'Μετάλλιο',
|
||||
'journey.studio.statsLook.odometer': 'Χιλιομετρητής',
|
||||
'journey.studio.statsLook.sign': 'Πινακίδα δρόμου',
|
||||
'journey.studio.statsLook.note': 'Σημείωση',
|
||||
'journey.studio.statsLook.sketch': 'Σκίτσο',
|
||||
'journey.studio.countryLook.visa': 'Βίζα',
|
||||
'journey.studio.countryLook.flag': 'Σημαία',
|
||||
'journey.studio.countryLook.pin': 'Πινέζα',
|
||||
'journey.studio.countryLook.label': 'Ετικέτα αποσκευών',
|
||||
'journey.studio.countryLook.sketch': 'Σκίτσο',
|
||||
'journey.studio.markStyle.ribbon': 'Κορδέλα',
|
||||
'journey.studio.markStyle.sticker': 'Αυτοκόλλητο',
|
||||
'journey.studio.markStyle.patch': 'Κεντητό σήμα',
|
||||
'journey.studio.markStyle.sketch': 'Σκίτσο',
|
||||
'journey.studio.travelFigures': 'Αριθμοί',
|
||||
'journey.studio.travelMaps': 'Χάρτες',
|
||||
'journey.studio.countryLook.classic': 'Κλασική',
|
||||
'journey.studio.countryLook.stamp': 'Σφραγίδα',
|
||||
'journey.studio.countryLook.postcard': 'Καρτ ποστάλ',
|
||||
'journey.studio.markStyle.stamp': 'Σφραγίδα',
|
||||
'journey.studio.markStyle.tag': 'Ετικέτα',
|
||||
'journey.studio.markStyle.pill': 'Κάψουλα',
|
||||
'journey.studio.pinNumbered': 'Αριθμοί',
|
||||
'journey.studio.pinEnds': 'Αφετηρία και τερματισμός',
|
||||
'journey.studio.icon': 'Εικονίδιο',
|
||||
'journey.studio.iconAndLabel': 'Εικονίδιο & κείμενο',
|
||||
'journey.studio.iconOnly': 'Μόνο εικονίδιο',
|
||||
@@ -552,7 +583,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'Διαγραφή σελίδας',
|
||||
'journey.studio.movePageUp': 'Μετακίνηση νωρίτερα',
|
||||
'journey.studio.movePageDown': 'Μετακίνηση αργότερα',
|
||||
'journey.studio.beta': 'Δοκιμαστική',
|
||||
'journey.studio.addProsCons': 'Υπέρ & Κατά',
|
||||
'journey.studio.showMarks': 'Σήματα',
|
||||
'journey.studio.formatCustom': 'Δικό σας μέγεθος',
|
||||
|
||||
@@ -38,6 +38,7 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.token.accent': 'Τόνος',
|
||||
'studio.theme.token.accent2': 'Τόνος 2',
|
||||
'studio.theme.token.accent3': 'Τόνος 3',
|
||||
'studio.theme.accents': 'Τόνοι',
|
||||
'studio.theme.fromPhotos': 'Χρώματα από τις φωτογραφίες',
|
||||
'studio.theme.noPhotoColours': 'Οι φωτογραφίες δεν έχουν χρώμα να πάρουμε',
|
||||
'studio.theme.fonts': 'Γραμματοσειρές',
|
||||
|
||||
@@ -294,7 +294,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Négyzet 30 × 30 cm',
|
||||
'journey.studio.pages': 'Oldalak',
|
||||
'journey.studio.cover': 'Borító',
|
||||
'journey.studio.inspectorEmpty': 'Válassz ki valamit az oldalon a szerkesztéséhez.',
|
||||
'journey.studio.inspectorEmpty': 'Válassz ki valamit',
|
||||
'journey.studio.autoLayout': 'Automatikus elrendezés',
|
||||
'journey.studio.export': 'Exportálás',
|
||||
'journey.studio.day': '{day}. NAP',
|
||||
@@ -368,6 +368,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeKind.ellipse': 'Ellipszis',
|
||||
'journey.studio.focalHint': 'Húzd a pontot, hogy kiválaszd, mi maradjon a képen.',
|
||||
'journey.studio.backCover': 'Hátsó borító',
|
||||
'journey.studio.coverInside': 'A borító belső oldala, üresen marad',
|
||||
'journey.studio.backCoverInside': 'A hátsó borító belső oldala, üresen marad',
|
||||
'journey.studio.sections': 'Szakaszok',
|
||||
'journey.studio.content': 'Tartalom',
|
||||
'journey.studio.elements': 'Elemek',
|
||||
@@ -496,6 +498,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': 'Címke',
|
||||
'journey.studio.markStyle.outline': 'Körvonalas',
|
||||
'journey.studio.markStyle.stacked': 'Egymás alatt',
|
||||
'journey.studio.statsLook.classic': 'Klasszikus',
|
||||
'journey.studio.statsLook.poster': 'Plakát',
|
||||
'journey.studio.statsLook.ticket': 'Jegy',
|
||||
'journey.studio.statsLook.tiles': 'Csempék',
|
||||
'journey.studio.statsLook.band': 'Sáv',
|
||||
'journey.studio.statsLook.medal': 'Érem',
|
||||
'journey.studio.statsLook.odometer': 'Kilométeróra',
|
||||
'journey.studio.statsLook.sign': 'Útjelző tábla',
|
||||
'journey.studio.statsLook.note': 'Jegyzet',
|
||||
'journey.studio.statsLook.sketch': 'Vázlat',
|
||||
'journey.studio.countryLook.visa': 'Vízum',
|
||||
'journey.studio.countryLook.flag': 'Zászló',
|
||||
'journey.studio.countryLook.pin': 'Gombostű',
|
||||
'journey.studio.countryLook.label': 'Bőröndcímke',
|
||||
'journey.studio.countryLook.sketch': 'Vázlat',
|
||||
'journey.studio.markStyle.ribbon': 'Szalag',
|
||||
'journey.studio.markStyle.sticker': 'Matrica',
|
||||
'journey.studio.markStyle.patch': 'Felvarró',
|
||||
'journey.studio.markStyle.sketch': 'Vázlat',
|
||||
'journey.studio.travelFigures': 'Számok',
|
||||
'journey.studio.travelMaps': 'Térképek',
|
||||
'journey.studio.countryLook.classic': 'Klasszikus',
|
||||
'journey.studio.countryLook.stamp': 'Pecsét',
|
||||
'journey.studio.countryLook.postcard': 'Képeslap',
|
||||
'journey.studio.markStyle.stamp': 'Pecsét',
|
||||
'journey.studio.markStyle.tag': 'Címke',
|
||||
'journey.studio.markStyle.pill': 'Kapszula',
|
||||
'journey.studio.pinNumbered': 'Számok',
|
||||
'journey.studio.pinEnds': 'Indulás és érkezés',
|
||||
'journey.studio.icon': 'Ikon',
|
||||
'journey.studio.iconAndLabel': 'Ikon és szöveg',
|
||||
'journey.studio.iconOnly': 'Csak ikon',
|
||||
@@ -543,7 +574,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'Oldal törlése',
|
||||
'journey.studio.movePageUp': 'Előrébb',
|
||||
'journey.studio.movePageDown': 'Hátrébb',
|
||||
'journey.studio.beta': 'Béta',
|
||||
'journey.studio.addProsCons': 'Előnyök és hátrányok',
|
||||
'journey.studio.showMarks': 'Jelek',
|
||||
'journey.studio.formatCustom': 'Egyéni méret',
|
||||
|
||||
@@ -38,6 +38,7 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.token.accent': 'Kiemelés',
|
||||
'studio.theme.token.accent2': 'Kiemelés 2',
|
||||
'studio.theme.token.accent3': 'Kiemelés 3',
|
||||
'studio.theme.accents': 'Kiemelések',
|
||||
'studio.theme.fromPhotos': 'Színek a fotókból',
|
||||
'studio.theme.noPhotoColours': 'A fotókon nincs átvehető szín',
|
||||
'studio.theme.fonts': 'Betűtípusok',
|
||||
|
||||
@@ -287,7 +287,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Persegi 30 × 30 cm',
|
||||
'journey.studio.pages': 'Halaman',
|
||||
'journey.studio.cover': 'Sampul',
|
||||
'journey.studio.inspectorEmpty': 'Pilih sesuatu di halaman untuk mengeditnya.',
|
||||
'journey.studio.inspectorEmpty': 'Pilih sesuatu untuk diedit',
|
||||
'journey.studio.autoLayout': 'Tata letak otomatis',
|
||||
'journey.studio.export': 'Ekspor',
|
||||
'journey.studio.day': 'HARI {day}',
|
||||
@@ -360,6 +360,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeKind.ellipse': 'Elips',
|
||||
'journey.studio.focalHint': 'Seret titik untuk memilih bagian yang tetap terlihat.',
|
||||
'journey.studio.backCover': 'Sampul belakang',
|
||||
'journey.studio.coverInside': 'Bagian dalam sampul, dibiarkan kosong',
|
||||
'journey.studio.backCoverInside': 'Bagian dalam sampul belakang, dibiarkan kosong',
|
||||
'journey.studio.sections': 'Bagian',
|
||||
'journey.studio.content': 'Konten',
|
||||
'journey.studio.elements': 'Elemen',
|
||||
@@ -489,6 +491,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': 'Kapsul',
|
||||
'journey.studio.markStyle.outline': 'Kapsul garis',
|
||||
'journey.studio.markStyle.stacked': 'Bertumpuk',
|
||||
'journey.studio.statsLook.classic': 'Klasik',
|
||||
'journey.studio.statsLook.poster': 'Poster', // same-as-en
|
||||
'journey.studio.statsLook.ticket': 'Tiket',
|
||||
'journey.studio.statsLook.tiles': 'Ubin',
|
||||
'journey.studio.statsLook.band': 'Pita',
|
||||
'journey.studio.statsLook.medal': 'Medali',
|
||||
'journey.studio.statsLook.odometer': 'Odometer', // same-as-en
|
||||
'journey.studio.statsLook.sign': 'Papan jalan',
|
||||
'journey.studio.statsLook.note': 'Catatan',
|
||||
'journey.studio.statsLook.sketch': 'Sketsa',
|
||||
'journey.studio.countryLook.visa': 'Visa', // same-as-en
|
||||
'journey.studio.countryLook.flag': 'Bendera',
|
||||
'journey.studio.countryLook.pin': 'Pin', // same-as-en
|
||||
'journey.studio.countryLook.label': 'Label koper',
|
||||
'journey.studio.countryLook.sketch': 'Sketsa',
|
||||
'journey.studio.markStyle.ribbon': 'Pita',
|
||||
'journey.studio.markStyle.sticker': 'Stiker',
|
||||
'journey.studio.markStyle.patch': 'Emblem',
|
||||
'journey.studio.markStyle.sketch': 'Sketsa',
|
||||
'journey.studio.travelFigures': 'Angka',
|
||||
'journey.studio.travelMaps': 'Peta',
|
||||
'journey.studio.countryLook.classic': 'Klasik',
|
||||
'journey.studio.countryLook.stamp': 'Stempel',
|
||||
'journey.studio.countryLook.postcard': 'Kartu pos',
|
||||
'journey.studio.markStyle.stamp': 'Stempel',
|
||||
'journey.studio.markStyle.tag': 'Label',
|
||||
'journey.studio.markStyle.pill': 'Kapsul',
|
||||
'journey.studio.pinNumbered': 'Nomor',
|
||||
'journey.studio.pinEnds': 'Awal dan akhir',
|
||||
'journey.studio.icon': 'Ikon',
|
||||
'journey.studio.iconAndLabel': 'Ikon & teks',
|
||||
'journey.studio.iconOnly': 'Hanya ikon',
|
||||
@@ -536,7 +567,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'Hapus halaman',
|
||||
'journey.studio.movePageUp': 'Pindah ke depan',
|
||||
'journey.studio.movePageDown': 'Pindah ke belakang',
|
||||
'journey.studio.beta': 'Beta', // same-as-en
|
||||
'journey.studio.addProsCons': 'Pro & Kontra',
|
||||
'journey.studio.showMarks': 'Tanda',
|
||||
'journey.studio.formatCustom': 'Ukuran sendiri',
|
||||
|
||||
@@ -38,6 +38,7 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.token.accent': 'Aksen',
|
||||
'studio.theme.token.accent2': 'Aksen 2',
|
||||
'studio.theme.token.accent3': 'Aksen 3',
|
||||
'studio.theme.accents': 'Aksen',
|
||||
'studio.theme.fromPhotos': 'Warna dari foto',
|
||||
'studio.theme.noPhotoColours': 'Foto tidak punya warna untuk diambil',
|
||||
'studio.theme.fonts': 'Jenis huruf',
|
||||
|
||||
@@ -294,7 +294,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Quadrato 30 × 30 cm',
|
||||
'journey.studio.pages': 'Pagine',
|
||||
'journey.studio.cover': 'Copertina',
|
||||
'journey.studio.inspectorEmpty': 'Seleziona un elemento della pagina per modificarlo.',
|
||||
'journey.studio.inspectorEmpty': 'Seleziona un elemento',
|
||||
'journey.studio.autoLayout': 'Layout automatico',
|
||||
'journey.studio.export': 'Esporta',
|
||||
'journey.studio.day': 'GIORNO {day}',
|
||||
@@ -370,6 +370,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeKind.ellipse': 'Ellisse',
|
||||
'journey.studio.focalHint': "Trascina il punto per scegliere cosa resta nell'inquadratura.",
|
||||
'journey.studio.backCover': 'Quarta di copertina',
|
||||
'journey.studio.coverInside': 'Interno della copertina, lasciato bianco',
|
||||
'journey.studio.backCoverInside': 'Interno della quarta di copertina, lasciato bianco',
|
||||
'journey.studio.sections': 'Sezioni',
|
||||
'journey.studio.content': 'Contenuti',
|
||||
'journey.studio.elements': 'Elementi',
|
||||
@@ -498,6 +500,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': 'Capsula',
|
||||
'journey.studio.markStyle.outline': 'Contorno',
|
||||
'journey.studio.markStyle.stacked': 'Impilato',
|
||||
'journey.studio.statsLook.classic': 'Classico',
|
||||
'journey.studio.statsLook.poster': 'Poster', // same-as-en
|
||||
'journey.studio.statsLook.ticket': 'Biglietto',
|
||||
'journey.studio.statsLook.tiles': 'Riquadri',
|
||||
'journey.studio.statsLook.band': 'Fascia',
|
||||
'journey.studio.statsLook.medal': 'Medaglia',
|
||||
'journey.studio.statsLook.odometer': 'Contachilometri',
|
||||
'journey.studio.statsLook.sign': 'Cartello stradale',
|
||||
'journey.studio.statsLook.note': 'Nota',
|
||||
'journey.studio.statsLook.sketch': 'Schizzo',
|
||||
'journey.studio.countryLook.visa': 'Visto',
|
||||
'journey.studio.countryLook.flag': 'Bandiera',
|
||||
'journey.studio.countryLook.pin': 'Spillo',
|
||||
'journey.studio.countryLook.label': 'Etichetta da valigia',
|
||||
'journey.studio.countryLook.sketch': 'Schizzo',
|
||||
'journey.studio.markStyle.ribbon': 'Nastro',
|
||||
'journey.studio.markStyle.sticker': 'Adesivo',
|
||||
'journey.studio.markStyle.patch': 'Toppa',
|
||||
'journey.studio.markStyle.sketch': 'Schizzo',
|
||||
'journey.studio.travelFigures': 'Cifre',
|
||||
'journey.studio.travelMaps': 'Mappe',
|
||||
'journey.studio.countryLook.classic': 'Classico',
|
||||
'journey.studio.countryLook.stamp': 'Timbro',
|
||||
'journey.studio.countryLook.postcard': 'Cartolina',
|
||||
'journey.studio.markStyle.stamp': 'Timbro',
|
||||
'journey.studio.markStyle.tag': 'Etichetta',
|
||||
'journey.studio.markStyle.pill': 'Pillola',
|
||||
'journey.studio.pinNumbered': 'Numeri',
|
||||
'journey.studio.pinEnds': 'Partenza e arrivo',
|
||||
'journey.studio.icon': 'Icona',
|
||||
'journey.studio.iconAndLabel': 'Icona e testo',
|
||||
'journey.studio.iconOnly': 'Solo icona',
|
||||
@@ -545,7 +576,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'Elimina pagina',
|
||||
'journey.studio.movePageUp': 'Sposta prima',
|
||||
'journey.studio.movePageDown': 'Sposta dopo',
|
||||
'journey.studio.beta': 'Beta', // same-as-en
|
||||
'journey.studio.addProsCons': 'Pro e contro',
|
||||
'journey.studio.showMarks': 'Segni',
|
||||
'journey.studio.formatCustom': 'Formato libero',
|
||||
|
||||
@@ -38,6 +38,7 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.token.accent': 'Accento',
|
||||
'studio.theme.token.accent2': 'Accento 2',
|
||||
'studio.theme.token.accent3': 'Accento 3',
|
||||
'studio.theme.accents': 'Accenti',
|
||||
'studio.theme.fromPhotos': 'Colori dalle foto',
|
||||
'studio.theme.noPhotoColours': 'Le foto non hanno colori da prendere',
|
||||
'studio.theme.fonts': 'Caratteri',
|
||||
|
||||
@@ -286,7 +286,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': '正方形 30 × 30 cm',
|
||||
'journey.studio.pages': 'ページ',
|
||||
'journey.studio.cover': '表紙',
|
||||
'journey.studio.inspectorEmpty': 'ページ上の要素を選択すると編集できます。',
|
||||
'journey.studio.inspectorEmpty': '編集する要素を選択',
|
||||
'journey.studio.autoLayout': '自動レイアウト',
|
||||
'journey.studio.export': 'エクスポート',
|
||||
'journey.studio.day': '{day}日目',
|
||||
@@ -358,6 +358,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeKind.ellipse': '楕円',
|
||||
'journey.studio.focalHint': '点をドラッグして、枠内に残す部分を選びます。',
|
||||
'journey.studio.backCover': '裏表紙',
|
||||
'journey.studio.coverInside': '表紙の裏、白紙のまま',
|
||||
'journey.studio.backCoverInside': '裏表紙の裏、白紙のまま',
|
||||
'journey.studio.sections': 'セクション',
|
||||
'journey.studio.content': 'コンテンツ',
|
||||
'journey.studio.elements': '要素',
|
||||
@@ -488,6 +490,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': 'チップ',
|
||||
'journey.studio.markStyle.outline': '枠線',
|
||||
'journey.studio.markStyle.stacked': '縦並び',
|
||||
'journey.studio.statsLook.classic': 'クラシック',
|
||||
'journey.studio.statsLook.poster': 'ポスター',
|
||||
'journey.studio.statsLook.ticket': 'チケット',
|
||||
'journey.studio.statsLook.tiles': 'タイル',
|
||||
'journey.studio.statsLook.band': 'バンド',
|
||||
'journey.studio.statsLook.medal': 'メダル',
|
||||
'journey.studio.statsLook.odometer': '走行距離計',
|
||||
'journey.studio.statsLook.sign': '道路標識',
|
||||
'journey.studio.statsLook.note': 'メモ',
|
||||
'journey.studio.statsLook.sketch': 'スケッチ',
|
||||
'journey.studio.countryLook.visa': 'ビザ',
|
||||
'journey.studio.countryLook.flag': '旗',
|
||||
'journey.studio.countryLook.pin': 'ピン',
|
||||
'journey.studio.countryLook.label': '荷物ラベル',
|
||||
'journey.studio.countryLook.sketch': 'スケッチ',
|
||||
'journey.studio.markStyle.ribbon': 'リボン',
|
||||
'journey.studio.markStyle.sticker': 'ステッカー',
|
||||
'journey.studio.markStyle.patch': 'ワッペン',
|
||||
'journey.studio.markStyle.sketch': 'スケッチ',
|
||||
'journey.studio.travelFigures': '数字',
|
||||
'journey.studio.travelMaps': '地図',
|
||||
'journey.studio.countryLook.classic': 'クラシック',
|
||||
'journey.studio.countryLook.stamp': 'スタンプ',
|
||||
'journey.studio.countryLook.postcard': 'ポストカード',
|
||||
'journey.studio.markStyle.stamp': 'スタンプ',
|
||||
'journey.studio.markStyle.tag': 'タグ',
|
||||
'journey.studio.markStyle.pill': 'ピル',
|
||||
'journey.studio.pinNumbered': '番号',
|
||||
'journey.studio.pinEnds': '出発地と到着地',
|
||||
'journey.studio.icon': 'アイコン',
|
||||
'journey.studio.iconAndLabel': 'アイコンと文字',
|
||||
'journey.studio.iconOnly': 'アイコンのみ',
|
||||
@@ -535,7 +566,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'ページを削除',
|
||||
'journey.studio.movePageUp': '前へ移動',
|
||||
'journey.studio.movePageDown': '後ろへ移動',
|
||||
'journey.studio.beta': 'ベータ',
|
||||
'journey.studio.addProsCons': '良かった点・気になった点',
|
||||
'journey.studio.showMarks': '記号',
|
||||
'journey.studio.formatCustom': 'カスタムサイズ',
|
||||
|
||||
@@ -37,6 +37,7 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.token.accent': 'アクセント',
|
||||
'studio.theme.token.accent2': 'アクセント 2',
|
||||
'studio.theme.token.accent3': 'アクセント 3',
|
||||
'studio.theme.accents': 'アクセント',
|
||||
'studio.theme.fromPhotos': '写真から色を取る',
|
||||
'studio.theme.noPhotoColours': '写真から取れる色がありません',
|
||||
'studio.theme.fonts': '書体',
|
||||
|
||||
@@ -283,7 +283,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': '정사각형 30 × 30 cm',
|
||||
'journey.studio.pages': '페이지',
|
||||
'journey.studio.cover': '표지',
|
||||
'journey.studio.inspectorEmpty': '페이지에서 편집할 항목을 선택하세요.',
|
||||
'journey.studio.inspectorEmpty': '편집할 항목을 선택하세요',
|
||||
'journey.studio.autoLayout': '자동 레이아웃',
|
||||
'journey.studio.export': '내보내기',
|
||||
'journey.studio.day': '{day}일차',
|
||||
@@ -355,6 +355,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeKind.ellipse': '타원',
|
||||
'journey.studio.focalHint': '점을 드래그해 프레임에 남길 부분을 정하세요.',
|
||||
'journey.studio.backCover': '뒤표지',
|
||||
'journey.studio.coverInside': '앞표지 안쪽, 비워 둠',
|
||||
'journey.studio.backCoverInside': '뒤표지 안쪽, 비워 둠',
|
||||
'journey.studio.sections': '섹션',
|
||||
'journey.studio.content': '콘텐츠',
|
||||
'journey.studio.elements': '요소',
|
||||
@@ -484,6 +486,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': '칩',
|
||||
'journey.studio.markStyle.outline': '테두리',
|
||||
'journey.studio.markStyle.stacked': '세로',
|
||||
'journey.studio.statsLook.classic': '클래식',
|
||||
'journey.studio.statsLook.poster': '포스터',
|
||||
'journey.studio.statsLook.ticket': '티켓',
|
||||
'journey.studio.statsLook.tiles': '타일',
|
||||
'journey.studio.statsLook.band': '띠',
|
||||
'journey.studio.statsLook.medal': '메달',
|
||||
'journey.studio.statsLook.odometer': '주행계',
|
||||
'journey.studio.statsLook.sign': '도로 표지판',
|
||||
'journey.studio.statsLook.note': '메모',
|
||||
'journey.studio.statsLook.sketch': '스케치',
|
||||
'journey.studio.countryLook.visa': '비자',
|
||||
'journey.studio.countryLook.flag': '깃발',
|
||||
'journey.studio.countryLook.pin': '핀',
|
||||
'journey.studio.countryLook.label': '수하물 라벨',
|
||||
'journey.studio.countryLook.sketch': '스케치',
|
||||
'journey.studio.markStyle.ribbon': '리본',
|
||||
'journey.studio.markStyle.sticker': '스티커',
|
||||
'journey.studio.markStyle.patch': '패치',
|
||||
'journey.studio.markStyle.sketch': '스케치',
|
||||
'journey.studio.travelFigures': '숫자',
|
||||
'journey.studio.travelMaps': '지도',
|
||||
'journey.studio.countryLook.classic': '클래식',
|
||||
'journey.studio.countryLook.stamp': '스탬프',
|
||||
'journey.studio.countryLook.postcard': '엽서',
|
||||
'journey.studio.markStyle.stamp': '스탬프',
|
||||
'journey.studio.markStyle.tag': '태그',
|
||||
'journey.studio.markStyle.pill': '알약형',
|
||||
'journey.studio.pinNumbered': '번호',
|
||||
'journey.studio.pinEnds': '출발과 도착',
|
||||
'journey.studio.icon': '아이콘',
|
||||
'journey.studio.iconAndLabel': '아이콘과 텍스트',
|
||||
'journey.studio.iconOnly': '아이콘만',
|
||||
@@ -531,7 +562,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': '페이지 삭제',
|
||||
'journey.studio.movePageUp': '앞으로 이동',
|
||||
'journey.studio.movePageDown': '뒤로 이동',
|
||||
'journey.studio.beta': '베타',
|
||||
'journey.studio.addProsCons': '장단점',
|
||||
'journey.studio.showMarks': '기호',
|
||||
'journey.studio.formatCustom': '사용자 지정 크기',
|
||||
|
||||
@@ -37,6 +37,7 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.token.accent': '포인트',
|
||||
'studio.theme.token.accent2': '포인트 2',
|
||||
'studio.theme.token.accent3': '포인트 3',
|
||||
'studio.theme.accents': '포인트',
|
||||
'studio.theme.fromPhotos': '사진에서 색 가져오기',
|
||||
'studio.theme.noPhotoColours': '사진에서 가져올 색이 없습니다',
|
||||
'studio.theme.fonts': '글꼴',
|
||||
|
||||
@@ -296,7 +296,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Vierkant 30 × 30 cm',
|
||||
'journey.studio.pages': "Pagina's",
|
||||
'journey.studio.cover': 'Omslag',
|
||||
'journey.studio.inspectorEmpty': 'Selecteer iets op de pagina om het te bewerken.',
|
||||
'journey.studio.inspectorEmpty': 'Selecteer iets om te bewerken',
|
||||
'journey.studio.autoLayout': 'Automatische opmaak',
|
||||
'journey.studio.export': 'Exporteren',
|
||||
'journey.studio.day': 'DAG {day}',
|
||||
@@ -372,6 +372,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeKind.ellipse': 'Ellips',
|
||||
'journey.studio.focalHint': 'Sleep het punt om te kiezen wat in beeld blijft.',
|
||||
'journey.studio.backCover': 'Achterkant',
|
||||
'journey.studio.coverInside': 'Binnenkant van de omslag, blijft leeg',
|
||||
'journey.studio.backCoverInside': 'Binnenkant van de achterkant, blijft leeg',
|
||||
'journey.studio.sections': 'Secties',
|
||||
'journey.studio.content': 'Inhoud',
|
||||
'journey.studio.elements': 'Elementen',
|
||||
@@ -500,6 +502,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': 'Gevuld',
|
||||
'journey.studio.markStyle.outline': 'Omlijnd',
|
||||
'journey.studio.markStyle.stacked': 'Gestapeld',
|
||||
'journey.studio.statsLook.classic': 'Klassiek',
|
||||
'journey.studio.statsLook.poster': 'Poster', // same-as-en
|
||||
'journey.studio.statsLook.ticket': 'Ticket', // same-as-en
|
||||
'journey.studio.statsLook.tiles': 'Tegels',
|
||||
'journey.studio.statsLook.band': 'Band', // same-as-en
|
||||
'journey.studio.statsLook.medal': 'Medaille',
|
||||
'journey.studio.statsLook.odometer': 'Kilometerteller',
|
||||
'journey.studio.statsLook.sign': 'Verkeersbord',
|
||||
'journey.studio.statsLook.note': 'Notitie',
|
||||
'journey.studio.statsLook.sketch': 'Schets',
|
||||
'journey.studio.countryLook.visa': 'Visum',
|
||||
'journey.studio.countryLook.flag': 'Vlag',
|
||||
'journey.studio.countryLook.pin': 'Speld',
|
||||
'journey.studio.countryLook.label': 'Kofferlabel',
|
||||
'journey.studio.countryLook.sketch': 'Schets',
|
||||
'journey.studio.markStyle.ribbon': 'Lint',
|
||||
'journey.studio.markStyle.sticker': 'Sticker', // same-as-en
|
||||
'journey.studio.markStyle.patch': 'Embleem',
|
||||
'journey.studio.markStyle.sketch': 'Schets',
|
||||
'journey.studio.travelFigures': 'Cijfers',
|
||||
'journey.studio.travelMaps': 'Kaarten',
|
||||
'journey.studio.countryLook.classic': 'Klassiek',
|
||||
'journey.studio.countryLook.stamp': 'Stempel',
|
||||
'journey.studio.countryLook.postcard': 'Ansichtkaart',
|
||||
'journey.studio.markStyle.stamp': 'Stempel',
|
||||
'journey.studio.markStyle.tag': 'Label',
|
||||
'journey.studio.markStyle.pill': 'Pil',
|
||||
'journey.studio.pinNumbered': 'Nummers',
|
||||
'journey.studio.pinEnds': 'Start en finish',
|
||||
'journey.studio.icon': 'Icoon',
|
||||
'journey.studio.iconAndLabel': 'Icoon & tekst',
|
||||
'journey.studio.iconOnly': 'Alleen icoon',
|
||||
@@ -547,7 +578,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'Pagina verwijderen',
|
||||
'journey.studio.movePageUp': 'Naar voren',
|
||||
'journey.studio.movePageDown': 'Naar achteren',
|
||||
'journey.studio.beta': 'Bèta',
|
||||
'journey.studio.addProsCons': 'Voor- & nadelen',
|
||||
'journey.studio.showMarks': 'Tekens',
|
||||
'journey.studio.formatCustom': 'Eigen formaat',
|
||||
|
||||
@@ -38,6 +38,7 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.token.accent': 'Accent', // same-as-en
|
||||
'studio.theme.token.accent2': 'Accent 2', // same-as-en
|
||||
'studio.theme.token.accent3': 'Accent 3', // same-as-en
|
||||
'studio.theme.accents': 'Accenten',
|
||||
'studio.theme.fromPhotos': 'Kleuren uit de foto’s',
|
||||
'studio.theme.noPhotoColours': 'De foto’s hebben geen kleur om over te nemen',
|
||||
'studio.theme.fonts': 'Lettertypen',
|
||||
|
||||
@@ -308,7 +308,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Kwadrat 30 × 30 cm',
|
||||
'journey.studio.pages': 'Strony',
|
||||
'journey.studio.cover': 'Okładka',
|
||||
'journey.studio.inspectorEmpty': 'Zaznacz coś na stronie, aby to edytować.',
|
||||
'journey.studio.inspectorEmpty': 'Zaznacz coś do edycji',
|
||||
'journey.studio.autoLayout': 'Układ automatyczny',
|
||||
'journey.studio.export': 'Eksportuj',
|
||||
'journey.studio.day': 'DZIEŃ {day}',
|
||||
@@ -388,6 +388,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeKind.ellipse': 'Elipsa',
|
||||
'journey.studio.focalHint': 'Przeciągnij punkt, aby wybrać, co zostaje w kadrze.',
|
||||
'journey.studio.backCover': 'Tylna okładka',
|
||||
'journey.studio.coverInside': 'Wewnętrzna strona okładki, pozostaje pusta',
|
||||
'journey.studio.backCoverInside': 'Wewnętrzna strona tylnej okładki, pozostaje pusta',
|
||||
'journey.studio.sections': 'Sekcje',
|
||||
'journey.studio.content': 'Treść',
|
||||
'journey.studio.elements': 'Elementy',
|
||||
@@ -516,6 +518,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': 'Pełny',
|
||||
'journey.studio.markStyle.outline': 'Obrysowany',
|
||||
'journey.studio.markStyle.stacked': 'Piętrowy',
|
||||
'journey.studio.statsLook.classic': 'Klasyczny',
|
||||
'journey.studio.statsLook.poster': 'Plakat',
|
||||
'journey.studio.statsLook.ticket': 'Bilet',
|
||||
'journey.studio.statsLook.tiles': 'Kafelki',
|
||||
'journey.studio.statsLook.band': 'Pasek',
|
||||
'journey.studio.statsLook.medal': 'Medal', // same-as-en
|
||||
'journey.studio.statsLook.odometer': 'Licznik kilometrów',
|
||||
'journey.studio.statsLook.sign': 'Drogowskaz',
|
||||
'journey.studio.statsLook.note': 'Notatka',
|
||||
'journey.studio.statsLook.sketch': 'Szkic',
|
||||
'journey.studio.countryLook.visa': 'Wiza',
|
||||
'journey.studio.countryLook.flag': 'Flaga',
|
||||
'journey.studio.countryLook.pin': 'Pinezka',
|
||||
'journey.studio.countryLook.label': 'Nalepka na bagaż',
|
||||
'journey.studio.countryLook.sketch': 'Szkic',
|
||||
'journey.studio.markStyle.ribbon': 'Wstęga',
|
||||
'journey.studio.markStyle.sticker': 'Naklejka',
|
||||
'journey.studio.markStyle.patch': 'Naszywka',
|
||||
'journey.studio.markStyle.sketch': 'Szkic',
|
||||
'journey.studio.travelFigures': 'Liczby',
|
||||
'journey.studio.travelMaps': 'Mapy',
|
||||
'journey.studio.countryLook.classic': 'Klasyczny',
|
||||
'journey.studio.countryLook.stamp': 'Pieczątka',
|
||||
'journey.studio.countryLook.postcard': 'Pocztówka',
|
||||
'journey.studio.markStyle.stamp': 'Pieczątka',
|
||||
'journey.studio.markStyle.tag': 'Zawieszka',
|
||||
'journey.studio.markStyle.pill': 'Kapsułka',
|
||||
'journey.studio.pinNumbered': 'Numery',
|
||||
'journey.studio.pinEnds': 'Start i meta',
|
||||
'journey.studio.icon': 'Ikona',
|
||||
'journey.studio.iconAndLabel': 'Ikona i tekst',
|
||||
'journey.studio.iconOnly': 'Tylko ikona',
|
||||
@@ -563,7 +594,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'Usuń stronę',
|
||||
'journey.studio.movePageUp': 'Przenieś wcześniej',
|
||||
'journey.studio.movePageDown': 'Przenieś później',
|
||||
'journey.studio.beta': 'Beta', // same-as-en
|
||||
'journey.studio.addProsCons': 'Zalety i wady',
|
||||
'journey.studio.showMarks': 'Znaki',
|
||||
'journey.studio.formatCustom': 'Własny format',
|
||||
|
||||
@@ -38,6 +38,7 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.token.accent': 'Akcent',
|
||||
'studio.theme.token.accent2': 'Akcent 2',
|
||||
'studio.theme.token.accent3': 'Akcent 3',
|
||||
'studio.theme.accents': 'Akcenty',
|
||||
'studio.theme.fromPhotos': 'Kolory ze zdjęć',
|
||||
'studio.theme.noPhotoColours': 'Zdjęcia nie mają koloru do przejęcia',
|
||||
'studio.theme.fonts': 'Kroje pisma',
|
||||
|
||||
@@ -308,7 +308,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Квадрат 30 × 30 см',
|
||||
'journey.studio.pages': 'Страницы',
|
||||
'journey.studio.cover': 'Обложка',
|
||||
'journey.studio.inspectorEmpty': 'Выберите что-нибудь на странице, чтобы изменить это.',
|
||||
'journey.studio.inspectorEmpty': 'Выберите элемент',
|
||||
'journey.studio.autoLayout': 'Автомакет',
|
||||
'journey.studio.export': 'Экспорт',
|
||||
'journey.studio.day': 'ДЕНЬ {day}',
|
||||
@@ -387,6 +387,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeKind.ellipse': 'Эллипс',
|
||||
'journey.studio.focalHint': 'Перетащите точку, чтобы выбрать, что останется в кадре.',
|
||||
'journey.studio.backCover': 'Задняя обложка',
|
||||
'journey.studio.coverInside': 'Внутренняя сторона обложки, остаётся пустой',
|
||||
'journey.studio.backCoverInside': 'Внутренняя сторона задней обложки, остаётся пустой',
|
||||
'journey.studio.sections': 'Разделы',
|
||||
'journey.studio.content': 'Содержимое',
|
||||
'journey.studio.elements': 'Элементы',
|
||||
@@ -516,6 +518,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': 'Плашка',
|
||||
'journey.studio.markStyle.outline': 'Контурный',
|
||||
'journey.studio.markStyle.stacked': 'Столбиком',
|
||||
'journey.studio.statsLook.classic': 'Классический',
|
||||
'journey.studio.statsLook.poster': 'Постер',
|
||||
'journey.studio.statsLook.ticket': 'Билет',
|
||||
'journey.studio.statsLook.tiles': 'Плитки',
|
||||
'journey.studio.statsLook.band': 'Лента',
|
||||
'journey.studio.statsLook.medal': 'Медаль',
|
||||
'journey.studio.statsLook.odometer': 'Одометр',
|
||||
'journey.studio.statsLook.sign': 'Дорожный указатель',
|
||||
'journey.studio.statsLook.note': 'Заметка',
|
||||
'journey.studio.statsLook.sketch': 'Набросок',
|
||||
'journey.studio.countryLook.visa': 'Виза',
|
||||
'journey.studio.countryLook.flag': 'Флаг',
|
||||
'journey.studio.countryLook.pin': 'Булавка',
|
||||
'journey.studio.countryLook.label': 'Багажная наклейка',
|
||||
'journey.studio.countryLook.sketch': 'Набросок',
|
||||
'journey.studio.markStyle.ribbon': 'Лента',
|
||||
'journey.studio.markStyle.sticker': 'Наклейка',
|
||||
'journey.studio.markStyle.patch': 'Нашивка',
|
||||
'journey.studio.markStyle.sketch': 'Набросок',
|
||||
'journey.studio.travelFigures': 'Цифры',
|
||||
'journey.studio.travelMaps': 'Карты',
|
||||
'journey.studio.countryLook.classic': 'Классический',
|
||||
'journey.studio.countryLook.stamp': 'Штамп',
|
||||
'journey.studio.countryLook.postcard': 'Открытка',
|
||||
'journey.studio.markStyle.stamp': 'Штамп',
|
||||
'journey.studio.markStyle.tag': 'Бирка',
|
||||
'journey.studio.markStyle.pill': 'Капсула',
|
||||
'journey.studio.pinNumbered': 'Номера',
|
||||
'journey.studio.pinEnds': 'Старт и финиш',
|
||||
'journey.studio.icon': 'Иконка',
|
||||
'journey.studio.iconAndLabel': 'Иконка и текст',
|
||||
'journey.studio.iconOnly': 'Только иконка',
|
||||
@@ -563,7 +594,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'Удалить разворот',
|
||||
'journey.studio.movePageUp': 'Переместить раньше',
|
||||
'journey.studio.movePageDown': 'Переместить позже',
|
||||
'journey.studio.beta': 'Бета',
|
||||
'journey.studio.addProsCons': 'Плюсы и минусы',
|
||||
'journey.studio.showMarks': 'Знаки',
|
||||
'journey.studio.formatCustom': 'Свой формат',
|
||||
|
||||
@@ -38,6 +38,7 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.token.accent': 'Акцент',
|
||||
'studio.theme.token.accent2': 'Акцент 2',
|
||||
'studio.theme.token.accent3': 'Акцент 3',
|
||||
'studio.theme.accents': 'Акценты',
|
||||
'studio.theme.fromPhotos': 'Цвета из фотографий',
|
||||
'studio.theme.noPhotoColours': 'На фотографиях нет цвета, который можно взять',
|
||||
'studio.theme.fonts': 'Шрифты',
|
||||
|
||||
@@ -300,7 +300,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Štvorec 30 × 30 cm',
|
||||
'journey.studio.pages': 'Strany',
|
||||
'journey.studio.cover': 'Obal',
|
||||
'journey.studio.inspectorEmpty': 'Vyberte niečo na strane a upravte to.',
|
||||
'journey.studio.inspectorEmpty': 'Vyberte niečo na úpravu',
|
||||
'journey.studio.autoLayout': 'Automatické rozloženie',
|
||||
'journey.studio.export': 'Exportovať',
|
||||
'journey.studio.day': 'DEŇ {day}',
|
||||
@@ -379,6 +379,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeKind.ellipse': 'Elipsa',
|
||||
'journey.studio.focalHint': 'Potiahnutím bodu zvoľte, čo zostane v zábere.',
|
||||
'journey.studio.backCover': 'Zadný obal',
|
||||
'journey.studio.coverInside': 'Vnútorná strana obálky, zostáva prázdna',
|
||||
'journey.studio.backCoverInside': 'Vnútorná strana zadnej obálky, zostáva prázdna',
|
||||
'journey.studio.sections': 'Sekcie',
|
||||
'journey.studio.content': 'Obsah',
|
||||
'journey.studio.elements': 'Prvky',
|
||||
@@ -508,6 +510,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': 'Vyplnený',
|
||||
'journey.studio.markStyle.outline': 'Obrysový',
|
||||
'journey.studio.markStyle.stacked': 'Nad sebou',
|
||||
'journey.studio.statsLook.classic': 'Klasický',
|
||||
'journey.studio.statsLook.poster': 'Plagát',
|
||||
'journey.studio.statsLook.ticket': 'Lístok',
|
||||
'journey.studio.statsLook.tiles': 'Dlaždice',
|
||||
'journey.studio.statsLook.band': 'Pás',
|
||||
'journey.studio.statsLook.medal': 'Medaila',
|
||||
'journey.studio.statsLook.odometer': 'Tachometer',
|
||||
'journey.studio.statsLook.sign': 'Smerovník',
|
||||
'journey.studio.statsLook.note': 'Poznámka',
|
||||
'journey.studio.statsLook.sketch': 'Skica',
|
||||
'journey.studio.countryLook.visa': 'Vízum',
|
||||
'journey.studio.countryLook.flag': 'Vlajka',
|
||||
'journey.studio.countryLook.pin': 'Špendlík',
|
||||
'journey.studio.countryLook.label': 'Nálepka na kufor',
|
||||
'journey.studio.countryLook.sketch': 'Skica',
|
||||
'journey.studio.markStyle.ribbon': 'Stuha',
|
||||
'journey.studio.markStyle.sticker': 'Nálepka',
|
||||
'journey.studio.markStyle.patch': 'Nášivka',
|
||||
'journey.studio.markStyle.sketch': 'Skica',
|
||||
'journey.studio.travelFigures': 'Čísla',
|
||||
'journey.studio.travelMaps': 'Mapy',
|
||||
'journey.studio.countryLook.classic': 'Klasický',
|
||||
'journey.studio.countryLook.stamp': 'Pečiatka',
|
||||
'journey.studio.countryLook.postcard': 'Pohľadnica',
|
||||
'journey.studio.markStyle.stamp': 'Pečiatka',
|
||||
'journey.studio.markStyle.tag': 'Visačka',
|
||||
'journey.studio.markStyle.pill': 'Kapsula',
|
||||
'journey.studio.pinNumbered': 'Čísla',
|
||||
'journey.studio.pinEnds': 'Štart a cieľ',
|
||||
'journey.studio.icon': 'Ikona',
|
||||
'journey.studio.iconAndLabel': 'Ikona a text',
|
||||
'journey.studio.iconOnly': 'Iba ikona',
|
||||
@@ -555,7 +586,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'Odstrániť dvojstranu',
|
||||
'journey.studio.movePageUp': 'Posunúť dopredu',
|
||||
'journey.studio.movePageDown': 'Posunúť dozadu',
|
||||
'journey.studio.beta': 'Beta', // same-as-en
|
||||
'journey.studio.addProsCons': 'Klady a zápory',
|
||||
'journey.studio.showMarks': 'Značky',
|
||||
'journey.studio.formatCustom': 'Vlastná veľkosť',
|
||||
|
||||
@@ -38,6 +38,7 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.token.accent': 'Akcent',
|
||||
'studio.theme.token.accent2': 'Akcent 2',
|
||||
'studio.theme.token.accent3': 'Akcent 3',
|
||||
'studio.theme.accents': 'Akcenty',
|
||||
'studio.theme.fromPhotos': 'Farby z fotiek',
|
||||
'studio.theme.noPhotoColours': 'Fotky nemajú žiadnu farbu na prevzatie',
|
||||
'studio.theme.fonts': 'Písma',
|
||||
|
||||
@@ -294,7 +294,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Kvadrat 30 × 30 cm',
|
||||
'journey.studio.pages': 'Sidor',
|
||||
'journey.studio.cover': 'Omslag',
|
||||
'journey.studio.inspectorEmpty': 'Markera något på sidan för att redigera det.',
|
||||
'journey.studio.inspectorEmpty': 'Markera något att redigera',
|
||||
'journey.studio.autoLayout': 'Automatisk layout',
|
||||
'journey.studio.export': 'Exportera',
|
||||
'journey.studio.day': 'DAG {day}',
|
||||
@@ -368,6 +368,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeKind.ellipse': 'Ellips',
|
||||
'journey.studio.focalHint': 'Dra punkten för att välja vad som ska stanna kvar i bild.',
|
||||
'journey.studio.backCover': 'Baksida',
|
||||
'journey.studio.coverInside': 'Omslagets insida, lämnas tom',
|
||||
'journey.studio.backCoverInside': 'Baksidans insida, lämnas tom',
|
||||
'journey.studio.sections': 'Avsnitt',
|
||||
'journey.studio.content': 'Innehåll',
|
||||
'journey.studio.elements': 'Element',
|
||||
@@ -496,6 +498,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': 'Fylld',
|
||||
'journey.studio.markStyle.outline': 'Konturerad',
|
||||
'journey.studio.markStyle.stacked': 'Staplad',
|
||||
'journey.studio.statsLook.classic': 'Klassisk',
|
||||
'journey.studio.statsLook.poster': 'Affisch',
|
||||
'journey.studio.statsLook.ticket': 'Biljett',
|
||||
'journey.studio.statsLook.tiles': 'Rutor',
|
||||
'journey.studio.statsLook.band': 'Band', // same-as-en
|
||||
'journey.studio.statsLook.medal': 'Medalj',
|
||||
'journey.studio.statsLook.odometer': 'Vägmätare',
|
||||
'journey.studio.statsLook.sign': 'Vägskylt',
|
||||
'journey.studio.statsLook.note': 'Anteckning',
|
||||
'journey.studio.statsLook.sketch': 'Skiss',
|
||||
'journey.studio.countryLook.visa': 'Visum',
|
||||
'journey.studio.countryLook.flag': 'Flagga',
|
||||
'journey.studio.countryLook.pin': 'Nål',
|
||||
'journey.studio.countryLook.label': 'Bagageetikett',
|
||||
'journey.studio.countryLook.sketch': 'Skiss',
|
||||
'journey.studio.markStyle.ribbon': 'Band',
|
||||
'journey.studio.markStyle.sticker': 'Klistermärke',
|
||||
'journey.studio.markStyle.patch': 'Tygmärke',
|
||||
'journey.studio.markStyle.sketch': 'Skiss',
|
||||
'journey.studio.travelFigures': 'Siffror',
|
||||
'journey.studio.travelMaps': 'Kartor',
|
||||
'journey.studio.countryLook.classic': 'Klassisk',
|
||||
'journey.studio.countryLook.stamp': 'Stämpel',
|
||||
'journey.studio.countryLook.postcard': 'Vykort',
|
||||
'journey.studio.markStyle.stamp': 'Stämpel',
|
||||
'journey.studio.markStyle.tag': 'Etikett',
|
||||
'journey.studio.markStyle.pill': 'Piller',
|
||||
'journey.studio.pinNumbered': 'Nummer',
|
||||
'journey.studio.pinEnds': 'Start och mål',
|
||||
'journey.studio.icon': 'Ikon',
|
||||
'journey.studio.iconAndLabel': 'Ikon & text',
|
||||
'journey.studio.iconOnly': 'Bara ikon',
|
||||
@@ -543,7 +574,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'Ta bort uppslag',
|
||||
'journey.studio.movePageUp': 'Flytta tidigare',
|
||||
'journey.studio.movePageDown': 'Flytta senare',
|
||||
'journey.studio.beta': 'Beta', // same-as-en
|
||||
'journey.studio.addProsCons': 'För- och nackdelar',
|
||||
'journey.studio.showMarks': 'Tecken',
|
||||
'journey.studio.formatCustom': 'Eget format',
|
||||
|
||||
@@ -38,6 +38,7 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.token.accent': 'Accent', // same-as-en
|
||||
'studio.theme.token.accent2': 'Accent 2', // same-as-en
|
||||
'studio.theme.token.accent3': 'Accent 3', // same-as-en
|
||||
'studio.theme.accents': 'Accenter',
|
||||
'studio.theme.fromPhotos': 'Färger från bilderna',
|
||||
'studio.theme.noPhotoColours': 'Bilderna har ingen färg att ta',
|
||||
'studio.theme.fonts': 'Typsnitt',
|
||||
|
||||
@@ -283,7 +283,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'สี่เหลี่ยมจัตุรัส 30 × 30 ซม',
|
||||
'journey.studio.pages': 'หน้า',
|
||||
'journey.studio.cover': 'ปก',
|
||||
'journey.studio.inspectorEmpty': 'เลือกบางอย่างบนเพจเพื่อแก้ไข',
|
||||
'journey.studio.inspectorEmpty': 'เลือกสิ่งที่จะแก้ไข',
|
||||
'journey.studio.autoLayout': 'เค้าโครงอัตโนมัติ',
|
||||
'journey.studio.export': 'ส่งออก',
|
||||
'journey.studio.day': 'วันที่ {day}',
|
||||
@@ -354,6 +354,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeKind.ellipse': 'วงรี',
|
||||
'journey.studio.focalHint': 'ลากจุดเพื่อเลือกสิ่งที่อยู่ในเฟรม',
|
||||
'journey.studio.backCover': 'ฝาหลัง',
|
||||
'journey.studio.coverInside': 'ด้านในของปกหน้า เว้นว่างไว้',
|
||||
'journey.studio.backCoverInside': 'ด้านในของปกหลัง เว้นว่างไว้',
|
||||
'journey.studio.sections': 'ส่วนต่างๆ',
|
||||
'journey.studio.content': 'เนื้อหา',
|
||||
'journey.studio.elements': 'องค์ประกอบ',
|
||||
@@ -482,6 +484,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': 'ชิป',
|
||||
'journey.studio.markStyle.outline': 'โครงร่าง',
|
||||
'journey.studio.markStyle.stacked': 'ซ้อนกัน',
|
||||
'journey.studio.statsLook.classic': 'คลาสสิก',
|
||||
'journey.studio.statsLook.poster': 'โปสเตอร์',
|
||||
'journey.studio.statsLook.ticket': 'ตั๋ว',
|
||||
'journey.studio.statsLook.tiles': 'ไทล์',
|
||||
'journey.studio.statsLook.band': 'แถบ',
|
||||
'journey.studio.statsLook.medal': 'เหรียญ',
|
||||
'journey.studio.statsLook.odometer': 'มาตรวัดระยะทาง',
|
||||
'journey.studio.statsLook.sign': 'ป้ายบอกทาง',
|
||||
'journey.studio.statsLook.note': 'โน้ต',
|
||||
'journey.studio.statsLook.sketch': 'ภาพร่าง',
|
||||
'journey.studio.countryLook.visa': 'วีซ่า',
|
||||
'journey.studio.countryLook.flag': 'ธง',
|
||||
'journey.studio.countryLook.pin': 'หมุด',
|
||||
'journey.studio.countryLook.label': 'ป้ายกระเป๋า',
|
||||
'journey.studio.countryLook.sketch': 'ภาพร่าง',
|
||||
'journey.studio.markStyle.ribbon': 'ริบบิ้น',
|
||||
'journey.studio.markStyle.sticker': 'สติกเกอร์',
|
||||
'journey.studio.markStyle.patch': 'อาร์มปัก',
|
||||
'journey.studio.markStyle.sketch': 'ภาพร่าง',
|
||||
'journey.studio.travelFigures': 'ตัวเลข',
|
||||
'journey.studio.travelMaps': 'แผนที่',
|
||||
'journey.studio.countryLook.classic': 'คลาสสิก',
|
||||
'journey.studio.countryLook.stamp': 'ตราประทับ',
|
||||
'journey.studio.countryLook.postcard': 'โปสการ์ด',
|
||||
'journey.studio.markStyle.stamp': 'ตราประทับ',
|
||||
'journey.studio.markStyle.tag': 'ป้ายห้อย',
|
||||
'journey.studio.markStyle.pill': 'แคปซูล',
|
||||
'journey.studio.pinNumbered': 'ตัวเลข',
|
||||
'journey.studio.pinEnds': 'จุดเริ่มต้นและปลายทาง',
|
||||
'journey.studio.icon': 'ไอคอน',
|
||||
'journey.studio.iconAndLabel': 'ไอคอนและข้อความ',
|
||||
'journey.studio.iconOnly': 'ไอคอนเท่านั้น',
|
||||
@@ -529,7 +560,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'ลบสเปรด',
|
||||
'journey.studio.movePageUp': 'ย้ายมาก่อน',
|
||||
'journey.studio.movePageDown': 'ย้ายในภายหลัง',
|
||||
'journey.studio.beta': 'เบต้า',
|
||||
'journey.studio.addProsCons': 'ข้อดีข้อเสีย',
|
||||
'journey.studio.showMarks': 'เครื่องหมาย',
|
||||
'journey.studio.formatCustom': 'ขนาดที่กำหนดเอง',
|
||||
|
||||
@@ -37,6 +37,7 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.token.accent': 'สีเน้น',
|
||||
'studio.theme.token.accent2': 'สีเน้น 2',
|
||||
'studio.theme.token.accent3': 'สีเน้น 3',
|
||||
'studio.theme.accents': 'สีเน้น',
|
||||
'studio.theme.fromPhotos': 'สีจากรูปภาพ',
|
||||
'studio.theme.noPhotoColours': 'รูปภาพไม่มีสีให้นำมาใช้',
|
||||
'studio.theme.fonts': 'แบบอักษร',
|
||||
|
||||
@@ -293,7 +293,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.formatSquare30': 'Kare 30 × 30 cm',
|
||||
'journey.studio.pages': 'Sayfalar',
|
||||
'journey.studio.cover': 'Kapak',
|
||||
'journey.studio.inspectorEmpty': 'Düzenlemek için sayfada bir öğe seç.',
|
||||
'journey.studio.inspectorEmpty': 'Düzenlemek için bir öğe seç',
|
||||
'journey.studio.autoLayout': 'Otomatik yerleşim',
|
||||
'journey.studio.export': 'Dışa aktar',
|
||||
'journey.studio.day': '{day}. GÜN',
|
||||
@@ -369,6 +369,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.shapeKind.ellipse': 'Elips',
|
||||
'journey.studio.focalHint': 'Karede neyin kalacağını seçmek için noktayı sürükle.',
|
||||
'journey.studio.backCover': 'Arka kapak',
|
||||
'journey.studio.coverInside': 'Kapağın iç yüzü, boş kalır',
|
||||
'journey.studio.backCoverInside': 'Arka kapağın iç yüzü, boş kalır',
|
||||
'journey.studio.sections': 'Bölümler',
|
||||
'journey.studio.content': 'İçerik',
|
||||
'journey.studio.elements': 'Öğeler',
|
||||
@@ -499,6 +501,35 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.markStyle.chip': 'Rozet',
|
||||
'journey.studio.markStyle.outline': 'Konturlu',
|
||||
'journey.studio.markStyle.stacked': 'Üst üste',
|
||||
'journey.studio.statsLook.classic': 'Klasik',
|
||||
'journey.studio.statsLook.poster': 'Poster', // same-as-en
|
||||
'journey.studio.statsLook.ticket': 'Bilet',
|
||||
'journey.studio.statsLook.tiles': 'Kutucuklar',
|
||||
'journey.studio.statsLook.band': 'Şerit',
|
||||
'journey.studio.statsLook.medal': 'Madalya',
|
||||
'journey.studio.statsLook.odometer': 'Kilometre sayacı',
|
||||
'journey.studio.statsLook.sign': 'Yol tabelası',
|
||||
'journey.studio.statsLook.note': 'Not',
|
||||
'journey.studio.statsLook.sketch': 'Eskiz',
|
||||
'journey.studio.countryLook.visa': 'Vize',
|
||||
'journey.studio.countryLook.flag': 'Bayrak',
|
||||
'journey.studio.countryLook.pin': 'İğne',
|
||||
'journey.studio.countryLook.label': 'Bavul etiketi',
|
||||
'journey.studio.countryLook.sketch': 'Eskiz',
|
||||
'journey.studio.markStyle.ribbon': 'Kurdele',
|
||||
'journey.studio.markStyle.sticker': 'Çıkartma',
|
||||
'journey.studio.markStyle.patch': 'Arma',
|
||||
'journey.studio.markStyle.sketch': 'Eskiz',
|
||||
'journey.studio.travelFigures': 'Rakamlar',
|
||||
'journey.studio.travelMaps': 'Haritalar',
|
||||
'journey.studio.countryLook.classic': 'Klasik',
|
||||
'journey.studio.countryLook.stamp': 'Damga',
|
||||
'journey.studio.countryLook.postcard': 'Kartpostal',
|
||||
'journey.studio.markStyle.stamp': 'Damga',
|
||||
'journey.studio.markStyle.tag': 'Etiket',
|
||||
'journey.studio.markStyle.pill': 'Kapsül',
|
||||
'journey.studio.pinNumbered': 'Numaralar',
|
||||
'journey.studio.pinEnds': 'Başlangıç ve bitiş',
|
||||
'journey.studio.icon': 'Simge',
|
||||
'journey.studio.iconAndLabel': 'Simge ve yazı',
|
||||
'journey.studio.iconOnly': 'Sadece simge',
|
||||
@@ -546,7 +577,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'Sayfayı sil',
|
||||
'journey.studio.movePageUp': 'Öne al',
|
||||
'journey.studio.movePageDown': 'Arkaya al',
|
||||
'journey.studio.beta': 'Beta', // same-as-en
|
||||
'journey.studio.addProsCons': 'Artılar ve Eksiler',
|
||||
'journey.studio.showMarks': 'İşaretler',
|
||||
'journey.studio.formatCustom': 'Özel boyut',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user