@perspective-dev/viewer-charts 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (258) hide show
  1. package/LICENSE.md +193 -0
  2. package/dist/cdn/perspective-viewer-charts.js +3 -0
  3. package/dist/cdn/perspective-viewer-charts.js.map +7 -0
  4. package/dist/esm/axis/axis-primitives.d.ts +24 -0
  5. package/dist/esm/axis/bar-axis.d.ts +51 -0
  6. package/dist/esm/axis/canvas.d.ts +24 -0
  7. package/dist/esm/axis/categorical-axis-core.d.ts +42 -0
  8. package/dist/esm/axis/categorical-axis.d.ts +27 -0
  9. package/dist/esm/axis/facet-chrome.d.ts +13 -0
  10. package/dist/esm/axis/label-geometry.d.ts +41 -0
  11. package/dist/esm/axis/legend.d.ts +44 -0
  12. package/dist/esm/axis/numeric-axis.d.ts +20 -0
  13. package/dist/esm/charts/candlestick/candlestick-build.d.ts +129 -0
  14. package/dist/esm/charts/candlestick/candlestick-interact.d.ts +10 -0
  15. package/dist/esm/charts/candlestick/candlestick-render.d.ts +24 -0
  16. package/dist/esm/charts/candlestick/candlestick.d.ts +144 -0
  17. package/dist/esm/charts/candlestick/glyphs/draw-candlesticks.d.ts +36 -0
  18. package/dist/esm/charts/candlestick/glyphs/draw-ohlc.d.ts +33 -0
  19. package/dist/esm/charts/canvas-types.d.ts +15 -0
  20. package/dist/esm/charts/cartesian/cartesian-build.d.ts +14 -0
  21. package/dist/esm/charts/cartesian/cartesian-interact.d.ts +20 -0
  22. package/dist/esm/charts/cartesian/cartesian-render.d.ts +26 -0
  23. package/dist/esm/charts/cartesian/cartesian.d.ts +239 -0
  24. package/dist/esm/charts/cartesian/glyph.d.ts +53 -0
  25. package/dist/esm/charts/cartesian/glyphs/density.d.ts +142 -0
  26. package/dist/esm/charts/cartesian/glyphs/lines.d.ts +23 -0
  27. package/dist/esm/charts/cartesian/glyphs/points.d.ts +24 -0
  28. package/dist/esm/charts/cartesian/label-interner.d.ts +21 -0
  29. package/dist/esm/charts/cartesian/tooltip-lines.d.ts +11 -0
  30. package/dist/esm/charts/chart-base.d.ts +402 -0
  31. package/dist/esm/charts/chart.d.ts +338 -0
  32. package/dist/esm/charts/common/band-layout.d.ts +32 -0
  33. package/dist/esm/charts/common/categorical-y-chart.d.ts +53 -0
  34. package/dist/esm/charts/common/category-axis-resolver.d.ts +90 -0
  35. package/dist/esm/charts/common/chrome-cache.d.ts +18 -0
  36. package/dist/esm/charts/common/draw-tooltip-box.d.ts +9 -0
  37. package/dist/esm/charts/common/leaf-color.d.ts +33 -0
  38. package/dist/esm/charts/common/node-store.d.ts +81 -0
  39. package/dist/esm/charts/common/tree-chart.d.ts +48 -0
  40. package/dist/esm/charts/common/tree-chrome.d.ts +31 -0
  41. package/dist/esm/charts/common/tree-data.d.ts +54 -0
  42. package/dist/esm/charts/common/visible-extent.d.ts +51 -0
  43. package/dist/esm/charts/heatmap/heatmap-build.d.ts +86 -0
  44. package/dist/esm/charts/heatmap/heatmap-interact.d.ts +19 -0
  45. package/dist/esm/charts/heatmap/heatmap-render.d.ts +19 -0
  46. package/dist/esm/charts/heatmap/heatmap-y-axis.d.ts +46 -0
  47. package/dist/esm/charts/heatmap/heatmap.d.ts +117 -0
  48. package/dist/esm/charts/map/map.d.ts +67 -0
  49. package/dist/esm/charts/registry.d.ts +14 -0
  50. package/dist/esm/charts/series/glyphs/draw-areas.d.ts +30 -0
  51. package/dist/esm/charts/series/glyphs/draw-bars.d.ts +15 -0
  52. package/dist/esm/charts/series/glyphs/draw-lines.d.ts +34 -0
  53. package/dist/esm/charts/series/glyphs/draw-scatter.d.ts +33 -0
  54. package/dist/esm/charts/series/series-build.d.ts +228 -0
  55. package/dist/esm/charts/series/series-interact.d.ts +35 -0
  56. package/dist/esm/charts/series/series-render.d.ts +41 -0
  57. package/dist/esm/charts/series/series-type.d.ts +49 -0
  58. package/dist/esm/charts/series/series.d.ts +317 -0
  59. package/dist/esm/charts/sunburst/sunburst-interact.d.ts +7 -0
  60. package/dist/esm/charts/sunburst/sunburst-layout.d.ts +33 -0
  61. package/dist/esm/charts/sunburst/sunburst-render.d.ts +22 -0
  62. package/dist/esm/charts/sunburst/sunburst.d.ts +85 -0
  63. package/dist/esm/charts/treemap/treemap-interact.d.ts +12 -0
  64. package/dist/esm/charts/treemap/treemap-layout.d.ts +28 -0
  65. package/dist/esm/charts/treemap/treemap-render.d.ts +18 -0
  66. package/dist/esm/charts/treemap/treemap.d.ts +74 -0
  67. package/dist/esm/config.d.ts +27 -0
  68. package/dist/esm/data/lazy-row.d.ts +32 -0
  69. package/dist/esm/data/split-groups.d.ts +20 -0
  70. package/dist/esm/data/view-reader.d.ts +35 -0
  71. package/dist/esm/event-detail.d.ts +28 -0
  72. package/dist/esm/index.d.ts +3 -0
  73. package/dist/esm/interaction/hit-test.d.ts +30 -0
  74. package/dist/esm/interaction/host-sink-dom.d.ts +19 -0
  75. package/dist/esm/interaction/host-sink-message.d.ts +46 -0
  76. package/dist/esm/interaction/lazy-tooltip.d.ts +61 -0
  77. package/dist/esm/interaction/raw-event-forwarder.d.ts +27 -0
  78. package/dist/esm/interaction/spatial-grid.d.ts +15 -0
  79. package/dist/esm/interaction/tooltip-controller.d.ts +193 -0
  80. package/dist/esm/interaction/zoom-controller.d.ts +106 -0
  81. package/dist/esm/interaction/zoom-router.d.ts +48 -0
  82. package/dist/esm/layout/facet-grid.d.ts +126 -0
  83. package/dist/esm/layout/plot-layout.d.ts +104 -0
  84. package/dist/esm/layout/ticks.d.ts +17 -0
  85. package/dist/esm/map/mercator.d.ts +102 -0
  86. package/dist/esm/map/tile-cache.d.ts +38 -0
  87. package/dist/esm/map/tile-layer.d.ts +66 -0
  88. package/dist/esm/map/tile-loader.d.ts +52 -0
  89. package/dist/esm/map/tile-source.d.ts +66 -0
  90. package/dist/esm/perspective-viewer-charts.js +3 -0
  91. package/dist/esm/perspective-viewer-charts.js.map +7 -0
  92. package/dist/esm/plugin/charts.d.ts +40 -0
  93. package/dist/esm/plugin/plugin.d.ts +95 -0
  94. package/dist/esm/render/scheduler.d.ts +41 -0
  95. package/dist/esm/theme/gradient.d.ts +48 -0
  96. package/dist/esm/theme/palette.d.ts +13 -0
  97. package/dist/esm/theme/theme-snapshot.d.ts +7 -0
  98. package/dist/esm/theme/theme.d.ts +53 -0
  99. package/dist/esm/transport/protocol.d.ts +430 -0
  100. package/dist/esm/transport/renderer-transport.d.ts +201 -0
  101. package/dist/esm/utils/css.d.ts +1 -0
  102. package/dist/esm/utils/font-snapshot.d.ts +50 -0
  103. package/dist/esm/webgl/buffer-pool.d.ts +62 -0
  104. package/dist/esm/webgl/context-manager.d.ts +184 -0
  105. package/dist/esm/webgl/gradient-texture.d.ts +17 -0
  106. package/dist/esm/webgl/instanced-attrs.d.ts +44 -0
  107. package/dist/esm/webgl/plot-frame.d.ts +39 -0
  108. package/dist/esm/webgl/program-cache.d.ts +13 -0
  109. package/dist/esm/webgl/shader-manifest.d.ts +53 -0
  110. package/dist/esm/webgl/shader-registry.d.ts +22 -0
  111. package/dist/esm/worker/boot.d.ts +0 -0
  112. package/dist/esm/worker/dispatch.d.ts +9 -0
  113. package/dist/esm/worker/font-loader.d.ts +2 -0
  114. package/dist/esm/worker/renderer.worker.d.ts +115 -0
  115. package/dist/esm/worker/session-host.d.ts +26 -0
  116. package/package.json +47 -0
  117. package/src/css/perspective-viewer-charts.css +95 -0
  118. package/src/ts/axis/axis-primitives.ts +125 -0
  119. package/src/ts/axis/bar-axis.ts +345 -0
  120. package/src/ts/axis/canvas.ts +64 -0
  121. package/src/ts/axis/categorical-axis-core.ts +125 -0
  122. package/src/ts/axis/categorical-axis.ts +716 -0
  123. package/src/ts/axis/facet-chrome.ts +42 -0
  124. package/src/ts/axis/label-geometry.ts +188 -0
  125. package/src/ts/axis/legend.ts +218 -0
  126. package/src/ts/axis/numeric-axis.ts +353 -0
  127. package/src/ts/charts/candlestick/candlestick-build.ts +516 -0
  128. package/src/ts/charts/candlestick/candlestick-interact.ts +256 -0
  129. package/src/ts/charts/candlestick/candlestick-render.ts +387 -0
  130. package/src/ts/charts/candlestick/candlestick.ts +367 -0
  131. package/src/ts/charts/candlestick/glyphs/draw-candlesticks.ts +432 -0
  132. package/src/ts/charts/candlestick/glyphs/draw-ohlc.ts +317 -0
  133. package/src/ts/charts/canvas-types.ts +30 -0
  134. package/src/ts/charts/cartesian/cartesian-build.ts +616 -0
  135. package/src/ts/charts/cartesian/cartesian-interact.ts +355 -0
  136. package/src/ts/charts/cartesian/cartesian-render.ts +948 -0
  137. package/src/ts/charts/cartesian/cartesian.ts +469 -0
  138. package/src/ts/charts/cartesian/glyph.ts +81 -0
  139. package/src/ts/charts/cartesian/glyphs/density.ts +1263 -0
  140. package/src/ts/charts/cartesian/glyphs/lines.ts +320 -0
  141. package/src/ts/charts/cartesian/glyphs/points.ts +239 -0
  142. package/src/ts/charts/cartesian/label-interner.ts +56 -0
  143. package/src/ts/charts/cartesian/tooltip-lines.ts +80 -0
  144. package/src/ts/charts/chart-base.ts +840 -0
  145. package/src/ts/charts/chart.ts +427 -0
  146. package/src/ts/charts/common/band-layout.ts +63 -0
  147. package/src/ts/charts/common/categorical-y-chart.ts +81 -0
  148. package/src/ts/charts/common/category-axis-resolver.ts +314 -0
  149. package/src/ts/charts/common/chrome-cache.ts +79 -0
  150. package/src/ts/charts/common/draw-tooltip-box.ts +84 -0
  151. package/src/ts/charts/common/leaf-color.ts +92 -0
  152. package/src/ts/charts/common/node-store.ts +235 -0
  153. package/src/ts/charts/common/tree-chart.ts +76 -0
  154. package/src/ts/charts/common/tree-chrome.ts +123 -0
  155. package/src/ts/charts/common/tree-data.ts +623 -0
  156. package/src/ts/charts/common/visible-extent.ts +112 -0
  157. package/src/ts/charts/heatmap/heatmap-build.ts +426 -0
  158. package/src/ts/charts/heatmap/heatmap-interact.ts +274 -0
  159. package/src/ts/charts/heatmap/heatmap-render.ts +815 -0
  160. package/src/ts/charts/heatmap/heatmap-y-axis.ts +351 -0
  161. package/src/ts/charts/heatmap/heatmap.ts +368 -0
  162. package/src/ts/charts/map/map.ts +201 -0
  163. package/src/ts/charts/registry.ts +65 -0
  164. package/src/ts/charts/series/glyphs/draw-areas.ts +331 -0
  165. package/src/ts/charts/series/glyphs/draw-bars.ts +113 -0
  166. package/src/ts/charts/series/glyphs/draw-lines.ts +320 -0
  167. package/src/ts/charts/series/glyphs/draw-scatter.ts +328 -0
  168. package/src/ts/charts/series/series-build.ts +848 -0
  169. package/src/ts/charts/series/series-interact.ts +604 -0
  170. package/src/ts/charts/series/series-render.ts +1109 -0
  171. package/src/ts/charts/series/series-type.ts +99 -0
  172. package/src/ts/charts/series/series.ts +794 -0
  173. package/src/ts/charts/sunburst/sunburst-interact.ts +460 -0
  174. package/src/ts/charts/sunburst/sunburst-layout.ts +238 -0
  175. package/src/ts/charts/sunburst/sunburst-render.ts +887 -0
  176. package/src/ts/charts/sunburst/sunburst.ts +248 -0
  177. package/src/ts/charts/treemap/treemap-interact.ts +445 -0
  178. package/src/ts/charts/treemap/treemap-layout.ts +328 -0
  179. package/src/ts/charts/treemap/treemap-render.ts +886 -0
  180. package/src/ts/charts/treemap/treemap.ts +247 -0
  181. package/src/ts/config.ts +41 -0
  182. package/src/ts/data/lazy-row.ts +140 -0
  183. package/src/ts/data/split-groups.ts +97 -0
  184. package/src/ts/data/view-reader.ts +107 -0
  185. package/src/ts/event-detail.ts +44 -0
  186. package/src/ts/index.ts +53 -0
  187. package/src/ts/interaction/hit-test.ts +106 -0
  188. package/src/ts/interaction/host-sink-dom.ts +85 -0
  189. package/src/ts/interaction/host-sink-message.ts +75 -0
  190. package/src/ts/interaction/lazy-tooltip.ts +102 -0
  191. package/src/ts/interaction/raw-event-forwarder.ts +175 -0
  192. package/src/ts/interaction/spatial-grid.ts +100 -0
  193. package/src/ts/interaction/tooltip-controller.ts +407 -0
  194. package/src/ts/interaction/zoom-controller.ts +468 -0
  195. package/src/ts/interaction/zoom-router.ts +230 -0
  196. package/src/ts/layout/facet-grid.ts +346 -0
  197. package/src/ts/layout/plot-layout.ts +277 -0
  198. package/src/ts/layout/ticks.ts +168 -0
  199. package/src/ts/map/mercator.ts +204 -0
  200. package/src/ts/map/tile-cache.ts +96 -0
  201. package/src/ts/map/tile-layer.ts +382 -0
  202. package/src/ts/map/tile-loader.ts +143 -0
  203. package/src/ts/map/tile-source.ts +156 -0
  204. package/src/ts/plugin/charts.ts +286 -0
  205. package/src/ts/plugin/plugin.ts +668 -0
  206. package/src/ts/render/scheduler.ts +339 -0
  207. package/src/ts/shaders/area.frag.glsl +20 -0
  208. package/src/ts/shaders/area.vert.glsl +19 -0
  209. package/src/ts/shaders/bar.frag.glsl +25 -0
  210. package/src/ts/shaders/bar.vert.glsl +60 -0
  211. package/src/ts/shaders/candlestick-body.frag.glsl +19 -0
  212. package/src/ts/shaders/candlestick-body.vert.glsl +34 -0
  213. package/src/ts/shaders/density-extreme.frag.glsl +30 -0
  214. package/src/ts/shaders/density-mrt.frag.glsl +44 -0
  215. package/src/ts/shaders/density-mrt.vert.glsl +48 -0
  216. package/src/ts/shaders/density-resolve.frag.glsl +89 -0
  217. package/src/ts/shaders/density-resolve.vert.glsl +23 -0
  218. package/src/ts/shaders/density-splat.frag.glsl +34 -0
  219. package/src/ts/shaders/density-splat.vert.glsl +52 -0
  220. package/src/ts/shaders/gridline.frag.glsl +18 -0
  221. package/src/ts/shaders/gridline.vert.glsl +18 -0
  222. package/src/ts/shaders/heatmap.frag.glsl +23 -0
  223. package/src/ts/shaders/heatmap.vert.glsl +42 -0
  224. package/src/ts/shaders/line-uniform.frag.glsl +26 -0
  225. package/src/ts/shaders/line-uniform.vert.glsl +54 -0
  226. package/src/ts/shaders/line.frag.glsl +28 -0
  227. package/src/ts/shaders/line.vert.glsl +87 -0
  228. package/src/ts/shaders/scatter.frag.glsl +39 -0
  229. package/src/ts/shaders/scatter.vert.glsl +67 -0
  230. package/src/ts/shaders/sunburst-arc.frag.glsl +19 -0
  231. package/src/ts/shaders/sunburst-arc.vert.glsl +79 -0
  232. package/src/ts/shaders/tile.frag.glsl +27 -0
  233. package/src/ts/shaders/tile.vert.glsl +35 -0
  234. package/src/ts/shaders/treemap.frag.glsl +19 -0
  235. package/src/ts/shaders/treemap.vert.glsl +25 -0
  236. package/src/ts/shaders/y-scatter.frag.glsl +30 -0
  237. package/src/ts/shaders/y-scatter.vert.glsl +31 -0
  238. package/src/ts/theme/gradient.ts +312 -0
  239. package/src/ts/theme/palette.ts +64 -0
  240. package/src/ts/theme/theme-snapshot.ts +66 -0
  241. package/src/ts/theme/theme.ts +166 -0
  242. package/src/ts/transport/protocol.ts +497 -0
  243. package/src/ts/transport/renderer-transport.ts +788 -0
  244. package/src/ts/utils/css.ts +36 -0
  245. package/src/ts/utils/font-snapshot.ts +159 -0
  246. package/src/ts/webgl/buffer-pool.ts +163 -0
  247. package/src/ts/webgl/context-manager.ts +414 -0
  248. package/src/ts/webgl/gradient-texture.ts +84 -0
  249. package/src/ts/webgl/instanced-attrs.ts +139 -0
  250. package/src/ts/webgl/plot-frame.ts +91 -0
  251. package/src/ts/webgl/program-cache.ts +46 -0
  252. package/src/ts/webgl/shader-manifest.ts +148 -0
  253. package/src/ts/webgl/shader-registry.ts +97 -0
  254. package/src/ts/worker/boot.ts +22 -0
  255. package/src/ts/worker/dispatch.ts +99 -0
  256. package/src/ts/worker/font-loader.ts +89 -0
  257. package/src/ts/worker/renderer.worker.ts +734 -0
  258. package/src/ts/worker/session-host.ts +118 -0
@@ -0,0 +1,887 @@
1
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ // ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ // ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ import type { Context2D } from "../canvas-types";
14
+ import type { WebGLContextManager } from "../../webgl/context-manager";
15
+ import type { SunburstChart } from "./sunburst";
16
+ import { NULL_NODE } from "../common/node-store";
17
+ import { resolvePalette, type Vec3 } from "../../theme/palette";
18
+ import { type GradientStop } from "../../theme/gradient";
19
+ import { renderLegend, renderCategoricalLegend } from "../../axis/legend";
20
+ import { PlotLayout } from "../../layout/plot-layout";
21
+ import { leafColor, leafRGBA, luminance } from "../common/leaf-color";
22
+ import arcVert from "../../shaders/sunburst-arc.vert.glsl";
23
+ import arcFrag from "../../shaders/sunburst-arc.frag.glsl";
24
+ import { getInstancing } from "../../webgl/instanced-attrs";
25
+ import {
26
+ partitionSunburst,
27
+ collectVisibleArcs,
28
+ collectVisibleArcsAppend,
29
+ INNER_RING_PX,
30
+ } from "./sunburst-layout";
31
+ import { buildFacetGrid } from "../../layout/facet-grid";
32
+ import { renderCategoricalLegendAt } from "../../axis/legend";
33
+ import { withChromeCache } from "../common/chrome-cache";
34
+ import {
35
+ renderBreadcrumbs as renderTreeBreadcrumbs,
36
+ renderTreeTooltip,
37
+ } from "../common/tree-chrome";
38
+
39
+ /**
40
+ * Triangle-strip template resolution. `N_STEPS` angular samples × 2
41
+ * radial sides = `2 * (N_STEPS + 1)` strip vertices. 32 samples is
42
+ * smooth to the eye at typical viewport sizes; bump to 64 if faceting
43
+ * becomes visible on full-circle arcs.
44
+ */
45
+ const N_STEPS = 32;
46
+ const BREADCRUMB_H = 28;
47
+ const LEGEND_W = 90;
48
+
49
+ /**
50
+ * Resolve the `(centerX, centerY)` of the facet that owns `nodeId`.
51
+ * Walks the ancestor chain and matches against each facet's
52
+ * `drillRoot`; returns `chart._centerX/_centerY` in non-faceted mode
53
+ * or as a defensive fallback. Used by every chrome path that needs to
54
+ * place geometry around an arc — labels, hover highlight, hover
55
+ * tooltip, pinned tooltip — so all four agree on which facet owns the
56
+ * node. The chart-wide `_centerX/_centerY` fields are
57
+ * `layoutFacetedSunburst`'s legacy first-facet publication and are
58
+ * not safe for these calls.
59
+ */
60
+ export function facetCenterForNode(
61
+ chart: SunburstChart,
62
+ nodeId: number,
63
+ ): { centerX: number; centerY: number } {
64
+ if (chart._facets.length === 0) {
65
+ return { centerX: chart._centerX, centerY: chart._centerY };
66
+ }
67
+
68
+ const store = chart._nodeStore;
69
+ for (const facet of chart._facets) {
70
+ let p = nodeId;
71
+ while (p !== NULL_NODE) {
72
+ if (p === facet.drillRoot) {
73
+ return { centerX: facet.centerX, centerY: facet.centerY };
74
+ }
75
+
76
+ p = store.parent[p];
77
+ }
78
+ }
79
+
80
+ return { centerX: chart._centerX, centerY: chart._centerY };
81
+ }
82
+
83
+ /**
84
+ * Full-frame render: layout → WebGL arcs → chrome overlay.
85
+ */
86
+ export function renderSunburstFrame(
87
+ chart: SunburstChart,
88
+ glManager: WebGLContextManager,
89
+ ): void {
90
+ if (chart._currentRootId === NULL_NODE) {
91
+ return;
92
+ }
93
+
94
+ const gl = glManager.gl;
95
+ const cssWidth = glManager.cssWidth;
96
+ const cssHeight = glManager.cssHeight;
97
+ if (cssWidth <= 0 || cssHeight <= 0) {
98
+ return;
99
+ }
100
+
101
+ const hasSplits =
102
+ chart._splitBy.length > 0 && chart._facetConfig.facet_mode === "grid";
103
+ const hasLegend =
104
+ chart._colorMode === "series"
105
+ ? chart._uniqueColorLabels.size > 1
106
+ : chart._colorMode === "numeric" &&
107
+ chart._colorMin < chart._colorMax;
108
+ const breadcrumbH =
109
+ !hasSplits && chart._breadcrumbIds.length > 1 ? BREADCRUMB_H : 0;
110
+ const legendW = hasLegend ? LEGEND_W : 0;
111
+
112
+ if (hasSplits) {
113
+ layoutFacetedSunburst(chart, cssWidth, cssHeight, legendW);
114
+ } else {
115
+ chart._facetGrid = null;
116
+ chart._facets = [];
117
+ const plotW = cssWidth - legendW;
118
+ const plotH = cssHeight - breadcrumbH;
119
+ chart._centerX = plotW / 2;
120
+ chart._centerY = breadcrumbH + plotH / 2;
121
+ chart._maxRadius = Math.max(0, Math.min(plotW, plotH) / 2 - 4);
122
+
123
+ partitionSunburst(
124
+ chart._nodeStore,
125
+ chart._currentRootId,
126
+ chart._maxRadius,
127
+ );
128
+ collectVisibleArcs(chart, chart._currentRootId);
129
+ }
130
+
131
+ ensureProgram(chart, glManager);
132
+
133
+ const theme = chart._resolveTheme();
134
+ const stops = theme.gradientStops;
135
+ const palette = resolvePalette(
136
+ theme.seriesPalette,
137
+ stops,
138
+ Math.max(1, chart._uniqueColorLabels.size),
139
+ );
140
+
141
+ if (chart._gridlineCanvas) {
142
+ const gCtx = chart._gridlineCanvas.getContext("2d") as Context2D | null;
143
+ if (gCtx) {
144
+ gCtx.clearRect(
145
+ 0,
146
+ 0,
147
+ chart._gridlineCanvas.width,
148
+ chart._gridlineCanvas.height,
149
+ );
150
+ }
151
+ }
152
+
153
+ const dpr = glManager.dpr;
154
+ chart._chromeCacheDirty = true;
155
+ uploadArcInstances(chart, gl, stops, palette, theme.areaOpacity, dpr);
156
+ gl.clearColor(0, 0, 0, 0);
157
+ gl.clear(gl.COLOR_BUFFER_BIT);
158
+ gl.enable(gl.BLEND);
159
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
160
+ gl.useProgram(chart._program!);
161
+
162
+ const loc = chart._locations!;
163
+ gl.uniform2f(loc.u_resolution, gl.canvas.width, gl.canvas.height);
164
+ gl.uniform1f(loc.u_border_px, theme.sunburstGapPx * dpr);
165
+
166
+ if (chart._facets.length > 0) {
167
+ // Faceted: one dispatch per facet with the matching `u_center`
168
+ // and instance range. Instance attribs are rebound per facet so
169
+ // instance 0 of each dispatch is the facet's first arc.
170
+ for (const facet of chart._facets) {
171
+ if (facet.instanceCount === 0) {
172
+ continue;
173
+ }
174
+
175
+ gl.uniform2f(
176
+ loc.u_center,
177
+ facet.centerX * dpr,
178
+ facet.centerY * dpr,
179
+ );
180
+ drawArcs(
181
+ chart,
182
+ gl,
183
+ glManager,
184
+ facet.instanceStart,
185
+ facet.instanceCount,
186
+ );
187
+ }
188
+ } else {
189
+ gl.uniform2f(loc.u_center, chart._centerX * dpr, chart._centerY * dpr);
190
+ drawArcs(chart, gl, glManager, 0, chart._instanceCount);
191
+ }
192
+
193
+ renderSunburstChromeOverlay(chart);
194
+ }
195
+
196
+ /**
197
+ * Allocate the facet grid and compute per-facet (center, radius, drill
198
+ * root) triples. Also runs `partitionSunburst` + `collectVisibleArcs`
199
+ * per facet so the combined visible list is in `_visibleNodeIds` with
200
+ * facets in cell order (instance uploads walk this list).
201
+ */
202
+ function layoutFacetedSunburst(
203
+ chart: SunburstChart,
204
+ cssWidth: number,
205
+ cssHeight: number,
206
+ legendW: number,
207
+ ): void {
208
+ const store = chart._nodeStore;
209
+ const facetIds: number[] = [];
210
+ const labels: string[] = [];
211
+ for (
212
+ let c = store.firstChild[chart._rootId];
213
+ c !== NULL_NODE;
214
+ c = store.nextSibling[c]
215
+ ) {
216
+ if (store.value[c] <= 0) {
217
+ continue;
218
+ }
219
+
220
+ facetIds.push(c);
221
+ labels.push(store.name[c]);
222
+ }
223
+
224
+ const gridWidth = Math.max(1, cssWidth - legendW);
225
+ const grid = buildFacetGrid(labels, {
226
+ cssWidth: gridWidth,
227
+ cssHeight,
228
+ hasLegend: false,
229
+
230
+ // Sunburst has no X/Y axes — no per-cell gutter reservation.
231
+ xAxis: "none",
232
+ yAxis: "none",
233
+ gap: chart._facetConfig.facet_padding,
234
+ });
235
+ chart._facetGrid = grid;
236
+
237
+ const facets: SunburstChart["_facets"] = [];
238
+ let outIdx = 0;
239
+ for (let i = 0; i < facetIds.length; i++) {
240
+ const facetId = facetIds[i];
241
+ const cell = grid.cells[i];
242
+ if (!cell) {
243
+ continue;
244
+ }
245
+
246
+ const label = store.name[facetId];
247
+ const drillRoot = chart._facetDrillRoots.get(label) ?? facetId;
248
+ const plot = cell.layout.plotRect;
249
+ const centerX = plot.x + plot.width / 2;
250
+ const centerY = plot.y + plot.height / 2;
251
+ const maxRadius = Math.max(
252
+ 0,
253
+ Math.min(plot.width, plot.height) / 2 - 4,
254
+ );
255
+
256
+ partitionSunburst(store, drillRoot, maxRadius);
257
+ const nextIdx = collectVisibleArcsAppend(chart, drillRoot, outIdx);
258
+ const instanceStart = outIdx;
259
+ const instanceCount = nextIdx - outIdx;
260
+
261
+ facets.push({
262
+ label,
263
+ centerX,
264
+ centerY,
265
+ maxRadius,
266
+ drillRoot,
267
+ instanceStart,
268
+ instanceCount,
269
+ nodeStart: instanceStart,
270
+ nodeCount: instanceCount,
271
+ });
272
+ outIdx = nextIdx;
273
+ }
274
+
275
+ chart._visibleNodeCount = outIdx;
276
+ chart._facets = facets;
277
+
278
+ // Publish the first facet's center/radius to the legacy fields so
279
+ // chrome code paths that still read them (e.g. non-faceted label
280
+ // placement) pick sensible values.
281
+ if (facets.length > 0) {
282
+ chart._centerX = facets[0].centerX;
283
+ chart._centerY = facets[0].centerY;
284
+ chart._maxRadius = facets[0].maxRadius;
285
+ }
286
+ }
287
+
288
+ function ensureProgram(
289
+ chart: SunburstChart,
290
+ glManager: WebGLContextManager,
291
+ ): void {
292
+ if (chart._program) {
293
+ return;
294
+ }
295
+
296
+ const gl = glManager.gl;
297
+ const prog = glManager.shaders.getOrCreate(
298
+ "sunburst-arc",
299
+ arcVert,
300
+ arcFrag,
301
+ );
302
+ chart._program = prog;
303
+ chart._locations = {
304
+ u_center: gl.getUniformLocation(prog, "u_center"),
305
+ u_resolution: gl.getUniformLocation(prog, "u_resolution"),
306
+ u_border_px: gl.getUniformLocation(prog, "u_border_px"),
307
+ a_strip_t: gl.getAttribLocation(prog, "a_strip_t"),
308
+ a_side: gl.getAttribLocation(prog, "a_side"),
309
+ a_angles: gl.getAttribLocation(prog, "a_angles"),
310
+ a_radii: gl.getAttribLocation(prog, "a_radii"),
311
+ a_color: gl.getAttribLocation(prog, "a_color"),
312
+ };
313
+
314
+ // Build the static triangle-strip template once. Layout:
315
+ // pairs of (strip_t, side) for each of the 2*(N_STEPS+1) vertices.
316
+ // even vertex = inner (side=0), odd vertex = outer (side=1).
317
+ const template = new Float32Array((N_STEPS + 1) * 2 * 2);
318
+ for (let i = 0; i <= N_STEPS; i++) {
319
+ const t = i / N_STEPS;
320
+ const o = i * 4;
321
+ template[o + 0] = t;
322
+ template[o + 1] = 0; // inner
323
+ template[o + 2] = t;
324
+ template[o + 3] = 1; // outer
325
+ }
326
+
327
+ chart._stripBuffer = gl.createBuffer()!;
328
+ gl.bindBuffer(gl.ARRAY_BUFFER, chart._stripBuffer);
329
+ gl.bufferData(gl.ARRAY_BUFFER, template, gl.STATIC_DRAW);
330
+
331
+ chart._instanceBuffer = gl.createBuffer()!;
332
+ }
333
+
334
+ function uploadArcInstances(
335
+ chart: SunburstChart,
336
+ gl: WebGL2RenderingContext | WebGLRenderingContext,
337
+ stops: GradientStop[],
338
+ palette: Vec3[],
339
+ negativeAlpha: number,
340
+ dpr: number,
341
+ ): void {
342
+ const store = chart._nodeStore;
343
+ const ids = chart._visibleNodeIds!;
344
+ const faceted = chart._facets.length > 0;
345
+
346
+ // Walk each facet's pre-upload visible range (instanceStart and
347
+ // instanceCount as set by `layoutFacetedSunburst`), skip the facet's
348
+ // drill root + any zero-width arcs, and emit one contiguous run per
349
+ // facet. Update `(instanceStart, instanceCount)` to the post-skip
350
+ // values so draw dispatch can offset into the shared buffer.
351
+ //
352
+ // 8 floats per instance: [a0, a1, r0, r1, r, g, b, a]. Alpha = 1
353
+ // for positive-size arcs, `negativeAlpha` for arcs whose raw size
354
+ // column value was negative (keeps the arc visible but dimmer).
355
+ const totalCap = faceted
356
+ ? chart._facets.reduce((a, f) => a + f.instanceCount, 0)
357
+ : chart._visibleNodeCount;
358
+ const data = new Float32Array(totalCap * 8);
359
+ let instance = 0;
360
+
361
+ const emitRange = (start: number, end: number, drillRoot: number) => {
362
+ const rangeStart = instance;
363
+ for (let i = start; i < end; i++) {
364
+ const id = ids[i];
365
+ if (id === drillRoot) {
366
+ continue;
367
+ }
368
+
369
+ const a0 = store.a0[id];
370
+ const a1 = store.a1[id];
371
+ const r0 = store.r0[id];
372
+ const r1 = store.r1[id];
373
+ if (a1 <= a0 || r1 <= r0) {
374
+ continue;
375
+ }
376
+
377
+ const color = leafRGBA(chart, id, stops, palette, negativeAlpha);
378
+ const o = instance * 8;
379
+ data[o + 0] = a0;
380
+ data[o + 1] = a1;
381
+ data[o + 2] = r0 * dpr;
382
+ data[o + 3] = r1 * dpr;
383
+ data[o + 4] = color[0];
384
+ data[o + 5] = color[1];
385
+ data[o + 6] = color[2];
386
+ data[o + 7] = color[3];
387
+ instance++;
388
+ }
389
+
390
+ return { rangeStart, rangeCount: instance - rangeStart };
391
+ };
392
+
393
+ if (faceted) {
394
+ for (const facet of chart._facets) {
395
+ const preStart = facet.instanceStart;
396
+ const preEnd = preStart + facet.instanceCount;
397
+ const { rangeStart, rangeCount } = emitRange(
398
+ preStart,
399
+ preEnd,
400
+ facet.drillRoot,
401
+ );
402
+ facet.instanceStart = rangeStart;
403
+ facet.instanceCount = rangeCount;
404
+ }
405
+ } else {
406
+ emitRange(0, chart._visibleNodeCount, chart._currentRootId);
407
+ }
408
+
409
+ chart._instanceCount = instance;
410
+ gl.bindBuffer(gl.ARRAY_BUFFER, chart._instanceBuffer);
411
+ gl.bufferData(
412
+ gl.ARRAY_BUFFER,
413
+ data.subarray(0, instance * 8),
414
+ gl.DYNAMIC_DRAW,
415
+ );
416
+ }
417
+
418
+ /**
419
+ * Dispatch one instanced draw over `[instanceStart, instanceStart+count)`
420
+ * of the shared arc instance buffer. In single-plot mode the range is
421
+ * the whole buffer; in faceted mode the caller dispatches once per
422
+ * facet with the matching `u_center` uniform.
423
+ *
424
+ * Instance attribute pointers are rebound with a byte offset per call
425
+ * so instance 0 of the draw is the facet's first arc.
426
+ */
427
+ function drawArcs(
428
+ chart: SunburstChart,
429
+ gl: WebGL2RenderingContext | WebGLRenderingContext,
430
+ glManager: WebGLContextManager,
431
+ instanceStart: number,
432
+ instanceCount: number,
433
+ ): void {
434
+ if (instanceCount === 0) {
435
+ return;
436
+ }
437
+
438
+ const loc = chart._locations!;
439
+
440
+ // Static strip: per-vertex (strip_t, side).
441
+ gl.bindBuffer(gl.ARRAY_BUFFER, chart._stripBuffer!);
442
+ const stripStride = 2 * Float32Array.BYTES_PER_ELEMENT;
443
+ gl.enableVertexAttribArray(loc.a_strip_t);
444
+ gl.vertexAttribPointer(loc.a_strip_t, 1, gl.FLOAT, false, stripStride, 0);
445
+ gl.enableVertexAttribArray(loc.a_side);
446
+ gl.vertexAttribPointer(
447
+ loc.a_side,
448
+ 1,
449
+ gl.FLOAT,
450
+ false,
451
+ stripStride,
452
+ Float32Array.BYTES_PER_ELEMENT,
453
+ );
454
+
455
+ const instancing = getInstancing(glManager);
456
+ const { setDivisor } = instancing;
457
+ setDivisor(loc.a_strip_t, 0);
458
+ setDivisor(loc.a_side, 0);
459
+
460
+ // Per-instance interleaved buffer (rebind with byte offset so
461
+ // instance 0 of the draw is slot `instanceStart`). Layout:
462
+ // [0..1] a_angles (a0, a1)
463
+ // [2..3] a_radii (r0, r1)
464
+ // [4..7] a_color (r, g, b, a)
465
+ gl.bindBuffer(gl.ARRAY_BUFFER, chart._instanceBuffer!);
466
+ const instStride = 8 * Float32Array.BYTES_PER_ELEMENT;
467
+ const f = Float32Array.BYTES_PER_ELEMENT;
468
+ const base = instanceStart * instStride;
469
+ gl.enableVertexAttribArray(loc.a_angles);
470
+ gl.vertexAttribPointer(loc.a_angles, 2, gl.FLOAT, false, instStride, base);
471
+ setDivisor(loc.a_angles, 1);
472
+ gl.enableVertexAttribArray(loc.a_radii);
473
+ gl.vertexAttribPointer(
474
+ loc.a_radii,
475
+ 2,
476
+ gl.FLOAT,
477
+ false,
478
+ instStride,
479
+ base + 2 * f,
480
+ );
481
+ setDivisor(loc.a_radii, 1);
482
+ gl.enableVertexAttribArray(loc.a_color);
483
+ gl.vertexAttribPointer(
484
+ loc.a_color,
485
+ 4,
486
+ gl.FLOAT,
487
+ false,
488
+ instStride,
489
+ base + 4 * f,
490
+ );
491
+ setDivisor(loc.a_color, 1);
492
+
493
+ instancing.drawArraysInstanced(
494
+ gl.TRIANGLE_STRIP,
495
+ 0,
496
+ 2 * (N_STEPS + 1),
497
+ instanceCount,
498
+ );
499
+
500
+ setDivisor(loc.a_angles, 0);
501
+ setDivisor(loc.a_radii, 0);
502
+ setDivisor(loc.a_color, 0);
503
+ }
504
+
505
+ // Chrome overlay (Canvas2D)
506
+
507
+ export function renderSunburstChromeOverlay(chart: SunburstChart): void {
508
+ if (!chart._chromeCanvas || chart._currentRootId === NULL_NODE) {
509
+ return;
510
+ }
511
+
512
+ const glManager = chart._glManager;
513
+ if (!glManager) {
514
+ return;
515
+ }
516
+
517
+ const { dpr, cssWidth, cssHeight } = glManager;
518
+
519
+ withChromeCache(
520
+ chart,
521
+ chart._chromeCanvas,
522
+ dpr,
523
+ cssWidth,
524
+ cssHeight,
525
+ (ctx) => drawStaticChrome(chart, ctx, dpr, cssWidth, cssHeight),
526
+ chart._hoveredNodeId !== NULL_NODE
527
+ ? (ctx) => {
528
+ renderHoverHighlight(ctx, chart, chart._hoveredNodeId);
529
+ renderSunburstTooltip(
530
+ chart,
531
+ ctx,
532
+ chart._hoveredNodeId,
533
+ cssWidth,
534
+ cssHeight,
535
+ chart._resolveTheme().fontFamily,
536
+ );
537
+ }
538
+ : null,
539
+ );
540
+ }
541
+
542
+ function drawStaticChrome(
543
+ chart: SunburstChart,
544
+ ctx: Context2D,
545
+ dpr: number,
546
+ cssWidth: number,
547
+ cssHeight: number,
548
+ ): void {
549
+ const canvas = chart._chromeCanvas!;
550
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
551
+ ctx.save();
552
+ ctx.scale(dpr, dpr);
553
+
554
+ const theme = chart._resolveTheme();
555
+ const { fontFamily, labelColor: textColor, tooltipBg } = theme;
556
+ const stops = theme.gradientStops;
557
+ const palette = resolvePalette(
558
+ theme.seriesPalette,
559
+ stops,
560
+ Math.max(1, chart._uniqueColorLabels.size),
561
+ );
562
+ const store = chart._nodeStore;
563
+ const ids = chart._visibleNodeIds!;
564
+ const n = chart._visibleNodeCount;
565
+ const faceted = chart._facets.length > 0;
566
+
567
+ // Arc labels — skip each facet's own drill root (its label is the
568
+ // center text / facet title, handled below). In faceted mode, walk
569
+ // each facet's `nodeStart`/`nodeCount` range over `_visibleNodeIds`
570
+ // and rotate around that facet's `(centerX, centerY)`. Without the
571
+ // per-facet center, every label translates around the chart's
572
+ // `_centerX/_centerY`, which `layoutFacetedSunburst` publishes
573
+ // from facet 0 — the symptom is "all labels pile onto facet 0."
574
+ if (faceted) {
575
+ for (const facet of chart._facets) {
576
+ const end = facet.nodeStart + facet.nodeCount;
577
+ for (let i = facet.nodeStart; i < end; i++) {
578
+ const id = ids[i];
579
+ if (id === facet.drillRoot) {
580
+ continue;
581
+ }
582
+
583
+ renderArcLabel(
584
+ chart,
585
+ ctx,
586
+ id,
587
+ fontFamily,
588
+ stops,
589
+ palette,
590
+ facet.centerX,
591
+ facet.centerY,
592
+ );
593
+ }
594
+ }
595
+ } else {
596
+ for (let i = 0; i < n; i++) {
597
+ const id = ids[i];
598
+ if (id === chart._currentRootId) {
599
+ continue;
600
+ }
601
+
602
+ renderArcLabel(
603
+ chart,
604
+ ctx,
605
+ id,
606
+ fontFamily,
607
+ stops,
608
+ palette,
609
+ chart._centerX,
610
+ chart._centerY,
611
+ );
612
+ }
613
+ }
614
+
615
+ // Inner drill-up circle(s). One per facet in faceted mode so each
616
+ // facet has its own center hit target.
617
+ const innerDiscR = Math.max(0, INNER_RING_PX - theme.sunburstGapPx * 0.5);
618
+ ctx.fillStyle = tooltipBg;
619
+ ctx.textAlign = "center";
620
+ ctx.textBaseline = "middle";
621
+
622
+ if (faceted) {
623
+ for (const facet of chart._facets) {
624
+ ctx.beginPath();
625
+ ctx.fillStyle = tooltipBg;
626
+ ctx.arc(facet.centerX, facet.centerY, innerDiscR, 0, 2 * Math.PI);
627
+ ctx.fill();
628
+ ctx.fillStyle = textColor;
629
+ ctx.font = `11px ${fontFamily}`;
630
+ ctx.fillText(
631
+ store.name[facet.drillRoot],
632
+ facet.centerX,
633
+ facet.centerY,
634
+ );
635
+
636
+ // Facet title band above the arcs.
637
+ if (chart._facetGrid) {
638
+ const cell = chart._facetGrid.cells.find(
639
+ (c) => c.label === facet.label,
640
+ );
641
+ if (cell?.titleRect) {
642
+ ctx.fillStyle = textColor;
643
+ ctx.font = `11px ${fontFamily}`;
644
+ ctx.textBaseline = "middle";
645
+ ctx.fillText(
646
+ facet.label,
647
+ cell.titleRect.x + cell.titleRect.width / 2,
648
+ cell.titleRect.y + cell.titleRect.height / 2,
649
+ );
650
+ }
651
+ }
652
+ }
653
+ } else {
654
+ ctx.beginPath();
655
+ ctx.arc(chart._centerX, chart._centerY, innerDiscR, 0, 2 * Math.PI);
656
+ ctx.fill();
657
+ ctx.fillStyle = textColor;
658
+ ctx.font = `11px ${fontFamily}`;
659
+ ctx.fillText(
660
+ store.name[chart._currentRootId],
661
+ chart._centerX,
662
+ chart._centerY,
663
+ );
664
+ }
665
+
666
+ // Breadcrumbs (non-facet only — per-facet drill is tracked through
667
+ // the per-facet drill root's label, not a global breadcrumb trail).
668
+ if (!faceted && chart._breadcrumbIds.length > 1) {
669
+ renderTreeBreadcrumbs(chart, ctx, cssWidth, fontFamily, textColor);
670
+ }
671
+
672
+ // Legend. In faceted mode use the grid's explicit rect; otherwise
673
+ // derive from a synthetic single-plot layout.
674
+ if (faceted && chart._facetGrid?.legendRect) {
675
+ if (
676
+ chart._colorMode === "series" &&
677
+ chart._uniqueColorLabels.size > 1
678
+ ) {
679
+ renderCategoricalLegendAt(
680
+ canvas,
681
+ chart._facetGrid.legendRect,
682
+ chart._uniqueColorLabels,
683
+ palette,
684
+ theme,
685
+ );
686
+ } else if (
687
+ chart._colorMode === "numeric" &&
688
+ chart._colorMin < chart._colorMax
689
+ ) {
690
+ const legendLayout = new PlotLayout(cssWidth, cssHeight, {
691
+ hasXLabel: false,
692
+ hasYLabel: false,
693
+ hasLegend: true,
694
+ });
695
+ renderLegend(
696
+ canvas,
697
+ legendLayout,
698
+ {
699
+ min: chart._colorMin,
700
+ max: chart._colorMax,
701
+ label: chart._colorName,
702
+ },
703
+ stops,
704
+ theme,
705
+ chart.getColumnFormatter(chart._colorName, "value"),
706
+ );
707
+ }
708
+ } else if (
709
+ chart._colorMode === "series" &&
710
+ chart._uniqueColorLabels.size > 1
711
+ ) {
712
+ const legendLayout = new PlotLayout(cssWidth, cssHeight, {
713
+ hasXLabel: false,
714
+ hasYLabel: false,
715
+ hasLegend: true,
716
+ });
717
+ renderCategoricalLegend(
718
+ canvas,
719
+ legendLayout,
720
+ chart._uniqueColorLabels,
721
+ palette,
722
+ theme,
723
+ );
724
+ } else if (
725
+ chart._colorMode === "numeric" &&
726
+ chart._colorMin < chart._colorMax
727
+ ) {
728
+ const legendLayout = new PlotLayout(cssWidth, cssHeight, {
729
+ hasXLabel: false,
730
+ hasYLabel: false,
731
+ hasLegend: true,
732
+ });
733
+ renderLegend(
734
+ canvas,
735
+ legendLayout,
736
+ {
737
+ min: chart._colorMin,
738
+ max: chart._colorMax,
739
+ label: chart._colorName,
740
+ },
741
+ stops,
742
+ theme,
743
+ chart.getColumnFormatter(chart._colorName, "value"),
744
+ );
745
+ }
746
+
747
+ ctx.restore();
748
+ }
749
+
750
+ /**
751
+ * Label placement: rotate the label *radial* to the arc at its midpoint
752
+ * (text runs along the radius, from near the center outward). In
753
+ * `"upright"` mode, arcs on the left half get an extra 180° flip so
754
+ * text reads left-to-right in both halves; `"radial"` mode skips the
755
+ * flip — simpler, but labels on the left read right-to-left.
756
+ *
757
+ * Sizing:
758
+ * - Text length fits in the ring width (radial direction).
759
+ * - Font size fits in the arc length at mid-radius (tangential
760
+ * direction).
761
+ */
762
+ function renderArcLabel(
763
+ chart: SunburstChart,
764
+ ctx: Context2D,
765
+ nodeId: number,
766
+ fontFamily: string,
767
+ stops: GradientStop[],
768
+ palette: Vec3[],
769
+ centerX: number,
770
+ centerY: number,
771
+ ): void {
772
+ const store = chart._nodeStore;
773
+ const a0 = store.a0[nodeId];
774
+ const a1 = store.a1[nodeId];
775
+ const r0 = store.r0[nodeId];
776
+ const r1 = store.r1[nodeId];
777
+ const ringWidth = r1 - r0;
778
+ const midR = (r0 + r1) / 2;
779
+ const arcSpan = a1 - a0;
780
+ const arcLen = arcSpan * midR;
781
+
782
+ // Radial labels need enough ring-width for text length and enough
783
+ // tangential space for font height.
784
+ if (ringWidth < 16 || arcLen < 8) {
785
+ return;
786
+ }
787
+
788
+ const fontSize = Math.min(11, Math.floor(arcLen * 0.7));
789
+ if (fontSize < 7) {
790
+ return;
791
+ }
792
+
793
+ ctx.font = `${fontSize}px ${fontFamily}`;
794
+ const name = store.name[nodeId];
795
+ const maxTextWidth = ringWidth - 4;
796
+ let text = name;
797
+ if (ctx.measureText(text).width > maxTextWidth) {
798
+ while (text.length > 1) {
799
+ text = text.slice(0, -1);
800
+ if (ctx.measureText(text + "…").width <= maxTextWidth) {
801
+ text += "…";
802
+ break;
803
+ }
804
+ }
805
+ }
806
+
807
+ if (text.length < 2) {
808
+ return;
809
+ }
810
+
811
+ const midA = (a0 + a1) / 2;
812
+
813
+ ctx.save();
814
+ ctx.translate(centerX, centerY);
815
+
816
+ // Rotate so the local +x axis points outward along the radius
817
+ // through the arc's midpoint. Text then runs along that axis.
818
+ let rot = midA;
819
+ const onLeftHalf = midA > Math.PI / 2 && midA < (3 * Math.PI) / 2;
820
+ if (chart._labelRotation === "upright" && onLeftHalf) {
821
+ rot += Math.PI;
822
+ }
823
+
824
+ ctx.rotate(rot);
825
+
826
+ // Pick label color by luminance of the arc's fill for contrast.
827
+ const fill = leafColor(chart, nodeId, stops, palette);
828
+ const lum = luminance(fill[0], fill[1], fill[2]);
829
+ ctx.fillStyle = lum > 0.5 ? "rgba(0,0,0,0.85)" : "rgba(255,255,255,0.9)";
830
+ ctx.textAlign = "center";
831
+ ctx.textBaseline = "middle";
832
+
833
+ // Place the label at radial midpoint along the rotated x-axis.
834
+ // Flip the sign when upright-mirrored so the center stays at the
835
+ // correct radial position (the rotation brought +x through the
836
+ // origin, so midR is now on the "back" side in local coords).
837
+ const x = chart._labelRotation === "upright" && onLeftHalf ? -midR : midR;
838
+ ctx.fillText(text, x, 0);
839
+ ctx.restore();
840
+ }
841
+
842
+ function renderHoverHighlight(
843
+ ctx: Context2D,
844
+ chart: SunburstChart,
845
+ nodeId: number,
846
+ ): void {
847
+ const store = chart._nodeStore;
848
+ const a0 = store.a0[nodeId];
849
+ const a1 = store.a1[nodeId];
850
+ const r0 = store.r0[nodeId];
851
+ const r1 = store.r1[nodeId];
852
+ const { centerX, centerY } = facetCenterForNode(chart, nodeId);
853
+
854
+ ctx.strokeStyle = "rgba(255,255,255,0.9)";
855
+ ctx.lineWidth = 2;
856
+ ctx.beginPath();
857
+ ctx.arc(centerX, centerY, r1, a0, a1);
858
+ ctx.arc(centerX, centerY, r0, a1, a0, true);
859
+ ctx.closePath();
860
+ ctx.stroke();
861
+ }
862
+
863
+ function renderSunburstTooltip(
864
+ chart: SunburstChart,
865
+ ctx: Context2D,
866
+ nodeId: number,
867
+ cssWidth: number,
868
+ cssHeight: number,
869
+ fontFamily: string,
870
+ ): void {
871
+ const store = chart._nodeStore;
872
+ const midA = (store.a0[nodeId] + store.a1[nodeId]) / 2;
873
+ const midR = (store.r0[nodeId] + store.r1[nodeId]) / 2;
874
+ const { centerX, centerY } = facetCenterForNode(chart, nodeId);
875
+ const cx = centerX + Math.cos(midA) * midR;
876
+ const cy = centerY + Math.sin(midA) * midR;
877
+ renderTreeTooltip(
878
+ chart,
879
+ ctx,
880
+ nodeId,
881
+ cx,
882
+ cy,
883
+ cssWidth,
884
+ cssHeight,
885
+ fontFamily,
886
+ );
887
+ }