viainti-chart 1.1.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +461 -454
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +462 -455
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -1,222 +1,266 @@
|
|
|
1
|
-
import React, { useRef, useState,
|
|
1
|
+
import React, { useRef, useState, useEffect, useMemo, useCallback, useSyncExternalStore, memo } from 'react';
|
|
2
2
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
3
|
|
|
4
|
+
const PRICE_SCALE_WIDTH = 72;
|
|
5
|
+
const LEFT_TOOLBAR_WIDTH = 0;
|
|
6
|
+
const PADDING_LEFT = 12;
|
|
7
|
+
const PADDING_RIGHT = 12;
|
|
8
|
+
const PADDING_TOP = 12;
|
|
9
|
+
const PADDING_BOTTOM = 12;
|
|
10
|
+
const X_AXIS_HEIGHT = 42;
|
|
11
|
+
const VOLUME_RATIO = 0.28;
|
|
12
|
+
const MIN_VOLUME_HEIGHT = 120;
|
|
13
|
+
const MAX_VOLUME_HEIGHT = 260;
|
|
14
|
+
const MIN_PRICE_HEIGHT = 200;
|
|
4
15
|
const MIN_BAR_SPACING$1 = 6;
|
|
5
16
|
const MAX_BAR_SPACING$1 = 18;
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
17
|
+
const MIN_VISIBLE_BARS = 20;
|
|
18
|
+
const MAX_VISIBLE_BARS = 800;
|
|
19
|
+
const CROSSHAIR_COLOR = 'rgba(255,255,255,0.35)';
|
|
20
|
+
const GRID_MAJOR = 'rgba(255,255,255,0.06)';
|
|
21
|
+
const GRID_MINOR = 'rgba(255,255,255,0.03)';
|
|
22
|
+
const BULL_COLOR = '#2ECC71';
|
|
23
|
+
const BEAR_COLOR = '#E74C3C';
|
|
9
24
|
const clamp$1 = (value, min, max) => Math.max(min, Math.min(max, value));
|
|
10
25
|
const alignStroke$1 = (value) => Math.round(value) + 0.5;
|
|
11
|
-
const
|
|
26
|
+
const niceStep = (range, targetTicks) => {
|
|
27
|
+
if (range <= 0 || !Number.isFinite(range))
|
|
28
|
+
return 1;
|
|
29
|
+
const rawStep = range / Math.max(targetTicks, 1);
|
|
30
|
+
const magnitude = 10 ** Math.floor(Math.log10(rawStep));
|
|
31
|
+
const normalized = rawStep / magnitude;
|
|
32
|
+
let niceNormalized;
|
|
33
|
+
if (normalized <= 1)
|
|
34
|
+
niceNormalized = 1;
|
|
35
|
+
else if (normalized <= 2)
|
|
36
|
+
niceNormalized = 2;
|
|
37
|
+
else if (normalized <= 5)
|
|
38
|
+
niceNormalized = 5;
|
|
39
|
+
else
|
|
40
|
+
niceNormalized = 10;
|
|
41
|
+
return niceNormalized * magnitude;
|
|
42
|
+
};
|
|
43
|
+
const formatPrice = (value, step) => {
|
|
44
|
+
if (!Number.isFinite(value))
|
|
45
|
+
return '—';
|
|
46
|
+
const decimals = clamp$1(-Math.floor(Math.log10(step || 1)), 0, value < 1 ? 6 : 4);
|
|
47
|
+
return value.toFixed(decimals);
|
|
48
|
+
};
|
|
49
|
+
const formatVolume = (value) => {
|
|
50
|
+
if (!Number.isFinite(value))
|
|
51
|
+
return '—';
|
|
52
|
+
if (value >= 1_000_000)
|
|
53
|
+
return `${(value / 1_000_000).toFixed(2)}M`;
|
|
54
|
+
if (value >= 1_000)
|
|
55
|
+
return `${(value / 1_000).toFixed(2)}K`;
|
|
56
|
+
return value.toFixed(2);
|
|
57
|
+
};
|
|
58
|
+
const formatTimeLabel = (timestamp) => {
|
|
59
|
+
const date = new Date(timestamp);
|
|
60
|
+
if (Number.isNaN(date.getTime()))
|
|
61
|
+
return `${timestamp}`;
|
|
62
|
+
return date.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
|
|
63
|
+
};
|
|
64
|
+
const getCandleTime = (candle, fallback) => {
|
|
12
65
|
if (!candle)
|
|
13
|
-
return
|
|
66
|
+
return fallback;
|
|
14
67
|
if (typeof candle.time === 'number')
|
|
15
68
|
return candle.time;
|
|
16
69
|
if (typeof candle.timestamp === 'number')
|
|
17
70
|
return candle.timestamp;
|
|
18
|
-
return
|
|
71
|
+
return fallback;
|
|
19
72
|
};
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
ticks.push(clampedValue);
|
|
35
|
-
}
|
|
36
|
-
ticks[ticks.length - 1] = maxTime;
|
|
37
|
-
return ticks;
|
|
38
|
-
};
|
|
39
|
-
const formatTickLabel = (value) => {
|
|
40
|
-
const date = new Date(value);
|
|
41
|
-
if (!Number.isNaN(date.getTime())) {
|
|
42
|
-
return date.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
|
|
43
|
-
}
|
|
44
|
-
return `${value}`;
|
|
45
|
-
};
|
|
46
|
-
const getPanBounds = (visibleBars, dataLength) => {
|
|
47
|
-
const maxPan = 0;
|
|
48
|
-
const minPan = Math.min(0, visibleBars - dataLength);
|
|
49
|
-
return { min: minPan, max: maxPan };
|
|
73
|
+
const computePlotArea = (containerWidth, containerHeight, showVolume) => {
|
|
74
|
+
const leftGutter = LEFT_TOOLBAR_WIDTH + PADDING_LEFT;
|
|
75
|
+
const rightGutter = PRICE_SCALE_WIDTH + PADDING_RIGHT;
|
|
76
|
+
const plotX0 = leftGutter;
|
|
77
|
+
const plotX1 = Math.max(plotX0 + 1, containerWidth - rightGutter);
|
|
78
|
+
const plotWidth = plotX1 - plotX0;
|
|
79
|
+
const availableHeight = Math.max(120, containerHeight - PADDING_TOP - PADDING_BOTTOM - X_AXIS_HEIGHT);
|
|
80
|
+
const targetVolume = showVolume ? clamp$1(Math.round(containerHeight * VOLUME_RATIO), MIN_VOLUME_HEIGHT, MAX_VOLUME_HEIGHT) : 0;
|
|
81
|
+
const volumePaneHeight = showVolume ? Math.min(targetVolume, Math.max(0, availableHeight - MIN_PRICE_HEIGHT)) : 0;
|
|
82
|
+
const pricePaneHeight = availableHeight - volumePaneHeight;
|
|
83
|
+
const pricePaneY0 = PADDING_TOP;
|
|
84
|
+
const volumePaneY0 = pricePaneY0 + pricePaneHeight + (showVolume ? 12 : 0);
|
|
85
|
+
const xAxisY0 = volumePaneY0 + volumePaneHeight;
|
|
86
|
+
return { plotX0, plotX1, plotWidth, pricePaneY0, pricePaneHeight, volumePaneY0, volumePaneHeight, xAxisY0 };
|
|
50
87
|
};
|
|
51
88
|
const computeViewport = (data, state) => {
|
|
52
89
|
if (!data.length) {
|
|
53
|
-
return { startIndex: 0, endIndex: 0 };
|
|
90
|
+
return { startIndex: 0, endIndex: 0, minTimeVisible: 0, maxTimeVisible: 0 };
|
|
54
91
|
}
|
|
55
92
|
const rawEnd = (data.length - 1) + Math.round(state.panOffsetBars);
|
|
56
|
-
const minEnd = Math.max(state.visibleBars - 1, 0);
|
|
57
93
|
const maxEnd = Math.max(data.length - 1, 0);
|
|
94
|
+
const minEnd = Math.min(Math.max(state.visibleBars - 1, 0), maxEnd);
|
|
58
95
|
const endIndex = clamp$1(rawEnd, minEnd, maxEnd);
|
|
59
96
|
const startIndex = Math.max(0, endIndex - state.visibleBars + 1);
|
|
60
|
-
|
|
97
|
+
const minTimeVisible = getCandleTime(data[startIndex], startIndex);
|
|
98
|
+
const maxTimeVisible = getCandleTime(data[endIndex], endIndex);
|
|
99
|
+
console.log('[Chart::Viewport]', { startIndex, endIndex, minTimeVisible, maxTimeVisible });
|
|
100
|
+
return { startIndex, endIndex, minTimeVisible, maxTimeVisible };
|
|
61
101
|
};
|
|
62
|
-
const computeXMapping = (plotArea, viewport) => {
|
|
63
|
-
const { plotX0, plotX1, leftPaddingPx, rightPaddingPx } = plotArea;
|
|
102
|
+
const computeXMapping = (plotArea, viewport, state) => {
|
|
64
103
|
const visibleCount = Math.max(1, viewport.endIndex - viewport.startIndex + 1);
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
const
|
|
104
|
+
const firstTarget = plotArea.plotX0 + state.leftPaddingPx;
|
|
105
|
+
const lastTarget = plotArea.plotX1 - state.rightPaddingPx;
|
|
106
|
+
const innerSpan = Math.max(1, lastTarget - firstTarget);
|
|
107
|
+
const gaps = Math.max(1, visibleCount - 1);
|
|
108
|
+
const rawSpacing = visibleCount > 1 ? innerSpan / gaps : innerSpan;
|
|
109
|
+
const barSpacing = clamp$1(rawSpacing, MIN_BAR_SPACING$1, MAX_BAR_SPACING$1);
|
|
110
|
+
const bodyWidth = clamp$1(barSpacing * 0.7, 3, barSpacing - 2);
|
|
111
|
+
const wickWidth = Math.max(1, Math.floor(bodyWidth * 0.25));
|
|
112
|
+
const offsetX = visibleCount > 1
|
|
113
|
+
? (lastTarget - (visibleCount - 1) * barSpacing) - plotArea.plotX0
|
|
114
|
+
: (lastTarget - plotArea.plotX0);
|
|
115
|
+
const candleCenterX = (index) => plotArea.plotX0 + offsetX + (index - viewport.startIndex) * barSpacing;
|
|
116
|
+
const candleLeftX = (index) => candleCenterX(index) - bodyWidth / 2;
|
|
117
|
+
const gapLeft = candleCenterX(viewport.startIndex) - (plotArea.plotX0 + state.leftPaddingPx);
|
|
118
|
+
const gapRight = (plotArea.plotX1 - state.rightPaddingPx) - candleCenterX(viewport.endIndex);
|
|
73
119
|
if (Math.abs(gapLeft) > 2 || Math.abs(gapRight) > 2) {
|
|
74
|
-
console.
|
|
120
|
+
console.warn('[Chart::GapMismatch]', { startIndex: viewport.startIndex, endIndex: viewport.endIndex, gapLeft, gapRight, visibleCount, barSpacing });
|
|
75
121
|
}
|
|
76
122
|
else {
|
|
77
|
-
console.log('[
|
|
123
|
+
console.log('[Chart::AlignmentOK]', { startIndex: viewport.startIndex, endIndex: viewport.endIndex, gapLeft, gapRight, visibleCount, barSpacing });
|
|
124
|
+
}
|
|
125
|
+
return { barSpacing, bodyWidth, wickWidth, offsetX, candleCenterX, candleLeftX };
|
|
126
|
+
};
|
|
127
|
+
const computeYMapping = (data, viewport, plotArea, state) => {
|
|
128
|
+
const slice = data.slice(Math.max(0, viewport.startIndex), Math.max(0, viewport.endIndex) + 1);
|
|
129
|
+
if (!slice.length) {
|
|
130
|
+
return {
|
|
131
|
+
yMin: 0,
|
|
132
|
+
yMax: 1,
|
|
133
|
+
priceToY: () => plotArea.pricePaneY0,
|
|
134
|
+
yTicks: []
|
|
135
|
+
};
|
|
78
136
|
}
|
|
79
|
-
|
|
137
|
+
let minPrice = Number.POSITIVE_INFINITY;
|
|
138
|
+
let maxPrice = Number.NEGATIVE_INFINITY;
|
|
139
|
+
slice.forEach(candle => {
|
|
140
|
+
minPrice = Math.min(minPrice, candle.low);
|
|
141
|
+
maxPrice = Math.max(maxPrice, candle.high);
|
|
142
|
+
});
|
|
143
|
+
if (!Number.isFinite(minPrice) || !Number.isFinite(maxPrice)) {
|
|
144
|
+
minPrice = 0;
|
|
145
|
+
maxPrice = 1;
|
|
146
|
+
}
|
|
147
|
+
const pad = (maxPrice - minPrice || Math.abs(maxPrice) * 0.01 || 1) * state.yPaddingPct;
|
|
148
|
+
const yMin = minPrice - pad;
|
|
149
|
+
const yMax = maxPrice + pad;
|
|
150
|
+
const priceToY = (value) => {
|
|
151
|
+
const clampedValue = Number.isFinite(value) ? value : 0;
|
|
152
|
+
return plotArea.pricePaneY0 + ((yMax - clampedValue) / Math.max(yMax - yMin, 1e-6)) * plotArea.pricePaneHeight;
|
|
153
|
+
};
|
|
154
|
+
const step = niceStep(yMax - yMin, 8);
|
|
155
|
+
const startTick = Math.ceil(yMin / step) * step;
|
|
156
|
+
const yTicks = [];
|
|
157
|
+
for (let value = startTick; value <= yMax; value += step) {
|
|
158
|
+
const y = priceToY(value);
|
|
159
|
+
yTicks.push({ value, y, label: formatPrice(value, step) });
|
|
160
|
+
}
|
|
161
|
+
return { yMin, yMax, priceToY, yTicks };
|
|
162
|
+
};
|
|
163
|
+
const computeTimeTicks = (data, viewport, xMapping, plotArea) => {
|
|
164
|
+
if (!data.length)
|
|
165
|
+
return [];
|
|
166
|
+
const ticks = [];
|
|
167
|
+
const targetSpacingPx = 120;
|
|
168
|
+
const candlesPerTick = Math.max(1, Math.round(targetSpacingPx / xMapping.barSpacing));
|
|
169
|
+
for (let idx = viewport.startIndex; idx <= viewport.endIndex; idx += candlesPerTick) {
|
|
170
|
+
const timestamp = getCandleTime(data[idx], idx);
|
|
171
|
+
if (timestamp < viewport.minTimeVisible || timestamp > viewport.maxTimeVisible)
|
|
172
|
+
continue;
|
|
173
|
+
const x = xMapping.candleCenterX(idx);
|
|
174
|
+
if (x < plotArea.plotX0 || x > plotArea.plotX1)
|
|
175
|
+
continue;
|
|
176
|
+
ticks.push({ index: idx, x, label: formatTimeLabel(timestamp) });
|
|
177
|
+
}
|
|
178
|
+
if (ticks.length === 0 && viewport.endIndex >= viewport.startIndex) {
|
|
179
|
+
const timestamp = getCandleTime(data[viewport.endIndex], viewport.endIndex);
|
|
180
|
+
if (timestamp >= viewport.minTimeVisible && timestamp <= viewport.maxTimeVisible) {
|
|
181
|
+
const x = xMapping.candleCenterX(viewport.endIndex);
|
|
182
|
+
ticks.push({ index: viewport.endIndex, x, label: formatTimeLabel(timestamp) });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const outOfRange = ticks.filter(tick => {
|
|
186
|
+
const t = getCandleTime(data[tick.index], tick.index);
|
|
187
|
+
return t < viewport.minTimeVisible || t > viewport.maxTimeVisible;
|
|
188
|
+
});
|
|
189
|
+
if (outOfRange.length) {
|
|
190
|
+
console.warn('[Chart::TimeTickTrim]', { outOfRange, minTimeVisible: viewport.minTimeVisible, maxTimeVisible: viewport.maxTimeVisible });
|
|
191
|
+
}
|
|
192
|
+
console.log('[Chart::TimeTicks]', {
|
|
193
|
+
count: ticks.length,
|
|
194
|
+
minTimeVisible: viewport.minTimeVisible,
|
|
195
|
+
maxTimeVisible: viewport.maxTimeVisible,
|
|
196
|
+
tickIndexes: ticks.map(t => t.index),
|
|
197
|
+
labels: ticks.map(t => t.label)
|
|
198
|
+
});
|
|
199
|
+
return ticks;
|
|
200
|
+
};
|
|
201
|
+
const drawRoundedRect$1 = (ctx, x, y, width, height, radius) => {
|
|
202
|
+
const r = Math.min(radius, width / 2, height / 2);
|
|
203
|
+
ctx.beginPath();
|
|
204
|
+
ctx.moveTo(x + r, y);
|
|
205
|
+
ctx.lineTo(x + width - r, y);
|
|
206
|
+
ctx.quadraticCurveTo(x + width, y, x + width, y + r);
|
|
207
|
+
ctx.lineTo(x + width, y + height - r);
|
|
208
|
+
ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
|
|
209
|
+
ctx.lineTo(x + r, y + height);
|
|
210
|
+
ctx.quadraticCurveTo(x, y + height, x, y + height - r);
|
|
211
|
+
ctx.lineTo(x, y + r);
|
|
212
|
+
ctx.quadraticCurveTo(x, y, x + r, y);
|
|
213
|
+
ctx.closePath();
|
|
80
214
|
};
|
|
81
|
-
const Chart = ({ data, width = 900, height =
|
|
215
|
+
const Chart = ({ data, width = 900, height = 520, visibleBars: defaultVisibleBars = 60 }) => {
|
|
82
216
|
const priceCanvasRef = useRef(null);
|
|
83
217
|
const overlayCanvasRef = useRef(null);
|
|
84
218
|
const chartAreaRef = useRef(null);
|
|
85
|
-
const isDraggingRef = useRef(false);
|
|
86
|
-
const lastPointerXRef = useRef(0);
|
|
87
|
-
const barSpacingRef = useRef(12);
|
|
88
|
-
const rafRef = useRef(null);
|
|
89
219
|
const [showMenu, setShowMenu] = useState(false);
|
|
90
|
-
const initialVisible = useMemo(() => {
|
|
91
|
-
if (!data.length)
|
|
92
|
-
return Math.max(MIN_VISIBLE_BARS, visibleBarsProp);
|
|
93
|
-
const maxBars = Math.max(1, data.length);
|
|
94
|
-
return clamp$1(Math.round(visibleBarsProp), Math.min(MIN_VISIBLE_BARS, maxBars), Math.min(MAX_VISIBLE_BARS, maxBars));
|
|
95
|
-
}, [data.length, visibleBarsProp]);
|
|
96
220
|
const [chartState, setChartState] = useState(() => ({
|
|
97
|
-
visibleBars:
|
|
98
|
-
defaultVisibleBars: initialVisible,
|
|
221
|
+
visibleBars: Math.max(defaultVisibleBars, MIN_VISIBLE_BARS),
|
|
99
222
|
panOffsetBars: 0,
|
|
100
223
|
locked: false,
|
|
101
224
|
crosshairEnabled: true,
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
hoveredIndex: null,
|
|
225
|
+
crosshairIndex: null,
|
|
226
|
+
crosshairPrice: null,
|
|
105
227
|
autoScaleY: true,
|
|
106
|
-
yMinManual: null,
|
|
107
|
-
yMaxManual: null,
|
|
108
228
|
yPaddingPct: 0.08,
|
|
109
|
-
showCandles: true,
|
|
110
|
-
showVolume: true,
|
|
111
229
|
showGrid: true,
|
|
112
|
-
|
|
230
|
+
showVolume: true,
|
|
231
|
+
rightPaddingPx: 60,
|
|
232
|
+
leftPaddingPx: 0
|
|
113
233
|
}));
|
|
114
234
|
useEffect(() => {
|
|
115
235
|
setChartState(prev => {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const nextVisible = clamp$1(Math.round(prev.visibleBars), Math.min(MIN_VISIBLE_BARS, maxBars), Math.min(MAX_VISIBLE_BARS, maxBars));
|
|
121
|
-
const nextDefault = clamp$1(Math.round(prev.defaultVisibleBars), Math.min(MIN_VISIBLE_BARS, maxBars), Math.min(MAX_VISIBLE_BARS, maxBars));
|
|
122
|
-
const bounds = getPanBounds(nextVisible, data.length);
|
|
123
|
-
const nextPan = clamp$1(prev.panOffsetBars, bounds.min, bounds.max);
|
|
124
|
-
if (nextVisible === prev.visibleBars && nextDefault === prev.defaultVisibleBars && nextPan === prev.panOffsetBars) {
|
|
236
|
+
const clamped = clamp$1(Math.round(prev.visibleBars), MIN_VISIBLE_BARS, Math.min(MAX_VISIBLE_BARS, Math.max(data.length, MIN_VISIBLE_BARS)));
|
|
237
|
+
const minPan = -Math.max(data.length - clamped, 0);
|
|
238
|
+
const panOffsetBars = clamp$1(prev.panOffsetBars, minPan, 0);
|
|
239
|
+
if (clamped === prev.visibleBars && panOffsetBars === prev.panOffsetBars)
|
|
125
240
|
return prev;
|
|
126
|
-
}
|
|
127
|
-
return { ...prev, visibleBars: nextVisible, defaultVisibleBars: nextDefault, panOffsetBars: nextPan };
|
|
241
|
+
return { ...prev, visibleBars: clamped, panOffsetBars };
|
|
128
242
|
});
|
|
129
243
|
}, [data.length]);
|
|
244
|
+
const plotArea = useMemo(() => computePlotArea(width, height, chartState.showVolume), [chartState.showVolume, height, width]);
|
|
130
245
|
const viewport = useMemo(() => computeViewport(data, chartState), [chartState, data]);
|
|
246
|
+
const xMapping = useMemo(() => computeXMapping(plotArea, viewport, chartState), [chartState, plotArea, viewport]);
|
|
247
|
+
const yMapping = useMemo(() => computeYMapping(data, viewport, plotArea, chartState), [chartState, data, plotArea, viewport]);
|
|
248
|
+
const timeTicks = useMemo(() => computeTimeTicks(data, viewport, xMapping, plotArea), [data, plotArea, viewport, xMapping]);
|
|
131
249
|
useEffect(() => {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const visibleData = data.slice(safeStart, safeEnd + 1);
|
|
142
|
-
const plotArea = useMemo(() => ({
|
|
143
|
-
plotX0: 0,
|
|
144
|
-
plotX1: width - RIGHT_PADDING_PX$1,
|
|
145
|
-
leftPaddingPx: 0,
|
|
146
|
-
rightPaddingPx: RIGHT_PADDING_PX$1
|
|
147
|
-
}), [width]);
|
|
148
|
-
const xMapping = useMemo(() => computeXMapping(plotArea, viewport), [plotArea, viewport]);
|
|
149
|
-
useEffect(() => {
|
|
150
|
-
barSpacingRef.current = xMapping.barSpacing;
|
|
151
|
-
}, [xMapping.barSpacing]);
|
|
152
|
-
const priceHeight = useMemo(() => {
|
|
153
|
-
if (!chartState.showVolume)
|
|
154
|
-
return height;
|
|
155
|
-
return Math.max(160, height - Math.max(48, Math.floor(height * 0.22)));
|
|
156
|
-
}, [chartState.showVolume, height]);
|
|
157
|
-
const volumeHeight = Math.max(0, height - priceHeight);
|
|
158
|
-
const priceWindow = useMemo(() => {
|
|
159
|
-
if (!visibleData.length) {
|
|
160
|
-
return { min: 0, max: 1 };
|
|
161
|
-
}
|
|
162
|
-
if (!chartState.autoScaleY && chartState.yMinManual != null && chartState.yMaxManual != null) {
|
|
163
|
-
return { min: chartState.yMinManual, max: chartState.yMaxManual };
|
|
164
|
-
}
|
|
165
|
-
const values = [];
|
|
166
|
-
visibleData.forEach(candle => {
|
|
167
|
-
values.push(candle.low, candle.high);
|
|
168
|
-
});
|
|
169
|
-
values.sort((a, b) => a - b);
|
|
170
|
-
let min = values[0] ?? 0;
|
|
171
|
-
let max = values[values.length - 1] ?? 1;
|
|
172
|
-
if (values.length >= 6) {
|
|
173
|
-
const lowerIdx = Math.max(0, Math.floor(values.length * 0.02));
|
|
174
|
-
const upperIdx = Math.min(values.length - 1, Math.ceil(values.length * 0.98) - 1);
|
|
175
|
-
min = values[lowerIdx] ?? min;
|
|
176
|
-
max = values[upperIdx] ?? max;
|
|
177
|
-
}
|
|
178
|
-
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
|
179
|
-
return { min: 0, max: 1 };
|
|
180
|
-
}
|
|
181
|
-
const clampedPaddingPct = clamp$1(chartState.yPaddingPct, 0.06, 0.1);
|
|
182
|
-
const rawRange = max - min || Math.abs(max) * 0.01 || 1;
|
|
183
|
-
const padding = Math.max(rawRange * clampedPaddingPct, 1e-6);
|
|
184
|
-
return { min: min - padding, max: max + padding };
|
|
185
|
-
}, [chartState.autoScaleY, chartState.yMaxManual, chartState.yMinManual, chartState.yPaddingPct, visibleData]);
|
|
186
|
-
const maxVolume = useMemo(() => visibleData.reduce((acc, candle) => Math.max(acc, candle.volume ?? 0), 0), [visibleData]);
|
|
187
|
-
const minTimeVisible = visibleData.length ? getCandleTime(visibleData[0], safeStart) : 0;
|
|
188
|
-
const maxTimeVisible = visibleData.length ? getCandleTime(visibleData[visibleData.length - 1], safeEnd) : 0;
|
|
189
|
-
const timeTicks = useMemo(() => {
|
|
190
|
-
if (!visibleData.length)
|
|
191
|
-
return [];
|
|
192
|
-
const availableWidth = plotArea.plotX1 - plotArea.plotX0;
|
|
193
|
-
const maxTicks = Math.max(2, Math.min(10, Math.floor(availableWidth / 120)));
|
|
194
|
-
const ticks = buildTimeTicks(minTimeVisible, maxTimeVisible, maxTicks);
|
|
195
|
-
return ticks.map(tick => clamp$1(tick, minTimeVisible, maxTimeVisible));
|
|
196
|
-
}, [maxTimeVisible, minTimeVisible, plotArea.plotX0, plotArea.plotX1, visibleData]);
|
|
197
|
-
const resizeCanvas = useCallback((canvas) => {
|
|
198
|
-
if (!canvas)
|
|
199
|
-
return;
|
|
200
|
-
const dpr = window.devicePixelRatio || 1;
|
|
201
|
-
canvas.width = width * dpr;
|
|
202
|
-
canvas.height = height * dpr;
|
|
203
|
-
canvas.style.width = `${width}px`;
|
|
204
|
-
canvas.style.height = `${height}px`;
|
|
205
|
-
}, [height, width]);
|
|
206
|
-
useEffect(() => {
|
|
250
|
+
const resizeCanvas = (canvas) => {
|
|
251
|
+
if (!canvas)
|
|
252
|
+
return;
|
|
253
|
+
const dpr = window.devicePixelRatio || 1;
|
|
254
|
+
canvas.width = width * dpr;
|
|
255
|
+
canvas.height = height * dpr;
|
|
256
|
+
canvas.style.width = `${width}px`;
|
|
257
|
+
canvas.style.height = `${height}px`;
|
|
258
|
+
};
|
|
207
259
|
resizeCanvas(priceCanvasRef.current);
|
|
208
260
|
resizeCanvas(overlayCanvasRef.current);
|
|
209
|
-
}, [
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
return xMapping.x(safeEnd);
|
|
213
|
-
}
|
|
214
|
-
const ratio = (tick - minTimeVisible) / (maxTimeVisible - minTimeVisible);
|
|
215
|
-
const target = safeStart + ratio * (safeEnd - safeStart);
|
|
216
|
-
const snapped = clamp$1(Math.round(target), safeStart, safeEnd);
|
|
217
|
-
return xMapping.x(snapped);
|
|
218
|
-
}, [maxTimeVisible, minTimeVisible, safeEnd, safeStart, xMapping]);
|
|
219
|
-
const drawPriceLayer = useCallback(() => {
|
|
261
|
+
}, [height, width]);
|
|
262
|
+
const priceSlice = useMemo(() => data.slice(Math.max(0, viewport.startIndex), Math.max(0, viewport.endIndex) + 1), [data, viewport.endIndex, viewport.startIndex]);
|
|
263
|
+
const drawBaseLayer = useCallback(() => {
|
|
220
264
|
const canvas = priceCanvasRef.current;
|
|
221
265
|
if (!canvas)
|
|
222
266
|
return;
|
|
@@ -228,167 +272,157 @@ const Chart = ({ data, width = 900, height = 480, visibleBars: visibleBarsProp =
|
|
|
228
272
|
ctx.clearRect(0, 0, width, height);
|
|
229
273
|
ctx.fillStyle = '#05070d';
|
|
230
274
|
ctx.fillRect(0, 0, width, height);
|
|
231
|
-
if (!
|
|
275
|
+
if (!priceSlice.length)
|
|
232
276
|
return;
|
|
233
|
-
|
|
234
|
-
const priceRange = Math.max(priceWindow.max - priceWindow.min, 1e-6);
|
|
235
|
-
const toY = (value) => ((priceWindow.max - value) / priceRange) * priceHeight;
|
|
236
|
-
const horizontalDivisions = 4;
|
|
277
|
+
// Price pane clip
|
|
237
278
|
ctx.save();
|
|
238
279
|
ctx.beginPath();
|
|
239
|
-
ctx.rect(plotArea.plotX0,
|
|
280
|
+
ctx.rect(plotArea.plotX0, plotArea.pricePaneY0, plotArea.plotWidth, plotArea.pricePaneHeight);
|
|
240
281
|
ctx.clip();
|
|
241
282
|
if (chartState.showGrid) {
|
|
242
|
-
ctx.strokeStyle =
|
|
243
|
-
ctx.lineWidth =
|
|
283
|
+
ctx.strokeStyle = GRID_MAJOR;
|
|
284
|
+
ctx.lineWidth = 1;
|
|
244
285
|
ctx.setLineDash([]);
|
|
245
|
-
|
|
246
|
-
const y = alignStroke$1((i / horizontalDivisions) * priceHeight);
|
|
286
|
+
yMapping.yTicks.forEach(tick => {
|
|
247
287
|
ctx.beginPath();
|
|
248
|
-
ctx.moveTo(plotArea.plotX0, y);
|
|
249
|
-
ctx.lineTo(plotArea.plotX1, y);
|
|
288
|
+
ctx.moveTo(plotArea.plotX0, alignStroke$1(tick.y));
|
|
289
|
+
ctx.lineTo(plotArea.plotX1, alignStroke$1(tick.y));
|
|
250
290
|
ctx.stroke();
|
|
251
|
-
}
|
|
291
|
+
});
|
|
252
292
|
timeTicks.forEach(tick => {
|
|
253
|
-
const x = alignStroke$1(getXForTimeValue(tick));
|
|
254
293
|
ctx.beginPath();
|
|
255
|
-
ctx.moveTo(x,
|
|
256
|
-
ctx.lineTo(x,
|
|
294
|
+
ctx.moveTo(alignStroke$1(tick.x), plotArea.pricePaneY0);
|
|
295
|
+
ctx.lineTo(alignStroke$1(tick.x), plotArea.pricePaneY0 + plotArea.pricePaneHeight);
|
|
257
296
|
ctx.stroke();
|
|
258
297
|
});
|
|
259
|
-
|
|
260
|
-
if (chartState.showGridMinor) {
|
|
261
|
-
ctx.save();
|
|
262
|
-
ctx.strokeStyle = 'rgba(58,63,83,0.45)';
|
|
263
|
-
ctx.lineWidth = 0.4;
|
|
298
|
+
ctx.strokeStyle = GRID_MINOR;
|
|
264
299
|
ctx.setLineDash([2, 4]);
|
|
265
|
-
for (let i = 0; i <
|
|
266
|
-
const
|
|
300
|
+
for (let i = 0; i < yMapping.yTicks.length - 1; i++) {
|
|
301
|
+
const current = yMapping.yTicks[i];
|
|
302
|
+
const next = yMapping.yTicks[i + 1];
|
|
303
|
+
if (!current || !next)
|
|
304
|
+
continue;
|
|
305
|
+
const midValue = (current.value + next.value) / 2;
|
|
306
|
+
const y = alignStroke$1(yMapping.priceToY(midValue));
|
|
267
307
|
ctx.beginPath();
|
|
268
308
|
ctx.moveTo(plotArea.plotX0, y);
|
|
269
309
|
ctx.lineTo(plotArea.plotX1, y);
|
|
270
310
|
ctx.stroke();
|
|
271
311
|
}
|
|
272
312
|
for (let i = 0; i < timeTicks.length - 1; i++) {
|
|
273
|
-
const
|
|
274
|
-
const
|
|
313
|
+
const current = timeTicks[i];
|
|
314
|
+
const next = timeTicks[i + 1];
|
|
315
|
+
if (!current || !next)
|
|
316
|
+
continue;
|
|
317
|
+
const midX = (current.x + next.x) / 2;
|
|
275
318
|
ctx.beginPath();
|
|
276
|
-
ctx.moveTo(
|
|
277
|
-
ctx.lineTo(
|
|
319
|
+
ctx.moveTo(alignStroke$1(midX), plotArea.pricePaneY0);
|
|
320
|
+
ctx.lineTo(alignStroke$1(midX), plotArea.pricePaneY0 + plotArea.pricePaneHeight);
|
|
278
321
|
ctx.stroke();
|
|
279
322
|
}
|
|
280
|
-
ctx.
|
|
323
|
+
ctx.setLineDash([]);
|
|
281
324
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
ctx.fillStyle = 'rgba(
|
|
286
|
-
ctx.fillRect(
|
|
325
|
+
const highlightIndex = chartState.crosshairIndex;
|
|
326
|
+
if (highlightIndex != null && highlightIndex >= viewport.startIndex && highlightIndex <= viewport.endIndex) {
|
|
327
|
+
const x = xMapping.candleLeftX(highlightIndex);
|
|
328
|
+
ctx.fillStyle = 'rgba(255,255,255,0.05)';
|
|
329
|
+
ctx.fillRect(x, plotArea.pricePaneY0, xMapping.bodyWidth, plotArea.pricePaneHeight);
|
|
287
330
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
ctx.lineTo(xCenter + (xMapping.barSpacing * 0.35), alignStroke$1(yClose));
|
|
311
|
-
ctx.stroke();
|
|
312
|
-
}
|
|
313
|
-
else {
|
|
314
|
-
ctx.fillRect(xCenter - (xMapping.barSpacing * 0.35), bodyY, xMapping.barSpacing * 0.7, bodyHeight);
|
|
315
|
-
}
|
|
316
|
-
});
|
|
317
|
-
}
|
|
318
|
-
const lastCandle = data[data.length - 1];
|
|
319
|
-
const prevCandle = data[data.length - 2];
|
|
331
|
+
priceSlice.forEach((candle, offset) => {
|
|
332
|
+
const index = viewport.startIndex + offset;
|
|
333
|
+
const xCenter = xMapping.candleCenterX(index);
|
|
334
|
+
const left = xCenter - xMapping.bodyWidth / 2;
|
|
335
|
+
const yOpen = yMapping.priceToY(candle.open);
|
|
336
|
+
const yClose = yMapping.priceToY(candle.close);
|
|
337
|
+
const yHigh = yMapping.priceToY(candle.high);
|
|
338
|
+
const yLow = yMapping.priceToY(candle.low);
|
|
339
|
+
const bodyTop = Math.min(yOpen, yClose);
|
|
340
|
+
const bodyHeight = Math.abs(yClose - yOpen) || 1;
|
|
341
|
+
const isBull = (candle.close ?? 0) >= (candle.open ?? 0);
|
|
342
|
+
ctx.strokeStyle = isBull ? BULL_COLOR : BEAR_COLOR;
|
|
343
|
+
ctx.lineWidth = xMapping.wickWidth;
|
|
344
|
+
ctx.beginPath();
|
|
345
|
+
ctx.moveTo(alignStroke$1(xCenter), yHigh);
|
|
346
|
+
ctx.lineTo(alignStroke$1(xCenter), yLow);
|
|
347
|
+
ctx.stroke();
|
|
348
|
+
ctx.fillStyle = isBull ? BULL_COLOR : BEAR_COLOR;
|
|
349
|
+
drawRoundedRect$1(ctx, left, bodyTop, xMapping.bodyWidth, bodyHeight, 1.5);
|
|
350
|
+
ctx.fill();
|
|
351
|
+
});
|
|
352
|
+
const lastCandle = data[viewport.endIndex] ?? data[data.length - 1];
|
|
320
353
|
if (lastCandle) {
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
354
|
+
const lastPrice = lastCandle.close ?? lastCandle.open ?? lastCandle.high ?? 0;
|
|
355
|
+
const prevClose = data[viewport.endIndex - 1]?.close ?? lastPrice;
|
|
356
|
+
const delta = lastPrice - prevClose;
|
|
357
|
+
const deltaPct = prevClose ? (delta / prevClose) * 100 : 0;
|
|
358
|
+
const y = yMapping.priceToY(lastPrice);
|
|
359
|
+
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
|
|
324
360
|
ctx.setLineDash([6, 4]);
|
|
325
361
|
ctx.lineWidth = 1;
|
|
326
362
|
ctx.beginPath();
|
|
327
363
|
ctx.moveTo(plotArea.plotX0, y);
|
|
328
364
|
ctx.lineTo(plotArea.plotX1, y);
|
|
329
365
|
ctx.stroke();
|
|
330
|
-
ctx.
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
ctx.fillStyle = '#facc15';
|
|
336
|
-
ctx.fillRect(plotArea.plotX1 + 4, badgeY - 10, RIGHT_PADDING_PX$1 - 8, 20);
|
|
366
|
+
ctx.setLineDash([]);
|
|
367
|
+
ctx.fillStyle = delta >= 0 ? '#16a34a' : '#dc2626';
|
|
368
|
+
const badgeX = plotArea.plotX1 + 4;
|
|
369
|
+
const badgeY = clamp$1(y - 12, plotArea.pricePaneY0 + 4, plotArea.pricePaneY0 + plotArea.pricePaneHeight - 24);
|
|
370
|
+
ctx.fillRect(badgeX, badgeY, PRICE_SCALE_WIDTH - 8, 24);
|
|
337
371
|
ctx.fillStyle = '#05070d';
|
|
338
|
-
ctx.font = '
|
|
372
|
+
ctx.font = '12px Inter, sans-serif';
|
|
339
373
|
ctx.textAlign = 'center';
|
|
340
374
|
ctx.textBaseline = 'middle';
|
|
341
|
-
ctx.fillText(
|
|
375
|
+
ctx.fillText(lastPrice.toFixed(2), badgeX + (PRICE_SCALE_WIDTH - 8) / 2, badgeY + 10);
|
|
342
376
|
ctx.textAlign = 'left';
|
|
343
|
-
ctx.fillStyle =
|
|
344
|
-
ctx.fillText(`${delta >= 0 ? '+' : ''}${delta.toFixed(2)} (${deltaPct >= 0 ? '+' : ''}${deltaPct.toFixed(2)}%)`,
|
|
377
|
+
ctx.fillStyle = '#f1f5f9';
|
|
378
|
+
ctx.fillText(`${delta >= 0 ? '+' : ''}${delta.toFixed(2)} (${deltaPct >= 0 ? '+' : ''}${deltaPct.toFixed(2)}%)`, badgeX, plotArea.pricePaneY0 - 6);
|
|
345
379
|
}
|
|
346
|
-
ctx.fillStyle = 'rgba(148,163,184,0.12)';
|
|
347
|
-
ctx.font = '600 32px Inter, sans-serif';
|
|
348
|
-
ctx.textAlign = 'left';
|
|
349
|
-
ctx.textBaseline = 'bottom';
|
|
350
|
-
ctx.fillText('viainti chart', plotArea.plotX0 + 12, priceHeight - 12);
|
|
351
380
|
ctx.restore();
|
|
352
|
-
ctx.fillStyle = '#
|
|
381
|
+
ctx.fillStyle = '#94a3b8';
|
|
353
382
|
ctx.font = '11px Inter, sans-serif';
|
|
354
383
|
ctx.textAlign = 'left';
|
|
355
384
|
ctx.textBaseline = 'middle';
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
ctx.fillText(price.toFixed(2), plotArea.plotX0 + 4, y);
|
|
360
|
-
}
|
|
385
|
+
yMapping.yTicks.forEach(tick => {
|
|
386
|
+
ctx.fillText(tick.label, plotArea.plotX0 + 4, tick.y);
|
|
387
|
+
});
|
|
361
388
|
ctx.textAlign = 'center';
|
|
362
389
|
ctx.textBaseline = 'bottom';
|
|
363
|
-
timeTicks.forEach(tick => {
|
|
364
|
-
const
|
|
365
|
-
|
|
390
|
+
timeTicks.forEach((tick, idx) => {
|
|
391
|
+
const prev = timeTicks[idx - 1];
|
|
392
|
+
if (prev && Math.abs(tick.x - prev.x) < 80)
|
|
393
|
+
return;
|
|
394
|
+
const x = clamp$1(tick.x, plotArea.plotX0 + 20, plotArea.plotX1 - 20);
|
|
395
|
+
ctx.fillText(tick.label, x, plotArea.pricePaneY0 + plotArea.pricePaneHeight + 18);
|
|
366
396
|
});
|
|
367
|
-
if (chartState.showVolume &&
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
397
|
+
if (chartState.showVolume && plotArea.volumePaneHeight > 0) {
|
|
398
|
+
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
|
|
399
|
+
ctx.beginPath();
|
|
400
|
+
ctx.moveTo(plotArea.plotX0, plotArea.volumePaneY0 - 6);
|
|
401
|
+
ctx.lineTo(plotArea.plotX1, plotArea.volumePaneY0 - 6);
|
|
402
|
+
ctx.stroke();
|
|
371
403
|
ctx.save();
|
|
372
404
|
ctx.beginPath();
|
|
373
|
-
ctx.rect(plotArea.plotX0,
|
|
405
|
+
ctx.rect(plotArea.plotX0, plotArea.volumePaneY0, plotArea.plotWidth, plotArea.volumePaneHeight);
|
|
374
406
|
ctx.clip();
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const
|
|
378
|
-
const xCenter = xMapping.
|
|
379
|
-
const barWidth = Math.max(2, xMapping.
|
|
380
|
-
const
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
407
|
+
const maxVolume = Math.max(...priceSlice.map(c => c.volume ?? 0), 1);
|
|
408
|
+
priceSlice.forEach((candle, offset) => {
|
|
409
|
+
const index = viewport.startIndex + offset;
|
|
410
|
+
const xCenter = xMapping.candleCenterX(index);
|
|
411
|
+
const barWidth = Math.max(2, xMapping.bodyWidth * 0.6);
|
|
412
|
+
const volume = candle.volume ?? 0;
|
|
413
|
+
const heightRatio = volume / maxVolume;
|
|
414
|
+
const barHeight = heightRatio * (plotArea.volumePaneHeight - 12);
|
|
415
|
+
const y = plotArea.volumePaneY0 + plotArea.volumePaneHeight - barHeight;
|
|
416
|
+
ctx.fillStyle = (candle.close ?? 0) >= (candle.open ?? 0) ? 'rgba(46,204,113,0.35)' : 'rgba(231,76,60,0.35)';
|
|
417
|
+
ctx.fillRect(xCenter - barWidth / 2, y, barWidth, Math.max(barHeight, 2));
|
|
418
|
+
if (highlightIndex === index) {
|
|
419
|
+
ctx.fillStyle = 'rgba(255,255,255,0.12)';
|
|
420
|
+
ctx.fillRect(xCenter - barWidth / 2, y, barWidth, Math.max(barHeight, 2));
|
|
387
421
|
}
|
|
388
422
|
});
|
|
389
423
|
ctx.restore();
|
|
390
424
|
}
|
|
391
|
-
}, [chartState.
|
|
425
|
+
}, [chartState.crosshairIndex, chartState.showGrid, chartState.showVolume, data, plotArea, priceSlice, timeTicks, viewport.endIndex, viewport.startIndex, width, height, xMapping, yMapping]);
|
|
392
426
|
const drawOverlayLayer = useCallback(() => {
|
|
393
427
|
const canvas = overlayCanvasRef.current;
|
|
394
428
|
if (!canvas)
|
|
@@ -399,237 +433,210 @@ const Chart = ({ data, width = 900, height = 480, visibleBars: visibleBarsProp =
|
|
|
399
433
|
const dpr = window.devicePixelRatio || 1;
|
|
400
434
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
401
435
|
ctx.clearRect(0, 0, width, height);
|
|
402
|
-
if (!chartState.crosshairEnabled || chartState.
|
|
436
|
+
if (!chartState.crosshairEnabled || chartState.crosshairIndex == null || chartState.crosshairPrice == null) {
|
|
403
437
|
return;
|
|
404
438
|
}
|
|
405
|
-
const
|
|
406
|
-
const
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
const priceValue = hoveredCandle?.close ?? (priceWindow.max - (chartState.crosshairY / Math.max(priceHeight, 1)) * priceRange);
|
|
410
|
-
ctx.strokeStyle = '#8186a5';
|
|
439
|
+
const index = chartState.crosshairIndex;
|
|
440
|
+
const x = xMapping.candleCenterX(index);
|
|
441
|
+
const priceY = yMapping.priceToY(chartState.crosshairPrice);
|
|
442
|
+
ctx.strokeStyle = CROSSHAIR_COLOR;
|
|
411
443
|
ctx.lineWidth = 1;
|
|
412
444
|
ctx.setLineDash([4, 4]);
|
|
413
445
|
ctx.beginPath();
|
|
414
|
-
ctx.moveTo(
|
|
415
|
-
ctx.lineTo(
|
|
416
|
-
ctx.moveTo(
|
|
417
|
-
ctx.lineTo(
|
|
446
|
+
ctx.moveTo(x, 0);
|
|
447
|
+
ctx.lineTo(x, height);
|
|
448
|
+
ctx.moveTo(plotArea.plotX0, priceY);
|
|
449
|
+
ctx.lineTo(plotArea.plotX1 + PRICE_SCALE_WIDTH, priceY);
|
|
418
450
|
ctx.stroke();
|
|
419
451
|
ctx.setLineDash([]);
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
ctx.font = '10px Inter, sans-serif';
|
|
437
|
-
ctx.textAlign = 'center';
|
|
438
|
-
ctx.textBaseline = 'middle';
|
|
439
|
-
ctx.fillText(formatTickLabel(timeValue), labelX + labelWidth / 2, labelY - 9);
|
|
440
|
-
}
|
|
441
|
-
if (hoveredCandle) {
|
|
442
|
-
const tooltipLines = [
|
|
443
|
-
`O ${hoveredCandle.open?.toFixed(2) ?? '—'}`,
|
|
444
|
-
`H ${hoveredCandle.high?.toFixed(2) ?? '—'}`,
|
|
445
|
-
`L ${hoveredCandle.low?.toFixed(2) ?? '—'}`,
|
|
446
|
-
`C ${hoveredCandle.close?.toFixed(2) ?? '—'}`,
|
|
447
|
-
`V ${(hoveredCandle.volume ?? 0).toLocaleString('en-US', { maximumFractionDigits: 2 })}`
|
|
448
|
-
];
|
|
449
|
-
const boxWidth = 120;
|
|
450
|
-
const boxHeight = tooltipLines.length * 16 + 12;
|
|
451
|
-
const boxX = plotArea.plotX0 + 12;
|
|
452
|
-
const boxY = 12;
|
|
453
|
-
ctx.fillStyle = '#0f172aee';
|
|
454
|
-
ctx.fillRect(boxX, boxY, boxWidth, boxHeight);
|
|
455
|
-
ctx.strokeStyle = '#22d3ee';
|
|
456
|
-
ctx.strokeRect(boxX, boxY, boxWidth, boxHeight);
|
|
457
|
-
ctx.fillStyle = '#f8fafc';
|
|
458
|
-
ctx.font = '11px Inter, sans-serif';
|
|
459
|
-
ctx.textAlign = 'left';
|
|
460
|
-
ctx.textBaseline = 'middle';
|
|
461
|
-
tooltipLines.forEach((line, idx) => {
|
|
462
|
-
ctx.fillText(line, boxX + 8, boxY + 12 + idx * 16);
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
}, [chartState.crosshairEnabled, chartState.crosshairX, chartState.crosshairY, chartState.hoveredIndex, data, height, priceHeight, priceWindow.max, priceWindow.min, plotArea.plotX0, plotArea.plotX1, volumeHeight, width]);
|
|
466
|
-
useEffect(() => {
|
|
467
|
-
if (rafRef.current) {
|
|
468
|
-
cancelAnimationFrame(rafRef.current);
|
|
469
|
-
}
|
|
470
|
-
rafRef.current = requestAnimationFrame(() => {
|
|
471
|
-
drawPriceLayer();
|
|
472
|
-
drawOverlayLayer();
|
|
473
|
-
});
|
|
474
|
-
return () => {
|
|
475
|
-
if (rafRef.current) {
|
|
476
|
-
cancelAnimationFrame(rafRef.current);
|
|
477
|
-
rafRef.current = null;
|
|
478
|
-
}
|
|
479
|
-
};
|
|
480
|
-
}, [drawOverlayLayer, drawPriceLayer]);
|
|
481
|
-
const updateVisibleBars = useCallback((factor) => {
|
|
482
|
-
if (chartState.locked)
|
|
452
|
+
// Price badge
|
|
453
|
+
ctx.fillStyle = '#111927';
|
|
454
|
+
ctx.fillRect(plotArea.plotX1 + 2, priceY - 10, PRICE_SCALE_WIDTH - 4, 20);
|
|
455
|
+
ctx.fillStyle = '#e2e8f0';
|
|
456
|
+
ctx.font = '11px Inter, sans-serif';
|
|
457
|
+
ctx.textAlign = 'center';
|
|
458
|
+
ctx.textBaseline = 'middle';
|
|
459
|
+
ctx.fillText(chartState.crosshairPrice.toFixed(4).replace(/0+$/, '').replace(/\.$/, ''), plotArea.plotX1 + (PRICE_SCALE_WIDTH - 4) / 2, priceY);
|
|
460
|
+
// Time badge
|
|
461
|
+
const tick = formatTimeLabel(getCandleTime(data[index], index));
|
|
462
|
+
ctx.fillStyle = '#111927';
|
|
463
|
+
ctx.fillRect(x - 48, plotArea.pricePaneY0 + plotArea.pricePaneHeight + 4, 96, 18);
|
|
464
|
+
ctx.fillStyle = '#e2e8f0';
|
|
465
|
+
ctx.fillText(tick, x, plotArea.pricePaneY0 + plotArea.pricePaneHeight + 13);
|
|
466
|
+
const candle = data[index];
|
|
467
|
+
if (!candle)
|
|
483
468
|
return;
|
|
469
|
+
const tooltipWidth = 170;
|
|
470
|
+
const tooltipHeight = 90;
|
|
471
|
+
const tooltipX = clamp$1(x - tooltipWidth - 16, plotArea.plotX0 + 8, plotArea.plotX1 - tooltipWidth - 8);
|
|
472
|
+
const tooltipY = plotArea.pricePaneY0 + 12;
|
|
473
|
+
ctx.fillStyle = 'rgba(15,15,20,0.9)';
|
|
474
|
+
drawRoundedRect$1(ctx, tooltipX, tooltipY, tooltipWidth, tooltipHeight, 8);
|
|
475
|
+
ctx.fill();
|
|
476
|
+
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
|
|
477
|
+
ctx.stroke();
|
|
478
|
+
ctx.fillStyle = '#f8fafc';
|
|
479
|
+
ctx.textAlign = 'left';
|
|
480
|
+
ctx.textBaseline = 'middle';
|
|
481
|
+
ctx.font = '11px Inter, sans-serif';
|
|
482
|
+
const lines = [
|
|
483
|
+
`O ${formatPrice(candle.open, 0.01)}`,
|
|
484
|
+
`H ${formatPrice(candle.high, 0.01)}`,
|
|
485
|
+
`L ${formatPrice(candle.low, 0.01)}`,
|
|
486
|
+
`C ${formatPrice(candle.close, 0.01)}`,
|
|
487
|
+
`V ${formatVolume(candle.volume ?? 0)}`
|
|
488
|
+
];
|
|
489
|
+
lines.forEach((line, idx) => {
|
|
490
|
+
ctx.fillText(line, tooltipX + 10, tooltipY + 16 + idx * 16);
|
|
491
|
+
});
|
|
492
|
+
}, [chartState.crosshairEnabled, chartState.crosshairIndex, chartState.crosshairPrice, data, height, plotArea, width, xMapping, yMapping]);
|
|
493
|
+
useEffect(() => {
|
|
494
|
+
drawBaseLayer();
|
|
495
|
+
}, [drawBaseLayer]);
|
|
496
|
+
useEffect(() => {
|
|
497
|
+
drawOverlayLayer();
|
|
498
|
+
}, [drawOverlayLayer]);
|
|
499
|
+
const setVisibleBars = useCallback((factor) => {
|
|
484
500
|
setChartState(prev => {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
const
|
|
501
|
+
if (prev.locked)
|
|
502
|
+
return prev;
|
|
503
|
+
const minBars = MIN_VISIBLE_BARS;
|
|
504
|
+
const maxBars = Math.max(minBars, Math.min(MAX_VISIBLE_BARS, data.length || MIN_VISIBLE_BARS));
|
|
488
505
|
const nextVisible = clamp$1(Math.round(prev.visibleBars * factor), minBars, maxBars);
|
|
489
506
|
if (nextVisible === prev.visibleBars)
|
|
490
507
|
return prev;
|
|
491
|
-
const
|
|
492
|
-
const
|
|
493
|
-
return { ...prev, visibleBars: nextVisible, panOffsetBars:
|
|
494
|
-
});
|
|
495
|
-
}, [chartState.locked, data.length]);
|
|
496
|
-
const updatePan = useCallback((deltaBars) => {
|
|
497
|
-
setChartState(prev => {
|
|
498
|
-
const bounds = getPanBounds(prev.visibleBars, data.length);
|
|
499
|
-
const nextPan = clamp$1(prev.panOffsetBars + deltaBars, bounds.min, bounds.max);
|
|
500
|
-
if (nextPan === prev.panOffsetBars)
|
|
501
|
-
return prev;
|
|
502
|
-
return { ...prev, panOffsetBars: nextPan };
|
|
508
|
+
const minPan = -Math.max(data.length - nextVisible, 0);
|
|
509
|
+
const nextPanOffset = clamp$1(prev.panOffsetBars, minPan, 0);
|
|
510
|
+
return { ...prev, visibleBars: nextVisible, panOffsetBars: nextPanOffset };
|
|
503
511
|
});
|
|
504
512
|
}, [data.length]);
|
|
505
|
-
const
|
|
506
|
-
if (chartState.locked)
|
|
507
|
-
return;
|
|
508
|
-
event.preventDefault();
|
|
509
|
-
const factor = event.deltaY > 0 ? 1.18 : 0.85;
|
|
510
|
-
updateVisibleBars(factor);
|
|
511
|
-
}, [chartState.locked, updateVisibleBars]);
|
|
512
|
-
const updateCrosshairPosition = useCallback((clientX, clientY) => {
|
|
513
|
-
if (chartState.locked || !chartState.crosshairEnabled || !visibleData.length)
|
|
513
|
+
const updateCrosshair = useCallback((clientX, clientY) => {
|
|
514
|
+
if (chartState.locked || !chartState.crosshairEnabled || !data.length)
|
|
514
515
|
return;
|
|
515
516
|
const rect = chartAreaRef.current?.getBoundingClientRect();
|
|
516
517
|
if (!rect)
|
|
517
518
|
return;
|
|
518
519
|
const localX = clientX - rect.left;
|
|
519
|
-
|
|
520
|
-
const
|
|
521
|
-
const
|
|
522
|
-
const
|
|
523
|
-
const
|
|
524
|
-
const
|
|
525
|
-
const
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
520
|
+
const clampedX = clamp$1(localX, plotArea.plotX0, plotArea.plotX1);
|
|
521
|
+
const ratio = (clampedX - (plotArea.plotX0 + xMapping.offsetX)) / (xMapping.barSpacing || 1);
|
|
522
|
+
const index = clamp$1(Math.round(ratio) + viewport.startIndex, viewport.startIndex, viewport.endIndex);
|
|
523
|
+
const localY = clientY - rect.top;
|
|
524
|
+
const clampedY = clamp$1(localY, plotArea.pricePaneY0, plotArea.pricePaneY0 + plotArea.pricePaneHeight);
|
|
525
|
+
const priceRange = yMapping.yMax - yMapping.yMin || 1;
|
|
526
|
+
const price = yMapping.yMax - ((clampedY - plotArea.pricePaneY0) / Math.max(plotArea.pricePaneHeight, 1)) * priceRange;
|
|
527
|
+
const safePrice = Number.isFinite(price) ? price : yMapping.yMin;
|
|
528
|
+
setChartState(prev => ({ ...prev, crosshairIndex: index, crosshairPrice: safePrice }));
|
|
529
|
+
}, [chartState.crosshairEnabled, chartState.locked, data.length, plotArea, viewport.endIndex, viewport.startIndex, xMapping, yMapping]);
|
|
530
|
+
const clampPan = useCallback((pan, visible) => {
|
|
531
|
+
const minPan = -Math.max(data.length - visible, 0);
|
|
532
|
+
return clamp$1(pan, minPan, 0);
|
|
533
|
+
}, [data.length]);
|
|
534
|
+
const handleWheel = useCallback((event) => {
|
|
535
|
+
if (chartState.locked)
|
|
536
|
+
return;
|
|
537
|
+
event.preventDefault();
|
|
538
|
+
const factor = event.deltaY < 0 ? 0.9 : 1.1;
|
|
539
|
+
setVisibleBars(factor);
|
|
540
|
+
}, [chartState.locked, setVisibleBars]);
|
|
541
|
+
const isDraggingRef = useRef(false);
|
|
542
|
+
const lastXRef = useRef(0);
|
|
531
543
|
const handlePointerDown = useCallback((event) => {
|
|
532
544
|
if (event.button !== 0)
|
|
533
545
|
return;
|
|
534
|
-
event.preventDefault();
|
|
535
546
|
chartAreaRef.current?.setPointerCapture(event.pointerId);
|
|
536
547
|
isDraggingRef.current = true;
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
}, [
|
|
548
|
+
lastXRef.current = event.clientX;
|
|
549
|
+
updateCrosshair(event.clientX, event.clientY);
|
|
550
|
+
}, [updateCrosshair]);
|
|
540
551
|
const handlePointerMove = useCallback((event) => {
|
|
541
552
|
if (isDraggingRef.current && !chartState.locked) {
|
|
542
|
-
const deltaX = event.clientX -
|
|
543
|
-
|
|
544
|
-
const deltaBars = -(deltaX / (
|
|
545
|
-
|
|
553
|
+
const deltaX = event.clientX - lastXRef.current;
|
|
554
|
+
lastXRef.current = event.clientX;
|
|
555
|
+
const deltaBars = -(deltaX / (xMapping.barSpacing || 1));
|
|
556
|
+
setChartState(prev => ({ ...prev, panOffsetBars: clampPan(prev.panOffsetBars + deltaBars, prev.visibleBars) }));
|
|
546
557
|
return;
|
|
547
558
|
}
|
|
548
|
-
|
|
549
|
-
}, [chartState.locked,
|
|
559
|
+
updateCrosshair(event.clientX, event.clientY);
|
|
560
|
+
}, [chartState.locked, clampPan, updateCrosshair, xMapping.barSpacing]);
|
|
550
561
|
const handlePointerUp = useCallback((event) => {
|
|
551
562
|
chartAreaRef.current?.releasePointerCapture(event.pointerId);
|
|
552
563
|
isDraggingRef.current = false;
|
|
553
564
|
}, []);
|
|
554
565
|
const handlePointerLeave = useCallback(() => {
|
|
555
566
|
isDraggingRef.current = false;
|
|
567
|
+
setChartState(prev => {
|
|
568
|
+
if (!prev.crosshairEnabled)
|
|
569
|
+
return prev;
|
|
570
|
+
return { ...prev, crosshairIndex: null, crosshairPrice: null };
|
|
571
|
+
});
|
|
572
|
+
}, [setChartState]);
|
|
573
|
+
const handleButtonZoom = (direction) => {
|
|
574
|
+
setVisibleBars(direction === 'in' ? 0.9 : 1.1);
|
|
575
|
+
};
|
|
576
|
+
const handleReset = () => {
|
|
556
577
|
setChartState(prev => ({
|
|
557
578
|
...prev,
|
|
558
|
-
|
|
559
|
-
crosshairY: chartState.crosshairEnabled ? null : prev.crosshairY,
|
|
560
|
-
hoveredIndex: null
|
|
561
|
-
}));
|
|
562
|
-
}, [chartState.crosshairEnabled]);
|
|
563
|
-
const handleReset = useCallback(() => {
|
|
564
|
-
setChartState(prev => ({
|
|
565
|
-
...prev,
|
|
579
|
+
visibleBars: Math.max(defaultVisibleBars, MIN_VISIBLE_BARS),
|
|
566
580
|
panOffsetBars: 0,
|
|
567
|
-
|
|
581
|
+
locked: false,
|
|
568
582
|
crosshairEnabled: true,
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
hoveredIndex: null,
|
|
583
|
+
crosshairIndex: null,
|
|
584
|
+
crosshairPrice: null,
|
|
572
585
|
autoScaleY: true,
|
|
573
|
-
showCandles: true,
|
|
574
|
-
showVolume: true,
|
|
575
586
|
showGrid: true,
|
|
576
|
-
|
|
587
|
+
showVolume: true
|
|
577
588
|
}));
|
|
578
|
-
}
|
|
579
|
-
const handleSnapshot =
|
|
580
|
-
const
|
|
581
|
-
if (!
|
|
589
|
+
};
|
|
590
|
+
const handleSnapshot = () => {
|
|
591
|
+
const base = priceCanvasRef.current;
|
|
592
|
+
if (!base)
|
|
582
593
|
return;
|
|
583
|
-
const
|
|
594
|
+
const overlay = overlayCanvasRef.current;
|
|
584
595
|
const exportCanvas = document.createElement('canvas');
|
|
585
|
-
exportCanvas.width =
|
|
586
|
-
exportCanvas.height =
|
|
596
|
+
exportCanvas.width = base.width;
|
|
597
|
+
exportCanvas.height = base.height;
|
|
587
598
|
const ctx = exportCanvas.getContext('2d');
|
|
588
599
|
if (!ctx)
|
|
589
600
|
return;
|
|
590
|
-
ctx.drawImage(
|
|
591
|
-
if (
|
|
592
|
-
ctx.drawImage(
|
|
593
|
-
}
|
|
601
|
+
ctx.drawImage(base, 0, 0);
|
|
602
|
+
if (overlay)
|
|
603
|
+
ctx.drawImage(overlay, 0, 0);
|
|
594
604
|
const link = document.createElement('a');
|
|
595
|
-
link.download = 'chart
|
|
605
|
+
link.download = 'viainti-chart.png';
|
|
596
606
|
link.href = exportCanvas.toDataURL('image/png');
|
|
597
607
|
link.click();
|
|
598
|
-
}
|
|
599
|
-
const derivedVisibleBars = Math.max(1, Math.min(chartState.visibleBars, data.length || chartState.visibleBars));
|
|
608
|
+
};
|
|
600
609
|
return (React.createElement("div", { style: { width } },
|
|
601
610
|
React.createElement("div", { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '12px' } },
|
|
602
|
-
React.createElement("button", { onClick: () =>
|
|
603
|
-
React.createElement("button", { onClick: () =>
|
|
611
|
+
React.createElement("button", { onClick: () => handleButtonZoom('in'), disabled: chartState.locked, style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: chartState.locked ? '#1f2937' : '#0f172a', color: '#f8fafc' } }, "Zoom +"),
|
|
612
|
+
React.createElement("button", { onClick: () => handleButtonZoom('out'), disabled: chartState.locked, style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: chartState.locked ? '#1f2937' : '#0f172a', color: '#f8fafc' } }, "Zoom -"),
|
|
604
613
|
React.createElement("button", { onClick: () => setChartState(prev => ({ ...prev, locked: !prev.locked })), style: { padding: '6px 12px', borderRadius: '999px', border: chartState.locked ? '1px solid #fbbf24' : '1px solid #475569', background: chartState.locked ? '#fbbf2411' : '#0f172a', color: '#f8fafc' } }, chartState.locked ? 'Unlock' : 'Lock'),
|
|
605
|
-
React.createElement("button", { onClick: () => setChartState(prev => ({
|
|
606
|
-
...prev,
|
|
607
|
-
crosshairEnabled: !prev.crosshairEnabled,
|
|
608
|
-
crosshairX: prev.crosshairEnabled ? null : prev.crosshairX,
|
|
609
|
-
crosshairY: prev.crosshairEnabled ? null : prev.crosshairY,
|
|
610
|
-
hoveredIndex: prev.crosshairEnabled ? null : prev.hoveredIndex
|
|
611
|
-
})), style: { padding: '6px 12px', borderRadius: '999px', border: chartState.crosshairEnabled ? '1px solid #22d3ee' : '1px solid #475569', background: chartState.crosshairEnabled ? '#22d3ee11' : '#0f172a', color: '#f8fafc' } }, chartState.crosshairEnabled ? 'Crosshair on' : 'Crosshair off'),
|
|
614
|
+
React.createElement("button", { onClick: () => setChartState(prev => ({ ...prev, crosshairEnabled: !prev.crosshairEnabled, crosshairIndex: prev.crosshairEnabled ? null : prev.crosshairIndex, crosshairPrice: prev.crosshairEnabled ? null : prev.crosshairPrice })), style: { padding: '6px 12px', borderRadius: '999px', border: chartState.crosshairEnabled ? '1px solid #22d3ee' : '1px solid #475569', background: chartState.crosshairEnabled ? '#22d3ee11' : '#0f172a', color: '#f8fafc' } }, chartState.crosshairEnabled ? 'Crosshair on' : 'Crosshair off'),
|
|
612
615
|
React.createElement("button", { onClick: handleReset, style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: '#0f172a', color: '#f8fafc' } }, "Reset"),
|
|
613
616
|
React.createElement("button", { onClick: handleSnapshot, style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: '#0f172a', color: '#f8fafc' } }, "Camera"),
|
|
614
617
|
React.createElement("button", { onClick: () => setShowMenu(prev => !prev), style: { padding: '6px 12px', borderRadius: '999px', border: '1px solid #475569', background: showMenu ? '#22c55e11' : '#0f172a', color: '#f8fafc' } }, "Menu")),
|
|
615
|
-
showMenu && (React.createElement("div", { style: { marginBottom: '12px', padding: '12px', borderRadius: '16px', border: '1px solid #1f2937', background: '#0b1120', color: '#e2e8f0', display: 'flex', flexWrap: 'wrap', gap: '
|
|
616
|
-
{
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
{
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
React.createElement("
|
|
623
|
-
|
|
624
|
-
|
|
618
|
+
showMenu && (React.createElement("div", { style: { marginBottom: '12px', padding: '12px', borderRadius: '16px', border: '1px solid #1f2937', background: '#0b1120', color: '#e2e8f0', display: 'flex', flexWrap: 'wrap', gap: '12px', fontSize: '12px' } },
|
|
619
|
+
React.createElement("label", { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
|
|
620
|
+
React.createElement("input", { type: "checkbox", checked: chartState.showGrid, onChange: () => setChartState(prev => ({ ...prev, showGrid: !prev.showGrid })) }),
|
|
621
|
+
" Grid"),
|
|
622
|
+
React.createElement("label", { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
|
|
623
|
+
React.createElement("input", { type: "checkbox", checked: chartState.showVolume, onChange: () => setChartState(prev => ({ ...prev, showVolume: !prev.showVolume })) }),
|
|
624
|
+
" Volumen"),
|
|
625
|
+
React.createElement("label", { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
|
|
626
|
+
React.createElement("input", { type: "checkbox", checked: chartState.autoScaleY, onChange: () => setChartState(prev => ({ ...prev, autoScaleY: !prev.autoScaleY })) }),
|
|
627
|
+
" AutoScale Y"))),
|
|
628
|
+
React.createElement("div", { ref: chartAreaRef, style: { position: 'relative', width, height, borderRadius: '24px', border: '1px solid #1f2937', overflow: 'hidden', background: '#05070d', touchAction: 'none' }, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerLeave: handlePointerLeave, onWheel: handleWheel },
|
|
625
629
|
React.createElement("canvas", { ref: priceCanvasRef, style: { position: 'absolute', inset: 0 } }),
|
|
626
630
|
React.createElement("canvas", { ref: overlayCanvasRef, style: { position: 'absolute', inset: 0, pointerEvents: 'none' } })),
|
|
627
|
-
React.createElement("p", { style: { marginTop: '
|
|
628
|
-
"
|
|
629
|
-
|
|
630
|
-
"
|
|
631
|
+
React.createElement("p", { style: { marginTop: '10px', fontSize: '12px', color: '#94a3b8' } },
|
|
632
|
+
"Bars: ",
|
|
633
|
+
chartState.visibleBars,
|
|
634
|
+
" \u00B7 Pan offset: ",
|
|
631
635
|
chartState.panOffsetBars.toFixed(2),
|
|
632
|
-
"
|
|
636
|
+
" \u00B7 Viewport ",
|
|
637
|
+
viewport.startIndex,
|
|
638
|
+
" \u2192 ",
|
|
639
|
+
viewport.endIndex)));
|
|
633
640
|
};
|
|
634
641
|
|
|
635
642
|
var DefaultContext = {
|