import { useState, useCallback, useMemo, useRef, useEffect } from 'react'; import type { Candle, ChartViewModel, ChartDimensions, CandleLayout, PriceRange, VolumeRange } from './chartTypes'; import { CHART_LAYOUT } from './chartTypes'; interface UseCandlestickViewModelOptions { candles: Candle[]; containerWidth: number; containerHeight: number; visibleStartOffset?: number; } interface UseCandlestickViewModelResult { viewModel: ChartViewModel; setHoveredIndex: (index: number | null) => void; setVisibleRange: (start: number, end: number) => void; setSelectedIndicators: (indicators: string[]) => void; setSelectedEvents: (events: string[]) => void; getXForIndex: (index: number) => number; getYForPrice: (price: number) => number; getYForVolume: (volume: number) => number; getIndexForX: (x: number) => number | null; getPriceForY: (y: number) => number; getTimeForX: (x: number) => number | null; getXForTime: (time: number) => number; } /** * View/Model layer hook for candlestick chart. * Maintains chart state separate from render logic. * Uses refs where appropriate to avoid unnecessary React re-renders. */ export function useCandlestickViewModel({ candles, containerWidth, containerHeight, visibleStartOffset = 0, }: UseCandlestickViewModelOptions): UseCandlestickViewModelResult { // State that triggers UI updates const [hoveredIndex, setHoveredIndex] = useState(null); const [visibleStartIndex, setVisibleStartIndex] = useState(0); const [visibleEndIndex, setVisibleEndIndex] = useState(0); const [selectedIndicators, setSelectedIndicators] = useState([]); const [selectedEvents, setSelectedEvents] = useState([]); // Calculate dimensions const dimensions: ChartDimensions = useMemo(() => { const priceChartHeight = (containerHeight - CHART_LAYOUT.PADDING_TOP - CHART_LAYOUT.PADDING_BOTTOM - CHART_LAYOUT.VOLUME_SECTION_GAP) * CHART_LAYOUT.PRICE_CHART_RATIO; const volumeChartHeight = (containerHeight - CHART_LAYOUT.PADDING_TOP - CHART_LAYOUT.PADDING_BOTTOM - CHART_LAYOUT.VOLUME_SECTION_GAP) * (1 - CHART_LAYOUT.PRICE_CHART_RATIO); return { width: containerWidth, height: containerHeight, priceChartHeight, volumeChartHeight, paddingTop: CHART_LAYOUT.PADDING_TOP, paddingRight: CHART_LAYOUT.PADDING_RIGHT, paddingBottom: CHART_LAYOUT.PADDING_BOTTOM, paddingLeft: CHART_LAYOUT.PADDING_LEFT, }; }, [containerWidth, containerHeight]); // Calculate candle layout based on visible range - scale to fill available width const candleLayout: CandleLayout = useMemo(() => { const visibleCandles = visibleEndIndex - visibleStartIndex + 1; const availableWidth = containerWidth - dimensions.paddingLeft - dimensions.paddingRight; if (visibleCandles <= 0 || availableWidth <= 0) { return { candleWidth: 8, candleSpacing: 2, xStep: 10 }; } // Calculate candle width to fill the entire available width const totalStepWidth = availableWidth / visibleCandles; const candleSpacing = totalStepWidth * CHART_LAYOUT.CANDLE_SPACING_RATIO; let candleWidth = totalStepWidth - candleSpacing; // Only apply minimum width constraint to prevent candles from being too thin candleWidth = Math.max(CHART_LAYOUT.MIN_CANDLE_WIDTH, candleWidth); const xStep = totalStepWidth; return { candleWidth, candleSpacing, xStep }; }, [visibleStartIndex, visibleEndIndex, containerWidth, dimensions]); // Calculate price range for visible candles const priceRange: PriceRange = useMemo(() => { if (candles.length === 0) { return { min: 0, max: 100 }; } const visibleCandles = candles.slice(visibleStartIndex, visibleEndIndex + 1); if (visibleCandles.length === 0) { return { min: 0, max: 100 }; } let min = Infinity; let max = -Infinity; for (const candle of visibleCandles) { min = Math.min(min, candle.low); max = Math.max(max, candle.high); } // Add padding const range = max - min; const padding = range * 0.05; return { min: min - padding, max: max + padding, }; }, [candles, visibleStartIndex, visibleEndIndex]); // Calculate volume range for visible candles const volumeRange: VolumeRange = useMemo(() => { if (candles.length === 0) { return { min: 0, max: 1000000 }; } const visibleCandles = candles.slice(visibleStartIndex, visibleEndIndex + 1); if (visibleCandles.length === 0) { return { min: 0, max: 1000000 }; } let max = 0; for (const candle of visibleCandles) { max = Math.max(max, candle.volume); } return { min: 0, max }; }, [candles, visibleStartIndex, visibleEndIndex]); // Initialize visible range when candles load. // Starts from visibleStartOffset to skip warm-up candles used for indicator calculations. useEffect(() => { if (candles.length > 0) { setVisibleStartIndex(visibleStartOffset); setVisibleEndIndex(candles.length - 1); } }, [candles.length, visibleStartOffset]); // Coordinate transformation functions const getXForIndex = useCallback((index: number): number => { const relativeIndex = index - visibleStartIndex; return dimensions.paddingLeft + relativeIndex * candleLayout.xStep + candleLayout.candleWidth / 2; }, [visibleStartIndex, dimensions.paddingLeft, candleLayout]); const getYForPrice = useCallback((price: number): number => { const { min, max } = priceRange; const ratio = (price - min) / (max - min); return dimensions.paddingTop + dimensions.priceChartHeight * (1 - ratio); }, [priceRange, dimensions]); const getYForVolume = useCallback((volume: number): number => { const { max } = volumeRange; const ratio = volume / max; const volumeTop = dimensions.paddingTop + dimensions.priceChartHeight + CHART_LAYOUT.VOLUME_SECTION_GAP; return volumeTop + dimensions.volumeChartHeight * (1 - ratio); }, [volumeRange, dimensions]); const getIndexForX = useCallback((x: number): number | null => { const relativeX = x - dimensions.paddingLeft; if (relativeX < 0) return null; const index = Math.floor(relativeX / candleLayout.xStep) + visibleStartIndex; if (index < visibleStartIndex || index > visibleEndIndex) { return null; } return index; }, [dimensions.paddingLeft, candleLayout.xStep, visibleStartIndex, visibleEndIndex]); const getPriceForY = useCallback((y: number): number => { const { min, max } = priceRange; const ratio = 1 - (y - dimensions.paddingTop) / dimensions.priceChartHeight; return min + ratio * (max - min); }, [priceRange, dimensions]); // Convert x coordinate to precise timestamp (interpolated between candles) const getTimeForX = useCallback((x: number): number | null => { if (candles.length === 0) return null; const relativeX = x - dimensions.paddingLeft; const availableWidth = dimensions.width - dimensions.paddingLeft - dimensions.paddingRight; if (relativeX < 0 || relativeX > availableWidth) return null; // Get the time range from visible candles const startCandle = candles[visibleStartIndex]; const endCandle = candles[visibleEndIndex]; if (!startCandle || !endCandle) return null; const startTime = startCandle.time; const endTime = endCandle.time; // Interpolate time based on x position const ratio = relativeX / availableWidth; const time = startTime + ratio * (endTime - startTime); return Math.round(time); // Round to nearest millisecond }, [candles, dimensions, visibleStartIndex, visibleEndIndex]); // Convert timestamp to x coordinate (interpolated) const getXForTime = useCallback((time: number): number => { if (candles.length === 0) return dimensions.paddingLeft; const startCandle = candles[visibleStartIndex]; const endCandle = candles[visibleEndIndex]; if (!startCandle || !endCandle) return dimensions.paddingLeft; const startTime = startCandle.time; const endTime = endCandle.time; const timeRange = endTime - startTime; if (timeRange === 0) return dimensions.paddingLeft; const availableWidth = dimensions.width - dimensions.paddingLeft - dimensions.paddingRight; const ratio = (time - startTime) / timeRange; return dimensions.paddingLeft + ratio * availableWidth; }, [candles, dimensions, visibleStartIndex, visibleEndIndex]); const setVisibleRange = useCallback((start: number, end: number) => { const clampedStart = Math.max(0, start); const clampedEnd = Math.min(candles.length - 1, end); setVisibleStartIndex(clampedStart); setVisibleEndIndex(clampedEnd); }, [candles.length]); // Assemble view model const viewModel: ChartViewModel = useMemo(() => ({ candles, visibleStartIndex, visibleEndIndex, hoveredIndex, chartDimensions: dimensions, priceRange, volumeRange, candleLayout, selectedIndicators, selectedEvents, }), [candles, visibleStartIndex, visibleEndIndex, hoveredIndex, dimensions, priceRange, volumeRange, candleLayout, selectedIndicators, selectedEvents]); return { viewModel, setHoveredIndex, setVisibleRange, setSelectedIndicators, setSelectedEvents, getXForIndex, getYForPrice, getYForVolume, getIndexForX, getPriceForY, getTimeForX, getXForTime, }; }