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