UNPKG

billboard.js

Version:

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

1,767 lines (1,529 loc) 86.3 kB
/** * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license */ import {select as d3Select} from "d3-selection"; import CanvasAxisRenderer from "../../canvas/CanvasAxisRenderer"; import CanvasEngine from "../../canvas/CanvasEngine"; import CanvasRenderer from "../../canvas/CanvasRenderer"; import CanvasTheme from "../../canvas/CanvasTheme"; import {$CANVAS} from "../../canvas/classes"; import HitDetector from "../../canvas/HitDetector"; import { hasCanvasDrawableValue, isCanvasBarType, isCanvasBubbleType, isCanvasCandlestickType, isCanvasPointType, isCanvasScatterType, isCanvasTargetSupported, isCanvasTreemapType } from "../../canvas/util"; import ChartInternal from "../../ChartInternal/ChartInternal"; import {getRenderDataPoint} from "../../ChartInternal/shape/core/geometry"; import {$FOCUS, $LEGEND} from "../../config/classes"; import {window} from "../../module/browser"; import {KEY} from "../../module/Cache"; import { callFn, extend, getBoundingRect, isBoolean, isFunction, parseDate, sanitize, tplProcess } from "../../module/util"; const CANVAS_SELECTABLE_TYPE_FILTERS = [ isCanvasPointType, isCanvasBarType, isCanvasCandlestickType, isCanvasTreemapType ]; const CANVAS_LEGEND_TOUCH_TAP_THRESHOLD = 10; const CANVAS_LEGEND_TOUCH_CLICK_TIMEOUT = 750; const CANVAS_SUBCHART_HANDLE_SIZE = 6; const CANVAS_SUBCHART_CLICK_TOLERANCE = 2; const CANVAS_FLOW_ANIMATION_MAX_VALUES = 100000; const CANVAS_HTML_ATTR_ESCAPE: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }; const canvasSelectionDragRows = new WeakMap<object, Map<string, any>>(); /** * Emit canvas v1 unsupported option warning. * @param {string} message Warning message * @private */ function warn(message: string): void { window.console?.warn?.(`[billboard.js] ${message}`); } /** * Get formatted legend text. * @param {object} $$ ChartInternal instance * @param {string} id Data id * @returns {string} Legend text * @private */ function getLegendText($$, id: string): string { const {config} = $$; const text = config.data_names[id] ?? id; return isFunction(config.legend_format) ? config.legend_format(text, id !== text ? id : undefined) : text; } /** * Escape a value for safe HTML attribute interpolation. * @param {string} value Attribute value * @returns {string} Escaped value * @private */ function escapeHtmlAttr(value: string): string { return `${value}`.replace(/[&<>"']/g, char => CANVAS_HTML_ATTR_ESCAPE[char]); } /** * Get the point pattern assigned to a legend item. * @param {object} $$ ChartInternal instance * @param {string} id Data id * @returns {string} Point pattern * @private */ function getCanvasLegendPointPattern($$, id: string): string { const {config} = $$; const targetIds = $$.mapToIds?.($$.data.targets) || []; const pattern = $$.getValidPointPattern?.() || ( Array.isArray(config.point_pattern) && config.point_pattern.length ? config.point_pattern : [config.point_type] ); const index = Math.max(0, targetIds.indexOf(id)); return pattern[index % pattern.length] || "circle"; } /** * Build inline SVG markup for a canvas HTML legend point tile. * @param {object} $$ ChartInternal instance * @param {string} id Data id * @returns {string} SVG markup * @private */ function getCanvasLegendPointIcon($$, id: string): string { const color = escapeHtmlAttr($$.color(id)); const pattern = getCanvasLegendPointPattern($$, id); const svgAttrs = "viewBox=\"0 0 8 8\" aria-hidden=\"true\" focusable=\"false\" " + "style=\"display:block;width:100%;height:100%;overflow:visible;pointer-events:none\""; const paintAttrs = `fill="${color}" stroke="${color}"`; if (/^rect(angle)?$/i.test(pattern)) { return `<svg ${svgAttrs}><rect x="1" y="1" width="6" height="6" ${paintAttrs}></rect></svg>`; } if (!pattern || /^circle$/i.test(pattern)) { return `<svg ${svgAttrs}><circle cx="4" cy="4" r="3" ${paintAttrs}></circle></svg>`; } return `<svg ${svgAttrs}><g ${paintAttrs}>${sanitize(pattern)}</g></svg>`; } /** * Update legend item lookup cache for canvas HTML legends. * @param {object} $$ ChartInternal instance * @param {object} item Legend item selection * @private */ function updateCanvasLegendItemMap($$, item): void { const itemMap = new Map<string, HTMLElement>(); item.each(function(id: string) { itemMap.set(id, this); }); $$.cache.add(KEY.legendItemMap, itemMap); } /** * Apply shared canvas HTML legend classes and state. * @param {object} $$ ChartInternal instance * @param {object} item Legend item selection * @private */ function setCanvasHtmlLegendItem($$, item): void { const {config} = $$; item .attr("class", function(id: string) { const current = d3Select(this).attr("class") || ""; const next = `${current} ${$$.generateClass($LEGEND.legendItem, id)}`; return Array.from(new Set(next.trim().split(/\s+/).filter(Boolean))).join(" "); }) .style("visibility", id => ($$.isLegendToShow(id) ? null : "hidden")) .classed($LEGEND.legendItemHidden, id => !$$.isTargetToShow(id)) .classed($CANVAS.legendItemInteractive, !!config.interaction_enabled); updateCanvasLegendItemMap($$, item); } /** * Apply SVG-compatible legend focus styling to canvas HTML legend items. * @param {object} $$ ChartInternal instance * @param {string} id Focused data id * @private */ function setCanvasHtmlLegendFocus($$, id: string): void { const {legend} = $$.$el; const targetIds = $$.mapToTargetIds?.([id]) || [id]; legend?.selectAll(`.${$LEGEND.legendItem}`) .classed($FOCUS.legendItemFocused, (d: string) => targetIds.indexOf(d) >= 0) .style("opacity", function(d: string) { return targetIds.indexOf(d) >= 0 ? null : $$.opacityForUnfocusedLegend.call($$, d3Select(this)); }); } /** * Revert canvas HTML legend focus styling. * @param {object} $$ ChartInternal instance * @private */ function revertCanvasHtmlLegendFocus($$): void { const {legend} = $$.$el; legend?.selectAll(`.${$LEGEND.legendItem}`) .classed($FOCUS.legendItemFocused, false) .style("opacity", null); } /** * Apply SVG-compatible target focus state for canvas redraw. * @param {object} $$ ChartInternal instance * @param {string} id Focused data id * @private */ function setCanvasLegendTargetFocus($$, id: string): void { const {state} = $$; const targetIds = $$.mapToTargetIds?.([id]) || [id]; const focusedIds = targetIds.filter($$.isTargetToShow, $$); const focusedSet = new Set(focusedIds); const defocusedIds = ($$.mapToTargetIds?.() || []) .filter(targetId => !focusedSet.has(targetId) && $$.isTargetToShow(targetId)); state.focusedTargetIds = focusedSet; state.defocusedTargetIds = new Set(defocusedIds); $$.renderCanvasFrame?.(undefined, null, false); } /** * Revert canvas target focus state. * @param {object} $$ ChartInternal instance * @private */ function revertCanvasLegendTargetFocus($$): void { const {state} = $$; const changed = !!state.focusedTargetIds?.size || !!state.defocusedTargetIds?.size; state.focusedTargetIds = new Set(); state.defocusedTargetIds = new Set(); changed && $$.renderCanvasFrame?.(undefined, null, false); } /** * Get touch point from a canvas legend touch event. * @param {TouchEvent} event Touch event * @returns {Touch | undefined} Touch point * @private */ function getCanvasLegendTouchPoint(event): Touch | undefined { return event.changedTouches?.[0] || event.touches?.[0]; } /** * Store the touch start position for canvas legend tap detection. * @param {object} $$ ChartInternal instance * @param {string} id Legend data id * @param {TouchEvent} event Touch event * @private */ function setCanvasLegendTouchStart($$, id: string, event): void { const touch = getCanvasLegendTouchPoint(event); $$.state.canvasLegendTouch = touch ? { id, x: touch.clientX, y: touch.clientY, moved: false } : null; } /** * Update whether the current canvas legend touch moved beyond tap tolerance. * @param {object} $$ ChartInternal instance * @param {TouchEvent} event Touch event * @private */ function updateCanvasLegendTouchMove($$, event): void { const start = $$.state.canvasLegendTouch; const touch = start && getCanvasLegendTouchPoint(event); if (touch) { start.moved = start.moved || Math.abs(touch.clientX - start.x) > CANVAS_LEGEND_TOUCH_TAP_THRESHOLD || Math.abs(touch.clientY - start.y) > CANVAS_LEGEND_TOUCH_TAP_THRESHOLD; } } /** * Determine whether a touch sequence is a canvas legend tap. * @param {object} $$ ChartInternal instance * @param {string} id Legend data id * @param {TouchEvent} event Touch event * @returns {boolean} Whether the touch sequence is a tap * @private */ function isCanvasLegendTouchTap($$, id: string, event): boolean { updateCanvasLegendTouchMove($$, event); const start = $$.state.canvasLegendTouch; $$.state.canvasLegendTouch = null; return !!start && start.id === id && !start.moved; } /** * Mark a canvas legend touch tap so the following compatibility click can be skipped. * @param {object} $$ ChartInternal instance * @param {string} id Legend data id * @private */ function markCanvasLegendTouchClick($$, id: string): void { $$.state.canvasLegendLastTouchClickId = id; $$.state.canvasLegendLastTouchClickTime = Date.now(); } /** * Check if a canvas legend click duplicates a recent touch tap. * @param {object} $$ ChartInternal instance * @param {string} id Legend data id * @returns {boolean} Whether the click is duplicate * @private */ function isDuplicateCanvasLegendTouchClick($$, id: string): boolean { const {state} = $$; const duplicate = state.canvasLegendLastTouchClickId === id && Date.now() - (state.canvasLegendLastTouchClickTime || 0) < CANVAS_LEGEND_TOUCH_CLICK_TIMEOUT; if (duplicate) { state.canvasLegendLastTouchClickId = null; state.canvasLegendLastTouchClickTime = 0; } return duplicate; } /** * Bind canvas HTML legend interactions without invoking SVG focus paths. * @param {object} $$ ChartInternal instance * @param {object} item Legend item selection * @private */ function bindCanvasHtmlLegendInteractions($$, item): void { const {api, config, state} = $$; item.on("click dblclick mouseover mouseout touchstart touchmove touchend", null); if (!config.interaction_enabled) { return; } const interaction = config.legend_item_interaction; const eventType = typeof interaction === "object" && interaction?.dblclick ? "dblclick" : "click"; const isTouch = state.inputType === "touch"; const hasClickInteraction = interaction || isFunction(config.legend_item_onclick); const touchOption = isTouch ? getCanvasTouchListenerOption(config) : undefined; const handleCanvasLegendToggle = function(event, id): void { if ( !callFn(config.legend_item_onclick, api, id, !state.hiddenTargetIds.has(id)) ) { const selected = d3Select(this); if (event.type === "dblclick" || event.altKey) { if ( state.hiddenTargetIds.size && !selected.classed($LEGEND.legendItemHidden) ) { api.show(); } else { api.hide(); api.show(id); } } else { api.toggle(id); selected.classed($FOCUS.legendItemFocused, false); } revertCanvasLegendTargetFocus($$); } isTouch && $$.hideTooltip?.(); }; item.on(eventType, hasClickInteraction ? function(event, id) { if ( isTouch && event.type === "click" && isDuplicateCanvasLegendTouchClick($$, id) ) { return; } handleCanvasLegendToggle.call(this, event, id); } : null); isTouch && eventType === "click" && hasClickInteraction && item .on("touchstart", function(event, id) { setCanvasLegendTouchStart($$, id, event); }, touchOption) .on("touchmove", event => { updateCanvasLegendTouchMove($$, event); }, touchOption) .on("touchend", function(event, id) { if (isCanvasLegendTouchTap($$, id, event)) { markCanvasLegendTouchClick($$, id); handleCanvasLegendToggle.call(this, event, id); } }, touchOption); !isTouch && item .on("mouseover", interaction || isFunction(config.legend_item_onover) ? function(event, id) { if ( !callFn(config.legend_item_onover, api, id, !state.hiddenTargetIds.has(id)) ) { setCanvasHtmlLegendFocus($$, id); !state.transiting && $$.isTargetToShow(id) && setCanvasLegendTargetFocus($$, id); } } : null) .on("mouseout", interaction || isFunction(config.legend_item_onout) ? function(event, id) { if ( !callFn(config.legend_item_onout, api, id, !state.hiddenTargetIds.has(id)) ) { revertCanvasHtmlLegendFocus($$); revertCanvasLegendTargetFocus($$); } } : null); } /** * Measure a legend text with the same SVG structure used by the default renderer. * @param {object} $$ ChartInternal instance * @param {string} text Legend text * @returns {object|null} Text bounding rect * @private */ function measureSvgLegendText($$, text: string): DOMRect | null { const chart = $$.$el.chart?.node?.(); const doc = chart?.ownerDocument; if (!chart || !doc) { return null; } const svg = doc.createElementNS("http://www.w3.org/2000/svg", "svg"); const group = doc.createElementNS("http://www.w3.org/2000/svg", "g"); const textElement = doc.createElementNS("http://www.w3.org/2000/svg", "text"); svg.style.cssText = "position:absolute;visibility:hidden;left:-10000px;top:-10000px;"; group.setAttribute("class", $LEGEND.legendItem); textElement.textContent = text; group.appendChild(textElement); svg.appendChild(group); chart.appendChild(svg); const rect = getBoundingRect(textElement); svg.remove(); return rect; } /** * Check if canvas can render the configured x axis type. * @param {string} type X axis type * @returns {boolean} Whether type is supported * @private */ function isSupportedCanvasXType(type: string): boolean { return /^(indexed|category|log|timeseries)$/.test(type); } /** * Check if canvas can render the configured y/y2 axis type. * @param {string} type Y axis type * @returns {boolean} Whether type is supported * @private */ function isSupportedCanvasYType(type: string): boolean { return /^(indexed|log|timeseries)$/.test(type); } /** * Get client position from mouse, pointer or touch event. * @param {Event} event Input event * @returns {object|null} Client position * @private */ function getCanvasEventClientPoint(event: MouseEvent | PointerEvent | TouchEvent) { const touchEvent = event as TouchEvent; const touch = touchEvent.changedTouches?.[0] || touchEvent.touches?.[0]; return touch || ("clientX" in event ? event : null); } /** * Get data row under a canvas pointer event. * @param {object} $$ ChartInternal instance * @param {Event} event Input event * @param {boolean} shapeOnly Whether to skip grouped x-index fallback * @returns {object|null} Hit data row * @private */ function getCanvasEventDatum($$, event: MouseEvent | PointerEvent | TouchEvent, shapeOnly = false) { const point = getCanvasEventPoint($$, event); return point ? (shapeOnly ? $$.hitDetector.findNearestShape?.(point[0], point[1]) : $$.hitDetector.findNearest(point[0], point[1])) : null; } /** * Check whether a canvas pointer coordinate can select the grouped x index. * @param {object} $$ ChartInternal instance * @param {Array} point Canvas-local coordinates * @returns {boolean} Whether x-index hover is allowed * @private */ function isCanvasXIndexHoverArea($$, point: number[]): boolean { const {config, state} = $$; const {height, margin, width, xAxisHeight} = state; const [x, y] = point; const left = margin.left; const right = margin.left + width; const top = margin.top; const bottom = margin.top + height; const axisBand = config.axis_x_show ? Math.max(xAxisHeight || 0, 24) : 0; if ($$.isMultipleX?.() || state.hasTreemap) { return false; } return config.axis_rotated ? y >= top && y <= bottom && x >= left - axisBand && x <= right : x >= left && x <= right && y >= top && y <= bottom + axisBand; } /** * Check whether a canvas pointer coordinate is inside the main plot area. * @param {object} $$ ChartInternal instance * @param {Array} point Canvas-local coordinates * @returns {boolean} Whether axis tooltip can be shown * @private */ function isCanvasAxisTooltipArea($$, point: number[]): boolean { const {state: {height, margin, width}} = $$; const [x, y] = point; return x >= margin.left && x <= margin.left + width && y >= margin.top && y <= margin.top + height; } /** * Get data row for hover from a canvas-local coordinate. * @param {object} $$ ChartInternal instance * @param {Array} point Canvas-local coordinates * @returns {object|null} Hover data row * @private */ function getCanvasHoverDatumFromPoint($$, point: number[]) { return $$.hitDetector.findNearest(point[0], point[1]) || (isCanvasXIndexHoverArea($$, point) ? $$.hitDetector.findNearestIndexByCoord?.(point[0], point[1]) : null); } /** * Get visible canvas data rows for a tooltip target. * @param {object} $$ ChartInternal instance * @param {object} d Base data row * @returns {Array} Tooltip data rows * @private */ function getCanvasTooltipData($$, d): any[] { const {config, state} = $$; const targetsToShow = $$.filterTargetsToShow($$.data.targets); const pointBased = isCanvasPointBasedInteraction($$, d); const sameXData = config.tooltip_grouped && !state.hasTreemap && !pointBased ? $$.filterByX?.(targetsToShow, d.x) : null; return (state.hasTreemap ? [d] : (pointBased ? [d] : (config.tooltip_grouped ? (sameXData?.length ? sameXData : targetsToShow.map(target => target.values[d.index]).filter(Boolean)) : [d]))) .map(v => $$.addName?.(v) || v); } /** * Get data rows for canvas focus rendering. * @param {object} $$ ChartInternal instance * @param {object} d Hover data row * @param {Array} selectedData Tooltip data rows * @returns {Array} Focus data rows * @private */ function getCanvasFocusData($$, d, selectedData): any[] { return $$.isMultipleX?.() && !isCanvasPointBasedInteraction($$, d) ? [$$.addName?.(d) || d] : selectedData; } /** * Get canvas-local coordinates from mouse, pointer or touch event. * @param {object} $$ ChartInternal instance * @param {Event} event Input event * @returns {Array|null} Canvas-local coordinates * @private */ function getCanvasEventPoint($$, event: MouseEvent | PointerEvent | TouchEvent): number[] | null { const point = getCanvasEventClientPoint(event); if (!point) { return null; } const canvas = $$.$el.canvas.node(); const rect = getBoundingRect(canvas, true); return [point.clientX - rect.left, point.clientY - rect.top]; } /** * Normalize a subchart domain range for the current x axis type. * @param {object} $$ ChartInternal instance * @param {Array} domain Domain range * @returns {Array} Normalized domain range * @private */ function normalizeCanvasSubchartDomain($$, domain): any[] | null { if (!Array.isArray(domain) || domain.length < 2) { return null; } const values = domain.slice(0, 2); return $$.axis?.isTimeSeries?.() ? values.map(value => parseDate.call($$, value)) : values; } /** * Get the canvas-space rectangle occupied by the subchart plot. * @param {object} $$ ChartInternal instance * @returns {object|null} Subchart rectangle * @private */ function getCanvasSubchartRect($$): {x: number, y: number, w: number, h: number} | null { const {config, state} = $$; if (!config.subchart_show || !state.hasAxis || state.width2 <= 0 || state.height2 <= 0) { return null; } return { x: state.margin2.left, y: state.margin2.top, w: state.width2, h: state.height2 }; } /** * Check whether a canvas point is inside the subchart plot area. * @param {object} $$ ChartInternal instance * @param {Array} point Canvas-local point * @returns {boolean} Whether point is in the subchart * @private */ function isCanvasSubchartPoint($$, point: number[]): boolean { const rect = getCanvasSubchartRect($$); return !!rect && point[0] >= rect.x && point[0] <= rect.x + rect.w && point[1] >= rect.y && point[1] <= rect.y + rect.h; } /** * Get the allowed local brush coordinate extent for canvas subchart interactions. * @param {object} $$ ChartInternal instance * @returns {Array} Brush extent in subchart-local pixels * @private */ function getCanvasSubchartBrushExtent($$): [number, number] { const rect = getCanvasSubchartRect($$); const axisLength = rect ? ($$.config.axis_rotated ? rect.h : rect.w) : 0; const extent = $$.axis?.getExtent?.(); const values = Array.isArray(extent) ? extent.slice(0, 2) : []; if (values.length === 2 && values.every(Number.isFinite)) { const start = Math.max(0, Math.min(axisLength, values[0])); const end = Math.max(0, Math.min(axisLength, values[1])); return [Math.min(start, end), Math.max(start, end)]; } return [0, axisLength]; } /** * Get the local brush coordinate from a canvas subchart pointer event. * @param {object} $$ ChartInternal instance * @param {Event} event Input event * @param {boolean} clampToExtent Whether to clamp pointer outside brush extent * @returns {number|null} Brush coordinate * @private */ function getCanvasSubchartBrushCoord( $$, event: MouseEvent | PointerEvent | TouchEvent, clampToExtent = false ): | number | null { const point = getCanvasEventPoint($$, event); const rect = point && getCanvasSubchartRect($$); if (!point || !rect || !isCanvasSubchartPoint($$, point)) { return null; } const raw = $$.config.axis_rotated ? point[1] - rect.y : point[0] - rect.x; const [min, max] = getCanvasSubchartBrushExtent($$); if (!clampToExtent && (raw < min || raw > max)) { return null; } return Math.max(min, Math.min(max, raw)); } /** * Build a scale domain from two subchart brush coordinates. * @param {object} $$ ChartInternal instance * @param {number} start Brush start coordinate * @param {number} end Brush end coordinate * @returns {Array} Domain range * @private */ function getCanvasSubchartDomainFromCoords($$, start: number, end: number): any[] { const coordStart = Math.min(start, end); const coordEnd = Math.max(start, end); return [ $$.scale.subX.invert(coordStart), $$.scale.subX.invert(coordEnd) ]; } /** * Get current canvas subchart brush selection in local subchart pixels. * @param {object} $$ ChartInternal instance * @returns {Array|null} Brush selection pixels * @private */ function getCanvasSubchartBrushSelection($$): [number, number] | null { const {scale, state} = $$; const domain = state.domain; if (!domain?.length || !scale.subX) { return null; } const rect = getCanvasSubchartRect($$); const p0 = scale.subX(domain[0]); const p1 = scale.subX(domain[1]); if (!rect || !Number.isFinite(p0) || !Number.isFinite(p1)) { return null; } const [min, max] = getCanvasSubchartBrushExtent($$); return [ Math.max(min, Math.min(max, Math.min(p0, p1))), Math.max(min, Math.min(max, Math.max(p0, p1))) ]; } /** * Get the subchart brush mode at a local brush coordinate. * @param {object} $$ ChartInternal instance * @param {number} coord Brush coordinate * @returns {string} Brush mode * @private */ function getCanvasSubchartBrushMode($$, coord: number): | "select" | "move" | "resize-start" | "resize-end" { const selection = getCanvasSubchartBrushSelection($$); if (!selection) { return "select"; } const [start, end] = selection; if (Math.abs(coord - start) <= CANVAS_SUBCHART_HANDLE_SIZE) { return "resize-start"; } if (Math.abs(coord - end) <= CANVAS_SUBCHART_HANDLE_SIZE) { return "resize-end"; } return coord > start && coord < end ? "move" : "select"; } /** * Clamp a subchart brush selection to the subchart extent. * @param {object} $$ ChartInternal instance * @param {number} start Selection start pixel * @param {number} end Selection end pixel * @returns {Array} Clamped selection pixels * @private */ function clampCanvasSubchartSelection($$, start: number, end: number): [number, number] { const [min, max] = getCanvasSubchartBrushExtent($$); return [ Math.max(min, Math.min(max, start)), Math.max(min, Math.min(max, end)) ]; } /** * Get cursor for a canvas subchart brush mode. * @param {object} $$ ChartInternal instance * @param {string} mode Brush mode * @returns {string} CSS cursor * @private */ function getCanvasSubchartCursor($$, mode: string): string { if (/^resize/.test(mode)) { return $$.config.axis_rotated ? "ns-resize" : "ew-resize"; } return mode === "move" ? "move" : "crosshair"; } /** * Get stable key for canvas data callbacks. * @param {object} d Data row * @returns {string|null} Data key * @private */ function getCanvasDataKey(d): string | null { return d ? `${d.id}:${d.index}` : null; } /** * Get canvas drag selection delta rows from current and previous hit rows. * @param {object} $$ ChartInternal instance * @param {Array} dataRows Current drag hit rows * @returns {object} Current included keys and rows to toggle * @private */ function getCanvasSelectionDragDelta($$, dataRows): {included: Set<string>, rows: any[]} { const previousKeys = $$.state.canvasSelectionDragIncluded as Set<string>; const previousRows = canvasSelectionDragRows.get($$) || new Map<string, any>(); const currentRows = new Map<string, any>(); const included = new Set<string>(); const rows: any[] = []; dataRows .filter(d => isCanvasSelectableData($$, d)) .forEach(d => { const key = getCanvasDataKey(d); if (key) { included.add(key); !currentRows.has(key) && currentRows.set(key, d); } }); currentRows.forEach((d, key) => { !previousKeys.has(key) && rows.push(d); }); previousRows.forEach((d, key) => { !included.has(key) && rows.push(d); }); canvasSelectionDragRows.set($$, currentRows); return {included, rows}; } /** * Normalize a data id or id list. * @param {string|Array} ids Data ids * @returns {Array|null} Normalized ids * @private */ function getCanvasSelectionIds(ids?: string | string[]): string[] | null { return ids ? (Array.isArray(ids) ? ids : [ids]) : null; } /** * Check whether a canvas data row participates in selection. * @param {object} $$ ChartInternal instance * @param {object} d Data row * @returns {boolean} Whether the row can be selected * @private */ function isCanvasSelectableData($$, d): boolean { return d && hasCanvasDrawableValue($$, d) && isCanvasTargetSupported($$, d, CANVAS_SELECTABLE_TYPE_FILTERS) && CANVAS_SELECTABLE_TYPE_FILTERS.some(filter => filter($$, d)) && $$.config.data_selection_isselectable.bind($$.api)(d); } /** * Iterate selectable canvas rows. * @param {object} $$ ChartInternal instance * @param {function} callback Row callback * @private */ function eachCanvasSelectableData($$, callback: (d) => void): void { $$.filterTargetsToShow($$.data.targets) .filter(target => isCanvasTargetSupported($$, target, CANVAS_SELECTABLE_TYPE_FILTERS)) .forEach(target => { target.values.forEach(d => { isCanvasSelectableData($$, d) && callback(d); }); }); } /** * Check whether a canvas data row should interact as a single point. * @param {object} $$ ChartInternal instance * @param {object} d Data row * @returns {boolean} Whether the row should stay ungrouped * @private */ function isCanvasPointBasedInteraction($$, d): boolean { return !($$.config.axis_x_forceAsSingle && $$.config.tooltip_grouped) && (isCanvasScatterType($$, d) || isCanvasBubbleType($$, d)); } /** * Check if touch input is enabled by option. * @param {object} config Config object * @returns {boolean} Whether touch is enabled * @private */ function isCanvasTouchEnabled(config): boolean { return config.interaction_inputType_touch !== false; } /** * Get touch listener passive option following interaction.inputType.touch.preventDefault. * @param {object} config Config object * @returns {object} Event listener options * @private */ function getCanvasTouchListenerOption(config): AddEventListenerOptions { const preventDefault = config.interaction_inputType_touch?.preventDefault; const isPrevented = (isBoolean(preventDefault) && preventDefault) || false; const preventThreshold = (!isNaN(preventDefault) && preventDefault) || null; return { passive: !isPrevented && preventThreshold === null }; } /** * Create touch preventDefault handler following interaction.inputType.touch.preventDefault. * @param {object} config Config object * @returns {function} Touch preventer * @private */ function getCanvasTouchPreventer(config): (event: TouchEvent) => void { const preventDefault = config.interaction_inputType_touch?.preventDefault; const isPrevented = (isBoolean(preventDefault) && preventDefault) || false; const preventThreshold = (!isNaN(preventDefault) && preventDefault) || null; let startPx; return event => { const touch = event.changedTouches?.[0] || event.touches?.[0]; if (!touch) { return; } const currentXY = touch[`client${config.axis_rotated ? "Y" : "X"}`]; if (event.type === "touchstart") { if (isPrevented) { event.preventDefault(); } else if (preventThreshold !== null) { startPx = currentXY; } } else if ( event.type === "touchmove" && ( isPrevented || startPx === true || ( preventThreshold !== null && Math.abs(startPx - currentXY) >= preventThreshold ) ) ) { startPx = true; event.preventDefault(); } }; } /** * Check whether a pointer event should be handled by canvas pointer path. * @param {object} $$ ChartInternal instance * @param {PointerEvent} event Pointer event * @returns {boolean} Whether event should be handled * @private */ function shouldHandleCanvasPointerEvent($$, event: PointerEvent): boolean { return event.pointerType !== "mouse" && $$.state.inputType !== "touch"; } /** * Check if click event duplicates a recent touch/pointer data click. * @param {object} $$ ChartInternal instance * @param {object} d Data row * @returns {boolean} Whether the click should skip data callback * @private */ function isDuplicateCanvasInputClick($$, d): boolean { const {state} = $$; const key = getCanvasDataKey(d); const duplicate = key && state.canvasLastInputClickKey === key && Date.now() - (state.canvasLastInputClickTime || 0) < 750; if (duplicate) { state.canvasLastInputClickKey = null; state.canvasLastInputClickTime = 0; } return Boolean(duplicate); } /** * Mark a touch/pointer data click so the following compatibility click can be ignored. * @param {object} $$ ChartInternal instance * @param {object} d Data row * @private */ function markCanvasInputClick($$, d): void { $$.state.canvasLastInputClickKey = getCanvasDataKey(d); $$.state.canvasLastInputClickTime = Date.now(); } /** * Get current animation timestamp. * @returns {number} Timestamp * @private */ function getCanvasAnimationTime(): number { return window.performance?.now?.() ?? Date.now(); } /** * Check whether a domain can be interpolated for canvas flow. * @param {Array} domain Domain values * @param {boolean} isLog Whether the domain is in log scale * @returns {boolean} Whether domain is numeric/date-like * @private */ function isCanvasFlowDomain(domain, isLog = false): boolean { return Array.isArray(domain) && domain.length >= 2 && domain.every(v => Number.isFinite(+v) && (!isLog || +v > 0)); } /** * Interpolate numeric/date domain values. * @param {Array} start Start domain * @param {Array} end End domain * @param {number} ratio Interpolation ratio * @param {boolean} isLog Whether to interpolate in log space * @returns {Array} Interpolated domain * @private */ function interpolateCanvasFlowDomain(start, end, ratio: number, isLog = false): any[] { return start.slice(0, 2).map((value, index) => { const next = isLog ? Math.exp(Math.log(+value) + ((Math.log(+end[index]) - Math.log(+value)) * ratio)) : +value + ((+end[index] - +value) * ratio); return value instanceof Date || end[index] instanceof Date ? new Date(next) : next; }); } /** * Count current canvas flow values. * @param {object} $$ ChartInternal instance * @returns {number} Total values * @private */ function getCanvasFlowValueCount($$): number { return $$.data.targets.reduce((sum, target) => sum + target.values.length, 0); } /** * Sync y/y2 domains for canvas flow frames after data has been appended. * @param {object} $$ ChartInternal instance * @private */ function syncCanvasFlowYDomains($$): void { const {scale} = $$; const targetsToShow = $$.filterTargetsToShow($$.data.targets); (["y", "y2"] as const).forEach(key => { scale[key]?.domain($$.getYDomain(targetsToShow, key)); }); scale.subY?.domain($$.getYDomain(targetsToShow, "y")); scale.subY2?.domain($$.getYDomain(targetsToShow, "y2")); } const canvasInternal = { /** * Normalize unsupported options before initializing canvas mode. * @private */ prepareCanvasConfig(): void { const {config, state} = this; this.warnUnsupportedCanvasOptions(); if ( state.orgConfig?.transition && Object.prototype.hasOwnProperty.call(state.orgConfig.transition, "duration") && state.orgConfig.transition.duration ) { warn("canvas mode: transition.duration is ignored; canvas redraws synchronously."); } config.transition_duration = 0; if (!isSupportedCanvasXType(config.axis_x_type)) { config.axis_x_type = "indexed"; } if (!isSupportedCanvasYType(config.axis_y_type)) { config.axis_y_type = "indexed"; } if (!isSupportedCanvasYType(config.axis_y2_type)) { config.axis_y2_type = "indexed"; } if (this.hasType?.("bubble")) { config.point_show = true; config.point_type = "circle"; } }, /** * Apply the current canvas subchart selection to the main x domain. * @private */ applyCanvasSubchartDomain(): void { const $$ = this; const {axis, config, scale, state} = $$; if (!config.subchart_show || !state.hasAxis || !scale.subX) { return; } if (!state.rendered && !state.domain && config.subchart_init_range) { state.domain = normalizeCanvasSubchartDomain($$, config.subchart_init_range); } const domain = normalizeCanvasSubchartDomain($$, state.domain); if ( !domain || !$$.withinRange(domain, $$.getZoomDomain("subX", true), $$.getZoomDomain("subX")) ) { return; } state.domain = domain; scale.x.domain(domain); axis.x.scale(scale.x); }, /** * Set the canvas subchart selection domain and redraw the main chart. * @param {Array} domain Domain range * @param {boolean} redraw Whether to redraw immediately * @param {boolean} callCallback Whether to call subchart.onbrush * @returns {Array|undefined} Applied domain range * @private */ setCanvasSubchartDomain(domain, redraw = true, callCallback = false): any[] | undefined { const $$ = this; const {config, scale, state} = $$; const nextDomain = normalizeCanvasSubchartDomain($$, domain); if ( !config.subchart_show || !nextDomain || !$$.withinRange(nextDomain, $$.getZoomDomain("subX", true), $$.getZoomDomain("subX")) ) { return undefined; } state.domain = nextDomain; scale.zoom = null; if (redraw) { $$.redraw({ withTransition: false, withY: config.zoom_rescale, withSubchart: false, withEventRect: false, withDimension: false }); } callCallback && config.subchart_onbrush.bind($$.api)(state.domain); return state.domain; }, /** * Clear the canvas subchart brush selection and restore the full x domain. * @param {boolean} redraw Whether to redraw immediately * @param {boolean} callCallback Whether to call subchart.onbrush * @private */ clearCanvasSubchartDomain(redraw = true, callCallback = false): void { const $$ = this; const {axis, config, org, scale, state} = $$; if (!config.subchart_show) { return; } state.domain = undefined; scale.zoom = null; if (org.xDomain?.length) { scale.x.domain(org.xDomain); axis.x.scale(scale.x); } if (redraw) { $$.redraw({ withTransition: false, withUpdateXDomain: true, withUpdateOrgXDomain: false, withY: config.zoom_rescale, withSubchart: false, withEventRect: false, withDimension: false }); } callCallback && config.subchart_onbrush.bind($$.api)(scale.x.orgDomain()); }, /** * Finish a pending canvas flow animation before accepting another flow mutation. * @private */ flushCanvasFlow(): void { const {state} = this; if (!state.canvasFlowFinish) { return; } state.canvasFlowFrame !== null && window.cancelAnimationFrame?.(state.canvasFlowFrame); state.canvasFlowFrame = null; state.canvasFlowFinish(); }, /** * Animate canvas flow by interpolating the x domain, then commit the final data window. * @param {object} flow Flow metadata * @returns {boolean} Whether animation was started * @private */ animateCanvasFlow(flow): boolean { const $$ = this; const {axis, data, org, scale, state} = $$; const {done = () => {}, duration, length, orgDataCount} = flow; const requestFrame = window.requestAnimationFrame?.bind(window); const isLog = !!axis.isLog?.("x"); if ( !requestFrame || !duration || !length || !orgDataCount || getCanvasFlowValueCount($$) > CANVAS_FLOW_ANIMATION_MAX_VALUES ) { return false; } const startDomain = scale.x.domain().slice(0, 2); const startOrgDomain = org.xDomain?.slice?.(); const startSubXDomain = scale.subX?.domain?.().slice?.(); if (!isCanvasFlowDomain(startDomain, isLog)) { return false; } const removed = data.targets.map(target => target.values.splice(0, length)); $$.updateXDomain($$.filterTargetsToShow(data.targets), true, true); const endDomain = scale.x.domain().slice(0, 2); const endOrgDomain = org.xDomain?.slice?.(); data.targets.forEach((target, index) => { target.values.unshift(...removed[index]); }); if (!isCanvasFlowDomain(endDomain, isLog)) { startOrgDomain && (org.xDomain = startOrgDomain); scale.x.domain(startDomain); startSubXDomain && scale.subX.domain(startSubXDomain); axis.x.scale(scale.x); return false; } syncCanvasFlowYDomains($$); let finished = false; const started = getCanvasAnimationTime(); const finish = () => { if (finished) { return; } finished = true; data.targets.forEach(target => { target.values.splice(0, length); }); endOrgDomain && (org.xDomain = endOrgDomain); scale.x.domain(endDomain); scale.subX?.domain?.(endOrgDomain ?? endDomain); axis.x.scale(scale.x); state.dirty.data = true; state._eventRectFingerprint = null; state.canvasShape = null; state._cachedDrawShape = null; $$.redraw({ withLegend: true, withTransition: false, withTrimXDomain: false, withUpdateOrgXDomain: true, withUpdateXAxis: true, withUpdateXDomain: true }); $$.updateTypesElements(); state.canvasFlowFrame = null; state.canvasFlowFinish = null; state.flowing = false; done.call($$.api); }; const render = (timestamp: number) => { const ratio = Math.min(1, Math.max(0, (timestamp - started) / duration)); const domain = interpolateCanvasFlowDomain(startDomain, endDomain, ratio, isLog); scale.x.domain(domain); axis.x.scale(scale.x); state.canvasShape = null; state._cachedDrawShape = null; state._canvasVisibleRangeCache = null; state._canvasXTickValuesCache = null; $$.renderCanvasFrame(undefined, null, false); if (ratio < 1) { state.canvasFlowFrame = requestFrame(render); } else { finish(); } }; startOrgDomain && (org.xDomain = startOrgDomain); scale.x.domain(startDomain); startSubXDomain && scale.subX.domain(startSubXDomain); axis.x.scale(scale.x); state.flowing = true; state.cancelClick = true; $$.hideTooltip?.(); state.canvasFlowFinish = finish; state.canvasFlowFrame = requestFrame(render); return true; }, /** * Initialize canvas renderers, theme, hit detector and event bindings. * @private */ initCanvas(): void { const $$ = this; const {config, state, $el} = $$; const container = $el.chart.node(); state.isCanvasMode = true; state.canvasInlineStyle.minHeight = container.style.minHeight; if (window.getComputedStyle(container).position === "static") { $el.chart.style("position", "relative"); } $el.chart.style("min-height", `${state.current.height}px`); $$.canvasEngine = new CanvasEngine(); $$.canvasEngine.init(container, state.current.width, $$.getCanvasSurfaceHeight()); $el.canvas = d3Select($$.canvasEngine.canvas); $$.canvasTheme = new CanvasTheme(); $$.canvasTheme.load(container, config.canvas_theme); $$.canvasAxisRenderer = new CanvasAxisRenderer($$.canvasEngine, $$.canvasTheme); $$.canvasRenderer = new CanvasRenderer($$.canvasEngine, $$.canvasTheme); $$.hitDetector = new HitDetector(); $$.bindCanvasEvents(); config.zoom_enabled && $$.bindZoomEvent?.(); }, /** * Warn about options that canvas mode does not support yet. * @private */ warnUnsupportedCanvasOptions(): void { const {config} = this; const unsupported = [ [ !isSupportedCanvasXType(config.axis_x_type), "axis.x.type other than indexed/category/log/timeseries" ], [ !isSupportedCanvasYType(config.axis_y_type), "axis.y.type other than indexed/log/timeseries" ], [ !isSupportedCanvasYType(config.axis_y2_type), "axis.y2.type other than indexed/log/timeseries" ], [config.boost_useCssRule, "boost.useCssRule"], [this.hasArcType?.(), "arc charts"], [this.hasType?.("radar"), "radar chart"], [this.hasType?.("polar"), "polar chart"], [this.hasType?.("funnel"), "funnel chart"] ]; unsupported.forEach(([condition, name]) => { condition && warn(`canvas mode: ${name} is not yet supported.`); }); }, /** * Bind pointer and click handlers to the canvas surface. * @private */ bindCanvasEvents(): void { const $$ = this; const {config, state, $el} = $$; const canvas = $el.canvas.node(); const preventTouchEvent = getCanvasTouchPreventer(config); const touchOption = getCanvasTouchListenerOption(config); canvas.addEventListener("mousemove", $$.onCanvasMouseMove.bind($$)); canvas.addEventListener("mouseenter", event => { state.event = event; config.onover?.bind($$.api)(event); }); canvas.addEventListener("mouseout", $$.onCanvasMouseOut.bind($$)); canvas.addEventListener("mouseleave", event => { state.event = event; config.onout?.bind($$.api)(event); }); canvas.addEventListener("mousedown", event => { if ($$.onCanvasSubchartBrushStart?.(event)) { return; } $$.onCanvasSelectionDragStart(event); }); canvas.addEventListener("click", $$.onCanvasClick.bind($$)); canvas.addEventListener("pointerdown", $$.onCanvasPointerDown.bind($$)); canvas.addEventListener("pointerenter", $$.onCanvasPointerEnter.bind($$)); canvas.addEventListener("pointermove", $$.onCanvasPointerMove.bind($$)); canvas.addEventListener("pointerup", $$.onCanvasPointerUp.bind($$)); canvas.addEventListener("pointerleave", $$.onCanvasPointerOut.bind($$)); canvas.addEventListener("pointercancel", $$.onCanvasPointerCancel.bind($$)); if (isCanvasTouchEnabled(config)) { canvas.addEventListener("touchstart", event => { preventTouchEvent(event); $$.onCanvasTouchStart(event); }, touchOption); canvas.addEventListener("touchmove", event => { preventTouchEvent(event); $$.onCanvasTouchMove(event); }, touchOption); canvas.addEventListener("touchend", $$.onCanvasTouchEnd.bind($$), touchOption); canvas.addEventListener("touchcancel", $$.onCanvasTouchCancel.bind($$), touchOption); } }, /** * Render and update the HTML legend used by canvas mode. * @private */ updateHtmlLegend(): void { const $$ = this; const {config, state, $el} = $$; const chart = $el.chart.node(); if (!config.legend_show || state.hasTreemap) { if (state.hasTreemap) { $el.legend?.remove(); $el.legend = null; } else { $el.legend?.style("visibility", "hidden"); } state.legendItemWidth = 0; state.legendItemHeight = 0; state.legendStep = 0; return; } const targetIds = $$.mapToIds($$.data.targets) .filter(id => config.data_names[id] !== null); if (config.legend_contents_bindto && config.legend_contents_template) { if ($$.updateHtmlLegendTemplate(targetIds)) { state.legendItemWidth = 0; state.legendItemHeight = 0; state.legendStep = 0; state.legendHasRendered = true; } else { state.legendItemWidth = 0; state.legendItemHeight = 0; state.legendStep = 0; } return; } if (!$el.legend) { $el.legend = d3Select(chart) .append("div") .classed($LEGEND.legend, true) .classed($CANVAS.legend, true); } $el.legend.style("visibility", null); const legendItems = $el.legend .selectAll(`button.${$LEGEND.legendItem}`) .data(targetIds, (id: string) => id); legendItems.exit().remove(); const enter = legendItems.enter() .append("button") .attr("type", "button") .attr("data-id", id => id); enter.append("span") .classed($LEGEND.legendItemTile, true); enter.append("span") .classed($CANVAS.legendItemTitle, true); const item = enter.merge(legendItems as any) .attr("class", id => $$.generateClass($LEGEND.legendItem, id).trim()); setCanvasHtmlLegendItem($$, item); item.select(`.${$LEGEND.legendItemTile}`) .classed($CANVAS.legendItemTileCircle, !config.legend_usePoint && config.legend_item_tile_type === "circle") .style("background-color", id => (config.legend_usePoint ? null : $$.color(id))) .html(id => (config.legend_usePoint ? getCanvasLegendPointIcon($$, id) : "")); item.select(`.${$CANVAS.legendItemTitle}`) .text(id => getLegendText($$, id)); if (config.legend_tooltip) { item.attr("title", id => getLegendText($$, id)); } bindCanvasHtmlLegendInteractions($$, item); $$.updateHtmlLegendSize(targetIds); $$.positionHtmlLegend(); state.legendHasRendered = true; }, /** * Render canvas mode legend into custom HTML template container. * @param {Array} targetIds Legend target ids * @returns {boolean} Whether template legend was rendered * @private */ updateHtmlLegendTemplate(targetIds: string[]): boolean { const $$ = this; const {api, config, $el} = $$; const wrapper = d3Select(config.legend_contents_bindto); const template = config.legend_contents_template; const ids: string[] = []; let html = ""; if (wrapper.empty()) { return false; } targetIds.forEach(id => { const content = isFunction(template) ? sanitize(template.call(api, id, $$.color(id), api.data(id)[0].values)) : tplProcess(template, { COLOR: $$.color(id), TITLE: id }); if (content) { ids.push(id); html += content; } }); const legendItem = wrapper .html(html) .classed($LEGEND.legend, true) .classed($CANVAS.legend, false) .style("visibility", null) .selectAll(function() { return this.children; }) .data(ids); setCanvasHtmlLegendItem($$, legendItem); bindCanvasHtmlLegendInteractions($$, legendItem); $el.legend = wrapper; return true; }, /** * Measure HTML legend items and update legend layout state. * @param {Array} targetIds Visible legend target ids * @private */ updateHtmlLegendSize(targetIds: string[]): void { const $$ = this; const {config, state, $el} = $$; const itemSizes: number[] = []; const itemLayouts: Record<string, any> = {}; let maxWidth = 0; let maxHeight = 0; let legendStep = 0; const useTemplateLegend = config.legend_contents_bindto && config.legend_contents_template; if (useTemplateLegend) { $el.legend.selectAll(`.${$LEGEND.legendItem}`) .each(function(id) { const rect = getBoundingRect(this, true); const text = `${getLegendText($$, id)}`; const fallbackWidth = Math.max(32, text.length * 7 + 24); const fallbackHeight = 20; const width = Math.ceil(rect.width || fallbackWidth); const height = Math.ceil(rect.height || fallbackHeight); itemSizes.push(width); maxWidth = Math.max(maxWidth, width); maxHeight = Math.max(maxHeight, height); }); } else if (targetIds.length) { const isRightOrInset = state.isLegendRight || state.isLegendInset; const isRectangle = config.legend_item_tile_type !== "circle"; const itemTileSize = { width: isRectangle ? config.legend_item_tile_width : config.legend_item_tile_r * 2, height: isRectangle ? config.legend_item_tile_height : config.legend_item_tile_r * 2 }; const dimension = { padding: { top: 4, right: 10 }, max: { width: 0, height: 0 }, posMin: 10, step: 0, tileWidth: itemTileSize.width + 5, totalLength: 0 }; const sizes = { offsets: {}, widths: {}, heights: {}, margins: [0], steps: {} }; const measured = targetIds.map((id, index) => { const text = `${getLegendText($$, id)}`; const rect = measureSvgLegendText($$, text); const fallbackWidth = Math.max(32, text.length * 7); const fallbackHeight = 12; const isLast = index === targetIds.length - 1; const hidden = config.legend_show && !$$.isLegendToShow(id); const width = hidden ? 0 : ( (rect?.width || fallbackWidth) + dimension.tileWidth + (isLast && !isRightOrInset ? 0 : dimension.padding.right) + config.legend_padding ); const height = hidden ? 0 : (rect?.height || fallbackHeight) + dimension.padding.top; dimension.max.width = Math.max(dimension.max.width, width); dimension.max.height = Math.max(dimension.max.height, height); return { id, hidden, width, height }; }); const areaLength = isRightOrInset ? $$.getLegendHeight() : $$.getLegendWidth(); const updateValues = (