UNPKG

openalgo-charts

Version:

From-scratch, dependency-free canvas charting engine for OpenAlgo: professional-grade interactive rendering, advanced on-chart trading, and a one-call widget tier that adds the toolbar, drawing rail, dialogs and shortcuts.

504 lines (490 loc) 22.9 kB
/** * Internal time is always **UTC seconds** (integer). Feed adapters convert * broker formats (IST strings, epoch ms) to this at the edge; see ARCHITECTURE.md §4.0. */ type UTCSeconds = number; /** A single OHLC(V) bar. `volume` is optional (not all feeds carry it). */ interface Bar { time: UTCSeconds; open: number; high: number; low: number; close: number; volume?: number; /** * Per-bar colour override, honoured by every Family-A renderer: candles and * OHLC bars take it on body, border and wick together, histogram and column * on the bar, and line, step, area and the HLC-area close line split their * stroke into runs at the bars where it changes. Baseline is the exception, * its stroke is already split by the above/below-base rule. * A MACD histogram is four colours by momentum, and a conditional study is * two. Neither is expressible with one colour for the whole series. */ color?: string; } /** * Unified style bag for all Family-A series types (ARCHITECTURE.md §6A). Each * renderer reads the fields it needs; per-type defaults are filled by the * chart-type registry. Keeping one optional-field interface avoids a sprawling * discriminated union at the rendering boundary. */ interface SeriesStyle { upColor?: string; downColor?: string; borderUpColor?: string; borderDownColor?: string; wickUpColor?: string; wickDownColor?: string; borderVisible?: boolean; wickVisible?: boolean; /** Paint candle bodies. Off leaves outline and wick. Default true. */ bodyVisible?: boolean; hollow?: boolean; /** * Color bars by close-versus-previous-close instead of close-versus-own-open, * which is how most terminals paint a bar. Body, border and wick switch * together. Default false. */ colorByPreviousClose?: boolean; /** Whether the series is drawn and counted in autoscale. Default true. */ visible?: boolean; /** Optional label carried with the series (for host-drawn legends). */ title?: string; /** Show the dashed horizontal last-price line across the plot. Default true. */ priceLineVisible?: boolean; /** * Show this series' current value as a tag on the price axis. Default true. * * Every series on the pane's readout scale gets one, not only the instrument: * an indicator overlay, a comparison line, a study on a pane of its own. The * instrument's tag is the up/down coloured one that also carries the bar * countdown; the rest are drawn in their own plot colour. A series whose * current value is not a number draws no tag, so a study that is `na` right * now says nothing rather than showing a stale reading. */ lastValueVisible?: boolean; /** * Decimal places for every price the scale this series maps to formats: the * axis ticks, the last-value tag, the crosshair label and the drawing-tool * labels. It overrides the precision the price scale infers from the tick * size or the visible range. Undefined is the "Default" entry of the * Precision dropdown: keep inferring. Valid range 0 to 8. * * It does not reach a legend row: a `PaneLegend` is handed finished strings, * so whoever builds those readings owns their formatting. */ precision?: number; color?: string; lineWidth?: number; /** Line dash style for line/step/area/HLC series. Default 'solid'. */ lineStyle?: 'solid' | 'dashed' | 'dotted'; step?: boolean; markers?: boolean; /** Draw only the markers, with no connecting line (Parabolic SAR, scatter). */ markersOnly?: boolean; markerRadius?: number; areaTopColor?: string; areaBottomColor?: string; baseValue?: number; topColor?: string; bottomColor?: string; /** * Stroke for the top edge of an HLC area's band. Undefined leaves the edge * unstroked, which is how the band has always drawn. */ highColor?: string; /** Stroke for the bottom edge of an HLC area's band. See `highColor`. */ lowColor?: string; closeColor?: string; base?: number; /** * Fallback box size for stacking P&F X/O glyphs. Columns from * `PointFigureTransform` carry their own `boxSize`, which wins, so set this * only for hand-built column data. */ boxSize?: number; /** Kagi thick (yang) line color. */ thickColor?: string; /** Kagi thin (yin) line color. */ thinColor?: string; } /** * Chart theme (palette). A single object drives chart chrome (background, grid, * axes, crosshair), series defaults (up/down, line, area gradient, last price), * and the trade layer (buy/sell, profit/loss). Renderers read theme colors when * a per-series style field is absent, so one theme restyles the whole chart. */ interface ChartTheme { background: string; grid: string; axisText: string; axisLine: string; /** 1px rule between stacked panes. Subtle: it separates, it does not divide. */ paneSeparator: string; crosshair: string; /** Axis label font size in px (default 11). */ axisFontSize?: number; /** Grid line dash style (default 'solid'). */ gridStyle?: 'solid' | 'dashed' | 'dotted'; /** Crosshair line dash style (default 'dashed'). */ crosshairStyle?: 'solid' | 'dashed' | 'dotted'; /** Crosshair line width in device px (default 1 = hairline). */ crosshairWidth?: number; /** Background of the crosshair value tags (defaults to `crosshair`). */ crosshairLabelBackground?: string; /** Show the crosshair price/time value tags (default true). */ crosshairLabelVisible?: boolean; upColor: string; downColor: string; wickUpColor: string; wickDownColor: string; lineColor: string; areaTopColor: string; areaBottomColor: string; baselineTopLine: string; baselineTopFill: string; baselineBottomLine: string; baselineBottomFill: string; lastPriceUp: string; lastPriceDown: string; lastPriceText: string; buy: string; sell: string; profit: string; loss: string; } /** * Chart-type registry (ARCHITECTURE.md §6A). Every series type registers a * descriptor: how to draw it and how it contributes to autoscale. The core * iterates descriptors, so adding a style is one registration — no core change. * Phase 5 fills the Family-A (time-indexed) types; Families B/C plug in later. */ interface DrawItem { x: number; bar: Bar; /** * Close of the bar left of the visible range, carried on the first item only. * Previous-close candle colouring is the one renderer that reads it, because * it is the one that needs a reference the visible window does not hold. */ prevClose?: number; } interface SeriesRenderContext { plotHeight: number; maxVolume: number; theme: ChartTheme; } interface RendererEntry { defaultStyle: SeriesStyle; /** True for the price series whose last close drives the last-price line. */ isPriceSeries: boolean; /** * Paint the visible items on a bitmap-scope 2D context. On screen the pane * reaches this through its render backend (src/render/backend.ts); the 2D * backend calls it as is, and the vector export calls it directly on the * serialising target. */ draw(ctx: CanvasRenderingContext2D, items: readonly DrawItem[], toY: (v: number) => number, barSpacing: number, dpr: number, style: SeriesStyle, rc: SeriesRenderContext): void; /** Min/max price contribution of one bar to autoscale. */ extents(bar: Bar, style: SeriesStyle): { min: number; max: number; }; } /** * The render backend port (ARCHITECTURE.md section 3). A pane paints its series * through one of these and draws everything else (background, grid, primitives, * axes) on a 2D context itself, so a backend that rasterises series on the GPU * plugs in without the pane, the registry or any renderer learning about it. * * The port is deliberately narrow. It covers the one hot path a GPU can take * over, the per-frame series pass, and nothing the 2D context already does * well. Text, dashed lines, gradients and the drawing tools stay on canvas 2D. * * Which backend a chart gets is decided once, at construction, by the * `renderer` chart option or an injected factory. This module keeps the * registry that resolves the option; the shipped 2D backend registers itself * from its own module, and a later tier registers `webgl2` the same way, so * this file never has to import either. */ /** The backends the port can name. Only `canvas2d` ships in the base tier. */ type RenderBackendKind = 'canvas2d' | 'webgl2'; /** * Paints one pane's series. One instance per pane: a backend holds a context * on that pane's base canvas, and contexts do not travel between canvases. */ interface IRenderBackend { readonly kind: RenderBackendKind; /** * Take the pane's base data canvas. `ctx2d` is the 2D context the pane * already holds on it, when it holds one: the canvas-2D backend draws on * exactly that object, so a frame is one context's op stream and not two * contexts' interleaved. A backend that owns the canvas outright ignores it. */ mount(canvas: HTMLCanvasElement, ctx2d: CanvasRenderingContext2D | null): void; /** The pane's media size and pixel ratio changed. Sizes in CSS px. */ resize(widthPx: number, heightPx: number, dpr: number): void; /** Start a frame on the base canvas, clearing it first when asked. */ beginFrame(clear: boolean): void; /** * Paint one series. Same contract as `RendererEntry.draw` minus the context: * `priceToY` and `items[].x` are media px, and the renderer snaps to device * pixels with `dpr` itself. */ drawSeries(entry: RendererEntry, items: readonly DrawItem[], priceToY: (price: number) => number, barSpacing: number, dpr: number, style: SeriesStyle, rc: SeriesRenderContext): void; /** Finish the frame: flush whatever the backend batched. */ endFrame(): void; /** * A 2D context for what the backend does not draw natively (grid, axes, * primitives), or null when the backend paints those itself. */ overlay2d(): CanvasRenderingContext2D | null; /** Release the context and any GPU resources. The pane removes the canvas. */ destroy(): void; /** * The GPU device behind the backend, when it has one. The chart reads it * after a frame (`backendDegradation`) to learn that the context has been * lost or never came up, and moves every pane to `canvas2d` for the rest of * the session, announced by the 'renderer:fallback' chart event. A backend * without a device is never degraded, so the 2D backend leaves it unset. */ readonly device?: RenderDevice; } /** * What the chart needs to know about a GPU device. `available` is whether it * ever got a usable context (a driver that cannot run the program counts as * never having one), and `lost` is the window between a context loss and its * restoration. */ interface RenderDevice { readonly available: boolean; readonly lost: boolean; } /** Premultiplied red, green, blue, alpha, each 0..1. */ type PremultipliedRgba = readonly [number, number, number, number]; /** * Memoised colour lookup. A frame asks for the same handful of strings a few * thousand times, and a heatmap plot asks for a fresh rgba() string per bar, * so the map is bounded: past the limit it is cleared rather than trimmed, * because the strings that matter are re-asked on the next frame anyway. */ declare class ColorCache { private readonly _map; /** * The premultiplied colour for `css`. `normalise` is consulted only on a * parse failure, and only once per string. A string neither can read is * transparent: that is a colour nothing can blend with, so the bar it was * meant for is visibly absent rather than painted in a neighbour's colour, * which is what a 2D context's silently kept `fillStyle` would do. */ get(css: string, normalise?: (css: string) => string | null): PremultipliedRgba; get size(): number; clear(): void; } /** * The vertex batch the WebGL2 backend fills during a frame and flushes in one * draw call. Every shape is one quad (four vertices, six indices) carrying a * signed-distance description of itself, so the fragment shader can compute * an analytic coverage at its edge: * * x y device px * r g b a premultiplied colour * u v position in the shape's own frame (centre 0,0, device px) * hx hy half extents of the shape's inner box * rad corner radius added to that box * * A rect is a box with no radius, a round-capped line is a box of zero * height with the half line width as radius (a capsule), a marker dot is a * capsule of zero length, and a solid triangle sets its extents so large no * fragment is ever near an edge. One layout, one program, one draw call, in * submission order, which is what keeps a wick under its body and an area * fill under its line without sorting anything. * * Index pattern is fixed per quad slot and identical for every batch, so the * device uploads an index buffer only when a batch outgrows the one it has; * a frame that fits the capacity from the last one allocates nothing. */ declare class VertexBatch { vertices: Float32Array; indices: Uint32Array; quadCount: number; private _capacity; constructor(capacity?: number); get capacity(): number; get vertexCount(): number; get indexCount(): number; get floatCount(): number; /** Empty the batch for the next frame. Keeps every buffer. */ reset(): void; /** * An axis-aligned rectangle in device px. Edges on whole pixels cover * exactly those pixels; fractional edges get analytic coverage, the way a * 2D `fillRect` does. The quad only grows a one-pixel skirt for the * anti-aliased fringe when an edge is fractional, so the integer rects a * candle is made of rasterise no fragment outside themselves. */ rect(x: number, y: number, w: number, h: number, color: PremultipliedRgba): void; /** * A stroked segment of half width `hw` between two device-px points. * `round` gives it the round caps a series line is stroked with (a capsule, * so two segments meeting at a point also form a round join); otherwise it * is cut square at both ends, the default butt cap. A zero-length round * segment is a dot of radius `hw`; a zero-length butt one is nothing. */ segment(x0: number, y0: number, x1: number, y1: number, hw: number, color: PremultipliedRgba, round: boolean): void; /** * A solid quad with a colour per corner, in strip order (the two triangles * are 0-1-2 and 2-1-3), for gradient fills. No anti-aliasing: these tile * edge to edge under a line that covers the seam. */ quad(x0: number, y0: number, c0: PremultipliedRgba, x1: number, y1: number, c1: PremultipliedRgba, x2: number, y2: number, c2: PremultipliedRgba, x3: number, y3: number, c3: PremultipliedRgba): void; /** A solid triangle: a quad whose last corner repeats, leaving one triangle degenerate. */ triangle(x0: number, y0: number, c0: PremultipliedRgba, x1: number, y1: number, c1: PremultipliedRgba, x2: number, y2: number, c2: PremultipliedRgba): void; /** Reserve one quad slot; returns the float offset of its first vertex. */ private _claim; private _vertex; private _grow; /** Write the fixed two-triangle pattern for every slot from `from` up. */ private _fillIndices; } /** * The WebGL2 render backend: the series pass rasterised on the GPU, everything * else exactly where the 2D backend leaves it. * * How it sits in the pane. The pane's base canvas keeps its 2D context, and * the backend is handed that context at `mount`, the same way the 2D backend * is. The GPU draws into a separate surface that is never in the document: * one WebGL2 context shared by every pane of every chart on the page (a * browser allows around sixteen live contexts, and a dashboard of a few * multi-pane charts would exhaust that at one per pane). `endFrame` renders * the frame's batch and blits the surface into the base canvas with one * `drawImage`, under the pane's current transform and plot clip, at the * exact point in the frame where the 2D backend would have painted the same * series. The composite is a GPU-side copy in every accelerated browser. * * That one decision is what keeps the rest of the engine unchanged: the pane * still owns one canvas per layer, the grid stays under the series and the * price lines and drawings over them, `takeScreenshot` and the context-menu * freeze still read `pane.base.element`, and a parity spec counting canvases * per pane counts the same number. * * What is drawn natively is every Family-A type: candles (plain, hollow, * volume), bars, high-low, line, line-markers, step, area, HLC area, * baseline, column and histogram. Each emitter mirrors its 2D renderer * branch for branch and takes its geometry from the same functions * (`candleGeometry`, `optimalBarWidth`, `candleTier`, `barGeometry`, * `valuePoints`, `stepPoints`), so the two backends agree on which device * pixels a bar covers. Anything else (kagi, point and figure, a custom type) * falls back: the batch so far is flushed to keep z-order, and the entry's * own renderer paints on the 2D context. A lost context takes the same * fallback for the whole frame, so the chart never goes blank while the GPU * is away; when the context comes back the program is rebuilt and the next * frame is on the GPU again. */ /** * What the shared context is created on: a detached canvas or an * OffscreenCanvas. Only the members the device touches, so a test can hand * in a plain object with a recording context behind `getContext`. */ interface GlSurface { width: number; height: number; getContext(id: 'webgl2', attributes?: WebGLContextAttributes): WebGL2RenderingContext | null; addEventListener?(type: string, listener: (event: Event) => void): void; removeEventListener?(type: string, listener: (event: Event) => void): void; } /** * One WebGL2 context, its program and its buffers, shared by every backend * built for the same surface factory. Reference counted by mounted backends * so the drawing buffer is given back once the last chart is destroyed; the * context itself is kept, because a canvas whose context has been lost on * purpose hands back that same lost context on the next `getContext`. */ declare class GlDevice { readonly colors: ColorCache; private readonly _createSurface; private _surface; private _gl; private _program; private _vao; private _vbo; private _ibo; private _indexCapacity; private _refs; private _lost; private _probed; private _onLost; private _onRestored; constructor(createSurface?: () => GlSurface | null); /** Whether a WebGL2 context exists or can be created. Probes at most once. */ get available(): boolean; /** True between a context loss and its restoration: frames go through 2D meanwhile. */ get lost(): boolean; get surface(): GlSurface | null; get refs(): number; get gl(): WebGL2RenderingContext | null; acquire(): boolean; release(): void; /** Grow the surface to hold a pane of this bitmap size. Never shrinks while in use. */ ensureSize(widthPx: number, heightPx: number): void; /** * Draw a batch into the top-left `widthPx` by `heightPx` of the surface, * cleared first. Returns false when the context is lost or absent, in * which case nothing was drawn and the caller must not blit. */ render(batch: VertexBatch, widthPx: number, heightPx: number): boolean; /** Program, buffers and fixed state, built once per context (and again after a restore). */ private _setup; } /** The page-wide device every default backend shares. */ declare function sharedGlDevice(): GlDevice; /** Whether this device can run the WebGL2 backend. Probes once and remembers. */ declare function isWebGL2Supported(): boolean; declare class WebGL2Backend implements IRenderBackend { readonly kind = "webgl2"; readonly device: GlDevice; private readonly _batch; private _canvas; private _ctx; private _widthPx; private _heightPx; /** Whether `mount` took a reference on the device, so `destroy` gives back exactly that. */ private _acquired; /** Set at `beginFrame` when the GPU is unavailable: the whole frame goes through 2D. */ private _frame2d; /** Colour lookup bound to this pane's 2D context for the strings the parser cannot read. */ private readonly _color; private readonly _normalise; constructor(device?: GlDevice); /** The batch under construction, for tests that check what a series emits. */ get batch(): VertexBatch; mount(canvas: HTMLCanvasElement, ctx2d: CanvasRenderingContext2D | null): void; /** The pane sized its own canvas already; only the shared surface has to keep up. */ resize(widthPx: number, heightPx: number, dpr: number): void; /** The 2D backend's clear (which is `CanvasLayer.clearBitmap`), plus an empty batch. */ beginFrame(clear: boolean): void; drawSeries(entry: RendererEntry, items: readonly DrawItem[], priceToY: (price: number) => number, barSpacing: number, dpr: number, style: SeriesStyle, rc: SeriesRenderContext): void; endFrame(): void; overlay2d(): CanvasRenderingContext2D | null; destroy(): void; /** * Render the batch and blit it into the base canvas. The blit is drawn * under whatever transform and clip the pane has in force, which is the * plot's, so the surface lands exactly where the series would have been * painted directly. A batch with nothing in it costs no GPU work at all. */ private _flush; } /** * The `webgl2` factory: a backend on the shared device, or null when this * device has no WebGL2, in which case the chart takes the 2D backend. */ declare function createWebGL2Backend(device?: GlDevice): IRenderBackend | null; declare const WEBGL_TIER: "webgl"; /** * Register the WebGL2 backend. Called as a side effect when this tier is * imported, and exported so a consumer whose bundler tree-shakes a bare * `import 'openalgo-charts/webgl'` can call it explicitly. Idempotent. */ declare function registerWebGL2Renderer(): void; export { ColorCache, GlDevice, type GlSurface, type PremultipliedRgba, VertexBatch, WEBGL_TIER, WebGL2Backend, createWebGL2Backend, isWebGL2Supported, registerWebGL2Renderer, sharedGlDevice };