@funnest/charts 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chart.d.ts +108 -3
- package/dist/index.d.ts +9 -5
- package/dist/index.js +870 -253
- package/dist/indicators/definitions.d.ts +15 -3
- package/dist/indicators/manager.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 +7 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -536,6 +536,7 @@ var PriceScale = class {
|
|
|
536
536
|
}
|
|
537
537
|
margins;
|
|
538
538
|
height = 0;
|
|
539
|
+
mode = "normal";
|
|
539
540
|
/** Pixels reserved at the top for overlays (e.g. a legend); margins apply below it. */
|
|
540
541
|
insetTop = 0;
|
|
541
542
|
autoScale = true;
|
|
@@ -550,6 +551,7 @@ var PriceScale = class {
|
|
|
550
551
|
setRange(min, max) {
|
|
551
552
|
if (!Number.isFinite(min) || !Number.isFinite(max)) return;
|
|
552
553
|
if (max < min) [min, max] = [max, min];
|
|
554
|
+
if (this.mode === "log" && min <= 0) min = max > 0 ? max / 1e3 : LOG_FLOOR;
|
|
553
555
|
if (max - min < 1e-12) {
|
|
554
556
|
const pad = Math.abs(max) * 0.01 || 1;
|
|
555
557
|
min -= pad;
|
|
@@ -568,27 +570,63 @@ var PriceScale = class {
|
|
|
568
570
|
get innerHeight() {
|
|
569
571
|
return Math.max(1, this.available * (1 - this.margins.top - this.margins.bottom));
|
|
570
572
|
}
|
|
573
|
+
/** Price → the space the axis is linear in (log prices in log mode). */
|
|
574
|
+
toAxis(price) {
|
|
575
|
+
return this.mode === "log" ? Math.log(Math.max(price, LOG_FLOOR)) : price;
|
|
576
|
+
}
|
|
577
|
+
fromAxis(v) {
|
|
578
|
+
return this.mode === "log" ? Math.exp(v) : v;
|
|
579
|
+
}
|
|
571
580
|
priceToY(price) {
|
|
572
|
-
|
|
581
|
+
const lo = this.toAxis(this.lo);
|
|
582
|
+
const hi = this.toAxis(this.hi);
|
|
583
|
+
return this.innerTop + (hi - this.toAxis(price)) / (hi - lo) * this.innerHeight;
|
|
573
584
|
}
|
|
574
585
|
yToPrice(y) {
|
|
575
|
-
|
|
586
|
+
const lo = this.toAxis(this.lo);
|
|
587
|
+
const hi = this.toAxis(this.hi);
|
|
588
|
+
return this.fromAxis(hi - (y - this.innerTop) / this.innerHeight * (hi - lo));
|
|
589
|
+
}
|
|
590
|
+
/** Sets the range in axis space (log prices in log mode). */
|
|
591
|
+
setAxisRange(lo, hi) {
|
|
592
|
+
this.setRange(this.fromAxis(lo), this.fromAxis(hi));
|
|
576
593
|
}
|
|
577
594
|
/** Scales around the center. factor > 1 shows a wider price range. Disables auto-scale. */
|
|
578
595
|
zoom(factor) {
|
|
579
|
-
const
|
|
580
|
-
const
|
|
581
|
-
|
|
596
|
+
const lo = this.toAxis(this.lo);
|
|
597
|
+
const hi = this.toAxis(this.hi);
|
|
598
|
+
const center = (lo + hi) / 2;
|
|
599
|
+
const half = (hi - lo) / 2 * factor;
|
|
600
|
+
this.setAxisRange(center - half, center + half);
|
|
582
601
|
this.autoScale = false;
|
|
583
602
|
}
|
|
584
603
|
/** Moves the content by `dy` pixels (positive = content moves down). Disables auto-scale. */
|
|
585
604
|
pan(dy) {
|
|
586
|
-
const
|
|
587
|
-
this.
|
|
605
|
+
const lo = this.toAxis(this.lo);
|
|
606
|
+
const hi = this.toAxis(this.hi);
|
|
607
|
+
const delta = dy / this.innerHeight * (hi - lo);
|
|
608
|
+
this.setAxisRange(lo + delta, hi + delta);
|
|
588
609
|
this.autoScale = false;
|
|
589
610
|
}
|
|
590
611
|
};
|
|
612
|
+
var LOG_FLOOR = 1e-12;
|
|
591
613
|
var NICE_MULTIPLIERS = [1, 2, 2.5, 5, 10];
|
|
614
|
+
function logTicks(scale, spacing, minStep = 0) {
|
|
615
|
+
const ticks = [];
|
|
616
|
+
for (let y = scale.height - spacing / 2; y > 0; y -= spacing) {
|
|
617
|
+
const raw = scale.yToPrice(y);
|
|
618
|
+
if (!(raw > 0)) continue;
|
|
619
|
+
const local = scale.yToPrice(y - spacing) - raw;
|
|
620
|
+
const mag = 10 ** Math.floor(Math.log10(local));
|
|
621
|
+
let step = NICE_MULTIPLIERS.map((m) => m * mag).find((s) => s >= local * 0.75) ?? 10 * mag;
|
|
622
|
+
if (minStep > 0) step = Math.max(minStep, Math.round(step / minStep) * minStep);
|
|
623
|
+
const best = Number((Math.round(raw / step) * step).toPrecision(12));
|
|
624
|
+
if (!(best > 0)) continue;
|
|
625
|
+
const prev = ticks[ticks.length - 1];
|
|
626
|
+
if (prev === void 0 || Math.abs(scale.priceToY(best) - scale.priceToY(prev)) >= spacing * 0.6) ticks.push(best);
|
|
627
|
+
}
|
|
628
|
+
return ticks;
|
|
629
|
+
}
|
|
592
630
|
function niceTicks(min, max, maxCount, minStep = 0) {
|
|
593
631
|
if (!(max > min) || maxCount < 1) return [];
|
|
594
632
|
const rough = (max - min) / maxCount;
|
|
@@ -831,6 +869,10 @@ var TimeScale = class {
|
|
|
831
869
|
}
|
|
832
870
|
};
|
|
833
871
|
|
|
872
|
+
// src/objects/types.ts
|
|
873
|
+
var counter = 0;
|
|
874
|
+
var newId = (prefix) => `${prefix}_${Date.now().toString(36)}${(counter++).toString(36)}`;
|
|
875
|
+
|
|
834
876
|
// src/indicators/math.ts
|
|
835
877
|
function sma(src2, period) {
|
|
836
878
|
const out = new Float64Array(src2.length).fill(Number.NaN);
|
|
@@ -1078,27 +1120,36 @@ var INDICATORS = {
|
|
|
1078
1120
|
}
|
|
1079
1121
|
};
|
|
1080
1122
|
|
|
1081
|
-
// src/objects/types.ts
|
|
1082
|
-
var counter = 0;
|
|
1083
|
-
var newId = (prefix) => `${prefix}_${Date.now().toString(36)}${(counter++).toString(36)}`;
|
|
1084
|
-
|
|
1085
1123
|
// src/indicators/manager.ts
|
|
1086
1124
|
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 ?? {} };
|
|
1125
|
+
function indicatorRegistry(extra = []) {
|
|
1126
|
+
const registry = new Map(Object.entries(INDICATORS));
|
|
1127
|
+
for (const def of extra) registry.set(def.type, def);
|
|
1128
|
+
return registry;
|
|
1095
1129
|
}
|
|
1096
1130
|
var IndicatorManager = class {
|
|
1131
|
+
constructor(registry = indicatorRegistry()) {
|
|
1132
|
+
this.registry = registry;
|
|
1133
|
+
}
|
|
1134
|
+
registry;
|
|
1097
1135
|
list = [];
|
|
1098
1136
|
cache = /* @__PURE__ */ new Map();
|
|
1137
|
+
def(type) {
|
|
1138
|
+
const def = this.registry.get(type);
|
|
1139
|
+
if (!def) throw new Error(`funnest-charts: unknown indicator "${type}"`);
|
|
1140
|
+
return def;
|
|
1141
|
+
}
|
|
1142
|
+
normalize(c) {
|
|
1143
|
+
const def = this.def(c.type);
|
|
1144
|
+
const params = { ...def.defaults, ...c.params };
|
|
1145
|
+
if ((c.type === "ma" || c.type === "ema") && !Array.isArray(c.params.lines) && Array.isArray(c.params.periods)) {
|
|
1146
|
+
params.lines = maLines({ periods: c.params.periods });
|
|
1147
|
+
}
|
|
1148
|
+
delete params.periods;
|
|
1149
|
+
return { id: c.id || newId(c.type), type: c.type, params, visible: c.visible ?? true, styles: c.styles ?? {} };
|
|
1150
|
+
}
|
|
1099
1151
|
add(type, params) {
|
|
1100
|
-
|
|
1101
|
-
const config = normalize2({ id: newId(type), type, params: params ?? {} });
|
|
1152
|
+
const config = this.normalize({ id: newId(type), type, params: params ?? {} });
|
|
1102
1153
|
this.list.push(config);
|
|
1103
1154
|
return config.id;
|
|
1104
1155
|
}
|
|
@@ -1116,25 +1167,26 @@ var IndicatorManager = class {
|
|
|
1116
1167
|
if (patch.visible !== void 0) cfg.visible = patch.visible;
|
|
1117
1168
|
return true;
|
|
1118
1169
|
}
|
|
1170
|
+
/** Unknown types (e.g. a plugin this chart wasn't given) are dropped. */
|
|
1119
1171
|
set(configs) {
|
|
1120
|
-
this.list = configs.filter((c) =>
|
|
1172
|
+
this.list = configs.filter((c) => this.registry.has(c.type)).map((c) => this.normalize(clone(c)));
|
|
1121
1173
|
this.cache.clear();
|
|
1122
1174
|
}
|
|
1123
1175
|
snapshot() {
|
|
1124
1176
|
return this.list.map(clone);
|
|
1125
1177
|
}
|
|
1126
1178
|
get overlays() {
|
|
1127
|
-
return this.list.filter((c) =>
|
|
1179
|
+
return this.list.filter((c) => this.def(c.type).overlay);
|
|
1128
1180
|
}
|
|
1129
1181
|
get panes() {
|
|
1130
|
-
return this.list.filter((c) => !
|
|
1182
|
+
return this.list.filter((c) => !this.def(c.type).overlay);
|
|
1131
1183
|
}
|
|
1132
1184
|
/** Computed series with user styles applied; recomputed only when data or settings change. */
|
|
1133
1185
|
output(cfg, bars, dataVersion) {
|
|
1134
1186
|
const key = `${dataVersion}|${JSON.stringify(cfg.params)}|${JSON.stringify(cfg.styles)}`;
|
|
1135
1187
|
const hit = this.cache.get(cfg.id);
|
|
1136
1188
|
if (hit?.key === key) return hit.output;
|
|
1137
|
-
const raw =
|
|
1189
|
+
const raw = this.def(cfg.type).compute(bars, cfg.params);
|
|
1138
1190
|
const output = {
|
|
1139
1191
|
...raw,
|
|
1140
1192
|
series: raw.series.map((s) => {
|
|
@@ -1153,7 +1205,6 @@ var IndicatorManager = class {
|
|
|
1153
1205
|
};
|
|
1154
1206
|
|
|
1155
1207
|
// src/objects/drawings.ts
|
|
1156
|
-
var DRAWING_POINTS = { trendline: 2, horizontal: 1, fibonacci: 2 };
|
|
1157
1208
|
var FIB_LEVELS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
|
|
1158
1209
|
var FIB_COLORS = ["#8B95A1", "#F04452", "#FF9F0A", "#12B886", "#3182F6", "#7048E8", "#8B95A1"];
|
|
1159
1210
|
var HANDLE_RADIUS = 5;
|
|
@@ -1164,11 +1215,84 @@ function distToSegment(px, py, x1, y1, x2, y2) {
|
|
|
1164
1215
|
const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / len2));
|
|
1165
1216
|
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
|
|
1166
1217
|
}
|
|
1218
|
+
var p0 = (g) => g.points[0];
|
|
1219
|
+
var p1 = (g) => g.points[1] ?? g.points[0];
|
|
1220
|
+
var BUILTIN_DRAWINGS = [
|
|
1221
|
+
{
|
|
1222
|
+
type: "trendline",
|
|
1223
|
+
name: { ko: "\uCD94\uC138\uC120", en: "Trend line" },
|
|
1224
|
+
group: "line",
|
|
1225
|
+
clicks: 2,
|
|
1226
|
+
distance: (g, x, y) => distToSegment(x, y, ...p0(g), ...p1(g)),
|
|
1227
|
+
render: (_d, { painter, color, ...g }) => painter.line(...p0(g), ...p1(g), color, 2)
|
|
1228
|
+
},
|
|
1229
|
+
{
|
|
1230
|
+
type: "horizontal",
|
|
1231
|
+
name: { ko: "\uC218\uD3C9\uC120", en: "Horizontal line" },
|
|
1232
|
+
group: "level",
|
|
1233
|
+
clicks: 1,
|
|
1234
|
+
// The anchor's time doesn't matter; keep the handle inside the plot.
|
|
1235
|
+
handles: (g) => [[Math.min(g.coords.plotWidth - 24, Math.max(24, p0(g)[0])), p0(g)[1]]],
|
|
1236
|
+
distance: (g, _x, y) => Math.abs(y - p0(g)[1]),
|
|
1237
|
+
render: (d, { painter, color, coords, badge, ...g }) => {
|
|
1238
|
+
const y = p0(g)[1];
|
|
1239
|
+
painter.hLine(y, 0, coords.plotWidth, color);
|
|
1240
|
+
badge(y, coords.formatPrice(d.points[0].price), color);
|
|
1241
|
+
}
|
|
1242
|
+
},
|
|
1243
|
+
{
|
|
1244
|
+
type: "fibonacci",
|
|
1245
|
+
name: { ko: "\uD53C\uBCF4\uB098\uCE58 \uB418\uB3CC\uB9BC", en: "Fib retracement" },
|
|
1246
|
+
group: "fib",
|
|
1247
|
+
clicks: 2,
|
|
1248
|
+
distance: (g, x, y) => {
|
|
1249
|
+
const [x1, y1] = p0(g);
|
|
1250
|
+
const [x2, y2] = p1(g);
|
|
1251
|
+
let best = distToSegment(x, y, x1, y1, x2, y2);
|
|
1252
|
+
if (x >= Math.min(x1, x2) && x <= Math.max(x1, x2)) {
|
|
1253
|
+
for (const level of FIB_LEVELS) best = Math.min(best, Math.abs(y - (y2 + (y1 - y2) * level)));
|
|
1254
|
+
}
|
|
1255
|
+
return best;
|
|
1256
|
+
},
|
|
1257
|
+
render: (d, { painter: p, coords: c, palette, ...g }) => {
|
|
1258
|
+
const [x1, y1] = p0(g);
|
|
1259
|
+
const [x2, y2] = p1(g);
|
|
1260
|
+
const left = Math.min(x1, x2);
|
|
1261
|
+
const right = Math.max(x1, x2, left + 1);
|
|
1262
|
+
const a = d.points[0].price;
|
|
1263
|
+
const b = d.points[1].price;
|
|
1264
|
+
const { ctx } = p;
|
|
1265
|
+
FIB_LEVELS.forEach((level, i) => {
|
|
1266
|
+
const price = b + (a - b) * level;
|
|
1267
|
+
const y = c.priceToY(price);
|
|
1268
|
+
const levelColor = d.color ?? FIB_COLORS[i];
|
|
1269
|
+
if (i > 0) {
|
|
1270
|
+
const prevY = c.priceToY(b + (a - b) * FIB_LEVELS[i - 1]);
|
|
1271
|
+
ctx.save();
|
|
1272
|
+
ctx.globalAlpha = 0.07;
|
|
1273
|
+
ctx.fillStyle = levelColor;
|
|
1274
|
+
ctx.fillRect(left, Math.min(y, prevY), right - left, Math.abs(y - prevY));
|
|
1275
|
+
ctx.restore();
|
|
1276
|
+
}
|
|
1277
|
+
p.hLine(y, left, right, levelColor);
|
|
1278
|
+
p.text(`${level} (${c.formatPrice(price)})`, left + 4, y - 7, levelColor, "left", "middle");
|
|
1279
|
+
});
|
|
1280
|
+
p.line(x1, y1, x2, y2, palette.textSecondary, 1, [4, 4]);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
];
|
|
1284
|
+
function drawingRegistry(extra = []) {
|
|
1285
|
+
const registry = new Map(BUILTIN_DRAWINGS.map((d) => [d.type, d]));
|
|
1286
|
+
for (const def of extra) registry.set(def.type, def);
|
|
1287
|
+
return registry;
|
|
1288
|
+
}
|
|
1167
1289
|
var DrawingManager = class {
|
|
1168
|
-
constructor(onChange) {
|
|
1290
|
+
constructor(onChange, registry = drawingRegistry()) {
|
|
1169
1291
|
this.onChange = onChange;
|
|
1292
|
+
this.registry = registry;
|
|
1170
1293
|
}
|
|
1171
1294
|
onChange;
|
|
1295
|
+
registry;
|
|
1172
1296
|
drawings = [];
|
|
1173
1297
|
tool = null;
|
|
1174
1298
|
selectedId = null;
|
|
@@ -1178,6 +1302,7 @@ var DrawingManager = class {
|
|
|
1178
1302
|
return this.drawings.find((d) => d.id === this.selectedId) ?? null;
|
|
1179
1303
|
}
|
|
1180
1304
|
setTool(tool) {
|
|
1305
|
+
if (tool && !this.registry.has(tool)) throw new Error(`funnest-charts: unknown drawing tool "${tool}"`);
|
|
1181
1306
|
this.tool = tool;
|
|
1182
1307
|
this.draft = null;
|
|
1183
1308
|
if (tool) this.selectedId = null;
|
|
@@ -1188,6 +1313,7 @@ var DrawingManager = class {
|
|
|
1188
1313
|
this.selectedId = id;
|
|
1189
1314
|
this.onChange("selection");
|
|
1190
1315
|
}
|
|
1316
|
+
/** Drawings of unknown types are kept (and saved) but not shown. */
|
|
1191
1317
|
set(drawings) {
|
|
1192
1318
|
this.drawings = drawings.map((d) => ({ ...d, points: d.points.map((p) => ({ ...p })) }));
|
|
1193
1319
|
if (!this.selected) this.selectedId = null;
|
|
@@ -1217,26 +1343,31 @@ var DrawingManager = class {
|
|
|
1217
1343
|
else this.select(null);
|
|
1218
1344
|
}
|
|
1219
1345
|
// ── Creation ──────────────────────────────────────────────────────
|
|
1346
|
+
expand(def, placed, c) {
|
|
1347
|
+
const full = Array.from({ length: def.clicks }, (_, i) => ({ ...placed[i] ?? placed[placed.length - 1] }));
|
|
1348
|
+
return def.create ? def.create(full, c) : full;
|
|
1349
|
+
}
|
|
1220
1350
|
createAt(c, x, y) {
|
|
1221
|
-
|
|
1351
|
+
const def = this.tool ? this.registry.get(this.tool) : void 0;
|
|
1352
|
+
if (!def) return;
|
|
1222
1353
|
const point = c.pointAt(x, y);
|
|
1223
1354
|
if (!this.draft) {
|
|
1224
|
-
const
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
if (count === 1) this.finish();
|
|
1355
|
+
const placed = [point];
|
|
1356
|
+
this.draft = { drawing: { id: newId("d"), type: def.type, points: this.expand(def, placed, c) }, placed };
|
|
1357
|
+
if (def.clicks <= 1) this.finish();
|
|
1228
1358
|
else this.onChange("preview");
|
|
1229
1359
|
return;
|
|
1230
1360
|
}
|
|
1231
|
-
this.draft.
|
|
1232
|
-
this.draft.placed
|
|
1233
|
-
if (this.draft.placed >=
|
|
1361
|
+
this.draft.placed.push(point);
|
|
1362
|
+
this.draft.drawing.points = this.expand(def, this.draft.placed, c);
|
|
1363
|
+
if (this.draft.placed.length >= def.clicks) this.finish();
|
|
1234
1364
|
}
|
|
1235
1365
|
/** Moves the not-yet-placed points (live preview while hovering or dragging). */
|
|
1236
1366
|
previewAt(c, x, y) {
|
|
1237
|
-
|
|
1238
|
-
const
|
|
1239
|
-
|
|
1367
|
+
const draft = this.draft;
|
|
1368
|
+
const def = draft && this.registry.get(draft.drawing.type);
|
|
1369
|
+
if (!draft || !def) return;
|
|
1370
|
+
draft.drawing.points = this.expand(def, [...draft.placed, c.pointAt(x, y)], c);
|
|
1240
1371
|
this.onChange("preview");
|
|
1241
1372
|
}
|
|
1242
1373
|
/** Pointer released: a drag since the first point places the next one (drag-to-draw). */
|
|
@@ -1253,17 +1384,24 @@ var DrawingManager = class {
|
|
|
1253
1384
|
this.onChange("commit");
|
|
1254
1385
|
}
|
|
1255
1386
|
// ── Editing ───────────────────────────────────────────────────────
|
|
1387
|
+
geometry(c, d) {
|
|
1388
|
+
return { coords: c, points: d.points.map((p) => [c.timeToX(p.time), c.priceToY(p.price)]) };
|
|
1389
|
+
}
|
|
1390
|
+
handles(c, d) {
|
|
1391
|
+
const def = this.registry.get(d.type);
|
|
1392
|
+
const g = this.geometry(c, d);
|
|
1393
|
+
return def?.handles ? def.handles(g) : g.points;
|
|
1394
|
+
}
|
|
1256
1395
|
hitTest(c, x, y, tolerance) {
|
|
1257
1396
|
const selected = this.selected;
|
|
1258
|
-
if (selected) {
|
|
1259
|
-
const handle = this.
|
|
1260
|
-
([px, py]) => Math.hypot(px - x, py - y) <= HANDLE_RADIUS + tolerance
|
|
1261
|
-
);
|
|
1397
|
+
if (selected && this.registry.has(selected.type)) {
|
|
1398
|
+
const handle = this.handles(c, selected).findIndex(([px, py]) => Math.hypot(px - x, py - y) <= HANDLE_RADIUS + tolerance);
|
|
1262
1399
|
if (handle >= 0) return { kind: "drawing", id: selected.id, part: handle };
|
|
1263
1400
|
}
|
|
1264
1401
|
for (let i = this.drawings.length - 1; i >= 0; i--) {
|
|
1265
1402
|
const d = this.drawings[i];
|
|
1266
|
-
|
|
1403
|
+
const def = this.registry.get(d.type);
|
|
1404
|
+
if (def && def.distance(this.geometry(c, d), x, y) <= tolerance) return { kind: "drawing", id: d.id, part: "body" };
|
|
1267
1405
|
}
|
|
1268
1406
|
return null;
|
|
1269
1407
|
}
|
|
@@ -1271,7 +1409,7 @@ var DrawingManager = class {
|
|
|
1271
1409
|
const d = this.drawings.find((x2) => x2.id === id);
|
|
1272
1410
|
if (!d) return;
|
|
1273
1411
|
this.select(id);
|
|
1274
|
-
this.drag = { id, part, x, y, origin: this.
|
|
1412
|
+
this.drag = { id, part, x, y, origin: this.geometry(c, d).points };
|
|
1275
1413
|
}
|
|
1276
1414
|
dragTo(c, x, y) {
|
|
1277
1415
|
const drag = this.drag;
|
|
@@ -1279,6 +1417,8 @@ var DrawingManager = class {
|
|
|
1279
1417
|
if (!drag || !d) return;
|
|
1280
1418
|
if (typeof drag.part === "number") {
|
|
1281
1419
|
d.points[drag.part] = c.pointAt(x, y);
|
|
1420
|
+
const def = this.registry.get(d.type);
|
|
1421
|
+
if (def?.constrain) d.points = def.constrain(d.points, drag.part);
|
|
1282
1422
|
} else {
|
|
1283
1423
|
const dx = x - drag.x;
|
|
1284
1424
|
const dy = y - drag.y;
|
|
@@ -1290,71 +1430,26 @@ var DrawingManager = class {
|
|
|
1290
1430
|
if (this.drag && moved) this.onChange("commit");
|
|
1291
1431
|
this.drag = null;
|
|
1292
1432
|
}
|
|
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
1433
|
// ── Rendering ─────────────────────────────────────────────────────
|
|
1311
1434
|
render(rc, badges) {
|
|
1312
1435
|
const all = this.draft ? [...this.drawings, this.draft.drawing] : this.drawings;
|
|
1436
|
+
const c = rc.coords;
|
|
1313
1437
|
for (const d of all) {
|
|
1314
|
-
const
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1438
|
+
const def = this.registry.get(d.type);
|
|
1439
|
+
if (!def) continue;
|
|
1440
|
+
const color = d.color ?? rc.palette.accent;
|
|
1441
|
+
def.render(d, {
|
|
1442
|
+
...this.geometry(c, d),
|
|
1443
|
+
painter: rc.painter,
|
|
1444
|
+
palette: rc.palette,
|
|
1445
|
+
color,
|
|
1446
|
+
badge: (y, text, bg, fg = "#FFFFFF") => badges.push({ y, text, bg, fg })
|
|
1447
|
+
});
|
|
1448
|
+
if (d.id === this.selectedId || d === this.draft?.drawing) {
|
|
1449
|
+
for (const [px, py] of this.handles(c, d)) rc.painter.circle(px, py, HANDLE_RADIUS, rc.palette.background, color);
|
|
1319
1450
|
}
|
|
1320
1451
|
}
|
|
1321
1452
|
}
|
|
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
1453
|
};
|
|
1359
1454
|
|
|
1360
1455
|
// src/render/painter.ts
|
|
@@ -1471,12 +1566,14 @@ var inRect = (r, x, y, pad = 0) => x >= r.x - pad && x <= r.x + r.w + pad && y >
|
|
|
1471
1566
|
var LABEL_X = 12;
|
|
1472
1567
|
var LABEL_HEIGHT = 20;
|
|
1473
1568
|
var ORDER_DASH = [6, 4];
|
|
1569
|
+
var ALERT_DASH = [2, 3];
|
|
1474
1570
|
var TradingLayer = class {
|
|
1475
1571
|
constructor(onChange) {
|
|
1476
1572
|
this.onChange = onChange;
|
|
1477
1573
|
}
|
|
1478
1574
|
onChange;
|
|
1479
1575
|
positions = /* @__PURE__ */ new Map();
|
|
1576
|
+
/** Order lines and alert lines (same dragging and × behavior). */
|
|
1480
1577
|
orders = /* @__PURE__ */ new Map();
|
|
1481
1578
|
executions = [];
|
|
1482
1579
|
labelRects = /* @__PURE__ */ new Map();
|
|
@@ -1494,6 +1591,12 @@ var TradingLayer = class {
|
|
|
1494
1591
|
this.onChange();
|
|
1495
1592
|
return this.handle(id, this.orders);
|
|
1496
1593
|
}
|
|
1594
|
+
addAlert(options) {
|
|
1595
|
+
const id = newId("alert");
|
|
1596
|
+
this.orders.set(id, { ...options, quantity: 0, side: "buy", alert: true });
|
|
1597
|
+
this.onChange();
|
|
1598
|
+
return this.handle(id, this.orders);
|
|
1599
|
+
}
|
|
1497
1600
|
setExecutions(markers) {
|
|
1498
1601
|
this.executions = markers.slice();
|
|
1499
1602
|
this.onChange();
|
|
@@ -1642,12 +1745,11 @@ var TradingLayer = class {
|
|
|
1642
1745
|
for (const [id, order] of this.orders) {
|
|
1643
1746
|
const price = this.drag?.id === id ? this.drag.price : order.price;
|
|
1644
1747
|
const y = c.priceToY(price);
|
|
1645
|
-
const color = order.side === "buy" ? palette.up : palette.down;
|
|
1748
|
+
const color = order.alert ? palette.textSecondary : order.side === "buy" ? palette.up : palette.down;
|
|
1646
1749
|
badges.push({ y, text: c.formatPrice(price), bg: color, fg: "#FFFFFF" });
|
|
1647
1750
|
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(" ");
|
|
1751
|
+
p.hLine(y, 0, c.plotWidth, color, order.alert ? ALERT_DASH : ORDER_DASH);
|
|
1752
|
+
const main = order.alert ? order.label ?? labels.alert : [order.side === "buy" ? labels.buy : labels.sell, order.label, String(order.quantity)].filter(Boolean).join(" ");
|
|
1651
1753
|
const segments = [{ text: main, bg: color, fg: "#FFFFFF" }];
|
|
1652
1754
|
if (order.onCancel) segments.push({ text: "\xD7", bg: color, fg: "#FFFFFF", padding: 7 });
|
|
1653
1755
|
const rects = p.pill(segments, LABEL_X, y, LABEL_HEIGHT);
|
|
@@ -1682,6 +1784,8 @@ var DEFAULT_CHART_SETTINGS = {
|
|
|
1682
1784
|
gridVertical: true,
|
|
1683
1785
|
volume: true,
|
|
1684
1786
|
navigationButtons: true,
|
|
1787
|
+
priceScale: "normal",
|
|
1788
|
+
countdown: true,
|
|
1685
1789
|
upColor: null,
|
|
1686
1790
|
downColor: null
|
|
1687
1791
|
};
|
|
@@ -1697,6 +1801,7 @@ function parseChartState(input) {
|
|
|
1697
1801
|
settings: { ...DEFAULT_CHART_SETTINGS, ...s.settings ?? {} },
|
|
1698
1802
|
indicators: Array.isArray(s.indicators) ? s.indicators : [],
|
|
1699
1803
|
drawings: s.drawings && typeof s.drawings === "object" ? s.drawings : {},
|
|
1804
|
+
compares: Array.isArray(s.compares) ? s.compares.filter((x) => typeof x === "string") : [],
|
|
1700
1805
|
view: {
|
|
1701
1806
|
barSpacing: Number(s.view?.barSpacing) || 8,
|
|
1702
1807
|
rightOffset: Number.isFinite(Number(s.view?.rightOffset)) ? Number(s.view?.rightOffset) : 5
|
|
@@ -1985,28 +2090,40 @@ function drawLineSeries(ctx, m, pane, area) {
|
|
|
1985
2090
|
function drawIndicator(ctx, p, m, pane, output) {
|
|
1986
2091
|
const { timeScale, frame } = m;
|
|
1987
2092
|
const pal = frame.palette;
|
|
1988
|
-
const
|
|
1989
|
-
|
|
1990
|
-
|
|
2093
|
+
const n = m.bars.length;
|
|
2094
|
+
if (frame.range.to < frame.range.from) return;
|
|
2095
|
+
const span = (shift) => {
|
|
2096
|
+
const lastX = Math.ceil(timeScale.xToIndexFloat(frame.plotWidth)) + 1;
|
|
2097
|
+
return [Math.max(0, frame.range.from - 1 - shift), Math.min(n - 1, (shift > 0 ? lastX : frame.range.to + 1) - shift)];
|
|
2098
|
+
};
|
|
2099
|
+
const [from, to] = span(0);
|
|
1991
2100
|
const yOf = (v) => pane.top + pane.scale.priceToY(v);
|
|
1992
|
-
const byKey = (key) => output.series.find((s) => s.key === key)
|
|
2101
|
+
const byKey = (key) => output.series.find((s) => s.key === key);
|
|
1993
2102
|
for (const level of output.levels ?? []) p.hLine(yOf(level), 0, frame.plotWidth, pal.textSecondary, [3, 4]);
|
|
1994
2103
|
if (output.fill) {
|
|
1995
|
-
const
|
|
1996
|
-
const lower = byKey(output.fill.lower);
|
|
1997
|
-
if (
|
|
2104
|
+
const upperSeries = byKey(output.fill.upper);
|
|
2105
|
+
const lower = byKey(output.fill.lower)?.values;
|
|
2106
|
+
if (upperSeries && lower) {
|
|
2107
|
+
const upper = upperSeries.values;
|
|
2108
|
+
const shift = upperSeries.shift ?? 0;
|
|
2109
|
+
const [f0, f1] = span(shift);
|
|
2110
|
+
const { color, downColor } = output.fill;
|
|
2111
|
+
const sideOf = (j) => downColor && lower[j] > upper[j] ? 1 : 0;
|
|
2112
|
+
const valid = (j) => !Number.isNaN(upper[j]) && !Number.isNaN(lower[j]);
|
|
1998
2113
|
ctx.save();
|
|
1999
2114
|
ctx.globalAlpha = 0.07;
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
while (i <= to && (Number.isNaN(upper[i]) || Number.isNaN(lower[i]))) i++;
|
|
2115
|
+
let i = f0;
|
|
2116
|
+
while (i <= f1) {
|
|
2117
|
+
while (i <= f1 && !valid(i)) i++;
|
|
2004
2118
|
const start = i;
|
|
2005
|
-
|
|
2006
|
-
|
|
2119
|
+
const side = valid(i) ? sideOf(i) : 0;
|
|
2120
|
+
while (i <= f1 && valid(i) && sideOf(i) === side) i++;
|
|
2121
|
+
const end = i <= f1 && valid(i) ? i + 1 : i;
|
|
2122
|
+
if (end - start < 2) continue;
|
|
2123
|
+
ctx.fillStyle = side ? downColor : color;
|
|
2007
2124
|
ctx.beginPath();
|
|
2008
|
-
for (let j = start; j <
|
|
2009
|
-
for (let j =
|
|
2125
|
+
for (let j = start; j < end; j++) ctx.lineTo(timeScale.indexToX(j + shift), yOf(upper[j]));
|
|
2126
|
+
for (let j = end - 1; j >= start; j--) ctx.lineTo(timeScale.indexToX(j + shift), yOf(lower[j]));
|
|
2010
2127
|
ctx.closePath();
|
|
2011
2128
|
ctx.fill();
|
|
2012
2129
|
}
|
|
@@ -2042,13 +2159,15 @@ function drawIndicator(ctx, p, m, pane, output) {
|
|
|
2042
2159
|
ctx.lineWidth = series.width;
|
|
2043
2160
|
ctx.beginPath();
|
|
2044
2161
|
let pen = false;
|
|
2045
|
-
|
|
2162
|
+
const shift = series.shift ?? 0;
|
|
2163
|
+
const [s0, s1] = span(shift);
|
|
2164
|
+
for (let i = s0; i <= s1; i++) {
|
|
2046
2165
|
const v = series.values[i];
|
|
2047
2166
|
if (Number.isNaN(v)) {
|
|
2048
2167
|
pen = false;
|
|
2049
2168
|
continue;
|
|
2050
2169
|
}
|
|
2051
|
-
const x = timeScale.indexToX(i);
|
|
2170
|
+
const x = timeScale.indexToX(i + shift);
|
|
2052
2171
|
if (pen) ctx.lineTo(x, yOf(v));
|
|
2053
2172
|
else ctx.moveTo(x, yOf(v));
|
|
2054
2173
|
pen = true;
|
|
@@ -2181,8 +2300,8 @@ function drawMain(layer, m) {
|
|
|
2181
2300
|
for (const price of f.priceTicks) {
|
|
2182
2301
|
const y = main.scale.priceToY(price);
|
|
2183
2302
|
if (y < 8 || y > main.height - 8) continue;
|
|
2184
|
-
if (last &&
|
|
2185
|
-
p.text(m.formatPrice(price), labelX, y, pal.textSecondary);
|
|
2303
|
+
if (last && y > lastY - BADGE_HEIGHT && y < lastY + (f.countdown ? BADGE_HEIGHT * 2 - 4 : BADGE_HEIGHT)) continue;
|
|
2304
|
+
p.text((main.tickFormat ?? m.formatPrice)(price), labelX, y, pal.textSecondary);
|
|
2186
2305
|
}
|
|
2187
2306
|
for (const pane of f.panes) {
|
|
2188
2307
|
if (pane.kind !== "indicator") continue;
|
|
@@ -2198,9 +2317,16 @@ function drawMain(layer, m) {
|
|
|
2198
2317
|
p.text(badge.text, labelX, y, badge.fg);
|
|
2199
2318
|
}
|
|
2200
2319
|
if (last && lastY >= 0 && lastY <= main.height) {
|
|
2201
|
-
const
|
|
2202
|
-
|
|
2203
|
-
p.
|
|
2320
|
+
const h = f.countdown ? BADGE_HEIGHT * 2 - 4 : BADGE_HEIGHT;
|
|
2321
|
+
const y = Math.min(main.height - h + BADGE_HEIGHT / 2, Math.max(BADGE_HEIGHT / 2, lastY));
|
|
2322
|
+
p.roundRect(f.plotWidth + 2, y - BADGE_HEIGHT / 2, f.width - f.plotWidth - 4, h, 4, lastColor);
|
|
2323
|
+
p.text(main.format(last.close), labelX, y, "#FFFFFF");
|
|
2324
|
+
if (f.countdown) {
|
|
2325
|
+
p.ctx.save();
|
|
2326
|
+
p.ctx.globalAlpha = 0.85;
|
|
2327
|
+
p.text(f.countdown, labelX, y + BADGE_HEIGHT - 4, "#FFFFFF");
|
|
2328
|
+
p.ctx.restore();
|
|
2329
|
+
}
|
|
2204
2330
|
}
|
|
2205
2331
|
const timeY = f.panesHeight + f.timeAxisHeight / 2;
|
|
2206
2332
|
for (const tick of f.timeTicks) {
|
|
@@ -2239,6 +2365,13 @@ function drawOverlay(layer, m, ch, tooltip = null) {
|
|
|
2239
2365
|
ctx.font = f.font;
|
|
2240
2366
|
if (tooltip) drawTooltip(p, f, tooltip);
|
|
2241
2367
|
if (!ch) return;
|
|
2368
|
+
if (m.replaySelecting) {
|
|
2369
|
+
ctx.save();
|
|
2370
|
+
ctx.globalAlpha = 0.12;
|
|
2371
|
+
ctx.fillStyle = pal.text;
|
|
2372
|
+
ctx.fillRect(ch.x, 0, f.plotWidth - ch.x, f.panesHeight);
|
|
2373
|
+
ctx.restore();
|
|
2374
|
+
}
|
|
2242
2375
|
p.vLine(ch.x, 0, f.panesHeight, pal.crosshair, DASH);
|
|
2243
2376
|
if (ch.source !== "sync") drawCrosshairValue(p, m, ch);
|
|
2244
2377
|
const timeLabel = m.formatCrosshairTime(ch.time);
|
|
@@ -2293,6 +2426,103 @@ function isIntradayResolution(resolution) {
|
|
|
2293
2426
|
return resolutionToMs(resolution) < DAY_MS;
|
|
2294
2427
|
}
|
|
2295
2428
|
|
|
2429
|
+
// src/realtime.ts
|
|
2430
|
+
var offsetFormatters = /* @__PURE__ */ new Map();
|
|
2431
|
+
function timeZoneOffset(time, timeZone) {
|
|
2432
|
+
if (timeZone === "UTC") return 0;
|
|
2433
|
+
let dtf = offsetFormatters.get(timeZone);
|
|
2434
|
+
if (!dtf) {
|
|
2435
|
+
dtf = new Intl.DateTimeFormat("en-US", {
|
|
2436
|
+
timeZone,
|
|
2437
|
+
hourCycle: "h23",
|
|
2438
|
+
year: "numeric",
|
|
2439
|
+
month: "numeric",
|
|
2440
|
+
day: "numeric",
|
|
2441
|
+
hour: "numeric",
|
|
2442
|
+
minute: "numeric",
|
|
2443
|
+
second: "numeric"
|
|
2444
|
+
});
|
|
2445
|
+
offsetFormatters.set(timeZone, dtf);
|
|
2446
|
+
}
|
|
2447
|
+
const p = {};
|
|
2448
|
+
for (const { type, value } of dtf.formatToParts(time)) p[type] = Number(value);
|
|
2449
|
+
const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour % 24, p.minute, p.second);
|
|
2450
|
+
return Math.round((asUtc - Math.floor(time / 1e3) * 1e3) / 6e4) * 6e4;
|
|
2451
|
+
}
|
|
2452
|
+
function bucketStart(time, resolution, timeZone = "UTC") {
|
|
2453
|
+
const offset = timeZoneOffset(time, timeZone);
|
|
2454
|
+
const local = time + offset;
|
|
2455
|
+
const unit = resolution.trim().slice(-1).toUpperCase();
|
|
2456
|
+
if (unit === "M") {
|
|
2457
|
+
const n = Number(resolution.slice(0, -1)) || 1;
|
|
2458
|
+
const d = new Date(local);
|
|
2459
|
+
const months = d.getUTCFullYear() * 12 + d.getUTCMonth();
|
|
2460
|
+
const start = months - months % n;
|
|
2461
|
+
return Date.UTC(Math.floor(start / 12), start % 12, 1) - offset;
|
|
2462
|
+
}
|
|
2463
|
+
if (unit === "W") {
|
|
2464
|
+
const days = Math.floor(local / DAY_MS);
|
|
2465
|
+
const sinceMonday = ((days + 3) % 7 + 7) % 7;
|
|
2466
|
+
return (days - sinceMonday) * DAY_MS - offset;
|
|
2467
|
+
}
|
|
2468
|
+
const size = resolutionToMs(resolution);
|
|
2469
|
+
return Math.floor(local / size) * size - offset;
|
|
2470
|
+
}
|
|
2471
|
+
var TickAggregator = class {
|
|
2472
|
+
constructor(resolution, timeZone = "UTC") {
|
|
2473
|
+
this.resolution = resolution;
|
|
2474
|
+
this.timeZone = timeZone;
|
|
2475
|
+
}
|
|
2476
|
+
resolution;
|
|
2477
|
+
timeZone;
|
|
2478
|
+
current = null;
|
|
2479
|
+
/** Start from the latest historical bar so the first tick updates it instead of opening a new one. */
|
|
2480
|
+
seed(bar) {
|
|
2481
|
+
this.current = bar ? { ...bar, volume: bar.volume ?? 0 } : null;
|
|
2482
|
+
}
|
|
2483
|
+
/** Returns the updated bar, or null for a tick belonging to an already-closed bar. */
|
|
2484
|
+
push(tick) {
|
|
2485
|
+
const start = bucketStart(tick.time, this.resolution, this.timeZone);
|
|
2486
|
+
const bar = this.current;
|
|
2487
|
+
const volume = tick.volume ?? 0;
|
|
2488
|
+
if (!bar || start > bar.time) {
|
|
2489
|
+
this.current = { time: start, open: tick.price, high: tick.price, low: tick.price, close: tick.price, volume };
|
|
2490
|
+
} else if (start === bar.time) {
|
|
2491
|
+
bar.high = Math.max(bar.high, tick.price);
|
|
2492
|
+
bar.low = Math.min(bar.low, tick.price);
|
|
2493
|
+
bar.close = tick.price;
|
|
2494
|
+
bar.volume = (bar.volume ?? 0) + volume;
|
|
2495
|
+
} else {
|
|
2496
|
+
return null;
|
|
2497
|
+
}
|
|
2498
|
+
return { ...this.current };
|
|
2499
|
+
}
|
|
2500
|
+
};
|
|
2501
|
+
|
|
2502
|
+
// src/session.ts
|
|
2503
|
+
var DAY = 864e5;
|
|
2504
|
+
var MINUTE2 = 6e4;
|
|
2505
|
+
function sessionStatus(session, timeZone, now = Date.now()) {
|
|
2506
|
+
if (session.trim().toLowerCase() === "24x7") return { open: true, nextChange: null };
|
|
2507
|
+
const match = /^(\d{2})(\d{2})-(\d{2})(\d{2})(?::([1-7]+))?$/.exec(session.trim());
|
|
2508
|
+
if (!match) return { open: true, nextChange: null };
|
|
2509
|
+
const openMin = Number(match[1]) * 60 + Number(match[2]);
|
|
2510
|
+
const closeMin = Number(match[3]) * 60 + Number(match[4]);
|
|
2511
|
+
const days = new Set((match[5] ?? "12345").split("").map(Number));
|
|
2512
|
+
const offset = timeZoneOffset(now, timeZone);
|
|
2513
|
+
const localMidnight = Math.floor((now + offset) / DAY) * DAY;
|
|
2514
|
+
for (let d = -1; d <= 8; d++) {
|
|
2515
|
+
const dayStart = localMidnight + d * DAY;
|
|
2516
|
+
const isoWeekday = (new Date(dayStart).getUTCDay() + 6) % 7 + 1;
|
|
2517
|
+
if (!days.has(isoWeekday)) continue;
|
|
2518
|
+
const open = dayStart + openMin * MINUTE2 - offset;
|
|
2519
|
+
const close = dayStart + closeMin * MINUTE2 - offset;
|
|
2520
|
+
if (now < open) return { open: false, nextChange: open };
|
|
2521
|
+
if (now < close) return { open: true, nextChange: close };
|
|
2522
|
+
}
|
|
2523
|
+
return { open: false, nextChange: null };
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2296
2526
|
// src/theme.ts
|
|
2297
2527
|
var FONT_STACK = "Pretendard, -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Segoe UI', Roboto, sans-serif";
|
|
2298
2528
|
var DIRECTION_COLORS = {
|
|
@@ -2339,10 +2569,31 @@ var LABELS = {
|
|
|
2339
2569
|
en: { high: "High", low: "Low" }
|
|
2340
2570
|
};
|
|
2341
2571
|
var TRADING_LABELS = {
|
|
2342
|
-
ko: { buy: "\uB9E4\uC218", sell: "\uB9E4\uB3C4", position: "\uD3C9\uADE0\uB2E8\uAC00" },
|
|
2343
|
-
en: { buy: "Buy", sell: "Sell", position: "Avg" }
|
|
2572
|
+
ko: { buy: "\uB9E4\uC218", sell: "\uB9E4\uB3C4", position: "\uD3C9\uADE0\uB2E8\uAC00", alert: "\uC54C\uB9BC" },
|
|
2573
|
+
en: { buy: "Buy", sell: "Sell", position: "Avg", alert: "Alert" }
|
|
2344
2574
|
};
|
|
2345
2575
|
var HIT_TOLERANCE = { mouse: 5, touch: 14 };
|
|
2576
|
+
var COMPARE_COLORS = ["#F06595", "#15AABF", "#FF9F0A", "#7048E8", "#12B886"];
|
|
2577
|
+
var MAX_COMPARES = COMPARE_COLORS.length;
|
|
2578
|
+
var HISTORY_LIMIT = 100;
|
|
2579
|
+
function formatCountdown(ms) {
|
|
2580
|
+
const total = Math.max(0, Math.floor(ms / 1e3));
|
|
2581
|
+
const d = Math.floor(total / 86400);
|
|
2582
|
+
const h = Math.floor(total % 86400 / 3600);
|
|
2583
|
+
const m = Math.floor(total % 3600 / 60);
|
|
2584
|
+
const s = total % 60;
|
|
2585
|
+
const two = (n) => String(n).padStart(2, "0");
|
|
2586
|
+
if (d > 0) return `${d}d ${two(h)}:${two(m)}`;
|
|
2587
|
+
return h > 0 ? `${h}:${two(m)}:${two(s)}` : `${two(m)}:${two(s)}`;
|
|
2588
|
+
}
|
|
2589
|
+
function formatPercent(v, decimals) {
|
|
2590
|
+
const rounded = Number(v.toFixed(decimals));
|
|
2591
|
+
return `${rounded > 0 ? "+" : ""}${rounded.toFixed(decimals)}%`;
|
|
2592
|
+
}
|
|
2593
|
+
function firstFinite(values, from) {
|
|
2594
|
+
for (let i = Math.max(0, from); i < values.length; i++) if (!Number.isNaN(values[i])) return values[i];
|
|
2595
|
+
return Number.NaN;
|
|
2596
|
+
}
|
|
2346
2597
|
var TOOLTIP_LABELS = {
|
|
2347
2598
|
ko: { buy: "\uB9E4\uC218", sell: "\uB9E4\uB3C4", fills: (n) => `${n}\uAC74`, price: "\uCCB4\uACB0\uAC00", avg: "\uD3C9\uADE0\uAC00", qty: "\uC218\uB7C9" },
|
|
2348
2599
|
en: { buy: "Buy", sell: "Sell", fills: (n) => `${n} fills`, price: "Price", avg: "Avg", qty: "Qty" }
|
|
@@ -2364,7 +2615,7 @@ var Chart = class {
|
|
|
2364
2615
|
store = new BarStore();
|
|
2365
2616
|
mainScale = new PriceScale({ top: 0.08, bottom: 0.08 });
|
|
2366
2617
|
volumeScale = new PriceScale({ top: 0.2, bottom: 0 });
|
|
2367
|
-
indicators
|
|
2618
|
+
indicators;
|
|
2368
2619
|
indicatorScales = /* @__PURE__ */ new Map();
|
|
2369
2620
|
emitter = new Emitter();
|
|
2370
2621
|
colors;
|
|
@@ -2416,6 +2667,15 @@ var Chart = class {
|
|
|
2416
2667
|
loadingHistory = false;
|
|
2417
2668
|
noMoreHistory = false;
|
|
2418
2669
|
historyRequestedFor = Number.NaN;
|
|
2670
|
+
countdownLabel = null;
|
|
2671
|
+
/** Undo/redo of drawings + indicators, as JSON snapshots. */
|
|
2672
|
+
history = { undo: [], redo: [], current: "", applying: false };
|
|
2673
|
+
compares = /* @__PURE__ */ new Map();
|
|
2674
|
+
compareVersion = 0;
|
|
2675
|
+
/** Base bar index of the % axis and compare lines (the first visible bar). */
|
|
2676
|
+
percentBaseIndex = 0;
|
|
2677
|
+
replayState = null;
|
|
2678
|
+
replaySelecting = false;
|
|
2419
2679
|
constructor(container, options = {}) {
|
|
2420
2680
|
this.theme = { ...lightTheme, ...options.theme };
|
|
2421
2681
|
this.locale = options.locale ?? "ko";
|
|
@@ -2446,18 +2706,21 @@ var Chart = class {
|
|
|
2446
2706
|
this.root.appendChild(probe);
|
|
2447
2707
|
container.appendChild(this.root);
|
|
2448
2708
|
this.colors = new ColorResolver(probe);
|
|
2709
|
+
this.indicators = new IndicatorManager(indicatorRegistry(options.indicatorDefinitions));
|
|
2449
2710
|
this.drawings = new DrawingManager((change) => {
|
|
2450
2711
|
this.invalidate();
|
|
2451
2712
|
if (change === "commit") {
|
|
2452
2713
|
this.emitter.emit("drawingsChange", this.getDrawings());
|
|
2453
2714
|
this.stateChanged();
|
|
2715
|
+
this.recordHistory();
|
|
2454
2716
|
}
|
|
2455
2717
|
if (change === "tool") this.emitter.emit("drawingToolChange", this.drawings.tool);
|
|
2456
2718
|
if (change !== "preview") {
|
|
2457
2719
|
const d = this.drawings.selected;
|
|
2458
2720
|
this.emitter.emit("selectionChange", d ? { ...d, points: d.points.map((p) => ({ ...p })) } : null);
|
|
2459
2721
|
}
|
|
2460
|
-
});
|
|
2722
|
+
}, drawingRegistry(options.drawingDefinitions));
|
|
2723
|
+
this.history.current = this.historySnapshot();
|
|
2461
2724
|
this.trading = new TradingLayer(() => this.invalidate());
|
|
2462
2725
|
this.tradingSync = new TradingSync(this.trading, (error) => this.emitter.emit("error", error));
|
|
2463
2726
|
this.tradingSync.adapter = options.trading ?? null;
|
|
@@ -2467,10 +2730,12 @@ var Chart = class {
|
|
|
2467
2730
|
this.observeSize();
|
|
2468
2731
|
this.observeThemeVariables();
|
|
2469
2732
|
this.observeKeyboard();
|
|
2733
|
+
this.observeCountdown();
|
|
2470
2734
|
}
|
|
2471
2735
|
// ── Public API ──────────────────────────────────────────────────────
|
|
2472
2736
|
/** Replaces all bars and scrolls to the latest. */
|
|
2473
2737
|
setData(bars) {
|
|
2738
|
+
this.endReplay(false);
|
|
2474
2739
|
this.store.set(bars);
|
|
2475
2740
|
this.timeScale.barCount = this.store.length;
|
|
2476
2741
|
this.timeScale.rightOffset = this.timeScale.options.rightOffset;
|
|
@@ -2483,6 +2748,13 @@ var Chart = class {
|
|
|
2483
2748
|
}
|
|
2484
2749
|
/** Realtime tick: same time as the last bar updates it, a newer time appends a bar. */
|
|
2485
2750
|
update(bar) {
|
|
2751
|
+
const replay = this.replayState;
|
|
2752
|
+
if (replay && (replay.future.length > 0 || bar.time > (this.store.last()?.time ?? -Infinity))) {
|
|
2753
|
+
const tail = replay.future[replay.future.length - 1];
|
|
2754
|
+
if (tail && tail.time === bar.time) replay.future[replay.future.length - 1] = bar;
|
|
2755
|
+
else if (!tail || bar.time > tail.time) replay.future.push(bar);
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2486
2758
|
const result = this.store.upsert(bar);
|
|
2487
2759
|
if (result === "ignored") return;
|
|
2488
2760
|
if (result === "append") {
|
|
@@ -2511,6 +2783,7 @@ var Chart = class {
|
|
|
2511
2783
|
const feed = this.datafeed;
|
|
2512
2784
|
if (!feed) throw new Error("funnest-charts: setSymbol() requires the `datafeed` option");
|
|
2513
2785
|
const token = ++this.loadToken;
|
|
2786
|
+
this.endReplay(false);
|
|
2514
2787
|
this.unsubscribeBars?.();
|
|
2515
2788
|
this.unsubscribeBars = null;
|
|
2516
2789
|
const keepQuotes = this.unsubscribeQuotes !== null && this.quoteSymbol === symbol;
|
|
@@ -2576,6 +2849,7 @@ var Chart = class {
|
|
|
2576
2849
|
this.emitter.emit("quoteChange", this.latestQuote);
|
|
2577
2850
|
}) ?? null;
|
|
2578
2851
|
}
|
|
2852
|
+
for (const entry of this.compares.values()) void this.reloadCompare(entry);
|
|
2579
2853
|
this.emitter.emit("symbolChange", info);
|
|
2580
2854
|
this.stateChanged();
|
|
2581
2855
|
}
|
|
@@ -2704,7 +2978,7 @@ var Chart = class {
|
|
|
2704
2978
|
*/
|
|
2705
2979
|
async resync(token) {
|
|
2706
2980
|
const feed = this.datafeed;
|
|
2707
|
-
if (!feed || !this.symbol || !this.resolution) return;
|
|
2981
|
+
if (!feed || !this.symbol || !this.resolution || this.replayState) return;
|
|
2708
2982
|
try {
|
|
2709
2983
|
const to = Date.now();
|
|
2710
2984
|
const result = await feed.getBars(this.symbol, this.resolution, {
|
|
@@ -2760,7 +3034,8 @@ var Chart = class {
|
|
|
2760
3034
|
settings: this.settings,
|
|
2761
3035
|
indicators: this.getIndicators(),
|
|
2762
3036
|
drawings: JSON.parse(JSON.stringify(drawings)),
|
|
2763
|
-
view: { barSpacing: this.timeScale.barSpacing, rightOffset: this.timeScale.rightOffset }
|
|
3037
|
+
view: { barSpacing: this.timeScale.barSpacing, rightOffset: this.timeScale.rightOffset },
|
|
3038
|
+
compares: [...this.compares.keys()]
|
|
2764
3039
|
};
|
|
2765
3040
|
}
|
|
2766
3041
|
/**
|
|
@@ -2783,6 +3058,10 @@ var Chart = class {
|
|
|
2783
3058
|
this.timeScale.barSpacing = state.view.barSpacing;
|
|
2784
3059
|
this.timeScale.rightOffset = state.view.rightOffset;
|
|
2785
3060
|
this.timeScale.clamp();
|
|
3061
|
+
const compares = state.compares ?? [];
|
|
3062
|
+
for (const symbol of [...this.compares.keys()]) if (!compares.includes(symbol)) this.removeCompare(symbol);
|
|
3063
|
+
if (this.datafeed) for (const symbol of compares) void this.addCompare(symbol);
|
|
3064
|
+
this.resetHistory();
|
|
2786
3065
|
this.invalidate();
|
|
2787
3066
|
this.stateChanged();
|
|
2788
3067
|
}
|
|
@@ -2791,6 +3070,7 @@ var Chart = class {
|
|
|
2791
3070
|
const list = this.drawingsBySymbol.get(symbol) ?? [];
|
|
2792
3071
|
this.drawingsBySymbol.delete(symbol);
|
|
2793
3072
|
this.drawings.set(list);
|
|
3073
|
+
this.resetHistory();
|
|
2794
3074
|
}
|
|
2795
3075
|
stateChanged() {
|
|
2796
3076
|
if (this.stateQueued) return;
|
|
@@ -2870,7 +3150,7 @@ var Chart = class {
|
|
|
2870
3150
|
/** Formatted indicator values at a bar index (default: the last bar), for legends. */
|
|
2871
3151
|
indicatorLegend(index = this.store.length - 1) {
|
|
2872
3152
|
return this.indicators.list.map((cfg) => {
|
|
2873
|
-
const def =
|
|
3153
|
+
const def = this.indicators.def(cfg.type);
|
|
2874
3154
|
const output = this.indicators.output(cfg, this.store.bars, this.dataVersion);
|
|
2875
3155
|
const precision = output.precision ?? this.pricePrecision;
|
|
2876
3156
|
return {
|
|
@@ -2880,7 +3160,7 @@ var Chart = class {
|
|
|
2880
3160
|
overlay: def.overlay,
|
|
2881
3161
|
visible: cfg.visible !== false,
|
|
2882
3162
|
values: output.series.filter((series) => series.visible).map((series) => {
|
|
2883
|
-
const v = series.values[index];
|
|
3163
|
+
const v = series.values[index - (series.shift ?? 0)];
|
|
2884
3164
|
return {
|
|
2885
3165
|
key: series.key,
|
|
2886
3166
|
label: series.label,
|
|
@@ -2895,6 +3175,347 @@ var Chart = class {
|
|
|
2895
3175
|
this.invalidate();
|
|
2896
3176
|
this.emitter.emit("indicatorsChange", this.getIndicators());
|
|
2897
3177
|
this.stateChanged();
|
|
3178
|
+
this.recordHistory();
|
|
3179
|
+
}
|
|
3180
|
+
/** Built-in and custom indicators this chart can add. */
|
|
3181
|
+
indicatorDefinitions() {
|
|
3182
|
+
return [...this.indicators.registry.values()];
|
|
3183
|
+
}
|
|
3184
|
+
indicatorDefinition(type) {
|
|
3185
|
+
return this.indicators.registry.get(type);
|
|
3186
|
+
}
|
|
3187
|
+
/** Built-in and custom drawing tools, in drawing-bar order. */
|
|
3188
|
+
drawingDefinitions() {
|
|
3189
|
+
return [...this.drawings.registry.values()];
|
|
3190
|
+
}
|
|
3191
|
+
// ── Undo / redo ────────────────────────────────────────────────────
|
|
3192
|
+
get canUndo() {
|
|
3193
|
+
return this.history.undo.length > 0;
|
|
3194
|
+
}
|
|
3195
|
+
get canRedo() {
|
|
3196
|
+
return this.history.redo.length > 0;
|
|
3197
|
+
}
|
|
3198
|
+
/** Reverts the last drawing or indicator change. */
|
|
3199
|
+
undo() {
|
|
3200
|
+
const previous = this.history.undo.pop();
|
|
3201
|
+
if (previous === void 0) return;
|
|
3202
|
+
this.history.redo.push(this.history.current);
|
|
3203
|
+
this.applyHistory(previous);
|
|
3204
|
+
}
|
|
3205
|
+
redo() {
|
|
3206
|
+
const next = this.history.redo.pop();
|
|
3207
|
+
if (next === void 0) return;
|
|
3208
|
+
this.history.undo.push(this.history.current);
|
|
3209
|
+
this.applyHistory(next);
|
|
3210
|
+
}
|
|
3211
|
+
historySnapshot() {
|
|
3212
|
+
return JSON.stringify([this.drawings.drawings, this.indicators.list]);
|
|
3213
|
+
}
|
|
3214
|
+
recordHistory() {
|
|
3215
|
+
const h = this.history;
|
|
3216
|
+
if (h.applying) return;
|
|
3217
|
+
const next = this.historySnapshot();
|
|
3218
|
+
if (next === h.current) return;
|
|
3219
|
+
h.undo.push(h.current);
|
|
3220
|
+
if (h.undo.length > HISTORY_LIMIT) h.undo.shift();
|
|
3221
|
+
h.redo = [];
|
|
3222
|
+
h.current = next;
|
|
3223
|
+
this.emitHistory();
|
|
3224
|
+
}
|
|
3225
|
+
resetHistory() {
|
|
3226
|
+
this.history.undo = [];
|
|
3227
|
+
this.history.redo = [];
|
|
3228
|
+
this.history.current = this.historySnapshot();
|
|
3229
|
+
this.emitHistory();
|
|
3230
|
+
}
|
|
3231
|
+
applyHistory(snapshot) {
|
|
3232
|
+
const [drawings, indicators] = JSON.parse(snapshot);
|
|
3233
|
+
this.history.applying = true;
|
|
3234
|
+
try {
|
|
3235
|
+
this.drawings.set(drawings);
|
|
3236
|
+
this.setIndicators(indicators);
|
|
3237
|
+
} finally {
|
|
3238
|
+
this.history.applying = false;
|
|
3239
|
+
}
|
|
3240
|
+
this.history.current = snapshot;
|
|
3241
|
+
this.emitHistory();
|
|
3242
|
+
}
|
|
3243
|
+
emitHistory() {
|
|
3244
|
+
this.emitter.emit("historyChange", { canUndo: this.canUndo, canRedo: this.canRedo });
|
|
3245
|
+
}
|
|
3246
|
+
// ── Compare ────────────────────────────────────────────────────────
|
|
3247
|
+
/**
|
|
3248
|
+
* Overlays another symbol's closes as a line, rebased to the first visible bar. The price
|
|
3249
|
+
* axis switches to % while any symbol is compared. Needs the `datafeed` option.
|
|
3250
|
+
*/
|
|
3251
|
+
async addCompare(symbol) {
|
|
3252
|
+
if (!this.datafeed) throw new Error("funnest-charts: addCompare() requires the `datafeed` option");
|
|
3253
|
+
if (this.compares.has(symbol) || this.compares.size >= MAX_COMPARES || symbol === this.symbol?.symbol) return;
|
|
3254
|
+
const used = new Set([...this.compares.values()].map((e) => e.color));
|
|
3255
|
+
const entry = {
|
|
3256
|
+
symbol,
|
|
3257
|
+
info: null,
|
|
3258
|
+
store: new BarStore(),
|
|
3259
|
+
color: COMPARE_COLORS.find((c) => !used.has(c)) ?? COMPARE_COLORS[0],
|
|
3260
|
+
unsubscribe: null,
|
|
3261
|
+
loadingOlder: false,
|
|
3262
|
+
noMoreOlder: false,
|
|
3263
|
+
aligned: null
|
|
3264
|
+
};
|
|
3265
|
+
this.compares.set(symbol, entry);
|
|
3266
|
+
this.comparesChanged();
|
|
3267
|
+
await this.reloadCompare(entry);
|
|
3268
|
+
}
|
|
3269
|
+
removeCompare(symbol) {
|
|
3270
|
+
const entry = this.compares.get(symbol);
|
|
3271
|
+
if (!entry) return;
|
|
3272
|
+
entry.unsubscribe?.();
|
|
3273
|
+
this.compares.delete(symbol);
|
|
3274
|
+
this.comparesChanged();
|
|
3275
|
+
}
|
|
3276
|
+
getCompares() {
|
|
3277
|
+
return [...this.compares.values()].map((e) => ({
|
|
3278
|
+
symbol: e.symbol,
|
|
3279
|
+
name: e.info?.name ?? e.symbol,
|
|
3280
|
+
color: e.color,
|
|
3281
|
+
ready: e.store.length > 0
|
|
3282
|
+
}));
|
|
3283
|
+
}
|
|
3284
|
+
/** Each compared symbol's % change from the first visible bar, at `index` (default: the last bar). */
|
|
3285
|
+
compareLegend(index = this.store.length - 1) {
|
|
3286
|
+
return this.getCompares().map((info) => {
|
|
3287
|
+
const values = this.alignedCompare(this.compares.get(info.symbol));
|
|
3288
|
+
const base = firstFinite(values, this.percentBaseIndex);
|
|
3289
|
+
const v = values[index];
|
|
3290
|
+
return { ...info, value: v === void 0 || Number.isNaN(v) || !base ? "\u2014" : formatPercent((v / base - 1) * 100, 2) };
|
|
3291
|
+
});
|
|
3292
|
+
}
|
|
3293
|
+
comparesChanged() {
|
|
3294
|
+
this.compareVersion++;
|
|
3295
|
+
this.invalidate();
|
|
3296
|
+
this.emitter.emit("comparesChange", this.getCompares());
|
|
3297
|
+
this.stateChanged();
|
|
3298
|
+
}
|
|
3299
|
+
/** (Re)loads a compared symbol's bars at the current resolution, back to the main series' first bar. */
|
|
3300
|
+
async reloadCompare(entry) {
|
|
3301
|
+
const feed = this.datafeed;
|
|
3302
|
+
const resolution = this.resolution;
|
|
3303
|
+
if (!feed || !resolution) return;
|
|
3304
|
+
const token = this.loadToken;
|
|
3305
|
+
const current = () => this.compares.get(entry.symbol) === entry && token === this.loadToken;
|
|
3306
|
+
entry.unsubscribe?.();
|
|
3307
|
+
entry.unsubscribe = null;
|
|
3308
|
+
try {
|
|
3309
|
+
const info = entry.info ?? await feed.resolveSymbol(entry.symbol);
|
|
3310
|
+
if (!current()) return;
|
|
3311
|
+
entry.info = info;
|
|
3312
|
+
const to = Date.now();
|
|
3313
|
+
const from = this.store.first()?.time ?? to - resolutionToMs(resolution) * this.historyBatchSize;
|
|
3314
|
+
const result = await feed.getBars(info, resolution, {
|
|
3315
|
+
from,
|
|
3316
|
+
to,
|
|
3317
|
+
countBack: Math.max(this.historyBatchSize, this.store.length + 1)
|
|
3318
|
+
});
|
|
3319
|
+
if (!current()) return;
|
|
3320
|
+
entry.store.set(result.bars);
|
|
3321
|
+
entry.noMoreOlder = result.noMoreData;
|
|
3322
|
+
entry.unsubscribe = feed.subscribeBars(
|
|
3323
|
+
info,
|
|
3324
|
+
resolution,
|
|
3325
|
+
(bar) => {
|
|
3326
|
+
if (!current()) return;
|
|
3327
|
+
entry.store.upsert(bar);
|
|
3328
|
+
this.compareVersion++;
|
|
3329
|
+
this.invalidate();
|
|
3330
|
+
},
|
|
3331
|
+
() => {
|
|
3332
|
+
}
|
|
3333
|
+
);
|
|
3334
|
+
this.comparesChanged();
|
|
3335
|
+
void this.loadOlderCompare(entry);
|
|
3336
|
+
} catch (error) {
|
|
3337
|
+
if (!current()) return;
|
|
3338
|
+
this.removeCompare(entry.symbol);
|
|
3339
|
+
this.emitter.emit("error", error);
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
/** Pages a compared symbol's history back until it covers the main series' first bar. */
|
|
3343
|
+
async loadOlderCompare(entry) {
|
|
3344
|
+
const feed = this.datafeed;
|
|
3345
|
+
const first = entry.store.first();
|
|
3346
|
+
const mainFirst = this.store.first();
|
|
3347
|
+
if (!feed || !entry.info || !this.resolution || !first || !mainFirst) return;
|
|
3348
|
+
if (entry.loadingOlder || entry.noMoreOlder || first.time <= mainFirst.time) return;
|
|
3349
|
+
const token = this.loadToken;
|
|
3350
|
+
entry.loadingOlder = true;
|
|
3351
|
+
try {
|
|
3352
|
+
const to = first.time - 1;
|
|
3353
|
+
const result = await feed.getBars(entry.info, this.resolution, { from: mainFirst.time, to, countBack: this.historyBatchSize });
|
|
3354
|
+
if (this.compares.get(entry.symbol) !== entry || token !== this.loadToken) return;
|
|
3355
|
+
entry.store.prepend(result.bars);
|
|
3356
|
+
entry.noMoreOlder = result.noMoreData || result.bars.length === 0;
|
|
3357
|
+
this.compareVersion++;
|
|
3358
|
+
this.invalidate();
|
|
3359
|
+
} catch (error) {
|
|
3360
|
+
this.emitter.emit("error", error);
|
|
3361
|
+
} finally {
|
|
3362
|
+
entry.loadingOlder = false;
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
3365
|
+
/** Compare closes at each main bar (the compare bar at or before its time). */
|
|
3366
|
+
alignedCompare(entry) {
|
|
3367
|
+
const key = `${this.dataVersion}:${this.compareVersion}`;
|
|
3368
|
+
if (entry.aligned?.key === key) return entry.aligned.values;
|
|
3369
|
+
const bars = this.store.bars;
|
|
3370
|
+
const other = entry.store.bars;
|
|
3371
|
+
const values = new Float64Array(bars.length).fill(Number.NaN);
|
|
3372
|
+
let j = -1;
|
|
3373
|
+
for (let i = 0; i < bars.length; i++) {
|
|
3374
|
+
const t = bars[i].time;
|
|
3375
|
+
while (j + 1 < other.length && other[j + 1].time <= t) j++;
|
|
3376
|
+
if (j >= 0) values[i] = other[j].close;
|
|
3377
|
+
}
|
|
3378
|
+
entry.aligned = { key, values };
|
|
3379
|
+
return values;
|
|
3380
|
+
}
|
|
3381
|
+
/** Compare lines in main-price space, each rebased so its first visible value sits on the main close there. */
|
|
3382
|
+
compareOverlays(baseIndex) {
|
|
3383
|
+
const mainBase = this.store.bars[baseIndex]?.close;
|
|
3384
|
+
if (mainBase === void 0) return [];
|
|
3385
|
+
const out = [];
|
|
3386
|
+
for (const entry of this.compares.values()) {
|
|
3387
|
+
if (entry.store.length === 0) continue;
|
|
3388
|
+
const aligned = this.alignedCompare(entry);
|
|
3389
|
+
const base = firstFinite(aligned, baseIndex);
|
|
3390
|
+
if (!base) continue;
|
|
3391
|
+
out.push({
|
|
3392
|
+
series: [
|
|
3393
|
+
{
|
|
3394
|
+
key: entry.symbol,
|
|
3395
|
+
label: entry.info?.name ?? entry.symbol,
|
|
3396
|
+
values: aligned.map((v) => mainBase * v / base),
|
|
3397
|
+
color: entry.color,
|
|
3398
|
+
width: 2,
|
|
3399
|
+
visible: true,
|
|
3400
|
+
kind: "line"
|
|
3401
|
+
}
|
|
3402
|
+
],
|
|
3403
|
+
precision: null
|
|
3404
|
+
});
|
|
3405
|
+
}
|
|
3406
|
+
return out;
|
|
3407
|
+
}
|
|
3408
|
+
// ── Replay ─────────────────────────────────────────────────────────
|
|
3409
|
+
get replay() {
|
|
3410
|
+
const r = this.replayState;
|
|
3411
|
+
return {
|
|
3412
|
+
selecting: this.replaySelecting,
|
|
3413
|
+
active: r !== null,
|
|
3414
|
+
playing: r?.timer != null,
|
|
3415
|
+
remaining: r?.future.length ?? 0,
|
|
3416
|
+
interval: r?.interval ?? 1e3
|
|
3417
|
+
};
|
|
3418
|
+
}
|
|
3419
|
+
/** Arms start picking: the next click on the chart starts a replay from that bar. Esc cancels. */
|
|
3420
|
+
setReplaySelecting(on) {
|
|
3421
|
+
if (on === this.replaySelecting) return;
|
|
3422
|
+
if (on && this.drawings.tool) this.drawings.setTool(null);
|
|
3423
|
+
this.replaySelecting = on;
|
|
3424
|
+
this.invalidate(false);
|
|
3425
|
+
this.emitReplay();
|
|
3426
|
+
}
|
|
3427
|
+
/** Hides every bar after `time` (UTC ms); reveal them with replayStep/replayPlay. */
|
|
3428
|
+
startReplay(time) {
|
|
3429
|
+
const all = this.replayState ? [...this.store.bars, ...this.replayState.future] : [...this.store.bars];
|
|
3430
|
+
let cut = -1;
|
|
3431
|
+
while (cut + 1 < all.length && all[cut + 1].time <= time) cut++;
|
|
3432
|
+
cut = Math.max(0, cut);
|
|
3433
|
+
if (cut >= all.length - 1) return;
|
|
3434
|
+
const interval = this.replayState?.interval ?? 1e3;
|
|
3435
|
+
this.stopReplayTimer();
|
|
3436
|
+
this.replaySelecting = false;
|
|
3437
|
+
this.replayState = { future: all.slice(cut + 1), timer: null, interval };
|
|
3438
|
+
this.store.set(all.slice(0, cut + 1));
|
|
3439
|
+
this.timeScale.barCount = this.store.length;
|
|
3440
|
+
this.timeScale.rightOffset = Math.max(this.timeScale.options.rightOffset, 10);
|
|
3441
|
+
this.timeScale.clamp();
|
|
3442
|
+
this.mainScale.autoScale = true;
|
|
3443
|
+
this.dataVersion++;
|
|
3444
|
+
this.invalidate();
|
|
3445
|
+
this.emitReplay();
|
|
3446
|
+
}
|
|
3447
|
+
/** Reveals the next `count` hidden bars. */
|
|
3448
|
+
replayStep(count = 1) {
|
|
3449
|
+
const r = this.replayState;
|
|
3450
|
+
if (!r) return;
|
|
3451
|
+
for (let k = 0; k < count && r.future.length > 0; k++) {
|
|
3452
|
+
this.store.upsert(r.future.shift());
|
|
3453
|
+
if (this.timeScale.rightOffset < 0) this.timeScale.rightOffset -= 1;
|
|
3454
|
+
}
|
|
3455
|
+
this.timeScale.barCount = this.store.length;
|
|
3456
|
+
this.dataVersion++;
|
|
3457
|
+
if (r.future.length === 0) this.stopReplayTimer();
|
|
3458
|
+
this.invalidate();
|
|
3459
|
+
this.emitReplay();
|
|
3460
|
+
}
|
|
3461
|
+
/** Plays one bar every `interval` ms (default: the last speed used, initially 1000). */
|
|
3462
|
+
replayPlay(interval) {
|
|
3463
|
+
const r = this.replayState;
|
|
3464
|
+
if (!r || r.future.length === 0) return;
|
|
3465
|
+
if (interval !== void 0) r.interval = Math.max(16, interval);
|
|
3466
|
+
if (r.timer) clearInterval(r.timer);
|
|
3467
|
+
r.timer = setInterval(() => this.replayStep(), r.interval);
|
|
3468
|
+
this.emitReplay();
|
|
3469
|
+
}
|
|
3470
|
+
/** Changes the playback speed (ms per bar), also while playing. */
|
|
3471
|
+
setReplayInterval(interval) {
|
|
3472
|
+
const r = this.replayState;
|
|
3473
|
+
if (!r) return;
|
|
3474
|
+
r.interval = Math.max(16, interval);
|
|
3475
|
+
if (r.timer) {
|
|
3476
|
+
clearInterval(r.timer);
|
|
3477
|
+
r.timer = setInterval(() => this.replayStep(), r.interval);
|
|
3478
|
+
}
|
|
3479
|
+
this.emitReplay();
|
|
3480
|
+
}
|
|
3481
|
+
replayPause() {
|
|
3482
|
+
this.stopReplayTimer();
|
|
3483
|
+
this.emitReplay();
|
|
3484
|
+
}
|
|
3485
|
+
/** Leaves replay and shows every bar again (including live ones that arrived meanwhile). */
|
|
3486
|
+
stopReplay() {
|
|
3487
|
+
this.endReplay(true);
|
|
3488
|
+
}
|
|
3489
|
+
endReplay(restore) {
|
|
3490
|
+
const r = this.replayState;
|
|
3491
|
+
const wasSelecting = this.replaySelecting;
|
|
3492
|
+
this.replaySelecting = false;
|
|
3493
|
+
if (!r) {
|
|
3494
|
+
if (wasSelecting) {
|
|
3495
|
+
this.invalidate(false);
|
|
3496
|
+
this.emitReplay();
|
|
3497
|
+
}
|
|
3498
|
+
return;
|
|
3499
|
+
}
|
|
3500
|
+
this.stopReplayTimer();
|
|
3501
|
+
this.replayState = null;
|
|
3502
|
+
if (restore) {
|
|
3503
|
+
for (const bar of r.future) this.store.upsert(bar);
|
|
3504
|
+
this.timeScale.barCount = this.store.length;
|
|
3505
|
+
this.timeScale.rightOffset = this.timeScale.options.rightOffset;
|
|
3506
|
+
this.timeScale.clamp();
|
|
3507
|
+
this.dataVersion++;
|
|
3508
|
+
this.invalidate();
|
|
3509
|
+
}
|
|
3510
|
+
this.emitReplay();
|
|
3511
|
+
}
|
|
3512
|
+
stopReplayTimer() {
|
|
3513
|
+
const r = this.replayState;
|
|
3514
|
+
if (r?.timer) clearInterval(r.timer);
|
|
3515
|
+
if (r) r.timer = null;
|
|
3516
|
+
}
|
|
3517
|
+
emitReplay() {
|
|
3518
|
+
this.emitter.emit("replayChange", this.replay);
|
|
2898
3519
|
}
|
|
2899
3520
|
indicatorScale(id) {
|
|
2900
3521
|
let scale = this.indicatorScales.get(id);
|
|
@@ -2919,6 +3540,10 @@ var Chart = class {
|
|
|
2919
3540
|
addOrderLine(options) {
|
|
2920
3541
|
return this.trading.addOrder(options);
|
|
2921
3542
|
}
|
|
3543
|
+
/** Price alert line (dotted, gray, "알림" label). Pass `onMove` to make it draggable and `onCancel` for an ×. */
|
|
3544
|
+
addAlertLine(options) {
|
|
3545
|
+
return this.trading.addAlert(options);
|
|
3546
|
+
}
|
|
2922
3547
|
/** Fill markers (▲ buy under the bar, ▼ sell above). Replaces the previous set. */
|
|
2923
3548
|
setExecutions(markers) {
|
|
2924
3549
|
this.trading.setExecutions(markers);
|
|
@@ -2976,6 +3601,8 @@ var Chart = class {
|
|
|
2976
3601
|
this.loadToken++;
|
|
2977
3602
|
this.unsubscribeBars?.();
|
|
2978
3603
|
this.unsubscribeQuotes?.();
|
|
3604
|
+
for (const entry of this.compares.values()) entry.unsubscribe?.();
|
|
3605
|
+
this.stopReplayTimer();
|
|
2979
3606
|
this.tradingSync.detach();
|
|
2980
3607
|
if (this.rafId) cancelAnimationFrame(this.rafId);
|
|
2981
3608
|
this.interaction.destroy();
|
|
@@ -3013,6 +3640,7 @@ var Chart = class {
|
|
|
3013
3640
|
formatCrosshairTime: (t) => this.formatter.crosshairLabel(t, this.isIntraday()),
|
|
3014
3641
|
labels: LABELS[this.locale],
|
|
3015
3642
|
chartType: this.chartType,
|
|
3643
|
+
replaySelecting: this.replaySelecting,
|
|
3016
3644
|
objects: {
|
|
3017
3645
|
coords: this.paneCoords(),
|
|
3018
3646
|
drawings: this.drawings,
|
|
@@ -3038,6 +3666,7 @@ var Chart = class {
|
|
|
3038
3666
|
plotWidth: ts.width,
|
|
3039
3667
|
height: scale.height,
|
|
3040
3668
|
timeToX: (time) => ts.indexToX(this.store.indexFloatOfTime(time, interval)),
|
|
3669
|
+
timeToIndex: (time) => this.store.indexFloatOfTime(time, interval),
|
|
3041
3670
|
pointAt: (x, y) => {
|
|
3042
3671
|
const index = Math.round(ts.xToIndexFloat(x));
|
|
3043
3672
|
const bar = this.magnet ? this.store.at(index) : void 0;
|
|
@@ -3050,7 +3679,8 @@ var Chart = class {
|
|
|
3050
3679
|
priceToY: (p) => scale.priceToY(p),
|
|
3051
3680
|
yToPrice: (y) => scale.yToPrice(y),
|
|
3052
3681
|
snapPrice: (p) => roundToTick(p, this.tickSize),
|
|
3053
|
-
|
|
3682
|
+
// Drawing and order badges follow the axis (a % axis labels them in %).
|
|
3683
|
+
formatPrice: this.mainScale.mode === "percent" ? this.mainValueFormat() : (p) => formatPrice(p, this.pricePrecision)
|
|
3054
3684
|
};
|
|
3055
3685
|
}
|
|
3056
3686
|
computeFrame() {
|
|
@@ -3074,6 +3704,13 @@ var Chart = class {
|
|
|
3074
3704
|
return fill && shown(fill.upper) && shown(fill.lower) ? { ...out, series, fill } : { ...out, series };
|
|
3075
3705
|
};
|
|
3076
3706
|
const overlays = this.indicators.overlays.map((config) => ({ config, output: drawable(config) }));
|
|
3707
|
+
const mode = this.compares.size > 0 ? "percent" : this.settingsValue.priceScale;
|
|
3708
|
+
if (this.mainScale.mode !== mode) {
|
|
3709
|
+
this.mainScale.mode = mode;
|
|
3710
|
+
this.mainScale.autoScale = true;
|
|
3711
|
+
}
|
|
3712
|
+
let compareOverlays = [];
|
|
3713
|
+
this.countdownLabel = this.countdownText();
|
|
3077
3714
|
const subPanes = indicatorPanes.map((config) => {
|
|
3078
3715
|
const scale = this.indicatorScale(config.id);
|
|
3079
3716
|
scale.height = indicatorHeight;
|
|
@@ -3100,15 +3737,11 @@ var Chart = class {
|
|
|
3100
3737
|
this.timeScale.width = Math.max(0, width - this.priceAxisWidth);
|
|
3101
3738
|
this.timeScale.clamp();
|
|
3102
3739
|
range = this.timeScale.visibleRange();
|
|
3103
|
-
|
|
3740
|
+
this.percentBaseIndex = Math.max(0, range.from);
|
|
3741
|
+
compareOverlays = this.compareOverlays(this.percentBaseIndex);
|
|
3742
|
+
highLow = this.autoScale(range, [...overlays.map((o) => o.output), ...compareOverlays]);
|
|
3104
3743
|
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
|
-
);
|
|
3744
|
+
priceTicks = this.mainTicks(mainHeight);
|
|
3112
3745
|
subTicks = subPanes.map((p) => {
|
|
3113
3746
|
const [lo, hi] = p.output.range ?? [p.scale.yToPrice(indicatorHeight), p.scale.yToPrice(0)];
|
|
3114
3747
|
return niceTicks(lo, hi, Math.max(1, Math.floor(indicatorHeight / 36)));
|
|
@@ -3116,7 +3749,10 @@ var Chart = class {
|
|
|
3116
3749
|
let labelWidth = 0;
|
|
3117
3750
|
const measure = (text) => labelWidth = Math.max(labelWidth, ctx.measureText(text).width);
|
|
3118
3751
|
const last = this.store.last();
|
|
3119
|
-
|
|
3752
|
+
const tickLabel = this.mainTickFormat(priceTicks);
|
|
3753
|
+
for (const v of priceTicks) measure(tickLabel(v));
|
|
3754
|
+
if (last) measure(this.mainValueFormat()(last.close));
|
|
3755
|
+
if (this.countdownLabel) measure(this.countdownLabel);
|
|
3120
3756
|
subPanes.forEach((p, i) => subTicks[i].forEach((v) => measure(tickFormat(subTicks[i], p.output)(v))));
|
|
3121
3757
|
const needed = Math.max(MIN_PRICE_AXIS_WIDTH, Math.ceil((labelWidth + 20) / 8) * 8);
|
|
3122
3758
|
if (needed === this.priceAxisWidth) break;
|
|
@@ -3131,7 +3767,8 @@ var Chart = class {
|
|
|
3131
3767
|
height: mainHeight,
|
|
3132
3768
|
scale: this.mainScale,
|
|
3133
3769
|
ticks: priceTicks,
|
|
3134
|
-
format:
|
|
3770
|
+
format: this.mainValueFormat(),
|
|
3771
|
+
tickFormat: this.mainTickFormat(priceTicks)
|
|
3135
3772
|
}
|
|
3136
3773
|
];
|
|
3137
3774
|
let top = mainHeight;
|
|
@@ -3173,12 +3810,63 @@ var Chart = class {
|
|
|
3173
3810
|
timeTicks: computeTimeTicks(this.store.bars, this.timeScale, this.formatter, TIME_LABEL_MIN_GAP),
|
|
3174
3811
|
highLow,
|
|
3175
3812
|
settings: this.settingsValue,
|
|
3176
|
-
overlays: overlays.map((o) => o.output),
|
|
3813
|
+
overlays: [...overlays.map((o) => o.output), ...compareOverlays],
|
|
3177
3814
|
palette: this.resolvePalette(),
|
|
3178
3815
|
font,
|
|
3179
|
-
radius: this.theme.radius
|
|
3816
|
+
radius: this.theme.radius,
|
|
3817
|
+
countdown: this.countdownLabel
|
|
3180
3818
|
};
|
|
3181
3819
|
}
|
|
3820
|
+
/** Price-pane tick values for the current axis mode. */
|
|
3821
|
+
mainTicks(height) {
|
|
3822
|
+
const s = this.mainScale;
|
|
3823
|
+
const lo = s.yToPrice(height);
|
|
3824
|
+
const hi = s.yToPrice(0);
|
|
3825
|
+
const count = Math.max(2, Math.floor(height / PRICE_TICK_SPACING));
|
|
3826
|
+
const minStep = resolveTickSize(this.tickSize, (lo + hi) / 2);
|
|
3827
|
+
if (s.mode === "log") return logTicks(s, PRICE_TICK_SPACING, minStep);
|
|
3828
|
+
if (s.mode === "percent") {
|
|
3829
|
+
const base = this.percentBase();
|
|
3830
|
+
const pct = (p) => (p / base - 1) * 100;
|
|
3831
|
+
return niceTicks(pct(lo), pct(hi), count).map((t) => base * (1 + t / 100));
|
|
3832
|
+
}
|
|
3833
|
+
return niceTicks(lo, hi, count, minStep);
|
|
3834
|
+
}
|
|
3835
|
+
percentBase() {
|
|
3836
|
+
return this.store.at(this.percentBaseIndex)?.close || 1;
|
|
3837
|
+
}
|
|
3838
|
+
/** Price-axis tick labels: prices, or "+2.5%" on a % axis (decimals follow the step). */
|
|
3839
|
+
mainTickFormat(ticks) {
|
|
3840
|
+
if (this.mainScale.mode !== "percent") return (v) => formatPrice(v, this.pricePrecision);
|
|
3841
|
+
const base = this.percentBase();
|
|
3842
|
+
const step = ticks.length > 1 ? Math.abs(ticks[1] - ticks[0]) / base * 100 : 1;
|
|
3843
|
+
const decimals = Math.min(2, decimalsOf(Number(step.toPrecision(6))));
|
|
3844
|
+
return (v) => formatPercent((v / base - 1) * 100, decimals);
|
|
3845
|
+
}
|
|
3846
|
+
/** Crosshair and last-price label of the price pane. */
|
|
3847
|
+
mainValueFormat() {
|
|
3848
|
+
if (this.mainScale.mode !== "percent") return (v) => formatPrice(roundToTick(v, this.tickSize), this.pricePrecision);
|
|
3849
|
+
const base = this.percentBase();
|
|
3850
|
+
return (v) => formatPercent((v / base - 1) * 100, 2);
|
|
3851
|
+
}
|
|
3852
|
+
/** Time left in the last bar, when it's still forming (null otherwise). */
|
|
3853
|
+
countdownText(now = Date.now()) {
|
|
3854
|
+
const s = this.settingsValue;
|
|
3855
|
+
const last = this.store.last();
|
|
3856
|
+
const info = this.symbol;
|
|
3857
|
+
if (!s.countdown || !s.lastPriceLine || this.replayState || !this.resolution || !info || !last) return null;
|
|
3858
|
+
let end = null;
|
|
3859
|
+
if (isIntradayResolution(this.resolution)) {
|
|
3860
|
+
end = last.time + resolutionToMs(this.resolution);
|
|
3861
|
+
} else if (resolutionToMs(this.resolution) === DAY_MS) {
|
|
3862
|
+
if (info.session.trim().toLowerCase() === "24x7") end = last.time + DAY_MS;
|
|
3863
|
+
else {
|
|
3864
|
+
const status = sessionStatus(info.session, info.timezone, now);
|
|
3865
|
+
end = status.open && now - last.time < DAY_MS ? status.nextChange : null;
|
|
3866
|
+
}
|
|
3867
|
+
}
|
|
3868
|
+
return end !== null && end > now ? formatCountdown(end - now) : null;
|
|
3869
|
+
}
|
|
3182
3870
|
/** Auto-scales the price pane to visible candles and overlay indicators; returns the candle extremes. */
|
|
3183
3871
|
autoScale(range, overlays) {
|
|
3184
3872
|
const bars = this.store.bars;
|
|
@@ -3204,8 +3892,9 @@ var Chart = class {
|
|
|
3204
3892
|
let max = high;
|
|
3205
3893
|
for (const output of overlays) {
|
|
3206
3894
|
for (const series of output.series) {
|
|
3895
|
+
const shift = series.shift ?? 0;
|
|
3207
3896
|
for (let i = range.from; i <= range.to; i++) {
|
|
3208
|
-
const v = series.values[i];
|
|
3897
|
+
const v = series.values[i - shift];
|
|
3209
3898
|
if (v < min) min = v;
|
|
3210
3899
|
if (v > max) max = v;
|
|
3211
3900
|
}
|
|
@@ -3229,8 +3918,9 @@ var Chart = class {
|
|
|
3229
3918
|
min = Math.min(min, 0);
|
|
3230
3919
|
max = Math.max(max, 0);
|
|
3231
3920
|
}
|
|
3921
|
+
const shift = series.shift ?? 0;
|
|
3232
3922
|
for (let i = range.from; i <= range.to; i++) {
|
|
3233
|
-
const v = series.values[i];
|
|
3923
|
+
const v = series.values[i - shift];
|
|
3234
3924
|
if (v < min) min = v;
|
|
3235
3925
|
if (v > max) max = v;
|
|
3236
3926
|
}
|
|
@@ -3300,6 +3990,7 @@ var Chart = class {
|
|
|
3300
3990
|
this.prependData(result.bars, { noMoreData: result.noMoreData || result.bars.length === 0 });
|
|
3301
3991
|
const first = this.store.first();
|
|
3302
3992
|
if (first) void this.tradingSync.loadOlder(first.time);
|
|
3993
|
+
for (const entry of this.compares.values()) void this.loadOlderCompare(entry);
|
|
3303
3994
|
}).catch((error) => this.emitter.emit("error", error)).finally(() => {
|
|
3304
3995
|
if (token === this.loadToken) this.loadingHistory = false;
|
|
3305
3996
|
});
|
|
@@ -3406,15 +4097,26 @@ var Chart = class {
|
|
|
3406
4097
|
this.invalidate();
|
|
3407
4098
|
},
|
|
3408
4099
|
resetView: () => this.resetView(),
|
|
3409
|
-
toolActive: () => this.drawings.tool !== null && this.store.length > 0,
|
|
4100
|
+
toolActive: () => (this.drawings.tool !== null || this.replaySelecting) && this.store.length > 0,
|
|
3410
4101
|
hitObject: (x, y, touch) => {
|
|
3411
4102
|
if (y > this.mainScale.height) return null;
|
|
3412
4103
|
const tolerance = touch ? HIT_TOLERANCE.touch : HIT_TOLERANCE.mouse;
|
|
3413
4104
|
return this.trading.hitTest((p) => this.mainScale.priceToY(p), x, y, tolerance) ?? this.drawings.hitTest(this.paneCoords(), x, y, tolerance);
|
|
3414
4105
|
},
|
|
3415
|
-
drawAt: (x, y) =>
|
|
3416
|
-
|
|
3417
|
-
|
|
4106
|
+
drawAt: (x, y) => {
|
|
4107
|
+
if (this.replaySelecting) {
|
|
4108
|
+
const bars = this.store.bars;
|
|
4109
|
+
this.startReplay(bars[Math.min(bars.length - 1, Math.max(0, this.timeScale.xToIndex(x)))].time);
|
|
4110
|
+
return;
|
|
4111
|
+
}
|
|
4112
|
+
this.drawings.createAt(this.paneCoords(), x, y);
|
|
4113
|
+
},
|
|
4114
|
+
drawPreview: (x, y) => {
|
|
4115
|
+
if (!this.replaySelecting) this.drawings.previewAt(this.paneCoords(), x, y);
|
|
4116
|
+
},
|
|
4117
|
+
drawRelease: (x, y, moved) => {
|
|
4118
|
+
if (!this.replaySelecting) this.drawings.releaseAt(this.paneCoords(), x, y, moved);
|
|
4119
|
+
},
|
|
3418
4120
|
objectDown: (hit, x, y) => {
|
|
3419
4121
|
this.objectDrag = hit;
|
|
3420
4122
|
if (hit.kind === "drawing") this.drawings.beginDrag(this.paneCoords(), hit.id, hit.part, x, y);
|
|
@@ -3449,7 +4151,9 @@ var Chart = class {
|
|
|
3449
4151
|
const onKeyDown = (e) => {
|
|
3450
4152
|
const target = e.target;
|
|
3451
4153
|
if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
|
|
3452
|
-
if (e.key === "Escape" &&
|
|
4154
|
+
if (e.key === "Escape" && this.replaySelecting) {
|
|
4155
|
+
this.setReplaySelecting(false);
|
|
4156
|
+
} else if (e.key === "Escape" && (this.drawings.tool || this.drawings.selectedId)) {
|
|
3453
4157
|
this.drawings.cancel();
|
|
3454
4158
|
} else if ((e.key === "Delete" || e.key === "Backspace") && this.drawings.selectedId) {
|
|
3455
4159
|
e.preventDefault();
|
|
@@ -3460,6 +4164,14 @@ var Chart = class {
|
|
|
3460
4164
|
this.cleanups.push(() => document.removeEventListener("keydown", onKeyDown));
|
|
3461
4165
|
}
|
|
3462
4166
|
// ── Environment observers ──────────────────────────────────────────
|
|
4167
|
+
/** Repaints when the bar-close countdown ticks over (once a second, only while it shows or changes). */
|
|
4168
|
+
observeCountdown() {
|
|
4169
|
+
const timer = setInterval(() => {
|
|
4170
|
+
if (this.destroyed || document.hidden) return;
|
|
4171
|
+
if (this.countdownText() !== this.countdownLabel) this.invalidate();
|
|
4172
|
+
}, 1e3);
|
|
4173
|
+
this.cleanups.push(() => clearInterval(timer));
|
|
4174
|
+
}
|
|
3463
4175
|
observeSize() {
|
|
3464
4176
|
const apply = (width, height) => {
|
|
3465
4177
|
const dpr = window.devicePixelRatio || 1;
|
|
@@ -3510,103 +4222,6 @@ var Chart = class {
|
|
|
3510
4222
|
}
|
|
3511
4223
|
};
|
|
3512
4224
|
|
|
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
4225
|
// src/datafeed/socket.ts
|
|
3611
4226
|
var toConnectionStatus = (status) => ({ connecting: "connecting", open: "connected", reconnecting: "reconnecting", closed: "disconnected" })[status];
|
|
3612
4227
|
var decoder;
|
|
@@ -3798,6 +4413,7 @@ function combineDatafeeds(feeds) {
|
|
|
3798
4413
|
};
|
|
3799
4414
|
}
|
|
3800
4415
|
export {
|
|
4416
|
+
BUILTIN_DRAWINGS,
|
|
3801
4417
|
Chart,
|
|
3802
4418
|
DEFAULT_CHART_SETTINGS,
|
|
3803
4419
|
FIB_LEVELS,
|
|
@@ -3813,6 +4429,7 @@ export {
|
|
|
3813
4429
|
createTheme,
|
|
3814
4430
|
darkTheme,
|
|
3815
4431
|
decimalsOf,
|
|
4432
|
+
distToSegment,
|
|
3816
4433
|
ema,
|
|
3817
4434
|
fallbackResolution,
|
|
3818
4435
|
formatCompact,
|