// This Source Code Form is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/
import { Chart, Ticks } from "chart.js";
import EventEmitter from "eventemitter3";
import { Zoom as ZoomPlugin } from "@foxglove/chartjs-plugin-zoom";
import { unwrap } from "@foxglove/den/monads";
import { maybeCast } from "@foxglove/studio-base/util/maybeCast";
import { fontMonospace } from "@foxglove/theme";
const fixedNumberFormat = new Intl.NumberFormat(undefined, {
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
});
function addEventListener(emitter) {
    return (eventName, fn) => {
        const existing = emitter.listeners(eventName);
        if (!fn || existing.includes(fn)) {
            return;
        }
        emitter.on(eventName, fn);
    };
}
function removeEventListener(emitter) {
    return (eventName, fn) => {
        if (fn) {
            emitter.off(eventName, fn);
        }
    };
}
export class ChartRenderer {
    #chartInstance;
    #fakeNodeEvents = new EventEmitter();
    #fakeDocumentEvents = new EventEmitter();
    constructor(args) {
        const fakeNode = {
            addEventListener: addEventListener(this.#fakeNodeEvents),
            removeEventListener: removeEventListener(this.#fakeNodeEvents),
            ownerDocument: {
                addEventListener: addEventListener(this.#fakeDocumentEvents),
                removeEventListener: removeEventListener(this.#fakeDocumentEvents),
            },
        };
        const origZoomStart = ZoomPlugin.start?.bind(ZoomPlugin);
        ZoomPlugin.start = (chartInstance, startArgs, pluginOptions) => {
            // swap the canvas with our fake dom node canvas to support zoom plugin addEventListener
            const ctx = chartInstance.ctx;
            chartInstance.ctx = {
                canvas: fakeNode,
            };
            const res = origZoomStart?.(chartInstance, startArgs, pluginOptions);
            chartInstance.ctx = ctx;
            return res;
        };
        const fullOptions = {
            maintainAspectRatio: false,
            animation: false,
            // Disable splines, they seem to cause weird rendering artifacts:
            elements: { line: { tension: 0 } },
            interaction: {
                intersect: false,
                mode: "x",
            },
            devicePixelRatio: args.devicePixelRatio,
            font: { family: fontMonospace, size: 10 },
            // we force responsive off since we manually trigger width/height updates on the chart
            // responsive mode does not work properly with offscreen canvases and retina device pixel ratios
            // it results in a run-away canvas that keeps doubling in size!
            responsive: false,
            scales: {
                x: {
                    type: "linear",
                    display: true,
                    grid: { color: args.gridColor },
                    ticks: {
                        font: {
                            family: fontMonospace,
                            size: 10,
                        },
                        color: args.tickColor,
                        maxRotation: 0,
                    },
                },
                y: {
                    type: "linear",
                    display: true,
                    grid: { color: args.gridColor },
                    ticks: {
                        font: {
                            family: fontMonospace,
                            size: 10,
                        },
                        color: args.tickColor,
                        padding: 0,
                        precision: 3,
                    },
                },
            },
            plugins: {
                decimation: {
                    enabled: false,
                },
                tooltip: {
                    enabled: false, // Disable native tooltips since we use custom ones.
                },
                zoom: {
                    zoom: {
                        enabled: true,
                        mode: "x",
                        sensitivity: 3,
                        speed: 0.1,
                    },
                    pan: {
                        mode: "xy",
                        enabled: true,
                        speed: 20,
                        threshold: 10,
                    },
                },
            },
        };
        // ChartJS supports offscreen canvas however the type definitions do not so we need to cast and
        // fool the constructor.
        //
        // https://www.chartjs.org/docs/latest/general/performance.html#parallel-rendering-with-web-workers-chromium-only
        const canvas = args.canvas;
        const chartInstance = new Chart(canvas, {
            type: "scatter",
            data: {
                datasets: [],
            },
            options: fullOptions,
            plugins: [ZoomPlugin],
        });
        ZoomPlugin.start = origZoomStart;
        this.#chartInstance = chartInstance;
    }
    update(action) {
        if (action.size) {
            this.#chartInstance.canvas.width = action.size.width;
            this.#chartInstance.canvas.height = action.size.height;
            this.#chartInstance.resize();
        }
        if (action.yBounds) {
            const scaleOption = this.#chartInstance.options.scales?.y;
            if (scaleOption && scaleOption.min !== action.yBounds.min) {
                scaleOption.min = action.yBounds.min;
            }
            if (scaleOption && scaleOption.max !== action.yBounds.max) {
                scaleOption.max = action.yBounds.max;
            }
        }
        if (action.xBounds) {
            const scaleOption = this.#chartInstance.options.scales?.x;
            if (scaleOption && scaleOption.min !== action.xBounds.min) {
                scaleOption.min = action.xBounds.min;
            }
            if (scaleOption && scaleOption.max !== action.xBounds.max) {
                scaleOption.max = action.xBounds.max;
            }
            // Adjust the `ticks` of the chart options to ensure the first/last x labels remain a
            // constant width. See https://github.com/foxglove/studio/issues/2926
            if (scaleOption?.ticks) {
                scaleOption.ticks.callback = function (value, index, ticks) {
                    // use a fixed formatter for the first/last ticks
                    if (index === 0 || index === ticks.length - 1) {
                        return fixedNumberFormat.format(value);
                    }
                    // otherwise use chart.js's default formatter
                    return Ticks.formatters.numeric.apply(this, [value, index, ticks]);
                };
            }
        }
        if (action.showYAxisLabels != undefined) {
            const ticksOptions = this.#chartInstance.options.scales?.y?.ticks;
            if (ticksOptions) {
                ticksOptions.display = action.showYAxisLabels;
            }
        }
        if (action.showXAxisLabels != undefined) {
            const ticksOptions = this.#chartInstance.options.scales?.x?.ticks;
            if (ticksOptions) {
                ticksOptions.display = action.showXAxisLabels;
            }
        }
        if (action.interactionEvents) {
            for (const event of action.interactionEvents) {
                this.#applyInteractionEvent(event);
            }
        }
        if (action.zoomMode) {
            unwrap(this.#chartInstance.options.plugins?.zoom?.zoom).mode = action.zoomMode;
        }
        if (action.referenceLines) {
            const annotation = this.#chartInstance.options.plugins?.annotation;
            if (!annotation) {
                return;
            }
            const newAnnotations = action.referenceLines.map((config) => {
                return {
                    type: "line",
                    display: true,
                    drawTime: "beforeDatasetsDraw",
                    scaleID: "y",
                    borderColor: config.color,
                    borderDash: [5, 5],
                    borderWidth: 1,
                    value: config.value,
                };
            });
            annotation.annotations = newAnnotations;
        }
        // NOTE: "none" disables animations - this is important for chart performance because we update
        // the entire data set which does not preserve history for the chart animations
        this.#chartInstance.update("none");
        // fill our rpc scales - we only support x and y scales for now
        const xScale = this.#chartInstance.scales.x;
        const yScale = this.#chartInstance.scales.y;
        if (!xScale || !yScale) {
            return undefined;
        }
        return {
            x: {
                min: xScale.min,
                max: xScale.max,
            },
            y: {
                min: yScale.min,
                max: yScale.max,
            },
        };
    }
    getElementsAtPixel(pixel) {
        const x = pixel.x;
        const y = pixel.y;
        const ev = {
            native: true,
            x,
            y,
        };
        // ev is cast to any because the typings for getElementsAtEventForMode are wrong
        // ev is specified as a dom Event - but the implementation does not require it for the basic platform
        const elements = this.#chartInstance.getElementsAtEventForMode(ev, this.#chartInstance.options.interaction?.mode ?? "intersect", this.#chartInstance.options.interaction ?? {}, false);
        const out = [];
        // sort elements by proximity to the cursor so the closer items are earlier in the list
        elements.sort((a, b) => {
            const dxA = pixel.x - a.element.x;
            const dyA = pixel.y - a.element.y;
            const dxB = pixel.x - b.element.x;
            const dyB = pixel.y - b.element.y;
            const distSquaredA = dxA * dxA + dyA * dyA;
            const distSquaredB = dxB * dxB + dyB * dyB;
            return distSquaredA - distSquaredB;
        });
        for (const element of elements) {
            const data = this.#chartInstance.data.datasets[element.datasetIndex]?.data[element.index];
            if (data == undefined || typeof data === "number") {
                continue;
            }
            out.push({
                data,
                configIndex: element.datasetIndex,
            });
        }
        return out;
    }
    updateDatasets(datasets) {
        this.#chartInstance.data.datasets = datasets;
        // While the chartjs API doesn't indicate update should be called after resize, in practice
        // we've found that performing a resize after an update sometimes results in a blank chart.
        //
        // NOTE: "none" disables animations - this is important for chart performance because we update
        // the entire data set which does not preserve history for the chart animations
        this.#chartInstance.update("none");
        return this.#getXScale();
    }
    #getXScale() {
        const xScale = this.#chartInstance.scales.x;
        if (!xScale) {
            return undefined;
        }
        return {
            min: xScale.min,
            max: xScale.max,
            left: xScale.left,
            right: xScale.right,
        };
    }
    #applyInteractionEvent(event) {
        const { type, boundingClientRect, ...rest } = event;
        switch (type) {
            case "wheel":
                this.#fakeNodeEvents.emit("wheel", {
                    ...rest,
                    target: {
                        getBoundingClientRect() {
                            return boundingClientRect;
                        },
                    },
                });
                break;
            case "panstart":
                maybeCast(this.#chartInstance)?.$zoom.panStartHandler({
                    center: event.center,
                    deltaX: event.deltaX,
                    deltaY: event.deltaY,
                    target: {
                        getBoundingClientRect() {
                            return boundingClientRect;
                        },
                    },
                });
                break;
            case "panmove":
                maybeCast(this.#chartInstance)?.$zoom.panHandler(event);
                break;
            case "panend":
                maybeCast(this.#chartInstance)?.$zoom.panEndHandler();
                break;
        }
    }
}
//# sourceMappingURL=ChartRenderer.js.map