viainti-chart 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,222 +1,266 @@
1
- import React, { useRef, useState, useMemo, useEffect, useCallback, useSyncExternalStore, memo } from 'react';
1
+ import React, { useRef, useState, useEffect, useMemo, useCallback, useSyncExternalStore, memo } from 'react';
2
2
  import { motion, AnimatePresence } from 'framer-motion';
3
3
 
4
+ const PRICE_SCALE_WIDTH = 72;
5
+ const LEFT_TOOLBAR_WIDTH = 0;
6
+ const PADDING_LEFT = 12;
7
+ const PADDING_RIGHT = 12;
8
+ const PADDING_TOP = 12;
9
+ const PADDING_BOTTOM = 12;
10
+ const X_AXIS_HEIGHT = 42;
11
+ const VOLUME_RATIO = 0.28;
12
+ const MIN_VOLUME_HEIGHT = 120;
13
+ const MAX_VOLUME_HEIGHT = 260;
14
+ const MIN_PRICE_HEIGHT = 200;
4
15
  const MIN_BAR_SPACING$1 = 6;
5
16
  const MAX_BAR_SPACING$1 = 18;
6
- const RIGHT_PADDING_PX$1 = 60;
7
- const MIN_VISIBLE_BARS = 12;
8
- const MAX_VISIBLE_BARS = 720;
17
+ const MIN_VISIBLE_BARS = 20;
18
+ const MAX_VISIBLE_BARS = 800;
19
+ const CROSSHAIR_COLOR = 'rgba(255,255,255,0.35)';
20
+ const GRID_MAJOR = 'rgba(255,255,255,0.06)';
21
+ const GRID_MINOR = 'rgba(255,255,255,0.03)';
22
+ const BULL_COLOR = '#2ECC71';
23
+ const BEAR_COLOR = '#E74C3C';
9
24
  const clamp$1 = (value, min, max) => Math.max(min, Math.min(max, value));
10
25
  const alignStroke$1 = (value) => Math.round(value) + 0.5;
11
- const getCandleTime = (candle, fallbackIndex) => {
26
+ const niceStep = (range, targetTicks) => {
27
+ if (range <= 0 || !Number.isFinite(range))
28
+ return 1;
29
+ const rawStep = range / Math.max(targetTicks, 1);
30
+ const magnitude = 10 ** Math.floor(Math.log10(rawStep));
31
+ const normalized = rawStep / magnitude;
32
+ let niceNormalized;
33
+ if (normalized <= 1)
34
+ niceNormalized = 1;
35
+ else if (normalized <= 2)
36
+ niceNormalized = 2;
37
+ else if (normalized <= 5)
38
+ niceNormalized = 5;
39
+ else
40
+ niceNormalized = 10;
41
+ return niceNormalized * magnitude;
42
+ };
43
+ const formatPrice = (value, step) => {
44
+ if (!Number.isFinite(value))
45
+ return '—';
46
+ const decimals = clamp$1(-Math.floor(Math.log10(step || 1)), 0, value < 1 ? 6 : 4);
47
+ return value.toFixed(decimals);
48
+ };
49
+ const formatVolume = (value) => {
50
+ if (!Number.isFinite(value))
51
+ return '—';
52
+ if (value >= 1_000_000)
53
+ return `${(value / 1_000_000).toFixed(2)}M`;
54
+ if (value >= 1_000)
55
+ return `${(value / 1_000).toFixed(2)}K`;
56
+ return value.toFixed(2);
57
+ };
58
+ const formatTimeLabel = (timestamp) => {
59
+ const date = new Date(timestamp);
60
+ if (Number.isNaN(date.getTime()))
61
+ return `${timestamp}`;
62
+ return date.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
63
+ };
64
+ const getCandleTime = (candle, fallback) => {
12
65
  if (!candle)
13
- return fallbackIndex;
66
+ return fallback;
14
67
  if (typeof candle.time === 'number')
15
68
  return candle.time;
16
69
  if (typeof candle.timestamp === 'number')
17
70
  return candle.timestamp;
18
- return fallbackIndex;
71
+ return fallback;
19
72
  };
20
- const buildTimeTicks = (minTime, maxTime, desiredTicks) => {
21
- if (!Number.isFinite(minTime) || !Number.isFinite(maxTime))
22
- return [];
23
- if (minTime === maxTime)
24
- return [minTime];
25
- const safeDesired = Math.max(2, desiredTicks);
26
- const span = maxTime - minTime;
27
- const step = span / (safeDesired - 1);
28
- const ticks = [];
29
- for (let i = 0; i < safeDesired; i++) {
30
- const rawValue = minTime + step * i;
31
- const clampedValue = clamp$1(rawValue, minTime, maxTime);
32
- if (ticks.length && Math.abs(clampedValue - ticks[ticks.length - 1]) < 1e-3)
33
- continue;
34
- ticks.push(clampedValue);
35
- }
36
- ticks[ticks.length - 1] = maxTime;
37
- return ticks;
38
- };
39
- const formatTickLabel = (value) => {
40
- const date = new Date(value);
41
- if (!Number.isNaN(date.getTime())) {
42
- return date.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
43
- }
44
- return `${value}`;
45
- };
46
- const getPanBounds = (visibleBars, dataLength) => {
47
- const maxPan = 0;
48
- const minPan = Math.min(0, visibleBars - dataLength);
49
- return { min: minPan, max: maxPan };
73
+ const computePlotArea = (containerWidth, containerHeight, showVolume) => {
74
+ const leftGutter = LEFT_TOOLBAR_WIDTH + PADDING_LEFT;
75
+ const rightGutter = PRICE_SCALE_WIDTH + PADDING_RIGHT;
76
+ const plotX0 = leftGutter;
77
+ const plotX1 = Math.max(plotX0 + 1, containerWidth - rightGutter);
78
+ const plotWidth = plotX1 - plotX0;
79
+ const availableHeight = Math.max(120, containerHeight - PADDING_TOP - PADDING_BOTTOM - X_AXIS_HEIGHT);
80
+ const targetVolume = showVolume ? clamp$1(Math.round(containerHeight * VOLUME_RATIO), MIN_VOLUME_HEIGHT, MAX_VOLUME_HEIGHT) : 0;
81
+ const volumePaneHeight = showVolume ? Math.min(targetVolume, Math.max(0, availableHeight - MIN_PRICE_HEIGHT)) : 0;
82
+ const pricePaneHeight = availableHeight - volumePaneHeight;
83
+ const pricePaneY0 = PADDING_TOP;
84
+ const volumePaneY0 = pricePaneY0 + pricePaneHeight + (showVolume ? 12 : 0);
85
+ const xAxisY0 = volumePaneY0 + volumePaneHeight;
86
+ return { plotX0, plotX1, plotWidth, pricePaneY0, pricePaneHeight, volumePaneY0, volumePaneHeight, xAxisY0 };
50
87
  };
51
88
  const computeViewport = (data, state) => {
52
89
  if (!data.length) {
53
- return { startIndex: 0, endIndex: 0 };
90
+ return { startIndex: 0, endIndex: 0, minTimeVisible: 0, maxTimeVisible: 0 };
54
91
  }
55
92
  const rawEnd = (data.length - 1) + Math.round(state.panOffsetBars);
56
- const minEnd = Math.max(state.visibleBars - 1, 0);
57
93
  const maxEnd = Math.max(data.length - 1, 0);
94
+ const minEnd = Math.min(Math.max(state.visibleBars - 1, 0), maxEnd);
58
95
  const endIndex = clamp$1(rawEnd, minEnd, maxEnd);
59
96
  const startIndex = Math.max(0, endIndex - state.visibleBars + 1);
60
- return { startIndex, endIndex };
97
+ const minTimeVisible = getCandleTime(data[startIndex], startIndex);
98
+ const maxTimeVisible = getCandleTime(data[endIndex], endIndex);
99
+ console.log('[Chart::Viewport]', { startIndex, endIndex, minTimeVisible, maxTimeVisible });
100
+ return { startIndex, endIndex, minTimeVisible, maxTimeVisible };
61
101
  };
62
- const computeXMapping = (plotArea, viewport) => {
63
- const { plotX0, plotX1, leftPaddingPx, rightPaddingPx } = plotArea;
102
+ const computeXMapping = (plotArea, viewport, state) => {
64
103
  const visibleCount = Math.max(1, viewport.endIndex - viewport.startIndex + 1);
65
- const availableWidth = Math.max(1, (plotX1 - rightPaddingPx) - (plotX0 + leftPaddingPx));
66
- const normalizedWidth = availableWidth * (visibleCount / Math.max(1, visibleCount - 1));
67
- const barSpacing = clamp$1(normalizedWidth / visibleCount, MIN_BAR_SPACING$1, MAX_BAR_SPACING$1);
68
- const targetLastX = plotX1 - rightPaddingPx;
69
- const offsetX = targetLastX - ((viewport.endIndex - viewport.startIndex) * barSpacing);
70
- const x = (index) => plotX0 + offsetX + (index - viewport.startIndex) * barSpacing;
71
- const gapLeft = x(viewport.startIndex) - (plotX0 + leftPaddingPx);
72
- const gapRight = (plotX1 - rightPaddingPx) - x(viewport.endIndex);
104
+ const firstTarget = plotArea.plotX0 + state.leftPaddingPx;
105
+ const lastTarget = plotArea.plotX1 - state.rightPaddingPx;
106
+ const innerSpan = Math.max(1, lastTarget - firstTarget);
107
+ const gaps = Math.max(1, visibleCount - 1);
108
+ const rawSpacing = visibleCount > 1 ? innerSpan / gaps : innerSpan;
109
+ const barSpacing = clamp$1(rawSpacing, MIN_BAR_SPACING$1, MAX_BAR_SPACING$1);
110
+ const bodyWidth = clamp$1(barSpacing * 0.7, 3, barSpacing - 2);
111
+ const wickWidth = Math.max(1, Math.floor(bodyWidth * 0.25));
112
+ const offsetX = visibleCount > 1
113
+ ? (lastTarget - (visibleCount - 1) * barSpacing) - plotArea.plotX0
114
+ : (lastTarget - plotArea.plotX0);
115
+ const candleCenterX = (index) => plotArea.plotX0 + offsetX + (index - viewport.startIndex) * barSpacing;
116
+ const candleLeftX = (index) => candleCenterX(index) - bodyWidth / 2;
117
+ const gapLeft = candleCenterX(viewport.startIndex) - (plotArea.plotX0 + state.leftPaddingPx);
118
+ const gapRight = (plotArea.plotX1 - state.rightPaddingPx) - candleCenterX(viewport.endIndex);
73
119
  if (Math.abs(gapLeft) > 2 || Math.abs(gapRight) > 2) {
74
- console.error('[ChartViewport::Alignment]', { startIndex: viewport.startIndex, endIndex: viewport.endIndex, gapLeft, gapRight, barSpacing, offsetX });
120
+ console.warn('[Chart::GapMismatch]', { startIndex: viewport.startIndex, endIndex: viewport.endIndex, gapLeft, gapRight, visibleCount, barSpacing });
75
121
  }
76
122
  else {
77
- console.log('[ChartViewport::Alignment]', { startIndex: viewport.startIndex, endIndex: viewport.endIndex, gapLeft, gapRight, barSpacing, offsetX });
123
+ console.log('[Chart::AlignmentOK]', { startIndex: viewport.startIndex, endIndex: viewport.endIndex, gapLeft, gapRight, visibleCount, barSpacing });
124
+ }
125
+ return { barSpacing, bodyWidth, wickWidth, offsetX, candleCenterX, candleLeftX };
126
+ };
127
+ const computeYMapping = (data, viewport, plotArea, state) => {
128
+ const slice = data.slice(Math.max(0, viewport.startIndex), Math.max(0, viewport.endIndex) + 1);
129
+ if (!slice.length) {
130
+ return {
131
+ yMin: 0,
132
+ yMax: 1,
133
+ priceToY: () => plotArea.pricePaneY0,
134
+ yTicks: []
135
+ };
78
136
  }
79
- return { barSpacing, offsetX, x };
137
+ let minPrice = Number.POSITIVE_INFINITY;
138
+ let maxPrice = Number.NEGATIVE_INFINITY;
139
+ slice.forEach(candle => {
140
+ minPrice = Math.min(minPrice, candle.low);
141
+ maxPrice = Math.max(maxPrice, candle.high);
142
+ });
143
+ if (!Number.isFinite(minPrice) || !Number.isFinite(maxPrice)) {
144
+ minPrice = 0;
145
+ maxPrice = 1;
146
+ }
147
+ const pad = (maxPrice - minPrice || Math.abs(maxPrice) * 0.01 || 1) * state.yPaddingPct;
148
+ const yMin = minPrice - pad;
149
+ const yMax = maxPrice + pad;
150
+ const priceToY = (value) => {
151
+ const clampedValue = Number.isFinite(value) ? value : 0;
152
+ return plotArea.pricePaneY0 + ((yMax - clampedValue) / Math.max(yMax - yMin, 1e-6)) * plotArea.pricePaneHeight;
153
+ };
154
+ const step = niceStep(yMax - yMin, 8);
155
+ const startTick = Math.ceil(yMin / step) * step;
156
+ const yTicks = [];
157
+ for (let value = startTick; value <= yMax; value += step) {
158
+ const y = priceToY(value);
159
+ yTicks.push({ value, y, label: formatPrice(value, step) });
160
+ }
161
+ return { yMin, yMax, priceToY, yTicks };
162
+ };
163
+ const computeTimeTicks = (data, viewport, xMapping, plotArea) => {
164
+ if (!data.length)
165
+ return [];
166
+ const ticks = [];
167
+ const targetSpacingPx = 120;
168
+ const candlesPerTick = Math.max(1, Math.round(targetSpacingPx / xMapping.barSpacing));
169
+ for (let idx = viewport.startIndex; idx <= viewport.endIndex; idx += candlesPerTick) {
170
+ const timestamp = getCandleTime(data[idx], idx);
171
+ if (timestamp < viewport.minTimeVisible || timestamp > viewport.maxTimeVisible)
172
+ continue;
173
+ const x = xMapping.candleCenterX(idx);
174
+ if (x < plotArea.plotX0 || x > plotArea.plotX1)
175
+ continue;
176
+ ticks.push({ index: idx, x, label: formatTimeLabel(timestamp) });
177
+ }
178
+ if (ticks.length === 0 && viewport.endIndex >= viewport.startIndex) {
179
+ const timestamp = getCandleTime(data[viewport.endIndex], viewport.endIndex);
180
+ if (timestamp >= viewport.minTimeVisible && timestamp <= viewport.maxTimeVisible) {
181
+ const x = xMapping.candleCenterX(viewport.endIndex);
182
+ ticks.push({ index: viewport.endIndex, x, label: formatTimeLabel(timestamp) });
183
+ }
184
+ }
185
+ const outOfRange = ticks.filter(tick => {
186
+ const t = getCandleTime(data[tick.index], tick.index);
187
+ return t < viewport.minTimeVisible || t > viewport.maxTimeVisible;
188
+ });
189
+ if (outOfRange.length) {
190
+ console.warn('[Chart::TimeTickTrim]', { outOfRange, minTimeVisible: viewport.minTimeVisible, maxTimeVisible: viewport.maxTimeVisible });
191
+ }
192
+ console.log('[Chart::TimeTicks]', {
193
+ count: ticks.length,
194
+ minTimeVisible: viewport.minTimeVisible,
195
+ maxTimeVisible: viewport.maxTimeVisible,
196
+ tickIndexes: ticks.map(t => t.index),
197
+ labels: ticks.map(t => t.label)
198
+ });
199
+ return ticks;
200
+ };
201
+ const drawRoundedRect$1 = (ctx, x, y, width, height, radius) => {
202
+ const r = Math.min(radius, width / 2, height / 2);
203
+ ctx.beginPath();
204
+ ctx.moveTo(x + r, y);
205
+ ctx.lineTo(x + width - r, y);
206
+ ctx.quadraticCurveTo(x + width, y, x + width, y + r);
207
+ ctx.lineTo(x + width, y + height - r);
208
+ ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
209
+ ctx.lineTo(x + r, y + height);
210
+ ctx.quadraticCurveTo(x, y + height, x, y + height - r);
211
+ ctx.lineTo(x, y + r);
212
+ ctx.quadraticCurveTo(x, y, x + r, y);
213
+ ctx.closePath();
80
214
  };
81
- const Chart = ({ data, width = 900, height = 480, visibleBars: visibleBarsProp = 60 }) => {
215
+ const Chart = ({ data, width = 900, height = 520, visibleBars: defaultVisibleBars = 60 }) => {
82
216
  const priceCanvasRef = useRef(null);
83
217
  const overlayCanvasRef = useRef(null);
84
218
  const chartAreaRef = useRef(null);
85
- const isDraggingRef = useRef(false);
86
- const lastPointerXRef = useRef(0);
87
- const barSpacingRef = useRef(12);
88
- const rafRef = useRef(null);
89
219
  const [showMenu, setShowMenu] = useState(false);
90
- const initialVisible = useMemo(() => {
91
- if (!data.length)
92
- return Math.max(MIN_VISIBLE_BARS, visibleBarsProp);
93
- const maxBars = Math.max(1, data.length);
94
- return clamp$1(Math.round(visibleBarsProp), Math.min(MIN_VISIBLE_BARS, maxBars), Math.min(MAX_VISIBLE_BARS, maxBars));
95
- }, [data.length, visibleBarsProp]);
96
220
  const [chartState, setChartState] = useState(() => ({
97
- visibleBars: initialVisible,
98
- defaultVisibleBars: initialVisible,
221
+ visibleBars: Math.max(defaultVisibleBars, MIN_VISIBLE_BARS),
99
222
  panOffsetBars: 0,
100
223
  locked: false,
101
224
  crosshairEnabled: true,
102
- crosshairX: null,
103
- crosshairY: null,
104
- hoveredIndex: null,
225
+ crosshairIndex: null,
226
+ crosshairPrice: null,
105
227
  autoScaleY: true,
106
- yMinManual: null,
107
- yMaxManual: null,
108
228
  yPaddingPct: 0.08,
109
- showCandles: true,
110
- showVolume: true,
111
229
  showGrid: true,
112
- showGridMinor: true
230
+ showVolume: true,
231
+ rightPaddingPx: 60,
232
+ leftPaddingPx: 0
113
233
  }));
114
234
  useEffect(() => {
115
235
  setChartState(prev => {
116
- if (!data.length) {
117
- return { ...prev, panOffsetBars: 0 };
118
- }
119
- const maxBars = Math.max(1, data.length);
120
- const nextVisible = clamp$1(Math.round(prev.visibleBars), Math.min(MIN_VISIBLE_BARS, maxBars), Math.min(MAX_VISIBLE_BARS, maxBars));
121
- const nextDefault = clamp$1(Math.round(prev.defaultVisibleBars), Math.min(MIN_VISIBLE_BARS, maxBars), Math.min(MAX_VISIBLE_BARS, maxBars));
122
- const bounds = getPanBounds(nextVisible, data.length);
123
- const nextPan = clamp$1(prev.panOffsetBars, bounds.min, bounds.max);
124
- if (nextVisible === prev.visibleBars && nextDefault === prev.defaultVisibleBars && nextPan === prev.panOffsetBars) {
236
+ const clamped = clamp$1(Math.round(prev.visibleBars), MIN_VISIBLE_BARS, Math.min(MAX_VISIBLE_BARS, Math.max(data.length, MIN_VISIBLE_BARS)));
237
+ const minPan = -Math.max(data.length - clamped, 0);
238
+ const panOffsetBars = clamp$1(prev.panOffsetBars, minPan, 0);
239
+ if (clamped === prev.visibleBars && panOffsetBars === prev.panOffsetBars)
125
240
  return prev;
126
- }
127
- return { ...prev, visibleBars: nextVisible, defaultVisibleBars: nextDefault, panOffsetBars: nextPan };
241
+ return { ...prev, visibleBars: clamped, panOffsetBars };
128
242
  });
129
243
  }, [data.length]);
244
+ const plotArea = useMemo(() => computePlotArea(width, height, chartState.showVolume), [chartState.showVolume, height, width]);
130
245
  const viewport = useMemo(() => computeViewport(data, chartState), [chartState, data]);
246
+ const xMapping = useMemo(() => computeXMapping(plotArea, viewport, chartState), [chartState, plotArea, viewport]);
247
+ const yMapping = useMemo(() => computeYMapping(data, viewport, plotArea, chartState), [chartState, data, plotArea, viewport]);
248
+ const timeTicks = useMemo(() => computeTimeTicks(data, viewport, xMapping, plotArea), [data, plotArea, viewport, xMapping]);
131
249
  useEffect(() => {
132
- console.log('[ChartState::Viewport]', {
133
- startIndex: viewport.startIndex,
134
- endIndex: viewport.endIndex,
135
- visibleBars: chartState.visibleBars,
136
- panOffsetBars: chartState.panOffsetBars
137
- });
138
- }, [chartState.panOffsetBars, chartState.visibleBars, viewport.endIndex, viewport.startIndex]);
139
- const safeStart = Math.max(0, viewport.startIndex);
140
- const safeEnd = Math.max(safeStart, viewport.endIndex);
141
- const visibleData = data.slice(safeStart, safeEnd + 1);
142
- const plotArea = useMemo(() => ({
143
- plotX0: 0,
144
- plotX1: width - RIGHT_PADDING_PX$1,
145
- leftPaddingPx: 0,
146
- rightPaddingPx: RIGHT_PADDING_PX$1
147
- }), [width]);
148
- const xMapping = useMemo(() => computeXMapping(plotArea, viewport), [plotArea, viewport]);
149
- useEffect(() => {
150
- barSpacingRef.current = xMapping.barSpacing;
151
- }, [xMapping.barSpacing]);
152
- const priceHeight = useMemo(() => {
153
- if (!chartState.showVolume)
154
- return height;
155
- return Math.max(160, height - Math.max(48, Math.floor(height * 0.22)));
156
- }, [chartState.showVolume, height]);
157
- const volumeHeight = Math.max(0, height - priceHeight);
158
- const priceWindow = useMemo(() => {
159
- if (!visibleData.length) {
160
- return { min: 0, max: 1 };
161
- }
162
- if (!chartState.autoScaleY && chartState.yMinManual != null && chartState.yMaxManual != null) {
163
- return { min: chartState.yMinManual, max: chartState.yMaxManual };
164
- }
165
- const values = [];
166
- visibleData.forEach(candle => {
167
- values.push(candle.low, candle.high);
168
- });
169
- values.sort((a, b) => a - b);
170
- let min = values[0] ?? 0;
171
- let max = values[values.length - 1] ?? 1;
172
- if (values.length >= 6) {
173
- const lowerIdx = Math.max(0, Math.floor(values.length * 0.02));
174
- const upperIdx = Math.min(values.length - 1, Math.ceil(values.length * 0.98) - 1);
175
- min = values[lowerIdx] ?? min;
176
- max = values[upperIdx] ?? max;
177
- }
178
- if (!Number.isFinite(min) || !Number.isFinite(max)) {
179
- return { min: 0, max: 1 };
180
- }
181
- const clampedPaddingPct = clamp$1(chartState.yPaddingPct, 0.06, 0.1);
182
- const rawRange = max - min || Math.abs(max) * 0.01 || 1;
183
- const padding = Math.max(rawRange * clampedPaddingPct, 1e-6);
184
- return { min: min - padding, max: max + padding };
185
- }, [chartState.autoScaleY, chartState.yMaxManual, chartState.yMinManual, chartState.yPaddingPct, visibleData]);
186
- const maxVolume = useMemo(() => visibleData.reduce((acc, candle) => Math.max(acc, candle.volume ?? 0), 0), [visibleData]);
187
- const minTimeVisible = visibleData.length ? getCandleTime(visibleData[0], safeStart) : 0;
188
- const maxTimeVisible = visibleData.length ? getCandleTime(visibleData[visibleData.length - 1], safeEnd) : 0;
189
- const timeTicks = useMemo(() => {
190
- if (!visibleData.length)
191
- return [];
192
- const availableWidth = plotArea.plotX1 - plotArea.plotX0;
193
- const maxTicks = Math.max(2, Math.min(10, Math.floor(availableWidth / 120)));
194
- const ticks = buildTimeTicks(minTimeVisible, maxTimeVisible, maxTicks);
195
- return ticks.map(tick => clamp$1(tick, minTimeVisible, maxTimeVisible));
196
- }, [maxTimeVisible, minTimeVisible, plotArea.plotX0, plotArea.plotX1, visibleData]);
197
- const resizeCanvas = useCallback((canvas) => {
198
- if (!canvas)
199
- return;
200
- const dpr = window.devicePixelRatio || 1;
201
- canvas.width = width * dpr;
202
- canvas.height = height * dpr;
203
- canvas.style.width = `${width}px`;
204
- canvas.style.height = `${height}px`;
205
- }, [height, width]);
206
- useEffect(() => {
250
+ const resizeCanvas = (canvas) => {
251
+ if (!canvas)
252
+ return;
253
+ const dpr = window.devicePixelRatio || 1;
254
+ canvas.width = width * dpr;
255
+ canvas.height = height * dpr;
256
+ canvas.style.width = `${width}px`;
257
+ canvas.style.height = `${height}px`;
258
+ };
207
259
  resizeCanvas(priceCanvasRef.current);
208
260
  resizeCanvas(overlayCanvasRef.current);
209
- }, [resizeCanvas]);
210
- const getXForTimeValue = useCallback((tick) => {
211
- if (maxTimeVisible === minTimeVisible) {
212
- return xMapping.x(safeEnd);
213
- }
214
- const ratio = (tick - minTimeVisible) / (maxTimeVisible - minTimeVisible);
215
- const target = safeStart + ratio * (safeEnd - safeStart);
216
- const snapped = clamp$1(Math.round(target), safeStart, safeEnd);
217
- return xMapping.x(snapped);
218
- }, [maxTimeVisible, minTimeVisible, safeEnd, safeStart, xMapping]);
219
- const drawPriceLayer = useCallback(() => {
261
+ }, [height, width]);
262
+ const priceSlice = useMemo(() => data.slice(Math.max(0, viewport.startIndex), Math.max(0, viewport.endIndex) + 1), [data, viewport.endIndex, viewport.startIndex]);
263
+ const drawBaseLayer = useCallback(() => {
220
264
  const canvas = priceCanvasRef.current;
221
265
  if (!canvas)
222
266
  return;
@@ -228,167 +272,157 @@ const Chart = ({ data, width = 900, height = 480, visibleBars: visibleBarsProp =
228
272
  ctx.clearRect(0, 0, width, height);
229
273
  ctx.fillStyle = '#05070d';
230
274
  ctx.fillRect(0, 0, width, height);
231
- if (!visibleData.length) {
275
+ if (!priceSlice.length)
232
276
  return;
233
- }
234
- const priceRange = Math.max(priceWindow.max - priceWindow.min, 1e-6);
235
- const toY = (value) => ((priceWindow.max - value) / priceRange) * priceHeight;
236
- const horizontalDivisions = 4;
277
+ // Price pane clip
237
278
  ctx.save();
238
279
  ctx.beginPath();
239
- ctx.rect(plotArea.plotX0, 0, plotArea.plotX1 - plotArea.plotX0, priceHeight);
280
+ ctx.rect(plotArea.plotX0, plotArea.pricePaneY0, plotArea.plotWidth, plotArea.pricePaneHeight);
240
281
  ctx.clip();
241
282
  if (chartState.showGrid) {
242
- ctx.strokeStyle = '#2b2f3a';
243
- ctx.lineWidth = 0.6;
283
+ ctx.strokeStyle = GRID_MAJOR;
284
+ ctx.lineWidth = 1;
244
285
  ctx.setLineDash([]);
245
- for (let i = 0; i <= horizontalDivisions; i++) {
246
- const y = alignStroke$1((i / horizontalDivisions) * priceHeight);
286
+ yMapping.yTicks.forEach(tick => {
247
287
  ctx.beginPath();
248
- ctx.moveTo(plotArea.plotX0, y);
249
- ctx.lineTo(plotArea.plotX1, y);
288
+ ctx.moveTo(plotArea.plotX0, alignStroke$1(tick.y));
289
+ ctx.lineTo(plotArea.plotX1, alignStroke$1(tick.y));
250
290
  ctx.stroke();
251
- }
291
+ });
252
292
  timeTicks.forEach(tick => {
253
- const x = alignStroke$1(getXForTimeValue(tick));
254
293
  ctx.beginPath();
255
- ctx.moveTo(x, 0);
256
- ctx.lineTo(x, priceHeight);
294
+ ctx.moveTo(alignStroke$1(tick.x), plotArea.pricePaneY0);
295
+ ctx.lineTo(alignStroke$1(tick.x), plotArea.pricePaneY0 + plotArea.pricePaneHeight);
257
296
  ctx.stroke();
258
297
  });
259
- }
260
- if (chartState.showGridMinor) {
261
- ctx.save();
262
- ctx.strokeStyle = 'rgba(58,63,83,0.45)';
263
- ctx.lineWidth = 0.4;
298
+ ctx.strokeStyle = GRID_MINOR;
264
299
  ctx.setLineDash([2, 4]);
265
- for (let i = 0; i < horizontalDivisions; i++) {
266
- const y = alignStroke$1(((i + 0.5) / horizontalDivisions) * priceHeight);
300
+ for (let i = 0; i < yMapping.yTicks.length - 1; i++) {
301
+ const current = yMapping.yTicks[i];
302
+ const next = yMapping.yTicks[i + 1];
303
+ if (!current || !next)
304
+ continue;
305
+ const midValue = (current.value + next.value) / 2;
306
+ const y = alignStroke$1(yMapping.priceToY(midValue));
267
307
  ctx.beginPath();
268
308
  ctx.moveTo(plotArea.plotX0, y);
269
309
  ctx.lineTo(plotArea.plotX1, y);
270
310
  ctx.stroke();
271
311
  }
272
312
  for (let i = 0; i < timeTicks.length - 1; i++) {
273
- const mid = (timeTicks[i] + timeTicks[i + 1]) / 2;
274
- const x = alignStroke$1(getXForTimeValue(mid));
313
+ const current = timeTicks[i];
314
+ const next = timeTicks[i + 1];
315
+ if (!current || !next)
316
+ continue;
317
+ const midX = (current.x + next.x) / 2;
275
318
  ctx.beginPath();
276
- ctx.moveTo(x, 0);
277
- ctx.lineTo(x, priceHeight);
319
+ ctx.moveTo(alignStroke$1(midX), plotArea.pricePaneY0);
320
+ ctx.lineTo(alignStroke$1(midX), plotArea.pricePaneY0 + plotArea.pricePaneHeight);
278
321
  ctx.stroke();
279
322
  }
280
- ctx.restore();
323
+ ctx.setLineDash([]);
281
324
  }
282
- if (chartState.hoveredIndex != null && chartState.hoveredIndex >= safeStart && chartState.hoveredIndex <= safeEnd) {
283
- const highlightX = xMapping.x(chartState.hoveredIndex);
284
- const halfWidth = xMapping.barSpacing * 0.5;
285
- ctx.fillStyle = 'rgba(148,163,184,0.08)';
286
- ctx.fillRect(highlightX - halfWidth, 0, halfWidth * 2, priceHeight);
325
+ const highlightIndex = chartState.crosshairIndex;
326
+ if (highlightIndex != null && highlightIndex >= viewport.startIndex && highlightIndex <= viewport.endIndex) {
327
+ const x = xMapping.candleLeftX(highlightIndex);
328
+ ctx.fillStyle = 'rgba(255,255,255,0.05)';
329
+ ctx.fillRect(x, plotArea.pricePaneY0, xMapping.bodyWidth, plotArea.pricePaneHeight);
287
330
  }
288
- if (chartState.showCandles) {
289
- visibleData.forEach((candle, offset) => {
290
- const index = safeStart + offset;
291
- const xCenter = xMapping.x(index);
292
- const strokeX = alignStroke$1(xCenter);
293
- const yHigh = toY(candle.high);
294
- const yLow = toY(candle.low);
295
- const yOpen = toY(candle.open);
296
- const yClose = toY(candle.close);
297
- const isBullish = (candle.close ?? 0) >= (candle.open ?? 0);
298
- const bodyHeight = Math.abs(yClose - yOpen);
299
- const bodyY = Math.min(yOpen, yClose);
300
- ctx.strokeStyle = isBullish ? '#089981' : '#f23645';
301
- ctx.lineWidth = Math.max(1, Math.floor(xMapping.barSpacing * 0.2));
302
- ctx.beginPath();
303
- ctx.moveTo(strokeX, yHigh);
304
- ctx.lineTo(strokeX, yLow);
305
- ctx.stroke();
306
- ctx.fillStyle = isBullish ? '#089981' : '#f23645';
307
- if (bodyHeight < 1) {
308
- ctx.beginPath();
309
- ctx.moveTo(xCenter - (xMapping.barSpacing * 0.35), alignStroke$1(yOpen));
310
- ctx.lineTo(xCenter + (xMapping.barSpacing * 0.35), alignStroke$1(yClose));
311
- ctx.stroke();
312
- }
313
- else {
314
- ctx.fillRect(xCenter - (xMapping.barSpacing * 0.35), bodyY, xMapping.barSpacing * 0.7, bodyHeight);
315
- }
316
- });
317
- }
318
- const lastCandle = data[data.length - 1];
319
- const prevCandle = data[data.length - 2];
331
+ priceSlice.forEach((candle, offset) => {
332
+ const index = viewport.startIndex + offset;
333
+ const xCenter = xMapping.candleCenterX(index);
334
+ const left = xCenter - xMapping.bodyWidth / 2;
335
+ const yOpen = yMapping.priceToY(candle.open);
336
+ const yClose = yMapping.priceToY(candle.close);
337
+ const yHigh = yMapping.priceToY(candle.high);
338
+ const yLow = yMapping.priceToY(candle.low);
339
+ const bodyTop = Math.min(yOpen, yClose);
340
+ const bodyHeight = Math.abs(yClose - yOpen) || 1;
341
+ const isBull = (candle.close ?? 0) >= (candle.open ?? 0);
342
+ ctx.strokeStyle = isBull ? BULL_COLOR : BEAR_COLOR;
343
+ ctx.lineWidth = xMapping.wickWidth;
344
+ ctx.beginPath();
345
+ ctx.moveTo(alignStroke$1(xCenter), yHigh);
346
+ ctx.lineTo(alignStroke$1(xCenter), yLow);
347
+ ctx.stroke();
348
+ ctx.fillStyle = isBull ? BULL_COLOR : BEAR_COLOR;
349
+ drawRoundedRect$1(ctx, left, bodyTop, xMapping.bodyWidth, bodyHeight, 1.5);
350
+ ctx.fill();
351
+ });
352
+ const lastCandle = data[viewport.endIndex] ?? data[data.length - 1];
320
353
  if (lastCandle) {
321
- const y = toY(lastCandle.close ?? lastCandle.open ?? 0);
322
- ctx.save();
323
- ctx.strokeStyle = '#facc15';
354
+ const lastPrice = lastCandle.close ?? lastCandle.open ?? lastCandle.high ?? 0;
355
+ const prevClose = data[viewport.endIndex - 1]?.close ?? lastPrice;
356
+ const delta = lastPrice - prevClose;
357
+ const deltaPct = prevClose ? (delta / prevClose) * 100 : 0;
358
+ const y = yMapping.priceToY(lastPrice);
359
+ ctx.strokeStyle = 'rgba(255,255,255,0.35)';
324
360
  ctx.setLineDash([6, 4]);
325
361
  ctx.lineWidth = 1;
326
362
  ctx.beginPath();
327
363
  ctx.moveTo(plotArea.plotX0, y);
328
364
  ctx.lineTo(plotArea.plotX1, y);
329
365
  ctx.stroke();
330
- ctx.restore();
331
- const prevClose = prevCandle?.close ?? lastCandle.close ?? 0;
332
- const delta = (lastCandle.close ?? 0) - prevClose;
333
- const deltaPct = prevClose ? (delta / prevClose) * 100 : 0;
334
- const badgeY = clamp$1(y, 12, priceHeight - 12);
335
- ctx.fillStyle = '#facc15';
336
- ctx.fillRect(plotArea.plotX1 + 4, badgeY - 10, RIGHT_PADDING_PX$1 - 8, 20);
366
+ ctx.setLineDash([]);
367
+ ctx.fillStyle = delta >= 0 ? '#16a34a' : '#dc2626';
368
+ const badgeX = plotArea.plotX1 + 4;
369
+ const badgeY = clamp$1(y - 12, plotArea.pricePaneY0 + 4, plotArea.pricePaneY0 + plotArea.pricePaneHeight - 24);
370
+ ctx.fillRect(badgeX, badgeY, PRICE_SCALE_WIDTH - 8, 24);
337
371
  ctx.fillStyle = '#05070d';
338
- ctx.font = '11px Inter, sans-serif';
372
+ ctx.font = '12px Inter, sans-serif';
339
373
  ctx.textAlign = 'center';
340
374
  ctx.textBaseline = 'middle';
341
- ctx.fillText((lastCandle.close ?? 0).toFixed(2), plotArea.plotX1 + (RIGHT_PADDING_PX$1 - 8) / 2 + 4, badgeY);
375
+ ctx.fillText(lastPrice.toFixed(2), badgeX + (PRICE_SCALE_WIDTH - 8) / 2, badgeY + 10);
342
376
  ctx.textAlign = 'left';
343
- ctx.fillStyle = delta >= 0 ? '#22c55e' : '#ef4444';
344
- ctx.fillText(`${delta >= 0 ? '+' : ''}${delta.toFixed(2)} (${deltaPct >= 0 ? '+' : ''}${deltaPct.toFixed(2)}%)`, plotArea.plotX1 + 6, Math.min(priceHeight + 16, height - 8));
377
+ ctx.fillStyle = '#f1f5f9';
378
+ ctx.fillText(`${delta >= 0 ? '+' : ''}${delta.toFixed(2)} (${deltaPct >= 0 ? '+' : ''}${deltaPct.toFixed(2)}%)`, badgeX, plotArea.pricePaneY0 - 6);
345
379
  }
346
- ctx.fillStyle = 'rgba(148,163,184,0.12)';
347
- ctx.font = '600 32px Inter, sans-serif';
348
- ctx.textAlign = 'left';
349
- ctx.textBaseline = 'bottom';
350
- ctx.fillText('viainti chart', plotArea.plotX0 + 12, priceHeight - 12);
351
380
  ctx.restore();
352
- ctx.fillStyle = '#9da3b4';
381
+ ctx.fillStyle = '#94a3b8';
353
382
  ctx.font = '11px Inter, sans-serif';
354
383
  ctx.textAlign = 'left';
355
384
  ctx.textBaseline = 'middle';
356
- for (let i = 0; i <= horizontalDivisions; i++) {
357
- const price = priceWindow.max - (priceRange * i) / horizontalDivisions;
358
- const y = (i / horizontalDivisions) * priceHeight;
359
- ctx.fillText(price.toFixed(2), plotArea.plotX0 + 4, y);
360
- }
385
+ yMapping.yTicks.forEach(tick => {
386
+ ctx.fillText(tick.label, plotArea.plotX0 + 4, tick.y);
387
+ });
361
388
  ctx.textAlign = 'center';
362
389
  ctx.textBaseline = 'bottom';
363
- timeTicks.forEach(tick => {
364
- const x = clamp$1(getXForTimeValue(tick), plotArea.plotX0 + 4, plotArea.plotX1 - 4);
365
- ctx.fillText(formatTickLabel(tick), x, priceHeight - 4);
390
+ timeTicks.forEach((tick, idx) => {
391
+ const prev = timeTicks[idx - 1];
392
+ if (prev && Math.abs(tick.x - prev.x) < 80)
393
+ return;
394
+ const x = clamp$1(tick.x, plotArea.plotX0 + 20, plotArea.plotX1 - 20);
395
+ ctx.fillText(tick.label, x, plotArea.pricePaneY0 + plotArea.pricePaneHeight + 18);
366
396
  });
367
- if (chartState.showVolume && volumeHeight > 0) {
368
- const baseY = priceHeight + volumeHeight;
369
- const usableHeight = volumeHeight - 12;
370
- const maxVol = Math.max(maxVolume, 1);
397
+ if (chartState.showVolume && plotArea.volumePaneHeight > 0) {
398
+ ctx.strokeStyle = 'rgba(255,255,255,0.08)';
399
+ ctx.beginPath();
400
+ ctx.moveTo(plotArea.plotX0, plotArea.volumePaneY0 - 6);
401
+ ctx.lineTo(plotArea.plotX1, plotArea.volumePaneY0 - 6);
402
+ ctx.stroke();
371
403
  ctx.save();
372
404
  ctx.beginPath();
373
- ctx.rect(plotArea.plotX0, priceHeight, plotArea.plotX1 - plotArea.plotX0, volumeHeight);
405
+ ctx.rect(plotArea.plotX0, plotArea.volumePaneY0, plotArea.plotWidth, plotArea.volumePaneHeight);
374
406
  ctx.clip();
375
- visibleData.forEach((candle, offset) => {
376
- const index = safeStart + offset;
377
- const vol = candle.volume ?? 0;
378
- const xCenter = xMapping.x(index);
379
- const barWidth = Math.max(2, xMapping.barSpacing * 0.5);
380
- const barHeight = (vol / maxVol) * usableHeight;
381
- const isBullish = (candle.close ?? 0) >= (candle.open ?? 0);
382
- ctx.fillStyle = isBullish ? '#3b82f6' : '#6366f1';
383
- ctx.fillRect(xCenter - barWidth / 2, baseY - barHeight, barWidth, barHeight);
384
- if (chartState.hoveredIndex === index) {
385
- ctx.fillStyle = 'rgba(148,163,184,0.12)';
386
- ctx.fillRect(xCenter - barWidth / 2, baseY - barHeight, barWidth, barHeight);
407
+ const maxVolume = Math.max(...priceSlice.map(c => c.volume ?? 0), 1);
408
+ priceSlice.forEach((candle, offset) => {
409
+ const index = viewport.startIndex + offset;
410
+ const xCenter = xMapping.candleCenterX(index);
411
+ const barWidth = Math.max(2, xMapping.bodyWidth * 0.6);
412
+ const volume = candle.volume ?? 0;
413
+ const heightRatio = volume / maxVolume;
414
+ const barHeight = heightRatio * (plotArea.volumePaneHeight - 12);
415
+ const y = plotArea.volumePaneY0 + plotArea.volumePaneHeight - barHeight;
416
+ ctx.fillStyle = (candle.close ?? 0) >= (candle.open ?? 0) ? 'rgba(46,204,113,0.35)' : 'rgba(231,76,60,0.35)';
417
+ ctx.fillRect(xCenter - barWidth / 2, y, barWidth, Math.max(barHeight, 2));
418
+ if (highlightIndex === index) {
419
+ ctx.fillStyle = 'rgba(255,255,255,0.12)';
420
+ ctx.fillRect(xCenter - barWidth / 2, y, barWidth, Math.max(barHeight, 2));
387
421
  }
388
422
  });
389
423
  ctx.restore();
390
424
  }
391
- }, [chartState.hoveredIndex, chartState.showCandles, chartState.showGrid, chartState.showGridMinor, chartState.showVolume, data, getXForTimeValue, height, maxVolume, plotArea.plotX0, plotArea.plotX1, priceHeight, priceWindow.max, priceWindow.min, safeEnd, safeStart, timeTicks, visibleData, volumeHeight, width, xMapping]);
425
+ }, [chartState.crosshairIndex, chartState.showGrid, chartState.showVolume, data, plotArea, priceSlice, timeTicks, viewport.endIndex, viewport.startIndex, width, height, xMapping, yMapping]);
392
426
  const drawOverlayLayer = useCallback(() => {
393
427
  const canvas = overlayCanvasRef.current;
394
428
  if (!canvas)
@@ -399,237 +433,210 @@ const Chart = ({ data, width = 900, height = 480, visibleBars: visibleBarsProp =
399
433
  const dpr = window.devicePixelRatio || 1;
400
434
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
401
435
  ctx.clearRect(0, 0, width, height);
402
- if (!chartState.crosshairEnabled || chartState.crosshairX == null || chartState.crosshairY == null) {
436
+ if (!chartState.crosshairEnabled || chartState.crosshairIndex == null || chartState.crosshairPrice == null) {
403
437
  return;
404
438
  }
405
- const hoveredIndex = chartState.hoveredIndex;
406
- const hoveredCandle = hoveredIndex != null ? data[hoveredIndex] : null;
407
- const priceRange = Math.max(priceWindow.max - priceWindow.min, 1e-6);
408
- const timeValue = hoveredIndex != null ? getCandleTime(data[hoveredIndex], hoveredIndex) : null;
409
- const priceValue = hoveredCandle?.close ?? (priceWindow.max - (chartState.crosshairY / Math.max(priceHeight, 1)) * priceRange);
410
- ctx.strokeStyle = '#8186a5';
439
+ const index = chartState.crosshairIndex;
440
+ const x = xMapping.candleCenterX(index);
441
+ const priceY = yMapping.priceToY(chartState.crosshairPrice);
442
+ ctx.strokeStyle = CROSSHAIR_COLOR;
411
443
  ctx.lineWidth = 1;
412
444
  ctx.setLineDash([4, 4]);
413
445
  ctx.beginPath();
414
- ctx.moveTo(chartState.crosshairX, 0);
415
- ctx.lineTo(chartState.crosshairX, height);
416
- ctx.moveTo(0, chartState.crosshairY);
417
- ctx.lineTo(width, chartState.crosshairY);
446
+ ctx.moveTo(x, 0);
447
+ ctx.lineTo(x, height);
448
+ ctx.moveTo(plotArea.plotX0, priceY);
449
+ ctx.lineTo(plotArea.plotX1 + PRICE_SCALE_WIDTH, priceY);
418
450
  ctx.stroke();
419
451
  ctx.setLineDash([]);
420
- if (priceValue != null) {
421
- ctx.fillStyle = '#1e293b';
422
- ctx.fillRect(plotArea.plotX1 + 2, chartState.crosshairY - 10, RIGHT_PADDING_PX$1 - 4, 20);
423
- ctx.fillStyle = '#f8fafc';
424
- ctx.font = '11px Inter, sans-serif';
425
- ctx.textAlign = 'center';
426
- ctx.textBaseline = 'middle';
427
- ctx.fillText(priceValue.toFixed(2), plotArea.plotX1 + (RIGHT_PADDING_PX$1 / 2), chartState.crosshairY);
428
- }
429
- if (timeValue != null) {
430
- const labelY = Math.min(priceHeight + volumeHeight - 4, height - 4);
431
- const labelWidth = 90;
432
- const labelX = clamp$1(chartState.crosshairX - labelWidth / 2, plotArea.plotX0 + 4, plotArea.plotX1 - labelWidth - 4);
433
- ctx.fillStyle = '#1e293b';
434
- ctx.fillRect(labelX, labelY - 18, labelWidth, 18);
435
- ctx.fillStyle = '#f8fafc';
436
- ctx.font = '10px Inter, sans-serif';
437
- ctx.textAlign = 'center';
438
- ctx.textBaseline = 'middle';
439
- ctx.fillText(formatTickLabel(timeValue), labelX + labelWidth / 2, labelY - 9);
440
- }
441
- if (hoveredCandle) {
442
- const tooltipLines = [
443
- `O ${hoveredCandle.open?.toFixed(2) ?? '—'}`,
444
- `H ${hoveredCandle.high?.toFixed(2) ?? '—'}`,
445
- `L ${hoveredCandle.low?.toFixed(2) ?? '—'}`,
446
- `C ${hoveredCandle.close?.toFixed(2) ?? '—'}`,
447
- `V ${(hoveredCandle.volume ?? 0).toLocaleString('en-US', { maximumFractionDigits: 2 })}`
448
- ];
449
- const boxWidth = 120;
450
- const boxHeight = tooltipLines.length * 16 + 12;
451
- const boxX = plotArea.plotX0 + 12;
452
- const boxY = 12;
453
- ctx.fillStyle = '#0f172aee';
454
- ctx.fillRect(boxX, boxY, boxWidth, boxHeight);
455
- ctx.strokeStyle = '#22d3ee';
456
- ctx.strokeRect(boxX, boxY, boxWidth, boxHeight);
457
- ctx.fillStyle = '#f8fafc';
458
- ctx.font = '11px Inter, sans-serif';
459
- ctx.textAlign = 'left';
460
- ctx.textBaseline = 'middle';
461
- tooltipLines.forEach((line, idx) => {
462
- ctx.fillText(line, boxX + 8, boxY + 12 + idx * 16);
463
- });
464
- }
465
- }, [chartState.crosshairEnabled, chartState.crosshairX, chartState.crosshairY, chartState.hoveredIndex, data, height, priceHeight, priceWindow.max, priceWindow.min, plotArea.plotX0, plotArea.plotX1, volumeHeight, width]);
466
- useEffect(() => {
467
- if (rafRef.current) {
468
- cancelAnimationFrame(rafRef.current);
469
- }
470
- rafRef.current = requestAnimationFrame(() => {
471
- drawPriceLayer();
472
- drawOverlayLayer();
473
- });
474
- return () => {
475
- if (rafRef.current) {
476
- cancelAnimationFrame(rafRef.current);
477
- rafRef.current = null;
478
- }
479
- };
480
- }, [drawOverlayLayer, drawPriceLayer]);
481
- const updateVisibleBars = useCallback((factor) => {
482
- if (chartState.locked)
452
+ // Price badge
453
+ ctx.fillStyle = '#111927';
454
+ ctx.fillRect(plotArea.plotX1 + 2, priceY - 10, PRICE_SCALE_WIDTH - 4, 20);
455
+ ctx.fillStyle = '#e2e8f0';
456
+ ctx.font = '11px Inter, sans-serif';
457
+ ctx.textAlign = 'center';
458
+ ctx.textBaseline = 'middle';
459
+ ctx.fillText(chartState.crosshairPrice.toFixed(4).replace(/0+$/, '').replace(/\.$/, ''), plotArea.plotX1 + (PRICE_SCALE_WIDTH - 4) / 2, priceY);
460
+ // Time badge
461
+ const tick = formatTimeLabel(getCandleTime(data[index], index));
462
+ ctx.fillStyle = '#111927';
463
+ ctx.fillRect(x - 48, plotArea.pricePaneY0 + plotArea.pricePaneHeight + 4, 96, 18);
464
+ ctx.fillStyle = '#e2e8f0';
465
+ ctx.fillText(tick, x, plotArea.pricePaneY0 + plotArea.pricePaneHeight + 13);
466
+ const candle = data[index];
467
+ if (!candle)
483
468
  return;
469
+ const tooltipWidth = 170;
470
+ const tooltipHeight = 90;
471
+ const tooltipX = clamp$1(x - tooltipWidth - 16, plotArea.plotX0 + 8, plotArea.plotX1 - tooltipWidth - 8);
472
+ const tooltipY = plotArea.pricePaneY0 + 12;
473
+ ctx.fillStyle = 'rgba(15,15,20,0.9)';
474
+ drawRoundedRect$1(ctx, tooltipX, tooltipY, tooltipWidth, tooltipHeight, 8);
475
+ ctx.fill();
476
+ ctx.strokeStyle = 'rgba(255,255,255,0.08)';
477
+ ctx.stroke();
478
+ ctx.fillStyle = '#f8fafc';
479
+ ctx.textAlign = 'left';
480
+ ctx.textBaseline = 'middle';
481
+ ctx.font = '11px Inter, sans-serif';
482
+ const lines = [
483
+ `O ${formatPrice(candle.open, 0.01)}`,
484
+ `H ${formatPrice(candle.high, 0.01)}`,
485
+ `L ${formatPrice(candle.low, 0.01)}`,
486
+ `C ${formatPrice(candle.close, 0.01)}`,
487
+ `V ${formatVolume(candle.volume ?? 0)}`
488
+ ];
489
+ lines.forEach((line, idx) => {
490
+ ctx.fillText(line, tooltipX + 10, tooltipY + 16 + idx * 16);
491
+ });
492
+ }, [chartState.crosshairEnabled, chartState.crosshairIndex, chartState.crosshairPrice, data, height, plotArea, width, xMapping, yMapping]);
493
+ useEffect(() => {
494
+ drawBaseLayer();
495
+ }, [drawBaseLayer]);
496
+ useEffect(() => {
497
+ drawOverlayLayer();
498
+ }, [drawOverlayLayer]);
499
+ const setVisibleBars = useCallback((factor) => {
484
500
  setChartState(prev => {
485
- const dataLen = Math.max(data.length, 1);
486
- const minBars = Math.min(Math.max(MIN_VISIBLE_BARS, 1), dataLen);
487
- const maxBars = Math.max(minBars, Math.min(MAX_VISIBLE_BARS, dataLen));
501
+ if (prev.locked)
502
+ return prev;
503
+ const minBars = MIN_VISIBLE_BARS;
504
+ const maxBars = Math.max(minBars, Math.min(MAX_VISIBLE_BARS, data.length || MIN_VISIBLE_BARS));
488
505
  const nextVisible = clamp$1(Math.round(prev.visibleBars * factor), minBars, maxBars);
489
506
  if (nextVisible === prev.visibleBars)
490
507
  return prev;
491
- const bounds = getPanBounds(nextVisible, dataLen);
492
- const nextPan = clamp$1(prev.panOffsetBars, bounds.min, bounds.max);
493
- return { ...prev, visibleBars: nextVisible, panOffsetBars: nextPan };
494
- });
495
- }, [chartState.locked, data.length]);
496
- const updatePan = useCallback((deltaBars) => {
497
- setChartState(prev => {
498
- const bounds = getPanBounds(prev.visibleBars, data.length);
499
- const nextPan = clamp$1(prev.panOffsetBars + deltaBars, bounds.min, bounds.max);
500
- if (nextPan === prev.panOffsetBars)
501
- return prev;
502
- return { ...prev, panOffsetBars: nextPan };
508
+ const minPan = -Math.max(data.length - nextVisible, 0);
509
+ const nextPanOffset = clamp$1(prev.panOffsetBars, minPan, 0);
510
+ return { ...prev, visibleBars: nextVisible, panOffsetBars: nextPanOffset };
503
511
  });
504
512
  }, [data.length]);
505
- const handleWheel = useCallback((event) => {
506
- if (chartState.locked)
507
- return;
508
- event.preventDefault();
509
- const factor = event.deltaY > 0 ? 1.18 : 0.85;
510
- updateVisibleBars(factor);
511
- }, [chartState.locked, updateVisibleBars]);
512
- const updateCrosshairPosition = useCallback((clientX, clientY) => {
513
- if (chartState.locked || !chartState.crosshairEnabled || !visibleData.length)
513
+ const updateCrosshair = useCallback((clientX, clientY) => {
514
+ if (chartState.locked || !chartState.crosshairEnabled || !data.length)
514
515
  return;
515
516
  const rect = chartAreaRef.current?.getBoundingClientRect();
516
517
  if (!rect)
517
518
  return;
518
519
  const localX = clientX - rect.left;
519
- clientY - rect.top;
520
- const relative = (localX - (plotArea.plotX0 + xMapping.offsetX)) / (xMapping.barSpacing || 1);
521
- const approx = safeStart + relative;
522
- const snapped = clamp$1(Math.round(approx), safeStart, safeEnd);
523
- const crosshairX = xMapping.x(snapped);
524
- const hoveredCandle = data[snapped];
525
- const priceRange = Math.max(priceWindow.max - priceWindow.min, 1e-6);
526
- const price = hoveredCandle?.close ?? hoveredCandle?.open ?? priceWindow.min;
527
- const snappedY = ((priceWindow.max - price) / priceRange) * priceHeight;
528
- const crosshairY = clamp$1(snappedY, 0, priceHeight);
529
- setChartState(prev => ({ ...prev, crosshairX, crosshairY, hoveredIndex: snapped }));
530
- }, [chartState.crosshairEnabled, chartState.locked, data, priceHeight, priceWindow.max, priceWindow.min, plotArea.plotX0, safeEnd, safeStart, visibleData.length, xMapping]);
520
+ const clampedX = clamp$1(localX, plotArea.plotX0, plotArea.plotX1);
521
+ const ratio = (clampedX - (plotArea.plotX0 + xMapping.offsetX)) / (xMapping.barSpacing || 1);
522
+ const index = clamp$1(Math.round(ratio) + viewport.startIndex, viewport.startIndex, viewport.endIndex);
523
+ const localY = clientY - rect.top;
524
+ const clampedY = clamp$1(localY, plotArea.pricePaneY0, plotArea.pricePaneY0 + plotArea.pricePaneHeight);
525
+ const priceRange = yMapping.yMax - yMapping.yMin || 1;
526
+ const price = yMapping.yMax - ((clampedY - plotArea.pricePaneY0) / Math.max(plotArea.pricePaneHeight, 1)) * priceRange;
527
+ const safePrice = Number.isFinite(price) ? price : yMapping.yMin;
528
+ setChartState(prev => ({ ...prev, crosshairIndex: index, crosshairPrice: safePrice }));
529
+ }, [chartState.crosshairEnabled, chartState.locked, data.length, plotArea, viewport.endIndex, viewport.startIndex, xMapping, yMapping]);
530
+ const clampPan = useCallback((pan, visible) => {
531
+ const minPan = -Math.max(data.length - visible, 0);
532
+ return clamp$1(pan, minPan, 0);
533
+ }, [data.length]);
534
+ const handleWheel = useCallback((event) => {
535
+ if (chartState.locked)
536
+ return;
537
+ event.preventDefault();
538
+ const factor = event.deltaY < 0 ? 0.9 : 1.1;
539
+ setVisibleBars(factor);
540
+ }, [chartState.locked, setVisibleBars]);
541
+ const isDraggingRef = useRef(false);
542
+ const lastXRef = useRef(0);
531
543
  const handlePointerDown = useCallback((event) => {
532
544
  if (event.button !== 0)
533
545
  return;
534
- event.preventDefault();
535
546
  chartAreaRef.current?.setPointerCapture(event.pointerId);
536
547
  isDraggingRef.current = true;
537
- lastPointerXRef.current = event.clientX;
538
- updateCrosshairPosition(event.clientX, event.clientY);
539
- }, [updateCrosshairPosition]);
548
+ lastXRef.current = event.clientX;
549
+ updateCrosshair(event.clientX, event.clientY);
550
+ }, [updateCrosshair]);
540
551
  const handlePointerMove = useCallback((event) => {
541
552
  if (isDraggingRef.current && !chartState.locked) {
542
- const deltaX = event.clientX - lastPointerXRef.current;
543
- lastPointerXRef.current = event.clientX;
544
- const deltaBars = -(deltaX / (barSpacingRef.current || 1));
545
- updatePan(deltaBars);
553
+ const deltaX = event.clientX - lastXRef.current;
554
+ lastXRef.current = event.clientX;
555
+ const deltaBars = -(deltaX / (xMapping.barSpacing || 1));
556
+ setChartState(prev => ({ ...prev, panOffsetBars: clampPan(prev.panOffsetBars + deltaBars, prev.visibleBars) }));
546
557
  return;
547
558
  }
548
- updateCrosshairPosition(event.clientX, event.clientY);
549
- }, [chartState.locked, updateCrosshairPosition, updatePan]);
559
+ updateCrosshair(event.clientX, event.clientY);
560
+ }, [chartState.locked, clampPan, updateCrosshair, xMapping.barSpacing]);
550
561
  const handlePointerUp = useCallback((event) => {
551
562
  chartAreaRef.current?.releasePointerCapture(event.pointerId);
552
563
  isDraggingRef.current = false;
553
564
  }, []);
554
565
  const handlePointerLeave = useCallback(() => {
555
566
  isDraggingRef.current = false;
567
+ setChartState(prev => {
568
+ if (!prev.crosshairEnabled)
569
+ return prev;
570
+ return { ...prev, crosshairIndex: null, crosshairPrice: null };
571
+ });
572
+ }, [setChartState]);
573
+ const handleButtonZoom = (direction) => {
574
+ setVisibleBars(direction === 'in' ? 0.9 : 1.1);
575
+ };
576
+ const handleReset = () => {
556
577
  setChartState(prev => ({
557
578
  ...prev,
558
- crosshairX: chartState.crosshairEnabled ? null : prev.crosshairX,
559
- crosshairY: chartState.crosshairEnabled ? null : prev.crosshairY,
560
- hoveredIndex: null
561
- }));
562
- }, [chartState.crosshairEnabled]);
563
- const handleReset = useCallback(() => {
564
- setChartState(prev => ({
565
- ...prev,
579
+ visibleBars: Math.max(defaultVisibleBars, MIN_VISIBLE_BARS),
566
580
  panOffsetBars: 0,
567
- visibleBars: prev.defaultVisibleBars,
581
+ locked: false,
568
582
  crosshairEnabled: true,
569
- crosshairX: null,
570
- crosshairY: null,
571
- hoveredIndex: null,
583
+ crosshairIndex: null,
584
+ crosshairPrice: null,
572
585
  autoScaleY: true,
573
- showCandles: true,
574
- showVolume: true,
575
586
  showGrid: true,
576
- showGridMinor: true
587
+ showVolume: true
577
588
  }));
578
- }, []);
579
- const handleSnapshot = useCallback(() => {
580
- const priceCanvas = priceCanvasRef.current;
581
- if (!priceCanvas)
589
+ };
590
+ const handleSnapshot = () => {
591
+ const base = priceCanvasRef.current;
592
+ if (!base)
582
593
  return;
583
- const overlayCanvas = overlayCanvasRef.current;
594
+ const overlay = overlayCanvasRef.current;
584
595
  const exportCanvas = document.createElement('canvas');
585
- exportCanvas.width = priceCanvas.width;
586
- exportCanvas.height = priceCanvas.height;
596
+ exportCanvas.width = base.width;
597
+ exportCanvas.height = base.height;
587
598
  const ctx = exportCanvas.getContext('2d');
588
599
  if (!ctx)
589
600
  return;
590
- ctx.drawImage(priceCanvas, 0, 0);
591
- if (overlayCanvas) {
592
- ctx.drawImage(overlayCanvas, 0, 0);
593
- }
601
+ ctx.drawImage(base, 0, 0);
602
+ if (overlay)
603
+ ctx.drawImage(overlay, 0, 0);
594
604
  const link = document.createElement('a');
595
- link.download = 'chart-simple.png';
605
+ link.download = 'viainti-chart.png';
596
606
  link.href = exportCanvas.toDataURL('image/png');
597
607
  link.click();
598
- }, []);
599
- const derivedVisibleBars = Math.max(1, Math.min(chartState.visibleBars, data.length || chartState.visibleBars));
608
+ };
600
609
  return (React.createElement("div", { style: { width } },
601
610
  React.createElement("div", { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '12px' } },
602
- React.createElement("button", { onClick: () => updateVisibleBars(0.85), disabled: chartState.locked, style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: chartState.locked ? '#1f2937' : '#0f172a', color: '#f8fafc' } }, "Zoom +"),
603
- React.createElement("button", { onClick: () => updateVisibleBars(1.18), disabled: chartState.locked, style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: chartState.locked ? '#1f2937' : '#0f172a', color: '#f8fafc' } }, "Zoom -"),
611
+ React.createElement("button", { onClick: () => handleButtonZoom('in'), disabled: chartState.locked, style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: chartState.locked ? '#1f2937' : '#0f172a', color: '#f8fafc' } }, "Zoom +"),
612
+ React.createElement("button", { onClick: () => handleButtonZoom('out'), disabled: chartState.locked, style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: chartState.locked ? '#1f2937' : '#0f172a', color: '#f8fafc' } }, "Zoom -"),
604
613
  React.createElement("button", { onClick: () => setChartState(prev => ({ ...prev, locked: !prev.locked })), style: { padding: '6px 12px', borderRadius: '999px', border: chartState.locked ? '1px solid #fbbf24' : '1px solid #475569', background: chartState.locked ? '#fbbf2411' : '#0f172a', color: '#f8fafc' } }, chartState.locked ? 'Unlock' : 'Lock'),
605
- React.createElement("button", { onClick: () => setChartState(prev => ({
606
- ...prev,
607
- crosshairEnabled: !prev.crosshairEnabled,
608
- crosshairX: prev.crosshairEnabled ? null : prev.crosshairX,
609
- crosshairY: prev.crosshairEnabled ? null : prev.crosshairY,
610
- hoveredIndex: prev.crosshairEnabled ? null : prev.hoveredIndex
611
- })), style: { padding: '6px 12px', borderRadius: '999px', border: chartState.crosshairEnabled ? '1px solid #22d3ee' : '1px solid #475569', background: chartState.crosshairEnabled ? '#22d3ee11' : '#0f172a', color: '#f8fafc' } }, chartState.crosshairEnabled ? 'Crosshair on' : 'Crosshair off'),
614
+ React.createElement("button", { onClick: () => setChartState(prev => ({ ...prev, crosshairEnabled: !prev.crosshairEnabled, crosshairIndex: prev.crosshairEnabled ? null : prev.crosshairIndex, crosshairPrice: prev.crosshairEnabled ? null : prev.crosshairPrice })), style: { padding: '6px 12px', borderRadius: '999px', border: chartState.crosshairEnabled ? '1px solid #22d3ee' : '1px solid #475569', background: chartState.crosshairEnabled ? '#22d3ee11' : '#0f172a', color: '#f8fafc' } }, chartState.crosshairEnabled ? 'Crosshair on' : 'Crosshair off'),
612
615
  React.createElement("button", { onClick: handleReset, style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: '#0f172a', color: '#f8fafc' } }, "Reset"),
613
616
  React.createElement("button", { onClick: handleSnapshot, style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: '#0f172a', color: '#f8fafc' } }, "Camera"),
614
617
  React.createElement("button", { onClick: () => setShowMenu(prev => !prev), style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: showMenu ? '#22c55e11' : '#0f172a', color: '#f8fafc' } }, "Menu")),
615
- showMenu && (React.createElement("div", { style: { marginBottom: '12px', padding: '12px', borderRadius: '16px', border: '1px solid #1f2937', background: '#0b1120', color: '#e2e8f0', display: 'flex', flexWrap: 'wrap', gap: '10px' } }, [
616
- { key: 'showCandles', label: 'Mostrar velas' },
617
- { key: 'showVolume', label: 'Mostrar volumen' },
618
- { key: 'showGrid', label: 'Grilla mayor' },
619
- { key: 'showGridMinor', label: 'Grilla menor' },
620
- { key: 'autoScaleY', label: 'Auto scale Y' }
621
- ].map(toggle => (React.createElement("label", { key: toggle.key, style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' } },
622
- React.createElement("input", { type: "checkbox", checked: chartState[toggle.key], onChange: () => setChartState(prev => ({ ...prev, [toggle.key]: !prev[toggle.key] })) }),
623
- toggle.label))))),
624
- React.createElement("div", { ref: chartAreaRef, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerLeave: handlePointerLeave, onWheel: handleWheel, style: { position: 'relative', width, height, borderRadius: '20px', overflow: 'hidden', border: '1px solid #1f2937', background: '#05070d', touchAction: 'none' } },
618
+ showMenu && (React.createElement("div", { style: { marginBottom: '12px', padding: '12px', borderRadius: '16px', border: '1px solid #1f2937', background: '#0b1120', color: '#e2e8f0', display: 'flex', flexWrap: 'wrap', gap: '12px', fontSize: '12px' } },
619
+ React.createElement("label", { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
620
+ React.createElement("input", { type: "checkbox", checked: chartState.showGrid, onChange: () => setChartState(prev => ({ ...prev, showGrid: !prev.showGrid })) }),
621
+ " Grid"),
622
+ React.createElement("label", { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
623
+ React.createElement("input", { type: "checkbox", checked: chartState.showVolume, onChange: () => setChartState(prev => ({ ...prev, showVolume: !prev.showVolume })) }),
624
+ " Volumen"),
625
+ React.createElement("label", { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
626
+ React.createElement("input", { type: "checkbox", checked: chartState.autoScaleY, onChange: () => setChartState(prev => ({ ...prev, autoScaleY: !prev.autoScaleY })) }),
627
+ " AutoScale Y"))),
628
+ React.createElement("div", { ref: chartAreaRef, style: { position: 'relative', width, height, borderRadius: '24px', border: '1px solid #1f2937', overflow: 'hidden', background: '#05070d', touchAction: 'none' }, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerLeave: handlePointerLeave, onWheel: handleWheel },
625
629
  React.createElement("canvas", { ref: priceCanvasRef, style: { position: 'absolute', inset: 0 } }),
626
630
  React.createElement("canvas", { ref: overlayCanvasRef, style: { position: 'absolute', inset: 0, pointerEvents: 'none' } })),
627
- React.createElement("p", { style: { marginTop: '8px', fontSize: '12px', color: '#94a3b8' } },
628
- "Ventana: ",
629
- derivedVisibleBars,
630
- " velas visibles \u00B7 Pan offset ",
631
+ React.createElement("p", { style: { marginTop: '10px', fontSize: '12px', color: '#94a3b8' } },
632
+ "Bars: ",
633
+ chartState.visibleBars,
634
+ " \u00B7 Pan offset: ",
631
635
  chartState.panOffsetBars.toFixed(2),
632
- " barras")));
636
+ " \u00B7 Viewport ",
637
+ viewport.startIndex,
638
+ " \u2192 ",
639
+ viewport.endIndex)));
633
640
  };
634
641
 
635
642
  var DefaultContext = {