@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/README.md CHANGED
@@ -1,3 +1,9 @@
1
- # Temporary Holding Version
1
+ # @supersolutions/super-chart-web
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Canvas chart engine and workspace for the browser: `SuperChart` / `ChartWorkspace`,
4
+ `EngineChart`, 65 drawing tools, replay, validated workspace JSON.
5
+
6
+ Entries: `.`, `/annotations` (symbol-scoped level, zone and marker layers), `/overlays`
7
+ (volume profile, fair-value gaps, order blocks). Give the host element an explicit height.
8
+
9
+ License: GPL-3.0-or-later; commercial licence available from SUPER SOLUTION LABS INC.
@@ -0,0 +1,124 @@
1
+ import { EngineFrame, MarketBar } from '@supersolutions/super-chart-core';
2
+
3
+ /** Optional paint layer. Callbacks read borrowed data; they must not mutate chart state. */
4
+ export interface EngineLayer {
5
+ update: (frame: EngineFrame, revealedRawBars: readonly MarketBar[]) => void;
6
+ paint: (context: CanvasRenderingContext2D) => void;
7
+ dispose?: () => void;
8
+ }
9
+ export type EngineAnnotationTone = "bull" | "bear" | "neutral" | "accent";
10
+ export type EngineAnnotationKind = "level" | "zone" | "marker";
11
+ export type EngineAnnotationTheme = "light" | "dark";
12
+ export interface EngineAnnotationAnchor {
13
+ readonly time: number;
14
+ readonly price?: number;
15
+ }
16
+ export interface EngineAnnotationSpec {
17
+ readonly key: string;
18
+ readonly kind: EngineAnnotationKind;
19
+ readonly anchors: readonly EngineAnnotationAnchor[];
20
+ readonly text?: string;
21
+ readonly tone?: EngineAnnotationTone;
22
+ readonly color?: string;
23
+ }
24
+ export type EngineAnnotationRejectCode = "bad_anchor" | "out_of_range" | "unknown_kind" | "duplicate_key" | "bad_key" | "bad_style" | "symbol_mismatch";
25
+ export interface EngineAnnotationRejection {
26
+ readonly key: string;
27
+ readonly code: EngineAnnotationRejectCode;
28
+ }
29
+ export interface EngineSetAnnotationsResult {
30
+ readonly applied: readonly string[];
31
+ readonly rejected: readonly EngineAnnotationRejection[];
32
+ }
33
+ export interface EngineAnnotationBounds {
34
+ readonly left: number;
35
+ readonly right: number;
36
+ readonly top: number;
37
+ readonly bottom: number;
38
+ }
39
+ export interface EngineAnnotationPoint {
40
+ readonly x: number;
41
+ readonly y: number;
42
+ }
43
+ export interface EngineAnnotationRectangle {
44
+ readonly x: number;
45
+ readonly y: number;
46
+ readonly width: number;
47
+ readonly height: number;
48
+ }
49
+ export interface EngineProjectedAnnotation {
50
+ readonly id: string;
51
+ readonly layer: string;
52
+ readonly spec: EngineAnnotationSpec;
53
+ readonly color: string;
54
+ readonly status: "visible" | "offscreen" | "unprojectable" | "no-data";
55
+ readonly point?: EngineAnnotationPoint;
56
+ readonly rect?: EngineAnnotationRectangle;
57
+ }
58
+ export interface EngineAnnotationSnapshot {
59
+ readonly version: number;
60
+ readonly symbol: string | null;
61
+ readonly bounds: EngineAnnotationBounds | null;
62
+ readonly annotations: readonly EngineProjectedAnnotation[];
63
+ }
64
+ export interface EngineAnnotationsOptions {
65
+ /** Use a stable market identity, including venue/network when tickers collide. */
66
+ symbol: string | null;
67
+ theme?: EngineAnnotationTheme;
68
+ font?: string;
69
+ /** Request a paint only: changed annotations reuse the latest frame geometry. */
70
+ onChange?: () => void;
71
+ }
72
+ /** Explicit host-resource bounds. Exceeding a document bound leaves its previous contents intact. */
73
+ export declare const ENGINE_ANNOTATION_LIMITS: Readonly<{
74
+ scopes: 16;
75
+ layersPerScope: 32;
76
+ entriesPerLayer: 1024;
77
+ entriesPerScope: 2048;
78
+ keyLength: 128;
79
+ symbolLength: 256;
80
+ textLength: 2048;
81
+ colorLength: 128;
82
+ }>;
83
+ /**
84
+ * Host/AI annotations are retained separately from user drawings and never enter
85
+ * their selection or undo history. Layer replacement is keyed and scope-local.
86
+ * Malformed individual entries are rejected; as in the existing annotation API,
87
+ * only accepted keys remain after replacement. Duplicate keys keep the first.
88
+ *
89
+ * Time anchors interpolate on the engine's loaded category timeline, including
90
+ * explicit times outside loaded history. Lines span the price pane; zones and
91
+ * markers are clipped there. They do not expand the candle/indicator price scale.
92
+ * This is an owned annotation contract, not arbitrary upstream overlay support.
93
+ */
94
+ export declare class EngineAnnotations implements EngineLayer {
95
+ private readonly scopes;
96
+ private readonly onChange;
97
+ private readonly font;
98
+ private symbol;
99
+ private currentTheme;
100
+ private frame;
101
+ private version;
102
+ private disposed;
103
+ private snapshot;
104
+ constructor(options: EngineAnnotationsOptions);
105
+ /** Null hides all annotations during async loading. A new scope needs a fresh frame. */
106
+ setScope(symbol: string | null): void;
107
+ setTheme(value: EngineAnnotationTheme): void;
108
+ setAnnotations(layer: string, specs: readonly EngineAnnotationSpec[], options?: {
109
+ symbol?: string;
110
+ }): EngineSetAnnotationsResult;
111
+ getAnnotations(layer?: string): readonly EngineAnnotationSpec[];
112
+ clearAnnotations(layer: string): void;
113
+ /** Releases retained layers for one symbol without evicting any other symbol. */
114
+ clearScope(symbol?: string | null): void;
115
+ readonly getSnapshot: () => EngineAnnotationSnapshot;
116
+ update(frame: EngineFrame): void;
117
+ paint(context: CanvasRenderingContext2D): void;
118
+ dispose(): void;
119
+ private assertLive;
120
+ private changed;
121
+ private project;
122
+ }
123
+
124
+ export {};
@@ -0,0 +1,315 @@
1
+ // packages/bindings/web/src/tokens.ts
2
+ var CANVAS_PALETTE = {
3
+ light: { up: "#089981", down: "#F23645", accent: "#2962FF", textSecond: "#5B6270" },
4
+ dark: { up: "#089981", down: "#F23645", accent: "#2962FF", textSecond: "#929AA5" }
5
+ };
6
+
7
+ // packages/bindings/web/src/annotations.ts
8
+ var ENGINE_ANNOTATION_LIMITS = Object.freeze({ scopes: 16, layersPerScope: 32, entriesPerLayer: 1024, entriesPerScope: 2048, keyLength: 128, symbolLength: 256, textLength: 2048, colorLength: 128 });
9
+ var EMPTY_SPECS = Object.freeze([]);
10
+ var KINDS = ["level", "zone", "marker"];
11
+ var TONES = ["bull", "bear", "neutral", "accent"];
12
+ function named(value, limit, name) {
13
+ if (typeof value !== "string" || value.trim().length === 0 || value.length > limit)
14
+ throw new TypeError(`Invalid annotation ${name}`);
15
+ return value;
16
+ }
17
+ function scope(value) {
18
+ return value === null ? null : named(value, ENGINE_ANNOTATION_LIMITS.symbolLength, "symbol");
19
+ }
20
+ function theme(value) {
21
+ if (value !== "light" && value !== "dark")
22
+ throw new TypeError("Invalid annotation theme");
23
+ return value;
24
+ }
25
+ function annotationId(layer, key) {
26
+ return `sc:ann:${encodeURIComponent(layer)}:${encodeURIComponent(key)}`;
27
+ }
28
+ function copiedSpec(value) {
29
+ if (value === null || typeof value !== "object")
30
+ return { code: "bad_anchor" };
31
+ if (typeof value.key !== "string" || value.key.trim().length === 0 || value.key.length > ENGINE_ANNOTATION_LIMITS.keyLength)
32
+ return { code: "bad_key" };
33
+ if (!KINDS.includes(value.kind))
34
+ return { code: "unknown_kind" };
35
+ const count = value.kind === "zone" ? 2 : 1;
36
+ if (!Array.isArray(value.anchors) || value.anchors.length < count)
37
+ return { code: "bad_anchor" };
38
+ const anchors = [];
39
+ for (let i = 0; i < count; i++) {
40
+ const anchor = value.anchors[i];
41
+ if (anchor === null || typeof anchor !== "object" || typeof anchor.time !== "number" || !Number.isFinite(anchor.time) || typeof anchor.price !== "number")
42
+ return { code: "bad_anchor" };
43
+ if (Math.abs(anchor.time) > 864e13 || !Number.isFinite(anchor.price))
44
+ return { code: "out_of_range" };
45
+ anchors.push(Object.freeze({ time: anchor.time, price: anchor.price }));
46
+ }
47
+ if (count === 2 && anchors[0].time === anchors[1].time && anchors[0].price === anchors[1].price)
48
+ return { code: "out_of_range" };
49
+ if (value.text !== void 0 && (typeof value.text !== "string" || value.text.length > ENGINE_ANNOTATION_LIMITS.textLength) || value.tone !== void 0 && !TONES.includes(value.tone) || value.color !== void 0 && (typeof value.color !== "string" || value.color.trim().length === 0 || value.color.length > ENGINE_ANNOTATION_LIMITS.colorLength))
50
+ return { code: "bad_style" };
51
+ return {
52
+ spec: Object.freeze({
53
+ key: value.key,
54
+ kind: value.kind,
55
+ anchors: Object.freeze(anchors),
56
+ ...value.text !== void 0 ? { text: value.text } : {},
57
+ ...value.tone !== void 0 ? { tone: value.tone } : {},
58
+ ...value.color !== void 0 ? { color: value.color } : {}
59
+ })
60
+ };
61
+ }
62
+ function sameSpec(a, b) {
63
+ return a.key === b.key && a.kind === b.kind && a.text === b.text && a.tone === b.tone && a.color === b.color && a.anchors.length === b.anchors.length && a.anchors.every((anchor, i) => anchor.time === b.anchors[i].time && anchor.price === b.anchors[i].price);
64
+ }
65
+ function resolveColor(spec, currentTheme) {
66
+ if (spec.color !== void 0)
67
+ return spec.color;
68
+ const palette = CANVAS_PALETTE[currentTheme];
69
+ return spec.tone === "bull" ? palette.up : spec.tone === "bear" ? palette.down : spec.tone === "accent" ? palette.accent : palette.textSecond;
70
+ }
71
+ function result(applied, rejected) {
72
+ return Object.freeze({ applied: Object.freeze(applied), rejected: Object.freeze(rejected.map((item) => Object.freeze(item))) });
73
+ }
74
+ var EngineAnnotations = class {
75
+ scopes = /* @__PURE__ */ new Map();
76
+ onChange;
77
+ font;
78
+ symbol;
79
+ currentTheme;
80
+ frame = null;
81
+ version = 0;
82
+ disposed = false;
83
+ snapshot;
84
+ constructor(options) {
85
+ this.symbol = scope(options.symbol);
86
+ this.currentTheme = theme(options.theme ?? "dark");
87
+ this.font = options.font === void 0 ? "12px system-ui" : named(options.font, 256, "font");
88
+ this.onChange = options.onChange;
89
+ this.snapshot = Object.freeze({ version: 0, symbol: this.symbol, bounds: null, annotations: Object.freeze([]) });
90
+ }
91
+ /** Null hides all annotations during async loading. A new scope needs a fresh frame. */
92
+ setScope(symbol) {
93
+ this.assertLive();
94
+ const next = scope(symbol);
95
+ if (next === this.symbol)
96
+ return;
97
+ this.symbol = next;
98
+ this.frame = null;
99
+ this.changed();
100
+ }
101
+ setTheme(value) {
102
+ this.assertLive();
103
+ const next = theme(value);
104
+ if (next === this.currentTheme)
105
+ return;
106
+ this.currentTheme = next;
107
+ this.changed();
108
+ }
109
+ setAnnotations(layer, specs, options = {}) {
110
+ this.assertLive();
111
+ named(layer, ENGINE_ANNOTATION_LIMITS.keyLength, "layer");
112
+ if (!Array.isArray(specs) || specs.length > ENGINE_ANNOTATION_LIMITS.entriesPerLayer)
113
+ throw new RangeError("Annotation layer document exceeds its bound");
114
+ const requested = options.symbol === void 0 ? this.symbol : scope(options.symbol);
115
+ const keyOf = (spec) => spec !== null && typeof spec === "object" && typeof spec.key === "string" ? spec.key : "";
116
+ if (this.symbol === null || requested !== this.symbol)
117
+ return result([], specs.map((spec) => ({ key: keyOf(spec), code: "symbol_mismatch" })));
118
+ const priorLayers = this.scopes.get(this.symbol);
119
+ const old = priorLayers?.get(layer) ?? EMPTY_SPECS;
120
+ const priorByKey = new Map(old.map((spec) => [spec.key, spec]));
121
+ const seen = /* @__PURE__ */ new Set(), next = [], applied = [], rejected = [];
122
+ for (const value of specs) {
123
+ const key = keyOf(value);
124
+ if (seen.has(key)) {
125
+ rejected.push({ key, code: "duplicate_key" });
126
+ continue;
127
+ }
128
+ seen.add(key);
129
+ const copied = copiedSpec(value);
130
+ if ("code" in copied) {
131
+ rejected.push({ key, code: copied.code });
132
+ continue;
133
+ }
134
+ const previous = priorByKey.get(key);
135
+ next.push(previous !== void 0 && sameSpec(previous, copied.spec) ? previous : copied.spec);
136
+ applied.push(annotationId(layer, key));
137
+ }
138
+ if (old.length === next.length && old.every((spec, i) => spec === next[i]))
139
+ return result(applied, rejected);
140
+ if (next.length > 0) {
141
+ if (priorLayers === void 0 && this.scopes.size >= ENGINE_ANNOTATION_LIMITS.scopes)
142
+ throw new RangeError("Annotation scope limit reached; clear an unused scope");
143
+ if (!priorLayers?.has(layer) && (priorLayers?.size ?? 0) >= ENGINE_ANNOTATION_LIMITS.layersPerScope)
144
+ throw new RangeError("Annotation layer limit reached");
145
+ let count = next.length;
146
+ for (const [name, entries] of priorLayers ?? [])
147
+ if (name !== layer)
148
+ count += entries.length;
149
+ if (count > ENGINE_ANNOTATION_LIMITS.entriesPerScope)
150
+ throw new RangeError("Annotation scope entry limit reached");
151
+ const layers = priorLayers ?? /* @__PURE__ */ new Map();
152
+ layers.set(layer, Object.freeze(next));
153
+ this.scopes.set(this.symbol, layers);
154
+ } else if (priorLayers !== void 0) {
155
+ priorLayers.delete(layer);
156
+ if (priorLayers.size === 0)
157
+ this.scopes.delete(this.symbol);
158
+ }
159
+ this.changed();
160
+ return result(applied, rejected);
161
+ }
162
+ getAnnotations(layer) {
163
+ if (layer !== void 0)
164
+ named(layer, ENGINE_ANNOTATION_LIMITS.keyLength, "layer");
165
+ const layers = this.symbol === null ? void 0 : this.scopes.get(this.symbol);
166
+ return layer === void 0 ? Object.freeze([...layers?.values() ?? []].flat()) : layers?.get(layer) ?? EMPTY_SPECS;
167
+ }
168
+ clearAnnotations(layer) {
169
+ this.assertLive();
170
+ named(layer, ENGINE_ANNOTATION_LIMITS.keyLength, "layer");
171
+ const layers = this.symbol === null ? void 0 : this.scopes.get(this.symbol);
172
+ if (layers === void 0 || !layers.delete(layer))
173
+ return;
174
+ if (layers.size === 0)
175
+ this.scopes.delete(this.symbol);
176
+ this.changed();
177
+ }
178
+ /** Releases retained layers for one symbol without evicting any other symbol. */
179
+ clearScope(symbol = this.symbol) {
180
+ this.assertLive();
181
+ const key = scope(symbol);
182
+ if (key === null || !this.scopes.delete(key))
183
+ return;
184
+ if (key === this.symbol)
185
+ this.changed();
186
+ }
187
+ getSnapshot = () => this.snapshot;
188
+ update(frame) {
189
+ if (this.disposed)
190
+ return;
191
+ this.frame = this.symbol === null ? null : frame;
192
+ this.project();
193
+ }
194
+ paint(context) {
195
+ const { bounds, annotations } = this.snapshot;
196
+ if (this.disposed || bounds === null || !annotations.some((item) => item.status === "visible"))
197
+ return;
198
+ context.save();
199
+ try {
200
+ context.globalCompositeOperation = "source-over";
201
+ context.globalAlpha = 1;
202
+ context.beginPath();
203
+ context.rect(bounds.left, bounds.top, bounds.right - bounds.left, bounds.bottom - bounds.top);
204
+ context.clip();
205
+ context.font = this.font;
206
+ context.textAlign = "left";
207
+ context.textBaseline = "bottom";
208
+ context.lineWidth = 1;
209
+ for (const annotation of annotations) {
210
+ if (annotation.status !== "visible")
211
+ continue;
212
+ const fallback = resolveColor({ ...annotation.spec, color: void 0 }, this.currentTheme);
213
+ context.fillStyle = fallback;
214
+ context.strokeStyle = fallback;
215
+ context.fillStyle = annotation.color;
216
+ context.strokeStyle = annotation.color;
217
+ context.globalAlpha = 1;
218
+ context.setLineDash([]);
219
+ let labelX = bounds.left + 4, labelY = bounds.top + 16;
220
+ if (annotation.spec.kind === "level" && annotation.point !== void 0) {
221
+ context.setLineDash([5, 4]);
222
+ context.beginPath();
223
+ context.moveTo(bounds.left, annotation.point.y);
224
+ context.lineTo(bounds.right, annotation.point.y);
225
+ context.stroke();
226
+ labelY = Math.max(bounds.top + 16, annotation.point.y - 3);
227
+ } else if (annotation.spec.kind === "zone" && annotation.rect !== void 0) {
228
+ const rect = annotation.rect;
229
+ context.globalAlpha = 0.14;
230
+ context.fillRect(rect.x, rect.y, rect.width, rect.height);
231
+ context.globalAlpha = 0.8;
232
+ context.strokeRect(rect.x, rect.y, rect.width, rect.height);
233
+ context.globalAlpha = 1;
234
+ labelX = rect.x + 4;
235
+ labelY = Math.max(bounds.top + 16, rect.y + 16);
236
+ } else if (annotation.point !== void 0) {
237
+ const p = annotation.point;
238
+ context.beginPath();
239
+ context.moveTo(p.x, p.y - 4);
240
+ context.lineTo(p.x + 4, p.y);
241
+ context.lineTo(p.x, p.y + 4);
242
+ context.lineTo(p.x - 4, p.y);
243
+ context.closePath();
244
+ context.fill();
245
+ labelX = p.x + 7;
246
+ labelY = Math.max(bounds.top + 16, p.y - 6);
247
+ }
248
+ if (annotation.spec.text !== void 0 && annotation.spec.text !== "") {
249
+ const maxWidth = Math.max(1, bounds.right - bounds.left - 8);
250
+ const width = Math.min(maxWidth, context.measureText(annotation.spec.text).width);
251
+ labelX = Math.max(bounds.left + 4, Math.min(labelX, bounds.right - width - 4));
252
+ context.fillText(annotation.spec.text, labelX, Math.min(bounds.bottom - 2, labelY), maxWidth);
253
+ }
254
+ }
255
+ } finally {
256
+ context.restore();
257
+ }
258
+ }
259
+ dispose() {
260
+ if (this.disposed)
261
+ return;
262
+ this.disposed = true;
263
+ this.scopes.clear();
264
+ this.frame = null;
265
+ this.symbol = null;
266
+ this.project();
267
+ }
268
+ assertLive() {
269
+ if (this.disposed)
270
+ throw new Error("Annotations have been disposed");
271
+ }
272
+ changed() {
273
+ this.project();
274
+ this.onChange?.();
275
+ }
276
+ project() {
277
+ const frame = this.frame, geometry = frame?.model.geometry;
278
+ const usable = geometry !== void 0 && [geometry.left, geometry.right, geometry.top, geometry.bottom].every(Number.isFinite) && geometry.right > geometry.left && geometry.bottom > geometry.top;
279
+ const bounds = usable ? Object.freeze({ left: geometry.left, right: geometry.right, top: geometry.top, bottom: geometry.bottom }) : null;
280
+ const annotations = [];
281
+ const layers = this.symbol === null ? void 0 : this.scopes.get(this.symbol);
282
+ for (const [layer, specs] of layers ?? []) {
283
+ for (const spec of specs) {
284
+ const base = { id: annotationId(layer, spec.key), layer, spec, color: resolveColor(spec, this.currentTheme) };
285
+ if (frame === null || bounds === null || frame.snapshot.bars.length === 0) {
286
+ annotations.push(Object.freeze({ ...base, status: "no-data" }));
287
+ continue;
288
+ }
289
+ const a = frame.project(spec.anchors[0].time, spec.anchors[0].price);
290
+ const b = spec.kind === "zone" ? frame.project(spec.anchors[1].time, spec.anchors[1].price) : a;
291
+ if (a === null || b === null || ![a.x, a.y, b.x, b.y].every(Number.isFinite)) {
292
+ annotations.push(Object.freeze({ ...base, status: "unprojectable" }));
293
+ continue;
294
+ }
295
+ if (spec.kind === "zone") {
296
+ const left = Math.max(bounds.left, Math.min(a.x, b.x)), right = Math.min(bounds.right, Math.max(a.x, b.x));
297
+ const top = Math.max(bounds.top, Math.min(a.y, b.y)), bottom = Math.min(bounds.bottom, Math.max(a.y, b.y));
298
+ annotations.push(Object.freeze({
299
+ ...base,
300
+ status: right >= left && bottom >= top ? "visible" : "offscreen",
301
+ ...right >= left && bottom >= top ? { rect: Object.freeze({ x: left, y: top, width: right - left, height: bottom - top }) } : {}
302
+ }));
303
+ } else {
304
+ const visible = a.y >= bounds.top && a.y <= bounds.bottom && (spec.kind === "level" || a.x >= bounds.left && a.x <= bounds.right);
305
+ annotations.push(Object.freeze({ ...base, status: visible ? "visible" : "offscreen", ...visible ? { point: Object.freeze({ x: a.x, y: a.y }) } : {} }));
306
+ }
307
+ }
308
+ }
309
+ this.snapshot = Object.freeze({ version: ++this.version, symbol: this.symbol, bounds, annotations: Object.freeze(annotations) });
310
+ }
311
+ };
312
+ export {
313
+ ENGINE_ANNOTATION_LIMITS,
314
+ EngineAnnotations
315
+ };