@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
@@ -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"/>'));
@@ -4968,6 +4981,7 @@ var BASELINE_FILL_ALPHA_FAR = 0.05;
4968
4981
  var BASELINE_LEVEL_DEFAULT = 50;
4969
4982
  var PREMARKET_SHADE = withAlpha(WARNING, 0.08);
4970
4983
  var POSTMARKET_SHADE = withAlpha(ACCENT, 0.08);
4984
+ var EXTENDED_SHADE = POSTMARKET_SHADE;
4971
4985
  function defaultChartStyle() {
4972
4986
  return {
4973
4987
  chartTypes: {},
@@ -4976,7 +4990,7 @@ function defaultChartStyle() {
4976
4990
  gridHorz: { visible: true, color: null },
4977
4991
  borderColor: null,
4978
4992
  separatorColor: null,
4979
- crosshair: { color: CROSSHAIR, width: 1, style: "dashed", opacity: 0.4, labelBackground: SLATE },
4993
+ crosshair: { color: CROSSHAIR, width: 1, style: "dashed", opacity: 0.4, labelBackground: CHIP_PLATE },
4980
4994
  candle: {
4981
4995
  bodyVisible: true,
4982
4996
  borderVisible: false,
@@ -4999,7 +5013,7 @@ function defaultChartStyle() {
4999
5013
  width: 2,
5000
5014
  baselineLevel: BASELINE_LEVEL_DEFAULT
5001
5015
  },
5002
- sessions: { premarketColor: PREMARKET_SHADE, postmarketColor: POSTMARKET_SHADE }
5016
+ sessions: { premarketColor: PREMARKET_SHADE, postmarketColor: POSTMARKET_SHADE, extendedColor: EXTENDED_SHADE }
5003
5017
  };
5004
5018
  }
5005
5019
  var LINE_STYLES = ["solid", "dashed", "dotted"];
@@ -5228,7 +5242,8 @@ function mergeConfig(base, patch) {
5228
5242
  },
5229
5243
  sessions: {
5230
5244
  premarketColor: isColor(sessions.premarketColor) ? sessions.premarketColor : base.sessions.premarketColor,
5231
- postmarketColor: isColor(sessions.postmarketColor) ? sessions.postmarketColor : base.sessions.postmarketColor
5245
+ postmarketColor: isColor(sessions.postmarketColor) ? sessions.postmarketColor : base.sessions.postmarketColor,
5246
+ extendedColor: isColor(sessions.extendedColor) ? sessions.extendedColor : base.sessions.extendedColor
5232
5247
  },
5233
5248
  stacking: {
5234
5249
  candles: isNum(stacking.candles) ? stacking.candles : base.stacking.candles,
@@ -12224,6 +12239,22 @@ var CSS5 = `
12224
12239
  }
12225
12240
  .vela-panel-close .vela-icon { width: 16px; height: 16px; }
12226
12241
  .vela-panel-close:hover { background: var(--vela-hover); color: var(--vela-fg-bright); }
12242
+ /* The pin: same footprint as the close button it sits beside; filled + bright once pinned. */
12243
+ .vela-panel-pin {
12244
+ all: unset;
12245
+ cursor: pointer;
12246
+ width: 26px;
12247
+ height: 26px;
12248
+ flex: none;
12249
+ display: inline-flex;
12250
+ align-items: center;
12251
+ justify-content: center;
12252
+ border-radius: 4px;
12253
+ color: var(--vela-fg-muted);
12254
+ }
12255
+ .vela-panel-pin .vela-icon { width: 15px; height: 15px; }
12256
+ .vela-panel-pin:hover { background: var(--vela-hover); color: var(--vela-fg-bright); }
12257
+ .vela-panel-pin[data-on='1'] { color: var(--vela-fg-bright); }
12227
12258
  .vela-panel-body { flex: 1; overflow: auto; padding: 8px; }
12228
12259
  .vela-panel-body::-webkit-scrollbar { width: 8px; }
12229
12260
  .vela-panel-body::-webkit-scrollbar-thumb { background: var(--vela-scroll); border-radius: 4px; border: 2px solid transparent; background-clip: padding-box; }
@@ -12248,8 +12279,23 @@ var CSS5 = `
12248
12279
  width: 1px;
12249
12280
  background: transparent;
12250
12281
  }
12282
+ /* The same hover ink as the workspace grid splitters and the canvas pane separators, so
12283
+ every draggable seam in the shell reads as one family. */
12251
12284
  .vela-panel-resizer:hover::after,
12252
- .vela-panel-resizer[data-dragging]::after { background: var(--vela-accent); }
12285
+ .vela-panel-resizer[data-dragging]::after { background: var(--vela-separator-hover-line); }
12286
+ /* An OVERLAY panel floats over the chart's right edge instead of taking a column: the
12287
+ chart keeps its width and layout (the dock host is position:relative \u2014 the anchoring the
12288
+ mobile rule below relies on too). Same z-order as that mobile overlay, above the chart's
12289
+ own DOM overlays; the shadow separates it from the plot it covers. */
12290
+ .vela-panel[data-overlay] {
12291
+ position: absolute;
12292
+ top: 0;
12293
+ right: 0;
12294
+ bottom: 0;
12295
+ max-width: 100%;
12296
+ z-index: 25;
12297
+ box-shadow: var(--vela-shadow);
12298
+ }
12253
12299
  /* Mobile: a 280px column would crush a phone-width chart \u2014 the panel overlays the
12254
12300
  chart area instead (its flex parent is position:relative), full-bleed, closed by
12255
12301
  the same header \u2715. Width dragging is a pointer affordance; off on mobile. */
@@ -12280,6 +12326,10 @@ var SidePanel = class {
12280
12326
  /** Notified when the USER settles a new width (drag release, or double-click reset) — never on
12281
12327
  * a programmatic {@link setWidth}, so restoring a persisted width raises no change. */
12282
12328
  this.onWidthChange = null;
12329
+ /** Notified when the USER pins or unpins a floating panel (`overlay` is the NEW placement) —
12330
+ * never on a programmatic {@link setOverlay}, for the same reason as widths. */
12331
+ this.onPlacementChange = null;
12332
+ this.pin = null;
12283
12333
  const doc = host.ownerDocument;
12284
12334
  injectStyles(STYLE_ID4, CSS5, doc);
12285
12335
  this.minWidth = opts.resizable ? opts.minWidth ?? DEFAULT_PANEL_MIN_WIDTH : 1;
@@ -12289,6 +12339,9 @@ var SidePanel = class {
12289
12339
  this.el = doc.createElement("div");
12290
12340
  this.el.className = `vela-panel ${modifier}`;
12291
12341
  this.el.hidden = true;
12342
+ this.floatable = opts.overlay === true;
12343
+ this.overlayOn = this.floatable;
12344
+ if (this.overlayOn) this.el.dataset.overlay = "1";
12292
12345
  this.el.style.setProperty("--vela-panel-w", `${this.widthPx}px`);
12293
12346
  const header = doc.createElement("div");
12294
12347
  header.className = "vela-panel-header";
@@ -12302,7 +12355,16 @@ var SidePanel = class {
12302
12355
  close.appendChild(iconEl("close", doc));
12303
12356
  close.title = "Close";
12304
12357
  close.addEventListener("click", () => this.toggle(false));
12305
- header.append(this.heading, this.slot, close);
12358
+ if (this.floatable) {
12359
+ this.pin = doc.createElement("button");
12360
+ this.pin.className = "vela-panel-pin";
12361
+ this.pin.addEventListener("click", () => {
12362
+ this.setOverlay(!this.overlayOn);
12363
+ this.onPlacementChange?.(this.overlayOn);
12364
+ });
12365
+ this.refreshPin();
12366
+ }
12367
+ header.append(this.heading, this.slot, ...this.pin ? [this.pin] : [], close);
12306
12368
  this.body = doc.createElement("div");
12307
12369
  this.body.className = "vela-panel-body";
12308
12370
  this.el.append(header, this.body);
@@ -12344,6 +12406,29 @@ var SidePanel = class {
12344
12406
  this.widthPx = next;
12345
12407
  this.el.style.setProperty("--vela-panel-w", `${next}px`);
12346
12408
  }
12409
+ /** Whether the panel floats over the chart right now (false for every docked panel). */
12410
+ get overlay() {
12411
+ return this.overlayOn;
12412
+ }
12413
+ /** Float or dock the panel. Only a panel declared `overlay` can float — on any other this is
12414
+ * a no-op. Silent — {@link onPlacementChange} reports the user's pin clicks only. */
12415
+ setOverlay(overlay) {
12416
+ if (!this.floatable || overlay === this.overlayOn) return;
12417
+ this.overlayOn = overlay;
12418
+ if (overlay) this.el.dataset.overlay = "1";
12419
+ else delete this.el.dataset.overlay;
12420
+ this.refreshPin();
12421
+ }
12422
+ refreshPin() {
12423
+ if (!this.pin) return;
12424
+ const pinned = !this.overlayOn;
12425
+ const doc = this.pin.ownerDocument;
12426
+ this.pin.replaceChildren(iconEl(pinned ? "pin-filled" : "pin", doc));
12427
+ this.pin.dataset.on = pinned ? "1" : "0";
12428
+ this.pin.title = pinned ? "Unpin \u2014 float over the chart" : "Pin beside the chart";
12429
+ this.pin.setAttribute("aria-label", this.pin.title);
12430
+ this.pin.setAttribute("aria-pressed", pinned ? "true" : "false");
12431
+ }
12347
12432
  destroy() {
12348
12433
  this.el.remove();
12349
12434
  }
@@ -13744,6 +13829,8 @@ var PanelDock = class {
13744
13829
  this.entries = [];
13745
13830
  /** Widths the USER settled, by panel id — the only ones worth persisting. */
13746
13831
  this.widths = /* @__PURE__ */ new Map();
13832
+ /** Floating panels the USER pinned as columns, by id — remembered for late registrations too. */
13833
+ this.pinned = /* @__PURE__ */ new Set();
13747
13834
  /** A restored `open` naming a panel that has not registered yet: honored when it docks. */
13748
13835
  this.pendingOpen = null;
13749
13836
  this.chart = null;
@@ -13766,7 +13853,8 @@ var PanelDock = class {
13766
13853
  width: desc.width,
13767
13854
  resizable: desc.resizable,
13768
13855
  minWidth: desc.minWidth,
13769
- maxWidth: desc.maxWidth
13856
+ maxWidth: desc.maxWidth,
13857
+ overlay: desc.overlay
13770
13858
  });
13771
13859
  const entry = {
13772
13860
  id: desc.id,
@@ -13814,7 +13902,8 @@ var PanelDock = class {
13814
13902
  const open2 = this.openId;
13815
13903
  if (open2) out.open = open2;
13816
13904
  if (this.widths.size > 0) out.widths = Object.fromEntries(this.widths);
13817
- return out.open || out.widths ? out : null;
13905
+ if (this.pinned.size > 0) out.pinned = [...this.pinned];
13906
+ return out.open || out.widths || out.pinned ? out : null;
13818
13907
  }
13819
13908
  /**
13820
13909
  * Restore a persisted dock state. Widths apply to any panel present, and are remembered for
@@ -13822,7 +13911,8 @@ var PanelDock = class {
13822
13911
  * a document that predates the dock has no `panels` field at all, so the shell never calls
13823
13912
  * this and the default (everything closed) stands. An `open` naming a panel that has not
13824
13913
  * registered yet is held until it docks (a plugin loaded after the restore), unless the user
13825
- * opens something in the meantime.
13914
+ * opens something in the meantime. `pinned` is the whole list of floating panels the user
13915
+ * docked — its absence means none is, so every floatable panel floats again.
13826
13916
  */
13827
13917
  applyState(state) {
13828
13918
  if (!state) return;
@@ -13832,6 +13922,8 @@ var PanelDock = class {
13832
13922
  this.entries.find((e) => e.id === id)?.panel.setWidth(px);
13833
13923
  }
13834
13924
  }
13925
+ this.pinned = new Set(state.pinned ?? []);
13926
+ for (const entry of this.entries) entry.panel.setOverlay(!this.pinned.has(entry.id));
13835
13927
  for (const entry of this.entries) entry.panel.toggle(entry.id === state.open);
13836
13928
  this.pendingOpen = state.open && !this.entries.some((e) => e.id === state.open) ? state.open : null;
13837
13929
  }
@@ -13845,6 +13937,7 @@ var PanelDock = class {
13845
13937
  this.entries.sort((a, b) => a.order - b.order);
13846
13938
  const stored = this.widths.get(entry.id);
13847
13939
  if (stored !== void 0) entry.panel.setWidth(stored);
13940
+ if (this.pinned.has(entry.id)) entry.panel.setOverlay(false);
13848
13941
  entry.panel.onOpenChange = (open2) => {
13849
13942
  if (open2) {
13850
13943
  for (const other of this.entries) if (other !== entry) other.panel.toggle(false);
@@ -13858,6 +13951,11 @@ var PanelDock = class {
13858
13951
  this.widths.set(entry.id, px);
13859
13952
  this.deps.changed?.();
13860
13953
  };
13954
+ entry.panel.onPlacementChange = (overlay) => {
13955
+ if (overlay) this.pinned.delete(entry.id);
13956
+ else this.pinned.add(entry.id);
13957
+ this.deps.changed?.();
13958
+ };
13861
13959
  if (this.pendingOpen === entry.id) entry.panel.toggle(true);
13862
13960
  if (!entry.contributed) this.publish();
13863
13961
  }
@@ -13933,6 +14031,17 @@ function filterSymbols(list, query, limit = 100, top = TOP_TICKERS) {
13933
14031
  }
13934
14032
  var STYLE_ID7 = "vela-widget-symbolpicker";
13935
14033
  var CSS8 = `
14034
+ /* Search + market tabs stay pinned while the result list scrolls underneath
14035
+ (mobile fullscreen: the dialog body is the scroller). Negative margin eats
14036
+ the body's padding so scrolled rows cannot peek around the island. */
14037
+ .vela-sp-sticky {
14038
+ position: sticky;
14039
+ top: 0;
14040
+ z-index: 1;
14041
+ background: var(--vela-surface);
14042
+ margin: calc(-1 * var(--vela-space-4)) calc(-1 * var(--vela-space-4)) 0;
14043
+ padding: var(--vela-space-4) var(--vela-space-4) 0;
14044
+ }
13936
14045
  .vela-sp-searchrow {
13937
14046
  display: flex;
13938
14047
  align-items: center;
@@ -13952,8 +14061,10 @@ var CSS8 = `
13952
14061
  border: none;
13953
14062
  font-size: 14px;
13954
14063
  outline: none;
14064
+ text-transform: uppercase;
13955
14065
  }
13956
- .vela-sp-tabs { display: flex; gap: 14px; margin: 12px 2px 6px; border-bottom: 1px solid var(--vela-border); padding-bottom: 8px; }
14066
+ .vela-sp-input::placeholder { text-transform: none; }
14067
+ .vela-sp-tabs { display: flex; gap: 14px; margin: 12px 2px 0; border-bottom: 1px solid var(--vela-border); padding-bottom: 8px; }
13957
14068
  /* Mobile (fullscreen dialog): the asset-class strip scrolls sideways instead of
13958
14069
  overflowing the body, and the result list stops capping itself \u2014 the body owns
13959
14070
  the scrolling in the fullscreen presentation. */
@@ -14002,7 +14113,7 @@ var CSS8 = `
14002
14113
  font-weight: 700;
14003
14114
  }
14004
14115
  .vela-sp-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
14005
- .vela-sp-ticker { font-weight: 700; color: var(--vela-fg-bright); font-size: 14px; }
14116
+ .vela-sp-ticker { font-weight: 700; color: var(--vela-fg-bright); font-size: 14px; text-transform: uppercase; }
14006
14117
  .vela-sp-desc { color: var(--vela-fg-muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
14007
14118
  .vela-sp-badge {
14008
14119
  flex: none;
@@ -14065,6 +14176,8 @@ var SymbolPicker = class {
14065
14176
  searchRow.append(iconEl("search", doc), this.input);
14066
14177
  this.tabs = doc.createElement("div");
14067
14178
  this.tabs.className = "vela-sp-tabs";
14179
+ const sticky = doc.createElement("div");
14180
+ sticky.className = "vela-sp-sticky";
14068
14181
  for (const t of ["All", "Stocks", "ETFs", "Crypto", "Futures", "Forex", "Commodities"]) {
14069
14182
  const b = doc.createElement("button");
14070
14183
  b.className = "vela-sp-tab";
@@ -14078,6 +14191,7 @@ var SymbolPicker = class {
14078
14191
  });
14079
14192
  this.tabs.appendChild(b);
14080
14193
  }
14194
+ sticky.append(searchRow, this.tabs);
14081
14195
  this.list = doc.createElement("div");
14082
14196
  this.list.className = "vela-sp-list";
14083
14197
  this.list.addEventListener("scroll", () => {
@@ -14090,21 +14204,32 @@ var SymbolPicker = class {
14090
14204
  title: "Symbol Search",
14091
14205
  host: opts.host,
14092
14206
  closeOnInteractOutside: true,
14093
- content: (body) => body.append(searchRow, this.tabs, this.list),
14207
+ content: (body) => body.append(sticky, this.list),
14094
14208
  onOpenChange: (open2) => {
14095
14209
  if (open2) {
14096
- this.input.value = this.seed;
14210
+ this.input.value = this.seed.toUpperCase();
14097
14211
  this.seed = "";
14098
14212
  this.refresh();
14099
14213
  setTimeout(() => {
14100
14214
  this.input.focus();
14101
14215
  this.input.setSelectionRange(this.input.value.length, this.input.value.length);
14102
14216
  }, 0);
14217
+ } else {
14218
+ this.input.blur();
14103
14219
  }
14104
14220
  opts.onOpenChange?.(open2);
14105
14221
  }
14106
14222
  });
14107
- this.input.addEventListener("input", () => this.refresh());
14223
+ this.input.addEventListener("input", () => {
14224
+ const next = this.input.value.toUpperCase();
14225
+ if (this.input.value !== next) {
14226
+ const start = this.input.selectionStart;
14227
+ const end = this.input.selectionEnd;
14228
+ this.input.value = next;
14229
+ if (start != null && end != null) this.input.setSelectionRange(start, end);
14230
+ }
14231
+ this.refresh();
14232
+ });
14108
14233
  this.input.addEventListener("keydown", (e) => {
14109
14234
  if (e.key === "ArrowDown") this.moveHighlight(1);
14110
14235
  else if (e.key === "ArrowUp") this.moveHighlight(-1);
@@ -17724,6 +17849,7 @@ function createCustomMark(doc, html, background) {
17724
17849
  });
17725
17850
  el.style.color = attributionMarkColor(background);
17726
17851
  el.innerHTML = html;
17852
+ el.dataset.velaScreenshot = "1";
17727
17853
  return el;
17728
17854
  }
17729
17855
  function createAttributionMark(doc, background) {
@@ -17742,6 +17868,7 @@ function createAttributionMark(doc, background) {
17742
17868
  cursor: "pointer"
17743
17869
  });
17744
17870
  applyAttributionMarkTheme(a, background);
17871
+ a.dataset.velaScreenshot = "1";
17745
17872
  const symbol = doc.createElement("span");
17746
17873
  symbol.className = "vela-attr-symbol";
17747
17874
  symbol.setAttribute("aria-hidden", "true");
@@ -17910,7 +18037,11 @@ function sanitizePanels(raw) {
17910
18037
  }
17911
18038
  if (Object.keys(widths).length > 0) out.widths = widths;
17912
18039
  }
17913
- return out.open || out.widths ? out : null;
18040
+ if (Array.isArray(p.pinned)) {
18041
+ const pinned = p.pinned.filter((id) => typeof id === "string" && id !== "");
18042
+ if (pinned.length > 0) out.pinned = [...new Set(pinned)];
18043
+ }
18044
+ return out.open || out.widths || out.pinned ? out : null;
17914
18045
  }
17915
18046
  function sanitizeTrackSizes(raw) {
17916
18047
  if (raw == null || typeof raw !== "object") return null;
@@ -20087,6 +20218,15 @@ var RendererControl = class {
20087
20218
  console.warn(`[vela] renderer "${this.renderer.name}" does not support screenshot().`);
20088
20219
  return null;
20089
20220
  }
20221
+ /**
20222
+ * Raster of the current chart onto a canvas (same pixels as {@link screenshot}),
20223
+ * or null if the renderer has no canvas export. Silent — a host compositing
20224
+ * several charts skips a renderer that cannot contribute.
20225
+ */
20226
+ screenshotCanvas() {
20227
+ if (typeof this.renderer.screenshotCanvas === "function") return this.renderer.screenshotCanvas();
20228
+ return null;
20229
+ }
20090
20230
  /**
20091
20231
  * The active renderer's full cosmetic config as a serializable, versioned JSON
20092
20232
  * document — persist it (templates, saved settings) and feed it back to
@@ -22655,6 +22795,66 @@ function candleGeometry(xCss, spacing, dpr, bodyScale = 1) {
22655
22795
  center: (wickLeftDev + wickDev / 2) / dpr
22656
22796
  };
22657
22797
  }
22798
+ function aggregateCandleColumns(bars, i0, i1, xOf, yOf) {
22799
+ const out = [];
22800
+ let col = NaN;
22801
+ let intervals = [];
22802
+ const flush = () => {
22803
+ if (intervals.length === 0) return;
22804
+ const merged = [intervals[0]];
22805
+ for (let k = 1; k < intervals.length; k += 1) {
22806
+ const prev2 = merged[merged.length - 1];
22807
+ const next = intervals[k];
22808
+ if (Math.abs(yOf(prev2.hi) - yOf(next.lo)) < 1) {
22809
+ prev2.hi = next.hi;
22810
+ if (next.headIdx < prev2.headIdx) {
22811
+ prev2.headIdx = next.headIdx;
22812
+ prev2.headTime = next.headTime;
22813
+ prev2.open = next.open;
22814
+ }
22815
+ if (next.lastIdx > prev2.lastIdx) {
22816
+ prev2.lastIdx = next.lastIdx;
22817
+ prev2.close = next.close;
22818
+ }
22819
+ } else {
22820
+ merged.push(next);
22821
+ }
22822
+ }
22823
+ for (const iv of merged) out.push({ x: col, hi: iv.hi, lo: iv.lo, headTime: iv.headTime, open: iv.open, close: iv.close });
22824
+ intervals = [];
22825
+ };
22826
+ for (let i = i0; i <= i1; i += 1) {
22827
+ const b = bars[i];
22828
+ if (!b || b.high <= b.low) continue;
22829
+ const x = Math.round(xOf(i));
22830
+ if (x !== col) {
22831
+ flush();
22832
+ col = x;
22833
+ }
22834
+ let lo = b.low;
22835
+ let hi = b.high;
22836
+ let headIdx = i;
22837
+ let headTime = b.time;
22838
+ let open2 = b.open;
22839
+ for (let k = intervals.length - 1; k >= 0; k -= 1) {
22840
+ const iv = intervals[k];
22841
+ if (iv.lo > hi || iv.hi < lo) continue;
22842
+ if (iv.lo < lo) lo = iv.lo;
22843
+ if (iv.hi > hi) hi = iv.hi;
22844
+ if (iv.headIdx < headIdx) {
22845
+ headIdx = iv.headIdx;
22846
+ headTime = iv.headTime;
22847
+ open2 = iv.open;
22848
+ }
22849
+ intervals.splice(k, 1);
22850
+ }
22851
+ let insertAt = 0;
22852
+ while (insertAt < intervals.length && intervals[insertAt].lo < lo) insertAt += 1;
22853
+ intervals.splice(insertAt, 0, { lo, hi, headIdx, headTime, open: open2, lastIdx: i, close: b.close });
22854
+ }
22855
+ flush();
22856
+ return out;
22857
+ }
22658
22858
 
22659
22859
  // src/renderers/native/backend/gl/Batch.ts
22660
22860
  var FLOATS_PER_VERT = 8;
@@ -23627,44 +23827,19 @@ var WebGL2Backend = class {
23627
23827
  }
23628
23828
  }
23629
23829
  /**
23630
- * Sub-pixel LOD (mirrors Canvas2dBackend.drawCandlesAggregated): bucket bars
23631
- * sharing a rounded pixel column into one high-low stick → draw cost bounded by
23632
- * screen width, not bar count. Bucket color follows first-open→last-close.
23830
+ * Sub-pixel LOD (mirrors Canvas2dBackend.drawCandlesAggregated): bars sharing a
23831
+ * rounded pixel column collapse into high-low sticks — one per contiguous
23832
+ * coverage run (see {@link aggregateCandleColumns}), so a price gap inside the
23833
+ * column stays a void. Draw cost stays bounded by screen width, not bar count;
23834
+ * stick color follows its own first-open→last-close.
23633
23835
  */
23634
23836
  emitCandlesAggregated(b, bars, i0, i1, coords, pane, up, down, barColors) {
23635
- let px = NaN;
23636
- let lo = Infinity;
23637
- let hi = -Infinity;
23638
- let openV = 0;
23639
- let closeV = 0;
23640
- let headTime = 0;
23641
- let active = false;
23642
- const flush = () => {
23643
- if (!active) return;
23644
- const c = parseColor(barColors.get(headTime) ?? (closeV >= openV ? up : down));
23645
- const hY = coords.priceToY(hi, pane.scale, pane.bounds);
23646
- const lY = coords.priceToY(lo, pane.scale, pane.bounds);
23647
- b.rect(px - 0.5, hY, 1, lY - hY, c);
23648
- };
23649
- for (let i = i0; i <= i1; i += 1) {
23650
- const bar = bars[i];
23651
- if (!bar || bar.high <= bar.low) continue;
23652
- const x = Math.round(coords.logicalToX(i));
23653
- if (!active || x !== px) {
23654
- flush();
23655
- px = x;
23656
- lo = bar.low;
23657
- hi = bar.high;
23658
- openV = bar.open;
23659
- headTime = bar.time;
23660
- active = true;
23661
- } else {
23662
- if (bar.low < lo) lo = bar.low;
23663
- if (bar.high > hi) hi = bar.high;
23664
- }
23665
- closeV = bar.close;
23837
+ const yOf = (price) => coords.priceToY(price, pane.scale, pane.bounds);
23838
+ for (const s of aggregateCandleColumns(bars, i0, i1, (i) => coords.logicalToX(i), yOf)) {
23839
+ const c = parseColor(barColors.get(s.headTime) ?? (s.close >= s.open ? up : down));
23840
+ const hY = yOf(s.hi);
23841
+ b.rect(s.x - 0.5, hY, 1, yOf(s.lo) - hY, c);
23666
23842
  }
23667
- flush();
23668
23843
  }
23669
23844
  emitSeries(b, spec, pane, coords, i0, i1, theme, off = 0) {
23670
23845
  if (spec.kind === "candle" || spec.kind === "bar") {
@@ -24962,13 +25137,15 @@ var SceneGraph = class {
24962
25137
  if (!this.orderedCache) this.orderedCache = [...this.panes.values()].sort((a, b) => a.order - b.order);
24963
25138
  return this.orderedCache;
24964
25139
  }
24965
- /** The session zones resolved into colored bands (pre/post-market washes from the
24966
- * config's session colors) — consumed by the same painting path as {@link highlights}. */
25140
+ /** The session zones resolved into colored bands (pre/post-market or extended-hours
25141
+ * washes from the config's session colors) — consumed by the same painting path as
25142
+ * {@link highlights}. */
24967
25143
  sessionHighlightBands() {
24968
25144
  if (!this.sessionZones) return [];
24969
25145
  const out = [];
24970
25146
  for (const [from, to] of this.sessionZones.pre) out.push({ from, to, color: this.style.sessions.premarketColor });
24971
25147
  for (const [from, to] of this.sessionZones.post) out.push({ from, to, color: this.style.sessions.postmarketColor });
25148
+ for (const [from, to] of this.sessionZones.extended) out.push({ from, to, color: this.style.sessions.extendedColor });
24972
25149
  return out;
24973
25150
  }
24974
25151
  indicatorsForPane(paneId) {
@@ -25507,50 +25684,23 @@ var Canvas2dBackend = class {
25507
25684
  }
25508
25685
  }
25509
25686
  /**
25510
- * Sub-pixel LOD: bucket every bar sharing a rounded pixel column into ONE
25511
- * high-low stick, so draw cost is bounded by screen width (not bar count) when
25512
- * zoomed far out. The bucket's color follows its first-open→last-close
25513
- * direction (a barcolor() on the head bar still wins).
25687
+ * Sub-pixel LOD: bars sharing a rounded pixel column collapse into high-low
25688
+ * sticks (one per contiguous coverage run — see {@link aggregateCandleColumns}),
25689
+ * so draw cost is bounded by screen width (not bar count) when zoomed far out
25690
+ * and a price gap inside the column stays a void. Each stick's color follows
25691
+ * its first-open→last-close direction (a barcolor() on the head bar still wins).
25514
25692
  */
25515
25693
  drawCandlesAggregated(ctx, bars, i0, i1, coords, pane, up, down, barColors) {
25516
- let px = NaN;
25517
- let lo = Infinity;
25518
- let hi = -Infinity;
25519
- let openV = 0;
25520
- let closeV = 0;
25521
- let headTime = 0;
25522
- let active = false;
25523
- const flush = () => {
25524
- if (!active) return;
25525
- const color = barColors.get(headTime) ?? (closeV >= openV ? up : down);
25526
- const hY = coords.priceToY(hi, pane.scale, pane.bounds);
25527
- const lY = coords.priceToY(lo, pane.scale, pane.bounds);
25528
- ctx.strokeStyle = color;
25529
- ctx.lineWidth = 1;
25694
+ const yOf = (price) => coords.priceToY(price, pane.scale, pane.bounds);
25695
+ ctx.lineWidth = 1;
25696
+ for (const s of aggregateCandleColumns(bars, i0, i1, (i) => coords.logicalToX(i), yOf)) {
25697
+ const x = s.x + 0.5;
25698
+ ctx.strokeStyle = barColors.get(s.headTime) ?? (s.close >= s.open ? up : down);
25530
25699
  ctx.beginPath();
25531
- ctx.moveTo(px, hY);
25532
- ctx.lineTo(px, lY);
25700
+ ctx.moveTo(x, yOf(s.hi));
25701
+ ctx.lineTo(x, yOf(s.lo));
25533
25702
  ctx.stroke();
25534
- };
25535
- for (let i = i0; i <= i1; i += 1) {
25536
- const b = bars[i];
25537
- if (!b || b.high <= b.low) continue;
25538
- const x = Math.round(coords.logicalToX(i)) + 0.5;
25539
- if (!active || x !== px) {
25540
- flush();
25541
- px = x;
25542
- lo = b.low;
25543
- hi = b.high;
25544
- openV = b.open;
25545
- headTime = b.time;
25546
- active = true;
25547
- } else {
25548
- if (b.low < lo) lo = b.low;
25549
- if (b.high > hi) hi = b.high;
25550
- }
25551
- closeV = b.close;
25552
25703
  }
25553
- flush();
25554
25704
  }
25555
25705
  drawSeries(ctx, spec, pane, coords, i0, i1, theme, off = 0) {
25556
25706
  if (spec.kind === "candle" || spec.kind === "bar") {
@@ -26574,6 +26724,32 @@ var DrawingSceneRenderer = class {
26574
26724
  }
26575
26725
  };
26576
26726
 
26727
+ // src/renderers/native/chrome/tz.ts
26728
+ function tzOffsetMs(ms, timeZone) {
26729
+ if (!timeZone || timeZone === "UTC") return 0;
26730
+ try {
26731
+ const dtf = new Intl.DateTimeFormat("en-US", {
26732
+ timeZone,
26733
+ hourCycle: "h23",
26734
+ year: "numeric",
26735
+ month: "2-digit",
26736
+ day: "2-digit",
26737
+ hour: "2-digit",
26738
+ minute: "2-digit",
26739
+ second: "2-digit"
26740
+ });
26741
+ const parts = dtf.formatToParts(new Date(ms));
26742
+ const get = (t) => Number(parts.find((p) => p.type === t)?.value);
26743
+ const asUTC = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour") % 24, get("minute"), get("second"));
26744
+ return asUTC - ms;
26745
+ } catch {
26746
+ return 0;
26747
+ }
26748
+ }
26749
+ function zonedDate(ms, timeZone) {
26750
+ return new Date(ms + tzOffsetMs(ms, timeZone));
26751
+ }
26752
+
26577
26753
  // src/renderers/native/chrome/ticks.ts
26578
26754
  function priceTicks(min, max, target = 6) {
26579
26755
  if (!(max > min) || !Number.isFinite(min) || !Number.isFinite(max)) return [];
@@ -26721,7 +26897,14 @@ var STEP_LADDER = [
26721
26897
  YEAR
26722
26898
  ];
26723
26899
  var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
26900
+ var WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
26724
26901
  var pad2 = (n) => n < 10 ? `0${n}` : String(n);
26902
+ function formatTimeStamp(ms, timeZone, barIntervalMs) {
26903
+ const d = zonedDate(ms, timeZone);
26904
+ const date = `${WEEKDAYS[d.getUTCDay()]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} '${pad2(d.getUTCFullYear() % 100)}`;
26905
+ if (barIntervalMs >= DAY) return date;
26906
+ return `${date} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}`;
26907
+ }
26725
26908
  function pickStep(targetMs) {
26726
26909
  for (const step of STEP_LADDER) if (step >= targetMs) return step;
26727
26910
  return STEP_LADDER[STEP_LADDER.length - 1];
@@ -26763,32 +26946,6 @@ function timeTicks(fromMs, toMs, target = 8, offsetMs = 0) {
26763
26946
  return out;
26764
26947
  }
26765
26948
 
26766
- // src/renderers/native/chrome/tz.ts
26767
- function tzOffsetMs(ms, timeZone) {
26768
- if (!timeZone || timeZone === "UTC") return 0;
26769
- try {
26770
- const dtf = new Intl.DateTimeFormat("en-US", {
26771
- timeZone,
26772
- hourCycle: "h23",
26773
- year: "numeric",
26774
- month: "2-digit",
26775
- day: "2-digit",
26776
- hour: "2-digit",
26777
- minute: "2-digit",
26778
- second: "2-digit"
26779
- });
26780
- const parts = dtf.formatToParts(new Date(ms));
26781
- const get = (t) => Number(parts.find((p) => p.type === t)?.value);
26782
- const asUTC = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour") % 24, get("minute"), get("second"));
26783
- return asUTC - ms;
26784
- } catch {
26785
- return 0;
26786
- }
26787
- }
26788
- function zonedDate(ms, timeZone) {
26789
- return new Date(ms + tzOffsetMs(ms, timeZone));
26790
- }
26791
-
26792
26949
  // src/renderers/native/chrome/ChromeRenderer.ts
26793
26950
  var ChromeRenderer = class {
26794
26951
  constructor() {
@@ -27235,7 +27392,7 @@ var CrosshairRenderer = class {
27235
27392
  const price = coords.yToPrice(ch.y, pane.scale, pane.bounds);
27236
27393
  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);
27237
27394
  }
27238
- this.chip(ctx, x, dataH + 1, formatStamp(coords.logicalToTime(logical), scene.timezone), chipBg, "center", true, theme.background);
27395
+ this.chip(ctx, x, dataH + 1, formatTimeStamp(coords.logicalToTime(logical), scene.timezone, coords.barInterval), chipBg, "center", true, theme.background);
27239
27396
  }
27240
27397
  destroy() {
27241
27398
  this.canvas = null;
@@ -27283,7 +27440,7 @@ var CrosshairRenderer = class {
27283
27440
  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);
27284
27441
  }
27285
27442
  }
27286
- this.chip(ctx, x, dataH + 1, formatStamp(ext.time, scene.timezone), chipBg, "center", true, theme.background);
27443
+ this.chip(ctx, x, dataH + 1, formatTimeStamp(ext.time, scene.timezone, coords.barInterval), chipBg, "center", true, theme.background);
27287
27444
  ctx.globalAlpha = 1;
27288
27445
  }
27289
27446
  /** A soft band + a brighter crisp center line over the hovered separator, so it reads as
@@ -27316,11 +27473,6 @@ function setDash3(ctx, style) {
27316
27473
  else if (style === "dotted") ctx.setLineDash([2, 3]);
27317
27474
  else ctx.setLineDash([]);
27318
27475
  }
27319
- function formatStamp(ms, timeZone) {
27320
- const d = zonedDate(ms, timeZone);
27321
- const pad = (n) => n < 10 ? `0${n}` : String(n);
27322
- return `${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
27323
- }
27324
27476
 
27325
27477
  // src/renderers/native/chrome/settings-visibility.ts
27326
27478
  function settingsIdSlug(label) {
@@ -33087,6 +33239,9 @@ function clipHighlightRect(x1, x2, left, right) {
33087
33239
  const end = Math.min(right, x2);
33088
33240
  return end > x ? { x, width: end - x } : null;
33089
33241
  }
33242
+ function bandEdgeSlot(logical) {
33243
+ return Math.ceil(logical) - 0.5;
33244
+ }
33090
33245
  var BackdropRenderer = class {
33091
33246
  constructor() {
33092
33247
  this.canvas = null;
@@ -33117,21 +33272,22 @@ var BackdropRenderer = class {
33117
33272
  this.drawGrid(ctx, scene, coords, theme, coords.width, gridAlpha);
33118
33273
  }
33119
33274
  /** Renderer-owned session highlight bands: full-height (all panes), behind the grid.
33120
- * Session-zone washes (pre/post-market) paint first, host highlights on top. */
33275
+ * Session-zone washes paint first (edges snapped to bar-slot boundaries so a band
33276
+ * never cuts a candle in two), host highlights on top (continuous time mapping —
33277
+ * hosts may paint sub-bar spans). */
33121
33278
  drawHighlights(ctx, scene, coords) {
33122
33279
  const sessions = scene.sessionHighlightBands();
33123
33280
  if (sessions.length > 0) {
33124
33281
  const left = Math.max(0, coords.logicalToX(-0.5));
33125
33282
  const right = Math.min(coords.width, coords.logicalToX(coords.barCount - 0.5));
33126
- this.drawHighlightSet(ctx, sessions, coords, left, right);
33283
+ this.drawHighlightSet(ctx, sessions, coords, left, right, true);
33127
33284
  }
33128
- this.drawHighlightSet(ctx, scene.highlights, coords, 0, coords.width);
33285
+ this.drawHighlightSet(ctx, scene.highlights, coords, 0, coords.width, false);
33129
33286
  }
33130
- drawHighlightSet(ctx, bands, coords, left, right) {
33287
+ drawHighlightSet(ctx, bands, coords, left, right, snapToSlots) {
33288
+ const edgeX = (ms) => snapToSlots ? coords.logicalToX(bandEdgeSlot(coords.timeToLogical(ms))) : coords.timeToX(ms);
33131
33289
  for (const band of bands) {
33132
- const x1 = coords.timeToX(band.from);
33133
- const x2 = coords.timeToX(band.to);
33134
- const rect = clipHighlightRect(x1, x2, left, right);
33290
+ const rect = clipHighlightRect(edgeX(band.from), edgeX(band.to), left, right);
33135
33291
  if (!rect) continue;
33136
33292
  ctx.fillStyle = band.color;
33137
33293
  ctx.fillRect(rect.x, 0, rect.width, coords.height);
@@ -34278,7 +34434,7 @@ var NativeRenderer = class {
34278
34434
  };
34279
34435
  })(),
34280
34436
  series: { style: this.scene.priceStyle, baseline: this.scene.baselineValue, spacing: this.coords.spacingScale },
34281
- sessions: { premarketColor: s.sessions.premarketColor, postmarketColor: s.sessions.postmarketColor }
34437
+ sessions: { premarketColor: s.sessions.premarketColor, postmarketColor: s.sessions.postmarketColor, extendedColor: s.sessions.extendedColor }
34282
34438
  };
34283
34439
  }
34284
34440
  /**
@@ -34360,7 +34516,7 @@ var NativeRenderer = class {
34360
34516
  width: next.baseline.width,
34361
34517
  baselineLevel: next.baseline.baselineLevel
34362
34518
  };
34363
- s.sessions = { premarketColor: next.sessions.premarketColor, postmarketColor: next.sessions.postmarketColor };
34519
+ s.sessions = { premarketColor: next.sessions.premarketColor, postmarketColor: next.sessions.postmarketColor, extendedColor: next.sessions.extendedColor };
34364
34520
  this.setPriceStyle(next.series.style);
34365
34521
  this.scene.candleOverride = candleOverrideFor(this.scene.priceStyle, s.chartTypes);
34366
34522
  this.scene.baselineValue = next.series.baseline;
@@ -34582,9 +34738,10 @@ var NativeRenderer = class {
34582
34738
  * opts in with a `data-vela-screenshot` attribute on the mount container's
34583
34739
  * subtree (the widget marks its status line, and its symbol watermark with
34584
34740
  * `"under"` — drawn beneath the canvases, where it sits on screen). Only the
34585
- * crosshair (L2) is intentionally excluded.
34741
+ * crosshair (L2) is intentionally excluded. {@link screenshot} is this canvas
34742
+ * as a PNG data URL.
34586
34743
  */
34587
- screenshot() {
34744
+ screenshotCanvas() {
34588
34745
  if (!this.dataCanvas) return null;
34589
34746
  this.computeScales();
34590
34747
  this.paintData();
@@ -34612,7 +34769,10 @@ var NativeRenderer = class {
34612
34769
  if (el.getAttribute("data-vela-screenshot") !== "under") rasterizeOverlay(ctx, el, frame);
34613
34770
  }
34614
34771
  }
34615
- return out.toDataURL("image/png");
34772
+ return out;
34773
+ }
34774
+ screenshot() {
34775
+ return this.screenshotCanvas()?.toDataURL("image/png") ?? null;
34616
34776
  }
34617
34777
  /**
34618
34778
  * The candles-first-appear reveal — animates the **candles themselves** (no extra
@@ -36007,7 +36167,7 @@ var NativeRenderer = class {
36007
36167
  const pricePane = this.dataWindowPricePane();
36008
36168
  const ohlc = this.dataWindowOHLC(bar, pricePane);
36009
36169
  const parts = [];
36010
- if (bar) parts.push(formatStamp2(bar.time, this.scene.timezone));
36170
+ if (bar) parts.push(formatStamp(bar.time, this.scene.timezone));
36011
36171
  if (ohlc) parts.push(`Open ${ohlc.o}, High ${ohlc.h}, Low ${ohlc.l}, Close ${ohlc.c}`);
36012
36172
  for (const group of this.dataWindowGroups(idx, pricePane)) {
36013
36173
  for (const row of group.rows) parts.push(`${row.label} ${row.value}`);
@@ -36894,7 +37054,7 @@ function formatStampParts(ms, timeZone) {
36894
37054
  time: `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`
36895
37055
  };
36896
37056
  }
36897
- function formatStamp2(ms, timeZone) {
37057
+ function formatStamp(ms, timeZone) {
36898
37058
  const { date, time } = formatStampParts(ms, timeZone);
36899
37059
  return `${date} ${time}`;
36900
37060
  }
@@ -36925,7 +37085,7 @@ function sanitizeSessionZones(value) {
36925
37085
  }
36926
37086
  return out.sort((a, b) => a[0] - b[0]);
36927
37087
  };
36928
- return { pre: windows(v.pre), post: windows(v.post) };
37088
+ return { pre: windows(v.pre), post: windows(v.post), extended: windows(v.extended) };
36929
37089
  }
36930
37090
  function clamp012(v) {
36931
37091
  return Math.max(0, Math.min(1, v));
@@ -37614,6 +37774,7 @@ var MARKET_LABELS = {
37614
37774
  open: "Market Open",
37615
37775
  pre: "Pre-Market",
37616
37776
  post: "Post-Market",
37777
+ extended: "Extended Hours",
37617
37778
  closed: "Market Closed",
37618
37779
  holiday: "Market Holiday"
37619
37780
  };
@@ -37621,6 +37782,7 @@ var MARKET_INKS = {
37621
37782
  open: "var(--vela-up)",
37622
37783
  pre: SESSION_PRE,
37623
37784
  post: SESSION_POST,
37785
+ extended: SESSION_POST,
37624
37786
  closed: SESSION_OFF,
37625
37787
  holiday: SESSION_OFF
37626
37788
  };
@@ -37934,118 +38096,17 @@ var Statusline = class {
37934
38096
  }
37935
38097
  };
37936
38098
 
37937
- // src/widget/market-status.ts
37938
- function civilDate(ms, tz) {
37939
- try {
37940
- return new Intl.DateTimeFormat("en-CA", { timeZone: tz }).format(ms);
37941
- } catch {
37942
- return "";
37943
- }
37944
- }
37945
- function isWeekday(ms, tz) {
37946
- try {
37947
- const wd = new Intl.DateTimeFormat("en-US", { timeZone: tz, weekday: "short" }).format(ms);
37948
- return wd !== "Sat" && wd !== "Sun";
37949
- } catch {
37950
- return false;
37951
- }
37952
- }
37953
- function deriveMarketStatus(now, w, tz) {
37954
- const within = (ws) => ws.find(([s, e]) => now >= s && now < e);
37955
- if (within(w.regular)) return "open";
37956
- const ext = within(w.extended);
37957
- if (ext) return w.regular.some(([s]) => s >= now && s < ext[1]) ? "pre" : "post";
37958
- if (isWeekday(now, tz)) {
37959
- const today = civilDate(now, tz);
37960
- if (today !== "" && !w.extended.some(([s]) => civilDate(s, tz) === today)) return "holiday";
37961
- }
37962
- return "closed";
37963
- }
37964
- function nextStatusBoundary(now, w) {
37965
- let next = null;
37966
- for (const ws of [w.regular, w.extended]) {
37967
- for (const [s, e] of ws) {
37968
- for (const t of [s, e]) {
37969
- if (t > now && (next == null || t < next)) next = t;
37970
- }
37971
- }
37972
- }
37973
- return next;
37974
- }
37975
- var FETCH_BACK_MS = 4 * 864e5;
37976
- var FETCH_AHEAD_MS = 10 * 864e5;
37977
- var MIN_TIMER_MS = 15e3;
37978
- var MAX_TIMER_MS = 36e5;
37979
- var RETRY_MS2 = 6e4;
37980
- var MarketStatusTracker = class {
37981
- constructor(onStatus) {
37982
- this.onStatus = onStatus;
37983
- /** Invalidates detached async work — bumped by every track()/stop(). */
37984
- this.epoch = 0;
37985
- this.timer = null;
37986
- }
37987
- /** (Re)bind to a chart's data surface + symbol and start evaluating. */
37988
- track(data, symbol) {
37989
- const my = ++this.epoch;
37990
- this.clearTimer();
37991
- void this.evaluate(my, data, symbol);
37992
- }
37993
- stop() {
37994
- this.epoch += 1;
37995
- this.clearTimer();
37996
- }
37997
- async evaluate(my, data, symbol) {
37998
- const resolved = data.resolve(symbol);
37999
- const provider = resolved ? data.providerInstance(resolved.provider) : void 0;
38000
- const si = await data.symbolInfo(symbol).catch(() => void 0);
38001
- if (my !== this.epoch) return;
38002
- const hasSessions = typeof si?.session === "string" && si.session !== "" && si.session !== "24x7";
38003
- if (!provider?.getCalendar || !hasSessions || !resolved) {
38004
- this.onStatus("open");
38005
- return;
38006
- }
38007
- const tz = typeof si?.timezone === "string" && si.timezone !== "" ? si.timezone : "Etc/UTC";
38008
- const now = Date.now();
38009
- const range = { from: now - FETCH_BACK_MS, to: now + FETCH_AHEAD_MS };
38010
- const [regular, extended] = await Promise.all([
38011
- provider.getCalendar(resolved.ticker, { ...range, session: "regular" }).catch(() => null),
38012
- provider.getCalendar(resolved.ticker, { ...range, session: "extended" }).catch(() => null)
38013
- ]);
38014
- if (my !== this.epoch) return;
38015
- if (!regular || !extended) {
38016
- this.arm(my, data, symbol, RETRY_MS2);
38017
- return;
38018
- }
38019
- const w = { regular, extended };
38020
- this.onStatus(deriveMarketStatus(now, w, tz));
38021
- const boundary = nextStatusBoundary(now, w);
38022
- const delay = Math.min(boundary != null ? boundary - now : MAX_TIMER_MS, MAX_TIMER_MS);
38023
- this.arm(my, data, symbol, Math.max(delay, MIN_TIMER_MS));
38024
- }
38025
- arm(my, data, symbol, delay) {
38026
- this.clearTimer();
38027
- this.timer = setTimeout(() => {
38028
- this.timer = null;
38029
- if (my !== this.epoch) return;
38030
- void this.evaluate(my, data, symbol);
38031
- }, delay);
38032
- }
38033
- clearTimer() {
38034
- if (this.timer != null) clearTimeout(this.timer);
38035
- this.timer = null;
38036
- }
38037
- };
38038
-
38039
38099
  // src/widget/session-shading.ts
38040
38100
  var DAY_MS2 = 864e5;
38041
38101
  var MINUTE_MS = 6e4;
38042
- function parseWindow(text) {
38102
+ function parseWindow(text, allowOvernight = false) {
38043
38103
  if (typeof text !== "string") return null;
38044
38104
  const m = /^(\d{2})(\d{2})-(\d{2})(\d{2})$/.exec(text);
38045
38105
  if (!m) return null;
38046
38106
  const start = Number(m[1]) * 60 + Number(m[2]);
38047
38107
  const end = Number(m[3]) * 60 + Number(m[4]);
38048
- if (start >= end || end > 1440) return null;
38108
+ if (end > 1440 || start >= 1440) return null;
38109
+ if (start >= end && !allowOvernight) return null;
38049
38110
  return { start, end };
38050
38111
  }
38051
38112
  function parseSessionSpec(si) {
@@ -38055,9 +38116,13 @@ function parseSessionSpec(si) {
38055
38116
  if (!regular) return null;
38056
38117
  const tz = si?.["timezone"];
38057
38118
  const timezone = typeof tz === "string" && tz !== "" ? tz : "Etc/UTC";
38058
- const ext = parseWindow(si?.["session_extended"]);
38119
+ const ext = parseWindow(si?.["session_extended"], true);
38120
+ if (ext && ext.start >= ext.end) {
38121
+ if (ext.start >= regular.end && ext.end >= regular.end) return { regular, extended: ext, overnight: true, timezone };
38122
+ return { regular, extended: { start: 0, end: 1440 }, overnight: false, timezone };
38123
+ }
38059
38124
  const extended = ext && ext.start <= regular.start && ext.end >= regular.end ? ext : { start: 0, end: 1440 };
38060
- return { regular, extended, timezone };
38125
+ return { regular, extended, overnight: false, timezone };
38061
38126
  }
38062
38127
  var dtfCache = /* @__PURE__ */ new Map();
38063
38128
  function civilFormatter(tz) {
@@ -38093,27 +38158,48 @@ function civilParts(dtf, ms) {
38093
38158
  minute: Number(get("minute"))
38094
38159
  };
38095
38160
  }
38161
+ function mergeAbutting(bands) {
38162
+ const sorted = [...bands].sort((a, b) => a[0] - b[0]);
38163
+ const out = [];
38164
+ for (const [s, e] of sorted) {
38165
+ if (e <= s) continue;
38166
+ const last = out[out.length - 1];
38167
+ if (last && s <= last[1]) last[1] = Math.max(last[1], e);
38168
+ else out.push([s, e]);
38169
+ }
38170
+ return out;
38171
+ }
38096
38172
  function expandSessionZones(spec, from, to) {
38097
38173
  const pre = [];
38098
38174
  const post = [];
38175
+ const extended = [];
38099
38176
  const dtf = civilFormatter(spec.timezone);
38100
- if (!dtf || !Number.isFinite(from) || !Number.isFinite(to)) return { pre, post };
38177
+ if (!dtf || !Number.isFinite(from) || !Number.isFinite(to)) return { pre, post, extended };
38101
38178
  const seen = /* @__PURE__ */ new Set();
38102
38179
  for (let cursor = from - DAY_MS2; cursor < to + DAY_MS2; cursor += DAY_MS2) {
38103
38180
  const civil = civilParts(dtf, cursor);
38104
38181
  const key = civil.year * 1e4 + civil.month * 100 + civil.day;
38105
38182
  if (!Number.isFinite(key) || seen.has(key)) continue;
38106
38183
  seen.add(key);
38107
- if (civil.weekday === "Sat" || civil.weekday === "Sun") continue;
38184
+ const sunday = civil.weekday === "Sun";
38185
+ if (civil.weekday === "Sat" || sunday && !spec.overnight) continue;
38108
38186
  const naive = Date.UTC(civil.year, civil.month - 1, civil.day);
38109
38187
  const noonGuess = naive + 720 * MINUTE_MS;
38110
38188
  const atNoon = civilParts(dtf, noonGuess);
38111
38189
  const offset = Date.UTC(atNoon.year, atNoon.month - 1, atNoon.day, atNoon.hour, atNoon.minute) - noonGuess;
38112
38190
  const at = (minutes) => naive + minutes * MINUTE_MS - offset;
38191
+ if (spec.overnight) {
38192
+ if (!sunday) {
38193
+ extended.push([at(0), at(spec.regular.start)]);
38194
+ if (spec.regular.end < spec.extended.end) extended.push([at(spec.regular.end), at(spec.extended.end)]);
38195
+ }
38196
+ if (civil.weekday !== "Fri") extended.push([at(spec.extended.start), at(1440)]);
38197
+ continue;
38198
+ }
38113
38199
  if (spec.extended.start < spec.regular.start) pre.push([at(spec.extended.start), at(spec.regular.start)]);
38114
38200
  if (spec.regular.end < spec.extended.end) post.push([at(spec.regular.end), at(spec.extended.end)]);
38115
38201
  }
38116
- return { pre, post };
38202
+ return { pre, post, extended: mergeAbutting(extended) };
38117
38203
  }
38118
38204
  var COVER_PAD_MIN_MS = 2 * DAY_MS2;
38119
38205
  var SessionShadingTracker = class {
@@ -38168,7 +38254,7 @@ var SessionShadingTracker = class {
38168
38254
  return;
38169
38255
  }
38170
38256
  if (this.session !== "extended" || !this.intraday) {
38171
- if (force) this.onZones({ pre: [], post: [] });
38257
+ if (force) this.onZones({ pre: [], post: [], extended: [] });
38172
38258
  return;
38173
38259
  }
38174
38260
  const from = Math.min(range.from, range.to);
@@ -38182,6 +38268,112 @@ var SessionShadingTracker = class {
38182
38268
  }
38183
38269
  };
38184
38270
 
38271
+ // src/widget/market-status.ts
38272
+ function civilDate(ms, tz) {
38273
+ try {
38274
+ return new Intl.DateTimeFormat("en-CA", { timeZone: tz }).format(ms);
38275
+ } catch {
38276
+ return "";
38277
+ }
38278
+ }
38279
+ function isWeekday(ms, tz) {
38280
+ try {
38281
+ const wd = new Intl.DateTimeFormat("en-US", { timeZone: tz, weekday: "short" }).format(ms);
38282
+ return wd !== "Sat" && wd !== "Sun";
38283
+ } catch {
38284
+ return false;
38285
+ }
38286
+ }
38287
+ function deriveMarketStatus(now, w, tz, overnight = false) {
38288
+ const within = (ws) => ws.find(([s, e]) => now >= s && now < e);
38289
+ if (within(w.regular)) return "open";
38290
+ const ext = within(w.extended);
38291
+ if (ext) {
38292
+ if (overnight) return "extended";
38293
+ return w.regular.some(([s]) => s >= now && s < ext[1]) ? "pre" : "post";
38294
+ }
38295
+ if (isWeekday(now, tz)) {
38296
+ const today = civilDate(now, tz);
38297
+ if (today !== "" && !w.extended.some(([s]) => civilDate(s, tz) === today)) return "holiday";
38298
+ }
38299
+ return "closed";
38300
+ }
38301
+ function nextStatusBoundary(now, w) {
38302
+ let next = null;
38303
+ for (const ws of [w.regular, w.extended]) {
38304
+ for (const [s, e] of ws) {
38305
+ for (const t of [s, e]) {
38306
+ if (t > now && (next == null || t < next)) next = t;
38307
+ }
38308
+ }
38309
+ }
38310
+ return next;
38311
+ }
38312
+ var FETCH_BACK_MS = 4 * 864e5;
38313
+ var FETCH_AHEAD_MS = 10 * 864e5;
38314
+ var MIN_TIMER_MS = 15e3;
38315
+ var MAX_TIMER_MS = 36e5;
38316
+ var RETRY_MS2 = 6e4;
38317
+ var MarketStatusTracker = class {
38318
+ constructor(onStatus) {
38319
+ this.onStatus = onStatus;
38320
+ /** Invalidates detached async work — bumped by every track()/stop(). */
38321
+ this.epoch = 0;
38322
+ this.timer = null;
38323
+ }
38324
+ /** (Re)bind to a chart's data surface + symbol and start evaluating. */
38325
+ track(data, symbol) {
38326
+ const my = ++this.epoch;
38327
+ this.clearTimer();
38328
+ void this.evaluate(my, data, symbol);
38329
+ }
38330
+ stop() {
38331
+ this.epoch += 1;
38332
+ this.clearTimer();
38333
+ }
38334
+ async evaluate(my, data, symbol) {
38335
+ const resolved = data.resolve(symbol);
38336
+ const provider = resolved ? data.providerInstance(resolved.provider) : void 0;
38337
+ const si = await data.symbolInfo(symbol).catch(() => void 0);
38338
+ if (my !== this.epoch) return;
38339
+ const hasSessions = typeof si?.session === "string" && si.session !== "" && si.session !== "24x7";
38340
+ if (!provider?.getCalendar || !hasSessions || !resolved) {
38341
+ this.onStatus("open");
38342
+ return;
38343
+ }
38344
+ const tz = typeof si?.timezone === "string" && si.timezone !== "" ? si.timezone : "Etc/UTC";
38345
+ const now = Date.now();
38346
+ const range = { from: now - FETCH_BACK_MS, to: now + FETCH_AHEAD_MS };
38347
+ const [regular, extended] = await Promise.all([
38348
+ provider.getCalendar(resolved.ticker, { ...range, session: "regular" }).catch(() => null),
38349
+ provider.getCalendar(resolved.ticker, { ...range, session: "extended" }).catch(() => null)
38350
+ ]);
38351
+ if (my !== this.epoch) return;
38352
+ if (!regular || !extended) {
38353
+ this.arm(my, data, symbol, RETRY_MS2);
38354
+ return;
38355
+ }
38356
+ const w = { regular, extended };
38357
+ const overnight = parseSessionSpec(si)?.overnight === true;
38358
+ this.onStatus(deriveMarketStatus(now, w, tz, overnight));
38359
+ const boundary = nextStatusBoundary(now, w);
38360
+ const delay = Math.min(boundary != null ? boundary - now : MAX_TIMER_MS, MAX_TIMER_MS);
38361
+ this.arm(my, data, symbol, Math.max(delay, MIN_TIMER_MS));
38362
+ }
38363
+ arm(my, data, symbol, delay) {
38364
+ this.clearTimer();
38365
+ this.timer = setTimeout(() => {
38366
+ this.timer = null;
38367
+ if (my !== this.epoch) return;
38368
+ void this.evaluate(my, data, symbol);
38369
+ }, delay);
38370
+ }
38371
+ clearTimer() {
38372
+ if (this.timer != null) clearTimeout(this.timer);
38373
+ this.timer = null;
38374
+ }
38375
+ };
38376
+
38185
38377
  // src/widget/watermark.ts
38186
38378
  var MAX_FONT_PX = 36;
38187
38379
  var MIN_FONT_PX = 12;
@@ -38695,7 +38887,10 @@ var ChartCell = class {
38695
38887
  this.activeRangeId = null;
38696
38888
  /** Latched verdict of {@link sessionAvailable} (async metadata, sticky per symbol). */
38697
38889
  this.sessionAvailableFlag = false;
38698
- /** Keeps the pre/post-market shading on the symbol's real calendar (see {@link SessionShadingTracker}). */
38890
+ /** Latched: the symbol's extended tape wraps midnight (an overnight roll market) —
38891
+ * one extended-hours shading phase instead of the pre/post split. */
38892
+ this.sessionOvernightFlag = false;
38893
+ /** Keeps the session shading on the symbol's real calendar (see {@link SessionShadingTracker}). */
38699
38894
  this.sessionShading = new SessionShadingTracker((zones) => this.inner?.renderer.set("sessionZones", zones));
38700
38895
  this.manifest = [];
38701
38896
  /** A restored ledger's manifest entry NAMES, waiting for the manifest to resolve
@@ -38948,8 +39143,10 @@ var ChartCell = class {
38948
39143
  void chart.data.symbolInfo(symbol).then((si) => {
38949
39144
  if (this.inner !== chart) return;
38950
39145
  const available = typeof si?.session === "string" && si.session !== "" && si.session !== "24x7";
38951
- if (available !== this.sessionAvailableFlag) {
39146
+ const overnight = parseSessionSpec(si)?.overnight === true;
39147
+ if (available !== this.sessionAvailableFlag || overnight !== this.sessionOvernightFlag) {
38952
39148
  this.sessionAvailableFlag = available;
39149
+ this.sessionOvernightFlag = overnight;
38953
39150
  this.deps.onMarketChanged(this.id);
38954
39151
  this.pushSettingsSections();
38955
39152
  }
@@ -38983,16 +39180,41 @@ var ChartCell = class {
38983
39180
  /**
38984
39181
  * (Re)contribute this cell's settings-dialog sections: status line parts, the
38985
39182
  * per-cell fetch depth, the watermark toggle, and — only while the cell's symbol
38986
- * HAS sessions — the Trading session group (RTH/ETH switch + the pre/post-market
38987
- * shading colors) inside the Symbol tab. Bars/watermark/titles are persistable
38988
- * cell state; a depth-only reload is silent, so mark dirty here. Re-run whenever
38989
- * a gate changes (the dialog reads the sections on open).
39183
+ * HAS sessions — the Trading session group (RTH/ETH switch + the session shading
39184
+ * colors: pre/post-market on day-split tapes, one extended-hours swatch on
39185
+ * overnight roll markets) inside the Symbol tab. Bars/watermark/titles are
39186
+ * persistable cell state; a depth-only reload is silent, so mark dirty here.
39187
+ * Re-run whenever a gate changes (the dialog reads the sections on open).
38990
39188
  */
38991
39189
  pushSettingsSections() {
38992
39190
  const chart = this.inner;
38993
39191
  if (!chart) return;
38994
39192
  const rth = "Regular hours (RTH)";
38995
39193
  const eth = "Extended hours (ETH)";
39194
+ const shadeRows = this.sessionOvernightFlag ? [
39195
+ {
39196
+ kind: "color",
39197
+ label: "Extended hours",
39198
+ id: "extended-color",
39199
+ get: () => this.sessionShadeColor("extendedColor"),
39200
+ set: (v) => this.setSessionShadeColor("extendedColor", v)
39201
+ }
39202
+ ] : [
39203
+ {
39204
+ kind: "color",
39205
+ label: "Pre-market",
39206
+ id: "premarket-color",
39207
+ get: () => this.sessionShadeColor("premarketColor"),
39208
+ set: (v) => this.setSessionShadeColor("premarketColor", v)
39209
+ },
39210
+ {
39211
+ kind: "color",
39212
+ label: "Post-market",
39213
+ id: "postmarket-color",
39214
+ get: () => this.sessionShadeColor("postmarketColor"),
39215
+ set: (v) => this.setSessionShadeColor("postmarketColor", v)
39216
+ }
39217
+ ];
38996
39218
  const sessionSection = {
38997
39219
  title: "Trading session",
38998
39220
  id: "trading-session",
@@ -39006,20 +39228,7 @@ var ChartCell = class {
39006
39228
  get: () => this.session === "extended" ? eth : rth,
39007
39229
  set: (v) => this.setSession(v === eth ? "extended" : "regular")
39008
39230
  },
39009
- {
39010
- kind: "color",
39011
- label: "Pre-market",
39012
- id: "premarket-color",
39013
- get: () => this.sessionShadeColor("premarketColor"),
39014
- set: (v) => this.setSessionShadeColor("premarketColor", v)
39015
- },
39016
- {
39017
- kind: "color",
39018
- label: "Post-market",
39019
- id: "postmarket-color",
39020
- get: () => this.sessionShadeColor("postmarketColor"),
39021
- set: (v) => this.setSessionShadeColor("postmarketColor", v)
39022
- }
39231
+ ...shadeRows
39023
39232
  ]
39024
39233
  };
39025
39234
  const advanced = {
@@ -39263,6 +39472,10 @@ var ChartCell = class {
39263
39472
  this.deps.activate(this.id);
39264
39473
  this.inner?.renderer.focus();
39265
39474
  }
39475
+ /** Raster of this cell's chart (same pixels as the PNG download), or null. */
39476
+ screenshotCanvas() {
39477
+ return this.inner?.renderer.screenshotCanvas() ?? null;
39478
+ }
39266
39479
  /** Download this cell's chart as a PNG (named after its market). */
39267
39480
  downloadScreenshot() {
39268
39481
  const url = this.inner?.renderer.screenshot();
@@ -39881,6 +40094,54 @@ var SplitterLayer = class {
39881
40094
  }
39882
40095
  };
39883
40096
 
40097
+ // src/workspace/screenshot.ts
40098
+ function destRect(tile, dpr) {
40099
+ const dx = Math.round(tile.x * dpr);
40100
+ const dy = Math.round(tile.y * dpr);
40101
+ return {
40102
+ dx,
40103
+ dy,
40104
+ dw: Math.round((tile.x + tile.width) * dpr) - dx,
40105
+ dh: Math.round((tile.y + tile.height) * dpr) - dy
40106
+ };
40107
+ }
40108
+ function tilesFromCellRects(grid, cells) {
40109
+ if (grid.width <= 0 || grid.height <= 0) return [];
40110
+ const tiles = [];
40111
+ for (const c of cells) {
40112
+ if (c.hidden || c.width <= 0 || c.height <= 0) continue;
40113
+ tiles.push({ x: c.left - grid.left, y: c.top - grid.top, width: c.width, height: c.height });
40114
+ }
40115
+ return tiles;
40116
+ }
40117
+ function compositeLayoutScreenshot(doc, frame, tiles) {
40118
+ if (frame.width <= 0 || frame.height <= 0 || tiles.length === 0) return null;
40119
+ const dpr = frame.dpr > 0 ? frame.dpr : 1;
40120
+ const out = doc.createElement("canvas");
40121
+ out.width = Math.max(1, Math.round(frame.width * dpr));
40122
+ out.height = Math.max(1, Math.round(frame.height * dpr));
40123
+ const ctx = out.getContext("2d");
40124
+ if (!ctx) return null;
40125
+ ctx.fillStyle = frame.gapColor;
40126
+ ctx.fillRect(0, 0, out.width, out.height);
40127
+ let painted = 0;
40128
+ for (const tile of tiles) {
40129
+ if (tile.width <= 0 || tile.height <= 0) continue;
40130
+ const { dx, dy, dw, dh } = destRect(tile, dpr);
40131
+ if (dw <= 0 || dh <= 0) continue;
40132
+ ctx.drawImage(tile.source, dx, dy, dw, dh);
40133
+ painted += 1;
40134
+ }
40135
+ if (painted === 0) return null;
40136
+ return out.toDataURL("image/png");
40137
+ }
40138
+ function triggerPngDownload(doc, url, filename) {
40139
+ const a = doc.createElement("a");
40140
+ a.href = url;
40141
+ a.download = filename;
40142
+ a.click();
40143
+ }
40144
+
39884
40145
  // src/workspace/VelaWorkspace.ts
39885
40146
  var DEFAULT_TIMEFRAMES = ["1", "5", "15", "30", "60", "240", "D", "W", "M"];
39886
40147
  var GAP_PX = 2;
@@ -40111,7 +40372,7 @@ var VelaWorkspace = class {
40111
40372
  ...picker ? { onIndicatorsClick: () => picker.open() } : {},
40112
40373
  onUndoClick: () => this.active.history.undo(),
40113
40374
  onRedoClick: () => this.active.history.redo(),
40114
- onScreenshotClick: () => this.active.downloadScreenshot(),
40375
+ onScreenshotClick: () => this.downloadScreenshot(),
40115
40376
  onAlertsClick: (anchor) => this.openAlertsMenu(anchor),
40116
40377
  timeframe: "60",
40117
40378
  timeframes: opts.timeframes ?? DEFAULT_TIMEFRAMES,
@@ -40170,6 +40431,7 @@ var VelaWorkspace = class {
40170
40431
  this.dock.addBuiltIn({ id: "dataWindow", title: "Data window", icon: "datawindow", order: 10, panel: this.dataWindow, onChart: (c) => this.dataWindow.onChart(c) });
40171
40432
  this.dock.addBuiltIn({ id: "objects", title: "Object tree", icon: "objects", order: 20, panel: this.objectTree, onChart: (c) => this.objectTree.onChart(c) });
40172
40433
  this.dock.refresh();
40434
+ if (boot?.panels) this.dock.applyState(boot.panels);
40173
40435
  this.root.appendChild(main);
40174
40436
  this.toastHost = new Toast(this.gridEl);
40175
40437
  const attribution = rendererDefaults().attribution;
@@ -40181,6 +40443,7 @@ var VelaWorkspace = class {
40181
40443
  bottom: `calc(var(--vela-bottom-gutter, ${TIME_AXIS_H4}px) + 10px)`,
40182
40444
  zIndex: "11"
40183
40445
  });
40446
+ mark.dataset.velaScreenshot = "1";
40184
40447
  this.gridEl.appendChild(mark);
40185
40448
  this.attributionMark = mark;
40186
40449
  }
@@ -40317,6 +40580,56 @@ var VelaWorkspace = class {
40317
40580
  get chart() {
40318
40581
  return this.active.chart;
40319
40582
  }
40583
+ /**
40584
+ * PNG data URL of the visible layout: every live cell in its grid slot, or the
40585
+ * maximized cell alone. Same pixels the screenshot button downloads. A single
40586
+ * visible cell returns that cell's own chart export (the one-chart case).
40587
+ */
40588
+ screenshot() {
40589
+ const cells = this.shotCells();
40590
+ if (cells.length === 0) return null;
40591
+ if (cells.length === 1) return cells[0].chart.renderer.screenshot();
40592
+ return this.compositeLayoutShot(cells);
40593
+ }
40594
+ /** Download {@link screenshot} as a PNG. A multi-cell layout is named
40595
+ * `vela-layout.png`; one visible cell keeps `${symbol}-${timeframe}.png`. */
40596
+ downloadScreenshot() {
40597
+ const cells = this.shotCells();
40598
+ if (cells.length <= 1) {
40599
+ (cells[0] ?? this.active).downloadScreenshot();
40600
+ return;
40601
+ }
40602
+ const url = this.compositeLayoutShot(cells);
40603
+ if (!url) return;
40604
+ triggerPngDownload(this.root.ownerDocument, url, "vela-layout.png");
40605
+ }
40606
+ /** Live cells the screenshot should include — maximized siblings are `hidden`. */
40607
+ shotCells() {
40608
+ return this.cells().filter((c) => c.host.style.visibility !== "hidden");
40609
+ }
40610
+ /** Place every cell's raster onto one canvas the size of the grid. */
40611
+ compositeLayoutShot(cells) {
40612
+ const gridEl = this.gridEl;
40613
+ const grid = gridEl.getBoundingClientRect();
40614
+ const win = gridEl.ownerDocument.defaultView;
40615
+ const dpr = win?.devicePixelRatio ?? 1;
40616
+ const gapColor = win ? win.getComputedStyle(gridEl).backgroundColor : "";
40617
+ const tiles = [];
40618
+ for (const cell of cells) {
40619
+ const source = cell.screenshotCanvas();
40620
+ if (!source) continue;
40621
+ const r = cell.host.getBoundingClientRect();
40622
+ const place = tilesFromCellRects(grid, [{ left: r.left, top: r.top, width: r.width, height: r.height }])[0];
40623
+ if (!place) continue;
40624
+ tiles.push({ source, ...place });
40625
+ }
40626
+ return compositeLayoutScreenshot(gridEl.ownerDocument, {
40627
+ width: grid.width,
40628
+ height: grid.height,
40629
+ dpr,
40630
+ gapColor: gapColor || "#000000"
40631
+ }, tiles);
40632
+ }
40320
40633
  setActiveCell(id) {
40321
40634
  if (id === this.activeId || this.destroyed) return;
40322
40635
  const prev2 = this.activeId;
@@ -41363,7 +41676,7 @@ var VelaWorkspace = class {
41363
41676
  this.moreDrawer ?? (this.moreDrawer = new MoreDrawer({
41364
41677
  host: this.root,
41365
41678
  ...has("undo-redo") ? { onUndo: () => this.active.history.undo(), onRedo: () => this.active.history.redo() } : {},
41366
- ...has("screenshot") ? { onScreenshot: this.screenshotOverride ? () => this.runOverride(this.screenshotOverride) : () => this.active.downloadScreenshot() } : {},
41679
+ ...has("screenshot") ? { onScreenshot: this.screenshotOverride ? () => this.runOverride(this.screenshotOverride) : () => this.downloadScreenshot() } : {},
41367
41680
  canUndo: () => this.active.history.canUndo,
41368
41681
  canRedo: () => this.active.history.canRedo,
41369
41682
  priceStyles: () => priceStyleIds().map((id) => ({ id, label: priceStyleLabel(id), icon: priceStyleIcon(id) })),
@@ -41464,9 +41777,9 @@ var VelaWorkspace = class {
41464
41777
  this.keymap.register({
41465
41778
  id: "chart.screenshot",
41466
41779
  keys: "mod+alt+s",
41467
- label: ov ? ov.label : "Download a chart screenshot",
41780
+ label: ov ? ov.label : "Download a screenshot of the layout",
41468
41781
  category: "Chart",
41469
- run: ov ? () => this.runOverride(ov) : () => this.active.downloadScreenshot()
41782
+ run: ov ? () => this.runOverride(ov) : () => this.downloadScreenshot()
41470
41783
  });
41471
41784
  }
41472
41785
  this.keymap.register({ id: "chart.reset-view", keys: "alt+r", label: "Reset view (all history)", category: "Chart", run: () => this.active.chart.setVisibleRangePreset("ALL") });