Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Built on native WebGL2 with no rendering runtime dependency.
<!-- README_PERFORMANCE_START -->
## 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`.

Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/api-reference.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

68 changes: 53 additions & 15 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
58 changes: 52 additions & 6 deletions src/ui/Chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 13 additions & 4 deletions website/src/site/charts/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } {
Expand Down
93 changes: 81 additions & 12 deletions website/src/site/components/docs-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 22 additions & 3 deletions website/src/site/components/home-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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));
},
};

Expand Down Expand Up @@ -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;
};
Expand Down