@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
@@ -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 = `
@@ -4642,6 +4910,44 @@ function legendActionsProviderFor(chart, context) {
4642
4910
  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) }));
4643
4911
  };
4644
4912
  }
4913
+ var calloutRegistry = /* @__PURE__ */ new Map();
4914
+ function legendCallouts() {
4915
+ return [...calloutRegistry.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
4916
+ }
4917
+ function legendCalloutsProviderFor(chart, context) {
4918
+ return (indicatorId) => {
4919
+ const handle = chart.indicators().find((h) => h.id === indicatorId);
4920
+ if (!handle) return [];
4921
+ const info = { id: handle.id, title: handle.title, ...handle.source !== void 0 ? { source: handle.source } : {} };
4922
+ const views = [];
4923
+ for (const d of legendCallouts()) {
4924
+ const spec = d.callout(info);
4925
+ if (!spec) continue;
4926
+ views.push({
4927
+ id: d.id,
4928
+ icon: spec.icon,
4929
+ background: spec.background,
4930
+ ...spec.color !== void 0 ? { color: spec.color } : {},
4931
+ tooltip: spec.tooltip,
4932
+ ...spec.content !== void 0 ? {
4933
+ content: {
4934
+ ...spec.content.title !== void 0 ? { title: spec.content.title } : {},
4935
+ items: spec.content.items.map(
4936
+ (item) => item.type === "text" ? item : {
4937
+ type: "button",
4938
+ label: item.label,
4939
+ ...item.primary !== void 0 ? { primary: item.primary } : {},
4940
+ ...item.close !== void 0 ? { close: item.close } : {},
4941
+ run: () => item.run(context(), info)
4942
+ }
4943
+ )
4944
+ }
4945
+ } : {}
4946
+ });
4947
+ }
4948
+ return views;
4949
+ };
4950
+ }
4645
4951
  var stateHandlers = /* @__PURE__ */ new Map();
4646
4952
  function statePersistenceHandlers(scope) {
4647
4953
  return [...stateHandlers.values()].filter((h) => h.scope === scope);
@@ -13449,6 +13755,17 @@ var PanelDock = class {
13449
13755
  for (const entry of this.entries) this.deps.chrome.setPanelActive(entry.id, entry.panel.open);
13450
13756
  }
13451
13757
  };
13758
+ function foldGroups(list) {
13759
+ const groupsAbove = /* @__PURE__ */ new Set();
13760
+ const out = [];
13761
+ for (const s of list) {
13762
+ if (isGroupRow(s)) {
13763
+ groupsAbove.add(groupKeyOf(s));
13764
+ out.push(s);
13765
+ } else if (s.group == null || !groupsAbove.has(groupKeyOf(s))) out.push(s);
13766
+ }
13767
+ return out;
13768
+ }
13452
13769
  var TOP_TICKERS = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT", "DOGEUSDT", "ADAUSDT", "LINKUSDT"];
13453
13770
  function parseQuery(raw, venues) {
13454
13771
  const m = raw.match(/^\s*([^\s:]+)\s*[:\s]\s*(.*)$/);
@@ -13580,6 +13897,21 @@ var CSS8 = `
13580
13897
  .vela-sp-badge[data-p='binance'] { color: #f0b90b; } /* palette-exempt: venue brand mark */
13581
13898
  .vela-sp-badge[data-p='hyperliquid'] { color: #50d2c1; } /* palette-exempt: venue brand mark */
13582
13899
  .vela-sp-empty { padding: var(--vela-space-3); color: var(--vela-fg-muted); text-align: center; }
13900
+ /* Grouped listings (futures roots): the chevron unfolds members inline, indented. */
13901
+ .vela-sp-expander {
13902
+ all: unset;
13903
+ flex: none;
13904
+ display: inline-flex;
13905
+ align-items: center;
13906
+ justify-content: center;
13907
+ width: 22px;
13908
+ height: 22px;
13909
+ border-radius: 5px;
13910
+ cursor: pointer;
13911
+ color: var(--vela-fg-muted);
13912
+ }
13913
+ .vela-sp-expander:hover { background: var(--vela-surface-elev); color: var(--vela-fg); }
13914
+ .vela-sp-row[data-member] { padding-left: 34px; }
13583
13915
  `;
13584
13916
  var PAGE = 100;
13585
13917
  var SymbolPicker = class {
@@ -13591,6 +13923,11 @@ var SymbolPicker = class {
13591
13923
  this.seed = "";
13592
13924
  this.activeTab = "All";
13593
13925
  this.visible = PAGE;
13926
+ /** The last filter pass returned fewer raw rows than asked — the pool is drained
13927
+ * (checked BEFORE folding: folding shortens pages without meaning exhaustion). */
13928
+ this.exhausted = false;
13929
+ /** Group rows currently expanded (venue-scoped keys) — members shown inline. */
13930
+ this.expanded = /* @__PURE__ */ new Set();
13594
13931
  /** The ranked pool cache — `key` fingerprints the raw pool the ranking ran on. */
13595
13932
  this.ranked = null;
13596
13933
  this.ranking = false;
@@ -13605,7 +13942,7 @@ var SymbolPicker = class {
13605
13942
  searchRow.append(iconEl("search", doc), this.input);
13606
13943
  this.tabs = doc.createElement("div");
13607
13944
  this.tabs.className = "vela-sp-tabs";
13608
- for (const t of ["All", "Stocks", "ETFs", "Crypto", "Forex", "Commodities"]) {
13945
+ for (const t of ["All", "Stocks", "ETFs", "Crypto", "Futures", "Forex", "Commodities"]) {
13609
13946
  const b = doc.createElement("button");
13610
13947
  b.className = "vela-sp-tab";
13611
13948
  b.textContent = t;
@@ -13621,7 +13958,7 @@ var SymbolPicker = class {
13621
13958
  this.list = doc.createElement("div");
13622
13959
  this.list.className = "vela-sp-list";
13623
13960
  this.list.addEventListener("scroll", () => {
13624
- if (this.rows.length < this.visible) return;
13961
+ if (this.exhausted) return;
13625
13962
  if (this.list.scrollTop + this.list.clientHeight < this.list.scrollHeight - 200) return;
13626
13963
  this.visible += PAGE;
13627
13964
  this.grow();
@@ -13650,14 +13987,19 @@ var SymbolPicker = class {
13650
13987
  else if (e.key === "ArrowUp") this.moveHighlight(-1);
13651
13988
  else if (e.key === "Enter") {
13652
13989
  const pick = this.rows[this.highlighted];
13653
- if (pick) this.select(pick.ticker, pick.prefix ?? pick.provider, opts.onSelect);
13990
+ if (pick) this.pick(pick);
13654
13991
  return;
13655
13992
  } else return;
13656
13993
  e.preventDefault();
13657
13994
  });
13658
13995
  this.list.addEventListener("click", (e) => {
13659
- const row = e.target.closest(".vela-sp-row");
13660
- if (row?.dataset.ticker) this.select(row.dataset.ticker, row.dataset.venue, opts.onSelect);
13996
+ const target = e.target;
13997
+ const row = target.closest(".vela-sp-row");
13998
+ if (!row) return;
13999
+ const s = this.rows[Number(row.dataset.i)];
14000
+ if (!s) return;
14001
+ if (target.closest(".vela-sp-expander")) this.toggleExpand(s);
14002
+ else this.pick(s);
13661
14003
  });
13662
14004
  }
13663
14005
  /** Wire where symbols come from (re-called on every widget rebuild). */
@@ -13674,6 +14016,27 @@ var SymbolPicker = class {
13674
14016
  destroy() {
13675
14017
  this.dialog.destroy();
13676
14018
  }
14019
+ /** Route a row activation: a GROUP row loads its default member (the root itself is
14020
+ * listed, never loadable), any other row loads itself. */
14021
+ pick(s) {
14022
+ const target = isGroupRow(s) ? defaultMemberOf(this.pool(), s) ?? s : s;
14023
+ this.select(target.ticker, target.prefix ?? target.provider, this.opts.onSelect);
14024
+ }
14025
+ /** Expand/collapse a group row IN PLACE — same query, same page, same scroll; only
14026
+ * the member rows under the group appear or go. */
14027
+ toggleExpand(s) {
14028
+ const key = groupKeyOf(s);
14029
+ if (!this.expanded.delete(key)) this.expanded.add(key);
14030
+ const scrollTop = this.list.scrollTop;
14031
+ const focus = this.rows[this.highlighted];
14032
+ this.rows = this.computeRows();
14033
+ this.list.replaceChildren();
14034
+ this.rows.forEach((r, i) => this.list.appendChild(this.rowEl(r, i)));
14035
+ const at = focus ? this.rows.indexOf(focus) : -1;
14036
+ this.highlighted = at >= 0 ? at : Math.min(this.highlighted, Math.max(0, this.rows.length - 1));
14037
+ this.renderHighlight();
14038
+ this.list.scrollTop = scrollTop;
14039
+ }
13677
14040
  select(ticker, venue, onSelect) {
13678
14041
  this.close();
13679
14042
  onSelect(venue ? `${venue}:${ticker}` : ticker);
@@ -13700,10 +14063,26 @@ var SymbolPicker = class {
13700
14063
  return raw;
13701
14064
  }
13702
14065
  computeRows() {
13703
- const TAB_TYPES = { Crypto: ["crypto"], Stocks: ["stock"], ETFs: ["etf"], Forex: ["forex"], Commodities: ["commodity"] };
14066
+ const TAB_TYPES = { Crypto: ["crypto"], Stocks: ["stock"], ETFs: ["etf"], Futures: ["futures", "root"], Forex: ["forex"], Commodities: ["commodity"] };
13704
14067
  const all = this.pool();
13705
14068
  const pool = this.activeTab === "All" ? all : all.filter((s) => TAB_TYPES[this.activeTab]?.includes((s.type ?? "").toLowerCase()) || this.activeTab === "Crypto" && (s.type ?? "").toLowerCase() === "futures");
13706
- return filterSymbols(pool, this.input.value, this.visible, TOP_TICKERS);
14069
+ const filtered = filterSymbols(pool, this.input.value, this.visible, TOP_TICKERS);
14070
+ this.exhausted = filtered.length < this.visible;
14071
+ const folded = foldGroups(filtered);
14072
+ if (!this.expanded.size) return folded;
14073
+ const keyOf = (s) => `${(s.prefix ?? s.provider ?? "").toLowerCase()}:${s.ticker.toUpperCase()}`;
14074
+ const present = new Set(folded.map(keyOf));
14075
+ const out = [];
14076
+ for (const s of folded) {
14077
+ out.push(s);
14078
+ if (!isGroupRow(s) || !this.expanded.has(groupKeyOf(s))) continue;
14079
+ for (const m of groupMembers(all, s)) {
14080
+ if (present.has(keyOf(m))) continue;
14081
+ present.add(keyOf(m));
14082
+ out.push(m);
14083
+ }
14084
+ }
14085
+ return out;
13707
14086
  }
13708
14087
  refresh() {
13709
14088
  const doc = this.list.ownerDocument;
@@ -13718,19 +14097,20 @@ var SymbolPicker = class {
13718
14097
  this.list.appendChild(empty);
13719
14098
  return;
13720
14099
  }
13721
- for (const s of this.rows) this.list.appendChild(this.rowEl(s));
14100
+ this.rows.forEach((s, i) => this.list.appendChild(this.rowEl(s, i)));
13722
14101
  this.renderHighlight();
13723
14102
  }
13724
14103
  /** Append the page the grown `visible` just uncovered — rows already on screen stay put. */
13725
14104
  grow() {
13726
14105
  const already = this.rows.length;
13727
14106
  this.rows = this.computeRows();
13728
- for (const s of this.rows.slice(already)) this.list.appendChild(this.rowEl(s));
14107
+ this.rows.slice(already).forEach((s, j) => this.list.appendChild(this.rowEl(s, already + j)));
13729
14108
  }
13730
- rowEl(s) {
14109
+ rowEl(s, i) {
13731
14110
  const doc = this.list.ownerDocument;
13732
14111
  const row = doc.createElement("div");
13733
14112
  row.className = "vela-sp-row";
14113
+ row.dataset.i = String(i);
13734
14114
  row.dataset.ticker = s.ticker;
13735
14115
  const venue = s.prefix ?? s.provider;
13736
14116
  if (venue) row.dataset.venue = venue;
@@ -13745,6 +14125,14 @@ var SymbolPicker = class {
13745
14125
  d.textContent = s.description ?? (s.type ?? "");
13746
14126
  main.append(t, d);
13747
14127
  row.append(av, main);
14128
+ if (isGroupRow(s)) {
14129
+ row.dataset.group = "1";
14130
+ const expander = doc.createElement("button");
14131
+ expander.className = "vela-sp-expander";
14132
+ expander.setAttribute("aria-label", "Show contracts");
14133
+ expander.appendChild(iconEl(this.expanded.has(groupKeyOf(s)) ? "chevron-down" : "chevron-right", doc));
14134
+ row.appendChild(expander);
14135
+ } else if (s.group != null && this.expanded.has(groupKeyOf(s))) row.dataset.member = "1";
13748
14136
  if (venue) {
13749
14137
  const badge = doc.createElement("span");
13750
14138
  badge.className = "vela-sp-badge";
@@ -14361,6 +14749,9 @@ var CSS13 = `
14361
14749
  }
14362
14750
  .vela-mb-item:active { background: var(--vela-hover); }
14363
14751
  .vela-mb-item .vela-icon { font-size: 18px; width: 18px; height: 18px; }
14752
+ /* A lit stop (the maximize toggle while something is isolated): the inverse
14753
+ "selected" chip \u2014 white on the dark theme, dark on the light one. */
14754
+ .vela-mb-item.vela-mb-on, .vela-mb-item.vela-mb-on:active { background: var(--vela-selected-bg); color: var(--vela-selected-fg); }
14364
14755
  /* Left-aligned contributed actions get their own stops (the built-in indicators
14365
14756
  slot) \u2014 the wrapper is layout-transparent so each stop flexes like a sibling. */
14366
14757
  .vela-mb-actions { display: contents; }
@@ -14401,9 +14792,11 @@ var MobileBar = class {
14401
14792
  this.actionsHost.className = "vela-mb-actions";
14402
14793
  const onDrawings = opts.onDrawingsClick;
14403
14794
  const drawings = onDrawings ? item("vela-mb-drawings", "Drawings", onDrawings, "pen") : null;
14795
+ const onMaximize = opts.onMaximizeClick;
14796
+ this.maxEl = onMaximize ? item("vela-mb-maximize", "Maximize chart", onMaximize, "maximize") : null;
14404
14797
  const more = item("vela-mb-more", "More", opts.onMoreClick, "kebab");
14405
14798
  const settings = item("vela-mb-settings", "Chart settings", opts.onSettingsClick, "gear");
14406
- this.el.append(this.symbolEl, this.tfEl, ...indicators ? [indicators] : [], this.actionsHost, ...drawings ? [drawings] : [], more, settings);
14799
+ this.el.append(this.symbolEl, this.tfEl, ...indicators ? [indicators] : [], this.actionsHost, ...drawings ? [drawings] : [], ...this.maxEl ? [this.maxEl] : [], more, settings);
14407
14800
  host.appendChild(this.el);
14408
14801
  this.renderActions();
14409
14802
  }
@@ -14435,6 +14828,14 @@ var MobileBar = class {
14435
14828
  setTimeframe(tf) {
14436
14829
  this.tfEl.textContent = timeframeLabel(tf);
14437
14830
  }
14831
+ /** Light the maximize stop while something is isolated (a chart over the grid,
14832
+ * or a maximized pane inside the active chart) — inverse chip + restore glyph. */
14833
+ setMaximizeActive(on) {
14834
+ if (!this.maxEl) return;
14835
+ this.maxEl.classList.toggle("vela-mb-on", on);
14836
+ this.maxEl.setAttribute("aria-label", on ? "Restore layout" : "Maximize chart");
14837
+ this.maxEl.replaceChildren(iconEl(on ? "restore" : "maximize", this.el.ownerDocument));
14838
+ }
14438
14839
  destroy() {
14439
14840
  this.el.remove();
14440
14841
  }
@@ -16612,7 +17013,7 @@ var DrawingToolbar = class {
16612
17013
  this.root.replaceChildren();
16613
17014
  this.groupCells.clear();
16614
17015
  this.groupIcons.clear();
16615
- this.cursorBtn = this.makeButton(CURSOR_ICON, "Cursor", () => this.onArm(null));
17016
+ this.cursorBtn = this.makeButton(CURSOR_ICON, "Cursor", () => this.onCursorClick());
16616
17017
  this.root.appendChild(this.cursorBtn);
16617
17018
  if (this.def.groups.length > 0) this.root.appendChild(this.divider());
16618
17019
  for (const g of this.def.groups) {
@@ -16716,6 +17117,14 @@ var DrawingToolbar = class {
16716
17117
  this.magnetIcon = icon2;
16717
17118
  return cell;
16718
17119
  }
17120
+ /** Cursor returns to select/idle: an active measure/eraser mode exits through its own
17121
+ * toggle callback (disarming a tool via `onArm(null)` alone can't — the host treats a
17122
+ * null arm as a no-op side effect of entering those modes), then the tool disarms. */
17123
+ onCursorClick() {
17124
+ if (this.measureActive) this.onMeasure();
17125
+ if (this.eraserActive) this.onEraser();
17126
+ this.onArm(null);
17127
+ }
16719
17128
  /** Clicking the icon arms the group's last-used tool (it does NOT open the flyout). */
16720
17129
  onGroupIconClick(group) {
16721
17130
  const type = this.lastUsed.get(group.id) ?? group.tools[0]?.type;
@@ -17510,6 +17919,7 @@ var IndicatorHandleImpl = class {
17510
17919
  constructor(id, title, controller, source) {
17511
17920
  this.controller = controller;
17512
17921
  this.schema = [];
17922
+ this.propsSchema = [];
17513
17923
  this.visibleState = true;
17514
17924
  this.bus = new TypedEventBus();
17515
17925
  this.id = id;
@@ -17519,6 +17929,9 @@ var IndicatorHandleImpl = class {
17519
17929
  get inputs() {
17520
17930
  return this.schema;
17521
17931
  }
17932
+ get props() {
17933
+ return this.propsSchema;
17934
+ }
17522
17935
  get visible() {
17523
17936
  return this.visibleState;
17524
17937
  }
@@ -17528,6 +17941,12 @@ var IndicatorHandleImpl = class {
17528
17941
  setInputs(values) {
17529
17942
  this.controller.applyInputs(this.id, values);
17530
17943
  }
17944
+ setProp(key, value) {
17945
+ this.controller.applyProps(this.id, { [key]: value });
17946
+ }
17947
+ setProps(values) {
17948
+ this.controller.applyProps(this.id, values);
17949
+ }
17531
17950
  setVisible(visible) {
17532
17951
  this.controller.setVisible(this.id, visible);
17533
17952
  }
@@ -17547,6 +17966,9 @@ var IndicatorHandleImpl = class {
17547
17966
  setSchema(schema) {
17548
17967
  this.schema = schema;
17549
17968
  }
17969
+ setPropsSchema(schema) {
17970
+ this.propsSchema = schema;
17971
+ }
17550
17972
  /** Sync the public `visible` getter when the orchestrator changes visibility (API or legend eye). */
17551
17973
  setVisibleState(visible) {
17552
17974
  this.visibleState = visible;
@@ -17560,6 +17982,8 @@ var IndicatorHandleImpl = class {
17560
17982
  function summarizeModel(model) {
17561
17983
  const series = {};
17562
17984
  for (const s of model.series) series[s.kind] = (series[s.kind] ?? 0) + 1;
17985
+ const countOverlay = (items) => items?.filter((i) => i.overlay === true).length ?? 0;
17986
+ 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);
17563
17987
  return {
17564
17988
  id: model.id,
17565
17989
  title: model.title,
@@ -17579,7 +18003,8 @@ function summarizeModel(model) {
17579
18003
  tables: model.tables?.length ?? 0,
17580
18004
  barColors: model.barColors?.length ?? 0,
17581
18005
  trades: model.trades?.length ?? 0,
17582
- inputs: model.inputs.length
18006
+ inputs: model.inputs.length,
18007
+ forcedOverlay
17583
18008
  };
17584
18009
  }
17585
18010
  function inspectModels(models) {
@@ -17600,7 +18025,8 @@ function inspectModels(models) {
17600
18025
  linefills: sum((s) => s.linefills),
17601
18026
  tables: sum((s) => s.tables),
17602
18027
  barColors: sum((s) => s.barColors),
17603
- trades: sum((s) => s.trades)
18028
+ trades: sum((s) => s.trades),
18029
+ forcedOverlay: sum((s) => s.forcedOverlay)
17604
18030
  }
17605
18031
  };
17606
18032
  }
@@ -17711,6 +18137,10 @@ var EngineOrchestrator = class _EngineOrchestrator {
17711
18137
  /** Invalidates detached async work (backfill loops, in-flight loads, gap heals):
17712
18138
  * bumped by init(), setMarket() and destroy(). */
17713
18139
  this.generation = 0;
18140
+ /** Aborts the in-flight PROGRESSIVE load's source polling on supersession — an
18141
+ * abandoned stream left polling to its own budget starves the browser's per-host
18142
+ * connection pool, and the NEXT symbol's very first fetch with it (measured). */
18143
+ this.progressiveAbort = null;
17714
18144
  /** Awaiters racing a superseded load (setMarket callers) — released on every bump so they never hang. */
17715
18145
  this.supersedeWaiters = [];
17716
18146
  /** `history:complete` fired for the CURRENT load. Each market load re-arms the cycle
@@ -17744,7 +18174,9 @@ var EngineOrchestrator = class _EngineOrchestrator {
17744
18174
  for (const engine of engines) this.registerEngine(engine.language, engine);
17745
18175
  this.defaultLanguage = config.defaultLanguage;
17746
18176
  this.renderer.mount(container, config.theme);
17747
- this.renderer.onInputChange((e) => this.applyInputs(e.indicatorId, { [e.key]: e.value }));
18177
+ this.renderer.onInputChange(
18178
+ (e) => e.kind === "prop" ? this.applyProps(e.indicatorId, { [e.key]: e.value }) : this.applyInputs(e.indicatorId, { [e.key]: e.value })
18179
+ );
17748
18180
  this.renderer.onRemoveIndicator((id) => this.removeIndicator(id));
17749
18181
  this.renderer.onToggleIndicatorVisible?.((id, visible) => this.setVisible(id, visible));
17750
18182
  this.paneActionUnsub = this.renderer.onPaneAction?.((a) => this.handlePaneAction(a)) ?? null;
@@ -17851,6 +18283,8 @@ var EngineOrchestrator = class _EngineOrchestrator {
17851
18283
  * superseded setMarket awaiters so their promises resolve instead of hanging. */
17852
18284
  bumpGeneration() {
17853
18285
  const gen = ++this.generation;
18286
+ this.progressiveAbort?.abort();
18287
+ this.progressiveAbort = null;
17854
18288
  for (const w of this.supersedeWaiters.splice(0)) w();
17855
18289
  return gen;
17856
18290
  }
@@ -17904,7 +18338,47 @@ var EngineOrchestrator = class _EngineOrchestrator {
17904
18338
  const requested = market.bars ?? 500;
17905
18339
  const initialRange = market.visibleRange;
17906
18340
  const deep = !market.data?.length && initialRange == null && requested > SINGLE_LOAD_BARS;
17907
- if (deep && this.feed.loadRange) {
18341
+ let progressiveServed = false;
18342
+ if (!market.data?.length && initialRange == null && this.feed.loadProgressive) {
18343
+ let painted = false;
18344
+ const paint = (bars, final) => {
18345
+ if (this.generation !== gen || !final && bars.length === 0) return;
18346
+ this.setBarSeries(bars, painted ? { preserveView: true } : void 0);
18347
+ if (!painted && bars.length > 0) {
18348
+ painted = true;
18349
+ if (opts.firstLoad) this.activateBarLayers();
18350
+ if (!final) this.historyState = "backfill";
18351
+ }
18352
+ };
18353
+ const abort = new AbortController();
18354
+ this.progressiveAbort = abort;
18355
+ progressiveServed = await new Promise((firstPaint) => {
18356
+ let signaled = false;
18357
+ const signal = (served) => {
18358
+ if (!signaled) {
18359
+ signaled = true;
18360
+ firstPaint(served);
18361
+ }
18362
+ };
18363
+ abort.signal.addEventListener("abort", () => signal(true), { once: true });
18364
+ this.feed.loadProgressive(market, (bars) => {
18365
+ paint(bars, false);
18366
+ if (painted) signal(true);
18367
+ }, { signal: abort.signal }).then((full) => {
18368
+ if (this.progressiveAbort === abort) this.progressiveAbort = null;
18369
+ if (full == null) return signal(false);
18370
+ if (this.generation !== gen) return signal(true);
18371
+ paint(full, true);
18372
+ this.completeHistory(full.length >= requested ? "depth" : "genesis");
18373
+ signal(true);
18374
+ }).catch(() => {
18375
+ if (this.progressiveAbort === abort) this.progressiveAbort = null;
18376
+ if (this.generation === gen) this.completeHistory("aborted");
18377
+ signal(true);
18378
+ });
18379
+ });
18380
+ }
18381
+ if (progressiveServed) ; else if (deep && this.feed.loadRange) {
17908
18382
  const head = await this.feed.load({ ...market, bars: Math.min(requested, CHUNK_BARS) });
17909
18383
  if (this.generation !== gen) return;
17910
18384
  this.setBarSeries(head);
@@ -18424,7 +18898,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
18424
18898
  const title = options.title ?? "Indicator";
18425
18899
  const handle = new IndicatorHandleImpl(id, title, this, source);
18426
18900
  this.handles.set(id, handle);
18427
- this.registry.add({ id, title, source, options, inputValues: { ...options.inputs ?? {} } });
18901
+ this.registry.add({ id, title, source, options, inputValues: { ...options.inputs ?? {} }, propValues: { ...options.props ?? {} } });
18428
18902
  void this.startIndicator(id, source, options, handle);
18429
18903
  return handle;
18430
18904
  }
@@ -18447,7 +18921,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
18447
18921
  return handle;
18448
18922
  }
18449
18923
  const inputValues = { ...descriptor.defaultInputs(), ...options.inputs ?? {} };
18450
- this.registry.add({ id, title, source: type, inputValues, native: { type, instance: descriptor.create(), descriptor } });
18924
+ this.registry.add({ id, title, source: type, inputValues, propValues: {}, native: { type, instance: descriptor.create(), descriptor } });
18451
18925
  handle.setSchema(descriptor.inputsSchema());
18452
18926
  void this.startNativeIndicator(id, handle);
18453
18927
  return handle;
@@ -18500,6 +18974,19 @@ var EngineOrchestrator = class _EngineOrchestrator {
18500
18974
  if (record.session) record.session.update(record.inputValues);
18501
18975
  else if (record.native && !record.hidden) record.native.instance.setInputs(record.inputValues);
18502
18976
  }
18977
+ /** IndicatorController: re-run an indicator with merged declaration-prop overrides.
18978
+ * Same lifecycle as {@link applyInputs} — a prop change replays the whole script.
18979
+ * Script indicators only: natives have no declaration props. */
18980
+ applyProps(id, values) {
18981
+ const record = this.registry.get(id);
18982
+ if (!record?.session) return;
18983
+ record.propValues = { ...record.propValues, ...values };
18984
+ if (record.renderHandle) this.renderer.setIndicatorInputs(record.renderHandle, record.inputValues, record.propValues);
18985
+ record.pendingStructural = true;
18986
+ if (!record.hidden) this.setLoading(record, true);
18987
+ record.pendingCause = "inputs";
18988
+ record.session.update(record.inputValues, record.propValues);
18989
+ }
18503
18990
  /** IndicatorController: tear down an indicator and (if now empty) its pane. */
18504
18991
  /** Live handles of every indicator on the chart (script + native), insertion order. */
18505
18992
  listIndicators() {
@@ -18648,6 +19135,10 @@ var EngineOrchestrator = class _EngineOrchestrator {
18648
19135
  for (const input of prepared.inputs) defaults2[input.key] = input.defval;
18649
19136
  record.inputValues = { ...defaults2, ...record.inputValues };
18650
19137
  handle.setSchema(prepared.inputs);
19138
+ const propDefaults = {};
19139
+ for (const prop of prepared.props ?? []) propDefaults[prop.key] = prop.defval;
19140
+ record.propValues = { ...propDefaults, ...record.propValues };
19141
+ handle.setPropsSchema(prepared.props ?? []);
18651
19142
  this.mountLoadingPlaceholder(id, record);
18652
19143
  this.executeIndicator(id, handle);
18653
19144
  } catch (err) {
@@ -18671,6 +19162,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
18671
19162
  getBars: () => this.bars,
18672
19163
  fetchSeries: (sym, tf, range) => this.fetchSeries(sym, tf, range),
18673
19164
  inputs: record.inputValues,
19165
+ props: record.propValues,
18674
19166
  visibleRange: this.currentVisibleRange(),
18675
19167
  mode,
18676
19168
  // Mid-backfill session starts (add / re-show / price-style re-execute) let the
@@ -18764,6 +19256,9 @@ var EngineOrchestrator = class _EngineOrchestrator {
18764
19256
  const meta = record.prepared.meta;
18765
19257
  const model = {
18766
19258
  id,
19259
+ // Deliberately the FULL title, never a shorttitle: while the script loads the
19260
+ // legend identifies it by its full name; the compact shorttitle arrives with
19261
+ // the first computed model and takes over from there.
18767
19262
  title: record.options?.title ?? meta.title,
18768
19263
  overlay: meta.overlay,
18769
19264
  paneHint: meta.overlay ? "price" : "new",
@@ -18772,7 +19267,8 @@ var EngineOrchestrator = class _EngineOrchestrator {
18772
19267
  backgrounds: [],
18773
19268
  priceLines: [],
18774
19269
  inputs: record.prepared.inputs,
18775
- inputValues: record.inputValues
19270
+ inputValues: record.inputValues,
19271
+ ...record.prepared.props ? { props: record.prepared.props, propValues: record.propValues } : {}
18776
19272
  };
18777
19273
  const paneId = this.routePane(id, model, record.options ?? {});
18778
19274
  this.placeModel(model, id, paneId);
@@ -19005,7 +19501,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
19005
19501
  record.model = model;
19006
19502
  if (record.pendingStructural) {
19007
19503
  record.renderHandle = this.renderer.mountIndicator(model);
19008
- this.renderer.setIndicatorInputs(record.renderHandle, record.inputValues);
19504
+ this.renderer.setIndicatorInputs(record.renderHandle, record.inputValues, record.propValues);
19009
19505
  record.pendingStructural = false;
19010
19506
  } else {
19011
19507
  this.renderer.updateIndicator(record.renderHandle, modelToValuePatch(model));
@@ -19032,16 +19528,17 @@ var EngineOrchestrator = class _EngineOrchestrator {
19032
19528
  placeModel(model, id, paneId) {
19033
19529
  model.id = id;
19034
19530
  model.paneId = paneId;
19035
- for (const series of model.series) series.paneId = paneId;
19036
- for (const fill of model.fills) fill.paneId = paneId;
19037
- for (const bg of model.backgrounds) bg.paneId = paneId;
19531
+ const paneOf = (item) => item.overlay === true ? "price" : paneId;
19532
+ for (const series of model.series) series.paneId = paneOf(series);
19533
+ for (const fill of model.fills) fill.paneId = paneOf(fill);
19534
+ for (const bg of model.backgrounds) bg.paneId = paneOf(bg);
19038
19535
  for (const line of model.priceLines) line.paneId = paneId;
19039
- if (model.lines) for (const ln of model.lines) ln.paneId = paneId;
19040
- if (model.boxes) for (const bx of model.boxes) bx.paneId = paneId;
19041
- if (model.labels) for (const lb of model.labels) lb.paneId = paneId;
19042
- if (model.polylines) for (const pl of model.polylines) pl.paneId = paneId;
19043
- if (model.linefills) for (const lf of model.linefills) lf.paneId = paneId;
19044
- if (model.tables) for (const tb of model.tables) tb.paneId = paneId;
19536
+ if (model.lines) for (const ln of model.lines) ln.paneId = paneOf(ln);
19537
+ if (model.boxes) for (const bx of model.boxes) bx.paneId = paneOf(bx);
19538
+ if (model.labels) for (const lb of model.labels) lb.paneId = paneOf(lb);
19539
+ if (model.polylines) for (const pl of model.polylines) pl.paneId = paneOf(pl);
19540
+ if (model.linefills) for (const lf of model.linefills) lf.paneId = paneOf(lf);
19541
+ if (model.tables) for (const tb of model.tables) tb.paneId = paneOf(tb);
19045
19542
  }
19046
19543
  emitContextChanged(id) {
19047
19544
  if (!this.registry.get(id)?.session?.getContext) return;
@@ -19194,6 +19691,15 @@ var RendererControl = class {
19194
19691
  setLegendActions(provider) {
19195
19692
  this.renderer.setLegendActions?.(provider);
19196
19693
  }
19694
+ /**
19695
+ * Wire the legend rows' HOST-CONTRIBUTED callout bubbles (the shells route the
19696
+ * plugin registry through this; see `registerLegendCallout`). Silent on a renderer
19697
+ * without the seam — contributed callouts simply never show there, same graceful
19698
+ * degradation as {@link setLegendActions}.
19699
+ */
19700
+ setLegendCallouts(provider) {
19701
+ this.renderer.setLegendCallouts?.(provider);
19702
+ }
19197
19703
  /**
19198
19704
  * Replace the indicator legend's fold toggle with a host action (or restore it with
19199
19705
  * `null`) — multi-chart shells point the chip at their indicator overview instead of
@@ -19703,6 +20209,10 @@ function inputVisible(when, values) {
19703
20209
  }
19704
20210
 
19705
20211
  // src/renderers/shared/IndicatorInputsDialog.ts
20212
+ var PROPS_TAB = "Properties";
20213
+ function bagOf(row, decl) {
20214
+ return decl.prop ? row.propValues : row.values;
20215
+ }
19706
20216
  var SOURCES = ["close", "open", "high", "low", "hl2", "hlc3", "ohlc4", "volume"];
19707
20217
  var IndicatorInputsDialog = class {
19708
20218
  constructor(host) {
@@ -19713,6 +20223,7 @@ var IndicatorInputsDialog = class {
19713
20223
  this.openId = null;
19714
20224
  this.row = null;
19715
20225
  this.snapshot = null;
20226
+ this.propSnapshot = null;
19716
20227
  this.dialogTips = [];
19717
20228
  /** Re-applies every `when` gate against current values; set while the dialog is open. */
19718
20229
  this.refreshVisibility = null;
@@ -19735,10 +20246,11 @@ var IndicatorInputsDialog = class {
19735
20246
  }
19736
20247
  open(row) {
19737
20248
  this.close();
19738
- if (row.inputs.length === 0) return;
20249
+ if (row.inputs.length === 0 && row.props.length === 0) return;
19739
20250
  this.row = row;
19740
20251
  this.openId = row.id;
19741
20252
  this.snapshot = { ...row.values };
20253
+ this.propSnapshot = { ...row.propValues };
19742
20254
  ensureDialogStyles();
19743
20255
  const t = this.host.theme();
19744
20256
  const border = "var(--vela-border)";
@@ -19746,7 +20258,7 @@ var IndicatorInputsDialog = class {
19746
20258
  const host = this.host.dialogHost() ?? this.host.container;
19747
20259
  const ui = new Dialog({
19748
20260
  host,
19749
- title: row.settingsTitle,
20261
+ title: row.title,
19750
20262
  // Non-modal: live-edit dialog — a modal machine locks pointer events on the
19751
20263
  // whole body, killing the chart and the body-portaled popovers.
19752
20264
  modal: false,
@@ -19778,6 +20290,16 @@ var IndicatorInputsDialog = class {
19778
20290
  text: () => "Close"
19779
20291
  }));
19780
20292
  const tabDefs = tabInputs(row.inputs);
20293
+ if (row.props.length > 0) {
20294
+ const propDecls = row.props.map((p) => {
20295
+ const d = { ...p, prop: true };
20296
+ delete d.tab;
20297
+ return d;
20298
+ });
20299
+ const existing = tabDefs.find((t2) => t2.name === PROPS_TAB);
20300
+ if (existing) existing.inputs.push(...propDecls);
20301
+ else tabDefs.push({ name: PROPS_TAB, inputs: propDecls });
20302
+ }
19781
20303
  const tabs = document.createElement("div");
19782
20304
  tabs.style.cssText = `display:flex;gap:12px;padding:0 20px;border-bottom:1px solid ${border};flex:0 0 auto;`;
19783
20305
  const tabEls = [];
@@ -19880,9 +20402,10 @@ var IndicatorInputsDialog = class {
19880
20402
  this.openId = null;
19881
20403
  this.row = null;
19882
20404
  this.snapshot = null;
20405
+ this.propSnapshot = null;
19883
20406
  ui?.destroy();
19884
20407
  }
19885
- /** Restore every input to its open-time value (re-running the indicator), then close. */
20408
+ /** Restore every input and prop to its open-time value (re-running the indicator), then close. */
19886
20409
  revertAndClose() {
19887
20410
  const row = this.row;
19888
20411
  const snap = this.snapshot;
@@ -19896,6 +20419,17 @@ var IndicatorInputsDialog = class {
19896
20419
  }
19897
20420
  }
19898
20421
  }
20422
+ const propSnap = this.propSnapshot;
20423
+ if (row && propSnap) {
20424
+ for (const p of row.props) {
20425
+ const snapped = propSnap[p.key];
20426
+ const before = snapped !== void 0 ? snapped : p.defval;
20427
+ if (row.propValues[p.key] !== before) {
20428
+ row.propValues[p.key] = before;
20429
+ this.host.onChange?.({ indicatorId: row.id, key: p.key, value: before, kind: "prop" });
20430
+ }
20431
+ }
20432
+ }
19899
20433
  this.close();
19900
20434
  }
19901
20435
  /** The footer's reset button — same chip as Cancel, pinned to the LEFT edge
@@ -19913,19 +20447,27 @@ var IndicatorInputsDialog = class {
19913
20447
  const row = this.row;
19914
20448
  if (!row) return;
19915
20449
  const snap = this.snapshot;
20450
+ const propSnap = this.propSnapshot;
19916
20451
  for (const inp of row.inputs) {
19917
20452
  if (row.values[inp.key] !== inp.defval) {
19918
20453
  row.values[inp.key] = inp.defval;
19919
20454
  this.host.onChange?.({ indicatorId: row.id, key: inp.key, value: inp.defval });
19920
20455
  }
19921
20456
  }
20457
+ for (const p of row.props) {
20458
+ if (row.propValues[p.key] !== p.defval) {
20459
+ row.propValues[p.key] = p.defval;
20460
+ this.host.onChange?.({ indicatorId: row.id, key: p.key, value: p.defval, kind: "prop" });
20461
+ }
20462
+ }
19922
20463
  this.open(row);
19923
20464
  if (snap) this.snapshot = snap;
20465
+ if (propSnap) this.propSnapshot = propSnap;
19924
20466
  }
19925
20467
  /** Write one edit through: store it, notify the host, and re-apply the `when` gates. */
19926
- commit(row, key, value) {
19927
- row.values[key] = value;
19928
- this.host.onChange?.({ indicatorId: row.id, key, value });
20468
+ commit(row, decl, value) {
20469
+ bagOf(row, decl)[decl.key] = value;
20470
+ this.host.onChange?.({ indicatorId: row.id, key: decl.key, value, ...decl.prop ? { kind: "prop" } : {} });
19929
20471
  this.refreshVisibility?.();
19930
20472
  }
19931
20473
  /** One settings row (or several `inline=` inputs) placed into a section's grid. */
@@ -19952,7 +20494,7 @@ var IndicatorInputsDialog = class {
19952
20494
  const id = idOf(lead);
19953
20495
  const info = lead.tooltip ? this.infoButton(lead.tooltip) : void 0;
19954
20496
  if (lead.type === "bool") {
19955
- const current = Boolean(row.values[lead.key] ?? lead.defval);
20497
+ const current = Boolean(bagOf(row, lead)[lead.key] ?? lead.defval);
19956
20498
  return append(fieldRow({
19957
20499
  label: nameOf(lead),
19958
20500
  id,
@@ -19961,7 +20503,7 @@ var IndicatorInputsDialog = class {
19961
20503
  toggle: {
19962
20504
  id,
19963
20505
  checked: current,
19964
- onChange: (v) => this.commit(row, lead.key, v)
20506
+ onChange: (v) => this.commit(row, lead, v)
19965
20507
  }
19966
20508
  }));
19967
20509
  }
@@ -19986,8 +20528,8 @@ var IndicatorInputsDialog = class {
19986
20528
  }
19987
20529
  /** Build the typed control for one input, committing edits live via `onChange`. */
19988
20530
  buildControl(row, inp, id) {
19989
- const current = row.values[inp.key] ?? inp.defval;
19990
- const emit = (value) => this.commit(row, inp.key, value);
20531
+ const current = bagOf(row, inp)[inp.key] ?? inp.defval;
20532
+ const emit = (value) => this.commit(row, inp, value);
19991
20533
  if (inp.type === "bool") {
19992
20534
  return buildFieldControl({ kind: "switch", id, checked: Boolean(current), onChange: (v) => emit(v) }).el;
19993
20535
  }
@@ -19998,7 +20540,7 @@ var IndicatorInputsDialog = class {
19998
20540
  kind: "color",
19999
20541
  id,
20000
20542
  theme: this.host.theme(),
20001
- get: () => String(row.values[inp.key] ?? inp.defval),
20543
+ get: () => String(bagOf(row, inp)[inp.key] ?? inp.defval),
20002
20544
  onChange: (v) => emit(v)
20003
20545
  }).el;
20004
20546
  }
@@ -20561,6 +21103,7 @@ function timeParts(ts) {
20561
21103
  }
20562
21104
 
20563
21105
  // src/renderers/shared/InputsUI.ts
21106
+ var LEGEND_AT_TOP_ATTR = "data-vela-pane-at-top";
20564
21107
  var STATUS_KEYFRAMES = "@keyframes vela-ind-pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.3;transform:scale(.6)}}";
20565
21108
  var LEGEND_ICON_PX2 = 16;
20566
21109
  var LEGEND_CTL_PX = 18;
@@ -20578,6 +21121,9 @@ var CLOSE_SVG = iconAt("close", LEGEND_ICON_PX2);
20578
21121
  var FOLD_SVG = iconAt("chevron-up", LEGEND_ICON_PX2);
20579
21122
  var UNFOLD_SVG = iconAt("chevron-down", LEGEND_ICON_PX2);
20580
21123
  var OVERVIEW_SVG = iconAt("objects", LEGEND_ICON_PX2);
21124
+ function legendCalloutsDisplay(open2, hasCallouts) {
21125
+ return !open2 && hasCallouts ? "inline-flex" : "none";
21126
+ }
20581
21127
  var InputsUI = class {
20582
21128
  constructor(container, theme, paneBoundsOf) {
20583
21129
  this.container = container;
@@ -20599,10 +21145,14 @@ var InputsUI = class {
20599
21145
  this.moveApi = null;
20600
21146
  /** Host-contributed legend actions, resolved PER ROW at render time (see setLegendActions). */
20601
21147
  this.legendActions = null;
21148
+ /** Host-contributed callout bubbles, resolved PER ROW at render time (see setLegendCallouts). */
21149
+ this.legendCallouts = null;
20602
21150
  /** Chrome-tooltip disposers, per row id — a tip open at removal must not outlive its row. */
20603
21151
  this.rowTips = /* @__PURE__ */ new Map();
20604
21152
  /** Same, for the contributed extras only (rebuilt independently by setLegendActions). */
20605
21153
  this.extrasTips = /* @__PURE__ */ new Map();
21154
+ /** Same, for the contributed callouts only (rebuilt independently by setLegendCallouts). */
21155
+ this.calloutTips = /* @__PURE__ */ new Map();
20606
21156
  /** Open "Move to" menu (kept so it can be torn down). */
20607
21157
  this.moveMenu = null;
20608
21158
  this.moveTargets = [];
@@ -20760,6 +21310,42 @@ var InputsUI = class {
20760
21310
  extrasEl.appendChild(btn2);
20761
21311
  }
20762
21312
  }
21313
+ /**
21314
+ * Wire the host-contributed callout bubbles. Same contract as
21315
+ * {@link setLegendActions}: re-calling replaces the provider and re-projects the
21316
+ * rows already on screen.
21317
+ */
21318
+ setLegendCallouts(provider) {
21319
+ this.legendCallouts = provider;
21320
+ for (const row of this.rows.values()) this.renderCallouts(row);
21321
+ }
21322
+ /** (Re)build one row's callout bubbles — tinted icon circles beside the title whose
21323
+ * click (when the view carries content) deploys a panel of text and actions. */
21324
+ renderCallouts(row) {
21325
+ this.disposeTips(this.calloutTips, row.id);
21326
+ for (const bubble of row.callouts) bubble.destroy();
21327
+ row.callouts = [];
21328
+ row.calloutsEl.replaceChildren();
21329
+ const views = this.legendCallouts?.(row.id) ?? [];
21330
+ row.calloutsEl.style.display = legendCalloutsDisplay(row.highlighted, views.length > 0);
21331
+ for (const view of views) {
21332
+ const bubble = new CalloutBubble({
21333
+ icon: view.icon,
21334
+ background: view.background,
21335
+ ...view.color !== void 0 ? { color: view.color } : {},
21336
+ label: view.tooltip,
21337
+ ...view.content !== void 0 ? { panel: view.content } : {},
21338
+ // The panel portals into the plot host and self-tokens: it must work on
21339
+ // a bare chart, where no `.vela-ui` token ancestor exists.
21340
+ host: this.container,
21341
+ theme: () => this.theme
21342
+ });
21343
+ bubble.el.dataset.legendCallout = view.id;
21344
+ this.tip(this.calloutTips, row.id, bubble.el, view.tooltip);
21345
+ row.callouts.push(bubble);
21346
+ row.calloutsEl.appendChild(bubble.el);
21347
+ }
21348
+ }
20763
21349
  /** Reposition the per-pane legend containers after a layout change. */
20764
21350
  reposition() {
20765
21351
  for (const [paneId, lg] of this.legends) this.positionLegend(lg, paneId);
@@ -20927,6 +21513,7 @@ var InputsUI = class {
20927
21513
  }
20928
21514
  positionLegend(lg, paneId) {
20929
21515
  const bounds = this.paneBoundsOf ? this.paneBoundsOf(paneId) : { top: 0, height: Infinity };
21516
+ lg.toggleAttribute(LEGEND_AT_TOP_ATTR, bounds.top === 0);
20930
21517
  lg.style.display = bounds.height < 4 || !this.titlesVisible ? "none" : "flex";
20931
21518
  const collapsed = this.paneCollapse.has(paneId);
20932
21519
  const masterId = this.paneCollapse.get(paneId) ?? null;
@@ -21002,13 +21589,13 @@ var InputsUI = class {
21002
21589
  }
21003
21590
  /** Create or update an indicator's legend row (in the legend for its pane). */
21004
21591
  upsert(id, title, inputs, values, paneId = "price", opts = {}) {
21005
- const settingsTitle = opts.settingsTitle ?? title;
21006
21592
  const existing = this.rows.get(id);
21007
21593
  if (existing) {
21008
21594
  existing.title = title;
21009
- existing.settingsTitle = settingsTitle;
21010
21595
  existing.inputs = inputs;
21011
21596
  existing.values = { ...values };
21597
+ existing.props = opts.props ?? [];
21598
+ existing.propValues = { ...opts.propValues ?? {} };
21012
21599
  existing.titleEl.textContent = title;
21013
21600
  if (existing.paneId !== paneId) {
21014
21601
  existing.paneId = paneId;
@@ -21060,6 +21647,9 @@ var InputsUI = class {
21060
21647
  titleWrap.appendChild(beta);
21061
21648
  }
21062
21649
  el.appendChild(titleWrap);
21650
+ const calloutsEl = document.createElement("span");
21651
+ calloutsEl.style.cssText = `display:none;align-items:center;gap:4px;flex:none;margin-left:${LEGEND_TITLE_STATUS_GAP_PX}px;`;
21652
+ el.appendChild(calloutsEl);
21063
21653
  el.appendChild(statusEl);
21064
21654
  const valuesEl = document.createElement("span");
21065
21655
  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;`;
@@ -21076,13 +21666,13 @@ var InputsUI = class {
21076
21666
  eye.className = "vela-ind-ctl";
21077
21667
  eye.style.cssText = LEGEND_CTL_CSS;
21078
21668
  eye.addEventListener("click", () => {
21079
- const row = this.rows.get(id);
21080
- this.onToggleVisible?.(id, Boolean(row?.hidden));
21669
+ const row2 = this.rows.get(id);
21670
+ this.onToggleVisible?.(id, Boolean(row2?.hidden));
21081
21671
  });
21082
21672
  controlsEl.appendChild(eye);
21083
21673
  eyeEl = eye;
21084
21674
  }
21085
- if (inputs.length > 0) {
21675
+ if (inputs.length > 0 || (opts.props ?? []).length > 0) {
21086
21676
  const gear = document.createElement("button");
21087
21677
  gear.type = "button";
21088
21678
  gear.setAttribute("aria-label", "Settings");
@@ -21122,7 +21712,9 @@ var InputsUI = class {
21122
21712
  controlsEl.appendChild(close);
21123
21713
  el.appendChild(controlsEl);
21124
21714
  this.attach(this.legendFor(paneId), el, !!opts.native);
21125
- 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 });
21715
+ 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 };
21716
+ this.rows.set(id, row);
21717
+ this.renderCallouts(row);
21126
21718
  this.syncFoldToggle();
21127
21719
  }
21128
21720
  /** Place a row in its pane's legend — native rows PREPEND (pinned to the top), Pine rows append. */
@@ -21131,9 +21723,11 @@ var InputsUI = class {
21131
21723
  else container.appendChild(el);
21132
21724
  }
21133
21725
  /** Reflect programmatic input changes (so a re-opened dialog shows current values). */
21134
- setValues(id, values) {
21726
+ setValues(id, values, props) {
21135
21727
  const row = this.rows.get(id);
21136
- if (row) row.values = { ...row.values, ...values };
21728
+ if (!row) return;
21729
+ row.values = { ...row.values, ...values };
21730
+ if (props) row.propValues = { ...row.propValues, ...props };
21137
21731
  }
21138
21732
  /**
21139
21733
  * Reflect an indicator's live status in its legend row: `'loading'` shows three pulsing
@@ -21215,8 +21809,13 @@ var InputsUI = class {
21215
21809
  syncRowActions(row) {
21216
21810
  const open2 = row.highlighted;
21217
21811
  row.controlsEl.style.display = open2 || row.hidden ? "inline-flex" : "none";
21218
- if (open2) row.el.appendChild(row.statusEl);
21219
- else row.el.insertBefore(row.statusEl, row.valuesEl);
21812
+ if (open2) {
21813
+ row.el.appendChild(row.statusEl);
21814
+ for (const bubble of row.callouts) bubble.hidePanel();
21815
+ } else {
21816
+ row.el.insertBefore(row.statusEl, row.valuesEl);
21817
+ }
21818
+ row.calloutsEl.style.display = legendCalloutsDisplay(open2, row.callouts.length > 0);
21220
21819
  for (const child of Array.from(row.controlsEl.children)) {
21221
21820
  if (!(child instanceof HTMLElement) || child === row.eyeEl) continue;
21222
21821
  if (child === row.extrasEl) {
@@ -21234,10 +21833,12 @@ var InputsUI = class {
21234
21833
  }
21235
21834
  remove(id) {
21236
21835
  const row = this.rows.get(id);
21836
+ for (const bubble of row?.callouts ?? []) bubble.destroy();
21237
21837
  row?.el.remove();
21238
21838
  this.rows.delete(id);
21239
21839
  this.disposeTips(this.rowTips, id);
21240
21840
  this.disposeTips(this.extrasTips, id);
21841
+ this.disposeTips(this.calloutTips, id);
21241
21842
  if (this.selectedId === id) this.selectedId = null;
21242
21843
  if (this.inputsDialog.openId === id) this.inputsDialog.close();
21243
21844
  this.syncFoldToggle();
@@ -21257,6 +21858,8 @@ var InputsUI = class {
21257
21858
  this.rowMenu = null;
21258
21859
  for (const id of [...this.rowTips.keys()]) this.disposeTips(this.rowTips, id);
21259
21860
  for (const id of [...this.extrasTips.keys()]) this.disposeTips(this.extrasTips, id);
21861
+ for (const id of [...this.calloutTips.keys()]) this.disposeTips(this.calloutTips, id);
21862
+ for (const row of this.rows.values()) for (const bubble of row.callouts) bubble.destroy();
21260
21863
  for (const lg of this.legends.values()) lg.remove();
21261
21864
  this.legends.clear();
21262
21865
  this.rows.clear();
@@ -21302,7 +21905,7 @@ var ICONS = {
21302
21905
  };
21303
21906
  var STYLE_ID21 = "vela-pane-controls";
21304
21907
  var ICON_PX = 12;
21305
- var CLUSTER_PILL = "rgba(0,0,0,0.28)";
21908
+ var CLUSTER_PILL = "rgba(0,0,0,0.65)";
21306
21909
  function ensureStyles3() {
21307
21910
  if (typeof document === "undefined" || document.getElementById(STYLE_ID21)) return;
21308
21911
  const st = document.createElement("style");
@@ -21322,8 +21925,12 @@ var PaneControls = class {
21322
21925
  this.deps = deps;
21323
21926
  this.clusters = /* @__PURE__ */ new Map();
21324
21927
  this.hoverPaneId = null;
21928
+ /** Mobile: hover clusters are meaningless without a cursor — suppressed; a
21929
+ * collapsed pane's standalone expand chip stays (the only way back up). */
21930
+ this.suspended = false;
21325
21931
  /** Reveal the cluster for the pane under the cursor, resolved from the pointer's y in the plot. */
21326
21932
  this.onPlotMove = (e) => {
21933
+ if (this.suspended) return;
21327
21934
  const rect = this.plot.getBoundingClientRect();
21328
21935
  const y = e.clientY - rect.top;
21329
21936
  let hit = null;
@@ -21395,7 +22002,12 @@ var PaneControls = class {
21395
22002
  }
21396
22003
  if (p.count > 1) {
21397
22004
  cluster.appendChild(
21398
- this.button(p.maximized ? ICONS.restore : ICONS.maximize, p.maximized ? "Restore pane" : "Maximize pane", false, () => this.deps.onToggleMaximize(p.id), { role: "maximize" })
22005
+ this.button(p.maximized ? ICONS.restore : ICONS.maximize, p.maximized ? "Restore pane" : "Maximize pane", false, () => this.deps.onToggleMaximize(p.id), {
22006
+ role: "maximize",
22007
+ // Same inverse-chip treatment as the collapsed pane's expand toggle: the
22008
+ // maximized state must read as an active state, not just a swapped glyph.
22009
+ selected: p.maximized
22010
+ })
21399
22011
  );
21400
22012
  }
21401
22013
  }
@@ -21432,17 +22044,18 @@ var PaneControls = class {
21432
22044
  }
21433
22045
  const hovered = id === this.hoverPaneId;
21434
22046
  const hasButtons = cluster.children.length > 0;
21435
- const visible = hasButtons && (hovered || p.collapsed) && p.height > 8;
22047
+ const stateChipRole = p.collapsed ? "collapse" : !this.suspended && p.maximized ? "maximize" : null;
22048
+ const visible = hasButtons && (hovered || stateChipRole != null) && p.height > 8;
21436
22049
  cluster.style.right = `${rightPx}px`;
21437
22050
  cluster.style.top = p.collapsed ? `${p.top + Math.max(1, Math.round((p.height - 24) / 2))}px` : `${p.top + 4}px`;
21438
22051
  cluster.style.display = visible ? "flex" : "none";
21439
22052
  if (!visible) continue;
21440
- const soloExpand = p.collapsed && !hovered;
21441
- cluster.style.background = soloExpand ? "transparent" : CLUSTER_PILL;
22053
+ const soloChip = stateChipRole != null && !hovered;
22054
+ cluster.style.background = soloChip ? "transparent" : CLUSTER_PILL;
21442
22055
  for (const child of cluster.children) {
21443
22056
  const btn2 = child;
21444
22057
  btn2.style.display = "inline-flex";
21445
- btn2.style.visibility = soloExpand && btn2.dataset.role !== "collapse" ? "hidden" : "visible";
22058
+ btn2.style.visibility = soloChip && btn2.dataset.role !== stateChipRole ? "hidden" : "visible";
21446
22059
  }
21447
22060
  }
21448
22061
  }
@@ -21452,6 +22065,14 @@ var PaneControls = class {
21452
22065
  this.hoverPaneId = paneId;
21453
22066
  this.reposition();
21454
22067
  }
22068
+ /** Mobile suppression: no hover clusters (touch has no cursor; the shell's own
22069
+ * chrome covers maximize), while collapsed panes keep their expand chips. */
22070
+ setSuspended(on) {
22071
+ if (on === this.suspended) return;
22072
+ this.suspended = on;
22073
+ if (on) this.hoverPaneId = null;
22074
+ this.reposition();
22075
+ }
21455
22076
  destroy() {
21456
22077
  this.plot.removeEventListener("pointermove", this.onPlotMove);
21457
22078
  this.plot.removeEventListener("pointerleave", this.onPlotLeave);
@@ -21602,131 +22223,6 @@ var AxisScaleButtons = class {
21602
22223
  }
21603
22224
  };
21604
22225
 
21605
- // src/renderers/shared/TableOverlay.ts
21606
- var SIZE_PX3 = {
21607
- auto: 13,
21608
- tiny: 10,
21609
- small: 11,
21610
- normal: 13,
21611
- large: 16,
21612
- huge: 20
21613
- };
21614
- var TableOverlay = class {
21615
- constructor(container, theme, paneBounds) {
21616
- this.container = container;
21617
- this.theme = theme;
21618
- this.paneBounds = paneBounds;
21619
- this.lastTables = [];
21620
- if (getComputedStyle(container).position === "static") container.style.position = "relative";
21621
- this.root = document.createElement("div");
21622
- Object.assign(this.root.style, {
21623
- position: "absolute",
21624
- inset: "0",
21625
- pointerEvents: "none",
21626
- overflow: "hidden",
21627
- zIndex: "3"
21628
- });
21629
- container.appendChild(this.root);
21630
- }
21631
- update(tables) {
21632
- this.lastTables = tables;
21633
- this.root.replaceChildren();
21634
- for (const t of tables) this.root.appendChild(this.renderTable(t));
21635
- }
21636
- /** Re-render at the current pane geometry — after layout settles or on resize. */
21637
- reposition() {
21638
- if (this.root.isConnected) this.update(this.lastTables);
21639
- }
21640
- /** Show/hide the whole overlay. Tables anchor to pane corners, not to bars, so unlike the
21641
- * series content they DON'T vanish with an emptied chart — the loading state hides them. */
21642
- setVisible(visible) {
21643
- this.root.style.display = visible ? "" : "none";
21644
- }
21645
- destroy() {
21646
- this.root.remove();
21647
- }
21648
- renderTable(t) {
21649
- const wrap = document.createElement("div");
21650
- wrap.style.position = "absolute";
21651
- this.anchor(wrap, t.position, this.paneBounds(t.paneId));
21652
- const table = document.createElement("table");
21653
- Object.assign(table.style, {
21654
- borderCollapse: "collapse",
21655
- background: t.bgColor ?? "transparent",
21656
- fontFamily: this.theme.fontFamily || "sans-serif",
21657
- // Frame as an inset shadow (not a `border`) so it stays independent of the
21658
- // collapsed cell borders even when frame_width != border_width.
21659
- boxShadow: t.frameColor && t.frameWidth > 0 ? `inset 0 0 0 ${t.frameWidth}px ${t.frameColor}` : "none",
21660
- border: "none",
21661
- tableLayout: "auto",
21662
- // Re-enable pointer events on the table only (root is none) so cell tooltips work.
21663
- pointerEvents: "auto"
21664
- });
21665
- const span = /* @__PURE__ */ new Map();
21666
- const skip = /* @__PURE__ */ new Set();
21667
- for (const m of t.merges) {
21668
- span.set(`${m.startRow}:${m.startCol}`, { cs: m.endCol - m.startCol + 1, rs: m.endRow - m.startRow + 1 });
21669
- for (let r = m.startRow; r <= m.endRow; r += 1) {
21670
- for (let c = m.startCol; c <= m.endCol; c += 1) {
21671
- if (r !== m.startRow || c !== m.startCol) skip.add(`${r}:${c}`);
21672
- }
21673
- }
21674
- }
21675
- const cellBorder = t.borderColor && t.borderWidth > 0 ? `${t.borderWidth}px solid ${t.borderColor}` : "none";
21676
- for (let r = 0; r < t.rows; r += 1) {
21677
- const tr = document.createElement("tr");
21678
- for (let c = 0; c < t.columns; c += 1) {
21679
- const cell = t.cells[r]?.[c] ?? null;
21680
- if (skip.has(`${r}:${c}`) || cell?.merged) continue;
21681
- const td = document.createElement("td");
21682
- const sp = span.get(`${r}:${c}`);
21683
- if (sp) {
21684
- if (sp.cs > 1) td.colSpan = sp.cs;
21685
- if (sp.rs > 1) td.rowSpan = sp.rs;
21686
- }
21687
- Object.assign(td.style, {
21688
- border: cellBorder,
21689
- padding: "2px 6px",
21690
- background: cell?.bgColor ?? "transparent",
21691
- color: cell?.textColor ?? this.theme.textColor,
21692
- textAlign: cell?.hAlign ?? "center",
21693
- verticalAlign: cell?.vAlign === "top" ? "top" : cell?.vAlign === "bottom" ? "bottom" : "middle",
21694
- fontSize: `${SIZE_PX3[cell?.textSize ?? "normal"]}px`,
21695
- fontFamily: cell?.fontFamily === "monospace" ? "monospace" : "inherit",
21696
- fontWeight: cell?.bold ? "bold" : "normal",
21697
- fontStyle: cell?.italic ? "italic" : "normal",
21698
- whiteSpace: "pre-line"
21699
- });
21700
- if (cell?.tooltip) td.title = cell.tooltip;
21701
- td.textContent = cell?.text ?? "";
21702
- tr.appendChild(td);
21703
- }
21704
- table.appendChild(tr);
21705
- }
21706
- wrap.appendChild(table);
21707
- return wrap;
21708
- }
21709
- /**
21710
- * Position the wrapper at a Pine `position.*` corner/edge of the table's PANE
21711
- * (not the whole chart), inset past the right price axis so it never overlaps
21712
- * the Y-axis labels.
21713
- */
21714
- anchor(el, position, b) {
21715
- const m = 6;
21716
- const containerH = this.root.clientHeight || this.container.clientHeight;
21717
- const paneBottom = b.top + b.height;
21718
- if (position.startsWith("top")) el.style.top = `${b.top + m}px`;
21719
- else if (position.startsWith("bottom")) el.style.bottom = `${Math.max(0, containerH - paneBottom) + m}px`;
21720
- else el.style.top = `${b.top + b.height / 2}px`;
21721
- if (position.endsWith("left")) el.style.left = `${m}px`;
21722
- else if (position.endsWith("right")) el.style.right = `${b.rightAxis + m}px`;
21723
- else el.style.left = `calc(50% - ${b.rightAxis / 2}px)`;
21724
- const tx = position.endsWith("center") ? "-50%" : "0";
21725
- const ty = position.startsWith("middle") ? "-50%" : "0";
21726
- if (tx !== "0" || ty !== "0") el.style.transform = `translate(${tx}, ${ty})`;
21727
- }
21728
- };
21729
-
21730
22226
  // src/renderers/native/capabilities.ts
21731
22227
  var NATIVE_CAPABILITIES = {
21732
22228
  panes: true,
@@ -21747,7 +22243,7 @@ var NATIVE_CAPABILITIES = {
21747
22243
  drawingDepth: true,
21748
22244
  // drawings share the series' z space (backend-composited interleave layers)
21749
22245
  tables: true,
21750
- // reuses the DOM TableOverlay
22246
+ // canvas-painted into the owning indicator's interleave slice
21751
22247
  trades: true,
21752
22248
  // strategy order-fill markers (arrows + labels + fill-price ticks)
21753
22249
  inputsUI: true
@@ -21778,6 +22274,9 @@ function candleTier(spacing) {
21778
22274
  if (spacing < CANDLE_BODY_MIN_SPACING) return "wick";
21779
22275
  return "full";
21780
22276
  }
22277
+ function snapY(yCss, dpr) {
22278
+ return Math.round(yCss * dpr) / dpr;
22279
+ }
21781
22280
  function candleGeometry(xCss, spacing, dpr, bodyScale = 1) {
21782
22281
  const wickDev = Math.max(1, Math.round(wickWidth(spacing) * dpr));
21783
22282
  const wickLeftDev = Math.round(xCss * dpr - wickDev / 2);
@@ -22300,8 +22799,12 @@ var WebGL2Backend = class {
22300
22799
  return sc === pane.scale ? pane : { ...pane, scale: sc };
22301
22800
  };
22302
22801
  b.alpha = this.modelAlpha;
22303
- for (const m of models) for (const bgSpan of m.backgrounds) this.emitBackground(b, bgSpan, pane, coords);
22304
- 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));
22802
+ for (const m of models) for (const bgSpan of m.backgrounds) if (bgSpan.overlay !== true) this.emitBackground(b, bgSpan, pane, coords);
22803
+ if (isPrice) {
22804
+ for (const m of scene.indicators.values()) {
22805
+ for (const bgSpan of m.backgrounds) if (bgSpan.overlay === true) this.emitBackground(b, bgSpan, pane, coords);
22806
+ }
22807
+ }
22305
22808
  const drawCandles = isPrice && !scene.candlesHidden;
22306
22809
  let candleDrawn = false;
22307
22810
  for (const m of models) {
@@ -22315,15 +22818,26 @@ var WebGL2Backend = class {
22315
22818
  b.alpha = this.modelAlpha;
22316
22819
  const off = scene.offsetOf(m.id);
22317
22820
  const mp = effPane(m);
22318
- for (const s of m.series) this.emitSeries(b, s, mp, coords, i0, i1, theme, off);
22821
+ for (const f of m.fills) if (f.overlay !== true) this.emitFill(b, m, f, mp, coords, i0, i1, off);
22822
+ for (const s of m.series) if (s.overlay !== true) this.emitSeries(b, s, mp, coords, i0, i1, theme, off);
22319
22823
  }
22320
22824
  if (drawCandles && !candleDrawn) {
22321
22825
  drawSlicesUpTo(scene.candleZ);
22322
22826
  b.alpha = this.candleStructureAlpha;
22323
22827
  this.emitPriceSeries(b, scene, i0, i1, coords, pane, theme, barColorMap, dataW);
22324
22828
  }
22325
- drawSlicesUpTo(Infinity);
22326
22829
  b.alpha = this.modelAlpha;
22830
+ if (isPrice) {
22831
+ for (const m of scene.indicators.values()) {
22832
+ const off = scene.offsetOf(m.id);
22833
+ for (const f of m.fills) if (f.overlay === true) this.emitFill(b, m, f, pane, coords, i0, i1, off);
22834
+ }
22835
+ for (const m of scene.indicators.values()) {
22836
+ const off = scene.offsetOf(m.id);
22837
+ for (const s of m.series) if (s.overlay === true) this.emitSeries(b, s, pane, coords, i0, i1, theme, off);
22838
+ }
22839
+ }
22840
+ drawSlicesUpTo(Infinity);
22327
22841
  for (const m of models) {
22328
22842
  const mp = effPane(m);
22329
22843
  for (const pl of m.priceLines) this.emitHline(b, pl, mp, coords, dataW, theme);
@@ -22370,7 +22884,7 @@ var WebGL2Backend = class {
22370
22884
  for (const pane of scene.orderedPanes()) {
22371
22885
  const b = this.glowBatch;
22372
22886
  b.reset();
22373
- this.emitGlowSources(b, scene.indicatorsForPane(pane.id), pane, coords, i0, i1, (id) => scene.offsetOf(id));
22887
+ this.emitGlowSources(b, scene, pane, coords, i0, i1);
22374
22888
  if (b.vertexCount === 0) continue;
22375
22889
  const topH = Math.round(pane.bounds.top * dpr * 0.5);
22376
22890
  const botH = Math.round((pane.bounds.top + pane.bounds.height) * dpr * 0.5);
@@ -22439,15 +22953,23 @@ var WebGL2Backend = class {
22439
22953
  gl.bindVertexArray(null);
22440
22954
  return true;
22441
22955
  }
22442
- /** The "neon" elements that glow: line/area/step lines + point markers (not candles/fills/bars). */
22443
- emitGlowSources(b, models, pane, coords, i0, i1, offsetOf = () => 0) {
22444
- for (const m of models) {
22445
- const off = offsetOf(m.id);
22446
- for (const s of m.series) {
22447
- if (!isLineLikeSeries(s) || s.visible === false) continue;
22448
- if (s.kind === "histogram" || s.kind === "columns") continue;
22449
- if (s.kind === "circles" || s.kind === "cross") this.emitPointMarkers(b, s, pane, coords, i0, i1, off);
22450
- else this.emitPolyline(b, s, pane, coords, i0, i1, s.kind === "step", off);
22956
+ /** The "neon" elements that glow: line/area/step lines + point markers (not candles/fills/bars).
22957
+ * Routing mirrors the main pass: own series per pane, force_overlay series on the price pane. */
22958
+ emitGlowSources(b, scene, pane, coords, i0, i1) {
22959
+ const emitOne = (s, off) => {
22960
+ if (!isLineLikeSeries(s) || s.visible === false) return;
22961
+ if (s.kind === "histogram" || s.kind === "columns") return;
22962
+ if (s.kind === "circles" || s.kind === "cross") this.emitPointMarkers(b, s, pane, coords, i0, i1, off);
22963
+ else this.emitPolyline(b, s, pane, coords, i0, i1, s.kind === "step", off);
22964
+ };
22965
+ for (const m of scene.indicatorsForPane(pane.id)) {
22966
+ const off = scene.offsetOf(m.id);
22967
+ for (const s of m.series) if (s.overlay !== true) emitOne(s, off);
22968
+ }
22969
+ if (pane.kind === "price") {
22970
+ for (const m of scene.indicators.values()) {
22971
+ const off = scene.offsetOf(m.id);
22972
+ for (const s of m.series) if (s.overlay === true) emitOne(s, off);
22451
22973
  }
22452
22974
  }
22453
22975
  }
@@ -22710,12 +23232,12 @@ var WebGL2Backend = class {
22710
23232
  if (drawBody) {
22711
23233
  const oY = coords.priceToY(bar.open, pane.scale, pane.bounds);
22712
23234
  const cY = coords.priceToY(bar.close, pane.scale, pane.bounds);
22713
- bodyTop = Math.min(oY, cY);
22714
- bodyH = Math.max(1, Math.abs(cY - oY));
23235
+ bodyTop = snapY(Math.min(oY, cY), coords.dpr);
23236
+ bodyH = Math.max(1 / coords.dpr, snapY(Math.max(oY, cY), coords.dpr) - bodyTop);
22715
23237
  }
22716
23238
  if (cs.wickVisible) {
22717
- const hY = coords.priceToY(bar.high, pane.scale, pane.bounds);
22718
- const lY = coords.priceToY(bar.low, pane.scale, pane.bounds);
23239
+ const hY = snapY(coords.priceToY(bar.high, pane.scale, pane.bounds), coords.dpr);
23240
+ const lY = snapY(coords.priceToY(bar.low, pane.scale, pane.bounds), coords.dpr);
22719
23241
  b.alpha = this.candleStructureAlpha;
22720
23242
  const wCol = parseColor((isUp ? cs.wickUpColor : cs.wickDownColor) ?? (drawBody ? dir : bodyColorStr));
22721
23243
  if (drawBody) {
@@ -22730,9 +23252,9 @@ var WebGL2Backend = class {
22730
23252
  b.alpha = this.candleBodyAlpha;
22731
23253
  b.rect(g.bodyX, bodyTop, g.bodyW, bodyH, c);
22732
23254
  }
22733
- if (cs.borderVisible || bc || fading && cs.bodyVisible) {
23255
+ if (cs.borderVisible || fading && cs.bodyVisible) {
22734
23256
  b.alpha = this.candleStructureAlpha;
22735
- const bord = cs.borderVisible || bc ? parseColor((isUp ? cs.borderUpColor : cs.borderDownColor) ?? dir) : c;
23257
+ const bord = cs.borderVisible ? parseColor((isUp ? cs.borderUpColor : cs.borderDownColor) ?? bodyColorStr) : c;
22736
23258
  const bw = Math.max(0, g.bodyW - 1);
22737
23259
  const bh = Math.max(0, bodyH - 1);
22738
23260
  b.rectStroke(g.bodyX + 0.5, bodyTop + 0.5, bw, bh, 1, bord);
@@ -24048,9 +24570,10 @@ var SceneGraph = class {
24048
24570
  * so each indicator arrives behind the candles (and behind older indicators);
24049
24571
  * `setIndicatorZ`/`bringToFront`/`sendToBack` change it. */
24050
24572
  this.seriesZ = /* @__PURE__ */ new Map();
24051
- /** Per-pane raster layers of user drawings interleaved into the series stack each is a
24573
+ /** Per-pane raster layers of drawings interleaved into the series stack (each
24574
+ * indicator's Pine drawings at its model's z, plus in-stack user drawings) — each a
24052
24575
  * prepainted canvas the backend composites just before the series carrying `beforeZ`.
24053
- * Rebuilt by the renderer per data frame; empty when every drawing sits over the stack. */
24576
+ * Rebuilt by the renderer per data frame. */
24054
24577
  this.drawingSlices = /* @__PURE__ */ new Map();
24055
24578
  /** Per-model index offset: the chart bar index of the model's `anchorTime` — its
24056
24579
  * index-aligned payloads (dense series arrays, `bar_index` drawings) count from that
@@ -24284,8 +24807,12 @@ var Canvas2dBackend = class {
24284
24807
  return sc === pane.scale ? pane : { ...pane, scale: sc };
24285
24808
  };
24286
24809
  ctx.globalAlpha = this.modelAlpha;
24287
- for (const m of models) for (const bg of m.backgrounds) this.drawBackground(ctx, bg, pane, coords);
24288
- 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));
24810
+ for (const m of models) for (const bg of m.backgrounds) if (bg.overlay !== true) this.drawBackground(ctx, bg, pane, coords);
24811
+ if (isPrice) {
24812
+ for (const m of scene.indicators.values()) {
24813
+ for (const bg of m.backgrounds) if (bg.overlay === true) this.drawBackground(ctx, bg, pane, coords);
24814
+ }
24815
+ }
24289
24816
  const slices = scene.drawingSlices.get(pane.id) ?? [];
24290
24817
  let si = 0;
24291
24818
  const drawSlicesUpTo = (z) => {
@@ -24307,13 +24834,25 @@ var Canvas2dBackend = class {
24307
24834
  ctx.globalAlpha = this.modelAlpha;
24308
24835
  const off = scene.offsetOf(m.id);
24309
24836
  const mp = effPane(m);
24310
- for (const s of m.series) this.drawSeries(ctx, s, mp, coords, i0, i1, theme, off);
24837
+ for (const f of m.fills) if (f.overlay !== true) this.drawFill(ctx, m, f, mp, coords, i0, i1, off);
24838
+ for (const s of m.series) if (s.overlay !== true) this.drawSeries(ctx, s, mp, coords, i0, i1, theme, off);
24311
24839
  }
24312
24840
  if (drawCandles && !candleDrawn) {
24313
24841
  drawSlicesUpTo(scene.candleZ);
24314
24842
  ctx.globalAlpha = this.candleStructureAlpha;
24315
24843
  this.drawPriceSeries(ctx, scene, i0, i1, coords, pane, theme, barColorMap, dataW);
24316
24844
  }
24845
+ if (isPrice) {
24846
+ ctx.globalAlpha = this.modelAlpha;
24847
+ for (const m of scene.indicators.values()) {
24848
+ const off = scene.offsetOf(m.id);
24849
+ for (const f of m.fills) if (f.overlay === true) this.drawFill(ctx, m, f, pane, coords, i0, i1, off);
24850
+ }
24851
+ for (const m of scene.indicators.values()) {
24852
+ const off = scene.offsetOf(m.id);
24853
+ for (const s of m.series) if (s.overlay === true) this.drawSeries(ctx, s, pane, coords, i0, i1, theme, off);
24854
+ }
24855
+ }
24317
24856
  drawSlicesUpTo(Infinity);
24318
24857
  ctx.globalAlpha = this.modelAlpha;
24319
24858
  for (const m of models) {
@@ -24558,13 +25097,13 @@ var Canvas2dBackend = class {
24558
25097
  if (drawBody) {
24559
25098
  const oY = coords.priceToY(b.open, pane.scale, pane.bounds);
24560
25099
  const cY = coords.priceToY(b.close, pane.scale, pane.bounds);
24561
- top = Math.min(oY, cY);
24562
- bodyH = Math.max(1, Math.abs(cY - oY));
25100
+ top = snapY(Math.min(oY, cY), coords.dpr);
25101
+ bodyH = Math.max(1 / coords.dpr, snapY(Math.max(oY, cY), coords.dpr) - top);
24563
25102
  }
24564
25103
  if (cs.wickVisible) {
24565
25104
  const wick = (up ? cs.wickUpColor : cs.wickDownColor) ?? (drawBody ? dir : color);
24566
- const hY = coords.priceToY(b.high, pane.scale, pane.bounds);
24567
- const lY = coords.priceToY(b.low, pane.scale, pane.bounds);
25105
+ const hY = snapY(coords.priceToY(b.high, pane.scale, pane.bounds), coords.dpr);
25106
+ const lY = snapY(coords.priceToY(b.low, pane.scale, pane.bounds), coords.dpr);
24568
25107
  ctx.globalAlpha = this.candleStructureAlpha;
24569
25108
  ctx.strokeStyle = wick;
24570
25109
  ctx.lineWidth = g.wickW;
@@ -24586,9 +25125,9 @@ var Canvas2dBackend = class {
24586
25125
  ctx.fillStyle = color;
24587
25126
  ctx.fillRect(g.bodyX, top, g.bodyW, bodyH);
24588
25127
  }
24589
- if (cs.borderVisible || bc || fading && cs.bodyVisible) {
25128
+ if (cs.borderVisible || fading && cs.bodyVisible) {
24590
25129
  ctx.globalAlpha = this.candleStructureAlpha;
24591
- ctx.strokeStyle = cs.borderVisible || bc ? (up ? cs.borderUpColor : cs.borderDownColor) ?? dir : color;
25130
+ ctx.strokeStyle = cs.borderVisible ? (up ? cs.borderUpColor : cs.borderDownColor) ?? color : color;
24592
25131
  ctx.lineWidth = 1;
24593
25132
  const bw = Math.max(0, g.bodyW - 1);
24594
25133
  const bh = Math.max(0, bodyH - 1);
@@ -25011,6 +25550,19 @@ function autoFontSize(lines, boxW, boxH, bold) {
25011
25550
 
25012
25551
  // src/renderers/shared/DrawingSceneRenderer.ts
25013
25552
  var EMPTY_DRAWING_SET = { lines: [], boxes: [], labels: [], polylines: [], linefills: [] };
25553
+ function modelDrawingSet(m, overlay) {
25554
+ const want = (d) => Boolean(d.overlay) === overlay;
25555
+ return {
25556
+ lines: (m.lines ?? []).filter(want),
25557
+ boxes: (m.boxes ?? []).filter(want),
25558
+ labels: (m.labels ?? []).filter(want),
25559
+ polylines: (m.polylines ?? []).filter(want),
25560
+ linefills: (m.linefills ?? []).filter(want)
25561
+ };
25562
+ }
25563
+ function drawingSetEmpty(s) {
25564
+ return !s.lines.length && !s.boxes.length && !s.labels.length && !s.polylines.length && !s.linefills.length;
25565
+ }
25014
25566
  function fontSizePx(size) {
25015
25567
  return size === "auto" ? 12 : namedFontSize(size);
25016
25568
  }
@@ -25020,6 +25572,8 @@ var DrawingSceneRenderer = class {
25020
25572
  this.set = set;
25021
25573
  /** measureText width cache, keyed by `${font} ${text}`, persists across frames. */
25022
25574
  this.widthCache = /* @__PURE__ */ new Map();
25575
+ /** Tooltip hit-rects captured while drawing the CURRENT set (rebuilt per render). */
25576
+ this.tipRegions = [];
25023
25577
  /**
25024
25578
  * Index offset of the CURRENT set's model: its `xloc:'bar_index'` coordinates count
25025
25579
  * from the model's anchor bar, so they shift by this to land on chart logical indices.
@@ -25040,8 +25594,13 @@ var DrawingSceneRenderer = class {
25040
25594
  const s = this.set;
25041
25595
  return !s.lines.length && !s.boxes.length && !s.labels.length && !s.polylines.length && !s.linefills.length;
25042
25596
  }
25597
+ /** Tooltip hit-rects of the labels drawn by the LAST `render` call (same coords as `ctx`). */
25598
+ labelTipRegions() {
25599
+ return this.tipRegions;
25600
+ }
25043
25601
  /** Draw the whole set into `ctx` using the supplied coordinate closures. */
25044
25602
  render(ctx, W, H, xOf, yOf) {
25603
+ this.tipRegions = [];
25045
25604
  if (this.isEmpty()) return;
25046
25605
  ctx.save();
25047
25606
  this.drawLinefills(ctx, W, H, xOf, yOf);
@@ -25389,13 +25948,35 @@ var DrawingSceneRenderer = class {
25389
25948
  if (this.isPointShape(lb.style)) {
25390
25949
  if (!lb.noFill) this.drawLabelShape(ctx, lb.style, px, py, fontPx, color);
25391
25950
  if (lb.text) this.drawLabelText(ctx, lb, px, py + fontPx, fontPx);
25392
- } else if (lb.style === "none" || lb.style === "text_outline" || lb.noFill) {
25393
- if (lb.text) this.drawLabelText(ctx, lb, px, py, fontPx, lb.style === "text_outline");
25951
+ if (lb.tooltip) {
25952
+ const r = Math.max(4, fontPx * 0.6) + 3;
25953
+ this.tipRegions.push({ left: px - r, top: py - r, right: px + r, bottom: py + r, text: lb.tooltip });
25954
+ }
25955
+ } else if (lb.style === "none" || lb.style === "text_outline") {
25956
+ if (lb.text) {
25957
+ this.drawLabelText(ctx, lb, px, py, fontPx, lb.style === "text_outline");
25958
+ if (lb.tooltip) this.tipRegions.push(this.textRegion(ctx, lb, px, py, fontPx, lb.tooltip));
25959
+ }
25394
25960
  } else {
25395
- this.drawBubble(ctx, lb, px, py, fontPx, color);
25961
+ const r = this.drawBubble(ctx, lb, px, py, fontPx, color);
25962
+ 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 });
25396
25963
  }
25397
25964
  }
25398
25965
  }
25966
+ /** Canvas font for a label's text — family + Pine `text_formatting` (bold/italic). */
25967
+ labelFont(lb, fontPx) {
25968
+ const family = lb.fontFamily === "monospace" ? "monospace" : this.deps.theme.fontFamily || "sans-serif";
25969
+ return `${lb.italic ? "italic " : ""}${lb.bold ? "bold " : ""}${fontPx}px ${family}`;
25970
+ }
25971
+ /** Hover rect of a text-only label (style none/text_outline/noFill), centered like drawLabelText. */
25972
+ textRegion(ctx, lb, cx, cy, fontPx, text) {
25973
+ const font = this.labelFont(lb, fontPx);
25974
+ const lines = (lb.text ?? "").split("\n");
25975
+ const w = Math.max(1, ...lines.map((l) => this.measure(ctx, font, l)));
25976
+ const h = fontPx * 1.25 * lines.length;
25977
+ const left = lb.textAlign === "left" ? cx : lb.textAlign === "right" ? cx - w : cx - w / 2;
25978
+ return { left, top: cy - h / 2, right: left + w, bottom: cy + h / 2, text };
25979
+ }
25399
25980
  isPointShape(style) {
25400
25981
  switch (style) {
25401
25982
  case "circle":
@@ -25414,8 +25995,7 @@ var DrawingSceneRenderer = class {
25414
25995
  }
25415
25996
  }
25416
25997
  drawLabelText(ctx, lb, cx, cy, fontPx, outline = false) {
25417
- const family = lb.fontFamily === "monospace" ? "monospace" : this.deps.theme.fontFamily || "sans-serif";
25418
- ctx.font = `${fontPx}px ${family}`;
25998
+ ctx.font = this.labelFont(lb, fontPx);
25419
25999
  ctx.textAlign = lb.textAlign === "left" ? "left" : lb.textAlign === "right" ? "right" : "center";
25420
26000
  ctx.textBaseline = "middle";
25421
26001
  const lines = lb.text.split("\n");
@@ -25495,9 +26075,9 @@ var DrawingSceneRenderer = class {
25495
26075
  ctx.fill();
25496
26076
  }
25497
26077
  }
26078
+ /** Draw a bubble label; returns the bubble body rect (for tooltip hit-testing). */
25498
26079
  drawBubble(ctx, lb, px, py, fontPx, color) {
25499
- const family = lb.fontFamily === "monospace" ? "monospace" : this.deps.theme.fontFamily || "sans-serif";
25500
- ctx.font = `${fontPx}px ${family}`;
26080
+ ctx.font = this.labelFont(lb, fontPx);
25501
26081
  const lines = (lb.text ?? "").split("\n");
25502
26082
  const padX = 6;
25503
26083
  const padY = 4;
@@ -25555,38 +26135,51 @@ var DrawingSceneRenderer = class {
25555
26135
  by = py - h - ptr;
25556
26136
  pointer = "down";
25557
26137
  }
25558
- ctx.fillStyle = color;
25559
- this.roundRect(ctx, bx, by, w, h, 4);
25560
- ctx.fill();
25561
- if (pointer !== "none") {
25562
- ctx.beginPath();
25563
- if (pointer === "down") {
25564
- ctx.moveTo(px - ptr, by + h);
25565
- ctx.lineTo(px + ptr, by + h);
25566
- ctx.lineTo(px, py);
25567
- } else if (pointer === "up") {
25568
- ctx.moveTo(px - ptr, by);
25569
- ctx.lineTo(px + ptr, by);
25570
- ctx.lineTo(px, py);
25571
- } else if (pointer === "left") {
25572
- ctx.moveTo(bx, py - ptr);
25573
- ctx.lineTo(bx, py + ptr);
25574
- ctx.lineTo(px, py);
25575
- } else {
25576
- ctx.moveTo(bx + w, py - ptr);
25577
- ctx.lineTo(bx + w, py + ptr);
25578
- ctx.lineTo(px, py);
25579
- }
25580
- ctx.closePath();
26138
+ if (!lb.noFill) {
26139
+ ctx.fillStyle = color;
26140
+ this.roundRect(ctx, bx, by, w, h, 4);
25581
26141
  ctx.fill();
26142
+ if (pointer !== "none") {
26143
+ ctx.beginPath();
26144
+ if (pointer === "down") {
26145
+ ctx.moveTo(px - ptr, by + h);
26146
+ ctx.lineTo(px + ptr, by + h);
26147
+ ctx.lineTo(px, py);
26148
+ } else if (pointer === "up") {
26149
+ ctx.moveTo(px - ptr, by);
26150
+ ctx.lineTo(px + ptr, by);
26151
+ ctx.lineTo(px, py);
26152
+ } else if (pointer === "left") {
26153
+ ctx.moveTo(bx, py - ptr);
26154
+ ctx.lineTo(bx, py + ptr);
26155
+ ctx.lineTo(px, py);
26156
+ } else {
26157
+ ctx.moveTo(bx + w, py - ptr);
26158
+ ctx.lineTo(bx + w, py + ptr);
26159
+ ctx.lineTo(px, py);
26160
+ }
26161
+ ctx.closePath();
26162
+ ctx.fill();
26163
+ }
25582
26164
  }
25583
26165
  if (lb.text) {
25584
- ctx.fillStyle = lb.textColor ?? contrastColor(color);
25585
- ctx.textAlign = "center";
26166
+ ctx.fillStyle = lb.textColor ?? (lb.noFill ? this.deps.theme.textColor : contrastColor(color));
25586
26167
  ctx.textBaseline = "middle";
26168
+ let tx;
26169
+ if (lb.textAlign === "left") {
26170
+ ctx.textAlign = "left";
26171
+ tx = bx + padX;
26172
+ } else if (lb.textAlign === "right") {
26173
+ ctx.textAlign = "right";
26174
+ tx = bx + w - padX;
26175
+ } else {
26176
+ ctx.textAlign = "center";
26177
+ tx = bx + w / 2;
26178
+ }
25587
26179
  const startY = by + padY + lineH / 2;
25588
- for (let i = 0; i < lines.length; i += 1) ctx.fillText(lines[i], bx + w / 2, startY + i * lineH);
26180
+ for (let i = 0; i < lines.length; i += 1) ctx.fillText(lines[i], tx, startY + i * lineH);
25589
26181
  }
26182
+ return { x: bx, y: by, w, h };
25590
26183
  }
25591
26184
  roundRect(ctx, x, y, w, h, r) {
25592
26185
  const rr = Math.min(r, w / 2, h / 2);
@@ -25822,7 +26415,7 @@ var ChromeRenderer = class {
25822
26415
  this.ctx = null;
25823
26416
  // The color for axis tick labels — the host-passed surface text, set each frame in render().
25824
26417
  this.axisTextColor = DARK_THEME.textColor;
25825
- // Shared Pine-drawing renderer (line/box/label/polyline/linefill); widthCache persists.
26418
+ // Shared Pine-drawing renderer, used here for autoscale geometry only; widthCache persists.
25826
26419
  this.drawScene = new DrawingSceneRenderer({ timeToLogical: () => 0, barAt: () => null, theme: {} });
25827
26420
  }
25828
26421
  mount(canvas) {
@@ -25847,8 +26440,8 @@ var ChromeRenderer = class {
25847
26440
  */
25848
26441
  paneDrawingsRange(ownModels, scene, isPricePane, vr) {
25849
26442
  let dr = null;
25850
- for (const m of ownModels) dr = unionRange(dr, this.drawingsRange(this.ownDrawings(m), vr, scene.offsetOf(m.id)));
25851
- if (isPricePane) for (const m of scene.indicators.values()) dr = unionRange(dr, this.drawingsRange(this.overlayDrawings(m), vr, scene.offsetOf(m.id)));
26443
+ for (const m of ownModels) dr = unionRange(dr, this.drawingsRange(modelDrawingSet(m, false), vr, scene.offsetOf(m.id)));
26444
+ if (isPricePane) for (const m of scene.indicators.values()) dr = unionRange(dr, this.drawingsRange(modelDrawingSet(m, true), vr, scene.offsetOf(m.id)));
25852
26445
  return dr;
25853
26446
  }
25854
26447
  /** Clear the chrome canvas and draw drawings + axes + current-price line.
@@ -25880,17 +26473,6 @@ var ChromeRenderer = class {
25880
26473
  return;
25881
26474
  }
25882
26475
  const pricePane = panes.find((p) => p.kind === "price") ?? null;
25883
- for (const pane of panes) {
25884
- if (pane.collapsed) continue;
25885
- for (const m of scene.indicatorsForPane(pane.id)) {
25886
- const sc = scene.scaleFor(m, pane);
25887
- const mp = sc === pane.scale ? pane : { ...pane, scale: sc };
25888
- this.renderDrawings(ctx, coords, this.ownDrawings(m), mp, dataW, scene.offsetOf(m.id));
25889
- }
25890
- }
25891
- if (pricePane) {
25892
- for (const m of scene.indicators.values()) this.renderDrawings(ctx, coords, this.overlayDrawings(m), pricePane, dataW, scene.offsetOf(m.id));
25893
- }
25894
26476
  if (pricePane && !pricePane.collapsed && scene.tradeMarkers.visible) {
25895
26477
  for (const m of scene.indicators.values()) {
25896
26478
  if (m.trades?.length) this.renderTrades(ctx, coords, scene, theme, m.trades, pricePane, dataW);
@@ -25906,25 +26488,6 @@ var ChromeRenderer = class {
25906
26488
  this.canvas = null;
25907
26489
  this.ctx = null;
25908
26490
  }
25909
- // ── Pine-drawing helpers (own vs force_overlay routing) ──
25910
- ownDrawings(m) {
25911
- return {
25912
- lines: (m.lines ?? []).filter((d) => !d.overlay),
25913
- boxes: (m.boxes ?? []).filter((d) => !d.overlay),
25914
- labels: (m.labels ?? []).filter((d) => !d.overlay),
25915
- polylines: (m.polylines ?? []).filter((d) => !d.overlay),
25916
- linefills: (m.linefills ?? []).filter((d) => !d.overlay)
25917
- };
25918
- }
25919
- overlayDrawings(m) {
25920
- return {
25921
- lines: (m.lines ?? []).filter((d) => d.overlay),
25922
- boxes: (m.boxes ?? []).filter((d) => d.overlay),
25923
- labels: (m.labels ?? []).filter((d) => d.overlay),
25924
- polylines: (m.polylines ?? []).filter((d) => d.overlay),
25925
- linefills: (m.linefills ?? []).filter((d) => d.overlay)
25926
- };
25927
- }
25928
26491
  drawingsRange(set, vr, indexOffset = 0) {
25929
26492
  this.drawScene.setSet(set, indexOffset);
25930
26493
  if (this.drawScene.isEmpty()) return null;
@@ -25958,23 +26521,6 @@ var ChromeRenderer = class {
25958
26521
  );
25959
26522
  ctx.restore();
25960
26523
  }
25961
- renderDrawings(ctx, coords, set, pane, dataW, indexOffset = 0) {
25962
- this.drawScene.setSet(set, indexOffset);
25963
- if (this.drawScene.isEmpty()) return;
25964
- ctx.save();
25965
- ctx.translate(0, pane.bounds.top);
25966
- ctx.beginPath();
25967
- ctx.rect(0, 0, dataW, pane.bounds.height);
25968
- ctx.clip();
25969
- this.drawScene.render(
25970
- ctx,
25971
- dataW,
25972
- pane.bounds.height,
25973
- (l) => coords.logicalToX(l),
25974
- (price) => coords.priceToY(price, pane.scale, pane.bounds) - pane.bounds.top
25975
- );
25976
- ctx.restore();
25977
- }
25978
26524
  // ── axes ──
25979
26525
  drawPriceAxes(ctx, scene, coords, theme, dataW, panes) {
25980
26526
  ctx.strokeStyle = scene.style.borderColor ?? theme.borderColor;
@@ -26190,6 +26736,68 @@ function formatCountdown(ms) {
26190
26736
  return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
26191
26737
  }
26192
26738
 
26739
+ // src/renderers/native/chrome/LabelTooltip.ts
26740
+ var HOVER_DELAY_MS = 350;
26741
+ var LabelTooltip = class {
26742
+ constructor(plot, deps) {
26743
+ this.plot = plot;
26744
+ this.deps = deps;
26745
+ this.tip = null;
26746
+ this.timer = null;
26747
+ /** Text of the currently open OR armed tip — dedupes moves inside one label. */
26748
+ this.current = null;
26749
+ this.onMove = (e) => {
26750
+ if (e.pointerType !== "mouse") return;
26751
+ const rect = this.plot.getBoundingClientRect();
26752
+ const x = e.clientX - rect.left;
26753
+ const y = e.clientY - rect.top;
26754
+ const text = this.deps.lookup(x, y);
26755
+ if (!text) {
26756
+ this.clear();
26757
+ return;
26758
+ }
26759
+ if (text === this.current) return;
26760
+ this.clear();
26761
+ this.current = text;
26762
+ this.timer = window.setTimeout(() => this.show(text, x, y), HOVER_DELAY_MS);
26763
+ };
26764
+ this.onLeave = () => {
26765
+ this.clear();
26766
+ };
26767
+ plot.addEventListener("pointermove", this.onMove);
26768
+ plot.addEventListener("pointerleave", this.onLeave);
26769
+ plot.addEventListener("pointerdown", this.onLeave);
26770
+ }
26771
+ destroy() {
26772
+ this.plot.removeEventListener("pointermove", this.onMove);
26773
+ this.plot.removeEventListener("pointerleave", this.onLeave);
26774
+ this.plot.removeEventListener("pointerdown", this.onLeave);
26775
+ this.clear();
26776
+ }
26777
+ show(text, x, y) {
26778
+ const doc = this.plot.ownerDocument;
26779
+ const tip = doc.createElement("div");
26780
+ tip.textContent = text;
26781
+ 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;";
26782
+ applyChromeTokens(tip, this.deps.theme());
26783
+ this.plot.appendChild(tip);
26784
+ const pw = this.plot.clientWidth;
26785
+ const ph = this.plot.clientHeight;
26786
+ tip.style.left = `${Math.max(0, Math.min(x + 12, pw - tip.offsetWidth - 4))}px`;
26787
+ tip.style.top = `${Math.max(0, Math.min(y + 16, ph - tip.offsetHeight - 4))}px`;
26788
+ this.tip = tip;
26789
+ }
26790
+ clear() {
26791
+ if (this.timer !== null) {
26792
+ clearTimeout(this.timer);
26793
+ this.timer = null;
26794
+ }
26795
+ this.tip?.remove();
26796
+ this.tip = null;
26797
+ this.current = null;
26798
+ }
26799
+ };
26800
+
26193
26801
  // src/renderers/native/chrome/CrosshairRenderer.ts
26194
26802
  var CrosshairRenderer = class {
26195
26803
  constructor() {
@@ -30231,17 +30839,17 @@ function glyphIcon(glyph) {
30231
30839
  return textGlyph(String(glyph), 15);
30232
30840
  }
30233
30841
  function stampSizeIcon(size) {
30234
- return textGlyph("\u25CF", (SIZE_PX4[String(size)] ?? 13) + 4);
30842
+ return textGlyph("\u25CF", (SIZE_PX3[String(size)] ?? 13) + 4);
30235
30843
  }
30236
30844
  function sizeIcon(size) {
30237
30845
  return textGlyph(String(size).charAt(0).toUpperCase(), 15);
30238
30846
  }
30239
- var SIZE_PX4 = { small: 10, normal: 13, large: 16, huge: 20 };
30847
+ var SIZE_PX3 = { small: 10, normal: 13, large: 16, huge: 20 };
30240
30848
  function numbersSizeIcon(size) {
30241
- return textGlyph("12", (SIZE_PX4[String(size)] ?? 13) - 1, 16.5, 'font-weight="600"');
30849
+ return textGlyph("12", (SIZE_PX3[String(size)] ?? 13) - 1, 16.5, 'font-weight="600"');
30242
30850
  }
30243
30851
  function labelSizeIcon(size) {
30244
- return textGlyph("T", (SIZE_PX4[String(size)] ?? 13) + 2);
30852
+ return textGlyph("T", (SIZE_PX3[String(size)] ?? 13) + 2);
30245
30853
  }
30246
30854
  function capitalize(s) {
30247
30855
  return s.charAt(0).toUpperCase() + s.slice(1);
@@ -31216,6 +31824,315 @@ var UserDrawingController = class {
31216
31824
  }
31217
31825
  };
31218
31826
 
31827
+ // src/renderers/shared/TableOverlay.ts
31828
+ var SIZE_PX4 = {
31829
+ auto: 13,
31830
+ tiny: 10,
31831
+ small: 11,
31832
+ normal: 13,
31833
+ large: 16,
31834
+ huge: 20
31835
+ };
31836
+ function fontPxOf(size) {
31837
+ if (typeof size === "number") return size > 0 ? size : SIZE_PX4.auto;
31838
+ return SIZE_PX4[size] ?? SIZE_PX4.auto;
31839
+ }
31840
+ function tableHasContent(t) {
31841
+ return t.cells.some((row) => row?.some((c) => c != null && !c.merged));
31842
+ }
31843
+ function mergeRenderPlan(t) {
31844
+ const span = /* @__PURE__ */ new Map();
31845
+ const omit = /* @__PURE__ */ new Set();
31846
+ for (const m of t.merges) {
31847
+ span.set(`${m.startRow}:${m.startCol}`, { cs: m.endCol - m.startCol + 1, rs: m.endRow - m.startRow + 1 });
31848
+ for (let r = m.startRow; r <= m.endRow; r += 1) {
31849
+ for (let c = m.startCol; c <= m.endCol; c += 1) {
31850
+ if (r !== m.startRow || c !== m.startCol) omit.add(`${r}:${c}`);
31851
+ }
31852
+ }
31853
+ }
31854
+ for (let r = 0; r < t.rows; r += 1) {
31855
+ for (let c = 0; c < t.columns; c += 1) {
31856
+ if (t.cells[r]?.[c]?.merged && !span.has(`${r}:${c}`)) omit.add(`${r}:${c}`);
31857
+ }
31858
+ }
31859
+ for (const key of span.keys()) omit.delete(key);
31860
+ return { span, omit };
31861
+ }
31862
+
31863
+ // src/renderers/shared/TableCanvasRenderer.ts
31864
+ var PAD_X = 6;
31865
+ var PAD_Y = 2;
31866
+ var MARGIN = 6;
31867
+ var LINE_HEIGHT = 1.2;
31868
+ function paintTable(ctx, t, args, tips) {
31869
+ if (!tableHasContent(t)) return;
31870
+ const layout = layoutTable(ctx, t, args);
31871
+ if (!layout || layout.w <= 0 || layout.h <= 0) return;
31872
+ const fw = t.frameColor && t.frameWidth > 0 ? t.frameWidth : 0;
31873
+ const { x, y } = anchorOrigin(t.position, layout.w + 2 * fw, layout.h + 2 * fw, args);
31874
+ const x0 = x + fw;
31875
+ const y0 = y + fw;
31876
+ if (t.bgColor) {
31877
+ ctx.fillStyle = t.bgColor;
31878
+ ctx.fillRect(x0, y0, layout.w, layout.h);
31879
+ }
31880
+ if (fw > 0 && t.frameColor) {
31881
+ ctx.strokeStyle = t.frameColor;
31882
+ ctx.lineWidth = fw;
31883
+ ctx.strokeRect(x + fw / 2, y + fw / 2, layout.w + fw, layout.h + fw);
31884
+ }
31885
+ const colX = [0];
31886
+ for (const w of layout.colW) colX.push(colX[colX.length - 1] + w);
31887
+ const rowY = [0];
31888
+ for (const h of layout.rowH) rowY.push(rowY[rowY.length - 1] + h);
31889
+ const prevBaseline = ctx.textBaseline;
31890
+ const prevAlign = ctx.textAlign;
31891
+ ctx.textBaseline = "middle";
31892
+ for (const box of layout.boxes) {
31893
+ const rx = x0 + colX[box.c];
31894
+ const ry = y0 + rowY[box.r];
31895
+ const rw = colX[box.c + box.cs] - colX[box.c];
31896
+ const rh = rowY[box.r + box.rs] - rowY[box.r];
31897
+ const cell = box.cell;
31898
+ if (cell.bgColor) {
31899
+ ctx.fillStyle = cell.bgColor;
31900
+ ctx.fillRect(rx, ry, rw, rh);
31901
+ }
31902
+ const text = cell.text ?? "";
31903
+ if (text.length > 0) {
31904
+ const px = fontPxOf(cell.textSize);
31905
+ ctx.font = cellFont(cell, px, args.theme);
31906
+ ctx.fillStyle = cell.textColor ?? args.theme.textColor;
31907
+ const lines = text.split("\n");
31908
+ const blockH = lines.length * px * LINE_HEIGHT;
31909
+ const blockTop = cell.vAlign === "top" ? ry + PAD_Y : cell.vAlign === "bottom" ? ry + rh - PAD_Y - blockH : ry + (rh - blockH) / 2;
31910
+ const tx = cell.hAlign === "left" ? rx + PAD_X : cell.hAlign === "right" ? rx + rw - PAD_X : rx + rw / 2;
31911
+ ctx.textAlign = cell.hAlign;
31912
+ lines.forEach((line, i) => ctx.fillText(line, tx, blockTop + (i + 0.5) * px * LINE_HEIGHT));
31913
+ }
31914
+ if (cell.tooltip) tips.push({ left: rx, top: ry, right: rx + rw, bottom: ry + rh, text: cell.tooltip });
31915
+ }
31916
+ ctx.textBaseline = prevBaseline;
31917
+ ctx.textAlign = prevAlign;
31918
+ if (t.borderColor && t.borderWidth > 0) {
31919
+ ctx.strokeStyle = t.borderColor;
31920
+ ctx.lineWidth = t.borderWidth;
31921
+ const seen = /* @__PURE__ */ new Set();
31922
+ ctx.beginPath();
31923
+ const edge = (ax, ay, bx, by) => {
31924
+ const key = `${ax},${ay},${bx},${by}`;
31925
+ if (seen.has(key)) return;
31926
+ seen.add(key);
31927
+ ctx.moveTo(ax, ay);
31928
+ ctx.lineTo(bx, by);
31929
+ };
31930
+ for (const box of layout.boxes) {
31931
+ const l = Math.round(x0 + colX[box.c]);
31932
+ const r = Math.round(x0 + colX[box.c + box.cs]);
31933
+ const tp = Math.round(y0 + rowY[box.r]);
31934
+ const bt = Math.round(y0 + rowY[box.r + box.rs]);
31935
+ edge(l, tp, r, tp);
31936
+ edge(l, bt, r, bt);
31937
+ edge(l, tp, l, bt);
31938
+ edge(r, tp, r, bt);
31939
+ }
31940
+ ctx.stroke();
31941
+ }
31942
+ }
31943
+ function layoutTable(ctx, t, args) {
31944
+ const { span, omit } = mergeRenderPlan(t);
31945
+ const colW = new Array(t.columns).fill(0);
31946
+ const rowH = new Array(t.rows).fill(0);
31947
+ const boxes = [];
31948
+ for (let r = 0; r < t.rows; r += 1) {
31949
+ for (let c = 0; c < t.columns; c += 1) {
31950
+ if (omit.has(`${r}:${c}`)) continue;
31951
+ const cell = t.cells[r]?.[c];
31952
+ if (cell == null) continue;
31953
+ const sp = span.get(`${r}:${c}`);
31954
+ boxes.push({ cell, r, c, cs: Math.min(sp?.cs ?? 1, t.columns - c), rs: Math.min(sp?.rs ?? 1, t.rows - r) });
31955
+ }
31956
+ }
31957
+ if (boxes.length === 0) return null;
31958
+ const sizeOf = (cell) => {
31959
+ const px = fontPxOf(cell.textSize);
31960
+ ctx.font = cellFont(cell, px, args.theme);
31961
+ const lines = (cell.text ?? "").split("\n");
31962
+ let maxW = 0;
31963
+ for (const line of lines) maxW = Math.max(maxW, ctx.measureText(line).width);
31964
+ let w2 = Math.ceil(maxW) + 2 * PAD_X;
31965
+ let h2 = Math.ceil(lines.length * px * LINE_HEIGHT) + 2 * PAD_Y;
31966
+ if (cell.width) w2 = Math.max(w2, cell.width / 100 * args.plotWidth);
31967
+ if (cell.height) h2 = Math.max(h2, cell.height / 100 * args.paneHeight);
31968
+ return { w: w2, h: h2 };
31969
+ };
31970
+ const spanning = [];
31971
+ for (const box of boxes) {
31972
+ const { w: w2, h: h2 } = sizeOf(box.cell);
31973
+ if (box.cs === 1) colW[box.c] = Math.max(colW[box.c], w2);
31974
+ if (box.rs === 1) rowH[box.r] = Math.max(rowH[box.r], h2);
31975
+ if (box.cs > 1 || box.rs > 1) spanning.push({ box, w: w2, h: h2 });
31976
+ }
31977
+ for (const { box, w: w2, h: h2 } of spanning) {
31978
+ if (box.cs > 1) {
31979
+ let sum = 0;
31980
+ for (let c = box.c; c < box.c + box.cs; c += 1) sum += colW[c];
31981
+ if (w2 > sum) for (let c = box.c; c < box.c + box.cs; c += 1) colW[c] += (w2 - sum) / box.cs;
31982
+ }
31983
+ if (box.rs > 1) {
31984
+ let sum = 0;
31985
+ for (let r = box.r; r < box.r + box.rs; r += 1) sum += rowH[r];
31986
+ if (h2 > sum) for (let r = box.r; r < box.r + box.rs; r += 1) rowH[r] += (h2 - sum) / box.rs;
31987
+ }
31988
+ }
31989
+ let w = 0;
31990
+ for (const cw of colW) w += cw;
31991
+ let h = 0;
31992
+ for (const rh of rowH) h += rh;
31993
+ return { colW, rowH, w, h, boxes };
31994
+ }
31995
+ function anchorOrigin(position, totalW, totalH, args) {
31996
+ let y;
31997
+ if (position.startsWith("top")) y = MARGIN;
31998
+ else if (position.startsWith("bottom")) y = args.paneHeight - MARGIN - totalH;
31999
+ else y = args.paneHeight / 2 - totalH / 2;
32000
+ let x;
32001
+ if (position.endsWith("left")) x = MARGIN;
32002
+ else if (position.endsWith("right")) x = args.plotWidth - MARGIN - totalW;
32003
+ else x = args.plotWidth / 2 - totalW / 2;
32004
+ return { x, y };
32005
+ }
32006
+ function cellFont(cell, px, theme) {
32007
+ const family = cell.fontFamily === "monospace" ? "monospace" : theme.fontFamily || "sans-serif";
32008
+ return `${cell.italic ? "italic " : ""}${cell.bold ? "bold " : ""}${px}px ${family}`;
32009
+ }
32010
+
32011
+ // src/renderers/native/drawings/IndicatorDrawingSlices.ts
32012
+ function indicatorSliceKey(z, boundaries) {
32013
+ return boundaries.find((b) => b > z) ?? Infinity;
32014
+ }
32015
+ var IndicatorDrawingSlices = class {
32016
+ constructor() {
32017
+ this.drawScene = new DrawingSceneRenderer({ timeToLogical: () => 0, barAt: () => null, theme: {} });
32018
+ /** Slice canvas cache, keyed `paneId|beforeZ` — same lifecycle as the user-drawing cache. */
32019
+ this.sliceCache = /* @__PURE__ */ new Map();
32020
+ /** Tooltip hit-rects of every label drawn this frame, in plot coords (rebuilt per prepare). */
32021
+ this.tips = [];
32022
+ }
32023
+ /**
32024
+ * Rebuild the per-indicator drawing slices for this data frame. `ref` is the data
32025
+ * canvas the slices must match pixel-for-pixel (the backend composites them 1:1).
32026
+ * Runs from the renderer's data paint, just before the backend composites the scene.
32027
+ */
32028
+ prepare(scene, coords, theme, ref) {
32029
+ this.tips = [];
32030
+ const out = /* @__PURE__ */ new Map();
32031
+ if (ref.width === 0 || ref.height === 0) {
32032
+ this.sliceCache.clear();
32033
+ return out;
32034
+ }
32035
+ this.drawScene.setDeps({
32036
+ timeToLogical: (ms) => coords.timeToLogical(ms),
32037
+ barAt: (logical) => {
32038
+ const b = scene.bars[Math.round(logical)];
32039
+ return b ? { high: b.high, low: b.low } : null;
32040
+ },
32041
+ theme
32042
+ });
32043
+ const dpr = coords.dpr;
32044
+ const dataW = coords.width;
32045
+ const buckets = /* @__PURE__ */ new Map();
32046
+ const add = (paneId, beforeZ, entry) => {
32047
+ const key = `${paneId}|${beforeZ}`;
32048
+ const bucket = buckets.get(key);
32049
+ if (bucket) bucket.entries.push(entry);
32050
+ else buckets.set(key, { paneId, beforeZ, entries: [entry] });
32051
+ };
32052
+ for (const pane of scene.orderedPanes()) {
32053
+ if (pane.collapsed) continue;
32054
+ const boundaries = scene.seriesBoundaries(pane.id);
32055
+ for (const m of scene.orderedIndicatorsForPane(pane.id)) {
32056
+ const set = modelDrawingSet(m, false);
32057
+ const tables = (m.tables ?? []).filter((t) => !t.overlay);
32058
+ if (drawingSetEmpty(set) && tables.length === 0) continue;
32059
+ const sc = scene.scaleFor(m, pane);
32060
+ const mp = sc === pane.scale ? pane : { ...pane, scale: sc };
32061
+ const beforeZ = indicatorSliceKey(scene.zOf(m.id), boundaries);
32062
+ add(pane.id, beforeZ, { set, tables, pane: mp, indexOffset: scene.offsetOf(m.id) });
32063
+ }
32064
+ if (pane.kind === "price") {
32065
+ for (const m of scene.indicators.values()) {
32066
+ const set = modelDrawingSet(m, true);
32067
+ const tables = (m.tables ?? []).filter((t) => t.overlay === true);
32068
+ if (drawingSetEmpty(set) && tables.length === 0) continue;
32069
+ add(pane.id, Infinity, { set, tables, pane, indexOffset: scene.offsetOf(m.id) });
32070
+ }
32071
+ }
32072
+ }
32073
+ for (const [key, { paneId, beforeZ, entries }] of buckets) {
32074
+ let canvas = this.sliceCache.get(key);
32075
+ if (!canvas) {
32076
+ canvas = document.createElement("canvas");
32077
+ this.sliceCache.set(key, canvas);
32078
+ }
32079
+ if (canvas.width !== ref.width || canvas.height !== ref.height) {
32080
+ canvas.width = ref.width;
32081
+ canvas.height = ref.height;
32082
+ }
32083
+ const ctx = canvas.getContext("2d");
32084
+ if (!ctx) continue;
32085
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
32086
+ ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
32087
+ for (const e of entries) this.paintEntry(ctx, e, coords, dataW, theme);
32088
+ const slices = out.get(paneId) ?? [];
32089
+ slices.push({ beforeZ, canvas });
32090
+ out.set(paneId, slices);
32091
+ }
32092
+ for (const key of [...this.sliceCache.keys()]) if (!buckets.has(key)) this.sliceCache.delete(key);
32093
+ for (const slices of out.values()) slices.sort((a, b) => a.beforeZ - b.beforeZ);
32094
+ return out;
32095
+ }
32096
+ paintEntry(ctx, e, coords, dataW, theme) {
32097
+ const { pane } = e;
32098
+ const paneTips = [];
32099
+ ctx.save();
32100
+ ctx.translate(0, pane.bounds.top);
32101
+ ctx.beginPath();
32102
+ ctx.rect(0, 0, dataW, pane.bounds.height);
32103
+ ctx.clip();
32104
+ this.drawScene.setSet(e.set, e.indexOffset);
32105
+ this.drawScene.render(
32106
+ ctx,
32107
+ dataW,
32108
+ pane.bounds.height,
32109
+ (l) => coords.logicalToX(l),
32110
+ (price) => coords.priceToY(price, pane.scale, pane.bounds) - pane.bounds.top
32111
+ );
32112
+ paneTips.push(...this.drawScene.labelTipRegions());
32113
+ for (const t of e.tables) paintTable(ctx, t, { paneHeight: pane.bounds.height, plotWidth: dataW, theme }, paneTips);
32114
+ ctx.restore();
32115
+ for (const r of paneTips) {
32116
+ this.tips.push({ ...r, top: r.top + pane.bounds.top, bottom: r.bottom + pane.bounds.top });
32117
+ }
32118
+ }
32119
+ /** Tooltip of the topmost label or table cell under a plot-space point, or null. Fed by the last prepare. */
32120
+ labelTooltipAt(x, y) {
32121
+ for (let i = this.tips.length - 1; i >= 0; i -= 1) {
32122
+ const r = this.tips[i];
32123
+ if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return r.text;
32124
+ }
32125
+ return null;
32126
+ }
32127
+ };
32128
+ function mergeSlices(indicator, user) {
32129
+ const out = /* @__PURE__ */ new Map();
32130
+ for (const [paneId, slices] of indicator) out.set(paneId, [...slices]);
32131
+ for (const [paneId, slices] of user) out.set(paneId, [...out.get(paneId) ?? [], ...slices]);
32132
+ for (const slices of out.values()) slices.sort((a, b) => a.beforeZ - b.beforeZ);
32133
+ return out;
32134
+ }
32135
+
31219
32136
  // src/renderers/native/drawings/Projector.ts
31220
32137
  function createProjector(coords, paneOf, paneIdAtY, barsInRange) {
31221
32138
  return {
@@ -31267,19 +32184,8 @@ function computePaneScale(models, bars, includeCandles, i0, i1, drawings, log =
31267
32184
  for (const model of models) {
31268
32185
  const off = offsetOf(model.id);
31269
32186
  for (const s of model.series) {
31270
- if (s.kind === "candle" || s.kind === "bar") {
31271
- for (let i = i0; i <= i1; i += 1) {
31272
- const b = s.bars[i - off];
31273
- if (b) {
31274
- consider(b.high);
31275
- consider(b.low);
31276
- }
31277
- }
31278
- } else if (isLineLikeSeries(s)) {
31279
- for (let i = i0; i <= i1; i += 1) consider(s.points[i - off]?.value);
31280
- if (s.kind === "histogram" || s.kind === "columns") consider(s.style?.base ?? 0);
31281
- else if (s.style?.base != null) consider(s.style.base);
31282
- }
32187
+ if (s.overlay === true) continue;
32188
+ considerSeries(s, i0, i1, off, consider);
31283
32189
  }
31284
32190
  for (const pl of model.priceLines) consider(pl.price);
31285
32191
  }
@@ -31301,6 +32207,36 @@ function computePaneScale(models, bars, includeCandles, i0, i1, drawings, log =
31301
32207
  const span = max - min;
31302
32208
  return { min: min - span * MARGIN_BOTTOM, max: max + span * MARGIN_TOP };
31303
32209
  }
32210
+ function considerSeries(s, i0, i1, off, consider) {
32211
+ if (s.kind === "candle" || s.kind === "bar") {
32212
+ for (let i = i0; i <= i1; i += 1) {
32213
+ const b = s.bars[i - off];
32214
+ if (b) {
32215
+ consider(b.high);
32216
+ consider(b.low);
32217
+ }
32218
+ }
32219
+ } else if (isLineLikeSeries(s)) {
32220
+ for (let i = i0; i <= i1; i += 1) consider(s.points[i - off]?.value);
32221
+ if (s.kind === "histogram" || s.kind === "columns") consider(s.style?.base ?? 0);
32222
+ else if (s.style?.base != null) consider(s.style.base);
32223
+ }
32224
+ }
32225
+ function overlaySeriesRange(models, i0, i1, offsetOf = () => 0) {
32226
+ let min = Infinity;
32227
+ let max = -Infinity;
32228
+ const consider = (v) => {
32229
+ if (v != null && Number.isFinite(v)) {
32230
+ if (v < min) min = v;
32231
+ if (v > max) max = v;
32232
+ }
32233
+ };
32234
+ for (const model of models) {
32235
+ const off = offsetOf(model.id);
32236
+ for (const s of model.series) if (s.overlay === true) considerSeries(s, i0, i1, off, consider);
32237
+ }
32238
+ return min === Infinity ? null : { min, max };
32239
+ }
31304
32240
  function expandScaleByPixels(scale, heightPx, abovePx, belowPx) {
31305
32241
  if (abovePx <= 0 && belowPx <= 0) return scale;
31306
32242
  const content = heightPx - abovePx - belowPx;
@@ -31942,6 +32878,11 @@ var NativeRenderer = class {
31942
32878
  this.vpvrRenderer = new VpvrRenderer();
31943
32879
  this.resizeObserver = null;
31944
32880
  this.dprMedia = null;
32881
+ /** Plot size in INTEGER device px, as last reported by the resize observer's
32882
+ * device-pixel-content-box — the browser's own statement of how many device pixels
32883
+ * it paints the plot into. `null` until the first report or where the box type is
32884
+ * unsupported (WebKit); syncSize then falls back to rounding the client rect. */
32885
+ this.plotDeviceSize = null;
31945
32886
  this.coords = new CoordinateSystem();
31946
32887
  this.scene = new SceneGraph();
31947
32888
  // chosen at mount (WebGL2 if available, else canvas2d)
@@ -31949,6 +32890,10 @@ var NativeRenderer = class {
31949
32890
  this.glowAmount = 0;
31950
32891
  // WebGL2 neon-glow intensity (canvas2d ignores it)
31951
32892
  this.chrome = new ChromeRenderer();
32893
+ /** Prepaints each indicator's Pine drawings into interleave slices at the model's z. */
32894
+ this.indicatorSlices = new IndicatorDrawingSlices();
32895
+ /** Hover tooltips for Pine labels (canvas hit-rects collected by the chrome layer). */
32896
+ this.labelTooltip = null;
31952
32897
  this.crosshairLayer = new CrosshairRenderer();
31953
32898
  /** 1 Hz repaint pump so the price-axis countdown-to-bar-close ticks; null when off. */
31954
32899
  this.countdownTimer = null;
@@ -31959,6 +32904,8 @@ var NativeRenderer = class {
31959
32904
  this.indicatorValuesOn = true;
31960
32905
  /** Host-contributed legend actions — held here so a rebuild of the legend re-wires them. */
31961
32906
  this.legendActionsProvider = null;
32907
+ /** Host-contributed legend callouts — held here so a rebuild of the legend re-wires them. */
32908
+ this.legendCalloutsProvider = null;
31962
32909
  /** Host override of the legend's fold toggle — held here so a remount re-applies it. */
31963
32910
  this.legendOverviewAction = null;
31964
32911
  // ── keyboard navigation / accessibility (item 11) ──
@@ -32093,7 +33040,6 @@ var NativeRenderer = class {
32093
33040
  this.toggleVisibleCbs = /* @__PURE__ */ new Set();
32094
33041
  this.moveIndicatorCbs = /* @__PURE__ */ new Set();
32095
33042
  this.priceStyleCbs = /* @__PURE__ */ new Set();
32096
- this.tableOverlays = /* @__PURE__ */ new Map();
32097
33043
  this.name = "native";
32098
33044
  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"];
32099
33045
  /** Track cursor proximity to the scroll button on the plot (bubbles from the button too,
@@ -32407,7 +33353,6 @@ var NativeRenderer = class {
32407
33353
  * hidden) on clear so a re-show picks up the current theme.
32408
33354
  */
32409
33355
  setLoading(loading) {
32410
- for (const overlay of this.tableOverlays.values()) overlay.setVisible(!loading);
32411
33356
  if (!loading || !this.wrapper) {
32412
33357
  this.loadingEl?.remove();
32413
33358
  this.loadingEl = null;
@@ -33067,6 +34012,10 @@ var NativeRenderer = class {
33067
34012
  this.plot.appendChild(this.scrollButton);
33068
34013
  this.plot.addEventListener("pointermove", this.onScrollProximityMove);
33069
34014
  this.plot.addEventListener("pointerleave", this.onScrollProximityLeave);
34015
+ this.labelTooltip = new LabelTooltip(this.plot, {
34016
+ theme: () => this.chromeTheme(),
34017
+ lookup: (x, y) => this.indicatorSlices.labelTooltipAt(x, y)
34018
+ });
33070
34019
  this.userDrawings = new UserDrawingController(this.wrapper, this.plot, this.drawingsCanvas, {
33071
34020
  projector: () => this.drawingProjector(),
33072
34021
  dpr: () => this.coords.dpr,
@@ -33106,9 +34055,10 @@ var NativeRenderer = class {
33106
34055
  this.inputsUI.setDialogHost(this.dialogHost);
33107
34056
  this.inputsUI.setSymbolPicker(this.symbolPicker);
33108
34057
  this.inputsUI.setLegendActions(this.legendActionsProvider);
34058
+ this.inputsUI.setLegendCallouts(this.legendCalloutsProvider);
33109
34059
  this.inputsUI.setLegendOverviewAction(this.legendOverviewAction);
33110
34060
  this.inputsUI.setOnChange((c) => {
33111
- for (const cb of this.inputChangeCbs) cb({ indicatorId: c.indicatorId, key: c.key, value: c.value });
34061
+ for (const cb of this.inputChangeCbs) cb({ indicatorId: c.indicatorId, key: c.key, value: c.value, ...c.kind ? { kind: c.kind } : {} });
33112
34062
  });
33113
34063
  this.inputsUI.setOnRemove((id) => {
33114
34064
  for (const cb of this.removeIndicatorCbs) cb(id);
@@ -33149,6 +34099,7 @@ var NativeRenderer = class {
33149
34099
  this.emitPaneAction({ type: "maximize", paneId, maximized });
33150
34100
  }
33151
34101
  });
34102
+ this.paneControls.setSuspended(this.layoutMode === "mobile");
33152
34103
  this.axisScaleButtons = new AxisScaleButtons(this.plot, theme, {
33153
34104
  panes: () => this.axisScaleViews(),
33154
34105
  rightAxis: () => this.rightAxisW,
@@ -33158,8 +34109,19 @@ var NativeRenderer = class {
33158
34109
  if (pane) this.setPaneLog(paneId, !paneLogScale(this.scene, pane));
33159
34110
  }
33160
34111
  });
33161
- this.resizeObserver = new ResizeObserver(() => this.resize());
34112
+ this.resizeObserver = new ResizeObserver((entries) => {
34113
+ for (const e of entries) {
34114
+ if (e.target !== this.plot) continue;
34115
+ const s = e.devicePixelContentBoxSize?.[0];
34116
+ if (s) this.plotDeviceSize = { width: s.inlineSize, height: s.blockSize };
34117
+ }
34118
+ this.resize();
34119
+ });
33162
34120
  this.resizeObserver.observe(this.wrapper);
34121
+ try {
34122
+ this.resizeObserver.observe(this.plot, { box: "device-pixel-content-box" });
34123
+ } catch {
34124
+ }
33163
34125
  this.watchDpr();
33164
34126
  this.syncSize();
33165
34127
  }
@@ -33284,8 +34246,6 @@ var NativeRenderer = class {
33284
34246
  this.inputsUI?.destroy();
33285
34247
  this.paneControls?.destroy();
33286
34248
  this.axisScaleButtons?.destroy();
33287
- for (const overlay of this.tableOverlays.values()) overlay.destroy();
33288
- this.tableOverlays.clear();
33289
34249
  this.resizeObserver?.disconnect();
33290
34250
  this.resizeObserver = null;
33291
34251
  this.dprMedia?.removeEventListener("change", this.onDprChange);
@@ -33298,6 +34258,8 @@ var NativeRenderer = class {
33298
34258
  this.settingsButton = null;
33299
34259
  this.plot?.removeEventListener("pointermove", this.onScrollProximityMove);
33300
34260
  this.plot?.removeEventListener("pointerleave", this.onScrollProximityLeave);
34261
+ this.labelTooltip?.destroy();
34262
+ this.labelTooltip = null;
33301
34263
  this.scrollButton?.remove();
33302
34264
  this.scrollButton = null;
33303
34265
  for (const l of this.extLayers) l.instance.destroy?.();
@@ -33312,6 +34274,7 @@ var NativeRenderer = class {
33312
34274
  this.attributionEl = null;
33313
34275
  this.mountContainer?.style.removeProperty("--vela-toolbar-gutter");
33314
34276
  this.mountContainer?.style.removeProperty("--vela-scale-gutter");
34277
+ this.mountContainer?.style.removeProperty("--vela-bottom-gutter");
33315
34278
  this.mountContainer?.style.removeProperty("--vela-price-pane-top");
33316
34279
  this.mountContainer?.style.removeProperty("--vela-price-pane-bottom");
33317
34280
  this.mountContainer = null;
@@ -33404,7 +34367,6 @@ var NativeRenderer = class {
33404
34367
  ensurePane(pane) {
33405
34368
  this.scene.ensurePane(pane.id, pane.kind, pane.order, pane.heightWeight ?? (pane.kind === "price" ? 3 : 1));
33406
34369
  this.layoutPanes();
33407
- this.repositionTables();
33408
34370
  this.paneControls?.refresh();
33409
34371
  this.scheduler.invalidate(4 /* Full */);
33410
34372
  }
@@ -33426,17 +34388,14 @@ var NativeRenderer = class {
33426
34388
  if (!model.ownScale) this.scene.dropIndicatorScale(handle.id);
33427
34389
  this.inputsUI.setPane(handle.id, paneId);
33428
34390
  this.refreshAnchorOffset(model);
33429
- this.syncTables(model);
33430
34391
  this.refreshAxisWidth();
33431
34392
  this.layoutPanes();
33432
- this.repositionTables();
33433
34393
  this.paneControls?.refresh();
33434
34394
  this.scheduler.invalidate(4 /* Full */);
33435
34395
  }
33436
34396
  orderPanes(orderedIds) {
33437
34397
  this.scene.orderPanes(orderedIds);
33438
34398
  this.layoutPanes();
33439
- this.repositionTables();
33440
34399
  this.paneControls?.refresh();
33441
34400
  this.scheduler.invalidate(4 /* Full */);
33442
34401
  }
@@ -33445,7 +34404,6 @@ var NativeRenderer = class {
33445
34404
  if (!pane || pane.collapsed === collapsed) return;
33446
34405
  pane.collapsed = collapsed;
33447
34406
  this.layoutPanes();
33448
- this.repositionTables();
33449
34407
  this.paneControls?.refresh();
33450
34408
  this.scheduler.invalidate(4 /* Full */);
33451
34409
  }
@@ -33453,7 +34411,6 @@ var NativeRenderer = class {
33453
34411
  if (paneId !== null && !this.scene.panes.has(paneId)) paneId = null;
33454
34412
  this.maximizedPaneId = paneId;
33455
34413
  this.layoutPanes();
33456
- this.repositionTables();
33457
34414
  this.paneControls?.refresh();
33458
34415
  this.scheduler.invalidate(4 /* Full */);
33459
34416
  }
@@ -33538,9 +34495,8 @@ var NativeRenderer = class {
33538
34495
  else this.scene.assignIndicatorZ(model.id);
33539
34496
  this.inputsUI.upsert(model.id, model.shorttitle ?? model.title, model.inputs, model.inputValues, model.paneId, {
33540
34497
  native: !!model.native,
33541
- ...model.shorttitle ? { settingsTitle: model.title } : {}
34498
+ ...model.props ? { props: model.props, propValues: model.propValues ?? {} } : {}
33542
34499
  });
33543
- this.syncTables(model);
33544
34500
  if (model.native?.type === "volume") {
33545
34501
  this.volumeActive = true;
33546
34502
  this.volumeHidden = false;
@@ -33564,7 +34520,6 @@ var NativeRenderer = class {
33564
34520
  }
33565
34521
  }
33566
34522
  applyPatch(model, patch);
33567
- this.syncTables(model);
33568
34523
  this.scheduler.invalidate(3 /* Light */);
33569
34524
  }
33570
34525
  removeIndicator(handle) {
@@ -33583,14 +34538,12 @@ var NativeRenderer = class {
33583
34538
  this.scene.forgetAnchorOffset(handle.id);
33584
34539
  this.scene.dropIndicatorScale(handle.id);
33585
34540
  this.inputsUI.remove(handle.id);
33586
- this.tableOverlays.get(handle.id)?.destroy();
33587
- this.tableOverlays.delete(handle.id);
33588
34541
  this.refreshAxisWidth();
33589
34542
  this.paneControls?.refresh();
33590
34543
  this.scheduler.invalidate(4 /* Full */);
33591
34544
  }
33592
- setIndicatorInputs(handle, values) {
33593
- this.inputsUI.setValues(handle.id, values);
34545
+ setIndicatorInputs(handle, values, props) {
34546
+ this.inputsUI.setValues(handle.id, values, props);
33594
34547
  }
33595
34548
  setSymbolPicker(picker) {
33596
34549
  this.symbolPicker = picker;
@@ -33600,6 +34553,10 @@ var NativeRenderer = class {
33600
34553
  this.legendActionsProvider = provider;
33601
34554
  this.inputsUI?.setLegendActions(provider);
33602
34555
  }
34556
+ setLegendCallouts(provider) {
34557
+ this.legendCalloutsProvider = provider;
34558
+ this.inputsUI?.setLegendCallouts(provider);
34559
+ }
33603
34560
  setLegendOverviewAction(action) {
33604
34561
  this.legendOverviewAction = action;
33605
34562
  this.inputsUI?.setLegendOverviewAction(action);
@@ -33624,8 +34581,6 @@ var NativeRenderer = class {
33624
34581
  }
33625
34582
  if (!visible) {
33626
34583
  this.scene.indicators.delete(handle.id);
33627
- this.tableOverlays.get(handle.id)?.destroy();
33628
- this.tableOverlays.delete(handle.id);
33629
34584
  }
33630
34585
  this.inputsUI.setVisible(handle.id, visible);
33631
34586
  this.scheduler.invalidate(4 /* Full */);
@@ -33671,6 +34626,7 @@ var NativeRenderer = class {
33671
34626
  this.userDrawings?.setLayoutMode(mode);
33672
34627
  this.settingsDialog?.setLayoutMode(mode);
33673
34628
  this.inputsUI?.setLayoutMode(mode);
34629
+ this.paneControls?.setSuspended(mode === "mobile");
33674
34630
  if (this.scrollButton) {
33675
34631
  const px = mode === "mobile" ? SCROLL_BTN_SIZE_TOUCH : SCROLL_BTN_SIZE;
33676
34632
  this.scrollButton.style.width = `${px}px`;
@@ -34066,7 +35022,6 @@ var NativeRenderer = class {
34066
35022
  /** Relayout + repaint + refresh the hover buttons after a collapse/maximize/order change. */
34067
35023
  afterPaneLayoutChange() {
34068
35024
  this.layoutPanes();
34069
- this.repositionTables();
34070
35025
  this.paneControls?.refresh();
34071
35026
  this.scheduler.invalidate(4 /* Full */);
34072
35027
  }
@@ -34147,7 +35102,6 @@ var NativeRenderer = class {
34147
35102
  above.heightWeight = next.above;
34148
35103
  below.heightWeight = next.below;
34149
35104
  this.layoutPanes();
34150
- this.repositionTables();
34151
35105
  this.scheduler.invalidate(4 /* Full */);
34152
35106
  }
34153
35107
  /** Double-click a separator → split the two adjacent panes evenly (each gets half of
@@ -34162,7 +35116,6 @@ var NativeRenderer = class {
34162
35116
  above.heightWeight = half;
34163
35117
  below.heightWeight = half;
34164
35118
  this.layoutPanes();
34165
- this.repositionTables();
34166
35119
  this.scheduler.invalidate(4 /* Full */);
34167
35120
  }
34168
35121
  // ── keyboard navigation / accessibility (item 11) ──
@@ -34441,7 +35394,10 @@ var NativeRenderer = class {
34441
35394
  const liveActual = li >= 0 ? this.bars[li] : void 0;
34442
35395
  const easeLive = !!liveActual && this.liveEaseTime === liveActual.time && (liveActual.high !== this.liveEaseHigh || liveActual.low !== this.liveEaseLow || liveActual.close !== this.liveEaseClose);
34443
35396
  if (easeLive && liveActual) this.bars[li] = { ...liveActual, high: this.liveEaseHigh, low: this.liveEaseLow, close: this.liveEaseClose };
34444
- this.scene.drawingSlices = this.userDrawings?.prepareSlices(this.scene.orderedPanes().map((p) => p.id)) ?? /* @__PURE__ */ new Map();
35397
+ this.scene.drawingSlices = mergeSlices(
35398
+ this.indicatorSlices.prepare(this.scene, this.coords, this.theme, this.dataCanvas),
35399
+ this.userDrawings?.prepareSlices(this.scene.orderedPanes().map((p) => p.id)) ?? /* @__PURE__ */ new Map()
35400
+ );
34445
35401
  this.backdropRenderer.render(this.scene, this.coords, this.theme, gridAlpha);
34446
35402
  this.backend.render(this.scene, this.coords, this.theme);
34447
35403
  this.chrome.render(this.scene, this.coords, this.theme, this.axisSurface());
@@ -34580,7 +35536,11 @@ var NativeRenderer = class {
34580
35536
  }
34581
35537
  const models = this.scene.indicatorsForPane(pane.id);
34582
35538
  const masterModels = models.filter((m) => m.ownScale !== true);
34583
- const dr = this.chrome.paneDrawingsRange(masterModels, this.scene, pane === pricePane, vr);
35539
+ let dr = this.chrome.paneDrawingsRange(masterModels, this.scene, pane === pricePane, vr);
35540
+ if (pane === pricePane) {
35541
+ const or = overlaySeriesRange(this.scene.indicators.values(), i0, i1, (id) => this.scene.offsetOf(id));
35542
+ if (or) dr = dr ? { min: Math.min(dr.min, or.min), max: Math.max(dr.max, or.max) } : or;
35543
+ }
34584
35544
  const includeCandles = pane.kind === "price" && !this.scene.candlesHidden;
34585
35545
  pane.scaleTarget = computePaneScale(masterModels, this.bars, includeCandles, i0, i1, dr, paneLogScale(this.scene, pane), (id) => this.scene.offsetOf(id));
34586
35546
  pane.percentBaseline = pane.kind === "price" ? this.bars[i0]?.close ?? 0 : this.firstVisibleValue(masterModels, i0);
@@ -34854,6 +35814,7 @@ var NativeRenderer = class {
34854
35814
  for (const m of models) {
34855
35815
  const off = this.scene.offsetOf(m.id);
34856
35816
  for (const s of m.series) {
35817
+ if (s.overlay === true) continue;
34857
35818
  if (isLineLikeSeries(s)) {
34858
35819
  const v = s.points[i0 - off]?.value;
34859
35820
  if (v != null && Number.isFinite(v)) return v;
@@ -34874,27 +35835,6 @@ var NativeRenderer = class {
34874
35835
  }
34875
35836
  return maxVol;
34876
35837
  }
34877
- /** Create/update/destroy an indicator's DOM table overlay (anchored off real pane geometry). */
34878
- syncTables(model) {
34879
- const tables = model.tables ?? [];
34880
- let overlay = this.tableOverlays.get(model.id);
34881
- if (tables.length === 0) {
34882
- if (overlay) {
34883
- overlay.destroy();
34884
- this.tableOverlays.delete(model.id);
34885
- }
34886
- return;
34887
- }
34888
- if (!overlay) {
34889
- overlay = new TableOverlay(this.plot, this.theme, (id) => this.paneBoundsFor(id));
34890
- overlay.setVisible(this.loadingEl === null);
34891
- this.tableOverlays.set(model.id, overlay);
34892
- }
34893
- overlay.update(tables);
34894
- }
34895
- repositionTables() {
34896
- for (const overlay of this.tableOverlays.values()) overlay.reposition();
34897
- }
34898
35838
  layoutPanes() {
34899
35839
  const panes = this.scene.orderedPanes();
34900
35840
  const dataHeight = this.coords.height;
@@ -34945,6 +35885,7 @@ var NativeRenderer = class {
34945
35885
  const visible = maxPane ? [maxPane] : this.scene.orderedPanes().filter((p) => !p.collapsed);
34946
35886
  const paneBottom = visible.length ? Math.max(...visible.map((p) => p.bounds.top + p.bounds.height)) : dataHeight;
34947
35887
  this.scrollBtnBottomPx = SCROLL_BTN_BOTTOM + Math.max(0, dataHeight - paneBottom);
35888
+ this.mountContainer?.style.setProperty("--vela-bottom-gutter", `${TIME_AXIS_H + Math.max(0, dataHeight - paneBottom)}px`);
34948
35889
  this.scrollBtnRightPx = this.rightAxisW + SCROLL_BTN_RIGHT_INSET;
34949
35890
  if (this.scrollButton) {
34950
35891
  this.scrollButton.style.bottom = `${this.scrollBtnBottomPx}px`;
@@ -35037,30 +35978,33 @@ var NativeRenderer = class {
35037
35978
  if (w <= 0 || h <= 0) return;
35038
35979
  const dpr = window.devicePixelRatio || 1;
35039
35980
  this.plot.style.left = `${this.toolbarGutter}px`;
35040
- const pw = Math.max(1, w - this.toolbarGutter);
35041
- const ph = h;
35042
- this.dataCanvas.width = Math.round(pw * dpr);
35043
- this.dataCanvas.height = Math.round(ph * dpr);
35044
- this.backdropCanvas.width = this.dataCanvas.width;
35045
- this.backdropCanvas.height = this.dataCanvas.height;
35046
- this.volumeCanvas.width = this.dataCanvas.width;
35047
- this.volumeCanvas.height = this.dataCanvas.height;
35048
- for (const l of this.extLayers) {
35049
- l.canvas.width = this.dataCanvas.width;
35050
- l.canvas.height = this.dataCanvas.height;
35051
- }
35052
- this.vpvrCanvas.width = this.dataCanvas.width;
35053
- this.vpvrCanvas.height = this.dataCanvas.height;
35054
- this.chromeCanvas.width = this.dataCanvas.width;
35055
- this.chromeCanvas.height = this.dataCanvas.height;
35056
- this.drawingsCanvas.width = this.dataCanvas.width;
35057
- this.drawingsCanvas.height = this.dataCanvas.height;
35058
- this.cursorCanvas.width = this.dataCanvas.width;
35059
- this.cursorCanvas.height = this.dataCanvas.height;
35981
+ const rect = this.plot.getBoundingClientRect();
35982
+ let bw = Math.max(1, Math.round(rect.width * dpr));
35983
+ let bh = Math.max(1, Math.round(rect.height * dpr));
35984
+ const dev = this.plotDeviceSize;
35985
+ if (dev && Math.abs(dev.width - rect.width * dpr) <= 1 && Math.abs(dev.height - rect.height * dpr) <= 1) {
35986
+ bw = Math.max(1, dev.width);
35987
+ bh = Math.max(1, dev.height);
35988
+ }
35989
+ const pw = bw / dpr;
35990
+ const ph = bh / dpr;
35991
+ const size = (canvas) => {
35992
+ canvas.width = bw;
35993
+ canvas.height = bh;
35994
+ canvas.style.width = `${pw}px`;
35995
+ canvas.style.height = `${ph}px`;
35996
+ };
35997
+ size(this.dataCanvas);
35998
+ size(this.backdropCanvas);
35999
+ size(this.volumeCanvas);
36000
+ for (const l of this.extLayers) size(l.canvas);
36001
+ size(this.vpvrCanvas);
36002
+ size(this.chromeCanvas);
36003
+ size(this.drawingsCanvas);
36004
+ size(this.cursorCanvas);
35060
36005
  this.coords.setSize(Math.max(1, pw - this.rightAxisW), Math.max(1, ph - TIME_AXIS_H), dpr);
35061
36006
  this.scene.crosshair = null;
35062
36007
  this.layoutPanes();
35063
- this.repositionTables();
35064
36008
  this.userDrawings?.onResize();
35065
36009
  if (!this.didInitialFit && this.coords.barCount > 0) {
35066
36010
  this.fitContent();
@@ -35726,45 +36670,24 @@ var CSS21 = `
35726
36670
  }
35727
36671
  .vela-statusline .vela-sl-symbol { font-weight: 600; font-size: var(--vela-font-size-lg); }
35728
36672
  .vela-statusline .vela-sl-meta { color: var(--vela-fg-muted); font-size: var(--vela-font-size-md); font-weight: 600; }
35729
- /* Market status badge \u2014 icon-only 16px circle, label on hover (kit tooltip). */
35730
- .vela-statusline .vela-sl-market {
35731
- display: inline-grid;
35732
- place-items: center;
35733
- width: 16px;
35734
- height: 16px;
35735
- border-radius: 50%;
35736
- flex: none;
35737
- align-self: center;
35738
- line-height: 0;
35739
- cursor: default;
35740
- }
35741
- .vela-statusline .vela-sl-market svg {
35742
- width: 12px;
35743
- height: 12px;
35744
- display: block;
35745
- }
35746
- /* Open wears the theme's up color; the other sessions are meaning constants from the
35747
- * palette (amber pre, sky post, gray closed/holiday). Same ink at 20% for the circle. */
35748
- .vela-statusline .vela-sl-market[data-status='open'] {
35749
- background: color-mix(in srgb, var(--vela-up) 20%, transparent);
35750
- color: var(--vela-up);
35751
- }
35752
- .vela-statusline .vela-sl-market[data-status='pre'] { background: color-mix(in srgb, ${SESSION_PRE} 20%, transparent); color: ${SESSION_PRE}; }
35753
- .vela-statusline .vela-sl-market[data-status='post'] { background: color-mix(in srgb, ${SESSION_POST} 20%, transparent); color: ${SESSION_POST}; }
35754
- .vela-statusline .vela-sl-market[data-status='closed'],
35755
- .vela-statusline .vela-sl-market[data-status='holiday'] { background: color-mix(in srgb, ${SESSION_OFF} 20%, transparent); color: ${SESSION_OFF}; }
36673
+ /* Market status badge \u2014 a kit callout bubble (icon-only 16px circle, label on hover
36674
+ * via the kit tooltip); the session tint is applied per status in setMarketStatus. */
36675
+ .vela-statusline .vela-sl-market { align-self: center; }
35756
36676
  .vela-statusline .vela-sl-ohlc { display: flex; gap: var(--vela-space-1); color: var(--vela-fg-muted); }
35757
36677
  .vela-statusline .vela-sl-ohlc b { color: var(--vela-fg); font-weight: 500; }
35758
36678
  /* The change value wears the SAME ink as the OHLC values (set inline per render) \u2014
35759
36679
  * these are the pre-ink fallbacks only. */
35760
36680
  .vela-statusline .vela-sl-change[data-dir='up'] { color: var(--vela-up); }
35761
36681
  .vela-statusline .vela-sl-change[data-dir='down'] { color: var(--vela-down); }
35762
- /* Stack the renderer's PRICE-pane legend below the status line (study panes stay put).
35763
- * The renderer sets the legend's inline top \u2014 shift with a transform, don't fight it.
35764
- * Scoped to hosts that actually CARRY a status line (the marker class set by the
35765
- * Statusline constructor) \u2014 the stylesheet is document-global, so a bare container
35766
- * class here would shift every chart on the page, including statusline-less ones. */
35767
- .vela-has-statusline [data-vela-pane='price'] { transform: translateY(26px); }
36682
+ /* Stack the TOP pane's legend below the status line (lower study panes stay put).
36683
+ * The renderer marks whichever legend sits at the plot's top edge \u2014 the price pane
36684
+ * normally, or a maximized study pane filling the plot \u2014 so the legend never merges
36685
+ * with the status line whichever pane owns the top. The renderer sets the legend's
36686
+ * inline top \u2014 shift with a transform, don't fight it. Scoped to hosts that actually
36687
+ * CARRY a status line (the marker class set by the Statusline constructor) \u2014 the
36688
+ * stylesheet is document-global, so a bare attribute selector here would shift every
36689
+ * chart on the page, including statusline-less ones. */
36690
+ .vela-has-statusline [${LEGEND_AT_TOP_ATTR}] { transform: translateY(26px); }
35768
36691
  /* Mobile: two-line chip \u2014 logo / symbol / meta / market status on one aligned row, the
35769
36692
  * bar change on the next. Full O/H/L/C stays hidden (too dense on a phone-width plot).
35770
36693
  * GRID, not a wrapping flexbox: an absolutely positioned wrapping flex container sizes
@@ -35800,7 +36723,7 @@ var CSS21 = `
35800
36723
  font-size: var(--vela-font-size-sm);
35801
36724
  line-height: 1.2;
35802
36725
  }
35803
- [data-layout='mobile'] .vela-has-statusline [data-vela-pane='price'] { transform: translateY(40px); }
36726
+ [data-layout='mobile'] .vela-has-statusline [${LEGEND_AT_TOP_ATTR}] { transform: translateY(40px); }
35804
36727
  /* FIT mode (multi-chart cells \u2014 see setFitMode): the line never wraps; segments that
35805
36728
  * don't fit are HIDDEN by fit() (change first, then meta, then the market badge), so
35806
36729
  * overflow:hidden only guards the transient between a resize and the next measure.
@@ -35817,7 +36740,7 @@ var CSS21 = `
35817
36740
  padding-left: 0;
35818
36741
  }
35819
36742
  /* One row again \u2014 the mobile two-line shift doesn't apply in fit mode. */
35820
- [data-layout='mobile'] .vela-sl-fit-host.vela-has-statusline [data-vela-pane='price'] { transform: translateY(26px); }
36743
+ [data-layout='mobile'] .vela-sl-fit-host.vela-has-statusline [${LEGEND_AT_TOP_ATTR}] { transform: translateY(26px); }
35821
36744
  `;
35822
36745
  function baseOfTicker(ticker) {
35823
36746
  return ticker.replace(/[-_/]?(USDT|USDC|USD1|USDS|BUSD|USD|EUR|PERP)$/i, "") || ticker;
@@ -35829,6 +36752,13 @@ var MARKET_LABELS = {
35829
36752
  closed: "Market Closed",
35830
36753
  holiday: "Market Holiday"
35831
36754
  };
36755
+ var MARKET_INKS = {
36756
+ open: "var(--vela-up)",
36757
+ pre: SESSION_PRE,
36758
+ post: SESSION_POST,
36759
+ closed: SESSION_OFF,
36760
+ holiday: SESSION_OFF
36761
+ };
35832
36762
  function statuslineInkOf(renderer, priceStyle) {
35833
36763
  const cfg = renderer.getConfig();
35834
36764
  const c = (v) => typeof v === "string" ? v : null;
@@ -35887,8 +36817,15 @@ var Statusline = class {
35887
36817
  this.symbolEl.textContent = ticker;
35888
36818
  this.metaEl = doc.createElement("span");
35889
36819
  this.metaEl.className = "vela-sl-meta";
35890
- this.marketEl = doc.createElement("span");
35891
- this.marketEl.className = "vela-sl-market";
36820
+ this.marketBubble = new CalloutBubble({
36821
+ icon: "market-open",
36822
+ background: `color-mix(in srgb, ${MARKET_INKS.open} 20%, transparent)`,
36823
+ color: MARKET_INKS.open,
36824
+ label: MARKET_LABELS.open,
36825
+ host
36826
+ });
36827
+ this.marketEl = this.marketBubble.el;
36828
+ this.marketEl.classList.add("vela-sl-market");
35892
36829
  this.ohlcEl = doc.createElement("span");
35893
36830
  this.ohlcEl.className = "vela-sl-ohlc";
35894
36831
  this.changeEl = doc.createElement("span");
@@ -35976,8 +36913,13 @@ var Statusline = class {
35976
36913
  * hover label. Callers with no session model leave the constructor's 'open'. */
35977
36914
  setMarketStatus(status) {
35978
36915
  this.marketEl.dataset.status = status;
35979
- this.marketEl.innerHTML = icon(`market-${status}`);
35980
- this.marketEl.setAttribute("aria-label", MARKET_LABELS[status]);
36916
+ const ink = MARKET_INKS[status];
36917
+ this.marketBubble.set({
36918
+ icon: `market-${status}`,
36919
+ background: `color-mix(in srgb, ${ink} 20%, transparent)`,
36920
+ color: ink,
36921
+ label: MARKET_LABELS[status]
36922
+ });
35981
36923
  this.marketTip.setContent(MARKET_LABELS[status]);
35982
36924
  }
35983
36925
  setPartVisible(part, visible) {
@@ -36010,6 +36952,7 @@ var Statusline = class {
36010
36952
  this.fitRO?.disconnect();
36011
36953
  this.fitRO = null;
36012
36954
  this.marketTip.destroy();
36955
+ this.marketBubble.destroy();
36013
36956
  this.host.classList.remove("vela-has-statusline", "vela-sl-fit-host");
36014
36957
  this.el.remove();
36015
36958
  }
@@ -36236,6 +37179,8 @@ var SessionShadingTracker = class {
36236
37179
  this.spec = null;
36237
37180
  this.ready = false;
36238
37181
  this.session = "regular";
37182
+ /** Whether one bar is shorter than a civil day — only then do pre/post bars exist. */
37183
+ this.intraday = false;
36239
37184
  this.covered = null;
36240
37185
  /** The newest range seen — viewport moves during metadata resolution (a load's fit
36241
37186
  * animation) must not be lost, so the resolution always expands the LATEST range. */
@@ -36248,6 +37193,8 @@ var SessionShadingTracker = class {
36248
37193
  this.spec = null;
36249
37194
  this.covered = null;
36250
37195
  this.session = opts.session;
37196
+ const tfMs = timeframeMs(opts.timeframe);
37197
+ this.intraday = Number.isFinite(tfMs) && tfMs < DAY_MS2;
36251
37198
  this.lastRange = opts.range;
36252
37199
  void data.symbolInfo(symbol).catch(() => void 0).then((si) => {
36253
37200
  if (my !== this.epoch) return;
@@ -36275,7 +37222,7 @@ var SessionShadingTracker = class {
36275
37222
  if (force) this.onZones(null);
36276
37223
  return;
36277
37224
  }
36278
- if (this.session !== "extended") {
37225
+ if (this.session !== "extended" || !this.intraday) {
36279
37226
  if (force) this.onZones({ pre: [], post: [] });
36280
37227
  return;
36281
37228
  }
@@ -36376,9 +37323,168 @@ var Watermark = class {
36376
37323
  }
36377
37324
  };
36378
37325
 
37326
+ // src/widget/cell-controls.ts
37327
+ var CELL_CONTROLS_PROXIMITY_PX = 120;
37328
+ var TIME_AXIS_H2 = 22;
37329
+ var CONTROLS_BOTTOM_PX = TIME_AXIS_H2 + 12;
37330
+ var CLUSTER_H2 = 24;
37331
+ var CLUSTER_PILL2 = "rgba(0,0,0,0.65)";
37332
+ var STYLE_ID26 = "vela-cell-controls";
37333
+ var CSS23 = `
37334
+ .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;}
37335
+ .vela-cc-btn svg{display:block;}
37336
+ .vela-cc-btn:hover{background:var(--vela-active);color:var(--vela-fg-bright);}
37337
+ .vela-cc-on,.vela-cc-on:hover{background:var(--vela-selected-bg);color:var(--vela-selected-fg);}
37338
+ .vela-cc-grip{cursor:grab;touch-action:none;}
37339
+ .vela-cc-grip:active{cursor:grabbing;}
37340
+ `;
37341
+ function nearBottomCenter(x, y, width, height, proximityPx = CELL_CONTROLS_PROXIMITY_PX) {
37342
+ const cx = width / 2;
37343
+ const cy = height - CONTROLS_BOTTOM_PX - CLUSTER_H2 / 2;
37344
+ return Math.hypot(x - cx, y - cy) <= proximityPx;
37345
+ }
37346
+ var CellControls = class {
37347
+ constructor(host, deps) {
37348
+ this.host = host;
37349
+ this.deps = deps;
37350
+ this.near = false;
37351
+ /** A grip drag is underway — the proximity reveal must not hide the cluster
37352
+ * while captured pointer moves sweep across the whole grid. */
37353
+ this.dragging = false;
37354
+ /** Mobile: the proximity reveal is meaningless without a cursor — the mobile
37355
+ * bar's maximize stop replaces the cluster. */
37356
+ this.suspended = false;
37357
+ this.onHostMove = (e) => {
37358
+ if (this.suspended) return;
37359
+ if (this.dragging) return;
37360
+ const rect = this.host.getBoundingClientRect();
37361
+ this.setNear(nearBottomCenter(e.clientX - rect.left, e.clientY - rect.top, rect.width, rect.height));
37362
+ };
37363
+ this.onHostLeave = () => {
37364
+ if (this.dragging) return;
37365
+ this.setNear(false);
37366
+ };
37367
+ injectStyles(STYLE_ID26, CSS23, host.ownerDocument);
37368
+ this.glider = new Glider(deps.chart);
37369
+ this.root = host.ownerDocument.createElement("div");
37370
+ Object.assign(this.root.style, {
37371
+ position: "absolute",
37372
+ left: "50%",
37373
+ bottom: `${CONTROLS_BOTTOM_PX}px`,
37374
+ transform: "translateX(-50%)",
37375
+ zIndex: "6",
37376
+ display: "none",
37377
+ // revealed by cursor proximity (onHostMove)
37378
+ gap: "2px",
37379
+ padding: "2px",
37380
+ borderRadius: "var(--vela-radius-md)",
37381
+ background: CLUSTER_PILL2,
37382
+ pointerEvents: "auto"
37383
+ });
37384
+ this.host.addEventListener("pointermove", this.onHostMove);
37385
+ this.host.addEventListener("pointerleave", this.onHostLeave);
37386
+ this.host.appendChild(this.root);
37387
+ this.refresh();
37388
+ }
37389
+ /** Rebuild the buttons (the multi-cell gate or the maximized state changed). */
37390
+ refresh() {
37391
+ this.root.textContent = "";
37392
+ const multi = this.deps.multiCell();
37393
+ const maximized = multi && this.deps.isMaximized();
37394
+ if (multi && !maximized) this.root.appendChild(this.makeGrip());
37395
+ this.root.appendChild(this.button("minus", "Zoom out", () => this.glider.zoom(ZOOM_OUT)));
37396
+ this.root.appendChild(this.button("plus", "Zoom in", () => this.glider.zoom(ZOOM_IN)));
37397
+ if (multi) {
37398
+ this.root.appendChild(
37399
+ this.button(maximized ? "restore" : "maximize", maximized ? "Restore layout" : "Maximize chart", () => this.deps.toggleMaximize(), {
37400
+ // The maximized state reads as an inverse chip (white-on-dark, dark-on-light),
37401
+ // the same active-state affordance as a collapsed pane's expand button.
37402
+ selected: maximized
37403
+ })
37404
+ );
37405
+ }
37406
+ this.root.appendChild(
37407
+ this.button("reset", "Reset chart", () => {
37408
+ this.glider.stop();
37409
+ this.deps.reset();
37410
+ })
37411
+ );
37412
+ }
37413
+ button(iconId, title, onClick, opts = {}) {
37414
+ const b = this.host.ownerDocument.createElement("button");
37415
+ b.type = "button";
37416
+ b.title = title;
37417
+ b.setAttribute("aria-label", title);
37418
+ b.className = opts.selected === true ? "vela-cc-btn vela-cc-on" : "vela-cc-btn";
37419
+ b.innerHTML = icon(iconId);
37420
+ b.addEventListener("click", (e) => {
37421
+ e.stopPropagation();
37422
+ onClick();
37423
+ });
37424
+ return b;
37425
+ }
37426
+ /** The drag handle (2×3 dot grip): press and drag onto another cell to trade
37427
+ * slots with it. The preview highlight follows the pointer; releasing outside
37428
+ * any other cell cancels. */
37429
+ makeGrip() {
37430
+ const b = this.host.ownerDocument.createElement("button");
37431
+ b.type = "button";
37432
+ b.title = "Drag to move chart";
37433
+ b.setAttribute("aria-label", "Drag to move chart");
37434
+ b.className = "vela-cc-btn vela-cc-grip";
37435
+ b.innerHTML = icon("grip");
37436
+ b.addEventListener("pointerdown", (e) => this.onGripDown(b, e));
37437
+ return b;
37438
+ }
37439
+ onGripDown(btn2, e) {
37440
+ if (e.button !== 0 && e.pointerType === "mouse") return;
37441
+ e.preventDefault();
37442
+ e.stopPropagation();
37443
+ try {
37444
+ btn2.setPointerCapture(e.pointerId);
37445
+ } catch {
37446
+ }
37447
+ this.dragging = true;
37448
+ let target = null;
37449
+ const move = (ev) => {
37450
+ target = this.deps.dragTargetAt(ev.clientX, ev.clientY);
37451
+ this.deps.previewDrop(target);
37452
+ };
37453
+ const finish = (commit) => () => {
37454
+ this.dragging = false;
37455
+ this.deps.previewDrop(null);
37456
+ btn2.removeEventListener("pointermove", move);
37457
+ btn2.removeEventListener("pointerup", onUp);
37458
+ btn2.removeEventListener("pointercancel", onCancel);
37459
+ if (commit && target != null) this.deps.dropOn(target);
37460
+ };
37461
+ const onUp = finish(true);
37462
+ const onCancel = finish(false);
37463
+ btn2.addEventListener("pointermove", move);
37464
+ btn2.addEventListener("pointerup", onUp);
37465
+ btn2.addEventListener("pointercancel", onCancel);
37466
+ }
37467
+ /** Mobile flips the cluster off entirely (and hides it if currently revealed). */
37468
+ setSuspended(on) {
37469
+ this.suspended = on;
37470
+ if (on) this.setNear(false);
37471
+ }
37472
+ setNear(near) {
37473
+ if (near === this.near) return;
37474
+ this.near = near;
37475
+ this.root.style.display = near ? "flex" : "none";
37476
+ }
37477
+ destroy() {
37478
+ this.glider.stop();
37479
+ this.host.removeEventListener("pointermove", this.onHostMove);
37480
+ this.host.removeEventListener("pointerleave", this.onHostLeave);
37481
+ this.root.remove();
37482
+ }
37483
+ };
37484
+
36379
37485
  // src/widget/context-menu.ts
36380
37486
  var PRICE_AXIS_W = 60;
36381
- var TIME_AXIS_H2 = 26;
37487
+ var TIME_AXIS_H3 = 26;
36382
37488
  var ChartContextMenu = class {
36383
37489
  constructor(host, cbs) {
36384
37490
  this.cbs = cbs;
@@ -36417,7 +37523,7 @@ var ChartContextMenu = class {
36417
37523
  zoneOf(e) {
36418
37524
  const rect = this.host.getBoundingClientRect();
36419
37525
  if (e.clientX - rect.left > rect.width - PRICE_AXIS_W) return "price-axis";
36420
- if (e.clientY - rect.top > rect.height - TIME_AXIS_H2) return "time-axis";
37526
+ if (e.clientY - rect.top > rect.height - TIME_AXIS_H3) return "time-axis";
36421
37527
  return "body";
36422
37528
  }
36423
37529
  /** The pane under the pointer, so every pane's price scale has its own menu. */
@@ -36690,6 +37796,7 @@ var ChartCell = class {
36690
37796
  this.inner.renderer.set("attribution", false);
36691
37797
  this.inner.renderer.set("dialogHost", deps.dialogHost);
36692
37798
  this.inner.renderer.setLegendActions(legendActionsProviderFor(this.inner, () => deps.context()));
37799
+ this.inner.renderer.setLegendCallouts(legendCalloutsProviderFor(this.inner, () => deps.context()));
36693
37800
  if (this.inner.renderer.supports("historyChords")) this.inner.renderer.set("historyChords", false);
36694
37801
  this.history.onChart(this.inner);
36695
37802
  this.inner.renderer.onConfigChanged(() => {
@@ -36750,11 +37857,18 @@ var ChartCell = class {
36750
37857
  if (this.inner && this.state.symbol) this.marketStatus?.track(this.inner.data, this.state.symbol);
36751
37858
  });
36752
37859
  this.syncStatuslineColors();
37860
+ this.cellControls = new CellControls(this.host, {
37861
+ chart: () => this.inner,
37862
+ reset: () => this.resetView(),
37863
+ multiCell: () => deps.multiCell(),
37864
+ isMaximized: () => deps.isMaximized(id),
37865
+ toggleMaximize: () => deps.toggleMaximize(id),
37866
+ dragTargetAt: (x, y) => deps.cellDragTarget(id, x, y),
37867
+ previewDrop: (target) => deps.previewDropTarget(target),
37868
+ dropOn: (target) => deps.dropCell(id, target)
37869
+ });
36753
37870
  this.contextMenu = new ChartContextMenu(this.host, {
36754
- resetView: () => {
36755
- this.inner?.renderer.set("autoScale", true);
36756
- this.inner?.setVisibleRangePreset("ALL");
36757
- },
37871
+ resetView: () => this.resetView(),
36758
37872
  timezone: () => this.deps.timezone(),
36759
37873
  setTimezone: (zone) => this.deps.setTimezone(zone),
36760
37874
  // Right-clicking activates the cell first (capture-phase pointerdown), so the
@@ -36806,20 +37920,30 @@ var ChartCell = class {
36806
37920
  this.refreshNativeCatalog();
36807
37921
  this.pushSettingsSections();
36808
37922
  this.offMarket = this.inner.on("market:changed", ({ symbol: symbol2, timeframe }) => {
36809
- this.state.symbol = symbol2;
36810
- this.state.provider = parseSymbol(symbol2).provider ?? void 0;
36811
- this.state.timeframe = timeframe;
36812
- this.state.session = normalizeSession(this.inner?.market.session);
36813
- this.watermark?.update(symbol2, timeframe);
36814
- this.statusline?.setSymbol(symbol2);
36815
- this.statusline?.setMeta(timeframe, this.inner?.data.displayPrefix(symbol2) ?? this.state.provider ?? "");
36816
- if (this.inner) this.statusline?.onChart(this.inner);
37923
+ this.projectMarket(symbol2, timeframe);
36817
37924
  this.refreshNativeCatalog();
36818
37925
  this.refreshSessionAvailable();
36819
37926
  if (this.inner) this.marketStatus?.track(this.inner.data, symbol2);
36820
37927
  this.deps.onMarketChanged(this.id);
36821
37928
  });
36822
37929
  }
37930
+ /**
37931
+ * Project a market identity into the cell state and its display overlays (watermark,
37932
+ * statusline), WITHOUT the data-dependent bookkeeping. Runs twice per user pick: once
37933
+ * optimistically from the cell setters — the labels reflect the pick immediately, not
37934
+ * after the bars load — and again from `market:changed` (the committed pass, and the
37935
+ * only pass for host `chart.setMarket` calls). Idempotent, so the double run converges.
37936
+ */
37937
+ projectMarket(symbol, timeframe) {
37938
+ this.state.symbol = symbol;
37939
+ this.state.provider = parseSymbol(symbol).provider ?? void 0;
37940
+ this.state.timeframe = timeframe;
37941
+ this.state.session = normalizeSession(this.inner?.market.session);
37942
+ this.watermark?.update(symbol, timeframe);
37943
+ this.statusline?.setSymbol(symbol);
37944
+ this.statusline?.setMeta(timeframe, this.inner?.data.displayPrefix(symbol) ?? this.state.provider ?? "");
37945
+ if (this.inner) this.statusline?.onChart(this.inner);
37946
+ }
36823
37947
  /**
36824
37948
  * Does this cell's market HAVE sessions (RTH/ETH meaningful)? Derived from the
36825
37949
  * symbol's own metadata (`syminfo.session !== '24x7'`), asynchronously — the
@@ -36865,7 +37989,7 @@ var ChartCell = class {
36865
37989
  const requestedSpan = Math.max(this.state.bars ?? 1e3, this.rangeBars) * timeframeMs(this.state.timeframe ?? "60");
36866
37990
  const fallbackSpan = Number.isFinite(requestedSpan) ? Math.max(3 * 864e5, requestedSpan) : 3 * 864e5;
36867
37991
  const range = chart.getVisibleRange() ?? { from: now - fallbackSpan, to: now };
36868
- this.sessionShading.track(chart.data, symbol, { session: this.session, range });
37992
+ this.sessionShading.track(chart.data, symbol, { session: this.session, timeframe: this.timeframe, range });
36869
37993
  }
36870
37994
  /** The session-shade colors live in the renderer CONFIG (persisted with it, edited
36871
37995
  * live by the dialog swatch) — the cell only proxies them into its settings rows. */
@@ -37054,17 +38178,23 @@ var ChartCell = class {
37054
38178
  get indicatorCount() {
37055
38179
  return this.instances.length + this.nativeCatalog.filter((n) => n.present).length;
37056
38180
  }
37057
- /** Switch this cell's market in place (the chart instance survives). */
38181
+ /** Switch this cell's market in place (the chart instance survives). The projection
38182
+ * is OPTIMISTIC — labels and chrome show the pick before the bars load; it follows
38183
+ * the setMarket call so the statusline reads the already-blanked chart. */
37058
38184
  setSymbol(symbol) {
37059
38185
  if (!this.inner || symbol === this.symbol) return;
37060
38186
  this.unresolvedToasted = null;
37061
38187
  void this.inner.setMarket({ symbol });
38188
+ this.projectMarket(symbol, this.timeframe);
38189
+ this.deps.onMarketChanged(this.id);
37062
38190
  }
37063
38191
  setTimeframe(timeframe) {
37064
38192
  if (!this.inner || timeframe === this.timeframe) return;
37065
38193
  this.activeRangeId = null;
37066
38194
  this.rangeBars = 0;
37067
38195
  void this.inner.setMarket({ timeframe, bars: this.state.bars });
38196
+ this.projectMarket(this.symbol, timeframe);
38197
+ this.deps.onMarketChanged(this.id);
37068
38198
  }
37069
38199
  /** Applied live (renderer feature) — no reload. */
37070
38200
  setPriceStyle(style) {
@@ -37112,6 +38242,20 @@ var ChartCell = class {
37112
38242
  this.inner.setVisibleRangePreset(preset.preset);
37113
38243
  }
37114
38244
  }
38245
+ /** Reset this cell's view: re-enable auto scale and frame the full history —
38246
+ * the same action the chart context menu offers. */
38247
+ resetView() {
38248
+ this.inner?.renderer.set("autoScale", true);
38249
+ this.inner?.setVisibleRangePreset("ALL");
38250
+ }
38251
+ /** Rebuild the view-controls cluster (the maximize gate or state changed). */
38252
+ refreshControls() {
38253
+ this.cellControls.refresh();
38254
+ }
38255
+ /** Mobile flips the per-cell cluster off (the shell's mobile bar replaces it). */
38256
+ setControlsSuspended(on) {
38257
+ this.cellControls.setSuspended(on);
38258
+ }
37115
38259
  /** Make this cell the active one and put keyboard focus on its chart surface. */
37116
38260
  focus() {
37117
38261
  this.deps.activate(this.id);
@@ -37425,6 +38569,7 @@ var ChartCell = class {
37425
38569
  destroy() {
37426
38570
  this.destroyed = true;
37427
38571
  this.offMarket();
38572
+ this.cellControls.destroy();
37428
38573
  this.contextMenu.destroy();
37429
38574
  this.history.destroy();
37430
38575
  this.marketStatus?.stop();
@@ -37723,10 +38868,10 @@ var SplitterLayer = class {
37723
38868
  var DEFAULT_TIMEFRAMES = ["1", "5", "15", "60", "240", "D", "W"];
37724
38869
  var GAP_PX = 2;
37725
38870
  var POOL_CAP = 16;
37726
- var TIME_AXIS_H3 = 22;
38871
+ var TIME_AXIS_H4 = 22;
37727
38872
  var ALERT_CAP = 50;
37728
- var STYLE_ID26 = "vela-workspace";
37729
- var CSS23 = `
38873
+ var STYLE_ID27 = "vela-workspace";
38874
+ var CSS24 = `
37730
38875
  .vela-workspace { position: relative; width: 100%; height: 100%; display: flex; flex-direction: column; background: var(--vela-bg); }
37731
38876
  .vela-ws-main { position: relative; display: flex; flex-direction: row; flex: 1 1 auto; min-height: 0; }
37732
38877
  .vela-ws-toolbar { position: relative; flex: none; }
@@ -37753,6 +38898,21 @@ var CSS23 = `
37753
38898
  /* Mobile: the docked drawing-toolbar column would eat a phone-width grid \u2014 the shell's
37754
38899
  drawings drawer + on-chart pill replace it (same policy as the widget's in-chart bar). */
37755
38900
  [data-layout='mobile'] .vela-ws-toolbar { display: none; }
38901
+ /* A maximized cell owns the whole grid: the splitter strips have no seams to grab and
38902
+ the active ring would just outline the only visible chart \u2014 both are noise here. */
38903
+ .vela-ws-grid[data-maximized='1'] .vela-ws-splitter { display: none; }
38904
+ .vela-ws-grid[data-maximized='1'] .vela-cell[data-active='1']::after { display: none; }
38905
+ /* Drop-target preview while a cell's drag handle is held: a dashed ring + the same
38906
+ soft wash the splitter hover uses, over the chart, inert to the pointer. */
38907
+ .vela-cell[data-drop-target='1']::before {
38908
+ content: '';
38909
+ position: absolute;
38910
+ inset: 0;
38911
+ border: 2px dashed var(--vela-fg-bright);
38912
+ background: var(--vela-separator-hover-band);
38913
+ pointer-events: none;
38914
+ z-index: 11;
38915
+ }
37756
38916
  `;
37757
38917
  registerIcon("layout", svg16('<rect x="1.5" y="1.5" width="13" height="13" rx="1.5"/><path d="M8 1.5v13M1.5 8h13"/>'));
37758
38918
  function declaredOrder(cells) {
@@ -37782,6 +38942,9 @@ var VelaWorkspace = class {
37782
38942
  * slots beyond the list get auto identities. Grows, never reorders. */
37783
38943
  this.order = [];
37784
38944
  this.activeId = null;
38945
+ /** The cell maximized over the whole grid (null = normal grid). TRANSIENT view
38946
+ * state — never persisted; any structural change (layout, applyState) restores. */
38947
+ this.maximizedId = null;
37785
38948
  this.cellBackend = "auto";
37786
38949
  this.destroyed = false;
37787
38950
  this.shortcutsHelp = null;
@@ -37886,7 +39049,7 @@ var VelaWorkspace = class {
37886
39049
  this.order = boot?.charts ? boot.charts.map((c) => c.id) : declaredOrder(opts.cells);
37887
39050
  const bootActive = boot?.activeCellId ?? null;
37888
39051
  const doc = hostEl.ownerDocument;
37889
- injectStyles(STYLE_ID26, CSS23, doc);
39052
+ injectStyles(STYLE_ID27, CSS24, doc);
37890
39053
  this.root = doc.createElement("div");
37891
39054
  this.root.className = "vela-workspace";
37892
39055
  ensureUIHost(this.root, resolveTheme(opts.theme));
@@ -37996,7 +39159,11 @@ var VelaWorkspace = class {
37996
39159
  if (attribution !== false) {
37997
39160
  const background = resolveTheme(opts.theme).background;
37998
39161
  const mark = typeof attribution === "string" && attribution.trim() ? createCustomMark(doc, attribution, background) : createAttributionMark(doc, background);
37999
- Object.assign(mark.style, { left: "12px", bottom: `${TIME_AXIS_H3 + 10}px`, zIndex: "11" });
39162
+ Object.assign(mark.style, {
39163
+ left: "calc(var(--vela-toolbar-gutter, 0px) + 12px)",
39164
+ bottom: `calc(var(--vela-bottom-gutter, ${TIME_AXIS_H4}px) + 10px)`,
39165
+ zIndex: "11"
39166
+ });
38000
39167
  this.gridEl.appendChild(mark);
38001
39168
  this.attributionMark = mark;
38002
39169
  }
@@ -38062,6 +39229,9 @@ var VelaWorkspace = class {
38062
39229
  ...topbarHas(this.topbarComp, "indicators") && (picker || this.indicatorsOverride) ? { onIndicatorsClick: this.indicatorsOverride ? () => this.runOverride(this.indicatorsOverride) : () => picker.open() } : {},
38063
39230
  getContext: () => this.context(),
38064
39231
  ...this.drawingsEnabled ? { onDrawingsClick: () => this.openDrawingsDrawer() } : {},
39232
+ // Multi-chart only: the stop that isolates the ACTIVE chart (the
39233
+ // per-cell hover cluster has no cursor to reveal it on mobile).
39234
+ ...this.monoLayout ? {} : { onMaximizeClick: () => this.toggleMobileMaximize() },
38065
39235
  onMoreClick: () => this.openMoreDrawer(),
38066
39236
  onSettingsClick: () => this.active.chart.renderer.openSettings()
38067
39237
  }) : null;
@@ -38176,7 +39346,10 @@ var VelaWorkspace = class {
38176
39346
  this.topbar.renderActions();
38177
39347
  this.mobileBar?.renderActions();
38178
39348
  this.dock.refresh();
38179
- for (const cell of this.cells()) cell.chart.renderer.setLegendActions(legendActionsProviderFor(cell.chart, () => this.context()));
39349
+ for (const cell of this.cells()) {
39350
+ cell.chart.renderer.setLegendActions(legendActionsProviderFor(cell.chart, () => this.context()));
39351
+ cell.chart.renderer.setLegendCallouts(legendCalloutsProviderFor(cell.chart, () => this.context()));
39352
+ }
38180
39353
  }
38181
39354
  // ── state surface (the SDK's read/restore of the whole grid's config + content) ──
38182
39355
  /**
@@ -38265,7 +39438,9 @@ var VelaWorkspace = class {
38265
39438
  this.pool.clear();
38266
39439
  for (const { id, ...cs } of st.charts.slice(liveCount)) this.pool.set(id, cs);
38267
39440
  this.order = st.charts.map((c) => c.id);
39441
+ this.clearMaximized();
38268
39442
  this.applyGrid();
39443
+ this.refreshCellControls();
38269
39444
  const nextActive2 = st.activeCellId && this.cellsById.has(st.activeCellId) ? st.activeCellId : this.order[0] ?? null;
38270
39445
  if (nextActive2 === this.activeId) this.projectActiveCell();
38271
39446
  else this.setActiveCell(nextActive2);
@@ -38291,6 +39466,7 @@ var VelaWorkspace = class {
38291
39466
  const def = this.monoLayout ? null : ensureLayout(st.layout);
38292
39467
  if (def) this.def = def;
38293
39468
  this.cellBackend = this.backendFor(this.def);
39469
+ this.clearMaximized();
38294
39470
  this.applyGrid();
38295
39471
  this.buildCells();
38296
39472
  this.syncCellPresentation();
@@ -38354,6 +39530,7 @@ var VelaWorkspace = class {
38354
39530
  setLayout(layout) {
38355
39531
  if (this.destroyed) return;
38356
39532
  if (this.monoLayout) return;
39533
+ this.clearMaximized();
38357
39534
  const next = this.resolveLayout(layout);
38358
39535
  const nextBackend = this.backendFor(next);
38359
39536
  const rebuildAll = nextBackend !== this.cellBackend;
@@ -38374,6 +39551,7 @@ var VelaWorkspace = class {
38374
39551
  this.buildCells();
38375
39552
  this.alignNewCellStyles(preexisting);
38376
39553
  this.syncCellPresentation();
39554
+ this.refreshCellControls();
38377
39555
  this.topbar.setLayout(next.id);
38378
39556
  const nextActive = activeAfterLayout(this.activeId, this.order.slice(0, next.cells.length));
38379
39557
  if (nextActive === this.activeId) this.projectActiveCell();
@@ -38382,6 +39560,67 @@ var VelaWorkspace = class {
38382
39560
  this.events.emit("layout:changed", { layout: next.id });
38383
39561
  this.markStateDirty();
38384
39562
  }
39563
+ /** The identity of the cell maximized over the whole grid, or null. */
39564
+ get maximizedCell() {
39565
+ return this.maximizedId;
39566
+ }
39567
+ /**
39568
+ * Maximize one cell over the whole grid, or restore the layout with `null`. Pure
39569
+ * presentation: the other cells stay alive underneath — charts, subscriptions and
39570
+ * state untouched — so restoring is instant. The maximized cell becomes the active
39571
+ * one. Transient view state (also reachable from each cell's bottom-center view
39572
+ * cluster): switching layouts or applying a state document restores the grid.
39573
+ */
39574
+ maximizeCell(id) {
39575
+ if (this.destroyed) return;
39576
+ if (id != null && (!this.cellsById.has(id) || this.def.cells.length <= 1)) return;
39577
+ if (id === this.maximizedId) return;
39578
+ this.maximizedId = id;
39579
+ if (id) this.setActiveCell(id);
39580
+ this.applyGrid();
39581
+ this.refreshCellControls();
39582
+ this.syncMobileMaximize();
39583
+ this.events.emit("cell:maximized", { id });
39584
+ }
39585
+ /** The mobile bar's maximize stop: one press isolates the ACTIVE chart over the
39586
+ * grid; while something is already isolated — the chart, or a pane inside it
39587
+ * (mobile's double-tap) — the press restores that instead. Every branch re-syncs
39588
+ * the stop on its own (`maximizeCell` directly, `panes.maximize` via its
39589
+ * synchronous `pane:changed`). */
39590
+ toggleMobileMaximize() {
39591
+ const cell = this.activeId ? this.cellsById.get(this.activeId) : void 0;
39592
+ if (!cell) return;
39593
+ if (this.maximizedId) this.maximizeCell(null);
39594
+ else if (cell.chart.panes.list().some((p) => p.maximized)) cell.chart.panes.maximize(null);
39595
+ else this.maximizeCell(cell.id);
39596
+ }
39597
+ /** Keep the mobile bar's maximize stop truthful: lit (inverse chip, restore
39598
+ * glyph) while the active chart covers the grid OR one of its panes is
39599
+ * maximized — the state a double-tap toggles is otherwise invisible on mobile. */
39600
+ syncMobileMaximize() {
39601
+ if (!this.mobileBar) return;
39602
+ const cell = this.activeId ? this.cellsById.get(this.activeId) : void 0;
39603
+ const paneMax = cell ? cell.chart.panes.list().some((p) => p.maximized) : false;
39604
+ this.mobileBar.setMaximizeActive(this.maximizedId != null || paneMax);
39605
+ }
39606
+ /**
39607
+ * Trade the SLOTS of two live cells — the grid arrangement changes, the cells
39608
+ * themselves (charts, indicators, drawings, the active flag) stay untouched.
39609
+ * What each cell's drag handle commits; also callable directly by hosts.
39610
+ */
39611
+ swapCells(a, b) {
39612
+ if (this.destroyed || a === b) return;
39613
+ const i = this.order.indexOf(a);
39614
+ const j = this.order.indexOf(b);
39615
+ if (i < 0 || j < 0 || !this.cellsById.has(a) || !this.cellsById.has(b)) return;
39616
+ [this.order[i], this.order[j]] = [this.order[j], this.order[i]];
39617
+ for (const [k] of this.def.cells.entries()) {
39618
+ const host = this.cellsById.get(this.order[k] ?? "")?.host;
39619
+ if (host) this.gridEl.appendChild(host);
39620
+ }
39621
+ this.applyGrid();
39622
+ this.markStateDirty();
39623
+ }
38385
39624
  resize() {
38386
39625
  this.splitters.layout();
38387
39626
  }
@@ -38451,6 +39690,7 @@ var VelaWorkspace = class {
38451
39690
  this.mobileBar?.renderActions();
38452
39691
  this.mobileBar?.setSymbol(cell.symbol);
38453
39692
  this.mobileBar?.setTimeframe(cell.timeframe);
39693
+ this.syncMobileMaximize();
38454
39694
  this.drawingPill?.onChart(cell.chart);
38455
39695
  const pushHistory = () => this.topbar.setHistoryState(cell.history.canUndo, cell.history.canRedo);
38456
39696
  this.historyUnsub?.();
@@ -38540,8 +39780,78 @@ var VelaWorkspace = class {
38540
39780
  const host = this.cellsById.get(this.order[i] ?? "")?.host;
38541
39781
  if (host) host.style.gridArea = perCell[slot.id]?.gridArea ?? "";
38542
39782
  }
39783
+ this.applyMaximizePresentation();
39784
+ this.mountAttributionMark();
38543
39785
  this.splitters.layout();
38544
39786
  }
39787
+ /** Overlay the maximize presentation on the freshly applied grid: EVERY cell spans
39788
+ * the full track grid — the maximized one on top, the siblings invisible beneath
39789
+ * it (their charts stay alive — restoring is instant). The siblings must span too:
39790
+ * left in their slots they would auto-flow into implicit zero-height rows, whose
39791
+ * gaps steal height from the maximized cell and collapse their renderers to 0.
39792
+ * The splitter strips and the active ring hide via the `data-maximized` rules. */
39793
+ applyMaximizePresentation() {
39794
+ const maxId = this.maximizedId;
39795
+ if (maxId) this.gridEl.dataset.maximized = "1";
39796
+ else delete this.gridEl.dataset.maximized;
39797
+ for (const [id, cell] of this.cellsById) {
39798
+ const style = cell.host.style;
39799
+ if (maxId) style.gridArea = "1 / 1 / -1 / -1";
39800
+ style.zIndex = maxId && id === maxId ? "5" : "";
39801
+ style.visibility = maxId && id !== maxId ? "hidden" : "";
39802
+ }
39803
+ }
39804
+ /** Rebuild every cell's view cluster (the maximize gate or state changed). */
39805
+ refreshCellControls() {
39806
+ for (const cell of this.cellsById.values()) cell.refreshControls();
39807
+ }
39808
+ /** Drop the transient maximize on a structural change (layout switch, state
39809
+ * document) — WITH the event, so hosts tracking `cell:maximized` never drift
39810
+ * from `maximizedCell`. The caller's own grid re-apply paints the restore. */
39811
+ clearMaximized() {
39812
+ if (this.maximizedId == null) return;
39813
+ this.maximizedId = null;
39814
+ this.events.emit("cell:maximized", { id: null });
39815
+ }
39816
+ /** The live cell under a viewport point, excluding `excludeId` and any host a
39817
+ * maximize has hidden — the drag handle's hit-test. */
39818
+ cellAtPoint(x, y, excludeId) {
39819
+ for (const [id, cell] of this.cellsById) {
39820
+ if (id === excludeId || cell.host.style.visibility === "hidden") continue;
39821
+ const r = cell.host.getBoundingClientRect();
39822
+ if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return id;
39823
+ }
39824
+ return null;
39825
+ }
39826
+ /** Mark one cell as the live drop target of a grip drag (null clears all) —
39827
+ * the `data-drop-target` stylesheet rule paints the dashed preview ring. */
39828
+ setDropTarget(id) {
39829
+ for (const [cid, cell] of this.cellsById) {
39830
+ if (cid === id) cell.host.dataset.dropTarget = "1";
39831
+ else delete cell.host.dataset.dropTarget;
39832
+ }
39833
+ }
39834
+ /** The cell whose bottom-left corner the grid's attribution mark floats in — the
39835
+ * maximized cell while one covers the grid, else the bottom-left slot's cell. */
39836
+ bottomLeftCell() {
39837
+ if (this.maximizedId) return this.cellsById.get(this.maximizedId);
39838
+ const grid = occupancyGrid(this.def);
39839
+ const slot = grid[grid.length - 1]?.[0];
39840
+ const idx = this.def.cells.findIndex((c) => (c.area ?? c.id) === slot);
39841
+ return this.cellsById.get(this.order[idx >= 0 ? idx : 0] ?? "");
39842
+ }
39843
+ /** Keep the shared attribution mark inside the BOTTOM-LEFT visible cell: its
39844
+ * offsets ride that cell's renderer-published `--vela-bottom-gutter` /
39845
+ * `--vela-toolbar-gutter`, so collapsed pane strips push the mark up without any
39846
+ * bookkeeping here. Re-run after anything that changes which host that is
39847
+ * (layout switch, maximize, cell rebuild); a destroyed host drops the mark from
39848
+ * the DOM, and this re-mount brings it back. */
39849
+ mountAttributionMark() {
39850
+ const mark = this.attributionMark;
39851
+ if (!mark) return;
39852
+ const host = this.bottomLeftCell()?.host ?? this.gridEl;
39853
+ if (mark.parentElement !== host) host.appendChild(mark);
39854
+ }
38545
39855
  /** Create the cells the current layout wants but don't exist yet (pool-first).
38546
39856
  * A slot's CELL IDENTITY is `order[i]` (declaration order — never the slot's own
38547
39857
  * positional id); slots past the declared list mint an auto identity once. */
@@ -38573,6 +39883,12 @@ var VelaWorkspace = class {
38573
39883
  setTimezone: (zone) => this.setTimezone(zone),
38574
39884
  context: () => this.context(),
38575
39885
  activate: (id2) => this.setActiveCell(id2),
39886
+ multiCell: () => !this.monoLayout && this.def.cells.length > 1,
39887
+ isMaximized: (id2) => this.maximizedId === id2,
39888
+ toggleMaximize: (id2) => this.maximizeCell(this.maximizedId === id2 ? null : id2),
39889
+ cellDragTarget: (id2, x, y) => this.cellAtPoint(x, y, id2),
39890
+ previewDropTarget: (target) => this.setDropTarget(target),
39891
+ dropCell: (id2, target) => this.swapCells(id2, target),
38576
39892
  onMarketChanged: (id2) => this.onCellMarketChanged(id2),
38577
39893
  onPriceStyleChanged: (id2) => this.onCellPriceStyleChanged(id2),
38578
39894
  onIndicatorsChanged: (id2) => this.onCellIndicatorsChanged(id2),
@@ -38586,6 +39902,7 @@ var VelaWorkspace = class {
38586
39902
  if (id === this.activeId) cell.host.dataset.active = "1";
38587
39903
  this.wireCell(cell);
38588
39904
  cell.chart.renderer.setLayoutMode(this.layoutCtl.current);
39905
+ cell.setControlsSuspended(this.layoutCtl.current === "mobile");
38589
39906
  if (this.favs.length > 0) cell.chart.drawings.setFavorites(this.favs);
38590
39907
  cell.setManifest(this.manifest, pooled?.indicators == null);
38591
39908
  cell.restorePersistedExt();
@@ -38595,6 +39912,7 @@ var VelaWorkspace = class {
38595
39912
  const host = this.cellsById.get(this.order[i] ?? "")?.host;
38596
39913
  if (host) this.gridEl.appendChild(host);
38597
39914
  }
39915
+ this.mountAttributionMark();
38598
39916
  }
38599
39917
  /** Per-cell chart subscriptions (trigger ② — the chart instance is stable for the
38600
39918
  * cell's whole life, so these live and die with the cell). */
@@ -38654,6 +39972,9 @@ var VelaWorkspace = class {
38654
39972
  chart.on("viewport:changed", (range) => this.propagateViewport(cell.id, range));
38655
39973
  chart.renderer.onConfigChanged(() => this.propagateStylePrefs(cell.id));
38656
39974
  chart.on("theme:changed", (t) => this.setTheme(t));
39975
+ chart.on("pane:changed", () => {
39976
+ if (cell.id === this.activeId) this.syncMobileMaximize();
39977
+ });
38657
39978
  chart.renderer.onAxisLongPress((e) => {
38658
39979
  if (this.layoutCtl.current !== "mobile") return;
38659
39980
  if (e.axis === "time") this.openTimezoneDrawer();
@@ -38974,6 +40295,7 @@ var VelaWorkspace = class {
38974
40295
  for (const cell of this.cellsById.values()) {
38975
40296
  cell.chart.renderer.closeDialogs();
38976
40297
  cell.chart.renderer.setLayoutMode(mode);
40298
+ cell.setControlsSuspended(mode === "mobile");
38977
40299
  }
38978
40300
  this.syncCellPresentation();
38979
40301
  }