billboard.js
Version:
Re-usable easy interface JavaScript chart library, based on D3 v4+
2,268 lines • 82.8 kB
JavaScript
/*!
* Copyright (c) 2017 ~ present NAVER Corp.
* billboard.js project is licensed under the MIT license
*
* billboard.js, JavaScript chart library
* https://naver.github.io/billboard.js/
*
* @version 4.1.1
*/
import { window as win } from "../../module/browser.js";
import { isBoolean, isFunction } from "../../module/util/type-checks.js";
import { sanitize } from "../../module/sanitize.js";
import { callFn, extend, parseDate, tplProcess } from "../../module/util/object.js";
import { getBoundingRect } from "../../module/util/dom.js";
import { $FOCUS, $LEGEND } from "../classes.js";
import { KEY } from "../../module/Cache.js";
import ChartInternal from "../../ChartInternal/ChartInternal.js";
import { getRenderDataPoint } from "../../ChartInternal/shape/core/geometry.js";
import { getMainCoordFromSubchartCoord } from "../../ChartInternal/internals/subchart.util.js";
import { hasCanvasDrawableValue, isCanvasBarType, isCanvasBubbleType, isCanvasCandlestickType, isCanvasPointType, isCanvasScatterType, isCanvasTargetSupported, isCanvasTreemapType } from "../../canvas/util.js";
import CanvasAxisRenderer from "../../canvas/CanvasAxisRenderer.js";
import { $CANVAS } from "../../canvas/classes.js";
import CanvasEngine from "../../canvas/CanvasEngine.js";
import CanvasRenderer from "../../canvas/CanvasRenderer.js";
import CanvasTheme from "../../canvas/CanvasTheme.js";
import HitDetector from "../../canvas/HitDetector.js";
import { select } from "d3-selection";
//#region src/config/resolver/canvas.ts
/**
* Copyright (c) 2017 ~ present NAVER Corp.
* billboard.js project is licensed under the MIT license
*/
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 = 1e5;
const CANVAS_HTML_ATTR_ESCAPE = {
"&": "&",
"<": "<",
">": ">",
"\"": """,
"'": "'"
};
const canvasSelectionDragRows = /* @__PURE__ */ new WeakMap();
/**
* Emit canvas v1 unsupported option warning.
* @param {string} message Warning message
* @private
*/
function warn(message) {
win.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) {
const { config } = $$;
const text = config.data_names[id] ?? id;
return isFunction(config.legend_format) ? config.legend_format(text, id !== text ? id : void 0) : text;
}
/**
* Escape a value for safe HTML attribute interpolation.
* @param {string} value Attribute value
* @returns {string} Escaped value
* @private
*/
function escapeHtmlAttr(value) {
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) {
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]);
return pattern[Math.max(0, targetIds.indexOf(id)) % 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) {
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) {
const itemMap = /* @__PURE__ */ new Map();
item.each(function(id) {
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) {
const { config } = $$;
item.attr("class", function(id) {
const next = `${select(this).attr("class") || ""} ${$$.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) {
const { legend } = $$.$el;
const targetIds = $$.mapToTargetIds?.([id]) || [id];
legend?.selectAll(`.${$LEGEND.legendItem}`).classed($FOCUS.legendItemFocused, (d) => targetIds.indexOf(d) >= 0).style("opacity", function(d) {
return targetIds.indexOf(d) >= 0 ? null : $$.opacityForUnfocusedLegend.call($$, select(this));
});
}
/**
* Revert canvas HTML legend focus styling.
* @param {object} $$ ChartInternal instance
* @private
*/
function revertCanvasHtmlLegendFocus($$) {
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) {
const { state } = $$;
const focusedIds = ($$.mapToTargetIds?.([id]) || [id]).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?.(void 0, null, false);
}
/**
* Revert canvas target focus state.
* @param {object} $$ ChartInternal instance
* @private
*/
function revertCanvasLegendTargetFocus($$) {
const { state } = $$;
const changed = !!state.focusedTargetIds?.size || !!state.defocusedTargetIds?.size;
state.focusedTargetIds = /* @__PURE__ */ new Set();
state.defocusedTargetIds = /* @__PURE__ */ new Set();
changed && $$.renderCanvasFrame?.(void 0, 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) {
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, event) {
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) {
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, event) {
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) {
$$.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) {
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) {
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) : void 0;
const handleCanvasLegendToggle = function(event, id) {
if (!callFn(config.legend_item_onclick, api, id, !state.hiddenTargetIds.has(id))) {
const selected = select(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))) {
if (!state.transiting && $$.isTargetToShow(id)) {
setCanvasHtmlLegendFocus($$, 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) {
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) {
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) {
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) {
const touchEvent = event;
return touchEvent.changedTouches?.[0] || touchEvent.touches?.[0] || ("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, 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) {
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) {
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) {
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) {
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) {
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) {
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) {
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($$) {
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) {
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;
}
/**
* Convert a canvas subchart point to the equivalent main plot point.
* @param {object} $$ ChartInternal instance
* @param {Array} point Canvas-local point
* @returns {Array|null} Main plot point or null when not applicable
* @private
*/
function getCanvasMainPointFromSubchart($$, point) {
const { config, state } = $$;
const rect = getCanvasSubchartRect($$);
if (config.subchart_brush_enabled !== false || !rect || !isCanvasSubchartPoint($$, point)) return null;
const mainCoord = getMainCoordFromSubchartCoord($$, config.axis_rotated ? point[1] - rect.y : point[0] - rect.x);
if (mainCoord === null) return null;
return config.axis_rotated ? [state.margin.left + state.width / 2, state.margin.top + mainCoord] : [state.margin.left + mainCoord, state.margin.top + state.height / 2];
}
/**
* 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($$) {
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, clampToExtent = false) {
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, end) {
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($$) {
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) {
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, end) {
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) {
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) {
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) {
const previousKeys = $$.state.canvasSelectionDragIncluded;
const previousRows = canvasSelectionDragRows.get($$) || /* @__PURE__ */ new Map();
const currentRows = /* @__PURE__ */ new Map();
const included = /* @__PURE__ */ new Set();
const rows = [];
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) {
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) {
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) {
$$.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) {
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) {
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) {
const preventDefault = config.interaction_inputType_touch?.preventDefault;
return { passive: !(isBoolean(preventDefault) && preventDefault || false) && (!isNaN(preventDefault) && preventDefault || null) === null };
}
/**
* Create touch preventDefault handler following interaction.inputType.touch.preventDefault.
* @param {object} config Config object
* @returns {function} Touch preventer
* @private
*/
function getCanvasTouchPreventer(config) {
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) {
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) {
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) {
$$.state.canvasLastInputClickKey = getCanvasDataKey(d);
$$.state.canvasLastInputClickTime = Date.now();
}
/**
* Get current animation timestamp.
* @returns {number} Timestamp
* @private
*/
function getCanvasAnimationTime() {
return win.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) {
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, isLog = false) {
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($$) {
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($$) {
const { scale } = $$;
const targetsToShow = $$.filterTargetsToShow($$.data.targets);
["y", "y2"].forEach((key) => {
scale[key]?.domain($$.getYDomain(targetsToShow, key));
});
$$.withSubchartTypeContext(() => {
scale.subY?.domain($$.getYDomain(targetsToShow, "y"));
scale.subY2?.domain($$.getYDomain(targetsToShow, "y2"));
});
}
const canvasInternal = {
/**
* Normalize unsupported options before initializing canvas mode.
* @private
*/
prepareCanvasConfig() {
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() {
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) {
const $$ = this;
const { config, scale, state } = $$;
const nextDomain = normalizeCanvasSubchartDomain($$, domain);
if (!config.subchart_show || !nextDomain || !$$.withinRange(nextDomain, $$.getZoomDomain("subX", true), $$.getZoomDomain("subX"))) return;
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) {
const $$ = this;
const { axis, config, org, scale, state } = $$;
if (!config.subchart_show) return;
state.domain = void 0;
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() {
const { state } = this;
if (!state.canvasFlowFinish) return;
state.canvasFlowFrame !== null && win.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) {
const $$ = this;
const { axis, data, org, scale, state } = $$;
const { done = () => {}, duration, length, orgDataCount } = flow;
const requestFrame = win.requestAnimationFrame?.bind(win);
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) => {
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(void 0, 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() {
const $$ = this;
const { config, state, $el } = $$;
const container = $el.chart.node();
state.isCanvasMode = true;
state.canvasInlineStyle.minHeight = container.style.minHeight;
if (win.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 = select($$.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() {
const { config } = this;
[
[!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"]
].forEach(([condition, name]) => {
condition && warn(`canvas mode: ${name} is not yet supported.`);
});
},
/**
* Bind pointer and click handlers to the canvas surface.
* @private
*/
bindCanvasEvents() {
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() {
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 = select(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) => 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).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) {
const $$ = this;
const { api, config, $el } = $$;
const wrapper = select(config.legend_contents_bindto);
const template = config.legend_contents_template;
const ids = [];
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) {
const $$ = this;
const { config, state, $el } = $$;
const itemSizes = [];
const itemLayouts = {};
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 = (id, itemLength, withoutStep = false) => {
let margin;
if (!withoutStep) {
margin = (areaLength - dimension.totalLength - itemLength) / 2;
if (margin < dimension.posMin) {
margin = (areaLength - itemLength) / 2;
dimension.totalLength = 0;
dimension.step++;
}
}
sizes.steps[id] = dimension.step;
sizes.margins[dimension.step] = state.isLegendInset ? 10 : margin;
sizes.offsets[id] = dimension.totalLength;
dimension.totalLength += itemLength;
};
if (state.isLegendInset) {
dimension.step = config.legend_inset_step ? config.legend_inset_step : targetIds.length;
state.legendStep = dimension.step;
}
measured.forEach(({ id, hidden, width, height }) => {
const itemWidth = config.legend_equally && !hidden ? dimension.max.width : width;
const itemHeight = config.legend_equally && !hidden ? dimension.max.height : height;
const itemLength = isRightOrInset ? itemHeight : itemWidth;
sizes.widths[id] = itemWidth;
sizes.heights[id] = itemHeight;
itemSizes.push(itemWidth);
maxWidth = Math.max(maxWidth, itemWidth);
maxHeight = Math.max(maxHeight, itemHeight);
if (hidden) {
sizes.steps[id] = 0;
sizes.offsets[id] = 0;
return;
}
updateValues(id, itemLength);
});
const xForLegend = state.isLegendRight ? (id) => dimension.max.width * sizes.steps[id] : state.isLegendInset ? (id) => dimension.max.width * sizes.steps[id] + 10 : (id) => sizes.margins[sizes.steps[id]] + sizes.offsets[id];
const yForLegend = state.isLegendRight || state.isLegendInset ? (id) => sizes.margins[sizes.steps[id]] + sizes.offsets[id] : (id) => dimension.max.height * sizes.steps[id];
const titleX = 4 + itemTileSize.width;
const tileCenterY = 9;
measured.forEach(({ id, hidden }) => {
const x = hidden ? 0 : xForLegend(id);
const y = hidden ? 0 : yForLegend(id);
itemLayouts[id] = {
item: {
x,
y: y - 5,
width: sizes.widths[id],
height: sizes.heights[id]
},
title: {
x: titleX,
y: 0,
height: sizes.heights[id]
},
tile: {
x: -2,
y: tileCenterY - itemTileSize.height / 2,
width: itemTileSize.width,
height: itemTileSize.height
}
};
});
legendStep = dimension.step;
}
if (!targetIds.length) {
state.legendItemWidth = 0;
state.legendItemHeight = 0;
state.legendStep = 0;
return;
}
if (useTemplateLegend) {
const availableWidth = Math.max(1, state.current.width);
const rowGap = 10;
let rows = 1;
let rowWidth = 0;
itemSizes.forEach((width) => {
if (rowWidth && rowWidth + rowGap + width > availableWidth) {
rows++;
rowWidth = width;
} else rowWidth += (rowWidth ? rowGap : 0) + width;
});
state.legendStep = Math.max(0, rows - 1);
} else state.legendStep = legendStep;
state.legendItemWidth = maxWidth;
state.legendItemHeight = Math.max(12, maxHeight);
if (!useTemplateLegend) $el.legend.selectAll(`button.${$LEGEND.legendItem}`).each(function(id) {
const layout = itemLayouts[id];
const item = select(this);
if (!layout) return;
item.style("left", `${layout.item.x}px`).style("top", `${layout.item.y}px`).style("width", `${layout.item.width}px`).style("height", `${layout.item.height}px`);
item.select(`.${$CANVAS.legendItemTitle}`).style("left", `${layout.title.x}px`).style("top", `${layout.title.y}px`).style("height", `${layout.title.height}px`).style("line-height", `${layout.title.height}px`);
item.select(`.${$LEGEND.legendItemTile}`).style("left", `${layout.tile.x}px`).style("top", `${layout.tile.y}px`).style("width", `${layout.tile.width}px`).style("height", `${layout.tile.height}px`);
});
},
/**
* Get bottom legend block height separated from the canvas drawing surface.
* @returns {number} Legend block height
* @private
*/
getCanvasBottomLegendHeight() {
const $$ = this;
const { config, state, $el } = $$;
if (!config.legend_show || state.isLegendRight || state.isLegendInset || state.hasTreemap || !$el.legend || !state.legendItemHeight) return 0;
const rectHeight = getBoundingRect($el.legend.node(), true).height;
const rowHeight = state.legendItemHeight * (state.legendStep + 1);
return Math.ceil(Math.max($$.getLegendHeight?.() || 0, rectHeight || rowHeight));
},
/**
* Get canvas drawing surface height excluding separately rendered bottom legend DOM.
* @returns {number} Canvas drawing surface height
* @private
*/
getCanvasSurfaceHeight() {
const $$ = this;
return Math.max(0, $$.state.current.height - $$.getCanvasBottomLegendHeight());
},
/**
* Position the HTML legend around or inside the canvas chart.
* @private
*/
positionHtmlLegend() {
const $$ = this;
const { config, state, $el } = $$;
const legend = $el.legend;
legend.classed($CANVAS.legendBottom, !state.isLegendRight && !state.isLegendInset).classed($CANVAS.legendRight, state.isLegendRight).classed($CANVAS.legendInset, state.isLegendInset).style("top", null).style("right", null).style("bottom", null).style("left", null).style("width", null).style("height", null).style("min-height", null);
if (state.isLegendRight) legend.style("top", `${state.margin3.top}px`).style("right", "0").style("bottom", `${state.margin3.bottom}px`).style("width", `${Math.max(0, $$.getLegendWidth())}px`);
else if (!state.isLegendInset) {
const legendHeight = $$.getLegendHeight?.() || 0;
legend.style("top", `${Math.max(0, state.current.height - legendHeight)}px`).style("right", "0").style("bottom", "auto").style("left", "0");
} else if (state.isLegendInset) {
const isTop = state.isLegendTop;
const isLeft = state.isLegendLeft;
legend.style(isTop ? "top" : "bottom", `${config.legend_inset_y}px`).style(isLeft ? "left" : "right", `${config.legend_inset_x}px`).style("width", `${Math.max(0, state.legendItemWidth * (state.legendStep + 1) + 20)}px`);
}
},
/**
* Get selected canvas data rows.
* @param {string} targetId Target id
* @returns {Array} Selected rows
* @private
*/
getCanvasSelectedData(targetId) {
const $$ = this;
const selected = $$.state.canvasSelection;
const data = [];
if (!$$.config.data_selection_enabled || !selected?.size) return data;
eachCanvasSelectableData($$, (d) => {
(!targetId || d.id === targetId) && selected.has(getCanvasDataKey(d)) && data.push(d);
});
return data;
},
/**
* Select or unselect canvas data rows.
* @param {boolean} isSelection Whether to select
* @param {string|Array} ids Target ids
* @param {Array} indices Target indices
* @param {boolean} resetOther Whether to reset unrelated selections
* @private
*/
setCanvasSelection(isSelection = false, ids, indices, resetOther) {
const $$ = this;
const { config, state } = $$;
const selected = state.canvasSelection;
const selectionGrouped = config.data_selection_grouped;
let targetIds = getCanvasSelectionIds(ids);
let changed = false;
const singleSelection = isSelection && !config.data_selection_multiple;
let resetDone = !singleSelection;
if (!config.data_selection_enabled) return;
if (singleSelection) {
indices = indices?.length ? [indices[0]] : [0];
if (!selectionGrouped) {
if (targetIds) targetIds = targetIds.slice(0, 1);
else {
const [firstTarget] = $$.filterTargetsToShow($$.data.targets);
targetIds = firstTarget ? [firstTarget.id] : [];
}
}
}
eachCanvasSelectableData($$, (d) => {
const key = getCanvasDataKey(d);
const isTargetId = selectionGrouped || !targetIds || targetIds.indexOf(d.id) >= 0;
const isTargetIndex = !indices || indices.indexOf(d.index) >= 0;
const isSelected = selected.has(key);
if (isSelection) {
if (isTargetId && isTargetIndex && (!isSelected || singleSelection)) {
if (!resetDone) {
$$.setCanvasSelection(false);
resetDone = true;
}
if (selected.has(key)) return;
selected.add(key);
callFn(config.data_onselected, $$.api, d, $$.canvasEngine.canvas);
changed = true;
} else if ((!singleSelection || resetDone) && resetOther && isSelected) {
selected.delete(key);
callFn(config.data_onunselected, $$.api, d, $$.canvasEngine.canvas);
changed = true;
}
} else if (isTargetId && isTargetIndex && isSelected) {
selected.delete(key);
callFn(config.data_onunselected, $$.api, d, $$.canvasEngine.canvas);
changed = true;
}
});
changed && $$.renderCanvasFrame?.(void 0, null, false);
},
/**
* Toggle canvas data selection from a click.
* @param {Array} dataRows Data rows to toggle
* @private
*/
toggleCanvasSelection(dataRows) {
const $$ = this;
const { config, state } = $$;
const selected = state.canvasSelection;
let changed = false;
if (!config.data_selection_enabled) return;
dataRows.filter((d) => isCanvasSelectableData($$, d)).forEach((d) => {
const key = getCanvasDataKey(d);
const isSelected = selected.has(key);
if (!config.data_selection_multiple) eachCanvasSelectableData($$, (selectedData) => {
const selectedKey = getCanvasDataKey(selectedData);
if ((config.data_selection_grouped ? selectedData.id === d.id : selectedKey !== key) && selected.has(selectedKey)) {
selected.delete(selectedKey);
callFn(config.data_onunselected, $$.api, selectedData, $$.canvasEngine.canvas);
changed = true;
}
});
if (isSelected && selected.has(key)) {
selected.delete(key);
callFn(config.data_onunselected, $$.api, d, $$.canvasEngine.canvas);
} else {
selected.add(key);
callFn(config.data_onselected, $$.api, d, $$.canvasEngine.canvas);
}
changed = true;
});
changed && $$.renderCanvasFrame?.(void 0, null, false);
},
/**
* Check whether canvas draggable selection can start.
* @param {MouseEvent|TouchEvent} event Input event
* @returns {boolean} Whether drag selection can start
* @private
*/
canStartCanvasSelectionDrag(event) {
const { config } = this;
const mouse = event;
return Boolean(config.interaction_enabled && config.data_selection_enabled && config.data_selection_draggable && config.data_selection_multiple && !config.zoom_enabled && (mouse.button === void 0 || mouse.button === 0));
},
/**
* Get the selected data rows inside the current canvas drag rectangle.
* @param {object} rect Canvas rectangle
* @returns {Array} Included rows
* @private
*/
getCanvasSelectionDragData(rect) {
const $$ = this;
const { config } = $$;
const data = $$.hitDetector.findInRect(rect, config.data_selection_grouped);
if (!config.data_selection_grouped) return data;
const indices = new Set(data.map((d) => d.index));
return $$.filterTargetsToShow($$.data.targets).flatMap((target) => target.values.filter((d) => indices.has(d.index)));
},
/**
* Start canvas draggable selection.
* @param {MouseEvent|TouchEvent} event Input event
* @returns {boolean} Whether drag started
* @private
*/
startCanvasSelectionDrag(event) {
const $$ = this;
const { state } = $$;
const point = getCanvasEventPoint($$, event);
if (!point || !$$.canStartCanvasSelectionDrag(event)) return false;
state.dragStart = point;
state.canvasSelectionDragStart = point;
state.canvasSelectionDragIncluded = /* @__PURE__ */ new Set();
state.canvasSelectionDragging = true;
state.canvasSelectionDragMoved = false;
canvasSelectionDragRows.set($$, /* @__PURE__ */ new Map());
$$.setDragStatus(true);
$$.hideTooltip?.();
event.preventDefault?.();
return true;
},
/**
* Update canvas draggable selection.
* @param {MouseEvent|TouchEvent} event Input event
* @returns {boolean} Whether drag was handled
* @private
*/
updateCanvasSelectionDrag(event) {
const $$ = this;
const { config, state } = $$;
const start = state.canvasSelectionDragStart;
const point = getCanvasEventPoint($$, event);
if (!state.canvasSelectionDragging || !start || !point) return false;
const [sx, sy] = start;
const [mx, my] = point;
const minX = Math.min(sx, mx);
const maxX = Math.max(sx, mx);
const minY = config.data_selection_grouped ? state.margin.top : Math.min(sy, my);
const maxY = config.data_selection_grouped ? state.margin.top + state.height : Math.max(sy, my);
const rect = {
x: minX,
y: minY,
w: maxX - minX,
h: maxY - minY
};
const moved = Math.abs(mx - sx) > 4 || Math.abs(my - sy) > 4;
state.canvasSelectionDragMoved = state.canvasSelectionDragMoved || moved;
if (moved) {
const { included, rows } = getCanvasSelectionDragDelta($$, $$.getCanvasSelectionDragData(rect));
rows.length && $$.toggleCanvasSelection(rows);
state.canvasSelectionDragIncluded = included;
state.cancelClick = true;
}
$$.renderCanvasSelectionDrag(rect);
event.preventDefault?.();
return true;
},
/**
* End canvas draggable selection.
* @param {MouseEvent|TouchEvent} event Input event
* @returns {boolean} Whether drag ended
* @private
*/
endCanvasSelectionDrag(event) {
const $$ = this;
const { state } = $$;
if (!state.canvasSelectionDragging) return false;
$$.updateCanvasSelectionDrag(event);
state.cancelClick = state.cancelClick || state.canvasSelectionDragMoved;
state.dragStart = null;
state.canvasSelectionDragStart = null;
state.canvasSelectionDragIncluded = /* @__PURE__ */ new Set();
state.canvasSelectionDragging = false;
state.canvasSelectionDragMoved = false;
canvasSelectionDragRows.delete($$);
$$.setDragStatus(false);
$$.clearCanvasFocus();
event.preventDefault?.();
return true;
},
/**
* Dispatch canvas data.onout callback for the current hovered row.
* @param {HTMLCanvasElement} canvas Canvas element
* @private
*/
dispatchCanvasDataOut(canvas) {
const $$ = this;
const { config, state } = $$;
const d = state.canvasDataHover;
if (!d) return;
state.canvasDataHover = null;
state.canvasDataHoverKey = null;
config.interaction_enabled && callFn(config.data_onout, $$.api, d, canvas);
},
/**
* Dispatch canvas data.onover callback when the hovered row changes.
* @param {object} d Data row
* @param {HTMLCanvasElement} canvas Canvas element
* @private
*/
dispatchCanvasDataOver(d, canvas) {
const $$ = this;
const { config, state } = $$;
const key = getCanvasDataKey(d);
if (state.canvasDataHoverKey === key) return;
$$.dispatchCanvasDataOut(canvas);
state.canvasDataHover = d;
state.canvasDataHoverKey = key;
config.interaction_enabled && d && callFn(config.data_onover, $$.api, d, canvas);
},
/**
* Dispatch canvas data.onclick callback for a pointer position.
* @param {Event} event Input event
* @param {boolean} markInputClick Whether to suppress following compatibility click
* @returns {object|null} Clicked data row
* @private
*/
dispatchCanvasDataClick(event, markInputClick = false) {
const $$ = this;
const { config, $el } = $$;
const canvas = $el.canvas.node();
const d = getCanvasEventDatum($$, event, true);
if (!d || isDuplicateCanvasInputClick($$, d)) return d;
if (config.interaction_enabled) {
const dataRows = config.data_selection_grouped ? $$.filterTargetsToShow($$.data.targets).map((target) => target.values[d.index]).filter(Boolean) : [d];
$$.toggleCanvasSelection?.(dataRows);
dataRows.forEach((row) => callFn(config.data_onclick, $$.api, row, canvas));
markInputClick && markCanvasInputClick($$, d);
}
return d;
},
/**
* Update canvas subchart brush domain from an input event.
* @param {Event} event Input event
* @returns {boolean} Whether the brush was updated
* @private
*/
updateCanvasSubchartBrush(event) {
const $$ = this;
const { config, state } = $$;
const start = state.canvasSubchartBrushStart;
const origin = state.canvasSubchartBrushOrigin;
const mode = state.canvasSubchartBrushMode;
const coord = getCanvasSubchartBrushCoord($$, event, true);
if (config.subchart_brush_enabled === false || !state.canvasSubchartBrushDragging || start === null || coord === null || !mode) return false;
const delta = coord - start;
if (Math.abs(delta) > CANVAS_SUBCHART_CLICK_TOLERANCE) state.canvasSubchartBrushMoved = true;
if (!state.canvasSubchartBrushMoved) {
event.preventDefault?.();
return true;
}
let selection;
if (mode === "move" && origin) {
const [extentStart, extentEnd] = getCanvasSubchartBrushExtent($$);
const size = origin[1] - origin[0];
let nextStart = origin[0] + delta;
let nextEnd = origin[1] + delta;
if (nextStart < extentStart) {
nextEnd += extentStart - nextStart;
nextStart = extentStart;
}
if (nextEnd > extentEnd) {
nextStart -= nextEnd - extentEnd;
nextEnd = extentEnd;
}
selection = [Math.max(extentStart, Math.min(extentEnd - size, nextStart)), Math.min(extentEnd, Math.max(extentStart + size, nextEnd))];
} else if (mode === "resize-start" && origin) selection = clampCanvasSubchartSelection($$, coord, origin[1]);
else if (mode === "resize-end" && origin) selection = clampCanvasSubchartSelection($$, origin[0], coord);
else selection = clampCanvasSubchartSelection($$, start, coord);
const domain = getCanvasSubchartDomainFromCoords($$, selection[0], selection[1]);
$$.setCanvasSubchartDomain(domain, true);
state.cancelClick = true;
event.preventDefault?.();
return true;
},
/**
* Update cursor while hovering over the canvas subchart brush area.
* @param {Event} event Input event
* @returns {boolean} Whether the pointer is over the subchart
* @private
*/
updateCanvasSubchartCursor(event) {
const $$ = this;
const canvas = $$.$el.canvas.node();
if ($$.config.subchart_brush_enabled === false) {
canvas.style.cursor = "";
return false;
}
const coord = getCanvasSubchartBrushCoord($$, event);
if (coord === null) {
canvas.style.cursor = "";
return false;
}
const mode = getCanvasSubchartBrushMode($$, coord);
canvas.style.cursor = getCanvasSubchartCursor($$, mode);
return true;
},
/**
* Start a canvas subchart brush gesture.
* @param {Event} event Mouse, pointer or touch event
* @returns {boolean} Whether brushing started
* @private
*/
startCanvasSubchartBrush(event) {
const $$ = this;
const { state } = $$;
if ($$.config.subchart_brush_enabled === false) return false;
const coord = getCanvasSubchartBrushCoord($$, event);
if (coord === null) return false;
const mode = getCanvasSubchartBrushMode($$, coord);
state.event = event;
state.canvasSubchartBrushDragging = true;
state.canvasSubchartBrushMode = mode;
state.canvasSubchartBrushStart = coord;
state.canvasSubchartBrushOrigin = getCanvasSubchartBrushSelection($$);
state.canvasSubchartBrushMoved = false;
state.cancelClick = true;
$$.setDragStatus(true);
event.preventDefault?.();
return true;
},
/**
* End a canvas subchart brush gesture.
* @param {Event} event Mouse, pointer or touch event
* @returns {boolean} Whether brushing ended
* @private
*/
endCanvasSubchartBrush(event) {
const $$ = this;
const { state } = $$;
if (!state.canvasSubchartBrushDragging) return false;
$$.updateCanvasSubchartBrush(event);
const shouldClear = state.canvasSubchartBrushMode === "select" && !state.canvasSubchartBrushMoved;
const shouldCallback = state.canvasSubchartBrushMoved || shouldClear;
if (shouldClear) $$.clearCanvasSubchartDomain?.(true, true);
else if (shouldCallback) $$.config.subchart_onbrush.bind($$.api)(state.domain ?? $$.scale.x.orgDomain());
state.canvasSubchartBrushDragging = false;
state.canvasSubchartBrushMode = null;
state.canvasSubchartBrushStart = null;
state.canvasSubchartBrushOrigin = null;
state.canvasSubchartBrushMoved = false;
$$.setDragStatus(false);
event.preventDefault?.();
return true;
},
/**
* Cancel a canvas subchart brush gesture without applying callbacks.
* @param {Event} event Mouse, pointer or touch event
* @returns {boolean} Whether brushing was cancelled
* @private
*/
cancelCanvasSubchartBrush(event) {
const $$ = this;
const { state } = $$;
if (!state.canvasSubchartBrushDragging) return false;
state.canvasSubchartBrushDragging = false;
state.canvasSubchartBrushMode = null;
state.canvasSubchartBrushStart = null;
state.canvasSubchartBrushOrigin = null;
state.canvasSubchartBrushMoved = false;
$$.setDragStatus(false);
event.preventDefault?.();
return true;
},
/**
* Start mouse-driven canvas subchart brushing.
* @param {MouseEvent} event Mouse event
* @returns {boolean} Whether brushing started
* @private
*/
onCanvasSubchartBrushStart(event) {
const $$ = this;
if (!$$.startCanvasSubchartBrush(event)) return false;
const move = (event) => {
$$.updateCanvasSubchartBrush(event);
};
const end = (event) => {
$$.endCanvasSubchartBrush(event);
win.removeEventListener("mousemove", move);
win.removeEventListener("mouseup", end);
};
win.addEventListener("mousemove", move);
win.addEventListener("mouseup", end);
return true;
},
/**
* Handle canvas click and dispatch shape-level data onclick.
* @param {MouseEvent} event Mouse click event
* @private
*/
onCanvasClick(event) {
const $$ = this;
const { config, state } = $$;
state.event = event;
if (state.flowing || state.cancelClick) {
state.cancelClick = false;
return;
}
$$.dispatchCanvasDataClick(event);
config.onclick?.bind($$.api)(event);
},
/**
* Start mouse-driven canvas draggable selection.
* @param {MouseEvent} event Mouse event
* @private
*/
onCanvasSelectionDragStart(event) {
const $$ = this;
const { state } = $$;
if (!$$.startCanvasSelectionDrag(event)) return;
const move = (event) => {
$$.updateCanvasSelectionDrag(event);
};
const end = (event) => {
$$.endCanvasSelectionDrag(event);
win.removeEventListener("mousemove", move);
win.removeEventListener("mouseup", end);
state.canvasSelectionDragMoveHandler = null;
state.canvasSelectionDragEndHandler = null;
};
state.canvasSelectionDragMoveHandler = move;
state.canvasSelectionDragEndHandler = end;
win.addEventListener("mousemove", move);
win.addEventListener("mouseup", end);
},
/**
* Start non-mouse pointer-driven canvas subchart brushing.
* @param {PointerEvent} event Pointer event
* @private
*/
onCanvasPointerDown(event) {
if (!shouldHandleCanvasPointerEvent(this, event)) return;
const $$ = this;
const canvas = $$.$el.canvas.node();
if (!$$.startCanvasSubchartBrush(event)) return;
const pointerId = event.pointerId;
const matchesPointer = (event) => event.pointerId === pointerId;
const move = (event) => {
matchesPointer(event) && $$.updateCanvasSubchartBrush(event);
};
const end = (event) => {
if (matchesPointer(event)) {
$$.endCanvasSubchartBrush(event);
cleanup();
}
};
const cancel = (event) => {
if (matchesPointer(event)) {
$$.cancelCanvasSubchartBrush(event);
cleanup();
}
};
/**
* Remove temporary pointer brush listeners.
* @private
*/
function cleanup() {
win.removeEventListener("pointermove", move);
win.removeEventListener("pointerup", end);
win.removeEventListener("pointercancel", cancel);
try {
canvas.releasePointerCapture?.(pointerId);
} catch {}
}
try {
canvas.setPointerCapture?.(pointerId);
} catch {}
win.addEventListener("pointermove", move);
win.addEventListener("pointerup", end);
win.addEventListener("pointercancel", cancel);
},
/**
* Handle non-mouse pointer entering the canvas.
* @param {PointerEvent} event Pointer event
* @private
*/
onCanvasPointerEnter(event) {
if (!shouldHandleCanvasPointerEvent(this, event)) return;
this.state.event = event;
this.config.onover?.bind(this.api)(event);
},
/**
* Handle non-mouse pointer movement over the canvas.
* @param {PointerEvent} event Pointer event
* @private
*/
onCanvasPointerMove(event) {
if (!shouldHandleCanvasPointerEvent(this, event)) return;
if (this.updateCanvasSubchartBrush(event)) return;
this.onCanvasMouseMove(event);
},
/**
* Handle non-mouse pointer click.
* @param {PointerEvent} event Pointer event
* @private
*/
onCanvasPointerUp(event) {
if (!shouldHandleCanvasPointerEvent(this, event)) return;
this.state.event = event;
if (this.endCanvasSubchartBrush(event)) {
this.config.onout?.bind(this.api)(event);
return;
}
this.dispatchCanvasDataClick(event, true);
this.dispatchCanvasDataOut(this.$el.canvas.node());
},
/**
* Handle non-mouse pointer leaving the canvas.
* @param {PointerEvent} event Pointer event
* @private
*/
onCanvasPointerOut(event) {
if (!shouldHandleCanvasPointerEvent(this, event)) return;
if (this.state.canvasSubchartBrushDragging) return;
this.state.event = event;
this.onCanvasMouseOut();
this.config.onout?.bind(this.api)(event);
},
/**
* Handle non-mouse pointer cancellation.
* @param {PointerEvent} event Pointer event
* @private
*/
onCanvasPointerCancel(event) {
if (!shouldHandleCanvasPointerEvent(this, event)) return;
this.state.event = event;
if (this.cancelCanvasSubchartBrush(event)) {
this.config.onout?.bind(this.api)(event);
return;
}
this.onCanvasPointerOut(event);
},
/**
* Handle touch start over the canvas.
* @param {TouchEvent} event Touch event
* @private
*/
onCanvasTouchStart(event) {
if (event.touches.length > 1) return;
this.state.event = event;
if (this.startCanvasSubchartBrush(event)) return;
if (this.startCanvasSelectionDrag(event)) return;
this.config.onover?.bind(this.api)(event);
this.onCanvasMouseMove(event);
},
/**
* Handle touch move over the canvas.
* @param {TouchEvent} event Touch event
* @private
*/
onCanvasTouchMove(event) {
if (event.touches.length > 1) return;
if (this.updateCanvasSubchartBrush(event)) return;
if (this.updateCanvasSelectionDrag(event)) return;
this.onCanvasMouseMove(event);
},
/**
* Handle touch end over the canvas.
* @param {TouchEvent} event Touch event
* @private
*/
onCanvasTouchEnd(event) {
this.state.event = event;
if (this.endCanvasSubchartBrush(event)) {
this.config.onout?.bind(this.api)(event);
return;
}
if (this.endCanvasSelectionDrag(event)) {
this.config.onout?.bind(this.api)(event);
return;
}
this.dispatchCanvasDataClick(event, true);
this.dispatchCanvasDataOut(this.$el.canvas.node());
this.config.onout?.bind(this.api)(event);
},
/**
* Handle touch cancellation.
* @param {TouchEvent} event Touch event
* @private
*/
onCanvasTouchCancel(event) {
this.state.event = event;
this.cancelCanvasSubchartBrush(event);
this.endCanvasSelectionDrag(event);
this.onCanvasMouseOut();
this.config.onout?.bind(this.api)(event);
},
/**
* Update canvas focus state and tooltip on pointer movement.
* @param {Event} event Mouse, pointer or touch move event
* @private
*/
onCanvasMouseMove(event) {
const $$ = this;
const { config, state, $el } = $$;
const canvas = $el.canvas.node();
state.event = event;
if (state.flowing) return;
if (state.canvasSelectionDragging) return;
if ($$.updateCanvasSubchartCursor?.(event)) {
$$.dispatchCanvasDataOut(canvas);
if (state.canvasFocusKey) {
state.canvasFocusKey = null;
$$.clearCanvasFocus();
}
$$.hideTooltip?.();
return;
}
const rawPoint = getCanvasEventPoint($$, event);
const point = rawPoint && (getCanvasMainPointFromSubchart($$, rawPoint) || rawPoint);
const d = point ? getCanvasHoverDatumFromPoint($$, point) : null;
if (!d) {
$$.dispatchCanvasDataOut(canvas);
if (state.canvasFocusKey) {
state.canvasFocusKey = null;
$$.clearCanvasFocus();
}
if (rawPoint && config.axis_tooltip && isCanvasAxisTooltipArea($$, rawPoint)) {
$$.renderCanvasAxisTooltip(rawPoint);
$$.hideTooltip?.();
return;
}
$$.hideTooltip?.();
return;
}
$$.dispatchCanvasDataOver(d, canvas);
const selectedData = getCanvasTooltipData($$, d);
const focusData = getCanvasFocusData($$, d, selectedData);
const focusKey = focusData.map((v) => `${v.id}:${v.index}`).join("|");
if (state.canvasFocusKey !== focusKey || config.axis_tooltip) {
state.canvasFocusKey = focusKey;
$$.renderCanvasFocus(focusData, point);
}
$$.showTooltip?.(selectedData, canvas);
},
/**
* Clear focus and tooltip state when the pointer leaves the canvas.
* @private
*/
onCanvasMouseOut() {
const canvas = this.$el.canvas.node();
canvas.style.cursor = "";
this.dispatchCanvasDataOut(canvas);
if (this.state.canvasFocusKey) {
this.state.canvasFocusKey = null;
this.clearCanvasFocus();
} else if (this.config.axis_tooltip) this.clearCanvasFocus();
this.hideTooltip?.();
},
/**
* Show canvas tooltip from linked tooltip propagation.
* @param {number} index Data index
* @returns {boolean} Whether linked tooltip was shown
* @private
*/
showCanvasLinkedTooltip(index) {
const $$ = this;
const { $el, state } = $$;
const canvas = $el.canvas?.node();
const d = $$.filterTargetsToShow($$.data.targets).map((target) => target.values[index]).find(Boolean);
if (!canvas || !d) {
$$.hideTooltip?.();
return false;
}
const selectedData = getCanvasTooltipData($$, d);
const focus = selectedData.find((v) => v && hasCanvasDrawableValue($$, v)) || selectedData[0];
if (!selectedData.length || !focus) {
$$.hideTooltip?.();
return false;
}
const { left, top } = getBoundingRect(canvas, true);
const point = getRenderDataPoint($$, focus);
const eventPoint = [state.margin.left + point.x, state.margin.top + point.y];
state.event = {
clientX: left + eventPoint[0],
clientY: top + eventPoint[1],
currentTarget: canvas,
target: canvas
};
state.canvasFocusKey = selectedData.map((v) => `${v.id}:${v.index}`).join("|");
$$.renderCanvasFocus(selectedData, eventPoint);
$$.showTooltip?.(selectedData, canvas);
return true;
},
/**
* Draw drag-zoom brush overlay on the cached canvas frame.
* @param {number} start Start coordinate
* @param {number} end End coordinate
* @private
*/
renderCanvasZoomBrush(start, end) {
const $$ = this;
$$.canvasEngine.withOverlay((ctx) => {
$$.canvasRenderer.withContext(ctx, () => {
$$.canvasRenderer.drawZoomBrush($$, start, end);
});
});
},
/**
* Clear drag-zoom brush overlay.
* @private
*/
clearCanvasZoomBrush() {
this.canvasEngine.clearOverlay();
},
/**
* Draw selection drag overlay on the cached canvas frame.
* @param {object} rect Selection rectangle
* @private
*/
renderCanvasSelectionDrag(rect) {
const $$ = this;
$$.canvasEngine.withOverlay((ctx) => {
$$.canvasRenderer.withContext(ctx, () => {
$$.canvasRenderer.drawSelectionDragArea($$, rect);
});
});
},
/**
* Draw a complete canvas frame and optionally rebuild hit indexes.
* @param {object} shape Cached draw shape object
* @param {Array} focusData Focused data rows
* @param {boolean} rebuildHit Whether to rebuild hit indexes
* @private
*/
renderCanvasFrame(shape, focusData, rebuildHit = false) {
const $$ = this;
const { state } = $$;
const drawShape = shape || state.canvasShape || $$.getDrawShape();
state.canvasShape = drawShape;
state._canvasVisibleRangeCache = null;
state._canvasXTickValuesCache = null;
$$.canvasEngine.beginFrame(state.current.width, $$.getCanvasSurfaceHeight());
try {
$$.canvasRenderer.drawBackground($$);
$$.canvasAxisRenderer.drawTitle($$);
state.hasAxis && $$.canvasAxisRenderer.draw($$);
$$.canvasRenderer.draw($$, drawShape);
state.hasAxis && $$.config.grid_lines_front && $$.canvasAxisRenderer.drawGridLines($$);
$$.canvasRenderer.drawSubchart($$, drawShape);
state.hasAxis && $$.canvasAxisRenderer.drawSubXAxis($$);
state.hasAxis && $$.canvasAxisRenderer.drawSubYAxes($$);
$$.canvasRenderer.drawEmptyLabel($$);
rebuildHit && $$.hitDetector.rebuild($$, drawShape);
} finally {
$$.canvasEngine.endFrame();
}
$$.canvasEngine.clearOverlay();
state.canvasFocusMainRedraw = false;
focusData && $$.renderCanvasFocus(focusData);
},
/**
* Restore the cached frame and draw focus overlays.
* @param {Array} focusData Focused data rows
* @param {Array} point Canvas-local pointer coordinate
* @private
*/
renderCanvasFocus(focusData, point) {
const $$ = this;
const { state } = $$;
const withMainRedraw = $$.canvasRenderer.hasExpandedShapeFocus?.($$, focusData);
if (withMainRedraw) {
const drawShape = state.canvasShape || $$.getDrawShape();
$$.canvasEngine.beginFrame(state.current.width, $$.getCanvasSurfaceHeight());
try {
$$.canvasRenderer.drawBackground($$);
$$.canvasAxisRenderer.drawTitle($$);
state.hasAxis && $$.canvasAxisRenderer.draw($$);
$$.canvasRenderer.draw($$, drawShape, focusData);
state.hasAxis && $$.config.grid_lines_front && $$.canvasAxisRenderer.drawGridLines($$);
$$.canvasRenderer.drawSubchart($$, drawShape);
state.hasAxis && $$.canvasAxisRenderer.drawSubXAxis($$);
state.hasAxis && $$.canvasAxisRenderer.drawSubYAxes($$);
$$.canvasRenderer.drawEmptyLabel($$);
} finally {
$$.canvasEngine.endFrame();
}
}
state.canvasFocusMainRedraw = !!withMainRedraw;
$$.canvasEngine.withOverlay((ctx) => {
$$.canvasRenderer.withContext(ctx, () => {
$$.canvasRenderer.drawFocus($$, focusData);
});
$$.canvasAxisRenderer.withContext(ctx, () => {
$$.canvasAxisRenderer.drawFocusedXAxisTick($$, focusData);
point && $$.canvasAxisRenderer.drawAxisTooltip($$, point);
});
});
},
/**
* Draw only the canvas axis tooltip overlay.
* @param {Array} point Canvas-local pointer coordinate
* @private
*/
renderCanvasAxisTooltip(point) {
const $$ = this;
$$.canvasEngine.withOverlay((ctx) => {
$$.canvasAxisRenderer.withContext(ctx, () => {
$$.canvasAxisRenderer.drawAxisTooltip($$, point);
});
});
},
/**
* Restore the cached canvas frame without focus overlays.
* @private
*/
clearCanvasFocus() {
const $$ = this;
if ($$.state.canvasFocusMainRedraw) {
$$.state.canvasFocusMainRedraw = false;
$$.renderCanvasFrame(void 0, null, false);
} else $$.canvasEngine?.clearOverlay();
},
/**
* Resize canvas surfaces and reload canvas theme values.
* @private
*/
resizeCanvas() {
const $$ = this;
const { config, state, $el } = $$;
const container = $el.chart.node();
$$.clearResizePreview();
$el.chart.style("min-height", `${state.current.height}px`);
$$.canvasEngine?.resize(state.current.width, $$.getCanvasSurfaceHeight());
$$.canvasTheme?.reload(container, config.canvas_theme);
}
};
/**
* Register canvas rendering modules and return canvas render mode.
* @returns {string} Canvas render mode
*/
let canvas = () => {
extend(ChartInternal.prototype, canvasInternal);
return (canvas = () => "canvas")();
};
//#endregion
export { canvas };