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/README.md +30 -0
- package/dist/index.cjs +753 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.mjs +754 -69
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -1,55 +1,642 @@
|
|
|
1
|
-
import React, { useRef,
|
|
1
|
+
import React, { useRef, useState, useEffect, useMemo, useCallback, useSyncExternalStore, memo } from 'react';
|
|
2
2
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
3
|
|
|
4
|
-
const
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
+
const dpr = window.devicePixelRatio || 1;
|
|
271
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
14
272
|
ctx.clearRect(0, 0, width, height);
|
|
15
|
-
|
|
273
|
+
ctx.fillStyle = '#05070d';
|
|
274
|
+
ctx.fillRect(0, 0, width, height);
|
|
275
|
+
if (!priceSlice.length)
|
|
16
276
|
return;
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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(
|
|
47
|
-
ctx.lineTo(
|
|
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
|
|
52
|
-
|
|
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
|
|
2116
|
-
const
|
|
2117
|
-
const
|
|
2118
|
-
const
|
|
2119
|
-
|
|
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
|
|
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
|
|
2605
|
-
const
|
|
2606
|
-
const
|
|
2607
|
-
const
|
|
2608
|
-
const
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
-
?
|
|
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(
|
|
2957
|
-
x =
|
|
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(
|
|
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(
|
|
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',
|