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
27 changes: 4 additions & 23 deletions apps/postcards/src/features/map/MapScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { GuideButton } from "../guides/GuideButton";
import { StatStrip } from "../stats/StatStrip";
import { MapView, hasSavedCamera, type Basemap, type MapFocus, type MapFit } from "./MapView";
import { tripArcs } from "./visitedLayers";
import { fitBounds } from "./mapFit";
import { dateBuckets, mapDateMatches, rangeExactYear, type MapDate } from "../travel/period";
import { citiesInView, type Bounds } from "./viewport";
import { bundledMapSource } from "../../lib/map-source/bundledMapSource";
Expand Down Expand Up @@ -641,29 +642,9 @@ export function MapScreen({ active = true }: { active?: boolean } = {}) {
}, [myPlaceCoords]);

function fitToMyPlaces(instant = false) {
if (!myPlaceCoords.length) return;
let south = Infinity, north = -Infinity;
for (const c of myPlaceCoords) {
south = Math.min(south, c.lat);
north = Math.max(north, c.lat);
}
// Longitude needs antimeridian care (Fiji + Samoa must not frame the whole
// globe): the tightest frame is the complement of the LARGEST gap between
// consecutive sorted longitudes (wrapping counts as a gap too).
const lons = myPlaceCoords.map((c) => c.lon).sort((a, b) => a - b);
let gapAfter = lons.length - 1;
let gapSize = lons[0]! + 360 - lons[lons.length - 1]!;
for (let i = 1; i < lons.length; i++) {
const g = lons[i]! - lons[i - 1]!;
if (g > gapSize) {
gapSize = g;
gapAfter = i - 1;
}
}
const west = lons[(gapAfter + 1) % lons.length]!;
let east = lons[gapAfter]!;
if (east < west) east += 360; // the frame crosses the antimeridian
setFit((f) => ({ bounds: [[west, south], [east, north]], key: (f?.key ?? 0) + 1, instant }));
const bounds = fitBounds(myPlaceCoords);
if (!bounds) return;
setFit((f) => ({ bounds, key: (f?.key ?? 0) + 1, instant }));
}

return (
Expand Down
35 changes: 35 additions & 0 deletions apps/postcards/src/features/map/mapFit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Antimeridian-aware framing shared by the main map and the trip composer's
// route map. The tightest longitude frame is the COMPLEMENT of the largest gap
// between consecutive sorted longitudes (wrapping counts as a gap), so Fiji +
// Samoa frame tight instead of spanning the whole globe. Pure & testable.

export type LngLatBounds = [[number, number], [number, number]];

/** Bounds `[[west, south], [east, north]]` enclosing all coords, or null when
* there are none. `east` may exceed 180 when the frame crosses the antimeridian. */
export function fitBounds(coords: { lon: number; lat: number }[]): LngLatBounds | null {
if (!coords.length) return null;
let south = Infinity;
let north = -Infinity;
for (const c of coords) {
south = Math.min(south, c.lat);
north = Math.max(north, c.lat);
}
const lons = coords.map((c) => c.lon).sort((a, b) => a - b);
let gapAfter = lons.length - 1;
let gapSize = lons[0]! + 360 - lons[lons.length - 1]!; // the wrap-around gap
for (let i = 1; i < lons.length; i++) {
const g = lons[i]! - lons[i - 1]!;
if (g > gapSize) {
gapSize = g;
gapAfter = i - 1;
}
}
const west = lons[(gapAfter + 1) % lons.length]!;
let east = lons[gapAfter]!;
if (east < west) east += 360; // the frame crosses the antimeridian
return [
[west, south],
[east, north],
];
}
39 changes: 28 additions & 11 deletions apps/postcards/src/features/map/visitedLayers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Feature, FeatureCollection, LineString, Point } from "geojson";
import type { Trip, Visit } from "../../lib/schema/models";
import type { PlaceRef, TravelMode, Trip, Visit } from "../../lib/schema/models";
import type { ReferenceData } from "../../lib/reference/types";
import { coordsOf } from "../travel/distance";

Expand Down Expand Up @@ -214,16 +214,33 @@ 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];
for (let i = 0; i < chain.length - 1; i++) {
const from = coordsOf(chain[i]!, ref);
const to = coordsOf(chain[i + 1]!, ref);
if (!from || !to) continue;
features.push({
type: "Feature",
geometry: { type: "LineString", coordinates: greatCircle(from, to) },
properties: { mode: t.mode },
});
}
features.push(...stopsArcs(chain, ref, t.mode).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
* 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,
): FeatureCollection<LineString> {
const features: Feature<LineString>[] = [];
for (let i = 0; i < stops.length - 1; i++) {
const from = coordsOf(stops[i]!, ref);
const to = coordsOf(stops[i + 1]!, ref);
if (!from || !to) continue;
features.push({
type: "Feature",
geometry: { type: "LineString", coordinates: greatCircle(from, to) },
properties: { mode },
});
}
return { type: "FeatureCollection", features };
}
Expand Down
126 changes: 10 additions & 116 deletions apps/postcards/src/features/travel/MyPlacesPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,34 @@ import { useDeferredValue, useMemo, useState } from "react";
import { getReferenceData } from "../../lib/reference/referenceData";
import { searchPlaces } from "../visits/search";
import { countryFlag } from "../../lib/format/format";
import { LAND_OUTLINE } from "../../lib/publish/landOutline";
import { RouteMap } from "./RouteMap";
import { useT } from "../../lib/i18n";
import type { PlaceRef } from "../../lib/schema/models";
import type { PlaceRef, TravelMode } from "../../lib/schema/models";
import type { MyPlace } from "./myPlaces";

// 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
// to reconstructing flights, and most people never log them as visits).
// • Map — a lightweight offline SVG map of your places (no MapLibre/tiles); tap a pin.
// • Map — the app's REAL MapLibre map (offline, bundled land) of your places; tap a
// pin to add it in sequence and watch the route draw (see RouteMap).
// Flags everywhere for instant recognition (spec 019).

const W = 720;
const H = 380;
const PAD = 34;
const MIN_SPAN = 0.35;
const mercY = (lat: number) => Math.log(Math.tan(Math.PI / 4 + (Math.max(-85, Math.min(85, lat)) * Math.PI) / 360));

const flagFor = (p: PlaceRef) => (p.kind === "airport" ? "✈️" : countryFlag(p.countryId));

export function MyPlacesPicker({
places,
addedKeys,
onPick,
stops,
travelMode,
}: {
places: MyPlace[];
addedKeys: Set<string>;
onPick: (place: PlaceRef) => void;
/** The route so far — drives the live arc + the "added" pin rings on the map. */
stops: PlaceRef[];
travelMode: TravelMode;
}) {
const t = useT();
const ref = useMemo(() => getReferenceData(), []);
Expand Down Expand Up @@ -131,114 +131,8 @@ export function MyPlacesPicker({
) : places.length === 0 ? (
<p className="muted empty">{t("trip.compose.noPlaces")}</p>
) : (
<PickMap places={places} addedKeys={addedKeys} onPick={onPick} />
<RouteMap pool={places} stops={stops} mode={travelMode} addedKeys={addedKeys} onPick={onPick} />
)}
</div>
);
}

/** The offline SVG map: your places as pins, tap to add. Pins are decorative for
* assistive tech; the legend list below is the keyboard/AT path (WCAG). */
function PickMap({
places,
addedKeys,
onPick,
}: {
places: MyPlace[];
addedKeys: Set<string>;
onPick: (place: PlaceRef) => void;
}) {
const t = useT();
const layout = useMemo(() => {
const X = places.map((p) => (p.lon * Math.PI) / 180);
const Y = places.map((p) => mercY(p.lat));
let minX = Math.min(...X);
let maxX = Math.max(...X);
let minY = Math.min(...Y);
let maxY = Math.max(...Y);
if (maxX - minX < MIN_SPAN) {
const c = (minX + maxX) / 2;
minX = c - MIN_SPAN / 2;
maxX = c + MIN_SPAN / 2;
}
if (maxY - minY < MIN_SPAN) {
const c = (minY + maxY) / 2;
minY = c - MIN_SPAN / 2;
maxY = c + MIN_SPAN / 2;
}
let spanX = maxX - minX;
let spanY = maxY - minY;
minX -= spanX * 0.16;
maxX += spanX * 0.16;
minY -= spanY * 0.2;
maxY += spanY * 0.2;
spanX = maxX - minX;
spanY = maxY - minY;
const scale = Math.min((W - 2 * PAD) / spanX, (H - 2 * PAD) / spanY);
const midX = (minX + maxX) / 2;
const midY = (minY + maxY) / 2;
const sx = (x: number) => W / 2 + (x - midX) * scale;
const sy = (y: number) => H / 2 - (y - midY) * scale;
let land = "";
for (const ring of LAND_OUTLINE) {
for (const off of [-360, 0, 360]) {
let seg = "";
let any = false;
let unwrapped = 0;
let prevRaw: number | null = null;
for (let i = 0; i < ring.length; i++) {
const llon = ring[i]![0];
const llat = ring[i]![1];
if (prevRaw === null) unwrapped = llon + off;
else {
let d = llon - prevRaw;
if (d > 180) d -= 360;
else if (d < -180) d += 360;
unwrapped += d;
}
prevRaw = llon;
const Lx = sx((unwrapped * Math.PI) / 180);
const Ly = sy(mercY(llat));
seg += (i === 0 ? "M" : "L") + Lx.toFixed(1) + " " + Ly.toFixed(1);
if (Lx > -60 && Lx < W + 60 && Ly > -60 && Ly < H + 60) any = true;
}
if (any) land += seg + "Z";
}
}
const dots = places.map((p) => ({ ...p, x: sx((p.lon * Math.PI) / 180), y: sy(mercY(p.lat)) }));
return { land, dots };
}, [places]);

return (
<div className="myplaces-map">
<svg className="storymap-svg" viewBox={`0 0 ${W} ${H}`} role="img" aria-label={t("trip.compose.mapAria")}>
{layout.land && <path className="storymap-land" d={layout.land} />}
{layout.dots.map((p) => (
<g key={p.key} className="storymap-pin" aria-hidden onClick={() => onPick(p.place)}>
<circle cx={p.x} cy={p.y} r={addedKeys.has(p.key) ? 8 : 6} className="storymap-pin-dot" />
</g>
))}
</svg>
<ul className="storymap-legend myplaces-legend">
{layout.dots.map((p) => (
<li key={p.key}>
<button
type="button"
className="link"
aria-label={t("trip.compose.pickAria", { name: p.name })}
onClick={() => onPick(p.place)}
>
{flagFor(p.place)} {p.name}
{addedKeys.has(p.key) && (
<span className="myplaces-added" aria-hidden>
{" "}
✓
</span>
)}
</button>
</li>
))}
</ul>
</div>
);
}
Loading
Loading