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