From f19f9fc4bee532064b94a61b9959ee11516ef8fc Mon Sep 17 00:00:00 2001 From: eoinohal Date: Wed, 13 May 2026 16:49:02 +0100 Subject: [PATCH 1/4] compression rebound speed histogram Made lineHistogram base component. Made velocityHistogram (using speed util and lineHistogram component) Cleaned up chart sections for consistency (Language: Displacement->travel, Fork/Shock->Suspension) --- .../app/components/graphs/base/Histogram.tsx | 14 +- .../components/graphs/base/LineHistogram.tsx | 229 ++++++++++++++++++ .../graphs/domain/VelocityHistogram.tsx | 73 ++++++ .../app/components/runs/chart-sections.tsx | 68 ++++-- 4 files changed, 355 insertions(+), 29 deletions(-) create mode 100644 apps/frontend/app/components/graphs/base/LineHistogram.tsx create mode 100644 apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx diff --git a/apps/frontend/app/components/graphs/base/Histogram.tsx b/apps/frontend/app/components/graphs/base/Histogram.tsx index 6589859..3fe235c 100644 --- a/apps/frontend/app/components/graphs/base/Histogram.tsx +++ b/apps/frontend/app/components/graphs/base/Histogram.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import * as d3 from 'd3'; -import { html } from 'd3'; import { getSeriesColor } from '../../../lib/graphColors'; export interface HistogramBin { @@ -53,8 +52,8 @@ export const Histogram: React.FC = ({ }, []); // Main D3 rendering - const finalSeries: HistogramSeries[] = useMemo( - () => + const finalSeries: HistogramSeries[] = useMemo(() => + // If series prop provided map each series item series && series.length > 0 ? series .filter((s) => Array.isArray(s.data)) @@ -68,14 +67,17 @@ export const Histogram: React.FC = ({ ); useEffect(() => { + // Missing container guard if (!containerRef.current || width === 0) return; + // Mising data guard const hasData = finalSeries.some((s) => s.data.length > 0); if (!hasData) { d3.select(containerRef.current).selectAll("svg").remove(); return; } + // Dimensions const margin = { top: 20, right: 20, bottom: 40, left: 50 }; const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom; @@ -89,15 +91,13 @@ export const Histogram: React.FC = ({ .thresholds(binCount); const binsBySeries = finalSeries.map((s) => binGenerator(s.data)); - if (binsBySeries.length === 0 || binsBySeries[0]?.length === 0) return; - const firstSeriesBins = binsBySeries[0]; if (!firstSeriesBins) return; const firstBin = firstSeriesBins[0]; const lastBin = firstSeriesBins[firstSeriesBins.length - 1]; if (!firstBin || !lastBin || firstBin.x0 == null || lastBin.x1 == null) return; - // Build scales for bars and axes + // Scales const x = d3 .scaleLinear() .domain([firstBin.x0, lastBin.x1]) @@ -116,6 +116,7 @@ export const Histogram: React.FC = ({ .domain([0, yMax]) .range([innerHeight, 0]); + // SVG setup const svg = d3 .select(containerRef.current) .selectAll("svg") @@ -147,6 +148,7 @@ export const Histogram: React.FC = ({ label: string; }; + // Prepare data for rendering const seriesCount = Math.max(1, finalSeries.length); const bars: RenderBar[] = binsBySeries.flatMap((seriesBins, seriesIndex) => { const seriesConfig = finalSeries[seriesIndex]; diff --git a/apps/frontend/app/components/graphs/base/LineHistogram.tsx b/apps/frontend/app/components/graphs/base/LineHistogram.tsx new file mode 100644 index 0000000..0d47478 --- /dev/null +++ b/apps/frontend/app/components/graphs/base/LineHistogram.tsx @@ -0,0 +1,229 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import * as d3 from 'd3'; +import { getSeriesColor } from '../../../lib/graphColors'; + +export interface LineHistogramBin { + x0: number; + x1: number; + percent: number; +} + +export interface LineHistogramSeries { + label: string; + data: number[]; + color?: string; +} + +export interface LineHistogramProps { + data?: number[]; + series?: LineHistogramSeries[]; + height?: number; + xDomain?: [number, number]; + className?: string; + fillColor?: string; + title?: string; + binCount?: number; +} + +export const LineHistogram: React.FC = ({ + data = [], + series, + height = 500, + xDomain, + className = "", + fillColor = "hsl(var(--chart-1))", + title, + binCount = 20 +}) => { + const containerRef = useRef(null); + const tooltipRef = useRef(null); + const [width, setWidth] = useState(0); + const [hoveredSeriesIndex, setHoveredSeriesIndex] = useState(null); + + // Responsive resize observer + useEffect(() => { + if (!containerRef.current) return; + const resizeObserver = new ResizeObserver(entries => { + const entry = entries[0]; + if (entry) setWidth(entry.contentRect.width); + }); + resizeObserver.observe(containerRef.current); + return () => resizeObserver.disconnect(); + }, []); + + // Main D3 rendering + const finalSeries: LineHistogramSeries[] = useMemo(() => + // If series prop provided map each series item + series && series.length > 0 + ? series + .filter((s) => Array.isArray(s.data)) + .map((s, index) => ({ + label: s.label || `Series ${index + 1}`, + data: s.data, + color: getSeriesColor(index, s.color, fillColor), + })) + : [{ label: title || 'Distribution', data, color: fillColor }], + [series, title, data, fillColor], + ); + + useEffect(() => { + // Missing container guard + if (!containerRef.current || width === 0) return; + + // Mising data guard + const hasData = finalSeries.some((s) => s.data.length > 0); + if (!hasData) { + d3.select(containerRef.current).selectAll("svg").remove(); + return; + } + + // Dimensions + const margin = { top: 20, right: 20, bottom: 40, left: 50 }; + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + if (innerWidth <= 0 || innerHeight <= 0) return; + + // Bin each series + const finalDomain: [number, number] = xDomain ?? [0, 100]; // our x domain is -4000 to 4000 + const binGenerator = d3 + .bin() + .domain(finalDomain) + .thresholds(binCount); + const binsBySeries = finalSeries.map((s) => binGenerator(s.data)); + + // Build line points for each series + const linePointsBySeries = binsBySeries.map((seriesBins, seriesIndex) => { + const totalPoints = finalSeries[seriesIndex]?.data.length ?? 0; + return seriesBins.map((bin) => ({ + x: ((bin.x0 ?? 0) + (bin.x1 ?? 0)) / 2, + y: totalPoints > 0 ? (bin.length / totalPoints) * 100 : 0, + })); + } + ); + + // Scales + const x = d3 + .scaleLinear() + .domain(finalDomain) + .range([0, innerWidth]); + + const maxPercent = + d3.max(binsBySeries, (seriesBins, seriesIndex) => { + const totalPoints = finalSeries[seriesIndex]?.data.length ?? 0; + if (totalPoints === 0) return 0; + return d3.max(seriesBins, (bin) => (bin.length / totalPoints) * 100) ?? 0; + }) ?? 0; + + const yMax = Math.max(1, maxPercent * 1.05); + const y = d3 + .scaleLinear() + .domain([0, yMax]) + .range([innerHeight, 0]); + + // SVG setup + const svg = d3 + .select(containerRef.current) + .selectAll("svg") + .data([null]) + .join("svg") + .attr("width", width) + .attr("height", height) + .attr("class", "overflow-hidden"); + + const g = svg + .selectAll("g.plot") + .data([null]) + .join("g") + .attr("class", "plot") + .attr("transform", `translate(${margin.left},${margin.top})`); + + const tooltip = d3.select(tooltipRef.current); + tooltip.style("opacity", 0); + + type RenderSeries = { + seriesIndex: number; + color: string; + label: string; + points: { x: number; y: number }[]; + }; + + const renderSeries: RenderSeries[] = linePointsBySeries.map((points, seriesIndex) => ({ + seriesIndex, + color: finalSeries[seriesIndex]?.color ?? fillColor, + label: finalSeries[seriesIndex]?.label ?? `Series ${seriesIndex + 1}`, + points, + })); + + // Draw axis + g.selectAll("g.x-axis") + .data([null]) + .join("g") + .attr("class", "x-axis") + .attr("transform", `translate(0,${innerHeight})`) + .call(d3.axisBottom(x).ticks(Math.max(2, Math.floor(innerWidth / 80))).tickSizeOuter(0)) + .call((axisGroup) => axisGroup.selectAll(".domain, .tick line").attr("class", "stroke-border")) + .call((axisGroup) => axisGroup.selectAll(".tick text").attr("class", "text-muted-foreground text-xs")); + + const yTickFormat = d3.format(".0f"); + g.selectAll("g.y-axis") + .data([null]) + .join("g") + .attr("class", "y-axis") + .call(d3.axisLeft(y).ticks(5).tickFormat((value) => `${yTickFormat(Number(value))}%`)) + .call((axisGroup) => axisGroup.selectAll(".domain, .tick line").attr("class", "stroke-border")) + .call((axisGroup) => axisGroup.selectAll(".tick text").attr("class", "text-muted-foreground text-xs")); + + g.selectAll("line.zero-line") + .data([null]) + .join("line") + .attr("class", "zero-line") + .attr("x1", x(0)).attr("x2", x(0)) + .attr("y1", 0).attr("y2", innerHeight) + .attr("stroke", "hsl(var(--border))") + .attr("stroke-dasharray", "4,4"); + + // Draw lines + const lineGenerator = d3.line<{ x: number; y: number }>() + .x((point) => x(point.x)) + .y((point) => y(point.y)) + .curve(d3.curveMonotoneX); + + g.selectAll("path.series-line") + .data(renderSeries, (s) => s.seriesIndex) + .join("path") + .attr("d", (s) => lineGenerator(s.points)) + .attr("fill", "none") + .attr("stroke", (s) => s.color) + .attr("stroke-width", 2) + .attr("opacity", (d) => (hoveredSeriesIndex === null || hoveredSeriesIndex === d.seriesIndex ? 1 : 0.3)); + + // Tooltip setup + // (no tooltip as of yet) + + } , [finalSeries, width, height, xDomain, binCount, hoveredSeriesIndex]); + + // JSX return + return ( +
+
+ {title &&

{title}

} + {finalSeries.length > 1 && ( +
+ {finalSeries.map((s, index) => ( +
setHoveredSeriesIndex(index)} + onMouseLeave={() => setHoveredSeriesIndex(null)} + > +
+ {s.label} +
+ ))} +
+ )} +
+
+
+ ); +}; diff --git a/apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx b/apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx new file mode 100644 index 0000000..60af2d7 --- /dev/null +++ b/apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx @@ -0,0 +1,73 @@ +import React, { useMemo } from "react"; +import { SeriesConfig } from "./DisplacementPlot"; +import { + LineHistogram, + LineHistogramSeries } from "../base/LineHistogram"; +import { + buildVelocitySamples, + RawSuspensionData} from '../../../lib/telemetryUtils'; +import { getSeriesColor } from "app/lib/graphColors"; + +interface VelocityHistogramProps { + rawData?: RawSuspensionData[]; + series?: { + label: string; + rawData: RawSuspensionData[]; + freq: number; + fillColor?: string; + min?: number; + max?: number; + }[]; + title?: string; + fillColor?: string; + height?: number; + min?: number; + max?: number; +} + +// Renders a histogram of velocity values +export const VelocityHistogram: React.FC = ({ + rawData = [], + series, + title = "Suspension Velocity", + fillColor = "hsl(var(--chart-1))", + height = 200, + min, + max, +}) => { + // buildVelocitySamples on rawData to get velocity samples + const histogramSeries = useMemo(() => { + if (series && series.length > 0) { + return series.map((seriesItem, index) => ({ + label: seriesItem.label, + color: getSeriesColor(index, seriesItem.fillColor, fillColor), + data: buildVelocitySamples(seriesItem.rawData, seriesItem.freq, seriesItem.min, seriesItem.max).map(s => s.velocity), + })); + } + + return [ + { + label: title, + color: fillColor, + data: buildVelocitySamples(rawData, 100, min, max).map(s => s.velocity), + }, + ]; + }, [series, rawData, min, max, fillColor, title]); + + // Keep layout stable + if (!histogramSeries.some((seriesItem) => seriesItem.data.length > 0)) { + return
No data
; + } + + return ( +
+ +
+ ); +}; diff --git a/apps/frontend/app/components/runs/chart-sections.tsx b/apps/frontend/app/components/runs/chart-sections.tsx index d17fbb8..6985b3e 100644 --- a/apps/frontend/app/components/runs/chart-sections.tsx +++ b/apps/frontend/app/components/runs/chart-sections.tsx @@ -5,6 +5,7 @@ import { LineHighlight, } from "app/components/graphs/domain/DisplacementPlot"; import { TravelHistogram } from "app/components/graphs/domain/TravelHistogram"; +import { VelocityHistogram } from "app/components/graphs/domain/VelocityHistogram"; import { ReboundCompressionPlot } from "app/components/graphs/domain/ReboundCompressionPlot"; import { SectionHeader } from "app/components/ui/run-elements"; import { Run } from "@repo/database"; @@ -90,7 +91,7 @@ function getSeriesConfig( }; } -// ---------------------- Displacement Plot ---------------------- +// ---------------------- Line Plots ---------------------- export function DisplacementSection({ selected, jsonData, @@ -107,19 +108,19 @@ export function DisplacementSection({ return (
- Displacement & Velocity + Line Plots {isCompareMode ? (
getSeriesConfig(run, i, jsonData, "front", undefined, true), )} highlight={highlight} /> getSeriesConfig(run, i, jsonData, "rear", undefined, true), )} @@ -127,7 +128,7 @@ export function DisplacementSection({ /> getSeriesConfig(run, i, jsonData, "front", undefined, true), )} @@ -138,7 +139,7 @@ export function DisplacementSection({ /> getSeriesConfig(run, i, jsonData, "rear", undefined, true), )} @@ -153,30 +154,30 @@ export function DisplacementSection({ !firstData.error && (
- Travel Histogram + Histogram Plots
No histogram data available
@@ -269,20 +270,20 @@ export function HistogramSection({ return (
- Travel Histogram -
+ Histogram Plots +
+
); @@ -297,16 +305,30 @@ export function HistogramSection({ return (
- Travel Histogram -
+ Histogram Plots +
+ + + getSeriesConfig(run, i, jsonData, "front"), + )} + /> + + getSeriesConfig(run, i, jsonData, "rear"), + )} + /> +
); From 8b3d670551ea327fbb69d1ed20bd4abea1a9cf1a Mon Sep 17 00:00:00 2001 From: eoinohal Date: Wed, 13 May 2026 18:22:32 +0100 Subject: [PATCH 2/4] fix forgot to switch back to LineHistogram (testing using histogram as base) --- .../frontend/app/components/graphs/domain/VelocityHistogram.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx b/apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx index 60af2d7..a4c9047 100644 --- a/apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx +++ b/apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx @@ -36,7 +36,7 @@ export const VelocityHistogram: React.FC = ({ max, }) => { // buildVelocitySamples on rawData to get velocity samples - const histogramSeries = useMemo(() => { + const histogramSeries = useMemo(() => { if (series && series.length > 0) { return series.map((seriesItem, index) => ({ label: seriesItem.label, From 4a95f440528d7aa26e33bf54acd78e7b90d4ce63 Mon Sep 17 00:00:00 2001 From: eoinohal Date: Wed, 13 May 2026 18:24:02 +0100 Subject: [PATCH 3/4] Update apps/frontend/app/components/graphs/base/LineHistogram.tsx Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- apps/frontend/app/components/graphs/base/LineHistogram.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/app/components/graphs/base/LineHistogram.tsx b/apps/frontend/app/components/graphs/base/LineHistogram.tsx index 0d47478..6756140 100644 --- a/apps/frontend/app/components/graphs/base/LineHistogram.tsx +++ b/apps/frontend/app/components/graphs/base/LineHistogram.tsx @@ -84,7 +84,7 @@ export const LineHistogram: React.FC = ({ if (innerWidth <= 0 || innerHeight <= 0) return; // Bin each series - const finalDomain: [number, number] = xDomain ?? [0, 100]; // our x domain is -4000 to 4000 + const finalDomain: [number, number] = xDomain ?? [0, 100]; const binGenerator = d3 .bin() .domain(finalDomain) From 55dd0c388bc42a61bd69a6791a1cfcf82e5f8fd8 Mon Sep 17 00:00:00 2001 From: eoinohal Date: Wed, 13 May 2026 18:27:46 +0100 Subject: [PATCH 4/4] review fixes Fixes from code review --- apps/frontend/app/components/graphs/base/LineHistogram.tsx | 2 +- .../app/components/graphs/domain/VelocityHistogram.tsx | 7 ++++--- apps/frontend/app/components/runs/chart-sections.tsx | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/frontend/app/components/graphs/base/LineHistogram.tsx b/apps/frontend/app/components/graphs/base/LineHistogram.tsx index 0d47478..55c3684 100644 --- a/apps/frontend/app/components/graphs/base/LineHistogram.tsx +++ b/apps/frontend/app/components/graphs/base/LineHistogram.tsx @@ -200,7 +200,7 @@ export const LineHistogram: React.FC = ({ // Tooltip setup // (no tooltip as of yet) - } , [finalSeries, width, height, xDomain, binCount, hoveredSeriesIndex]); + } , [finalSeries, width, height, xDomain, binCount]); // add hoveredSeriesIndex back for tooltip // JSX return return ( diff --git a/apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx b/apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx index a4c9047..3aac247 100644 --- a/apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx +++ b/apps/frontend/app/components/graphs/domain/VelocityHistogram.tsx @@ -1,5 +1,4 @@ import React, { useMemo } from "react"; -import { SeriesConfig } from "./DisplacementPlot"; import { LineHistogram, LineHistogramSeries } from "../base/LineHistogram"; @@ -10,6 +9,7 @@ import { getSeriesColor } from "app/lib/graphColors"; interface VelocityHistogramProps { rawData?: RawSuspensionData[]; + freq?: number; series?: { label: string; rawData: RawSuspensionData[]; @@ -28,6 +28,7 @@ interface VelocityHistogramProps { // Renders a histogram of velocity values export const VelocityHistogram: React.FC = ({ rawData = [], + freq = 100, series, title = "Suspension Velocity", fillColor = "hsl(var(--chart-1))", @@ -49,10 +50,10 @@ export const VelocityHistogram: React.FC = ({ { label: title, color: fillColor, - data: buildVelocitySamples(rawData, 100, min, max).map(s => s.velocity), + data: buildVelocitySamples(rawData, freq, min, max).map(s => s.velocity), }, ]; - }, [series, rawData, min, max, fillColor, title]); + }, [series, rawData, freq, min, max, fillColor, title]); // Keep layout stable if (!histogramSeries.some((seriesItem) => seriesItem.data.length > 0)) { diff --git a/apps/frontend/app/components/runs/chart-sections.tsx b/apps/frontend/app/components/runs/chart-sections.tsx index 6985b3e..1cfc407 100644 --- a/apps/frontend/app/components/runs/chart-sections.tsx +++ b/apps/frontend/app/components/runs/chart-sections.tsx @@ -120,7 +120,7 @@ export function DisplacementSection({ highlight={highlight} /> getSeriesConfig(run, i, jsonData, "rear", undefined, true), )}