// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

import type { Canvas2D } from "../canvas-types";
import { drawFacetTitle } from "../../axis/facet-chrome";
import type { WebGLContextManager } from "../../webgl/context-manager";
import type { CartesianChart } from "./cartesian";
import { PlotLayout } from "../../layout/plot-layout";
import {
    buildFacetGrid,
    bottomRowLayouts,
    leftColumnLayouts,
    type FacetGrid,
} from "../../layout/facet-grid";
import { type Theme } from "../../theme/theme";
import { resolvePalette } from "../../theme/palette";
import { paletteToStops } from "../../theme/gradient";
import {
    renderInPlotFrame,
    clearAndSetupFrame,
    withScissor,
} from "../../webgl/plot-frame";
import { ensureGradientTexture } from "../../webgl/gradient-texture";
import { renderCanvasTooltip } from "../../interaction/tooltip-controller";
import {
    computeTicks,
    renderGridlines,
    renderCellXAxis,
    renderCellYAxis,
    renderOuterXAxis,
    renderOuterYAxis,
    type AxisDomain,
} from "../../axis/numeric-axis";
import { initCanvas, getScaledContext } from "../../axis/canvas";
import {
    type CategoricalDomain,
    type CategoricalLevel,
    measureCategoricalAxisHeight,
    measureCategoricalAxisWidth,
    renderCategoricalXTicks,
    renderCategoricalYTicks,
} from "../../axis/categorical-axis";
import {
    renderLegend,
    renderLegendAt,
    renderCategoricalLegend,
    renderCategoricalLegendAt,
} from "../../axis/legend";

/**
 * NaN guard: `_xOrigin`/`_yOrigin` start as NaN before the first valid sample.
 */
function rebaseOrigin(o: number): number {
    return isNaN(o) ? 0 : o;
}

/**
 * Full-frame render: gridlines → glyph draw inside the plot-frame
 * scissor → chrome overlay (axes + legend + tooltip).
 *
 * Branches on `_facetConfig.facet_mode`:
 *
 *   - `"overlay"` (legacy): a single plot rect; all split series are
 *     drawn together, distinguished by color. This is the pre-facet
 *     behavior, preserved for manual opt-in via `plugin_config.facet_mode`.
 *   - `"grid"` (default): when splits are present, `_splitGroups` laid
 *     out as a grid of sub-plots by {@link buildFacetGrid}. When splits
 *     are absent, falls through to the single-plot path — identical to
 *     the `"overlay"` case with 0 splits, so the non-split render path
 *     is byte-for-byte unchanged from before this feature.
 */
export function renderCartesianFrame(
    chart: CartesianChart,
    glManager: WebGLContextManager,
): void {
    const gl = glManager.gl;
    const dpr = glManager.dpr;
    const cssWidth = gl.canvas.width / dpr;
    const cssHeight = gl.canvas.height / dpr;
    if (cssWidth <= 0 || cssHeight <= 0) {
        return;
    }

    const hasSplits = chart._splitGroups.length > 0;
    const facetMode = chart._facetConfig.facet_mode;
    const useGrid = hasSplits && facetMode === "grid";

    chart.computeEffectiveFacetFlags();

    // Legend appears only when the user wired a color column with a
    // non-degenerate range. `split_by` alone no longer forces a
    // legend — faceting is the axis of splitting, not coloring.
    const hasColorCol =
        chart._colorName !== "" && chart._colorMin < chart._colorMax;

    // Overall domain = current viewport in shared-zoom mode, full data
    // extents in independent-zoom mode (each facet consults its own
    // controller inside `renderFacetedFrame`).
    const independent =
        useGrid && chart._facetConfig.zoom_mode === "independent";
    let domain: { xMin: number; xMax: number; yMin: number; yMax: number };
    if (chart._zoomController && !independent) {
        domain = chart._zoomController.getVisibleDomain();
    } else {
        domain = {
            xMin: chart._xMin,
            xMax: chart._xMax,
            yMin: chart._yMin,
            yMax: chart._yMax,
        };
    }

    if (!isFinite(domain.xMin) || !isFinite(domain.yMin)) {
        return;
    }

    const theme = chart._resolveTheme();
    const seriesPalette = theme.seriesPalette;

    const xType = chart._columnTypes[chart._xLabel] || "";
    const yType = chart._columnTypes[chart._yLabel] || "";
    const xIsDate = xType === "date" || xType === "datetime";
    const yIsDate = yType === "date" || yType === "datetime";

    // Prepare the shared gradient LUT once (used by all facets).
    //
    // Three color sources map to three LUT types:
    //   - split_by or string color column → multi-entry series palette
    //     keyed by `_uniqueColorLabels.size`.
    //   - no color source at all          → single-entry series palette
    //     (`palette[0]`). Points are stored with `a_color_value = 0.5`
    //     in the build; a 1-color LUT returns the same RGB for every
    //     sample so the default value is harmless.
    //   - numeric color column            → continuous theme gradient.
    // Categorical only when a string color column was wired —
    // `split_by` alone no longer implies categorical coloring.
    const isCategorical = chart._colorIsString;
    const hasNoColorSource = !isCategorical && !chart._colorName;
    let lutStops = theme.gradientStops;
    if (isCategorical || hasNoColorSource) {
        const labelCount = hasNoColorSource
            ? Math.max(1, chart._splitGroups.length)
            : Math.max(1, chart._uniqueColorLabels.size);

        // Cache key carries the `seriesPalette` reference (changes per
        // theme — `_resolveTheme` returns a fresh `Theme` after
        // `invalidateTheme()`) plus `labelCount`. Reference compare
        // catches theme switches that the prior length-only key
        // missed.
        if (
            chart._lastLutStops &&
            chart._lastLutSeriesPalette === seriesPalette &&
            chart._lastLutLabelCount === labelCount
        ) {
            lutStops = chart._lastLutStops;
        } else {
            const palette = resolvePalette(
                seriesPalette,
                theme.gradientStops,
                labelCount,
            );
            lutStops = paletteToStops(palette);
            chart._lastLutStops = lutStops;
            chart._lastLutSeriesPalette = seriesPalette;
            chart._lastLutLabelCount = labelCount;
        }
    } else {
        chart._lastLutStops = null;
        chart._lastLutSeriesPalette = null;
        chart._lastLutLabelCount = -1;
    }

    chart._gradientCache = ensureGradientTexture(
        glManager,
        chart._gradientCache,
        lutStops,
    );

    if (useGrid) {
        renderFacetedFrame(chart, glManager, domain, theme, {
            xIsDate,
            yIsDate,
            cssWidth,
            cssHeight,
        });
    } else {
        // Single-plot path (no splits, or `"overlay"` mode).
        chart._facetGrid = null;
        renderSinglePlotFrame(chart, glManager, domain, theme, {
            xIsDate,
            yIsDate,
            cssWidth,
            cssHeight,
            hasColorCol,
        });
    }

    renderCartesianChromeOverlay(chart);
}

interface RenderFrameCtx {
    xIsDate: boolean;
    yIsDate: boolean;
    cssWidth: number;
    cssHeight: number;
}

interface SinglePlotCtx extends RenderFrameCtx {
    hasColorCol: boolean;
}

function buildXDomain(
    chart: CartesianChart,
    min: number,
    max: number,
    isDate: boolean,
): AxisDomain {
    return {
        min,
        max,
        label:
            chart._xLabel || (chart._xIsRowIndex ? "Row" : chart._xName || ""),
        isDate,
    };
}

function buildYDomain(
    chart: CartesianChart,
    min: number,
    max: number,
    isDate: boolean,
): AxisDomain {
    return {
        min,
        max,
        label: chart._yLabel || chart._yName,
        isDate,
    };
}

/**
 * Wrap a value-axis dictionary into the single-level `CategoricalDomain`
 * the categorical axis painter expects. Caches by reference identity on
 * the chart so chrome-overlay redraws on hover don't rebuild the domain.
 */
function buildCategoricalDomainFromDict(
    dictionary: string[],
    label: string,
): CategoricalDomain {
    let maxLabelChars = 0;
    for (const s of dictionary) {
        if (s.length > maxLabelChars) {
            maxLabelChars = s.length;
        }
    }

    const level: CategoricalLevel = {
        labels: dictionary.slice(),
        runs: [],
        maxLabelChars,
    };
    return {
        levels: [level],
        numRows: dictionary.length,
        levelLabels: [label],
    };
}

/**
 * Dispatch each axis side to the categorical painter when its source
 * column is post-aggregation `string`-typed, or to the numeric painter
 * otherwise. Used by both single-plot and faceted (per-cell) chrome
 * overlays.
 */
function renderCartesianCellAxes(
    chart: CartesianChart,
    canvas: Canvas2D,
    layout: PlotLayout,
    xDomain: AxisDomain,
    yDomain: AxisDomain,
    xTicks: number[],
    yTicks: number[],
    theme: Theme,
    dpr: number,
): void {
    if (chart._xIsString && chart._xCategoryDomain) {
        const ctx = getScaledContext(canvas, dpr);
        if (ctx) {
            renderCategoricalXTicks(ctx, layout, chart._xCategoryDomain, theme);
        }
    } else {
        renderCellXAxis(
            canvas,
            xDomain,
            layout,
            xTicks,
            theme,
            true,
            dpr,
            chart.getColumnFormatter(chart._xName, "tick"),
        );
    }

    if (chart._yIsString && chart._yCategoryDomain) {
        const ctx = getScaledContext(canvas, dpr);
        if (ctx) {
            renderCategoricalYTicks(ctx, layout, chart._yCategoryDomain, theme);
        }
    } else {
        renderCellYAxis(
            canvas,
            yDomain,
            layout,
            yTicks,
            theme,
            true,
            dpr,
            chart.getColumnFormatter(chart._yName, "tick"),
        );
    }
}

/**
 * Original single-plot render path — all series drawn into one
 * `PlotLayout` with one projection matrix. Used when splits are absent
 * or when `facet_mode === "overlay"`.
 */
function renderSinglePlotFrame(
    chart: CartesianChart,
    glManager: WebGLContextManager,
    domain: { xMin: number; xMax: number; yMin: number; yMax: number },
    theme: Theme,
    ctx: SinglePlotCtx,
): void {
    const gl = glManager.gl;
    const { cssWidth, cssHeight, xIsDate, yIsDate, hasColorCol } = ctx;

    // Materialize per-axis categorical domains from the build-pass
    // dictionaries before measuring gutters — the leaf-rotation budget
    // in `measureCategoricalAxisHeight` depends on `maxLabelChars`.
    chart._xCategoryDomain =
        chart._xIsString && chart._xCategoryDictionary.length > 0
            ? buildCategoricalDomainFromDict(
                  chart._xCategoryDictionary,
                  chart._xLabel || chart._xName || "",
              )
            : null;
    chart._yCategoryDomain =
        chart._yIsString && chart._yCategoryDictionary.length > 0
            ? buildCategoricalDomainFromDict(
                  chart._yCategoryDictionary,
                  chart._yLabel || chart._yName || "",
              )
            : null;

    // One-pass plot-width / plot-height estimate to size the
    // categorical gutter overrides; same approach as series-render.
    const estRight = hasColorCol ? 80 : 16;
    const estLeftPlain = 55 + (chart._yLabel ? 16 : 0);
    const estPlotWidth = Math.max(1, cssWidth - estLeftPlain - estRight);
    const leftExtra = chart._yCategoryDomain
        ? measureCategoricalAxisWidth(chart._yCategoryDomain)
        : undefined;
    const bottomExtra = chart._xCategoryDomain
        ? measureCategoricalAxisHeight(chart._xCategoryDomain, estPlotWidth)
        : undefined;

    const layout = new PlotLayout(cssWidth, cssHeight, {
        hasXLabel: !!chart._xLabel,
        hasYLabel: !!chart._yLabel,
        hasLegend: hasColorCol,
        leftExtra,
        bottomExtra,
    });
    chart._lastLayout = layout;
    if (chart._zoomController) {
        chart._zoomController.updateLayout(layout);
    }

    const projection = layout.buildProjectionMatrix(
        domain.xMin,
        domain.xMax,
        domain.yMin,
        domain.yMax,
        undefined,
        undefined,
        undefined,
        rebaseOrigin(chart._xOrigin),
        rebaseOrigin(chart._yOrigin),
    );

    const xDomain = buildXDomain(chart, domain.xMin, domain.xMax, xIsDate);
    const yDomain = buildYDomain(chart, domain.yMin, domain.yMax, yIsDate);
    const numericTicks = computeTicks(xDomain, yDomain, layout);
    const xTicks = chart._xIsString ? [] : numericTicks.xTicks;
    const yTicks = chart._yIsString ? [] : numericTicks.yTicks;

    const isMap = chart._renderMode === "map";

    if (chart._gridlineCanvas && !isMap) {
        // One-shot destructive prep (resizes + clears + scales to DPR).
        // `renderGridlines` itself is non-destructive.
        const dpr = glManager.dpr;
        initCanvas(chart._gridlineCanvas, layout, dpr);
        renderGridlines(
            chart._gridlineCanvas,
            layout,
            xTicks,
            yTicks,
            theme,
            dpr,
        );
    } else if (chart._gridlineCanvas && isMap) {
        // Map mode draws no cartesian gridlines, but the gridline
        // canvas may carry stale ink from a prior cartesian chart
        // type. Reset it to a clean transparent surface so the
        // basemap (rendered into the GL canvas below) reads as the
        // only background layer.
        initCanvas(chart._gridlineCanvas, layout, glManager.dpr);
    }

    renderInPlotFrame(gl, layout, glManager.dpr, () => {
        if (isMap) {
            chart.renderBackground(
                glManager,
                layout,
                projection,
                domain,
                rebaseOrigin(chart._xOrigin),
                rebaseOrigin(chart._yOrigin),
            );
        }

        chart.glyph.draw(chart, glManager, projection);
    });

    chart._lastXDomain = xDomain;
    chart._lastYDomain = yDomain;
    chart._lastXTicks = xTicks;
    chart._lastYTicks = yTicks;
    chart._lastGradientStops = theme.gradientStops;
    chart._lastHasColorCol = hasColorCol;
}

/**
 * Faceted render path — one sub-plot per split, laid out in a grid.
 * Each facet gets its own `PlotLayout` (with canvas-absolute margins),
 * its own projection matrix, and one `drawSeries(s)` dispatch inside
 * its scissor rect. Shader, buffers, gradient texture, and zoom
 * controller state are all shared.
 *
 * Shared-zoom mode uses one global domain for every facet's projection
 * (current default). Independent-zoom mode (Stage 6) will consult a
 * per-facet `ZoomController`.
 */
function renderFacetedFrame(
    chart: CartesianChart,
    glManager: WebGLContextManager,
    domain: { xMin: number; xMax: number; yMin: number; yMax: number },
    theme: Theme,
    ctx: RenderFrameCtx,
): void {
    const gl = glManager.gl;
    const { cssWidth, cssHeight, xIsDate, yIsDate } = ctx;

    // Materialize per-axis categorical domains (shared across facets:
    // the build dictionary is global, so slot N refers to the same
    // string in every cell). Faceted layout still uses the default
    // per-cell gutters from `buildFacetGrid` — rotated leaf labels in
    // tight cells may overflow but won't crash.
    chart._xCategoryDomain =
        chart._xIsString && chart._xCategoryDictionary.length > 0
            ? buildCategoricalDomainFromDict(
                  chart._xCategoryDictionary,
                  chart._xLabel || chart._xName || "",
              )
            : null;
    chart._yCategoryDomain =
        chart._yIsString && chart._yCategoryDictionary.length > 0
            ? buildCategoricalDomainFromDict(
                  chart._yCategoryDictionary,
                  chart._yLabel || chart._yName || "",
              )
            : null;

    const labels = chart._splitGroups.map((g) => g.prefix);

    // Legend: reserve space only when the user wired a color column.
    //   - string column: categorical swatches from `_uniqueColorLabels`.
    //   - numeric column: gradient bar from `_colorMin/_colorMax`.
    //   - no color column: no legend (facets alone don't warrant one).
    const hasCategoricalLegend =
        chart._colorIsString && chart._uniqueColorLabels.size > 1;
    const hasGradientLegend =
        !!chart._colorName &&
        !chart._colorIsString &&
        chart._colorMin < chart._colorMax;
    const hasLegend = hasCategoricalLegend || hasGradientLegend;

    // Use the frame-local effective flags (set in
    // `renderCartesianFrame`) so independent-zoom mode falls through
    // to per-cell axes without mutating the user's stored
    // `_facetConfig.shared_x_axis` / `shared_y_axis`. Continuous
    // charts always have both axes, so the false branch maps to
    // per-cell mode (never to "none", which is reserved for tree
    // charts).
    const grid: FacetGrid = buildFacetGrid(labels, {
        cssWidth,
        cssHeight,
        xAxis: chart._lastEffectiveSharedX ? "outer" : "cell",
        yAxis: chart._lastEffectiveSharedY ? "outer" : "cell",
        hasLegend,
        hasXLabel: !!chart._xLabel,
        hasYLabel: !!chart._yLabel,
        gap: chart._facetConfig.facet_padding,
    });
    chart._facetGrid = grid;

    // Grid invariant: every cell has the same plot rect dimensions.
    // Downstream code (tick sampling, projection math) depends on
    // this. The O(N) comparison runs at most once per frame and bails
    // at the first mismatch — cheap enough to leave on unconditionally.
    if (grid.cells.length > 1) {
        const r0 = grid.cells[0].layout.plotRect;
        for (let i = 1; i < grid.cells.length; i++) {
            const r = grid.cells[i].layout.plotRect;
            if (r.width !== r0.width || r.height !== r0.height) {
                console.warn(
                    `facet-grid: cell ${i} size (${r.width}×${r.height}) ` +
                        `differs from cell 0 (${r0.width}×${r0.height})`,
                );
                break;
            }
        }
    }

    // `_lastLayout` backs the hover hit-test in `continuous-interact.ts`.
    // In faceted mode the hover routine resolves the facet under the
    // cursor and consults that cell's layout directly; for legacy
    // fallback (shouldn't fire), publish the first cell's layout.
    chart._lastLayout = grid.cells[0]?.layout ?? null;

    // Keep every controller's layout pointer fresh for wheel/pan math.
    chart.syncFacetZoomLayouts(grid.cells);
    const independent = chart._facetConfig.zoom_mode === "independent";

    const xDomain = buildXDomain(chart, domain.xMin, domain.xMax, xIsDate);
    const yDomain = buildYDomain(chart, domain.yMin, domain.yMax, yIsDate);

    // Gridlines + per-facet axes use the first cell's layout for tick
    // sampling (all cells have identical plotRect dimensions). Per-facet
    // rendering then reuses the same tick arrays. Categorical sides
    // skip numeric tick computation; the categorical painter handles
    // its own label placement off the dictionary.
    const sampleLayout = grid.cells[0]?.layout;
    const numericFacetTicks = sampleLayout
        ? computeTicks(xDomain, yDomain, sampleLayout)
        : { xTicks: [], yTicks: [] };
    const xTicks = chart._xIsString ? [] : numericFacetTicks.xTicks;
    const yTicks = chart._yIsString ? [] : numericFacetTicks.yTicks;

    // One-shot destructive prep for the gridline + WebGL canvases.
    // Both phases below are per-facet; calling their destructive
    // helpers (initCanvas / renderInPlotFrame) in the loop would wipe
    // every previously-drawn facet, leaving only the last cell
    // visible.
    if (chart._gridlineCanvas && sampleLayout) {
        initCanvas(chart._gridlineCanvas, sampleLayout, glManager.dpr);
    }

    clearAndSetupFrame(gl);

    for (let i = 0; i < grid.cells.length; i++) {
        const cell = grid.cells[i];
        const zc = chart.getZoomControllerForFacet(i);
        const facetDomain = independent && zc ? zc.getVisibleDomain() : domain;

        // `buildProjectionMatrix` must run before `renderGridlines`:
        // it seeds the padded-domain fields on `cell.layout` that
        // `dataToPixel` (used by gridline tick → pixel mapping) reads.
        // Skipping this order leaves the layout on its default
        // `[0, 1]` padded domain, and every tick pixel falls outside
        // the cell's `plotRect`, so `drawGridlinesX/Y` filters them
        // all out and the gridline canvas stays blank.
        const projection = cell.layout.buildProjectionMatrix(
            facetDomain.xMin,
            facetDomain.xMax,
            facetDomain.yMin,
            facetDomain.yMax,
            undefined,
            undefined,
            undefined,
            rebaseOrigin(chart._xOrigin),
            rebaseOrigin(chart._yOrigin),
        );

        // Per-facet gridlines: reuse shared ticks in shared-zoom mode,
        // compute fresh ticks in independent mode (each facet has its
        // own domain). Map mode skips gridlines entirely; the
        // basemap layer is rendered into the GL canvas inside the
        // facet's scissor below.
        const isMap = chart._renderMode === "map";
        if (chart._gridlineCanvas && !isMap) {
            const localXTicks = independent
                ? computeTicks(
                      buildXDomain(
                          chart,
                          facetDomain.xMin,
                          facetDomain.xMax,
                          xIsDate,
                      ),
                      buildYDomain(
                          chart,
                          facetDomain.yMin,
                          facetDomain.yMax,
                          yIsDate,
                      ),
                      cell.layout,
                  ).xTicks
                : xTicks;
            const localYTicks = independent
                ? computeTicks(
                      buildXDomain(
                          chart,
                          facetDomain.xMin,
                          facetDomain.xMax,
                          xIsDate,
                      ),
                      buildYDomain(
                          chart,
                          facetDomain.yMin,
                          facetDomain.yMax,
                          yIsDate,
                      ),
                      cell.layout,
                  ).yTicks
                : yTicks;
            renderGridlines(
                chart._gridlineCanvas,
                cell.layout,
                localXTicks,
                localYTicks,
                theme,
                glManager.dpr,
            );
        }

        withScissor(gl, cell.layout, glManager.dpr, () => {
            if (isMap) {
                chart.renderBackground(
                    glManager,
                    cell.layout,
                    projection,
                    facetDomain,
                    rebaseOrigin(chart._xOrigin),
                    rebaseOrigin(chart._yOrigin),
                );
            }

            chart.glyph.drawSeries(chart, glManager, projection, i);
        });
    }

    chart._lastXDomain = xDomain;
    chart._lastYDomain = yDomain;
    chart._lastXTicks = xTicks;
    chart._lastYTicks = yTicks;
    chart._lastGradientStops = theme.gradientStops;
    chart._lastHasColorCol = hasLegend;
}

/**
 * Redraw the chrome canvas only. Used for lightweight hover updates.
 */
export function renderCartesianChromeOverlay(chart: CartesianChart): void {
    if (
        !chart._chromeCanvas ||
        !chart._lastLayout ||
        !chart._lastXDomain ||
        !chart._lastYDomain ||
        !chart._glManager
    ) {
        return;
    }

    // One-shot destructive prep for the chrome canvas — resizes to
    // CSS × DPR and scales the transform. Per-facet calls below read
    // the already-prepared context via `getScaledContext` so the
    // bitmap persists across the loop.
    initCanvas(chart._chromeCanvas, chart._lastLayout, chart._glManager.dpr);
    if (chart._facetGrid) {
        renderFacetedChromeOverlay(chart);
    } else {
        renderSinglePlotChromeOverlay(chart);
    }
}

function renderSinglePlotChromeOverlay(chart: CartesianChart): void {
    const layout = chart._lastLayout!;
    const theme = chart._resolveTheme();
    const dpr = chart._glManager?.dpr ?? 1;
    const isMap = chart._renderMode === "map";

    if (isMap) {
        chart.renderMapChrome(chart._chromeCanvas!, layout, theme, dpr);
    } else {
        renderCartesianCellAxes(
            chart,
            chart._chromeCanvas!,
            layout,
            chart._lastXDomain!,
            chart._lastYDomain!,
            chart._lastXTicks!,
            chart._lastYTicks!,
            theme,
            dpr,
        );
    }

    if (chart._lastHasColorCol) {
        const stops = chart._lastGradientStops ?? theme.gradientStops;
        if (chart._colorIsString && chart._uniqueColorLabels.size > 0) {
            const palette = resolvePalette(
                theme.seriesPalette,
                stops,
                chart._uniqueColorLabels.size,
            );
            renderCategoricalLegend(
                chart._chromeCanvas!,
                layout,
                chart._uniqueColorLabels,
                palette,
                theme,
            );
        } else if (chart._colorName) {
            renderLegend(
                chart._chromeCanvas!,
                layout,
                {
                    min: chart._colorMin,
                    max: chart._colorMax,
                    label: chart._colorName,
                },
                stops,
                theme,
                chart.getColumnFormatter(chart._colorName, "value"),
            );
        }
    }

    renderScatterLabels(chart, chart._chromeCanvas!, layout, 0, 1);

    if (chart._hoveredIndex >= 0 && chart._xData && chart._yData) {
        renderTooltip(chart, chart._chromeCanvas!, layout);
    }
}

function renderFacetedChromeOverlay(chart: CartesianChart): void {
    const grid = chart._facetGrid!;
    const canvas = chart._chromeCanvas!;
    const theme = chart._resolveTheme();
    const dpr = chart._glManager?.dpr ?? 1;
    const sharedXTicks = chart._lastXTicks!;
    const sharedYTicks = chart._lastYTicks!;
    const xDomain = chart._lastXDomain!;
    const yDomain = chart._lastYDomain!;
    const isMap = chart._renderMode === "map";

    // Read the frame-local effective flags set by `renderCartesianFrame`
    // — these already fold in the independent-zoom override (outer
    // axes are incompatible with per-cell viewports), so `sharedX` /
    // `sharedY` true here implies shared-zoom too.
    //
    // Categorical axes additionally force per-cell rendering: the
    // outer-axis painter is numeric-only and the shared dictionary
    // already produces the same labels in every cell, so a per-cell
    // categorical axis is equivalent to a shared one visually.
    const sharedX = chart._lastEffectiveSharedX && !chart._xIsString;
    const sharedY = chart._lastEffectiveSharedY && !chart._yIsString;
    const independent = chart._facetConfig.zoom_mode === "independent";

    // Shared X axis: one outer band across the bottom of the grid,
    // with ticks painted per-column (one pass per bottom-row cell).
    // Shared Y axis: one outer band down the left, ticks per-row
    // (one pass per leftmost-column cell). Map mode replaces both
    // with `renderMapChrome` (attribution + scale bar), painted once
    // over the whole facet grid.
    if (isMap) {
        chart.renderMapChrome(canvas, chart._lastLayout!, theme, dpr);
    }

    if (!isMap && sharedX && grid.outerXAxisRect) {
        renderOuterXAxis(
            canvas,
            grid.outerXAxisRect,
            xDomain,
            sharedXTicks,
            bottomRowLayouts(grid),
            theme,
            !!chart._xLabel,
            dpr,
            chart.getColumnFormatter(chart._xName, "tick"),
        );
    }

    if (!isMap && sharedY && grid.outerYAxisRect) {
        renderOuterYAxis(
            canvas,
            grid.outerYAxisRect,
            yDomain,
            sharedYTicks,
            leftColumnLayouts(grid),
            theme,
            !!chart._yLabel,
            dpr,
            chart.getColumnFormatter(chart._yName, "tick"),
        );
    }

    // Per-facet axes for the non-shared sides + title strips.
    // Map mode skips per-cell axis rendering (no cartesian axes
    // belong on a map) but still paints facet titles and labels.
    for (let i = 0; i < grid.cells.length; i++) {
        const cell = grid.cells[i];
        const zc = independent ? chart.getZoomControllerForFacet(i) : null;
        const d = zc ? zc.getVisibleDomain() : null;
        const localX = d ? { ...xDomain, min: d.xMin, max: d.xMax } : xDomain;
        const localY = d ? { ...yDomain, min: d.yMin, max: d.yMax } : yDomain;
        const ticks = independent
            ? computeTicks(localX, localY, cell.layout)
            : { xTicks: sharedXTicks, yTicks: sharedYTicks };

        if (!isMap && !sharedX) {
            if (chart._xIsString && chart._xCategoryDomain) {
                const cellCtx = getScaledContext(canvas, dpr);
                if (cellCtx) {
                    renderCategoricalXTicks(
                        cellCtx,
                        cell.layout,
                        chart._xCategoryDomain,
                        theme,
                    );
                }
            } else {
                renderCellXAxis(
                    canvas,
                    localX,
                    cell.layout,
                    ticks.xTicks,
                    theme,
                    !!chart._xLabel,
                    dpr,
                    chart.getColumnFormatter(chart._xName, "tick"),
                );
            }
        }

        if (!isMap && !sharedY) {
            if (chart._yIsString && chart._yCategoryDomain) {
                const cellCtx = getScaledContext(canvas, dpr);
                if (cellCtx) {
                    renderCategoricalYTicks(
                        cellCtx,
                        cell.layout,
                        chart._yCategoryDomain,
                        theme,
                    );
                }
            } else {
                renderCellYAxis(
                    canvas,
                    localY,
                    cell.layout,
                    ticks.yTicks,
                    theme,
                    !!chart._yLabel,
                    dpr,
                    chart.getColumnFormatter(chart._yName, "tick"),
                );
            }
        }

        if (cell.titleRect) {
            drawFacetTitle(canvas, cell.label, cell.titleRect, theme, dpr);
        }

        renderScatterLabels(chart, canvas, cell.layout, i, i + 1);
    }

    // Shared legend: categorical (string color) or gradient
    // (numeric color). Position derives from `grid.legendRect`
    // which `buildFacetGrid` populates when `hasLegend` was set.
    if (chart._lastHasColorCol && grid.legendRect) {
        const stops = chart._lastGradientStops ?? theme.gradientStops;
        if (chart._colorIsString && chart._uniqueColorLabels.size > 0) {
            const palette = resolvePalette(
                theme.seriesPalette,
                stops,
                Math.max(1, chart._uniqueColorLabels.size),
            );
            renderCategoricalLegendAt(
                canvas,
                grid.legendRect,
                chart._uniqueColorLabels,
                palette,
                theme,
            );
        } else if (chart._colorName) {
            // Numeric gradient legend in the shared outer rect. The
            // label sits above the bar, so inset the rect's top by
            // the usual 20 px that `renderLegend` reserves.
            renderLegendAt(
                canvas,
                {
                    x: grid.legendRect.x,
                    y: grid.legendRect.y + 20,
                    width: grid.legendRect.width,
                    height: grid.legendRect.height - 20,
                },
                {
                    min: chart._colorMin,
                    max: chart._colorMax,
                    label: chart._colorName,
                },
                stops,
                theme,
                chart.getColumnFormatter(chart._colorName, "value"),
            );
        }
    }

    // Coordinated hover / click indicators across facets. The tooltip
    // lines are whatever the last resolved lazy fetch produced (or
    // null while a fetch is still in flight); `renderCanvasTooltip`
    // paints crosshair + ring regardless, but skips the text box
    // until lines are available. See `handleCartesianHover`.
    if (chart._hoveredIndex >= 0 && chart._xData && chart._yData) {
        // `_xData`/`_yData` are rebased; `dataToPixel` expects absolute
        // domain coords (matching `paddedXMin`/`paddedXMax`), so undo
        // the rebase before mapping.
        const xOrigin = isNaN(chart._xOrigin) ? 0 : chart._xOrigin;
        const yOrigin = isNaN(chart._yOrigin) ? 0 : chart._yOrigin;
        const dataX = chart._xData[chart._hoveredIndex] + xOrigin;
        const dataY = chart._yData[chart._hoveredIndex] + yOrigin;
        const sourceFacet = seriesFromIndex(chart, chart._hoveredIndex);
        const opts = chart.glyph.tooltipOptions();
        const tooltipLines = chart._lazyTooltip.lines ?? [];

        for (let i = 0; i < grid.cells.length; i++) {
            const cell = grid.cells[i];
            const isSource = i === sourceFacet;

            // Pixel position inside this facet for the source point's
            // data coordinate — ghost indicator in non-source facets.
            const pos = cell.layout.dataToPixel(dataX, dataY);
            const plot = cell.layout.plotRect;
            if (
                pos.px < plot.x ||
                pos.px > plot.x + plot.width ||
                pos.py < plot.y ||
                pos.py > plot.y + plot.height
            ) {
                continue;
            }

            const coordinated = chart._facetConfig.coordinated_tooltip;
            const lines = isSource || coordinated ? tooltipLines : [];
            renderCanvasTooltip(canvas, pos, lines, cell.layout, theme, dpr, {
                crosshair: opts.crosshair,
                highlightRadius: isSource ? opts.highlightRadius : 0,
            });
        }
    }
}

/**
 * Map a flat slotted index back to its series (facet) index.
 */
export function seriesFromIndex(
    chart: CartesianChart,
    flatIdx: number,
): number {
    if (chart._seriesCapacity <= 0) {
        return 0;
    }

    return Math.floor(flatIdx / chart._seriesCapacity);
}

/**
 * Maximum scatter labels painted in a single chrome pass. Beyond this
 * we sample with a fixed stride so the canvas pass stays bounded as
 * the user zooms out. The chrome overlay redraws on hover, so an
 * unbounded `fillText` loop would stutter on every mouse move.
 */
const MAX_SCATTER_LABELS = 5_000;

/**
 * Draw the scatter-label column (slot 4) as 2D text next to each
 * visible point. Labels are anchored slightly to the right of the
 * point and vertically centered on it, painted in the theme's
 * `labelColor`. Caller scopes us to a series range so faceted mode
 * draws only the cell's own labels.
 */
function renderScatterLabels(
    chart: CartesianChart,
    canvas: Canvas2D,
    layout: PlotLayout,
    seriesStart: number,
    seriesEnd: number,
): void {
    if (!chart._labels || !chart._xData || !chart._yData) {
        return;
    }

    const dict = chart._labels.dictionary;
    const labelData = chart._labels.data;
    const xData = chart._xData;
    const yData = chart._yData;
    const xOrigin = isNaN(chart._xOrigin) ? 0 : chart._xOrigin;
    const yOrigin = isNaN(chart._yOrigin) ? 0 : chart._yOrigin;
    const cap = chart._seriesCapacity;
    if (cap <= 0) {
        return;
    }

    let visibleCount = 0;
    for (let s = seriesStart; s < seriesEnd; s++) {
        visibleCount += chart._seriesUploadedCounts[s] ?? 0;
    }

    if (visibleCount === 0) {
        return;
    }

    const dpr = chart._glManager?.dpr ?? 1;
    const ctx = getScaledContext(canvas, dpr);
    if (!ctx) {
        return;
    }

    const theme = chart._resolveTheme();
    const plot = layout.plotRect;
    const stride = Math.max(1, Math.ceil(visibleCount / MAX_SCATTER_LABELS));

    ctx.save();
    ctx.font = `11px ${theme.fontFamily}`;
    ctx.fillStyle = theme.labelColor;
    ctx.textAlign = "left";
    ctx.textBaseline = "middle";

    for (let s = seriesStart; s < seriesEnd; s++) {
        const count = chart._seriesUploadedCounts[s] ?? 0;
        const base = s * cap;
        for (let j = 0; j < count; j += stride) {
            const idx = base + j;
            const dictIdx = labelData[idx];
            if (dictIdx < 0) {
                continue;
            }

            const { px, py } = layout.dataToPixel(
                xData[idx] + xOrigin,
                yData[idx] + yOrigin,
            );
            if (
                px < plot.x ||
                px > plot.x + plot.width ||
                py < plot.y ||
                py > plot.y + plot.height
            ) {
                continue;
            }

            ctx.fillText(dict[dictIdx], px + 8, py - 4);
        }
    }

    ctx.restore();
}

function renderTooltip(
    chart: CartesianChart,
    canvas: Canvas2D,
    layout: PlotLayout,
): void {
    const idx = chart._hoveredIndex;
    if (idx < 0 || !chart._xData || !chart._yData) {
        return;
    }

    const xOrigin = isNaN(chart._xOrigin) ? 0 : chart._xOrigin;
    const yOrigin = isNaN(chart._yOrigin) ? 0 : chart._yOrigin;
    const pos = layout.dataToPixel(
        chart._xData[idx] + xOrigin,
        chart._yData[idx] + yOrigin,
    );

    // Lines come from the async lazy tooltip fetch kicked off in
    // `handleCartesianHover`. While a fetch is in flight this is
    // `null`; the canvas tooltip helper still paints the crosshair /
    // highlight ring but skips the text box.
    const lines = chart._lazyTooltip.lines ?? [];
    const theme = chart._resolveTheme();
    renderCanvasTooltip(
        canvas,
        pos,
        lines,
        layout,
        theme,
        chart._glManager?.dpr ?? 1,
        chart.glyph.tooltipOptions(),
    );
}
