@luxalgo/vela 0.6.4 → 0.6.6

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 (41) hide show
  1. package/README.md +16 -16
  2. package/dist/{DataProvider-DrN7ZIou.d.cts → DataProvider-DDUYw2qP.d.cts} +1 -1
  3. package/dist/{DataProvider-BmHkcwvJ.d.ts → DataProvider-JJq7M2it.d.ts} +1 -1
  4. package/dist/{history-CvOtsjT5.d.ts → bottombar-COnL2Sk4.d.ts} +110 -127
  5. package/dist/{history-BmyjaVir.d.cts → bottombar-DeDXbF_K.d.cts} +110 -127
  6. package/dist/{chunk-6FA4TDUF.js → chunk-JQFO2WMU.js} +2666 -2333
  7. package/dist/{chunk-BEJ57HP4.js → chunk-OEUGUXL7.js} +14 -1
  8. package/dist/{chunk-3QX6QOCX.js → chunk-TMXK2SJR.js} +5852 -3340
  9. package/dist/{contributions-2bEmCF9S.d.cts → contributions-BqCIsbaP.d.ts} +108 -4
  10. package/dist/{contributions-DatoAzMx.d.ts → contributions-DNMqrAuw.d.cts} +108 -4
  11. package/dist/index.cjs +2681 -2332
  12. package/dist/index.d.cts +65 -14
  13. package/dist/index.d.ts +65 -14
  14. package/dist/index.js +2 -2
  15. package/dist/{options-YdiaaVjt.d.cts → options-BaVTMXaO.d.cts} +48 -2
  16. package/dist/{options-YdiaaVjt.d.ts → options-BaVTMXaO.d.ts} +48 -2
  17. package/dist/{plugin-CkiStRaI.d.cts → plugin-CN8U2__Q.d.cts} +11 -5
  18. package/dist/{plugin-BeCMF1VQ.d.ts → plugin-WnvNNJOp.d.ts} +11 -5
  19. package/dist/plugin.cjs +16 -0
  20. package/dist/plugin.d.cts +4 -4
  21. package/dist/plugin.d.ts +4 -4
  22. package/dist/plugin.js +1 -1
  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/ui.d.cts +1 -1
  30. package/dist/ui.d.ts +1 -1
  31. package/dist/vela.global.js +2661 -2312
  32. package/dist/vela.global.min.js +48 -48
  33. package/dist/widget.cjs +35658 -34074
  34. package/dist/widget.d.cts +39 -271
  35. package/dist/widget.d.ts +39 -271
  36. package/dist/widget.js +60 -1313
  37. package/dist/workspace.cjs +1584 -960
  38. package/dist/workspace.d.cts +111 -15
  39. package/dist/workspace.d.ts +111 -15
  40. package/dist/workspace.js +6 -2281
  41. package/package.json +1 -1
package/dist/workspace.js CHANGED
@@ -1,2281 +1,6 @@
1
- import { WidgetHistory, SessionShadingTracker, prefixedSymbol, normalizeSession, Watermark, Statusline, MarketStatusTracker, ChartContextMenu, timeframeMs, statuslineInkOf, indicatorLedger, Glider, localStorageAdapter, decodeState, SymbolPicker, IndicatorPicker, TimeframeQuick, Topbar, PanelDock, ObjectTree, DataWindow, Toast, Bottombar, MobileBar, DrawingPill, LayoutModeController, toolShortcutHints, resolveIndicators, sanitizeState, encodeState, TimeframeDrawer, RANGE_PRESETS, DrawingsDrawer, MoreDrawer, priceStyleIcon, priceStyleLabel, TimezoneDrawer, PriceScaleDrawer, ZOOM_IN, ZOOM_OUT, PAN_FAST, ShortcutsHelp } from './chunk-3QX6QOCX.js';
2
- export { decodeState, encodeState, sanitizeState } from './chunk-3QX6QOCX.js';
3
- import { parseSymbol, Vela, normalizeTimezone, TypedEventBus, MultiProviderFeed, resolveTheme, createCustomMark, createAttributionMark, DrawingToolbar, defaultToolbar, applyAttributionMarkTheme, sharedBarStore, timeframeToMs, priceStyleIds } from './chunk-6FA4TDUF.js';
4
- import { resolveEngines, legendActionsProviderFor, rendererDefaults, widgetActions, widgetAttachments } from './chunk-BEJ57HP4.js';
5
- import { KeymapManager, isEditableTarget } from './chunk-2R7BANEM.js';
6
- import { applyPlotOverlayTokens, ensureUIHost, Menu } from './chunk-OICPLNU7.js';
7
- import { registerIcon, svg16, injectStyles } from './chunk-PN5KWFZ4.js';
8
-
9
- // src/workspace/sync.ts
10
- function syncTargets(originId, setting, cellIds) {
11
- if (setting == null || setting === false) return [];
12
- if (setting === true) return cellIds.filter((id) => id !== originId);
13
- const group = setting[originId];
14
- if (group == null) return [];
15
- return cellIds.filter((id) => id !== originId && setting[id] === group);
16
- }
17
- function rangesWithin(a, b, epsMs) {
18
- return Math.abs(a.from - b.from) <= epsMs && Math.abs(a.to - b.to) <= epsMs;
19
- }
20
-
21
- // src/workspace/persist.ts
22
- var memoryStore = /* @__PURE__ */ new Map();
23
- function memoryStorageAdapter() {
24
- return {
25
- get: (key) => memoryStore.get(key) ?? null,
26
- set: (key, value) => {
27
- memoryStore.set(key, value);
28
- },
29
- remove: (key) => {
30
- memoryStore.delete(key);
31
- }
32
- };
33
- }
34
-
35
- // src/workspace/ChartCell.ts
36
- function seedDefaults(opts) {
37
- return {
38
- symbol: opts.symbol,
39
- timeframe: opts.timeframe,
40
- bars: opts.bars,
41
- priceStyle: opts.priceStyle,
42
- session: opts.session,
43
- data: opts.data,
44
- visibleRange: opts.visibleRange
45
- };
46
- }
47
- function cellChartDefaults(opts) {
48
- const { renderer, defaultLanguage, currentPriceLine, logScale, animations, glow, upColor, downColor, drawings } = opts;
49
- return { renderer, defaultLanguage, currentPriceLine, logScale, animations, glow, upColor, downColor, drawings };
50
- }
51
- function cellDrawings(opt) {
52
- if (opt === false) return false;
53
- if (opt === true || opt == null) return { toolbar: false };
54
- return { ...opt, toolbar: false };
55
- }
56
- var ChartCell = class {
57
- constructor(id, gridHost, seed, deps) {
58
- this.id = id;
59
- this.deps = deps;
60
- /** This cell's unified app+drawings undo timeline (the shared Ctrl+Z routes here). */
61
- this.history = new WidgetHistory(() => this.inner);
62
- /** Live manifest-indicator instances on this cell (the SAME entry may repeat). */
63
- this.instances = [];
64
- /** The native-indicator catalog with this cell's live supported/present flags. */
65
- this.nativeCatalog = [];
66
- /** Last crosshair position in this cell (the alt+H/alt+V shortcuts anchor here). */
67
- this.lastCrossTime = null;
68
- this.lastCrossPrice = null;
69
- /** The bottombar range chip this cell is framed on (null = none). */
70
- this.activeRangeId = null;
71
- /** Latched verdict of {@link sessionAvailable} (async metadata, sticky per symbol). */
72
- this.sessionAvailableFlag = false;
73
- /** Keeps the pre/post-market shading on the symbol's real calendar (see {@link SessionShadingTracker}). */
74
- this.sessionShading = new SessionShadingTracker((zones) => this.inner?.renderer.set("sessionZones", zones));
75
- this.manifest = [];
76
- /** A restored ledger's manifest entry NAMES, waiting for the manifest to resolve
77
- * (a pool/persisted cell can be built before the shared manifest has loaded). */
78
- this.pendingManifestNames = null;
79
- /** The volume auto-add rides the cell's first candles (`load:end`); until then the
80
- * registry can't show it and the dehydrated ledger reports the INTENT instead. */
81
- this.volumeMayBePending = true;
82
- /** Sync mirror of the chart's present native types — the removal handler diffs
83
- * against it to identify (and record) whichever type was just removed. */
84
- this.presentNatives = [];
85
- this.rangeBars = 0;
86
- this.pendingRange = null;
87
- /** Indicator titles (this cell's in-chart legend rows) shown. */
88
- this.indicatorTitlesOn = true;
89
- /** Plot values beside this cell's legend titles shown. */
90
- this.indicatorValuesOn = true;
91
- this.destroyed = false;
92
- this.appTheme = deps.theme;
93
- const symbol = prefixedSymbol(seed);
94
- this.state = { symbol, provider: parseSymbol(symbol ?? "").provider ?? void 0, timeframe: seed.timeframe, priceStyle: seed.priceStyle, bars: seed.bars, session: normalizeSession(seed.session) };
95
- const doc = gridHost.ownerDocument;
96
- this.host = doc.createElement("div");
97
- this.host.className = "vela-cell";
98
- this.host.dataset.cellId = id;
99
- this.host.style.cssText = "position:relative;overflow:hidden;";
100
- this.host.addEventListener("pointerdown", () => this.deps.activate(id), true);
101
- this.host.addEventListener("focusin", () => this.deps.activate(id));
102
- gridHost.appendChild(this.host);
103
- this.inner = new Vela(
104
- this.host,
105
- {
106
- ...deps.chartDefaults,
107
- symbol,
108
- timeframe: seed.timeframe,
109
- bars: seed.bars,
110
- priceStyle: seed.priceStyle,
111
- session: normalizeSession(seed.session),
112
- data: seed.data,
113
- visibleRange: seed.visibleRange,
114
- theme: deps.theme,
115
- live: deps.live,
116
- // A RESTORED ledger is authoritative for the auto-added volume too: a
117
- // slot persisted without it must come back without it (fresh slots
118
- // keep the workspace default).
119
- volume: seed.indicators ? seed.indicators.natives.includes("volume") : deps.volume,
120
- nativeBackend: deps.nativeBackend,
121
- // The user's drawings option minus its toolbar: one SHARED bar serves
122
- // the whole workspace (per-cell bars would cost a 44px gutter each).
123
- drawings: cellDrawings(deps.chartDefaults.drawings)
124
- },
125
- { dataFeed: deps.feed }
126
- );
127
- for (const [language, make] of Object.entries(resolveEngines(deps.engines))) this.inner.registerEngine(language, make());
128
- this.inner.renderer.set("attribution", false);
129
- this.inner.renderer.set("dialogHost", deps.dialogHost);
130
- this.inner.renderer.setLegendActions(legendActionsProviderFor(this.inner, () => deps.context()));
131
- if (this.inner.renderer.supports("historyChords")) this.inner.renderer.set("historyChords", false);
132
- this.history.onChart(this.inner);
133
- this.inner.renderer.onConfigChanged(() => {
134
- const zone = this.inner?.renderer.get("timezone");
135
- if (typeof zone === "string" && normalizeTimezone(zone) !== normalizeTimezone(this.deps.timezone())) {
136
- this.deps.setTimezone(normalizeTimezone(zone));
137
- }
138
- this.syncStatuslineColors();
139
- this.syncPlotOverlayTokens();
140
- });
141
- this.inner.on("theme:changed", (t) => {
142
- this.appTheme = t;
143
- this.syncPlotOverlayTokens();
144
- });
145
- this.syncPlotOverlayTokens();
146
- if (seed.rendererConfig != null) this.inner.renderer.applyConfig(seed.rendererConfig);
147
- if (seed.drawings != null) this.inner.drawings.fromJSON(seed.drawings);
148
- if (seed.indicators) {
149
- for (const type of seed.indicators.natives) this.inner.addNativeIndicator(type);
150
- this.pendingManifestNames = [...seed.indicators.manifest];
151
- }
152
- this.volumeIntent = seed.indicators ? seed.indicators.natives.includes("volume") : deps.volume;
153
- this.inner.on("load:end", () => {
154
- this.volumeMayBePending = false;
155
- });
156
- this.inner.on("load:start", () => this.watermark?.setLoading(true));
157
- this.inner.on("load:end", () => this.watermark?.setLoading(false));
158
- const tz = deps.timezone();
159
- if (tz !== "Etc/UTC") this.inner.renderer.set("timezone", tz);
160
- this.indicatorTitlesOn = seed.indicatorTitles ?? true;
161
- if (!this.indicatorTitlesOn) this.inner.renderer.set("indicatorTitles", false);
162
- this.indicatorValuesOn = seed.indicatorValues ?? true;
163
- if (!this.indicatorValuesOn) this.inner.renderer.set("indicatorValues", false);
164
- this.watermarkOn = seed.watermark ?? deps.watermark;
165
- this.watermark = deps.watermark ? new Watermark(this.host, symbol ?? "", seed.timeframe ?? "60") : null;
166
- if (!this.watermarkOn) this.watermark?.setVisible(false);
167
- this.statusline = deps.statusline ? new Statusline(this.host, symbol ?? "") : null;
168
- this.statusline?.setMeta(seed.timeframe ?? "60", this.state.provider ?? "");
169
- this.statusline?.onChart(this.inner);
170
- this.marketStatus = this.statusline ? new MarketStatusTracker((s) => this.statusline?.setMarketStatus(s)) : null;
171
- void this.inner.data.ready().then(() => {
172
- if (this.inner && this.state.symbol) this.statusline?.setMeta(this.state.timeframe ?? "60", this.inner.data.displayPrefix(this.state.symbol) ?? this.state.provider ?? "");
173
- this.refreshSessionAvailable();
174
- if (this.inner && this.state.symbol) this.marketStatus?.track(this.inner.data, this.state.symbol);
175
- });
176
- this.syncStatuslineColors();
177
- this.contextMenu = new ChartContextMenu(this.host, {
178
- resetView: () => {
179
- this.inner?.renderer.set("autoScale", true);
180
- this.inner?.setVisibleRangePreset("ALL");
181
- },
182
- timezone: () => this.deps.timezone(),
183
- setTimezone: (zone) => this.deps.setTimezone(zone),
184
- // Right-clicking activates the cell first (capture-phase pointerdown), so the
185
- // context the actions receive is this cell's — the active one.
186
- getContext: () => this.deps.context()
187
- });
188
- this.contextMenu.onChart(this.inner);
189
- this.inner.renderer.onCrosshairMove((e) => {
190
- this.lastCrossTime = e.time;
191
- this.lastCrossPrice = e.price;
192
- });
193
- this.inner.on("indicator:added", () => {
194
- this.syncPresentNatives();
195
- this.refreshNativeCatalog();
196
- });
197
- this.inner.on("indicator:removed", ({ id: id2 }) => {
198
- if (this.destroyed) return;
199
- const idx = this.instances.findIndex((it) => it.handle?.id === id2);
200
- if (idx >= 0) {
201
- const snapshot = this.instances[idx];
202
- this.instances.splice(idx, 1);
203
- this.history.push({
204
- undo: () => {
205
- snapshot.handle = this.addToChart(snapshot.entry);
206
- this.instances.push(snapshot);
207
- this.deps.onIndicatorsChanged(this.id);
208
- },
209
- redo: () => this.dropInstance(snapshot)
210
- });
211
- } else {
212
- const now = this.inner?.presentNativeIndicators() ?? [];
213
- for (const type of this.presentNatives.filter((t) => !now.includes(t))) {
214
- this.history.push({
215
- undo: () => {
216
- this.inner?.addNativeIndicator(type);
217
- this.refreshNativeCatalog();
218
- },
219
- redo: () => {
220
- this.inner?.addNativeIndicator(type).remove();
221
- this.refreshNativeCatalog();
222
- }
223
- });
224
- }
225
- }
226
- this.syncPresentNatives();
227
- this.refreshNativeCatalog();
228
- });
229
- this.syncPresentNatives();
230
- this.refreshNativeCatalog();
231
- this.pushSettingsSections();
232
- this.offMarket = this.inner.on("market:changed", ({ symbol: symbol2, timeframe }) => {
233
- this.state.symbol = symbol2;
234
- this.state.provider = parseSymbol(symbol2).provider ?? void 0;
235
- this.state.timeframe = timeframe;
236
- this.state.session = normalizeSession(this.inner?.market.session);
237
- this.watermark?.update(symbol2, timeframe);
238
- this.statusline?.setSymbol(symbol2);
239
- this.statusline?.setMeta(timeframe, this.inner?.data.displayPrefix(symbol2) ?? this.state.provider ?? "");
240
- if (this.inner) this.statusline?.onChart(this.inner);
241
- this.refreshNativeCatalog();
242
- this.refreshSessionAvailable();
243
- if (this.inner) this.marketStatus?.track(this.inner.data, symbol2);
244
- this.deps.onMarketChanged(this.id);
245
- });
246
- }
247
- /**
248
- * Does this cell's market HAVE sessions (RTH/ETH meaningful)? Derived from the
249
- * symbol's own metadata (`syminfo.session !== '24x7'`), asynchronously — the
250
- * workspace re-projects the shared bottombar when the verdict lands or changes.
251
- */
252
- get sessionAvailable() {
253
- return this.sessionAvailableFlag;
254
- }
255
- /** This cell's shown session (`regular` when unset — the provider default). */
256
- get session() {
257
- return normalizeSession(this.state.session) ?? "regular";
258
- }
259
- /** Switch this cell's shown session in place (a reload — RTH and ETH are different bars). */
260
- setSession(session) {
261
- if (session === this.session) return;
262
- this.state.session = session;
263
- this.deps.onStateDirty();
264
- void this.inner?.setMarket({ session });
265
- }
266
- refreshSessionAvailable() {
267
- const chart = this.inner;
268
- const symbol = this.state.symbol;
269
- if (!chart || !symbol) return;
270
- void chart.data.symbolInfo(symbol).then((si) => {
271
- if (this.inner !== chart) return;
272
- const available = typeof si?.session === "string" && si.session !== "" && si.session !== "24x7";
273
- if (available !== this.sessionAvailableFlag) {
274
- this.sessionAvailableFlag = available;
275
- this.deps.onMarketChanged(this.id);
276
- this.pushSettingsSections();
277
- }
278
- this.refreshSessionShading();
279
- });
280
- }
281
- /** (Re)derive the pre/post-market shading bands for this cell's market. The loaded
282
- * depth (bars × timeframe) bounds the calendar fetch; the tracker clamps it. */
283
- refreshSessionShading() {
284
- const chart = this.inner;
285
- const symbol = this.state.symbol;
286
- if (!chart || !symbol) return;
287
- const lookbackMs = Math.max(this.state.bars ?? 1e3, this.rangeBars) * timeframeMs(this.state.timeframe ?? "60");
288
- this.sessionShading.track(chart.data, symbol, { session: this.session, lookbackMs });
289
- }
290
- /** The session-shade colors live in the renderer CONFIG (persisted with it, edited
291
- * live by the dialog swatch) — the cell only proxies them into its settings rows. */
292
- sessionShadeColor(key) {
293
- const cfg = this.inner?.renderer.getConfig();
294
- const v = cfg?.sessions?.[key];
295
- return typeof v === "string" ? v : "";
296
- }
297
- setSessionShadeColor(key, color) {
298
- this.inner?.renderer.applyConfig({ sessions: { [key]: color } });
299
- this.deps.onStateDirty();
300
- }
301
- /**
302
- * (Re)contribute this cell's settings-dialog sections: status line parts, the
303
- * per-cell fetch depth, the watermark toggle, and — only while the cell's symbol
304
- * HAS sessions — the Trading session group (RTH/ETH switch + the pre/post-market
305
- * shading colors) inside the Symbol tab. Bars/watermark/titles are persistable
306
- * cell state; a depth-only reload is silent, so mark dirty here. Re-run whenever
307
- * a gate changes (the dialog reads the sections on open).
308
- */
309
- pushSettingsSections() {
310
- const chart = this.inner;
311
- if (!chart) return;
312
- const rth = "Regular hours (RTH)";
313
- const eth = "Extended hours (ETH)";
314
- const sessionSection = {
315
- title: "Trading session",
316
- placement: "symbol",
317
- rows: [
318
- {
319
- kind: "select",
320
- label: "Session",
321
- options: [rth, eth],
322
- get: () => this.session === "extended" ? eth : rth,
323
- set: (v) => this.setSession(v === eth ? "extended" : "regular")
324
- },
325
- {
326
- kind: "color",
327
- label: "Pre-market",
328
- get: () => this.sessionShadeColor("premarketColor"),
329
- set: (v) => this.setSessionShadeColor("premarketColor", v)
330
- },
331
- {
332
- kind: "color",
333
- label: "Post-market",
334
- get: () => this.sessionShadeColor("postmarketColor"),
335
- set: (v) => this.setSessionShadeColor("postmarketColor", v)
336
- }
337
- ]
338
- };
339
- const advanced = {
340
- title: "Advanced",
341
- placement: "end",
342
- rows: [
343
- {
344
- kind: "select",
345
- label: "Bars to fetch",
346
- options: ["500", "1000", "2000", "5000", "10000", "20000"],
347
- get: () => String(this.state.bars ?? 1e3),
348
- set: (v) => {
349
- this.state.bars = Number(v);
350
- this.deps.onStateDirty();
351
- void this.inner?.setMarket({ bars: Math.max(this.state.bars, this.rangeBars) });
352
- }
353
- }
354
- ]
355
- };
356
- const watermarkSection = {
357
- title: "Watermark",
358
- placement: "symbol",
359
- rows: [
360
- {
361
- kind: "toggle",
362
- label: "Symbol watermark",
363
- get: () => this.watermarkOn,
364
- set: (v) => this.setWatermarkVisible(v)
365
- }
366
- ]
367
- };
368
- const sections = [];
369
- if (this.statusline) {
370
- const sl = this.statusline;
371
- sections.push({
372
- title: "Status line",
373
- rows: [
374
- { kind: "heading", label: "Status line" },
375
- { kind: "toggle", label: "Symbol name", get: () => sl.partVisible("name"), set: (v) => sl.setPartVisible("name", v) },
376
- { kind: "toggle", label: "Market status", get: () => sl.partVisible("market"), set: (v) => sl.setPartVisible("market", v) },
377
- { kind: "toggle", label: "OHLC values", get: () => sl.partVisible("ohlc"), set: (v) => sl.setPartVisible("ohlc", v) },
378
- { kind: "toggle", label: "Bar change values", get: () => sl.partVisible("change"), set: (v) => sl.setPartVisible("change", v) },
379
- { kind: "heading", label: "Indicators" },
380
- {
381
- kind: "toggle",
382
- label: "Titles",
383
- get: () => this.indicatorTitlesOn,
384
- set: (v) => this.setIndicatorTitlesVisible(v)
385
- },
386
- {
387
- kind: "toggle",
388
- label: "Values",
389
- get: () => this.indicatorValuesOn,
390
- set: (v) => this.setIndicatorValuesVisible(v)
391
- }
392
- ]
393
- });
394
- }
395
- sections.push(advanced);
396
- if (this.sessionAvailableFlag) sections.push(sessionSection);
397
- sections.push(watermarkSection);
398
- chart.renderer.setSettingsSections(sections);
399
- }
400
- /** Show/hide this cell's symbol watermark (persisted per cell). */
401
- setWatermarkVisible(visible) {
402
- this.watermarkOn = visible;
403
- this.watermark?.setVisible(visible);
404
- this.deps.onStateDirty();
405
- }
406
- /** Show/hide this cell's indicator titles — the in-chart legend rows (persisted per cell). */
407
- setIndicatorTitlesVisible(visible) {
408
- this.indicatorTitlesOn = visible;
409
- this.inner?.renderer.set("indicatorTitles", visible);
410
- this.deps.onStateDirty();
411
- }
412
- /** Show/hide the plot values beside this cell's legend titles (persisted per cell). */
413
- setIndicatorValuesVisible(visible) {
414
- this.indicatorValuesOn = visible;
415
- this.inner?.renderer.set("indicatorValues", visible);
416
- this.deps.onStateDirty();
417
- }
418
- /** The LIVE chart of this cell — never cache it across a layout change (the cell's
419
- * identity is what endures; the chart dies with the cell). */
420
- get chart() {
421
- if (!this.inner) throw new Error(`[vela] cell "${this.id}" is destroyed`);
422
- return this.inner;
423
- }
424
- get symbol() {
425
- return this.state.symbol ?? "";
426
- }
427
- get timeframe() {
428
- return this.state.timeframe ?? "60";
429
- }
430
- get priceStyle() {
431
- const live = this.inner?.renderer.get("priceStyle");
432
- return typeof live === "string" ? live : this.state.priceStyle ?? "candles";
433
- }
434
- /** Manifest instances + present natives — the topbar indicator count. */
435
- get indicatorCount() {
436
- return this.instances.length + this.nativeCatalog.filter((n) => n.present).length;
437
- }
438
- /** Switch this cell's market in place (the chart instance survives). */
439
- setSymbol(symbol) {
440
- if (!this.inner || symbol === this.symbol) return;
441
- void this.inner.setMarket({ symbol });
442
- }
443
- setTimeframe(timeframe) {
444
- if (!this.inner || timeframe === this.timeframe) return;
445
- this.activeRangeId = null;
446
- this.rangeBars = 0;
447
- void this.inner.setMarket({ timeframe, bars: this.state.bars });
448
- }
449
- /** Applied live (renderer feature) — no reload. */
450
- setPriceStyle(style) {
451
- this.state.priceStyle = style;
452
- this.inner?.renderer.set("priceStyle", style);
453
- this.syncStatuslineColors();
454
- this.deps.onPriceStyleChanged(this.id);
455
- }
456
- /** OHLC/change ink in the status line follows the ACTIVE price style's configured
457
- * colors and direction rule (candle bodies by close-vs-open, baseline by position
458
- * against the live baseline price, …) instead of the fixed theme tokens. */
459
- syncStatuslineColors() {
460
- if (!this.statusline || !this.inner) return;
461
- this.statusline.setDirectionColors(...statuslineInkOf(this.inner.renderer, this.priceStyle));
462
- }
463
- /** Multi-cell grids keep the status line on one row and hide what doesn't fit —
464
- * the workspace flips this with the layout (see Statusline.setFitMode). */
465
- setStatuslineFit(on) {
466
- this.statusline?.setFitMode(on);
467
- }
468
- /** The workspace shell keeps the app theme; the cell host's tokens re-derive from
469
- * the LIVE plot surface (see {@link applyPlotOverlayTokens}). */
470
- syncPlotOverlayTokens() {
471
- applyPlotOverlayTokens(this.host, this.appTheme, this.inner?.renderer.getConfig() ?? null);
472
- }
473
- /**
474
- * Frame a bottombar range chip: switch to its timeframe, fetch the depth its window
475
- * needs, and keep it framed (re-asserted once the deeper history is painted).
476
- */
477
- applyRange(preset) {
478
- if (!this.inner || this.destroyed) return;
479
- this.activeRangeId = preset.id;
480
- const tfChanged = preset.tf !== this.timeframe;
481
- const deeper = preset.bars > Math.max(this.state.bars ?? 500, this.rangeBars);
482
- this.rangeBars = preset.bars;
483
- if (tfChanged || deeper) {
484
- this.pendingRange = preset;
485
- void this.inner.setMarket({ timeframe: preset.tf, bars: Math.max(this.state.bars ?? 500, this.rangeBars), visibleRange: preset.preset }).then(() => {
486
- if (!this.destroyed && this.pendingRange === preset) {
487
- this.inner?.setVisibleRangePreset(preset.preset);
488
- this.pendingRange = null;
489
- }
490
- });
491
- } else {
492
- this.inner.setVisibleRangePreset(preset.preset);
493
- }
494
- }
495
- /** Make this cell the active one and put keyboard focus on its chart surface. */
496
- focus() {
497
- this.deps.activate(this.id);
498
- this.inner?.renderer.focus();
499
- }
500
- /** Download this cell's chart as a PNG (named after its market). */
501
- downloadScreenshot() {
502
- const url = this.inner?.renderer.screenshot();
503
- if (!url) return;
504
- const a = this.host.ownerDocument.createElement("a");
505
- a.href = url;
506
- a.download = `${this.symbol || "chart"}-${this.timeframe}.png`;
507
- a.click();
508
- }
509
- // ── indicator ledger (shared manifest, per-cell instances) ──
510
- /**
511
- * Hand the cell the workspace's resolved manifest. A RESTORED ledger (pool or
512
- * persisted state) re-adds its recorded entries by name — held until the manifest
513
- * actually carries them. Otherwise `seedEnabled` auto-adds the manifest's `enabled`
514
- * entries (fresh cells only).
515
- */
516
- setManifest(list, seedEnabled) {
517
- this.manifest = list;
518
- if (this.pendingManifestNames) {
519
- if (list.length === 0) return;
520
- for (const name of this.pendingManifestNames) {
521
- const entry = list.find((e) => e.name === name);
522
- if (entry) this.addManifestInstance(entry, { record: false });
523
- }
524
- this.pendingManifestNames = null;
525
- return;
526
- }
527
- if (seedEnabled) {
528
- for (const entry of list) if (entry.enabled) this.addManifestInstance(entry, { record: false });
529
- }
530
- }
531
- /** The picker's library rows: supported natives first, then the manifest. */
532
- libraryRows() {
533
- return [
534
- ...this.nativeCatalog.filter((n) => n.supported).map((n) => ({ name: n.title, category: "Vela", native: true, nativeType: n.type, beta: n.beta })),
535
- ...this.manifest.map((e) => ({ name: e.name, language: e.language, category: e.category }))
536
- ];
537
- }
538
- /** The picker's on-chart rows: present natives first, then live instances. */
539
- onChartRows() {
540
- return [
541
- ...this.nativeCatalog.filter((n) => n.present).map((n) => ({ name: n.title, native: true, nativeType: n.type })),
542
- ...this.instances.map((it) => ({ name: it.entry.name, language: it.entry.language }))
543
- ];
544
- }
545
- /** Add by picker LIBRARY index (natives precede the manifest — mirrors libraryRows). */
546
- addFromLibrary(index) {
547
- const natives = this.nativeCatalog.filter((n) => n.supported);
548
- if (index < natives.length) this.addNative(natives[index].type);
549
- else {
550
- const entry = this.manifest[index - natives.length];
551
- if (entry) this.addManifestInstance(entry);
552
- }
553
- }
554
- /** Remove by picker ON-CHART index (present natives precede instances). */
555
- removeFromChart(index) {
556
- const present = this.nativeCatalog.filter((n) => n.present);
557
- if (index < present.length) this.removeNative(present[index].type);
558
- else this.removeInstance(index - present.length);
559
- }
560
- /** Add ONE instance of a manifest entry (repeatable — duplicates are legitimate). */
561
- addManifestInstance(entry, opts = {}) {
562
- if (this.destroyed) return;
563
- const it = { entry, handle: this.addToChart(entry) };
564
- this.instances.push(it);
565
- this.deps.onIndicatorsChanged(this.id);
566
- if (opts.record === false) return;
567
- const snapshot = it;
568
- this.history.push({
569
- undo: () => this.dropInstance(snapshot),
570
- redo: () => {
571
- snapshot.handle = this.addToChart(snapshot.entry);
572
- this.instances.push(snapshot);
573
- this.deps.onIndicatorsChanged(this.id);
574
- }
575
- });
576
- }
577
- removeInstance(index) {
578
- const it = this.instances[index];
579
- if (!it || this.destroyed) return;
580
- this.dropInstance(it);
581
- const snapshot = it;
582
- this.history.push({
583
- undo: () => {
584
- snapshot.handle = this.addToChart(snapshot.entry);
585
- this.instances.push(snapshot);
586
- this.deps.onIndicatorsChanged(this.id);
587
- },
588
- redo: () => this.dropInstance(snapshot)
589
- });
590
- }
591
- dropInstance(it) {
592
- const idx = this.instances.indexOf(it);
593
- if (idx >= 0) this.instances.splice(idx, 1);
594
- try {
595
- it.handle?.remove();
596
- } catch {
597
- }
598
- it.handle = null;
599
- this.deps.onIndicatorsChanged(this.id);
600
- }
601
- /** Add a native indicator (single-instance per type — the core dedupes). */
602
- addNative(type) {
603
- this.inner?.addNativeIndicator(type);
604
- this.syncPresentNatives();
605
- this.refreshNativeCatalog();
606
- this.history.push({
607
- undo: () => {
608
- this.inner?.addNativeIndicator(type).remove();
609
- this.refreshNativeCatalog();
610
- },
611
- redo: () => {
612
- this.inner?.addNativeIndicator(type);
613
- this.refreshNativeCatalog();
614
- }
615
- });
616
- }
617
- removeNative(type) {
618
- this.inner?.addNativeIndicator(type).remove();
619
- this.refreshNativeCatalog();
620
- }
621
- /** Sync mirror of the chart's present native types — the removal handler diffs
622
- * against it to identify (and record) whichever type was just removed. */
623
- syncPresentNatives() {
624
- this.presentNatives = this.inner?.presentNativeIndicators() ?? [];
625
- }
626
- /** Refresh the native catalog (supported/present flags) for this cell's market. */
627
- refreshNativeCatalog() {
628
- const chart = this.inner;
629
- if (!chart) return;
630
- void chart.availableNativeIndicators().then((list) => {
631
- if (this.destroyed || this.inner !== chart) return;
632
- this.nativeCatalog = list.map((n) => ({ type: n.type, title: n.title, supported: n.supported, present: n.present, beta: n.beta }));
633
- this.deps.onIndicatorsChanged(this.id);
634
- });
635
- }
636
- addToChart(entry) {
637
- try {
638
- return this.inner?.addIndicator(entry.script, entry.language !== void 0 ? { language: entry.language } : void 0) ?? null;
639
- } catch (err) {
640
- console.warn(`[vela] indicator "${entry.name}" failed to add:`, err);
641
- return null;
642
- }
643
- }
644
- // ── lifecycle ──
645
- /** Snapshot everything the pool needs to restore this slot later. The market fields
646
- * come from the LIVE config (`chart.market`) — the requested identity — so a switch
647
- * still loading when the snapshot is taken (persist-on-close) is not lost. */
648
- dehydrate() {
649
- const live = this.inner?.market;
650
- return {
651
- ...this.state,
652
- ...live ? { symbol: live.symbol, provider: live.provider, timeframe: live.timeframe } : {},
653
- priceStyle: this.priceStyle,
654
- watermark: this.watermarkOn,
655
- indicatorTitles: this.indicatorTitlesOn,
656
- indicatorValues: this.indicatorValuesOn,
657
- rendererConfig: this.inner?.renderer.getConfig() ?? void 0,
658
- drawings: this.inner ? this.inner.drawings.toJSON() : void 0,
659
- // Natives from the chart's SYNC registry read — an async catalog mirror here
660
- // lost unload-time saves, and the old empty-set fallbacks resurrected removed
661
- // indicators. Manifest names fall back to the restored ledger only until the
662
- // shared manifest settles. See {@link indicatorLedger}.
663
- indicators: indicatorLedger({
664
- present: this.inner ? this.inner.presentNativeIndicators() : [],
665
- instanceNames: this.instances.map((it) => it.entry.name),
666
- pendingManifest: this.pendingManifestNames,
667
- manifestSettled: this.deps.manifestSettled(),
668
- volumePending: this.volumeMayBePending && this.volumeIntent
669
- })
670
- };
671
- }
672
- destroy() {
673
- this.destroyed = true;
674
- this.offMarket();
675
- this.contextMenu.destroy();
676
- this.history.destroy();
677
- this.marketStatus?.stop();
678
- this.sessionShading.stop();
679
- this.statusline?.destroy();
680
- this.watermark?.destroy();
681
- this.inner?.destroy();
682
- this.inner = null;
683
- this.host.remove();
684
- }
685
- };
686
-
687
- // src/workspace/context.ts
688
- function buildContext(host) {
689
- const active = host.active();
690
- return {
691
- get chart() {
692
- const cell = host.active();
693
- if (!cell) throw new Error("VelaWorkspace has no active cell yet");
694
- return cell.chart;
695
- },
696
- symbol: active?.symbol ?? "",
697
- timeframe: active?.timeframe ?? "60",
698
- priceStyle: active?.priceStyle ?? "candles",
699
- setSymbol: (symbol) => host.active()?.setSymbol(symbol),
700
- setTimeframe: (tf) => host.active()?.setTimeframe(tf),
701
- setPriceStyle: (style) => host.active()?.setPriceStyle(style),
702
- openSymbolSearch: (query) => host.openSymbolSearch(query),
703
- togglePanel: (id, open) => host.togglePanel(id, open),
704
- host: host.root,
705
- toast: (message, kind) => host.toast(message, kind),
706
- cells: host.cells().map((c) => ({ id: c.id, chart: c.chart, symbol: c.symbol, timeframe: c.timeframe })),
707
- activeCellId: active?.id ?? "",
708
- setActiveCell: (id) => host.setActiveCell(id)
709
- };
710
- }
711
-
712
- // src/workspace/layouts.ts
713
- var registry = /* @__PURE__ */ new Map();
714
- function registerLayout(def) {
715
- registry.set(def.id, def);
716
- }
717
- function unregisterLayout(id) {
718
- registry.delete(id);
719
- }
720
- function layoutDefinition(id) {
721
- return registry.get(id);
722
- }
723
- function layouts() {
724
- return [...registry.values()];
725
- }
726
- function slots(n) {
727
- return Array.from({ length: n }, (_, i) => ({ id: `c${i + 1}` }));
728
- }
729
- function registerBuiltinLayouts() {
730
- registerLayout({ id: "1", label: "Single", cols: [1], rows: [1], cells: slots(1) });
731
- registerLayout({ id: "2h", label: "2 side by side", cols: [1, 1], rows: [1], cells: slots(2) });
732
- registerLayout({ id: "2v", label: "2 stacked", cols: [1], rows: [1, 1], cells: slots(2) });
733
- registerLayout({ id: "4", label: "4 grid", cols: [1, 1], rows: [1, 1], cells: slots(4) });
734
- registerLayout({ id: "8", label: "8 grid", cols: [1, 1, 1, 1], rows: [1, 1], cells: slots(8) });
735
- }
736
- var GRID_PICKER_MAX = 4;
737
- var GRID_BUILTIN_IDS = {
738
- "1x1": "1",
739
- "1x2": "2h",
740
- "2x1": "2v",
741
- "2x2": "4",
742
- "2x4": "8"
743
- };
744
- var clampTrack = (n) => Math.max(1, Math.min(GRID_PICKER_MAX, Math.round(n)));
745
- function layoutForGrid(rows, cols) {
746
- const r = clampTrack(rows);
747
- const c = clampTrack(cols);
748
- const builtin = GRID_BUILTIN_IDS[`${r}x${c}`];
749
- const registered = builtin ? registry.get(builtin) : void 0;
750
- if (registered) return registered;
751
- return {
752
- id: `g${r}x${c}`,
753
- // Width-first label — matches the picker's caption ("3 × 2" = 3 wide, 2 tall).
754
- label: `${c} \xD7 ${r} grid`,
755
- cols: Array.from({ length: c }, () => 1),
756
- rows: Array.from({ length: r }, () => 1),
757
- cells: slots(r * c)
758
- };
759
- }
760
- var GRID_ID_RE = /^g([1-4])x([1-4])$/;
761
- function ensureLayout(id) {
762
- const registered = registry.get(id);
763
- if (registered) return registered;
764
- const g = GRID_ID_RE.exec(id);
765
- if (g) return layoutForGrid(Number(g[1]), Number(g[2]));
766
- return void 0;
767
- }
768
- function layoutShape(def) {
769
- if (!def.areas && def.rows.length <= GRID_PICKER_MAX && def.cols.length <= GRID_PICKER_MAX && def.cells.length === def.rows.length * def.cols.length) {
770
- return { rows: def.rows.length, cols: def.cols.length };
771
- }
772
- return null;
773
- }
774
- function occupancyGrid(def) {
775
- if (def.areas) return def.areas.map((row) => row.trim().split(/\s+/));
776
- const cols = def.cols.length;
777
- return def.rows.map((_, r) => def.cols.map((_2, c) => def.cells[r * cols + c]?.id ?? `\xB7${r}x${c}`));
778
- }
779
- function gridStyles(def, trackSizes) {
780
- const cols = trackSizes?.cols?.length === def.cols.length ? trackSizes.cols : def.cols;
781
- const rows = trackSizes?.rows?.length === def.rows.length ? trackSizes.rows : def.rows;
782
- const container = {
783
- display: "grid",
784
- gridTemplateColumns: cols.map((w) => `${w}fr`).join(" "),
785
- gridTemplateRows: rows.map((w) => `${w}fr`).join(" ")
786
- };
787
- if (def.areas) container.gridTemplateAreas = def.areas.map((r) => `"${r}"`).join(" ");
788
- const perCell = {};
789
- for (const cell of def.cells) {
790
- perCell[cell.id] = cell.area ? { gridArea: cell.area } : {};
791
- }
792
- return { container, perCell };
793
- }
794
- function activeAfterLayout(current, cellIds) {
795
- if (current != null && cellIds.includes(current)) return current;
796
- return cellIds[0] ?? null;
797
- }
798
- function orderAfterLayout(order, slots2, active) {
799
- const next = [...order];
800
- if (active == null || slots2 <= 0) return next;
801
- const idx = next.indexOf(active);
802
- if (idx < 0 || idx < slots2) return next;
803
- next.splice(idx, 1);
804
- next.splice(slots2 - 1, 0, active);
805
- return next;
806
- }
807
-
808
- // src/workspace/splitters.ts
809
- var MIN_TRACK_FRAC = 0.1;
810
- function evenTracks(n) {
811
- return Array.from({ length: n }, () => 1);
812
- }
813
- function resizeTracks(weights, index, deltaPx, sizePx) {
814
- const out = [...weights];
815
- const a = out[index];
816
- const b = out[index + 1];
817
- if (a === void 0 || b === void 0 || sizePx <= 0) return out;
818
- const total = out.reduce((s, w) => s + w, 0);
819
- const min = total * MIN_TRACK_FRAC;
820
- let deltaFr = deltaPx / sizePx * total;
821
- deltaFr = Math.max(deltaFr, min - a);
822
- deltaFr = Math.min(deltaFr, b - min);
823
- out[index] = a + deltaFr;
824
- out[index + 1] = b - deltaFr;
825
- return out;
826
- }
827
- function trackOffsets(weights, sizePx, gapPx) {
828
- const total = weights.reduce((s, w) => s + w, 0);
829
- if (total <= 0) return [];
830
- const content = sizePx - gapPx * (weights.length - 1);
831
- const out = [];
832
- let acc = 0;
833
- for (let i = 0; i < weights.length - 1; i += 1) {
834
- acc += weights[i] / total * content;
835
- out.push(acc + gapPx * i + gapPx / 2);
836
- }
837
- return out;
838
- }
839
- function seamSegments(grid, axis, index) {
840
- const count = axis === "cols" ? grid.length : grid[0]?.length ?? 0;
841
- const isSeam = (k) => axis === "cols" ? grid[k]?.[index] !== grid[k]?.[index + 1] : grid[index]?.[k] !== grid[index + 1]?.[k];
842
- const out = [];
843
- for (let k = 0; k < count; k += 1) {
844
- if (!isSeam(k)) continue;
845
- const last = out[out.length - 1];
846
- if (last && last[1] === k - 1) last[1] = k;
847
- else out.push([k, k]);
848
- }
849
- return out;
850
- }
851
- function segmentSpanPx(weights, sizePx, gapPx, from, to) {
852
- const total = weights.reduce((s, w) => s + w, 0);
853
- if (total <= 0) return { start: 0, end: 0 };
854
- const content = sizePx - gapPx * (weights.length - 1);
855
- const startOf = (k) => weights.slice(0, k).reduce((s, w) => s + w / total * content, 0) + gapPx * k;
856
- const start = from === 0 ? 0 : startOf(from) - gapPx / 2;
857
- const end = to === weights.length - 1 ? sizePx : startOf(to + 1) - gapPx / 2;
858
- return { start, end };
859
- }
860
- var HIT_PX = 8;
861
- var SplitterLayer = class {
862
- constructor(container, deps) {
863
- this.container = container;
864
- this.deps = deps;
865
- this.strips = [];
866
- this.drag = null;
867
- }
868
- /** Rebuild + reposition the strips for the current tracks/size/occupancy. */
869
- layout() {
870
- const { cols, rows } = this.deps.tracks();
871
- const grid = this.deps.grid();
872
- const rect = this.container.getBoundingClientRect();
873
- const gap = this.deps.gapPx();
874
- const placements = [];
875
- for (const [i, x] of trackOffsets(cols, rect.width, gap).entries()) {
876
- for (const [from, to] of seamSegments(grid, "cols", i)) {
877
- const { start, end } = segmentSpanPx(rows, rect.height, gap, from, to);
878
- placements.push({
879
- axis: "cols",
880
- index: i,
881
- css: `left:${Math.round(x - HIT_PX / 2)}px;top:${Math.round(start)}px;width:${HIT_PX}px;height:${Math.round(end - start)}px;cursor:col-resize;`
882
- });
883
- }
884
- }
885
- for (const [i, y] of trackOffsets(rows, rect.height, gap).entries()) {
886
- for (const [from, to] of seamSegments(grid, "rows", i)) {
887
- const { start, end } = segmentSpanPx(cols, rect.width, gap, from, to);
888
- placements.push({
889
- axis: "rows",
890
- index: i,
891
- css: `left:${Math.round(start)}px;top:${Math.round(y - HIT_PX / 2)}px;width:${Math.round(end - start)}px;height:${HIT_PX}px;cursor:row-resize;`
892
- });
893
- }
894
- }
895
- while (this.strips.length > placements.length) this.strips.pop().remove();
896
- while (this.strips.length < placements.length) this.strips.push(this.makeStrip());
897
- for (const [k, p] of placements.entries()) this.place(this.strips[k], p.axis, p.index, p.css);
898
- }
899
- destroy() {
900
- for (const s of this.strips.splice(0)) s.remove();
901
- this.drag = null;
902
- }
903
- makeStrip() {
904
- const el = document.createElement("div");
905
- el.className = "vela-ws-splitter";
906
- el.style.cssText = "position:absolute;z-index:30;";
907
- el.addEventListener("pointerdown", (e) => this.onDown(el, e));
908
- el.addEventListener("dblclick", () => {
909
- const axis = el.dataset.axis;
910
- if (axis) this.deps.reset(axis);
911
- });
912
- this.container.appendChild(el);
913
- return el;
914
- }
915
- place(el, axis, index, css) {
916
- el.dataset.axis = axis;
917
- el.dataset.index = String(index);
918
- el.style.cssText = `position:absolute;z-index:30;${css}`;
919
- }
920
- onDown(el, e) {
921
- const axis = el.dataset.axis;
922
- const index = Number(el.dataset.index);
923
- if (!axis || Number.isNaN(index)) return;
924
- e.preventDefault();
925
- const rect = this.container.getBoundingClientRect();
926
- const gap = this.deps.gapPx();
927
- const tracks = this.deps.tracks()[axis];
928
- this.drag = {
929
- axis,
930
- index,
931
- startPx: axis === "cols" ? e.clientX : e.clientY,
932
- startWeights: [...tracks],
933
- sizePx: (axis === "cols" ? rect.width : rect.height) - gap * (tracks.length - 1)
934
- };
935
- try {
936
- el.setPointerCapture(e.pointerId);
937
- } catch {
938
- }
939
- const move = (ev) => {
940
- const d = this.drag;
941
- if (!d) return;
942
- const delta = (d.axis === "cols" ? ev.clientX : ev.clientY) - d.startPx;
943
- this.deps.apply(d.axis, resizeTracks(d.startWeights, d.index, delta, d.sizePx));
944
- };
945
- const up = () => {
946
- this.drag = null;
947
- el.removeEventListener("pointermove", move);
948
- el.removeEventListener("pointerup", up);
949
- el.removeEventListener("pointercancel", up);
950
- };
951
- el.addEventListener("pointermove", move);
952
- el.addEventListener("pointerup", up);
953
- el.addEventListener("pointercancel", up);
954
- }
955
- };
956
-
957
- // src/workspace/VelaWorkspace.ts
958
- var DEFAULT_TIMEFRAMES = ["1", "5", "15", "60", "240", "D", "W"];
959
- var GAP_PX = 2;
960
- var POOL_CAP = 16;
961
- var TIME_AXIS_H = 22;
962
- var ALERT_CAP = 50;
963
- var STYLE_ID = "vela-workspace";
964
- var CSS = `
965
- .vela-workspace { position: relative; width: 100%; height: 100%; display: flex; flex-direction: column; background: var(--vela-bg); }
966
- .vela-ws-main { position: relative; display: flex; flex-direction: row; flex: 1 1 auto; min-height: 0; }
967
- .vela-ws-toolbar { position: relative; flex: none; }
968
- .vela-ws-grid { position: relative; flex: 1 1 auto; min-width: 0; display: grid; gap: ${GAP_PX}px; background: var(--vela-border-soft); }
969
- .vela-cell { background: var(--vela-bg); position: relative; }
970
- /* Active-cell highlight: an overlay ring ABOVE the chart's own canvas stack (a plain
971
- outline on the cell is painted under them) \u2014 inert to the pointer. Scoped to
972
- multi-cell grids ([data-multi]): a single-cell layout always has an active cell,
973
- and ringing the only chart would just be noise. */
974
- .vela-ws-grid[data-multi='1'] .vela-cell[data-active='1']::after {
975
- content: '';
976
- position: absolute;
977
- inset: 0;
978
- border: 2px solid var(--vela-fg-bright);
979
- pointer-events: none;
980
- z-index: 10;
981
- }
982
- /* Splitter hover mirrors the in-chart pane separator hover (CrosshairRenderer):
983
- a soft band over the whole grab target + a solid 2px line on the seam center. */
984
- .vela-ws-splitter:hover { background: var(--vela-separator-hover-band); }
985
- .vela-ws-splitter:hover::after { content: ''; position: absolute; background: var(--vela-separator-hover-line); }
986
- .vela-ws-splitter[data-axis='cols']:hover::after { left: calc(50% - 1px); top: 0; width: 2px; height: 100%; }
987
- .vela-ws-splitter[data-axis='rows']:hover::after { top: calc(50% - 1px); left: 0; height: 2px; width: 100%; }
988
- /* Mobile: the docked drawing-toolbar column would eat a phone-width grid \u2014 the shell's
989
- drawings drawer + on-chart pill replace it (same policy as the widget's in-chart bar). */
990
- [data-layout='mobile'] .vela-ws-toolbar { display: none; }
991
- `;
992
- registerIcon("layout", svg16('<rect x="1.5" y="1.5" width="13" height="13" rx="1.5"/><path d="M8 1.5v13M1.5 8h13"/>'));
993
- function declaredOrder(cells) {
994
- const names = Object.keys(cells ?? {});
995
- for (const n of names) {
996
- if (/^\d+$/.test(n)) {
997
- console.warn(`[vela] workspace cell "${n}" ignored \u2014 a purely-numeric name cannot keep its declaration order (JS object key semantics); use e.g. "cell${n}"`);
998
- }
999
- }
1000
- return names.filter((n) => !/^\d+$/.test(n));
1001
- }
1002
- function nextAutoCellId(taken) {
1003
- for (let i = 1; ; i += 1) {
1004
- if (!taken.has(`c${i}`)) return `c${i}`;
1005
- }
1006
- }
1007
- var VelaWorkspace = class {
1008
- constructor(container, opts = {}) {
1009
- this.events = new TypedEventBus();
1010
- this.feed = new MultiProviderFeed();
1011
- this.cellsById = /* @__PURE__ */ new Map();
1012
- this.pool = /* @__PURE__ */ new Map();
1013
- this.trackSizes = /* @__PURE__ */ new Map();
1014
- this.resizeObserver = null;
1015
- /** Cell identities by SLOT POSITION — `order[i]` lives in the layout's i-th slot.
1016
- * Names come from the `cells` declaration order (then the persisted document);
1017
- * slots beyond the list get auto identities. Grows, never reorders. */
1018
- this.order = [];
1019
- this.activeId = null;
1020
- this.cellBackend = "auto";
1021
- this.destroyed = false;
1022
- this.shortcutsHelp = null;
1023
- this.glider = new Glider(() => this.activeId ? this.cellsById.get(this.activeId)?.chart ?? null : null);
1024
- /** The GLOBAL armed tool/magnet/stay (workspace policy) — re-applied to whichever cell
1025
- * takes the focus; only the ACTIVE cell ever holds a non-null tool. Measure/eraser
1026
- * stay transient and per-cell: they exit when the focus leaves. */
1027
- this.globalTool = null;
1028
- this.globalSnap = "off";
1029
- this.globalStay = false;
1030
- /** Live subscription to the ACTIVE cell's unified history (rebound on every projection). */
1031
- this.historyUnsub = null;
1032
- /** Favorite drawing tools — a WORKSPACE preference (one star set, every cell). */
1033
- this.favs = [];
1034
- /** Favorite timeframes — the shared topbar's quick-switch chips, one set for the grid. */
1035
- this.tfFavs = [];
1036
- /** Live sync configuration (mutable copy of the option). */
1037
- this.syncOpts = {};
1038
- this.stateTimer = null;
1039
- this.onUnload = () => this.persistNow();
1040
- /** Re-entrance guard around one propagation tick: followers' synchronous echoes
1041
- * (their setVisibleRange re-emits viewport:changed) must not re-propagate. */
1042
- this.syncBusy = false;
1043
- /** Same guard for the drawings link: the propagated mutations' own `drawing:*`
1044
- * events fire synchronously inside the propagation loop and must not fan out again. */
1045
- this.drawingSyncBusy = false;
1046
- /** LINKED drawings (the drawings sync): one map per synced set (cellId → that
1047
- * cell's drawing id), reachable from every member under its `cellId\0drawingId`
1048
- * key — any member finds its peers to push edits/removals onto. Survives a
1049
- * toggle-off (propagation freezes while the setting is off; re-enabling resumes
1050
- * edit/delete for these pairs). Cleared on reload / `applyState`. */
1051
- this.drawingLinks = /* @__PURE__ */ new Map();
1052
- this.manifest = [];
1053
- /** The shared manifest can no longer change instance sets — resolved, or no
1054
- * `indicators` option so nothing ever will. Gates the cells' ledger fallback. */
1055
- this.manifestSettled = false;
1056
- this.openDialogs = 0;
1057
- this.alerts = [];
1058
- this.alertsMenu = null;
1059
- this.tfDrawer = null;
1060
- this.drawingsDrawer = null;
1061
- this.moreDrawer = null;
1062
- this.timezoneDrawer = null;
1063
- this.priceScaleDrawer = null;
1064
- /** Plot-local y of the last price-axis long-press (targets the pane under the finger). */
1065
- this.priceScalePressY = 0;
1066
- this.attachmentDisposers = /* @__PURE__ */ new Map();
1067
- /** The single grid-wide attribution mark — re-inked on a live theme swap. */
1068
- this.attributionMark = null;
1069
- this.onRootKeydown = (ev) => this.routeTyping(ev);
1070
- /** The sync-link control surface: `set(kind, true | {cellId: group} | false)`,
1071
- * `get(kind)`, `state()`. Enabling a market/viewport link aligns the followers to
1072
- * the ACTIVE cell once, so the grid starts coherent; `crosshair` mirrors the
1073
- * pointer time as ghost crosshairs on the followers (also a toggle in the layout
1074
- * dropdown). */
1075
- this.sync = {
1076
- set: (kind, setting) => this.applySyncSetting(kind, setting === false ? void 0 : setting, true),
1077
- get: (kind) => this.syncOpts[kind],
1078
- state: () => ({ ...this.syncOpts })
1079
- };
1080
- registerBuiltinLayouts();
1081
- const hostEl = typeof container === "string" ? document.querySelector(container) : container;
1082
- if (!hostEl) throw new Error(`VelaWorkspace: container not found: ${String(container)}`);
1083
- this.opts = opts;
1084
- this.persistKey = opts.persist === void 0 || opts.persist === false ? null : opts.persist === true ? "vela-workspace" : opts.persist;
1085
- this.storage = opts.storage ?? localStorageAdapter();
1086
- let boot = null;
1087
- if (this.persistKey !== null) {
1088
- const raw = this.storage.get(this.persistKey);
1089
- if (typeof raw === "string") boot = decodeState(raw);
1090
- else if (raw != null && typeof raw === "object") {
1091
- void raw.then((r) => {
1092
- if (!this.destroyed && r) this.applyState(decodeState(r));
1093
- });
1094
- }
1095
- if (typeof window !== "undefined") window.addEventListener("beforeunload", this.onUnload);
1096
- }
1097
- this.timezone = boot?.timezone ?? opts.timezone ?? "Etc/UTC";
1098
- if (boot?.favorites) this.favs = [...boot.favorites];
1099
- if (boot?.timeframeFavorites) this.tfFavs = [...boot.timeframeFavorites];
1100
- const sync = boot?.sync ?? opts.sync;
1101
- for (const kind of ["viewport", "symbol", "timeframe", "crosshair", "drawings"]) {
1102
- this.applySyncSetting(kind, sync?.[kind]);
1103
- }
1104
- this.def = this.resolveLayout(boot?.layout && ensureLayout(boot.layout) ? boot.layout : opts.layout ?? "4");
1105
- if (boot?.trackSizes) for (const [id, ts] of Object.entries(boot.trackSizes)) this.trackSizes.set(id, ts);
1106
- if (boot?.charts) for (const { id, ...cs } of boot.charts) this.pool.set(id, cs);
1107
- this.order = boot?.charts ? boot.charts.map((c) => c.id) : declaredOrder(opts.cells);
1108
- const bootActive = boot?.activeCellId ?? null;
1109
- const doc = hostEl.ownerDocument;
1110
- injectStyles(STYLE_ID, CSS, doc);
1111
- this.root = doc.createElement("div");
1112
- this.root.className = "vela-workspace";
1113
- ensureUIHost(this.root, resolveTheme(opts.theme));
1114
- for (const [name, make] of Object.entries(opts.providers ?? {})) void this.feed.registerProvider(name, make());
1115
- void this.feed.ready().then(() => {
1116
- if (!this.destroyed) this.refreshRetention();
1117
- });
1118
- this.symbolPicker = new SymbolPicker({
1119
- host: this.root,
1120
- onSelect: (ticker) => this.active.setSymbol(ticker),
1121
- onOpenChange: (open) => {
1122
- if (open) for (const cell of this.cells()) cell.chart.renderer.closeDialogs();
1123
- this.trackDialog(open);
1124
- }
1125
- });
1126
- this.symbolPicker.setSource(() => this.feed.symbols());
1127
- this.indicatorPicker = opts.indicatorPicker !== false ? new IndicatorPicker({
1128
- host: this.root,
1129
- library: () => this.active.libraryRows(),
1130
- onChart: () => this.active.onChartRows(),
1131
- onAdd: (i) => this.active.addFromLibrary(i),
1132
- onRemove: (i) => this.active.removeFromChart(i),
1133
- onOpenChange: (open) => {
1134
- if (open) for (const cell of this.cells()) cell.chart.renderer.closeDialogs();
1135
- this.trackDialog(open);
1136
- }
1137
- }) : null;
1138
- const picker = this.indicatorPicker;
1139
- this.tfQuick = new TimeframeQuick({
1140
- host: this.root,
1141
- onApply: (tf) => this.setActiveTimeframe(tf),
1142
- onOpenChange: (open) => this.trackDialog(open)
1143
- });
1144
- this.topbar = new Topbar(this.root, {
1145
- symbol: "",
1146
- onSymbolClick: () => this.symbolPicker.open(),
1147
- ...picker ? { onIndicatorsClick: () => picker.open() } : {},
1148
- onUndoClick: () => this.active.history.undo(),
1149
- onRedoClick: () => this.active.history.redo(),
1150
- onScreenshotClick: () => this.active.downloadScreenshot(),
1151
- onAlertsClick: (anchor) => this.openAlertsMenu(anchor),
1152
- timeframe: "60",
1153
- timeframes: opts.timeframes ?? DEFAULT_TIMEFRAMES,
1154
- timeframeFavorites: this.tfFavs,
1155
- priceStyle: "candles",
1156
- onTimeframe: (tf) => this.setActiveTimeframe(tf),
1157
- onTimeframeFavorite: (tf, on) => this.setTimeframeFavorite(tf, on),
1158
- onPriceStyle: (style) => this.active.setPriceStyle(style),
1159
- layout: {
1160
- current: this.def.id,
1161
- // The picker composes dynamic layouts on its grid canvas; registered
1162
- // presets the canvas cannot express (bespoke plugin areas) list as rows.
1163
- shape: () => layoutShape(this.def),
1164
- presets: () => layouts().filter((l) => layoutShape(l) === null).map((l) => ({ id: l.id, label: l.label })),
1165
- onSelectGrid: (rows, cols) => this.setLayout(layoutForGrid(rows, cols)),
1166
- onSelectPreset: (id) => this.setLayout(id),
1167
- // The SYNC switches reflect the simple all-cells form; flipping one
1168
- // OVERRIDES a host-set group record with plain on/off (groups stay an
1169
- // API-only shape).
1170
- syncs: () => [
1171
- { id: "symbol", label: "Symbol", checked: this.syncOpts.symbol === true },
1172
- { id: "timeframe", label: "Interval", checked: this.syncOpts.timeframe === true },
1173
- { id: "crosshair", label: "Crosshair", checked: this.syncOpts.crosshair === true }
1174
- ],
1175
- onToggleSync: (id) => {
1176
- const kind = id;
1177
- this.sync.set(kind, this.syncOpts[kind] ? false : true);
1178
- }
1179
- },
1180
- getContext: () => this.context()
1181
- });
1182
- const main = doc.createElement("div");
1183
- main.className = "vela-ws-main";
1184
- let toolbarHost = null;
1185
- if (opts.drawingToolbar !== false) {
1186
- toolbarHost = doc.createElement("div");
1187
- toolbarHost.className = "vela-ws-toolbar";
1188
- main.appendChild(toolbarHost);
1189
- }
1190
- this.gridEl = doc.createElement("div");
1191
- this.gridEl.className = "vela-ws-grid";
1192
- main.appendChild(this.gridEl);
1193
- this.dock = new PanelDock(main, {
1194
- chrome: this.topbar,
1195
- context: () => this.context(),
1196
- changed: () => this.markStateDirty()
1197
- });
1198
- this.objectTree = new ObjectTree(main);
1199
- this.dataWindow = new DataWindow(main);
1200
- this.dock.addBuiltIn({ id: "dataWindow", title: "Data window", icon: "datawindow", order: 10, panel: this.dataWindow, onChart: (c) => this.dataWindow.onChart(c) });
1201
- this.dock.addBuiltIn({ id: "objects", title: "Object tree", icon: "objects", order: 20, panel: this.objectTree, onChart: (c) => this.objectTree.onChart(c) });
1202
- this.dock.refresh();
1203
- this.root.appendChild(main);
1204
- this.toast = new Toast(this.gridEl);
1205
- const attribution = rendererDefaults().attribution;
1206
- if (attribution !== false) {
1207
- const background = resolveTheme(opts.theme).background;
1208
- const mark = typeof attribution === "string" && attribution.trim() ? createCustomMark(doc, attribution, background) : createAttributionMark(doc, background);
1209
- Object.assign(mark.style, { left: "12px", bottom: `${TIME_AXIS_H + 10}px`, zIndex: "11" });
1210
- this.gridEl.appendChild(mark);
1211
- this.attributionMark = mark;
1212
- }
1213
- this.drawToolbar = toolbarHost ? new DrawingToolbar(
1214
- toolbarHost,
1215
- resolveTheme(opts.theme),
1216
- (type) => {
1217
- this.active.chart.drawings.setTool(type);
1218
- this.refocusActive();
1219
- },
1220
- (mode) => {
1221
- this.active.chart.drawings.setSnapMode(mode);
1222
- this.refocusActive();
1223
- },
1224
- () => {
1225
- const d = this.active.chart.drawings;
1226
- d.setMode(d.getMode() === "measure" ? null : "measure");
1227
- this.refocusActive();
1228
- },
1229
- () => {
1230
- const d = this.active.chart.drawings;
1231
- d.setMode(d.getMode() === "eraser" ? null : "eraser");
1232
- this.refocusActive();
1233
- },
1234
- // No refocus on a star: the flyout stays open for more browsing.
1235
- (type, on) => this.active.chart.drawings.setFavorite(type, on),
1236
- (on) => {
1237
- this.active.chart.drawings.setStayMode(on);
1238
- this.refocusActive();
1239
- },
1240
- {
1241
- dock: "static",
1242
- // Drawings sync is a WORKSPACE link (same model as the layout
1243
- // dropdown's switches) — the bar only hosts its toggle.
1244
- onDrawingsSync: (on) => {
1245
- this.sync.set("drawings", on);
1246
- this.refocusActive();
1247
- }
1248
- }
1249
- ) : null;
1250
- this.drawToolbar?.setDefinition(defaultToolbar());
1251
- this.drawToolbar?.setVisible(true);
1252
- this.drawToolbar?.setDrawingsSyncMode(!!this.syncOpts.drawings);
1253
- this.bottombar = opts.bottombar !== false ? new Bottombar(this.root, {
1254
- timezone: this.timezone,
1255
- onRange: (preset) => {
1256
- this.active.applyRange(preset);
1257
- this.bottombar?.setActiveRange(preset.id);
1258
- },
1259
- onTimezone: (zone) => this.setTimezone(zone),
1260
- // RTH/ETH acts on the ACTIVE cell (like the range chips): sessions
1261
- // are a per-chart market dimension, not a shell preference.
1262
- onSession: (session) => this.active.setSession(session),
1263
- onSettingsClick: () => this.active.chart.renderer.openSettings()
1264
- }) : null;
1265
- this.mobileBar = opts.bottombar !== false ? new MobileBar(this.root, {
1266
- symbol: "",
1267
- timeframe: "60",
1268
- onSymbolClick: () => this.symbolPicker.open(),
1269
- onTimeframeClick: () => this.openTimeframeDrawer(),
1270
- ...picker ? { onIndicatorsClick: () => picker.open() } : {},
1271
- getContext: () => this.context(),
1272
- onDrawingsClick: () => this.openDrawingsDrawer(),
1273
- onMoreClick: () => this.openMoreDrawer(),
1274
- onSettingsClick: () => this.active.chart.renderer.openSettings()
1275
- }) : null;
1276
- this.drawingPill = new DrawingPill(this.gridEl);
1277
- hostEl.appendChild(this.root);
1278
- this.layoutCtl = new LayoutModeController(this.root, opts.layoutMode ?? "auto");
1279
- this.layoutCtl.onChange((mode) => this.onLayoutModeChange(mode));
1280
- this.splitters = new SplitterLayer(this.gridEl, {
1281
- tracks: () => this.currentTracks(),
1282
- grid: () => occupancyGrid(this.def),
1283
- apply: (axis, weights) => this.applyTracks(axis, weights),
1284
- reset: (axis) => this.applyTracks(axis, evenTracks(this.currentTracks()[axis].length)),
1285
- gapPx: () => GAP_PX
1286
- });
1287
- if (typeof ResizeObserver !== "undefined") {
1288
- this.resizeObserver = new ResizeObserver(() => this.splitters.layout());
1289
- this.resizeObserver.observe(this.gridEl);
1290
- }
1291
- this.keymap = new KeymapManager();
1292
- this.keymap.attach(this.root);
1293
- this.registerDefaultKeys();
1294
- this.drawToolbar?.setShortcuts(toolShortcutHints(this.keymap));
1295
- this.root.addEventListener("keydown", this.onRootKeydown);
1296
- this.root.tabIndex = -1;
1297
- this.cellBackend = this.backendFor(this.def);
1298
- this.applyGrid();
1299
- this.buildCells();
1300
- this.syncCellPresentation();
1301
- this.setActiveCell(bootActive != null && this.cellsById.has(bootActive) ? bootActive : this.order[0] ?? null);
1302
- if (opts.autofocus) this.refocusActive();
1303
- if (opts.indicators !== void 0) {
1304
- void resolveIndicators(opts.indicators).then((list) => {
1305
- if (this.destroyed) return;
1306
- this.manifest = list;
1307
- this.manifestSettled = true;
1308
- for (const cell of this.cellsById.values()) cell.setManifest(list, true);
1309
- this.projectActiveCell();
1310
- });
1311
- } else {
1312
- this.manifestSettled = true;
1313
- }
1314
- this.mountAttachments();
1315
- }
1316
- // ── access ──────────────────────────────────────────────────
1317
- /** The cell with identity `id` (its declared name, or `c<N>` when undeclared), or
1318
- * undefined when no live cell holds it. */
1319
- cell(id) {
1320
- return this.cellsById.get(id);
1321
- }
1322
- /** Every live cell, in slot order. Enumerated over `order` — the IDENTITY space —
1323
- * never the layout's positional slot ids, which only coincide with it for a
1324
- * workspace whose cells are undeclared. Identities past the current layout size are
1325
- * pooled, not live, so they drop out here. */
1326
- cells() {
1327
- return this.order.map((id) => this.cellsById.get(id)).filter((c) => c != null);
1328
- }
1329
- /** The ACTIVE cell — the one the shared chrome reflects and acts on. */
1330
- get active() {
1331
- const cell = this.activeId ? this.cellsById.get(this.activeId) : void 0;
1332
- if (!cell) throw new Error("VelaWorkspace has no active cell (destroyed?)");
1333
- return cell;
1334
- }
1335
- /** Shortcut for `active.chart` — the same habit as `widget.chart`. LIVE: read it at
1336
- * the point of use; the durable identity to hold is the cell (or its id). */
1337
- get chart() {
1338
- return this.active.chart;
1339
- }
1340
- setActiveCell(id) {
1341
- if (id === this.activeId || this.destroyed) return;
1342
- const prev = this.activeId;
1343
- if (prev) {
1344
- const el = this.cellsById.get(prev)?.host;
1345
- if (el) delete el.dataset.active;
1346
- }
1347
- this.activeId = id;
1348
- const prevCell = prev ? this.cellsById.get(prev) : void 0;
1349
- if (prevCell) {
1350
- prevCell.chart.drawings.setTool(null);
1351
- prevCell.chart.drawings.setMode(null);
1352
- }
1353
- if (id) {
1354
- const el = this.cellsById.get(id)?.host;
1355
- if (el) el.dataset.active = "1";
1356
- }
1357
- if (id) {
1358
- this.projectActiveCell();
1359
- this.events.emit("cell:active", { id, prev });
1360
- this.markStateDirty();
1361
- }
1362
- }
1363
- on(event, handler) {
1364
- return this.events.on(event, handler);
1365
- }
1366
- /** The context handed to contributed actions/attachments (rebuilt per invocation). */
1367
- context() {
1368
- return buildContext({
1369
- // Null during early construction (the topbar projects actions before cells exist).
1370
- active: () => this.activeId ? this.cellsById.get(this.activeId) ?? null : null,
1371
- cells: () => this.cells(),
1372
- setActiveCell: (id) => this.setActiveCell(id),
1373
- openSymbolSearch: (query) => this.symbolPicker.open(query ?? ""),
1374
- togglePanel: (id, open) => this.dock.toggle(id, open),
1375
- root: this.root,
1376
- toast: (message, kind) => this.toast.show(message, kind)
1377
- });
1378
- }
1379
- /** Re-project contributed topbar actions + side panels, and mount late-registered attachments. */
1380
- refreshActions() {
1381
- this.mountAttachments();
1382
- this.topbar.renderActions();
1383
- this.mobileBar?.renderActions();
1384
- this.dock.refresh();
1385
- for (const cell of this.cells()) cell.chart.renderer.setLegendActions(legendActionsProviderFor(cell.chart, () => this.context()));
1386
- }
1387
- // ── state surface (the SDK's read/restore of the whole grid's config + content) ──
1388
- /**
1389
- * Snapshot the COMPLETE workspace state as a versioned, serializable document:
1390
- * layout + splitter sizes, active cell, sync links, timezone, and — per slot, live
1391
- * AND dormant — the market, the renderer's cosmetic config, the user-drawings
1392
- * document, and the indicator ledger. This is what `persist` writes; hosts build
1393
- * custom flows on it (server snapshots, share links, templates).
1394
- */
1395
- getState() {
1396
- const byId = /* @__PURE__ */ new Map();
1397
- for (const [id, cs] of this.pool) byId.set(id, cs);
1398
- for (const [id, cell] of this.cellsById) byId.set(id, cell.dehydrate());
1399
- const charts = [];
1400
- for (const id of this.order) {
1401
- const cs = byId.get(id);
1402
- if (cs) {
1403
- charts.push({ id, ...cs });
1404
- byId.delete(id);
1405
- }
1406
- }
1407
- for (const [id, cs] of byId) charts.push({ id, ...cs });
1408
- const state = { version: 1, layout: this.def.id, timezone: this.timezone, sync: { ...this.syncOpts }, charts };
1409
- if (this.activeId) state.activeCellId = this.activeId;
1410
- if (this.favs.length > 0) state.favorites = [...this.favs];
1411
- if (this.tfFavs.length > 0) state.timeframeFavorites = [...this.tfFavs];
1412
- if (this.trackSizes.size > 0) state.trackSizes = Object.fromEntries([...this.trackSizes].map(([k, v]) => [k, { ...v }]));
1413
- const panels = this.dock.getState();
1414
- if (panels) state.panels = panels;
1415
- return state;
1416
- }
1417
- /**
1418
- * Restore a state document produced by {@link getState} (untrusted-safe: malformed
1419
- * fields are dropped). Replaces the WHOLE workspace state: prefs, sync links,
1420
- * layout, and every slot — current cells are rebuilt from the document. A layout id
1421
- * that is not registered keeps the current grid (register custom layouts first).
1422
- */
1423
- applyState(state) {
1424
- if (this.destroyed) return;
1425
- const st = sanitizeState(state);
1426
- if (!st) return;
1427
- if (st.timezone) {
1428
- this.timezone = st.timezone;
1429
- this.bottombar?.setTimezone(st.timezone);
1430
- }
1431
- if (st.favorites) this.favs = [...st.favorites];
1432
- if (st.timeframeFavorites) {
1433
- this.tfFavs = [...st.timeframeFavorites];
1434
- this.topbar.setTimeframeFavorites(this.tfFavs);
1435
- }
1436
- this.dock.applyState(st.panels);
1437
- for (const kind of ["viewport", "symbol", "timeframe", "crosshair", "drawings"]) this.applySyncSetting(kind, st.sync?.[kind]);
1438
- this.trackSizes.clear();
1439
- if (st.trackSizes) for (const [id, ts] of Object.entries(st.trackSizes)) this.trackSizes.set(id, ts);
1440
- for (const [id, cell] of [...this.cellsById]) {
1441
- cell.destroy();
1442
- this.cellsById.delete(id);
1443
- this.events.emit("cell:destroyed", { id });
1444
- }
1445
- this.pool.clear();
1446
- this.drawingLinks.clear();
1447
- for (const { id, ...cs } of st.charts) this.pool.set(id, cs);
1448
- this.order = st.charts.map((c) => c.id);
1449
- const def = ensureLayout(st.layout);
1450
- if (def) this.def = def;
1451
- this.cellBackend = this.backendFor(this.def);
1452
- this.applyGrid();
1453
- this.buildCells();
1454
- this.syncCellPresentation();
1455
- this.topbar.setLayout(this.def.id);
1456
- const nextActive = st.activeCellId && this.cellsById.has(st.activeCellId) ? st.activeCellId : this.order[0] ?? null;
1457
- if (nextActive === this.activeId) this.projectActiveCell();
1458
- else this.setActiveCell(nextActive);
1459
- this.refreshRetention();
1460
- this.events.emit("layout:changed", { layout: this.def.id });
1461
- this.markStateDirty();
1462
- }
1463
- /** Set the workspace-global display timezone — applied to EVERY cell. */
1464
- setTimezone(zone) {
1465
- this.timezone = zone;
1466
- this.bottombar?.setTimezone(zone);
1467
- for (const cell of this.cellsById.values()) cell.chart.renderer.set("timezone", zone);
1468
- this.markStateDirty();
1469
- }
1470
- /**
1471
- * Swap the workspace theme at runtime — `'dark'`, `'light'`, or a full custom theme,
1472
- * applied to the shared chrome (topbar, panels, drawing toolbar) and EVERY cell.
1473
- * Also reached from any cell's chart settings → Canvas → Theme. The choice sticks:
1474
- * cells rebuilt by later layout switches reconstruct with it.
1475
- */
1476
- setTheme(theme) {
1477
- if (this.destroyed) return;
1478
- const t = resolveTheme(theme);
1479
- this.opts.theme = theme;
1480
- ensureUIHost(this.root, t);
1481
- this.drawToolbar?.setTheme(t);
1482
- if (this.attributionMark) applyAttributionMarkTheme(this.attributionMark, t.background);
1483
- for (const cell of this.cellsById.values()) cell.chart.setTheme(t);
1484
- }
1485
- // ── layout ──────────────────────────────────────────────────
1486
- get layout() {
1487
- return this.def;
1488
- }
1489
- /**
1490
- * Switch the grid. Cells are diffed BY IDENTITY (`order` head of the next size):
1491
- * surviving cells keep their live charts untouched; cells past the new size
1492
- * dehydrate into the pool; (re)appearing positions hydrate their identity from
1493
- * the pool (or its seed). Crossing the WebGL budget rebuilds every cell through
1494
- * the pool so the backend stays uniform.
1495
- */
1496
- setLayout(layout) {
1497
- if (this.destroyed) return;
1498
- const next = this.resolveLayout(layout);
1499
- const nextBackend = this.backendFor(next);
1500
- const rebuildAll = nextBackend !== this.cellBackend;
1501
- this.order = orderAfterLayout(this.order, next.cells.length, this.activeId);
1502
- const keep = new Set(this.order.slice(0, next.cells.length));
1503
- for (const [id, cell] of [...this.cellsById]) {
1504
- if (!keep.has(id) || rebuildAll) {
1505
- this.poolSet(id, cell.dehydrate());
1506
- cell.destroy();
1507
- this.cellsById.delete(id);
1508
- this.events.emit("cell:destroyed", { id });
1509
- }
1510
- }
1511
- this.def = next;
1512
- this.cellBackend = nextBackend;
1513
- this.applyGrid();
1514
- this.buildCells();
1515
- this.syncCellPresentation();
1516
- this.topbar.setLayout(next.id);
1517
- const nextActive = activeAfterLayout(this.activeId, this.order.slice(0, next.cells.length));
1518
- if (nextActive === this.activeId) this.projectActiveCell();
1519
- else this.setActiveCell(nextActive);
1520
- this.refreshRetention();
1521
- this.events.emit("layout:changed", { layout: next.id });
1522
- this.markStateDirty();
1523
- }
1524
- resize() {
1525
- this.splitters.layout();
1526
- }
1527
- destroy() {
1528
- if (this.destroyed) return;
1529
- this.persistNow();
1530
- this.destroyed = true;
1531
- if (this.stateTimer != null) clearTimeout(this.stateTimer);
1532
- if (this.persistKey !== null && typeof window !== "undefined") window.removeEventListener("beforeunload", this.onUnload);
1533
- this.resizeObserver?.disconnect();
1534
- this.splitters.destroy();
1535
- for (const [id, cell] of [...this.cellsById]) {
1536
- cell.destroy();
1537
- this.cellsById.delete(id);
1538
- }
1539
- for (const dispose of this.attachmentDisposers.values()) {
1540
- try {
1541
- dispose();
1542
- } catch {
1543
- }
1544
- }
1545
- this.attachmentDisposers.clear();
1546
- this.root.removeEventListener("keydown", this.onRootKeydown);
1547
- this.keymap.destroy();
1548
- this.drawToolbar?.destroy();
1549
- this.topbar.destroy();
1550
- this.bottombar?.destroy();
1551
- this.mobileBar?.destroy();
1552
- this.drawingPill.destroy();
1553
- this.tfDrawer?.destroy();
1554
- this.drawingsDrawer?.destroy();
1555
- this.moreDrawer?.destroy();
1556
- this.timezoneDrawer?.destroy();
1557
- this.priceScaleDrawer?.destroy();
1558
- this.layoutCtl.destroy();
1559
- this.dock.destroy();
1560
- this.objectTree.destroy();
1561
- this.dataWindow.destroy();
1562
- this.symbolPicker.destroy();
1563
- this.indicatorPicker?.destroy();
1564
- this.tfQuick.destroy();
1565
- this.shortcutsHelp?.destroy();
1566
- this.toast.destroy();
1567
- this.alertsMenu?.destroy();
1568
- this.glider.stop();
1569
- sharedBarStore.retain(/* @__PURE__ */ new Set());
1570
- this.root.remove();
1571
- this.events.clear();
1572
- }
1573
- // ── the projection rule (trigger ① — full rebind on activation) ──
1574
- /** Re-project the shared chrome from the ACTIVE cell. The chrome holds no state of
1575
- * its own — this is a pure read of the cell, safe to call redundantly. */
1576
- projectActiveCell() {
1577
- const cell = this.activeId ? this.cellsById.get(this.activeId) : void 0;
1578
- if (!cell) return;
1579
- this.topbar.setSymbol(cell.symbol);
1580
- this.topbar.setTimeframe(cell.timeframe);
1581
- this.topbar.setPriceStyle(cell.priceStyle);
1582
- this.topbar.setIndicatorCount(cell.indicatorCount);
1583
- this.topbar.renderActions();
1584
- this.mobileBar?.renderActions();
1585
- this.mobileBar?.setSymbol(cell.symbol);
1586
- this.mobileBar?.setTimeframe(cell.timeframe);
1587
- this.drawingPill.onChart(cell.chart);
1588
- const pushHistory = () => this.topbar.setHistoryState(cell.history.canUndo, cell.history.canRedo);
1589
- this.historyUnsub?.();
1590
- this.historyUnsub = cell.history.onChange(pushHistory);
1591
- pushHistory();
1592
- this.objectTree.setSymbol(cell.symbol);
1593
- this.dock.onChart(cell.chart);
1594
- this.bottombar?.setActiveRange(cell.activeRangeId);
1595
- this.bottombar?.setSession({ session: cell.session, enabled: cell.sessionAvailable });
1596
- this.indicatorPicker?.sync();
1597
- this.glider.stop();
1598
- const d = cell.chart.drawings;
1599
- if (d.getTool() !== this.globalTool) d.setTool(this.globalTool);
1600
- if (d.getSnapMode() !== this.globalSnap) d.setSnapMode(this.globalSnap);
1601
- if (d.getStayMode() !== this.globalStay) d.setStayMode(this.globalStay);
1602
- if (this.drawToolbar) {
1603
- this.drawToolbar.setActiveTool(this.globalTool);
1604
- this.drawToolbar.setMagnetMode(this.globalSnap);
1605
- this.drawToolbar.setStayMode(this.globalStay);
1606
- const mode = d.getMode();
1607
- this.drawToolbar.setMeasureActive(mode === "measure");
1608
- this.drawToolbar.setEraserActive(mode === "eraser");
1609
- this.drawToolbar.setFavorites(d.favorites());
1610
- }
1611
- }
1612
- /** Put keyboard focus back on the active cell's chart surface (after a toolbar press
1613
- * stole it) so chart/drawing shortcuts keep working. */
1614
- refocusActive() {
1615
- if (this.activeId) this.cellsById.get(this.activeId)?.chart.renderer.focus();
1616
- }
1617
- /** Debounced dirty mark: one `state:changed` (+ one storage write in persist mode)
1618
- * per burst of edits, flushed hard on unload/destroy. */
1619
- markStateDirty() {
1620
- if (this.destroyed) return;
1621
- if (this.stateTimer != null) clearTimeout(this.stateTimer);
1622
- this.stateTimer = setTimeout(() => {
1623
- this.stateTimer = null;
1624
- this.events.emit("state:changed", void 0);
1625
- this.persistNow();
1626
- }, 500);
1627
- }
1628
- /** Write the current state through the storage adapter now (fire-and-forget). */
1629
- persistNow() {
1630
- if (this.persistKey === null || this.destroyed) return;
1631
- try {
1632
- void this.storage.set(this.persistKey, encodeState(this.getState()));
1633
- } catch {
1634
- }
1635
- }
1636
- // ── internals ───────────────────────────────────────────────
1637
- resolveLayout(layout) {
1638
- if (typeof layout !== "string") return layout;
1639
- const def = ensureLayout(layout);
1640
- if (!def) throw new Error(`[vela] unknown workspace layout "${layout}" \u2014 register it with registerLayout().`);
1641
- return def;
1642
- }
1643
- backendFor(def) {
1644
- const explicit = this.opts.nativeBackend;
1645
- if (explicit && explicit !== "auto") return explicit;
1646
- return def.cells.length > (this.opts.maxWebglCells ?? 8) ? "canvas2d" : "auto";
1647
- }
1648
- currentTracks() {
1649
- const sizes = this.trackSizes.get(this.def.id);
1650
- return {
1651
- cols: sizes?.cols?.length === this.def.cols.length ? [...sizes.cols] : [...this.def.cols],
1652
- rows: sizes?.rows?.length === this.def.rows.length ? [...sizes.rows] : [...this.def.rows]
1653
- };
1654
- }
1655
- applyTracks(axis, weights) {
1656
- const sizes = this.trackSizes.get(this.def.id) ?? {};
1657
- sizes[axis] = weights;
1658
- this.trackSizes.set(this.def.id, sizes);
1659
- this.applyGrid();
1660
- this.markStateDirty();
1661
- }
1662
- /** Apply the grid template (+ per-cell areas) and reposition the splitter strips.
1663
- * Geometry is keyed by SLOT (`perCell[slot.id]`); the cell living there is
1664
- * `order[i]` — the identity/position decoupling in one line. */
1665
- applyGrid() {
1666
- const { container, perCell } = gridStyles(this.def, this.trackSizes.get(this.def.id));
1667
- if (this.def.cells.length > 1) this.gridEl.dataset.multi = "1";
1668
- else delete this.gridEl.dataset.multi;
1669
- this.gridEl.style.gridTemplateColumns = container.gridTemplateColumns ?? "";
1670
- this.gridEl.style.gridTemplateRows = container.gridTemplateRows ?? "";
1671
- this.gridEl.style.gridTemplateAreas = container.gridTemplateAreas ?? "";
1672
- for (const [i, slot] of this.def.cells.entries()) {
1673
- const host = this.cellsById.get(this.order[i] ?? "")?.host;
1674
- if (host) host.style.gridArea = perCell[slot.id]?.gridArea ?? "";
1675
- }
1676
- this.splitters.layout();
1677
- }
1678
- /** Create the cells the current layout wants but don't exist yet (pool-first).
1679
- * A slot's CELL IDENTITY is `order[i]` (declaration order — never the slot's own
1680
- * positional id); slots past the declared list mint an auto identity once. */
1681
- buildCells() {
1682
- const theme = resolveTheme(this.opts.theme);
1683
- const { perCell } = gridStyles(this.def, this.trackSizes.get(this.def.id));
1684
- for (const [i, slot] of this.def.cells.entries()) {
1685
- let id = this.order[i];
1686
- if (!id) {
1687
- id = nextAutoCellId(/* @__PURE__ */ new Set([...this.order, ...this.pool.keys(), ...this.cellsById.keys()]));
1688
- this.order[i] = id;
1689
- }
1690
- if (this.cellsById.has(id)) continue;
1691
- const pooled = this.pool.get(id);
1692
- const seed = pooled ?? { ...seedDefaults(this.opts), ...this.opts.cells?.[id] ?? {} };
1693
- this.pool.delete(id);
1694
- const cell = new ChartCell(id, this.gridEl, seed, {
1695
- feed: this.feed,
1696
- engines: this.opts.engines ?? {},
1697
- chartDefaults: cellChartDefaults(this.opts),
1698
- theme,
1699
- live: this.opts.live ?? false,
1700
- volume: this.opts.volume ?? true,
1701
- statusline: this.opts.statusline !== false,
1702
- watermark: this.opts.watermark !== false,
1703
- nativeBackend: this.cellBackend,
1704
- dialogHost: this.root,
1705
- timezone: () => this.timezone,
1706
- setTimezone: (zone) => this.setTimezone(zone),
1707
- context: () => this.context(),
1708
- activate: (id2) => this.setActiveCell(id2),
1709
- onMarketChanged: (id2) => this.onCellMarketChanged(id2),
1710
- onPriceStyleChanged: (id2) => this.onCellPriceStyleChanged(id2),
1711
- onIndicatorsChanged: (id2) => this.onCellIndicatorsChanged(id2),
1712
- onStateDirty: () => this.markStateDirty(),
1713
- manifestSettled: () => this.manifestSettled
1714
- });
1715
- cell.host.style.gridArea = perCell[slot.id]?.gridArea ?? "";
1716
- this.cellsById.set(id, cell);
1717
- if (id === this.activeId) cell.host.dataset.active = "1";
1718
- this.wireCell(cell);
1719
- cell.chart.renderer.setLayoutMode(this.layoutCtl.current);
1720
- if (this.favs.length > 0) cell.chart.drawings.setFavorites(this.favs);
1721
- cell.setManifest(this.manifest, pooled?.indicators == null);
1722
- this.events.emit("cell:created", { id });
1723
- }
1724
- for (const [i] of this.def.cells.entries()) {
1725
- const host = this.cellsById.get(this.order[i] ?? "")?.host;
1726
- if (host) this.gridEl.appendChild(host);
1727
- }
1728
- }
1729
- /** Per-cell chart subscriptions (trigger ② — the chart instance is stable for the
1730
- * cell's whole life, so these live and die with the cell). */
1731
- wireCell(cell) {
1732
- const chart = cell.chart;
1733
- chart.on("indicator:error", ({ error }) => this.toast.show(`[${cell.id}] ${error.message}`, "error", 5e3));
1734
- chart.on("script:run", (run) => this.events.emit("script:run", { ...run, cell: cell.id }));
1735
- chart.on("alert", (alert) => {
1736
- this.alerts.unshift({ cellId: cell.id, symbol: cell.symbol, title: alert.title ?? "Alert", message: alert.message, time: alert.time });
1737
- if (this.alerts.length > ALERT_CAP) this.alerts.pop();
1738
- this.toast.show(`[${cell.id} \xB7 ${cell.symbol}] ${alert.title ? alert.title + " \u2014 " : ""}${alert.message}`, "info", 4e3);
1739
- this.topbar.setAlertCount(this.alerts.length);
1740
- });
1741
- chart.on("drawing:favorites", ({ favorites }) => {
1742
- this.favs = favorites;
1743
- for (const other of this.cellsById.values()) {
1744
- if (other !== cell) other.chart.drawings.setFavorites(favorites);
1745
- }
1746
- this.drawToolbar?.setFavorites(favorites);
1747
- this.markStateDirty();
1748
- });
1749
- chart.renderer.onCrosshairMove((e) => this.propagateCrosshair(cell.id, e.time, e.paneKind === "price" ? e.price : null));
1750
- chart.on("drawing:created", ({ id }) => {
1751
- this.propagateDrawing(cell.id, id);
1752
- this.markStateDirty();
1753
- });
1754
- chart.on("drawing:draft", ({ doc }) => this.propagateDraft(cell.id, doc));
1755
- chart.on("drawing:edited", ({ id }) => {
1756
- this.propagateDrawingEdit(cell.id, id);
1757
- this.markStateDirty();
1758
- });
1759
- chart.on("drawing:removed", ({ id }) => {
1760
- this.propagateDrawingRemoval(cell.id, id);
1761
- this.markStateDirty();
1762
- });
1763
- chart.on("drawing:tool", ({ type }) => {
1764
- if (cell.id !== this.activeId) return;
1765
- this.globalTool = type;
1766
- this.drawToolbar?.setActiveTool(type);
1767
- });
1768
- chart.on("drawing:snap", ({ mode }) => {
1769
- if (cell.id !== this.activeId) return;
1770
- this.globalSnap = mode;
1771
- this.drawToolbar?.setMagnetMode(mode);
1772
- });
1773
- chart.on("drawing:stay", ({ on }) => {
1774
- if (cell.id !== this.activeId) return;
1775
- this.globalStay = on;
1776
- this.drawToolbar?.setStayMode(on);
1777
- });
1778
- chart.on("drawing:mode", ({ mode }) => {
1779
- if (cell.id !== this.activeId) return;
1780
- this.drawToolbar?.setMeasureActive(mode === "measure");
1781
- this.drawToolbar?.setEraserActive(mode === "eraser");
1782
- });
1783
- chart.on("viewport:changed", (range) => this.propagateViewport(cell.id, range));
1784
- chart.on("theme:changed", (t) => this.setTheme(t));
1785
- chart.renderer.onAxisLongPress((e) => {
1786
- if (this.layoutCtl.current !== "mobile") return;
1787
- if (e.axis === "time") this.openTimezoneDrawer();
1788
- else this.openPriceScaleDrawer(e.y);
1789
- });
1790
- }
1791
- // ── sync links ──────────────────────────────────────────────
1792
- applySyncSetting(kind, setting, align = false) {
1793
- if (setting == null || setting === false) delete this.syncOpts[kind];
1794
- else this.syncOpts[kind] = setting;
1795
- if (kind === "drawings") {
1796
- for (const cell of this.cellsById.values()) cell.chart.drawings.setExternalGhost(null);
1797
- this.drawToolbar?.setDrawingsSyncMode(!!setting);
1798
- this.markStateDirty();
1799
- return;
1800
- }
1801
- if (kind === "crosshair") {
1802
- for (const cell of this.cellsById.values()) cell.chart.renderer.setExternalCrosshair(null);
1803
- if (setting && ![...this.cellsById.values()].some((c) => c.chart.renderer.supportsExternalCrosshair)) {
1804
- console.warn("[vela] crosshair sync: no cell renderer supports an external crosshair \u2014 nothing will show.");
1805
- }
1806
- if (this.topbar && this.def) this.topbar.setLayout(this.def.id);
1807
- this.markStateDirty();
1808
- return;
1809
- }
1810
- if (this.topbar && this.def) this.topbar.setLayout(this.def.id);
1811
- this.markStateDirty();
1812
- if (align && setting && this.activeId) {
1813
- if (kind === "viewport") {
1814
- const range = this.cellsById.get(this.activeId)?.chart.getVisibleRange();
1815
- if (range) this.propagateViewport(this.activeId, range);
1816
- } else {
1817
- this.propagateMarket(this.activeId);
1818
- }
1819
- }
1820
- }
1821
- /**
1822
- * Mirror an origin cell's pointer time onto its same-group followers as GHOST
1823
- * crosshairs (`renderer.setExternalCrosshair`). The horizontal price level rides
1824
- * along ONLY to followers showing the same ticker — price scales are comparable
1825
- * there, while an origin's price painted on another market's pane would be noise.
1826
- * Leaving the origin propagates `null` (the event already carries it) and clears
1827
- * every ghost. No busy guard needed: an external crosshair never re-emits
1828
- * `onCrosshairMove` — the flow is one-way by port contract, so no echo loop can
1829
- * exist.
1830
- */
1831
- propagateCrosshair(originId, time, price = null) {
1832
- if (this.destroyed) return;
1833
- const setting = this.syncOpts.crosshair;
1834
- if (!setting) return;
1835
- const originTicker = parseSymbol(this.cellsById.get(originId)?.symbol ?? "").ticker;
1836
- for (const id of syncTargets(originId, setting, [...this.cellsById.keys()])) {
1837
- const follower = this.cellsById.get(id);
1838
- if (!follower) continue;
1839
- const comparable = originTicker !== "" && parseSymbol(follower.symbol).ticker === originTicker;
1840
- follower.chart.renderer.setExternalCrosshair(time, comparable ? price : null);
1841
- }
1842
- }
1843
- /**
1844
- * Push an origin cell's visible range onto its same-group followers. Loop-safe two
1845
- * ways: the busy guard eats the followers' SYNCHRONOUS echoes (their setVisibleRange
1846
- * re-emits `viewport:changed` in the same tick), and the half-bar epsilon
1847
- * short-circuits any async residue — a follower already within half of ITS OWN bar
1848
- * interval is left alone, so cross-timeframe groups settle instead of oscillating.
1849
- */
1850
- propagateViewport(originId, range) {
1851
- if (this.syncBusy || this.destroyed) return;
1852
- const targets = syncTargets(originId, this.syncOpts.viewport, [...this.cellsById.keys()]);
1853
- if (targets.length === 0) return;
1854
- this.syncBusy = true;
1855
- try {
1856
- for (const id of targets) {
1857
- const cell = this.cellsById.get(id);
1858
- if (!cell) continue;
1859
- const current = cell.chart.getVisibleRange();
1860
- const tfMs = timeframeToMs(cell.timeframe);
1861
- const eps = Number.isFinite(tfMs) ? tfMs / 2 : 0;
1862
- if (current && rangesWithin(current, range, eps)) continue;
1863
- cell.chart.setVisibleRange(range);
1864
- }
1865
- } finally {
1866
- this.syncBusy = false;
1867
- }
1868
- }
1869
- /**
1870
- * Copy a freshly created drawing onto the origin's same-group followers (fresh ids
1871
- * through `drawings.add` — anchors are time+price, so the copy lands at the same
1872
- * spot whatever the follower shows) and register the whole set as LINKED: edits
1873
- * and removals of any member follow while the link is on ({@link drawingLinks}).
1874
- * The busy guard stops the copies' own `drawing:created` events from fanning out.
1875
- */
1876
- propagateDrawing(originId, drawingId) {
1877
- if (this.drawingSyncBusy || this.destroyed) return;
1878
- const setting = this.syncOpts.drawings;
1879
- if (!setting) return;
1880
- const doc = this.cellsById.get(originId)?.chart.drawings.all().find((d) => d.id === drawingId);
1881
- if (!doc) return;
1882
- this.drawingSyncBusy = true;
1883
- try {
1884
- const group = /* @__PURE__ */ new Map([[originId, drawingId]]);
1885
- for (const id of syncTargets(originId, setting, [...this.cellsById.keys()])) {
1886
- const copy = this.cellsById.get(id)?.chart.drawings.add(doc.type, {
1887
- paneId: doc.paneId,
1888
- anchors: doc.anchors,
1889
- style: doc.style,
1890
- text: doc.text,
1891
- props: doc.props
1892
- });
1893
- if (copy) group.set(id, copy.id);
1894
- }
1895
- if (group.size > 1) {
1896
- for (const [cellId, dId] of group) this.drawingLinks.set(`${cellId}\0${dId}`, group);
1897
- }
1898
- } finally {
1899
- this.drawingSyncBusy = false;
1900
- }
1901
- }
1902
- /** Mirror an in-progress placement (its current ghost, `null` = placement ended)
1903
- * onto the origin's same-group followers — the live half of the drawings link;
1904
- * the created copy replaces the ghosts when the placement completes. One-way by
1905
- * contract (an external ghost never re-emits drafts), so no busy guard needed. */
1906
- propagateDraft(originId, doc) {
1907
- if (this.destroyed) return;
1908
- const setting = this.syncOpts.drawings;
1909
- if (!setting) return;
1910
- for (const id of syncTargets(originId, setting, [...this.cellsById.keys()])) {
1911
- this.cellsById.get(id)?.chart.drawings.setExternalGhost(doc);
1912
- }
1913
- }
1914
- /** Push a linked drawing's edited CONTENT (anchors, style, text, per-type props)
1915
- * onto its same-group peers — any member propagates, not just the original. */
1916
- propagateDrawingEdit(originId, drawingId) {
1917
- if (this.drawingSyncBusy || this.destroyed) return;
1918
- const setting = this.syncOpts.drawings;
1919
- if (!setting) return;
1920
- const group = this.drawingLinks.get(`${originId}\0${drawingId}`);
1921
- if (!group) return;
1922
- const doc = this.cellsById.get(originId)?.chart.drawings.all().find((d) => d.id === drawingId);
1923
- if (!doc) return;
1924
- this.drawingSyncBusy = true;
1925
- try {
1926
- for (const id of syncTargets(originId, setting, [...this.cellsById.keys()])) {
1927
- const peerId = group.get(id);
1928
- if (peerId == null) continue;
1929
- this.cellsById.get(id)?.chart.drawings.update(peerId, {
1930
- anchors: doc.anchors,
1931
- style: doc.style,
1932
- text: doc.text,
1933
- props: doc.props
1934
- });
1935
- }
1936
- } finally {
1937
- this.drawingSyncBusy = false;
1938
- }
1939
- }
1940
- /** Remove a linked drawing's peers with it. The removed member always leaves its
1941
- * link group (whatever the setting); peers are only deleted while the link is on.
1942
- * A propagated peer removal re-enters here under the busy guard and just cleans
1943
- * its own link key. */
1944
- propagateDrawingRemoval(originId, drawingId) {
1945
- if (this.destroyed) return;
1946
- const key = `${originId}\0${drawingId}`;
1947
- const group = this.drawingLinks.get(key);
1948
- if (!group) return;
1949
- this.drawingLinks.delete(key);
1950
- group.delete(originId);
1951
- if (this.drawingSyncBusy) return;
1952
- const setting = this.syncOpts.drawings;
1953
- if (!setting) return;
1954
- this.drawingSyncBusy = true;
1955
- try {
1956
- for (const id of syncTargets(originId, setting, [...this.cellsById.keys()])) {
1957
- const peerId = group.get(id);
1958
- if (peerId != null) this.cellsById.get(id)?.chart.drawings.remove(peerId);
1959
- }
1960
- } finally {
1961
- this.drawingSyncBusy = false;
1962
- }
1963
- }
1964
- /**
1965
- * Push an origin cell's symbol/timeframe onto its same-group followers (fired from
1966
- * `market:changed`). Convergence comes from IDEMPOTENCE, not the guard: a follower's
1967
- * own (async) `market:changed` propagates back, but every peer already carries the
1968
- * value, so the cell setters no-op and the wave dies.
1969
- */
1970
- propagateMarket(originId) {
1971
- if (this.syncBusy || this.destroyed) return;
1972
- const origin = this.cellsById.get(originId);
1973
- if (!origin) return;
1974
- const ids = [...this.cellsById.keys()];
1975
- this.syncBusy = true;
1976
- try {
1977
- if (origin.symbol) {
1978
- for (const id of syncTargets(originId, this.syncOpts.symbol, ids)) this.cellsById.get(id)?.setSymbol(origin.symbol);
1979
- }
1980
- for (const id of syncTargets(originId, this.syncOpts.timeframe, ids)) this.cellsById.get(id)?.setTimeframe(origin.timeframe);
1981
- } finally {
1982
- this.syncBusy = false;
1983
- }
1984
- }
1985
- /** Trigger ② — a cell's market changed: retention + sync always; chrome only if active. */
1986
- onCellMarketChanged(id) {
1987
- this.refreshRetention();
1988
- this.propagateMarket(id);
1989
- this.markStateDirty();
1990
- if (id !== this.activeId) return;
1991
- const cell = this.cellsById.get(id);
1992
- if (!cell) return;
1993
- this.topbar.setSymbol(cell.symbol);
1994
- this.topbar.setTimeframe(cell.timeframe);
1995
- this.mobileBar?.setSymbol(cell.symbol);
1996
- this.mobileBar?.setTimeframe(cell.timeframe);
1997
- this.objectTree.setSymbol(cell.symbol);
1998
- this.bottombar?.setSession({ session: cell.session, enabled: cell.sessionAvailable });
1999
- }
2000
- /** Trigger ② — a cell's price style changed: the topbar button/menu only if active.
2001
- * Reads the cell back (not the requested style) so the button reflects what the
2002
- * renderer actually applied. */
2003
- onCellPriceStyleChanged(id) {
2004
- this.markStateDirty();
2005
- if (id !== this.activeId) return;
2006
- const cell = this.cellsById.get(id);
2007
- if (!cell) return;
2008
- this.topbar.setPriceStyle(cell.priceStyle);
2009
- }
2010
- /** Trigger ② — a cell's indicator ledger changed: count + picker only if active. */
2011
- onCellIndicatorsChanged(id) {
2012
- this.markStateDirty();
2013
- if (id !== this.activeId) return;
2014
- const cell = this.cellsById.get(id);
2015
- if (!cell) return;
2016
- this.topbar.setIndicatorCount(cell.indicatorCount);
2017
- this.indicatorPicker?.sync();
2018
- }
2019
- /** Timeframe changes routed from the topbar menu / quick entry (chip state follows). */
2020
- setActiveTimeframe(tf) {
2021
- this.bottombar?.setActiveRange(null);
2022
- this.active.setTimeframe(tf);
2023
- }
2024
- /** Star/unstar a timeframe — a WORKSPACE preference (one chip row, whatever the
2025
- * active cell); the topbar follows and the set persists with the document. */
2026
- setTimeframeFavorite(tf, on) {
2027
- if (on === this.tfFavs.includes(tf)) return;
2028
- this.tfFavs = on ? [tf, ...this.tfFavs] : this.tfFavs.filter((f) => f !== tf);
2029
- this.topbar.setTimeframeFavorites(this.tfFavs);
2030
- this.markStateDirty();
2031
- }
2032
- /** The mode flipped (container resized across the breakpoint, or a coarse-pointer
2033
- * change). Close the open surfaces — a desktop card must not linger over the
2034
- * mobile chrome (and vice versa) — and re-present every cell's own chrome. */
2035
- onLayoutModeChange(mode) {
2036
- this.symbolPicker.close();
2037
- this.indicatorPicker?.close();
2038
- this.tfDrawer?.close();
2039
- this.drawingsDrawer?.close();
2040
- this.moreDrawer?.close();
2041
- this.timezoneDrawer?.close();
2042
- this.priceScaleDrawer?.close();
2043
- this.alertsMenu?.destroy();
2044
- this.alertsMenu = null;
2045
- for (const cell of this.cellsById.values()) {
2046
- cell.chart.renderer.closeDialogs();
2047
- cell.chart.renderer.setLayoutMode(mode);
2048
- }
2049
- this.syncCellPresentation();
2050
- }
2051
- /** Push the layout-shape-dependent chrome onto every cell: multi-cell grids keep
2052
- * the status line on one row (segments that don't fit hide), and on MOBILE their
2053
- * legends' fold chip routes to the object tree instead of unfolding in place —
2054
- * per-indicator controls live there (the legend rows have no room in a grid cell). */
2055
- syncCellPresentation() {
2056
- const multi = this.def.cells.length > 1;
2057
- const overview = multi && this.layoutCtl.current === "mobile" ? () => this.dock.toggle("objects", true) : null;
2058
- for (const cell of this.cellsById.values()) {
2059
- cell.setStatuslineFit(multi);
2060
- cell.chart.renderer.setLegendOverviewAction(overview);
2061
- }
2062
- }
2063
- // ── mobile drawers (built on first open; every read is live and hits the ACTIVE cell) ──
2064
- openTimeframeDrawer() {
2065
- this.tfDrawer ?? (this.tfDrawer = new TimeframeDrawer({
2066
- host: this.root,
2067
- timeframes: this.opts.timeframes ?? DEFAULT_TIMEFRAMES,
2068
- ranges: RANGE_PRESETS,
2069
- currentTimeframe: () => this.active.timeframe,
2070
- activeRange: () => this.active.activeRangeId,
2071
- onTimeframe: (tf) => this.setActiveTimeframe(tf),
2072
- onRange: (preset) => {
2073
- this.active.applyRange(preset);
2074
- this.bottombar?.setActiveRange(preset.id);
2075
- },
2076
- onOpenChange: (open) => this.trackDialog(open)
2077
- }));
2078
- this.tfDrawer.open();
2079
- }
2080
- openDrawingsDrawer() {
2081
- this.drawingsDrawer ?? (this.drawingsDrawer = new DrawingsDrawer({
2082
- host: this.root,
2083
- toolbar: () => defaultToolbar(),
2084
- // the shared static toolbar's definition (see constructor)
2085
- currentTool: () => this.active.chart.drawings.getTool(),
2086
- isFavorite: (type) => this.active.chart.drawings.isFavorite(type),
2087
- onFavorite: (type, on) => this.active.chart.drawings.setFavorite(type, on),
2088
- onSelect: (type) => this.active.chart.drawings.setTool(type),
2089
- onOpenChange: (open) => this.trackDialog(open)
2090
- }));
2091
- this.drawingsDrawer.open();
2092
- }
2093
- openMoreDrawer() {
2094
- this.moreDrawer ?? (this.moreDrawer = new MoreDrawer({
2095
- host: this.root,
2096
- onUndo: () => this.active.history.undo(),
2097
- onRedo: () => this.active.history.redo(),
2098
- onScreenshot: () => this.active.downloadScreenshot(),
2099
- canUndo: () => this.active.history.canUndo,
2100
- canRedo: () => this.active.history.canRedo,
2101
- priceStyles: () => priceStyleIds().map((id) => ({ id, label: priceStyleLabel(id), icon: priceStyleIcon(id) })),
2102
- priceStyle: () => this.active.priceStyle,
2103
- onPriceStyle: (id) => this.active.setPriceStyle(id),
2104
- panels: () => [...this.dock.list()],
2105
- onTogglePanel: (id) => this.dock.toggle(id),
2106
- alerts: () => this.alerts.map((a) => ({ title: `[${a.cellId} \xB7 ${a.symbol}] ${a.title}`, message: a.message, time: a.time })),
2107
- // Left-aligned actions have their own bottom-bar stop — only the rest
2108
- // lands in the drawer, or every left action would appear twice.
2109
- actions: () => widgetActions("topbar", this.context()).filter((a) => a.align !== "left").map((a) => ({ label: a.label, icon: a.icon, run: () => a.run(this.context()) })),
2110
- // The desktop layout dropdown's whole surface — the grid canvas, the
2111
- // non-canvas presets and the sync switches — relocated into the kebab
2112
- // drawer (the topbar is hidden on mobile). Same reads as the topbar block.
2113
- layout: {
2114
- shape: () => layoutShape(this.def),
2115
- presets: () => layouts().filter((l) => layoutShape(l) === null).map((l) => ({ id: l.id, label: l.label, checked: l.id === this.def.id })),
2116
- onSelectGrid: (rows, cols) => this.setLayout(layoutForGrid(rows, cols)),
2117
- onSelectPreset: (id) => this.setLayout(id),
2118
- syncs: () => [
2119
- { id: "symbol", label: "Symbol", checked: this.syncOpts.symbol === true },
2120
- { id: "timeframe", label: "Interval", checked: this.syncOpts.timeframe === true },
2121
- { id: "crosshair", label: "Crosshair", checked: this.syncOpts.crosshair === true }
2122
- ],
2123
- onToggleSync: (id) => {
2124
- const kind = id;
2125
- this.sync.set(kind, this.syncOpts[kind] ? false : true);
2126
- }
2127
- },
2128
- onOpenChange: (open) => this.trackDialog(open)
2129
- }));
2130
- this.moreDrawer.open();
2131
- }
2132
- openTimezoneDrawer() {
2133
- this.timezoneDrawer ?? (this.timezoneDrawer = new TimezoneDrawer({
2134
- host: this.root,
2135
- timezone: () => this.timezone,
2136
- onTimezone: (zone) => this.setTimezone(zone),
2137
- onOpenChange: (open) => this.trackDialog(open)
2138
- }));
2139
- this.timezoneDrawer.open();
2140
- }
2141
- openPriceScaleDrawer(y) {
2142
- this.priceScalePressY = y;
2143
- this.priceScaleDrawer ?? (this.priceScaleDrawer = new PriceScaleDrawer({
2144
- host: this.root,
2145
- chart: () => this.activeId ? this.cellsById.get(this.activeId)?.chart ?? null : null,
2146
- pressY: () => this.priceScalePressY,
2147
- onOpenChange: (open) => this.trackDialog(open)
2148
- }));
2149
- this.priceScaleDrawer.open();
2150
- }
2151
- /** The aggregated alerts bell — entries carry their cell; selecting one activates it. */
2152
- openAlertsMenu(anchor) {
2153
- this.alertsMenu?.destroy();
2154
- const items = this.alerts.length ? this.alerts.map((a, i) => ({
2155
- id: String(i),
2156
- label: `[${a.cellId} \xB7 ${a.symbol}] ${new Date(a.time).toLocaleTimeString()} \xB7 ${a.title}: ${a.message}`.slice(0, 80)
2157
- })) : [{ id: "none", label: "No alerts yet", disabled: true }];
2158
- this.alertsMenu = new Menu({
2159
- host: this.root,
2160
- items,
2161
- onSelect: (id) => {
2162
- const alert = this.alerts[Number(id)];
2163
- if (alert && this.cellsById.has(alert.cellId)) this.setActiveCell(alert.cellId);
2164
- }
2165
- });
2166
- const r = anchor.getBoundingClientRect();
2167
- this.alertsMenu.openAt(r.left, r.bottom + 4);
2168
- }
2169
- /** Mount registered attachments not yet mounted on this workspace (idempotent per id). */
2170
- mountAttachments() {
2171
- for (const att of widgetAttachments()) {
2172
- if (this.attachmentDisposers.has(att.id)) continue;
2173
- try {
2174
- this.attachmentDisposers.set(att.id, att.mount(this.context()));
2175
- } catch (err) {
2176
- console.warn(`[vela] workspace attachment "${att.id}" failed to mount:`, err);
2177
- }
2178
- }
2179
- }
2180
- /** The default shortcut set — every binding acts on the ACTIVE cell. */
2181
- registerDefaultKeys() {
2182
- this.keymap.register({ id: "chart.screenshot", keys: "mod+alt+s", label: "Download a chart screenshot", category: "Chart", run: () => this.active.downloadScreenshot() });
2183
- this.keymap.register({ id: "chart.reset-view", keys: "alt+r", label: "Reset view (all history)", category: "Chart", run: () => this.active.chart.setVisibleRangePreset("ALL") });
2184
- this.keymap.register({ id: "chart.toggle-log", keys: "alt+l", label: "Toggle logarithmic scale", category: "Chart", run: () => this.active.chart.renderer.set("logScale", !this.active.chart.renderer.get("logScale")) });
2185
- this.keymap.register({
2186
- id: "chart.toggle-percent",
2187
- keys: "alt+p",
2188
- label: "Toggle percent scale",
2189
- category: "Chart",
2190
- run: () => {
2191
- const mode = this.active.chart.renderer.get("scaleMode");
2192
- this.active.chart.renderer.set("scaleMode", mode === "percent" ? "price" : "percent");
2193
- }
2194
- });
2195
- this.keymap.register({ id: "drawings.trendline", keys: "alt+t", label: "Arm the trend line tool", category: "Drawings", run: () => this.active.chart.drawings.setTool("trendline") });
2196
- this.keymap.register({
2197
- id: "drawings.hline-cursor",
2198
- keys: "alt+h",
2199
- label: "Horizontal line at the cursor price",
2200
- category: "Drawings",
2201
- run: () => {
2202
- const c = this.active;
2203
- if (c.lastCrossTime != null && c.lastCrossPrice != null) c.chart.drawings.add("hline", { anchors: [{ time: c.lastCrossTime, price: c.lastCrossPrice }] });
2204
- }
2205
- });
2206
- this.keymap.register({
2207
- id: "drawings.vline-cursor",
2208
- keys: "alt+v",
2209
- label: "Vertical line at the cursor time",
2210
- category: "Drawings",
2211
- run: () => {
2212
- const c = this.active;
2213
- if (c.lastCrossTime != null && c.lastCrossPrice != null) c.chart.drawings.add("vline", { anchors: [{ time: c.lastCrossTime, price: c.lastCrossPrice }] });
2214
- }
2215
- });
2216
- this.keymap.register({ id: "history.undo", keys: ["mod+z"], label: "Undo (active chart)", category: "Edit", run: () => this.active.history.undo() });
2217
- this.keymap.register({ id: "history.redo", keys: ["mod+y", "mod+shift+z"], label: "Redo (active chart)", category: "Edit", run: () => this.active.history.redo() });
2218
- this.keymap.register({ id: "view.zoom-in", keys: "mod+arrowup", label: "Zoom in", category: "Chart", run: () => this.glider.zoom(ZOOM_IN) });
2219
- this.keymap.register({ id: "view.zoom-out", keys: "mod+arrowdown", label: "Zoom out", category: "Chart", run: () => this.glider.zoom(ZOOM_OUT) });
2220
- this.keymap.register({ id: "view.pan-left", keys: "mod+arrowleft", label: "Pan toward history", category: "Chart", run: () => this.active.chart.panBy(-PAN_FAST) });
2221
- this.keymap.register({ id: "view.pan-right", keys: "mod+arrowright", label: "Pan toward now", category: "Chart", run: () => this.active.chart.panBy(PAN_FAST) });
2222
- if (this.indicatorPicker) {
2223
- this.keymap.register({ id: "indicators.open", keys: "/", label: "Open the indicator picker", category: "Indicators", run: () => this.indicatorPicker?.open() });
2224
- }
2225
- this.keymap.register({
2226
- id: "help.shortcuts",
2227
- keys: "?",
2228
- label: "Show this shortcuts panel",
2229
- category: "Help",
2230
- run: () => {
2231
- this.shortcutsHelp ?? (this.shortcutsHelp = new ShortcutsHelp(this.keymap, this.root, (open) => this.trackDialog(open)));
2232
- this.shortcutsHelp.open();
2233
- }
2234
- });
2235
- }
2236
- /** Bare-typing router: letters → symbol search (seeded), digits → timeframe entry. */
2237
- routeTyping(ev) {
2238
- if (this.destroyed || this.openDialogs > 0) return;
2239
- if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
2240
- if (isEditableTarget(ev)) return;
2241
- const key = ev.key;
2242
- if (/^[a-zA-Z]$/.test(key)) {
2243
- ev.preventDefault();
2244
- this.symbolPicker.open(key.toUpperCase());
2245
- } else if (/^[0-9]$/.test(key)) {
2246
- ev.preventDefault();
2247
- this.tfQuick.open(key);
2248
- }
2249
- }
2250
- trackDialog(open) {
2251
- this.openDialogs = Math.max(0, this.openDialogs + (open ? 1 : -1));
2252
- if (open) this.keymap.pushScope("dialog");
2253
- else this.keymap.popScope("dialog");
2254
- }
2255
- /** Pool a dehydrated slot state (bounded — oldest entries drop past the cap). */
2256
- poolSet(id, state) {
2257
- this.pool.delete(id);
2258
- this.pool.set(id, state);
2259
- if (this.pool.size > POOL_CAP) {
2260
- const oldest = this.pool.keys().next().value;
2261
- if (oldest != null) this.pool.delete(oldest);
2262
- }
2263
- }
2264
- /**
2265
- * Declare every live cell's symbol to the shared bar cache so one cell's load never
2266
- * evicts the others' history (multi-symbol retention). Keys are CANONICAL tickers —
2267
- * resolved through the registry when its indexes are ready, raw until then (refreshed
2268
- * again on `feed.ready()`).
2269
- */
2270
- refreshRetention() {
2271
- const symbols = /* @__PURE__ */ new Set();
2272
- for (const cell of this.cellsById.values()) {
2273
- const raw = cell.symbol;
2274
- if (!raw) continue;
2275
- symbols.add(this.feed.resolveSymbol(raw)?.ticker ?? raw);
2276
- }
2277
- sharedBarStore.retain(symbols);
2278
- }
2279
- };
2280
-
2281
- export { ChartCell, GRID_PICKER_MAX, VelaWorkspace, activeAfterLayout, ensureLayout, evenTracks, gridStyles, layoutDefinition, layoutForGrid, layoutShape, layouts, memoryStorageAdapter, rangesWithin, registerBuiltinLayouts, registerLayout, resizeTracks, syncTargets, trackOffsets, unregisterLayout };
1
+ export { ChartCell, GRID_PICKER_MAX, VelaWorkspace, activeAfterLayout, decodeState, encodeState, ensureLayout, evenTracks, gridStyles, layoutDefinition, layoutForGrid, layoutShape, layouts, memoryStorageAdapter, rangesWithin, registerBuiltinLayouts, registerLayout, resizeTracks, sanitizeState, syncTargets, trackOffsets, unregisterLayout } from './chunk-TMXK2SJR.js';
2
+ import './chunk-JQFO2WMU.js';
3
+ import './chunk-OEUGUXL7.js';
4
+ import './chunk-2R7BANEM.js';
5
+ import './chunk-OICPLNU7.js';
6
+ import './chunk-PN5KWFZ4.js';