diff --git a/openspec/changes/candle-backend/tasks.md b/openspec/changes/candle-backend/tasks.md index c71c821..bbda6cd 100644 --- a/openspec/changes/candle-backend/tasks.md +++ b/openspec/changes/candle-backend/tasks.md @@ -86,11 +86,11 @@ ## 10. Prediction UI — Chart Rendering -- [ ] 10.1 Add histogram series to CandleChart for prediction rendering — per-bar colors from label config at 10-20% opacity -- [ ] 10.2 Add series markers for prediction span labels — show `{label} ({confidence}%)` below bars at span start -- [ ] 10.3 Implement confidence threshold filtering — only render predictions above threshold -- [ ] 10.4 Implement label type filtering — toggle visibility per label from PredictionPanel checkboxes -- [ ] 10.5 Implement prediction layer visibility toggle — show/hide histogram series and markers +- [x] 10.1 Add histogram series to CandleChart for prediction rendering — per-bar colors from label config at 10-20% opacity +- [x] 10.2 Add series markers for prediction span labels — show `{label} ({confidence}%)` below bars at span start +- [x] 10.3 Implement confidence threshold filtering — only render predictions above threshold +- [x] 10.4 Implement label type filtering — toggle visibility per label from PredictionPanel checkboxes +- [x] 10.5 Implement prediction layer visibility toggle — show/hide histogram series and markers ## 11. Prediction UI — Disagreements & Feedback diff --git a/src/app/page.tsx b/src/app/page.tsx index 0d40564..936e81a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -573,6 +573,12 @@ export default function Home() { selectedSpanId={selectedSpanId} onSpanAnnotationsChange={handleSpanAnnotationsChange} onSelectedSpanChange={handleSelectedSpanChange} + predictionVisible={predictionState.visible} + perCandlePredictions={predictionState.perCandlePredictions} + predictionSpans={predictionState.spans} + confidenceThreshold={predictionState.confidenceThreshold} + selectedLabels={predictionState.selectedLabels} + modelInfo={predictionState.modelInfo} /> diff --git a/src/components/CandleChart.tsx b/src/components/CandleChart.tsx index e736e37..b94f360 100644 --- a/src/components/CandleChart.tsx +++ b/src/components/CandleChart.tsx @@ -1,10 +1,11 @@ 'use client'; import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react'; -import { createChart, IChartApi, ISeriesApi, CandlestickData, Time } from 'lightweight-charts'; +import { createChart, IChartApi, ISeriesApi, CandlestickData, HistogramData, Time, SeriesMarker } from 'lightweight-charts'; import { useTheme } from 'next-themes'; import SvgOverlay from './SvgOverlay'; import SpanAnnotationManager from './SpanAnnotationManager'; +import type { PerCandlePrediction, PredictionSpan, ModelInfoResponse } from '@/types/predictions'; interface Candle { time: number; @@ -74,10 +75,18 @@ interface CandleChartProps { selectedSpanId?: number | null; onSpanAnnotationsChange?: () => void; onSelectedSpanChange?: (spanId: number | null) => void; + // Prediction rendering props + predictionVisible?: boolean; + perCandlePredictions?: PerCandlePrediction[]; + predictionSpans?: PredictionSpan[]; + confidenceThreshold?: number; + selectedLabels?: Set; + modelInfo?: ModelInfoResponse | null; } export interface CandleChartHandle { refreshData: () => Promise; + getVisibleCandles: () => Candle[]; } const CandleChart = forwardRef( @@ -93,10 +102,17 @@ const CandleChart = forwardRef( selectedSpanId = null, onSpanAnnotationsChange, onSelectedSpanChange, + predictionVisible = false, + perCandlePredictions = [], + predictionSpans = [], + confidenceThreshold = 0.5, + selectedLabels = new Set(), + modelInfo = null, }, ref) => { const chartContainerRef = useRef(null); const chartRef = useRef(null); const seriesRef = useRef | null>(null); + const histogramSeriesRef = useRef | null>(null); const [candles, setCandles] = useState([]); const [annotations, setAnnotations] = useState([]); const [annotationTypes, setAnnotationTypes] = useState([]); @@ -151,13 +167,26 @@ const CandleChart = forwardRef( } }; - // Expose refresh method to parent + // Expose methods to parent useImperativeHandle(ref, () => ({ refreshData: async () => { await fetchCandles(); await fetchAnnotations(); await fetchAnnotationTypes(); }, + getVisibleCandles: () => { + if (!chartRef.current || candles.length === 0) return []; + const timeScale = chartRef.current.timeScale(); + const visibleRange = timeScale.getVisibleLogicalRange(); + if (!visibleRange) return candles; + const barsInfo = seriesRef.current?.barsInLogicalRange(visibleRange); + if (!barsInfo || !barsInfo.barsBefore && !barsInfo.barsAfter) return candles; + // Filter candles by visible time range + const from = barsInfo.from as number; + const to = barsInfo.to as number; + if (typeof from !== 'number' || typeof to !== 'number') return candles; + return candles.filter((c) => c.time >= from && c.time <= to); + }, })); // Get theme-specific colors @@ -241,8 +270,20 @@ const CandleChart = forwardRef( borderDownColor: '#444444', }); + // Add histogram series for prediction overlay (rendered behind candles) + const histogramSeries = chart.addHistogramSeries({ + color: 'rgba(128, 128, 128, 0.15)', + priceFormat: { type: 'volume' }, + priceScaleId: 'prediction_overlay', + }); + histogramSeries.priceScale().applyOptions({ + scaleMargins: { top: 0, bottom: 0 }, + visible: false, + }); + chartRef.current = chart; seriesRef.current = candlestickSeries; + histogramSeriesRef.current = histogramSeries; // Handle resize const resizeObserver = new ResizeObserver((entries) => { @@ -255,6 +296,7 @@ const CandleChart = forwardRef( return () => { resizeObserver.disconnect(); + histogramSeriesRef.current = null; chart.remove(); }; }, [isEmpty, mounted, resolvedTheme]); @@ -316,6 +358,88 @@ const CandleChart = forwardRef( seriesRef.current.setMarkers(markers as any); }, [annotations, selectedLabelId, annotationTypes]); + // Render prediction histogram overlay and span markers + useEffect(() => { + if (!histogramSeriesRef.current || !seriesRef.current || candles.length === 0) return; + + // If predictions are not visible or no data, clear the histogram and markers + if (!predictionVisible || perCandlePredictions.length === 0) { + histogramSeriesRef.current.setData([]); + histogramSeriesRef.current.setMarkers([]); + return; + } + + // Build a label-to-color map from modelInfo + const labelColorMap: Record = {}; + if (modelInfo?.label_config) { + modelInfo.label_config.forEach((lc) => { + labelColorMap[lc.name] = lc.color; + }); + } + + // Helper to convert hex to rgba + const hexToRgba = (hex: string, alpha: number): string => { + hex = hex.replace('#', ''); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + }; + + // Build candle price lookup for histogram values + const candleMap = new Map(candles.map((c) => [c.time, c])); + + // Filter and map predictions to histogram data + const histogramData: (HistogramData & { color?: string })[] = perCandlePredictions + .filter((p) => { + // Filter by confidence threshold + if (p.confidence < confidenceThreshold) return false; + // Filter by selected labels + if (!selectedLabels.has(p.label)) return false; + // Skip "O" (no-pattern) labels + if (p.label === 'O') return false; + return true; + }) + .map((p) => { + const candle = candleMap.get(p.time); + // Use candle high as the histogram value so it overlays correctly + const value = candle ? candle.high : 0; + const baseColor = labelColorMap[p.label] || '#888888'; + return { + time: p.time as Time, + value, + color: hexToRgba(baseColor, 0.15), + }; + }) + .sort((a, b) => (a.time as number) - (b.time as number)); + + histogramSeriesRef.current.setData(histogramData); + + // Add markers for prediction span labels at span start times + const spanMarkers: SeriesMarker