@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/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
- return this.innerTop + (this.hi - price) / (this.hi - this.lo) * this.innerHeight;
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
- return this.hi - (y - this.innerTop) / this.innerHeight * (this.hi - this.lo);
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 center = (this.lo + this.hi) / 2;
580
- const half = (this.hi - this.lo) / 2 * factor;
581
- this.setRange(center - half, center + half);
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 delta = dy / this.innerHeight * (this.hi - this.lo);
587
- this.setRange(this.lo + delta, this.hi + delta);
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 normalize2(c) {
1088
- const def = INDICATORS[c.type];
1089
- const params = { ...def.defaults, ...c.params };
1090
- if ((c.type === "ma" || c.type === "ema") && !Array.isArray(c.params.lines) && Array.isArray(c.params.periods)) {
1091
- params.lines = maLines({ periods: c.params.periods });
1092
- }
1093
- delete params.periods;
1094
- return { id: c.id || newId(c.type), type: c.type, params, visible: c.visible ?? true, styles: c.styles ?? {} };
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
- if (!INDICATORS[type]) throw new Error(`funnest-charts: unknown indicator "${type}"`);
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) => INDICATORS[c.type]).map((c) => normalize2(clone(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) => INDICATORS[c.type].overlay);
1179
+ return this.list.filter((c) => this.def(c.type).overlay);
1128
1180
  }
1129
1181
  get panes() {
1130
- return this.list.filter((c) => !INDICATORS[c.type].overlay);
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 = INDICATORS[cfg.type].compute(bars, cfg.params);
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
- if (!this.tool) return;
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 count = DRAWING_POINTS[this.tool];
1225
- const drawing = { id: newId("d"), type: this.tool, points: Array.from({ length: count }, () => ({ ...point })) };
1226
- this.draft = { drawing, placed: 1 };
1227
- if (count === 1) this.finish();
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.drawing.points[this.draft.placed] = point;
1232
- this.draft.placed++;
1233
- if (this.draft.placed >= this.draft.drawing.points.length) this.finish();
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
- if (!this.draft) return;
1238
- const point = c.pointAt(x, y);
1239
- for (let i = this.draft.placed; i < this.draft.drawing.points.length; i++) this.draft.drawing.points[i] = { ...point };
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.screenPoints(c, selected).findIndex(
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
- if (this.distance(c, d, x, y) <= tolerance) return { kind: "drawing", id: d.id, part: "body" };
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.screenPoints(c, d) };
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 isDraft = d === this.draft?.drawing;
1315
- this.renderOne(rc, d, badges);
1316
- if (d.id === this.selectedId || isDraft) {
1317
- const color = d.color ?? rc.palette.accent;
1318
- for (const [px, py] of this.screenPoints(rc.coords, d)) rc.painter.circle(px, py, HANDLE_RADIUS, rc.palette.background, color);
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 side = order.side === "buy" ? labels.buy : labels.sell;
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 from = Math.max(0, frame.range.from - 1);
1989
- const to = Math.min(m.bars.length - 1, frame.range.to + 1);
1990
- if (to < from) return;
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)?.values;
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 upper = byKey(output.fill.upper);
1996
- const lower = byKey(output.fill.lower);
1997
- if (upper && lower) {
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
- ctx.fillStyle = output.fill.color;
2001
- let i = from;
2002
- while (i <= to) {
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
- while (i <= to && !Number.isNaN(upper[i]) && !Number.isNaN(lower[i])) i++;
2006
- if (i - start < 2) continue;
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 < i; j++) ctx.lineTo(timeScale.indexToX(j), yOf(upper[j]));
2009
- for (let j = i - 1; j >= start; j--) ctx.lineTo(timeScale.indexToX(j), yOf(lower[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
- for (let i = from; i <= to; i++) {
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 && Math.abs(y - lastY) < BADGE_HEIGHT) continue;
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 y = Math.min(main.height - BADGE_HEIGHT / 2, Math.max(BADGE_HEIGHT / 2, lastY));
2202
- p.roundRect(f.plotWidth + 2, y - BADGE_HEIGHT / 2, f.width - f.plotWidth - 4, BADGE_HEIGHT, 4, lastColor);
2203
- p.text(m.formatPrice(last.close), labelX, y, "#FFFFFF");
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 = new IndicatorManager();
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 = INDICATORS[cfg.type];
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
- formatPrice: (p) => formatPrice(p, this.pricePrecision)
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
- highLow = this.autoScale(range, overlays.map((o) => o.output));
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
- const mid = (this.mainScale.min + this.mainScale.max) / 2;
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
- for (const v of last ? [...priceTicks, last.close] : priceTicks) measure(formatPrice(v, this.pricePrecision));
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: (v) => formatPrice(roundToTick(v, this.tickSize), this.pricePrecision)
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) => this.drawings.createAt(this.paneCoords(), x, y),
3416
- drawPreview: (x, y) => this.drawings.previewAt(this.paneCoords(), x, y),
3417
- drawRelease: (x, y, moved) => this.drawings.releaseAt(this.paneCoords(), x, y, moved),
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" && (this.drawings.tool || this.drawings.selectedId)) {
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,