@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.
- package/package.json +50 -0
- package/src/__tests__/backwardCompatibility.test.ts +191 -0
- package/src/__tests__/candleInvariant.test.ts +500 -0
- package/src/__tests__/public-api-surface.ts +76 -0
- package/src/__tests__/timeframeBoundary.test.ts +583 -0
- package/src/api/DrawingManager.ts +188 -0
- package/src/api/EventBus.ts +53 -0
- package/src/api/IndicatorDAG.ts +389 -0
- package/src/api/IndicatorRegistry.ts +47 -0
- package/src/api/LayoutManager.ts +72 -0
- package/src/api/PaneManager.ts +129 -0
- package/src/api/ReferenceAPI.ts +195 -0
- package/src/api/TChart.ts +881 -0
- package/src/api/createChart.ts +43 -0
- package/src/api/drawing tools/fib gann menu/fibRetracement.ts +27 -0
- package/src/api/drawing tools/lines menu/crossLine.ts +21 -0
- package/src/api/drawing tools/lines menu/disjointChannel.ts +74 -0
- package/src/api/drawing tools/lines menu/extendedLine.ts +22 -0
- package/src/api/drawing tools/lines menu/flatTopBottom.ts +45 -0
- package/src/api/drawing tools/lines menu/horizontal.ts +24 -0
- package/src/api/drawing tools/lines menu/horizontalRay.ts +25 -0
- package/src/api/drawing tools/lines menu/infoLine.ts +127 -0
- package/src/api/drawing tools/lines menu/insidePitchfork.ts +21 -0
- package/src/api/drawing tools/lines menu/modifiedSchiffPitchfork.ts +18 -0
- package/src/api/drawing tools/lines menu/parallelChannel.ts +47 -0
- package/src/api/drawing tools/lines menu/pitchfork.ts +15 -0
- package/src/api/drawing tools/lines menu/ray.ts +28 -0
- package/src/api/drawing tools/lines menu/regressionTrend.ts +157 -0
- package/src/api/drawing tools/lines menu/schiffPitchfork.ts +18 -0
- package/src/api/drawing tools/lines menu/trendAngle.ts +64 -0
- package/src/api/drawing tools/lines menu/trendline.ts +16 -0
- package/src/api/drawing tools/lines menu/vertical.ts +16 -0
- package/src/api/drawing tools/pointers menu/crosshair.ts +17 -0
- package/src/api/drawing tools/pointers menu/cursor.ts +16 -0
- package/src/api/drawing tools/pointers menu/demonstration.ts +35 -0
- package/src/api/drawing tools/pointers menu/dot.ts +26 -0
- package/src/api/drawing tools/shapes menu/rectangle.ts +24 -0
- package/src/api/drawing tools/shapes menu/text.ts +30 -0
- package/src/api/drawingUtils.ts +82 -0
- package/src/core/CanvasLayer.ts +77 -0
- package/src/core/Chart.ts +917 -0
- package/src/core/CoordTransform.ts +282 -0
- package/src/core/Crosshair.ts +207 -0
- package/src/core/IndicatorEngine.ts +216 -0
- package/src/core/InteractionManager.ts +899 -0
- package/src/core/PriceScale.ts +133 -0
- package/src/core/Series.ts +132 -0
- package/src/core/TimeScale.ts +175 -0
- package/src/datafeed/DatafeedConnector.ts +300 -0
- package/src/engine/CandleEngine.ts +458 -0
- package/src/engine/__tests__/CandleEngine.test.ts +402 -0
- package/src/engine/candleInvariants.ts +172 -0
- package/src/engine/mergeUtils.ts +93 -0
- package/src/engine/timeframeUtils.ts +118 -0
- package/src/index.ts +190 -0
- package/src/internal.ts +41 -0
- package/src/licensing/ChartRuntimeResolver.ts +380 -0
- package/src/licensing/LicenseManager.ts +131 -0
- package/src/licensing/__tests__/ChartRuntimeResolver.test.ts +207 -0
- package/src/licensing/__tests__/LicenseManager.test.ts +180 -0
- package/src/licensing/licenseTypes.ts +19 -0
- package/src/pine/PineCompiler.ts +68 -0
- package/src/pine/diagnostics.ts +30 -0
- package/src/pine/index.ts +7 -0
- package/src/pine/pine-ast.ts +163 -0
- package/src/pine/pine-lexer.ts +265 -0
- package/src/pine/pine-parser.ts +439 -0
- package/src/pine/pine-transpiler.ts +301 -0
- package/src/pixi/LayerName.ts +35 -0
- package/src/pixi/PixiCandlestickRenderer.ts +125 -0
- package/src/pixi/PixiChart.ts +425 -0
- package/src/pixi/PixiCrosshairRenderer.ts +134 -0
- package/src/pixi/PixiDrawingRenderer.ts +121 -0
- package/src/pixi/PixiGridRenderer.ts +136 -0
- package/src/pixi/PixiLayerManager.ts +102 -0
- package/src/renderers/CandlestickRenderer.ts +130 -0
- package/src/renderers/HistogramRenderer.ts +63 -0
- package/src/renderers/LineRenderer.ts +77 -0
- package/src/theme/colors.ts +21 -0
- package/src/tools/barDivergenceCheck.ts +305 -0
- package/src/trading/TradingOverlayStore.ts +161 -0
- package/src/trading/UnmanagedIngestion.ts +156 -0
- package/src/trading/__tests__/ManagedTradingController.test.ts +338 -0
- package/src/trading/__tests__/TradingOverlayStore.test.ts +323 -0
- package/src/trading/__tests__/UnmanagedIngestion.test.ts +205 -0
- package/src/trading/managed/ManagedTradingController.ts +292 -0
- package/src/trading/managed/managedCapabilities.ts +98 -0
- package/src/trading/managed/managedTypes.ts +151 -0
- package/src/trading/tradingTypes.ts +135 -0
- package/src/tscript/TScriptIndicator.ts +54 -0
- package/src/tscript/ast.ts +105 -0
- package/src/tscript/lexer.ts +190 -0
- package/src/tscript/parser.ts +334 -0
- package/src/tscript/runtime.ts +525 -0
- package/src/tscript/series.ts +84 -0
- package/src/types/IChart.ts +56 -0
- package/src/types/IRenderer.ts +16 -0
- package/src/types/ISeries.ts +30 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +15 -0
- package/vitest.config.ts +25 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { PriceRange, ChartColors, PriceScaleOptions } from '@forgecharts/types';
|
|
2
|
+
import { mapRange, computeTicks, roundToPrecision } from '@forgecharts/utils';
|
|
3
|
+
import type { CanvasLayer } from './CanvasLayer';
|
|
4
|
+
|
|
5
|
+
const AXIS_WIDTH = 70;
|
|
6
|
+
const TIME_SCALE_HEIGHT = 30;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* PriceScale — manages the vertical price axis.
|
|
10
|
+
* Auto-scales to the visible data range and renders price labels.
|
|
11
|
+
*/
|
|
12
|
+
export class PriceScale {
|
|
13
|
+
private _visibleRange: PriceRange;
|
|
14
|
+
private readonly _layer: CanvasLayer;
|
|
15
|
+
private readonly _colors: ChartColors;
|
|
16
|
+
private readonly _options: Required<PriceScaleOptions>;
|
|
17
|
+
|
|
18
|
+
constructor(layer: CanvasLayer, colors: ChartColors, options: PriceScaleOptions = {}) {
|
|
19
|
+
this._layer = layer;
|
|
20
|
+
this._colors = colors;
|
|
21
|
+
this._options = {
|
|
22
|
+
autoScale: options.autoScale ?? true,
|
|
23
|
+
invertScale: options.invertScale ?? false,
|
|
24
|
+
borderVisible: options.borderVisible ?? true,
|
|
25
|
+
ticksVisible: options.ticksVisible ?? true,
|
|
26
|
+
mode: options.mode ?? 'normal',
|
|
27
|
+
width: options.width ?? AXIS_WIDTH,
|
|
28
|
+
};
|
|
29
|
+
this._visibleRange = { min: 0, max: 100 };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get visibleRange(): PriceRange {
|
|
33
|
+
return this._visibleRange;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get width(): number {
|
|
37
|
+
return this._options.width;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
setVisibleRange(range: PriceRange): void {
|
|
41
|
+
this._visibleRange = range;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Converts a price value to a canvas Y coordinate.
|
|
46
|
+
*/
|
|
47
|
+
priceToY(price: number, canvasHeight: number): number {
|
|
48
|
+
const plotHeight = canvasHeight - TIME_SCALE_HEIGHT;
|
|
49
|
+
const { min, max } = this._visibleRange;
|
|
50
|
+
|
|
51
|
+
if (this._options.mode === 'logarithmic') {
|
|
52
|
+
const logMin = Math.log(Math.max(min, 1e-10));
|
|
53
|
+
const logMax = Math.log(Math.max(max, 1e-10));
|
|
54
|
+
const logPrice = Math.log(Math.max(price, 1e-10));
|
|
55
|
+
return mapRange(logPrice, logMax, logMin, 0, plotHeight);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return this._options.invertScale
|
|
59
|
+
? mapRange(price, max, min, 0, plotHeight)
|
|
60
|
+
: mapRange(price, max, min, 0, plotHeight);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Converts a canvas Y coordinate back to a price value.
|
|
65
|
+
*/
|
|
66
|
+
yToPrice(y: number, canvasHeight: number): number {
|
|
67
|
+
const plotHeight = canvasHeight - TIME_SCALE_HEIGHT;
|
|
68
|
+
const { min, max } = this._visibleRange;
|
|
69
|
+
return mapRange(y, 0, plotHeight, max, min);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
render(ctx: CanvasRenderingContext2D, width: number, height: number): void {
|
|
73
|
+
if (!this._options.ticksVisible) return;
|
|
74
|
+
|
|
75
|
+
const axisX = width - this._options.width;
|
|
76
|
+
const plotHeight = height - TIME_SCALE_HEIGHT;
|
|
77
|
+
|
|
78
|
+
// Axis separator
|
|
79
|
+
if (this._options.borderVisible) {
|
|
80
|
+
ctx.beginPath();
|
|
81
|
+
ctx.strokeStyle = this._colors.border;
|
|
82
|
+
ctx.lineWidth = 1;
|
|
83
|
+
ctx.moveTo(axisX, 0);
|
|
84
|
+
ctx.lineTo(axisX, plotHeight);
|
|
85
|
+
ctx.stroke();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Compute ticks
|
|
89
|
+
const approxTicks = Math.max(2, Math.floor(plotHeight / 50));
|
|
90
|
+
const ticks = computeTicks(this._visibleRange.min, this._visibleRange.max, approxTicks);
|
|
91
|
+
|
|
92
|
+
ctx.fillStyle = this._colors.text;
|
|
93
|
+
ctx.font = '11px system-ui, sans-serif';
|
|
94
|
+
ctx.textAlign = 'left';
|
|
95
|
+
ctx.textBaseline = 'middle';
|
|
96
|
+
|
|
97
|
+
for (const tick of ticks) {
|
|
98
|
+
if (tick < this._visibleRange.min || tick > this._visibleRange.max) continue;
|
|
99
|
+
|
|
100
|
+
const y = this.priceToY(tick, height);
|
|
101
|
+
if (y < 0 || y > plotHeight) continue;
|
|
102
|
+
|
|
103
|
+
// Grid line
|
|
104
|
+
ctx.beginPath();
|
|
105
|
+
ctx.strokeStyle = this._colors.grid;
|
|
106
|
+
ctx.setLineDash([1, 3]);
|
|
107
|
+
ctx.moveTo(0, y);
|
|
108
|
+
ctx.lineTo(axisX, y);
|
|
109
|
+
ctx.stroke();
|
|
110
|
+
ctx.setLineDash([]);
|
|
111
|
+
|
|
112
|
+
// Tick mark
|
|
113
|
+
ctx.beginPath();
|
|
114
|
+
ctx.strokeStyle = this._colors.border;
|
|
115
|
+
ctx.lineWidth = 1;
|
|
116
|
+
ctx.moveTo(axisX, y);
|
|
117
|
+
ctx.lineTo(axisX + 4, y);
|
|
118
|
+
ctx.stroke();
|
|
119
|
+
|
|
120
|
+
// Label
|
|
121
|
+
const label = this._formatPrice(tick);
|
|
122
|
+
ctx.fillText(label, axisX + 6, y);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── Private ─────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
private _formatPrice(price: number): string {
|
|
129
|
+
const range = this._visibleRange.max - this._visibleRange.min;
|
|
130
|
+
const precision = range < 0.01 ? 6 : range < 1 ? 4 : range < 100 ? 2 : 0;
|
|
131
|
+
return roundToPrecision(price, precision).toFixed(precision);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { OHLCV, SeriesOptions, Viewport, Rect, ChartColors } from '@forgecharts/types';
|
|
2
|
+
import type { ISeries } from '../types/ISeries';
|
|
3
|
+
import type { IRenderer } from '../types/IRenderer';
|
|
4
|
+
import { CandlestickRenderer } from '../renderers/CandlestickRenderer';
|
|
5
|
+
import { LineRenderer } from '../renderers/LineRenderer';
|
|
6
|
+
import { HistogramRenderer } from '../renderers/HistogramRenderer';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Series holds a dataset (OHLCV[]) and delegates pixel-level rendering
|
|
10
|
+
* to the appropriate IRenderer implementation.
|
|
11
|
+
*/
|
|
12
|
+
export class Series implements ISeries {
|
|
13
|
+
private _data: OHLCV[] = [];
|
|
14
|
+
private _options: SeriesOptions;
|
|
15
|
+
private readonly _renderer: IRenderer;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Optional callback invoked after `setData()` or `update()` mutates the dataset.
|
|
19
|
+
* Used by `PixiChart` to mark the PriceSeries layer dirty without polling.
|
|
20
|
+
*/
|
|
21
|
+
onDataChanged?: () => void;
|
|
22
|
+
|
|
23
|
+
constructor(options: SeriesOptions, colors: ChartColors) {
|
|
24
|
+
this._options = options;
|
|
25
|
+
this._renderer = Series._createRenderer(options, colors);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── ISeries implementation ──────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
setData(data: readonly OHLCV[]): void {
|
|
31
|
+
this._data = [...data].sort((a, b) => a.time - b.time);
|
|
32
|
+
this.onDataChanged?.();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Apply a real-time OHLCV tick to the series.
|
|
37
|
+
*
|
|
38
|
+
* Rules (mirrors TradingView / Binance behaviour):
|
|
39
|
+
* - time normalisation : if > 1e12 it is milliseconds → convert to seconds
|
|
40
|
+
* - same candle (time === last.time):
|
|
41
|
+
* open → unchanged (preserved from first tick of this candle)
|
|
42
|
+
* high → max(prev.high, incoming.high, incoming.close)
|
|
43
|
+
* low → min(prev.low, incoming.low, incoming.close)
|
|
44
|
+
* close → incoming.close
|
|
45
|
+
* volume → incoming.volume
|
|
46
|
+
* - new candle (time > last.time) → append
|
|
47
|
+
* - out-of-order (time < last.time) → silently ignored
|
|
48
|
+
*/
|
|
49
|
+
update(bar: OHLCV): void {
|
|
50
|
+
// ── 1. Normalise timestamp: ms → s ────────────────────────────────────────
|
|
51
|
+
const t = bar.time > 1e12 ? Math.floor(bar.time / 1000) : bar.time;
|
|
52
|
+
|
|
53
|
+
// ── 2. First bar ever ────────────────────────────────────────────────────
|
|
54
|
+
if (this._data.length === 0) {
|
|
55
|
+
this._data.push({ ...bar, time: t });
|
|
56
|
+
this.onDataChanged?.();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const last = this._data[this._data.length - 1]!;
|
|
61
|
+
|
|
62
|
+
if (t === last.time) {
|
|
63
|
+
// ── 3a. Same candle: replace last element (OHLCV properties are readonly)
|
|
64
|
+
// open stays untouched — never replace the candle's original open
|
|
65
|
+
this._data[this._data.length - 1] = {
|
|
66
|
+
time: last.time,
|
|
67
|
+
open: last.open,
|
|
68
|
+
high: Math.max(last.high, bar.high, bar.close),
|
|
69
|
+
low: Math.min(last.low, bar.low, bar.close),
|
|
70
|
+
close: bar.close,
|
|
71
|
+
volume: bar.volume,
|
|
72
|
+
};
|
|
73
|
+
} else if (t > last.time) {
|
|
74
|
+
// ── 3b. New candle: append ───────────────────────────────────────────────
|
|
75
|
+
this._data.push({ ...bar, time: t });
|
|
76
|
+
}
|
|
77
|
+
// ── 3c. Out-of-order (t < last.time): silently discard ──────────────────
|
|
78
|
+
|
|
79
|
+
this.onDataChanged?.();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
data(): readonly OHLCV[] {
|
|
83
|
+
return this._data;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
applyOptions(options: Partial<SeriesOptions>): void {
|
|
87
|
+
this._options = { ...this._options, ...options } as SeriesOptions;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
options(): SeriesOptions {
|
|
91
|
+
return this._options;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── Rendering (called by Chart) ────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
render(ctx: CanvasRenderingContext2D, viewport: Viewport, rect: Rect): void {
|
|
97
|
+
if (this._data.length === 0) return;
|
|
98
|
+
const visible = this._options.visible ?? true;
|
|
99
|
+
if (!visible) return;
|
|
100
|
+
|
|
101
|
+
const visibleData = this._getVisibleData(viewport);
|
|
102
|
+
if (visibleData.length === 0) return;
|
|
103
|
+
|
|
104
|
+
this._renderer.draw(ctx, visibleData, viewport, rect, this._options);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Private helpers ─────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
private _getVisibleData(viewport: Viewport): OHLCV[] {
|
|
110
|
+
const { from, to } = viewport.timeRange;
|
|
111
|
+
// Small margin so partial bars at edges are included
|
|
112
|
+
return this._data.filter((b) => b.time >= from - 1 && b.time <= to + 1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private static _createRenderer(options: SeriesOptions, colors: ChartColors): IRenderer {
|
|
116
|
+
switch (options.type) {
|
|
117
|
+
case 'candlestick':
|
|
118
|
+
case 'heikinashi':
|
|
119
|
+
case 'bar':
|
|
120
|
+
return new CandlestickRenderer(colors);
|
|
121
|
+
case 'line':
|
|
122
|
+
case 'area':
|
|
123
|
+
return new LineRenderer(colors);
|
|
124
|
+
case 'histogram':
|
|
125
|
+
return new HistogramRenderer(colors);
|
|
126
|
+
default: {
|
|
127
|
+
const _exhaustive: never = options;
|
|
128
|
+
throw new Error(`[ForgeCharts] Unknown series type: ${JSON.stringify(_exhaustive)}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { TimeRange, ChartColors, TimeScaleOptions, Timeframe } from '@forgecharts/types';
|
|
2
|
+
import { mapRange, computeTicks } from '@forgecharts/utils';
|
|
3
|
+
import { formatTimestampLabel } from '@forgecharts/utils';
|
|
4
|
+
import type { CanvasLayer } from './CanvasLayer';
|
|
5
|
+
|
|
6
|
+
const AXIS_HEIGHT = 30;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* TimeScale — manages the horizontal time axis.
|
|
10
|
+
* Handles pan/zoom of the visible time range and renders tick labels.
|
|
11
|
+
*/
|
|
12
|
+
export class TimeScale {
|
|
13
|
+
private _visibleRange: TimeRange;
|
|
14
|
+
private readonly _layer: CanvasLayer;
|
|
15
|
+
private readonly _colors: ChartColors;
|
|
16
|
+
private readonly _options: Required<TimeScaleOptions>;
|
|
17
|
+
private _activeTimeframe: Timeframe = '1h';
|
|
18
|
+
/** IANA timezone for axis labels. Falls back to UTC when unset. */
|
|
19
|
+
timezone: string | undefined = undefined;
|
|
20
|
+
|
|
21
|
+
constructor(layer: CanvasLayer, colors: ChartColors, options: TimeScaleOptions = {}) {
|
|
22
|
+
this._layer = layer;
|
|
23
|
+
this._colors = colors;
|
|
24
|
+
this._options = {
|
|
25
|
+
timeVisible: options.timeVisible ?? true,
|
|
26
|
+
secondsVisible: options.secondsVisible ?? false,
|
|
27
|
+
borderVisible: options.borderVisible ?? true,
|
|
28
|
+
ticksVisible: options.ticksVisible ?? true,
|
|
29
|
+
height: options.height ?? AXIS_HEIGHT,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const now = Math.floor(Date.now() / 1000);
|
|
33
|
+
this._visibleRange = { from: now - 86400, to: now };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get visibleRange(): TimeRange {
|
|
37
|
+
return this._visibleRange;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get height(): number {
|
|
41
|
+
return this._options.height;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get activeTimeframe(): Timeframe {
|
|
45
|
+
return this._activeTimeframe;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
setVisibleRange(range: TimeRange): void {
|
|
49
|
+
this._visibleRange = range;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setTimeframe(tf: Timeframe): void {
|
|
53
|
+
this._activeTimeframe = tf;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Converts a Unix timestamp to a canvas X coordinate.
|
|
58
|
+
*/
|
|
59
|
+
timeToX(time: number, canvasWidth: number): number {
|
|
60
|
+
const plotWidth = canvasWidth - this._priceScaleWidth();
|
|
61
|
+
return mapRange(time, this._visibleRange.from, this._visibleRange.to, 0, plotWidth);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Converts a canvas X coordinate back to a Unix timestamp.
|
|
66
|
+
*/
|
|
67
|
+
xToTime(x: number, canvasWidth: number): number {
|
|
68
|
+
const plotWidth = canvasWidth - this._priceScaleWidth();
|
|
69
|
+
return mapRange(x, 0, plotWidth, this._visibleRange.from, this._visibleRange.to);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Zooms around a pivot X coordinate by a scale factor.
|
|
74
|
+
*/
|
|
75
|
+
zoom(factor: number, pivotX: number, canvasWidth: number = this._layer.width): void {
|
|
76
|
+
const pivotTime = this.xToTime(pivotX, canvasWidth);
|
|
77
|
+
const span = this._visibleRange.to - this._visibleRange.from;
|
|
78
|
+
const newSpan = Math.max(60, span * factor);
|
|
79
|
+
const ratio = (pivotTime - this._visibleRange.from) / span;
|
|
80
|
+
const newFrom = pivotTime - ratio * newSpan;
|
|
81
|
+
this._visibleRange = { from: newFrom, to: newFrom + newSpan };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Scrolls by a delta in seconds.
|
|
86
|
+
*/
|
|
87
|
+
scroll(deltaSeconds: number): void {
|
|
88
|
+
this._visibleRange = {
|
|
89
|
+
from: this._visibleRange.from + deltaSeconds,
|
|
90
|
+
to: this._visibleRange.to + deltaSeconds,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
render(ctx: CanvasRenderingContext2D, width: number, height: number): void {
|
|
95
|
+
if (!this._options.ticksVisible) return;
|
|
96
|
+
|
|
97
|
+
const axisY = height - this._options.height;
|
|
98
|
+
const plotWidth = width - this._priceScaleWidth();
|
|
99
|
+
|
|
100
|
+
// Axis separator line
|
|
101
|
+
if (this._options.borderVisible) {
|
|
102
|
+
ctx.beginPath();
|
|
103
|
+
ctx.strokeStyle = this._colors.border;
|
|
104
|
+
ctx.lineWidth = 1;
|
|
105
|
+
ctx.moveTo(0, axisY);
|
|
106
|
+
ctx.lineTo(plotWidth, axisY);
|
|
107
|
+
ctx.stroke();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Compute tick spacing — aim for a tick every ~100px
|
|
111
|
+
const approxTicks = Math.max(2, Math.floor(plotWidth / 100));
|
|
112
|
+
const span = this._visibleRange.to - this._visibleRange.from;
|
|
113
|
+
const rawStep = span / approxTicks;
|
|
114
|
+
const step = this._niceTimeStep(rawStep);
|
|
115
|
+
|
|
116
|
+
const startTime = Math.ceil(this._visibleRange.from / step) * step;
|
|
117
|
+
|
|
118
|
+
ctx.fillStyle = this._colors.text;
|
|
119
|
+
ctx.font = '11px system-ui, sans-serif';
|
|
120
|
+
ctx.textAlign = 'center';
|
|
121
|
+
ctx.textBaseline = 'middle';
|
|
122
|
+
|
|
123
|
+
for (let t = startTime; t <= this._visibleRange.to; t += step) {
|
|
124
|
+
const x = this.timeToX(t, width);
|
|
125
|
+
if (x < 0 || x > plotWidth) continue;
|
|
126
|
+
|
|
127
|
+
// Tick mark
|
|
128
|
+
ctx.beginPath();
|
|
129
|
+
ctx.strokeStyle = this._colors.border;
|
|
130
|
+
ctx.lineWidth = 1;
|
|
131
|
+
ctx.moveTo(x, axisY);
|
|
132
|
+
ctx.lineTo(x, axisY + 4);
|
|
133
|
+
ctx.stroke();
|
|
134
|
+
|
|
135
|
+
// Grid line
|
|
136
|
+
ctx.beginPath();
|
|
137
|
+
ctx.strokeStyle = this._colors.grid;
|
|
138
|
+
ctx.setLineDash([1, 3]);
|
|
139
|
+
ctx.moveTo(x, 0);
|
|
140
|
+
ctx.lineTo(x, axisY);
|
|
141
|
+
ctx.stroke();
|
|
142
|
+
ctx.setLineDash([]);
|
|
143
|
+
|
|
144
|
+
// Label
|
|
145
|
+
const label = formatTimestampLabel(t, step, this.timezone);
|
|
146
|
+
ctx.fillText(label, x, axisY + this._options.height / 2);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─── Private helpers ─────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
private _priceScaleWidth(): number {
|
|
153
|
+
return 70; // reserved for price scale — keep in sync with PriceScale
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private _niceTimeStep(rawSeconds: number): number {
|
|
157
|
+
const nice = [
|
|
158
|
+
1, 5, 10, 30, 60, 300, 600, 900, 1800, 3600, 7200, 14400, 21600, 43200,
|
|
159
|
+
86400, // 1 day
|
|
160
|
+
7 * 86400, // 1 week
|
|
161
|
+
14 * 86400, // 2 weeks
|
|
162
|
+
30 * 86400, // ~1 month
|
|
163
|
+
91 * 86400, // ~quarter
|
|
164
|
+
182 * 86400, // ~half year
|
|
165
|
+
365 * 86400, // ~1 year
|
|
166
|
+
];
|
|
167
|
+
for (const s of nice) {
|
|
168
|
+
if (s >= rawSeconds) return s;
|
|
169
|
+
}
|
|
170
|
+
return 365 * 86400 * Math.ceil(rawSeconds / (365 * 86400));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Re-export computeTicks so renderers can use it without an extra import
|
|
175
|
+
export { computeTicks };
|