@funnest/charts 0.5.2 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chart.d.ts +135 -3
- package/dist/events.d.ts +1 -0
- package/dist/index.d.ts +9 -5
- package/dist/index.js +966 -266
- package/dist/indicators/definitions.d.ts +15 -3
- package/dist/indicators/manager.d.ts +8 -1
- package/dist/interaction.d.ts +8 -1
- package/dist/model/price-scale.d.ts +16 -0
- package/dist/objects/drawings.d.ts +62 -6
- package/dist/objects/trading.d.ts +13 -0
- package/dist/objects/types.d.ts +2 -0
- package/dist/render/renderer.d.ts +4 -0
- package/dist/settings.d.ts +9 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -12,6 +12,9 @@ var Emitter = class {
|
|
|
12
12
|
if (!set) return;
|
|
13
13
|
for (const listener of set) listener(payload);
|
|
14
14
|
}
|
|
15
|
+
has(event) {
|
|
16
|
+
return (this.listeners.get(event)?.size ?? 0) > 0;
|
|
17
|
+
}
|
|
15
18
|
clear() {
|
|
16
19
|
this.listeners.clear();
|
|
17
20
|
}
|
|
@@ -102,6 +105,9 @@ var Interaction = class {
|
|
|
102
105
|
case "timeAxis":
|
|
103
106
|
this.mode = "timeScale";
|
|
104
107
|
break;
|
|
108
|
+
case "paneBorder":
|
|
109
|
+
this.mode = "paneResize";
|
|
110
|
+
break;
|
|
105
111
|
case "plot": {
|
|
106
112
|
if (this.host.toolActive()) {
|
|
107
113
|
this.mode = "draw";
|
|
@@ -173,6 +179,9 @@ var Interaction = class {
|
|
|
173
179
|
case "timeScale":
|
|
174
180
|
this.host.zoomTime(this.host.plotWidth(), Math.exp((p.x - this.last.x) * AXIS_DRAG_SPEED));
|
|
175
181
|
break;
|
|
182
|
+
case "paneResize":
|
|
183
|
+
if (this.region.border) this.host.resizePane(this.region.border, p.y - this.last.y);
|
|
184
|
+
break;
|
|
176
185
|
}
|
|
177
186
|
this.last = p;
|
|
178
187
|
}
|
|
@@ -187,7 +196,7 @@ var Interaction = class {
|
|
|
187
196
|
}
|
|
188
197
|
if (this.mode === "draw") this.host.drawRelease(p.x, p.y, this.moved);
|
|
189
198
|
if (this.mode === "object") this.host.objectUp(this.moved);
|
|
190
|
-
const tapModes = ["pending", "priceScale", "timeScale"];
|
|
199
|
+
const tapModes = ["pending", "priceScale", "timeScale", "paneResize"];
|
|
191
200
|
if (this.touch && !this.moved && tapModes.includes(this.mode)) this.handleTap(p);
|
|
192
201
|
if (this.mode === "pan") this.startKinetic();
|
|
193
202
|
if (this.mode === "crosshair") this.host.clearCrosshair();
|
|
@@ -213,7 +222,7 @@ var Interaction = class {
|
|
|
213
222
|
}
|
|
214
223
|
hover(p) {
|
|
215
224
|
const region = this.host.hitTest(p.x, p.y);
|
|
216
|
-
let cursor = region.area === "priceAxis" ? "ns-resize" : region.area === "timeAxis" ? "ew-resize" : "crosshair";
|
|
225
|
+
let cursor = region.area === "priceAxis" ? "ns-resize" : region.area === "timeAxis" ? "ew-resize" : region.area === "paneBorder" ? "row-resize" : "crosshair";
|
|
217
226
|
let hovered = null;
|
|
218
227
|
if (region.area === "plot") {
|
|
219
228
|
if (this.host.toolActive()) {
|
|
@@ -244,6 +253,7 @@ var Interaction = class {
|
|
|
244
253
|
if (isDouble) {
|
|
245
254
|
this.lastTap.t = 0;
|
|
246
255
|
if (this.region.area === "priceAxis" && this.region.pane) this.host.resetPriceScale(this.region.pane);
|
|
256
|
+
else if (this.region.area === "paneBorder") this.host.resetPaneSizes();
|
|
247
257
|
else this.host.resetView();
|
|
248
258
|
} else {
|
|
249
259
|
this.lastTap = { t, x: p.x, y: p.y };
|
|
@@ -331,13 +341,19 @@ var Interaction = class {
|
|
|
331
341
|
const p = this.local(e);
|
|
332
342
|
const region = this.host.hitTest(p.x, p.y);
|
|
333
343
|
if (region.area === "priceAxis" && region.pane) this.host.resetPriceScale(region.pane);
|
|
344
|
+
else if (region.area === "paneBorder") this.host.resetPaneSizes();
|
|
334
345
|
else if (region.area !== "none") this.host.resetView();
|
|
335
346
|
}
|
|
336
347
|
onTouchMove(e) {
|
|
337
348
|
if (this.mode !== "idle" && this.mode !== "pending") e.preventDefault();
|
|
338
349
|
}
|
|
339
350
|
onContextMenu(e) {
|
|
340
|
-
if (this.touch)
|
|
351
|
+
if (this.touch) {
|
|
352
|
+
e.preventDefault();
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const p = this.local(e);
|
|
356
|
+
if (this.host.contextMenu(p.x, p.y)) e.preventDefault();
|
|
341
357
|
}
|
|
342
358
|
};
|
|
343
359
|
|
|
@@ -536,6 +552,7 @@ var PriceScale = class {
|
|
|
536
552
|
}
|
|
537
553
|
margins;
|
|
538
554
|
height = 0;
|
|
555
|
+
mode = "normal";
|
|
539
556
|
/** Pixels reserved at the top for overlays (e.g. a legend); margins apply below it. */
|
|
540
557
|
insetTop = 0;
|
|
541
558
|
autoScale = true;
|
|
@@ -550,6 +567,7 @@ var PriceScale = class {
|
|
|
550
567
|
setRange(min, max) {
|
|
551
568
|
if (!Number.isFinite(min) || !Number.isFinite(max)) return;
|
|
552
569
|
if (max < min) [min, max] = [max, min];
|
|
570
|
+
if (this.mode === "log" && min <= 0) min = max > 0 ? max / 1e3 : LOG_FLOOR;
|
|
553
571
|
if (max - min < 1e-12) {
|
|
554
572
|
const pad = Math.abs(max) * 0.01 || 1;
|
|
555
573
|
min -= pad;
|
|
@@ -568,27 +586,63 @@ var PriceScale = class {
|
|
|
568
586
|
get innerHeight() {
|
|
569
587
|
return Math.max(1, this.available * (1 - this.margins.top - this.margins.bottom));
|
|
570
588
|
}
|
|
589
|
+
/** Price → the space the axis is linear in (log prices in log mode). */
|
|
590
|
+
toAxis(price) {
|
|
591
|
+
return this.mode === "log" ? Math.log(Math.max(price, LOG_FLOOR)) : price;
|
|
592
|
+
}
|
|
593
|
+
fromAxis(v) {
|
|
594
|
+
return this.mode === "log" ? Math.exp(v) : v;
|
|
595
|
+
}
|
|
571
596
|
priceToY(price) {
|
|
572
|
-
|
|
597
|
+
const lo = this.toAxis(this.lo);
|
|
598
|
+
const hi = this.toAxis(this.hi);
|
|
599
|
+
return this.innerTop + (hi - this.toAxis(price)) / (hi - lo) * this.innerHeight;
|
|
573
600
|
}
|
|
574
601
|
yToPrice(y) {
|
|
575
|
-
|
|
602
|
+
const lo = this.toAxis(this.lo);
|
|
603
|
+
const hi = this.toAxis(this.hi);
|
|
604
|
+
return this.fromAxis(hi - (y - this.innerTop) / this.innerHeight * (hi - lo));
|
|
605
|
+
}
|
|
606
|
+
/** Sets the range in axis space (log prices in log mode). */
|
|
607
|
+
setAxisRange(lo, hi) {
|
|
608
|
+
this.setRange(this.fromAxis(lo), this.fromAxis(hi));
|
|
576
609
|
}
|
|
577
610
|
/** Scales around the center. factor > 1 shows a wider price range. Disables auto-scale. */
|
|
578
611
|
zoom(factor) {
|
|
579
|
-
const
|
|
580
|
-
const
|
|
581
|
-
|
|
612
|
+
const lo = this.toAxis(this.lo);
|
|
613
|
+
const hi = this.toAxis(this.hi);
|
|
614
|
+
const center = (lo + hi) / 2;
|
|
615
|
+
const half = (hi - lo) / 2 * factor;
|
|
616
|
+
this.setAxisRange(center - half, center + half);
|
|
582
617
|
this.autoScale = false;
|
|
583
618
|
}
|
|
584
619
|
/** Moves the content by `dy` pixels (positive = content moves down). Disables auto-scale. */
|
|
585
620
|
pan(dy) {
|
|
586
|
-
const
|
|
587
|
-
this.
|
|
621
|
+
const lo = this.toAxis(this.lo);
|
|
622
|
+
const hi = this.toAxis(this.hi);
|
|
623
|
+
const delta = dy / this.innerHeight * (hi - lo);
|
|
624
|
+
this.setAxisRange(lo + delta, hi + delta);
|
|
588
625
|
this.autoScale = false;
|
|
589
626
|
}
|
|
590
627
|
};
|
|
628
|
+
var LOG_FLOOR = 1e-12;
|
|
591
629
|
var NICE_MULTIPLIERS = [1, 2, 2.5, 5, 10];
|
|
630
|
+
function logTicks(scale, spacing, minStep = 0) {
|
|
631
|
+
const ticks = [];
|
|
632
|
+
for (let y = scale.height - spacing / 2; y > 0; y -= spacing) {
|
|
633
|
+
const raw = scale.yToPrice(y);
|
|
634
|
+
if (!(raw > 0)) continue;
|
|
635
|
+
const local = scale.yToPrice(y - spacing) - raw;
|
|
636
|
+
const mag = 10 ** Math.floor(Math.log10(local));
|
|
637
|
+
let step = NICE_MULTIPLIERS.map((m) => m * mag).find((s) => s >= local * 0.75) ?? 10 * mag;
|
|
638
|
+
if (minStep > 0) step = Math.max(minStep, Math.round(step / minStep) * minStep);
|
|
639
|
+
const best = Number((Math.round(raw / step) * step).toPrecision(12));
|
|
640
|
+
if (!(best > 0)) continue;
|
|
641
|
+
const prev = ticks[ticks.length - 1];
|
|
642
|
+
if (prev === void 0 || Math.abs(scale.priceToY(best) - scale.priceToY(prev)) >= spacing * 0.6) ticks.push(best);
|
|
643
|
+
}
|
|
644
|
+
return ticks;
|
|
645
|
+
}
|
|
592
646
|
function niceTicks(min, max, maxCount, minStep = 0) {
|
|
593
647
|
if (!(max > min) || maxCount < 1) return [];
|
|
594
648
|
const rough = (max - min) / maxCount;
|
|
@@ -831,6 +885,10 @@ var TimeScale = class {
|
|
|
831
885
|
}
|
|
832
886
|
};
|
|
833
887
|
|
|
888
|
+
// src/objects/types.ts
|
|
889
|
+
var counter = 0;
|
|
890
|
+
var newId = (prefix) => `${prefix}_${Date.now().toString(36)}${(counter++).toString(36)}`;
|
|
891
|
+
|
|
834
892
|
// src/indicators/math.ts
|
|
835
893
|
function sma(src2, period) {
|
|
836
894
|
const out = new Float64Array(src2.length).fill(Number.NaN);
|
|
@@ -1078,27 +1136,36 @@ var INDICATORS = {
|
|
|
1078
1136
|
}
|
|
1079
1137
|
};
|
|
1080
1138
|
|
|
1081
|
-
// src/objects/types.ts
|
|
1082
|
-
var counter = 0;
|
|
1083
|
-
var newId = (prefix) => `${prefix}_${Date.now().toString(36)}${(counter++).toString(36)}`;
|
|
1084
|
-
|
|
1085
1139
|
// src/indicators/manager.ts
|
|
1086
1140
|
var clone = (c) => JSON.parse(JSON.stringify(c));
|
|
1087
|
-
function
|
|
1088
|
-
const
|
|
1089
|
-
const
|
|
1090
|
-
|
|
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 ?? {} };
|
|
1141
|
+
function indicatorRegistry(extra = []) {
|
|
1142
|
+
const registry = new Map(Object.entries(INDICATORS));
|
|
1143
|
+
for (const def of extra) registry.set(def.type, def);
|
|
1144
|
+
return registry;
|
|
1095
1145
|
}
|
|
1096
1146
|
var IndicatorManager = class {
|
|
1147
|
+
constructor(registry = indicatorRegistry()) {
|
|
1148
|
+
this.registry = registry;
|
|
1149
|
+
}
|
|
1150
|
+
registry;
|
|
1097
1151
|
list = [];
|
|
1098
1152
|
cache = /* @__PURE__ */ new Map();
|
|
1153
|
+
def(type) {
|
|
1154
|
+
const def = this.registry.get(type);
|
|
1155
|
+
if (!def) throw new Error(`funnest-charts: unknown indicator "${type}"`);
|
|
1156
|
+
return def;
|
|
1157
|
+
}
|
|
1158
|
+
normalize(c) {
|
|
1159
|
+
const def = this.def(c.type);
|
|
1160
|
+
const params = { ...def.defaults, ...c.params };
|
|
1161
|
+
if ((c.type === "ma" || c.type === "ema") && !Array.isArray(c.params.lines) && Array.isArray(c.params.periods)) {
|
|
1162
|
+
params.lines = maLines({ periods: c.params.periods });
|
|
1163
|
+
}
|
|
1164
|
+
delete params.periods;
|
|
1165
|
+
return { id: c.id || newId(c.type), type: c.type, params, visible: c.visible ?? true, styles: c.styles ?? {} };
|
|
1166
|
+
}
|
|
1099
1167
|
add(type, params) {
|
|
1100
|
-
|
|
1101
|
-
const config = normalize2({ id: newId(type), type, params: params ?? {} });
|
|
1168
|
+
const config = this.normalize({ id: newId(type), type, params: params ?? {} });
|
|
1102
1169
|
this.list.push(config);
|
|
1103
1170
|
return config.id;
|
|
1104
1171
|
}
|
|
@@ -1116,25 +1183,26 @@ var IndicatorManager = class {
|
|
|
1116
1183
|
if (patch.visible !== void 0) cfg.visible = patch.visible;
|
|
1117
1184
|
return true;
|
|
1118
1185
|
}
|
|
1186
|
+
/** Unknown types (e.g. a plugin this chart wasn't given) are dropped. */
|
|
1119
1187
|
set(configs) {
|
|
1120
|
-
this.list = configs.filter((c) =>
|
|
1188
|
+
this.list = configs.filter((c) => this.registry.has(c.type)).map((c) => this.normalize(clone(c)));
|
|
1121
1189
|
this.cache.clear();
|
|
1122
1190
|
}
|
|
1123
1191
|
snapshot() {
|
|
1124
1192
|
return this.list.map(clone);
|
|
1125
1193
|
}
|
|
1126
1194
|
get overlays() {
|
|
1127
|
-
return this.list.filter((c) =>
|
|
1195
|
+
return this.list.filter((c) => this.def(c.type).overlay);
|
|
1128
1196
|
}
|
|
1129
1197
|
get panes() {
|
|
1130
|
-
return this.list.filter((c) => !
|
|
1198
|
+
return this.list.filter((c) => !this.def(c.type).overlay);
|
|
1131
1199
|
}
|
|
1132
1200
|
/** Computed series with user styles applied; recomputed only when data or settings change. */
|
|
1133
1201
|
output(cfg, bars, dataVersion) {
|
|
1134
1202
|
const key = `${dataVersion}|${JSON.stringify(cfg.params)}|${JSON.stringify(cfg.styles)}`;
|
|
1135
1203
|
const hit = this.cache.get(cfg.id);
|
|
1136
1204
|
if (hit?.key === key) return hit.output;
|
|
1137
|
-
const raw =
|
|
1205
|
+
const raw = this.def(cfg.type).compute(bars, cfg.params);
|
|
1138
1206
|
const output = {
|
|
1139
1207
|
...raw,
|
|
1140
1208
|
series: raw.series.map((s) => {
|
|
@@ -1153,7 +1221,6 @@ var IndicatorManager = class {
|
|
|
1153
1221
|
};
|
|
1154
1222
|
|
|
1155
1223
|
// src/objects/drawings.ts
|
|
1156
|
-
var DRAWING_POINTS = { trendline: 2, horizontal: 1, fibonacci: 2 };
|
|
1157
1224
|
var FIB_LEVELS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
|
|
1158
1225
|
var FIB_COLORS = ["#8B95A1", "#F04452", "#FF9F0A", "#12B886", "#3182F6", "#7048E8", "#8B95A1"];
|
|
1159
1226
|
var HANDLE_RADIUS = 5;
|
|
@@ -1164,11 +1231,84 @@ function distToSegment(px, py, x1, y1, x2, y2) {
|
|
|
1164
1231
|
const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / len2));
|
|
1165
1232
|
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
|
|
1166
1233
|
}
|
|
1234
|
+
var p0 = (g) => g.points[0];
|
|
1235
|
+
var p1 = (g) => g.points[1] ?? g.points[0];
|
|
1236
|
+
var BUILTIN_DRAWINGS = [
|
|
1237
|
+
{
|
|
1238
|
+
type: "trendline",
|
|
1239
|
+
name: { ko: "\uCD94\uC138\uC120", en: "Trend line" },
|
|
1240
|
+
group: "line",
|
|
1241
|
+
clicks: 2,
|
|
1242
|
+
distance: (g, x, y) => distToSegment(x, y, ...p0(g), ...p1(g)),
|
|
1243
|
+
render: (_d, { painter, color, ...g }) => painter.line(...p0(g), ...p1(g), color, 2)
|
|
1244
|
+
},
|
|
1245
|
+
{
|
|
1246
|
+
type: "horizontal",
|
|
1247
|
+
name: { ko: "\uC218\uD3C9\uC120", en: "Horizontal line" },
|
|
1248
|
+
group: "level",
|
|
1249
|
+
clicks: 1,
|
|
1250
|
+
// The anchor's time doesn't matter; keep the handle inside the plot.
|
|
1251
|
+
handles: (g) => [[Math.min(g.coords.plotWidth - 24, Math.max(24, p0(g)[0])), p0(g)[1]]],
|
|
1252
|
+
distance: (g, _x, y) => Math.abs(y - p0(g)[1]),
|
|
1253
|
+
render: (d, { painter, color, coords, badge, ...g }) => {
|
|
1254
|
+
const y = p0(g)[1];
|
|
1255
|
+
painter.hLine(y, 0, coords.plotWidth, color);
|
|
1256
|
+
badge(y, coords.formatPrice(d.points[0].price), color);
|
|
1257
|
+
}
|
|
1258
|
+
},
|
|
1259
|
+
{
|
|
1260
|
+
type: "fibonacci",
|
|
1261
|
+
name: { ko: "\uD53C\uBCF4\uB098\uCE58 \uB418\uB3CC\uB9BC", en: "Fib retracement" },
|
|
1262
|
+
group: "fib",
|
|
1263
|
+
clicks: 2,
|
|
1264
|
+
distance: (g, x, y) => {
|
|
1265
|
+
const [x1, y1] = p0(g);
|
|
1266
|
+
const [x2, y2] = p1(g);
|
|
1267
|
+
let best = distToSegment(x, y, x1, y1, x2, y2);
|
|
1268
|
+
if (x >= Math.min(x1, x2) && x <= Math.max(x1, x2)) {
|
|
1269
|
+
for (const level of FIB_LEVELS) best = Math.min(best, Math.abs(y - (y2 + (y1 - y2) * level)));
|
|
1270
|
+
}
|
|
1271
|
+
return best;
|
|
1272
|
+
},
|
|
1273
|
+
render: (d, { painter: p, coords: c, palette, ...g }) => {
|
|
1274
|
+
const [x1, y1] = p0(g);
|
|
1275
|
+
const [x2, y2] = p1(g);
|
|
1276
|
+
const left = Math.min(x1, x2);
|
|
1277
|
+
const right = Math.max(x1, x2, left + 1);
|
|
1278
|
+
const a = d.points[0].price;
|
|
1279
|
+
const b = d.points[1].price;
|
|
1280
|
+
const { ctx } = p;
|
|
1281
|
+
FIB_LEVELS.forEach((level, i) => {
|
|
1282
|
+
const price = b + (a - b) * level;
|
|
1283
|
+
const y = c.priceToY(price);
|
|
1284
|
+
const levelColor = d.color ?? FIB_COLORS[i];
|
|
1285
|
+
if (i > 0) {
|
|
1286
|
+
const prevY = c.priceToY(b + (a - b) * FIB_LEVELS[i - 1]);
|
|
1287
|
+
ctx.save();
|
|
1288
|
+
ctx.globalAlpha = 0.07;
|
|
1289
|
+
ctx.fillStyle = levelColor;
|
|
1290
|
+
ctx.fillRect(left, Math.min(y, prevY), right - left, Math.abs(y - prevY));
|
|
1291
|
+
ctx.restore();
|
|
1292
|
+
}
|
|
1293
|
+
p.hLine(y, left, right, levelColor);
|
|
1294
|
+
p.text(`${level} (${c.formatPrice(price)})`, left + 4, y - 7, levelColor, "left", "middle");
|
|
1295
|
+
});
|
|
1296
|
+
p.line(x1, y1, x2, y2, palette.textSecondary, 1, [4, 4]);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
];
|
|
1300
|
+
function drawingRegistry(extra = []) {
|
|
1301
|
+
const registry = new Map(BUILTIN_DRAWINGS.map((d) => [d.type, d]));
|
|
1302
|
+
for (const def of extra) registry.set(def.type, def);
|
|
1303
|
+
return registry;
|
|
1304
|
+
}
|
|
1167
1305
|
var DrawingManager = class {
|
|
1168
|
-
constructor(onChange) {
|
|
1306
|
+
constructor(onChange, registry = drawingRegistry()) {
|
|
1169
1307
|
this.onChange = onChange;
|
|
1308
|
+
this.registry = registry;
|
|
1170
1309
|
}
|
|
1171
1310
|
onChange;
|
|
1311
|
+
registry;
|
|
1172
1312
|
drawings = [];
|
|
1173
1313
|
tool = null;
|
|
1174
1314
|
selectedId = null;
|
|
@@ -1178,6 +1318,7 @@ var DrawingManager = class {
|
|
|
1178
1318
|
return this.drawings.find((d) => d.id === this.selectedId) ?? null;
|
|
1179
1319
|
}
|
|
1180
1320
|
setTool(tool) {
|
|
1321
|
+
if (tool && !this.registry.has(tool)) throw new Error(`funnest-charts: unknown drawing tool "${tool}"`);
|
|
1181
1322
|
this.tool = tool;
|
|
1182
1323
|
this.draft = null;
|
|
1183
1324
|
if (tool) this.selectedId = null;
|
|
@@ -1188,6 +1329,7 @@ var DrawingManager = class {
|
|
|
1188
1329
|
this.selectedId = id;
|
|
1189
1330
|
this.onChange("selection");
|
|
1190
1331
|
}
|
|
1332
|
+
/** Drawings of unknown types are kept (and saved) but not shown. */
|
|
1191
1333
|
set(drawings) {
|
|
1192
1334
|
this.drawings = drawings.map((d) => ({ ...d, points: d.points.map((p) => ({ ...p })) }));
|
|
1193
1335
|
if (!this.selected) this.selectedId = null;
|
|
@@ -1217,26 +1359,31 @@ var DrawingManager = class {
|
|
|
1217
1359
|
else this.select(null);
|
|
1218
1360
|
}
|
|
1219
1361
|
// ── Creation ──────────────────────────────────────────────────────
|
|
1362
|
+
expand(def, placed, c) {
|
|
1363
|
+
const full = Array.from({ length: def.clicks }, (_, i) => ({ ...placed[i] ?? placed[placed.length - 1] }));
|
|
1364
|
+
return def.create ? def.create(full, c) : full;
|
|
1365
|
+
}
|
|
1220
1366
|
createAt(c, x, y) {
|
|
1221
|
-
|
|
1367
|
+
const def = this.tool ? this.registry.get(this.tool) : void 0;
|
|
1368
|
+
if (!def) return;
|
|
1222
1369
|
const point = c.pointAt(x, y);
|
|
1223
1370
|
if (!this.draft) {
|
|
1224
|
-
const
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
if (count === 1) this.finish();
|
|
1371
|
+
const placed = [point];
|
|
1372
|
+
this.draft = { drawing: { id: newId("d"), type: def.type, points: this.expand(def, placed, c) }, placed };
|
|
1373
|
+
if (def.clicks <= 1) this.finish();
|
|
1228
1374
|
else this.onChange("preview");
|
|
1229
1375
|
return;
|
|
1230
1376
|
}
|
|
1231
|
-
this.draft.
|
|
1232
|
-
this.draft.placed
|
|
1233
|
-
if (this.draft.placed >=
|
|
1377
|
+
this.draft.placed.push(point);
|
|
1378
|
+
this.draft.drawing.points = this.expand(def, this.draft.placed, c);
|
|
1379
|
+
if (this.draft.placed.length >= def.clicks) this.finish();
|
|
1234
1380
|
}
|
|
1235
1381
|
/** Moves the not-yet-placed points (live preview while hovering or dragging). */
|
|
1236
1382
|
previewAt(c, x, y) {
|
|
1237
|
-
|
|
1238
|
-
const
|
|
1239
|
-
|
|
1383
|
+
const draft = this.draft;
|
|
1384
|
+
const def = draft && this.registry.get(draft.drawing.type);
|
|
1385
|
+
if (!draft || !def) return;
|
|
1386
|
+
draft.drawing.points = this.expand(def, [...draft.placed, c.pointAt(x, y)], c);
|
|
1240
1387
|
this.onChange("preview");
|
|
1241
1388
|
}
|
|
1242
1389
|
/** Pointer released: a drag since the first point places the next one (drag-to-draw). */
|
|
@@ -1253,17 +1400,24 @@ var DrawingManager = class {
|
|
|
1253
1400
|
this.onChange("commit");
|
|
1254
1401
|
}
|
|
1255
1402
|
// ── Editing ───────────────────────────────────────────────────────
|
|
1403
|
+
geometry(c, d) {
|
|
1404
|
+
return { coords: c, points: d.points.map((p) => [c.timeToX(p.time), c.priceToY(p.price)]) };
|
|
1405
|
+
}
|
|
1406
|
+
handles(c, d) {
|
|
1407
|
+
const def = this.registry.get(d.type);
|
|
1408
|
+
const g = this.geometry(c, d);
|
|
1409
|
+
return def?.handles ? def.handles(g) : g.points;
|
|
1410
|
+
}
|
|
1256
1411
|
hitTest(c, x, y, tolerance) {
|
|
1257
1412
|
const selected = this.selected;
|
|
1258
|
-
if (selected) {
|
|
1259
|
-
const handle = this.
|
|
1260
|
-
([px, py]) => Math.hypot(px - x, py - y) <= HANDLE_RADIUS + tolerance
|
|
1261
|
-
);
|
|
1413
|
+
if (selected && this.registry.has(selected.type)) {
|
|
1414
|
+
const handle = this.handles(c, selected).findIndex(([px, py]) => Math.hypot(px - x, py - y) <= HANDLE_RADIUS + tolerance);
|
|
1262
1415
|
if (handle >= 0) return { kind: "drawing", id: selected.id, part: handle };
|
|
1263
1416
|
}
|
|
1264
1417
|
for (let i = this.drawings.length - 1; i >= 0; i--) {
|
|
1265
1418
|
const d = this.drawings[i];
|
|
1266
|
-
|
|
1419
|
+
const def = this.registry.get(d.type);
|
|
1420
|
+
if (def && def.distance(this.geometry(c, d), x, y) <= tolerance) return { kind: "drawing", id: d.id, part: "body" };
|
|
1267
1421
|
}
|
|
1268
1422
|
return null;
|
|
1269
1423
|
}
|
|
@@ -1271,7 +1425,7 @@ var DrawingManager = class {
|
|
|
1271
1425
|
const d = this.drawings.find((x2) => x2.id === id);
|
|
1272
1426
|
if (!d) return;
|
|
1273
1427
|
this.select(id);
|
|
1274
|
-
this.drag = { id, part, x, y, origin: this.
|
|
1428
|
+
this.drag = { id, part, x, y, origin: this.geometry(c, d).points };
|
|
1275
1429
|
}
|
|
1276
1430
|
dragTo(c, x, y) {
|
|
1277
1431
|
const drag = this.drag;
|
|
@@ -1279,6 +1433,8 @@ var DrawingManager = class {
|
|
|
1279
1433
|
if (!drag || !d) return;
|
|
1280
1434
|
if (typeof drag.part === "number") {
|
|
1281
1435
|
d.points[drag.part] = c.pointAt(x, y);
|
|
1436
|
+
const def = this.registry.get(d.type);
|
|
1437
|
+
if (def?.constrain) d.points = def.constrain(d.points, drag.part);
|
|
1282
1438
|
} else {
|
|
1283
1439
|
const dx = x - drag.x;
|
|
1284
1440
|
const dy = y - drag.y;
|
|
@@ -1290,71 +1446,26 @@ var DrawingManager = class {
|
|
|
1290
1446
|
if (this.drag && moved) this.onChange("commit");
|
|
1291
1447
|
this.drag = null;
|
|
1292
1448
|
}
|
|
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
1449
|
// ── Rendering ─────────────────────────────────────────────────────
|
|
1311
1450
|
render(rc, badges) {
|
|
1312
1451
|
const all = this.draft ? [...this.drawings, this.draft.drawing] : this.drawings;
|
|
1452
|
+
const c = rc.coords;
|
|
1313
1453
|
for (const d of all) {
|
|
1314
|
-
const
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1454
|
+
const def = this.registry.get(d.type);
|
|
1455
|
+
if (!def) continue;
|
|
1456
|
+
const color = d.color ?? rc.palette.accent;
|
|
1457
|
+
def.render(d, {
|
|
1458
|
+
...this.geometry(c, d),
|
|
1459
|
+
painter: rc.painter,
|
|
1460
|
+
palette: rc.palette,
|
|
1461
|
+
color,
|
|
1462
|
+
badge: (y, text, bg, fg = "#FFFFFF") => badges.push({ y, text, bg, fg })
|
|
1463
|
+
});
|
|
1464
|
+
if (d.id === this.selectedId || d === this.draft?.drawing) {
|
|
1465
|
+
for (const [px, py] of this.handles(c, d)) rc.painter.circle(px, py, HANDLE_RADIUS, rc.palette.background, color);
|
|
1319
1466
|
}
|
|
1320
1467
|
}
|
|
1321
1468
|
}
|
|
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
1469
|
};
|
|
1359
1470
|
|
|
1360
1471
|
// src/render/painter.ts
|
|
@@ -1471,12 +1582,14 @@ var inRect = (r, x, y, pad = 0) => x >= r.x - pad && x <= r.x + r.w + pad && y >
|
|
|
1471
1582
|
var LABEL_X = 12;
|
|
1472
1583
|
var LABEL_HEIGHT = 20;
|
|
1473
1584
|
var ORDER_DASH = [6, 4];
|
|
1585
|
+
var ALERT_DASH = [2, 3];
|
|
1474
1586
|
var TradingLayer = class {
|
|
1475
1587
|
constructor(onChange) {
|
|
1476
1588
|
this.onChange = onChange;
|
|
1477
1589
|
}
|
|
1478
1590
|
onChange;
|
|
1479
1591
|
positions = /* @__PURE__ */ new Map();
|
|
1592
|
+
/** Order lines and alert lines (same dragging and × behavior). */
|
|
1480
1593
|
orders = /* @__PURE__ */ new Map();
|
|
1481
1594
|
executions = [];
|
|
1482
1595
|
labelRects = /* @__PURE__ */ new Map();
|
|
@@ -1494,6 +1607,12 @@ var TradingLayer = class {
|
|
|
1494
1607
|
this.onChange();
|
|
1495
1608
|
return this.handle(id, this.orders);
|
|
1496
1609
|
}
|
|
1610
|
+
addAlert(options) {
|
|
1611
|
+
const id = newId("alert");
|
|
1612
|
+
this.orders.set(id, { ...options, quantity: 0, side: "buy", alert: true });
|
|
1613
|
+
this.onChange();
|
|
1614
|
+
return this.handle(id, this.orders);
|
|
1615
|
+
}
|
|
1497
1616
|
setExecutions(markers) {
|
|
1498
1617
|
this.executions = markers.slice();
|
|
1499
1618
|
this.onChange();
|
|
@@ -1642,12 +1761,11 @@ var TradingLayer = class {
|
|
|
1642
1761
|
for (const [id, order] of this.orders) {
|
|
1643
1762
|
const price = this.drag?.id === id ? this.drag.price : order.price;
|
|
1644
1763
|
const y = c.priceToY(price);
|
|
1645
|
-
const color = order.side === "buy" ? palette.up : palette.down;
|
|
1764
|
+
const color = order.alert ? palette.textSecondary : order.side === "buy" ? palette.up : palette.down;
|
|
1646
1765
|
badges.push({ y, text: c.formatPrice(price), bg: color, fg: "#FFFFFF" });
|
|
1647
1766
|
if (!visible(y)) continue;
|
|
1648
|
-
p.hLine(y, 0, c.plotWidth, color, ORDER_DASH);
|
|
1649
|
-
const
|
|
1650
|
-
const main = [side, order.label, String(order.quantity)].filter(Boolean).join(" ");
|
|
1767
|
+
p.hLine(y, 0, c.plotWidth, color, order.alert ? ALERT_DASH : ORDER_DASH);
|
|
1768
|
+
const main = order.alert ? order.label ?? labels.alert : [order.side === "buy" ? labels.buy : labels.sell, order.label, String(order.quantity)].filter(Boolean).join(" ");
|
|
1651
1769
|
const segments = [{ text: main, bg: color, fg: "#FFFFFF" }];
|
|
1652
1770
|
if (order.onCancel) segments.push({ text: "\xD7", bg: color, fg: "#FFFFFF", padding: 7 });
|
|
1653
1771
|
const rects = p.pill(segments, LABEL_X, y, LABEL_HEIGHT);
|
|
@@ -1682,6 +1800,8 @@ var DEFAULT_CHART_SETTINGS = {
|
|
|
1682
1800
|
gridVertical: true,
|
|
1683
1801
|
volume: true,
|
|
1684
1802
|
navigationButtons: true,
|
|
1803
|
+
priceScale: "normal",
|
|
1804
|
+
countdown: true,
|
|
1685
1805
|
upColor: null,
|
|
1686
1806
|
downColor: null
|
|
1687
1807
|
};
|
|
@@ -1697,6 +1817,8 @@ function parseChartState(input) {
|
|
|
1697
1817
|
settings: { ...DEFAULT_CHART_SETTINGS, ...s.settings ?? {} },
|
|
1698
1818
|
indicators: Array.isArray(s.indicators) ? s.indicators : [],
|
|
1699
1819
|
drawings: s.drawings && typeof s.drawings === "object" ? s.drawings : {},
|
|
1820
|
+
paneWeights: s.paneWeights && typeof s.paneWeights === "object" ? Object.fromEntries(Object.entries(s.paneWeights).filter(([, w]) => typeof w === "number" && w > 0)) : {},
|
|
1821
|
+
compares: Array.isArray(s.compares) ? s.compares.filter((x) => typeof x === "string") : [],
|
|
1700
1822
|
view: {
|
|
1701
1823
|
barSpacing: Number(s.view?.barSpacing) || 8,
|
|
1702
1824
|
rightOffset: Number.isFinite(Number(s.view?.rightOffset)) ? Number(s.view?.rightOffset) : 5
|
|
@@ -1985,28 +2107,40 @@ function drawLineSeries(ctx, m, pane, area) {
|
|
|
1985
2107
|
function drawIndicator(ctx, p, m, pane, output) {
|
|
1986
2108
|
const { timeScale, frame } = m;
|
|
1987
2109
|
const pal = frame.palette;
|
|
1988
|
-
const
|
|
1989
|
-
|
|
1990
|
-
|
|
2110
|
+
const n = m.bars.length;
|
|
2111
|
+
if (frame.range.to < frame.range.from) return;
|
|
2112
|
+
const span = (shift) => {
|
|
2113
|
+
const lastX = Math.ceil(timeScale.xToIndexFloat(frame.plotWidth)) + 1;
|
|
2114
|
+
return [Math.max(0, frame.range.from - 1 - shift), Math.min(n - 1, (shift > 0 ? lastX : frame.range.to + 1) - shift)];
|
|
2115
|
+
};
|
|
2116
|
+
const [from, to] = span(0);
|
|
1991
2117
|
const yOf = (v) => pane.top + pane.scale.priceToY(v);
|
|
1992
|
-
const byKey = (key) => output.series.find((s) => s.key === key)
|
|
2118
|
+
const byKey = (key) => output.series.find((s) => s.key === key);
|
|
1993
2119
|
for (const level of output.levels ?? []) p.hLine(yOf(level), 0, frame.plotWidth, pal.textSecondary, [3, 4]);
|
|
1994
2120
|
if (output.fill) {
|
|
1995
|
-
const
|
|
1996
|
-
const lower = byKey(output.fill.lower);
|
|
1997
|
-
if (
|
|
2121
|
+
const upperSeries = byKey(output.fill.upper);
|
|
2122
|
+
const lower = byKey(output.fill.lower)?.values;
|
|
2123
|
+
if (upperSeries && lower) {
|
|
2124
|
+
const upper = upperSeries.values;
|
|
2125
|
+
const shift = upperSeries.shift ?? 0;
|
|
2126
|
+
const [f0, f1] = span(shift);
|
|
2127
|
+
const { color, downColor } = output.fill;
|
|
2128
|
+
const sideOf = (j) => downColor && lower[j] > upper[j] ? 1 : 0;
|
|
2129
|
+
const valid = (j) => !Number.isNaN(upper[j]) && !Number.isNaN(lower[j]);
|
|
1998
2130
|
ctx.save();
|
|
1999
2131
|
ctx.globalAlpha = 0.07;
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
while (i <= to && (Number.isNaN(upper[i]) || Number.isNaN(lower[i]))) i++;
|
|
2132
|
+
let i = f0;
|
|
2133
|
+
while (i <= f1) {
|
|
2134
|
+
while (i <= f1 && !valid(i)) i++;
|
|
2004
2135
|
const start = i;
|
|
2005
|
-
|
|
2006
|
-
|
|
2136
|
+
const side = valid(i) ? sideOf(i) : 0;
|
|
2137
|
+
while (i <= f1 && valid(i) && sideOf(i) === side) i++;
|
|
2138
|
+
const end = i <= f1 && valid(i) ? i + 1 : i;
|
|
2139
|
+
if (end - start < 2) continue;
|
|
2140
|
+
ctx.fillStyle = side ? downColor : color;
|
|
2007
2141
|
ctx.beginPath();
|
|
2008
|
-
for (let j = start; j <
|
|
2009
|
-
for (let j =
|
|
2142
|
+
for (let j = start; j < end; j++) ctx.lineTo(timeScale.indexToX(j + shift), yOf(upper[j]));
|
|
2143
|
+
for (let j = end - 1; j >= start; j--) ctx.lineTo(timeScale.indexToX(j + shift), yOf(lower[j]));
|
|
2010
2144
|
ctx.closePath();
|
|
2011
2145
|
ctx.fill();
|
|
2012
2146
|
}
|
|
@@ -2042,13 +2176,15 @@ function drawIndicator(ctx, p, m, pane, output) {
|
|
|
2042
2176
|
ctx.lineWidth = series.width;
|
|
2043
2177
|
ctx.beginPath();
|
|
2044
2178
|
let pen = false;
|
|
2045
|
-
|
|
2179
|
+
const shift = series.shift ?? 0;
|
|
2180
|
+
const [s0, s1] = span(shift);
|
|
2181
|
+
for (let i = s0; i <= s1; i++) {
|
|
2046
2182
|
const v = series.values[i];
|
|
2047
2183
|
if (Number.isNaN(v)) {
|
|
2048
2184
|
pen = false;
|
|
2049
2185
|
continue;
|
|
2050
2186
|
}
|
|
2051
|
-
const x = timeScale.indexToX(i);
|
|
2187
|
+
const x = timeScale.indexToX(i + shift);
|
|
2052
2188
|
if (pen) ctx.lineTo(x, yOf(v));
|
|
2053
2189
|
else ctx.moveTo(x, yOf(v));
|
|
2054
2190
|
pen = true;
|
|
@@ -2181,8 +2317,8 @@ function drawMain(layer, m) {
|
|
|
2181
2317
|
for (const price of f.priceTicks) {
|
|
2182
2318
|
const y = main.scale.priceToY(price);
|
|
2183
2319
|
if (y < 8 || y > main.height - 8) continue;
|
|
2184
|
-
if (last &&
|
|
2185
|
-
p.text(m.formatPrice(price), labelX, y, pal.textSecondary);
|
|
2320
|
+
if (last && y > lastY - BADGE_HEIGHT && y < lastY + (f.countdown ? BADGE_HEIGHT * 2 - 4 : BADGE_HEIGHT)) continue;
|
|
2321
|
+
p.text((main.tickFormat ?? m.formatPrice)(price), labelX, y, pal.textSecondary);
|
|
2186
2322
|
}
|
|
2187
2323
|
for (const pane of f.panes) {
|
|
2188
2324
|
if (pane.kind !== "indicator") continue;
|
|
@@ -2198,9 +2334,16 @@ function drawMain(layer, m) {
|
|
|
2198
2334
|
p.text(badge.text, labelX, y, badge.fg);
|
|
2199
2335
|
}
|
|
2200
2336
|
if (last && lastY >= 0 && lastY <= main.height) {
|
|
2201
|
-
const
|
|
2202
|
-
|
|
2203
|
-
p.
|
|
2337
|
+
const h = f.countdown ? BADGE_HEIGHT * 2 - 4 : BADGE_HEIGHT;
|
|
2338
|
+
const y = Math.min(main.height - h + BADGE_HEIGHT / 2, Math.max(BADGE_HEIGHT / 2, lastY));
|
|
2339
|
+
p.roundRect(f.plotWidth + 2, y - BADGE_HEIGHT / 2, f.width - f.plotWidth - 4, h, 4, lastColor);
|
|
2340
|
+
p.text(main.format(last.close), labelX, y, "#FFFFFF");
|
|
2341
|
+
if (f.countdown) {
|
|
2342
|
+
p.ctx.save();
|
|
2343
|
+
p.ctx.globalAlpha = 0.85;
|
|
2344
|
+
p.text(f.countdown, labelX, y + BADGE_HEIGHT - 4, "#FFFFFF");
|
|
2345
|
+
p.ctx.restore();
|
|
2346
|
+
}
|
|
2204
2347
|
}
|
|
2205
2348
|
const timeY = f.panesHeight + f.timeAxisHeight / 2;
|
|
2206
2349
|
for (const tick of f.timeTicks) {
|
|
@@ -2239,6 +2382,13 @@ function drawOverlay(layer, m, ch, tooltip = null) {
|
|
|
2239
2382
|
ctx.font = f.font;
|
|
2240
2383
|
if (tooltip) drawTooltip(p, f, tooltip);
|
|
2241
2384
|
if (!ch) return;
|
|
2385
|
+
if (m.replaySelecting) {
|
|
2386
|
+
ctx.save();
|
|
2387
|
+
ctx.globalAlpha = 0.12;
|
|
2388
|
+
ctx.fillStyle = pal.text;
|
|
2389
|
+
ctx.fillRect(ch.x, 0, f.plotWidth - ch.x, f.panesHeight);
|
|
2390
|
+
ctx.restore();
|
|
2391
|
+
}
|
|
2242
2392
|
p.vLine(ch.x, 0, f.panesHeight, pal.crosshair, DASH);
|
|
2243
2393
|
if (ch.source !== "sync") drawCrosshairValue(p, m, ch);
|
|
2244
2394
|
const timeLabel = m.formatCrosshairTime(ch.time);
|
|
@@ -2293,6 +2443,103 @@ function isIntradayResolution(resolution) {
|
|
|
2293
2443
|
return resolutionToMs(resolution) < DAY_MS;
|
|
2294
2444
|
}
|
|
2295
2445
|
|
|
2446
|
+
// src/realtime.ts
|
|
2447
|
+
var offsetFormatters = /* @__PURE__ */ new Map();
|
|
2448
|
+
function timeZoneOffset(time, timeZone) {
|
|
2449
|
+
if (timeZone === "UTC") return 0;
|
|
2450
|
+
let dtf = offsetFormatters.get(timeZone);
|
|
2451
|
+
if (!dtf) {
|
|
2452
|
+
dtf = new Intl.DateTimeFormat("en-US", {
|
|
2453
|
+
timeZone,
|
|
2454
|
+
hourCycle: "h23",
|
|
2455
|
+
year: "numeric",
|
|
2456
|
+
month: "numeric",
|
|
2457
|
+
day: "numeric",
|
|
2458
|
+
hour: "numeric",
|
|
2459
|
+
minute: "numeric",
|
|
2460
|
+
second: "numeric"
|
|
2461
|
+
});
|
|
2462
|
+
offsetFormatters.set(timeZone, dtf);
|
|
2463
|
+
}
|
|
2464
|
+
const p = {};
|
|
2465
|
+
for (const { type, value } of dtf.formatToParts(time)) p[type] = Number(value);
|
|
2466
|
+
const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour % 24, p.minute, p.second);
|
|
2467
|
+
return Math.round((asUtc - Math.floor(time / 1e3) * 1e3) / 6e4) * 6e4;
|
|
2468
|
+
}
|
|
2469
|
+
function bucketStart(time, resolution, timeZone = "UTC") {
|
|
2470
|
+
const offset = timeZoneOffset(time, timeZone);
|
|
2471
|
+
const local = time + offset;
|
|
2472
|
+
const unit = resolution.trim().slice(-1).toUpperCase();
|
|
2473
|
+
if (unit === "M") {
|
|
2474
|
+
const n = Number(resolution.slice(0, -1)) || 1;
|
|
2475
|
+
const d = new Date(local);
|
|
2476
|
+
const months = d.getUTCFullYear() * 12 + d.getUTCMonth();
|
|
2477
|
+
const start = months - months % n;
|
|
2478
|
+
return Date.UTC(Math.floor(start / 12), start % 12, 1) - offset;
|
|
2479
|
+
}
|
|
2480
|
+
if (unit === "W") {
|
|
2481
|
+
const days = Math.floor(local / DAY_MS);
|
|
2482
|
+
const sinceMonday = ((days + 3) % 7 + 7) % 7;
|
|
2483
|
+
return (days - sinceMonday) * DAY_MS - offset;
|
|
2484
|
+
}
|
|
2485
|
+
const size = resolutionToMs(resolution);
|
|
2486
|
+
return Math.floor(local / size) * size - offset;
|
|
2487
|
+
}
|
|
2488
|
+
var TickAggregator = class {
|
|
2489
|
+
constructor(resolution, timeZone = "UTC") {
|
|
2490
|
+
this.resolution = resolution;
|
|
2491
|
+
this.timeZone = timeZone;
|
|
2492
|
+
}
|
|
2493
|
+
resolution;
|
|
2494
|
+
timeZone;
|
|
2495
|
+
current = null;
|
|
2496
|
+
/** Start from the latest historical bar so the first tick updates it instead of opening a new one. */
|
|
2497
|
+
seed(bar) {
|
|
2498
|
+
this.current = bar ? { ...bar, volume: bar.volume ?? 0 } : null;
|
|
2499
|
+
}
|
|
2500
|
+
/** Returns the updated bar, or null for a tick belonging to an already-closed bar. */
|
|
2501
|
+
push(tick) {
|
|
2502
|
+
const start = bucketStart(tick.time, this.resolution, this.timeZone);
|
|
2503
|
+
const bar = this.current;
|
|
2504
|
+
const volume = tick.volume ?? 0;
|
|
2505
|
+
if (!bar || start > bar.time) {
|
|
2506
|
+
this.current = { time: start, open: tick.price, high: tick.price, low: tick.price, close: tick.price, volume };
|
|
2507
|
+
} else if (start === bar.time) {
|
|
2508
|
+
bar.high = Math.max(bar.high, tick.price);
|
|
2509
|
+
bar.low = Math.min(bar.low, tick.price);
|
|
2510
|
+
bar.close = tick.price;
|
|
2511
|
+
bar.volume = (bar.volume ?? 0) + volume;
|
|
2512
|
+
} else {
|
|
2513
|
+
return null;
|
|
2514
|
+
}
|
|
2515
|
+
return { ...this.current };
|
|
2516
|
+
}
|
|
2517
|
+
};
|
|
2518
|
+
|
|
2519
|
+
// src/session.ts
|
|
2520
|
+
var DAY = 864e5;
|
|
2521
|
+
var MINUTE2 = 6e4;
|
|
2522
|
+
function sessionStatus(session, timeZone, now = Date.now()) {
|
|
2523
|
+
if (session.trim().toLowerCase() === "24x7") return { open: true, nextChange: null };
|
|
2524
|
+
const match = /^(\d{2})(\d{2})-(\d{2})(\d{2})(?::([1-7]+))?$/.exec(session.trim());
|
|
2525
|
+
if (!match) return { open: true, nextChange: null };
|
|
2526
|
+
const openMin = Number(match[1]) * 60 + Number(match[2]);
|
|
2527
|
+
const closeMin = Number(match[3]) * 60 + Number(match[4]);
|
|
2528
|
+
const days = new Set((match[5] ?? "12345").split("").map(Number));
|
|
2529
|
+
const offset = timeZoneOffset(now, timeZone);
|
|
2530
|
+
const localMidnight = Math.floor((now + offset) / DAY) * DAY;
|
|
2531
|
+
for (let d = -1; d <= 8; d++) {
|
|
2532
|
+
const dayStart = localMidnight + d * DAY;
|
|
2533
|
+
const isoWeekday = (new Date(dayStart).getUTCDay() + 6) % 7 + 1;
|
|
2534
|
+
if (!days.has(isoWeekday)) continue;
|
|
2535
|
+
const open = dayStart + openMin * MINUTE2 - offset;
|
|
2536
|
+
const close = dayStart + closeMin * MINUTE2 - offset;
|
|
2537
|
+
if (now < open) return { open: false, nextChange: open };
|
|
2538
|
+
if (now < close) return { open: true, nextChange: close };
|
|
2539
|
+
}
|
|
2540
|
+
return { open: false, nextChange: null };
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2296
2543
|
// src/theme.ts
|
|
2297
2544
|
var FONT_STACK = "Pretendard, -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Segoe UI', Roboto, sans-serif";
|
|
2298
2545
|
var DIRECTION_COLORS = {
|
|
@@ -2331,6 +2578,8 @@ function createTheme(base, overrides = {}, direction) {
|
|
|
2331
2578
|
var TIME_AXIS_HEIGHT = 28;
|
|
2332
2579
|
var MIN_PRICE_AXIS_WIDTH = 56;
|
|
2333
2580
|
var PANE_WEIGHT = { main: 4, volume: 1, indicator: 1.3 };
|
|
2581
|
+
var MIN_PANE_HEIGHT = 32;
|
|
2582
|
+
var PANE_BORDER_GRAB = 4;
|
|
2334
2583
|
var HISTORY_TRIGGER_BARS = 20;
|
|
2335
2584
|
var TIME_LABEL_MIN_GAP = 72;
|
|
2336
2585
|
var PRICE_TICK_SPACING = 48;
|
|
@@ -2339,10 +2588,31 @@ var LABELS = {
|
|
|
2339
2588
|
en: { high: "High", low: "Low" }
|
|
2340
2589
|
};
|
|
2341
2590
|
var TRADING_LABELS = {
|
|
2342
|
-
ko: { buy: "\uB9E4\uC218", sell: "\uB9E4\uB3C4", position: "\uD3C9\uADE0\uB2E8\uAC00" },
|
|
2343
|
-
en: { buy: "Buy", sell: "Sell", position: "Avg" }
|
|
2591
|
+
ko: { buy: "\uB9E4\uC218", sell: "\uB9E4\uB3C4", position: "\uD3C9\uADE0\uB2E8\uAC00", alert: "\uC54C\uB9BC" },
|
|
2592
|
+
en: { buy: "Buy", sell: "Sell", position: "Avg", alert: "Alert" }
|
|
2344
2593
|
};
|
|
2345
2594
|
var HIT_TOLERANCE = { mouse: 5, touch: 14 };
|
|
2595
|
+
var COMPARE_COLORS = ["#F06595", "#15AABF", "#FF9F0A", "#7048E8", "#12B886"];
|
|
2596
|
+
var MAX_COMPARES = COMPARE_COLORS.length;
|
|
2597
|
+
var HISTORY_LIMIT = 100;
|
|
2598
|
+
function formatCountdown(ms) {
|
|
2599
|
+
const total = Math.max(0, Math.floor(ms / 1e3));
|
|
2600
|
+
const d = Math.floor(total / 86400);
|
|
2601
|
+
const h = Math.floor(total % 86400 / 3600);
|
|
2602
|
+
const m = Math.floor(total % 3600 / 60);
|
|
2603
|
+
const s = total % 60;
|
|
2604
|
+
const two = (n) => String(n).padStart(2, "0");
|
|
2605
|
+
if (d > 0) return `${d}d ${two(h)}:${two(m)}`;
|
|
2606
|
+
return h > 0 ? `${h}:${two(m)}:${two(s)}` : `${two(m)}:${two(s)}`;
|
|
2607
|
+
}
|
|
2608
|
+
function formatPercent(v, decimals) {
|
|
2609
|
+
const rounded = Number(v.toFixed(decimals));
|
|
2610
|
+
return `${rounded > 0 ? "+" : ""}${rounded.toFixed(decimals)}%`;
|
|
2611
|
+
}
|
|
2612
|
+
function firstFinite(values, from) {
|
|
2613
|
+
for (let i = Math.max(0, from); i < values.length; i++) if (!Number.isNaN(values[i])) return values[i];
|
|
2614
|
+
return Number.NaN;
|
|
2615
|
+
}
|
|
2346
2616
|
var TOOLTIP_LABELS = {
|
|
2347
2617
|
ko: { buy: "\uB9E4\uC218", sell: "\uB9E4\uB3C4", fills: (n) => `${n}\uAC74`, price: "\uCCB4\uACB0\uAC00", avg: "\uD3C9\uADE0\uAC00", qty: "\uC218\uB7C9" },
|
|
2348
2618
|
en: { buy: "Buy", sell: "Sell", fills: (n) => `${n} fills`, price: "Price", avg: "Avg", qty: "Qty" }
|
|
@@ -2364,7 +2634,7 @@ var Chart = class {
|
|
|
2364
2634
|
store = new BarStore();
|
|
2365
2635
|
mainScale = new PriceScale({ top: 0.08, bottom: 0.08 });
|
|
2366
2636
|
volumeScale = new PriceScale({ top: 0.2, bottom: 0 });
|
|
2367
|
-
indicators
|
|
2637
|
+
indicators;
|
|
2368
2638
|
indicatorScales = /* @__PURE__ */ new Map();
|
|
2369
2639
|
emitter = new Emitter();
|
|
2370
2640
|
colors;
|
|
@@ -2416,6 +2686,18 @@ var Chart = class {
|
|
|
2416
2686
|
loadingHistory = false;
|
|
2417
2687
|
noMoreHistory = false;
|
|
2418
2688
|
historyRequestedFor = Number.NaN;
|
|
2689
|
+
countdownLabel = null;
|
|
2690
|
+
/** Undo/redo of drawings + indicators, as JSON snapshots. */
|
|
2691
|
+
history = { undo: [], redo: [], current: "", applying: false };
|
|
2692
|
+
compares = /* @__PURE__ */ new Map();
|
|
2693
|
+
compareVersion = 0;
|
|
2694
|
+
/** Base bar index of the % axis and compare lines (the first visible bar). */
|
|
2695
|
+
percentBaseIndex = 0;
|
|
2696
|
+
replayState = null;
|
|
2697
|
+
replaySelecting = false;
|
|
2698
|
+
/** Pane height weights the user set by dragging borders (unset panes use PANE_WEIGHT). */
|
|
2699
|
+
paneWeights = /* @__PURE__ */ new Map();
|
|
2700
|
+
paneWeightTotal = 1;
|
|
2419
2701
|
constructor(container, options = {}) {
|
|
2420
2702
|
this.theme = { ...lightTheme, ...options.theme };
|
|
2421
2703
|
this.locale = options.locale ?? "ko";
|
|
@@ -2446,18 +2728,21 @@ var Chart = class {
|
|
|
2446
2728
|
this.root.appendChild(probe);
|
|
2447
2729
|
container.appendChild(this.root);
|
|
2448
2730
|
this.colors = new ColorResolver(probe);
|
|
2731
|
+
this.indicators = new IndicatorManager(indicatorRegistry(options.indicatorDefinitions));
|
|
2449
2732
|
this.drawings = new DrawingManager((change) => {
|
|
2450
2733
|
this.invalidate();
|
|
2451
2734
|
if (change === "commit") {
|
|
2452
2735
|
this.emitter.emit("drawingsChange", this.getDrawings());
|
|
2453
2736
|
this.stateChanged();
|
|
2737
|
+
this.recordHistory();
|
|
2454
2738
|
}
|
|
2455
2739
|
if (change === "tool") this.emitter.emit("drawingToolChange", this.drawings.tool);
|
|
2456
2740
|
if (change !== "preview") {
|
|
2457
2741
|
const d = this.drawings.selected;
|
|
2458
2742
|
this.emitter.emit("selectionChange", d ? { ...d, points: d.points.map((p) => ({ ...p })) } : null);
|
|
2459
2743
|
}
|
|
2460
|
-
});
|
|
2744
|
+
}, drawingRegistry(options.drawingDefinitions));
|
|
2745
|
+
this.history.current = this.historySnapshot();
|
|
2461
2746
|
this.trading = new TradingLayer(() => this.invalidate());
|
|
2462
2747
|
this.tradingSync = new TradingSync(this.trading, (error) => this.emitter.emit("error", error));
|
|
2463
2748
|
this.tradingSync.adapter = options.trading ?? null;
|
|
@@ -2467,10 +2752,12 @@ var Chart = class {
|
|
|
2467
2752
|
this.observeSize();
|
|
2468
2753
|
this.observeThemeVariables();
|
|
2469
2754
|
this.observeKeyboard();
|
|
2755
|
+
this.observeCountdown();
|
|
2470
2756
|
}
|
|
2471
2757
|
// ── Public API ──────────────────────────────────────────────────────
|
|
2472
2758
|
/** Replaces all bars and scrolls to the latest. */
|
|
2473
2759
|
setData(bars) {
|
|
2760
|
+
this.endReplay(false);
|
|
2474
2761
|
this.store.set(bars);
|
|
2475
2762
|
this.timeScale.barCount = this.store.length;
|
|
2476
2763
|
this.timeScale.rightOffset = this.timeScale.options.rightOffset;
|
|
@@ -2483,6 +2770,13 @@ var Chart = class {
|
|
|
2483
2770
|
}
|
|
2484
2771
|
/** Realtime tick: same time as the last bar updates it, a newer time appends a bar. */
|
|
2485
2772
|
update(bar) {
|
|
2773
|
+
const replay = this.replayState;
|
|
2774
|
+
if (replay && (replay.future.length > 0 || bar.time > (this.store.last()?.time ?? -Infinity))) {
|
|
2775
|
+
const tail = replay.future[replay.future.length - 1];
|
|
2776
|
+
if (tail && tail.time === bar.time) replay.future[replay.future.length - 1] = bar;
|
|
2777
|
+
else if (!tail || bar.time > tail.time) replay.future.push(bar);
|
|
2778
|
+
return;
|
|
2779
|
+
}
|
|
2486
2780
|
const result = this.store.upsert(bar);
|
|
2487
2781
|
if (result === "ignored") return;
|
|
2488
2782
|
if (result === "append") {
|
|
@@ -2511,6 +2805,7 @@ var Chart = class {
|
|
|
2511
2805
|
const feed = this.datafeed;
|
|
2512
2806
|
if (!feed) throw new Error("funnest-charts: setSymbol() requires the `datafeed` option");
|
|
2513
2807
|
const token = ++this.loadToken;
|
|
2808
|
+
this.endReplay(false);
|
|
2514
2809
|
this.unsubscribeBars?.();
|
|
2515
2810
|
this.unsubscribeBars = null;
|
|
2516
2811
|
const keepQuotes = this.unsubscribeQuotes !== null && this.quoteSymbol === symbol;
|
|
@@ -2576,6 +2871,7 @@ var Chart = class {
|
|
|
2576
2871
|
this.emitter.emit("quoteChange", this.latestQuote);
|
|
2577
2872
|
}) ?? null;
|
|
2578
2873
|
}
|
|
2874
|
+
for (const entry of this.compares.values()) void this.reloadCompare(entry);
|
|
2579
2875
|
this.emitter.emit("symbolChange", info);
|
|
2580
2876
|
this.stateChanged();
|
|
2581
2877
|
}
|
|
@@ -2704,7 +3000,7 @@ var Chart = class {
|
|
|
2704
3000
|
*/
|
|
2705
3001
|
async resync(token) {
|
|
2706
3002
|
const feed = this.datafeed;
|
|
2707
|
-
if (!feed || !this.symbol || !this.resolution) return;
|
|
3003
|
+
if (!feed || !this.symbol || !this.resolution || this.replayState) return;
|
|
2708
3004
|
try {
|
|
2709
3005
|
const to = Date.now();
|
|
2710
3006
|
const result = await feed.getBars(this.symbol, this.resolution, {
|
|
@@ -2760,7 +3056,9 @@ var Chart = class {
|
|
|
2760
3056
|
settings: this.settings,
|
|
2761
3057
|
indicators: this.getIndicators(),
|
|
2762
3058
|
drawings: JSON.parse(JSON.stringify(drawings)),
|
|
2763
|
-
view: { barSpacing: this.timeScale.barSpacing, rightOffset: this.timeScale.rightOffset }
|
|
3059
|
+
view: { barSpacing: this.timeScale.barSpacing, rightOffset: this.timeScale.rightOffset },
|
|
3060
|
+
compares: [...this.compares.keys()],
|
|
3061
|
+
paneWeights: Object.fromEntries(this.paneWeights)
|
|
2764
3062
|
};
|
|
2765
3063
|
}
|
|
2766
3064
|
/**
|
|
@@ -2783,6 +3081,12 @@ var Chart = class {
|
|
|
2783
3081
|
this.timeScale.barSpacing = state.view.barSpacing;
|
|
2784
3082
|
this.timeScale.rightOffset = state.view.rightOffset;
|
|
2785
3083
|
this.timeScale.clamp();
|
|
3084
|
+
this.paneWeights.clear();
|
|
3085
|
+
for (const [id, w] of Object.entries(state.paneWeights ?? {})) if (w > 0) this.paneWeights.set(id, w);
|
|
3086
|
+
const compares = state.compares ?? [];
|
|
3087
|
+
for (const symbol of [...this.compares.keys()]) if (!compares.includes(symbol)) this.removeCompare(symbol);
|
|
3088
|
+
if (this.datafeed) for (const symbol of compares) void this.addCompare(symbol);
|
|
3089
|
+
this.resetHistory();
|
|
2786
3090
|
this.invalidate();
|
|
2787
3091
|
this.stateChanged();
|
|
2788
3092
|
}
|
|
@@ -2791,6 +3095,7 @@ var Chart = class {
|
|
|
2791
3095
|
const list = this.drawingsBySymbol.get(symbol) ?? [];
|
|
2792
3096
|
this.drawingsBySymbol.delete(symbol);
|
|
2793
3097
|
this.drawings.set(list);
|
|
3098
|
+
this.resetHistory();
|
|
2794
3099
|
}
|
|
2795
3100
|
stateChanged() {
|
|
2796
3101
|
if (this.stateQueued) return;
|
|
@@ -2827,6 +3132,13 @@ var Chart = class {
|
|
|
2827
3132
|
updateDrawing(id, patch) {
|
|
2828
3133
|
this.drawings.update(id, patch);
|
|
2829
3134
|
}
|
|
3135
|
+
/** Adds a drawing (e.g. a horizontal line at a price) and selects it. Returns its id. */
|
|
3136
|
+
addDrawing(drawing) {
|
|
3137
|
+
const id = `d_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
3138
|
+
this.drawings.set([...this.drawings.drawings, { ...drawing, id, points: drawing.points.map((p) => ({ ...p })) }]);
|
|
3139
|
+
this.drawings.select(id);
|
|
3140
|
+
return id;
|
|
3141
|
+
}
|
|
2830
3142
|
removeDrawing(id) {
|
|
2831
3143
|
this.drawings.remove(id);
|
|
2832
3144
|
}
|
|
@@ -2849,6 +3161,7 @@ var Chart = class {
|
|
|
2849
3161
|
removeIndicator(id) {
|
|
2850
3162
|
if (!this.indicators.remove(id)) return;
|
|
2851
3163
|
this.indicatorScales.delete(id);
|
|
3164
|
+
this.paneWeights.delete(id);
|
|
2852
3165
|
this.indicatorsChanged();
|
|
2853
3166
|
}
|
|
2854
3167
|
/** Changes params, per-series styles (color/width/visible) and/or overall visibility. */
|
|
@@ -2870,7 +3183,7 @@ var Chart = class {
|
|
|
2870
3183
|
/** Formatted indicator values at a bar index (default: the last bar), for legends. */
|
|
2871
3184
|
indicatorLegend(index = this.store.length - 1) {
|
|
2872
3185
|
return this.indicators.list.map((cfg) => {
|
|
2873
|
-
const def =
|
|
3186
|
+
const def = this.indicators.def(cfg.type);
|
|
2874
3187
|
const output = this.indicators.output(cfg, this.store.bars, this.dataVersion);
|
|
2875
3188
|
const precision = output.precision ?? this.pricePrecision;
|
|
2876
3189
|
return {
|
|
@@ -2880,7 +3193,7 @@ var Chart = class {
|
|
|
2880
3193
|
overlay: def.overlay,
|
|
2881
3194
|
visible: cfg.visible !== false,
|
|
2882
3195
|
values: output.series.filter((series) => series.visible).map((series) => {
|
|
2883
|
-
const v = series.values[index];
|
|
3196
|
+
const v = series.values[index - (series.shift ?? 0)];
|
|
2884
3197
|
return {
|
|
2885
3198
|
key: series.key,
|
|
2886
3199
|
label: series.label,
|
|
@@ -2895,6 +3208,373 @@ var Chart = class {
|
|
|
2895
3208
|
this.invalidate();
|
|
2896
3209
|
this.emitter.emit("indicatorsChange", this.getIndicators());
|
|
2897
3210
|
this.stateChanged();
|
|
3211
|
+
this.recordHistory();
|
|
3212
|
+
}
|
|
3213
|
+
/** Built-in and custom indicators this chart can add. */
|
|
3214
|
+
indicatorDefinitions() {
|
|
3215
|
+
return [...this.indicators.registry.values()];
|
|
3216
|
+
}
|
|
3217
|
+
indicatorDefinition(type) {
|
|
3218
|
+
return this.indicators.registry.get(type);
|
|
3219
|
+
}
|
|
3220
|
+
/** Built-in and custom drawing tools, in drawing-bar order. */
|
|
3221
|
+
drawingDefinitions() {
|
|
3222
|
+
return [...this.drawings.registry.values()];
|
|
3223
|
+
}
|
|
3224
|
+
// ── Pane sizes ─────────────────────────────────────────────────────
|
|
3225
|
+
/**
|
|
3226
|
+
* Moves the border above pane `index` (1 = between the price pane and the next one) by `dy` px.
|
|
3227
|
+
* The two neighbouring panes trade height; the rest keep theirs. Users do this by dragging.
|
|
3228
|
+
*/
|
|
3229
|
+
resizePane(index, dy) {
|
|
3230
|
+
const panes = this.frame?.panes;
|
|
3231
|
+
const above = panes?.[index - 1];
|
|
3232
|
+
const below = panes?.[index];
|
|
3233
|
+
if (!panes || !above || !below || !this.frame) return;
|
|
3234
|
+
const pair = above.height + below.height;
|
|
3235
|
+
const top = Math.min(pair - MIN_PANE_HEIGHT, Math.max(MIN_PANE_HEIGHT, above.height + dy));
|
|
3236
|
+
const unit = this.paneWeightTotal / Math.max(1, this.frame.panesHeight);
|
|
3237
|
+
for (const p of panes) this.paneWeights.set(p.id, p.height * unit);
|
|
3238
|
+
this.paneWeights.set(above.id, top * unit);
|
|
3239
|
+
this.paneWeights.set(below.id, (pair - top) * unit);
|
|
3240
|
+
this.invalidate();
|
|
3241
|
+
this.stateChanged();
|
|
3242
|
+
}
|
|
3243
|
+
/** Back to the default pane heights. */
|
|
3244
|
+
resetPaneSizes() {
|
|
3245
|
+
if (this.paneWeights.size === 0) return;
|
|
3246
|
+
this.paneWeights.clear();
|
|
3247
|
+
this.invalidate();
|
|
3248
|
+
this.stateChanged();
|
|
3249
|
+
}
|
|
3250
|
+
// ── Undo / redo ────────────────────────────────────────────────────
|
|
3251
|
+
get canUndo() {
|
|
3252
|
+
return this.history.undo.length > 0;
|
|
3253
|
+
}
|
|
3254
|
+
get canRedo() {
|
|
3255
|
+
return this.history.redo.length > 0;
|
|
3256
|
+
}
|
|
3257
|
+
/** Reverts the last drawing or indicator change. */
|
|
3258
|
+
undo() {
|
|
3259
|
+
const previous = this.history.undo.pop();
|
|
3260
|
+
if (previous === void 0) return;
|
|
3261
|
+
this.history.redo.push(this.history.current);
|
|
3262
|
+
this.applyHistory(previous);
|
|
3263
|
+
}
|
|
3264
|
+
redo() {
|
|
3265
|
+
const next = this.history.redo.pop();
|
|
3266
|
+
if (next === void 0) return;
|
|
3267
|
+
this.history.undo.push(this.history.current);
|
|
3268
|
+
this.applyHistory(next);
|
|
3269
|
+
}
|
|
3270
|
+
historySnapshot() {
|
|
3271
|
+
return JSON.stringify([this.drawings.drawings, this.indicators.list]);
|
|
3272
|
+
}
|
|
3273
|
+
recordHistory() {
|
|
3274
|
+
const h = this.history;
|
|
3275
|
+
if (h.applying) return;
|
|
3276
|
+
const next = this.historySnapshot();
|
|
3277
|
+
if (next === h.current) return;
|
|
3278
|
+
h.undo.push(h.current);
|
|
3279
|
+
if (h.undo.length > HISTORY_LIMIT) h.undo.shift();
|
|
3280
|
+
h.redo = [];
|
|
3281
|
+
h.current = next;
|
|
3282
|
+
this.emitHistory();
|
|
3283
|
+
}
|
|
3284
|
+
resetHistory() {
|
|
3285
|
+
this.history.undo = [];
|
|
3286
|
+
this.history.redo = [];
|
|
3287
|
+
this.history.current = this.historySnapshot();
|
|
3288
|
+
this.emitHistory();
|
|
3289
|
+
}
|
|
3290
|
+
applyHistory(snapshot) {
|
|
3291
|
+
const [drawings, indicators] = JSON.parse(snapshot);
|
|
3292
|
+
this.history.applying = true;
|
|
3293
|
+
try {
|
|
3294
|
+
this.drawings.set(drawings);
|
|
3295
|
+
this.setIndicators(indicators);
|
|
3296
|
+
} finally {
|
|
3297
|
+
this.history.applying = false;
|
|
3298
|
+
}
|
|
3299
|
+
this.history.current = snapshot;
|
|
3300
|
+
this.emitHistory();
|
|
3301
|
+
}
|
|
3302
|
+
emitHistory() {
|
|
3303
|
+
this.emitter.emit("historyChange", { canUndo: this.canUndo, canRedo: this.canRedo });
|
|
3304
|
+
}
|
|
3305
|
+
// ── Compare ────────────────────────────────────────────────────────
|
|
3306
|
+
/**
|
|
3307
|
+
* Overlays another symbol's closes as a line, rebased to the first visible bar. The price
|
|
3308
|
+
* axis switches to % while any symbol is compared. Needs the `datafeed` option.
|
|
3309
|
+
*/
|
|
3310
|
+
async addCompare(symbol) {
|
|
3311
|
+
if (!this.datafeed) throw new Error("funnest-charts: addCompare() requires the `datafeed` option");
|
|
3312
|
+
if (this.compares.has(symbol) || this.compares.size >= MAX_COMPARES || symbol === this.symbol?.symbol) return;
|
|
3313
|
+
const used = new Set([...this.compares.values()].map((e) => e.color));
|
|
3314
|
+
const entry = {
|
|
3315
|
+
symbol,
|
|
3316
|
+
info: null,
|
|
3317
|
+
store: new BarStore(),
|
|
3318
|
+
color: COMPARE_COLORS.find((c) => !used.has(c)) ?? COMPARE_COLORS[0],
|
|
3319
|
+
unsubscribe: null,
|
|
3320
|
+
loadingOlder: false,
|
|
3321
|
+
noMoreOlder: false,
|
|
3322
|
+
aligned: null
|
|
3323
|
+
};
|
|
3324
|
+
this.compares.set(symbol, entry);
|
|
3325
|
+
this.comparesChanged();
|
|
3326
|
+
await this.reloadCompare(entry);
|
|
3327
|
+
}
|
|
3328
|
+
removeCompare(symbol) {
|
|
3329
|
+
const entry = this.compares.get(symbol);
|
|
3330
|
+
if (!entry) return;
|
|
3331
|
+
entry.unsubscribe?.();
|
|
3332
|
+
this.compares.delete(symbol);
|
|
3333
|
+
this.comparesChanged();
|
|
3334
|
+
}
|
|
3335
|
+
getCompares() {
|
|
3336
|
+
return [...this.compares.values()].map((e) => ({
|
|
3337
|
+
symbol: e.symbol,
|
|
3338
|
+
name: e.info?.name ?? e.symbol,
|
|
3339
|
+
color: e.color,
|
|
3340
|
+
ready: e.store.length > 0
|
|
3341
|
+
}));
|
|
3342
|
+
}
|
|
3343
|
+
/** Each compared symbol's % change from the first visible bar, at `index` (default: the last bar). */
|
|
3344
|
+
compareLegend(index = this.store.length - 1) {
|
|
3345
|
+
return this.getCompares().map((info) => {
|
|
3346
|
+
const values = this.alignedCompare(this.compares.get(info.symbol));
|
|
3347
|
+
const base = firstFinite(values, this.percentBaseIndex);
|
|
3348
|
+
const v = values[index];
|
|
3349
|
+
return { ...info, value: v === void 0 || Number.isNaN(v) || !base ? "\u2014" : formatPercent((v / base - 1) * 100, 2) };
|
|
3350
|
+
});
|
|
3351
|
+
}
|
|
3352
|
+
comparesChanged() {
|
|
3353
|
+
this.compareVersion++;
|
|
3354
|
+
this.invalidate();
|
|
3355
|
+
this.emitter.emit("comparesChange", this.getCompares());
|
|
3356
|
+
this.stateChanged();
|
|
3357
|
+
}
|
|
3358
|
+
/** (Re)loads a compared symbol's bars at the current resolution, back to the main series' first bar. */
|
|
3359
|
+
async reloadCompare(entry) {
|
|
3360
|
+
const feed = this.datafeed;
|
|
3361
|
+
const resolution = this.resolution;
|
|
3362
|
+
if (!feed || !resolution) return;
|
|
3363
|
+
const token = this.loadToken;
|
|
3364
|
+
const current = () => this.compares.get(entry.symbol) === entry && token === this.loadToken;
|
|
3365
|
+
entry.unsubscribe?.();
|
|
3366
|
+
entry.unsubscribe = null;
|
|
3367
|
+
try {
|
|
3368
|
+
const info = entry.info ?? await feed.resolveSymbol(entry.symbol);
|
|
3369
|
+
if (!current()) return;
|
|
3370
|
+
entry.info = info;
|
|
3371
|
+
const to = Date.now();
|
|
3372
|
+
const from = this.store.first()?.time ?? to - resolutionToMs(resolution) * this.historyBatchSize;
|
|
3373
|
+
const result = await feed.getBars(info, resolution, {
|
|
3374
|
+
from,
|
|
3375
|
+
to,
|
|
3376
|
+
countBack: Math.max(this.historyBatchSize, this.store.length + 1)
|
|
3377
|
+
});
|
|
3378
|
+
if (!current()) return;
|
|
3379
|
+
entry.store.set(result.bars);
|
|
3380
|
+
entry.noMoreOlder = result.noMoreData;
|
|
3381
|
+
entry.unsubscribe = feed.subscribeBars(
|
|
3382
|
+
info,
|
|
3383
|
+
resolution,
|
|
3384
|
+
(bar) => {
|
|
3385
|
+
if (!current()) return;
|
|
3386
|
+
entry.store.upsert(bar);
|
|
3387
|
+
this.compareVersion++;
|
|
3388
|
+
this.invalidate();
|
|
3389
|
+
},
|
|
3390
|
+
() => {
|
|
3391
|
+
}
|
|
3392
|
+
);
|
|
3393
|
+
this.comparesChanged();
|
|
3394
|
+
void this.loadOlderCompare(entry);
|
|
3395
|
+
} catch (error) {
|
|
3396
|
+
if (!current()) return;
|
|
3397
|
+
this.removeCompare(entry.symbol);
|
|
3398
|
+
this.emitter.emit("error", error);
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
3401
|
+
/** Pages a compared symbol's history back until it covers the main series' first bar. */
|
|
3402
|
+
async loadOlderCompare(entry) {
|
|
3403
|
+
const feed = this.datafeed;
|
|
3404
|
+
const first = entry.store.first();
|
|
3405
|
+
const mainFirst = this.store.first();
|
|
3406
|
+
if (!feed || !entry.info || !this.resolution || !first || !mainFirst) return;
|
|
3407
|
+
if (entry.loadingOlder || entry.noMoreOlder || first.time <= mainFirst.time) return;
|
|
3408
|
+
const token = this.loadToken;
|
|
3409
|
+
entry.loadingOlder = true;
|
|
3410
|
+
try {
|
|
3411
|
+
const to = first.time - 1;
|
|
3412
|
+
const result = await feed.getBars(entry.info, this.resolution, { from: mainFirst.time, to, countBack: this.historyBatchSize });
|
|
3413
|
+
if (this.compares.get(entry.symbol) !== entry || token !== this.loadToken) return;
|
|
3414
|
+
entry.store.prepend(result.bars);
|
|
3415
|
+
entry.noMoreOlder = result.noMoreData || result.bars.length === 0;
|
|
3416
|
+
this.compareVersion++;
|
|
3417
|
+
this.invalidate();
|
|
3418
|
+
} catch (error) {
|
|
3419
|
+
this.emitter.emit("error", error);
|
|
3420
|
+
} finally {
|
|
3421
|
+
entry.loadingOlder = false;
|
|
3422
|
+
}
|
|
3423
|
+
}
|
|
3424
|
+
/** Compare closes at each main bar (the compare bar at or before its time). */
|
|
3425
|
+
alignedCompare(entry) {
|
|
3426
|
+
const key = `${this.dataVersion}:${this.compareVersion}`;
|
|
3427
|
+
if (entry.aligned?.key === key) return entry.aligned.values;
|
|
3428
|
+
const bars = this.store.bars;
|
|
3429
|
+
const other = entry.store.bars;
|
|
3430
|
+
const values = new Float64Array(bars.length).fill(Number.NaN);
|
|
3431
|
+
let j = -1;
|
|
3432
|
+
for (let i = 0; i < bars.length; i++) {
|
|
3433
|
+
const t = bars[i].time;
|
|
3434
|
+
while (j + 1 < other.length && other[j + 1].time <= t) j++;
|
|
3435
|
+
if (j >= 0) values[i] = other[j].close;
|
|
3436
|
+
}
|
|
3437
|
+
entry.aligned = { key, values };
|
|
3438
|
+
return values;
|
|
3439
|
+
}
|
|
3440
|
+
/** Compare lines in main-price space, each rebased so its first visible value sits on the main close there. */
|
|
3441
|
+
compareOverlays(baseIndex) {
|
|
3442
|
+
const mainBase = this.store.bars[baseIndex]?.close;
|
|
3443
|
+
if (mainBase === void 0) return [];
|
|
3444
|
+
const out = [];
|
|
3445
|
+
for (const entry of this.compares.values()) {
|
|
3446
|
+
if (entry.store.length === 0) continue;
|
|
3447
|
+
const aligned = this.alignedCompare(entry);
|
|
3448
|
+
const base = firstFinite(aligned, baseIndex);
|
|
3449
|
+
if (!base) continue;
|
|
3450
|
+
out.push({
|
|
3451
|
+
series: [
|
|
3452
|
+
{
|
|
3453
|
+
key: entry.symbol,
|
|
3454
|
+
label: entry.info?.name ?? entry.symbol,
|
|
3455
|
+
values: aligned.map((v) => mainBase * v / base),
|
|
3456
|
+
color: entry.color,
|
|
3457
|
+
width: 2,
|
|
3458
|
+
visible: true,
|
|
3459
|
+
kind: "line"
|
|
3460
|
+
}
|
|
3461
|
+
],
|
|
3462
|
+
precision: null
|
|
3463
|
+
});
|
|
3464
|
+
}
|
|
3465
|
+
return out;
|
|
3466
|
+
}
|
|
3467
|
+
// ── Replay ─────────────────────────────────────────────────────────
|
|
3468
|
+
get replay() {
|
|
3469
|
+
const r = this.replayState;
|
|
3470
|
+
return {
|
|
3471
|
+
selecting: this.replaySelecting,
|
|
3472
|
+
active: r !== null,
|
|
3473
|
+
playing: r?.timer != null,
|
|
3474
|
+
remaining: r?.future.length ?? 0,
|
|
3475
|
+
interval: r?.interval ?? 1e3
|
|
3476
|
+
};
|
|
3477
|
+
}
|
|
3478
|
+
/** Arms start picking: the next click on the chart starts a replay from that bar. Esc cancels. */
|
|
3479
|
+
setReplaySelecting(on) {
|
|
3480
|
+
if (on === this.replaySelecting) return;
|
|
3481
|
+
if (on && this.drawings.tool) this.drawings.setTool(null);
|
|
3482
|
+
this.replaySelecting = on;
|
|
3483
|
+
this.invalidate(false);
|
|
3484
|
+
this.emitReplay();
|
|
3485
|
+
}
|
|
3486
|
+
/** Hides every bar after `time` (UTC ms); reveal them with replayStep/replayPlay. */
|
|
3487
|
+
startReplay(time) {
|
|
3488
|
+
const all = this.replayState ? [...this.store.bars, ...this.replayState.future] : [...this.store.bars];
|
|
3489
|
+
let cut = -1;
|
|
3490
|
+
while (cut + 1 < all.length && all[cut + 1].time <= time) cut++;
|
|
3491
|
+
cut = Math.max(0, cut);
|
|
3492
|
+
if (cut >= all.length - 1) return;
|
|
3493
|
+
const interval = this.replayState?.interval ?? 1e3;
|
|
3494
|
+
this.stopReplayTimer();
|
|
3495
|
+
this.replaySelecting = false;
|
|
3496
|
+
this.replayState = { future: all.slice(cut + 1), timer: null, interval };
|
|
3497
|
+
this.store.set(all.slice(0, cut + 1));
|
|
3498
|
+
this.timeScale.barCount = this.store.length;
|
|
3499
|
+
this.timeScale.rightOffset = Math.max(this.timeScale.options.rightOffset, 10);
|
|
3500
|
+
this.timeScale.clamp();
|
|
3501
|
+
this.mainScale.autoScale = true;
|
|
3502
|
+
this.dataVersion++;
|
|
3503
|
+
this.invalidate();
|
|
3504
|
+
this.emitReplay();
|
|
3505
|
+
}
|
|
3506
|
+
/** Reveals the next `count` hidden bars. */
|
|
3507
|
+
replayStep(count = 1) {
|
|
3508
|
+
const r = this.replayState;
|
|
3509
|
+
if (!r) return;
|
|
3510
|
+
for (let k = 0; k < count && r.future.length > 0; k++) {
|
|
3511
|
+
this.store.upsert(r.future.shift());
|
|
3512
|
+
if (this.timeScale.rightOffset < 0) this.timeScale.rightOffset -= 1;
|
|
3513
|
+
}
|
|
3514
|
+
this.timeScale.barCount = this.store.length;
|
|
3515
|
+
this.dataVersion++;
|
|
3516
|
+
if (r.future.length === 0) this.stopReplayTimer();
|
|
3517
|
+
this.invalidate();
|
|
3518
|
+
this.emitReplay();
|
|
3519
|
+
}
|
|
3520
|
+
/** Plays one bar every `interval` ms (default: the last speed used, initially 1000). */
|
|
3521
|
+
replayPlay(interval) {
|
|
3522
|
+
const r = this.replayState;
|
|
3523
|
+
if (!r || r.future.length === 0) return;
|
|
3524
|
+
if (interval !== void 0) r.interval = Math.max(16, interval);
|
|
3525
|
+
if (r.timer) clearInterval(r.timer);
|
|
3526
|
+
r.timer = setInterval(() => this.replayStep(), r.interval);
|
|
3527
|
+
this.emitReplay();
|
|
3528
|
+
}
|
|
3529
|
+
/** Changes the playback speed (ms per bar), also while playing. */
|
|
3530
|
+
setReplayInterval(interval) {
|
|
3531
|
+
const r = this.replayState;
|
|
3532
|
+
if (!r) return;
|
|
3533
|
+
r.interval = Math.max(16, interval);
|
|
3534
|
+
if (r.timer) {
|
|
3535
|
+
clearInterval(r.timer);
|
|
3536
|
+
r.timer = setInterval(() => this.replayStep(), r.interval);
|
|
3537
|
+
}
|
|
3538
|
+
this.emitReplay();
|
|
3539
|
+
}
|
|
3540
|
+
replayPause() {
|
|
3541
|
+
this.stopReplayTimer();
|
|
3542
|
+
this.emitReplay();
|
|
3543
|
+
}
|
|
3544
|
+
/** Leaves replay and shows every bar again (including live ones that arrived meanwhile). */
|
|
3545
|
+
stopReplay() {
|
|
3546
|
+
this.endReplay(true);
|
|
3547
|
+
}
|
|
3548
|
+
endReplay(restore) {
|
|
3549
|
+
const r = this.replayState;
|
|
3550
|
+
const wasSelecting = this.replaySelecting;
|
|
3551
|
+
this.replaySelecting = false;
|
|
3552
|
+
if (!r) {
|
|
3553
|
+
if (wasSelecting) {
|
|
3554
|
+
this.invalidate(false);
|
|
3555
|
+
this.emitReplay();
|
|
3556
|
+
}
|
|
3557
|
+
return;
|
|
3558
|
+
}
|
|
3559
|
+
this.stopReplayTimer();
|
|
3560
|
+
this.replayState = null;
|
|
3561
|
+
if (restore) {
|
|
3562
|
+
for (const bar of r.future) this.store.upsert(bar);
|
|
3563
|
+
this.timeScale.barCount = this.store.length;
|
|
3564
|
+
this.timeScale.rightOffset = this.timeScale.options.rightOffset;
|
|
3565
|
+
this.timeScale.clamp();
|
|
3566
|
+
this.dataVersion++;
|
|
3567
|
+
this.invalidate();
|
|
3568
|
+
}
|
|
3569
|
+
this.emitReplay();
|
|
3570
|
+
}
|
|
3571
|
+
stopReplayTimer() {
|
|
3572
|
+
const r = this.replayState;
|
|
3573
|
+
if (r?.timer) clearInterval(r.timer);
|
|
3574
|
+
if (r) r.timer = null;
|
|
3575
|
+
}
|
|
3576
|
+
emitReplay() {
|
|
3577
|
+
this.emitter.emit("replayChange", this.replay);
|
|
2898
3578
|
}
|
|
2899
3579
|
indicatorScale(id) {
|
|
2900
3580
|
let scale = this.indicatorScales.get(id);
|
|
@@ -2919,6 +3599,10 @@ var Chart = class {
|
|
|
2919
3599
|
addOrderLine(options) {
|
|
2920
3600
|
return this.trading.addOrder(options);
|
|
2921
3601
|
}
|
|
3602
|
+
/** Price alert line (dotted, gray, "알림" label). Pass `onMove` to make it draggable and `onCancel` for an ×. */
|
|
3603
|
+
addAlertLine(options) {
|
|
3604
|
+
return this.trading.addAlert(options);
|
|
3605
|
+
}
|
|
2922
3606
|
/** Fill markers (▲ buy under the bar, ▼ sell above). Replaces the previous set. */
|
|
2923
3607
|
setExecutions(markers) {
|
|
2924
3608
|
this.trading.setExecutions(markers);
|
|
@@ -2976,6 +3660,8 @@ var Chart = class {
|
|
|
2976
3660
|
this.loadToken++;
|
|
2977
3661
|
this.unsubscribeBars?.();
|
|
2978
3662
|
this.unsubscribeQuotes?.();
|
|
3663
|
+
for (const entry of this.compares.values()) entry.unsubscribe?.();
|
|
3664
|
+
this.stopReplayTimer();
|
|
2979
3665
|
this.tradingSync.detach();
|
|
2980
3666
|
if (this.rafId) cancelAnimationFrame(this.rafId);
|
|
2981
3667
|
this.interaction.destroy();
|
|
@@ -3013,6 +3699,7 @@ var Chart = class {
|
|
|
3013
3699
|
formatCrosshairTime: (t) => this.formatter.crosshairLabel(t, this.isIntraday()),
|
|
3014
3700
|
labels: LABELS[this.locale],
|
|
3015
3701
|
chartType: this.chartType,
|
|
3702
|
+
replaySelecting: this.replaySelecting,
|
|
3016
3703
|
objects: {
|
|
3017
3704
|
coords: this.paneCoords(),
|
|
3018
3705
|
drawings: this.drawings,
|
|
@@ -3038,6 +3725,7 @@ var Chart = class {
|
|
|
3038
3725
|
plotWidth: ts.width,
|
|
3039
3726
|
height: scale.height,
|
|
3040
3727
|
timeToX: (time) => ts.indexToX(this.store.indexFloatOfTime(time, interval)),
|
|
3728
|
+
timeToIndex: (time) => this.store.indexFloatOfTime(time, interval),
|
|
3041
3729
|
pointAt: (x, y) => {
|
|
3042
3730
|
const index = Math.round(ts.xToIndexFloat(x));
|
|
3043
3731
|
const bar = this.magnet ? this.store.at(index) : void 0;
|
|
@@ -3050,7 +3738,8 @@ var Chart = class {
|
|
|
3050
3738
|
priceToY: (p) => scale.priceToY(p),
|
|
3051
3739
|
yToPrice: (y) => scale.yToPrice(y),
|
|
3052
3740
|
snapPrice: (p) => roundToTick(p, this.tickSize),
|
|
3053
|
-
|
|
3741
|
+
// Drawing and order badges follow the axis (a % axis labels them in %).
|
|
3742
|
+
formatPrice: this.mainScale.mode === "percent" ? this.mainValueFormat() : (p) => formatPrice(p, this.pricePrecision)
|
|
3054
3743
|
};
|
|
3055
3744
|
}
|
|
3056
3745
|
computeFrame() {
|
|
@@ -3059,11 +3748,16 @@ var Chart = class {
|
|
|
3059
3748
|
const panesHeight = Math.max(0, height - TIME_AXIS_HEIGHT);
|
|
3060
3749
|
this.timeScale.barCount = this.store.length;
|
|
3061
3750
|
const indicatorPanes = this.indicators.panes;
|
|
3062
|
-
const
|
|
3063
|
-
const
|
|
3064
|
-
|
|
3065
|
-
const
|
|
3066
|
-
const
|
|
3751
|
+
const weightOf = (id, kind) => this.paneWeights.get(id) ?? PANE_WEIGHT[kind];
|
|
3752
|
+
const weights = [["main", weightOf("main", "main")]];
|
|
3753
|
+
if (this.settingsValue.volume) weights.push(["volume", weightOf("volume", "volume")]);
|
|
3754
|
+
for (const c of indicatorPanes) weights.push([c.id, weightOf(c.id, "indicator")]);
|
|
3755
|
+
const totalWeight = weights.reduce((sum, [, w]) => sum + w, 0);
|
|
3756
|
+
this.paneWeightTotal = totalWeight;
|
|
3757
|
+
const heightOf = new Map(weights.map(([id, w]) => [id, Math.max(MIN_PANE_HEIGHT, Math.round(panesHeight * w / totalWeight))]));
|
|
3758
|
+
const volumeHeight = heightOf.get("volume") ?? 0;
|
|
3759
|
+
const indicatorHeights = indicatorPanes.map((c) => heightOf.get(c.id));
|
|
3760
|
+
const mainHeight = Math.max(MIN_PANE_HEIGHT, panesHeight - volumeHeight - indicatorHeights.reduce((a, b) => a + b, 0));
|
|
3067
3761
|
this.mainScale.height = mainHeight;
|
|
3068
3762
|
this.volumeScale.height = volumeHeight;
|
|
3069
3763
|
const drawable = (config) => {
|
|
@@ -3074,9 +3768,16 @@ var Chart = class {
|
|
|
3074
3768
|
return fill && shown(fill.upper) && shown(fill.lower) ? { ...out, series, fill } : { ...out, series };
|
|
3075
3769
|
};
|
|
3076
3770
|
const overlays = this.indicators.overlays.map((config) => ({ config, output: drawable(config) }));
|
|
3771
|
+
const mode = this.compares.size > 0 ? "percent" : this.settingsValue.priceScale;
|
|
3772
|
+
if (this.mainScale.mode !== mode) {
|
|
3773
|
+
this.mainScale.mode = mode;
|
|
3774
|
+
this.mainScale.autoScale = true;
|
|
3775
|
+
}
|
|
3776
|
+
let compareOverlays = [];
|
|
3777
|
+
this.countdownLabel = this.countdownText();
|
|
3077
3778
|
const subPanes = indicatorPanes.map((config) => {
|
|
3078
3779
|
const scale = this.indicatorScale(config.id);
|
|
3079
|
-
scale.height =
|
|
3780
|
+
scale.height = heightOf.get(config.id);
|
|
3080
3781
|
return { config, scale, output: drawable(config) };
|
|
3081
3782
|
});
|
|
3082
3783
|
const indicatorFormat = (output) => {
|
|
@@ -3100,23 +3801,22 @@ var Chart = class {
|
|
|
3100
3801
|
this.timeScale.width = Math.max(0, width - this.priceAxisWidth);
|
|
3101
3802
|
this.timeScale.clamp();
|
|
3102
3803
|
range = this.timeScale.visibleRange();
|
|
3103
|
-
|
|
3804
|
+
this.percentBaseIndex = Math.max(0, range.from);
|
|
3805
|
+
compareOverlays = this.compareOverlays(this.percentBaseIndex);
|
|
3806
|
+
highLow = this.autoScale(range, [...overlays.map((o) => o.output), ...compareOverlays]);
|
|
3104
3807
|
for (const pane of subPanes) this.autoScaleIndicator(pane.scale, pane.output, range);
|
|
3105
|
-
|
|
3106
|
-
priceTicks = niceTicks(
|
|
3107
|
-
this.mainScale.yToPrice(mainHeight),
|
|
3108
|
-
this.mainScale.yToPrice(0),
|
|
3109
|
-
Math.max(2, Math.floor(mainHeight / PRICE_TICK_SPACING)),
|
|
3110
|
-
resolveTickSize(this.tickSize, mid)
|
|
3111
|
-
);
|
|
3808
|
+
priceTicks = this.mainTicks(mainHeight);
|
|
3112
3809
|
subTicks = subPanes.map((p) => {
|
|
3113
|
-
const [lo, hi] = p.output.range ?? [p.scale.yToPrice(
|
|
3114
|
-
return niceTicks(lo, hi, Math.max(1, Math.floor(
|
|
3810
|
+
const [lo, hi] = p.output.range ?? [p.scale.yToPrice(p.scale.height), p.scale.yToPrice(0)];
|
|
3811
|
+
return niceTicks(lo, hi, Math.max(1, Math.floor(p.scale.height / 36)));
|
|
3115
3812
|
});
|
|
3116
3813
|
let labelWidth = 0;
|
|
3117
3814
|
const measure = (text) => labelWidth = Math.max(labelWidth, ctx.measureText(text).width);
|
|
3118
3815
|
const last = this.store.last();
|
|
3119
|
-
|
|
3816
|
+
const tickLabel = this.mainTickFormat(priceTicks);
|
|
3817
|
+
for (const v of priceTicks) measure(tickLabel(v));
|
|
3818
|
+
if (last) measure(this.mainValueFormat()(last.close));
|
|
3819
|
+
if (this.countdownLabel) measure(this.countdownLabel);
|
|
3120
3820
|
subPanes.forEach((p, i) => subTicks[i].forEach((v) => measure(tickFormat(subTicks[i], p.output)(v))));
|
|
3121
3821
|
const needed = Math.max(MIN_PRICE_AXIS_WIDTH, Math.ceil((labelWidth + 20) / 8) * 8);
|
|
3122
3822
|
if (needed === this.priceAxisWidth) break;
|
|
@@ -3131,7 +3831,8 @@ var Chart = class {
|
|
|
3131
3831
|
height: mainHeight,
|
|
3132
3832
|
scale: this.mainScale,
|
|
3133
3833
|
ticks: priceTicks,
|
|
3134
|
-
format:
|
|
3834
|
+
format: this.mainValueFormat(),
|
|
3835
|
+
tickFormat: this.mainTickFormat(priceTicks)
|
|
3135
3836
|
}
|
|
3136
3837
|
];
|
|
3137
3838
|
let top = mainHeight;
|
|
@@ -3152,14 +3853,14 @@ var Chart = class {
|
|
|
3152
3853
|
id: p.config.id,
|
|
3153
3854
|
kind: "indicator",
|
|
3154
3855
|
top,
|
|
3155
|
-
height:
|
|
3856
|
+
height: p.scale.height,
|
|
3156
3857
|
scale: p.scale,
|
|
3157
3858
|
ticks: subTicks[i],
|
|
3158
3859
|
format: indicatorFormat(p.output),
|
|
3159
3860
|
tickFormat: tickFormat(subTicks[i], p.output),
|
|
3160
3861
|
indicator: p.output
|
|
3161
3862
|
});
|
|
3162
|
-
top +=
|
|
3863
|
+
top += p.scale.height;
|
|
3163
3864
|
});
|
|
3164
3865
|
return {
|
|
3165
3866
|
width,
|
|
@@ -3173,12 +3874,63 @@ var Chart = class {
|
|
|
3173
3874
|
timeTicks: computeTimeTicks(this.store.bars, this.timeScale, this.formatter, TIME_LABEL_MIN_GAP),
|
|
3174
3875
|
highLow,
|
|
3175
3876
|
settings: this.settingsValue,
|
|
3176
|
-
overlays: overlays.map((o) => o.output),
|
|
3877
|
+
overlays: [...overlays.map((o) => o.output), ...compareOverlays],
|
|
3177
3878
|
palette: this.resolvePalette(),
|
|
3178
3879
|
font,
|
|
3179
|
-
radius: this.theme.radius
|
|
3880
|
+
radius: this.theme.radius,
|
|
3881
|
+
countdown: this.countdownLabel
|
|
3180
3882
|
};
|
|
3181
3883
|
}
|
|
3884
|
+
/** Price-pane tick values for the current axis mode. */
|
|
3885
|
+
mainTicks(height) {
|
|
3886
|
+
const s = this.mainScale;
|
|
3887
|
+
const lo = s.yToPrice(height);
|
|
3888
|
+
const hi = s.yToPrice(0);
|
|
3889
|
+
const count = Math.max(2, Math.floor(height / PRICE_TICK_SPACING));
|
|
3890
|
+
const minStep = resolveTickSize(this.tickSize, (lo + hi) / 2);
|
|
3891
|
+
if (s.mode === "log") return logTicks(s, PRICE_TICK_SPACING, minStep);
|
|
3892
|
+
if (s.mode === "percent") {
|
|
3893
|
+
const base = this.percentBase();
|
|
3894
|
+
const pct = (p) => (p / base - 1) * 100;
|
|
3895
|
+
return niceTicks(pct(lo), pct(hi), count).map((t) => base * (1 + t / 100));
|
|
3896
|
+
}
|
|
3897
|
+
return niceTicks(lo, hi, count, minStep);
|
|
3898
|
+
}
|
|
3899
|
+
percentBase() {
|
|
3900
|
+
return this.store.at(this.percentBaseIndex)?.close || 1;
|
|
3901
|
+
}
|
|
3902
|
+
/** Price-axis tick labels: prices, or "+2.5%" on a % axis (decimals follow the step). */
|
|
3903
|
+
mainTickFormat(ticks) {
|
|
3904
|
+
if (this.mainScale.mode !== "percent") return (v) => formatPrice(v, this.pricePrecision);
|
|
3905
|
+
const base = this.percentBase();
|
|
3906
|
+
const step = ticks.length > 1 ? Math.abs(ticks[1] - ticks[0]) / base * 100 : 1;
|
|
3907
|
+
const decimals = Math.min(2, decimalsOf(Number(step.toPrecision(6))));
|
|
3908
|
+
return (v) => formatPercent((v / base - 1) * 100, decimals);
|
|
3909
|
+
}
|
|
3910
|
+
/** Crosshair and last-price label of the price pane. */
|
|
3911
|
+
mainValueFormat() {
|
|
3912
|
+
if (this.mainScale.mode !== "percent") return (v) => formatPrice(roundToTick(v, this.tickSize), this.pricePrecision);
|
|
3913
|
+
const base = this.percentBase();
|
|
3914
|
+
return (v) => formatPercent((v / base - 1) * 100, 2);
|
|
3915
|
+
}
|
|
3916
|
+
/** Time left in the last bar, when it's still forming (null otherwise). */
|
|
3917
|
+
countdownText(now = Date.now()) {
|
|
3918
|
+
const s = this.settingsValue;
|
|
3919
|
+
const last = this.store.last();
|
|
3920
|
+
const info = this.symbol;
|
|
3921
|
+
if (!s.countdown || !s.lastPriceLine || this.replayState || !this.resolution || !info || !last) return null;
|
|
3922
|
+
let end = null;
|
|
3923
|
+
if (isIntradayResolution(this.resolution)) {
|
|
3924
|
+
end = last.time + resolutionToMs(this.resolution);
|
|
3925
|
+
} else if (resolutionToMs(this.resolution) === DAY_MS) {
|
|
3926
|
+
if (info.session.trim().toLowerCase() === "24x7") end = last.time + DAY_MS;
|
|
3927
|
+
else {
|
|
3928
|
+
const status = sessionStatus(info.session, info.timezone, now);
|
|
3929
|
+
end = status.open && now - last.time < DAY_MS ? status.nextChange : null;
|
|
3930
|
+
}
|
|
3931
|
+
}
|
|
3932
|
+
return end !== null && end > now ? formatCountdown(end - now) : null;
|
|
3933
|
+
}
|
|
3182
3934
|
/** Auto-scales the price pane to visible candles and overlay indicators; returns the candle extremes. */
|
|
3183
3935
|
autoScale(range, overlays) {
|
|
3184
3936
|
const bars = this.store.bars;
|
|
@@ -3204,8 +3956,9 @@ var Chart = class {
|
|
|
3204
3956
|
let max = high;
|
|
3205
3957
|
for (const output of overlays) {
|
|
3206
3958
|
for (const series of output.series) {
|
|
3959
|
+
const shift = series.shift ?? 0;
|
|
3207
3960
|
for (let i = range.from; i <= range.to; i++) {
|
|
3208
|
-
const v = series.values[i];
|
|
3961
|
+
const v = series.values[i - shift];
|
|
3209
3962
|
if (v < min) min = v;
|
|
3210
3963
|
if (v > max) max = v;
|
|
3211
3964
|
}
|
|
@@ -3229,8 +3982,9 @@ var Chart = class {
|
|
|
3229
3982
|
min = Math.min(min, 0);
|
|
3230
3983
|
max = Math.max(max, 0);
|
|
3231
3984
|
}
|
|
3985
|
+
const shift = series.shift ?? 0;
|
|
3232
3986
|
for (let i = range.from; i <= range.to; i++) {
|
|
3233
|
-
const v = series.values[i];
|
|
3987
|
+
const v = series.values[i - shift];
|
|
3234
3988
|
if (v < min) min = v;
|
|
3235
3989
|
if (v > max) max = v;
|
|
3236
3990
|
}
|
|
@@ -3300,6 +4054,7 @@ var Chart = class {
|
|
|
3300
4054
|
this.prependData(result.bars, { noMoreData: result.noMoreData || result.bars.length === 0 });
|
|
3301
4055
|
const first = this.store.first();
|
|
3302
4056
|
if (first) void this.tradingSync.loadOlder(first.time);
|
|
4057
|
+
for (const entry of this.compares.values()) void this.loadOlderCompare(entry);
|
|
3303
4058
|
}).catch((error) => this.emitter.emit("error", error)).finally(() => {
|
|
3304
4059
|
if (token === this.loadToken) this.loadingHistory = false;
|
|
3305
4060
|
});
|
|
@@ -3371,6 +4126,8 @@ var Chart = class {
|
|
|
3371
4126
|
const f = this.frame;
|
|
3372
4127
|
if (!f) return { area: "none", pane: null };
|
|
3373
4128
|
if (y >= f.panesHeight) return { area: x < f.plotWidth ? "timeAxis" : "none", pane: null };
|
|
4129
|
+
const border = f.panes.findIndex((p, i) => i > 0 && Math.abs(y - p.top) <= PANE_BORDER_GRAB);
|
|
4130
|
+
if (border > 0) return { area: "paneBorder", pane: f.panes[border].id, border };
|
|
3374
4131
|
return { area: x >= f.plotWidth ? "priceAxis" : "plot", pane: this.paneAt(f, y).id };
|
|
3375
4132
|
},
|
|
3376
4133
|
plotWidth: () => this.timeScale.width,
|
|
@@ -3406,15 +4163,43 @@ var Chart = class {
|
|
|
3406
4163
|
this.invalidate();
|
|
3407
4164
|
},
|
|
3408
4165
|
resetView: () => this.resetView(),
|
|
3409
|
-
|
|
4166
|
+
resizePane: (index, dy) => this.resizePane(index, dy),
|
|
4167
|
+
contextMenu: (x, y) => {
|
|
4168
|
+
const f = this.frame;
|
|
4169
|
+
if (!this.emitter.has("contextMenu") || !f || x > f.plotWidth || y > f.panesHeight) return false;
|
|
4170
|
+
const pane = this.paneAt(f, y);
|
|
4171
|
+
const index = this.timeScale.xToIndex(x);
|
|
4172
|
+
const bar = this.store.at(index);
|
|
4173
|
+
this.emitter.emit("contextMenu", {
|
|
4174
|
+
x,
|
|
4175
|
+
y,
|
|
4176
|
+
pane: pane.id,
|
|
4177
|
+
price: pane.kind === "main" ? roundToTick(this.mainScale.yToPrice(y), this.tickSize) : null,
|
|
4178
|
+
time: bar?.time ?? null
|
|
4179
|
+
});
|
|
4180
|
+
return true;
|
|
4181
|
+
},
|
|
4182
|
+
resetPaneSizes: () => this.resetPaneSizes(),
|
|
4183
|
+
toolActive: () => (this.drawings.tool !== null || this.replaySelecting) && this.store.length > 0,
|
|
3410
4184
|
hitObject: (x, y, touch) => {
|
|
3411
4185
|
if (y > this.mainScale.height) return null;
|
|
3412
4186
|
const tolerance = touch ? HIT_TOLERANCE.touch : HIT_TOLERANCE.mouse;
|
|
3413
4187
|
return this.trading.hitTest((p) => this.mainScale.priceToY(p), x, y, tolerance) ?? this.drawings.hitTest(this.paneCoords(), x, y, tolerance);
|
|
3414
4188
|
},
|
|
3415
|
-
drawAt: (x, y) =>
|
|
3416
|
-
|
|
3417
|
-
|
|
4189
|
+
drawAt: (x, y) => {
|
|
4190
|
+
if (this.replaySelecting) {
|
|
4191
|
+
const bars = this.store.bars;
|
|
4192
|
+
this.startReplay(bars[Math.min(bars.length - 1, Math.max(0, this.timeScale.xToIndex(x)))].time);
|
|
4193
|
+
return;
|
|
4194
|
+
}
|
|
4195
|
+
this.drawings.createAt(this.paneCoords(), x, y);
|
|
4196
|
+
},
|
|
4197
|
+
drawPreview: (x, y) => {
|
|
4198
|
+
if (!this.replaySelecting) this.drawings.previewAt(this.paneCoords(), x, y);
|
|
4199
|
+
},
|
|
4200
|
+
drawRelease: (x, y, moved) => {
|
|
4201
|
+
if (!this.replaySelecting) this.drawings.releaseAt(this.paneCoords(), x, y, moved);
|
|
4202
|
+
},
|
|
3418
4203
|
objectDown: (hit, x, y) => {
|
|
3419
4204
|
this.objectDrag = hit;
|
|
3420
4205
|
if (hit.kind === "drawing") this.drawings.beginDrag(this.paneCoords(), hit.id, hit.part, x, y);
|
|
@@ -3449,7 +4234,9 @@ var Chart = class {
|
|
|
3449
4234
|
const onKeyDown = (e) => {
|
|
3450
4235
|
const target = e.target;
|
|
3451
4236
|
if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
|
|
3452
|
-
if (e.key === "Escape" &&
|
|
4237
|
+
if (e.key === "Escape" && this.replaySelecting) {
|
|
4238
|
+
this.setReplaySelecting(false);
|
|
4239
|
+
} else if (e.key === "Escape" && (this.drawings.tool || this.drawings.selectedId)) {
|
|
3453
4240
|
this.drawings.cancel();
|
|
3454
4241
|
} else if ((e.key === "Delete" || e.key === "Backspace") && this.drawings.selectedId) {
|
|
3455
4242
|
e.preventDefault();
|
|
@@ -3460,6 +4247,14 @@ var Chart = class {
|
|
|
3460
4247
|
this.cleanups.push(() => document.removeEventListener("keydown", onKeyDown));
|
|
3461
4248
|
}
|
|
3462
4249
|
// ── Environment observers ──────────────────────────────────────────
|
|
4250
|
+
/** Repaints when the bar-close countdown ticks over (once a second, only while it shows or changes). */
|
|
4251
|
+
observeCountdown() {
|
|
4252
|
+
const timer = setInterval(() => {
|
|
4253
|
+
if (this.destroyed || document.hidden) return;
|
|
4254
|
+
if (this.countdownText() !== this.countdownLabel) this.invalidate();
|
|
4255
|
+
}, 1e3);
|
|
4256
|
+
this.cleanups.push(() => clearInterval(timer));
|
|
4257
|
+
}
|
|
3463
4258
|
observeSize() {
|
|
3464
4259
|
const apply = (width, height) => {
|
|
3465
4260
|
const dpr = window.devicePixelRatio || 1;
|
|
@@ -3510,103 +4305,6 @@ var Chart = class {
|
|
|
3510
4305
|
}
|
|
3511
4306
|
};
|
|
3512
4307
|
|
|
3513
|
-
// src/realtime.ts
|
|
3514
|
-
var offsetFormatters = /* @__PURE__ */ new Map();
|
|
3515
|
-
function timeZoneOffset(time, timeZone) {
|
|
3516
|
-
if (timeZone === "UTC") return 0;
|
|
3517
|
-
let dtf = offsetFormatters.get(timeZone);
|
|
3518
|
-
if (!dtf) {
|
|
3519
|
-
dtf = new Intl.DateTimeFormat("en-US", {
|
|
3520
|
-
timeZone,
|
|
3521
|
-
hourCycle: "h23",
|
|
3522
|
-
year: "numeric",
|
|
3523
|
-
month: "numeric",
|
|
3524
|
-
day: "numeric",
|
|
3525
|
-
hour: "numeric",
|
|
3526
|
-
minute: "numeric",
|
|
3527
|
-
second: "numeric"
|
|
3528
|
-
});
|
|
3529
|
-
offsetFormatters.set(timeZone, dtf);
|
|
3530
|
-
}
|
|
3531
|
-
const p = {};
|
|
3532
|
-
for (const { type, value } of dtf.formatToParts(time)) p[type] = Number(value);
|
|
3533
|
-
const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour % 24, p.minute, p.second);
|
|
3534
|
-
return Math.round((asUtc - Math.floor(time / 1e3) * 1e3) / 6e4) * 6e4;
|
|
3535
|
-
}
|
|
3536
|
-
function bucketStart(time, resolution, timeZone = "UTC") {
|
|
3537
|
-
const offset = timeZoneOffset(time, timeZone);
|
|
3538
|
-
const local = time + offset;
|
|
3539
|
-
const unit = resolution.trim().slice(-1).toUpperCase();
|
|
3540
|
-
if (unit === "M") {
|
|
3541
|
-
const n = Number(resolution.slice(0, -1)) || 1;
|
|
3542
|
-
const d = new Date(local);
|
|
3543
|
-
const months = d.getUTCFullYear() * 12 + d.getUTCMonth();
|
|
3544
|
-
const start = months - months % n;
|
|
3545
|
-
return Date.UTC(Math.floor(start / 12), start % 12, 1) - offset;
|
|
3546
|
-
}
|
|
3547
|
-
if (unit === "W") {
|
|
3548
|
-
const days = Math.floor(local / DAY_MS);
|
|
3549
|
-
const sinceMonday = ((days + 3) % 7 + 7) % 7;
|
|
3550
|
-
return (days - sinceMonday) * DAY_MS - offset;
|
|
3551
|
-
}
|
|
3552
|
-
const size = resolutionToMs(resolution);
|
|
3553
|
-
return Math.floor(local / size) * size - offset;
|
|
3554
|
-
}
|
|
3555
|
-
var TickAggregator = class {
|
|
3556
|
-
constructor(resolution, timeZone = "UTC") {
|
|
3557
|
-
this.resolution = resolution;
|
|
3558
|
-
this.timeZone = timeZone;
|
|
3559
|
-
}
|
|
3560
|
-
resolution;
|
|
3561
|
-
timeZone;
|
|
3562
|
-
current = null;
|
|
3563
|
-
/** Start from the latest historical bar so the first tick updates it instead of opening a new one. */
|
|
3564
|
-
seed(bar) {
|
|
3565
|
-
this.current = bar ? { ...bar, volume: bar.volume ?? 0 } : null;
|
|
3566
|
-
}
|
|
3567
|
-
/** Returns the updated bar, or null for a tick belonging to an already-closed bar. */
|
|
3568
|
-
push(tick) {
|
|
3569
|
-
const start = bucketStart(tick.time, this.resolution, this.timeZone);
|
|
3570
|
-
const bar = this.current;
|
|
3571
|
-
const volume = tick.volume ?? 0;
|
|
3572
|
-
if (!bar || start > bar.time) {
|
|
3573
|
-
this.current = { time: start, open: tick.price, high: tick.price, low: tick.price, close: tick.price, volume };
|
|
3574
|
-
} else if (start === bar.time) {
|
|
3575
|
-
bar.high = Math.max(bar.high, tick.price);
|
|
3576
|
-
bar.low = Math.min(bar.low, tick.price);
|
|
3577
|
-
bar.close = tick.price;
|
|
3578
|
-
bar.volume = (bar.volume ?? 0) + volume;
|
|
3579
|
-
} else {
|
|
3580
|
-
return null;
|
|
3581
|
-
}
|
|
3582
|
-
return { ...this.current };
|
|
3583
|
-
}
|
|
3584
|
-
};
|
|
3585
|
-
|
|
3586
|
-
// src/session.ts
|
|
3587
|
-
var DAY = 864e5;
|
|
3588
|
-
var MINUTE2 = 6e4;
|
|
3589
|
-
function sessionStatus(session, timeZone, now = Date.now()) {
|
|
3590
|
-
if (session.trim().toLowerCase() === "24x7") return { open: true, nextChange: null };
|
|
3591
|
-
const match = /^(\d{2})(\d{2})-(\d{2})(\d{2})(?::([1-7]+))?$/.exec(session.trim());
|
|
3592
|
-
if (!match) return { open: true, nextChange: null };
|
|
3593
|
-
const openMin = Number(match[1]) * 60 + Number(match[2]);
|
|
3594
|
-
const closeMin = Number(match[3]) * 60 + Number(match[4]);
|
|
3595
|
-
const days = new Set((match[5] ?? "12345").split("").map(Number));
|
|
3596
|
-
const offset = timeZoneOffset(now, timeZone);
|
|
3597
|
-
const localMidnight = Math.floor((now + offset) / DAY) * DAY;
|
|
3598
|
-
for (let d = -1; d <= 8; d++) {
|
|
3599
|
-
const dayStart = localMidnight + d * DAY;
|
|
3600
|
-
const isoWeekday = (new Date(dayStart).getUTCDay() + 6) % 7 + 1;
|
|
3601
|
-
if (!days.has(isoWeekday)) continue;
|
|
3602
|
-
const open = dayStart + openMin * MINUTE2 - offset;
|
|
3603
|
-
const close = dayStart + closeMin * MINUTE2 - offset;
|
|
3604
|
-
if (now < open) return { open: false, nextChange: open };
|
|
3605
|
-
if (now < close) return { open: true, nextChange: close };
|
|
3606
|
-
}
|
|
3607
|
-
return { open: false, nextChange: null };
|
|
3608
|
-
}
|
|
3609
|
-
|
|
3610
4308
|
// src/datafeed/socket.ts
|
|
3611
4309
|
var toConnectionStatus = (status) => ({ connecting: "connecting", open: "connected", reconnecting: "reconnecting", closed: "disconnected" })[status];
|
|
3612
4310
|
var decoder;
|
|
@@ -3798,6 +4496,7 @@ function combineDatafeeds(feeds) {
|
|
|
3798
4496
|
};
|
|
3799
4497
|
}
|
|
3800
4498
|
export {
|
|
4499
|
+
BUILTIN_DRAWINGS,
|
|
3801
4500
|
Chart,
|
|
3802
4501
|
DEFAULT_CHART_SETTINGS,
|
|
3803
4502
|
FIB_LEVELS,
|
|
@@ -3813,6 +4512,7 @@ export {
|
|
|
3813
4512
|
createTheme,
|
|
3814
4513
|
darkTheme,
|
|
3815
4514
|
decimalsOf,
|
|
4515
|
+
distToSegment,
|
|
3816
4516
|
ema,
|
|
3817
4517
|
fallbackResolution,
|
|
3818
4518
|
formatCompact,
|