@@ -80,47 +97,7 @@ export function CellView({
- display.setPlotView(value as PlotView)}
- >
-
-
- Line
-
-
- Fingerprint
-
-
-
-
- {display.plotView === "fingerprint" ? (
-
- ) : 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" ? (
+
+ ) : 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) => (
+
+ ))
+ : null}
{!desktop && hover ? (
) : null}
- {desktop && shown?.anchor ? (
+ {desktop &&
+ separate &&
+ hover?.anchor &&
+ !pins.some((pin) => sameCell(pin, hover)) ? (
+
+ ) : null}
+ {desktop && !separate && shown?.anchor ? (
setPinned(null) : undefined}
+ onClose={pinned ? () => setPins([]) : undefined}
/>
) : null}
diff --git a/src/components/plots/MapSideTooltip.tsx b/src/components/plots/MapSideTooltip.tsx
index 19d3553..1f7c6cc 100644
--- a/src/components/plots/MapSideTooltip.tsx
+++ b/src/components/plots/MapSideTooltip.tsx
@@ -3,11 +3,13 @@
import { useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
+import { cn } from "cn";
import { formatSeriesValue } from "@/lib/plots/chartTheme";
import { Button } from "@/components/ui/button";
const DESKTOP_QUERY = "(width >= 48rem)";
const TOOLTIP_GAP = 12;
+const TOOLTIP_ROOM = 220;
function subscribeDesktop(onChange: () => void) {
const query = matchMedia(DESKTOP_QUERY);
@@ -27,16 +29,50 @@ export type TooltipAnchor = { left: number; top: number };
export function anchorBesidePanel(
plot: Element | null,
+ clientX: number,
clientY: number,
): TooltipAnchor | null {
const panel = plot?.closest("[data-slot=card]");
if (!panel) return null;
+ const besidePanel = panel.getBoundingClientRect().right + TOOLTIP_GAP;
+ const left =
+ besidePanel + TOOLTIP_ROOM <= window.innerWidth
+ ? besidePanel
+ : Math.min(clientX + TOOLTIP_GAP, window.innerWidth - TOOLTIP_ROOM);
+ return { left, top: clientY };
+}
+
+export type PinPosition = { x: number; y: number; flip: boolean };
+
+export function pinPosition(
+ plot: Element,
+ clientX: number,
+ clientY: number,
+): PinPosition {
+ const rect = plot.getBoundingClientRect();
+ const x = clientX - rect.left;
return {
- left: panel.getBoundingClientRect().right + TOOLTIP_GAP,
- top: clientY,
+ x,
+ y: clientY - rect.top,
+ flip: x + TOOLTIP_GAP + TOOLTIP_ROOM > rect.width,
};
}
+export function togglePin(
+ pins: T[],
+ pin: T,
+ limit: number,
+ same: (a: T, b: T) => boolean,
+): T[] {
+ if (pins.some((existing) => same(existing, pin))) {
+ return pins.filter((existing) => !same(existing, pin));
+ }
+ return [...pins, pin].slice(-limit);
+}
+
+const tooltipBoxClass =
+ "z-40 grid min-w-40 -translate-y-4 gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs whitespace-nowrap shadow-xl";
+
export type TooltipPoint = { label: string; shortLabel: string; value: number };
function formatChange(point: TooltipPoint, base?: TooltipPoint) {
@@ -68,6 +104,64 @@ function PointRows({
);
}
+function TooltipHeader({
+ point,
+ onClose,
+}: {
+ point: TooltipPoint;
+ onClose?: () => void;
+}) {
+ return (
+
+ {point.label}
+ {onClose ? (
+
+ ) : null}
+
+ );
+}
+
+export function PinnedTooltip({
+ point,
+ previous,
+ position,
+ units,
+ onClose,
+}: {
+ point: TooltipPoint;
+ previous?: TooltipPoint;
+ position: PinPosition;
+ units?: string | null;
+ onClose: () => void;
+}) {
+ return (
+
+ );
+}
+
export function MapSideTooltip({
point,
previous,
@@ -85,23 +179,10 @@ export function MapSideTooltip({
}) {
return createPortal(
-
- {point.label}
- {onClose ? (
-
- ) : null}
-
+
{compared ? (
diff --git a/src/components/plots/TimeSeriesPlot.tsx b/src/components/plots/TimeSeriesPlot.tsx
index 18f73f5..d0f2923 100644
--- a/src/components/plots/TimeSeriesPlot.tsx
+++ b/src/components/plots/TimeSeriesPlot.tsx
@@ -18,7 +18,11 @@ import { dailyMeanSeries, type DailyMeanPoint } from "@/lib/zarr/series";
import {
anchorBesidePanel,
MapSideTooltip,
+ PinnedTooltip,
+ pinPosition,
+ togglePin,
useDesktop,
+ type PinPosition,
type TooltipAnchor,
type TooltipPoint,
} from "@/components/plots/MapSideTooltip";
@@ -33,7 +37,9 @@ const CHART_CONFIG = {
value: { label: "Daily mean", color: "var(--brand)" },
} satisfies ChartConfig;
-type Readout = TooltipAnchor & { index: number };
+type Readout = TooltipAnchor & PinPosition & { index: number };
+
+const sameDay = (a: Readout, b: Readout) => a.index === b.index;
function dayPoint(point: DailyMeanPoint | undefined): TooltipPoint | undefined {
if (!point) return undefined;
@@ -47,6 +53,7 @@ type TimeSeriesPlotProps = {
hoursPerDay?: number;
height?: number;
width?: number;
+ pinLimit?: number;
ref?: Ref
;
};
@@ -56,6 +63,7 @@ export function TimeSeriesPlot({
hoursPerDay = 24,
height = TIME_SERIES_PLOT_HEIGHT,
width,
+ pinLimit = 1,
ref,
}: TimeSeriesPlotProps) {
const data = useMemo(
@@ -66,27 +74,37 @@ export function TimeSeriesPlot({
const desktop = useDesktop();
const wrapperRef = useRef(null);
const [hover, setHover] = useState(null);
- const [pinned, setPinned] = useState(null);
+ const [pins, setPins] = useState([]);
const [pinnedData, setPinnedData] = useState(data);
if (pinnedData !== data) {
setPinnedData(data);
- setPinned(null);
+ setPins([]);
}
if (data.length === 0) return null;
- const readoutAt = (index: unknown, clientY: number): Readout | null => {
- const anchor = anchorBesidePanel(wrapperRef.current, clientY);
+ const readoutAt = (
+ index: unknown,
+ clientX: number,
+ clientY: number,
+ ): Readout | null => {
+ const wrapper = wrapperRef.current;
+ const anchor = anchorBesidePanel(wrapper, clientX, clientY);
const at = Number(index);
- if (index == null || !Number.isInteger(at) || !anchor) return null;
- return { ...anchor, index: at };
+ if (index == null || !Number.isInteger(at) || !anchor || !wrapper) {
+ return null;
+ }
+ return { ...anchor, ...pinPosition(wrapper, clientX, clientY), index: at };
};
+ const separate = pinLimit > 1;
+ const pinned = separate ? null : (pins[0] ?? null);
+ const lastPin = pins.at(-1);
const shown = pinned ?? hover;
const shownPoint = shown ? dayPoint(data[shown.index]) : undefined;
return (
-
+
- setHover(readoutAt(state.activeIndex, event.clientY))
+ setHover(readoutAt(state.activeIndex, event.clientX, event.clientY))
}
onMouseLeave={() => setHover(null)}
// Recharts reports no active index on click, so pin the hovered day.
onClick={() => {
- if (desktop && hover) setPinned(hover);
+ if (desktop && hover) {
+ setPins((current) =>
+ togglePin(current, hover, pinLimit, sameDay),
+ );
+ }
}}
>
@@ -126,13 +148,16 @@ export function TimeSeriesPlot({
width={48}
tickFormatter={formatSeriesValue}
/>
- {desktop && pinned ? (
-
- ) : null}
+ {desktop
+ ? pins.map((pin) => (
+
+ ))
+ : null}
- {desktop && shown && shownPoint ? (
+ {desktop && separate
+ ? pins.map((pin) => {
+ const point = dayPoint(data[pin.index]);
+ if (!point) return null;
+ return (
+
+ setPins((current) =>
+ current.filter((other) => !sameDay(other, pin)),
+ )
+ }
+ />
+ );
+ })
+ : null}
+ {desktop &&
+ separate &&
+ hover &&
+ shownPoint &&
+ !pins.some((pin) => sameDay(pin, hover)) ? (
+
+ ) : null}
+ {desktop && !separate && shown && shownPoint ? (
setPinned(null) : undefined}
+ onClose={pinned ? () => setPins([]) : undefined}
/>
) : null}