hyperprop-charting-library 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/hyperprop-charting-library.cjs +1475 -0
- package/dist/hyperprop-charting-library.d.ts +170 -0
- package/dist/hyperprop-charting-library.js +1450 -0
- package/dist/index.cjs +1475 -0
- package/dist/index.d.cts +170 -0
- package/dist/index.d.ts +170 -0
- package/dist/index.js +1450 -0
- package/package.json +27 -0
|
@@ -0,0 +1,1475 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
createChart: () => createChart
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(index_exports);
|
|
26
|
+
var DEFAULT_GRID_OPTIONS = {
|
|
27
|
+
color: "#e2e8f0",
|
|
28
|
+
opacity: 0.9,
|
|
29
|
+
horizontalLines: true,
|
|
30
|
+
verticalLines: true,
|
|
31
|
+
horizontalTickCount: 5
|
|
32
|
+
};
|
|
33
|
+
var DEFAULT_AXIS_OPTIONS = {
|
|
34
|
+
lineColor: "#94a3b8",
|
|
35
|
+
textColor: "#94a3b8",
|
|
36
|
+
fontSize: 12,
|
|
37
|
+
lineWidth: 1
|
|
38
|
+
};
|
|
39
|
+
var DEFAULT_CROSSHAIR_OPTIONS = {
|
|
40
|
+
visible: true,
|
|
41
|
+
color: "#94a3b8",
|
|
42
|
+
width: 1,
|
|
43
|
+
style: "dotted",
|
|
44
|
+
showHorizontal: true,
|
|
45
|
+
showVertical: true
|
|
46
|
+
};
|
|
47
|
+
var DEFAULT_WATERMARK_OPTIONS = {
|
|
48
|
+
visible: false,
|
|
49
|
+
text: "",
|
|
50
|
+
color: "#94a3b8",
|
|
51
|
+
opacity: 0.2,
|
|
52
|
+
fontSize: 92,
|
|
53
|
+
fontWeight: 700,
|
|
54
|
+
thickness: 0
|
|
55
|
+
};
|
|
56
|
+
var DEFAULT_PRICE_LINE_OPTIONS = {
|
|
57
|
+
visible: true,
|
|
58
|
+
style: "solid",
|
|
59
|
+
thickness: 1,
|
|
60
|
+
color: "#f59e0b",
|
|
61
|
+
labelBackgroundColor: "#f59e0b",
|
|
62
|
+
labelTextColor: "#0f172a",
|
|
63
|
+
labelBorderRadius: 3,
|
|
64
|
+
showLabel: true
|
|
65
|
+
};
|
|
66
|
+
var DEFAULT_ORDER_LINE_OPTIONS = {
|
|
67
|
+
visible: true,
|
|
68
|
+
behavior: "static",
|
|
69
|
+
style: "solid",
|
|
70
|
+
thickness: 1,
|
|
71
|
+
color: "#f59e0b",
|
|
72
|
+
labelBackgroundColor: "#f59e0b",
|
|
73
|
+
labelTextColor: "#0f172a",
|
|
74
|
+
labelBorderRadius: 3,
|
|
75
|
+
showCloseButton: true,
|
|
76
|
+
widgetPosition: "left",
|
|
77
|
+
draggable: false,
|
|
78
|
+
actionButtonAction: "execute",
|
|
79
|
+
actionButtonTextColor: "#dbeafe",
|
|
80
|
+
actionButtonBackgroundColor: "#2563eb",
|
|
81
|
+
actionButtonBorderRadius: 2,
|
|
82
|
+
actionButtonMinWidth: 34,
|
|
83
|
+
actionButtonPaddingX: 0,
|
|
84
|
+
actionButtonFullHeight: true,
|
|
85
|
+
actionButtonFontWeight: 500,
|
|
86
|
+
actionButtonBorderColor: "#2563eb",
|
|
87
|
+
actionButtonBorderStyle: "solid",
|
|
88
|
+
actionButtons: [],
|
|
89
|
+
connectorToPrice: Number.NaN,
|
|
90
|
+
connectorColor: "#2563eb",
|
|
91
|
+
connectorStyle: "dotted",
|
|
92
|
+
connectorThickness: 1,
|
|
93
|
+
connectorAnchorPaddingRight: 10,
|
|
94
|
+
fillToPrice: Number.NaN,
|
|
95
|
+
fillColor: "rgba(37,99,235,0.18)"
|
|
96
|
+
};
|
|
97
|
+
var DEFAULT_OPTIONS = {
|
|
98
|
+
width: 720,
|
|
99
|
+
height: 360,
|
|
100
|
+
backgroundColor: "#ffffff",
|
|
101
|
+
axisColor: "#94a3b8",
|
|
102
|
+
axis: DEFAULT_AXIS_OPTIONS,
|
|
103
|
+
upColor: "#16a34a",
|
|
104
|
+
downColor: "#dc2626",
|
|
105
|
+
gridColor: "#e2e8f0",
|
|
106
|
+
fontFamily: "Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif",
|
|
107
|
+
candleBodyWidthRatio: 0.7,
|
|
108
|
+
candleMinWidth: 0.5,
|
|
109
|
+
candleWickWidth: 1,
|
|
110
|
+
autoScaleSmoothing: 0.16,
|
|
111
|
+
autoScaleIgnoreLatestCandle: true,
|
|
112
|
+
doubleClickEnabled: true,
|
|
113
|
+
doubleClickAction: "reset",
|
|
114
|
+
crosshair: DEFAULT_CROSSHAIR_OPTIONS,
|
|
115
|
+
grid: DEFAULT_GRID_OPTIONS,
|
|
116
|
+
watermark: DEFAULT_WATERMARK_OPTIONS,
|
|
117
|
+
priceLines: [],
|
|
118
|
+
orderLines: [],
|
|
119
|
+
tickerLine: {
|
|
120
|
+
visible: true,
|
|
121
|
+
style: "dashed",
|
|
122
|
+
thickness: 1,
|
|
123
|
+
color: "#22c55e",
|
|
124
|
+
labelBackgroundColor: "#22c55e",
|
|
125
|
+
labelTextColor: "#0b1220",
|
|
126
|
+
labelBorderRadius: 6
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
function createChart(element, options = {}) {
|
|
130
|
+
const mergedOptions = {
|
|
131
|
+
...DEFAULT_OPTIONS,
|
|
132
|
+
...options,
|
|
133
|
+
axis: {
|
|
134
|
+
...DEFAULT_AXIS_OPTIONS,
|
|
135
|
+
...options.axis ?? {},
|
|
136
|
+
...options.axisColor ? { lineColor: options.axisColor, textColor: options.axisColor } : {}
|
|
137
|
+
},
|
|
138
|
+
crosshair: {
|
|
139
|
+
...DEFAULT_CROSSHAIR_OPTIONS,
|
|
140
|
+
...options.crosshair ?? {}
|
|
141
|
+
},
|
|
142
|
+
grid: {
|
|
143
|
+
...DEFAULT_GRID_OPTIONS,
|
|
144
|
+
...options.grid
|
|
145
|
+
},
|
|
146
|
+
watermark: {
|
|
147
|
+
...DEFAULT_WATERMARK_OPTIONS,
|
|
148
|
+
...options.watermark
|
|
149
|
+
},
|
|
150
|
+
tickerLine: {
|
|
151
|
+
...DEFAULT_OPTIONS.tickerLine,
|
|
152
|
+
...options.tickerLine
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
let width = mergedOptions.width;
|
|
156
|
+
let height = mergedOptions.height;
|
|
157
|
+
let data = [];
|
|
158
|
+
let priceLines = (options.priceLines ?? []).map((line, index) => ({
|
|
159
|
+
...line,
|
|
160
|
+
id: line.id ?? `line-${index + 1}`
|
|
161
|
+
}));
|
|
162
|
+
let orderLines = (options.orderLines ?? []).map((line, index) => ({
|
|
163
|
+
...line,
|
|
164
|
+
id: line.id ?? `order-${index + 1}`
|
|
165
|
+
}));
|
|
166
|
+
let orderActionHandler = null;
|
|
167
|
+
let chartClickHandler = null;
|
|
168
|
+
let orderActionRegions = [];
|
|
169
|
+
let orderDragRegions = [];
|
|
170
|
+
let generatedPriceLineId = 1;
|
|
171
|
+
let generatedOrderLineId = 1;
|
|
172
|
+
const orderWidgetWidthById = /* @__PURE__ */ new Map();
|
|
173
|
+
const orderPriceTagWidthById = /* @__PURE__ */ new Map();
|
|
174
|
+
let xCenter = 0;
|
|
175
|
+
let xSpan = 60;
|
|
176
|
+
let yMinOverride = null;
|
|
177
|
+
let yMaxOverride = null;
|
|
178
|
+
let autoYMin = null;
|
|
179
|
+
let autoYMax = null;
|
|
180
|
+
let drawState = null;
|
|
181
|
+
let orderDragState = null;
|
|
182
|
+
let actionDragState = null;
|
|
183
|
+
let pointerDownInfo = null;
|
|
184
|
+
let crosshairPoint = null;
|
|
185
|
+
let doubleClickEnabled = mergedOptions.doubleClickEnabled;
|
|
186
|
+
let doubleClickAction = mergedOptions.doubleClickAction;
|
|
187
|
+
const canvas = document.createElement("canvas");
|
|
188
|
+
const ctx = canvas.getContext("2d");
|
|
189
|
+
if (!ctx) {
|
|
190
|
+
throw new Error("Could not create canvas context.");
|
|
191
|
+
}
|
|
192
|
+
canvas.style.display = "block";
|
|
193
|
+
canvas.style.touchAction = "none";
|
|
194
|
+
element.innerHTML = "";
|
|
195
|
+
element.appendChild(canvas);
|
|
196
|
+
const margin = { top: 16, right: 72, bottom: 34, left: 12 };
|
|
197
|
+
const maxPanBars = 1e6;
|
|
198
|
+
const rightEdgePaddingBars = 2;
|
|
199
|
+
const getPixelRatio = () => {
|
|
200
|
+
if (typeof window === "undefined") {
|
|
201
|
+
return 1;
|
|
202
|
+
}
|
|
203
|
+
return Math.max(1, window.devicePixelRatio || 1);
|
|
204
|
+
};
|
|
205
|
+
const crisp = (value) => Math.round(value) + 0.5;
|
|
206
|
+
const clamp = (value, min, max) => {
|
|
207
|
+
return Math.min(max, Math.max(min, value));
|
|
208
|
+
};
|
|
209
|
+
const clampXViewport = () => {
|
|
210
|
+
const count = data.length;
|
|
211
|
+
if (count === 0) {
|
|
212
|
+
xCenter = 0;
|
|
213
|
+
xSpan = 60;
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const minSpan = 5;
|
|
217
|
+
const maxSpan = Math.max(minSpan, count + maxPanBars * 2);
|
|
218
|
+
xSpan = clamp(xSpan, minSpan, maxSpan);
|
|
219
|
+
xCenter = clamp(xCenter, -maxPanBars, count + maxPanBars);
|
|
220
|
+
};
|
|
221
|
+
const fitXViewport = () => {
|
|
222
|
+
const count = data.length;
|
|
223
|
+
if (count === 0) {
|
|
224
|
+
xCenter = 0;
|
|
225
|
+
xSpan = 60;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
xSpan = Math.min(60, count);
|
|
229
|
+
xCenter = count - xSpan / 2;
|
|
230
|
+
clampXViewport();
|
|
231
|
+
};
|
|
232
|
+
const getYBounds = () => {
|
|
233
|
+
const allDataMin = Math.min(...data.map((point) => point.l));
|
|
234
|
+
const allDataMax = Math.max(...data.map((point) => point.h));
|
|
235
|
+
const baseRange = allDataMax - allDataMin || 1;
|
|
236
|
+
const minRange = Math.max(baseRange * 3e-3, 1e-4);
|
|
237
|
+
const hardMin = allDataMin - baseRange * 3;
|
|
238
|
+
const hardMax = allDataMax + baseRange * 3;
|
|
239
|
+
return {
|
|
240
|
+
allDataMin,
|
|
241
|
+
allDataMax,
|
|
242
|
+
baseRange,
|
|
243
|
+
minRange,
|
|
244
|
+
hardMin,
|
|
245
|
+
hardMax,
|
|
246
|
+
hardSpan: Math.max(minRange, hardMax - hardMin)
|
|
247
|
+
};
|
|
248
|
+
};
|
|
249
|
+
const clampYRange = (minValue, maxValue) => {
|
|
250
|
+
const bounds = getYBounds();
|
|
251
|
+
const requestedRange = maxValue - minValue || 1;
|
|
252
|
+
const range = clamp(requestedRange, bounds.minRange, bounds.hardSpan);
|
|
253
|
+
const center = (minValue + maxValue) / 2;
|
|
254
|
+
let nextMin = center - range / 2;
|
|
255
|
+
let nextMax = center + range / 2;
|
|
256
|
+
if (nextMin < bounds.hardMin) {
|
|
257
|
+
const shift = bounds.hardMin - nextMin;
|
|
258
|
+
nextMin += shift;
|
|
259
|
+
nextMax += shift;
|
|
260
|
+
}
|
|
261
|
+
if (nextMax > bounds.hardMax) {
|
|
262
|
+
const shift = nextMax - bounds.hardMax;
|
|
263
|
+
nextMin -= shift;
|
|
264
|
+
nextMax -= shift;
|
|
265
|
+
}
|
|
266
|
+
return { min: nextMin, max: nextMax };
|
|
267
|
+
};
|
|
268
|
+
const formatPrice = (price) => {
|
|
269
|
+
if (price >= 1e3) {
|
|
270
|
+
return price.toFixed(0);
|
|
271
|
+
}
|
|
272
|
+
if (price >= 100) {
|
|
273
|
+
return price.toFixed(1);
|
|
274
|
+
}
|
|
275
|
+
return price.toFixed(2);
|
|
276
|
+
};
|
|
277
|
+
const parseData = (nextData) => {
|
|
278
|
+
return nextData.map((point) => ({
|
|
279
|
+
time: new Date(point.t),
|
|
280
|
+
o: point.o,
|
|
281
|
+
h: point.h,
|
|
282
|
+
l: point.l,
|
|
283
|
+
c: point.c,
|
|
284
|
+
...point.v === void 0 ? {} : { v: point.v }
|
|
285
|
+
})).filter((point) => Number.isFinite(point.time.getTime())).sort((a, b) => a.time.getTime() - b.time.getTime());
|
|
286
|
+
};
|
|
287
|
+
const getTimeStepMs = () => {
|
|
288
|
+
if (data.length < 2) {
|
|
289
|
+
return 24 * 60 * 60 * 1e3;
|
|
290
|
+
}
|
|
291
|
+
const deltas = [];
|
|
292
|
+
for (let index = 1; index < Math.min(data.length, 40); index += 1) {
|
|
293
|
+
const previous = data[index - 1];
|
|
294
|
+
const current = data[index];
|
|
295
|
+
if (!previous || !current) {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const delta = current.time.getTime() - previous.time.getTime();
|
|
299
|
+
if (delta > 0) {
|
|
300
|
+
deltas.push(delta);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (deltas.length === 0) {
|
|
304
|
+
return 24 * 60 * 60 * 1e3;
|
|
305
|
+
}
|
|
306
|
+
deltas.sort((a, b) => a - b);
|
|
307
|
+
return deltas[Math.floor(deltas.length / 2)] || 24 * 60 * 60 * 1e3;
|
|
308
|
+
};
|
|
309
|
+
const getTimeForIndex = (index) => {
|
|
310
|
+
if (data.length === 0) {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
const direct = data[index];
|
|
314
|
+
if (direct) {
|
|
315
|
+
return direct.time;
|
|
316
|
+
}
|
|
317
|
+
const stepMs = getTimeStepMs();
|
|
318
|
+
const first = data[0];
|
|
319
|
+
const last = data[data.length - 1];
|
|
320
|
+
if (!first || !last) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
if (index < 0) {
|
|
324
|
+
return new Date(first.time.getTime() + index * stepMs);
|
|
325
|
+
}
|
|
326
|
+
const extra = index - (data.length - 1);
|
|
327
|
+
return new Date(last.time.getTime() + extra * stepMs);
|
|
328
|
+
};
|
|
329
|
+
const drawText = (text, x, y, align = "left", baseline = "alphabetic", color = mergedOptions.axis?.textColor ?? mergedOptions.axisColor) => {
|
|
330
|
+
ctx.fillStyle = color;
|
|
331
|
+
ctx.textAlign = align;
|
|
332
|
+
ctx.textBaseline = baseline;
|
|
333
|
+
ctx.fillText(text, x, y);
|
|
334
|
+
};
|
|
335
|
+
const drawPriceLine = (line, yFromPrice, chartLeft, chartTop, chartRight, chartBottom) => {
|
|
336
|
+
const mergedLine = { ...DEFAULT_PRICE_LINE_OPTIONS, ...line };
|
|
337
|
+
if (!mergedLine.visible || !Number.isFinite(mergedLine.price)) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const lineY = clamp(yFromPrice(mergedLine.price), chartTop + 1, chartBottom - 1);
|
|
341
|
+
const color = mergedLine.color;
|
|
342
|
+
ctx.save();
|
|
343
|
+
ctx.strokeStyle = color;
|
|
344
|
+
ctx.lineWidth = Math.max(1, mergedLine.thickness);
|
|
345
|
+
if (mergedLine.style === "dotted") {
|
|
346
|
+
ctx.setLineDash([2, 4]);
|
|
347
|
+
} else if (mergedLine.style === "dashed") {
|
|
348
|
+
ctx.setLineDash([8, 6]);
|
|
349
|
+
} else {
|
|
350
|
+
ctx.setLineDash([]);
|
|
351
|
+
}
|
|
352
|
+
ctx.beginPath();
|
|
353
|
+
ctx.moveTo(crisp(chartLeft), crisp(lineY));
|
|
354
|
+
ctx.lineTo(crisp(chartRight), crisp(lineY));
|
|
355
|
+
ctx.stroke();
|
|
356
|
+
ctx.restore();
|
|
357
|
+
if (!mergedLine.showLabel) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const labelText = mergedLine.label ?? formatPrice(mergedLine.price);
|
|
361
|
+
const axis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.axis ?? {} };
|
|
362
|
+
ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
|
|
363
|
+
const labelPaddingX = 8;
|
|
364
|
+
const labelHeight = 20;
|
|
365
|
+
const labelWidth = Math.ceil(ctx.measureText(labelText).width) + labelPaddingX * 2;
|
|
366
|
+
const labelX = chartRight + 4;
|
|
367
|
+
const labelY = clamp(lineY - labelHeight / 2, chartTop, chartBottom - labelHeight);
|
|
368
|
+
ctx.fillStyle = mergedLine.labelBackgroundColor;
|
|
369
|
+
fillRoundedRect(
|
|
370
|
+
Math.round(labelX),
|
|
371
|
+
Math.round(labelY),
|
|
372
|
+
labelWidth,
|
|
373
|
+
labelHeight,
|
|
374
|
+
Math.max(0, mergedLine.labelBorderRadius)
|
|
375
|
+
);
|
|
376
|
+
drawText(
|
|
377
|
+
labelText,
|
|
378
|
+
labelX + labelPaddingX,
|
|
379
|
+
labelY + labelHeight / 2,
|
|
380
|
+
"left",
|
|
381
|
+
"middle",
|
|
382
|
+
mergedLine.labelTextColor
|
|
383
|
+
);
|
|
384
|
+
};
|
|
385
|
+
const drawOrderLine = (line, yFromPrice, chartLeft, chartTop, chartRight, chartBottom) => {
|
|
386
|
+
const mergedLine = { ...DEFAULT_ORDER_LINE_OPTIONS, ...line };
|
|
387
|
+
const renderPrice = mergedLine.behavior === "follow" && Number.isFinite(mergedLine.followPrice) ? mergedLine.followPrice : mergedLine.price;
|
|
388
|
+
if (!mergedLine.visible || !Number.isFinite(renderPrice)) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const lineY = clamp(yFromPrice(renderPrice), chartTop + 1, chartBottom - 1);
|
|
392
|
+
const color = mergedLine.color ?? (mergedLine.side === "buy" ? mergedOptions.upColor : mergedLine.side === "sell" ? mergedOptions.downColor : "#f59e0b");
|
|
393
|
+
if (Number.isFinite(mergedLine.fillToPrice)) {
|
|
394
|
+
const fillY = clamp(yFromPrice(mergedLine.fillToPrice), chartTop + 1, chartBottom - 1);
|
|
395
|
+
const topY = Math.min(lineY, fillY);
|
|
396
|
+
const heightY = Math.max(1, Math.abs(lineY - fillY));
|
|
397
|
+
ctx.save();
|
|
398
|
+
ctx.fillStyle = mergedLine.fillColor ?? "rgba(37,99,235,0.18)";
|
|
399
|
+
ctx.fillRect(Math.round(chartLeft), Math.round(topY), Math.max(1, Math.round(chartRight - chartLeft)), Math.round(heightY));
|
|
400
|
+
ctx.restore();
|
|
401
|
+
}
|
|
402
|
+
ctx.save();
|
|
403
|
+
ctx.strokeStyle = color;
|
|
404
|
+
ctx.lineWidth = Math.max(1, mergedLine.thickness);
|
|
405
|
+
if (mergedLine.style === "dotted") {
|
|
406
|
+
ctx.setLineDash([2, 4]);
|
|
407
|
+
} else if (mergedLine.style === "dashed") {
|
|
408
|
+
ctx.setLineDash([8, 6]);
|
|
409
|
+
} else {
|
|
410
|
+
ctx.setLineDash([]);
|
|
411
|
+
}
|
|
412
|
+
ctx.beginPath();
|
|
413
|
+
ctx.moveTo(crisp(chartLeft), crisp(lineY));
|
|
414
|
+
ctx.lineTo(crisp(chartRight), crisp(lineY));
|
|
415
|
+
ctx.stroke();
|
|
416
|
+
ctx.restore();
|
|
417
|
+
if (Number.isFinite(mergedLine.connectorToPrice)) {
|
|
418
|
+
const connectorY = clamp(yFromPrice(mergedLine.connectorToPrice), chartTop + 1, chartBottom - 1);
|
|
419
|
+
const connectorX = chartRight - Math.max(6, mergedLine.connectorAnchorPaddingRight);
|
|
420
|
+
ctx.save();
|
|
421
|
+
ctx.strokeStyle = mergedLine.connectorColor ?? color;
|
|
422
|
+
ctx.lineWidth = Math.max(1, mergedLine.connectorThickness);
|
|
423
|
+
if (mergedLine.connectorStyle === "dotted") {
|
|
424
|
+
ctx.setLineDash([2, 5]);
|
|
425
|
+
} else if (mergedLine.connectorStyle === "dashed") {
|
|
426
|
+
ctx.setLineDash([6, 5]);
|
|
427
|
+
} else {
|
|
428
|
+
ctx.setLineDash([]);
|
|
429
|
+
}
|
|
430
|
+
ctx.beginPath();
|
|
431
|
+
ctx.moveTo(crisp(connectorX), crisp(lineY));
|
|
432
|
+
ctx.lineTo(crisp(connectorX), crisp(connectorY));
|
|
433
|
+
ctx.stroke();
|
|
434
|
+
ctx.restore();
|
|
435
|
+
}
|
|
436
|
+
const qtyText = mergedLine.qty === void 0 ? "" : String(mergedLine.qty);
|
|
437
|
+
const typeTextMap = {
|
|
438
|
+
market: "Market",
|
|
439
|
+
limit: "Limit",
|
|
440
|
+
stop: "Stop",
|
|
441
|
+
takeProfit: "TP"
|
|
442
|
+
};
|
|
443
|
+
const sideText = mergedLine.side === "buy" ? "Buy" : "Sell";
|
|
444
|
+
const baseText = `${sideText} ${typeTextMap[mergedLine.type]}`;
|
|
445
|
+
const pnlText = mergedLine.pnl === void 0 ? "" : `${mergedLine.pnl >= 0 ? "+" : ""}${mergedLine.pnl.toFixed(2)} USD`;
|
|
446
|
+
const centerText = mergedLine.label ?? (pnlText ? `${baseText} ${pnlText}` : baseText);
|
|
447
|
+
const closeAction = mergedLine.type === "market" ? "close" : "cancel";
|
|
448
|
+
const showCloseButton = mergedLine.showCloseButton;
|
|
449
|
+
const actionButtonText = mergedLine.actionButtonText;
|
|
450
|
+
const axis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.axis ?? {} };
|
|
451
|
+
ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
|
|
452
|
+
const legacyButton = actionButtonText ? [
|
|
453
|
+
{
|
|
454
|
+
text: actionButtonText,
|
|
455
|
+
action: mergedLine.actionButtonAction ?? "execute",
|
|
456
|
+
textColor: mergedLine.actionButtonTextColor,
|
|
457
|
+
backgroundColor: mergedLine.actionButtonBackgroundColor,
|
|
458
|
+
borderColor: mergedLine.actionButtonBorderColor,
|
|
459
|
+
borderStyle: mergedLine.actionButtonBorderStyle,
|
|
460
|
+
borderRadius: mergedLine.actionButtonBorderRadius,
|
|
461
|
+
minWidth: mergedLine.actionButtonMinWidth,
|
|
462
|
+
paddingX: mergedLine.actionButtonPaddingX,
|
|
463
|
+
fullHeight: mergedLine.actionButtonFullHeight,
|
|
464
|
+
fontWeight: mergedLine.actionButtonFontWeight,
|
|
465
|
+
draggable: false
|
|
466
|
+
}
|
|
467
|
+
] : [];
|
|
468
|
+
const actionButtons = [...mergedLine.actionButtons ?? [], ...legacyButton];
|
|
469
|
+
const actionButtonMetrics = actionButtons.map((button) => {
|
|
470
|
+
const paddingX = Math.max(2, button.paddingX ?? mergedLine.actionButtonPaddingX);
|
|
471
|
+
const minWidth = Math.max(16, button.minWidth ?? mergedLine.actionButtonMinWidth);
|
|
472
|
+
const width2 = Math.max(minWidth, Math.ceil(ctx.measureText(button.text).width) + paddingX * 2);
|
|
473
|
+
return { button, width: width2 };
|
|
474
|
+
});
|
|
475
|
+
const actionButtonInnerGap = actionButtonMetrics.length > 1 ? 4 : 0;
|
|
476
|
+
const actionButtonsTotalWidth = actionButtonMetrics.reduce((sum, metric) => sum + metric.width, 0) + Math.max(0, actionButtonMetrics.length - 1) * actionButtonInnerGap;
|
|
477
|
+
const actionButtonsGap = actionButtonMetrics.length > 0 ? 6 : 0;
|
|
478
|
+
const segmentPaddingX = 8;
|
|
479
|
+
const labelHeight = 22;
|
|
480
|
+
const qtyWidth = qtyText ? Math.ceil(ctx.measureText(qtyText).width) + segmentPaddingX * 2 : 0;
|
|
481
|
+
const centerMeasuredWidth = Math.ceil(ctx.measureText(centerText).width) + segmentPaddingX * 2;
|
|
482
|
+
const centerWidth = mergedLine.id === void 0 ? centerMeasuredWidth : Math.max(centerMeasuredWidth, orderWidgetWidthById.get(mergedLine.id) ?? 0);
|
|
483
|
+
if (mergedLine.id) {
|
|
484
|
+
orderWidgetWidthById.set(mergedLine.id, centerWidth);
|
|
485
|
+
}
|
|
486
|
+
const closeWidth = showCloseButton ? 24 : 0;
|
|
487
|
+
const mainWidgetWidth = qtyWidth + centerWidth + closeWidth;
|
|
488
|
+
const totalWidth = mainWidgetWidth + actionButtonsGap + actionButtonsTotalWidth;
|
|
489
|
+
const rightMarginInsideChart = 10;
|
|
490
|
+
const maxWidgetX = chartRight - totalWidth - rightMarginInsideChart;
|
|
491
|
+
const leftWidgetXBase = chartLeft + 8;
|
|
492
|
+
let leftWidgetX = leftWidgetXBase;
|
|
493
|
+
if (mergedLine.widgetPosition === "center") {
|
|
494
|
+
leftWidgetX = chartLeft + (chartRight - chartLeft - totalWidth) / 2;
|
|
495
|
+
} else if (mergedLine.widgetPosition === "right") {
|
|
496
|
+
leftWidgetX = maxWidgetX;
|
|
497
|
+
}
|
|
498
|
+
leftWidgetX = clamp(leftWidgetX, leftWidgetXBase, maxWidgetX);
|
|
499
|
+
const labelY = clamp(lineY - labelHeight / 2, chartTop, chartBottom - labelHeight);
|
|
500
|
+
const borderRadius = Math.max(0, mergedLine.labelBorderRadius);
|
|
501
|
+
const widgetBackground = mergedOptions.backgroundColor;
|
|
502
|
+
const widgetBorder = color;
|
|
503
|
+
const textColor = mergedLine.labelTextColor;
|
|
504
|
+
const mainWidgetX = leftWidgetX + actionButtonsTotalWidth + actionButtonsGap;
|
|
505
|
+
ctx.fillStyle = widgetBackground;
|
|
506
|
+
fillRoundedRect(Math.round(mainWidgetX), Math.round(labelY), mainWidgetWidth, labelHeight, borderRadius);
|
|
507
|
+
ctx.strokeStyle = widgetBorder;
|
|
508
|
+
ctx.lineWidth = 1;
|
|
509
|
+
strokeRoundedRect(Math.round(mainWidgetX), Math.round(labelY), mainWidgetWidth, labelHeight, borderRadius);
|
|
510
|
+
let cursorX = mainWidgetX;
|
|
511
|
+
const separatorColor = "rgba(148,163,184,0.45)";
|
|
512
|
+
if (actionButtonMetrics.length > 0) {
|
|
513
|
+
let actionCursorX = leftWidgetX;
|
|
514
|
+
actionButtonMetrics.forEach((metric, i) => {
|
|
515
|
+
const { button, width: width2 } = metric;
|
|
516
|
+
const fullHeight = button.fullHeight ?? mergedLine.actionButtonFullHeight;
|
|
517
|
+
const actionPadding = fullHeight ? 0 : 2;
|
|
518
|
+
const actionX = actionCursorX + actionPadding;
|
|
519
|
+
const actionY = labelY + actionPadding;
|
|
520
|
+
const actionH = labelHeight - actionPadding * 2;
|
|
521
|
+
const actionW = Math.max(8, width2 - actionPadding * 2);
|
|
522
|
+
const actionRadius = Math.max(1, button.borderRadius ?? mergedLine.actionButtonBorderRadius);
|
|
523
|
+
const actionBg = button.backgroundColor ?? mergedLine.actionButtonBackgroundColor;
|
|
524
|
+
const actionTextColor = button.textColor ?? mergedLine.actionButtonTextColor;
|
|
525
|
+
const actionBorderColor = button.borderColor ?? mergedLine.actionButtonBorderColor;
|
|
526
|
+
const actionBorderStyle = button.borderStyle ?? mergedLine.actionButtonBorderStyle;
|
|
527
|
+
ctx.fillStyle = actionBg;
|
|
528
|
+
fillRoundedRect(Math.round(actionX), Math.round(actionY), actionW, actionH, actionRadius);
|
|
529
|
+
ctx.save();
|
|
530
|
+
ctx.strokeStyle = actionBorderColor;
|
|
531
|
+
ctx.lineWidth = 1;
|
|
532
|
+
if (actionBorderStyle === "dotted") {
|
|
533
|
+
ctx.setLineDash([2, 3]);
|
|
534
|
+
} else if (actionBorderStyle === "dashed") {
|
|
535
|
+
ctx.setLineDash([6, 4]);
|
|
536
|
+
} else {
|
|
537
|
+
ctx.setLineDash([]);
|
|
538
|
+
}
|
|
539
|
+
strokeRoundedRect(Math.round(actionX), Math.round(actionY), actionW, actionH, actionRadius);
|
|
540
|
+
ctx.restore();
|
|
541
|
+
const baseFont = ctx.font;
|
|
542
|
+
ctx.font = `${button.fontWeight ?? mergedLine.actionButtonFontWeight} ${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
|
|
543
|
+
drawText(button.text, actionX + actionW / 2, actionY + actionH / 2, "center", "middle", actionTextColor);
|
|
544
|
+
ctx.font = baseFont;
|
|
545
|
+
if (mergedLine.id) {
|
|
546
|
+
orderActionRegions.push({
|
|
547
|
+
orderId: mergedLine.id,
|
|
548
|
+
action: button.action,
|
|
549
|
+
...button.draggable === void 0 ? {} : { draggable: button.draggable },
|
|
550
|
+
x: actionX,
|
|
551
|
+
y: actionY,
|
|
552
|
+
width: actionW,
|
|
553
|
+
height: actionH,
|
|
554
|
+
line: mergedLine
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
actionCursorX += width2 + (i < actionButtonMetrics.length - 1 ? actionButtonInnerGap : 0);
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
if (qtyWidth > 0) {
|
|
561
|
+
drawText(qtyText, cursorX + segmentPaddingX, labelY + labelHeight / 2, "left", "middle", textColor);
|
|
562
|
+
cursorX += qtyWidth;
|
|
563
|
+
ctx.strokeStyle = separatorColor;
|
|
564
|
+
ctx.beginPath();
|
|
565
|
+
ctx.moveTo(crisp(cursorX), labelY + 4);
|
|
566
|
+
ctx.lineTo(crisp(cursorX), labelY + labelHeight - 4);
|
|
567
|
+
ctx.stroke();
|
|
568
|
+
}
|
|
569
|
+
drawText(centerText, cursorX + segmentPaddingX, labelY + labelHeight / 2, "left", "middle", textColor);
|
|
570
|
+
cursorX += centerWidth;
|
|
571
|
+
if (showCloseButton) {
|
|
572
|
+
ctx.strokeStyle = separatorColor;
|
|
573
|
+
ctx.beginPath();
|
|
574
|
+
ctx.moveTo(crisp(cursorX), labelY + 4);
|
|
575
|
+
ctx.lineTo(crisp(cursorX), labelY + labelHeight - 4);
|
|
576
|
+
ctx.stroke();
|
|
577
|
+
drawText("x", cursorX + closeWidth / 2, labelY + labelHeight / 2, "center", "middle", textColor);
|
|
578
|
+
if (mergedLine.id) {
|
|
579
|
+
orderActionRegions.push({
|
|
580
|
+
orderId: mergedLine.id,
|
|
581
|
+
action: closeAction,
|
|
582
|
+
x: cursorX,
|
|
583
|
+
y: labelY,
|
|
584
|
+
width: closeWidth,
|
|
585
|
+
height: labelHeight,
|
|
586
|
+
line: mergedLine
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (mergedLine.draggable && mergedLine.id) {
|
|
591
|
+
orderDragRegions.push({
|
|
592
|
+
orderId: mergedLine.id,
|
|
593
|
+
x: chartLeft,
|
|
594
|
+
y: lineY - 8,
|
|
595
|
+
width: chartRight - chartLeft,
|
|
596
|
+
height: 16,
|
|
597
|
+
line: mergedLine,
|
|
598
|
+
price: renderPrice
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
const priceText = formatPrice(renderPrice);
|
|
602
|
+
const pricePaddingX = 8;
|
|
603
|
+
const measuredPriceWidth = Math.ceil(ctx.measureText(priceText).width) + pricePaddingX * 2;
|
|
604
|
+
const priceWidth = mergedLine.id === void 0 ? measuredPriceWidth : Math.max(measuredPriceWidth, orderPriceTagWidthById.get(mergedLine.id) ?? 0);
|
|
605
|
+
if (mergedLine.id) {
|
|
606
|
+
orderPriceTagWidthById.set(mergedLine.id, priceWidth);
|
|
607
|
+
}
|
|
608
|
+
const priceX = chartRight + 4;
|
|
609
|
+
ctx.fillStyle = mergedLine.labelBackgroundColor ?? color;
|
|
610
|
+
fillRoundedRect(Math.round(priceX), Math.round(labelY), priceWidth, labelHeight, borderRadius);
|
|
611
|
+
ctx.strokeStyle = widgetBorder;
|
|
612
|
+
ctx.lineWidth = 1;
|
|
613
|
+
strokeRoundedRect(Math.round(priceX), Math.round(labelY), priceWidth, labelHeight, borderRadius);
|
|
614
|
+
drawText(priceText, priceX + pricePaddingX, labelY + labelHeight / 2, "left", "middle", textColor);
|
|
615
|
+
};
|
|
616
|
+
const fillRoundedRect = (x, y, widthValue, heightValue, radiusValue) => {
|
|
617
|
+
const maxRadius = Math.min(widthValue, heightValue) / 2;
|
|
618
|
+
const radius = clamp(radiusValue, 0, maxRadius);
|
|
619
|
+
ctx.beginPath();
|
|
620
|
+
ctx.moveTo(x + radius, y);
|
|
621
|
+
ctx.lineTo(x + widthValue - radius, y);
|
|
622
|
+
ctx.quadraticCurveTo(x + widthValue, y, x + widthValue, y + radius);
|
|
623
|
+
ctx.lineTo(x + widthValue, y + heightValue - radius);
|
|
624
|
+
ctx.quadraticCurveTo(x + widthValue, y + heightValue, x + widthValue - radius, y + heightValue);
|
|
625
|
+
ctx.lineTo(x + radius, y + heightValue);
|
|
626
|
+
ctx.quadraticCurveTo(x, y + heightValue, x, y + heightValue - radius);
|
|
627
|
+
ctx.lineTo(x, y + radius);
|
|
628
|
+
ctx.quadraticCurveTo(x, y, x + radius, y);
|
|
629
|
+
ctx.closePath();
|
|
630
|
+
ctx.fill();
|
|
631
|
+
};
|
|
632
|
+
const strokeRoundedRect = (x, y, widthValue, heightValue, radiusValue) => {
|
|
633
|
+
const maxRadius = Math.min(widthValue, heightValue) / 2;
|
|
634
|
+
const radius = clamp(radiusValue, 0, maxRadius);
|
|
635
|
+
ctx.beginPath();
|
|
636
|
+
ctx.moveTo(x + radius, y);
|
|
637
|
+
ctx.lineTo(x + widthValue - radius, y);
|
|
638
|
+
ctx.quadraticCurveTo(x + widthValue, y, x + widthValue, y + radius);
|
|
639
|
+
ctx.lineTo(x + widthValue, y + heightValue - radius);
|
|
640
|
+
ctx.quadraticCurveTo(x + widthValue, y + heightValue, x + widthValue - radius, y + heightValue);
|
|
641
|
+
ctx.lineTo(x + radius, y + heightValue);
|
|
642
|
+
ctx.quadraticCurveTo(x, y + heightValue, x, y + heightValue - radius);
|
|
643
|
+
ctx.lineTo(x, y + radius);
|
|
644
|
+
ctx.quadraticCurveTo(x, y, x + radius, y);
|
|
645
|
+
ctx.closePath();
|
|
646
|
+
ctx.stroke();
|
|
647
|
+
};
|
|
648
|
+
const setCrosshairPoint = (point) => {
|
|
649
|
+
const current = crosshairPoint;
|
|
650
|
+
if (point === null && current === null) {
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (point !== null && current !== null) {
|
|
654
|
+
const sameX = Math.round(point.x) === Math.round(current.x);
|
|
655
|
+
const sameY = Math.round(point.y) === Math.round(current.y);
|
|
656
|
+
if (sameX && sameY) {
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
crosshairPoint = point;
|
|
661
|
+
draw();
|
|
662
|
+
};
|
|
663
|
+
const draw = () => {
|
|
664
|
+
orderActionRegions = [];
|
|
665
|
+
orderDragRegions = [];
|
|
666
|
+
const pixelRatio = getPixelRatio();
|
|
667
|
+
canvas.style.width = `${width}px`;
|
|
668
|
+
canvas.style.height = `${height}px`;
|
|
669
|
+
canvas.width = Math.floor(width * pixelRatio);
|
|
670
|
+
canvas.height = Math.floor(height * pixelRatio);
|
|
671
|
+
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
|
672
|
+
const axis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.axis ?? {} };
|
|
673
|
+
ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
|
|
674
|
+
ctx.fillStyle = mergedOptions.backgroundColor;
|
|
675
|
+
ctx.fillRect(0, 0, width, height);
|
|
676
|
+
const chartLeft = margin.left;
|
|
677
|
+
const chartTop = margin.top;
|
|
678
|
+
const chartWidth = width - margin.left - margin.right;
|
|
679
|
+
const chartHeight = height - margin.top - margin.bottom;
|
|
680
|
+
const chartBottom = chartTop + chartHeight;
|
|
681
|
+
const chartRight = chartLeft + chartWidth;
|
|
682
|
+
const watermark = { ...DEFAULT_WATERMARK_OPTIONS, ...mergedOptions.watermark ?? {} };
|
|
683
|
+
if (data.length === 0) {
|
|
684
|
+
drawState = {
|
|
685
|
+
chartLeft,
|
|
686
|
+
chartTop,
|
|
687
|
+
chartRight,
|
|
688
|
+
chartBottom,
|
|
689
|
+
chartWidth,
|
|
690
|
+
chartHeight,
|
|
691
|
+
xStart: 0,
|
|
692
|
+
xSpan: 0,
|
|
693
|
+
yMin: 0,
|
|
694
|
+
yMax: 0
|
|
695
|
+
};
|
|
696
|
+
drawText("Load OHLC data to render chart", chartLeft + 10, chartTop + 20, "left", "middle", "#64748b");
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
clampXViewport();
|
|
700
|
+
const xStart = xCenter - xSpan / 2;
|
|
701
|
+
const xEnd = xStart + xSpan;
|
|
702
|
+
const startIndex = Math.max(0, Math.floor(xStart));
|
|
703
|
+
const endIndex = Math.min(data.length - 1, Math.ceil(xEnd) - 1);
|
|
704
|
+
const visibleData = data.slice(startIndex, endIndex + 1);
|
|
705
|
+
let priceSource = visibleData.length > 0 ? visibleData : data;
|
|
706
|
+
if (mergedOptions.autoScaleIgnoreLatestCandle && priceSource.length > 1) {
|
|
707
|
+
priceSource = priceSource.slice(0, -1);
|
|
708
|
+
if (priceSource.length === 0) {
|
|
709
|
+
priceSource = visibleData.length > 0 ? visibleData : data;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
const minPrice = Math.min(...priceSource.map((point) => point.l));
|
|
713
|
+
const maxPrice = Math.max(...priceSource.map((point) => point.h));
|
|
714
|
+
const priceRange = maxPrice - minPrice || 1;
|
|
715
|
+
const autoMin = minPrice - priceRange * 0.08;
|
|
716
|
+
const autoMax = maxPrice + priceRange * 0.08;
|
|
717
|
+
const smoothing = clamp(mergedOptions.autoScaleSmoothing, 0, 1);
|
|
718
|
+
if (yMinOverride === null || yMaxOverride === null) {
|
|
719
|
+
if (autoYMin === null || autoYMax === null) {
|
|
720
|
+
autoYMin = autoMin;
|
|
721
|
+
autoYMax = autoMax;
|
|
722
|
+
} else {
|
|
723
|
+
if (autoMin < autoYMin) {
|
|
724
|
+
autoYMin = autoMin;
|
|
725
|
+
} else {
|
|
726
|
+
autoYMin += (autoMin - autoYMin) * smoothing;
|
|
727
|
+
}
|
|
728
|
+
if (autoMax > autoYMax) {
|
|
729
|
+
autoYMax = autoMax;
|
|
730
|
+
} else {
|
|
731
|
+
autoYMax += (autoMax - autoYMax) * smoothing;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
const yMin = yMinOverride ?? autoYMin ?? autoMin;
|
|
736
|
+
const yMax = yMaxOverride ?? autoYMax ?? autoMax;
|
|
737
|
+
const yRange = yMax - yMin || 1;
|
|
738
|
+
drawState = {
|
|
739
|
+
chartLeft,
|
|
740
|
+
chartTop,
|
|
741
|
+
chartRight,
|
|
742
|
+
chartBottom,
|
|
743
|
+
chartWidth,
|
|
744
|
+
chartHeight,
|
|
745
|
+
xStart,
|
|
746
|
+
xSpan,
|
|
747
|
+
yMin,
|
|
748
|
+
yMax
|
|
749
|
+
};
|
|
750
|
+
const yFromPrice = (price) => {
|
|
751
|
+
return chartBottom - (price - yMin) / yRange * chartHeight;
|
|
752
|
+
};
|
|
753
|
+
if (watermark.visible && watermark.text.trim().length > 0) {
|
|
754
|
+
ctx.save();
|
|
755
|
+
ctx.globalAlpha = clamp(watermark.opacity, 0, 1);
|
|
756
|
+
ctx.textAlign = "center";
|
|
757
|
+
ctx.textBaseline = "middle";
|
|
758
|
+
ctx.fillStyle = watermark.color;
|
|
759
|
+
ctx.font = `${watermark.fontWeight} ${Math.max(8, watermark.fontSize)}px ${mergedOptions.fontFamily}`;
|
|
760
|
+
const centerX = chartLeft + chartWidth / 2;
|
|
761
|
+
const centerY = chartTop + chartHeight / 2;
|
|
762
|
+
if (watermark.thickness > 0) {
|
|
763
|
+
ctx.strokeStyle = watermark.color;
|
|
764
|
+
ctx.lineWidth = Math.max(0.5, watermark.thickness);
|
|
765
|
+
ctx.strokeText(watermark.text, centerX, centerY);
|
|
766
|
+
}
|
|
767
|
+
ctx.fillText(watermark.text, centerX, centerY);
|
|
768
|
+
ctx.restore();
|
|
769
|
+
}
|
|
770
|
+
ctx.save();
|
|
771
|
+
ctx.beginPath();
|
|
772
|
+
ctx.rect(chartLeft + 1, chartTop + 1, Math.max(0, chartWidth - 2), Math.max(0, chartHeight - 2));
|
|
773
|
+
ctx.clip();
|
|
774
|
+
const candleSpacing = chartWidth / xSpan;
|
|
775
|
+
const candleBodyWidthRatio = clamp(mergedOptions.candleBodyWidthRatio, 0.1, 0.98);
|
|
776
|
+
const candleMinWidth = Math.max(0.5, mergedOptions.candleMinWidth);
|
|
777
|
+
const candleWickWidth = Math.max(1, mergedOptions.candleWickWidth);
|
|
778
|
+
const bodyWidth = Math.min(
|
|
779
|
+
Math.max(1, candleSpacing - 1),
|
|
780
|
+
Math.max(candleMinWidth, Math.floor(candleSpacing * candleBodyWidthRatio))
|
|
781
|
+
);
|
|
782
|
+
const grid = { ...DEFAULT_GRID_OPTIONS, ...mergedOptions.grid ?? {} };
|
|
783
|
+
const gridOpacity = clamp(grid.opacity, 0, 1);
|
|
784
|
+
const yTicks = Math.max(1, Math.floor(grid.horizontalTickCount));
|
|
785
|
+
const gridColor = grid.color ?? mergedOptions.gridColor;
|
|
786
|
+
if (grid.horizontalLines) {
|
|
787
|
+
ctx.save();
|
|
788
|
+
ctx.globalAlpha = gridOpacity;
|
|
789
|
+
for (let tick = 0; tick <= yTicks; tick += 1) {
|
|
790
|
+
const ratio = tick / yTicks;
|
|
791
|
+
const price = yMin + yRange * ratio;
|
|
792
|
+
const y = yFromPrice(price);
|
|
793
|
+
ctx.strokeStyle = gridColor;
|
|
794
|
+
ctx.lineWidth = 1;
|
|
795
|
+
ctx.beginPath();
|
|
796
|
+
ctx.moveTo(crisp(chartLeft), crisp(y));
|
|
797
|
+
ctx.lineTo(crisp(chartRight), crisp(y));
|
|
798
|
+
ctx.stroke();
|
|
799
|
+
}
|
|
800
|
+
ctx.restore();
|
|
801
|
+
}
|
|
802
|
+
const minLabelSpacingPx = Math.max(72, candleSpacing * 6);
|
|
803
|
+
const approxLabelCount = Math.max(2, Math.floor(chartWidth / minLabelSpacingPx));
|
|
804
|
+
const rawStep = xSpan / approxLabelCount;
|
|
805
|
+
const xStep = Math.max(1, Math.ceil(rawStep));
|
|
806
|
+
const visibleTickStart = Math.floor(xStart);
|
|
807
|
+
const visibleTickEnd = Math.ceil(xEnd) - 1;
|
|
808
|
+
const tickStartIndex = Math.ceil(visibleTickStart / xStep) * xStep;
|
|
809
|
+
if (grid.verticalLines) {
|
|
810
|
+
ctx.save();
|
|
811
|
+
ctx.globalAlpha = gridOpacity;
|
|
812
|
+
for (let index = tickStartIndex; index <= visibleTickEnd; index += xStep) {
|
|
813
|
+
const x = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
|
|
814
|
+
ctx.strokeStyle = gridColor;
|
|
815
|
+
ctx.beginPath();
|
|
816
|
+
ctx.moveTo(crisp(x), crisp(chartTop));
|
|
817
|
+
ctx.lineTo(crisp(x), crisp(chartBottom));
|
|
818
|
+
ctx.stroke();
|
|
819
|
+
}
|
|
820
|
+
ctx.restore();
|
|
821
|
+
}
|
|
822
|
+
for (let index = startIndex; index <= endIndex; index += 1) {
|
|
823
|
+
const point = data[index];
|
|
824
|
+
if (!point) {
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
const centerX = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
|
|
828
|
+
const openY = yFromPrice(point.o);
|
|
829
|
+
const closeY = yFromPrice(point.c);
|
|
830
|
+
const highY = yFromPrice(point.h);
|
|
831
|
+
const lowY = yFromPrice(point.l);
|
|
832
|
+
const isUp = point.c >= point.o;
|
|
833
|
+
const candleColor = isUp ? mergedOptions.upColor : mergedOptions.downColor;
|
|
834
|
+
ctx.strokeStyle = candleColor;
|
|
835
|
+
ctx.lineWidth = candleWickWidth;
|
|
836
|
+
ctx.beginPath();
|
|
837
|
+
ctx.moveTo(crisp(centerX), crisp(highY));
|
|
838
|
+
ctx.lineTo(crisp(centerX), crisp(lowY));
|
|
839
|
+
ctx.stroke();
|
|
840
|
+
const bodyTop = Math.min(openY, closeY);
|
|
841
|
+
const bodyHeight = Math.max(1, Math.abs(closeY - openY));
|
|
842
|
+
ctx.fillStyle = candleColor;
|
|
843
|
+
ctx.fillRect(Math.round(centerX - bodyWidth / 2), Math.round(bodyTop), bodyWidth, Math.max(1, Math.round(bodyHeight)));
|
|
844
|
+
}
|
|
845
|
+
const crosshair = { ...DEFAULT_CROSSHAIR_OPTIONS, ...mergedOptions.crosshair ?? {} };
|
|
846
|
+
if (crosshair.visible && crosshairPoint) {
|
|
847
|
+
const cx = clamp(crosshairPoint.x, chartLeft, chartRight);
|
|
848
|
+
const cy = clamp(crosshairPoint.y, chartTop, chartBottom);
|
|
849
|
+
ctx.save();
|
|
850
|
+
ctx.strokeStyle = crosshair.color;
|
|
851
|
+
ctx.lineWidth = Math.max(1, crosshair.width);
|
|
852
|
+
if (crosshair.style === "dotted") {
|
|
853
|
+
ctx.setLineDash([2, 4]);
|
|
854
|
+
} else if (crosshair.style === "dashed") {
|
|
855
|
+
ctx.setLineDash([8, 6]);
|
|
856
|
+
} else {
|
|
857
|
+
ctx.setLineDash([]);
|
|
858
|
+
}
|
|
859
|
+
if (crosshair.showVertical) {
|
|
860
|
+
ctx.beginPath();
|
|
861
|
+
ctx.moveTo(crisp(cx), crisp(chartTop));
|
|
862
|
+
ctx.lineTo(crisp(cx), crisp(chartBottom));
|
|
863
|
+
ctx.stroke();
|
|
864
|
+
}
|
|
865
|
+
if (crosshair.showHorizontal) {
|
|
866
|
+
ctx.beginPath();
|
|
867
|
+
ctx.moveTo(crisp(chartLeft), crisp(cy));
|
|
868
|
+
ctx.lineTo(crisp(chartRight), crisp(cy));
|
|
869
|
+
ctx.stroke();
|
|
870
|
+
}
|
|
871
|
+
ctx.restore();
|
|
872
|
+
}
|
|
873
|
+
ctx.restore();
|
|
874
|
+
ctx.strokeStyle = axis.lineColor;
|
|
875
|
+
ctx.lineWidth = Math.max(1, axis.lineWidth);
|
|
876
|
+
ctx.beginPath();
|
|
877
|
+
ctx.moveTo(crisp(chartLeft), crisp(chartBottom));
|
|
878
|
+
ctx.lineTo(crisp(chartRight), crisp(chartBottom));
|
|
879
|
+
ctx.moveTo(crisp(chartRight), crisp(chartTop));
|
|
880
|
+
ctx.lineTo(crisp(chartRight), crisp(chartBottom));
|
|
881
|
+
ctx.stroke();
|
|
882
|
+
for (let tick = 0; tick <= yTicks; tick += 1) {
|
|
883
|
+
const ratio = tick / yTicks;
|
|
884
|
+
const price = yMin + yRange * ratio;
|
|
885
|
+
const y = yFromPrice(price);
|
|
886
|
+
drawText(formatPrice(price), chartRight + 6, y, "left", "middle", axis.textColor);
|
|
887
|
+
}
|
|
888
|
+
const ticker = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
|
|
889
|
+
const lastPoint = data[data.length - 1];
|
|
890
|
+
if ((ticker.visible ?? true) && lastPoint) {
|
|
891
|
+
const tickerPrice = lastPoint.c;
|
|
892
|
+
const tickerY = yFromPrice(tickerPrice);
|
|
893
|
+
const lineY = clamp(tickerY, chartTop + 1, chartBottom - 1);
|
|
894
|
+
const tickerColor = ticker.color ?? (lastPoint.c >= lastPoint.o ? mergedOptions.upColor : mergedOptions.downColor);
|
|
895
|
+
const tickerThickness = Math.max(1, ticker.thickness ?? 1);
|
|
896
|
+
const tickerStyle = ticker.style ?? "solid";
|
|
897
|
+
ctx.save();
|
|
898
|
+
ctx.strokeStyle = tickerColor;
|
|
899
|
+
ctx.lineWidth = tickerThickness;
|
|
900
|
+
if (tickerStyle === "dotted") {
|
|
901
|
+
ctx.setLineDash([2, 4]);
|
|
902
|
+
} else if (tickerStyle === "dashed") {
|
|
903
|
+
ctx.setLineDash([8, 6]);
|
|
904
|
+
} else {
|
|
905
|
+
ctx.setLineDash([]);
|
|
906
|
+
}
|
|
907
|
+
ctx.beginPath();
|
|
908
|
+
ctx.moveTo(crisp(chartLeft), crisp(lineY));
|
|
909
|
+
ctx.lineTo(crisp(chartRight), crisp(lineY));
|
|
910
|
+
ctx.stroke();
|
|
911
|
+
ctx.setLineDash([]);
|
|
912
|
+
ctx.restore();
|
|
913
|
+
const tickerLabel = formatPrice(tickerPrice);
|
|
914
|
+
ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
|
|
915
|
+
const labelPaddingX = 8;
|
|
916
|
+
const labelHeight = 20;
|
|
917
|
+
const labelWidth = Math.ceil(ctx.measureText(tickerLabel).width) + labelPaddingX * 2;
|
|
918
|
+
const labelX = chartRight + 4;
|
|
919
|
+
const labelY = clamp(lineY - labelHeight / 2, chartTop, chartBottom - labelHeight);
|
|
920
|
+
const labelRadius = Math.max(0, ticker.labelBorderRadius ?? 0);
|
|
921
|
+
ctx.fillStyle = ticker.labelBackgroundColor ?? tickerColor;
|
|
922
|
+
fillRoundedRect(Math.round(labelX), Math.round(labelY), labelWidth, labelHeight, labelRadius);
|
|
923
|
+
drawText(
|
|
924
|
+
tickerLabel,
|
|
925
|
+
labelX + labelPaddingX,
|
|
926
|
+
labelY + labelHeight / 2,
|
|
927
|
+
"left",
|
|
928
|
+
"middle",
|
|
929
|
+
ticker.labelTextColor ?? "#0b1220"
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
for (const priceLine of priceLines) {
|
|
933
|
+
drawPriceLine(priceLine, yFromPrice, chartLeft, chartTop, chartRight, chartBottom);
|
|
934
|
+
}
|
|
935
|
+
for (const orderLine of orderLines) {
|
|
936
|
+
drawOrderLine(orderLine, yFromPrice, chartLeft, chartTop, chartRight, chartBottom);
|
|
937
|
+
}
|
|
938
|
+
for (let index = tickStartIndex; index <= visibleTickEnd; index += xStep) {
|
|
939
|
+
const tickTime = getTimeForIndex(index);
|
|
940
|
+
if (!tickTime) {
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
const x = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
|
|
944
|
+
const timeLabel = tickTime.toLocaleDateString(void 0, {
|
|
945
|
+
month: "short",
|
|
946
|
+
day: "numeric"
|
|
947
|
+
});
|
|
948
|
+
drawText(timeLabel, x, chartBottom + 8, "center", "top", axis.textColor);
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
const zoomX = (factor, anchorX) => {
|
|
952
|
+
if (!drawState || data.length === 0) {
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
const minSpan = 5;
|
|
956
|
+
const maxSpan = Math.max(minSpan, data.length + maxPanBars * 2);
|
|
957
|
+
const nextSpan = clamp(xSpan * factor, minSpan, maxSpan);
|
|
958
|
+
const anchorRatio = clamp((anchorX - drawState.chartLeft) / drawState.chartWidth, 0, 1);
|
|
959
|
+
const anchorIndex = drawState.xStart + anchorRatio * xSpan;
|
|
960
|
+
const nextStart = anchorIndex - anchorRatio * nextSpan;
|
|
961
|
+
xSpan = nextSpan;
|
|
962
|
+
xCenter = nextStart + nextSpan / 2;
|
|
963
|
+
clampXViewport();
|
|
964
|
+
draw();
|
|
965
|
+
};
|
|
966
|
+
const zoomXToLatest = (factor) => {
|
|
967
|
+
if (!drawState || data.length === 0) {
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
const minSpan = 5;
|
|
971
|
+
const maxSpan = Math.max(minSpan, data.length + maxPanBars * 2);
|
|
972
|
+
const nextSpan = clamp(xSpan * factor, minSpan, maxSpan);
|
|
973
|
+
const rightEdgeIndex = data.length + rightEdgePaddingBars;
|
|
974
|
+
const nextStart = rightEdgeIndex - nextSpan;
|
|
975
|
+
xSpan = nextSpan;
|
|
976
|
+
xCenter = nextStart + nextSpan / 2;
|
|
977
|
+
clampXViewport();
|
|
978
|
+
draw();
|
|
979
|
+
};
|
|
980
|
+
const zoomY = (factor, anchorY) => {
|
|
981
|
+
if (!drawState || data.length === 0) {
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
const currentMin = yMinOverride ?? drawState.yMin;
|
|
985
|
+
const currentMax = yMaxOverride ?? drawState.yMax;
|
|
986
|
+
const currentRange = currentMax - currentMin || 1;
|
|
987
|
+
const bounds = getYBounds();
|
|
988
|
+
const minRange = bounds.minRange;
|
|
989
|
+
const maxRange = bounds.hardSpan;
|
|
990
|
+
const nextRange = clamp(currentRange * factor, minRange, maxRange);
|
|
991
|
+
const anchorRatio = clamp((anchorY - drawState.chartTop) / drawState.chartHeight, 0, 1);
|
|
992
|
+
const anchorPrice = currentMax - anchorRatio * currentRange;
|
|
993
|
+
const nextMax = anchorPrice + anchorRatio * nextRange;
|
|
994
|
+
const nextMin = nextMax - nextRange;
|
|
995
|
+
const clamped = clampYRange(nextMin, nextMax);
|
|
996
|
+
yMinOverride = clamped.min;
|
|
997
|
+
yMaxOverride = clamped.max;
|
|
998
|
+
draw();
|
|
999
|
+
};
|
|
1000
|
+
const pan = (deltaX, deltaY, allowX, allowY) => {
|
|
1001
|
+
if (!drawState || data.length === 0) {
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
if (allowX) {
|
|
1005
|
+
const indexShift = deltaX / drawState.chartWidth * xSpan;
|
|
1006
|
+
xCenter -= indexShift;
|
|
1007
|
+
clampXViewport();
|
|
1008
|
+
}
|
|
1009
|
+
if (allowY) {
|
|
1010
|
+
const currentMin = yMinOverride ?? drawState.yMin;
|
|
1011
|
+
const currentMax = yMaxOverride ?? drawState.yMax;
|
|
1012
|
+
const range = currentMax - currentMin || 1;
|
|
1013
|
+
const priceShift = deltaY / drawState.chartHeight * range;
|
|
1014
|
+
const clamped = clampYRange(currentMin + priceShift, currentMax + priceShift);
|
|
1015
|
+
yMinOverride = clamped.min;
|
|
1016
|
+
yMaxOverride = clamped.max;
|
|
1017
|
+
}
|
|
1018
|
+
draw();
|
|
1019
|
+
};
|
|
1020
|
+
const getCanvasPoint = (event) => {
|
|
1021
|
+
const rect = canvas.getBoundingClientRect();
|
|
1022
|
+
return {
|
|
1023
|
+
x: event.clientX - rect.left,
|
|
1024
|
+
y: event.clientY - rect.top
|
|
1025
|
+
};
|
|
1026
|
+
};
|
|
1027
|
+
const getOrderActionRegion = (x, y) => {
|
|
1028
|
+
return orderActionRegions.find(
|
|
1029
|
+
(region) => x >= region.x && x <= region.x + region.width && y >= region.y && y <= region.y + region.height
|
|
1030
|
+
);
|
|
1031
|
+
};
|
|
1032
|
+
const getOrderDragRegion = (x, y) => {
|
|
1033
|
+
return orderDragRegions.find(
|
|
1034
|
+
(region) => x >= region.x && x <= region.x + region.width && y >= region.y && y <= region.y + region.height
|
|
1035
|
+
);
|
|
1036
|
+
};
|
|
1037
|
+
const priceFromCanvasY = (y) => {
|
|
1038
|
+
if (!drawState) {
|
|
1039
|
+
return 0;
|
|
1040
|
+
}
|
|
1041
|
+
const ratio = clamp((drawState.chartBottom - y) / drawState.chartHeight, 0, 1);
|
|
1042
|
+
return drawState.yMin + ratio * (drawState.yMax - drawState.yMin);
|
|
1043
|
+
};
|
|
1044
|
+
const getHitRegion = (x, y) => {
|
|
1045
|
+
if (!drawState) {
|
|
1046
|
+
return "outside";
|
|
1047
|
+
}
|
|
1048
|
+
const inPlot = x >= drawState.chartLeft && x <= drawState.chartRight && y >= drawState.chartTop && y <= drawState.chartBottom;
|
|
1049
|
+
if (inPlot) {
|
|
1050
|
+
return "plot";
|
|
1051
|
+
}
|
|
1052
|
+
const inXAxis = x >= drawState.chartLeft && x <= drawState.chartRight && y > drawState.chartBottom && y <= height;
|
|
1053
|
+
if (inXAxis) {
|
|
1054
|
+
return "x-axis";
|
|
1055
|
+
}
|
|
1056
|
+
const inYAxis = x >= drawState.chartRight && x <= width && y >= drawState.chartTop && y <= drawState.chartBottom;
|
|
1057
|
+
if (inYAxis) {
|
|
1058
|
+
return "y-axis";
|
|
1059
|
+
}
|
|
1060
|
+
return "outside";
|
|
1061
|
+
};
|
|
1062
|
+
let isDragging = false;
|
|
1063
|
+
let dragMode = null;
|
|
1064
|
+
let lastPointerX = 0;
|
|
1065
|
+
let lastPointerY = 0;
|
|
1066
|
+
let activePointerId = null;
|
|
1067
|
+
const onPointerDown = (event) => {
|
|
1068
|
+
const point = getCanvasPoint(event);
|
|
1069
|
+
const orderRegion = getOrderActionRegion(point.x, point.y);
|
|
1070
|
+
if (orderRegion) {
|
|
1071
|
+
if (orderRegion.draggable) {
|
|
1072
|
+
activePointerId = event.pointerId;
|
|
1073
|
+
const startPrice = Number(orderRegion.line.price.toFixed(2));
|
|
1074
|
+
actionDragState = {
|
|
1075
|
+
orderId: orderRegion.orderId,
|
|
1076
|
+
action: orderRegion.action,
|
|
1077
|
+
startPrice,
|
|
1078
|
+
lastPrice: startPrice,
|
|
1079
|
+
moved: false
|
|
1080
|
+
};
|
|
1081
|
+
canvas.setPointerCapture(event.pointerId);
|
|
1082
|
+
canvas.style.cursor = "ns-resize";
|
|
1083
|
+
setCrosshairPoint(null);
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
setCrosshairPoint(null);
|
|
1087
|
+
orderActionHandler?.({
|
|
1088
|
+
orderId: orderRegion.orderId,
|
|
1089
|
+
action: orderRegion.action,
|
|
1090
|
+
line: orderRegion.line
|
|
1091
|
+
});
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
const orderDragRegion = getOrderDragRegion(point.x, point.y);
|
|
1095
|
+
if (orderDragRegion) {
|
|
1096
|
+
activePointerId = event.pointerId;
|
|
1097
|
+
orderDragState = {
|
|
1098
|
+
orderId: orderDragRegion.orderId,
|
|
1099
|
+
startPrice: orderDragRegion.price,
|
|
1100
|
+
lastPrice: orderDragRegion.price
|
|
1101
|
+
};
|
|
1102
|
+
canvas.setPointerCapture(event.pointerId);
|
|
1103
|
+
canvas.style.cursor = "ns-resize";
|
|
1104
|
+
setCrosshairPoint(null);
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
const region = getHitRegion(point.x, point.y);
|
|
1108
|
+
if (region === "outside") {
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
isDragging = true;
|
|
1112
|
+
dragMode = region;
|
|
1113
|
+
activePointerId = event.pointerId;
|
|
1114
|
+
pointerDownInfo = { pointerId: event.pointerId, x: point.x, y: point.y, region, moved: false };
|
|
1115
|
+
lastPointerX = point.x;
|
|
1116
|
+
lastPointerY = point.y;
|
|
1117
|
+
canvas.setPointerCapture(event.pointerId);
|
|
1118
|
+
setCrosshairPoint(null);
|
|
1119
|
+
};
|
|
1120
|
+
const onPointerMove = (event) => {
|
|
1121
|
+
const point = getCanvasPoint(event);
|
|
1122
|
+
if (pointerDownInfo && pointerDownInfo.pointerId === event.pointerId && !pointerDownInfo.moved) {
|
|
1123
|
+
const dx = point.x - pointerDownInfo.x;
|
|
1124
|
+
const dy = point.y - pointerDownInfo.y;
|
|
1125
|
+
if (dx * dx + dy * dy > 16) {
|
|
1126
|
+
pointerDownInfo.moved = true;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
if (orderDragState) {
|
|
1130
|
+
if (activePointerId !== null && event.pointerId !== activePointerId) {
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
const nextPrice = Number(priceFromCanvasY(point.y).toFixed(2));
|
|
1134
|
+
if (nextPrice !== orderDragState.lastPrice) {
|
|
1135
|
+
orderDragState.lastPrice = nextPrice;
|
|
1136
|
+
orderLines = orderLines.map((line) => {
|
|
1137
|
+
if (line.id !== orderDragState?.orderId) {
|
|
1138
|
+
return line;
|
|
1139
|
+
}
|
|
1140
|
+
if (line.behavior === "follow") {
|
|
1141
|
+
return { ...line, price: nextPrice, followPrice: nextPrice };
|
|
1142
|
+
}
|
|
1143
|
+
return { ...line, price: nextPrice };
|
|
1144
|
+
});
|
|
1145
|
+
const currentLine = orderLines.find((line) => line.id === orderDragState?.orderId);
|
|
1146
|
+
orderActionHandler?.({
|
|
1147
|
+
orderId: orderDragState.orderId,
|
|
1148
|
+
action: "move",
|
|
1149
|
+
price: nextPrice,
|
|
1150
|
+
dragging: true,
|
|
1151
|
+
...currentLine ? { line: currentLine } : {}
|
|
1152
|
+
});
|
|
1153
|
+
draw();
|
|
1154
|
+
}
|
|
1155
|
+
canvas.style.cursor = "ns-resize";
|
|
1156
|
+
setCrosshairPoint(null);
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
if (actionDragState) {
|
|
1160
|
+
if (activePointerId !== null && event.pointerId !== activePointerId) {
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
const nextPrice = Number(priceFromCanvasY(point.y).toFixed(2));
|
|
1164
|
+
if (nextPrice !== actionDragState.lastPrice) {
|
|
1165
|
+
actionDragState.lastPrice = nextPrice;
|
|
1166
|
+
actionDragState.moved = true;
|
|
1167
|
+
const currentLine = orderLines.find((line) => line.id === actionDragState?.orderId);
|
|
1168
|
+
orderActionHandler?.({
|
|
1169
|
+
orderId: actionDragState.orderId,
|
|
1170
|
+
action: actionDragState.action,
|
|
1171
|
+
price: nextPrice,
|
|
1172
|
+
dragging: true,
|
|
1173
|
+
...currentLine ? { line: currentLine } : {}
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
canvas.style.cursor = "ns-resize";
|
|
1177
|
+
setCrosshairPoint(null);
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
if (!isDragging || !dragMode) {
|
|
1181
|
+
const orderRegion = getOrderActionRegion(point.x, point.y);
|
|
1182
|
+
if (orderRegion) {
|
|
1183
|
+
canvas.style.cursor = orderRegion.draggable ? "ns-resize" : "pointer";
|
|
1184
|
+
setCrosshairPoint(null);
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
const orderDragRegion = getOrderDragRegion(point.x, point.y);
|
|
1188
|
+
if (orderDragRegion) {
|
|
1189
|
+
canvas.style.cursor = "ns-resize";
|
|
1190
|
+
setCrosshairPoint(null);
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
const hoverRegion = getHitRegion(point.x, point.y);
|
|
1194
|
+
if (hoverRegion === "plot") {
|
|
1195
|
+
canvas.style.cursor = "grab";
|
|
1196
|
+
setCrosshairPoint(point);
|
|
1197
|
+
} else if (hoverRegion === "x-axis") {
|
|
1198
|
+
canvas.style.cursor = "ew-resize";
|
|
1199
|
+
setCrosshairPoint(null);
|
|
1200
|
+
} else if (hoverRegion === "y-axis") {
|
|
1201
|
+
canvas.style.cursor = "ns-resize";
|
|
1202
|
+
setCrosshairPoint(null);
|
|
1203
|
+
} else {
|
|
1204
|
+
canvas.style.cursor = "default";
|
|
1205
|
+
setCrosshairPoint(null);
|
|
1206
|
+
}
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
const deltaX = point.x - lastPointerX;
|
|
1210
|
+
const deltaY = point.y - lastPointerY;
|
|
1211
|
+
if (dragMode === "plot") {
|
|
1212
|
+
canvas.style.cursor = "grabbing";
|
|
1213
|
+
pan(deltaX, deltaY, true, true);
|
|
1214
|
+
setCrosshairPoint(null);
|
|
1215
|
+
} else if (dragMode === "x-axis") {
|
|
1216
|
+
canvas.style.cursor = "ew-resize";
|
|
1217
|
+
zoomXToLatest(Math.exp(-deltaX * 0.01));
|
|
1218
|
+
setCrosshairPoint(null);
|
|
1219
|
+
} else if (dragMode === "y-axis") {
|
|
1220
|
+
canvas.style.cursor = "ns-resize";
|
|
1221
|
+
const anchorY = drawState ? drawState.chartTop + drawState.chartHeight / 2 : point.y;
|
|
1222
|
+
zoomY(Math.exp(deltaY * 0.01), anchorY);
|
|
1223
|
+
setCrosshairPoint(null);
|
|
1224
|
+
}
|
|
1225
|
+
lastPointerX = point.x;
|
|
1226
|
+
lastPointerY = point.y;
|
|
1227
|
+
};
|
|
1228
|
+
const endPointerDrag = (event) => {
|
|
1229
|
+
if (event && activePointerId !== null && event.pointerId !== activePointerId) {
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
if (orderDragState) {
|
|
1233
|
+
const moved = orderDragState.lastPrice !== orderDragState.startPrice;
|
|
1234
|
+
const finalLine = orderLines.find((line) => line.id === orderDragState?.orderId);
|
|
1235
|
+
if (moved && finalLine && orderDragState.orderId) {
|
|
1236
|
+
orderActionHandler?.({
|
|
1237
|
+
orderId: orderDragState.orderId,
|
|
1238
|
+
action: "move",
|
|
1239
|
+
price: orderDragState.lastPrice,
|
|
1240
|
+
dragging: false,
|
|
1241
|
+
line: finalLine
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
orderDragState = null;
|
|
1245
|
+
}
|
|
1246
|
+
if (actionDragState) {
|
|
1247
|
+
const currentLine = orderLines.find((line) => line.id === actionDragState?.orderId);
|
|
1248
|
+
if (actionDragState.moved) {
|
|
1249
|
+
orderActionHandler?.({
|
|
1250
|
+
orderId: actionDragState.orderId,
|
|
1251
|
+
action: actionDragState.action,
|
|
1252
|
+
price: actionDragState.lastPrice,
|
|
1253
|
+
dragging: false,
|
|
1254
|
+
...currentLine ? { line: currentLine } : {}
|
|
1255
|
+
});
|
|
1256
|
+
} else {
|
|
1257
|
+
orderActionHandler?.({
|
|
1258
|
+
orderId: actionDragState.orderId,
|
|
1259
|
+
action: actionDragState.action,
|
|
1260
|
+
dragging: false,
|
|
1261
|
+
...currentLine ? { line: currentLine } : {}
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
actionDragState = null;
|
|
1265
|
+
}
|
|
1266
|
+
isDragging = false;
|
|
1267
|
+
dragMode = null;
|
|
1268
|
+
activePointerId = null;
|
|
1269
|
+
canvas.style.cursor = "default";
|
|
1270
|
+
setCrosshairPoint(null);
|
|
1271
|
+
if (event && pointerDownInfo && event.pointerId === pointerDownInfo.pointerId) {
|
|
1272
|
+
if (!pointerDownInfo.moved) {
|
|
1273
|
+
const clickPrice = pointerDownInfo.region === "plot" ? Number(priceFromCanvasY(pointerDownInfo.y).toFixed(2)) : void 0;
|
|
1274
|
+
chartClickHandler?.({
|
|
1275
|
+
x: pointerDownInfo.x,
|
|
1276
|
+
y: pointerDownInfo.y,
|
|
1277
|
+
...clickPrice === void 0 ? {} : { price: clickPrice },
|
|
1278
|
+
region: pointerDownInfo.region
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
pointerDownInfo = null;
|
|
1282
|
+
} else if (!event) {
|
|
1283
|
+
pointerDownInfo = null;
|
|
1284
|
+
}
|
|
1285
|
+
};
|
|
1286
|
+
const onWheel = (event) => {
|
|
1287
|
+
if (!drawState) {
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
const point = getCanvasPoint(event);
|
|
1291
|
+
const region = getHitRegion(point.x, point.y);
|
|
1292
|
+
if (region === "outside") {
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
event.preventDefault();
|
|
1296
|
+
const factor = Math.exp(event.deltaY * 16e-4);
|
|
1297
|
+
if (region === "y-axis") {
|
|
1298
|
+
zoomY(factor, point.y);
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
if (region === "x-axis") {
|
|
1302
|
+
zoomXToLatest(factor);
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
zoomX(factor, point.x);
|
|
1306
|
+
};
|
|
1307
|
+
const onDoubleClick = (event) => {
|
|
1308
|
+
if (!doubleClickEnabled) {
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
const point = getCanvasPoint(event);
|
|
1312
|
+
const region = getHitRegion(point.x, point.y);
|
|
1313
|
+
if (region === "outside") {
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
if (doubleClickAction === "placeLimitOrder") {
|
|
1317
|
+
if (region !== "plot") {
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
orderActionHandler?.({
|
|
1321
|
+
action: "createLimit",
|
|
1322
|
+
price: Number(priceFromCanvasY(point.y).toFixed(2))
|
|
1323
|
+
});
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
if (region === "y-axis") {
|
|
1327
|
+
yMinOverride = null;
|
|
1328
|
+
yMaxOverride = null;
|
|
1329
|
+
autoYMin = null;
|
|
1330
|
+
autoYMax = null;
|
|
1331
|
+
draw();
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
fitXViewport();
|
|
1335
|
+
yMinOverride = null;
|
|
1336
|
+
yMaxOverride = null;
|
|
1337
|
+
autoYMin = null;
|
|
1338
|
+
autoYMax = null;
|
|
1339
|
+
draw();
|
|
1340
|
+
};
|
|
1341
|
+
canvas.addEventListener("pointerdown", onPointerDown);
|
|
1342
|
+
canvas.addEventListener("pointermove", onPointerMove);
|
|
1343
|
+
canvas.addEventListener("pointerup", endPointerDrag);
|
|
1344
|
+
canvas.addEventListener("pointercancel", endPointerDrag);
|
|
1345
|
+
canvas.addEventListener("pointerleave", endPointerDrag);
|
|
1346
|
+
canvas.addEventListener("wheel", onWheel, { passive: false });
|
|
1347
|
+
canvas.addEventListener("dblclick", onDoubleClick);
|
|
1348
|
+
const resize = (nextWidth, nextHeight) => {
|
|
1349
|
+
if (nextWidth && nextWidth > 0) {
|
|
1350
|
+
width = nextWidth;
|
|
1351
|
+
}
|
|
1352
|
+
if (nextHeight && nextHeight > 0) {
|
|
1353
|
+
height = nextHeight;
|
|
1354
|
+
}
|
|
1355
|
+
draw();
|
|
1356
|
+
};
|
|
1357
|
+
const setData = (nextData) => {
|
|
1358
|
+
const hadData = data.length > 0;
|
|
1359
|
+
data = parseData(nextData);
|
|
1360
|
+
if (data.length === 0) {
|
|
1361
|
+
xCenter = 0;
|
|
1362
|
+
xSpan = 60;
|
|
1363
|
+
yMinOverride = null;
|
|
1364
|
+
yMaxOverride = null;
|
|
1365
|
+
autoYMin = null;
|
|
1366
|
+
autoYMax = null;
|
|
1367
|
+
draw();
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
if (!hadData) {
|
|
1371
|
+
fitXViewport();
|
|
1372
|
+
yMinOverride = null;
|
|
1373
|
+
yMaxOverride = null;
|
|
1374
|
+
autoYMin = null;
|
|
1375
|
+
autoYMax = null;
|
|
1376
|
+
} else {
|
|
1377
|
+
clampXViewport();
|
|
1378
|
+
}
|
|
1379
|
+
draw();
|
|
1380
|
+
};
|
|
1381
|
+
const setPriceLines = (lines) => {
|
|
1382
|
+
priceLines = lines.map((line, index) => ({
|
|
1383
|
+
...line,
|
|
1384
|
+
id: line.id ?? `line-${index + 1}`
|
|
1385
|
+
}));
|
|
1386
|
+
draw();
|
|
1387
|
+
};
|
|
1388
|
+
const addPriceLine = (line) => {
|
|
1389
|
+
const id = line.id ?? `line-${generatedPriceLineId++}`;
|
|
1390
|
+
priceLines.push({ ...line, id });
|
|
1391
|
+
draw();
|
|
1392
|
+
return id;
|
|
1393
|
+
};
|
|
1394
|
+
const removePriceLine = (id) => {
|
|
1395
|
+
priceLines = priceLines.filter((line) => line.id !== id);
|
|
1396
|
+
draw();
|
|
1397
|
+
};
|
|
1398
|
+
const setOrderLines = (lines) => {
|
|
1399
|
+
orderLines = lines.map((line, index) => ({
|
|
1400
|
+
...line,
|
|
1401
|
+
id: line.id ?? `order-${index + 1}`
|
|
1402
|
+
}));
|
|
1403
|
+
const activeIds = new Set(orderLines.map((line) => line.id));
|
|
1404
|
+
for (const id of Array.from(orderWidgetWidthById.keys())) {
|
|
1405
|
+
if (!activeIds.has(id)) {
|
|
1406
|
+
orderWidgetWidthById.delete(id);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
for (const id of Array.from(orderPriceTagWidthById.keys())) {
|
|
1410
|
+
if (!activeIds.has(id)) {
|
|
1411
|
+
orderPriceTagWidthById.delete(id);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
draw();
|
|
1415
|
+
};
|
|
1416
|
+
const addOrderLine = (line) => {
|
|
1417
|
+
const id = line.id ?? `order-${generatedOrderLineId++}`;
|
|
1418
|
+
orderLines.push({ ...line, id });
|
|
1419
|
+
draw();
|
|
1420
|
+
return id;
|
|
1421
|
+
};
|
|
1422
|
+
const updateOrderLine = (id, patch) => {
|
|
1423
|
+
orderLines = orderLines.map((line) => line.id === id ? { ...line, ...patch, id } : line);
|
|
1424
|
+
draw();
|
|
1425
|
+
};
|
|
1426
|
+
const removeOrderLine = (id) => {
|
|
1427
|
+
orderLines = orderLines.filter((line) => line.id !== id);
|
|
1428
|
+
orderWidgetWidthById.delete(id);
|
|
1429
|
+
orderPriceTagWidthById.delete(id);
|
|
1430
|
+
draw();
|
|
1431
|
+
};
|
|
1432
|
+
const onOrderAction = (handler) => {
|
|
1433
|
+
orderActionHandler = handler;
|
|
1434
|
+
};
|
|
1435
|
+
const onChartClick = (handler) => {
|
|
1436
|
+
chartClickHandler = handler;
|
|
1437
|
+
};
|
|
1438
|
+
const setDoubleClickEnabled = (enabled) => {
|
|
1439
|
+
doubleClickEnabled = enabled;
|
|
1440
|
+
};
|
|
1441
|
+
const setDoubleClickAction = (action) => {
|
|
1442
|
+
doubleClickAction = action;
|
|
1443
|
+
};
|
|
1444
|
+
const destroy = () => {
|
|
1445
|
+
canvas.removeEventListener("pointerdown", onPointerDown);
|
|
1446
|
+
canvas.removeEventListener("pointermove", onPointerMove);
|
|
1447
|
+
canvas.removeEventListener("pointerup", endPointerDrag);
|
|
1448
|
+
canvas.removeEventListener("pointercancel", endPointerDrag);
|
|
1449
|
+
canvas.removeEventListener("pointerleave", endPointerDrag);
|
|
1450
|
+
canvas.removeEventListener("wheel", onWheel);
|
|
1451
|
+
canvas.removeEventListener("dblclick", onDoubleClick);
|
|
1452
|
+
element.innerHTML = "";
|
|
1453
|
+
};
|
|
1454
|
+
draw();
|
|
1455
|
+
return {
|
|
1456
|
+
setData,
|
|
1457
|
+
setPriceLines,
|
|
1458
|
+
addPriceLine,
|
|
1459
|
+
removePriceLine,
|
|
1460
|
+
setOrderLines,
|
|
1461
|
+
addOrderLine,
|
|
1462
|
+
updateOrderLine,
|
|
1463
|
+
removeOrderLine,
|
|
1464
|
+
onOrderAction,
|
|
1465
|
+
onChartClick,
|
|
1466
|
+
setDoubleClickEnabled,
|
|
1467
|
+
setDoubleClickAction,
|
|
1468
|
+
resize,
|
|
1469
|
+
destroy
|
|
1470
|
+
};
|
|
1471
|
+
}
|
|
1472
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1473
|
+
0 && (module.exports = {
|
|
1474
|
+
createChart
|
|
1475
|
+
});
|