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