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