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
5 changes: 4 additions & 1 deletion apps/postcards/scripts/build-reader-land.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import { readFileSync, writeFileSync } from "node:fs";
import { feature } from "topojson-client";
import { unwrapAntimeridian } from "../src/features/map/antimeridian.ts";

const SRC = new URL("../public/basemap/countries-50m.json", import.meta.url);
const OUT = new URL("../src/lib/publish/landOutline.ts", import.meta.url);
Expand All @@ -21,7 +22,9 @@ const MIN_AREA_DEG2 = 1.6; // drop rings whose lon×lat bbox area is below this
const COORD_DECIMALS = 1; // 0.1° grid — plenty for a small context map

const topo = JSON.parse(readFileSync(SRC, "utf8"));
const land = feature(topo, topo.objects.land);
// Unwrapped at the antimeridian first: the reader draws on a plane, where a
// ring jumping from +180 to -180 streaks across the map.
const land = unwrapAntimeridian(feature(topo, topo.objects.land));

/** Collect every polygon ring (outer + holes ignored — silhouette only). */
function collectRings(geo) {
Expand Down
3 changes: 2 additions & 1 deletion apps/postcards/src/features/map/MapView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from "./visitedLayers";
import { prefetchAroundBounds, prefetchAroundPoint, OSM_TILE_TEMPLATE } from "../../lib/offline/tiles";
import { markerCitiesInView, type Bounds } from "./viewport";
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";
Expand Down Expand Up @@ -69,7 +70,7 @@ async function fetchCountries(): Promise<FeatureCollection<Polygon | MultiPolygo
for (const f of fc.features) {
f.properties = { ...(f.properties ?? {}), numeric: String(f.id ?? "") };
}
return fc;
return unwrapAntimeridian(fc);
} catch {
return null;
}
Expand Down
78 changes: 78 additions & 0 deletions apps/postcards/src/features/map/antimeridian.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { FeatureCollection, MultiPolygon, Polygon, Position } from "geojson";

// world-atlas stores rings in spherical coordinates: a ring crossing the
// antimeridian jumps from +180 to -180 between two vertices, and Antarctica's
// coast wraps the whole globe around the pole. MapLibre draws them on a plane,
// so that jump becomes an edge spanning the whole map and a visited Russia or
// Fiji paints a horizontal band across it. Each ring is therefore unwrapped
// into continuous longitudes and closed through the pole when it circles one —
// once, when the geometry loads. The rings are not cut at ±180: MapLibre wraps
// a shape running past the edge into the next world copy itself, where a cut
// would be drawn as a border line down the 180th meridian.

type Ring = Position[];

/** Continuous longitudes: every jump of more than 180° is taken the short way. */
function unwrap(ring: Ring): Ring {
const out: Ring = [];
let off = 0;
let prev: number | null = null;
for (const [lon, lat] of ring) {
if (prev !== null) {
while (lon! + off - prev > 180) off -= 360;
while (lon! + off - prev < -180) off += 360;
}
prev = lon! + off;
out.push([prev, lat!]);
}
return out;
}

function area(ring: Ring): number {
let s = 0;
for (let i = 0; i < ring.length; i++) {
const a = ring[i]!;
const b = ring[(i + 1) % ring.length]!;
s += a[0]! * b[1]! - b[0]! * a[1]!;
}
return s / 2;
}

/** A ring around a pole ends 360° from where it started: close it along the
* pole's latitude so it encloses the cap instead of a sliver. */
function closePole(ring: Ring): Ring {
const first = ring[0]!;
const last = ring[ring.length - 1]!;
if (Math.abs(last[0]! - first[0]!) <= 180) return ring;
const poleLat = ring.reduce((s, p) => s + p[1]!, 0) < 0 ? -90 : 90;
return [...ring, [last[0]!, poleLat], [first[0]!, poleLat], [first[0]!, first[1]!]];
}

const minLon = (ring: Ring) => ring.reduce((m, p) => Math.min(m, p[0]!), Infinity);

/** Shift `ring` by whole turns so its westernmost point lies in [west, west + 360). */
function shiftInto(ring: Ring, west: number): Ring {
const turns = Math.floor((minLon(ring) - west) / 360);
return turns === 0 ? ring : ring.map(([x, y]) => [x! - turns * 360, y!]);
}

/** Unwrap a polygon's rings, its holes kept in the same world copy as its
* outline. A ring with no area on the plane goes: Antarctica's outline is a
* circle just off the south pole, with its coast as the hole around it. */
function unwrapPolygon(rings: Ring[]): Ring[] {
const kept = rings.map(unwrap).filter((r) => Math.abs(area(r)) > 1e-6);
if (!kept.length) return rings;
const outer = shiftInto(closePole(kept[0]!), -180);
const west = minLon(outer);
return [outer, ...kept.slice(1).map((r) => shiftInto(closePole(r), west))];
}

/** Country outlines safe to draw on a plane: no edge jumps across ±180°. */
export function unwrapAntimeridian<T extends FeatureCollection<Polygon | MultiPolygon>>(fc: T): T {
for (const f of fc.features) {
const g = f.geometry;
if (g.type === "Polygon") g.coordinates = unwrapPolygon(g.coordinates);
else g.coordinates = g.coordinates.map(unwrapPolygon);
}
return fc;
}
35 changes: 8 additions & 27 deletions apps/postcards/src/features/passport/poster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,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 { unwrapAntimeridian } from "../map/antimeridian";

const GEOMETRY_URL = `${import.meta.env.BASE_URL}basemap/countries-50m.json`;

Expand All @@ -18,25 +19,6 @@ function project([lon, lat]: Position): [number, number] {
return [((lon + 180) / 360) * W, ((90 - lat) / 180) * MAP_H];
}

/**
* Unwrap a ring's longitudes into a continuous sequence (may run past ±180).
* Natural Earth stores Russia/Fiji with rings that jump across the antimeridian;
* projecting those jumps linearly smears a fill band across the whole map.
*/
function unwrapRing(ring: Position[]): Position[] {
let prev: number | null = null;
let off = 0;
return ring.map(([lon, lat]) => {
if (prev !== null) {
while (lon! + off - prev > 180) off -= 360;
while (lon! + off - prev < -180) off += 360;
}
const l = lon! + off;
prev = l;
return [l, lat!];
});
}

function drawRing(ctx: CanvasRenderingContext2D, ring: Position[], lonShift = 0): void {
ring.forEach((pt, i) => {
const [x, y] = project([pt[0]! + lonShift, pt[1]!]);
Expand All @@ -46,10 +28,9 @@ function drawRing(ctx: CanvasRenderingContext2D, ring: Position[], lonShift = 0)
ctx.closePath();
}

/** Draw one ring, duplicated ±360° when it runs past the map edge after
* unwrapping, so an antimeridian-crossing shape appears on both sides. */
function drawWrappedRing(ctx: CanvasRenderingContext2D, rawRing: Position[]): void {
const ring = unwrapRing(rawRing);
/** Draw one ring, duplicated ±360° when it runs past the map edge (the rings
* arrive unwrapped), so an antimeridian-crossing shape appears on both sides. */
function drawWrappedRing(ctx: CanvasRenderingContext2D, ring: Position[]): void {
let minLon = Infinity;
let maxLon = -Infinity;
for (const [lon] of ring as [number, number][]) {
Expand All @@ -67,7 +48,7 @@ function flagAnchor(geom: Polygon | MultiPolygon): [number, number] {
let best: [number, number] | null = null;
let bestArea = -1;
for (const p of polys) {
const ring = unwrapRing(p[0]!);
const ring = p[0]!;
let minX = Infinity, maxX = -Infinity, minY = 90, maxY = -90;
for (const [x, y] of ring as [number, number][]) {
minX = Math.min(minX, x); maxX = Math.max(maxX, x);
Expand Down Expand Up @@ -103,9 +84,9 @@ export async function renderPoster(
if (!res.ok) throw new Error("map geometry unavailable");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const topo: any = await res.json();
const fc = feature(topo, topo.objects.countries) as unknown as FeatureCollection<
Polygon | MultiPolygon
>;
const fc = unwrapAntimeridian(
feature(topo, topo.objects.countries) as unknown as FeatureCollection<Polygon | MultiPolygon>,
);

const canvas = document.createElement("canvas");
canvas.width = W;
Expand Down
13 changes: 8 additions & 5 deletions apps/postcards/src/features/stats/CountryCoverageMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,14 @@ export function CountryCoverageMap({ iso2, name }: { iso2: string; name: string
}
}
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;
// Unwrap across the antimeridian: when the frame runs past 180° (the land
// arrives unwrapped, so Russia's mainland reaches 191°) or spans > 180° of
// raw longitude, the country 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 and Chukotka's towns fall off the map.
const unwrap =
Math.max(...frameLons) > 180 || 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);
Expand Down
7 changes: 5 additions & 2 deletions apps/postcards/src/features/travel/landGeometry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { FeatureCollection } from "geojson";
import type { FeatureCollection, MultiPolygon, Polygon } from "geojson";
import { feature } from "topojson-client";
import { unwrapAntimeridian } from "../map/antimeridian";

// Bundled, offline land geometry for the composer's route map — the SAME public
// Natural Earth asset the main map uses (public domain, SW-precached), fetched
Expand All @@ -19,7 +20,9 @@ export function getLand(): Promise<FeatureCollection | null> {
if (!res.ok) return null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const topo: any = await res.json();
return feature(topo, topo.objects.countries) as unknown as FeatureCollection;
return unwrapAntimeridian(
feature(topo, topo.objects.countries) as unknown as FeatureCollection<Polygon | MultiPolygon>,
);
} catch {
return null;
}
Expand Down
4 changes: 2 additions & 2 deletions apps/postcards/src/lib/publish/landOutline.ts

Large diffs are not rendered by default.

Loading
Loading