@luxalgo/vela 0.6.14 → 0.6.15

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.
Files changed (49) hide show
  1. package/NOTICE +28 -28
  2. package/README.md +1 -1
  3. package/dist/{DataProvider-DhzstpQb.d.ts → DataProvider-XLdJB86x.d.ts} +1 -1
  4. package/dist/{DataProvider-DlMtrwqM.d.cts → DataProvider-xH2VBcFT.d.cts} +1 -1
  5. package/dist/{chunk-F4M24ANM.js → chunk-32A7KHFM.js} +73 -3
  6. package/dist/{chunk-KG4YT3TI.js → chunk-6DPAYEGO.js} +8 -1
  7. package/dist/{chunk-64TF7JAJ.js → chunk-7C7NWEXH.js} +345 -148
  8. package/dist/{chunk-N2Q5EEFH.js → chunk-J5QIR2Z3.js} +163 -130
  9. package/dist/{chunk-MHY7MVXH.js → chunk-MWDNPGU3.js} +7 -1
  10. package/dist/{chunk-G77Y7LK2.js → chunk-MWS4CUJL.js} +2 -2
  11. package/dist/{contributions-37nni40G.d.ts → contributions-h5BzEiV2.d.ts} +13 -2
  12. package/dist/{contributions-lPojhTxI.d.cts → contributions-x7iamlnd.d.cts} +13 -2
  13. package/dist/index.cjs +174 -127
  14. package/dist/index.d.cts +20 -12
  15. package/dist/index.d.ts +20 -12
  16. package/dist/index.js +4 -4
  17. package/dist/{options-CYS5Wmlx.d.ts → options-bdSAtp7y.d.cts} +5 -0
  18. package/dist/{options-CYS5Wmlx.d.cts → options-bdSAtp7y.d.ts} +5 -0
  19. package/dist/{plugin-Cwikpz1m.d.cts → plugin-2sJcf5mk.d.cts} +11 -8
  20. package/dist/{plugin-DHyaoMjW.d.ts → plugin-BLaNPh55.d.ts} +11 -8
  21. package/dist/plugin.cjs +8 -0
  22. package/dist/plugin.d.cts +5 -5
  23. package/dist/plugin.d.ts +5 -5
  24. package/dist/plugin.js +2 -2
  25. package/dist/providers/binance.d.cts +2 -2
  26. package/dist/providers/binance.d.ts +2 -2
  27. package/dist/providers/coinbase.d.cts +2 -2
  28. package/dist/providers/coinbase.d.ts +2 -2
  29. package/dist/providers/hyperliquid.d.cts +2 -2
  30. package/dist/providers/hyperliquid.d.ts +2 -2
  31. package/dist/{side-panel-CT9ZwIGz.d.cts → side-panel-HF0IAzwf.d.cts} +17 -0
  32. package/dist/{side-panel-CT9ZwIGz.d.ts → side-panel-HF0IAzwf.d.ts} +17 -0
  33. package/dist/{statusline-model-D-q_OOx9.d.ts → statusline-model--tH0VWCy.d.cts} +6 -4
  34. package/dist/{statusline-model-CiJm-riV.d.cts → statusline-model-CLpaO3Dl.d.ts} +6 -4
  35. package/dist/ui.cjs +12 -0
  36. package/dist/ui.d.cts +1 -1
  37. package/dist/ui.d.ts +1 -1
  38. package/dist/ui.js +3 -3
  39. package/dist/vela.global.js +174 -127
  40. package/dist/vela.global.min.js +52 -46
  41. package/dist/widget.cjs +585 -272
  42. package/dist/widget.d.cts +15 -11
  43. package/dist/widget.d.ts +15 -11
  44. package/dist/widget.js +7 -7
  45. package/dist/workspace.cjs +585 -272
  46. package/dist/workspace.d.cts +29 -10
  47. package/dist/workspace.d.ts +29 -10
  48. package/dist/workspace.js +6 -6
  49. package/package.json +1 -1
package/dist/widget.cjs CHANGED
@@ -46,6 +46,7 @@ var SERIES_LINE = "#3b82f6";
46
46
  var CROSSHAIR = "#9aa0ad";
47
47
  var SLATE_DEEP = "#1e293b";
48
48
  var SLATE = "#475569";
49
+ var CHIP_PLATE = "#595959";
49
50
  var TRADE_LONG = ACCENT;
50
51
  var TRADE_SHORT = BEARISH;
51
52
  var TRADE_EXIT = "#d500f9";
@@ -1086,6 +1087,12 @@ ${STATIC_DECLS}
1086
1087
  .vela-ui *, .vela-ui-layer * { box-sizing: border-box; }
1087
1088
  /* Text ENTRY is the one exception: selection is part of editing. */
1088
1089
  .vela-ui :is(input, textarea), .vela-ui-layer :is(input, textarea) { user-select: text; -webkit-user-select: text; }
1090
+ /* iOS Safari zooms the page when a focused field is under 16px, and often
1091
+ keeps that zoom after the field blurs or a dialog closes. The shell's
1092
+ mobile size class is the honest gate \u2014 not a media query \u2014 so an embedded
1093
+ chart on a wide desktop still gets the rule when it is in the phone layout.
1094
+ Scoped to the kit root so a host page's own [data-layout] is never touched. */
1095
+ .vela-ui[data-layout='mobile'] :is(input, textarea, select) { font-size: 16px; }
1089
1096
  .vela-icon { display: inline-flex; align-items: center; flex: none; }
1090
1097
  .vela-icon svg { display: block; }
1091
1098
  `;
@@ -1181,6 +1188,10 @@ registerIcon(
1181
1188
  "market-post",
1182
1189
  svg24('<path d="M12 10V2"/><path d="m4.93 10.93 1.41 1.41"/><path d="M2 18h2"/><path d="M20 18h2"/><path d="m19.07 10.93-1.41 1.41"/><path d="M22 22H2"/><path d="m16 14-4 4-4-4"/><path d="M16 6a4 4 0 0 0-8 0"/>')
1183
1190
  );
1191
+ registerIcon(
1192
+ "market-extended",
1193
+ svg24('<path d="M22 22H2"/><path d="M12 3a5 5 0 0 0 7.5 7.5A7.5 7.5 0 1 1 12 3Z"/>')
1194
+ );
1184
1195
  registerIcon("market-closed", svg24('<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>'));
1185
1196
  registerIcon(
1186
1197
  "market-holiday",
@@ -1194,6 +1205,8 @@ registerIcon("chevrons-right", S('<path d="m4 3.5 4.5 4.5L4 12.5"/><path d="m8.5
1194
1205
  registerIcon("chevrons-left", S('<path d="M12 3.5 7.5 8l4.5 4.5"/><path d="M7.5 3.5 3 8l4.5 4.5"/>'));
1195
1206
  registerIcon("check", S('<path d="m2.8 8.4 3.4 3.4 7-7.6"/>'));
1196
1207
  registerIcon("close", S('<path d="m3.8 3.8 8.4 8.4M12.2 3.8l-8.4 8.4"/>'));
1208
+ registerIcon("pin", S('<path d="M5.6 2.5h4.8M6.4 2.5v3.6L4.4 8.4v.9h7.2v-.9L9.6 6.1V2.5M8 9.3v4.2"/>'));
1209
+ registerIcon("pin-filled", S('<path d="M5.6 2.5h4.8M6.4 2.5v3.6L4.4 8.4v.9h7.2v-.9L9.6 6.1V2.5M8 9.3v4.2"/><path d="M6.4 2.5h3.2v3.6l2 2.3v.9H4.4v-.9l2-2.3z" fill="currentColor" stroke="none"/>'));
1197
1210
  registerIcon("eye", S('<path d="M1.5 8s2.5-5.4 6.5-5.4S14.5 8 14.5 8 12 13.4 8 13.4 1.5 8 1.5 8z"/><circle cx="8" cy="8" r="1.8"/>'));
1198
1211
  registerIcon("eye-off", S('<path d="M1.5 8s2.5-5.4 6.5-5.4S14.5 8 14.5 8 12 13.4 8 13.4 1.5 8 1.5 8z" opacity="0.45"/><path d="m3 13 10-10"/>'));
1199
1212
  registerIcon("lock", S('<rect x="3.2" y="7" width="9.6" height="6.6" rx="1.2"/><path d="M5.6 7V5.2a2.4 2.4 0 0 1 4.8 0V7"/>'));
@@ -5022,6 +5035,7 @@ var BASELINE_FILL_ALPHA_FAR = 0.05;
5022
5035
  var BASELINE_LEVEL_DEFAULT = 50;
5023
5036
  var PREMARKET_SHADE = withAlpha(WARNING, 0.08);
5024
5037
  var POSTMARKET_SHADE = withAlpha(ACCENT, 0.08);
5038
+ var EXTENDED_SHADE = POSTMARKET_SHADE;
5025
5039
  function defaultChartStyle() {
5026
5040
  return {
5027
5041
  chartTypes: {},
@@ -5030,7 +5044,7 @@ function defaultChartStyle() {
5030
5044
  gridHorz: { visible: true, color: null },
5031
5045
  borderColor: null,
5032
5046
  separatorColor: null,
5033
- crosshair: { color: CROSSHAIR, width: 1, style: "dashed", opacity: 0.4, labelBackground: SLATE },
5047
+ crosshair: { color: CROSSHAIR, width: 1, style: "dashed", opacity: 0.4, labelBackground: CHIP_PLATE },
5034
5048
  candle: {
5035
5049
  bodyVisible: true,
5036
5050
  borderVisible: false,
@@ -5053,7 +5067,7 @@ function defaultChartStyle() {
5053
5067
  width: 2,
5054
5068
  baselineLevel: BASELINE_LEVEL_DEFAULT
5055
5069
  },
5056
- sessions: { premarketColor: PREMARKET_SHADE, postmarketColor: POSTMARKET_SHADE }
5070
+ sessions: { premarketColor: PREMARKET_SHADE, postmarketColor: POSTMARKET_SHADE, extendedColor: EXTENDED_SHADE }
5057
5071
  };
5058
5072
  }
5059
5073
  var LINE_STYLES = ["solid", "dashed", "dotted"];
@@ -5282,7 +5296,8 @@ function mergeConfig(base, patch) {
5282
5296
  },
5283
5297
  sessions: {
5284
5298
  premarketColor: isColor(sessions.premarketColor) ? sessions.premarketColor : base.sessions.premarketColor,
5285
- postmarketColor: isColor(sessions.postmarketColor) ? sessions.postmarketColor : base.sessions.postmarketColor
5299
+ postmarketColor: isColor(sessions.postmarketColor) ? sessions.postmarketColor : base.sessions.postmarketColor,
5300
+ extendedColor: isColor(sessions.extendedColor) ? sessions.extendedColor : base.sessions.extendedColor
5286
5301
  },
5287
5302
  stacking: {
5288
5303
  candles: isNum(stacking.candles) ? stacking.candles : base.stacking.candles,
@@ -12278,6 +12293,22 @@ var CSS5 = `
12278
12293
  }
12279
12294
  .vela-panel-close .vela-icon { width: 16px; height: 16px; }
12280
12295
  .vela-panel-close:hover { background: var(--vela-hover); color: var(--vela-fg-bright); }
12296
+ /* The pin: same footprint as the close button it sits beside; filled + bright once pinned. */
12297
+ .vela-panel-pin {
12298
+ all: unset;
12299
+ cursor: pointer;
12300
+ width: 26px;
12301
+ height: 26px;
12302
+ flex: none;
12303
+ display: inline-flex;
12304
+ align-items: center;
12305
+ justify-content: center;
12306
+ border-radius: 4px;
12307
+ color: var(--vela-fg-muted);
12308
+ }
12309
+ .vela-panel-pin .vela-icon { width: 15px; height: 15px; }
12310
+ .vela-panel-pin:hover { background: var(--vela-hover); color: var(--vela-fg-bright); }
12311
+ .vela-panel-pin[data-on='1'] { color: var(--vela-fg-bright); }
12281
12312
  .vela-panel-body { flex: 1; overflow: auto; padding: 8px; }
12282
12313
  .vela-panel-body::-webkit-scrollbar { width: 8px; }
12283
12314
  .vela-panel-body::-webkit-scrollbar-thumb { background: var(--vela-scroll); border-radius: 4px; border: 2px solid transparent; background-clip: padding-box; }
@@ -12302,8 +12333,23 @@ var CSS5 = `
12302
12333
  width: 1px;
12303
12334
  background: transparent;
12304
12335
  }
12336
+ /* The same hover ink as the workspace grid splitters and the canvas pane separators, so
12337
+ every draggable seam in the shell reads as one family. */
12305
12338
  .vela-panel-resizer:hover::after,
12306
- .vela-panel-resizer[data-dragging]::after { background: var(--vela-accent); }
12339
+ .vela-panel-resizer[data-dragging]::after { background: var(--vela-separator-hover-line); }
12340
+ /* An OVERLAY panel floats over the chart's right edge instead of taking a column: the
12341
+ chart keeps its width and layout (the dock host is position:relative \u2014 the anchoring the
12342
+ mobile rule below relies on too). Same z-order as that mobile overlay, above the chart's
12343
+ own DOM overlays; the shadow separates it from the plot it covers. */
12344
+ .vela-panel[data-overlay] {
12345
+ position: absolute;
12346
+ top: 0;
12347
+ right: 0;
12348
+ bottom: 0;
12349
+ max-width: 100%;
12350
+ z-index: 25;
12351
+ box-shadow: var(--vela-shadow);
12352
+ }
12307
12353
  /* Mobile: a 280px column would crush a phone-width chart \u2014 the panel overlays the
12308
12354
  chart area instead (its flex parent is position:relative), full-bleed, closed by
12309
12355
  the same header \u2715. Width dragging is a pointer affordance; off on mobile. */
@@ -12334,6 +12380,10 @@ var SidePanel = class {
12334
12380
  /** Notified when the USER settles a new width (drag release, or double-click reset) — never on
12335
12381
  * a programmatic {@link setWidth}, so restoring a persisted width raises no change. */
12336
12382
  this.onWidthChange = null;
12383
+ /** Notified when the USER pins or unpins a floating panel (`overlay` is the NEW placement) —
12384
+ * never on a programmatic {@link setOverlay}, for the same reason as widths. */
12385
+ this.onPlacementChange = null;
12386
+ this.pin = null;
12337
12387
  const doc = host.ownerDocument;
12338
12388
  injectStyles(STYLE_ID4, CSS5, doc);
12339
12389
  this.minWidth = opts.resizable ? opts.minWidth ?? DEFAULT_PANEL_MIN_WIDTH : 1;
@@ -12343,6 +12393,9 @@ var SidePanel = class {
12343
12393
  this.el = doc.createElement("div");
12344
12394
  this.el.className = `vela-panel ${modifier}`;
12345
12395
  this.el.hidden = true;
12396
+ this.floatable = opts.overlay === true;
12397
+ this.overlayOn = this.floatable;
12398
+ if (this.overlayOn) this.el.dataset.overlay = "1";
12346
12399
  this.el.style.setProperty("--vela-panel-w", `${this.widthPx}px`);
12347
12400
  const header = doc.createElement("div");
12348
12401
  header.className = "vela-panel-header";
@@ -12356,7 +12409,16 @@ var SidePanel = class {
12356
12409
  close.appendChild(iconEl("close", doc));
12357
12410
  close.title = "Close";
12358
12411
  close.addEventListener("click", () => this.toggle(false));
12359
- header.append(this.heading, this.slot, close);
12412
+ if (this.floatable) {
12413
+ this.pin = doc.createElement("button");
12414
+ this.pin.className = "vela-panel-pin";
12415
+ this.pin.addEventListener("click", () => {
12416
+ this.setOverlay(!this.overlayOn);
12417
+ this.onPlacementChange?.(this.overlayOn);
12418
+ });
12419
+ this.refreshPin();
12420
+ }
12421
+ header.append(this.heading, this.slot, ...this.pin ? [this.pin] : [], close);
12360
12422
  this.body = doc.createElement("div");
12361
12423
  this.body.className = "vela-panel-body";
12362
12424
  this.el.append(header, this.body);
@@ -12398,6 +12460,29 @@ var SidePanel = class {
12398
12460
  this.widthPx = next;
12399
12461
  this.el.style.setProperty("--vela-panel-w", `${next}px`);
12400
12462
  }
12463
+ /** Whether the panel floats over the chart right now (false for every docked panel). */
12464
+ get overlay() {
12465
+ return this.overlayOn;
12466
+ }
12467
+ /** Float or dock the panel. Only a panel declared `overlay` can float — on any other this is
12468
+ * a no-op. Silent — {@link onPlacementChange} reports the user's pin clicks only. */
12469
+ setOverlay(overlay) {
12470
+ if (!this.floatable || overlay === this.overlayOn) return;
12471
+ this.overlayOn = overlay;
12472
+ if (overlay) this.el.dataset.overlay = "1";
12473
+ else delete this.el.dataset.overlay;
12474
+ this.refreshPin();
12475
+ }
12476
+ refreshPin() {
12477
+ if (!this.pin) return;
12478
+ const pinned = !this.overlayOn;
12479
+ const doc = this.pin.ownerDocument;
12480
+ this.pin.replaceChildren(iconEl(pinned ? "pin-filled" : "pin", doc));
12481
+ this.pin.dataset.on = pinned ? "1" : "0";
12482
+ this.pin.title = pinned ? "Unpin \u2014 float over the chart" : "Pin beside the chart";
12483
+ this.pin.setAttribute("aria-label", this.pin.title);
12484
+ this.pin.setAttribute("aria-pressed", pinned ? "true" : "false");
12485
+ }
12401
12486
  destroy() {
12402
12487
  this.el.remove();
12403
12488
  }
@@ -13798,6 +13883,8 @@ var PanelDock = class {
13798
13883
  this.entries = [];
13799
13884
  /** Widths the USER settled, by panel id — the only ones worth persisting. */
13800
13885
  this.widths = /* @__PURE__ */ new Map();
13886
+ /** Floating panels the USER pinned as columns, by id — remembered for late registrations too. */
13887
+ this.pinned = /* @__PURE__ */ new Set();
13801
13888
  /** A restored `open` naming a panel that has not registered yet: honored when it docks. */
13802
13889
  this.pendingOpen = null;
13803
13890
  this.chart = null;
@@ -13820,7 +13907,8 @@ var PanelDock = class {
13820
13907
  width: desc.width,
13821
13908
  resizable: desc.resizable,
13822
13909
  minWidth: desc.minWidth,
13823
- maxWidth: desc.maxWidth
13910
+ maxWidth: desc.maxWidth,
13911
+ overlay: desc.overlay
13824
13912
  });
13825
13913
  const entry = {
13826
13914
  id: desc.id,
@@ -13868,7 +13956,8 @@ var PanelDock = class {
13868
13956
  const open2 = this.openId;
13869
13957
  if (open2) out.open = open2;
13870
13958
  if (this.widths.size > 0) out.widths = Object.fromEntries(this.widths);
13871
- return out.open || out.widths ? out : null;
13959
+ if (this.pinned.size > 0) out.pinned = [...this.pinned];
13960
+ return out.open || out.widths || out.pinned ? out : null;
13872
13961
  }
13873
13962
  /**
13874
13963
  * Restore a persisted dock state. Widths apply to any panel present, and are remembered for
@@ -13876,7 +13965,8 @@ var PanelDock = class {
13876
13965
  * a document that predates the dock has no `panels` field at all, so the shell never calls
13877
13966
  * this and the default (everything closed) stands. An `open` naming a panel that has not
13878
13967
  * registered yet is held until it docks (a plugin loaded after the restore), unless the user
13879
- * opens something in the meantime.
13968
+ * opens something in the meantime. `pinned` is the whole list of floating panels the user
13969
+ * docked — its absence means none is, so every floatable panel floats again.
13880
13970
  */
13881
13971
  applyState(state) {
13882
13972
  if (!state) return;
@@ -13886,6 +13976,8 @@ var PanelDock = class {
13886
13976
  this.entries.find((e) => e.id === id)?.panel.setWidth(px);
13887
13977
  }
13888
13978
  }
13979
+ this.pinned = new Set(state.pinned ?? []);
13980
+ for (const entry of this.entries) entry.panel.setOverlay(!this.pinned.has(entry.id));
13889
13981
  for (const entry of this.entries) entry.panel.toggle(entry.id === state.open);
13890
13982
  this.pendingOpen = state.open && !this.entries.some((e) => e.id === state.open) ? state.open : null;
13891
13983
  }
@@ -13899,6 +13991,7 @@ var PanelDock = class {
13899
13991
  this.entries.sort((a, b) => a.order - b.order);
13900
13992
  const stored = this.widths.get(entry.id);
13901
13993
  if (stored !== void 0) entry.panel.setWidth(stored);
13994
+ if (this.pinned.has(entry.id)) entry.panel.setOverlay(false);
13902
13995
  entry.panel.onOpenChange = (open2) => {
13903
13996
  if (open2) {
13904
13997
  for (const other of this.entries) if (other !== entry) other.panel.toggle(false);
@@ -13912,6 +14005,11 @@ var PanelDock = class {
13912
14005
  this.widths.set(entry.id, px);
13913
14006
  this.deps.changed?.();
13914
14007
  };
14008
+ entry.panel.onPlacementChange = (overlay) => {
14009
+ if (overlay) this.pinned.delete(entry.id);
14010
+ else this.pinned.add(entry.id);
14011
+ this.deps.changed?.();
14012
+ };
13915
14013
  if (this.pendingOpen === entry.id) entry.panel.toggle(true);
13916
14014
  if (!entry.contributed) this.publish();
13917
14015
  }
@@ -14000,6 +14098,17 @@ function filterSymbols(list, query, limit = 100, top = TOP_TICKERS) {
14000
14098
  }
14001
14099
  var STYLE_ID7 = "vela-widget-symbolpicker";
14002
14100
  var CSS8 = `
14101
+ /* Search + market tabs stay pinned while the result list scrolls underneath
14102
+ (mobile fullscreen: the dialog body is the scroller). Negative margin eats
14103
+ the body's padding so scrolled rows cannot peek around the island. */
14104
+ .vela-sp-sticky {
14105
+ position: sticky;
14106
+ top: 0;
14107
+ z-index: 1;
14108
+ background: var(--vela-surface);
14109
+ margin: calc(-1 * var(--vela-space-4)) calc(-1 * var(--vela-space-4)) 0;
14110
+ padding: var(--vela-space-4) var(--vela-space-4) 0;
14111
+ }
14003
14112
  .vela-sp-searchrow {
14004
14113
  display: flex;
14005
14114
  align-items: center;
@@ -14019,8 +14128,10 @@ var CSS8 = `
14019
14128
  border: none;
14020
14129
  font-size: 14px;
14021
14130
  outline: none;
14131
+ text-transform: uppercase;
14022
14132
  }
14023
- .vela-sp-tabs { display: flex; gap: 14px; margin: 12px 2px 6px; border-bottom: 1px solid var(--vela-border); padding-bottom: 8px; }
14133
+ .vela-sp-input::placeholder { text-transform: none; }
14134
+ .vela-sp-tabs { display: flex; gap: 14px; margin: 12px 2px 0; border-bottom: 1px solid var(--vela-border); padding-bottom: 8px; }
14024
14135
  /* Mobile (fullscreen dialog): the asset-class strip scrolls sideways instead of
14025
14136
  overflowing the body, and the result list stops capping itself \u2014 the body owns
14026
14137
  the scrolling in the fullscreen presentation. */
@@ -14069,7 +14180,7 @@ var CSS8 = `
14069
14180
  font-weight: 700;
14070
14181
  }
14071
14182
  .vela-sp-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
14072
- .vela-sp-ticker { font-weight: 700; color: var(--vela-fg-bright); font-size: 14px; }
14183
+ .vela-sp-ticker { font-weight: 700; color: var(--vela-fg-bright); font-size: 14px; text-transform: uppercase; }
14073
14184
  .vela-sp-desc { color: var(--vela-fg-muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
14074
14185
  .vela-sp-badge {
14075
14186
  flex: none;
@@ -14132,6 +14243,8 @@ var SymbolPicker = class {
14132
14243
  searchRow.append(iconEl("search", doc), this.input);
14133
14244
  this.tabs = doc.createElement("div");
14134
14245
  this.tabs.className = "vela-sp-tabs";
14246
+ const sticky = doc.createElement("div");
14247
+ sticky.className = "vela-sp-sticky";
14135
14248
  for (const t of ["All", "Stocks", "ETFs", "Crypto", "Futures", "Forex", "Commodities"]) {
14136
14249
  const b = doc.createElement("button");
14137
14250
  b.className = "vela-sp-tab";
@@ -14145,6 +14258,7 @@ var SymbolPicker = class {
14145
14258
  });
14146
14259
  this.tabs.appendChild(b);
14147
14260
  }
14261
+ sticky.append(searchRow, this.tabs);
14148
14262
  this.list = doc.createElement("div");
14149
14263
  this.list.className = "vela-sp-list";
14150
14264
  this.list.addEventListener("scroll", () => {
@@ -14157,21 +14271,32 @@ var SymbolPicker = class {
14157
14271
  title: "Symbol Search",
14158
14272
  host: opts.host,
14159
14273
  closeOnInteractOutside: true,
14160
- content: (body) => body.append(searchRow, this.tabs, this.list),
14274
+ content: (body) => body.append(sticky, this.list),
14161
14275
  onOpenChange: (open2) => {
14162
14276
  if (open2) {
14163
- this.input.value = this.seed;
14277
+ this.input.value = this.seed.toUpperCase();
14164
14278
  this.seed = "";
14165
14279
  this.refresh();
14166
14280
  setTimeout(() => {
14167
14281
  this.input.focus();
14168
14282
  this.input.setSelectionRange(this.input.value.length, this.input.value.length);
14169
14283
  }, 0);
14284
+ } else {
14285
+ this.input.blur();
14170
14286
  }
14171
14287
  opts.onOpenChange?.(open2);
14172
14288
  }
14173
14289
  });
14174
- this.input.addEventListener("input", () => this.refresh());
14290
+ this.input.addEventListener("input", () => {
14291
+ const next = this.input.value.toUpperCase();
14292
+ if (this.input.value !== next) {
14293
+ const start = this.input.selectionStart;
14294
+ const end = this.input.selectionEnd;
14295
+ this.input.value = next;
14296
+ if (start != null && end != null) this.input.setSelectionRange(start, end);
14297
+ }
14298
+ this.refresh();
14299
+ });
14175
14300
  this.input.addEventListener("keydown", (e) => {
14176
14301
  if (e.key === "ArrowDown") this.moveHighlight(1);
14177
14302
  else if (e.key === "ArrowUp") this.moveHighlight(-1);
@@ -17806,6 +17931,7 @@ function createCustomMark(doc, html, background) {
17806
17931
  });
17807
17932
  el.style.color = attributionMarkColor(background);
17808
17933
  el.innerHTML = html;
17934
+ el.dataset.velaScreenshot = "1";
17809
17935
  return el;
17810
17936
  }
17811
17937
  function createAttributionMark(doc, background) {
@@ -17824,6 +17950,7 @@ function createAttributionMark(doc, background) {
17824
17950
  cursor: "pointer"
17825
17951
  });
17826
17952
  applyAttributionMarkTheme(a, background);
17953
+ a.dataset.velaScreenshot = "1";
17827
17954
  const symbol = doc.createElement("span");
17828
17955
  symbol.className = "vela-attr-symbol";
17829
17956
  symbol.setAttribute("aria-hidden", "true");
@@ -17992,7 +18119,11 @@ function sanitizePanels(raw) {
17992
18119
  }
17993
18120
  if (Object.keys(widths).length > 0) out.widths = widths;
17994
18121
  }
17995
- return out.open || out.widths ? out : null;
18122
+ if (Array.isArray(p.pinned)) {
18123
+ const pinned = p.pinned.filter((id) => typeof id === "string" && id !== "");
18124
+ if (pinned.length > 0) out.pinned = [...new Set(pinned)];
18125
+ }
18126
+ return out.open || out.widths || out.pinned ? out : null;
17996
18127
  }
17997
18128
  function sanitizeTrackSizes(raw) {
17998
18129
  if (raw == null || typeof raw !== "object") return null;
@@ -20155,6 +20286,15 @@ var RendererControl = class {
20155
20286
  console.warn(`[vela] renderer "${this.renderer.name}" does not support screenshot().`);
20156
20287
  return null;
20157
20288
  }
20289
+ /**
20290
+ * Raster of the current chart onto a canvas (same pixels as {@link screenshot}),
20291
+ * or null if the renderer has no canvas export. Silent — a host compositing
20292
+ * several charts skips a renderer that cannot contribute.
20293
+ */
20294
+ screenshotCanvas() {
20295
+ if (typeof this.renderer.screenshotCanvas === "function") return this.renderer.screenshotCanvas();
20296
+ return null;
20297
+ }
20158
20298
  /**
20159
20299
  * The active renderer's full cosmetic config as a serializable, versioned JSON
20160
20300
  * document — persist it (templates, saved settings) and feed it back to
@@ -22723,6 +22863,66 @@ function candleGeometry(xCss, spacing, dpr, bodyScale = 1) {
22723
22863
  center: (wickLeftDev + wickDev / 2) / dpr
22724
22864
  };
22725
22865
  }
22866
+ function aggregateCandleColumns(bars, i0, i1, xOf, yOf) {
22867
+ const out = [];
22868
+ let col = NaN;
22869
+ let intervals = [];
22870
+ const flush = () => {
22871
+ if (intervals.length === 0) return;
22872
+ const merged = [intervals[0]];
22873
+ for (let k = 1; k < intervals.length; k += 1) {
22874
+ const prev2 = merged[merged.length - 1];
22875
+ const next = intervals[k];
22876
+ if (Math.abs(yOf(prev2.hi) - yOf(next.lo)) < 1) {
22877
+ prev2.hi = next.hi;
22878
+ if (next.headIdx < prev2.headIdx) {
22879
+ prev2.headIdx = next.headIdx;
22880
+ prev2.headTime = next.headTime;
22881
+ prev2.open = next.open;
22882
+ }
22883
+ if (next.lastIdx > prev2.lastIdx) {
22884
+ prev2.lastIdx = next.lastIdx;
22885
+ prev2.close = next.close;
22886
+ }
22887
+ } else {
22888
+ merged.push(next);
22889
+ }
22890
+ }
22891
+ for (const iv of merged) out.push({ x: col, hi: iv.hi, lo: iv.lo, headTime: iv.headTime, open: iv.open, close: iv.close });
22892
+ intervals = [];
22893
+ };
22894
+ for (let i = i0; i <= i1; i += 1) {
22895
+ const b = bars[i];
22896
+ if (!b || b.high <= b.low) continue;
22897
+ const x = Math.round(xOf(i));
22898
+ if (x !== col) {
22899
+ flush();
22900
+ col = x;
22901
+ }
22902
+ let lo = b.low;
22903
+ let hi = b.high;
22904
+ let headIdx = i;
22905
+ let headTime = b.time;
22906
+ let open2 = b.open;
22907
+ for (let k = intervals.length - 1; k >= 0; k -= 1) {
22908
+ const iv = intervals[k];
22909
+ if (iv.lo > hi || iv.hi < lo) continue;
22910
+ if (iv.lo < lo) lo = iv.lo;
22911
+ if (iv.hi > hi) hi = iv.hi;
22912
+ if (iv.headIdx < headIdx) {
22913
+ headIdx = iv.headIdx;
22914
+ headTime = iv.headTime;
22915
+ open2 = iv.open;
22916
+ }
22917
+ intervals.splice(k, 1);
22918
+ }
22919
+ let insertAt = 0;
22920
+ while (insertAt < intervals.length && intervals[insertAt].lo < lo) insertAt += 1;
22921
+ intervals.splice(insertAt, 0, { lo, hi, headIdx, headTime, open: open2, lastIdx: i, close: b.close });
22922
+ }
22923
+ flush();
22924
+ return out;
22925
+ }
22726
22926
 
22727
22927
  // src/renderers/native/backend/gl/Batch.ts
22728
22928
  var FLOATS_PER_VERT = 8;
@@ -23695,44 +23895,19 @@ var WebGL2Backend = class {
23695
23895
  }
23696
23896
  }
23697
23897
  /**
23698
- * Sub-pixel LOD (mirrors Canvas2dBackend.drawCandlesAggregated): bucket bars
23699
- * sharing a rounded pixel column into one high-low stick draw cost bounded by
23700
- * screen width, not bar count. Bucket color follows first-open→last-close.
23898
+ * Sub-pixel LOD (mirrors Canvas2dBackend.drawCandlesAggregated): bars sharing a
23899
+ * rounded pixel column collapse into high-low sticks one per contiguous
23900
+ * coverage run (see {@link aggregateCandleColumns}), so a price gap inside the
23901
+ * column stays a void. Draw cost stays bounded by screen width, not bar count;
23902
+ * stick color follows its own first-open→last-close.
23701
23903
  */
23702
23904
  emitCandlesAggregated(b, bars, i0, i1, coords, pane, up, down, barColors) {
23703
- let px = NaN;
23704
- let lo = Infinity;
23705
- let hi = -Infinity;
23706
- let openV = 0;
23707
- let closeV = 0;
23708
- let headTime = 0;
23709
- let active = false;
23710
- const flush = () => {
23711
- if (!active) return;
23712
- const c = parseColor(barColors.get(headTime) ?? (closeV >= openV ? up : down));
23713
- const hY = coords.priceToY(hi, pane.scale, pane.bounds);
23714
- const lY = coords.priceToY(lo, pane.scale, pane.bounds);
23715
- b.rect(px - 0.5, hY, 1, lY - hY, c);
23716
- };
23717
- for (let i = i0; i <= i1; i += 1) {
23718
- const bar = bars[i];
23719
- if (!bar || bar.high <= bar.low) continue;
23720
- const x = Math.round(coords.logicalToX(i));
23721
- if (!active || x !== px) {
23722
- flush();
23723
- px = x;
23724
- lo = bar.low;
23725
- hi = bar.high;
23726
- openV = bar.open;
23727
- headTime = bar.time;
23728
- active = true;
23729
- } else {
23730
- if (bar.low < lo) lo = bar.low;
23731
- if (bar.high > hi) hi = bar.high;
23732
- }
23733
- closeV = bar.close;
23905
+ const yOf = (price) => coords.priceToY(price, pane.scale, pane.bounds);
23906
+ for (const s of aggregateCandleColumns(bars, i0, i1, (i) => coords.logicalToX(i), yOf)) {
23907
+ const c = parseColor(barColors.get(s.headTime) ?? (s.close >= s.open ? up : down));
23908
+ const hY = yOf(s.hi);
23909
+ b.rect(s.x - 0.5, hY, 1, yOf(s.lo) - hY, c);
23734
23910
  }
23735
- flush();
23736
23911
  }
23737
23912
  emitSeries(b, spec, pane, coords, i0, i1, theme, off = 0) {
23738
23913
  if (spec.kind === "candle" || spec.kind === "bar") {
@@ -25030,13 +25205,15 @@ var SceneGraph = class {
25030
25205
  if (!this.orderedCache) this.orderedCache = [...this.panes.values()].sort((a, b) => a.order - b.order);
25031
25206
  return this.orderedCache;
25032
25207
  }
25033
- /** The session zones resolved into colored bands (pre/post-market washes from the
25034
- * config's session colors) — consumed by the same painting path as {@link highlights}. */
25208
+ /** The session zones resolved into colored bands (pre/post-market or extended-hours
25209
+ * washes from the config's session colors) — consumed by the same painting path as
25210
+ * {@link highlights}. */
25035
25211
  sessionHighlightBands() {
25036
25212
  if (!this.sessionZones) return [];
25037
25213
  const out = [];
25038
25214
  for (const [from, to] of this.sessionZones.pre) out.push({ from, to, color: this.style.sessions.premarketColor });
25039
25215
  for (const [from, to] of this.sessionZones.post) out.push({ from, to, color: this.style.sessions.postmarketColor });
25216
+ for (const [from, to] of this.sessionZones.extended) out.push({ from, to, color: this.style.sessions.extendedColor });
25040
25217
  return out;
25041
25218
  }
25042
25219
  indicatorsForPane(paneId) {
@@ -25575,50 +25752,23 @@ var Canvas2dBackend = class {
25575
25752
  }
25576
25753
  }
25577
25754
  /**
25578
- * Sub-pixel LOD: bucket every bar sharing a rounded pixel column into ONE
25579
- * high-low stick, so draw cost is bounded by screen width (not bar count) when
25580
- * zoomed far out. The bucket's color follows its first-open→last-close
25581
- * direction (a barcolor() on the head bar still wins).
25755
+ * Sub-pixel LOD: bars sharing a rounded pixel column collapse into high-low
25756
+ * sticks (one per contiguous coverage run see {@link aggregateCandleColumns}),
25757
+ * so draw cost is bounded by screen width (not bar count) when zoomed far out
25758
+ * and a price gap inside the column stays a void. Each stick's color follows
25759
+ * its first-open→last-close direction (a barcolor() on the head bar still wins).
25582
25760
  */
25583
25761
  drawCandlesAggregated(ctx, bars, i0, i1, coords, pane, up, down, barColors) {
25584
- let px = NaN;
25585
- let lo = Infinity;
25586
- let hi = -Infinity;
25587
- let openV = 0;
25588
- let closeV = 0;
25589
- let headTime = 0;
25590
- let active = false;
25591
- const flush = () => {
25592
- if (!active) return;
25593
- const color = barColors.get(headTime) ?? (closeV >= openV ? up : down);
25594
- const hY = coords.priceToY(hi, pane.scale, pane.bounds);
25595
- const lY = coords.priceToY(lo, pane.scale, pane.bounds);
25596
- ctx.strokeStyle = color;
25597
- ctx.lineWidth = 1;
25762
+ const yOf = (price) => coords.priceToY(price, pane.scale, pane.bounds);
25763
+ ctx.lineWidth = 1;
25764
+ for (const s of aggregateCandleColumns(bars, i0, i1, (i) => coords.logicalToX(i), yOf)) {
25765
+ const x = s.x + 0.5;
25766
+ ctx.strokeStyle = barColors.get(s.headTime) ?? (s.close >= s.open ? up : down);
25598
25767
  ctx.beginPath();
25599
- ctx.moveTo(px, hY);
25600
- ctx.lineTo(px, lY);
25768
+ ctx.moveTo(x, yOf(s.hi));
25769
+ ctx.lineTo(x, yOf(s.lo));
25601
25770
  ctx.stroke();
25602
- };
25603
- for (let i = i0; i <= i1; i += 1) {
25604
- const b = bars[i];
25605
- if (!b || b.high <= b.low) continue;
25606
- const x = Math.round(coords.logicalToX(i)) + 0.5;
25607
- if (!active || x !== px) {
25608
- flush();
25609
- px = x;
25610
- lo = b.low;
25611
- hi = b.high;
25612
- openV = b.open;
25613
- headTime = b.time;
25614
- active = true;
25615
- } else {
25616
- if (b.low < lo) lo = b.low;
25617
- if (b.high > hi) hi = b.high;
25618
- }
25619
- closeV = b.close;
25620
25771
  }
25621
- flush();
25622
25772
  }
25623
25773
  drawSeries(ctx, spec, pane, coords, i0, i1, theme, off = 0) {
25624
25774
  if (spec.kind === "candle" || spec.kind === "bar") {
@@ -26642,6 +26792,32 @@ var DrawingSceneRenderer = class {
26642
26792
  }
26643
26793
  };
26644
26794
 
26795
+ // src/renderers/native/chrome/tz.ts
26796
+ function tzOffsetMs(ms, timeZone) {
26797
+ if (!timeZone || timeZone === "UTC") return 0;
26798
+ try {
26799
+ const dtf = new Intl.DateTimeFormat("en-US", {
26800
+ timeZone,
26801
+ hourCycle: "h23",
26802
+ year: "numeric",
26803
+ month: "2-digit",
26804
+ day: "2-digit",
26805
+ hour: "2-digit",
26806
+ minute: "2-digit",
26807
+ second: "2-digit"
26808
+ });
26809
+ const parts = dtf.formatToParts(new Date(ms));
26810
+ const get = (t) => Number(parts.find((p) => p.type === t)?.value);
26811
+ const asUTC = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour") % 24, get("minute"), get("second"));
26812
+ return asUTC - ms;
26813
+ } catch {
26814
+ return 0;
26815
+ }
26816
+ }
26817
+ function zonedDate(ms, timeZone) {
26818
+ return new Date(ms + tzOffsetMs(ms, timeZone));
26819
+ }
26820
+
26645
26821
  // src/renderers/native/chrome/ticks.ts
26646
26822
  function priceTicks(min, max, target = 6) {
26647
26823
  if (!(max > min) || !Number.isFinite(min) || !Number.isFinite(max)) return [];
@@ -26789,7 +26965,14 @@ var STEP_LADDER = [
26789
26965
  YEAR
26790
26966
  ];
26791
26967
  var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
26968
+ var WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
26792
26969
  var pad2 = (n) => n < 10 ? `0${n}` : String(n);
26970
+ function formatTimeStamp(ms, timeZone, barIntervalMs) {
26971
+ const d = zonedDate(ms, timeZone);
26972
+ const date = `${WEEKDAYS[d.getUTCDay()]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} '${pad2(d.getUTCFullYear() % 100)}`;
26973
+ if (barIntervalMs >= DAY) return date;
26974
+ return `${date} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}`;
26975
+ }
26793
26976
  function pickStep(targetMs) {
26794
26977
  for (const step of STEP_LADDER) if (step >= targetMs) return step;
26795
26978
  return STEP_LADDER[STEP_LADDER.length - 1];
@@ -26831,32 +27014,6 @@ function timeTicks(fromMs, toMs, target = 8, offsetMs = 0) {
26831
27014
  return out;
26832
27015
  }
26833
27016
 
26834
- // src/renderers/native/chrome/tz.ts
26835
- function tzOffsetMs(ms, timeZone) {
26836
- if (!timeZone || timeZone === "UTC") return 0;
26837
- try {
26838
- const dtf = new Intl.DateTimeFormat("en-US", {
26839
- timeZone,
26840
- hourCycle: "h23",
26841
- year: "numeric",
26842
- month: "2-digit",
26843
- day: "2-digit",
26844
- hour: "2-digit",
26845
- minute: "2-digit",
26846
- second: "2-digit"
26847
- });
26848
- const parts = dtf.formatToParts(new Date(ms));
26849
- const get = (t) => Number(parts.find((p) => p.type === t)?.value);
26850
- const asUTC = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour") % 24, get("minute"), get("second"));
26851
- return asUTC - ms;
26852
- } catch {
26853
- return 0;
26854
- }
26855
- }
26856
- function zonedDate(ms, timeZone) {
26857
- return new Date(ms + tzOffsetMs(ms, timeZone));
26858
- }
26859
-
26860
27017
  // src/renderers/native/chrome/ChromeRenderer.ts
26861
27018
  var ChromeRenderer = class {
26862
27019
  constructor() {
@@ -27303,7 +27460,7 @@ var CrosshairRenderer = class {
27303
27460
  const price = coords.yToPrice(ch.y, pane.scale, pane.bounds);
27304
27461
  this.chip(ctx, dataW + 1, ch.y, formatAxisValue(pane.scale, pane.bounds.height, price, percentScaleFor(scene, pane), scene.priceMintick, pane.axisFormat), chipBg, "left", false, theme.background);
27305
27462
  }
27306
- this.chip(ctx, x, dataH + 1, formatStamp(coords.logicalToTime(logical), scene.timezone), chipBg, "center", true, theme.background);
27463
+ this.chip(ctx, x, dataH + 1, formatTimeStamp(coords.logicalToTime(logical), scene.timezone, coords.barInterval), chipBg, "center", true, theme.background);
27307
27464
  }
27308
27465
  destroy() {
27309
27466
  this.canvas = null;
@@ -27351,7 +27508,7 @@ var CrosshairRenderer = class {
27351
27508
  this.chip(ctx, dataW + 1, ext.y, formatAxisValue(pane.scale, pane.bounds.height, ext.price, percentScaleFor(scene, pane), scene.priceMintick, pane.axisFormat), chipBg, "left", false, theme.background);
27352
27509
  }
27353
27510
  }
27354
- this.chip(ctx, x, dataH + 1, formatStamp(ext.time, scene.timezone), chipBg, "center", true, theme.background);
27511
+ this.chip(ctx, x, dataH + 1, formatTimeStamp(ext.time, scene.timezone, coords.barInterval), chipBg, "center", true, theme.background);
27355
27512
  ctx.globalAlpha = 1;
27356
27513
  }
27357
27514
  /** A soft band + a brighter crisp center line over the hovered separator, so it reads as
@@ -27384,11 +27541,6 @@ function setDash3(ctx, style) {
27384
27541
  else if (style === "dotted") ctx.setLineDash([2, 3]);
27385
27542
  else ctx.setLineDash([]);
27386
27543
  }
27387
- function formatStamp(ms, timeZone) {
27388
- const d = zonedDate(ms, timeZone);
27389
- const pad = (n) => n < 10 ? `0${n}` : String(n);
27390
- return `${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
27391
- }
27392
27544
 
27393
27545
  // src/renderers/native/chrome/settings-visibility.ts
27394
27546
  function settingsIdSlug(label) {
@@ -33155,6 +33307,9 @@ function clipHighlightRect(x1, x2, left, right) {
33155
33307
  const end = Math.min(right, x2);
33156
33308
  return end > x ? { x, width: end - x } : null;
33157
33309
  }
33310
+ function bandEdgeSlot(logical) {
33311
+ return Math.ceil(logical) - 0.5;
33312
+ }
33158
33313
  var BackdropRenderer = class {
33159
33314
  constructor() {
33160
33315
  this.canvas = null;
@@ -33185,21 +33340,22 @@ var BackdropRenderer = class {
33185
33340
  this.drawGrid(ctx, scene, coords, theme, coords.width, gridAlpha);
33186
33341
  }
33187
33342
  /** Renderer-owned session highlight bands: full-height (all panes), behind the grid.
33188
- * Session-zone washes (pre/post-market) paint first, host highlights on top. */
33343
+ * Session-zone washes paint first (edges snapped to bar-slot boundaries so a band
33344
+ * never cuts a candle in two), host highlights on top (continuous time mapping —
33345
+ * hosts may paint sub-bar spans). */
33189
33346
  drawHighlights(ctx, scene, coords) {
33190
33347
  const sessions = scene.sessionHighlightBands();
33191
33348
  if (sessions.length > 0) {
33192
33349
  const left = Math.max(0, coords.logicalToX(-0.5));
33193
33350
  const right = Math.min(coords.width, coords.logicalToX(coords.barCount - 0.5));
33194
- this.drawHighlightSet(ctx, sessions, coords, left, right);
33351
+ this.drawHighlightSet(ctx, sessions, coords, left, right, true);
33195
33352
  }
33196
- this.drawHighlightSet(ctx, scene.highlights, coords, 0, coords.width);
33353
+ this.drawHighlightSet(ctx, scene.highlights, coords, 0, coords.width, false);
33197
33354
  }
33198
- drawHighlightSet(ctx, bands, coords, left, right) {
33355
+ drawHighlightSet(ctx, bands, coords, left, right, snapToSlots) {
33356
+ const edgeX = (ms) => snapToSlots ? coords.logicalToX(bandEdgeSlot(coords.timeToLogical(ms))) : coords.timeToX(ms);
33199
33357
  for (const band of bands) {
33200
- const x1 = coords.timeToX(band.from);
33201
- const x2 = coords.timeToX(band.to);
33202
- const rect = clipHighlightRect(x1, x2, left, right);
33358
+ const rect = clipHighlightRect(edgeX(band.from), edgeX(band.to), left, right);
33203
33359
  if (!rect) continue;
33204
33360
  ctx.fillStyle = band.color;
33205
33361
  ctx.fillRect(rect.x, 0, rect.width, coords.height);
@@ -34346,7 +34502,7 @@ var NativeRenderer = class {
34346
34502
  };
34347
34503
  })(),
34348
34504
  series: { style: this.scene.priceStyle, baseline: this.scene.baselineValue, spacing: this.coords.spacingScale },
34349
- sessions: { premarketColor: s.sessions.premarketColor, postmarketColor: s.sessions.postmarketColor }
34505
+ sessions: { premarketColor: s.sessions.premarketColor, postmarketColor: s.sessions.postmarketColor, extendedColor: s.sessions.extendedColor }
34350
34506
  };
34351
34507
  }
34352
34508
  /**
@@ -34428,7 +34584,7 @@ var NativeRenderer = class {
34428
34584
  width: next.baseline.width,
34429
34585
  baselineLevel: next.baseline.baselineLevel
34430
34586
  };
34431
- s.sessions = { premarketColor: next.sessions.premarketColor, postmarketColor: next.sessions.postmarketColor };
34587
+ s.sessions = { premarketColor: next.sessions.premarketColor, postmarketColor: next.sessions.postmarketColor, extendedColor: next.sessions.extendedColor };
34432
34588
  this.setPriceStyle(next.series.style);
34433
34589
  this.scene.candleOverride = candleOverrideFor(this.scene.priceStyle, s.chartTypes);
34434
34590
  this.scene.baselineValue = next.series.baseline;
@@ -34650,9 +34806,10 @@ var NativeRenderer = class {
34650
34806
  * opts in with a `data-vela-screenshot` attribute on the mount container's
34651
34807
  * subtree (the widget marks its status line, and its symbol watermark with
34652
34808
  * `"under"` — drawn beneath the canvases, where it sits on screen). Only the
34653
- * crosshair (L2) is intentionally excluded.
34809
+ * crosshair (L2) is intentionally excluded. {@link screenshot} is this canvas
34810
+ * as a PNG data URL.
34654
34811
  */
34655
- screenshot() {
34812
+ screenshotCanvas() {
34656
34813
  if (!this.dataCanvas) return null;
34657
34814
  this.computeScales();
34658
34815
  this.paintData();
@@ -34680,7 +34837,10 @@ var NativeRenderer = class {
34680
34837
  if (el.getAttribute("data-vela-screenshot") !== "under") rasterizeOverlay(ctx, el, frame);
34681
34838
  }
34682
34839
  }
34683
- return out.toDataURL("image/png");
34840
+ return out;
34841
+ }
34842
+ screenshot() {
34843
+ return this.screenshotCanvas()?.toDataURL("image/png") ?? null;
34684
34844
  }
34685
34845
  /**
34686
34846
  * The candles-first-appear reveal — animates the **candles themselves** (no extra
@@ -36075,7 +36235,7 @@ var NativeRenderer = class {
36075
36235
  const pricePane = this.dataWindowPricePane();
36076
36236
  const ohlc = this.dataWindowOHLC(bar, pricePane);
36077
36237
  const parts = [];
36078
- if (bar) parts.push(formatStamp2(bar.time, this.scene.timezone));
36238
+ if (bar) parts.push(formatStamp(bar.time, this.scene.timezone));
36079
36239
  if (ohlc) parts.push(`Open ${ohlc.o}, High ${ohlc.h}, Low ${ohlc.l}, Close ${ohlc.c}`);
36080
36240
  for (const group of this.dataWindowGroups(idx, pricePane)) {
36081
36241
  for (const row of group.rows) parts.push(`${row.label} ${row.value}`);
@@ -36962,7 +37122,7 @@ function formatStampParts(ms, timeZone) {
36962
37122
  time: `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`
36963
37123
  };
36964
37124
  }
36965
- function formatStamp2(ms, timeZone) {
37125
+ function formatStamp(ms, timeZone) {
36966
37126
  const { date, time } = formatStampParts(ms, timeZone);
36967
37127
  return `${date} ${time}`;
36968
37128
  }
@@ -36993,7 +37153,7 @@ function sanitizeSessionZones(value) {
36993
37153
  }
36994
37154
  return out.sort((a, b) => a[0] - b[0]);
36995
37155
  };
36996
- return { pre: windows(v.pre), post: windows(v.post) };
37156
+ return { pre: windows(v.pre), post: windows(v.post), extended: windows(v.extended) };
36997
37157
  }
36998
37158
  function clamp012(v) {
36999
37159
  return Math.max(0, Math.min(1, v));
@@ -37682,6 +37842,7 @@ var MARKET_LABELS = {
37682
37842
  open: "Market Open",
37683
37843
  pre: "Pre-Market",
37684
37844
  post: "Post-Market",
37845
+ extended: "Extended Hours",
37685
37846
  closed: "Market Closed",
37686
37847
  holiday: "Market Holiday"
37687
37848
  };
@@ -37689,6 +37850,7 @@ var MARKET_INKS = {
37689
37850
  open: "var(--vela-up)",
37690
37851
  pre: SESSION_PRE,
37691
37852
  post: SESSION_POST,
37853
+ extended: SESSION_POST,
37692
37854
  closed: SESSION_OFF,
37693
37855
  holiday: SESSION_OFF
37694
37856
  };
@@ -38002,118 +38164,17 @@ var Statusline = class {
38002
38164
  }
38003
38165
  };
38004
38166
 
38005
- // src/widget/market-status.ts
38006
- function civilDate(ms, tz) {
38007
- try {
38008
- return new Intl.DateTimeFormat("en-CA", { timeZone: tz }).format(ms);
38009
- } catch {
38010
- return "";
38011
- }
38012
- }
38013
- function isWeekday(ms, tz) {
38014
- try {
38015
- const wd = new Intl.DateTimeFormat("en-US", { timeZone: tz, weekday: "short" }).format(ms);
38016
- return wd !== "Sat" && wd !== "Sun";
38017
- } catch {
38018
- return false;
38019
- }
38020
- }
38021
- function deriveMarketStatus(now, w, tz) {
38022
- const within = (ws) => ws.find(([s, e]) => now >= s && now < e);
38023
- if (within(w.regular)) return "open";
38024
- const ext = within(w.extended);
38025
- if (ext) return w.regular.some(([s]) => s >= now && s < ext[1]) ? "pre" : "post";
38026
- if (isWeekday(now, tz)) {
38027
- const today = civilDate(now, tz);
38028
- if (today !== "" && !w.extended.some(([s]) => civilDate(s, tz) === today)) return "holiday";
38029
- }
38030
- return "closed";
38031
- }
38032
- function nextStatusBoundary(now, w) {
38033
- let next = null;
38034
- for (const ws of [w.regular, w.extended]) {
38035
- for (const [s, e] of ws) {
38036
- for (const t of [s, e]) {
38037
- if (t > now && (next == null || t < next)) next = t;
38038
- }
38039
- }
38040
- }
38041
- return next;
38042
- }
38043
- var FETCH_BACK_MS = 4 * 864e5;
38044
- var FETCH_AHEAD_MS = 10 * 864e5;
38045
- var MIN_TIMER_MS = 15e3;
38046
- var MAX_TIMER_MS = 36e5;
38047
- var RETRY_MS2 = 6e4;
38048
- var MarketStatusTracker = class {
38049
- constructor(onStatus) {
38050
- this.onStatus = onStatus;
38051
- /** Invalidates detached async work — bumped by every track()/stop(). */
38052
- this.epoch = 0;
38053
- this.timer = null;
38054
- }
38055
- /** (Re)bind to a chart's data surface + symbol and start evaluating. */
38056
- track(data, symbol) {
38057
- const my = ++this.epoch;
38058
- this.clearTimer();
38059
- void this.evaluate(my, data, symbol);
38060
- }
38061
- stop() {
38062
- this.epoch += 1;
38063
- this.clearTimer();
38064
- }
38065
- async evaluate(my, data, symbol) {
38066
- const resolved = data.resolve(symbol);
38067
- const provider = resolved ? data.providerInstance(resolved.provider) : void 0;
38068
- const si = await data.symbolInfo(symbol).catch(() => void 0);
38069
- if (my !== this.epoch) return;
38070
- const hasSessions = typeof si?.session === "string" && si.session !== "" && si.session !== "24x7";
38071
- if (!provider?.getCalendar || !hasSessions || !resolved) {
38072
- this.onStatus("open");
38073
- return;
38074
- }
38075
- const tz = typeof si?.timezone === "string" && si.timezone !== "" ? si.timezone : "Etc/UTC";
38076
- const now = Date.now();
38077
- const range = { from: now - FETCH_BACK_MS, to: now + FETCH_AHEAD_MS };
38078
- const [regular, extended] = await Promise.all([
38079
- provider.getCalendar(resolved.ticker, { ...range, session: "regular" }).catch(() => null),
38080
- provider.getCalendar(resolved.ticker, { ...range, session: "extended" }).catch(() => null)
38081
- ]);
38082
- if (my !== this.epoch) return;
38083
- if (!regular || !extended) {
38084
- this.arm(my, data, symbol, RETRY_MS2);
38085
- return;
38086
- }
38087
- const w = { regular, extended };
38088
- this.onStatus(deriveMarketStatus(now, w, tz));
38089
- const boundary = nextStatusBoundary(now, w);
38090
- const delay = Math.min(boundary != null ? boundary - now : MAX_TIMER_MS, MAX_TIMER_MS);
38091
- this.arm(my, data, symbol, Math.max(delay, MIN_TIMER_MS));
38092
- }
38093
- arm(my, data, symbol, delay) {
38094
- this.clearTimer();
38095
- this.timer = setTimeout(() => {
38096
- this.timer = null;
38097
- if (my !== this.epoch) return;
38098
- void this.evaluate(my, data, symbol);
38099
- }, delay);
38100
- }
38101
- clearTimer() {
38102
- if (this.timer != null) clearTimeout(this.timer);
38103
- this.timer = null;
38104
- }
38105
- };
38106
-
38107
38167
  // src/widget/session-shading.ts
38108
38168
  var DAY_MS2 = 864e5;
38109
38169
  var MINUTE_MS = 6e4;
38110
- function parseWindow(text) {
38170
+ function parseWindow(text, allowOvernight = false) {
38111
38171
  if (typeof text !== "string") return null;
38112
38172
  const m = /^(\d{2})(\d{2})-(\d{2})(\d{2})$/.exec(text);
38113
38173
  if (!m) return null;
38114
38174
  const start = Number(m[1]) * 60 + Number(m[2]);
38115
38175
  const end = Number(m[3]) * 60 + Number(m[4]);
38116
- if (start >= end || end > 1440) return null;
38176
+ if (end > 1440 || start >= 1440) return null;
38177
+ if (start >= end && !allowOvernight) return null;
38117
38178
  return { start, end };
38118
38179
  }
38119
38180
  function parseSessionSpec(si) {
@@ -38123,9 +38184,13 @@ function parseSessionSpec(si) {
38123
38184
  if (!regular) return null;
38124
38185
  const tz = si?.["timezone"];
38125
38186
  const timezone = typeof tz === "string" && tz !== "" ? tz : "Etc/UTC";
38126
- const ext = parseWindow(si?.["session_extended"]);
38187
+ const ext = parseWindow(si?.["session_extended"], true);
38188
+ if (ext && ext.start >= ext.end) {
38189
+ if (ext.start >= regular.end && ext.end >= regular.end) return { regular, extended: ext, overnight: true, timezone };
38190
+ return { regular, extended: { start: 0, end: 1440 }, overnight: false, timezone };
38191
+ }
38127
38192
  const extended = ext && ext.start <= regular.start && ext.end >= regular.end ? ext : { start: 0, end: 1440 };
38128
- return { regular, extended, timezone };
38193
+ return { regular, extended, overnight: false, timezone };
38129
38194
  }
38130
38195
  var dtfCache = /* @__PURE__ */ new Map();
38131
38196
  function civilFormatter(tz) {
@@ -38161,27 +38226,48 @@ function civilParts(dtf, ms) {
38161
38226
  minute: Number(get("minute"))
38162
38227
  };
38163
38228
  }
38229
+ function mergeAbutting(bands) {
38230
+ const sorted = [...bands].sort((a, b) => a[0] - b[0]);
38231
+ const out = [];
38232
+ for (const [s, e] of sorted) {
38233
+ if (e <= s) continue;
38234
+ const last = out[out.length - 1];
38235
+ if (last && s <= last[1]) last[1] = Math.max(last[1], e);
38236
+ else out.push([s, e]);
38237
+ }
38238
+ return out;
38239
+ }
38164
38240
  function expandSessionZones(spec, from, to) {
38165
38241
  const pre = [];
38166
38242
  const post = [];
38243
+ const extended = [];
38167
38244
  const dtf = civilFormatter(spec.timezone);
38168
- if (!dtf || !Number.isFinite(from) || !Number.isFinite(to)) return { pre, post };
38245
+ if (!dtf || !Number.isFinite(from) || !Number.isFinite(to)) return { pre, post, extended };
38169
38246
  const seen = /* @__PURE__ */ new Set();
38170
38247
  for (let cursor = from - DAY_MS2; cursor < to + DAY_MS2; cursor += DAY_MS2) {
38171
38248
  const civil = civilParts(dtf, cursor);
38172
38249
  const key = civil.year * 1e4 + civil.month * 100 + civil.day;
38173
38250
  if (!Number.isFinite(key) || seen.has(key)) continue;
38174
38251
  seen.add(key);
38175
- if (civil.weekday === "Sat" || civil.weekday === "Sun") continue;
38252
+ const sunday = civil.weekday === "Sun";
38253
+ if (civil.weekday === "Sat" || sunday && !spec.overnight) continue;
38176
38254
  const naive = Date.UTC(civil.year, civil.month - 1, civil.day);
38177
38255
  const noonGuess = naive + 720 * MINUTE_MS;
38178
38256
  const atNoon = civilParts(dtf, noonGuess);
38179
38257
  const offset = Date.UTC(atNoon.year, atNoon.month - 1, atNoon.day, atNoon.hour, atNoon.minute) - noonGuess;
38180
38258
  const at = (minutes) => naive + minutes * MINUTE_MS - offset;
38259
+ if (spec.overnight) {
38260
+ if (!sunday) {
38261
+ extended.push([at(0), at(spec.regular.start)]);
38262
+ if (spec.regular.end < spec.extended.end) extended.push([at(spec.regular.end), at(spec.extended.end)]);
38263
+ }
38264
+ if (civil.weekday !== "Fri") extended.push([at(spec.extended.start), at(1440)]);
38265
+ continue;
38266
+ }
38181
38267
  if (spec.extended.start < spec.regular.start) pre.push([at(spec.extended.start), at(spec.regular.start)]);
38182
38268
  if (spec.regular.end < spec.extended.end) post.push([at(spec.regular.end), at(spec.extended.end)]);
38183
38269
  }
38184
- return { pre, post };
38270
+ return { pre, post, extended: mergeAbutting(extended) };
38185
38271
  }
38186
38272
  var COVER_PAD_MIN_MS = 2 * DAY_MS2;
38187
38273
  var SessionShadingTracker = class {
@@ -38236,7 +38322,7 @@ var SessionShadingTracker = class {
38236
38322
  return;
38237
38323
  }
38238
38324
  if (this.session !== "extended" || !this.intraday) {
38239
- if (force) this.onZones({ pre: [], post: [] });
38325
+ if (force) this.onZones({ pre: [], post: [], extended: [] });
38240
38326
  return;
38241
38327
  }
38242
38328
  const from = Math.min(range.from, range.to);
@@ -38250,6 +38336,112 @@ var SessionShadingTracker = class {
38250
38336
  }
38251
38337
  };
38252
38338
 
38339
+ // src/widget/market-status.ts
38340
+ function civilDate(ms, tz) {
38341
+ try {
38342
+ return new Intl.DateTimeFormat("en-CA", { timeZone: tz }).format(ms);
38343
+ } catch {
38344
+ return "";
38345
+ }
38346
+ }
38347
+ function isWeekday(ms, tz) {
38348
+ try {
38349
+ const wd = new Intl.DateTimeFormat("en-US", { timeZone: tz, weekday: "short" }).format(ms);
38350
+ return wd !== "Sat" && wd !== "Sun";
38351
+ } catch {
38352
+ return false;
38353
+ }
38354
+ }
38355
+ function deriveMarketStatus(now, w, tz, overnight = false) {
38356
+ const within = (ws) => ws.find(([s, e]) => now >= s && now < e);
38357
+ if (within(w.regular)) return "open";
38358
+ const ext = within(w.extended);
38359
+ if (ext) {
38360
+ if (overnight) return "extended";
38361
+ return w.regular.some(([s]) => s >= now && s < ext[1]) ? "pre" : "post";
38362
+ }
38363
+ if (isWeekday(now, tz)) {
38364
+ const today = civilDate(now, tz);
38365
+ if (today !== "" && !w.extended.some(([s]) => civilDate(s, tz) === today)) return "holiday";
38366
+ }
38367
+ return "closed";
38368
+ }
38369
+ function nextStatusBoundary(now, w) {
38370
+ let next = null;
38371
+ for (const ws of [w.regular, w.extended]) {
38372
+ for (const [s, e] of ws) {
38373
+ for (const t of [s, e]) {
38374
+ if (t > now && (next == null || t < next)) next = t;
38375
+ }
38376
+ }
38377
+ }
38378
+ return next;
38379
+ }
38380
+ var FETCH_BACK_MS = 4 * 864e5;
38381
+ var FETCH_AHEAD_MS = 10 * 864e5;
38382
+ var MIN_TIMER_MS = 15e3;
38383
+ var MAX_TIMER_MS = 36e5;
38384
+ var RETRY_MS2 = 6e4;
38385
+ var MarketStatusTracker = class {
38386
+ constructor(onStatus) {
38387
+ this.onStatus = onStatus;
38388
+ /** Invalidates detached async work — bumped by every track()/stop(). */
38389
+ this.epoch = 0;
38390
+ this.timer = null;
38391
+ }
38392
+ /** (Re)bind to a chart's data surface + symbol and start evaluating. */
38393
+ track(data, symbol) {
38394
+ const my = ++this.epoch;
38395
+ this.clearTimer();
38396
+ void this.evaluate(my, data, symbol);
38397
+ }
38398
+ stop() {
38399
+ this.epoch += 1;
38400
+ this.clearTimer();
38401
+ }
38402
+ async evaluate(my, data, symbol) {
38403
+ const resolved = data.resolve(symbol);
38404
+ const provider = resolved ? data.providerInstance(resolved.provider) : void 0;
38405
+ const si = await data.symbolInfo(symbol).catch(() => void 0);
38406
+ if (my !== this.epoch) return;
38407
+ const hasSessions = typeof si?.session === "string" && si.session !== "" && si.session !== "24x7";
38408
+ if (!provider?.getCalendar || !hasSessions || !resolved) {
38409
+ this.onStatus("open");
38410
+ return;
38411
+ }
38412
+ const tz = typeof si?.timezone === "string" && si.timezone !== "" ? si.timezone : "Etc/UTC";
38413
+ const now = Date.now();
38414
+ const range = { from: now - FETCH_BACK_MS, to: now + FETCH_AHEAD_MS };
38415
+ const [regular, extended] = await Promise.all([
38416
+ provider.getCalendar(resolved.ticker, { ...range, session: "regular" }).catch(() => null),
38417
+ provider.getCalendar(resolved.ticker, { ...range, session: "extended" }).catch(() => null)
38418
+ ]);
38419
+ if (my !== this.epoch) return;
38420
+ if (!regular || !extended) {
38421
+ this.arm(my, data, symbol, RETRY_MS2);
38422
+ return;
38423
+ }
38424
+ const w = { regular, extended };
38425
+ const overnight = parseSessionSpec(si)?.overnight === true;
38426
+ this.onStatus(deriveMarketStatus(now, w, tz, overnight));
38427
+ const boundary = nextStatusBoundary(now, w);
38428
+ const delay = Math.min(boundary != null ? boundary - now : MAX_TIMER_MS, MAX_TIMER_MS);
38429
+ this.arm(my, data, symbol, Math.max(delay, MIN_TIMER_MS));
38430
+ }
38431
+ arm(my, data, symbol, delay) {
38432
+ this.clearTimer();
38433
+ this.timer = setTimeout(() => {
38434
+ this.timer = null;
38435
+ if (my !== this.epoch) return;
38436
+ void this.evaluate(my, data, symbol);
38437
+ }, delay);
38438
+ }
38439
+ clearTimer() {
38440
+ if (this.timer != null) clearTimeout(this.timer);
38441
+ this.timer = null;
38442
+ }
38443
+ };
38444
+
38253
38445
  // src/widget/watermark.ts
38254
38446
  var MAX_FONT_PX = 36;
38255
38447
  var MIN_FONT_PX = 12;
@@ -38763,7 +38955,10 @@ var ChartCell = class {
38763
38955
  this.activeRangeId = null;
38764
38956
  /** Latched verdict of {@link sessionAvailable} (async metadata, sticky per symbol). */
38765
38957
  this.sessionAvailableFlag = false;
38766
- /** Keeps the pre/post-market shading on the symbol's real calendar (see {@link SessionShadingTracker}). */
38958
+ /** Latched: the symbol's extended tape wraps midnight (an overnight roll market)
38959
+ * one extended-hours shading phase instead of the pre/post split. */
38960
+ this.sessionOvernightFlag = false;
38961
+ /** Keeps the session shading on the symbol's real calendar (see {@link SessionShadingTracker}). */
38767
38962
  this.sessionShading = new SessionShadingTracker((zones) => this.inner?.renderer.set("sessionZones", zones));
38768
38963
  this.manifest = [];
38769
38964
  /** A restored ledger's manifest entry NAMES, waiting for the manifest to resolve
@@ -39016,8 +39211,10 @@ var ChartCell = class {
39016
39211
  void chart.data.symbolInfo(symbol).then((si) => {
39017
39212
  if (this.inner !== chart) return;
39018
39213
  const available = typeof si?.session === "string" && si.session !== "" && si.session !== "24x7";
39019
- if (available !== this.sessionAvailableFlag) {
39214
+ const overnight = parseSessionSpec(si)?.overnight === true;
39215
+ if (available !== this.sessionAvailableFlag || overnight !== this.sessionOvernightFlag) {
39020
39216
  this.sessionAvailableFlag = available;
39217
+ this.sessionOvernightFlag = overnight;
39021
39218
  this.deps.onMarketChanged(this.id);
39022
39219
  this.pushSettingsSections();
39023
39220
  }
@@ -39051,16 +39248,41 @@ var ChartCell = class {
39051
39248
  /**
39052
39249
  * (Re)contribute this cell's settings-dialog sections: status line parts, the
39053
39250
  * per-cell fetch depth, the watermark toggle, and — only while the cell's symbol
39054
- * HAS sessions — the Trading session group (RTH/ETH switch + the pre/post-market
39055
- * shading colors) inside the Symbol tab. Bars/watermark/titles are persistable
39056
- * cell state; a depth-only reload is silent, so mark dirty here. Re-run whenever
39057
- * a gate changes (the dialog reads the sections on open).
39251
+ * HAS sessions — the Trading session group (RTH/ETH switch + the session shading
39252
+ * colors: pre/post-market on day-split tapes, one extended-hours swatch on
39253
+ * overnight roll markets) inside the Symbol tab. Bars/watermark/titles are
39254
+ * persistable cell state; a depth-only reload is silent, so mark dirty here.
39255
+ * Re-run whenever a gate changes (the dialog reads the sections on open).
39058
39256
  */
39059
39257
  pushSettingsSections() {
39060
39258
  const chart = this.inner;
39061
39259
  if (!chart) return;
39062
39260
  const rth = "Regular hours (RTH)";
39063
39261
  const eth = "Extended hours (ETH)";
39262
+ const shadeRows = this.sessionOvernightFlag ? [
39263
+ {
39264
+ kind: "color",
39265
+ label: "Extended hours",
39266
+ id: "extended-color",
39267
+ get: () => this.sessionShadeColor("extendedColor"),
39268
+ set: (v) => this.setSessionShadeColor("extendedColor", v)
39269
+ }
39270
+ ] : [
39271
+ {
39272
+ kind: "color",
39273
+ label: "Pre-market",
39274
+ id: "premarket-color",
39275
+ get: () => this.sessionShadeColor("premarketColor"),
39276
+ set: (v) => this.setSessionShadeColor("premarketColor", v)
39277
+ },
39278
+ {
39279
+ kind: "color",
39280
+ label: "Post-market",
39281
+ id: "postmarket-color",
39282
+ get: () => this.sessionShadeColor("postmarketColor"),
39283
+ set: (v) => this.setSessionShadeColor("postmarketColor", v)
39284
+ }
39285
+ ];
39064
39286
  const sessionSection = {
39065
39287
  title: "Trading session",
39066
39288
  id: "trading-session",
@@ -39074,20 +39296,7 @@ var ChartCell = class {
39074
39296
  get: () => this.session === "extended" ? eth : rth,
39075
39297
  set: (v) => this.setSession(v === eth ? "extended" : "regular")
39076
39298
  },
39077
- {
39078
- kind: "color",
39079
- label: "Pre-market",
39080
- id: "premarket-color",
39081
- get: () => this.sessionShadeColor("premarketColor"),
39082
- set: (v) => this.setSessionShadeColor("premarketColor", v)
39083
- },
39084
- {
39085
- kind: "color",
39086
- label: "Post-market",
39087
- id: "postmarket-color",
39088
- get: () => this.sessionShadeColor("postmarketColor"),
39089
- set: (v) => this.setSessionShadeColor("postmarketColor", v)
39090
- }
39299
+ ...shadeRows
39091
39300
  ]
39092
39301
  };
39093
39302
  const advanced = {
@@ -39331,6 +39540,10 @@ var ChartCell = class {
39331
39540
  this.deps.activate(this.id);
39332
39541
  this.inner?.renderer.focus();
39333
39542
  }
39543
+ /** Raster of this cell's chart (same pixels as the PNG download), or null. */
39544
+ screenshotCanvas() {
39545
+ return this.inner?.renderer.screenshotCanvas() ?? null;
39546
+ }
39334
39547
  /** Download this cell's chart as a PNG (named after its market). */
39335
39548
  downloadScreenshot() {
39336
39549
  const url = this.inner?.renderer.screenshot();
@@ -39943,6 +40156,54 @@ var SplitterLayer = class {
39943
40156
  }
39944
40157
  };
39945
40158
 
40159
+ // src/workspace/screenshot.ts
40160
+ function destRect(tile, dpr) {
40161
+ const dx = Math.round(tile.x * dpr);
40162
+ const dy = Math.round(tile.y * dpr);
40163
+ return {
40164
+ dx,
40165
+ dy,
40166
+ dw: Math.round((tile.x + tile.width) * dpr) - dx,
40167
+ dh: Math.round((tile.y + tile.height) * dpr) - dy
40168
+ };
40169
+ }
40170
+ function tilesFromCellRects(grid, cells) {
40171
+ if (grid.width <= 0 || grid.height <= 0) return [];
40172
+ const tiles = [];
40173
+ for (const c of cells) {
40174
+ if (c.hidden || c.width <= 0 || c.height <= 0) continue;
40175
+ tiles.push({ x: c.left - grid.left, y: c.top - grid.top, width: c.width, height: c.height });
40176
+ }
40177
+ return tiles;
40178
+ }
40179
+ function compositeLayoutScreenshot(doc, frame, tiles) {
40180
+ if (frame.width <= 0 || frame.height <= 0 || tiles.length === 0) return null;
40181
+ const dpr = frame.dpr > 0 ? frame.dpr : 1;
40182
+ const out = doc.createElement("canvas");
40183
+ out.width = Math.max(1, Math.round(frame.width * dpr));
40184
+ out.height = Math.max(1, Math.round(frame.height * dpr));
40185
+ const ctx = out.getContext("2d");
40186
+ if (!ctx) return null;
40187
+ ctx.fillStyle = frame.gapColor;
40188
+ ctx.fillRect(0, 0, out.width, out.height);
40189
+ let painted = 0;
40190
+ for (const tile of tiles) {
40191
+ if (tile.width <= 0 || tile.height <= 0) continue;
40192
+ const { dx, dy, dw, dh } = destRect(tile, dpr);
40193
+ if (dw <= 0 || dh <= 0) continue;
40194
+ ctx.drawImage(tile.source, dx, dy, dw, dh);
40195
+ painted += 1;
40196
+ }
40197
+ if (painted === 0) return null;
40198
+ return out.toDataURL("image/png");
40199
+ }
40200
+ function triggerPngDownload(doc, url, filename) {
40201
+ const a = doc.createElement("a");
40202
+ a.href = url;
40203
+ a.download = filename;
40204
+ a.click();
40205
+ }
40206
+
39946
40207
  // src/workspace/VelaWorkspace.ts
39947
40208
  var DEFAULT_TIMEFRAMES = ["1", "5", "15", "30", "60", "240", "D", "W", "M"];
39948
40209
  var GAP_PX = 2;
@@ -40173,7 +40434,7 @@ var VelaWorkspace = class {
40173
40434
  ...picker ? { onIndicatorsClick: () => picker.open() } : {},
40174
40435
  onUndoClick: () => this.active.history.undo(),
40175
40436
  onRedoClick: () => this.active.history.redo(),
40176
- onScreenshotClick: () => this.active.downloadScreenshot(),
40437
+ onScreenshotClick: () => this.downloadScreenshot(),
40177
40438
  onAlertsClick: (anchor) => this.openAlertsMenu(anchor),
40178
40439
  timeframe: "60",
40179
40440
  timeframes: opts.timeframes ?? DEFAULT_TIMEFRAMES,
@@ -40232,6 +40493,7 @@ var VelaWorkspace = class {
40232
40493
  this.dock.addBuiltIn({ id: "dataWindow", title: "Data window", icon: "datawindow", order: 10, panel: this.dataWindow, onChart: (c) => this.dataWindow.onChart(c) });
40233
40494
  this.dock.addBuiltIn({ id: "objects", title: "Object tree", icon: "objects", order: 20, panel: this.objectTree, onChart: (c) => this.objectTree.onChart(c) });
40234
40495
  this.dock.refresh();
40496
+ if (boot?.panels) this.dock.applyState(boot.panels);
40235
40497
  this.root.appendChild(main);
40236
40498
  this.toastHost = new Toast(this.gridEl);
40237
40499
  const attribution = rendererDefaults().attribution;
@@ -40243,6 +40505,7 @@ var VelaWorkspace = class {
40243
40505
  bottom: `calc(var(--vela-bottom-gutter, ${TIME_AXIS_H4}px) + 10px)`,
40244
40506
  zIndex: "11"
40245
40507
  });
40508
+ mark.dataset.velaScreenshot = "1";
40246
40509
  this.gridEl.appendChild(mark);
40247
40510
  this.attributionMark = mark;
40248
40511
  }
@@ -40379,6 +40642,56 @@ var VelaWorkspace = class {
40379
40642
  get chart() {
40380
40643
  return this.active.chart;
40381
40644
  }
40645
+ /**
40646
+ * PNG data URL of the visible layout: every live cell in its grid slot, or the
40647
+ * maximized cell alone. Same pixels the screenshot button downloads. A single
40648
+ * visible cell returns that cell's own chart export (the one-chart case).
40649
+ */
40650
+ screenshot() {
40651
+ const cells = this.shotCells();
40652
+ if (cells.length === 0) return null;
40653
+ if (cells.length === 1) return cells[0].chart.renderer.screenshot();
40654
+ return this.compositeLayoutShot(cells);
40655
+ }
40656
+ /** Download {@link screenshot} as a PNG. A multi-cell layout is named
40657
+ * `vela-layout.png`; one visible cell keeps `${symbol}-${timeframe}.png`. */
40658
+ downloadScreenshot() {
40659
+ const cells = this.shotCells();
40660
+ if (cells.length <= 1) {
40661
+ (cells[0] ?? this.active).downloadScreenshot();
40662
+ return;
40663
+ }
40664
+ const url = this.compositeLayoutShot(cells);
40665
+ if (!url) return;
40666
+ triggerPngDownload(this.root.ownerDocument, url, "vela-layout.png");
40667
+ }
40668
+ /** Live cells the screenshot should include — maximized siblings are `hidden`. */
40669
+ shotCells() {
40670
+ return this.cells().filter((c) => c.host.style.visibility !== "hidden");
40671
+ }
40672
+ /** Place every cell's raster onto one canvas the size of the grid. */
40673
+ compositeLayoutShot(cells) {
40674
+ const gridEl = this.gridEl;
40675
+ const grid = gridEl.getBoundingClientRect();
40676
+ const win = gridEl.ownerDocument.defaultView;
40677
+ const dpr = win?.devicePixelRatio ?? 1;
40678
+ const gapColor = win ? win.getComputedStyle(gridEl).backgroundColor : "";
40679
+ const tiles = [];
40680
+ for (const cell of cells) {
40681
+ const source = cell.screenshotCanvas();
40682
+ if (!source) continue;
40683
+ const r = cell.host.getBoundingClientRect();
40684
+ const place = tilesFromCellRects(grid, [{ left: r.left, top: r.top, width: r.width, height: r.height }])[0];
40685
+ if (!place) continue;
40686
+ tiles.push({ source, ...place });
40687
+ }
40688
+ return compositeLayoutScreenshot(gridEl.ownerDocument, {
40689
+ width: grid.width,
40690
+ height: grid.height,
40691
+ dpr,
40692
+ gapColor: gapColor || "#000000"
40693
+ }, tiles);
40694
+ }
40382
40695
  setActiveCell(id) {
40383
40696
  if (id === this.activeId || this.destroyed) return;
40384
40697
  const prev2 = this.activeId;
@@ -41425,7 +41738,7 @@ var VelaWorkspace = class {
41425
41738
  this.moreDrawer ?? (this.moreDrawer = new MoreDrawer({
41426
41739
  host: this.root,
41427
41740
  ...has("undo-redo") ? { onUndo: () => this.active.history.undo(), onRedo: () => this.active.history.redo() } : {},
41428
- ...has("screenshot") ? { onScreenshot: this.screenshotOverride ? () => this.runOverride(this.screenshotOverride) : () => this.active.downloadScreenshot() } : {},
41741
+ ...has("screenshot") ? { onScreenshot: this.screenshotOverride ? () => this.runOverride(this.screenshotOverride) : () => this.downloadScreenshot() } : {},
41429
41742
  canUndo: () => this.active.history.canUndo,
41430
41743
  canRedo: () => this.active.history.canRedo,
41431
41744
  priceStyles: () => priceStyleIds().map((id) => ({ id, label: priceStyleLabel(id), icon: priceStyleIcon(id) })),
@@ -41526,9 +41839,9 @@ var VelaWorkspace = class {
41526
41839
  this.keymap.register({
41527
41840
  id: "chart.screenshot",
41528
41841
  keys: "mod+alt+s",
41529
- label: ov ? ov.label : "Download a chart screenshot",
41842
+ label: ov ? ov.label : "Download a screenshot of the layout",
41530
41843
  category: "Chart",
41531
- run: ov ? () => this.runOverride(ov) : () => this.active.downloadScreenshot()
41844
+ run: ov ? () => this.runOverride(ov) : () => this.downloadScreenshot()
41532
41845
  });
41533
41846
  }
41534
41847
  this.keymap.register({ id: "chart.reset-view", keys: "alt+r", label: "Reset view (all history)", category: "Chart", run: () => this.active.chart.setVisibleRangePreset("ALL") });