@luxalgo/vela 0.5.0
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.
- package/LICENSE +201 -0
- package/NOTICE +28 -0
- package/README.md +135 -0
- package/dist/DataProvider-DKNDHpNv.d.cts +134 -0
- package/dist/DataProvider-Dzd-Erlk.d.ts +134 -0
- package/dist/chunk-7UFX5ZIG.js +5976 -0
- package/dist/chunk-GYD2THPV.js +252 -0
- package/dist/chunk-KCCZNKH7.js +19137 -0
- package/dist/chunk-KM6LHB3Y.js +76 -0
- package/dist/chunk-OVQKKXLZ.js +3978 -0
- package/dist/chunk-Q3XQHLIH.js +7 -0
- package/dist/chunk-RHIDOUFL.js +698 -0
- package/dist/contributions-hX3EUyjG.d.ts +1528 -0
- package/dist/contributions-o1GRKPI_.d.cts +1528 -0
- package/dist/history-Dzxz-MQj.d.ts +298 -0
- package/dist/history-LJkz4-sS.d.cts +298 -0
- package/dist/icons-BZYbJXSV.d.cts +10 -0
- package/dist/icons-BZYbJXSV.d.ts +10 -0
- package/dist/index.cjs +25294 -0
- package/dist/index.d.cts +1189 -0
- package/dist/index.d.ts +1189 -0
- package/dist/index.js +5 -0
- package/dist/keymap-CGOz5F5f.d.cts +64 -0
- package/dist/keymap-CGOz5F5f.d.ts +64 -0
- package/dist/options-Q-576hIi.d.cts +1545 -0
- package/dist/options-Q-576hIi.d.ts +1545 -0
- package/dist/plugin-D94muTV-.d.cts +405 -0
- package/dist/plugin-aGUD1epn.d.ts +405 -0
- package/dist/plugin.cjs +5967 -0
- package/dist/plugin.d.cts +7 -0
- package/dist/plugin.d.ts +7 -0
- package/dist/plugin.js +3 -0
- package/dist/providers/binance.cjs +390 -0
- package/dist/providers/binance.d.cts +62 -0
- package/dist/providers/binance.d.ts +62 -0
- package/dist/providers/binance.js +388 -0
- package/dist/providers/coinbase.cjs +462 -0
- package/dist/providers/coinbase.d.cts +59 -0
- package/dist/providers/coinbase.d.ts +59 -0
- package/dist/providers/coinbase.js +460 -0
- package/dist/providers/hyperliquid.cjs +361 -0
- package/dist/providers/hyperliquid.d.cts +54 -0
- package/dist/providers/hyperliquid.d.ts +54 -0
- package/dist/providers/hyperliquid.js +359 -0
- package/dist/side-panel-CT9ZwIGz.d.cts +63 -0
- package/dist/side-panel-CT9ZwIGz.d.ts +63 -0
- package/dist/ui.cjs +1030 -0
- package/dist/ui.d.cts +188 -0
- package/dist/ui.d.ts +188 -0
- package/dist/ui.js +3 -0
- package/dist/vela.global.js +26509 -0
- package/dist/vela.global.min.js +230 -0
- package/dist/widget.cjs +31178 -0
- package/dist/widget.d.cts +799 -0
- package/dist/widget.d.ts +799 -0
- package/dist/widget.js +1060 -0
- package/dist/workspace.cjs +31692 -0
- package/dist/workspace.d.cts +579 -0
- package/dist/workspace.d.ts +579 -0
- package/dist/workspace.js +1694 -0
- package/package.json +95 -0
|
@@ -0,0 +1,1694 @@
|
|
|
1
|
+
import { WidgetHistory, prefixedSymbol, Watermark, Statusline, ChartContextMenu, statuslineInkOf, indicatorLedger, Glider, localStorageAdapter, decodeState, SymbolPicker, IndicatorPicker, TimeframeQuick, Topbar, PanelDock, ObjectTree, DataWindow, Toast, Bottombar, toolShortcutHints, resolveIndicators, sanitizeState, encodeState, ZOOM_IN, ZOOM_OUT, PAN_FAST, ShortcutsHelp } from './chunk-OVQKKXLZ.js';
|
|
2
|
+
export { decodeState, encodeState, sanitizeState } from './chunk-OVQKKXLZ.js';
|
|
3
|
+
import { parseSymbol, Vela, normalizeTimezone, TypedEventBus, MultiProviderFeed, resolveTheme, createCustomMark, createAttributionMark, DrawingToolbar, defaultToolbar, applyAttributionMarkTheme, sharedBarStore, timeframeToMs } from './chunk-KCCZNKH7.js';
|
|
4
|
+
import { resolveEngines, legendActionsProviderFor, rendererDefaults, widgetAttachments } from './chunk-7UFX5ZIG.js';
|
|
5
|
+
import { applyPlotOverlayTokens, ensureUIHost, KeymapManager, Menu, isEditableTarget } from './chunk-RHIDOUFL.js';
|
|
6
|
+
import './chunk-KM6LHB3Y.js';
|
|
7
|
+
import { registerIcon, svg16, injectStyles } from './chunk-GYD2THPV.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
|
+
data: opts.data,
|
|
43
|
+
visibleRange: opts.visibleRange
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function cellChartDefaults(opts) {
|
|
47
|
+
const { renderer, defaultLanguage, currentPriceLine, logScale, animations, glow, upColor, downColor, drawings } = opts;
|
|
48
|
+
return { renderer, defaultLanguage, currentPriceLine, logScale, animations, glow, upColor, downColor, drawings };
|
|
49
|
+
}
|
|
50
|
+
function cellDrawings(opt) {
|
|
51
|
+
if (opt === false) return false;
|
|
52
|
+
if (opt === true || opt == null) return { toolbar: false };
|
|
53
|
+
return { ...opt, toolbar: false };
|
|
54
|
+
}
|
|
55
|
+
var ChartCell = class {
|
|
56
|
+
constructor(id, gridHost, seed, deps) {
|
|
57
|
+
this.id = id;
|
|
58
|
+
this.deps = deps;
|
|
59
|
+
/** This cell's unified app+drawings undo timeline (the shared Ctrl+Z routes here). */
|
|
60
|
+
this.history = new WidgetHistory(() => this.inner);
|
|
61
|
+
/** Live manifest-indicator instances on this cell (the SAME entry may repeat). */
|
|
62
|
+
this.instances = [];
|
|
63
|
+
/** The native-indicator catalog with this cell's live supported/present flags. */
|
|
64
|
+
this.nativeCatalog = [];
|
|
65
|
+
/** Last crosshair position in this cell (the alt+H/alt+V shortcuts anchor here). */
|
|
66
|
+
this.lastCrossTime = null;
|
|
67
|
+
this.lastCrossPrice = null;
|
|
68
|
+
/** The bottombar range chip this cell is framed on (null = none). */
|
|
69
|
+
this.activeRangeId = null;
|
|
70
|
+
this.manifest = [];
|
|
71
|
+
/** A restored ledger's manifest entry NAMES, waiting for the manifest to resolve
|
|
72
|
+
* (a pool/persisted cell can be built before the shared manifest has loaded). */
|
|
73
|
+
this.pendingManifestNames = null;
|
|
74
|
+
/** The volume auto-add rides the cell's first candles (`load:end`); until then the
|
|
75
|
+
* registry can't show it and the dehydrated ledger reports the INTENT instead. */
|
|
76
|
+
this.volumeMayBePending = true;
|
|
77
|
+
/** Sync mirror of the chart's present native types — the removal handler diffs
|
|
78
|
+
* against it to identify (and record) whichever type was just removed. */
|
|
79
|
+
this.presentNatives = [];
|
|
80
|
+
this.rangeBars = 0;
|
|
81
|
+
this.pendingRange = null;
|
|
82
|
+
/** Indicator titles (this cell's in-chart legend rows) shown. */
|
|
83
|
+
this.indicatorTitlesOn = true;
|
|
84
|
+
this.destroyed = false;
|
|
85
|
+
this.appTheme = deps.theme;
|
|
86
|
+
const symbol = prefixedSymbol(seed);
|
|
87
|
+
this.state = { symbol, provider: parseSymbol(symbol ?? "").provider ?? void 0, timeframe: seed.timeframe, priceStyle: seed.priceStyle, bars: seed.bars };
|
|
88
|
+
const doc = gridHost.ownerDocument;
|
|
89
|
+
this.host = doc.createElement("div");
|
|
90
|
+
this.host.className = "vela-cell";
|
|
91
|
+
this.host.dataset.cellId = id;
|
|
92
|
+
this.host.style.cssText = "position:relative;overflow:hidden;";
|
|
93
|
+
this.host.addEventListener("pointerdown", () => this.deps.activate(id), true);
|
|
94
|
+
this.host.addEventListener("focusin", () => this.deps.activate(id));
|
|
95
|
+
gridHost.appendChild(this.host);
|
|
96
|
+
this.inner = new Vela(
|
|
97
|
+
this.host,
|
|
98
|
+
{
|
|
99
|
+
...deps.chartDefaults,
|
|
100
|
+
symbol,
|
|
101
|
+
timeframe: seed.timeframe,
|
|
102
|
+
bars: seed.bars,
|
|
103
|
+
priceStyle: seed.priceStyle,
|
|
104
|
+
data: seed.data,
|
|
105
|
+
visibleRange: seed.visibleRange,
|
|
106
|
+
theme: deps.theme,
|
|
107
|
+
live: deps.live,
|
|
108
|
+
// A RESTORED ledger is authoritative for the auto-added volume too: a
|
|
109
|
+
// slot persisted without it must come back without it (fresh slots
|
|
110
|
+
// keep the workspace default).
|
|
111
|
+
volume: seed.indicators ? seed.indicators.natives.includes("volume") : deps.volume,
|
|
112
|
+
nativeBackend: deps.nativeBackend,
|
|
113
|
+
// The user's drawings option minus its toolbar: one SHARED bar serves
|
|
114
|
+
// the whole workspace (per-cell bars would cost a 44px gutter each).
|
|
115
|
+
drawings: cellDrawings(deps.chartDefaults.drawings)
|
|
116
|
+
},
|
|
117
|
+
{ dataFeed: deps.feed }
|
|
118
|
+
);
|
|
119
|
+
for (const [language, make] of Object.entries(resolveEngines(deps.engines))) this.inner.registerEngine(language, make());
|
|
120
|
+
this.inner.renderer.set("attribution", false);
|
|
121
|
+
this.inner.renderer.set("dialogHost", deps.dialogHost);
|
|
122
|
+
this.inner.renderer.setLegendActions(legendActionsProviderFor(this.inner, () => deps.context()));
|
|
123
|
+
if (this.inner.renderer.supports("historyChords")) this.inner.renderer.set("historyChords", false);
|
|
124
|
+
this.history.onChart(this.inner);
|
|
125
|
+
this.inner.renderer.onConfigChanged(() => {
|
|
126
|
+
const zone = this.inner?.renderer.get("timezone");
|
|
127
|
+
if (typeof zone === "string" && normalizeTimezone(zone) !== normalizeTimezone(this.deps.timezone())) {
|
|
128
|
+
this.deps.setTimezone(normalizeTimezone(zone));
|
|
129
|
+
}
|
|
130
|
+
this.syncStatuslineColors();
|
|
131
|
+
this.syncPlotOverlayTokens();
|
|
132
|
+
});
|
|
133
|
+
this.inner.on("theme:changed", (t) => {
|
|
134
|
+
this.appTheme = t;
|
|
135
|
+
this.syncPlotOverlayTokens();
|
|
136
|
+
});
|
|
137
|
+
this.syncPlotOverlayTokens();
|
|
138
|
+
if (seed.rendererConfig != null) this.inner.renderer.applyConfig(seed.rendererConfig);
|
|
139
|
+
if (seed.drawings != null) this.inner.drawings.fromJSON(seed.drawings);
|
|
140
|
+
if (seed.indicators) {
|
|
141
|
+
for (const type of seed.indicators.natives) this.inner.addNativeIndicator(type);
|
|
142
|
+
this.pendingManifestNames = [...seed.indicators.manifest];
|
|
143
|
+
}
|
|
144
|
+
this.volumeIntent = seed.indicators ? seed.indicators.natives.includes("volume") : deps.volume;
|
|
145
|
+
this.inner.on("load:end", () => {
|
|
146
|
+
this.volumeMayBePending = false;
|
|
147
|
+
});
|
|
148
|
+
const tz = deps.timezone();
|
|
149
|
+
if (tz !== "Etc/UTC") this.inner.renderer.set("timezone", tz);
|
|
150
|
+
this.indicatorTitlesOn = seed.indicatorTitles ?? true;
|
|
151
|
+
if (!this.indicatorTitlesOn) this.inner.renderer.set("indicatorTitles", false);
|
|
152
|
+
this.watermarkOn = seed.watermark ?? deps.watermark;
|
|
153
|
+
this.watermark = deps.watermark ? new Watermark(this.host, symbol ?? "", seed.timeframe ?? "60") : null;
|
|
154
|
+
if (!this.watermarkOn) this.watermark?.setVisible(false);
|
|
155
|
+
this.statusline = deps.statusline ? new Statusline(this.host, symbol ?? "") : null;
|
|
156
|
+
this.statusline?.setMeta(seed.timeframe ?? "60", this.state.provider ?? "");
|
|
157
|
+
this.statusline?.onChart(this.inner);
|
|
158
|
+
this.syncStatuslineColors();
|
|
159
|
+
this.contextMenu = new ChartContextMenu(this.host, {
|
|
160
|
+
resetView: () => {
|
|
161
|
+
this.inner?.renderer.set("autoScale", true);
|
|
162
|
+
this.inner?.setVisibleRangePreset("ALL");
|
|
163
|
+
},
|
|
164
|
+
timezone: () => this.deps.timezone(),
|
|
165
|
+
setTimezone: (zone) => this.deps.setTimezone(zone),
|
|
166
|
+
// Right-clicking activates the cell first (capture-phase pointerdown), so the
|
|
167
|
+
// context the actions receive is this cell's — the active one.
|
|
168
|
+
getContext: () => this.deps.context()
|
|
169
|
+
});
|
|
170
|
+
this.contextMenu.onChart(this.inner);
|
|
171
|
+
this.inner.renderer.onCrosshairMove((e) => {
|
|
172
|
+
this.lastCrossTime = e.time;
|
|
173
|
+
this.lastCrossPrice = e.price;
|
|
174
|
+
});
|
|
175
|
+
this.inner.on("indicator:added", () => {
|
|
176
|
+
this.syncPresentNatives();
|
|
177
|
+
this.refreshNativeCatalog();
|
|
178
|
+
});
|
|
179
|
+
this.inner.on("indicator:removed", ({ id: id2 }) => {
|
|
180
|
+
if (this.destroyed) return;
|
|
181
|
+
const idx = this.instances.findIndex((it) => it.handle?.id === id2);
|
|
182
|
+
if (idx >= 0) {
|
|
183
|
+
const snapshot = this.instances[idx];
|
|
184
|
+
this.instances.splice(idx, 1);
|
|
185
|
+
this.history.push({
|
|
186
|
+
undo: () => {
|
|
187
|
+
snapshot.handle = this.addToChart(snapshot.entry);
|
|
188
|
+
this.instances.push(snapshot);
|
|
189
|
+
this.deps.onIndicatorsChanged(this.id);
|
|
190
|
+
},
|
|
191
|
+
redo: () => this.dropInstance(snapshot)
|
|
192
|
+
});
|
|
193
|
+
} else {
|
|
194
|
+
const now = this.inner?.presentNativeIndicators() ?? [];
|
|
195
|
+
for (const type of this.presentNatives.filter((t) => !now.includes(t))) {
|
|
196
|
+
this.history.push({
|
|
197
|
+
undo: () => {
|
|
198
|
+
this.inner?.addNativeIndicator(type);
|
|
199
|
+
this.refreshNativeCatalog();
|
|
200
|
+
},
|
|
201
|
+
redo: () => {
|
|
202
|
+
this.inner?.addNativeIndicator(type).remove();
|
|
203
|
+
this.refreshNativeCatalog();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
this.syncPresentNatives();
|
|
209
|
+
this.refreshNativeCatalog();
|
|
210
|
+
});
|
|
211
|
+
this.syncPresentNatives();
|
|
212
|
+
this.refreshNativeCatalog();
|
|
213
|
+
const advanced = {
|
|
214
|
+
title: "Advanced",
|
|
215
|
+
placement: "end",
|
|
216
|
+
rows: [
|
|
217
|
+
{
|
|
218
|
+
kind: "select",
|
|
219
|
+
label: "Bars to fetch",
|
|
220
|
+
options: ["500", "1000", "2000", "5000", "10000", "20000"],
|
|
221
|
+
get: () => String(this.state.bars ?? 1e3),
|
|
222
|
+
set: (v) => {
|
|
223
|
+
this.state.bars = Number(v);
|
|
224
|
+
this.deps.onStateDirty();
|
|
225
|
+
void this.inner?.setMarket({ bars: Math.max(this.state.bars, this.rangeBars) });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
]
|
|
229
|
+
};
|
|
230
|
+
const watermarkSection = {
|
|
231
|
+
title: "Watermark",
|
|
232
|
+
placement: "symbol",
|
|
233
|
+
rows: [
|
|
234
|
+
{
|
|
235
|
+
kind: "toggle",
|
|
236
|
+
label: "Symbol watermark",
|
|
237
|
+
get: () => this.watermarkOn,
|
|
238
|
+
set: (v) => this.setWatermarkVisible(v)
|
|
239
|
+
}
|
|
240
|
+
]
|
|
241
|
+
};
|
|
242
|
+
if (this.statusline) {
|
|
243
|
+
const sl = this.statusline;
|
|
244
|
+
this.inner.renderer.setSettingsSections([
|
|
245
|
+
{
|
|
246
|
+
title: "Status line",
|
|
247
|
+
rows: [
|
|
248
|
+
{ kind: "heading", label: "Status line" },
|
|
249
|
+
{ kind: "toggle", label: "Symbol name", get: () => sl.partVisible("name"), set: (v) => sl.setPartVisible("name", v) },
|
|
250
|
+
{ kind: "toggle", label: "Market status", get: () => sl.partVisible("market"), set: (v) => sl.setPartVisible("market", v) },
|
|
251
|
+
{ kind: "toggle", label: "OHLC values", get: () => sl.partVisible("ohlc"), set: (v) => sl.setPartVisible("ohlc", v) },
|
|
252
|
+
{ kind: "toggle", label: "Bar change values", get: () => sl.partVisible("change"), set: (v) => sl.setPartVisible("change", v) },
|
|
253
|
+
{ kind: "heading", label: "Indicators" },
|
|
254
|
+
{
|
|
255
|
+
kind: "toggle",
|
|
256
|
+
label: "Titles",
|
|
257
|
+
get: () => this.indicatorTitlesOn,
|
|
258
|
+
set: (v) => this.setIndicatorTitlesVisible(v)
|
|
259
|
+
}
|
|
260
|
+
]
|
|
261
|
+
},
|
|
262
|
+
advanced,
|
|
263
|
+
watermarkSection
|
|
264
|
+
]);
|
|
265
|
+
} else {
|
|
266
|
+
this.inner.renderer.setSettingsSections([advanced, watermarkSection]);
|
|
267
|
+
}
|
|
268
|
+
this.offMarket = this.inner.on("market:changed", ({ symbol: symbol2, timeframe }) => {
|
|
269
|
+
this.state.symbol = symbol2;
|
|
270
|
+
this.state.provider = parseSymbol(symbol2).provider ?? void 0;
|
|
271
|
+
this.state.timeframe = timeframe;
|
|
272
|
+
this.watermark?.update(symbol2, timeframe);
|
|
273
|
+
this.statusline?.setSymbol(symbol2);
|
|
274
|
+
this.statusline?.setMeta(timeframe, this.inner?.data.resolve(symbol2)?.provider ?? this.state.provider ?? "");
|
|
275
|
+
if (this.inner) this.statusline?.onChart(this.inner);
|
|
276
|
+
this.refreshNativeCatalog();
|
|
277
|
+
this.deps.onMarketChanged(this.id);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
/** Show/hide this cell's symbol watermark (persisted per cell). */
|
|
281
|
+
setWatermarkVisible(visible) {
|
|
282
|
+
this.watermarkOn = visible;
|
|
283
|
+
this.watermark?.setVisible(visible);
|
|
284
|
+
this.deps.onStateDirty();
|
|
285
|
+
}
|
|
286
|
+
/** Show/hide this cell's indicator titles — the in-chart legend rows (persisted per cell). */
|
|
287
|
+
setIndicatorTitlesVisible(visible) {
|
|
288
|
+
this.indicatorTitlesOn = visible;
|
|
289
|
+
this.inner?.renderer.set("indicatorTitles", visible);
|
|
290
|
+
this.deps.onStateDirty();
|
|
291
|
+
}
|
|
292
|
+
/** The LIVE chart of this cell — never cache it across a layout change (the cell's
|
|
293
|
+
* identity is what endures; the chart dies with the cell). */
|
|
294
|
+
get chart() {
|
|
295
|
+
if (!this.inner) throw new Error(`[vela] cell "${this.id}" is destroyed`);
|
|
296
|
+
return this.inner;
|
|
297
|
+
}
|
|
298
|
+
get symbol() {
|
|
299
|
+
return this.state.symbol ?? "";
|
|
300
|
+
}
|
|
301
|
+
get timeframe() {
|
|
302
|
+
return this.state.timeframe ?? "60";
|
|
303
|
+
}
|
|
304
|
+
get priceStyle() {
|
|
305
|
+
const live = this.inner?.renderer.get("priceStyle");
|
|
306
|
+
return typeof live === "string" ? live : this.state.priceStyle ?? "candles";
|
|
307
|
+
}
|
|
308
|
+
/** Manifest instances + present natives — the topbar indicator count. */
|
|
309
|
+
get indicatorCount() {
|
|
310
|
+
return this.instances.length + this.nativeCatalog.filter((n) => n.present).length;
|
|
311
|
+
}
|
|
312
|
+
/** Switch this cell's market in place (the chart instance survives). */
|
|
313
|
+
setSymbol(symbol) {
|
|
314
|
+
if (!this.inner || symbol === this.symbol) return;
|
|
315
|
+
void this.inner.setMarket({ symbol });
|
|
316
|
+
}
|
|
317
|
+
setTimeframe(timeframe) {
|
|
318
|
+
if (!this.inner || timeframe === this.timeframe) return;
|
|
319
|
+
this.activeRangeId = null;
|
|
320
|
+
this.rangeBars = 0;
|
|
321
|
+
void this.inner.setMarket({ timeframe, bars: this.state.bars });
|
|
322
|
+
}
|
|
323
|
+
/** Applied live (renderer feature) — no reload. */
|
|
324
|
+
setPriceStyle(style) {
|
|
325
|
+
this.state.priceStyle = style;
|
|
326
|
+
this.inner?.renderer.set("priceStyle", style);
|
|
327
|
+
this.syncStatuslineColors();
|
|
328
|
+
}
|
|
329
|
+
/** OHLC/change ink in the status line follows the ACTIVE price style's configured
|
|
330
|
+
* colors and direction rule (candle bodies by close-vs-open, baseline by position
|
|
331
|
+
* against the live baseline price, …) instead of the fixed theme tokens. */
|
|
332
|
+
syncStatuslineColors() {
|
|
333
|
+
if (!this.statusline || !this.inner) return;
|
|
334
|
+
this.statusline.setDirectionColors(...statuslineInkOf(this.inner.renderer, this.priceStyle));
|
|
335
|
+
}
|
|
336
|
+
/** The workspace shell keeps the app theme; the cell host's tokens re-derive from
|
|
337
|
+
* the LIVE plot surface (see {@link applyPlotOverlayTokens}). */
|
|
338
|
+
syncPlotOverlayTokens() {
|
|
339
|
+
applyPlotOverlayTokens(this.host, this.appTheme, this.inner?.renderer.getConfig() ?? null);
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Frame a bottombar range chip: switch to its timeframe, fetch the depth its window
|
|
343
|
+
* needs, and keep it framed (re-asserted once the deeper history is painted).
|
|
344
|
+
*/
|
|
345
|
+
applyRange(preset) {
|
|
346
|
+
if (!this.inner || this.destroyed) return;
|
|
347
|
+
this.activeRangeId = preset.id;
|
|
348
|
+
const tfChanged = preset.tf !== this.timeframe;
|
|
349
|
+
const deeper = preset.bars > Math.max(this.state.bars ?? 500, this.rangeBars);
|
|
350
|
+
this.rangeBars = preset.bars;
|
|
351
|
+
if (tfChanged || deeper) {
|
|
352
|
+
this.pendingRange = preset;
|
|
353
|
+
void this.inner.setMarket({ timeframe: preset.tf, bars: Math.max(this.state.bars ?? 500, this.rangeBars), visibleRange: preset.preset }).then(() => {
|
|
354
|
+
if (!this.destroyed && this.pendingRange === preset) {
|
|
355
|
+
this.inner?.setVisibleRangePreset(preset.preset);
|
|
356
|
+
this.pendingRange = null;
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
} else {
|
|
360
|
+
this.inner.setVisibleRangePreset(preset.preset);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/** Make this cell the active one and put keyboard focus on its chart surface. */
|
|
364
|
+
focus() {
|
|
365
|
+
this.deps.activate(this.id);
|
|
366
|
+
this.inner?.renderer.focus();
|
|
367
|
+
}
|
|
368
|
+
/** Download this cell's chart as a PNG (named after its market). */
|
|
369
|
+
downloadScreenshot() {
|
|
370
|
+
const url = this.inner?.renderer.screenshot();
|
|
371
|
+
if (!url) return;
|
|
372
|
+
const a = this.host.ownerDocument.createElement("a");
|
|
373
|
+
a.href = url;
|
|
374
|
+
a.download = `${this.symbol || "chart"}-${this.timeframe}.png`;
|
|
375
|
+
a.click();
|
|
376
|
+
}
|
|
377
|
+
// ── indicator ledger (shared manifest, per-cell instances) ──
|
|
378
|
+
/**
|
|
379
|
+
* Hand the cell the workspace's resolved manifest. A RESTORED ledger (pool or
|
|
380
|
+
* persisted state) re-adds its recorded entries by name — held until the manifest
|
|
381
|
+
* actually carries them. Otherwise `seedEnabled` auto-adds the manifest's `enabled`
|
|
382
|
+
* entries (fresh cells only).
|
|
383
|
+
*/
|
|
384
|
+
setManifest(list, seedEnabled) {
|
|
385
|
+
this.manifest = list;
|
|
386
|
+
if (this.pendingManifestNames) {
|
|
387
|
+
if (list.length === 0) return;
|
|
388
|
+
for (const name of this.pendingManifestNames) {
|
|
389
|
+
const entry = list.find((e) => e.name === name);
|
|
390
|
+
if (entry) this.addManifestInstance(entry, { record: false });
|
|
391
|
+
}
|
|
392
|
+
this.pendingManifestNames = null;
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (seedEnabled) {
|
|
396
|
+
for (const entry of list) if (entry.enabled) this.addManifestInstance(entry, { record: false });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/** The picker's library rows: supported natives first, then the manifest. */
|
|
400
|
+
libraryRows() {
|
|
401
|
+
return [
|
|
402
|
+
...this.nativeCatalog.filter((n) => n.supported).map((n) => ({ name: n.title, category: "Vela", native: true, nativeType: n.type, beta: n.beta })),
|
|
403
|
+
...this.manifest.map((e) => ({ name: e.name, language: e.language, category: e.category }))
|
|
404
|
+
];
|
|
405
|
+
}
|
|
406
|
+
/** The picker's on-chart rows: present natives first, then live instances. */
|
|
407
|
+
onChartRows() {
|
|
408
|
+
return [
|
|
409
|
+
...this.nativeCatalog.filter((n) => n.present).map((n) => ({ name: n.title, native: true, nativeType: n.type })),
|
|
410
|
+
...this.instances.map((it) => ({ name: it.entry.name, language: it.entry.language }))
|
|
411
|
+
];
|
|
412
|
+
}
|
|
413
|
+
/** Add by picker LIBRARY index (natives precede the manifest — mirrors libraryRows). */
|
|
414
|
+
addFromLibrary(index) {
|
|
415
|
+
const natives = this.nativeCatalog.filter((n) => n.supported);
|
|
416
|
+
if (index < natives.length) this.addNative(natives[index].type);
|
|
417
|
+
else {
|
|
418
|
+
const entry = this.manifest[index - natives.length];
|
|
419
|
+
if (entry) this.addManifestInstance(entry);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/** Remove by picker ON-CHART index (present natives precede instances). */
|
|
423
|
+
removeFromChart(index) {
|
|
424
|
+
const present = this.nativeCatalog.filter((n) => n.present);
|
|
425
|
+
if (index < present.length) this.removeNative(present[index].type);
|
|
426
|
+
else this.removeInstance(index - present.length);
|
|
427
|
+
}
|
|
428
|
+
/** Add ONE instance of a manifest entry (repeatable — duplicates are legitimate). */
|
|
429
|
+
addManifestInstance(entry, opts = {}) {
|
|
430
|
+
if (this.destroyed) return;
|
|
431
|
+
const it = { entry, handle: this.addToChart(entry) };
|
|
432
|
+
this.instances.push(it);
|
|
433
|
+
this.deps.onIndicatorsChanged(this.id);
|
|
434
|
+
if (opts.record === false) return;
|
|
435
|
+
const snapshot = it;
|
|
436
|
+
this.history.push({
|
|
437
|
+
undo: () => this.dropInstance(snapshot),
|
|
438
|
+
redo: () => {
|
|
439
|
+
snapshot.handle = this.addToChart(snapshot.entry);
|
|
440
|
+
this.instances.push(snapshot);
|
|
441
|
+
this.deps.onIndicatorsChanged(this.id);
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
removeInstance(index) {
|
|
446
|
+
const it = this.instances[index];
|
|
447
|
+
if (!it || this.destroyed) return;
|
|
448
|
+
this.dropInstance(it);
|
|
449
|
+
const snapshot = it;
|
|
450
|
+
this.history.push({
|
|
451
|
+
undo: () => {
|
|
452
|
+
snapshot.handle = this.addToChart(snapshot.entry);
|
|
453
|
+
this.instances.push(snapshot);
|
|
454
|
+
this.deps.onIndicatorsChanged(this.id);
|
|
455
|
+
},
|
|
456
|
+
redo: () => this.dropInstance(snapshot)
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
dropInstance(it) {
|
|
460
|
+
const idx = this.instances.indexOf(it);
|
|
461
|
+
if (idx >= 0) this.instances.splice(idx, 1);
|
|
462
|
+
try {
|
|
463
|
+
it.handle?.remove();
|
|
464
|
+
} catch {
|
|
465
|
+
}
|
|
466
|
+
it.handle = null;
|
|
467
|
+
this.deps.onIndicatorsChanged(this.id);
|
|
468
|
+
}
|
|
469
|
+
/** Add a native indicator (single-instance per type — the core dedupes). */
|
|
470
|
+
addNative(type) {
|
|
471
|
+
this.inner?.addNativeIndicator(type);
|
|
472
|
+
this.syncPresentNatives();
|
|
473
|
+
this.refreshNativeCatalog();
|
|
474
|
+
this.history.push({
|
|
475
|
+
undo: () => {
|
|
476
|
+
this.inner?.addNativeIndicator(type).remove();
|
|
477
|
+
this.refreshNativeCatalog();
|
|
478
|
+
},
|
|
479
|
+
redo: () => {
|
|
480
|
+
this.inner?.addNativeIndicator(type);
|
|
481
|
+
this.refreshNativeCatalog();
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
removeNative(type) {
|
|
486
|
+
this.inner?.addNativeIndicator(type).remove();
|
|
487
|
+
this.refreshNativeCatalog();
|
|
488
|
+
}
|
|
489
|
+
/** Sync mirror of the chart's present native types — the removal handler diffs
|
|
490
|
+
* against it to identify (and record) whichever type was just removed. */
|
|
491
|
+
syncPresentNatives() {
|
|
492
|
+
this.presentNatives = this.inner?.presentNativeIndicators() ?? [];
|
|
493
|
+
}
|
|
494
|
+
/** Refresh the native catalog (supported/present flags) for this cell's market. */
|
|
495
|
+
refreshNativeCatalog() {
|
|
496
|
+
const chart = this.inner;
|
|
497
|
+
if (!chart) return;
|
|
498
|
+
void chart.availableNativeIndicators().then((list) => {
|
|
499
|
+
if (this.destroyed || this.inner !== chart) return;
|
|
500
|
+
this.nativeCatalog = list.map((n) => ({ type: n.type, title: n.title, supported: n.supported, present: n.present, beta: n.beta }));
|
|
501
|
+
this.deps.onIndicatorsChanged(this.id);
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
addToChart(entry) {
|
|
505
|
+
try {
|
|
506
|
+
return this.inner?.addIndicator(entry.script, entry.language !== void 0 ? { language: entry.language } : void 0) ?? null;
|
|
507
|
+
} catch (err) {
|
|
508
|
+
console.warn(`[vela] indicator "${entry.name}" failed to add:`, err);
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// ── lifecycle ──
|
|
513
|
+
/** Snapshot everything the pool needs to restore this slot later. The market fields
|
|
514
|
+
* come from the LIVE config (`chart.market`) — the requested identity — so a switch
|
|
515
|
+
* still loading when the snapshot is taken (persist-on-close) is not lost. */
|
|
516
|
+
dehydrate() {
|
|
517
|
+
const live = this.inner?.market;
|
|
518
|
+
return {
|
|
519
|
+
...this.state,
|
|
520
|
+
...live ? { symbol: live.symbol, provider: live.provider, timeframe: live.timeframe } : {},
|
|
521
|
+
priceStyle: this.priceStyle,
|
|
522
|
+
watermark: this.watermarkOn,
|
|
523
|
+
indicatorTitles: this.indicatorTitlesOn,
|
|
524
|
+
rendererConfig: this.inner?.renderer.getConfig() ?? void 0,
|
|
525
|
+
drawings: this.inner ? this.inner.drawings.toJSON() : void 0,
|
|
526
|
+
// Natives from the chart's SYNC registry read — an async catalog mirror here
|
|
527
|
+
// lost unload-time saves, and the old empty-set fallbacks resurrected removed
|
|
528
|
+
// indicators. Manifest names fall back to the restored ledger only until the
|
|
529
|
+
// shared manifest settles. See {@link indicatorLedger}.
|
|
530
|
+
indicators: indicatorLedger({
|
|
531
|
+
present: this.inner ? this.inner.presentNativeIndicators() : [],
|
|
532
|
+
instanceNames: this.instances.map((it) => it.entry.name),
|
|
533
|
+
pendingManifest: this.pendingManifestNames,
|
|
534
|
+
manifestSettled: this.deps.manifestSettled(),
|
|
535
|
+
volumePending: this.volumeMayBePending && this.volumeIntent
|
|
536
|
+
})
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
destroy() {
|
|
540
|
+
this.destroyed = true;
|
|
541
|
+
this.offMarket();
|
|
542
|
+
this.contextMenu.destroy();
|
|
543
|
+
this.history.destroy();
|
|
544
|
+
this.statusline?.destroy();
|
|
545
|
+
this.watermark?.destroy();
|
|
546
|
+
this.inner?.destroy();
|
|
547
|
+
this.inner = null;
|
|
548
|
+
this.host.remove();
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
// src/workspace/context.ts
|
|
553
|
+
function buildContext(host) {
|
|
554
|
+
const active = host.active();
|
|
555
|
+
return {
|
|
556
|
+
get chart() {
|
|
557
|
+
const cell = host.active();
|
|
558
|
+
if (!cell) throw new Error("VelaWorkspace has no active cell yet");
|
|
559
|
+
return cell.chart;
|
|
560
|
+
},
|
|
561
|
+
symbol: active?.symbol ?? "",
|
|
562
|
+
timeframe: active?.timeframe ?? "60",
|
|
563
|
+
priceStyle: active?.priceStyle ?? "candles",
|
|
564
|
+
setSymbol: (symbol) => host.active()?.setSymbol(symbol),
|
|
565
|
+
setTimeframe: (tf) => host.active()?.setTimeframe(tf),
|
|
566
|
+
setPriceStyle: (style) => host.active()?.setPriceStyle(style),
|
|
567
|
+
openSymbolSearch: (query) => host.openSymbolSearch(query),
|
|
568
|
+
togglePanel: (id, open) => host.togglePanel(id, open),
|
|
569
|
+
host: host.root,
|
|
570
|
+
toast: (message, kind) => host.toast(message, kind),
|
|
571
|
+
cells: host.cells().map((c) => ({ id: c.id, chart: c.chart, symbol: c.symbol, timeframe: c.timeframe })),
|
|
572
|
+
activeCellId: active?.id ?? "",
|
|
573
|
+
setActiveCell: (id) => host.setActiveCell(id)
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// src/workspace/layouts.ts
|
|
578
|
+
var registry = /* @__PURE__ */ new Map();
|
|
579
|
+
function registerLayout(def) {
|
|
580
|
+
registry.set(def.id, def);
|
|
581
|
+
}
|
|
582
|
+
function unregisterLayout(id) {
|
|
583
|
+
registry.delete(id);
|
|
584
|
+
}
|
|
585
|
+
function layoutDefinition(id) {
|
|
586
|
+
return registry.get(id);
|
|
587
|
+
}
|
|
588
|
+
function layouts() {
|
|
589
|
+
return [...registry.values()];
|
|
590
|
+
}
|
|
591
|
+
function slots(n) {
|
|
592
|
+
return Array.from({ length: n }, (_, i) => ({ id: `c${i + 1}` }));
|
|
593
|
+
}
|
|
594
|
+
function registerBuiltinLayouts() {
|
|
595
|
+
registerLayout({ id: "1", label: "Single", cols: [1], rows: [1], cells: slots(1) });
|
|
596
|
+
registerLayout({ id: "2h", label: "2 side by side", cols: [1, 1], rows: [1], cells: slots(2) });
|
|
597
|
+
registerLayout({ id: "2v", label: "2 stacked", cols: [1], rows: [1, 1], cells: slots(2) });
|
|
598
|
+
registerLayout({ id: "4", label: "4 grid", cols: [1, 1], rows: [1, 1], cells: slots(4) });
|
|
599
|
+
registerLayout({ id: "8", label: "8 grid", cols: [1, 1, 1, 1], rows: [1, 1], cells: slots(8) });
|
|
600
|
+
}
|
|
601
|
+
function gridStyles(def, trackSizes) {
|
|
602
|
+
const cols = trackSizes?.cols?.length === def.cols.length ? trackSizes.cols : def.cols;
|
|
603
|
+
const rows = trackSizes?.rows?.length === def.rows.length ? trackSizes.rows : def.rows;
|
|
604
|
+
const container = {
|
|
605
|
+
display: "grid",
|
|
606
|
+
gridTemplateColumns: cols.map((w) => `${w}fr`).join(" "),
|
|
607
|
+
gridTemplateRows: rows.map((w) => `${w}fr`).join(" ")
|
|
608
|
+
};
|
|
609
|
+
if (def.areas) container.gridTemplateAreas = def.areas.map((r) => `"${r}"`).join(" ");
|
|
610
|
+
const perCell = {};
|
|
611
|
+
for (const cell of def.cells) {
|
|
612
|
+
perCell[cell.id] = cell.area ? { gridArea: cell.area } : {};
|
|
613
|
+
}
|
|
614
|
+
return { container, perCell };
|
|
615
|
+
}
|
|
616
|
+
function activeAfterLayout(current, cellIds) {
|
|
617
|
+
if (current != null && cellIds.includes(current)) return current;
|
|
618
|
+
return cellIds[0] ?? null;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// src/workspace/splitters.ts
|
|
622
|
+
var MIN_TRACK_FRAC = 0.1;
|
|
623
|
+
function evenTracks(n) {
|
|
624
|
+
return Array.from({ length: n }, () => 1);
|
|
625
|
+
}
|
|
626
|
+
function resizeTracks(weights, index, deltaPx, sizePx) {
|
|
627
|
+
const out = [...weights];
|
|
628
|
+
const a = out[index];
|
|
629
|
+
const b = out[index + 1];
|
|
630
|
+
if (a === void 0 || b === void 0 || sizePx <= 0) return out;
|
|
631
|
+
const total = out.reduce((s, w) => s + w, 0);
|
|
632
|
+
const min = total * MIN_TRACK_FRAC;
|
|
633
|
+
let deltaFr = deltaPx / sizePx * total;
|
|
634
|
+
deltaFr = Math.max(deltaFr, min - a);
|
|
635
|
+
deltaFr = Math.min(deltaFr, b - min);
|
|
636
|
+
out[index] = a + deltaFr;
|
|
637
|
+
out[index + 1] = b - deltaFr;
|
|
638
|
+
return out;
|
|
639
|
+
}
|
|
640
|
+
function trackOffsets(weights, sizePx, gapPx) {
|
|
641
|
+
const total = weights.reduce((s, w) => s + w, 0);
|
|
642
|
+
if (total <= 0) return [];
|
|
643
|
+
const content = sizePx - gapPx * (weights.length - 1);
|
|
644
|
+
const out = [];
|
|
645
|
+
let acc = 0;
|
|
646
|
+
for (let i = 0; i < weights.length - 1; i += 1) {
|
|
647
|
+
acc += weights[i] / total * content;
|
|
648
|
+
out.push(acc + gapPx * i + gapPx / 2);
|
|
649
|
+
}
|
|
650
|
+
return out;
|
|
651
|
+
}
|
|
652
|
+
var HIT_PX = 8;
|
|
653
|
+
var SplitterLayer = class {
|
|
654
|
+
constructor(container, deps) {
|
|
655
|
+
this.container = container;
|
|
656
|
+
this.deps = deps;
|
|
657
|
+
this.strips = [];
|
|
658
|
+
this.drag = null;
|
|
659
|
+
}
|
|
660
|
+
/** Rebuild + reposition the strips for the current tracks/size. */
|
|
661
|
+
layout() {
|
|
662
|
+
const { cols, rows } = this.deps.tracks();
|
|
663
|
+
const rect = this.container.getBoundingClientRect();
|
|
664
|
+
const gap = this.deps.gapPx();
|
|
665
|
+
const wanted = Math.max(0, cols.length - 1) + Math.max(0, rows.length - 1);
|
|
666
|
+
while (this.strips.length > wanted) this.strips.pop().remove();
|
|
667
|
+
while (this.strips.length < wanted) this.strips.push(this.makeStrip());
|
|
668
|
+
let k = 0;
|
|
669
|
+
for (const [i, x] of trackOffsets(cols, rect.width, gap).entries()) {
|
|
670
|
+
this.place(this.strips[k], "cols", i, `left:${Math.round(x - HIT_PX / 2)}px;top:0;width:${HIT_PX}px;height:100%;cursor:col-resize;`);
|
|
671
|
+
k += 1;
|
|
672
|
+
}
|
|
673
|
+
for (const [i, y] of trackOffsets(rows, rect.height, gap).entries()) {
|
|
674
|
+
this.place(this.strips[k], "rows", i, `left:0;top:${Math.round(y - HIT_PX / 2)}px;width:100%;height:${HIT_PX}px;cursor:row-resize;`);
|
|
675
|
+
k += 1;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
destroy() {
|
|
679
|
+
for (const s of this.strips.splice(0)) s.remove();
|
|
680
|
+
this.drag = null;
|
|
681
|
+
}
|
|
682
|
+
makeStrip() {
|
|
683
|
+
const el = document.createElement("div");
|
|
684
|
+
el.className = "vela-ws-splitter";
|
|
685
|
+
el.style.cssText = "position:absolute;z-index:30;";
|
|
686
|
+
el.addEventListener("pointerdown", (e) => this.onDown(el, e));
|
|
687
|
+
el.addEventListener("dblclick", () => {
|
|
688
|
+
const axis = el.dataset.axis;
|
|
689
|
+
if (axis) this.deps.reset(axis);
|
|
690
|
+
});
|
|
691
|
+
this.container.appendChild(el);
|
|
692
|
+
return el;
|
|
693
|
+
}
|
|
694
|
+
place(el, axis, index, css) {
|
|
695
|
+
el.dataset.axis = axis;
|
|
696
|
+
el.dataset.index = String(index);
|
|
697
|
+
el.style.cssText = `position:absolute;z-index:30;${css}`;
|
|
698
|
+
}
|
|
699
|
+
onDown(el, e) {
|
|
700
|
+
const axis = el.dataset.axis;
|
|
701
|
+
const index = Number(el.dataset.index);
|
|
702
|
+
if (!axis || Number.isNaN(index)) return;
|
|
703
|
+
e.preventDefault();
|
|
704
|
+
const rect = this.container.getBoundingClientRect();
|
|
705
|
+
const gap = this.deps.gapPx();
|
|
706
|
+
const tracks = this.deps.tracks()[axis];
|
|
707
|
+
this.drag = {
|
|
708
|
+
axis,
|
|
709
|
+
index,
|
|
710
|
+
startPx: axis === "cols" ? e.clientX : e.clientY,
|
|
711
|
+
startWeights: [...tracks],
|
|
712
|
+
sizePx: (axis === "cols" ? rect.width : rect.height) - gap * (tracks.length - 1)
|
|
713
|
+
};
|
|
714
|
+
try {
|
|
715
|
+
el.setPointerCapture(e.pointerId);
|
|
716
|
+
} catch {
|
|
717
|
+
}
|
|
718
|
+
const move = (ev) => {
|
|
719
|
+
const d = this.drag;
|
|
720
|
+
if (!d) return;
|
|
721
|
+
const delta = (d.axis === "cols" ? ev.clientX : ev.clientY) - d.startPx;
|
|
722
|
+
this.deps.apply(d.axis, resizeTracks(d.startWeights, d.index, delta, d.sizePx));
|
|
723
|
+
};
|
|
724
|
+
const up = () => {
|
|
725
|
+
this.drag = null;
|
|
726
|
+
el.removeEventListener("pointermove", move);
|
|
727
|
+
el.removeEventListener("pointerup", up);
|
|
728
|
+
el.removeEventListener("pointercancel", up);
|
|
729
|
+
};
|
|
730
|
+
el.addEventListener("pointermove", move);
|
|
731
|
+
el.addEventListener("pointerup", up);
|
|
732
|
+
el.addEventListener("pointercancel", up);
|
|
733
|
+
}
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
// src/workspace/VelaWorkspace.ts
|
|
737
|
+
var DEFAULT_TIMEFRAMES = ["1", "5", "15", "60", "240", "D", "W"];
|
|
738
|
+
var GAP_PX = 2;
|
|
739
|
+
var POOL_CAP = 16;
|
|
740
|
+
var TIME_AXIS_H = 22;
|
|
741
|
+
var ALERT_CAP = 50;
|
|
742
|
+
var STYLE_ID = "vela-workspace";
|
|
743
|
+
var CSS = `
|
|
744
|
+
.vela-workspace { position: relative; width: 100%; height: 100%; display: flex; flex-direction: column; background: var(--vela-bg); }
|
|
745
|
+
.vela-ws-main { position: relative; display: flex; flex-direction: row; flex: 1 1 auto; min-height: 0; }
|
|
746
|
+
.vela-ws-toolbar { position: relative; flex: none; }
|
|
747
|
+
.vela-ws-grid { position: relative; flex: 1 1 auto; min-width: 0; display: grid; gap: ${GAP_PX}px; background: var(--vela-border-soft); }
|
|
748
|
+
.vela-cell { background: var(--vela-bg); position: relative; }
|
|
749
|
+
/* Active-cell highlight: an overlay ring ABOVE the chart's own canvas stack (a plain
|
|
750
|
+
outline on the cell is painted under them) \u2014 inert to the pointer. */
|
|
751
|
+
.vela-cell[data-active='1']::after {
|
|
752
|
+
content: '';
|
|
753
|
+
position: absolute;
|
|
754
|
+
inset: 0;
|
|
755
|
+
border: 2px solid var(--vela-fg-bright);
|
|
756
|
+
pointer-events: none;
|
|
757
|
+
z-index: 10;
|
|
758
|
+
}
|
|
759
|
+
.vela-ws-splitter:hover { background: var(--vela-accent); opacity: 0.35; }
|
|
760
|
+
`;
|
|
761
|
+
registerIcon("layout", svg16('<rect x="1.5" y="1.5" width="13" height="13" rx="1.5"/><path d="M8 1.5v13M1.5 8h13"/>'));
|
|
762
|
+
function declaredOrder(cells) {
|
|
763
|
+
const names = Object.keys(cells ?? {});
|
|
764
|
+
for (const n of names) {
|
|
765
|
+
if (/^\d+$/.test(n)) {
|
|
766
|
+
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}"`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return names.filter((n) => !/^\d+$/.test(n));
|
|
770
|
+
}
|
|
771
|
+
function nextAutoCellId(taken) {
|
|
772
|
+
for (let i = 1; ; i += 1) {
|
|
773
|
+
if (!taken.has(`c${i}`)) return `c${i}`;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
var VelaWorkspace = class {
|
|
777
|
+
constructor(container, opts = {}) {
|
|
778
|
+
this.events = new TypedEventBus();
|
|
779
|
+
this.feed = new MultiProviderFeed();
|
|
780
|
+
this.cellsById = /* @__PURE__ */ new Map();
|
|
781
|
+
this.pool = /* @__PURE__ */ new Map();
|
|
782
|
+
this.trackSizes = /* @__PURE__ */ new Map();
|
|
783
|
+
this.resizeObserver = null;
|
|
784
|
+
/** Cell identities by SLOT POSITION — `order[i]` lives in the layout's i-th slot.
|
|
785
|
+
* Names come from the `cells` declaration order (then the persisted document);
|
|
786
|
+
* slots beyond the list get auto identities. Grows, never reorders. */
|
|
787
|
+
this.order = [];
|
|
788
|
+
this.activeId = null;
|
|
789
|
+
this.cellBackend = "auto";
|
|
790
|
+
this.destroyed = false;
|
|
791
|
+
this.shortcutsHelp = null;
|
|
792
|
+
this.glider = new Glider(() => this.activeId ? this.cellsById.get(this.activeId)?.chart ?? null : null);
|
|
793
|
+
/** The GLOBAL armed tool/magnet/stay (workspace policy) — re-applied to whichever cell
|
|
794
|
+
* takes the focus; only the ACTIVE cell ever holds a non-null tool. Measure/eraser
|
|
795
|
+
* stay transient and per-cell: they exit when the focus leaves. */
|
|
796
|
+
this.globalTool = null;
|
|
797
|
+
this.globalSnap = "off";
|
|
798
|
+
this.globalStay = false;
|
|
799
|
+
/** Live subscription to the ACTIVE cell's unified history (rebound on every projection). */
|
|
800
|
+
this.historyUnsub = null;
|
|
801
|
+
/** Favorite drawing tools — a WORKSPACE preference (one star set, every cell). */
|
|
802
|
+
this.favs = [];
|
|
803
|
+
/** Live sync configuration (mutable copy of the option). */
|
|
804
|
+
this.syncOpts = {};
|
|
805
|
+
this.stateTimer = null;
|
|
806
|
+
this.onUnload = () => this.persistNow();
|
|
807
|
+
/** Re-entrance guard around one propagation tick: followers' synchronous echoes
|
|
808
|
+
* (their setVisibleRange re-emits viewport:changed) must not re-propagate. */
|
|
809
|
+
this.syncBusy = false;
|
|
810
|
+
this.manifest = [];
|
|
811
|
+
/** The shared manifest can no longer change instance sets — resolved, or no
|
|
812
|
+
* `indicators` option so nothing ever will. Gates the cells' ledger fallback. */
|
|
813
|
+
this.manifestSettled = false;
|
|
814
|
+
this.openDialogs = 0;
|
|
815
|
+
this.alerts = [];
|
|
816
|
+
this.alertsMenu = null;
|
|
817
|
+
this.attachmentDisposers = /* @__PURE__ */ new Map();
|
|
818
|
+
/** The single grid-wide attribution mark — re-inked on a live theme swap. */
|
|
819
|
+
this.attributionMark = null;
|
|
820
|
+
this.onRootKeydown = (ev) => this.routeTyping(ev);
|
|
821
|
+
/** The sync-link control surface: `set(kind, true | {cellId: group} | false)`,
|
|
822
|
+
* `get(kind)`, `state()`. Enabling a market/viewport link aligns the followers to
|
|
823
|
+
* the ACTIVE cell once, so the grid starts coherent; `crosshair` mirrors the
|
|
824
|
+
* pointer time as ghost crosshairs on the followers (also a toggle in the layout
|
|
825
|
+
* dropdown). */
|
|
826
|
+
this.sync = {
|
|
827
|
+
set: (kind, setting) => this.applySyncSetting(kind, setting === false ? void 0 : setting, true),
|
|
828
|
+
get: (kind) => this.syncOpts[kind],
|
|
829
|
+
state: () => ({ ...this.syncOpts })
|
|
830
|
+
};
|
|
831
|
+
registerBuiltinLayouts();
|
|
832
|
+
const hostEl = typeof container === "string" ? document.querySelector(container) : container;
|
|
833
|
+
if (!hostEl) throw new Error(`VelaWorkspace: container not found: ${String(container)}`);
|
|
834
|
+
this.opts = opts;
|
|
835
|
+
this.persistKey = opts.persist === void 0 || opts.persist === false ? null : opts.persist === true ? "vela-workspace" : opts.persist;
|
|
836
|
+
this.storage = opts.storage ?? localStorageAdapter();
|
|
837
|
+
let boot = null;
|
|
838
|
+
if (this.persistKey !== null) {
|
|
839
|
+
const raw = this.storage.get(this.persistKey);
|
|
840
|
+
if (typeof raw === "string") boot = decodeState(raw);
|
|
841
|
+
else if (raw != null && typeof raw === "object") {
|
|
842
|
+
void raw.then((r) => {
|
|
843
|
+
if (!this.destroyed && r) this.applyState(decodeState(r));
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
if (typeof window !== "undefined") window.addEventListener("beforeunload", this.onUnload);
|
|
847
|
+
}
|
|
848
|
+
this.timezone = boot?.timezone ?? opts.timezone ?? "Etc/UTC";
|
|
849
|
+
if (boot?.favorites) this.favs = [...boot.favorites];
|
|
850
|
+
const sync = boot?.sync ?? opts.sync;
|
|
851
|
+
for (const kind of ["viewport", "symbol", "timeframe", "crosshair"]) {
|
|
852
|
+
this.applySyncSetting(kind, sync?.[kind]);
|
|
853
|
+
}
|
|
854
|
+
this.def = this.resolveLayout(boot?.layout && layoutDefinition(boot.layout) ? boot.layout : opts.layout ?? "4");
|
|
855
|
+
if (boot?.trackSizes) for (const [id, ts] of Object.entries(boot.trackSizes)) this.trackSizes.set(id, ts);
|
|
856
|
+
if (boot?.charts) for (const { id, ...cs } of boot.charts) this.pool.set(id, cs);
|
|
857
|
+
this.order = boot?.charts ? boot.charts.map((c) => c.id) : declaredOrder(opts.cells);
|
|
858
|
+
const bootActive = boot?.activeCellId ?? null;
|
|
859
|
+
const doc = hostEl.ownerDocument;
|
|
860
|
+
injectStyles(STYLE_ID, CSS, doc);
|
|
861
|
+
this.root = doc.createElement("div");
|
|
862
|
+
this.root.className = "vela-workspace";
|
|
863
|
+
ensureUIHost(this.root, resolveTheme(opts.theme));
|
|
864
|
+
for (const [name, make] of Object.entries(opts.providers ?? {})) void this.feed.registerProvider(name, make());
|
|
865
|
+
void this.feed.ready().then(() => {
|
|
866
|
+
if (!this.destroyed) this.refreshRetention();
|
|
867
|
+
});
|
|
868
|
+
this.symbolPicker = new SymbolPicker({
|
|
869
|
+
host: this.root,
|
|
870
|
+
onSelect: (ticker) => this.active.setSymbol(ticker),
|
|
871
|
+
onOpenChange: (open) => {
|
|
872
|
+
if (open) for (const cell of this.cells()) cell.chart.renderer.closeDialogs();
|
|
873
|
+
this.trackDialog(open);
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
this.symbolPicker.setSource(() => this.feed.symbols());
|
|
877
|
+
this.indicatorPicker = new IndicatorPicker({
|
|
878
|
+
host: this.root,
|
|
879
|
+
library: () => this.active.libraryRows(),
|
|
880
|
+
onChart: () => this.active.onChartRows(),
|
|
881
|
+
onAdd: (i) => this.active.addFromLibrary(i),
|
|
882
|
+
onRemove: (i) => this.active.removeFromChart(i),
|
|
883
|
+
onOpenChange: (open) => this.trackDialog(open)
|
|
884
|
+
});
|
|
885
|
+
this.tfQuick = new TimeframeQuick({
|
|
886
|
+
host: this.root,
|
|
887
|
+
onApply: (tf) => this.setActiveTimeframe(tf),
|
|
888
|
+
onOpenChange: (open) => this.trackDialog(open)
|
|
889
|
+
});
|
|
890
|
+
this.topbar = new Topbar(this.root, {
|
|
891
|
+
symbol: "",
|
|
892
|
+
onSymbolClick: () => this.symbolPicker.open(),
|
|
893
|
+
onIndicatorsClick: () => this.indicatorPicker.open(),
|
|
894
|
+
onUndoClick: () => this.active.history.undo(),
|
|
895
|
+
onRedoClick: () => this.active.history.redo(),
|
|
896
|
+
onScreenshotClick: () => this.active.downloadScreenshot(),
|
|
897
|
+
onAlertsClick: (anchor) => this.openAlertsMenu(anchor),
|
|
898
|
+
timeframe: "60",
|
|
899
|
+
timeframes: opts.timeframes ?? DEFAULT_TIMEFRAMES,
|
|
900
|
+
priceStyle: "candles",
|
|
901
|
+
onTimeframe: (tf) => this.setActiveTimeframe(tf),
|
|
902
|
+
onPriceStyle: (style) => this.active.setPriceStyle(style),
|
|
903
|
+
layout: {
|
|
904
|
+
current: this.def.id,
|
|
905
|
+
options: () => layouts().map((l) => ({ id: l.id, label: l.label })),
|
|
906
|
+
onSelect: (id) => this.setLayout(id),
|
|
907
|
+
// Workspace-wide view toggles live under the grid presets. The check
|
|
908
|
+
// reflects the simple all-cells form; flipping OVERRIDES a host-set
|
|
909
|
+
// group record with plain on/off (groups stay an API-only shape).
|
|
910
|
+
toggles: () => [{ id: "crosshair-sync", label: "Sync crosshair", checked: this.syncOpts.crosshair === true }],
|
|
911
|
+
onToggle: (id) => {
|
|
912
|
+
if (id === "crosshair-sync") this.sync.set("crosshair", this.syncOpts.crosshair ? false : true);
|
|
913
|
+
}
|
|
914
|
+
},
|
|
915
|
+
getContext: () => this.context()
|
|
916
|
+
});
|
|
917
|
+
const main = doc.createElement("div");
|
|
918
|
+
main.className = "vela-ws-main";
|
|
919
|
+
let toolbarHost = null;
|
|
920
|
+
if (opts.drawingToolbar !== false) {
|
|
921
|
+
toolbarHost = doc.createElement("div");
|
|
922
|
+
toolbarHost.className = "vela-ws-toolbar";
|
|
923
|
+
main.appendChild(toolbarHost);
|
|
924
|
+
}
|
|
925
|
+
this.gridEl = doc.createElement("div");
|
|
926
|
+
this.gridEl.className = "vela-ws-grid";
|
|
927
|
+
main.appendChild(this.gridEl);
|
|
928
|
+
this.dock = new PanelDock(main, {
|
|
929
|
+
chrome: this.topbar,
|
|
930
|
+
context: () => this.context(),
|
|
931
|
+
changed: () => this.markStateDirty()
|
|
932
|
+
});
|
|
933
|
+
this.objectTree = new ObjectTree(main);
|
|
934
|
+
this.dataWindow = new DataWindow(main);
|
|
935
|
+
this.dock.addBuiltIn({ id: "dataWindow", title: "Data window", icon: "datawindow", order: 10, panel: this.dataWindow, onChart: (c) => this.dataWindow.onChart(c) });
|
|
936
|
+
this.dock.addBuiltIn({ id: "objects", title: "Object tree", icon: "objects", order: 20, panel: this.objectTree, onChart: (c) => this.objectTree.onChart(c) });
|
|
937
|
+
this.dock.refresh();
|
|
938
|
+
this.root.appendChild(main);
|
|
939
|
+
this.toast = new Toast(this.gridEl);
|
|
940
|
+
const attribution = rendererDefaults().attribution;
|
|
941
|
+
if (attribution !== false) {
|
|
942
|
+
const background = resolveTheme(opts.theme).background;
|
|
943
|
+
const mark = typeof attribution === "string" && attribution.trim() ? createCustomMark(doc, attribution, background) : createAttributionMark(doc, background);
|
|
944
|
+
Object.assign(mark.style, { left: "12px", bottom: `${TIME_AXIS_H + 10}px`, zIndex: "11" });
|
|
945
|
+
this.gridEl.appendChild(mark);
|
|
946
|
+
this.attributionMark = mark;
|
|
947
|
+
}
|
|
948
|
+
this.drawToolbar = toolbarHost ? new DrawingToolbar(
|
|
949
|
+
toolbarHost,
|
|
950
|
+
resolveTheme(opts.theme),
|
|
951
|
+
(type) => {
|
|
952
|
+
this.active.chart.drawings.setTool(type);
|
|
953
|
+
this.refocusActive();
|
|
954
|
+
},
|
|
955
|
+
(mode) => {
|
|
956
|
+
this.active.chart.drawings.setSnapMode(mode);
|
|
957
|
+
this.refocusActive();
|
|
958
|
+
},
|
|
959
|
+
() => {
|
|
960
|
+
const d = this.active.chart.drawings;
|
|
961
|
+
d.setMode(d.getMode() === "measure" ? null : "measure");
|
|
962
|
+
this.refocusActive();
|
|
963
|
+
},
|
|
964
|
+
() => {
|
|
965
|
+
const d = this.active.chart.drawings;
|
|
966
|
+
d.setMode(d.getMode() === "eraser" ? null : "eraser");
|
|
967
|
+
this.refocusActive();
|
|
968
|
+
},
|
|
969
|
+
// No refocus on a star: the flyout stays open for more browsing.
|
|
970
|
+
(type, on) => this.active.chart.drawings.setFavorite(type, on),
|
|
971
|
+
(on) => {
|
|
972
|
+
this.active.chart.drawings.setStayMode(on);
|
|
973
|
+
this.refocusActive();
|
|
974
|
+
},
|
|
975
|
+
{ dock: "static" }
|
|
976
|
+
) : null;
|
|
977
|
+
this.drawToolbar?.setDefinition(defaultToolbar());
|
|
978
|
+
this.drawToolbar?.setVisible(true);
|
|
979
|
+
this.bottombar = opts.bottombar !== false ? new Bottombar(this.root, {
|
|
980
|
+
timezone: this.timezone,
|
|
981
|
+
onRange: (preset) => {
|
|
982
|
+
this.active.applyRange(preset);
|
|
983
|
+
this.bottombar?.setActiveRange(preset.id);
|
|
984
|
+
},
|
|
985
|
+
onTimezone: (zone) => this.setTimezone(zone),
|
|
986
|
+
onSettingsClick: () => this.active.chart.renderer.openSettings()
|
|
987
|
+
}) : null;
|
|
988
|
+
hostEl.appendChild(this.root);
|
|
989
|
+
this.splitters = new SplitterLayer(this.gridEl, {
|
|
990
|
+
tracks: () => this.currentTracks(),
|
|
991
|
+
apply: (axis, weights) => this.applyTracks(axis, weights),
|
|
992
|
+
reset: (axis) => this.applyTracks(axis, evenTracks(this.currentTracks()[axis].length)),
|
|
993
|
+
gapPx: () => GAP_PX
|
|
994
|
+
});
|
|
995
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
996
|
+
this.resizeObserver = new ResizeObserver(() => this.splitters.layout());
|
|
997
|
+
this.resizeObserver.observe(this.gridEl);
|
|
998
|
+
}
|
|
999
|
+
this.keymap = new KeymapManager();
|
|
1000
|
+
this.keymap.attach(this.root);
|
|
1001
|
+
this.registerDefaultKeys();
|
|
1002
|
+
this.drawToolbar?.setShortcuts(toolShortcutHints(this.keymap));
|
|
1003
|
+
this.root.addEventListener("keydown", this.onRootKeydown);
|
|
1004
|
+
this.root.tabIndex = -1;
|
|
1005
|
+
this.cellBackend = this.backendFor(this.def);
|
|
1006
|
+
this.applyGrid();
|
|
1007
|
+
this.buildCells();
|
|
1008
|
+
this.setActiveCell(bootActive != null && this.cellsById.has(bootActive) ? bootActive : this.order[0] ?? null);
|
|
1009
|
+
if (opts.autofocus) this.refocusActive();
|
|
1010
|
+
if (opts.indicators !== void 0) {
|
|
1011
|
+
void resolveIndicators(opts.indicators).then((list) => {
|
|
1012
|
+
if (this.destroyed) return;
|
|
1013
|
+
this.manifest = list;
|
|
1014
|
+
this.manifestSettled = true;
|
|
1015
|
+
for (const cell of this.cellsById.values()) cell.setManifest(list, true);
|
|
1016
|
+
this.projectActiveCell();
|
|
1017
|
+
});
|
|
1018
|
+
} else {
|
|
1019
|
+
this.manifestSettled = true;
|
|
1020
|
+
}
|
|
1021
|
+
this.mountAttachments();
|
|
1022
|
+
}
|
|
1023
|
+
// ── access ──────────────────────────────────────────────────
|
|
1024
|
+
/** The cell with identity `id` (its declared name, or `c<N>` when undeclared), or
|
|
1025
|
+
* undefined when no live cell holds it. */
|
|
1026
|
+
cell(id) {
|
|
1027
|
+
return this.cellsById.get(id);
|
|
1028
|
+
}
|
|
1029
|
+
/** Every live cell, in slot order. Enumerated over `order` — the IDENTITY space —
|
|
1030
|
+
* never the layout's positional slot ids, which only coincide with it for a
|
|
1031
|
+
* workspace whose cells are undeclared. Identities past the current layout size are
|
|
1032
|
+
* pooled, not live, so they drop out here. */
|
|
1033
|
+
cells() {
|
|
1034
|
+
return this.order.map((id) => this.cellsById.get(id)).filter((c) => c != null);
|
|
1035
|
+
}
|
|
1036
|
+
/** The ACTIVE cell — the one the shared chrome reflects and acts on. */
|
|
1037
|
+
get active() {
|
|
1038
|
+
const cell = this.activeId ? this.cellsById.get(this.activeId) : void 0;
|
|
1039
|
+
if (!cell) throw new Error("VelaWorkspace has no active cell (destroyed?)");
|
|
1040
|
+
return cell;
|
|
1041
|
+
}
|
|
1042
|
+
/** Shortcut for `active.chart` — the same habit as `widget.chart`. LIVE: read it at
|
|
1043
|
+
* the point of use; the durable identity to hold is the cell (or its id). */
|
|
1044
|
+
get chart() {
|
|
1045
|
+
return this.active.chart;
|
|
1046
|
+
}
|
|
1047
|
+
setActiveCell(id) {
|
|
1048
|
+
if (id === this.activeId || this.destroyed) return;
|
|
1049
|
+
const prev = this.activeId;
|
|
1050
|
+
if (prev) {
|
|
1051
|
+
const el = this.cellsById.get(prev)?.host;
|
|
1052
|
+
if (el) delete el.dataset.active;
|
|
1053
|
+
}
|
|
1054
|
+
this.activeId = id;
|
|
1055
|
+
const prevCell = prev ? this.cellsById.get(prev) : void 0;
|
|
1056
|
+
if (prevCell) {
|
|
1057
|
+
prevCell.chart.drawings.setTool(null);
|
|
1058
|
+
prevCell.chart.drawings.setMode(null);
|
|
1059
|
+
}
|
|
1060
|
+
if (id) {
|
|
1061
|
+
const el = this.cellsById.get(id)?.host;
|
|
1062
|
+
if (el) el.dataset.active = "1";
|
|
1063
|
+
}
|
|
1064
|
+
if (id) {
|
|
1065
|
+
this.projectActiveCell();
|
|
1066
|
+
this.events.emit("cell:active", { id, prev });
|
|
1067
|
+
this.markStateDirty();
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
on(event, handler) {
|
|
1071
|
+
return this.events.on(event, handler);
|
|
1072
|
+
}
|
|
1073
|
+
/** The context handed to contributed actions/attachments (rebuilt per invocation). */
|
|
1074
|
+
context() {
|
|
1075
|
+
return buildContext({
|
|
1076
|
+
// Null during early construction (the topbar projects actions before cells exist).
|
|
1077
|
+
active: () => this.activeId ? this.cellsById.get(this.activeId) ?? null : null,
|
|
1078
|
+
cells: () => this.cells(),
|
|
1079
|
+
setActiveCell: (id) => this.setActiveCell(id),
|
|
1080
|
+
openSymbolSearch: (query) => this.symbolPicker.open(query ?? ""),
|
|
1081
|
+
togglePanel: (id, open) => this.dock.toggle(id, open),
|
|
1082
|
+
root: this.root,
|
|
1083
|
+
toast: (message, kind) => this.toast.show(message, kind)
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
/** Re-project contributed topbar actions + side panels, and mount late-registered attachments. */
|
|
1087
|
+
refreshActions() {
|
|
1088
|
+
this.mountAttachments();
|
|
1089
|
+
this.topbar.renderActions();
|
|
1090
|
+
this.dock.refresh();
|
|
1091
|
+
for (const cell of this.cells()) cell.chart.renderer.setLegendActions(legendActionsProviderFor(cell.chart, () => this.context()));
|
|
1092
|
+
}
|
|
1093
|
+
// ── state surface (the SDK's read/restore of the whole grid's config + content) ──
|
|
1094
|
+
/**
|
|
1095
|
+
* Snapshot the COMPLETE workspace state as a versioned, serializable document:
|
|
1096
|
+
* layout + splitter sizes, active cell, sync links, timezone, and — per slot, live
|
|
1097
|
+
* AND dormant — the market, the renderer's cosmetic config, the user-drawings
|
|
1098
|
+
* document, and the indicator ledger. This is what `persist` writes; hosts build
|
|
1099
|
+
* custom flows on it (server snapshots, share links, templates).
|
|
1100
|
+
*/
|
|
1101
|
+
getState() {
|
|
1102
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1103
|
+
for (const [id, cs] of this.pool) byId.set(id, cs);
|
|
1104
|
+
for (const [id, cell] of this.cellsById) byId.set(id, cell.dehydrate());
|
|
1105
|
+
const charts = [];
|
|
1106
|
+
for (const id of this.order) {
|
|
1107
|
+
const cs = byId.get(id);
|
|
1108
|
+
if (cs) {
|
|
1109
|
+
charts.push({ id, ...cs });
|
|
1110
|
+
byId.delete(id);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
for (const [id, cs] of byId) charts.push({ id, ...cs });
|
|
1114
|
+
const state = { version: 1, layout: this.def.id, timezone: this.timezone, sync: { ...this.syncOpts }, charts };
|
|
1115
|
+
if (this.activeId) state.activeCellId = this.activeId;
|
|
1116
|
+
if (this.favs.length > 0) state.favorites = [...this.favs];
|
|
1117
|
+
if (this.trackSizes.size > 0) state.trackSizes = Object.fromEntries([...this.trackSizes].map(([k, v]) => [k, { ...v }]));
|
|
1118
|
+
const panels = this.dock.getState();
|
|
1119
|
+
if (panels) state.panels = panels;
|
|
1120
|
+
return state;
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Restore a state document produced by {@link getState} (untrusted-safe: malformed
|
|
1124
|
+
* fields are dropped). Replaces the WHOLE workspace state: prefs, sync links,
|
|
1125
|
+
* layout, and every slot — current cells are rebuilt from the document. A layout id
|
|
1126
|
+
* that is not registered keeps the current grid (register custom layouts first).
|
|
1127
|
+
*/
|
|
1128
|
+
applyState(state) {
|
|
1129
|
+
if (this.destroyed) return;
|
|
1130
|
+
const st = sanitizeState(state);
|
|
1131
|
+
if (!st) return;
|
|
1132
|
+
if (st.timezone) {
|
|
1133
|
+
this.timezone = st.timezone;
|
|
1134
|
+
this.bottombar?.setTimezone(st.timezone);
|
|
1135
|
+
}
|
|
1136
|
+
if (st.favorites) this.favs = [...st.favorites];
|
|
1137
|
+
this.dock.applyState(st.panels);
|
|
1138
|
+
for (const kind of ["viewport", "symbol", "timeframe", "crosshair"]) this.applySyncSetting(kind, st.sync?.[kind]);
|
|
1139
|
+
this.trackSizes.clear();
|
|
1140
|
+
if (st.trackSizes) for (const [id, ts] of Object.entries(st.trackSizes)) this.trackSizes.set(id, ts);
|
|
1141
|
+
for (const [id, cell] of [...this.cellsById]) {
|
|
1142
|
+
cell.destroy();
|
|
1143
|
+
this.cellsById.delete(id);
|
|
1144
|
+
this.events.emit("cell:destroyed", { id });
|
|
1145
|
+
}
|
|
1146
|
+
this.pool.clear();
|
|
1147
|
+
for (const { id, ...cs } of st.charts) this.pool.set(id, cs);
|
|
1148
|
+
this.order = st.charts.map((c) => c.id);
|
|
1149
|
+
const def = layoutDefinition(st.layout);
|
|
1150
|
+
if (def) this.def = def;
|
|
1151
|
+
this.cellBackend = this.backendFor(this.def);
|
|
1152
|
+
this.applyGrid();
|
|
1153
|
+
this.buildCells();
|
|
1154
|
+
this.topbar.setLayout(this.def.id);
|
|
1155
|
+
const nextActive = st.activeCellId && this.cellsById.has(st.activeCellId) ? st.activeCellId : this.order[0] ?? null;
|
|
1156
|
+
if (nextActive === this.activeId) this.projectActiveCell();
|
|
1157
|
+
else this.setActiveCell(nextActive);
|
|
1158
|
+
this.refreshRetention();
|
|
1159
|
+
this.events.emit("layout:changed", { layout: this.def.id });
|
|
1160
|
+
this.markStateDirty();
|
|
1161
|
+
}
|
|
1162
|
+
/** Set the workspace-global display timezone — applied to EVERY cell. */
|
|
1163
|
+
setTimezone(zone) {
|
|
1164
|
+
this.timezone = zone;
|
|
1165
|
+
this.bottombar?.setTimezone(zone);
|
|
1166
|
+
for (const cell of this.cellsById.values()) cell.chart.renderer.set("timezone", zone);
|
|
1167
|
+
this.markStateDirty();
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Swap the workspace theme at runtime — `'dark'`, `'light'`, or a full custom theme,
|
|
1171
|
+
* applied to the shared chrome (topbar, panels, drawing toolbar) and EVERY cell.
|
|
1172
|
+
* Also reached from any cell's chart settings → Canvas → Theme. The choice sticks:
|
|
1173
|
+
* cells rebuilt by later layout switches reconstruct with it.
|
|
1174
|
+
*/
|
|
1175
|
+
setTheme(theme) {
|
|
1176
|
+
if (this.destroyed) return;
|
|
1177
|
+
const t = resolveTheme(theme);
|
|
1178
|
+
this.opts.theme = theme;
|
|
1179
|
+
ensureUIHost(this.root, t);
|
|
1180
|
+
this.drawToolbar?.setTheme(t);
|
|
1181
|
+
if (this.attributionMark) applyAttributionMarkTheme(this.attributionMark, t.background);
|
|
1182
|
+
for (const cell of this.cellsById.values()) cell.chart.setTheme(t);
|
|
1183
|
+
}
|
|
1184
|
+
// ── layout ──────────────────────────────────────────────────
|
|
1185
|
+
get layout() {
|
|
1186
|
+
return this.def;
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Switch the grid. Cells are diffed BY IDENTITY (`order` head of the next size):
|
|
1190
|
+
* surviving cells keep their live charts untouched; cells past the new size
|
|
1191
|
+
* dehydrate into the pool; (re)appearing positions hydrate their identity from
|
|
1192
|
+
* the pool (or its seed). Crossing the WebGL budget rebuilds every cell through
|
|
1193
|
+
* the pool so the backend stays uniform.
|
|
1194
|
+
*/
|
|
1195
|
+
setLayout(layout) {
|
|
1196
|
+
if (this.destroyed) return;
|
|
1197
|
+
const next = this.resolveLayout(layout);
|
|
1198
|
+
const nextBackend = this.backendFor(next);
|
|
1199
|
+
const rebuildAll = nextBackend !== this.cellBackend;
|
|
1200
|
+
const keep = new Set(this.order.slice(0, next.cells.length));
|
|
1201
|
+
for (const [id, cell] of [...this.cellsById]) {
|
|
1202
|
+
if (!keep.has(id) || rebuildAll) {
|
|
1203
|
+
this.poolSet(id, cell.dehydrate());
|
|
1204
|
+
cell.destroy();
|
|
1205
|
+
this.cellsById.delete(id);
|
|
1206
|
+
this.events.emit("cell:destroyed", { id });
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
this.def = next;
|
|
1210
|
+
this.cellBackend = nextBackend;
|
|
1211
|
+
this.applyGrid();
|
|
1212
|
+
this.buildCells();
|
|
1213
|
+
this.topbar.setLayout(next.id);
|
|
1214
|
+
const nextActive = activeAfterLayout(this.activeId, this.order.slice(0, next.cells.length));
|
|
1215
|
+
if (nextActive === this.activeId) this.projectActiveCell();
|
|
1216
|
+
else this.setActiveCell(nextActive);
|
|
1217
|
+
this.refreshRetention();
|
|
1218
|
+
this.events.emit("layout:changed", { layout: next.id });
|
|
1219
|
+
this.markStateDirty();
|
|
1220
|
+
}
|
|
1221
|
+
resize() {
|
|
1222
|
+
this.splitters.layout();
|
|
1223
|
+
}
|
|
1224
|
+
destroy() {
|
|
1225
|
+
if (this.destroyed) return;
|
|
1226
|
+
this.persistNow();
|
|
1227
|
+
this.destroyed = true;
|
|
1228
|
+
if (this.stateTimer != null) clearTimeout(this.stateTimer);
|
|
1229
|
+
if (this.persistKey !== null && typeof window !== "undefined") window.removeEventListener("beforeunload", this.onUnload);
|
|
1230
|
+
this.resizeObserver?.disconnect();
|
|
1231
|
+
this.splitters.destroy();
|
|
1232
|
+
for (const [id, cell] of [...this.cellsById]) {
|
|
1233
|
+
cell.destroy();
|
|
1234
|
+
this.cellsById.delete(id);
|
|
1235
|
+
}
|
|
1236
|
+
for (const dispose of this.attachmentDisposers.values()) {
|
|
1237
|
+
try {
|
|
1238
|
+
dispose();
|
|
1239
|
+
} catch {
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
this.attachmentDisposers.clear();
|
|
1243
|
+
this.root.removeEventListener("keydown", this.onRootKeydown);
|
|
1244
|
+
this.keymap.destroy();
|
|
1245
|
+
this.drawToolbar?.destroy();
|
|
1246
|
+
this.topbar.destroy();
|
|
1247
|
+
this.bottombar?.destroy();
|
|
1248
|
+
this.dock.destroy();
|
|
1249
|
+
this.objectTree.destroy();
|
|
1250
|
+
this.dataWindow.destroy();
|
|
1251
|
+
this.symbolPicker.destroy();
|
|
1252
|
+
this.indicatorPicker.destroy();
|
|
1253
|
+
this.tfQuick.destroy();
|
|
1254
|
+
this.shortcutsHelp?.destroy();
|
|
1255
|
+
this.toast.destroy();
|
|
1256
|
+
this.alertsMenu?.destroy();
|
|
1257
|
+
this.glider.stop();
|
|
1258
|
+
sharedBarStore.retain(/* @__PURE__ */ new Set());
|
|
1259
|
+
this.root.remove();
|
|
1260
|
+
this.events.clear();
|
|
1261
|
+
}
|
|
1262
|
+
// ── the projection rule (trigger ① — full rebind on activation) ──
|
|
1263
|
+
/** Re-project the shared chrome from the ACTIVE cell. The chrome holds no state of
|
|
1264
|
+
* its own — this is a pure read of the cell, safe to call redundantly. */
|
|
1265
|
+
projectActiveCell() {
|
|
1266
|
+
const cell = this.activeId ? this.cellsById.get(this.activeId) : void 0;
|
|
1267
|
+
if (!cell) return;
|
|
1268
|
+
this.topbar.setSymbol(cell.symbol);
|
|
1269
|
+
this.topbar.setTimeframe(cell.timeframe);
|
|
1270
|
+
this.topbar.setPriceStyle(cell.priceStyle);
|
|
1271
|
+
this.topbar.setIndicatorCount(cell.indicatorCount);
|
|
1272
|
+
this.topbar.renderActions();
|
|
1273
|
+
const pushHistory = () => this.topbar.setHistoryState(cell.history.canUndo, cell.history.canRedo);
|
|
1274
|
+
this.historyUnsub?.();
|
|
1275
|
+
this.historyUnsub = cell.history.onChange(pushHistory);
|
|
1276
|
+
pushHistory();
|
|
1277
|
+
this.objectTree.setSymbol(cell.symbol);
|
|
1278
|
+
this.dock.onChart(cell.chart);
|
|
1279
|
+
this.bottombar?.setActiveRange(cell.activeRangeId);
|
|
1280
|
+
this.indicatorPicker.sync();
|
|
1281
|
+
this.glider.stop();
|
|
1282
|
+
const d = cell.chart.drawings;
|
|
1283
|
+
if (d.getTool() !== this.globalTool) d.setTool(this.globalTool);
|
|
1284
|
+
if (d.getSnapMode() !== this.globalSnap) d.setSnapMode(this.globalSnap);
|
|
1285
|
+
if (d.getStayMode() !== this.globalStay) d.setStayMode(this.globalStay);
|
|
1286
|
+
if (this.drawToolbar) {
|
|
1287
|
+
this.drawToolbar.setActiveTool(this.globalTool);
|
|
1288
|
+
this.drawToolbar.setMagnetMode(this.globalSnap);
|
|
1289
|
+
this.drawToolbar.setStayMode(this.globalStay);
|
|
1290
|
+
const mode = d.getMode();
|
|
1291
|
+
this.drawToolbar.setMeasureActive(mode === "measure");
|
|
1292
|
+
this.drawToolbar.setEraserActive(mode === "eraser");
|
|
1293
|
+
this.drawToolbar.setFavorites(d.favorites());
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
/** Put keyboard focus back on the active cell's chart surface (after a toolbar press
|
|
1297
|
+
* stole it) so chart/drawing shortcuts keep working. */
|
|
1298
|
+
refocusActive() {
|
|
1299
|
+
if (this.activeId) this.cellsById.get(this.activeId)?.chart.renderer.focus();
|
|
1300
|
+
}
|
|
1301
|
+
/** Debounced dirty mark: one `state:changed` (+ one storage write in persist mode)
|
|
1302
|
+
* per burst of edits, flushed hard on unload/destroy. */
|
|
1303
|
+
markStateDirty() {
|
|
1304
|
+
if (this.destroyed) return;
|
|
1305
|
+
if (this.stateTimer != null) clearTimeout(this.stateTimer);
|
|
1306
|
+
this.stateTimer = setTimeout(() => {
|
|
1307
|
+
this.stateTimer = null;
|
|
1308
|
+
this.events.emit("state:changed", void 0);
|
|
1309
|
+
this.persistNow();
|
|
1310
|
+
}, 500);
|
|
1311
|
+
}
|
|
1312
|
+
/** Write the current state through the storage adapter now (fire-and-forget). */
|
|
1313
|
+
persistNow() {
|
|
1314
|
+
if (this.persistKey === null || this.destroyed) return;
|
|
1315
|
+
try {
|
|
1316
|
+
void this.storage.set(this.persistKey, encodeState(this.getState()));
|
|
1317
|
+
} catch {
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
// ── internals ───────────────────────────────────────────────
|
|
1321
|
+
resolveLayout(layout) {
|
|
1322
|
+
if (typeof layout !== "string") return layout;
|
|
1323
|
+
const def = layoutDefinition(layout);
|
|
1324
|
+
if (!def) throw new Error(`[vela] unknown workspace layout "${layout}" \u2014 register it with registerLayout().`);
|
|
1325
|
+
return def;
|
|
1326
|
+
}
|
|
1327
|
+
backendFor(def) {
|
|
1328
|
+
const explicit = this.opts.nativeBackend;
|
|
1329
|
+
if (explicit && explicit !== "auto") return explicit;
|
|
1330
|
+
return def.cells.length > (this.opts.maxWebglCells ?? 8) ? "canvas2d" : "auto";
|
|
1331
|
+
}
|
|
1332
|
+
currentTracks() {
|
|
1333
|
+
const sizes = this.trackSizes.get(this.def.id);
|
|
1334
|
+
return {
|
|
1335
|
+
cols: sizes?.cols?.length === this.def.cols.length ? [...sizes.cols] : [...this.def.cols],
|
|
1336
|
+
rows: sizes?.rows?.length === this.def.rows.length ? [...sizes.rows] : [...this.def.rows]
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
applyTracks(axis, weights) {
|
|
1340
|
+
const sizes = this.trackSizes.get(this.def.id) ?? {};
|
|
1341
|
+
sizes[axis] = weights;
|
|
1342
|
+
this.trackSizes.set(this.def.id, sizes);
|
|
1343
|
+
this.applyGrid();
|
|
1344
|
+
this.markStateDirty();
|
|
1345
|
+
}
|
|
1346
|
+
/** Apply the grid template (+ per-cell areas) and reposition the splitter strips.
|
|
1347
|
+
* Geometry is keyed by SLOT (`perCell[slot.id]`); the cell living there is
|
|
1348
|
+
* `order[i]` — the identity/position decoupling in one line. */
|
|
1349
|
+
applyGrid() {
|
|
1350
|
+
const { container, perCell } = gridStyles(this.def, this.trackSizes.get(this.def.id));
|
|
1351
|
+
this.gridEl.style.gridTemplateColumns = container.gridTemplateColumns ?? "";
|
|
1352
|
+
this.gridEl.style.gridTemplateRows = container.gridTemplateRows ?? "";
|
|
1353
|
+
this.gridEl.style.gridTemplateAreas = container.gridTemplateAreas ?? "";
|
|
1354
|
+
for (const [i, slot] of this.def.cells.entries()) {
|
|
1355
|
+
const host = this.cellsById.get(this.order[i] ?? "")?.host;
|
|
1356
|
+
if (host) host.style.gridArea = perCell[slot.id]?.gridArea ?? "";
|
|
1357
|
+
}
|
|
1358
|
+
this.splitters.layout();
|
|
1359
|
+
}
|
|
1360
|
+
/** Create the cells the current layout wants but don't exist yet (pool-first).
|
|
1361
|
+
* A slot's CELL IDENTITY is `order[i]` (declaration order — never the slot's own
|
|
1362
|
+
* positional id); slots past the declared list mint an auto identity once. */
|
|
1363
|
+
buildCells() {
|
|
1364
|
+
const theme = resolveTheme(this.opts.theme);
|
|
1365
|
+
const { perCell } = gridStyles(this.def, this.trackSizes.get(this.def.id));
|
|
1366
|
+
for (const [i, slot] of this.def.cells.entries()) {
|
|
1367
|
+
let id = this.order[i];
|
|
1368
|
+
if (!id) {
|
|
1369
|
+
id = nextAutoCellId(/* @__PURE__ */ new Set([...this.order, ...this.pool.keys(), ...this.cellsById.keys()]));
|
|
1370
|
+
this.order[i] = id;
|
|
1371
|
+
}
|
|
1372
|
+
if (this.cellsById.has(id)) continue;
|
|
1373
|
+
const pooled = this.pool.get(id);
|
|
1374
|
+
const seed = pooled ?? { ...seedDefaults(this.opts), ...this.opts.cells?.[id] ?? {} };
|
|
1375
|
+
this.pool.delete(id);
|
|
1376
|
+
const cell = new ChartCell(id, this.gridEl, seed, {
|
|
1377
|
+
feed: this.feed,
|
|
1378
|
+
engines: this.opts.engines ?? {},
|
|
1379
|
+
chartDefaults: cellChartDefaults(this.opts),
|
|
1380
|
+
theme,
|
|
1381
|
+
live: this.opts.live ?? false,
|
|
1382
|
+
volume: this.opts.volume ?? true,
|
|
1383
|
+
statusline: this.opts.statusline !== false,
|
|
1384
|
+
watermark: this.opts.watermark !== false,
|
|
1385
|
+
nativeBackend: this.cellBackend,
|
|
1386
|
+
dialogHost: this.root,
|
|
1387
|
+
timezone: () => this.timezone,
|
|
1388
|
+
setTimezone: (zone) => this.setTimezone(zone),
|
|
1389
|
+
context: () => this.context(),
|
|
1390
|
+
activate: (id2) => this.setActiveCell(id2),
|
|
1391
|
+
onMarketChanged: (id2) => this.onCellMarketChanged(id2),
|
|
1392
|
+
onIndicatorsChanged: (id2) => this.onCellIndicatorsChanged(id2),
|
|
1393
|
+
onStateDirty: () => this.markStateDirty(),
|
|
1394
|
+
manifestSettled: () => this.manifestSettled
|
|
1395
|
+
});
|
|
1396
|
+
cell.host.style.gridArea = perCell[slot.id]?.gridArea ?? "";
|
|
1397
|
+
this.cellsById.set(id, cell);
|
|
1398
|
+
this.wireCell(cell);
|
|
1399
|
+
if (this.favs.length > 0) cell.chart.drawings.setFavorites(this.favs);
|
|
1400
|
+
cell.setManifest(this.manifest, pooled?.indicators == null);
|
|
1401
|
+
this.events.emit("cell:created", { id });
|
|
1402
|
+
}
|
|
1403
|
+
for (const [i] of this.def.cells.entries()) {
|
|
1404
|
+
const host = this.cellsById.get(this.order[i] ?? "")?.host;
|
|
1405
|
+
if (host) this.gridEl.appendChild(host);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
/** Per-cell chart subscriptions (trigger ② — the chart instance is stable for the
|
|
1409
|
+
* cell's whole life, so these live and die with the cell). */
|
|
1410
|
+
wireCell(cell) {
|
|
1411
|
+
const chart = cell.chart;
|
|
1412
|
+
chart.on("indicator:error", ({ error }) => this.toast.show(`[${cell.id}] ${error.message}`, "error", 5e3));
|
|
1413
|
+
chart.on("script:run", (run) => this.events.emit("script:run", { ...run, cell: cell.id }));
|
|
1414
|
+
chart.on("alert", (alert) => {
|
|
1415
|
+
this.alerts.unshift({ cellId: cell.id, symbol: cell.symbol, title: alert.title ?? "Alert", message: alert.message, time: alert.time });
|
|
1416
|
+
if (this.alerts.length > ALERT_CAP) this.alerts.pop();
|
|
1417
|
+
this.toast.show(`[${cell.id} \xB7 ${cell.symbol}] ${alert.title ? alert.title + " \u2014 " : ""}${alert.message}`, "info", 4e3);
|
|
1418
|
+
this.topbar.setAlertCount(this.alerts.length);
|
|
1419
|
+
});
|
|
1420
|
+
chart.on("drawing:favorites", ({ favorites }) => {
|
|
1421
|
+
this.favs = favorites;
|
|
1422
|
+
for (const other of this.cellsById.values()) {
|
|
1423
|
+
if (other !== cell) other.chart.drawings.setFavorites(favorites);
|
|
1424
|
+
}
|
|
1425
|
+
this.drawToolbar?.setFavorites(favorites);
|
|
1426
|
+
this.markStateDirty();
|
|
1427
|
+
});
|
|
1428
|
+
chart.renderer.onCrosshairMove((e) => this.propagateCrosshair(cell.id, e.time));
|
|
1429
|
+
chart.on("drawing:created", () => this.markStateDirty());
|
|
1430
|
+
chart.on("drawing:edited", () => this.markStateDirty());
|
|
1431
|
+
chart.on("drawing:removed", () => this.markStateDirty());
|
|
1432
|
+
chart.on("drawing:tool", ({ type }) => {
|
|
1433
|
+
if (cell.id !== this.activeId) return;
|
|
1434
|
+
this.globalTool = type;
|
|
1435
|
+
this.drawToolbar?.setActiveTool(type);
|
|
1436
|
+
});
|
|
1437
|
+
chart.on("drawing:snap", ({ mode }) => {
|
|
1438
|
+
if (cell.id !== this.activeId) return;
|
|
1439
|
+
this.globalSnap = mode;
|
|
1440
|
+
this.drawToolbar?.setMagnetMode(mode);
|
|
1441
|
+
});
|
|
1442
|
+
chart.on("drawing:stay", ({ on }) => {
|
|
1443
|
+
if (cell.id !== this.activeId) return;
|
|
1444
|
+
this.globalStay = on;
|
|
1445
|
+
this.drawToolbar?.setStayMode(on);
|
|
1446
|
+
});
|
|
1447
|
+
chart.on("drawing:mode", ({ mode }) => {
|
|
1448
|
+
if (cell.id !== this.activeId) return;
|
|
1449
|
+
this.drawToolbar?.setMeasureActive(mode === "measure");
|
|
1450
|
+
this.drawToolbar?.setEraserActive(mode === "eraser");
|
|
1451
|
+
});
|
|
1452
|
+
chart.on("viewport:changed", (range) => this.propagateViewport(cell.id, range));
|
|
1453
|
+
chart.on("theme:changed", (t) => this.setTheme(t));
|
|
1454
|
+
}
|
|
1455
|
+
// ── sync links ──────────────────────────────────────────────
|
|
1456
|
+
applySyncSetting(kind, setting, align = false) {
|
|
1457
|
+
if (setting == null || setting === false) delete this.syncOpts[kind];
|
|
1458
|
+
else this.syncOpts[kind] = setting;
|
|
1459
|
+
if (kind === "crosshair") {
|
|
1460
|
+
for (const cell of this.cellsById.values()) cell.chart.renderer.setExternalCrosshair(null);
|
|
1461
|
+
if (setting && ![...this.cellsById.values()].some((c) => c.chart.renderer.supportsExternalCrosshair)) {
|
|
1462
|
+
console.warn("[vela] crosshair sync: no cell renderer supports an external crosshair \u2014 nothing will show.");
|
|
1463
|
+
}
|
|
1464
|
+
if (this.topbar && this.def) this.topbar.setLayout(this.def.id);
|
|
1465
|
+
this.markStateDirty();
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
this.markStateDirty();
|
|
1469
|
+
if (align && setting && this.activeId) {
|
|
1470
|
+
if (kind === "viewport") {
|
|
1471
|
+
const range = this.cellsById.get(this.activeId)?.chart.getVisibleRange();
|
|
1472
|
+
if (range) this.propagateViewport(this.activeId, range);
|
|
1473
|
+
} else {
|
|
1474
|
+
this.propagateMarket(this.activeId);
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
/**
|
|
1479
|
+
* Mirror an origin cell's pointer time onto its same-group followers as GHOST
|
|
1480
|
+
* crosshairs (`renderer.setExternalCrosshair`). Leaving the origin propagates
|
|
1481
|
+
* `null` (the event already carries it) and clears every ghost. No busy guard
|
|
1482
|
+
* needed: an external crosshair never re-emits `onCrosshairMove` — the flow is
|
|
1483
|
+
* one-way by port contract, so no echo loop can exist.
|
|
1484
|
+
*/
|
|
1485
|
+
propagateCrosshair(originId, time) {
|
|
1486
|
+
if (this.destroyed) return;
|
|
1487
|
+
const setting = this.syncOpts.crosshair;
|
|
1488
|
+
if (!setting) return;
|
|
1489
|
+
for (const id of syncTargets(originId, setting, [...this.cellsById.keys()])) {
|
|
1490
|
+
this.cellsById.get(id)?.chart.renderer.setExternalCrosshair(time);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Push an origin cell's visible range onto its same-group followers. Loop-safe two
|
|
1495
|
+
* ways: the busy guard eats the followers' SYNCHRONOUS echoes (their setVisibleRange
|
|
1496
|
+
* re-emits `viewport:changed` in the same tick), and the half-bar epsilon
|
|
1497
|
+
* short-circuits any async residue — a follower already within half of ITS OWN bar
|
|
1498
|
+
* interval is left alone, so cross-timeframe groups settle instead of oscillating.
|
|
1499
|
+
*/
|
|
1500
|
+
propagateViewport(originId, range) {
|
|
1501
|
+
if (this.syncBusy || this.destroyed) return;
|
|
1502
|
+
const targets = syncTargets(originId, this.syncOpts.viewport, [...this.cellsById.keys()]);
|
|
1503
|
+
if (targets.length === 0) return;
|
|
1504
|
+
this.syncBusy = true;
|
|
1505
|
+
try {
|
|
1506
|
+
for (const id of targets) {
|
|
1507
|
+
const cell = this.cellsById.get(id);
|
|
1508
|
+
if (!cell) continue;
|
|
1509
|
+
const current = cell.chart.getVisibleRange();
|
|
1510
|
+
const tfMs = timeframeToMs(cell.timeframe);
|
|
1511
|
+
const eps = Number.isFinite(tfMs) ? tfMs / 2 : 0;
|
|
1512
|
+
if (current && rangesWithin(current, range, eps)) continue;
|
|
1513
|
+
cell.chart.setVisibleRange(range);
|
|
1514
|
+
}
|
|
1515
|
+
} finally {
|
|
1516
|
+
this.syncBusy = false;
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Push an origin cell's symbol/timeframe onto its same-group followers (fired from
|
|
1521
|
+
* `market:changed`). Convergence comes from IDEMPOTENCE, not the guard: a follower's
|
|
1522
|
+
* own (async) `market:changed` propagates back, but every peer already carries the
|
|
1523
|
+
* value, so the cell setters no-op and the wave dies.
|
|
1524
|
+
*/
|
|
1525
|
+
propagateMarket(originId) {
|
|
1526
|
+
if (this.syncBusy || this.destroyed) return;
|
|
1527
|
+
const origin = this.cellsById.get(originId);
|
|
1528
|
+
if (!origin) return;
|
|
1529
|
+
const ids = [...this.cellsById.keys()];
|
|
1530
|
+
this.syncBusy = true;
|
|
1531
|
+
try {
|
|
1532
|
+
if (origin.symbol) {
|
|
1533
|
+
for (const id of syncTargets(originId, this.syncOpts.symbol, ids)) this.cellsById.get(id)?.setSymbol(origin.symbol);
|
|
1534
|
+
}
|
|
1535
|
+
for (const id of syncTargets(originId, this.syncOpts.timeframe, ids)) this.cellsById.get(id)?.setTimeframe(origin.timeframe);
|
|
1536
|
+
} finally {
|
|
1537
|
+
this.syncBusy = false;
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
/** Trigger ② — a cell's market changed: retention + sync always; chrome only if active. */
|
|
1541
|
+
onCellMarketChanged(id) {
|
|
1542
|
+
this.refreshRetention();
|
|
1543
|
+
this.propagateMarket(id);
|
|
1544
|
+
this.markStateDirty();
|
|
1545
|
+
if (id !== this.activeId) return;
|
|
1546
|
+
const cell = this.cellsById.get(id);
|
|
1547
|
+
if (!cell) return;
|
|
1548
|
+
this.topbar.setSymbol(cell.symbol);
|
|
1549
|
+
this.topbar.setTimeframe(cell.timeframe);
|
|
1550
|
+
this.objectTree.setSymbol(cell.symbol);
|
|
1551
|
+
}
|
|
1552
|
+
/** Trigger ② — a cell's indicator ledger changed: count + picker only if active. */
|
|
1553
|
+
onCellIndicatorsChanged(id) {
|
|
1554
|
+
this.markStateDirty();
|
|
1555
|
+
if (id !== this.activeId) return;
|
|
1556
|
+
const cell = this.cellsById.get(id);
|
|
1557
|
+
if (!cell) return;
|
|
1558
|
+
this.topbar.setIndicatorCount(cell.indicatorCount);
|
|
1559
|
+
this.indicatorPicker.sync();
|
|
1560
|
+
}
|
|
1561
|
+
/** Timeframe changes routed from the topbar menu / quick entry (chip state follows). */
|
|
1562
|
+
setActiveTimeframe(tf) {
|
|
1563
|
+
this.bottombar?.setActiveRange(null);
|
|
1564
|
+
this.active.setTimeframe(tf);
|
|
1565
|
+
}
|
|
1566
|
+
/** The aggregated alerts bell — entries carry their cell; selecting one activates it. */
|
|
1567
|
+
openAlertsMenu(anchor) {
|
|
1568
|
+
this.alertsMenu?.destroy();
|
|
1569
|
+
const items = this.alerts.length ? this.alerts.map((a, i) => ({
|
|
1570
|
+
id: String(i),
|
|
1571
|
+
label: `[${a.cellId} \xB7 ${a.symbol}] ${new Date(a.time).toLocaleTimeString()} \xB7 ${a.title}: ${a.message}`.slice(0, 80)
|
|
1572
|
+
})) : [{ id: "none", label: "No alerts yet", disabled: true }];
|
|
1573
|
+
this.alertsMenu = new Menu({
|
|
1574
|
+
host: this.root,
|
|
1575
|
+
items,
|
|
1576
|
+
onSelect: (id) => {
|
|
1577
|
+
const alert = this.alerts[Number(id)];
|
|
1578
|
+
if (alert && this.cellsById.has(alert.cellId)) this.setActiveCell(alert.cellId);
|
|
1579
|
+
}
|
|
1580
|
+
});
|
|
1581
|
+
const r = anchor.getBoundingClientRect();
|
|
1582
|
+
this.alertsMenu.openAt(r.left, r.bottom + 4);
|
|
1583
|
+
}
|
|
1584
|
+
/** Mount registered attachments not yet mounted on this workspace (idempotent per id). */
|
|
1585
|
+
mountAttachments() {
|
|
1586
|
+
for (const att of widgetAttachments()) {
|
|
1587
|
+
if (this.attachmentDisposers.has(att.id)) continue;
|
|
1588
|
+
try {
|
|
1589
|
+
this.attachmentDisposers.set(att.id, att.mount(this.context()));
|
|
1590
|
+
} catch (err) {
|
|
1591
|
+
console.warn(`[vela] workspace attachment "${att.id}" failed to mount:`, err);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
/** The default shortcut set — every binding acts on the ACTIVE cell. */
|
|
1596
|
+
registerDefaultKeys() {
|
|
1597
|
+
this.keymap.register({ id: "chart.screenshot", keys: "mod+alt+s", label: "Download a chart screenshot", category: "Chart", run: () => this.active.downloadScreenshot() });
|
|
1598
|
+
this.keymap.register({ id: "chart.reset-view", keys: "alt+r", label: "Reset view (all history)", category: "Chart", run: () => this.active.chart.setVisibleRangePreset("ALL") });
|
|
1599
|
+
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")) });
|
|
1600
|
+
this.keymap.register({
|
|
1601
|
+
id: "chart.toggle-percent",
|
|
1602
|
+
keys: "alt+p",
|
|
1603
|
+
label: "Toggle percent scale",
|
|
1604
|
+
category: "Chart",
|
|
1605
|
+
run: () => {
|
|
1606
|
+
const mode = this.active.chart.renderer.get("scaleMode");
|
|
1607
|
+
this.active.chart.renderer.set("scaleMode", mode === "percent" ? "price" : "percent");
|
|
1608
|
+
}
|
|
1609
|
+
});
|
|
1610
|
+
this.keymap.register({ id: "drawings.trendline", keys: "alt+t", label: "Arm the trend line tool", category: "Drawings", run: () => this.active.chart.drawings.setTool("trendline") });
|
|
1611
|
+
this.keymap.register({
|
|
1612
|
+
id: "drawings.hline-cursor",
|
|
1613
|
+
keys: "alt+h",
|
|
1614
|
+
label: "Horizontal line at the cursor price",
|
|
1615
|
+
category: "Drawings",
|
|
1616
|
+
run: () => {
|
|
1617
|
+
const c = this.active;
|
|
1618
|
+
if (c.lastCrossTime != null && c.lastCrossPrice != null) c.chart.drawings.add("hline", { anchors: [{ time: c.lastCrossTime, price: c.lastCrossPrice }] });
|
|
1619
|
+
}
|
|
1620
|
+
});
|
|
1621
|
+
this.keymap.register({
|
|
1622
|
+
id: "drawings.vline-cursor",
|
|
1623
|
+
keys: "alt+v",
|
|
1624
|
+
label: "Vertical line at the cursor time",
|
|
1625
|
+
category: "Drawings",
|
|
1626
|
+
run: () => {
|
|
1627
|
+
const c = this.active;
|
|
1628
|
+
if (c.lastCrossTime != null && c.lastCrossPrice != null) c.chart.drawings.add("vline", { anchors: [{ time: c.lastCrossTime, price: c.lastCrossPrice }] });
|
|
1629
|
+
}
|
|
1630
|
+
});
|
|
1631
|
+
this.keymap.register({ id: "history.undo", keys: ["mod+z"], label: "Undo (active chart)", category: "Edit", run: () => this.active.history.undo() });
|
|
1632
|
+
this.keymap.register({ id: "history.redo", keys: ["mod+y", "mod+shift+z"], label: "Redo (active chart)", category: "Edit", run: () => this.active.history.redo() });
|
|
1633
|
+
this.keymap.register({ id: "view.zoom-in", keys: "mod+arrowup", label: "Zoom in", category: "Chart", run: () => this.glider.zoom(ZOOM_IN) });
|
|
1634
|
+
this.keymap.register({ id: "view.zoom-out", keys: "mod+arrowdown", label: "Zoom out", category: "Chart", run: () => this.glider.zoom(ZOOM_OUT) });
|
|
1635
|
+
this.keymap.register({ id: "view.pan-left", keys: "mod+arrowleft", label: "Pan toward history", category: "Chart", run: () => this.active.chart.panBy(-PAN_FAST) });
|
|
1636
|
+
this.keymap.register({ id: "view.pan-right", keys: "mod+arrowright", label: "Pan toward now", category: "Chart", run: () => this.active.chart.panBy(PAN_FAST) });
|
|
1637
|
+
this.keymap.register({ id: "indicators.open", keys: "/", label: "Open the indicator picker", category: "Indicators", run: () => this.indicatorPicker.open() });
|
|
1638
|
+
this.keymap.register({
|
|
1639
|
+
id: "help.shortcuts",
|
|
1640
|
+
keys: "?",
|
|
1641
|
+
label: "Show this shortcuts panel",
|
|
1642
|
+
category: "Help",
|
|
1643
|
+
run: () => {
|
|
1644
|
+
this.shortcutsHelp ?? (this.shortcutsHelp = new ShortcutsHelp(this.keymap, this.root, (open) => this.trackDialog(open)));
|
|
1645
|
+
this.shortcutsHelp.open();
|
|
1646
|
+
}
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
/** Bare-typing router: letters → symbol search (seeded), digits → timeframe entry. */
|
|
1650
|
+
routeTyping(ev) {
|
|
1651
|
+
if (this.destroyed || this.openDialogs > 0) return;
|
|
1652
|
+
if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
|
|
1653
|
+
if (isEditableTarget(ev)) return;
|
|
1654
|
+
const key = ev.key;
|
|
1655
|
+
if (/^[a-zA-Z]$/.test(key)) {
|
|
1656
|
+
ev.preventDefault();
|
|
1657
|
+
this.symbolPicker.open(key.toUpperCase());
|
|
1658
|
+
} else if (/^[0-9]$/.test(key)) {
|
|
1659
|
+
ev.preventDefault();
|
|
1660
|
+
this.tfQuick.open(key);
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
trackDialog(open) {
|
|
1664
|
+
this.openDialogs = Math.max(0, this.openDialogs + (open ? 1 : -1));
|
|
1665
|
+
if (open) this.keymap.pushScope("dialog");
|
|
1666
|
+
else this.keymap.popScope("dialog");
|
|
1667
|
+
}
|
|
1668
|
+
/** Pool a dehydrated slot state (bounded — oldest entries drop past the cap). */
|
|
1669
|
+
poolSet(id, state) {
|
|
1670
|
+
this.pool.delete(id);
|
|
1671
|
+
this.pool.set(id, state);
|
|
1672
|
+
if (this.pool.size > POOL_CAP) {
|
|
1673
|
+
const oldest = this.pool.keys().next().value;
|
|
1674
|
+
if (oldest != null) this.pool.delete(oldest);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Declare every live cell's symbol to the shared bar cache so one cell's load never
|
|
1679
|
+
* evicts the others' history (multi-symbol retention). Keys are CANONICAL tickers —
|
|
1680
|
+
* resolved through the registry when its indexes are ready, raw until then (refreshed
|
|
1681
|
+
* again on `feed.ready()`).
|
|
1682
|
+
*/
|
|
1683
|
+
refreshRetention() {
|
|
1684
|
+
const symbols = /* @__PURE__ */ new Set();
|
|
1685
|
+
for (const cell of this.cellsById.values()) {
|
|
1686
|
+
const raw = cell.symbol;
|
|
1687
|
+
if (!raw) continue;
|
|
1688
|
+
symbols.add(this.feed.resolveSymbol(raw)?.ticker ?? raw);
|
|
1689
|
+
}
|
|
1690
|
+
sharedBarStore.retain(symbols);
|
|
1691
|
+
}
|
|
1692
|
+
};
|
|
1693
|
+
|
|
1694
|
+
export { ChartCell, VelaWorkspace, activeAfterLayout, evenTracks, gridStyles, layoutDefinition, layouts, memoryStorageAdapter, rangesWithin, registerBuiltinLayouts, registerLayout, resizeTracks, syncTargets, trackOffsets, unregisterLayout };
|