// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ // ┃ 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 { SunburstChart } from "./sunburst"; import { NULL_NODE } from "../common/node-store"; import { resolvePalette, type Vec3 } from "../../theme/palette"; import { type GradientStop } from "../../theme/gradient"; import { leafColor, leafRGBA, luminance } from "../common/leaf-color"; import arcVert from "../../shaders/sunburst-arc.vert.glsl"; import arcFrag from "../../shaders/sunburst-arc.frag.glsl"; import { getInstancing } from "../../webgl/instanced-attrs"; import { compileProgram } from "../../webgl/program-cache"; import { partitionSunburst, collectVisibleArcs, collectVisibleArcsAppend, INNER_RING_PX, } from "./sunburst-layout"; import { buildFacetGrid } from "../../layout/facet-grid"; import { withChromeCache } from "../common/chrome-cache"; import { renderBreadcrumbs as renderTreeBreadcrumbs, renderTreeColorLegend, renderTreeTooltip, } from "../common/tree-chrome"; /** * Triangle-strip template resolution. `N_STEPS` angular samples × 2 * radial sides = `2 * (N_STEPS + 1)` strip vertices. 32 samples is * smooth to the eye at typical viewport sizes; bump to 64 if faceting * becomes visible on full-circle arcs. */ const N_STEPS = 32; const BREADCRUMB_H = 28; const LEGEND_W = 90; /** * Resolve the `(centerX, centerY)` of the facet that owns `nodeId`. * Walks the ancestor chain and matches against each facet's * `drillRoot`; returns `chart._centerX/_centerY` in non-faceted mode * or as a defensive fallback. Used by every chrome path that needs to * place geometry around an arc — labels, hover highlight, hover * tooltip, pinned tooltip — so all four agree on which facet owns the * node. The chart-wide `_centerX/_centerY` fields are * `layoutFacetedSunburst`'s legacy first-facet publication and are * not safe for these calls. */ export function facetCenterForNode( chart: SunburstChart, nodeId: number, ): { centerX: number; centerY: number } { if (chart._facets.length === 0) { return { centerX: chart._centerX, centerY: chart._centerY }; } const store = chart._nodeStore; for (const facet of chart._facets) { let p = nodeId; while (p !== NULL_NODE) { if (p === facet.drillRoot) { return { centerX: facet.centerX, centerY: facet.centerY }; } p = store.parent[p]; } } return { centerX: chart._centerX, centerY: chart._centerY }; } /** * Full-frame render: layout → WebGL arcs → chrome overlay. */ export function renderSunburstFrame( chart: SunburstChart, 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 hasSplits = chart._splitBy.length > 0 && chart._facetConfig.facet_mode === "grid"; const hasLegend = chart._colorMode === "series" ? chart._uniqueColorLabels.size > 1 : chart._colorMode === "numeric" && chart._colorMin < chart._colorMax; const breadcrumbH = !hasSplits && chart._breadcrumbIds.length > 1 ? BREADCRUMB_H : 0; const legendW = hasLegend ? LEGEND_W : 0; if (hasSplits) { layoutFacetedSunburst(chart, cssWidth, cssHeight, legendW); } else { chart._facetGrid = null; chart._facets = []; const plotW = cssWidth - legendW; const plotH = cssHeight - breadcrumbH; chart._centerX = plotW / 2; chart._centerY = breadcrumbH + plotH / 2; chart._maxRadius = Math.max(0, Math.min(plotW, plotH) / 2 - 4); partitionSunburst( chart._nodeStore, chart._currentRootId, chart._maxRadius, ); collectVisibleArcs(chart, chart._currentRootId); } ensureProgram(chart, glManager); 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, ); } } const dpr = glManager.dpr; chart._chromeCacheDirty = true; uploadArcInstances(chart, gl, stops, palette, theme.areaOpacity, dpr); 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!); const loc = chart._locations!; gl.uniform2f(loc.u_resolution, gl.canvas.width, gl.canvas.height); gl.uniform1f(loc.u_border_px, theme.sunburstGapPx * dpr); if (chart._facets.length > 0) { // Faceted: one dispatch per facet with the matching `u_center` // and instance range. Instance attribs are rebound per facet so // instance 0 of each dispatch is the facet's first arc. for (const facet of chart._facets) { if (facet.instanceCount === 0) { continue; } gl.uniform2f( loc.u_center, facet.centerX * dpr, facet.centerY * dpr, ); drawArcs( chart, gl, glManager, facet.instanceStart, facet.instanceCount, ); } } else { gl.uniform2f(loc.u_center, chart._centerX * dpr, chart._centerY * dpr); drawArcs(chart, gl, glManager, 0, chart._instanceCount); } renderSunburstChromeOverlay(chart); } /** * Allocate the facet grid and compute per-facet (center, radius, drill * root) triples. Also runs `partitionSunburst` + `collectVisibleArcs` * per facet so the combined visible list is in `_visibleNodeIds` with * facets in cell order (instance uploads walk this list). */ function layoutFacetedSunburst( chart: SunburstChart, cssWidth: number, cssHeight: number, legendW: number, ): void { const store = chart._nodeStore; 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 gridWidth = Math.max(1, cssWidth - legendW); const grid = buildFacetGrid(labels, { cssWidth: gridWidth, cssHeight, hasLegend: false, // Sunburst has no X/Y axes — no per-cell gutter reservation. xAxis: "none", yAxis: "none", gap: chart._facetConfig.facet_padding, }); chart._facetGrid = grid; const facets: SunburstChart["_facets"] = []; 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 plot = cell.layout.plotRect; const centerX = plot.x + plot.width / 2; const centerY = plot.y + plot.height / 2; const maxRadius = Math.max( 0, Math.min(plot.width, plot.height) / 2 - 4, ); partitionSunburst(store, drillRoot, maxRadius); const nextIdx = collectVisibleArcsAppend(chart, drillRoot, outIdx); const instanceStart = outIdx; const instanceCount = nextIdx - outIdx; facets.push({ label, centerX, centerY, maxRadius, drillRoot, instanceStart, instanceCount, nodeStart: instanceStart, nodeCount: instanceCount, }); outIdx = nextIdx; } chart._visibleNodeCount = outIdx; chart._facets = facets; // Publish the first facet's center/radius to the legacy fields so // chrome code paths that still read them (e.g. non-faceted label // placement) pick sensible values. if (facets.length > 0) { chart._centerX = facets[0].centerX; chart._centerY = facets[0].centerY; chart._maxRadius = facets[0].maxRadius; } } function ensureProgram( chart: SunburstChart, glManager: WebGLContextManager, ): void { if (chart._program) { return; } const gl = glManager.gl; const compiled = compileProgram< { program: WebGLProgram } & NonNullable >( glManager, "sunburst-arc", arcVert, arcFrag, ["u_center", "u_resolution", "u_border_px"], ["a_strip_t", "a_side", "a_angles", "a_radii", "a_color"], ); chart._program = compiled.program; chart._locations = compiled; // Build the static triangle-strip template once. Layout: // pairs of (strip_t, side) for each of the 2*(N_STEPS+1) vertices. // even vertex = inner (side=0), odd vertex = outer (side=1). const template = new Float32Array((N_STEPS + 1) * 2 * 2); for (let i = 0; i <= N_STEPS; i++) { const t = i / N_STEPS; const o = i * 4; template[o + 0] = t; template[o + 1] = 0; // inner template[o + 2] = t; template[o + 3] = 1; // outer } chart._stripBuffer = gl.createBuffer()!; gl.bindBuffer(gl.ARRAY_BUFFER, chart._stripBuffer); gl.bufferData(gl.ARRAY_BUFFER, template, gl.STATIC_DRAW); chart._instanceBuffer = gl.createBuffer()!; } function uploadArcInstances( chart: SunburstChart, gl: WebGL2RenderingContext | WebGLRenderingContext, stops: GradientStop[], palette: Vec3[], negativeAlpha: number, dpr: number, ): void { const store = chart._nodeStore; const ids = chart._visibleNodeIds!; const faceted = chart._facets.length > 0; // Walk each facet's pre-upload visible range (instanceStart and // instanceCount as set by `layoutFacetedSunburst`), skip the facet's // drill root + any zero-width arcs, and emit one contiguous run per // facet. Update `(instanceStart, instanceCount)` to the post-skip // values so draw dispatch can offset into the shared buffer. // // 8 floats per instance: [a0, a1, r0, r1, r, g, b, a]. Alpha = 1 // for positive-size arcs, `negativeAlpha` for arcs whose raw size // column value was negative (keeps the arc visible but dimmer). const totalCap = faceted ? chart._facets.reduce((a, f) => a + f.instanceCount, 0) : chart._visibleNodeCount; const data = new Float32Array(totalCap * 8); let instance = 0; const emitRange = (start: number, end: number, drillRoot: number) => { const rangeStart = instance; for (let i = start; i < end; i++) { const id = ids[i]; if (id === drillRoot) { continue; } const a0 = store.a0[id]; const a1 = store.a1[id]; const r0 = store.r0[id]; const r1 = store.r1[id]; if (a1 <= a0 || r1 <= r0) { continue; } const color = leafRGBA(chart, id, stops, palette, negativeAlpha); const o = instance * 8; data[o + 0] = a0; data[o + 1] = a1; data[o + 2] = r0 * dpr; data[o + 3] = r1 * dpr; data[o + 4] = color[0]; data[o + 5] = color[1]; data[o + 6] = color[2]; data[o + 7] = color[3]; instance++; } return { rangeStart, rangeCount: instance - rangeStart }; }; if (faceted) { for (const facet of chart._facets) { const preStart = facet.instanceStart; const preEnd = preStart + facet.instanceCount; const { rangeStart, rangeCount } = emitRange( preStart, preEnd, facet.drillRoot, ); facet.instanceStart = rangeStart; facet.instanceCount = rangeCount; } } else { emitRange(0, chart._visibleNodeCount, chart._currentRootId); } chart._instanceCount = instance; gl.bindBuffer(gl.ARRAY_BUFFER, chart._instanceBuffer); gl.bufferData( gl.ARRAY_BUFFER, data.subarray(0, instance * 8), gl.DYNAMIC_DRAW, ); } /** * Dispatch one instanced draw over `[instanceStart, instanceStart+count)` * of the shared arc instance buffer. In single-plot mode the range is * the whole buffer; in faceted mode the caller dispatches once per * facet with the matching `u_center` uniform. * * Instance attribute pointers are rebound with a byte offset per call * so instance 0 of the draw is the facet's first arc. */ function drawArcs( chart: SunburstChart, gl: WebGL2RenderingContext | WebGLRenderingContext, glManager: WebGLContextManager, instanceStart: number, instanceCount: number, ): void { if (instanceCount === 0) { return; } const loc = chart._locations!; // Static strip: per-vertex (strip_t, side). gl.bindBuffer(gl.ARRAY_BUFFER, chart._stripBuffer!); const stripStride = 2 * Float32Array.BYTES_PER_ELEMENT; gl.enableVertexAttribArray(loc.a_strip_t); gl.vertexAttribPointer(loc.a_strip_t, 1, gl.FLOAT, false, stripStride, 0); gl.enableVertexAttribArray(loc.a_side); gl.vertexAttribPointer( loc.a_side, 1, gl.FLOAT, false, stripStride, Float32Array.BYTES_PER_ELEMENT, ); const instancing = getInstancing(glManager); const { setDivisor } = instancing; setDivisor(loc.a_strip_t, 0); setDivisor(loc.a_side, 0); // Per-instance interleaved buffer (rebind with byte offset so // instance 0 of the draw is slot `instanceStart`). Layout: // [0..1] a_angles (a0, a1) // [2..3] a_radii (r0, r1) // [4..7] a_color (r, g, b, a) gl.bindBuffer(gl.ARRAY_BUFFER, chart._instanceBuffer!); const instStride = 8 * Float32Array.BYTES_PER_ELEMENT; const f = Float32Array.BYTES_PER_ELEMENT; const base = instanceStart * instStride; gl.enableVertexAttribArray(loc.a_angles); gl.vertexAttribPointer(loc.a_angles, 2, gl.FLOAT, false, instStride, base); setDivisor(loc.a_angles, 1); gl.enableVertexAttribArray(loc.a_radii); gl.vertexAttribPointer( loc.a_radii, 2, gl.FLOAT, false, instStride, base + 2 * f, ); setDivisor(loc.a_radii, 1); gl.enableVertexAttribArray(loc.a_color); gl.vertexAttribPointer( loc.a_color, 4, gl.FLOAT, false, instStride, base + 4 * f, ); setDivisor(loc.a_color, 1); instancing.drawArraysInstanced( gl.TRIANGLE_STRIP, 0, 2 * (N_STEPS + 1), instanceCount, ); setDivisor(loc.a_angles, 0); setDivisor(loc.a_radii, 0); setDivisor(loc.a_color, 0); } // Chrome overlay (Canvas2D) export function renderSunburstChromeOverlay(chart: SunburstChart): void { if (!chart._chromeCanvas || chart._currentRootId === NULL_NODE) { return; } const glManager = chart._glManager; if (!glManager) { return; } const { dpr, cssWidth, cssHeight } = glManager; withChromeCache( chart, chart._chromeCanvas, dpr, cssWidth, cssHeight, (ctx) => drawStaticChrome(chart, ctx, dpr, cssWidth, cssHeight), chart._hoveredNodeId !== NULL_NODE ? (ctx) => { renderHoverHighlight(ctx, chart, chart._hoveredNodeId); renderSunburstTooltip( chart, ctx, chart._hoveredNodeId, cssWidth, cssHeight, chart._resolveTheme().fontFamily, ); } : null, ); } function drawStaticChrome( chart: SunburstChart, 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, tooltipBg } = 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 faceted = chart._facets.length > 0; // Arc labels — skip each facet's own drill root (its label is the // center text / facet title, handled below). In faceted mode, walk // each facet's `nodeStart`/`nodeCount` range over `_visibleNodeIds` // and rotate around that facet's `(centerX, centerY)`. Without the // per-facet center, every label translates around the chart's // `_centerX/_centerY`, which `layoutFacetedSunburst` publishes // from facet 0 — the symptom is "all labels pile onto facet 0." if (faceted) { for (const facet of chart._facets) { const end = facet.nodeStart + facet.nodeCount; for (let i = facet.nodeStart; i < end; i++) { const id = ids[i]; if (id === facet.drillRoot) { continue; } renderArcLabel( chart, ctx, id, fontFamily, stops, palette, facet.centerX, facet.centerY, ); } } } else { for (let i = 0; i < n; i++) { const id = ids[i]; if (id === chart._currentRootId) { continue; } renderArcLabel( chart, ctx, id, fontFamily, stops, palette, chart._centerX, chart._centerY, ); } } // Inner drill-up circle(s). One per facet in faceted mode so each // facet has its own center hit target. const innerDiscR = Math.max(0, INNER_RING_PX - theme.sunburstGapPx * 0.5); ctx.fillStyle = tooltipBg; ctx.textAlign = "center"; ctx.textBaseline = "middle"; if (faceted) { for (const facet of chart._facets) { ctx.beginPath(); ctx.fillStyle = tooltipBg; ctx.arc(facet.centerX, facet.centerY, innerDiscR, 0, 2 * Math.PI); ctx.fill(); ctx.fillStyle = textColor; ctx.font = `11px ${fontFamily}`; ctx.fillText( store.name[facet.drillRoot], facet.centerX, facet.centerY, ); // Facet title band above the arcs. if (chart._facetGrid) { const cell = chart._facetGrid.cells.find( (c) => c.label === facet.label, ); if (cell?.titleRect) { ctx.fillStyle = textColor; ctx.font = `11px ${fontFamily}`; ctx.textBaseline = "middle"; ctx.fillText( facet.label, cell.titleRect.x + cell.titleRect.width / 2, cell.titleRect.y + cell.titleRect.height / 2, ); } } } } else { ctx.beginPath(); ctx.arc(chart._centerX, chart._centerY, innerDiscR, 0, 2 * Math.PI); ctx.fill(); ctx.fillStyle = textColor; ctx.font = `11px ${fontFamily}`; ctx.fillText( store.name[chart._currentRootId], chart._centerX, chart._centerY, ); } // Breadcrumbs (non-facet only — per-facet drill is tracked through // the per-facet drill root's label, not a global breadcrumb trail). if (!faceted && chart._breadcrumbIds.length > 1) { renderTreeBreadcrumbs(chart, ctx, cssWidth, fontFamily, textColor); } // Legend. Faceted mode passes `FacetGrid.legendRect` so the // categorical-swatch variant lands in the dedicated grid slot; // numeric gradient always derives from a synthetic single-plot // layout (its vertical bar doesn't fit the compact rect). renderTreeColorLegend( chart, canvas, palette, stops, theme, cssWidth, cssHeight, faceted ? (chart._facetGrid?.legendRect ?? null) : null, ); ctx.restore(); } /** * Label placement: rotate the label *radial* to the arc at its midpoint * (text runs along the radius, from near the center outward). In * `"upright"` mode, arcs on the left half get an extra 180° flip so * text reads left-to-right in both halves; `"radial"` mode skips the * flip — simpler, but labels on the left read right-to-left. * * Sizing: * - Text length fits in the ring width (radial direction). * - Font size fits in the arc length at mid-radius (tangential * direction). */ function renderArcLabel( chart: SunburstChart, ctx: Context2D, nodeId: number, fontFamily: string, stops: GradientStop[], palette: Vec3[], centerX: number, centerY: number, ): void { const store = chart._nodeStore; const a0 = store.a0[nodeId]; const a1 = store.a1[nodeId]; const r0 = store.r0[nodeId]; const r1 = store.r1[nodeId]; const ringWidth = r1 - r0; const midR = (r0 + r1) / 2; const arcSpan = a1 - a0; const arcLen = arcSpan * midR; // Radial labels need enough ring-width for text length and enough // tangential space for font height. if (ringWidth < 16 || arcLen < 8) { return; } const fontSize = Math.min(11, Math.floor(arcLen * 0.7)); if (fontSize < 7) { return; } ctx.font = `${fontSize}px ${fontFamily}`; const name = store.name[nodeId]; const maxTextWidth = ringWidth - 4; let text = name; if (ctx.measureText(text).width > maxTextWidth) { while (text.length > 1) { text = text.slice(0, -1); if (ctx.measureText(text + "…").width <= maxTextWidth) { text += "…"; break; } } } if (text.length < 2) { return; } const midA = (a0 + a1) / 2; ctx.save(); ctx.translate(centerX, centerY); // Rotate so the local +x axis points outward along the radius // through the arc's midpoint. Text then runs along that axis. let rot = midA; const onLeftHalf = midA > Math.PI / 2 && midA < (3 * Math.PI) / 2; if (chart._labelRotation === "upright" && onLeftHalf) { rot += Math.PI; } ctx.rotate(rot); // Pick label color by luminance of the arc's fill for contrast. const fill = leafColor(chart, nodeId, stops, palette); const lum = luminance(fill[0], fill[1], fill[2]); ctx.fillStyle = lum > 0.5 ? "rgba(0,0,0,0.85)" : "rgba(255,255,255,0.9)"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; // Place the label at radial midpoint along the rotated x-axis. // Flip the sign when upright-mirrored so the center stays at the // correct radial position (the rotation brought +x through the // origin, so midR is now on the "back" side in local coords). const x = chart._labelRotation === "upright" && onLeftHalf ? -midR : midR; ctx.fillText(text, x, 0); ctx.restore(); } function renderHoverHighlight( ctx: Context2D, chart: SunburstChart, nodeId: number, ): void { const store = chart._nodeStore; const a0 = store.a0[nodeId]; const a1 = store.a1[nodeId]; const r0 = store.r0[nodeId]; const r1 = store.r1[nodeId]; const { centerX, centerY } = facetCenterForNode(chart, nodeId); ctx.strokeStyle = "rgba(255,255,255,0.9)"; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(centerX, centerY, r1, a0, a1); ctx.arc(centerX, centerY, r0, a1, a0, true); ctx.closePath(); ctx.stroke(); } function renderSunburstTooltip( chart: SunburstChart, ctx: Context2D, nodeId: number, cssWidth: number, cssHeight: number, fontFamily: string, ): void { const store = chart._nodeStore; const midA = (store.a0[nodeId] + store.a1[nodeId]) / 2; const midR = (store.r0[nodeId] + store.r1[nodeId]) / 2; const { centerX, centerY } = facetCenterForNode(chart, nodeId); const cx = centerX + Math.cos(midA) * midR; const cy = centerY + Math.sin(midA) * midR; renderTreeTooltip( chart, ctx, nodeId, cx, cy, cssWidth, cssHeight, fontFamily, ); }