diff --git a/src/app/globals.css b/src/app/globals.css index 6365ce7..85c006e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -151,6 +151,7 @@ --color-brand: var(--brand); --color-accent-solid: var(--accent-solid); --color-accent-bright: var(--accent-bright); + --color-editor-bg-depth: var(--editor-bg-depth); --color-editor-bg-base: var(--editor-bg-base); --color-editor-bg-primary: var(--editor-bg-primary); --color-editor-bg-secondary: var(--editor-bg-secondary); diff --git a/src/components/data/DataView.tsx b/src/components/data/DataView.tsx new file mode 100644 index 0000000..492ee2a --- /dev/null +++ b/src/components/data/DataView.tsx @@ -0,0 +1,284 @@ +"use client"; + +import { useMemo, useRef, useSyncExternalStore } from "react"; +import { Globe } from "lucide-react"; +import { formatLatitude, formatLongitude } from "@/lib/map/geogrid"; +import { formatIsoDate } from "@/lib/map/fingerprintScale"; +import { formatSeriesValue } from "@/lib/plots/chartTheme"; +import { dailyMeanSeries } from "@/lib/zarr/series"; +import { + formatSelectedYearsLabel, + getSelectedYearsDayMapping, + ZARR_TIME, +} from "@/lib/zarr/timeRange"; +import type { TimeBasis } from "@/lib/zarr/localTime"; +import type { CellSeries } from "@/hooks/useCellSeries"; +import { useSeriesDisplay } from "@/hooks/useSeriesDisplay"; +import { CellExport } from "@/components/panels/CellExport"; +import { PlotArea, PlotControls } from "@/components/panels/CellView"; +import { YearPicker } from "@/components/panels/YearPicker"; +import { + MainViewSwitch, + type MainView, +} from "@/components/data/MainViewSwitch"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardTitle, +} from "@/components/ui/card"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; + +const FADE_DISTANCE = 48; + +function subscribeResize(onChange: () => void) { + window.addEventListener("resize", onChange); + return () => window.removeEventListener("resize", onChange); +} + +function usePlotHeight() { + return useSyncExternalStore( + subscribeResize, + () => Math.round(Math.min(560, Math.max(260, window.innerHeight * 0.5))), + () => 360, + ); +} + +type DayStat = { value: number; date: string }; + +type Summary = { + mean: number; + lowest: DayStat; + highest: DayStat; + days: number; +}; + +function summarize( + values: Float32Array, + selectedYears: number[], +): Summary | null { + const hoursPerDay = ZARR_TIME.hoursPerDay; + const daily = dailyMeanSeries(values, hoursPerDay); + if (daily.length === 0) return null; + const { absoluteDays } = getSelectedYearsDayMapping( + selectedYears, + undefined, + Math.floor(values.length / hoursPerDay), + ); + const dateOf = (day: number) => + formatIsoDate(absoluteDays[day - 1] ?? day - 1); + + let sum = 0; + let lowest = daily[0]; + let highest = daily[0]; + for (const point of daily) { + sum += point.value; + if (point.value < lowest.value) lowest = point; + if (point.value > highest.value) highest = point; + } + return { + mean: sum / daily.length, + lowest: { value: lowest.value, date: dateOf(lowest.day) }, + highest: { value: highest.value, date: dateOf(highest.day) }, + days: daily.length, + }; +} + +function Chip({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function StatCard({ + label, + value, + units, + note, +}: { + label: string; + value: string; + units?: string | null; + note?: string; +}) { + return ( + + + {label} +
+ + {value} + {units ? ( + + {units} + + ) : null} + + {note ? ( + + {note} + + ) : null} +
+
+
+ ); +} + +type DataViewProps = { + series: CellSeries; + timeBasis: TimeBasis; + onMainViewChange: (view: MainView) => void; +}; + +export function DataView({ + series, + timeBasis, + onMainViewChange, +}: DataViewProps) { + const display = useSeriesDisplay(series, timeBasis); + const fadeRef = useRef(null); + const plotHeight = usePlotHeight(); + const { selection, selectedYears, units } = series; + const values = display.displayValues; + + const summary = useMemo( + () => + values && !display.isEmptyCell ? summarize(values, selectedYears) : null, + [values, display.isEmptyCell, selectedYears], + ); + + return ( +
{ + const fade = fadeRef.current; + if (!fade) return; + const progress = event.currentTarget.scrollTop / FADE_DISTANCE; + fade.style.opacity = String(Math.min(1, progress)); + }} + > + + ); +} diff --git a/src/components/data/MainViewSwitch.tsx b/src/components/data/MainViewSwitch.tsx new file mode 100644 index 0000000..e48c484 --- /dev/null +++ b/src/components/data/MainViewSwitch.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { ChartColumn, Globe } from "lucide-react"; +import { SegmentedTabs } from "@/components/ui/segmented-tabs"; + +export type MainView = "map" | "data"; + +const MAIN_VIEWS = [ + { value: "map", label: "Map", icon: Globe }, + { value: "data", label: "Data", icon: ChartColumn }, +] as const; + +type MainViewSwitchProps = { + value: MainView; + onChange: (view: MainView) => void; +}; + +export function MainViewSwitch({ value, onChange }: MainViewSwitchProps) { + return ( + + ); +} diff --git a/src/components/layout/MapToolbar.tsx b/src/components/layout/MapToolbar.tsx index a617cd9..6ddb5b7 100644 --- a/src/components/layout/MapToolbar.tsx +++ b/src/components/layout/MapToolbar.tsx @@ -94,6 +94,7 @@ export function MapActions({ onBeforeGuide }: { onBeforeGuide: () => void }) { type MapToolbarProps = { openPanel: ToolbarPanel | null; onOpenPanelChange: (panel: ToolbarPanel | null) => void; + hideChart?: boolean; hideOnMobile?: boolean; }; @@ -122,8 +123,13 @@ export function SiteMark() { export function MapToolbar({ openPanel, onOpenPanelChange, + hideChart = false, hideOnMobile = false, }: MapToolbarProps) { + const panels = hideChart + ? PANELS.filter(({ value }) => value !== "chart") + : PANELS; + return (
@@ -145,7 +151,7 @@ export function MapToolbar({ onOpenPanelChange((value[0] as ToolbarPanel | undefined) ?? null) } > - {PANELS.map(({ value, label, Icon }) => ( + {panels.map(({ value, label, Icon }) => ( 1 - (1 - t) ** 3, }; +const MINIMAP_ZOOM = 2; + function readViewState(map: MapRef): MapViewState { const center = map.getCenter(); return { @@ -69,10 +73,12 @@ export function ReliefMap() { const mapRef = useRef(null); const detachWheelZoomRef = useRef<(() => void) | null>(null); const didAutoLocateRef = useRef(false); + const viewBeforeDataRef = useRef(null); const [viewMode, setViewMode] = useState("sphere"); const [viewState, setViewState] = useState(DEFAULT_MAP_VIEW); const [mapSize, setMapSize] = useState({ width: 0, height: 0 }); const [openPanel, setOpenPanel] = useState(null); + const [mainView, setMainView] = useState("map"); const [tourExpandsPanel, setTourExpandsPanel] = useState(false); const [tourCoversBottom, setTourCoversBottom] = useState(false); const [tourOpensDetails, setTourOpensDetails] = useState(false); @@ -84,6 +90,7 @@ export function ReliefMap() { const settings = useMapSettings(); const geolocation = useGeolocation(); const isSphere = viewMode === "sphere"; + const showsData = mainView === "data"; const applyTouchRotation = useCallback( (map: MapLibreMap) => { @@ -140,10 +147,10 @@ export function ReliefMap() { const pick = useCallback( (lon: number, lat: number, options?: { fly?: boolean }) => { const selection = series.select(lon, lat); - setOpenPanel("chart"); + if (!showsData) setOpenPanel("chart"); if (options?.fly ?? settings.autoZoom) flyToCell(selection.grid); }, - [flyToCell, series, settings.autoZoom], + [flyToCell, series, settings.autoZoom, showsData], ); // Refs, because the permission dialog can take seconds and the map moves. @@ -195,6 +202,37 @@ export function ReliefMap() { }); }, []); + const handleMainViewChange = useCallback( + (view: MainView) => { + setMainView(view); + const map = mapRef.current; + if (view === "map") { + const saved = viewBeforeDataRef.current; + viewBeforeDataRef.current = null; + if (map && saved) { + map.jumpTo({ + center: [saved.longitude, saved.latitude], + zoom: saved.zoom, + bearing: saved.bearing ?? 0, + pitch: saved.pitch ?? 0, + }); + } + return; + } + if (openPanel === "chart") setOpenPanel(null); + if (!map) return; + viewBeforeDataRef.current = readViewState(map); + const cell = series.selection?.grid; + map.jumpTo({ + center: cell ? [cell.lon, cell.lat] : map.getCenter(), + zoom: Math.min(map.getZoom(), MINIMAP_ZOOM), + bearing: 0, + pitch: 0, + }); + }, + [openPanel, series.selection], + ); + const handleMove = useCallback((event: ViewStateChangeEvent) => { setViewState(event.viewState); }, []); @@ -208,7 +246,14 @@ export function ReliefMap() { return (
-
+
+ {showsData ? ( + + ) : null}
+ {showsData ? ( + + ) : null} +
- {openPanel === "chart" ? ( + {openPanel === "chart" && !showsData ? ( setOpenPanel(null)} expand={tourExpandsPanel} openDetails={tourOpensDetails} + onShowData={() => handleMainViewChange("data")} /> ) : null} {openPanel === "settings" ? ( @@ -309,12 +376,17 @@ export function ReliefMap() {
- setOpenPanel(null)} /> + { + setOpenPanel(null); + setMainView("map"); + }} + />
mapRef.current?.zoomIn()} diff --git a/src/components/panels/CellPanel.tsx b/src/components/panels/CellPanel.tsx index 53f8512..4e9a70a 100644 --- a/src/components/panels/CellPanel.tsx +++ b/src/components/panels/CellPanel.tsx @@ -27,6 +27,7 @@ type CellPanelProps = { onClose: () => void; expand?: boolean; openDetails?: boolean; + onShowData: () => void; }; export function CellPanel({ @@ -35,6 +36,7 @@ export function CellPanel({ onClose, expand, openDetails, + onShowData, }: CellPanelProps) { const display = useSeriesDisplay(series, timeBasis); @@ -70,6 +72,7 @@ export function CellPanel({ series={series} display={display} openDetails={openDetails} + onShowData={onShowData} /> diff --git a/src/components/panels/CellView.tsx b/src/components/panels/CellView.tsx index 776d584..7a8faf7 100644 --- a/src/components/panels/CellView.tsx +++ b/src/components/panels/CellView.tsx @@ -10,6 +10,8 @@ import type { CellSeries, SeriesProgress } from "@/hooks/useCellSeries"; import type { PlotView, SeriesDisplay } from "@/hooks/useSeriesDisplay"; import { TimeSeriesPlot } from "@/components/plots/TimeSeriesPlot"; import { FingerprintPlot } from "@/components/plots/FingerprintPlot"; +import { useDesktop } from "@/components/plots/MapSideTooltip"; +import { MainViewSwitch } from "@/components/data/MainViewSwitch"; import { YearPicker } from "@/components/panels/YearPicker"; import { ColormapSelect } from "@/components/panels/ColormapSelect"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -27,13 +29,16 @@ type CellViewProps = { series: CellSeries; display: SeriesDisplay; openDetails?: boolean; + onShowData: () => void; }; export function CellView({ series, display, openDetails = false, + onShowData, }: CellViewProps) { + const desktop = useDesktop(); const { selection, patchWindow } = series; const [detailsOpen, setDetailsOpen] = useState(openDetails); const [prevOpenDetails, setPrevOpenDetails] = useState(openDetails); @@ -43,24 +48,36 @@ export function CellView({ } if (!selection) return null; - const disabled = display.isEmptyCell; - return (
-
- - {formatLongitude(selection.grid.lon)},{" "} - {formatLatitude(selection.grid.lat)} - - - Details · Years {formatSelectedYearsLabel(series.selectedYears)} - - +
+
+ + {formatLongitude(selection.grid.lon)},{" "} + {formatLatitude(selection.grid.lat)} + + {desktop ? ( + + Years {formatSelectedYearsLabel(series.selectedYears)} + + ) : ( + + Details · Years {formatSelectedYearsLabel(series.selectedYears)} + + + )} +
+ { + if (view === "data") onShowData(); + }} + />
@@ -80,47 +97,7 @@ export function CellView({
- display.setPlotView(value as PlotView)} - > - - - Line - - - Fingerprint - - - - - {display.plotView === "fingerprint" ? ( -
- - - Flip axes - - -
- ) : null} +
@@ -128,12 +105,69 @@ export function CellView({ ); } -function PlotArea({ series, display }: CellViewProps) { +export function PlotControls({ display }: { display: SeriesDisplay }) { + const disabled = display.isEmptyCell; + + return ( +
+ display.setPlotView(value as PlotView)} + > + + + Line + + + Fingerprint + + + + + {display.plotView === "fingerprint" ? ( +
+ + + Flip axes + + +
+ ) : null} +
+ ); +} + +type PlotAreaProps = { + series: CellSeries; + display: SeriesDisplay; + height?: number; + pinLimit?: number; +}; + +export function PlotArea({ series, display, height, pinLimit }: PlotAreaProps) { if (series.loading) { return (
- +
); } @@ -164,6 +198,8 @@ function PlotArea({ series, display }: CellViewProps) { values={display.displayValues} units={series.units} hoursPerDay={ZARR_TIME.hoursPerDay} + height={height} + pinLimit={pinLimit} /> ) : ( ); } diff --git a/src/components/plots/FingerprintPlot.tsx b/src/components/plots/FingerprintPlot.tsx index f15abb6..4de97ba 100644 --- a/src/components/plots/FingerprintPlot.tsx +++ b/src/components/plots/FingerprintPlot.tsx @@ -22,6 +22,9 @@ import { useTheme } from "@/providers/ThemeProvider"; import { anchorBesidePanel, MapSideTooltip, + PinnedTooltip, + pinPosition, + togglePin, useDesktop, type TooltipAnchor, type TooltipPoint, @@ -38,14 +41,20 @@ type FingerprintPlotProps = { timeBasisLabel?: string; transposed: boolean; colormapId: ColormapId; + height?: number; + pinLimit?: number; }; type Hover = FingerprintCell & { left: number; top: number; + flip: boolean; anchor: TooltipAnchor | null; }; +const sameCell = (a: Hover, b: Hover) => + a.dayLocal === b.dayLocal && a.hour === b.hour; + export function FingerprintPlot({ values, units, @@ -54,17 +63,19 @@ export function FingerprintPlot({ timeBasisLabel, transposed, colormapId, + height: heightOverride, + pinLimit = 1, }: FingerprintPlotProps) { const { isLight } = useTheme(); const wrapperRef = useRef(null); const canvasRef = useRef(null); const [width, setWidth] = useState(0); const [hover, setHover] = useState(null); - const [pinned, setPinned] = useState(null); + const [pins, setPins] = useState([]); const [pinnedValues, setPinnedValues] = useState(values); if (pinnedValues !== values) { setPinnedValues(values); - setPinned(null); + setPins([]); } const desktop = useDesktop(); @@ -78,7 +89,9 @@ export function FingerprintPlot({ return () => observer.disconnect(); }, []); - const height = transposed ? TRANSPOSED_MIN_HEIGHT : TIME_SERIES_PLOT_HEIGHT; + const height = + heightOverride ?? + (transposed ? TRANSPOSED_MIN_HEIGHT : TIME_SERIES_PLOT_HEIGHT); const layout = useMemo( () => fingerprintLayout( @@ -131,8 +144,17 @@ export function FingerprintPlot({ const left = event.clientX - rect.left; const top = event.clientY - rect.top; const cell = fingerprintCellAt(layout, left, top); - const anchor = anchorBesidePanel(wrapperRef.current, event.clientY); - setHover(cell ? { ...cell, left, top, anchor } : null); + const anchor = anchorBesidePanel( + wrapperRef.current, + event.clientX, + event.clientY, + ); + const { flip } = pinPosition( + event.currentTarget, + event.clientX, + event.clientY, + ); + setHover(cell ? { ...cell, left, top, flip, anchor } : null); }; const pointAt = ( @@ -155,6 +177,9 @@ export function FingerprintPlot({ ? pointAt(cell.dayLocal, cell.hour - 1, "time") : pointAt(cell.dayLocal - 1, hoursPerDay - 1, "full"); + const separate = pinLimit > 1; + const pinned = separate ? null : (pins[0] ?? null); + const lastPin = pins.at(-1); const shown = pinned ?? hover; const comparing = pinned && @@ -172,9 +197,39 @@ export function FingerprintPlot({ onMouseMove={handleMove} onMouseLeave={() => setHover(null)} onClick={() => { - if (desktop && hover?.anchor) setPinned(hover); + if (desktop && hover?.anchor) { + setPins((current) => + togglePin(current, hover, pinLimit, sameCell), + ); + } }} /> + {desktop && separate + ? pins.map((pin) => ( + + setPins((current) => + current.filter((other) => !sameCell(other, pin)), + ) + } + /> + )) + : null} + {desktop && separate + ? pins.map((pin) => ( +