feat(ui): add prediction chart rendering with histogram overlay, markers, filtering, and visibility toggle
- Add histogram series to CandleChart for per-bar prediction colors (15% opacity) - Add series markers showing label name and confidence % at prediction span starts - Implement confidence threshold filtering for both histogram and markers - Implement label type filtering from PredictionPanel checkboxes - Implement prediction layer visibility toggle (show/hide) - Add getVisibleCandles method to CandleChartHandle for on-demand prediction fetching - Pass prediction state props from page.tsx to CandleChart Tasks 10.1-10.5 complete.
This commit is contained in:
parent
28ebe2c5d1
commit
952eb7413c
4 changed files with 138 additions and 8 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
modelInfo?: ModelInfoResponse | null;
|
||||
}
|
||||
|
||||
export interface CandleChartHandle {
|
||||
refreshData: () => Promise<void>;
|
||||
getVisibleCandles: () => Candle[];
|
||||
}
|
||||
|
||||
const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
||||
|
|
@ -93,10 +102,17 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
|||
selectedSpanId = null,
|
||||
onSpanAnnotationsChange,
|
||||
onSelectedSpanChange,
|
||||
predictionVisible = false,
|
||||
perCandlePredictions = [],
|
||||
predictionSpans = [],
|
||||
confidenceThreshold = 0.5,
|
||||
selectedLabels = new Set<string>(),
|
||||
modelInfo = null,
|
||||
}, ref) => {
|
||||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null);
|
||||
const histogramSeriesRef = useRef<ISeriesApi<'Histogram'> | null>(null);
|
||||
const [candles, setCandles] = useState<Candle[]>([]);
|
||||
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
||||
const [annotationTypes, setAnnotationTypes] = useState<AnnotationType[]>([]);
|
||||
|
|
@ -151,13 +167,26 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
|||
}
|
||||
};
|
||||
|
||||
// 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<CandleChartHandle, CandleChartProps>(
|
|||
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<CandleChartHandle, CandleChartProps>(
|
|||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
histogramSeriesRef.current = null;
|
||||
chart.remove();
|
||||
};
|
||||
}, [isEmpty, mounted, resolvedTheme]);
|
||||
|
|
@ -316,6 +358,88 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
|||
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<string, string> = {};
|
||||
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<Time>[] = predictionSpans
|
||||
.filter((span) => {
|
||||
if (span.avg_confidence < confidenceThreshold) return false;
|
||||
if (!selectedLabels.has(span.label)) return false;
|
||||
if (span.label === 'O') return false;
|
||||
return true;
|
||||
})
|
||||
.map((span) => {
|
||||
const baseColor = labelColorMap[span.label] || '#888888';
|
||||
const confidencePct = Math.round(span.avg_confidence * 100);
|
||||
return {
|
||||
time: span.start_time as Time,
|
||||
position: 'belowBar' as const,
|
||||
color: baseColor,
|
||||
shape: 'square' as const,
|
||||
text: `${span.label} (${confidencePct}%)`,
|
||||
size: 1,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => (a.time as number) - (b.time as number));
|
||||
|
||||
histogramSeriesRef.current.setMarkers(spanMarkers);
|
||||
}, [predictionVisible, perCandlePredictions, predictionSpans, confidenceThreshold, selectedLabels, modelInfo, candles]);
|
||||
|
||||
// Handle chart clicks for annotation
|
||||
useEffect(() => {
|
||||
if (!chartRef.current || !seriesRef.current) return;
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue