// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { ColumnDataMap, ColumnData } from "../../data/view-reader";
import { buildSplitGroups } from "../../data/split-groups";
import type { WebGLContextManager } from "../../webgl/context-manager";
import type { CartesianChart, SplitGroup } from "./cartesian";
import { LabelInterner } from "./label-interner";

/**
 * Resolve a row's string value into a slot index in `dictionary`,
 * inserting on first encounter. Invalid / missing values land in a
 * lazily-added `"(null)"` slot — no reserved slot 0 when the data has
 * no nulls. Shared by the X and Y categorical paths in
 * `processCartesianChunk`.
 */
function lookupCategorySlot(
    col: ColumnData | null,
    rowIdx: number,
    dictionary: string[],
    seen: Map<string, number>,
): number {
    let label: string;
    if (!col) {
        label = "(null)";
    } else {
        const valid = col.valid;
        const isValid = valid
            ? !!((valid[rowIdx >> 3] >> (rowIdx & 7)) & 1)
            : true;
        if (!isValid) {
            label = "(null)";
        } else if (col.indices && col.dictionary) {
            label = col.dictionary[col.indices[rowIdx]] ?? "(null)";
        } else if (col.values) {
            const v = col.values[rowIdx];
            label = v == null ? "(null)" : String(v);
        } else {
            label = "(null)";
        }
    }

    let slot = seen.get(label);
    if (slot === undefined) {
        slot = dictionary.length;
        dictionary.push(label);
        seen.set(label, slot);
    }

    return slot;
}

/**
 * Resolve per-split-prefix column-name tuples. `colorBase`/`sizeBase`
 * are optional (empty string when the corresponding slot is unset).
 */
function buildCartesianSplitGroups(
    columns: ColumnDataMap,
    xBase: string,
    yBase: string,
    colorBase: string,
    sizeBase: string,
    labelBase: string,
): SplitGroup[] {
    const required = xBase ? [xBase, yBase] : [yBase];
    const optional: string[] = [];
    if (colorBase) {
        optional.push(colorBase);
    }

    if (sizeBase) {
        optional.push(sizeBase);
    }

    if (labelBase) {
        optional.push(labelBase);
    }

    return buildSplitGroups(columns, required, optional).map((g) => ({
        prefix: g.prefix,
        xColName: xBase ? g.colNames.get(xBase)! : "",
        yColName: g.colNames.get(yBase)!,
        colorColName: colorBase ? `${g.prefix}|${colorBase}` : "",
        sizeColName: sizeBase ? `${g.prefix}|${sizeBase}` : "",
        labelColName: labelBase ? `${g.prefix}|${labelBase}` : "",
    }));
}

/**
 * First-chunk init: compile the glyph program, reset data extents,
 * resolve column roles and split groups, pre-allocate CPU + GPU buffers.
 */
export function initCartesianPipeline(
    chart: CartesianChart,
    glManager: WebGLContextManager,
    columns: ColumnDataMap,
    endRow: number,
): void {
    chart.glyph.ensureProgram(chart, glManager);

    const prevColorName = chart._colorName;
    const prevColorIsString = chart._colorIsString;

    // `domain_mode: "expand"` seeds the per-build extents from the
    // running accumulator instead of `±Infinity`, so the per-row scan
    // below naturally unions new data into the previously rendered
    // domain / range / color / size scales. `"fit"` clears the
    // accumulator alongside the live extents so toggling back to
    // expand later starts from a fresh baseline.
    const expand = chart._pluginConfig.domain_mode === "expand";
    if (expand) {
        chart._xMin = chart._expandedXMin;
        chart._xMax = chart._expandedXMax;
        chart._yMin = chart._expandedYMin;
        chart._yMax = chart._expandedYMax;
        chart._colorMin = chart._expandedColorMin;
        chart._colorMax = chart._expandedColorMax;
        chart._sizeMin = chart._expandedSizeMin;
        chart._sizeMax = chart._expandedSizeMax;
    } else {
        chart._xMin = Infinity;
        chart._xMax = -Infinity;
        chart._yMin = Infinity;
        chart._yMax = -Infinity;
        chart._colorMin = Infinity;
        chart._colorMax = -Infinity;
        chart._sizeMin = Infinity;
        chart._sizeMax = -Infinity;
        chart._expandedXMin = Infinity;
        chart._expandedXMax = -Infinity;
        chart._expandedYMin = Infinity;
        chart._expandedYMax = -Infinity;
        chart._expandedColorMin = Infinity;
        chart._expandedColorMax = -Infinity;
        chart._expandedSizeMin = Infinity;
        chart._expandedSizeMax = -Infinity;
    }

    chart._xOrigin = NaN;
    chart._yOrigin = NaN;
    chart._dataCount = 0;
    chart._hitTest.clear();
    chart._maxSeriesUploaded = 0;

    const slots = chart._columnSlots;

    // Line uses `[yBase]` with row-index X; scatter and X/Y Line use
    // `[xBase, yBase, colorBase, sizeBase]`. A single positional layout
    // handles both: treat an empty slot[0] as "X = row index".
    const xBase = slots[0] || "";
    const yBase = slots[1] || "";
    const colorBase = slots[2] || "";
    const sizeBase = slots[3] || "";
    const labelBase = slots[4] || "";
    chart._xLabel = xBase;
    chart._yLabel = yBase;
    chart._xIsRowIndex = !xBase;

    // Post-aggregation `string` columns on X / Y switch the axis to
    // categorical: per-row slot indices are written into `_xData` /
    // `_yData` instead of raw values, and the chrome overlay paints a
    // categorical axis. Reset the per-frame dictionary state here at
    // chunk 0 so slot 0 is always the first non-null label encountered
    // in arrival order (matching the perspective view's sort).
    chart._xIsString = !!xBase && chart._columnTypes[xBase] === "string";
    chart._yIsString = !!yBase && chart._columnTypes[yBase] === "string";
    chart._xCategoryDictionary = [];
    chart._yCategoryDictionary = [];
    chart._xCategorySeen = new Map();
    chart._yCategorySeen = new Map();
    chart._xCategoryDomain = null;
    chart._yCategoryDomain = null;

    // Categorical axes use 0-based slot indices, so the rebase origin
    // is fixed at 0. Skipping the NaN-init guard below prevents the
    // first-seen-slot from being adopted as the origin (which would
    // shift every other slot's pixel position).
    if (chart._xIsString) {
        chart._xOrigin = 0;
        chart._xMin = 0;
        chart._xMax = 0;
        chart._expandedXMin = Infinity;
        chart._expandedXMax = -Infinity;
    }

    if (chart._yIsString) {
        chart._yOrigin = 0;
        chart._yMin = 0;
        chart._yMax = 0;
        chart._expandedYMin = Infinity;
        chart._expandedYMax = -Infinity;
    }

    // Capture the per-series row budget BEFORE any split expansion. When
    // split_by is active we grow `totalCapacity` to fit `numSplits`
    // parallel slot ranges; reading `totalCapacity` again after that
    // would hand every series the whole expanded buffer and cause
    // series 1..N writes to overshoot the GPU buffer.
    const rowsPerSeries = glManager.bufferPool.totalCapacity || endRow;

    if (chart._splitBy.length > 0) {
        chart._splitGroups = buildCartesianSplitGroups(
            columns,
            xBase,
            yBase,
            colorBase,
            sizeBase,
            labelBase,
        );
        if (chart._splitGroups.length === 0) {
            chart._seriesCapacity = 0;
            chart._seriesUploadedCounts = [];
            return;
        }

        // Split mode: per-point columns live under `${prefix}|${base}`.
        // The `_*Name` fields hold the base names so downstream code
        // (render labels, tooltip lookup) can present them as one
        // logical column. The per-facet resolution happens inside
        // `processCartesianChunk` via `_splitGroups[i].*ColName`.
        chart._xName = chart._splitGroups[0].xColName;
        chart._yName = chart._splitGroups[0].yColName;
        chart._colorName = colorBase;
        chart._sizeName = sizeBase;
        chart._labelName = labelBase;

        // Infer dtype from any split's color column — all splits
        // share the same underlying column type.
        chart._colorIsString = false;
        if (colorBase) {
            const firstColorCol = columns.get(
                chart._splitGroups[0].colorColName,
            );
            chart._colorIsString = firstColorCol?.type === "string";
        }

        glManager.ensureBufferCapacity(
            rowsPerSeries * chart._splitGroups.length,
        );
    } else {
        chart._splitGroups = [];
        chart._xName = xBase;
        chart._yName = yBase;
        chart._colorName = colorBase;
        chart._sizeName = sizeBase;
        chart._labelName = labelBase;
        chart._colorIsString = false;

        if (chart._colorName) {
            const colorCol = columns.get(chart._colorName);
            chart._colorIsString = colorCol?.type === "string";
        }
    }

    // Color label identity persists across `update()` calls so a given
    // string keeps the same palette index for as long as the color column
    // stays the same — perspective's dictionary encoding does not promise
    // a stable index order between batches, so re-seeding from scratch
    // would shuffle every label's color on each update. Reset only when
    // the user changes the column or its dtype (string ↔ numeric); a
    // numeric color column doesn't use this map and clearing keeps it
    // small.
    if (
        chart._colorName !== prevColorName ||
        chart._colorIsString !== prevColorIsString
    ) {
        chart._uniqueColorLabels = new Map();
    }

    const numSeries = Math.max(1, chart._splitGroups.length);
    chart._seriesCapacity = rowsPerSeries;
    chart._seriesUploadedCounts = new Array(numSeries).fill(0);

    const cpuCap = numSeries * rowsPerSeries;
    chart._xData = new Float32Array(cpuCap);
    chart._yData = new Float32Array(cpuCap);
    chart._colorData = new Float32Array(cpuCap);
    chart._rowIndexData = new Int32Array(cpuCap);

    chart._labels = labelBase ? new LabelInterner(cpuCap) : null;
}

/**
 * Process one data chunk: extract positions + optional color/size per
 * point, extend extents, write into per-series slots, capture tooltip
 * data, and let the glyph upload its own GPU attribute buffers.
 */
export function processCartesianChunk(
    chart: CartesianChart,
    glManager: WebGLContextManager,
    columns: ColumnDataMap,
    startRow: number,
    chunkLength: number,
    endRow: number,
): void {
    if (!chart._yName) {
        return;
    }

    const sourceLength = chunkLength;
    if (sourceLength === 0) {
        return;
    }

    if (chart._seriesCapacity === 0) {
        return;
    }

    const hasSplits = chart._splitGroups.length > 0;

    // Per-series data source. `colorCol` is the facet's color column
    // reference — in split mode each series has its own
    // `${prefix}|${colorBase}`, in non-split mode the single series
    // carries the user's selected color column. The color-resolution
    // logic in the inner loop reads uniformly from `ser.colorCol`
    // across both modes.
    //
    // `xColData` / `yColData` carry the full `ColumnData` so the
    // categorical path can read `indices` + `dictionary` for slot
    // lookup; `xCol` / `yCol` keep the numeric fast path zero-cost
    // (and stay `null` on string columns where `values` is unset).
    type SeriesSrc = {
        xCol: Float32Array | Float64Array | Int32Array | null;
        yCol: Float32Array | Float64Array | Int32Array | null;
        xColData: ColumnData | null;
        yColData: ColumnData | null;
        xValid: Uint8Array | undefined;
        yValid: Uint8Array | undefined;
        colorCol: ColumnData | null;
        sizeCol: (Float32Array | Float64Array | Int32Array) | null;
        labelCol: ColumnData | null;
    };
    const series: SeriesSrc[] = [];

    if (hasSplits) {
        for (const sg of chart._splitGroups) {
            const xc = sg.xColName ? columns.get(sg.xColName) : null;
            const yc = columns.get(sg.yColName);
            if (!yc) {
                continue;
            }

            if (!chart._yIsString && !yc.values) {
                continue;
            }

            const sc = sg.sizeColName ? columns.get(sg.sizeColName) : null;
            const cc = sg.colorColName
                ? (columns.get(sg.colorColName) ?? null)
                : null;
            const lc = sg.labelColName
                ? (columns.get(sg.labelColName) ?? null)
                : null;
            series.push({
                xCol: xc?.values ?? null,
                yCol: yc.values ?? null,
                xColData: xc ?? null,
                yColData: yc,
                xValid: xc?.valid,
                yValid: yc.valid,
                colorCol: cc,
                sizeCol: sc?.values ?? null,
                labelCol: lc,
            });
        }
    } else {
        const xc = chart._xName ? columns.get(chart._xName) : null;
        const yc = chart._yName ? columns.get(chart._yName) : null;
        if (!yc) {
            return;
        }

        if (!chart._yIsString && !yc.values) {
            return;
        }

        const cc = chart._colorName
            ? (columns.get(chart._colorName) ?? null)
            : null;
        const lc = chart._labelName
            ? (columns.get(chart._labelName) ?? null)
            : null;
        series.push({
            xCol: xc?.values ?? null,
            yCol: yc.values ?? null,
            xColData: xc ?? null,
            yColData: yc,
            xValid: xc?.valid,
            yValid: yc?.valid,
            colorCol: cc,
            sizeCol: null,
            labelCol: lc,
        });
    }

    if (series.length === 0) {
        return;
    }

    if (chart._stagingChunkSize < sourceLength) {
        chart._stagingPositions = new Float32Array(sourceLength * 2);
        chart._stagingColors = new Float32Array(sourceLength);
        chart._stagingSizes = new Float32Array(sourceLength);
        chart._stagingChunkSize = sourceLength;
    }

    const positions = chart._stagingPositions!;
    const colorValues = chart._stagingColors!;
    const sizeValues = chart._stagingSizes!;

    // Non-split size column: resolve once; inner loop reads values[i].
    const nonSplitSizeValues =
        !hasSplits && chart._sizeName
            ? (columns.get(chart._sizeName)?.values ?? null)
            : null;

    // Seed `_uniqueColorLabels` from the color column's dictionary in
    // index order. For a stable single dictionary this makes
    // `palette[_uniqueColorLabels.get(label)] === palette[dictIdx %
    // N]`. For splits (distinct dictionaries per facet) values that
    // appear in multiple splits are inserted once — later splits
    // extend the map without disturbing earlier indices, so the
    // same string has the same color in every facet.
    //
    // Also pin `_colorMin` / `_colorMax` to the full palette-index
    // domain. If the row loop only encountered a subset of indices
    // we'd otherwise set a narrower range and the shader's
    // `(v - min) / (max - min)` mapping would land on the wrong
    // palette stop.
    if (chart._colorIsString && chart._colorName) {
        for (const ser of series) {
            const dict = ser.colorCol?.dictionary;
            if (!dict) {
                continue;
            }

            for (let i = 0; i < dict.length; i++) {
                const s = dict[i];
                if (!chart._uniqueColorLabels.has(s)) {
                    chart._uniqueColorLabels.set(
                        s,
                        chart._uniqueColorLabels.size,
                    );
                }
            }
        }

        if (chart._uniqueColorLabels.size > 0) {
            chart._colorMin = 0;
            chart._colorMax = chart._uniqueColorLabels.size - 1;
        }
    }

    // Faceted-no-Color: pin the color range to the facet-index domain
    // so the vertex shader's linear `(v - cmin) / (cmax - cmin)`
    // mapping lands per-point at LUT stop `s / (N-1)`. Without this
    // pin, `_colorMin/_colorMax` would stay at the +Inf/-Inf sentinel
    // and every facet's points would sample the LUT center.
    if (!chart._colorName && chart._splitGroups.length > 1) {
        chart._colorMin = 0;
        chart._colorMax = chart._splitGroups.length - 1;
    }

    for (let s = 0; s < series.length; s++) {
        const ser = series[s];
        const prevCount = chart._seriesUploadedCounts[s] ?? 0;
        const slotBase = s * chart._seriesCapacity;
        const maxWrite = chart._seriesCapacity - prevCount;
        if (maxWrite <= 0) {
            continue;
        }

        const colorValid = ser.colorCol?.valid;
        let writeIdx = 0;
        for (let j = 0; j < sourceLength && writeIdx < maxWrite; j++) {
            const i = j;
            // Numeric axes filter out null/invalid rows entirely;
            // categorical axes route them into a `"(null)"` slot
            // instead, so the validity / NaN guards only apply on
            // the numeric branch.
            if (
                !chart._yIsString &&
                ser.yValid &&
                !((ser.yValid[i >> 3] >> (i & 7)) & 1)
            ) {
                continue;
            }

            if (
                !chart._xIsString &&
                ser.xCol &&
                ser.xValid &&
                !((ser.xValid[i >> 3] >> (i & 7)) & 1)
            ) {
                continue;
            }

            const colorIsNull =
                colorValid !== undefined &&
                !((colorValid[i >> 3] >> (i & 7)) & 1);

            let rawY: number;
            if (chart._yIsString) {
                rawY = lookupCategorySlot(
                    ser.yColData,
                    i,
                    chart._yCategoryDictionary,
                    chart._yCategorySeen,
                );
            } else if (ser.yCol) {
                rawY = ser.yCol[i] as number;
                if (isNaN(rawY)) {
                    continue;
                }
            } else {
                continue;
            }

            let rawX: number;
            if (chart._xIsString) {
                rawX = lookupCategorySlot(
                    ser.xColData,
                    i,
                    chart._xCategoryDictionary,
                    chart._xCategorySeen,
                );
            } else if (ser.xCol) {
                rawX = ser.xCol[i] as number;
                if (isNaN(rawX)) {
                    continue;
                }
            } else {
                rawX = startRow + i;
            }

            // Project raw (x, y) → data-space (x, y). Default is
            // identity for cartesian charts; map subclasses override
            // to apply Mercator. Second NaN guard catches projection
            // failures (e.g. Mercator's ±85° lat clamp).
            const [x, y] = chart.projectPoint(rawX, rawY);
            if (isNaN(x) || isNaN(y)) {
                continue;
            }

            if (x < chart._xMin) {
                chart._xMin = x;
            }

            if (x > chart._xMax) {
                chart._xMax = x;
            }

            if (y < chart._yMin) {
                chart._yMin = y;
            }

            if (y > chart._yMax) {
                chart._yMax = y;
            }

            // Capture rebase origins from the first valid sample. The
            // origin is f64 in JS state but applied before every f32
            // store below — `_xData`, `_yData`, and the GPU `positions`
            // staging buffer all hold rebased values, so the projection
            // matrix's `tx`/`ty` terms (built from rebased extents in
            // cartesian-render) stay near zero and the shader's
            // `sx*x + tx` cancellation is precision-safe.
            if (isNaN(chart._xOrigin)) {
                chart._xOrigin = x;
            }

            if (isNaN(chart._yOrigin)) {
                chart._yOrigin = y;
            }

            const xr = x - chart._xOrigin;
            const yr = y - chart._yOrigin;

            const flatIdx = slotBase + prevCount + writeIdx;
            chart._xData![flatIdx] = xr;
            chart._yData![flatIdx] = yr;

            // Remember the source arrow row this slot came from so
            // lazy tooltip fetches can resolve columns on demand. In
            // split mode each series duplicates the same arrow row
            // into its own slot, so `startRow + i` is the right view
            // row regardless of `s`.
            chart._rowIndexData![flatIdx] = startRow + i;

            positions[writeIdx * 2] = xr;
            positions[writeIdx * 2 + 1] = yr;

            //  Color: unified resolution for split + non-split.
            // Read from this series' own color column (facet-specific
            // in split mode, the chart-wide column otherwise). Scales
            // (`_colorMin/_colorMax` and `_uniqueColorLabels`) are
            // shared across every series so identical values render
            // as identical colors in every facet.
            const cc = ser.colorCol;
            if (colorIsNull) {
                colorValues[writeIdx] = 0.5;
                chart._colorData![flatIdx] = 0.5;
            } else if (cc && !chart._colorIsString && cc.values) {
                const v = cc.values[i] as number;
                colorValues[writeIdx] = v;
                chart._colorData![flatIdx] = v;
                if (v < chart._colorMin) {
                    chart._colorMin = v;
                }

                if (v > chart._colorMax) {
                    chart._colorMax = v;
                }
            } else if (
                cc &&
                chart._colorIsString &&
                cc.indices &&
                cc.dictionary
            ) {
                const label = cc.dictionary[cc.indices[i]];

                // Dict-seeding above ensures this label is already
                // in `_uniqueColorLabels`; defensive insert for any
                // value that appears in data but not the dictionary
                // (shouldn't happen for Arrow dict columns).
                if (!chart._uniqueColorLabels.has(label)) {
                    chart._uniqueColorLabels.set(
                        label,
                        chart._uniqueColorLabels.size,
                    );
                    chart._colorMax = chart._uniqueColorLabels.size - 1;
                }

                const idx = chart._uniqueColorLabels.get(label)!;
                colorValues[writeIdx] = idx;
                chart._colorData![flatIdx] = idx;

                // Skip min/max updates — they were pinned to the full
                // palette-index domain during seeding.
            } else {
                colorValues[writeIdx] = s;
                chart._colorData![flatIdx] = s;
            }

            //  Label: resolve the slot's string via the column's arrow
            // dictionary; `LabelInterner.set` deduplicates across
            // facets so identical strings share an entry. Non-string
            // or unencoded label columns are silently skipped — the
            // slot stays at its `-1` initialization.
            if (chart._labels && ser.labelCol) {
                const lc = ser.labelCol;
                const labelValid = lc.valid;
                const labelIsNull =
                    labelValid !== undefined &&
                    !((labelValid[i >> 3] >> (i & 7)) & 1);
                if (!labelIsNull && lc.indices && lc.dictionary) {
                    chart._labels.set(flatIdx, lc.dictionary[lc.indices[i]]);
                }
            }

            //  Size: per-split size column, or global sizeName.
            if (ser.sizeCol) {
                const v = ser.sizeCol[i] as number;
                sizeValues[writeIdx] = v;
                if (v < chart._sizeMin) {
                    chart._sizeMin = v;
                }

                if (v > chart._sizeMax) {
                    chart._sizeMax = v;
                }
            } else if (nonSplitSizeValues) {
                const v = nonSplitSizeValues[i] as number;
                sizeValues[writeIdx] = v;
                if (v < chart._sizeMin) {
                    chart._sizeMin = v;
                }

                if (v > chart._sizeMax) {
                    chart._sizeMax = v;
                }
            } else {
                sizeValues[writeIdx] = 0;
            }

            writeIdx++;
        }

        if (writeIdx === 0) {
            continue;
        }

        // Upload the shared position buffer for this series's new slice.
        const positionByteOffset =
            (slotBase + prevCount) * 2 * Float32Array.BYTES_PER_ELEMENT;
        glManager.bufferPool.upload(
            "a_position",
            positions.subarray(0, writeIdx * 2),
            positionByteOffset,
            2,
        );

        // Upload the raw color and size buffers (consumed by glyphs).
        const scalarByteOffset =
            (slotBase + prevCount) * Float32Array.BYTES_PER_ELEMENT;
        glManager.bufferPool.upload(
            "a_color_value",
            colorValues.subarray(0, writeIdx),
            scalarByteOffset,
        );
        glManager.bufferPool.upload(
            "a_size_value",
            sizeValues.subarray(0, writeIdx),
            scalarByteOffset,
        );

        chart._seriesUploadedCounts[s] = prevCount + writeIdx;
        if (chart._seriesUploadedCounts[s] > chart._maxSeriesUploaded) {
            chart._maxSeriesUploaded = chart._seriesUploadedCounts[s];
        }
    }

    // Total dataCount = sum of all series' uploaded counts.
    let total = 0;
    for (const c of chart._seriesUploadedCounts) {
        total += c;
    }

    chart._dataCount = total;
    glManager.uploadedCount = total;
    chart._hitTest.markDirty();

    if (isFinite(chart._xMin)) {
        chart.setZoomBaseDomain(
            chart._xMin,
            chart._xMax,
            chart._yMin,
            chart._yMax,
        );
    }
}
