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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ Generated from `dist/` after the package build.
| shared WebGL2Backend chunk | `dist/WebGL2Backend-*.js` | 22 KiB |
| shared LinkedChartsCore chunk | `dist/LinkedChartsCore-*.js` | 2 KiB |
| lazy screenshot chunk | `dist/screenshot-*.js` | 4 KiB |
| shared OverlayUtils chunk | `dist/OverlayUtils-*.js` | 3 KiB |
| shared OverlayUtils chunk | `dist/OverlayUtils-*.js` | 4 KiB |
| shared Tooltip chunk | `dist/Tooltip-*.js` | 6 KiB |
| shared Crosshair chunk | `dist/Crosshair-*.js` | 10 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.

9 changes: 4 additions & 5 deletions scripts/benchmark-compare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { arch, cpus, platform, release, tmpdir, totalmem } from "node:os";
import { basename, join, resolve } from "node:path";
import officialConfig from "./benchmark-config.json";

interface Options {
scenarios: string[];
Expand Down Expand Up @@ -143,11 +144,9 @@ interface LibraryInfo {
version: string;
}

const OFFICIAL_SCENARIOS = ["line-100k-static", "line-1m-static", "line-1m-pan", "line-1m-stream", "line-10m-accelerated-pan"] as const;
const OFFICIAL_LIBRARIES = ["blazeplot", "uplot", "chartjs"] as const;
const RUNTIME_COMPARISONS = [
{ primaryLibrary: "blazeplot", referenceLibrary: "uplot" },
] as const;
const OFFICIAL_SCENARIOS = officialConfig.scenarios;
const OFFICIAL_LIBRARIES = officialConfig.libraries;
const RUNTIME_COMPARISONS = officialConfig.runtimeComparisons;

interface CompareReport {
schemaVersion: 1;
Expand Down
7 changes: 7 additions & 0 deletions scripts/benchmark-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"libraries": ["blazeplot", "uplot", "chartjs"],
"scenarios": ["line-100k-static", "line-1m-static", "line-1m-pan", "line-1m-stream", "line-10m-accelerated-pan"],
"runtimeComparisons": [
{ "primaryLibrary": "blazeplot", "referenceLibrary": "uplot" }
]
}
2 changes: 1 addition & 1 deletion scripts/bundle-size-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ function printHelpAndExit(): never {
}

function displayPath(path: string): string {
return path.replace(/-[A-Za-z0-9_]+\.js$/u, "-*.js");
return path.replace(/-[A-Za-z0-9_-]+\.js$/u, "-*.js");
}

function formatBytes(bytes: number): string {
Expand Down
9 changes: 4 additions & 5 deletions scripts/generate-readme-docs.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,10 @@ const docsStartMarker = "<!-- README_DOCS_START -->";
const docsEndMarker = "<!-- README_DOCS_END -->";
const performanceStartMarker = "<!-- README_PERFORMANCE_START -->";
const performanceEndMarker = "<!-- README_PERFORMANCE_END -->";
const officialComparisonLibraries = ["blazeplot", "uplot", "chartjs"];
const officialComparisonScenarios = ["line-100k-static", "line-1m-static", "line-1m-pan", "line-1m-stream", "line-10m-accelerated-pan"];
const runtimeComparisonPairs = [
{ primaryLibrary: "blazeplot", referenceLibrary: "uplot" },
];
const officialComparisonConfig = JSON.parse(readFileSync(resolve(root, "scripts/benchmark-config.json"), "utf-8"));
const officialComparisonLibraries = officialComparisonConfig.libraries;
const officialComparisonScenarios = officialComparisonConfig.scenarios;
const runtimeComparisonPairs = officialComparisonConfig.runtimeComparisons;

const args = new Set(process.argv.slice(2));
const check = args.has("--check");
Expand Down
14 changes: 2 additions & 12 deletions src/core/Histogram.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { upperBoundArray } from "./search.js";
import { StaticDataset } from "./StaticDataset.js";
import type { XRange, XRangeDataset } from "./types.js";

Expand Down Expand Up @@ -130,7 +131,7 @@ export function histogram(values: ArrayLike<number>, options: HistogramOptions =
if (binIndex < 0) binIndex = 0;
if (binIndex >= counted.length) binIndex = counted.length - 1;
} else {
binIndex = upperBound(edges.edges, value) - 1;
binIndex = upperBoundArray(edges.edges, value) - 1;
}

counted[binIndex]!.count++;
Expand Down Expand Up @@ -362,17 +363,6 @@ function inferUniformWidth(edges: readonly number[]): number | null {
return firstWidth;
}

function upperBound(values: readonly number[], needle: number): number {
let low = 0;
let high = values.length;
while (low < high) {
const mid = low + ((high - low) >> 1);
if (values[mid]! <= needle) low = mid + 1;
else high = mid;
}
return low;
}

function hasExplicitEdgeThresholds(options: HistogramOptions): boolean {
return Array.isArray(options.thresholds) || isReadonlyNumberArray(options.thresholds);
}
Expand Down
5 changes: 5 additions & 0 deletions src/core/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,8 @@ export function upperBound(length: number, valueAt: (index: number) => number, v
}
return lo;
}

/** Return the first array index whose value is greater than `value`. */
export function upperBoundArray(values: readonly number[], value: number): number {
return upperBound(values.length, (index) => values[index]!, value);
}
47 changes: 25 additions & 22 deletions src/ui/AxisOverlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,25 @@ type RenderAxis = "x" | "y" | "y2";

const AXIS_LABEL_COLLISION_GAP_PX = 2;

interface AxisLabelInterval {
readonly el: HTMLDivElement;
readonly start: number;
readonly end: number;
readonly edge: boolean;
}

function hideOverlappingLabels(labels: readonly AxisLabelInterval[]): void {
const placed: Array<{ start: number; end: number }> = [];
for (const label of [...labels].sort((a, b) => Number(b.edge) - Number(a.edge) || a.start - b.start)) {
const overlaps = placed.some((used) => label.start < used.end + AXIS_LABEL_COLLISION_GAP_PX && label.end > used.start - AXIS_LABEL_COLLISION_GAP_PX);
if (overlaps) {
label.el.style.display = "none";
} else {
placed.push({ start: label.start, end: label.end });
}
}
}

/** SVG overlay that renders chart axes and ticks. */
export class AxisOverlay {
private xPool: HTMLDivElement[] = [];
Expand Down Expand Up @@ -120,7 +139,7 @@ export class AxisOverlay {
}

if (axis === "x") {
const labels: Array<{ el: HTMLDivElement; left: number; right: number; edge: boolean }> = [];
const labels: AxisLabelInterval[] = [];
for (let i = 0; i < values.length; i++) {
const el = pool[i]!;
const value = values[i]!;
Expand Down Expand Up @@ -148,24 +167,16 @@ export class AxisOverlay {
el.style.top = "auto";
el.style.bottom = "4px";
}
labels.push({ el, left: labelLeft, right: labelLeft + labelWidth, edge });
labels.push({ el, start: labelLeft, end: 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 {
placed.push({ left: label.left, right: label.right });
}
}
hideOverlappingLabels(labels);
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 }> = [];
const labels: AxisLabelInterval[] = [];
for (let i = 0; i < values.length; i++) {
const el = pool[i]!;
const value = values[i]!;
Expand Down Expand Up @@ -193,17 +204,9 @@ export class AxisOverlay {
el.style.left = isRight ? "auto" : "4px";
el.style.right = isRight ? "4px" : "auto";
}
labels.push({ el, top: labelTop, bottom: labelTop + labelHeight, edge });
labels.push({ el, start: labelTop, end: 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 });
}
}
hideOverlappingLabels(labels);
}
}
9 changes: 6 additions & 3 deletions src/ui/Chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,7 @@ export class Chart implements ChartPluginContext {
this.lastPointerPlotX = event.offsetX;
this.lastPointerPlotY = event.offsetY;
this.lastPointerButtons = event.buttons;
this.refreshHover();
this.scheduleHoverRefresh();
}
if (this.pointerSubscribers.pointermove.size > 0) this.emitPointerEvent("pointermove", event);
};
Expand Down Expand Up @@ -978,12 +978,15 @@ export class Chart implements ChartPluginContext {
this.requestRender();
}

/** Resume latest-X following after an interaction pause. */
/**
* Resume latest-X following after an interaction pause.
* @deprecated Use `resumeLatestXFollow()` for clearer latest-data semantics.
*/
resumeXFollow(): void {
this.resumeLatestXFollow();
}

/** Alias for `resumeXFollow()`. */
/** Resume latest-X following after an interaction pause. */
resumeLatestXFollow(): void {
this.clearXFollowResumeTimer();
this.xFollowPaused = false;
Expand Down
30 changes: 7 additions & 23 deletions src/ui/Crosshair.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { SeriesYAxis } from "../core/types.js";
import type { Chart, ChartPickItem, ChartPickMode, ChartPlugin, ChartPluginContext } from "./Chart.js";
import { createLongPressTouchTracker, createPickMarker, formatCompactNumber, placeAbsoluteWithinBox, renderPickItems, rgba } from "./OverlayUtils.js";
import { createLongPressTouchTracker, createOverlayLayer, createPickMarker, formatCompactNumber, pickAtDataX, placeAbsoluteWithinBox, renderPickItems, rgba } from "./OverlayUtils.js";

/** Axis drawn by the crosshair overlay. */
export type CrosshairAxis = "x" | "y" | "xy";
Expand Down Expand Up @@ -132,12 +132,12 @@ function resolvePosition(chart: ChartPluginContext, clientX: number, clientY: nu
function resolveSharedPosition(chart: ChartPluginContext, dataX: number, yAxis: SeriesYAxis): CrosshairPosition | null {
const rect = chart.canvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return null;

const viewport = chart.getViewport(yAxis);
const dataY = viewport.yMin + (viewport.yMax - viewport.yMin) * 0.5;
const [plotX, plotY] = chart.dataToPlot(dataX, dataY, yAxis);
const picked = positionFromPick(chart, rect.left + plotX, rect.top + plotY, "nearest-x");
if (picked) return picked;
const picked = pickAtDataX(chart, dataX, { yAxis, mode: "nearest-x", group: "none" });
const item = picked?.items[0];
if (item) return { dataX: item.x, dataY: item.y, plotX: item.plotX, plotY: item.plotY, items: [item] };
return { dataX, dataY, plotX, plotY, items: [] };
}

Expand All @@ -159,17 +159,6 @@ function createXRangeHighlight(item: ChartPickItem, chart: Chart, strokeColor: s
return marker;
}

function createCrosshairLayer(className: string, zIndex: number): HTMLDivElement {
const layer = document.createElement("div");
layer.className = className;
layer.style.position = "absolute";
layer.style.inset = "0";
layer.style.display = "none";
layer.style.pointerEvents = "none";
layer.style.zIndex = String(zIndex);
return layer;
}

function renderDefaultLabel(
position: CrosshairPosition,
container: HTMLElement,
Expand Down Expand Up @@ -374,8 +363,8 @@ export function crosshairPlugin(options: CrosshairPluginOptions = {}): Crosshair
root.style.display = "none";
root.style.pointerEvents = "none";

lineLayer = createCrosshairLayer("blazeplot-crosshair-lines", options.zIndex ?? 0);
overlayLayer = createCrosshairLayer("blazeplot-crosshair-overlay", options.zIndex ?? 22);
lineLayer = createOverlayLayer("blazeplot-crosshair-lines", { inset: "0", zIndex: options.zIndex ?? 0 });
overlayLayer = createOverlayLayer("blazeplot-crosshair-overlay", { inset: "0", zIndex: options.zIndex ?? 22 });

vertical = document.createElement("div");
vertical.style.position = "absolute";
Expand All @@ -393,12 +382,7 @@ export function crosshairPlugin(options: CrosshairPluginOptions = {}): Crosshair
horizontal.style.borderTop = `${width} solid ${color}`;
if (dash) horizontal.style.borderTopStyle = "dashed";

markerLayer = document.createElement("div");
markerLayer.className = "blazeplot-crosshair-markers";
markerLayer.style.position = "absolute";
markerLayer.style.inset = "0";
markerLayer.style.zIndex = "2";
markerLayer.style.pointerEvents = "none";
markerLayer = createOverlayLayer("blazeplot-crosshair-markers", { inset: "0", display: "block", zIndex: 2 });

label = document.createElement("div");
label.style.position = "absolute";
Expand Down
46 changes: 45 additions & 1 deletion src/ui/OverlayUtils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ChartPickItem } from "./Chart.js";
import type { SeriesYAxis } from "../core/types.js";
import type { ChartHoverState, ChartPickGroup, ChartPickItem, ChartPickMode, ChartPluginContext } from "./Chart.js";

/** Return the display label for a picked series item. */
export function labelOfPickItem(item: ChartPickItem): string {
Expand All @@ -23,6 +24,26 @@ export function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}

/** Shared absolute overlay layer options. */
export interface OverlayLayerOptions {
readonly zIndex?: number | string;
readonly display?: string;
readonly inset?: string;
readonly pointerEvents?: string;
}

/** Create a plot overlay layer with consistent positioning and pointer behavior. */
export function createOverlayLayer(className: string, options: OverlayLayerOptions = {}): HTMLDivElement {
const layer = document.createElement("div");
layer.className = className;
layer.style.position = "absolute";
if (options.inset !== undefined) layer.style.inset = options.inset;
layer.style.display = options.display ?? "none";
layer.style.pointerEvents = options.pointerEvents ?? "none";
if (options.zIndex !== undefined) layer.style.zIndex = String(options.zIndex);
return layer;
}

/** Position a fixed element near a client point while keeping it onscreen. */
export function placeFixedWithinViewport(
element: HTMLElement,
Expand Down Expand Up @@ -82,6 +103,29 @@ export interface PickMarkerOptions {
readonly strokeWidthPx?: number;
}

/** Options for picking chart items at a data-X value. */
export interface PickAtDataXOptions {
readonly yAxis?: SeriesYAxis;
readonly mode?: ChartPickMode;
readonly group?: ChartPickGroup;
readonly maxDistancePx?: number;
}

/** Pick chart items at a data-X value using midpoint Y for pointer-based pick APIs. */
export function pickAtDataX(chart: ChartPluginContext, dataX: number, options: PickAtDataXOptions = {}): ChartHoverState | null {
const yAxis = options.yAxis ?? "left";
const rect = chart.canvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return null;
const viewport = chart.getViewport(yAxis);
const dataY = viewport.yMin + (viewport.yMax - viewport.yMin) * 0.5;
const [plotX, plotY] = chart.dataToPlot(dataX, dataY, yAxis);
return chart.pick(rect.left + plotX, rect.top + plotY, {
mode: options.mode ?? "nearest-x",
group: options.group ?? "x",
maxDistancePx: options.maxDistancePx,
});
}

/** Create a marker element for a picked series point. */
export function createPickMarker(item: ChartPickItem, options: PickMarkerOptions = {}): HTMLDivElement {
const marker = document.createElement("div");
Expand Down
12 changes: 2 additions & 10 deletions src/ui/Selection.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { SeriesSample, SeriesYAxis, Viewport } from "../core/types.js";
import type { SeriesStore } from "../core/SeriesStore.js";
import type { ChartPlugin, ChartPluginContext, ChartSeriesState } from "./Chart.js";
import { clamp, createOverlayLayer } from "./OverlayUtils.js";

/** Geometry captured by the selection plugin. */
export type SelectionMode = "x-range" | "y-range" | "xy";
Expand Down Expand Up @@ -90,10 +91,6 @@ interface DragState {
const DEFAULT_FILL = "rgba(59, 130, 246, 0.16)";
const DEFAULT_STROKE = "rgba(147, 197, 253, 0.95)";

function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(value, max));
}

function normalizeBounds(a: [number, number], b: [number, number], current: Viewport, mode: SelectionMode): SelectionBounds {
const xMin = Math.min(a[0], b[0]);
const xMax = Math.max(a[0], b[0]);
Expand Down Expand Up @@ -233,12 +230,7 @@ export function selectionPlugin(options: SelectionPluginOptions = {}): Selection
install(chart: ChartPluginContext) {
chartRef = chart;
const canvas = chart.canvas;
overlay = document.createElement("div");
overlay.className = options.className ?? "blazeplot-selection-brush";
overlay.style.position = "absolute";
overlay.style.display = "none";
overlay.style.pointerEvents = "none";
overlay.style.zIndex = String(options.zIndex ?? 26);
overlay = createOverlayLayer(options.className ?? "blazeplot-selection-brush", { zIndex: options.zIndex ?? 26 });
overlay.style.border = `1px solid ${options.stroke ?? DEFAULT_STROKE}`;
overlay.style.background = options.fill ?? DEFAULT_FILL;
chart.plotElement.appendChild(overlay);
Expand Down
Loading