diff --git a/frontend/src/charts/BarChart.svelte b/frontend/src/charts/BarChart.svelte index aede7b6c0..ad16034c2 100644 --- a/frontend/src/charts/BarChart.svelte +++ b/frontend/src/charts/BarChart.svelte @@ -8,6 +8,7 @@ import { ctx, currentTimeFilterDateFormat, short } from "../stores/format.ts"; import Axis from "./Axis.svelte"; import type { BarChart } from "./bar.ts"; + import { dateRangeBrush } from "./brush.ts"; import { currenciesScale, filterTicks, @@ -84,10 +85,29 @@ let yAxis = $derived( axisLeft(y).tickPadding(6).tickSize(-innerWidth).tickFormat($short), ); + + /** Invert a pixel x position to the date of the nearest bar group. */ + function invertX(px: number): Date { + const half = x0.bandwidth() / 2; + let bestDate = new Date(); + let bestDist = Infinity; + for (const { label, date } of bar_groups) { + let position = x0(label) ?? 0; + const dist = Math.abs(px - position - half); + if (dist < bestDist) { + bestDist = dist; + bestDate = date; + } + } + return bestDate; + } - + {#each bar_groups as group (group.date)} diff --git a/frontend/src/charts/LineChart.svelte b/frontend/src/charts/LineChart.svelte index 56063d6a6..10978e2ce 100644 --- a/frontend/src/charts/LineChart.svelte +++ b/frontend/src/charts/LineChart.svelte @@ -8,6 +8,7 @@ import { chartToggledCurrencies, lineChartMode } from "../stores/chart.ts"; import { ctx, short } from "../stores/format.ts"; import Axis from "./Axis.svelte"; + import { dateRangeBrush } from "./brush.ts"; import { currenciesScale, includeZero, padExtent } from "./helpers.ts"; import type { LineChart, LineChartDatum } from "./line.ts"; import type { TooltipFindNode } from "./tooltip.ts"; @@ -91,6 +92,7 @@ x.invert(px), innerWidth, innerHeight)} transform={`translate(${margin.left.toString()},${margin.top.toString()})`} > diff --git a/frontend/src/charts/ScatterPlot.svelte b/frontend/src/charts/ScatterPlot.svelte index 8520d1105..002c4f3b8 100644 --- a/frontend/src/charts/ScatterPlot.svelte +++ b/frontend/src/charts/ScatterPlot.svelte @@ -6,6 +6,7 @@ import { day } from "../format.ts"; import Axis from "./Axis.svelte"; + import { dateRangeBrush } from "./brush.ts"; import { scatterplotScale } from "./helpers.ts"; import type { ScatterPlot, ScatterPlotDatum } from "./scatterplot.ts"; import type { TooltipFindNode } from "./tooltip.ts"; @@ -66,6 +67,7 @@ x.invert(px), innerWidth, innerHeight)} transform={`translate(${margin.left.toString()},${margin.top.toString()})`} > diff --git a/frontend/src/charts/brush.ts b/frontend/src/charts/brush.ts new file mode 100644 index 000000000..b33ca341d --- /dev/null +++ b/frontend/src/charts/brush.ts @@ -0,0 +1,108 @@ +import type { Attachment } from "svelte/attachments"; +import { get as store_get } from "svelte/store"; + +import { router } from "../router.ts"; +import { currentTimeFilterDateFormat } from "../stores/format.ts"; + +const DRAG_THRESHOLD = 2; + +/** + * Create a Svelte attachment that adds horizontal brush selection to a chart. + * + * @param invert - Function to convert a pixel x position to a Date. + * @param innerWidth - The width of the chart area. + * @param innerHeight - The height of the chart area. + */ +export function dateRangeBrush( + invert: (px: number) => Date, + innerWidth: number, + innerHeight: number, +): Attachment { + return (node) => { + let startX = 0; + let activePointerId: number | null = null; + let rect: SVGRectElement | null = null; + + // Prevent touch scrolling so the brush works on touch devices. + node.style.touchAction = "none"; + + function localX(event: PointerEvent): number { + const ctm = node.getScreenCTM(); + if (!ctm) { + return 0; + } + return Math.max(0, Math.min((event.clientX - ctm.e) / ctm.a, innerWidth)); + } + + function onPointerDown(event: PointerEvent) { + if ( + activePointerId != null || + event.button !== 0 || + event.ctrlKey || + event.metaKey || + event.shiftKey + ) { + return; + } + startX = localX(event); + activePointerId = event.pointerId; + event.preventDefault(); + } + + function onPointerMove(event: PointerEvent) { + if (event.pointerId !== activePointerId) { + return; + } + const currentX = localX(event); + if (!rect) { + if (Math.abs(currentX - startX) <= DRAG_THRESHOLD) { + return; + } + node.setPointerCapture(event.pointerId); + rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + rect.setAttribute("y", "0"); + rect.setAttribute("height", innerHeight.toString()); + rect.style.fill = "var(--text-color)"; + rect.style.opacity = "0.1"; + node.appendChild(rect); + document.body.style.cursor = "ew-resize"; + } + rect.setAttribute("x", Math.min(startX, currentX).toString()); + rect.setAttribute("width", Math.abs(currentX - startX).toString()); + } + + function onPointerUp(event: PointerEvent) { + if (event.pointerId !== activePointerId) { + return; + } + if (rect) { + const endX = localX(event); + const date1 = invert(Math.min(startX, endX)); + const date2 = invert(Math.max(startX, endX)); + const fmt = store_get(currentTimeFilterDateFormat); + const start = fmt(date1); + const end = fmt(date2); + router.set_search_param( + "time", + start === end ? start : `${start} - ${end}`, + ); + document.body.style.cursor = ""; + rect.remove(); + rect = null; + } + activePointerId = null; + } + + node.addEventListener("pointerdown", onPointerDown); + node.addEventListener("pointermove", onPointerMove); + node.addEventListener("pointerup", onPointerUp); + + return () => { + node.removeEventListener("pointerdown", onPointerDown); + node.removeEventListener("pointermove", onPointerMove); + node.removeEventListener("pointerup", onPointerUp); + document.body.style.cursor = ""; + rect?.remove(); + }; + }; +}