diff --git a/apps/postcards/src/features/backup/archiveZip.ts b/apps/postcards/src/features/backup/archiveZip.ts index 48e5bda..882992e 100644 --- a/apps/postcards/src/features/backup/archiveZip.ts +++ b/apps/postcards/src/features/backup/archiveZip.ts @@ -13,8 +13,13 @@ import { buildFile } from "./exportJson"; export const ARCHIVE_FILENAME = "postcards-backup.zip"; export const MANIFEST_NAME = "backup.postcards.json"; +// Extensions must cover EVERY mime the photo schema admits (png|jpe?g|webp|gif| +// avif — note both image/jpeg AND image/jpg pass its regex), so a written file +// always maps back to a schema-valid image mime on read. An unknown image mime +// falls back to its subtype so it still round-trips rather than becoming ".bin". const EXT_OF: Record = { "image/jpeg": "jpg", + "image/jpg": "jpg", "image/png": "png", "image/webp": "webp", "image/gif": "gif", @@ -29,17 +34,29 @@ const MIME_OF: Record = { avif: "image/avif", }; +const extForMime = (mime: string): string => + EXT_OF[mime] ?? (mime.replace(/^image\//, "").replace(/[^a-z0-9]/gi, "").toLowerCase() || "bin"); +const mimeForExt = (ext: string): string => MIME_OF[ext] ?? `image/${ext}`; + const B64_CHUNK = 0x8000; -/** Decode a `data:;base64,` URL into raw bytes + its mime. */ +/** Decode a `data:[;base64],` URL into raw bytes + its (parameter- + * stripped) mime. Handles both base64 and percent-encoded/plain payloads so a + * schema-valid but non-base64 photo can't throw and abort the whole archive. */ function decodeDataUrl(dataUrl: string): { bytes: Uint8Array; mime: string } { const comma = dataUrl.indexOf(","); const meta = dataUrl.slice(5, comma); // between "data:" and "," - const mime = meta.replace(/;base64$/i, "") || "application/octet-stream"; - const bin = atob(dataUrl.slice(comma + 1)); - const bytes = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); - return { bytes, mime }; + const isBase64 = /;base64$/i.test(meta); + // Strip the ;base64 flag AND any ;charset=… parameters to get the bare mime. + const mime = meta.replace(/;base64$/i, "").split(";")[0] || "application/octet-stream"; + const payload = dataUrl.slice(comma + 1); + if (isBase64) { + const bin = atob(payload); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return { bytes, mime }; + } + return { bytes: new TextEncoder().encode(decodeURIComponent(payload)), mime }; } /** Re-encode raw image bytes as an inline base64 data URL. */ @@ -66,8 +83,7 @@ export function buildArchive( const stash = (photos?: PhotoLike[]) => photos?.map((p) => { const { bytes, mime } = decodeDataUrl(p.src); - const ext = EXT_OF[mime] ?? "bin"; - const name = `photos/${String(++n).padStart(4, "0")}.${ext}`; + const name = `photos/${String(++n).padStart(4, "0")}.${extForMime(mime)}`; photoEntries.push({ name, data: bytes }); return { src: `zip:${name}`, caption: p.caption ?? null }; }); @@ -101,7 +117,7 @@ export function archiveToJson(bytes: Uint8Array): string { const data = fileMap.get(name); if (!data) return null; // referenced image missing — drop rather than break restore const ext = name.split(".").pop()?.toLowerCase() ?? ""; - return { src: bytesToDataUrl(data, MIME_OF[ext] ?? "application/octet-stream"), caption: ph.caption ?? null }; + return { src: bytesToDataUrl(data, mimeForExt(ext)), caption: ph.caption ?? null }; } return ph; }) @@ -111,6 +127,19 @@ export function archiveToJson(bytes: Uint8Array): string { return r.photos ? { ...r, photos: reinline(r.photos) } : r; }; if (Array.isArray(obj.visits)) obj.visits = obj.visits.map(withPhotos); - if (Array.isArray(obj.stories)) obj.stories = obj.stories.map(withPhotos); + if (Array.isArray(obj.stories)) { + obj.stories = (obj.stories as unknown[]) + .map(withPhotos) + // A story may be image-only (no title/text). If its images went missing from + // the archive, re-inlining empties it — which the schema rejects, aborting the + // WHOLE restore. Drop such a now-empty story instead so the rest still loads. + .filter((rec) => { + const s = rec as { title?: unknown; text?: unknown; photos?: unknown[] }; + const hasText = typeof s.title === "string" && s.title.trim().length > 0; + const hasBody = typeof s.text === "string" && s.text.trim().length > 0; + const hasPhotos = Array.isArray(s.photos) && s.photos.length > 0; + return hasText || hasBody || hasPhotos; + }); + } return JSON.stringify(obj); } diff --git a/apps/postcards/src/features/stats/CountryCoverageMap.tsx b/apps/postcards/src/features/stats/CountryCoverageMap.tsx index d470a04..49dc22d 100644 --- a/apps/postcards/src/features/stats/CountryCoverageMap.tsx +++ b/apps/postcards/src/features/stats/CountryCoverageMap.tsx @@ -94,23 +94,38 @@ export function CountryCoverageMap({ iso2, name }: { iso2: string; name: string }, [land, iso2, ref]); const layout = useMemo(() => { - const xs: number[] = []; - const ys: number[] = []; - const push = (lon: number, lat: number) => { - xs.push((lon * Math.PI) / 180); - ys.push(mercY(lat)); - }; // Frame to the MAINLAND — the ring with the most points — so a country with // far-flung overseas territories (France, the US…) doesn't zoom out to the // whole globe. Everything else still draws, clipped by the viewBox. let mainRing: Position[] | null = null; for (const r of rings) if (r.length > (mainRing?.length ?? 0)) mainRing = r; - if (mainRing) for (const p of mainRing) push(p[0]!, p[1]!); + const frameLons: number[] = []; + const frameLats: number[] = []; + if (mainRing) + for (const p of mainRing) { + frameLons.push(p[0]!); + frameLats.push(p[1]!); + } else { - for (const p of model.visitedPoints) push(p.lon, p.lat); - for (const m of model.missing) push(m.lon, m.lat); + for (const p of model.visitedPoints) { + frameLons.push(p.lon); + frameLats.push(p.lat); + } + for (const m of model.missing) { + frameLons.push(m.lon); + frameLats.push(m.lat); + } } - if (!xs.length) return null; + if (!frameLons.length) return null; + // Unwrap across the antimeridian: when the frame spans > 180° of raw longitude + // the land wraps the date line (Russia, Fiji…), so shift western lons by +360 + // and project EVERYTHING (rings, dots, blobs) in that continuous space — else + // the silhouette collapses to a distorted, off-centre sliver. + const unwrap = Math.max(...frameLons) - Math.min(...frameLons) > 180; + const wrapLon = (lon: number) => (unwrap && lon < 0 ? lon + 360 : lon); + + const xs = frameLons.map((lon) => (wrapLon(lon) * Math.PI) / 180); + const ys = frameLats.map(mercY); let minX = Math.min(...xs); let maxX = Math.max(...xs); let minY = Math.min(...ys); @@ -124,7 +139,7 @@ export function CountryCoverageMap({ iso2, name }: { iso2: string; name: string const scale = Math.min((W - 2 * PAD) / (maxX - minX), (H - 2 * PAD) / (maxY - minY)); const midX = (minX + maxX) / 2; const midY = (minY + maxY) / 2; - const sx = (lon: number) => W / 2 + ((lon * Math.PI) / 180 - midX) * scale; + const sx = (lon: number) => W / 2 + ((wrapLon(lon) * Math.PI) / 180 - midX) * scale; const sy = (lat: number) => H / 2 - (mercY(lat) - midY) * scale; const degToPx = (scale * Math.PI) / 180; // ~px per degree at this scale diff --git a/apps/postcards/src/features/stats/StatStrip.tsx b/apps/postcards/src/features/stats/StatStrip.tsx index 30ad4e1..6c48944 100644 --- a/apps/postcards/src/features/stats/StatStrip.tsx +++ b/apps/postcards/src/features/stats/StatStrip.tsx @@ -61,9 +61,10 @@ export function StatStrip() { type="button" className="ss-item" onClick={() => { - // World-level shortcut — drop any country drill-down so it shows the - // whole world, not the last country you opened from a stats card. - useFilters.getState().set({ country: "" }); + // World-level shortcut — drop the WHOLE country drill-down a stats card + // may have set (country AND minPop), so the list matches this counter's + // number instead of staying gated at the last tier you opened. + useFilters.getState().set({ country: "", minPop: 0 }); openPlaces(view); }} title={t("statStrip.openAria", { label })} diff --git a/apps/postcards/src/features/stats/StatsView.tsx b/apps/postcards/src/features/stats/StatsView.tsx index 87f100f..bd0b287 100644 --- a/apps/postcards/src/features/stats/StatsView.tsx +++ b/apps/postcards/src/features/stats/StatsView.tsx @@ -281,10 +281,12 @@ export function StatsView() { useFilters.getState().set({ minPop, country: "" }); useUi.getState().openPlaces("visited"); } - // The coverage-hero + KPI tiles are all world-level: drop any country - // drill-down before opening the view so they never stay narrowed to one country. + // The coverage-hero + KPI tiles are all world-level: drop the WHOLE country + // drill-down (country AND its population tier) before opening the view, so a + // world "Cities" bar never opens a list still truncated to 1M+ from a prior + // mega-city drill (mirrors openCitiesFiltered, which already resets both). function openWorld(view: PlacesView) { - useFilters.getState().set({ country: "" }); + useFilters.getState().set({ country: "", minPop: 0 }); useUi.getState().openPlaces(view); } const continentCov = useMemo( diff --git a/apps/postcards/src/features/travel/RouteMap.tsx b/apps/postcards/src/features/travel/RouteMap.tsx index b03b145..111fdb5 100644 --- a/apps/postcards/src/features/travel/RouteMap.tsx +++ b/apps/postcards/src/features/travel/RouteMap.tsx @@ -91,7 +91,10 @@ export function RouteMap({ map.on("load", () => { void getLand().then((land) => { - if (!mapRef.current || !land) return; + // Guard on IDENTITY, not just truthiness: a fast Map→List→Map toggle can + // remove this map and create a new one while the (shared, cached) land + // promise is in flight — touching the removed map would throw. + if (mapRef.current !== map || !land) return; if (map.getSource("land")) return; map.addSource("land", { type: "geojson", data: land }); map.addLayer({ diff --git a/apps/postcards/src/features/travel/distance.ts b/apps/postcards/src/features/travel/distance.ts index 97055a0..a4a64cd 100644 --- a/apps/postcards/src/features/travel/distance.ts +++ b/apps/postcards/src/features/travel/distance.ts @@ -17,6 +17,12 @@ export function coordsOf(place: PlaceRef, ref: ReferenceData): { lon: number; la // Some sites have no coordinate in the source (stored as 0,0) — treat as unknown. return h && (h.lat !== 0 || h.lon !== 0) ? { lon: h.lon, lat: h.lat } : null; } + // A user-authored "custom" pin carries its own coordinates on the record (there's + // no reference entry to look up). The trip pool already resolves these the same + // way, so a custom stop's leg must draw + measure here too — not vanish. + if (place.kind === "custom") { + return place.lat != null && place.lon != null ? { lon: place.lon, lat: place.lat } : null; + } return null; // countries have no single coordinate } diff --git a/apps/postcards/tests/unit/archiveZip.spec.ts b/apps/postcards/tests/unit/archiveZip.spec.ts index 0c05c01..1e3b39b 100644 --- a/apps/postcards/tests/unit/archiveZip.spec.ts +++ b/apps/postcards/tests/unit/archiveZip.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { zipStore, unzipStore, looksLikeZip, crc32 } from "../../src/lib/backup/zip"; import { buildArchive, archiveToJson, MANIFEST_NAME } from "../../src/features/backup/archiveZip"; import { importFile } from "../../src/features/backup/importJson"; -import type { Visit } from "../../src/lib/schema/models"; +import type { Story, Visit } from "../../src/lib/schema/models"; const enc = (s: string) => new TextEncoder().encode(s); const dec = (b: Uint8Array) => new TextDecoder().decode(b); @@ -91,4 +91,40 @@ describe('"Save everything" archive round-trip (data + photos-as-files)', () => expect(result.ok).toBe(true); if (result.ok) expect(result.visits[0]!.photos ?? []).toHaveLength(0); }); + + it("round-trips an image/jpg photo to a schema-valid mime (regression: was .bin → reject)", () => { + // The schema accepts data:image/jpg (jpe?g); the archive must not turn it into + // an un-restorable .bin. AAAA is valid base64 (3 zero bytes). + const jpg = "data:image/jpg;base64,AAAA"; + const bytes = buildArchive([{ ...visit(), photos: [{ src: jpg, caption: null }] }], [], []); + // Stored as .jpg, not .bin. + expect(unzipStore(bytes).some((e) => e.name === "photos/0001.jpg")).toBe(true); + const result = importFile(archiveToJson(bytes)); + expect(result.ok).toBe(true); + if (result.ok) expect(result.visits[0]!.photos![0]!.src).toMatch(/^data:image\/jpeg;base64,/); + }); + + it("does NOT throw on a schema-valid non-base64 photo (regression: atob threw, aborting export)", () => { + const nonB64 = "data:image/png;charset=utf-8,hello"; + expect(() => buildArchive([{ ...visit(), photos: [{ src: nonB64, caption: null }] }], [], [])).not.toThrow(); + }); + + it("drops an image-only story whose image went missing rather than aborting the whole restore", () => { + const story: Story = { + storyId: crypto.randomUUID(), + place: { kind: "city", id: "paris-fr", name: "Paris", countryId: "FR" }, + date: "2019-08-12", + photos: [{ src: dataUrl, caption: null }], // image-only (no title/text) + addedAt: new Date().toISOString(), + } as Story; + const bytes = buildArchive([visit()], [], [story]); + // Rebuild the archive WITHOUT the story's image (only the manifest survives). + const stripped = zipStore(unzipStore(bytes).filter((e) => e.name === MANIFEST_NAME)); + const result = importFile(archiveToJson(stripped)); + expect(result.ok).toBe(true); // the visit still restores… + if (result.ok) { + expect(result.visits).toHaveLength(1); + expect(result.stories).toHaveLength(0); // …and the now-empty story is dropped, not fatal + } + }); }); diff --git a/apps/postcards/tests/unit/tripLegs.spec.ts b/apps/postcards/tests/unit/tripLegs.spec.ts index 264f3cc..9725677 100644 --- a/apps/postcards/tests/unit/tripLegs.spec.ts +++ b/apps/postcards/tests/unit/tripLegs.spec.ts @@ -66,6 +66,17 @@ const ref = { heritageById: () => undefined, } as unknown as ReferenceData; +describe("custom stops draw + measure (regression: coordsOf ignored kind=custom)", () => { + const cA: PlaceRef = { kind: "custom", id: "a", name: "Cabin", countryId: "FR", lon: 2, lat: 48 }; + const cB: PlaceRef = { kind: "custom", id: "b", name: "Lake", countryId: "FR", lon: 9, lat: 45 }; + it("a leg between two custom pins draws an arc and contributes distance", async () => { + const { tripPathKm } = await import("../../src/features/travel/distance"); + expect(stopsArcs([cA, cB], ref, "car").features).toHaveLength(1); + expect(tripPathKm([cA, cB], ref).km).toBeGreaterThan(0); + expect(tripPathKm([cA, cB], ref).unresolvedLegs).toBe(0); + }); +}); + describe("stopsArcs tags each leg with its own mode", () => { it("uses legModes[i] when present, else the trip default", () => { const fc = stopsArcs([P, T, O], ref, "flight", ["flight", "train"]);