Compare commits

...

25 Commits

Author SHA1 Message Date
sam 53828741e1 Merge branch 'main' into cooking/planetora 2026-05-23 11:21:31 +07:00
sam 4cd2aadba2 Lift the Planetora event panel off the background
The panel was bg-background/40 + backdrop-blur — a fogged version of
the same hue as the warm dawn-toned page background, which on mobile
made the bottom sheet visually merge with the globe area underneath.

Now:
- Surface fill bumped to bg-background/85 so the panel reads as a
  distinct surface instead of a tinted overlay (backdrop-blur is
  retained so a hint of the globe still glows through).
- Border tightened to border-border/70 plus a hairline foreground ring
  to crisp the edge against any backdrop colour.
- Mobile sheet gets an explicit two-direction shadow (top + bottom)
  rather than shadow-2xl, which only casts downward — for a sheet
  pinned to the bottom of the viewport that downward cast falls off
  screen and leaves no visible separation. The new top-edge shadow is
  what actually lifts the sheet off the globe.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 19:58:41 +07:00
sam e57a3029f5 Track event latitude when focusing the camera
The selected event eased toward lng=0 (centre of the visible
hemisphere) but latitude stayed pinned at the resting tilt
(VIEW_TILT_DEG = 20°). For events near the equator that pushed the
marker well below the disc's centre — and on mobile, where the bottom
sheet sits over the lower half of the screen, the focused location
ended up partly behind the panel (e.g. a Venezuelan post sitting at
~8°N landed roughly 50 px below the globe's centre, right in the
panel's overlap zone).

Now both rotLng and rotLat ease toward the selected event's coords so
the focal point lands at the centre of the disc, well clear of the
mobile panel. With nothing selected the camera settles back to
VIEW_TILT_DEG. The existing depth-based fading uses the same eased
latitude so rings and the selected dot continue to fade correctly as
the globe tumbles.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 19:56:20 +07:00
sam 7590af5a76 Drop the orphaned WebGL Planetora globe and its react-globe.gl dep
PlanetoraGlobe.tsx hadn't been rendered since the SVG renderer landed —
only its PlanetoraTheme type was still referenced. Move the type into
PlanetoraSvgGlobe.tsx (next to the only renderer that consumes it),
delete the legacy file, and trim WARM_THEME of the WebGL-only fields
(countrySideFill, atmosphereColor, background) along with their now
defunct comments.

react-globe.gl (which pulled in three.js, ~280 kB gzip) drops out
entirely — `npm uninstall react-globe.gl` cleans node_modules and the
shipped bundle. Stale doc-comment references to PlanetoraGlobe in the
playback / auto-pilot hooks are pointed at PlanetoraSvgGlobe.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 19:53:17 +07:00
sam 605e7f599f Shift the Planetora globe up (not down) when the mobile panel opens
When the bottom-sheet event panel was opened on mobile, the SVG canvas
was extended off the bottom of the viewport — which centres the globe
*lower* in screen space, sliding it down behind the panel. Flip the
extension to the top edge instead so the globe centres in the
panel-free band above the sheet, the way it does on desktop with the
right-side panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 19:36:51 +07:00
sam f5bc774aba Hide the Planetora bottom HUD on mobile while an event panel is open
The bottom-sheet event panel and the timeline / live-stat HUD both
anchor to the bottom of the viewport on mobile, so they fight for the
same pixels — the panel covers the HUD and the page reads as
cluttered. Add a hideBottomHud flag and toggle it on whenever the
mobile event panel is up; the HUD fades out so the panel is the
unambiguous focus, and fades back in when the panel closes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 19:16:58 +07:00
sam 3b125592d1 Layer the homepage's atmospheric backdrop behind Planetora
Tracking bg-background was correct for theme awareness but left the
page flat — solid cream in light mode, solid black in dark mode —
which read as nothing like the homepage's hero. Layer in the same
recipe: a warm/cool primary→secondary gradient wash, a directional
dawn-gold scrim from the upper-left, a big radial sunrise glow
brightened with mix-blend-screen, a thin top-edge sliver of
sunrise light, and the homepage's fractal-noise film grain.

Adds peach corners + a soft warm glow in light mode and a deep warm
atmosphere around the globe in dark mode, matching the hero
section's vibe without copying its content.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 15:38:02 +07:00
sam e318ca0550 Have Planetora's page background follow the app's light/dark theme
Pinning Planetora to a fixed dawn-sky gradient broke the visual
contract with the rest of the app — the homepage tracks bg-background
(cream in light mode, near-black in dark mode) and the warm globe
already pops in either, so there's no reason for Planetora to pick
its own background. The page now uses bg-background like everything
else; the warm sphere palette stays fixed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 15:31:28 +07:00
sam af36a9c7d5 Lighten the Planetora page background to a dawn sky gradient
Even with the warm globe palette in place, sitting it on a near-black
slab still made the page feel heavy. Swap the solid dusk navy for a
pale dawn sky — powder blue overhead easing into peach-cream near the
horizon — so the cream globe sits in light instead of a void, and the
honey-shaded limb naturally blends into the warmer lower band.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 15:27:53 +07:00
sam d7729b705e Recolour Planetora globe with the homepage's warm dawn palette
A dark globe with bright orange splashes was reading as a
surveillance / situation-room dashboard rather than a friendly
community-activity visualiser. Planetora now uses the same warm
"dawn earth" tones as the homepage HeroGlobe — a cream→honey radial
sphere, sandy-amber inactive countries, terracotta active countries,
cream pulse rings, and a soft dusk-navy page background — regardless
of the surrounding app's light/dark mode.

Adds three sphere-shading fields (sphereCentre / sphereMid /
sphereEdge) to the shared PlanetoraTheme so the SVG renderer can
paint a real lit sphere instead of stacking opacities of the page
background. Drops the now-unused light/dark CSS-variable observer in
the page (the palette is fixed) along with its hsl() helper.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 15:23:41 +07:00
sam e04342668b Add a continuous ping animation to the selected event marker
The selected marker was just a static halo ring around a dot, which
made the focused event read as a flat target. Two phase-offset pulse
rings now expand outwards continuously while the marker is shown,
matching the visual language of the per-event rings so a focused event
still feels alive.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 15:16:08 +07:00
sam 80b56b3318 Render Planetora countries with d3-geo orthographic projection
Drops the hand-rolled projection / hemisphere clipper for d3-geo's
geoOrthographic + geoPath. The previous implementation cut a straight
chord across the back of the disc when a polygon (Russia, Greenland,
Antarctica…) crossed from front to back to front, leaving abrupt flat
edges along the limb. d3 walks the limb arc properly via clipAngle(90),
so the silhouette matches the sphere.

Also drops the camera "zoom" by lowering RADIUS from 285 to 250, giving
the globe ~17% more breathing room from the viewport edges.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 15:05:25 +07:00
sam 3ade1e8126 Cap Planetora event panel height to clear the bottom HUD
The panel anchored at top-36 with max-h calc(100vh-10rem) could extend
to within 16px of the bottom edge, overlapping the "now showing" summary
that lives inset-x-0 bottom-0. Reserving 18rem (≈9rem top + 9rem
bottom) instead keeps tall notes scrollable inside the panel body
while leaving the timeline / live HUD visible underneath.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 14:55:41 +07:00
sam 279c8b914c Drop the orange limb glow on the SVG globe
The rim gradient was layering a thick atmosphere-coloured band onto the
sphere's edge, which on the dark theme reads as a glowing orange halo
ring. Sphere shading is fine without it — the base radial fill plus the
highlight already give the disc enough depth.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 14:54:13 +07:00
sam 9313e9b1d7 Shift Planetora globe off-centre when the event panel is open
Restores the layout the WebGL globe had: the SVG canvas now extends off
the left edge (desktop) or bottom edge (mobile) by the panel's width or
height, so the globe sits centred in the *panel-free* space and stops
visually colliding with the opened note.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 14:45:14 +07:00
sam 9ca70dfcc2 Swap WebGL globe for a pure-SVG renderer in Planetora
Drops the dependency on react-globe.gl / WebGL so the page works on
machines and browsers that fall back to software rendering. Country
polygons and pulse rings are projected each rAF tick and applied
imperatively to ref'd SVG nodes, so the React tree stays quiet during
animation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 14:34:31 +07:00
sam 4067904e09 Merge branch 'main' into cooking/planetora 2026-05-21 13:51:53 +07:00
sam 523235e043 live events from user relays too 2026-05-20 22:43:38 +07:00
sam 14ca8999ad live mode 2026-05-20 22:16:10 +07:00
sam 9dcc183044 link in footer to planetora page 2026-05-20 20:35:17 +07:00
sam 7a519ba341 Merge branch 'main' into cooking/planetora 2026-05-20 20:18:22 +07:00
sam aeb73e941b transparency++ 2026-05-15 14:47:12 +07:00
sam 9fed3bc0b7 edge case to avoid stale data getting stuck 2026-05-15 12:57:36 +07:00
sam 6555253224 perf 2026-05-15 12:43:36 +07:00
sam 4ad6feac5d first draft - planetora; a visualisation/hub of agora activity 2026-05-15 12:23:07 +07:00
16 changed files with 2625 additions and 4 deletions
+67 -1
View File
@@ -102,6 +102,7 @@
"@scure/btc-signer": "^2.2.0",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
"@types/d3-geo": "^3.1.0",
"@unhead/addons": "^2.1.13",
"@unhead/react": "^2.1.13",
"blurhash": "^2.0.5",
@@ -110,6 +111,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"d3-celestial": "^0.7.35",
"d3-geo": "^3.1.1",
"d3-scale": "^4.0.2",
"date-fns": "^3.6.0",
"dompurify": "^3.3.3",
@@ -145,6 +147,7 @@
"smol-toml": "^1.6.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"topojson-client": "^3.1.0",
"uri-templates": "^0.2.0",
"vaul": "^1.1.2",
"zod": "^4.3.6"
@@ -167,6 +170,8 @@
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
"@types/topojson-specification": "^1.0.5",
"@vitejs/plugin-react": "^6.0.1",
"@webbtc/webln-types": "^3.0.0",
"@webxdc/types": "^2.1.2",
@@ -6623,6 +6628,15 @@
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
@@ -6716,7 +6730,6 @@
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/hast": {
@@ -6834,6 +6847,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/topojson-client": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
"integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*",
"@types/topojson-specification": "*"
}
},
"node_modules/@types/topojson-specification": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz",
"integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -8375,6 +8409,18 @@
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
@@ -14429,6 +14475,26 @@
"node": ">=8.0"
}
},
"node_modules/topojson-client": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
"license": "ISC",
"dependencies": {
"commander": "2"
},
"bin": {
"topo2geo": "bin/topo2geo",
"topomerge": "bin/topomerge",
"topoquantize": "bin/topoquantize"
}
},
"node_modules/topojson-client/node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+5
View File
@@ -109,6 +109,7 @@
"@scure/btc-signer": "^2.2.0",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
"@types/d3-geo": "^3.1.0",
"@unhead/addons": "^2.1.13",
"@unhead/react": "^2.1.13",
"blurhash": "^2.0.5",
@@ -117,6 +118,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"d3-celestial": "^0.7.35",
"d3-geo": "^3.1.1",
"d3-scale": "^4.0.2",
"date-fns": "^3.6.0",
"dompurify": "^3.3.3",
@@ -152,6 +154,7 @@
"smol-toml": "^1.6.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"topojson-client": "^3.1.0",
"uri-templates": "^0.2.0",
"vaul": "^1.1.2",
"zod": "^4.3.6"
@@ -174,6 +177,8 @@
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
"@types/topojson-specification": "^1.0.5",
"@vitejs/plugin-react": "^6.0.1",
"@webbtc/webln-types": "^3.0.0",
"@webxdc/types": "^2.1.2",
File diff suppressed because one or more lines are too long
+2
View File
@@ -88,6 +88,7 @@ const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
const FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
const PlanetoraPage = lazy(() => import("./pages/PlanetoraPage").then(m => ({ default: m.PlanetoraPage })));
const ReceivePage = lazy(() => import("./pages/ReceivePage").then(m => ({ default: m.ReceivePage })));
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
@@ -164,6 +165,7 @@ export function AppRouter() {
<Route path="/receive" element={<ReceivePage />} />
{/* All routes share the persistent FundraiserLayout (top nav + footer) */}
<Route path="/planetora" element={<PlanetoraPage />} />
<Route element={<FundraiserLayout />}>
<Route path="/" element={<CampaignsPage />} />
<Route path="/feed" element={<Index />} />
+4 -3
View File
@@ -106,9 +106,10 @@ function SiteFooter() {
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-muted-foreground">
<span>&copy; {new Date().getFullYear()} Agora. Fundraisers on Nostr.</span>
<nav className="flex items-center gap-5">
<Link to="/help" className="hover:text-foreground motion-safe:transition-colors">Help</Link>
<Link to="/privacy" className="hover:text-foreground motion-safe:transition-colors">Privacy</Link>
<Link to="/safety" className="hover:text-foreground motion-safe:transition-colors">Safety</Link>
<a href="/planetora" className="hover:text-foreground motion-safe:transition-colors">Planetora</a>
<a href="/help" className="hover:text-foreground motion-safe:transition-colors">Help</a>
<a href="/privacy" className="hover:text-foreground motion-safe:transition-colors">Privacy</a>
<a href="/safety" className="hover:text-foreground motion-safe:transition-colors">Safety</a>
<Link to="/changelog" className="hover:text-foreground motion-safe:transition-colors">Changelog</Link>
</nav>
</div>
@@ -0,0 +1,523 @@
import { useEffect, useState } from 'react';
import { Info, Play, Radio, SkipForward } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { AgoraLogo } from '@/components/AgoraLogo';
import { cn } from '@/lib/utils';
import {
periodLabel,
type PlanetoraPeriod,
} from '@/hooks/usePlanetoraEvents';
import type { PlanetoraMode } from '@/pages/PlanetoraPage';
const PERIODS: { value: PlanetoraPeriod; label: string }[] = [
{ value: '24h', label: '24h' },
{ value: '7d', label: '7d' },
{ value: '30d', label: '30d' },
{ value: 'all', label: 'All' },
];
interface PlanetoraControlsProps {
mode: PlanetoraMode;
onModeChange: (mode: PlanetoraMode) => void;
period: PlanetoraPeriod;
onPeriodChange: (period: PlanetoraPeriod) => void;
autoPilot: boolean;
onAutoPilotToggle: () => void;
onSkip: () => void;
/** Number of events in the active period (replay) or seen so far (live). */
eventCount: number;
/** Number of distinct countries with events in the active period / live session. */
countryCount: number;
/** Whether the underlying event source is loading (replay) or connecting (live). */
loading: boolean;
/** Real-time second of the moment currently being "rendered" on the globe (replay). */
playheadSec: number;
/** Real-time second at progress=0 (start of the period window) (replay). */
windowStartSec: number;
/** Real-time second at progress=1 (end of the period window — usually now) (replay). */
windowEndSec: number;
/** Playback progress 0..1 within the 60-second replay cycle (replay). */
progress: number;
/** Unix-second of the most recently received live event (live mode only). */
liveLatestAt?: number;
/**
* Hide the bottom timeline / live-stat HUD. Used when an event panel
* is open on mobile (the panel covers the same screen real-estate, so
* keeping the HUD around just clutters the view).
*/
hideBottomHud?: boolean;
}
/**
* Floating control overlay for Planetora.
*
* Top-left: back button + page title.
* Top-right: mode switch (LIVE / replay), period selector (replay only),
* stat readout, auto-pilot toggle, skip.
* Bottom-centre: scrubber + playhead clock (replay), or a LIVE counter (live).
*/
export function PlanetoraControls({
mode,
onModeChange,
period,
onPeriodChange,
autoPilot,
onAutoPilotToggle,
onSkip,
eventCount,
countryCount,
loading,
playheadSec,
windowStartSec,
windowEndSec,
progress,
liveLatestAt,
hideBottomHud = false,
}: PlanetoraControlsProps) {
const isLive = mode === 'live';
const spanSec = Math.max(1, windowEndSec - windowStartSec);
const playheadLabel = formatPlayhead(playheadSec, spanSec);
const startLabel = formatBoundary(windowStartSec, spanSec);
const endLabel = formatBoundary(windowEndSec, spanSec);
const pct = Math.min(100, Math.max(0, progress * 100));
const [aboutOpen, setAboutOpen] = useState(false);
return (
<>
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-start justify-between gap-3 p-3 sm:p-4">
<div className="pointer-events-auto flex items-center gap-2">
<Link
to="/"
aria-label="Back to Agora"
className="rounded-lg bg-background/70 backdrop-blur-md border border-border/50 p-2 shadow-lg hover:bg-background/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition"
>
<AgoraLogo size={36} />
</Link>
<div className="hidden sm:flex items-center gap-2 rounded-lg bg-background/70 backdrop-blur-md border border-border/50 pl-4 pr-1.5 py-2 shadow-lg">
<span className="text-lg font-semibold tracking-tight leading-none">
Planetora
</span>
<span className="text-muted-foreground text-sm leading-none">
{isLive ? 'live global activity' : 'global activity replay'}
</span>
<button
type="button"
onClick={() => setAboutOpen(true)}
aria-label="About Planetora"
className="ml-1 inline-flex size-7 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-foreground/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors"
>
<Info className="size-4" />
</button>
</div>
</div>
<div className="pointer-events-auto flex flex-col items-end gap-2">
{/*
Mode + period row.
LIVE sits in its own pill to the left, visually separated from
the replay-window selector by a gap and a different colour
language (red, with an animated radio dot). The four replay
periods stay grouped together so they read as a single
"history window" control.
*/}
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => onModeChange(isLive ? 'replay' : 'live')}
aria-pressed={isLive}
className={cn(
'inline-flex items-center gap-1.5 h-8 rounded-md px-2.5 text-xs font-semibold tracking-wide uppercase transition-colors shadow-lg',
'border backdrop-blur-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isLive
? 'bg-red-600 text-white border-red-500/70 hover:bg-red-600/90'
: 'bg-background/70 border-border/50 text-muted-foreground hover:text-foreground hover:bg-background/90',
)}
>
<span className="relative flex size-2 items-center justify-center">
<span
className={cn(
'absolute inline-flex h-full w-full rounded-full',
isLive
? 'bg-white/80 motion-safe:animate-ping'
: 'bg-red-500/60',
)}
/>
<span
className={cn(
'relative inline-flex size-1.5 rounded-full',
isLive ? 'bg-white' : 'bg-red-500',
)}
/>
</span>
Live
</button>
<div
className={cn(
'flex items-center gap-1 rounded-md bg-background/70 backdrop-blur-md border border-border/50 p-1 shadow-lg transition-opacity',
isLive && 'opacity-60',
)}
aria-label="Replay window"
>
{PERIODS.map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => {
if (isLive) onModeChange('replay');
onPeriodChange(value);
}}
aria-pressed={!isLive && period === value}
className={cn(
'rounded px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
!isLive && period === value
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{label}
</button>
))}
</div>
</div>
<div className="flex items-center gap-1 rounded-md bg-background/70 backdrop-blur-md border border-border/50 p-1 shadow-lg">
{/*
Two distinct visual states for auto-pilot.
ON → filled primary, animated pulse dot, "Pause" label.
OFF → outlined neutral, static play icon, "Auto-pilot" label.
In live mode the same toggle controls "auto-focus on incoming
events" — same metaphor, different data source.
*/}
<button
type="button"
onClick={onAutoPilotToggle}
aria-pressed={autoPilot}
aria-label={autoPilot ? 'Pause auto-pilot' : 'Start auto-pilot'}
className={cn(
'inline-flex items-center gap-1.5 h-8 rounded px-2.5 text-xs font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
autoPilot
? 'bg-primary text-primary-foreground shadow-inner shadow-primary/40 hover:bg-primary/90'
: 'bg-transparent text-foreground border border-border/70 hover:bg-foreground/5',
)}
>
{autoPilot ? (
<span className="relative flex size-2.5 items-center justify-center">
<span className="absolute inline-flex h-full w-full rounded-full bg-primary-foreground/70 motion-safe:animate-ping" />
<span className="relative inline-flex size-1.5 rounded-full bg-primary-foreground" />
</span>
) : (
<Play className="size-3.5" />
)}
<span className="hidden sm:inline">
{autoPilot ? 'Auto-pilot on' : 'Auto-pilot off'}
</span>
</button>
{autoPilot && !isLive && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onSkip}
aria-label="Skip to next event"
className="h-8 w-8 p-0"
>
<SkipForward className="size-4" />
</Button>
)}
</div>
</div>
</div>
<div
className={cn(
'pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3 sm:p-4',
'transition-opacity duration-200',
hideBottomHud && 'opacity-0 pointer-events-none',
)}
aria-hidden={hideBottomHud}
>
{isLive ? (
<LiveHud
connecting={loading}
eventCount={eventCount}
countryCount={countryCount}
latestAt={liveLatestAt}
/>
) : (
<div className="pointer-events-auto w-full max-w-[520px] rounded-md bg-background/75 backdrop-blur-md border border-border/50 px-3 py-2 shadow-lg">
{loading ? (
<div className="text-xs sm:text-sm text-muted-foreground text-center">
Loading events
</div>
) : (
<div className="flex flex-col gap-1.5">
{/* Playhead clock — large + monospaced so the time is the
visual anchor of the bottom bar. */}
<div className="flex items-baseline justify-between gap-2">
<div className="text-xs uppercase tracking-wider text-muted-foreground">
Now showing
</div>
<div className="text-xs text-muted-foreground">
<span className="font-semibold text-foreground">
{eventCount.toLocaleString()}
</span>{' '}
event{eventCount === 1 ? '' : 's'} ·{' '}
<span className="font-semibold text-foreground">
{countryCount}
</span>{' '}
countr{countryCount === 1 ? 'y' : 'ies'}{' '}
<span className="hidden sm:inline">
· {periodLabel(period)}
</span>
</div>
</div>
<div
className="text-base sm:text-lg font-semibold tabular-nums leading-tight"
aria-live="polite"
aria-atomic="true"
>
{playheadLabel}
</div>
{/* Timeline scrubber. Read-only — clearly conveys which slice
of real time the pulses correspond to. */}
<div
className="relative h-1 rounded-full bg-foreground/10 mt-1"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(pct)}
aria-label="Playback progress"
>
<div
className="absolute inset-y-0 left-0 rounded-full bg-primary/70"
style={{ width: `${pct}%` }}
/>
<div
className="absolute -top-1 size-3 rounded-full bg-primary ring-2 ring-background shadow"
style={{
left: `${pct}%`,
transform: 'translateX(-50%)',
}}
/>
</div>
<div className="flex justify-between text-[11px] tabular-nums text-muted-foreground">
<span>{startLabel}</span>
<span>{endLabel}</span>
</div>
</div>
)}
</div>
)}
</div>
<Dialog open={aboutOpen} onOpenChange={setAboutOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>About Planetora</DialogTitle>
<DialogDescription>
A live 3D replay of geo-tagged activity on the Agora network.
</DialogDescription>
</DialogHeader>
<div className="text-sm space-y-4 text-foreground/90">
<div>
<h4 className="text-sm font-semibold mb-1.5">How it works</h4>
<p className="text-muted-foreground">
Every event tagged with a country code is shown as a pulse on
the globe. In replay mode the whole selected period is
compressed into a rolling 60-second timeline, so a week of
activity replays in a minute and loops. Switch to{' '}
<span className="text-red-500 font-semibold">Live</span> to
instead subscribe to events as they happen each new event
pulses in real time.
</p>
</div>
<div>
<h4 className="text-sm font-semibold mb-1.5">
Event kinds included
</h4>
<ul className="space-y-1.5 text-muted-foreground">
<li>
<span className="font-mono text-foreground">1111</span>{' '}
· NIP-22 geographic posts comments and notes tagged with
a country via an{' '}
<span className="font-mono">iso3166</span> or{' '}
<span className="font-mono">geo</span> identifier.
</li>
<li>
<span className="font-mono text-foreground">1068</span>{' '}
· NIP-88 polls with a country tag.
</li>
<li>
<span className="font-mono text-foreground">36639</span>{' '}
· Agora challenges (a.k.a. actions / pathos challenges).
</li>
</ul>
</div>
<div>
<h4 className="text-sm font-semibold mb-1.5">Auto-pilot</h4>
<p className="text-muted-foreground">
When on, the globe randomly cycles through individual events
in replay mode, or auto-focuses on incoming events in live
mode (rate-limited so a flood of activity doesn't flicker
the panel). Pause it any time, or interact with the globe
and it'll auto-pause.
</p>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
/** Live-mode bottom HUD: a streaming counter + last-event ticker. */
function LiveHud({
connecting,
eventCount,
countryCount,
latestAt,
}: {
connecting: boolean;
eventCount: number;
countryCount: number;
latestAt: number | undefined;
}) {
// Re-tick once a second so "5s ago" stays accurate without re-rendering
// the whole page on every interval.
const [, setNowTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setNowTick((n) => n + 1), 1000);
return () => clearInterval(id);
}, []);
const ago = latestAt ? formatRelative(Math.floor(Date.now() / 1000) - latestAt) : null;
return (
<div className="pointer-events-auto w-full max-w-[520px] rounded-md bg-background/75 backdrop-blur-md border border-red-500/40 px-3 py-2 shadow-lg">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="relative flex size-2.5 items-center justify-center">
<span className="absolute inline-flex h-full w-full rounded-full bg-red-500/70 motion-safe:animate-ping" />
<span className="relative inline-flex size-1.5 rounded-full bg-red-500" />
</span>
<span className="inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-red-500">
<Radio className="size-3.5" /> Live
</span>
</div>
<div className="text-xs text-muted-foreground">
{connecting && eventCount === 0 ? (
<span>Connecting to relays</span>
) : (
<>
<span className="font-semibold text-foreground">
{eventCount.toLocaleString()}
</span>{' '}
event{eventCount === 1 ? '' : 's'} ·{' '}
<span className="font-semibold text-foreground">
{countryCount}
</span>{' '}
countr{countryCount === 1 ? 'y' : 'ies'}
</>
)}
</div>
</div>
<div
className="mt-1 text-sm tabular-nums text-foreground/90"
aria-live="polite"
aria-atomic="true"
>
{ago === null ? (
<span className="text-muted-foreground">
Waiting for the next event
</span>
) : (
<>
<span className="text-muted-foreground">Last event </span>
<span className="font-semibold">{ago}</span>
</>
)}
</div>
</div>
);
}
const ONE_DAY_SEC = 86_400;
/** Render "5s ago", "12m ago", "2h ago", "3d ago" from a non-negative delta. */
function formatRelative(deltaSec: number): string {
const d = Math.max(0, deltaSec);
if (d < 1) return 'just now';
if (d < 60) return `${Math.floor(d)}s ago`;
if (d < 60 * 60) return `${Math.floor(d / 60)}m ago`;
if (d < ONE_DAY_SEC) return `${Math.floor(d / 3600)}h ago`;
return `${Math.floor(d / ONE_DAY_SEC)}d ago`;
}
/**
* Format the "currently rendering" moment. Granularity scales with the
* window span — minute precision for short windows, day-level for spans
* over a year so the label stays compact and readable.
*/
function formatPlayhead(unixSec: number, spanSec: number): string {
const date = new Date(unixSec * 1000);
if (spanSec <= 2 * ONE_DAY_SEC) {
return date.toLocaleString(undefined, {
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
if (spanSec <= 90 * ONE_DAY_SEC) {
return date.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
if (spanSec <= 2 * 365 * ONE_DAY_SEC) {
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
return date.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
});
}
/** Short label for the window's start/end markers. */
function formatBoundary(unixSec: number, spanSec: number): string {
const date = new Date(unixSec * 1000);
if (spanSec <= 2 * ONE_DAY_SEC) {
return date.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
if (spanSec <= 90 * ONE_DAY_SEC) {
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
}
return date.toLocaleDateString(undefined, {
month: 'short',
year: '2-digit',
});
}
@@ -0,0 +1,246 @@
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { ExternalLink, X } from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { NoteContent } from '@/components/NoteContent';
import { COUNTRIES } from '@/lib/countries';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import type { PlanetoraEvent } from '@/hooks/usePlanetoraEvents';
interface PlanetoraEventPanelProps {
event: PlanetoraEvent;
onClose: () => void;
/** `right` on wide screens, `bottom` on narrow. */
side: 'right' | 'bottom';
}
/**
* Floating side panel that shows the auto-pilot's currently focused event.
*
* Right-side card on desktop, positioned below the top-right control cluster
* so it never collides with the period selector / autopilot buttons. Bottom
* sheet on mobile. Closing the panel stops the auto-pilot loop.
*/
export function PlanetoraEventPanel({
event,
onClose,
side,
}: PlanetoraEventPanelProps) {
const author = useAuthor(event.event.pubkey);
const metadata = author.data?.metadata;
const displayName =
metadata?.display_name || metadata?.name || genUserName(event.event.pubkey);
const country = COUNTRIES[event.country];
const neventLink = useMemo(() => {
try {
return nip19.neventEncode({
id: event.event.id,
author: event.event.pubkey,
kind: event.event.kind,
});
} catch {
return event.event.id;
}
}, [event]);
const isPanel = side === 'right';
// Auto-size the panel to its content with smooth height transitions.
//
// Key insight: ResizeObserver fires on box-size changes, NOT on
// scrollHeight changes. So observing the (potentially grid-constrained)
// body section won't pick up image loads inside it — the body's box
// doesn't grow, only its scrollHeight does. Instead we use an inner
// wrapper (`bodyInnerRef`) that has no height constraint at all, so its
// box naturally tracks the content. RO on the inner wrapper fires
// reliably whenever anything inside the post changes size.
const panelRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const bodyInnerRef = useRef<HTMLDivElement>(null);
const [explicitHeight, setExplicitHeight] = useState<number | undefined>(
undefined,
);
useLayoutEffect(() => {
if (!isPanel) return;
const panelEl = panelRef.current;
const bodyEl = bodyRef.current;
const bodyInnerEl = bodyInnerRef.current;
if (!panelEl || !bodyEl || !bodyInnerEl) return;
const measure = () => {
const naturalBody = bodyInnerEl.offsetHeight;
let chrome = 0;
for (const child of Array.from(panelEl.children)) {
if (child !== bodyEl) {
chrome += (child as HTMLElement).getBoundingClientRect().height;
}
}
setExplicitHeight(chrome + naturalBody);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(bodyInnerEl);
for (const child of Array.from(panelEl.children)) {
if (child !== bodyEl) {
ro.observe(child);
}
}
return () => ro.disconnect();
}, [isPanel]);
return (
<div
ref={panelRef}
data-planetora-panel
className={cn(
'pointer-events-auto fixed z-30 rounded-xl overflow-hidden',
// Surface is mostly opaque (was /40 — read as "fogged version of
// the page bg" against the warm dawn theme so the edges
// disappeared). 85% opacity + backdrop-blur keeps a hint of the
// globe glow underneath while clearly reading as a separate
// surface.
'bg-background/85 backdrop-blur-2xl',
// Slightly more emphatic border + inner ring to crisp the edge
// against any colour.
'border border-border/70 ring-1 ring-foreground/[0.04]',
isPanel
? 'top-36 right-4 w-[380px] max-w-[calc(100vw-2rem)] grid shadow-2xl'
: // Two-direction shadow so the bottom sheet "lifts" off the
// globe — `shadow-2xl` alone only casts downward, which on a
// sheet pinned to the bottom of the viewport is invisible.
// Top-edge shadow provides the actual visual separation.
'flex flex-col left-2 right-2 bottom-2 max-h-[60vh] shadow-[0_-14px_40px_rgba(0,0,0,0.22),0_24px_56px_rgba(0,0,0,0.18)]',
)}
style={
isPanel
? {
height: explicitHeight,
// top-36 anchors the panel 9rem from the top; reserving 18rem
// total leaves ~9rem at the bottom for the timeline / live HUD
// so a tall note doesn't overlap the "now showing" summary.
maxHeight: 'calc(100vh - 18rem)',
// header (auto) · country (auto) · body (1fr — constrained
// when the panel has hit its max height so overflow-y-auto
// inside the body kicks in) · footer (auto).
gridTemplateRows: 'auto auto 1fr auto',
transition: 'height 350ms cubic-bezier(0.4, 0, 0.2, 1)',
}
: undefined
}
role="dialog"
aria-label="Event details"
>
<div className="flex items-center justify-between gap-3 border-b border-border/50 p-3 sm:p-4">
<Link
to={`/${nip19.npubEncode(event.event.pubkey)}`}
className="flex items-center gap-3 min-w-0 group focus-visible:outline-none"
>
<Avatar className="size-10 shrink-0 ring-2 ring-border/40 group-hover:ring-primary/40 transition">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback>
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="truncate text-sm font-semibold leading-tight group-hover:underline">
{displayName}
</div>
<div className="truncate text-xs text-muted-foreground leading-tight">
{metadata?.nip05 ?? formatTime(event.event.created_at)}
</div>
</div>
</Link>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClose}
aria-label="Close"
className="size-8 shrink-0"
>
<X className="size-4" />
</Button>
</div>
<div className="flex items-center gap-2 px-3 sm:px-4 py-2 border-b border-border/40 text-xs">
<span className="text-lg leading-none" aria-hidden>
{country?.flag ?? '🌐'}
</span>
<span className="font-medium">{country?.name ?? event.country}</span>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">
kind {event.event.kind}
</span>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">
{formatTime(event.event.created_at)}
</span>
</div>
<div
ref={bodyRef}
className={cn(
'overflow-y-auto min-h-0',
!isPanel && 'flex-1',
)}
>
{/*
Inner wrapper has no height constraint, so its `offsetHeight`
always reflects the post's true natural size — the basis for
deciding how tall the panel should be. The outer scrollable box
above is where overflow-y-auto kicks in when the panel hits
its max-height cap.
*/}
<div ref={bodyInnerRef} className="px-3 sm:px-4 py-3">
{author.isLoading && !metadata ? (
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
) : (
<div className="whitespace-pre-wrap break-words text-sm leading-relaxed">
<NoteContent event={event.event} />
</div>
)}
</div>
</div>
<div className="border-t border-border/50 p-2 sm:p-3 flex justify-end">
<Button asChild variant="ghost" size="sm" className="gap-1">
<Link to={`/${neventLink}`}>
<span>Open full post</span>
<ExternalLink className="size-3.5" />
</Link>
</Button>
</div>
</div>
);
}
function formatTime(unixSeconds: number): string {
try {
const date = new Date(unixSeconds * 1000);
const now = Date.now();
const diffMs = now - date.getTime();
const minutes = Math.round(diffMs / 60000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
if (days < 30) return `${days}d ago`;
return date.toLocaleDateString();
} catch {
return '';
}
}
@@ -0,0 +1,430 @@
import { useEffect, useMemo, useRef } from 'react';
import { geoDistance, geoOrthographic, geoPath } from 'd3-geo';
import type { CountryFeature } from '@/lib/planetora/countryGeo';
import type { PlanetoraRing } from '@/hooks/usePlanetoraPlayback';
import { PLANETORA_RING_TTL_MS } from '@/hooks/usePlanetoraPlayback';
import type { PlanetoraEvent } from '@/hooks/usePlanetoraEvents';
import { cn } from '@/lib/utils';
/**
* Colour palette shared between the page (which builds it) and the SVG
* globe (which renders it). Lives next to the renderer so the renderer's
* field requirements stay co-located with the consumer.
*/
export interface PlanetoraTheme {
/** Solid colour for active country fill (alpha applied via opacity). */
activeCountryColor: string;
/** Opacity 0..1 applied to the active country fill. */
activeCountryOpacity: number;
/** Solid colour for inactive country fill. */
restCountryColor: string;
/** Opacity 0..1 applied to the inactive country fill. */
restCountryOpacity: number;
/** Country border stroke colour. */
countryBorder: string;
/** Pulse ring stroke colour. */
ringColor: string;
/** Highlighted (auto-pilot focus / selected event) ring + dot colour. */
highlightColor: string;
/**
* Sphere shading — the radial-gradient stops paint the disc as a lit
* sphere (top-left highlight → equator midtone → limb shadow).
*/
sphereCentre: string;
sphereMid: string;
sphereEdge: string;
}
export interface PlanetoraSvgGlobeProps {
countries: CountryFeature[];
rings: PlanetoraRing[];
selected: PlanetoraEvent | null;
/** Set of ISO alpha-2 country codes that have events in the active period. */
activeCountries: Set<string>;
theme: PlanetoraTheme;
/** Gentle idle rotation. Disabled while a selected event is in focus. */
autoRotate?: boolean;
/**
* Where the event side panel is currently rendered. Used to shift the
* SVG canvas off the corresponding edge so the globe stays visually
* centred in the panel-free part of the viewport.
*/
panelSide?: 'right' | 'bottom' | 'none';
/** Notified when the user drags / pinches / wheels the globe. */
onUserInteract?: () => void;
className?: string;
}
// ── Projection / sizing ─────────────────────────────────────────────────────
/**
* SVG viewBox is 600×600 — RADIUS leaves a margin between the sphere and the
* edge of the viewport so the limb doesn't crowd UI elements stacked over
* the canvas (controls, panels). Tweak this to "zoom" the camera.
*/
const RADIUS = 250;
const CENTER = 300;
/** Seconds per full revolution during idle rotation. Slow on purpose. */
const IDLE_ROTATION_PERIOD_SECONDS = 140;
/**
* Easing speed for fly-to: fraction of the remaining distance closed each
* 16ms tick. ~0.06 → critically-damped feel over ~1.5 s.
*/
const FOCUS_EASE = 0.06;
/**
* Latitude tilt — keeps the poles off-centre so the globe never looks like
* a flat dial. Matches the WebGL camera's resting altitude.
*/
const VIEW_TILT_DEG = 20;
// ── Component ───────────────────────────────────────────────────────────────
/**
* Pure-SVG Planetora globe. Country polygons and pulse rings are projected
* each animation frame and applied imperatively to ref'd SVG elements
* (no React re-renders inside the loop). The orthographic projection +
* 90° clip-angle is supplied by d3-geo, which walks the limb arc when a
* polygon crosses the back hemisphere — no flat shortcuts across the
* disc.
*
* Camera always frames the full sphere — fly-to rotates the globe so the
* focused location faces the viewer, in lieu of zooming.
*/
export function PlanetoraSvgGlobe({
countries,
rings,
selected,
activeCountries,
theme,
autoRotate = true,
panelSide = 'none',
onUserInteract,
className,
}: PlanetoraSvgGlobeProps) {
// d3-geo orthographic projection + path generator. Recreated only when
// the viewport-relative scale / centre changes (currently never).
// `clipAngle(90)` is the default for ortho — d3 walks the limb arc when a
// polygon crosses the back hemisphere instead of cutting a chord.
const { projection, pathBuilder } = useMemo(() => {
const proj = geoOrthographic()
.scale(RADIUS)
.translate([CENTER, CENTER])
.clipAngle(90);
return { projection: proj, pathBuilder: geoPath(proj) };
}, []);
// Refs to imperatively update each polygon / ring without React re-renders.
const landGroupRef = useRef<SVGGElement | null>(null);
const ringsGroupRef = useRef<SVGGElement | null>(null);
const selectedDotRef = useRef<SVGCircleElement | null>(null);
const selectedHaloRef = useRef<SVGCircleElement | null>(null);
// Two phase-offset rings give the selected event a continuous "ping",
// separate from the one-shot rings emitted on every event.
const selectedPulseRefs = [
useRef<SVGCircleElement | null>(null),
useRef<SVGCircleElement | null>(null),
] as const;
// Live refs for the rAF tick — read latest props without retriggering effect.
const ringsRef = useRef(rings);
ringsRef.current = rings;
const selectedRef = useRef(selected);
selectedRef.current = selected;
const activeCountriesRef = useRef(activeCountries);
activeCountriesRef.current = activeCountries;
const themeRef = useRef(theme);
themeRef.current = theme;
const autoRotateRef = useRef(autoRotate);
autoRotateRef.current = autoRotate;
// Animated camera state. Longitude spins on idle / eases to a focused
// event; latitude rests at VIEW_TILT_DEG and eases toward the focused
// event's latitude so the selected location lands at the visual
// centre of the disc (otherwise events near the equator project well
// below centre and on mobile fall behind the bottom-sheet panel).
const rotLngRef = useRef(10);
const rotLatRef = useRef(VIEW_TILT_DEG);
useEffect(() => {
const prefersReducedMotion =
typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
let raf = 0;
let lastTs: number | null = null;
const tick = (ts: number) => {
if (lastTs === null) lastTs = ts;
const dt = Math.min(64, ts - lastTs); // clamp huge frame gaps
lastTs = ts;
// ── Camera ────────────────────────────────────────────────────────
const sel = selectedRef.current;
if (sel) {
// Ease longitude towards the selected point so it sits at lng=0
// (i.e. dead-centre of the visible hemisphere).
const targetLng = sel.coords[0];
const curLng = rotLngRef.current;
// Wrap-aware shortest path (-180 ↔ 180).
let dLng = ((targetLng - curLng + 540) % 360) - 180;
if (Math.abs(dLng) < 0.01) dLng = 0;
rotLngRef.current = curLng + dLng * FOCUS_EASE;
} else if (autoRotateRef.current && !prefersReducedMotion) {
const degPerMs = 360 / (IDLE_ROTATION_PERIOD_SECONDS * 1000);
rotLngRef.current = (rotLngRef.current + degPerMs * dt) % 360;
}
// Latitude eases toward the selected event's latitude so the focal
// point lands at the centre of the disc — otherwise mid- and
// low-latitude events project well below centre and slip behind the
// mobile bottom-sheet panel. With nothing selected the camera
// settles back to the resting tilt.
const targetLat = sel ? sel.coords[1] : VIEW_TILT_DEG;
const curLat = rotLatRef.current;
let dLat = targetLat - curLat;
if (Math.abs(dLat) < 0.01) dLat = 0;
rotLatRef.current = curLat + dLat * FOCUS_EASE;
const rotLng = rotLngRef.current;
const rotLat = rotLatRef.current;
const t = themeRef.current;
const active = activeCountriesRef.current;
// d3-geo's rotate uses [lambda, phi, gamma]. Negative lambda spins
// the globe west-to-east; phi tilts the camera off the equator.
projection.rotate([-rotLng, -rotLat]);
// The geographic point currently dead-centre on the visible
// hemisphere — used to fade things by depth (rings, selected dot).
const cameraLng = rotLng;
const cameraLat = rotLat;
// ── Country polygons ──────────────────────────────────────────────
const landEl = landGroupRef.current;
if (landEl) {
const paths = landEl.children;
for (let i = 0; i < countries.length; i++) {
const path = paths[i] as SVGPathElement | undefined;
if (!path) continue;
const feature = countries[i];
const d = pathBuilder(feature);
if (!d) {
path.setAttribute('opacity', '0');
continue;
}
const isActive = active.has(feature.properties.iso);
path.setAttribute('d', d);
path.setAttribute(
'fill',
isActive ? t.activeCountryColor : t.restCountryColor,
);
path.setAttribute(
'opacity',
(isActive ? t.activeCountryOpacity : t.restCountryOpacity).toFixed(2),
);
}
}
// ── Pulse rings ───────────────────────────────────────────────────
const ringsEl = ringsGroupRef.current;
const live = ringsRef.current;
if (ringsEl) {
const els = ringsEl.children;
const now = performance.now();
for (let i = 0; i < live.length; i++) {
const ring = live[i];
const el = els[i] as SVGCircleElement | undefined;
if (!el) continue;
const age = now - ring.spawnedAt;
if (age < 0 || age >= PLANETORA_RING_TTL_MS) {
el.setAttribute('opacity', '0');
continue;
}
const xy = projection([ring.lng, ring.lat]);
if (!xy) {
el.setAttribute('opacity', '0');
continue;
}
// 0 (centre) → π (antipode); we treat anything past π/2 as hidden.
const dist = geoDistance([ring.lng, ring.lat], [cameraLng, cameraLat]);
const z = Math.cos(dist); // 1 centre, 0 limb
const phase = age / PLANETORA_RING_TTL_MS; // 0..1
const r = 4 + phase * 24;
const opacity = (1 - phase) * (0.25 + Math.max(0, z) * 0.65);
el.setAttribute('cx', xy[0].toFixed(1));
el.setAttribute('cy', xy[1].toFixed(1));
el.setAttribute('r', r.toFixed(1));
el.setAttribute('opacity', opacity.toFixed(2));
el.setAttribute('stroke', t.ringColor);
}
}
// ── Selected marker ───────────────────────────────────────────────
const dotEl = selectedDotRef.current;
const haloEl = selectedHaloRef.current;
const pulseEls = selectedPulseRefs.map((r) => r.current);
if (sel && dotEl && haloEl) {
const xy = projection([sel.coords[0], sel.coords[1]]);
if (xy) {
const dist = geoDistance(
[sel.coords[0], sel.coords[1]],
[cameraLng, cameraLat],
);
const z = Math.cos(dist);
const op = Math.max(0, Math.min(1, 0.4 + z * 0.7));
dotEl.setAttribute('cx', xy[0].toFixed(1));
dotEl.setAttribute('cy', xy[1].toFixed(1));
dotEl.setAttribute('opacity', op.toFixed(2));
dotEl.setAttribute('fill', t.highlightColor);
haloEl.setAttribute('cx', xy[0].toFixed(1));
haloEl.setAttribute('cy', xy[1].toFixed(1));
haloEl.setAttribute('opacity', op.toFixed(2));
haloEl.setAttribute('stroke', t.highlightColor);
// Continuous ping — two rings, phase-offset by half a cycle, each
// expanding outwards and fading. Cycle length matches the regular
// event rings so the visual language stays consistent.
const cycle = PLANETORA_RING_TTL_MS;
const tNow = ts;
for (let i = 0; i < pulseEls.length; i++) {
const el = pulseEls[i];
if (!el) continue;
const phase = ((tNow / cycle + i / pulseEls.length) % 1 + 1) % 1;
const r = 6 + phase * 26;
const pulseOp = (1 - phase) * 0.7 * op;
el.setAttribute('cx', xy[0].toFixed(1));
el.setAttribute('cy', xy[1].toFixed(1));
el.setAttribute('r', r.toFixed(1));
el.setAttribute('opacity', pulseOp.toFixed(2));
el.setAttribute('stroke', t.highlightColor);
}
} else {
dotEl.setAttribute('opacity', '0');
haloEl.setAttribute('opacity', '0');
for (const el of pulseEls) el?.setAttribute('opacity', '0');
}
} else if (dotEl && haloEl) {
dotEl.setAttribute('opacity', '0');
haloEl.setAttribute('opacity', '0');
for (const el of pulseEls) el?.setAttribute('opacity', '0');
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [countries, projection, pathBuilder]);
// When the side panel is open we extend the SVG canvas off the LEFT
// (desktop) or TOP (mobile, since the panel sits at the bottom of the
// viewport) edge. The globe stays centred inside this oversized
// canvas, so visually its centre lands in the middle of the
// panel-free space — same trick the WebGL renderer used so the globe
// and the open note never visually collide.
const panelShiftLeftPx = panelSide === 'right' ? 380 : 0;
const panelShiftTopPx = panelSide === 'bottom' ? 240 : 0;
return (
<div
className={cn('relative w-full h-full overflow-hidden', className)}
onPointerDown={onUserInteract}
onWheel={onUserInteract}
onTouchStart={onUserInteract}
>
<div
className="absolute"
style={{
left: -panelShiftLeftPx,
top: -panelShiftTopPx,
width: `calc(100% + ${panelShiftLeftPx}px)`,
height: `calc(100% + ${panelShiftTopPx}px)`,
transition:
'left 300ms cubic-bezier(0.4, 0, 0.2, 1), top 300ms cubic-bezier(0.4, 0, 0.2, 1), width 300ms cubic-bezier(0.4, 0, 0.2, 1), height 300ms cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
<svg
viewBox="0 0 600 600"
preserveAspectRatio="xMidYMid meet"
className="absolute inset-0 size-full"
role="img"
aria-label="Globe of geo-tagged Nostr activity"
focusable="false"
>
<defs>
{/* Sphere base — a warm cream→honey radial fill so the globe
reads as lit-from-within "dawn earth" rather than as a dark
surveillance dashboard. Colours intentionally live here
instead of in the theme so the globe stays friendly even
when the surrounding app is in dark mode. */}
<radialGradient id="planetora-svg-base" cx="34%" cy="28%" r="78%">
<stop offset="0%" stopColor={theme.sphereCentre} />
<stop offset="55%" stopColor={theme.sphereMid} />
<stop offset="100%" stopColor={theme.sphereEdge} />
</radialGradient>
{/* Soft top-left highlight to sell the sphere shape. */}
<radialGradient id="planetora-svg-highlight" cx="28%" cy="22%" r="42%">
<stop offset="0%" stopColor="#ffffff" stopOpacity="0.45" />
<stop offset="100%" stopColor="#ffffff" stopOpacity="0" />
</radialGradient>
<clipPath id="planetora-svg-clip">
<circle cx={CENTER} cy={CENTER} r={RADIUS} />
</clipPath>
</defs>
{/* Sphere base. */}
<circle cx={CENTER} cy={CENTER} r={RADIUS} fill="url(#planetora-svg-base)" />
{/* Country polygons (clipped to the sphere). */}
<g clipPath="url(#planetora-svg-clip)">
<g
ref={landGroupRef}
stroke={theme.countryBorder}
strokeWidth="0.6"
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
>
{countries.map((f) => (
<path key={f.properties.iso} opacity={0} />
))}
</g>
</g>
{/* Pulse rings (clipped so trailing edges fade off the limb cleanly). */}
<g clipPath="url(#planetora-svg-clip)">
<g ref={ringsGroupRef} fill="none" strokeWidth="2">
{rings.map((r) => (
<circle key={r.key} opacity={0} />
))}
</g>
</g>
{/* Selected marker — continuous ping rings + bullseye + dot. */}
<g pointerEvents="none" fill="none">
{selectedPulseRefs.map((ref, i) => (
<circle key={i} ref={ref} strokeWidth="1.5" opacity={0} />
))}
<circle
ref={selectedHaloRef}
r={14}
strokeWidth="2"
opacity={0}
/>
<circle ref={selectedDotRef} r={5} fill="currentColor" opacity={0} />
</g>
{/* Highlight + rim sit above the land for a "lit ball" feel. */}
<circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="url(#planetora-svg-highlight)"
pointerEvents="none"
/>
</svg>
</div>
</div>
);
}
export default PlanetoraSvgGlobe;
+116
View File
@@ -0,0 +1,116 @@
/**
* Auto-pilot for the Planetora globe.
*
* When active, repeatedly picks a random event from the current period set
* and exposes it as `selected`. The PlanetoraSvgGlobe component owns the actual
* camera fly-to (it watches `selected` directly) so that focus survives the
* globe-instance-not-yet-ready race. Modelled on relay-nova's
* `useAutoPilot` but simplified — there's no scroll-driven dwell, just a
* fixed `DWELL_MS`.
*
* Pausing is one of two states:
* - `enabled=false`: hard off (toggle button)
* - `userInteracting=true`: soft off — the page sets this when the user
* drags / clicks the globe; a `RESUME_AFTER_MS` inactivity timer flips it
* back automatically so the experience self-heals.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import type { PlanetoraEvent } from './usePlanetoraEvents';
interface UsePlanetoraAutoPilotOptions {
enabled: boolean;
events: PlanetoraEvent[];
/** How long to dwell on each event before moving on. */
dwellMs?: number;
}
const DEFAULT_DWELL_MS = 10_000;
export function usePlanetoraAutoPilot({
enabled,
events,
dwellMs = DEFAULT_DWELL_MS,
}: UsePlanetoraAutoPilotOptions): {
selected: PlanetoraEvent | null;
skip: () => void;
clear: () => void;
} {
const [selected, setSelected] = useState<PlanetoraEvent | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Keep latest values in refs so we can read them inside the timeout.
const eventsRef = useRef(events);
eventsRef.current = events;
const selectedRef = useRef(selected);
selectedRef.current = selected;
const pickAndFocus = useCallback(() => {
const pool = eventsRef.current;
if (pool.length === 0) {
setSelected(null);
return;
}
// Try to pick something different from the current selection.
let next = pool[Math.floor(Math.random() * pool.length)];
if (pool.length > 1 && selectedRef.current?.event.id === next.event.id) {
next = pool[(pool.indexOf(next) + 1) % pool.length];
}
setSelected(next);
}, []);
const scheduleNext = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
pickAndFocus();
scheduleNext();
}, dwellMs);
}, [dwellMs, pickAndFocus]);
// Start / stop the loop in response to `enabled` and event-set changes.
// If the current selection no longer exists in the new set (e.g. period
// switch), immediately pick a valid one.
useEffect(() => {
if (!enabled) {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
return;
}
if (events.length === 0) {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
setSelected(null);
return;
}
const selectedId = selectedRef.current?.event.id;
const selectedStillVisible = selectedId
? events.some((e) => e.event.id === selectedId)
: false;
if (!selectedStillVisible) {
pickAndFocus();
}
scheduleNext();
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [enabled, events, pickAndFocus, scheduleNext]);
const skip = useCallback(() => {
if (!enabled) return;
pickAndFocus();
scheduleNext();
}, [enabled, pickAndFocus, scheduleNext]);
const clear = useCallback(() => {
setSelected(null);
}, []);
return { selected, skip, clear };
}
+210
View File
@@ -0,0 +1,210 @@
/**
* Fetch all geographically-tagged Nostr events for the Planetora globe.
*
* Queries kinds 1111 (geographic posts), 1068 (polls), and 36639 (challenges)
* with high limits and no `since` so we capture all-time data. Period
* filtering happens client-side via `useMemo` so switching windows is instant.
*
* Each returned `PlanetoraEvent` carries a country code plus a stable
* `[lng, lat]` randomly sampled inside that country's polygon — computed once,
* never recomputed.
*/
import { useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import {
loadCountryFeatureMap,
type CountryFeature,
} from '@/lib/planetora/countryGeo';
import { randomPointInCountry } from '@/lib/planetora/randomPointInPolygon';
import { parseCountryIdentifier } from '@/lib/countryIdentifiers';
import { useAppContext } from '@/hooks/useAppContext';
import { APP_RELAYS } from '@/lib/appRelays';
/** Selectable replay-window options. */
export type PlanetoraPeriod = '24h' | '7d' | '30d' | 'all';
export interface PlanetoraEvent {
event: NostrEvent;
/** ISO 3166-1 alpha-2 country code. */
country: string;
/** Random `[lng, lat]` inside the country polygon (stable per event). */
coords: [number, number];
}
const CHALLENGE_T_ALIASES = ['agora-action', 'pathos-challenge', 'agora-challenge'];
/**
* Build the Nostr filter list used to fetch / subscribe to Planetora events.
*
* Pass `since` to bound to events from a given Unix-second onward (used by
* the live subscription so we don't replay history).
*/
export function planetoraEventFilters(opts?: { since?: number }) {
const base = opts?.since !== undefined ? { since: opts.since } : {};
return [
{ kinds: [1111, 1068], '#k': ['iso3166', 'geo'], limit: 20000, ...base },
{ kinds: [36639], '#t': CHALLENGE_T_ALIASES, limit: 5000, ...base },
];
}
/**
* Resolve a raw Nostr event into a `PlanetoraEvent` (country + in-country
* random point) using a pre-loaded country feature map. Returns `null` if
* the event has no resolvable country tag or the country isn't on the map.
*
* Shared by the historical query and the live subscription so the two
* paths stay perfectly in sync.
*/
export function buildPlanetoraEvent(
event: NostrEvent,
countryMap: Map<string, CountryFeature>,
): PlanetoraEvent | null {
const country = extractCountry(event);
if (!country) return null;
const feature = countryMap.get(country);
if (!feature) return null;
return {
event,
country,
coords: randomPointInCountry(feature),
};
}
/**
* Resolve the relay set Planetora should always query.
*
* Combines the app defaults with the user's NIP-65 relay list (read-flagged),
* deliberately ignoring the global `useUserRelays` toggle: Planetora is a
* global-activity surface, so a relay the user has explicitly added belongs
* here regardless of whether they've opted into using their own relays for
* the rest of the app.
*/
export function usePlanetoraRelays(): string[] {
const { config } = useAppContext();
return useMemo(() => {
const seen = new Set<string>();
const out: string[] = [];
const norm = (url: string) => url.toLowerCase().replace(/\/+$/, '');
const add = (url: string) => {
const key = norm(url);
if (!seen.has(key)) {
seen.add(key);
out.push(url);
}
};
for (const r of APP_RELAYS.relays) if (r.read) add(r.url);
for (const r of config.relayMetadata.relays) if (r.read) add(r.url);
return out;
}, [config.relayMetadata]);
}
/**
* Returns all events with a resolvable country code and an in-country point.
* Caches the result for 5 minutes; the underlying query has effectively
* unlimited limits (20k/5k) since the brief calls for "practically unlimited".
*/
export function usePlanetoraEvents() {
const { nostr } = useNostr();
const relays = usePlanetoraRelays();
const relayKey = relays.join('|');
return useQuery({
queryKey: ['planetora-events', relayKey],
queryFn: async ({ signal: qSignal }) => {
const signal = AbortSignal.any([
qSignal,
AbortSignal.timeout(15000),
]);
const [rawEvents, countryMap] = await Promise.all([
nostr.group(relays).query(planetoraEventFilters(), { signal }),
loadCountryFeatureMap(),
]);
const out: PlanetoraEvent[] = [];
const seen = new Set<string>();
for (const event of rawEvents) {
if (seen.has(event.id)) continue;
seen.add(event.id);
const planEvent = buildPlanetoraEvent(event, countryMap);
if (planEvent) out.push(planEvent);
}
// Most recent first.
out.sort((a, b) => b.event.created_at - a.event.created_at);
return out;
},
staleTime: 5 * 60 * 1000,
});
}
/**
* Filter the cached event set down to a replay window.
* `'all'` returns the full set unchanged.
*/
export function useEventsForPeriod(
events: PlanetoraEvent[] | undefined,
period: PlanetoraPeriod,
): PlanetoraEvent[] {
return useMemo(() => {
if (!events) return [];
if (period === 'all') return events;
const now = Math.floor(Date.now() / 1000);
const window = periodSeconds(period);
const since = now - window;
return events.filter((e) => e.event.created_at >= since);
}, [events, period]);
}
/** Window length in seconds for a `PlanetoraPeriod`. */
export function periodSeconds(period: PlanetoraPeriod): number {
switch (period) {
case '24h':
return 24 * 60 * 60;
case '7d':
return 7 * 24 * 60 * 60;
case '30d':
return 30 * 24 * 60 * 60;
case 'all':
// Effectively infinite; consumers should special-case `'all'` instead.
return Number.MAX_SAFE_INTEGER;
}
}
/** Pretty label for the period selector and stats readout. */
export function periodLabel(period: PlanetoraPeriod): string {
switch (period) {
case '24h':
return 'last 24 hours';
case '7d':
return 'last 7 days';
case '30d':
return 'last 30 days';
case 'all':
return 'all time';
}
}
/**
* Extract a 2-letter ISO 3166-1 alpha-2 country code from an event.
* Subdivision codes (e.g. `US-CA`) are truncated to the parent country
* so they map to a country polygon.
*/
function extractCountry(event: NostrEvent): string | null {
for (const tag of event.tags) {
if (tag[0] !== 'i') continue;
const parsed = parseCountryIdentifier(tag[1]);
if (parsed) return parsed.split('-')[0];
}
// kind 36639 also uses a `location` fallback in legacy data.
if (event.kind === 36639) {
const loc = event.tags.find(([n]) => n === 'location')?.[1];
if (loc && /^[A-Za-z]{2}$/.test(loc)) return loc.toUpperCase();
}
return null;
}
/** Test helper / external consumers — re-exported so other modules don't need to know about countryGeo internals. */
export type { CountryFeature };
+231
View File
@@ -0,0 +1,231 @@
/**
* Real-time Planetora event stream.
*
* Subscribes (via `nostr.req(...)`) to the same event kinds the historical
* playback engine queries — kinds 1111 / 1068 / 36639 with the same country
* tag constraints — so the two views are perfectly comparable. Every
* incoming event becomes:
*
* 1. A pulse ring on the globe (auto-culled after `RING_TTL_MS`).
* 2. An entry in a recent-events buffer (capped, newest first).
* 3. A throttled "selected" hand-off to the side panel — every event
* pulses, but the panel only refreshes every `selectionThrottleMs` so
* a high-traffic moment doesn't flicker the panel into uselessness.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNostr } from '@nostrify/react';
import {
buildPlanetoraEvent,
planetoraEventFilters,
usePlanetoraRelays,
type PlanetoraEvent,
} from './usePlanetoraEvents';
import {
makePlanetoraRing,
PLANETORA_MAX_VISIBLE_RINGS,
PLANETORA_RING_TTL_MS,
type PlanetoraRing,
} from './usePlanetoraPlayback';
import { loadCountryFeatureMap } from '@/lib/planetora/countryGeo';
/** How many recent live events to keep in the panel buffer. */
const MAX_EVENT_BUFFER = 500;
/** How often to cull expired rings between events. */
const CULL_INTERVAL_MS = 200;
/** How long to wait before refreshing `throttledLatest` after the last update. */
const DEFAULT_SELECTION_THROTTLE_MS = 5000;
export interface PlanetoraLiveStream {
/** Recent live events (newest first), capped at `MAX_EVENT_BUFFER`. */
events: PlanetoraEvent[];
/** Currently active pulse rings. */
rings: PlanetoraRing[];
/** The most recently-arrived event, or `null` before any events. */
latest: PlanetoraEvent | null;
/**
* Throttled mirror of `latest` — only updates every `selectionThrottleMs`,
* so the side panel doesn't flicker when events arrive in bursts. Trailing-
* edge updates: if events keep arriving faster than the throttle, the most
* recent one is applied at the next interval boundary.
*/
throttledLatest: PlanetoraEvent | null;
/** Total events received since the subscription was last started. */
count: number;
/** True until the first event arrives or the relays send EOSE. */
connecting: boolean;
/** Reset throttledLatest to null (e.g. when the user closes the panel). */
clearSelection: () => void;
}
interface UsePlanetoraLiveStreamOptions {
/** Toggle the subscription on / off. */
enabled: boolean;
/**
* If true, `throttledLatest` updates as events arrive (subject to the
* throttle). If false, `throttledLatest` stays at `null` — used to
* implement the "auto-pilot off" state without tearing down the stream.
*/
autoFocus: boolean;
/** Defaults to 5 s. Set higher to dwell longer on each focused event. */
selectionThrottleMs?: number;
}
export function usePlanetoraLiveStream({
enabled,
autoFocus,
selectionThrottleMs = DEFAULT_SELECTION_THROTTLE_MS,
}: UsePlanetoraLiveStreamOptions): PlanetoraLiveStream {
const { nostr } = useNostr();
const relays = usePlanetoraRelays();
// Stable string for the effect dep so we re-subscribe whenever the user
// adds or removes a relay — without this, the subscription's `reqRouter`
// snapshot is fixed at open time and a freshly-added relay is never
// queried until the page reloads.
const relayKey = relays.join('|');
const [events, setEvents] = useState<PlanetoraEvent[]>([]);
const [rings, setRings] = useState<PlanetoraRing[]>([]);
const [latest, setLatest] = useState<PlanetoraEvent | null>(null);
const [throttledLatest, setThrottledLatest] = useState<PlanetoraEvent | null>(null);
const [count, setCount] = useState(0);
const [connecting, setConnecting] = useState(true);
// Refs read inside the throttle effect.
const lastSelectionAt = useRef(0);
const latestRef = useRef<PlanetoraEvent | null>(null);
latestRef.current = latest;
const clearSelection = useCallback(() => {
setThrottledLatest(null);
// Don't reset lastSelectionAt — we still want the next auto-focus to
// honour the cooldown so reopening the page right after a manual close
// doesn't immediately flash the same event back in.
}, []);
// Reset all state when toggling on / off so a stale buffer from a previous
// session doesn't bleed into a fresh subscription.
useEffect(() => {
if (!enabled) {
setEvents([]);
setRings([]);
setLatest(null);
setThrottledLatest(null);
setCount(0);
setConnecting(true);
lastSelectionAt.current = 0;
return;
}
let alive = true;
const ac = new AbortController();
const seen = new Set<string>();
const cullTimer = setInterval(() => {
const now = performance.now();
setRings((prev) => {
const filtered = prev.filter((r) => now - r.spawnedAt < PLANETORA_RING_TTL_MS);
return filtered.length === prev.length ? prev : filtered;
});
}, CULL_INTERVAL_MS);
(async () => {
let countryMap: Awaited<ReturnType<typeof loadCountryFeatureMap>>;
try {
countryMap = await loadCountryFeatureMap();
} catch (err) {
console.warn('[planetora] live: country map failed', err);
return;
}
if (!alive) return;
const since = Math.floor(Date.now() / 1000);
try {
for await (const msg of nostr
.group(relays)
.req(planetoraEventFilters({ since }), { signal: ac.signal })) {
if (!alive) break;
if (msg[0] === 'EOSE') {
setConnecting(false);
continue;
}
if (msg[0] === 'CLOSED') break;
if (msg[0] !== 'EVENT') continue;
const event = msg[2];
if (seen.has(event.id)) continue;
seen.add(event.id);
const planEvent = buildPlanetoraEvent(event, countryMap);
if (!planEvent) continue;
setLatest(planEvent);
setCount((c) => c + 1);
setConnecting(false);
setEvents((prev) => {
const next = [planEvent, ...prev];
return next.length > MAX_EVENT_BUFFER
? next.slice(0, MAX_EVENT_BUFFER)
: next;
});
setRings((prev) => {
const next = prev.concat(makePlanetoraRing(planEvent, `${event.id}#live`));
return next.length > PLANETORA_MAX_VISIBLE_RINGS
? next.slice(next.length - PLANETORA_MAX_VISIBLE_RINGS)
: next;
});
}
} catch (err) {
if ((err as Error)?.name !== 'AbortError') {
console.warn('[planetora] live stream failed', err);
}
}
})();
return () => {
alive = false;
ac.abort();
clearInterval(cullTimer);
};
// `relayKey` (string) drives re-subscription on relay changes; `relays`
// itself is excluded so we don't re-run on every render even when the
// resolved set is identical.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, nostr, relayKey]);
// Throttled selection: when `latest` changes, mirror it into
// `throttledLatest` only if enough time has passed; otherwise schedule a
// trailing-edge update so the most recent event still wins eventually.
useEffect(() => {
if (!enabled || !autoFocus) {
setThrottledLatest(null);
return;
}
if (!latest) return;
const now = performance.now();
const delta = now - lastSelectionAt.current;
if (delta >= selectionThrottleMs) {
setThrottledLatest(latest);
lastSelectionAt.current = now;
return;
}
const remaining = selectionThrottleMs - delta;
const t = setTimeout(() => {
setThrottledLatest(latestRef.current);
lastSelectionAt.current = performance.now();
}, remaining);
return () => clearTimeout(t);
}, [latest, enabled, autoFocus, selectionThrottleMs]);
return {
events,
rings,
latest,
throttledLatest,
count,
connecting,
clearSelection,
};
}
+280
View File
@@ -0,0 +1,280 @@
/**
* Time-compressed replay engine for the Planetora globe.
*
* Maps every event's `created_at` to a `phase ∈ [0, 1)` within the active
* window, then loops a 60-second playback timeline driven by
* `requestAnimationFrame`. Each tick we emit any events whose phase fell
* inside the elapsed slice as ring "pings". Pings auto-expire after a
* fixed TTL.
*
* Behaviour notes (from the brief):
* - Relative timing within the window is preserved (NOT uniform spacing).
* - The 60s timeline loops forever.
* - For `'all'` time we anchor the window to the oldest event in the set.
* - The active set is bounded to `MAX_VISIBLE_RINGS` to keep WebGL happy.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import type { PlanetoraEvent, PlanetoraPeriod } from './usePlanetoraEvents';
import { periodSeconds } from './usePlanetoraEvents';
/** A single animated pulse on the globe. */
export interface PlanetoraRing {
/** Stable per-event-per-cycle key (event id + cycle index). */
key: string;
lat: number;
lng: number;
/** Spawn timestamp in ms — used to age rings out. */
spawnedAt: number;
/** ISO alpha-2 country code, for highlighting. */
country: string;
/** Event id, for the panel and auto-pilot lookups. */
eventId: string;
}
/**
* Short TTL so each ring entry only emits a single pulse before being
* culled. Combined with the rAF expansion in PlanetoraSvgGlobe (1500ms),
* this gives discrete "earthquake-style" pings instead of continuously-
* pulsing dots, and keeps the active ring count low.
*
* Exported so the live-stream hook uses the same value — visual
* consistency between replay and live mode is non-negotiable.
*/
export const PLANETORA_RING_TTL_MS = 1_500;
/** Cap on simultaneously-rendered rings. Same rationale as the TTL. */
export const PLANETORA_MAX_VISIBLE_RINGS = 120;
/**
* Build a `PlanetoraRing` from a `PlanetoraEvent`. Shared by both the
* playback engine (one ring per cycle) and the live stream (one ring per
* arrival) so the on-globe shape stays identical.
*/
export function makePlanetoraRing(
planEvent: PlanetoraEvent,
key: string,
spawnedAt: number = performance.now(),
): PlanetoraRing {
return {
key,
lat: planEvent.coords[1],
lng: planEvent.coords[0],
spawnedAt,
country: planEvent.country,
eventId: planEvent.event.id,
};
}
interface UsePlanetoraPlaybackOptions {
events: PlanetoraEvent[];
period: PlanetoraPeriod;
/** Pause emission while still keeping the rAF loop alive. */
paused?: boolean;
/** Duration of one full loop in ms. Defaults to 60_000. */
cycleMs?: number;
/** How long a ring stays on the globe before being culled, in ms. */
ringTtlMs?: number;
}
const DEFAULT_CYCLE_MS = 60_000;
/**
* The rAF loop runs at the display refresh rate to keep ring spawn timing
* accurate, but React state for the *playhead readout* doesn't need 60fps
* updates. Throttling to ~5Hz reduces parent re-renders by 12× while still
* giving a smooth ticking clock.
*/
const PROGRESS_UPDATE_INTERVAL_MS = 200;
/**
* Drive the 60s replay timeline. Returns the live ring set and a 0..1
* progress value for any UI that wants to show a scrubber.
*/
export function usePlanetoraPlayback({
events,
period,
paused = false,
cycleMs = DEFAULT_CYCLE_MS,
ringTtlMs = PLANETORA_RING_TTL_MS,
}: UsePlanetoraPlaybackOptions): {
rings: PlanetoraRing[];
progress: number;
/** Unix seconds the timeline maps to at `progress = 0`. */
windowStartSec: number;
/** Unix seconds the timeline maps to at `progress = 1`. */
windowEndSec: number;
/**
* Unix seconds of the "currently rendering" moment. Derived from
* `windowStart + progress * windowSpan`. Lets the UI show a clock that
* tracks which slice of real time the pulses correspond to.
*/
playheadSec: number;
} {
const [rings, setRings] = useState<PlanetoraRing[]>([]);
const [progress, setProgress] = useState(0);
// Phase table: { event, phaseInCycleMs } — recomputed when events/period change.
const phaseResult = useMemo(
() => computePhases(events, period, cycleMs),
[events, period, cycleMs],
);
const phases = phaseResult.phases;
const windowStartSec = phaseResult.windowStartSec;
const windowEndSec = phaseResult.windowEndSec;
const windowSpanSec = Math.max(1, windowEndSec - windowStartSec);
const cycleRef = useRef(0);
const playheadRef = useRef(0);
const nextPhaseIndexRef = useRef(0);
const lastFrameRef = useRef<number | null>(null);
const lastProgressUpdateRef = useRef(0);
const pausedRef = useRef(paused);
pausedRef.current = paused;
// Reset the playhead when the active set changes meaningfully.
useEffect(() => {
cycleRef.current = 0;
playheadRef.current = 0;
nextPhaseIndexRef.current = 0;
lastFrameRef.current = null;
setRings([]);
setProgress(0);
}, [phases]);
// Main rAF loop.
useEffect(() => {
if (phases.length === 0) return;
let raf = 0;
const tick = (now: number) => {
if (lastFrameRef.current === null) lastFrameRef.current = now;
// Cap dt so a background-tab jump doesn't force us to simulate
// multiple full cycles in one frame.
const dt = pausedRef.current
? 0
: Math.min(cycleMs, now - lastFrameRef.current);
lastFrameRef.current = now;
const prevHead = playheadRef.current;
let nextHead = prevHead + dt;
let crossedCycle = false;
if (nextHead >= cycleMs) {
nextHead = nextHead % cycleMs;
crossedCycle = true;
cycleRef.current += 1;
}
playheadRef.current = nextHead;
// Throttle the React state update for the visible clock to ~5Hz.
if (
now - lastProgressUpdateRef.current >= PROGRESS_UPDATE_INTERVAL_MS ||
crossedCycle
) {
setProgress(nextHead / cycleMs);
lastProgressUpdateRef.current = now;
}
if (!pausedRef.current) {
const spawned: PlanetoraRing[] = [];
const emitUntil = (to: number, cycle: number) => {
let idx = nextPhaseIndexRef.current;
while (idx < phases.length && phases[idx].phaseInCycleMs < to) {
const p = phases[idx];
spawned.push(
makePlanetoraRing(p.event, `${p.event.event.id}#${cycle}`, now),
);
idx += 1;
}
nextPhaseIndexRef.current = idx;
};
if (crossedCycle) {
emitUntil(cycleMs, cycleRef.current - 1);
nextPhaseIndexRef.current = 0;
emitUntil(nextHead, cycleRef.current);
} else {
emitUntil(nextHead, cycleRef.current);
}
if (spawned.length > 0) {
setRings((prev) => {
const filtered = prev.filter((r) => now - r.spawnedAt < ringTtlMs);
const merged = filtered.concat(spawned);
return merged.length > PLANETORA_MAX_VISIBLE_RINGS
? merged.slice(merged.length - PLANETORA_MAX_VISIBLE_RINGS)
: merged;
});
} else {
// Periodic cull even when nothing new spawned.
setRings((prev) => {
const filtered = prev.filter((r) => now - r.spawnedAt < ringTtlMs);
return filtered.length === prev.length ? prev : filtered;
});
}
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [phases, cycleMs, ringTtlMs]);
const playheadSec = windowStartSec + progress * windowSpanSec;
return { rings, progress, windowStartSec, windowEndSec, playheadSec };
}
interface PhaseEntry {
event: PlanetoraEvent;
phaseInCycleMs: number;
}
interface PhaseResult {
phases: PhaseEntry[];
windowStartSec: number;
windowEndSec: number;
}
/**
* Map each event's `created_at` to a phase within the replay cycle.
* For finite windows we use `[now - window, now]`. For `'all'` we anchor
* the window to `[oldestEvent.created_at, now]` so older events show up
* earlier in the loop.
*/
function computePhases(
events: PlanetoraEvent[],
period: PlanetoraPeriod,
cycleMs: number,
): PhaseResult {
const now = Math.floor(Date.now() / 1000);
if (events.length === 0) {
return { phases: [], windowStartSec: now, windowEndSec: now };
}
let windowStart: number;
if (period === 'all') {
let oldest = events[0].event.created_at;
for (const e of events) {
if (e.event.created_at < oldest) oldest = e.event.created_at;
}
windowStart = oldest;
} else {
windowStart = now - periodSeconds(period);
}
const span = Math.max(1, now - windowStart);
const entries: PhaseEntry[] = [];
for (const event of events) {
const t = event.event.created_at;
if (t < windowStart) continue;
const phase = Math.min(0.9999, Math.max(0, (t - windowStart) / span));
entries.push({ event, phaseInCycleMs: phase * cycleMs });
}
entries.sort((a, b) => a.phaseInCycleMs - b.phaseInCycleMs);
return {
phases: entries,
windowStartSec: windowStart,
windowEndSec: now,
};
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Country GeoJSON loader for the Planetora globe.
*
* Loads the bundled `world-110m.json` TopoJSON file (sourced from the
* `world-atlas` D3 dataset, same file used by the original covid-planet
* project), decodes it to GeoJSON, attaches an ISO 3166-1 alpha-2 country
* code to each feature, and pre-computes centroid + bbox for fast lookup
* when placing camera tweens and per-event random points.
*/
import { feature } from 'topojson-client';
import type { Topology, GeometryCollection } from 'topojson-specification';
import type { Feature, FeatureCollection, Geometry, MultiPolygon, Polygon } from 'geojson';
import { iso31661NumericToAlpha2 } from 'iso-3166';
/** Properties attached to every Planetora country feature. */
export interface CountryFeatureProps {
/** ISO 3166-1 alpha-2 country code. */
iso: string;
/** Approximate geographic centroid as `[lng, lat]`. */
centroid: [number, number];
/** Bounding box as `[minLng, minLat, maxLng, maxLat]`. */
bbox: [number, number, number, number];
}
export type CountryFeature = Feature<Polygon | MultiPolygon, CountryFeatureProps>;
let cache: Promise<CountryFeature[]> | null = null;
/** URL to fetch the bundled TopoJSON from. */
const WORLD_TOPO_URL = '/planetora/world-110m.json';
/**
* Returns the full set of country features (cached after first call).
* Resolves to an empty array if the asset fails to load.
*/
export function loadCountryFeatures(): Promise<CountryFeature[]> {
if (cache) return cache;
cache = (async () => {
try {
const res = await fetch(WORLD_TOPO_URL);
if (!res.ok) throw new Error(`world-110m fetch failed: ${res.status}`);
const topo = (await res.json()) as Topology;
const collection = feature(
topo,
topo.objects.countries as GeometryCollection,
) as FeatureCollection<Polygon | MultiPolygon>;
const features: CountryFeature[] = [];
for (const f of collection.features) {
const iso = numericIdToAlpha2(f.id);
if (!iso) continue;
const bbox = computeBbox(f.geometry);
const centroid = computeCentroid(f.geometry);
features.push({
...f,
properties: { iso, centroid, bbox },
});
}
return features;
} catch (err) {
console.error('[planetora] failed to load country features', err);
return [];
}
})();
return cache;
}
/** Build a `Map<iso2, CountryFeature>` from the cached features. */
export async function loadCountryFeatureMap(): Promise<Map<string, CountryFeature>> {
const features = await loadCountryFeatures();
const map = new Map<string, CountryFeature>();
for (const f of features) map.set(f.properties.iso, f);
return map;
}
/**
* Convert TopoJSON numeric country id (e.g. `4` or `"004"`) to ISO alpha-2
* (e.g. `"AF"`). Returns `null` for unknown ids.
*/
function numericIdToAlpha2(id: Feature['id']): string | null {
if (id === undefined || id === null) return null;
// world-atlas uses numeric ids (sometimes as number, sometimes as string).
const padded = String(id).padStart(3, '0');
const alpha2 = iso31661NumericToAlpha2[padded];
return alpha2 ?? null;
}
/** Compute a simple average-of-vertices centroid for a polygon/multipolygon. */
function computeCentroid(geometry: Geometry): [number, number] {
let lngSum = 0;
let latSum = 0;
let count = 0;
forEachOuterRing(geometry, (ring) => {
for (const [lng, lat] of ring) {
lngSum += lng;
latSum += lat;
count += 1;
}
});
if (count === 0) return [0, 0];
return [lngSum / count, latSum / count];
}
/** Compute axis-aligned bbox `[minLng, minLat, maxLng, maxLat]`. */
function computeBbox(geometry: Geometry): [number, number, number, number] {
let minLng = Infinity;
let minLat = Infinity;
let maxLng = -Infinity;
let maxLat = -Infinity;
forEachOuterRing(geometry, (ring) => {
for (const [lng, lat] of ring) {
if (lng < minLng) minLng = lng;
if (lat < minLat) minLat = lat;
if (lng > maxLng) maxLng = lng;
if (lat > maxLat) maxLat = lat;
}
});
if (!Number.isFinite(minLng)) return [0, 0, 0, 0];
return [minLng, minLat, maxLng, maxLat];
}
function forEachOuterRing(
geometry: Geometry,
cb: (ring: number[][]) => void,
): void {
if (geometry.type === 'Polygon') {
if (geometry.coordinates[0]) cb(geometry.coordinates[0]);
} else if (geometry.type === 'MultiPolygon') {
for (const poly of geometry.coordinates) {
if (poly[0]) cb(poly[0]);
}
}
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Pick a random `[lng, lat]` point inside a country's Polygon or MultiPolygon.
*
* Algorithm: bbox sample + reject. Generate uniformly distributed points in
* the feature's bounding box, accept the first one that passes a ray-casting
* point-in-polygon test. After `MAX_ATTEMPTS` failures we fall back to the
* pre-computed centroid so callers always get a usable coordinate.
*/
import type { CountryFeature } from './countryGeo';
const MAX_ATTEMPTS = 100;
/** Return a random `[lng, lat]` inside the country feature. */
export function randomPointInCountry(feature: CountryFeature): [number, number] {
const [minLng, minLat, maxLng, maxLat] = feature.properties.bbox;
const geom = feature.geometry;
for (let i = 0; i < MAX_ATTEMPTS; i += 1) {
const lng = minLng + Math.random() * (maxLng - minLng);
const lat = minLat + Math.random() * (maxLat - minLat);
if (pointInGeometry(lng, lat, geom)) {
return [lng, lat];
}
}
return feature.properties.centroid;
}
function pointInGeometry(
lng: number,
lat: number,
geom: CountryFeature['geometry'],
): boolean {
if (geom.type === 'Polygon') {
return pointInPolygon(lng, lat, geom.coordinates);
}
for (const poly of geom.coordinates) {
if (pointInPolygon(lng, lat, poly)) return true;
}
return false;
}
/**
* Ray-cast point-in-polygon. `rings[0]` is the outer ring; subsequent rings
* are holes. A point is "in" if it's inside the outer ring and not inside
* any hole.
*/
function pointInPolygon(x: number, y: number, rings: number[][][]): boolean {
if (rings.length === 0) return false;
if (!pointInRing(x, y, rings[0])) return false;
for (let i = 1; i < rings.length; i += 1) {
if (pointInRing(x, y, rings[i])) return false;
}
return true;
}
function pointInRing(x: number, y: number, ring: number[][]): boolean {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
const intersect =
yi > y !== yj > y &&
x < ((xj - xi) * (y - yi)) / (yj - yi + Number.EPSILON) + xi;
if (intersect) inside = !inside;
}
return inside;
}
+2
View File
@@ -15,6 +15,7 @@ import {
Code,
Earth,
Film,
Globe2,
HandHeart,
HelpCircle,
Highlighter,
@@ -247,6 +248,7 @@ export const CONTENT_KIND_ICONS: Record<string, IconComponent> = {
badges: HelpCircle,
communities: Users,
world: Earth,
planetora: Globe2,
archive: HelpCircle,
bluesky: HelpCircle,
vanish: MessageSquareMore,
+306
View File
@@ -0,0 +1,306 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import {
PlanetoraSvgGlobe,
type PlanetoraTheme,
} from '@/components/planetora/PlanetoraSvgGlobe';
import { PlanetoraControls } from '@/components/planetora/PlanetoraControls';
import { PlanetoraEventPanel } from '@/components/planetora/PlanetoraEventPanel';
import {
usePlanetoraEvents,
useEventsForPeriod,
type PlanetoraPeriod,
} from '@/hooks/usePlanetoraEvents';
import { usePlanetoraPlayback } from '@/hooks/usePlanetoraPlayback';
import { usePlanetoraAutoPilot } from '@/hooks/usePlanetoraAutoPilot';
import { usePlanetoraLiveStream } from '@/hooks/usePlanetoraLiveStream';
import { loadCountryFeatures, type CountryFeature } from '@/lib/planetora/countryGeo';
import { useIsMobile } from '@/hooks/useIsMobile';
const USER_INTERACTION_PAUSE_MS = 6000;
/** Top-level Planetora view mode. */
export type PlanetoraMode = 'replay' | 'live';
/**
* Planetora — a fullscreen 3D globe that visualises geographically-tagged
* Nostr activity (kinds 1111 / 1068 / 36639).
*
* Two complementary modes:
* - **Replay**: query a window of recent history and time-compress it into
* a 60-second pulsing replay loop with optional auto-pilot tour.
* - **Live**: subscribe to events as they arrive and pulse them on the
* globe in real time. The side panel auto-focuses on incoming events
* but is rate-limited so a flood of activity doesn't flicker the panel.
*
* Designed to be mounted *outside* `MainLayout` so it owns the entire viewport.
*/
export function PlanetoraPage() {
useSeoMeta({
title: 'Planetora — Agora',
description: 'A live 3D visualisation of global activity on Agora.',
});
const [mode, setMode] = useState<PlanetoraMode>('replay');
const [period, setPeriod] = useState<PlanetoraPeriod>('7d');
// Auto-pilot is the "tour" experience and is on by default — the page is
// designed to be a self-running visualisation that visitors can leave
// playing. The user can toggle it off via the control or by interacting
// with the globe. In live mode it controls whether the panel auto-focuses
// on incoming events.
const [autoPilot, setAutoPilot] = useState(true);
const [countries, setCountries] = useState<CountryFeature[]>([]);
const liveEnabled = mode === 'live';
// Load the country polygons once at mount; the lib caches the result.
useEffect(() => {
let cancelled = false;
loadCountryFeatures().then((features) => {
if (!cancelled) setCountries(features);
});
return () => {
cancelled = true;
};
}, []);
// ── Replay mode data ────────────────────────────────────────────────────
const events = usePlanetoraEvents();
const periodEvents = useEventsForPeriod(events.data, period);
const replay = usePlanetoraPlayback({
// When live mode is active we still let the rAF loop sleep (it
// early-returns on phases.length === 0), so feeding it `[]` is the
// cheapest way to suspend it without unmounting.
events: liveEnabled ? [] : periodEvents,
period,
});
const replayAutoPilot = usePlanetoraAutoPilot({
enabled: !liveEnabled && autoPilot,
events: periodEvents,
});
// ── Live mode data ─────────────────────────────────────────────────────
const live = usePlanetoraLiveStream({
enabled: liveEnabled,
autoFocus: liveEnabled && autoPilot,
});
// ── Unified outputs ────────────────────────────────────────────────────
const rings = liveEnabled ? live.rings : replay.rings;
const selected = liveEnabled ? live.throttledLatest : replayAutoPilot.selected;
const activeCountries = useMemo(() => {
const s = new Set<string>();
const source = liveEnabled ? live.events : periodEvents;
for (const e of source) s.add(e.country);
return s;
}, [liveEnabled, live.events, periodEvents]);
// Pause auto-pilot briefly on direct user interaction with the globe.
const userInteractTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearUserInteractTimer = useCallback(() => {
if (userInteractTimer.current) {
clearTimeout(userInteractTimer.current);
userInteractTimer.current = null;
}
}, []);
const onUserInteract = useCallback(() => {
if (!autoPilot) return;
setAutoPilot(false);
clearUserInteractTimer();
userInteractTimer.current = setTimeout(() => {
userInteractTimer.current = null;
setAutoPilot(true);
}, USER_INTERACTION_PAUSE_MS);
}, [autoPilot, clearUserInteractTimer]);
useEffect(() => {
return () => {
clearUserInteractTimer();
};
}, [clearUserInteractTimer]);
const handleAutoPilotToggle = useCallback(() => {
clearUserInteractTimer();
setAutoPilot((on) => {
if (on) {
replayAutoPilot.clear();
live.clearSelection();
}
return !on;
});
}, [replayAutoPilot, live, clearUserInteractTimer]);
const handlePanelClose = useCallback(() => {
clearUserInteractTimer();
setAutoPilot(false);
replayAutoPilot.clear();
live.clearSelection();
}, [replayAutoPilot, live, clearUserInteractTimer]);
// Skip is only surfaced in replay mode (the controls hide it when live),
// so we don't need a live branch here.
const handleSkip = replayAutoPilot.skip;
// Keyboard shortcuts: Space toggles auto-pilot, Esc closes the panel.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.code === 'Space') {
e.preventDefault();
handleAutoPilotToggle();
} else if (e.code === 'Escape' && selected) {
e.preventDefault();
handlePanelClose();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [handleAutoPilotToggle, handlePanelClose, selected]);
const theme = usePlanetoraTheme();
const isMobile = useIsMobile();
const eventCount = liveEnabled ? live.count : periodEvents.length;
const loading = liveEnabled ? live.connecting : events.isLoading;
return (
<div className="fixed inset-0 z-0 overflow-hidden bg-background text-foreground">
{/* Atmospheric backdrop — same recipe as the homepage hero so the
page reads as a continuation of that visual language instead of
flat white / flat black. Ordered behind the globe via natural
DOM stacking; pointer-events disabled so the globe stays
interactive. */}
<div className="absolute inset-0 pointer-events-none" aria-hidden="true">
{/* Warm/cool gradient wash — the same `from-primary/30 via-background
to-secondary/40` fallback CampaignHeroBackground uses when no
photo is available. Adds peach corners in light mode, deep warmth
in dark mode. */}
<div className="absolute inset-0 bg-gradient-to-br from-primary/25 via-background to-secondary/35" />
{/* Directional warm scrim — pulls the upper-left toward dawn-gold. */}
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(115deg, hsl(38 92% 60% / 0.16) 0%, transparent 60%)',
}}
/>
{/* Big radial dawn glow — mix-blend-screen so it brightens warmly
on dark mode without flattening light mode (where the base
gradient already carries the warmth). */}
<div
className="absolute inset-0"
style={{
backgroundImage:
'radial-gradient(45rem 32rem at 35% 50%, hsl(40 100% 65% / 0.45) 0%, transparent 70%)',
mixBlendMode: 'screen',
}}
/>
{/* Thin sliver of sunrise light along the top edge. */}
<div
className="absolute inset-x-0 top-0 h-1/3"
style={{
backgroundImage:
'linear-gradient(to bottom, hsl(42 100% 72% / 0.35) 0%, transparent 100%)',
mixBlendMode: 'screen',
opacity: 0.6,
}}
/>
{/* Film grain — same trick as the homepage; helps the composited
globe + atmosphere feel like one image. */}
<svg className="absolute inset-0 w-full h-full" style={{ opacity: 0.14 }}>
<filter id="planetora-grain">
<feTurbulence
type="fractalNoise"
baseFrequency="0.7"
numOctaves="2"
stitchTiles="stitch"
/>
<feColorMatrix type="saturate" values="0" />
</filter>
<rect width="100%" height="100%" filter="url(#planetora-grain)" />
</svg>
</div>
<PlanetoraSvgGlobe
countries={countries}
rings={rings}
selected={selected}
activeCountries={activeCountries}
theme={theme}
autoRotate={!autoPilot}
panelSide={selected ? (isMobile ? 'bottom' : 'right') : 'none'}
onUserInteract={onUserInteract}
/>
<PlanetoraControls
mode={mode}
onModeChange={setMode}
period={period}
onPeriodChange={setPeriod}
autoPilot={autoPilot}
onAutoPilotToggle={handleAutoPilotToggle}
onSkip={handleSkip}
eventCount={eventCount}
countryCount={activeCountries.size}
loading={loading}
playheadSec={replay.playheadSec}
windowStartSec={replay.windowStartSec}
windowEndSec={replay.windowEndSec}
progress={replay.progress}
liveLatestAt={live.latest?.event.created_at}
// The bottom-sheet event panel on mobile sits on the same row as
// the timeline / live HUD — hide the HUD while a panel is open
// so they don't fight for the same pixels.
hideBottomHud={isMobile && !!selected}
/>
{selected && (
<PlanetoraEventPanel
event={selected}
onClose={handlePanelClose}
side={isMobile ? 'bottom' : 'right'}
/>
)}
</div>
);
}
export default PlanetoraPage;
/**
* Planetora intentionally uses the same warm "dawn earth" palette
* regardless of the surrounding app's light/dark mode — a dark globe
* tends to read as a surveillance / situation-room screen, which is
* the opposite vibe we want for a community-activity visualiser.
*
* Cream sphere, sandy-amber inactive countries, terracotta active
* countries. Mirrors the homepage HeroGlobe so Planetora reads as a
* continuation of that visual language.
*/
function usePlanetoraTheme(): PlanetoraTheme {
return WARM_THEME;
}
const WARM_THEME: PlanetoraTheme = {
// Sphere shading.
sphereCentre: 'hsl(46 95% 94%)',
sphereMid: 'hsl(34 65% 78%)',
sphereEdge: 'hsl(24 50% 55%)',
// Land.
activeCountryColor: 'hsl(15 78% 50%)',
activeCountryOpacity: 0.92,
restCountryColor: 'hsl(28 38% 60%)',
restCountryOpacity: 0.85,
countryBorder: 'hsl(22 50% 28% / 0.5)',
// Pulse rings — warm cream so they pop on the amber land without
// looking like a security HUD.
ringColor: 'hsl(46 100% 95%)',
// Selected event highlight — saturated saffron so it reads clearly
// against both cream rings and the terracotta active fill.
highlightColor: 'hsl(40 100% 60%)',
};