common.mjs

/**
 * @module common
 */
import * as colorMod from './color.mjs';


/**
 * Native CSS Color value (browser support dependent)
 *
 * @typedef CSS_Color
 * @type {string}
 * @external
 * @see {@link https://developer.mozilla.org/en-US/docs/Web/CSS/color_value}
 */

/**
 * Coordinate box [top, right, bottom, left]
 *
 * @typedef BoxArray
 * @type {Array<number>}
 * @property {number} 0 - top
 * @property {number} 1 - right
 * @property {number} 2 - bottom
 * @property {number} 3 - left
 */

/**
 * Chart Tooltip positions
 *
 * A string containing horizontal and/or veritical placement hints.
 *
 * @example
 * "left below"
 *
 * @typedef TooltipPosition
 * @type {string}
 * @property {"left"|"center"|"right"|"leftright"} 0 - Horizontal placement hints
 * @property {"above"|"top"|"middle"|"bottom"|"below"} 1 - Vertical placement hints
 */

/**
 * Chart Axis options
 *
 * @typedef AxisOptions
 * @type {object}
 * @property {boolean} [disabled]
 * @property {("left"|"right"|"top"|"bottom")} [position=("left"|"bottom")] - Where to place the axis
 * @property {("inside"|"outside")} [align="outside"] - Alignment axis labels and ticks
 * @property {number} [ticks] - Number of ticks/labels to draw
 * @property {boolean} [showFirst] - Render the first (low) value of the axis
 * @property {number} [tickLength=6] - Size of the tick marks
 * @property {function} [format] - Custom callback function for label values
 */

/**
 * Chart Tooltip options
 *
 * @typedef TooltipOptions
 * @type {object}
 * @property {boolean} [disabled]
 * @property {BoxArray} [padding] - Padding offsets for tooltip box
 * @property {number} [linger=800] - Milliseconds to linger before hiding
 * @property {TooltipPosition} [position="leftright"] - Relative positioning of tooltip with
 *                                                      respect to the pointer
 * @property {function} [format] - Custom callback function for tooltip value
 * @property {function} [formatKey] - Custom callback function for tooltip key
 */

/**
 * Chart type specific normalized data entry
 *
 * @typedef DataEntry
 * @type object
 */

/**
 * Chart data array - Chart specific meaning
 *
 * @typedef ChartData
 * @type {Array<DataEntry|DataValue|DataTuple>}
 */

/**
 * Value only for data entry.  The X position is inferred by array index
 *
 * @typedef DataValue
 * @type number
 */

/**
 * An array with the position/size and value components
 *
 * @typedef DataTuple
 * @type Array<number>
 * @property {number} 0 - position/size
 * @property {number} 1 - value
 */

/**
 * X, Y coordinate values
 *
 * @typedef CoordTuple
 * @type Array<number>
 * @property {number} 0 - X coordinate
 * @property {number} 1 - Y coordinate
 */

/**
 * Coordinate range tuple
 *
 * @typedef CoordRange
 * @type Array<number>
 * @property {number} 0 - start value
 * @property {number} 1 - end value
 */

/**
 * Tooltip position event
 *
 * @event tooltip
 * @type {object}
 * @property {number} [x]
 * @property {number} [y]
 * @property {number} [index]
 * @property {boolean} internal - Was the event triggered internally by pointer events
 * @property {Chart} chart
 */

/**
 * Zoom event
 *
 * @event zoom
 * @type {object}
 * @property {("data"|"visual")} type
 * @property {CoordRange} [xRange]
 * @property {CoordRange} [yRange]
 * @property {CoordTuple} [translate]
 * @property {number} [scale]
 * @property {boolean} internal - Was the event triggered internally by pointer events
 * @property {Chart} chart
 */

const defaultTooltipId = '__default__';
let globalIdCounter = 0;


export const createSVG = _createNodes.bind(
    null, document.createElementNS.bind(document, 'http://www.w3.org/2000/svg'));

export const createHTML = _createNodes.bind(
    null, document.createElement.bind(document));


function _createNodes(createFunction, options) {
    const el = createFunction(options.name);
    if (options) {
        if (options.id) {
            el.id = options.id;
        }
        if (options.class) {
            const classes = Array.isArray(options.class) ? options.class : [options.class];
            el.classList.add(...classes);
        }
        if (options.data) {
            Object.assign(el.dataset, options.data);
        }
        if (options.attrs) {
            for (const [k, v] of Object.entries(options.attrs)) {
                el.setAttribute(k, v);
            }
        }
        if (options.style) {
            for (const [k, v] of Object.entries(options.style)) {
                el.style.setProperty(k, v);
            }
        }
        if (options.children) {
            el.append(...options.children.map(x => _createNodes(createFunction, x)));
        }
    }
    return el;
}


export function getStyleValue(el, key, type) {
    const raw = getComputedStyle(el).getPropertyValue(key);
    if (!type) {
        return raw;
    } else if (type === 'number') {
        return parseFloat(raw);
    } else if (type === 'time') {
        return parseFloat(raw) * (raw.endsWith('ms') ? 1 : 1000);
    }
}


export function requestIdle(cb, timeout=400) {
    if (window.requestIdleCallback) {
        return requestIdleCallback(cb, {timeout});
    } else {
        return setTimeout(cb, timeout / 2);
    }
}


export function cancelIdle(id) {
    if (window.requestIdleCallback) {
        return cancelIdleCallback(id);
    } else {
        return clearTimeout(id);
    }
}


export function lerp(n1, n2, t) {
    return ((1 - t) * n1) + (t * n2);
}


// Ported from https://github.com/joshcarr/largest-triangle-three-buckets.js
// See: https://github.com/sveinn-steinarsson/flot-downsample
function largestTriangleThreeBuckets(inData, outLen) {
    if (!outLen || outLen >= inData.length) {
        return inData;
    }
    const outData = [inData[0]];
    const every = (inData.length - 2) / (outLen - 2);
    let a = 0;
    let nextA;
    let maxAreaPoint;
    for (let i = 0; i < outLen - 2; i++) {
        const bStart = ((i + 1) * every | 0) + 1;
        const bEnd = Math.min(inData.length, ((i + 2) * every | 0) + 1);
        const bFactor = 1 / (bEnd - bStart);
        let avgX = 0, avgY = 0;
        for (let ii = bStart; ii < bEnd; ii++) {
            avgX += inData[ii].x * bFactor;
            avgY += inData[ii].y * bFactor;
        }
        const rangeFrom = ((i + 0) * every | 0) + 1;
        const rangeTo = ((i + 1) * every | 0) + 1;
        const aX = inData[a].x;
        const aY = inData[a].y;
        const aXAvgDist = aX - avgX;
        const ayAvgDist = avgY - aY;
        let maxArea = -1;
        for (let ii = rangeFrom; ii < rangeTo; ii++) {
            const area = Math.abs((aXAvgDist * (inData[ii].y - aY)) - (ayAvgDist * (aX - inData[ii].x)));
            if (area > maxArea) {
                maxArea = area;
                maxAreaPoint = inData[ii];
                nextA = i;
            }
        }
        outData.push(maxAreaPoint);
        a = nextA;
    }
    outData.push(inData[inData.length - 1]);
    return outData;
}


const resample = largestTriangleThreeBuckets;


/**
 * @typedef ChartOptions
 * @type {object}
 * @property {Element} [el] - DOM Element to insert chart into
 * @property {Chart} [parent] - Make this chart a child chart that shares one svg element
 * @property {ChartData} [data] - Initial data to use for chart rendering
 * @property {number} [xMin] - Minimum X data value
 * @property {number} [xMax] - Maximum X data value
 * @property {number} [yMin] - Minimum Y data value
 * @property {number} [yMax] - Maximum Y data value
 * @property {string} [title] - Visually displayed title of chart
 * @property {external:CSS_Color} [color] - The CSS color basis for this chart's data
 * @property {BoxArray} [padding] - Plot padding in DPI adjusted coordinates.
 * @property {number} [width] - Fixed width of plot
 * @property {number} [height] - Fixed height of plot
 * @property {TooltipOptions} [tooltip] - Tooltip options
 * @property {AxisOptions} [xAxis] - X axis options
 * @property {AxisOptions} [yAxis] - Y axis options
 * @property {boolean} [disableAnimation] - Disable all animation/transitions
 * @property {boolean} [darkMode] - Force use of darkmode
 */


/**
 * Base class for charts subclasses.
 *
 * @abstract
 * @extends {EventTarget}
 * @param {ChartOptions} [options] - Common chart options
 * @emits zoom
 * @emits tooltip
 */
export class Chart extends EventTarget {

    resampleThreshold = 1.5;
    resampleTarget = 1.0;

    constructor(options={}) {
        super();
        this.init(options);
        this.id = globalIdCounter++;
        this.xMin = options.xMin;
        this.xMax = options.xMax;
        this.yMin = options.yMin;
        this.yMax = options.yMax;
        this.title = options.title;
        this.color = options.color;
        this.width = options.width;
        this.height = options.height;
        this.parent = options.parent;
        this.isRoot = !this.parent;
        this._tooltipViews = new Map();
        this._tooltips = new Map();
        this.xAxis = options.xAxis ?? {};
        this.yAxis = options.yAxis ?? {};
        this.padding = options.padding;
        if (!this.padding) {
            const defPad = 4;
            const hAxisPad = 20;
            const vAxisPad = 40;
            this.padding = [defPad, defPad, defPad, defPad];
            if (!this.xAxis.disabled && this.xAxis.align !== 'inside') {
                if (this.xAxis.position !== 'top') {
                    this.padding[2] += hAxisPad;
                } else {
                    this.padding[0] += hAxisPad;
                }
            }
            if (!this.yAxis.disabled && this.yAxis.align !== 'inside') {
                if (this.yAxis.position !== 'right') {
                    this.padding[3] += vAxisPad;
                } else {
                    this.padding[1] += vAxisPad;
                }
            }
        }
        this.disableAnimation = options.disableAnimation;
        this.darkMode = options.darkMode;
        this.childCharts = [];
        this._zoomState = {rev: 0};
        this._gradients = new Set();
        this._onPointerEnterBound = this.onPointerEnter.bind(this);
        this._resizeObserver = new ResizeObserver(this.onResize.bind(this));
        if (options.el) {
            if (options.parent) {
                throw new Error("`parent` and `el` options are mutually exclusive");
            }
            this.setElement(options.el);
        } else if (options.parent) {
            options.parent.addChart(this);
        }
        if (this.isRoot) {
            addEventListener('scroll', ev => {
                for (const view of this._tooltipViews.values()) {
                    let rect;
                    if (view.state.visible) {
                        if (!rect) {
                            rect = this.el.getBoundingClientRect();
                        }
                        view.state.elOffset = [rect.x, rect.y];
                        this._updateTooltipView(view, {disableAnimation: true});
                    }
                }
            }, {passive: true, capture: true});
        }
        const tooltipOptions = options.tooltip ?? {};
        if (!tooltipOptions.disabled) {
            this.addTooltip(defaultTooltipId, {pointerEvents: true, ...tooltipOptions});
        }
        if (options.data) {
            this.setData(options.data);
        }
    }

    /**
     * @protected
     * @param {ChartOptions} options
     */
    init(options) {
    }

    /**
     * Add a color gradient which can be used in SVG contexts
     *
     * @param {(module:color.Gradient|module:color~GradientOptions)} gradient
     * @returns {module:color.Gradient}
     */
    addGradient(gradient) {
        if (!(gradient instanceof colorMod.Gradient)) {
            gradient = colorMod.Gradient.fromObject(gradient);
        }
        gradient.render();
        this._gradients.add(gradient);
        this._defsEl.append(gradient.el);
        return gradient;
    }

    /**
     * @param {module:color.Gradient} gradient
     */
    removeGradient(gradient) {
        this._gradients.delete(gradient);
        gradient.el.remove();
        gradient.el = null;
    }

    onResize(entries) {
        for (let i = 0; i < entries.length; i++) {
            const x = entries[i];
            if (x.target.classList.contains('sc-tooltip-box')) {
                requestAnimationFrame(() => this._onResizeTooltip(x));
            } else if (x.target === this.el) {
                requestAnimationFrame(() => this._onResizeContainer(x));
            }
        }
    }

    _onResizeTooltip(resize) {
        const minChange = 10;
        const courseWidth = Math.ceil(resize.borderBoxSize[0].inlineSize / minChange) * minChange;
        resize.target._positioner.style.setProperty('--course-width', `${courseWidth}px`);
    }

    _onResizeContainer(resize) {
        const hasAnim = !this.el.classList.contains('sc-disable-animation');
        if (hasAnim) {
            this.el.classList.add('sc-disable-animation');
        }
        try {
            this.adjustSize(resize.contentRect.width, resize.contentRect.height);
            this.render({disableAnimation: true});
        } finally {
            if (hasAnim) {
                this.el.offsetWidth;
                this.el.classList.remove('sc-disable-animation');
            }
        }
    }

    /**
     * @protected
     */
    adjustSize(boxWidth, boxHeight) {
        this.devicePixelRatio = devicePixelRatio || 1;
        if (boxWidth === undefined) {
            ({width: boxWidth, height: boxHeight} = this._rootSvgEl.getBoundingClientRect());
        }
        const allCharts = this.getAllCharts();
        if (allCharts.every(x => x.width != null)) {
            boxWidth = Math.max(...allCharts.map(x => x.padding[3] + x.width + x.padding[1]));
        }
        if (allCharts.every(x => x.height != null)) {
            boxHeight = Math.max(...allCharts.map(x => x.padding[0] + x.height + x.padding[2]));
        }
        if (!boxWidth || !boxHeight) {
            this._boxWidth = 0;
            this._boxHeight = 0;
            this._plotWidth = 0;
            this._plotHeight = 0;
            this._plotBox = [0, 0, 0, 0];
            return;
        }
        this._boxWidth = Math.round(boxWidth * this.devicePixelRatio);
        this._boxHeight = Math.round(boxHeight * this.devicePixelRatio);
        const inset = this.padding.map(x => x * this.devicePixelRatio);
        this._plotWidth = Math.max(0, this.width ?
            Math.round(this.width * this.devicePixelRatio) :
            this._boxWidth - inset[3] - inset[1]);
        this._plotHeight = Math.max(0, this.height ?
            Math.round(this.height * this.devicePixelRatio) :
            this._boxHeight - inset[0] - inset[2]);
        this._plotBox = [
            inset[0],
            this._plotWidth + inset[3],
            this._plotHeight + inset[0],
            inset[3]
        ];
        const plotStyle = this._plotRegionEl.style;
        plotStyle.setProperty('--plot-box-top', `${this._plotBox[0]}px`);
        plotStyle.setProperty('--plot-box-right', `${this._plotBox[1]}px`);
        plotStyle.setProperty('--plot-box-bottom', `${this._plotBox[2]}px`);
        plotStyle.setProperty('--plot-box-left', `${this._plotBox[3]}px`);
        plotStyle.setProperty('--plot-width', `${this._plotWidth}px`);
        plotStyle.setProperty('--plot-height', `${this._plotHeight}px`);
        if (this.isRoot) {
            this._rootSvgEl.setAttribute('viewBox', `0 0 ${this._boxWidth} ${this._boxHeight}`);
            this.el.style.setProperty('--dpr', this.devicePixelRatio);
        }
        for (const view of this._tooltipViews.values()) {
            if (view.state.visible) {
                this._establishTooltipViewState(view);
            }
        }
    }

    _drawXAxis() {
        this._drawAxis('horizontal', this._xAxisEl, this.xAxis);
    }

    _drawYAxis() {
        this._drawAxis('vertical', this._yAxisEl, this.yAxis);
    }

    _drawAxis(orientation, el, options) {
        const vert = orientation === 'vertical';
        const baseline = el.querySelector('line.sc-baseline');
        const [top, right, bottom, left] = this._plotBox;
        const inside = (options.align ?? 'outside') === 'inside';
        let position;
        if (vert) {
            position = options.position ?? 'left';
            el.classList.toggle('sc-right', position === 'right');
            baseline.setAttribute('x1', position === 'right' ? right : left);
            baseline.setAttribute('x2', position === 'right' ? right : left);
            baseline.setAttribute('y1', top);
            baseline.setAttribute('y2', bottom);
        } else {
            position = options.position ?? 'bottom';
            el.classList.toggle('sc-top', position !== 'bottom');
            baseline.setAttribute('x1', left);
            baseline.setAttribute('x2', right);
            baseline.setAttribute('y1', position === 'bottom' ? bottom : top);
            baseline.setAttribute('y2', position === 'bottom' ? bottom : top);
        }
        el.classList.toggle('sc-inside', inside);
        let ticks = options.ticks;
        const trackLength = vert ? this._plotHeight : this._plotWidth;
        if (ticks == null) {
            ticks = 1 + Math.floor((trackLength / devicePixelRatio) / (vert ? 100 : 200));
        }
        const tickLen = options.tickLength ?? 6;
        const existingTicks = el.querySelectorAll('line.sc-tick');
        const existingLabels = el.querySelectorAll('text.sc-label');
        let visualCount = 0;
        const steps = options.showFirst ? ticks : ticks + 1;
        const gap = trackLength / (steps - 1);
        for (let i = options.showFirst ? 0 : 1; i < steps; i++) {
            let x1, x2, y1, y2;
            if (vert) {
                x1 = position === 'right' ? right : left;
                if (inside) {
                    x2 = x1 + tickLen * (position === 'right' ? -1 : 1);
                } else {
                    x2 = x1 - tickLen * (position === 'right' ? -1 : 1);
                }
                y1 = y2 = bottom - i * gap;
            } else {
                x1 = x2 = left + i * gap;
                y1 = position === 'bottom' ? bottom : top;
                if ((inside && position === 'bottom') || (!inside && position !== 'bottom')) {
                    y2 = y1 - tickLen;
                } else {
                    y2 = y1 + tickLen;
                }
            }
            let tick = existingTicks[visualCount];
            if (!tick) {
                tick = createSVG({name: 'line', class: 'sc-tick'});
                el.append(tick);
            }
            tick.setAttribute('x1', x1);
            tick.setAttribute('x2', x2);
            tick.setAttribute('y1', y1);
            tick.setAttribute('y2', y2);
            let label = existingLabels[visualCount];
            if (!label) {
                label = createSVG({name: 'text', class: 'sc-label'});
                el.append(label);
            }
            const percent = i / (steps - 1);
            label.setAttribute('x', x1);
            label.setAttribute('y', y1);
            label.setAttribute('data-percent', percent);
            label.textContent = this.onAxisLabel({
                orientation,
                percent,
                format: options.format,
            });
            visualCount++;
        }
        for (let i = visualCount; i < existingTicks.length; i++) {
            existingTicks[i].remove();
        }
        for (let i = visualCount; i < existingLabels.length; i++) {
            existingLabels[i].remove();
        }
    }

    /**
     * Set the DOM element to be used by this chart
     *
     * @param {Element} el
     */
    setElement(el) {
        this.beforeSetElement(el);
        const old = this.el !== el ? this.el : null;
        this.el = el;
        if (old) {
            this.doReset();
        }
        this._resizeObserver.disconnect();
        if (this.isRoot) {
            if (old) {
                old.removeEventListener('pointerenter', this._onPointerEnterBound);
                if (this._plotRegionEl) {
                    this._plotRegionEl.remove();
                    this._plotRegionEl = null;
                }
                if (this._xAxisEl) {
                    this._xAxisEl.remove();
                    this._xAxisEl = null;
                }
                if (this._yAxisEl) {
                    this._yAxisEl.remove();
                    this._yAxisEl = null;
                }
            }
            el.classList.add('saucechart', 'sc-wrap');
            el.classList.toggle('sc-disable-animation', !!this.disableAnimation);
            let darkMode = this.darkMode;
            if (darkMode === undefined) {
                const c = colorMod.parse(getStyleValue(el, 'color'));
                darkMode = c.l >= 0.5;
            }
            this.el.classList.toggle('sc-darkmode', darkMode);
            const svg = createSVG({
                name: 'svg',
                class: 'sc-root',
                attrs: {
                    version: '1.1',
                    preserveAspectRatio: 'none',
                },
                children: [{
                    name: 'defs'
                }, {
                    name: 'g',
                    class: 'sc-plot-regions',
                }]
            });
            const tooltips = createHTML({
                name: 'div',
                class: 'sc-tooltips',
            });
            el.replaceChildren(svg, tooltips);
        }
        this._rootSvgEl = el.querySelector('svg.sc-root');
        this._defsEl = this._rootSvgEl.querySelector(':scope > defs');
        const plotRegionsEl = this._rootSvgEl.querySelector('.sc-plot-regions');
        this._plotRegionEl = createSVG({name: 'g', class: 'sc-plot-region', data: {id: this.id}});
        if (this.color) {
            this._plotRegionEl.style.setProperty('--color', this.color);
            this._computedColor = null;
        }
        if (this.title) {
            const titleEl = createSVG({
                name: 'text',
                class: 'sc-title',
                x: 0,
                y: 10,
            });
            titleEl.textContent = this.title;
            this._plotRegionEl.append(titleEl);
        }
        plotRegionsEl.append(this._plotRegionEl);
        if (!this.xAxis.disabled) {
            this._xAxisEl = createSVG({
                name: 'g',
                class: ['sc-axis', 'sc-x-axis'],
                children: [{name: 'line', class: 'sc-baseline'}]
            });
            this._rootSvgEl.append(this._xAxisEl);
        }
        if (!this.yAxis.disabled) {
            this._yAxisEl = createSVG({
                name: 'g',
                class: ['sc-axis', 'sc-y-axis'],
                children: [{name: 'line', class: 'sc-baseline'}]
            });
            this._rootSvgEl.append(this._yAxisEl);
        }
        if (this.isRoot) {
            const tooltipsEl = el.querySelector('.sc-tooltips');
            for (const view of this._tooltipViews.values()) {
                tooltipsEl.append(view.positioner);
                plotRegionsEl.after(view.graphics);
                this._resizeObserver.observe(view.box);
            }
            for (const x of this.childCharts) {
                x.setElement(el);
            }
            el.addEventListener('pointerenter', this._onPointerEnterBound);
        }
        this._resizeObserver.observe(el);
        this.afterSetElement(el);
        this.adjustSize();
        this.render();
    }

    /**
     * @protected
     */
    beforeSetElement() {}

    /**
     * @protected
     */
    afterSetElement() {}

    /**
     * Retrieve the computed CSS color for this chart
     *
     * @returns {external:CSS_Color}
     */
    getColor() {
        if (!this._computedColor) {
            this._computedColor = getStyleValue(this._plotRegionEl, '--color');
        }
        return this._computedColor;
    }

    /**
     * @protected
     * @param {Chart} chart
     */
    addChart(chart) {
        if (this.parent) {
            throw new TypeError("only valid on parent");
        }
        if (this.childCharts.indexOf(chart) !== -1) {
            throw new Error("Chart already present");
        }
        this.childCharts.push(chart);
        chart.parent = this;
        for (const x of this.childCharts) {
            x._computedColor = null;
        }
        this._computedColor = null;
        if (this.el) {
            chart.setElement(this.el);
        }
    }

    /**
     * @returns {Array<Chart>} All the charts sharing this chart's `el` property
     */
    getAllCharts() {
        const root = this.parent ?? this;
        return [root, ...root.childCharts];
    }

    /**
     * Add a new tooltip to this chart.
     *
     * @param {string} id - Identifier for this tooltip
     * @param {TooltipOptions} - Options for this tooltip
     */
    addTooltip(id, options={}) {
        if (this._tooltips.has(id)) {
            throw new Error('Tooltip already present');
        }
        const tooltip = {
            id,
            chart: this,
            options: {
                format: options.format,
                formatKey: options.formatKey,
            },
            ephemeral: new Map(),
        };
        const root = this.parent ?? this;
        let view = root._tooltipViews.get(id);
        if (!view) {
            const positioner = createHTML({
                name: 'div',
                class: 'sc-tooltip-positioner',
                data: {id},
                children: [{
                    name: 'div',
                    class: 'sc-tooltip-box-wrap',
                    children: [{
                        name: 'div',
                        class: 'sc-tooltip-box',
                    }]
                }]
            });
            const box = positioner.querySelector('.sc-tooltip-box');
            box._positioner = positioner;
            const graphics = createSVG({name: 'g', class: 'sc-tooltip-graphics', data: {id}});
            if (root.el) {
                root.el.querySelector('.sc-tooltips').append(positioner);
                root._rootSvgEl.querySelector('.sc-plot-regions').after(graphics);
                root._resizeObserver.observe(box);
            }
            view = {
                id,
                positioner,
                box,
                graphics,
                options: {
                    padding: [0, 0, 0, 0],
                    position: 'leftright',
                    linger: 800,
                },
                tooltips: new Set(),
                state: {suspendRefCnt: 0},
            };
            root._tooltipViews.set(id, view);
        }
        if (options.padding != null) {
            view.options.padding = options.padding;
        }
        if (options.position != null) {
            view.options.position = options.position;
        }
        if (options.linger != null) {
            view.options.linger = options.linger;
        }
        if (options.pointerEvents != null) {
            view.options.pointerEvents = options.pointerEvents;
        }
        tooltip.view = view;
        view.tooltips.add(tooltip);
        this._tooltips.set(id, tooltip);
    }

    /**
     * Remove a tooltip from this chart
     */
    removeTooltip(id) {
        const tooltip = this._tooltips.get(id);
        if (!tooltip) {
            throw new Error('Tooltip not found');
        }
        this._tooltips.delete(id);
        for (const x of tooltip.ephemeral.values()) {
            x.remove();
        }
        tooltip.ephemeral.clear();
        tooltip.view.tooltips.delete(tooltip);
        if (!tooltip.view.tooltips.size) {
            tooltip.view.positioner.remove();
            tooltip.view.graphics.remove();
            const root = this.parent ?? this;
            root._tooltipViews.delete(id);
        }
    }

    localeNumber(value) {
        let localeConfig;
        if (value % 1 && value < 1e4) {
            localeConfig = {
                maximumFractionDigits: 2,
                minimumFractionDigits: 2,
                useGrouping: 'min2',
            };
        } else if (value < 100) {
            localeConfig = {maximumFractionDigits: 1};
        } else {
            localeConfig = {
                useGrouping: 'min2',
                maximumFractionDigits: 0
            };
        }
        return value.toLocaleString(undefined, localeConfig);
    }

    /**
     * The default tooltip formatter
     *
     * @protected
     * @param {object} options
     * @param {DataEntry} options.entry - Data Entry
     * @param {object} options.tooltip - Tooltip
     * @returns {Element} Tooltip contents
     */
    onTooltip({entry, tooltip}) {
        let entryEl = tooltip.ephemeral.get(`entry-${this.id}`);
        let keyEl, valueEl;
        if (!entryEl) {
            entryEl = document.createElement('div');
            entryEl.className = 'sc-tooltip-entry';
            entryEl.dataset.chartId = this.id;
            keyEl = document.createElement('key');
            valueEl = document.createElement('value');
            tooltip.ephemeral.set(`key-${this.id}`, keyEl);
            tooltip.ephemeral.set(`value-${this.id}`, valueEl);
            entryEl.append(keyEl, valueEl);
        } else {
            keyEl = tooltip.ephemeral.get(`key-${this.id}`);
            valueEl = tooltip.ephemeral.get(`value-${this.id}`);
        }
        entryEl.style.setProperty('--color', entry.color ?? this.getColor());
        let key, value;
        if (tooltip.options.formatKey) {
            key = tooltip.options.formatKey({
                value: entry.x,
                index: entry.index,
                entry,
                chart: this
            });
        } else if (!isNaN(entry.x) && entry.x !== null) {
            if (this.xAxis.format) {
                key = this.xAxis.format({value: entry.x, chart: this});
            } else {
                key = this.localeNumber(entry.x);
            }
        }
        if (tooltip.options.format) {
            value = tooltip.options.format({
                value: entry.y,
                index: entry.index,
                entry,
                chart: this
            });
        } else if (!isNaN(entry.y) && entry.y !== null) {
            if (this.yAxis.format) {
                value = this.yAxis.format({value: entry.y, chart: this});
            } else {
                value = this.localeNumber(entry.y);
            }
        }
        if (key != null && value != null) {
            keyEl.replaceChildren(key);
            valueEl.replaceChildren(value);
            return entryEl;
        }
    }

    /**
     * The default Axis Label formatter
     *
     * @protected
     * @param {object} options
     * @param {string} options.orientation - "vertical" or "horizontal" orientation
     * @param {number} options.percent - Normalized percentage of label, i.e. 0 -> 1
     * @param {function} [options.format] - Optional format callback
     * @returns {string} Label contents
     */
    onAxisLabel({orientation, percent, format}) {
        let range;
        let start;
        if (orientation === 'vertical') {
            start = this._yMin;
            range = this._yMax - this._yMin;
        } else {
            start = this._xMin;
            range = this._xMax - this._xMin;
        }
        const value = start + (range * percent);
        if (isNaN(value) || value === null) {
            return '';
        }
        if (format) {
            return format({value, chart: this});
        } else {
            let localeConfig;
            if (range <= 1) {
                localeConfig = {
                    maximumFractionDigits: 2,
                    minimumFractionDigits: 2
                };
            } else if (range < 100) {
                localeConfig = {maximumFractionDigits: 1};
            } else {
                localeConfig = {
                    useGrouping: 'min2',
                    maximumFractionDigits: 0
                };
            }
            return value.toLocaleString(undefined, localeConfig);
        }
    }

    onPointerEnter(ev) {
        for (const view of this._tooltipViews.values()) {
            if (view.options.pointerEvents) {
                this._onPointerEnter(view, ev);
            }
        }
    }

    _onPointerEnter(tooltipView, ev) {
        if (!this._isTooltipViewAvailable(tooltipView) ||
            this._isTooltipViewPointing(tooltipView) ||
            !this._renderData ||
            !this._renderData.length) {
            return;
        }
        const state = this._establishTooltipViewState(tooltipView);
        const pointerAborter = state.pointerAborter = new AbortController();
        const signal = pointerAborter.signal;
        signal.addEventListener('abort', () => {
            setTimeout(() => {
                if (tooltipView.state.pointerAborter === pointerAborter) {
                    this._hideTooltipView(tooltipView);
                }
            }, tooltipView.options.linger);
            this.dispatchEvent(new CustomEvent('tooltip', {
                detail: {internal: true, chart: this}
            }));
        });
        // Cancel-esc pointer events are sloppy and unreliable (proven).  Kitchen sink...
        addEventListener('pointercancel', () => pointerAborter.abort(), {signal});
        addEventListener('pointerout', ev => !this.el.contains(ev.target) && pointerAborter.abort(),
                         {signal});
        this.el.addEventListener('pointerleave', () => pointerAborter.abort(), {signal});
        let af;
        this.el.addEventListener('pointermove', ev => {
            cancelAnimationFrame(af);
            const x = (ev.x - state.elOffset[0]) * this.devicePixelRatio;
            af = requestAnimationFrame(() => {
                if (!pointerAborter.signal.aborted) {
                    this._setTooltipViewPosition(tooltipView, {x, disableAnimation: true, internal: true});
                }
            });
        }, {signal});
        const x = (ev.x - state.elOffset[0]) * this.devicePixelRatio;
        this._setTooltipViewPosition(tooltipView, {x, disableAnimation: true, internal: true});
        this._showTooltipView(tooltipView);
    }

    /**
     * Hide tooltip (if visible)
     *
     * @param {string} [id] - ID of the tooltip to hide
     */
    hideTooltip(id=defaultTooltipId) {
        return this._hideTooltipView(this._tooltips.get(id).view);
    }

    _hideTooltipView(view) {
        const state = view.state;
        if (state.pointerAborter && !state.pointerAborter.signal.aborted) {
            state.pointerAborter.abort();
        }
        if (!state.visible) {
            return;
        }
        view.positioner.classList.remove('sc-active');
        view.graphics.classList.remove('sc-active');
        state.visible = false;
    }

    /**
     * Show tooltip (if available)
     *
     * @param {string} [id] - ID of the tooltip to show
     */
    showTooltip(id=defaultTooltipId) {
        const tooltip = this._tooltips.get(id);
        if (!tooltip) {
            throw new Error('Tooltip not found');
        }
        return this._showTooltipView(tooltip.view);
    }

    _showTooltipView(view) {
        if (!this._isTooltipViewAvailable(view) || view.state.visible) {
            return;
        }
        const state = this._establishTooltipViewState(view);
        const hasAnim = !view.positioner.classList.contains('sc-disable-animation') &&
            !this.el.classList.contains('sc-disable-animation');
        if (hasAnim) {
            view.positioner.classList.add('sc-disable-animation');
        }
        view.positioner.classList.add('sc-active');
        view.graphics.classList.add('sc-active');
        state.visible = true;
        if (hasAnim) {
            view.positioner.offsetWidth;
            view.positioner.classList.remove('sc-disable-animation');
        }
    }

    /**
     * Increment the tooltip's suspend reference count by 1.
     * While the tooltip suspend reference count is > 0 the tooltip will not be
     * available and thus won't be visible.
     *
     * @param {string} [id] - ID of the tooltip to suspend
     */
    suspendTooltip(id=defaultTooltipId) {
        this._suspendTooltipView(this._tooltips.get(id).view);
    }

    _suspendTooltipView(view) {
        view.state.suspendRefCnt++;
        if (view.state.visible) {
            this._hideTooltipView(view);
        }
    }

    /**
     * Decrement the tooltip's suspend reference count by 1
     *
     * @param {string} [id] - ID of the tooltip to suspend
     */
    resumeTooltip(id=defaultTooltipId) {
        this._resumeTooltipView(this._tooltips.get(id).view);
    }

    _resumeTooltipView(view) {
        if (view.state.suspendRefCnt === 0) {
            throw new Error("not suspended");
        }
        view.state.suspendRefCnt--;
    }

    /**
     * @returns {boolean} Is the tooltip suspended or disabled
     *
     * @param {string} [id] - ID of the tooltip to check
     */
    isTooltipAvailable(id=defaultTooltipId) {
        return this._isTooltipViewAvailable(this._tooltips.get(id).view);
    }

    _isTooltipViewAvailable(view) {
        return view.state.suspendRefCnt === 0;
    }

    /**
     * @returns {boolean} Is the tooltip actively handling pointer events
     */
    isTooltipPointing(id=defaultTooltipId) {
        return this._isTooltipViewPointing(this._tooltips.get(id).view);
    }

    _isTooltipViewPointing(view) {
        return !!(
            view.state.visible &&
            view.state.pointerAborter &&
            !view.state.pointerAborter.signal.aborted
        );
    }

    _establishTooltipViewState(view) {
        let positionCallback, hAlign, vAlign;
        if (typeof view.options.position === 'function') {
            positionCallback = view.opptions.position;
        } else {
            let tp = view.options.position;
            if (typeof tp === 'string') {
                tp = tp.split(/\s+/);
            }
            hAlign = tp.find(x => ['left', 'center', 'right', 'leftright'].includes(x));
            vAlign = tp.find(x => ['above', 'top', 'middle', 'bottom', 'below'].includes(x)) || 'top';
            if (vAlign && !hAlign) {
                hAlign = {
                    below: 'center',
                    above: 'center',
                }[vAlign] || 'leftright';
            }
        }
        const rect = this.el.getBoundingClientRect();
        Object.assign(view.state, {
            elOffset: [rect.x, rect.y],
            positionCallback,
            hAlign,
            vAlign,
            lastDrawSig: undefined,
            hasDrawn: false,
        });
        return view.state;
    }

    /**
     * Place the tooltip at specific data value coordinates or by data index
     *
     * @param {object} options
     * @param {number} [options.x]
     * @param {number} [options.y]
     * @param {number} [options.index] - Data index
     * @param {string} [id] - ID of the tooltip to position
     */
    setTooltipPosition(options, id=defaultTooltipId) {
        const tooltip = this._tooltips.get(id);
        if (!tooltip) {
            throw new Error('Tooltip not found');
        }
        if (!tooltip.view.state.visible) {
            this._establishTooltipViewState(tooltip.view);
        }
        return this._setTooltipViewPosition(tooltip.view, options);
    }

    _setTooltipViewPosition(view, {x, y, index, disableAnimation, internal=false}) {
        Object.assign(view.state, {x, y, index});
        this._updateTooltipView(view, {disableAnimation});
        queueMicrotask(() => this.dispatchEvent(new CustomEvent('tooltip', {
            detail: {
                id: view.id,
                x,
                y,
                index,
                internal,
                chart: this,
            }
        })));
    }

    /**
     * @protected
     */
    updateVisibleTooltips(options) {
        // XXX use requestAnimationFrame to debounce this with multiple charts
        const root = this.parent ?? this;
        for (const view of root._tooltipViews.values()) {
            if (view.state.visible && this._isTooltipViewAvailable(view)) {
                this._updateTooltipView(view, options);
            }
        }
    }

    _updateTooltipView(view, options={}) {
        const contents = [];
        const state = view.state;
        let drawSig = state.elOffset.join();
        for (const tooltip of view.tooltips) {
            const chart = tooltip.chart;
            let xRef = state.index != null ?
                chart.xValueToCoord(chart.normalizedData?.[state.index]?.x) :
                state.x;
            if (isNaN(xRef)) {
                continue;
            }
            if (xRef >= chart._plotBox[1]) {
                xRef = chart._plotBox[1] - 1e-6;
            } else if (xRef <= chart._plotBox[3]) {
                xRef = chart._plotBox[3] + 1e-6;
            }
            const entry = chart.findNearestFromXCoord(xRef);
            if (entry === undefined || entry.x < chart._xMin || entry.x > chart._xMax) {
                continue;
            }
            const element = chart.onTooltip({entry, tooltip});
            if (element) {
                const coordinates = [chart.xValueToCoord(entry.x), chart.yValueToCoord(entry.y)];
                contents.push({chart, entry, coordinates, element});
                drawSig += ` ${chart.id} ${entry.index} ${coordinates[0]} ${coordinates[1]}`;
            }
        }
        if (drawSig !== state.lastDrawSig) {
            state.lastDrawSig = drawSig;
            const disableAnim = (options.disableAnimation || !state.hasDrawn) &&
                (!view.positioner.classList.contains('sc-disable-animation') &&
                 !this.el.classList.contains('sc-disable-animation'));
            if (disableAnim) {
                view.positioner.classList.add('sc-disable-animation');
                view.graphics.classList.add('sc-disable-animation');
            }
            try {
                this._drawTooltipView(view, contents);
            } finally {
                if (disableAnim) {
                    view.positioner.offsetWidth;
                    view.positioner.classList.remove('sc-disable-animation');
                    view.graphics.classList.remove('sc-disable-animation');
                }
            }
        }
    }

    _drawTooltipView(view, contents) {
        if (!contents.length) {
            view.box.replaceChildren();
            return;
        }
        const state = view.state;
        let centerX = 0;
        let top = Infinity;
        let bottom = -Infinity;
        for (let i = 0; i < contents.length; i++) {
            const {chart, coordinates} = contents[i];
            centerX += coordinates[0];
            const t = chart._plotBox[0];
            const b = chart._plotBox[2];
            if (t < top) {
                top = t;
            }
            if (b > bottom) {
                bottom = b;
            }
        }
        centerX /= contents.length;
        const centerY = top + (bottom - top) / 2;
        if (!view.graphics.childNodes.length) {
            const line = createSVG({
                name: 'path',
                class: ['sc-line', 'sc-vertical']
            });
            view.graphics.append(line);
        }
        const vertLine = view.graphics.childNodes[0];
        const existingHLines = view.graphics.querySelectorAll('.sc-line.sc-horizontal');
        const existingDots = view.graphics.querySelectorAll('circle.sc-highlight-dot');
        let minX = this._boxWidth;
        let minY = this._boxHeight;
        let maxX = 0;
        let maxY = 0;
        let hLinesCount = 0;
        for (let i = 0; i < contents.length; i++) {
            const [x, y] = contents[i].coordinates;
            let dot = existingDots[i];
            if (!dot) {
                dot = createSVG({name: 'circle', class: 'sc-highlight-dot'});
                view.graphics.append(dot);
            }
            dot.setAttribute('cx', x);
            dot.setAttribute('cy', y);
            if (x > maxX) {
                maxX = x;
            }
            if (x < minX) {
                minX = x;
            }
            if (y > maxY) {
                maxY = y;
            }
            if (y < minY) {
                minY = y;
            }
            if (Math.abs(x - centerX) > 1) {
                let horizLine = existingHLines[hLinesCount];
                if (!horizLine) {
                    horizLine = createSVG({name: 'path', class: ['sc-line', 'sc-horizontal']});
                    vertLine.after(horizLine);
                }
                horizLine.setAttribute(
                    'd', `M ${x}, ${y} L ${centerX}, ${Math.min(bottom, Math.max(y, top))}`);
                hLinesCount++;
            }
        }
        for (let i = hLinesCount; i < existingHLines.length; i++) {
            existingHLines[i].remove();
        }
        for (let i = contents.length; i < existingDots.length; i++) {
            existingDots[i].remove();
        }
        let vAlign, hAlign;
        if (state.positionCallback) {
            [vAlign, hAlign] = state.positionCallback({
                id: view.id,
                chart: this,
                minX,
                minY,
                maxX,
                maxY,
                contents,
            });
        } else {
            hAlign = state.hAlign === 'leftright' ?
                (centerX >= this._boxWidth * 0.5 ? 'left' : 'right') :
                state.hAlign;
            vAlign = state.vAlign;
        }
        vertLine.setAttribute('d', `M ${centerX}, ${bottom} V ${top}`);
        view.box.replaceChildren(...contents.map(x => x.element));
        view.positioner.dataset.hAlign = hAlign;
        view.positioner.dataset.vAlign = vAlign;
        const f = 1 / this.devicePixelRatio;
        const [offtX, offtY] = state.elOffset;
        view.positioner.style.setProperty('--x-left', `${minX * f + offtX}px`);
        view.positioner.style.setProperty('--x-right', `${maxX * f + offtX}px`);
        view.positioner.style.setProperty('--x-center', `${centerX * f + offtX}px`);
        view.positioner.style.setProperty('--y-center', `${centerY * f + offtY}px`);
        view.positioner.style.setProperty('--y-top', `${top * f + offtY}px`);
        view.positioner.style.setProperty('--y-bottom', `${bottom * f + offtY}px`);
        state.hasDrawn = true;
    }

    /**
     * Binary search for nearest data entry using an X coordinate
     *
     * @param {number} coord - X coord
     * @param {Array<DataEntry>} [data=this._renderData]
     * @returns {DataEntry}
     */
    findNearestFromXCoord(coord, data=this._renderData) {
        const index = this.findNearestIndexFromXCoord(coord, data);
        return index !== undefined ? data[index] : undefined;
    }

    /**
     * Binary search for nearest data entry using an X value
     *
     * @param {number} value - X value
     * @param {Array<DataEntry>} [data=this._renderData]
     * @returns {DataEntry}
     */
    findNearestFromXValue(value, data=this._renderData) {
        const index = this.findNearestIndexFromXValue(value, data);
        return index !== undefined ? data[index] : undefined;
    }

    /**
     * Binary search for nearest data index using an X coordinate
     *
     * @param {number} coord - X coord
     * @param {Array<DataEntry>} [data=this.normalizedData]
     * @returns {number}
     */
    findNearestIndexFromXCoord(coord, data=this.normalizedData) {
        return this.findNearestIndexFromXValue(this.xCoordToValue(coord), data);
    }

    /**
     * Binary search for nearest data index using an X value
     *
     * @param {number} value - X value
     * @param {Array<DataEntry>} [data=this.normalizedData]
     * @returns {number}
     */
    findNearestIndexFromXValue(value, data=this.normalizedData) {
        if (isNaN(value) || value === null || !data || !data.length) {
            return;
        }
        const len = data.length;
        let left = 0;
        let right = len - 1;
        for (let i = (len * 0.5) | 0;; i = ((right - left) * 0.5 + left) | 0) {
            const x = data[i].x;
            if (x > value) {
                right = i;
            } else if (x < value) {
                left = i;
            } else {
                return i;
            }
            if (right - left <= 1) {
                const lDist = value - data[left].x;
                const rDist = data[right].x - value;
                return lDist < rDist ? left : right;
            }
        }
    }

    /**
     * @param {object} options
     * @param {("data"|"visual")} [options.type="data"] - What coordinate scheme to use and how to keep this
     *                                                    zoom anchored when data is updated
     * @param {CoordRange} [options.xRange] - x axis coordinates (type=data only)
     * @param {CoordRange} [options.yRange] - y axis coordinates (type=data only)
     * @param {CoordTuple} [options.translate] - [x, y] coordinate offsets (type=visual only)
     * @param {number} [options.scale] - Scaling factor (type=visual only)
     */
    setZoom(options) {
        this._zoomState.rev++;
        if (!options || options.type === null) {
            this._zoomState.active = false;
            this._zoomState.type = null;
            this._zoomState.translate = this._zoomState.scale = null;
            this._zoomState.xRange = this._zoomState.yRange = null;
        } else {
            const {xRange, yRange, translate, scale, type='data'} = options;
            if (type === 'data') {
                this._zoomState.translate = this._zoomState.scale = null;
                this._zoomState.xRange = xRange ?? null;
                this._zoomState.yRange = yRange ?? null;
            } else if (type === 'visual') {
                this._zoomState.xRange = this._zoomState.yRange = null;
                this._zoomState.translate = translate ?? null;
                this._zoomState.scale = scale ?? null;
            } else {
                throw new TypeError("invalid zoom type");
            }
            this._zoomState.active = true;
            this._zoomState.type = type;
        }
        this.render();
        queueMicrotask(() => this.dispatchEvent(new CustomEvent('zoom', {
            detail: {
                ...this._zoomState,
                internal: !!options?._internal,
                chart: this,
            }
        })));
    }

    /**
     * @param {ChartData} [data]
     * @param {object} [options]
     */
    setData(data, options={}) {
        this.data = data;
        this.normalizedData = this.normalizeData(data);
        if (options.render !== false) {
            this.render(options);
        }
    }

    /**
     * @protected
     *
     * @param {ChartData} data
     * @returns {Array<DataEntry>}
     */
    normalizeData(data) {
        const norm = new Array(data.length);
        if (!data.length) {
            return norm;
        }
        if (Array.isArray(data[0])) {
            // [[x, y], [x1, y1], ...]
            for (let i = 0; i < data.length; i++) {
                norm[i] = {index: i, x: data[i][0] || 0, y: data[i][1] || 0};
            }
        } else if (typeof data[0] === 'object' && Object.getPrototypeOf(data[0]) === Object.prototype) {
            // [{x, y, ...}, {x, y, ...}, ...]
            for (let i = 0; i < data.length; i++) {
                const o = data[i];
                norm[i] = {...o, index: i, x: o.x || 0, y: o.y || 0};
            }
        } else {
            // [y, y1, ...]
            for (let i = 0; i < data.length; i++) {
                norm[i] = {index: i, x: i, y: data[i] || 0};
            }
        }
        return norm;
    }

    /**
     * Render the chart
     *
     * @param {object} [options]
     */
    render(options={}) {
        if (!this.el || !this._boxWidth || !this._boxHeight) {
            return;
        }
        if (!this.data || !this.data.length) {
            this.doReset();
            return;
        }
        options.disableAnimation ||= this.disableAnimation;
        const manifest = this.beforeRender(options);
        this._xMin = this.xMin;
        this._xMax = this.xMax;
        this._yMin = this.yMin;
        this._yMax = this.yMax;
        const zoomType = this._zoomState.active ? this._zoomState.type : undefined;
        if (zoomType === 'data') {
            this.applyDataZoom();
        }
        this.adjustScale(manifest);
        if (zoomType === 'visual') {
            this.applyVisualZoom();
        }
        this.doLayout(manifest, options);
        const axisSig = [
            this._xMin,
            this._xMax,
            this._yMin,
            this._yMax,
            this._plotWidth,
            this._plotHeight,
            this._zoomState.rev
        ].join('-');
        if (this._lastAxisSig !== axisSig) {
            this._lastAxisSig = axisSig;
            if (this._xAxisEl) {
                this._drawXAxis();
            }
            if (this._yAxisEl) {
                this._drawYAxis();
            }
        }
        this.afterRender(manifest, options);
    }

    /**
     * @protected
     */
    applyDataZoom() {
        if (!this._zoomState.active || this._zoomState.type !== 'data') {
            throw new TypeError('data zoom not active');
        }
        if (this._zoomState.xRange) {
            this._xMin = this._zoomState.xRange[0];
            this._xMax = this._zoomState.xRange[1];
        }
        if (this._zoomState.yRange) {
            this._yMin = this._zoomState.yRange[0];
            this._yMax = this._zoomState.yRange[1];
        }
    }

    /**
     * @protected
     */
    applyVisualZoom() {
        if (!this._zoomState.active || this._zoomState.type !== 'visual') {
            throw new TypeError('visual zoom not active');
        }
        // Get offsets and size before transforms...
        const xOfft = this._zoomState.translate ? this.xCoordScale(this._zoomState.translate[0]) : 0;
        const yOfft = this._zoomState.translate ? this.yCoordScale(this._zoomState.translate[1]) : 0;
        if (this._zoomState.scale) {
            this._xMax = this._xMin + (this._xMax - this._xMin) / this._zoomState.scale[0];
            this._yMax = this._yMin + (this._yMax - this._yMin) / this._zoomState.scale[1];
        }
        this._xMin += xOfft;
        this._xMax += xOfft;
        this._yMin += yOfft;
        this._yMax += yOfft;
    }

    /**
     * Called before any visual reflow/painting
     *
     * @protected
     * @param {object} [options] - Render options
     */
    beforeRender(options) {
        let data = this.normalizedData;
        if (this._zoomState.active && this._zoomState.type === 'data' && this._zoomState.xRange) {
            const [start, end] = this._zoomState.xRange;
            const startIndex = this.findNearestIndexFromXValue(start, data) - 1;
            const endIndex = this.findNearestIndexFromXValue(end, data) + 1;
            if (startIndex != null && endIndex != null) {
                data = data.slice(Math.max(0, startIndex), Math.max(0, endIndex));
            }
        }
        const resampling = this.resampleThreshold ?? data.length > this._plotWidth * this.resampleThreshold;
        if (resampling) {
            data = resample(data, this._plotWidth * this.resampleTarget | 0);
        }
        this._renderData = data;
        return {data, resampling};
    }

    /**
     * @protected
     */
    adjustScale({data}) {
        if (this._yMin == null || this._yMax == null) {
            let min = Infinity;
            let max = -Infinity;
            for (let i = 0; i < data.length; i++) {
                const v = data[i].y;
                if (v < min) {
                    min = v;
                }
                if (v > max) {
                    max = v;
                }
            }
            if (this._yMin == null) {
                this._yMin = min;
            }
            if (this._yMax == null) {
                this._yMax = max;
            }
        }
    }

    /**
     * @protected
     * @abstract
     */
    doLayout(manifest, options) {
        throw new Error("subclass impl required");
    }

    /**
     * @protected
     */
    afterRender(manifest, options) {
        this.updateVisibleTooltips();
    }

    /**
     * @param {number} value
     * @returns {number}
     */
    xValueScale(value) {
        return value * (this._plotWidth / (this._xMax - this._xMin));
    }

    /**
     * @param {number} value
     * @returns {number}
     */
    yValueScale(value) {
        return value * (this._plotHeight / (this._yMax - this._yMin));
    }

    /**
     * @param {number} value
     * @returns {number}
     */
    xValueToCoord(value) {
        return (value - this._xMin) * (this._plotWidth / (this._xMax - this._xMin)) + this._plotBox[3];
    }

    /**
     * @param {number} value
     * @returns {number}
     */
    yValueToCoord(value) {
        return this._plotBox[2] - ((value - this._yMin) * (this._plotHeight / (this._yMax - this._yMin)));
    }

    /**
     * @param {number} coord
     * @returns {number}
     */
    xCoordScale(coord) {
        return coord / (this._plotWidth / (this._xMax - this._xMin));
    }

    /**
     * @param {number} coord
     * @returns {number}
     */
    yCoordScale(coord) {
        return coord / (this._plotHeight / (this._yMax - this._yMin));
    }

    /**
     * @param {number} coord
     * @returns {number}
     */
    xCoordToValue(coord) {
        return (coord - this._plotBox[3]) / (this._plotWidth / (this._xMax - this._xMin)) + this._xMin;
    }

    /**
     * @param {number} coord
     * @returns {number}
     */
    yCoordToValue(coord) {
        return (this._plotBox[2] - coord) / (this._plotHeight / (this._yMax - this._yMin)) + this._yMin;
    }

    /**
     * Clear this charts data and rendering
     */
    reset() {
        if (this.data) {
            this.data.length = 0;
        }
        this._renderData = null;
        this._lastAxisSig = null;
        this.doReset();
    }

    /**
     * @protected
     * @abstract
     */
    doReset() {}

    /**
     * Create an SVG or CSS path
     *
     * @param {Array<CoordTuple>} coords
     * @param {object} [options]
     * @param {boolean} [options.css] - CSS compatible output
     * @param {boolean} [options.closed] - Close the Path so it can be filled
     */
    makePath(coords, {css, closed}={}) {
        if (!coords.length) {
            return '';
        }
        let path = closed ?
            `M ${coords[0][0]},${this._plotBox[2]} V ${coords[0][1]}` :
            `M ${coords[0][0]},${coords[0][1]}`;
        for (let i = 1; i < coords.length; i++) {
            path += ` L ${coords[i][0]},${coords[i][1]}`;
        }
        if (closed) {
            path += ` V ${this._plotBox[2]} Z`;
        }
        return css ? `path('${path}')` : path;
    }
}