viainti-chart 1.0.6 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -3,55 +3,642 @@
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 PRICE_SCALE_WIDTH = 72;
7
+ const LEFT_TOOLBAR_WIDTH = 0;
8
+ const PADDING_LEFT = 12;
9
+ const PADDING_RIGHT = 12;
10
+ const PADDING_TOP = 12;
11
+ const PADDING_BOTTOM = 12;
12
+ const X_AXIS_HEIGHT = 42;
13
+ const VOLUME_RATIO = 0.28;
14
+ const MIN_VOLUME_HEIGHT = 120;
15
+ const MAX_VOLUME_HEIGHT = 260;
16
+ const MIN_PRICE_HEIGHT = 200;
17
+ const MIN_BAR_SPACING$1 = 6;
18
+ const MAX_BAR_SPACING$1 = 18;
19
+ const MIN_VISIBLE_BARS = 20;
20
+ const MAX_VISIBLE_BARS = 800;
21
+ const CROSSHAIR_COLOR = 'rgba(255,255,255,0.35)';
22
+ const GRID_MAJOR = 'rgba(255,255,255,0.06)';
23
+ const GRID_MINOR = 'rgba(255,255,255,0.03)';
24
+ const BULL_COLOR = '#2ECC71';
25
+ const BEAR_COLOR = '#E74C3C';
26
+ const clamp$1 = (value, min, max) => Math.max(min, Math.min(max, value));
27
+ const alignStroke$1 = (value) => Math.round(value) + 0.5;
28
+ const niceStep = (range, targetTicks) => {
29
+ if (range <= 0 || !Number.isFinite(range))
30
+ return 1;
31
+ const rawStep = range / Math.max(targetTicks, 1);
32
+ const magnitude = 10 ** Math.floor(Math.log10(rawStep));
33
+ const normalized = rawStep / magnitude;
34
+ let niceNormalized;
35
+ if (normalized <= 1)
36
+ niceNormalized = 1;
37
+ else if (normalized <= 2)
38
+ niceNormalized = 2;
39
+ else if (normalized <= 5)
40
+ niceNormalized = 5;
41
+ else
42
+ niceNormalized = 10;
43
+ return niceNormalized * magnitude;
44
+ };
45
+ const formatPrice = (value, step) => {
46
+ if (!Number.isFinite(value))
47
+ return '—';
48
+ const decimals = clamp$1(-Math.floor(Math.log10(step || 1)), 0, value < 1 ? 6 : 4);
49
+ return value.toFixed(decimals);
50
+ };
51
+ const formatVolume = (value) => {
52
+ if (!Number.isFinite(value))
53
+ return '—';
54
+ if (value >= 1_000_000)
55
+ return `${(value / 1_000_000).toFixed(2)}M`;
56
+ if (value >= 1_000)
57
+ return `${(value / 1_000).toFixed(2)}K`;
58
+ return value.toFixed(2);
59
+ };
60
+ const formatTimeLabel = (timestamp) => {
61
+ const date = new Date(timestamp);
62
+ if (Number.isNaN(date.getTime()))
63
+ return `${timestamp}`;
64
+ return date.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
65
+ };
66
+ const getCandleTime = (candle, fallback) => {
67
+ if (!candle)
68
+ return fallback;
69
+ if (typeof candle.time === 'number')
70
+ return candle.time;
71
+ if (typeof candle.timestamp === 'number')
72
+ return candle.timestamp;
73
+ return fallback;
74
+ };
75
+ const computePlotArea = (containerWidth, containerHeight, showVolume) => {
76
+ const leftGutter = LEFT_TOOLBAR_WIDTH + PADDING_LEFT;
77
+ const rightGutter = PRICE_SCALE_WIDTH + PADDING_RIGHT;
78
+ const plotX0 = leftGutter;
79
+ const plotX1 = Math.max(plotX0 + 1, containerWidth - rightGutter);
80
+ const plotWidth = plotX1 - plotX0;
81
+ const availableHeight = Math.max(120, containerHeight - PADDING_TOP - PADDING_BOTTOM - X_AXIS_HEIGHT);
82
+ const targetVolume = showVolume ? clamp$1(Math.round(containerHeight * VOLUME_RATIO), MIN_VOLUME_HEIGHT, MAX_VOLUME_HEIGHT) : 0;
83
+ const volumePaneHeight = showVolume ? Math.min(targetVolume, Math.max(0, availableHeight - MIN_PRICE_HEIGHT)) : 0;
84
+ const pricePaneHeight = availableHeight - volumePaneHeight;
85
+ const pricePaneY0 = PADDING_TOP;
86
+ const volumePaneY0 = pricePaneY0 + pricePaneHeight + (showVolume ? 12 : 0);
87
+ const xAxisY0 = volumePaneY0 + volumePaneHeight;
88
+ return { plotX0, plotX1, plotWidth, pricePaneY0, pricePaneHeight, volumePaneY0, volumePaneHeight, xAxisY0 };
89
+ };
90
+ const computeViewport = (data, state) => {
91
+ if (!data.length) {
92
+ return { startIndex: 0, endIndex: 0, minTimeVisible: 0, maxTimeVisible: 0 };
93
+ }
94
+ const rawEnd = (data.length - 1) + Math.round(state.panOffsetBars);
95
+ const maxEnd = Math.max(data.length - 1, 0);
96
+ const minEnd = Math.min(Math.max(state.visibleBars - 1, 0), maxEnd);
97
+ const endIndex = clamp$1(rawEnd, minEnd, maxEnd);
98
+ const startIndex = Math.max(0, endIndex - state.visibleBars + 1);
99
+ const minTimeVisible = getCandleTime(data[startIndex], startIndex);
100
+ const maxTimeVisible = getCandleTime(data[endIndex], endIndex);
101
+ console.log('[Chart::Viewport]', { startIndex, endIndex, minTimeVisible, maxTimeVisible });
102
+ return { startIndex, endIndex, minTimeVisible, maxTimeVisible };
103
+ };
104
+ const computeXMapping = (plotArea, viewport, state) => {
105
+ const visibleCount = Math.max(1, viewport.endIndex - viewport.startIndex + 1);
106
+ const firstTarget = plotArea.plotX0 + state.leftPaddingPx;
107
+ const lastTarget = plotArea.plotX1 - state.rightPaddingPx;
108
+ const innerSpan = Math.max(1, lastTarget - firstTarget);
109
+ const gaps = Math.max(1, visibleCount - 1);
110
+ const rawSpacing = visibleCount > 1 ? innerSpan / gaps : innerSpan;
111
+ const barSpacing = clamp$1(rawSpacing, MIN_BAR_SPACING$1, MAX_BAR_SPACING$1);
112
+ const bodyWidth = clamp$1(barSpacing * 0.7, 3, barSpacing - 2);
113
+ const wickWidth = Math.max(1, Math.floor(bodyWidth * 0.25));
114
+ const offsetX = visibleCount > 1
115
+ ? (lastTarget - (visibleCount - 1) * barSpacing) - plotArea.plotX0
116
+ : (lastTarget - plotArea.plotX0);
117
+ const candleCenterX = (index) => plotArea.plotX0 + offsetX + (index - viewport.startIndex) * barSpacing;
118
+ const candleLeftX = (index) => candleCenterX(index) - bodyWidth / 2;
119
+ const gapLeft = candleCenterX(viewport.startIndex) - (plotArea.plotX0 + state.leftPaddingPx);
120
+ const gapRight = (plotArea.plotX1 - state.rightPaddingPx) - candleCenterX(viewport.endIndex);
121
+ if (Math.abs(gapLeft) > 2 || Math.abs(gapRight) > 2) {
122
+ console.warn('[Chart::GapMismatch]', { startIndex: viewport.startIndex, endIndex: viewport.endIndex, gapLeft, gapRight, visibleCount, barSpacing });
123
+ }
124
+ else {
125
+ console.log('[Chart::AlignmentOK]', { startIndex: viewport.startIndex, endIndex: viewport.endIndex, gapLeft, gapRight, visibleCount, barSpacing });
126
+ }
127
+ return { barSpacing, bodyWidth, wickWidth, offsetX, candleCenterX, candleLeftX };
128
+ };
129
+ const computeYMapping = (data, viewport, plotArea, state) => {
130
+ const slice = data.slice(Math.max(0, viewport.startIndex), Math.max(0, viewport.endIndex) + 1);
131
+ if (!slice.length) {
132
+ return {
133
+ yMin: 0,
134
+ yMax: 1,
135
+ priceToY: () => plotArea.pricePaneY0,
136
+ yTicks: []
137
+ };
138
+ }
139
+ let minPrice = Number.POSITIVE_INFINITY;
140
+ let maxPrice = Number.NEGATIVE_INFINITY;
141
+ slice.forEach(candle => {
142
+ minPrice = Math.min(minPrice, candle.low);
143
+ maxPrice = Math.max(maxPrice, candle.high);
144
+ });
145
+ if (!Number.isFinite(minPrice) || !Number.isFinite(maxPrice)) {
146
+ minPrice = 0;
147
+ maxPrice = 1;
148
+ }
149
+ const pad = (maxPrice - minPrice || Math.abs(maxPrice) * 0.01 || 1) * state.yPaddingPct;
150
+ const yMin = minPrice - pad;
151
+ const yMax = maxPrice + pad;
152
+ const priceToY = (value) => {
153
+ const clampedValue = Number.isFinite(value) ? value : 0;
154
+ return plotArea.pricePaneY0 + ((yMax - clampedValue) / Math.max(yMax - yMin, 1e-6)) * plotArea.pricePaneHeight;
155
+ };
156
+ const step = niceStep(yMax - yMin, 8);
157
+ const startTick = Math.ceil(yMin / step) * step;
158
+ const yTicks = [];
159
+ for (let value = startTick; value <= yMax; value += step) {
160
+ const y = priceToY(value);
161
+ yTicks.push({ value, y, label: formatPrice(value, step) });
162
+ }
163
+ return { yMin, yMax, priceToY, yTicks };
164
+ };
165
+ const computeTimeTicks = (data, viewport, xMapping, plotArea) => {
166
+ if (!data.length)
167
+ return [];
168
+ const ticks = [];
169
+ const targetSpacingPx = 120;
170
+ const candlesPerTick = Math.max(1, Math.round(targetSpacingPx / xMapping.barSpacing));
171
+ for (let idx = viewport.startIndex; idx <= viewport.endIndex; idx += candlesPerTick) {
172
+ const timestamp = getCandleTime(data[idx], idx);
173
+ if (timestamp < viewport.minTimeVisible || timestamp > viewport.maxTimeVisible)
174
+ continue;
175
+ const x = xMapping.candleCenterX(idx);
176
+ if (x < plotArea.plotX0 || x > plotArea.plotX1)
177
+ continue;
178
+ ticks.push({ index: idx, x, label: formatTimeLabel(timestamp) });
179
+ }
180
+ if (ticks.length === 0 && viewport.endIndex >= viewport.startIndex) {
181
+ const timestamp = getCandleTime(data[viewport.endIndex], viewport.endIndex);
182
+ if (timestamp >= viewport.minTimeVisible && timestamp <= viewport.maxTimeVisible) {
183
+ const x = xMapping.candleCenterX(viewport.endIndex);
184
+ ticks.push({ index: viewport.endIndex, x, label: formatTimeLabel(timestamp) });
185
+ }
186
+ }
187
+ const outOfRange = ticks.filter(tick => {
188
+ const t = getCandleTime(data[tick.index], tick.index);
189
+ return t < viewport.minTimeVisible || t > viewport.maxTimeVisible;
190
+ });
191
+ if (outOfRange.length) {
192
+ console.warn('[Chart::TimeTickTrim]', { outOfRange, minTimeVisible: viewport.minTimeVisible, maxTimeVisible: viewport.maxTimeVisible });
193
+ }
194
+ console.log('[Chart::TimeTicks]', {
195
+ count: ticks.length,
196
+ minTimeVisible: viewport.minTimeVisible,
197
+ maxTimeVisible: viewport.maxTimeVisible,
198
+ tickIndexes: ticks.map(t => t.index),
199
+ labels: ticks.map(t => t.label)
200
+ });
201
+ return ticks;
202
+ };
203
+ const drawRoundedRect$1 = (ctx, x, y, width, height, radius) => {
204
+ const r = Math.min(radius, width / 2, height / 2);
205
+ ctx.beginPath();
206
+ ctx.moveTo(x + r, y);
207
+ ctx.lineTo(x + width - r, y);
208
+ ctx.quadraticCurveTo(x + width, y, x + width, y + r);
209
+ ctx.lineTo(x + width, y + height - r);
210
+ ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
211
+ ctx.lineTo(x + r, y + height);
212
+ ctx.quadraticCurveTo(x, y + height, x, y + height - r);
213
+ ctx.lineTo(x, y + r);
214
+ ctx.quadraticCurveTo(x, y, x + r, y);
215
+ ctx.closePath();
216
+ };
217
+ const Chart = ({ data, width = 900, height = 520, visibleBars: defaultVisibleBars = 60 }) => {
218
+ const priceCanvasRef = React.useRef(null);
219
+ const overlayCanvasRef = React.useRef(null);
220
+ const chartAreaRef = React.useRef(null);
221
+ const [showMenu, setShowMenu] = React.useState(false);
222
+ const [chartState, setChartState] = React.useState(() => ({
223
+ visibleBars: Math.max(defaultVisibleBars, MIN_VISIBLE_BARS),
224
+ panOffsetBars: 0,
225
+ locked: false,
226
+ crosshairEnabled: true,
227
+ crosshairIndex: null,
228
+ crosshairPrice: null,
229
+ autoScaleY: true,
230
+ yPaddingPct: 0.08,
231
+ showGrid: true,
232
+ showVolume: true,
233
+ rightPaddingPx: 60,
234
+ leftPaddingPx: 0
235
+ }));
8
236
  React.useEffect(() => {
9
- const canvas = canvasRef.current;
237
+ setChartState(prev => {
238
+ const clamped = clamp$1(Math.round(prev.visibleBars), MIN_VISIBLE_BARS, Math.min(MAX_VISIBLE_BARS, Math.max(data.length, MIN_VISIBLE_BARS)));
239
+ const minPan = -Math.max(data.length - clamped, 0);
240
+ const panOffsetBars = clamp$1(prev.panOffsetBars, minPan, 0);
241
+ if (clamped === prev.visibleBars && panOffsetBars === prev.panOffsetBars)
242
+ return prev;
243
+ return { ...prev, visibleBars: clamped, panOffsetBars };
244
+ });
245
+ }, [data.length]);
246
+ const plotArea = React.useMemo(() => computePlotArea(width, height, chartState.showVolume), [chartState.showVolume, height, width]);
247
+ const viewport = React.useMemo(() => computeViewport(data, chartState), [chartState, data]);
248
+ const xMapping = React.useMemo(() => computeXMapping(plotArea, viewport, chartState), [chartState, plotArea, viewport]);
249
+ const yMapping = React.useMemo(() => computeYMapping(data, viewport, plotArea, chartState), [chartState, data, plotArea, viewport]);
250
+ const timeTicks = React.useMemo(() => computeTimeTicks(data, viewport, xMapping, plotArea), [data, plotArea, viewport, xMapping]);
251
+ React.useEffect(() => {
252
+ const resizeCanvas = (canvas) => {
253
+ if (!canvas)
254
+ return;
255
+ const dpr = window.devicePixelRatio || 1;
256
+ canvas.width = width * dpr;
257
+ canvas.height = height * dpr;
258
+ canvas.style.width = `${width}px`;
259
+ canvas.style.height = `${height}px`;
260
+ };
261
+ resizeCanvas(priceCanvasRef.current);
262
+ resizeCanvas(overlayCanvasRef.current);
263
+ }, [height, width]);
264
+ const priceSlice = React.useMemo(() => data.slice(Math.max(0, viewport.startIndex), Math.max(0, viewport.endIndex) + 1), [data, viewport.endIndex, viewport.startIndex]);
265
+ const drawBaseLayer = React.useCallback(() => {
266
+ const canvas = priceCanvasRef.current;
10
267
  if (!canvas)
11
268
  return;
12
269
  const ctx = canvas.getContext('2d');
13
270
  if (!ctx)
14
271
  return;
15
- // Clear canvas
272
+ const dpr = window.devicePixelRatio || 1;
273
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
16
274
  ctx.clearRect(0, 0, width, height);
17
- if (data.length === 0)
275
+ ctx.fillStyle = '#05070d';
276
+ ctx.fillRect(0, 0, width, height);
277
+ if (!priceSlice.length)
18
278
  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';
279
+ // Price pane clip
280
+ ctx.save();
281
+ ctx.beginPath();
282
+ ctx.rect(plotArea.plotX0, plotArea.pricePaneY0, plotArea.plotWidth, plotArea.pricePaneHeight);
283
+ ctx.clip();
284
+ if (chartState.showGrid) {
285
+ ctx.strokeStyle = GRID_MAJOR;
286
+ ctx.lineWidth = 1;
287
+ ctx.setLineDash([]);
288
+ yMapping.yTicks.forEach(tick => {
289
+ ctx.beginPath();
290
+ ctx.moveTo(plotArea.plotX0, alignStroke$1(tick.y));
291
+ ctx.lineTo(plotArea.plotX1, alignStroke$1(tick.y));
292
+ ctx.stroke();
293
+ });
294
+ timeTicks.forEach(tick => {
295
+ ctx.beginPath();
296
+ ctx.moveTo(alignStroke$1(tick.x), plotArea.pricePaneY0);
297
+ ctx.lineTo(alignStroke$1(tick.x), plotArea.pricePaneY0 + plotArea.pricePaneHeight);
298
+ ctx.stroke();
299
+ });
300
+ ctx.strokeStyle = GRID_MINOR;
301
+ ctx.setLineDash([2, 4]);
302
+ for (let i = 0; i < yMapping.yTicks.length - 1; i++) {
303
+ const current = yMapping.yTicks[i];
304
+ const next = yMapping.yTicks[i + 1];
305
+ if (!current || !next)
306
+ continue;
307
+ const midValue = (current.value + next.value) / 2;
308
+ const y = alignStroke$1(yMapping.priceToY(midValue));
47
309
  ctx.beginPath();
48
- ctx.moveTo(x + candleWidth / 2, yOpen);
49
- ctx.lineTo(x + candleWidth / 2, yClose);
310
+ ctx.moveTo(plotArea.plotX0, y);
311
+ ctx.lineTo(plotArea.plotX1, y);
50
312
  ctx.stroke();
51
313
  }
314
+ for (let i = 0; i < timeTicks.length - 1; i++) {
315
+ const current = timeTicks[i];
316
+ const next = timeTicks[i + 1];
317
+ if (!current || !next)
318
+ continue;
319
+ const midX = (current.x + next.x) / 2;
320
+ ctx.beginPath();
321
+ ctx.moveTo(alignStroke$1(midX), plotArea.pricePaneY0);
322
+ ctx.lineTo(alignStroke$1(midX), plotArea.pricePaneY0 + plotArea.pricePaneHeight);
323
+ ctx.stroke();
324
+ }
325
+ ctx.setLineDash([]);
326
+ }
327
+ const highlightIndex = chartState.crosshairIndex;
328
+ if (highlightIndex != null && highlightIndex >= viewport.startIndex && highlightIndex <= viewport.endIndex) {
329
+ const x = xMapping.candleLeftX(highlightIndex);
330
+ ctx.fillStyle = 'rgba(255,255,255,0.05)';
331
+ ctx.fillRect(x, plotArea.pricePaneY0, xMapping.bodyWidth, plotArea.pricePaneHeight);
332
+ }
333
+ priceSlice.forEach((candle, offset) => {
334
+ const index = viewport.startIndex + offset;
335
+ const xCenter = xMapping.candleCenterX(index);
336
+ const left = xCenter - xMapping.bodyWidth / 2;
337
+ const yOpen = yMapping.priceToY(candle.open);
338
+ const yClose = yMapping.priceToY(candle.close);
339
+ const yHigh = yMapping.priceToY(candle.high);
340
+ const yLow = yMapping.priceToY(candle.low);
341
+ const bodyTop = Math.min(yOpen, yClose);
342
+ const bodyHeight = Math.abs(yClose - yOpen) || 1;
343
+ const isBull = (candle.close ?? 0) >= (candle.open ?? 0);
344
+ ctx.strokeStyle = isBull ? BULL_COLOR : BEAR_COLOR;
345
+ ctx.lineWidth = xMapping.wickWidth;
346
+ ctx.beginPath();
347
+ ctx.moveTo(alignStroke$1(xCenter), yHigh);
348
+ ctx.lineTo(alignStroke$1(xCenter), yLow);
349
+ ctx.stroke();
350
+ ctx.fillStyle = isBull ? BULL_COLOR : BEAR_COLOR;
351
+ drawRoundedRect$1(ctx, left, bodyTop, xMapping.bodyWidth, bodyHeight, 1.5);
352
+ ctx.fill();
353
+ });
354
+ const lastCandle = data[viewport.endIndex] ?? data[data.length - 1];
355
+ if (lastCandle) {
356
+ const lastPrice = lastCandle.close ?? lastCandle.open ?? lastCandle.high ?? 0;
357
+ const prevClose = data[viewport.endIndex - 1]?.close ?? lastPrice;
358
+ const delta = lastPrice - prevClose;
359
+ const deltaPct = prevClose ? (delta / prevClose) * 100 : 0;
360
+ const y = yMapping.priceToY(lastPrice);
361
+ ctx.strokeStyle = 'rgba(255,255,255,0.35)';
362
+ ctx.setLineDash([6, 4]);
363
+ ctx.lineWidth = 1;
364
+ ctx.beginPath();
365
+ ctx.moveTo(plotArea.plotX0, y);
366
+ ctx.lineTo(plotArea.plotX1, y);
367
+ ctx.stroke();
368
+ ctx.setLineDash([]);
369
+ ctx.fillStyle = delta >= 0 ? '#16a34a' : '#dc2626';
370
+ const badgeX = plotArea.plotX1 + 4;
371
+ const badgeY = clamp$1(y - 12, plotArea.pricePaneY0 + 4, plotArea.pricePaneY0 + plotArea.pricePaneHeight - 24);
372
+ ctx.fillRect(badgeX, badgeY, PRICE_SCALE_WIDTH - 8, 24);
373
+ ctx.fillStyle = '#05070d';
374
+ ctx.font = '12px Inter, sans-serif';
375
+ ctx.textAlign = 'center';
376
+ ctx.textBaseline = 'middle';
377
+ ctx.fillText(lastPrice.toFixed(2), badgeX + (PRICE_SCALE_WIDTH - 8) / 2, badgeY + 10);
378
+ ctx.textAlign = 'left';
379
+ ctx.fillStyle = '#f1f5f9';
380
+ ctx.fillText(`${delta >= 0 ? '+' : ''}${delta.toFixed(2)} (${deltaPct >= 0 ? '+' : ''}${deltaPct.toFixed(2)}%)`, badgeX, plotArea.pricePaneY0 - 6);
381
+ }
382
+ ctx.restore();
383
+ ctx.fillStyle = '#94a3b8';
384
+ ctx.font = '11px Inter, sans-serif';
385
+ ctx.textAlign = 'left';
386
+ ctx.textBaseline = 'middle';
387
+ yMapping.yTicks.forEach(tick => {
388
+ ctx.fillText(tick.label, plotArea.plotX0 + 4, tick.y);
389
+ });
390
+ ctx.textAlign = 'center';
391
+ ctx.textBaseline = 'bottom';
392
+ timeTicks.forEach((tick, idx) => {
393
+ const prev = timeTicks[idx - 1];
394
+ if (prev && Math.abs(tick.x - prev.x) < 80)
395
+ return;
396
+ const x = clamp$1(tick.x, plotArea.plotX0 + 20, plotArea.plotX1 - 20);
397
+ ctx.fillText(tick.label, x, plotArea.pricePaneY0 + plotArea.pricePaneHeight + 18);
398
+ });
399
+ if (chartState.showVolume && plotArea.volumePaneHeight > 0) {
400
+ ctx.strokeStyle = 'rgba(255,255,255,0.08)';
401
+ ctx.beginPath();
402
+ ctx.moveTo(plotArea.plotX0, plotArea.volumePaneY0 - 6);
403
+ ctx.lineTo(plotArea.plotX1, plotArea.volumePaneY0 - 6);
404
+ ctx.stroke();
405
+ ctx.save();
406
+ ctx.beginPath();
407
+ ctx.rect(plotArea.plotX0, plotArea.volumePaneY0, plotArea.plotWidth, plotArea.volumePaneHeight);
408
+ ctx.clip();
409
+ const maxVolume = Math.max(...priceSlice.map(c => c.volume ?? 0), 1);
410
+ priceSlice.forEach((candle, offset) => {
411
+ const index = viewport.startIndex + offset;
412
+ const xCenter = xMapping.candleCenterX(index);
413
+ const barWidth = Math.max(2, xMapping.bodyWidth * 0.6);
414
+ const volume = candle.volume ?? 0;
415
+ const heightRatio = volume / maxVolume;
416
+ const barHeight = heightRatio * (plotArea.volumePaneHeight - 12);
417
+ const y = plotArea.volumePaneY0 + plotArea.volumePaneHeight - barHeight;
418
+ ctx.fillStyle = (candle.close ?? 0) >= (candle.open ?? 0) ? 'rgba(46,204,113,0.35)' : 'rgba(231,76,60,0.35)';
419
+ ctx.fillRect(xCenter - barWidth / 2, y, barWidth, Math.max(barHeight, 2));
420
+ if (highlightIndex === index) {
421
+ ctx.fillStyle = 'rgba(255,255,255,0.12)';
422
+ ctx.fillRect(xCenter - barWidth / 2, y, barWidth, Math.max(barHeight, 2));
423
+ }
424
+ });
425
+ ctx.restore();
426
+ }
427
+ }, [chartState.crosshairIndex, chartState.showGrid, chartState.showVolume, data, plotArea, priceSlice, timeTicks, viewport.endIndex, viewport.startIndex, width, height, xMapping, yMapping]);
428
+ const drawOverlayLayer = React.useCallback(() => {
429
+ const canvas = overlayCanvasRef.current;
430
+ if (!canvas)
431
+ return;
432
+ const ctx = canvas.getContext('2d');
433
+ if (!ctx)
434
+ return;
435
+ const dpr = window.devicePixelRatio || 1;
436
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
437
+ ctx.clearRect(0, 0, width, height);
438
+ if (!chartState.crosshairEnabled || chartState.crosshairIndex == null || chartState.crosshairPrice == null) {
439
+ return;
440
+ }
441
+ const index = chartState.crosshairIndex;
442
+ const x = xMapping.candleCenterX(index);
443
+ const priceY = yMapping.priceToY(chartState.crosshairPrice);
444
+ ctx.strokeStyle = CROSSHAIR_COLOR;
445
+ ctx.lineWidth = 1;
446
+ ctx.setLineDash([4, 4]);
447
+ ctx.beginPath();
448
+ ctx.moveTo(x, 0);
449
+ ctx.lineTo(x, height);
450
+ ctx.moveTo(plotArea.plotX0, priceY);
451
+ ctx.lineTo(plotArea.plotX1 + PRICE_SCALE_WIDTH, priceY);
452
+ ctx.stroke();
453
+ ctx.setLineDash([]);
454
+ // Price badge
455
+ ctx.fillStyle = '#111927';
456
+ ctx.fillRect(plotArea.plotX1 + 2, priceY - 10, PRICE_SCALE_WIDTH - 4, 20);
457
+ ctx.fillStyle = '#e2e8f0';
458
+ ctx.font = '11px Inter, sans-serif';
459
+ ctx.textAlign = 'center';
460
+ ctx.textBaseline = 'middle';
461
+ ctx.fillText(chartState.crosshairPrice.toFixed(4).replace(/0+$/, '').replace(/\.$/, ''), plotArea.plotX1 + (PRICE_SCALE_WIDTH - 4) / 2, priceY);
462
+ // Time badge
463
+ const tick = formatTimeLabel(getCandleTime(data[index], index));
464
+ ctx.fillStyle = '#111927';
465
+ ctx.fillRect(x - 48, plotArea.pricePaneY0 + plotArea.pricePaneHeight + 4, 96, 18);
466
+ ctx.fillStyle = '#e2e8f0';
467
+ ctx.fillText(tick, x, plotArea.pricePaneY0 + plotArea.pricePaneHeight + 13);
468
+ const candle = data[index];
469
+ if (!candle)
470
+ return;
471
+ const tooltipWidth = 170;
472
+ const tooltipHeight = 90;
473
+ const tooltipX = clamp$1(x - tooltipWidth - 16, plotArea.plotX0 + 8, plotArea.plotX1 - tooltipWidth - 8);
474
+ const tooltipY = plotArea.pricePaneY0 + 12;
475
+ ctx.fillStyle = 'rgba(15,15,20,0.9)';
476
+ drawRoundedRect$1(ctx, tooltipX, tooltipY, tooltipWidth, tooltipHeight, 8);
477
+ ctx.fill();
478
+ ctx.strokeStyle = 'rgba(255,255,255,0.08)';
479
+ ctx.stroke();
480
+ ctx.fillStyle = '#f8fafc';
481
+ ctx.textAlign = 'left';
482
+ ctx.textBaseline = 'middle';
483
+ ctx.font = '11px Inter, sans-serif';
484
+ const lines = [
485
+ `O ${formatPrice(candle.open, 0.01)}`,
486
+ `H ${formatPrice(candle.high, 0.01)}`,
487
+ `L ${formatPrice(candle.low, 0.01)}`,
488
+ `C ${formatPrice(candle.close, 0.01)}`,
489
+ `V ${formatVolume(candle.volume ?? 0)}`
490
+ ];
491
+ lines.forEach((line, idx) => {
492
+ ctx.fillText(line, tooltipX + 10, tooltipY + 16 + idx * 16);
493
+ });
494
+ }, [chartState.crosshairEnabled, chartState.crosshairIndex, chartState.crosshairPrice, data, height, plotArea, width, xMapping, yMapping]);
495
+ React.useEffect(() => {
496
+ drawBaseLayer();
497
+ }, [drawBaseLayer]);
498
+ React.useEffect(() => {
499
+ drawOverlayLayer();
500
+ }, [drawOverlayLayer]);
501
+ const setVisibleBars = React.useCallback((factor) => {
502
+ setChartState(prev => {
503
+ if (prev.locked)
504
+ return prev;
505
+ const minBars = MIN_VISIBLE_BARS;
506
+ const maxBars = Math.max(minBars, Math.min(MAX_VISIBLE_BARS, data.length || MIN_VISIBLE_BARS));
507
+ const nextVisible = clamp$1(Math.round(prev.visibleBars * factor), minBars, maxBars);
508
+ if (nextVisible === prev.visibleBars)
509
+ return prev;
510
+ const minPan = -Math.max(data.length - nextVisible, 0);
511
+ const nextPanOffset = clamp$1(prev.panOffsetBars, minPan, 0);
512
+ return { ...prev, visibleBars: nextVisible, panOffsetBars: nextPanOffset };
52
513
  });
53
- }, [data, width, height]);
54
- return React.createElement("canvas", { ref: canvasRef, width: width, height: height });
514
+ }, [data.length]);
515
+ const updateCrosshair = React.useCallback((clientX, clientY) => {
516
+ if (chartState.locked || !chartState.crosshairEnabled || !data.length)
517
+ return;
518
+ const rect = chartAreaRef.current?.getBoundingClientRect();
519
+ if (!rect)
520
+ return;
521
+ const localX = clientX - rect.left;
522
+ const clampedX = clamp$1(localX, plotArea.plotX0, plotArea.plotX1);
523
+ const ratio = (clampedX - (plotArea.plotX0 + xMapping.offsetX)) / (xMapping.barSpacing || 1);
524
+ const index = clamp$1(Math.round(ratio) + viewport.startIndex, viewport.startIndex, viewport.endIndex);
525
+ const localY = clientY - rect.top;
526
+ const clampedY = clamp$1(localY, plotArea.pricePaneY0, plotArea.pricePaneY0 + plotArea.pricePaneHeight);
527
+ const priceRange = yMapping.yMax - yMapping.yMin || 1;
528
+ const price = yMapping.yMax - ((clampedY - plotArea.pricePaneY0) / Math.max(plotArea.pricePaneHeight, 1)) * priceRange;
529
+ const safePrice = Number.isFinite(price) ? price : yMapping.yMin;
530
+ setChartState(prev => ({ ...prev, crosshairIndex: index, crosshairPrice: safePrice }));
531
+ }, [chartState.crosshairEnabled, chartState.locked, data.length, plotArea, viewport.endIndex, viewport.startIndex, xMapping, yMapping]);
532
+ const clampPan = React.useCallback((pan, visible) => {
533
+ const minPan = -Math.max(data.length - visible, 0);
534
+ return clamp$1(pan, minPan, 0);
535
+ }, [data.length]);
536
+ const handleWheel = React.useCallback((event) => {
537
+ if (chartState.locked)
538
+ return;
539
+ event.preventDefault();
540
+ const factor = event.deltaY < 0 ? 0.9 : 1.1;
541
+ setVisibleBars(factor);
542
+ }, [chartState.locked, setVisibleBars]);
543
+ const isDraggingRef = React.useRef(false);
544
+ const lastXRef = React.useRef(0);
545
+ const handlePointerDown = React.useCallback((event) => {
546
+ if (event.button !== 0)
547
+ return;
548
+ chartAreaRef.current?.setPointerCapture(event.pointerId);
549
+ isDraggingRef.current = true;
550
+ lastXRef.current = event.clientX;
551
+ updateCrosshair(event.clientX, event.clientY);
552
+ }, [updateCrosshair]);
553
+ const handlePointerMove = React.useCallback((event) => {
554
+ if (isDraggingRef.current && !chartState.locked) {
555
+ const deltaX = event.clientX - lastXRef.current;
556
+ lastXRef.current = event.clientX;
557
+ const deltaBars = -(deltaX / (xMapping.barSpacing || 1));
558
+ setChartState(prev => ({ ...prev, panOffsetBars: clampPan(prev.panOffsetBars + deltaBars, prev.visibleBars) }));
559
+ return;
560
+ }
561
+ updateCrosshair(event.clientX, event.clientY);
562
+ }, [chartState.locked, clampPan, updateCrosshair, xMapping.barSpacing]);
563
+ const handlePointerUp = React.useCallback((event) => {
564
+ chartAreaRef.current?.releasePointerCapture(event.pointerId);
565
+ isDraggingRef.current = false;
566
+ }, []);
567
+ const handlePointerLeave = React.useCallback(() => {
568
+ isDraggingRef.current = false;
569
+ setChartState(prev => {
570
+ if (!prev.crosshairEnabled)
571
+ return prev;
572
+ return { ...prev, crosshairIndex: null, crosshairPrice: null };
573
+ });
574
+ }, [setChartState]);
575
+ const handleButtonZoom = (direction) => {
576
+ setVisibleBars(direction === 'in' ? 0.9 : 1.1);
577
+ };
578
+ const handleReset = () => {
579
+ setChartState(prev => ({
580
+ ...prev,
581
+ visibleBars: Math.max(defaultVisibleBars, MIN_VISIBLE_BARS),
582
+ panOffsetBars: 0,
583
+ locked: false,
584
+ crosshairEnabled: true,
585
+ crosshairIndex: null,
586
+ crosshairPrice: null,
587
+ autoScaleY: true,
588
+ showGrid: true,
589
+ showVolume: true
590
+ }));
591
+ };
592
+ const handleSnapshot = () => {
593
+ const base = priceCanvasRef.current;
594
+ if (!base)
595
+ return;
596
+ const overlay = overlayCanvasRef.current;
597
+ const exportCanvas = document.createElement('canvas');
598
+ exportCanvas.width = base.width;
599
+ exportCanvas.height = base.height;
600
+ const ctx = exportCanvas.getContext('2d');
601
+ if (!ctx)
602
+ return;
603
+ ctx.drawImage(base, 0, 0);
604
+ if (overlay)
605
+ ctx.drawImage(overlay, 0, 0);
606
+ const link = document.createElement('a');
607
+ link.download = 'viainti-chart.png';
608
+ link.href = exportCanvas.toDataURL('image/png');
609
+ link.click();
610
+ };
611
+ return (React.createElement("div", { style: { width } },
612
+ React.createElement("div", { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '12px' } },
613
+ React.createElement("button", { onClick: () => handleButtonZoom('in'), disabled: chartState.locked, style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: chartState.locked ? '#1f2937' : '#0f172a', color: '#f8fafc' } }, "Zoom +"),
614
+ React.createElement("button", { onClick: () => handleButtonZoom('out'), disabled: chartState.locked, style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: chartState.locked ? '#1f2937' : '#0f172a', color: '#f8fafc' } }, "Zoom -"),
615
+ React.createElement("button", { onClick: () => setChartState(prev => ({ ...prev, locked: !prev.locked })), style: { padding: '6px 12px', borderRadius: '999px', border: chartState.locked ? '1px solid #fbbf24' : '1px solid #475569', background: chartState.locked ? '#fbbf2411' : '#0f172a', color: '#f8fafc' } }, chartState.locked ? 'Unlock' : 'Lock'),
616
+ React.createElement("button", { onClick: () => setChartState(prev => ({ ...prev, crosshairEnabled: !prev.crosshairEnabled, crosshairIndex: prev.crosshairEnabled ? null : prev.crosshairIndex, crosshairPrice: prev.crosshairEnabled ? null : prev.crosshairPrice })), style: { padding: '6px 12px', borderRadius: '999px', border: chartState.crosshairEnabled ? '1px solid #22d3ee' : '1px solid #475569', background: chartState.crosshairEnabled ? '#22d3ee11' : '#0f172a', color: '#f8fafc' } }, chartState.crosshairEnabled ? 'Crosshair on' : 'Crosshair off'),
617
+ React.createElement("button", { onClick: handleReset, style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: '#0f172a', color: '#f8fafc' } }, "Reset"),
618
+ React.createElement("button", { onClick: handleSnapshot, style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: '#0f172a', color: '#f8fafc' } }, "Camera"),
619
+ React.createElement("button", { onClick: () => setShowMenu(prev => !prev), style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: showMenu ? '#22c55e11' : '#0f172a', color: '#f8fafc' } }, "Menu")),
620
+ showMenu && (React.createElement("div", { style: { marginBottom: '12px', padding: '12px', borderRadius: '16px', border: '1px solid #1f2937', background: '#0b1120', color: '#e2e8f0', display: 'flex', flexWrap: 'wrap', gap: '12px', fontSize: '12px' } },
621
+ React.createElement("label", { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
622
+ React.createElement("input", { type: "checkbox", checked: chartState.showGrid, onChange: () => setChartState(prev => ({ ...prev, showGrid: !prev.showGrid })) }),
623
+ " Grid"),
624
+ React.createElement("label", { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
625
+ React.createElement("input", { type: "checkbox", checked: chartState.showVolume, onChange: () => setChartState(prev => ({ ...prev, showVolume: !prev.showVolume })) }),
626
+ " Volumen"),
627
+ React.createElement("label", { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
628
+ React.createElement("input", { type: "checkbox", checked: chartState.autoScaleY, onChange: () => setChartState(prev => ({ ...prev, autoScaleY: !prev.autoScaleY })) }),
629
+ " AutoScale Y"))),
630
+ React.createElement("div", { ref: chartAreaRef, style: { position: 'relative', width, height, borderRadius: '24px', border: '1px solid #1f2937', overflow: 'hidden', background: '#05070d', touchAction: 'none' }, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerLeave: handlePointerLeave, onWheel: handleWheel },
631
+ React.createElement("canvas", { ref: priceCanvasRef, style: { position: 'absolute', inset: 0 } }),
632
+ React.createElement("canvas", { ref: overlayCanvasRef, style: { position: 'absolute', inset: 0, pointerEvents: 'none' } })),
633
+ React.createElement("p", { style: { marginTop: '10px', fontSize: '12px', color: '#94a3b8' } },
634
+ "Bars: ",
635
+ chartState.visibleBars,
636
+ " \u00B7 Pan offset: ",
637
+ chartState.panOffsetBars.toFixed(2),
638
+ " \u00B7 Viewport ",
639
+ viewport.startIndex,
640
+ " \u2192 ",
641
+ viewport.endIndex)));
55
642
  };
56
643
 
57
644
  var DefaultContext = {
@@ -2085,10 +2672,14 @@ const CUSTOM_THEME_FIELDS = [
2085
2672
  const MIN_CANDLE_PX = 4;
2086
2673
  const MAX_CANDLE_PX = 28;
2087
2674
  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
2675
  const GRID_DIVISIONS = 10;
2676
+ const RIGHT_PADDING_PX = 60;
2677
+ const RIGHT_PADDING_MIN = 40;
2678
+ const RIGHT_PADDING_MAX = 120;
2679
+ const LEFT_PADDING_PX = 0;
2680
+ const MIN_BAR_SPACING = 6;
2681
+ const MAX_BAR_SPACING = 16;
2682
+ const MIN_BODY_WIDTH = 3;
2092
2683
  const INERTIA_DURATION_MS = 900;
2093
2684
  const ZOOM_MIN = 0.2;
2094
2685
  const ZOOM_MAX = 6;
@@ -2114,19 +2705,19 @@ const determinePriceFormat = (reference) => {
2114
2705
  return { min: 2, max: 3 };
2115
2706
  return { min: 2, max: 2 };
2116
2707
  };
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;
2708
+ const getPlotArea = ({ containerWidth, containerHeight, leftToolsWidth, priceScaleWidth, paddingLeft = 0, paddingRight = 0, paddingTop = 0, paddingBottom = 0 }) => {
2709
+ const plotX0 = Math.max(0, leftToolsWidth + paddingLeft);
2710
+ const plotX1 = Math.max(plotX0, containerWidth - priceScaleWidth - paddingRight);
2711
+ const usableWidth = Math.max(1, plotX1 - plotX0);
2712
+ const usableHeight = Math.max(1, containerHeight - paddingTop - paddingBottom);
2713
+ return {
2714
+ plotX0,
2715
+ plotX1,
2716
+ plotWidth: usableWidth,
2717
+ plotHeight: usableHeight
2718
+ };
2122
2719
  };
2123
2720
  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
2721
  const clampCandleIndex = (value, length) => {
2131
2722
  if (length <= 0)
2132
2723
  return 0;
@@ -2231,7 +2822,19 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2231
2822
  const showCrosshairRef = React.useRef(false);
2232
2823
  const containerRef = React.useRef(null);
2233
2824
  const plotAreaRef = React.useRef(null);
2825
+ const layoutRowRef = React.useRef(null);
2826
+ const barGeometryRef = React.useRef({ barSpacing: MAX_BAR_SPACING, offsetX: 0 });
2234
2827
  const priceWindowRef = React.useRef({ min: 0, max: 1 });
2828
+ const projectPixelToIndex = React.useCallback((pixelX) => {
2829
+ const { barSpacing, offsetX } = barGeometryRef.current;
2830
+ if (barSpacing <= 0)
2831
+ return 0;
2832
+ return (pixelX - offsetX) / barSpacing;
2833
+ }, []);
2834
+ const getLocalCenter = React.useCallback((index) => {
2835
+ const { barSpacing, offsetX } = barGeometryRef.current;
2836
+ return offsetX + index * barSpacing;
2837
+ }, []);
2235
2838
  const [dimensions, setDimensions] = React.useState({ width: 800, height: 400, cssWidth: 800, cssHeight: 400 });
2236
2839
  const isDraggingRef = React.useRef(false);
2237
2840
  const lastMouseXRef = React.useRef(0);
@@ -2275,6 +2878,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2275
2878
  default: return 25;
2276
2879
  }
2277
2880
  }, []);
2881
+ const totalDataPoints = storeData.length;
2278
2882
  React.useEffect(() => {
2279
2883
  setData(data);
2280
2884
  }, [data, setData]);
@@ -2360,7 +2964,8 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2360
2964
  if (!chartWidth)
2361
2965
  return;
2362
2966
  const candles = getVisibleCandles();
2363
- const pxPerCandle = chartWidth / Math.max(getEffectiveBarCount(candles.length || 1), 1);
2967
+ const spacing = barGeometryRef.current.barSpacing || (chartWidth / Math.max(candles.length || 1, 1));
2968
+ const pxPerCandle = Math.max(spacing, 1e-3);
2364
2969
  if (!isFinite(pxPerCandle) || pxPerCandle === 0)
2365
2970
  return;
2366
2971
  const deltaOffset = deltaPx / Math.max(pxPerCandle, 1e-3);
@@ -2517,6 +3122,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2517
3122
  const [noteDraft, setNoteDraft] = React.useState('');
2518
3123
  const [priceFormat, setPriceFormat] = React.useState({ min: 2, max: 4 });
2519
3124
  const [isMobile, setIsMobile] = React.useState(false);
3125
+ const [layoutSize, setLayoutSize] = React.useState({ width: 0, height: 0 });
2520
3126
  React.useEffect(() => {
2521
3127
  if (!plotAreaRef.current)
2522
3128
  return;
@@ -2530,6 +3136,20 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2530
3136
  window.addEventListener('resize', checkMobile);
2531
3137
  return () => window.removeEventListener('resize', checkMobile);
2532
3138
  }, []);
3139
+ React.useEffect(() => {
3140
+ if (!layoutRowRef.current || typeof ResizeObserver === 'undefined') {
3141
+ return;
3142
+ }
3143
+ const observer = new ResizeObserver(entries => {
3144
+ const entry = entries[0];
3145
+ if (!entry)
3146
+ return;
3147
+ const { width, height } = entry.contentRect;
3148
+ setLayoutSize(prev => (prev.width === width && prev.height === height ? prev : { width, height }));
3149
+ });
3150
+ observer.observe(layoutRowRef.current);
3151
+ return () => observer.disconnect();
3152
+ }, []);
2533
3153
  React.useEffect(() => {
2534
3154
  if (!visibleData.length)
2535
3155
  return;
@@ -2603,11 +3223,77 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2603
3223
  const referenceMagnitude = Math.min(Math.abs(minPrice), Math.abs(maxPrice)) || Math.max(Math.abs(minPrice), Math.abs(maxPrice));
2604
3224
  const suggestedFormat = determinePriceFormat(referenceMagnitude);
2605
3225
  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);
3226
+ const leftToolsWidth = showDrawingToolbar ? (isMobile ? 60 : 88) : 0;
3227
+ const layoutRect = layoutRowRef.current?.getBoundingClientRect();
3228
+ const fallbackWidth = leftToolsWidth + cssWidth;
3229
+ const layoutWidth = layoutSize.width || layoutRect?.width || fallbackWidth;
3230
+ const layoutHeight = layoutSize.height || layoutRect?.height || cssHeight;
3231
+ const paddingLeft = 0;
3232
+ const paddingRight = 0;
3233
+ const effectiveContainerHeight = Math.max(0, layoutHeight - timeScaleHeight);
3234
+ const plotArea = getPlotArea({
3235
+ containerWidth: layoutWidth,
3236
+ containerHeight: layoutHeight,
3237
+ leftToolsWidth,
3238
+ priceScaleWidth,
3239
+ paddingLeft,
3240
+ paddingRight
3241
+ });
3242
+ const chartWidth = Math.max(1, plotArea.plotWidth);
3243
+ const chartHeight = Math.max(1, plotArea.plotHeight - volumeHeight);
3244
+ const visibleBars = Math.max(candles.length, 1);
3245
+ const leftPaddingPx = LEFT_PADDING_PX;
3246
+ const rightPaddingPx = clamp(RIGHT_PADDING_PX, RIGHT_PADDING_MIN, RIGHT_PADDING_MAX);
3247
+ const targetFirstLocal = leftPaddingPx;
3248
+ const targetLastLocal = Math.max(targetFirstLocal, plotArea.plotWidth - rightPaddingPx);
3249
+ const indexSpan = Math.max(visibleBars - 1, 1);
3250
+ const availableSpan = Math.max(targetLastLocal - targetFirstLocal, MIN_BAR_SPACING);
3251
+ const barSpacing = clamp(availableSpan / indexSpan, MIN_BAR_SPACING, MAX_BAR_SPACING);
3252
+ const candleWidth = clamp(barSpacing * 0.7, MIN_BODY_WIDTH, Math.max(barSpacing - 2, MIN_BODY_WIDTH));
3253
+ const wickWidth = Math.max(1, Math.floor(candleWidth * 0.2));
3254
+ const windowMeta = visibleWindowRef.current;
3255
+ const startIndex = windowMeta.start;
3256
+ const endIndex = Math.max(windowMeta.end - 1, startIndex);
3257
+ const spanBars = Math.max(0, endIndex - startIndex);
3258
+ const rawOffset = targetLastLocal - spanBars * barSpacing;
3259
+ const maxOffset = Math.max(0, plotArea.plotWidth - rightPaddingPx);
3260
+ const offsetXLocal = clamp(rawOffset, 0, maxOffset);
3261
+ const xForIndex = (absIndex) => plotArea.plotX0 + offsetXLocal + (absIndex - startIndex) * barSpacing;
3262
+ const centerX = (index) => xForIndex(startIndex + index) - plotArea.plotX0;
3263
+ const lastXAbsolute = candles.length ? xForIndex(endIndex) : plotArea.plotX0 + offsetXLocal;
3264
+ const lastX = lastXAbsolute - plotArea.plotX0;
3265
+ const gapLeft = offsetXLocal - leftPaddingPx;
3266
+ const gapRight = targetLastLocal - lastX;
3267
+ if (Math.abs(gapLeft) > 2 || Math.abs(gapRight) > 2) {
3268
+ console.warn('[ChartLayout] gap mismatch', { gapLeft, gapRight, visibleBars, startIndex, endIndex });
3269
+ }
3270
+ const panOffset = panOffsetRef.current;
3271
+ barGeometryRef.current = { barSpacing, offsetX: offsetXLocal };
3272
+ console.log('[ChartLayout]', {
3273
+ containerWidth: layoutWidth,
3274
+ containerHeight: effectiveContainerHeight,
3275
+ leftToolsWidth,
3276
+ priceScaleWidth,
3277
+ paddingLeft,
3278
+ paddingRight,
3279
+ plotX0: plotArea.plotX0,
3280
+ plotX1: plotArea.plotX1,
3281
+ plotWidth: plotArea.plotWidth,
3282
+ plotHeight: plotArea.plotHeight,
3283
+ dataLength: totalDataPoints,
3284
+ visibleBars: candles.length,
3285
+ barSpacing,
3286
+ candleWidth,
3287
+ wickWidth,
3288
+ rightPaddingPx,
3289
+ offsetIndex: panOffset,
3290
+ offsetXPx: offsetXLocal,
3291
+ startIndex,
3292
+ endIndex,
3293
+ lastX,
3294
+ lastXAbsolute,
3295
+ gapRight
3296
+ });
2611
3297
  // Generate price labels
2612
3298
  const priceLabelsArray = [];
2613
3299
  for (let i = 0; i <= 10; i++) {
@@ -2644,7 +3330,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2644
3330
  }
2645
3331
  const timeStep = Math.max(1, Math.floor(Math.max(candles.length, 1) / GRID_DIVISIONS));
2646
3332
  for (let i = 0; i <= candles.length; i += timeStep) {
2647
- const x = alignStroke((i + LEFT_OFFSET_BARS) * candleStep);
3333
+ const x = alignStroke(offsetXLocal + i * barSpacing);
2648
3334
  if (x < 0 || x > chartWidth)
2649
3335
  continue;
2650
3336
  gridCtx.beginPath();
@@ -2680,7 +3366,6 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2680
3366
  else if (seriesType === 'step') {
2681
3367
  const prev = candles[index - 1];
2682
3368
  if (prev) {
2683
- centerX(index - 1);
2684
3369
  const prevY = ((maxPrice - prev.close) / priceRange) * chartHeight;
2685
3370
  chartCtx.lineTo(x, prevY);
2686
3371
  chartCtx.lineTo(x, yClose);
@@ -2740,7 +3425,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2740
3425
  seriesType === 'high-low';
2741
3426
  if (usesWick) {
2742
3427
  chartCtx.strokeStyle = color;
2743
- chartCtx.lineWidth = 1;
3428
+ chartCtx.lineWidth = wickWidth;
2744
3429
  chartCtx.beginPath();
2745
3430
  chartCtx.moveTo(strokeX, alignedYHigh);
2746
3431
  chartCtx.lineTo(strokeX, alignedYLow);
@@ -2846,7 +3531,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2846
3531
  });
2847
3532
  indicatorCtx.setLineDash([]); // Reset line dash
2848
3533
  }
2849
- }, [visibleData, cssWidth, cssHeight, colorScheme, calculatedIndicators, seriesType, getVisibleCandles]);
3534
+ }, [visibleData, cssWidth, cssHeight, colorScheme, calculatedIndicators, seriesType, getVisibleCandles, showDrawingToolbar, isMobile, totalDataPoints, layoutSize]);
2850
3535
  const drawCrosshair = React.useCallback(() => {
2851
3536
  const overlayCtx = overlayRef.current?.getContext('2d');
2852
3537
  if (overlayCtx && showCrosshairRef.current) {
@@ -2883,13 +3568,13 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2883
3568
  }
2884
3569
  if (!isDraggingRef.current) {
2885
3570
  const { min, max } = priceWindowRef.current;
2886
- const rawIndex = pixelToCandleIndex(x, chartWidth, visibleData.length);
3571
+ const rawIndex = projectPixelToIndex(x);
2887
3572
  const hoveredIndex = clampCandleIndex(rawIndex, visibleData.length);
2888
3573
  const hoveredCandle = visibleData[hoveredIndex];
2889
3574
  const snappedPrice = coordsRef.current.snapToPrice(y, chartHeight, min, max);
2890
3575
  const yPixel = coordsRef.current.priceToPixel(snappedPrice, chartHeight, min, max);
2891
3576
  const xPixel = magnetEnabled
2892
- ? getCandleCenter(hoveredIndex, chartWidth, visibleData.length)
3577
+ ? getLocalCenter(hoveredIndex)
2893
3578
  : clampPixelToChart(x, chartWidth);
2894
3579
  mousePosRef.current = { x: xPixel, y: yPixel };
2895
3580
  setCrosshairMeta({
@@ -2955,8 +3640,8 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2955
3640
  let snappedPrice = coordsRef.current.snapToPrice(y, chartHeight, bounds.minPrice, bounds.maxPrice, 0.5);
2956
3641
  let snappedY = y;
2957
3642
  if (magnetEnabled && targetData.length) {
2958
- const snappedIndex = clampCandleIndex(pixelToCandleIndex(x, chartWidth, dataLength), dataLength);
2959
- x = getCandleCenter(snappedIndex, chartWidth, dataLength);
3643
+ const snappedIndex = clampCandleIndex(projectPixelToIndex(x), dataLength);
3644
+ x = getLocalCenter(snappedIndex);
2960
3645
  snappedY = coordsRef.current.priceToPixel(snappedPrice, chartHeight, bounds.minPrice, bounds.maxPrice);
2961
3646
  y = snappedY;
2962
3647
  }
@@ -2964,7 +3649,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
2964
3649
  x = clampPixelToChart(x, chartWidth);
2965
3650
  }
2966
3651
  const price = coordsRef.current.pixelToPrice(snappedY, chartHeight, bounds.minPrice, bounds.maxPrice);
2967
- const hoveredIndex = clampCandleIndex(pixelToCandleIndex(x, chartWidth, dataLength), dataLength);
3652
+ const hoveredIndex = clampCandleIndex(projectPixelToIndex(x), dataLength);
2968
3653
  const time = targetData[hoveredIndex]?.timestamp ?? hoveredIndex;
2969
3654
  return { x, y: snappedY, price, time };
2970
3655
  }, [visibleData, storeData, magnetEnabled, chartWidth, chartHeight, interactionsLocked]);
@@ -3177,7 +3862,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
3177
3862
  }
3178
3863
  if (visibleData.length) {
3179
3864
  const { min, max } = priceWindowRef.current;
3180
- const snappedIndex = clampCandleIndex(pixelToCandleIndex(rawX, chartWidth, visibleData.length), visibleData.length);
3865
+ const snappedIndex = clampCandleIndex(projectPixelToIndex(rawX), visibleData.length);
3181
3866
  setSelectedCandleIndex(snappedIndex);
3182
3867
  const price = coordsRef.current.pixelToPrice(rawY, chartHeight, min, max);
3183
3868
  setClickedPrice({ x: rawX, y: rawY, price });
@@ -3695,7 +4380,7 @@ const TradingViewChart = ({ data, symbol = 'BTC/USDT', onTimeframeChange, showSt
3695
4380
  fontSize: '13px',
3696
4381
  fontWeight: 600
3697
4382
  } }, "Save note"))))),
3698
- React.createElement("div", { style: {
4383
+ React.createElement("div", { ref: layoutRowRef, style: {
3699
4384
  flex: 1,
3700
4385
  display: 'flex',
3701
4386
  gap: isMobile ? '12px' : '16px',