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
14 changes: 7 additions & 7 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 / 33 KiB gzip**. 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 / 34 KiB gzip**. 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,16 +210,16 @@ Generated from `dist/` after the package build.
| tooltip plugin entry | `dist/plugins/tooltip.js` | 0.1 KiB |
| crosshair plugin entry | `dist/plugins/crosshair.js` | 0.1 KiB |
| flamegraph plugin | `dist/plugins/flamegraph.js` | 20.7 KiB |
| shared Chart chunk | `dist/Chart-Bl4erFBO.js` | 55.2 KiB |
| shared streaming data chunk | `dist/UniformRingBuffer-zewj9EWq.js` | 44.2 KiB |
| shared OhlcDataset chunk | `dist/OhlcDataset-u2Eao8OX.js` | 11.2 KiB |
| shared Chart chunk | `dist/Chart-BXL4MXf-.js` | 56.5 KiB |
| shared streaming data chunk | `dist/UniformRingBuffer-C5yCnNeR.js` | 44.3 KiB |
| shared OhlcDataset chunk | `dist/OhlcDataset-DmFKmNbM.js` | 11.2 KiB |
| shared AxisController chunk | `dist/AxisController-CCk21uVK.js` | 13.8 KiB |
| shared WebGL2Backend chunk | `dist/WebGL2Backend-DivtLMNz.js` | 22.0 KiB |
| shared LinkedChartsCore chunk | `dist/LinkedChartsCore-BxD1tCts.js` | 2.1 KiB |
| shared LinkedChartsCore chunk | `dist/LinkedChartsCore-CYBw2rB_.js` | 2.1 KiB |
| lazy screenshot chunk | `dist/screenshot-PUXj6UGd.js` | 3.5 KiB |
| shared OverlayUtils chunk | `dist/OverlayUtils-BoCHW3n7.js` | 3.1 KiB |
| shared Tooltip chunk | `dist/Tooltip-D0WRT6Fj.js` | 5.7 KiB |
| shared Crosshair chunk | `dist/Crosshair-B0iu8h16.js` | 8.8 KiB |
| shared Tooltip chunk | `dist/Tooltip-D7t8uZIg.js` | 5.8 KiB |
| shared Crosshair chunk | `dist/Crosshair-BViz8JUB.js` | 9.9 KiB |

### All public exports

Expand Down
12 changes: 6 additions & 6 deletions docs/api-reference.md

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

11 changes: 10 additions & 1 deletion src/core/SeriesStore.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import type { Dataset, AppendableDataset, YAppendableDataset, UpdatableDataset, YUpdatableDataset, OhlcDataset, RangeMinMaxDataset, RangeSampleCopyDataset, VisibleSampleCopyDataset, VisiblePointCopyDataset, MinMaxSegmentCopyDataset, LODView, Viewport, TimeRange, SeriesConfig, SeriesStyle, SeriesSample } from "./types.js";
import type { Dataset, AppendableDataset, YAppendableDataset, UpdatableDataset, YUpdatableDataset, OhlcDataset, XRange, XRangeDataset, RangeMinMaxDataset, RangeSampleCopyDataset, VisibleSampleCopyDataset, VisiblePointCopyDataset, MinMaxSegmentCopyDataset, LODView, Viewport, TimeRange, SeriesConfig, SeriesStyle, SeriesSample } from "./types.js";
import { MinMaxPyramid } from "./MinMaxPyramid.js";

function hasRangeMinMaxY(dataset: Dataset): dataset is RangeMinMaxDataset {
return "rangeMinMaxY" in dataset;
}

function hasXRange(dataset: Dataset): dataset is XRangeDataset {
return "getXRange" in dataset;
}

function isOhlcDataset(dataset: Dataset): dataset is OhlcDataset {
return "getOpen" in dataset && "getHigh" in dataset && "getLow" in dataset && "getClose" in dataset;
}
Expand Down Expand Up @@ -522,6 +526,11 @@ export class SeriesStore {
return Math.max(0, end - start);
}

/** Return the represented X interval for a logical index when the dataset exposes interval metadata. */
xRangeAt(index: number): XRange | null {
return hasXRange(this.dataset) ? this.dataset.getXRange(index) : null;
}

/** Return an XY sample by logical index. */
sampleAt(index: number): SeriesSample | null {
if (index < 0 || index >= this.dataset.length) return null;
Expand Down
11 changes: 11 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ export interface Dataset {
upperBoundX(x: number): number;
}

/** Data-domain X interval represented by one dataset sample. */
export interface XRange {
readonly xStart: number;
readonly xEnd: number;
}

/** Dataset whose sample X values represent intervals rather than points. */
export interface XRangeDataset extends Dataset {
getXRange(index: number): XRange | null;
Comment on lines +72 to +74

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Export the new X-range dataset types

These new interfaces are the only named contract for datasets that want crosshair/tooltip interval highlighting, but they are not re-exported from either src/index.ts or src/core/index.ts, so package consumers cannot import XRangeDataset/XRange through the public blazeplot or blazeplot/core entry points despite the runtime now checking for getXRange(). This makes the new extension point effectively internal for TypeScript users; add these types to the public barrels alongside the other dataset capability interfaces.

Useful? React with 👍 / 👎.

}

/** Dataset that can answer min/max Y queries for index ranges. */
export interface RangeMinMaxDataset extends Dataset {
rangeMinMaxY(start: number, end: number): { minY: number; maxY: number } | null;
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/crosshair.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { crosshairPlugin } from "../ui/Crosshair.js";
export type { CrosshairAxis, CrosshairEventType, CrosshairMode, CrosshairPlugin, CrosshairPluginOptions, CrosshairPosition, CrosshairSnapMode, RulerMeasurement } from "../ui/Crosshair.js";
export type { CrosshairAxis, CrosshairEventType, CrosshairHighlightRenderer, CrosshairLabelPlacement, CrosshairMode, CrosshairPlugin, CrosshairPluginOptions, CrosshairPosition, CrosshairSnapMode, RulerMeasurement } from "../ui/Crosshair.js";
99 changes: 74 additions & 25 deletions src/ui/AxisOverlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export type AxisOverlayConfig = ChartLayoutConfig;

type RenderAxis = "x" | "y" | "y2";

const AXIS_LABEL_COLLISION_GAP_PX = 2;

/** SVG overlay that renders chart axes and ticks. */
export class AxisOverlay {
private xPool: HTMLDivElement[] = [];
Expand Down Expand Up @@ -117,44 +119,91 @@ export class AxisOverlay {
pool[i]!.style.display = "none";
}

for (let i = 0; i < values.length; i++) {
const el = pool[i]!;
const value = values[i]!;
const text = controller.formatValue(value, axis === "x" ? "x" : "y");
if (el.textContent !== text) {
el.textContent = text;
}
el.style.display = "block";

if (axis === "x") {
if (axis === "x") {
const labels: Array<{ el: HTMLDivElement; left: number; right: number; edge: boolean }> = [];
for (let i = 0; i < values.length; i++) {
const el = pool[i]!;
const value = values[i]!;
const text = controller.formatValue(value, "x");
if (el.textContent !== text) el.textContent = text;
const [clipX] = camera.toClip(value, camera.yMin);
const screenX = (clipX + 1) * 0.5 * plotW;
el.style.left = `${screenX}px`;
if (screenX < 0 || screenX > plotW) {
el.style.display = "none";
continue;
}
el.style.display = "block";
const labelWidth = Math.max(1, el.offsetWidth);
const centeredLeft = screenX - labelWidth * 0.5;
const maxLeft = Math.max(0, plotW - labelWidth);
const labelLeft = Math.min(Math.max(0, centeredLeft), maxLeft);
const edge = labelLeft === 0 || labelLeft === maxLeft;
el.style.left = `${labelLeft}px`;
el.style.right = "auto";
el.style.transform = "translateX(-50%)";
el.style.transform = "none";
if (this.config.x.position === "outside") {
el.style.top = "4px";
el.style.bottom = "auto";
} else {
el.style.top = "auto";
el.style.bottom = "4px";
}
} else {
const isRight = axis === "y2";
const config = isRight ? this.config.y2 : this.config.y;
const [, clipY] = camera.toClip(camera.xMin, value);
const screenY = (1 - clipY) * 0.5 * plotH;
el.style.top = `${screenY}px`;
el.style.bottom = "auto";
el.style.transform = "translateY(-50%)";
if (config.position === "outside") {
el.style.left = isRight ? "4px" : "auto";
el.style.right = isRight ? "auto" : "4px";
labels.push({ el, left: labelLeft, right: labelLeft + labelWidth, edge });
}

const placed: Array<{ left: number; right: number }> = [];
for (const label of [...labels].sort((a, b) => Number(b.edge) - Number(a.edge) || a.left - b.left)) {
const overlaps = placed.some((used) => label.left < used.right + AXIS_LABEL_COLLISION_GAP_PX && label.right > used.left - AXIS_LABEL_COLLISION_GAP_PX);
if (overlaps) {
label.el.style.display = "none";
} else {
el.style.left = isRight ? "auto" : "4px";
el.style.right = isRight ? "4px" : "auto";
placed.push({ left: label.left, right: label.right });
}
}
return;
}

const isRight = axis === "y2";
const config = isRight ? this.config.y2 : this.config.y;
const labels: Array<{ el: HTMLDivElement; top: number; bottom: number; edge: boolean }> = [];
for (let i = 0; i < values.length; i++) {
const el = pool[i]!;
const value = values[i]!;
const text = controller.formatValue(value, "y");
if (el.textContent !== text) el.textContent = text;
const [, clipY] = camera.toClip(camera.xMin, value);
const screenY = (1 - clipY) * 0.5 * plotH;
if (screenY < 0 || screenY > plotH) {
el.style.display = "none";
continue;
}
el.style.display = "block";
const labelHeight = Math.max(1, el.offsetHeight);
const centeredTop = screenY - labelHeight * 0.5;
const maxTop = Math.max(0, plotH - labelHeight);
const labelTop = Math.min(Math.max(0, centeredTop), maxTop);
const edge = labelTop === 0 || labelTop === maxTop;
el.style.top = `${labelTop}px`;
el.style.bottom = "auto";
el.style.transform = "none";
if (config.position === "outside") {
el.style.left = isRight ? "4px" : "auto";
el.style.right = isRight ? "auto" : "4px";
} else {
el.style.left = isRight ? "auto" : "4px";
el.style.right = isRight ? "4px" : "auto";
}
labels.push({ el, top: labelTop, bottom: labelTop + labelHeight, edge });
}

const placed: Array<{ top: number; bottom: number }> = [];
for (const label of [...labels].sort((a, b) => Number(b.edge) - Number(a.edge) || a.top - b.top)) {
const overlaps = placed.some((used) => label.top < used.bottom + AXIS_LABEL_COLLISION_GAP_PX && label.bottom > used.top - AXIS_LABEL_COLLISION_GAP_PX);
if (overlaps) {
label.el.style.display = "none";
} else {
placed.push({ top: label.top, bottom: label.bottom });
}
}
}
}
6 changes: 5 additions & 1 deletion src/ui/Chart.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SeriesConfig, SeriesStyle, Dataset, SeriesMode, SeriesSample, SeriesYAxis, Viewport } from "../core/types.js";
import type { SeriesConfig, SeriesStyle, Dataset, SeriesMode, SeriesSample, SeriesYAxis, Viewport, XRange } from "../core/types.js";
import { SeriesStore } from "../core/SeriesStore.js";
import { RingBuffer } from "../core/RingBuffer.js";
import { UniformRingBuffer } from "../core/UniformRingBuffer.js";
Expand Down Expand Up @@ -180,6 +180,8 @@ export interface ChartSeriesState {

/** A picked data point with series metadata and screen coordinates. */
export interface ChartPickItem extends SeriesSample {
/** Optional represented X interval for interval-backed samples. */
readonly xRange?: XRange;
readonly series: SeriesStore;
readonly seriesIndex: number;
readonly id?: string;
Expand Down Expand Up @@ -2148,8 +2150,10 @@ export class Chart implements ChartPluginContext {
const itemClientY = rect.top + plotY;
const dx = itemClientX - clientX;
const dy = itemClientY - clientY;
const xRange = series.xRangeAt(sample.index);
return {
...sample,
...(xRange ? { xRange } : {}),
distancePx: Math.hypot(dx, dy),
series,
seriesIndex,
Expand Down
55 changes: 45 additions & 10 deletions src/ui/Crosshair.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import type { SeriesYAxis } from "../core/types.js";
import type { Chart, ChartPickItem, ChartPickMode, ChartPlugin, ChartPluginContext } from "./Chart.js";
import { createLongPressTouchTracker, createPickMarker, formatCompactNumber, placeAbsoluteWithinBox, renderPickItems } from "./OverlayUtils.js";
import { createLongPressTouchTracker, createPickMarker, formatCompactNumber, placeAbsoluteWithinBox, renderPickItems, rgba } from "./OverlayUtils.js";

/** Axis drawn by the crosshair overlay. */
export type CrosshairAxis = "x" | "y" | "xy";
/** Optional snapping strategy for crosshair positions. */
export type CrosshairSnapMode = "none" | "nearest-x" | "nearest-point";
/** Crosshair display behavior. */
export type CrosshairMode = "crosshair" | "ruler";
/** Crosshair label placement relative to current position. */
export type CrosshairLabelPlacement = "bottom-right" | "top-right" | "bottom-left" | "top-left";

/** Custom renderer for crosshair pick highlights. */
export type CrosshairHighlightRenderer = (position: CrosshairPosition, container: HTMLElement, chart: Chart) => void;

/** Crosshair position in client and data coordinates. */
export interface CrosshairPosition {
Expand Down Expand Up @@ -45,11 +50,14 @@ export interface CrosshairPluginOptions {
readonly labelBackground?: string;
readonly labelColor?: string;
readonly labelFont?: string;
readonly labelPlacement?: CrosshairLabelPlacement;
readonly zIndex?: number;
readonly highlight?: boolean;
readonly markerSize?: number;
readonly markerStrokeColor?: string;
readonly markerStrokeWidth?: number;
/** Override default pick highlighting. Defaults to points, or X-interval rectangles for items with `xRange`. */
readonly renderHighlight?: CrosshairHighlightRenderer;
readonly longPressMs?: number | false;
readonly rulerModifier?: "none" | "ctrl" | "shift" | "alt" | "meta";
readonly formatX?: (value: number) => string;
Expand Down Expand Up @@ -133,6 +141,24 @@ function resolveSharedPosition(chart: ChartPluginContext, dataX: number, yAxis:
return { dataX, dataY, plotX, plotY, items: [] };
}

function createXRangeHighlight(item: ChartPickItem, chart: Chart, strokeColor: string | undefined, strokeWidth: number): HTMLDivElement {
const yAxis = item.series.config.yAxis ?? "left";
const baseline = item.series.style.baseline ?? 0;
const [leftX, valueY] = chart.dataToPlot(item.xRange!.xStart, item.y, yAxis);
const [rightX, baselineY] = chart.dataToPlot(item.xRange!.xEnd, baseline, yAxis);
const marker = document.createElement("div");
marker.style.position = "absolute";
marker.style.left = `${Math.min(leftX, rightX)}px`;
marker.style.top = `${Math.min(valueY, baselineY)}px`;
marker.style.width = `${Math.max(2, Math.abs(rightX - leftX))}px`;
marker.style.height = `${Math.max(2, Math.abs(baselineY - valueY))}px`;
marker.style.border = `${strokeWidth}px solid ${strokeColor ?? "#f8fafc"}`;
marker.style.background = `linear-gradient(${rgba(item.series.style.color).replace(/, [^)]+\)$/u, ", 0.38)")}, ${rgba(item.series.style.color).replace(/, [^)]+\)$/u, ", 0.38)")}), ${chart.theme.backgroundCssColor}`;
marker.style.boxShadow = "0 0 0 1px rgba(4, 8, 16, 0.85)";
marker.style.boxSizing = "border-box";
return marker;
}

function renderDefaultLabel(
position: CrosshairPosition,
container: HTMLElement,
Expand Down Expand Up @@ -212,25 +238,34 @@ export function crosshairPlugin(options: CrosshairPluginOptions = {}): Crosshair
const placeLabel = (position: CrosshairPosition): void => {
const chart = chartRef;
if (!chart || !label) return;
placeAbsoluteWithinBox(label, position.plotX, position.plotY, chart.canvas.clientWidth, chart.canvas.clientHeight, {
offsetX: 12,
offsetY: 12,
});
const placement = options.labelPlacement ?? "bottom-right";
const rect = label.getBoundingClientRect();
const offsetX = placement.endsWith("left") ? -rect.width - 12 : 12;
const offsetY = placement.startsWith("top") ? -rect.height - 12 : 12;
placeAbsoluteWithinBox(label, position.plotX, position.plotY, chart.canvas.clientWidth, chart.canvas.clientHeight, { offsetX, offsetY });
};

const renderMarkers = (position: CrosshairPosition | null): void => {
if (!markerLayer) return;
markerLayer.replaceChildren();
if (options.highlight === false || !position) return;
if (options.renderHighlight && chartRef) {
options.renderHighlight(position, markerLayer, chartRef as Chart);
return;
}

const size = Math.max(2, options.markerSize ?? 10);
const strokeWidth = Math.max(0, options.markerStrokeWidth ?? 2);
for (const item of position.items) {
markerLayer.appendChild(createPickMarker(item, {
sizePx: size,
strokeColor: options.markerStrokeColor,
strokeWidthPx: strokeWidth,
}));
if (item.xRange && chartRef) {
markerLayer.appendChild(createXRangeHighlight(item, chartRef as Chart, options.markerStrokeColor, strokeWidth));
} else {
markerLayer.appendChild(createPickMarker(item, {
sizePx: size,
strokeColor: options.markerStrokeColor,
strokeWidthPx: strokeWidth,
}));
}
}
};

Expand Down
13 changes: 12 additions & 1 deletion src/ui/Tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,21 @@ function renderDefaultTooltip(state: ChartHoverState, container: HTMLElement, fo
state.items,
state,
formatter,
(item) => `(${formatCompactNumber(item.x)}, ${formatCompactNumber(item.y)})`,
formatDefaultTooltipItem,
);
}

function formatDefaultTooltipItem(item: ChartPickItem): string {
if (item.xRange) {
return `${formatXRange(item.xRange)}: ${formatCompactNumber(item.y)}`;
}
return `(${formatCompactNumber(item.x)}, ${formatCompactNumber(item.y)})`;
}

function formatXRange(range: NonNullable<ChartPickItem["xRange"]>): string {
return `${formatCompactNumber(range.xStart)}–${formatCompactNumber(range.xEnd)}`;
}

interface TooltipPeer {
showShared(dataX: number): void;
hideShared(): void;
Expand Down
Loading