Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 74 additions & 100 deletions apps/frontend/src/components/charts/AreaChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ import {
} from "./CartesianChartFrame";
import { useChartSync } from "./ChartSyncContext";
import { useChartKeyboardNav } from "./keyboardNav";
import { formatScrubDelta, useChartScrub } from "./scrub";
import { ScrubBand, ScrubReadout } from "./ScrubOverlay";
import { scrubView, useChartScrub, type ScrubSeries } from "./scrub";
import { ChartTooltip, type ChartTooltipDatum } from "./ChartTooltip";
import { CHART_NEUTRAL, getChartColor } from "./palette";
import { ChartEntrance, ChartLayerSvg, ChartReveal } from "./chartEntrance";
import { durations } from "@/lib/motion";
import { cn } from "@/lib/utils";
import { useLanguage } from "@/stores/hydration/LanguageHydration";
import { useAppSettings } from "@/stores/hydration/AppSettingsHydration";
import { formatDateWithAppSettings } from "@/lib/dateUtils";
Expand Down Expand Up @@ -93,7 +93,7 @@ export interface AreaChartProps<Datum> {
readonly ariaLabel?: string | undefined;
/** Opt into synced crosshairs with sibling charts sharing this id (needs ChartSyncProvider). */
readonly syncId?: string | undefined;
/** Enable pointer-drag range compare (Δ + %) on the primary series. */
/** Enable range compare (Δ + % per series): drag across the chart, or click a point and move. */
readonly scrubbable?: boolean | undefined;
}

Expand Down Expand Up @@ -416,7 +416,7 @@ function AreaChartInner<Datum>({
if (datum === undefined) return;
setHoverIndex(idx);
publishHover(Number(stableXAccessor(datum)));
if (scrub.scrubbing) scrub.move(idx);
scrub.hover(idx);
},
[indexAtClientX, publishHover, stableXAccessor, data, scrub],
);
Expand All @@ -433,13 +433,14 @@ function AreaChartInner<Datum>({
if (!scrubbable) return;
const idx = indexAtClientX(event);
if (idx == null) return;
// Keep receiving moves while the button is held outside the plot.
event.currentTarget.setPointerCapture(event.pointerId);
scrub.begin(idx);
scrub.press(idx);
},
[scrubbable, indexAtClientX, scrub],
);

const handleUp = useCallback(() => scrub.end(), [scrub]);
const handleUp = useCallback(() => scrub.release(), [scrub]);

// Keyboard path (←/→, Home/End, Shift+←/→, Escape) — drives the same
// hover/scrub state as the pointer, so tooltip/crosshair render unchanged.
Expand Down Expand Up @@ -511,6 +512,27 @@ function AreaChartInner<Datum>({
: 0;
const tooltipTop = margin.top;

const titleOf = (datum: Datum) =>
tooltipTitle
? tooltipTitle(datum)
: formatHoverTitle(xAccessor(datum), appSettings.dateFormat);

const scrubSeries = useMemo<ScrubSeries<Datum>[]>(
() =>
series.map((s, i) => ({
key: s.key,
label: s.label,
color: s.color ?? getChartColor(i),
value: s.accessor,
})),
[series],
);
const scrubbed = scrubbable ? scrubView(scrub, data, scrubSeries) : null;
const formatScrubValue = (v: number, key: string) =>
tooltipValueFormat
? tooltipValueFormat(v, key)
: String(Math.round(v * 100) / 100);

// useId: deterministic per render position (stable snapshots), unique per
// chart instance — replaces the old Math.random() suffix.
const reactId = useId();
Expand Down Expand Up @@ -604,33 +626,34 @@ function AreaChartInner<Datum>({
})}

{/* Scrub range band */}
{scrub.range
? (() => {
const startDatum = data[scrub.range.startIndex];
const endDatum = data[scrub.range.endIndex];
if (
startDatum === undefined ||
endDatum === undefined
)
return null;
const xA =
xScale(stableXAccessor(startDatum));
const xB = xScale(stableXAccessor(endDatum));
return (
<rect
x={Math.min(xA, xB)}
y={0}
width={Math.abs(xB - xA)}
height={innerHeight}
fill={CHART_NEUTRAL.label}
fillOpacity={0.08}
stroke={CHART_NEUTRAL.label}
strokeOpacity={0.25}
pointerEvents="none"
/>
);
})()
: null}
{scrubbed ? (
<ScrubBand
xStart={xScale(stableXAccessor(scrubbed.startDatum))}
xEnd={xScale(stableXAccessor(scrubbed.endDatum))}
innerHeight={innerHeight}
rows={scrubbed.rows}
// Stacked bands sit on cumulative values; dots on
// the raw values would float off the marks.
startMarkers={
stacked
? undefined
: scrubbed.rows.map((r) => ({
key: r.key,
y: yScale(r.start),
color: r.color,
}))
}
endMarkers={
stacked
? undefined
: scrubbed.rows.map((r) => ({
key: r.key,
y: yScale(r.end),
color: r.color,
}))
}
/>
) : null}

{/* Crosshair */}
{hoverDatum != null ? (
Expand Down Expand Up @@ -702,82 +725,33 @@ function AreaChartInner<Datum>({
onPointerLeave={handleLeave}
onPointerDown={handleDown}
onPointerUp={handleUp}
onPointerCancel={handleUp}
onPointerCancel={handleLeave}
/>
</Group>
</CartesianChartFrame>

{scrub.range
? (() => {
const first = series[0];
const startDatum = data[scrub.range.startIndex];
const endDatum = data[scrub.range.endIndex];
if (
!first ||
startDatum === undefined ||
endDatum === undefined
)
return null;
const a = first.accessor(startDatum);
const b = first.accessor(endDatum);
if (
a == null ||
b == null ||
!Number.isFinite(a) ||
!Number.isFinite(b)
)
return null;
const fmt = (v: number) =>
tooltipValueFormat
? tooltipValueFormat(v, first.key)
: String(Math.round(v * 100) / 100);
const xA =
margin.left +
(xScale(stableXAccessor(startDatum)));
const xB =
margin.left +
(xScale(stableXAccessor(endDatum)));
const mid = (xA + xB) / 2;
const rising = b - a > 0;
return (
<div
className={cn(
"pointer-events-none absolute left-0 top-0 z-10 -translate-x-1/2 whitespace-nowrap rounded-full px-2.5 py-1 type-footnote font-medium tabular-nums",
fullEffects
? "glass-thick"
: "border border-border/60 bg-card shadow-elevation-1",
rising
? "text-gain"
: b - a < 0
? "text-loss"
: "text-foreground",
)}
// transform, not left/top: the pill follows the pointer, and
// a transform moves it without relayout per move.
style={{
transform: `translate(${mid}px, 2px)`,
}}
>
{formatScrubDelta(a, b, fmt, formatPercent)}
</div>
);
})()
: null}
{scrubbed?.isRange ? (
<ScrubReadout
xStart={
margin.left + xScale(stableXAccessor(scrubbed.startDatum))
}
xEnd={margin.left + xScale(stableXAccessor(scrubbed.endDatum))}
width={width}
top={2}
startTitle={titleOf(scrubbed.startDatum)}
endTitle={titleOf(scrubbed.endDatum)}
rows={scrubbed.rows}
formatValue={formatScrubValue}
formatPercent={formatPercent}
fullEffects={fullEffects}
/>
) : null}

<ChartTooltip
open={hoverDatum != null && !scrub.range}
left={tooltipLeft}
top={tooltipTop}
title={
hoverDatum != null && tooltipTitle
? tooltipTitle(hoverDatum)
: hoverDatum != null
? formatHoverTitle(
xAccessor(hoverDatum),
appSettings.dateFormat,
)
: undefined
}
title={hoverDatum != null ? titleOf(hoverDatum) : undefined}
items={tooltipItems}
/>
</div>
Expand Down
9 changes: 8 additions & 1 deletion apps/frontend/src/components/charts/CartesianChartFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
useRef,
} from "react";

import { cn } from "@/lib/utils";

export interface CartesianChartMargin {
readonly top: number;
readonly right: number;
Expand Down Expand Up @@ -127,7 +129,12 @@ export function CartesianChartFrame({
}: CartesianChartFrameProps) {
return (
<svg
className={className}
// A click focuses the chart (it is a tab stop); only keyboard
// focus draws the ring, not the browser's default outline.
className={cn(
"[&:focus:not(:focus-visible)]:outline-none",
className,
)}
width={width}
height={height}
role="img"
Expand Down
Loading