Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53828741e1 | |||
| 4cd2aadba2 | |||
| e57a3029f5 | |||
| 7590af5a76 | |||
| 605e7f599f | |||
| f5bc774aba | |||
| 3b125592d1 | |||
| e318ca0550 | |||
| af36a9c7d5 | |||
| d7729b705e | |||
| e04342668b | |||
| 80b56b3318 | |||
| 3ade1e8126 | |||
| 279c8b914c | |||
| 9313e9b1d7 | |||
| 9ca70dfcc2 | |||
| 4067904e09 | |||
| 523235e043 | |||
| 14ca8999ad | |||
| 9dcc183044 | |||
| 7a519ba341 | |||
| aeb73e941b | |||
| 9fed3bc0b7 | |||
| 6555253224 | |||
| 4ad6feac5d |
Generated
+67
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 />} />
|
||||
|
||||
@@ -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>© {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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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%)',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user