@luxalgo/vela 0.6.8 → 0.6.10

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 (45) hide show
  1. package/dist/{DataProvider-BSLlBpB9.d.ts → DataProvider-CWmp31dA.d.ts} +49 -1
  2. package/dist/{DataProvider-BsQM2WNH.d.cts → DataProvider-l_eLMly_.d.cts} +49 -1
  3. package/dist/{chunk-L3I2CCYO.js → chunk-4KZVZ7QQ.js} +49 -2
  4. package/dist/{chunk-SQETIBFU.js → chunk-FFOP37FQ.js} +996 -377
  5. package/dist/{chunk-2R7BANEM.js → chunk-G77Y7LK2.js} +2 -2
  6. package/dist/{chunk-PN5KWFZ4.js → chunk-KG4YT3TI.js} +5 -3
  7. package/dist/{chunk-OICPLNU7.js → chunk-MHY7MVXH.js} +192 -2
  8. package/dist/{chunk-N7LGMKCE.js → chunk-NMTQXNT4.js} +637 -164
  9. package/dist/{contributions-DLLdV9jD.d.ts → contributions-BCz6Dr6a.d.ts} +99 -7
  10. package/dist/{contributions-Vw-Hm58R.d.cts → contributions-D9vTzm5p.d.cts} +99 -7
  11. package/dist/index.cjs +1210 -383
  12. package/dist/index.d.cts +37 -11
  13. package/dist/index.d.ts +37 -11
  14. package/dist/index.js +4 -4
  15. package/dist/{options-BaVTMXaO.d.ts → options-DSqHsQyN.d.cts} +84 -7
  16. package/dist/{options-BaVTMXaO.d.cts → options-DSqHsQyN.d.ts} +84 -7
  17. package/dist/{plugin-BnJgjLAy.d.cts → plugin-Bt8hLR8Z.d.cts} +3 -3
  18. package/dist/{plugin-CC7rBrOY.d.ts → plugin-CH7pfMrE.d.ts} +3 -3
  19. package/dist/plugin.cjs +21 -3
  20. package/dist/plugin.d.cts +4 -4
  21. package/dist/plugin.d.ts +4 -4
  22. package/dist/plugin.js +2 -2
  23. package/dist/providers/binance.d.cts +2 -2
  24. package/dist/providers/binance.d.ts +2 -2
  25. package/dist/providers/coinbase.d.cts +2 -2
  26. package/dist/providers/coinbase.d.ts +2 -2
  27. package/dist/providers/hyperliquid.d.cts +2 -2
  28. package/dist/providers/hyperliquid.d.ts +2 -2
  29. package/dist/{statusline-CGkB2EOo.d.ts → statusline-5K7y4Ya2.d.ts} +5 -3
  30. package/dist/{statusline-DqzBqy42.d.cts → statusline-TYLQmoeh.d.cts} +5 -3
  31. package/dist/ui.cjs +208 -5
  32. package/dist/ui.d.cts +91 -2
  33. package/dist/ui.d.ts +91 -2
  34. package/dist/ui.js +3 -3
  35. package/dist/vela.global.js +1210 -383
  36. package/dist/vela.global.min.js +99 -48
  37. package/dist/widget.cjs +1771 -449
  38. package/dist/widget.d.cts +17 -6
  39. package/dist/widget.d.ts +17 -6
  40. package/dist/widget.js +7 -7
  41. package/dist/workspace.cjs +1771 -449
  42. package/dist/workspace.d.cts +100 -6
  43. package/dist/workspace.d.ts +100 -6
  44. package/dist/workspace.js +6 -6
  45. package/package.json +1 -1
package/dist/widget.cjs CHANGED
@@ -125,6 +125,23 @@ var TypedEventBus = class {
125
125
  }
126
126
  };
127
127
 
128
+ // src/data/symbol-groups.ts
129
+ function isGroupRow(d) {
130
+ return d.group != null && d.ticker === d.group;
131
+ }
132
+ function groupKeyOf(d) {
133
+ return `${(d.prefix ?? d.provider ?? "").toLowerCase()}:${(d.group ?? "").toUpperCase()}`;
134
+ }
135
+ function groupMembers(pool, groupRow) {
136
+ const key = groupKeyOf(groupRow);
137
+ return pool.filter((s) => s.group != null && !isGroupRow(s) && groupKeyOf(s) === key);
138
+ }
139
+ function defaultMemberOf(pool, groupRow) {
140
+ const members = groupMembers(pool, groupRow);
141
+ const defaults2 = members.filter((m) => m.default);
142
+ return (defaults2.length === 1 ? defaults2[0] : members[0]) ?? null;
143
+ }
144
+
128
145
  // src/data/ProviderRegistry.ts
129
146
  var normName = (n) => n.trim().toLowerCase();
130
147
  var normTicker = (t) => t.trim().toUpperCase();
@@ -214,17 +231,24 @@ var ProviderRegistry = class {
214
231
  resolve(raw, opts = {}) {
215
232
  const { provider, ticker } = this.parse(raw);
216
233
  if (provider) {
217
- if (this.entries.has(provider)) return { provider, ticker };
234
+ if (this.entries.has(provider)) {
235
+ const d = this.entries.get(provider).byTicker?.get(normTicker(ticker));
236
+ return { provider, ticker: d != null && isGroupRow(d) ? this.loadableTicker(provider, d) : ticker };
237
+ }
218
238
  const key = `${provider}:${normTicker(ticker)}`;
219
239
  for (const name of this.candidateOrder(opts.default)) {
220
240
  const d = this.entries.get(name).prefixIndex?.get(key);
221
- if (d) return { provider: name, ticker: d.ticker };
241
+ if (d) return { provider: name, ticker: this.loadableTicker(name, d) };
222
242
  }
223
243
  return null;
224
244
  }
225
245
  const norm = normTicker(ticker);
226
246
  for (const name of this.candidateOrder(opts.default)) {
227
- if (this.entries.get(name).index?.has(norm)) return { provider: name, ticker };
247
+ const e = this.entries.get(name);
248
+ if (e.index?.has(norm)) {
249
+ const d = e.byTicker?.get(norm);
250
+ return { provider: name, ticker: d != null && isGroupRow(d) ? this.loadableTicker(name, d) : ticker };
251
+ }
228
252
  }
229
253
  if (opts.lenient && this.entries.size === 1) {
230
254
  const only = this.names()[0];
@@ -233,6 +257,13 @@ var ProviderRegistry = class {
233
257
  }
234
258
  return null;
235
259
  }
260
+ /** What a resolved descriptor LOADS: itself — unless it is a GROUP row (listed, never
261
+ * served), which loads its default member (single `default`, else first listed). A
262
+ * memberless group keeps its own ticker and fails downstream like any unknown symbol. */
263
+ loadableTicker(name, d) {
264
+ if (!isGroupRow(d)) return d.ticker;
265
+ return defaultMemberOf(this.entries.get(name)?.descriptors ?? [], d)?.ticker ?? d.ticker;
266
+ }
236
267
  /**
237
268
  * Resolve now if possible, else resolve later — re-attempting after each provider
238
269
  * settles. The promise stays pending until some registered provider can serve the
@@ -486,6 +517,28 @@ var CachingDataFeed = class {
486
517
  this.store.merge(key, dropForming(bars));
487
518
  return bars;
488
519
  }
520
+ /**
521
+ * Progressive twin of {@link load}: a COLD load streams through the inner feed's
522
+ * progressive path — batches forwarded verbatim, the FINAL answer cached exactly as
523
+ * `load` caches — while a cache-covered load answers once through `load` itself (a
524
+ * warm chart has nothing to stream). Falls back to `load` wholesale when the inner
525
+ * feed lacks the capability, so callers may prefer this method unconditionally.
526
+ */
527
+ async loadProgressive(cfg, onBatch, opts) {
528
+ if (cfg.data && cfg.data.length > 0) return this.inner.load(cfg);
529
+ if (!this.inner.loadProgressive) return null;
530
+ const symbol = cfg.symbol ?? "TEST";
531
+ const key = cacheKey(symbol, cfg.timeframe ?? "60", cfg.session);
532
+ const cached = this.store.get(key);
533
+ const n = cfg.bars ?? 500;
534
+ const lastCached = cached?.[cached.length - 1];
535
+ if (cached && lastCached && cached.length >= n - 1 && this.inner.loadRange) return this.load(cfg);
536
+ this.store.retainSymbol(symbol);
537
+ const bars = await this.inner.loadProgressive(cfg, onBatch, opts);
538
+ if (bars == null) return null;
539
+ this.store.merge(key, dropForming(bars));
540
+ return bars;
541
+ }
489
542
  /**
490
543
  * Cache-backed ranged fetch — the gateway engines use for secondary series
491
544
  * (`request.security` HTF/LTF/cross-symbol) and the orchestrator's backward
@@ -731,6 +784,17 @@ var MultiProviderFeed = class {
731
784
  this.prefetchSymbolInfo(resolved);
732
785
  return this.cache.load(canonical(cfg, resolved));
733
786
  }
787
+ /** Progressive twin of {@link load} — same resolution, the cache streams the batches. */
788
+ async loadProgressive(cfg, onBatch, opts) {
789
+ if (cfg.data && cfg.data.length > 0) {
790
+ this.liveBars = cfg.data.map((b) => ({ ...b }));
791
+ return cfg.data;
792
+ }
793
+ const resolved = await this.registry.whenResolvable(rawSymbol(cfg), { default: this.primaryProvider });
794
+ this.primaryProvider = resolved.provider;
795
+ this.prefetchSymbolInfo(resolved);
796
+ return this.cache.loadProgressive(canonical(cfg, resolved), onBatch, opts);
797
+ }
734
798
  /**
735
799
  * Synchronous per-symbol metadata for engines (Pine `syminfo.*`), served from the cache
736
800
  * warmed by load(). Undefined until the prefetch lands (the engine then synthesizes a
@@ -818,6 +882,18 @@ var RegistryFetchFeed = class {
818
882
  if (!provider) return Promise.resolve([]);
819
883
  return safeBars(provider, ticker, cfg.timeframe ?? "60", { limit: cfg.bars ?? 500, session: cfg.session });
820
884
  }
885
+ async loadProgressive(cfg, onBatch, opts) {
886
+ const { provider: name, ticker } = parseSymbol(cfg.symbol ?? "");
887
+ const provider = this.registry.get(name ?? "");
888
+ if (!provider) return [];
889
+ if (!provider.getBarsProgressive) return null;
890
+ try {
891
+ return await provider.getBarsProgressive(ticker, cfg.timeframe ?? "60", { limit: cfg.bars ?? 500, session: cfg.session }, onBatch, opts);
892
+ } catch (e) {
893
+ console.warn(`[vela] progressive fetch failed for ${ticker} ${cfg.timeframe ?? "60"} \u2014 ${e instanceof Error ? e.message : String(e)}`);
894
+ return [];
895
+ }
896
+ }
821
897
  loadRange(cfg, range) {
822
898
  const { provider: name, ticker } = parseSymbol(cfg.symbol ?? "");
823
899
  const provider = this.registry.get(name ?? "");
@@ -1097,11 +1173,11 @@ registerIcon(
1097
1173
  );
1098
1174
  registerIcon(
1099
1175
  "market-pre",
1100
- svg24('<path d="M12 2v8"/><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 6-4 4-4-4"/><path d="M16 18a4 4 0 0 0-8 0"/>')
1176
+ svg24('<path d="M12 2v8"/><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="m8 6 4-4 4 4"/><path d="M16 18a4 4 0 0 0-8 0"/>')
1101
1177
  );
1102
1178
  registerIcon(
1103
1179
  "market-post",
1104
- 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 18-4-4-4 4"/><path d="M16 6a4 4 0 0 0-8 0"/>')
1180
+ 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"/>')
1105
1181
  );
1106
1182
  registerIcon("market-closed", svg24('<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>'));
1107
1183
  registerIcon(
@@ -1134,6 +1210,8 @@ registerIcon("folder-plus", S('<path d="M1.6 4.4a1 1 0 0 1 1-1h2.7l1.3 1.7h6.8a1
1134
1210
  registerIcon("folder-minus", S('<path d="M1.6 4.4a1 1 0 0 1 1-1h2.7l1.3 1.7h6.8a1 1 0 0 1 1 1v6.5a1 1 0 0 1-1 1h-10.8a1 1 0 0 1-1-1z"/><path d="M6.2 9.4h3.6"/>'));
1135
1211
  registerIcon("collapse", S('<path d="M3 8h10"/>'));
1136
1212
  registerIcon("expand", S('<rect x="2.2" y="2.2" width="11.6" height="11.6" rx="1.4"/><path d="M8 5.4v5.2M5.4 8h5.2"/>'));
1213
+ registerIcon("plus", S('<path d="M8 2.8v10.4M2.8 8h10.4"/>'));
1214
+ registerIcon("minus", S('<path d="M2.8 8h10.4"/>'));
1137
1215
  registerIcon("maximize", S('<path d="M2.5 6V3a.5.5 0 0 1 .5-.5h3M10 2.5h3a.5.5 0 0 1 .5.5v3M13.5 10v3a.5.5 0 0 1-.5.5h-3M6 13.5H3a.5.5 0 0 1-.5-.5v-3"/>'));
1138
1216
  registerIcon("restore", S('<path d="M6.2 2.5v3.7H2.5M9.8 13.5V9.8h3.7M13.5 6.2H9.8V2.5M2.5 9.8h3.7v3.7"/>'));
1139
1217
  registerIcon("star", S('<path d="M8 2.2l1.75 3.55 3.9.55-2.8 2.75.65 3.9L8 11.1l-3.5 1.85.65-3.9-2.8-2.75 3.9-.55z"/>'));
@@ -1141,7 +1219,7 @@ registerIcon("star-filled", S('<path d="M8 2.2l1.75 3.55 3.9.55-2.8 2.75.65 3.9L
1141
1219
  registerIcon("grip", S('<circle cx="6" cy="3.5" r="1"/><circle cx="10" cy="3.5" r="1"/><circle cx="6" cy="8" r="1"/><circle cx="10" cy="8" r="1"/><circle cx="6" cy="12.5" r="1"/><circle cx="10" cy="12.5" r="1"/>', 'fill="currentColor" stroke="none"'));
1142
1220
  registerIcon("kebab", S('<circle cx="8" cy="3.2" r="1.2"/><circle cx="8" cy="8" r="1.2"/><circle cx="8" cy="12.8" r="1.2"/>', 'fill="currentColor" stroke="none"'));
1143
1221
  registerIcon("burger", S('<path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/>'));
1144
- registerIcon("reset", S('<rect x="6" y="6" width="4" height="4" rx="0.8"/><path d="M2.2 8a5.8 5.8 0 1 0 1.9-4.3L2.2 5.3"/><path d="M2.2 2.2v3.1h3.1"/>'));
1222
+ registerIcon("reset", S('<path d="M2 8a6 6 0 1 0 6-6 6.5 6.5 0 0 0-4.5 1.83L2 5.33"/><path d="M2 2v3.33h3.33"/>'));
1145
1223
  registerIcon("pane-collapse", S('<path d="M2.6 9.4h4v4M13.4 6.6h-4v-4"/><path d="m9.4 6.6 4.2-4.2M2.4 13.6l4.2-4.2"/>'));
1146
1224
  registerIcon("pane-expand", S('<path d="M9.8 2.4h3.8v3.8M6.2 13.6H2.4V9.8"/><path d="m13.6 2.4-4.4 4.4M2.4 13.6l4.4-4.4"/>'));
1147
1225
  registerIcon("cursor", svg24('<path d="M5 3l6 16 2-6 6-2z"/>', 'fill="currentColor" stroke="none"'));
@@ -4195,6 +4273,196 @@ function buildFieldControl(desc) {
4195
4273
  return { el };
4196
4274
  }
4197
4275
 
4276
+ // src/ui/components/callout-bubble/controller.ts
4277
+ function closesPanel(item) {
4278
+ return item.close !== false;
4279
+ }
4280
+ function calloutPanelRows(items) {
4281
+ const rows = [];
4282
+ for (const item of items) {
4283
+ if (item.type === "text") {
4284
+ rows.push({ type: "text", text: item.text });
4285
+ continue;
4286
+ }
4287
+ const last = rows[rows.length - 1];
4288
+ if (last && last.type === "buttons") last.buttons.push(item);
4289
+ else rows.push({ type: "buttons", buttons: [item] });
4290
+ }
4291
+ return rows;
4292
+ }
4293
+
4294
+ // src/ui/components/callout-bubble/styles.ts
4295
+ var CALLOUT_STYLE_ID = "vela-ui-callout-bubble";
4296
+ var CALLOUT_CSS = `
4297
+ .vela-callout {
4298
+ display: inline-grid;
4299
+ place-items: center;
4300
+ border-radius: 50%;
4301
+ flex: none;
4302
+ line-height: 0;
4303
+ box-sizing: border-box;
4304
+ cursor: default;
4305
+ user-select: none;
4306
+ -webkit-user-select: none;
4307
+ }
4308
+ .vela-callout[role='button'] { cursor: pointer; }
4309
+ .vela-callout svg { display: block; }
4310
+ /* The deployed panel \u2014 carries the kit's elevated-card look itself (the popover
4311
+ shell is bare positioning chrome). */
4312
+ .vela-callout-panel {
4313
+ display: flex;
4314
+ flex-direction: column;
4315
+ gap: 8px;
4316
+ padding: 10px 12px;
4317
+ max-width: 280px;
4318
+ box-sizing: border-box;
4319
+ background: var(--vela-surface-elev);
4320
+ border: 1px solid var(--vela-border-strong);
4321
+ border-radius: 6px;
4322
+ box-shadow: var(--vela-shadow);
4323
+ color: var(--vela-fg);
4324
+ font: var(--vela-font-size-md) var(--vela-font);
4325
+ }
4326
+ .vela-callout-title { font-weight: 600; color: var(--vela-fg-bright); }
4327
+ .vela-callout-text { color: var(--vela-fg-muted); line-height: 1.45; white-space: pre-line; }
4328
+ .vela-callout-actions { display: flex; flex-wrap: wrap; gap: 8px; }
4329
+ .vela-callout-btn {
4330
+ cursor: pointer;
4331
+ height: 26px;
4332
+ padding: 0 10px;
4333
+ border-radius: 5px;
4334
+ border: 1px solid var(--vela-border);
4335
+ background: transparent;
4336
+ color: var(--vela-fg);
4337
+ font-size: var(--vela-font-size-md);
4338
+ font-family: inherit;
4339
+ transition: background var(--vela-dur-fast) ease, color var(--vela-dur-fast) ease, opacity var(--vela-dur-fast) ease, border-color var(--vela-dur-fast) ease;
4340
+ }
4341
+ .vela-callout-btn:hover { background: var(--vela-hover); color: var(--vela-fg-bright); border-color: var(--vela-fg-muted); }
4342
+ .vela-callout-btn-primary { border-color: var(--vela-selected-bg); background: var(--vela-selected-bg); color: var(--vela-selected-fg); }
4343
+ .vela-callout-btn-primary:hover { background: var(--vela-selected-bg); color: var(--vela-selected-fg); opacity: 0.85; border-color: var(--vela-selected-bg); }
4344
+ `;
4345
+
4346
+ // src/ui/components/callout-bubble/view.ts
4347
+ var CalloutBubble = class {
4348
+ constructor(opts) {
4349
+ this.pop = null;
4350
+ this.spec = { icon: opts.icon, background: opts.background, label: opts.label };
4351
+ if (opts.color !== void 0) this.spec.color = opts.color;
4352
+ if (opts.panel !== void 0) this.spec.panel = opts.panel;
4353
+ this.size = opts.size ?? 16;
4354
+ this.host = opts.host;
4355
+ this.theme = opts.theme;
4356
+ this.boundary = opts.boundary;
4357
+ const doc = (opts.host ?? document.body).ownerDocument;
4358
+ injectStyles(CALLOUT_STYLE_ID, CALLOUT_CSS, doc);
4359
+ this.el = doc.createElement("span");
4360
+ this.el.className = "vela-callout";
4361
+ this.el.style.width = `${this.size}px`;
4362
+ this.el.style.height = `${this.size}px`;
4363
+ this.el.addEventListener("click", (e) => {
4364
+ if (!this.spec.panel) return;
4365
+ e.stopPropagation();
4366
+ this.toggle();
4367
+ });
4368
+ this.el.addEventListener("dblclick", (e) => {
4369
+ if (this.spec.panel) e.stopPropagation();
4370
+ });
4371
+ this.el.addEventListener("keydown", (e) => {
4372
+ if (!this.spec.panel || e.key !== "Enter" && e.key !== " ") return;
4373
+ e.preventDefault();
4374
+ this.toggle();
4375
+ });
4376
+ this.dress();
4377
+ }
4378
+ /** Re-dress the bubble (a status change: new icon, tint, label, panel). */
4379
+ set(spec) {
4380
+ this.spec = { ...this.spec, ...spec };
4381
+ if ("panel" in spec) this.pop?.hide();
4382
+ this.dress();
4383
+ }
4384
+ /** Whether the deployed panel is currently open. */
4385
+ get open() {
4386
+ return this.pop?.open ?? false;
4387
+ }
4388
+ /** Close the deployed panel, if any. The bubble itself stays. */
4389
+ hidePanel() {
4390
+ this.pop?.hide();
4391
+ }
4392
+ destroy() {
4393
+ this.pop?.destroy();
4394
+ this.pop = null;
4395
+ this.el.remove();
4396
+ }
4397
+ dress() {
4398
+ const clickable = this.spec.panel !== void 0;
4399
+ this.el.style.background = this.spec.background;
4400
+ this.el.style.color = this.spec.color ?? "";
4401
+ this.el.innerHTML = iconAt(this.spec.icon, this.size - 4);
4402
+ this.el.setAttribute("aria-label", this.spec.label);
4403
+ if (clickable) {
4404
+ this.el.setAttribute("role", "button");
4405
+ this.el.tabIndex = 0;
4406
+ } else {
4407
+ this.el.removeAttribute("role");
4408
+ this.el.removeAttribute("tabindex");
4409
+ }
4410
+ }
4411
+ toggle() {
4412
+ if (this.pop?.open) {
4413
+ this.pop.hide();
4414
+ return;
4415
+ }
4416
+ const panel = this.spec.panel;
4417
+ if (!panel) return;
4418
+ this.pop?.destroy();
4419
+ this.pop = new Popover({
4420
+ trigger: this.el,
4421
+ gap: 6,
4422
+ content: (body) => this.buildPanel(body, panel),
4423
+ ...this.host ? { host: this.host } : {},
4424
+ ...this.theme ? { theme: this.theme() } : {},
4425
+ ...this.boundary ? { boundary: this.boundary } : {}
4426
+ });
4427
+ this.pop.show();
4428
+ }
4429
+ buildPanel(body, panel) {
4430
+ const doc = body.ownerDocument;
4431
+ const root = doc.createElement("div");
4432
+ root.className = "vela-callout-panel";
4433
+ if (panel.title) {
4434
+ const title = doc.createElement("div");
4435
+ title.className = "vela-callout-title";
4436
+ title.textContent = panel.title;
4437
+ root.appendChild(title);
4438
+ }
4439
+ for (const row of calloutPanelRows(panel.items)) {
4440
+ if (row.type === "text") {
4441
+ const text = doc.createElement("div");
4442
+ text.className = "vela-callout-text";
4443
+ text.textContent = row.text;
4444
+ root.appendChild(text);
4445
+ continue;
4446
+ }
4447
+ const actions = doc.createElement("div");
4448
+ actions.className = "vela-callout-actions";
4449
+ for (const item of row.buttons) {
4450
+ const btn2 = doc.createElement("button");
4451
+ btn2.type = "button";
4452
+ btn2.className = "vela-callout-btn" + (item.primary ? " vela-callout-btn-primary" : "");
4453
+ btn2.textContent = item.label;
4454
+ btn2.addEventListener("click", () => {
4455
+ item.run();
4456
+ if (closesPanel(item)) this.pop?.hide();
4457
+ });
4458
+ actions.appendChild(btn2);
4459
+ }
4460
+ root.appendChild(actions);
4461
+ }
4462
+ body.appendChild(root);
4463
+ }
4464
+ };
4465
+
4198
4466
  // src/widget/layout-picker.ts
4199
4467
  var STYLE_ID = "vela-widget-layout-picker-v14";
4200
4468
  var CSS2 = `
@@ -4677,6 +4945,44 @@ function legendActionsProviderFor(chart, context) {
4677
4945
  return legendActions().filter((d) => !d.when || d.when(info)).map((d) => ({ id: d.id, icon: d.icon, tooltip: d.tooltip, run: () => d.run(context(), info) }));
4678
4946
  };
4679
4947
  }
4948
+ var calloutRegistry = /* @__PURE__ */ new Map();
4949
+ function legendCallouts() {
4950
+ return [...calloutRegistry.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
4951
+ }
4952
+ function legendCalloutsProviderFor(chart, context) {
4953
+ return (indicatorId) => {
4954
+ const handle = chart.indicators().find((h) => h.id === indicatorId);
4955
+ if (!handle) return [];
4956
+ const info = { id: handle.id, title: handle.title, ...handle.source !== void 0 ? { source: handle.source } : {} };
4957
+ const views = [];
4958
+ for (const d of legendCallouts()) {
4959
+ const spec = d.callout(info);
4960
+ if (!spec) continue;
4961
+ views.push({
4962
+ id: d.id,
4963
+ icon: spec.icon,
4964
+ background: spec.background,
4965
+ ...spec.color !== void 0 ? { color: spec.color } : {},
4966
+ tooltip: spec.tooltip,
4967
+ ...spec.content !== void 0 ? {
4968
+ content: {
4969
+ ...spec.content.title !== void 0 ? { title: spec.content.title } : {},
4970
+ items: spec.content.items.map(
4971
+ (item) => item.type === "text" ? item : {
4972
+ type: "button",
4973
+ label: item.label,
4974
+ ...item.primary !== void 0 ? { primary: item.primary } : {},
4975
+ ...item.close !== void 0 ? { close: item.close } : {},
4976
+ run: () => item.run(context(), info)
4977
+ }
4978
+ )
4979
+ }
4980
+ } : {}
4981
+ });
4982
+ }
4983
+ return views;
4984
+ };
4985
+ }
4680
4986
  var stateHandlers = /* @__PURE__ */ new Map();
4681
4987
  function registerStatePersistence(handler) {
4682
4988
  stateHandlers.set(handler.key, handler);
@@ -13516,6 +13822,17 @@ function dedupeSymbols(list) {
13516
13822
  }
13517
13823
  return out;
13518
13824
  }
13825
+ function foldGroups(list) {
13826
+ const groupsAbove = /* @__PURE__ */ new Set();
13827
+ const out = [];
13828
+ for (const s of list) {
13829
+ if (isGroupRow(s)) {
13830
+ groupsAbove.add(groupKeyOf(s));
13831
+ out.push(s);
13832
+ } else if (s.group == null || !groupsAbove.has(groupKeyOf(s))) out.push(s);
13833
+ }
13834
+ return out;
13835
+ }
13519
13836
  var TOP_TICKERS = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT", "DOGEUSDT", "ADAUSDT", "LINKUSDT"];
13520
13837
  function parseQuery(raw, venues) {
13521
13838
  const m = raw.match(/^\s*([^\s:]+)\s*[:\s]\s*(.*)$/);
@@ -13647,6 +13964,21 @@ var CSS8 = `
13647
13964
  .vela-sp-badge[data-p='binance'] { color: #f0b90b; } /* palette-exempt: venue brand mark */
13648
13965
  .vela-sp-badge[data-p='hyperliquid'] { color: #50d2c1; } /* palette-exempt: venue brand mark */
13649
13966
  .vela-sp-empty { padding: var(--vela-space-3); color: var(--vela-fg-muted); text-align: center; }
13967
+ /* Grouped listings (futures roots): the chevron unfolds members inline, indented. */
13968
+ .vela-sp-expander {
13969
+ all: unset;
13970
+ flex: none;
13971
+ display: inline-flex;
13972
+ align-items: center;
13973
+ justify-content: center;
13974
+ width: 22px;
13975
+ height: 22px;
13976
+ border-radius: 5px;
13977
+ cursor: pointer;
13978
+ color: var(--vela-fg-muted);
13979
+ }
13980
+ .vela-sp-expander:hover { background: var(--vela-surface-elev); color: var(--vela-fg); }
13981
+ .vela-sp-row[data-member] { padding-left: 34px; }
13650
13982
  `;
13651
13983
  var PAGE = 100;
13652
13984
  var SymbolPicker = class {
@@ -13658,6 +13990,11 @@ var SymbolPicker = class {
13658
13990
  this.seed = "";
13659
13991
  this.activeTab = "All";
13660
13992
  this.visible = PAGE;
13993
+ /** The last filter pass returned fewer raw rows than asked — the pool is drained
13994
+ * (checked BEFORE folding: folding shortens pages without meaning exhaustion). */
13995
+ this.exhausted = false;
13996
+ /** Group rows currently expanded (venue-scoped keys) — members shown inline. */
13997
+ this.expanded = /* @__PURE__ */ new Set();
13661
13998
  /** The ranked pool cache — `key` fingerprints the raw pool the ranking ran on. */
13662
13999
  this.ranked = null;
13663
14000
  this.ranking = false;
@@ -13672,7 +14009,7 @@ var SymbolPicker = class {
13672
14009
  searchRow.append(iconEl("search", doc), this.input);
13673
14010
  this.tabs = doc.createElement("div");
13674
14011
  this.tabs.className = "vela-sp-tabs";
13675
- for (const t of ["All", "Stocks", "ETFs", "Crypto", "Forex", "Commodities"]) {
14012
+ for (const t of ["All", "Stocks", "ETFs", "Crypto", "Futures", "Forex", "Commodities"]) {
13676
14013
  const b = doc.createElement("button");
13677
14014
  b.className = "vela-sp-tab";
13678
14015
  b.textContent = t;
@@ -13688,7 +14025,7 @@ var SymbolPicker = class {
13688
14025
  this.list = doc.createElement("div");
13689
14026
  this.list.className = "vela-sp-list";
13690
14027
  this.list.addEventListener("scroll", () => {
13691
- if (this.rows.length < this.visible) return;
14028
+ if (this.exhausted) return;
13692
14029
  if (this.list.scrollTop + this.list.clientHeight < this.list.scrollHeight - 200) return;
13693
14030
  this.visible += PAGE;
13694
14031
  this.grow();
@@ -13717,14 +14054,19 @@ var SymbolPicker = class {
13717
14054
  else if (e.key === "ArrowUp") this.moveHighlight(-1);
13718
14055
  else if (e.key === "Enter") {
13719
14056
  const pick = this.rows[this.highlighted];
13720
- if (pick) this.select(pick.ticker, pick.prefix ?? pick.provider, opts.onSelect);
14057
+ if (pick) this.pick(pick);
13721
14058
  return;
13722
14059
  } else return;
13723
14060
  e.preventDefault();
13724
14061
  });
13725
14062
  this.list.addEventListener("click", (e) => {
13726
- const row = e.target.closest(".vela-sp-row");
13727
- if (row?.dataset.ticker) this.select(row.dataset.ticker, row.dataset.venue, opts.onSelect);
14063
+ const target = e.target;
14064
+ const row = target.closest(".vela-sp-row");
14065
+ if (!row) return;
14066
+ const s = this.rows[Number(row.dataset.i)];
14067
+ if (!s) return;
14068
+ if (target.closest(".vela-sp-expander")) this.toggleExpand(s);
14069
+ else this.pick(s);
13728
14070
  });
13729
14071
  }
13730
14072
  /** Wire where symbols come from (re-called on every widget rebuild). */
@@ -13741,6 +14083,27 @@ var SymbolPicker = class {
13741
14083
  destroy() {
13742
14084
  this.dialog.destroy();
13743
14085
  }
14086
+ /** Route a row activation: a GROUP row loads its default member (the root itself is
14087
+ * listed, never loadable), any other row loads itself. */
14088
+ pick(s) {
14089
+ const target = isGroupRow(s) ? defaultMemberOf(this.pool(), s) ?? s : s;
14090
+ this.select(target.ticker, target.prefix ?? target.provider, this.opts.onSelect);
14091
+ }
14092
+ /** Expand/collapse a group row IN PLACE — same query, same page, same scroll; only
14093
+ * the member rows under the group appear or go. */
14094
+ toggleExpand(s) {
14095
+ const key = groupKeyOf(s);
14096
+ if (!this.expanded.delete(key)) this.expanded.add(key);
14097
+ const scrollTop = this.list.scrollTop;
14098
+ const focus = this.rows[this.highlighted];
14099
+ this.rows = this.computeRows();
14100
+ this.list.replaceChildren();
14101
+ this.rows.forEach((r, i) => this.list.appendChild(this.rowEl(r, i)));
14102
+ const at = focus ? this.rows.indexOf(focus) : -1;
14103
+ this.highlighted = at >= 0 ? at : Math.min(this.highlighted, Math.max(0, this.rows.length - 1));
14104
+ this.renderHighlight();
14105
+ this.list.scrollTop = scrollTop;
14106
+ }
13744
14107
  select(ticker, venue, onSelect) {
13745
14108
  this.close();
13746
14109
  onSelect(venue ? `${venue}:${ticker}` : ticker);
@@ -13782,10 +14145,26 @@ var SymbolPicker = class {
13782
14145
  return this.ranked?.result ?? raw;
13783
14146
  }
13784
14147
  computeRows() {
13785
- const TAB_TYPES = { Crypto: ["crypto"], Stocks: ["stock"], ETFs: ["etf"], Forex: ["forex"], Commodities: ["commodity"] };
14148
+ const TAB_TYPES = { Crypto: ["crypto"], Stocks: ["stock"], ETFs: ["etf"], Futures: ["futures", "root"], Forex: ["forex"], Commodities: ["commodity"] };
13786
14149
  const all = this.pool();
13787
14150
  const pool = this.activeTab === "All" ? all : all.filter((s) => TAB_TYPES[this.activeTab]?.includes((s.type ?? "").toLowerCase()) || this.activeTab === "Crypto" && (s.type ?? "").toLowerCase() === "futures");
13788
- return filterSymbols(pool, this.input.value, this.visible, symbolRanking() ? false : TOP_TICKERS);
14151
+ const filtered = filterSymbols(pool, this.input.value, this.visible, symbolRanking() ? false : TOP_TICKERS);
14152
+ this.exhausted = filtered.length < this.visible;
14153
+ const folded = foldGroups(filtered);
14154
+ if (!this.expanded.size) return folded;
14155
+ const keyOf = (s) => `${(s.prefix ?? s.provider ?? "").toLowerCase()}:${s.ticker.toUpperCase()}`;
14156
+ const present = new Set(folded.map(keyOf));
14157
+ const out = [];
14158
+ for (const s of folded) {
14159
+ out.push(s);
14160
+ if (!isGroupRow(s) || !this.expanded.has(groupKeyOf(s))) continue;
14161
+ for (const m of groupMembers(all, s)) {
14162
+ if (present.has(keyOf(m))) continue;
14163
+ present.add(keyOf(m));
14164
+ out.push(m);
14165
+ }
14166
+ }
14167
+ return out;
13789
14168
  }
13790
14169
  refresh() {
13791
14170
  const doc = this.list.ownerDocument;
@@ -13800,19 +14179,20 @@ var SymbolPicker = class {
13800
14179
  this.list.appendChild(empty);
13801
14180
  return;
13802
14181
  }
13803
- for (const s of this.rows) this.list.appendChild(this.rowEl(s));
14182
+ this.rows.forEach((s, i) => this.list.appendChild(this.rowEl(s, i)));
13804
14183
  this.renderHighlight();
13805
14184
  }
13806
14185
  /** Append the page the grown `visible` just uncovered — rows already on screen stay put. */
13807
14186
  grow() {
13808
14187
  const already = this.rows.length;
13809
14188
  this.rows = this.computeRows();
13810
- for (const s of this.rows.slice(already)) this.list.appendChild(this.rowEl(s));
14189
+ this.rows.slice(already).forEach((s, j) => this.list.appendChild(this.rowEl(s, already + j)));
13811
14190
  }
13812
- rowEl(s) {
14191
+ rowEl(s, i) {
13813
14192
  const doc = this.list.ownerDocument;
13814
14193
  const row = doc.createElement("div");
13815
14194
  row.className = "vela-sp-row";
14195
+ row.dataset.i = String(i);
13816
14196
  row.dataset.ticker = s.ticker;
13817
14197
  const venue = s.prefix ?? s.provider;
13818
14198
  if (venue) row.dataset.venue = venue;
@@ -13827,6 +14207,14 @@ var SymbolPicker = class {
13827
14207
  d.textContent = s.description ?? (s.type ?? "");
13828
14208
  main.append(t, d);
13829
14209
  row.append(av, main);
14210
+ if (isGroupRow(s)) {
14211
+ row.dataset.group = "1";
14212
+ const expander = doc.createElement("button");
14213
+ expander.className = "vela-sp-expander";
14214
+ expander.setAttribute("aria-label", "Show contracts");
14215
+ expander.appendChild(iconEl(this.expanded.has(groupKeyOf(s)) ? "chevron-down" : "chevron-right", doc));
14216
+ row.appendChild(expander);
14217
+ } else if (s.group != null && this.expanded.has(groupKeyOf(s))) row.dataset.member = "1";
13830
14218
  if (venue) {
13831
14219
  const badge = doc.createElement("span");
13832
14220
  badge.className = "vela-sp-badge";
@@ -14443,6 +14831,9 @@ var CSS13 = `
14443
14831
  }
14444
14832
  .vela-mb-item:active { background: var(--vela-hover); }
14445
14833
  .vela-mb-item .vela-icon { font-size: 18px; width: 18px; height: 18px; }
14834
+ /* A lit stop (the maximize toggle while something is isolated): the inverse
14835
+ "selected" chip \u2014 white on the dark theme, dark on the light one. */
14836
+ .vela-mb-item.vela-mb-on, .vela-mb-item.vela-mb-on:active { background: var(--vela-selected-bg); color: var(--vela-selected-fg); }
14446
14837
  /* Left-aligned contributed actions get their own stops (the built-in indicators
14447
14838
  slot) \u2014 the wrapper is layout-transparent so each stop flexes like a sibling. */
14448
14839
  .vela-mb-actions { display: contents; }
@@ -14483,9 +14874,11 @@ var MobileBar = class {
14483
14874
  this.actionsHost.className = "vela-mb-actions";
14484
14875
  const onDrawings = opts.onDrawingsClick;
14485
14876
  const drawings = onDrawings ? item("vela-mb-drawings", "Drawings", onDrawings, "pen") : null;
14877
+ const onMaximize = opts.onMaximizeClick;
14878
+ this.maxEl = onMaximize ? item("vela-mb-maximize", "Maximize chart", onMaximize, "maximize") : null;
14486
14879
  const more = item("vela-mb-more", "More", opts.onMoreClick, "kebab");
14487
14880
  const settings = item("vela-mb-settings", "Chart settings", opts.onSettingsClick, "gear");
14488
- this.el.append(this.symbolEl, this.tfEl, ...indicators ? [indicators] : [], this.actionsHost, ...drawings ? [drawings] : [], more, settings);
14881
+ this.el.append(this.symbolEl, this.tfEl, ...indicators ? [indicators] : [], this.actionsHost, ...drawings ? [drawings] : [], ...this.maxEl ? [this.maxEl] : [], more, settings);
14489
14882
  host.appendChild(this.el);
14490
14883
  this.renderActions();
14491
14884
  }
@@ -14517,6 +14910,14 @@ var MobileBar = class {
14517
14910
  setTimeframe(tf) {
14518
14911
  this.tfEl.textContent = timeframeLabel(tf);
14519
14912
  }
14913
+ /** Light the maximize stop while something is isolated (a chart over the grid,
14914
+ * or a maximized pane inside the active chart) — inverse chip + restore glyph. */
14915
+ setMaximizeActive(on) {
14916
+ if (!this.maxEl) return;
14917
+ this.maxEl.classList.toggle("vela-mb-on", on);
14918
+ this.maxEl.setAttribute("aria-label", on ? "Restore layout" : "Maximize chart");
14919
+ this.maxEl.replaceChildren(iconEl(on ? "restore" : "maximize", this.el.ownerDocument));
14920
+ }
14520
14921
  destroy() {
14521
14922
  this.el.remove();
14522
14923
  }
@@ -16694,7 +17095,7 @@ var DrawingToolbar = class {
16694
17095
  this.root.replaceChildren();
16695
17096
  this.groupCells.clear();
16696
17097
  this.groupIcons.clear();
16697
- this.cursorBtn = this.makeButton(CURSOR_ICON, "Cursor", () => this.onArm(null));
17098
+ this.cursorBtn = this.makeButton(CURSOR_ICON, "Cursor", () => this.onCursorClick());
16698
17099
  this.root.appendChild(this.cursorBtn);
16699
17100
  if (this.def.groups.length > 0) this.root.appendChild(this.divider());
16700
17101
  for (const g of this.def.groups) {
@@ -16798,6 +17199,14 @@ var DrawingToolbar = class {
16798
17199
  this.magnetIcon = icon2;
16799
17200
  return cell;
16800
17201
  }
17202
+ /** Cursor returns to select/idle: an active measure/eraser mode exits through its own
17203
+ * toggle callback (disarming a tool via `onArm(null)` alone can't — the host treats a
17204
+ * null arm as a no-op side effect of entering those modes), then the tool disarms. */
17205
+ onCursorClick() {
17206
+ if (this.measureActive) this.onMeasure();
17207
+ if (this.eraserActive) this.onEraser();
17208
+ this.onArm(null);
17209
+ }
16801
17210
  /** Clicking the icon arms the group's last-used tool (it does NOT open the flyout). */
16802
17211
  onGroupIconClick(group) {
16803
17212
  const type = this.lastUsed.get(group.id) ?? group.tools[0]?.type;
@@ -17578,6 +17987,7 @@ var IndicatorHandleImpl = class {
17578
17987
  constructor(id, title, controller, source) {
17579
17988
  this.controller = controller;
17580
17989
  this.schema = [];
17990
+ this.propsSchema = [];
17581
17991
  this.visibleState = true;
17582
17992
  this.bus = new TypedEventBus();
17583
17993
  this.id = id;
@@ -17587,6 +17997,9 @@ var IndicatorHandleImpl = class {
17587
17997
  get inputs() {
17588
17998
  return this.schema;
17589
17999
  }
18000
+ get props() {
18001
+ return this.propsSchema;
18002
+ }
17590
18003
  get visible() {
17591
18004
  return this.visibleState;
17592
18005
  }
@@ -17596,6 +18009,12 @@ var IndicatorHandleImpl = class {
17596
18009
  setInputs(values) {
17597
18010
  this.controller.applyInputs(this.id, values);
17598
18011
  }
18012
+ setProp(key, value) {
18013
+ this.controller.applyProps(this.id, { [key]: value });
18014
+ }
18015
+ setProps(values) {
18016
+ this.controller.applyProps(this.id, values);
18017
+ }
17599
18018
  setVisible(visible) {
17600
18019
  this.controller.setVisible(this.id, visible);
17601
18020
  }
@@ -17615,6 +18034,9 @@ var IndicatorHandleImpl = class {
17615
18034
  setSchema(schema) {
17616
18035
  this.schema = schema;
17617
18036
  }
18037
+ setPropsSchema(schema) {
18038
+ this.propsSchema = schema;
18039
+ }
17618
18040
  /** Sync the public `visible` getter when the orchestrator changes visibility (API or legend eye). */
17619
18041
  setVisibleState(visible) {
17620
18042
  this.visibleState = visible;
@@ -17628,6 +18050,8 @@ var IndicatorHandleImpl = class {
17628
18050
  function summarizeModel(model) {
17629
18051
  const series = {};
17630
18052
  for (const s of model.series) series[s.kind] = (series[s.kind] ?? 0) + 1;
18053
+ const countOverlay = (items) => items?.filter((i) => i.overlay === true).length ?? 0;
18054
+ const forcedOverlay = countOverlay(model.series) + countOverlay(model.fills) + countOverlay(model.backgrounds) + countOverlay(model.lines) + countOverlay(model.boxes) + countOverlay(model.labels) + countOverlay(model.polylines) + countOverlay(model.linefills) + countOverlay(model.tables);
17631
18055
  return {
17632
18056
  id: model.id,
17633
18057
  title: model.title,
@@ -17647,7 +18071,8 @@ function summarizeModel(model) {
17647
18071
  tables: model.tables?.length ?? 0,
17648
18072
  barColors: model.barColors?.length ?? 0,
17649
18073
  trades: model.trades?.length ?? 0,
17650
- inputs: model.inputs.length
18074
+ inputs: model.inputs.length,
18075
+ forcedOverlay
17651
18076
  };
17652
18077
  }
17653
18078
  function inspectModels(models) {
@@ -17668,7 +18093,8 @@ function inspectModels(models) {
17668
18093
  linefills: sum((s) => s.linefills),
17669
18094
  tables: sum((s) => s.tables),
17670
18095
  barColors: sum((s) => s.barColors),
17671
- trades: sum((s) => s.trades)
18096
+ trades: sum((s) => s.trades),
18097
+ forcedOverlay: sum((s) => s.forcedOverlay)
17672
18098
  }
17673
18099
  };
17674
18100
  }
@@ -17779,6 +18205,10 @@ var EngineOrchestrator = class _EngineOrchestrator {
17779
18205
  /** Invalidates detached async work (backfill loops, in-flight loads, gap heals):
17780
18206
  * bumped by init(), setMarket() and destroy(). */
17781
18207
  this.generation = 0;
18208
+ /** Aborts the in-flight PROGRESSIVE load's source polling on supersession — an
18209
+ * abandoned stream left polling to its own budget starves the browser's per-host
18210
+ * connection pool, and the NEXT symbol's very first fetch with it (measured). */
18211
+ this.progressiveAbort = null;
17782
18212
  /** Awaiters racing a superseded load (setMarket callers) — released on every bump so they never hang. */
17783
18213
  this.supersedeWaiters = [];
17784
18214
  /** `history:complete` fired for the CURRENT load. Each market load re-arms the cycle
@@ -17812,7 +18242,9 @@ var EngineOrchestrator = class _EngineOrchestrator {
17812
18242
  for (const engine of engines) this.registerEngine(engine.language, engine);
17813
18243
  this.defaultLanguage = config.defaultLanguage;
17814
18244
  this.renderer.mount(container, config.theme);
17815
- this.renderer.onInputChange((e) => this.applyInputs(e.indicatorId, { [e.key]: e.value }));
18245
+ this.renderer.onInputChange(
18246
+ (e) => e.kind === "prop" ? this.applyProps(e.indicatorId, { [e.key]: e.value }) : this.applyInputs(e.indicatorId, { [e.key]: e.value })
18247
+ );
17816
18248
  this.renderer.onRemoveIndicator((id) => this.removeIndicator(id));
17817
18249
  this.renderer.onToggleIndicatorVisible?.((id, visible) => this.setVisible(id, visible));
17818
18250
  this.paneActionUnsub = this.renderer.onPaneAction?.((a) => this.handlePaneAction(a)) ?? null;
@@ -17919,6 +18351,8 @@ var EngineOrchestrator = class _EngineOrchestrator {
17919
18351
  * superseded setMarket awaiters so their promises resolve instead of hanging. */
17920
18352
  bumpGeneration() {
17921
18353
  const gen = ++this.generation;
18354
+ this.progressiveAbort?.abort();
18355
+ this.progressiveAbort = null;
17922
18356
  for (const w of this.supersedeWaiters.splice(0)) w();
17923
18357
  return gen;
17924
18358
  }
@@ -17972,7 +18406,47 @@ var EngineOrchestrator = class _EngineOrchestrator {
17972
18406
  const requested = market.bars ?? 500;
17973
18407
  const initialRange = market.visibleRange;
17974
18408
  const deep = !market.data?.length && initialRange == null && requested > SINGLE_LOAD_BARS;
17975
- if (deep && this.feed.loadRange) {
18409
+ let progressiveServed = false;
18410
+ if (!market.data?.length && initialRange == null && this.feed.loadProgressive) {
18411
+ let painted = false;
18412
+ const paint = (bars, final) => {
18413
+ if (this.generation !== gen || !final && bars.length === 0) return;
18414
+ this.setBarSeries(bars, painted ? { preserveView: true } : void 0);
18415
+ if (!painted && bars.length > 0) {
18416
+ painted = true;
18417
+ if (opts.firstLoad) this.activateBarLayers();
18418
+ if (!final) this.historyState = "backfill";
18419
+ }
18420
+ };
18421
+ const abort = new AbortController();
18422
+ this.progressiveAbort = abort;
18423
+ progressiveServed = await new Promise((firstPaint) => {
18424
+ let signaled = false;
18425
+ const signal = (served) => {
18426
+ if (!signaled) {
18427
+ signaled = true;
18428
+ firstPaint(served);
18429
+ }
18430
+ };
18431
+ abort.signal.addEventListener("abort", () => signal(true), { once: true });
18432
+ this.feed.loadProgressive(market, (bars) => {
18433
+ paint(bars, false);
18434
+ if (painted) signal(true);
18435
+ }, { signal: abort.signal }).then((full) => {
18436
+ if (this.progressiveAbort === abort) this.progressiveAbort = null;
18437
+ if (full == null) return signal(false);
18438
+ if (this.generation !== gen) return signal(true);
18439
+ paint(full, true);
18440
+ this.completeHistory(full.length >= requested ? "depth" : "genesis");
18441
+ signal(true);
18442
+ }).catch(() => {
18443
+ if (this.progressiveAbort === abort) this.progressiveAbort = null;
18444
+ if (this.generation === gen) this.completeHistory("aborted");
18445
+ signal(true);
18446
+ });
18447
+ });
18448
+ }
18449
+ if (progressiveServed) ; else if (deep && this.feed.loadRange) {
17976
18450
  const head = await this.feed.load({ ...market, bars: Math.min(requested, CHUNK_BARS) });
17977
18451
  if (this.generation !== gen) return;
17978
18452
  this.setBarSeries(head);
@@ -18492,7 +18966,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
18492
18966
  const title = options.title ?? "Indicator";
18493
18967
  const handle = new IndicatorHandleImpl(id, title, this, source);
18494
18968
  this.handles.set(id, handle);
18495
- this.registry.add({ id, title, source, options, inputValues: { ...options.inputs ?? {} } });
18969
+ this.registry.add({ id, title, source, options, inputValues: { ...options.inputs ?? {} }, propValues: { ...options.props ?? {} } });
18496
18970
  void this.startIndicator(id, source, options, handle);
18497
18971
  return handle;
18498
18972
  }
@@ -18515,7 +18989,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
18515
18989
  return handle;
18516
18990
  }
18517
18991
  const inputValues = { ...descriptor.defaultInputs(), ...options.inputs ?? {} };
18518
- this.registry.add({ id, title, source: type, inputValues, native: { type, instance: descriptor.create(), descriptor } });
18992
+ this.registry.add({ id, title, source: type, inputValues, propValues: {}, native: { type, instance: descriptor.create(), descriptor } });
18519
18993
  handle.setSchema(descriptor.inputsSchema());
18520
18994
  void this.startNativeIndicator(id, handle);
18521
18995
  return handle;
@@ -18568,6 +19042,19 @@ var EngineOrchestrator = class _EngineOrchestrator {
18568
19042
  if (record.session) record.session.update(record.inputValues);
18569
19043
  else if (record.native && !record.hidden) record.native.instance.setInputs(record.inputValues);
18570
19044
  }
19045
+ /** IndicatorController: re-run an indicator with merged declaration-prop overrides.
19046
+ * Same lifecycle as {@link applyInputs} — a prop change replays the whole script.
19047
+ * Script indicators only: natives have no declaration props. */
19048
+ applyProps(id, values) {
19049
+ const record = this.registry.get(id);
19050
+ if (!record?.session) return;
19051
+ record.propValues = { ...record.propValues, ...values };
19052
+ if (record.renderHandle) this.renderer.setIndicatorInputs(record.renderHandle, record.inputValues, record.propValues);
19053
+ record.pendingStructural = true;
19054
+ if (!record.hidden) this.setLoading(record, true);
19055
+ record.pendingCause = "inputs";
19056
+ record.session.update(record.inputValues, record.propValues);
19057
+ }
18571
19058
  /** IndicatorController: tear down an indicator and (if now empty) its pane. */
18572
19059
  /** Live handles of every indicator on the chart (script + native), insertion order. */
18573
19060
  listIndicators() {
@@ -18716,6 +19203,10 @@ var EngineOrchestrator = class _EngineOrchestrator {
18716
19203
  for (const input of prepared.inputs) defaults2[input.key] = input.defval;
18717
19204
  record.inputValues = { ...defaults2, ...record.inputValues };
18718
19205
  handle.setSchema(prepared.inputs);
19206
+ const propDefaults = {};
19207
+ for (const prop of prepared.props ?? []) propDefaults[prop.key] = prop.defval;
19208
+ record.propValues = { ...propDefaults, ...record.propValues };
19209
+ handle.setPropsSchema(prepared.props ?? []);
18719
19210
  this.mountLoadingPlaceholder(id, record);
18720
19211
  this.executeIndicator(id, handle);
18721
19212
  } catch (err) {
@@ -18739,6 +19230,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
18739
19230
  getBars: () => this.bars,
18740
19231
  fetchSeries: (sym, tf, range) => this.fetchSeries(sym, tf, range),
18741
19232
  inputs: record.inputValues,
19233
+ props: record.propValues,
18742
19234
  visibleRange: this.currentVisibleRange(),
18743
19235
  mode,
18744
19236
  // Mid-backfill session starts (add / re-show / price-style re-execute) let the
@@ -18832,6 +19324,9 @@ var EngineOrchestrator = class _EngineOrchestrator {
18832
19324
  const meta = record.prepared.meta;
18833
19325
  const model = {
18834
19326
  id,
19327
+ // Deliberately the FULL title, never a shorttitle: while the script loads the
19328
+ // legend identifies it by its full name; the compact shorttitle arrives with
19329
+ // the first computed model and takes over from there.
18835
19330
  title: record.options?.title ?? meta.title,
18836
19331
  overlay: meta.overlay,
18837
19332
  paneHint: meta.overlay ? "price" : "new",
@@ -18840,7 +19335,8 @@ var EngineOrchestrator = class _EngineOrchestrator {
18840
19335
  backgrounds: [],
18841
19336
  priceLines: [],
18842
19337
  inputs: record.prepared.inputs,
18843
- inputValues: record.inputValues
19338
+ inputValues: record.inputValues,
19339
+ ...record.prepared.props ? { props: record.prepared.props, propValues: record.propValues } : {}
18844
19340
  };
18845
19341
  const paneId = this.routePane(id, model, record.options ?? {});
18846
19342
  this.placeModel(model, id, paneId);
@@ -19073,7 +19569,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
19073
19569
  record.model = model;
19074
19570
  if (record.pendingStructural) {
19075
19571
  record.renderHandle = this.renderer.mountIndicator(model);
19076
- this.renderer.setIndicatorInputs(record.renderHandle, record.inputValues);
19572
+ this.renderer.setIndicatorInputs(record.renderHandle, record.inputValues, record.propValues);
19077
19573
  record.pendingStructural = false;
19078
19574
  } else {
19079
19575
  this.renderer.updateIndicator(record.renderHandle, modelToValuePatch(model));
@@ -19100,16 +19596,17 @@ var EngineOrchestrator = class _EngineOrchestrator {
19100
19596
  placeModel(model, id, paneId) {
19101
19597
  model.id = id;
19102
19598
  model.paneId = paneId;
19103
- for (const series of model.series) series.paneId = paneId;
19104
- for (const fill of model.fills) fill.paneId = paneId;
19105
- for (const bg of model.backgrounds) bg.paneId = paneId;
19599
+ const paneOf = (item) => item.overlay === true ? "price" : paneId;
19600
+ for (const series of model.series) series.paneId = paneOf(series);
19601
+ for (const fill of model.fills) fill.paneId = paneOf(fill);
19602
+ for (const bg of model.backgrounds) bg.paneId = paneOf(bg);
19106
19603
  for (const line of model.priceLines) line.paneId = paneId;
19107
- if (model.lines) for (const ln of model.lines) ln.paneId = paneId;
19108
- if (model.boxes) for (const bx of model.boxes) bx.paneId = paneId;
19109
- if (model.labels) for (const lb of model.labels) lb.paneId = paneId;
19110
- if (model.polylines) for (const pl of model.polylines) pl.paneId = paneId;
19111
- if (model.linefills) for (const lf of model.linefills) lf.paneId = paneId;
19112
- if (model.tables) for (const tb of model.tables) tb.paneId = paneId;
19604
+ if (model.lines) for (const ln of model.lines) ln.paneId = paneOf(ln);
19605
+ if (model.boxes) for (const bx of model.boxes) bx.paneId = paneOf(bx);
19606
+ if (model.labels) for (const lb of model.labels) lb.paneId = paneOf(lb);
19607
+ if (model.polylines) for (const pl of model.polylines) pl.paneId = paneOf(pl);
19608
+ if (model.linefills) for (const lf of model.linefills) lf.paneId = paneOf(lf);
19609
+ if (model.tables) for (const tb of model.tables) tb.paneId = paneOf(tb);
19113
19610
  }
19114
19611
  emitContextChanged(id) {
19115
19612
  if (!this.registry.get(id)?.session?.getContext) return;
@@ -19262,6 +19759,15 @@ var RendererControl = class {
19262
19759
  setLegendActions(provider) {
19263
19760
  this.renderer.setLegendActions?.(provider);
19264
19761
  }
19762
+ /**
19763
+ * Wire the legend rows' HOST-CONTRIBUTED callout bubbles (the shells route the
19764
+ * plugin registry through this; see `registerLegendCallout`). Silent on a renderer
19765
+ * without the seam — contributed callouts simply never show there, same graceful
19766
+ * degradation as {@link setLegendActions}.
19767
+ */
19768
+ setLegendCallouts(provider) {
19769
+ this.renderer.setLegendCallouts?.(provider);
19770
+ }
19265
19771
  /**
19266
19772
  * Replace the indicator legend's fold toggle with a host action (or restore it with
19267
19773
  * `null`) — multi-chart shells point the chip at their indicator overview instead of
@@ -19771,6 +20277,10 @@ function inputVisible(when, values) {
19771
20277
  }
19772
20278
 
19773
20279
  // src/renderers/shared/IndicatorInputsDialog.ts
20280
+ var PROPS_TAB = "Properties";
20281
+ function bagOf(row, decl) {
20282
+ return decl.prop ? row.propValues : row.values;
20283
+ }
19774
20284
  var SOURCES = ["close", "open", "high", "low", "hl2", "hlc3", "ohlc4", "volume"];
19775
20285
  var IndicatorInputsDialog = class {
19776
20286
  constructor(host) {
@@ -19781,6 +20291,7 @@ var IndicatorInputsDialog = class {
19781
20291
  this.openId = null;
19782
20292
  this.row = null;
19783
20293
  this.snapshot = null;
20294
+ this.propSnapshot = null;
19784
20295
  this.dialogTips = [];
19785
20296
  /** Re-applies every `when` gate against current values; set while the dialog is open. */
19786
20297
  this.refreshVisibility = null;
@@ -19803,10 +20314,11 @@ var IndicatorInputsDialog = class {
19803
20314
  }
19804
20315
  open(row) {
19805
20316
  this.close();
19806
- if (row.inputs.length === 0) return;
20317
+ if (row.inputs.length === 0 && row.props.length === 0) return;
19807
20318
  this.row = row;
19808
20319
  this.openId = row.id;
19809
20320
  this.snapshot = { ...row.values };
20321
+ this.propSnapshot = { ...row.propValues };
19810
20322
  ensureDialogStyles();
19811
20323
  const t = this.host.theme();
19812
20324
  const border = "var(--vela-border)";
@@ -19814,7 +20326,7 @@ var IndicatorInputsDialog = class {
19814
20326
  const host = this.host.dialogHost() ?? this.host.container;
19815
20327
  const ui = new Dialog({
19816
20328
  host,
19817
- title: row.settingsTitle,
20329
+ title: row.title,
19818
20330
  // Non-modal: live-edit dialog — a modal machine locks pointer events on the
19819
20331
  // whole body, killing the chart and the body-portaled popovers.
19820
20332
  modal: false,
@@ -19846,6 +20358,16 @@ var IndicatorInputsDialog = class {
19846
20358
  text: () => "Close"
19847
20359
  }));
19848
20360
  const tabDefs = tabInputs(row.inputs);
20361
+ if (row.props.length > 0) {
20362
+ const propDecls = row.props.map((p) => {
20363
+ const d = { ...p, prop: true };
20364
+ delete d.tab;
20365
+ return d;
20366
+ });
20367
+ const existing = tabDefs.find((t2) => t2.name === PROPS_TAB);
20368
+ if (existing) existing.inputs.push(...propDecls);
20369
+ else tabDefs.push({ name: PROPS_TAB, inputs: propDecls });
20370
+ }
19849
20371
  const tabs = document.createElement("div");
19850
20372
  tabs.style.cssText = `display:flex;gap:12px;padding:0 20px;border-bottom:1px solid ${border};flex:0 0 auto;`;
19851
20373
  const tabEls = [];
@@ -19948,9 +20470,10 @@ var IndicatorInputsDialog = class {
19948
20470
  this.openId = null;
19949
20471
  this.row = null;
19950
20472
  this.snapshot = null;
20473
+ this.propSnapshot = null;
19951
20474
  ui?.destroy();
19952
20475
  }
19953
- /** Restore every input to its open-time value (re-running the indicator), then close. */
20476
+ /** Restore every input and prop to its open-time value (re-running the indicator), then close. */
19954
20477
  revertAndClose() {
19955
20478
  const row = this.row;
19956
20479
  const snap = this.snapshot;
@@ -19964,6 +20487,17 @@ var IndicatorInputsDialog = class {
19964
20487
  }
19965
20488
  }
19966
20489
  }
20490
+ const propSnap = this.propSnapshot;
20491
+ if (row && propSnap) {
20492
+ for (const p of row.props) {
20493
+ const snapped = propSnap[p.key];
20494
+ const before = snapped !== void 0 ? snapped : p.defval;
20495
+ if (row.propValues[p.key] !== before) {
20496
+ row.propValues[p.key] = before;
20497
+ this.host.onChange?.({ indicatorId: row.id, key: p.key, value: before, kind: "prop" });
20498
+ }
20499
+ }
20500
+ }
19967
20501
  this.close();
19968
20502
  }
19969
20503
  /** The footer's reset button — same chip as Cancel, pinned to the LEFT edge
@@ -19981,19 +20515,27 @@ var IndicatorInputsDialog = class {
19981
20515
  const row = this.row;
19982
20516
  if (!row) return;
19983
20517
  const snap = this.snapshot;
20518
+ const propSnap = this.propSnapshot;
19984
20519
  for (const inp of row.inputs) {
19985
20520
  if (row.values[inp.key] !== inp.defval) {
19986
20521
  row.values[inp.key] = inp.defval;
19987
20522
  this.host.onChange?.({ indicatorId: row.id, key: inp.key, value: inp.defval });
19988
20523
  }
19989
20524
  }
20525
+ for (const p of row.props) {
20526
+ if (row.propValues[p.key] !== p.defval) {
20527
+ row.propValues[p.key] = p.defval;
20528
+ this.host.onChange?.({ indicatorId: row.id, key: p.key, value: p.defval, kind: "prop" });
20529
+ }
20530
+ }
19990
20531
  this.open(row);
19991
20532
  if (snap) this.snapshot = snap;
20533
+ if (propSnap) this.propSnapshot = propSnap;
19992
20534
  }
19993
20535
  /** Write one edit through: store it, notify the host, and re-apply the `when` gates. */
19994
- commit(row, key, value) {
19995
- row.values[key] = value;
19996
- this.host.onChange?.({ indicatorId: row.id, key, value });
20536
+ commit(row, decl, value) {
20537
+ bagOf(row, decl)[decl.key] = value;
20538
+ this.host.onChange?.({ indicatorId: row.id, key: decl.key, value, ...decl.prop ? { kind: "prop" } : {} });
19997
20539
  this.refreshVisibility?.();
19998
20540
  }
19999
20541
  /** One settings row (or several `inline=` inputs) placed into a section's grid. */
@@ -20020,7 +20562,7 @@ var IndicatorInputsDialog = class {
20020
20562
  const id = idOf(lead);
20021
20563
  const info = lead.tooltip ? this.infoButton(lead.tooltip) : void 0;
20022
20564
  if (lead.type === "bool") {
20023
- const current = Boolean(row.values[lead.key] ?? lead.defval);
20565
+ const current = Boolean(bagOf(row, lead)[lead.key] ?? lead.defval);
20024
20566
  return append(fieldRow({
20025
20567
  label: nameOf(lead),
20026
20568
  id,
@@ -20029,7 +20571,7 @@ var IndicatorInputsDialog = class {
20029
20571
  toggle: {
20030
20572
  id,
20031
20573
  checked: current,
20032
- onChange: (v) => this.commit(row, lead.key, v)
20574
+ onChange: (v) => this.commit(row, lead, v)
20033
20575
  }
20034
20576
  }));
20035
20577
  }
@@ -20054,8 +20596,8 @@ var IndicatorInputsDialog = class {
20054
20596
  }
20055
20597
  /** Build the typed control for one input, committing edits live via `onChange`. */
20056
20598
  buildControl(row, inp, id) {
20057
- const current = row.values[inp.key] ?? inp.defval;
20058
- const emit = (value) => this.commit(row, inp.key, value);
20599
+ const current = bagOf(row, inp)[inp.key] ?? inp.defval;
20600
+ const emit = (value) => this.commit(row, inp, value);
20059
20601
  if (inp.type === "bool") {
20060
20602
  return buildFieldControl({ kind: "switch", id, checked: Boolean(current), onChange: (v) => emit(v) }).el;
20061
20603
  }
@@ -20066,7 +20608,7 @@ var IndicatorInputsDialog = class {
20066
20608
  kind: "color",
20067
20609
  id,
20068
20610
  theme: this.host.theme(),
20069
- get: () => String(row.values[inp.key] ?? inp.defval),
20611
+ get: () => String(bagOf(row, inp)[inp.key] ?? inp.defval),
20070
20612
  onChange: (v) => emit(v)
20071
20613
  }).el;
20072
20614
  }
@@ -20629,6 +21171,7 @@ function timeParts(ts) {
20629
21171
  }
20630
21172
 
20631
21173
  // src/renderers/shared/InputsUI.ts
21174
+ var LEGEND_AT_TOP_ATTR = "data-vela-pane-at-top";
20632
21175
  var STATUS_KEYFRAMES = "@keyframes vela-ind-pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.3;transform:scale(.6)}}";
20633
21176
  var LEGEND_ICON_PX2 = 16;
20634
21177
  var LEGEND_CTL_PX = 18;
@@ -20646,6 +21189,9 @@ var CLOSE_SVG = iconAt("close", LEGEND_ICON_PX2);
20646
21189
  var FOLD_SVG = iconAt("chevron-up", LEGEND_ICON_PX2);
20647
21190
  var UNFOLD_SVG = iconAt("chevron-down", LEGEND_ICON_PX2);
20648
21191
  var OVERVIEW_SVG = iconAt("objects", LEGEND_ICON_PX2);
21192
+ function legendCalloutsDisplay(open2, hasCallouts) {
21193
+ return !open2 && hasCallouts ? "inline-flex" : "none";
21194
+ }
20649
21195
  var InputsUI = class {
20650
21196
  constructor(container, theme, paneBoundsOf) {
20651
21197
  this.container = container;
@@ -20667,10 +21213,14 @@ var InputsUI = class {
20667
21213
  this.moveApi = null;
20668
21214
  /** Host-contributed legend actions, resolved PER ROW at render time (see setLegendActions). */
20669
21215
  this.legendActions = null;
21216
+ /** Host-contributed callout bubbles, resolved PER ROW at render time (see setLegendCallouts). */
21217
+ this.legendCallouts = null;
20670
21218
  /** Chrome-tooltip disposers, per row id — a tip open at removal must not outlive its row. */
20671
21219
  this.rowTips = /* @__PURE__ */ new Map();
20672
21220
  /** Same, for the contributed extras only (rebuilt independently by setLegendActions). */
20673
21221
  this.extrasTips = /* @__PURE__ */ new Map();
21222
+ /** Same, for the contributed callouts only (rebuilt independently by setLegendCallouts). */
21223
+ this.calloutTips = /* @__PURE__ */ new Map();
20674
21224
  /** Open "Move to" menu (kept so it can be torn down). */
20675
21225
  this.moveMenu = null;
20676
21226
  this.moveTargets = [];
@@ -20828,6 +21378,42 @@ var InputsUI = class {
20828
21378
  extrasEl.appendChild(btn2);
20829
21379
  }
20830
21380
  }
21381
+ /**
21382
+ * Wire the host-contributed callout bubbles. Same contract as
21383
+ * {@link setLegendActions}: re-calling replaces the provider and re-projects the
21384
+ * rows already on screen.
21385
+ */
21386
+ setLegendCallouts(provider) {
21387
+ this.legendCallouts = provider;
21388
+ for (const row of this.rows.values()) this.renderCallouts(row);
21389
+ }
21390
+ /** (Re)build one row's callout bubbles — tinted icon circles beside the title whose
21391
+ * click (when the view carries content) deploys a panel of text and actions. */
21392
+ renderCallouts(row) {
21393
+ this.disposeTips(this.calloutTips, row.id);
21394
+ for (const bubble of row.callouts) bubble.destroy();
21395
+ row.callouts = [];
21396
+ row.calloutsEl.replaceChildren();
21397
+ const views = this.legendCallouts?.(row.id) ?? [];
21398
+ row.calloutsEl.style.display = legendCalloutsDisplay(row.highlighted, views.length > 0);
21399
+ for (const view of views) {
21400
+ const bubble = new CalloutBubble({
21401
+ icon: view.icon,
21402
+ background: view.background,
21403
+ ...view.color !== void 0 ? { color: view.color } : {},
21404
+ label: view.tooltip,
21405
+ ...view.content !== void 0 ? { panel: view.content } : {},
21406
+ // The panel portals into the plot host and self-tokens: it must work on
21407
+ // a bare chart, where no `.vela-ui` token ancestor exists.
21408
+ host: this.container,
21409
+ theme: () => this.theme
21410
+ });
21411
+ bubble.el.dataset.legendCallout = view.id;
21412
+ this.tip(this.calloutTips, row.id, bubble.el, view.tooltip);
21413
+ row.callouts.push(bubble);
21414
+ row.calloutsEl.appendChild(bubble.el);
21415
+ }
21416
+ }
20831
21417
  /** Reposition the per-pane legend containers after a layout change. */
20832
21418
  reposition() {
20833
21419
  for (const [paneId, lg] of this.legends) this.positionLegend(lg, paneId);
@@ -20995,6 +21581,7 @@ var InputsUI = class {
20995
21581
  }
20996
21582
  positionLegend(lg, paneId) {
20997
21583
  const bounds = this.paneBoundsOf ? this.paneBoundsOf(paneId) : { top: 0, height: Infinity };
21584
+ lg.toggleAttribute(LEGEND_AT_TOP_ATTR, bounds.top === 0);
20998
21585
  lg.style.display = bounds.height < 4 || !this.titlesVisible ? "none" : "flex";
20999
21586
  const collapsed = this.paneCollapse.has(paneId);
21000
21587
  const masterId = this.paneCollapse.get(paneId) ?? null;
@@ -21070,13 +21657,13 @@ var InputsUI = class {
21070
21657
  }
21071
21658
  /** Create or update an indicator's legend row (in the legend for its pane). */
21072
21659
  upsert(id, title, inputs, values, paneId = "price", opts = {}) {
21073
- const settingsTitle = opts.settingsTitle ?? title;
21074
21660
  const existing = this.rows.get(id);
21075
21661
  if (existing) {
21076
21662
  existing.title = title;
21077
- existing.settingsTitle = settingsTitle;
21078
21663
  existing.inputs = inputs;
21079
21664
  existing.values = { ...values };
21665
+ existing.props = opts.props ?? [];
21666
+ existing.propValues = { ...opts.propValues ?? {} };
21080
21667
  existing.titleEl.textContent = title;
21081
21668
  if (existing.paneId !== paneId) {
21082
21669
  existing.paneId = paneId;
@@ -21128,6 +21715,9 @@ var InputsUI = class {
21128
21715
  titleWrap.appendChild(beta);
21129
21716
  }
21130
21717
  el.appendChild(titleWrap);
21718
+ const calloutsEl = document.createElement("span");
21719
+ calloutsEl.style.cssText = `display:none;align-items:center;gap:4px;flex:none;margin-left:${LEGEND_TITLE_STATUS_GAP_PX}px;`;
21720
+ el.appendChild(calloutsEl);
21131
21721
  el.appendChild(statusEl);
21132
21722
  const valuesEl = document.createElement("span");
21133
21723
  valuesEl.style.cssText = `display:none;align-items:center;gap:5px;margin-left:${LEGEND_TITLE_VALUES_GAP_PX}px;white-space:nowrap;font-variant-numeric:tabular-nums;`;
@@ -21144,13 +21734,13 @@ var InputsUI = class {
21144
21734
  eye.className = "vela-ind-ctl";
21145
21735
  eye.style.cssText = LEGEND_CTL_CSS;
21146
21736
  eye.addEventListener("click", () => {
21147
- const row = this.rows.get(id);
21148
- this.onToggleVisible?.(id, Boolean(row?.hidden));
21737
+ const row2 = this.rows.get(id);
21738
+ this.onToggleVisible?.(id, Boolean(row2?.hidden));
21149
21739
  });
21150
21740
  controlsEl.appendChild(eye);
21151
21741
  eyeEl = eye;
21152
21742
  }
21153
- if (inputs.length > 0) {
21743
+ if (inputs.length > 0 || (opts.props ?? []).length > 0) {
21154
21744
  const gear = document.createElement("button");
21155
21745
  gear.type = "button";
21156
21746
  gear.setAttribute("aria-label", "Settings");
@@ -21190,7 +21780,9 @@ var InputsUI = class {
21190
21780
  controlsEl.appendChild(close);
21191
21781
  el.appendChild(controlsEl);
21192
21782
  this.attach(this.legendFor(paneId), el, !!opts.native);
21193
- this.rows.set(id, { id, title, settingsTitle, inputs, values: { ...values }, el, titleEl, statusEl, valuesEl, plotValues: [], plotValuesKey: "", showValues: null, highlighted: false, paneId, hidden: false, eyeEl, controlsEl, extrasEl, native: !!opts.native });
21783
+ const row = { id, title, inputs, values: { ...values }, props: opts.props ?? [], propValues: { ...opts.propValues ?? {} }, el, titleEl, statusEl, valuesEl, plotValues: [], plotValuesKey: "", showValues: null, highlighted: false, paneId, hidden: false, eyeEl, controlsEl, extrasEl, calloutsEl, callouts: [], native: !!opts.native };
21784
+ this.rows.set(id, row);
21785
+ this.renderCallouts(row);
21194
21786
  this.syncFoldToggle();
21195
21787
  }
21196
21788
  /** Place a row in its pane's legend — native rows PREPEND (pinned to the top), Pine rows append. */
@@ -21199,9 +21791,11 @@ var InputsUI = class {
21199
21791
  else container.appendChild(el);
21200
21792
  }
21201
21793
  /** Reflect programmatic input changes (so a re-opened dialog shows current values). */
21202
- setValues(id, values) {
21794
+ setValues(id, values, props) {
21203
21795
  const row = this.rows.get(id);
21204
- if (row) row.values = { ...row.values, ...values };
21796
+ if (!row) return;
21797
+ row.values = { ...row.values, ...values };
21798
+ if (props) row.propValues = { ...row.propValues, ...props };
21205
21799
  }
21206
21800
  /**
21207
21801
  * Reflect an indicator's live status in its legend row: `'loading'` shows three pulsing
@@ -21283,8 +21877,13 @@ var InputsUI = class {
21283
21877
  syncRowActions(row) {
21284
21878
  const open2 = row.highlighted;
21285
21879
  row.controlsEl.style.display = open2 || row.hidden ? "inline-flex" : "none";
21286
- if (open2) row.el.appendChild(row.statusEl);
21287
- else row.el.insertBefore(row.statusEl, row.valuesEl);
21880
+ if (open2) {
21881
+ row.el.appendChild(row.statusEl);
21882
+ for (const bubble of row.callouts) bubble.hidePanel();
21883
+ } else {
21884
+ row.el.insertBefore(row.statusEl, row.valuesEl);
21885
+ }
21886
+ row.calloutsEl.style.display = legendCalloutsDisplay(open2, row.callouts.length > 0);
21288
21887
  for (const child of Array.from(row.controlsEl.children)) {
21289
21888
  if (!(child instanceof HTMLElement) || child === row.eyeEl) continue;
21290
21889
  if (child === row.extrasEl) {
@@ -21302,10 +21901,12 @@ var InputsUI = class {
21302
21901
  }
21303
21902
  remove(id) {
21304
21903
  const row = this.rows.get(id);
21904
+ for (const bubble of row?.callouts ?? []) bubble.destroy();
21305
21905
  row?.el.remove();
21306
21906
  this.rows.delete(id);
21307
21907
  this.disposeTips(this.rowTips, id);
21308
21908
  this.disposeTips(this.extrasTips, id);
21909
+ this.disposeTips(this.calloutTips, id);
21309
21910
  if (this.selectedId === id) this.selectedId = null;
21310
21911
  if (this.inputsDialog.openId === id) this.inputsDialog.close();
21311
21912
  this.syncFoldToggle();
@@ -21325,6 +21926,8 @@ var InputsUI = class {
21325
21926
  this.rowMenu = null;
21326
21927
  for (const id of [...this.rowTips.keys()]) this.disposeTips(this.rowTips, id);
21327
21928
  for (const id of [...this.extrasTips.keys()]) this.disposeTips(this.extrasTips, id);
21929
+ for (const id of [...this.calloutTips.keys()]) this.disposeTips(this.calloutTips, id);
21930
+ for (const row of this.rows.values()) for (const bubble of row.callouts) bubble.destroy();
21328
21931
  for (const lg of this.legends.values()) lg.remove();
21329
21932
  this.legends.clear();
21330
21933
  this.rows.clear();
@@ -21370,7 +21973,7 @@ var ICONS = {
21370
21973
  };
21371
21974
  var STYLE_ID21 = "vela-pane-controls";
21372
21975
  var ICON_PX = 12;
21373
- var CLUSTER_PILL = "rgba(0,0,0,0.28)";
21976
+ var CLUSTER_PILL = "rgba(0,0,0,0.65)";
21374
21977
  function ensureStyles3() {
21375
21978
  if (typeof document === "undefined" || document.getElementById(STYLE_ID21)) return;
21376
21979
  const st = document.createElement("style");
@@ -21390,8 +21993,12 @@ var PaneControls = class {
21390
21993
  this.deps = deps;
21391
21994
  this.clusters = /* @__PURE__ */ new Map();
21392
21995
  this.hoverPaneId = null;
21996
+ /** Mobile: hover clusters are meaningless without a cursor — suppressed; a
21997
+ * collapsed pane's standalone expand chip stays (the only way back up). */
21998
+ this.suspended = false;
21393
21999
  /** Reveal the cluster for the pane under the cursor, resolved from the pointer's y in the plot. */
21394
22000
  this.onPlotMove = (e) => {
22001
+ if (this.suspended) return;
21395
22002
  const rect = this.plot.getBoundingClientRect();
21396
22003
  const y = e.clientY - rect.top;
21397
22004
  let hit = null;
@@ -21463,7 +22070,12 @@ var PaneControls = class {
21463
22070
  }
21464
22071
  if (p.count > 1) {
21465
22072
  cluster.appendChild(
21466
- this.button(p.maximized ? ICONS.restore : ICONS.maximize, p.maximized ? "Restore pane" : "Maximize pane", false, () => this.deps.onToggleMaximize(p.id), { role: "maximize" })
22073
+ this.button(p.maximized ? ICONS.restore : ICONS.maximize, p.maximized ? "Restore pane" : "Maximize pane", false, () => this.deps.onToggleMaximize(p.id), {
22074
+ role: "maximize",
22075
+ // Same inverse-chip treatment as the collapsed pane's expand toggle: the
22076
+ // maximized state must read as an active state, not just a swapped glyph.
22077
+ selected: p.maximized
22078
+ })
21467
22079
  );
21468
22080
  }
21469
22081
  }
@@ -21500,17 +22112,18 @@ var PaneControls = class {
21500
22112
  }
21501
22113
  const hovered = id === this.hoverPaneId;
21502
22114
  const hasButtons = cluster.children.length > 0;
21503
- const visible = hasButtons && (hovered || p.collapsed) && p.height > 8;
22115
+ const stateChipRole = p.collapsed ? "collapse" : !this.suspended && p.maximized ? "maximize" : null;
22116
+ const visible = hasButtons && (hovered || stateChipRole != null) && p.height > 8;
21504
22117
  cluster.style.right = `${rightPx}px`;
21505
22118
  cluster.style.top = p.collapsed ? `${p.top + Math.max(1, Math.round((p.height - 24) / 2))}px` : `${p.top + 4}px`;
21506
22119
  cluster.style.display = visible ? "flex" : "none";
21507
22120
  if (!visible) continue;
21508
- const soloExpand = p.collapsed && !hovered;
21509
- cluster.style.background = soloExpand ? "transparent" : CLUSTER_PILL;
22121
+ const soloChip = stateChipRole != null && !hovered;
22122
+ cluster.style.background = soloChip ? "transparent" : CLUSTER_PILL;
21510
22123
  for (const child of cluster.children) {
21511
22124
  const btn2 = child;
21512
22125
  btn2.style.display = "inline-flex";
21513
- btn2.style.visibility = soloExpand && btn2.dataset.role !== "collapse" ? "hidden" : "visible";
22126
+ btn2.style.visibility = soloChip && btn2.dataset.role !== stateChipRole ? "hidden" : "visible";
21514
22127
  }
21515
22128
  }
21516
22129
  }
@@ -21520,6 +22133,14 @@ var PaneControls = class {
21520
22133
  this.hoverPaneId = paneId;
21521
22134
  this.reposition();
21522
22135
  }
22136
+ /** Mobile suppression: no hover clusters (touch has no cursor; the shell's own
22137
+ * chrome covers maximize), while collapsed panes keep their expand chips. */
22138
+ setSuspended(on) {
22139
+ if (on === this.suspended) return;
22140
+ this.suspended = on;
22141
+ if (on) this.hoverPaneId = null;
22142
+ this.reposition();
22143
+ }
21523
22144
  destroy() {
21524
22145
  this.plot.removeEventListener("pointermove", this.onPlotMove);
21525
22146
  this.plot.removeEventListener("pointerleave", this.onPlotLeave);
@@ -21670,131 +22291,6 @@ var AxisScaleButtons = class {
21670
22291
  }
21671
22292
  };
21672
22293
 
21673
- // src/renderers/shared/TableOverlay.ts
21674
- var SIZE_PX3 = {
21675
- auto: 13,
21676
- tiny: 10,
21677
- small: 11,
21678
- normal: 13,
21679
- large: 16,
21680
- huge: 20
21681
- };
21682
- var TableOverlay = class {
21683
- constructor(container, theme, paneBounds) {
21684
- this.container = container;
21685
- this.theme = theme;
21686
- this.paneBounds = paneBounds;
21687
- this.lastTables = [];
21688
- if (getComputedStyle(container).position === "static") container.style.position = "relative";
21689
- this.root = document.createElement("div");
21690
- Object.assign(this.root.style, {
21691
- position: "absolute",
21692
- inset: "0",
21693
- pointerEvents: "none",
21694
- overflow: "hidden",
21695
- zIndex: "3"
21696
- });
21697
- container.appendChild(this.root);
21698
- }
21699
- update(tables) {
21700
- this.lastTables = tables;
21701
- this.root.replaceChildren();
21702
- for (const t of tables) this.root.appendChild(this.renderTable(t));
21703
- }
21704
- /** Re-render at the current pane geometry — after layout settles or on resize. */
21705
- reposition() {
21706
- if (this.root.isConnected) this.update(this.lastTables);
21707
- }
21708
- /** Show/hide the whole overlay. Tables anchor to pane corners, not to bars, so unlike the
21709
- * series content they DON'T vanish with an emptied chart — the loading state hides them. */
21710
- setVisible(visible) {
21711
- this.root.style.display = visible ? "" : "none";
21712
- }
21713
- destroy() {
21714
- this.root.remove();
21715
- }
21716
- renderTable(t) {
21717
- const wrap = document.createElement("div");
21718
- wrap.style.position = "absolute";
21719
- this.anchor(wrap, t.position, this.paneBounds(t.paneId));
21720
- const table = document.createElement("table");
21721
- Object.assign(table.style, {
21722
- borderCollapse: "collapse",
21723
- background: t.bgColor ?? "transparent",
21724
- fontFamily: this.theme.fontFamily || "sans-serif",
21725
- // Frame as an inset shadow (not a `border`) so it stays independent of the
21726
- // collapsed cell borders even when frame_width != border_width.
21727
- boxShadow: t.frameColor && t.frameWidth > 0 ? `inset 0 0 0 ${t.frameWidth}px ${t.frameColor}` : "none",
21728
- border: "none",
21729
- tableLayout: "auto",
21730
- // Re-enable pointer events on the table only (root is none) so cell tooltips work.
21731
- pointerEvents: "auto"
21732
- });
21733
- const span = /* @__PURE__ */ new Map();
21734
- const skip = /* @__PURE__ */ new Set();
21735
- for (const m of t.merges) {
21736
- span.set(`${m.startRow}:${m.startCol}`, { cs: m.endCol - m.startCol + 1, rs: m.endRow - m.startRow + 1 });
21737
- for (let r = m.startRow; r <= m.endRow; r += 1) {
21738
- for (let c = m.startCol; c <= m.endCol; c += 1) {
21739
- if (r !== m.startRow || c !== m.startCol) skip.add(`${r}:${c}`);
21740
- }
21741
- }
21742
- }
21743
- const cellBorder = t.borderColor && t.borderWidth > 0 ? `${t.borderWidth}px solid ${t.borderColor}` : "none";
21744
- for (let r = 0; r < t.rows; r += 1) {
21745
- const tr = document.createElement("tr");
21746
- for (let c = 0; c < t.columns; c += 1) {
21747
- const cell = t.cells[r]?.[c] ?? null;
21748
- if (skip.has(`${r}:${c}`) || cell?.merged) continue;
21749
- const td = document.createElement("td");
21750
- const sp = span.get(`${r}:${c}`);
21751
- if (sp) {
21752
- if (sp.cs > 1) td.colSpan = sp.cs;
21753
- if (sp.rs > 1) td.rowSpan = sp.rs;
21754
- }
21755
- Object.assign(td.style, {
21756
- border: cellBorder,
21757
- padding: "2px 6px",
21758
- background: cell?.bgColor ?? "transparent",
21759
- color: cell?.textColor ?? this.theme.textColor,
21760
- textAlign: cell?.hAlign ?? "center",
21761
- verticalAlign: cell?.vAlign === "top" ? "top" : cell?.vAlign === "bottom" ? "bottom" : "middle",
21762
- fontSize: `${SIZE_PX3[cell?.textSize ?? "normal"]}px`,
21763
- fontFamily: cell?.fontFamily === "monospace" ? "monospace" : "inherit",
21764
- fontWeight: cell?.bold ? "bold" : "normal",
21765
- fontStyle: cell?.italic ? "italic" : "normal",
21766
- whiteSpace: "pre-line"
21767
- });
21768
- if (cell?.tooltip) td.title = cell.tooltip;
21769
- td.textContent = cell?.text ?? "";
21770
- tr.appendChild(td);
21771
- }
21772
- table.appendChild(tr);
21773
- }
21774
- wrap.appendChild(table);
21775
- return wrap;
21776
- }
21777
- /**
21778
- * Position the wrapper at a Pine `position.*` corner/edge of the table's PANE
21779
- * (not the whole chart), inset past the right price axis so it never overlaps
21780
- * the Y-axis labels.
21781
- */
21782
- anchor(el, position, b) {
21783
- const m = 6;
21784
- const containerH = this.root.clientHeight || this.container.clientHeight;
21785
- const paneBottom = b.top + b.height;
21786
- if (position.startsWith("top")) el.style.top = `${b.top + m}px`;
21787
- else if (position.startsWith("bottom")) el.style.bottom = `${Math.max(0, containerH - paneBottom) + m}px`;
21788
- else el.style.top = `${b.top + b.height / 2}px`;
21789
- if (position.endsWith("left")) el.style.left = `${m}px`;
21790
- else if (position.endsWith("right")) el.style.right = `${b.rightAxis + m}px`;
21791
- else el.style.left = `calc(50% - ${b.rightAxis / 2}px)`;
21792
- const tx = position.endsWith("center") ? "-50%" : "0";
21793
- const ty = position.startsWith("middle") ? "-50%" : "0";
21794
- if (tx !== "0" || ty !== "0") el.style.transform = `translate(${tx}, ${ty})`;
21795
- }
21796
- };
21797
-
21798
22294
  // src/renderers/native/capabilities.ts
21799
22295
  var NATIVE_CAPABILITIES = {
21800
22296
  panes: true,
@@ -21815,7 +22311,7 @@ var NATIVE_CAPABILITIES = {
21815
22311
  drawingDepth: true,
21816
22312
  // drawings share the series' z space (backend-composited interleave layers)
21817
22313
  tables: true,
21818
- // reuses the DOM TableOverlay
22314
+ // canvas-painted into the owning indicator's interleave slice
21819
22315
  trades: true,
21820
22316
  // strategy order-fill markers (arrows + labels + fill-price ticks)
21821
22317
  inputsUI: true
@@ -21846,6 +22342,9 @@ function candleTier(spacing) {
21846
22342
  if (spacing < CANDLE_BODY_MIN_SPACING) return "wick";
21847
22343
  return "full";
21848
22344
  }
22345
+ function snapY(yCss, dpr) {
22346
+ return Math.round(yCss * dpr) / dpr;
22347
+ }
21849
22348
  function candleGeometry(xCss, spacing, dpr, bodyScale = 1) {
21850
22349
  const wickDev = Math.max(1, Math.round(wickWidth(spacing) * dpr));
21851
22350
  const wickLeftDev = Math.round(xCss * dpr - wickDev / 2);
@@ -22368,8 +22867,12 @@ var WebGL2Backend = class {
22368
22867
  return sc === pane.scale ? pane : { ...pane, scale: sc };
22369
22868
  };
22370
22869
  b.alpha = this.modelAlpha;
22371
- for (const m of models) for (const bgSpan of m.backgrounds) this.emitBackground(b, bgSpan, pane, coords);
22372
- for (const m of models) for (const f of m.fills) this.emitFill(b, m, f, effPane(m), coords, i0, i1, scene.offsetOf(m.id));
22870
+ for (const m of models) for (const bgSpan of m.backgrounds) if (bgSpan.overlay !== true) this.emitBackground(b, bgSpan, pane, coords);
22871
+ if (isPrice) {
22872
+ for (const m of scene.indicators.values()) {
22873
+ for (const bgSpan of m.backgrounds) if (bgSpan.overlay === true) this.emitBackground(b, bgSpan, pane, coords);
22874
+ }
22875
+ }
22373
22876
  const drawCandles = isPrice && !scene.candlesHidden;
22374
22877
  let candleDrawn = false;
22375
22878
  for (const m of models) {
@@ -22383,15 +22886,26 @@ var WebGL2Backend = class {
22383
22886
  b.alpha = this.modelAlpha;
22384
22887
  const off = scene.offsetOf(m.id);
22385
22888
  const mp = effPane(m);
22386
- for (const s of m.series) this.emitSeries(b, s, mp, coords, i0, i1, theme, off);
22889
+ for (const f of m.fills) if (f.overlay !== true) this.emitFill(b, m, f, mp, coords, i0, i1, off);
22890
+ for (const s of m.series) if (s.overlay !== true) this.emitSeries(b, s, mp, coords, i0, i1, theme, off);
22387
22891
  }
22388
22892
  if (drawCandles && !candleDrawn) {
22389
22893
  drawSlicesUpTo(scene.candleZ);
22390
22894
  b.alpha = this.candleStructureAlpha;
22391
22895
  this.emitPriceSeries(b, scene, i0, i1, coords, pane, theme, barColorMap, dataW);
22392
22896
  }
22393
- drawSlicesUpTo(Infinity);
22394
22897
  b.alpha = this.modelAlpha;
22898
+ if (isPrice) {
22899
+ for (const m of scene.indicators.values()) {
22900
+ const off = scene.offsetOf(m.id);
22901
+ for (const f of m.fills) if (f.overlay === true) this.emitFill(b, m, f, pane, coords, i0, i1, off);
22902
+ }
22903
+ for (const m of scene.indicators.values()) {
22904
+ const off = scene.offsetOf(m.id);
22905
+ for (const s of m.series) if (s.overlay === true) this.emitSeries(b, s, pane, coords, i0, i1, theme, off);
22906
+ }
22907
+ }
22908
+ drawSlicesUpTo(Infinity);
22395
22909
  for (const m of models) {
22396
22910
  const mp = effPane(m);
22397
22911
  for (const pl of m.priceLines) this.emitHline(b, pl, mp, coords, dataW, theme);
@@ -22438,7 +22952,7 @@ var WebGL2Backend = class {
22438
22952
  for (const pane of scene.orderedPanes()) {
22439
22953
  const b = this.glowBatch;
22440
22954
  b.reset();
22441
- this.emitGlowSources(b, scene.indicatorsForPane(pane.id), pane, coords, i0, i1, (id) => scene.offsetOf(id));
22955
+ this.emitGlowSources(b, scene, pane, coords, i0, i1);
22442
22956
  if (b.vertexCount === 0) continue;
22443
22957
  const topH = Math.round(pane.bounds.top * dpr * 0.5);
22444
22958
  const botH = Math.round((pane.bounds.top + pane.bounds.height) * dpr * 0.5);
@@ -22507,15 +23021,23 @@ var WebGL2Backend = class {
22507
23021
  gl.bindVertexArray(null);
22508
23022
  return true;
22509
23023
  }
22510
- /** The "neon" elements that glow: line/area/step lines + point markers (not candles/fills/bars). */
22511
- emitGlowSources(b, models, pane, coords, i0, i1, offsetOf = () => 0) {
22512
- for (const m of models) {
22513
- const off = offsetOf(m.id);
22514
- for (const s of m.series) {
22515
- if (!isLineLikeSeries(s) || s.visible === false) continue;
22516
- if (s.kind === "histogram" || s.kind === "columns") continue;
22517
- if (s.kind === "circles" || s.kind === "cross") this.emitPointMarkers(b, s, pane, coords, i0, i1, off);
22518
- else this.emitPolyline(b, s, pane, coords, i0, i1, s.kind === "step", off);
23024
+ /** The "neon" elements that glow: line/area/step lines + point markers (not candles/fills/bars).
23025
+ * Routing mirrors the main pass: own series per pane, force_overlay series on the price pane. */
23026
+ emitGlowSources(b, scene, pane, coords, i0, i1) {
23027
+ const emitOne = (s, off) => {
23028
+ if (!isLineLikeSeries(s) || s.visible === false) return;
23029
+ if (s.kind === "histogram" || s.kind === "columns") return;
23030
+ if (s.kind === "circles" || s.kind === "cross") this.emitPointMarkers(b, s, pane, coords, i0, i1, off);
23031
+ else this.emitPolyline(b, s, pane, coords, i0, i1, s.kind === "step", off);
23032
+ };
23033
+ for (const m of scene.indicatorsForPane(pane.id)) {
23034
+ const off = scene.offsetOf(m.id);
23035
+ for (const s of m.series) if (s.overlay !== true) emitOne(s, off);
23036
+ }
23037
+ if (pane.kind === "price") {
23038
+ for (const m of scene.indicators.values()) {
23039
+ const off = scene.offsetOf(m.id);
23040
+ for (const s of m.series) if (s.overlay === true) emitOne(s, off);
22519
23041
  }
22520
23042
  }
22521
23043
  }
@@ -22778,12 +23300,12 @@ var WebGL2Backend = class {
22778
23300
  if (drawBody) {
22779
23301
  const oY = coords.priceToY(bar.open, pane.scale, pane.bounds);
22780
23302
  const cY = coords.priceToY(bar.close, pane.scale, pane.bounds);
22781
- bodyTop = Math.min(oY, cY);
22782
- bodyH = Math.max(1, Math.abs(cY - oY));
23303
+ bodyTop = snapY(Math.min(oY, cY), coords.dpr);
23304
+ bodyH = Math.max(1 / coords.dpr, snapY(Math.max(oY, cY), coords.dpr) - bodyTop);
22783
23305
  }
22784
23306
  if (cs.wickVisible) {
22785
- const hY = coords.priceToY(bar.high, pane.scale, pane.bounds);
22786
- const lY = coords.priceToY(bar.low, pane.scale, pane.bounds);
23307
+ const hY = snapY(coords.priceToY(bar.high, pane.scale, pane.bounds), coords.dpr);
23308
+ const lY = snapY(coords.priceToY(bar.low, pane.scale, pane.bounds), coords.dpr);
22787
23309
  b.alpha = this.candleStructureAlpha;
22788
23310
  const wCol = parseColor((isUp ? cs.wickUpColor : cs.wickDownColor) ?? (drawBody ? dir : bodyColorStr));
22789
23311
  if (drawBody) {
@@ -22798,9 +23320,9 @@ var WebGL2Backend = class {
22798
23320
  b.alpha = this.candleBodyAlpha;
22799
23321
  b.rect(g.bodyX, bodyTop, g.bodyW, bodyH, c);
22800
23322
  }
22801
- if (cs.borderVisible || bc || fading && cs.bodyVisible) {
23323
+ if (cs.borderVisible || fading && cs.bodyVisible) {
22802
23324
  b.alpha = this.candleStructureAlpha;
22803
- const bord = cs.borderVisible || bc ? parseColor((isUp ? cs.borderUpColor : cs.borderDownColor) ?? dir) : c;
23325
+ const bord = cs.borderVisible ? parseColor((isUp ? cs.borderUpColor : cs.borderDownColor) ?? bodyColorStr) : c;
22804
23326
  const bw = Math.max(0, g.bodyW - 1);
22805
23327
  const bh = Math.max(0, bodyH - 1);
22806
23328
  b.rectStroke(g.bodyX + 0.5, bodyTop + 0.5, bw, bh, 1, bord);
@@ -24116,9 +24638,10 @@ var SceneGraph = class {
24116
24638
  * so each indicator arrives behind the candles (and behind older indicators);
24117
24639
  * `setIndicatorZ`/`bringToFront`/`sendToBack` change it. */
24118
24640
  this.seriesZ = /* @__PURE__ */ new Map();
24119
- /** Per-pane raster layers of user drawings interleaved into the series stack each is a
24641
+ /** Per-pane raster layers of drawings interleaved into the series stack (each
24642
+ * indicator's Pine drawings at its model's z, plus in-stack user drawings) — each a
24120
24643
  * prepainted canvas the backend composites just before the series carrying `beforeZ`.
24121
- * Rebuilt by the renderer per data frame; empty when every drawing sits over the stack. */
24644
+ * Rebuilt by the renderer per data frame. */
24122
24645
  this.drawingSlices = /* @__PURE__ */ new Map();
24123
24646
  /** Per-model index offset: the chart bar index of the model's `anchorTime` — its
24124
24647
  * index-aligned payloads (dense series arrays, `bar_index` drawings) count from that
@@ -24352,8 +24875,12 @@ var Canvas2dBackend = class {
24352
24875
  return sc === pane.scale ? pane : { ...pane, scale: sc };
24353
24876
  };
24354
24877
  ctx.globalAlpha = this.modelAlpha;
24355
- for (const m of models) for (const bg of m.backgrounds) this.drawBackground(ctx, bg, pane, coords);
24356
- for (const m of models) for (const f of m.fills) this.drawFill(ctx, m, f, effPane(m), coords, i0, i1, scene.offsetOf(m.id));
24878
+ for (const m of models) for (const bg of m.backgrounds) if (bg.overlay !== true) this.drawBackground(ctx, bg, pane, coords);
24879
+ if (isPrice) {
24880
+ for (const m of scene.indicators.values()) {
24881
+ for (const bg of m.backgrounds) if (bg.overlay === true) this.drawBackground(ctx, bg, pane, coords);
24882
+ }
24883
+ }
24357
24884
  const slices = scene.drawingSlices.get(pane.id) ?? [];
24358
24885
  let si = 0;
24359
24886
  const drawSlicesUpTo = (z) => {
@@ -24375,13 +24902,25 @@ var Canvas2dBackend = class {
24375
24902
  ctx.globalAlpha = this.modelAlpha;
24376
24903
  const off = scene.offsetOf(m.id);
24377
24904
  const mp = effPane(m);
24378
- for (const s of m.series) this.drawSeries(ctx, s, mp, coords, i0, i1, theme, off);
24905
+ for (const f of m.fills) if (f.overlay !== true) this.drawFill(ctx, m, f, mp, coords, i0, i1, off);
24906
+ for (const s of m.series) if (s.overlay !== true) this.drawSeries(ctx, s, mp, coords, i0, i1, theme, off);
24379
24907
  }
24380
24908
  if (drawCandles && !candleDrawn) {
24381
24909
  drawSlicesUpTo(scene.candleZ);
24382
24910
  ctx.globalAlpha = this.candleStructureAlpha;
24383
24911
  this.drawPriceSeries(ctx, scene, i0, i1, coords, pane, theme, barColorMap, dataW);
24384
24912
  }
24913
+ if (isPrice) {
24914
+ ctx.globalAlpha = this.modelAlpha;
24915
+ for (const m of scene.indicators.values()) {
24916
+ const off = scene.offsetOf(m.id);
24917
+ for (const f of m.fills) if (f.overlay === true) this.drawFill(ctx, m, f, pane, coords, i0, i1, off);
24918
+ }
24919
+ for (const m of scene.indicators.values()) {
24920
+ const off = scene.offsetOf(m.id);
24921
+ for (const s of m.series) if (s.overlay === true) this.drawSeries(ctx, s, pane, coords, i0, i1, theme, off);
24922
+ }
24923
+ }
24385
24924
  drawSlicesUpTo(Infinity);
24386
24925
  ctx.globalAlpha = this.modelAlpha;
24387
24926
  for (const m of models) {
@@ -24626,13 +25165,13 @@ var Canvas2dBackend = class {
24626
25165
  if (drawBody) {
24627
25166
  const oY = coords.priceToY(b.open, pane.scale, pane.bounds);
24628
25167
  const cY = coords.priceToY(b.close, pane.scale, pane.bounds);
24629
- top = Math.min(oY, cY);
24630
- bodyH = Math.max(1, Math.abs(cY - oY));
25168
+ top = snapY(Math.min(oY, cY), coords.dpr);
25169
+ bodyH = Math.max(1 / coords.dpr, snapY(Math.max(oY, cY), coords.dpr) - top);
24631
25170
  }
24632
25171
  if (cs.wickVisible) {
24633
25172
  const wick = (up ? cs.wickUpColor : cs.wickDownColor) ?? (drawBody ? dir : color);
24634
- const hY = coords.priceToY(b.high, pane.scale, pane.bounds);
24635
- const lY = coords.priceToY(b.low, pane.scale, pane.bounds);
25173
+ const hY = snapY(coords.priceToY(b.high, pane.scale, pane.bounds), coords.dpr);
25174
+ const lY = snapY(coords.priceToY(b.low, pane.scale, pane.bounds), coords.dpr);
24636
25175
  ctx.globalAlpha = this.candleStructureAlpha;
24637
25176
  ctx.strokeStyle = wick;
24638
25177
  ctx.lineWidth = g.wickW;
@@ -24654,9 +25193,9 @@ var Canvas2dBackend = class {
24654
25193
  ctx.fillStyle = color;
24655
25194
  ctx.fillRect(g.bodyX, top, g.bodyW, bodyH);
24656
25195
  }
24657
- if (cs.borderVisible || bc || fading && cs.bodyVisible) {
25196
+ if (cs.borderVisible || fading && cs.bodyVisible) {
24658
25197
  ctx.globalAlpha = this.candleStructureAlpha;
24659
- ctx.strokeStyle = cs.borderVisible || bc ? (up ? cs.borderUpColor : cs.borderDownColor) ?? dir : color;
25198
+ ctx.strokeStyle = cs.borderVisible ? (up ? cs.borderUpColor : cs.borderDownColor) ?? color : color;
24660
25199
  ctx.lineWidth = 1;
24661
25200
  const bw = Math.max(0, g.bodyW - 1);
24662
25201
  const bh = Math.max(0, bodyH - 1);
@@ -25079,6 +25618,19 @@ function autoFontSize(lines, boxW, boxH, bold) {
25079
25618
 
25080
25619
  // src/renderers/shared/DrawingSceneRenderer.ts
25081
25620
  var EMPTY_DRAWING_SET = { lines: [], boxes: [], labels: [], polylines: [], linefills: [] };
25621
+ function modelDrawingSet(m, overlay) {
25622
+ const want = (d) => Boolean(d.overlay) === overlay;
25623
+ return {
25624
+ lines: (m.lines ?? []).filter(want),
25625
+ boxes: (m.boxes ?? []).filter(want),
25626
+ labels: (m.labels ?? []).filter(want),
25627
+ polylines: (m.polylines ?? []).filter(want),
25628
+ linefills: (m.linefills ?? []).filter(want)
25629
+ };
25630
+ }
25631
+ function drawingSetEmpty(s) {
25632
+ return !s.lines.length && !s.boxes.length && !s.labels.length && !s.polylines.length && !s.linefills.length;
25633
+ }
25082
25634
  function fontSizePx(size) {
25083
25635
  return size === "auto" ? 12 : namedFontSize(size);
25084
25636
  }
@@ -25088,6 +25640,8 @@ var DrawingSceneRenderer = class {
25088
25640
  this.set = set;
25089
25641
  /** measureText width cache, keyed by `${font} ${text}`, persists across frames. */
25090
25642
  this.widthCache = /* @__PURE__ */ new Map();
25643
+ /** Tooltip hit-rects captured while drawing the CURRENT set (rebuilt per render). */
25644
+ this.tipRegions = [];
25091
25645
  /**
25092
25646
  * Index offset of the CURRENT set's model: its `xloc:'bar_index'` coordinates count
25093
25647
  * from the model's anchor bar, so they shift by this to land on chart logical indices.
@@ -25108,8 +25662,13 @@ var DrawingSceneRenderer = class {
25108
25662
  const s = this.set;
25109
25663
  return !s.lines.length && !s.boxes.length && !s.labels.length && !s.polylines.length && !s.linefills.length;
25110
25664
  }
25665
+ /** Tooltip hit-rects of the labels drawn by the LAST `render` call (same coords as `ctx`). */
25666
+ labelTipRegions() {
25667
+ return this.tipRegions;
25668
+ }
25111
25669
  /** Draw the whole set into `ctx` using the supplied coordinate closures. */
25112
25670
  render(ctx, W, H, xOf, yOf) {
25671
+ this.tipRegions = [];
25113
25672
  if (this.isEmpty()) return;
25114
25673
  ctx.save();
25115
25674
  this.drawLinefills(ctx, W, H, xOf, yOf);
@@ -25457,13 +26016,35 @@ var DrawingSceneRenderer = class {
25457
26016
  if (this.isPointShape(lb.style)) {
25458
26017
  if (!lb.noFill) this.drawLabelShape(ctx, lb.style, px, py, fontPx, color);
25459
26018
  if (lb.text) this.drawLabelText(ctx, lb, px, py + fontPx, fontPx);
25460
- } else if (lb.style === "none" || lb.style === "text_outline" || lb.noFill) {
25461
- if (lb.text) this.drawLabelText(ctx, lb, px, py, fontPx, lb.style === "text_outline");
26019
+ if (lb.tooltip) {
26020
+ const r = Math.max(4, fontPx * 0.6) + 3;
26021
+ this.tipRegions.push({ left: px - r, top: py - r, right: px + r, bottom: py + r, text: lb.tooltip });
26022
+ }
26023
+ } else if (lb.style === "none" || lb.style === "text_outline") {
26024
+ if (lb.text) {
26025
+ this.drawLabelText(ctx, lb, px, py, fontPx, lb.style === "text_outline");
26026
+ if (lb.tooltip) this.tipRegions.push(this.textRegion(ctx, lb, px, py, fontPx, lb.tooltip));
26027
+ }
25462
26028
  } else {
25463
- this.drawBubble(ctx, lb, px, py, fontPx, color);
26029
+ const r = this.drawBubble(ctx, lb, px, py, fontPx, color);
26030
+ if (lb.tooltip) this.tipRegions.push({ left: r.x, top: r.y, right: r.x + r.w, bottom: r.y + r.h, text: lb.tooltip });
25464
26031
  }
25465
26032
  }
25466
26033
  }
26034
+ /** Canvas font for a label's text — family + Pine `text_formatting` (bold/italic). */
26035
+ labelFont(lb, fontPx) {
26036
+ const family = lb.fontFamily === "monospace" ? "monospace" : this.deps.theme.fontFamily || "sans-serif";
26037
+ return `${lb.italic ? "italic " : ""}${lb.bold ? "bold " : ""}${fontPx}px ${family}`;
26038
+ }
26039
+ /** Hover rect of a text-only label (style none/text_outline/noFill), centered like drawLabelText. */
26040
+ textRegion(ctx, lb, cx, cy, fontPx, text) {
26041
+ const font = this.labelFont(lb, fontPx);
26042
+ const lines = (lb.text ?? "").split("\n");
26043
+ const w = Math.max(1, ...lines.map((l) => this.measure(ctx, font, l)));
26044
+ const h = fontPx * 1.25 * lines.length;
26045
+ const left = lb.textAlign === "left" ? cx : lb.textAlign === "right" ? cx - w : cx - w / 2;
26046
+ return { left, top: cy - h / 2, right: left + w, bottom: cy + h / 2, text };
26047
+ }
25467
26048
  isPointShape(style) {
25468
26049
  switch (style) {
25469
26050
  case "circle":
@@ -25482,8 +26063,7 @@ var DrawingSceneRenderer = class {
25482
26063
  }
25483
26064
  }
25484
26065
  drawLabelText(ctx, lb, cx, cy, fontPx, outline = false) {
25485
- const family = lb.fontFamily === "monospace" ? "monospace" : this.deps.theme.fontFamily || "sans-serif";
25486
- ctx.font = `${fontPx}px ${family}`;
26066
+ ctx.font = this.labelFont(lb, fontPx);
25487
26067
  ctx.textAlign = lb.textAlign === "left" ? "left" : lb.textAlign === "right" ? "right" : "center";
25488
26068
  ctx.textBaseline = "middle";
25489
26069
  const lines = lb.text.split("\n");
@@ -25563,9 +26143,9 @@ var DrawingSceneRenderer = class {
25563
26143
  ctx.fill();
25564
26144
  }
25565
26145
  }
26146
+ /** Draw a bubble label; returns the bubble body rect (for tooltip hit-testing). */
25566
26147
  drawBubble(ctx, lb, px, py, fontPx, color) {
25567
- const family = lb.fontFamily === "monospace" ? "monospace" : this.deps.theme.fontFamily || "sans-serif";
25568
- ctx.font = `${fontPx}px ${family}`;
26148
+ ctx.font = this.labelFont(lb, fontPx);
25569
26149
  const lines = (lb.text ?? "").split("\n");
25570
26150
  const padX = 6;
25571
26151
  const padY = 4;
@@ -25623,38 +26203,51 @@ var DrawingSceneRenderer = class {
25623
26203
  by = py - h - ptr;
25624
26204
  pointer = "down";
25625
26205
  }
25626
- ctx.fillStyle = color;
25627
- this.roundRect(ctx, bx, by, w, h, 4);
25628
- ctx.fill();
25629
- if (pointer !== "none") {
25630
- ctx.beginPath();
25631
- if (pointer === "down") {
25632
- ctx.moveTo(px - ptr, by + h);
25633
- ctx.lineTo(px + ptr, by + h);
25634
- ctx.lineTo(px, py);
25635
- } else if (pointer === "up") {
25636
- ctx.moveTo(px - ptr, by);
25637
- ctx.lineTo(px + ptr, by);
25638
- ctx.lineTo(px, py);
25639
- } else if (pointer === "left") {
25640
- ctx.moveTo(bx, py - ptr);
25641
- ctx.lineTo(bx, py + ptr);
25642
- ctx.lineTo(px, py);
25643
- } else {
25644
- ctx.moveTo(bx + w, py - ptr);
25645
- ctx.lineTo(bx + w, py + ptr);
25646
- ctx.lineTo(px, py);
25647
- }
25648
- ctx.closePath();
26206
+ if (!lb.noFill) {
26207
+ ctx.fillStyle = color;
26208
+ this.roundRect(ctx, bx, by, w, h, 4);
25649
26209
  ctx.fill();
26210
+ if (pointer !== "none") {
26211
+ ctx.beginPath();
26212
+ if (pointer === "down") {
26213
+ ctx.moveTo(px - ptr, by + h);
26214
+ ctx.lineTo(px + ptr, by + h);
26215
+ ctx.lineTo(px, py);
26216
+ } else if (pointer === "up") {
26217
+ ctx.moveTo(px - ptr, by);
26218
+ ctx.lineTo(px + ptr, by);
26219
+ ctx.lineTo(px, py);
26220
+ } else if (pointer === "left") {
26221
+ ctx.moveTo(bx, py - ptr);
26222
+ ctx.lineTo(bx, py + ptr);
26223
+ ctx.lineTo(px, py);
26224
+ } else {
26225
+ ctx.moveTo(bx + w, py - ptr);
26226
+ ctx.lineTo(bx + w, py + ptr);
26227
+ ctx.lineTo(px, py);
26228
+ }
26229
+ ctx.closePath();
26230
+ ctx.fill();
26231
+ }
25650
26232
  }
25651
26233
  if (lb.text) {
25652
- ctx.fillStyle = lb.textColor ?? contrastColor(color);
25653
- ctx.textAlign = "center";
26234
+ ctx.fillStyle = lb.textColor ?? (lb.noFill ? this.deps.theme.textColor : contrastColor(color));
25654
26235
  ctx.textBaseline = "middle";
26236
+ let tx;
26237
+ if (lb.textAlign === "left") {
26238
+ ctx.textAlign = "left";
26239
+ tx = bx + padX;
26240
+ } else if (lb.textAlign === "right") {
26241
+ ctx.textAlign = "right";
26242
+ tx = bx + w - padX;
26243
+ } else {
26244
+ ctx.textAlign = "center";
26245
+ tx = bx + w / 2;
26246
+ }
25655
26247
  const startY = by + padY + lineH / 2;
25656
- for (let i = 0; i < lines.length; i += 1) ctx.fillText(lines[i], bx + w / 2, startY + i * lineH);
26248
+ for (let i = 0; i < lines.length; i += 1) ctx.fillText(lines[i], tx, startY + i * lineH);
25657
26249
  }
26250
+ return { x: bx, y: by, w, h };
25658
26251
  }
25659
26252
  roundRect(ctx, x, y, w, h, r) {
25660
26253
  const rr = Math.min(r, w / 2, h / 2);
@@ -25890,7 +26483,7 @@ var ChromeRenderer = class {
25890
26483
  this.ctx = null;
25891
26484
  // The color for axis tick labels — the host-passed surface text, set each frame in render().
25892
26485
  this.axisTextColor = DARK_THEME.textColor;
25893
- // Shared Pine-drawing renderer (line/box/label/polyline/linefill); widthCache persists.
26486
+ // Shared Pine-drawing renderer, used here for autoscale geometry only; widthCache persists.
25894
26487
  this.drawScene = new DrawingSceneRenderer({ timeToLogical: () => 0, barAt: () => null, theme: {} });
25895
26488
  }
25896
26489
  mount(canvas) {
@@ -25915,8 +26508,8 @@ var ChromeRenderer = class {
25915
26508
  */
25916
26509
  paneDrawingsRange(ownModels, scene, isPricePane, vr) {
25917
26510
  let dr = null;
25918
- for (const m of ownModels) dr = unionRange(dr, this.drawingsRange(this.ownDrawings(m), vr, scene.offsetOf(m.id)));
25919
- if (isPricePane) for (const m of scene.indicators.values()) dr = unionRange(dr, this.drawingsRange(this.overlayDrawings(m), vr, scene.offsetOf(m.id)));
26511
+ for (const m of ownModels) dr = unionRange(dr, this.drawingsRange(modelDrawingSet(m, false), vr, scene.offsetOf(m.id)));
26512
+ if (isPricePane) for (const m of scene.indicators.values()) dr = unionRange(dr, this.drawingsRange(modelDrawingSet(m, true), vr, scene.offsetOf(m.id)));
25920
26513
  return dr;
25921
26514
  }
25922
26515
  /** Clear the chrome canvas and draw drawings + axes + current-price line.
@@ -25948,17 +26541,6 @@ var ChromeRenderer = class {
25948
26541
  return;
25949
26542
  }
25950
26543
  const pricePane = panes.find((p) => p.kind === "price") ?? null;
25951
- for (const pane of panes) {
25952
- if (pane.collapsed) continue;
25953
- for (const m of scene.indicatorsForPane(pane.id)) {
25954
- const sc = scene.scaleFor(m, pane);
25955
- const mp = sc === pane.scale ? pane : { ...pane, scale: sc };
25956
- this.renderDrawings(ctx, coords, this.ownDrawings(m), mp, dataW, scene.offsetOf(m.id));
25957
- }
25958
- }
25959
- if (pricePane) {
25960
- for (const m of scene.indicators.values()) this.renderDrawings(ctx, coords, this.overlayDrawings(m), pricePane, dataW, scene.offsetOf(m.id));
25961
- }
25962
26544
  if (pricePane && !pricePane.collapsed && scene.tradeMarkers.visible) {
25963
26545
  for (const m of scene.indicators.values()) {
25964
26546
  if (m.trades?.length) this.renderTrades(ctx, coords, scene, theme, m.trades, pricePane, dataW);
@@ -25974,25 +26556,6 @@ var ChromeRenderer = class {
25974
26556
  this.canvas = null;
25975
26557
  this.ctx = null;
25976
26558
  }
25977
- // ── Pine-drawing helpers (own vs force_overlay routing) ──
25978
- ownDrawings(m) {
25979
- return {
25980
- lines: (m.lines ?? []).filter((d) => !d.overlay),
25981
- boxes: (m.boxes ?? []).filter((d) => !d.overlay),
25982
- labels: (m.labels ?? []).filter((d) => !d.overlay),
25983
- polylines: (m.polylines ?? []).filter((d) => !d.overlay),
25984
- linefills: (m.linefills ?? []).filter((d) => !d.overlay)
25985
- };
25986
- }
25987
- overlayDrawings(m) {
25988
- return {
25989
- lines: (m.lines ?? []).filter((d) => d.overlay),
25990
- boxes: (m.boxes ?? []).filter((d) => d.overlay),
25991
- labels: (m.labels ?? []).filter((d) => d.overlay),
25992
- polylines: (m.polylines ?? []).filter((d) => d.overlay),
25993
- linefills: (m.linefills ?? []).filter((d) => d.overlay)
25994
- };
25995
- }
25996
26559
  drawingsRange(set, vr, indexOffset = 0) {
25997
26560
  this.drawScene.setSet(set, indexOffset);
25998
26561
  if (this.drawScene.isEmpty()) return null;
@@ -26026,23 +26589,6 @@ var ChromeRenderer = class {
26026
26589
  );
26027
26590
  ctx.restore();
26028
26591
  }
26029
- renderDrawings(ctx, coords, set, pane, dataW, indexOffset = 0) {
26030
- this.drawScene.setSet(set, indexOffset);
26031
- if (this.drawScene.isEmpty()) return;
26032
- ctx.save();
26033
- ctx.translate(0, pane.bounds.top);
26034
- ctx.beginPath();
26035
- ctx.rect(0, 0, dataW, pane.bounds.height);
26036
- ctx.clip();
26037
- this.drawScene.render(
26038
- ctx,
26039
- dataW,
26040
- pane.bounds.height,
26041
- (l) => coords.logicalToX(l),
26042
- (price) => coords.priceToY(price, pane.scale, pane.bounds) - pane.bounds.top
26043
- );
26044
- ctx.restore();
26045
- }
26046
26592
  // ── axes ──
26047
26593
  drawPriceAxes(ctx, scene, coords, theme, dataW, panes) {
26048
26594
  ctx.strokeStyle = scene.style.borderColor ?? theme.borderColor;
@@ -26258,6 +26804,68 @@ function formatCountdown(ms) {
26258
26804
  return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
26259
26805
  }
26260
26806
 
26807
+ // src/renderers/native/chrome/LabelTooltip.ts
26808
+ var HOVER_DELAY_MS = 350;
26809
+ var LabelTooltip = class {
26810
+ constructor(plot, deps) {
26811
+ this.plot = plot;
26812
+ this.deps = deps;
26813
+ this.tip = null;
26814
+ this.timer = null;
26815
+ /** Text of the currently open OR armed tip — dedupes moves inside one label. */
26816
+ this.current = null;
26817
+ this.onMove = (e) => {
26818
+ if (e.pointerType !== "mouse") return;
26819
+ const rect = this.plot.getBoundingClientRect();
26820
+ const x = e.clientX - rect.left;
26821
+ const y = e.clientY - rect.top;
26822
+ const text = this.deps.lookup(x, y);
26823
+ if (!text) {
26824
+ this.clear();
26825
+ return;
26826
+ }
26827
+ if (text === this.current) return;
26828
+ this.clear();
26829
+ this.current = text;
26830
+ this.timer = window.setTimeout(() => this.show(text, x, y), HOVER_DELAY_MS);
26831
+ };
26832
+ this.onLeave = () => {
26833
+ this.clear();
26834
+ };
26835
+ plot.addEventListener("pointermove", this.onMove);
26836
+ plot.addEventListener("pointerleave", this.onLeave);
26837
+ plot.addEventListener("pointerdown", this.onLeave);
26838
+ }
26839
+ destroy() {
26840
+ this.plot.removeEventListener("pointermove", this.onMove);
26841
+ this.plot.removeEventListener("pointerleave", this.onLeave);
26842
+ this.plot.removeEventListener("pointerdown", this.onLeave);
26843
+ this.clear();
26844
+ }
26845
+ show(text, x, y) {
26846
+ const doc = this.plot.ownerDocument;
26847
+ const tip = doc.createElement("div");
26848
+ tip.textContent = text;
26849
+ tip.style.cssText = "position:absolute;z-index:var(--vela-z-tooltip);pointer-events:none;background:var(--vela-bg);border:1px solid var(--vela-border);color:var(--vela-fg);border-radius:var(--vela-radius-md);padding:4px 9px;box-shadow:var(--vela-shadow);font:var(--vela-font-size-md) var(--vela-font);max-width:260px;";
26850
+ applyChromeTokens(tip, this.deps.theme());
26851
+ this.plot.appendChild(tip);
26852
+ const pw = this.plot.clientWidth;
26853
+ const ph = this.plot.clientHeight;
26854
+ tip.style.left = `${Math.max(0, Math.min(x + 12, pw - tip.offsetWidth - 4))}px`;
26855
+ tip.style.top = `${Math.max(0, Math.min(y + 16, ph - tip.offsetHeight - 4))}px`;
26856
+ this.tip = tip;
26857
+ }
26858
+ clear() {
26859
+ if (this.timer !== null) {
26860
+ clearTimeout(this.timer);
26861
+ this.timer = null;
26862
+ }
26863
+ this.tip?.remove();
26864
+ this.tip = null;
26865
+ this.current = null;
26866
+ }
26867
+ };
26868
+
26261
26869
  // src/renderers/native/chrome/CrosshairRenderer.ts
26262
26870
  var CrosshairRenderer = class {
26263
26871
  constructor() {
@@ -30299,17 +30907,17 @@ function glyphIcon(glyph) {
30299
30907
  return textGlyph(String(glyph), 15);
30300
30908
  }
30301
30909
  function stampSizeIcon(size) {
30302
- return textGlyph("\u25CF", (SIZE_PX4[String(size)] ?? 13) + 4);
30910
+ return textGlyph("\u25CF", (SIZE_PX3[String(size)] ?? 13) + 4);
30303
30911
  }
30304
30912
  function sizeIcon(size) {
30305
30913
  return textGlyph(String(size).charAt(0).toUpperCase(), 15);
30306
30914
  }
30307
- var SIZE_PX4 = { small: 10, normal: 13, large: 16, huge: 20 };
30915
+ var SIZE_PX3 = { small: 10, normal: 13, large: 16, huge: 20 };
30308
30916
  function numbersSizeIcon(size) {
30309
- return textGlyph("12", (SIZE_PX4[String(size)] ?? 13) - 1, 16.5, 'font-weight="600"');
30917
+ return textGlyph("12", (SIZE_PX3[String(size)] ?? 13) - 1, 16.5, 'font-weight="600"');
30310
30918
  }
30311
30919
  function labelSizeIcon(size) {
30312
- return textGlyph("T", (SIZE_PX4[String(size)] ?? 13) + 2);
30920
+ return textGlyph("T", (SIZE_PX3[String(size)] ?? 13) + 2);
30313
30921
  }
30314
30922
  function capitalize(s) {
30315
30923
  return s.charAt(0).toUpperCase() + s.slice(1);
@@ -31284,6 +31892,315 @@ var UserDrawingController = class {
31284
31892
  }
31285
31893
  };
31286
31894
 
31895
+ // src/renderers/shared/TableOverlay.ts
31896
+ var SIZE_PX4 = {
31897
+ auto: 13,
31898
+ tiny: 10,
31899
+ small: 11,
31900
+ normal: 13,
31901
+ large: 16,
31902
+ huge: 20
31903
+ };
31904
+ function fontPxOf(size) {
31905
+ if (typeof size === "number") return size > 0 ? size : SIZE_PX4.auto;
31906
+ return SIZE_PX4[size] ?? SIZE_PX4.auto;
31907
+ }
31908
+ function tableHasContent(t) {
31909
+ return t.cells.some((row) => row?.some((c) => c != null && !c.merged));
31910
+ }
31911
+ function mergeRenderPlan(t) {
31912
+ const span = /* @__PURE__ */ new Map();
31913
+ const omit = /* @__PURE__ */ new Set();
31914
+ for (const m of t.merges) {
31915
+ span.set(`${m.startRow}:${m.startCol}`, { cs: m.endCol - m.startCol + 1, rs: m.endRow - m.startRow + 1 });
31916
+ for (let r = m.startRow; r <= m.endRow; r += 1) {
31917
+ for (let c = m.startCol; c <= m.endCol; c += 1) {
31918
+ if (r !== m.startRow || c !== m.startCol) omit.add(`${r}:${c}`);
31919
+ }
31920
+ }
31921
+ }
31922
+ for (let r = 0; r < t.rows; r += 1) {
31923
+ for (let c = 0; c < t.columns; c += 1) {
31924
+ if (t.cells[r]?.[c]?.merged && !span.has(`${r}:${c}`)) omit.add(`${r}:${c}`);
31925
+ }
31926
+ }
31927
+ for (const key of span.keys()) omit.delete(key);
31928
+ return { span, omit };
31929
+ }
31930
+
31931
+ // src/renderers/shared/TableCanvasRenderer.ts
31932
+ var PAD_X = 6;
31933
+ var PAD_Y = 2;
31934
+ var MARGIN = 6;
31935
+ var LINE_HEIGHT = 1.2;
31936
+ function paintTable(ctx, t, args, tips) {
31937
+ if (!tableHasContent(t)) return;
31938
+ const layout = layoutTable(ctx, t, args);
31939
+ if (!layout || layout.w <= 0 || layout.h <= 0) return;
31940
+ const fw = t.frameColor && t.frameWidth > 0 ? t.frameWidth : 0;
31941
+ const { x, y } = anchorOrigin(t.position, layout.w + 2 * fw, layout.h + 2 * fw, args);
31942
+ const x0 = x + fw;
31943
+ const y0 = y + fw;
31944
+ if (t.bgColor) {
31945
+ ctx.fillStyle = t.bgColor;
31946
+ ctx.fillRect(x0, y0, layout.w, layout.h);
31947
+ }
31948
+ if (fw > 0 && t.frameColor) {
31949
+ ctx.strokeStyle = t.frameColor;
31950
+ ctx.lineWidth = fw;
31951
+ ctx.strokeRect(x + fw / 2, y + fw / 2, layout.w + fw, layout.h + fw);
31952
+ }
31953
+ const colX = [0];
31954
+ for (const w of layout.colW) colX.push(colX[colX.length - 1] + w);
31955
+ const rowY = [0];
31956
+ for (const h of layout.rowH) rowY.push(rowY[rowY.length - 1] + h);
31957
+ const prevBaseline = ctx.textBaseline;
31958
+ const prevAlign = ctx.textAlign;
31959
+ ctx.textBaseline = "middle";
31960
+ for (const box of layout.boxes) {
31961
+ const rx = x0 + colX[box.c];
31962
+ const ry = y0 + rowY[box.r];
31963
+ const rw = colX[box.c + box.cs] - colX[box.c];
31964
+ const rh = rowY[box.r + box.rs] - rowY[box.r];
31965
+ const cell = box.cell;
31966
+ if (cell.bgColor) {
31967
+ ctx.fillStyle = cell.bgColor;
31968
+ ctx.fillRect(rx, ry, rw, rh);
31969
+ }
31970
+ const text = cell.text ?? "";
31971
+ if (text.length > 0) {
31972
+ const px = fontPxOf(cell.textSize);
31973
+ ctx.font = cellFont(cell, px, args.theme);
31974
+ ctx.fillStyle = cell.textColor ?? args.theme.textColor;
31975
+ const lines = text.split("\n");
31976
+ const blockH = lines.length * px * LINE_HEIGHT;
31977
+ const blockTop = cell.vAlign === "top" ? ry + PAD_Y : cell.vAlign === "bottom" ? ry + rh - PAD_Y - blockH : ry + (rh - blockH) / 2;
31978
+ const tx = cell.hAlign === "left" ? rx + PAD_X : cell.hAlign === "right" ? rx + rw - PAD_X : rx + rw / 2;
31979
+ ctx.textAlign = cell.hAlign;
31980
+ lines.forEach((line, i) => ctx.fillText(line, tx, blockTop + (i + 0.5) * px * LINE_HEIGHT));
31981
+ }
31982
+ if (cell.tooltip) tips.push({ left: rx, top: ry, right: rx + rw, bottom: ry + rh, text: cell.tooltip });
31983
+ }
31984
+ ctx.textBaseline = prevBaseline;
31985
+ ctx.textAlign = prevAlign;
31986
+ if (t.borderColor && t.borderWidth > 0) {
31987
+ ctx.strokeStyle = t.borderColor;
31988
+ ctx.lineWidth = t.borderWidth;
31989
+ const seen = /* @__PURE__ */ new Set();
31990
+ ctx.beginPath();
31991
+ const edge = (ax, ay, bx, by) => {
31992
+ const key = `${ax},${ay},${bx},${by}`;
31993
+ if (seen.has(key)) return;
31994
+ seen.add(key);
31995
+ ctx.moveTo(ax, ay);
31996
+ ctx.lineTo(bx, by);
31997
+ };
31998
+ for (const box of layout.boxes) {
31999
+ const l = Math.round(x0 + colX[box.c]);
32000
+ const r = Math.round(x0 + colX[box.c + box.cs]);
32001
+ const tp = Math.round(y0 + rowY[box.r]);
32002
+ const bt = Math.round(y0 + rowY[box.r + box.rs]);
32003
+ edge(l, tp, r, tp);
32004
+ edge(l, bt, r, bt);
32005
+ edge(l, tp, l, bt);
32006
+ edge(r, tp, r, bt);
32007
+ }
32008
+ ctx.stroke();
32009
+ }
32010
+ }
32011
+ function layoutTable(ctx, t, args) {
32012
+ const { span, omit } = mergeRenderPlan(t);
32013
+ const colW = new Array(t.columns).fill(0);
32014
+ const rowH = new Array(t.rows).fill(0);
32015
+ const boxes = [];
32016
+ for (let r = 0; r < t.rows; r += 1) {
32017
+ for (let c = 0; c < t.columns; c += 1) {
32018
+ if (omit.has(`${r}:${c}`)) continue;
32019
+ const cell = t.cells[r]?.[c];
32020
+ if (cell == null) continue;
32021
+ const sp = span.get(`${r}:${c}`);
32022
+ boxes.push({ cell, r, c, cs: Math.min(sp?.cs ?? 1, t.columns - c), rs: Math.min(sp?.rs ?? 1, t.rows - r) });
32023
+ }
32024
+ }
32025
+ if (boxes.length === 0) return null;
32026
+ const sizeOf = (cell) => {
32027
+ const px = fontPxOf(cell.textSize);
32028
+ ctx.font = cellFont(cell, px, args.theme);
32029
+ const lines = (cell.text ?? "").split("\n");
32030
+ let maxW = 0;
32031
+ for (const line of lines) maxW = Math.max(maxW, ctx.measureText(line).width);
32032
+ let w2 = Math.ceil(maxW) + 2 * PAD_X;
32033
+ let h2 = Math.ceil(lines.length * px * LINE_HEIGHT) + 2 * PAD_Y;
32034
+ if (cell.width) w2 = Math.max(w2, cell.width / 100 * args.plotWidth);
32035
+ if (cell.height) h2 = Math.max(h2, cell.height / 100 * args.paneHeight);
32036
+ return { w: w2, h: h2 };
32037
+ };
32038
+ const spanning = [];
32039
+ for (const box of boxes) {
32040
+ const { w: w2, h: h2 } = sizeOf(box.cell);
32041
+ if (box.cs === 1) colW[box.c] = Math.max(colW[box.c], w2);
32042
+ if (box.rs === 1) rowH[box.r] = Math.max(rowH[box.r], h2);
32043
+ if (box.cs > 1 || box.rs > 1) spanning.push({ box, w: w2, h: h2 });
32044
+ }
32045
+ for (const { box, w: w2, h: h2 } of spanning) {
32046
+ if (box.cs > 1) {
32047
+ let sum = 0;
32048
+ for (let c = box.c; c < box.c + box.cs; c += 1) sum += colW[c];
32049
+ if (w2 > sum) for (let c = box.c; c < box.c + box.cs; c += 1) colW[c] += (w2 - sum) / box.cs;
32050
+ }
32051
+ if (box.rs > 1) {
32052
+ let sum = 0;
32053
+ for (let r = box.r; r < box.r + box.rs; r += 1) sum += rowH[r];
32054
+ if (h2 > sum) for (let r = box.r; r < box.r + box.rs; r += 1) rowH[r] += (h2 - sum) / box.rs;
32055
+ }
32056
+ }
32057
+ let w = 0;
32058
+ for (const cw of colW) w += cw;
32059
+ let h = 0;
32060
+ for (const rh of rowH) h += rh;
32061
+ return { colW, rowH, w, h, boxes };
32062
+ }
32063
+ function anchorOrigin(position, totalW, totalH, args) {
32064
+ let y;
32065
+ if (position.startsWith("top")) y = MARGIN;
32066
+ else if (position.startsWith("bottom")) y = args.paneHeight - MARGIN - totalH;
32067
+ else y = args.paneHeight / 2 - totalH / 2;
32068
+ let x;
32069
+ if (position.endsWith("left")) x = MARGIN;
32070
+ else if (position.endsWith("right")) x = args.plotWidth - MARGIN - totalW;
32071
+ else x = args.plotWidth / 2 - totalW / 2;
32072
+ return { x, y };
32073
+ }
32074
+ function cellFont(cell, px, theme) {
32075
+ const family = cell.fontFamily === "monospace" ? "monospace" : theme.fontFamily || "sans-serif";
32076
+ return `${cell.italic ? "italic " : ""}${cell.bold ? "bold " : ""}${px}px ${family}`;
32077
+ }
32078
+
32079
+ // src/renderers/native/drawings/IndicatorDrawingSlices.ts
32080
+ function indicatorSliceKey(z, boundaries) {
32081
+ return boundaries.find((b) => b > z) ?? Infinity;
32082
+ }
32083
+ var IndicatorDrawingSlices = class {
32084
+ constructor() {
32085
+ this.drawScene = new DrawingSceneRenderer({ timeToLogical: () => 0, barAt: () => null, theme: {} });
32086
+ /** Slice canvas cache, keyed `paneId|beforeZ` — same lifecycle as the user-drawing cache. */
32087
+ this.sliceCache = /* @__PURE__ */ new Map();
32088
+ /** Tooltip hit-rects of every label drawn this frame, in plot coords (rebuilt per prepare). */
32089
+ this.tips = [];
32090
+ }
32091
+ /**
32092
+ * Rebuild the per-indicator drawing slices for this data frame. `ref` is the data
32093
+ * canvas the slices must match pixel-for-pixel (the backend composites them 1:1).
32094
+ * Runs from the renderer's data paint, just before the backend composites the scene.
32095
+ */
32096
+ prepare(scene, coords, theme, ref) {
32097
+ this.tips = [];
32098
+ const out = /* @__PURE__ */ new Map();
32099
+ if (ref.width === 0 || ref.height === 0) {
32100
+ this.sliceCache.clear();
32101
+ return out;
32102
+ }
32103
+ this.drawScene.setDeps({
32104
+ timeToLogical: (ms) => coords.timeToLogical(ms),
32105
+ barAt: (logical) => {
32106
+ const b = scene.bars[Math.round(logical)];
32107
+ return b ? { high: b.high, low: b.low } : null;
32108
+ },
32109
+ theme
32110
+ });
32111
+ const dpr = coords.dpr;
32112
+ const dataW = coords.width;
32113
+ const buckets = /* @__PURE__ */ new Map();
32114
+ const add = (paneId, beforeZ, entry) => {
32115
+ const key = `${paneId}|${beforeZ}`;
32116
+ const bucket = buckets.get(key);
32117
+ if (bucket) bucket.entries.push(entry);
32118
+ else buckets.set(key, { paneId, beforeZ, entries: [entry] });
32119
+ };
32120
+ for (const pane of scene.orderedPanes()) {
32121
+ if (pane.collapsed) continue;
32122
+ const boundaries = scene.seriesBoundaries(pane.id);
32123
+ for (const m of scene.orderedIndicatorsForPane(pane.id)) {
32124
+ const set = modelDrawingSet(m, false);
32125
+ const tables = (m.tables ?? []).filter((t) => !t.overlay);
32126
+ if (drawingSetEmpty(set) && tables.length === 0) continue;
32127
+ const sc = scene.scaleFor(m, pane);
32128
+ const mp = sc === pane.scale ? pane : { ...pane, scale: sc };
32129
+ const beforeZ = indicatorSliceKey(scene.zOf(m.id), boundaries);
32130
+ add(pane.id, beforeZ, { set, tables, pane: mp, indexOffset: scene.offsetOf(m.id) });
32131
+ }
32132
+ if (pane.kind === "price") {
32133
+ for (const m of scene.indicators.values()) {
32134
+ const set = modelDrawingSet(m, true);
32135
+ const tables = (m.tables ?? []).filter((t) => t.overlay === true);
32136
+ if (drawingSetEmpty(set) && tables.length === 0) continue;
32137
+ add(pane.id, Infinity, { set, tables, pane, indexOffset: scene.offsetOf(m.id) });
32138
+ }
32139
+ }
32140
+ }
32141
+ for (const [key, { paneId, beforeZ, entries }] of buckets) {
32142
+ let canvas = this.sliceCache.get(key);
32143
+ if (!canvas) {
32144
+ canvas = document.createElement("canvas");
32145
+ this.sliceCache.set(key, canvas);
32146
+ }
32147
+ if (canvas.width !== ref.width || canvas.height !== ref.height) {
32148
+ canvas.width = ref.width;
32149
+ canvas.height = ref.height;
32150
+ }
32151
+ const ctx = canvas.getContext("2d");
32152
+ if (!ctx) continue;
32153
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
32154
+ ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
32155
+ for (const e of entries) this.paintEntry(ctx, e, coords, dataW, theme);
32156
+ const slices = out.get(paneId) ?? [];
32157
+ slices.push({ beforeZ, canvas });
32158
+ out.set(paneId, slices);
32159
+ }
32160
+ for (const key of [...this.sliceCache.keys()]) if (!buckets.has(key)) this.sliceCache.delete(key);
32161
+ for (const slices of out.values()) slices.sort((a, b) => a.beforeZ - b.beforeZ);
32162
+ return out;
32163
+ }
32164
+ paintEntry(ctx, e, coords, dataW, theme) {
32165
+ const { pane } = e;
32166
+ const paneTips = [];
32167
+ ctx.save();
32168
+ ctx.translate(0, pane.bounds.top);
32169
+ ctx.beginPath();
32170
+ ctx.rect(0, 0, dataW, pane.bounds.height);
32171
+ ctx.clip();
32172
+ this.drawScene.setSet(e.set, e.indexOffset);
32173
+ this.drawScene.render(
32174
+ ctx,
32175
+ dataW,
32176
+ pane.bounds.height,
32177
+ (l) => coords.logicalToX(l),
32178
+ (price) => coords.priceToY(price, pane.scale, pane.bounds) - pane.bounds.top
32179
+ );
32180
+ paneTips.push(...this.drawScene.labelTipRegions());
32181
+ for (const t of e.tables) paintTable(ctx, t, { paneHeight: pane.bounds.height, plotWidth: dataW, theme }, paneTips);
32182
+ ctx.restore();
32183
+ for (const r of paneTips) {
32184
+ this.tips.push({ ...r, top: r.top + pane.bounds.top, bottom: r.bottom + pane.bounds.top });
32185
+ }
32186
+ }
32187
+ /** Tooltip of the topmost label or table cell under a plot-space point, or null. Fed by the last prepare. */
32188
+ labelTooltipAt(x, y) {
32189
+ for (let i = this.tips.length - 1; i >= 0; i -= 1) {
32190
+ const r = this.tips[i];
32191
+ if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return r.text;
32192
+ }
32193
+ return null;
32194
+ }
32195
+ };
32196
+ function mergeSlices(indicator, user) {
32197
+ const out = /* @__PURE__ */ new Map();
32198
+ for (const [paneId, slices] of indicator) out.set(paneId, [...slices]);
32199
+ for (const [paneId, slices] of user) out.set(paneId, [...out.get(paneId) ?? [], ...slices]);
32200
+ for (const slices of out.values()) slices.sort((a, b) => a.beforeZ - b.beforeZ);
32201
+ return out;
32202
+ }
32203
+
31287
32204
  // src/renderers/native/drawings/Projector.ts
31288
32205
  function createProjector(coords, paneOf, paneIdAtY, barsInRange) {
31289
32206
  return {
@@ -31335,19 +32252,8 @@ function computePaneScale(models, bars, includeCandles, i0, i1, drawings, log =
31335
32252
  for (const model of models) {
31336
32253
  const off = offsetOf(model.id);
31337
32254
  for (const s of model.series) {
31338
- if (s.kind === "candle" || s.kind === "bar") {
31339
- for (let i = i0; i <= i1; i += 1) {
31340
- const b = s.bars[i - off];
31341
- if (b) {
31342
- consider(b.high);
31343
- consider(b.low);
31344
- }
31345
- }
31346
- } else if (isLineLikeSeries(s)) {
31347
- for (let i = i0; i <= i1; i += 1) consider(s.points[i - off]?.value);
31348
- if (s.kind === "histogram" || s.kind === "columns") consider(s.style?.base ?? 0);
31349
- else if (s.style?.base != null) consider(s.style.base);
31350
- }
32255
+ if (s.overlay === true) continue;
32256
+ considerSeries(s, i0, i1, off, consider);
31351
32257
  }
31352
32258
  for (const pl of model.priceLines) consider(pl.price);
31353
32259
  }
@@ -31369,6 +32275,36 @@ function computePaneScale(models, bars, includeCandles, i0, i1, drawings, log =
31369
32275
  const span = max - min;
31370
32276
  return { min: min - span * MARGIN_BOTTOM, max: max + span * MARGIN_TOP };
31371
32277
  }
32278
+ function considerSeries(s, i0, i1, off, consider) {
32279
+ if (s.kind === "candle" || s.kind === "bar") {
32280
+ for (let i = i0; i <= i1; i += 1) {
32281
+ const b = s.bars[i - off];
32282
+ if (b) {
32283
+ consider(b.high);
32284
+ consider(b.low);
32285
+ }
32286
+ }
32287
+ } else if (isLineLikeSeries(s)) {
32288
+ for (let i = i0; i <= i1; i += 1) consider(s.points[i - off]?.value);
32289
+ if (s.kind === "histogram" || s.kind === "columns") consider(s.style?.base ?? 0);
32290
+ else if (s.style?.base != null) consider(s.style.base);
32291
+ }
32292
+ }
32293
+ function overlaySeriesRange(models, i0, i1, offsetOf = () => 0) {
32294
+ let min = Infinity;
32295
+ let max = -Infinity;
32296
+ const consider = (v) => {
32297
+ if (v != null && Number.isFinite(v)) {
32298
+ if (v < min) min = v;
32299
+ if (v > max) max = v;
32300
+ }
32301
+ };
32302
+ for (const model of models) {
32303
+ const off = offsetOf(model.id);
32304
+ for (const s of model.series) if (s.overlay === true) considerSeries(s, i0, i1, off, consider);
32305
+ }
32306
+ return min === Infinity ? null : { min, max };
32307
+ }
31372
32308
  function expandScaleByPixels(scale, heightPx, abovePx, belowPx) {
31373
32309
  if (abovePx <= 0 && belowPx <= 0) return scale;
31374
32310
  const content = heightPx - abovePx - belowPx;
@@ -32010,6 +32946,11 @@ var NativeRenderer = class {
32010
32946
  this.vpvrRenderer = new VpvrRenderer();
32011
32947
  this.resizeObserver = null;
32012
32948
  this.dprMedia = null;
32949
+ /** Plot size in INTEGER device px, as last reported by the resize observer's
32950
+ * device-pixel-content-box — the browser's own statement of how many device pixels
32951
+ * it paints the plot into. `null` until the first report or where the box type is
32952
+ * unsupported (WebKit); syncSize then falls back to rounding the client rect. */
32953
+ this.plotDeviceSize = null;
32013
32954
  this.coords = new CoordinateSystem();
32014
32955
  this.scene = new SceneGraph();
32015
32956
  // chosen at mount (WebGL2 if available, else canvas2d)
@@ -32017,6 +32958,10 @@ var NativeRenderer = class {
32017
32958
  this.glowAmount = 0;
32018
32959
  // WebGL2 neon-glow intensity (canvas2d ignores it)
32019
32960
  this.chrome = new ChromeRenderer();
32961
+ /** Prepaints each indicator's Pine drawings into interleave slices at the model's z. */
32962
+ this.indicatorSlices = new IndicatorDrawingSlices();
32963
+ /** Hover tooltips for Pine labels (canvas hit-rects collected by the chrome layer). */
32964
+ this.labelTooltip = null;
32020
32965
  this.crosshairLayer = new CrosshairRenderer();
32021
32966
  /** 1 Hz repaint pump so the price-axis countdown-to-bar-close ticks; null when off. */
32022
32967
  this.countdownTimer = null;
@@ -32027,6 +32972,8 @@ var NativeRenderer = class {
32027
32972
  this.indicatorValuesOn = true;
32028
32973
  /** Host-contributed legend actions — held here so a rebuild of the legend re-wires them. */
32029
32974
  this.legendActionsProvider = null;
32975
+ /** Host-contributed legend callouts — held here so a rebuild of the legend re-wires them. */
32976
+ this.legendCalloutsProvider = null;
32030
32977
  /** Host override of the legend's fold toggle — held here so a remount re-applies it. */
32031
32978
  this.legendOverviewAction = null;
32032
32979
  // ── keyboard navigation / accessibility (item 11) ──
@@ -32161,7 +33108,6 @@ var NativeRenderer = class {
32161
33108
  this.toggleVisibleCbs = /* @__PURE__ */ new Set();
32162
33109
  this.moveIndicatorCbs = /* @__PURE__ */ new Set();
32163
33110
  this.priceStyleCbs = /* @__PURE__ */ new Set();
32164
- this.tableOverlays = /* @__PURE__ */ new Map();
32165
33111
  this.name = "native";
32166
33112
  this.features = ["logScale", "currentPriceLine", "priceLabel", "countdown", "upColor", "downColor", "glow", "animZoom", "animPan", "intro", "zoomAnchor", "axisDrag", "paneResize", "candleZOrder", "candleVisible", "seriesOrder", "highlights", "sessionZones", "gridlines", "axisLabels", "scaleMode", "invertScale", "paneScales", "autoScale", "timezone", "keyboard", "historyChords", "priceStyle", "priceBaseline", "baselinePrice", "settings", "attribution", "dialogHost", "tradeMarkers", "indicatorTitles", "indicatorValues"];
32167
33113
  /** Track cursor proximity to the scroll button on the plot (bubbles from the button too,
@@ -32475,7 +33421,6 @@ var NativeRenderer = class {
32475
33421
  * hidden) on clear so a re-show picks up the current theme.
32476
33422
  */
32477
33423
  setLoading(loading) {
32478
- for (const overlay of this.tableOverlays.values()) overlay.setVisible(!loading);
32479
33424
  if (!loading || !this.wrapper) {
32480
33425
  this.loadingEl?.remove();
32481
33426
  this.loadingEl = null;
@@ -33135,6 +34080,10 @@ var NativeRenderer = class {
33135
34080
  this.plot.appendChild(this.scrollButton);
33136
34081
  this.plot.addEventListener("pointermove", this.onScrollProximityMove);
33137
34082
  this.plot.addEventListener("pointerleave", this.onScrollProximityLeave);
34083
+ this.labelTooltip = new LabelTooltip(this.plot, {
34084
+ theme: () => this.chromeTheme(),
34085
+ lookup: (x, y) => this.indicatorSlices.labelTooltipAt(x, y)
34086
+ });
33138
34087
  this.userDrawings = new UserDrawingController(this.wrapper, this.plot, this.drawingsCanvas, {
33139
34088
  projector: () => this.drawingProjector(),
33140
34089
  dpr: () => this.coords.dpr,
@@ -33174,9 +34123,10 @@ var NativeRenderer = class {
33174
34123
  this.inputsUI.setDialogHost(this.dialogHost);
33175
34124
  this.inputsUI.setSymbolPicker(this.symbolPicker);
33176
34125
  this.inputsUI.setLegendActions(this.legendActionsProvider);
34126
+ this.inputsUI.setLegendCallouts(this.legendCalloutsProvider);
33177
34127
  this.inputsUI.setLegendOverviewAction(this.legendOverviewAction);
33178
34128
  this.inputsUI.setOnChange((c) => {
33179
- for (const cb of this.inputChangeCbs) cb({ indicatorId: c.indicatorId, key: c.key, value: c.value });
34129
+ for (const cb of this.inputChangeCbs) cb({ indicatorId: c.indicatorId, key: c.key, value: c.value, ...c.kind ? { kind: c.kind } : {} });
33180
34130
  });
33181
34131
  this.inputsUI.setOnRemove((id) => {
33182
34132
  for (const cb of this.removeIndicatorCbs) cb(id);
@@ -33217,6 +34167,7 @@ var NativeRenderer = class {
33217
34167
  this.emitPaneAction({ type: "maximize", paneId, maximized });
33218
34168
  }
33219
34169
  });
34170
+ this.paneControls.setSuspended(this.layoutMode === "mobile");
33220
34171
  this.axisScaleButtons = new AxisScaleButtons(this.plot, theme, {
33221
34172
  panes: () => this.axisScaleViews(),
33222
34173
  rightAxis: () => this.rightAxisW,
@@ -33226,8 +34177,19 @@ var NativeRenderer = class {
33226
34177
  if (pane) this.setPaneLog(paneId, !paneLogScale(this.scene, pane));
33227
34178
  }
33228
34179
  });
33229
- this.resizeObserver = new ResizeObserver(() => this.resize());
34180
+ this.resizeObserver = new ResizeObserver((entries) => {
34181
+ for (const e of entries) {
34182
+ if (e.target !== this.plot) continue;
34183
+ const s = e.devicePixelContentBoxSize?.[0];
34184
+ if (s) this.plotDeviceSize = { width: s.inlineSize, height: s.blockSize };
34185
+ }
34186
+ this.resize();
34187
+ });
33230
34188
  this.resizeObserver.observe(this.wrapper);
34189
+ try {
34190
+ this.resizeObserver.observe(this.plot, { box: "device-pixel-content-box" });
34191
+ } catch {
34192
+ }
33231
34193
  this.watchDpr();
33232
34194
  this.syncSize();
33233
34195
  }
@@ -33352,8 +34314,6 @@ var NativeRenderer = class {
33352
34314
  this.inputsUI?.destroy();
33353
34315
  this.paneControls?.destroy();
33354
34316
  this.axisScaleButtons?.destroy();
33355
- for (const overlay of this.tableOverlays.values()) overlay.destroy();
33356
- this.tableOverlays.clear();
33357
34317
  this.resizeObserver?.disconnect();
33358
34318
  this.resizeObserver = null;
33359
34319
  this.dprMedia?.removeEventListener("change", this.onDprChange);
@@ -33366,6 +34326,8 @@ var NativeRenderer = class {
33366
34326
  this.settingsButton = null;
33367
34327
  this.plot?.removeEventListener("pointermove", this.onScrollProximityMove);
33368
34328
  this.plot?.removeEventListener("pointerleave", this.onScrollProximityLeave);
34329
+ this.labelTooltip?.destroy();
34330
+ this.labelTooltip = null;
33369
34331
  this.scrollButton?.remove();
33370
34332
  this.scrollButton = null;
33371
34333
  for (const l of this.extLayers) l.instance.destroy?.();
@@ -33380,6 +34342,7 @@ var NativeRenderer = class {
33380
34342
  this.attributionEl = null;
33381
34343
  this.mountContainer?.style.removeProperty("--vela-toolbar-gutter");
33382
34344
  this.mountContainer?.style.removeProperty("--vela-scale-gutter");
34345
+ this.mountContainer?.style.removeProperty("--vela-bottom-gutter");
33383
34346
  this.mountContainer?.style.removeProperty("--vela-price-pane-top");
33384
34347
  this.mountContainer?.style.removeProperty("--vela-price-pane-bottom");
33385
34348
  this.mountContainer = null;
@@ -33472,7 +34435,6 @@ var NativeRenderer = class {
33472
34435
  ensurePane(pane) {
33473
34436
  this.scene.ensurePane(pane.id, pane.kind, pane.order, pane.heightWeight ?? (pane.kind === "price" ? 3 : 1));
33474
34437
  this.layoutPanes();
33475
- this.repositionTables();
33476
34438
  this.paneControls?.refresh();
33477
34439
  this.scheduler.invalidate(4 /* Full */);
33478
34440
  }
@@ -33494,17 +34456,14 @@ var NativeRenderer = class {
33494
34456
  if (!model.ownScale) this.scene.dropIndicatorScale(handle.id);
33495
34457
  this.inputsUI.setPane(handle.id, paneId);
33496
34458
  this.refreshAnchorOffset(model);
33497
- this.syncTables(model);
33498
34459
  this.refreshAxisWidth();
33499
34460
  this.layoutPanes();
33500
- this.repositionTables();
33501
34461
  this.paneControls?.refresh();
33502
34462
  this.scheduler.invalidate(4 /* Full */);
33503
34463
  }
33504
34464
  orderPanes(orderedIds) {
33505
34465
  this.scene.orderPanes(orderedIds);
33506
34466
  this.layoutPanes();
33507
- this.repositionTables();
33508
34467
  this.paneControls?.refresh();
33509
34468
  this.scheduler.invalidate(4 /* Full */);
33510
34469
  }
@@ -33513,7 +34472,6 @@ var NativeRenderer = class {
33513
34472
  if (!pane || pane.collapsed === collapsed) return;
33514
34473
  pane.collapsed = collapsed;
33515
34474
  this.layoutPanes();
33516
- this.repositionTables();
33517
34475
  this.paneControls?.refresh();
33518
34476
  this.scheduler.invalidate(4 /* Full */);
33519
34477
  }
@@ -33521,7 +34479,6 @@ var NativeRenderer = class {
33521
34479
  if (paneId !== null && !this.scene.panes.has(paneId)) paneId = null;
33522
34480
  this.maximizedPaneId = paneId;
33523
34481
  this.layoutPanes();
33524
- this.repositionTables();
33525
34482
  this.paneControls?.refresh();
33526
34483
  this.scheduler.invalidate(4 /* Full */);
33527
34484
  }
@@ -33606,9 +34563,8 @@ var NativeRenderer = class {
33606
34563
  else this.scene.assignIndicatorZ(model.id);
33607
34564
  this.inputsUI.upsert(model.id, model.shorttitle ?? model.title, model.inputs, model.inputValues, model.paneId, {
33608
34565
  native: !!model.native,
33609
- ...model.shorttitle ? { settingsTitle: model.title } : {}
34566
+ ...model.props ? { props: model.props, propValues: model.propValues ?? {} } : {}
33610
34567
  });
33611
- this.syncTables(model);
33612
34568
  if (model.native?.type === "volume") {
33613
34569
  this.volumeActive = true;
33614
34570
  this.volumeHidden = false;
@@ -33632,7 +34588,6 @@ var NativeRenderer = class {
33632
34588
  }
33633
34589
  }
33634
34590
  applyPatch(model, patch);
33635
- this.syncTables(model);
33636
34591
  this.scheduler.invalidate(3 /* Light */);
33637
34592
  }
33638
34593
  removeIndicator(handle) {
@@ -33651,14 +34606,12 @@ var NativeRenderer = class {
33651
34606
  this.scene.forgetAnchorOffset(handle.id);
33652
34607
  this.scene.dropIndicatorScale(handle.id);
33653
34608
  this.inputsUI.remove(handle.id);
33654
- this.tableOverlays.get(handle.id)?.destroy();
33655
- this.tableOverlays.delete(handle.id);
33656
34609
  this.refreshAxisWidth();
33657
34610
  this.paneControls?.refresh();
33658
34611
  this.scheduler.invalidate(4 /* Full */);
33659
34612
  }
33660
- setIndicatorInputs(handle, values) {
33661
- this.inputsUI.setValues(handle.id, values);
34613
+ setIndicatorInputs(handle, values, props) {
34614
+ this.inputsUI.setValues(handle.id, values, props);
33662
34615
  }
33663
34616
  setSymbolPicker(picker) {
33664
34617
  this.symbolPicker = picker;
@@ -33668,6 +34621,10 @@ var NativeRenderer = class {
33668
34621
  this.legendActionsProvider = provider;
33669
34622
  this.inputsUI?.setLegendActions(provider);
33670
34623
  }
34624
+ setLegendCallouts(provider) {
34625
+ this.legendCalloutsProvider = provider;
34626
+ this.inputsUI?.setLegendCallouts(provider);
34627
+ }
33671
34628
  setLegendOverviewAction(action) {
33672
34629
  this.legendOverviewAction = action;
33673
34630
  this.inputsUI?.setLegendOverviewAction(action);
@@ -33692,8 +34649,6 @@ var NativeRenderer = class {
33692
34649
  }
33693
34650
  if (!visible) {
33694
34651
  this.scene.indicators.delete(handle.id);
33695
- this.tableOverlays.get(handle.id)?.destroy();
33696
- this.tableOverlays.delete(handle.id);
33697
34652
  }
33698
34653
  this.inputsUI.setVisible(handle.id, visible);
33699
34654
  this.scheduler.invalidate(4 /* Full */);
@@ -33739,6 +34694,7 @@ var NativeRenderer = class {
33739
34694
  this.userDrawings?.setLayoutMode(mode);
33740
34695
  this.settingsDialog?.setLayoutMode(mode);
33741
34696
  this.inputsUI?.setLayoutMode(mode);
34697
+ this.paneControls?.setSuspended(mode === "mobile");
33742
34698
  if (this.scrollButton) {
33743
34699
  const px = mode === "mobile" ? SCROLL_BTN_SIZE_TOUCH : SCROLL_BTN_SIZE;
33744
34700
  this.scrollButton.style.width = `${px}px`;
@@ -34134,7 +35090,6 @@ var NativeRenderer = class {
34134
35090
  /** Relayout + repaint + refresh the hover buttons after a collapse/maximize/order change. */
34135
35091
  afterPaneLayoutChange() {
34136
35092
  this.layoutPanes();
34137
- this.repositionTables();
34138
35093
  this.paneControls?.refresh();
34139
35094
  this.scheduler.invalidate(4 /* Full */);
34140
35095
  }
@@ -34215,7 +35170,6 @@ var NativeRenderer = class {
34215
35170
  above.heightWeight = next.above;
34216
35171
  below.heightWeight = next.below;
34217
35172
  this.layoutPanes();
34218
- this.repositionTables();
34219
35173
  this.scheduler.invalidate(4 /* Full */);
34220
35174
  }
34221
35175
  /** Double-click a separator → split the two adjacent panes evenly (each gets half of
@@ -34230,7 +35184,6 @@ var NativeRenderer = class {
34230
35184
  above.heightWeight = half;
34231
35185
  below.heightWeight = half;
34232
35186
  this.layoutPanes();
34233
- this.repositionTables();
34234
35187
  this.scheduler.invalidate(4 /* Full */);
34235
35188
  }
34236
35189
  // ── keyboard navigation / accessibility (item 11) ──
@@ -34509,7 +35462,10 @@ var NativeRenderer = class {
34509
35462
  const liveActual = li >= 0 ? this.bars[li] : void 0;
34510
35463
  const easeLive = !!liveActual && this.liveEaseTime === liveActual.time && (liveActual.high !== this.liveEaseHigh || liveActual.low !== this.liveEaseLow || liveActual.close !== this.liveEaseClose);
34511
35464
  if (easeLive && liveActual) this.bars[li] = { ...liveActual, high: this.liveEaseHigh, low: this.liveEaseLow, close: this.liveEaseClose };
34512
- this.scene.drawingSlices = this.userDrawings?.prepareSlices(this.scene.orderedPanes().map((p) => p.id)) ?? /* @__PURE__ */ new Map();
35465
+ this.scene.drawingSlices = mergeSlices(
35466
+ this.indicatorSlices.prepare(this.scene, this.coords, this.theme, this.dataCanvas),
35467
+ this.userDrawings?.prepareSlices(this.scene.orderedPanes().map((p) => p.id)) ?? /* @__PURE__ */ new Map()
35468
+ );
34513
35469
  this.backdropRenderer.render(this.scene, this.coords, this.theme, gridAlpha);
34514
35470
  this.backend.render(this.scene, this.coords, this.theme);
34515
35471
  this.chrome.render(this.scene, this.coords, this.theme, this.axisSurface());
@@ -34648,7 +35604,11 @@ var NativeRenderer = class {
34648
35604
  }
34649
35605
  const models = this.scene.indicatorsForPane(pane.id);
34650
35606
  const masterModels = models.filter((m) => m.ownScale !== true);
34651
- const dr = this.chrome.paneDrawingsRange(masterModels, this.scene, pane === pricePane, vr);
35607
+ let dr = this.chrome.paneDrawingsRange(masterModels, this.scene, pane === pricePane, vr);
35608
+ if (pane === pricePane) {
35609
+ const or = overlaySeriesRange(this.scene.indicators.values(), i0, i1, (id) => this.scene.offsetOf(id));
35610
+ if (or) dr = dr ? { min: Math.min(dr.min, or.min), max: Math.max(dr.max, or.max) } : or;
35611
+ }
34652
35612
  const includeCandles = pane.kind === "price" && !this.scene.candlesHidden;
34653
35613
  pane.scaleTarget = computePaneScale(masterModels, this.bars, includeCandles, i0, i1, dr, paneLogScale(this.scene, pane), (id) => this.scene.offsetOf(id));
34654
35614
  pane.percentBaseline = pane.kind === "price" ? this.bars[i0]?.close ?? 0 : this.firstVisibleValue(masterModels, i0);
@@ -34922,6 +35882,7 @@ var NativeRenderer = class {
34922
35882
  for (const m of models) {
34923
35883
  const off = this.scene.offsetOf(m.id);
34924
35884
  for (const s of m.series) {
35885
+ if (s.overlay === true) continue;
34925
35886
  if (isLineLikeSeries(s)) {
34926
35887
  const v = s.points[i0 - off]?.value;
34927
35888
  if (v != null && Number.isFinite(v)) return v;
@@ -34942,27 +35903,6 @@ var NativeRenderer = class {
34942
35903
  }
34943
35904
  return maxVol;
34944
35905
  }
34945
- /** Create/update/destroy an indicator's DOM table overlay (anchored off real pane geometry). */
34946
- syncTables(model) {
34947
- const tables = model.tables ?? [];
34948
- let overlay = this.tableOverlays.get(model.id);
34949
- if (tables.length === 0) {
34950
- if (overlay) {
34951
- overlay.destroy();
34952
- this.tableOverlays.delete(model.id);
34953
- }
34954
- return;
34955
- }
34956
- if (!overlay) {
34957
- overlay = new TableOverlay(this.plot, this.theme, (id) => this.paneBoundsFor(id));
34958
- overlay.setVisible(this.loadingEl === null);
34959
- this.tableOverlays.set(model.id, overlay);
34960
- }
34961
- overlay.update(tables);
34962
- }
34963
- repositionTables() {
34964
- for (const overlay of this.tableOverlays.values()) overlay.reposition();
34965
- }
34966
35906
  layoutPanes() {
34967
35907
  const panes = this.scene.orderedPanes();
34968
35908
  const dataHeight = this.coords.height;
@@ -35013,6 +35953,7 @@ var NativeRenderer = class {
35013
35953
  const visible = maxPane ? [maxPane] : this.scene.orderedPanes().filter((p) => !p.collapsed);
35014
35954
  const paneBottom = visible.length ? Math.max(...visible.map((p) => p.bounds.top + p.bounds.height)) : dataHeight;
35015
35955
  this.scrollBtnBottomPx = SCROLL_BTN_BOTTOM + Math.max(0, dataHeight - paneBottom);
35956
+ this.mountContainer?.style.setProperty("--vela-bottom-gutter", `${TIME_AXIS_H + Math.max(0, dataHeight - paneBottom)}px`);
35016
35957
  this.scrollBtnRightPx = this.rightAxisW + SCROLL_BTN_RIGHT_INSET;
35017
35958
  if (this.scrollButton) {
35018
35959
  this.scrollButton.style.bottom = `${this.scrollBtnBottomPx}px`;
@@ -35105,30 +36046,33 @@ var NativeRenderer = class {
35105
36046
  if (w <= 0 || h <= 0) return;
35106
36047
  const dpr = window.devicePixelRatio || 1;
35107
36048
  this.plot.style.left = `${this.toolbarGutter}px`;
35108
- const pw = Math.max(1, w - this.toolbarGutter);
35109
- const ph = h;
35110
- this.dataCanvas.width = Math.round(pw * dpr);
35111
- this.dataCanvas.height = Math.round(ph * dpr);
35112
- this.backdropCanvas.width = this.dataCanvas.width;
35113
- this.backdropCanvas.height = this.dataCanvas.height;
35114
- this.volumeCanvas.width = this.dataCanvas.width;
35115
- this.volumeCanvas.height = this.dataCanvas.height;
35116
- for (const l of this.extLayers) {
35117
- l.canvas.width = this.dataCanvas.width;
35118
- l.canvas.height = this.dataCanvas.height;
35119
- }
35120
- this.vpvrCanvas.width = this.dataCanvas.width;
35121
- this.vpvrCanvas.height = this.dataCanvas.height;
35122
- this.chromeCanvas.width = this.dataCanvas.width;
35123
- this.chromeCanvas.height = this.dataCanvas.height;
35124
- this.drawingsCanvas.width = this.dataCanvas.width;
35125
- this.drawingsCanvas.height = this.dataCanvas.height;
35126
- this.cursorCanvas.width = this.dataCanvas.width;
35127
- this.cursorCanvas.height = this.dataCanvas.height;
36049
+ const rect = this.plot.getBoundingClientRect();
36050
+ let bw = Math.max(1, Math.round(rect.width * dpr));
36051
+ let bh = Math.max(1, Math.round(rect.height * dpr));
36052
+ const dev = this.plotDeviceSize;
36053
+ if (dev && Math.abs(dev.width - rect.width * dpr) <= 1 && Math.abs(dev.height - rect.height * dpr) <= 1) {
36054
+ bw = Math.max(1, dev.width);
36055
+ bh = Math.max(1, dev.height);
36056
+ }
36057
+ const pw = bw / dpr;
36058
+ const ph = bh / dpr;
36059
+ const size = (canvas) => {
36060
+ canvas.width = bw;
36061
+ canvas.height = bh;
36062
+ canvas.style.width = `${pw}px`;
36063
+ canvas.style.height = `${ph}px`;
36064
+ };
36065
+ size(this.dataCanvas);
36066
+ size(this.backdropCanvas);
36067
+ size(this.volumeCanvas);
36068
+ for (const l of this.extLayers) size(l.canvas);
36069
+ size(this.vpvrCanvas);
36070
+ size(this.chromeCanvas);
36071
+ size(this.drawingsCanvas);
36072
+ size(this.cursorCanvas);
35128
36073
  this.coords.setSize(Math.max(1, pw - this.rightAxisW), Math.max(1, ph - TIME_AXIS_H), dpr);
35129
36074
  this.scene.crosshair = null;
35130
36075
  this.layoutPanes();
35131
- this.repositionTables();
35132
36076
  this.userDrawings?.onResize();
35133
36077
  if (!this.didInitialFit && this.coords.barCount > 0) {
35134
36078
  this.fitContent();
@@ -35794,45 +36738,24 @@ var CSS21 = `
35794
36738
  }
35795
36739
  .vela-statusline .vela-sl-symbol { font-weight: 600; font-size: var(--vela-font-size-lg); }
35796
36740
  .vela-statusline .vela-sl-meta { color: var(--vela-fg-muted); font-size: var(--vela-font-size-md); font-weight: 600; }
35797
- /* Market status badge \u2014 icon-only 16px circle, label on hover (kit tooltip). */
35798
- .vela-statusline .vela-sl-market {
35799
- display: inline-grid;
35800
- place-items: center;
35801
- width: 16px;
35802
- height: 16px;
35803
- border-radius: 50%;
35804
- flex: none;
35805
- align-self: center;
35806
- line-height: 0;
35807
- cursor: default;
35808
- }
35809
- .vela-statusline .vela-sl-market svg {
35810
- width: 12px;
35811
- height: 12px;
35812
- display: block;
35813
- }
35814
- /* Open wears the theme's up color; the other sessions are meaning constants from the
35815
- * palette (amber pre, sky post, gray closed/holiday). Same ink at 20% for the circle. */
35816
- .vela-statusline .vela-sl-market[data-status='open'] {
35817
- background: color-mix(in srgb, var(--vela-up) 20%, transparent);
35818
- color: var(--vela-up);
35819
- }
35820
- .vela-statusline .vela-sl-market[data-status='pre'] { background: color-mix(in srgb, ${SESSION_PRE} 20%, transparent); color: ${SESSION_PRE}; }
35821
- .vela-statusline .vela-sl-market[data-status='post'] { background: color-mix(in srgb, ${SESSION_POST} 20%, transparent); color: ${SESSION_POST}; }
35822
- .vela-statusline .vela-sl-market[data-status='closed'],
35823
- .vela-statusline .vela-sl-market[data-status='holiday'] { background: color-mix(in srgb, ${SESSION_OFF} 20%, transparent); color: ${SESSION_OFF}; }
36741
+ /* Market status badge \u2014 a kit callout bubble (icon-only 16px circle, label on hover
36742
+ * via the kit tooltip); the session tint is applied per status in setMarketStatus. */
36743
+ .vela-statusline .vela-sl-market { align-self: center; }
35824
36744
  .vela-statusline .vela-sl-ohlc { display: flex; gap: var(--vela-space-1); color: var(--vela-fg-muted); }
35825
36745
  .vela-statusline .vela-sl-ohlc b { color: var(--vela-fg); font-weight: 500; }
35826
36746
  /* The change value wears the SAME ink as the OHLC values (set inline per render) \u2014
35827
36747
  * these are the pre-ink fallbacks only. */
35828
36748
  .vela-statusline .vela-sl-change[data-dir='up'] { color: var(--vela-up); }
35829
36749
  .vela-statusline .vela-sl-change[data-dir='down'] { color: var(--vela-down); }
35830
- /* Stack the renderer's PRICE-pane legend below the status line (study panes stay put).
35831
- * The renderer sets the legend's inline top \u2014 shift with a transform, don't fight it.
35832
- * Scoped to hosts that actually CARRY a status line (the marker class set by the
35833
- * Statusline constructor) \u2014 the stylesheet is document-global, so a bare container
35834
- * class here would shift every chart on the page, including statusline-less ones. */
35835
- .vela-has-statusline [data-vela-pane='price'] { transform: translateY(26px); }
36750
+ /* Stack the TOP pane's legend below the status line (lower study panes stay put).
36751
+ * The renderer marks whichever legend sits at the plot's top edge \u2014 the price pane
36752
+ * normally, or a maximized study pane filling the plot \u2014 so the legend never merges
36753
+ * with the status line whichever pane owns the top. The renderer sets the legend's
36754
+ * inline top \u2014 shift with a transform, don't fight it. Scoped to hosts that actually
36755
+ * CARRY a status line (the marker class set by the Statusline constructor) \u2014 the
36756
+ * stylesheet is document-global, so a bare attribute selector here would shift every
36757
+ * chart on the page, including statusline-less ones. */
36758
+ .vela-has-statusline [${LEGEND_AT_TOP_ATTR}] { transform: translateY(26px); }
35836
36759
  /* Mobile: two-line chip \u2014 logo / symbol / meta / market status on one aligned row, the
35837
36760
  * bar change on the next. Full O/H/L/C stays hidden (too dense on a phone-width plot).
35838
36761
  * GRID, not a wrapping flexbox: an absolutely positioned wrapping flex container sizes
@@ -35868,7 +36791,7 @@ var CSS21 = `
35868
36791
  font-size: var(--vela-font-size-sm);
35869
36792
  line-height: 1.2;
35870
36793
  }
35871
- [data-layout='mobile'] .vela-has-statusline [data-vela-pane='price'] { transform: translateY(40px); }
36794
+ [data-layout='mobile'] .vela-has-statusline [${LEGEND_AT_TOP_ATTR}] { transform: translateY(40px); }
35872
36795
  /* FIT mode (multi-chart cells \u2014 see setFitMode): the line never wraps; segments that
35873
36796
  * don't fit are HIDDEN by fit() (change first, then meta, then the market badge), so
35874
36797
  * overflow:hidden only guards the transient between a resize and the next measure.
@@ -35885,7 +36808,7 @@ var CSS21 = `
35885
36808
  padding-left: 0;
35886
36809
  }
35887
36810
  /* One row again \u2014 the mobile two-line shift doesn't apply in fit mode. */
35888
- [data-layout='mobile'] .vela-sl-fit-host.vela-has-statusline [data-vela-pane='price'] { transform: translateY(26px); }
36811
+ [data-layout='mobile'] .vela-sl-fit-host.vela-has-statusline [${LEGEND_AT_TOP_ATTR}] { transform: translateY(26px); }
35889
36812
  `;
35890
36813
  function baseOfTicker(ticker) {
35891
36814
  return ticker.replace(/[-_/]?(USDT|USDC|USD1|USDS|BUSD|USD|EUR|PERP)$/i, "") || ticker;
@@ -35897,6 +36820,13 @@ var MARKET_LABELS = {
35897
36820
  closed: "Market Closed",
35898
36821
  holiday: "Market Holiday"
35899
36822
  };
36823
+ var MARKET_INKS = {
36824
+ open: "var(--vela-up)",
36825
+ pre: SESSION_PRE,
36826
+ post: SESSION_POST,
36827
+ closed: SESSION_OFF,
36828
+ holiday: SESSION_OFF
36829
+ };
35900
36830
  function statuslineInkOf(renderer, priceStyle) {
35901
36831
  const cfg = renderer.getConfig();
35902
36832
  const c = (v) => typeof v === "string" ? v : null;
@@ -35955,8 +36885,15 @@ var Statusline = class {
35955
36885
  this.symbolEl.textContent = ticker;
35956
36886
  this.metaEl = doc.createElement("span");
35957
36887
  this.metaEl.className = "vela-sl-meta";
35958
- this.marketEl = doc.createElement("span");
35959
- this.marketEl.className = "vela-sl-market";
36888
+ this.marketBubble = new CalloutBubble({
36889
+ icon: "market-open",
36890
+ background: `color-mix(in srgb, ${MARKET_INKS.open} 20%, transparent)`,
36891
+ color: MARKET_INKS.open,
36892
+ label: MARKET_LABELS.open,
36893
+ host
36894
+ });
36895
+ this.marketEl = this.marketBubble.el;
36896
+ this.marketEl.classList.add("vela-sl-market");
35960
36897
  this.ohlcEl = doc.createElement("span");
35961
36898
  this.ohlcEl.className = "vela-sl-ohlc";
35962
36899
  this.changeEl = doc.createElement("span");
@@ -36044,8 +36981,13 @@ var Statusline = class {
36044
36981
  * hover label. Callers with no session model leave the constructor's 'open'. */
36045
36982
  setMarketStatus(status) {
36046
36983
  this.marketEl.dataset.status = status;
36047
- this.marketEl.innerHTML = icon(`market-${status}`);
36048
- this.marketEl.setAttribute("aria-label", MARKET_LABELS[status]);
36984
+ const ink = MARKET_INKS[status];
36985
+ this.marketBubble.set({
36986
+ icon: `market-${status}`,
36987
+ background: `color-mix(in srgb, ${ink} 20%, transparent)`,
36988
+ color: ink,
36989
+ label: MARKET_LABELS[status]
36990
+ });
36049
36991
  this.marketTip.setContent(MARKET_LABELS[status]);
36050
36992
  }
36051
36993
  setPartVisible(part, visible) {
@@ -36078,6 +37020,7 @@ var Statusline = class {
36078
37020
  this.fitRO?.disconnect();
36079
37021
  this.fitRO = null;
36080
37022
  this.marketTip.destroy();
37023
+ this.marketBubble.destroy();
36081
37024
  this.host.classList.remove("vela-has-statusline", "vela-sl-fit-host");
36082
37025
  this.el.remove();
36083
37026
  }
@@ -36304,6 +37247,8 @@ var SessionShadingTracker = class {
36304
37247
  this.spec = null;
36305
37248
  this.ready = false;
36306
37249
  this.session = "regular";
37250
+ /** Whether one bar is shorter than a civil day — only then do pre/post bars exist. */
37251
+ this.intraday = false;
36307
37252
  this.covered = null;
36308
37253
  /** The newest range seen — viewport moves during metadata resolution (a load's fit
36309
37254
  * animation) must not be lost, so the resolution always expands the LATEST range. */
@@ -36316,6 +37261,8 @@ var SessionShadingTracker = class {
36316
37261
  this.spec = null;
36317
37262
  this.covered = null;
36318
37263
  this.session = opts.session;
37264
+ const tfMs = timeframeMs(opts.timeframe);
37265
+ this.intraday = Number.isFinite(tfMs) && tfMs < DAY_MS2;
36319
37266
  this.lastRange = opts.range;
36320
37267
  void data.symbolInfo(symbol).catch(() => void 0).then((si) => {
36321
37268
  if (my !== this.epoch) return;
@@ -36343,7 +37290,7 @@ var SessionShadingTracker = class {
36343
37290
  if (force) this.onZones(null);
36344
37291
  return;
36345
37292
  }
36346
- if (this.session !== "extended") {
37293
+ if (this.session !== "extended" || !this.intraday) {
36347
37294
  if (force) this.onZones({ pre: [], post: [] });
36348
37295
  return;
36349
37296
  }
@@ -36444,9 +37391,168 @@ var Watermark = class {
36444
37391
  }
36445
37392
  };
36446
37393
 
37394
+ // src/widget/cell-controls.ts
37395
+ var CELL_CONTROLS_PROXIMITY_PX = 120;
37396
+ var TIME_AXIS_H2 = 22;
37397
+ var CONTROLS_BOTTOM_PX = TIME_AXIS_H2 + 12;
37398
+ var CLUSTER_H2 = 24;
37399
+ var CLUSTER_PILL2 = "rgba(0,0,0,0.65)";
37400
+ var STYLE_ID26 = "vela-cell-controls";
37401
+ var CSS23 = `
37402
+ .vela-cc-btn{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:none;border-radius:var(--vela-radius-sm);background:transparent;line-height:0;font-size:12px;color:var(--vela-fg-muted);cursor:pointer;}
37403
+ .vela-cc-btn svg{display:block;}
37404
+ .vela-cc-btn:hover{background:var(--vela-active);color:var(--vela-fg-bright);}
37405
+ .vela-cc-on,.vela-cc-on:hover{background:var(--vela-selected-bg);color:var(--vela-selected-fg);}
37406
+ .vela-cc-grip{cursor:grab;touch-action:none;}
37407
+ .vela-cc-grip:active{cursor:grabbing;}
37408
+ `;
37409
+ function nearBottomCenter(x, y, width, height, proximityPx = CELL_CONTROLS_PROXIMITY_PX) {
37410
+ const cx = width / 2;
37411
+ const cy = height - CONTROLS_BOTTOM_PX - CLUSTER_H2 / 2;
37412
+ return Math.hypot(x - cx, y - cy) <= proximityPx;
37413
+ }
37414
+ var CellControls = class {
37415
+ constructor(host, deps) {
37416
+ this.host = host;
37417
+ this.deps = deps;
37418
+ this.near = false;
37419
+ /** A grip drag is underway — the proximity reveal must not hide the cluster
37420
+ * while captured pointer moves sweep across the whole grid. */
37421
+ this.dragging = false;
37422
+ /** Mobile: the proximity reveal is meaningless without a cursor — the mobile
37423
+ * bar's maximize stop replaces the cluster. */
37424
+ this.suspended = false;
37425
+ this.onHostMove = (e) => {
37426
+ if (this.suspended) return;
37427
+ if (this.dragging) return;
37428
+ const rect = this.host.getBoundingClientRect();
37429
+ this.setNear(nearBottomCenter(e.clientX - rect.left, e.clientY - rect.top, rect.width, rect.height));
37430
+ };
37431
+ this.onHostLeave = () => {
37432
+ if (this.dragging) return;
37433
+ this.setNear(false);
37434
+ };
37435
+ injectStyles(STYLE_ID26, CSS23, host.ownerDocument);
37436
+ this.glider = new Glider(deps.chart);
37437
+ this.root = host.ownerDocument.createElement("div");
37438
+ Object.assign(this.root.style, {
37439
+ position: "absolute",
37440
+ left: "50%",
37441
+ bottom: `${CONTROLS_BOTTOM_PX}px`,
37442
+ transform: "translateX(-50%)",
37443
+ zIndex: "6",
37444
+ display: "none",
37445
+ // revealed by cursor proximity (onHostMove)
37446
+ gap: "2px",
37447
+ padding: "2px",
37448
+ borderRadius: "var(--vela-radius-md)",
37449
+ background: CLUSTER_PILL2,
37450
+ pointerEvents: "auto"
37451
+ });
37452
+ this.host.addEventListener("pointermove", this.onHostMove);
37453
+ this.host.addEventListener("pointerleave", this.onHostLeave);
37454
+ this.host.appendChild(this.root);
37455
+ this.refresh();
37456
+ }
37457
+ /** Rebuild the buttons (the multi-cell gate or the maximized state changed). */
37458
+ refresh() {
37459
+ this.root.textContent = "";
37460
+ const multi = this.deps.multiCell();
37461
+ const maximized = multi && this.deps.isMaximized();
37462
+ if (multi && !maximized) this.root.appendChild(this.makeGrip());
37463
+ this.root.appendChild(this.button("minus", "Zoom out", () => this.glider.zoom(ZOOM_OUT)));
37464
+ this.root.appendChild(this.button("plus", "Zoom in", () => this.glider.zoom(ZOOM_IN)));
37465
+ if (multi) {
37466
+ this.root.appendChild(
37467
+ this.button(maximized ? "restore" : "maximize", maximized ? "Restore layout" : "Maximize chart", () => this.deps.toggleMaximize(), {
37468
+ // The maximized state reads as an inverse chip (white-on-dark, dark-on-light),
37469
+ // the same active-state affordance as a collapsed pane's expand button.
37470
+ selected: maximized
37471
+ })
37472
+ );
37473
+ }
37474
+ this.root.appendChild(
37475
+ this.button("reset", "Reset chart", () => {
37476
+ this.glider.stop();
37477
+ this.deps.reset();
37478
+ })
37479
+ );
37480
+ }
37481
+ button(iconId, title, onClick, opts = {}) {
37482
+ const b = this.host.ownerDocument.createElement("button");
37483
+ b.type = "button";
37484
+ b.title = title;
37485
+ b.setAttribute("aria-label", title);
37486
+ b.className = opts.selected === true ? "vela-cc-btn vela-cc-on" : "vela-cc-btn";
37487
+ b.innerHTML = icon(iconId);
37488
+ b.addEventListener("click", (e) => {
37489
+ e.stopPropagation();
37490
+ onClick();
37491
+ });
37492
+ return b;
37493
+ }
37494
+ /** The drag handle (2×3 dot grip): press and drag onto another cell to trade
37495
+ * slots with it. The preview highlight follows the pointer; releasing outside
37496
+ * any other cell cancels. */
37497
+ makeGrip() {
37498
+ const b = this.host.ownerDocument.createElement("button");
37499
+ b.type = "button";
37500
+ b.title = "Drag to move chart";
37501
+ b.setAttribute("aria-label", "Drag to move chart");
37502
+ b.className = "vela-cc-btn vela-cc-grip";
37503
+ b.innerHTML = icon("grip");
37504
+ b.addEventListener("pointerdown", (e) => this.onGripDown(b, e));
37505
+ return b;
37506
+ }
37507
+ onGripDown(btn2, e) {
37508
+ if (e.button !== 0 && e.pointerType === "mouse") return;
37509
+ e.preventDefault();
37510
+ e.stopPropagation();
37511
+ try {
37512
+ btn2.setPointerCapture(e.pointerId);
37513
+ } catch {
37514
+ }
37515
+ this.dragging = true;
37516
+ let target = null;
37517
+ const move = (ev) => {
37518
+ target = this.deps.dragTargetAt(ev.clientX, ev.clientY);
37519
+ this.deps.previewDrop(target);
37520
+ };
37521
+ const finish = (commit) => () => {
37522
+ this.dragging = false;
37523
+ this.deps.previewDrop(null);
37524
+ btn2.removeEventListener("pointermove", move);
37525
+ btn2.removeEventListener("pointerup", onUp);
37526
+ btn2.removeEventListener("pointercancel", onCancel);
37527
+ if (commit && target != null) this.deps.dropOn(target);
37528
+ };
37529
+ const onUp = finish(true);
37530
+ const onCancel = finish(false);
37531
+ btn2.addEventListener("pointermove", move);
37532
+ btn2.addEventListener("pointerup", onUp);
37533
+ btn2.addEventListener("pointercancel", onCancel);
37534
+ }
37535
+ /** Mobile flips the cluster off entirely (and hides it if currently revealed). */
37536
+ setSuspended(on) {
37537
+ this.suspended = on;
37538
+ if (on) this.setNear(false);
37539
+ }
37540
+ setNear(near) {
37541
+ if (near === this.near) return;
37542
+ this.near = near;
37543
+ this.root.style.display = near ? "flex" : "none";
37544
+ }
37545
+ destroy() {
37546
+ this.glider.stop();
37547
+ this.host.removeEventListener("pointermove", this.onHostMove);
37548
+ this.host.removeEventListener("pointerleave", this.onHostLeave);
37549
+ this.root.remove();
37550
+ }
37551
+ };
37552
+
36447
37553
  // src/widget/context-menu.ts
36448
37554
  var PRICE_AXIS_W = 60;
36449
- var TIME_AXIS_H2 = 26;
37555
+ var TIME_AXIS_H3 = 26;
36450
37556
  var ChartContextMenu = class {
36451
37557
  constructor(host, cbs) {
36452
37558
  this.cbs = cbs;
@@ -36485,7 +37591,7 @@ var ChartContextMenu = class {
36485
37591
  zoneOf(e) {
36486
37592
  const rect = this.host.getBoundingClientRect();
36487
37593
  if (e.clientX - rect.left > rect.width - PRICE_AXIS_W) return "price-axis";
36488
- if (e.clientY - rect.top > rect.height - TIME_AXIS_H2) return "time-axis";
37594
+ if (e.clientY - rect.top > rect.height - TIME_AXIS_H3) return "time-axis";
36489
37595
  return "body";
36490
37596
  }
36491
37597
  /** The pane under the pointer, so every pane's price scale has its own menu. */
@@ -36758,6 +37864,7 @@ var ChartCell = class {
36758
37864
  this.inner.renderer.set("attribution", false);
36759
37865
  this.inner.renderer.set("dialogHost", deps.dialogHost);
36760
37866
  this.inner.renderer.setLegendActions(legendActionsProviderFor(this.inner, () => deps.context()));
37867
+ this.inner.renderer.setLegendCallouts(legendCalloutsProviderFor(this.inner, () => deps.context()));
36761
37868
  if (this.inner.renderer.supports("historyChords")) this.inner.renderer.set("historyChords", false);
36762
37869
  this.history.onChart(this.inner);
36763
37870
  this.inner.renderer.onConfigChanged(() => {
@@ -36818,11 +37925,18 @@ var ChartCell = class {
36818
37925
  if (this.inner && this.state.symbol) this.marketStatus?.track(this.inner.data, this.state.symbol);
36819
37926
  });
36820
37927
  this.syncStatuslineColors();
37928
+ this.cellControls = new CellControls(this.host, {
37929
+ chart: () => this.inner,
37930
+ reset: () => this.resetView(),
37931
+ multiCell: () => deps.multiCell(),
37932
+ isMaximized: () => deps.isMaximized(id),
37933
+ toggleMaximize: () => deps.toggleMaximize(id),
37934
+ dragTargetAt: (x, y) => deps.cellDragTarget(id, x, y),
37935
+ previewDrop: (target) => deps.previewDropTarget(target),
37936
+ dropOn: (target) => deps.dropCell(id, target)
37937
+ });
36821
37938
  this.contextMenu = new ChartContextMenu(this.host, {
36822
- resetView: () => {
36823
- this.inner?.renderer.set("autoScale", true);
36824
- this.inner?.setVisibleRangePreset("ALL");
36825
- },
37939
+ resetView: () => this.resetView(),
36826
37940
  timezone: () => this.deps.timezone(),
36827
37941
  setTimezone: (zone) => this.deps.setTimezone(zone),
36828
37942
  // Right-clicking activates the cell first (capture-phase pointerdown), so the
@@ -36874,20 +37988,30 @@ var ChartCell = class {
36874
37988
  this.refreshNativeCatalog();
36875
37989
  this.pushSettingsSections();
36876
37990
  this.offMarket = this.inner.on("market:changed", ({ symbol: symbol2, timeframe }) => {
36877
- this.state.symbol = symbol2;
36878
- this.state.provider = parseSymbol(symbol2).provider ?? void 0;
36879
- this.state.timeframe = timeframe;
36880
- this.state.session = normalizeSession(this.inner?.market.session);
36881
- this.watermark?.update(symbol2, timeframe);
36882
- this.statusline?.setSymbol(symbol2);
36883
- this.statusline?.setMeta(timeframe, this.inner?.data.displayPrefix(symbol2) ?? this.state.provider ?? "");
36884
- if (this.inner) this.statusline?.onChart(this.inner);
37991
+ this.projectMarket(symbol2, timeframe);
36885
37992
  this.refreshNativeCatalog();
36886
37993
  this.refreshSessionAvailable();
36887
37994
  if (this.inner) this.marketStatus?.track(this.inner.data, symbol2);
36888
37995
  this.deps.onMarketChanged(this.id);
36889
37996
  });
36890
37997
  }
37998
+ /**
37999
+ * Project a market identity into the cell state and its display overlays (watermark,
38000
+ * statusline), WITHOUT the data-dependent bookkeeping. Runs twice per user pick: once
38001
+ * optimistically from the cell setters — the labels reflect the pick immediately, not
38002
+ * after the bars load — and again from `market:changed` (the committed pass, and the
38003
+ * only pass for host `chart.setMarket` calls). Idempotent, so the double run converges.
38004
+ */
38005
+ projectMarket(symbol, timeframe) {
38006
+ this.state.symbol = symbol;
38007
+ this.state.provider = parseSymbol(symbol).provider ?? void 0;
38008
+ this.state.timeframe = timeframe;
38009
+ this.state.session = normalizeSession(this.inner?.market.session);
38010
+ this.watermark?.update(symbol, timeframe);
38011
+ this.statusline?.setSymbol(symbol);
38012
+ this.statusline?.setMeta(timeframe, this.inner?.data.displayPrefix(symbol) ?? this.state.provider ?? "");
38013
+ if (this.inner) this.statusline?.onChart(this.inner);
38014
+ }
36891
38015
  /**
36892
38016
  * Does this cell's market HAVE sessions (RTH/ETH meaningful)? Derived from the
36893
38017
  * symbol's own metadata (`syminfo.session !== '24x7'`), asynchronously — the
@@ -36933,7 +38057,7 @@ var ChartCell = class {
36933
38057
  const requestedSpan = Math.max(this.state.bars ?? 1e3, this.rangeBars) * timeframeMs(this.state.timeframe ?? "60");
36934
38058
  const fallbackSpan = Number.isFinite(requestedSpan) ? Math.max(3 * 864e5, requestedSpan) : 3 * 864e5;
36935
38059
  const range = chart.getVisibleRange() ?? { from: now - fallbackSpan, to: now };
36936
- this.sessionShading.track(chart.data, symbol, { session: this.session, range });
38060
+ this.sessionShading.track(chart.data, symbol, { session: this.session, timeframe: this.timeframe, range });
36937
38061
  }
36938
38062
  /** The session-shade colors live in the renderer CONFIG (persisted with it, edited
36939
38063
  * live by the dialog swatch) — the cell only proxies them into its settings rows. */
@@ -37122,17 +38246,23 @@ var ChartCell = class {
37122
38246
  get indicatorCount() {
37123
38247
  return this.instances.length + this.nativeCatalog.filter((n) => n.present).length;
37124
38248
  }
37125
- /** Switch this cell's market in place (the chart instance survives). */
38249
+ /** Switch this cell's market in place (the chart instance survives). The projection
38250
+ * is OPTIMISTIC — labels and chrome show the pick before the bars load; it follows
38251
+ * the setMarket call so the statusline reads the already-blanked chart. */
37126
38252
  setSymbol(symbol) {
37127
38253
  if (!this.inner || symbol === this.symbol) return;
37128
38254
  this.unresolvedToasted = null;
37129
38255
  void this.inner.setMarket({ symbol });
38256
+ this.projectMarket(symbol, this.timeframe);
38257
+ this.deps.onMarketChanged(this.id);
37130
38258
  }
37131
38259
  setTimeframe(timeframe) {
37132
38260
  if (!this.inner || timeframe === this.timeframe) return;
37133
38261
  this.activeRangeId = null;
37134
38262
  this.rangeBars = 0;
37135
38263
  void this.inner.setMarket({ timeframe, bars: this.state.bars });
38264
+ this.projectMarket(this.symbol, timeframe);
38265
+ this.deps.onMarketChanged(this.id);
37136
38266
  }
37137
38267
  /** Applied live (renderer feature) — no reload. */
37138
38268
  setPriceStyle(style) {
@@ -37180,6 +38310,20 @@ var ChartCell = class {
37180
38310
  this.inner.setVisibleRangePreset(preset.preset);
37181
38311
  }
37182
38312
  }
38313
+ /** Reset this cell's view: re-enable auto scale and frame the full history —
38314
+ * the same action the chart context menu offers. */
38315
+ resetView() {
38316
+ this.inner?.renderer.set("autoScale", true);
38317
+ this.inner?.setVisibleRangePreset("ALL");
38318
+ }
38319
+ /** Rebuild the view-controls cluster (the maximize gate or state changed). */
38320
+ refreshControls() {
38321
+ this.cellControls.refresh();
38322
+ }
38323
+ /** Mobile flips the per-cell cluster off (the shell's mobile bar replaces it). */
38324
+ setControlsSuspended(on) {
38325
+ this.cellControls.setSuspended(on);
38326
+ }
37183
38327
  /** Make this cell the active one and put keyboard focus on its chart surface. */
37184
38328
  focus() {
37185
38329
  this.deps.activate(this.id);
@@ -37493,6 +38637,7 @@ var ChartCell = class {
37493
38637
  destroy() {
37494
38638
  this.destroyed = true;
37495
38639
  this.offMarket();
38640
+ this.cellControls.destroy();
37496
38641
  this.contextMenu.destroy();
37497
38642
  this.history.destroy();
37498
38643
  this.marketStatus?.stop();
@@ -37785,10 +38930,10 @@ var SplitterLayer = class {
37785
38930
  var DEFAULT_TIMEFRAMES = ["1", "5", "15", "60", "240", "D", "W"];
37786
38931
  var GAP_PX = 2;
37787
38932
  var POOL_CAP = 16;
37788
- var TIME_AXIS_H3 = 22;
38933
+ var TIME_AXIS_H4 = 22;
37789
38934
  var ALERT_CAP = 50;
37790
- var STYLE_ID26 = "vela-workspace";
37791
- var CSS23 = `
38935
+ var STYLE_ID27 = "vela-workspace";
38936
+ var CSS24 = `
37792
38937
  .vela-workspace { position: relative; width: 100%; height: 100%; display: flex; flex-direction: column; background: var(--vela-bg); }
37793
38938
  .vela-ws-main { position: relative; display: flex; flex-direction: row; flex: 1 1 auto; min-height: 0; }
37794
38939
  .vela-ws-toolbar { position: relative; flex: none; }
@@ -37815,6 +38960,21 @@ var CSS23 = `
37815
38960
  /* Mobile: the docked drawing-toolbar column would eat a phone-width grid \u2014 the shell's
37816
38961
  drawings drawer + on-chart pill replace it (same policy as the widget's in-chart bar). */
37817
38962
  [data-layout='mobile'] .vela-ws-toolbar { display: none; }
38963
+ /* A maximized cell owns the whole grid: the splitter strips have no seams to grab and
38964
+ the active ring would just outline the only visible chart \u2014 both are noise here. */
38965
+ .vela-ws-grid[data-maximized='1'] .vela-ws-splitter { display: none; }
38966
+ .vela-ws-grid[data-maximized='1'] .vela-cell[data-active='1']::after { display: none; }
38967
+ /* Drop-target preview while a cell's drag handle is held: a dashed ring + the same
38968
+ soft wash the splitter hover uses, over the chart, inert to the pointer. */
38969
+ .vela-cell[data-drop-target='1']::before {
38970
+ content: '';
38971
+ position: absolute;
38972
+ inset: 0;
38973
+ border: 2px dashed var(--vela-fg-bright);
38974
+ background: var(--vela-separator-hover-band);
38975
+ pointer-events: none;
38976
+ z-index: 11;
38977
+ }
37818
38978
  `;
37819
38979
  registerIcon("layout", svg16('<rect x="1.5" y="1.5" width="13" height="13" rx="1.5"/><path d="M8 1.5v13M1.5 8h13"/>'));
37820
38980
  function declaredOrder(cells) {
@@ -37844,6 +39004,9 @@ var VelaWorkspace = class {
37844
39004
  * slots beyond the list get auto identities. Grows, never reorders. */
37845
39005
  this.order = [];
37846
39006
  this.activeId = null;
39007
+ /** The cell maximized over the whole grid (null = normal grid). TRANSIENT view
39008
+ * state — never persisted; any structural change (layout, applyState) restores. */
39009
+ this.maximizedId = null;
37847
39010
  this.cellBackend = "auto";
37848
39011
  this.destroyed = false;
37849
39012
  this.shortcutsHelp = null;
@@ -37948,7 +39111,7 @@ var VelaWorkspace = class {
37948
39111
  this.order = boot?.charts ? boot.charts.map((c) => c.id) : declaredOrder(opts.cells);
37949
39112
  const bootActive = boot?.activeCellId ?? null;
37950
39113
  const doc = hostEl.ownerDocument;
37951
- injectStyles(STYLE_ID26, CSS23, doc);
39114
+ injectStyles(STYLE_ID27, CSS24, doc);
37952
39115
  this.root = doc.createElement("div");
37953
39116
  this.root.className = "vela-workspace";
37954
39117
  ensureUIHost(this.root, resolveTheme(opts.theme));
@@ -38058,7 +39221,11 @@ var VelaWorkspace = class {
38058
39221
  if (attribution !== false) {
38059
39222
  const background = resolveTheme(opts.theme).background;
38060
39223
  const mark = typeof attribution === "string" && attribution.trim() ? createCustomMark(doc, attribution, background) : createAttributionMark(doc, background);
38061
- Object.assign(mark.style, { left: "12px", bottom: `${TIME_AXIS_H3 + 10}px`, zIndex: "11" });
39224
+ Object.assign(mark.style, {
39225
+ left: "calc(var(--vela-toolbar-gutter, 0px) + 12px)",
39226
+ bottom: `calc(var(--vela-bottom-gutter, ${TIME_AXIS_H4}px) + 10px)`,
39227
+ zIndex: "11"
39228
+ });
38062
39229
  this.gridEl.appendChild(mark);
38063
39230
  this.attributionMark = mark;
38064
39231
  }
@@ -38124,6 +39291,9 @@ var VelaWorkspace = class {
38124
39291
  ...topbarHas(this.topbarComp, "indicators") && (picker || this.indicatorsOverride) ? { onIndicatorsClick: this.indicatorsOverride ? () => this.runOverride(this.indicatorsOverride) : () => picker.open() } : {},
38125
39292
  getContext: () => this.context(),
38126
39293
  ...this.drawingsEnabled ? { onDrawingsClick: () => this.openDrawingsDrawer() } : {},
39294
+ // Multi-chart only: the stop that isolates the ACTIVE chart (the
39295
+ // per-cell hover cluster has no cursor to reveal it on mobile).
39296
+ ...this.monoLayout ? {} : { onMaximizeClick: () => this.toggleMobileMaximize() },
38127
39297
  onMoreClick: () => this.openMoreDrawer(),
38128
39298
  onSettingsClick: () => this.active.chart.renderer.openSettings()
38129
39299
  }) : null;
@@ -38238,7 +39408,10 @@ var VelaWorkspace = class {
38238
39408
  this.topbar.renderActions();
38239
39409
  this.mobileBar?.renderActions();
38240
39410
  this.dock.refresh();
38241
- for (const cell of this.cells()) cell.chart.renderer.setLegendActions(legendActionsProviderFor(cell.chart, () => this.context()));
39411
+ for (const cell of this.cells()) {
39412
+ cell.chart.renderer.setLegendActions(legendActionsProviderFor(cell.chart, () => this.context()));
39413
+ cell.chart.renderer.setLegendCallouts(legendCalloutsProviderFor(cell.chart, () => this.context()));
39414
+ }
38242
39415
  }
38243
39416
  // ── state surface (the SDK's read/restore of the whole grid's config + content) ──
38244
39417
  /**
@@ -38327,7 +39500,9 @@ var VelaWorkspace = class {
38327
39500
  this.pool.clear();
38328
39501
  for (const { id, ...cs } of st.charts.slice(liveCount)) this.pool.set(id, cs);
38329
39502
  this.order = st.charts.map((c) => c.id);
39503
+ this.clearMaximized();
38330
39504
  this.applyGrid();
39505
+ this.refreshCellControls();
38331
39506
  const nextActive2 = st.activeCellId && this.cellsById.has(st.activeCellId) ? st.activeCellId : this.order[0] ?? null;
38332
39507
  if (nextActive2 === this.activeId) this.projectActiveCell();
38333
39508
  else this.setActiveCell(nextActive2);
@@ -38353,6 +39528,7 @@ var VelaWorkspace = class {
38353
39528
  const def = this.monoLayout ? null : ensureLayout(st.layout);
38354
39529
  if (def) this.def = def;
38355
39530
  this.cellBackend = this.backendFor(this.def);
39531
+ this.clearMaximized();
38356
39532
  this.applyGrid();
38357
39533
  this.buildCells();
38358
39534
  this.syncCellPresentation();
@@ -38416,6 +39592,7 @@ var VelaWorkspace = class {
38416
39592
  setLayout(layout) {
38417
39593
  if (this.destroyed) return;
38418
39594
  if (this.monoLayout) return;
39595
+ this.clearMaximized();
38419
39596
  const next = this.resolveLayout(layout);
38420
39597
  const nextBackend = this.backendFor(next);
38421
39598
  const rebuildAll = nextBackend !== this.cellBackend;
@@ -38436,6 +39613,7 @@ var VelaWorkspace = class {
38436
39613
  this.buildCells();
38437
39614
  this.alignNewCellStyles(preexisting);
38438
39615
  this.syncCellPresentation();
39616
+ this.refreshCellControls();
38439
39617
  this.topbar.setLayout(next.id);
38440
39618
  const nextActive = activeAfterLayout(this.activeId, this.order.slice(0, next.cells.length));
38441
39619
  if (nextActive === this.activeId) this.projectActiveCell();
@@ -38444,6 +39622,67 @@ var VelaWorkspace = class {
38444
39622
  this.events.emit("layout:changed", { layout: next.id });
38445
39623
  this.markStateDirty();
38446
39624
  }
39625
+ /** The identity of the cell maximized over the whole grid, or null. */
39626
+ get maximizedCell() {
39627
+ return this.maximizedId;
39628
+ }
39629
+ /**
39630
+ * Maximize one cell over the whole grid, or restore the layout with `null`. Pure
39631
+ * presentation: the other cells stay alive underneath — charts, subscriptions and
39632
+ * state untouched — so restoring is instant. The maximized cell becomes the active
39633
+ * one. Transient view state (also reachable from each cell's bottom-center view
39634
+ * cluster): switching layouts or applying a state document restores the grid.
39635
+ */
39636
+ maximizeCell(id) {
39637
+ if (this.destroyed) return;
39638
+ if (id != null && (!this.cellsById.has(id) || this.def.cells.length <= 1)) return;
39639
+ if (id === this.maximizedId) return;
39640
+ this.maximizedId = id;
39641
+ if (id) this.setActiveCell(id);
39642
+ this.applyGrid();
39643
+ this.refreshCellControls();
39644
+ this.syncMobileMaximize();
39645
+ this.events.emit("cell:maximized", { id });
39646
+ }
39647
+ /** The mobile bar's maximize stop: one press isolates the ACTIVE chart over the
39648
+ * grid; while something is already isolated — the chart, or a pane inside it
39649
+ * (mobile's double-tap) — the press restores that instead. Every branch re-syncs
39650
+ * the stop on its own (`maximizeCell` directly, `panes.maximize` via its
39651
+ * synchronous `pane:changed`). */
39652
+ toggleMobileMaximize() {
39653
+ const cell = this.activeId ? this.cellsById.get(this.activeId) : void 0;
39654
+ if (!cell) return;
39655
+ if (this.maximizedId) this.maximizeCell(null);
39656
+ else if (cell.chart.panes.list().some((p) => p.maximized)) cell.chart.panes.maximize(null);
39657
+ else this.maximizeCell(cell.id);
39658
+ }
39659
+ /** Keep the mobile bar's maximize stop truthful: lit (inverse chip, restore
39660
+ * glyph) while the active chart covers the grid OR one of its panes is
39661
+ * maximized — the state a double-tap toggles is otherwise invisible on mobile. */
39662
+ syncMobileMaximize() {
39663
+ if (!this.mobileBar) return;
39664
+ const cell = this.activeId ? this.cellsById.get(this.activeId) : void 0;
39665
+ const paneMax = cell ? cell.chart.panes.list().some((p) => p.maximized) : false;
39666
+ this.mobileBar.setMaximizeActive(this.maximizedId != null || paneMax);
39667
+ }
39668
+ /**
39669
+ * Trade the SLOTS of two live cells — the grid arrangement changes, the cells
39670
+ * themselves (charts, indicators, drawings, the active flag) stay untouched.
39671
+ * What each cell's drag handle commits; also callable directly by hosts.
39672
+ */
39673
+ swapCells(a, b) {
39674
+ if (this.destroyed || a === b) return;
39675
+ const i = this.order.indexOf(a);
39676
+ const j = this.order.indexOf(b);
39677
+ if (i < 0 || j < 0 || !this.cellsById.has(a) || !this.cellsById.has(b)) return;
39678
+ [this.order[i], this.order[j]] = [this.order[j], this.order[i]];
39679
+ for (const [k] of this.def.cells.entries()) {
39680
+ const host = this.cellsById.get(this.order[k] ?? "")?.host;
39681
+ if (host) this.gridEl.appendChild(host);
39682
+ }
39683
+ this.applyGrid();
39684
+ this.markStateDirty();
39685
+ }
38447
39686
  resize() {
38448
39687
  this.splitters.layout();
38449
39688
  }
@@ -38513,6 +39752,7 @@ var VelaWorkspace = class {
38513
39752
  this.mobileBar?.renderActions();
38514
39753
  this.mobileBar?.setSymbol(cell.symbol);
38515
39754
  this.mobileBar?.setTimeframe(cell.timeframe);
39755
+ this.syncMobileMaximize();
38516
39756
  this.drawingPill?.onChart(cell.chart);
38517
39757
  const pushHistory = () => this.topbar.setHistoryState(cell.history.canUndo, cell.history.canRedo);
38518
39758
  this.historyUnsub?.();
@@ -38602,8 +39842,78 @@ var VelaWorkspace = class {
38602
39842
  const host = this.cellsById.get(this.order[i] ?? "")?.host;
38603
39843
  if (host) host.style.gridArea = perCell[slot.id]?.gridArea ?? "";
38604
39844
  }
39845
+ this.applyMaximizePresentation();
39846
+ this.mountAttributionMark();
38605
39847
  this.splitters.layout();
38606
39848
  }
39849
+ /** Overlay the maximize presentation on the freshly applied grid: EVERY cell spans
39850
+ * the full track grid — the maximized one on top, the siblings invisible beneath
39851
+ * it (their charts stay alive — restoring is instant). The siblings must span too:
39852
+ * left in their slots they would auto-flow into implicit zero-height rows, whose
39853
+ * gaps steal height from the maximized cell and collapse their renderers to 0.
39854
+ * The splitter strips and the active ring hide via the `data-maximized` rules. */
39855
+ applyMaximizePresentation() {
39856
+ const maxId = this.maximizedId;
39857
+ if (maxId) this.gridEl.dataset.maximized = "1";
39858
+ else delete this.gridEl.dataset.maximized;
39859
+ for (const [id, cell] of this.cellsById) {
39860
+ const style = cell.host.style;
39861
+ if (maxId) style.gridArea = "1 / 1 / -1 / -1";
39862
+ style.zIndex = maxId && id === maxId ? "5" : "";
39863
+ style.visibility = maxId && id !== maxId ? "hidden" : "";
39864
+ }
39865
+ }
39866
+ /** Rebuild every cell's view cluster (the maximize gate or state changed). */
39867
+ refreshCellControls() {
39868
+ for (const cell of this.cellsById.values()) cell.refreshControls();
39869
+ }
39870
+ /** Drop the transient maximize on a structural change (layout switch, state
39871
+ * document) — WITH the event, so hosts tracking `cell:maximized` never drift
39872
+ * from `maximizedCell`. The caller's own grid re-apply paints the restore. */
39873
+ clearMaximized() {
39874
+ if (this.maximizedId == null) return;
39875
+ this.maximizedId = null;
39876
+ this.events.emit("cell:maximized", { id: null });
39877
+ }
39878
+ /** The live cell under a viewport point, excluding `excludeId` and any host a
39879
+ * maximize has hidden — the drag handle's hit-test. */
39880
+ cellAtPoint(x, y, excludeId) {
39881
+ for (const [id, cell] of this.cellsById) {
39882
+ if (id === excludeId || cell.host.style.visibility === "hidden") continue;
39883
+ const r = cell.host.getBoundingClientRect();
39884
+ if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return id;
39885
+ }
39886
+ return null;
39887
+ }
39888
+ /** Mark one cell as the live drop target of a grip drag (null clears all) —
39889
+ * the `data-drop-target` stylesheet rule paints the dashed preview ring. */
39890
+ setDropTarget(id) {
39891
+ for (const [cid, cell] of this.cellsById) {
39892
+ if (cid === id) cell.host.dataset.dropTarget = "1";
39893
+ else delete cell.host.dataset.dropTarget;
39894
+ }
39895
+ }
39896
+ /** The cell whose bottom-left corner the grid's attribution mark floats in — the
39897
+ * maximized cell while one covers the grid, else the bottom-left slot's cell. */
39898
+ bottomLeftCell() {
39899
+ if (this.maximizedId) return this.cellsById.get(this.maximizedId);
39900
+ const grid = occupancyGrid(this.def);
39901
+ const slot = grid[grid.length - 1]?.[0];
39902
+ const idx = this.def.cells.findIndex((c) => (c.area ?? c.id) === slot);
39903
+ return this.cellsById.get(this.order[idx >= 0 ? idx : 0] ?? "");
39904
+ }
39905
+ /** Keep the shared attribution mark inside the BOTTOM-LEFT visible cell: its
39906
+ * offsets ride that cell's renderer-published `--vela-bottom-gutter` /
39907
+ * `--vela-toolbar-gutter`, so collapsed pane strips push the mark up without any
39908
+ * bookkeeping here. Re-run after anything that changes which host that is
39909
+ * (layout switch, maximize, cell rebuild); a destroyed host drops the mark from
39910
+ * the DOM, and this re-mount brings it back. */
39911
+ mountAttributionMark() {
39912
+ const mark = this.attributionMark;
39913
+ if (!mark) return;
39914
+ const host = this.bottomLeftCell()?.host ?? this.gridEl;
39915
+ if (mark.parentElement !== host) host.appendChild(mark);
39916
+ }
38607
39917
  /** Create the cells the current layout wants but don't exist yet (pool-first).
38608
39918
  * A slot's CELL IDENTITY is `order[i]` (declaration order — never the slot's own
38609
39919
  * positional id); slots past the declared list mint an auto identity once. */
@@ -38635,6 +39945,12 @@ var VelaWorkspace = class {
38635
39945
  setTimezone: (zone) => this.setTimezone(zone),
38636
39946
  context: () => this.context(),
38637
39947
  activate: (id2) => this.setActiveCell(id2),
39948
+ multiCell: () => !this.monoLayout && this.def.cells.length > 1,
39949
+ isMaximized: (id2) => this.maximizedId === id2,
39950
+ toggleMaximize: (id2) => this.maximizeCell(this.maximizedId === id2 ? null : id2),
39951
+ cellDragTarget: (id2, x, y) => this.cellAtPoint(x, y, id2),
39952
+ previewDropTarget: (target) => this.setDropTarget(target),
39953
+ dropCell: (id2, target) => this.swapCells(id2, target),
38638
39954
  onMarketChanged: (id2) => this.onCellMarketChanged(id2),
38639
39955
  onPriceStyleChanged: (id2) => this.onCellPriceStyleChanged(id2),
38640
39956
  onIndicatorsChanged: (id2) => this.onCellIndicatorsChanged(id2),
@@ -38648,6 +39964,7 @@ var VelaWorkspace = class {
38648
39964
  if (id === this.activeId) cell.host.dataset.active = "1";
38649
39965
  this.wireCell(cell);
38650
39966
  cell.chart.renderer.setLayoutMode(this.layoutCtl.current);
39967
+ cell.setControlsSuspended(this.layoutCtl.current === "mobile");
38651
39968
  if (this.favs.length > 0) cell.chart.drawings.setFavorites(this.favs);
38652
39969
  cell.setManifest(this.manifest, pooled?.indicators == null);
38653
39970
  cell.restorePersistedExt();
@@ -38657,6 +39974,7 @@ var VelaWorkspace = class {
38657
39974
  const host = this.cellsById.get(this.order[i] ?? "")?.host;
38658
39975
  if (host) this.gridEl.appendChild(host);
38659
39976
  }
39977
+ this.mountAttributionMark();
38660
39978
  }
38661
39979
  /** Per-cell chart subscriptions (trigger ② — the chart instance is stable for the
38662
39980
  * cell's whole life, so these live and die with the cell). */
@@ -38716,6 +40034,9 @@ var VelaWorkspace = class {
38716
40034
  chart.on("viewport:changed", (range) => this.propagateViewport(cell.id, range));
38717
40035
  chart.renderer.onConfigChanged(() => this.propagateStylePrefs(cell.id));
38718
40036
  chart.on("theme:changed", (t) => this.setTheme(t));
40037
+ chart.on("pane:changed", () => {
40038
+ if (cell.id === this.activeId) this.syncMobileMaximize();
40039
+ });
38719
40040
  chart.renderer.onAxisLongPress((e) => {
38720
40041
  if (this.layoutCtl.current !== "mobile") return;
38721
40042
  if (e.axis === "time") this.openTimezoneDrawer();
@@ -39036,6 +40357,7 @@ var VelaWorkspace = class {
39036
40357
  for (const cell of this.cellsById.values()) {
39037
40358
  cell.chart.renderer.closeDialogs();
39038
40359
  cell.chart.renderer.setLayoutMode(mode);
40360
+ cell.setControlsSuspended(mode === "mobile");
39039
40361
  }
39040
40362
  this.syncCellPresentation();
39041
40363
  }