@funnest/charts 0.5.2 → 0.6.1

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