From aa944ff223ed7ce6b4465c45857d2d0289fa95ce Mon Sep 17 00:00:00 2001 From: Federico Cervelli Date: Fri, 29 May 2026 22:03:19 +0200 Subject: [PATCH] Improve financial chart examples --- README.md | 4 +- docs/api-reference.md | 2 +- docs/examples.md | 68 +++++++++++++---- src/ui/Chart.ts | 58 +++++++++++++-- website/src/site/charts/signals.ts | 17 ++++- website/src/site/components/docs-page.ts | 93 +++++++++++++++++++++--- website/src/site/components/home-page.ts | 25 ++++++- 7 files changed, 224 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 7531fad..f1cbcb6 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Built on native WebGL2 with no rendering runtime dependency. ## Performance -The core chart runtime is intentionally compact: the production build for `blazeplot` (without optional plugins) is about **148 KiB raw**. Optional plugins and helpers ship as separate subpath entries. +The core chart runtime is intentionally compact: the production build for `blazeplot` (without optional plugins) is about **149 KiB raw**. Optional plugins and helpers ship as separate subpath entries. Latest manual headed comparison: 2026-05-22T15:20:02.565Z on AMD Ryzen 5 5600H with Radeon Graphics (12 logical CPUs), ANGLE (NVIDIA Corporation, NVIDIA GeForce RTX 3050 Laptop GPU/PCIe/SSE2, OpenGL 4.5.0), Chrome/148.0.7778.167. The harness prewarms each selected library before measured runs (317.4 ms total) and discards 1 setup warmup run(s) before each displayed row. Source: `benchmarks/latest.json`. @@ -210,7 +210,7 @@ Generated from `dist/` after the package build. | tooltip plugin entry | `dist/plugins/tooltip.js` | 0 KiB | | crosshair plugin entry | `dist/plugins/crosshair.js` | 0 KiB | | flamegraph plugin | `dist/plugins/flamegraph.js` | 21 KiB | -| shared Chart chunk | `dist/Chart-*.js` | 57 KiB | +| shared Chart chunk | `dist/Chart-*.js` | 58 KiB | | shared streaming data chunk | `dist/UniformRingBuffer-*.js` | 44 KiB | | shared OhlcDataset chunk | `dist/OhlcDataset-*.js` | 9 KiB | | shared AxisController chunk | `dist/AxisController-*.js` | 14 KiB | diff --git a/docs/api-reference.md b/docs/api-reference.md index 5cce639..67357e6 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -67,7 +67,7 @@ Generated from `dist/` after the package build. | tooltip plugin entry | `dist/plugins/tooltip.js` | 0 KiB | | crosshair plugin entry | `dist/plugins/crosshair.js` | 0 KiB | | flamegraph plugin | `dist/plugins/flamegraph.js` | 21 KiB | -| shared Chart chunk | `dist/Chart-*.js` | 57 KiB | +| shared Chart chunk | `dist/Chart-*.js` | 58 KiB | | shared streaming data chunk | `dist/UniformRingBuffer-*.js` | 44 KiB | | shared OhlcDataset chunk | `dist/OhlcDataset-*.js` | 9 KiB | | shared AxisController chunk | `dist/AxisController-*.js` | 14 KiB | diff --git a/docs/examples.md b/docs/examples.md index d4a7eed..162592f 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -199,27 +199,65 @@ Bucket ranges should be sorted and non-overlapping for predictable picking, boun ## Financial OHLC and candlesticks -Use `StaticOhlcDataset` for historical data or `OhlcRingBuffer` for live feeds. +Use `StaticOhlcDataset` for historical bars or `OhlcRingBuffer` for live feeds. A market chart usually wants candles, volume, time axes, crosshair labels, and annotation overlays, not just raw OHLC sticks. ```ts -import { Chart, StaticOhlcDataset } from "blazeplot"; +import { Chart, StaticDataset, StaticOhlcDataset } from "blazeplot"; +import { createLinkedCharts } from "blazeplot/linked"; +import { annotationsPlugin } from "blazeplot/plugins/annotations"; +import { crosshairPlugin } from "blazeplot/plugins/crosshair"; +import { interactionsPlugin } from "blazeplot/plugins/interactions"; +import { legendPlugin } from "blazeplot/plugins/legend"; -const dataset = new StaticOhlcDataset( - [0, 1, 2, 3], - [100, 104, 102, 108], - [106, 107, 110, 112], - [98, 101, 101, 105], - [104, 102, 108, 111], -); +const dayMs = 24 * 60 * 60 * 1000; +const time = [1704067200000, 1704153600000, 1704240000000, 1704326400000]; +const open = [100, 104, 102, 108]; +const high = [106, 107, 110, 112]; +const low = [98, 101, 101, 105]; +const close = [104, 102, 108, 111]; +const volume = [42, 58, 76, 63]; -const chart = new Chart(element); -chart.addOhlc({ dataset, name: "OHLC" }); -chart.addCandlestick({ dataset, name: "candles" }); -chart.fitToData(); -chart.start(); +const candles = new StaticOhlcDataset(time, open, high, low, close); +const volumes = new StaticDataset(time, volume); +const last = close.at(-1) ?? 0; + +const linked = createLinkedCharts(element, { + rows: 2, + sharedX: true, + spacing: 0, + panels: [ + { + options: { + axes: { x: { position: "outside", scale: "time" }, y: { position: "outside" } }, + grid: true, + plugins: [ + interactionsPlugin({ wheelZoom: true, shiftDragPan: true, boxZoom: true }), + crosshairPlugin({ axis: "xy", snap: "nearest-x", label: true }), + legendPlugin({ position: "top-left" }), + annotationsPlugin({ annotations: [{ type: "y-line", y: last, label: `last ${last}` }] }), + ], + }, + }, + { + options: { + axes: { x: { position: "outside", scale: "time" }, y: { position: "outside" } }, + grid: true, + plugins: [interactionsPlugin(), crosshairPlugin({ axis: "xy", snap: "nearest-x", label: true })], + }, + }, + ], +}); + +const priceChart = linked.charts[0] as Chart | undefined; +const volumeChart = linked.charts[1] as Chart | undefined; +priceChart?.addCandlestick({ dataset: candles, name: "candles" }, { barWidth: dayMs * 0.7 }); +volumeChart?.addBar({ dataset: volumes, name: "volume" }, { baseline: 0, barWidth: dayMs * 0.7 }); +priceChart?.fitToData({ padding: { x: 0.02, y: 0.12 } }); +volumeChart?.fitToData({ includeZero: true, padding: { x: 0.02, y: 0.12 } }); +for (const chart of linked.charts) chart.start(); ``` -:::chart financial OHLC and candlestick series +:::chart financial Candlesticks, volume, crosshair, price line, and markers OHLC bounds use high/low values, while generic `getY()` returns close. For live OHLC streams, append through the returned series with `series.append({ x, open, high, low, close })`, append row batches like `series.append([{ x, open, high, low, close }])`, update a candle with `series.updateAt(index, { open, high, low, close })`, or update the active candle with `series.updateLast({ open, high, low, close })`; direct `dataset.push(...)` / `dataset.updateLast(...)` calls need a follow-up `series.markDirty()`. See [Data semantics](./data-semantics.md#ohlc-datasets). diff --git a/src/ui/Chart.ts b/src/ui/Chart.ts index 680d665..98ec902 100644 --- a/src/ui/Chart.ts +++ b/src/ui/Chart.ts @@ -1855,15 +1855,20 @@ export class Chart implements ChartPluginContext { private drawOhlcSeries(series: SeriesStore, viewport: Viewport, projection: RenderProjection): void { const range = series.visibleIndexRange(viewport); - const maxCandles = Math.floor(this.rawLineData.length / FLOATS_PER_OHLC_CANDLE); + const maxCandles = Math.min( + Math.floor(this.rawLineData.length / FLOATS_PER_OHLC_TUPLE), + Math.floor(this.barTriangleData.length / FLOATS_PER_OHLC_CANDLE), + ); + const tickWidth = series.style.tickWidth ?? series.style.barWidth ?? 0.8; + const upStyle: SeriesStyle = { ...series.style, color: series.style.upColor ?? series.style.color }; + const downStyle: SeriesStyle = { ...series.style, color: series.style.downColor ?? series.style.fillColor ?? series.style.color }; + for (let start = range.start; start < range.end;) { - const candleCount = series.copyOhlcRange(start, range.end, this.rawLineData, maxCandles, series.style.tickWidth ?? series.style.barWidth ?? 0.8, this.currentXOrigin); + const candleCount = series.copyOhlcTuplesRange(start, range.end, this.rawLineData, maxCandles, this.currentXOrigin); if (candleCount <= 0) break; - const vertexCount = candleCount * 6; - this.uploadRawLineData(vertexCount); - this.renderer.drawLines(this.rawLineBuffer, vertexCount, series.style, projection); - this.recordDraw("raw", vertexCount); + this.drawOhlcLines(candleCount, tickWidth, "up", upStyle, projection); + this.drawOhlcLines(candleCount, tickWidth, "down", downStyle, projection); start += candleCount; } } @@ -2073,6 +2078,47 @@ export class Chart implements ChartPluginContext { return candleCount * 2; } + private drawOhlcLines( + candleCount: number, + tickWidth: number, + direction: "up" | "down", + style: SeriesStyle, + projection: RenderProjection, + ): void { + const halfTick = tickWidth * 0.5; + let vertexCount = 0; + for (let i = 0; i < candleCount; i++) { + const src = i * FLOATS_PER_OHLC_TUPLE; + const x = this.rawLineData[src]!; + const open = this.rawLineData[src + 1]!; + const high = this.rawLineData[src + 2]!; + const low = this.rawLineData[src + 3]!; + const close = this.rawLineData[src + 4]!; + const isUp = close >= open; + if ((direction === "up") !== isUp) continue; + + const dst = vertexCount * 2; + this.barTriangleData[dst] = x; + this.barTriangleData[dst + 1] = low; + this.barTriangleData[dst + 2] = x; + this.barTriangleData[dst + 3] = high; + this.barTriangleData[dst + 4] = x - halfTick; + this.barTriangleData[dst + 5] = open; + this.barTriangleData[dst + 6] = x; + this.barTriangleData[dst + 7] = open; + this.barTriangleData[dst + 8] = x; + this.barTriangleData[dst + 9] = close; + this.barTriangleData[dst + 10] = x + halfTick; + this.barTriangleData[dst + 11] = close; + vertexCount += 6; + } + + if (vertexCount <= 0) return; + this.uploadBarTriangleData(vertexCount); + this.renderer.drawLines(this.barTriangleBuffer, vertexCount, style, projection); + this.recordDraw("raw", vertexCount); + } + private drawCandlestickBodies( candleCount: number, bodyWidth: number, diff --git a/website/src/site/charts/signals.ts b/website/src/site/charts/signals.ts index 10e3e8a..da1bf8a 100644 --- a/website/src/site/charts/signals.ts +++ b/website/src/site/charts/signals.ts @@ -3,11 +3,20 @@ export function demoSignal(x: number, phase: number): number { return Math.sin(t + phase * 0.8) * 0.65 + Math.sin(t * 0.33 + phase) * 0.32 + Math.cos(t * 1.8 + phase) * 0.08; } +function demoMarketClose(x: number): number { + const trend = x * 0.035; + const cycle = Math.sin(x * 0.065) * 9 + Math.sin(x * 0.19) * 3.2; + const pullback = x > 120 && x < 170 ? -(x - 120) * 0.11 : x >= 170 && x < 220 ? -5.5 + (x - 170) * 0.08 : 0; + return 184 + trend + cycle + pullback; +} + export function demoOhlcValues(x: number): readonly [number, number, number, number] { - const open = demoSignal(x - 1, 0) * 0.75; - const close = demoSignal(x, 0) * 0.75; - const spread = 0.12 + Math.abs(Math.sin(x * 0.19)) * 0.08; - return [open, Math.max(open, close) + spread, Math.min(open, close) - spread, close]; + const open = demoMarketClose(x - 1); + const close = demoMarketClose(x) + Math.sin(x * 0.73) * 0.9; + const volatility = 1.4 + Math.abs(Math.sin(x * 0.23)) * 2.2 + (x % 41 === 0 ? 4.5 : 0); + const high = Math.max(open, close) + volatility * (0.45 + Math.abs(Math.sin(x * 0.37)) * 0.35); + const low = Math.min(open, close) - volatility * (0.45 + Math.abs(Math.cos(x * 0.29)) * 0.35); + return [open, high, low, close]; } export function lineData(count: number, phase = 0, xStart = 0, xStep = 1): { x: Float64Array; y: Float32Array } { diff --git a/website/src/site/components/docs-page.ts b/website/src/site/components/docs-page.ts index 6470a5f..91d4049 100644 --- a/website/src/site/components/docs-page.ts +++ b/website/src/site/components/docs-page.ts @@ -270,27 +270,96 @@ export class BlazeplotDocsPage extends LitElement { } private mountFinancialDocChart(target: HTMLElement): void { - const count = 48; + const count = 180; + const barMs = 4 * 60 * 60 * 1000; + const start = Date.UTC(2026, 0, 5); const x = new Float64Array(count); const open = new Float32Array(count); const high = new Float32Array(count); const low = new Float32Array(count); const close = new Float32Array(count); - let price = 100; + const volume = new Float32Array(count); + let price = 184.2; for (let i = 0; i < count; i += 1) { - const delta = Math.sin(i * 0.37) * 1.6 + Math.cos(i * 0.11) * 0.8; - x[i] = i; + const drift = Math.sin(i * 0.043) * 0.85 + Math.cos(i * 0.137) * 0.38 + (i > 112 ? 0.18 : 0.03); + const body = Math.sin(i * 0.61) * 1.25 + Math.sin(i * 0.17) * 0.85 + drift; + const volatility = 1.8 + Math.abs(Math.sin(i * 0.23)) * 2.8 + (i === 88 || i === 142 ? 6 : 0); + x[i] = start + i * barMs; open[i] = price; - close[i] = price + delta; - high[i] = Math.max(open[i]!, close[i]!) + 0.8 + Math.abs(Math.sin(i)) * 0.8; - low[i] = Math.min(open[i]!, close[i]!) - 0.8 - Math.abs(Math.cos(i)) * 0.8; + close[i] = price + body; + high[i] = Math.max(open[i]!, close[i]!) + volatility * (0.45 + Math.abs(Math.sin(i * 0.61)) * 0.35); + low[i] = Math.min(open[i]!, close[i]!) - volatility * (0.45 + Math.abs(Math.cos(i * 0.53)) * 0.35); + volume[i] = 52 + Math.abs(body) * 18 + Math.abs(Math.sin(i * 0.31)) * 35 + (i === 88 || i === 142 ? 90 : 0); price = close[i]!; } - const dataset = new StaticOhlcDataset(x, open, high, low, close); - const chart = this.createDocChart(target, { plugins: [interactionsPlugin({ doubleClickReset: true }), tooltipPlugin({ mode: "nearest-x" })] }); - chart.addCandlestick({ dataset, name: "candles" }); - chart.fitToData({ padding: { x: 0.02, y: 0.12 } }); - chart.start(); + + const candleDataset = new StaticOhlcDataset(x, open, high, low, close); + const volumeDataset = new StaticDataset(x, volume); + const lastClose = close[count - 1] ?? price; + const highWatermark = Math.max(...Array.from(high)); + const lowWatermark = Math.min(...Array.from(low)); + const priceAnnotations = annotationsPlugin({ + annotations: [ + { type: "y-line", y: lastClose, color: "#f59e0b", width: 1, dash: "4 4", label: { text: `last ${lastClose.toFixed(2)}`, position: "right", color: "#fbbf24" } }, + { type: "y-range", yMin: lowWatermark, yMax: highWatermark, fillColor: "rgba(59,130,246,0.06)", borderColor: "rgba(59,130,246,0.22)", label: "range" }, + { type: "x-range", xMin: x[86]!, xMax: x[92]!, fillColor: "rgba(245,158,11,0.10)", borderColor: "rgba(245,158,11,0.35)", label: "event" }, + { type: "point", x: x[54]!, y: low[54]!, shape: "diamond", radius: 5, color: "#22c55e", strokeColor: "#052e16", strokeWidth: 1, label: { text: "buy", position: "bottom", color: "#86efac" } }, + { type: "point", x: x[142]!, y: high[142]!, shape: "diamond", radius: 5, color: "#ef4444", strokeColor: "#450a0a", strokeWidth: 1, label: { text: "sell", position: "top", color: "#fca5a5" } }, + ], + }); + const linked = createLinkedCharts(target, { + rows: 2, + sharedX: true, + spacing: 0, + panels: [ + { + options: this.docChartOptions({ + axes: { x: { position: "outside", scale: "time", timezone: "utc" }, y: { position: "outside" } }, + grid: true, + hover: { mode: "nearest-x", group: "x", maxDistancePx: 48 }, + plugins: [ + interactionsPlugin({ wheelZoom: true, shiftDragPan: true, boxZoom: true, doubleClickReset: true }), + crosshairPlugin({ + axis: "xy", + snap: "nearest-x", + label: true, + formatX: (value) => new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", timeZone: "UTC" }).format(new Date(value)), + formatY: (value) => value.toFixed(2), + formatter: (item) => { + const candle = item.series.ohlcAt(item.index); + if (!candle) return ""; + const change = candle.close - candle.open; + return `O ${candle.open.toFixed(2)} H ${candle.high.toFixed(2)}\nL ${candle.low.toFixed(2)} C ${candle.close.toFixed(2)}\n${change >= 0 ? "+" : ""}${change.toFixed(2)}`; + }, + }), + legendPlugin({ position: "top-left" }), + priceAnnotations, + ], + }), + }, + { + options: this.docChartOptions({ + axes: { x: { position: "outside", scale: "time", timezone: "utc" }, y: { position: "outside" } }, + grid: true, + hover: { mode: "nearest-x", group: "x", maxDistancePx: 48 }, + plugins: [interactionsPlugin({ wheelZoom: true, shiftDragPan: true, doubleClickReset: true }), crosshairPlugin({ axis: "xy", snap: "nearest-x", label: true }), legendPlugin({ position: "top-left" })], + }), + }, + ], + }); + linked.root.style.gridTemplateRows = "minmax(0,2.2fr) minmax(0,0.8fr)"; + const priceChart = linked.charts[0]; + const volumeChart = linked.charts[1]; + priceChart?.addCandlestick( + { dataset: candleDataset, name: "BLAZEUSDT 4h" }, + { barWidth: barMs * 0.7, lineWidth: 1, upColor: [0.13, 0.84, 0.49, 1], downColor: [0.94, 0.27, 0.27, 1], wickColor: [0.73, 0.78, 0.88, 1] }, + ); + volumeChart?.addBar({ dataset: volumeDataset, name: "volume", downsample: "none" }, { baseline: 0, barWidth: barMs * 0.68, color: [0.35, 0.55, 0.95, 0.58] }); + priceChart?.fitToData({ padding: { x: 0.02, y: 0.12 } }); + volumeChart?.fitToData({ includeZero: true, padding: { x: 0.02, y: 0.12 } }); + linked.setXRange(x[30]!, x[count - 1]! + barMs); + for (const chart of linked.charts) chart.start(); + this.docDisposers.push(() => { linked.dispose(); }); } private mountLinkedDocChart(target: HTMLElement): void { diff --git a/website/src/site/components/home-page.ts b/website/src/site/components/home-page.ts index e8b3fb1..dd966c5 100644 --- a/website/src/site/components/home-page.ts +++ b/website/src/site/components/home-page.ts @@ -101,12 +101,16 @@ export class BlazeplotHomePage extends LitElement { const initialCount = 420; let nextX = initialCount; let followLive = this.homeDataMode === "streaming"; + const homeViewport = (xMin: number, xMax: number): { xMin: number; xMax: number; yMin: number; yMax: number } => { + const yRange = this.homeChartMode === "ohlc" ? this.homeOhlcYRange(xMin, xMax) : { yMin: -1.35, yMax: 1.35 }; + return { xMin, xMax, ...yRange }; + }; const resetViewport = (): { xMin: number; xMax: number; yMin: number; yMax: number } => { if (this.homeDataMode === "streaming") { followLive = true; - return { xMin: nextX - initialCount, xMax: nextX - 1, yMin: -1.35, yMax: 1.35 }; + return homeViewport(nextX - initialCount, nextX - 1); } - return { xMin: 0, xMax: initialCount - 1, yMin: -1.35, yMax: 1.35 }; + return homeViewport(0, initialCount - 1); }; const viewportPolicy: ViewportPolicy = { beforePan(_camera, intent) { @@ -119,7 +123,7 @@ export class BlazeplotHomePage extends LitElement { }, beforeRender: (camera) => { if (this.homeDataMode !== "streaming" || !followLive) return; - camera.setViewport({ xMin: nextX - initialCount, xMax: nextX - 1, yMin: -1.35, yMax: 1.35 }); + camera.setViewport(homeViewport(nextX - initialCount, nextX - 1)); }, }; @@ -238,6 +242,21 @@ export class BlazeplotHomePage extends LitElement { dataset.push(x, open, high, low, close); } + private homeOhlcYRange(xMin: number, xMax: number): { yMin: number; yMax: number } { + const start = Math.max(0, Math.floor(xMin)); + const end = Math.max(start + 1, Math.ceil(xMax)); + let min = Infinity; + let max = -Infinity; + for (let x = start; x <= end; x += 1) { + const [, high, low] = demoOhlcValues(x); + min = Math.min(min, low); + max = Math.max(max, high); + } + if (!Number.isFinite(min) || !Number.isFinite(max)) return { yMin: -1.35, yMax: 1.35 }; + const padding = Math.max(1, (max - min) * 0.12); + return { yMin: min - padding, yMax: max + padding }; + } + private readonly handleHomeDataModeChange = (event: Event): void => { this.homeDataMode = (event.currentTarget as HTMLSelectElement).value as HomeDataMode; };