Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/postcards/src/features/backup/exportJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import {
} from "../../lib/schema/models";
import { getReferenceData } from "../../lib/reference/referenceData";

/** Drop an empty `photos` array so a photo-less record stays lean in the file. */
function dropEmptyPhotos<T extends { photos?: unknown[] }>(rec: T): T | Omit<T, "photos"> {
const { photos, ...rest } = rec;
return photos && photos.length ? { ...rest, photos } : rest;
}

/** Build the canonical portable file object from the current visits + trips + stories.
* `tombstones` is written only for device sync; a plain backup passes none, so the
* exported file stays free of an empty `tombstones` key. */
Expand All @@ -32,9 +38,9 @@ export function buildFile(
schemaVersion: SCHEMA_VERSION,
exportedAt: now.toISOString(),
// Drop empty `photos` arrays so a photo-less export stays lean and readable.
visits: visits.map(({ photos, ...rest }) => (photos && photos.length ? { ...rest, photos } : rest)),
visits: visits.map(dropEmptyPhotos),
trips,
stories: stories.map(({ photos, ...rest }) => (photos && photos.length ? { ...rest, photos } : rest)),
stories: stories.map(dropEmptyPhotos),
...(tombstones.length ? { tombstones } : {}),
referenceSources,
};
Expand Down
4 changes: 2 additions & 2 deletions apps/postcards/src/features/backup/importJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ export function importFile(text: string): ImportResult {
// lists a place twice, keep the first record's identity but UNION the galleries
// (photos are now the payload — dropping one silently would lose data).
const byPlace = new Map<string, Visit>();
for (const raw of parsed.data.visits) {
const v = normalizeVisitPhotos(raw);
for (const rawVisit of parsed.data.visits) {
const v = normalizeVisitPhotos(rawVisit);
const key = placeKey(v.place);
const existing = byPlace.get(key);
if (!existing) {
Expand Down
2 changes: 1 addition & 1 deletion apps/postcards/src/features/guides/GuideButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const isOffline = () => typeof navigator !== "undefined" && !navigator.onLine;

/** Resolve the names a place's guides are built from (common country name —
* the real Wikivoyage article title, e.g. "Russia", not "Russian Federation"). */
function guideNames(place: PlaceRef) {
function guideNames(place: PlaceRef): GuideNames | null {
const ref = getReferenceData();
const country = ref.countryByIso2(place.countryId);
if (!country) return null;
Expand Down
10 changes: 6 additions & 4 deletions apps/postcards/src/features/map/visitedLayers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,22 +214,24 @@ export function tripArcs(trips: Trip[], ref: ReferenceData): FeatureCollection<L
const features: Feature<LineString>[] = [];
for (const t of trips) {
const chain = t.stops && t.stops.length >= 2 ? t.stops : [t.from, t.to];
features.push(...stopsArcs(chain, ref, t.mode).features);
features.push(...stopsArcs(chain, ref, t.mode, t.legModes).features);
}
return { type: "FeatureCollection", features };
}

/**
* Great-circle arcs for an ORDERED chain of stops (spec 019) — one arc per
* consecutive resolvable leg, tagged with the travel `mode`. Powers the live
* route drawn while reconstructing a journey (the composer's real map). A leg
* consecutive resolvable leg, each tagged with ITS transport so the map can colour
* a mixed-mode journey correctly (leg i uses `legModes[i]`, else the trip default
* `mode`). Powers the live route drawn while reconstructing a journey. A leg
* touching a coordinate-less stop is skipped — nothing invented (FR-013); fewer
* than two stops → an empty collection. Takes raw stops, NOT a Trip.
*/
export function stopsArcs(
stops: PlaceRef[],
ref: ReferenceData,
mode: TravelMode,
legModes?: TravelMode[],
): FeatureCollection<LineString> {
const features: Feature<LineString>[] = [];
for (let i = 0; i < stops.length - 1; i++) {
Expand All @@ -239,7 +241,7 @@ export function stopsArcs(
features.push({
type: "Feature",
geometry: { type: "LineString", coordinates: greatCircle(from, to) },
properties: { mode },
properties: { mode: legModes?.[i] ?? mode },
});
}
return { type: "FeatureCollection", features };
Expand Down
12 changes: 6 additions & 6 deletions apps/postcards/src/features/passport/PassportScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ export function PassportScreen({ embedded }: { embedded?: boolean } = {}) {
}, [posterUrl]);

const visitedIds = useMemo(() => visitedCountryIds(visits), [visits]);
const { collected, missing, continents } = useMemo(() => {
const { collectedCount, missing, continents } = useMemo(() => {
const all = ref.countries.filter((c) => inScope(c.sovereignty, scope));
const collected = all.filter((c) => visitedIds.has(c.iso2));
const collectedCount = all.filter((c) => visitedIds.has(c.iso2)).length;
const missing = all.filter((c) => !visitedIds.has(c.iso2));
// Collected flags grouped by continent, each with its own progress, so the
// passport reads like pages of a real one.
Expand All @@ -88,7 +88,7 @@ export function PassportScreen({ embedded }: { embedded?: boolean } = {}) {
.filter(([, g]) => g.done.length > 0)
.map(([name, g]) => ({ name, done: g.done, total: g.total }))
.sort((a, b) => b.done.length - a.done.length || a.name.localeCompare(b.name));
return { collected, missing, continents };
return { collectedCount, missing, continents };
}, [ref, visitedIds, scope]);
const [shownMissing, setShownMissing] = useState(60);

Expand Down Expand Up @@ -152,15 +152,15 @@ export function PassportScreen({ embedded }: { embedded?: boolean } = {}) {

<div className="passport-head">
<p className="muted">
<strong className="flags-count">{formatInt(collected.length)}</strong>{" "}
{t("passport.ofFlags", { total: formatInt(collected.length + missing.length) })}
<strong className="flags-count">{formatInt(collectedCount)}</strong>{" "}
{t("passport.ofFlags", { total: formatInt(collectedCount + missing.length) })}
</p>
<button className="btn" type="button" disabled={rendering} onClick={() => void exportPoster()}>
{rendering ? t("passport.rendering") : `🖼 ${t("passport.worldPoster")}`}
</button>
</div>

{collected.length === 0 ? (
{collectedCount === 0 ? (
<p className="muted empty">
<span className="empty-emoji" aria-hidden>
🛂
Expand Down
4 changes: 2 additions & 2 deletions apps/postcards/src/features/publish/PublishScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,11 +213,11 @@ export function PublishScreen({ onClose }: { onClose: () => void }) {

/** Build the final self-contained HTML (encrypted when a passphrase is set). */
async function buildHtml(): Promise<string> {
// Normalise ONCE and use the SAME value for the encrypt decision and the
// Use the SAME normalised value (passNorm) for the encrypt decision and the
// encryption itself. Before, the decision used passphrase.trim() but the
// encrypt used the raw value: a spaces-only box silently published PLAINTEXT,
// and surrounding spaces produced a file that could never be unlocked.
const pass = passphrase.normalize("NFC").trim();
const pass = passNorm;
if (pass) {
if (pass.length < MIN_PASSPHRASE_LENGTH) {
throw new Error(`Use a passphrase of at least ${MIN_PASSPHRASE_LENGTH} characters.`);
Expand Down
174 changes: 174 additions & 0 deletions apps/postcards/src/features/stats/CountryCoverageMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { useEffect, useMemo, useState } from "react";
import type { FeatureCollection, Position } from "geojson";
import { getReferenceData } from "../../lib/reference/referenceData";
import { useVisits } from "../../lib/store/useVisits";
import { useGazetteerGeneration } from "../../lib/reference/useGazetteer";
import { getLand } from "../travel/landGeometry";
import { useT } from "../../lib/i18n";

// A STATIC (non-interactive) per-country coverage map, shown under a country card
// in Stats in place of the long "regions/monuments to explore" text lists. It
// paints the country silhouette (bundled offline Natural Earth geometry), tints
// the regions you HAVEN'T been as soft "missing" blobs, and dots the cities you
// have — so coverage reads at a glance. Pure SVG, computed lazily when the card
// opens; the full, interactive lists still live on the country's own page.

const W = 320;
const H = 190;
const PAD = 10;
const MAX_DOTS = 400;

const mercY = (lat: number) => Math.log(Math.tan(Math.PI / 4 + (Math.max(-85, Math.min(85, lat)) * Math.PI) / 360));

export function CountryCoverageMap({ iso2, name }: { iso2: string; name: string }) {
const t = useT();
const ref = useMemo(() => getReferenceData(), []);
const visits = useVisits((s) => s.visits);
const gazGen = useGazetteerGeneration(); // city set grows when the full gazetteer lands
const [land, setLand] = useState<FeatureCollection | null>(null);
useEffect(() => {
let alive = true;
void getLand().then((fc) => {
if (alive) setLand(fc);
});
return () => {
alive = false;
};
}, []);

// Visited city points + per-region centroids/spread, and which regions are unvisited.
const model = useMemo(() => {
const cities = ref.citiesOf(iso2);
const visitedCityIds = new Set(
visits
.filter((v) => v.status === "visited" && v.place.kind === "city" && v.place.countryId === iso2)
.map((v) => v.place.id),
);
type Reg = { sx: number; sy: number; sxx: number; syy: number; n: number; visited: boolean };
const regions = new Map<string, Reg>();
const visitedPoints: { lon: number; lat: number }[] = [];
for (const c of cities) {
const isVisited = visitedCityIds.has(c.id);
if (isVisited && visitedPoints.length < MAX_DOTS) visitedPoints.push({ lon: c.lon, lat: c.lat });
const sub = c.subdivisionId;
if (!sub) continue;
let g = regions.get(sub);
if (!g) {
g = { sx: 0, sy: 0, sxx: 0, syy: 0, n: 0, visited: false };
regions.set(sub, g);
}
g.n++;
g.sx += c.lon;
g.sy += c.lat;
g.sxx += c.lon * c.lon;
g.syy += c.lat * c.lat;
if (isVisited) g.visited = true;
}
const missing = [...regions.values()]
.filter((g) => !g.visited)
.map((g) => {
const lon = g.sx / g.n;
const lat = g.sy / g.n;
// Rough spread (deg) across the region's cities, to size the blob.
const spread = Math.sqrt(Math.max(0, g.sxx / g.n - lon * lon) + Math.max(0, g.syy / g.n - lat * lat));
return { lon, lat, spread };
});
const regionsTotal = ref.countryByIso2(iso2)?.subdivisionCount ?? regions.size;
const regionsVisited = [...regions.values()].filter((g) => g.visited).length;
return { visitedPoints, missing, regionsTotal, regionsVisited };
}, [iso2, visits, ref, gazGen]);

// The country's polygon rings, matched from the bundled geometry by numeric code.
const rings = useMemo<Position[][]>(() => {
if (!land) return [];
const numeric = ref.countryByIso2(iso2)?.numeric;
// The bundled TopoJSON carries the numeric country code as the feature `id`.
const feat = land.features.find(
(f) => String(f.id ?? f.properties?.numeric ?? "") === String(numeric),
);
const geom = feat?.geometry;
const out: Position[][] = [];
if (geom?.type === "Polygon") out.push(...(geom.coordinates as Position[][]));
else if (geom?.type === "MultiPolygon") for (const p of geom.coordinates as Position[][][]) out.push(...p);
return out;
}, [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]!);
else {
for (const p of model.visitedPoints) push(p.lon, p.lat);
for (const m of model.missing) push(m.lon, m.lat);
}
if (!xs.length) return null;
let minX = Math.min(...xs);
let maxX = Math.max(...xs);
let minY = Math.min(...ys);
let maxY = Math.max(...ys);
const spanX = maxX - minX || 0.1;
const spanY = maxY - minY || 0.1;
minX -= spanX * 0.08;
maxX += spanX * 0.08;
minY -= spanY * 0.12;
maxY += spanY * 0.12;
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 sy = (lat: number) => H / 2 - (mercY(lat) - midY) * scale;
const degToPx = (scale * Math.PI) / 180; // ~px per degree at this scale

const landPath = rings
.map((r) => r.map((p, i) => (i ? "L" : "M") + sx(p[0]!).toFixed(1) + " " + sy(p[1]!).toFixed(1)).join("") + "Z")
.join("");
const blobs = model.missing.map((m) => ({
x: sx(m.lon),
y: sy(m.lat),
r: Math.max(6, Math.min(W / 4, (m.spread || 0.4) * degToPx)),
}));
const dots = model.visitedPoints.map((p) => ({ x: sx(p.lon), y: sy(p.lat) }));
return { landPath, blobs, dots };
}, [rings, model]);

if (!layout) return null;

const aria = t("stats.country.mapAria", {
name,
visited: model.regionsVisited,
total: model.regionsTotal,
});

return (
<figure className="country-cov-map">
<svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label={aria} preserveAspectRatio="xMidYMid meet">
{layout.landPath && <path className="ccov-land" d={layout.landPath} />}
{/* Painted "still to explore" regions. */}
{layout.blobs.map((b, i) => (
<circle key={`m${i}`} className="ccov-missing" cx={b.x} cy={b.y} r={b.r} />
))}
{/* Cities you've been. */}
{layout.dots.map((d, i) => (
<circle key={`v${i}`} className="ccov-visited" cx={d.x} cy={d.y} r={2.6} />
))}
</svg>
<figcaption className="country-cov-legend">
<span>
<span className="ccov-key ccov-key-visited" aria-hidden /> {t("stats.country.mapVisited")}
</span>
<span>
<span className="ccov-key ccov-key-missing" aria-hidden /> {t("stats.country.mapMissing")}
</span>
</figcaption>
</figure>
);
}
Loading
Loading