// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ // ┃ 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 { PlotLayout } from "../../layout/plot-layout"; import type { Theme } from "../../theme/theme"; import type { CategoricalDomain, CategoricalLevel, } from "../../axis/categorical-axis"; import { runsInRange } from "../../axis/categorical-axis-core"; import { truncateLabel } from "../../axis/label-geometry"; interface LevelTickLayout { size: number; // width in CSS pixels consumed by this level } export interface CategoricalYAxisOptions { /** * Drop the innermost level of the domain before rendering, promoting * the second-to-last level into the leaf position. Used by the * heatmap, whose column names encode `split…|aggregate` — with a * single aggregate the leaf level is a redundant constant column, * and showing the last split as the leaf reads more naturally. * * No-op when `levels.length <= 1` (there's nothing left to promote); * the axis renders nothing in that case. */ skipLeafLevel?: boolean; } /** * Apply `skipLeafLevel` to a levels array. Returns the input unchanged * when the option is off or when there's only a single level (the leaf * itself). */ function effectiveLevels( levels: CategoricalLevel[], opts?: CategoricalYAxisOptions, ): CategoricalLevel[] { if (opts?.skipLeafLevel && levels.length > 0) { return levels.slice(0, -1); } return levels; } const LEAF_LEVEL_WIDTH = 80; const OUTER_LEVEL_WIDTH = 20; const TICK_SIZE = 5; const LABEL_FONT_PX = 11; /** * Map a Y category index to a pixel y-coordinate. Matches the convention * chosen for the heatmap plot: yIdx=0 sits at the *bottom* (math Y-up), * so a higher yIdx maps to a lower pixel value via `dataToPixel`'s * standard (1 - t) flip. */ export function categoryIndexToPixelY( layout: PlotLayout, index: number, ): number { return layout.dataToPixel(0, index).py; } /** * Per-level widths for a vertical categorical axis. Leaf width is * derived from the longest label in that level's dictionary; outer * levels get a fixed narrow column for the bracket + short text. */ export function measureCategoricalYLevels( domain: CategoricalDomain, opts?: CategoricalYAxisOptions, ): LevelTickLayout[] { const levels = effectiveLevels(domain.levels, opts); const L = levels.length; const result: LevelTickLayout[] = []; for (let l = 0; l < L; l++) { const longest = levels[l].maxLabelChars; if (l === L - 1) { const w = Math.max(LEAF_LEVEL_WIDTH, longest * 6.5 + 16); result.push({ size: w }); } else { const w = Math.max(OUTER_LEVEL_WIDTH, longest * 6.5 + 16); result.push({ size: w }); } } return result; } /** * Total CSS-pixel width required for the categorical tick band (levels), * NOT including the per-axis-label allowance. Caller feeds this to * `PlotLayout` as `leftExtra`; the axis label is added via `hasYLabel`. */ export function measureCategoricalAxisWidth( domain: CategoricalDomain, opts?: CategoricalYAxisOptions, ): number { const levels = effectiveLevels(domain.levels, opts); if (domain.numRows === 0 || levels.length === 0) { return 55; } const levelLayouts = measureCategoricalYLevels(domain, opts); let total = 0; for (const l of levelLayouts) { total += l.size; } return total; } function getLeafText(level: CategoricalLevel, row: number): string { return level.labels[row] ?? ""; } /** * Render hierarchical Y-axis tick marks, leaf labels, and outer-level * bracket labels on the chrome canvas. The axis line is drawn by the * caller alongside the X axis. */ export function renderCategoricalYTicks( ctx: Context2D, layout: PlotLayout, domain: CategoricalDomain, theme: Theme, opts?: CategoricalYAxisOptions, ): void { const levels = effectiveLevels(domain.levels, opts); if (domain.numRows === 0 || levels.length === 0) { return; } const { tickColor, labelColor, fontFamily } = theme; const { plotRect: plot } = layout; ctx.strokeStyle = tickColor; ctx.fillStyle = tickColor; ctx.lineWidth = 1; ctx.font = `${LABEL_FONT_PX}px ${fontFamily}`; const levelLayouts = measureCategoricalYLevels(domain, opts); // Visible Y range from the (possibly zoomed) padded Y domain. const visMin = Math.max(0, Math.ceil(layout.paddedYMin)); const visMax = Math.min(domain.numRows - 1, Math.floor(layout.paddedYMax)); if (visMax < visMin) { return; } const L = levels.length; // Cursor walks from the plot's left edge leftward, innermost (leaf) // level closest to the plot, outer levels further left. let xCursor = plot.x; for (let l = L - 1; l >= 0; l--) { const level = levels[l]; const lay = levelLayouts[l]; const bandRight = xCursor; const bandLeft = xCursor - lay.size; xCursor = bandLeft; if (l === L - 1) { renderLeafLevel( ctx, layout, level, visMin, visMax, bandRight, bandLeft, fontFamily, tickColor, ); } else { renderOuterLevel( ctx, layout, level, visMin, visMax, bandLeft, bandRight, fontFamily, tickColor, ); } } // Axis label — optional; draw vertical along the leftmost edge. const axisLabel = domain.levelLabels.filter((s) => !!s).join(" / "); if (axisLabel) { ctx.fillStyle = labelColor; ctx.font = `13px ${fontFamily}`; ctx.save(); ctx.translate(14, plot.y + plot.height / 2); ctx.rotate(-Math.PI / 2); ctx.textAlign = "center"; ctx.textBaseline = "top"; ctx.fillText(axisLabel, 0, 0); ctx.restore(); } } function renderLeafLevel( ctx: Context2D, layout: PlotLayout, level: CategoricalLevel, visMin: number, visMax: number, bandRight: number, bandLeft: number, fontFamily: string, tickColor: string, ): void { const plot = layout.plotRect; ctx.strokeStyle = tickColor; ctx.fillStyle = tickColor; ctx.beginPath(); for (let r = visMin; r <= visMax; r++) { const py = categoryIndexToPixelY(layout, r); if (py < plot.y - 1 || py > plot.y + plot.height + 1) { continue; } ctx.moveTo(bandRight, py); ctx.lineTo(bandRight - TICK_SIZE, py); } ctx.stroke(); ctx.font = `${LABEL_FONT_PX}px ${fontFamily}`; ctx.textAlign = "right"; ctx.textBaseline = "middle"; const labelMaxWidth = bandRight - TICK_SIZE - 4 - bandLeft - 4; for (let r = visMin; r <= visMax; r++) { const py = categoryIndexToPixelY(layout, r); if (py < plot.y - 1 || py > plot.y + plot.height + 1) { continue; } const text = getLeafText(level, r); if (!text) { continue; } const truncated = truncateLabel(ctx, text, labelMaxWidth); if (!truncated) { continue; } ctx.fillText(truncated, bandRight - TICK_SIZE - 4, py); } } function renderOuterLevel( ctx: Context2D, layout: PlotLayout, level: CategoricalLevel, visMin: number, visMax: number, bandLeft: number, bandRight: number, fontFamily: string, tickColor: string, ): void { const plot = layout.plotRect; const runs = runsInRange(level.runs, visMin, visMax); if (runs.length === 0) { return; } ctx.strokeStyle = tickColor; ctx.fillStyle = tickColor; // Bracket line: vertical line near the right edge of the band, with // short horizontal ticks at each run boundary. const bracketX = bandRight - 3; ctx.beginPath(); for (const run of runs) { const yTop = categoryIndexToPixelY(layout, run.endIdx + 0.5); const yBot = categoryIndexToPixelY(layout, run.startIdx - 0.5); const yHi = Math.min(yTop, yBot); const yLo = Math.max(yTop, yBot); const clippedHi = Math.max(plot.y, yHi); const clippedLo = Math.min(plot.y + plot.height, yLo); if (clippedLo <= clippedHi) { continue; } ctx.moveTo(bracketX, clippedHi); ctx.lineTo(bracketX, clippedLo); // Boundary ticks pointing inward (toward the plot). ctx.moveTo(bracketX, clippedHi); ctx.lineTo(bracketX + 3, clippedHi); ctx.moveTo(bracketX, clippedLo); ctx.lineTo(bracketX + 3, clippedLo); } ctx.stroke(); // Centered run label, rotated -90° so long labels fit in a narrow band. ctx.font = `${LABEL_FONT_PX}px ${fontFamily}`; for (const run of runs) { const yTop = categoryIndexToPixelY(layout, run.endIdx + 0.5); const yBot = categoryIndexToPixelY(layout, run.startIdx - 0.5); const yHi = Math.min(yTop, yBot); const yLo = Math.max(yTop, yBot); const clippedHi = Math.max(plot.y, yHi); const clippedLo = Math.min(plot.y + plot.height, yLo); if (clippedLo <= clippedHi) { continue; } const cy = (clippedHi + clippedLo) / 2; const cx = bandLeft + (bandRight - bandLeft - 3) / 2; const text = run.label; if (!text) { continue; } const span = clippedLo - clippedHi - 4; const truncated = truncateLabel(ctx, text, Math.max(0, span)); if (!truncated) { continue; } ctx.save(); ctx.translate(cx, cy); ctx.rotate(-Math.PI / 2); ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(truncated, 0, 0); ctx.restore(); } }