@supersolutions/super-chart-web 0.0.0-stage → 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/LICENSE +674 -0
- package/README.md +8 -2
- package/dist/annotations.d.ts +124 -0
- package/dist/annotations.js +315 -0
- package/dist/chunk-PTYNDDEE.js +3082 -0
- package/dist/engine.d.ts +147 -0
- package/dist/engine.js +6 -0
- package/dist/index.d.ts +413 -0
- package/dist/index.js +1807 -0
- package/dist/overlays.d.ts +24 -0
- package/dist/overlays.js +109 -0
- package/dist/super-chart.engine.global.js +7 -0
- package/dist/super-chart.global.js +75 -0
- package/package.json +65 -4
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
|
+
};
|