From 56c6c7ecdfd30a3b64496f320fc4c03441833a67 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Tue, 23 Jun 2026 23:41:22 +0300 Subject: [PATCH 1/2] Refactor global styles and improve hero component rendering --- DESIGN_SYSTEM.md | 88 +++++++ src/app/globals.css | 308 +++++++++++----------- src/components/hero/HeroCanvas.tsx | 28 +- src/components/hero/HeroOverlays.tsx | 1 - src/components/layout/Nav.tsx | 16 +- src/components/map/MapReadout.tsx | 14 +- src/components/nav/Brand.tsx | 2 +- src/components/nav/MapNav.tsx | 47 ++++ src/components/nav/MapNavMenu.tsx | 45 ++++ src/components/nav/MobileMenuButton.tsx | 4 +- src/components/nav/MobileMenuDropdown.tsx | 11 +- src/components/nav/NavLinks.tsx | 9 +- src/components/ui/ThemeToggle.tsx | 12 +- src/lib/constants/theme.ts | 32 ++- src/lib/design-system/tokens.ts | 60 +++++ src/lib/hero/drawHeroField.ts | 95 +++++-- src/lib/nav/isActiveLink.ts | 5 + src/styles/primitives.css | 175 ++++++++++++ src/styles/tokens.css | 112 ++++++++ 19 files changed, 851 insertions(+), 213 deletions(-) create mode 100644 DESIGN_SYSTEM.md create mode 100644 src/components/nav/MapNav.tsx create mode 100644 src/components/nav/MapNavMenu.tsx create mode 100644 src/lib/design-system/tokens.ts create mode 100644 src/lib/nav/isActiveLink.ts create mode 100644 src/styles/primitives.css create mode 100644 src/styles/tokens.css diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md new file mode 100644 index 0000000..f2bf683 --- /dev/null +++ b/DESIGN_SYSTEM.md @@ -0,0 +1,88 @@ +# EarthPrints Design System + +Single source of truth for visual consistency across marketing pages, the map, and canvas/WebGL code. + +## File map + +| File | Purpose | +|---|---| +| `src/styles/tokens.css` | CSS custom properties (colors, spacing, type, motion, map chrome) | +| `src/styles/primitives.css` | Reusable UI patterns (`.island`, `.ds-kicker`, …) | +| `src/lib/design-system/tokens.ts` | Same values for TypeScript / canvas / deck.gl | +| `src/app/globals.css` | Page-specific layout; imports tokens + primitives | + +## Colors + +Black / white surfaces with a teal accent: + +- **Light mode accent:** `#006C66` (`--accent-solid`, `--accent`) +- **Dark mode accent:** `#52D4C8` (`--accent` on dark backgrounds) + +Use semantic tokens, not raw hex, in new CSS: + +| Token | Use for | +|---|---| +| `--text` | Primary copy | +| `--text-secondary` | Body subtitles, hints | +| `--text-muted` | Large labels only (18px+) | +| `--text-dim` | Decorative only — never body text | +| `--accent` | Links, active states, data highlights | +| `--surface` / `--surface-2` | Nested backgrounds, hovers | +| `--elevated-bg` + `--elevated-border` | Floating panels and chips | + +## Spacing & radius + +```css +--space-1 … --space-6 /* 4px → 24px */ +--space-page-x /* horizontal inset for map chrome */ +--radius-sm | md | lg | pill +``` + +## Typography primitives + +| Class | Use for | +|---|---| +| `.ds-brand-word` | “EarthPrints” wordmark (nav, map chip, footer) | +| `.ds-title` | Panel headings (“Pixel location”) | +| `.ds-kicker` | Uppercase dataset / status pills (`FLUXCOM-X NEE`) | +| `.ds-label` | Form section labels (`History window`) | +| `.ds-hint` | Secondary explanatory copy | + +Pair layout-specific classes when needed: `className="ds-title map-readout-title"`. + +## Surfaces + +| Class | Use for | +|---|---| +| `.island` / `.map-island` | Glass panels on the map (readout, menu) | +| `.elevated-chip` / `.map-nav-chip` | Top chrome pills (brand, menu trigger) | +| `.ds-nav-link` | Vertical nav items in map menu | + +## Map layout tokens + +```css +--map-chrome-top / --map-chrome-height /* top row reserved for brand + menu */ +--map-panel-top /* panels start below chrome */ +``` + +Do not place content over the top-left brand chip. + +## TypeScript usage + +```ts +import { accentRgb, primitives } from "@/lib/design-system/tokens"; + +// deck.gl layer color +getLineColor: [...accentRgb.onDark, 255] + +// React class names + diff --git a/src/components/nav/Brand.tsx b/src/components/nav/Brand.tsx index bcbc235..c02d709 100644 --- a/src/components/nav/Brand.tsx +++ b/src/components/nav/Brand.tsx @@ -8,7 +8,7 @@ export function Brand() { - + {SITE_NAME} diff --git a/src/components/nav/MapNav.tsx b/src/components/nav/MapNav.tsx new file mode 100644 index 0000000..9a9f2d6 --- /dev/null +++ b/src/components/nav/MapNav.tsx @@ -0,0 +1,47 @@ +"use client"; + +import Link from "next/link"; +import { useId, useState } from "react"; +import { BrandMark } from "@/icons/BrandMark"; +import { ThemeToggle } from "@/components/ui/ThemeToggle"; +import { MobileMenuButton } from "@/components/nav/MobileMenuButton"; +import { MapNavMenu } from "@/components/nav/MapNavMenu"; +import { SITE_NAME } from "@/lib/constants/site"; + +export function MapNav() { + const [menuOpen, setMenuOpen] = useState(false); + const menuId = useId(); + + return ( + <> + + + setMenuOpen(false)} + menuId={menuId} + /> + + ); +} diff --git a/src/components/nav/MapNavMenu.tsx b/src/components/nav/MapNavMenu.tsx new file mode 100644 index 0000000..a5cd039 --- /dev/null +++ b/src/components/nav/MapNavMenu.tsx @@ -0,0 +1,45 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useEffect } from "react"; +import { NAV_LINKS } from "@/lib/constants/nav"; +import { isActiveLink } from "@/lib/nav/isActiveLink"; + +type MapNavMenuProps = { + open: boolean; + onClose: () => void; + menuId: string; +}; + +export function MapNavMenu({ open, onClose, menuId }: MapNavMenuProps) { + const pathname = usePathname(); + + useEffect(() => { + onClose(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname]); + + if (!open) return null; + + return ( + + ); +} diff --git a/src/components/nav/MobileMenuButton.tsx b/src/components/nav/MobileMenuButton.tsx index 4d7fecc..4955c95 100644 --- a/src/components/nav/MobileMenuButton.tsx +++ b/src/components/nav/MobileMenuButton.tsx @@ -7,12 +7,14 @@ type MobileMenuButtonProps = { open: boolean; onToggle: () => void; controlsId: string; + className?: string; }; export function MobileMenuButton({ open, onToggle, controlsId, + className, }: MobileMenuButtonProps) { return ( diff --git a/src/components/nav/MobileMenuDropdown.tsx b/src/components/nav/MobileMenuDropdown.tsx index 0d22520..fa149e4 100644 --- a/src/components/nav/MobileMenuDropdown.tsx +++ b/src/components/nav/MobileMenuDropdown.tsx @@ -5,12 +5,7 @@ import { usePathname } from "next/navigation"; import { useEffect } from "react"; import { ArrowUpRight } from "@/icons/ArrowUpRight"; import { NAV_LINKS } from "@/lib/constants/nav"; - -function isActive(pathname: string, href: string): boolean { - if (href === "#") return false; - if (href === "/") return pathname === "/"; - return pathname === href || pathname.startsWith(`${href}/`); -} +import { isActiveLink } from "@/lib/nav/isActiveLink"; type MobileMenuDropdownProps = { open: boolean; @@ -58,13 +53,13 @@ export function MobileMenuDropdown({ {link.label} ))} - {!isActive(pathname, "/map") && ( + {!isActiveLink(pathname, "/map") && ( Open Map diff --git a/src/components/nav/NavLinks.tsx b/src/components/nav/NavLinks.tsx index f7c6ea8..1d0a2c0 100644 --- a/src/components/nav/NavLinks.tsx +++ b/src/components/nav/NavLinks.tsx @@ -3,12 +3,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { NAV_LINKS } from "@/lib/constants/nav"; - -function isActive(pathname: string, href: string): boolean { - if (href === "#") return false; - if (href === "/") return pathname === "/"; - return pathname === href || pathname.startsWith(`${href}/`); -} +import { isActiveLink } from "@/lib/nav/isActiveLink"; export function NavLinks() { const pathname = usePathname(); @@ -19,7 +14,7 @@ export function NavLinks() { {link.label} diff --git a/src/components/ui/ThemeToggle.tsx b/src/components/ui/ThemeToggle.tsx index 6250f43..32ec93e 100644 --- a/src/components/ui/ThemeToggle.tsx +++ b/src/components/ui/ThemeToggle.tsx @@ -5,11 +5,19 @@ import { MoonIcon } from "@/icons/MoonIcon"; import { SunIcon } from "@/icons/SunIcon"; import { useTheme } from "@/providers/ThemeProvider"; -export function ThemeToggle() { +type ThemeToggleProps = { + className?: string; +}; + +export function ThemeToggle({ className }: ThemeToggleProps) { const { toggleTheme } = useTheme(); return ( - + diff --git a/src/lib/constants/theme.ts b/src/lib/constants/theme.ts index 1bda4ab..f85441b 100644 --- a/src/lib/constants/theme.ts +++ b/src/lib/constants/theme.ts @@ -1,16 +1,26 @@ +import { + accentRgb, + coldRgb, + heroBaseColor, +} from "@/lib/design-system/tokens"; + +export { + accentBrightRgb, + accentRgb, + coldRgb, + colors, + heroBaseColor, + primitives, + radius, + spacing, +} from "@/lib/design-system/tokens"; + export const HERO_SEED = 41.7; -export const TEAL_RGB = [0, 108, 102] as const; +export const TEAL_RGB = accentRgb.light; -/** Bright teal for dark UI (matches --accent in dark mode). */ -export const TEAL_ON_DARK_RGB = [82, 212, 200] as const; +export const TEAL_ON_DARK_RGB = accentRgb.onDark; -export const COLD_RGB = { - light: [120, 120, 118] as const, - dark: [150, 150, 150] as const, -}; +export const COLD_RGB = coldRgb; -export const HERO_BASE_COLOR = { - light: "#F4F4F2", - dark: "#070707", -} as const; +export const HERO_BASE_COLOR = heroBaseColor; diff --git a/src/lib/design-system/tokens.ts b/src/lib/design-system/tokens.ts new file mode 100644 index 0000000..938a384 --- /dev/null +++ b/src/lib/design-system/tokens.ts @@ -0,0 +1,60 @@ +/** Design tokens for canvas, WebGL, and other non-CSS consumers. */ + +export const colors = { + accentSolid: "#006c66", + accentDark: "#52d4c8", + accentBrightLight: "#00837b", + accentBrightDark: "#7df5e8", + pageLight: "#f2f1ee", + pageDark: "#242424", + mapLight: "#f4f4f2", + mapDark: "#000000", + textLight: "#0a0a0a", + textDark: "#ffffff", +} as const; + +/** RGB tuples aligned with `--accent` / `--accent-solid` in tokens.css */ +export const accentRgb = { + light: [0, 108, 102] as const, + onDark: [82, 212, 200] as const, +} as const; + +export const accentBrightRgb = { + light: [0, 131, 123] as const, + dark: [125, 245, 232] as const, +} as const; + +export const coldRgb = { + light: [120, 120, 118] as const, + dark: [150, 150, 150] as const, +} as const; + +export const heroBaseColor = { + light: colors.pageLight, + dark: colors.pageDark, +} as const; + +export const spacing = { + pageX: "clamp(16px, 3vw, 28px)", + pageWide: "clamp(22px, 6vw, 84px)", +} as const; + +export const radius = { + sm: "10px", + md: "14px", + lg: "18px", + pill: "100px", +} as const; + +/** CSS class names for shared primitives — use in components for consistency. */ +export const primitives = { + island: "island", + elevatedChip: "elevated-chip", + brandWord: "ds-brand-word", + title: "ds-title", + kicker: "ds-kicker", + label: "ds-label", + hint: "ds-hint", + navLink: "ds-nav-link", + enter: "ds-enter", +} as const; diff --git a/src/lib/hero/drawHeroField.ts b/src/lib/hero/drawHeroField.ts index 4616867..5ce97df 100644 --- a/src/lib/hero/drawHeroField.ts +++ b/src/lib/hero/drawHeroField.ts @@ -1,10 +1,53 @@ -import { fbm } from "@/lib/hero/noise"; +import { fbm, hash } from "@/lib/hero/noise"; +import { HERO_BASE_COLOR } from "@/lib/constants/theme"; const HERO_SEED = 41.7; +/** Soft cluster hotspots — main group sits behind hero copy. */ +const HERO_CLUSTERS: Array<[number, number, number, number]> = [ + [0.38, 0.48, 1.65, 0.62], + [0.68, 0.22, 2.15, 0.4], + [0.2, 0.34, 2.35, 0.34], + [0.78, 0.5, 2.05, 0.36], + [0.54, 0.7, 2.45, 0.28], + [0.14, 0.6, 2.7, 0.24], +]; + +function clusterBand(nx: number, ny: number): number { + let band = 0; + for (const [cx, cy, falloff, weight] of HERO_CLUSTERS) { + const dx = nx - cx; + const dy = ny - cy; + const dist = Math.sqrt(dx * dx + dy * dy); + band = Math.max(band, Math.max(0, 1 - dist * falloff) * weight); + } + return band; +} + +function dotPhase(i: number, j: number, salt: number): number { + return hash(i * 1.17 + salt, j * 0.93 + salt * 0.5, HERO_SEED) * Math.PI * 2; +} + +/** Oscillate around the grid anchor; offset is zero at time 0. */ +function dotDrift( + time: number, + phase: number, + phaseB: number, + amp: number, +): { x: number; y: number } { + return { + x: + (Math.sin(time * 0.55 + phase) - Math.sin(phase)) * amp + + (Math.cos(time * 0.37 + phaseB) - Math.cos(phaseB)) * amp * 0.55, + y: + (Math.cos(time * 0.48 + phaseB) - Math.cos(phaseB)) * amp + + (Math.sin(time * 0.41 + phase) - Math.sin(phase)) * amp * 0.55, + }; +} + function readHeroTokens(isLight: boolean) { return { - baseFill: isLight ? "#F4F4F2" : "#070707", // --bg-deep + baseFill: isLight ? HERO_BASE_COLOR.light : HERO_BASE_COLOR.dark, dotFaint: isLight ? "rgba(10,10,10,.05)" : "rgba(255,255,255,.045)", // --grid-line tint @@ -14,20 +57,24 @@ function readHeroTokens(isLight: boolean) { tealRgb: isLight ? ([0, 108, 102] as [number, number, number]) // --accent-solid / TEAL_RGB : ([82, 212, 200] as [number, number, number]), // --accent / TEAL_ON_DARK_RGB + tealBrightRgb: isLight + ? ([0, 131, 123] as [number, number, number]) // --accent-bright light + : ([125, 245, 232] as [number, number, number]), // --accent-bright dark coldRgb: isLight ? ([120, 120, 118] as [number, number, number]) // COLD_RGB.light : ([150, 150, 150] as [number, number, number]), // COLD_RGB.dark tealGlowDark: isLight - ? (t: number) => `rgba(0,108,102,${(t - 0.8) * 0.5})` // TEAL_RGB - : (t: number) => `rgba(82,212,200,${(t - 0.8) * 0.45})`, // TEAL_ON_DARK_RGB - dotAlphaBase: isLight ? 0.1 : 0.16, - dotAlphaScale: isLight ? 0.55 : 0.72, + ? (t: number) => `rgba(0,108,102,${(t - 0.65) * 0.62})` + : (t: number) => `rgba(125,245,232,${(t - 0.65) * 0.55})`, + dotAlphaBase: isLight ? 0.12 : 0.2, + dotAlphaScale: isLight ? 0.62 : 0.88, }; } export function drawHeroField( canvas: HTMLCanvasElement, isLight: boolean, + time = 0, ): void { const ctx = canvas.getContext("2d"); if (!ctx) return; @@ -51,6 +98,7 @@ export function drawHeroField( const cold = tok.coldRgb; const teal = tok.tealRgb; + const tealBright = tok.tealBrightRgb; for (let j = 0; j < rows; j++) { for (let i = 0; i < cols; i++) { @@ -58,12 +106,11 @@ export function drawHeroField( const y = j * step; let n = fbm(i * 0.11 + 0.5, j * 0.13 + 0.5, HERO_SEED); - const dx = x / w - 0.24; - const dy = y / h - 0.78; - const band = Math.max(0, 1 - Math.sqrt(dx * dx + dy * dy) * 1.5); - n = Math.min(1, n * 0.85 + band * 0.5); + const patch = fbm(i * 0.21 + 1.7, j * 0.18 + 0.9, HERO_SEED + 13); + const band = clusterBand(x / w, y / h); + n = Math.min(1, n * 0.48 + patch * 0.4 + band * 0.52); - const t = Math.max(0, (n - 0.34) / 0.66); + const t = Math.max(0, (n - 0.3) / 0.7); if (t <= 0.02) { ctx.fillStyle = tok.dotFaint; @@ -73,22 +120,32 @@ export function drawHeroField( continue; } - const r = 0.9 + t * 2.6; - const mix = Math.pow(t, 1.3); - const cr = Math.round(cold[0] + (teal[0] - cold[0]) * mix); - const cg = Math.round(cold[1] + (teal[1] - cold[1]) * mix); - const cb = Math.round(cold[2] + (teal[2] - cold[2]) * mix); + const phase = dotPhase(i, j, 0); + const phaseB = dotPhase(i, j, 1); + const drift = dotDrift(time, phase, phaseB, 2.4 + t * 3.2); + const drawX = x + drift.x; + const drawY = y + drift.y; + + const r = 0.9 + t * 3; + const mix = Math.pow(t, 1.15); + const spark = Math.pow(Math.max(0, (t - 0.55) / 0.45), 0.75); + let cr = Math.round(cold[0] + (teal[0] - cold[0]) * mix); + let cg = Math.round(cold[1] + (teal[1] - cold[1]) * mix); + let cb = Math.round(cold[2] + (teal[2] - cold[2]) * mix); + cr = Math.round(cr + (tealBright[0] - cr) * spark); + cg = Math.round(cg + (tealBright[1] - cg) * spark); + cb = Math.round(cb + (tealBright[2] - cb) * spark); const a = tok.dotAlphaBase + t * tok.dotAlphaScale; ctx.fillStyle = `rgba(${cr},${cg},${cb},${a})`; ctx.beginPath(); - ctx.arc(x, y, r, 0, 6.283); + ctx.arc(drawX, drawY, r, 0, 6.283); ctx.fill(); - if (t > 0.8) { + if (t > 0.65) { ctx.fillStyle = tok.tealGlowDark(t); ctx.beginPath(); - ctx.arc(x, y, r * 2.6, 0, 6.283); + ctx.arc(drawX, drawY, r * 2.8, 0, 6.283); ctx.fill(); } } diff --git a/src/lib/nav/isActiveLink.ts b/src/lib/nav/isActiveLink.ts new file mode 100644 index 0000000..4f61be2 --- /dev/null +++ b/src/lib/nav/isActiveLink.ts @@ -0,0 +1,5 @@ +export function isActiveLink(pathname: string, href: string): boolean { + if (href === "#") return false; + if (href === "/") return pathname === "/"; + return pathname === href || pathname.startsWith(`${href}/`); +} diff --git a/src/styles/primitives.css b/src/styles/primitives.css new file mode 100644 index 0000000..94fd5a5 --- /dev/null +++ b/src/styles/primitives.css @@ -0,0 +1,175 @@ +/* Shared UI primitives — compose these classes in components. */ + +.island, +.map-island { + border: 1px solid var(--map-island-border, var(--elevated-border)); + border-radius: var(--radius-lg); + color: var(--text); + background: var(--elevated-bg); + backdrop-filter: blur(var(--blur-elevated)); + -webkit-backdrop-filter: blur(var(--blur-elevated)); + box-shadow: var(--elevated-shadow); +} + +.elevated-chip, +.map-nav-chip:not(.icon-btn) { + border: 1px solid var(--elevated-border); + border-radius: var(--radius-md); + background: var(--elevated-bg); + backdrop-filter: blur(var(--blur-elevated)); + -webkit-backdrop-filter: blur(var(--blur-elevated)); + box-shadow: var(--elevated-shadow); + color: var(--text); + transition: + border-color var(--duration-fast), + background var(--duration-fast); +} + +.map-nav-chip.icon-btn { + border: 1px solid var(--elevated-border); + border-radius: 50%; + background: var(--elevated-bg); + backdrop-filter: blur(var(--blur-elevated)); + -webkit-backdrop-filter: blur(var(--blur-elevated)); + box-shadow: var(--elevated-shadow); + color: var(--text); + transition: + border-color var(--duration-fast), + background var(--duration-fast); +} + +.elevated-chip:hover, +.map-nav-theme.icon-btn:hover, +.map-nav-brand:hover, +.map-nav-chip.icon-btn:hover { + border-color: color-mix(in srgb, var(--elevated-border) 60%, var(--text)); + background: color-mix(in srgb, var(--elevated-bg) 88%, var(--surface-2)); +} + +.ds-brand-word, +.brand .word, +.map-nav-brand-word { + font-weight: 600; + font-size: var(--font-size-lg); + letter-spacing: var(--tracking-snug); + white-space: nowrap; +} + +.brand .word { + font-size: var(--font-size-brand); +} + +.ds-brand-word b, +.brand .word b, +.map-nav-brand-word b { + font-weight: 600; +} + +.ds-title, +.map-readout-title { + font-size: var(--font-size-xl); + font-weight: 600; + letter-spacing: var(--tracking-tight); + margin-bottom: 0; + color: var(--text); +} + +.ds-kicker, +.map-readout-kicker { + display: inline-flex; + align-items: center; + width: fit-content; + flex-shrink: 0; + font-size: var(--font-size-xs); + font-weight: 600; + letter-spacing: var(--tracking-wider); + text-transform: uppercase; + color: var(--accent); + padding: 5px 10px; + border-radius: var(--radius-pill); + border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); + background: color-mix(in srgb, var(--accent) 14%, transparent); +} + +.ds-label, +.map-readout-control-label { + font-size: var(--font-size-sm); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + color: var(--text-secondary); + font-weight: 500; +} + +.ds-hint, +.map-readout-hint, +.map-readout-empty { + color: var(--text-secondary); + font-size: var(--font-size-meta); + line-height: 1.55; +} + +.ds-nav-link, +.map-nav-menu-links a { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: var(--font-size-base); + color: var(--text-secondary); + padding: 7px 10px; + border-radius: var(--radius-sm); + letter-spacing: var(--tracking-normal); + transition: + color var(--duration-fast), + background var(--duration-fast); +} + +.ds-nav-link:hover, +.map-nav-menu-links a:hover { + color: var(--text); + background: var(--surface-2); +} + +.ds-nav-link.active, +.map-nav-menu-links a.active { + color: var(--text); + background: color-mix(in srgb, var(--accent) 10%, var(--surface-2)); +} + +.ds-nav-link.active::before, +.map-nav-menu-links a.active::before { + content: ""; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--accent); + flex-shrink: 0; +} + +.ds-enter { + animation: fadeIn var(--duration-enter) var(--ease-out); +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.map-panel { + position: fixed; + top: var(--map-panel-top); + padding: var(--space-5) var(--space-5) var(--space-4); +} + +.map-panel--left { + left: var(--space-page-x); + z-index: 2; +} + +.map-panel--right { + right: var(--space-page-x); + z-index: 81; +} diff --git a/src/styles/tokens.css b/src/styles/tokens.css new file mode 100644 index 0000000..683cf2b --- /dev/null +++ b/src/styles/tokens.css @@ -0,0 +1,112 @@ +/* EarthPrints design tokens — single source of truth for CSS variables. */ + +:root { + /* ── Color: surfaces ── */ + --bg: var(--page-bg); + --page-bg: #242424; + --map-bg: #000000; + --bg-deep: var(--map-bg); + --surface: #161616; + --surface-2: #1f1f1f; + --nav-bg: #121212; + + /* ── Color: text (contrast notes kept for a11y audits) ── */ + --text: #ffffff; + --text-secondary: rgba(255, 255, 255, 0.8); + --text-muted: rgba(255, 255, 255, 0.56); + --text-dim: rgba(255, 255, 255, 0.34); + + /* ── Color: borders & overlays ── */ + --border: rgba(255, 255, 255, 0.1); + --border-strong: rgba(255, 255, 255, 0.18); + --scrim: rgba(10, 10, 10, 0.92); + --scrim-soft: rgba(10, 10, 10, 0.55); + --grid-line: rgba(255, 255, 255, 0.05); + + /* ── Color: brand accent ── */ + --accent-solid: #006c66; + --accent: #52d4c8; + --accent-bright: #7df5e8; + --accent-on-dark: #52d4c8; + + /* ── Elevation (floating panels, nav chips, map islands) ── */ + --elevated-bg: var(--page-bg); + --elevated-border: rgba(255, 255, 255, 0.24); + --elevated-shadow: + 0 10px 28px -14px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(255, 255, 255, 0.08) inset; + --shadow: 0 18px 50px -24px rgba(0, 0, 0, 0.7); + --blur-elevated: 16px; + --blur-chip: 6px; + + /* ── Spacing ── */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 18px; + --space-6: 24px; + --space-page-x: clamp(16px, 3vw, 28px); + --space-page-wide: clamp(22px, 6vw, 84px); + + /* ── Radius ── */ + --radius-sm: 10px; + --radius-md: 14px; + --radius-lg: 18px; + --radius-xl: 24px; + --radius-pill: 100px; + + /* ── Typography ── */ + --font-size-xs: 11px; + --font-size-sm: 12px; + --font-size-meta: 13px; + --font-size-base: 14px; + --font-size-md: 15px; + --font-size-lg: 16px; + --font-size-xl: 20px; + --font-size-brand: 17px; + --tracking-tight: -0.03em; + --tracking-snug: -0.02em; + --tracking-normal: -0.01em; + --tracking-wide: 0.06em; + --tracking-wider: 0.1em; + + /* ── Motion ── */ + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --duration-fast: 0.2s; + --duration-enter: 0.32s; + + /* ── Map chrome ── */ + --map-chrome-top: var(--space-5); + --map-chrome-height: 44px; + --map-chrome-gap: var(--space-3); + --map-panel-top: calc( + var(--map-chrome-top) + var(--map-chrome-height) + var(--map-chrome-gap) + ); + --map-island-border: var(--elevated-border); +} + +:root.light { + --page-bg: #f2f1ee; + --map-bg: #f4f4f2; + --surface: #ffffff; + --surface-2: #f6f6f4; + --nav-bg: #ffffff; + --text: #0a0a0a; + --text-secondary: rgba(10, 10, 10, 0.72); + --text-muted: rgba(10, 10, 10, 0.56); + --text-dim: rgba(10, 10, 10, 0.36); + --border: rgba(10, 10, 10, 0.1); + --border-strong: rgba(10, 10, 10, 0.16); + --accent-solid: #006c66; + --accent: #006c66; + --accent-bright: #00837b; + --accent-on-dark: #006c66; + --scrim: rgba(255, 255, 255, 0.92); + --scrim-soft: rgba(255, 255, 255, 0.55); + --grid-line: rgba(10, 10, 10, 0.05); + --shadow: 0 18px 50px -28px rgba(0, 0, 0, 0.22); + --elevated-bg: var(--page-bg); + --elevated-border: var(--border-strong); + --elevated-shadow: 0 8px 20px -16px rgba(0, 0, 0, 0.1); +} From 041f436879041141823d8b750f9821e1a7d03baf Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Wed, 24 Jun 2026 00:29:31 +0300 Subject: [PATCH 2/2] feat: add recharts for time series visualization and enhance map readout component --- package-lock.json | 223 +++++++++++++++++-- package.json | 1 + src/app/globals.css | 51 ++++- src/components/map/EarthMap.tsx | 12 +- src/components/map/MapReadout.tsx | 83 +++---- src/components/map/TimeSeriesPlot.tsx | 102 +++++++++ src/components/map/TimeSeriesPlotLoading.tsx | 71 ++++++ src/components/map/timeSeriesChartConfig.ts | 32 +++ src/lib/zarr/series.test.ts | 21 ++ src/lib/zarr/series.ts | 32 +++ 10 files changed, 563 insertions(+), 65 deletions(-) create mode 100644 src/components/map/TimeSeriesPlot.tsx create mode 100644 src/components/map/TimeSeriesPlotLoading.tsx create mode 100644 src/components/map/timeSeriesChartConfig.ts create mode 100644 src/lib/zarr/series.test.ts create mode 100644 src/lib/zarr/series.ts diff --git a/package-lock.json b/package-lock.json index 6adff83..8e33d14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "react": "19.2.7", "react-dom": "19.2.7", "react-map-gl": "^8.1.1", + "recharts": "^3.9.0", "zarrita": "^0.7.3" }, "devDependencies": { @@ -2405,6 +2406,42 @@ "integrity": "sha512-4VpAyMHOqydSvPlEyHwXaE+AkIdR03nX+Qhlxsk2D/IW4OVmDZgIsvJB1cDzyEEtcfKcnaEbfXeiPgejBceT6g==", "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -2717,7 +2754,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, "node_modules/@swc/helpers": { @@ -3296,8 +3338,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/d3-fetch": { "version": "3.0.7", @@ -3345,7 +3386,6 @@ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", "license": "MIT", - "peer": true, "dependencies": { "@types/d3-color": "*" } @@ -3354,8 +3394,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/d3-polygon": { "version": "3.0.2", @@ -3439,7 +3478,6 @@ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", "license": "MIT", - "peer": true, "dependencies": { "@types/d3-path": "*" } @@ -3448,8 +3486,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/d3-time-format": { "version": "4.0.3", @@ -3462,8 +3499,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/d3-transition": { "version": "3.0.9", @@ -3559,7 +3595,7 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -3596,6 +3632,12 @@ "license": "MIT", "peer": true }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", @@ -5220,6 +5262,15 @@ "node": ">=0.8" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", @@ -5628,7 +5679,6 @@ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=12" } @@ -5716,7 +5766,6 @@ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -5841,7 +5890,6 @@ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", "license": "ISC", - "peer": true, "dependencies": { "d3-path": "^3.1.0" }, @@ -5878,7 +5926,6 @@ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -6078,6 +6125,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deck.gl": { "version": "9.3.3", "resolved": "https://registry.npmjs.org/deck.gl/-/deck.gl-9.3.3.tgz", @@ -6472,7 +6525,6 @@ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", "license": "MIT", - "peer": true, "workspaces": [ "docs", "benchmarks" @@ -6924,6 +6976,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -7568,6 +7626,16 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -9624,7 +9692,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/react-map-gl": { @@ -9651,6 +9718,29 @@ } } }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -9672,6 +9762,51 @@ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, + "node_modules/recharts": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.0.tgz", + "integrity": "sha512-dCEcE9y20c8H2tkVeByrAXhhnBJk6/QLbxKmn+dJUptOfc5NMjwRh1jo0vZPRLD+5dMrHrP+hPEsfbGBMfnf5Q==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reference-spec-reader": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz", @@ -9721,6 +9856,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve": { "version": "2.0.0-next.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", @@ -10633,6 +10774,12 @@ "license": "MIT", "peer": true }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -11093,12 +11240,52 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/victory-vendor/node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, "node_modules/vite": { "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", diff --git a/package.json b/package.json index ec79f0b..31adf17 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "react": "19.2.7", "react-dom": "19.2.7", "react-map-gl": "^8.1.1", + "recharts": "^3.9.0", "zarrita": "^0.7.3" }, "devDependencies": { diff --git a/src/app/globals.css b/src/app/globals.css index 3769eed..ec4c858 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -749,7 +749,7 @@ body:has(.map-shell) .footer { } .map-readout { - width: min(360px, calc(100vw - 32px)); + width: min(420px, calc(100vw - 32px)); --map-readout-border: var(--map-island-border, var(--elevated-border)); } @@ -827,6 +827,55 @@ body:has(.map-shell) .footer { border-top: 1px solid var(--map-readout-border); } +.map-readout-series-section { + margin-top: var(--space-4); + padding-top: var(--space-4); + border-top: 1px solid var(--map-readout-border); +} + +.map-readout-series-label { + margin-bottom: var(--space-3); +} + +.map-readout-series { + display: grid; + gap: var(--space-3); + min-width: 0; +} + +.map-readout-series-status { + margin: 0; +} + +.map-time-series-plot { + width: 100%; + min-width: 0; + margin-inline: calc(var(--space-1) * -1); + padding-inline: var(--space-1); +} + +.map-time-series-caption { + margin-top: var(--space-2); + font-size: var(--font-size-xs); + color: var(--text-secondary); + line-height: 1.5; +} + +.map-time-series-loading { + position: relative; +} + +.map-time-series-loading-label { + position: absolute; + inset: 12px 8px 28px 48px; + display: grid; + place-items: center; + margin: 0; + pointer-events: none; + font-size: var(--font-size-meta); + color: var(--text-secondary); +} + /* ============================================================ FOOTER ============================================================ */ diff --git a/src/components/map/EarthMap.tsx b/src/components/map/EarthMap.tsx index def3ee2..f553f8b 100644 --- a/src/components/map/EarthMap.tsx +++ b/src/components/map/EarthMap.tsx @@ -60,8 +60,7 @@ export function EarthMap({ className }: EarthMapProps) { const [historyYears, setHistoryYears] = useState(DEFAULT_HISTORY_YEARS); const [loadingSeries, setLoadingSeries] = useState(false); const [seriesError, setSeriesError] = useState(null); - const [seriesLength, setSeriesLength] = useState(null); - const [seriesPreview, setSeriesPreview] = useState(null); + const [seriesValues, setSeriesValues] = useState(null); const [seriesUnits, setSeriesUnits] = useState(null); const [darkMapStyle, setDarkMapStyle] = useState( null, @@ -101,8 +100,7 @@ export function EarthMap({ className }: EarthMapProps) { const requestId = ++requestIdRef.current; setLoadingSeries(true); setSeriesError(null); - setSeriesLength(null); - setSeriesPreview(null); + setSeriesValues(null); setSeriesUnits(null); try { @@ -125,8 +123,7 @@ export function EarthMap({ className }: EarthMapProps) { if (requestId !== requestIdRef.current) return; - setSeriesLength(values.length); - setSeriesPreview(Array.from(values.subarray(0, 3))); + setSeriesValues(values); setSeriesUnits(units ?? null); } catch (error) { if (requestId !== requestIdRef.current) return; @@ -279,8 +276,7 @@ export function EarthMap({ className }: EarthMapProps) { onHistoryYearsChange={handleHistoryYearsChange} loadingSeries={loadingSeries} seriesError={seriesError} - seriesLength={seriesLength} - seriesPreview={seriesPreview} + seriesValues={seriesValues} seriesUnits={seriesUnits} /> diff --git a/src/components/map/MapReadout.tsx b/src/components/map/MapReadout.tsx index d7c9834..2ab3e3b 100644 --- a/src/components/map/MapReadout.tsx +++ b/src/components/map/MapReadout.tsx @@ -4,6 +4,8 @@ import type { MapSelection } from "@/types/map"; import { formatGeoPoint } from "@/lib/map/geogrid"; import { ZARR_STORE } from "@/lib/constants/store"; import { ZARR_TIME } from "@/lib/zarr/timeRange"; +import { TimeSeriesPlot } from "@/components/map/TimeSeriesPlot"; +import { TimeSeriesPlotLoading } from "@/components/map/TimeSeriesPlotLoading"; type MapReadoutProps = { selection: MapSelection | null; @@ -11,8 +13,7 @@ type MapReadoutProps = { onHistoryYearsChange: (years: number) => void; loadingSeries: boolean; seriesError: string | null; - seriesLength: number | null; - seriesPreview: number[] | null; + seriesValues: Float32Array | null; seriesUnits: string | null; }; @@ -22,8 +23,7 @@ export function MapReadout({ onHistoryYearsChange, loadingSeries, seriesError, - seriesLength, - seriesPreview, + seriesValues, seriesUnits, }: MapReadoutProps) { const historyLabel = @@ -66,40 +66,47 @@ export function MapReadout({
{selection ? ( -
-
-
Click
-
{formatGeoPoint(selection.click)}
-
-
-
Grid cell
-
{formatGeoPoint(selection.grid)}
-
-
-
Indices
-
- lon {selection.grid.lonIndex}, lat {selection.grid.latIndex} -
-
-
-
Variable
-
{ZARR_STORE.defaultVariable}
-
-
-
Time series
-
- {loadingSeries && "Fetching from Zarr…"} - {!loadingSeries && seriesError && seriesError} - {!loadingSeries && - !seriesError && - seriesPreview && - seriesLength !== null && - `${seriesLength} steps · first ${seriesPreview - .map((value) => value.toFixed(2)) - .join(", ")}${seriesUnits ? ` ${seriesUnits}` : ""}`} -
-
-
+ <> +
+
+
Click
+
{formatGeoPoint(selection.click)}
+
+
+
Grid cell
+
{formatGeoPoint(selection.grid)}
+
+
+
Indices
+
+ lon {selection.grid.lonIndex}, lat {selection.grid.latIndex} +
+
+
+
Variable
+
{ZARR_STORE.defaultVariable}
+
+
+ +
+

Time series

+
+ {loadingSeries && ( + + )} + {!loadingSeries && seriesError && ( +

{seriesError}

+ )} + {!loadingSeries && !seriesError && seriesValues && ( + + )} +
+
+ ) : (

No pixel selected yet.

)} diff --git a/src/components/map/TimeSeriesPlot.tsx b/src/components/map/TimeSeriesPlot.tsx new file mode 100644 index 0000000..0df1854 --- /dev/null +++ b/src/components/map/TimeSeriesPlot.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useMemo } from "react"; +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { + formatSeriesValue, + TIME_SERIES_CHART_MARGIN, + TIME_SERIES_PLOT_HEIGHT, + timeSeriesChartTheme, +} from "@/components/map/timeSeriesChartConfig"; +import { dailyMeanSeries } from "@/lib/zarr/series"; +import { useTheme } from "@/providers/ThemeProvider"; + +export { TIME_SERIES_PLOT_HEIGHT } from "@/components/map/timeSeriesChartConfig"; + +type TimeSeriesPlotProps = { + values: Float32Array; + units?: string | null; + hoursPerDay?: number; +}; + +export function TimeSeriesPlot({ + values, + units, + hoursPerDay = 24, +}: TimeSeriesPlotProps) { + const { isLight } = useTheme(); + const data = useMemo( + () => dailyMeanSeries(values, hoursPerDay), + [values, hoursPerDay], + ); + + const { stroke, grid, tick, tooltipBg, tooltipBorder } = + timeSeriesChartTheme(isLight); + + if (data.length === 0) return null; + + return ( +
+ + + + + + `Day ${day}`} + formatter={(value) => [ + `${formatSeriesValue(Number(value))}${units ? ` ${units}` : ""}`, + "Daily mean", + ]} + /> + + + +

+ {values.length.toLocaleString()} hourly steps · {data.length} daily means + {units ? ` · ${units}` : ""} +

+
+ ); +} diff --git a/src/components/map/TimeSeriesPlotLoading.tsx b/src/components/map/TimeSeriesPlotLoading.tsx new file mode 100644 index 0000000..d5c3db9 --- /dev/null +++ b/src/components/map/TimeSeriesPlotLoading.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { + CartesianGrid, + LineChart, + ResponsiveContainer, + XAxis, + YAxis, +} from "recharts"; +import { + dayCountForHistory, + formatSeriesValue, + TIME_SERIES_CHART_MARGIN, + TIME_SERIES_PLOT_HEIGHT, + timeSeriesChartTheme, +} from "@/components/map/timeSeriesChartConfig"; +import { useTheme } from "@/providers/ThemeProvider"; + +type TimeSeriesPlotLoadingProps = { + historyYears: number; +}; + +export function TimeSeriesPlotLoading({ + historyYears, +}: TimeSeriesPlotLoadingProps) { + const { isLight } = useTheme(); + const { grid, tick } = timeSeriesChartTheme(isLight); + const dayCount = dayCountForHistory(historyYears); + + return ( +
+ + + + + + + +

Loading…

+
+ ); +} diff --git a/src/components/map/timeSeriesChartConfig.ts b/src/components/map/timeSeriesChartConfig.ts new file mode 100644 index 0000000..f91c029 --- /dev/null +++ b/src/components/map/timeSeriesChartConfig.ts @@ -0,0 +1,32 @@ +import { colors } from "@/lib/design-system/tokens"; +import { yearsToDayRange } from "@/lib/zarr/timeRange"; + +export const TIME_SERIES_PLOT_HEIGHT = 220; + +export const TIME_SERIES_CHART_MARGIN = { + top: 12, + right: 8, + left: 0, + bottom: 20, +} as const; + +export function formatSeriesValue(value: number): string { + return value.toFixed(2); +} + +export function dayCountForHistory(historyYears: number): number { + const [start, stop] = yearsToDayRange(historyYears); + return stop - start; +} + +export function timeSeriesChartTheme(isLight: boolean) { + return { + stroke: isLight ? colors.accentSolid : colors.accentDark, + grid: isLight ? "rgba(10, 10, 10, 0.08)" : "rgba(255, 255, 255, 0.08)", + tick: isLight ? "rgba(10, 10, 10, 0.56)" : "rgba(255, 255, 255, 0.56)", + tooltipBg: isLight ? colors.pageLight : colors.pageDark, + tooltipBorder: isLight + ? "rgba(10, 10, 10, 0.16)" + : "rgba(255, 255, 255, 0.18)", + }; +} diff --git a/src/lib/zarr/series.test.ts b/src/lib/zarr/series.test.ts new file mode 100644 index 0000000..d372bc1 --- /dev/null +++ b/src/lib/zarr/series.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { dailyMeanSeries } from "@/lib/zarr/series"; + +describe("dailyMeanSeries", () => { + it("averages each block of hourly values", () => { + const hourly = new Float32Array([ + 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, + 24, 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, + 100, 200, + ]); + + expect(dailyMeanSeries(hourly, 24)).toEqual([ + { day: 1, value: 23 }, + { day: 2, value: 150 }, + ]); + }); + + it("returns an empty array for empty input", () => { + expect(dailyMeanSeries(new Float32Array(), 24)).toEqual([]); + }); +}); diff --git a/src/lib/zarr/series.ts b/src/lib/zarr/series.ts new file mode 100644 index 0000000..192cbad --- /dev/null +++ b/src/lib/zarr/series.ts @@ -0,0 +1,32 @@ +export type DailyMeanPoint = { + day: number; + value: number; +}; + +/** Collapse hourly (or sub-daily) steps into one mean per day. */ +export function dailyMeanSeries( + values: ArrayLike, + hoursPerDay = 24, +): DailyMeanPoint[] { + if (values.length === 0 || hoursPerDay <= 0) return []; + + const dayCount = Math.ceil(values.length / hoursPerDay); + const points: DailyMeanPoint[] = []; + + for (let day = 0; day < dayCount; day++) { + const start = day * hoursPerDay; + const end = Math.min(start + hoursPerDay, values.length); + let sum = 0; + + for (let i = start; i < end; i++) { + sum += values[i]!; + } + + points.push({ + day: day + 1, + value: sum / (end - start), + }); + } + + return points; +}