diff --git a/scripts/build-land-polygons.mjs b/scripts/build-land-polygons.mjs index 9e0cf472..e0bcb90c 100644 --- a/scripts/build-land-polygons.mjs +++ b/scripts/build-land-polygons.mjs @@ -17,45 +17,6 @@ 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'); -/** Douglas-Peucker on a polyline of [lng, lat] pairs. */ -function simplify(points, tolerance) { - if (points.length < 3) return points.slice(); - const sqTol = tolerance * tolerance; - - function sqSegDist(p, a, b) { - let x = a[0], y = a[1]; - let dx = b[0] - x, dy = b[1] - y; - if (dx !== 0 || dy !== 0) { - const t = ((p[0] - x) * dx + (p[1] - y) * dy) / (dx * dx + dy * dy); - if (t > 1) { x = b[0]; y = b[1]; } - else if (t > 0) { x += dx * t; y += dy * t; } - } - dx = p[0] - x; - dy = p[1] - y; - return dx * dx + dy * dy; - } - - function dp(first, last, simplified) { - let maxSqDist = sqTol; - let index = -1; - for (let i = first + 1; i < last; i++) { - const sqDist = sqSegDist(points[i], points[first], points[last]); - if (sqDist > maxSqDist) { index = i; maxSqDist = sqDist; } - } - if (index !== -1) { - if (index - first > 1) dp(first, index, simplified); - simplified.push(points[index]); - if (last - index > 1) dp(index, last, simplified); - } - } - - const last = points.length - 1; - const simplified = [points[0]]; - dp(0, last, simplified); - simplified.push(points[last]); - return simplified; -} - const topo = JSON.parse(fs.readFileSync(INPUT, 'utf8')); const layer = topo.objects.countries; const transform = topo.transform; @@ -114,19 +75,17 @@ for (const feature of layer.geometries) { } } -// Aggressive simplification: tolerance is in degrees. ~1.2° drops most coastal -// noise while keeping continent shapes recognizable at hero scale. -const TOLERANCE_DEG = 1.2; -// Drop tiny islands whose simplified ring would be near-useless. -const MIN_VERTS_AFTER = 4; +// 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) { - const s = simplify(ring, TOLERANCE_DEG); - if (s.length < MIN_VERTS_AFTER) continue; - // Quantize to 0.1° to shave bytes — well below the resolution we render at. + if (ring.length < MIN_VERTS) continue; const flat = []; - for (const [lng, lat] of s) { + for (const [lng, lat] of ring) { flat.push(Math.round(lng * 10) / 10, Math.round(lat * 10) / 10); } simplifiedRings.push(flat); diff --git a/src/components/CampaignCard.tsx b/src/components/CampaignCard.tsx index a0b14389..511de399 100644 --- a/src/components/CampaignCard.tsx +++ b/src/components/CampaignCard.tsx @@ -14,24 +14,12 @@ import { type ParsedCampaign, encodeCampaignNaddr, } from '@/lib/campaign'; -import { fetchBtcPrice, satsToUSDWhole } from '@/lib/bitcoin'; +import { fetchBtcPrice } from '@/lib/bitcoin'; +import { formatCampaignAmount } from '@/lib/formatCampaignAmount'; import { genUserName } from '@/lib/genUserName'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { cn } from '@/lib/utils'; -/** Formats a sats count into `1,234,567 sats` or `0.012 BTC` once large. */ -function formatSatsShort(sats: number): string { - if (sats >= 100_000_000) return `${(sats / 100_000_000).toFixed(2)} BTC`; - if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(2)}M sats`; - if (sats >= 10_000) return `${(sats / 1_000).toFixed(0)}K sats`; - return `${sats.toLocaleString()} sats`; -} - -function formatCampaignAmount(sats: number, btcPrice: number | undefined): string { - if (btcPrice) return satsToUSDWhole(sats, btcPrice); - return formatSatsShort(sats); -} - function formatDeadline(unixSeconds: number): { label: string; isPast: boolean } { const now = Math.floor(Date.now() / 1000); const diff = unixSeconds - now; diff --git a/src/components/CampaignHeroBackground.tsx b/src/components/CampaignHeroBackground.tsx new file mode 100644 index 00000000..e72d98b5 --- /dev/null +++ b/src/components/CampaignHeroBackground.tsx @@ -0,0 +1,108 @@ +import { useEffect, useRef, useState } from 'react'; + +import { sanitizeUrl } from '@/lib/sanitizeUrl'; +import { cn } from '@/lib/utils'; +interface CampaignHeroBackgroundProps { + /** + * Image URL for the active campaign. Each new URL crossfades over the + * previous one — we keep up to two layers mounted at a time so the + * transition is smooth even when the source changes mid-fade. + */ + imageUrl: string | undefined; + /** Optional className for the outer wrapper. */ + className?: string; +} + +interface Layer { + /** Stable key so React doesn't tear down the layer mid-transition. */ + id: number; + /** Sanitized URL (or `null` for the gradient-only fallback). */ + url: string | null; +} + +const FADE_MS = 1500; + +/** + * Full-bleed crossfading background built from the active campaign's banner + * image. Modelled after Treasures' HeroGallery: each image gets its own + * stacked layer and we toggle opacity to crossfade. The previous layer + * unmounts after the fade completes, so we never accumulate more than a + * couple of layers in the DOM. + * + * A warm tint + subtle film-grain SVG sit on top so headlines stay readable + * over any photo. + */ +export function CampaignHeroBackground({ imageUrl, className }: CampaignHeroBackgroundProps) { + const idRef = useRef(0); + const [layers, setLayers] = useState([]); + const lastUrlRef = useRef(null); + + useEffect(() => { + const safe = sanitizeUrl(imageUrl) ?? null; + if (safe === lastUrlRef.current) return; + lastUrlRef.current = safe; + + const id = ++idRef.current; + // Add the new layer; existing layers stay mounted so the crossfade has + // something to fade from. + setLayers((prev) => [...prev, { id, url: safe }]); + + // After the fade completes, drop everything except the most recent + // layer to keep the DOM tidy. + const timeout = window.setTimeout(() => { + setLayers((prev) => prev.filter((l) => l.id === id)); + }, FADE_MS + 50); + return () => window.clearTimeout(timeout); + }, [imageUrl]); + + return ( +