SoftTraderFrontend/components/candlestick-chart/useCandlestickViewModel.ts
2026-03-20 11:10:13 -04:00

259 lines
9.5 KiB
TypeScript

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<number | null>(null);
const [visibleStartIndex, setVisibleStartIndex] = useState(0);
const [visibleEndIndex, setVisibleEndIndex] = useState(0);
const [selectedIndicators, setSelectedIndicators] = useState<string[]>([]);
const [selectedEvents, setSelectedEvents] = useState<string[]>([]);
// 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,
};
}