/* *
 *
 *  (c) 2009-2026 Highsoft AS
 *  Author: Øystein Moseng
 *
 *  Handle keyboard navigation for series.
 *
 *  Integration of this software requires a license.
 *  - For commercial use, see www.highcharts.com/license
 *  - For non-commercial, see www.highcharts.com/license-eula
 *
 *
 * */
'use strict';
import Point from '../../../Core/Series/Point.js';
import Series from '../../../Core/Series/Series.js';
import SeriesRegistry from '../../../Core/Series/SeriesRegistry.js';
const { seriesTypes } = SeriesRegistry;
import H from '../../../Core/Globals.js';
const { doc } = H;
import KeyboardNavigationHandler from '../../KeyboardNavigationHandler.js';
import EventProvider from '../../Utils/EventProvider.js';
import ChartUtilities from '../../Utils/ChartUtilities.js';
import { defined, fireEvent } from '../../../Shared/Utilities.js';
const { getPointFromXY, getSeriesFromName, scrollAxisToPoint } = ChartUtilities;
/* *
 *
 *  Functions
 *
 * */
/**
 * Get the index of a point in a series. This is needed when using e.g. data
 * grouping.
 *
 * @private
 * @function getPointIndex
 * @param {Highcharts.AccessibilityPoint} point
 * The point to find index of.
 * @return {number|undefined}
 * The index in the series.points array of the point.
 */
function getPointIndex(point) {
    const index = point.index, points = point.series.points;
    let i = points.length;
    if (points[index] !== point) {
        while (i--) {
            if (points[i] === point) {
                return i;
            }
        }
    }
    else {
        return index;
    }
}
/**
 * Determine if series navigation should be skipped
 * @private
 */
function isSkipSeries(series) {
    const a11yOptions = series.chart.options.accessibility, seriesNavOptions = a11yOptions.keyboardNavigation.seriesNavigation, seriesA11yOptions = series.options.accessibility || {}, seriesKbdNavOptions = seriesA11yOptions.keyboardNavigation;
    return seriesKbdNavOptions && seriesKbdNavOptions.enabled === false ||
        seriesA11yOptions.enabled === false ||
        series.options.enableMouseTracking === false || // #8440
        !series.visible ||
        // Skip all points in a series where pointNavigationEnabledThreshold is
        // reached
        (seriesNavOptions.pointNavigationEnabledThreshold &&
            +seriesNavOptions.pointNavigationEnabledThreshold <=
                series.points.length);
}
/**
 * Determine if navigation for a point should be skipped
 * @private
 */
function isSkipPoint(point) {
    const series = point.series, nullInteraction = series.options.nullInteraction, pointOptions = point.options, pointA11yOptions = pointOptions.accessibility, a11yOptions = series.chart.options.accessibility, pointA11yDisabled = pointA11yOptions?.enabled === false, 
    // Whether to skip null points. If the user did not set this
    // option, use the opposite of nullInteraction as the default
    // (#24650).
    skipNullPoints = a11yOptions
        .keyboardNavigation
        .seriesNavigation
        .skipNullPoints ?? !nullInteraction;
    return point.isNull && skipNullPoints ||
        point.visible === false ||
        point.isInside === false ||
        pointA11yDisabled ||
        isSkipSeries(series);
}
/**
 * Get the first point that is not a skip point in this series.
 * @private
 */
function getFirstValidPointInSeries(series) {
    const points = series.points || [], len = points.length;
    for (let i = 0; i < len; ++i) {
        if (!isSkipPoint(points[i])) {
            return points[i];
        }
    }
    return null;
}
/**
 * Get the first point that is not a skip point in this chart.
 * @private
 */
function getFirstValidPointInChart(chart) {
    const series = chart.series || [], len = series.length;
    for (let i = 0; i < len; ++i) {
        if (!isSkipSeries(series[i])) {
            const point = getFirstValidPointInSeries(series[i]);
            if (point) {
                return point;
            }
        }
    }
    return null;
}
/**
 * @private
 */
function highlightLastValidPointInChart(chart) {
    const numSeries = chart.series.length;
    let i = numSeries, res = false;
    while (i--) {
        chart.highlightedPoint = chart.series[i].points[chart.series[i].points.length - 1];
        // Highlight first valid point in the series will also
        // look backwards. It always starts from currently
        // highlighted point.
        res = chart.series[i].highlightNextValidPoint();
        if (res) {
            break;
        }
    }
    return res;
}
/**
 * After drilling down/up, we need to set focus to the first point for
 * screen readers and keyboard nav.
 * @private
 */
function updateChartFocusAfterDrilling(chart) {
    const point = getFirstValidPointInChart(chart);
    if (point) {
        point.highlight(false); // Do not visually highlight
    }
}
/**
 * Highlight the first point in chart that is not a skip point
 * @private
 */
function highlightFirstValidPointInChart(chart) {
    delete chart.highlightedPoint;
    const point = getFirstValidPointInChart(chart);
    return point ? point.highlight() : false;
}
/* *
 *
 *  Class
 *
 * */
/**
 * @private
 * @class
 * @name Highcharts.SeriesKeyboardNavigation
 */
class SeriesKeyboardNavigation {
    /* *
     *
     *  Constructor
     *
     * */
    constructor(chart, keyCodes) {
        this.keyCodes = keyCodes;
        this.chart = chart;
    }
    /* *
     *
     *  Functions
     *
     * */
    /**
     * Init the keyboard navigation
     */
    init() {
        const keyboardNavigation = this, chart = this.chart, e = this.eventProvider = new EventProvider();
        e.addEvent(Series, 'destroy', function () {
            return keyboardNavigation.onSeriesDestroy(this);
        });
        e.addEvent(chart, 'afterApplyDrilldown', function () {
            updateChartFocusAfterDrilling(this);
        });
        e.addEvent(chart, 'drilldown', function (e) {
            const point = e.point, series = point.series;
            keyboardNavigation.lastDrilledDownPoint = {
                x: point.x,
                y: point.y,
                seriesName: series ? series.name : ''
            };
        });
        e.addEvent(chart, 'drillupall', function () {
            setTimeout(function () {
                keyboardNavigation.onDrillupAll();
            }, 10);
        });
        // Heatmaps et al. alter z-index in setState, causing elements
        // to lose focus
        e.addEvent(Point, 'afterSetState', function () {
            const point = this;
            const pointEl = point.graphic && point.graphic.element;
            const focusedElement = doc.activeElement;
            // VO brings focus with it to container, causing series nav to run.
            // If then navigating with virtual cursor, it is possible to leave
            // keyboard nav module state on the data points and still activate
            // proxy buttons.
            const focusedElClassName = (focusedElement && focusedElement.getAttribute('class'));
            const isProxyFocused = focusedElClassName &&
                focusedElClassName.indexOf('highcharts-a11y-proxy-element') > -1;
            if (chart.highlightedPoint === point &&
                focusedElement !== pointEl &&
                !isProxyFocused &&
                pointEl &&
                pointEl.focus) {
                pointEl.focus();
            }
        });
    }
    /**
     * After drillup we want to find the point that was drilled down to and
     * highlight it.
     * @private
     */
    onDrillupAll() {
        const last = this.lastDrilledDownPoint, chart = this.chart, series = last && getSeriesFromName(chart, last.seriesName);
        let point;
        if (last && series && defined(last.x) && defined(last.y)) {
            point = getPointFromXY(series, last.x, last.y);
        }
        point = point || getFirstValidPointInChart(chart);
        // Container focus can be lost on drillup due to deleted elements.
        if (chart.container) {
            chart.container.focus();
        }
        if (point && point.highlight) {
            point.highlight(false); // Do not visually highlight
        }
    }
    /**
     * @private
     */
    getKeyboardNavigationHandler() {
        const keyboardNavigation = this, keys = this.keyCodes, chart = this.chart, inverted = chart.inverted;
        return new KeyboardNavigationHandler(chart, {
            keyCodeMap: [
                [
                    inverted ? [keys.up, keys.down] : [keys.left, keys.right],
                    function (keyCode) {
                        return keyboardNavigation.onKbdSideways(this, keyCode);
                    }
                ],
                [
                    inverted ? [keys.left, keys.right] : [keys.up, keys.down],
                    function (keyCode) {
                        return keyboardNavigation.onKbdVertical(this, keyCode);
                    }
                ],
                [
                    [keys.enter, keys.space],
                    function (keyCode, event) {
                        const point = chart.highlightedPoint;
                        if (point) {
                            const { plotLeft, plotTop } = this.chart, { plotX = 0, plotY = 0 } = point;
                            event = {
                                ...event,
                                chartX: plotLeft + plotX,
                                chartY: plotTop + plotY,
                                point: point,
                                target: point.graphic?.element || event.target
                            };
                            fireEvent(point.series, 'click', event);
                            point.firePointEvent('click', event);
                        }
                        return this.response.success;
                    }
                ],
                [
                    [keys.home],
                    function () {
                        highlightFirstValidPointInChart(chart);
                        return this.response.success;
                    }
                ],
                [
                    [keys.end],
                    function () {
                        highlightLastValidPointInChart(chart);
                        return this.response.success;
                    }
                ],
                [
                    [keys.pageDown, keys.pageUp],
                    function (keyCode) {
                        chart.highlightAdjacentSeries(keyCode === keys.pageDown);
                        return this.response.success;
                    }
                ]
            ],
            init: function () {
                return keyboardNavigation.onHandlerInit(this);
            },
            validate: function () {
                return !!getFirstValidPointInChart(chart);
            },
            terminate: function () {
                return keyboardNavigation.onHandlerTerminate();
            }
        });
    }
    /**
     * @private
     * @param {Highcharts.KeyboardNavigationHandler} handler
     * @param {number} keyCode
     * @return {number}
     * response
     */
    onKbdSideways(handler, keyCode) {
        const keys = this.keyCodes, isNext = keyCode === keys.right || keyCode === keys.down;
        return this.attemptHighlightAdjacentPoint(handler, isNext);
    }
    /**
     * When keyboard navigation inits.
     * @private
     * @param {Highcharts.KeyboardNavigationHandler} handler The handler object
     * @return {number}
     * response
     */
    onHandlerInit(handler) {
        const chart = this.chart, kbdNavOptions = chart.options.accessibility.keyboardNavigation;
        if (kbdNavOptions.seriesNavigation.rememberPointFocus &&
            chart.highlightedPoint) {
            chart.highlightedPoint.highlight();
        }
        else {
            highlightFirstValidPointInChart(chart);
        }
        return handler.response.success;
    }
    /**
     * @private
     * @param {Highcharts.KeyboardNavigationHandler} handler
     * @param {number} keyCode
     * @return {number}
     * response
     */
    onKbdVertical(handler, keyCode) {
        const chart = this.chart, keys = this.keyCodes, isNext = keyCode === keys.down || keyCode === keys.right, navOptions = chart.options.accessibility.keyboardNavigation
            .seriesNavigation;
        // Handle serialized mode, act like left/right
        if (navOptions.mode && navOptions.mode === 'serialize') {
            return this.attemptHighlightAdjacentPoint(handler, isNext);
        }
        // Normal mode, move between series
        const highlightMethod = (chart.highlightedPoint &&
            chart.highlightedPoint.series.keyboardMoveVertical) ?
            'highlightAdjacentPointVertical' :
            'highlightAdjacentSeries';
        chart[highlightMethod](isNext);
        return handler.response.success;
    }
    /**
     * @private
     */
    onHandlerTerminate() {
        const chart = this.chart, kbdNavOptions = chart.options.accessibility.keyboardNavigation;
        if (chart.tooltip) {
            chart.tooltip.hide(0);
        }
        const hoverSeries = (chart.highlightedPoint && chart.highlightedPoint.series);
        if (hoverSeries && hoverSeries.onMouseOut) {
            hoverSeries.onMouseOut();
        }
        if (chart.highlightedPoint && chart.highlightedPoint.onMouseOut) {
            chart.highlightedPoint.onMouseOut();
        }
        if (!kbdNavOptions.seriesNavigation.rememberPointFocus) {
            delete chart.highlightedPoint;
        }
    }
    /**
     * Function that attempts to highlight next/prev point. Handles wrap around.
     * @private
     */
    attemptHighlightAdjacentPoint(handler, directionIsNext) {
        const chart = this.chart, wrapAround = chart.options.accessibility.keyboardNavigation
            .wrapAround, highlightSuccessful = chart.highlightAdjacentPoint(directionIsNext);
        if (!highlightSuccessful) {
            if (wrapAround && (directionIsNext ?
                highlightFirstValidPointInChart(chart) :
                highlightLastValidPointInChart(chart))) {
                return handler.response.success;
            }
            return handler.response[directionIsNext ? 'next' : 'prev'];
        }
        return handler.response.success;
    }
    /**
     * @private
     */
    onSeriesDestroy(series) {
        const chart = this.chart, currentHighlightedPointDestroyed = chart.highlightedPoint &&
            chart.highlightedPoint.series === series;
        if (currentHighlightedPointDestroyed) {
            delete chart.highlightedPoint;
            if (chart.focusElement) {
                chart.focusElement.removeFocusBorder();
            }
        }
    }
    /**
     * @private
     */
    destroy() {
        this.eventProvider.removeAddedEvents();
    }
}
/* *
 *
 *  Class Namespace
 *
 * */
(function (SeriesKeyboardNavigation) {
    /* *
     *
     *  Declarations
     *
     * */
    /* *
     *
     *  Functions
     *
     * */
    /**
     * Function to highlight next/previous point in chart.
     *
     * @private
     * @function Highcharts.Chart#highlightAdjacentPoint
     *
     * @param {boolean} next
     * Flag for the direction.
     *
     * @return {Highcharts.Point|boolean}
     * Returns highlighted point on success, false on failure (no adjacent point
     * to highlight in chosen direction).
     */
    function chartHighlightAdjacentPoint(next) {
        const chart = this, series = chart.series, curPoint = chart.highlightedPoint, curPointIndex = curPoint && getPointIndex(curPoint) || 0, curPoints = curPoint && curPoint.series.points || [], lastSeries = chart.series && chart.series[chart.series.length - 1], lastPoint = lastSeries &&
            lastSeries.points &&
            lastSeries.points[lastSeries.points.length - 1];
        let newSeries, newPoint;
        // If no points, return false
        if (!series[0] || !series[0].points) {
            return false;
        }
        if (!curPoint) {
            // No point is highlighted yet. Try first/last point depending on
            // move direction
            newPoint = next ? series[0].points[0] : lastPoint;
        }
        else {
            // We have a highlighted point. Grab next/prev point & series.
            newSeries = series[curPoint.series.index + (next ? 1 : -1)];
            newPoint = curPoints[curPointIndex + (next ? 1 : -1)];
            if (!newPoint && newSeries) {
                // Done with this series, try next one
                newPoint = newSeries.points[next ? 0 : newSeries.points.length - 1];
            }
            // If there is no adjacent point, we return false
            if (!newPoint) {
                return false;
            }
        }
        // Recursively skip points
        if (isSkipPoint(newPoint)) {
            // If we skip this whole series, move to the end of the series
            // before we recurse, just to optimize
            newSeries = newPoint.series;
            if (isSkipSeries(newSeries)) {
                chart.highlightedPoint = next ?
                    newSeries.points[newSeries.points.length - 1] :
                    newSeries.points[0];
            }
            else {
                // Otherwise, just move one point
                chart.highlightedPoint = newPoint;
            }
            // Retry
            return chart.highlightAdjacentPoint(next);
        }
        // There is an adjacent point, highlight it
        return newPoint.highlight();
    }
    /**
     * Highlight the closest point vertically.
     * @private
     */
    function chartHighlightAdjacentPointVertical(down) {
        const curPoint = this.highlightedPoint;
        let minDistance = Infinity, bestPoint;
        if (!defined(curPoint.plotX) || !defined(curPoint.plotY)) {
            return false;
        }
        this.series.forEach((series) => {
            if (isSkipSeries(series)) {
                return;
            }
            series.points.forEach((point) => {
                if (!defined(point.plotY) || !defined(point.plotX) ||
                    point === curPoint) {
                    return;
                }
                let yDistance = point.plotY - curPoint.plotY;
                const width = Math.abs(point.plotX - curPoint.plotX), distance = Math.abs(yDistance) * Math.abs(yDistance) +
                    width * width * 4; // Weigh horizontal distance highly
                // Reverse distance number if axis is reversed
                if (series.yAxis && series.yAxis.reversed) {
                    yDistance *= -1;
                }
                if (yDistance <= 0 && down || yDistance >= 0 && !down ||
                    distance < 5 || // Points in same spot => infinite loop
                    isSkipPoint(point)) {
                    return;
                }
                if (distance < minDistance) {
                    minDistance = distance;
                    bestPoint = point;
                }
            });
        });
        return bestPoint ? bestPoint.highlight() : false;
    }
    /**
     * Highlight next/previous series in chart. Returns false if no adjacent
     * series in the direction, otherwise returns new highlighted point.
     * @private
     */
    function chartHighlightAdjacentSeries(down) {
        const chart = this, curPoint = chart.highlightedPoint, lastSeries = chart.series && chart.series[chart.series.length - 1], lastPoint = lastSeries && lastSeries.points &&
            lastSeries.points[lastSeries.points.length - 1];
        let newSeries, newPoint, adjacentNewPoint;
        // If no point is highlighted, highlight the first/last point
        if (!chart.highlightedPoint) {
            newSeries = down ? (chart.series && chart.series[0]) : lastSeries;
            newPoint = down ?
                (newSeries && newSeries.points && newSeries.points[0]) :
                lastPoint;
            return newPoint ? newPoint.highlight() : false;
        }
        newSeries = (chart.series[curPoint.series.index + (down ? -1 : 1)]);
        if (!newSeries) {
            return false;
        }
        // We have a new series in this direction, find the right point
        // Weigh xDistance as counting much higher than Y distance
        newPoint = getClosestPoint(curPoint, newSeries, 4);
        if (!newPoint) {
            return false;
        }
        // New series and point exists, but we might want to skip it
        if (isSkipSeries(newSeries)) {
            // Skip the series
            newPoint.highlight();
            // Try recurse
            adjacentNewPoint = chart.highlightAdjacentSeries(down);
            if (!adjacentNewPoint) {
                // Recurse failed
                curPoint.highlight();
                return false;
            }
            // Recurse succeeded
            return adjacentNewPoint;
        }
        // Highlight the new point or any first valid point back or forwards
        // from it
        newPoint.highlight();
        return newPoint.series.highlightNextValidPoint();
    }
    /**
     * @private
     */
    function compose(ChartClass, PointClass, SeriesClass) {
        const chartProto = ChartClass.prototype, pointProto = PointClass.prototype, seriesProto = SeriesClass.prototype;
        if (!chartProto.highlightAdjacentPoint) {
            chartProto.highlightAdjacentPoint = chartHighlightAdjacentPoint;
            chartProto.highlightAdjacentPointVertical = (chartHighlightAdjacentPointVertical);
            chartProto.highlightAdjacentSeries = chartHighlightAdjacentSeries;
            pointProto.highlight = pointHighlight;
            /**
             * Set for which series types it makes sense to move to the closest
             * point with up/down arrows, and which series types should just
             * move to next series.
             * @private
             */
            seriesProto.keyboardMoveVertical = true;
            [
                'column',
                'gantt',
                'pie'
            ].forEach((type) => {
                if (seriesTypes[type]) {
                    seriesTypes[type].prototype.keyboardMoveVertical = false;
                }
            });
            seriesProto.highlightNextValidPoint = (seriesHighlightNextValidPoint);
        }
    }
    SeriesKeyboardNavigation.compose = compose;
    /**
     * Get the point in a series that is closest (in pixel distance) to a
     * reference point. Optionally supply weight factors for x and y directions.
     * @private
     */
    function getClosestPoint(point, series, xWeight, yWeight) {
        let minDistance = Infinity, dPoint, minIx, distance, i = series.points.length;
        const hasUndefinedPosition = (point) => (!(defined(point.plotX) && defined(point.plotY)));
        if (hasUndefinedPosition(point)) {
            return;
        }
        while (i--) {
            dPoint = series.points[i];
            if (hasUndefinedPosition(dPoint)) {
                continue;
            }
            distance = (point.plotX - dPoint.plotX) *
                (point.plotX - dPoint.plotX) *
                (xWeight || 1) +
                (point.plotY - dPoint.plotY) *
                    (point.plotY - dPoint.plotY) *
                    (yWeight || 1);
            if (distance < minDistance) {
                minDistance = distance;
                minIx = i;
            }
        }
        return defined(minIx) ? series.points[minIx] : void 0;
    }
    /**
     * Highlights a point (show tooltip, display hover state, focus element).
     *
     * @private
     * @function Highcharts.Point#highlight
     *
     * @return {Highcharts.Point}
     *         This highlighted point.
     */
    function pointHighlight(highlightVisually = true) {
        const chart = this.series.chart, tooltipElement = chart.tooltip?.label?.element;
        if ((!this.isNull ||
            this.series.options?.nullInteraction) && highlightVisually) {
            this.onMouseOver(); // Show the hover marker and tooltip
        }
        else {
            if (chart.tooltip) {
                chart.tooltip.hide(0);
            }
            // Do not call blur on the element, as it messes up the focus of the
            // div element of the chart
        }
        scrollAxisToPoint(this);
        // We focus only after calling onMouseOver because the state change can
        // change z-index and mess up the element.
        if (this.graphic) {
            chart.setFocusToElement(this.graphic);
            if (!highlightVisually && chart.focusElement) {
                chart.focusElement.removeFocusBorder();
            }
        }
        chart.highlightedPoint = this;
        // Get position of the tooltip.
        const tooltipTop = tooltipElement?.getBoundingClientRect().top;
        if (tooltipElement && tooltipTop && tooltipTop < 0) {
            // Calculate scroll position.
            const scrollTop = window.scrollY, newScrollTop = scrollTop + tooltipTop;
            // Scroll window to new position.
            window.scrollTo({
                behavior: 'smooth',
                top: newScrollTop
            });
        }
        return this;
    }
    /**
     * Highlight first valid point in a series. Returns the point if
     * successfully highlighted, otherwise false. If there is a highlighted
     * point in the series, use that as starting point.
     *
     * @private
     * @function Highcharts.Series#highlightNextValidPoint
     */
    function seriesHighlightNextValidPoint() {
        const curPoint = this.chart.highlightedPoint, start = (curPoint && curPoint.series) === this ?
            getPointIndex(curPoint) :
            0, points = this.points, len = points.length;
        if (points && len) {
            for (let i = start; i < len; ++i) {
                if (!isSkipPoint(points[i])) {
                    return points[i].highlight();
                }
            }
            for (let j = start; j >= 0; --j) {
                if (!isSkipPoint(points[j])) {
                    return points[j].highlight();
                }
            }
        }
        return false;
    }
})(SeriesKeyboardNavigation || (SeriesKeyboardNavigation = {}));
/* *
 *
 *  Default Export
 *
 * */
export default SeriesKeyboardNavigation;
