259 lines
9.5 KiB
TypeScript
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,
|
|
};
|
|
}
|