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:
Marko Djordjevic 2026-02-15 16:26:17 +01:00
parent 28ebe2c5d1
commit 952eb7413c
4 changed files with 138 additions and 8 deletions

View file

@ -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

View file

@ -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>

View file

@ -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