@luxalgo/vela 0.6.13 → 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-G52XAKZY.js → chunk-7C7NWEXH.js} +345 -148
  8. package/dist/{chunk-JKGA36ZM.js → chunk-J5QIR2Z3.js} +165 -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 +176 -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 +176 -127
  40. package/dist/vela.global.min.js +52 -46
  41. package/dist/widget.cjs +587 -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 +587 -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;
@@ -18393,6 +18524,7 @@ var RUN_EMIT_THROTTLE_MS = 1e3;
18393
18524
  var PREVIEW_BARS = 300;
18394
18525
  var SINGLE_LOAD_BARS = 5e3;
18395
18526
  var CHUNK_BARS = 1e4;
18527
+ var FIRST_PAINT_BARS = 20;
18396
18528
  var GAP_FACTOR = 1.5;
18397
18529
  var HEAL_COOLDOWN_MS = 5e3;
18398
18530
  var EngineOrchestrator = class _EngineOrchestrator {
@@ -18668,6 +18800,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
18668
18800
  let painted = false;
18669
18801
  const paint = (bars, final) => {
18670
18802
  if (this.generation !== gen || !final && bars.length === 0) return;
18803
+ if (!painted && !final && bars.length < Math.min(requested, FIRST_PAINT_BARS)) return;
18671
18804
  this.setBarSeries(bars, painted ? { preserveView: true } : void 0);
18672
18805
  if (!painted && bars.length > 0) {
18673
18806
  painted = true;
@@ -20085,6 +20218,15 @@ var RendererControl = class {
20085
20218
  console.warn(`[vela] renderer "${this.renderer.name}" does not support screenshot().`);
20086
20219
  return null;
20087
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
+ }
20088
20230
  /**
20089
20231
  * The active renderer's full cosmetic config as a serializable, versioned JSON
20090
20232
  * document — persist it (templates, saved settings) and feed it back to
@@ -22653,6 +22795,66 @@ function candleGeometry(xCss, spacing, dpr, bodyScale = 1) {
22653
22795
  center: (wickLeftDev + wickDev / 2) / dpr
22654
22796
  };
22655
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
+ }
22656
22858
 
22657
22859
  // src/renderers/native/backend/gl/Batch.ts
22658
22860
  var FLOATS_PER_VERT = 8;
@@ -23625,44 +23827,19 @@ var WebGL2Backend = class {
23625
23827
  }
23626
23828
  }
23627
23829
  /**
23628
- * Sub-pixel LOD (mirrors Canvas2dBackend.drawCandlesAggregated): bucket bars
23629
- * sharing a rounded pixel column into one high-low stick draw cost bounded by
23630
- * 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.
23631
23835
  */
23632
23836
  emitCandlesAggregated(b, bars, i0, i1, coords, pane, up, down, barColors) {
23633
- let px = NaN;
23634
- let lo = Infinity;
23635
- let hi = -Infinity;
23636
- let openV = 0;
23637
- let closeV = 0;
23638
- let headTime = 0;
23639
- let active = false;
23640
- const flush = () => {
23641
- if (!active) return;
23642
- const c = parseColor(barColors.get(headTime) ?? (closeV >= openV ? up : down));
23643
- const hY = coords.priceToY(hi, pane.scale, pane.bounds);
23644
- const lY = coords.priceToY(lo, pane.scale, pane.bounds);
23645
- b.rect(px - 0.5, hY, 1, lY - hY, c);
23646
- };
23647
- for (let i = i0; i <= i1; i += 1) {
23648
- const bar = bars[i];
23649
- if (!bar || bar.high <= bar.low) continue;
23650
- const x = Math.round(coords.logicalToX(i));
23651
- if (!active || x !== px) {
23652
- flush();
23653
- px = x;
23654
- lo = bar.low;
23655
- hi = bar.high;
23656
- openV = bar.open;
23657
- headTime = bar.time;
23658
- active = true;
23659
- } else {
23660
- if (bar.low < lo) lo = bar.low;
23661
- if (bar.high > hi) hi = bar.high;
23662
- }
23663
- 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);
23664
23842
  }
23665
- flush();
23666
23843
  }
23667
23844
  emitSeries(b, spec, pane, coords, i0, i1, theme, off = 0) {
23668
23845
  if (spec.kind === "candle" || spec.kind === "bar") {
@@ -24960,13 +25137,15 @@ var SceneGraph = class {
24960
25137
  if (!this.orderedCache) this.orderedCache = [...this.panes.values()].sort((a, b) => a.order - b.order);
24961
25138
  return this.orderedCache;
24962
25139
  }
24963
- /** The session zones resolved into colored bands (pre/post-market washes from the
24964
- * 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}. */
24965
25143
  sessionHighlightBands() {
24966
25144
  if (!this.sessionZones) return [];
24967
25145
  const out = [];
24968
25146
  for (const [from, to] of this.sessionZones.pre) out.push({ from, to, color: this.style.sessions.premarketColor });
24969
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 });
24970
25149
  return out;
24971
25150
  }
24972
25151
  indicatorsForPane(paneId) {
@@ -25505,50 +25684,23 @@ var Canvas2dBackend = class {
25505
25684
  }
25506
25685
  }
25507
25686
  /**
25508
- * Sub-pixel LOD: bucket every bar sharing a rounded pixel column into ONE
25509
- * high-low stick, so draw cost is bounded by screen width (not bar count) when
25510
- * zoomed far out. The bucket's color follows its first-open→last-close
25511
- * 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).
25512
25692
  */
25513
25693
  drawCandlesAggregated(ctx, bars, i0, i1, coords, pane, up, down, barColors) {
25514
- let px = NaN;
25515
- let lo = Infinity;
25516
- let hi = -Infinity;
25517
- let openV = 0;
25518
- let closeV = 0;
25519
- let headTime = 0;
25520
- let active = false;
25521
- const flush = () => {
25522
- if (!active) return;
25523
- const color = barColors.get(headTime) ?? (closeV >= openV ? up : down);
25524
- const hY = coords.priceToY(hi, pane.scale, pane.bounds);
25525
- const lY = coords.priceToY(lo, pane.scale, pane.bounds);
25526
- ctx.strokeStyle = color;
25527
- 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);
25528
25699
  ctx.beginPath();
25529
- ctx.moveTo(px, hY);
25530
- ctx.lineTo(px, lY);
25700
+ ctx.moveTo(x, yOf(s.hi));
25701
+ ctx.lineTo(x, yOf(s.lo));
25531
25702
  ctx.stroke();
25532
- };
25533
- for (let i = i0; i <= i1; i += 1) {
25534
- const b = bars[i];
25535
- if (!b || b.high <= b.low) continue;
25536
- const x = Math.round(coords.logicalToX(i)) + 0.5;
25537
- if (!active || x !== px) {
25538
- flush();
25539
- px = x;
25540
- lo = b.low;
25541
- hi = b.high;
25542
- openV = b.open;
25543
- headTime = b.time;
25544
- active = true;
25545
- } else {
25546
- if (b.low < lo) lo = b.low;
25547
- if (b.high > hi) hi = b.high;
25548
- }
25549
- closeV = b.close;
25550
25703
  }
25551
- flush();
25552
25704
  }
25553
25705
  drawSeries(ctx, spec, pane, coords, i0, i1, theme, off = 0) {
25554
25706
  if (spec.kind === "candle" || spec.kind === "bar") {
@@ -26572,6 +26724,32 @@ var DrawingSceneRenderer = class {
26572
26724
  }
26573
26725
  };
26574
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
+
26575
26753
  // src/renderers/native/chrome/ticks.ts
26576
26754
  function priceTicks(min, max, target = 6) {
26577
26755
  if (!(max > min) || !Number.isFinite(min) || !Number.isFinite(max)) return [];
@@ -26719,7 +26897,14 @@ var STEP_LADDER = [
26719
26897
  YEAR
26720
26898
  ];
26721
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"];
26722
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
+ }
26723
26908
  function pickStep(targetMs) {
26724
26909
  for (const step of STEP_LADDER) if (step >= targetMs) return step;
26725
26910
  return STEP_LADDER[STEP_LADDER.length - 1];
@@ -26761,32 +26946,6 @@ function timeTicks(fromMs, toMs, target = 8, offsetMs = 0) {
26761
26946
  return out;
26762
26947
  }
26763
26948
 
26764
- // src/renderers/native/chrome/tz.ts
26765
- function tzOffsetMs(ms, timeZone) {
26766
- if (!timeZone || timeZone === "UTC") return 0;
26767
- try {
26768
- const dtf = new Intl.DateTimeFormat("en-US", {
26769
- timeZone,
26770
- hourCycle: "h23",
26771
- year: "numeric",
26772
- month: "2-digit",
26773
- day: "2-digit",
26774
- hour: "2-digit",
26775
- minute: "2-digit",
26776
- second: "2-digit"
26777
- });
26778
- const parts = dtf.formatToParts(new Date(ms));
26779
- const get = (t) => Number(parts.find((p) => p.type === t)?.value);
26780
- const asUTC = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour") % 24, get("minute"), get("second"));
26781
- return asUTC - ms;
26782
- } catch {
26783
- return 0;
26784
- }
26785
- }
26786
- function zonedDate(ms, timeZone) {
26787
- return new Date(ms + tzOffsetMs(ms, timeZone));
26788
- }
26789
-
26790
26949
  // src/renderers/native/chrome/ChromeRenderer.ts
26791
26950
  var ChromeRenderer = class {
26792
26951
  constructor() {
@@ -27233,7 +27392,7 @@ var CrosshairRenderer = class {
27233
27392
  const price = coords.yToPrice(ch.y, pane.scale, pane.bounds);
27234
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);
27235
27394
  }
27236
- 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);
27237
27396
  }
27238
27397
  destroy() {
27239
27398
  this.canvas = null;
@@ -27281,7 +27440,7 @@ var CrosshairRenderer = class {
27281
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);
27282
27441
  }
27283
27442
  }
27284
- 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);
27285
27444
  ctx.globalAlpha = 1;
27286
27445
  }
27287
27446
  /** A soft band + a brighter crisp center line over the hovered separator, so it reads as
@@ -27314,11 +27473,6 @@ function setDash3(ctx, style) {
27314
27473
  else if (style === "dotted") ctx.setLineDash([2, 3]);
27315
27474
  else ctx.setLineDash([]);
27316
27475
  }
27317
- function formatStamp(ms, timeZone) {
27318
- const d = zonedDate(ms, timeZone);
27319
- const pad = (n) => n < 10 ? `0${n}` : String(n);
27320
- return `${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
27321
- }
27322
27476
 
27323
27477
  // src/renderers/native/chrome/settings-visibility.ts
27324
27478
  function settingsIdSlug(label) {
@@ -33085,6 +33239,9 @@ function clipHighlightRect(x1, x2, left, right) {
33085
33239
  const end = Math.min(right, x2);
33086
33240
  return end > x ? { x, width: end - x } : null;
33087
33241
  }
33242
+ function bandEdgeSlot(logical) {
33243
+ return Math.ceil(logical) - 0.5;
33244
+ }
33088
33245
  var BackdropRenderer = class {
33089
33246
  constructor() {
33090
33247
  this.canvas = null;
@@ -33115,21 +33272,22 @@ var BackdropRenderer = class {
33115
33272
  this.drawGrid(ctx, scene, coords, theme, coords.width, gridAlpha);
33116
33273
  }
33117
33274
  /** Renderer-owned session highlight bands: full-height (all panes), behind the grid.
33118
- * 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). */
33119
33278
  drawHighlights(ctx, scene, coords) {
33120
33279
  const sessions = scene.sessionHighlightBands();
33121
33280
  if (sessions.length > 0) {
33122
33281
  const left = Math.max(0, coords.logicalToX(-0.5));
33123
33282
  const right = Math.min(coords.width, coords.logicalToX(coords.barCount - 0.5));
33124
- this.drawHighlightSet(ctx, sessions, coords, left, right);
33283
+ this.drawHighlightSet(ctx, sessions, coords, left, right, true);
33125
33284
  }
33126
- this.drawHighlightSet(ctx, scene.highlights, coords, 0, coords.width);
33285
+ this.drawHighlightSet(ctx, scene.highlights, coords, 0, coords.width, false);
33127
33286
  }
33128
- 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);
33129
33289
  for (const band of bands) {
33130
- const x1 = coords.timeToX(band.from);
33131
- const x2 = coords.timeToX(band.to);
33132
- const rect = clipHighlightRect(x1, x2, left, right);
33290
+ const rect = clipHighlightRect(edgeX(band.from), edgeX(band.to), left, right);
33133
33291
  if (!rect) continue;
33134
33292
  ctx.fillStyle = band.color;
33135
33293
  ctx.fillRect(rect.x, 0, rect.width, coords.height);
@@ -34276,7 +34434,7 @@ var NativeRenderer = class {
34276
34434
  };
34277
34435
  })(),
34278
34436
  series: { style: this.scene.priceStyle, baseline: this.scene.baselineValue, spacing: this.coords.spacingScale },
34279
- sessions: { premarketColor: s.sessions.premarketColor, postmarketColor: s.sessions.postmarketColor }
34437
+ sessions: { premarketColor: s.sessions.premarketColor, postmarketColor: s.sessions.postmarketColor, extendedColor: s.sessions.extendedColor }
34280
34438
  };
34281
34439
  }
34282
34440
  /**
@@ -34358,7 +34516,7 @@ var NativeRenderer = class {
34358
34516
  width: next.baseline.width,
34359
34517
  baselineLevel: next.baseline.baselineLevel
34360
34518
  };
34361
- 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 };
34362
34520
  this.setPriceStyle(next.series.style);
34363
34521
  this.scene.candleOverride = candleOverrideFor(this.scene.priceStyle, s.chartTypes);
34364
34522
  this.scene.baselineValue = next.series.baseline;
@@ -34580,9 +34738,10 @@ var NativeRenderer = class {
34580
34738
  * opts in with a `data-vela-screenshot` attribute on the mount container's
34581
34739
  * subtree (the widget marks its status line, and its symbol watermark with
34582
34740
  * `"under"` — drawn beneath the canvases, where it sits on screen). Only the
34583
- * crosshair (L2) is intentionally excluded.
34741
+ * crosshair (L2) is intentionally excluded. {@link screenshot} is this canvas
34742
+ * as a PNG data URL.
34584
34743
  */
34585
- screenshot() {
34744
+ screenshotCanvas() {
34586
34745
  if (!this.dataCanvas) return null;
34587
34746
  this.computeScales();
34588
34747
  this.paintData();
@@ -34610,7 +34769,10 @@ var NativeRenderer = class {
34610
34769
  if (el.getAttribute("data-vela-screenshot") !== "under") rasterizeOverlay(ctx, el, frame);
34611
34770
  }
34612
34771
  }
34613
- return out.toDataURL("image/png");
34772
+ return out;
34773
+ }
34774
+ screenshot() {
34775
+ return this.screenshotCanvas()?.toDataURL("image/png") ?? null;
34614
34776
  }
34615
34777
  /**
34616
34778
  * The candles-first-appear reveal — animates the **candles themselves** (no extra
@@ -36005,7 +36167,7 @@ var NativeRenderer = class {
36005
36167
  const pricePane = this.dataWindowPricePane();
36006
36168
  const ohlc = this.dataWindowOHLC(bar, pricePane);
36007
36169
  const parts = [];
36008
- if (bar) parts.push(formatStamp2(bar.time, this.scene.timezone));
36170
+ if (bar) parts.push(formatStamp(bar.time, this.scene.timezone));
36009
36171
  if (ohlc) parts.push(`Open ${ohlc.o}, High ${ohlc.h}, Low ${ohlc.l}, Close ${ohlc.c}`);
36010
36172
  for (const group of this.dataWindowGroups(idx, pricePane)) {
36011
36173
  for (const row of group.rows) parts.push(`${row.label} ${row.value}`);
@@ -36892,7 +37054,7 @@ function formatStampParts(ms, timeZone) {
36892
37054
  time: `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`
36893
37055
  };
36894
37056
  }
36895
- function formatStamp2(ms, timeZone) {
37057
+ function formatStamp(ms, timeZone) {
36896
37058
  const { date, time } = formatStampParts(ms, timeZone);
36897
37059
  return `${date} ${time}`;
36898
37060
  }
@@ -36923,7 +37085,7 @@ function sanitizeSessionZones(value) {
36923
37085
  }
36924
37086
  return out.sort((a, b) => a[0] - b[0]);
36925
37087
  };
36926
- return { pre: windows(v.pre), post: windows(v.post) };
37088
+ return { pre: windows(v.pre), post: windows(v.post), extended: windows(v.extended) };
36927
37089
  }
36928
37090
  function clamp012(v) {
36929
37091
  return Math.max(0, Math.min(1, v));
@@ -37612,6 +37774,7 @@ var MARKET_LABELS = {
37612
37774
  open: "Market Open",
37613
37775
  pre: "Pre-Market",
37614
37776
  post: "Post-Market",
37777
+ extended: "Extended Hours",
37615
37778
  closed: "Market Closed",
37616
37779
  holiday: "Market Holiday"
37617
37780
  };
@@ -37619,6 +37782,7 @@ var MARKET_INKS = {
37619
37782
  open: "var(--vela-up)",
37620
37783
  pre: SESSION_PRE,
37621
37784
  post: SESSION_POST,
37785
+ extended: SESSION_POST,
37622
37786
  closed: SESSION_OFF,
37623
37787
  holiday: SESSION_OFF
37624
37788
  };
@@ -37932,118 +38096,17 @@ var Statusline = class {
37932
38096
  }
37933
38097
  };
37934
38098
 
37935
- // src/widget/market-status.ts
37936
- function civilDate(ms, tz) {
37937
- try {
37938
- return new Intl.DateTimeFormat("en-CA", { timeZone: tz }).format(ms);
37939
- } catch {
37940
- return "";
37941
- }
37942
- }
37943
- function isWeekday(ms, tz) {
37944
- try {
37945
- const wd = new Intl.DateTimeFormat("en-US", { timeZone: tz, weekday: "short" }).format(ms);
37946
- return wd !== "Sat" && wd !== "Sun";
37947
- } catch {
37948
- return false;
37949
- }
37950
- }
37951
- function deriveMarketStatus(now, w, tz) {
37952
- const within = (ws) => ws.find(([s, e]) => now >= s && now < e);
37953
- if (within(w.regular)) return "open";
37954
- const ext = within(w.extended);
37955
- if (ext) return w.regular.some(([s]) => s >= now && s < ext[1]) ? "pre" : "post";
37956
- if (isWeekday(now, tz)) {
37957
- const today = civilDate(now, tz);
37958
- if (today !== "" && !w.extended.some(([s]) => civilDate(s, tz) === today)) return "holiday";
37959
- }
37960
- return "closed";
37961
- }
37962
- function nextStatusBoundary(now, w) {
37963
- let next = null;
37964
- for (const ws of [w.regular, w.extended]) {
37965
- for (const [s, e] of ws) {
37966
- for (const t of [s, e]) {
37967
- if (t > now && (next == null || t < next)) next = t;
37968
- }
37969
- }
37970
- }
37971
- return next;
37972
- }
37973
- var FETCH_BACK_MS = 4 * 864e5;
37974
- var FETCH_AHEAD_MS = 10 * 864e5;
37975
- var MIN_TIMER_MS = 15e3;
37976
- var MAX_TIMER_MS = 36e5;
37977
- var RETRY_MS2 = 6e4;
37978
- var MarketStatusTracker = class {
37979
- constructor(onStatus) {
37980
- this.onStatus = onStatus;
37981
- /** Invalidates detached async work — bumped by every track()/stop(). */
37982
- this.epoch = 0;
37983
- this.timer = null;
37984
- }
37985
- /** (Re)bind to a chart's data surface + symbol and start evaluating. */
37986
- track(data, symbol) {
37987
- const my = ++this.epoch;
37988
- this.clearTimer();
37989
- void this.evaluate(my, data, symbol);
37990
- }
37991
- stop() {
37992
- this.epoch += 1;
37993
- this.clearTimer();
37994
- }
37995
- async evaluate(my, data, symbol) {
37996
- const resolved = data.resolve(symbol);
37997
- const provider = resolved ? data.providerInstance(resolved.provider) : void 0;
37998
- const si = await data.symbolInfo(symbol).catch(() => void 0);
37999
- if (my !== this.epoch) return;
38000
- const hasSessions = typeof si?.session === "string" && si.session !== "" && si.session !== "24x7";
38001
- if (!provider?.getCalendar || !hasSessions || !resolved) {
38002
- this.onStatus("open");
38003
- return;
38004
- }
38005
- const tz = typeof si?.timezone === "string" && si.timezone !== "" ? si.timezone : "Etc/UTC";
38006
- const now = Date.now();
38007
- const range = { from: now - FETCH_BACK_MS, to: now + FETCH_AHEAD_MS };
38008
- const [regular, extended] = await Promise.all([
38009
- provider.getCalendar(resolved.ticker, { ...range, session: "regular" }).catch(() => null),
38010
- provider.getCalendar(resolved.ticker, { ...range, session: "extended" }).catch(() => null)
38011
- ]);
38012
- if (my !== this.epoch) return;
38013
- if (!regular || !extended) {
38014
- this.arm(my, data, symbol, RETRY_MS2);
38015
- return;
38016
- }
38017
- const w = { regular, extended };
38018
- this.onStatus(deriveMarketStatus(now, w, tz));
38019
- const boundary = nextStatusBoundary(now, w);
38020
- const delay = Math.min(boundary != null ? boundary - now : MAX_TIMER_MS, MAX_TIMER_MS);
38021
- this.arm(my, data, symbol, Math.max(delay, MIN_TIMER_MS));
38022
- }
38023
- arm(my, data, symbol, delay) {
38024
- this.clearTimer();
38025
- this.timer = setTimeout(() => {
38026
- this.timer = null;
38027
- if (my !== this.epoch) return;
38028
- void this.evaluate(my, data, symbol);
38029
- }, delay);
38030
- }
38031
- clearTimer() {
38032
- if (this.timer != null) clearTimeout(this.timer);
38033
- this.timer = null;
38034
- }
38035
- };
38036
-
38037
38099
  // src/widget/session-shading.ts
38038
38100
  var DAY_MS2 = 864e5;
38039
38101
  var MINUTE_MS = 6e4;
38040
- function parseWindow(text) {
38102
+ function parseWindow(text, allowOvernight = false) {
38041
38103
  if (typeof text !== "string") return null;
38042
38104
  const m = /^(\d{2})(\d{2})-(\d{2})(\d{2})$/.exec(text);
38043
38105
  if (!m) return null;
38044
38106
  const start = Number(m[1]) * 60 + Number(m[2]);
38045
38107
  const end = Number(m[3]) * 60 + Number(m[4]);
38046
- if (start >= end || end > 1440) return null;
38108
+ if (end > 1440 || start >= 1440) return null;
38109
+ if (start >= end && !allowOvernight) return null;
38047
38110
  return { start, end };
38048
38111
  }
38049
38112
  function parseSessionSpec(si) {
@@ -38053,9 +38116,13 @@ function parseSessionSpec(si) {
38053
38116
  if (!regular) return null;
38054
38117
  const tz = si?.["timezone"];
38055
38118
  const timezone = typeof tz === "string" && tz !== "" ? tz : "Etc/UTC";
38056
- 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
+ }
38057
38124
  const extended = ext && ext.start <= regular.start && ext.end >= regular.end ? ext : { start: 0, end: 1440 };
38058
- return { regular, extended, timezone };
38125
+ return { regular, extended, overnight: false, timezone };
38059
38126
  }
38060
38127
  var dtfCache = /* @__PURE__ */ new Map();
38061
38128
  function civilFormatter(tz) {
@@ -38091,27 +38158,48 @@ function civilParts(dtf, ms) {
38091
38158
  minute: Number(get("minute"))
38092
38159
  };
38093
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
+ }
38094
38172
  function expandSessionZones(spec, from, to) {
38095
38173
  const pre = [];
38096
38174
  const post = [];
38175
+ const extended = [];
38097
38176
  const dtf = civilFormatter(spec.timezone);
38098
- if (!dtf || !Number.isFinite(from) || !Number.isFinite(to)) return { pre, post };
38177
+ if (!dtf || !Number.isFinite(from) || !Number.isFinite(to)) return { pre, post, extended };
38099
38178
  const seen = /* @__PURE__ */ new Set();
38100
38179
  for (let cursor = from - DAY_MS2; cursor < to + DAY_MS2; cursor += DAY_MS2) {
38101
38180
  const civil = civilParts(dtf, cursor);
38102
38181
  const key = civil.year * 1e4 + civil.month * 100 + civil.day;
38103
38182
  if (!Number.isFinite(key) || seen.has(key)) continue;
38104
38183
  seen.add(key);
38105
- if (civil.weekday === "Sat" || civil.weekday === "Sun") continue;
38184
+ const sunday = civil.weekday === "Sun";
38185
+ if (civil.weekday === "Sat" || sunday && !spec.overnight) continue;
38106
38186
  const naive = Date.UTC(civil.year, civil.month - 1, civil.day);
38107
38187
  const noonGuess = naive + 720 * MINUTE_MS;
38108
38188
  const atNoon = civilParts(dtf, noonGuess);
38109
38189
  const offset = Date.UTC(atNoon.year, atNoon.month - 1, atNoon.day, atNoon.hour, atNoon.minute) - noonGuess;
38110
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
+ }
38111
38199
  if (spec.extended.start < spec.regular.start) pre.push([at(spec.extended.start), at(spec.regular.start)]);
38112
38200
  if (spec.regular.end < spec.extended.end) post.push([at(spec.regular.end), at(spec.extended.end)]);
38113
38201
  }
38114
- return { pre, post };
38202
+ return { pre, post, extended: mergeAbutting(extended) };
38115
38203
  }
38116
38204
  var COVER_PAD_MIN_MS = 2 * DAY_MS2;
38117
38205
  var SessionShadingTracker = class {
@@ -38166,7 +38254,7 @@ var SessionShadingTracker = class {
38166
38254
  return;
38167
38255
  }
38168
38256
  if (this.session !== "extended" || !this.intraday) {
38169
- if (force) this.onZones({ pre: [], post: [] });
38257
+ if (force) this.onZones({ pre: [], post: [], extended: [] });
38170
38258
  return;
38171
38259
  }
38172
38260
  const from = Math.min(range.from, range.to);
@@ -38180,6 +38268,112 @@ var SessionShadingTracker = class {
38180
38268
  }
38181
38269
  };
38182
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
+
38183
38377
  // src/widget/watermark.ts
38184
38378
  var MAX_FONT_PX = 36;
38185
38379
  var MIN_FONT_PX = 12;
@@ -38693,7 +38887,10 @@ var ChartCell = class {
38693
38887
  this.activeRangeId = null;
38694
38888
  /** Latched verdict of {@link sessionAvailable} (async metadata, sticky per symbol). */
38695
38889
  this.sessionAvailableFlag = false;
38696
- /** 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}). */
38697
38894
  this.sessionShading = new SessionShadingTracker((zones) => this.inner?.renderer.set("sessionZones", zones));
38698
38895
  this.manifest = [];
38699
38896
  /** A restored ledger's manifest entry NAMES, waiting for the manifest to resolve
@@ -38946,8 +39143,10 @@ var ChartCell = class {
38946
39143
  void chart.data.symbolInfo(symbol).then((si) => {
38947
39144
  if (this.inner !== chart) return;
38948
39145
  const available = typeof si?.session === "string" && si.session !== "" && si.session !== "24x7";
38949
- if (available !== this.sessionAvailableFlag) {
39146
+ const overnight = parseSessionSpec(si)?.overnight === true;
39147
+ if (available !== this.sessionAvailableFlag || overnight !== this.sessionOvernightFlag) {
38950
39148
  this.sessionAvailableFlag = available;
39149
+ this.sessionOvernightFlag = overnight;
38951
39150
  this.deps.onMarketChanged(this.id);
38952
39151
  this.pushSettingsSections();
38953
39152
  }
@@ -38981,16 +39180,41 @@ var ChartCell = class {
38981
39180
  /**
38982
39181
  * (Re)contribute this cell's settings-dialog sections: status line parts, the
38983
39182
  * per-cell fetch depth, the watermark toggle, and — only while the cell's symbol
38984
- * HAS sessions — the Trading session group (RTH/ETH switch + the pre/post-market
38985
- * shading colors) inside the Symbol tab. Bars/watermark/titles are persistable
38986
- * cell state; a depth-only reload is silent, so mark dirty here. Re-run whenever
38987
- * 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).
38988
39188
  */
38989
39189
  pushSettingsSections() {
38990
39190
  const chart = this.inner;
38991
39191
  if (!chart) return;
38992
39192
  const rth = "Regular hours (RTH)";
38993
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
+ ];
38994
39218
  const sessionSection = {
38995
39219
  title: "Trading session",
38996
39220
  id: "trading-session",
@@ -39004,20 +39228,7 @@ var ChartCell = class {
39004
39228
  get: () => this.session === "extended" ? eth : rth,
39005
39229
  set: (v) => this.setSession(v === eth ? "extended" : "regular")
39006
39230
  },
39007
- {
39008
- kind: "color",
39009
- label: "Pre-market",
39010
- id: "premarket-color",
39011
- get: () => this.sessionShadeColor("premarketColor"),
39012
- set: (v) => this.setSessionShadeColor("premarketColor", v)
39013
- },
39014
- {
39015
- kind: "color",
39016
- label: "Post-market",
39017
- id: "postmarket-color",
39018
- get: () => this.sessionShadeColor("postmarketColor"),
39019
- set: (v) => this.setSessionShadeColor("postmarketColor", v)
39020
- }
39231
+ ...shadeRows
39021
39232
  ]
39022
39233
  };
39023
39234
  const advanced = {
@@ -39261,6 +39472,10 @@ var ChartCell = class {
39261
39472
  this.deps.activate(this.id);
39262
39473
  this.inner?.renderer.focus();
39263
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
+ }
39264
39479
  /** Download this cell's chart as a PNG (named after its market). */
39265
39480
  downloadScreenshot() {
39266
39481
  const url = this.inner?.renderer.screenshot();
@@ -39879,6 +40094,54 @@ var SplitterLayer = class {
39879
40094
  }
39880
40095
  };
39881
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
+
39882
40145
  // src/workspace/VelaWorkspace.ts
39883
40146
  var DEFAULT_TIMEFRAMES = ["1", "5", "15", "30", "60", "240", "D", "W", "M"];
39884
40147
  var GAP_PX = 2;
@@ -40109,7 +40372,7 @@ var VelaWorkspace = class {
40109
40372
  ...picker ? { onIndicatorsClick: () => picker.open() } : {},
40110
40373
  onUndoClick: () => this.active.history.undo(),
40111
40374
  onRedoClick: () => this.active.history.redo(),
40112
- onScreenshotClick: () => this.active.downloadScreenshot(),
40375
+ onScreenshotClick: () => this.downloadScreenshot(),
40113
40376
  onAlertsClick: (anchor) => this.openAlertsMenu(anchor),
40114
40377
  timeframe: "60",
40115
40378
  timeframes: opts.timeframes ?? DEFAULT_TIMEFRAMES,
@@ -40168,6 +40431,7 @@ var VelaWorkspace = class {
40168
40431
  this.dock.addBuiltIn({ id: "dataWindow", title: "Data window", icon: "datawindow", order: 10, panel: this.dataWindow, onChart: (c) => this.dataWindow.onChart(c) });
40169
40432
  this.dock.addBuiltIn({ id: "objects", title: "Object tree", icon: "objects", order: 20, panel: this.objectTree, onChart: (c) => this.objectTree.onChart(c) });
40170
40433
  this.dock.refresh();
40434
+ if (boot?.panels) this.dock.applyState(boot.panels);
40171
40435
  this.root.appendChild(main);
40172
40436
  this.toastHost = new Toast(this.gridEl);
40173
40437
  const attribution = rendererDefaults().attribution;
@@ -40179,6 +40443,7 @@ var VelaWorkspace = class {
40179
40443
  bottom: `calc(var(--vela-bottom-gutter, ${TIME_AXIS_H4}px) + 10px)`,
40180
40444
  zIndex: "11"
40181
40445
  });
40446
+ mark.dataset.velaScreenshot = "1";
40182
40447
  this.gridEl.appendChild(mark);
40183
40448
  this.attributionMark = mark;
40184
40449
  }
@@ -40315,6 +40580,56 @@ var VelaWorkspace = class {
40315
40580
  get chart() {
40316
40581
  return this.active.chart;
40317
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
+ }
40318
40633
  setActiveCell(id) {
40319
40634
  if (id === this.activeId || this.destroyed) return;
40320
40635
  const prev2 = this.activeId;
@@ -41361,7 +41676,7 @@ var VelaWorkspace = class {
41361
41676
  this.moreDrawer ?? (this.moreDrawer = new MoreDrawer({
41362
41677
  host: this.root,
41363
41678
  ...has("undo-redo") ? { onUndo: () => this.active.history.undo(), onRedo: () => this.active.history.redo() } : {},
41364
- ...has("screenshot") ? { onScreenshot: this.screenshotOverride ? () => this.runOverride(this.screenshotOverride) : () => this.active.downloadScreenshot() } : {},
41679
+ ...has("screenshot") ? { onScreenshot: this.screenshotOverride ? () => this.runOverride(this.screenshotOverride) : () => this.downloadScreenshot() } : {},
41365
41680
  canUndo: () => this.active.history.canUndo,
41366
41681
  canRedo: () => this.active.history.canRedo,
41367
41682
  priceStyles: () => priceStyleIds().map((id) => ({ id, label: priceStyleLabel(id), icon: priceStyleIcon(id) })),
@@ -41462,9 +41777,9 @@ var VelaWorkspace = class {
41462
41777
  this.keymap.register({
41463
41778
  id: "chart.screenshot",
41464
41779
  keys: "mod+alt+s",
41465
- label: ov ? ov.label : "Download a chart screenshot",
41780
+ label: ov ? ov.label : "Download a screenshot of the layout",
41466
41781
  category: "Chart",
41467
- run: ov ? () => this.runOverride(ov) : () => this.active.downloadScreenshot()
41782
+ run: ov ? () => this.runOverride(ov) : () => this.downloadScreenshot()
41468
41783
  });
41469
41784
  }
41470
41785
  this.keymap.register({ id: "chart.reset-view", keys: "alt+r", label: "Reset view (all history)", category: "Chart", run: () => this.active.chart.setVisibleRangePreset("ALL") });