@funnest/charts 0.0.0-stage → 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/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +28 -2
- package/dist/chart.d.ts +300 -0
- package/dist/datafeed/combine.d.ts +7 -0
- package/dist/datafeed/latency.d.ts +5 -0
- package/dist/datafeed/socket.d.ts +60 -0
- package/dist/events.d.ts +7 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +3749 -0
- package/dist/indicators/definitions.d.ts +74 -0
- package/dist/indicators/manager.d.ts +26 -0
- package/dist/indicators/math.d.ts +21 -0
- package/dist/interaction.d.ts +81 -0
- package/dist/model/bar-store.d.ts +36 -0
- package/dist/model/price-scale.d.ts +35 -0
- package/dist/model/time-format.d.ts +50 -0
- package/dist/model/time-scale.d.ts +42 -0
- package/dist/objects/drawings.d.ts +52 -0
- package/dist/objects/magnet.d.ts +6 -0
- package/dist/objects/trading-sync.d.ts +72 -0
- package/dist/objects/trading.d.ts +76 -0
- package/dist/objects/types.d.ts +42 -0
- package/dist/price.d.ts +17 -0
- package/dist/realtime.d.ts +39 -0
- package/dist/render/canvas-layer.d.ts +13 -0
- package/dist/render/color-resolver.d.ts +14 -0
- package/dist/render/painter.d.ts +38 -0
- package/dist/render/renderer.d.ts +100 -0
- package/dist/resolution.d.ts +8 -0
- package/dist/session.d.ts +12 -0
- package/dist/settings.d.ts +40 -0
- package/dist/theme.d.ts +24 -0
- package/dist/types.d.ts +106 -0
- package/package.json +47 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,3749 @@
|
|
|
1
|
+
// src/events.ts
|
|
2
|
+
var Emitter = class {
|
|
3
|
+
listeners = /* @__PURE__ */ new Map();
|
|
4
|
+
on(event, listener) {
|
|
5
|
+
let set = this.listeners.get(event);
|
|
6
|
+
if (!set) this.listeners.set(event, set = /* @__PURE__ */ new Set());
|
|
7
|
+
set.add(listener);
|
|
8
|
+
return () => set.delete(listener);
|
|
9
|
+
}
|
|
10
|
+
emit(event, payload) {
|
|
11
|
+
const set = this.listeners.get(event);
|
|
12
|
+
if (!set) return;
|
|
13
|
+
for (const listener of set) listener(payload);
|
|
14
|
+
}
|
|
15
|
+
clear() {
|
|
16
|
+
this.listeners.clear();
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// src/interaction.ts
|
|
21
|
+
var LONG_PRESS_MS = 400;
|
|
22
|
+
var MOVE_SLOP = 8;
|
|
23
|
+
var DOUBLE_TAP_MS = 300;
|
|
24
|
+
var DOUBLE_TAP_SLOP = 24;
|
|
25
|
+
var VELOCITY_WINDOW_MS = 100;
|
|
26
|
+
var KINETIC_MIN_VELOCITY = 0.15;
|
|
27
|
+
var KINETIC_STOP_VELOCITY = 0.02;
|
|
28
|
+
var KINETIC_FRICTION = 0.995;
|
|
29
|
+
var WHEEL_ZOOM_SPEED = 15e-4;
|
|
30
|
+
var PINCH_WHEEL_ZOOM_SPEED = 0.01;
|
|
31
|
+
var AXIS_DRAG_SPEED = 4e-3;
|
|
32
|
+
var dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
|
33
|
+
var Interaction = class {
|
|
34
|
+
constructor(host, el) {
|
|
35
|
+
this.host = host;
|
|
36
|
+
this.el = el;
|
|
37
|
+
el.style.touchAction = "pan-y";
|
|
38
|
+
el.style.cursor = "crosshair";
|
|
39
|
+
this.listen("pointerdown", this.onPointerDown);
|
|
40
|
+
this.listen("pointermove", this.onPointerMove);
|
|
41
|
+
this.listen("pointerup", this.onPointerUp);
|
|
42
|
+
this.listen("pointercancel", this.onPointerCancel);
|
|
43
|
+
this.listen("pointerleave", this.onPointerLeave);
|
|
44
|
+
this.listen("wheel", this.onWheel, { passive: false });
|
|
45
|
+
this.listen("dblclick", this.onDoubleClick);
|
|
46
|
+
this.listen("touchmove", this.onTouchMove, { passive: false });
|
|
47
|
+
this.listen("contextmenu", this.onContextMenu);
|
|
48
|
+
}
|
|
49
|
+
host;
|
|
50
|
+
el;
|
|
51
|
+
pointers = /* @__PURE__ */ new Map();
|
|
52
|
+
mode = "idle";
|
|
53
|
+
touch = false;
|
|
54
|
+
moved = false;
|
|
55
|
+
start = { x: 0, y: 0 };
|
|
56
|
+
last = { x: 0, y: 0 };
|
|
57
|
+
region = { area: "none", pane: null };
|
|
58
|
+
longPressTimer;
|
|
59
|
+
samples = [];
|
|
60
|
+
kineticFrame = 0;
|
|
61
|
+
pinchDistance = 0;
|
|
62
|
+
pinchMid = { x: 0, y: 0 };
|
|
63
|
+
lastTap = { t: 0, x: 0, y: 0 };
|
|
64
|
+
cleanups = [];
|
|
65
|
+
destroy() {
|
|
66
|
+
this.stopKinetic();
|
|
67
|
+
clearTimeout(this.longPressTimer);
|
|
68
|
+
for (const cleanup of this.cleanups) cleanup();
|
|
69
|
+
this.cleanups.length = 0;
|
|
70
|
+
}
|
|
71
|
+
listen(type, handler, options) {
|
|
72
|
+
const bound = handler.bind(this);
|
|
73
|
+
this.el.addEventListener(type, bound, options);
|
|
74
|
+
this.cleanups.push(() => this.el.removeEventListener(type, bound, options));
|
|
75
|
+
}
|
|
76
|
+
local(e) {
|
|
77
|
+
const rect = this.el.getBoundingClientRect();
|
|
78
|
+
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
79
|
+
}
|
|
80
|
+
// ── Pointer lifecycle ────────────────────────────────────────────────
|
|
81
|
+
onPointerDown(e) {
|
|
82
|
+
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
83
|
+
this.stopKinetic();
|
|
84
|
+
const p = this.local(e);
|
|
85
|
+
this.pointers.set(e.pointerId, p);
|
|
86
|
+
this.el.setPointerCapture?.(e.pointerId);
|
|
87
|
+
this.touch = e.pointerType !== "mouse";
|
|
88
|
+
if (this.pointers.size === 2) {
|
|
89
|
+
this.beginPinch();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (this.pointers.size > 2) return;
|
|
93
|
+
this.start = p;
|
|
94
|
+
this.last = p;
|
|
95
|
+
this.moved = false;
|
|
96
|
+
this.samples = [{ t: performance.now(), x: p.x }];
|
|
97
|
+
this.region = this.host.hitTest(p.x, p.y);
|
|
98
|
+
switch (this.region.area) {
|
|
99
|
+
case "priceAxis":
|
|
100
|
+
this.mode = "priceScale";
|
|
101
|
+
break;
|
|
102
|
+
case "timeAxis":
|
|
103
|
+
this.mode = "timeScale";
|
|
104
|
+
break;
|
|
105
|
+
case "plot": {
|
|
106
|
+
if (this.host.toolActive()) {
|
|
107
|
+
this.mode = "draw";
|
|
108
|
+
this.host.drawAt(p.x, p.y);
|
|
109
|
+
if (!this.touch) this.host.setCrosshair(p.x, p.y, "mouse");
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
const hit = this.host.hitObject(p.x, p.y, this.touch);
|
|
113
|
+
if (hit) {
|
|
114
|
+
this.mode = "object";
|
|
115
|
+
this.host.objectDown(hit, p.x, p.y);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
this.host.clearSelection();
|
|
119
|
+
if (this.touch) {
|
|
120
|
+
this.mode = "pending";
|
|
121
|
+
this.longPressTimer = setTimeout(() => this.onLongPress(), LONG_PRESS_MS);
|
|
122
|
+
} else {
|
|
123
|
+
this.mode = "pan";
|
|
124
|
+
this.el.style.cursor = "grabbing";
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
default:
|
|
129
|
+
this.mode = "idle";
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
onLongPress() {
|
|
133
|
+
if (this.mode !== "pending") return;
|
|
134
|
+
this.mode = "crosshair";
|
|
135
|
+
this.host.setCrosshair(this.last.x, this.last.y, "touch");
|
|
136
|
+
navigator.vibrate?.(8);
|
|
137
|
+
}
|
|
138
|
+
onPointerMove(e) {
|
|
139
|
+
const p = this.local(e);
|
|
140
|
+
if (!this.pointers.has(e.pointerId)) {
|
|
141
|
+
if (e.pointerType === "mouse" && this.mode === "idle") this.hover(p);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
this.pointers.set(e.pointerId, p);
|
|
145
|
+
if (!this.moved && dist(p, this.start) > MOVE_SLOP) this.moved = true;
|
|
146
|
+
switch (this.mode) {
|
|
147
|
+
case "pinch":
|
|
148
|
+
this.updatePinch();
|
|
149
|
+
return;
|
|
150
|
+
case "draw":
|
|
151
|
+
this.host.drawPreview(p.x, p.y);
|
|
152
|
+
if (!this.touch) this.host.setCrosshair(p.x, p.y, "mouse");
|
|
153
|
+
break;
|
|
154
|
+
case "object":
|
|
155
|
+
this.host.objectMove(p.x, p.y);
|
|
156
|
+
break;
|
|
157
|
+
case "pending":
|
|
158
|
+
if (!this.moved) return;
|
|
159
|
+
clearTimeout(this.longPressTimer);
|
|
160
|
+
this.mode = "pan";
|
|
161
|
+
this.pan(p);
|
|
162
|
+
break;
|
|
163
|
+
case "pan":
|
|
164
|
+
this.pan(p);
|
|
165
|
+
if (!this.touch) this.host.setCrosshair(p.x, p.y, "mouse");
|
|
166
|
+
break;
|
|
167
|
+
case "crosshair":
|
|
168
|
+
this.host.setCrosshair(p.x, p.y, "touch");
|
|
169
|
+
break;
|
|
170
|
+
case "priceScale":
|
|
171
|
+
if (this.region.pane) this.host.scalePrice(this.region.pane, Math.exp((p.y - this.last.y) * AXIS_DRAG_SPEED));
|
|
172
|
+
break;
|
|
173
|
+
case "timeScale":
|
|
174
|
+
this.host.zoomTime(this.host.plotWidth(), Math.exp((p.x - this.last.x) * AXIS_DRAG_SPEED));
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
this.last = p;
|
|
178
|
+
}
|
|
179
|
+
onPointerUp(e) {
|
|
180
|
+
if (!this.pointers.has(e.pointerId)) return;
|
|
181
|
+
const p = this.local(e);
|
|
182
|
+
this.pointers.delete(e.pointerId);
|
|
183
|
+
clearTimeout(this.longPressTimer);
|
|
184
|
+
if (this.mode === "pinch") {
|
|
185
|
+
this.endPinch();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (this.mode === "draw") this.host.drawRelease(p.x, p.y, this.moved);
|
|
189
|
+
if (this.mode === "object") this.host.objectUp(this.moved);
|
|
190
|
+
const tapModes = ["pending", "priceScale", "timeScale"];
|
|
191
|
+
if (this.touch && !this.moved && tapModes.includes(this.mode)) this.handleTap(p);
|
|
192
|
+
if (this.mode === "pan") this.startKinetic();
|
|
193
|
+
if (this.mode === "crosshair") this.host.clearCrosshair();
|
|
194
|
+
this.mode = "idle";
|
|
195
|
+
if (!this.touch) this.hover(p);
|
|
196
|
+
}
|
|
197
|
+
onPointerCancel(e) {
|
|
198
|
+
this.pointers.delete(e.pointerId);
|
|
199
|
+
clearTimeout(this.longPressTimer);
|
|
200
|
+
if (this.mode === "pinch" && this.pointers.size > 0) {
|
|
201
|
+
this.endPinch();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (this.mode === "object") this.host.objectUp(this.moved);
|
|
205
|
+
if (this.mode === "crosshair" || this.touch) this.host.clearCrosshair();
|
|
206
|
+
this.mode = "idle";
|
|
207
|
+
}
|
|
208
|
+
onPointerLeave(e) {
|
|
209
|
+
if (e.pointerType === "mouse" && this.mode === "idle") {
|
|
210
|
+
this.host.clearCrosshair();
|
|
211
|
+
this.host.hoverObject(null);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
hover(p) {
|
|
215
|
+
const region = this.host.hitTest(p.x, p.y);
|
|
216
|
+
let cursor = region.area === "priceAxis" ? "ns-resize" : region.area === "timeAxis" ? "ew-resize" : "crosshair";
|
|
217
|
+
let hovered = null;
|
|
218
|
+
if (region.area === "plot") {
|
|
219
|
+
if (this.host.toolActive()) {
|
|
220
|
+
this.host.drawPreview(p.x, p.y);
|
|
221
|
+
} else {
|
|
222
|
+
const hit = this.host.hitObject(p.x, p.y, false);
|
|
223
|
+
hovered = hit;
|
|
224
|
+
if (hit?.kind === "order") cursor = hit.part === "cancel" ? "pointer" : "ns-resize";
|
|
225
|
+
else if (hit?.kind === "execution") cursor = "pointer";
|
|
226
|
+
else if (hit) cursor = hit.part === "body" ? "move" : "grab";
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
this.host.hoverObject(hovered);
|
|
230
|
+
this.el.style.cursor = cursor;
|
|
231
|
+
if (region.area === "plot") this.host.setCrosshair(p.x, p.y, "mouse");
|
|
232
|
+
else this.host.clearCrosshair();
|
|
233
|
+
}
|
|
234
|
+
pan(p) {
|
|
235
|
+
this.host.panTime(p.x - this.last.x);
|
|
236
|
+
if (this.region.pane) this.host.panPrice(this.region.pane, p.y - this.last.y);
|
|
237
|
+
const t = performance.now();
|
|
238
|
+
this.samples.push({ t, x: p.x });
|
|
239
|
+
while (this.samples.length > 2 && t - this.samples[0].t > VELOCITY_WINDOW_MS) this.samples.shift();
|
|
240
|
+
}
|
|
241
|
+
handleTap(p) {
|
|
242
|
+
const t = performance.now();
|
|
243
|
+
const isDouble = t - this.lastTap.t < DOUBLE_TAP_MS && dist(p, this.lastTap) < DOUBLE_TAP_SLOP;
|
|
244
|
+
if (isDouble) {
|
|
245
|
+
this.lastTap.t = 0;
|
|
246
|
+
if (this.region.area === "priceAxis" && this.region.pane) this.host.resetPriceScale(this.region.pane);
|
|
247
|
+
else this.host.resetView();
|
|
248
|
+
} else {
|
|
249
|
+
this.lastTap = { t, x: p.x, y: p.y };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// ── Pinch ───────────────────────────────────────────────────────────
|
|
253
|
+
pinchPoints() {
|
|
254
|
+
const [a, b] = [...this.pointers.values()];
|
|
255
|
+
return [a, b];
|
|
256
|
+
}
|
|
257
|
+
beginPinch() {
|
|
258
|
+
clearTimeout(this.longPressTimer);
|
|
259
|
+
this.host.clearCrosshair();
|
|
260
|
+
this.mode = "pinch";
|
|
261
|
+
const [a, b] = this.pinchPoints();
|
|
262
|
+
this.pinchDistance = dist(a, b);
|
|
263
|
+
this.pinchMid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
264
|
+
}
|
|
265
|
+
updatePinch() {
|
|
266
|
+
if (this.pointers.size < 2) return;
|
|
267
|
+
const [a, b] = this.pinchPoints();
|
|
268
|
+
const d = dist(a, b);
|
|
269
|
+
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
270
|
+
if (this.pinchDistance > 0 && d > 0) this.host.zoomTime(mid.x, d / this.pinchDistance);
|
|
271
|
+
this.host.panTime(mid.x - this.pinchMid.x);
|
|
272
|
+
this.pinchDistance = d;
|
|
273
|
+
this.pinchMid = mid;
|
|
274
|
+
}
|
|
275
|
+
endPinch() {
|
|
276
|
+
const remaining = [...this.pointers.values()][0];
|
|
277
|
+
if (remaining) {
|
|
278
|
+
this.mode = "pan";
|
|
279
|
+
this.moved = true;
|
|
280
|
+
this.last = remaining;
|
|
281
|
+
this.region = this.host.hitTest(remaining.x, remaining.y);
|
|
282
|
+
this.samples = [];
|
|
283
|
+
} else {
|
|
284
|
+
this.mode = "idle";
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// ── Inertia ─────────────────────────────────────────────────────────
|
|
288
|
+
startKinetic() {
|
|
289
|
+
const first = this.samples[0];
|
|
290
|
+
const last = this.samples[this.samples.length - 1];
|
|
291
|
+
if (!first || !last || last.t - first.t <= 0 || performance.now() - last.t > 50) return;
|
|
292
|
+
let velocity = (last.x - first.x) / (last.t - first.t);
|
|
293
|
+
if (Math.abs(velocity) < KINETIC_MIN_VELOCITY) return;
|
|
294
|
+
let prev = performance.now();
|
|
295
|
+
const step = (now) => {
|
|
296
|
+
const dt = Math.min(32, now - prev);
|
|
297
|
+
prev = now;
|
|
298
|
+
this.host.panTime(velocity * dt);
|
|
299
|
+
velocity *= KINETIC_FRICTION ** dt;
|
|
300
|
+
this.kineticFrame = Math.abs(velocity) > KINETIC_STOP_VELOCITY ? requestAnimationFrame(step) : 0;
|
|
301
|
+
};
|
|
302
|
+
this.kineticFrame = requestAnimationFrame(step);
|
|
303
|
+
}
|
|
304
|
+
stopKinetic() {
|
|
305
|
+
if (this.kineticFrame) cancelAnimationFrame(this.kineticFrame);
|
|
306
|
+
this.kineticFrame = 0;
|
|
307
|
+
}
|
|
308
|
+
// ── Wheel / misc ────────────────────────────────────────────────────
|
|
309
|
+
onWheel(e) {
|
|
310
|
+
e.preventDefault();
|
|
311
|
+
this.stopKinetic();
|
|
312
|
+
const p = this.local(e);
|
|
313
|
+
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? this.el.clientHeight : 1;
|
|
314
|
+
let dx = e.deltaX * unit;
|
|
315
|
+
const dy = e.deltaY * unit;
|
|
316
|
+
const region = this.host.hitTest(p.x, p.y);
|
|
317
|
+
if (region.area === "priceAxis" && region.pane) {
|
|
318
|
+
this.host.scalePrice(region.pane, Math.exp(dy * 2e-3));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (e.ctrlKey) {
|
|
322
|
+
this.host.zoomTime(p.x, Math.exp(-dy * PINCH_WHEEL_ZOOM_SPEED));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (e.shiftKey && dx === 0) dx = dy;
|
|
326
|
+
if (Math.abs(dx) > Math.abs(dy) || e.shiftKey) this.host.panTime(-dx);
|
|
327
|
+
else this.host.zoomTime(Math.min(p.x, this.host.plotWidth()), Math.exp(-dy * WHEEL_ZOOM_SPEED));
|
|
328
|
+
}
|
|
329
|
+
onDoubleClick(e) {
|
|
330
|
+
if (this.touch) return;
|
|
331
|
+
const p = this.local(e);
|
|
332
|
+
const region = this.host.hitTest(p.x, p.y);
|
|
333
|
+
if (region.area === "priceAxis" && region.pane) this.host.resetPriceScale(region.pane);
|
|
334
|
+
else if (region.area !== "none") this.host.resetView();
|
|
335
|
+
}
|
|
336
|
+
onTouchMove(e) {
|
|
337
|
+
if (this.mode !== "idle" && this.mode !== "pending") e.preventDefault();
|
|
338
|
+
}
|
|
339
|
+
onContextMenu(e) {
|
|
340
|
+
if (this.touch) e.preventDefault();
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// src/model/bar-store.ts
|
|
345
|
+
function normalize(bars) {
|
|
346
|
+
let sorted = true;
|
|
347
|
+
for (let i = 1; i < bars.length; i++) {
|
|
348
|
+
if (bars[i].time <= bars[i - 1].time) {
|
|
349
|
+
sorted = false;
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (sorted) return bars.slice();
|
|
354
|
+
const copy = bars.slice().sort((a, b) => a.time - b.time);
|
|
355
|
+
const out = [];
|
|
356
|
+
for (const bar of copy) {
|
|
357
|
+
if (out.length > 0 && out[out.length - 1].time === bar.time) out[out.length - 1] = bar;
|
|
358
|
+
else out.push(bar);
|
|
359
|
+
}
|
|
360
|
+
return out;
|
|
361
|
+
}
|
|
362
|
+
var BarStore = class {
|
|
363
|
+
items = [];
|
|
364
|
+
get bars() {
|
|
365
|
+
return this.items;
|
|
366
|
+
}
|
|
367
|
+
get length() {
|
|
368
|
+
return this.items.length;
|
|
369
|
+
}
|
|
370
|
+
at(index) {
|
|
371
|
+
return this.items[index];
|
|
372
|
+
}
|
|
373
|
+
first() {
|
|
374
|
+
return this.items[0];
|
|
375
|
+
}
|
|
376
|
+
last() {
|
|
377
|
+
return this.items[this.items.length - 1];
|
|
378
|
+
}
|
|
379
|
+
set(bars) {
|
|
380
|
+
this.items = normalize(bars);
|
|
381
|
+
}
|
|
382
|
+
/** Adds older bars in front. Bars not strictly older than the current first bar are dropped. Returns the count added. */
|
|
383
|
+
prepend(bars) {
|
|
384
|
+
const first = this.first();
|
|
385
|
+
const older = normalize(first ? bars.filter((b) => b.time < first.time) : bars);
|
|
386
|
+
if (older.length > 0) this.items = older.concat(this.items);
|
|
387
|
+
return older.length;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Newer than the last bar → append. Same time as any existing bar → update in place
|
|
391
|
+
* (late/out-of-order WebSocket messages can still correct a closed bar).
|
|
392
|
+
* Older time that doesn't exist → ignored (use `merge` to fill gaps).
|
|
393
|
+
*/
|
|
394
|
+
upsert(bar) {
|
|
395
|
+
const last = this.last();
|
|
396
|
+
if (!last || bar.time > last.time) {
|
|
397
|
+
this.items.push(bar);
|
|
398
|
+
return "append";
|
|
399
|
+
}
|
|
400
|
+
const index = bar.time === last.time ? this.items.length - 1 : this.indexOfTime(bar.time);
|
|
401
|
+
if (index < 0) return "ignored";
|
|
402
|
+
this.items[index] = bar;
|
|
403
|
+
return "update";
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Merges a batch that may overlap, extend, or fill gaps in the series
|
|
407
|
+
* (e.g. bars re-fetched after a WebSocket reconnect). Incoming bars win on equal time.
|
|
408
|
+
*/
|
|
409
|
+
merge(bars) {
|
|
410
|
+
if (bars.length === 0) return;
|
|
411
|
+
this.items = normalize(this.items.concat(bars));
|
|
412
|
+
}
|
|
413
|
+
/** Index of the bar containing `time` (largest bar.time <= time); -1 if before the first bar. */
|
|
414
|
+
indexAtOrBefore(time) {
|
|
415
|
+
let lo = 0;
|
|
416
|
+
let hi = this.items.length - 1;
|
|
417
|
+
let found = -1;
|
|
418
|
+
while (lo <= hi) {
|
|
419
|
+
const mid = lo + hi >> 1;
|
|
420
|
+
if (this.items[mid].time <= time) {
|
|
421
|
+
found = mid;
|
|
422
|
+
lo = mid + 1;
|
|
423
|
+
} else hi = mid - 1;
|
|
424
|
+
}
|
|
425
|
+
return found;
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Fractional index for any timestamp: interpolated between bars, extrapolated
|
|
429
|
+
* beyond either end using `interval` (ms per bar). Lets drawings anchor to time.
|
|
430
|
+
*/
|
|
431
|
+
indexFloatOfTime(time, interval) {
|
|
432
|
+
const n = this.items.length;
|
|
433
|
+
if (n === 0) return Number.NaN;
|
|
434
|
+
const first = this.items[0];
|
|
435
|
+
const last = this.items[n - 1];
|
|
436
|
+
if (time <= first.time) return (time - first.time) / interval;
|
|
437
|
+
if (time >= last.time) return n - 1 + (time - last.time) / interval;
|
|
438
|
+
const i = this.indexAtOrBefore(time);
|
|
439
|
+
const t0 = this.items[i].time;
|
|
440
|
+
const t1 = this.items[i + 1].time;
|
|
441
|
+
return i + (time - t0) / (t1 - t0);
|
|
442
|
+
}
|
|
443
|
+
/** Inverse of `indexFloatOfTime`. */
|
|
444
|
+
timeOfIndexFloat(index, interval) {
|
|
445
|
+
const n = this.items.length;
|
|
446
|
+
if (n === 0) return Number.NaN;
|
|
447
|
+
if (index <= 0) return this.items[0].time + index * interval;
|
|
448
|
+
if (index >= n - 1) return this.items[n - 1].time + (index - (n - 1)) * interval;
|
|
449
|
+
const i = Math.floor(index);
|
|
450
|
+
const t0 = this.items[i].time;
|
|
451
|
+
return t0 + (index - i) * (this.items[i + 1].time - t0);
|
|
452
|
+
}
|
|
453
|
+
/** Binary search for an exact timestamp; -1 when absent. */
|
|
454
|
+
indexOfTime(time) {
|
|
455
|
+
let lo = 0;
|
|
456
|
+
let hi = this.items.length - 1;
|
|
457
|
+
while (lo <= hi) {
|
|
458
|
+
const mid = lo + hi >> 1;
|
|
459
|
+
const t = this.items[mid].time;
|
|
460
|
+
if (t === time) return mid;
|
|
461
|
+
if (t < time) lo = mid + 1;
|
|
462
|
+
else hi = mid - 1;
|
|
463
|
+
}
|
|
464
|
+
return -1;
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
// src/price.ts
|
|
469
|
+
function decimalsOf(value) {
|
|
470
|
+
if (!Number.isFinite(value) || Number.isInteger(value)) return 0;
|
|
471
|
+
const [mantissa = "", exp] = value.toExponential().split("e");
|
|
472
|
+
const fraction = mantissa.split(".")[1]?.length ?? 0;
|
|
473
|
+
return Math.max(0, fraction - Number(exp));
|
|
474
|
+
}
|
|
475
|
+
function resolveTickSize(tickSize, price) {
|
|
476
|
+
return typeof tickSize === "function" ? tickSize(price) : tickSize;
|
|
477
|
+
}
|
|
478
|
+
function roundToTick(price, tickSize) {
|
|
479
|
+
const tick = resolveTickSize(tickSize, price);
|
|
480
|
+
if (!(tick > 0)) return price;
|
|
481
|
+
const decimals = decimalsOf(tick);
|
|
482
|
+
const scale = 10 ** decimals;
|
|
483
|
+
const scaledTick = Math.round(tick * scale);
|
|
484
|
+
const scaledPrice = Number((price * scale).toPrecision(15));
|
|
485
|
+
return Math.round(scaledPrice / scaledTick) * scaledTick / scale;
|
|
486
|
+
}
|
|
487
|
+
function krxTickSize(price) {
|
|
488
|
+
if (price < 2e3) return 1;
|
|
489
|
+
if (price < 5e3) return 5;
|
|
490
|
+
if (price < 2e4) return 10;
|
|
491
|
+
if (price < 5e4) return 50;
|
|
492
|
+
if (price < 2e5) return 100;
|
|
493
|
+
if (price < 5e5) return 500;
|
|
494
|
+
return 1e3;
|
|
495
|
+
}
|
|
496
|
+
function formatPrice(price, precision, { thousandsSeparator = true } = {}) {
|
|
497
|
+
if (!Number.isFinite(price)) return "";
|
|
498
|
+
const fixed = Math.abs(price).toFixed(precision);
|
|
499
|
+
const [int = "0", frac] = fixed.split(".");
|
|
500
|
+
const intPart = thousandsSeparator ? int.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : int;
|
|
501
|
+
const sign = price < 0 && Number(fixed) !== 0 ? "-" : "";
|
|
502
|
+
return sign + (frac ? `${intPart}.${frac}` : intPart);
|
|
503
|
+
}
|
|
504
|
+
var COMPACT_UNITS = {
|
|
505
|
+
ko: [
|
|
506
|
+
[1e12, "\uC870"],
|
|
507
|
+
[1e8, "\uC5B5"],
|
|
508
|
+
[1e4, "\uB9CC"]
|
|
509
|
+
],
|
|
510
|
+
en: [
|
|
511
|
+
[1e9, "B"],
|
|
512
|
+
[1e6, "M"],
|
|
513
|
+
[1e3, "K"]
|
|
514
|
+
]
|
|
515
|
+
};
|
|
516
|
+
function formatCompact(value, locale = "ko") {
|
|
517
|
+
if (!Number.isFinite(value)) return "";
|
|
518
|
+
const abs = Math.abs(value);
|
|
519
|
+
for (const [unit, suffix] of COMPACT_UNITS[locale]) {
|
|
520
|
+
if (abs >= unit) {
|
|
521
|
+
const scaled = value / unit;
|
|
522
|
+
const digits = Math.abs(scaled) < 10 ? 2 : Math.abs(scaled) < 100 ? 1 : 0;
|
|
523
|
+
return trimFractionZeros(formatPrice(scaled, digits)) + suffix;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return trimFractionZeros(formatPrice(value, abs < 10 && !Number.isInteger(value) ? 2 : 0));
|
|
527
|
+
}
|
|
528
|
+
function trimFractionZeros(s) {
|
|
529
|
+
return s.includes(".") ? s.replace(/\.?0+$/, "") : s;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// src/model/price-scale.ts
|
|
533
|
+
var PriceScale = class {
|
|
534
|
+
constructor(margins) {
|
|
535
|
+
this.margins = margins;
|
|
536
|
+
}
|
|
537
|
+
margins;
|
|
538
|
+
height = 0;
|
|
539
|
+
/** Pixels reserved at the top for overlays (e.g. a legend); margins apply below it. */
|
|
540
|
+
insetTop = 0;
|
|
541
|
+
autoScale = true;
|
|
542
|
+
lo = 0;
|
|
543
|
+
hi = 1;
|
|
544
|
+
get min() {
|
|
545
|
+
return this.lo;
|
|
546
|
+
}
|
|
547
|
+
get max() {
|
|
548
|
+
return this.hi;
|
|
549
|
+
}
|
|
550
|
+
setRange(min, max) {
|
|
551
|
+
if (!Number.isFinite(min) || !Number.isFinite(max)) return;
|
|
552
|
+
if (max < min) [min, max] = [max, min];
|
|
553
|
+
if (max - min < 1e-12) {
|
|
554
|
+
const pad = Math.abs(max) * 0.01 || 1;
|
|
555
|
+
min -= pad;
|
|
556
|
+
max += pad;
|
|
557
|
+
}
|
|
558
|
+
this.lo = min;
|
|
559
|
+
this.hi = max;
|
|
560
|
+
}
|
|
561
|
+
/** Height left after the inset (never less than half the pane, so tiny panes still show data). */
|
|
562
|
+
get available() {
|
|
563
|
+
return Math.max(this.height / 2, this.height - this.insetTop);
|
|
564
|
+
}
|
|
565
|
+
get innerTop() {
|
|
566
|
+
return this.height - this.available + this.available * this.margins.top;
|
|
567
|
+
}
|
|
568
|
+
get innerHeight() {
|
|
569
|
+
return Math.max(1, this.available * (1 - this.margins.top - this.margins.bottom));
|
|
570
|
+
}
|
|
571
|
+
priceToY(price) {
|
|
572
|
+
return this.innerTop + (this.hi - price) / (this.hi - this.lo) * this.innerHeight;
|
|
573
|
+
}
|
|
574
|
+
yToPrice(y) {
|
|
575
|
+
return this.hi - (y - this.innerTop) / this.innerHeight * (this.hi - this.lo);
|
|
576
|
+
}
|
|
577
|
+
/** Scales around the center. factor > 1 shows a wider price range. Disables auto-scale. */
|
|
578
|
+
zoom(factor) {
|
|
579
|
+
const center = (this.lo + this.hi) / 2;
|
|
580
|
+
const half = (this.hi - this.lo) / 2 * factor;
|
|
581
|
+
this.setRange(center - half, center + half);
|
|
582
|
+
this.autoScale = false;
|
|
583
|
+
}
|
|
584
|
+
/** Moves the content by `dy` pixels (positive = content moves down). Disables auto-scale. */
|
|
585
|
+
pan(dy) {
|
|
586
|
+
const delta = dy / this.innerHeight * (this.hi - this.lo);
|
|
587
|
+
this.setRange(this.lo + delta, this.hi + delta);
|
|
588
|
+
this.autoScale = false;
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
var NICE_MULTIPLIERS = [1, 2, 2.5, 5, 10];
|
|
592
|
+
function niceTicks(min, max, maxCount, minStep = 0) {
|
|
593
|
+
if (!(max > min) || maxCount < 1) return [];
|
|
594
|
+
const rough = (max - min) / maxCount;
|
|
595
|
+
const magnitude = 10 ** Math.floor(Math.log10(rough));
|
|
596
|
+
const fits = (step2) => minStep <= 0 || Math.abs(step2 / minStep - Math.round(step2 / minStep)) < 1e-6;
|
|
597
|
+
let step = Number.NaN;
|
|
598
|
+
for (let mag = magnitude; Number.isNaN(step); mag *= 10) {
|
|
599
|
+
for (const m of NICE_MULTIPLIERS) {
|
|
600
|
+
const candidate = m * mag;
|
|
601
|
+
if (candidate >= rough && candidate >= minStep && fits(candidate)) {
|
|
602
|
+
step = candidate;
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (step > rough * 1.6) {
|
|
608
|
+
for (const m of [...NICE_MULTIPLIERS].reverse()) {
|
|
609
|
+
const candidate = m * magnitude;
|
|
610
|
+
if (candidate < step && candidate >= rough * 0.75 && candidate >= minStep && fits(candidate)) {
|
|
611
|
+
step = candidate;
|
|
612
|
+
break;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
step = Number(step.toPrecision(12));
|
|
617
|
+
const decimals = decimalsOf(step);
|
|
618
|
+
const ticks = [];
|
|
619
|
+
const first = Math.ceil(min / step - 1e-9);
|
|
620
|
+
const last = Math.floor(max / step + 1e-9);
|
|
621
|
+
for (let k = first; k <= last && ticks.length < 1e3; k++) {
|
|
622
|
+
ticks.push(Number((k * step).toFixed(decimals)));
|
|
623
|
+
}
|
|
624
|
+
return ticks;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// src/model/time-format.ts
|
|
628
|
+
var WEEKDAY_INDEX = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
629
|
+
var WEEKDAYS = {
|
|
630
|
+
ko: ["\uC77C", "\uC6D4", "\uD654", "\uC218", "\uBAA9", "\uAE08", "\uD1A0"],
|
|
631
|
+
en: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
|
632
|
+
};
|
|
633
|
+
var MONTHS_EN = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
634
|
+
var CACHE_LIMIT = 1e4;
|
|
635
|
+
var pad2 = (n) => n < 10 ? `0${n}` : String(n);
|
|
636
|
+
function defaultTimeZone() {
|
|
637
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
638
|
+
}
|
|
639
|
+
var TimeFormatter = class {
|
|
640
|
+
constructor(timeZone, locale) {
|
|
641
|
+
this.timeZone = timeZone;
|
|
642
|
+
this.locale = locale;
|
|
643
|
+
this.dtf = new Intl.DateTimeFormat("en-US", {
|
|
644
|
+
timeZone,
|
|
645
|
+
hourCycle: "h23",
|
|
646
|
+
weekday: "short",
|
|
647
|
+
year: "numeric",
|
|
648
|
+
month: "numeric",
|
|
649
|
+
day: "numeric",
|
|
650
|
+
hour: "numeric",
|
|
651
|
+
minute: "numeric",
|
|
652
|
+
second: "numeric"
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
timeZone;
|
|
656
|
+
locale;
|
|
657
|
+
dtf;
|
|
658
|
+
cache = /* @__PURE__ */ new Map();
|
|
659
|
+
parts(time) {
|
|
660
|
+
const cached = this.cache.get(time);
|
|
661
|
+
if (cached) return cached;
|
|
662
|
+
const p = { year: 0, month: 0, day: 0, hour: 0, minute: 0, second: 0, weekday: 0 };
|
|
663
|
+
for (const { type, value } of this.dtf.formatToParts(time)) {
|
|
664
|
+
switch (type) {
|
|
665
|
+
case "year":
|
|
666
|
+
p.year = Number(value);
|
|
667
|
+
break;
|
|
668
|
+
case "month":
|
|
669
|
+
p.month = Number(value);
|
|
670
|
+
break;
|
|
671
|
+
case "day":
|
|
672
|
+
p.day = Number(value);
|
|
673
|
+
break;
|
|
674
|
+
case "hour":
|
|
675
|
+
p.hour = Number(value) % 24;
|
|
676
|
+
break;
|
|
677
|
+
case "minute":
|
|
678
|
+
p.minute = Number(value);
|
|
679
|
+
break;
|
|
680
|
+
case "second":
|
|
681
|
+
p.second = Number(value);
|
|
682
|
+
break;
|
|
683
|
+
case "weekday":
|
|
684
|
+
p.weekday = WEEKDAY_INDEX[value] ?? 0;
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
if (this.cache.size >= CACHE_LIMIT) this.cache.clear();
|
|
689
|
+
this.cache.set(time, p);
|
|
690
|
+
return p;
|
|
691
|
+
}
|
|
692
|
+
/** Full label for the crosshair, e.g. "2026.11.09 (월) 14:30". */
|
|
693
|
+
crosshairLabel(time, intraday) {
|
|
694
|
+
const p = this.parts(time);
|
|
695
|
+
const sep = this.locale === "ko" ? "." : "-";
|
|
696
|
+
const date = `${p.year}${sep}${pad2(p.month)}${sep}${pad2(p.day)} (${WEEKDAYS[this.locale][p.weekday]})`;
|
|
697
|
+
return intraday ? `${date} ${pad2(p.hour)}:${pad2(p.minute)}` : date;
|
|
698
|
+
}
|
|
699
|
+
tickLabel(p, weight) {
|
|
700
|
+
const ko = this.locale === "ko";
|
|
701
|
+
if (weight >= TickWeight.Year) return ko ? `${p.year}\uB144` : String(p.year);
|
|
702
|
+
if (weight >= TickWeight.Month) return ko ? `${p.month}\uC6D4` : MONTHS_EN[p.month - 1];
|
|
703
|
+
if (weight >= TickWeight.Day) return ko ? `${p.day}\uC77C` : String(p.day);
|
|
704
|
+
return `${pad2(p.hour)}:${pad2(p.minute)}`;
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
var TickWeight = {
|
|
708
|
+
Year: 60,
|
|
709
|
+
Month: 50,
|
|
710
|
+
Day: 40,
|
|
711
|
+
Hour: 30,
|
|
712
|
+
Minute30: 25,
|
|
713
|
+
Minute15: 22,
|
|
714
|
+
Minute5: 20,
|
|
715
|
+
Minute: 10,
|
|
716
|
+
None: 0
|
|
717
|
+
};
|
|
718
|
+
function boundaryWeight(prev, cur) {
|
|
719
|
+
if (!prev) return TickWeight.Minute;
|
|
720
|
+
if (cur.year !== prev.year) return TickWeight.Year;
|
|
721
|
+
if (cur.month !== prev.month) return TickWeight.Month;
|
|
722
|
+
if (cur.day !== prev.day) return TickWeight.Day;
|
|
723
|
+
if (cur.hour !== prev.hour) return TickWeight.Hour;
|
|
724
|
+
if (cur.minute !== prev.minute) {
|
|
725
|
+
if (cur.minute % 30 === 0) return TickWeight.Minute30;
|
|
726
|
+
if (cur.minute % 15 === 0) return TickWeight.Minute15;
|
|
727
|
+
if (cur.minute % 5 === 0) return TickWeight.Minute5;
|
|
728
|
+
return TickWeight.Minute;
|
|
729
|
+
}
|
|
730
|
+
return TickWeight.None;
|
|
731
|
+
}
|
|
732
|
+
function computeTimeTicks(bars, timeScale, formatter, minGap) {
|
|
733
|
+
const { from, to } = timeScale.visibleRange();
|
|
734
|
+
if (to < from) return [];
|
|
735
|
+
const candidates = [];
|
|
736
|
+
let prev = from > 0 ? formatter.parts(bars[from - 1].time) : null;
|
|
737
|
+
for (let i = from; i <= to; i++) {
|
|
738
|
+
const parts = formatter.parts(bars[i].time);
|
|
739
|
+
const weight = boundaryWeight(prev, parts);
|
|
740
|
+
if (weight > TickWeight.None) candidates.push({ index: i, weight, parts });
|
|
741
|
+
prev = parts;
|
|
742
|
+
}
|
|
743
|
+
candidates.sort((a, b) => b.weight - a.weight || a.index - b.index);
|
|
744
|
+
const edge = minGap / 3;
|
|
745
|
+
const picked = [];
|
|
746
|
+
for (const c of candidates) {
|
|
747
|
+
const x = timeScale.indexToX(c.index);
|
|
748
|
+
if (x < edge || x > timeScale.width - edge) continue;
|
|
749
|
+
if (picked.some((t) => Math.abs(t.x - x) < minGap)) continue;
|
|
750
|
+
picked.push({ index: c.index, x, label: formatter.tickLabel(c.parts, c.weight) });
|
|
751
|
+
}
|
|
752
|
+
return picked.sort((a, b) => a.index - b.index);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// src/model/time-scale.ts
|
|
756
|
+
var DEFAULT_TIME_SCALE_OPTIONS = {
|
|
757
|
+
barSpacing: 8,
|
|
758
|
+
minBarSpacing: 0.5,
|
|
759
|
+
maxBarSpacing: 60,
|
|
760
|
+
rightOffset: 5
|
|
761
|
+
};
|
|
762
|
+
var clamp = (v, min, max) => Math.min(max, Math.max(min, v));
|
|
763
|
+
var TimeScale = class {
|
|
764
|
+
constructor(options = DEFAULT_TIME_SCALE_OPTIONS) {
|
|
765
|
+
this.options = options;
|
|
766
|
+
this.barSpacing = options.barSpacing;
|
|
767
|
+
this.rightOffset = options.rightOffset;
|
|
768
|
+
}
|
|
769
|
+
options;
|
|
770
|
+
width = 0;
|
|
771
|
+
barCount = 0;
|
|
772
|
+
barSpacing;
|
|
773
|
+
rightOffset;
|
|
774
|
+
/** Logical (fractional) bar index at the right edge. */
|
|
775
|
+
get rightIndex() {
|
|
776
|
+
return this.barCount - 1 + this.rightOffset;
|
|
777
|
+
}
|
|
778
|
+
/** Center x of bar `index`. */
|
|
779
|
+
indexToX(index) {
|
|
780
|
+
return this.width - (this.rightIndex - index + 0.5) * this.barSpacing;
|
|
781
|
+
}
|
|
782
|
+
xToIndexFloat(x) {
|
|
783
|
+
return this.rightIndex - (this.width - x) / this.barSpacing + 0.5;
|
|
784
|
+
}
|
|
785
|
+
xToIndex(x) {
|
|
786
|
+
return Math.round(this.xToIndexFloat(x));
|
|
787
|
+
}
|
|
788
|
+
/** Visible bar indices clamped to existing data. `to < from` when nothing is visible. */
|
|
789
|
+
visibleRange() {
|
|
790
|
+
if (this.barCount === 0 || this.width <= 0) return { from: 0, to: -1 };
|
|
791
|
+
return {
|
|
792
|
+
from: Math.max(0, Math.round(this.xToIndexFloat(0))),
|
|
793
|
+
to: Math.min(this.barCount - 1, Math.round(this.xToIndexFloat(this.width)))
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
pan(dx) {
|
|
797
|
+
this.rightOffset -= dx / this.barSpacing;
|
|
798
|
+
this.clamp();
|
|
799
|
+
}
|
|
800
|
+
/** Zooms by `factor` keeping the bar under `anchorX` fixed. */
|
|
801
|
+
zoom(anchorX, factor) {
|
|
802
|
+
const anchorIndex = this.xToIndexFloat(anchorX);
|
|
803
|
+
this.barSpacing = clamp(this.barSpacing * factor, this.options.minBarSpacing, this.options.maxBarSpacing);
|
|
804
|
+
const rightIndex = anchorIndex + (this.width - anchorX) / this.barSpacing - 0.5;
|
|
805
|
+
this.rightOffset = rightIndex - (this.barCount - 1);
|
|
806
|
+
this.clamp();
|
|
807
|
+
}
|
|
808
|
+
reset() {
|
|
809
|
+
this.barSpacing = this.options.barSpacing;
|
|
810
|
+
this.rightOffset = this.options.rightOffset;
|
|
811
|
+
this.clamp();
|
|
812
|
+
}
|
|
813
|
+
fitContent() {
|
|
814
|
+
if (this.barCount === 0 || this.width <= 0) return;
|
|
815
|
+
this.barSpacing = clamp(
|
|
816
|
+
this.width / (this.barCount + this.options.rightOffset),
|
|
817
|
+
this.options.minBarSpacing,
|
|
818
|
+
this.options.maxBarSpacing
|
|
819
|
+
);
|
|
820
|
+
this.rightOffset = this.options.rightOffset;
|
|
821
|
+
this.clamp();
|
|
822
|
+
}
|
|
823
|
+
/** Keeps at least a couple of bars on screen. */
|
|
824
|
+
clamp() {
|
|
825
|
+
this.barSpacing = clamp(this.barSpacing, this.options.minBarSpacing, this.options.maxBarSpacing);
|
|
826
|
+
if (this.barCount === 0 || this.width <= 0) return;
|
|
827
|
+
const minBars = Math.min(this.barCount, 2);
|
|
828
|
+
const minOffset = minBars - this.barCount;
|
|
829
|
+
const maxOffset = Math.max(minOffset, this.width / this.barSpacing - minBars);
|
|
830
|
+
this.rightOffset = clamp(this.rightOffset, minOffset, maxOffset);
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
// src/indicators/math.ts
|
|
835
|
+
function sma(src2, period) {
|
|
836
|
+
const out = new Float64Array(src2.length).fill(Number.NaN);
|
|
837
|
+
if (period < 1) return out;
|
|
838
|
+
let sum = 0;
|
|
839
|
+
let valid = 0;
|
|
840
|
+
for (let i = 0; i < src2.length; i++) {
|
|
841
|
+
const v = src2[i];
|
|
842
|
+
if (Number.isNaN(v)) {
|
|
843
|
+
sum = 0;
|
|
844
|
+
valid = 0;
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
sum += v;
|
|
848
|
+
valid++;
|
|
849
|
+
if (valid > period) sum -= src2[i - period];
|
|
850
|
+
if (valid >= period) out[i] = sum / period;
|
|
851
|
+
}
|
|
852
|
+
return out;
|
|
853
|
+
}
|
|
854
|
+
function ema(src2, period) {
|
|
855
|
+
const out = new Float64Array(src2.length).fill(Number.NaN);
|
|
856
|
+
if (period < 1) return out;
|
|
857
|
+
const k = 2 / (period + 1);
|
|
858
|
+
let prev = Number.NaN;
|
|
859
|
+
let seedSum = 0;
|
|
860
|
+
let seedCount = 0;
|
|
861
|
+
for (let i = 0; i < src2.length; i++) {
|
|
862
|
+
const v = src2[i];
|
|
863
|
+
if (Number.isNaN(v)) continue;
|
|
864
|
+
if (Number.isNaN(prev)) {
|
|
865
|
+
seedSum += v;
|
|
866
|
+
seedCount++;
|
|
867
|
+
if (seedCount === period) out[i] = prev = seedSum / period;
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
out[i] = prev = v * k + prev * (1 - k);
|
|
871
|
+
}
|
|
872
|
+
return out;
|
|
873
|
+
}
|
|
874
|
+
function stdev(src2, period) {
|
|
875
|
+
const out = new Float64Array(src2.length).fill(Number.NaN);
|
|
876
|
+
const mean = sma(src2, period);
|
|
877
|
+
for (let i = period - 1; i < src2.length; i++) {
|
|
878
|
+
const m = mean[i];
|
|
879
|
+
if (Number.isNaN(m)) continue;
|
|
880
|
+
let acc = 0;
|
|
881
|
+
for (let j = i - period + 1; j <= i; j++) acc += (src2[j] - m) ** 2;
|
|
882
|
+
out[i] = Math.sqrt(acc / period);
|
|
883
|
+
}
|
|
884
|
+
return out;
|
|
885
|
+
}
|
|
886
|
+
function bollinger(src2, period, mult) {
|
|
887
|
+
const middle = sma(src2, period);
|
|
888
|
+
const sd = stdev(src2, period);
|
|
889
|
+
const upper = new Float64Array(src2.length);
|
|
890
|
+
const lower = new Float64Array(src2.length);
|
|
891
|
+
for (let i = 0; i < src2.length; i++) {
|
|
892
|
+
upper[i] = middle[i] + mult * sd[i];
|
|
893
|
+
lower[i] = middle[i] - mult * sd[i];
|
|
894
|
+
}
|
|
895
|
+
return { middle, upper, lower };
|
|
896
|
+
}
|
|
897
|
+
function rsi(src2, period) {
|
|
898
|
+
const out = new Float64Array(src2.length).fill(Number.NaN);
|
|
899
|
+
if (period < 1 || src2.length <= period) return out;
|
|
900
|
+
let gain = 0;
|
|
901
|
+
let loss = 0;
|
|
902
|
+
for (let i = 1; i <= period; i++) {
|
|
903
|
+
const d = src2[i] - src2[i - 1];
|
|
904
|
+
if (d > 0) gain += d;
|
|
905
|
+
else loss -= d;
|
|
906
|
+
}
|
|
907
|
+
gain /= period;
|
|
908
|
+
loss /= period;
|
|
909
|
+
const value = () => loss === 0 ? gain === 0 ? 50 : 100 : 100 - 100 / (1 + gain / loss);
|
|
910
|
+
out[period] = value();
|
|
911
|
+
for (let i = period + 1; i < src2.length; i++) {
|
|
912
|
+
const d = src2[i] - src2[i - 1];
|
|
913
|
+
gain = (gain * (period - 1) + Math.max(d, 0)) / period;
|
|
914
|
+
loss = (loss * (period - 1) + Math.max(-d, 0)) / period;
|
|
915
|
+
out[i] = value();
|
|
916
|
+
}
|
|
917
|
+
return out;
|
|
918
|
+
}
|
|
919
|
+
function macd(src2, fast, slow, signal) {
|
|
920
|
+
const fastEma = ema(src2, fast);
|
|
921
|
+
const slowEma = ema(src2, slow);
|
|
922
|
+
const line2 = new Float64Array(src2.length);
|
|
923
|
+
for (let i = 0; i < src2.length; i++) line2[i] = fastEma[i] - slowEma[i];
|
|
924
|
+
const signalLine = ema(line2, signal);
|
|
925
|
+
const histogram = new Float64Array(src2.length);
|
|
926
|
+
for (let i = 0; i < src2.length; i++) histogram[i] = line2[i] - signalLine[i];
|
|
927
|
+
return { macd: line2, signal: signalLine, histogram };
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// src/indicators/definitions.ts
|
|
931
|
+
var PRICE_SOURCES = [
|
|
932
|
+
{ value: "close", label: { ko: "\uC885\uAC00", en: "Close" } },
|
|
933
|
+
{ value: "open", label: { ko: "\uC2DC\uAC00", en: "Open" } },
|
|
934
|
+
{ value: "high", label: { ko: "\uACE0\uAC00", en: "High" } },
|
|
935
|
+
{ value: "low", label: { ko: "\uC800\uAC00", en: "Low" } },
|
|
936
|
+
{ value: "hl2", label: { ko: "(\uACE0+\uC800)/2", en: "(H+L)/2" } },
|
|
937
|
+
{ value: "hlc3", label: { ko: "(\uACE0+\uC800+\uC885)/3", en: "(H+L+C)/3" } },
|
|
938
|
+
{ value: "ohlc4", label: { ko: "(\uC2DC+\uACE0+\uC800+\uC885)/4", en: "(O+H+L+C)/4" } }
|
|
939
|
+
];
|
|
940
|
+
var SERIES_COLORS = ["#12B886", "#FF9F0A", "#7048E8", "#8B95A1", "#F06595", "#15AABF", "#3182F6", "#795548"];
|
|
941
|
+
function priceSource(bars, source = "close") {
|
|
942
|
+
switch (source) {
|
|
943
|
+
case "open":
|
|
944
|
+
return Float64Array.from(bars, (b) => b.open);
|
|
945
|
+
case "high":
|
|
946
|
+
return Float64Array.from(bars, (b) => b.high);
|
|
947
|
+
case "low":
|
|
948
|
+
return Float64Array.from(bars, (b) => b.low);
|
|
949
|
+
case "hl2":
|
|
950
|
+
return Float64Array.from(bars, (b) => (b.high + b.low) / 2);
|
|
951
|
+
case "hlc3":
|
|
952
|
+
return Float64Array.from(bars, (b) => (b.high + b.low + b.close) / 3);
|
|
953
|
+
case "ohlc4":
|
|
954
|
+
return Float64Array.from(bars, (b) => (b.open + b.high + b.low + b.close) / 4);
|
|
955
|
+
default:
|
|
956
|
+
return Float64Array.from(bars, (b) => b.close);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
var num = (p, key) => Number(p[key]);
|
|
960
|
+
var src = (p) => typeof p.source === "string" ? p.source : "close";
|
|
961
|
+
var SOURCE_SHORT = { close: "", open: "O", high: "H", low: "L", hl2: "HL2", hlc3: "HLC3", ohlc4: "OHLC4" };
|
|
962
|
+
function maLines(p) {
|
|
963
|
+
const raw = Array.isArray(p.lines) ? p.lines : Array.isArray(p.periods) ? p.periods.map((period) => ({ period, source: "close" })) : [];
|
|
964
|
+
return raw.map((l) => ({ period: Math.round(Number(l.period)), source: l.source ?? "close" })).filter((l) => Number.isFinite(l.period) && l.period > 0);
|
|
965
|
+
}
|
|
966
|
+
var line = (key, label, values, color) => ({
|
|
967
|
+
key,
|
|
968
|
+
label,
|
|
969
|
+
values,
|
|
970
|
+
color,
|
|
971
|
+
width: 1,
|
|
972
|
+
visible: true,
|
|
973
|
+
kind: "line"
|
|
974
|
+
});
|
|
975
|
+
function movingAverages(bars, p, prefix, fn, colors) {
|
|
976
|
+
const cache = /* @__PURE__ */ new Map();
|
|
977
|
+
return {
|
|
978
|
+
series: maLines(p).map((l, i) => {
|
|
979
|
+
let values = cache.get(l.source);
|
|
980
|
+
if (!values) cache.set(l.source, values = priceSource(bars, l.source));
|
|
981
|
+
const suffix = SOURCE_SHORT[l.source] ? `(${SOURCE_SHORT[l.source]})` : "";
|
|
982
|
+
return line(`l${i}`, `${prefix}${l.period}${suffix}`, fn(values, l.period), colors[i % colors.length]);
|
|
983
|
+
}),
|
|
984
|
+
precision: null
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
var PARAM = (ko, en) => ({ ko, en });
|
|
988
|
+
var SOURCE_PARAM = { key: "source", label: PARAM("\uAC00\uACA9 \uAE30\uC900", "Source"), kind: "source" };
|
|
989
|
+
var INDICATORS = {
|
|
990
|
+
ma: {
|
|
991
|
+
type: "ma",
|
|
992
|
+
name: { ko: "\uC774\uB3D9\uD3C9\uADE0\uC120", en: "Moving Average" },
|
|
993
|
+
description: { ko: "\uB2E8\uC21C \uC774\uB3D9\uD3C9\uADE0 (5\xB720\xB760\xB7120)", en: "Simple moving averages" },
|
|
994
|
+
overlay: true,
|
|
995
|
+
params: [{ key: "lines", label: PARAM("\uAE30\uAC04", "Period"), kind: "lines", min: 1, max: 1e3 }],
|
|
996
|
+
defaults: {
|
|
997
|
+
lines: [5, 20, 60, 120].map((period) => ({ period, source: "close" }))
|
|
998
|
+
},
|
|
999
|
+
title: (p) => `MA ${maLines(p).map((l) => l.period).join("\xB7")}`,
|
|
1000
|
+
compute: (bars, p) => movingAverages(bars, p, "MA", sma, SERIES_COLORS)
|
|
1001
|
+
},
|
|
1002
|
+
ema: {
|
|
1003
|
+
type: "ema",
|
|
1004
|
+
name: { ko: "\uC9C0\uC218\uC774\uB3D9\uD3C9\uADE0", en: "Exponential MA" },
|
|
1005
|
+
description: { ko: "\uCD5C\uADFC \uAC00\uACA9\uC5D0 \uAC00\uC911\uCE58\uB97C \uB454 \uC774\uB3D9\uD3C9\uADE0", en: "Moving average weighted to recent prices" },
|
|
1006
|
+
overlay: true,
|
|
1007
|
+
params: [{ key: "lines", label: PARAM("\uAE30\uAC04", "Period"), kind: "lines", min: 1, max: 1e3 }],
|
|
1008
|
+
defaults: { lines: [12, 26].map((period) => ({ period, source: "close" })) },
|
|
1009
|
+
title: (p) => `EMA ${maLines(p).map((l) => l.period).join("\xB7")}`,
|
|
1010
|
+
compute: (bars, p) => movingAverages(bars, p, "EMA", ema, ["#3182F6", "#F06595", "#15AABF", "#FF9F0A"])
|
|
1011
|
+
},
|
|
1012
|
+
bb: {
|
|
1013
|
+
type: "bb",
|
|
1014
|
+
name: { ko: "\uBCFC\uB9B0\uC800\uBC34\uB4DC", en: "Bollinger Bands" },
|
|
1015
|
+
description: { ko: "\uC774\uB3D9\uD3C9\uADE0 \xB1 \uD45C\uC900\uD3B8\uCC28 \uBC34\uB4DC", en: "Moving average \xB1 standard deviations" },
|
|
1016
|
+
overlay: true,
|
|
1017
|
+
params: [
|
|
1018
|
+
{ key: "period", label: PARAM("\uAE30\uAC04", "Period"), kind: "number", min: 2, max: 500 },
|
|
1019
|
+
{ key: "mult", label: PARAM("\uC2B9\uC218", "Multiplier"), kind: "number", min: 0.1, max: 10, step: 0.1 },
|
|
1020
|
+
SOURCE_PARAM
|
|
1021
|
+
],
|
|
1022
|
+
defaults: { period: 20, mult: 2, source: "close" },
|
|
1023
|
+
title: (p) => `BB ${num(p, "period")} ${num(p, "mult")}`,
|
|
1024
|
+
compute: (bars, p) => {
|
|
1025
|
+
const { middle, upper, lower } = bollinger(priceSource(bars, src(p)), num(p, "period"), num(p, "mult"));
|
|
1026
|
+
const color = "#3182F6";
|
|
1027
|
+
return {
|
|
1028
|
+
series: [
|
|
1029
|
+
line("upper", "Upper", upper, color),
|
|
1030
|
+
line("middle", "Basis", middle, "#FF9F0A"),
|
|
1031
|
+
line("lower", "Lower", lower, color)
|
|
1032
|
+
],
|
|
1033
|
+
fill: { upper: "upper", lower: "lower", color },
|
|
1034
|
+
precision: null
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
},
|
|
1038
|
+
rsi: {
|
|
1039
|
+
type: "rsi",
|
|
1040
|
+
name: { ko: "RSI", en: "RSI" },
|
|
1041
|
+
description: { ko: "\uC0C1\uB300\uAC15\uB3C4\uC9C0\uC218 (\uACFC\uB9E4\uC218 70 / \uACFC\uB9E4\uB3C4 30)", en: "Relative Strength Index (70 / 30)" },
|
|
1042
|
+
overlay: false,
|
|
1043
|
+
params: [{ key: "period", label: PARAM("\uAE30\uAC04", "Period"), kind: "number", min: 2, max: 200 }, SOURCE_PARAM],
|
|
1044
|
+
defaults: { period: 14, source: "close" },
|
|
1045
|
+
title: (p) => `RSI ${num(p, "period")}`,
|
|
1046
|
+
compute: (bars, p) => ({
|
|
1047
|
+
series: [line("rsi", "RSI", rsi(priceSource(bars, src(p)), num(p, "period")), "#7048E8")],
|
|
1048
|
+
levels: [30, 70],
|
|
1049
|
+
range: [0, 100],
|
|
1050
|
+
precision: 2
|
|
1051
|
+
})
|
|
1052
|
+
},
|
|
1053
|
+
macd: {
|
|
1054
|
+
type: "macd",
|
|
1055
|
+
name: { ko: "MACD", en: "MACD" },
|
|
1056
|
+
description: { ko: "\uB2E8\uAE30\xB7\uC7A5\uAE30 \uC9C0\uC218\uC774\uB3D9\uD3C9\uADE0\uC758 \uCC28\uC774\uC640 \uC2DC\uADF8\uB110", en: "Fast/slow EMA spread with a signal line" },
|
|
1057
|
+
overlay: false,
|
|
1058
|
+
params: [
|
|
1059
|
+
{ key: "fast", label: PARAM("\uB2E8\uAE30", "Fast"), kind: "number", min: 1, max: 200 },
|
|
1060
|
+
{ key: "slow", label: PARAM("\uC7A5\uAE30", "Slow"), kind: "number", min: 1, max: 400 },
|
|
1061
|
+
{ key: "signal", label: PARAM("\uC2DC\uADF8\uB110", "Signal"), kind: "number", min: 1, max: 200 },
|
|
1062
|
+
SOURCE_PARAM
|
|
1063
|
+
],
|
|
1064
|
+
defaults: { fast: 12, slow: 26, signal: 9, source: "close" },
|
|
1065
|
+
title: (p) => `MACD ${num(p, "fast")} ${num(p, "slow")} ${num(p, "signal")}`,
|
|
1066
|
+
compute: (bars, p) => {
|
|
1067
|
+
const m = macd(priceSource(bars, src(p)), num(p, "fast"), num(p, "slow"), num(p, "signal"));
|
|
1068
|
+
return {
|
|
1069
|
+
series: [
|
|
1070
|
+
line("macd", "MACD", m.macd, "#3182F6"),
|
|
1071
|
+
line("signal", "Signal", m.signal, "#FF9F0A"),
|
|
1072
|
+
// Histograms are always painted beneath lines regardless of order here.
|
|
1073
|
+
{ ...line("hist", "Hist", m.histogram, ""), kind: "histogram" }
|
|
1074
|
+
],
|
|
1075
|
+
precision: null
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
// src/objects/types.ts
|
|
1082
|
+
var counter = 0;
|
|
1083
|
+
var newId = (prefix) => `${prefix}_${Date.now().toString(36)}${(counter++).toString(36)}`;
|
|
1084
|
+
|
|
1085
|
+
// src/indicators/manager.ts
|
|
1086
|
+
var clone = (c) => JSON.parse(JSON.stringify(c));
|
|
1087
|
+
function normalize2(c) {
|
|
1088
|
+
const def = INDICATORS[c.type];
|
|
1089
|
+
const params = { ...def.defaults, ...c.params };
|
|
1090
|
+
if ((c.type === "ma" || c.type === "ema") && !Array.isArray(c.params.lines) && Array.isArray(c.params.periods)) {
|
|
1091
|
+
params.lines = maLines({ periods: c.params.periods });
|
|
1092
|
+
}
|
|
1093
|
+
delete params.periods;
|
|
1094
|
+
return { id: c.id || newId(c.type), type: c.type, params, visible: c.visible ?? true, styles: c.styles ?? {} };
|
|
1095
|
+
}
|
|
1096
|
+
var IndicatorManager = class {
|
|
1097
|
+
list = [];
|
|
1098
|
+
cache = /* @__PURE__ */ new Map();
|
|
1099
|
+
add(type, params) {
|
|
1100
|
+
if (!INDICATORS[type]) throw new Error(`funnest-charts: unknown indicator "${type}"`);
|
|
1101
|
+
const config = normalize2({ id: newId(type), type, params: params ?? {} });
|
|
1102
|
+
this.list.push(config);
|
|
1103
|
+
return config.id;
|
|
1104
|
+
}
|
|
1105
|
+
remove(id) {
|
|
1106
|
+
const before = this.list.length;
|
|
1107
|
+
this.list = this.list.filter((c) => c.id !== id);
|
|
1108
|
+
this.cache.delete(id);
|
|
1109
|
+
return this.list.length !== before;
|
|
1110
|
+
}
|
|
1111
|
+
update(id, patch) {
|
|
1112
|
+
const cfg = this.list.find((c) => c.id === id);
|
|
1113
|
+
if (!cfg) return false;
|
|
1114
|
+
if (patch.params) cfg.params = { ...cfg.params, ...patch.params };
|
|
1115
|
+
if (patch.styles) cfg.styles = { ...patch.styles };
|
|
1116
|
+
if (patch.visible !== void 0) cfg.visible = patch.visible;
|
|
1117
|
+
return true;
|
|
1118
|
+
}
|
|
1119
|
+
set(configs) {
|
|
1120
|
+
this.list = configs.filter((c) => INDICATORS[c.type]).map((c) => normalize2(clone(c)));
|
|
1121
|
+
this.cache.clear();
|
|
1122
|
+
}
|
|
1123
|
+
snapshot() {
|
|
1124
|
+
return this.list.map(clone);
|
|
1125
|
+
}
|
|
1126
|
+
get overlays() {
|
|
1127
|
+
return this.list.filter((c) => INDICATORS[c.type].overlay);
|
|
1128
|
+
}
|
|
1129
|
+
get panes() {
|
|
1130
|
+
return this.list.filter((c) => !INDICATORS[c.type].overlay);
|
|
1131
|
+
}
|
|
1132
|
+
/** Computed series with user styles applied; recomputed only when data or settings change. */
|
|
1133
|
+
output(cfg, bars, dataVersion) {
|
|
1134
|
+
const key = `${dataVersion}|${JSON.stringify(cfg.params)}|${JSON.stringify(cfg.styles)}`;
|
|
1135
|
+
const hit = this.cache.get(cfg.id);
|
|
1136
|
+
if (hit?.key === key) return hit.output;
|
|
1137
|
+
const raw = INDICATORS[cfg.type].compute(bars, cfg.params);
|
|
1138
|
+
const output = {
|
|
1139
|
+
...raw,
|
|
1140
|
+
series: raw.series.map((s) => {
|
|
1141
|
+
const style = cfg.styles?.[s.key];
|
|
1142
|
+
return {
|
|
1143
|
+
...s,
|
|
1144
|
+
color: style?.color ?? s.color,
|
|
1145
|
+
width: style?.width ?? s.width,
|
|
1146
|
+
visible: style?.visible ?? s.visible
|
|
1147
|
+
};
|
|
1148
|
+
})
|
|
1149
|
+
};
|
|
1150
|
+
this.cache.set(cfg.id, { key, output });
|
|
1151
|
+
return output;
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
// src/objects/drawings.ts
|
|
1156
|
+
var DRAWING_POINTS = { trendline: 2, horizontal: 1, fibonacci: 2 };
|
|
1157
|
+
var FIB_LEVELS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
|
|
1158
|
+
var FIB_COLORS = ["#8B95A1", "#F04452", "#FF9F0A", "#12B886", "#3182F6", "#7048E8", "#8B95A1"];
|
|
1159
|
+
var HANDLE_RADIUS = 5;
|
|
1160
|
+
function distToSegment(px, py, x1, y1, x2, y2) {
|
|
1161
|
+
const dx = x2 - x1;
|
|
1162
|
+
const dy = y2 - y1;
|
|
1163
|
+
const len2 = dx * dx + dy * dy;
|
|
1164
|
+
const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / len2));
|
|
1165
|
+
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
|
|
1166
|
+
}
|
|
1167
|
+
var DrawingManager = class {
|
|
1168
|
+
constructor(onChange) {
|
|
1169
|
+
this.onChange = onChange;
|
|
1170
|
+
}
|
|
1171
|
+
onChange;
|
|
1172
|
+
drawings = [];
|
|
1173
|
+
tool = null;
|
|
1174
|
+
selectedId = null;
|
|
1175
|
+
draft = null;
|
|
1176
|
+
drag = null;
|
|
1177
|
+
get selected() {
|
|
1178
|
+
return this.drawings.find((d) => d.id === this.selectedId) ?? null;
|
|
1179
|
+
}
|
|
1180
|
+
setTool(tool) {
|
|
1181
|
+
this.tool = tool;
|
|
1182
|
+
this.draft = null;
|
|
1183
|
+
if (tool) this.selectedId = null;
|
|
1184
|
+
this.onChange("tool");
|
|
1185
|
+
}
|
|
1186
|
+
select(id) {
|
|
1187
|
+
if (id === this.selectedId) return;
|
|
1188
|
+
this.selectedId = id;
|
|
1189
|
+
this.onChange("selection");
|
|
1190
|
+
}
|
|
1191
|
+
set(drawings) {
|
|
1192
|
+
this.drawings = drawings.map((d) => ({ ...d, points: d.points.map((p) => ({ ...p })) }));
|
|
1193
|
+
if (!this.selected) this.selectedId = null;
|
|
1194
|
+
this.onChange("commit");
|
|
1195
|
+
}
|
|
1196
|
+
update(id, patch) {
|
|
1197
|
+
const d = this.drawings.find((x) => x.id === id);
|
|
1198
|
+
if (!d) return;
|
|
1199
|
+
Object.assign(d, patch);
|
|
1200
|
+
this.onChange("commit");
|
|
1201
|
+
}
|
|
1202
|
+
remove(id) {
|
|
1203
|
+
const before = this.drawings.length;
|
|
1204
|
+
this.drawings = this.drawings.filter((d) => d.id !== id);
|
|
1205
|
+
if (this.selectedId === id) this.selectedId = null;
|
|
1206
|
+
if (this.drawings.length !== before) this.onChange("commit");
|
|
1207
|
+
}
|
|
1208
|
+
clear() {
|
|
1209
|
+
this.drawings = [];
|
|
1210
|
+
this.selectedId = null;
|
|
1211
|
+
this.draft = null;
|
|
1212
|
+
this.onChange("commit");
|
|
1213
|
+
}
|
|
1214
|
+
/** Esc: abort an in-progress drawing, else drop the tool, else deselect. */
|
|
1215
|
+
cancel() {
|
|
1216
|
+
if (this.draft || this.tool) this.setTool(null);
|
|
1217
|
+
else this.select(null);
|
|
1218
|
+
}
|
|
1219
|
+
// ── Creation ──────────────────────────────────────────────────────
|
|
1220
|
+
createAt(c, x, y) {
|
|
1221
|
+
if (!this.tool) return;
|
|
1222
|
+
const point = c.pointAt(x, y);
|
|
1223
|
+
if (!this.draft) {
|
|
1224
|
+
const count = DRAWING_POINTS[this.tool];
|
|
1225
|
+
const drawing = { id: newId("d"), type: this.tool, points: Array.from({ length: count }, () => ({ ...point })) };
|
|
1226
|
+
this.draft = { drawing, placed: 1 };
|
|
1227
|
+
if (count === 1) this.finish();
|
|
1228
|
+
else this.onChange("preview");
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
this.draft.drawing.points[this.draft.placed] = point;
|
|
1232
|
+
this.draft.placed++;
|
|
1233
|
+
if (this.draft.placed >= this.draft.drawing.points.length) this.finish();
|
|
1234
|
+
}
|
|
1235
|
+
/** Moves the not-yet-placed points (live preview while hovering or dragging). */
|
|
1236
|
+
previewAt(c, x, y) {
|
|
1237
|
+
if (!this.draft) return;
|
|
1238
|
+
const point = c.pointAt(x, y);
|
|
1239
|
+
for (let i = this.draft.placed; i < this.draft.drawing.points.length; i++) this.draft.drawing.points[i] = { ...point };
|
|
1240
|
+
this.onChange("preview");
|
|
1241
|
+
}
|
|
1242
|
+
/** Pointer released: a drag since the first point places the next one (drag-to-draw). */
|
|
1243
|
+
releaseAt(c, x, y, moved) {
|
|
1244
|
+
if (this.draft && moved) this.createAt(c, x, y);
|
|
1245
|
+
}
|
|
1246
|
+
finish() {
|
|
1247
|
+
const drawing = this.draft.drawing;
|
|
1248
|
+
this.draft = null;
|
|
1249
|
+
this.drawings.push(drawing);
|
|
1250
|
+
this.selectedId = drawing.id;
|
|
1251
|
+
this.tool = null;
|
|
1252
|
+
this.onChange("tool");
|
|
1253
|
+
this.onChange("commit");
|
|
1254
|
+
}
|
|
1255
|
+
// ── Editing ───────────────────────────────────────────────────────
|
|
1256
|
+
hitTest(c, x, y, tolerance) {
|
|
1257
|
+
const selected = this.selected;
|
|
1258
|
+
if (selected) {
|
|
1259
|
+
const handle = this.screenPoints(c, selected).findIndex(
|
|
1260
|
+
([px, py]) => Math.hypot(px - x, py - y) <= HANDLE_RADIUS + tolerance
|
|
1261
|
+
);
|
|
1262
|
+
if (handle >= 0) return { kind: "drawing", id: selected.id, part: handle };
|
|
1263
|
+
}
|
|
1264
|
+
for (let i = this.drawings.length - 1; i >= 0; i--) {
|
|
1265
|
+
const d = this.drawings[i];
|
|
1266
|
+
if (this.distance(c, d, x, y) <= tolerance) return { kind: "drawing", id: d.id, part: "body" };
|
|
1267
|
+
}
|
|
1268
|
+
return null;
|
|
1269
|
+
}
|
|
1270
|
+
beginDrag(c, id, part, x, y) {
|
|
1271
|
+
const d = this.drawings.find((x2) => x2.id === id);
|
|
1272
|
+
if (!d) return;
|
|
1273
|
+
this.select(id);
|
|
1274
|
+
this.drag = { id, part, x, y, origin: this.screenPoints(c, d) };
|
|
1275
|
+
}
|
|
1276
|
+
dragTo(c, x, y) {
|
|
1277
|
+
const drag = this.drag;
|
|
1278
|
+
const d = drag && this.drawings.find((x2) => x2.id === drag.id);
|
|
1279
|
+
if (!drag || !d) return;
|
|
1280
|
+
if (typeof drag.part === "number") {
|
|
1281
|
+
d.points[drag.part] = c.pointAt(x, y);
|
|
1282
|
+
} else {
|
|
1283
|
+
const dx = x - drag.x;
|
|
1284
|
+
const dy = y - drag.y;
|
|
1285
|
+
d.points = drag.origin.map(([ox, oy]) => c.pointAt(ox + dx, oy + dy));
|
|
1286
|
+
}
|
|
1287
|
+
this.onChange("preview");
|
|
1288
|
+
}
|
|
1289
|
+
endDrag(moved) {
|
|
1290
|
+
if (this.drag && moved) this.onChange("commit");
|
|
1291
|
+
this.drag = null;
|
|
1292
|
+
}
|
|
1293
|
+
screenPoints(c, d) {
|
|
1294
|
+
return d.points.map((p) => [d.type === "horizontal" ? Math.min(c.plotWidth - 24, Math.max(24, c.timeToX(p.time))) : c.timeToX(p.time), c.priceToY(p.price)]);
|
|
1295
|
+
}
|
|
1296
|
+
distance(c, d, x, y) {
|
|
1297
|
+
const pts = this.screenPoints(c, d);
|
|
1298
|
+
const [x1, y1] = pts[0];
|
|
1299
|
+
if (d.type === "horizontal") return Math.abs(y - y1);
|
|
1300
|
+
const [x2, y2] = pts[1];
|
|
1301
|
+
if (d.type === "trendline") return distToSegment(x, y, x1, y1, x2, y2);
|
|
1302
|
+
const left = Math.min(x1, x2);
|
|
1303
|
+
const right = Math.max(x1, x2);
|
|
1304
|
+
let best = distToSegment(x, y, x1, y1, x2, y2);
|
|
1305
|
+
if (x >= left && x <= right) {
|
|
1306
|
+
for (const level of FIB_LEVELS) best = Math.min(best, Math.abs(y - (y2 + (y1 - y2) * level)));
|
|
1307
|
+
}
|
|
1308
|
+
return best;
|
|
1309
|
+
}
|
|
1310
|
+
// ── Rendering ─────────────────────────────────────────────────────
|
|
1311
|
+
render(rc, badges) {
|
|
1312
|
+
const all = this.draft ? [...this.drawings, this.draft.drawing] : this.drawings;
|
|
1313
|
+
for (const d of all) {
|
|
1314
|
+
const isDraft = d === this.draft?.drawing;
|
|
1315
|
+
this.renderOne(rc, d, badges);
|
|
1316
|
+
if (d.id === this.selectedId || isDraft) {
|
|
1317
|
+
const color = d.color ?? rc.palette.accent;
|
|
1318
|
+
for (const [px, py] of this.screenPoints(rc.coords, d)) rc.painter.circle(px, py, HANDLE_RADIUS, rc.palette.background, color);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
renderOne({ painter: p, coords: c, palette }, d, badges) {
|
|
1323
|
+
const color = d.color ?? palette.accent;
|
|
1324
|
+
const pts = this.screenPoints(c, d);
|
|
1325
|
+
const [x1, y1] = pts[0];
|
|
1326
|
+
if (d.type === "horizontal") {
|
|
1327
|
+
p.hLine(y1, 0, c.plotWidth, color);
|
|
1328
|
+
badges.push({ y: y1, text: c.formatPrice(d.points[0].price), bg: color, fg: "#FFFFFF" });
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
const [x2, y2] = pts[1];
|
|
1332
|
+
if (d.type === "trendline") {
|
|
1333
|
+
p.line(x1, y1, x2, y2, color, 2);
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
const left = Math.min(x1, x2);
|
|
1337
|
+
const right = Math.max(x1, x2, left + 1);
|
|
1338
|
+
const p0 = d.points[0].price;
|
|
1339
|
+
const p1 = d.points[1].price;
|
|
1340
|
+
const { ctx } = p;
|
|
1341
|
+
FIB_LEVELS.forEach((level, i) => {
|
|
1342
|
+
const price = p1 + (p0 - p1) * level;
|
|
1343
|
+
const y = c.priceToY(price);
|
|
1344
|
+
const levelColor = d.color ?? FIB_COLORS[i];
|
|
1345
|
+
if (i > 0) {
|
|
1346
|
+
const prevY = c.priceToY(p1 + (p0 - p1) * FIB_LEVELS[i - 1]);
|
|
1347
|
+
ctx.save();
|
|
1348
|
+
ctx.globalAlpha = 0.07;
|
|
1349
|
+
ctx.fillStyle = levelColor;
|
|
1350
|
+
ctx.fillRect(left, Math.min(y, prevY), right - left, Math.abs(y - prevY));
|
|
1351
|
+
ctx.restore();
|
|
1352
|
+
}
|
|
1353
|
+
p.hLine(y, left, right, levelColor);
|
|
1354
|
+
p.text(`${level} (${c.formatPrice(price)})`, left + 4, y - 7, levelColor, "left", "middle");
|
|
1355
|
+
});
|
|
1356
|
+
p.line(x1, y1, x2, y2, palette.textSecondary, 1, [4, 4]);
|
|
1357
|
+
}
|
|
1358
|
+
};
|
|
1359
|
+
|
|
1360
|
+
// src/render/painter.ts
|
|
1361
|
+
var Painter = class {
|
|
1362
|
+
constructor(ctx, dpr) {
|
|
1363
|
+
this.ctx = ctx;
|
|
1364
|
+
this.dpr = dpr;
|
|
1365
|
+
}
|
|
1366
|
+
ctx;
|
|
1367
|
+
dpr;
|
|
1368
|
+
/** Thickness of a "1px" hairline: one whole device pixel (or more on high-DPR screens). */
|
|
1369
|
+
get hairline() {
|
|
1370
|
+
return Math.max(1, Math.floor(this.dpr)) / this.dpr;
|
|
1371
|
+
}
|
|
1372
|
+
snap(v) {
|
|
1373
|
+
return Math.floor(v * this.dpr) / this.dpr;
|
|
1374
|
+
}
|
|
1375
|
+
hLine(y, x1, x2, color, dash) {
|
|
1376
|
+
const { ctx } = this;
|
|
1377
|
+
const y0 = this.snap(y);
|
|
1378
|
+
const t = this.hairline;
|
|
1379
|
+
ctx.fillStyle = color;
|
|
1380
|
+
if (!dash) {
|
|
1381
|
+
ctx.fillRect(x1, y0, x2 - x1, t);
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
const [on, off] = dash;
|
|
1385
|
+
for (let x = x1; x < x2; x += on + off) ctx.fillRect(this.snap(x), y0, Math.min(on, x2 - x), t);
|
|
1386
|
+
}
|
|
1387
|
+
vLine(x, y1, y2, color, dash) {
|
|
1388
|
+
const { ctx } = this;
|
|
1389
|
+
const x0 = this.snap(x);
|
|
1390
|
+
const t = this.hairline;
|
|
1391
|
+
ctx.fillStyle = color;
|
|
1392
|
+
if (!dash) {
|
|
1393
|
+
ctx.fillRect(x0, y1, t, y2 - y1);
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
const [on, off] = dash;
|
|
1397
|
+
for (let y = y1; y < y2; y += on + off) ctx.fillRect(x0, this.snap(y), t, Math.min(on, y2 - y));
|
|
1398
|
+
}
|
|
1399
|
+
text(str, x, y, color, align = "left", baseline = "middle") {
|
|
1400
|
+
const { ctx } = this;
|
|
1401
|
+
ctx.fillStyle = color;
|
|
1402
|
+
ctx.textAlign = align;
|
|
1403
|
+
ctx.textBaseline = baseline;
|
|
1404
|
+
ctx.fillText(str, x, y);
|
|
1405
|
+
}
|
|
1406
|
+
roundRect(x, y, w, h, r, color) {
|
|
1407
|
+
const { ctx } = this;
|
|
1408
|
+
ctx.fillStyle = color;
|
|
1409
|
+
ctx.beginPath();
|
|
1410
|
+
ctx.roundRect(this.snap(x), this.snap(y), this.snap(w), this.snap(h), r);
|
|
1411
|
+
ctx.fill();
|
|
1412
|
+
}
|
|
1413
|
+
measure(str) {
|
|
1414
|
+
return this.ctx.measureText(str).width;
|
|
1415
|
+
}
|
|
1416
|
+
/** Anti-aliased line for arbitrary angles (trendlines). */
|
|
1417
|
+
line(x1, y1, x2, y2, color, width = 1, dash) {
|
|
1418
|
+
const { ctx } = this;
|
|
1419
|
+
ctx.save();
|
|
1420
|
+
ctx.strokeStyle = color;
|
|
1421
|
+
ctx.lineWidth = width;
|
|
1422
|
+
ctx.lineCap = "round";
|
|
1423
|
+
if (dash) ctx.setLineDash(dash);
|
|
1424
|
+
ctx.beginPath();
|
|
1425
|
+
ctx.moveTo(x1, y1);
|
|
1426
|
+
ctx.lineTo(x2, y2);
|
|
1427
|
+
ctx.stroke();
|
|
1428
|
+
ctx.restore();
|
|
1429
|
+
}
|
|
1430
|
+
circle(x, y, r, fill, stroke, strokeWidth = 1.5) {
|
|
1431
|
+
const { ctx } = this;
|
|
1432
|
+
ctx.beginPath();
|
|
1433
|
+
ctx.arc(x, y, r, 0, Math.PI * 2);
|
|
1434
|
+
ctx.fillStyle = fill;
|
|
1435
|
+
ctx.fill();
|
|
1436
|
+
if (stroke) {
|
|
1437
|
+
ctx.strokeStyle = stroke;
|
|
1438
|
+
ctx.lineWidth = strokeWidth;
|
|
1439
|
+
ctx.stroke();
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* A row of joined label segments ("pill") centered vertically on `y`.
|
|
1444
|
+
* Returns each segment's rect for hit testing (e.g. an order's cancel button).
|
|
1445
|
+
*/
|
|
1446
|
+
pill(segments, x, y, height = 20, radius = 4) {
|
|
1447
|
+
const { ctx } = this;
|
|
1448
|
+
const rects = [];
|
|
1449
|
+
let cx = x;
|
|
1450
|
+
const top = y - height / 2;
|
|
1451
|
+
ctx.textBaseline = "middle";
|
|
1452
|
+
ctx.textAlign = "left";
|
|
1453
|
+
segments.forEach((seg, i) => {
|
|
1454
|
+
const w = Math.ceil(this.measure(seg.text)) + (seg.padding ?? 8) * 2;
|
|
1455
|
+
const r = [i === 0 ? radius : 0, i === segments.length - 1 ? radius : 0];
|
|
1456
|
+
ctx.fillStyle = seg.bg;
|
|
1457
|
+
ctx.beginPath();
|
|
1458
|
+
ctx.roundRect(cx, top, w, height, [r[0], r[1], r[1], r[0]]);
|
|
1459
|
+
ctx.fill();
|
|
1460
|
+
ctx.fillStyle = seg.fg;
|
|
1461
|
+
ctx.fillText(seg.text, cx + (seg.padding ?? 8), y);
|
|
1462
|
+
rects.push({ x: cx, y: top, w, h: height });
|
|
1463
|
+
cx += w;
|
|
1464
|
+
});
|
|
1465
|
+
return rects;
|
|
1466
|
+
}
|
|
1467
|
+
};
|
|
1468
|
+
var inRect = (r, x, y, pad = 0) => x >= r.x - pad && x <= r.x + r.w + pad && y >= r.y - pad && y <= r.y + r.h + pad;
|
|
1469
|
+
|
|
1470
|
+
// src/objects/trading.ts
|
|
1471
|
+
var LABEL_X = 12;
|
|
1472
|
+
var LABEL_HEIGHT = 20;
|
|
1473
|
+
var ORDER_DASH = [6, 4];
|
|
1474
|
+
var TradingLayer = class {
|
|
1475
|
+
constructor(onChange) {
|
|
1476
|
+
this.onChange = onChange;
|
|
1477
|
+
}
|
|
1478
|
+
onChange;
|
|
1479
|
+
positions = /* @__PURE__ */ new Map();
|
|
1480
|
+
orders = /* @__PURE__ */ new Map();
|
|
1481
|
+
executions = [];
|
|
1482
|
+
labelRects = /* @__PURE__ */ new Map();
|
|
1483
|
+
drag = null;
|
|
1484
|
+
groups = [];
|
|
1485
|
+
addPosition(options) {
|
|
1486
|
+
const id = newId("pos");
|
|
1487
|
+
this.positions.set(id, { ...options });
|
|
1488
|
+
this.onChange();
|
|
1489
|
+
return this.handle(id, this.positions);
|
|
1490
|
+
}
|
|
1491
|
+
addOrder(options) {
|
|
1492
|
+
const id = newId("ord");
|
|
1493
|
+
this.orders.set(id, { ...options });
|
|
1494
|
+
this.onChange();
|
|
1495
|
+
return this.handle(id, this.orders);
|
|
1496
|
+
}
|
|
1497
|
+
setExecutions(markers) {
|
|
1498
|
+
this.executions = markers.slice();
|
|
1499
|
+
this.onChange();
|
|
1500
|
+
}
|
|
1501
|
+
handle(id, map) {
|
|
1502
|
+
return {
|
|
1503
|
+
id,
|
|
1504
|
+
update: (patch) => {
|
|
1505
|
+
const current = map.get(id);
|
|
1506
|
+
if (!current) return;
|
|
1507
|
+
map.set(id, { ...current, ...patch });
|
|
1508
|
+
this.onChange();
|
|
1509
|
+
},
|
|
1510
|
+
remove: () => {
|
|
1511
|
+
if (map.delete(id)) this.onChange();
|
|
1512
|
+
}
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
get executionList() {
|
|
1516
|
+
return this.executions;
|
|
1517
|
+
}
|
|
1518
|
+
executionGroup(key) {
|
|
1519
|
+
return this.groups.find((g) => g.key === key);
|
|
1520
|
+
}
|
|
1521
|
+
// ── Interaction ───────────────────────────────────────────────────
|
|
1522
|
+
hitTest(priceToY, x, y, tolerance) {
|
|
1523
|
+
for (const g of this.groups) {
|
|
1524
|
+
if (Math.abs(g.x - x) <= 6 + tolerance / 2 && Math.abs(g.y - y) <= 6 + tolerance / 2) return { kind: "execution", key: g.key };
|
|
1525
|
+
}
|
|
1526
|
+
for (const [id, order] of this.orders) {
|
|
1527
|
+
const rects = this.labelRects.get(id);
|
|
1528
|
+
if (rects?.cancel && inRect(rects.cancel, x, y, tolerance / 2)) return { kind: "order", id, part: "cancel" };
|
|
1529
|
+
if (!order.onMove) continue;
|
|
1530
|
+
const lineY = priceToY(this.drag?.id === id ? this.drag.price : order.price);
|
|
1531
|
+
if (rects && inRect(rects.label, x, y, tolerance / 2) || Math.abs(y - lineY) <= tolerance) {
|
|
1532
|
+
return { kind: "order", id, part: "line" };
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
return null;
|
|
1536
|
+
}
|
|
1537
|
+
beginDrag(id) {
|
|
1538
|
+
const order = this.orders.get(id);
|
|
1539
|
+
if (order?.onMove) this.drag = { id, price: order.price };
|
|
1540
|
+
}
|
|
1541
|
+
dragTo(price) {
|
|
1542
|
+
if (!this.drag) return;
|
|
1543
|
+
this.drag.price = price;
|
|
1544
|
+
this.onChange();
|
|
1545
|
+
}
|
|
1546
|
+
endDrag() {
|
|
1547
|
+
const drag = this.drag;
|
|
1548
|
+
this.drag = null;
|
|
1549
|
+
const order = drag && this.orders.get(drag.id);
|
|
1550
|
+
if (!drag || !order || drag.price === order.price) {
|
|
1551
|
+
this.onChange();
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
const previous = order.price;
|
|
1555
|
+
order.price = drag.price;
|
|
1556
|
+
this.onChange();
|
|
1557
|
+
const revert = () => {
|
|
1558
|
+
const current = this.orders.get(drag.id);
|
|
1559
|
+
if (current && current.price === drag.price) {
|
|
1560
|
+
current.price = previous;
|
|
1561
|
+
this.onChange();
|
|
1562
|
+
}
|
|
1563
|
+
};
|
|
1564
|
+
try {
|
|
1565
|
+
Promise.resolve(order.onMove?.(drag.price)).then((ok) => ok === false && revert(), revert);
|
|
1566
|
+
} catch {
|
|
1567
|
+
revert();
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
cancel(id) {
|
|
1571
|
+
this.orders.get(id)?.onCancel?.();
|
|
1572
|
+
}
|
|
1573
|
+
// ── Rendering ─────────────────────────────────────────────────────
|
|
1574
|
+
render({ painter: p, coords: c, palette }, bars, barIndexAt, indexToX, labels, badges) {
|
|
1575
|
+
this.labelRects.clear();
|
|
1576
|
+
const last = bars[bars.length - 1];
|
|
1577
|
+
const visible = (y) => y >= 0 && y <= c.height;
|
|
1578
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
1579
|
+
for (const e of this.executions) {
|
|
1580
|
+
const index = barIndexAt(e.time);
|
|
1581
|
+
const bar = bars[index];
|
|
1582
|
+
if (!bar) continue;
|
|
1583
|
+
const key = `${bar.time}:${e.side}`;
|
|
1584
|
+
let g = byKey.get(key);
|
|
1585
|
+
if (!g) {
|
|
1586
|
+
const buy = e.side === "buy";
|
|
1587
|
+
g = {
|
|
1588
|
+
key,
|
|
1589
|
+
side: e.side,
|
|
1590
|
+
count: 0,
|
|
1591
|
+
quantity: 0,
|
|
1592
|
+
averagePrice: 0,
|
|
1593
|
+
time: e.time,
|
|
1594
|
+
x: indexToX(index),
|
|
1595
|
+
y: buy ? c.priceToY(bar.low) + 10 : c.priceToY(bar.high) - 10,
|
|
1596
|
+
weighted: 0,
|
|
1597
|
+
weight: 0
|
|
1598
|
+
};
|
|
1599
|
+
byKey.set(key, g);
|
|
1600
|
+
}
|
|
1601
|
+
const q = e.quantity ?? 1;
|
|
1602
|
+
g.count++;
|
|
1603
|
+
g.quantity += e.quantity ?? 0;
|
|
1604
|
+
g.weighted += e.price * q;
|
|
1605
|
+
g.weight += q;
|
|
1606
|
+
g.time = Math.min(g.time, e.time);
|
|
1607
|
+
}
|
|
1608
|
+
this.groups = [...byKey.values()].map(({ weighted, weight, ...g }) => ({ ...g, averagePrice: weighted / weight }));
|
|
1609
|
+
const { ctx } = p;
|
|
1610
|
+
for (const g of this.groups) {
|
|
1611
|
+
if (g.x < -10 || g.x > c.plotWidth + 10) continue;
|
|
1612
|
+
const buy = g.side === "buy";
|
|
1613
|
+
ctx.beginPath();
|
|
1614
|
+
ctx.moveTo(g.x, buy ? g.y - 5 : g.y + 5);
|
|
1615
|
+
ctx.lineTo(g.x - 5, buy ? g.y + 4 : g.y - 4);
|
|
1616
|
+
ctx.lineTo(g.x + 5, buy ? g.y + 4 : g.y - 4);
|
|
1617
|
+
ctx.closePath();
|
|
1618
|
+
ctx.fillStyle = buy ? palette.up : palette.down;
|
|
1619
|
+
ctx.fill();
|
|
1620
|
+
if (g.count > 1) p.text(String(g.count), g.x + 7, g.y, buy ? palette.up : palette.down, "left", "middle");
|
|
1621
|
+
}
|
|
1622
|
+
for (const pos of this.positions.values()) {
|
|
1623
|
+
const y = c.priceToY(pos.price);
|
|
1624
|
+
badges.push({ y, text: c.formatPrice(pos.price), bg: palette.text, fg: palette.background });
|
|
1625
|
+
if (!visible(y)) continue;
|
|
1626
|
+
p.hLine(y, 0, c.plotWidth, palette.text);
|
|
1627
|
+
const segments = [
|
|
1628
|
+
{ text: `${pos.label ?? labels.position} \xB7 ${pos.quantity}`, bg: palette.text, fg: palette.background }
|
|
1629
|
+
];
|
|
1630
|
+
if (last) {
|
|
1631
|
+
const pnl = (last.close - pos.price) * pos.quantity * (pos.side === "long" ? 1 : -1);
|
|
1632
|
+
const pct = pos.price ? (last.close - pos.price) / pos.price * 100 * (pos.side === "long" ? 1 : -1) : 0;
|
|
1633
|
+
const sign = pnl > 0 ? "+" : "";
|
|
1634
|
+
segments.push({
|
|
1635
|
+
text: `${sign}${c.formatPrice(pnl)} (${sign}${pct.toFixed(2)}%)`,
|
|
1636
|
+
bg: pnl >= 0 ? palette.up : palette.down,
|
|
1637
|
+
fg: "#FFFFFF"
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
p.pill(segments, LABEL_X, y, LABEL_HEIGHT);
|
|
1641
|
+
}
|
|
1642
|
+
for (const [id, order] of this.orders) {
|
|
1643
|
+
const price = this.drag?.id === id ? this.drag.price : order.price;
|
|
1644
|
+
const y = c.priceToY(price);
|
|
1645
|
+
const color = order.side === "buy" ? palette.up : palette.down;
|
|
1646
|
+
badges.push({ y, text: c.formatPrice(price), bg: color, fg: "#FFFFFF" });
|
|
1647
|
+
if (!visible(y)) continue;
|
|
1648
|
+
p.hLine(y, 0, c.plotWidth, color, ORDER_DASH);
|
|
1649
|
+
const side = order.side === "buy" ? labels.buy : labels.sell;
|
|
1650
|
+
const main = [side, order.label, String(order.quantity)].filter(Boolean).join(" ");
|
|
1651
|
+
const segments = [{ text: main, bg: color, fg: "#FFFFFF" }];
|
|
1652
|
+
if (order.onCancel) segments.push({ text: "\xD7", bg: color, fg: "#FFFFFF", padding: 7 });
|
|
1653
|
+
const rects = p.pill(segments, LABEL_X, y, LABEL_HEIGHT);
|
|
1654
|
+
if (order.onCancel && rects[1]) p.vLine(rects[1].x, y - 6, y + 6, "rgba(255,255,255,0.4)");
|
|
1655
|
+
this.labelRects.set(id, {
|
|
1656
|
+
label: { x: rects[0].x, y: rects[0].y, w: rects.reduce((w, r) => w + r.w, 0), h: LABEL_HEIGHT },
|
|
1657
|
+
cancel: order.onCancel ? rects[1] : null
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
};
|
|
1662
|
+
|
|
1663
|
+
// src/objects/magnet.ts
|
|
1664
|
+
function snapToOhlc(bar, y, priceToY) {
|
|
1665
|
+
let best = bar.close;
|
|
1666
|
+
let bestDistance = Infinity;
|
|
1667
|
+
for (const price of [bar.open, bar.high, bar.low, bar.close]) {
|
|
1668
|
+
const d = Math.abs(priceToY(price) - y);
|
|
1669
|
+
if (d < bestDistance) {
|
|
1670
|
+
bestDistance = d;
|
|
1671
|
+
best = price;
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
return best;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
// src/settings.ts
|
|
1678
|
+
var DEFAULT_CHART_SETTINGS = {
|
|
1679
|
+
highLowMarkers: true,
|
|
1680
|
+
lastPriceLine: true,
|
|
1681
|
+
gridHorizontal: true,
|
|
1682
|
+
gridVertical: true,
|
|
1683
|
+
volume: true,
|
|
1684
|
+
upColor: null,
|
|
1685
|
+
downColor: null
|
|
1686
|
+
};
|
|
1687
|
+
var CHART_TYPES = ["candles", "line", "area"];
|
|
1688
|
+
function parseChartState(input) {
|
|
1689
|
+
const s = input;
|
|
1690
|
+
if (!s || typeof s !== "object" || s.version !== 1) throw new Error("funnest-charts: unsupported chart state (expected version 1)");
|
|
1691
|
+
return {
|
|
1692
|
+
version: 1,
|
|
1693
|
+
symbol: typeof s.symbol === "string" ? s.symbol : null,
|
|
1694
|
+
resolution: typeof s.resolution === "string" ? s.resolution : null,
|
|
1695
|
+
chartType: CHART_TYPES.includes(s.chartType) ? s.chartType : "candles",
|
|
1696
|
+
settings: { ...DEFAULT_CHART_SETTINGS, ...s.settings ?? {} },
|
|
1697
|
+
indicators: Array.isArray(s.indicators) ? s.indicators : [],
|
|
1698
|
+
drawings: s.drawings && typeof s.drawings === "object" ? s.drawings : {},
|
|
1699
|
+
view: {
|
|
1700
|
+
barSpacing: Number(s.view?.barSpacing) || 8,
|
|
1701
|
+
rightOffset: Number.isFinite(Number(s.view?.rightOffset)) ? Number(s.view?.rightOffset) : 5
|
|
1702
|
+
}
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// src/objects/trading-sync.ts
|
|
1707
|
+
var executionKey = (e) => e.id ?? `${e.time}|${e.side}|${e.price}|${e.quantity ?? ""}`;
|
|
1708
|
+
var TradingSync = class {
|
|
1709
|
+
constructor(layer, onError) {
|
|
1710
|
+
this.layer = layer;
|
|
1711
|
+
this.onError = onError;
|
|
1712
|
+
}
|
|
1713
|
+
layer;
|
|
1714
|
+
onError;
|
|
1715
|
+
adapter = null;
|
|
1716
|
+
token = 0;
|
|
1717
|
+
symbol = null;
|
|
1718
|
+
unsubscribe = null;
|
|
1719
|
+
positions = [];
|
|
1720
|
+
orders = /* @__PURE__ */ new Map();
|
|
1721
|
+
executions = /* @__PURE__ */ new Map();
|
|
1722
|
+
loadedFrom = Infinity;
|
|
1723
|
+
/** (Re)loads everything for a symbol whose bars start at `from`. */
|
|
1724
|
+
async load(symbol, from) {
|
|
1725
|
+
const token = ++this.token;
|
|
1726
|
+
this.reset();
|
|
1727
|
+
this.symbol = symbol;
|
|
1728
|
+
const a = this.adapter;
|
|
1729
|
+
if (!a) return;
|
|
1730
|
+
try {
|
|
1731
|
+
const [positions, orders, executions] = await Promise.all([
|
|
1732
|
+
a.getPositions ? a.getPositions(symbol) : Promise.resolve([]),
|
|
1733
|
+
a.getOrders ? a.getOrders(symbol) : Promise.resolve([]),
|
|
1734
|
+
a.getExecutions ? a.getExecutions(symbol, { from, to: Date.now() }) : Promise.resolve([])
|
|
1735
|
+
]);
|
|
1736
|
+
if (token !== this.token) return;
|
|
1737
|
+
this.loadedFrom = from;
|
|
1738
|
+
this.setPositions(positions);
|
|
1739
|
+
this.setOrders(orders);
|
|
1740
|
+
this.addExecutions(executions);
|
|
1741
|
+
this.unsubscribe = a.subscribe?.(symbol, {
|
|
1742
|
+
onExecution: (e) => token === this.token && this.addExecutions([e]),
|
|
1743
|
+
onPositions: (p) => token === this.token && this.setPositions(p),
|
|
1744
|
+
onOrders: (o) => token === this.token && this.setOrders(o)
|
|
1745
|
+
}) ?? null;
|
|
1746
|
+
} catch (error) {
|
|
1747
|
+
if (token === this.token) this.onError(error);
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
/** Fetches fills for newly loaded older history (bars now start at `from`). */
|
|
1751
|
+
async loadOlder(from) {
|
|
1752
|
+
const a = this.adapter;
|
|
1753
|
+
const symbol = this.symbol;
|
|
1754
|
+
if (!a?.getExecutions || !symbol || from >= this.loadedFrom) return;
|
|
1755
|
+
const token = this.token;
|
|
1756
|
+
const to = this.loadedFrom - 1;
|
|
1757
|
+
this.loadedFrom = from;
|
|
1758
|
+
try {
|
|
1759
|
+
const older = await a.getExecutions(symbol, { from, to });
|
|
1760
|
+
if (token === this.token) this.addExecutions(older);
|
|
1761
|
+
} catch (error) {
|
|
1762
|
+
this.onError(error);
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
reset() {
|
|
1766
|
+
this.unsubscribe?.();
|
|
1767
|
+
this.unsubscribe = null;
|
|
1768
|
+
this.positions.forEach((h) => h.remove());
|
|
1769
|
+
this.positions = [];
|
|
1770
|
+
this.orders.forEach(({ handle }) => handle.remove());
|
|
1771
|
+
this.orders.clear();
|
|
1772
|
+
this.executions.clear();
|
|
1773
|
+
this.loadedFrom = Infinity;
|
|
1774
|
+
this.layer.setExecutions([]);
|
|
1775
|
+
}
|
|
1776
|
+
/** Drops the adapter's lines and stops updates (e.g. adapter removed, chart destroyed). */
|
|
1777
|
+
detach() {
|
|
1778
|
+
this.token++;
|
|
1779
|
+
this.reset();
|
|
1780
|
+
}
|
|
1781
|
+
setPositions(list) {
|
|
1782
|
+
this.positions.forEach((h) => h.remove());
|
|
1783
|
+
this.positions = list.map((p) => this.layer.addPosition({ ...p }));
|
|
1784
|
+
}
|
|
1785
|
+
setOrders(list) {
|
|
1786
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1787
|
+
for (const info of list) {
|
|
1788
|
+
seen.add(info.id);
|
|
1789
|
+
const existing = this.orders.get(info.id);
|
|
1790
|
+
if (existing) {
|
|
1791
|
+
existing.info = info;
|
|
1792
|
+
existing.handle.update({ price: info.price, quantity: info.quantity, side: info.side, ...info.label ? { label: info.label } : {} });
|
|
1793
|
+
} else {
|
|
1794
|
+
const entry = { info, handle: this.layer.addOrder(this.orderOptions(info)) };
|
|
1795
|
+
this.orders.set(info.id, entry);
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
for (const [id, { handle }] of this.orders) {
|
|
1799
|
+
if (seen.has(id)) continue;
|
|
1800
|
+
handle.remove();
|
|
1801
|
+
this.orders.delete(id);
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
orderOptions(info) {
|
|
1805
|
+
const a = this.adapter;
|
|
1806
|
+
const opts = { price: info.price, quantity: info.quantity, side: info.side };
|
|
1807
|
+
if (info.label) opts.label = info.label;
|
|
1808
|
+
const current = () => this.orders.get(info.id)?.info ?? info;
|
|
1809
|
+
if (a.modifyOrder) {
|
|
1810
|
+
opts.onMove = async (price) => {
|
|
1811
|
+
const ok = await a.modifyOrder(current(), price);
|
|
1812
|
+
const entry = this.orders.get(info.id);
|
|
1813
|
+
if (ok !== false && entry) entry.info = { ...entry.info, price };
|
|
1814
|
+
return ok;
|
|
1815
|
+
};
|
|
1816
|
+
}
|
|
1817
|
+
if (a.cancelOrder) {
|
|
1818
|
+
opts.onCancel = () => {
|
|
1819
|
+
Promise.resolve(a.cancelOrder(current())).then(() => {
|
|
1820
|
+
if (a.subscribe) return;
|
|
1821
|
+
this.orders.get(info.id)?.handle.remove();
|
|
1822
|
+
this.orders.delete(info.id);
|
|
1823
|
+
}).catch(this.onError);
|
|
1824
|
+
};
|
|
1825
|
+
}
|
|
1826
|
+
return opts;
|
|
1827
|
+
}
|
|
1828
|
+
addExecutions(list) {
|
|
1829
|
+
if (list.length === 0) return;
|
|
1830
|
+
for (const e of list) this.executions.set(executionKey(e), e);
|
|
1831
|
+
this.layer.setExecutions([...this.executions.values()]);
|
|
1832
|
+
}
|
|
1833
|
+
};
|
|
1834
|
+
|
|
1835
|
+
// src/render/canvas-layer.ts
|
|
1836
|
+
var CanvasLayer = class {
|
|
1837
|
+
canvas;
|
|
1838
|
+
ctx;
|
|
1839
|
+
width = 0;
|
|
1840
|
+
height = 0;
|
|
1841
|
+
dpr = 1;
|
|
1842
|
+
constructor(parent) {
|
|
1843
|
+
this.canvas = document.createElement("canvas");
|
|
1844
|
+
Object.assign(this.canvas.style, { position: "absolute", left: "0", top: "0", display: "block" });
|
|
1845
|
+
parent.appendChild(this.canvas);
|
|
1846
|
+
const ctx = this.canvas.getContext("2d");
|
|
1847
|
+
if (!ctx) throw new Error("funnest-charts: Canvas 2D is not supported");
|
|
1848
|
+
this.ctx = ctx;
|
|
1849
|
+
}
|
|
1850
|
+
resize(width, height, dpr) {
|
|
1851
|
+
this.width = width;
|
|
1852
|
+
this.height = height;
|
|
1853
|
+
this.dpr = dpr;
|
|
1854
|
+
this.canvas.width = Math.max(1, Math.round(width * dpr));
|
|
1855
|
+
this.canvas.height = Math.max(1, Math.round(height * dpr));
|
|
1856
|
+
this.canvas.style.width = `${width}px`;
|
|
1857
|
+
this.canvas.style.height = `${height}px`;
|
|
1858
|
+
}
|
|
1859
|
+
/** Resets the transform to CSS pixels and clears. */
|
|
1860
|
+
begin() {
|
|
1861
|
+
const { ctx } = this;
|
|
1862
|
+
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
|
1863
|
+
ctx.clearRect(0, 0, this.width, this.height);
|
|
1864
|
+
return ctx;
|
|
1865
|
+
}
|
|
1866
|
+
destroy() {
|
|
1867
|
+
this.canvas.remove();
|
|
1868
|
+
}
|
|
1869
|
+
};
|
|
1870
|
+
|
|
1871
|
+
// src/render/color-resolver.ts
|
|
1872
|
+
var ColorResolver = class {
|
|
1873
|
+
constructor(probe) {
|
|
1874
|
+
this.probe = probe;
|
|
1875
|
+
}
|
|
1876
|
+
probe;
|
|
1877
|
+
cache = /* @__PURE__ */ new Map();
|
|
1878
|
+
color(value) {
|
|
1879
|
+
if (!value.includes("var(")) return value;
|
|
1880
|
+
return this.lookup(`c:${value}`, () => {
|
|
1881
|
+
this.probe.style.color = "";
|
|
1882
|
+
this.probe.style.color = value;
|
|
1883
|
+
return getComputedStyle(this.probe).color;
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
fontFamily(value) {
|
|
1887
|
+
if (!value.includes("var(")) return value;
|
|
1888
|
+
return this.lookup(`f:${value}`, () => {
|
|
1889
|
+
this.probe.style.fontFamily = "";
|
|
1890
|
+
this.probe.style.fontFamily = value;
|
|
1891
|
+
return getComputedStyle(this.probe).fontFamily;
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
clear() {
|
|
1895
|
+
this.cache.clear();
|
|
1896
|
+
}
|
|
1897
|
+
lookup(key, compute) {
|
|
1898
|
+
let v = this.cache.get(key);
|
|
1899
|
+
if (v === void 0) {
|
|
1900
|
+
v = compute();
|
|
1901
|
+
this.cache.set(key, v);
|
|
1902
|
+
}
|
|
1903
|
+
return v;
|
|
1904
|
+
}
|
|
1905
|
+
};
|
|
1906
|
+
|
|
1907
|
+
// src/render/renderer.ts
|
|
1908
|
+
var VOLUME_ALPHA = 0.35;
|
|
1909
|
+
var BADGE_HEIGHT = 20;
|
|
1910
|
+
var DASH = [4, 4];
|
|
1911
|
+
var DOT = [2, 3];
|
|
1912
|
+
function barGeometry(barSpacing, dpr) {
|
|
1913
|
+
const spacing = barSpacing * dpr;
|
|
1914
|
+
const wick = Math.max(1, Math.floor(dpr));
|
|
1915
|
+
let body = Math.floor(spacing * 0.72);
|
|
1916
|
+
if (spacing >= 3) body = Math.min(body, Math.floor(spacing) - 1);
|
|
1917
|
+
body = Math.max(wick, body);
|
|
1918
|
+
if ((body - wick) % 2 !== 0) body -= 1;
|
|
1919
|
+
return { wick, body };
|
|
1920
|
+
}
|
|
1921
|
+
function drawCandles(ctx, dpr, m, pane) {
|
|
1922
|
+
const { bars, timeScale, frame } = m;
|
|
1923
|
+
const { from, to } = frame.range;
|
|
1924
|
+
const { wick, body } = barGeometry(timeScale.barSpacing, dpr);
|
|
1925
|
+
const half = wick >> 1;
|
|
1926
|
+
const inset = (body - wick) / 2;
|
|
1927
|
+
const s = pane.scale;
|
|
1928
|
+
const y = (price) => Math.round((pane.top + s.priceToY(price)) * dpr);
|
|
1929
|
+
ctx.save();
|
|
1930
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
1931
|
+
for (const up of [true, false]) {
|
|
1932
|
+
ctx.beginPath();
|
|
1933
|
+
for (let i = from; i <= to; i++) {
|
|
1934
|
+
const bar = bars[i];
|
|
1935
|
+
if (bar.close >= bar.open !== up) continue;
|
|
1936
|
+
const left = Math.round(timeScale.indexToX(i) * dpr) - half;
|
|
1937
|
+
const yHigh = y(bar.high);
|
|
1938
|
+
const yLow = y(bar.low);
|
|
1939
|
+
ctx.rect(left, yHigh, wick, Math.max(1, yLow - yHigh));
|
|
1940
|
+
if (body > wick) {
|
|
1941
|
+
const yOpen = y(bar.open);
|
|
1942
|
+
const yClose = y(bar.close);
|
|
1943
|
+
const top = Math.min(yOpen, yClose);
|
|
1944
|
+
ctx.rect(left - inset, top, body, Math.max(1, Math.max(yOpen, yClose) - top));
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
ctx.fillStyle = up ? frame.palette.up : frame.palette.down;
|
|
1948
|
+
ctx.fill();
|
|
1949
|
+
}
|
|
1950
|
+
ctx.restore();
|
|
1951
|
+
}
|
|
1952
|
+
function drawLineSeries(ctx, m, pane, area) {
|
|
1953
|
+
const { bars, timeScale, frame } = m;
|
|
1954
|
+
const from = Math.max(0, frame.range.from - 1);
|
|
1955
|
+
const to = Math.min(bars.length - 1, frame.range.to + 1);
|
|
1956
|
+
if (to < from) return;
|
|
1957
|
+
const color = frame.palette.accent;
|
|
1958
|
+
ctx.save();
|
|
1959
|
+
ctx.beginPath();
|
|
1960
|
+
for (let i = from; i <= to; i++) {
|
|
1961
|
+
const x = timeScale.indexToX(i);
|
|
1962
|
+
const y = pane.top + pane.scale.priceToY(bars[i].close);
|
|
1963
|
+
if (i === from) ctx.moveTo(x, y);
|
|
1964
|
+
else ctx.lineTo(x, y);
|
|
1965
|
+
}
|
|
1966
|
+
ctx.lineJoin = "round";
|
|
1967
|
+
ctx.lineWidth = 2;
|
|
1968
|
+
ctx.strokeStyle = color;
|
|
1969
|
+
ctx.stroke();
|
|
1970
|
+
if (area) {
|
|
1971
|
+
const bottom = pane.top + pane.height;
|
|
1972
|
+
ctx.lineTo(timeScale.indexToX(to), bottom);
|
|
1973
|
+
ctx.lineTo(timeScale.indexToX(from), bottom);
|
|
1974
|
+
ctx.closePath();
|
|
1975
|
+
const gradient = ctx.createLinearGradient(0, pane.top, 0, bottom);
|
|
1976
|
+
gradient.addColorStop(0, color);
|
|
1977
|
+
gradient.addColorStop(1, "transparent");
|
|
1978
|
+
ctx.globalAlpha = 0.18;
|
|
1979
|
+
ctx.fillStyle = gradient;
|
|
1980
|
+
ctx.fill();
|
|
1981
|
+
}
|
|
1982
|
+
ctx.restore();
|
|
1983
|
+
}
|
|
1984
|
+
function drawIndicator(ctx, p, m, pane, output) {
|
|
1985
|
+
const { timeScale, frame } = m;
|
|
1986
|
+
const pal = frame.palette;
|
|
1987
|
+
const from = Math.max(0, frame.range.from - 1);
|
|
1988
|
+
const to = Math.min(m.bars.length - 1, frame.range.to + 1);
|
|
1989
|
+
if (to < from) return;
|
|
1990
|
+
const yOf = (v) => pane.top + pane.scale.priceToY(v);
|
|
1991
|
+
const byKey = (key) => output.series.find((s) => s.key === key)?.values;
|
|
1992
|
+
for (const level of output.levels ?? []) p.hLine(yOf(level), 0, frame.plotWidth, pal.textSecondary, [3, 4]);
|
|
1993
|
+
if (output.fill) {
|
|
1994
|
+
const upper = byKey(output.fill.upper);
|
|
1995
|
+
const lower = byKey(output.fill.lower);
|
|
1996
|
+
if (upper && lower) {
|
|
1997
|
+
ctx.save();
|
|
1998
|
+
ctx.globalAlpha = 0.07;
|
|
1999
|
+
ctx.fillStyle = output.fill.color;
|
|
2000
|
+
let i = from;
|
|
2001
|
+
while (i <= to) {
|
|
2002
|
+
while (i <= to && (Number.isNaN(upper[i]) || Number.isNaN(lower[i]))) i++;
|
|
2003
|
+
const start = i;
|
|
2004
|
+
while (i <= to && !Number.isNaN(upper[i]) && !Number.isNaN(lower[i])) i++;
|
|
2005
|
+
if (i - start < 2) continue;
|
|
2006
|
+
ctx.beginPath();
|
|
2007
|
+
for (let j = start; j < i; j++) ctx.lineTo(timeScale.indexToX(j), yOf(upper[j]));
|
|
2008
|
+
for (let j = i - 1; j >= start; j--) ctx.lineTo(timeScale.indexToX(j), yOf(lower[j]));
|
|
2009
|
+
ctx.closePath();
|
|
2010
|
+
ctx.fill();
|
|
2011
|
+
}
|
|
2012
|
+
ctx.restore();
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
for (const series of output.series) {
|
|
2016
|
+
if (series.kind !== "histogram") continue;
|
|
2017
|
+
const { wick, body } = barGeometry(timeScale.barSpacing, layerDpr(ctx));
|
|
2018
|
+
const dpr = layerDpr(ctx);
|
|
2019
|
+
const zero = Math.round(yOf(0) * dpr);
|
|
2020
|
+
ctx.save();
|
|
2021
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
2022
|
+
ctx.globalAlpha = 0.6;
|
|
2023
|
+
for (const positive of [true, false]) {
|
|
2024
|
+
ctx.beginPath();
|
|
2025
|
+
for (let i = from; i <= to; i++) {
|
|
2026
|
+
const v = series.values[i];
|
|
2027
|
+
if (Number.isNaN(v) || v >= 0 !== positive) continue;
|
|
2028
|
+
const left = Math.round(timeScale.indexToX(i) * dpr) - (wick >> 1) - (body - wick) / 2;
|
|
2029
|
+
const y = Math.round(yOf(v) * dpr);
|
|
2030
|
+
ctx.rect(left, Math.min(y, zero), body, Math.max(1, Math.abs(zero - y)));
|
|
2031
|
+
}
|
|
2032
|
+
ctx.fillStyle = positive ? pal.up : pal.down;
|
|
2033
|
+
ctx.fill();
|
|
2034
|
+
}
|
|
2035
|
+
ctx.restore();
|
|
2036
|
+
}
|
|
2037
|
+
ctx.save();
|
|
2038
|
+
ctx.lineJoin = "round";
|
|
2039
|
+
for (const series of output.series) {
|
|
2040
|
+
if (series.kind !== "line") continue;
|
|
2041
|
+
ctx.lineWidth = series.width;
|
|
2042
|
+
ctx.beginPath();
|
|
2043
|
+
let pen = false;
|
|
2044
|
+
for (let i = from; i <= to; i++) {
|
|
2045
|
+
const v = series.values[i];
|
|
2046
|
+
if (Number.isNaN(v)) {
|
|
2047
|
+
pen = false;
|
|
2048
|
+
continue;
|
|
2049
|
+
}
|
|
2050
|
+
const x = timeScale.indexToX(i);
|
|
2051
|
+
if (pen) ctx.lineTo(x, yOf(v));
|
|
2052
|
+
else ctx.moveTo(x, yOf(v));
|
|
2053
|
+
pen = true;
|
|
2054
|
+
}
|
|
2055
|
+
ctx.strokeStyle = series.color;
|
|
2056
|
+
ctx.stroke();
|
|
2057
|
+
}
|
|
2058
|
+
ctx.restore();
|
|
2059
|
+
}
|
|
2060
|
+
var layerDpr = (ctx) => ctx.getTransform().a || 1;
|
|
2061
|
+
function drawVolume(ctx, dpr, m, pane) {
|
|
2062
|
+
const { bars, timeScale, frame } = m;
|
|
2063
|
+
const { from, to } = frame.range;
|
|
2064
|
+
const { wick, body } = barGeometry(timeScale.barSpacing, dpr);
|
|
2065
|
+
const half = wick >> 1;
|
|
2066
|
+
const inset = (body - wick) / 2;
|
|
2067
|
+
const bottom = Math.round((pane.top + pane.height) * dpr);
|
|
2068
|
+
ctx.save();
|
|
2069
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
2070
|
+
ctx.globalAlpha = VOLUME_ALPHA;
|
|
2071
|
+
for (const up of [true, false]) {
|
|
2072
|
+
ctx.beginPath();
|
|
2073
|
+
for (let i = from; i <= to; i++) {
|
|
2074
|
+
const bar = bars[i];
|
|
2075
|
+
if (!bar.volume || bar.close >= bar.open !== up) continue;
|
|
2076
|
+
const left = Math.round(timeScale.indexToX(i) * dpr) - half - inset;
|
|
2077
|
+
const top = Math.round((pane.top + pane.scale.priceToY(bar.volume)) * dpr);
|
|
2078
|
+
ctx.rect(left, top, body, Math.max(1, bottom - top));
|
|
2079
|
+
}
|
|
2080
|
+
ctx.fillStyle = up ? frame.palette.up : frame.palette.down;
|
|
2081
|
+
ctx.fill();
|
|
2082
|
+
}
|
|
2083
|
+
ctx.restore();
|
|
2084
|
+
}
|
|
2085
|
+
function drawHighLow(p, m, pane) {
|
|
2086
|
+
const { frame, bars, timeScale } = m;
|
|
2087
|
+
if (!frame.highLow) return;
|
|
2088
|
+
const marks = [
|
|
2089
|
+
{ index: frame.highLow.highIndex, price: bars[frame.highLow.highIndex].high, label: m.labels.high },
|
|
2090
|
+
{ index: frame.highLow.lowIndex, price: bars[frame.highLow.lowIndex].low, label: m.labels.low }
|
|
2091
|
+
];
|
|
2092
|
+
for (const mark of marks) {
|
|
2093
|
+
const x = timeScale.indexToX(mark.index);
|
|
2094
|
+
const y = pane.top + Math.min(pane.height - 8, Math.max(8, pane.scale.priceToY(mark.price)));
|
|
2095
|
+
const toLeft = x > frame.plotWidth / 2;
|
|
2096
|
+
const dir = toLeft ? -1 : 1;
|
|
2097
|
+
const text = `${mark.label} ${m.formatPrice(mark.price)}`;
|
|
2098
|
+
p.hLine(y, Math.min(x, x + dir * 12), Math.max(x, x + dir * 12), frame.palette.textSecondary);
|
|
2099
|
+
p.text(text, x + dir * 16, y, frame.palette.textSecondary, toLeft ? "right" : "left");
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
function drawMain(layer, m) {
|
|
2103
|
+
const ctx = layer.begin();
|
|
2104
|
+
const p = new Painter(ctx, layer.dpr);
|
|
2105
|
+
const f = m.frame;
|
|
2106
|
+
const pal = f.palette;
|
|
2107
|
+
const [main, volume] = f.panes;
|
|
2108
|
+
ctx.fillStyle = pal.background;
|
|
2109
|
+
ctx.fillRect(0, 0, f.width, f.height);
|
|
2110
|
+
ctx.font = f.font;
|
|
2111
|
+
const gridH = f.settings.gridHorizontal;
|
|
2112
|
+
for (const price of gridH ? f.priceTicks : []) {
|
|
2113
|
+
const y = main.scale.priceToY(price);
|
|
2114
|
+
if (y > 0 && y < main.height) p.hLine(y, 0, f.plotWidth, pal.grid);
|
|
2115
|
+
}
|
|
2116
|
+
const [t0, t1] = f.priceTicks;
|
|
2117
|
+
if (gridH && volume && volume.kind === "volume" && t0 !== void 0 && t1 !== void 0) {
|
|
2118
|
+
const step = Math.abs(main.scale.priceToY(t1) - main.scale.priceToY(t0));
|
|
2119
|
+
if (step >= 8) {
|
|
2120
|
+
const phase = main.scale.priceToY(t0);
|
|
2121
|
+
let y = phase + Math.ceil((volume.top - phase) / step) * step;
|
|
2122
|
+
if (y <= volume.top) y += step;
|
|
2123
|
+
const bottom = volume.top + volume.height;
|
|
2124
|
+
for (; y < bottom; y += step) if (y - volume.top > 10 && bottom - y > 10) p.hLine(y, 0, f.plotWidth, pal.grid);
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
if (f.settings.gridVertical) for (const tick of f.timeTicks) p.vLine(tick.x, 0, f.panesHeight, pal.grid);
|
|
2128
|
+
const hasBars = f.range.to >= f.range.from;
|
|
2129
|
+
if (hasBars) {
|
|
2130
|
+
ctx.save();
|
|
2131
|
+
ctx.beginPath();
|
|
2132
|
+
ctx.rect(0, main.top, f.plotWidth, main.height);
|
|
2133
|
+
ctx.clip();
|
|
2134
|
+
if (m.chartType === "candles") drawCandles(ctx, layer.dpr, m, main);
|
|
2135
|
+
else drawLineSeries(ctx, m, main, m.chartType === "area");
|
|
2136
|
+
for (const overlay of f.overlays) drawIndicator(ctx, p, m, main, overlay);
|
|
2137
|
+
if (f.settings.highLowMarkers) drawHighLow(p, m, main);
|
|
2138
|
+
ctx.restore();
|
|
2139
|
+
if (volume && volume.kind === "volume" && volume.height > 0) {
|
|
2140
|
+
ctx.save();
|
|
2141
|
+
ctx.beginPath();
|
|
2142
|
+
ctx.rect(0, volume.top, f.plotWidth, volume.height);
|
|
2143
|
+
ctx.clip();
|
|
2144
|
+
drawVolume(ctx, layer.dpr, m, volume);
|
|
2145
|
+
ctx.restore();
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
for (const pane of f.panes) {
|
|
2149
|
+
if (pane.kind !== "indicator" || !pane.indicator) continue;
|
|
2150
|
+
for (const v of gridH ? pane.ticks : []) {
|
|
2151
|
+
const y = pane.top + pane.scale.priceToY(v);
|
|
2152
|
+
if (y > pane.top && y < pane.top + pane.height) p.hLine(y, 0, f.plotWidth, pal.grid);
|
|
2153
|
+
}
|
|
2154
|
+
if (!hasBars) continue;
|
|
2155
|
+
ctx.save();
|
|
2156
|
+
ctx.beginPath();
|
|
2157
|
+
ctx.rect(0, pane.top, f.plotWidth, pane.height);
|
|
2158
|
+
ctx.clip();
|
|
2159
|
+
drawIndicator(ctx, p, m, pane, pane.indicator);
|
|
2160
|
+
ctx.restore();
|
|
2161
|
+
}
|
|
2162
|
+
const badges = [];
|
|
2163
|
+
ctx.save();
|
|
2164
|
+
ctx.beginPath();
|
|
2165
|
+
ctx.rect(0, main.top, f.plotWidth, main.height);
|
|
2166
|
+
ctx.clip();
|
|
2167
|
+
const o = m.objects;
|
|
2168
|
+
const rc = { painter: p, coords: o.coords, palette: pal };
|
|
2169
|
+
o.trading.render(rc, m.bars, o.barIndexAt, (i) => m.timeScale.indexToX(i), o.tradingLabels, badges);
|
|
2170
|
+
o.drawings.render(rc, badges);
|
|
2171
|
+
ctx.restore();
|
|
2172
|
+
const last = f.settings.lastPriceLine ? m.bars[m.bars.length - 1] : void 0;
|
|
2173
|
+
const lastColor = last && last.close >= last.open ? pal.up : pal.down;
|
|
2174
|
+
const lastY = last ? main.scale.priceToY(last.close) : NaN;
|
|
2175
|
+
if (last && lastY >= 0 && lastY <= main.height) p.hLine(lastY, 0, f.plotWidth, lastColor, DOT);
|
|
2176
|
+
for (const pane of f.panes) if (pane.kind !== "main") p.hLine(pane.top, 0, f.plotWidth, pal.border);
|
|
2177
|
+
p.vLine(f.plotWidth, 0, f.panesHeight, pal.border);
|
|
2178
|
+
p.hLine(f.panesHeight, 0, f.width, pal.border);
|
|
2179
|
+
const labelX = f.plotWidth + 8;
|
|
2180
|
+
for (const price of f.priceTicks) {
|
|
2181
|
+
const y = main.scale.priceToY(price);
|
|
2182
|
+
if (y < 8 || y > main.height - 8) continue;
|
|
2183
|
+
if (last && Math.abs(y - lastY) < BADGE_HEIGHT) continue;
|
|
2184
|
+
p.text(m.formatPrice(price), labelX, y, pal.textSecondary);
|
|
2185
|
+
}
|
|
2186
|
+
for (const pane of f.panes) {
|
|
2187
|
+
if (pane.kind !== "indicator") continue;
|
|
2188
|
+
for (const v of pane.ticks) {
|
|
2189
|
+
const y = pane.top + pane.scale.priceToY(v);
|
|
2190
|
+
if (y < pane.top + 8 || y > pane.top + pane.height - 8) continue;
|
|
2191
|
+
p.text((pane.tickFormat ?? pane.format)(v), labelX, y, pal.textSecondary);
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
for (const badge of badges) {
|
|
2195
|
+
const y = Math.min(main.height - BADGE_HEIGHT / 2, Math.max(BADGE_HEIGHT / 2, badge.y));
|
|
2196
|
+
p.roundRect(f.plotWidth + 2, y - BADGE_HEIGHT / 2, f.width - f.plotWidth - 4, BADGE_HEIGHT, 4, badge.bg);
|
|
2197
|
+
p.text(badge.text, labelX, y, badge.fg);
|
|
2198
|
+
}
|
|
2199
|
+
if (last && lastY >= 0 && lastY <= main.height) {
|
|
2200
|
+
const y = Math.min(main.height - BADGE_HEIGHT / 2, Math.max(BADGE_HEIGHT / 2, lastY));
|
|
2201
|
+
p.roundRect(f.plotWidth + 2, y - BADGE_HEIGHT / 2, f.width - f.plotWidth - 4, BADGE_HEIGHT, 4, lastColor);
|
|
2202
|
+
p.text(m.formatPrice(last.close), labelX, y, "#FFFFFF");
|
|
2203
|
+
}
|
|
2204
|
+
const timeY = f.panesHeight + f.timeAxisHeight / 2;
|
|
2205
|
+
for (const tick of f.timeTicks) {
|
|
2206
|
+
const w = p.measure(tick.label);
|
|
2207
|
+
if (tick.x - w / 2 < 0 || tick.x + w / 2 > f.plotWidth) continue;
|
|
2208
|
+
p.text(tick.label, tick.x, timeY, pal.textSecondary, "center");
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
function drawTooltip(p, f, tip) {
|
|
2212
|
+
const { ctx } = p;
|
|
2213
|
+
const pal = f.palette;
|
|
2214
|
+
const lineHeight = 18;
|
|
2215
|
+
const pad = 10;
|
|
2216
|
+
const w = Math.ceil(Math.max(...tip.lines.map((l) => p.measure(l)))) + pad * 2;
|
|
2217
|
+
const h = tip.lines.length * lineHeight + pad * 2 - 4;
|
|
2218
|
+
let y = tip.side === "buy" ? tip.y + 12 : tip.y - 12 - h;
|
|
2219
|
+
if (y < 4) y = tip.y + 12;
|
|
2220
|
+
if (y + h > f.panesHeight - 4) y = tip.y - 12 - h;
|
|
2221
|
+
const x = Math.min(f.plotWidth - w - 4, Math.max(4, tip.x - w / 2));
|
|
2222
|
+
ctx.save();
|
|
2223
|
+
ctx.shadowColor = "rgba(0,0,0,0.16)";
|
|
2224
|
+
ctx.shadowBlur = 16;
|
|
2225
|
+
ctx.shadowOffsetY = 4;
|
|
2226
|
+
p.roundRect(x, y, w, h, 8, pal.background);
|
|
2227
|
+
ctx.restore();
|
|
2228
|
+
tip.lines.forEach((line2, i) => {
|
|
2229
|
+
const color = i === 0 ? tip.side === "buy" ? pal.up : pal.down : i === tip.lines.length - 1 ? pal.textSecondary : pal.text;
|
|
2230
|
+
p.text(line2, x + pad, y + pad + i * lineHeight + 7, color);
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
function drawOverlay(layer, m, ch, tooltip = null) {
|
|
2234
|
+
const ctx = layer.begin();
|
|
2235
|
+
const p = new Painter(ctx, layer.dpr);
|
|
2236
|
+
const f = m.frame;
|
|
2237
|
+
const pal = f.palette;
|
|
2238
|
+
ctx.font = f.font;
|
|
2239
|
+
if (tooltip) drawTooltip(p, f, tooltip);
|
|
2240
|
+
if (!ch) return;
|
|
2241
|
+
p.vLine(ch.x, 0, f.panesHeight, pal.crosshair, DASH);
|
|
2242
|
+
p.hLine(ch.y, 0, f.plotWidth, pal.crosshair, DASH);
|
|
2243
|
+
if (ch.source === "touch") {
|
|
2244
|
+
ctx.beginPath();
|
|
2245
|
+
ctx.arc(ch.x, ch.y, 5, 0, Math.PI * 2);
|
|
2246
|
+
ctx.fillStyle = pal.background;
|
|
2247
|
+
ctx.fill();
|
|
2248
|
+
ctx.beginPath();
|
|
2249
|
+
ctx.arc(ch.x, ch.y, 3.5, 0, Math.PI * 2);
|
|
2250
|
+
ctx.fillStyle = ch.bar.close >= ch.bar.open ? pal.up : pal.down;
|
|
2251
|
+
ctx.fill();
|
|
2252
|
+
}
|
|
2253
|
+
const pane = f.panes.find((x) => x.id === ch.pane);
|
|
2254
|
+
const valueLabel = pane ? pane.format(ch.value) : m.formatPrice(ch.value);
|
|
2255
|
+
const by = Math.min(f.panesHeight - BADGE_HEIGHT / 2, Math.max(BADGE_HEIGHT / 2, ch.y));
|
|
2256
|
+
p.roundRect(f.plotWidth + 2, by - BADGE_HEIGHT / 2, f.width - f.plotWidth - 4, BADGE_HEIGHT, 4, pal.text);
|
|
2257
|
+
p.text(valueLabel, f.plotWidth + 8, by, pal.background);
|
|
2258
|
+
const timeLabel = m.formatCrosshairTime(ch.time);
|
|
2259
|
+
const w = p.measure(timeLabel) + 16;
|
|
2260
|
+
const cx = Math.min(f.plotWidth - w / 2, Math.max(w / 2, ch.x));
|
|
2261
|
+
p.roundRect(cx - w / 2, f.panesHeight + 4, w, f.timeAxisHeight - 8, 4, pal.text);
|
|
2262
|
+
p.text(timeLabel, cx, f.panesHeight + f.timeAxisHeight / 2, pal.background, "center");
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
// src/resolution.ts
|
|
2266
|
+
var MINUTE = 6e4;
|
|
2267
|
+
var DAY_MS = 864e5;
|
|
2268
|
+
function resolutionToMs(resolution) {
|
|
2269
|
+
const match = /^(\d*)([SDWM]?)$/i.exec(resolution.trim());
|
|
2270
|
+
if (!match) throw new Error(`funnest-charts: invalid resolution "${resolution}"`);
|
|
2271
|
+
const n = match[1] ? Number(match[1]) : 1;
|
|
2272
|
+
switch ((match[2] ?? "").toUpperCase()) {
|
|
2273
|
+
case "S":
|
|
2274
|
+
return n * 1e3;
|
|
2275
|
+
case "D":
|
|
2276
|
+
return n * DAY_MS;
|
|
2277
|
+
case "W":
|
|
2278
|
+
return n * 7 * DAY_MS;
|
|
2279
|
+
case "M":
|
|
2280
|
+
return n * 30 * DAY_MS;
|
|
2281
|
+
default:
|
|
2282
|
+
return n * MINUTE;
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
function isIntradayResolution(resolution) {
|
|
2286
|
+
return resolutionToMs(resolution) < DAY_MS;
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
// src/theme.ts
|
|
2290
|
+
var FONT_STACK = "Pretendard, -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Segoe UI', Roboto, sans-serif";
|
|
2291
|
+
var DIRECTION_COLORS = {
|
|
2292
|
+
kr: { up: "#F04452", down: "#3182F6" },
|
|
2293
|
+
global: { up: "#12B886", down: "#F04452" }
|
|
2294
|
+
};
|
|
2295
|
+
var lightTheme = {
|
|
2296
|
+
background: "#FFFFFF",
|
|
2297
|
+
surface: "#F2F4F6",
|
|
2298
|
+
text: "#191F28",
|
|
2299
|
+
textSecondary: "#8B95A1",
|
|
2300
|
+
grid: "#F2F4F6",
|
|
2301
|
+
border: "#E5E8EB",
|
|
2302
|
+
crosshair: "#8B95A1",
|
|
2303
|
+
...DIRECTION_COLORS.kr,
|
|
2304
|
+
accent: "#3182F6",
|
|
2305
|
+
fontFamily: FONT_STACK,
|
|
2306
|
+
fontSize: 12,
|
|
2307
|
+
radius: 8
|
|
2308
|
+
};
|
|
2309
|
+
var darkTheme = {
|
|
2310
|
+
...lightTheme,
|
|
2311
|
+
background: "#17171C",
|
|
2312
|
+
surface: "#202027",
|
|
2313
|
+
text: "#E4E4E5",
|
|
2314
|
+
textSecondary: "#7E7E87",
|
|
2315
|
+
grid: "#202027",
|
|
2316
|
+
border: "#2C2C35",
|
|
2317
|
+
crosshair: "#7E7E87"
|
|
2318
|
+
};
|
|
2319
|
+
function createTheme(base, overrides = {}, direction) {
|
|
2320
|
+
return { ...base, ...direction ? DIRECTION_COLORS[direction] : {}, ...overrides };
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
// src/chart.ts
|
|
2324
|
+
var TIME_AXIS_HEIGHT = 28;
|
|
2325
|
+
var MIN_PRICE_AXIS_WIDTH = 56;
|
|
2326
|
+
var PANE_WEIGHT = { main: 4, volume: 1, indicator: 1.3 };
|
|
2327
|
+
var HISTORY_TRIGGER_BARS = 20;
|
|
2328
|
+
var TIME_LABEL_MIN_GAP = 72;
|
|
2329
|
+
var PRICE_TICK_SPACING = 48;
|
|
2330
|
+
var LABELS = {
|
|
2331
|
+
ko: { high: "\uCD5C\uACE0", low: "\uCD5C\uC800" },
|
|
2332
|
+
en: { high: "High", low: "Low" }
|
|
2333
|
+
};
|
|
2334
|
+
var TRADING_LABELS = {
|
|
2335
|
+
ko: { buy: "\uB9E4\uC218", sell: "\uB9E4\uB3C4", position: "\uD3C9\uADE0\uB2E8\uAC00" },
|
|
2336
|
+
en: { buy: "Buy", sell: "Sell", position: "Avg" }
|
|
2337
|
+
};
|
|
2338
|
+
var HIT_TOLERANCE = { mouse: 5, touch: 14 };
|
|
2339
|
+
var TOOLTIP_LABELS = {
|
|
2340
|
+
ko: { buy: "\uB9E4\uC218", sell: "\uB9E4\uB3C4", fills: (n) => `${n}\uAC74`, price: "\uCCB4\uACB0\uAC00", avg: "\uD3C9\uADE0\uAC00", qty: "\uC218\uB7C9" },
|
|
2341
|
+
en: { buy: "Buy", sell: "Sell", fills: (n) => `${n} fills`, price: "Price", avg: "Avg", qty: "Qty" }
|
|
2342
|
+
};
|
|
2343
|
+
function fallbackResolution(requested, supported) {
|
|
2344
|
+
if (!supported?.length || supported.includes(requested)) return requested;
|
|
2345
|
+
const target = resolutionToMs(requested);
|
|
2346
|
+
const sorted = [...supported].sort((a, b) => resolutionToMs(a) - resolutionToMs(b));
|
|
2347
|
+
return [...sorted].reverse().find((r) => resolutionToMs(r) <= target) ?? sorted[0];
|
|
2348
|
+
}
|
|
2349
|
+
function createChart(container, options = {}) {
|
|
2350
|
+
return new Chart(container, options);
|
|
2351
|
+
}
|
|
2352
|
+
var Chart = class {
|
|
2353
|
+
timeScale;
|
|
2354
|
+
root;
|
|
2355
|
+
mainLayer;
|
|
2356
|
+
overlayLayer;
|
|
2357
|
+
store = new BarStore();
|
|
2358
|
+
mainScale = new PriceScale({ top: 0.08, bottom: 0.08 });
|
|
2359
|
+
volumeScale = new PriceScale({ top: 0.2, bottom: 0 });
|
|
2360
|
+
indicators = new IndicatorManager();
|
|
2361
|
+
indicatorScales = /* @__PURE__ */ new Map();
|
|
2362
|
+
emitter = new Emitter();
|
|
2363
|
+
colors;
|
|
2364
|
+
interaction;
|
|
2365
|
+
cleanups = [];
|
|
2366
|
+
drawings;
|
|
2367
|
+
trading;
|
|
2368
|
+
tradingSync;
|
|
2369
|
+
tooltipKey = null;
|
|
2370
|
+
chartType = "candles";
|
|
2371
|
+
magnet = false;
|
|
2372
|
+
objectDrag = null;
|
|
2373
|
+
theme;
|
|
2374
|
+
locale;
|
|
2375
|
+
formatter;
|
|
2376
|
+
pricePrecision;
|
|
2377
|
+
tickSize;
|
|
2378
|
+
settingsValue;
|
|
2379
|
+
/** Drawings of symbols other than the current one (the current set lives in DrawingManager). */
|
|
2380
|
+
drawingsBySymbol = /* @__PURE__ */ new Map();
|
|
2381
|
+
stateQueued = false;
|
|
2382
|
+
datafeed;
|
|
2383
|
+
historyBatchSize;
|
|
2384
|
+
size = { width: 0, height: 0, dpr: 1 };
|
|
2385
|
+
priceAxisWidth = MIN_PRICE_AXIS_WIDTH;
|
|
2386
|
+
frame = null;
|
|
2387
|
+
crosshair = null;
|
|
2388
|
+
lastCrosshairKey = "";
|
|
2389
|
+
lastRangeKey = "";
|
|
2390
|
+
lastLayoutKey = "";
|
|
2391
|
+
dataVersion = 0;
|
|
2392
|
+
emittedDataVersion = -1;
|
|
2393
|
+
rafId = 0;
|
|
2394
|
+
mainDirty = true;
|
|
2395
|
+
destroyed = false;
|
|
2396
|
+
symbol = null;
|
|
2397
|
+
resolution = null;
|
|
2398
|
+
loadToken = 0;
|
|
2399
|
+
unsubscribeBars = null;
|
|
2400
|
+
unsubscribeQuotes = null;
|
|
2401
|
+
latestQuote = null;
|
|
2402
|
+
connectionState = null;
|
|
2403
|
+
displayTimezone;
|
|
2404
|
+
loadingHistory = false;
|
|
2405
|
+
noMoreHistory = false;
|
|
2406
|
+
historyRequestedFor = Number.NaN;
|
|
2407
|
+
constructor(container, options = {}) {
|
|
2408
|
+
this.theme = { ...lightTheme, ...options.theme };
|
|
2409
|
+
this.locale = options.locale ?? "ko";
|
|
2410
|
+
this.formatter = new TimeFormatter(options.timezone ?? defaultTimeZone(), this.locale);
|
|
2411
|
+
this.pricePrecision = options.pricePrecision ?? 2;
|
|
2412
|
+
this.tickSize = options.tickSize ?? 0;
|
|
2413
|
+
this.settingsValue = { ...DEFAULT_CHART_SETTINGS, ...options.settings };
|
|
2414
|
+
if (options.volume !== void 0) this.settingsValue.volume = options.volume;
|
|
2415
|
+
this.datafeed = options.datafeed;
|
|
2416
|
+
this.historyBatchSize = options.historyBatchSize ?? 300;
|
|
2417
|
+
this.displayTimezone = options.displayTimezone;
|
|
2418
|
+
if (options.displayTimezone) this.formatter = new TimeFormatter(options.displayTimezone, this.locale);
|
|
2419
|
+
this.timeScale = new TimeScale({ ...DEFAULT_TIME_SCALE_OPTIONS, ...options.timeScale });
|
|
2420
|
+
this.root = document.createElement("div");
|
|
2421
|
+
this.root.className = "fnc-chart";
|
|
2422
|
+
Object.assign(this.root.style, {
|
|
2423
|
+
position: "relative",
|
|
2424
|
+
width: "100%",
|
|
2425
|
+
height: "100%",
|
|
2426
|
+
overflow: "hidden",
|
|
2427
|
+
userSelect: "none"
|
|
2428
|
+
});
|
|
2429
|
+
this.root.style.setProperty("-webkit-user-select", "none");
|
|
2430
|
+
this.root.style.setProperty("-webkit-touch-callout", "none");
|
|
2431
|
+
this.root.style.setProperty("-webkit-tap-highlight-color", "transparent");
|
|
2432
|
+
const probe = document.createElement("span");
|
|
2433
|
+
probe.style.display = "none";
|
|
2434
|
+
this.root.appendChild(probe);
|
|
2435
|
+
container.appendChild(this.root);
|
|
2436
|
+
this.colors = new ColorResolver(probe);
|
|
2437
|
+
this.drawings = new DrawingManager((change) => {
|
|
2438
|
+
this.invalidate();
|
|
2439
|
+
if (change === "commit") {
|
|
2440
|
+
this.emitter.emit("drawingsChange", this.getDrawings());
|
|
2441
|
+
this.stateChanged();
|
|
2442
|
+
}
|
|
2443
|
+
if (change === "tool") this.emitter.emit("drawingToolChange", this.drawings.tool);
|
|
2444
|
+
if (change !== "preview") {
|
|
2445
|
+
const d = this.drawings.selected;
|
|
2446
|
+
this.emitter.emit("selectionChange", d ? { ...d, points: d.points.map((p) => ({ ...p })) } : null);
|
|
2447
|
+
}
|
|
2448
|
+
});
|
|
2449
|
+
this.trading = new TradingLayer(() => this.invalidate());
|
|
2450
|
+
this.tradingSync = new TradingSync(this.trading, (error) => this.emitter.emit("error", error));
|
|
2451
|
+
this.tradingSync.adapter = options.trading ?? null;
|
|
2452
|
+
this.mainLayer = new CanvasLayer(this.root);
|
|
2453
|
+
this.overlayLayer = new CanvasLayer(this.root);
|
|
2454
|
+
this.interaction = new Interaction(this.createInteractionHost(), this.overlayLayer.canvas);
|
|
2455
|
+
this.observeSize();
|
|
2456
|
+
this.observeThemeVariables();
|
|
2457
|
+
this.observeKeyboard();
|
|
2458
|
+
}
|
|
2459
|
+
// ── Public API ──────────────────────────────────────────────────────
|
|
2460
|
+
/** Replaces all bars and scrolls to the latest. */
|
|
2461
|
+
setData(bars) {
|
|
2462
|
+
this.store.set(bars);
|
|
2463
|
+
this.timeScale.barCount = this.store.length;
|
|
2464
|
+
this.timeScale.rightOffset = this.timeScale.options.rightOffset;
|
|
2465
|
+
this.mainScale.autoScale = true;
|
|
2466
|
+
this.volumeScale.autoScale = true;
|
|
2467
|
+
this.noMoreHistory = false;
|
|
2468
|
+
this.historyRequestedFor = Number.NaN;
|
|
2469
|
+
this.dataVersion++;
|
|
2470
|
+
this.invalidate();
|
|
2471
|
+
}
|
|
2472
|
+
/** Realtime tick: same time as the last bar updates it, a newer time appends a bar. */
|
|
2473
|
+
update(bar) {
|
|
2474
|
+
const result = this.store.upsert(bar);
|
|
2475
|
+
if (result === "ignored") return;
|
|
2476
|
+
if (result === "append") {
|
|
2477
|
+
this.timeScale.barCount = this.store.length;
|
|
2478
|
+
if (this.timeScale.rightOffset < 0) this.timeScale.rightOffset -= 1;
|
|
2479
|
+
}
|
|
2480
|
+
this.dataVersion++;
|
|
2481
|
+
this.invalidate();
|
|
2482
|
+
}
|
|
2483
|
+
/** Adds older bars (history paging). The view stays where it is. */
|
|
2484
|
+
prependData(bars, options = {}) {
|
|
2485
|
+
this.store.prepend(bars);
|
|
2486
|
+
this.timeScale.barCount = this.store.length;
|
|
2487
|
+
if (options.noMoreData) this.noMoreHistory = true;
|
|
2488
|
+
this.dataVersion++;
|
|
2489
|
+
this.invalidate();
|
|
2490
|
+
}
|
|
2491
|
+
get bars() {
|
|
2492
|
+
return this.store.bars;
|
|
2493
|
+
}
|
|
2494
|
+
get symbolInfo() {
|
|
2495
|
+
return this.symbol;
|
|
2496
|
+
}
|
|
2497
|
+
/** Loads a symbol through the datafeed, then follows realtime updates. */
|
|
2498
|
+
async setSymbol(symbol, resolution) {
|
|
2499
|
+
const feed = this.datafeed;
|
|
2500
|
+
if (!feed) throw new Error("funnest-charts: setSymbol() requires the `datafeed` option");
|
|
2501
|
+
const token = ++this.loadToken;
|
|
2502
|
+
this.unsubscribeBars?.();
|
|
2503
|
+
this.unsubscribeBars = null;
|
|
2504
|
+
this.unsubscribeQuotes?.();
|
|
2505
|
+
this.unsubscribeQuotes = null;
|
|
2506
|
+
this.latestQuote = null;
|
|
2507
|
+
this.emitter.emit("loadingChange", true);
|
|
2508
|
+
let info;
|
|
2509
|
+
let result;
|
|
2510
|
+
try {
|
|
2511
|
+
info = await feed.resolveSymbol(symbol);
|
|
2512
|
+
if (token !== this.loadToken) return;
|
|
2513
|
+
resolution = fallbackResolution(resolution, info.supportedResolutions);
|
|
2514
|
+
const to = Date.now();
|
|
2515
|
+
result = await feed.getBars(info, resolution, {
|
|
2516
|
+
from: to - resolutionToMs(resolution) * this.historyBatchSize,
|
|
2517
|
+
to,
|
|
2518
|
+
countBack: this.historyBatchSize
|
|
2519
|
+
});
|
|
2520
|
+
if (token !== this.loadToken) return;
|
|
2521
|
+
} finally {
|
|
2522
|
+
if (token === this.loadToken) this.emitter.emit("loadingChange", false);
|
|
2523
|
+
}
|
|
2524
|
+
const previous = this.symbol?.symbol ?? "";
|
|
2525
|
+
if (previous !== info.symbol) {
|
|
2526
|
+
const current = this.getDrawings();
|
|
2527
|
+
if (current.length) this.drawingsBySymbol.set(previous, current);
|
|
2528
|
+
this.restoreDrawingsFor(info.symbol);
|
|
2529
|
+
}
|
|
2530
|
+
this.symbol = info;
|
|
2531
|
+
this.resolution = resolution;
|
|
2532
|
+
this.formatter = new TimeFormatter(this.displayTimezone ?? info.timezone, this.locale);
|
|
2533
|
+
this.pricePrecision = info.pricePrecision;
|
|
2534
|
+
this.tickSize = info.tickSize;
|
|
2535
|
+
this.setData(result.bars);
|
|
2536
|
+
this.noMoreHistory = result.noMoreData;
|
|
2537
|
+
void this.tradingSync.load(info, result.bars[0]?.time ?? Date.now());
|
|
2538
|
+
this.unsubscribeBars = feed.subscribeBars(
|
|
2539
|
+
info,
|
|
2540
|
+
resolution,
|
|
2541
|
+
(bar) => {
|
|
2542
|
+
if (token === this.loadToken) this.update(bar);
|
|
2543
|
+
},
|
|
2544
|
+
() => {
|
|
2545
|
+
if (token === this.loadToken) void this.resync(token);
|
|
2546
|
+
},
|
|
2547
|
+
(state) => {
|
|
2548
|
+
if (token !== this.loadToken) return;
|
|
2549
|
+
this.connectionState = state;
|
|
2550
|
+
this.emitter.emit("connectionChange", state);
|
|
2551
|
+
}
|
|
2552
|
+
);
|
|
2553
|
+
this.unsubscribeQuotes = feed.subscribeQuotes?.(info, (quote) => {
|
|
2554
|
+
if (token !== this.loadToken) return;
|
|
2555
|
+
this.latestQuote = { ...this.latestQuote, ...quote };
|
|
2556
|
+
this.emitter.emit("quoteChange", this.latestQuote);
|
|
2557
|
+
}) ?? null;
|
|
2558
|
+
this.emitter.emit("symbolChange", info);
|
|
2559
|
+
this.stateChanged();
|
|
2560
|
+
}
|
|
2561
|
+
/** Latest market statistics (merged across partial updates), if the datafeed provides them. */
|
|
2562
|
+
get quote() {
|
|
2563
|
+
return this.latestQuote;
|
|
2564
|
+
}
|
|
2565
|
+
/** Realtime connection state, if the datafeed reports it. */
|
|
2566
|
+
get connection() {
|
|
2567
|
+
return this.connectionState;
|
|
2568
|
+
}
|
|
2569
|
+
get currentResolution() {
|
|
2570
|
+
return this.resolution;
|
|
2571
|
+
}
|
|
2572
|
+
/** Reloads the current symbol at another resolution. */
|
|
2573
|
+
setResolution(resolution) {
|
|
2574
|
+
if (!this.symbol) throw new Error("funnest-charts: setResolution() needs a symbol loaded via setSymbol()");
|
|
2575
|
+
return this.setSymbol(this.symbol.symbol, resolution);
|
|
2576
|
+
}
|
|
2577
|
+
/** Plot geometry, for positioning overlays. */
|
|
2578
|
+
get layout() {
|
|
2579
|
+
const f = this.frame;
|
|
2580
|
+
return {
|
|
2581
|
+
width: this.size.width,
|
|
2582
|
+
height: this.size.height,
|
|
2583
|
+
plotWidth: f?.plotWidth ?? 0,
|
|
2584
|
+
panesHeight: f?.panesHeight ?? 0,
|
|
2585
|
+
timeAxisHeight: TIME_AXIS_HEIGHT,
|
|
2586
|
+
panes: (f?.panes ?? []).map((p) => ({ id: p.id, kind: p.kind, top: p.top, height: p.height }))
|
|
2587
|
+
};
|
|
2588
|
+
}
|
|
2589
|
+
/**
|
|
2590
|
+
* Reserves `px` at the top of the price pane so overlays (like a legend) never cover
|
|
2591
|
+
* the candles. Auto-scaled data is laid out below the inset.
|
|
2592
|
+
*/
|
|
2593
|
+
setTopInset(px) {
|
|
2594
|
+
const value = Math.max(0, Math.round(px));
|
|
2595
|
+
if (value === this.mainScale.insetTop) return;
|
|
2596
|
+
this.mainScale.insetTop = value;
|
|
2597
|
+
this.invalidate();
|
|
2598
|
+
}
|
|
2599
|
+
/** factor > 1 zooms in. Anchored to the latest bar when it's on screen, otherwise to the center. */
|
|
2600
|
+
zoom(factor) {
|
|
2601
|
+
const anchor = this.isAtRealtime() ? this.timeScale.width : this.timeScale.width / 2;
|
|
2602
|
+
this.timeScale.zoom(anchor, factor);
|
|
2603
|
+
this.invalidate();
|
|
2604
|
+
}
|
|
2605
|
+
/** Scrolls by a number of bars; positive moves toward newer bars. */
|
|
2606
|
+
scrollBars(bars) {
|
|
2607
|
+
this.timeScale.pan(-bars * this.timeScale.barSpacing);
|
|
2608
|
+
this.invalidate();
|
|
2609
|
+
}
|
|
2610
|
+
/** True when the newest bar is visible. */
|
|
2611
|
+
isAtRealtime() {
|
|
2612
|
+
return this.store.length === 0 || this.timeScale.visibleRange().to >= this.store.length - 1;
|
|
2613
|
+
}
|
|
2614
|
+
/** Snapshot of the chart (without the crosshair) at device resolution. */
|
|
2615
|
+
takeScreenshot() {
|
|
2616
|
+
if (this.rafId || this.mainDirty) this.render();
|
|
2617
|
+
const src2 = this.mainLayer.canvas;
|
|
2618
|
+
const out = document.createElement("canvas");
|
|
2619
|
+
out.width = src2.width;
|
|
2620
|
+
out.height = src2.height;
|
|
2621
|
+
out.getContext("2d")?.drawImage(src2, 0, 0);
|
|
2622
|
+
return out;
|
|
2623
|
+
}
|
|
2624
|
+
/**
|
|
2625
|
+
* Re-fetches the latest window and merges it in, keeping the view. Datafeeds trigger this
|
|
2626
|
+
* via the `onResetCache` callback of subscribeBars after a WebSocket reconnect, so bars
|
|
2627
|
+
* missed while disconnected are filled in.
|
|
2628
|
+
*/
|
|
2629
|
+
async resync(token) {
|
|
2630
|
+
const feed = this.datafeed;
|
|
2631
|
+
if (!feed || !this.symbol || !this.resolution) return;
|
|
2632
|
+
try {
|
|
2633
|
+
const to = Date.now();
|
|
2634
|
+
const result = await feed.getBars(this.symbol, this.resolution, {
|
|
2635
|
+
from: to - resolutionToMs(this.resolution) * this.historyBatchSize,
|
|
2636
|
+
to,
|
|
2637
|
+
countBack: this.historyBatchSize
|
|
2638
|
+
});
|
|
2639
|
+
if (token !== this.loadToken) return;
|
|
2640
|
+
const lastBefore = this.store.last()?.time ?? -Infinity;
|
|
2641
|
+
const before = this.store.length;
|
|
2642
|
+
this.store.merge(result.bars);
|
|
2643
|
+
if (this.timeScale.rightOffset < 0) {
|
|
2644
|
+
const appended = result.bars.filter((b) => b.time > lastBefore).length;
|
|
2645
|
+
this.timeScale.rightOffset -= Math.min(appended, this.store.length - before);
|
|
2646
|
+
}
|
|
2647
|
+
this.timeScale.barCount = this.store.length;
|
|
2648
|
+
this.dataVersion++;
|
|
2649
|
+
this.invalidate();
|
|
2650
|
+
} catch (error) {
|
|
2651
|
+
this.emitter.emit("error", error);
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
// ── Chart type ─────────────────────────────────────────────────────
|
|
2655
|
+
get type() {
|
|
2656
|
+
return this.chartType;
|
|
2657
|
+
}
|
|
2658
|
+
setChartType(type) {
|
|
2659
|
+
if (type === this.chartType) return;
|
|
2660
|
+
this.chartType = type;
|
|
2661
|
+
this.invalidate();
|
|
2662
|
+
this.emitter.emit("chartTypeChange", type);
|
|
2663
|
+
this.stateChanged();
|
|
2664
|
+
}
|
|
2665
|
+
// ── Settings & state ───────────────────────────────────────────────
|
|
2666
|
+
get settings() {
|
|
2667
|
+
return { ...this.settingsValue };
|
|
2668
|
+
}
|
|
2669
|
+
setSettings(patch) {
|
|
2670
|
+
this.settingsValue = { ...this.settingsValue, ...patch };
|
|
2671
|
+
this.invalidate();
|
|
2672
|
+
this.emitter.emit("settingsChange", this.settings);
|
|
2673
|
+
this.stateChanged();
|
|
2674
|
+
}
|
|
2675
|
+
/** Serializable snapshot of the layout. Pair with `load()`; see `stateChange` for autosave. */
|
|
2676
|
+
save() {
|
|
2677
|
+
const drawings = Object.fromEntries(this.drawingsBySymbol);
|
|
2678
|
+
drawings[this.symbol?.symbol ?? ""] = this.getDrawings();
|
|
2679
|
+
return {
|
|
2680
|
+
version: 1,
|
|
2681
|
+
symbol: this.symbol?.symbol ?? null,
|
|
2682
|
+
resolution: this.resolution,
|
|
2683
|
+
chartType: this.chartType,
|
|
2684
|
+
settings: this.settings,
|
|
2685
|
+
indicators: this.getIndicators(),
|
|
2686
|
+
drawings: JSON.parse(JSON.stringify(drawings)),
|
|
2687
|
+
view: { barSpacing: this.timeScale.barSpacing, rightOffset: this.timeScale.rightOffset }
|
|
2688
|
+
};
|
|
2689
|
+
}
|
|
2690
|
+
/**
|
|
2691
|
+
* Restores a layout from `save()` (or JSON from storage — it's validated). Loads the saved
|
|
2692
|
+
* symbol/interval through the datafeed first unless `{ symbol: false }`.
|
|
2693
|
+
*/
|
|
2694
|
+
async load(input, options = {}) {
|
|
2695
|
+
const state = parseChartState(input);
|
|
2696
|
+
this.drawingsBySymbol.clear();
|
|
2697
|
+
for (const [symbol, list] of Object.entries(state.drawings)) this.drawingsBySymbol.set(symbol, list);
|
|
2698
|
+
this.restoreDrawingsFor(this.symbol?.symbol ?? "");
|
|
2699
|
+
const wantSymbol = options.symbol !== false && this.datafeed && state.symbol;
|
|
2700
|
+
if (wantSymbol && (state.symbol !== this.symbol?.symbol || state.resolution !== this.resolution)) {
|
|
2701
|
+
await this.setSymbol(state.symbol, state.resolution ?? this.resolution ?? "1D");
|
|
2702
|
+
}
|
|
2703
|
+
this.settingsValue = state.settings;
|
|
2704
|
+
this.emitter.emit("settingsChange", this.settings);
|
|
2705
|
+
this.setChartType(state.chartType);
|
|
2706
|
+
this.setIndicators(state.indicators);
|
|
2707
|
+
this.timeScale.barSpacing = state.view.barSpacing;
|
|
2708
|
+
this.timeScale.rightOffset = state.view.rightOffset;
|
|
2709
|
+
this.timeScale.clamp();
|
|
2710
|
+
this.invalidate();
|
|
2711
|
+
this.stateChanged();
|
|
2712
|
+
}
|
|
2713
|
+
/** Swaps the active drawing set to `symbol`'s. */
|
|
2714
|
+
restoreDrawingsFor(symbol) {
|
|
2715
|
+
const list = this.drawingsBySymbol.get(symbol) ?? [];
|
|
2716
|
+
this.drawingsBySymbol.delete(symbol);
|
|
2717
|
+
this.drawings.set(list);
|
|
2718
|
+
}
|
|
2719
|
+
stateChanged() {
|
|
2720
|
+
if (this.stateQueued) return;
|
|
2721
|
+
this.stateQueued = true;
|
|
2722
|
+
queueMicrotask(() => {
|
|
2723
|
+
this.stateQueued = false;
|
|
2724
|
+
if (!this.destroyed) this.emitter.emit("stateChange", void 0);
|
|
2725
|
+
});
|
|
2726
|
+
}
|
|
2727
|
+
// ── Drawings ───────────────────────────────────────────────────────
|
|
2728
|
+
/** Arms a drawing tool (null = cursor). It disarms itself after one drawing is placed. */
|
|
2729
|
+
setDrawingTool(tool) {
|
|
2730
|
+
this.drawings.setTool(tool);
|
|
2731
|
+
}
|
|
2732
|
+
get drawingTool() {
|
|
2733
|
+
return this.drawings.tool;
|
|
2734
|
+
}
|
|
2735
|
+
/** Magnet mode: drawing points snap to the nearest open/high/low/close of the bar. */
|
|
2736
|
+
setMagnet(on) {
|
|
2737
|
+
if (on === this.magnet) return;
|
|
2738
|
+
this.magnet = on;
|
|
2739
|
+
this.emitter.emit("magnetChange", on);
|
|
2740
|
+
}
|
|
2741
|
+
get magnetEnabled() {
|
|
2742
|
+
return this.magnet;
|
|
2743
|
+
}
|
|
2744
|
+
getDrawings() {
|
|
2745
|
+
return this.drawings.drawings.map((d) => ({ ...d, points: d.points.map((p) => ({ ...p })) }));
|
|
2746
|
+
}
|
|
2747
|
+
/** Restores drawings (e.g. from storage). */
|
|
2748
|
+
setDrawings(drawings) {
|
|
2749
|
+
this.drawings.set(drawings);
|
|
2750
|
+
}
|
|
2751
|
+
updateDrawing(id, patch) {
|
|
2752
|
+
this.drawings.update(id, patch);
|
|
2753
|
+
}
|
|
2754
|
+
removeDrawing(id) {
|
|
2755
|
+
this.drawings.remove(id);
|
|
2756
|
+
}
|
|
2757
|
+
clearDrawings() {
|
|
2758
|
+
this.drawings.clear();
|
|
2759
|
+
}
|
|
2760
|
+
get selectedDrawing() {
|
|
2761
|
+
return this.drawings.selected;
|
|
2762
|
+
}
|
|
2763
|
+
selectDrawing(id) {
|
|
2764
|
+
this.drawings.select(id);
|
|
2765
|
+
}
|
|
2766
|
+
// ── Indicators ─────────────────────────────────────────────────────
|
|
2767
|
+
/** Adds an indicator (overlays draw on the candles, others get their own pane). Returns its id. */
|
|
2768
|
+
addIndicator(type, params) {
|
|
2769
|
+
const id = this.indicators.add(type, params);
|
|
2770
|
+
this.indicatorsChanged();
|
|
2771
|
+
return id;
|
|
2772
|
+
}
|
|
2773
|
+
removeIndicator(id) {
|
|
2774
|
+
if (!this.indicators.remove(id)) return;
|
|
2775
|
+
this.indicatorScales.delete(id);
|
|
2776
|
+
this.indicatorsChanged();
|
|
2777
|
+
}
|
|
2778
|
+
/** Changes params, per-series styles (color/width/visible) and/or overall visibility. */
|
|
2779
|
+
updateIndicator(id, patch) {
|
|
2780
|
+
if (this.indicators.update(id, patch)) this.indicatorsChanged();
|
|
2781
|
+
}
|
|
2782
|
+
setIndicatorVisible(id, visible) {
|
|
2783
|
+
this.updateIndicator(id, { visible });
|
|
2784
|
+
}
|
|
2785
|
+
getIndicators() {
|
|
2786
|
+
return this.indicators.snapshot();
|
|
2787
|
+
}
|
|
2788
|
+
/** Restores indicators (e.g. from storage). */
|
|
2789
|
+
setIndicators(configs) {
|
|
2790
|
+
this.indicators.set(configs);
|
|
2791
|
+
this.indicatorScales.clear();
|
|
2792
|
+
this.indicatorsChanged();
|
|
2793
|
+
}
|
|
2794
|
+
/** Formatted indicator values at a bar index (default: the last bar), for legends. */
|
|
2795
|
+
indicatorLegend(index = this.store.length - 1) {
|
|
2796
|
+
return this.indicators.list.map((cfg) => {
|
|
2797
|
+
const def = INDICATORS[cfg.type];
|
|
2798
|
+
const output = this.indicators.output(cfg, this.store.bars, this.dataVersion);
|
|
2799
|
+
const precision = output.precision ?? this.pricePrecision;
|
|
2800
|
+
return {
|
|
2801
|
+
id: cfg.id,
|
|
2802
|
+
type: cfg.type,
|
|
2803
|
+
title: def.title(cfg.params),
|
|
2804
|
+
overlay: def.overlay,
|
|
2805
|
+
visible: cfg.visible !== false,
|
|
2806
|
+
values: output.series.filter((series) => series.visible).map((series) => {
|
|
2807
|
+
const v = series.values[index];
|
|
2808
|
+
return {
|
|
2809
|
+
key: series.key,
|
|
2810
|
+
label: series.label,
|
|
2811
|
+
value: v === void 0 || Number.isNaN(v) ? "\u2014" : formatPrice(v, precision),
|
|
2812
|
+
color: series.kind === "histogram" ? v !== void 0 && v < 0 ? this.theme.down : this.theme.up : series.color
|
|
2813
|
+
};
|
|
2814
|
+
})
|
|
2815
|
+
};
|
|
2816
|
+
});
|
|
2817
|
+
}
|
|
2818
|
+
indicatorsChanged() {
|
|
2819
|
+
this.invalidate();
|
|
2820
|
+
this.emitter.emit("indicatorsChange", this.getIndicators());
|
|
2821
|
+
this.stateChanged();
|
|
2822
|
+
}
|
|
2823
|
+
indicatorScale(id) {
|
|
2824
|
+
let scale = this.indicatorScales.get(id);
|
|
2825
|
+
if (!scale) this.indicatorScales.set(id, scale = new PriceScale({ top: 0.24, bottom: 0.08 }));
|
|
2826
|
+
return scale;
|
|
2827
|
+
}
|
|
2828
|
+
// ── Trading ────────────────────────────────────────────────────────
|
|
2829
|
+
/**
|
|
2830
|
+
* Connects the user's account (fills, positions, orders). The chart loads them for the
|
|
2831
|
+
* current symbol, pages older fills with history and applies live updates. Pass null to detach.
|
|
2832
|
+
*/
|
|
2833
|
+
setTradingAdapter(adapter) {
|
|
2834
|
+
this.tradingSync.adapter = adapter;
|
|
2835
|
+
if (adapter && this.symbol) void this.tradingSync.load(this.symbol, this.store.first()?.time ?? Date.now());
|
|
2836
|
+
else this.tradingSync.detach();
|
|
2837
|
+
}
|
|
2838
|
+
/** Average-price line with live P&L against the last price. */
|
|
2839
|
+
addPositionLine(options) {
|
|
2840
|
+
return this.trading.addPosition(options);
|
|
2841
|
+
}
|
|
2842
|
+
/** Pending order line. Pass `onMove` to make it draggable and `onCancel` for an × button. */
|
|
2843
|
+
addOrderLine(options) {
|
|
2844
|
+
return this.trading.addOrder(options);
|
|
2845
|
+
}
|
|
2846
|
+
/** Fill markers (▲ buy under the bar, ▼ sell above). Replaces the previous set. */
|
|
2847
|
+
setExecutions(markers) {
|
|
2848
|
+
this.trading.setExecutions(markers);
|
|
2849
|
+
}
|
|
2850
|
+
setTheme(theme) {
|
|
2851
|
+
this.theme = { ...this.theme, ...theme };
|
|
2852
|
+
this.colors.clear();
|
|
2853
|
+
this.invalidate();
|
|
2854
|
+
}
|
|
2855
|
+
/** Re-reads CSS variables used by the theme (call after changing them outside of class/style attributes). */
|
|
2856
|
+
refreshTheme() {
|
|
2857
|
+
this.colors.clear();
|
|
2858
|
+
this.invalidate();
|
|
2859
|
+
}
|
|
2860
|
+
setLocale(locale) {
|
|
2861
|
+
this.locale = locale;
|
|
2862
|
+
this.formatter = new TimeFormatter(this.formatter.timeZone, locale);
|
|
2863
|
+
this.invalidate();
|
|
2864
|
+
}
|
|
2865
|
+
setTimezone(timeZone) {
|
|
2866
|
+
this.formatter = new TimeFormatter(this.displayTimezone ?? timeZone, this.locale);
|
|
2867
|
+
this.invalidate();
|
|
2868
|
+
}
|
|
2869
|
+
setPriceFormat(format) {
|
|
2870
|
+
if (format.precision !== void 0) this.pricePrecision = format.precision;
|
|
2871
|
+
if (format.tickSize !== void 0) this.tickSize = format.tickSize;
|
|
2872
|
+
this.invalidate();
|
|
2873
|
+
}
|
|
2874
|
+
setVolumeVisible(visible) {
|
|
2875
|
+
this.setSettings({ volume: visible });
|
|
2876
|
+
}
|
|
2877
|
+
fitContent() {
|
|
2878
|
+
this.timeScale.fitContent();
|
|
2879
|
+
this.mainScale.autoScale = true;
|
|
2880
|
+
this.invalidate();
|
|
2881
|
+
}
|
|
2882
|
+
scrollToRealtime() {
|
|
2883
|
+
this.timeScale.rightOffset = this.timeScale.options.rightOffset;
|
|
2884
|
+
this.timeScale.clamp();
|
|
2885
|
+
this.invalidate();
|
|
2886
|
+
}
|
|
2887
|
+
resetView() {
|
|
2888
|
+
this.timeScale.reset();
|
|
2889
|
+
this.mainScale.autoScale = true;
|
|
2890
|
+
this.volumeScale.autoScale = true;
|
|
2891
|
+
for (const scale of this.indicatorScales.values()) scale.autoScale = true;
|
|
2892
|
+
this.invalidate();
|
|
2893
|
+
}
|
|
2894
|
+
on(event, listener) {
|
|
2895
|
+
return this.emitter.on(event, listener);
|
|
2896
|
+
}
|
|
2897
|
+
destroy() {
|
|
2898
|
+
if (this.destroyed) return;
|
|
2899
|
+
this.destroyed = true;
|
|
2900
|
+
this.loadToken++;
|
|
2901
|
+
this.unsubscribeBars?.();
|
|
2902
|
+
this.unsubscribeQuotes?.();
|
|
2903
|
+
this.tradingSync.detach();
|
|
2904
|
+
if (this.rafId) cancelAnimationFrame(this.rafId);
|
|
2905
|
+
this.interaction.destroy();
|
|
2906
|
+
for (const cleanup of this.cleanups) cleanup();
|
|
2907
|
+
this.emitter.clear();
|
|
2908
|
+
this.root.remove();
|
|
2909
|
+
}
|
|
2910
|
+
// ── Scheduling & rendering ─────────────────────────────────────────
|
|
2911
|
+
/** Coalesces any number of changes into one paint per animation frame. */
|
|
2912
|
+
invalidate(main = true) {
|
|
2913
|
+
if (this.destroyed) return;
|
|
2914
|
+
if (main) this.mainDirty = true;
|
|
2915
|
+
if (!this.rafId) this.rafId = requestAnimationFrame(() => this.render());
|
|
2916
|
+
}
|
|
2917
|
+
render() {
|
|
2918
|
+
this.rafId = 0;
|
|
2919
|
+
if (this.destroyed || this.size.width <= 0 || this.size.height <= 0) return;
|
|
2920
|
+
if (this.mainDirty || !this.frame) {
|
|
2921
|
+
this.mainDirty = false;
|
|
2922
|
+
this.frame = this.computeFrame();
|
|
2923
|
+
drawMain(this.mainLayer, this.renderModel(this.frame));
|
|
2924
|
+
this.afterFrame();
|
|
2925
|
+
}
|
|
2926
|
+
const crosshair = this.resolveCrosshair(this.frame);
|
|
2927
|
+
drawOverlay(this.overlayLayer, this.renderModel(this.frame), crosshair, this.executionTooltip());
|
|
2928
|
+
this.emitCrosshair(crosshair);
|
|
2929
|
+
}
|
|
2930
|
+
renderModel(frame) {
|
|
2931
|
+
return {
|
|
2932
|
+
frame,
|
|
2933
|
+
bars: this.store.bars,
|
|
2934
|
+
timeScale: this.timeScale,
|
|
2935
|
+
formatPrice: (p) => formatPrice(p, this.pricePrecision),
|
|
2936
|
+
formatVolume: (v) => formatCompact(v, this.locale),
|
|
2937
|
+
formatCrosshairTime: (t) => this.formatter.crosshairLabel(t, this.isIntraday()),
|
|
2938
|
+
labels: LABELS[this.locale],
|
|
2939
|
+
chartType: this.chartType,
|
|
2940
|
+
objects: {
|
|
2941
|
+
coords: this.paneCoords(),
|
|
2942
|
+
drawings: this.drawings,
|
|
2943
|
+
trading: this.trading,
|
|
2944
|
+
tradingLabels: TRADING_LABELS[this.locale],
|
|
2945
|
+
barIndexAt: (time) => this.store.indexAtOrBefore(time)
|
|
2946
|
+
}
|
|
2947
|
+
};
|
|
2948
|
+
}
|
|
2949
|
+
/** Approximate ms per bar: the resolution when known, else the smallest recent gap. */
|
|
2950
|
+
barInterval() {
|
|
2951
|
+
if (this.resolution) return resolutionToMs(this.resolution);
|
|
2952
|
+
const bars = this.store.bars;
|
|
2953
|
+
let gap = Infinity;
|
|
2954
|
+
for (let i = Math.max(1, bars.length - 20); i < bars.length; i++) gap = Math.min(gap, bars[i].time - bars[i - 1].time);
|
|
2955
|
+
return Number.isFinite(gap) && gap > 0 ? gap : 6e4;
|
|
2956
|
+
}
|
|
2957
|
+
paneCoords() {
|
|
2958
|
+
const interval = this.barInterval();
|
|
2959
|
+
const ts = this.timeScale;
|
|
2960
|
+
const scale = this.mainScale;
|
|
2961
|
+
return {
|
|
2962
|
+
plotWidth: ts.width,
|
|
2963
|
+
height: scale.height,
|
|
2964
|
+
timeToX: (time) => ts.indexToX(this.store.indexFloatOfTime(time, interval)),
|
|
2965
|
+
pointAt: (x, y) => {
|
|
2966
|
+
const index = Math.round(ts.xToIndexFloat(x));
|
|
2967
|
+
const bar = this.magnet ? this.store.at(index) : void 0;
|
|
2968
|
+
return {
|
|
2969
|
+
time: this.store.timeOfIndexFloat(index, interval),
|
|
2970
|
+
// Magnet: stick to the nearest OHLC of the bar under the pointer (no bar → free).
|
|
2971
|
+
price: bar ? snapToOhlc(bar, y, (p) => scale.priceToY(p)) : roundToTick(scale.yToPrice(y), this.tickSize)
|
|
2972
|
+
};
|
|
2973
|
+
},
|
|
2974
|
+
priceToY: (p) => scale.priceToY(p),
|
|
2975
|
+
yToPrice: (y) => scale.yToPrice(y),
|
|
2976
|
+
snapPrice: (p) => roundToTick(p, this.tickSize),
|
|
2977
|
+
formatPrice: (p) => formatPrice(p, this.pricePrecision)
|
|
2978
|
+
};
|
|
2979
|
+
}
|
|
2980
|
+
computeFrame() {
|
|
2981
|
+
const { width, height } = this.size;
|
|
2982
|
+
const bars = this.store.bars;
|
|
2983
|
+
const panesHeight = Math.max(0, height - TIME_AXIS_HEIGHT);
|
|
2984
|
+
this.timeScale.barCount = this.store.length;
|
|
2985
|
+
const indicatorPanes = this.indicators.panes;
|
|
2986
|
+
const totalWeight = PANE_WEIGHT.main + (this.settingsValue.volume ? PANE_WEIGHT.volume : 0) + indicatorPanes.length * PANE_WEIGHT.indicator;
|
|
2987
|
+
const share = (w) => Math.round(panesHeight * w / totalWeight);
|
|
2988
|
+
const volumeHeight = this.settingsValue.volume ? share(PANE_WEIGHT.volume) : 0;
|
|
2989
|
+
const indicatorHeight = share(PANE_WEIGHT.indicator);
|
|
2990
|
+
const mainHeight = panesHeight - volumeHeight - indicatorHeight * indicatorPanes.length;
|
|
2991
|
+
this.mainScale.height = mainHeight;
|
|
2992
|
+
this.volumeScale.height = volumeHeight;
|
|
2993
|
+
const drawable = (config) => {
|
|
2994
|
+
const { fill, ...out } = this.indicators.output(config, bars, this.dataVersion);
|
|
2995
|
+
if (config.visible === false) return { ...out, series: [] };
|
|
2996
|
+
const series = out.series.filter((x) => x.visible);
|
|
2997
|
+
const shown = (key) => series.some((x) => x.key === key);
|
|
2998
|
+
return fill && shown(fill.upper) && shown(fill.lower) ? { ...out, series, fill } : { ...out, series };
|
|
2999
|
+
};
|
|
3000
|
+
const overlays = this.indicators.overlays.map((config) => ({ config, output: drawable(config) }));
|
|
3001
|
+
const subPanes = indicatorPanes.map((config) => {
|
|
3002
|
+
const scale = this.indicatorScale(config.id);
|
|
3003
|
+
scale.height = indicatorHeight;
|
|
3004
|
+
return { config, scale, output: drawable(config) };
|
|
3005
|
+
});
|
|
3006
|
+
const indicatorFormat = (output) => {
|
|
3007
|
+
const precision = output.precision ?? this.pricePrecision;
|
|
3008
|
+
return (v) => formatPrice(v, precision);
|
|
3009
|
+
};
|
|
3010
|
+
const tickFormat = (ticks, output) => {
|
|
3011
|
+
const step = ticks.length > 1 ? Math.abs(ticks[1] - ticks[0]) : 1;
|
|
3012
|
+
const decimals = Math.min(output.precision ?? this.pricePrecision, decimalsOf(step));
|
|
3013
|
+
return (v) => formatPrice(v, decimals);
|
|
3014
|
+
};
|
|
3015
|
+
const fontFamily = this.colors.fontFamily(this.theme.fontFamily);
|
|
3016
|
+
const font = `${this.theme.fontSize}px ${fontFamily}`;
|
|
3017
|
+
const ctx = this.mainLayer.ctx;
|
|
3018
|
+
ctx.font = font;
|
|
3019
|
+
let range = { from: 0, to: -1 };
|
|
3020
|
+
let highLow = null;
|
|
3021
|
+
let priceTicks = [];
|
|
3022
|
+
let subTicks = [];
|
|
3023
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
3024
|
+
this.timeScale.width = Math.max(0, width - this.priceAxisWidth);
|
|
3025
|
+
this.timeScale.clamp();
|
|
3026
|
+
range = this.timeScale.visibleRange();
|
|
3027
|
+
highLow = this.autoScale(range, overlays.map((o) => o.output));
|
|
3028
|
+
for (const pane of subPanes) this.autoScaleIndicator(pane.scale, pane.output, range);
|
|
3029
|
+
const mid = (this.mainScale.min + this.mainScale.max) / 2;
|
|
3030
|
+
priceTicks = niceTicks(
|
|
3031
|
+
this.mainScale.yToPrice(mainHeight),
|
|
3032
|
+
this.mainScale.yToPrice(0),
|
|
3033
|
+
Math.max(2, Math.floor(mainHeight / PRICE_TICK_SPACING)),
|
|
3034
|
+
resolveTickSize(this.tickSize, mid)
|
|
3035
|
+
);
|
|
3036
|
+
subTicks = subPanes.map((p) => {
|
|
3037
|
+
const [lo, hi] = p.output.range ?? [p.scale.yToPrice(indicatorHeight), p.scale.yToPrice(0)];
|
|
3038
|
+
return niceTicks(lo, hi, Math.max(1, Math.floor(indicatorHeight / 36)));
|
|
3039
|
+
});
|
|
3040
|
+
let labelWidth = 0;
|
|
3041
|
+
const measure = (text) => labelWidth = Math.max(labelWidth, ctx.measureText(text).width);
|
|
3042
|
+
const last = this.store.last();
|
|
3043
|
+
for (const v of last ? [...priceTicks, last.close] : priceTicks) measure(formatPrice(v, this.pricePrecision));
|
|
3044
|
+
subPanes.forEach((p, i) => subTicks[i].forEach((v) => measure(tickFormat(subTicks[i], p.output)(v))));
|
|
3045
|
+
const needed = Math.max(MIN_PRICE_AXIS_WIDTH, Math.ceil((labelWidth + 20) / 8) * 8);
|
|
3046
|
+
if (needed === this.priceAxisWidth) break;
|
|
3047
|
+
this.priceAxisWidth = needed;
|
|
3048
|
+
}
|
|
3049
|
+
const plotWidth = this.timeScale.width;
|
|
3050
|
+
const panes = [
|
|
3051
|
+
{
|
|
3052
|
+
id: "main",
|
|
3053
|
+
kind: "main",
|
|
3054
|
+
top: 0,
|
|
3055
|
+
height: mainHeight,
|
|
3056
|
+
scale: this.mainScale,
|
|
3057
|
+
ticks: priceTicks,
|
|
3058
|
+
format: (v) => formatPrice(roundToTick(v, this.tickSize), this.pricePrecision)
|
|
3059
|
+
}
|
|
3060
|
+
];
|
|
3061
|
+
let top = mainHeight;
|
|
3062
|
+
if (volumeHeight > 0) {
|
|
3063
|
+
panes.push({
|
|
3064
|
+
id: "volume",
|
|
3065
|
+
kind: "volume",
|
|
3066
|
+
top,
|
|
3067
|
+
height: volumeHeight,
|
|
3068
|
+
scale: this.volumeScale,
|
|
3069
|
+
ticks: [],
|
|
3070
|
+
format: (v) => formatCompact(Math.max(0, v), this.locale)
|
|
3071
|
+
});
|
|
3072
|
+
top += volumeHeight;
|
|
3073
|
+
}
|
|
3074
|
+
subPanes.forEach((p, i) => {
|
|
3075
|
+
panes.push({
|
|
3076
|
+
id: p.config.id,
|
|
3077
|
+
kind: "indicator",
|
|
3078
|
+
top,
|
|
3079
|
+
height: indicatorHeight,
|
|
3080
|
+
scale: p.scale,
|
|
3081
|
+
ticks: subTicks[i],
|
|
3082
|
+
format: indicatorFormat(p.output),
|
|
3083
|
+
tickFormat: tickFormat(subTicks[i], p.output),
|
|
3084
|
+
indicator: p.output
|
|
3085
|
+
});
|
|
3086
|
+
top += indicatorHeight;
|
|
3087
|
+
});
|
|
3088
|
+
return {
|
|
3089
|
+
width,
|
|
3090
|
+
height,
|
|
3091
|
+
plotWidth,
|
|
3092
|
+
panesHeight,
|
|
3093
|
+
timeAxisHeight: TIME_AXIS_HEIGHT,
|
|
3094
|
+
panes,
|
|
3095
|
+
range,
|
|
3096
|
+
priceTicks,
|
|
3097
|
+
timeTicks: computeTimeTicks(this.store.bars, this.timeScale, this.formatter, TIME_LABEL_MIN_GAP),
|
|
3098
|
+
highLow,
|
|
3099
|
+
settings: this.settingsValue,
|
|
3100
|
+
overlays: overlays.map((o) => o.output),
|
|
3101
|
+
palette: this.resolvePalette(),
|
|
3102
|
+
font,
|
|
3103
|
+
radius: this.theme.radius
|
|
3104
|
+
};
|
|
3105
|
+
}
|
|
3106
|
+
/** Auto-scales the price pane to visible candles and overlay indicators; returns the candle extremes. */
|
|
3107
|
+
autoScale(range, overlays) {
|
|
3108
|
+
const bars = this.store.bars;
|
|
3109
|
+
let high = -Infinity;
|
|
3110
|
+
let low = Infinity;
|
|
3111
|
+
let highIndex = -1;
|
|
3112
|
+
let lowIndex = -1;
|
|
3113
|
+
let maxVolume = 0;
|
|
3114
|
+
for (let i = range.from; i <= range.to; i++) {
|
|
3115
|
+
const bar = bars[i];
|
|
3116
|
+
if (bar.high > high) {
|
|
3117
|
+
high = bar.high;
|
|
3118
|
+
highIndex = i;
|
|
3119
|
+
}
|
|
3120
|
+
if (bar.low < low) {
|
|
3121
|
+
low = bar.low;
|
|
3122
|
+
lowIndex = i;
|
|
3123
|
+
}
|
|
3124
|
+
if (bar.volume && bar.volume > maxVolume) maxVolume = bar.volume;
|
|
3125
|
+
}
|
|
3126
|
+
if (this.mainScale.autoScale && highIndex >= 0) {
|
|
3127
|
+
let min = low;
|
|
3128
|
+
let max = high;
|
|
3129
|
+
for (const output of overlays) {
|
|
3130
|
+
for (const series of output.series) {
|
|
3131
|
+
for (let i = range.from; i <= range.to; i++) {
|
|
3132
|
+
const v = series.values[i];
|
|
3133
|
+
if (v < min) min = v;
|
|
3134
|
+
if (v > max) max = v;
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
this.mainScale.setRange(min, max);
|
|
3139
|
+
}
|
|
3140
|
+
if (this.volumeScale.autoScale) this.volumeScale.setRange(0, maxVolume || 1);
|
|
3141
|
+
return highIndex >= 0 ? { highIndex, lowIndex } : null;
|
|
3142
|
+
}
|
|
3143
|
+
autoScaleIndicator(scale, output, range) {
|
|
3144
|
+
if (!scale.autoScale) return;
|
|
3145
|
+
if (output.range) {
|
|
3146
|
+
scale.setRange(output.range[0], output.range[1]);
|
|
3147
|
+
return;
|
|
3148
|
+
}
|
|
3149
|
+
let min = Infinity;
|
|
3150
|
+
let max = -Infinity;
|
|
3151
|
+
for (const series of output.series) {
|
|
3152
|
+
if (series.kind === "histogram") {
|
|
3153
|
+
min = Math.min(min, 0);
|
|
3154
|
+
max = Math.max(max, 0);
|
|
3155
|
+
}
|
|
3156
|
+
for (let i = range.from; i <= range.to; i++) {
|
|
3157
|
+
const v = series.values[i];
|
|
3158
|
+
if (v < min) min = v;
|
|
3159
|
+
if (v > max) max = v;
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
if (Number.isFinite(min) && Number.isFinite(max)) scale.setRange(min, max);
|
|
3163
|
+
else scale.setRange(0, 1);
|
|
3164
|
+
}
|
|
3165
|
+
resolvePalette() {
|
|
3166
|
+
const t = this.theme;
|
|
3167
|
+
const c = (v) => this.colors.color(v);
|
|
3168
|
+
return {
|
|
3169
|
+
background: c(t.background),
|
|
3170
|
+
text: c(t.text),
|
|
3171
|
+
textSecondary: c(t.textSecondary),
|
|
3172
|
+
grid: c(t.grid),
|
|
3173
|
+
border: c(t.border),
|
|
3174
|
+
crosshair: c(t.crosshair),
|
|
3175
|
+
up: c(this.settingsValue.upColor ?? t.up),
|
|
3176
|
+
down: c(this.settingsValue.downColor ?? t.down),
|
|
3177
|
+
accent: c(t.accent)
|
|
3178
|
+
};
|
|
3179
|
+
}
|
|
3180
|
+
isIntraday() {
|
|
3181
|
+
if (this.resolution) return isIntradayResolution(this.resolution);
|
|
3182
|
+
const bars = this.store.bars;
|
|
3183
|
+
let minGap = Infinity;
|
|
3184
|
+
for (let i = 1; i < Math.min(bars.length, 20); i++) minGap = Math.min(minGap, bars[i].time - bars[i - 1].time);
|
|
3185
|
+
return minGap < DAY_MS;
|
|
3186
|
+
}
|
|
3187
|
+
afterFrame() {
|
|
3188
|
+
const layout = this.layout;
|
|
3189
|
+
const layoutKey = `${layout.width}:${layout.height}:${layout.plotWidth}:${layout.panesHeight}:${layout.panes.map((p) => `${p.id}@${p.top}`).join(",")}`;
|
|
3190
|
+
if (layoutKey !== this.lastLayoutKey) {
|
|
3191
|
+
this.lastLayoutKey = layoutKey;
|
|
3192
|
+
this.emitter.emit("layoutChange", layout);
|
|
3193
|
+
}
|
|
3194
|
+
if (this.dataVersion !== this.emittedDataVersion) {
|
|
3195
|
+
this.emittedDataVersion = this.dataVersion;
|
|
3196
|
+
this.emitter.emit("dataChange", { last: this.store.last() });
|
|
3197
|
+
}
|
|
3198
|
+
const range = this.timeScale.visibleRange();
|
|
3199
|
+
const key = `${range.from}:${range.to}`;
|
|
3200
|
+
if (key !== this.lastRangeKey) {
|
|
3201
|
+
this.lastRangeKey = key;
|
|
3202
|
+
this.emitter.emit("visibleRangeChange", range);
|
|
3203
|
+
}
|
|
3204
|
+
if (this.store.length > 0 && this.timeScale.xToIndexFloat(0) < HISTORY_TRIGGER_BARS) this.requestHistory();
|
|
3205
|
+
}
|
|
3206
|
+
requestHistory() {
|
|
3207
|
+
const oldest = this.store.first();
|
|
3208
|
+
if (!oldest || this.loadingHistory || this.noMoreHistory || oldest.time === this.historyRequestedFor) return;
|
|
3209
|
+
this.historyRequestedFor = oldest.time;
|
|
3210
|
+
const feed = this.datafeed;
|
|
3211
|
+
if (!feed || !this.symbol || !this.resolution) {
|
|
3212
|
+
this.emitter.emit("needHistory", { oldestTime: oldest.time });
|
|
3213
|
+
return;
|
|
3214
|
+
}
|
|
3215
|
+
const token = this.loadToken;
|
|
3216
|
+
this.loadingHistory = true;
|
|
3217
|
+
const to = oldest.time - 1;
|
|
3218
|
+
feed.getBars(this.symbol, this.resolution, {
|
|
3219
|
+
from: to - resolutionToMs(this.resolution) * this.historyBatchSize,
|
|
3220
|
+
to,
|
|
3221
|
+
countBack: this.historyBatchSize
|
|
3222
|
+
}).then((result) => {
|
|
3223
|
+
if (token !== this.loadToken) return;
|
|
3224
|
+
this.prependData(result.bars, { noMoreData: result.noMoreData || result.bars.length === 0 });
|
|
3225
|
+
const first = this.store.first();
|
|
3226
|
+
if (first) void this.tradingSync.loadOlder(first.time);
|
|
3227
|
+
}).catch((error) => this.emitter.emit("error", error)).finally(() => {
|
|
3228
|
+
if (token === this.loadToken) this.loadingHistory = false;
|
|
3229
|
+
});
|
|
3230
|
+
}
|
|
3231
|
+
// ── Crosshair ──────────────────────────────────────────────────────
|
|
3232
|
+
resolveCrosshair(frame) {
|
|
3233
|
+
const ch = this.crosshair;
|
|
3234
|
+
const bars = this.store.bars;
|
|
3235
|
+
if (!ch || bars.length === 0) return null;
|
|
3236
|
+
if (ch.source === "mouse" && (ch.x < 0 || ch.x > frame.plotWidth || ch.y < 0 || ch.y > frame.panesHeight)) return null;
|
|
3237
|
+
const index = Math.min(bars.length - 1, Math.max(0, this.timeScale.xToIndex(ch.x)));
|
|
3238
|
+
const bar = bars[index];
|
|
3239
|
+
const x = this.timeScale.indexToX(index);
|
|
3240
|
+
const y = ch.source === "touch" ? this.mainScale.priceToY(bar.close) : ch.y;
|
|
3241
|
+
const pane = this.paneAt(frame, y);
|
|
3242
|
+
const raw = pane.scale.yToPrice(y - pane.top);
|
|
3243
|
+
const value = pane.kind === "main" ? roundToTick(raw, this.tickSize) : raw;
|
|
3244
|
+
return { index, time: bar.time, bar, value, pane: pane.id, x, y, source: ch.source };
|
|
3245
|
+
}
|
|
3246
|
+
paneAt(frame, y) {
|
|
3247
|
+
return frame.panes.find((p) => y >= p.top && y < p.top + p.height) ?? frame.panes[frame.panes.length - 1];
|
|
3248
|
+
}
|
|
3249
|
+
setTooltip(key) {
|
|
3250
|
+
if (key === this.tooltipKey) return;
|
|
3251
|
+
this.tooltipKey = key;
|
|
3252
|
+
this.invalidate(false);
|
|
3253
|
+
}
|
|
3254
|
+
executionTooltip() {
|
|
3255
|
+
const g = this.tooltipKey ? this.trading.executionGroup(this.tooltipKey) : void 0;
|
|
3256
|
+
if (!g) return null;
|
|
3257
|
+
const t = TOOLTIP_LABELS[this.locale];
|
|
3258
|
+
const side = g.side === "buy" ? t.buy : t.sell;
|
|
3259
|
+
const lines = [
|
|
3260
|
+
g.count > 1 ? `${side} ${t.fills(g.count)}` : side,
|
|
3261
|
+
`${g.count > 1 ? t.avg : t.price} ${formatPrice(roundToTick(g.averagePrice, this.tickSize), this.pricePrecision)}`
|
|
3262
|
+
];
|
|
3263
|
+
if (g.quantity > 0) lines.push(`${t.qty} ${Number(g.quantity.toFixed(8))}`);
|
|
3264
|
+
lines.push(this.formatter.crosshairLabel(g.time, true));
|
|
3265
|
+
return { x: g.x, y: g.y, side: g.side, lines };
|
|
3266
|
+
}
|
|
3267
|
+
emitCrosshair(info) {
|
|
3268
|
+
const key = info ? `${info.index}|${info.value}|${info.bar.close}|${info.bar.volume ?? ""}` : "";
|
|
3269
|
+
if (key === this.lastCrosshairKey) return;
|
|
3270
|
+
this.lastCrosshairKey = key;
|
|
3271
|
+
this.emitter.emit("crosshairMove", info);
|
|
3272
|
+
}
|
|
3273
|
+
// ── Interaction host ───────────────────────────────────────────────
|
|
3274
|
+
paneScale(pane) {
|
|
3275
|
+
if (pane === "main") return this.mainScale;
|
|
3276
|
+
if (pane === "volume") return this.volumeScale;
|
|
3277
|
+
return this.indicatorScales.get(pane) ?? this.mainScale;
|
|
3278
|
+
}
|
|
3279
|
+
createInteractionHost() {
|
|
3280
|
+
return {
|
|
3281
|
+
hitTest: (x, y) => {
|
|
3282
|
+
const f = this.frame;
|
|
3283
|
+
if (!f) return { area: "none", pane: null };
|
|
3284
|
+
if (y >= f.panesHeight) return { area: x < f.plotWidth ? "timeAxis" : "none", pane: null };
|
|
3285
|
+
return { area: x >= f.plotWidth ? "priceAxis" : "plot", pane: this.paneAt(f, y).id };
|
|
3286
|
+
},
|
|
3287
|
+
plotWidth: () => this.timeScale.width,
|
|
3288
|
+
setCrosshair: (x, y, source) => {
|
|
3289
|
+
this.crosshair = { x, y, source };
|
|
3290
|
+
this.invalidate(false);
|
|
3291
|
+
},
|
|
3292
|
+
clearCrosshair: () => {
|
|
3293
|
+
if (!this.crosshair) return;
|
|
3294
|
+
this.crosshair = null;
|
|
3295
|
+
this.invalidate(false);
|
|
3296
|
+
},
|
|
3297
|
+
panTime: (dx) => {
|
|
3298
|
+
this.timeScale.pan(dx);
|
|
3299
|
+
this.invalidate();
|
|
3300
|
+
},
|
|
3301
|
+
zoomTime: (anchorX, factor) => {
|
|
3302
|
+
this.timeScale.zoom(anchorX, factor);
|
|
3303
|
+
this.invalidate();
|
|
3304
|
+
},
|
|
3305
|
+
panPrice: (pane, dy) => {
|
|
3306
|
+
const scale = this.paneScale(pane);
|
|
3307
|
+
if (scale.autoScale || pane !== "main") return;
|
|
3308
|
+
scale.pan(dy);
|
|
3309
|
+
this.invalidate();
|
|
3310
|
+
},
|
|
3311
|
+
scalePrice: (pane, factor) => {
|
|
3312
|
+
this.paneScale(pane).zoom(factor);
|
|
3313
|
+
this.invalidate();
|
|
3314
|
+
},
|
|
3315
|
+
resetPriceScale: (pane) => {
|
|
3316
|
+
this.paneScale(pane).autoScale = true;
|
|
3317
|
+
this.invalidate();
|
|
3318
|
+
},
|
|
3319
|
+
resetView: () => this.resetView(),
|
|
3320
|
+
toolActive: () => this.drawings.tool !== null && this.store.length > 0,
|
|
3321
|
+
hitObject: (x, y, touch) => {
|
|
3322
|
+
if (y > this.mainScale.height) return null;
|
|
3323
|
+
const tolerance = touch ? HIT_TOLERANCE.touch : HIT_TOLERANCE.mouse;
|
|
3324
|
+
return this.trading.hitTest((p) => this.mainScale.priceToY(p), x, y, tolerance) ?? this.drawings.hitTest(this.paneCoords(), x, y, tolerance);
|
|
3325
|
+
},
|
|
3326
|
+
drawAt: (x, y) => this.drawings.createAt(this.paneCoords(), x, y),
|
|
3327
|
+
drawPreview: (x, y) => this.drawings.previewAt(this.paneCoords(), x, y),
|
|
3328
|
+
drawRelease: (x, y, moved) => this.drawings.releaseAt(this.paneCoords(), x, y, moved),
|
|
3329
|
+
objectDown: (hit, x, y) => {
|
|
3330
|
+
this.objectDrag = hit;
|
|
3331
|
+
if (hit.kind === "drawing") this.drawings.beginDrag(this.paneCoords(), hit.id, hit.part, x, y);
|
|
3332
|
+
else if (hit.kind === "execution") this.setTooltip(hit.key);
|
|
3333
|
+
else if (hit.part === "line") this.trading.beginDrag(hit.id);
|
|
3334
|
+
},
|
|
3335
|
+
objectMove: (x, y) => {
|
|
3336
|
+
const hit = this.objectDrag;
|
|
3337
|
+
if (!hit) return;
|
|
3338
|
+
if (hit.kind === "drawing") this.drawings.dragTo(this.paneCoords(), x, y);
|
|
3339
|
+
else if (hit.kind === "order" && hit.part === "line") {
|
|
3340
|
+
this.trading.dragTo(roundToTick(this.mainScale.yToPrice(y), this.tickSize));
|
|
3341
|
+
}
|
|
3342
|
+
},
|
|
3343
|
+
objectUp: (moved) => {
|
|
3344
|
+
const hit = this.objectDrag;
|
|
3345
|
+
this.objectDrag = null;
|
|
3346
|
+
if (!hit) return;
|
|
3347
|
+
if (hit.kind === "drawing") this.drawings.endDrag(moved);
|
|
3348
|
+
else if (hit.kind === "order" && hit.part === "line") this.trading.endDrag();
|
|
3349
|
+
else if (hit.kind === "order" && !moved) this.trading.cancel(hit.id);
|
|
3350
|
+
},
|
|
3351
|
+
hoverObject: (hit) => this.setTooltip(hit?.kind === "execution" ? hit.key : null),
|
|
3352
|
+
clearSelection: () => {
|
|
3353
|
+
this.drawings.select(null);
|
|
3354
|
+
this.setTooltip(null);
|
|
3355
|
+
}
|
|
3356
|
+
};
|
|
3357
|
+
}
|
|
3358
|
+
/** Esc cancels a drawing in progress or deselects; Delete/Backspace removes the selected drawing. */
|
|
3359
|
+
observeKeyboard() {
|
|
3360
|
+
const onKeyDown = (e) => {
|
|
3361
|
+
const target = e.target;
|
|
3362
|
+
if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
|
|
3363
|
+
if (e.key === "Escape" && (this.drawings.tool || this.drawings.selectedId)) {
|
|
3364
|
+
this.drawings.cancel();
|
|
3365
|
+
} else if ((e.key === "Delete" || e.key === "Backspace") && this.drawings.selectedId) {
|
|
3366
|
+
e.preventDefault();
|
|
3367
|
+
this.drawings.remove(this.drawings.selectedId);
|
|
3368
|
+
}
|
|
3369
|
+
};
|
|
3370
|
+
document.addEventListener("keydown", onKeyDown);
|
|
3371
|
+
this.cleanups.push(() => document.removeEventListener("keydown", onKeyDown));
|
|
3372
|
+
}
|
|
3373
|
+
// ── Environment observers ──────────────────────────────────────────
|
|
3374
|
+
observeSize() {
|
|
3375
|
+
const apply = (width, height) => {
|
|
3376
|
+
const dpr = window.devicePixelRatio || 1;
|
|
3377
|
+
if (width === this.size.width && height === this.size.height && dpr === this.size.dpr) return;
|
|
3378
|
+
this.size = { width, height, dpr };
|
|
3379
|
+
this.mainLayer.resize(width, height, dpr);
|
|
3380
|
+
this.overlayLayer.resize(width, height, dpr);
|
|
3381
|
+
this.invalidate();
|
|
3382
|
+
};
|
|
3383
|
+
const ro = new ResizeObserver((entries) => {
|
|
3384
|
+
const rect2 = entries[entries.length - 1]?.contentRect;
|
|
3385
|
+
if (rect2) apply(Math.floor(rect2.width), Math.floor(rect2.height));
|
|
3386
|
+
});
|
|
3387
|
+
ro.observe(this.root);
|
|
3388
|
+
this.cleanups.push(() => ro.disconnect());
|
|
3389
|
+
let mq = null;
|
|
3390
|
+
const onDprChange = () => {
|
|
3391
|
+
apply(this.size.width, this.size.height);
|
|
3392
|
+
watchDpr();
|
|
3393
|
+
};
|
|
3394
|
+
const watchDpr = () => {
|
|
3395
|
+
mq?.removeEventListener("change", onDprChange);
|
|
3396
|
+
mq = matchMedia(`(resolution: ${window.devicePixelRatio || 1}dppx)`);
|
|
3397
|
+
mq.addEventListener("change", onDprChange);
|
|
3398
|
+
};
|
|
3399
|
+
watchDpr();
|
|
3400
|
+
this.cleanups.push(() => mq?.removeEventListener("change", onDprChange));
|
|
3401
|
+
const rect = this.root.getBoundingClientRect();
|
|
3402
|
+
apply(Math.floor(rect.width), Math.floor(rect.height));
|
|
3403
|
+
}
|
|
3404
|
+
/** Theme values using `var(--x)` follow the host page's dark-mode / theme switches automatically. */
|
|
3405
|
+
observeThemeVariables() {
|
|
3406
|
+
const onChange = () => {
|
|
3407
|
+
const t = this.theme;
|
|
3408
|
+
if (!Object.values(t).some((v) => typeof v === "string" && v.includes("var("))) return;
|
|
3409
|
+
this.refreshTheme();
|
|
3410
|
+
};
|
|
3411
|
+
const mo = new MutationObserver(onChange);
|
|
3412
|
+
for (const el of [document.documentElement, document.body]) {
|
|
3413
|
+
if (el) mo.observe(el, { attributes: true, attributeFilter: ["class", "style", "data-theme"] });
|
|
3414
|
+
}
|
|
3415
|
+
const mq = matchMedia("(prefers-color-scheme: dark)");
|
|
3416
|
+
mq.addEventListener("change", onChange);
|
|
3417
|
+
this.cleanups.push(() => {
|
|
3418
|
+
mo.disconnect();
|
|
3419
|
+
mq.removeEventListener("change", onChange);
|
|
3420
|
+
});
|
|
3421
|
+
}
|
|
3422
|
+
};
|
|
3423
|
+
|
|
3424
|
+
// src/realtime.ts
|
|
3425
|
+
var offsetFormatters = /* @__PURE__ */ new Map();
|
|
3426
|
+
function timeZoneOffset(time, timeZone) {
|
|
3427
|
+
if (timeZone === "UTC") return 0;
|
|
3428
|
+
let dtf = offsetFormatters.get(timeZone);
|
|
3429
|
+
if (!dtf) {
|
|
3430
|
+
dtf = new Intl.DateTimeFormat("en-US", {
|
|
3431
|
+
timeZone,
|
|
3432
|
+
hourCycle: "h23",
|
|
3433
|
+
year: "numeric",
|
|
3434
|
+
month: "numeric",
|
|
3435
|
+
day: "numeric",
|
|
3436
|
+
hour: "numeric",
|
|
3437
|
+
minute: "numeric",
|
|
3438
|
+
second: "numeric"
|
|
3439
|
+
});
|
|
3440
|
+
offsetFormatters.set(timeZone, dtf);
|
|
3441
|
+
}
|
|
3442
|
+
const p = {};
|
|
3443
|
+
for (const { type, value } of dtf.formatToParts(time)) p[type] = Number(value);
|
|
3444
|
+
const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour % 24, p.minute, p.second);
|
|
3445
|
+
return Math.round((asUtc - Math.floor(time / 1e3) * 1e3) / 6e4) * 6e4;
|
|
3446
|
+
}
|
|
3447
|
+
function bucketStart(time, resolution, timeZone = "UTC") {
|
|
3448
|
+
const offset = timeZoneOffset(time, timeZone);
|
|
3449
|
+
const local = time + offset;
|
|
3450
|
+
const unit = resolution.trim().slice(-1).toUpperCase();
|
|
3451
|
+
if (unit === "M") {
|
|
3452
|
+
const n = Number(resolution.slice(0, -1)) || 1;
|
|
3453
|
+
const d = new Date(local);
|
|
3454
|
+
const months = d.getUTCFullYear() * 12 + d.getUTCMonth();
|
|
3455
|
+
const start = months - months % n;
|
|
3456
|
+
return Date.UTC(Math.floor(start / 12), start % 12, 1) - offset;
|
|
3457
|
+
}
|
|
3458
|
+
if (unit === "W") {
|
|
3459
|
+
const days = Math.floor(local / DAY_MS);
|
|
3460
|
+
const sinceMonday = ((days + 3) % 7 + 7) % 7;
|
|
3461
|
+
return (days - sinceMonday) * DAY_MS - offset;
|
|
3462
|
+
}
|
|
3463
|
+
const size = resolutionToMs(resolution);
|
|
3464
|
+
return Math.floor(local / size) * size - offset;
|
|
3465
|
+
}
|
|
3466
|
+
var TickAggregator = class {
|
|
3467
|
+
constructor(resolution, timeZone = "UTC") {
|
|
3468
|
+
this.resolution = resolution;
|
|
3469
|
+
this.timeZone = timeZone;
|
|
3470
|
+
}
|
|
3471
|
+
resolution;
|
|
3472
|
+
timeZone;
|
|
3473
|
+
current = null;
|
|
3474
|
+
/** Start from the latest historical bar so the first tick updates it instead of opening a new one. */
|
|
3475
|
+
seed(bar) {
|
|
3476
|
+
this.current = bar ? { ...bar, volume: bar.volume ?? 0 } : null;
|
|
3477
|
+
}
|
|
3478
|
+
/** Returns the updated bar, or null for a tick belonging to an already-closed bar. */
|
|
3479
|
+
push(tick) {
|
|
3480
|
+
const start = bucketStart(tick.time, this.resolution, this.timeZone);
|
|
3481
|
+
const bar = this.current;
|
|
3482
|
+
const volume = tick.volume ?? 0;
|
|
3483
|
+
if (!bar || start > bar.time) {
|
|
3484
|
+
this.current = { time: start, open: tick.price, high: tick.price, low: tick.price, close: tick.price, volume };
|
|
3485
|
+
} else if (start === bar.time) {
|
|
3486
|
+
bar.high = Math.max(bar.high, tick.price);
|
|
3487
|
+
bar.low = Math.min(bar.low, tick.price);
|
|
3488
|
+
bar.close = tick.price;
|
|
3489
|
+
bar.volume = (bar.volume ?? 0) + volume;
|
|
3490
|
+
} else {
|
|
3491
|
+
return null;
|
|
3492
|
+
}
|
|
3493
|
+
return { ...this.current };
|
|
3494
|
+
}
|
|
3495
|
+
};
|
|
3496
|
+
|
|
3497
|
+
// src/session.ts
|
|
3498
|
+
var DAY = 864e5;
|
|
3499
|
+
var MINUTE2 = 6e4;
|
|
3500
|
+
function sessionStatus(session, timeZone, now = Date.now()) {
|
|
3501
|
+
if (session.trim().toLowerCase() === "24x7") return { open: true, nextChange: null };
|
|
3502
|
+
const match = /^(\d{2})(\d{2})-(\d{2})(\d{2})(?::([1-7]+))?$/.exec(session.trim());
|
|
3503
|
+
if (!match) return { open: true, nextChange: null };
|
|
3504
|
+
const openMin = Number(match[1]) * 60 + Number(match[2]);
|
|
3505
|
+
const closeMin = Number(match[3]) * 60 + Number(match[4]);
|
|
3506
|
+
const days = new Set((match[5] ?? "12345").split("").map(Number));
|
|
3507
|
+
const offset = timeZoneOffset(now, timeZone);
|
|
3508
|
+
const localMidnight = Math.floor((now + offset) / DAY) * DAY;
|
|
3509
|
+
for (let d = -1; d <= 8; d++) {
|
|
3510
|
+
const dayStart = localMidnight + d * DAY;
|
|
3511
|
+
const isoWeekday = (new Date(dayStart).getUTCDay() + 6) % 7 + 1;
|
|
3512
|
+
if (!days.has(isoWeekday)) continue;
|
|
3513
|
+
const open = dayStart + openMin * MINUTE2 - offset;
|
|
3514
|
+
const close = dayStart + closeMin * MINUTE2 - offset;
|
|
3515
|
+
if (now < open) return { open: false, nextChange: open };
|
|
3516
|
+
if (now < close) return { open: true, nextChange: close };
|
|
3517
|
+
}
|
|
3518
|
+
return { open: false, nextChange: null };
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3521
|
+
// src/datafeed/socket.ts
|
|
3522
|
+
var toConnectionStatus = (status) => ({ connecting: "connecting", open: "connected", reconnecting: "reconnecting", closed: "disconnected" })[status];
|
|
3523
|
+
var decoder;
|
|
3524
|
+
var decode = (data) => (decoder ??= new TextDecoder()).decode(data);
|
|
3525
|
+
var ReconnectingWebSocket = class {
|
|
3526
|
+
constructor(url, options) {
|
|
3527
|
+
this.url = url;
|
|
3528
|
+
this.options = options;
|
|
3529
|
+
globalThis.addEventListener?.("online", this.onOnline);
|
|
3530
|
+
this.connect();
|
|
3531
|
+
}
|
|
3532
|
+
url;
|
|
3533
|
+
options;
|
|
3534
|
+
ws = null;
|
|
3535
|
+
attempt = 0;
|
|
3536
|
+
everOpened = false;
|
|
3537
|
+
closed = false;
|
|
3538
|
+
retryTimer;
|
|
3539
|
+
idleTimer;
|
|
3540
|
+
pingTimer;
|
|
3541
|
+
pingSentAt = 0;
|
|
3542
|
+
onOnline = () => {
|
|
3543
|
+
if (this.ws || this.closed) return;
|
|
3544
|
+
clearTimeout(this.retryTimer);
|
|
3545
|
+
this.connect();
|
|
3546
|
+
};
|
|
3547
|
+
get status() {
|
|
3548
|
+
if (this.closed) return "closed";
|
|
3549
|
+
if (this.ws?.readyState === WebSocket.OPEN) return "open";
|
|
3550
|
+
return this.everOpened ? "reconnecting" : "connecting";
|
|
3551
|
+
}
|
|
3552
|
+
/** Drops the current connection and reconnects right away (e.g. after the tab wakes up). */
|
|
3553
|
+
reconnect() {
|
|
3554
|
+
if (this.closed) return;
|
|
3555
|
+
clearTimeout(this.retryTimer);
|
|
3556
|
+
if (this.ws) this.drop(this.ws, 0);
|
|
3557
|
+
else this.connect();
|
|
3558
|
+
}
|
|
3559
|
+
send(data) {
|
|
3560
|
+
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(data);
|
|
3561
|
+
}
|
|
3562
|
+
close() {
|
|
3563
|
+
this.closed = true;
|
|
3564
|
+
clearTimeout(this.retryTimer);
|
|
3565
|
+
clearTimeout(this.idleTimer);
|
|
3566
|
+
clearInterval(this.pingTimer);
|
|
3567
|
+
globalThis.removeEventListener?.("online", this.onOnline);
|
|
3568
|
+
const ws = this.ws;
|
|
3569
|
+
this.ws = null;
|
|
3570
|
+
ws?.close();
|
|
3571
|
+
this.options.onStatus?.("closed");
|
|
3572
|
+
}
|
|
3573
|
+
connect() {
|
|
3574
|
+
if (this.closed) return;
|
|
3575
|
+
this.options.onStatus?.(this.everOpened ? "reconnecting" : "connecting");
|
|
3576
|
+
const ws = new WebSocket(this.url);
|
|
3577
|
+
ws.binaryType = "arraybuffer";
|
|
3578
|
+
this.ws = ws;
|
|
3579
|
+
ws.onopen = () => {
|
|
3580
|
+
if (this.ws !== ws) return;
|
|
3581
|
+
const reconnected = this.everOpened;
|
|
3582
|
+
this.everOpened = true;
|
|
3583
|
+
this.attempt = 0;
|
|
3584
|
+
this.armIdle();
|
|
3585
|
+
this.options.onStatus?.("open");
|
|
3586
|
+
this.options.onOpen?.(ws, reconnected);
|
|
3587
|
+
this.startPing(ws);
|
|
3588
|
+
};
|
|
3589
|
+
ws.onmessage = (event) => {
|
|
3590
|
+
if (this.ws !== ws) return;
|
|
3591
|
+
this.armIdle();
|
|
3592
|
+
const text = typeof event.data === "string" ? event.data : decode(event.data);
|
|
3593
|
+
const ping = this.options.ping;
|
|
3594
|
+
if (ping && this.pingSentAt && ping.isPong(text)) {
|
|
3595
|
+
this.options.onLatency?.(Math.round(performance.now() - this.pingSentAt));
|
|
3596
|
+
this.pingSentAt = 0;
|
|
3597
|
+
return;
|
|
3598
|
+
}
|
|
3599
|
+
this.options.onMessage(text);
|
|
3600
|
+
};
|
|
3601
|
+
ws.onerror = () => this.drop(ws);
|
|
3602
|
+
ws.onclose = () => this.drop(ws);
|
|
3603
|
+
}
|
|
3604
|
+
/**
|
|
3605
|
+
* Abandons a socket and schedules the next attempt immediately. Doesn't wait for the
|
|
3606
|
+
* close handshake: on a dead network the server never answers, and `close()` alone would
|
|
3607
|
+
* leave us hanging with no `onclose` for a long time.
|
|
3608
|
+
*/
|
|
3609
|
+
drop(ws, delay) {
|
|
3610
|
+
if (this.ws !== ws) return;
|
|
3611
|
+
ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null;
|
|
3612
|
+
try {
|
|
3613
|
+
ws.close();
|
|
3614
|
+
} catch {
|
|
3615
|
+
}
|
|
3616
|
+
this.ws = null;
|
|
3617
|
+
clearTimeout(this.idleTimer);
|
|
3618
|
+
clearInterval(this.pingTimer);
|
|
3619
|
+
this.scheduleReconnect(delay);
|
|
3620
|
+
}
|
|
3621
|
+
startPing(ws) {
|
|
3622
|
+
const ping = this.options.ping;
|
|
3623
|
+
if (!ping) return;
|
|
3624
|
+
clearInterval(this.pingTimer);
|
|
3625
|
+
const send = () => {
|
|
3626
|
+
if (this.ws !== ws || ws.readyState !== WebSocket.OPEN) return;
|
|
3627
|
+
this.pingSentAt = performance.now();
|
|
3628
|
+
ws.send(ping.message());
|
|
3629
|
+
};
|
|
3630
|
+
send();
|
|
3631
|
+
this.pingTimer = setInterval(send, ping.interval);
|
|
3632
|
+
}
|
|
3633
|
+
scheduleReconnect(override) {
|
|
3634
|
+
if (this.closed) return;
|
|
3635
|
+
const min = this.options.minDelay ?? 500;
|
|
3636
|
+
const max = this.options.maxDelay ?? 15e3;
|
|
3637
|
+
const delay = override ?? Math.min(max, min * 2 ** this.attempt) * (0.5 + Math.random() * 0.5);
|
|
3638
|
+
this.attempt++;
|
|
3639
|
+
this.options.onStatus?.("reconnecting");
|
|
3640
|
+
this.retryTimer = setTimeout(() => this.connect(), delay);
|
|
3641
|
+
}
|
|
3642
|
+
armIdle() {
|
|
3643
|
+
const timeout = this.options.idleTimeout ?? 0;
|
|
3644
|
+
if (!timeout) return;
|
|
3645
|
+
clearTimeout(this.idleTimer);
|
|
3646
|
+
this.idleTimer = setTimeout(() => this.ws && this.drop(this.ws), timeout);
|
|
3647
|
+
}
|
|
3648
|
+
};
|
|
3649
|
+
|
|
3650
|
+
// src/datafeed/latency.ts
|
|
3651
|
+
function monitorLatency(probe, onLatency, interval = 1e4) {
|
|
3652
|
+
let stopped = false;
|
|
3653
|
+
const run = async () => {
|
|
3654
|
+
const start = performance.now();
|
|
3655
|
+
try {
|
|
3656
|
+
await probe();
|
|
3657
|
+
if (!stopped) onLatency(Math.round(performance.now() - start));
|
|
3658
|
+
} catch {
|
|
3659
|
+
}
|
|
3660
|
+
};
|
|
3661
|
+
void run();
|
|
3662
|
+
const timer = setInterval(run, interval);
|
|
3663
|
+
return () => {
|
|
3664
|
+
stopped = true;
|
|
3665
|
+
clearInterval(timer);
|
|
3666
|
+
};
|
|
3667
|
+
}
|
|
3668
|
+
|
|
3669
|
+
// src/datafeed/combine.ts
|
|
3670
|
+
function combineDatafeeds(feeds) {
|
|
3671
|
+
const keys = Object.keys(feeds);
|
|
3672
|
+
if (keys.length === 0) throw new Error("combineDatafeeds: no datafeeds given");
|
|
3673
|
+
const split = (symbol) => {
|
|
3674
|
+
const i = symbol.indexOf(":");
|
|
3675
|
+
const key = i > 0 ? symbol.slice(0, i) : keys[0];
|
|
3676
|
+
const feed = feeds[key];
|
|
3677
|
+
if (!feed) throw new Error(`combineDatafeeds: unknown prefix "${key}"`);
|
|
3678
|
+
return [key, feed, i > 0 ? symbol.slice(i + 1) : symbol];
|
|
3679
|
+
};
|
|
3680
|
+
const inner = (info) => {
|
|
3681
|
+
const [, feed, symbol] = split(info.symbol);
|
|
3682
|
+
return [feed, { ...info, symbol }];
|
|
3683
|
+
};
|
|
3684
|
+
const outer = (key, info) => ({ ...info, symbol: `${key}:${info.symbol}` });
|
|
3685
|
+
return {
|
|
3686
|
+
async resolveSymbol(symbol) {
|
|
3687
|
+
const [key, feed, raw] = split(symbol);
|
|
3688
|
+
return outer(key, await feed.resolveSymbol(raw));
|
|
3689
|
+
},
|
|
3690
|
+
getBars(info, resolution, req) {
|
|
3691
|
+
const [feed, raw] = inner(info);
|
|
3692
|
+
return feed.getBars(raw, resolution, req);
|
|
3693
|
+
},
|
|
3694
|
+
subscribeBars(info, resolution, onBar, onResetCache, onConnection) {
|
|
3695
|
+
const [feed, raw] = inner(info);
|
|
3696
|
+
return feed.subscribeBars(raw, resolution, onBar, onResetCache, onConnection);
|
|
3697
|
+
},
|
|
3698
|
+
subscribeQuotes(info, onQuote) {
|
|
3699
|
+
const [feed, raw] = inner(info);
|
|
3700
|
+
return feed.subscribeQuotes?.(raw, onQuote) ?? (() => {
|
|
3701
|
+
});
|
|
3702
|
+
},
|
|
3703
|
+
async searchSymbols(query) {
|
|
3704
|
+
const results = await Promise.allSettled(
|
|
3705
|
+
keys.map(async (key) => (await feeds[key].searchSymbols?.(query) ?? []).map((info) => outer(key, info)))
|
|
3706
|
+
);
|
|
3707
|
+
return results.flatMap((r) => r.status === "fulfilled" ? r.value : []);
|
|
3708
|
+
}
|
|
3709
|
+
};
|
|
3710
|
+
}
|
|
3711
|
+
export {
|
|
3712
|
+
Chart,
|
|
3713
|
+
DEFAULT_CHART_SETTINGS,
|
|
3714
|
+
FIB_LEVELS,
|
|
3715
|
+
INDICATORS,
|
|
3716
|
+
PRICE_SOURCES,
|
|
3717
|
+
ReconnectingWebSocket,
|
|
3718
|
+
SERIES_COLORS,
|
|
3719
|
+
TickAggregator,
|
|
3720
|
+
bollinger,
|
|
3721
|
+
bucketStart,
|
|
3722
|
+
combineDatafeeds,
|
|
3723
|
+
createChart,
|
|
3724
|
+
createTheme,
|
|
3725
|
+
darkTheme,
|
|
3726
|
+
decimalsOf,
|
|
3727
|
+
ema,
|
|
3728
|
+
fallbackResolution,
|
|
3729
|
+
formatCompact,
|
|
3730
|
+
formatPrice,
|
|
3731
|
+
isIntradayResolution,
|
|
3732
|
+
krxTickSize,
|
|
3733
|
+
lightTheme,
|
|
3734
|
+
maLines,
|
|
3735
|
+
macd,
|
|
3736
|
+
monitorLatency,
|
|
3737
|
+
parseChartState,
|
|
3738
|
+
priceSource,
|
|
3739
|
+
resolutionToMs,
|
|
3740
|
+
resolveTickSize,
|
|
3741
|
+
roundToTick,
|
|
3742
|
+
rsi,
|
|
3743
|
+
sessionStatus,
|
|
3744
|
+
sma,
|
|
3745
|
+
snapToOhlc,
|
|
3746
|
+
stdev,
|
|
3747
|
+
timeZoneOffset,
|
|
3748
|
+
toConnectionStatus
|
|
3749
|
+
};
|