From 199099febeeae48a18f54f62b5ecf7adf9e64ff3 Mon Sep 17 00:00:00 2001 From: James Moyles Date: Mon, 9 Mar 2026 15:42:50 +0000 Subject: [PATCH 01/17] feat: add rebound and compression charts --- .../app/components/graphs/base/LinePlot.tsx | 113 ++++-- .../components/graphs/base/ScatterPlot.tsx | 324 ++++++++++++++++++ .../graphs/domain/DisplacementPlot.tsx | 63 +++- .../graphs/domain/ReboundCompressionPlot.tsx | 304 ++++++++++++++++ .../app/components/runs/chart-sections.tsx | 242 +++++++++++-- apps/frontend/app/lib/telemetryUtils.ts | 194 +++++++++-- 6 files changed, 1144 insertions(+), 96 deletions(-) create mode 100644 apps/frontend/app/components/graphs/base/ScatterPlot.tsx create mode 100644 apps/frontend/app/components/graphs/domain/ReboundCompressionPlot.tsx diff --git a/apps/frontend/app/components/graphs/base/LinePlot.tsx b/apps/frontend/app/components/graphs/base/LinePlot.tsx index b2f7592..839c1a1 100644 --- a/apps/frontend/app/components/graphs/base/LinePlot.tsx +++ b/apps/frontend/app/components/graphs/base/LinePlot.tsx @@ -27,7 +27,7 @@ export const LinePlot: React.FC = ({ const svgRef = useRef(null); const clipPathId = useId(); const [width, setWidth] = useState(0); - + // Persist scales for brush const scalesRef = useRef({ x: d3.scaleLinear(), @@ -69,7 +69,8 @@ export const LinePlot: React.FC = ({ } else { const allPoints = data.flat(); const xExtent = d3.extent(allPoints, (d) => d.x); - finalXDomain = xExtent[0] !== undefined ? xExtent as [number, number] : [0, 100]; + finalXDomain = + xExtent[0] !== undefined ? (xExtent as [number, number]) : [0, 100]; } // Update scales @@ -81,46 +82,58 @@ export const LinePlot: React.FC = ({ // Brushed interaction handler const brushed = (event: d3.D3BrushEvent) => { if (event.sourceEvent?.type === "zoom") return; - + const s = (event.selection as [number, number]) || x2.range(); x.domain(s.map(x2.invert, x2)); - - const lineGenerator = d3.line() + + const lineGenerator = d3 + .line() .x((d) => x(d.x)) .y((d) => y(d.y)) .curve(d3.curveMonotoneX); - - svg.select(".focus") + + svg + .select(".focus") .selectAll(".line-path") .attr("d", lineGenerator); - + svg.select(".focus .x-axis").call(d3.axisBottom(x)); }; // Clip path (scoped per component instance) - svg.selectAll("defs").data([null]).join("defs") - .selectAll("clipPath").data([null]).join("clipPath") + svg + .selectAll("defs") + .data([null]) + .join("defs") + .selectAll("clipPath") + .data([null]) + .join("clipPath") .attr("id", clipPathId) - .selectAll("rect").data([null]).join("rect") + .selectAll("rect") + .data([null]) + .join("rect") .attr("width", innerWidth) .attr("height", innerHeight); // Focus group (main chart) - const focus = svg.selectAll(".focus") + const focus = svg + .selectAll(".focus") .data([null]) .join("g") .attr("class", "focus") .attr("transform", `translate(${margin.left},${margin.top})`); // Context group (brush area) - const context = svg.selectAll(".context") + const context = svg + .selectAll(".context") .data([null]) .join("g") .attr("class", "context") .attr("transform", `translate(${margin2.left},${margin2.top})`); // Focus X Axis - focus.selectAll(".x-axis") + focus + .selectAll(".x-axis") .data([null]) .join("g") .attr("class", "x-axis axis text-muted-foreground text-xs") @@ -128,14 +141,16 @@ export const LinePlot: React.FC = ({ .call(d3.axisBottom(x) as d3.Axis); // Focus Y Axis - focus.selectAll(".y-axis") + focus + .selectAll(".y-axis") .data([null]) .join("g") .attr("class", "y-axis axis text-muted-foreground text-xs") .call(d3.axisLeft(y).ticks(5) as d3.Axis); // Context X Axis - context.selectAll(".x-axis") + context + .selectAll(".x-axis") .data([null]) .join("g") .attr("class", "x-axis axis text-muted-foreground text-xs") @@ -143,22 +158,28 @@ export const LinePlot: React.FC = ({ .call(d3.axisBottom(x2) as d3.Axis); // Line generators with curve smoothing - const lineGenerator = d3.line() + const lineGenerator = d3 + .line() .x((d) => x(d.x)) .y((d) => y(d.y)) .curve(d3.curveMonotoneX); - const lineGenerator2 = d3.line() + const lineGenerator2 = d3 + .line() .x((d) => x2(d.x)) .y((d) => y2(d.y)) .curve(d3.curveMonotoneX); // Focus lines - focus.selectAll(".line-path") + focus + .selectAll(".line-path") .data(data) .join("path") .attr("clip-path", `url(#${clipPathId})`) .attr("class", "line-path") - .style("stroke", (_, i) => styleForSeries?.(i)?.stroke?.toString() ?? null) + .style( + "stroke", + (_, i) => styleForSeries?.(i)?.stroke?.toString() ?? null, + ) .style("opacity", (_, i) => { const opacity = styleForSeries?.(i)?.opacity; return typeof opacity === "number" ? opacity : null; @@ -167,14 +188,26 @@ export const LinePlot: React.FC = ({ const strokeWidth = styleForSeries?.(i)?.strokeWidth; return strokeWidth !== undefined ? strokeWidth.toString() : null; }) + .style("stroke-dasharray", (_, i) => { + const dash = styleForSeries?.(i)?.strokeDasharray; + return dash !== undefined ? dash.toString() : null; + }) + .style("stroke-linecap", (_, i) => { + const linecap = styleForSeries?.(i)?.strokeLinecap; + return linecap !== undefined ? linecap.toString() : null; + }) .attr("d", lineGenerator); // Context lines - context.selectAll(".line-context") + context + .selectAll(".line-context") .data(data) .join("path") .attr("class", "line-context") - .style("stroke", (_, i) => styleForSeries?.(i)?.stroke?.toString() ?? null) + .style( + "stroke", + (_, i) => styleForSeries?.(i)?.stroke?.toString() ?? null, + ) .style("opacity", (_, i) => { const opacity = styleForSeries?.(i)?.opacity; return typeof opacity === "number" ? opacity : null; @@ -183,26 +216,48 @@ export const LinePlot: React.FC = ({ const strokeWidth = styleForSeries?.(i)?.strokeWidth; return strokeWidth !== undefined ? strokeWidth.toString() : null; }) + .style("stroke-dasharray", (_, i) => { + const dash = styleForSeries?.(i)?.strokeDasharray; + return dash !== undefined ? dash.toString() : null; + }) + .style("stroke-linecap", (_, i) => { + const linecap = styleForSeries?.(i)?.strokeLinecap; + return linecap !== undefined ? linecap.toString() : null; + }) .attr("d", lineGenerator2); // Brush - const brush = d3.brushX() - .extent([[0, 0], [innerWidth, innerHeight2]]) + const brush = d3 + .brushX() + .extent([ + [0, 0], + [innerWidth, innerHeight2], + ]) .on("brush end", brushed); - context.selectAll(".brush") + context + .selectAll(".brush") .data([null]) .join("g") .attr("class", "brush") .call(brush) .selectAll(".selection") .attr("class", "selection fill-muted-foreground/30 stroke-border"); - }, [data, width, height, xDomain, yDomain, styleForSeries, clipPathId]); return ( -
- {width > 0 && } +
+ {width > 0 && ( + + )}
); -}; \ No newline at end of file +}; diff --git a/apps/frontend/app/components/graphs/base/ScatterPlot.tsx b/apps/frontend/app/components/graphs/base/ScatterPlot.tsx new file mode 100644 index 0000000..fd5a10e --- /dev/null +++ b/apps/frontend/app/components/graphs/base/ScatterPlot.tsx @@ -0,0 +1,324 @@ +import React, { useEffect, useId, useMemo, useRef, useState } from "react"; +import * as d3 from "d3"; + +export interface ScatterPoint { + x: number; + y: number; + id?: string; + meta?: Record; +} + +export interface ScatterSeries { + label: string; + color?: string; + points: ScatterPoint[]; + pointRadius?: number; + opacity?: number; +} + +export interface ScatterBand { + start: number; + end: number; + color: string; + label?: string; +} + +export interface ScatterLine { + label?: string; + color?: string; + points: ScatterPoint[]; + strokeWidth?: number; + strokeDasharray?: string; + opacity?: number; +} + +interface ScatterPlotProps { + series: ScatterSeries[]; + lines?: ScatterLine[]; + bands?: ScatterBand[]; + height?: number; + xDomain?: [number, number]; + yDomain?: [number, number]; + xLabel?: string; + yLabel?: string; + onPointClick?: (point: ScatterPoint, seriesIndex: number) => void; +} + +export const ScatterPlot: React.FC = ({ + series, + lines = [], + bands = [], + height = 320, + xDomain, + yDomain, + xLabel, + yLabel, + onPointClick, +}) => { + const containerRef = useRef(null); + const svgRef = useRef(null); + const tooltipRef = useRef(null); + const [width, setWidth] = useState(0); + const clipPathId = useId(); + + const allPoints = useMemo(() => series.flatMap((s) => s.points), [series]); + const hasData = allPoints.some( + (p) => Number.isFinite(p.x) && Number.isFinite(p.y), + ); + + useEffect(() => { + if (!containerRef.current) return; + const resizeObserver = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + setWidth(entry.contentRect.width); + }); + resizeObserver.observe(containerRef.current); + return () => resizeObserver.disconnect(); + }, []); + + useEffect(() => { + if (!svgRef.current || width === 0 || !hasData) return; + + const svg = d3.select(svgRef.current); + const tooltip = d3.select(tooltipRef.current); + tooltip.style("opacity", 0); + + const margin = { top: 20, right: 30, bottom: 46, left: 56 }; + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + if (innerWidth <= 0 || innerHeight <= 0) return; + + const xExtent = d3.extent(allPoints, (d) => d.x) as [number, number]; + const yExtent = d3.extent(allPoints, (d) => d.y) as [number, number]; + + const finalXDomain: [number, number] = xDomain ?? [ + Number.isFinite(xExtent?.[0]) ? xExtent[0] : 0, + Number.isFinite(xExtent?.[1]) ? xExtent[1] : 1, + ]; + + const finalYDomain: [number, number] = yDomain ?? [ + Number.isFinite(yExtent?.[0]) ? yExtent[0] : 0, + Number.isFinite(yExtent?.[1]) ? yExtent[1] : 1, + ]; + + const x = d3.scaleLinear().domain(finalXDomain).range([0, innerWidth]); + const y = d3.scaleLinear().domain(finalYDomain).range([innerHeight, 0]); + + svg + .selectAll("defs") + .data([null]) + .join("defs") + .selectAll("clipPath") + .data([null]) + .join("clipPath") + .attr("id", clipPathId) + .selectAll("rect") + .data([null]) + .join("rect") + .attr("width", innerWidth) + .attr("height", innerHeight); + + const g = svg + .selectAll("g.plot") + .data([null]) + .join("g") + .attr("class", "plot") + .attr("transform", `translate(${margin.left},${margin.top})`); + + const bandData = bands.filter( + (b) => + Number.isFinite(b.start) && Number.isFinite(b.end) && b.end > b.start, + ); + + g.selectAll("rect.band") + .data(bandData) + .join("rect") + .attr("class", "band") + .attr("x", (d) => x(d.start)) + .attr("y", 0) + .attr("width", (d) => Math.max(0, x(d.end) - x(d.start))) + .attr("height", innerHeight) + .attr("fill", (d) => d.color) + .attr("opacity", 0.18); + + const lineGenerator = d3 + .line() + .x((d) => x(d.x)) + .y((d) => y(d.y)) + .curve(d3.curveLinear); + + g.selectAll("path.trendline") + .data(lines) + .join("path") + .attr("class", "trendline") + .attr("clip-path", `url(#${clipPathId})`) + .attr("fill", "none") + .attr("stroke", (d) => d.color ?? "#333") + .attr("stroke-width", (d) => d.strokeWidth ?? 2) + .attr("stroke-dasharray", (d) => d.strokeDasharray ?? null) + .attr("opacity", (d) => d.opacity ?? 0.9) + .attr("d", (d) => lineGenerator(d.points)); + + const seriesGroups = g + .selectAll("g.series") + .data(series) + .join("g") + .attr("class", "series") + .attr("clip-path", `url(#${clipPathId})`); + + seriesGroups + .selectAll("circle.point") + .data((d, seriesIndex) => + d.points.map((p) => ({ point: p, series: d, seriesIndex })), + ) + .join("circle") + .attr("class", "point") + .attr("cx", (d) => x(d.point.x)) + .attr("cy", (d) => y(d.point.y)) + .attr("r", (d) => d.series.pointRadius ?? 2.5) + .attr("fill", (d) => d.series.color ?? "#666") + .attr("opacity", (d) => d.series.opacity ?? 0.7) + .attr("cursor", onPointClick ? "pointer" : "default") + .on("mouseenter", function (event: MouseEvent, d) { + d3.select(this).attr("r", (d.series.pointRadius ?? 2.5) + 1); + if (tooltipRef.current) { + const [xPos, yPos] = d3.pointer(event, containerRef.current); + tooltip.style("opacity", 1); + tooltip.selectAll("*").remove(); + const speed = d.point.meta?.speed; + const displacement = d.point.meta?.displacement; + const unit = d.point.meta?.unit; + if (typeof speed === "number") { + tooltip + .append("div") + .attr("class", "text-xs text-gray-600") + .text(`Speed: ${speed.toFixed(1)} mm/s`); + } + if (typeof displacement === "number") { + tooltip + .append("div") + .attr("class", "text-xs text-gray-600") + .text( + `Disp: ${displacement.toFixed(1)}${unit === "percent" ? "%" : " mm"}`, + ); + } + tooltip.style("left", `${xPos}px`).style("top", `${yPos - 12}px`); + } + }) + .on("mousemove", (event: MouseEvent) => { + const [xPos, yPos] = d3.pointer(event, containerRef.current); + tooltip.style("left", `${xPos}px`).style("top", `${yPos - 12}px`); + }) + .on("mouseleave", function (_, d) { + d3.select(this).attr("r", d.series.pointRadius ?? 2.5); + tooltip.style("opacity", 0); + }) + .on("click", (_, d) => { + if (!onPointClick) return; + onPointClick(d.point, d.seriesIndex ?? 0); + }); + + 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))) as d3.Axis, + ) + .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("g.y-axis") + .data([null]) + .join("g") + .attr("class", "y-axis") + .call(d3.axisLeft(y).ticks(5) as d3.Axis) + .call((axisGroup) => + axisGroup + .selectAll(".domain, .tick line") + .attr("class", "stroke-border"), + ) + .call((axisGroup) => + axisGroup + .selectAll(".tick text") + .attr("class", "text-muted-foreground text-xs"), + ); + + if (xLabel) { + g.selectAll("text.x-label") + .data([null]) + .join("text") + .attr("class", "x-label text-xs text-muted-foreground") + .attr("x", innerWidth / 2) + .attr("y", innerHeight + 34) + .attr("text-anchor", "middle") + .text(xLabel); + } + + if (yLabel) { + g.selectAll("text.y-label") + .data([null]) + .join("text") + .attr("class", "y-label text-xs text-muted-foreground") + .attr("x", -innerHeight / 2) + .attr("y", -42) + .attr("transform", "rotate(-90)") + .attr("text-anchor", "middle") + .text(yLabel); + } + }, [ + series, + lines, + bands, + width, + height, + xDomain, + yDomain, + xLabel, + yLabel, + hasData, + onPointClick, + clipPathId, + allPoints, + ]); + + if (!hasData) { + return ( +
+ No data +
+ ); + } + + return ( +
+ {width > 0 && ( + + )} +
+
+ ); +}; diff --git a/apps/frontend/app/components/graphs/domain/DisplacementPlot.tsx b/apps/frontend/app/components/graphs/domain/DisplacementPlot.tsx index 75de2ac..84008ef 100644 --- a/apps/frontend/app/components/graphs/domain/DisplacementPlot.tsx +++ b/apps/frontend/app/components/graphs/domain/DisplacementPlot.tsx @@ -18,10 +18,17 @@ export interface SeriesConfig { dynamicSag?: boolean; } +export interface LineHighlight { + seriesIndex: number; + startIndex: number; + endIndex: number; +} + interface DisplacementPlotProps { title?: string; series: SeriesConfig[]; height?: number; + highlight?: LineHighlight | null; } interface LineMetadata { @@ -34,6 +41,7 @@ export const DisplacementPlot: React.FC = ({ title = "Displacement", series, height = 300, + highlight, }) => { // Build plot lines for each series and optional smoothed sag overlays. const { chartData, lineMetadata } = useMemo(() => { @@ -63,10 +71,35 @@ export const DisplacementPlot: React.FC = ({ return { chartData: lines, lineMetadata: metadata }; }, [series]); + const highlightLine = useMemo(() => { + if (!highlight) return null; + const seriesLine = chartData[highlight.seriesIndex]; + if (!seriesLine || seriesLine.length === 0) return null; + + const start = Math.max( + 0, + Math.min(seriesLine.length - 1, highlight.startIndex), + ); + const end = Math.max( + 0, + Math.min(seriesLine.length - 1, highlight.endIndex), + ); + if (start === end) return [seriesLine[start]!]; + + const sliceStart = Math.min(start, end); + const sliceEnd = Math.max(start, end) + 1; + const segment = seriesLine.slice(sliceStart, sliceEnd); + return segment.length > 0 ? segment : null; + }, [highlight, chartData]); + // No chart if every generated line is empty. const hasAnyData = chartData.some((line) => line.length > 0); if (!hasAnyData) { - return
No data available for {title}
; + return ( +
+ No data available for {title} +
+ ); } return ( @@ -76,8 +109,14 @@ export const DisplacementPlot: React.FC = ({ {/* Legend for primary series (sag overlays inherit the same color). */}
{series.map((s, index) => ( -
-
+
+
{s.label} {s.dynamicSag ? " (sag)" : ""} @@ -89,11 +128,20 @@ export const DisplacementPlot: React.FC = ({
{ const meta = lineMetadata[i]; + if (highlightLine && i === chartData.length) { + return { + stroke: "#111827", + strokeWidth: 3, + strokeDasharray: "6 4", + strokeLinecap: "round", + opacity: 0.95, + }; + } if (!meta) { return { stroke: getSeriesColor(i), @@ -101,7 +149,10 @@ export const DisplacementPlot: React.FC = ({ } return { - stroke: getSeriesColor(meta.seriesIndex, series[meta.seriesIndex]?.color), + stroke: getSeriesColor( + meta.seriesIndex, + series[meta.seriesIndex]?.color, + ), opacity: meta.isSag ? 0.35 : 1, }; }} @@ -109,4 +160,4 @@ export const DisplacementPlot: React.FC = ({
); -}; \ No newline at end of file +}; diff --git a/apps/frontend/app/components/graphs/domain/ReboundCompressionPlot.tsx b/apps/frontend/app/components/graphs/domain/ReboundCompressionPlot.tsx new file mode 100644 index 0000000..d720e80 --- /dev/null +++ b/apps/frontend/app/components/graphs/domain/ReboundCompressionPlot.tsx @@ -0,0 +1,304 @@ +import React, { useMemo, useState } from "react"; +import { + ScatterPlot, + ScatterSeries, + ScatterLine, + ScatterBand, +} from "../base/ScatterPlot"; +import { + RawSuspensionData, + buildVelocitySamples, + buildLineFromPoints, + VelocitySample, + LinePoint, +} from "app/lib/telemetryUtils"; +import { getSeriesColor } from "app/lib/graphColors"; + +export type SpeedRegion = { + low: number; + high: number; +}; + +export type UnitMode = "mm" | "percent"; + +interface ReboundCompressionPlotProps { + title: string; + series: { + label: string; + color?: string; + rawData: RawSuspensionData[]; + freq: number; + min?: number; + max?: number; + }[]; + mode: "compression" | "rebound"; + height?: number; + speedRegion: SpeedRegion; + unitMode?: UnitMode; + onUnitModeChange?: (mode: UnitMode) => void; + onPointSelect?: (selection: { seriesIndex: number; index: number }) => void; +} + +type PreparedSeries = { + label: string; + color: string; + samples: VelocitySample[]; + points: LinePoint[]; + lowPoints: LinePoint[]; + highPoints: LinePoint[]; + scatterPoints: { + x: number; + y: number; + id: string; + meta: { + index: number; + speed: number; + displacement: number; + unit: UnitMode; + }; + }[]; +}; + +const DEFAULT_UNIT: UnitMode = "percent"; + +const toggleLabels: Record = { + percent: "%", + mm: "mm", +}; + +const formatModeLabel = (mode: UnitMode) => + mode === "percent" ? "% travel" : "mm displacement"; + +export const ReboundCompressionPlot: React.FC = ({ + title, + series, + mode, + height = 320, + speedRegion, + unitMode = DEFAULT_UNIT, + onUnitModeChange, + onPointSelect, +}) => { + const [internalMode, setInternalMode] = useState(unitMode); + const activeMode = onUnitModeChange ? unitMode : internalMode; + + const prepared = useMemo(() => { + return series.map((s, index) => { + const samples = buildVelocitySamples(s.rawData, s.freq, s.min, s.max); + const filtered = samples.filter((sample) => + mode === "compression" ? sample.velocity > 0 : sample.velocity < 0, + ); + + const points = filtered.map((sample) => ({ + x: sample.speed, + y: activeMode === "percent" ? sample.normalized : sample.displacement, + })); + + const lowPoints = filtered + .filter((sample) => sample.speed <= speedRegion.low) + .map((sample) => ({ + x: sample.speed, + y: activeMode === "percent" ? sample.normalized : sample.displacement, + })); + + const highPoints = filtered + .filter((sample) => sample.speed >= speedRegion.high) + .map((sample) => ({ + x: sample.speed, + y: activeMode === "percent" ? sample.normalized : sample.displacement, + })); + + const scatterPoints = filtered.map((sample) => ({ + x: sample.speed, + y: activeMode === "percent" ? sample.normalized : sample.displacement, + id: `${index}-${sample.index}`, + meta: { + index: sample.index, + speed: sample.speed, + displacement: + activeMode === "percent" ? sample.normalized : sample.displacement, + unit: activeMode, + }, + })); + + return { + label: s.label, + color: getSeriesColor(index, s.color), + samples, + points, + lowPoints, + highPoints, + scatterPoints, + }; + }); + }, [series, mode, speedRegion.low, speedRegion.high, activeMode]); + + const maxSpeed = useMemo(() => { + let max = 0; + prepared.forEach((p) => { + p.samples.forEach((sample) => { + if (sample.speed > max) max = sample.speed; + }); + }); + return max > 0 ? max : null; + }, [prepared]); + + const scatterSeries = useMemo(() => { + return prepared.map((p) => ({ + label: p.label, + color: p.color, + points: p.scatterPoints, + pointRadius: 2.5, + opacity: 0.7, + })); + }, [prepared]); + + const trendLines = useMemo(() => { + const lines: ScatterLine[] = []; + + prepared.forEach((p) => { + const allLine = buildLineFromPoints(p.points); + if (allLine.length > 0) { + lines.push({ + label: `${p.label} overall`, + color: p.color, + points: allLine, + strokeWidth: 2.5, + opacity: 0.9, + }); + } + + const lowLine = buildLineFromPoints(p.lowPoints); + if (lowLine.length > 0) { + lines.push({ + label: `${p.label} low-speed`, + color: p.color, + points: lowLine, + strokeWidth: 2, + strokeDasharray: "4 4", + opacity: 0.9, + }); + } + + const highLine = buildLineFromPoints(p.highPoints); + if (highLine.length > 0) { + lines.push({ + label: `${p.label} high-speed`, + color: p.color, + points: highLine, + strokeWidth: 2, + strokeDasharray: "2 6", + opacity: 0.9, + }); + } + }); + + return lines; + }, [prepared]); + + const bands = useMemo(() => { + const maxBand = + maxSpeed && Number.isFinite(maxSpeed) + ? maxSpeed * 1.05 + : speedRegion.high * 1.4; + return [ + { + start: 0, + end: speedRegion.low, + color: "#a7f3d0", + label: "Low-speed", + }, + { + start: speedRegion.high, + end: Math.max(maxBand, speedRegion.high + 20), + color: "#fde68a", + label: "High-speed", + }, + ]; + }, [speedRegion, maxSpeed]); + + const handleToggle = () => { + const next = activeMode === "percent" ? "mm" : "percent"; + if (onUnitModeChange) { + onUnitModeChange(next); + } else { + setInternalMode(next); + } + }; + + const yLabel = formatModeLabel(activeMode); + const xLabel = "Speed (mm/s)"; + + return ( +
+
+
+

{title}

+

+ Low speed: 0–{speedRegion.low} mm/s · High speed: {speedRegion.high} + + mm/s +

+
+ +
+ +
+ {prepared.map((p) => ( +
+ + {p.label} +
+ ))} +
+ + Best fit +
+
+ + Low speed fit +
+
+ + High speed fit +
+
+ + { + if (!onPointSelect) return; + const index = + typeof point.meta?.index === "number" + ? (point.meta.index as number) + : 0; + onPointSelect({ seriesIndex, index }); + }} + /> +
+ ); +}; diff --git a/apps/frontend/app/components/runs/chart-sections.tsx b/apps/frontend/app/components/runs/chart-sections.tsx index 93cc4c6..2e6f87e 100644 --- a/apps/frontend/app/components/runs/chart-sections.tsx +++ b/apps/frontend/app/components/runs/chart-sections.tsx @@ -2,12 +2,15 @@ import { RunJson } from "app/types/runs"; import { DisplacementPlot, SeriesConfig, + LineHighlight, } from "app/components/graphs/domain/DisplacementPlot"; import { TravelHistogram } from "app/components/graphs/domain/TravelHistogram"; +import { ReboundCompressionPlot } from "app/components/graphs/domain/ReboundCompressionPlot"; import { SectionHeader } from "app/components/ui/run-elements"; import { Run } from "@repo/database"; import { getSeriesColor } from "app/lib/graphColors"; import { getProfileFromRun } from "app/lib/telemetryUtils"; +import { useState } from "react"; interface ChartSectionProps { selected: Run[]; @@ -68,6 +71,9 @@ export function DisplacementSection({ jsonData, isCompareMode, }: ChartSectionProps) { + const [highlight, setHighlight] = useState(null); + const [unitMode, setUnitMode] = useState<"mm" | "percent">("percent"); + if (!selected || selected.length === 0 || !selected[0]) { return No run selected; } @@ -77,33 +83,221 @@ export function DisplacementSection({ return (
- Displacement Plot + Displacement & Velocity {isCompareMode ? (
- getSeriesConfig(run, i, jsonData, "front", undefined, true) + getSeriesConfig(run, i, jsonData, "front", undefined, true), )} + highlight={highlight} /> - getSeriesConfig(run, i, jsonData, "rear", undefined, true) + getSeriesConfig(run, i, jsonData, "rear", undefined, true), )} + highlight={highlight} /> + +
+ + getSeriesConfig(run, i, jsonData, "front", undefined, true), + )} + speedRegion={{ low: 50, high: 150 }} + unitMode={unitMode} + onUnitModeChange={setUnitMode} + onPointSelect={({ seriesIndex, index }) => { + setHighlight({ + seriesIndex, + startIndex: Math.max(0, index - 2), + endIndex: index + 2, + }); + }} + /> + + getSeriesConfig(run, i, jsonData, "front", undefined, true), + )} + speedRegion={{ low: 50, high: 150 }} + unitMode={unitMode} + onUnitModeChange={setUnitMode} + onPointSelect={({ seriesIndex, index }) => { + setHighlight({ + seriesIndex, + startIndex: Math.max(0, index - 2), + endIndex: index + 2, + }); + }} + /> +
+ +
+ + getSeriesConfig(run, i, jsonData, "rear", undefined, true), + )} + speedRegion={{ low: 50, high: 150 }} + unitMode={unitMode} + onUnitModeChange={setUnitMode} + onPointSelect={({ seriesIndex, index }) => { + setHighlight({ + seriesIndex, + startIndex: Math.max(0, index - 2), + endIndex: index + 2, + }); + }} + /> + + getSeriesConfig(run, i, jsonData, "rear", undefined, true), + )} + speedRegion={{ low: 50, high: 150 }} + unitMode={unitMode} + onUnitModeChange={setUnitMode} + onPointSelect={({ seriesIndex, index }) => { + setHighlight({ + seriesIndex, + startIndex: Math.max(0, index - 2), + endIndex: index + 2, + }); + }} + /> +
) : ( firstData && !firstData.error && ( - +
+ + +
+ { + setHighlight({ + seriesIndex: 0, + startIndex: Math.max(0, index - 2), + endIndex: index + 2, + }); + }} + /> + { + setHighlight({ + seriesIndex: 0, + startIndex: Math.max(0, index - 2), + endIndex: index + 2, + }); + }} + /> +
+ +
+ { + setHighlight({ + seriesIndex: 1, + startIndex: Math.max(0, index - 2), + endIndex: index + 2, + }); + }} + /> + { + setHighlight({ + seriesIndex: 1, + startIndex: Math.max(0, index - 2), + endIndex: index + 2, + }); + }} + /> +
+
) )}
@@ -125,14 +319,8 @@ export function HistogramSection({ } const profile = getProfileFromRun(run); - const min = - type === "front" - ? profile?.front_min - : profile?.back_min; - const max = - type === "front" - ? profile?.front_max - : profile?.back_max; + const min = type === "front" ? profile?.front_min : profile?.back_min; + const max = type === "front" ? profile?.front_max : profile?.back_max; return { label: run.title ?? `Run ${run.id}`, @@ -145,7 +333,9 @@ export function HistogramSection({ max, }; }) - .filter((seriesItem): seriesItem is NonNullable => Boolean(seriesItem)); + .filter((seriesItem): seriesItem is NonNullable => + Boolean(seriesItem), + ); }; const frontSeries = buildSeries("front"); @@ -162,7 +352,9 @@ export function HistogramSection({ return (
Travel Histogram -
No histogram data available
+
+ No histogram data available +
); } @@ -199,14 +391,8 @@ export function HistogramSection({
Travel Histogram
- - + +
); diff --git a/apps/frontend/app/lib/telemetryUtils.ts b/apps/frontend/app/lib/telemetryUtils.ts index 44a28e3..b403883 100644 --- a/apps/frontend/app/lib/telemetryUtils.ts +++ b/apps/frontend/app/lib/telemetryUtils.ts @@ -3,15 +3,31 @@ import { Profile, Run } from "@repo/database"; export const MAX_TRAVEL = 4096; const WINDOWMS = 600; -export type RawSuspensionData = number | { displacement: number; timebase?: number }; +export type RawSuspensionData = + | number + | { displacement: number; timebase?: number }; export interface StandardizedPoint { time: number; - val: number; + val: number; } export interface NormalizedPoint { - x: number; - y: number; + x: number; + y: number; +} + +export interface VelocitySample { + index: number; + time: number; + displacement: number; + normalized: number; + velocity: number; + speed: number; +} + +export interface LinePoint { + x: number; + y: number; } export function getProfileFromRun(run: Run): Profile | null { @@ -23,13 +39,23 @@ export function getProfileFromRun(run: Run): Profile | null { !("front_max" in profileCandidate) || !("back_min" in profileCandidate) || !("back_max" in profileCandidate) - ) return null; + ) + return null; return profileCandidate as Profile; } -export function normalizeToPercentage(val: number, min?: number, max?: number): number { +export function normalizeToPercentage( + val: number, + min?: number, + max?: number, +): number { // If caller provides a valid min/max range, use it; otherwise fall back to 0..MAX_TRAVEL - const hasValidRange = typeof min === 'number' && typeof max === 'number' && isFinite(min) && isFinite(max) && max > min; + const hasValidRange = + typeof min === "number" && + typeof max === "number" && + isFinite(min) && + isFinite(max) && + max > min; if (!hasValidRange) { const result = (val / MAX_TRAVEL) * 100; @@ -41,14 +67,17 @@ export function normalizeToPercentage(val: number, min?: number, max?: number): return Number.isFinite(result) ? Math.min(100, Math.max(0, result)) : 0; } -export function standardizeData(dataArr: RawSuspensionData[], freq: number): StandardizedPoint[] { +export function standardizeData( + dataArr: RawSuspensionData[], + freq: number, +): StandardizedPoint[] { if (!Array.isArray(dataArr)) return []; - + return dataArr.map((p, i) => { let val = 0; - let time = i / freq; + let time = i / freq; - if (typeof p === 'number') { + if (typeof p === "number") { val = p; } else { val = Number(p.displacement ?? 0); @@ -56,47 +85,56 @@ export function standardizeData(dataArr: RawSuspensionData[], freq: number): Sta time = Number(p.timebase); } } - + return { time, val }; }); } - // DisplacementPlot - Standardizes raw suspension data and maps it into normalized time-series points. -export function processLinePlotData(dataArr: RawSuspensionData[], freq: number, min?: number, max?: number): NormalizedPoint[] { +export function processLinePlotData( + dataArr: RawSuspensionData[], + freq: number, + min?: number, + max?: number, +): NormalizedPoint[] { const cleanData = standardizeData(dataArr, freq); - return cleanData.map(point => ({ + return cleanData.map((point) => ({ x: point.time, - y: normalizeToPercentage(point.val, min, max) + y: normalizeToPercentage(point.val, min, max), })); } // TravelHistogram - Normalise displacement values for histogram distribution -export const processHistogramData = (dataArr: RawSuspensionData[], min?: number, max?: number): number[] => { +export const processHistogramData = ( + dataArr: RawSuspensionData[], + min?: number, + max?: number, +): number[] => { if (!Array.isArray(dataArr)) return []; - return dataArr.map(p => { - let val = 0; - if (typeof p === 'number') { - val = p; - } else { - val = Number(p.displacement ?? 0); - } - return normalizeToPercentage(val, min, max); - }).filter(v => !isNaN(v) && isFinite(v)); + return dataArr + .map((p) => { + let val = 0; + if (typeof p === "number") { + val = p; + } else { + val = Number(p.displacement ?? 0); + } + return normalizeToPercentage(val, min, max); + }) + .filter((v) => !isNaN(v) && isFinite(v)); }; // DynamicSagPlot - Moving average (over set time window) export function calculateMovingAverage( - data: NormalizedPoint[], - freq: number, + data: NormalizedPoint[], + freq: number, ): NormalizedPoint[] { - const windowSize = Math.max(1, Math.floor((WINDOWMS / 1000) * freq)); if (data.length < windowSize) return []; - const halfWindowX = (WINDOWMS / 1000) / 2; // centre x axis offset + const halfWindowX = WINDOWMS / 1000 / 2; // centre x axis offset const result: NormalizedPoint[] = []; // Initial sum @@ -109,7 +147,7 @@ export function calculateMovingAverage( // First centered point result.push({ x: data[windowSize - 1]!.x - halfWindowX, - y: currentSum / windowSize + y: currentSum / windowSize, }); // Sliding window @@ -122,9 +160,99 @@ export function calculateMovingAverage( result.push({ x: inPt.x - halfWindowX, - y: currentSum / windowSize + y: currentSum / windowSize, }); } return result; -} \ No newline at end of file +} + +// Velocity samples (mm/s) derived from displacement time-series. +export function buildVelocitySamples( + dataArr: RawSuspensionData[], + freq: number, + min?: number, + max?: number, +): VelocitySample[] { + const cleanData = standardizeData(dataArr, freq); + if (cleanData.length < 2) return []; + + const samples: VelocitySample[] = []; + + for (let i = 1; i < cleanData.length; i++) { + const prev = cleanData[i - 1]; + const curr = cleanData[i]; + if (!prev || !curr) continue; + + const dt = curr.time - prev.time; + if (!Number.isFinite(dt) || dt <= 0) continue; + + const displacement = curr.val; + const velocity = (curr.val - prev.val) / dt; + if (!Number.isFinite(velocity)) continue; + + const normalized = normalizeToPercentage(displacement, min, max); + + samples.push({ + index: i, + time: curr.time, + displacement, + normalized, + velocity, + speed: Math.abs(velocity), + }); + } + + return samples; +} + +export function fitLine( + points: LinePoint[], +): { slope: number; intercept: number } | null { + if (!points || points.length < 2) return null; + + let sumX = 0; + let sumY = 0; + let sumXY = 0; + let sumXX = 0; + const n = points.length; + + for (const pt of points) { + if (!Number.isFinite(pt.x) || !Number.isFinite(pt.y)) continue; + sumX += pt.x; + sumY += pt.y; + sumXY += pt.x * pt.y; + sumXX += pt.x * pt.x; + } + + const denominator = n * sumXX - sumX * sumX; + if (!Number.isFinite(denominator) || denominator === 0) return null; + + const slope = (n * sumXY - sumX * sumY) / denominator; + const intercept = (sumY - slope * sumX) / n; + if (!Number.isFinite(slope) || !Number.isFinite(intercept)) return null; + + return { slope, intercept }; +} + +export function buildLineFromPoints(points: LinePoint[]): LinePoint[] { + const fit = fitLine(points); + if (!fit) return []; + + let minX = Infinity; + let maxX = -Infinity; + + for (const pt of points) { + if (!Number.isFinite(pt.x)) continue; + if (pt.x < minX) minX = pt.x; + if (pt.x > maxX) maxX = pt.x; + } + + if (!Number.isFinite(minX) || !Number.isFinite(maxX) || minX === maxX) + return []; + + return [ + { x: minX, y: fit.slope * minX + fit.intercept }, + { x: maxX, y: fit.slope * maxX + fit.intercept }, + ]; +} From b96edd21e380d1e197c6cbebc7e79e0d5f969067 Mon Sep 17 00:00:00 2001 From: James Moyles Date: Tue, 17 Mar 2026 18:04:53 +0000 Subject: [PATCH 02/17] merge: resolve merge conflicts --- .../app/components/graphs/base/LinePlot.tsx | 45 +++---------------- 1 file changed, 5 insertions(+), 40 deletions(-) diff --git a/apps/frontend/app/components/graphs/base/LinePlot.tsx b/apps/frontend/app/components/graphs/base/LinePlot.tsx index 1f3e82c..52617f4 100644 --- a/apps/frontend/app/components/graphs/base/LinePlot.tsx +++ b/apps/frontend/app/components/graphs/base/LinePlot.tsx @@ -28,16 +28,14 @@ export const LinePlot: React.FC = ({ const svgRef = useRef(null); const clipPathId = useId(); const [width, setWidth] = useState(0); -<<<<<<< HEAD -======= const innerWidthForDownsample = Math.max(0, width); const fullDataRef = useRef([]); const rafRef = useRef(null); const latestDomainRef = useRef<[number, number] | null>(null); const selectedDomainRef = useRef<[number, number] | null>(null); - // Threshold for downsampling + // Threshold for downsampling const downsampleThreshold = Math.max(500, Math.floor(innerWidthForDownsample)); const focusData = useMemo( () => data.map((series) => lttbDownsample(series, downsampleThreshold)), @@ -47,8 +45,7 @@ export const LinePlot: React.FC = ({ () => data.map((series) => lttbDownsample(series, 500)), [data], ); - ->>>>>>> 93e7f03d0a48e78cd69ee50f7369b258447121f8 + // Persist scales for brush const scalesRef = useRef({ x: d3.scaleLinear(), @@ -124,24 +121,7 @@ export const LinePlot: React.FC = ({ // Brushed interaction handler const brushed = (event: d3.D3BrushEvent) => { if (event.sourceEvent?.type === "zoom") return; -<<<<<<< HEAD - - const s = (event.selection as [number, number]) || x2.range(); - x.domain(s.map(x2.invert, x2)); - - const lineGenerator = d3 - .line() - .x((d) => x(d.x)) - .y((d) => y(d.y)) - .curve(d3.curveMonotoneX); - - svg - .select(".focus") - .selectAll(".line-path") - .attr("d", lineGenerator); - svg.select(".focus .x-axis").call(d3.axisBottom(x)); -======= if (event.selection) { latestDomainRef.current = (event.selection as [number, number]).map(x2.invert, x2) as [number, number]; } else { @@ -169,7 +149,7 @@ export const LinePlot: React.FC = ({ svg.select(".focus .x-axis").call(d3.axisBottom(x)); }); ->>>>>>> 93e7f03d0a48e78cd69ee50f7369b258447121f8 + }; // Clip path (scoped per component instance) @@ -242,14 +222,9 @@ export const LinePlot: React.FC = ({ .curve(d3.curveMonotoneX); // Focus lines -<<<<<<< HEAD - focus - .selectAll(".line-path") - .data(data) -======= + focus.selectAll(".line-path") .data(buildVisibleData(selectedDomainRef.current)) ->>>>>>> 93e7f03d0a48e78cd69ee50f7369b258447121f8 .join("path") .attr("clip-path", `url(#${clipPathId})`) .attr("class", "line-path") @@ -276,14 +251,8 @@ export const LinePlot: React.FC = ({ .attr("d", lineGenerator); // Context lines -<<<<<<< HEAD - context - .selectAll(".line-context") - .data(data) -======= context.selectAll(".line-context") .data(contextData) ->>>>>>> 93e7f03d0a48e78cd69ee50f7369b258447121f8 .join("path") .attr("class", "line-context") .style( @@ -325,11 +294,8 @@ export const LinePlot: React.FC = ({ .call(brush) .selectAll(".selection") .attr("class", "selection fill-muted-foreground/30 stroke-border"); -<<<<<<< HEAD - }, [data, width, height, xDomain, yDomain, styleForSeries, clipPathId]); -======= - // Keep brush UI in sync + // Keep brush UI in sync context.select(".brush").call( brush.move, selectedDomainRef.current @@ -345,7 +311,6 @@ export const LinePlot: React.FC = ({ }; }, [focusData, contextData, width, height, xDomain, yDomain, styleForSeries, clipPathId, downsampleThreshold]); ->>>>>>> 93e7f03d0a48e78cd69ee50f7369b258447121f8 return (
Date: Sun, 22 Mar 2026 20:51:45 +0000 Subject: [PATCH 03/17] feat(analysis): store suspension length alongside profiles, new processCompressions function cleaner --- apps/backend/src/s3/s3.controller.ts | 14 +- apps/frontend/app/lib/run-analysis.ts | 117 ++++++++ .../drizzle/0007_illegal_killmonger.sql | 2 + .../database/drizzle/meta/0007_snapshot.json | 256 ++++++++++++++++++ packages/database/drizzle/meta/_journal.json | 7 + packages/database/src/schema.ts | 2 + 6 files changed, 392 insertions(+), 6 deletions(-) create mode 100644 apps/frontend/app/lib/run-analysis.ts create mode 100644 packages/database/drizzle/0007_illegal_killmonger.sql create mode 100644 packages/database/drizzle/meta/0007_snapshot.json diff --git a/apps/backend/src/s3/s3.controller.ts b/apps/backend/src/s3/s3.controller.ts index 0d77337..0a1d215 100644 --- a/apps/backend/src/s3/s3.controller.ts +++ b/apps/backend/src/s3/s3.controller.ts @@ -41,7 +41,7 @@ export class S3Controller { if (!path) { throw new BadRequestException('Query parameter `path` is required'); } - + try { const { stream, contentType, contentLength } = await this.s3Service.getFileStream(path); @@ -64,11 +64,11 @@ export class S3Controller { private async ensureUniqueKey(originalKey: string): Promise { let candidate = originalKey; let i = 1; - + while (true) { // Fix: Removed try/catch. If objectExists fails, it will throw automatically. const exists = await this.s3Service.objectExists(candidate); - + if (!exists) { return candidate; } @@ -119,7 +119,7 @@ private async ensureUniqueKey(originalKey: string): Promise { const frontStart = parseInt(rows[1][7], 10); return [rearStart, frontStart]; } - + private findMax(startValue, suspensionStroke, potentiometerStroke): number { if (!suspensionStroke || !Number.isFinite(suspensionStroke)) return 4095; @@ -127,7 +127,7 @@ private async ensureUniqueKey(originalKey: string): Promise { const endValue = startValue + suspensionStroke * adcPerMm; return Math.min(4095, Math.max(0, Math.round(endValue))); } - + /** * Accept multipart/form-data file upload (field `file`). * Extracts metadata from the JSON file itself and creates a DB run record. @@ -227,7 +227,9 @@ private async ensureUniqueKey(originalKey: string): Promise { back_min: rearStart, front_max: front_max, back_max: back_max, - } + front_travel: metadata?.front_stroke, + back_travel: metadata?.rear_stroke + } as const console.log('💾 Creating profile record with data:', JSON.stringify(profileData, null, 2)); const profile = await this.profilesService.create(profileData); console.log('💾 Created profile record:', profile); diff --git a/apps/frontend/app/lib/run-analysis.ts b/apps/frontend/app/lib/run-analysis.ts new file mode 100644 index 0000000..0a11576 --- /dev/null +++ b/apps/frontend/app/lib/run-analysis.ts @@ -0,0 +1,117 @@ +import { RawSuspensionData, standardizeData } from "./telemetryUtils"; + +interface RawReading { + displacement: number + time: number +} + +interface Reading { + displacement: number + time: number +} + +interface VelocityReading { + displacement: number + velocity: number + time: { + start: number + end: number + duration: number + } + travel: { + start: number + end: number + } +} + +interface SuspensionActivity extends VelocityReading { + type: "rebound" | "compression" +} + +function convertToMillis(reading: RawReading, suspensionLength: number, min: number, max: number): Reading { + + const displacementPercentage = (reading.displacement - min) / (max - min) + const displacementInMilis = suspensionLength * displacementPercentage + + return { displacement: displacementInMilis, time: reading.time } +} + +function displacementToVelocity(reading: Reading, nextReading: Reading): VelocityReading { + + const displacement = nextReading.displacement - reading.displacement + const duration = nextReading.time - reading.time + + const velocity = displacement / duration + + const time = { duration, start: reading.time, end: nextReading.time } + const travel = { start: reading.displacement, end: nextReading.displacement } + + return { velocity, displacement, time, travel } as VelocityReading +} + +export function processCompressions( + data: RawSuspensionData[], + freq: number, + length: number, + min: number, + max: number +): SuspensionActivity[] { + + const dataStandardized: RawReading[] = standardizeData(data, freq).map(point => ({ displacement: point.val, time: point.time })) + + const dataInMillis: Reading[] = dataStandardized.map((rawReading) => convertToMillis(rawReading, length, min, max)) + + const velocities = dataInMillis.slice(0, -1).map((reading, i) => displacementToVelocity(reading, dataInMillis[i + 1]!)) + + const activity: SuspensionActivity[] = [] + + let i = 0 + while (i < velocities.length) { + const current = velocities[i]! + if (current.velocity === 0) { + i++ + continue + } + + const isCompression = current.velocity > 0 + let totalDisplacement = current.displacement + let totalDuration = current.time.duration + const startTime = current.time.start + const travelStart = current.travel.start + let endTime = current.time.end + let travelEnd = current.travel.end + + let j = i + 1 + while (j < velocities.length) { + const next = velocities[j]! + if ((isCompression && next.velocity > 0) || (!isCompression && next.velocity < 0)) { + totalDisplacement += next.displacement + totalDuration += next.time.duration + endTime = next.time.end + travelEnd = next.travel.end + j++ + } else { + break + } + } + + activity.push({ + type: isCompression ? "compression" : "rebound", + displacement: totalDisplacement, + velocity: totalDisplacement / totalDuration, + time: { + start: startTime, + end: endTime, + duration: totalDuration, + }, + travel: { + start: travelStart, + end: travelEnd, + }, + }) + + i = j + } + + return activity +} diff --git a/packages/database/drizzle/0007_illegal_killmonger.sql b/packages/database/drizzle/0007_illegal_killmonger.sql new file mode 100644 index 0000000..7fd81c2 --- /dev/null +++ b/packages/database/drizzle/0007_illegal_killmonger.sql @@ -0,0 +1,2 @@ +ALTER TABLE "profiles" ADD COLUMN "front_travel" integer DEFAULT 220 NOT NULL;--> statement-breakpoint +ALTER TABLE "profiles" ADD COLUMN "back_travel" integer DEFAULT 220 NOT NULL; \ No newline at end of file diff --git a/packages/database/drizzle/meta/0007_snapshot.json b/packages/database/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..fe2886f --- /dev/null +++ b/packages/database/drizzle/meta/0007_snapshot.json @@ -0,0 +1,256 @@ +{ + "id": "831fe362-0f33-49dd-9ea8-2a29da4f10c7", + "prevId": "19e0a4b4-bbef-45cc-a41e-bfe902b455ff", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "front_min": { + "name": "front_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "front_max": { + "name": "front_max", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4096 + }, + "front_travel": { + "name": "front_travel", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 220 + }, + "back_min": { + "name": "back_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "back_max": { + "name": "back_max", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4096 + }, + "back_travel": { + "name": "back_travel", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 220 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.runs": { + "name": "runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "src_path": { + "name": "src_path", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "comments": { + "name": "comments", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "length": { + "name": "length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "location": { + "name": "location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "profile": { + "name": "profile", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "front_freq": { + "name": "front_freq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rear_freq": { + "name": "rear_freq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "runs_profile_profiles_id_fk": { + "name": "runs_profile_profiles_id_fk", + "tableFrom": "runs", + "tableTo": "profiles", + "columnsFrom": [ + "profile" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "runs_src_path_unique": { + "name": "runs_src_path_unique", + "nullsNotDistinct": false, + "columns": [ + "src_path" + ] + } + } + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json index 97add18..d83c42e 100644 --- a/packages/database/drizzle/meta/_journal.json +++ b/packages/database/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1773177482368, "tag": "0006_majestic_praxagora", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1773772326843, + "tag": "0007_illegal_killmonger", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index df4ac99..f685be6 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -26,8 +26,10 @@ export const profiles = pgTable("profiles", { name: varchar("name", { length: 255 }).notNull(), front_min: integer("front_min").notNull().default(0), front_max: integer("front_max").notNull().default(4096), + front_travel: integer("front_travel").notNull().default(220), back_min: integer("back_min").notNull().default(0), back_max: integer("back_max").notNull().default(4096), + back_travel: integer("back_travel").notNull().default(220), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); From bfb8cecef4be7bebd7efadb63826830625dfff72 Mon Sep 17 00:00:00 2001 From: eoinohal Date: Mon, 6 Apr 2026 22:32:40 +0100 Subject: [PATCH 04/17] trim runs added lower_bound_idx and upper_bound_idx (db schema migration, api updating) for trimming runs. In frontend, implemented graph-based trim editor with the displacement subgraph brush. --- apps/backend/src/runs/dto/update-run.dto.ts | 12 +- apps/backend/src/runs/runs.service.spec.ts | 53 +++- apps/backend/src/runs/runs.service.ts | 59 +++- apps/frontend/app/api/runs.ts | 10 +- .../app/components/graphs/base/LinePlot.tsx | 115 +++++++- .../graphs/domain/DisplacementPlot.tsx | 10 +- .../app/components/runs/chart-sections.tsx | 37 ++- .../app/components/runs/main-content.tsx | 41 ++- .../app/components/runs/summary-section.tsx | 12 +- .../app/components/runs/trimPopup.tsx | 255 ++++++++++++++++++ apps/frontend/app/hooks/useOptimisticRuns.ts | 3 +- apps/frontend/app/lib/telemetryUtils.ts | 50 ++++ apps/frontend/app/routes/index.tsx | 21 +- .../database/drizzle/0006_trim_bounds.sql | 12 + packages/database/drizzle/meta/_journal.json | 7 + packages/database/src/schema.ts | 2 + 16 files changed, 656 insertions(+), 43 deletions(-) create mode 100644 apps/frontend/app/components/runs/trimPopup.tsx create mode 100644 packages/database/drizzle/0006_trim_bounds.sql diff --git a/apps/backend/src/runs/dto/update-run.dto.ts b/apps/backend/src/runs/dto/update-run.dto.ts index 9a54169..f6678a5 100644 --- a/apps/backend/src/runs/dto/update-run.dto.ts +++ b/apps/backend/src/runs/dto/update-run.dto.ts @@ -1,5 +1,5 @@ // apps/backend/src/runs/dto/update-run.dto.ts -import { IsString, IsOptional, IsNumber } from 'class-validator'; +import { IsString, IsOptional, IsNumber, IsInt, Min } from 'class-validator'; export class UpdateRunDto { @IsOptional() @@ -13,4 +13,14 @@ export class UpdateRunDto { @IsOptional() @IsString() location?: string; + + @IsOptional() + @IsInt() + @Min(0) + lower_bound_idx?: number; + + @IsOptional() + @IsInt() + @Min(0) + upper_bound_idx?: number; } \ No newline at end of file diff --git a/apps/backend/src/runs/runs.service.spec.ts b/apps/backend/src/runs/runs.service.spec.ts index f6526ec..150edfc 100644 --- a/apps/backend/src/runs/runs.service.spec.ts +++ b/apps/backend/src/runs/runs.service.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, ConflictException } from '@nestjs/common'; import { RunsService } from './runs.service'; describe('RunsService', () => { @@ -84,11 +84,11 @@ describe('RunsService', () => { expect(res2).toBeNull(); }); - it('createRun throws when run with srcPath exists', async () => { + it('createRun throws ConflictException when run with srcPath exists', async () => { const data = { srcPath: 'x', length: 10 } as any; jest.spyOn(service, 'findBySrcPath').mockResolvedValue({ id: 1 } as any); - await expect(service.createRun(data)).rejects.toThrow('Run with this srcPath already exists'); + await expect(service.createRun(data)).rejects.toBeInstanceOf(ConflictException); }); it('createRun inserts and returns created row', async () => { @@ -104,6 +104,21 @@ describe('RunsService', () => { expect(res).toEqual(inserted); }); + it('createRun defaults bounds to full run range when omitted', async () => { + const data = { srcPath: 'bounds-default', length: 10 } as any; + jest.spyOn(service, 'findBySrcPath').mockResolvedValue(null); + mockDb.insert().values().returning.mockResolvedValue([{ id: 7, ...data }]); + + await service.createRun(data); + + expect(mockDb.insert().values).toHaveBeenCalledWith( + expect.objectContaining({ + lower_bound_idx: 0, + upper_bound_idx: 9, + }), + ); + }); + it('updateRun throws BadRequestException when updates empty', async () => { await expect(service.updateRun(1, {} as any)).rejects.toBeInstanceOf(BadRequestException); }); @@ -117,6 +132,38 @@ describe('RunsService', () => { expect(res).toEqual(updated); }); + it('updateRun rejects invalid trim bounds', async () => { + mockDb.query.runs.findFirst.mockResolvedValue({ + id: 1, + length: 10, + lower_bound_idx: 0, + upper_bound_idx: 9, + }); + + await expect( + service.updateRun(1, { lower_bound_idx: 8, upper_bound_idx: 4 } as any), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('updateRun allows valid trim bounds', async () => { + mockDb.query.runs.findFirst.mockResolvedValue({ + id: 1, + length: 10, + lower_bound_idx: 0, + upper_bound_idx: 9, + }); + mockDb.update().set().where().returning.mockResolvedValue([ + { id: 1, lower_bound_idx: 2, upper_bound_idx: 8 }, + ]); + + const res = await service.updateRun(1, { + lower_bound_idx: 2, + upper_bound_idx: 8, + } as any); + + expect(res).toEqual({ id: 1, lower_bound_idx: 2, upper_bound_idx: 8 }); + }); + it('updateRun returns null when no rows updated', async () => { mockDb.update().set().where().returning.mockResolvedValue([]); diff --git a/apps/backend/src/runs/runs.service.ts b/apps/backend/src/runs/runs.service.ts index 0183e81..f48ff49 100644 --- a/apps/backend/src/runs/runs.service.ts +++ b/apps/backend/src/runs/runs.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject, BadRequestException } from "@nestjs/common"; +import { Injectable, Inject, BadRequestException, ConflictException } from "@nestjs/common"; import { eq } from "drizzle-orm"; import { DATABASE_CONNECTION } from "../database/database.module"; import { runs } from "@repo/database"; @@ -39,19 +39,26 @@ export class RunsService { date?: Date; location?: string; profile?: number; + lower_bound_idx?: number; + upper_bound_idx?: number; front_freq?: number; rear_freq?: number; }) { // enforce uniqueness at service level to avoid duplicate runs const existing = await this.findBySrcPath(data.srcPath); if (existing) { - throw new Error("Run with this srcPath already exists"); + throw new ConflictException("Run with this srcPath already exists"); } + const resolvedLowerBound = data.lower_bound_idx ?? 0; + const resolvedUpperBound = data.upper_bound_idx ?? Math.max(data.length - 1, 0); + const inserted = await this.db .insert(runs) .values({ ...data, + lower_bound_idx: resolvedLowerBound, + upper_bound_idx: resolvedUpperBound, date: data.date ?? new Date(), // default to now if not provided }) .returning(); @@ -60,11 +67,57 @@ export class RunsService { } // Partial update for a run (e.g., update comments) - async updateRun(id: number, updates: Partial<{ comments: string; length: number; location: string }>) { + async updateRun( + id: number, + updates: Partial<{ + comments: string; + length: number; + location: string; + lower_bound_idx: number; + upper_bound_idx: number; + }>, + ) { if (Object.keys(updates).length === 0) { throw new BadRequestException("No updates provided for the run."); } + const hasBoundsUpdate = + updates.lower_bound_idx !== undefined || + updates.upper_bound_idx !== undefined; + + if (hasBoundsUpdate) { + const currentRun = await this.getRunById(id); + if (!currentRun) { + return null; + } + + const effectiveLength = updates.length ?? currentRun.length; + const effectiveLowerBound = + updates.lower_bound_idx ?? currentRun.lower_bound_idx ?? 0; + const effectiveUpperBound = + updates.upper_bound_idx ?? currentRun.upper_bound_idx ?? Math.max(effectiveLength - 1, 0); + + if (!Number.isInteger(effectiveLowerBound) || !Number.isInteger(effectiveUpperBound)) { + throw new BadRequestException("Trim bounds must be integers."); + } + + if (effectiveLength <= 0) { + throw new BadRequestException("Run length must be greater than zero."); + } + + if (effectiveLowerBound < 0) { + throw new BadRequestException("lower_bound_idx must be greater than or equal to zero."); + } + + if (effectiveUpperBound < effectiveLowerBound) { + throw new BadRequestException("upper_bound_idx must be greater than or equal to lower_bound_idx."); + } + + if (effectiveUpperBound >= effectiveLength) { + throw new BadRequestException("upper_bound_idx must be less than run length."); + } + } + const updated = await this.db .update(runs) .set(updates) diff --git a/apps/frontend/app/api/runs.ts b/apps/frontend/app/api/runs.ts index 3b263d3..42fc8ae 100644 --- a/apps/frontend/app/api/runs.ts +++ b/apps/frontend/app/api/runs.ts @@ -1,6 +1,14 @@ import { apiClient } from "./client"; import { Run } from "@repo/database"; +export interface RunUpdatePayload { + comments?: string; + length?: number; + location?: string; + lower_bound_idx?: number; + upper_bound_idx?: number; +} + export function getRuns() { return apiClient.get("/runs"); } @@ -9,6 +17,6 @@ export function getRunById(id: number) { return apiClient.get(`/runs/${id}`); } -export function updateRun(id: number, payload: Partial>) { +export function updateRun(id: number, payload: RunUpdatePayload) { return apiClient.patch(`/runs/${id}`, payload); } diff --git a/apps/frontend/app/components/graphs/base/LinePlot.tsx b/apps/frontend/app/components/graphs/base/LinePlot.tsx index b2f7592..0762505 100644 --- a/apps/frontend/app/components/graphs/base/LinePlot.tsx +++ b/apps/frontend/app/components/graphs/base/LinePlot.tsx @@ -13,20 +13,29 @@ interface LinePlotProps { height?: number; className?: string; styleForSeries?: (index: number) => React.CSSProperties | undefined; + brushSelection?: [number, number] | null; + onBrushSelection?: (selection: [number, number] | null) => void; } -export const LinePlot: React.FC = ({ +export const LinePlot: React.FC = React.memo(({ data, xDomain, yDomain = [0, 100], height = 400, className = "", styleForSeries, + brushSelection, + onBrushSelection, }) => { const containerRef = useRef(null); const svgRef = useRef(null); const clipPathId = useId(); const [width, setWidth] = useState(0); + const brushRef = useRef | null>(null); + const brushGroupRef = useRef | null>(null); + const onBrushSelectionRef = useRef(onBrushSelection); + const isApplyingExternalSelectionRef = useRef(false); + const latestBrushSelectionRef = useRef<[number, number] | null>(null); // Persist scales for brush const scalesRef = useRef({ @@ -35,6 +44,10 @@ export const LinePlot: React.FC = ({ x2: d3.scaleLinear(), }); + useEffect(() => { + onBrushSelectionRef.current = onBrushSelection; + }, [onBrushSelection]); + // Resize observer (under graph) useEffect(() => { if (!containerRef.current) return; @@ -47,7 +60,7 @@ export const LinePlot: React.FC = ({ return () => resizeObserver.disconnect(); }, []); - // Main D3 rendering logic + // Main D3 rendering logic (axes, paths, and brush setup) useEffect(() => { if (!svgRef.current || data.length === 0 || width === 0) return; @@ -78,25 +91,52 @@ export const LinePlot: React.FC = ({ x2.range([0, innerWidth]).domain(finalXDomain); const y2 = d3.scaleLinear().range([innerHeight2, 0]).domain(yDomain); - // Brushed interaction handler + // Brushed interaction handler keeps the focus chart in sync while dragging. const brushed = (event: d3.D3BrushEvent) => { if (event.sourceEvent?.type === "zoom") return; - - const s = (event.selection as [number, number]) || x2.range(); + + const s = event.selection as [number, number] | null; + if (!s) { + return; + } + x.domain(s.map(x2.invert, x2)); - + const lineGenerator = d3.line() .x((d) => x(d.x)) .y((d) => y(d.y)) .curve(d3.curveMonotoneX); - + svg.select(".focus") .selectAll(".line-path") .attr("d", lineGenerator); - + svg.select(".focus .x-axis").call(d3.axisBottom(x)); }; + // Emit selection to React only when brushing ends to avoid render thrash. + const brushEnded = (event: d3.D3BrushEvent) => { + if (isApplyingExternalSelectionRef.current) { + return; + } + + // Ignore programmatic brush.move calls. + if (!event.sourceEvent) { + return; + } + + const s = event.selection as [number, number] | null; + if (!s) { + latestBrushSelectionRef.current = null; + onBrushSelectionRef.current?.(null); + return; + } + + const nextSelection = s.map(x2.invert, x2) as [number, number]; + latestBrushSelectionRef.current = nextSelection; + onBrushSelectionRef.current?.(nextSelection); + }; + // Clip path (scoped per component instance) svg.selectAll("defs").data([null]).join("defs") .selectAll("clipPath").data([null]).join("clipPath") @@ -186,9 +226,10 @@ export const LinePlot: React.FC = ({ .attr("d", lineGenerator2); // Brush - const brush = d3.brushX() + const brush = d3.brushX() .extent([[0, 0], [innerWidth, innerHeight2]]) - .on("brush end", brushed); + .on("brush", brushed) + .on("end", brushEnded); context.selectAll(".brush") .data([null]) @@ -198,11 +239,63 @@ export const LinePlot: React.FC = ({ .selectAll(".selection") .attr("class", "selection fill-muted-foreground/30 stroke-border"); + brushRef.current = brush; + brushGroupRef.current = context.select(".brush"); + + if (brushSelection) { + isApplyingExternalSelectionRef.current = true; + brushGroupRef.current?.call( + brush.move, + brushSelection.map((value) => x2(value)) as [number, number], + ); + isApplyingExternalSelectionRef.current = false; + latestBrushSelectionRef.current = brushSelection; + } + }, [data, width, height, xDomain, yDomain, styleForSeries, clipPathId]); + // External brush sync (e.g., reset/save/load) without re-running full chart setup. + useEffect(() => { + if (!brushRef.current || !brushGroupRef.current || data.length === 0 || width === 0) { + return; + } + + const { x2 } = scalesRef.current; + + const hasLatest = latestBrushSelectionRef.current !== null; + const hasIncoming = brushSelection !== null && brushSelection !== undefined; + + if (!hasIncoming) { + if (!hasLatest) { + return; + } + + isApplyingExternalSelectionRef.current = true; + brushGroupRef.current.call(brushRef.current.move, null); + isApplyingExternalSelectionRef.current = false; + latestBrushSelectionRef.current = null; + return; + } + + const next = brushSelection as [number, number]; + const prev = latestBrushSelectionRef.current; + + if (prev && prev[0] === next[0] && prev[1] === next[1]) { + return; + } + + isApplyingExternalSelectionRef.current = true; + brushGroupRef.current.call( + brushRef.current.move, + next.map((value) => x2(value)) as [number, number], + ); + isApplyingExternalSelectionRef.current = false; + latestBrushSelectionRef.current = next; + }, [brushSelection, data.length, width]); + return (
{width > 0 && }
); -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/apps/frontend/app/components/graphs/domain/DisplacementPlot.tsx b/apps/frontend/app/components/graphs/domain/DisplacementPlot.tsx index 75de2ac..c4eee51 100644 --- a/apps/frontend/app/components/graphs/domain/DisplacementPlot.tsx +++ b/apps/frontend/app/components/graphs/domain/DisplacementPlot.tsx @@ -22,6 +22,8 @@ interface DisplacementPlotProps { title?: string; series: SeriesConfig[]; height?: number; + brushSelection?: [number, number] | null; + onBrushSelection?: (selection: [number, number] | null) => void; } interface LineMetadata { @@ -30,10 +32,12 @@ interface LineMetadata { } // DisplacementPlot renders suspension displacement -export const DisplacementPlot: React.FC = ({ +export const DisplacementPlot: React.FC = React.memo(({ title = "Displacement", series, height = 300, + brushSelection, + onBrushSelection, }) => { // Build plot lines for each series and optional smoothed sag overlays. const { chartData, lineMetadata } = useMemo(() => { @@ -92,6 +96,8 @@ export const DisplacementPlot: React.FC = ({ data={chartData} yDomain={[0, 100]} height={height} + brushSelection={brushSelection} + onBrushSelection={onBrushSelection} styleForSeries={(i) => { const meta = lineMetadata[i]; if (!meta) { @@ -109,4 +115,4 @@ export const DisplacementPlot: React.FC = ({
); -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/apps/frontend/app/components/runs/chart-sections.tsx b/apps/frontend/app/components/runs/chart-sections.tsx index 93cc4c6..d2d06d1 100644 --- a/apps/frontend/app/components/runs/chart-sections.tsx +++ b/apps/frontend/app/components/runs/chart-sections.tsx @@ -7,7 +7,11 @@ import { TravelHistogram } from "app/components/graphs/domain/TravelHistogram"; import { SectionHeader } from "app/components/ui/run-elements"; import { Run } from "@repo/database"; import { getSeriesColor } from "app/lib/graphColors"; -import { getProfileFromRun } from "app/lib/telemetryUtils"; +import { + getProfileFromRun, + RawSuspensionData, + trimRawDataByBounds, +} from "app/lib/telemetryUtils"; interface ChartSectionProps { selected: Run[]; @@ -15,6 +19,14 @@ interface ChartSectionProps { isCompareMode: boolean; } +function toRawSuspensionArray(value: unknown): RawSuspensionData[] { + if (!Array.isArray(value)) { + return []; + } + + return value as RawSuspensionData[]; +} + function getSeriesConfig( run: Run, index: number, @@ -28,9 +40,12 @@ function getSeriesConfig( const isError = !data || data.error; const rawData = isError ? [] - : type === "front" - ? data.data.suspension.front_sus - : data.data.suspension.rear_sus; + : toRawSuspensionArray( + type === "front" + ? data.data.suspension.front_sus + : data.data.suspension.rear_sus, + ); + const trimmedRawData = trimRawDataByBounds(run, rawData); const freq = isError ? 100 : type === "front" @@ -54,7 +69,7 @@ function getSeriesConfig( return { label: customLabel ?? run.title ?? `Run ${run.id}`, color: getSeriesColor(index), - rawData, + rawData: trimmedRawData, freq, min, max, @@ -136,10 +151,14 @@ export function HistogramSection({ return { label: run.title ?? `Run ${run.id}`, - rawData: - type === "front" - ? data.data.suspension.front_sus - : data.data.suspension.rear_sus, + rawData: trimRawDataByBounds( + run, + toRawSuspensionArray( + type === "front" + ? data.data.suspension.front_sus + : data.data.suspension.rear_sus, + ), + ), fillColor: getSeriesColor(runIndex), min, max, diff --git a/apps/frontend/app/components/runs/main-content.tsx b/apps/frontend/app/components/runs/main-content.tsx index 8886bc6..c2e7cc6 100644 --- a/apps/frontend/app/components/runs/main-content.tsx +++ b/apps/frontend/app/components/runs/main-content.tsx @@ -11,12 +11,13 @@ import { import { Run } from "@repo/database"; import { useState } from "react"; import { ProfilePopup } from "../profiles/profilePopUp"; -import { UserIcon } from "lucide-react"; +import { UserIcon, Scissors } from "lucide-react"; import { RunsMetadata } from "./runs-metadata"; import { useOptimisticRuns } from "app/hooks/useOptimisticRuns"; import { CommentsPopup } from "./commentsPopup"; import { updateRun } from "app/api/runs"; import { SummarySection } from "./summary-section"; +import { TrimPopup } from "./trimPopup"; @@ -25,6 +26,7 @@ interface MainContentProps { jsonData: Record; loadingJson: boolean; isCompareMode: boolean; + onRunUpdate?: (id: number, updates: Partial) => void; } export function MainContent({ @@ -32,6 +34,7 @@ export function MainContent({ jsonData, loadingJson, isCompareMode, + onRunUpdate, }: MainContentProps) { const { runs, handleProfileUpdate, handleRunUpdate } = useOptimisticRuns(initialSelected); @@ -40,6 +43,7 @@ export function MainContent({ // Comments popup state const [commentsOpen, setCommentsOpen] = useState(false); const [commentsRun, setCommentsRun] = useState(null); + const [trimOpen, setTrimOpen] = useState(false); // 2. NOW you can do conditional returns @@ -116,6 +120,7 @@ export function MainContent({ await updateRun(id, { comments: newComments }); // update local state on success handleRunUpdate(id, { comments: newComments }); + onRunUpdate?.(id, { comments: newComments }); } catch (e) { console.error("Failed to persist comments", e); // Optionally, add user-facing error handling here. @@ -123,16 +128,38 @@ export function MainContent({ }} /> + setTrimOpen(false)} + run={!isCompareMode ? runs[0] ?? null : null} + runJson={!isCompareMode && runs[0] ? jsonData[runs[0].id] : undefined} + onSave={async (id, payload) => { + await updateRun(id, payload); + handleRunUpdate(id, payload); + onRunUpdate?.(id, payload); + }} + /> +

{isCompareMode ? "Run Comparison" : runs[0]?.title || "Run Details"}

- +
+ + +
{/* Metadata section (extracted to RunsMetadata component) */} diff --git a/apps/frontend/app/components/runs/summary-section.tsx b/apps/frontend/app/components/runs/summary-section.tsx index 3c0a471..0bc9d12 100644 --- a/apps/frontend/app/components/runs/summary-section.tsx +++ b/apps/frontend/app/components/runs/summary-section.tsx @@ -2,7 +2,12 @@ import { useMemo } from 'react'; import { Run } from "@repo/database"; import { RunJson } from "app/types/runs"; import { SectionHeader } from "app/components/ui/run-elements"; -import { RawSuspensionData, normalizeToPercentage, getProfileFromRun } from "app/lib/telemetryUtils"; +import { + RawSuspensionData, + normalizeToPercentage, + getProfileFromRun, + trimRawDataByBounds, +} from "app/lib/telemetryUtils"; import { cn } from "app/lib/utils"; import { ArrowUp, ArrowDown } from "lucide-react"; @@ -26,11 +31,14 @@ function getNormalizedSuspensionData( jsonData[run.id]?.data?.suspension?.[type === 'front' ? 'front_sus' : 'rear_sus']; if (!suspensionData || suspensionData.length === 0) return null; + const trimmedSuspensionData = trimRawDataByBounds(run, suspensionData); + if (trimmedSuspensionData.length === 0) return null; + const profile = getProfileFromRun(run); const min = profile ? (type === 'front' ? profile.front_min : profile.back_min) : undefined; const max = profile ? (type === 'front' ? profile.front_max : profile.back_max) : undefined; - const normalized = suspensionData + const normalized = trimmedSuspensionData .map(p => { const val = typeof p === 'number' ? p : Number(p.displacement ?? 0); return normalizeToPercentage(val, min, max); diff --git a/apps/frontend/app/components/runs/trimPopup.tsx b/apps/frontend/app/components/runs/trimPopup.tsx new file mode 100644 index 0000000..ef10f35 --- /dev/null +++ b/apps/frontend/app/components/runs/trimPopup.tsx @@ -0,0 +1,255 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Run } from "@repo/database"; +import { DisplacementPlot } from "app/components/graphs/domain/DisplacementPlot"; +import { RunJson } from "app/types/runs"; +import { + RawSuspensionData, + getProfileFromRun, + resolveTrimBounds, +} from "app/lib/telemetryUtils"; +import { RunUpdatePayload } from "app/api/runs"; + +interface TrimPopupProps { + isOpen: boolean; + run: Run | null; + runJson?: RunJson; + onClose: () => void; + onSave: (id: number, payload: RunUpdatePayload) => Promise; +} + +function toRawSuspensionArray(value: unknown): RawSuspensionData[] { + if (!Array.isArray(value)) { + return []; + } + + return value as RawSuspensionData[]; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function boundsToSelection( + lowerBound: number, + upperBound: number, + freq: number, +): [number, number] { + if (!Number.isFinite(freq) || freq <= 0) { + return [lowerBound, upperBound]; + } + + return [lowerBound / freq, upperBound / freq]; +} + +function selectionToBounds( + selection: [number, number], + freq: number, + sampleLength: number, +): [number, number] { + const lastIndex = Math.max(sampleLength - 1, 0); + if (!Number.isFinite(freq) || freq <= 0) { + return [0, lastIndex]; + } + + const lower = clamp(Math.round(selection[0] * freq), 0, lastIndex); + const upper = clamp(Math.round(selection[1] * freq), lower, lastIndex); + return [lower, upper]; +} + +export function TrimPopup({ isOpen, run, runJson, onClose, onSave }: TrimPopupProps) { + const [lowerBound, setLowerBound] = useState(0); + const [upperBound, setUpperBound] = useState(0); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const frontRaw = useMemo( + () => toRawSuspensionArray(runJson?.data?.suspension?.front_sus), + [runJson], + ); + const rearRaw = useMemo( + () => toRawSuspensionArray(runJson?.data?.suspension?.rear_sus), + [runJson], + ); + + const sampleLength = useMemo(() => { + const longestSeries = Math.max(frontRaw.length, rearRaw.length); + if (longestSeries > 0) { + return longestSeries; + } + return run?.length ?? 0; + }, [frontRaw.length, rearRaw.length, run]); + + const profile = useMemo(() => (run ? getProfileFromRun(run) : null), [run]); + const referenceFreq = run?.front_freq || run?.rear_freq || 100; + + useEffect(() => { + if (!run || sampleLength <= 0) { + setLowerBound(0); + setUpperBound(0); + return; + } + + const bounds = resolveTrimBounds(run, sampleLength); + if (!bounds) { + setLowerBound(0); + setUpperBound(0); + return; + } + + setLowerBound(bounds.lowerBoundIdx); + setUpperBound(bounds.upperBoundIdx); + }, [run, sampleLength, isOpen]); + + const maxIndex = Math.max(sampleLength - 1, 0); + const hasData = sampleLength > 0; + + const safeLowerBound = clamp(lowerBound, 0, maxIndex); + const safeUpperBound = clamp(upperBound, safeLowerBound, maxIndex); + + const brushSelection = useMemo( + () => (hasData ? boundsToSelection(safeLowerBound, safeUpperBound, referenceFreq) : null), + [hasData, safeLowerBound, safeUpperBound, referenceFreq], + ); + + const selectedSamples = hasData ? safeUpperBound - safeLowerBound + 1 : 0; + + const seriesConfig = useMemo( + () => [ + { + label: "Front Fork", + rawData: frontRaw, + freq: run?.front_freq || 100, + min: profile?.front_min, + max: profile?.front_max, + dynamicSag: true, + }, + { + label: "Rear Shock", + rawData: rearRaw, + freq: run?.rear_freq || 100, + min: profile?.back_min, + max: profile?.back_max, + dynamicSag: true, + }, + ], + [frontRaw, rearRaw, run, profile], + ); + + const canSave = + !saving && + hasData && + safeLowerBound >= 0 && + safeUpperBound >= safeLowerBound && + safeUpperBound < sampleLength; + + const handleReset = useCallback(() => { + setLowerBound(0); + setUpperBound(maxIndex); + setSaveError(null); + }, [maxIndex]); + + const handleBrushSelection = useCallback((selection: [number, number] | null) => { + if (!selection || !hasData) { + return; + } + + const [nextLower, nextUpper] = selectionToBounds(selection, referenceFreq, sampleLength); + setLowerBound(nextLower); + setUpperBound(nextUpper); + setSaveError(null); + }, [hasData, referenceFreq, sampleLength]); + + const handleSave = useCallback(async () => { + if (!run || !canSave) { + return; + } + + setSaving(true); + setSaveError(null); + + try { + await onSave(run.id, { + lower_bound_idx: safeLowerBound, + upper_bound_idx: safeUpperBound, + }); + onClose(); + } catch (error) { + setSaveError(error instanceof Error ? error.message : "Failed to save trim bounds."); + } finally { + setSaving(false); + } + }, [run, canSave, safeLowerBound, safeUpperBound, onSave, onClose]); + + // Early return only for JSX rendering, not hook execution + if (!isOpen || !run) { + return null; + } + + return ( +
+
+
+
+

Trim Run

+

+ Adjust inclusive sample bounds and save to apply trimmed charts. +

+
+ +
+ + {!hasData ? ( +
+ No telemetry data available for trimming this run. +
+ ) : ( + <> + +

+ Drag the shaded selection in the lower subgraph to set trim bounds. + Current selection: {safeLowerBound} to {safeUpperBound} + {' '}({selectedSamples} / {sampleLength} samples) +

+ + {saveError && ( +
+ {saveError} +
+ )} + + )} + +
+ + +
+
+
+ ); +} diff --git a/apps/frontend/app/hooks/useOptimisticRuns.ts b/apps/frontend/app/hooks/useOptimisticRuns.ts index 27d314b..0ed568d 100644 --- a/apps/frontend/app/hooks/useOptimisticRuns.ts +++ b/apps/frontend/app/hooks/useOptimisticRuns.ts @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { Run } from "@repo/database"; import { Profile } from "app/components/profiles/profileRow"; +import { RunUpdatePayload } from 'app/api/runs'; export function useOptimisticRuns(initialRuns: Run[]) { const queryClient = useQueryClient(); @@ -41,7 +42,7 @@ export function useOptimisticRuns(initialRuns: Run[]) { queryClient.invalidateQueries({ queryKey: ['runs'] }); }, [queryClient]); - const handleRunUpdate = useCallback((id: number, updates: Partial>) => { + const handleRunUpdate = useCallback((id: number, updates: RunUpdatePayload) => { setRuns((prevRuns) => prevRuns.map((run) => (run.id === id ? { ...run, ...updates } : run))); queryClient.invalidateQueries({ queryKey: ['runs'] }); }, [queryClient]); diff --git a/apps/frontend/app/lib/telemetryUtils.ts b/apps/frontend/app/lib/telemetryUtils.ts index 44a28e3..2da281f 100644 --- a/apps/frontend/app/lib/telemetryUtils.ts +++ b/apps/frontend/app/lib/telemetryUtils.ts @@ -14,6 +14,19 @@ export interface NormalizedPoint { y: number; } +export interface RunTrimBounds { + lowerBoundIdx: number; + upperBoundIdx: number; +} + +function parseBound(value: unknown): number | null { + if (typeof value !== "number" || !Number.isSafeInteger(value)) { + return null; + } + + return value; +} + export function getProfileFromRun(run: Run): Profile | null { const profileCandidate = (run as Run & { profile?: unknown }).profile; if ( @@ -27,6 +40,43 @@ export function getProfileFromRun(run: Run): Profile | null { return profileCandidate as Profile; } +export function resolveTrimBounds(run: Run, sampleLength: number): RunTrimBounds | null { + if (!Number.isFinite(sampleLength) || sampleLength <= 0) { + return null; + } + + const runWithBounds = run as Run & { + lower_bound_idx?: unknown; + upper_bound_idx?: unknown; + }; + + const lastIndex = sampleLength - 1; + const rawLower = runWithBounds.lower_bound_idx; + const rawUpper = runWithBounds.upper_bound_idx; + + const parsedLower = parseBound(rawLower) ?? 0; + const parsedUpper = parseBound(rawUpper) ?? lastIndex; + + const lowerBoundIdx = Math.min(Math.max(parsedLower, 0), lastIndex); + const clampedUpper = Math.min(Math.max(parsedUpper, 0), lastIndex); + const upperBoundIdx = Math.max(clampedUpper, lowerBoundIdx); + + return { lowerBoundIdx, upperBoundIdx }; +} + +export function trimRawDataByBounds(run: Run, dataArr: T[]): T[] { + if (!Array.isArray(dataArr) || dataArr.length === 0) { + return []; + } + + const bounds = resolveTrimBounds(run, dataArr.length); + if (!bounds) { + return []; + } + + return dataArr.slice(bounds.lowerBoundIdx, bounds.upperBoundIdx + 1); +} + export function normalizeToPercentage(val: number, min?: number, max?: number): number { // If caller provides a valid min/max range, use it; otherwise fall back to 0..MAX_TRAVEL const hasValidRange = typeof min === 'number' && typeof max === 'number' && isFinite(min) && isFinite(max) && max > min; diff --git a/apps/frontend/app/routes/index.tsx b/apps/frontend/app/routes/index.tsx index a75cfd3..e1f288e 100644 --- a/apps/frontend/app/routes/index.tsx +++ b/apps/frontend/app/routes/index.tsx @@ -1,5 +1,5 @@ import { useLoaderData } from "react-router"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { Sidebar } from "../components/runs/sidebar"; import { MainContent } from "app/components/runs/main-content"; import { RunJson } from "app/types/runs"; @@ -15,13 +15,23 @@ export const loader = async () => { // ---------------------- MAIN PAGE COMPONENT ---------------------- export default function Runs() { - const { runs } = useLoaderData(); + const { runs: initialRuns } = useLoaderData(); + const [runs, setRuns] = useState(initialRuns); const [selected, setSelected] = useState([]); const [jsonData, setJsonData] = useState>({}); const [loadingRuns, setLoadingRuns] = useState>(new Set()); const isCompareMode = selected.length > 1; const loadingJson = loadingRuns.size > 0; + const selectedRunIds = useMemo(() => selected.map((run) => run.id), [selected]); + + useEffect(() => { + const selectedIdSet = new Set(selectedRunIds); + setJsonData((prev) => { + const nextEntries = Object.entries(prev).filter(([id]) => selectedIdSet.has(Number(id))); + return Object.fromEntries(nextEntries); + }); + }, [selectedRunIds]); useEffect(() => { const fetchJson = async (run: Run) => { @@ -54,10 +64,15 @@ export default function Runs() {
runs.find((r) => r.id === s.id) || s)} jsonData={jsonData} loadingJson={loadingJson} isCompareMode={isCompareMode} + onRunUpdate={(id, updates) => { + setRuns((prevRuns) => + prevRuns.map((r) => (r.id === id ? { ...r, ...updates } : r)) + ); + }} />
); diff --git a/packages/database/drizzle/0006_trim_bounds.sql b/packages/database/drizzle/0006_trim_bounds.sql new file mode 100644 index 0000000..670c8e6 --- /dev/null +++ b/packages/database/drizzle/0006_trim_bounds.sql @@ -0,0 +1,12 @@ +ALTER TABLE "runs" ADD COLUMN IF NOT EXISTS "lower_bound_idx" integer DEFAULT 0 NOT NULL; +--> statement-breakpoint +ALTER TABLE "runs" ADD COLUMN IF NOT EXISTS "upper_bound_idx" integer; +--> statement-breakpoint +UPDATE "runs" +SET + "upper_bound_idx" = GREATEST("length" - 1, 0) +WHERE "upper_bound_idx" IS NULL; +--> statement-breakpoint +ALTER TABLE "runs" ALTER COLUMN "upper_bound_idx" SET NOT NULL; +--> statement-breakpoint +ALTER TABLE "runs" ALTER COLUMN "upper_bound_idx" DROP DEFAULT; diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json index 0cbbb54..22221f7 100644 --- a/packages/database/drizzle/meta/_journal.json +++ b/packages/database/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1767372639766, "tag": "0005_mighty_chamber", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1775433600000, + "tag": "0006_trim_bounds", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index 6f9a3ed..ca2ae12 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -44,6 +44,8 @@ export const runs = pgTable("runs", { date: timestamp("date").defaultNow().notNull(), // run date/time location: varchar("location", { length: 255 }), // place or tag for run profile: integer("profile").references(() => profiles.id), // associated profile + lower_bound_idx: integer("lower_bound_idx").notNull().default(0), // inclusive trim start index (default: 0) + upper_bound_idx: integer("upper_bound_idx").notNull(), // inclusive trim end index (set by service layer to length-1) front_freq: integer("front_freq"), // front suspension sample frequency rear_freq: integer("rear_freq"), // rear suspension sample frequency createdAt: timestamp("created_at").defaultNow(), From c6f93e0ec31d51d48256cc6e4a8ff4a26ac422a7 Mon Sep 17 00:00:00 2001 From: James Moyles Date: Tue, 28 Apr 2026 18:07:18 +0100 Subject: [PATCH 05/17] feat(graphs): support changes to rebound and compression graphs --- .../graphs/domain/DisplacementPlot.tsx | 6 +- .../graphs/domain/ReboundCompressionPlot.tsx | 351 +++++++++++------- .../app/components/runs/chart-sections.tsx | 235 +++--------- .../app/components/runs/main-content.tsx | 12 +- apps/frontend/app/lib/run-analysis.ts | 2 +- 5 files changed, 298 insertions(+), 308 deletions(-) diff --git a/apps/frontend/app/components/graphs/domain/DisplacementPlot.tsx b/apps/frontend/app/components/graphs/domain/DisplacementPlot.tsx index 84008ef..4529e98 100644 --- a/apps/frontend/app/components/graphs/domain/DisplacementPlot.tsx +++ b/apps/frontend/app/components/graphs/domain/DisplacementPlot.tsx @@ -15,6 +15,7 @@ export interface SeriesConfig { freq: number; min?: number; max?: number; + length?: number; dynamicSag?: boolean; } @@ -73,7 +74,10 @@ export const DisplacementPlot: React.FC = ({ const highlightLine = useMemo(() => { if (!highlight) return null; - const seriesLine = chartData[highlight.seriesIndex]; + const dataIndex = lineMetadata.findIndex( + (m) => m.seriesIndex === highlight.seriesIndex && !m.isSag, + ); + const seriesLine = dataIndex >= 0 ? chartData[dataIndex] : undefined; if (!seriesLine || seriesLine.length === 0) return null; const start = Math.max( diff --git a/apps/frontend/app/components/graphs/domain/ReboundCompressionPlot.tsx b/apps/frontend/app/components/graphs/domain/ReboundCompressionPlot.tsx index d720e80..57b6ba0 100644 --- a/apps/frontend/app/components/graphs/domain/ReboundCompressionPlot.tsx +++ b/apps/frontend/app/components/graphs/domain/ReboundCompressionPlot.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from "react"; +import React, { useMemo } from "react"; import { ScatterPlot, ScatterSeries, @@ -7,11 +7,13 @@ import { } from "../base/ScatterPlot"; import { RawSuspensionData, - buildVelocitySamples, buildLineFromPoints, - VelocitySample, LinePoint, } from "app/lib/telemetryUtils"; +import { + processCompressions, + SuspensionActivity, +} from "app/lib/run-analysis"; import { getSeriesColor } from "app/lib/graphColors"; export type SpeedRegion = { @@ -19,8 +21,6 @@ export type SpeedRegion = { high: number; }; -export type UnitMode = "mm" | "percent"; - interface ReboundCompressionPlotProps { title: string; series: { @@ -30,137 +30,219 @@ interface ReboundCompressionPlotProps { freq: number; min?: number; max?: number; + length?: number; }[]; - mode: "compression" | "rebound"; height?: number; speedRegion: SpeedRegion; - unitMode?: UnitMode; - onUnitModeChange?: (mode: UnitMode) => void; - onPointSelect?: (selection: { seriesIndex: number; index: number }) => void; + onPointSelect?: (selection: { + seriesIndex: number; + startIndex: number; + endIndex: number; + }) => void; } type PreparedSeries = { label: string; color: string; - samples: VelocitySample[]; - points: LinePoint[]; - lowPoints: LinePoint[]; - highPoints: LinePoint[]; - scatterPoints: { + freq: number; + compressionScatterPoints: { x: number; y: number; id: string; meta: { - index: number; + startIndex: number; + endIndex: number; speed: number; displacement: number; - unit: UnitMode; }; }[]; + reboundScatterPoints: { + x: number; + y: number; + id: string; + meta: { + startIndex: number; + endIndex: number; + speed: number; + displacement: number; + }; + }[]; + compressionPoints: LinePoint[]; + compressionLowPoints: LinePoint[]; + compressionHighPoints: LinePoint[]; + reboundPoints: LinePoint[]; + reboundLowPoints: LinePoint[]; + reboundHighPoints: LinePoint[]; }; -const DEFAULT_UNIT: UnitMode = "percent"; +const flipY = (pt: LinePoint): LinePoint => ({ x: pt.x, y: Math.abs(pt.y) }); -const toggleLabels: Record = { - percent: "%", - mm: "mm", -}; +function filterOutliers( + suspensionActivity: SuspensionActivity[], +): SuspensionActivity[] { + if (suspensionActivity.length === 0) return suspensionActivity; + + const velocities = suspensionActivity.map((a) => a.velocity); + const displacements = suspensionActivity.map((a) => a.displacement); -const formatModeLabel = (mode: UnitMode) => - mode === "percent" ? "% travel" : "mm displacement"; + const mean = (arr: number[]) => arr.reduce((s, v) => s + v, 0) / arr.length; + const std = (arr: number[], m: number) => + Math.sqrt(arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length); + + const vMean = mean(velocities); + const vStd = std(velocities, vMean); + const dMean = mean(displacements); + const dStd = std(displacements, dMean); + + return suspensionActivity.filter( + (a) => + Math.abs(a.velocity - vMean) < 5 * vStd && + Math.abs(a.displacement - dMean) < 5 * dStd, + ); +} export const ReboundCompressionPlot: React.FC = ({ title, series, - mode, height = 320, speedRegion, - unitMode = DEFAULT_UNIT, - onUnitModeChange, onPointSelect, }) => { - const [internalMode, setInternalMode] = useState(unitMode); - const activeMode = onUnitModeChange ? unitMode : internalMode; - const prepared = useMemo(() => { return series.map((s, index) => { - const samples = buildVelocitySamples(s.rawData, s.freq, s.min, s.max); - const filtered = samples.filter((sample) => - mode === "compression" ? sample.velocity > 0 : sample.velocity < 0, + const length = s.length ?? 220; + const min = s.min ?? 0; + const max = s.max ?? 4096; + + const activities = processCompressions( + s.rawData, + s.freq, + length, + min, + max, ); - const points = filtered.map((sample) => ({ - x: sample.speed, - y: activeMode === "percent" ? sample.normalized : sample.displacement, - })); + const toPoint = (a: SuspensionActivity): LinePoint => ({ + x: Math.abs(a.velocity), + y: a.displacement, + }); - const lowPoints = filtered - .filter((sample) => sample.speed <= speedRegion.low) - .map((sample) => ({ - x: sample.speed, - y: activeMode === "percent" ? sample.normalized : sample.displacement, - })); + const compressions = filterOutliers( + activities.filter((a) => a.type === "compression"), + ); + const rebounds = filterOutliers( + activities.filter((a) => a.type === "rebound"), + ); - const highPoints = filtered - .filter((sample) => sample.speed >= speedRegion.high) - .map((sample) => ({ - x: sample.speed, - y: activeMode === "percent" ? sample.normalized : sample.displacement, - })); + const compressionPoints = compressions.map(toPoint); + const reboundPoints = rebounds.map(toPoint).map(flipY); - const scatterPoints = filtered.map((sample) => ({ - x: sample.speed, - y: activeMode === "percent" ? sample.normalized : sample.displacement, - id: `${index}-${sample.index}`, - meta: { - index: sample.index, - speed: sample.speed, - displacement: - activeMode === "percent" ? sample.normalized : sample.displacement, - unit: activeMode, - }, - })); + const compressionLowPoints = compressions + .filter((a) => Math.abs(a.velocity) <= speedRegion.low) + .map(toPoint); + const compressionHighPoints = compressions + .filter((a) => Math.abs(a.velocity) >= speedRegion.high) + .map(toPoint); + + const reboundLowPoints = rebounds + .filter((a) => Math.abs(a.velocity) <= speedRegion.low) + .map(toPoint) + .map(flipY); + const reboundHighPoints = rebounds + .filter((a) => Math.abs(a.velocity) >= speedRegion.high) + .map(toPoint) + .map(flipY); + + const makeScatterPoints = ( + acts: SuspensionActivity[], + flip: boolean, + ) => + acts.map((a, idx) => ({ + x: Math.abs(a.velocity), + y: flip ? Math.abs(a.displacement) : a.displacement, + id: `${index}-${flip ? "r" : "c"}-${idx}`, + meta: { + startIndex: Math.round(a.time.start * s.freq), + endIndex: Math.round(a.time.end * s.freq), + speed: Math.abs(a.velocity), + displacement: a.displacement, + }, + })); return { label: s.label, color: getSeriesColor(index, s.color), - samples, - points, - lowPoints, - highPoints, - scatterPoints, + freq: s.freq, + compressionScatterPoints: makeScatterPoints(compressions, false), + reboundScatterPoints: makeScatterPoints(rebounds, true), + compressionPoints, + compressionLowPoints, + compressionHighPoints, + reboundPoints, + reboundLowPoints, + reboundHighPoints, }; }); - }, [series, mode, speedRegion.low, speedRegion.high, activeMode]); + }, [series, speedRegion.low, speedRegion.high]); const maxSpeed = useMemo(() => { let max = 0; prepared.forEach((p) => { - p.samples.forEach((sample) => { - if (sample.speed > max) max = sample.speed; - }); + [...p.compressionScatterPoints, ...p.reboundScatterPoints].forEach( + (pt) => { + if (pt.x > max) max = pt.x; + }, + ); }); return max > 0 ? max : null; }, [prepared]); - const scatterSeries = useMemo(() => { + const xMax = maxSpeed + ? Math.max(speedRegion.high, maxSpeed) * 1.05 + : speedRegion.high * 1.3; + + const compressionScatterSeries = useMemo(() => { + return prepared.map((p) => ({ + label: p.label, + color: p.color, + points: p.compressionScatterPoints, + pointRadius: 2.5, + opacity: 0.7, + })); + }, [prepared]); + + const reboundScatterSeries = useMemo(() => { return prepared.map((p) => ({ label: p.label, color: p.color, - points: p.scatterPoints, + points: p.reboundScatterPoints, pointRadius: 2.5, opacity: 0.7, })); }, [prepared]); - const trendLines = useMemo(() => { + const buildTrendLines = ( + type: "compression" | "rebound", + ): ScatterLine[] => { const lines: ScatterLine[] = []; prepared.forEach((p) => { - const allLine = buildLineFromPoints(p.points); + const allPts = + type === "compression" ? p.compressionPoints : p.reboundPoints; + const lowPts = + type === "compression" + ? p.compressionLowPoints + : p.reboundLowPoints; + const highPts = + type === "compression" + ? p.compressionHighPoints + : p.reboundHighPoints; + const prefix = type === "compression" ? "comp" : "reb"; + + const allLine = buildLineFromPoints(allPts); if (allLine.length > 0) { lines.push({ - label: `${p.label} overall`, + label: `${p.label} ${type}`, color: p.color, points: allLine, strokeWidth: 2.5, @@ -168,10 +250,10 @@ export const ReboundCompressionPlot: React.FC = ({ }); } - const lowLine = buildLineFromPoints(p.lowPoints); + const lowLine = buildLineFromPoints(lowPts); if (lowLine.length > 0) { lines.push({ - label: `${p.label} low-speed`, + label: `${p.label} ${prefix} low-speed`, color: p.color, points: lowLine, strokeWidth: 2, @@ -180,10 +262,10 @@ export const ReboundCompressionPlot: React.FC = ({ }); } - const highLine = buildLineFromPoints(p.highPoints); + const highLine = buildLineFromPoints(highPts); if (highLine.length > 0) { lines.push({ - label: `${p.label} high-speed`, + label: `${p.label} ${prefix} high-speed`, color: p.color, points: highLine, strokeWidth: 2, @@ -194,7 +276,19 @@ export const ReboundCompressionPlot: React.FC = ({ }); return lines; - }, [prepared]); + }; + + const compressionTrendLines = useMemo( + () => buildTrendLines("compression"), + // eslint-disable-next-line react-hooks/exhaustive-deps + [prepared, maxSpeed, speedRegion.high], + ); + + const reboundTrendLines = useMemo( + () => buildTrendLines("rebound"), + // eslint-disable-next-line react-hooks/exhaustive-deps + [prepared, maxSpeed, speedRegion.high], + ); const bands = useMemo(() => { const maxBand = @@ -217,17 +311,28 @@ export const ReboundCompressionPlot: React.FC = ({ ]; }, [speedRegion, maxSpeed]); - const handleToggle = () => { - const next = activeMode === "percent" ? "mm" : "percent"; - if (onUnitModeChange) { - onUnitModeChange(next); - } else { - setInternalMode(next); - } - }; - - const yLabel = formatModeLabel(activeMode); const xLabel = "Speed (mm/s)"; + const yLabel = "Displacement (mm)"; + + const handlePointClick = + (filterType: "compression" | "rebound") => + ( + point: { meta?: Record }, + seriesIndex: number, + ) => { + if (!onPointSelect) return; + const startIndex = + typeof point.meta?.startIndex === "number" + ? (point.meta.startIndex as number) + : 0; + const endIndex = + typeof point.meta?.endIndex === "number" + ? (point.meta.endIndex as number) + : 0; + onPointSelect({ seriesIndex, startIndex, endIndex }); + }; + + const plotHeight = Math.round(height * 0.75); return (
@@ -235,21 +340,9 @@ export const ReboundCompressionPlot: React.FC = ({

{title}

- Low speed: 0–{speedRegion.low} mm/s · High speed: {speedRegion.high} - + mm/s + Low: 0–{speedRegion.low} mm/s · High: {speedRegion.high}+ mm/s

-
@@ -276,29 +369,37 @@ export const ReboundCompressionPlot: React.FC = ({
- { - if (!onPointSelect) return; - const index = - typeof point.meta?.index === "number" - ? (point.meta.index as number) - : 0; - onPointSelect({ seriesIndex, index }); - }} - /> +
+
+

+ Compression +

+ +
+ +
+

Rebound

+ +
+
); }; diff --git a/apps/frontend/app/components/runs/chart-sections.tsx b/apps/frontend/app/components/runs/chart-sections.tsx index 2e6f87e..2ede367 100644 --- a/apps/frontend/app/components/runs/chart-sections.tsx +++ b/apps/frontend/app/components/runs/chart-sections.tsx @@ -40,7 +40,7 @@ function getSeriesConfig( ? run.front_freq || 100 : run.rear_freq || 100; - // Apply rider profile min/max range + // Apply rider profile min/max range and suspension travel const profile = getProfileFromRun(run); const min = profile ? type === "front" @@ -52,6 +52,11 @@ function getSeriesConfig( ? profile.front_max : profile.back_max : undefined; + const length = profile + ? type === "front" + ? profile.front_travel + : profile.back_travel + : undefined; // Build unified series config for displacement rendering return { @@ -61,6 +66,7 @@ function getSeriesConfig( freq, min, max, + length, dynamicSag, }; } @@ -72,7 +78,6 @@ export function DisplacementSection({ isCompareMode, }: ChartSectionProps) { const [highlight, setHighlight] = useState(null); - const [unitMode, setUnitMode] = useState<"mm" | "percent">("percent"); if (!selected || selected.length === 0 || !selected[0]) { return No run selected; @@ -102,79 +107,27 @@ export function DisplacementSection({ highlight={highlight} /> -
- - getSeriesConfig(run, i, jsonData, "front", undefined, true), - )} - speedRegion={{ low: 50, high: 150 }} - unitMode={unitMode} - onUnitModeChange={setUnitMode} - onPointSelect={({ seriesIndex, index }) => { - setHighlight({ - seriesIndex, - startIndex: Math.max(0, index - 2), - endIndex: index + 2, - }); - }} - /> - - getSeriesConfig(run, i, jsonData, "front", undefined, true), - )} - speedRegion={{ low: 50, high: 150 }} - unitMode={unitMode} - onUnitModeChange={setUnitMode} - onPointSelect={({ seriesIndex, index }) => { - setHighlight({ - seriesIndex, - startIndex: Math.max(0, index - 2), - endIndex: index + 2, - }); - }} - /> -
+ + getSeriesConfig(run, i, jsonData, "front", undefined, true), + )} + speedRegion={{ low: 50, high: 150 }} + onPointSelect={({ seriesIndex, startIndex, endIndex }) => { + setHighlight({ seriesIndex, startIndex, endIndex }); + }} + /> -
- - getSeriesConfig(run, i, jsonData, "rear", undefined, true), - )} - speedRegion={{ low: 50, high: 150 }} - unitMode={unitMode} - onUnitModeChange={setUnitMode} - onPointSelect={({ seriesIndex, index }) => { - setHighlight({ - seriesIndex, - startIndex: Math.max(0, index - 2), - endIndex: index + 2, - }); - }} - /> - - getSeriesConfig(run, i, jsonData, "rear", undefined, true), - )} - speedRegion={{ low: 50, high: 150 }} - unitMode={unitMode} - onUnitModeChange={setUnitMode} - onPointSelect={({ seriesIndex, index }) => { - setHighlight({ - seriesIndex, - startIndex: Math.max(0, index - 2), - endIndex: index + 2, - }); - }} - /> -
+ + getSeriesConfig(run, i, jsonData, "rear", undefined, true), + )} + speedRegion={{ low: 50, high: 150 }} + onPointSelect={({ seriesIndex, startIndex, endIndex }) => { + setHighlight({ seriesIndex, startIndex, endIndex }); + }} + />
) : ( firstData && @@ -196,107 +149,39 @@ export function DisplacementSection({ highlight={highlight} /> -
- { - setHighlight({ - seriesIndex: 0, - startIndex: Math.max(0, index - 2), - endIndex: index + 2, - }); - }} - /> - { - setHighlight({ - seriesIndex: 0, - startIndex: Math.max(0, index - 2), - endIndex: index + 2, - }); - }} - /> -
+ { + setHighlight({ seriesIndex, startIndex, endIndex }); + }} + /> -
- { - setHighlight({ - seriesIndex: 1, - startIndex: Math.max(0, index - 2), - endIndex: index + 2, - }); - }} - /> - { - setHighlight({ - seriesIndex: 1, - startIndex: Math.max(0, index - 2), - endIndex: index + 2, - }); - }} - /> -
+ { + setHighlight({ seriesIndex: 1, startIndex, endIndex }); + }} + />
) )} @@ -396,4 +281,4 @@ export function HistogramSection({
); -} +} \ No newline at end of file diff --git a/apps/frontend/app/components/runs/main-content.tsx b/apps/frontend/app/components/runs/main-content.tsx index 8886bc6..375ba83 100644 --- a/apps/frontend/app/components/runs/main-content.tsx +++ b/apps/frontend/app/components/runs/main-content.tsx @@ -7,13 +7,13 @@ import { EmptyState, LoadingState, SectionDivider, -} from "app/components/ui/run-elements"; +} from "app/components/ui/run-elements"; import { Run } from "@repo/database"; import { useState } from "react"; import { ProfilePopup } from "../profiles/profilePopUp"; import { UserIcon } from "lucide-react"; import { RunsMetadata } from "./runs-metadata"; -import { useOptimisticRuns } from "app/hooks/useOptimisticRuns"; +import { useOptimisticRuns } from "app/hooks/useOptimisticRuns"; import { CommentsPopup } from "./commentsPopup"; import { updateRun } from "app/api/runs"; import { SummarySection } from "./summary-section"; @@ -33,7 +33,7 @@ export function MainContent({ loadingJson, isCompareMode, }: MainContentProps) { - + const { runs, handleProfileUpdate, handleRunUpdate } = useOptimisticRuns(initialSelected); const [isPopupOpen, setIsPopupOpen] = useState(false); @@ -98,7 +98,7 @@ export function MainContent({ setIsPopupOpen(false)} - selected={runs} + selected={runs} onProfileUpdate={handleProfileUpdate} /> )} @@ -127,8 +127,8 @@ export function MainContent({

{isCompareMode ? "Run Comparison" : runs[0]?.title || "Run Details"}

- +
+ + +
{/* Metadata section (extracted to RunsMetadata component) */} diff --git a/apps/frontend/app/components/runs/sidebar.tsx b/apps/frontend/app/components/runs/sidebar.tsx index e7b742f..ebeaf14 100644 --- a/apps/frontend/app/components/runs/sidebar.tsx +++ b/apps/frontend/app/components/runs/sidebar.tsx @@ -7,22 +7,22 @@ import { useState, useMemo } from "react"; // ----------------- Sidebar Menu Button ----------------- interface SidebarMenuButtonProps { run: Run; - selected: Run[]; - setSelected: (runs: Run[]) => void; + selectedRunIds: number[]; + setSelectedRunIds: (ids: number[]) => void; } function SidebarMenuButton({ run, - selected, - setSelected, + selectedRunIds, + setSelectedRunIds, }: SidebarMenuButtonProps) { - const isSelected = selected.some((r) => r.id === run.id); + const isSelected = selectedRunIds.includes(run.id); const toggle = () => { if (isSelected) { - setSelected(selected.filter((r) => r.id !== run.id)); - } else if (selected.length < 2) { - setSelected([...selected, run]); + setSelectedRunIds(selectedRunIds.filter((id) => id !== run.id)); + } else if (selectedRunIds.length < 2) { + setSelectedRunIds([...selectedRunIds, run.id]); } }; @@ -52,14 +52,14 @@ function SidebarMenuButton({ interface DateGroupProps { date: string; runs: Run[]; - selected: Run[]; - setSelected: (runs: Run[]) => void; + selectedRunIds: number[]; + setSelectedRunIds: (ids: number[]) => void; } -const DateGroup = ({ date, runs, selected, setSelected }: DateGroupProps) => { +const DateGroup = ({ date, runs, selectedRunIds, setSelectedRunIds }: DateGroupProps) => { const [isOpen, setIsOpen] = useState(false); - const hasSelectedRuns = runs.some(run => selected.some(r => r.id === run.id)); + const hasSelectedRuns = runs.some((run) => selectedRunIds.includes(run.id)); return (
  • @@ -93,7 +93,7 @@ const DateGroup = ({ date, runs, selected, setSelected }: DateGroupProps) => { transition={{ duration: 0.3, ease: "easeInOut" }} className="flex flex-col pl-2 border-l border-slate-200 ml-3 gap-1 overflow-hidden" > - {(isOpen ? runs : runs.filter(run => selected.some(r => r.id === run.id))) + {(isOpen ? runs : runs.filter((run) => selectedRunIds.includes(run.id))) .map(run => ( { exit={{ opacity: 0, y: -4 }} transition={{ duration: 0.2 }} > - + ))} @@ -116,11 +116,11 @@ const DateGroup = ({ date, runs, selected, setSelected }: DateGroupProps) => { // ----------------- Sidebar ----------------- interface SidebarProps { runs: Run[]; - selected: Run[]; - setSelected: (runs: Run[]) => void; + selectedRunIds: number[]; + setSelectedRunIds: (ids: number[]) => void; } -export function Sidebar({ runs, selected, setSelected }: SidebarProps) { +export function Sidebar({ runs, selectedRunIds, setSelectedRunIds }: SidebarProps) { const [collapsed, setCollapsed] = useState(false); const [query, setQuery] = useState(""); @@ -130,11 +130,11 @@ export function Sidebar({ runs, selected, setSelected }: SidebarProps) { const q = query.toLowerCase(); return runs.filter((run) => - selected.some((r) => r.id === run.id) || + selectedRunIds.includes(run.id) || (run.title ?? "").toLowerCase().includes(q) || (run.location ?? "").toLowerCase().includes(q) ); - }, [runs, query,selected]); + }, [runs, query, selectedRunIds]); // Group runs by date and return a sorted array of groups (newest first). const groupedRuns = useMemo(() => { const groups: Record = {}; @@ -235,8 +235,8 @@ export function Sidebar({ runs, selected, setSelected }: SidebarProps) { key={group.dateKey} date={group.dateKey} runs={group.runs} - selected={selected} - setSelected={setSelected} + selectedRunIds={selectedRunIds} + setSelectedRunIds={setSelectedRunIds} /> )) )} diff --git a/apps/frontend/app/components/runs/trimPopup.tsx b/apps/frontend/app/components/runs/trimPopup.tsx new file mode 100644 index 0000000..fd9ecf9 --- /dev/null +++ b/apps/frontend/app/components/runs/trimPopup.tsx @@ -0,0 +1,262 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Run } from "@repo/database"; +import { DisplacementPlot } from "app/components/graphs/domain/DisplacementPlot"; +import { RunJson } from "app/types/runs"; +import { + RawSuspensionData, + getProfileFromRun, + resolveTrimBounds, +} from "app/lib/telemetryUtils"; +import { RunUpdatePayload } from "app/api/runs"; + +interface TrimPopupProps { + isOpen: boolean; + run: Run | null; + runJson?: RunJson; + onClose: () => void; + onSave: (id: number, payload: RunUpdatePayload) => Promise; +} + +function toRawSuspensionArray(value: unknown): RawSuspensionData[] { + if (!Array.isArray(value)) { + return []; + } + + return value as RawSuspensionData[]; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function boundsToSelection( + lowerBound: number, + upperBound: number, + freq: number, +): [number, number] { + if (!Number.isFinite(freq) || freq <= 0) { + return [lowerBound, upperBound]; + } + + return [lowerBound / freq, upperBound / freq]; +} + +function selectionToBounds( + selection: [number, number], + freq: number, + sampleLength: number, +): [number, number] { + const lastIndex = Math.max(sampleLength - 1, 0); + if (!Number.isFinite(freq) || freq <= 0) { + return [0, lastIndex]; + } + + const lower = clamp(Math.round(selection[0] * freq), 0, lastIndex); + const upper = clamp(Math.round(selection[1] * freq), lower, lastIndex); + return [lower, upper]; +} + +export function TrimPopup({ isOpen, run, runJson, onClose, onSave }: TrimPopupProps) { + const [lowerBound, setLowerBound] = useState(0); + const [upperBound, setUpperBound] = useState(0); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const frontRaw = useMemo( + () => toRawSuspensionArray(runJson?.data?.suspension?.front_sus), + [runJson], + ); + const rearRaw = useMemo( + () => toRawSuspensionArray(runJson?.data?.suspension?.rear_sus), + [runJson], + ); + + const sampleLength = useMemo(() => { + const longestSeries = Math.max(frontRaw.length, rearRaw.length); + if (longestSeries > 0) { + return longestSeries; + } + return run?.length ?? 0; + }, [frontRaw.length, rearRaw.length, run]); + + const profile = useMemo(() => (run ? getProfileFromRun(run) : null), [run]); + const referenceFreq = run?.front_freq || run?.rear_freq || 100; + + useEffect(() => { + if (!run || sampleLength <= 0) { + setLowerBound(0); + setUpperBound(0); + return; + } + + const bounds = resolveTrimBounds(run, sampleLength); + if (!bounds) { + setLowerBound(0); + setUpperBound(0); + return; + } + + setLowerBound(bounds.lowerBoundIdx); + setUpperBound(bounds.upperBoundIdx); + }, [run, sampleLength, isOpen]); + + const maxIndex = Math.max(sampleLength - 1, 0); + const hasData = sampleLength > 0; + + const safeLowerBound = clamp(lowerBound, 0, maxIndex); + const safeUpperBound = clamp(upperBound, safeLowerBound, maxIndex); + + const brushSelection = useMemo( + () => (hasData ? boundsToSelection(safeLowerBound, safeUpperBound, referenceFreq) : null), + [hasData, safeLowerBound, safeUpperBound, referenceFreq], + ); + + const selectedSamples = hasData ? safeUpperBound - safeLowerBound + 1 : 0; + + const seriesConfig = useMemo( + () => [ + { + label: "Front Fork", + rawData: frontRaw, + freq: run?.front_freq || 100, + min: profile?.front_min, + max: profile?.front_max, + dynamicSag: true, + }, + { + label: "Rear Shock", + rawData: rearRaw, + freq: run?.rear_freq || 100, + min: profile?.back_min, + max: profile?.back_max, + dynamicSag: true, + }, + ], + [frontRaw, rearRaw, run, profile], + ); + + const canSave = + !saving && + hasData && + safeLowerBound >= 0 && + safeUpperBound >= safeLowerBound && + safeUpperBound < sampleLength; + + const handleReset = useCallback(() => { + setLowerBound(0); + setUpperBound(maxIndex); + setSaveError(null); + }, [maxIndex]); + + const handleBrushSelection = useCallback((selection: [number, number] | null) => { + if (!selection || !hasData) { + return; + } + + const [nextLower, nextUpper] = selectionToBounds(selection, referenceFreq, sampleLength); + setLowerBound(nextLower); + setUpperBound(nextUpper); + setSaveError(null); + }, [hasData, referenceFreq, sampleLength]); + + const handleSave = useCallback(async () => { + if (!run || !canSave) { + return; + } + + setSaving(true); + setSaveError(null); + + // Use (length - 1) for scaling to handle indices correctly (fencepost safety) + const scale = sampleLength > 1 ? (run.length - 1) / (sampleLength - 1) : 1; + + try { + await onSave(run.id, { + lower_bound_idx: Math.round(safeLowerBound * scale), + // Clamp to ensure we never exceed DB run length due to float precision + upper_bound_idx: Math.min( + Math.round(safeUpperBound * scale), + run.length - 1, + ), + }); + onClose(); + } catch (error) { + setSaveError(error instanceof Error ? error.message : "Failed to save trim bounds."); + } finally { + setSaving(false); + } + }, [run, canSave, safeLowerBound, safeUpperBound, sampleLength, onSave, onClose]); + + // Early return only for JSX rendering, not hook execution + if (!isOpen || !run) { + return null; + } + + return ( +
    +
    +
    +
    +

    Trim Run

    +

    + Adjust inclusive sample bounds and save to apply trimmed charts. +

    +
    + +
    + + {!hasData ? ( +
    + No telemetry data available for trimming this run. +
    + ) : ( + <> + +

    + Drag the shaded selection in the lower subgraph to set trim bounds. + Current selection: {safeLowerBound} to {safeUpperBound} + {' '}({selectedSamples} / {sampleLength} samples) +

    + + {saveError && ( +
    + {saveError} +
    + )} + + )} + +
    + + +
    +
    +
    + ); +} diff --git a/apps/frontend/app/hooks/useOptimisticRuns.ts b/apps/frontend/app/hooks/useOptimisticRuns.ts index 27d314b..0ed568d 100644 --- a/apps/frontend/app/hooks/useOptimisticRuns.ts +++ b/apps/frontend/app/hooks/useOptimisticRuns.ts @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { Run } from "@repo/database"; import { Profile } from "app/components/profiles/profileRow"; +import { RunUpdatePayload } from 'app/api/runs'; export function useOptimisticRuns(initialRuns: Run[]) { const queryClient = useQueryClient(); @@ -41,7 +42,7 @@ export function useOptimisticRuns(initialRuns: Run[]) { queryClient.invalidateQueries({ queryKey: ['runs'] }); }, [queryClient]); - const handleRunUpdate = useCallback((id: number, updates: Partial>) => { + const handleRunUpdate = useCallback((id: number, updates: RunUpdatePayload) => { setRuns((prevRuns) => prevRuns.map((run) => (run.id === id ? { ...run, ...updates } : run))); queryClient.invalidateQueries({ queryKey: ['runs'] }); }, [queryClient]); diff --git a/apps/frontend/app/hooks/useRunMetrics.ts b/apps/frontend/app/hooks/useRunMetrics.ts index 79d2893..7b97e82 100644 --- a/apps/frontend/app/hooks/useRunMetrics.ts +++ b/apps/frontend/app/hooks/useRunMetrics.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { Run } from "@repo/database"; import { RunJson } from "app/types/runs"; -import { RawSuspensionData, normalizeToPercentage, getProfileFromRun } from "app/lib/telemetryUtils"; +import { RawSuspensionData, normalizeToPercentage, getProfileFromRun, trimRawDataByBounds } from "app/lib/telemetryUtils"; export const DYNAMIC_SAG_IDEAL_MIN_FRONT = 25; export const DYNAMIC_SAG_IDEAL_MAX_FRONT = 35; @@ -20,11 +20,14 @@ function getNormalizedSuspensionData( jsonData[run.id]?.data?.suspension?.[type === 'front' ? 'front_sus' : 'rear_sus']; if (!suspensionData || suspensionData.length === 0) return null; + const trimmedSuspensionData = trimRawDataByBounds(run, suspensionData); + if (trimmedSuspensionData.length === 0) return null; + const profile = getProfileFromRun(run); const min = profile ? (type === 'front' ? profile.front_min : profile.back_min) : undefined; const max = profile ? (type === 'front' ? profile.front_max : profile.back_max) : undefined; - const normalized = suspensionData + const normalized = trimmedSuspensionData .map(p => { const val = typeof p === 'number' ? p : Number(p.displacement ?? 0); return normalizeToPercentage(val, min, max); diff --git a/apps/frontend/app/lib/telemetryUtils.ts b/apps/frontend/app/lib/telemetryUtils.ts index a9afa67..89f1201 100644 --- a/apps/frontend/app/lib/telemetryUtils.ts +++ b/apps/frontend/app/lib/telemetryUtils.ts @@ -14,6 +14,19 @@ export interface NormalizedPoint { y: number; } +export interface RunTrimBounds { + lowerBoundIdx: number; + upperBoundIdx: number; +} + +function parseBound(value: unknown): number | null { + if (typeof value !== "number" || !Number.isSafeInteger(value)) { + return null; + } + + return value; +} + export function getProfileFromRun(run: Run): Profile | null { const profileCandidate = (run as Run & { profile?: unknown }).profile; if ( @@ -27,6 +40,48 @@ export function getProfileFromRun(run: Run): Profile | null { return profileCandidate as Profile; } +export function resolveTrimBounds(run: Run, sampleLength: number): RunTrimBounds | null { + if (!Number.isFinite(sampleLength) || sampleLength <= 0) { + return null; + } + + if (!Number.isFinite(run.length) || run.length <= 0) { + return { lowerBoundIdx: 0, upperBoundIdx: sampleLength - 1 }; + } + + const runWithBounds = run as Run & { + lower_bound_idx?: unknown; + upper_bound_idx?: unknown; + }; + + const lastIndex = sampleLength - 1; + const scale = sampleLength / run.length; + const rawLower = runWithBounds.lower_bound_idx; + const rawUpper = runWithBounds.upper_bound_idx; + + const parsedLower = parseBound(rawLower) ?? 0; + const parsedUpper = parseBound(rawUpper) ?? (run.length - 1); + + const lowerBoundIdx = Math.min(Math.max(Math.round(parsedLower * scale), 0), lastIndex); + const clampedUpper = Math.min(Math.max(Math.round(parsedUpper * scale), 0), lastIndex); + const upperBoundIdx = Math.max(clampedUpper, lowerBoundIdx); + + return { lowerBoundIdx, upperBoundIdx }; +} + +export function trimRawDataByBounds(run: Run, dataArr: T[]): T[] { + if (!Array.isArray(dataArr) || dataArr.length === 0) { + return []; + } + + const bounds = resolveTrimBounds(run, dataArr.length); + if (!bounds) { + return []; + } + + return dataArr.slice(bounds.lowerBoundIdx, bounds.upperBoundIdx + 1); +} + export function normalizeToPercentage(val: number, min?: number, max?: number): number { // If caller provides a valid min/max range, use it; otherwise fall back to 0..MAX_TRAVEL const hasValidRange = typeof min === 'number' && typeof max === 'number' && isFinite(min) && isFinite(max) && max > min; @@ -41,14 +96,18 @@ export function normalizeToPercentage(val: number, min?: number, max?: number): return Number.isFinite(result) ? Math.min(100, Math.max(0, result)) : 0; } -export function standardizeData(dataArr: RawSuspensionData[], freq: number): StandardizedPoint[] { +export function standardizeData( + dataArr: RawSuspensionData[], + freq: number, + indexOffset: number = 0, +): StandardizedPoint[] { if (!Array.isArray(dataArr)) return []; - + return dataArr.map((p, i) => { let val = 0; - let time = i / freq; + let time = (i + indexOffset) / freq; - if (typeof p === 'number') { + if (typeof p === "number") { val = p; } else { val = Number(p.displacement ?? 0); @@ -56,19 +115,24 @@ export function standardizeData(dataArr: RawSuspensionData[], freq: number): Sta time = Number(p.timebase); } } - + return { time, val }; }); } - // DisplacementPlot - Standardizes raw suspension data and maps it into normalized time-series points. -export function processLinePlotData(dataArr: RawSuspensionData[], freq: number, min?: number, max?: number): NormalizedPoint[] { - const cleanData = standardizeData(dataArr, freq); +export function processLinePlotData( + dataArr: RawSuspensionData[], + freq: number, + min?: number, + max?: number, + indexOffset: number = 0, +): NormalizedPoint[] { + const cleanData = standardizeData(dataArr, freq, indexOffset); - return cleanData.map(point => ({ + return cleanData.map((point) => ({ x: point.time, - y: normalizeToPercentage(point.val, min, max) + y: normalizeToPercentage(point.val, min, max), })); } diff --git a/apps/frontend/app/routes/index.tsx b/apps/frontend/app/routes/index.tsx index 12d940a..ac3cd7e 100644 --- a/apps/frontend/app/routes/index.tsx +++ b/apps/frontend/app/routes/index.tsx @@ -1,6 +1,5 @@ -import { useLoaderData } from "react-router-dom"; -import { redirect } from "react-router"; -import { useState, useEffect } from "react"; +import { redirect, useLoaderData } from "react-router"; +import { useState, useEffect, useMemo } from "react"; import { Sidebar } from "../components/runs/sidebar"; import { MainContent } from "app/components/runs/main-content"; import { RunJson } from "app/types/runs"; @@ -31,20 +30,45 @@ clientLoader.hydrate = true as const; // ---------------------- MAIN PAGE COMPONENT ---------------------- export default function Runs() { - const { runs } = useLoaderData(); - - const [selected, setSelected] = useState([]); + const { runs: initialRuns } = useLoaderData(); + const [runs, setRuns] = useState(initialRuns); + const [selectedRunIds, setSelectedRunIds] = useState([]); const [jsonData, setJsonData] = useState>({}); const [loadingRuns, setLoadingRuns] = useState>(new Set()); + const runsById = useMemo( + () => new Map(runs.map((run) => [run.id, run])), + [runs], + ); + const selected = useMemo( + () => selectedRunIds.map((id) => runsById.get(id)).filter((run): run is Run => Boolean(run)), + [selectedRunIds, runsById], + ); const isCompareMode = selected.length > 1; const loadingJson = loadingRuns.size > 0; + useEffect(() => { + setSelectedRunIds((prevIds) => prevIds.filter((id) => runsById.has(id))); + }, [runsById]); + + useEffect(() => { + const selectedIdSet = new Set(selectedRunIds); + setJsonData((prev) => { + const nextEntries = Object.entries(prev).filter(([id]) => selectedIdSet.has(Number(id))); + return Object.fromEntries(nextEntries); + }); + }, [selectedRunIds]); + // Auth check is handled in the loader above — no need to repeat it here. // ----------------- Fetch JSON for selected runs ----------------- useEffect(() => { const fetchJson = async (run: Run) => { + // Check if already loading or already fetched (double-check inside async) + if (loadingRuns.has(run.id) || jsonData[run.id]) { + return; + } + setLoadingRuns((prev) => new Set(prev).add(run.id)); try { const files = await getFile(run.srcPath); @@ -66,18 +90,25 @@ export default function Runs() { }; selected.forEach((run) => { - if (!jsonData[run.id]) fetchJson(run); + if (!jsonData[run.id] && !loadingRuns.has(run.id)) { + fetchJson(run); + } }); - }, [selected, jsonData]); + }, [selected, jsonData, loadingRuns]); return (
    - + { + setRuns((prevRuns) => + prevRuns.map((r) => (r.id === id ? { ...r, ...updates } : r)) + ); + }} />
    ); diff --git a/packages/database/drizzle/0008_shiny_iceman.sql b/packages/database/drizzle/0008_shiny_iceman.sql new file mode 100644 index 0000000..7da9723 --- /dev/null +++ b/packages/database/drizzle/0008_shiny_iceman.sql @@ -0,0 +1,5 @@ + +ALTER TABLE "runs" ADD COLUMN "lower_bound_idx" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "runs" ADD COLUMN "upper_bound_idx" integer;--> statement-breakpoint +UPDATE "runs" SET "upper_bound_idx" = "length" - 1;--> statement-breakpoint +ALTER TABLE "runs" ALTER COLUMN "upper_bound_idx" SET NOT NULL; \ No newline at end of file diff --git a/packages/database/drizzle/meta/0008_snapshot.json b/packages/database/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..ff2ca43 --- /dev/null +++ b/packages/database/drizzle/meta/0008_snapshot.json @@ -0,0 +1,269 @@ +{ + "id": "698f7546-0c8c-494f-974e-064fd640c18f", + "prevId": "831fe362-0f33-49dd-9ea8-2a29da4f10c7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "front_min": { + "name": "front_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "front_max": { + "name": "front_max", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4096 + }, + "front_travel": { + "name": "front_travel", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 220 + }, + "back_min": { + "name": "back_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "back_max": { + "name": "back_max", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4096 + }, + "back_travel": { + "name": "back_travel", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 220 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.runs": { + "name": "runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "src_path": { + "name": "src_path", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "comments": { + "name": "comments", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "length": { + "name": "length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "location": { + "name": "location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "profile": { + "name": "profile", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lower_bound_idx": { + "name": "lower_bound_idx", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "upper_bound_idx": { + "name": "upper_bound_idx", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "front_freq": { + "name": "front_freq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rear_freq": { + "name": "rear_freq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "runs_profile_profiles_id_fk": { + "name": "runs_profile_profiles_id_fk", + "tableFrom": "runs", + "tableTo": "profiles", + "columnsFrom": [ + "profile" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "runs_src_path_unique": { + "name": "runs_src_path_unique", + "nullsNotDistinct": false, + "columns": [ + "src_path" + ] + } + } + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json index d83c42e..36a6772 100644 --- a/packages/database/drizzle/meta/_journal.json +++ b/packages/database/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1773772326843, "tag": "0007_illegal_killmonger", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1777391980743, + "tag": "0008_shiny_iceman", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/database/scripts/seed.ts b/packages/database/scripts/seed.ts index 50b2abc..cc8b7e0 100644 --- a/packages/database/scripts/seed.ts +++ b/packages/database/scripts/seed.ts @@ -200,18 +200,22 @@ async function seed() { ]; for (const runItem of initialRuns) { + const runLength = Number.isFinite(runItem.length) ? runItem.length : 0; + const upperBoundIdx = Math.max(runLength - 1, 0); await db .insert(runs) .values({ srcPath: runItem.srcPath, title: runItem.title ?? null, comments: runItem.comments ?? null, - length: Number.isFinite(runItem.length) ? runItem.length : 0, + length: runLength, date: runItem.date ? new Date(runItem.date) : new Date(), location: runItem.location ?? null, profile: runItem.profile ?? null, front_freq: runItem.front_freq ?? 250, rear_freq: runItem.rear_freq ?? 250, + lower_bound_idx: 0, + upper_bound_idx: upperBoundIdx, createdAt: runItem.createdAt ? new Date(runItem.createdAt) : new Date(), }) .onConflictDoUpdate({ @@ -219,12 +223,14 @@ async function seed() { set: { title: runItem.title ?? null, comments: runItem.comments ?? null, - length: Number.isFinite(runItem.length) ? runItem.length : 0, + length: runLength, date: runItem.date ? new Date(runItem.date) : new Date(), location: runItem.location ?? null, profile: runItem.profile ?? null, front_freq: runItem.front_freq ?? 250, rear_freq: runItem.rear_freq ?? 250, + lower_bound_idx: 0, + upper_bound_idx: upperBoundIdx, createdAt: runItem.createdAt ? new Date(runItem.createdAt) : new Date(), diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index f685be6..ec12be9 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -47,6 +47,8 @@ export const runs = pgTable("runs", { date: timestamp("date").defaultNow().notNull(), // run date/time location: varchar("location", { length: 255 }), // place or tag for run profile: integer("profile").references(() => profiles.id), // associated profile + lower_bound_idx: integer("lower_bound_idx").notNull().default(0), // inclusive trim start index (default: 0) + upper_bound_idx: integer("upper_bound_idx").notNull(), // inclusive trim end index (set by service layer to length-1) front_freq: integer("front_freq"), // front suspension sample frequency rear_freq: integer("rear_freq"), // rear suspension sample frequency createdAt: timestamp("created_at").defaultNow(), From 07b890362660d256cb3a5e589920a9a031500ed4 Mon Sep 17 00:00:00 2001 From: eoinohal Date: Tue, 28 Apr 2026 20:01:33 +0100 Subject: [PATCH 07/17] Small update import app/hooks/useRunMetrics.ts Not part of the trims commit, but its good to fix this here for DRY principles. --- .../app/components/runs/summary-section.tsx | 56 ------------------- apps/frontend/app/hooks/useRunMetrics.ts | 12 ++-- 2 files changed, 6 insertions(+), 62 deletions(-) diff --git a/apps/frontend/app/components/runs/summary-section.tsx b/apps/frontend/app/components/runs/summary-section.tsx index a8d3b02..a47c23f 100644 --- a/apps/frontend/app/components/runs/summary-section.tsx +++ b/apps/frontend/app/components/runs/summary-section.tsx @@ -1,12 +1,6 @@ import { Run } from "@repo/database"; import { RunJson } from "app/types/runs"; import { SectionHeader } from "app/components/ui/run-elements"; -import { - RawSuspensionData, - normalizeToPercentage, - getProfileFromRun, - trimRawDataByBounds, -} from "app/lib/telemetryUtils"; import { cn } from "app/lib/utils"; import { ArrowUp, ArrowDown } from "lucide-react"; import { useRunMetrics, DYNAMIC_SAG_IDEAL_MIN_FRONT, DYNAMIC_SAG_IDEAL_MAX_FRONT, DYNAMIC_SAG_IDEAL_MIN_REAR, DYNAMIC_SAG_IDEAL_MAX_REAR, BOTTOM_OUT_COUNT_THRESHOLD, BOTTOM_OUT_TRAVEL_MIN } from "app/hooks/useRunMetrics"; @@ -17,56 +11,6 @@ interface SummarySectionProps { isCompareMode: boolean; } -function getNormalizedSuspensionData( - run: Run, - jsonData: Record, - type: 'front' | 'rear', -): number[] | null { - const suspensionData: RawSuspensionData[] | undefined = - jsonData[run.id]?.data?.suspension?.[type === 'front' ? 'front_sus' : 'rear_sus']; - if (!suspensionData || suspensionData.length === 0) return null; - - const trimmedSuspensionData = trimRawDataByBounds(run, suspensionData); - if (trimmedSuspensionData.length === 0) return null; - - const profile = getProfileFromRun(run); - const min = profile ? (type === 'front' ? profile.front_min : profile.back_min) : undefined; - const max = profile ? (type === 'front' ? profile.front_max : profile.back_max) : undefined; - - const normalized = trimmedSuspensionData - .map(p => { - const val = typeof p === 'number' ? p : Number(p.displacement ?? 0); - return normalizeToPercentage(val, min, max); - }) - .filter(v => isFinite(v)); - - return normalized.length === 0 ? null : normalized; -} - -function dynamicSag(norm: number[] | null): number | null { - if (!norm) return null; - const sorted = [...norm].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 === 0 - ? (sorted[mid - 1]! + sorted[mid]!) / 2 - : sorted[mid]!; -} - -function zonePercent(norm: number[] | null, lo: number, hi: number): number | null { - if (!norm) return null; - return (norm.filter(v => v >= lo && v <= hi).length / norm.length) * 100; -} - -function zoneSeconds( - norm: number[] | null, - freq: number | null, - lo: number, - hi: number, -): number | null { - if (!norm || !freq) return null; - return norm.filter(v => v >= lo && v <= hi).length / freq; -} - interface SagCellProps { value: number | null; type: 'front' | 'rear'; diff --git a/apps/frontend/app/hooks/useRunMetrics.ts b/apps/frontend/app/hooks/useRunMetrics.ts index 7b97e82..fcc3ab8 100644 --- a/apps/frontend/app/hooks/useRunMetrics.ts +++ b/apps/frontend/app/hooks/useRunMetrics.ts @@ -11,7 +11,7 @@ export const BOTTOM_OUT_TRAVEL_MIN = 95; export const BOTTOM_OUT_COUNT_THRESHOLD = 3; const OFF_GROUND_TRAVEL_MAX = 5; -function getNormalizedSuspensionData( +export function getNormalizedSuspensionData( run: Run, jsonData: Record, type: 'front' | 'rear', @@ -37,7 +37,7 @@ function getNormalizedSuspensionData( return normalized.length === 0 ? null : normalized; } -function dynamicSag(norm: number[] | null): number | null { +export function dynamicSag(norm: number[] | null): number | null { if (!norm) return null; const sorted = [...norm].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); @@ -46,12 +46,12 @@ function dynamicSag(norm: number[] | null): number | null { : sorted[mid]!; } -function zonePercent(norm: number[] | null, lo: number, hi: number): number | null { +export function zonePercent(norm: number[] | null, lo: number, hi: number): number | null { if (!norm) return null; return (norm.filter(v => v >= lo && v <= hi).length / norm.length) * 100; } -function zoneSeconds( +export function zoneSeconds( norm: number[] | null, freq: number | null, lo: number, @@ -61,12 +61,12 @@ function zoneSeconds( return norm.filter(v => v >= lo && v <= hi).length / freq; } -function maxTravel(norm: number[] | null): number | null { +export function maxTravel(norm: number[] | null): number | null { if (!norm || norm.length === 0) return null; return norm.reduce((max, v) => (v > max ? v : max), -Infinity); } -function countZoneEntries(norm: number[] | null, lo: number, hi: number): number | null { +export function countZoneEntries(norm: number[] | null, lo: number, hi: number): number | null { if (!norm || norm.length === 0) return null; let count = 0; let inZone = norm[0]! >= lo && norm[0]! <= hi; From fa698fc6a08d42460259fee64b83aa8ec236bce8 Mon Sep 17 00:00:00 2001 From: James Moyles Date: Mon, 4 May 2026 22:16:57 +0100 Subject: [PATCH 08/17] chore(plots): remove the unused eslint skip comments --- .../app/components/graphs/domain/ReboundCompressionPlot.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/frontend/app/components/graphs/domain/ReboundCompressionPlot.tsx b/apps/frontend/app/components/graphs/domain/ReboundCompressionPlot.tsx index 57b6ba0..a175986 100644 --- a/apps/frontend/app/components/graphs/domain/ReboundCompressionPlot.tsx +++ b/apps/frontend/app/components/graphs/domain/ReboundCompressionPlot.tsx @@ -280,13 +280,11 @@ export const ReboundCompressionPlot: React.FC = ({ const compressionTrendLines = useMemo( () => buildTrendLines("compression"), - // eslint-disable-next-line react-hooks/exhaustive-deps [prepared, maxSpeed, speedRegion.high], ); const reboundTrendLines = useMemo( () => buildTrendLines("rebound"), - // eslint-disable-next-line react-hooks/exhaustive-deps [prepared, maxSpeed, speedRegion.high], ); From 0f597790aa388ad399d7aac32e99f20bcf55a4d0 Mon Sep 17 00:00:00 2001 From: James Moyles Date: Mon, 4 May 2026 22:27:36 +0100 Subject: [PATCH 09/17] feat: resolve merge conflicts --- .../app/components/graphs/base/LinePlot.tsx | 68 +------------------ .../graphs/domain/DisplacementPlot.tsx | 10 --- .../app/components/runs/chart-sections.tsx | 6 +- .../app/components/runs/main-content.tsx | 9 --- apps/frontend/app/lib/telemetryUtils.ts | 20 ------ 5 files changed, 2 insertions(+), 111 deletions(-) diff --git a/apps/frontend/app/components/graphs/base/LinePlot.tsx b/apps/frontend/app/components/graphs/base/LinePlot.tsx index 989de81..d7a567e 100644 --- a/apps/frontend/app/components/graphs/base/LinePlot.tsx +++ b/apps/frontend/app/components/graphs/base/LinePlot.tsx @@ -33,19 +33,13 @@ export const LinePlot: React.FC = React.memo(({ const clipPathId = useId(); const [width, setWidth] = useState(0); -<<<<<<< HEAD -======= // Performance / Downsampling State ->>>>>>> 424d7dc57db4bca2669a873290a65921c9fec387 const innerWidthForDownsample = Math.max(0, width); const fullDataRef = useRef([]); const rafRef = useRef(null); const latestDomainRef = useRef<[number, number] | null>(null); const selectedDomainRef = useRef<[number, number] | null>(null); -<<<<<<< HEAD - // Threshold for downsampling -======= // Brush / Trim State const brushRef = useRef | null>(null); const brushGroupRef = useRef | null>(null); @@ -53,8 +47,7 @@ export const LinePlot: React.FC = React.memo(({ const isApplyingExternalSelectionRef = useRef(false); const latestBrushSelectionRef = useRef<[number, number] | null>(null); - // Threshold for downsampling ->>>>>>> 424d7dc57db4bca2669a873290a65921c9fec387 + // Threshold for downsampling const downsampleThreshold = Math.max(500, Math.floor(innerWidthForDownsample)); const focusData = useMemo( () => data.map((series) => lttbDownsample(series, downsampleThreshold)), @@ -144,16 +137,10 @@ export const LinePlot: React.FC = React.memo(({ // Brushed interaction handler (Downsampling version) const brushed = (event: d3.D3BrushEvent) => { if (event.sourceEvent?.type === "zoom") return; -<<<<<<< HEAD - - if (event.selection) { - latestDomainRef.current = (event.selection as [number, number]).map(x2.invert, x2) as [number, number]; -======= const s = event.selection as [number, number] | null; if (s) { latestDomainRef.current = s.map(x2.invert, x2) as [number, number]; ->>>>>>> 424d7dc57db4bca2669a873290a65921c9fec387 } else { latestDomainRef.current = null; } @@ -183,16 +170,6 @@ export const LinePlot: React.FC = React.memo(({ }; -<<<<<<< HEAD - // Clip path (scoped per component instance) - svg - .selectAll("defs") - .data([null]) - .join("defs") - .selectAll("clipPath") - .data([null]) - .join("clipPath") -======= // Emit selection to React only when brushing ends const brushEnded = (event: d3.D3BrushEvent) => { if (isApplyingExternalSelectionRef.current) return; @@ -213,7 +190,6 @@ export const LinePlot: React.FC = React.memo(({ // Clip path svg.selectAll("defs").data([null]).join("defs") .selectAll("clipPath").data([null]).join("clipPath") ->>>>>>> 424d7dc57db4bca2669a873290a65921c9fec387 .attr("id", clipPathId) .selectAll("rect") .data([null]) @@ -221,27 +197,15 @@ export const LinePlot: React.FC = React.memo(({ .attr("width", innerWidth) .attr("height", innerHeight); -<<<<<<< HEAD - // Focus group (main chart) - const focus = svg - .selectAll(".focus") -======= // Focus group const focus = svg.selectAll(".focus") ->>>>>>> 424d7dc57db4bca2669a873290a65921c9fec387 .data([null]) .join("g") .attr("class", "focus") .attr("transform", `translate(${margin.left},${margin.top})`); -<<<<<<< HEAD - // Context group (brush area) - const context = svg - .selectAll(".context") -======= // Context group const context = svg.selectAll(".context") ->>>>>>> 424d7dc57db4bca2669a873290a65921c9fec387 .data([null]) .join("g") .attr("class", "context") @@ -273,14 +237,8 @@ export const LinePlot: React.FC = React.memo(({ .attr("transform", `translate(0,${innerHeight2})`) .call(d3.axisBottom(x2) as d3.Axis); -<<<<<<< HEAD - // Line generators with curve smoothing - const lineGenerator = d3 - .line() -======= // Line generators const lineGenerator = d3.line() ->>>>>>> 424d7dc57db4bca2669a873290a65921c9fec387 .x((d) => x(d.x)) .y((d) => y(d.y)) .curve(d3.curveMonotoneX); @@ -347,20 +305,10 @@ export const LinePlot: React.FC = React.memo(({ .attr("d", lineGenerator2); // Brush -<<<<<<< HEAD - const brush = d3 - .brushX() - .extent([ - [0, 0], - [innerWidth, innerHeight2], - ]) - .on("brush end", brushed); -======= const brush = d3.brushX() .extent([[0, 0], [innerWidth, innerHeight2]]) .on("brush", brushed) .on("end", brushEnded); ->>>>>>> 424d7dc57db4bca2669a873290a65921c9fec387 context .selectAll(".brush") @@ -371,15 +319,6 @@ export const LinePlot: React.FC = React.memo(({ .selectAll(".selection") .attr("class", "selection fill-muted-foreground/30 stroke-border"); -<<<<<<< HEAD - // Keep brush UI in sync - context.select(".brush").call( - brush.move, - selectedDomainRef.current - ? (selectedDomainRef.current.map(x2) as [number, number]) - : null, - ); -======= brushRef.current = brush; brushGroupRef.current = context.select(".brush"); @@ -394,7 +333,6 @@ export const LinePlot: React.FC = React.memo(({ latestBrushSelectionRef.current = brushSelection; selectedDomainRef.current = brushSelection; } ->>>>>>> 424d7dc57db4bca2669a873290a65921c9fec387 return () => { if (rafRef.current !== null) { @@ -453,8 +391,4 @@ export const LinePlot: React.FC = React.memo(({ )} ); -<<<<<<< HEAD -}; -======= }); ->>>>>>> 424d7dc57db4bca2669a873290a65921c9fec387 diff --git a/apps/frontend/app/components/graphs/domain/DisplacementPlot.tsx b/apps/frontend/app/components/graphs/domain/DisplacementPlot.tsx index f30b378..e18124f 100644 --- a/apps/frontend/app/components/graphs/domain/DisplacementPlot.tsx +++ b/apps/frontend/app/components/graphs/domain/DisplacementPlot.tsx @@ -31,12 +31,9 @@ interface DisplacementPlotProps { title?: string; series: SeriesConfig[]; height?: number; -<<<<<<< HEAD highlight?: LineHighlight | null; -======= brushSelection?: [number, number] | null; onBrushSelection?: (selection: [number, number] | null) => void; ->>>>>>> 424d7dc57db4bca2669a873290a65921c9fec387 } interface LineMetadata { @@ -49,12 +46,9 @@ export const DisplacementPlot: React.FC = React.memo(({ title = "Displacement", series, height = 300, -<<<<<<< HEAD highlight, -======= brushSelection, onBrushSelection, ->>>>>>> 424d7dc57db4bca2669a873290a65921c9fec387 }) => { const isMobile = useIsMobile(); @@ -184,8 +178,4 @@ export const DisplacementPlot: React.FC = React.memo(({ ); -<<<<<<< HEAD -}; -======= }); ->>>>>>> 424d7dc57db4bca2669a873290a65921c9fec387 diff --git a/apps/frontend/app/components/runs/chart-sections.tsx b/apps/frontend/app/components/runs/chart-sections.tsx index 0f73028..d17fbb8 100644 --- a/apps/frontend/app/components/runs/chart-sections.tsx +++ b/apps/frontend/app/components/runs/chart-sections.tsx @@ -9,17 +9,13 @@ import { ReboundCompressionPlot } from "app/components/graphs/domain/ReboundComp import { SectionHeader } from "app/components/ui/run-elements"; import { Run } from "@repo/database"; import { getSeriesColor } from "app/lib/graphColors"; -<<<<<<< HEAD -import { getProfileFromRun } from "app/lib/telemetryUtils"; -import { useState } from "react"; -======= import { getProfileFromRun, RawSuspensionData, resolveTrimBounds, trimRawDataByBounds, } from "app/lib/telemetryUtils"; ->>>>>>> 424d7dc57db4bca2669a873290a65921c9fec387 +import { useState } from "react"; interface ChartSectionProps { selected: Run[]; diff --git a/apps/frontend/app/components/runs/main-content.tsx b/apps/frontend/app/components/runs/main-content.tsx index 1cefd1b..44d9af3 100644 --- a/apps/frontend/app/components/runs/main-content.tsx +++ b/apps/frontend/app/components/runs/main-content.tsx @@ -144,14 +144,6 @@ export function MainContent({

    {isCompareMode ? "Run Comparison" : runs[0]?.title || "Run Details"}

    -<<<<<<< HEAD - -=======
    {!isCompareMode && (
    ->>>>>>> 424d7dc57db4bca2669a873290a65921c9fec387 {/* Metadata section (extracted to RunsMetadata component) */} diff --git a/apps/frontend/app/lib/telemetryUtils.ts b/apps/frontend/app/lib/telemetryUtils.ts index 707ac39..f4bfb5e 100644 --- a/apps/frontend/app/lib/telemetryUtils.ts +++ b/apps/frontend/app/lib/telemetryUtils.ts @@ -57,13 +57,6 @@ export function getProfileFromRun(run: Run): Profile | null { return profileCandidate as Profile; } -<<<<<<< HEAD -export function normalizeToPercentage( - val: number, - min?: number, - max?: number, -): number { -======= export function resolveTrimBounds(run: Run, sampleLength: number): RunTrimBounds | null { if (!Number.isFinite(sampleLength) || sampleLength <= 0) { return null; @@ -107,7 +100,6 @@ export function trimRawDataByBounds(run: Run, dataArr: T[]): T[] { } export function normalizeToPercentage(val: number, min?: number, max?: number): number { ->>>>>>> 424d7dc57db4bca2669a873290a65921c9fec387 // If caller provides a valid min/max range, use it; otherwise fall back to 0..MAX_TRAVEL const hasValidRange = typeof min === "number" && @@ -129,20 +121,13 @@ export function normalizeToPercentage(val: number, min?: number, max?: number): export function standardizeData( dataArr: RawSuspensionData[], freq: number, -<<<<<<< HEAD -======= indexOffset: number = 0, ->>>>>>> 424d7dc57db4bca2669a873290a65921c9fec387 ): StandardizedPoint[] { if (!Array.isArray(dataArr)) return []; return dataArr.map((p, i) => { let val = 0; -<<<<<<< HEAD - let time = i / freq; -======= let time = (i + indexOffset) / freq; ->>>>>>> 424d7dc57db4bca2669a873290a65921c9fec387 if (typeof p === "number") { val = p; @@ -163,14 +148,9 @@ export function processLinePlotData( freq: number, min?: number, max?: number, -<<<<<<< HEAD -): NormalizedPoint[] { - const cleanData = standardizeData(dataArr, freq); -======= indexOffset: number = 0, ): NormalizedPoint[] { const cleanData = standardizeData(dataArr, freq, indexOffset); ->>>>>>> 424d7dc57db4bca2669a873290a65921c9fec387 return cleanData.map((point) => ({ x: point.time, From f19f9fc4bee532064b94a61b9959ee11516ef8fc Mon Sep 17 00:00:00 2001 From: eoinohal Date: Wed, 13 May 2026 16:49:02 +0100 Subject: [PATCH 10/17] 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 11/17] 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 12/17] 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 13/17] 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), )} From 2a2c33c44bc66ae4e7f81e58d6f8ab343bf61a1d Mon Sep 17 00:00:00 2001 From: William Ellis Date: Sat, 16 May 2026 09:38:26 +0100 Subject: [PATCH 14/17] feat: add cursor-pointer thoughtout where it is nessasarry --- apps/frontend/app/components/profiles/profilePopUp.tsx | 2 +- apps/frontend/app/components/runs/main-content.tsx | 4 ++-- apps/frontend/app/components/runs/runs-metadata.tsx | 2 +- apps/frontend/app/components/runs/sidebar.tsx | 4 ++-- apps/frontend/app/components/runs/trimPopup.tsx | 6 +++--- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/frontend/app/components/profiles/profilePopUp.tsx b/apps/frontend/app/components/profiles/profilePopUp.tsx index 74f82f0..ef08cf8 100644 --- a/apps/frontend/app/components/profiles/profilePopUp.tsx +++ b/apps/frontend/app/components/profiles/profilePopUp.tsx @@ -36,7 +36,7 @@ export const ProfilePopup = ({ isOpen, onClose, selected, onProfileUpdate }: Pro
    )} @@ -266,7 +266,7 @@ export function Sidebar({ runs, selectedRunIds, setSelectedRunIds }: SidebarProp exit={{ x: -50, opacity: 0 }} transition={{ duration: 0.3 }} onClick={() => setCollapsed(false)} - className="fixed top-4 left-4 p-3 bg-slate-800 text-white rounded-full hover:bg-slate-700 z-50 shadow-lg" + className="cursor-pointer fixed top-4 left-4 p-3 bg-slate-800 text-white rounded-full hover:bg-slate-700 z-50 shadow-lg" > diff --git a/apps/frontend/app/components/runs/trimPopup.tsx b/apps/frontend/app/components/runs/trimPopup.tsx index fd9ecf9..6d45146 100644 --- a/apps/frontend/app/components/runs/trimPopup.tsx +++ b/apps/frontend/app/components/runs/trimPopup.tsx @@ -204,7 +204,7 @@ export function TrimPopup({ isOpen, run, runJson, onClose, onSave }: TrimPopupPr
    )} @@ -266,7 +266,7 @@ export function Sidebar({ runs, selectedRunIds, setSelectedRunIds }: SidebarProp exit={{ x: -50, opacity: 0 }} transition={{ duration: 0.3 }} onClick={() => setCollapsed(false)} - className="cursor-pointer fixed top-4 left-4 p-3 bg-slate-800 text-white rounded-full hover:bg-slate-700 z-50 shadow-lg" + className="fixed top-4 left-4 p-3 bg-slate-800 text-white rounded-full hover:bg-slate-700 z-50 shadow-lg" > diff --git a/apps/frontend/app/components/runs/trimPopup.tsx b/apps/frontend/app/components/runs/trimPopup.tsx index 6d45146..fd9ecf9 100644 --- a/apps/frontend/app/components/runs/trimPopup.tsx +++ b/apps/frontend/app/components/runs/trimPopup.tsx @@ -204,7 +204,7 @@ export function TrimPopup({ isOpen, run, runJson, onClose, onSave }: TrimPopupPr
    diff --git a/apps/frontend/app/tailwind.css b/apps/frontend/app/tailwind.css index c0a006b..5e8ffee 100644 --- a/apps/frontend/app/tailwind.css +++ b/apps/frontend/app/tailwind.css @@ -18,6 +18,13 @@ --muted-foreground: 240 5% 64.9%; --border: 240 3.7% 15.9%; } + + button:not(:disabled) { + @apply cursor-pointer; + } + button:disabled { + @apply cursor-not-allowed; + } } @theme { From 7da2f0033ac8dd4384318a2b5f7f56ba41a356e7 Mon Sep 17 00:00:00 2001 From: William Ellis Date: Sat, 16 May 2026 10:28:49 +0100 Subject: [PATCH 16/17] refactor: move summary section to the top feat: add recommendations section to inform the user of the tooltips --- .../app/components/runs/main-content.tsx | 12 +-- .../app/components/runs/summary-section.tsx | 97 +++++++++++++++++++ 2 files changed, 102 insertions(+), 7 deletions(-) diff --git a/apps/frontend/app/components/runs/main-content.tsx b/apps/frontend/app/components/runs/main-content.tsx index 44d9af3..03bea2f 100644 --- a/apps/frontend/app/components/runs/main-content.tsx +++ b/apps/frontend/app/components/runs/main-content.tsx @@ -169,17 +169,15 @@ export function MainContent({ jsonData={jsonData} onOpenComments={(r) => { setCommentsRun(r); setCommentsOpen(true); }} /> + - - - - {/* Always visible: Histogram */} - - - )}
    diff --git a/apps/frontend/app/components/runs/summary-section.tsx b/apps/frontend/app/components/runs/summary-section.tsx index a47c23f..7e90072 100644 --- a/apps/frontend/app/components/runs/summary-section.tsx +++ b/apps/frontend/app/components/runs/summary-section.tsx @@ -11,6 +11,33 @@ interface SummarySectionProps { isCompareMode: boolean; } +function getComponentRecommendations( + sag: number | null, + sagMin: number, + sagMax: number, + sagInRange: boolean | null, + bottomOutCount: number | null, + maxTravel: number | null, +): string[] { + const items: string[] = []; + + if (sag !== null) { + if (sag < sagMin) items.push(`Reduce pressure (sag too low: ${sag.toFixed(1)}%)`); + else if (sag > sagMax) items.push(`Increase pressure (sag too high: ${sag.toFixed(1)}%)`); + } + + if (bottomOutCount !== null) { + const sagWarning = sagInRange === false ? 'Sag not in range — suggestions may be inaccurate. ' : ''; + if (bottomOutCount > BOTTOM_OUT_COUNT_THRESHOLD) { + items.push(`${sagWarning}Add a volume spacer (${bottomOutCount} bottom-outs)`); + } else if (bottomOutCount === 0 && maxTravel !== null && maxTravel < BOTTOM_OUT_TRAVEL_MIN) { + items.push(`${sagWarning}Remove volume spacer (never reached travel)`); + } + } + + return items; +} + interface SagCellProps { value: number | null; type: 'front' | 'rear'; @@ -290,6 +317,75 @@ function MobileSummaryTable({ selected, jsonData }: MobileSummaryTableProps) { ); } +interface RunRecommendationsBlockProps { + run: Run; + jsonData: Record; +} + +function ComponentRecommendationList({ items }: { items: string[] }) { + const display = items.length > 0 ? items : ['No issues detected.']; + return ( +
      + {display.map((item, i) => ( +
    • + + {item} +
    • + ))} +
    + ); +} + +function RunRecommendationsBlock({ run, jsonData }: RunRecommendationsBlockProps) { + const metrics = useRunMetrics(run, jsonData); + const title = run.title || `Run ${run.id}`; + + const forkItems = getComponentRecommendations( + metrics.frontSag, DYNAMIC_SAG_IDEAL_MIN_FRONT, DYNAMIC_SAG_IDEAL_MAX_FRONT, + metrics.frontSagInRange, metrics.frontBottomOutCount, metrics.frontMaxTravel, + ); + const shockItems = getComponentRecommendations( + metrics.rearSag, DYNAMIC_SAG_IDEAL_MIN_REAR, DYNAMIC_SAG_IDEAL_MAX_REAR, + metrics.rearSagInRange, metrics.rearBottomOutCount, metrics.rearMaxTravel, + ); + + return ( +
    +

    {title}

    +
    +
    +

    Fork

    + +
    +
    +

    Shock

    + +
    +
    +
    + ); +} + +interface RecommendationsSummaryProps { + selected: Run[]; + jsonData: Record; +} + +function RecommendationsSummary({ selected, jsonData }: RecommendationsSummaryProps) { + if (selected.length === 0) return null; + + return ( +
    +

    Recommendations

    +
    + {selected.map((run) => ( + + ))} +
    +
    + ); +} + export function SummarySection({ selected, jsonData, @@ -306,6 +402,7 @@ export function SummarySection({
    +
    ); } From 25222411a25767a62536233f3c33a38c17122aa1 Mon Sep 17 00:00:00 2001 From: William Ellis Date: Sat, 16 May 2026 10:38:14 +0100 Subject: [PATCH 17/17] fix code review changes --- .../app/components/runs/summary-section.tsx | 51 +++++++------- apps/frontend/app/hooks/useRunMetrics.ts | 70 ++++++++++--------- 2 files changed, 64 insertions(+), 57 deletions(-) diff --git a/apps/frontend/app/components/runs/summary-section.tsx b/apps/frontend/app/components/runs/summary-section.tsx index 7e90072..9192ff1 100644 --- a/apps/frontend/app/components/runs/summary-section.tsx +++ b/apps/frontend/app/components/runs/summary-section.tsx @@ -1,9 +1,10 @@ +import { useMemo } from "react"; import { Run } from "@repo/database"; import { RunJson } from "app/types/runs"; import { SectionHeader } from "app/components/ui/run-elements"; import { cn } from "app/lib/utils"; import { ArrowUp, ArrowDown } from "lucide-react"; -import { useRunMetrics, DYNAMIC_SAG_IDEAL_MIN_FRONT, DYNAMIC_SAG_IDEAL_MAX_FRONT, DYNAMIC_SAG_IDEAL_MIN_REAR, DYNAMIC_SAG_IDEAL_MAX_REAR, BOTTOM_OUT_COUNT_THRESHOLD, BOTTOM_OUT_TRAVEL_MIN } from "app/hooks/useRunMetrics"; +import { computeRunMetrics, type RunMetrics, DYNAMIC_SAG_IDEAL_MIN_FRONT, DYNAMIC_SAG_IDEAL_MAX_FRONT, DYNAMIC_SAG_IDEAL_MIN_REAR, DYNAMIC_SAG_IDEAL_MAX_REAR, BOTTOM_OUT_COUNT_THRESHOLD, BOTTOM_OUT_TRAVEL_MIN } from "app/hooks/useRunMetrics"; interface SummarySectionProps { selected: Run[]; @@ -127,11 +128,10 @@ function TravelZoneCell({ value, seconds }: TravelZoneCellProps) { interface RunSummaryRowProps { run: Run; - jsonData: Record; + metrics: RunMetrics; } -function RunSummaryRow({ run, jsonData }: RunSummaryRowProps) { - const metrics = useRunMetrics(run, jsonData); +function RunSummaryRow({ run, metrics }: RunSummaryRowProps) { return ( @@ -164,10 +164,10 @@ function RunSummaryRow({ run, jsonData }: RunSummaryRowProps) { interface SummaryTableProps { selected: Run[]; - jsonData: Record; + metricsMap: Map; } -function SummaryTable({ selected, jsonData }: SummaryTableProps) { +function SummaryTable({ selected, metricsMap }: SummaryTableProps) { if (!selected || selected.length === 0) { return (
    No runs selected.
    @@ -227,7 +227,7 @@ function SummaryTable({ selected, jsonData }: SummaryTableProps) { {selected.map((run) => ( - + ))} @@ -237,12 +237,11 @@ function SummaryTable({ selected, jsonData }: SummaryTableProps) { interface MobileRunSummaryRowProps { run: Run; - jsonData: Record; + metrics: RunMetrics; type: 'fork' | 'shock'; } -function MobileRunSummaryRow({ run, jsonData, type }: MobileRunSummaryRowProps) { - const metrics = useRunMetrics(run, jsonData); +function MobileRunSummaryRow({ run, metrics, type }: MobileRunSummaryRowProps) { const isFork = type === 'fork'; @@ -273,10 +272,10 @@ function MobileRunSummaryRow({ run, jsonData, type }: MobileRunSummaryRowProps) interface MobileSummaryTableProps { selected: Run[]; - jsonData: Record; + metricsMap: Map; } -function MobileSummaryTable({ selected, jsonData }: MobileSummaryTableProps) { +function MobileSummaryTable({ selected, metricsMap }: MobileSummaryTableProps) { if (!selected || selected.length === 0) { return
    No runs selected.
    ; } @@ -296,7 +295,7 @@ function MobileSummaryTable({ selected, jsonData }: MobileSummaryTableProps) { {selected.map((run) => ( - + ))} @@ -319,15 +318,15 @@ function MobileSummaryTable({ selected, jsonData }: MobileSummaryTableProps) { interface RunRecommendationsBlockProps { run: Run; - jsonData: Record; + metrics: RunMetrics; } function ComponentRecommendationList({ items }: { items: string[] }) { const display = items.length > 0 ? items : ['No issues detected.']; return (
      - {display.map((item, i) => ( -
    • + {display.map((item) => ( +
    • {item}
    • @@ -336,8 +335,7 @@ function ComponentRecommendationList({ items }: { items: string[] }) { ); } -function RunRecommendationsBlock({ run, jsonData }: RunRecommendationsBlockProps) { - const metrics = useRunMetrics(run, jsonData); +function RunRecommendationsBlock({ run, metrics }: RunRecommendationsBlockProps) { const title = run.title || `Run ${run.id}`; const forkItems = getComponentRecommendations( @@ -368,10 +366,10 @@ function RunRecommendationsBlock({ run, jsonData }: RunRecommendationsBlockProps interface RecommendationsSummaryProps { selected: Run[]; - jsonData: Record; + metricsMap: Map; } -function RecommendationsSummary({ selected, jsonData }: RecommendationsSummaryProps) { +function RecommendationsSummary({ selected, metricsMap }: RecommendationsSummaryProps) { if (selected.length === 0) return null; return ( @@ -379,7 +377,7 @@ function RecommendationsSummary({ selected, jsonData }: RecommendationsSummaryPr

      Recommendations

      {selected.map((run) => ( - + ))}
    @@ -391,18 +389,23 @@ export function SummarySection({ jsonData, isCompareMode, }: SummarySectionProps) { + const metricsMap = useMemo( + () => new Map(selected.map((run) => [run.id, computeRunMetrics(run, jsonData)])), + [selected, jsonData], + ); + return (
    {isCompareMode ? "Comparison Summary" : "Summary"} {/* Mobile: tabbed Fork / Shock view */}
    - +
    {/* Desktop: full table */}
    - +
    - +
    ); } diff --git a/apps/frontend/app/hooks/useRunMetrics.ts b/apps/frontend/app/hooks/useRunMetrics.ts index fcc3ab8..8aa4f69 100644 --- a/apps/frontend/app/hooks/useRunMetrics.ts +++ b/apps/frontend/app/hooks/useRunMetrics.ts @@ -87,37 +87,41 @@ function calculateRebound(_run: Run, _jsonData: Record, _type: return 0; } -export function useRunMetrics(run: Run, jsonData: Record) { - return useMemo(() => { - const frontNorm = getNormalizedSuspensionData(run, jsonData, 'front'); - const rearNorm = getNormalizedSuspensionData(run, jsonData, 'rear'); - const frontFreq = run.front_freq ?? null; - const rearFreq = run.rear_freq ?? null; - - const frontSag = dynamicSag(frontNorm); - const rearSag = dynamicSag(rearNorm); - - return { - frontSag, - rearSag, - frontSagInRange: frontSag === null ? null : frontSag >= DYNAMIC_SAG_IDEAL_MIN_FRONT && frontSag <= DYNAMIC_SAG_IDEAL_MAX_FRONT, - rearSagInRange: rearSag === null ? null : rearSag >= DYNAMIC_SAG_IDEAL_MIN_REAR && rearSag <= DYNAMIC_SAG_IDEAL_MAX_REAR, - frontBottomOutPct: zonePercent(frontNorm, BOTTOM_OUT_TRAVEL_MIN, 100), - frontBottomOutSec: zoneSeconds(frontNorm, frontFreq, BOTTOM_OUT_TRAVEL_MIN, 100), - frontBottomOutCount: countZoneEntries(frontNorm, BOTTOM_OUT_TRAVEL_MIN, 100), - frontMaxTravel: maxTravel(frontNorm), - frontOffGroundPct: zonePercent(frontNorm, 0, OFF_GROUND_TRAVEL_MAX), - frontOffGroundSec: zoneSeconds(frontNorm, frontFreq, 0, OFF_GROUND_TRAVEL_MAX), - rearBottomOutPct: zonePercent(rearNorm, BOTTOM_OUT_TRAVEL_MIN, 100), - rearBottomOutSec: zoneSeconds(rearNorm, rearFreq, BOTTOM_OUT_TRAVEL_MIN, 100), - rearBottomOutCount: countZoneEntries(rearNorm, BOTTOM_OUT_TRAVEL_MIN, 100), - rearMaxTravel: maxTravel(rearNorm), - rearOffGroundPct: zonePercent(rearNorm, 0, OFF_GROUND_TRAVEL_MAX), - rearOffGroundSec: zoneSeconds(rearNorm, rearFreq, 0, OFF_GROUND_TRAVEL_MAX), - frontCompression: calculateCompression(run, jsonData, 'front'), - rearCompression: calculateCompression(run, jsonData, 'rear'), - frontRebound: calculateRebound(run, jsonData, 'front'), - rearRebound: calculateRebound(run, jsonData, 'rear'), - }; - }, [run, jsonData]); +export function computeRunMetrics(run: Run, jsonData: Record) { + const frontNorm = getNormalizedSuspensionData(run, jsonData, 'front'); + const rearNorm = getNormalizedSuspensionData(run, jsonData, 'rear'); + const frontFreq = run.front_freq ?? null; + const rearFreq = run.rear_freq ?? null; + + const frontSag = dynamicSag(frontNorm); + const rearSag = dynamicSag(rearNorm); + + return { + frontSag, + rearSag, + frontSagInRange: frontSag === null ? null : frontSag >= DYNAMIC_SAG_IDEAL_MIN_FRONT && frontSag <= DYNAMIC_SAG_IDEAL_MAX_FRONT, + rearSagInRange: rearSag === null ? null : rearSag >= DYNAMIC_SAG_IDEAL_MIN_REAR && rearSag <= DYNAMIC_SAG_IDEAL_MAX_REAR, + frontBottomOutPct: zonePercent(frontNorm, BOTTOM_OUT_TRAVEL_MIN, 100), + frontBottomOutSec: zoneSeconds(frontNorm, frontFreq, BOTTOM_OUT_TRAVEL_MIN, 100), + frontBottomOutCount: countZoneEntries(frontNorm, BOTTOM_OUT_TRAVEL_MIN, 100), + frontMaxTravel: maxTravel(frontNorm), + frontOffGroundPct: zonePercent(frontNorm, 0, OFF_GROUND_TRAVEL_MAX), + frontOffGroundSec: zoneSeconds(frontNorm, frontFreq, 0, OFF_GROUND_TRAVEL_MAX), + rearBottomOutPct: zonePercent(rearNorm, BOTTOM_OUT_TRAVEL_MIN, 100), + rearBottomOutSec: zoneSeconds(rearNorm, rearFreq, BOTTOM_OUT_TRAVEL_MIN, 100), + rearBottomOutCount: countZoneEntries(rearNorm, BOTTOM_OUT_TRAVEL_MIN, 100), + rearMaxTravel: maxTravel(rearNorm), + rearOffGroundPct: zonePercent(rearNorm, 0, OFF_GROUND_TRAVEL_MAX), + rearOffGroundSec: zoneSeconds(rearNorm, rearFreq, 0, OFF_GROUND_TRAVEL_MAX), + frontCompression: calculateCompression(run, jsonData, 'front'), + rearCompression: calculateCompression(run, jsonData, 'rear'), + frontRebound: calculateRebound(run, jsonData, 'front'), + rearRebound: calculateRebound(run, jsonData, 'rear'), + }; +} + +export type RunMetrics = ReturnType; + +export function useRunMetrics(run: Run, jsonData: Record): RunMetrics { + return useMemo(() => computeRunMetrics(run, jsonData), [run, jsonData]); }