Skip to content
Open
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
22 changes: 21 additions & 1 deletion frontend/src/charts/BarChart.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
</script>

<svg viewBox={`0 0 ${width.toString()} ${height.toString()}`}>
<g transform={`translate(${offset.toString()},${margin.top.toString()})`}>
<g
{@attach dateRangeBrush(invertX, innerWidth, innerHeight)}
transform={`translate(${offset.toString()},${margin.top.toString()})`}
>
<Axis x axis={xAxis} {innerHeight} />
<Axis y axis={yAxis} lineAtZero={y(0)} />
{#each bar_groups as group (group.date)}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/charts/LineChart.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -91,6 +92,7 @@
</filter>
<g
{@attach positionedTooltip(tooltipFindNode)}
{@attach dateRangeBrush((px) => x.invert(px), innerWidth, innerHeight)}
transform={`translate(${margin.left.toString()},${margin.top.toString()})`}
>
<Axis x axis={xAxis} {innerHeight} />
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/charts/ScatterPlot.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -66,6 +67,7 @@
<svg viewBox={`0 0 ${width.toString()} ${height.toString()}`}>
<g
{@attach positionedTooltip(tooltipFindNode)}
{@attach dateRangeBrush((px) => x.invert(px), innerWidth, innerHeight)}
transform={`translate(${margin.left.toString()},${margin.top.toString()})`}
>
<Axis x axis={xAxis} {innerHeight} />
Expand Down
108 changes: 108 additions & 0 deletions frontend/src/charts/brush.ts
Original file line number Diff line number Diff line change
@@ -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<SVGGElement> {
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();
};
};
}
Loading