diff --git a/apps/postcards/index.html b/apps/postcards/index.html index bf5ea54..fd223bb 100644 --- a/apps/postcards/index.html +++ b/apps/postcards/index.html @@ -53,18 +53,19 @@ - + - - - Postcards @@ -83,7 +84,10 @@ -
+ +

Postcards

diff --git a/apps/postcards/public/boot-guard.js b/apps/postcards/public/boot-guard.js index c7f48d9..fcfbc36 100644 --- a/apps/postcards/public/boot-guard.js +++ b/apps/postcards/public/boot-guard.js @@ -64,9 +64,12 @@ }); shown.appendChild(p); shown.appendChild(b); - // Into the app's own container, empty while the app has not run; the page - // locks body scrolling and gives #root the full height. - (document.getElementById("root") || document.body).appendChild(shown); + // Into the app's own container, in place of the static shell index.html + // paints there (a full-height app frame that left the message below the + // fold); the page locks body scrolling and gives #root the full height. + var root = document.getElementById("root"); + if (root) root.replaceChildren(shown); + else document.body.appendChild(shown); } // The entry script, or a module it imports (modulepreloaded), that fails to diff --git a/apps/postcards/public/fonts/PROVENANCE.md b/apps/postcards/public/fonts/PROVENANCE.md new file mode 100644 index 0000000..1698c80 --- /dev/null +++ b/apps/postcards/public/fonts/PROVENANCE.md @@ -0,0 +1,14 @@ +# Font provenance + +- `TwemojiCountryFlags.woff2` — the country-flag glyphs (regional-indicator + pairs, plus the England/Scotland/Wales tag sequences) of **Twemoji**, graphics + © Twitter, Inc and other contributors, licensed CC-BY 4.0 + (https://creativecommons.org/licenses/by/4.0/). Packaged as a flag-only font by + the MIT-licensed `country-flag-emoji-polyfill` 0.1.10 by TalkJS + (https://github.com/talkjs/country-flag-emoji-polyfill); both licences are in + `TwemojiCountryFlags-LICENSE.md`. Unmodified. + +It is bundled so a flag renders on every platform, offline: Windows and minimal +Linux have no colour flag glyphs and show the two letters or an empty box. The +`@font-face` in `src/styles.css` limits it to the regional indicators with +`unicode-range`, so a browser downloads it only once a flag is on screen. diff --git a/apps/postcards/public/fonts/TwemojiCountryFlags-LICENSE.md b/apps/postcards/public/fonts/TwemojiCountryFlags-LICENSE.md new file mode 100644 index 0000000..adff684 --- /dev/null +++ b/apps/postcards/public/fonts/TwemojiCountryFlags-LICENSE.md @@ -0,0 +1,54 @@ +## License for the Code (MIT) + +Copyright (c) 2022 TalkJS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## License for the Visual Design + +The Emoji art in the "Twemoji Country Flags" font comes from [Twemoji](https://twitter.github.io/twemoji), +and is used and redistributed under the CC-BY-4.0 [license terms](https://github.com/twitter/twemoji#license) +offered by the Twemoji project. + +### Creative Commons Attribution 4.0 International (CC BY 4.0) +https://creativecommons.org/licenses/by/4.0/legalcode +or for the human readable summary: https://creativecommons.org/licenses/by/4.0/ + + +#### You are free to: +**Share** — copy and redistribute the material in any medium or format + +**Adapt** — remix, transform, and build upon the material for any purpose, even commercially. + +The licensor cannot revoke these freedoms as long as you follow the license terms. + + +#### Under the following terms: +**Attribution** — You must give appropriate credit, provide a link to the license, +and indicate if changes were made. +You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use. + +**No additional restrictions** — You may not apply legal terms or **technological measures** +that legally restrict others from doing anything the license permits. + +#### Notices: +You do not have to comply with the license for elements of the material in the public domain +or where your use is permitted by an applicable exception or limitation. No warranties are given. +The license may not give you all of the permissions necessary for your intended use. +For example, other rights such as publicity, privacy, or moral rights may limit how you use the material. diff --git a/apps/postcards/public/fonts/TwemojiCountryFlags.woff2 b/apps/postcards/public/fonts/TwemojiCountryFlags.woff2 new file mode 100644 index 0000000..b9d6ea8 Binary files /dev/null and b/apps/postcards/public/fonts/TwemojiCountryFlags.woff2 differ diff --git a/apps/postcards/src/app/App.tsx b/apps/postcards/src/app/App.tsx index cd93b4a..4d0cfe7 100644 --- a/apps/postcards/src/app/App.tsx +++ b/apps/postcards/src/app/App.tsx @@ -20,11 +20,23 @@ import { LoadBoundary } from "../ui/LoadFailure"; import { handoffRequested } from "../lib/moved/moved"; import { followOtherTabs, loadPortable } from "../lib/store/portable"; import { ConnectionStatus } from "../ui/ConnectionStatus"; -import { MapIcon, ChartIcon, ListIcon, RouteIcon, BookIcon, GearIcon, InfoIcon } from "../ui/icons"; +import { + MapIcon, + ChartIcon, + ListIcon, + RouteIcon, + BookIcon, + GearIcon, + InfoIcon, + StarIcon, +} from "../ui/icons"; import { useState, type PointerEvent as ReactPointerEvent } from "react"; import { useInstallPrompt } from "../lib/hooks/useInstallPrompt"; import { useAutoSync } from "../lib/hooks/useAutoSync"; import { useT, type MessageKey } from "../lib/i18n"; +import { HOME, parseRoute, routeHash, type Route } from "./route"; +import { useReferenceExtrasPending } from "../lib/reference/useGazetteer"; +import { requestStations } from "../lib/reference/referenceData"; // Code-split MapLibre so it loads only when the map is shown. const MapScreen = lazy(() => @@ -50,6 +62,16 @@ const TABS: { id: Tab; label: MessageKey; keys: string[]; Icon: () => JSX.Elemen const DIALOG_LAYER_SELECTOR = ".modal-backdrop, .lightbox, .maplibregl-popup:not(.map-keep-hidden *), .journal-composer-busy"; +// Land on the screen the address names (a reload, a shared link) before the +// first render, so reloading Places never spins the map up first. +const initialRoute = typeof location === "undefined" ? null : parseRoute(location.hash); +if (initialRoute) useUi.setState(initialRoute); + +/** What the address shows for the current screen (the composers stay out). */ +function currentRoute(s: Route): Route { + return { tab: s.tab, cityPageId: s.cityPageId, countryPageId: s.countryPageId }; +} + // First run: show the "How it works" intro once so a newcomer learns what the // app is and what's optionally downloadable, before touching anything. Stored, // so it never reappears; the top-bar button still opens it anytime. @@ -100,7 +122,21 @@ export function App() { } }; const mapVisible = tab === "map" && !cityPageId && !countryPageId && !tripEditId && !storyEditId; + // Airports, heritage sites and stations land a moment after the first paint. + // The map, Places, Stats and country pages refresh when they do; trips + // (distances), the journal and its composer (story maps) and a place's page + // read them once, so a reload straight onto one waits that moment instead of + // showing it half-resolved. + const extrasPending = useReferenceExtrasPending(); + const waitForExtras = + extrasPending && + (!!cityPageId || !!tripEditId || !!storyEditId || tab === "trips" || tab === "journal"); const firstRender = useRef(true); + // The railway stations wait for the map to load (see loadExtras); every other + // screen may read them, and has no map to compete with, so it asks at once. + useEffect(() => { + if (!mapVisible) requestStations(); + }, [mapVisible]); // Scroll memory.
is the single scroll container reused across tabs and // detail pages, so its scrollTop leaks between views: open a city while scrolled @@ -232,45 +268,74 @@ export function App() { // Back NEVER quits the app: at the home screen (map, empty history) it just // re-arms and stays put — like a native app, where you leave with the home/tab // gesture, not by backing out into a blank page. - useEffect(() => { - const arm = () => history.pushState({ pc: true }, ""); - arm(); - function onPop() { + // The screen also lives in the address (route.ts): each navigation pushes an + // entry carrying its position, so a reload lands where you were, Back after a + // reload walks the screens before it, and Forward re-opens what Back left. + // A layout effect, so a Back pressed as soon as the screen shows is caught. + useLayoutEffect(() => { + const state = history.state as { pc?: unknown } | null; + let at = typeof state?.pc === "number" ? state.pc : 0; + const hashNow = () => routeHash(currentRoute(useUi.getState())); + const onScreen = () => routeHash(parseRoute(location.hash) ?? HOME) === hashNow(); + // The screen came from the address, so it is left as it is (its query and + // a fragment naming no screen included). + history.replaceState({ ...state, pc: at }, ""); + // Point the address at the screen: a new entry when the screen moved, and + // one spare entry above the first, so Back from home has one to consume. + const sync = () => { + if (onScreen() && at > 0) return; + at += 1; + const url = onScreen() ? undefined : hashNow() || location.pathname + location.search; + history.pushState({ pc: at }, "", url); + }; + sync(); + const unsubscribe = useUi.subscribe((s, prev) => { + if ( + s.tab !== prev.tab || + s.cityPageId !== prev.cityPageId || + s.countryPageId !== prev.countryPageId + ) + sync(); + }); + function onPop(e: PopStateEvent) { + const to = (e.state as { pc?: unknown } | null)?.pc; + // A fragment link (the skip link) makes an entry of its own: not a screen. + if (typeof to !== "number") return; + const forward = to > at; + at = to; const ui = useUi.getState(); + const landed = parseRoute(location.hash); + if (forward) { + if (landed) ui.openRoute(landed); + sync(); + return; + } const dialogOpen = !!document.querySelector(DIALOG_LAYER_SELECTOR); if (dialogOpen) { // Let the open layer close via its own Escape handler. window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); - arm(); - return; - } - // Step out of a local sub-view first (mirrors Escape), then the LAST - // screen: pop the app's own navigation history. - if (runEscapeInterceptors()) { - arm(); - return; - } - if (ui.goBack()) { - arm(); - return; - } - // A detail page is still open with no history behind it (e.g. deep-linked or - // opened from search): close it in place — Back must never fall through and - // quit the app while you're looking at a city/country page. - if (ui.cityPageId || ui.countryPageId || ui.tripEditId || ui.storyEditId) { + } else if (runEscapeInterceptors()) { + // Stepped out of a local sub-view first (mirrors Escape). + } else if (ui.goBack()) { + // The LAST screen: popped the app's own navigation history. + } else if (landed) { + // No history of its own (after a reload): the screen this entry names. + useUi.setState({ ...landed, tripEditId: null, storyEditId: null }); + } else if (ui.cityPageId || ui.countryPageId || ui.tripEditId || ui.storyEditId) { + // A detail page with nothing behind it: close it in place — Back must + // never fall through and quit the app on a city/country page. ui.closePages(); - arm(); - return; } - // At the home screen (map) with nothing left in history: DON'T let Back quit - // the app — re-arm so the map is the terminal home for the Back gesture - // (matches a native app; use the tab/home gesture to actually leave). Fixes + // At the home screen with nothing left, this re-arms instead of letting + // Back quit the app (use the tab/home gesture to actually leave). Fixes // "map → places → country → back back … quit the application". - arm(); + sync(); } window.addEventListener("popstate", onPop); - return () => window.removeEventListener("popstate", onPop); - // eslint-disable-next-line react-hooks/exhaustive-deps + return () => { + window.removeEventListener("popstate", onPop); + unsubscribe(); + }; }, []); const currentTab = TABS.find((x) => x.id === tab); @@ -324,7 +389,7 @@ export function App() { aria-label={t("topbar.githubStar")} > - ⭐ + {t("topbar.github")} @@ -423,7 +488,11 @@ export function App() { )} - {cityPageId ? ( + {waitForExtras ? ( +

+ {t("app.loading")} +

+ ) : cityPageId ? ( useUi.getState().closeCity()} /> ) : countryPageId ? ( useUi.getState().closeCity()} /> diff --git a/apps/postcards/src/app/route.ts b/apps/postcards/src/app/route.ts new file mode 100644 index 0000000..3096e2a --- /dev/null +++ b/apps/postcards/src/app/route.ts @@ -0,0 +1,46 @@ +import type { Tab } from "../lib/store/useUi"; + +// The screen in the address bar, so a reload lands where you were and a link +// opens that screen: `#/places`, `#/map/city/2988507`, `#/stats/country/FR`. +// Hash-based because static hosts (GitHub Pages, the native wrap) serve only +// the one index.html. The home screen (the map, no page) is the bare address, +// and so is any fragment that names no screen. The trip and postcard +// composers are unsaved forms, so they stay out. + +export interface Route { + tab: Tab; + cityPageId: string | null; + countryPageId: string | null; +} + +const TABS: readonly Tab[] = ["map", "places", "trips", "journal", "stats", "settings"]; + +export const HOME: Route = { tab: "map", cityPageId: null, countryPageId: null }; + +/** The hash naming a screen; "" for home. */ +export function routeHash(r: Route): string { + if (r.tab === "map" && !r.cityPageId && !r.countryPageId) return ""; + const page = r.cityPageId + ? `/city/${encodeURIComponent(r.cityPageId)}` + : r.countryPageId + ? `/country/${r.countryPageId}` + : ""; + return `#/${r.tab}${page}`; +} + +/** The route a hash names, or null for an empty or unknown one. */ +export function parseRoute(hash: string): Route | null { + const [tab, kind, id, ...rest] = hash.replace(/^#\/?/, "").split("/"); + if (!TABS.includes(tab as Tab) || rest.length) return null; + const route: Route = { tab: tab as Tab, cityPageId: null, countryPageId: null }; + if (kind === undefined) return route; + if (kind === "city" && id) { + try { + return { ...route, cityPageId: decodeURIComponent(id) }; + } catch { + return null; + } + } + if (kind === "country" && id && /^[A-Z]{2}$/.test(id)) return { ...route, countryPageId: id }; + return null; +} diff --git a/apps/postcards/src/features/backup/Backup.tsx b/apps/postcards/src/features/backup/Backup.tsx index 165f317..dc0ce93 100644 --- a/apps/postcards/src/features/backup/Backup.tsx +++ b/apps/postcards/src/features/backup/Backup.tsx @@ -16,6 +16,7 @@ import { snoozeReminder, } from "../../lib/backupReminder"; import { useT } from "../../lib/i18n"; +import type { Story, Trip, Visit } from "../../lib/schema/models"; /** * Get the file to the user, wherever they want it. Inside the native wrap @@ -62,6 +63,64 @@ async function deliverBlob(filename: string, blob: Blob, type: string): Promise< await downloadBlob(filename, blob); } +/** The full .json backup, delivered, and the reminder clock reset: a places- + * only .csv/.md export is a share, not a backup. */ +export async function backUpNow(visits: Visit[], trips: Trip[], stories: Story[]): Promise { + // Loaded on click: the codec pulls in the Zod schemas (~65 KB min), which + // nothing on the startup path needs — keep them out of the boot chunk. + const { serializeFile, EXPORT_FILENAME } = await import("./exportJson"); + await deliver(EXPORT_FILENAME, serializeFile(visits, trips, stories), "application/json"); + markBackedUp(Date.now()); +} + +/** "It's been N days since your last backup": the nudge Settings and Places + * both show once a backup is due (lib/backupReminder decides when). */ +export function BackupReminder({ + onBackUp, + onLater, + compact = false, +}: { + onBackUp: () => void; + onLater: () => void; + /** One slim line, for a screen that is about something else. */ + compact?: boolean; +}) { + const t = useT(); + const daysSince = daysSinceBackup(Date.now()); + return ( +
+ 🛟 + + {daysSince == null + ? t("backup.reminder.never") + : t.plural("backup.reminder.days", daysSince)}{" "} + {t("backup.reminder.suffix")} + + + + + +
+ ); +} + export function Backup() { const t = useT(); const ref = useMemo(() => getReferenceData(), []); @@ -76,18 +135,12 @@ export function Backup() { const RESET_WORD = "RESET"; const hasData = visits.length > 0 || trips.length > 0 || stories.length > 0; // Whether to nudge a backup right now (computed once on open, editable by the - // export/snooze actions). daysSince is for the wording ("N days" vs "never"). + // export/snooze actions). const [reminderDue, setReminderDue] = useState(() => isBackupDue(hasData, Date.now())); - const daysSince = daysSinceBackup(Date.now()); async function exportJson() { try { - // Loaded on click: the codec pulls in the Zod schemas (~65 KB min), which - // nothing on the startup path needs — keep them out of the boot chunk. - const { serializeFile, EXPORT_FILENAME } = await import("./exportJson"); - await deliver(EXPORT_FILENAME, serializeFile(visits, trips, stories), "application/json"); - // A full .json export is a real backup — reset the reminder clock. - markBackedUp(Date.now()); + await backUpNow(visits, trips, stories); setReminderDue(false); } catch { setMessage({ kind: "err", text: t("backup.msg.exportJsonErr") }); @@ -212,36 +265,7 @@ export function Backup() { {reminderDue && ( -
- 🛟 - - {daysSince == null - ? t("backup.reminder.never") - : t.plural("backup.reminder.days", daysSince)}{" "} - {t("backup.reminder.suffix")} - - - - - -
+ void exportJson()} onLater={() => setReminderDue(false)} /> )}

{t("backup.intro")}

diff --git a/apps/postcards/src/features/map/MapScreen.tsx b/apps/postcards/src/features/map/MapScreen.tsx index b1f0ad5..66cc162 100644 --- a/apps/postcards/src/features/map/MapScreen.tsx +++ b/apps/postcards/src/features/map/MapScreen.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { getReferenceData } from "../../lib/reference/referenceData"; +import { getReferenceData, requestStations } from "../../lib/reference/referenceData"; import { useGazetteerGeneration } from "../../lib/reference/useGazetteer"; import { useVisits } from "../../lib/store/useVisits"; import { useTrips } from "../../lib/store/useTrips"; @@ -35,6 +35,7 @@ import { import { activeChips } from "../filter/applyFilters"; import { FilterPanel } from "../../ui/FilterPanel"; import { FilterSummary } from "../../ui/FilterSummary"; +import { CityIcon, MonumentIcon, PlaneIcon, TrainIcon } from "../../ui/icons"; import { useT, type MessageKey } from "../../lib/i18n"; // Fewer rows, faster everything: the list pages in small steps, and the @@ -280,6 +281,11 @@ export function MapScreen({ active = true }: { active?: boolean } = {}) { const [layersOpen, setLayersOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false); const mode = filters.mode; + // The Stations mode shows nothing but stations: fetch them without waiting + // for the map to load. + useEffect(() => { + if (mode === "stations") requestStations(); + }, [mode]); const [dark, setDark] = useState(() => resolveDark(theme)); // Offer the offline street basemap only when a PMTiles pack is actually @@ -529,9 +535,11 @@ export function MapScreen({ active = true }: { active?: boolean } = {}) { return null; // visits deliberately via getState() — the header count refreshing on a // check is fine to defer to the next bounds/mode change. The date window / - // folder ARE deps so the "seen" counts re-derive when the selection changes. + // folder ARE deps so the "seen" counts re-derive when the selection changes, + // and gazGen so the list fills in when the monuments, airports and stations + // land after the first paint. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mode, bounds, ref, dateFilter, folder]); + }, [mode, bounds, ref, dateFilter, folder, gazGen]); // The monument/airport list AFTER the visited/hide-visited filter — hoisted so // the list can show an honest empty message when the filter matches nothing // (before, the chips floated above a blank void and it read as broken). @@ -597,9 +605,9 @@ export function MapScreen({ active = true }: { active?: boolean } = {}) { if (place.kind === "city") { const c = ref.cityById(place.id); const region = c?.subdivisionId ? ref.subdivisionById(c.subdivisionId)?.name : null; - return `· ${country}${region ? ` - ${region}` : ""}`; + return `${country}${region ? ` - ${region}` : ""}`; } - return `· ${country}`; + return country; } // Fly to a place AND open its preview card — the SAME result as tapping the @@ -731,7 +739,7 @@ export function MapScreen({ active = true }: { active?: boolean } = {}) { )} - {/* Place-kind switch, front and centre. Cities / monuments / airports are + {/* Place-kind switch, front and centre. Cities / monuments / stations / airports are genuinely DIFFERENT data, not one more filter to bury in the panel — so it's a first-class map control (its own prominent pill), never a row in the Filter menu. */} @@ -739,16 +747,16 @@ export function MapScreen({ active = true }: { active?: boolean } = {}) {
{(["all", "cities", "monuments", "stations", "airports"] as FilterMode[]).map((m) => { const label = t(`filter.mode.${m}` as const); - const icon = + const Icon = m === "cities" - ? "🏙" + ? CityIcon : m === "monuments" - ? "🏛" + ? MonumentIcon : m === "airports" - ? "✈" + ? PlaneIcon : m === "stations" - ? "🚉" - : ""; + ? TrainIcon + : null; const active = mode === m; return ( ); })} @@ -975,7 +983,7 @@ export function MapScreen({ active = true }: { active?: boolean } = {}) { lon: x.lon, lat: x.lat, name: x.name, - sub: `· ${x.sub}`, + sub: x.sub, place: x.place, hasPage: true, }) @@ -1036,7 +1044,10 @@ export function MapScreen({ active = true }: { active?: boolean } = {}) { selected && scrollToIdRef.current === c.id ? (el) => { if (el) { - el.scrollIntoView({ block: "nearest", behavior: "smooth" }); + el.scrollIntoView({ + block: "nearest", + behavior: reducedMotion ? "auto" : "smooth", + }); scrollToIdRef.current = null; } } @@ -1057,7 +1068,7 @@ export function MapScreen({ active = true }: { active?: boolean } = {}) { lon: c.lon, lat: c.lat, name: c.name, - sub: `· ${country}${region ? ` - ${region}` : ""}`, + sub: `${country}${region ? ` - ${region}` : ""}`, place, hasPage: true, }) diff --git a/apps/postcards/src/features/map/MapView.tsx b/apps/postcards/src/features/map/MapView.tsx index 10fdf6c..70e7f3e 100644 --- a/apps/postcards/src/features/map/MapView.tsx +++ b/apps/postcards/src/features/map/MapView.tsx @@ -8,7 +8,11 @@ import maplibregl, { import { Protocol } from "pmtiles"; import { feature } from "topojson-client"; import type { FeatureCollection, Polygon, MultiPolygon, Point, Feature, LineString } from "geojson"; -import { getReferenceData, gazetteerGeneration } from "../../lib/reference/referenceData"; +import { + getReferenceData, + gazetteerGeneration, + requestStations, +} from "../../lib/reference/referenceData"; import { useGazetteerGeneration } from "../../lib/reference/useGazetteer"; import { bundledMapSource } from "../../lib/map-source/bundledMapSource"; import { useVisits, findByPlace } from "../../lib/store/useVisits"; @@ -20,6 +24,7 @@ import { mapDateMatches, type MapDate } from "../travel/period"; import { airportPoints, optimizeVisitedPoints, + stackSameCountry, stationPoints, visitedCityPoints, wishlistCityPoints, @@ -30,7 +35,7 @@ import { unwrapAntimeridian } from "./antimeridian"; import { statusShows, type FilterStatus } from "../../lib/store/useFilters"; import type { City } from "../../lib/reference/types"; import type { PlaceRef, Visit } from "../../lib/schema/models"; -import { countryFlag, formatInt } from "../../lib/format/format"; +import { countryFlag, flagFontReady, FLAG_FONT, formatInt } from "../../lib/format/format"; // Natural Earth 50m country geometry, served as a static asset (SW-cached for // offline). Fetched ONCE and cached at module scope so remounts (basemap change, @@ -130,9 +135,26 @@ function getRivers(): Promise { return riversPromise; } -const EMOJI_FONT = '"Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", sans-serif'; const PILL_FONT = '600 21px "Inter Variable", system-ui, sans-serif'; +/** Gold favourite star, drawn as a shape: a ⭐ emoji drew nothing on systems + * without a colour emoji font. White rim so it reads on any flag. */ +function drawStar(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number): void { + ctx.beginPath(); + for (let i = 0; i < 10; i++) { + const a = -Math.PI / 2 + (i * Math.PI) / 5; + const rr = i % 2 ? r * 0.45 : r; + ctx.lineTo(cx + rr * Math.cos(a), cy + rr * Math.sin(a)); + } + ctx.closePath(); + ctx.lineWidth = 2; + ctx.strokeStyle = "#ffffff"; + ctx.lineJoin = "round"; + ctx.stroke(); + ctx.fillStyle = "#f5b301"; + ctx.fill(); +} + /** * City marker, at ONE size for both states so a want-list city is never bigger * than a visited one. VISITED is the bare country flag. WANT-LIST is a distinct @@ -160,13 +182,11 @@ function makeCityPill(iso2: string, favorite: boolean, wish: boolean): ImageData ctx.strokeStyle = "#ffffff"; ctx.stroke(); } else { - ctx.font = `27px ${EMOJI_FONT}`; - ctx.fillText(countryFlag(iso2), w / 2, h / 2 + 1); - } - if (favorite) { - ctx.font = `14px ${EMOJI_FONT}`; - ctx.fillText("⭐", w - 9, 9); + // The bundled flag glyph sits above the middle baseline: set lower to centre. + ctx.font = `27px ${FLAG_FONT}`; + ctx.fillText(countryFlag(iso2), w / 2, h / 2 + 5); } + if (favorite) drawStar(ctx, w - 9, 9, 7); return ctx.getImageData(0, 0, w, h); } @@ -197,10 +217,10 @@ function makeMonumentPin(category: string, seen: boolean): ImageData { ctx.stroke(); ctx.textAlign = "center"; ctx.textBaseline = "middle"; - ctx.font = `29px ${EMOJI_FONT}`; + ctx.font = `29px ${FLAG_FONT}`; ctx.fillText(st.emoji, s / 2, s / 2 + 1); if (seen) { - ctx.font = `15px ${EMOJI_FONT}`; + ctx.font = `15px ${FLAG_FONT}`; ctx.fillText("✅", s - 10, 10); } return ctx.getImageData(0, 0, s, s); @@ -223,7 +243,7 @@ function makeAirportDot(): ImageData { ctx.stroke(); ctx.textAlign = "center"; ctx.textBaseline = "middle"; - ctx.font = `17px ${EMOJI_FONT}`; + ctx.font = `17px ${FLAG_FONT}`; ctx.fillText("✈️", s / 2, s / 2 + 1); return ctx.getImageData(0, 0, s, s); } @@ -233,7 +253,7 @@ function makeAirportPin(iata: string, wish: boolean, favorite: boolean): ImageDa const h = 30; const pad = 8; const gap = 5; - const planeFont = `16px ${EMOJI_FONT}`; + const planeFont = `16px ${FLAG_FONT}`; const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d")!; const plane = "✈️"; @@ -288,7 +308,7 @@ function makeStationDot(): ImageData { ctx.stroke(); ctx.textAlign = "center"; ctx.textBaseline = "middle"; - ctx.font = `17px ${EMOJI_FONT}`; + ctx.font = `17px ${FLAG_FONT}`; ctx.fillText("🚉", s / 2, s / 2 + 1); return ctx.getImageData(0, 0, s, s); } @@ -313,7 +333,7 @@ function makeStationPin(wish: boolean, favorite: boolean): ImageData { ctx.stroke(); ctx.textAlign = "center"; ctx.textBaseline = "middle"; - ctx.font = `15px ${EMOJI_FONT}`; + ctx.font = `15px ${FLAG_FONT}`; ctx.fillText("🚉", s / 2, s / 2 + 1); return ctx.getImageData(0, 0, s, s); } @@ -490,7 +510,14 @@ function openPlacePopup( body.type = "button"; body.className = "map-popup-main"; const name = document.createElement("strong"); - name.textContent = info.name; + // The country's flag leads the name, as in every place list. + if (info.place.countryId) { + const flag = document.createElement("span"); + flag.className = "map-popup-flag"; + flag.setAttribute("aria-hidden", "true"); + flag.textContent = countryFlag(info.place.countryId); + name.append(flag, info.name); + } else name.textContent = info.name; body.appendChild(name); if (info.sub) { const sub = document.createElement("span"); @@ -775,25 +802,8 @@ function overlayLayers(basemap: Basemap, dark: boolean): StyleSpecification["lay "icon-allow-overlap": true, }, }, - { - id: "cities-visited", - type: "symbol", - source: "cities", - layout: { - "icon-image": ["concat", "pill-", ["get", "cc"], "-", ["to-string", ["get", "fav"]], "-", ["to-string", ["get", "wish"]]], - // Grow with zoom so markers stay obvious on big screens & close views. - "icon-size": ["interpolate", ["linear"], ["zoom"], 1, 0.9, 5, 1.1, 10, 1.35], - "icon-padding": 1, - // Your flags are ALWAYS drawn (overlap allowed) — never hidden by collision - // or by browse monuments/airports — because seeing everywhere you've been - // is the whole point. Density is managed by "Show one city per area" - // (region-optimize), not by dropping flags. symbol-sort-key = -(population) - // keeps the biggest city on top where they stack; favourites are pinned. - // (Runtime-flipped by "Show every place at once": OFF re-enables thinning.) - "icon-allow-overlap": true, - "symbol-sort-key": ["get", "sortKey"], - }, - }, + // Your logged airports and stations sit UNDER your flags: at world zoom an + // airport's pill lands right on its city and hid that country's only flag. { id: "airports", type: "symbol", @@ -808,7 +818,8 @@ function overlayLayers(basemap: Basemap, dark: boolean): StyleSpecification["lay "-", ["to-string", ["get", "fav"]], ], - "icon-size": 1, + // Smaller at world zoom, where a pill covers a small country outright. + "icon-size": ["interpolate", ["linear"], ["zoom"], 1, 0.75, 5, 1], "icon-padding": 1, "icon-allow-overlap": true, }, @@ -830,6 +841,27 @@ function overlayLayers(basemap: Basemap, dark: boolean): StyleSpecification["lay "icon-allow-overlap": true, }, }, + { + id: "cities-visited", + type: "symbol", + source: "cities", + layout: { + "icon-image": ["concat", "pill-", ["get", "cc"], "-", ["to-string", ["get", "fav"]], "-", ["to-string", ["get", "wish"]]], + // Grow with zoom so markers stay obvious on big screens & close views. + "icon-size": ["interpolate", ["linear"], ["zoom"], 1, 0.9, 5, 1.1, 10, 1.35], + "icon-padding": 1, + // Your flags are ALWAYS drawn (overlap allowed) — never hidden by collision + // or by browse monuments/airports — because seeing everywhere you've been + // is the whole point. Density is managed by "Show one city per area" + // (region-optimize) and by stacking a country's own flags into one at + // low zoom (stackSameCountry), not by dropping countries. The sort key + // (favourites, then each country's main city, then population) is set + // for the mode by applyMarkerOverlap, with "Show every place at once": + // OFF re-enables thinning. + "icon-allow-overlap": true, + "symbol-sort-key": ["get", "sortKey"], + }, + }, ]; } @@ -1181,7 +1213,12 @@ export function MapView({ // rebuilds the source; toggling the setting does too. `undefined` = setting // off (show all); a null granularity = on but zoomed in enough to show all. const gran = optimizeMarkersRef.current ? optimizeGranularity(map.getZoom()) : undefined; - const optTag = optimizeMarkersRef.current ? `opt:${gran ?? "all"}|` : "all|"; + // Same-country flags that would sit on each other collapse into one + // (stackSameCountry), measured at the zoom rounded down to a half step so + // a pan or a small zoom does not rebuild the sources. + const stackZoom = Math.floor(map.getZoom() * 2) / 2; + const optTag = + (optimizeMarkersRef.current ? `opt:${gran ?? "all"}|` : "all|") + `z${stackZoom}|`; const citiesKey = optTag + vis @@ -1194,7 +1231,7 @@ export function MapView({ if (citiesKey !== lastCitiesKey.current) { lastCitiesKey.current = citiesKey; const base = visitedCityPoints(vis, ref); - const fc = gran ? optimizeVisitedPoints(base, gran) : base; + const fc = stackSameCountry(gran ? optimizeVisitedPoints(base, gran) : base, stackZoom); (map.getSource("cities") as GeoJSONSource | undefined)?.setData(fc); // Warm the flag images up front — a later zoom-out must not have to fetch // any visited flag's image before it can paint (the "load in late" bug). @@ -1211,7 +1248,7 @@ export function MapView({ lastWishKey.current = wishKey; // Want-list cities get the SAME zoom-aware region optimisation as visited. const wbase = wishlistCityPoints(vis, ref); - const wfc = gran ? optimizeVisitedPoints(wbase, gran) : wbase; + const wfc = stackSameCountry(gran ? optimizeVisitedPoints(wbase, gran) : wbase, stackZoom); (map.getSource("wishlist") as GeoJSONSource | undefined)?.setData(wfc); ensurePillImages(map, wfc); } @@ -1327,7 +1364,16 @@ export function MapView({ function applyMarkerOverlap(map: MlMap) { const all = showAllRef.current; for (const id of ["cities-visited", "cities-wishlist"]) { - if (map.getLayer(id)) map.setLayoutProperty(id, "icon-allow-overlap", all); + if (!map.getLayer(id)) continue; + map.setLayoutProperty(id, "icon-allow-overlap", all); + // A low sort key wins placement when markers collide, but when they + // overlap the HIGHEST key draws on top: negate it there, so favourites and + // each country's main city stay on top of a pile instead of under it. + map.setLayoutProperty( + id, + "symbol-sort-key", + all ? ["*", -1, ["get", "sortKey"]] : ["get", "sortKey"], + ); } } @@ -1532,6 +1578,7 @@ export function MapView({ // No map will report a view, so hand the list the whole world: the // fallback below promises the cities list still works. onBoundsRef.current?.({ west: -180, south: -90, east: 180, north: 90 }); + requestStations(); return; } mapRef.current = map; @@ -1556,6 +1603,17 @@ export function MapView({ // Layers pills in the bottom-right (they were literally covering the ⓘ). map.addControl(new maplibregl.AttributionControl({ compact: true }), "bottom-left"); + // A flag drawn before the bundled flag font loaded keeps its empty boxes: + // redraw every flag marker once the font is there (a no-op when cached). + void flagFontReady().then(() => { + if (cancelled || !map) return; + for (const id of map.listImages()) { + if (!id.startsWith("pill-")) continue; + const [, cc, fav, wish] = id.split("-"); + map.updateImage(id, makeCityPill(cc ?? "", fav === "1", wish === "1")); + } + }); + // Marker images, generated lazily on demand. map.on("styleimagemissing", (e) => { if (!map || map.hasImage(e.id)) return; @@ -1583,6 +1641,8 @@ export function MapView({ map.on("load", () => { if (cancelled || !map) return; loadedRef.current = true; + // The stations wait for the map to be up (see loadExtras). + requestStations(); // The globe effect skips a change made before load: catch it up here. if (globeRef.current !== builtGlobe) map.setProjection({ type: globeRef.current ? "globe" : "mercator" }); @@ -1652,9 +1712,9 @@ export function MapView({ // without waiting on a React render. applyViewportPoi(map); applyInViewCities(map); - // Zoom-aware "one city per area": rebuild your flags when the zoom crosses - // a granularity bucket (key-guarded, so a same-bucket pan is a no-op). - if (optimizeMarkersRef.current) applyVisited(map); + // Zoom-aware flags: rebuild them when the zoom crosses a stacking step + // or a "one city per area" bucket (key-guarded, so a pan is a no-op). + applyVisited(map); const wasProgrammatic = suppressBoundsRef.current; if (wasProgrammatic) { suppressBoundsRef.current = false; // programmatic fly — keep the list still @@ -1966,6 +2026,9 @@ export function MapView({ lastCitiesKey.current = ""; lastWishKey.current = ""; lastAirKey.current = ""; + // Heritage sites, airports and stations arrive after first paint. + lastStaKey.current = ""; + lastPoiKey.current = ""; applyVisited(map); applyInViewCities(map); // the full set changes which cities fall in view // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/postcards/src/features/map/visitedLayers.ts b/apps/postcards/src/features/map/visitedLayers.ts index 673f51b..e6db630 100644 --- a/apps/postcards/src/features/map/visitedLayers.ts +++ b/apps/postcards/src/features/map/visitedLayers.ts @@ -13,6 +13,20 @@ function isVisited(v: Visit): boolean { // bias drops favourites and your own custom pins BELOW every browse city so they // are never the one thinned out — your marks always stay on the map. const PINNED = 1_000_000_000; +// Below the pins, each country's most-populous city outranks every other city, +// so where flags collide every country keeps one before any keeps a second. +// Larger than any city's population. +const COUNTRY_REP = 100_000_000; + +/** Ids of the most-populous city per country among `cities`. */ +function countryReps(cities: { id: string; cc: string; pop: number }[]): Set { + const best = new Map(); + for (const c of cities) { + const b = best.get(c.cc); + if (!b || c.pop > b.pop) best.set(c.cc, c); + } + return new Set([...best.values()].map((b) => b.id)); +} /** * Point features for visited cities. Each carries what the flag marker needs @@ -22,6 +36,12 @@ const PINNED = 1_000_000_000; */ export function visitedCityPoints(visits: Visit[], ref: ReferenceData): FeatureCollection { const features: Feature[] = []; + const reps = countryReps( + visits.flatMap((v) => { + const c = isVisited(v) && v.place.kind === "city" ? ref.cityById(v.place.id) : undefined; + return c ? [{ id: c.id, cc: c.countryIso2, pop: c.population ?? 0 }] : []; + }), + ); for (const v of visits) { if (!isVisited(v)) continue; // User-authored custom points carry their own coordinates on the record. @@ -62,9 +82,11 @@ export function visitedCityPoints(visits: Visit[], ref: ReferenceData): FeatureC custom: 0, fav: v.favorite ? 1 : 0, wish: 0, - // Favourites are pinned below every non-favourite; within each group the - // most-populous city is kept when flags collide. - sortKey: (v.favorite ? -PINNED : 0) - (city.population ?? 0), + // Favourites are pinned below every non-favourite, then one city per + // country; within each group the most-populous city is kept when flags + // collide. + sortKey: + (v.favorite ? -PINNED : 0) - (reps.has(city.id) ? COUNTRY_REP : 0) - (city.population ?? 0), }, }); } @@ -121,6 +143,48 @@ export function optimizeVisitedPoints( return { type: "FeatureCollection", features: kept }; } +// On-screen footprint of a flag marker, in CSS pixels. +const MARKER_PX = 20; + +/** + * At world zoom a country's cities sit on top of each other, and their identical + * flags pile up over the neighbours' (Europe especially). This keeps, per + * country, only the markers at least one marker apart on screen at `zoom`, + * highest priority first (the symbol sort key): a marker left out would have + * drawn under the same flag, so no country, favourite or custom place leaves the + * map, and zooming in brings each city back. Different countries never hide + * each other here; the map's collision pass does that when it is on. + */ +export function stackSameCountry( + fc: FeatureCollection, + zoom: number, +): FeatureCollection { + const world = 512 * 2 ** zoom; + const toPx = ([lon, lat]: number[]): [number, number] => { + const s = Math.sin((Math.max(-85, Math.min(85, lat!)) * Math.PI) / 180); + return [ + ((lon! + 180) / 360) * world, + (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * world, + ]; + }; + const byPriority = [...fc.features].sort( + (a, b) => Number(a.properties?.sortKey ?? 0) - Number(b.properties?.sortKey ?? 0), + ); + const keptByCountry = new Map(); + const kept = new Set>(); + for (const f of byPriority) { + const p = f.properties ?? {}; + const at = toPx(f.geometry.coordinates); + const same = keptByCountry.get(p.cc) ?? []; + const pinned = p.custom === 1 || p.fav === 1; + if (!pinned && same.some(([x, y]) => Math.hypot(x - at[0], y - at[1]) < MARKER_PX)) continue; + same.push(at); + keptByCountry.set(p.cc, same); + kept.add(f); + } + return { type: "FeatureCollection", features: fc.features.filter((f) => kept.has(f)) }; +} + /** * Point features for wish-to-go cities. Identical property shape to * {@link visitedCityPoints} (`wish: 1` is the only difference) so want-list @@ -131,6 +195,12 @@ export function optimizeVisitedPoints( */ export function wishlistCityPoints(visits: Visit[], ref: ReferenceData): FeatureCollection { const features: Feature[] = []; + const reps = countryReps( + visits.flatMap((v) => { + const c = v.status === "wishlist" && v.place.kind === "city" ? ref.cityById(v.place.id) : undefined; + return c ? [{ id: c.id, cc: c.countryIso2, pop: c.population ?? 0 }] : []; + }), + ); for (const v of visits) { if (v.status !== "wishlist" || v.place.kind !== "city") continue; const city = ref.cityById(v.place.id); @@ -148,7 +218,8 @@ export function wishlistCityPoints(visits: Visit[], ref: ReferenceData): Feature custom: 0, fav: v.favorite ? 1 : 0, wish: 1, - sortKey: (v.favorite ? -PINNED : 0) - (city.population ?? 0), + sortKey: + (v.favorite ? -PINNED : 0) - (reps.has(city.id) ? COUNTRY_REP : 0) - (city.population ?? 0), }, }); } diff --git a/apps/postcards/src/features/passport/poster.ts b/apps/postcards/src/features/passport/poster.ts index c74560d..0c164f5 100644 --- a/apps/postcards/src/features/passport/poster.ts +++ b/apps/postcards/src/features/passport/poster.ts @@ -5,7 +5,7 @@ import type { FeatureCollection, MultiPolygon, Polygon, Position } from "geojson"; import type { ReferenceData } from "../../lib/reference/types"; import { CONTINENT_COLORS } from "../../lib/reference/continents"; -import { countryFlag } from "../../lib/format/format"; +import { countryFlag, flagFontReady, FLAG_FONT } from "../../lib/format/format"; import { getLand } from "../travel/landGeometry"; const W = 2000; @@ -127,7 +127,8 @@ export async function renderPoster( } // Flag stamps on top (after all fills so nothing covers them). - ctx.font = '36px "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", sans-serif'; + await flagFontReady(); + ctx.font = `36px ${FLAG_FONT}`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; for (const s of stamps) ctx.fillText(s.flag, s.x, s.y); diff --git a/apps/postcards/src/features/travel/MyPlacesPicker.tsx b/apps/postcards/src/features/travel/MyPlacesPicker.tsx index f62e2bf..f2d0052 100644 --- a/apps/postcards/src/features/travel/MyPlacesPicker.tsx +++ b/apps/postcards/src/features/travel/MyPlacesPicker.tsx @@ -1,11 +1,14 @@ -import { useDeferredValue, useMemo, useState } from "react"; +import { lazy, Suspense, useDeferredValue, useMemo, useState } from "react"; import { getReferenceData } from "../../lib/reference/referenceData"; import { searchPlaces } from "../visits/search"; -import { RouteMap } from "./RouteMap"; import { useT } from "../../lib/i18n"; import type { PlaceRef, TravelMode } from "../../lib/schema/models"; import { placeFlag, type MyPlace } from "./myPlaces"; +// MapLibre (~280 KB compressed) loads with the map view that needs it: a +// static import here put it on every app start, ahead of the first paint. +const RouteMap = lazy(() => import("./RouteMap").then((m) => ({ default: m.RouteMap }))); + // Pick trip stops fast. Two ways: // • List — the places you've BEEN (visited + past trips) as instant taps, AND a // search that reaches ANY airport or city in the gazetteer (airports are central @@ -137,7 +140,9 @@ export function MyPlacesPicker({ ) : places.length === 0 ? (

{t("trip.compose.noPlaces")}

) : ( - + {t("map.loading")}

}> + +
)}
); diff --git a/apps/postcards/src/features/visits/PlaceSearch.tsx b/apps/postcards/src/features/visits/PlaceSearch.tsx index 501ee4b..440295a 100644 --- a/apps/postcards/src/features/visits/PlaceSearch.tsx +++ b/apps/postcards/src/features/visits/PlaceSearch.tsx @@ -1,12 +1,15 @@ import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { getReferenceData } from "../../lib/reference/referenceData"; import { searchPlaces } from "./search"; +import { useGazetteerGeneration } from "../../lib/reference/useGazetteer"; import { useVisits, findByPlace, visitIndex } from "../../lib/store/useVisits"; import { useUi } from "../../lib/store/useUi"; import { useToast } from "../../lib/store/useToast"; import { placeKey } from "../../lib/schema/helpers"; import type { PlaceRef } from "../../lib/schema/models"; import { useT } from "../../lib/i18n"; +import { countryFlag } from "../../lib/format/format"; +import { SearchIcon } from "../../ui/icons"; /** * Global place search. Picking a result NAVIGATES — it flies the map to a @@ -35,7 +38,7 @@ export function PlaceSearch({ // On a phone the top-bar field is narrow (it shares the row with the brand and // the action icons), so the full "Search a city or country…" placeholder gets // clipped mid-word. Use a short placeholder there — it stays fully readable, - // and the leading 🔍 plus the accessible name still convey what it searches. + // and the leading magnifier plus the accessible name still convey what it searches. const [narrow, setNarrow] = useState( () => typeof matchMedia !== "undefined" && matchMedia("(max-width: 899.98px)").matches, ); @@ -57,7 +60,9 @@ export function PlaceSearch({ // if the next keystroke lands first). notFound keys on the deferred query so // the add-place form doesn't flash while results lag a beat behind. const dq = useDeferredValue(q); - const results = useMemo(() => searchPlaces(ref, dq), [ref, dq]); + // Airports, heritage sites and stations land a moment after first paint: search again then. + const gazGen = useGazetteerGeneration(); + const results = useMemo(() => searchPlaces(ref, dq), [ref, dq, gazGen]); const notFound = dq.trim().length >= 2 && results.length === 0; // Keep the active option visible as arrows move it. @@ -146,7 +151,7 @@ export function PlaceSearch({ return (
- {/* A leading 🔍 makes the field unmistakably a search even when the top-bar + {/* A leading magnifier makes the field unmistakably a search even when the top-bar squeezes the placeholder to "Search a…" on a phone. It's a pointer affordance that focuses the field on tap; the input already carries the accessible name, so this is aria-hidden + non-focusable (no duplicate @@ -159,7 +164,7 @@ export function PlaceSearch({ title={t("search.aria")} onClick={() => inputRef.current?.focus()} > - 🔍 + pick(r.place)} > + + {r.place.countryId ? countryFlag(r.place.countryId) : ""} + {r.place.name} {r.detail} diff --git a/apps/postcards/src/features/visits/PlacesScreen.tsx b/apps/postcards/src/features/visits/PlacesScreen.tsx index 82841b9..e2aa428 100644 --- a/apps/postcards/src/features/visits/PlacesScreen.tsx +++ b/apps/postcards/src/features/visits/PlacesScreen.tsx @@ -9,6 +9,7 @@ import { type RefObject, } from "react"; import { getReferenceData } from "../../lib/reference/referenceData"; +import { useGazetteerGeneration } from "../../lib/reference/useGazetteer"; import { useVisits } from "../../lib/store/useVisits"; import { useToast } from "../../lib/store/useToast"; import { useUi, type PlacesView } from "../../lib/store/useUi"; @@ -40,6 +41,10 @@ import { placeMatches, sortPlaces, activeChips } from "../filter/applyFilters"; import { FilterPanel } from "../../ui/FilterPanel"; import { FilterSummary } from "../../ui/FilterSummary"; import { useT, type TFunction } from "../../lib/i18n"; +import { useTrips } from "../../lib/store/useTrips"; +import { useStories } from "../../lib/store/useStories"; +import { isBackupDue } from "../../lib/backupReminder"; +import { BackupReminder, backUpNow } from "../backup/Backup"; // The Places screen is ONE unified explore-&-track surface (spec 018): two // independent single-select axes drive it — a KIND (what you're looking at) and a @@ -306,6 +311,7 @@ function RowMenu({ const VisitRow = memo(function VisitRow({ v, wishlist }: { v: Visit; wishlist?: boolean }) { const t = useT(); const ref = useMemo(() => getReferenceData(), []); + useGazetteerGeneration(); // a monument's glyph and an airport's place resolve once they land const toggleVisit = useVisits((s) => s.toggleVisit); const toggleFavorite = useVisits((s) => s.toggleFavorite); const [menuOpen, setMenuOpen] = useState(false); @@ -467,6 +473,29 @@ function NoMatch({ q, onClear }: { q: string; onClear: () => void }) { /** Browse and track every place kind — one kind axis × one status axis, plus the * cross-cutting Moments / Photos / Passport collections. */ +/** The backup nudge used to live only 2,000 px down Settings, where nobody + * scrolls: once a backup is due (lib/backupReminder) it heads Places too. */ +function PlacesBackupReminder() { + const visits = useVisits((s) => s.visits); + const trips = useTrips((s) => s.trips); + const stories = useStories((s) => s.stories); + const [dismissed, setDismissed] = useState(false); + const hasData = visits.length > 0 || trips.length > 0 || stories.length > 0; + if (dismissed || !isBackupDue(hasData, Date.now())) return null; + return ( + + void backUpNow(visits, trips, stories).then( + () => setDismissed(true), + () => undefined, // not delivered: the nudge stays + ) + } + onLater={() => setDismissed(true)} + /> + ); +} + export function PlacesScreen() { const t = useT(); const ref = useMemo(() => getReferenceData(), []); @@ -558,7 +587,9 @@ export function PlacesScreen() { }); }, [collection]); - const heritageAvailable = useMemo(() => ref.allHeritage().length > 0, [ref]); + // Heritage sites and airports land a moment after first paint (initReferenceData). + const gazGen = useGazetteerGeneration(); + const heritageAvailable = useMemo(() => ref.allHeritage().length > 0, [ref, gazGen]); // ── Personal records (kind = All): the user's own saved places, all kinds mixed, // narrowed by the status axis. ──────────────────────────────────────────────── @@ -683,7 +714,7 @@ export function PlacesScreen() { return { rows: [] as BrowseRow[], hasMore: false }; return browseList(kind, status, currentFilters(filters), ref, visits, deferredFilter.trim(), shown); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [kind, status, filters.continent, filters.minPop, filters.category, filters.country, ref, visits, deferredFilter, shown]); + }, [kind, status, filters.continent, filters.minPop, filters.category, filters.country, ref, visits, deferredFilter, shown, gazGen]); const browseRows = browse.rows; // The years your visits span, newest first, for the date filter chips. @@ -851,6 +882,8 @@ export function PlacesScreen() {
+ + {/* The two independent axes. Each place kind appears in exactly ONE control (the kind axis) — no kind duplicated in a status/collection row (US1). */}
diff --git a/apps/postcards/src/lib/format/format.ts b/apps/postcards/src/lib/format/format.ts index cd80fb6..22d6910 100644 --- a/apps/postcards/src/lib/format/format.ts +++ b/apps/postcards/src/lib/format/format.ts @@ -22,8 +22,9 @@ export function formatInt(n: number, locale = activeLocale): string { /** * Flag emoji for an ISO 3166-1 alpha-2 country/territory code ("FR" -> 🇫🇷). - * Pure Unicode regional indicators — offline, no assets; platforms without a - * flag font show the letter pair, which stays informative. + * Pure Unicode regional indicators. Platforms without flag glyphs (Windows, + * minimal Linux) get them from the bundled flag-only font that leads the app's + * font stacks (styles.css, FLAG_FONT for canvases), so a flag always renders. */ export function countryFlag(iso2: string): string { // "ZZ" is the ISO user-assigned code Postcards uses for places outside any @@ -34,6 +35,20 @@ export function countryFlag(iso2: string): string { .replace(/[A-Z]/g, (ch) => String.fromCodePoint(0x1f1e6 + ch.charCodeAt(0) - 65)); } +/** Font stack for flags drawn on a canvas: the bundled flag font first, then + * the platform's colour emoji for everything else (pins, stars). */ +export const FLAG_FONT = + '"Twemoji Country Flags", "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", sans-serif'; + +/** Resolves once the flag font can draw on a canvas (a canvas never waits for + * a web font, and an image drawn before it loads keeps the empty boxes). */ +export function flagFontReady(): Promise { + if (typeof document === "undefined" || !document.fonts) return Promise.resolve(); + return document.fonts + .load('16px "Twemoji Country Flags"', "\u{1F1EB}\u{1F1F7}") + .catch(() => undefined); +} + /** Great-circle distance in km -> localized "1,234 km" (rounded to the km). */ export function formatKm(km: number, locale = activeLocale): string { return `${formatInt(km, locale)} km`; diff --git a/apps/postcards/src/lib/i18n/en.ts b/apps/postcards/src/lib/i18n/en.ts index f3b88bc..f0cfc68 100644 --- a/apps/postcards/src/lib/i18n/en.ts +++ b/apps/postcards/src/lib/i18n/en.ts @@ -38,6 +38,7 @@ export const en = { "nav.sectionsAria": "Sections", "nav.sectionStatus": "{section} section", "app.skipToContent": "Skip to content", + "app.loading": "Loading…", "topbar.goToMap": "Go to the map", "topbar.install": "Install app", "topbar.installAria": "Install the app", diff --git a/apps/postcards/src/lib/i18n/fr.ts b/apps/postcards/src/lib/i18n/fr.ts index 68cac3a..094153b 100644 --- a/apps/postcards/src/lib/i18n/fr.ts +++ b/apps/postcards/src/lib/i18n/fr.ts @@ -31,6 +31,7 @@ export const fr: Messages = { "nav.sectionsAria": "Sections", "nav.sectionStatus": "Section {section}", "app.skipToContent": "Aller au contenu", + "app.loading": "Chargement…", "topbar.goToMap": "Aller à la carte", "topbar.install": "Installer l'appli", "topbar.installAria": "Installer l'application", diff --git a/apps/postcards/src/lib/i18n/ko.ts b/apps/postcards/src/lib/i18n/ko.ts index 51e90c8..0956acc 100644 --- a/apps/postcards/src/lib/i18n/ko.ts +++ b/apps/postcards/src/lib/i18n/ko.ts @@ -32,6 +32,7 @@ export const ko: Messages = { "nav.sectionsAria": "섹션", "nav.sectionStatus": "{section} 섹션", "app.skipToContent": "본문으로 건너뛰기", + "app.loading": "불러오는 중…", "topbar.goToMap": "지도로 이동", "topbar.install": "앱 설치", "topbar.installAria": "앱 설치", diff --git a/apps/postcards/src/lib/reference/referenceData.ts b/apps/postcards/src/lib/reference/referenceData.ts index 620a341..928a041 100644 --- a/apps/postcards/src/lib/reference/referenceData.ts +++ b/apps/postcards/src/lib/reference/referenceData.ts @@ -131,9 +131,9 @@ class ReferenceDataImpl implements ReferenceData { readonly countries: Country[]; readonly provenance: ReferenceProvenance[] = provenance; private cities: IndexedCity[] = []; - private airports: IndexedAirport[]; - private stations: IndexedStation[]; - private heritage: IndexedHeritage[]; + private airports: IndexedAirport[] = []; + private stations: IndexedStation[] = []; + private heritage: IndexedHeritage[] = []; private byIso2 = new Map(); private byNumeric = new Map(); private cityIndex = new Map(); @@ -145,8 +145,8 @@ class ReferenceDataImpl implements ReferenceData { private subIndex = new Map(); private subsByCountry = new Map(); private countrySearch: { c: Country; search: string }[]; - private languages: Record; - private articleNames: Record; + private languages: Record = {}; + private articleNames: Record = {}; // The world gazetteer (bundled core → optional full set). Kept separate from the // community-pack places so a gazetteer swap never drops installed packs. @@ -204,17 +204,12 @@ class ReferenceDataImpl implements ReferenceData { articleNames: Record = {}, stations: Station[] = [], ) { - this.languages = languages; - this.articleNames = articleNames; // Population-descending order is the contract everywhere (search relevance, // the cities-in-view list's presorted fast path). The bundled file is already // sorted, so this is a near-free adaptive pass; it guarantees injected data too. this.baseCities = cities .map((c) => ({ ...c, search: normalize(c.name) })) .sort((a, b) => (b.population ?? 0) - (a.population ?? 0)); - this.airports = airports.map((a) => ({ ...a, search: normalize(a.name) })); - this.stations = stations.map((s) => ({ ...s, search: normalize(s.name) })); - this.heritage = heritage.map((h) => ({ ...h, search: normalize(h.name) })); this.countries = buildCountries(cities, subdivisions); this.countrySearch = this.countries.map((c) => ({ c, search: normalize(c.name) })); for (const c of this.countries) { @@ -223,25 +218,38 @@ class ReferenceDataImpl implements ReferenceData { } // Build the live city set (world gazetteer + any installed pack places). this.remergeCities(); - for (const a of this.airports) this.airportIndex.set(a.id, a); - for (const s of this.stations) { - this.stationIndex.set(s.id, s); - const arr = this.stationsByCountry.get(s.countryIso2); + this.setExtras(airports, heritage, languages, articleNames); + this.replaceStations(stations); + for (const s of subdivisions) { + this.subIndex.set(s.id, s); + const arr = this.subsByCountry.get(s.countryIso2); if (arr) arr.push(s); - else this.stationsByCountry.set(s.countryIso2, [s]); + else this.subsByCountry.set(s.countryIso2, [s]); } + } + + /** Install the airports, heritage sites, languages and article names, which + * arrive after first paint (see initReferenceData). */ + setExtras( + airports: Airport[], + heritage: HeritageSite[], + languages: Record, + articleNames: Record, + ): void { + this.languages = languages; + this.articleNames = articleNames; + this.airports = airports.map((a) => ({ ...a, search: normalize(a.name) })); + this.heritage = heritage.map((h) => ({ ...h, search: normalize(h.name) })); + this.airportIndex.clear(); + for (const a of this.airports) this.airportIndex.set(a.id, a); + this.heritageIndex.clear(); + this.heritageByCountry.clear(); for (const h of this.heritage) { this.heritageIndex.set(h.id, h); const arr = this.heritageByCountry.get(h.countryIso2); if (arr) arr.push(h); else this.heritageByCountry.set(h.countryIso2, [h]); } - for (const s of subdivisions) { - this.subIndex.set(s.id, s); - const arr = this.subsByCountry.get(s.countryIso2); - if (arr) arr.push(s); - else this.subsByCountry.set(s.countryIso2, [s]); - } } countryByIso2(iso2: string): Country | undefined { @@ -428,34 +436,16 @@ export function initReferenceDataSync( export async function initReferenceData(): Promise { if (instance) return instance; try { - const [cities, subdivisions, airports, heritage, landmarks, languages, articleNames, stations] = - await Promise.all([ - fetch(CITIES_URL).then((r) => (r.ok ? r.json() : Promise.reject(new Error("cities")))), - fetch(SUBDIVISIONS_URL).then((r) => (r.ok ? r.json() : [])), - fetch(AIRPORTS_URL).then((r) => (r.ok ? r.json() : [])), - fetch(HERITAGE_URL).then((r) => (r.ok ? r.json() : [])), - fetch(LANDMARKS_URL).then((r) => (r.ok ? r.json() : [])), - fetch(LANGUAGES_URL).then((r) => (r.ok ? r.json() : {})), - fetch(ARTICLE_NAMES_URL).then((r) => (r.ok ? r.json() : {})), - // Railway stations: whichever dataset the user chose in Settings (default - // Trainline; "None" loads nothing). A { _source, stations:[…] } wrapper; - // absent file → []. Switching source later re-fetches via setStationData. - (() => { - const src = stationSourceById(loadStationSource()); - return src.url - ? fetch(src.url).then((r) => (r.ok ? r.json() : { stations: [] })) - : Promise.resolve({ stations: [] }); - })(), - ]); - const ref = initReferenceDataSync( - cities as City[], - subdivisions as Subdivision[], - airports as Airport[], - [...(heritage as HeritageSite[]), ...(landmarks as HeritageSite[])], - languages as Record, - articleNames as Record, - ((stations as { stations?: Station[] })?.stations ?? []) as Station[], - ); + // The first render waits only for what every first screen reads: the city + // gazetteer and its regions. On a phone the rest used to hold the first + // paint back by seconds; it follows in the background (loadExtras). + const [cities, subdivisions] = await Promise.all([ + fetch(CITIES_URL).then((r) => (r.ok ? r.json() : Promise.reject(new Error("cities")))), + fetch(SUBDIVISIONS_URL).then((r) => (r.ok ? r.json() : [])), + ]); + const ref = initReferenceDataSync(cities as City[], subdivisions as Subdivision[]); + extrasPending = true; + void loadExtras(ref as ReferenceDataImpl); // The full 135k-city set is NOT auto-fetched — it's a one-tap download in // Settings (like a tile pack). Only re-load it here if the user already opted // in on a previous run; then it comes straight from the SW cache. @@ -467,6 +457,60 @@ export async function initReferenceData(): Promise { } } +let extrasPending = false; +let stationsSwapped = false; +let stationsWanted!: () => void; +const stationsRequested = new Promise((r) => (stationsWanted = r)); + +/** Let the railway stations download: the map calls this once it has loaded, + * and any other screen as soon as it shows (loadExtras). */ +export function requestStations(): void { + stationsWanted(); +} +/** Whether the airports, heritage sites and stations are still on their way + * (loadExtras). */ +export function referenceExtrasPending(): boolean { + return extrasPending; +} + +/** Airports, heritage sites + landmarks, languages, article names and railway + * stations, merged in once they arrive; the gazetteer event lets screens that + * hold snapshots (the map's markers, stats, country pages) refresh. */ +async function loadExtras(impl: ReferenceDataImpl): Promise { + const json = (url: string, empty: T): Promise => + fetch(url) + .then((r) => (r.ok ? (r.json() as Promise) : empty)) + .catch(() => empty); + // Railway stations: whichever dataset the user chose in Settings (default + // Trainline; "None" loads nothing). A { _source, stations:[…] } wrapper; + // absent file → []. Switching source later re-fetches via setStationData. + // The biggest file here (5 MB, a long parse on a phone), so it waits for a + // screen to ask (requestStations): on the map, once the map has loaded, so it + // never competes with MapLibre's download or start-up. + const stationSrc = stationSourceById(loadStationSource()); + const stations = stationsRequested.then(() => + stationSrc.url + ? json<{ stations?: Station[] }>(stationSrc.url, { stations: [] }) + : { stations: [] as Station[] }, + ); + const [airports, heritage, landmarks, languages, articleNames] = await Promise.all([ + json(AIRPORTS_URL, []), + json(HERITAGE_URL, []), + json(LANDMARKS_URL, []), + json>(LANGUAGES_URL, {}), + json>(ARTICLE_NAMES_URL, {}), + ]); + impl.setExtras(airports, [...heritage, ...landmarks], languages, articleNames); + generation++; + window.dispatchEvent(new Event(GAZETTEER_UPGRADED_EVENT)); + const loaded = await stations; + // A source switched in Settings while this was in flight already won. + if (!stationsSwapped) impl.replaceStations(loaded?.stations ?? []); + extrasPending = false; + generation++; + window.dispatchEvent(new Event(GAZETTEER_UPGRADED_EVENT)); +} + /** Wait for a calm moment — the 17 MB gazetteer must never race first paint, * the map spinning up, or the service worker's install for bandwidth/CPU. */ function whenIdle(): Promise { @@ -550,6 +594,7 @@ export function setPackPlaces(places: City[], aliases = new Map( export function setStationData(stations: Station[]): void { const impl = instance as ReferenceDataImpl | null; if (!impl) return; + stationsSwapped = true; impl.replaceStations(stations); generation++; window.dispatchEvent(new Event(GAZETTEER_UPGRADED_EVENT)); diff --git a/apps/postcards/src/lib/reference/useGazetteer.ts b/apps/postcards/src/lib/reference/useGazetteer.ts index 80e3fa4..8c3bb14 100644 --- a/apps/postcards/src/lib/reference/useGazetteer.ts +++ b/apps/postcards/src/lib/reference/useGazetteer.ts @@ -1,5 +1,9 @@ import { useSyncExternalStore } from "react"; -import { GAZETTEER_UPGRADED_EVENT, gazetteerGeneration } from "./referenceData"; +import { + GAZETTEER_UPGRADED_EVENT, + gazetteerGeneration, + referenceExtrasPending, +} from "./referenceData"; /** * Re-render when the background full-gazetteer upgrade lands (core ~24k cities @@ -13,6 +17,12 @@ export function useGazetteerGeneration(): number { return useSyncExternalStore(subscribe, gazetteerGeneration, gazetteerGeneration); } +/** True until the airports, heritage sites and stations, which follow the + * first paint, have landed (initReferenceData). */ +export function useReferenceExtrasPending(): boolean { + return useSyncExternalStore(subscribe, referenceExtrasPending, referenceExtrasPending); +} + function subscribe(onChange: () => void): () => void { window.addEventListener(GAZETTEER_UPGRADED_EVENT, onChange); return () => window.removeEventListener(GAZETTEER_UPGRADED_EVENT, onChange); diff --git a/apps/postcards/src/lib/store/useUi.ts b/apps/postcards/src/lib/store/useUi.ts index 8f83cfd..3a51b1a 100644 --- a/apps/postcards/src/lib/store/useUi.ts +++ b/apps/postcards/src/lib/store/useUi.ts @@ -74,6 +74,8 @@ interface UiState { storyDraftPlace: PlaceRef | null; journalDraftRequest: { place: PlaceRef; nonce: number } | null; openJournalDraft: (place: PlaceRef) => void; + /** Open the screen an address names (browser Forward), as a new history step. */ + openRoute: (r: Pick) => void; history: NavState[]; /** Return to the previous screen. True if there was somewhere to go back to. */ goBack: () => boolean; @@ -192,6 +194,7 @@ export const useUi = create((set, get) => { countryPageId: null, tripEditId: null, }), + openRoute: (r) => set({ history: pushHistory(), ...r, tripEditId: null, storyEditId: null }), history: [], goBack: () => { const h = get().history; diff --git a/apps/postcards/src/styles.css b/apps/postcards/src/styles.css index a4a741c..85e8ba7 100644 --- a/apps/postcards/src/styles.css +++ b/apps/postcards/src/styles.css @@ -20,7 +20,7 @@ --radius-lg: 18px; --shadow: 0 1px 2px rgba(30, 24, 16, 0.05), 0 6px 18px -6px rgba(30, 24, 16, 0.12); --shadow-sm: 0 1px 2px rgba(30, 24, 16, 0.06); - --display: "Space Grotesk Variable", "Inter Variable", system-ui, sans-serif; + --display: "Twemoji Country Flags", "Space Grotesk Variable", "Inter Variable", system-ui, sans-serif; /* Semantic colours that used to be hardcoded twice (light rule + a dark-only override). Now they're tokens so a single unconditional component rule works under system-dark, explicit-dark and light alike. Light values here. */ @@ -106,6 +106,21 @@ color-scheme: dark; } +/* Country flags on every platform. A flag is a pair of Unicode regional + indicators; Windows and minimal Linux have no colour glyphs for them and show + the two letters or an empty box. This flag-only Twemoji font (CC-BY 4.0, see + public/fonts/PROVENANCE.md) covers just those code points, so it leads every + font stack below and the browser fetches it only once a flag is on screen. */ +@font-face { + font-family: "Twemoji Country Flags"; + src: url("/fonts/TwemojiCountryFlags.woff2") format("woff2"); + unicode-range: U+1F1E6-1F1FF; + /* Its flags fill less of the em than colour-emoji flags: scale them to match, + so a flag keeps the size the layout was drawn for. */ + size-adjust: 118%; + font-display: swap; +} + * { box-sizing: border-box; } @@ -129,8 +144,8 @@ body { body { background: var(--bg); color: var(--text); - font: 15px/1.45 "Inter Variable", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, - sans-serif; + font: 15px/1.45 "Twemoji Country Flags", "Inter Variable", system-ui, -apple-system, "Segoe UI", + Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; font-feature-settings: "cv02", "cv11"; /* Inter: open digits & disambiguated l/I */ } @@ -597,6 +612,12 @@ h1.brand { color: var(--muted); font-variant-numeric: tabular-nums; } +.map-popup strong .map-popup-flag { + display: inline; + margin: 0 6px 0 0; + font-size: 15px; + color: inherit; +} /* Search */ .search { @@ -615,6 +636,12 @@ h1.brand { .search-input::placeholder { color: var(--muted); } +/* The field draws its own ✕ (.search-clear); Chromium's built-in one sat + beside it as a second clear button. */ +.search-input::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; +} /* Inputs that carry a trailing ✕ clear button reserve room so typed text never slides under it. */ .search-input.has-clear { @@ -706,6 +733,15 @@ h1.brand { flex: 1; min-width: 0; width: auto; + justify-content: flex-start; +} +/* The country's flag leads each option, as in every other place list. */ +.result-flag { + flex: none; + width: 1.25em; + font-size: 18px; + line-height: 1; + text-align: center; } .results-split .result-add { flex: none; @@ -2089,6 +2125,16 @@ a.btn-ghost { align-items: center; gap: 12px; } +/* The same nudge as a slim line atop Places. */ +.backup-reminder-line { + padding: 7px 12px; + gap: 6px 10px; +} +.backup-reminder-line .backup-reminder-text { + min-width: 0; + flex-basis: 220px; + font-size: 13px; +} .attribution { margin-top: 22px; font-size: 12px; @@ -4145,6 +4191,9 @@ textarea.select { padding: 2px; } .map-ctl-top .map-mode button { + display: inline-flex; + align-items: center; + gap: 5px; padding: 4px 10px; font-size: 13px; line-height: 1.15; @@ -4222,6 +4271,25 @@ textarea.select { height: 38px; } } +/* A phone bar holds the brand, the search and four action buttons: the + wordmark alone left the field 85px wide at 375px, clipping its placeholder + to "Sea". Below 480px the brand keeps its pin mark (the text stays the + button's accessible name) and the search takes the room. */ +@media (max-width: 479.98px) { + .topbar .brand-wrap { + display: flex; + } + .topbar button.brand { + font-size: 0; + min-width: 32px; + min-height: 32px; + justify-content: center; + } + .topbar .brand::before { + width: 14px; + height: 14px; + } +} /* Mid-width: icon-only tabs so the left-anchored row stays clear of the search. */ @media (min-width: 900px) and (max-width: 1299.98px) { .bottom-nav .nav-item span { @@ -4257,7 +4325,7 @@ button.brand:focus-visible { even when narrow-screen rules hide the action labels — without this the button collapsed to an empty pill. */ .topbar-star .star-glyph { - display: inline; + display: inline-flex; color: #f5b301; font-size: 15px; line-height: 1; diff --git a/apps/postcards/src/ui/Attribution.tsx b/apps/postcards/src/ui/Attribution.tsx index 4a72507..94cac0a 100644 --- a/apps/postcards/src/ui/Attribution.tsx +++ b/apps/postcards/src/ui/Attribution.tsx @@ -15,6 +15,9 @@ export function Attribution() { {p.dataset} ({p.license}) ))} + {/* A bundled asset, not a reference dataset, so it stays out of the + provenance an export records (public/fonts/PROVENANCE.md). */} + · Country flags: Twemoji (CC-BY-4.0)
); } diff --git a/apps/postcards/src/ui/icons.tsx b/apps/postcards/src/ui/icons.tsx index 37f1159..9dcf540 100644 --- a/apps/postcards/src/ui/icons.tsx +++ b/apps/postcards/src/ui/icons.tsx @@ -83,3 +83,64 @@ export function InfoIcon() { ); } + +// Small inline icons that replace emoji used as controls: without a colour +// emoji font (Windows browsers, minimal Linux) the emoji drew as nothing. +// `size` in CSS pixels; each inherits the text colour. + +export function SearchIcon({ size = 16 }: { size?: number }) { + return ( + + + + + ); +} + +export function StarIcon({ size = 15 }: { size?: number }) { + return ( + + + + ); +} + +export function CityIcon({ size = 15 }: { size?: number }) { + return ( + + + + + + + ); +} + +export function MonumentIcon({ size = 15 }: { size?: number }) { + return ( + + + + + + ); +} + +export function PlaneIcon({ size = 15 }: { size?: number }) { + return ( + + + + ); +} + +export function TrainIcon({ size = 15 }: { size?: number }) { + return ( + + + + + + + ); +} diff --git a/apps/postcards/tests/e2e/flags.spec.ts b/apps/postcards/tests/e2e/flags.spec.ts new file mode 100644 index 0000000..ba6098b --- /dev/null +++ b/apps/postcards/tests/e2e/flags.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from "@playwright/test"; + +// Flags are Unicode regional-indicator pairs. Windows and minimal Linux have no +// glyphs for them, so the app bundles a flag-only font that leads its font +// stacks; these checks hold whatever fonts the machine running them has. +test("country flags draw from the bundled flag font", async ({ page }) => { + const fontLoads: number[] = []; + page.on("response", (r) => { + if (r.url().endsWith("/fonts/TwemojiCountryFlags.woff2")) fontLoads.push(r.status()); + }); + await page.goto("/"); + await page.getByLabel("Search a city or country").fill("Paris"); + await page.getByRole("button", { name: "Mark Paris visited" }).first().click(); + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "Places", exact: true }).click(); + await expect(page.getByText("Paris", { exact: true })).toBeVisible(); + + const face = await page.evaluate(async () => { + await document.fonts.ready; + const f = [...document.fonts].find((x) => x.family.replace(/"/g, "") === "Twemoji Country Flags"); + return { + status: f?.status, + range: f?.unicodeRange, + // Its glyphs fill less of the em than colour-emoji flags: scaled to match. + sizeAdjust: (f as (FontFace & { sizeAdjust?: string }) | undefined)?.sizeAdjust, + stack: getComputedStyle(document.body).fontFamily, + }; + }); + expect(face.status).toBe("loaded"); + expect(face.range).toBe("U+1F1E6-1F1FF"); + expect(face.sizeAdjust).toBe("118%"); + expect(face.stack.replace(/"/g, "")).toMatch(/^Twemoji Country Flags,/); + expect(fontLoads).toContain(200); +}); + +// A country named without its flag read as a gap next to every list that shows +// one: search results and the map's place card lead with it too. +test("search results and the map's place card lead with the country's flag", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText("Cities in view")).toBeVisible(); + await page.getByLabel("Search a city or country").fill("Istanbul"); + const first = page.locator("#search-results [role=option]").first(); + await expect(first.locator(".result-flag")).toHaveText("🇹🇷"); + // The flag is decoration: the option's name stays the place's name. + await expect(first.locator(".result-open")).toHaveAccessibleName(/^Istanbul/); + + await first.locator(".result-open").click(); + await expect(page.locator(".map-popup-flag")).toHaveText("🇹🇷"); + await expect(page.locator(".map-popup-main strong")).toHaveText("🇹🇷Istanbul"); +}); diff --git a/apps/postcards/tests/e2e/navigation-url.spec.ts b/apps/postcards/tests/e2e/navigation-url.spec.ts new file mode 100644 index 0000000..fdb3481 --- /dev/null +++ b/apps/postcards/tests/e2e/navigation-url.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from "@playwright/test"; + +// The current screen lives in the address (#/places, #/places/country/FR), so a +// reload lands where you were and the browser's Back and Forward walk screens. +test("a reload keeps the tab and the open page; Back and Forward walk them", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText("Cities in view")).toBeVisible(); + await page.getByRole("button", { name: "Places", exact: true }).click(); + await expect(page).toHaveURL(/#\/places$/); + await page.reload(); + await expect(page.getByRole("button", { name: "Places", exact: true })).toHaveAttribute( + "aria-current", + "page", + ); + + await page.getByLabel("Search a city or country").fill("France"); + await page.locator("#search-results .result-open", { hasText: "France" }).first().click(); + await expect(page).toHaveURL(/#\/places\/country\/FR$/); + await page.reload(); + const heading = page.getByRole("heading", { name: /France/ }).first(); + await expect(heading).toBeVisible(); + + await page.goBack(); + await expect(page).toHaveURL(/#\/places$/); + await expect(heading).toBeHidden(); + await page.goForward(); + await expect(page).toHaveURL(/#\/places\/country\/FR$/); + await expect(heading).toBeVisible(); + + // Home is the bare address. + await page.getByRole("button", { name: "Map", exact: true }).click(); + await expect(page).toHaveURL(/localhost:4173\/$/); +}); + +test("Back at the home screen stays in the app", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText("Cities in view")).toBeVisible(); + await page.goBack(); + await page.goBack(); + await expect(page).toHaveURL(/localhost:4173\/$/); + await expect(page.getByText("Cities in view")).toBeVisible(); +}); + +// A station page (spec 021) is a place page like a city's: its address opens +// it, even though the stations land a moment after the first paint. +test("a station page opens from its address", async ({ page }) => { + await page.goto("/#/map/city/tl-4676"); + await expect(page.getByRole("heading", { name: /Lyon Part-Dieu/ }).first()).toBeVisible(); + await expect(page).toHaveURL(/#\/map\/city\/tl-4676$/); +}); diff --git a/apps/postcards/tests/e2e/startup.spec.ts b/apps/postcards/tests/e2e/startup.spec.ts new file mode 100644 index 0000000..68f08aa --- /dev/null +++ b/apps/postcards/tests/e2e/startup.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from "@playwright/test"; + +// On a throttled phone nothing painted for ~7 s: the page waited for the +// bundle, MapLibre (pulled into the entry by its stylesheet and the trip +// composer's route map) and every reference file. The shell now paints from the +// HTML, MapLibre loads with the map, and the first render waits only for the +// cities and their regions. +test("the first paint needs neither MapLibre nor the airport and heritage lists", async ({ + page, +}) => { + const html = await (await page.request.get("/")).text(); + expect(html).toMatch(/
/); + expect(html).not.toMatch(/maplibre/); + const preloads = [...html.matchAll(/rel="preload" href="([^"]+)"/g)].map((m) => m[1]); + expect(preloads).toEqual(["/reference/cities.json", "/reference/subdivisions.json"]); + + const entry = html.match(/