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