@forgecharts/sdk 1.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/package.json +50 -0
  2. package/src/__tests__/backwardCompatibility.test.ts +191 -0
  3. package/src/__tests__/candleInvariant.test.ts +500 -0
  4. package/src/__tests__/public-api-surface.ts +76 -0
  5. package/src/__tests__/timeframeBoundary.test.ts +583 -0
  6. package/src/api/DrawingManager.ts +188 -0
  7. package/src/api/EventBus.ts +53 -0
  8. package/src/api/IndicatorDAG.ts +389 -0
  9. package/src/api/IndicatorRegistry.ts +47 -0
  10. package/src/api/LayoutManager.ts +72 -0
  11. package/src/api/PaneManager.ts +129 -0
  12. package/src/api/ReferenceAPI.ts +195 -0
  13. package/src/api/TChart.ts +881 -0
  14. package/src/api/createChart.ts +43 -0
  15. package/src/api/drawing tools/fib gann menu/fibRetracement.ts +27 -0
  16. package/src/api/drawing tools/lines menu/crossLine.ts +21 -0
  17. package/src/api/drawing tools/lines menu/disjointChannel.ts +74 -0
  18. package/src/api/drawing tools/lines menu/extendedLine.ts +22 -0
  19. package/src/api/drawing tools/lines menu/flatTopBottom.ts +45 -0
  20. package/src/api/drawing tools/lines menu/horizontal.ts +24 -0
  21. package/src/api/drawing tools/lines menu/horizontalRay.ts +25 -0
  22. package/src/api/drawing tools/lines menu/infoLine.ts +127 -0
  23. package/src/api/drawing tools/lines menu/insidePitchfork.ts +21 -0
  24. package/src/api/drawing tools/lines menu/modifiedSchiffPitchfork.ts +18 -0
  25. package/src/api/drawing tools/lines menu/parallelChannel.ts +47 -0
  26. package/src/api/drawing tools/lines menu/pitchfork.ts +15 -0
  27. package/src/api/drawing tools/lines menu/ray.ts +28 -0
  28. package/src/api/drawing tools/lines menu/regressionTrend.ts +157 -0
  29. package/src/api/drawing tools/lines menu/schiffPitchfork.ts +18 -0
  30. package/src/api/drawing tools/lines menu/trendAngle.ts +64 -0
  31. package/src/api/drawing tools/lines menu/trendline.ts +16 -0
  32. package/src/api/drawing tools/lines menu/vertical.ts +16 -0
  33. package/src/api/drawing tools/pointers menu/crosshair.ts +17 -0
  34. package/src/api/drawing tools/pointers menu/cursor.ts +16 -0
  35. package/src/api/drawing tools/pointers menu/demonstration.ts +35 -0
  36. package/src/api/drawing tools/pointers menu/dot.ts +26 -0
  37. package/src/api/drawing tools/shapes menu/rectangle.ts +24 -0
  38. package/src/api/drawing tools/shapes menu/text.ts +30 -0
  39. package/src/api/drawingUtils.ts +82 -0
  40. package/src/core/CanvasLayer.ts +77 -0
  41. package/src/core/Chart.ts +917 -0
  42. package/src/core/CoordTransform.ts +282 -0
  43. package/src/core/Crosshair.ts +207 -0
  44. package/src/core/IndicatorEngine.ts +216 -0
  45. package/src/core/InteractionManager.ts +899 -0
  46. package/src/core/PriceScale.ts +133 -0
  47. package/src/core/Series.ts +132 -0
  48. package/src/core/TimeScale.ts +175 -0
  49. package/src/datafeed/DatafeedConnector.ts +300 -0
  50. package/src/engine/CandleEngine.ts +458 -0
  51. package/src/engine/__tests__/CandleEngine.test.ts +402 -0
  52. package/src/engine/candleInvariants.ts +172 -0
  53. package/src/engine/mergeUtils.ts +93 -0
  54. package/src/engine/timeframeUtils.ts +118 -0
  55. package/src/index.ts +190 -0
  56. package/src/internal.ts +41 -0
  57. package/src/licensing/ChartRuntimeResolver.ts +380 -0
  58. package/src/licensing/LicenseManager.ts +131 -0
  59. package/src/licensing/__tests__/ChartRuntimeResolver.test.ts +207 -0
  60. package/src/licensing/__tests__/LicenseManager.test.ts +180 -0
  61. package/src/licensing/licenseTypes.ts +19 -0
  62. package/src/pine/PineCompiler.ts +68 -0
  63. package/src/pine/diagnostics.ts +30 -0
  64. package/src/pine/index.ts +7 -0
  65. package/src/pine/pine-ast.ts +163 -0
  66. package/src/pine/pine-lexer.ts +265 -0
  67. package/src/pine/pine-parser.ts +439 -0
  68. package/src/pine/pine-transpiler.ts +301 -0
  69. package/src/pixi/LayerName.ts +35 -0
  70. package/src/pixi/PixiCandlestickRenderer.ts +125 -0
  71. package/src/pixi/PixiChart.ts +425 -0
  72. package/src/pixi/PixiCrosshairRenderer.ts +134 -0
  73. package/src/pixi/PixiDrawingRenderer.ts +121 -0
  74. package/src/pixi/PixiGridRenderer.ts +136 -0
  75. package/src/pixi/PixiLayerManager.ts +102 -0
  76. package/src/renderers/CandlestickRenderer.ts +130 -0
  77. package/src/renderers/HistogramRenderer.ts +63 -0
  78. package/src/renderers/LineRenderer.ts +77 -0
  79. package/src/theme/colors.ts +21 -0
  80. package/src/tools/barDivergenceCheck.ts +305 -0
  81. package/src/trading/TradingOverlayStore.ts +161 -0
  82. package/src/trading/UnmanagedIngestion.ts +156 -0
  83. package/src/trading/__tests__/ManagedTradingController.test.ts +338 -0
  84. package/src/trading/__tests__/TradingOverlayStore.test.ts +323 -0
  85. package/src/trading/__tests__/UnmanagedIngestion.test.ts +205 -0
  86. package/src/trading/managed/ManagedTradingController.ts +292 -0
  87. package/src/trading/managed/managedCapabilities.ts +98 -0
  88. package/src/trading/managed/managedTypes.ts +151 -0
  89. package/src/trading/tradingTypes.ts +135 -0
  90. package/src/tscript/TScriptIndicator.ts +54 -0
  91. package/src/tscript/ast.ts +105 -0
  92. package/src/tscript/lexer.ts +190 -0
  93. package/src/tscript/parser.ts +334 -0
  94. package/src/tscript/runtime.ts +525 -0
  95. package/src/tscript/series.ts +84 -0
  96. package/src/types/IChart.ts +56 -0
  97. package/src/types/IRenderer.ts +16 -0
  98. package/src/types/ISeries.ts +30 -0
  99. package/tsconfig.json +22 -0
  100. package/tsup.config.ts +15 -0
  101. package/vitest.config.ts +25 -0
@@ -0,0 +1,425 @@
1
+ import { Application, Container } from 'pixi.js';
2
+ import type { ChartOptions, ChartColors, ChartTheme, SeriesOptions, Drawing, OHLCV, ChartKeyAction } from '@forgecharts/types';
3
+ import type { IChart, IChartPlugin } from '../types/IChart';
4
+ import type { ISeries } from '../types/ISeries';
5
+ import { CanvasLayer } from '../core/CanvasLayer';
6
+ import { TimeScale } from '../core/TimeScale';
7
+ import { PriceScale } from '../core/PriceScale';
8
+ import { CoordTransform } from '../core/CoordTransform';
9
+ import { Series } from '../core/Series';
10
+ import { InteractionManager } from '../core/InteractionManager';
11
+ import { DARK_COLORS, LIGHT_COLORS } from '../theme/colors';
12
+ import { LayerName } from './LayerName';
13
+ import { PixiLayerManager } from './PixiLayerManager';
14
+ import { PixiGridRenderer } from './PixiGridRenderer';
15
+ import { PixiCandlestickRenderer } from './PixiCandlestickRenderer';
16
+ import { PixiCrosshairRenderer } from './PixiCrosshairRenderer';
17
+ import { PixiDrawingRenderer } from './PixiDrawingRenderer';
18
+
19
+ /**
20
+ * PixiChart — GPU-accelerated chart engine built on PixiJS v8.
21
+ *
22
+ * Implements `IChart` identically to the Canvas 2D `Chart` class, so it is
23
+ * a drop-in replacement. Rendering is performed inside a PixiJS `Application`
24
+ * (WebGL / WebGPU auto-detected) using seven sortable `Container` layers.
25
+ *
26
+ * Selective redraw:
27
+ * - `DirtyFlags` tracks which layers need painting.
28
+ * - Each layer's renderer is called only when its flag is set.
29
+ * - Viewport changes (zoom/pan) mark all layers dirty via `_markViewportDirty()`.
30
+ * - Data changes only mark `PriceSeries` dirty.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * const chart = new PixiChart(container, { theme: 'dark' });
35
+ * const series = chart.addSeries({ type: 'candlestick' });
36
+ * series.setData(bars);
37
+ *
38
+ * // Access the transform for drawing tools or plugins
39
+ * const { x, y } = chart.transform().toPixel(bar.time, bar.close);
40
+ * ```
41
+ */
42
+ export class PixiChart implements IChart {
43
+ private readonly _app: Application;
44
+ private readonly _stage: Container;
45
+ private readonly _container: HTMLElement;
46
+ private readonly _options: Required<ChartOptions>;
47
+ private _colors: ChartColors;
48
+
49
+ private readonly _timeScale: TimeScale;
50
+ private readonly _priceScale: PriceScale;
51
+ private readonly _transform: CoordTransform;
52
+
53
+ private readonly _layers: PixiLayerManager;
54
+ private readonly _gridRenderer: PixiGridRenderer;
55
+ private readonly _candleRenderer: PixiCandlestickRenderer;
56
+ private readonly _crosshairRenderer: PixiCrosshairRenderer;
57
+ private readonly _drawingRenderer: PixiDrawingRenderer;
58
+
59
+ private _series: Series[] = [];
60
+ private _drawings: Drawing[] = [];
61
+ private _plugins: IChartPlugin[] = [];
62
+
63
+ private _resizeObserver: ResizeObserver;
64
+ private _initPromise: Promise<void>;
65
+ private _ready = false;
66
+ private _interaction!: InteractionManager;
67
+
68
+ constructor(container: HTMLElement, options: ChartOptions = {}) {
69
+ this._container = container;
70
+ this._container.style.position = 'relative';
71
+ this._container.style.overflow = 'hidden';
72
+
73
+ this._options = this._resolveOptions(options);
74
+ this._colors = this._resolveColors(this._options.theme, this._options.colors);
75
+
76
+ const { width, height } = container.getBoundingClientRect();
77
+
78
+ // ── Scales & transform ────────────────────────────────────────────────────
79
+ // TimeScale and PriceScale need a CanvasLayer for their Canvas 2D render()
80
+ // pass; for PixiChart that pass is never called — Pixi renderers own all
81
+ // drawing. A hidden dummy canvas satisfies the constructor requirement.
82
+ const dpr = window.devicePixelRatio ?? 1;
83
+ const dummyLayer = new CanvasLayer(container, { width, height, dpr, zIndex: 0 });
84
+ dummyLayer.canvas.style.display = 'none';
85
+
86
+ this._timeScale = new TimeScale(dummyLayer, this._colors, this._options.timeScale);
87
+ this._priceScale = new PriceScale(dummyLayer, this._colors, this._options.priceScale);
88
+
89
+ this._transform = new CoordTransform(
90
+ this._timeScale,
91
+ this._priceScale,
92
+ this._priceScale.width,
93
+ this._timeScale.height,
94
+ );
95
+ this._transform.update(width, height);
96
+
97
+ // ── PixiJS Application ────────────────────────────────────────────────────
98
+ this._app = new Application();
99
+ this._stage = this._app.stage;
100
+
101
+ this._initPromise = this._app.init({
102
+ width,
103
+ height,
104
+ backgroundColor: parseInt(this._colors.background.replace('#', ''), 16),
105
+ antialias: true,
106
+ resolution: dpr,
107
+ autoDensity: true,
108
+ }).then(() => {
109
+ const canvas = this._app.canvas as HTMLCanvasElement;
110
+ canvas.style.position = 'absolute';
111
+ canvas.style.inset = '0';
112
+ container.appendChild(canvas);
113
+
114
+ // ── Layers ──────────────────────────────────────────────────────────────
115
+ this._layers.dirty.markAll();
116
+ this._ready = true;
117
+
118
+ // Attach PixiJS ticker for per-frame selective redraw
119
+ this._app.ticker.add(() => this._renderFrame());
120
+ });
121
+
122
+ // Layer manager + renderers (can be constructed before init resolves)
123
+ this._layers = new PixiLayerManager(this._stage);
124
+
125
+ this._gridRenderer = new PixiGridRenderer(
126
+ this._layers.get(LayerName.Background),
127
+ this._colors,
128
+ this._timeScale,
129
+ );
130
+ this._candleRenderer = new PixiCandlestickRenderer(
131
+ this._layers.get(LayerName.PriceSeries),
132
+ );
133
+ this._crosshairRenderer = new PixiCrosshairRenderer(
134
+ this._layers.get(LayerName.Interaction),
135
+ this._colors,
136
+ );
137
+ this._drawingRenderer = new PixiDrawingRenderer(
138
+ this._layers.get(LayerName.Drawing),
139
+ );
140
+
141
+ // ── Interactions ─────────────────────────────────────────────────────────
142
+ this._interaction = new InteractionManager(
143
+ this._container,
144
+ this._transform,
145
+ {
146
+ onPan: (dx, dy) => {
147
+ this._transform.pan(dx, dy);
148
+ this._markViewportDirty();
149
+ },
150
+ onZoom: (factor, originX) => {
151
+ this._transform.zoomTime(factor, originX);
152
+ this._markViewportDirty();
153
+ },
154
+ onCrosshairMove: (x, y) => {
155
+ this._crosshairRenderer.update(x, y);
156
+ this._layers.dirty.mark(LayerName.Interaction);
157
+ },
158
+ onCrosshairHide: () => {
159
+ this._crosshairRenderer.hide();
160
+ this._layers.dirty.mark(LayerName.Interaction);
161
+ },
162
+ onKeyAction: (action) => {
163
+ this._handleKeyAction(action);
164
+ },
165
+ },
166
+ {
167
+ enablePan: this._options.handleScroll,
168
+ enableZoom: this._options.handleScale,
169
+ },
170
+ );
171
+
172
+ this._resizeObserver = new ResizeObserver(() => this._onResize());
173
+ this._resizeObserver.observe(container);
174
+ }
175
+
176
+ // ─── IChart implementation ────────────────────────────────────────────────
177
+
178
+ addSeries(options: Parameters<IChart['addSeries']>[0]): ISeries {
179
+ const series = new Series(options, this._colors);
180
+
181
+ // When the caller mutates data, mark the series + background layers dirty
182
+ // so the next ticker frame redraws only those layers (selective redraw).
183
+ series.onDataChanged = () => {
184
+ this._layers.dirty.mark(LayerName.PriceSeries);
185
+ this._layers.dirty.mark(LayerName.Background); // price range may change
186
+ };
187
+
188
+ this._series.push(series);
189
+ this._layers.dirty.mark(LayerName.PriceSeries);
190
+ return series;
191
+ }
192
+
193
+ removeSeries(series: ISeries): void {
194
+ this._series = this._series.filter((s) => s !== series);
195
+ this._layers.dirty.mark(LayerName.PriceSeries);
196
+ }
197
+
198
+ addPlugin(plugin: IChartPlugin): void {
199
+ this._plugins.push(plugin);
200
+ plugin.onAttach(this);
201
+ }
202
+
203
+ removePlugin(plugin: IChartPlugin): void {
204
+ this._plugins = this._plugins.filter((p) => p !== plugin);
205
+ plugin.onDetach();
206
+ }
207
+
208
+ applyOptions(options: Partial<ChartOptions>): void {
209
+ Object.assign(this._options as Record<string, unknown>, options);
210
+ if (options.theme) {
211
+ // Mutate the existing colors object in-place so all renderers that hold
212
+ // a reference to it (PixiGridRenderer, PixiCrosshairRenderer, etc.) pick
213
+ // up the new values without needing a reference swap.
214
+ Object.assign(this._colors, this._resolveColors(options.theme as ChartTheme, this._options.colors));
215
+ // Also update the PixiJS renderer's own background color.
216
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
217
+ (this._app.renderer as any).background.color = parseInt(this._colors.background.replace('#', ''), 16);
218
+ }
219
+ if (options.timezone !== undefined) {
220
+ this._crosshairRenderer.timezone = options.timezone ?? 'UTC';
221
+ }
222
+ this._layers.dirty.markAll();
223
+ }
224
+
225
+ setInterval(tf: string): void {
226
+ this._crosshairRenderer.interval = tf;
227
+ this._layers.dirty.mark(LayerName.Interaction);
228
+ }
229
+
230
+ resize(width: number, height: number): void {
231
+ this._app.renderer.resize(width, height);
232
+ this._transform.update(width, height);
233
+ this._markViewportDirty();
234
+ }
235
+
236
+ transform(): CoordTransform {
237
+ return this._transform;
238
+ }
239
+
240
+ destroy(): void {
241
+ this._resizeObserver.disconnect();
242
+ this._interaction.destroy();
243
+ this._layers.destroy();
244
+ this._plugins.forEach((p) => p.onDetach());
245
+ this._series = [];
246
+ this._plugins = [];
247
+ void this._app.destroy(true, { children: true });
248
+ }
249
+
250
+ // ─── Drawing objects (used by PixiChart directly; TChart uses DrawingManager) ─
251
+
252
+ /** @internal Used by PixiChart when DrawingManager pushes an update. */
253
+ setDrawings(drawings: readonly Drawing[]): void {
254
+ this._drawings = [...drawings];
255
+ this._layers.dirty.mark(LayerName.Drawing);
256
+ }
257
+
258
+ // ─── Rendering ───────────────────────────────────────────────────────────────
259
+
260
+ private _renderFrame(): void {
261
+ if (!this._ready) return;
262
+
263
+ const t = this._transform;
264
+ const cw = t.canvasWidth;
265
+ const ch = t.canvasHeight;
266
+ const dirty = this._layers.dirty;
267
+
268
+ // ── Price series first so auto-fit updates the price range ───────────────
269
+ // This must run before Background so the grid can reflect the new range
270
+ // within the same frame.
271
+ if (dirty.isDirty(LayerName.PriceSeries)) {
272
+ // Auto-fit price range to visible data, then mark background dirty so
273
+ // the grid redraws with the updated price range in this same frame.
274
+ this._autoFitPriceRange();
275
+ dirty.mark(LayerName.Background);
276
+
277
+ // Renderers own their Graphics object; calling gfx.clear() internally.
278
+ // Do NOT call _layers.clear(PriceSeries) — that would detach the gfx.
279
+ for (const series of this._series) {
280
+ const opts = series.options();
281
+ if (opts.visible === false) continue;
282
+ const bars = this._getVisibleBars(series);
283
+ if (bars.length === 0) continue;
284
+ if (opts.type === 'heikinashi') {
285
+ this._candleRenderer.drawHeikinAshi(bars, t);
286
+ } else {
287
+ this._candleRenderer.draw(bars, t);
288
+ }
289
+ }
290
+ dirty.clear(LayerName.PriceSeries);
291
+ }
292
+
293
+ // ── Background + grid (axes, labels) ─────────────────────────────────────
294
+ if (dirty.isDirty(LayerName.Background)) {
295
+ this._gridRenderer.draw(t, cw, ch);
296
+ dirty.clear(LayerName.Background);
297
+ }
298
+
299
+ // ── Indicator — placeholder ───────────────────────────────────────────────
300
+ if (dirty.isDirty(LayerName.Indicator)) {
301
+ dirty.clear(LayerName.Indicator);
302
+ }
303
+
304
+ // ── Drawings ──────────────────────────────────────────────────────────────
305
+ if (dirty.isDirty(LayerName.Drawing)) {
306
+ this._drawingRenderer.draw(this._drawings, t);
307
+ dirty.clear(LayerName.Drawing);
308
+ }
309
+
310
+ // ── Trading overlay — placeholder ─────────────────────────────────────────
311
+ if (dirty.isDirty(LayerName.TradingOverlay)) {
312
+ dirty.clear(LayerName.TradingOverlay);
313
+ }
314
+
315
+ // ── Crosshair (Interaction layer) ─────────────────────────────────────────
316
+ if (dirty.isDirty(LayerName.Interaction)) {
317
+ this._crosshairRenderer.draw(t);
318
+ dirty.clear(LayerName.Interaction);
319
+ }
320
+
321
+ // ── UI overlay — placeholder ──────────────────────────────────────────────
322
+ if (dirty.isDirty(LayerName.UIOverlay)) {
323
+ dirty.clear(LayerName.UIOverlay);
324
+ }
325
+
326
+ // Let plugins render
327
+ this._plugins.forEach((p) => p.onRender());
328
+ }
329
+
330
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
331
+
332
+ private _getVisibleBars(series: Series): OHLCV[] {
333
+ const { from, to } = this._transform.timeRange;
334
+ return (series.data() as OHLCV[]).filter((b) => b.time >= from - 1 && b.time <= to + 1);
335
+ }
336
+
337
+ private _autoFitPriceRange(): void {
338
+ let low = Infinity, high = -Infinity;
339
+ for (const series of this._series) {
340
+ for (const bar of this._getVisibleBars(series)) {
341
+ if (bar.low < low) low = bar.low;
342
+ if (bar.high > high) high = bar.high;
343
+ }
344
+ }
345
+ if (low === Infinity || high === -Infinity) return;
346
+ const padding = (high - low) * 0.05;
347
+ this._priceScale.setVisibleRange({ min: low - padding, max: high + padding });
348
+ // Background must redraw so price-axis labels reflect the new range.
349
+ // Caller is responsible for marking Background dirty after calling this.
350
+ }
351
+
352
+ private _markViewportDirty(): void {
353
+ this._layers.dirty.mark(LayerName.Background);
354
+ this._layers.dirty.mark(LayerName.PriceSeries);
355
+ this._layers.dirty.mark(LayerName.Indicator);
356
+ this._layers.dirty.mark(LayerName.Drawing);
357
+ this._layers.dirty.mark(LayerName.TradingOverlay);
358
+ this._layers.dirty.mark(LayerName.Interaction);
359
+ this._layers.dirty.mark(LayerName.UIOverlay);
360
+ }
361
+
362
+ private _onResize(): void {
363
+ const { width, height } = this._container.getBoundingClientRect();
364
+ this.resize(width, height);
365
+ }
366
+
367
+ // ─── Keyboard action handler ──────────────────────────────────────────────────
368
+
369
+ private _handleKeyAction(action: ChartKeyAction): void {
370
+ const step = 40; // px per arrow-key press
371
+ const zf = 1.1; // zoom factor per key press
372
+ const cx = this._transform.plotWidth / 2;
373
+
374
+ switch (action) {
375
+ case 'panLeft': this._transform.pan( step, 0); break;
376
+ case 'panRight': this._transform.pan(-step, 0); break;
377
+ case 'panUp': this._transform.pan(0, step); break;
378
+ case 'panDown': this._transform.pan(0, -step); break;
379
+ case 'zoomIn': this._transform.zoomTime(1 / zf, cx); break;
380
+ case 'zoomOut': this._transform.zoomTime(zf, cx); break;
381
+ case 'scrollToEnd': this._scrollToLatestBar(); break;
382
+ case 'deselect': this._interaction.clearSelection(); break;
383
+ case 'scrollToStart':
384
+ case 'deleteSelection': break; // handled externally
385
+ }
386
+ this._markViewportDirty();
387
+ }
388
+
389
+ /** Pans the viewport so the latest bar sits near the right edge. */
390
+ private _scrollToLatestBar(): void {
391
+ let latestTime = -Infinity;
392
+ for (const s of this._series) {
393
+ const data = s.data();
394
+ const last = data[data.length - 1];
395
+ if (last && last.time > latestTime) latestTime = last.time;
396
+ }
397
+ if (!isFinite(latestTime)) return;
398
+ const pad = this._transform.plotWidth * 0.05;
399
+ const px = this._transform.timeToX(latestTime);
400
+ this._transform.pan(-(px - (this._transform.plotWidth - pad)), 0);
401
+ }
402
+
403
+ // ─── Option resolvers ────────────────────────────────────────────────────────
404
+
405
+ private _resolveOptions(opts: ChartOptions): Required<ChartOptions> {
406
+ return {
407
+ theme: opts.theme ?? 'dark',
408
+ colors: opts.colors ?? {},
409
+ timeScale: opts.timeScale ?? {},
410
+ priceScale: opts.priceScale ?? {},
411
+ crosshair: opts.crosshair ?? {},
412
+ handleScroll: opts.handleScroll ?? true,
413
+ handleScale: opts.handleScale ?? true,
414
+ timezone: opts.timezone ?? 'UTC',
415
+ };
416
+ }
417
+
418
+ private _resolveColors(
419
+ theme: ChartTheme,
420
+ overrides: Partial<import('@forgecharts/types').ChartColors>,
421
+ ): ChartColors {
422
+ const base = theme === 'dark' ? DARK_COLORS : LIGHT_COLORS;
423
+ return { ...base, ...overrides };
424
+ }
425
+ }
@@ -0,0 +1,134 @@
1
+ import { Graphics, Container, Text, TextStyle } from 'pixi.js';
2
+ import type { ChartColors } from '@forgecharts/types';
3
+ import type { CoordTransform } from '../core/CoordTransform';
4
+
5
+ /**
6
+ * PixiCrosshairRenderer — draws the interactive crosshair lines and their
7
+ * data-accurate price / time labels using GPU-batched PixiJS Graphics.
8
+ *
9
+ * Owned by the Interaction layer; receives pixel coords from mouse events
10
+ * and converts them to data values via `CoordTransform`.
11
+ */
12
+ export class PixiCrosshairRenderer {
13
+ private readonly _gfx: Graphics;
14
+ private readonly _priceLabel: Text;
15
+ private readonly _timeLabel: Text;
16
+
17
+ private _x: number | null = null;
18
+ private _y: number | null = null;
19
+
20
+ /** Active timeframe — controls whether time is shown alongside the date. */
21
+ interval: string = '1h';
22
+ /** IANA timezone for the time label. Falls back to UTC. */
23
+ timezone: string = 'UTC';
24
+
25
+ constructor(
26
+ layer: Container,
27
+ private readonly _colors: ChartColors,
28
+ ) {
29
+ const labelStyle = new TextStyle({
30
+ fontSize: 11,
31
+ fontFamily: 'system-ui, sans-serif',
32
+ fill: _colors.background,
33
+ });
34
+
35
+ this._gfx = new Graphics();
36
+ this._priceLabel = new Text({ text: '', style: labelStyle });
37
+ this._timeLabel = new Text({ text: '', style: labelStyle });
38
+
39
+ layer.addChild(this._gfx, this._priceLabel, this._timeLabel);
40
+ }
41
+
42
+ update(x: number, y: number): void {
43
+ this._x = x;
44
+ this._y = y;
45
+ }
46
+
47
+ hide(): void {
48
+ this._x = null;
49
+ this._y = null;
50
+ }
51
+
52
+ draw(transform: CoordTransform): void {
53
+ this._gfx.clear();
54
+ this._priceLabel.text = '';
55
+ this._timeLabel.text = '';
56
+
57
+ if (this._x === null || this._y === null) return;
58
+
59
+ const x = this._x;
60
+ const y = this._y;
61
+ const pw = transform.plotWidth;
62
+ const ph = transform.plotHeight;
63
+ const psw = transform.priceScaleWidth;
64
+ const tsh = transform.timeScaleHeight;
65
+
66
+ if (!transform.inPlotArea(x, y)) return;
67
+
68
+ const crossColor = parseInt(this._colors.crosshair.replace('#', ''), 16);
69
+
70
+ // Vertical line
71
+ this._gfx
72
+ .moveTo(x, 0)
73
+ .lineTo(x, ph)
74
+ .stroke({ color: crossColor, width: 1, alpha: 0.85, join: 'miter' });
75
+
76
+ // Horizontal line
77
+ this._gfx
78
+ .moveTo(0, y)
79
+ .lineTo(pw, y)
80
+ .stroke({ color: crossColor, width: 1, alpha: 0.85, join: 'miter' });
81
+
82
+ // ── Price label (right axis) ─────────────────────────────────────────────
83
+ const price = transform.yToPrice(y);
84
+ const priceText = this._formatPrice(price);
85
+ const lh = 18;
86
+ const lw = psw;
87
+
88
+ this._gfx
89
+ .rect(pw, y - lh / 2, lw, lh)
90
+ .fill({ color: crossColor, alpha: 0.9 });
91
+
92
+ this._priceLabel.text = priceText;
93
+ this._priceLabel.x = pw + 4;
94
+ this._priceLabel.y = y - this._priceLabel.height / 2;
95
+
96
+ // ── Time label (bottom axis) ─────────────────────────────────────────────
97
+ const time = transform.xToTime(x);
98
+ const timeText = this._formatTime(time);
99
+ const dailyIntervals = new Set(['1d', '2d', '3d', '1w', '2w', '1M', '3M', '6M', '12M']);
100
+ const tlw = dailyIntervals.has(this.interval) ? 120 : 170;
101
+ const tlh = tsh - 4;
102
+ const tlx = Math.max(tlw / 2, Math.min(x, pw - tlw / 2));
103
+
104
+ this._gfx
105
+ .rect(tlx - tlw / 2, ph + 2, tlw, tlh)
106
+ .fill({ color: crossColor, alpha: 0.9 });
107
+
108
+ this._timeLabel.text = timeText;
109
+ this._timeLabel.x = tlx - this._timeLabel.width / 2;
110
+ this._timeLabel.y = ph + 2 + (tlh - this._timeLabel.height) / 2;
111
+ }
112
+
113
+ private _formatPrice(price: number): string {
114
+ const decimals = price < 1 ? 6 : price < 1000 ? 4 : 2;
115
+ return price.toFixed(decimals);
116
+ }
117
+
118
+ private _formatTime(time: number): string {
119
+ const timeZone = (!this.timezone || this.timezone === 'exchange') ? 'UTC' : this.timezone;
120
+ const d = new Date(time * 1000);
121
+ const weekday = d.toLocaleDateString('en-US', { weekday: 'short', timeZone });
122
+ const day = d.toLocaleDateString('en-US', { day: '2-digit', timeZone });
123
+ const month = d.toLocaleDateString('en-US', { month: 'short', timeZone });
124
+ const year = d.toLocaleDateString('en-US', { year: '2-digit', timeZone });
125
+ const datePart = `${weekday} ${day} ${month} '${year}`;
126
+
127
+ const dailyIntervals = new Set(['1d', '2d', '3d', '1w', '2w', '1M', '3M', '6M', '12M']);
128
+ if (dailyIntervals.has(this.interval)) return datePart;
129
+
130
+ const hh = d.toLocaleString('en-US', { hour: '2-digit', hour12: false, timeZone });
131
+ const mm = d.toLocaleString('en-US', { minute: '2-digit', timeZone }).padStart(2, '0');
132
+ return `${datePart} ${hh}:${mm}`;
133
+ }
134
+ }
@@ -0,0 +1,121 @@
1
+ import { Graphics } from 'pixi.js';
2
+ import type { Container } from 'pixi.js';
3
+ import type { Drawing } from '@forgecharts/types';
4
+ import type { CoordTransform } from '../core/CoordTransform';
5
+
6
+ const DEFAULT_COLOR = 0x888888;
7
+ const DEFAULT_WIDTH = 1;
8
+ const FIB_RATIOS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1] as const;
9
+
10
+ /**
11
+ * PixiDrawingRenderer — renders all drawing objects via GPU-batched Graphics.
12
+ *
13
+ * All `DrawingPoint` (time, price) → pixel conversions go through
14
+ * `CoordTransform`, keeping drawings perfectly aligned with the price series
15
+ * across zoom/pan without requiring a full re-fetch of underlying data.
16
+ */
17
+ export class PixiDrawingRenderer {
18
+ private readonly _gfx: Graphics;
19
+
20
+ constructor(layer: Container) {
21
+ this._gfx = new Graphics();
22
+ layer.addChild(this._gfx);
23
+ }
24
+
25
+ draw(drawings: readonly Drawing[], transform: CoordTransform): void {
26
+ this._gfx.clear();
27
+ for (const d of drawings) {
28
+ if (d.visible === false) continue;
29
+ this._drawOne(d, transform);
30
+ }
31
+ }
32
+
33
+ private _drawOne(d: Drawing, t: CoordTransform): void {
34
+ const gfx = this._gfx;
35
+ const color = d.color ? parseInt(d.color.replace('#', ''), 16) : DEFAULT_COLOR;
36
+ const width = d.lineWidth ?? DEFAULT_WIDTH;
37
+ const stroke = { color, width };
38
+ const { points, type } = d;
39
+
40
+ switch (type) {
41
+ case 'trendline': {
42
+ if (points.length < 2) break;
43
+ const p0 = t.toPixel(points[0]!.time, points[0]!.price);
44
+ const p1 = t.toPixel(points[1]!.time, points[1]!.price);
45
+ gfx.moveTo(p0.x, p0.y).lineTo(p1.x, p1.y).stroke(stroke);
46
+ break;
47
+ }
48
+
49
+ case 'horizontal': {
50
+ if (points.length < 1) break;
51
+ const y = t.priceToY(points[0]!.price);
52
+ gfx.moveTo(0, y).lineTo(t.plotWidth, y).stroke(stroke);
53
+ break;
54
+ }
55
+
56
+ case 'vertical': {
57
+ if (points.length < 1) break;
58
+ const x = t.timeToX(points[0]!.time);
59
+ gfx.moveTo(x, 0).lineTo(x, t.plotHeight).stroke(stroke);
60
+ break;
61
+ }
62
+
63
+ case 'rectangle': {
64
+ if (points.length < 2) break;
65
+ const p0 = t.toPixel(points[0]!.time, points[0]!.price);
66
+ const p1 = t.toPixel(points[1]!.time, points[1]!.price);
67
+ gfx
68
+ .rect(
69
+ Math.min(p0.x, p1.x),
70
+ Math.min(p0.y, p1.y),
71
+ Math.abs(p1.x - p0.x),
72
+ Math.abs(p1.y - p0.y),
73
+ )
74
+ .stroke(stroke);
75
+ break;
76
+ }
77
+
78
+ case 'fibRetracement': {
79
+ if (points.length < 2) break;
80
+ const priceHigh = Math.max(points[0]!.price, points[1]!.price);
81
+ const priceLow = Math.min(points[0]!.price, points[1]!.price);
82
+ const span = priceHigh - priceLow;
83
+ const x0 = Math.min(t.timeToX(points[0]!.time), t.timeToX(points[1]!.time));
84
+ const x1 = Math.max(t.timeToX(points[0]!.time), t.timeToX(points[1]!.time));
85
+
86
+ for (const ratio of FIB_RATIOS) {
87
+ const levelPrice = priceLow + span * (1 - ratio);
88
+ const y = t.priceToY(levelPrice);
89
+ gfx.moveTo(x0, y).lineTo(x1, y).stroke({ color, width, alpha: 0.7 });
90
+ }
91
+ break;
92
+ }
93
+
94
+ // ray: rendered as a segment from p0 through p1 to the plot boundary
95
+ case 'ray': {
96
+ if (points.length < 2) break;
97
+ const x0 = t.timeToX(points[0]!.time);
98
+ const y0 = t.priceToY(points[0]!.price);
99
+ const dx = t.timeToX(points[1]!.time) - x0;
100
+ const dy = t.priceToY(points[1]!.price) - y0;
101
+ let tMin = Infinity;
102
+ if (dx > 0) tMin = Math.min(tMin, (t.plotWidth - x0) / dx);
103
+ else if (dx < 0) tMin = Math.min(tMin, -x0 / dx);
104
+ if (dy > 0) tMin = Math.min(tMin, (t.plotHeight - y0) / dy);
105
+ else if (dy < 0) tMin = Math.min(tMin, -y0 / dy);
106
+ const ex = tMin === Infinity ? x0 : x0 + dx * tMin;
107
+ const ey = tMin === Infinity ? y0 : y0 + dy * tMin;
108
+ gfx.moveTo(x0, y0).lineTo(ex, ey).stroke(stroke);
109
+ break;
110
+ }
111
+
112
+ // text: PixiJS does not handle text labels — skip silently
113
+ case 'text':
114
+ break;
115
+
116
+ default:
117
+ // Fib/Gann tools and future drawing types are rendered by the canvas layer, not Pixi.
118
+ break;
119
+ }
120
+ }
121
+ }