Files
eranos/scripts/build-land-polygons.mjs
T
Chad Curtis 2c8cd11153 Rebuild campaigns hero around photo BG + globe + spotlight
The hero is now layered like Treasures' HeroGallery:

- CampaignHeroBackground (new) — full-bleed banner image from the
  currently-spotlit campaign, crossfading over ~1.5 s and panning left.
  Warm tint + film grain overlay so foreground text stays legible.
- HeroGlobe — pushed to the right edge with a larger radius, slightly
  translucent so the photo bleeds through. Hearts replace the old dots
  for marker symbols; clicking one selects that campaign.
- HeroCampaignSpotlight (new) — minimal text overlay anchored to the
  bottom-left of the hero container (title, summary, avatar + author,
  location, progress bar with goal, 'View' link). No card chrome.

Land polygons are now the full Natural Earth 110m fidelity (~10.5k
vertices) instead of being heavily Douglas-Peucker'd, so coastlines
look organic rather than chunky. Back-hemisphere rings are now
properly hidden by walking each edge and either dropping back-side
vertices outright or interpolating to the sphere limb where a ring
crosses it — fixes the 'phantom continents through the front' bug.
Rings additionally fade in/out over a narrow z-band near the limb
instead of popping at z = 0.

Markers also have proper z-fade and pull off-canvas when on the back
so they can't intercept clicks they aren't visible for. Selected
markers scale 1.35x with a stronger glow so the user can tell which
campaign the spotlight refers to.

Other cleanup:

- formatCampaignAmount + formatSatsShort move out of CampaignCard.tsx
  into src/lib/formatCampaignAmount.ts so CampaignCard stops failing
  the react-refresh/only-export-components lint.
- Hero CTAs drop the 'Unstoppable fundraising on Nostr' pill and the
  em dash from the supporting copy.
- New keyframes (heroPanLeft / heroPanRight) for the slow Ken-Burns
  pan on the background photos, with prefers-reduced-motion respected.
2026-05-17 21:15:05 -05:00

121 lines
3.6 KiB
JavaScript

// Build a heavily-simplified land-polygon dataset for the hero globe.
//
// Input: Natural Earth 110m countries TopoJSON
// (https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json)
//
// Output: src/lib/landPolygons.ts — an array of rings (each ring is a flat
// array [lng0, lat0, lng1, lat1, ...]) representing landmasses.
//
// Run with: node scripts/build-land-polygons.mjs
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..');
const INPUT = process.argv[2] ?? '/tmp/opencode/countries-110m.json';
const OUTPUT = path.join(REPO_ROOT, 'src/lib/landPolygons.ts');
const topo = JSON.parse(fs.readFileSync(INPUT, 'utf8'));
const layer = topo.objects.countries;
const transform = topo.transform;
/** Decode a topojson arc into absolute [lng, lat] pairs. */
function decodeArc(arc) {
const out = [];
let x = 0;
let y = 0;
for (const [dx, dy] of arc) {
x += dx;
y += dy;
out.push([
x * transform.scale[0] + transform.translate[0],
y * transform.scale[1] + transform.translate[1],
]);
}
return out;
}
const arcs = topo.arcs.map(decodeArc);
/** Resolve a topojson arc index (negative means reversed) into points. */
function resolveArc(i) {
if (i < 0) {
const arc = arcs[~i];
return arc.slice().reverse();
}
return arcs[i];
}
/** Build a ring from an array of arc indices. */
function buildRing(arcIndices) {
const ring = [];
for (let i = 0; i < arcIndices.length; i++) {
const seg = resolveArc(arcIndices[i]);
// Skip the duplicated joining point between consecutive arcs.
if (i === 0) ring.push(...seg);
else ring.push(...seg.slice(1));
}
return ring;
}
const rings = [];
for (const feature of layer.geometries) {
if (feature.type === 'Polygon') {
for (const arcIndices of feature.arcs) {
rings.push(buildRing(arcIndices));
}
} else if (feature.type === 'MultiPolygon') {
for (const polygon of feature.arcs) {
for (const arcIndices of polygon) {
rings.push(buildRing(arcIndices));
}
}
}
}
// Use the full Natural Earth 110m resolution. We skip Douglas-Peucker
// entirely so coastlines look organic at hero scale rather than blocky.
// We still quantize to 0.1° (well below the rendered pixel size on a
// ~600 px globe) which is a free ~30 % byte saving with no visible loss.
const MIN_VERTS = 3;
const simplifiedRings = [];
for (const ring of rings) {
if (ring.length < MIN_VERTS) continue;
const flat = [];
for (const [lng, lat] of ring) {
flat.push(Math.round(lng * 10) / 10, Math.round(lat * 10) / 10);
}
simplifiedRings.push(flat);
}
const totalCoords = simplifiedRings.reduce((sum, r) => sum + r.length / 2, 0);
const banner = `/**
* Simplified land polygons for the hero globe.
*
* Generated from Natural Earth 110m country boundaries via
* \`scripts/build-land-polygons.mjs\`. Each entry is a flat \`[lng, lat, lng,
* lat, ...]\` ring. We keep the data inline (rather than fetching a TopoJSON
* blob at runtime) so the hero renders instantly, with no network jitter and
* no extra runtime dependency.
*
* Do not edit by hand — re-run the script to regenerate.
*/
`;
const body = `export const LAND_RINGS: readonly (readonly number[])[] = [\n${
simplifiedRings.map((r) => ` [${r.join(',')}],`).join('\n')
}\n];\n`;
fs.writeFileSync(OUTPUT, banner + body);
console.log(
`Wrote ${OUTPUT}`,
`\n rings: ${simplifiedRings.length}`,
`\n vertices: ${totalCoords}`,
`\n bytes: ${fs.statSync(OUTPUT).size}`,
);