UNPKG

billboard.js

Version:

Re-usable easy interface JavaScript chart library, based on D3 v4+

615 lines (523 loc) 16 kB
/** * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license */ import {getTreemapNodeRect} from "../ChartInternal/shape/core/geometry"; import {TYPE} from "../config/const"; import {getCanvasBarGeometry, getCanvasCandlestickGeometry} from "./geometry"; import { createCanvasPointOccupancyGrid, DENSE_SCATTER_POINT_CULL_THRESHOLD, getCanvasShapeIndices, getCanvasTargetVisibleRange, hasCanvasDrawableValue, isCanvasBarType, isCanvasBubbleType, isCanvasCandlestickType, isCanvasPointType, isCanvasScatterType, isCanvasTargetSupported, isCanvasTreemapType, isFiniteCanvasCoordinate, markCanvasPointOccupancy } from "./util"; type HitItem = {x: number, y: number, w?: number, h?: number, sensitivity?: number, data: any}; type HitGrid = Map<string, HitItem[]>; type PlotArea = {x: number, y: number, w: number, h: number}; type HitRect = {x: number, y: number, w: number, h: number}; // Canvas-only spatial hit fallback. SVG point hit testing resolves from point_sensitivity; // this keeps the point hit grid usable before a per-point sensitivity is known. const HIT_DISTANCE = 24; // Canvas-only spatial index bucket for rectangular shapes. SVG relies on DOM geometry, // while canvas scans grid candidates; 64px balances bucket count and candidate length. const BAR_CELL_SIZE = 64; const HIT_GROUPED_TYPE_FILTERS = [ isCanvasPointType, isCanvasBarType, isCanvasCandlestickType ]; /** * Get spatial grid cell coordinate. * @param {number} value Pixel coordinate * @param {number} size Cell size * @returns {number} Cell coordinate * @private */ function getCell(value: number, size: number): number { return Math.floor(value / size); } /** * Add hit item to spatial grid cells it covers. * @param {Map} grid Spatial grid * @param {object} item Hit item * @param {number} size Cell size * @private */ function addGridItem(grid: HitGrid, item: HitItem, size: number): void { const minX = getCell(item.x, size); const maxX = getCell(item.x + (item.w ?? 0), size); const minY = getCell(item.y, size); const maxY = getCell(item.y + (item.h ?? 0), size); for (let x = minX; x <= maxX; x++) { for (let y = minY; y <= maxY; y++) { const key = `${x}:${y}`; const items = grid.get(key); items ? items.push(item) : grid.set(key, [item]); } } } /** * Get hit items around a coordinate from spatial grid. * @param {Map} grid Spatial grid * @param {number} x X coordinate * @param {number} y Y coordinate * @param {number} size Cell size * @param {number} radius Neighbor cell radius * @returns {Array} Candidate hit items * @private */ function getGridItems( grid: HitGrid, x: number, y: number, size: number, radius = 0 ): HitItem[] { const cellX = getCell(x, size); const cellY = getCell(y, size); const items: HitItem[] = []; for (let dx = -radius; dx <= radius; dx++) { for (let dy = -radius; dy <= radius; dy++) { const values = grid.get(`${cellX + dx}:${cellY + dy}`); if (values?.length) { for (let i = 0; i < values.length; i++) { items.push(values[i]); } } } } return items; } /** * Get point hit sensitivity, avoiding radius work for fixed numeric sensitivity. * @param {object} $$ ChartInternal instance * @param {object} d Data row * @returns {number} Hit sensitivity * @private */ function getPointHitSensitivity($$, d): number { const sensitivity = $$.config.point_sensitivity; if (Number.isFinite(sensitivity)) { return sensitivity; } $$.pointR?.(d); const resolved = $$.getPointSensitivity?.(d) ?? HIT_DISTANCE; return Number.isFinite(resolved) ? resolved : HIT_DISTANCE; } /** * Check whether point hit grid can use the same visible-center culling as dense scatter draw. * @param {object} $$ ChartInternal instance * @param {object} target Data target * @returns {boolean} Whether hit points can be culled * @private */ function shouldCullDenseScatterHitPoints($$, target): boolean { return isCanvasScatterType($$, target) && target.values.length > DENSE_SCATTER_POINT_CULL_THRESHOLD && !$$.config.data_selection_enabled && Number.isFinite($$.config.point_sensitivity) && Number.isFinite($$.config.point_r); } /** * Build and query canvas hit-test indexes. * @private */ export default class HitDetector { private bars: HitItem[] = []; private points: HitItem[] = []; private indices: HitItem[] = []; private barGrid: HitGrid = new Map(); private pointGrid: HitGrid = new Map(); private plot: PlotArea = {x: 0, y: 0, w: 0, h: 0}; private pointCellSize = HIT_DISTANCE; private maxPointSensitivity = HIT_DISTANCE; private grouped = false; private pointBased = false; private indexAxis: "x" | "y" = "x"; /** * Rebuild hit-test indexes from current chart geometry. * @param {object} $$ ChartInternal instance * @param {object} shape Cached draw shape object * @private */ rebuild($$, shape): void { const {current, margin, width, height} = $$.state; const targets = $$.filterTargetsToShow(); this.bars = []; this.points = []; this.indices = []; this.barGrid = new Map(); this.pointGrid = new Map(); this.maxPointSensitivity = HIT_DISTANCE; this.indexAxis = $$.config.axis_rotated ? "y" : "x"; this.plot = $$.state.hasTreemap ? {x: 0, y: 0, w: current.width, h: current.height} : {x: margin.left, y: margin.top, w: width, h: height}; this.grouped = !!$$.config.tooltip_grouped && !$$.state.hasTreemap; this.pointBased = !($$.config.axis_x_forceAsSingle && this.grouped) && ( !!$$.isMultipleX?.() || targets.some(target => isCanvasScatterType($$, target) || isCanvasBubbleType($$, target) ) ); const needsIndex = this.grouped && (!this.pointBased || ($$.config.data_selection_enabled && $$.config.data_selection_grouped)); const indexMap = needsIndex ? new Map<number | string, HitItem>() : null; const addIndex = (x: number, y: number, data): void => { if (!indexMap) { return; } const key = $$.getXCacheKey?.(data.x) ?? data.index; if ( Number.isFinite(data.index) && !indexMap.has(key) && isFiniteCanvasCoordinate(x, y) ) { indexMap.set(key, {x, y, data}); } }; if ($$.state.hasTreemap) { const root = $$.getTreemapRoot?.($$.data.targets); const nodes = root?.children || []; for (const node of nodes) { const {data} = node; if (!isCanvasTreemapType($$, data)) { continue; } const {x, y, w, h} = getTreemapNodeRect($$, node, root, true); if ( !isFiniteCanvasCoordinate(x, y) || !isFiniteCanvasCoordinate(x + w, y + h) ) { continue; } this.addBar({x, y, w, h, data}); } this.indices = []; return; } if (shape.indices[TYPE.BAR] || targets.some(isCanvasBarType.bind(null, $$))) { const isBar = isCanvasBarType.bind(null, $$); const indices = getCanvasShapeIndices($$, shape, TYPE.BAR, isBar); const getPoints = $$.generateGetBarPoints(indices, false); targets .filter(isBar) .filter(target => isCanvasTargetSupported($$, target, HIT_GROUPED_TYPE_FILTERS)) .forEach(target => { const range = getCanvasTargetVisibleRange($$, target); for (let i = range.start; i < range.end; i++) { const d = target.values[i]; if (!hasCanvasDrawableValue($$, d)) { continue; } const geometry = getCanvasBarGeometry($$, getPoints, d, i); if (!geometry) { continue; } const {rect} = geometry; const x = margin.left + rect.x; const y = margin.top + rect.y; const {w, h} = rect; this.addBar({x, y, w, h, data: d}); addIndex(x + w / 2, y + h / 2, d); } }); } if ( shape.indices[TYPE.CANDLESTICK] || targets.some(isCanvasCandlestickType.bind(null, $$)) ) { const isCandlestick = isCanvasCandlestickType.bind(null, $$); const indices = getCanvasShapeIndices($$, shape, TYPE.CANDLESTICK, isCandlestick); const getPoints = $$.generateGetCandlestickPoints?.(indices, false); if (getPoints) { targets .filter(isCandlestick) .filter(target => isCanvasTargetSupported($$, target, HIT_GROUPED_TYPE_FILTERS)) .forEach(target => { const range = getCanvasTargetVisibleRange($$, target); for (let i = range.start; i < range.end; i++) { const d = target.values[i]; const value = $$.getCandlestickData?.(d); if (!value) { continue; } const geometry = getCanvasCandlestickGeometry($$, getPoints, d, i); if (!geometry) { continue; } const {body: rect} = geometry; const x = margin.left + rect.x; const y = margin.top + rect.y; const {w, h} = rect; this.addBar({ x, y, w: Math.max(1, w), h: Math.max(1, h), data: d }); addIndex(x + w / 2, y + h / 2, d); } }); } } const {cx, cy} = shape.pos; if (cx && cy) { targets .filter(isCanvasPointType.bind(null, $$)) .filter(target => isCanvasTargetSupported($$, target, HIT_GROUPED_TYPE_FILTERS)) .forEach(target => { const range = getCanvasTargetVisibleRange($$, target); const occupancy = shouldCullDenseScatterHitPoints($$, target) ? createCanvasPointOccupancyGrid(width, height, $$.config.point_r) : null; for (let i = range.start; i < range.end; i++) { const d = target.values[i]; if (!hasCanvasDrawableValue($$, d)) { continue; } const x = margin.left + cx(d, i); const y = margin.top + cy(d, i); const sensitivity = getPointHitSensitivity($$, d); if (!isFiniteCanvasCoordinate(x, y)) { continue; } if ( occupancy && !markCanvasPointOccupancy( occupancy, x - margin.left, y - margin.top ) ) { continue; } this.addPoint({ x, y, sensitivity, data: d }); addIndex(x, y, d); } }); } this.buildPointGrid(); this.indices = indexMap ? Array.from(indexMap.values()) .sort((a, b) => a[this.indexAxis] - b[this.indexAxis]) : []; } /** * Hit-test bars at the given canvas coordinates. * @param {number} mx Mouse x coordinate * @param {number} my Mouse y coordinate * @returns {object|null} Matching data row * @private */ private hitBar(mx: number, my: number): any | null { for (const item of getGridItems(this.barGrid, mx, my, BAR_CELL_SIZE)) { const {w = 0, h = 0} = item; if ( mx >= item.x && mx <= item.x + w && my >= item.y && my <= item.y + h ) { return item.data; } } return null; } /** * Find the nearest point within sensitivity at the given canvas coordinates. * @param {number} mx Mouse x coordinate * @param {number} my Mouse y coordinate * @returns {object|null} Matching data row * @private */ private hitPoint(mx: number, my: number): any | null { let nearest: HitItem | null = null; let min = Number.POSITIVE_INFINITY; for (const item of getGridItems(this.pointGrid, mx, my, this.pointCellSize, 1)) { const dx = item.x - mx; const dy = item.y - my; const dist = Math.sqrt(dx * dx + dy * dy); const sensitivity = item.sensitivity ?? HIT_DISTANCE; if (dist <= sensitivity && dist < min) { min = dist; nearest = item; } } return nearest?.data ?? null; } /** * Find the nearest data row for the given canvas coordinates. * @param {number} mx Mouse x coordinate * @param {number} my Mouse y coordinate * @returns {object|null} Matching data row * @private */ findNearest(mx: number, my: number): any | null { const bar = this.hitBar(mx, my); if (bar) { return bar; } if (!this.pointBased && this.grouped && this.isWithinPlot(mx, my)) { const item = this.findNearestIndexItem(mx, my); if (item) { return item.data; } } return this.hitPoint(mx, my); } /** * Find the nearest directly hit shape row, excluding grouped index fallback. * @param {number} mx Mouse x coordinate * @param {number} my Mouse y coordinate * @returns {object|null} Matching data row * @private */ findNearestShape(mx: number, my: number): any | null { return this.hitBar(mx, my) ?? this.hitPoint(mx, my); } /** * Find the nearest grouped x-index row for an axis-adjacent pointer coordinate. * @param {number} mx Mouse x coordinate * @param {number} my Mouse y coordinate * @returns {object|null} Matching data row * @private */ findNearestIndexByCoord(mx: number, my: number): any | null { return this.findNearestIndexItem(mx, my)?.data ?? null; } /** * Find data rows included by a rectangular selection area. * @param {object} rect Selection rectangle in canvas coordinates * @param {boolean} grouped Whether to match by the index axis only * @returns {Array} Matching data rows * @private */ findInRect(rect: HitRect, grouped = false): any[] { const x1 = Math.min(rect.x, rect.x + rect.w); const x2 = Math.max(rect.x, rect.x + rect.w); const y1 = Math.min(rect.y, rect.y + rect.h); const y2 = Math.max(rect.y, rect.y + rect.h); const seen = new Set<string>(); const data: any[] = []; const add = (item: HitItem): void => { const d = item.data; const key = `${d.id}:${d.index}`; if (!seen.has(key)) { seen.add(key); data.push(d); } }; if (grouped) { const axis = this.indexAxis; const min = axis === "y" ? y1 : x1; const max = axis === "y" ? y2 : x2; this.indices .filter(item => item[axis] >= min && item[axis] <= max) .forEach(add); return data; } this.bars .filter(item => { const w = item.w ?? 0; const h = item.h ?? 0; return !(x2 < item.x || item.x + w < x1) && !(y2 < item.y || item.y + h < y1); }) .forEach(add); this.points .filter(item => item.x >= x1 && item.x <= x2 && item.y >= y1 && item.y <= y2) .forEach(add); return data; } /** * Add bar-like hit item and register its grid coverage. * @param {object} item Hit item * @private */ private addBar(item: HitItem): void { this.bars.push(item); addGridItem(this.barGrid, item, BAR_CELL_SIZE); } /** * Add point-like hit item. * @param {object} item Hit item * @private */ private addPoint(item: HitItem): void { const sensitivity = item.sensitivity ?? HIT_DISTANCE; this.maxPointSensitivity = Math.max(this.maxPointSensitivity, sensitivity); this.points.push(item); } /** * Build point spatial grid after the maximum sensitivity is known. * @private */ private buildPointGrid(): void { this.pointCellSize = Math.max(1, this.maxPointSensitivity); this.pointGrid = new Map(); for (const point of this.points) { addGridItem(this.pointGrid, point, this.pointCellSize); } } /** * Check if coordinates are inside the plot area. * @param {number} x X coordinate * @param {number} y Y coordinate * @returns {boolean} Whether the coordinates are inside the plot * @private */ private isWithinPlot(x: number, y: number): boolean { const {plot} = this; return x >= plot.x && x <= plot.x + plot.w && y >= plot.y && y <= plot.y + plot.h; } /** * Find the nearest indexed data row by current index-axis coordinate. * @param {number} x X coordinate * @param {number} y Y coordinate * @returns {object|null} Matching hit item * @private */ private findNearestIndexItem(x: number, y: number): HitItem | null { const {indexAxis, indices} = this; const value = indexAxis === "y" ? y : x; if (!indices.length) { return null; } let start = 0; let end = indices.length - 1; while (start < end) { const mid = (start + end) >> 1; if (indices[mid][indexAxis] < value) { start = mid + 1; } else { end = mid; } } const current = indices[start]; const previous = indices[start - 1]; return previous && Math.abs(previous[indexAxis] - value) < Math.abs(current[indexAxis] - value) ? previous : current; } }