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