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.mjs CHANGED
@@ -1,55 +1,635 @@
1
- import React, { useRef, useEffect, useSyncExternalStore, useState, memo, useCallback, useMemo } from 'react';
1
+ import React, { useRef, useState, useMemo, useEffect, useCallback, useSyncExternalStore, memo } from 'react';
2
2
  import { motion, AnimatePresence } from 'framer-motion';
3
3
 
4
- const Chart = ({ data, width = 800, height = 400 }) => {
5
- const canvasRef = useRef(null);
4
+ const MIN_BAR_SPACING$1 = 6;
5
+ const MAX_BAR_SPACING$1 = 18;
6
+ const RIGHT_PADDING_PX$1 = 60;
7
+ const MIN_VISIBLE_BARS = 12;
8
+ const MAX_VISIBLE_BARS = 720;
9
+ const clamp$1 = (value, min, max) => Math.max(min, Math.min(max, value));
10
+ const alignStroke$1 = (value) => Math.round(value) + 0.5;
11
+ const getCandleTime = (candle, fallbackIndex) => {
12
+ if (!candle)
13
+ return fallbackIndex;
14
+ if (typeof candle.time === 'number')
15
+ return candle.time;
16
+ if (typeof candle.timestamp === 'number')
17
+ return candle.timestamp;
18
+ return fallbackIndex;
19
+ };
20
+ const buildTimeTicks = (minTime, maxTime, desiredTicks) => {
21
+ if (!Number.isFinite(minTime) || !Number.isFinite(maxTime))
22
+ return [];
23
+ if (minTime === maxTime)
24
+ return [minTime];
25
+ const safeDesired = Math.max(2, desiredTicks);
26
+ const span = maxTime - minTime;
27
+ const step = span / (safeDesired - 1);
28
+ const ticks = [];
29
+ for (let i = 0; i < safeDesired; i++) {
30
+ const rawValue = minTime + step * i;
31
+ const clampedValue = clamp$1(rawValue, minTime, maxTime);
32
+ if (ticks.length && Math.abs(clampedValue - ticks[ticks.length - 1]) < 1e-3)
33
+ continue;
34
+ ticks.push(clampedValue);
35
+ }
36
+ ticks[ticks.length - 1] = maxTime;
37
+ return ticks;
38
+ };
39
+ const formatTickLabel = (value) => {
40
+ const date = new Date(value);
41
+ if (!Number.isNaN(date.getTime())) {
42
+ return date.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
43
+ }
44
+ return `${value}`;
45
+ };
46
+ const getPanBounds = (visibleBars, dataLength) => {
47
+ const maxPan = 0;
48
+ const minPan = Math.min(0, visibleBars - dataLength);
49
+ return { min: minPan, max: maxPan };
50
+ };
51
+ const computeViewport = (data, state) => {
52
+ if (!data.length) {
53
+ return { startIndex: 0, endIndex: 0 };
54
+ }
55
+ const rawEnd = (data.length - 1) + Math.round(state.panOffsetBars);
56
+ const minEnd = Math.max(state.visibleBars - 1, 0);
57
+ const maxEnd = Math.max(data.length - 1, 0);
58
+ const endIndex = clamp$1(rawEnd, minEnd, maxEnd);
59
+ const startIndex = Math.max(0, endIndex - state.visibleBars + 1);
60
+ return { startIndex, endIndex };
61
+ };
62
+ const computeXMapping = (plotArea, viewport) => {
63
+ const { plotX0, plotX1, leftPaddingPx, rightPaddingPx } = plotArea;
64
+ const visibleCount = Math.max(1, viewport.endIndex - viewport.startIndex + 1);
65
+ const availableWidth = Math.max(1, (plotX1 - rightPaddingPx) - (plotX0 + leftPaddingPx));
66
+ const normalizedWidth = availableWidth * (visibleCount / Math.max(1, visibleCount - 1));
67
+ const barSpacing = clamp$1(normalizedWidth / visibleCount, MIN_BAR_SPACING$1, MAX_BAR_SPACING$1);
68
+ const targetLastX = plotX1 - rightPaddingPx;
69
+ const offsetX = targetLastX - ((viewport.endIndex - viewport.startIndex) * barSpacing);
70
+ const x = (index) => plotX0 + offsetX + (index - viewport.startIndex) * barSpacing;
71
+ const gapLeft = x(viewport.startIndex) - (plotX0 + leftPaddingPx);
72
+ const gapRight = (plotX1 - rightPaddingPx) - x(viewport.endIndex);
73
+ if (Math.abs(gapLeft) > 2 || Math.abs(gapRight) > 2) {
74
+ console.error('[ChartViewport::Alignment]', { startIndex: viewport.startIndex, endIndex: viewport.endIndex, gapLeft, gapRight, barSpacing, offsetX });
75
+ }
76
+ else {
77
+ console.log('[ChartViewport::Alignment]', { startIndex: viewport.startIndex, endIndex: viewport.endIndex, gapLeft, gapRight, barSpacing, offsetX });
78
+ }
79
+ return { barSpacing, offsetX, x };
80
+ };
81
+ const Chart = ({ data, width = 900, height = 480, visibleBars: visibleBarsProp = 60 }) => {
82
+ const priceCanvasRef = useRef(null);
83
+ const overlayCanvasRef = useRef(null);
84
+ const chartAreaRef = useRef(null);
85
+ const isDraggingRef = useRef(false);
86
+ const lastPointerXRef = useRef(0);
87
+ const barSpacingRef = useRef(12);
88
+ const rafRef = useRef(null);
89
+ const [showMenu, setShowMenu] = useState(false);
90
+ const initialVisible = useMemo(() => {
91
+ if (!data.length)
92
+ return Math.max(MIN_VISIBLE_BARS, visibleBarsProp);
93
+ const maxBars = Math.max(1, data.length);
94
+ return clamp$1(Math.round(visibleBarsProp), Math.min(MIN_VISIBLE_BARS, maxBars), Math.min(MAX_VISIBLE_BARS, maxBars));
95
+ }, [data.length, visibleBarsProp]);
96
+ const [chartState, setChartState] = useState(() => ({
97
+ visibleBars: initialVisible,
98
+ defaultVisibleBars: initialVisible,
99
+ panOffsetBars: 0,
100
+ locked: false,
101
+ crosshairEnabled: true,
102
+ crosshairX: null,
103
+ crosshairY: null,
104
+ hoveredIndex: null,
105
+ autoScaleY: true,
106
+ yMinManual: null,
107
+ yMaxManual: null,
108
+ yPaddingPct: 0.08,
109
+ showCandles: true,
110
+ showVolume: true,
111
+ showGrid: true,
112
+ showGridMinor: true
113
+ }));
114
+ useEffect(() => {
115
+ setChartState(prev => {
116
+ if (!data.length) {
117
+ return { ...prev, panOffsetBars: 0 };
118
+ }
119
+ const maxBars = Math.max(1, data.length);
120
+ const nextVisible = clamp$1(Math.round(prev.visibleBars), Math.min(MIN_VISIBLE_BARS, maxBars), Math.min(MAX_VISIBLE_BARS, maxBars));
121
+ const nextDefault = clamp$1(Math.round(prev.defaultVisibleBars), Math.min(MIN_VISIBLE_BARS, maxBars), Math.min(MAX_VISIBLE_BARS, maxBars));
122
+ const bounds = getPanBounds(nextVisible, data.length);
123
+ const nextPan = clamp$1(prev.panOffsetBars, bounds.min, bounds.max);
124
+ if (nextVisible === prev.visibleBars && nextDefault === prev.defaultVisibleBars && nextPan === prev.panOffsetBars) {
125
+ return prev;
126
+ }
127
+ return { ...prev, visibleBars: nextVisible, defaultVisibleBars: nextDefault, panOffsetBars: nextPan };
128
+ });
129
+ }, [data.length]);
130
+ const viewport = useMemo(() => computeViewport(data, chartState), [chartState, data]);
6
131
  useEffect(() => {
7
- const canvas = canvasRef.current;
132
+ console.log('[ChartState::Viewport]', {
133
+ startIndex: viewport.startIndex,
134
+ endIndex: viewport.endIndex,
135
+ visibleBars: chartState.visibleBars,
136
+ panOffsetBars: chartState.panOffsetBars
137
+ });
138
+ }, [chartState.panOffsetBars, chartState.visibleBars, viewport.endIndex, viewport.startIndex]);
139
+ const safeStart = Math.max(0, viewport.startIndex);
140
+ const safeEnd = Math.max(safeStart, viewport.endIndex);
141
+ const visibleData = data.slice(safeStart, safeEnd + 1);
142
+ const plotArea = useMemo(() => ({
143
+ plotX0: 0,
144
+ plotX1: width - RIGHT_PADDING_PX$1,
145
+ leftPaddingPx: 0,
146
+ rightPaddingPx: RIGHT_PADDING_PX$1
147
+ }), [width]);
148
+ const xMapping = useMemo(() => computeXMapping(plotArea, viewport), [plotArea, viewport]);
149
+ useEffect(() => {
150
+ barSpacingRef.current = xMapping.barSpacing;
151
+ }, [xMapping.barSpacing]);
152
+ const priceHeight = useMemo(() => {
153
+ if (!chartState.showVolume)
154
+ return height;
155
+ return Math.max(160, height - Math.max(48, Math.floor(height * 0.22)));
156
+ }, [chartState.showVolume, height]);
157
+ const volumeHeight = Math.max(0, height - priceHeight);
158
+ const priceWindow = useMemo(() => {
159
+ if (!visibleData.length) {
160
+ return { min: 0, max: 1 };
161
+ }
162
+ if (!chartState.autoScaleY && chartState.yMinManual != null && chartState.yMaxManual != null) {
163
+ return { min: chartState.yMinManual, max: chartState.yMaxManual };
164
+ }
165
+ const values = [];
166
+ visibleData.forEach(candle => {
167
+ values.push(candle.low, candle.high);
168
+ });
169
+ values.sort((a, b) => a - b);
170
+ let min = values[0] ?? 0;
171
+ let max = values[values.length - 1] ?? 1;
172
+ if (values.length >= 6) {
173
+ const lowerIdx = Math.max(0, Math.floor(values.length * 0.02));
174
+ const upperIdx = Math.min(values.length - 1, Math.ceil(values.length * 0.98) - 1);
175
+ min = values[lowerIdx] ?? min;
176
+ max = values[upperIdx] ?? max;
177
+ }
178
+ if (!Number.isFinite(min) || !Number.isFinite(max)) {
179
+ return { min: 0, max: 1 };
180
+ }
181
+ const clampedPaddingPct = clamp$1(chartState.yPaddingPct, 0.06, 0.1);
182
+ const rawRange = max - min || Math.abs(max) * 0.01 || 1;
183
+ const padding = Math.max(rawRange * clampedPaddingPct, 1e-6);
184
+ return { min: min - padding, max: max + padding };
185
+ }, [chartState.autoScaleY, chartState.yMaxManual, chartState.yMinManual, chartState.yPaddingPct, visibleData]);
186
+ const maxVolume = useMemo(() => visibleData.reduce((acc, candle) => Math.max(acc, candle.volume ?? 0), 0), [visibleData]);
187
+ const minTimeVisible = visibleData.length ? getCandleTime(visibleData[0], safeStart) : 0;
188
+ const maxTimeVisible = visibleData.length ? getCandleTime(visibleData[visibleData.length - 1], safeEnd) : 0;
189
+ const timeTicks = useMemo(() => {
190
+ if (!visibleData.length)
191
+ return [];
192
+ const availableWidth = plotArea.plotX1 - plotArea.plotX0;
193
+ const maxTicks = Math.max(2, Math.min(10, Math.floor(availableWidth / 120)));
194
+ const ticks = buildTimeTicks(minTimeVisible, maxTimeVisible, maxTicks);
195
+ return ticks.map(tick => clamp$1(tick, minTimeVisible, maxTimeVisible));
196
+ }, [maxTimeVisible, minTimeVisible, plotArea.plotX0, plotArea.plotX1, visibleData]);
197
+ const resizeCanvas = useCallback((canvas) => {
198
+ if (!canvas)
199
+ return;
200
+ const dpr = window.devicePixelRatio || 1;
201
+ canvas.width = width * dpr;
202
+ canvas.height = height * dpr;
203
+ canvas.style.width = `${width}px`;
204
+ canvas.style.height = `${height}px`;
205
+ }, [height, width]);
206
+ useEffect(() => {
207
+ resizeCanvas(priceCanvasRef.current);
208
+ resizeCanvas(overlayCanvasRef.current);
209
+ }, [resizeCanvas]);
210
+ const getXForTimeValue = useCallback((tick) => {
211
+ if (maxTimeVisible === minTimeVisible) {
212
+ return xMapping.x(safeEnd);
213
+ }
214
+ const ratio = (tick - minTimeVisible) / (maxTimeVisible - minTimeVisible);
215
+ const target = safeStart + ratio * (safeEnd - safeStart);
216
+ const snapped = clamp$1(Math.round(target), safeStart, safeEnd);
217
+ return xMapping.x(snapped);
218
+ }, [maxTimeVisible, minTimeVisible, safeEnd, safeStart, xMapping]);
219
+ const drawPriceLayer = useCallback(() => {
220
+ const canvas = priceCanvasRef.current;
8
221
  if (!canvas)
9
222
  return;
10
223
  const ctx = canvas.getContext('2d');
11
224
  if (!ctx)
12
225
  return;
13
- // Clear canvas
226
+ const dpr = window.devicePixelRatio || 1;
227
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
14
228
  ctx.clearRect(0, 0, width, height);
15
- if (data.length === 0)
229
+ ctx.fillStyle = '#05070d';
230
+ ctx.fillRect(0, 0, width, height);
231
+ if (!visibleData.length) {
16
232
  return;
17
- // Find min and max values
18
- const highs = data.map((d) => d.high);
19
- const lows = data.map((d) => d.low);
20
- const minPrice = Math.min(...lows);
21
- const maxPrice = Math.max(...highs);
22
- const priceRange = maxPrice - minPrice;
23
- const candleWidth = width / data.length;
24
- data.forEach((candle, index) => {
25
- const x = index * candleWidth;
26
- const yHigh = ((maxPrice - candle.high) / priceRange) * height;
27
- const yLow = ((maxPrice - candle.low) / priceRange) * height;
28
- const yOpen = ((maxPrice - candle.open) / priceRange) * height;
29
- const yClose = ((maxPrice - candle.close) / priceRange) * height;
30
- // Draw high-low line
31
- ctx.strokeStyle = 'black';
32
- ctx.beginPath();
33
- ctx.moveTo(x + candleWidth / 2, yHigh);
34
- ctx.lineTo(x + candleWidth / 2, yLow);
35
- ctx.stroke();
36
- // Draw open-close body
37
- const bodyHeight = Math.abs(yClose - yOpen);
38
- const bodyY = Math.min(yOpen, yClose);
39
- const isGreen = candle.close > candle.open;
40
- ctx.fillStyle = isGreen ? 'green' : 'red';
41
- ctx.fillRect(x + candleWidth * 0.1, bodyY, candleWidth * 0.8, bodyHeight);
42
- // Draw open/close wicks if body is small
43
- if (bodyHeight < 1) {
44
- ctx.strokeStyle = isGreen ? 'green' : 'red';
233
+ }
234
+ const priceRange = Math.max(priceWindow.max - priceWindow.min, 1e-6);
235
+ const toY = (value) => ((priceWindow.max - value) / priceRange) * priceHeight;
236
+ const horizontalDivisions = 4;
237
+ ctx.save();
238
+ ctx.beginPath();
239
+ ctx.rect(plotArea.plotX0, 0, plotArea.plotX1 - plotArea.plotX0, priceHeight);
240
+ ctx.clip();
241
+ if (chartState.showGrid) {
242
+ ctx.strokeStyle = '#2b2f3a';
243
+ ctx.lineWidth = 0.6;
244
+ ctx.setLineDash([]);
245
+ for (let i = 0; i <= horizontalDivisions; i++) {
246
+ const y = alignStroke$1((i / horizontalDivisions) * priceHeight);
45
247
  ctx.beginPath();
46
- ctx.moveTo(x + candleWidth / 2, yOpen);
47
- ctx.lineTo(x + candleWidth / 2, yClose);
248
+ ctx.moveTo(plotArea.plotX0, y);
249
+ ctx.lineTo(plotArea.plotX1, y);
48
250
  ctx.stroke();
49
251
  }
252
+ timeTicks.forEach(tick => {
253
+ const x = alignStroke$1(getXForTimeValue(tick));
254
+ ctx.beginPath();
255
+ ctx.moveTo(x, 0);
256
+ ctx.lineTo(x, priceHeight);
257
+ ctx.stroke();
258
+ });
259
+ }
260
+ if (chartState.showGridMinor) {
261
+ ctx.save();
262
+ ctx.strokeStyle = 'rgba(58,63,83,0.45)';
263
+ ctx.lineWidth = 0.4;
264
+ ctx.setLineDash([2, 4]);
265
+ for (let i = 0; i < horizontalDivisions; i++) {
266
+ const y = alignStroke$1(((i + 0.5) / horizontalDivisions) * priceHeight);
267
+ ctx.beginPath();
268
+ ctx.moveTo(plotArea.plotX0, y);
269
+ ctx.lineTo(plotArea.plotX1, y);
270
+ ctx.stroke();
271
+ }
272
+ for (let i = 0; i < timeTicks.length - 1; i++) {
273
+ const mid = (timeTicks[i] + timeTicks[i + 1]) / 2;
274
+ const x = alignStroke$1(getXForTimeValue(mid));
275
+ ctx.beginPath();
276
+ ctx.moveTo(x, 0);
277
+ ctx.lineTo(x, priceHeight);
278
+ ctx.stroke();
279
+ }
280
+ ctx.restore();
281
+ }
282
+ if (chartState.hoveredIndex != null && chartState.hoveredIndex >= safeStart && chartState.hoveredIndex <= safeEnd) {
283
+ const highlightX = xMapping.x(chartState.hoveredIndex);
284
+ const halfWidth = xMapping.barSpacing * 0.5;
285
+ ctx.fillStyle = 'rgba(148,163,184,0.08)';
286
+ ctx.fillRect(highlightX - halfWidth, 0, halfWidth * 2, priceHeight);
287
+ }
288
+ if (chartState.showCandles) {
289
+ visibleData.forEach((candle, offset) => {
290
+ const index = safeStart + offset;
291
+ const xCenter = xMapping.x(index);
292
+ const strokeX = alignStroke$1(xCenter);
293
+ const yHigh = toY(candle.high);
294
+ const yLow = toY(candle.low);
295
+ const yOpen = toY(candle.open);
296
+ const yClose = toY(candle.close);
297
+ const isBullish = (candle.close ?? 0) >= (candle.open ?? 0);
298
+ const bodyHeight = Math.abs(yClose - yOpen);
299
+ const bodyY = Math.min(yOpen, yClose);
300
+ ctx.strokeStyle = isBullish ? '#089981' : '#f23645';
301
+ ctx.lineWidth = Math.max(1, Math.floor(xMapping.barSpacing * 0.2));
302
+ ctx.beginPath();
303
+ ctx.moveTo(strokeX, yHigh);
304
+ ctx.lineTo(strokeX, yLow);
305
+ ctx.stroke();
306
+ ctx.fillStyle = isBullish ? '#089981' : '#f23645';
307
+ if (bodyHeight < 1) {
308
+ ctx.beginPath();
309
+ ctx.moveTo(xCenter - (xMapping.barSpacing * 0.35), alignStroke$1(yOpen));
310
+ ctx.lineTo(xCenter + (xMapping.barSpacing * 0.35), alignStroke$1(yClose));
311
+ ctx.stroke();
312
+ }
313
+ else {
314
+ ctx.fillRect(xCenter - (xMapping.barSpacing * 0.35), bodyY, xMapping.barSpacing * 0.7, bodyHeight);
315
+ }
316
+ });
317
+ }
318
+ const lastCandle = data[data.length - 1];
319
+ const prevCandle = data[data.length - 2];
320
+ if (lastCandle) {
321
+ const y = toY(lastCandle.close ?? lastCandle.open ?? 0);
322
+ ctx.save();
323
+ ctx.strokeStyle = '#facc15';
324
+ ctx.setLineDash([6, 4]);
325
+ ctx.lineWidth = 1;
326
+ ctx.beginPath();
327
+ ctx.moveTo(plotArea.plotX0, y);
328
+ ctx.lineTo(plotArea.plotX1, y);
329
+ ctx.stroke();
330
+ ctx.restore();
331
+ const prevClose = prevCandle?.close ?? lastCandle.close ?? 0;
332
+ const delta = (lastCandle.close ?? 0) - prevClose;
333
+ const deltaPct = prevClose ? (delta / prevClose) * 100 : 0;
334
+ const badgeY = clamp$1(y, 12, priceHeight - 12);
335
+ ctx.fillStyle = '#facc15';
336
+ ctx.fillRect(plotArea.plotX1 + 4, badgeY - 10, RIGHT_PADDING_PX$1 - 8, 20);
337
+ ctx.fillStyle = '#05070d';
338
+ ctx.font = '11px Inter, sans-serif';
339
+ ctx.textAlign = 'center';
340
+ ctx.textBaseline = 'middle';
341
+ ctx.fillText((lastCandle.close ?? 0).toFixed(2), plotArea.plotX1 + (RIGHT_PADDING_PX$1 - 8) / 2 + 4, badgeY);
342
+ ctx.textAlign = 'left';
343
+ ctx.fillStyle = delta >= 0 ? '#22c55e' : '#ef4444';
344
+ ctx.fillText(`${delta >= 0 ? '+' : ''}${delta.toFixed(2)} (${deltaPct >= 0 ? '+' : ''}${deltaPct.toFixed(2)}%)`, plotArea.plotX1 + 6, Math.min(priceHeight + 16, height - 8));
345
+ }
346
+ ctx.fillStyle = 'rgba(148,163,184,0.12)';
347
+ ctx.font = '600 32px Inter, sans-serif';
348
+ ctx.textAlign = 'left';
349
+ ctx.textBaseline = 'bottom';
350
+ ctx.fillText('viainti chart', plotArea.plotX0 + 12, priceHeight - 12);
351
+ ctx.restore();
352
+ ctx.fillStyle = '#9da3b4';
353
+ ctx.font = '11px Inter, sans-serif';
354
+ ctx.textAlign = 'left';
355
+ ctx.textBaseline = 'middle';
356
+ for (let i = 0; i <= horizontalDivisions; i++) {
357
+ const price = priceWindow.max - (priceRange * i) / horizontalDivisions;
358
+ const y = (i / horizontalDivisions) * priceHeight;
359
+ ctx.fillText(price.toFixed(2), plotArea.plotX0 + 4, y);
360
+ }
361
+ ctx.textAlign = 'center';
362
+ ctx.textBaseline = 'bottom';
363
+ timeTicks.forEach(tick => {
364
+ const x = clamp$1(getXForTimeValue(tick), plotArea.plotX0 + 4, plotArea.plotX1 - 4);
365
+ ctx.fillText(formatTickLabel(tick), x, priceHeight - 4);
50
366
  });
51
- }, [data, width, height]);
52
- return React.createElement("canvas", { ref: canvasRef, width: width, height: height });
367
+ if (chartState.showVolume && volumeHeight > 0) {
368
+ const baseY = priceHeight + volumeHeight;
369
+ const usableHeight = volumeHeight - 12;
370
+ const maxVol = Math.max(maxVolume, 1);
371
+ ctx.save();
372
+ ctx.beginPath();
373
+ ctx.rect(plotArea.plotX0, priceHeight, plotArea.plotX1 - plotArea.plotX0, volumeHeight);
374
+ ctx.clip();
375
+ visibleData.forEach((candle, offset) => {
376
+ const index = safeStart + offset;
377
+ const vol = candle.volume ?? 0;
378
+ const xCenter = xMapping.x(index);
379
+ const barWidth = Math.max(2, xMapping.barSpacing * 0.5);
380
+ const barHeight = (vol / maxVol) * usableHeight;
381
+ const isBullish = (candle.close ?? 0) >= (candle.open ?? 0);
382
+ ctx.fillStyle = isBullish ? '#3b82f6' : '#6366f1';
383
+ ctx.fillRect(xCenter - barWidth / 2, baseY - barHeight, barWidth, barHeight);
384
+ if (chartState.hoveredIndex === index) {
385
+ ctx.fillStyle = 'rgba(148,163,184,0.12)';
386
+ ctx.fillRect(xCenter - barWidth / 2, baseY - barHeight, barWidth, barHeight);
387
+ }
388
+ });
389
+ ctx.restore();
390
+ }
391
+ }, [chartState.hoveredIndex, chartState.showCandles, chartState.showGrid, chartState.showGridMinor, chartState.showVolume, data, getXForTimeValue, height, maxVolume, plotArea.plotX0, plotArea.plotX1, priceHeight, priceWindow.max, priceWindow.min, safeEnd, safeStart, timeTicks, visibleData, volumeHeight, width, xMapping]);
392
+ const drawOverlayLayer = useCallback(() => {
393
+ const canvas = overlayCanvasRef.current;
394
+ if (!canvas)
395
+ return;
396
+ const ctx = canvas.getContext('2d');
397
+ if (!ctx)
398
+ return;
399
+ const dpr = window.devicePixelRatio || 1;
400
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
401
+ ctx.clearRect(0, 0, width, height);
402
+ if (!chartState.crosshairEnabled || chartState.crosshairX == null || chartState.crosshairY == null) {
403
+ return;
404
+ }
405
+ const hoveredIndex = chartState.hoveredIndex;
406
+ const hoveredCandle = hoveredIndex != null ? data[hoveredIndex] : null;
407
+ const priceRange = Math.max(priceWindow.max - priceWindow.min, 1e-6);
408
+ const timeValue = hoveredIndex != null ? getCandleTime(data[hoveredIndex], hoveredIndex) : null;
409
+ const priceValue = hoveredCandle?.close ?? (priceWindow.max - (chartState.crosshairY / Math.max(priceHeight, 1)) * priceRange);
410
+ ctx.strokeStyle = '#8186a5';
411
+ ctx.lineWidth = 1;
412
+ ctx.setLineDash([4, 4]);
413
+ ctx.beginPath();
414
+ ctx.moveTo(chartState.crosshairX, 0);
415
+ ctx.lineTo(chartState.crosshairX, height);
416
+ ctx.moveTo(0, chartState.crosshairY);
417
+ ctx.lineTo(width, chartState.crosshairY);
418
+ ctx.stroke();
419
+ ctx.setLineDash([]);
420
+ if (priceValue != null) {
421
+ ctx.fillStyle = '#1e293b';
422
+ ctx.fillRect(plotArea.plotX1 + 2, chartState.crosshairY - 10, RIGHT_PADDING_PX$1 - 4, 20);
423
+ ctx.fillStyle = '#f8fafc';
424
+ ctx.font = '11px Inter, sans-serif';
425
+ ctx.textAlign = 'center';
426
+ ctx.textBaseline = 'middle';
427
+ ctx.fillText(priceValue.toFixed(2), plotArea.plotX1 + (RIGHT_PADDING_PX$1 / 2), chartState.crosshairY);
428
+ }
429
+ if (timeValue != null) {
430
+ const labelY = Math.min(priceHeight + volumeHeight - 4, height - 4);
431
+ const labelWidth = 90;
432
+ const labelX = clamp$1(chartState.crosshairX - labelWidth / 2, plotArea.plotX0 + 4, plotArea.plotX1 - labelWidth - 4);
433
+ ctx.fillStyle = '#1e293b';
434
+ ctx.fillRect(labelX, labelY - 18, labelWidth, 18);
435
+ ctx.fillStyle = '#f8fafc';
436
+ ctx.font = '10px Inter, sans-serif';
437
+ ctx.textAlign = 'center';
438
+ ctx.textBaseline = 'middle';
439
+ ctx.fillText(formatTickLabel(timeValue), labelX + labelWidth / 2, labelY - 9);
440
+ }
441
+ if (hoveredCandle) {
442
+ const tooltipLines = [
443
+ `O ${hoveredCandle.open?.toFixed(2) ?? '—'}`,
444
+ `H ${hoveredCandle.high?.toFixed(2) ?? '—'}`,
445
+ `L ${hoveredCandle.low?.toFixed(2) ?? '—'}`,
446
+ `C ${hoveredCandle.close?.toFixed(2) ?? '—'}`,
447
+ `V ${(hoveredCandle.volume ?? 0).toLocaleString('en-US', { maximumFractionDigits: 2 })}`
448
+ ];
449
+ const boxWidth = 120;
450
+ const boxHeight = tooltipLines.length * 16 + 12;
451
+ const boxX = plotArea.plotX0 + 12;
452
+ const boxY = 12;
453
+ ctx.fillStyle = '#0f172aee';
454
+ ctx.fillRect(boxX, boxY, boxWidth, boxHeight);
455
+ ctx.strokeStyle = '#22d3ee';
456
+ ctx.strokeRect(boxX, boxY, boxWidth, boxHeight);
457
+ ctx.fillStyle = '#f8fafc';
458
+ ctx.font = '11px Inter, sans-serif';
459
+ ctx.textAlign = 'left';
460
+ ctx.textBaseline = 'middle';
461
+ tooltipLines.forEach((line, idx) => {
462
+ ctx.fillText(line, boxX + 8, boxY + 12 + idx * 16);
463
+ });
464
+ }
465
+ }, [chartState.crosshairEnabled, chartState.crosshairX, chartState.crosshairY, chartState.hoveredIndex, data, height, priceHeight, priceWindow.max, priceWindow.min, plotArea.plotX0, plotArea.plotX1, volumeHeight, width]);
466
+ useEffect(() => {
467
+ if (rafRef.current) {
468
+ cancelAnimationFrame(rafRef.current);
469
+ }
470
+ rafRef.current = requestAnimationFrame(() => {
471
+ drawPriceLayer();
472
+ drawOverlayLayer();
473
+ });
474
+ return () => {
475
+ if (rafRef.current) {
476
+ cancelAnimationFrame(rafRef.current);
477
+ rafRef.current = null;
478
+ }
479
+ };
480
+ }, [drawOverlayLayer, drawPriceLayer]);
481
+ const updateVisibleBars = useCallback((factor) => {
482
+ if (chartState.locked)
483
+ return;
484
+ setChartState(prev => {
485
+ const dataLen = Math.max(data.length, 1);
486
+ const minBars = Math.min(Math.max(MIN_VISIBLE_BARS, 1), dataLen);
487
+ const maxBars = Math.max(minBars, Math.min(MAX_VISIBLE_BARS, dataLen));
488
+ const nextVisible = clamp$1(Math.round(prev.visibleBars * factor), minBars, maxBars);
489
+ if (nextVisible === prev.visibleBars)
490
+ return prev;
491
+ const bounds = getPanBounds(nextVisible, dataLen);
492
+ const nextPan = clamp$1(prev.panOffsetBars, bounds.min, bounds.max);
493
+ return { ...prev, visibleBars: nextVisible, panOffsetBars: nextPan };
494
+ });
495
+ }, [chartState.locked, data.length]);
496
+ const updatePan = useCallback((deltaBars) => {
497
+ setChartState(prev => {
498
+ const bounds = getPanBounds(prev.visibleBars, data.length);
499
+ const nextPan = clamp$1(prev.panOffsetBars + deltaBars, bounds.min, bounds.max);
500
+ if (nextPan === prev.panOffsetBars)
501
+ return prev;
502
+ return { ...prev, panOffsetBars: nextPan };
503
+ });
504
+ }, [data.length]);
505
+ const handleWheel = useCallback((event) => {
506
+ if (chartState.locked)
507
+ return;
508
+ event.preventDefault();
509
+ const factor = event.deltaY > 0 ? 1.18 : 0.85;
510
+ updateVisibleBars(factor);
511
+ }, [chartState.locked, updateVisibleBars]);
512
+ const updateCrosshairPosition = useCallback((clientX, clientY) => {
513
+ if (chartState.locked || !chartState.crosshairEnabled || !visibleData.length)
514
+ return;
515
+ const rect = chartAreaRef.current?.getBoundingClientRect();
516
+ if (!rect)
517
+ return;
518
+ const localX = clientX - rect.left;
519
+ clientY - rect.top;
520
+ const relative = (localX - (plotArea.plotX0 + xMapping.offsetX)) / (xMapping.barSpacing || 1);
521
+ const approx = safeStart + relative;
522
+ const snapped = clamp$1(Math.round(approx), safeStart, safeEnd);
523
+ const crosshairX = xMapping.x(snapped);
524
+ const hoveredCandle = data[snapped];
525
+ const priceRange = Math.max(priceWindow.max - priceWindow.min, 1e-6);
526
+ const price = hoveredCandle?.close ?? hoveredCandle?.open ?? priceWindow.min;
527
+ const snappedY = ((priceWindow.max - price) / priceRange) * priceHeight;
528
+ const crosshairY = clamp$1(snappedY, 0, priceHeight);
529
+ setChartState(prev => ({ ...prev, crosshairX, crosshairY, hoveredIndex: snapped }));
530
+ }, [chartState.crosshairEnabled, chartState.locked, data, priceHeight, priceWindow.max, priceWindow.min, plotArea.plotX0, safeEnd, safeStart, visibleData.length, xMapping]);
531
+ const handlePointerDown = useCallback((event) => {
532
+ if (event.button !== 0)
533
+ return;
534
+ event.preventDefault();
535
+ chartAreaRef.current?.setPointerCapture(event.pointerId);
536
+ isDraggingRef.current = true;
537
+ lastPointerXRef.current = event.clientX;
538
+ updateCrosshairPosition(event.clientX, event.clientY);
539
+ }, [updateCrosshairPosition]);
540
+ const handlePointerMove = useCallback((event) => {
541
+ if (isDraggingRef.current && !chartState.locked) {
542
+ const deltaX = event.clientX - lastPointerXRef.current;
543
+ lastPointerXRef.current = event.clientX;
544
+ const deltaBars = -(deltaX / (barSpacingRef.current || 1));
545
+ updatePan(deltaBars);
546
+ return;
547
+ }
548
+ updateCrosshairPosition(event.clientX, event.clientY);
549
+ }, [chartState.locked, updateCrosshairPosition, updatePan]);
550
+ const handlePointerUp = useCallback((event) => {
551
+ chartAreaRef.current?.releasePointerCapture(event.pointerId);
552
+ isDraggingRef.current = false;
553
+ }, []);
554
+ const handlePointerLeave = useCallback(() => {
555
+ isDraggingRef.current = false;
556
+ setChartState(prev => ({
557
+ ...prev,
558
+ crosshairX: chartState.crosshairEnabled ? null : prev.crosshairX,
559
+ crosshairY: chartState.crosshairEnabled ? null : prev.crosshairY,
560
+ hoveredIndex: null
561
+ }));
562
+ }, [chartState.crosshairEnabled]);
563
+ const handleReset = useCallback(() => {
564
+ setChartState(prev => ({
565
+ ...prev,
566
+ panOffsetBars: 0,
567
+ visibleBars: prev.defaultVisibleBars,
568
+ crosshairEnabled: true,
569
+ crosshairX: null,
570
+ crosshairY: null,
571
+ hoveredIndex: null,
572
+ autoScaleY: true,
573
+ showCandles: true,
574
+ showVolume: true,
575
+ showGrid: true,
576
+ showGridMinor: true
577
+ }));
578
+ }, []);
579
+ const handleSnapshot = useCallback(() => {
580
+ const priceCanvas = priceCanvasRef.current;
581
+ if (!priceCanvas)
582
+ return;
583
+ const overlayCanvas = overlayCanvasRef.current;
584
+ const exportCanvas = document.createElement('canvas');
585
+ exportCanvas.width = priceCanvas.width;
586
+ exportCanvas.height = priceCanvas.height;
587
+ const ctx = exportCanvas.getContext('2d');
588
+ if (!ctx)
589
+ return;
590
+ ctx.drawImage(priceCanvas, 0, 0);
591
+ if (overlayCanvas) {
592
+ ctx.drawImage(overlayCanvas, 0, 0);
593
+ }
594
+ const link = document.createElement('a');
595
+ link.download = 'chart-simple.png';
596
+ link.href = exportCanvas.toDataURL('image/png');
597
+ link.click();
598
+ }, []);
599
+ const derivedVisibleBars = Math.max(1, Math.min(chartState.visibleBars, data.length || chartState.visibleBars));
600
+ return (React.createElement("div", { style: { width } },
601
+ React.createElement("div", { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '12px' } },
602
+ React.createElement("button", { onClick: () => updateVisibleBars(0.85), disabled: chartState.locked, style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: chartState.locked ? '#1f2937' : '#0f172a', color: '#f8fafc' } }, "Zoom +"),
603
+ React.createElement("button", { onClick: () => updateVisibleBars(1.18), disabled: chartState.locked, style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: chartState.locked ? '#1f2937' : '#0f172a', color: '#f8fafc' } }, "Zoom -"),
604
+ React.createElement("button", { onClick: () => setChartState(prev => ({ ...prev, locked: !prev.locked })), style: { padding: '6px 12px', borderRadius: '999px', border: chartState.locked ? '1px solid #fbbf24' : '1px solid #475569', background: chartState.locked ? '#fbbf2411' : '#0f172a', color: '#f8fafc' } }, chartState.locked ? 'Unlock' : 'Lock'),
605
+ React.createElement("button", { onClick: () => setChartState(prev => ({
606
+ ...prev,
607
+ crosshairEnabled: !prev.crosshairEnabled,
608
+ crosshairX: prev.crosshairEnabled ? null : prev.crosshairX,
609
+ crosshairY: prev.crosshairEnabled ? null : prev.crosshairY,
610
+ hoveredIndex: prev.crosshairEnabled ? null : prev.hoveredIndex
611
+ })), style: { padding: '6px 12px', borderRadius: '999px', border: chartState.crosshairEnabled ? '1px solid #22d3ee' : '1px solid #475569', background: chartState.crosshairEnabled ? '#22d3ee11' : '#0f172a', color: '#f8fafc' } }, chartState.crosshairEnabled ? 'Crosshair on' : 'Crosshair off'),
612
+ React.createElement("button", { onClick: handleReset, style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: '#0f172a', color: '#f8fafc' } }, "Reset"),
613
+ React.createElement("button", { onClick: handleSnapshot, style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: '#0f172a', color: '#f8fafc' } }, "Camera"),
614
+ React.createElement("button", { onClick: () => setShowMenu(prev => !prev), style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: showMenu ? '#22c55e11' : '#0f172a', color: '#f8fafc' } }, "Menu")),
615
+ showMenu && (React.createElement("div", { style: { marginBottom: '12px', padding: '12px', borderRadius: '16px', border: '1px solid #1f2937', background: '#0b1120', color: '#e2e8f0', display: 'flex', flexWrap: 'wrap', gap: '10px' } }, [
616
+ { key: 'showCandles', label: 'Mostrar velas' },
617
+ { key: 'showVolume', label: 'Mostrar volumen' },
618
+ { key: 'showGrid', label: 'Grilla mayor' },
619
+ { key: 'showGridMinor', label: 'Grilla menor' },
620
+ { key: 'autoScaleY', label: 'Auto scale Y' }
621
+ ].map(toggle => (React.createElement("label", { key: toggle.key, style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' } },
622
+ React.createElement("input", { type: "checkbox", checked: chartState[toggle.key], onChange: () => setChartState(prev => ({ ...prev, [toggle.key]: !prev[toggle.key] })) }),
623
+ toggle.label))))),
624
+ React.createElement("div", { ref: chartAreaRef, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerLeave: handlePointerLeave, onWheel: handleWheel, style: { position: 'relative', width, height, borderRadius: '20px', overflow: 'hidden', border: '1px solid #1f2937', background: '#05070d', touchAction: 'none' } },
625
+ React.createElement("canvas", { ref: priceCanvasRef, style: { position: 'absolute', inset: 0 } }),
626
+ React.createElement("canvas", { ref: overlayCanvasRef, style: { position: 'absolute', inset: 0, pointerEvents: 'none' } })),
627
+ React.createElement("p", { style: { marginTop: '8px', fontSize: '12px', color: '#94a3b8' } },
628
+ "Ventana: ",
629
+ derivedVisibleBars,
630
+ " velas visibles \u00B7 Pan offset ",
631
+ chartState.panOffsetBars.toFixed(2),
632
+ " barras")));
53
633
  };
54
634
 
55
635
  var DefaultContext = {
@@ -2083,10 +2663,14 @@ const CUSTOM_THEME_FIELDS = [
2083
2663
  const MIN_CANDLE_PX = 4;
2084
2664
  const MAX_CANDLE_PX = 28;
2085
2665
  const BUFFER_FRACTION = 0.18;
2086
- const LEFT_OFFSET_BARS = 2;
2087
- const RIGHT_OFFSET_BARS = 18;
2088
- const BAR_BODY_RATIO = 0.78;
2089
2666
  const GRID_DIVISIONS = 10;
2667
+ const RIGHT_PADDING_PX = 60;
2668
+ const RIGHT_PADDING_MIN = 40;
2669
+ const RIGHT_PADDING_MAX = 120;
2670
+ const LEFT_PADDING_PX = 0;
2671
+ const MIN_BAR_SPACING = 6;
2672
+ const MAX_BAR_SPACING = 16;
2673
+ const MIN_BODY_WIDTH = 3;
2090
2674
  const INERTIA_DURATION_MS = 900;
2091
2675
  const ZOOM_MIN = 0.2;
2092
2676
  const ZOOM_MAX = 6;
@@ -2112,19 +2696,19 @@ const determinePriceFormat = (reference) => {
2112
2696
  return { min: 2, max: 3 };
2113
2697
  return { min: 2, max: 2 };
2114
2698
  };
2115
- const getEffectiveBarCount = (count) => Math.max(count + LEFT_OFFSET_BARS + RIGHT_OFFSET_BARS, 1);
2116
- const getCandleStep = (width, count) => (width <= 0 ? 0 : width / getEffectiveBarCount(count));
2117
- const getCandleCenter = (index, width, count) => {
2118
- const step = getCandleStep(width, count);
2119
- return (index + LEFT_OFFSET_BARS + 0.5) * step;
2699
+ const getPlotArea = ({ containerWidth, containerHeight, leftToolsWidth, priceScaleWidth, paddingLeft = 0, paddingRight = 0, paddingTop = 0, paddingBottom = 0 }) => {
2700
+ const plotX0 = Math.max(0, leftToolsWidth + paddingLeft);
2701
+ const plotX1 = Math.max(plotX0, containerWidth - priceScaleWidth - paddingRight);
2702
+ const usableWidth = Math.max(1, plotX1 - plotX0);
2703
+ const usableHeight = Math.max(1, containerHeight - paddingTop - paddingBottom);
2704
+ return {
2705
+ plotX0,
2706
+ plotX1,
2707
+ plotWidth: usableWidth,
2708
+ plotHeight: usableHeight
2709
+ };
2120
2710
  };
2121
2711
  const clampPixelToChart = (pixel, width) => Math.max(0, Math.min(width, pixel));
2122
- const pixelToCandleIndex = (pixel, width, count) => {
2123
- const step = getCandleStep(width, count);
2124
- if (step === 0)
2125
- return 0;
2126
- return pixel / step - LEFT_OFFSET_BARS - 0.5;
2127
- };
2128
2712
  const clampCandleIndex = (value, length) => {
2129
2713
  if (length <= 0)
2130
2714
  return 0;
@@ -2229,7 +2813,19 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2229
2813
  const showCrosshairRef = useRef(false);
2230
2814
  const containerRef = useRef(null);
2231
2815
  const plotAreaRef = useRef(null);
2816
+ const layoutRowRef = useRef(null);
2817
+ const barGeometryRef = useRef({ barSpacing: MAX_BAR_SPACING, offsetX: 0 });
2232
2818
  const priceWindowRef = useRef({ min: 0, max: 1 });
2819
+ const projectPixelToIndex = useCallback((pixelX) => {
2820
+ const { barSpacing, offsetX } = barGeometryRef.current;
2821
+ if (barSpacing <= 0)
2822
+ return 0;
2823
+ return (pixelX - offsetX) / barSpacing;
2824
+ }, []);
2825
+ const getLocalCenter = useCallback((index) => {
2826
+ const { barSpacing, offsetX } = barGeometryRef.current;
2827
+ return offsetX + index * barSpacing;
2828
+ }, []);
2233
2829
  const [dimensions, setDimensions] = useState({ width: 800, height: 400, cssWidth: 800, cssHeight: 400 });
2234
2830
  const isDraggingRef = useRef(false);
2235
2831
  const lastMouseXRef = useRef(0);
@@ -2273,6 +2869,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2273
2869
  default: return 25;
2274
2870
  }
2275
2871
  }, []);
2872
+ const totalDataPoints = storeData.length;
2276
2873
  useEffect(() => {
2277
2874
  setData(data);
2278
2875
  }, [data, setData]);
@@ -2358,7 +2955,8 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2358
2955
  if (!chartWidth)
2359
2956
  return;
2360
2957
  const candles = getVisibleCandles();
2361
- const pxPerCandle = chartWidth / Math.max(getEffectiveBarCount(candles.length || 1), 1);
2958
+ const spacing = barGeometryRef.current.barSpacing || (chartWidth / Math.max(candles.length || 1, 1));
2959
+ const pxPerCandle = Math.max(spacing, 1e-3);
2362
2960
  if (!isFinite(pxPerCandle) || pxPerCandle === 0)
2363
2961
  return;
2364
2962
  const deltaOffset = deltaPx / Math.max(pxPerCandle, 1e-3);
@@ -2515,6 +3113,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2515
3113
  const [noteDraft, setNoteDraft] = useState('');
2516
3114
  const [priceFormat, setPriceFormat] = useState({ min: 2, max: 4 });
2517
3115
  const [isMobile, setIsMobile] = useState(false);
3116
+ const [layoutSize, setLayoutSize] = useState({ width: 0, height: 0 });
2518
3117
  useEffect(() => {
2519
3118
  if (!plotAreaRef.current)
2520
3119
  return;
@@ -2528,6 +3127,20 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2528
3127
  window.addEventListener('resize', checkMobile);
2529
3128
  return () => window.removeEventListener('resize', checkMobile);
2530
3129
  }, []);
3130
+ useEffect(() => {
3131
+ if (!layoutRowRef.current || typeof ResizeObserver === 'undefined') {
3132
+ return;
3133
+ }
3134
+ const observer = new ResizeObserver(entries => {
3135
+ const entry = entries[0];
3136
+ if (!entry)
3137
+ return;
3138
+ const { width, height } = entry.contentRect;
3139
+ setLayoutSize(prev => (prev.width === width && prev.height === height ? prev : { width, height }));
3140
+ });
3141
+ observer.observe(layoutRowRef.current);
3142
+ return () => observer.disconnect();
3143
+ }, []);
2531
3144
  useEffect(() => {
2532
3145
  if (!visibleData.length)
2533
3146
  return;
@@ -2601,11 +3214,77 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2601
3214
  const referenceMagnitude = Math.min(Math.abs(minPrice), Math.abs(maxPrice)) || Math.max(Math.abs(minPrice), Math.abs(maxPrice));
2602
3215
  const suggestedFormat = determinePriceFormat(referenceMagnitude);
2603
3216
  setPriceFormat((prev) => (prev.min === suggestedFormat.min && prev.max === suggestedFormat.max ? prev : suggestedFormat));
2604
- const chartWidth = Math.max(1, cssWidth - priceScaleWidth);
2605
- const chartHeight = Math.max(1, cssHeight - timeScaleHeight - volumeHeight);
2606
- const candleStep = getCandleStep(chartWidth, candles.length);
2607
- const candleWidth = Math.max(1, candleStep * BAR_BODY_RATIO);
2608
- const centerX = (index) => getCandleCenter(index, chartWidth, candles.length);
3217
+ const leftToolsWidth = showDrawingToolbar ? (isMobile ? 60 : 88) : 0;
3218
+ const layoutRect = layoutRowRef.current?.getBoundingClientRect();
3219
+ const fallbackWidth = leftToolsWidth + cssWidth;
3220
+ const layoutWidth = layoutSize.width || layoutRect?.width || fallbackWidth;
3221
+ const layoutHeight = layoutSize.height || layoutRect?.height || cssHeight;
3222
+ const paddingLeft = 0;
3223
+ const paddingRight = 0;
3224
+ const effectiveContainerHeight = Math.max(0, layoutHeight - timeScaleHeight);
3225
+ const plotArea = getPlotArea({
3226
+ containerWidth: layoutWidth,
3227
+ containerHeight: layoutHeight,
3228
+ leftToolsWidth,
3229
+ priceScaleWidth,
3230
+ paddingLeft,
3231
+ paddingRight
3232
+ });
3233
+ const chartWidth = Math.max(1, plotArea.plotWidth);
3234
+ const chartHeight = Math.max(1, plotArea.plotHeight - volumeHeight);
3235
+ const visibleBars = Math.max(candles.length, 1);
3236
+ const leftPaddingPx = LEFT_PADDING_PX;
3237
+ const rightPaddingPx = clamp(RIGHT_PADDING_PX, RIGHT_PADDING_MIN, RIGHT_PADDING_MAX);
3238
+ const targetFirstLocal = leftPaddingPx;
3239
+ const targetLastLocal = Math.max(targetFirstLocal, plotArea.plotWidth - rightPaddingPx);
3240
+ const indexSpan = Math.max(visibleBars - 1, 1);
3241
+ const availableSpan = Math.max(targetLastLocal - targetFirstLocal, MIN_BAR_SPACING);
3242
+ const barSpacing = clamp(availableSpan / indexSpan, MIN_BAR_SPACING, MAX_BAR_SPACING);
3243
+ const candleWidth = clamp(barSpacing * 0.7, MIN_BODY_WIDTH, Math.max(barSpacing - 2, MIN_BODY_WIDTH));
3244
+ const wickWidth = Math.max(1, Math.floor(candleWidth * 0.2));
3245
+ const windowMeta = visibleWindowRef.current;
3246
+ const startIndex = windowMeta.start;
3247
+ const endIndex = Math.max(windowMeta.end - 1, startIndex);
3248
+ const spanBars = Math.max(0, endIndex - startIndex);
3249
+ const rawOffset = targetLastLocal - spanBars * barSpacing;
3250
+ const maxOffset = Math.max(0, plotArea.plotWidth - rightPaddingPx);
3251
+ const offsetXLocal = clamp(rawOffset, 0, maxOffset);
3252
+ const xForIndex = (absIndex) => plotArea.plotX0 + offsetXLocal + (absIndex - startIndex) * barSpacing;
3253
+ const centerX = (index) => xForIndex(startIndex + index) - plotArea.plotX0;
3254
+ const lastXAbsolute = candles.length ? xForIndex(endIndex) : plotArea.plotX0 + offsetXLocal;
3255
+ const lastX = lastXAbsolute - plotArea.plotX0;
3256
+ const gapLeft = offsetXLocal - leftPaddingPx;
3257
+ const gapRight = targetLastLocal - lastX;
3258
+ if (Math.abs(gapLeft) > 2 || Math.abs(gapRight) > 2) {
3259
+ console.warn('[ChartLayout] gap mismatch', { gapLeft, gapRight, visibleBars, startIndex, endIndex });
3260
+ }
3261
+ const panOffset = panOffsetRef.current;
3262
+ barGeometryRef.current = { barSpacing, offsetX: offsetXLocal };
3263
+ console.log('[ChartLayout]', {
3264
+ containerWidth: layoutWidth,
3265
+ containerHeight: effectiveContainerHeight,
3266
+ leftToolsWidth,
3267
+ priceScaleWidth,
3268
+ paddingLeft,
3269
+ paddingRight,
3270
+ plotX0: plotArea.plotX0,
3271
+ plotX1: plotArea.plotX1,
3272
+ plotWidth: plotArea.plotWidth,
3273
+ plotHeight: plotArea.plotHeight,
3274
+ dataLength: totalDataPoints,
3275
+ visibleBars: candles.length,
3276
+ barSpacing,
3277
+ candleWidth,
3278
+ wickWidth,
3279
+ rightPaddingPx,
3280
+ offsetIndex: panOffset,
3281
+ offsetXPx: offsetXLocal,
3282
+ startIndex,
3283
+ endIndex,
3284
+ lastX,
3285
+ lastXAbsolute,
3286
+ gapRight
3287
+ });
2609
3288
  // Generate price labels
2610
3289
  const priceLabelsArray = [];
2611
3290
  for (let i = 0; i <= 10; i++) {
@@ -2642,7 +3321,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2642
3321
  }
2643
3322
  const timeStep = Math.max(1, Math.floor(Math.max(candles.length, 1) / GRID_DIVISIONS));
2644
3323
  for (let i = 0; i <= candles.length; i += timeStep) {
2645
- const x = alignStroke((i + LEFT_OFFSET_BARS) * candleStep);
3324
+ const x = alignStroke(offsetXLocal + i * barSpacing);
2646
3325
  if (x < 0 || x > chartWidth)
2647
3326
  continue;
2648
3327
  gridCtx.beginPath();
@@ -2678,7 +3357,6 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2678
3357
  else if (seriesType === 'step') {
2679
3358
  const prev = candles[index - 1];
2680
3359
  if (prev) {
2681
- centerX(index - 1);
2682
3360
  const prevY = ((maxPrice - prev.close) / priceRange) * chartHeight;
2683
3361
  chartCtx.lineTo(x, prevY);
2684
3362
  chartCtx.lineTo(x, yClose);
@@ -2738,7 +3416,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2738
3416
  seriesType === 'high-low';
2739
3417
  if (usesWick) {
2740
3418
  chartCtx.strokeStyle = color;
2741
- chartCtx.lineWidth = 1;
3419
+ chartCtx.lineWidth = wickWidth;
2742
3420
  chartCtx.beginPath();
2743
3421
  chartCtx.moveTo(strokeX, alignedYHigh);
2744
3422
  chartCtx.lineTo(strokeX, alignedYLow);
@@ -2844,7 +3522,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2844
3522
  });
2845
3523
  indicatorCtx.setLineDash([]); // Reset line dash
2846
3524
  }
2847
- }, [visibleData, cssWidth, cssHeight, colorScheme, calculatedIndicators, seriesType, getVisibleCandles]);
3525
+ }, [visibleData, cssWidth, cssHeight, colorScheme, calculatedIndicators, seriesType, getVisibleCandles, showDrawingToolbar, isMobile, totalDataPoints, layoutSize]);
2848
3526
  const drawCrosshair = useCallback(() => {
2849
3527
  const overlayCtx = overlayRef.current?.getContext('2d');
2850
3528
  if (overlayCtx && showCrosshairRef.current) {
@@ -2881,13 +3559,13 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2881
3559
  }
2882
3560
  if (!isDraggingRef.current) {
2883
3561
  const { min, max } = priceWindowRef.current;
2884
- const rawIndex = pixelToCandleIndex(x, chartWidth, visibleData.length);
3562
+ const rawIndex = projectPixelToIndex(x);
2885
3563
  const hoveredIndex = clampCandleIndex(rawIndex, visibleData.length);
2886
3564
  const hoveredCandle = visibleData[hoveredIndex];
2887
3565
  const snappedPrice = coordsRef.current.snapToPrice(y, chartHeight, min, max);
2888
3566
  const yPixel = coordsRef.current.priceToPixel(snappedPrice, chartHeight, min, max);
2889
3567
  const xPixel = magnetEnabled
2890
- ? getCandleCenter(hoveredIndex, chartWidth, visibleData.length)
3568
+ ? getLocalCenter(hoveredIndex)
2891
3569
  : clampPixelToChart(x, chartWidth);
2892
3570
  mousePosRef.current = { x: xPixel, y: yPixel };
2893
3571
  setCrosshairMeta({
@@ -2953,8 +3631,8 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2953
3631
  let snappedPrice = coordsRef.current.snapToPrice(y, chartHeight, bounds.minPrice, bounds.maxPrice, 0.5);
2954
3632
  let snappedY = y;
2955
3633
  if (magnetEnabled && targetData.length) {
2956
- const snappedIndex = clampCandleIndex(pixelToCandleIndex(x, chartWidth, dataLength), dataLength);
2957
- x = getCandleCenter(snappedIndex, chartWidth, dataLength);
3634
+ const snappedIndex = clampCandleIndex(projectPixelToIndex(x), dataLength);
3635
+ x = getLocalCenter(snappedIndex);
2958
3636
  snappedY = coordsRef.current.priceToPixel(snappedPrice, chartHeight, bounds.minPrice, bounds.maxPrice);
2959
3637
  y = snappedY;
2960
3638
  }
@@ -2962,7 +3640,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2962
3640
  x = clampPixelToChart(x, chartWidth);
2963
3641
  }
2964
3642
  const price = coordsRef.current.pixelToPrice(snappedY, chartHeight, bounds.minPrice, bounds.maxPrice);
2965
- const hoveredIndex = clampCandleIndex(pixelToCandleIndex(x, chartWidth, dataLength), dataLength);
3643
+ const hoveredIndex = clampCandleIndex(projectPixelToIndex(x), dataLength);
2966
3644
  const time = targetData[hoveredIndex]?.timestamp ?? hoveredIndex;
2967
3645
  return { x, y: snappedY, price, time };
2968
3646
  }, [visibleData, storeData, magnetEnabled, chartWidth, chartHeight, interactionsLocked]);
@@ -3175,7 +3853,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
3175
3853
  }
3176
3854
  if (visibleData.length) {
3177
3855
  const { min, max } = priceWindowRef.current;
3178
- const snappedIndex = clampCandleIndex(pixelToCandleIndex(rawX, chartWidth, visibleData.length), visibleData.length);
3856
+ const snappedIndex = clampCandleIndex(projectPixelToIndex(rawX), visibleData.length);
3179
3857
  setSelectedCandleIndex(snappedIndex);
3180
3858
  const price = coordsRef.current.pixelToPrice(rawY, chartHeight, min, max);
3181
3859
  setClickedPrice({ x: rawX, y: rawY, price });
@@ -3693,7 +4371,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
3693
4371
  fontSize: '13px',
3694
4372
  fontWeight: 600
3695
4373
  } }, "Save note"))))),
3696
- React.createElement("div", { style: {
4374
+ React.createElement("div", { ref: layoutRowRef, style: {
3697
4375
  flex: 1,
3698
4376
  display: 'flex',
3699
4377
  gap: isMobile ? '12px' : '16px',