// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { Context2D } from "../canvas-types";
import type { WebGLContextManager } from "../../webgl/context-manager";
import type { TreemapChart } from "./treemap";
import { NULL_NODE } from "../common/node-store";
import {
    squarify,
    collectVisible,
    collectVisibleAppend,
} from "./treemap-layout";
import { Theme } from "../../theme/theme";
import { resolvePalette, type Vec3 } from "../../theme/palette";
import { type GradientStop } from "../../theme/gradient";
import { buildFacetGrid } from "../../layout/facet-grid";
import { leafColor, leafRGBA, luminance } from "../common/leaf-color";
import treemapVert from "../../shaders/treemap.vert.glsl";
import treemapFrag from "../../shaders/treemap.frag.glsl";
import { compileProgram } from "../../webgl/program-cache";
import { withChromeCache } from "../common/chrome-cache";
import { wrapLabel } from "../../axis/label-geometry";
import {
    renderBreadcrumbs as renderTreeBreadcrumbs,
    renderTreeColorLegend,
    renderTreeTooltip,
} from "../common/tree-chrome";

type GL = WebGL2RenderingContext | WebGLRenderingContext;

/**
 * Full-frame treemap render: layout → WebGL rects → chrome overlay.
 *
 * When `_splitBy` is populated the top-level children of `_rootId`
 * become facet roots; each is squarified into its own cell rect via
 * {@link buildFacetGrid}. The visible-node list is concatenated across
 * facets so a single vertex buffer + draw call covers the whole scene.
 */
export function renderTreemapFrame(
    chart: TreemapChart,
    glManager: WebGLContextManager,
): void {
    if (chart._currentRootId === NULL_NODE) {
        return;
    }

    const gl = glManager.gl;
    const cssWidth = glManager.cssWidth;
    const cssHeight = glManager.cssHeight;
    if (cssWidth <= 0 || cssHeight <= 0) {
        return;
    }

    const store = chart._nodeStore;
    const hasSplits =
        chart._splitBy.length > 0 && chart._facetConfig.facet_mode === "grid";

    const breadcrumbH = chart._breadcrumbIds.length > 1 ? 28 : 0;
    const hasLegend =
        chart._colorMode === "series"
            ? chart._uniqueColorLabels.size > 1
            : chart._colorMode === "numeric" &&
              chart._colorMin < chart._colorMax;
    const legendW = hasLegend ? 90 : 0;

    // Scratch buffer for the ordered-layout child ids. Worst case:
    // active children at every level = store.count. Reuse the chart's
    // visible-id buffer as scratch when large enough.
    const scratch = new Int32Array(Math.max(store.count, 64));

    if (hasSplits) {
        layoutFaceted(
            chart,
            scratch,
            cssWidth,
            cssHeight,
            breadcrumbH,
            legendW,
        );
    } else {
        chart._facetGrid = null;
        const baseDepth = store.depth[chart._currentRootId];
        squarify(
            store,
            chart._currentRootId,
            0,
            breadcrumbH,
            cssWidth - legendW,
            cssHeight,
            baseDepth,
            scratch,
            chart._showBranchHeader,
        );
        collectVisible(chart, chart._currentRootId, 100, baseDepth);
        ensureVisibleMetadata(chart);
        const baseArr = chart._visibleBaseDepths!;
        const rootArr = chart._visibleRootIds!;
        for (let k = 0; k < chart._visibleNodeCount; k++) {
            baseArr[k] = baseDepth;
            rootArr[k] = chart._currentRootId;
        }
    }

    if (!chart._program) {
        const compiled = compileProgram<
            { program: WebGLProgram } & NonNullable<TreemapChart["_locations"]>
        >(
            glManager,
            "treemap",
            treemapVert,
            treemapFrag,
            ["u_resolution"],
            ["a_position", "a_color"],
        );
        chart._program = compiled.program;
        chart._locations = compiled;
    }

    const theme = chart._resolveTheme();
    const stops = theme.gradientStops;
    const palette = resolvePalette(
        theme.seriesPalette,
        stops,
        Math.max(1, chart._uniqueColorLabels.size),
    );

    if (chart._gridlineCanvas) {
        const gCtx = chart._gridlineCanvas.getContext("2d") as Context2D | null;
        if (gCtx) {
            gCtx.clearRect(
                0,
                0,
                chart._gridlineCanvas.width,
                chart._gridlineCanvas.height,
            );
        }
    }

    chart._chromeCacheDirty = true;

    generateAndUploadTreemap(chart, gl, stops, palette, theme.areaOpacity);

    gl.clearColor(0, 0, 0, 0);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
    gl.useProgram(chart._program);
    gl.uniform2f(chart._locations!.u_resolution, cssWidth, cssHeight);

    gl.bindBuffer(gl.ARRAY_BUFFER, chart._positionBuffer);
    gl.enableVertexAttribArray(chart._locations!.a_position);
    gl.vertexAttribPointer(
        chart._locations!.a_position,
        2,
        gl.FLOAT,
        false,
        0,
        0,
    );

    gl.bindBuffer(gl.ARRAY_BUFFER, chart._colorBuffer);
    gl.enableVertexAttribArray(chart._locations!.a_color);
    gl.vertexAttribPointer(chart._locations!.a_color, 4, gl.FLOAT, false, 0, 0);

    gl.drawArrays(gl.TRIANGLES, 0, chart._vertexCount);

    renderTreemapChromeOverlay(chart);
}

/**
 * Faceted layout: each top-level child of `_rootId` is one facet.
 * Squarify per cell into the cell's rect and concatenate visible
 * nodes, so downstream rendering and hit-testing treat the scene as
 * one flat visible list.
 *
 * `_visibleBaseDepths` and `_visibleRootIds` are filled in parallel so
 * render code can compute the relative depth of each node without
 * knowing its owning facet. Non-facet callers leave these as copies of
 * the single `_currentRootId` depth.
 */
function layoutFaceted(
    chart: TreemapChart,
    scratch: Int32Array,
    cssWidth: number,
    cssHeight: number,
    breadcrumbH: number,
    legendW: number,
): void {
    const store = chart._nodeStore;

    // Collect the facet roots in declaration order (= top-level children
    // of the synthetic root). Skip zero-value facets.
    const facetIds: number[] = [];
    const labels: string[] = [];
    for (
        let c = store.firstChild[chart._rootId];
        c !== NULL_NODE;
        c = store.nextSibling[c]
    ) {
        if (store.value[c] <= 0) {
            continue;
        }

        facetIds.push(c);
        labels.push(store.name[c]);
    }

    const gridHeight = Math.max(1, cssHeight - breadcrumbH);
    const gridWidth = Math.max(1, cssWidth - legendW);
    const grid = buildFacetGrid(labels, {
        cssWidth: gridWidth,
        cssHeight: gridHeight,
        hasLegend: false, // legend rect handled separately by the chrome
        // Treemap has no X/Y axes — skip the per-cell axis gutters and
        // let adjacent cell plot rects sit flush.
        xAxis: "none",
        yAxis: "none",
        gap: chart._facetConfig.facet_padding,
    });
    chart._facetGrid = grid;

    ensureVisibleMetadata(chart);
    const baseArr = chart._visibleBaseDepths!;

    let outIdx = 0;
    for (let i = 0; i < facetIds.length; i++) {
        const facetId = facetIds[i];
        const cell = grid.cells[i];
        if (!cell) {
            continue;
        }

        const label = store.name[facetId];
        const drillRoot = chart._facetDrillRoots.get(label) ?? facetId;
        const baseDepth = store.depth[drillRoot];
        const plot = cell.layout.plotRect;

        // Shift by breadcrumb band — `buildFacetGrid` works in a
        // local coord system starting at (0,0), but we need absolute
        // canvas coords for squarify's rect.
        squarify(
            store,
            drillRoot,
            plot.x,
            plot.y + breadcrumbH,
            plot.x + plot.width,
            plot.y + breadcrumbH + plot.height,
            baseDepth,
            scratch,
            chart._showBranchHeader,
        );
        const nextIdx = collectVisibleAppend(
            chart,
            drillRoot,
            100,
            baseDepth,
            outIdx,
        );

        // Ensure metadata arrays are wide enough after the append.
        if (baseArr.length < nextIdx) {
            ensureVisibleMetadata(chart);
        }

        const baseArr2 = chart._visibleBaseDepths!;
        const rootArr2 = chart._visibleRootIds!;
        for (let k = outIdx; k < nextIdx; k++) {
            baseArr2[k] = baseDepth;
            rootArr2[k] = drillRoot;
        }

        outIdx = nextIdx;
    }

    chart._visibleNodeCount = outIdx;
}

function ensureVisibleMetadata(chart: TreemapChart): void {
    const need = chart._visibleNodeIds?.length ?? chart._nodeStore.count;
    if (!chart._visibleBaseDepths || chart._visibleBaseDepths.length < need) {
        chart._visibleBaseDepths = new Int32Array(need);
    }

    if (!chart._visibleRootIds || chart._visibleRootIds.length < need) {
        chart._visibleRootIds = new Int32Array(need);
    }
}

function generateAndUploadTreemap(
    chart: TreemapChart,
    gl: GL,
    stops: GradientStop[],
    palette: Vec3[],
    negativeAlpha: number,
): void {
    const store = chart._nodeStore;
    const ids = chart._visibleNodeIds!;
    const n = chart._visibleNodeCount;
    const baseArr = chart._visibleBaseDepths;
    const rootArr = chart._visibleRootIds;

    const baseDepthOf = (i: number): number =>
        baseArr ? baseArr[i] : store.depth[chart._currentRootId];
    const rootOf = (i: number): number =>
        rootArr ? rootArr[i] : chart._currentRootId;

    // Count the rects we'll emit so we can size the buffers exactly.
    let rectCount = 0;
    for (let i = 0; i < n; i++) {
        const id = ids[i];
        if (id === rootOf(i)) {
            continue;
        }

        const w = store.x1[id] - store.x0[id];
        const h = store.y1[id] - store.y0[id];
        if (w < 1 || h < 1) {
            continue;
        }

        if (store.firstChild[id] === NULL_NODE) {
            rectCount++;
        } else if (store.depth[id] - baseDepthOf(i) === 1) {
            rectCount += 2;
        }
    }

    const positions = new Float32Array(rectCount * 6 * 2);

    // 4 floats per vertex (RGBA) — negative-size leaves emit with a
    // reduced alpha (= `theme.areaOpacity`); everything else is opaque.
    const colors = new Float32Array(rectCount * 6 * 4);
    let vi = 0;

    for (let i = 0; i < n; i++) {
        const id = ids[i];
        if (id === rootOf(i)) {
            continue;
        }

        const sx0 = store.x0[id];
        const sy0 = store.y0[id];
        const sx1 = store.x1[id];
        const sy1 = store.y1[id];
        const w = sx1 - sx0;
        const h = sy1 - sy0;
        if (w < 1 || h < 1) {
            continue;
        }

        if (store.firstChild[id] === NULL_NODE) {
            const color = leafRGBA(chart, id, stops, palette, negativeAlpha);
            vi = emitRect(
                positions,
                colors,
                vi,
                sx0,
                sy0,
                sx1 - 1,
                sy1 - 1,
                color,
            );
        } else {
            const relDepth = store.depth[id] - baseDepthOf(i);
            if (relDepth === 1) {
                // Branch borders are structural; always opaque.
                const borderColor: [number, number, number, number] = [
                    0.25, 0.25, 0.25, 1.0,
                ];
                vi = emitRect(
                    positions,
                    colors,
                    vi,
                    sx0,
                    sy1 - 1,
                    sx1,
                    sy1,
                    borderColor,
                );
                vi = emitRect(
                    positions,
                    colors,
                    vi,
                    sx1 - 1,
                    sy0,
                    sx1,
                    sy1,
                    borderColor,
                );
            }
        }
    }

    chart._vertexCount = vi;

    if (!chart._positionBuffer) {
        chart._positionBuffer = gl.createBuffer();
    }

    gl.bindBuffer(gl.ARRAY_BUFFER, chart._positionBuffer);
    gl.bufferData(
        gl.ARRAY_BUFFER,
        positions.subarray(0, vi * 2),
        gl.DYNAMIC_DRAW,
    );

    if (!chart._colorBuffer) {
        chart._colorBuffer = gl.createBuffer();
    }

    gl.bindBuffer(gl.ARRAY_BUFFER, chart._colorBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, colors.subarray(0, vi * 4), gl.DYNAMIC_DRAW);
}

function emitRect(
    positions: Float32Array,
    colors: Float32Array,
    vi: number,
    x0: number,
    y0: number,
    x1: number,
    y1: number,
    color: [number, number, number, number],
): number {
    const pi = vi * 2;
    const ci = vi * 4;

    positions[pi + 0] = x0;
    positions[pi + 1] = y0;
    positions[pi + 2] = x1;
    positions[pi + 3] = y0;
    positions[pi + 4] = x0;
    positions[pi + 5] = y1;

    positions[pi + 6] = x1;
    positions[pi + 7] = y0;
    positions[pi + 8] = x1;
    positions[pi + 9] = y1;
    positions[pi + 10] = x0;
    positions[pi + 11] = y1;

    for (let v = 0; v < 6; v++) {
        colors[ci + v * 4 + 0] = color[0];
        colors[ci + v * 4 + 1] = color[1];
        colors[ci + v * 4 + 2] = color[2];
        colors[ci + v * 4 + 3] = color[3];
    }

    return vi + 6;
}

/**
 * Render the chrome overlay. On layout changes, draws static content
 * (labels, breadcrumbs, legend) directly and snapshots it into a cached
 * bitmap. On hover-only updates, blits the cache and draws only the
 * tooltip + highlight on top.
 */
export function renderTreemapChromeOverlay(chart: TreemapChart): void {
    if (!chart._chromeCanvas || chart._currentRootId === NULL_NODE) {
        return;
    }

    const glManager = chart._glManager;
    if (!glManager) {
        return;
    }

    const { dpr, cssWidth, cssHeight } = glManager;

    const highlightId =
        chart._pinnedNodeId !== NULL_NODE
            ? chart._pinnedNodeId
            : chart._hoveredNodeId;

    withChromeCache(
        chart,
        chart._chromeCanvas,
        dpr,
        cssWidth,
        cssHeight,
        (ctx) => drawStaticChrome(chart, ctx, dpr, cssWidth, cssHeight),
        highlightId !== NULL_NODE
            ? (ctx) => {
                  const theme = chart._resolveTheme();
                  const { fontFamily } = theme;
                  const store = chart._nodeStore;

                  renderHoverHighlight(ctx, store, highlightId);

                  const ids = chart._visibleNodeIds!;
                  const n = chart._visibleNodeCount;
                  const baseArr = chart._visibleBaseDepths;
                  const rootArr = chart._visibleRootIds;
                  for (let i = 0; i < n; i++) {
                      const id = ids[i];
                      const rootId = rootArr
                          ? rootArr[i]
                          : chart._currentRootId;
                      if (id === rootId || store.firstChild[id] === NULL_NODE) {
                          continue;
                      }

                      const nw = store.x1[id] - store.x0[id];
                      const nh = store.y1[id] - store.y0[id];
                      const baseDepth = baseArr
                          ? baseArr[i]
                          : store.depth[chart._currentRootId];
                      const relDepth = store.depth[id] - baseDepth;
                      if (relDepth === 1) {
                          renderBranchLabel(
                              ctx,
                              store,
                              id,
                              nw,
                              nh,
                              theme,
                              !chart._showBranchHeader,
                          );
                      } else if (relDepth === 2) {
                          renderBranchLabel(
                              ctx,
                              store,
                              id,
                              nw,
                              nh,
                              theme,
                              true,
                          );
                      }
                  }

                  if (store.firstChild[highlightId] === NULL_NODE) {
                      const stops = theme.gradientStops;
                      const palette = resolvePalette(
                          theme.seriesPalette,
                          stops,
                          Math.max(1, chart._uniqueColorLabels.size),
                      );
                      const hw = store.x1[highlightId] - store.x0[highlightId];
                      const hh = store.y1[highlightId] - store.y0[highlightId];
                      renderNodeLabel(
                          chart,
                          ctx,
                          highlightId,
                          hw,
                          hh,
                          fontFamily,
                          stops,
                          palette,
                          true,
                      );
                  }

                  if (
                      chart._pinnedNodeId === NULL_NODE &&
                      chart._hoveredNodeId !== NULL_NODE
                  ) {
                      renderTreemapTooltip(
                          chart,
                          ctx,
                          chart._hoveredNodeId,
                          cssWidth,
                          cssHeight,
                          fontFamily,
                      );
                  }
              }
            : null,
    );
}

function drawStaticChrome(
    chart: TreemapChart,
    ctx: Context2D,
    dpr: number,
    cssWidth: number,
    cssHeight: number,
): void {
    const canvas = chart._chromeCanvas!;

    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.save();
    ctx.scale(dpr, dpr);

    const theme = chart._resolveTheme();
    const { fontFamily, labelColor: textColor } = theme;
    const stops = theme.gradientStops;
    const palette = resolvePalette(
        theme.seriesPalette,
        stops,
        Math.max(1, chart._uniqueColorLabels.size),
    );

    const store = chart._nodeStore;
    const ids = chart._visibleNodeIds!;
    const n = chart._visibleNodeCount;
    const baseArr = chart._visibleBaseDepths;
    const rootArr = chart._visibleRootIds;

    for (let i = 0; i < n; i++) {
        const id = ids[i];
        const rootId = rootArr ? rootArr[i] : chart._currentRootId;
        if (id === rootId || store.firstChild[id] !== NULL_NODE) {
            continue;
        }

        const w = store.x1[id] - store.x0[id];
        const h = store.y1[id] - store.y0[id];
        renderNodeLabel(chart, ctx, id, w, h, fontFamily, stops, palette);
    }

    for (let i = 0; i < n; i++) {
        const id = ids[i];
        const rootId = rootArr ? rootArr[i] : chart._currentRootId;
        if (id === rootId || store.firstChild[id] === NULL_NODE) {
            continue;
        }

        const w = store.x1[id] - store.x0[id];
        const h = store.y1[id] - store.y0[id];
        const baseDepth = baseArr
            ? baseArr[i]
            : store.depth[chart._currentRootId];
        const relDepth = store.depth[id] - baseDepth;
        if (relDepth === 1) {
            renderBranchLabel(
                ctx,
                store,
                id,
                w,
                h,
                theme,
                !chart._showBranchHeader,
            );
        } else if (relDepth === 2) {
            renderBranchLabel(ctx, store, id, w, h, theme, true);
        }
    }

    if (chart._breadcrumbIds.length > 1) {
        renderTreeBreadcrumbs(chart, ctx, cssWidth, fontFamily, textColor);
    }

    renderTreeColorLegend(
        chart,
        canvas,
        palette,
        stops,
        theme,
        cssWidth,
        cssHeight,
    );

    // Per-facet titles (rendered over the layout; painted in the static
    // chrome bitmap so they appear alongside leaf labels).
    if (chart._facetGrid) {
        ctx.font = `11px ${fontFamily}`;
        ctx.fillStyle = textColor;
        ctx.textAlign = "center";
        ctx.textBaseline = "top";
        for (const cell of chart._facetGrid.cells) {
            const plot = cell.layout.plotRect;
            ctx.fillText(cell.label, plot.x + plot.width / 2, plot.y - 14);
        }
    }

    ctx.restore();
}

function renderNodeLabel(
    chart: TreemapChart,
    ctx: Context2D,
    nodeId: number,
    w: number,
    h: number,
    fontFamily: string,
    stops: GradientStop[],
    palette: Vec3[],
    hovered = false,
): void {
    const MAX_FONT = 11;
    const PAD = 4;
    const LINE_HEIGHT = 1.3;

    if (w < 30 || h < 14) {
        return;
    }

    const store = chart._nodeStore;
    const fillColor = leafColor(chart, nodeId, stops, palette);

    const lum = luminance(fillColor[0], fillColor[1], fillColor[2]);
    const labelColor = hovered
        ? lum > 0.5
            ? "rgba(0,0,0,0.85)"
            : "rgba(255,255,255,0.9)"
        : lum > 0.5
          ? "rgba(0,0,0,0.5)"
          : "rgba(255,255,255,0.55)";

    const fontSize = Math.min(MAX_FONT, Math.floor(h / 2));
    if (fontSize < 7) {
        return;
    }

    ctx.font = `${fontSize}px ${fontFamily}`;

    const maxW = w - PAD * 2;
    const lineH = fontSize * LINE_HEIGHT;
    const maxLines = Math.max(1, Math.floor((h - PAD * 2) / lineH));

    const lines = wrapLabel(ctx, store.name[nodeId], maxW, maxLines);
    if (lines.length === 0) {
        return;
    }

    const blockH = lines.length * lineH;
    const startY = store.y0[nodeId] + (h - blockH) / 2 + lineH / 2;

    ctx.fillStyle = labelColor;
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    const cx = store.x0[nodeId] + w / 2;
    for (let i = 0; i < lines.length; i++) {
        ctx.fillText(lines[i], cx, startY + i * lineH);
    }
}

function renderBranchLabel(
    ctx: Context2D,
    store: import("../common/node-store").NodeStore,
    nodeId: number,
    w: number,
    h: number,
    { fontFamily, labelColor, backgroundColor }: Theme,
    nested: boolean,
): void {
    const x0 = store.x0[nodeId];
    const y0 = store.y0[nodeId];
    const name = store.name[nodeId];

    if (nested) {
        if (w < 60 || h < 30) {
            return;
        }

        const fontSize = 12;
        ctx.font = `${fontSize}px ${fontFamily}`;

        let text = name;
        const maxW = w - 16;
        const textW = ctx.measureText(text).width;
        if (textW > maxW) {
            while (text.length > 1) {
                text = text.slice(0, -1);
                if (ctx.measureText(text + "\u2026").width <= maxW) {
                    text += "\u2026";
                    break;
                }
            }
        }

        if (text.length <= 3) {
            return;
        }

        ctx.save();
        ctx.beginPath();
        ctx.rect(x0, y0, w, h);
        ctx.clip();

        const cx = x0 + w / 2;
        const cy = y0 + h / 2;
        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        ctx.lineWidth = 2;
        ctx.strokeStyle = labelColor;
        ctx.lineJoin = "round";
        ctx.strokeText(text, cx, cy);
        ctx.fillStyle = backgroundColor;
        ctx.fillText(text, cx, cy);

        ctx.restore();
    } else {
        if (w < 40 || h < 22) {
            return;
        }

        const fontSize = 11;
        ctx.font = `${fontSize}px ${fontFamily}`;

        let text = name;
        const maxW = w - 10;
        const textW = ctx.measureText(text).width;
        if (textW > maxW) {
            while (text.length > 1) {
                text = text.slice(0, -1);
                if (ctx.measureText(text + "\u2026").width <= maxW) {
                    text += "\u2026";
                    break;
                }
            }
        }

        ctx.fillStyle = labelColor;
        ctx.globalAlpha = 0.85;
        ctx.textAlign = "left";
        ctx.textBaseline = "top";
        ctx.fillText(text, x0 + 5, y0 + 4);
        ctx.globalAlpha = 1.0;
    }
}

function renderHoverHighlight(
    ctx: Context2D,
    store: import("../common/node-store").NodeStore,
    nodeId: number,
): void {
    ctx.strokeStyle = "rgba(255,255,255,0.9)";
    ctx.lineWidth = 2;
    ctx.strokeRect(
        store.x0[nodeId],
        store.y0[nodeId],
        store.x1[nodeId] - store.x0[nodeId],
        store.y1[nodeId] - store.y0[nodeId],
    );
}

function renderTreemapTooltip(
    chart: TreemapChart,
    ctx: Context2D,
    nodeId: number,
    cssWidth: number,
    cssHeight: number,
    fontFamily: string,
): void {
    const store = chart._nodeStore;
    const cx = (store.x0[nodeId] + store.x1[nodeId]) / 2;
    const cy = (store.y0[nodeId] + store.y1[nodeId]) / 2;
    renderTreeTooltip(
        chart,
        ctx,
        nodeId,
        cx,
        cy,
        cssWidth,
        cssHeight,
        fontFamily,
    );
}
