diff --git a/README.md b/README.md index cc110ed..18a414e 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 / 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`. @@ -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 diff --git a/docs/api-reference.md b/docs/api-reference.md index e288705..20b4fe0 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -67,16 +67,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 diff --git a/src/core/SeriesStore.ts b/src/core/SeriesStore.ts index 9a09e86..c80365d 100644 --- a/src/core/SeriesStore.ts +++ b/src/core/SeriesStore.ts @@ -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; } @@ -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; diff --git a/src/core/types.ts b/src/core/types.ts index 22041b8..f9384b3 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -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; +} + /** 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; diff --git a/src/plugins/crosshair.ts b/src/plugins/crosshair.ts index e1a1235..ad17768 100644 --- a/src/plugins/crosshair.ts +++ b/src/plugins/crosshair.ts @@ -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"; diff --git a/src/ui/AxisOverlay.ts b/src/ui/AxisOverlay.ts index ff189fc..f0599a1 100644 --- a/src/ui/AxisOverlay.ts +++ b/src/ui/AxisOverlay.ts @@ -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[] = []; @@ -117,21 +119,28 @@ 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"; @@ -139,22 +148,62 @@ export class AxisOverlay { 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 }); + } } } } diff --git a/src/ui/Chart.ts b/src/ui/Chart.ts index 8917123..ce093fa 100644 --- a/src/ui/Chart.ts +++ b/src/ui/Chart.ts @@ -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"; @@ -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; @@ -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, diff --git a/src/ui/Crosshair.ts b/src/ui/Crosshair.ts index 5f92755..4b261fe 100644 --- a/src/ui/Crosshair.ts +++ b/src/ui/Crosshair.ts @@ -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 } 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"; @@ -8,6 +8,11 @@ export type CrosshairAxis = "x" | "y" | "xy"; 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 { @@ -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; @@ -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, @@ -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, + })); + } } }; diff --git a/src/ui/Tooltip.ts b/src/ui/Tooltip.ts index bcaece0..95d83e5 100644 --- a/src/ui/Tooltip.ts +++ b/src/ui/Tooltip.ts @@ -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): string { + return `${formatCompactNumber(range.xStart)}–${formatCompactNumber(range.xEnd)}`; +} + interface TooltipPeer { showShared(dataX: number): void; hideShared(): void;