diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 0b8efec..ce7d600 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -42,7 +42,6 @@ "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/vite": "^4.1.11", "@tanstack/react-query": "^5.83.1", - "bun": "^1.3.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -1613,149 +1612,6 @@ "node": ">= 18" } }, - "node_modules/@oven/bun-darwin-aarch64": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.7.tgz", - "integrity": "sha512-Mh78f4B+vNTOhFpI7RWHRWDqSKTnFXj/MauRx7I/GmNwEfw56sUx98gWRwXyF4lkW+9VNU+33wuw6E+M22W66w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-darwin-x64": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.7.tgz", - "integrity": "sha512-dFfKdSVz6Ois5zjEJboUC7igcYAVd+c//ajotd0L6WUQAKQrHMVq/+6LjOj/0zjC6VPFNGWzeF8erymNo1y0Jw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-darwin-x64-baseline": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.7.tgz", - "integrity": "sha512-bUND1aQoTCfIL+idALT7FWtuX59ltOIRo954c7p/JkESbSIJ01jY06BSNVbkGk8RQM19v/7qiqZZqi4NyO4Utw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-linux-aarch64": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.7.tgz", - "integrity": "sha512-m03OtzEs+/RkWtk6tBf8yw0GW4P8ajfzTXnTt984tQBgkMubGQYUyUnFasWgr3mD2820LhkVjhYeBf1rkz/biQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-aarch64-musl": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.7.tgz", - "integrity": "sha512-QDxrROdUnC1d/uoilXtUeFHaLhYdRN7dRIzw/Iqj/vrrhnkA6VS+HYoCWtyyVvci/K+JrPmDwxOWlSRpmV4INA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.7.tgz", - "integrity": "sha512-uttKQ/eIRVGc4uBtLRqmQqXGf57/dmQaF0AEd37RQNRRRd1P/VYnFMiMcVaot3HJ6IFjHjGtcPO9ekT49LxBYQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-baseline": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.7.tgz", - "integrity": "sha512-Jlb/AcrIFU3QDeR3EL4UVT1CIKqnLJDgbU+R0k/+NaSWMrBEpZV+gJJT5L1cmEKTNhU/d+c7hudxkjtqA7XXqA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-musl": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.7.tgz", - "integrity": "sha512-aK8fvkCosrHRG3CNdVqMom1C8Rj3XkqZp0ZFSBXgaXlKP22RkxlEE9tS7OmSq9yVgEk6euTB3dW4NFo/jlXqeg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-musl-baseline": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.7.tgz", - "integrity": "sha512-lySQQ7zJJsoa5hQH+PE5bQyQaTI8G2Erszhu4iQuDtsocwy3zSxjB6TxGWTd4HmetPl9aRvg3nb2KR8RVAd7ug==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-windows-x64": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.7.tgz", - "integrity": "sha512-3QdIGdSn3fkssCq/vPjtPLAQxo+eMUzcwJedn1c5mXDy1AoisjhoxhWnbVl8+uk+wt9N6JUPdISoe0N4OdwXfg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@oven/bun-windows-x64-baseline": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.7.tgz", - "integrity": "sha512-wMgELfW5vFceh4qEOYb5iV5TjrjjnBJzE383ixA3kqGKzaubksSxNc11eZhS0ptcJ5a0UjN5hfbMh6sYoh+cRQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@phosphor-icons/react": { "version": "2.1.10", "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz", @@ -5787,39 +5643,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/bun": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/bun/-/bun-1.3.7.tgz", - "integrity": "sha512-ha86NG8WiAXYR7eQw/9S+7V7Lo8KfD36XutWJNS1VndzaipWS0QIen5n3K9MT3PpP/sdGmmHjhkrU0sCM2lGGQ==", - "cpu": [ - "arm64", - "x64" - ], - "hasInstallScript": true, - "license": "MIT", - "os": [ - "darwin", - "linux", - "win32" - ], - "bin": { - "bun": "bin/bun.exe", - "bunx": "bin/bunx.exe" - }, - "optionalDependencies": { - "@oven/bun-darwin-aarch64": "1.3.7", - "@oven/bun-darwin-x64": "1.3.7", - "@oven/bun-darwin-x64-baseline": "1.3.7", - "@oven/bun-linux-aarch64": "1.3.7", - "@oven/bun-linux-aarch64-musl": "1.3.7", - "@oven/bun-linux-x64": "1.3.7", - "@oven/bun-linux-x64-baseline": "1.3.7", - "@oven/bun-linux-x64-musl": "1.3.7", - "@oven/bun-linux-x64-musl-baseline": "1.3.7", - "@oven/bun-windows-x64": "1.3.7", - "@oven/bun-windows-x64-baseline": "1.3.7" - } - }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", diff --git a/apps/web/src/components/ShotCharts.tsx b/apps/web/src/components/ShotCharts.tsx new file mode 100644 index 0000000..66baae5 --- /dev/null +++ b/apps/web/src/components/ShotCharts.tsx @@ -0,0 +1,371 @@ +import React from 'react' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { ChartLine, Play } from '@phosphor-icons/react' +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, + ReferenceArea, + ReferenceLine, + Customized +} from 'recharts' +import { + CHART_COLORS, + STAGE_COLORS, + STAGE_BORDER_COLORS, + STAGE_TEXT_COLORS_LIGHT, + STAGE_TEXT_COLORS_DARK, + COMPARISON_COLORS +} from '@/lib/chartStyles' + +interface ChartDataPoint { + time: number + pressure?: number + flow?: number + weight?: number + gravimetricFlow?: number + stage?: string + targetPressure?: number + targetFlow?: number +} + +interface StageRange { + name: string + startTime: number + endTime: number + colorIndex: number +} + +interface ProfileTargetPoint { + time: number + target_pressure?: number + target_flow?: number +} + +// Custom tooltip payload type (extends Recharts default) +interface TooltipPayloadItem { + name: string + value: number + color: string + dataKey: string + payload?: ChartDataPoint +} + +// Custom tooltip for the chart +function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: TooltipPayloadItem[]; label?: number }) { + if (!active || !payload || !payload.length) return null + + // Find stage from the first payload item if available + const stageData = payload[0]?.payload + const stageName = stageData?.stage + + return ( +
+

+ Time: {typeof label === 'number' ? label.toFixed(1) : '0'}s +

+ {stageName && typeof stageName === 'string' && ( +

+ Stage: {stageName} +

+ )} +
+ {payload.map((entry, index) => ( +

+ {entry.name}: {typeof entry.value === 'number' ? entry.value.toFixed(2) : '-'} +

+ ))} +
+
+ ) +} + +interface ReplayChartProps { + displayData: ChartDataPoint[] + displayStageRanges: StageRange[] + stageRanges: StageRange[] + dataMaxTime: number + maxLeftAxis: number + maxRightAxis: number + hasGravFlow: boolean + isShowingReplay: boolean + currentTime: number + isPlaying: boolean + playbackSpeed: number + isDark: boolean + variant?: 'mobile' | 'desktop' +} + +export function ReplayChart({ + displayData, + displayStageRanges, + stageRanges, + dataMaxTime, + maxLeftAxis, + maxRightAxis, + hasGravFlow, + isShowingReplay, + currentTime, + isPlaying, + playbackSpeed, + isDark, + variant = 'mobile' +}: ReplayChartProps) { + const isMobile = variant === 'mobile' + const chartHeight = isMobile ? 'h-64' : 'h-[60vh] min-h-[400px]' + const padding = isMobile ? 'p-1' : 'p-2' + const rightMargin = isMobile ? 0 : 5 + + const content = ( + <> +
+ + {isPlaying && ( + + + Replaying {playbackSpeed}x + + )} +
+
+
+ + + + {displayStageRanges.map((stage, idx) => ( + + ))} + {isShowingReplay && } + `${Math.round(v)}s`} axisLine={{ stroke: '#444' }} tickLine={{ stroke: '#444' }} domain={[0, dataMaxTime]} type="number" allowDataOverflow={false} /> + + + } /> + + + + + {hasGravFlow && } + + +
+
+ {/* Stage Legend */} + {(() => { + if (stageRanges.length === 0) return null + return ( +
+ {stageRanges.map((stage, idx) => ( + + {typeof stage.name === 'string' ? stage.name : String(stage.name || '')} + + ))} +
+ ) + })()} + + ) + + // Mobile needs a wrapper div for spacing, desktop uses parent's space-y-2 + return isMobile ?
{content}
: content +} + +interface CombinedDataPoint { + time: number + pressureA?: number + flowA?: number + weightA?: number + pressureB?: number + flowB?: number + weightB?: number +} + +interface CompareChartProps { + combinedData: CombinedDataPoint[] + dataMaxTime: number + leftDomain: number + rightDomain: number + isShowingReplay: boolean + comparisonCurrentTime: number + comparisonIsPlaying: boolean + comparisonPlaybackSpeed: number + variant?: 'mobile' | 'desktop' +} + +export function CompareChart({ + combinedData, + dataMaxTime, + leftDomain, + rightDomain, + isShowingReplay, + comparisonCurrentTime, + comparisonIsPlaying, + comparisonPlaybackSpeed, + variant = 'mobile' +}: CompareChartProps) { + const isMobile = variant === 'mobile' + const chartHeight = isMobile ? 'h-64' : 'h-[60vh] min-h-[400px]' + const padding = isMobile ? 'p-1' : 'p-2' + const displayData = isShowingReplay ? combinedData.filter(d => d.time <= comparisonCurrentTime) : combinedData + + return ( + <> +
+ + {comparisonIsPlaying && ( + + + {comparisonPlaybackSpeed}x + + )} +
+
+
+ + + + {isShowingReplay && } + `${Math.round(v)}s`} domain={[0, dataMaxTime]} type="number" allowDataOverflow={false} /> + + + + + + + + + + + + +
+
+
+
Shot A (solid) +
Shot B (dashed) +
+ + ) +} + +interface AnalyzeChartProps { + chartData: ChartDataPoint[] + stageRanges: StageRange[] + hasTargetCurves: boolean + dataMaxTime: number + maxLeftAxis: number + maxFlow: number + profileTargetCurves?: ProfileTargetPoint[] + isDark: boolean + variant?: 'mobile' | 'desktop' +} + +export function AnalyzeChart({ + chartData, + stageRanges, + hasTargetCurves, + dataMaxTime, + maxLeftAxis, + maxFlow, + profileTargetCurves, + isDark, + variant = 'mobile' +}: AnalyzeChartProps) { + const isMobile = variant === 'mobile' + const chartHeight = isMobile ? 'h-64' : 'h-[60vh] min-h-[400px]' + const padding = isMobile ? 'p-1' : 'p-2' + + return ( + <> +
+ + {hasTargetCurves && ( + Target overlay + )} +
+
+
+ + + + {stageRanges.map((stage, idx) => ( + + ))} + `${Math.round(v)}s`} axisLine={{ stroke: '#444' }} type="number" domain={[0, dataMaxTime]} /> + + + [`${value?.toFixed(1) || '-'}`, name]} labelFormatter={(label) => `${Number(label).toFixed(1)}s`} /> + + + {hasTargetCurves && profileTargetCurves && ( + number }>; yAxisMap?: Record number }> }) => { + if (!xAxisMap || !yAxisMap) return null + const xAxis = Object.values(xAxisMap)[0] + const yAxis = yAxisMap['left'] + if (!xAxis?.scale || !yAxis?.scale) return null + const curves = profileTargetCurves! + const pressurePoints = curves.filter(p => p.target_pressure !== undefined).sort((a, b) => a.time - b.time) + const flowPoints = curves.filter(p => p.target_flow !== undefined).sort((a, b) => a.time - b.time) + let pressurePath = '' + if (pressurePoints.length >= 2) pressurePath = pressurePoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${xAxis.scale(p.time)} ${yAxis.scale(p.target_pressure!)}`).join(' ') + let flowPath = '' + if (flowPoints.length >= 2) flowPath = flowPoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${xAxis.scale(p.time)} ${yAxis.scale(p.target_flow!)}`).join(' ') + return ( + + {pressurePath && <> + + {pressurePoints.map((p, i) => )} + } + {flowPath && <> + + {flowPoints.map((p, i) => )} + } + + ) + }} + /> + )} + + +
+
+ {/* Legend */} +
+
Pressure
+
Flow
+ {hasTargetCurves && <> +
Target Pressure
+
Target Flow
+ } +
+ {/* Stage Legend */} + {(() => { + if (stageRanges.length === 0) return null + return ( +
+ {stageRanges.map((stage, idx) => ( + + {typeof stage.name === 'string' ? stage.name : String(stage.name || '')} + + ))} +
+ ) + })()} + + ) +} diff --git a/apps/web/src/components/ShotHistoryView.tsx b/apps/web/src/components/ShotHistoryView.tsx index d63cb31..461160d 100644 --- a/apps/web/src/components/ShotHistoryView.tsx +++ b/apps/web/src/components/ShotHistoryView.tsx @@ -41,8 +41,9 @@ import { import { domToPng } from 'modern-screenshot' import { useShotHistory, ShotInfo, ShotData } from '@/hooks/useShotHistory' import { ExpertAnalysisView } from '@/components/ExpertAnalysisView' +import { ReplayChart, CompareChart, AnalyzeChart } from '@/components/ShotCharts' import { getServerUrl } from '@/lib/config' -import { formatDistanceToNow, format } from 'date-fns' +import { formatDistanceToNow } from 'date-fns' import { LineChart, Line, @@ -56,73 +57,18 @@ import { ReferenceLine, Customized } from 'recharts' - -// Chart colors matching Meticulous app style (muted to fit dark theme) -const CHART_COLORS = { - pressure: '#4ade80', // Green (muted) - flow: '#67e8f9', // Light cyan/blue (muted) - weight: '#fbbf24', // Amber/Yellow (muted) - gravimetricFlow: '#c2855a', // Brown-orange (muted to fit dark theme) - // Profile target curves (lighter/dashed versions of main colors) - targetPressure: '#86efac', // Lighter green for target pressure - targetFlow: '#a5f3fc' // Lighter cyan for target flow -} - -// Stage colors for background areas (matching tag colors) -const STAGE_COLORS = [ - 'rgba(239, 68, 68, 0.25)', // Red - 'rgba(249, 115, 22, 0.25)', // Orange - 'rgba(234, 179, 8, 0.25)', // Yellow - 'rgba(34, 197, 94, 0.25)', // Green - 'rgba(59, 130, 246, 0.25)', // Blue - 'rgba(168, 85, 247, 0.25)', // Purple - 'rgba(236, 72, 153, 0.25)', // Pink - 'rgba(20, 184, 166, 0.25)', // Teal -] - -const STAGE_BORDER_COLORS = [ - 'rgba(239, 68, 68, 0.5)', - 'rgba(249, 115, 22, 0.5)', - 'rgba(234, 179, 8, 0.5)', - 'rgba(34, 197, 94, 0.5)', - 'rgba(59, 130, 246, 0.5)', - 'rgba(168, 85, 247, 0.5)', - 'rgba(236, 72, 153, 0.5)', - 'rgba(20, 184, 166, 0.5)', -] - -// Darker text colors for stage pills — legible on both light and dark backgrounds -const STAGE_TEXT_COLORS_LIGHT = [ - 'rgb(153, 27, 27)', // Red-800 - 'rgb(154, 52, 18)', // Orange-800 - 'rgb(133, 77, 14)', // Yellow-800 - 'rgb(22, 101, 52)', // Green-800 - 'rgb(30, 64, 175)', // Blue-800 - 'rgb(107, 33, 168)', // Purple-800 - 'rgb(157, 23, 77)', // Pink-800 - 'rgb(17, 94, 89)', // Teal-800 -] -const STAGE_TEXT_COLORS_DARK = [ - 'rgb(252, 165, 165)', // Red-300 - 'rgb(253, 186, 116)', // Orange-300 - 'rgb(253, 224, 71)', // Yellow-300 - 'rgb(134, 239, 172)', // Green-300 - 'rgb(147, 197, 253)', // Blue-300 - 'rgb(216, 180, 254)', // Purple-300 - 'rgb(249, 168, 212)', // Pink-300 - 'rgb(94, 234, 212)', // Teal-300 -] +import { + CHART_COLORS, + STAGE_COLORS, + STAGE_BORDER_COLORS, + STAGE_TEXT_COLORS_LIGHT, + STAGE_TEXT_COLORS_DARK, + COMPARISON_COLORS +} from '@/lib/chartStyles' // Playback speed options - defined outside component to avoid re-creation const SPEED_OPTIONS: number[] = [0.5, 1, 2, 3, 5] -// Comparison chart colors -const COMPARISON_COLORS = { - pressure: '#4ade80', - flow: '#67e8f9', - weight: '#fbbf24' -} - interface ShotHistoryViewProps { profileName: string onBack: () => void @@ -1490,78 +1436,24 @@ export function ShotHistoryView({ profileName, onBack }: ShotHistoryViewProps) { ? stageRanges.filter(s => s.startTime <= currentTime).map(s => ({ ...s, endTime: Math.min(s.endTime, currentTime) })) : stageRanges return ( -
-
- - {isPlaying && ( - - - Replaying {playbackSpeed}x - - )} -
-
-
- - - - {displayStageRanges.map((stage, idx) => ( - - ))} - {isShowingReplay && } - `${Math.round(value)}s`} axisLine={{ stroke: '#444' }} tickLine={{ stroke: '#444' }} domain={[0, dataMaxTime]} type="number" allowDataOverflow={false} /> - - - } /> - - - - - {hasGravFlow && } - - -
-
- {/* Stage Legend */} - {(() => { - const sr = getStageRanges(getChartData(shotData)) - if (sr.length === 0) return null - return ( -
- {sr.map((stage, idx) => ( - - {typeof stage.name === 'string' ? stage.name : String(stage.name || '')} - - ))} -
- ) - })()} -
+ ) })()}
- - - Analyze - - - - {/* Replay Tab Content */} - - {/* Progress Bar */} {maxTime > 0 && (
@@ -1762,88 +1654,31 @@ export function ShotHistoryView({ profileName, onBack }: ShotHistoryViewProps) { {/* Comparison Chart with Replay */} {comparisonShotData && (
-
- - {comparisonIsPlaying && ( - - - {comparisonPlaybackSpeed}x - - )} -
-
-
- {(() => { - const combinedData = getCombinedChartData() - const dataMaxTime = combinedData.length > 0 ? combinedData[combinedData.length - 1].time : 0 - const maxPressure = Math.max(...combinedData.map(d => Math.max(d.pressureA || 0, d.pressureB || 0)), 12) - const maxFlow = Math.max(...combinedData.map(d => Math.max(d.flowA || 0, d.flowB || 0)), 8) - const maxWeight = Math.max(...combinedData.map(d => Math.max(d.weightA || 0, d.weightB || 0)), 50) - const leftDomain = Math.ceil(Math.max(maxPressure, maxFlow) * 1.1) - const rightDomain = Math.ceil(maxWeight * 1.1) - - // Filter data for replay - show data when playing or paused at a position - const isShowingReplay = comparisonCurrentTime > 0 && comparisonCurrentTime < dataMaxTime - const displayData = isShowingReplay - ? combinedData.filter(d => d.time <= comparisonCurrentTime) - : combinedData - - return ( - - - - - {/* Playhead during replay */} - {isShowingReplay && ( - - )} - - `${Math.round(v)}s`} - domain={[0, dataMaxTime]} - type="number" - allowDataOverflow={false} - /> - - - - - - {/* Shot A - Solid */} - - - - - {/* Shot B - Dashed */} - - - - - - ) - })()} -
-
- - {/* Legend */} -
-
Shot A (solid) -
Shot B (dashed) -
+ {/* Chart */} + {(() => { + const combinedData = getCombinedChartData() + const dataMaxTime = combinedData.length > 0 ? combinedData[combinedData.length - 1].time : 0 + const maxPressure = Math.max(...combinedData.map(d => Math.max(d.pressureA || 0, d.pressureB || 0)), 12) + const maxFlow = Math.max(...combinedData.map(d => Math.max(d.flowA || 0, d.flowB || 0)), 8) + const maxWeight = Math.max(...combinedData.map(d => Math.max(d.weightA || 0, d.weightB || 0)), 50) + const leftDomain = Math.ceil(Math.max(maxPressure, maxFlow) * 1.1) + const rightDomain = Math.ceil(maxWeight * 1.1) + const isShowingReplay = comparisonCurrentTime > 0 && comparisonCurrentTime < dataMaxTime + + return ( + + ) + })()} {/* Replay Controls */}
@@ -2691,55 +2526,21 @@ export function ShotHistoryView({ profileName, onBack }: ShotHistoryViewProps) { ? stageRanges.filter(s => s.startTime <= currentTime).map(s => ({ ...s, endTime: Math.min(s.endTime, currentTime) })) : stageRanges return ( - <> -
- - {isPlaying && ( - - - Replaying {playbackSpeed}x - - )} -
-
-
- - - - {displayStageRanges.map((stage, idx) => ( - - ))} - {isShowingReplay && } - `${Math.round(v)}s`} axisLine={{ stroke: '#444' }} tickLine={{ stroke: '#444' }} domain={[0, dataMaxTime]} type="number" allowDataOverflow={false} /> - - - } /> - - - - - {hasGravFlow && } - - -
-
- {/* Stage Legend */} - {(() => { - if (stageRanges.length === 0) return null - return ( -
- {stageRanges.map((stage, idx) => ( - - {typeof stage.name === 'string' ? stage.name : String(stage.name || '')} - - ))} -
- ) - })()} - + ) })()} @@ -2753,47 +2554,18 @@ export function ShotHistoryView({ profileName, onBack }: ShotHistoryViewProps) { const leftDomain = Math.ceil(Math.max(maxPressure, maxFlow) * 1.1) const rightDomain = Math.ceil(maxWeight * 1.1) const isShowingReplay = comparisonCurrentTime > 0 && comparisonCurrentTime < dataMaxTime - const displayData = isShowingReplay ? combinedData.filter(d => d.time <= comparisonCurrentTime) : combinedData return ( - <> -
- - {comparisonIsPlaying && ( - - - {comparisonPlaybackSpeed}x - - )} -
-
-
- - - - {isShowingReplay && } - `${Math.round(v)}s`} domain={[0, dataMaxTime]} type="number" allowDataOverflow={false} /> - - - - - - - - - - - - -
-
-
-
Shot A (solid) -
Shot B (dashed) -
- + ) })()} @@ -2815,86 +2587,17 @@ export function ShotHistoryView({ profileName, onBack }: ShotHistoryViewProps) { const maxFlow = Math.max(...chartData.map(d => d.flow || 0), ...(analysisResult.profile_target_curves?.map(d => d.target_flow || 0) || []), 5) const maxLeftAxis = Math.ceil(Math.max(maxPressure, maxFlow) * 1.1) return ( - <> -
- - {hasTargetCurves && ( - Target overlay - )} -
-
-
- - - - {stageRanges.map((stage, idx) => ( - - ))} - `${Math.round(v)}s`} axisLine={{ stroke: '#444' }} type="number" domain={[0, dataMaxTime]} /> - - - [`${value?.toFixed(1) || '-'}`, name]} labelFormatter={(label) => `${Number(label).toFixed(1)}s`} /> - - - {hasTargetCurves && analysisResult.profile_target_curves && ( - number }>; yAxisMap?: Record number }> }) => { - if (!xAxisMap || !yAxisMap) return null - const xAxis = Object.values(xAxisMap)[0] - const yAxis = yAxisMap['left'] - if (!xAxis?.scale || !yAxis?.scale) return null - const curves = analysisResult.profile_target_curves! - const pressurePoints = curves.filter(p => p.target_pressure !== undefined).sort((a, b) => a.time - b.time) - const flowPoints = curves.filter(p => p.target_flow !== undefined).sort((a, b) => a.time - b.time) - let pressurePath = '' - if (pressurePoints.length >= 2) pressurePath = pressurePoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${xAxis.scale(p.time)} ${yAxis.scale(p.target_pressure!)}`).join(' ') - let flowPath = '' - if (flowPoints.length >= 2) flowPath = flowPoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${xAxis.scale(p.time)} ${yAxis.scale(p.target_flow!)}`).join(' ') - return ( - - {pressurePath && <> - - {pressurePoints.map((p, i) => )} - } - {flowPath && <> - - {flowPoints.map((p, i) => )} - } - - ) - }} - /> - )} - - -
-
- {/* Legend */} -
-
Pressure
-
Flow
- {hasTargetCurves && <> -
Target Pressure
-
Target Flow
- } -
- {/* Stage Legend */} - {(() => { - if (stageRanges.length === 0) return null - return ( -
- {stageRanges.map((stage, idx) => ( - - {typeof stage.name === 'string' ? stage.name : String(stage.name || '')} - - ))} -
- ) - })()} - + ) })()} diff --git a/apps/web/src/lib/chartStyles.ts b/apps/web/src/lib/chartStyles.ts new file mode 100644 index 0000000..bd5e3e2 --- /dev/null +++ b/apps/web/src/lib/chartStyles.ts @@ -0,0 +1,63 @@ +// Chart colors matching Meticulous app style (muted to fit dark theme) +export const CHART_COLORS = { + pressure: '#4ade80', // Green (muted) + flow: '#67e8f9', // Light cyan/blue (muted) + weight: '#fbbf24', // Amber/Yellow (muted) + gravimetricFlow: '#c2855a', // Brown-orange (muted to fit dark theme) + // Profile target curves (lighter/dashed versions of main colors) + targetPressure: '#86efac', // Lighter green for target pressure + targetFlow: '#a5f3fc' // Lighter cyan for target flow +} + +// Stage colors for background areas (matching tag colors) +export const STAGE_COLORS = [ + 'rgba(239, 68, 68, 0.25)', // Red + 'rgba(249, 115, 22, 0.25)', // Orange + 'rgba(234, 179, 8, 0.25)', // Yellow + 'rgba(34, 197, 94, 0.25)', // Green + 'rgba(59, 130, 246, 0.25)', // Blue + 'rgba(168, 85, 247, 0.25)', // Purple + 'rgba(236, 72, 153, 0.25)', // Pink + 'rgba(20, 184, 166, 0.25)', // Teal +] + +export const STAGE_BORDER_COLORS = [ + 'rgba(239, 68, 68, 0.5)', + 'rgba(249, 115, 22, 0.5)', + 'rgba(234, 179, 8, 0.5)', + 'rgba(34, 197, 94, 0.5)', + 'rgba(59, 130, 246, 0.5)', + 'rgba(168, 85, 247, 0.5)', + 'rgba(236, 72, 153, 0.5)', + 'rgba(20, 184, 166, 0.5)', +] + +// Darker text colors for stage pills — legible on both light and dark backgrounds +export const STAGE_TEXT_COLORS_LIGHT = [ + 'rgb(153, 27, 27)', // Red-800 + 'rgb(154, 52, 18)', // Orange-800 + 'rgb(133, 77, 14)', // Yellow-800 + 'rgb(22, 101, 52)', // Green-800 + 'rgb(30, 64, 175)', // Blue-800 + 'rgb(107, 33, 168)', // Purple-800 + 'rgb(157, 23, 77)', // Pink-800 + 'rgb(17, 94, 89)', // Teal-800 +] + +export const STAGE_TEXT_COLORS_DARK = [ + 'rgb(252, 165, 165)', // Red-300 + 'rgb(253, 186, 116)', // Orange-300 + 'rgb(253, 224, 71)', // Yellow-300 + 'rgb(134, 239, 172)', // Green-300 + 'rgb(147, 197, 253)', // Blue-300 + 'rgb(216, 180, 254)', // Purple-300 + 'rgb(249, 168, 212)', // Pink-300 + 'rgb(94, 234, 212)', // Teal-300 +] + +// Comparison chart colors +export const COMPARISON_COLORS = { + pressure: '#4ade80', + flow: '#67e8f9', + weight: '#fbbf24' +}