@supersolutions/super-chart-web 0.0.0 → 0.1.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/dist/index.js ADDED
@@ -0,0 +1,1807 @@
1
+ import {
2
+ DrawingImageRenderer,
3
+ EngineChart,
4
+ createDrawingImage,
5
+ paintEngineCursor,
6
+ paintEngineFrame
7
+ } from "./chunk-PTYNDDEE.js";
8
+
9
+ // packages/bindings/web/src/workspace.ts
10
+ import { ENGINE_CHART_TYPES, DRAWING_TYPES, DataLoader, resolvePricePrecision, symbolKey, periodKey, parsePeriod, WorkspaceEventBus, WorkspaceLoadQueue, createTimedReplay, createBarCountdown, parseWorkspaceDocument } from "@supersolutions/super-chart-core";
11
+
12
+ // packages/bindings/web/src/theme.ts
13
+ var WORKSPACE_CHROME_VARS = Object.freeze({
14
+ background: "--sc-background",
15
+ text: "--sc-text",
16
+ surface: "--sc-control-background",
17
+ border: "--sc-control-border",
18
+ divider: "--sc-border",
19
+ danger: "--sc-danger",
20
+ toolbar: "--sc-toolbar-background",
21
+ muted: "--sc-muted",
22
+ hover: "--sc-hover-background",
23
+ hoverBorder: "--sc-hover-border",
24
+ focus: "--sc-focus",
25
+ accent: "--sc-accent",
26
+ active: "--sc-active-background",
27
+ panelBorder: "--sc-panel-border",
28
+ panel: "--sc-panel-background",
29
+ panelShadow: "--sc-panel-shadow",
30
+ error: "--sc-error-background",
31
+ colorScheme: "--sc-color-scheme"
32
+ });
33
+ var WORKSPACE_THEMES = Object.freeze({
34
+ dark: Object.freeze({
35
+ chrome: Object.freeze({
36
+ background: "#10151d",
37
+ text: "#d6deea",
38
+ surface: "#19232f",
39
+ border: "#344150",
40
+ divider: "#27313e",
41
+ danger: "#f6a69e",
42
+ toolbar: "#131b25",
43
+ muted: "#94a3b8",
44
+ hover: "#253444",
45
+ hoverBorder: "#596b80",
46
+ focus: "#77b6f5",
47
+ accent: "#77b6f5",
48
+ active: "#203146",
49
+ panelBorder: "#43546a",
50
+ panel: "#17212d",
51
+ panelShadow: "0 10px 30px #0005",
52
+ error: "#301e25",
53
+ colorScheme: "dark"
54
+ }),
55
+ render: Object.freeze({ background: "#10151d", grid: "#283444", text: "#aebcca", up: "#58c9ac", down: "#f28b82", line: "#8bafff" })
56
+ }),
57
+ light: Object.freeze({
58
+ chrome: Object.freeze({
59
+ background: "#ffffff",
60
+ text: "#1f2937",
61
+ surface: "#f3f5f8",
62
+ border: "#b8c2d1",
63
+ divider: "#e3e8ef",
64
+ danger: "#b42318",
65
+ toolbar: "#f8fafc",
66
+ muted: "#526176",
67
+ hover: "#e4ebf3",
68
+ hoverBorder: "#71849c",
69
+ focus: "#2962ff",
70
+ accent: "#2459db",
71
+ active: "#e5edff",
72
+ panelBorder: "#b8c2d1",
73
+ panel: "#ffffff",
74
+ panelShadow: "0 10px 30px #10203022",
75
+ error: "#fff0ee",
76
+ colorScheme: "light"
77
+ }),
78
+ render: Object.freeze({ background: "#ffffff", grid: "#e6e9ee", text: "#3a4250", up: "#16a34a", down: "#dc2626", line: "#2962ff" })
79
+ })
80
+ });
81
+
82
+ // packages/bindings/web/src/drawingDocuments.ts
83
+ var DrawingDocuments = class {
84
+ constructor(options) {
85
+ this.options = options;
86
+ this.maxSymbols = options?.maxSymbols ?? 8;
87
+ this.maxBytes = options?.maxBytes ?? 2 * 1024 * 1024;
88
+ if (options !== void 0 && (typeof options.namespace !== "string" || options.namespace.trim() === "" || options.namespace.length > 256))
89
+ throw new RangeError("Drawing storage needs a bounded nonempty namespace");
90
+ if (!Number.isSafeInteger(this.maxSymbols) || this.maxSymbols < 0 || !Number.isSafeInteger(this.maxBytes) || this.maxBytes < 0)
91
+ throw new RangeError("Drawing cache limits must be nonnegative safe integers");
92
+ }
93
+ entries = /* @__PURE__ */ new Map();
94
+ maxSymbols;
95
+ maxBytes;
96
+ get(symbol) {
97
+ const cached = this.entries.get(symbol);
98
+ if (cached !== void 0) {
99
+ this.entries.delete(symbol);
100
+ this.entries.set(symbol, cached);
101
+ return cached.json;
102
+ }
103
+ return this.options?.storage.getItem(this.key(symbol)) ?? void 0;
104
+ }
105
+ set(symbol, json) {
106
+ const previous = this.entries.get(symbol);
107
+ if (previous?.json === json && previous.saved)
108
+ return;
109
+ this.entries.delete(symbol);
110
+ const entry = { json, saved: false };
111
+ this.entries.set(symbol, entry);
112
+ if (this.options !== void 0) {
113
+ const result = this.options.storage.setItem(this.key(symbol), json);
114
+ if (result !== null && (typeof result === "object" || typeof result === "function") && typeof result.then === "function") {
115
+ void Promise.resolve(result).catch(() => {
116
+ });
117
+ throw new TypeError("Drawing storage setItem must finish synchronously");
118
+ }
119
+ entry.saved = true;
120
+ this.trim();
121
+ } else if (json === '{"version":1,"drawings":[]}') {
122
+ this.entries.delete(symbol);
123
+ }
124
+ }
125
+ getStats() {
126
+ let bytes = 0, unsaved = 0;
127
+ for (const [key, entry] of this.entries) {
128
+ bytes += (key.length + entry.json.length) * 2;
129
+ if (!entry.saved)
130
+ unsaved++;
131
+ }
132
+ return { symbols: this.entries.size, bytes, unsaved };
133
+ }
134
+ clear() {
135
+ this.entries.clear();
136
+ }
137
+ key(symbol) {
138
+ return "super-chart:drawings:" + JSON.stringify([this.options?.namespace, symbol]);
139
+ }
140
+ trim() {
141
+ let { bytes } = this.getStats();
142
+ for (const [key, entry] of this.entries) {
143
+ if (this.entries.size <= this.maxSymbols && bytes <= this.maxBytes)
144
+ break;
145
+ if (!entry.saved)
146
+ continue;
147
+ bytes -= (key.length + entry.json.length) * 2;
148
+ this.entries.delete(key);
149
+ }
150
+ }
151
+ };
152
+
153
+ // packages/bindings/web/src/workspaceStyle.ts
154
+ var WORKSPACE_STYLE = `
155
+ .sc-workspace{position:relative;display:flex;flex-direction:column;width:100%;height:100%;min-width:0;min-height:280px;box-sizing:border-box;background:var(--sc-background,#10151d);color:var(--sc-text,#d6deea);font:var(--sc-font-size,12px)/1.4 var(--sc-font,system-ui,-apple-system,"Segoe UI",sans-serif);color-scheme:var(--sc-color-scheme,dark);isolation:isolate}
156
+ .sc-workspace *, .sc-workspace *::before, .sc-workspace *::after{box-sizing:border-box}
157
+ .sc-workspace [hidden]{display:none!important}
158
+ .sc-workspace .sc-row{display:flex;align-items:center;gap:6px;min-width:0;padding:5px 8px;flex-shrink:0;border-bottom:1px solid var(--sc-border,#27313e);background:var(--sc-toolbar-background,#131b25)}
159
+ .sc-workspace .sc-marketbar{flex-wrap:wrap;min-height:42px}
160
+ .sc-workspace .sc-drawingbar{overflow-x:auto;overscroll-behavior-x:contain;scrollbar-width:thin;white-space:nowrap;min-height:39px}
161
+ .sc-workspace .sc-button,.sc-workspace .sc-select,.sc-workspace .sc-input,.sc-workspace .sc-menu>summary{font:inherit;line-height:1.3;min-height:var(--sc-control-height,28px);border:1px solid var(--sc-control-border,#344150);border-radius:var(--sc-radius,4px);background:var(--sc-control-background,#19232f);color:var(--sc-text,#d6deea);padding:5px 8px;margin:0}
162
+ .sc-workspace .sc-button,.sc-workspace .sc-menu>summary{cursor:pointer;white-space:nowrap;flex-shrink:0;user-select:none;touch-action:manipulation}
163
+ .sc-workspace .sc-select{cursor:pointer;max-width:190px;min-width:0;accent-color:var(--sc-accent,#77b6f5)}
164
+ .sc-workspace .sc-input{width:100%;min-width:0;outline-offset:2px}
165
+ .sc-workspace .sc-button:hover:not(:disabled),.sc-workspace .sc-menu>summary:hover,.sc-workspace .sc-select:hover:not(:disabled){background:var(--sc-hover-background,#253444);border-color:var(--sc-hover-border,#596b80)}
166
+ .sc-workspace .sc-button:disabled,.sc-workspace .sc-select:disabled,.sc-workspace .sc-input:disabled{opacity:.4;cursor:not-allowed}
167
+ .sc-workspace .sc-button:focus-visible,.sc-workspace .sc-select:focus-visible,.sc-workspace .sc-input:focus-visible,.sc-workspace summary:focus-visible{outline:2px solid var(--sc-focus,#77b6f5);outline-offset:2px}
168
+ .sc-workspace .sc-button[aria-pressed=true],.sc-workspace .sc-menu[open]>summary{border-color:var(--sc-accent,#77b6f5);color:var(--sc-accent,#77b6f5);background:var(--sc-active-background,#203146)}
169
+ .sc-workspace .sc-primary{border-color:var(--sc-accent,#77b6f5);color:var(--sc-accent,#77b6f5)}
170
+ .sc-workspace .sc-danger:hover:not(:disabled){color:var(--sc-danger,#f6a69e);border-color:var(--sc-danger,#f6a69e)}
171
+ .sc-workspace .sc-symbol{font-weight:650;background:transparent;border-color:transparent;max-width:180px;overflow:hidden;text-overflow:ellipsis;text-align:left}
172
+ .sc-workspace .sc-symbol::after{content:'\u2315';margin-left:10px;color:var(--sc-muted,#94a3b8)}
173
+ .sc-workspace .sc-field{display:flex;align-items:center;gap:6px;min-width:0;margin:0}
174
+ .sc-workspace .sc-field-label{font-size:11px;color:var(--sc-muted,#94a3b8);white-space:nowrap}
175
+ .sc-workspace .sc-marketbar>.sc-field>.sc-field-label,.sc-workspace .sc-drawingbar>.sc-field>.sc-field-label{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
176
+ .sc-workspace .sc-menu{position:static;flex-shrink:0}
177
+ .sc-workspace .sc-menu>summary{display:flex;align-items:center;gap:8px;list-style:none}
178
+ .sc-workspace .sc-menu>summary::-webkit-details-marker{display:none}
179
+ .sc-workspace .sc-menu>summary::after{content:'\u2304';font-size:11px;color:var(--sc-muted,#94a3b8)}
180
+ .sc-workspace .sc-menu-panel,.sc-workspace .sc-search{position:absolute;z-index:8;top:46px;left:8px;right:8px;max-width:520px;max-height:calc(100% - 58px);overflow:auto;overscroll-behavior:contain;border:1px solid var(--sc-panel-border,#43546a);border-radius:var(--sc-panel-radius,6px);background:var(--sc-panel-background,#17212d);box-shadow:var(--sc-panel-shadow,0 10px 30px #0005);padding:12px}
181
+ .sc-workspace .sc-menu:not([open])>.sc-menu-panel{display:none}
182
+ .sc-workspace .sc-settings-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
183
+ .sc-workspace .sc-settings-grid>.sc-field,.sc-workspace .sc-indicator-editor>.sc-field{flex-direction:column;align-items:stretch;gap:5px}
184
+ .sc-workspace .sc-settings-grid .sc-select,.sc-workspace .sc-indicator-editor .sc-select{max-width:none;width:100%}
185
+ .sc-workspace .sc-indicator-add{display:flex;align-items:end;gap:8px;padding-bottom:12px;border-bottom:1px solid var(--sc-border,#344150)}
186
+ .sc-workspace .sc-indicator-add>.sc-field{flex:1;align-items:stretch;flex-direction:column}
187
+ .sc-workspace .sc-indicator-add .sc-select{max-width:none;width:100%}
188
+ .sc-workspace .sc-indicator-list{display:flex;flex-direction:column;gap:8px;padding-top:10px}
189
+ .sc-workspace .sc-indicator-entry{display:flex;align-items:flex-start;gap:8px;padding:8px;border:1px solid var(--sc-border,#344150);border-radius:var(--sc-radius,4px)}
190
+ .sc-workspace .sc-indicator{flex:1;min-width:0}
191
+ .sc-workspace .sc-indicator>summary{cursor:pointer;padding:5px 0;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
192
+ .sc-workspace .sc-indicator-editor{display:grid;gap:10px;margin-top:10px;grid-template-columns:1fr 1fr}
193
+ .sc-workspace .sc-indicator-editor>.sc-field:first-child,.sc-workspace .sc-actions{grid-column:1/-1}
194
+ .sc-workspace .sc-actions{display:flex;gap:6px;flex-wrap:wrap}
195
+ .sc-workspace .sc-hint{color:var(--sc-muted,#94a3b8);margin:4px 0}
196
+ .sc-workspace .sc-stage{position:relative;flex:1;min-height:160px;min-width:0}
197
+ .sc-workspace .sc-chart{position:absolute;inset:0}
198
+ .sc-workspace .sc-stage-status{position:absolute;inset:0;display:grid;place-content:center;padding:24px;color:var(--sc-muted,#94a3b8);text-align:center;overflow-wrap:anywhere;pointer-events:none}
199
+ .sc-workspace[data-state=error] .sc-stage-status{color:var(--sc-danger,#f6a69e)}
200
+ .sc-workspace .sc-notice{flex-shrink:0;padding:8px 12px;color:var(--sc-danger,#f6a69e);background:var(--sc-error-background,#301e25);border-bottom:1px solid var(--sc-border,#344150);overflow-wrap:anywhere}
201
+ .sc-workspace .sc-notice:empty{display:none}
202
+ .sc-workspace .sc-footer{flex-wrap:wrap;border-top:1px solid var(--sc-border,#27313e);border-bottom:0;min-height:36px;font-size:11px}
203
+ .sc-workspace .sc-status{flex:1;min-width:120px;overflow-wrap:anywhere;color:var(--sc-muted,#94a3b8)}
204
+ .sc-workspace .sc-countdown{font-variant-numeric:tabular-nums;color:var(--sc-muted,#94a3b8);white-space:nowrap}
205
+ .sc-workspace .sc-countdown:empty{display:none}
206
+ .sc-workspace .sc-replay-controls{display:flex;align-items:center;flex-wrap:wrap;gap:6px;padding:6px 8px;border-top:1px solid var(--sc-border,#344150);background:var(--sc-toolbar-background,#131b25)}
207
+ .sc-workspace .sc-replay-info{font-variant-numeric:tabular-nums;color:var(--sc-muted,#94a3b8);font-size:11px;overflow-wrap:anywhere}
208
+ .sc-workspace .sc-panel-header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px}
209
+ .sc-workspace .sc-panel-header h2{font:inherit;font-size:14px;font-weight:600;margin:0}
210
+ .sc-workspace .sc-search{z-index:10}
211
+ .sc-workspace .sc-search-input{width:100%;font-size:14px;min-height:36px}
212
+ .sc-workspace .sc-search-status{font-size:11px;color:var(--sc-muted,#94a3b8);padding:9px 0;min-height:32px}
213
+ .sc-workspace .sc-search-results{list-style:none;margin:0;padding:0;max-height:260px;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin}
214
+ .sc-workspace .sc-search-results li+li{margin-top:4px}
215
+ .sc-workspace .sc-search-result{display:flex;align-items:center;gap:12px;width:100%;text-align:left;min-height:38px;white-space:normal}
216
+ .sc-workspace .sc-search-result strong{min-width:70px;flex-shrink:0}
217
+ .sc-workspace .sc-search-result span{color:var(--sc-muted,#94a3b8);font-size:11px;overflow-wrap:anywhere}
218
+ .sc-workspace[data-density=comfortable] .sc-button,.sc-workspace[data-density=comfortable] .sc-select,.sc-workspace[data-density=comfortable] .sc-input,.sc-workspace[data-density=comfortable] .sc-menu>summary{min-height:var(--sc-control-height,36px)}
219
+ .sc-workspace:fullscreen{min-height:0;background:var(--sc-background,#10151d)}
220
+ @media(pointer:coarse){.sc-workspace .sc-button,.sc-workspace .sc-select,.sc-workspace .sc-menu>summary{min-height:var(--sc-touch-target,36px)}}
221
+ @media(forced-colors:active){.sc-workspace .sc-button[aria-pressed=true],.sc-workspace .sc-menu[open]>summary{outline:2px solid Highlight}.sc-workspace .sc-button:disabled{opacity:1;color:GrayText}}
222
+ `;
223
+
224
+ // packages/bindings/web/src/symbolSearchNav.ts
225
+ function moveActiveIndex(current, delta, length) {
226
+ if (length <= 0) {
227
+ return -1;
228
+ }
229
+ if (current < 0) {
230
+ return delta >= 0 ? 0 : length - 1;
231
+ }
232
+ return ((current + delta) % length + length) % length;
233
+ }
234
+ function clampActiveIndex(index, length) {
235
+ if (length <= 0) {
236
+ return -1;
237
+ }
238
+ return Math.max(0, Math.min(length - 1, index));
239
+ }
240
+ function decideEnter(p) {
241
+ if (p.isComposing) {
242
+ return { action: "ignore" };
243
+ }
244
+ if (p.loading || p.shownQuery !== p.currentQuery) {
245
+ return { action: "wait" };
246
+ }
247
+ if (p.resultCount === 0) {
248
+ return { action: "none" };
249
+ }
250
+ return { action: "select", index: clampActiveIndex(p.activeIndex, p.resultCount) };
251
+ }
252
+ function parseTickerArray(raw) {
253
+ return Array.isArray(raw) ? raw.filter((x) => typeof x === "string") : [];
254
+ }
255
+ function createRecentsStore(config, storage) {
256
+ const max = config.max ?? 8;
257
+ if (!Number.isSafeInteger(max) || max < 0 || max > 100)
258
+ throw new RangeError("Recent-symbol limit must be from 0 to 100");
259
+ const key = config.persistKey !== void 0 ? `sc-recent-symbols:${config.persistKey}` : null;
260
+ const read = () => {
261
+ if (key === null || storage === void 0) {
262
+ return parseTickerArray(config.seed ?? []);
263
+ }
264
+ try {
265
+ const raw = storage.getItem(key);
266
+ return raw === null || raw.length > 65536 ? parseTickerArray(config.seed ?? []) : parseTickerArray(JSON.parse(raw));
267
+ } catch {
268
+ return parseTickerArray(config.seed ?? []);
269
+ }
270
+ };
271
+ let mem = [...new Set(read().filter((value) => value.length <= 4096))].slice(0, max);
272
+ return {
273
+ list: () => mem.slice(),
274
+ push: (ticker) => {
275
+ mem = [ticker, ...mem.filter((t) => t !== ticker)].slice(0, max);
276
+ if (key !== null && storage !== void 0) {
277
+ try {
278
+ storage.setItem(key, JSON.stringify(mem));
279
+ } catch {
280
+ }
281
+ }
282
+ }
283
+ };
284
+ }
285
+ function visibleRecents(recentTickers, allSymbols, tickerOf, limit = 5) {
286
+ const byTicker = new Map(allSymbols.map((s) => [tickerOf(s), s]));
287
+ const out = [];
288
+ for (const ticker of recentTickers) {
289
+ const match = byTicker.get(ticker);
290
+ if (match !== void 0) {
291
+ out.push(match);
292
+ if (out.length >= limit) {
293
+ break;
294
+ }
295
+ }
296
+ }
297
+ return out;
298
+ }
299
+
300
+ // packages/bindings/web/src/workspace.ts
301
+ var EMPTY_DRAWINGS = '{"version":1,"drawings":[]}';
302
+ var DEFAULT_PERIODS = [
303
+ { multiplier: 1, timespan: "minute", text: "1m" },
304
+ { multiplier: 5, timespan: "minute", text: "5m" },
305
+ { multiplier: 15, timespan: "minute", text: "15m" },
306
+ { multiplier: 1, timespan: "hour", text: "1h" },
307
+ { multiplier: 4, timespan: "hour", text: "4h" },
308
+ { multiplier: 1, timespan: "day", text: "1D" }
309
+ ];
310
+ var pairKey = (symbol, period) => `${symbolKey(symbol)}:${periodKey(period)}`;
311
+ var label = (value) => value.replace(/-/g, " ");
312
+ function validTheme(theme) {
313
+ if (theme !== "dark" && theme !== "light")
314
+ throw new TypeError('Workspace theme must be "dark" or "light"');
315
+ return theme;
316
+ }
317
+ function validSymbol(symbol) {
318
+ if (typeof symbol?.ticker !== "string" || symbol.ticker.trim() === "")
319
+ throw new TypeError("A symbol ticker is required");
320
+ return { ...symbol };
321
+ }
322
+ function validPeriod(period) {
323
+ if (!Number.isSafeInteger(period?.multiplier) || period.multiplier <= 0 || typeof period.timespan !== "string" || period.timespan.trim() === "" || typeof period.text !== "string") {
324
+ throw new TypeError("A positive interval and timespan are required");
325
+ }
326
+ return { ...period };
327
+ }
328
+ var ChartWorkspace = class {
329
+ chart;
330
+ /** Constructor load outcome; same-tick market changes share its final request. */
331
+ ready;
332
+ doc;
333
+ win;
334
+ abort;
335
+ loader;
336
+ timedReplay;
337
+ countdown;
338
+ loaderOptions;
339
+ loads;
340
+ events;
341
+ loaderUnsubscribe = [];
342
+ datafeed;
343
+ root;
344
+ chartHost;
345
+ statusNode;
346
+ stageStatus;
347
+ replayControls;
348
+ downloadButton;
349
+ downloading = false;
350
+ reportedStatusUI = "";
351
+ notice;
352
+ symbolButton;
353
+ periodSelect;
354
+ drawingSelect;
355
+ indicatorSelect;
356
+ syncPresentation;
357
+ searchPanel;
358
+ searchInput;
359
+ searchResults;
360
+ searchStatus;
361
+ recents;
362
+ recentLimit;
363
+ searchSymbols = [];
364
+ searchActive = -1;
365
+ searchShownQuery = "";
366
+ searchLoading = false;
367
+ retryButton;
368
+ olderButton;
369
+ replayInfo;
370
+ replayButtons;
371
+ replaySpeed;
372
+ drawingButtons;
373
+ unsubscribe = [];
374
+ drawingDocuments;
375
+ drawingSaveTimer = null;
376
+ persistentDrawings;
377
+ lastDrawingList = null;
378
+ downloads = /* @__PURE__ */ new Map();
379
+ actions = /* @__PURE__ */ new WeakMap();
380
+ symbol;
381
+ period;
382
+ periods;
383
+ scale;
384
+ drawingSymbol = null;
385
+ displayedPair = null;
386
+ pending = false;
387
+ waitingStart = false;
388
+ // The loader releases this through applyMoreData, including its delayed
389
+ // failure acknowledgement. Repeated edge gestures must not cancel a page.
390
+ olderPending = false;
391
+ applyError = null;
392
+ cancelled = false;
393
+ reportedLoadState = "";
394
+ reportedReplayState = "";
395
+ searchAbort = null;
396
+ searchTimer = null;
397
+ searchGeneration = 0;
398
+ disposed = false;
399
+ restoreGeneration = 0;
400
+ pendingRestore = null;
401
+ constructor(host, options) {
402
+ if (host === null || typeof host !== "object" || host.ownerDocument == null)
403
+ throw new TypeError("ChartWorkspace needs a host HTMLElement to mount into");
404
+ if (options === null || typeof options !== "object")
405
+ throw new TypeError("ChartWorkspace needs options: { datafeed, symbol, period }");
406
+ if (typeof options.datafeed?.getHistoryKLineData !== "function")
407
+ throw new TypeError("options.datafeed must implement getHistoryKLineData(symbol, period, from, to)");
408
+ this.doc = host.ownerDocument;
409
+ if (this.doc.defaultView === null)
410
+ throw new Error("Workspace host needs an active document");
411
+ this.win = this.doc.defaultView;
412
+ this.abort = new this.win.AbortController();
413
+ this.loads = new WorkspaceLoadQueue((callback) => this.win.queueMicrotask(callback), (result) => this.events.emit("loadResult", result));
414
+ this.events = new WorkspaceEventBus((...args) => this.win.console.warn(...args), (callback) => this.win.queueMicrotask(callback));
415
+ this.loaderOptions = options.loader;
416
+ this.drawingDocuments = new DrawingDocuments(options.drawingCache);
417
+ this.persistentDrawings = options.drawingCache !== void 0;
418
+ this.datafeed = options.datafeed;
419
+ this.symbol = validSymbol(options.symbol);
420
+ this.period = validPeriod(options.period);
421
+ this.recentLimit = options.search?.recentLimit ?? 8;
422
+ if (!Number.isSafeInteger(this.recentLimit) || this.recentLimit < 0 || this.recentLimit > 50)
423
+ throw new RangeError("Recent-symbol limit must be from 0 to 50");
424
+ let recentStorage = options.search?.storage;
425
+ if (options.search?.persistKey !== void 0) {
426
+ if (typeof options.search.persistKey !== "string" || options.search.persistKey.trim() === "" || options.search.persistKey.length > 256)
427
+ throw new RangeError("Recent-symbol storage needs a bounded nonempty host key");
428
+ try {
429
+ recentStorage ??= this.win.localStorage;
430
+ } catch {
431
+ }
432
+ }
433
+ this.recents = createRecentsStore({
434
+ max: this.recentLimit,
435
+ persistKey: options.search?.persistKey === void 0 ? void 0 : `owned:${options.search.persistKey}`,
436
+ seed: options.search?.initialRecent?.slice(0, this.recentLimit).map((symbol) => symbolKey(validSymbol(symbol)))
437
+ }, recentStorage);
438
+ this.periods = [...options.periods ?? DEFAULT_PERIODS].map(validPeriod);
439
+ if (!this.periods.some((period) => periodKey(period) === periodKey(this.period)))
440
+ this.periods.push(this.period);
441
+ this.scale = { ...options.chart?.layout?.priceScale };
442
+ this.root = this.node("div");
443
+ this.root.className = "sc-workspace";
444
+ if (options.ui?.className !== void 0)
445
+ this.root.classList.add(...options.ui.className.split(/\s+/).filter(Boolean));
446
+ const density = options.ui?.density ?? "compact";
447
+ if (density !== "compact" && density !== "comfortable")
448
+ throw new TypeError("Unknown workspace density");
449
+ this.root.dataset.density = density;
450
+ if (options.theme !== void 0)
451
+ this.applyChromeTheme(validTheme(options.theme));
452
+ for (const [name, value] of Object.entries(options.ui?.variables ?? {})) {
453
+ if (!/^--sc-[a-z0-9-]+$/.test(name) || typeof value !== "string")
454
+ throw new TypeError("Workspace variables must use --sc- names and string values");
455
+ this.root.style.setProperty(name, value);
456
+ }
457
+ const style = this.node("style");
458
+ style.textContent = WORKSPACE_STYLE;
459
+ this.root.append(style);
460
+ this.root.setAttribute("aria-label", "Chart workspace");
461
+ this.listen(this.root, "click", (event) => {
462
+ const button = event.target instanceof this.win.Element ? event.target.closest("button") : null;
463
+ if (!this.disposed && button instanceof this.win.HTMLButtonElement && !button.disabled)
464
+ this.actions.get(button)?.();
465
+ });
466
+ const toolbar = this.row("Chart controls"), drawingBar = this.row("Drawing controls"), footer = this.row("Chart playback");
467
+ toolbar.classList.add("sc-marketbar");
468
+ drawingBar.classList.add("sc-drawingbar");
469
+ footer.classList.add("sc-footer");
470
+ const presentation = this.menu("Chart", "Chart presentation"), indicators = this.menu("Indicators", "Manage indicators");
471
+ this.symbolButton = this.button(this.symbol.ticker, () => this.openSearch());
472
+ this.symbolButton.classList.add("sc-symbol");
473
+ this.symbolButton.title = "Find a symbol";
474
+ this.symbolButton.setAttribute("aria-haspopup", "dialog");
475
+ this.symbolButton.setAttribute("aria-expanded", "false");
476
+ this.periodSelect = this.select("Interval", this.periods.map((period) => [periodKey(period), period.text]), periodKey(this.period));
477
+ this.listen(this.periodSelect, "change", () => this.run(() => {
478
+ const period = this.periods.find((item) => periodKey(item) === this.periodSelect.value);
479
+ if (period !== void 0)
480
+ void this.setPeriod(period);
481
+ }));
482
+ const chartType = this.select("Chart type", ENGINE_CHART_TYPES.map((type) => [type, label(type)]), options.chart?.render?.chartType ?? "candles");
483
+ this.listen(chartType, "change", () => {
484
+ this.run(() => this.chart.setRenderOptions({ chartType: chartType.value }));
485
+ this.syncPresentation();
486
+ });
487
+ const mode = this.select("Candles", [["raw", "Original prices"], ["heikin-ashi", "Heikin Ashi"]], options.chart?.mode ?? "raw");
488
+ this.listen(mode, "change", () => {
489
+ this.run(() => this.chart.view.setMode(mode.value));
490
+ this.syncPresentation();
491
+ });
492
+ const scale = this.select("Price scale", [["linear", "Linear"], ["log", "Logarithmic"], ["percentage", "Percentage"]], this.scale.mode ?? "linear");
493
+ this.listen(scale, "change", () => {
494
+ const next = { ...this.chart.getLayoutOptions().priceScale, mode: scale.value };
495
+ if (this.run(() => this.chart.setLayout({ priceScale: next })))
496
+ this.scale = next;
497
+ else
498
+ scale.value = this.scale.mode ?? "linear";
499
+ });
500
+ const reverse = this.button("Reverse scale", () => {
501
+ const current = this.chart.getLayoutOptions().priceScale;
502
+ const next = { ...current, reversed: current?.reversed !== true };
503
+ if (this.run(() => this.chart.setLayout({ priceScale: next }))) {
504
+ this.scale = next;
505
+ reverse.setAttribute("aria-pressed", String(next.reversed));
506
+ }
507
+ });
508
+ reverse.setAttribute("aria-pressed", String(this.scale.reversed === true));
509
+ this.syncPresentation = () => {
510
+ this.scale = { ...this.chart.getLayoutOptions().priceScale };
511
+ chartType.value = this.chart.getRenderOptions().chartType ?? "candles";
512
+ mode.value = this.chart.view.getSnapshot().mode;
513
+ scale.value = this.scale.mode ?? "linear";
514
+ reverse.setAttribute("aria-pressed", String(this.scale.reversed === true));
515
+ };
516
+ presentation.panel.classList.add("sc-settings-grid");
517
+ presentation.panel.append(this.field("Chart type", chartType), this.field("Price mode", mode), this.field("Price scale", scale), reverse);
518
+ toolbar.append(this.symbolButton, this.field("Interval", this.periodSelect), presentation.root, indicators.root);
519
+ this.indicatorSelect = this.select("Add indicator", [], "");
520
+ const indicatorList = this.node("div");
521
+ indicatorList.className = "sc-indicator-list";
522
+ let indicatorEditorDirty = true;
523
+ const renderIndicators = () => {
524
+ if (!indicators.root.open || !indicatorEditorDirty)
525
+ return;
526
+ const active = this.doc.activeElement;
527
+ const focused = active instanceof this.win.HTMLElement && indicatorList.contains(active) ? active : null;
528
+ const focusId = focused?.closest("[data-indicator]")?.dataset.indicator;
529
+ const focusControl = focused?.dataset.control;
530
+ const expanded = new Set([...indicatorList.querySelectorAll("details[open]")].map((item) => item.dataset.indicator));
531
+ const selected = this.indicatorSelect.value || "MA";
532
+ const registered = this.chart.getRegisteredIndicatorNames();
533
+ this.indicatorSelect.replaceChildren(...registered.map((name) => {
534
+ const option = this.node("option");
535
+ option.value = name;
536
+ option.textContent = name;
537
+ return option;
538
+ }));
539
+ if (registered.includes(selected))
540
+ this.indicatorSelect.value = selected;
541
+ const currentIndicators = this.chart.listIndicators();
542
+ this.setText(indicators.summary, `Indicators${currentIndicators.length > 0 ? ` \xB7 ${currentIndicators.length}` : ""}`);
543
+ indicatorList.replaceChildren(...currentIndicators.map((indicator) => {
544
+ const settings = this.node("details"), title = this.node("summary");
545
+ settings.className = "sc-indicator";
546
+ settings.dataset.indicator = indicator.id;
547
+ settings.open = expanded.has(indicator.id);
548
+ title.textContent = `${indicator.name} ${indicator.params.join(",")}${indicator.visible ? "" : " (hidden)"}`;
549
+ const params = this.node("input");
550
+ params.type = "text";
551
+ params.value = indicator.params.join(",");
552
+ params.setAttribute("aria-label", `${indicator.name} parameters`);
553
+ params.className = "sc-input";
554
+ params.dataset.control = "params";
555
+ params.autocomplete = "off";
556
+ params.spellcheck = false;
557
+ const pane = this.select(`${indicator.name} pane`, [["price", "Price pane"], ["separate", "Separate pane"]], indicator.pane);
558
+ const group = this.node("input");
559
+ group.type = "text";
560
+ group.value = indicator.paneId ?? "";
561
+ group.maxLength = 128;
562
+ group.setAttribute("aria-label", `${indicator.name} pane group`);
563
+ group.placeholder = "Independent pane";
564
+ group.className = "sc-input";
565
+ group.dataset.control = "group";
566
+ pane.dataset.control = "pane";
567
+ group.disabled = pane.value !== "separate";
568
+ pane.onchange = () => {
569
+ group.disabled = pane.value !== "separate";
570
+ };
571
+ const apply = this.button("Apply indicator", () => this.run(() => {
572
+ const raw = params.value.trim(), values = raw === "" ? [] : raw.split(",").map((value) => {
573
+ if (value.trim() === "")
574
+ throw new TypeError("Indicator parameters must be numbers separated by commas");
575
+ return Number(value);
576
+ });
577
+ this.chart.updateIndicator(indicator.id, { params: values, pane: pane.value, paneId: pane.value === "separate" ? group.value.trim() || void 0 : void 0 });
578
+ }));
579
+ const toggle = this.button(indicator.visible ? "Hide indicator" : "Show indicator", () => this.run(() => {
580
+ const current = this.chart.listIndicators().find((item) => item.id === indicator.id);
581
+ if (current !== void 0)
582
+ this.chart.updateIndicator(indicator.id, { visible: !current.visible });
583
+ }));
584
+ const remove = this.button("Remove", () => {
585
+ this.run(() => {
586
+ this.chart.removeIndicator(indicator.id);
587
+ });
588
+ });
589
+ remove.setAttribute("aria-label", `Remove ${indicator.name} ${indicator.params.join(",")}`);
590
+ apply.classList.add("sc-primary");
591
+ apply.dataset.control = "apply";
592
+ toggle.dataset.control = "toggle";
593
+ remove.dataset.control = "remove";
594
+ toggle.setAttribute("aria-pressed", String(indicator.visible));
595
+ remove.classList.add("sc-danger");
596
+ params.onkeydown = (event) => {
597
+ if (event.key === "Enter" && !event.isComposing) {
598
+ event.preventDefault();
599
+ apply.click();
600
+ }
601
+ };
602
+ const editor = this.node("div");
603
+ editor.className = "sc-indicator-editor";
604
+ const actions = this.node("div");
605
+ actions.className = "sc-actions";
606
+ actions.append(apply, toggle);
607
+ editor.append(this.field("Parameters", params), this.field("Pane", pane), this.field("Pane group", group), actions);
608
+ settings.append(title, editor);
609
+ const entry = this.node("div");
610
+ entry.className = "sc-indicator-entry";
611
+ entry.dataset.indicator = indicator.id;
612
+ entry.append(settings, remove);
613
+ return entry;
614
+ }));
615
+ if (currentIndicators.length === 0) {
616
+ const empty = this.node("p");
617
+ empty.className = "sc-hint";
618
+ empty.textContent = "No indicators. Choose one above to add it.";
619
+ indicatorList.append(empty);
620
+ }
621
+ if (focused !== null) {
622
+ const entry = [...indicatorList.children].find((node) => node.dataset.indicator === focusId);
623
+ const target = focusControl === void 0 ? entry?.querySelector("summary") : entry?.querySelector(`[data-control="${focusControl}"]`);
624
+ (target ?? indicators.summary).focus({ preventScroll: true });
625
+ }
626
+ indicatorEditorDirty = false;
627
+ };
628
+ const refreshIndicators = () => {
629
+ indicatorEditorDirty = true;
630
+ const count = this.chart.listIndicators().length;
631
+ this.setText(indicators.summary, `Indicators${count > 0 ? ` \xB7 ${count}` : ""}`);
632
+ renderIndicators();
633
+ };
634
+ this.listen(indicators.root, "toggle", renderIndicators);
635
+ const indicatorAdd = this.node("div");
636
+ indicatorAdd.className = "sc-indicator-add";
637
+ const add = this.button("Add", () => {
638
+ this.run(() => {
639
+ this.chart.addIndicator({ name: this.indicatorSelect.value });
640
+ });
641
+ });
642
+ add.classList.add("sc-primary");
643
+ indicatorAdd.append(this.field("Indicator", this.indicatorSelect), add);
644
+ indicators.panel.append(indicatorAdd, indicatorList);
645
+ this.drawingSelect = this.select("Drawing tool", [["", "Pan / select"]], "");
646
+ const toolGroups = /* @__PURE__ */ new Map();
647
+ for (const type of DRAWING_TYPES) {
648
+ const category = /fibonacci|gann/.test(type) ? "Fibonacci & Gann" : /waves|abcd/.test(type) ? "Patterns & waves" : /vwap|volume-profile/.test(type) ? "Data studies" : /brush|highlighter/.test(type) ? "Freehand" : /channel|pitchfork|regression/.test(type) ? "Channels & pitchforks" : /position|range|forecast/.test(type) ? "Measurement & risk" : /text|note|callout|signpost|pin|image|label|flag|arrow/.test(type) ? "Annotations" : /horizontal|vertical|segment|ray|trend-line|price-line/.test(type) ? "Lines" : "Shapes & curves";
649
+ let group = toolGroups.get(category);
650
+ if (group === void 0) {
651
+ group = this.node("optgroup");
652
+ group.label = category;
653
+ toolGroups.set(category, group);
654
+ this.drawingSelect.append(group);
655
+ }
656
+ const option = this.node("option");
657
+ option.value = type;
658
+ option.textContent = label(type);
659
+ group.append(option);
660
+ }
661
+ this.listen(this.drawingSelect, "change", () => this.run(() => {
662
+ this.chart.setDrawingTool(this.drawingSelect.value === "" ? null : this.drawingSelect.value);
663
+ this.chartHost.querySelector('[tabindex="0"]')?.focus({ preventScroll: true });
664
+ }));
665
+ this.drawingButtons = {
666
+ undo: this.button("Undo", () => this.run(() => {
667
+ this.chart.drawings.undo();
668
+ })),
669
+ redo: this.button("Redo", () => this.run(() => {
670
+ this.chart.drawings.redo();
671
+ })),
672
+ lock: this.button("Lock", () => this.run(() => {
673
+ const state = this.chart.drawings.getSnapshot(), selected = state.drawings.find((item) => item.id === state.selectedId);
674
+ if (selected !== void 0)
675
+ this.chart.drawings.update(selected.id, { locked: !selected.locked });
676
+ })),
677
+ remove: this.button("Delete drawing", () => this.run(() => {
678
+ const id = this.chart.drawings.getSnapshot().selectedId;
679
+ if (id !== null)
680
+ this.chart.drawings.remove(id);
681
+ }))
682
+ };
683
+ const navBack = this.button("\u2190", () => this.run(() => {
684
+ this.chart.view.undoNavigation();
685
+ })), navForward = this.button("\u2192", () => this.run(() => {
686
+ this.chart.view.redoNavigation();
687
+ }));
688
+ navBack.title = "Previous chart view (Alt+Left)";
689
+ navBack.setAttribute("aria-label", "Previous chart view");
690
+ navForward.title = "Next chart view (Alt+Right)";
691
+ navForward.setAttribute("aria-label", "Next chart view");
692
+ const updateNavigation = () => {
693
+ const history = this.chart.view.getNavigationHistory();
694
+ navBack.disabled = !history.canUndo;
695
+ navForward.disabled = !history.canRedo;
696
+ };
697
+ navBack.disabled = navForward.disabled = true;
698
+ const latest = this.button("Latest", () => this.run(() => this.chart.view.followLatest()));
699
+ latest.title = "Follow latest bars";
700
+ latest.setAttribute("aria-label", "Follow latest bars");
701
+ this.downloadButton = this.button("PNG", () => {
702
+ void this.download();
703
+ });
704
+ this.downloadButton.title = "Download chart as PNG";
705
+ this.downloadButton.setAttribute("aria-label", "Download chart as PNG");
706
+ this.drawingButtons.remove.classList.add("sc-danger");
707
+ drawingBar.append(this.field("Draw", this.drawingSelect), ...Object.values(this.drawingButtons), navBack, navForward, latest, this.downloadButton);
708
+ if (typeof this.root.requestFullscreen === "function" && typeof this.doc.exitFullscreen === "function") {
709
+ const fullscreen = this.button("Fullscreen", () => {
710
+ void (this.doc.fullscreenElement === this.root ? this.doc.exitFullscreen() : this.root.requestFullscreen()).catch((error) => this.showNotice(error));
711
+ });
712
+ this.listen(this.doc, "fullscreenchange", () => {
713
+ const active = this.doc.fullscreenElement === this.root;
714
+ this.setText(fullscreen, active ? "Exit fullscreen" : "Fullscreen");
715
+ fullscreen.title = fullscreen.textContent ?? "";
716
+ fullscreen.setAttribute("aria-pressed", String(active));
717
+ });
718
+ fullscreen.setAttribute("aria-pressed", "false");
719
+ drawingBar.append(fullscreen);
720
+ }
721
+ this.searchPanel = this.node("div");
722
+ this.searchPanel.hidden = true;
723
+ this.searchPanel.setAttribute("role", "dialog");
724
+ this.searchPanel.setAttribute("aria-label", "Find a symbol");
725
+ this.searchPanel.className = "sc-search";
726
+ this.searchInput = this.node("input");
727
+ this.searchInput.type = "search";
728
+ this.searchInput.placeholder = "Symbol or name";
729
+ this.searchInput.setAttribute("aria-label", "Symbol or name");
730
+ this.searchInput.autocomplete = "off";
731
+ this.searchInput.spellcheck = false;
732
+ this.searchInput.className = "sc-input sc-search-input";
733
+ this.searchStatus = this.node("div");
734
+ this.searchStatus.className = "sc-search-status";
735
+ this.searchStatus.setAttribute("role", "status");
736
+ this.searchResults = this.node("ul");
737
+ this.searchResults.className = "sc-search-results";
738
+ this.searchResults.setAttribute("aria-label", "Matching symbols");
739
+ const searchHeader = this.node("div");
740
+ searchHeader.className = "sc-panel-header";
741
+ const searchTitle = this.node("h2");
742
+ searchTitle.textContent = "Find a symbol";
743
+ searchHeader.append(searchTitle, this.button("Close", () => this.closeSearch()));
744
+ this.searchPanel.append(searchHeader, this.searchInput, this.searchStatus, this.searchResults);
745
+ this.listen(this.searchInput, "input", () => this.queueSearch(200));
746
+ this.listen(this.searchPanel, "keydown", (event) => this.searchKey(event));
747
+ this.listen(this.root, "keydown", (event) => {
748
+ const key = event;
749
+ if (key.isComposing || key.ctrlKey || key.metaKey || key.altKey || !/^[a-z0-9]$/i.test(key.key) || !(key.target instanceof this.win.HTMLElement) || !this.chartHost.contains(key.target) || key.target.closest('input,textarea,select,[contenteditable="true"]') !== null)
750
+ return;
751
+ key.preventDefault();
752
+ this.openSearch(key.key);
753
+ });
754
+ this.listen(this.doc, "pointerdown", (event) => {
755
+ if (!(event.target instanceof this.win.Node))
756
+ return;
757
+ if (!this.searchPanel.hidden && !this.searchPanel.contains(event.target) && !this.symbolButton.contains(event.target))
758
+ this.closeSearch(false);
759
+ for (const menu of this.root.querySelectorAll(".sc-menu[open]"))
760
+ if (!menu.contains(event.target))
761
+ menu.open = false;
762
+ });
763
+ const stage = this.node("div");
764
+ stage.className = "sc-stage";
765
+ this.chartHost = this.node("div");
766
+ this.chartHost.className = "sc-chart";
767
+ this.stageStatus = this.node("div");
768
+ this.stageStatus.className = "sc-stage-status";
769
+ this.stageStatus.setAttribute("aria-hidden", "true");
770
+ this.notice = this.node("div");
771
+ this.notice.className = "sc-notice";
772
+ this.notice.setAttribute("role", "alert");
773
+ this.statusNode = this.node("div");
774
+ this.statusNode.className = "sc-status";
775
+ this.statusNode.setAttribute("role", "status");
776
+ stage.append(this.chartHost, this.stageStatus);
777
+ this.retryButton = this.button("Retry", () => this.reload());
778
+ this.olderButton = this.button("Load older", () => this.run(() => this.chart.view.requestMore()));
779
+ this.replayInfo = this.node("span");
780
+ this.replayButtons = {
781
+ start: this.button("Replay", () => this.run(() => {
782
+ if (!this.startReplay())
783
+ throw new Error("At least three loaded bars are needed for replay");
784
+ })),
785
+ back: this.button("Previous bar", () => this.run(() => {
786
+ this.stepReplay(-1);
787
+ })),
788
+ next: this.button("Next bar", () => this.run(() => {
789
+ this.stepReplay(1);
790
+ })),
791
+ play: this.button("Play replay", () => this.run(() => {
792
+ if (this.getReplayState().playing)
793
+ this.pauseReplay();
794
+ else
795
+ this.playReplay();
796
+ })),
797
+ exit: this.button("Exit replay", () => this.run(() => {
798
+ this.exitReplay();
799
+ }))
800
+ };
801
+ this.replaySpeed = this.select("Replay speed", [0.25, 0.5, 1, 2, 4, 8, 16].map((speed) => [String(speed), `${speed}\xD7`]), "1");
802
+ this.listen(this.replaySpeed, "change", () => this.run(() => this.setReplaySpeed(Number(this.replaySpeed.value))));
803
+ this.replayControls = this.node("div");
804
+ this.replayControls.className = "sc-replay-controls";
805
+ this.replayControls.hidden = true;
806
+ this.replayInfo.className = "sc-replay-info";
807
+ const countdownNode = this.node("span");
808
+ countdownNode.className = "sc-countdown";
809
+ countdownNode.title = "Period boundary estimate; exchange holidays and early closes need a host-provided close time.";
810
+ this.replayControls.append(this.replayButtons.back, this.replayButtons.next, this.replayButtons.play, this.field("Speed", this.replaySpeed), this.replayButtons.exit, this.replayInfo);
811
+ footer.append(this.statusNode, countdownNode, this.replayButtons.start, this.olderButton, this.retryButton);
812
+ this.root.append(toolbar, drawingBar, this.notice, stage, this.replayControls, footer, this.searchPanel);
813
+ host.append(this.root);
814
+ try {
815
+ this.chart = new EngineChart(this.chartHost, {
816
+ ...options.chart,
817
+ render: { ...options.theme === void 0 ? {} : WORKSPACE_THEMES[options.theme].render, ...options.chart?.render, timespan: this.period.timespan, symbolTimezone: this.symbol.timezone },
818
+ onToolComplete: () => {
819
+ this.drawingSelect.value = "";
820
+ options.chart?.onToolComplete?.();
821
+ }
822
+ });
823
+ } catch (error) {
824
+ this.abort.abort();
825
+ this.root.remove();
826
+ throw error;
827
+ }
828
+ this.loader = this.createLoader();
829
+ this.timedReplay = this.createReplay();
830
+ this.countdown = createBarCountdown({
831
+ getContext: () => {
832
+ if (options.countdown?.enabled === false || this.getStatus().state !== "ready" || !this.shownMatches())
833
+ return null;
834
+ const lastBar = this.chart.getLastBar();
835
+ let closeTimestamp;
836
+ if (lastBar !== null && options.countdown?.closeTimestamp !== void 0) {
837
+ try {
838
+ closeTimestamp = options.countdown.closeTimestamp(this.market(), lastBar);
839
+ } catch {
840
+ return null;
841
+ }
842
+ }
843
+ return { marketKey: this.requestedPair(), lastBar, period: this.period, symbolTimezone: this.symbol.timezone, replaying: this.loader.replaying, closeTimestamp };
844
+ },
845
+ onChange: (snapshot) => {
846
+ this.setText(countdownNode, snapshot === null ? "" : `Close \u2248 ${snapshot.text}`);
847
+ },
848
+ hidden: () => this.doc.hidden,
849
+ timer: { setTimeout: (callback, ms) => this.win.setTimeout(callback, ms), clearTimeout: (handle) => this.win.clearTimeout(handle) }
850
+ });
851
+ this.listen(this.doc, "visibilitychange", () => {
852
+ if (this.doc.hidden)
853
+ this.timedReplay.pause();
854
+ this.countdown.refresh();
855
+ });
856
+ this.unsubscribe.push(this.chart.view.subscribe(updateNavigation));
857
+ updateNavigation();
858
+ this.unsubscribe.push(this.chart.onIndicatorsChanged(refreshIndicators));
859
+ this.unsubscribe.push(this.chart.drawings.subscribe(() => {
860
+ this.updateDrawingControls();
861
+ this.queueDrawingSave();
862
+ }));
863
+ this.loader.attach(this.dataPort());
864
+ refreshIndicators();
865
+ this.updateDrawingControls();
866
+ this.ready = this.request();
867
+ }
868
+ getSymbol() {
869
+ return { ...this.symbol };
870
+ }
871
+ getPeriod() {
872
+ return { ...this.period };
873
+ }
874
+ /** Register an optional pack on demand without loading it into every workspace. */
875
+ registerIndicator(definition) {
876
+ this.assertLive();
877
+ this.chart.registerIndicator(definition);
878
+ }
879
+ setSymbol(symbol) {
880
+ return this.setMarket(symbol, this.period);
881
+ }
882
+ setPeriod(period) {
883
+ return this.setMarket(this.symbol, period);
884
+ }
885
+ /** Validate both inputs before changing either; same-tick calls fetch the final pair once. */
886
+ setMarket(symbol, period) {
887
+ this.restoreGeneration++;
888
+ this.pendingRestore = null;
889
+ return this.changeMarket(symbol, period);
890
+ }
891
+ changeMarket(symbol, period) {
892
+ this.assertLive();
893
+ this.saveDrawings();
894
+ const nextSymbol = validSymbol(symbol), nextPeriod = this.resolvePeriod(period);
895
+ const previous = this.market(), changed = pairKey(nextSymbol, nextPeriod) !== this.requestedPair();
896
+ if (symbolKey(nextSymbol) !== symbolKey(this.symbol)) {
897
+ this.scale = { ...this.chart.getLayoutOptions().priceScale, range: void 0 };
898
+ this.chart.setLayout({ priceScale: this.scale });
899
+ }
900
+ if (nextPeriod.timespan !== this.period.timespan || nextSymbol.timezone !== this.symbol.timezone || !changed && nextSymbol.pricePrecision !== this.symbol.pricePrecision) {
901
+ this.chart.setRenderOptions({
902
+ timespan: nextPeriod.timespan,
903
+ symbolTimezone: nextSymbol.timezone,
904
+ ...!changed && nextSymbol.pricePrecision !== this.symbol.pricePrecision ? { pricePrecision: resolvePricePrecision(nextSymbol.pricePrecision, this.chart.dataPort.getDataList()) } : {}
905
+ });
906
+ }
907
+ if (!this.periods.some((item) => periodKey(item) === periodKey(nextPeriod))) {
908
+ this.periods.push(nextPeriod);
909
+ const option = this.node("option");
910
+ option.value = periodKey(nextPeriod);
911
+ option.textContent = nextPeriod.text;
912
+ this.periodSelect.append(option);
913
+ }
914
+ this.symbol = nextSymbol;
915
+ this.period = nextPeriod;
916
+ this.symbolButton.textContent = nextSymbol.ticker;
917
+ this.periodSelect.value = periodKey(nextPeriod);
918
+ const result = changed ? this.request(true) : this.loads.current(this.market()) ?? Promise.resolve(this.currentResult());
919
+ if (changed)
920
+ this.events.emit("marketChange", { previous, requested: this.market() });
921
+ this.updateStatus();
922
+ return result;
923
+ }
924
+ /** Starts a real fresh request even when the selected market is unchanged. */
925
+ reload() {
926
+ this.assertLive();
927
+ this.restoreGeneration++;
928
+ this.pendingRestore = null;
929
+ return this.request();
930
+ }
931
+ /**
932
+ * Abort the pending market load and its history/sync requests. Explicit
933
+ * cancellation retires this loader's cache; the next reload starts fresh.
934
+ * A settled chart is unaffected (returns false).
935
+ */
936
+ cancelLoad() {
937
+ this.assertLive();
938
+ this.restoreGeneration++;
939
+ this.pendingRestore = null;
940
+ const waitingResult = this.loads.cancel();
941
+ if (waitingResult === null && this.getStatus().state !== "error")
942
+ return false;
943
+ const result = waitingResult ?? { status: "cancelled", ...this.market() };
944
+ this.cancelled = true;
945
+ this.pending = false;
946
+ this.waitingStart = false;
947
+ this.olderPending = false;
948
+ this.applyError = null;
949
+ this.displayedPair = null;
950
+ this.timedReplay.dispose();
951
+ this.loaderUnsubscribe.splice(0).forEach((fn) => fn());
952
+ this.loader.dispose();
953
+ this.loader = this.createLoader();
954
+ this.timedReplay = this.createReplay();
955
+ this.loader.attach(this.dataPort());
956
+ this.chart.setDrawingTool(null);
957
+ this.chart.setData([], false);
958
+ this.updateStatus();
959
+ this.events.emit("loadResult", result);
960
+ return true;
961
+ }
962
+ on(type, listener) {
963
+ this.assertLive();
964
+ return this.events.on(type, listener);
965
+ }
966
+ off(type, listener) {
967
+ this.events.off(type, listener);
968
+ }
969
+ /** Detached candle objects; during replay, only revealed candles are returned. */
970
+ getBars(options = {}) {
971
+ this.assertLive();
972
+ if (Object.keys(options).some((key) => key !== "limit"))
973
+ throw new TypeError("Only the bars limit option is supported");
974
+ const limit = options.limit;
975
+ if (limit !== void 0 && (!Number.isSafeInteger(limit) || limit < 0))
976
+ throw new RangeError("Bars limit must be a nonnegative safe integer");
977
+ return this.chart.getBars(options);
978
+ }
979
+ getLoadState() {
980
+ this.assertLive();
981
+ const status = this.getStatus();
982
+ return {
983
+ requested: this.market(),
984
+ loaded: this.displayedPair === this.requestedPair() && this.applyError === null && status.state !== "error" && status.state !== "idle" ? this.market() : null,
985
+ status: this.cancelled ? "cancelled" : status.state,
986
+ loadingMore: status.loadingMore || this.olderPending,
987
+ error: status.error === null ? null : { message: this.errorMessage(status.error.error), phase: status.error.phase, timeout: status.error.timeout }
988
+ };
989
+ }
990
+ getState() {
991
+ this.assertLive();
992
+ const bars = this.chart.dataPort.getDataList(), view = this.chart.view.getSnapshot(), drawings = this.chart.drawings.getSnapshot();
993
+ return {
994
+ schema: 1,
995
+ load: this.getLoadState(),
996
+ replay: this.getReplayState(),
997
+ data: { count: bars.length, firstTimestamp: bars[0]?.timestamp ?? null, lastTimestamp: bars[bars.length - 1]?.timestamp ?? null },
998
+ view: { from: view.viewport.i0, to: view.viewport.i1, following: view.following, mode: view.mode },
999
+ indicators: this.chart.listIndicators().map((item) => ({ ...item, params: [...item.params] })),
1000
+ drawings: { count: drawings.drawings.length, selectedId: drawings.selectedId }
1001
+ };
1002
+ }
1003
+ getReplayState() {
1004
+ this.assertLive();
1005
+ const info = this.loader.replayInfo;
1006
+ return {
1007
+ active: info !== null,
1008
+ index: info?.index ?? null,
1009
+ length: info?.length ?? this.chart.view.getSnapshot().length,
1010
+ timestamp: info !== null && Number.isFinite(info.timestamp) ? info.timestamp : null,
1011
+ atEnd: this.loader.replayAtEnd,
1012
+ playing: this.timedReplay?.getState().playing ?? false,
1013
+ speed: this.timedReplay?.getState().speed ?? 1
1014
+ };
1015
+ }
1016
+ startReplay(at) {
1017
+ this.assertLive();
1018
+ if (at !== void 0 && (!Number.isSafeInteger(at) || at < 0))
1019
+ throw new RangeError("Replay index must be a nonnegative safe integer");
1020
+ const started = this.timedReplay.start({ index: at });
1021
+ this.updateReplay();
1022
+ return started;
1023
+ }
1024
+ stepReplay(delta = 1) {
1025
+ this.assertLive();
1026
+ if (!Number.isSafeInteger(delta))
1027
+ throw new RangeError("Replay step must be a safe integer");
1028
+ if (!this.loader.replaying)
1029
+ throw new Error("Start replay before stepping");
1030
+ this.timedReplay.step(delta);
1031
+ this.updateReplay();
1032
+ return this.getReplayState();
1033
+ }
1034
+ startReplayAt(timestamp) {
1035
+ this.assertLive();
1036
+ return this.timedReplay.startByTimestamp(timestamp);
1037
+ }
1038
+ playReplay(speed) {
1039
+ this.assertLive();
1040
+ if (this.doc.hidden)
1041
+ return false;
1042
+ return this.timedReplay.play(speed);
1043
+ }
1044
+ pauseReplay() {
1045
+ this.assertLive();
1046
+ this.timedReplay.pause();
1047
+ }
1048
+ setReplaySpeed(speed) {
1049
+ this.assertLive();
1050
+ this.timedReplay.setSpeed(speed);
1051
+ }
1052
+ exitReplay() {
1053
+ this.assertLive();
1054
+ this.timedReplay.exit();
1055
+ this.updateReplay();
1056
+ return this.getReplayState();
1057
+ }
1058
+ /** Market, indicators, drawings and presentation only; no feed, code or credentials. */
1059
+ exportDocument() {
1060
+ this.assertLive();
1061
+ const state = this.getLoadState();
1062
+ if (state.loaded === null || !["ready", "empty"].includes(state.status))
1063
+ throw new Error("Load a market before saving its workspace");
1064
+ const { formatPrice, showTimeAxis, timespan, symbolTimezone, ...render } = this.chart.getRenderOptions();
1065
+ const document = {
1066
+ version: 1,
1067
+ ...this.market(),
1068
+ mode: this.chart.view.getSnapshot().mode,
1069
+ render,
1070
+ layout: this.chart.getLayoutOptions(),
1071
+ indicators: this.chart.listIndicators(),
1072
+ drawings: JSON.parse(this.chart.drawings.exportJson())
1073
+ };
1074
+ const json = JSON.stringify(document);
1075
+ parseWorkspaceDocument(json, { registeredIndicatorNames: this.chart.getRegisteredIndicatorNames() });
1076
+ return json;
1077
+ }
1078
+ /**
1079
+ * Validate/decode first, then load the requested market through the usual feed.
1080
+ * A failed or superseded load never applies saved state to a different market.
1081
+ * Registered executable layers are host-owned and are not serialized/restored.
1082
+ */
1083
+ async restoreDocument(json) {
1084
+ this.assertLive();
1085
+ const document = parseWorkspaceDocument(json, { registeredIndicatorNames: this.chart.getRegisteredIndicatorNames() });
1086
+ const period = this.resolvePeriod(document.period);
1087
+ this.chart.validateIndicatorReplacement(document.indicators);
1088
+ const generation = ++this.restoreGeneration;
1089
+ this.pendingRestore = null;
1090
+ const releaseImages = this.chart.holdDrawingImages(document.drawings.drawings);
1091
+ try {
1092
+ try {
1093
+ await this.chart.prepareDrawingImages(document.drawings.drawings);
1094
+ } catch (error) {
1095
+ if (this.disposed)
1096
+ return { status: "disposed" };
1097
+ if (generation !== this.restoreGeneration)
1098
+ return { status: "superseded", by: this.market() };
1099
+ throw error;
1100
+ }
1101
+ if (this.disposed)
1102
+ return { status: "disposed" };
1103
+ if (generation !== this.restoreGeneration)
1104
+ return { status: "superseded", by: this.market() };
1105
+ const configuration = {
1106
+ render: { ...document.render, formatPrice: this.chart.getRenderOptions().formatPrice, timespan: period.timespan, symbolTimezone: document.symbol.timezone },
1107
+ layout: document.layout,
1108
+ mode: document.mode ?? "raw",
1109
+ indicators: document.indicators,
1110
+ drawingsJson: JSON.stringify(document.drawings)
1111
+ };
1112
+ const pending = { generation, key: pairKey(document.symbol, period), configuration, applied: false };
1113
+ this.pendingRestore = pending;
1114
+ this.timedReplay.exit();
1115
+ try {
1116
+ const result = await this.changeMarket(document.symbol, period);
1117
+ if (this.disposed)
1118
+ return { status: "disposed" };
1119
+ if (this.pendingRestore !== pending || generation !== this.restoreGeneration || pending.key !== this.requestedPair())
1120
+ return { status: "superseded", by: this.market() };
1121
+ if ((result.status === "loaded" || result.status === "unchanged") && this.getLoadState().loaded !== null) {
1122
+ if (!pending.applied)
1123
+ this.chart.restoreConfiguration(configuration);
1124
+ this.syncPresentation();
1125
+ this.drawingSymbol = symbolKey(this.symbol);
1126
+ this.drawingDocuments.set(this.drawingSymbol, this.chart.drawings.exportJson());
1127
+ this.updateDrawingControls();
1128
+ this.updateReplay();
1129
+ }
1130
+ return result;
1131
+ } finally {
1132
+ if (this.pendingRestore === pending)
1133
+ this.pendingRestore = null;
1134
+ }
1135
+ } finally {
1136
+ releaseImages();
1137
+ }
1138
+ }
1139
+ getStatus() {
1140
+ if (this.cancelled)
1141
+ return { state: "idle", loadingMore: false, error: null };
1142
+ if (this.applyError !== null)
1143
+ return { state: "error", loadingMore: false, error: { phase: "initial", symbol: this.getSymbol(), period: this.getPeriod(), error: this.applyError, timeout: false } };
1144
+ const state = this.loader.status;
1145
+ const loading = this.pending && (this.waitingStart || state.state !== "error");
1146
+ return { ...state, state: loading ? "loading" : state.state, error: loading || state.error === null ? null : { ...state.error, symbol: { ...state.error.symbol }, period: { ...state.error.period } } };
1147
+ }
1148
+ /**
1149
+ * Switch the chrome and the canvas colours to a preset. The preset's six canvas colours replace the current ones,
1150
+ * including any the host passed in `chart.render`; set those again afterwards through `chart.setRenderOptions`.
1151
+ * Preset chrome variables likewise replace earlier values; other `ui.variables` stay untouched.
1152
+ */
1153
+ setTheme(theme) {
1154
+ if (this.disposed)
1155
+ return;
1156
+ const name = validTheme(theme);
1157
+ this.applyChromeTheme(name);
1158
+ this.chart.setRenderOptions({ ...WORKSPACE_THEMES[name].render });
1159
+ }
1160
+ applyChromeTheme(theme) {
1161
+ for (const key of Object.keys(WORKSPACE_CHROME_VARS))
1162
+ this.root.style.setProperty(WORKSPACE_CHROME_VARS[key], WORKSPACE_THEMES[theme].chrome[key]);
1163
+ }
1164
+ dispose() {
1165
+ if (this.disposed)
1166
+ return;
1167
+ this.saveDrawings();
1168
+ this.disposed = true;
1169
+ this.cancelSearch();
1170
+ this.abort.abort();
1171
+ this.restoreGeneration++;
1172
+ this.pendingRestore = null;
1173
+ this.timedReplay.dispose();
1174
+ this.countdown.dispose();
1175
+ this.loads.dispose();
1176
+ this.events.clear();
1177
+ this.unsubscribe.forEach((fn) => fn());
1178
+ this.loaderUnsubscribe.splice(0).forEach((fn) => fn());
1179
+ this.loader.dispose();
1180
+ for (const [timer, url] of this.downloads) {
1181
+ this.win.clearTimeout(timer);
1182
+ this.win.URL.revokeObjectURL(url);
1183
+ }
1184
+ this.downloads.clear();
1185
+ this.drawingDocuments.clear();
1186
+ try {
1187
+ this.chart.dispose();
1188
+ } finally {
1189
+ this.root.remove();
1190
+ }
1191
+ }
1192
+ /** Flush a completed edit before leaving. Storage errors propagate; the document
1193
+ * stays in memory and disposal can be retried after the host handles the error. */
1194
+ saveDrawings() {
1195
+ if (this.drawingSaveTimer !== null) {
1196
+ this.win.clearTimeout(this.drawingSaveTimer);
1197
+ this.drawingSaveTimer = null;
1198
+ }
1199
+ if (this.drawingSymbol !== null)
1200
+ this.drawingDocuments.set(this.drawingSymbol, this.chart.drawings.exportJson());
1201
+ }
1202
+ getDrawingCacheStats() {
1203
+ return this.drawingDocuments.getStats();
1204
+ }
1205
+ queueDrawingSave() {
1206
+ const list = this.chart.drawings.getSnapshot().drawings;
1207
+ if (list === this.lastDrawingList)
1208
+ return;
1209
+ this.lastDrawingList = list;
1210
+ if (!this.persistentDrawings || this.drawingSymbol === null)
1211
+ return;
1212
+ if (this.drawingSaveTimer !== null)
1213
+ this.win.clearTimeout(this.drawingSaveTimer);
1214
+ this.drawingSaveTimer = this.win.setTimeout(() => {
1215
+ this.drawingSaveTimer = null;
1216
+ try {
1217
+ this.saveDrawings();
1218
+ } catch (error) {
1219
+ this.showNotice(error);
1220
+ }
1221
+ }, 250);
1222
+ }
1223
+ market() {
1224
+ return { symbol: this.getSymbol(), period: this.getPeriod() };
1225
+ }
1226
+ requestedPair() {
1227
+ return pairKey(this.symbol, this.period);
1228
+ }
1229
+ resolvePeriod(input) {
1230
+ const parsed = typeof input === "string" ? parsePeriod(input) : null;
1231
+ if (typeof input === "string" && parsed === null)
1232
+ throw new RangeError("Unsupported interval; use a configured period or a value such as 1m, 4H or 1D");
1233
+ let next = validPeriod(typeof input === "string" ? { ...parsed, text: input.trim() } : input);
1234
+ const configured = this.periods.find((item) => periodKey(item) === periodKey(next));
1235
+ if (configured !== void 0)
1236
+ return typeof input === "string" ? { ...configured } : next;
1237
+ const resolved = this.datafeed.resolvePeriod?.({ multiplier: next.multiplier, timespan: next.timespan });
1238
+ if (resolved == null || periodKey(resolved) !== periodKey(next))
1239
+ throw new RangeError("The datafeed does not support this interval");
1240
+ next = validPeriod(resolved);
1241
+ return next;
1242
+ }
1243
+ createReplay() {
1244
+ return createTimedReplay({
1245
+ loader: this.loader,
1246
+ getDataList: () => this.chart.dataPort.getDataList(),
1247
+ getMarketKey: () => this.getStatus().state === "ready" && this.shownMatches() ? this.requestedPair() : null,
1248
+ onChange: () => this.updateReplay(),
1249
+ timer: { setTimeout: (callback, ms) => this.win.setTimeout(callback, ms), clearTimeout: (handle) => this.win.clearTimeout(handle) }
1250
+ });
1251
+ }
1252
+ createLoader() {
1253
+ const loader = new DataLoader({
1254
+ ...this.loaderOptions,
1255
+ datafeed: this.datafeed,
1256
+ env: {
1257
+ now: () => Date.now(),
1258
+ setTimeout: (fn, ms) => this.win.setTimeout(fn, ms),
1259
+ clearTimeout: (handle) => this.win.clearTimeout(handle),
1260
+ requestFrame: (fn) => this.win.requestAnimationFrame(fn),
1261
+ cancelFrame: (handle) => this.win.cancelAnimationFrame(handle),
1262
+ queueMicrotask: (fn) => this.win.queueMicrotask(fn),
1263
+ hidden: () => this.doc.hidden,
1264
+ visibility: this.doc,
1265
+ connectivity: this.win,
1266
+ warn: (...args) => this.win.console.warn(...args)
1267
+ }
1268
+ });
1269
+ this.loaderUnsubscribe.push(loader.on("status", (status) => {
1270
+ if (this.waitingStart && status.state !== "loading")
1271
+ return;
1272
+ if (status.state === "loading") {
1273
+ this.waitingStart = false;
1274
+ this.pending = true;
1275
+ }
1276
+ if (status.state === "error")
1277
+ this.pending = false;
1278
+ this.restoreDrawings();
1279
+ this.updateStatus();
1280
+ }), loader.on("bars", (event) => {
1281
+ if (pairKey(event.symbol, event.period) === this.requestedPair())
1282
+ this.run(() => this.chart.setRenderOptions({ pricePrecision: resolvePricePrecision(this.symbol.pricePrecision, event.bars) }));
1283
+ }), loader.on("replay", () => this.updateReplay()));
1284
+ return loader;
1285
+ }
1286
+ shownMatches() {
1287
+ const shown = this.loader.shown;
1288
+ return shown !== null && pairKey(shown.symbol, shown.period) === this.requestedPair();
1289
+ }
1290
+ prepareRequest() {
1291
+ this.drawingSymbol = null;
1292
+ this.displayedPair = null;
1293
+ this.pending = true;
1294
+ this.waitingStart = true;
1295
+ this.applyError = null;
1296
+ this.cancelled = false;
1297
+ this.olderPending = false;
1298
+ this.chart.setDrawingTool(null);
1299
+ this.drawingSelect.value = "";
1300
+ this.chart.drawings.importJson(EMPTY_DRAWINGS);
1301
+ this.chart.setData([], false);
1302
+ this.notice.textContent = "";
1303
+ this.updateStatus();
1304
+ }
1305
+ request(drawingsSaved = false) {
1306
+ if (!drawingsSaved)
1307
+ this.saveDrawings();
1308
+ this.timedReplay.pause();
1309
+ const waiting = this.loads.request(this.market());
1310
+ this.prepareRequest();
1311
+ this.loader.request(this.getSymbol(), this.getPeriod());
1312
+ return waiting;
1313
+ }
1314
+ currentResult() {
1315
+ const status = this.getStatus(), market = this.market();
1316
+ if (this.cancelled)
1317
+ return { status: "cancelled", ...market };
1318
+ if (status.state === "error")
1319
+ return { status: "error", ...market, message: this.errorMessage(status.error?.error), timeout: status.error?.timeout ?? false };
1320
+ return { status: "unchanged", ...market };
1321
+ }
1322
+ settleLoad() {
1323
+ if (this.waitingStart || !this.shownMatches())
1324
+ return;
1325
+ const status = this.getStatus(), market = this.market();
1326
+ let result;
1327
+ if (status.state === "error")
1328
+ result = { status: "error", ...market, message: this.errorMessage(status.error?.error), timeout: status.error?.timeout ?? false };
1329
+ else if ((status.state === "ready" || status.state === "empty") && this.displayedPair === this.requestedPair()) {
1330
+ result = { status: "loaded", ...market, bars: this.chart.view.getSnapshot().length };
1331
+ } else
1332
+ return;
1333
+ if (this.loads.settle(market, result))
1334
+ this.events.emit("loadResult", result);
1335
+ }
1336
+ dataPort() {
1337
+ const port = this.chart.dataPort;
1338
+ const apply = (change, kind, initial = false) => {
1339
+ if (this.disposed || this.waitingStart || !(initial && this.loader.status.state === "loading") && !this.shownMatches())
1340
+ return;
1341
+ try {
1342
+ change();
1343
+ this.displayedPair = this.requestedPair();
1344
+ if (this.loader.status.state !== "loading")
1345
+ this.pending = false;
1346
+ this.restoreDrawings();
1347
+ this.updateStatus();
1348
+ this.settleLoad();
1349
+ this.events.emit("dataChange", { market: this.market(), kind, count: this.chart.view.getSnapshot().length });
1350
+ } catch (error) {
1351
+ this.applyError = error;
1352
+ this.pending = false;
1353
+ this.chart.setDrawingTool(null);
1354
+ this.chart.setData([], false);
1355
+ this.updateStatus();
1356
+ this.settleLoad();
1357
+ }
1358
+ };
1359
+ const reset = (bars, more, normalized = false) => apply(() => {
1360
+ const restoring = this.pendingRestore;
1361
+ if (restoring !== null && !restoring.applied && restoring.generation === this.restoreGeneration && restoring.key === this.requestedPair()) {
1362
+ this.chart.restoreConfiguration(restoring.configuration, { bars, more });
1363
+ restoring.applied = true;
1364
+ this.drawingSymbol = symbolKey(this.symbol);
1365
+ } else if (normalized)
1366
+ port.applyNormalizedData(bars, more);
1367
+ else
1368
+ port.applyNewData(bars, more);
1369
+ }, "reset", true);
1370
+ return {
1371
+ applyNewData: (bars, more) => reset(bars, more),
1372
+ applyNormalizedData: (bars, more) => reset(bars, more, true),
1373
+ truncateData: (length, expectedLength, expectedLastTimestamp) => {
1374
+ let applied = false;
1375
+ apply(() => {
1376
+ applied = port.truncateData(length, expectedLength, expectedLastTimestamp);
1377
+ }, "reset");
1378
+ return applied;
1379
+ },
1380
+ applyMoreData: (bars, more) => apply(() => {
1381
+ this.olderPending = false;
1382
+ port.applyMoreData(bars, more);
1383
+ }, "prepend"),
1384
+ updateData: (bar) => apply(() => port.updateData(bar), "tail"),
1385
+ updateDataBatch: (bars, more) => apply(() => port.updateDataBatch?.(bars, more), "tail"),
1386
+ getDataList: port.getDataList,
1387
+ getLastTimestamp: port.getLastTimestamp,
1388
+ cancelHistoryLoad: () => {
1389
+ this.olderPending = false;
1390
+ this.updateStatus();
1391
+ },
1392
+ loadMore: (callback) => port.loadMore?.((timestamp) => {
1393
+ if (this.disposed || this.olderPending || this.getStatus().state !== "ready")
1394
+ return;
1395
+ this.olderPending = true;
1396
+ this.updateStatus();
1397
+ try {
1398
+ callback(timestamp);
1399
+ } catch (error) {
1400
+ this.olderPending = false;
1401
+ this.updateStatus();
1402
+ throw error;
1403
+ }
1404
+ })
1405
+ };
1406
+ }
1407
+ restoreDrawings() {
1408
+ if (this.loader.status.state !== "ready" || !this.shownMatches() || this.displayedPair !== this.requestedPair() || this.applyError !== null)
1409
+ return;
1410
+ this.pending = false;
1411
+ const key = symbolKey(this.symbol);
1412
+ if (this.drawingSymbol === key)
1413
+ return;
1414
+ this.chart.drawings.importJson(this.drawingDocuments.get(key) ?? EMPTY_DRAWINGS);
1415
+ this.drawingSymbol = key;
1416
+ }
1417
+ updateStatus() {
1418
+ if (this.disposed)
1419
+ return;
1420
+ const status = this.getStatus(), pair = `${this.symbol.ticker} \xB7 ${this.period.text}`;
1421
+ const text = this.cancelled ? `Loading cancelled \xB7 ${pair}` : status.state === "loading" ? `Loading ${pair}\u2026` : status.state === "error" ? `Could not load ${pair}: ${this.errorMessage(status.error?.error)}` : status.state === "empty" ? `No data for ${pair}` : status.loadingMore ? `${pair} \xB7 Loading older bars\u2026` : pair;
1422
+ const shown = status.state === "ready" && this.displayedPair === this.requestedPair();
1423
+ const hasMore = this.chart.view.getSnapshot().hasMore;
1424
+ const signatureUI = `${text}|${shown}|${this.olderPending}|${hasMore}|${this.downloading}`;
1425
+ if (signatureUI !== this.reportedStatusUI) {
1426
+ this.reportedStatusUI = signatureUI;
1427
+ this.setText(this.statusNode, text);
1428
+ this.root.dataset.state = this.cancelled ? "cancelled" : status.state;
1429
+ this.chartHost.style.visibility = shown ? "visible" : "hidden";
1430
+ this.chartHost.inert = !shown;
1431
+ this.setText(this.stageStatus, shown ? "" : text);
1432
+ this.stageStatus.hidden = shown;
1433
+ this.drawingSelect.disabled = !shown;
1434
+ this.downloadButton.disabled = !shown || this.downloading;
1435
+ this.retryButton.hidden = status.state !== "error" && !this.cancelled;
1436
+ this.olderButton.hidden = !hasMore && status.state === "ready";
1437
+ this.olderButton.disabled = status.state !== "ready" || status.loadingMore || this.olderPending || !hasMore;
1438
+ this.olderButton.title = status.loadingMore || this.olderPending ? "Older bars are loading" : !hasMore ? "All available history is loaded" : "Load older bars";
1439
+ }
1440
+ this.updateReplay();
1441
+ const state = this.getLoadState(), signature = JSON.stringify(state);
1442
+ if (signature !== this.reportedLoadState) {
1443
+ this.reportedLoadState = signature;
1444
+ this.events.emit("loadState", state);
1445
+ }
1446
+ }
1447
+ updateReplay() {
1448
+ if (this.disposed)
1449
+ return;
1450
+ const info = this.loader.replayInfo;
1451
+ const focused = this.doc.activeElement;
1452
+ const entering = info !== null && this.replayControls.hidden;
1453
+ const leaving = info === null && !this.replayControls.hidden;
1454
+ this.replayButtons.start.disabled = this.getStatus().state !== "ready" || info !== null || this.chart.view.getSnapshot().length < 3;
1455
+ this.replayButtons.back.disabled = info === null || info.index === 0;
1456
+ this.replayButtons.next.disabled = info === null || this.loader.replayAtEnd;
1457
+ this.replayButtons.exit.disabled = info === null;
1458
+ const playback = this.timedReplay?.getState();
1459
+ this.replayButtons.play.disabled = info === null || this.loader.replayAtEnd;
1460
+ this.setText(this.replayButtons.play, playback?.playing === true ? "Pause replay" : "Play replay");
1461
+ this.replayButtons.play.title = this.replayButtons.play.textContent ?? "";
1462
+ this.replayButtons.play.setAttribute("aria-pressed", String(playback?.playing === true));
1463
+ this.replayControls.hidden = info === null;
1464
+ this.replayButtons.start.hidden = info !== null;
1465
+ this.replaySpeed.disabled = info === null;
1466
+ if (entering && focused === this.replayButtons.start) {
1467
+ (this.replayButtons.play.disabled ? this.replayButtons.exit : this.replayButtons.play).focus({ preventScroll: true });
1468
+ } else if (leaving && focused !== null && this.replayControls.contains(focused)) {
1469
+ (this.replayButtons.start.disabled ? this.symbolButton : this.replayButtons.start).focus({ preventScroll: true });
1470
+ }
1471
+ const speed = String(playback?.speed ?? 1);
1472
+ if (![...this.replaySpeed.options].some((option) => option.value === speed)) {
1473
+ this.replaySpeed.querySelector("option[data-custom]")?.remove();
1474
+ const option = this.node("option");
1475
+ option.value = speed;
1476
+ option.textContent = `${speed}\xD7`;
1477
+ option.dataset.custom = "true";
1478
+ this.replaySpeed.append(option);
1479
+ }
1480
+ this.replaySpeed.value = speed;
1481
+ this.countdown?.refresh();
1482
+ this.setText(this.replayInfo, info === null ? "" : `${info.index + 1} / ${info.length} \xB7 ${Number.isFinite(info.timestamp) ? new Date(info.timestamp).toISOString().replace("T", " ").replace(".000Z", " UTC") : ""}`);
1483
+ const state = this.getReplayState(), signature = JSON.stringify(state);
1484
+ if (signature !== this.reportedReplayState) {
1485
+ this.reportedReplayState = signature;
1486
+ this.events.emit("replayChange", state);
1487
+ }
1488
+ }
1489
+ updateDrawingControls() {
1490
+ const state = this.chart.drawings.getSnapshot(), selected = state.drawings.find((item) => item.id === state.selectedId);
1491
+ this.drawingButtons.undo.disabled = !state.canUndo;
1492
+ this.drawingButtons.redo.disabled = !state.canRedo;
1493
+ this.drawingButtons.lock.disabled = selected === void 0;
1494
+ this.setText(this.drawingButtons.lock, selected?.locked === true ? "Unlock" : "Lock");
1495
+ this.drawingButtons.lock.setAttribute("aria-pressed", String(selected?.locked === true));
1496
+ this.drawingButtons.lock.title = selected === void 0 ? "Select a drawing to lock it" : selected.locked ? "Unlock selected drawing" : "Lock selected drawing";
1497
+ this.drawingButtons.remove.title = selected?.locked === true ? "Unlock this drawing before deleting it" : "Delete selected drawing";
1498
+ this.drawingButtons.remove.disabled = selected === void 0 || selected.locked;
1499
+ }
1500
+ openSearch(initialQuery) {
1501
+ if (this.disposed)
1502
+ return;
1503
+ if (initialQuery !== void 0)
1504
+ this.searchInput.value = initialQuery;
1505
+ for (const menu of this.root.querySelectorAll(".sc-menu[open]"))
1506
+ menu.open = false;
1507
+ const toolbar = this.root.querySelector(".sc-marketbar");
1508
+ if (toolbar !== null)
1509
+ this.searchPanel.style.top = `${toolbar.offsetHeight + 4}px`;
1510
+ this.searchPanel.hidden = false;
1511
+ this.symbolButton.setAttribute("aria-expanded", "true");
1512
+ this.searchInput.focus();
1513
+ this.queueSearch(0);
1514
+ }
1515
+ closeSearch(focus = true) {
1516
+ this.cancelSearch();
1517
+ this.searchPanel.hidden = true;
1518
+ this.symbolButton.setAttribute("aria-expanded", "false");
1519
+ if (focus && !this.disposed)
1520
+ this.symbolButton.focus();
1521
+ }
1522
+ cancelSearch() {
1523
+ this.searchGeneration++;
1524
+ this.searchAbort?.abort();
1525
+ this.searchAbort = null;
1526
+ if (this.searchTimer !== null)
1527
+ this.win.clearTimeout(this.searchTimer);
1528
+ this.searchTimer = null;
1529
+ this.searchLoading = false;
1530
+ }
1531
+ queueSearch(delay) {
1532
+ this.cancelSearch();
1533
+ this.searchResults.replaceChildren();
1534
+ this.searchStatus.textContent = "Searching\u2026";
1535
+ this.searchResults.setAttribute("aria-busy", "true");
1536
+ this.searchLoading = true;
1537
+ this.searchSymbols = [];
1538
+ this.searchActive = -1;
1539
+ const generation = this.searchGeneration;
1540
+ this.searchTimer = this.win.setTimeout(() => {
1541
+ this.searchTimer = null;
1542
+ void this.search(generation);
1543
+ }, delay);
1544
+ }
1545
+ async search(generation) {
1546
+ const controller = new this.win.AbortController();
1547
+ this.searchAbort = controller;
1548
+ const query = this.searchInput.value.trim();
1549
+ try {
1550
+ const symbols = await this.datafeed.searchSymbols(query, { signal: controller.signal });
1551
+ if (this.disposed || controller.signal.aborted || generation !== this.searchGeneration)
1552
+ return;
1553
+ if (!Array.isArray(symbols))
1554
+ throw new TypeError("Search returned an invalid symbol list");
1555
+ const offered = /* @__PURE__ */ new Map();
1556
+ for (const symbol of symbols) {
1557
+ if (typeof symbol?.ticker !== "string" || symbol.ticker.trim() === "")
1558
+ continue;
1559
+ const copy = validSymbol(symbol);
1560
+ offered.set(symbolKey(copy), copy);
1561
+ if (offered.size >= 200)
1562
+ break;
1563
+ }
1564
+ const candidates = [...offered.values()];
1565
+ const recent = query === "" && this.recentLimit > 0 ? visibleRecents(this.recents.list(), candidates, symbolKey, this.recentLimit) : [];
1566
+ const recentKeys = new Set(recent.map(symbolKey));
1567
+ const results = [...recent, ...candidates.filter((symbol) => !recentKeys.has(symbolKey(symbol)))].slice(0, 50);
1568
+ this.searchSymbols = results;
1569
+ this.searchShownQuery = query;
1570
+ this.searchLoading = false;
1571
+ this.searchActive = -1;
1572
+ this.searchResults.setAttribute("aria-busy", "false");
1573
+ this.searchStatus.textContent = results.length === 0 ? "No matching symbols" : symbols.length > 50 ? "Showing the first 50 matches" : `${results.length} matches`;
1574
+ this.searchResults.replaceChildren(...results.map((symbol, index) => {
1575
+ const item = this.node("li"), button = this.button(`${symbol.ticker}${symbol.name ? ` \xB7 ${symbol.name}` : ""}${symbol.exchange ? ` \xB7 ${symbol.exchange}` : ""}`, () => {
1576
+ this.chooseSearchResult(index);
1577
+ });
1578
+ button.classList.add("sc-search-result");
1579
+ const ticker = this.node("strong");
1580
+ ticker.textContent = symbol.ticker;
1581
+ const description = this.node("span");
1582
+ description.textContent = [symbol.name, symbol.exchange].filter(Boolean).join(" \xB7 ");
1583
+ button.replaceChildren(ticker, description);
1584
+ item.append(button);
1585
+ return item;
1586
+ }));
1587
+ } catch (error) {
1588
+ if (this.disposed || controller.signal.aborted || generation !== this.searchGeneration)
1589
+ return;
1590
+ this.searchStatus.replaceChildren(this.doc.createTextNode(`Search failed: ${this.errorMessage(error)} `), this.button("Retry search", () => this.queueSearch(0)));
1591
+ this.searchLoading = false;
1592
+ this.searchResults.setAttribute("aria-busy", "false");
1593
+ } finally {
1594
+ if (this.searchAbort === controller)
1595
+ this.searchAbort = null;
1596
+ }
1597
+ }
1598
+ chooseSearchResult(index) {
1599
+ const decision = decideEnter({ isComposing: false, shownQuery: this.searchShownQuery, currentQuery: this.searchInput.value.trim(), loading: this.searchLoading, activeIndex: index, resultCount: this.searchSymbols.length });
1600
+ if (decision.action !== "select")
1601
+ return;
1602
+ const symbol = this.searchSymbols[decision.index];
1603
+ if (this.run(() => {
1604
+ void this.setSymbol(symbol);
1605
+ })) {
1606
+ this.recents.push(symbolKey(symbol));
1607
+ this.closeSearch();
1608
+ }
1609
+ }
1610
+ searchKey(event) {
1611
+ if (event.isComposing)
1612
+ return;
1613
+ if (event.key === "Escape") {
1614
+ event.preventDefault();
1615
+ event.stopPropagation();
1616
+ this.closeSearch();
1617
+ return;
1618
+ }
1619
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
1620
+ return;
1621
+ if (event.key === "Enter" && event.target === this.searchInput) {
1622
+ event.preventDefault();
1623
+ this.chooseSearchResult(this.searchActive);
1624
+ return;
1625
+ }
1626
+ if (this.searchLoading || !(event.target instanceof this.win.Node) || event.target !== this.searchInput && !this.searchResults.contains(event.target))
1627
+ return;
1628
+ const buttons = [...this.searchResults.querySelectorAll("button")];
1629
+ const focused = buttons.indexOf(this.doc.activeElement);
1630
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
1631
+ this.searchActive = moveActiveIndex(focused, event.key === "ArrowDown" ? 1 : -1, buttons.length);
1632
+ } else if ((event.key === "Home" || event.key === "End") && focused !== -1) {
1633
+ this.searchActive = event.key === "Home" ? 0 : buttons.length - 1;
1634
+ } else
1635
+ return;
1636
+ event.preventDefault();
1637
+ const selected = buttons[this.searchActive];
1638
+ selected?.focus({ preventScroll: true });
1639
+ selected?.scrollIntoView({ block: "nearest" });
1640
+ }
1641
+ async download() {
1642
+ if (this.disposed || this.downloading)
1643
+ return;
1644
+ this.downloading = true;
1645
+ this.setText(this.downloadButton, "Saving\u2026");
1646
+ this.updateStatus();
1647
+ try {
1648
+ if (this.getStatus().state !== "ready")
1649
+ throw new Error("Load chart data before downloading an image");
1650
+ const filename = `${this.symbol.ticker.replace(/[^a-z0-9._-]/gi, "_")}-${this.period.text.replace(/[^a-z0-9._-]/gi, "_")}.png`;
1651
+ const blob = await this.chart.snapshot();
1652
+ if (this.disposed)
1653
+ return;
1654
+ const url = this.win.URL.createObjectURL(blob), link = this.node("a");
1655
+ link.href = url;
1656
+ link.download = filename;
1657
+ this.root.append(link);
1658
+ link.click();
1659
+ link.remove();
1660
+ const timer = this.win.setTimeout(() => {
1661
+ this.win.URL.revokeObjectURL(url);
1662
+ this.downloads.delete(timer);
1663
+ }, 1e3);
1664
+ this.downloads.set(timer, url);
1665
+ } catch (error) {
1666
+ this.showNotice(error);
1667
+ } finally {
1668
+ this.downloading = false;
1669
+ if (!this.disposed) {
1670
+ this.setText(this.downloadButton, "PNG");
1671
+ this.updateStatus();
1672
+ }
1673
+ }
1674
+ }
1675
+ node(tag) {
1676
+ return this.doc.createElement(tag);
1677
+ }
1678
+ listen(target, type, listener) {
1679
+ target.addEventListener(type, listener, { signal: this.abort.signal });
1680
+ }
1681
+ button(text, action) {
1682
+ const button = this.node("button");
1683
+ button.type = "button";
1684
+ button.textContent = text;
1685
+ button.className = "sc-button";
1686
+ button.title = text;
1687
+ this.actions.set(button, action);
1688
+ return button;
1689
+ }
1690
+ select(name, values, selected) {
1691
+ const select = this.node("select");
1692
+ select.setAttribute("aria-label", name);
1693
+ select.className = "sc-select";
1694
+ select.title = name;
1695
+ for (const [value, text] of values) {
1696
+ const option = this.node("option");
1697
+ option.value = value;
1698
+ option.textContent = text;
1699
+ select.append(option);
1700
+ }
1701
+ select.value = selected;
1702
+ return select;
1703
+ }
1704
+ row(name) {
1705
+ const row = this.node("div");
1706
+ row.setAttribute("role", "toolbar");
1707
+ row.setAttribute("aria-label", name);
1708
+ row.className = "sc-row";
1709
+ return row;
1710
+ }
1711
+ field(name, control) {
1712
+ const field = this.node("label");
1713
+ field.className = "sc-field";
1714
+ const caption = this.node("span");
1715
+ caption.className = "sc-field-label";
1716
+ caption.textContent = name;
1717
+ field.append(caption, control);
1718
+ return field;
1719
+ }
1720
+ setText(node, value) {
1721
+ if (node.textContent !== value)
1722
+ node.textContent = value;
1723
+ }
1724
+ menu(name, description) {
1725
+ const root = this.node("details");
1726
+ root.className = "sc-menu";
1727
+ const summary = this.node("summary");
1728
+ summary.textContent = name;
1729
+ summary.title = description;
1730
+ const panel = this.node("div");
1731
+ panel.className = "sc-menu-panel";
1732
+ panel.setAttribute("role", "group");
1733
+ panel.setAttribute("aria-label", description);
1734
+ root.append(summary, panel);
1735
+ this.listen(root, "toggle", () => {
1736
+ if (root.open) {
1737
+ const toolbar = this.root.querySelector(".sc-marketbar");
1738
+ if (toolbar !== null)
1739
+ panel.style.top = `${toolbar.offsetHeight + 4}px`;
1740
+ this.closeSearch(false);
1741
+ for (const other of this.root.querySelectorAll(".sc-menu[open]"))
1742
+ if (other !== root)
1743
+ other.open = false;
1744
+ }
1745
+ });
1746
+ this.listen(root, "keydown", (event) => {
1747
+ const key = event;
1748
+ if (key.key === "Escape" && root.open) {
1749
+ key.preventDefault();
1750
+ key.stopPropagation();
1751
+ root.open = false;
1752
+ summary.focus();
1753
+ }
1754
+ });
1755
+ return { root, summary, panel };
1756
+ }
1757
+ run(action) {
1758
+ if (this.disposed)
1759
+ return false;
1760
+ try {
1761
+ action();
1762
+ this.notice.textContent = "";
1763
+ return true;
1764
+ } catch (error) {
1765
+ this.showNotice(error);
1766
+ return false;
1767
+ }
1768
+ }
1769
+ errorMessage(error) {
1770
+ return error instanceof Error ? error.message : String(error ?? "Unknown error");
1771
+ }
1772
+ showNotice(error) {
1773
+ if (!this.disposed)
1774
+ this.notice.textContent = this.errorMessage(error);
1775
+ }
1776
+ assertLive() {
1777
+ if (this.disposed)
1778
+ throw new Error("Chart workspace is disposed");
1779
+ }
1780
+ };
1781
+
1782
+ // packages/bindings/web/src/index.ts
1783
+ import { ENGINE_CHART_TYPES as ENGINE_CHART_TYPES2, EngineIndicators, DrawingStore, projectDrawing, hitTestDrawingGeometry, DRAWING_TYPES as DRAWING_TYPES2, drawingAnchorCount, createPriceScale, buildEngineFrame, parseWorkspaceDocument as parseWorkspaceDocument2, WORKSPACE_DOCUMENT_LIMITS, validateDrawingImage, DRAWING_IMAGE_LIMITS } from "@supersolutions/super-chart-core";
1784
+ export {
1785
+ ChartWorkspace,
1786
+ DRAWING_IMAGE_LIMITS,
1787
+ DRAWING_TYPES2 as DRAWING_TYPES,
1788
+ DrawingImageRenderer,
1789
+ DrawingStore,
1790
+ ENGINE_CHART_TYPES2 as ENGINE_CHART_TYPES,
1791
+ EngineChart,
1792
+ EngineIndicators,
1793
+ ChartWorkspace as SuperChart,
1794
+ WORKSPACE_CHROME_VARS,
1795
+ WORKSPACE_DOCUMENT_LIMITS,
1796
+ WORKSPACE_THEMES,
1797
+ buildEngineFrame,
1798
+ createDrawingImage,
1799
+ createPriceScale,
1800
+ drawingAnchorCount,
1801
+ hitTestDrawingGeometry,
1802
+ paintEngineCursor,
1803
+ paintEngineFrame,
1804
+ parseWorkspaceDocument2 as parseWorkspaceDocument,
1805
+ projectDrawing,
1806
+ validateDrawingImage
1807
+ };