billboard.js
Version:
Re-usable easy interface JavaScript chart library, based on D3 v4+
1,767 lines (1,529 loc) • 86.3 kB
text/typescript
/**
* 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> = {
"&": "&",
"<": "<",
">": ">",
"\"": """,
"'": "'"
};
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 = (