diff --git a/src/components/data/DataView.tsx b/src/components/data/DataView.tsx index 492ee2a..d60cde7 100644 --- a/src/components/data/DataView.tsx +++ b/src/components/data/DataView.tsx @@ -1,26 +1,17 @@ "use client"; -import { useMemo, useRef, useSyncExternalStore } from "react"; -import { Globe } from "lucide-react"; +import { useRef, useSyncExternalStore } from "react"; +import { ArrowLeft } 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 { formatSelectedYearsLabel } 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 { TimeBasisField } from "@/components/panels/TimeBasisField"; import { YearPicker } from "@/components/panels/YearPicker"; -import { - MainViewSwitch, - type MainView, -} from "@/components/data/MainViewSwitch"; +import { SeriesSummary } from "@/components/data/SeriesSummary"; import { Button } from "@/components/ui/button"; import { Card, @@ -28,14 +19,6 @@ import { CardDescription, CardTitle, } from "@/components/ui/card"; -import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "@/components/ui/empty"; const FADE_DISTANCE = 48; @@ -52,46 +35,6 @@ function usePlotHeight() { ); } -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 ( @@ -100,63 +43,23 @@ function Chip({ children }: { children: React.ReactNode }) { ); } -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; + onTimeBasisChange: (basis: TimeBasis) => void; + onClose: () => void; }; export function DataView({ series, timeBasis, - onMainViewChange, + onTimeBasisChange, + onClose, }: 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 (
- {!selection ? ( - - - - - - No point picked yet - - Pick a point on the map or search for a place to see its record - - - - - - - ) : ( + {selection ? (
- )} + ) : null}
); diff --git a/src/components/data/MainViewSwitch.tsx b/src/components/data/MainViewSwitch.tsx deleted file mode 100644 index e48c484..0000000 --- a/src/components/data/MainViewSwitch.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"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/data/SeriesSummary.tsx b/src/components/data/SeriesSummary.tsx new file mode 100644 index 0000000..d7c9d75 --- /dev/null +++ b/src/components/data/SeriesSummary.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { useMemo } from "react"; +import { formatIsoDate } from "@/lib/map/fingerprintScale"; +import { formatSeriesValue } from "@/lib/plots/chartTheme"; +import { dailyMeanSeries } from "@/lib/zarr/series"; +import { getSelectedYearsDayMapping, ZARR_TIME } from "@/lib/zarr/timeRange"; +import type { CellSeries } from "@/hooks/useCellSeries"; +import type { SeriesDisplay } from "@/hooks/useSeriesDisplay"; +import { Card, CardContent } from "@/components/ui/card"; + +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, + }; +} + +/** One summary per selected year, read from that year's slice of the series. */ +function summarizeByYear( + values: Float32Array, + selectedYears: number[], +): { year: number; summary: Summary }[] { + const hoursPerDay = ZARR_TIME.hoursPerDay; + const { yearIntervals } = getSelectedYearsDayMapping( + selectedYears, + undefined, + Math.floor(values.length / hoursPerDay), + ); + return yearIntervals.flatMap(({ year, startDayLocal, endDayLocal }) => { + const summary = summarize( + values.subarray(startDayLocal * hoursPerDay, (endDayLocal + 1) * hoursPerDay), + [year], + ); + return summary ? [{ year, summary }] : []; + }); +} + +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 SeriesSummaryProps = { + series: CellSeries; + display: SeriesDisplay; + className?: string; +}; + +export function SeriesSummary({ + series, + display, + className, +}: SeriesSummaryProps) { + const { selectedYears, units } = series; + const values = display.displayValues; + + const summary = useMemo( + () => + values && !display.isEmptyCell ? summarize(values, selectedYears) : null, + [values, display.isEmptyCell, selectedYears], + ); + + const byYear = useMemo( + () => + values && !display.isEmptyCell && selectedYears.length > 1 + ? summarizeByYear(values, selectedYears) + : [], + [values, display.isEmptyCell, selectedYears], + ); + + if (!summary) return null; + + return ( +
+

+ Daily means over the selected years +

+
+ + + + +
+ {byYear.length > 0 ? ( + + ) : null} +
+ ); +} + +function DayCell({ stat }: { stat: DayStat }) { + return ( + + {formatSeriesValue(stat.value)} + {stat.date} + + ); +} + +function YearTable({ + rows, + total, + units, +}: { + rows: { year: number; summary: Summary }[]; + total: Summary; + units?: string | null; +}) { + const row = (label: string, summary: Summary, isTotal = false) => ( + + + {label} + + + {formatSeriesValue(summary.mean)} + + + + + {summary.days.toLocaleString()} + + + ); + + return ( + +
+ + + + + + + + + + + + + {rows.map(({ year, summary }) => row(String(year), summary))} + {row("All years", total, true)} + +
+ Per year{units ? `, ${units}` : ""} +
+ Year + + Mean + + Lowest day + + Highest day + + Days with data +
+
+
+ ); +} diff --git a/src/components/layout/MapToolbar.tsx b/src/components/layout/MapToolbar.tsx index 6ddb5b7..849867c 100644 --- a/src/components/layout/MapToolbar.tsx +++ b/src/components/layout/MapToolbar.tsx @@ -5,6 +5,7 @@ import { ChartLine, CircleQuestionMark, Compass, + type LucideIcon, Moon, Settings, Sun, @@ -96,6 +97,7 @@ type MapToolbarProps = { onOpenPanelChange: (panel: ToolbarPanel | null) => void; hideChart?: boolean; hideOnMobile?: boolean; + viewAction?: { label: string; Icon: LucideIcon; onClick: () => void }; }; export function SiteMark() { @@ -125,6 +127,7 @@ export function MapToolbar({ onOpenPanelChange, hideChart = false, hideOnMobile = false, + viewAction, }: MapToolbarProps) { const panels = hideChart ? PANELS.filter(({ value }) => value !== "chart") @@ -164,6 +167,17 @@ export function MapToolbar({ + + {viewAction ? ( + + + + + + ) : null} ); } diff --git a/src/components/map/ReliefMap.tsx b/src/components/map/ReliefMap.tsx index 8535cc4..4a3c648 100644 --- a/src/components/map/ReliefMap.tsx +++ b/src/components/map/ReliefMap.tsx @@ -7,7 +7,7 @@ import Map, { type ViewStateChangeEvent, } from "react-map-gl/maplibre"; import type { Map as MapLibreMap, MapLibreEvent } from "maplibre-gl"; -import { Maximize2, X } from "lucide-react"; +import { ArrowLeft, Maximize2, X } from "lucide-react"; import "maplibre-gl/dist/maplibre-gl.css"; import "@/lib/map/initMaplibre"; import { geoPointToZarrGrid } from "@/lib/map/geogrid"; @@ -36,7 +36,6 @@ import { type ToolbarPanel, } from "@/components/layout/MapToolbar"; import { DataView } from "@/components/data/DataView"; -import type { MainView } from "@/components/data/MainViewSwitch"; import { MapControls } from "@/components/map/MapControls"; import { MapSearch } from "@/components/map/MapSearch"; import { MapTour } from "@/components/map/MapTour"; @@ -74,11 +73,12 @@ export function ReliefMap() { const detachWheelZoomRef = useRef<(() => void) | null>(null); const didAutoLocateRef = useRef(false); const viewBeforeDataRef = useRef(null); + const reopenChartRef = useRef(false); 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 [extended, setExtended] = useState(false); const [tourExpandsPanel, setTourExpandsPanel] = useState(false); const [tourCoversBottom, setTourCoversBottom] = useState(false); const [tourOpensDetails, setTourOpensDetails] = useState(false); @@ -90,7 +90,7 @@ export function ReliefMap() { const settings = useMapSettings(); const geolocation = useGeolocation(); const isSphere = viewMode === "sphere"; - const showsData = mainView === "data"; + const showsData = extended && series.selection !== null; const applyTouchRotation = useCallback( (map: MapLibreMap) => { @@ -202,11 +202,14 @@ export function ReliefMap() { }); }, []); - const handleMainViewChange = useCallback( - (view: MainView) => { - setMainView(view); + const showExtended = useCallback( + (open: boolean) => { + if (open && !series.selection) return; + setExtended(open); const map = mapRef.current; - if (view === "map") { + if (!open) { + if (reopenChartRef.current) setOpenPanel("chart"); + reopenChartRef.current = false; const saved = viewBeforeDataRef.current; viewBeforeDataRef.current = null; if (map && saved) { @@ -219,7 +222,10 @@ export function ReliefMap() { } return; } - if (openPanel === "chart") setOpenPanel(null); + if (openPanel === "chart") { + reopenChartRef.current = true; + setOpenPanel(null); + } if (!map) return; viewBeforeDataRef.current = readViewState(map); const cell = series.selection?.grid; @@ -233,6 +239,34 @@ export function ReliefMap() { [openPanel, series.selection], ); + // A history entry, so the back button or gesture closes the view rather + // than leaving the site. + const openExtended = useCallback(() => { + window.history.pushState({ extendedView: true }, ""); + showExtended(true); + }, [showExtended]); + + const closeExtended = useCallback(() => { + if (window.history.state?.extendedView) window.history.back(); + else showExtended(false); + }, [showExtended]); + + useEffect(() => { + const onPopState = () => + showExtended(Boolean(window.history.state?.extendedView)); + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); + }, [showExtended]); + + useEffect(() => { + if (!showsData) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && !event.defaultPrevented) closeExtended(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [showsData, closeExtended]); + const handleMove = useCallback((event: ViewStateChangeEvent) => { setViewState(event.viewState); }, []); @@ -303,7 +337,7 @@ export function ReliefMap() {