viainti-chart 1.0.6 → 1.1.0

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,55 +3,635 @@
3
3
  var React = require('react');
4
4
  var framerMotion = require('framer-motion');
5
5
 
6
- const Chart = ({ data, width = 800, height = 400 }) => {
7
- const canvasRef = React.useRef(null);
6
+ const MIN_BAR_SPACING$1 = 6;
7
+ 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;
11
+ const clamp$1 = (value, min, max) => Math.max(min, Math.min(max, value));
12
+ const alignStroke$1 = (value) => Math.round(value) + 0.5;
13
+ const getCandleTime = (candle, fallbackIndex) => {
14
+ if (!candle)
15
+ return fallbackIndex;
16
+ if (typeof candle.time === 'number')
17
+ return candle.time;
18
+ if (typeof candle.timestamp === 'number')
19
+ return candle.timestamp;
20
+ return fallbackIndex;
21
+ };
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 };
52
+ };
53
+ const computeViewport = (data, state) => {
54
+ if (!data.length) {
55
+ return { startIndex: 0, endIndex: 0 };
56
+ }
57
+ const rawEnd = (data.length - 1) + Math.round(state.panOffsetBars);
58
+ const minEnd = Math.max(state.visibleBars - 1, 0);
59
+ const maxEnd = Math.max(data.length - 1, 0);
60
+ const endIndex = clamp$1(rawEnd, minEnd, maxEnd);
61
+ const startIndex = Math.max(0, endIndex - state.visibleBars + 1);
62
+ return { startIndex, endIndex };
63
+ };
64
+ const computeXMapping = (plotArea, viewport) => {
65
+ const { plotX0, plotX1, leftPaddingPx, rightPaddingPx } = plotArea;
66
+ 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);
75
+ if (Math.abs(gapLeft) > 2 || Math.abs(gapRight) > 2) {
76
+ console.error('[ChartViewport::Alignment]', { startIndex: viewport.startIndex, endIndex: viewport.endIndex, gapLeft, gapRight, barSpacing, offsetX });
77
+ }
78
+ else {
79
+ console.log('[ChartViewport::Alignment]', { startIndex: viewport.startIndex, endIndex: viewport.endIndex, gapLeft, gapRight, barSpacing, offsetX });
80
+ }
81
+ return { barSpacing, offsetX, x };
82
+ };
83
+ const Chart = ({ data, width = 900, height = 480, visibleBars: visibleBarsProp = 60 }) => {
84
+ const priceCanvasRef = React.useRef(null);
85
+ const overlayCanvasRef = React.useRef(null);
86
+ 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
+ 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
+ const [chartState, setChartState] = React.useState(() => ({
99
+ visibleBars: initialVisible,
100
+ defaultVisibleBars: initialVisible,
101
+ panOffsetBars: 0,
102
+ locked: false,
103
+ crosshairEnabled: true,
104
+ crosshairX: null,
105
+ crosshairY: null,
106
+ hoveredIndex: null,
107
+ autoScaleY: true,
108
+ yMinManual: null,
109
+ yMaxManual: null,
110
+ yPaddingPct: 0.08,
111
+ showCandles: true,
112
+ showVolume: true,
113
+ showGrid: true,
114
+ showGridMinor: true
115
+ }));
116
+ React.useEffect(() => {
117
+ 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) {
127
+ return prev;
128
+ }
129
+ return { ...prev, visibleBars: nextVisible, defaultVisibleBars: nextDefault, panOffsetBars: nextPan };
130
+ });
131
+ }, [data.length]);
132
+ const viewport = React.useMemo(() => computeViewport(data, chartState), [chartState, data]);
8
133
  React.useEffect(() => {
9
- const canvas = canvasRef.current;
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(() => {
209
+ resizeCanvas(priceCanvasRef.current);
210
+ 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(() => {
222
+ const canvas = priceCanvasRef.current;
10
223
  if (!canvas)
11
224
  return;
12
225
  const ctx = canvas.getContext('2d');
13
226
  if (!ctx)
14
227
  return;
15
- // Clear canvas
228
+ const dpr = window.devicePixelRatio || 1;
229
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
16
230
  ctx.clearRect(0, 0, width, height);
17
- if (data.length === 0)
231
+ ctx.fillStyle = '#05070d';
232
+ ctx.fillRect(0, 0, width, height);
233
+ if (!visibleData.length) {
18
234
  return;
19
- // Find min and max values
20
- const highs = data.map((d) => d.high);
21
- const lows = data.map((d) => d.low);
22
- const minPrice = Math.min(...lows);
23
- const maxPrice = Math.max(...highs);
24
- const priceRange = maxPrice - minPrice;
25
- const candleWidth = width / data.length;
26
- data.forEach((candle, index) => {
27
- const x = index * candleWidth;
28
- const yHigh = ((maxPrice - candle.high) / priceRange) * height;
29
- const yLow = ((maxPrice - candle.low) / priceRange) * height;
30
- const yOpen = ((maxPrice - candle.open) / priceRange) * height;
31
- const yClose = ((maxPrice - candle.close) / priceRange) * height;
32
- // Draw high-low line
33
- ctx.strokeStyle = 'black';
34
- ctx.beginPath();
35
- ctx.moveTo(x + candleWidth / 2, yHigh);
36
- ctx.lineTo(x + candleWidth / 2, yLow);
37
- ctx.stroke();
38
- // Draw open-close body
39
- const bodyHeight = Math.abs(yClose - yOpen);
40
- const bodyY = Math.min(yOpen, yClose);
41
- const isGreen = candle.close > candle.open;
42
- ctx.fillStyle = isGreen ? 'green' : 'red';
43
- ctx.fillRect(x + candleWidth * 0.1, bodyY, candleWidth * 0.8, bodyHeight);
44
- // Draw open/close wicks if body is small
45
- if (bodyHeight < 1) {
46
- ctx.strokeStyle = isGreen ? 'green' : 'red';
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;
239
+ ctx.save();
240
+ ctx.beginPath();
241
+ ctx.rect(plotArea.plotX0, 0, plotArea.plotX1 - plotArea.plotX0, priceHeight);
242
+ ctx.clip();
243
+ if (chartState.showGrid) {
244
+ ctx.strokeStyle = '#2b2f3a';
245
+ ctx.lineWidth = 0.6;
246
+ ctx.setLineDash([]);
247
+ for (let i = 0; i <= horizontalDivisions; i++) {
248
+ const y = alignStroke$1((i / horizontalDivisions) * priceHeight);
47
249
  ctx.beginPath();
48
- ctx.moveTo(x + candleWidth / 2, yOpen);
49
- ctx.lineTo(x + candleWidth / 2, yClose);
250
+ ctx.moveTo(plotArea.plotX0, y);
251
+ ctx.lineTo(plotArea.plotX1, y);
50
252
  ctx.stroke();
51
253
  }
254
+ timeTicks.forEach(tick => {
255
+ const x = alignStroke$1(getXForTimeValue(tick));
256
+ ctx.beginPath();
257
+ ctx.moveTo(x, 0);
258
+ ctx.lineTo(x, priceHeight);
259
+ ctx.stroke();
260
+ });
261
+ }
262
+ if (chartState.showGridMinor) {
263
+ ctx.save();
264
+ ctx.strokeStyle = 'rgba(58,63,83,0.45)';
265
+ ctx.lineWidth = 0.4;
266
+ ctx.setLineDash([2, 4]);
267
+ for (let i = 0; i < horizontalDivisions; i++) {
268
+ const y = alignStroke$1(((i + 0.5) / horizontalDivisions) * priceHeight);
269
+ ctx.beginPath();
270
+ ctx.moveTo(plotArea.plotX0, y);
271
+ ctx.lineTo(plotArea.plotX1, y);
272
+ ctx.stroke();
273
+ }
274
+ 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));
277
+ ctx.beginPath();
278
+ ctx.moveTo(x, 0);
279
+ ctx.lineTo(x, priceHeight);
280
+ ctx.stroke();
281
+ }
282
+ ctx.restore();
283
+ }
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);
289
+ }
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];
322
+ if (lastCandle) {
323
+ const y = toY(lastCandle.close ?? lastCandle.open ?? 0);
324
+ ctx.save();
325
+ ctx.strokeStyle = '#facc15';
326
+ ctx.setLineDash([6, 4]);
327
+ ctx.lineWidth = 1;
328
+ ctx.beginPath();
329
+ ctx.moveTo(plotArea.plotX0, y);
330
+ ctx.lineTo(plotArea.plotX1, y);
331
+ 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);
339
+ ctx.fillStyle = '#05070d';
340
+ ctx.font = '11px Inter, sans-serif';
341
+ ctx.textAlign = 'center';
342
+ ctx.textBaseline = 'middle';
343
+ ctx.fillText((lastCandle.close ?? 0).toFixed(2), plotArea.plotX1 + (RIGHT_PADDING_PX$1 - 8) / 2 + 4, badgeY);
344
+ 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));
347
+ }
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
+ ctx.restore();
354
+ ctx.fillStyle = '#9da3b4';
355
+ ctx.font = '11px Inter, sans-serif';
356
+ ctx.textAlign = 'left';
357
+ 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
+ }
363
+ ctx.textAlign = 'center';
364
+ 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);
52
368
  });
53
- }, [data, width, height]);
54
- return React.createElement("canvas", { ref: canvasRef, width: width, height: height });
369
+ if (chartState.showVolume && volumeHeight > 0) {
370
+ const baseY = priceHeight + volumeHeight;
371
+ const usableHeight = volumeHeight - 12;
372
+ const maxVol = Math.max(maxVolume, 1);
373
+ ctx.save();
374
+ ctx.beginPath();
375
+ ctx.rect(plotArea.plotX0, priceHeight, plotArea.plotX1 - plotArea.plotX0, volumeHeight);
376
+ 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);
389
+ }
390
+ });
391
+ ctx.restore();
392
+ }
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]);
394
+ const drawOverlayLayer = React.useCallback(() => {
395
+ const canvas = overlayCanvasRef.current;
396
+ if (!canvas)
397
+ return;
398
+ const ctx = canvas.getContext('2d');
399
+ if (!ctx)
400
+ return;
401
+ const dpr = window.devicePixelRatio || 1;
402
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
403
+ ctx.clearRect(0, 0, width, height);
404
+ if (!chartState.crosshairEnabled || chartState.crosshairX == null || chartState.crosshairY == null) {
405
+ return;
406
+ }
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';
413
+ ctx.lineWidth = 1;
414
+ ctx.setLineDash([4, 4]);
415
+ 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);
420
+ ctx.stroke();
421
+ 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)
485
+ return;
486
+ 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));
490
+ const nextVisible = clamp$1(Math.round(prev.visibleBars * factor), minBars, maxBars);
491
+ if (nextVisible === prev.visibleBars)
492
+ 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 };
505
+ });
506
+ }, [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)
516
+ return;
517
+ const rect = chartAreaRef.current?.getBoundingClientRect();
518
+ if (!rect)
519
+ return;
520
+ 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]);
533
+ const handlePointerDown = React.useCallback((event) => {
534
+ if (event.button !== 0)
535
+ return;
536
+ event.preventDefault();
537
+ chartAreaRef.current?.setPointerCapture(event.pointerId);
538
+ isDraggingRef.current = true;
539
+ lastPointerXRef.current = event.clientX;
540
+ updateCrosshairPosition(event.clientX, event.clientY);
541
+ }, [updateCrosshairPosition]);
542
+ const handlePointerMove = React.useCallback((event) => {
543
+ 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);
548
+ return;
549
+ }
550
+ updateCrosshairPosition(event.clientX, event.clientY);
551
+ }, [chartState.locked, updateCrosshairPosition, updatePan]);
552
+ const handlePointerUp = React.useCallback((event) => {
553
+ chartAreaRef.current?.releasePointerCapture(event.pointerId);
554
+ isDraggingRef.current = false;
555
+ }, []);
556
+ const handlePointerLeave = React.useCallback(() => {
557
+ isDraggingRef.current = false;
558
+ setChartState(prev => ({
559
+ ...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,
568
+ panOffsetBars: 0,
569
+ visibleBars: prev.defaultVisibleBars,
570
+ crosshairEnabled: true,
571
+ crosshairX: null,
572
+ crosshairY: null,
573
+ hoveredIndex: null,
574
+ autoScaleY: true,
575
+ showCandles: true,
576
+ showVolume: true,
577
+ showGrid: true,
578
+ showGridMinor: true
579
+ }));
580
+ }, []);
581
+ const handleSnapshot = React.useCallback(() => {
582
+ const priceCanvas = priceCanvasRef.current;
583
+ if (!priceCanvas)
584
+ return;
585
+ const overlayCanvas = overlayCanvasRef.current;
586
+ const exportCanvas = document.createElement('canvas');
587
+ exportCanvas.width = priceCanvas.width;
588
+ exportCanvas.height = priceCanvas.height;
589
+ const ctx = exportCanvas.getContext('2d');
590
+ if (!ctx)
591
+ return;
592
+ ctx.drawImage(priceCanvas, 0, 0);
593
+ if (overlayCanvas) {
594
+ ctx.drawImage(overlayCanvas, 0, 0);
595
+ }
596
+ const link = document.createElement('a');
597
+ link.download = 'chart-simple.png';
598
+ link.href = exportCanvas.toDataURL('image/png');
599
+ link.click();
600
+ }, []);
601
+ const derivedVisibleBars = Math.max(1, Math.min(chartState.visibleBars, data.length || chartState.visibleBars));
602
+ return (React.createElement("div", { style: { width } },
603
+ 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 -"),
606
+ 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'),
614
+ React.createElement("button", { onClick: handleReset, style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: '#0f172a', color: '#f8fafc' } }, "Reset"),
615
+ React.createElement("button", { onClick: handleSnapshot, style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: '#0f172a', color: '#f8fafc' } }, "Camera"),
616
+ 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' } },
627
+ React.createElement("canvas", { ref: priceCanvasRef, style: { position: 'absolute', inset: 0 } }),
628
+ 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
+ chartState.panOffsetBars.toFixed(2),
634
+ " barras")));
55
635
  };
56
636
 
57
637
  var DefaultContext = {
@@ -2085,10 +2665,14 @@ const CUSTOM_THEME_FIELDS = [
2085
2665
  const MIN_CANDLE_PX = 4;
2086
2666
  const MAX_CANDLE_PX = 28;
2087
2667
  const BUFFER_FRACTION = 0.18;
2088
- const LEFT_OFFSET_BARS = 2;
2089
- const RIGHT_OFFSET_BARS = 18;
2090
- const BAR_BODY_RATIO = 0.78;
2091
2668
  const GRID_DIVISIONS = 10;
2669
+ const RIGHT_PADDING_PX = 60;
2670
+ const RIGHT_PADDING_MIN = 40;
2671
+ const RIGHT_PADDING_MAX = 120;
2672
+ const LEFT_PADDING_PX = 0;
2673
+ const MIN_BAR_SPACING = 6;
2674
+ const MAX_BAR_SPACING = 16;
2675
+ const MIN_BODY_WIDTH = 3;
2092
2676
  const INERTIA_DURATION_MS = 900;
2093
2677
  const ZOOM_MIN = 0.2;
2094
2678
  const ZOOM_MAX = 6;
@@ -2114,19 +2698,19 @@ const determinePriceFormat = (reference) => {
2114
2698
  return { min: 2, max: 3 };
2115
2699
  return { min: 2, max: 2 };
2116
2700
  };
2117
- const getEffectiveBarCount = (count) => Math.max(count + LEFT_OFFSET_BARS + RIGHT_OFFSET_BARS, 1);
2118
- const getCandleStep = (width, count) => (width <= 0 ? 0 : width / getEffectiveBarCount(count));
2119
- const getCandleCenter = (index, width, count) => {
2120
- const step = getCandleStep(width, count);
2121
- return (index + LEFT_OFFSET_BARS + 0.5) * step;
2701
+ const getPlotArea = ({ containerWidth, containerHeight, leftToolsWidth, priceScaleWidth, paddingLeft = 0, paddingRight = 0, paddingTop = 0, paddingBottom = 0 }) => {
2702
+ const plotX0 = Math.max(0, leftToolsWidth + paddingLeft);
2703
+ const plotX1 = Math.max(plotX0, containerWidth - priceScaleWidth - paddingRight);
2704
+ const usableWidth = Math.max(1, plotX1 - plotX0);
2705
+ const usableHeight = Math.max(1, containerHeight - paddingTop - paddingBottom);
2706
+ return {
2707
+ plotX0,
2708
+ plotX1,
2709
+ plotWidth: usableWidth,
2710
+ plotHeight: usableHeight
2711
+ };
2122
2712
  };
2123
2713
  const clampPixelToChart = (pixel, width) => Math.max(0, Math.min(width, pixel));
2124
- const pixelToCandleIndex = (pixel, width, count) => {
2125
- const step = getCandleStep(width, count);
2126
- if (step === 0)
2127
- return 0;
2128
- return pixel / step - LEFT_OFFSET_BARS - 0.5;
2129
- };
2130
2714
  const clampCandleIndex = (value, length) => {
2131
2715
  if (length <= 0)
2132
2716
  return 0;
@@ -2231,7 +2815,19 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2231
2815
  const showCrosshairRef = React.useRef(false);
2232
2816
  const containerRef = React.useRef(null);
2233
2817
  const plotAreaRef = React.useRef(null);
2818
+ const layoutRowRef = React.useRef(null);
2819
+ const barGeometryRef = React.useRef({ barSpacing: MAX_BAR_SPACING, offsetX: 0 });
2234
2820
  const priceWindowRef = React.useRef({ min: 0, max: 1 });
2821
+ const projectPixelToIndex = React.useCallback((pixelX) => {
2822
+ const { barSpacing, offsetX } = barGeometryRef.current;
2823
+ if (barSpacing <= 0)
2824
+ return 0;
2825
+ return (pixelX - offsetX) / barSpacing;
2826
+ }, []);
2827
+ const getLocalCenter = React.useCallback((index) => {
2828
+ const { barSpacing, offsetX } = barGeometryRef.current;
2829
+ return offsetX + index * barSpacing;
2830
+ }, []);
2235
2831
  const [dimensions, setDimensions] = React.useState({ width: 800, height: 400, cssWidth: 800, cssHeight: 400 });
2236
2832
  const isDraggingRef = React.useRef(false);
2237
2833
  const lastMouseXRef = React.useRef(0);
@@ -2275,6 +2871,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2275
2871
  default: return 25;
2276
2872
  }
2277
2873
  }, []);
2874
+ const totalDataPoints = storeData.length;
2278
2875
  React.useEffect(() => {
2279
2876
  setData(data);
2280
2877
  }, [data, setData]);
@@ -2360,7 +2957,8 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2360
2957
  if (!chartWidth)
2361
2958
  return;
2362
2959
  const candles = getVisibleCandles();
2363
- const pxPerCandle = chartWidth / Math.max(getEffectiveBarCount(candles.length || 1), 1);
2960
+ const spacing = barGeometryRef.current.barSpacing || (chartWidth / Math.max(candles.length || 1, 1));
2961
+ const pxPerCandle = Math.max(spacing, 1e-3);
2364
2962
  if (!isFinite(pxPerCandle) || pxPerCandle === 0)
2365
2963
  return;
2366
2964
  const deltaOffset = deltaPx / Math.max(pxPerCandle, 1e-3);
@@ -2517,6 +3115,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2517
3115
  const [noteDraft, setNoteDraft] = React.useState('');
2518
3116
  const [priceFormat, setPriceFormat] = React.useState({ min: 2, max: 4 });
2519
3117
  const [isMobile, setIsMobile] = React.useState(false);
3118
+ const [layoutSize, setLayoutSize] = React.useState({ width: 0, height: 0 });
2520
3119
  React.useEffect(() => {
2521
3120
  if (!plotAreaRef.current)
2522
3121
  return;
@@ -2530,6 +3129,20 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2530
3129
  window.addEventListener('resize', checkMobile);
2531
3130
  return () => window.removeEventListener('resize', checkMobile);
2532
3131
  }, []);
3132
+ React.useEffect(() => {
3133
+ if (!layoutRowRef.current || typeof ResizeObserver === 'undefined') {
3134
+ return;
3135
+ }
3136
+ const observer = new ResizeObserver(entries => {
3137
+ const entry = entries[0];
3138
+ if (!entry)
3139
+ return;
3140
+ const { width, height } = entry.contentRect;
3141
+ setLayoutSize(prev => (prev.width === width && prev.height === height ? prev : { width, height }));
3142
+ });
3143
+ observer.observe(layoutRowRef.current);
3144
+ return () => observer.disconnect();
3145
+ }, []);
2533
3146
  React.useEffect(() => {
2534
3147
  if (!visibleData.length)
2535
3148
  return;
@@ -2603,11 +3216,77 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2603
3216
  const referenceMagnitude = Math.min(Math.abs(minPrice), Math.abs(maxPrice)) || Math.max(Math.abs(minPrice), Math.abs(maxPrice));
2604
3217
  const suggestedFormat = determinePriceFormat(referenceMagnitude);
2605
3218
  setPriceFormat((prev) => (prev.min === suggestedFormat.min && prev.max === suggestedFormat.max ? prev : suggestedFormat));
2606
- const chartWidth = Math.max(1, cssWidth - priceScaleWidth);
2607
- const chartHeight = Math.max(1, cssHeight - timeScaleHeight - volumeHeight);
2608
- const candleStep = getCandleStep(chartWidth, candles.length);
2609
- const candleWidth = Math.max(1, candleStep * BAR_BODY_RATIO);
2610
- const centerX = (index) => getCandleCenter(index, chartWidth, candles.length);
3219
+ const leftToolsWidth = showDrawingToolbar ? (isMobile ? 60 : 88) : 0;
3220
+ const layoutRect = layoutRowRef.current?.getBoundingClientRect();
3221
+ const fallbackWidth = leftToolsWidth + cssWidth;
3222
+ const layoutWidth = layoutSize.width || layoutRect?.width || fallbackWidth;
3223
+ const layoutHeight = layoutSize.height || layoutRect?.height || cssHeight;
3224
+ const paddingLeft = 0;
3225
+ const paddingRight = 0;
3226
+ const effectiveContainerHeight = Math.max(0, layoutHeight - timeScaleHeight);
3227
+ const plotArea = getPlotArea({
3228
+ containerWidth: layoutWidth,
3229
+ containerHeight: layoutHeight,
3230
+ leftToolsWidth,
3231
+ priceScaleWidth,
3232
+ paddingLeft,
3233
+ paddingRight
3234
+ });
3235
+ const chartWidth = Math.max(1, plotArea.plotWidth);
3236
+ const chartHeight = Math.max(1, plotArea.plotHeight - volumeHeight);
3237
+ const visibleBars = Math.max(candles.length, 1);
3238
+ const leftPaddingPx = LEFT_PADDING_PX;
3239
+ const rightPaddingPx = clamp(RIGHT_PADDING_PX, RIGHT_PADDING_MIN, RIGHT_PADDING_MAX);
3240
+ const targetFirstLocal = leftPaddingPx;
3241
+ const targetLastLocal = Math.max(targetFirstLocal, plotArea.plotWidth - rightPaddingPx);
3242
+ const indexSpan = Math.max(visibleBars - 1, 1);
3243
+ const availableSpan = Math.max(targetLastLocal - targetFirstLocal, MIN_BAR_SPACING);
3244
+ const barSpacing = clamp(availableSpan / indexSpan, MIN_BAR_SPACING, MAX_BAR_SPACING);
3245
+ const candleWidth = clamp(barSpacing * 0.7, MIN_BODY_WIDTH, Math.max(barSpacing - 2, MIN_BODY_WIDTH));
3246
+ const wickWidth = Math.max(1, Math.floor(candleWidth * 0.2));
3247
+ const windowMeta = visibleWindowRef.current;
3248
+ const startIndex = windowMeta.start;
3249
+ const endIndex = Math.max(windowMeta.end - 1, startIndex);
3250
+ const spanBars = Math.max(0, endIndex - startIndex);
3251
+ const rawOffset = targetLastLocal - spanBars * barSpacing;
3252
+ const maxOffset = Math.max(0, plotArea.plotWidth - rightPaddingPx);
3253
+ const offsetXLocal = clamp(rawOffset, 0, maxOffset);
3254
+ const xForIndex = (absIndex) => plotArea.plotX0 + offsetXLocal + (absIndex - startIndex) * barSpacing;
3255
+ const centerX = (index) => xForIndex(startIndex + index) - plotArea.plotX0;
3256
+ const lastXAbsolute = candles.length ? xForIndex(endIndex) : plotArea.plotX0 + offsetXLocal;
3257
+ const lastX = lastXAbsolute - plotArea.plotX0;
3258
+ const gapLeft = offsetXLocal - leftPaddingPx;
3259
+ const gapRight = targetLastLocal - lastX;
3260
+ if (Math.abs(gapLeft) > 2 || Math.abs(gapRight) > 2) {
3261
+ console.warn('[ChartLayout] gap mismatch', { gapLeft, gapRight, visibleBars, startIndex, endIndex });
3262
+ }
3263
+ const panOffset = panOffsetRef.current;
3264
+ barGeometryRef.current = { barSpacing, offsetX: offsetXLocal };
3265
+ console.log('[ChartLayout]', {
3266
+ containerWidth: layoutWidth,
3267
+ containerHeight: effectiveContainerHeight,
3268
+ leftToolsWidth,
3269
+ priceScaleWidth,
3270
+ paddingLeft,
3271
+ paddingRight,
3272
+ plotX0: plotArea.plotX0,
3273
+ plotX1: plotArea.plotX1,
3274
+ plotWidth: plotArea.plotWidth,
3275
+ plotHeight: plotArea.plotHeight,
3276
+ dataLength: totalDataPoints,
3277
+ visibleBars: candles.length,
3278
+ barSpacing,
3279
+ candleWidth,
3280
+ wickWidth,
3281
+ rightPaddingPx,
3282
+ offsetIndex: panOffset,
3283
+ offsetXPx: offsetXLocal,
3284
+ startIndex,
3285
+ endIndex,
3286
+ lastX,
3287
+ lastXAbsolute,
3288
+ gapRight
3289
+ });
2611
3290
  // Generate price labels
2612
3291
  const priceLabelsArray = [];
2613
3292
  for (let i = 0; i <= 10; i++) {
@@ -2644,7 +3323,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2644
3323
  }
2645
3324
  const timeStep = Math.max(1, Math.floor(Math.max(candles.length, 1) / GRID_DIVISIONS));
2646
3325
  for (let i = 0; i <= candles.length; i += timeStep) {
2647
- const x = alignStroke((i + LEFT_OFFSET_BARS) * candleStep);
3326
+ const x = alignStroke(offsetXLocal + i * barSpacing);
2648
3327
  if (x < 0 || x > chartWidth)
2649
3328
  continue;
2650
3329
  gridCtx.beginPath();
@@ -2680,7 +3359,6 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2680
3359
  else if (seriesType === 'step') {
2681
3360
  const prev = candles[index - 1];
2682
3361
  if (prev) {
2683
- centerX(index - 1);
2684
3362
  const prevY = ((maxPrice - prev.close) / priceRange) * chartHeight;
2685
3363
  chartCtx.lineTo(x, prevY);
2686
3364
  chartCtx.lineTo(x, yClose);
@@ -2740,7 +3418,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2740
3418
  seriesType === 'high-low';
2741
3419
  if (usesWick) {
2742
3420
  chartCtx.strokeStyle = color;
2743
- chartCtx.lineWidth = 1;
3421
+ chartCtx.lineWidth = wickWidth;
2744
3422
  chartCtx.beginPath();
2745
3423
  chartCtx.moveTo(strokeX, alignedYHigh);
2746
3424
  chartCtx.lineTo(strokeX, alignedYLow);
@@ -2846,7 +3524,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2846
3524
  });
2847
3525
  indicatorCtx.setLineDash([]); // Reset line dash
2848
3526
  }
2849
- }, [visibleData, cssWidth, cssHeight, colorScheme, calculatedIndicators, seriesType, getVisibleCandles]);
3527
+ }, [visibleData, cssWidth, cssHeight, colorScheme, calculatedIndicators, seriesType, getVisibleCandles, showDrawingToolbar, isMobile, totalDataPoints, layoutSize]);
2850
3528
  const drawCrosshair = React.useCallback(() => {
2851
3529
  const overlayCtx = overlayRef.current?.getContext('2d');
2852
3530
  if (overlayCtx && showCrosshairRef.current) {
@@ -2883,13 +3561,13 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2883
3561
  }
2884
3562
  if (!isDraggingRef.current) {
2885
3563
  const { min, max } = priceWindowRef.current;
2886
- const rawIndex = pixelToCandleIndex(x, chartWidth, visibleData.length);
3564
+ const rawIndex = projectPixelToIndex(x);
2887
3565
  const hoveredIndex = clampCandleIndex(rawIndex, visibleData.length);
2888
3566
  const hoveredCandle = visibleData[hoveredIndex];
2889
3567
  const snappedPrice = coordsRef.current.snapToPrice(y, chartHeight, min, max);
2890
3568
  const yPixel = coordsRef.current.priceToPixel(snappedPrice, chartHeight, min, max);
2891
3569
  const xPixel = magnetEnabled
2892
- ? getCandleCenter(hoveredIndex, chartWidth, visibleData.length)
3570
+ ? getLocalCenter(hoveredIndex)
2893
3571
  : clampPixelToChart(x, chartWidth);
2894
3572
  mousePosRef.current = { x: xPixel, y: yPixel };
2895
3573
  setCrosshairMeta({
@@ -2955,8 +3633,8 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2955
3633
  let snappedPrice = coordsRef.current.snapToPrice(y, chartHeight, bounds.minPrice, bounds.maxPrice, 0.5);
2956
3634
  let snappedY = y;
2957
3635
  if (magnetEnabled && targetData.length) {
2958
- const snappedIndex = clampCandleIndex(pixelToCandleIndex(x, chartWidth, dataLength), dataLength);
2959
- x = getCandleCenter(snappedIndex, chartWidth, dataLength);
3636
+ const snappedIndex = clampCandleIndex(projectPixelToIndex(x), dataLength);
3637
+ x = getLocalCenter(snappedIndex);
2960
3638
  snappedY = coordsRef.current.priceToPixel(snappedPrice, chartHeight, bounds.minPrice, bounds.maxPrice);
2961
3639
  y = snappedY;
2962
3640
  }
@@ -2964,7 +3642,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2964
3642
  x = clampPixelToChart(x, chartWidth);
2965
3643
  }
2966
3644
  const price = coordsRef.current.pixelToPrice(snappedY, chartHeight, bounds.minPrice, bounds.maxPrice);
2967
- const hoveredIndex = clampCandleIndex(pixelToCandleIndex(x, chartWidth, dataLength), dataLength);
3645
+ const hoveredIndex = clampCandleIndex(projectPixelToIndex(x), dataLength);
2968
3646
  const time = targetData[hoveredIndex]?.timestamp ?? hoveredIndex;
2969
3647
  return { x, y: snappedY, price, time };
2970
3648
  }, [visibleData, storeData, magnetEnabled, chartWidth, chartHeight, interactionsLocked]);
@@ -3177,7 +3855,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
3177
3855
  }
3178
3856
  if (visibleData.length) {
3179
3857
  const { min, max } = priceWindowRef.current;
3180
- const snappedIndex = clampCandleIndex(pixelToCandleIndex(rawX, chartWidth, visibleData.length), visibleData.length);
3858
+ const snappedIndex = clampCandleIndex(projectPixelToIndex(rawX), visibleData.length);
3181
3859
  setSelectedCandleIndex(snappedIndex);
3182
3860
  const price = coordsRef.current.pixelToPrice(rawY, chartHeight, min, max);
3183
3861
  setClickedPrice({ x: rawX, y: rawY, price });
@@ -3695,7 +4373,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
3695
4373
  fontSize: '13px',
3696
4374
  fontWeight: 600
3697
4375
  } }, "Save note"))))),
3698
- React.createElement("div", { style: {
4376
+ React.createElement("div", { ref: layoutRowRef, style: {
3699
4377
  flex: 1,
3700
4378
  display: 'flex',
3701
4379
  gap: isMobile ? '12px' : '16px',