From 80201cb51273aa8bcade51734a303da427cb92e1 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Wed, 14 Oct 2026 18:44:50 +0300 Subject: [PATCH 1/4] Open the data view from an Extended view button Replaces the Map/Data switch. A floating "Extended view" button sits beside the chart panel (above the sheet on phones). The data view has a "Map" back button, and Escape or the browser back button also return to the map, reopening the chart panel. Local/UTC moves from Settings into the data view's plot controls, and the empty "No point picked yet" state is gone since the view needs a point. --- src/components/data/DataView.tsx | 69 +++++++++++-------------- src/components/data/MainViewSwitch.tsx | 28 ---------- src/components/map/ReliefMap.tsx | 60 ++++++++++++++++----- src/components/panels/CellPanel.tsx | 63 ++++++++++++---------- src/components/panels/CellView.tsx | 18 +++---- src/components/panels/SettingsPanel.tsx | 26 ++-------- src/lib/constants/tour.ts | 2 +- 7 files changed, 129 insertions(+), 137 deletions(-) delete mode 100644 src/components/data/MainViewSwitch.tsx diff --git a/src/components/data/DataView.tsx b/src/components/data/DataView.tsx index 492ee2a..60e957a 100644 --- a/src/components/data/DataView.tsx +++ b/src/components/data/DataView.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo, useRef, useSyncExternalStore } from "react"; -import { Globe } from "lucide-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"; @@ -17,10 +17,6 @@ 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, @@ -28,17 +24,15 @@ import { CardDescription, CardTitle, } from "@/components/ui/card"; -import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "@/components/ui/empty"; +import { SegmentedTabs } from "@/components/ui/segmented-tabs"; const FADE_DISTANCE = 48; +const TIME_BASES = [ + { value: "local", label: "Local" }, + { value: "utc", label: "UTC" }, +] as const; + function subscribeResize(onChange: () => void) { window.addEventListener("resize", onChange); return () => window.removeEventListener("resize", onChange); @@ -138,13 +132,15 @@ function StatCard({ 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); @@ -175,24 +171,7 @@ export function DataView({ className="pointer-events-none fixed inset-x-0 top-0 z-10 h-25 bg-linear-to-b from-editor-bg-depth from-55% to-transparent opacity-0 max-md:hidden" />
- {!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/map/ReliefMap.tsx b/src/components/map/ReliefMap.tsx index 8535cc4..8b97089 100644 --- a/src/components/map/ReliefMap.tsx +++ b/src/components/map/ReliefMap.tsx @@ -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() { + + + + + View + + + Export + + + + + + + + + + + ); } diff --git a/src/components/panels/CellView.tsx b/src/components/panels/CellView.tsx index 7a8faf7..796ff96 100644 --- a/src/components/panels/CellView.tsx +++ b/src/components/panels/CellView.tsx @@ -11,7 +11,6 @@ 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"; @@ -29,14 +28,12 @@ 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; @@ -72,12 +69,6 @@ export function CellView({ )} - { - if (view === "data") onShowData(); - }} - />
@@ -105,7 +96,13 @@ export function CellView({ ); } -export function PlotControls({ display }: { display: SeriesDisplay }) { +export function PlotControls({ + display, + children, +}: { + display: SeriesDisplay; + children?: React.ReactNode; +}) { const disabled = display.isEmptyCell; return ( @@ -151,6 +148,7 @@ export function PlotControls({ display }: { display: SeriesDisplay }) { />
) : null} + {children} ); } diff --git a/src/components/panels/SettingsPanel.tsx b/src/components/panels/SettingsPanel.tsx index 44406bb..d2fb7cc 100644 --- a/src/components/panels/SettingsPanel.tsx +++ b/src/components/panels/SettingsPanel.tsx @@ -6,7 +6,6 @@ import { PATCH_WINDOW_SIZES, type PatchWindowSize, } from "@/lib/settings/patchWindow"; -import type { TimeBasis } from "@/lib/zarr/localTime"; import type { MapViewMode } from "@/types/map"; import type { MapSettings } from "@/hooks/useMapSettings"; import { FloatingPanel } from "@/components/panels/FloatingPanel"; @@ -22,14 +21,8 @@ import { FieldSet, } from "@/components/ui/field"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { SegmentedTabs } from "@/components/ui/segmented-tabs"; import { Switch } from "@/components/ui/switch"; -const TIME_BASES = [ - { value: "local", label: "Local" }, - { value: "utc", label: "UTC" }, -] as const; - type SettingsPanelProps = { settings: MapSettings; patchWindow: PatchWindowSize; @@ -50,21 +43,10 @@ export function SettingsPanel({ return ( -
-
- Projection - -
-
- Plot hours in - - value={settings.timeBasis} - onChange={settings.setTimeBasis} - options={TIME_BASES} - aria-label="Time basis" - /> -
-
+
+ Projection + +
diff --git a/src/lib/constants/tour.ts b/src/lib/constants/tour.ts index 0df9691..ad7d31c 100644 --- a/src/lib/constants/tour.ts +++ b/src/lib/constants/tour.ts @@ -92,7 +92,7 @@ export const TOUR_STEPS: TourStepSpec[] = [ target: '[data-tour="plot"]', title: "Two views of the same numbers", body: [ - "Line is the daily mean, one point per day. Fingerprint keeps all 24 hours: columns are days, rows are hours in local time (or UTC, set in Settings), blue for uptake and red for release.", + "Line is the daily mean, one point per day. Fingerprint keeps all 24 hours: columns are days, rows are hours in local time (or UTC, switched in the extended view), blue for uptake and red for release.", "The Export tab downloads a zip: a PDF report, the hourly numbers as XLSX and CSV, and both plots as images.", ], gate: "series", From 4c3c47b9c1c1d44f115f9bee3271b02ae0758d1f Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Wed, 14 Oct 2026 18:56:07 +0300 Subject: [PATCH 2/4] Add plot hours selection to DataView and enhance MapToolbar with view action button --- src/components/data/DataView.tsx | 28 +++++++++++++++++----------- src/components/layout/MapToolbar.tsx | 14 ++++++++++++++ src/components/map/ReliefMap.tsx | 13 ++++++++++++- src/components/panels/CellPanel.tsx | 2 +- src/components/panels/CellView.tsx | 9 +-------- 5 files changed, 45 insertions(+), 21 deletions(-) diff --git a/src/components/data/DataView.tsx b/src/components/data/DataView.tsx index 60e957a..0fab0cf 100644 --- a/src/components/data/DataView.tsx +++ b/src/components/data/DataView.tsx @@ -24,6 +24,7 @@ import { CardDescription, CardTitle, } from "@/components/ui/card"; +import { FieldLegend, FieldSet } from "@/components/ui/field"; import { SegmentedTabs } from "@/components/ui/segmented-tabs"; const FADE_DISTANCE = 48; @@ -184,6 +185,19 @@ export function DataView({ /> + + +
+ Plot hours in + + value={timeBasis} + onChange={onTimeBasisChange} + options={TIME_BASES} + aria-label="Plot hours in" + /> +
+
+
@@ -193,7 +207,7 @@ export function DataView({
-
+
{formatLongitude(selection.grid.lon)},{" "} @@ -212,22 +226,14 @@ export function DataView({
- - - value={timeBasis} - onChange={onTimeBasisChange} - options={TIME_BASES} - aria-label="Plot hours in" - className="ml-auto" - /> - + 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 8b97089..783968a 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"; @@ -363,6 +363,17 @@ export function ReliefMap() { onOpenPanelChange={setOpenPanel} hideChart={showsData} hideOnMobile={tourCoversBottom} + viewAction={ + showsData + ? { label: "Map", Icon: ArrowLeft, onClick: closeExtended } + : openPanel === "chart" && series.selection + ? { + label: "Extended view", + Icon: Maximize2, + onClick: openExtended, + } + : undefined + } />
{openPanel === "chart" && !showsData ? ( diff --git a/src/components/panels/CellPanel.tsx b/src/components/panels/CellPanel.tsx index 527b282..bf30ca4 100644 --- a/src/components/panels/CellPanel.tsx +++ b/src/components/panels/CellPanel.tsx @@ -63,7 +63,7 @@ export function CellPanel({ variant="outline" size="lg" onClick={onOpenExtended} - className="absolute top-0 left-full ml-2 bg-background shadow-sm dark:bg-background max-md:top-auto max-md:right-3 max-md:bottom-full max-md:left-auto max-md:ml-0 max-md:mb-3 max-md:rounded-full max-md:shadow-lg" + className="absolute right-3 bottom-full mb-3 rounded-full bg-background shadow-lg md:hidden dark:bg-background" > Extended view diff --git a/src/components/panels/CellView.tsx b/src/components/panels/CellView.tsx index 796ff96..d6b5b0b 100644 --- a/src/components/panels/CellView.tsx +++ b/src/components/panels/CellView.tsx @@ -96,13 +96,7 @@ export function CellView({ ); } -export function PlotControls({ - display, - children, -}: { - display: SeriesDisplay; - children?: React.ReactNode; -}) { +export function PlotControls({ display }: { display: SeriesDisplay }) { const disabled = display.isEmptyCell; return ( @@ -148,7 +142,6 @@ export function PlotControls({ />
) : null} - {children} ); } From b39e33538ade02afa8e39e6653e19291401344fe Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Wed, 14 Oct 2026 19:59:46 +0300 Subject: [PATCH 3/4] Refactor DataView and CellPanel components --- src/components/data/DataView.tsx | 150 ++--------------------- src/components/data/SeriesSummary.tsx | 139 +++++++++++++++++++++ src/components/map/ReliefMap.tsx | 4 +- src/components/panels/CellPanel.tsx | 64 ++++------ src/components/panels/CellView.tsx | 12 ++ src/components/panels/TimeBasisField.tsx | 29 +++++ src/lib/constants/tour.ts | 2 +- 7 files changed, 223 insertions(+), 177 deletions(-) create mode 100644 src/components/data/SeriesSummary.tsx create mode 100644 src/components/panels/TimeBasisField.tsx diff --git a/src/components/data/DataView.tsx b/src/components/data/DataView.tsx index 0fab0cf..d60cde7 100644 --- a/src/components/data/DataView.tsx +++ b/src/components/data/DataView.tsx @@ -1,22 +1,17 @@ "use client"; -import { useMemo, useRef, useSyncExternalStore } from "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 { SeriesSummary } from "@/components/data/SeriesSummary"; import { Button } from "@/components/ui/button"; import { Card, @@ -24,16 +19,9 @@ import { CardDescription, CardTitle, } from "@/components/ui/card"; -import { FieldLegend, FieldSet } from "@/components/ui/field"; -import { SegmentedTabs } from "@/components/ui/segmented-tabs"; const FADE_DISTANCE = 48; -const TIME_BASES = [ - { value: "local", label: "Local" }, - { value: "utc", label: "UTC" }, -] as const; - function subscribeResize(onChange: () => void) { window.addEventListener("resize", onChange); return () => window.removeEventListener("resize", onChange); @@ -47,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 ( @@ -95,41 +43,6 @@ 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; @@ -147,13 +60,6 @@ export function DataView({ 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 (
-
- Plot hours in - - value={timeBasis} - onChange={onTimeBasisChange} - options={TIME_BASES} - aria-label="Plot hours in" - /> -
+
@@ -244,36 +145,11 @@ export function DataView({ - {summary ? ( -
-

- Daily means over the selected years -

-
- - - - -
-
- ) : null} +
) : null} diff --git a/src/components/data/SeriesSummary.tsx b/src/components/data/SeriesSummary.tsx new file mode 100644 index 0000000..800d4cd --- /dev/null +++ b/src/components/data/SeriesSummary.tsx @@ -0,0 +1,139 @@ +"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, + }; +} + +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], + ); + + if (!summary) return null; + + return ( +
+

+ Daily means over the selected years +

+
+ + + + +
+
+ ); +} diff --git a/src/components/map/ReliefMap.tsx b/src/components/map/ReliefMap.tsx index 783968a..4a3c648 100644 --- a/src/components/map/ReliefMap.tsx +++ b/src/components/map/ReliefMap.tsx @@ -368,7 +368,7 @@ export function ReliefMap() { ? { label: "Map", Icon: ArrowLeft, onClick: closeExtended } : openPanel === "chart" && series.selection ? { - label: "Extended view", + label: "Data", Icon: Maximize2, onClick: openExtended, } @@ -383,7 +383,7 @@ export function ReliefMap() { onClose={() => setOpenPanel(null)} expand={tourExpandsPanel} openDetails={tourOpensDetails} - onOpenExtended={openExtended} + onTimeBasisChange={settings.setTimeBasis} /> ) : null} {openPanel === "settings" ? ( diff --git a/src/components/panels/CellPanel.tsx b/src/components/panels/CellPanel.tsx index bf30ca4..b05cf18 100644 --- a/src/components/panels/CellPanel.tsx +++ b/src/components/panels/CellPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { Maximize2, MousePointerClick } from "lucide-react"; +import { MousePointerClick } from "lucide-react"; import { cn } from "cn"; import type { CellSeries } from "@/hooks/useCellSeries"; import { useSeriesDisplay } from "@/hooks/useSeriesDisplay"; @@ -8,7 +8,6 @@ import type { TimeBasis } from "@/lib/zarr/localTime"; import { CellView } from "@/components/panels/CellView"; import { CellExport } from "@/components/panels/CellExport"; import { FloatingPanel } from "@/components/panels/FloatingPanel"; -import { Button } from "@/components/ui/button"; import { Empty, EmptyDescription, @@ -28,7 +27,7 @@ type CellPanelProps = { onClose: () => void; expand?: boolean; openDetails?: boolean; - onOpenExtended: () => void; + onTimeBasisChange: (basis: TimeBasis) => void; }; export function CellPanel({ @@ -37,7 +36,7 @@ export function CellPanel({ onClose, expand, openDetails, - onOpenExtended, + onTimeBasisChange, }: CellPanelProps) { const display = useSeriesDisplay(series, timeBasis); @@ -58,38 +57,29 @@ export function CellPanel({ } return ( -
- - - - - - View - - - Export - - - - - - - - - - -
+ + + + + View + + + Export + + + + + + + + + + ); } diff --git a/src/components/panels/CellView.tsx b/src/components/panels/CellView.tsx index d6b5b0b..b2e7038 100644 --- a/src/components/panels/CellView.tsx +++ b/src/components/panels/CellView.tsx @@ -6,12 +6,15 @@ import { ArrowLeftRight, ChevronDown } from "lucide-react"; import { formatLatitude, formatLongitude } from "@/lib/map/geogrid"; import { formatSelectedYearsLabel, ZARR_TIME } from "@/lib/zarr/timeRange"; import { TIME_SERIES_PLOT_HEIGHT } from "@/lib/plots/chartTheme"; +import type { TimeBasis } from "@/lib/zarr/localTime"; 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 { TimeBasisField } from "@/components/panels/TimeBasisField"; import { YearPicker } from "@/components/panels/YearPicker"; +import { SeriesSummary } from "@/components/data/SeriesSummary"; import { ColormapSelect } from "@/components/panels/ColormapSelect"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { CardDescription, CardTitle } from "@/components/ui/card"; @@ -27,12 +30,16 @@ import { Toggle } from "@/components/ui/toggle"; type CellViewProps = { series: CellSeries; display: SeriesDisplay; + timeBasis: TimeBasis; + onTimeBasisChange: (basis: TimeBasis) => void; openDetails?: boolean; }; export function CellView({ series, display, + timeBasis, + onTimeBasisChange, openDetails = false, }: CellViewProps) { const desktop = useDesktop(); @@ -83,6 +90,9 @@ export function CellView({ loading={series.loading} onSelectYears={series.selectYears} /> + {desktop ? null : ( + + )}
@@ -92,6 +102,8 @@ export function CellView({ + + {desktop ? null : } ); } diff --git a/src/components/panels/TimeBasisField.tsx b/src/components/panels/TimeBasisField.tsx new file mode 100644 index 0000000..4ed083f --- /dev/null +++ b/src/components/panels/TimeBasisField.tsx @@ -0,0 +1,29 @@ +"use client"; + +import type { TimeBasis } from "@/lib/zarr/localTime"; +import { FieldLegend, FieldSet } from "@/components/ui/field"; +import { SegmentedTabs } from "@/components/ui/segmented-tabs"; + +const TIME_BASES = [ + { value: "local", label: "Local" }, + { value: "utc", label: "UTC" }, +] as const; + +type TimeBasisFieldProps = { + value: TimeBasis; + onChange: (basis: TimeBasis) => void; +}; + +export function TimeBasisField({ value, onChange }: TimeBasisFieldProps) { + return ( +
+ Plot hours in + + value={value} + onChange={onChange} + options={TIME_BASES} + aria-label="Plot hours in" + /> +
+ ); +} diff --git a/src/lib/constants/tour.ts b/src/lib/constants/tour.ts index ad7d31c..174c587 100644 --- a/src/lib/constants/tour.ts +++ b/src/lib/constants/tour.ts @@ -92,7 +92,7 @@ export const TOUR_STEPS: TourStepSpec[] = [ target: '[data-tour="plot"]', title: "Two views of the same numbers", body: [ - "Line is the daily mean, one point per day. Fingerprint keeps all 24 hours: columns are days, rows are hours in local time (or UTC, switched in the extended view), blue for uptake and red for release.", + "Line is the daily mean, one point per day. Fingerprint keeps all 24 hours: columns are days, rows are hours in local time (or UTC), blue for uptake and red for release.", "The Export tab downloads a zip: a PDF report, the hourly numbers as XLSX and CSV, and both plots as images.", ], gate: "series", From bb29bd95747133011774164014fb54e0902c9da1 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Sat, 17 Oct 2026 14:20:53 +0300 Subject: [PATCH 4/4] Show the summary per year when several years are selected Adds a table under the stat cards with one row per selected year and an all-years row, using the same mean, lowest day, highest day and day count. --- src/components/data/SeriesSummary.tsx | 104 ++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/src/components/data/SeriesSummary.tsx b/src/components/data/SeriesSummary.tsx index 800d4cd..d7c9d75 100644 --- a/src/components/data/SeriesSummary.tsx +++ b/src/components/data/SeriesSummary.tsx @@ -49,6 +49,26 @@ function summarize( }; } +/** 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, @@ -104,6 +124,14 @@ export function SeriesSummary({ [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 ( @@ -134,6 +162,82 @@ export function SeriesSummary({ value={summary.days.toLocaleString()} /> + {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 +
+
+
+ ); +}