@blukavantui/charts-community 0.0.0-stage → 1.2.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 (262) hide show
  1. package/LICENSE.txt +103 -0
  2. package/README.md +98 -2
  3. package/THIRD_PARTY_NOTICES.txt +288 -0
  4. package/dist/accessibility.d.ts +53 -0
  5. package/dist/async.d.ts +2 -0
  6. package/dist/bar.css +1 -0
  7. package/dist/bar.d.ts +5 -0
  8. package/dist/bar.js +1 -0
  9. package/dist/browser/charts-community.css +1 -0
  10. package/dist/browser/charts-community.js +52 -0
  11. package/dist/build-audit.json +209 -0
  12. package/dist/builders/bar.d.ts +6 -0
  13. package/dist/builders/doughnut.d.ts +6 -0
  14. package/dist/builders/heatmap.d.ts +16 -0
  15. package/dist/builders/line.d.ts +6 -0
  16. package/dist/builders/pie.d.ts +6 -0
  17. package/dist/builders/radar.d.ts +6 -0
  18. package/dist/builders/scatter.d.ts +6 -0
  19. package/dist/catalogue-model.d.ts +11 -0
  20. package/dist/catalogue-runtime.d.ts +23 -0
  21. package/dist/catalogue-types.d.ts +167 -0
  22. package/dist/chart-state.d.ts +18 -0
  23. package/dist/chunk-2GUWWKHW.js +1 -0
  24. package/dist/chunk-3PITHTEO.js +1 -0
  25. package/dist/chunk-4B5MQ6GZ.js +1 -0
  26. package/dist/chunk-4EQMOBBN.js +1 -0
  27. package/dist/chunk-4WDZUJDS.js +1 -0
  28. package/dist/chunk-5NUOOMHV.js +1 -0
  29. package/dist/chunk-7OCMRHIH.js +1 -0
  30. package/dist/chunk-CF5Q2DDP.js +1 -0
  31. package/dist/chunk-EITBQBN2.js +1 -0
  32. package/dist/chunk-FXNKAHRG.js +1 -0
  33. package/dist/chunk-IDMAFTWV.js +1 -0
  34. package/dist/chunk-ILTENI35.js +1 -0
  35. package/dist/chunk-KRMM7EWI.js +1 -0
  36. package/dist/chunk-LUUPBBD4.js +1 -0
  37. package/dist/chunk-MR5VITXX.js +1 -0
  38. package/dist/chunk-NASBRHIW.js +1 -0
  39. package/dist/chunk-NXDBBMF5.js +1 -0
  40. package/dist/chunk-OQ6MP7PY.js +1 -0
  41. package/dist/chunk-RC2TCMJF.js +1 -0
  42. package/dist/chunk-RXOGAN23.js +1 -0
  43. package/dist/chunk-UGXWOWAG.js +2 -0
  44. package/dist/chunk-UO2KNHTU.js +2 -0
  45. package/dist/chunk-UQMMBG26.js +3 -0
  46. package/dist/chunk-VTJ4XPRV.js +1 -0
  47. package/dist/chunk-WJ3UHH6J.js +1 -0
  48. package/dist/chunk-XLJUSNGV.js +1 -0
  49. package/dist/colors.d.ts +15 -0
  50. package/dist/community.d.ts +11 -0
  51. package/dist/doughnut.css +1 -0
  52. package/dist/doughnut.d.ts +5 -0
  53. package/dist/doughnut.js +1 -0
  54. package/dist/export.d.ts +5 -0
  55. package/dist/fonts/dm-sans-LICENSE.txt +93 -0
  56. package/dist/fonts/dm-sans-latin-ext-wght-italic.woff2 +0 -0
  57. package/dist/fonts/dm-sans-latin-ext-wght-normal.woff2 +0 -0
  58. package/dist/fonts/dm-sans-latin-wght-italic.woff2 +0 -0
  59. package/dist/fonts/dm-sans-latin-wght-normal.woff2 +0 -0
  60. package/dist/fonts/ibm-plex-mono-LICENSE.txt +93 -0
  61. package/dist/fonts/ibm-plex-mono-latin-100-italic.woff2 +0 -0
  62. package/dist/fonts/ibm-plex-mono-latin-100-normal.woff2 +0 -0
  63. package/dist/fonts/ibm-plex-mono-latin-200-italic.woff2 +0 -0
  64. package/dist/fonts/ibm-plex-mono-latin-200-normal.woff2 +0 -0
  65. package/dist/fonts/ibm-plex-mono-latin-300-italic.woff2 +0 -0
  66. package/dist/fonts/ibm-plex-mono-latin-300-normal.woff2 +0 -0
  67. package/dist/fonts/ibm-plex-mono-latin-400-italic.woff2 +0 -0
  68. package/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  69. package/dist/fonts/ibm-plex-mono-latin-500-italic.woff2 +0 -0
  70. package/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  71. package/dist/fonts/ibm-plex-mono-latin-600-italic.woff2 +0 -0
  72. package/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  73. package/dist/fonts/ibm-plex-mono-latin-700-italic.woff2 +0 -0
  74. package/dist/fonts/ibm-plex-mono-latin-700-normal.woff2 +0 -0
  75. package/dist/fonts/ibm-plex-mono-latin-ext-100-italic.woff2 +0 -0
  76. package/dist/fonts/ibm-plex-mono-latin-ext-100-normal.woff2 +0 -0
  77. package/dist/fonts/ibm-plex-mono-latin-ext-200-italic.woff2 +0 -0
  78. package/dist/fonts/ibm-plex-mono-latin-ext-200-normal.woff2 +0 -0
  79. package/dist/fonts/ibm-plex-mono-latin-ext-300-italic.woff2 +0 -0
  80. package/dist/fonts/ibm-plex-mono-latin-ext-300-normal.woff2 +0 -0
  81. package/dist/fonts/ibm-plex-mono-latin-ext-400-italic.woff2 +0 -0
  82. package/dist/fonts/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  83. package/dist/fonts/ibm-plex-mono-latin-ext-500-italic.woff2 +0 -0
  84. package/dist/fonts/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  85. package/dist/fonts/ibm-plex-mono-latin-ext-600-italic.woff2 +0 -0
  86. package/dist/fonts/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
  87. package/dist/fonts/ibm-plex-mono-latin-ext-700-italic.woff2 +0 -0
  88. package/dist/fonts/ibm-plex-mono-latin-ext-700-normal.woff2 +0 -0
  89. package/dist/fonts/inter-LICENSE.txt +93 -0
  90. package/dist/fonts/inter-latin-ext-wght-italic.woff2 +0 -0
  91. package/dist/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
  92. package/dist/fonts/inter-latin-wght-italic.woff2 +0 -0
  93. package/dist/fonts/inter-latin-wght-normal.woff2 +0 -0
  94. package/dist/fonts/jetbrains-mono-LICENSE.txt +93 -0
  95. package/dist/fonts/jetbrains-mono-latin-ext-wght-italic.woff2 +0 -0
  96. package/dist/fonts/jetbrains-mono-latin-ext-wght-normal.woff2 +0 -0
  97. package/dist/fonts/jetbrains-mono-latin-wght-italic.woff2 +0 -0
  98. package/dist/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  99. package/dist/fonts/lora-LICENSE.txt +93 -0
  100. package/dist/fonts/lora-latin-ext-wght-italic.woff2 +0 -0
  101. package/dist/fonts/lora-latin-ext-wght-normal.woff2 +0 -0
  102. package/dist/fonts/lora-latin-wght-italic.woff2 +0 -0
  103. package/dist/fonts/lora-latin-wght-normal.woff2 +0 -0
  104. package/dist/fonts/manifest.json +618 -0
  105. package/dist/fonts/manrope-LICENSE.txt +93 -0
  106. package/dist/fonts/manrope-latin-ext-wght-normal.woff2 +0 -0
  107. package/dist/fonts/manrope-latin-wght-normal.woff2 +0 -0
  108. package/dist/fonts/merriweather-LICENSE.txt +93 -0
  109. package/dist/fonts/merriweather-latin-ext-wght-italic.woff2 +0 -0
  110. package/dist/fonts/merriweather-latin-ext-wght-normal.woff2 +0 -0
  111. package/dist/fonts/merriweather-latin-wght-italic.woff2 +0 -0
  112. package/dist/fonts/merriweather-latin-wght-normal.woff2 +0 -0
  113. package/dist/fonts/montserrat-LICENSE.txt +93 -0
  114. package/dist/fonts/montserrat-latin-ext-wght-italic.woff2 +0 -0
  115. package/dist/fonts/montserrat-latin-ext-wght-normal.woff2 +0 -0
  116. package/dist/fonts/montserrat-latin-wght-italic.woff2 +0 -0
  117. package/dist/fonts/montserrat-latin-wght-normal.woff2 +0 -0
  118. package/dist/fonts/playfair-display-LICENSE.txt +93 -0
  119. package/dist/fonts/playfair-display-latin-ext-wght-italic.woff2 +0 -0
  120. package/dist/fonts/playfair-display-latin-ext-wght-normal.woff2 +0 -0
  121. package/dist/fonts/playfair-display-latin-wght-italic.woff2 +0 -0
  122. package/dist/fonts/playfair-display-latin-wght-normal.woff2 +0 -0
  123. package/dist/fonts/roboto-LICENSE.txt +93 -0
  124. package/dist/fonts/roboto-latin-ext-wght-italic.woff2 +0 -0
  125. package/dist/fonts/roboto-latin-ext-wght-normal.woff2 +0 -0
  126. package/dist/fonts/roboto-latin-wght-italic.woff2 +0 -0
  127. package/dist/fonts/roboto-latin-wght-normal.woff2 +0 -0
  128. package/dist/fonts/source-sans-3-LICENSE.txt +93 -0
  129. package/dist/fonts/source-sans-3-latin-ext-wght-italic.woff2 +0 -0
  130. package/dist/fonts/source-sans-3-latin-ext-wght-normal.woff2 +0 -0
  131. package/dist/fonts/source-sans-3-latin-wght-italic.woff2 +0 -0
  132. package/dist/fonts/source-sans-3-latin-wght-normal.woff2 +0 -0
  133. package/dist/heatmap.css +1 -0
  134. package/dist/heatmap.d.ts +3 -0
  135. package/dist/heatmap.js +1 -0
  136. package/dist/index.css +1 -0
  137. package/dist/index.d.ts +11 -0
  138. package/dist/index.js +1 -0
  139. package/dist/legend-export.d.ts +9 -0
  140. package/dist/legend-model.d.ts +39 -0
  141. package/dist/legend.d.ts +19 -0
  142. package/dist/limits.d.ts +16 -0
  143. package/dist/line.css +1 -0
  144. package/dist/line.d.ts +5 -0
  145. package/dist/line.js +1 -0
  146. package/dist/loading-state.d.ts +6 -0
  147. package/dist/map-assets.d.ts +4 -0
  148. package/dist/map-resources.d.ts +24 -0
  149. package/dist/messages.d.ts +3 -0
  150. package/dist/model.d.ts +41 -0
  151. package/dist/motion/bar.css +1 -0
  152. package/dist/motion/bar.d.ts +8 -0
  153. package/dist/motion/bar.js +1 -0
  154. package/dist/motion/doughnut.css +1 -0
  155. package/dist/motion/doughnut.d.ts +8 -0
  156. package/dist/motion/doughnut.js +1 -0
  157. package/dist/motion/heatmap.css +1 -0
  158. package/dist/motion/heatmap.d.ts +7 -0
  159. package/dist/motion/heatmap.js +1 -0
  160. package/dist/motion/line.css +1 -0
  161. package/dist/motion/line.d.ts +8 -0
  162. package/dist/motion/line.js +1 -0
  163. package/dist/motion/pie.css +1 -0
  164. package/dist/motion/pie.d.ts +8 -0
  165. package/dist/motion/pie.js +1 -0
  166. package/dist/motion/radar.css +1 -0
  167. package/dist/motion/radar.d.ts +8 -0
  168. package/dist/motion/radar.js +1 -0
  169. package/dist/motion/scatter.css +1 -0
  170. package/dist/motion/scatter.d.ts +8 -0
  171. package/dist/motion/scatter.js +1 -0
  172. package/dist/motion/sparkline.d.ts +7 -0
  173. package/dist/motion/sparkline.js +1 -0
  174. package/dist/motion-collection.d.ts +7 -0
  175. package/dist/motion-community.d.ts +40 -0
  176. package/dist/motion-policy.d.ts +14 -0
  177. package/dist/motion.css +1 -0
  178. package/dist/motion.d.ts +40 -0
  179. package/dist/motion.js +1 -0
  180. package/dist/options.d.ts +11 -0
  181. package/dist/pie.css +1 -0
  182. package/dist/pie.d.ts +5 -0
  183. package/dist/pie.js +1 -0
  184. package/dist/radar.css +1 -0
  185. package/dist/radar.d.ts +4 -0
  186. package/dist/radar.js +1 -0
  187. package/dist/runtime.d.ts +4 -0
  188. package/dist/scatter.css +1 -0
  189. package/dist/scatter.d.ts +5 -0
  190. package/dist/scatter.js +1 -0
  191. package/dist/sparkline-runtime/runtime.d.ts +10 -0
  192. package/dist/sparkline-validation.d.ts +3 -0
  193. package/dist/sparkline.css +25 -0
  194. package/dist/sparkline.d.ts +44 -0
  195. package/dist/sparkline.js +1 -0
  196. package/dist/state-announcement.d.ts +6 -0
  197. package/dist/styles.css +496 -0
  198. package/dist/theme.css +1391 -0
  199. package/dist/types.d.ts +137 -0
  200. package/dist/validation.d.ts +8 -0
  201. package/dist/web-api.json +4305 -0
  202. package/dist/web-motion-sparkline.d.ts +4 -0
  203. package/edition.json +48 -0
  204. package/package.json +103 -4
  205. package/src/accessibility.ts +81 -0
  206. package/src/assets.d.ts +1 -0
  207. package/src/async.ts +12 -0
  208. package/src/bar.ts +13 -0
  209. package/src/builders/bar.ts +39 -0
  210. package/src/builders/doughnut.ts +66 -0
  211. package/src/builders/heatmap.ts +60 -0
  212. package/src/builders/line.ts +39 -0
  213. package/src/builders/pie.ts +66 -0
  214. package/src/builders/radar.ts +65 -0
  215. package/src/builders/scatter.ts +34 -0
  216. package/src/catalogue-model.ts +235 -0
  217. package/src/catalogue-runtime.ts +244 -0
  218. package/src/catalogue-types.ts +163 -0
  219. package/src/chart-state.ts +35 -0
  220. package/src/charts.css +135 -0
  221. package/src/colors.ts +277 -0
  222. package/src/community.ts +11 -0
  223. package/src/doughnut.ts +12 -0
  224. package/src/export.ts +50 -0
  225. package/src/heatmap.ts +11 -0
  226. package/src/index.ts +11 -0
  227. package/src/legend-export.ts +36 -0
  228. package/src/legend-model.ts +76 -0
  229. package/src/legend.css +64 -0
  230. package/src/legend.ts +90 -0
  231. package/src/limits.ts +7 -0
  232. package/src/line.ts +13 -0
  233. package/src/loading-state.ts +39 -0
  234. package/src/map-assets.ts +1 -0
  235. package/src/map-resources.ts +46 -0
  236. package/src/messages.ts +5 -0
  237. package/src/model.ts +32 -0
  238. package/src/motion/bar.ts +15 -0
  239. package/src/motion/doughnut.ts +14 -0
  240. package/src/motion/heatmap.ts +14 -0
  241. package/src/motion/line.ts +15 -0
  242. package/src/motion/pie.ts +14 -0
  243. package/src/motion/radar.ts +15 -0
  244. package/src/motion/scatter.ts +15 -0
  245. package/src/motion/sparkline.ts +10 -0
  246. package/src/motion-collection.ts +20 -0
  247. package/src/motion-community.ts +22 -0
  248. package/src/motion-policy.ts +36 -0
  249. package/src/options.ts +24 -0
  250. package/src/pie.ts +12 -0
  251. package/src/radar.ts +12 -0
  252. package/src/runtime.ts +7 -0
  253. package/src/scatter.ts +13 -0
  254. package/src/sparkline-runtime/runtime.d.ts +10 -0
  255. package/src/sparkline-runtime/runtime.js +263 -0
  256. package/src/sparkline-validation.ts +19 -0
  257. package/src/sparkline.css +25 -0
  258. package/src/sparkline.ts +68 -0
  259. package/src/state-announcement.ts +17 -0
  260. package/src/types.ts +104 -0
  261. package/src/validation.ts +75 -0
  262. package/src/web-motion-sparkline.ts +37 -0
@@ -0,0 +1,65 @@
1
+ import { format, type EChartsCoreOption } from 'echarts/core';
2
+ import type { CatalogueKind, CatalogueOptions, CartesianOptions, RadarOptions, CircularOptions, ScatterOptions, TreemapOptions, SankeyOptions, FunnelOptions, GeoMapOptions, CalendarHeatmapOptions, ChartPoint, TreeNode } from '../catalogue-types.ts';
3
+ import { cataloguePoints, treeValue } from '../catalogue-model.ts';
4
+ import { resolveChartColor, contrastText, interpolateColor } from '../colors.ts';
5
+ import { formatValue } from '../model.ts';
6
+ import { escapeText } from '../validation.ts';
7
+ import { markColor, markStyle, highlightStyle, sequentialScale, seriesSymbol, seriesDash, seriesDecal, boundedLabels } from '../accessibility.ts';
8
+ import type { themeTokens } from '../options.ts';
9
+ type Tokens = ReturnType<typeof themeTokens>;
10
+ export function buildOption(options: CatalogueOptions, t: Tokens, mapName?: string, reducedMotion = false, width = 640): EChartsCoreOption {
11
+ const kind = 'radar' as CatalogueKind;
12
+ const o = options as any, points = cataloguePoints(kind, options);
13
+ const decal = (index: number, fill: string) => o.ShowPatterns ? seriesDecal(index, fill) : undefined;
14
+ const colors = t.palette;
15
+ const color = (c: any, i: number) => markColor(t.forcedColors ? t.text : resolveChartColor(c, colors[i % colors.length], t.dark, t.read), t.surface, kind === 'line' || kind === 'radar' || kind === 'polar' && o.PolarType === 'Line' ? 4.5 : 3.2);
16
+ const style = (key: string, c: any, i: number) => markStyle(color(c, i), t.surface, o.SelectedKey === key);
17
+ const point = (key: string, seriesKey?: string) => points.find(p => p.Key === key && (!seriesKey || p.SeriesKey === seriesKey));
18
+ const namesForLegend: string[] = ['line','radar','bar','polar'].includes(kind) ? o.Series.map((s: any) => s.Label) : kind === 'scatter' ? [...new Set<string>(o.Points.map((p: any) => p.Group || 'Observations'))] : ['pie','doughnut','nightingale','funnel'].includes(kind) ? [...new Set<string>(o.Items.map((i: any) => i.Label))] : [];
19
+ const legend = { data: namesForLegend, show: o.ShowLegend, type: 'scroll', top: 6, left: 10, right: 10, itemWidth: 24, itemHeight: 12, selectedMode: false, pageTextStyle: { color: t.muted }, pageIconColor: t.text, textStyle: { color: t.muted, fontSize: 11 } };
20
+ const label = { show: o.ShowLabels, color: contrastText(t.surface), fontSize: 11, backgroundColor: t.surface, padding: [2, 3], borderRadius: 2 };
21
+ const tooltip = { show: o.ShowTooltip, confine: true, enterable: true, backgroundColor: t.surface, borderColor: t.border, textStyle: { color: t.text, fontSize: 12 }, formatter: (p: any) => {
22
+ const d = p.data?.point as ChartPoint | undefined;
23
+ if (!d)
24
+ return `${escapeText(p.name || '')}${p.value == null || Number.isNaN(p.value) ? ' · No data' : ''}`;
25
+ const extra = d.Date ? `<br/>${escapeText(d.Date)}<br/><strong>${escapeText(formatValue(d.Value, o))}</strong>` : d.X !== undefined ? `<br/>${escapeText(o.XLabel || 'X')}: ${d.X}<br/>${escapeText(o.YLabel || 'Y')}: ${d.Y}${d.Size === undefined ? '' : `<br/>Size: ${d.Size}`}` : `<br/><strong>${escapeText(formatValue(d.Value, o))}</strong>`;
26
+ return `${escapeText(d.SeriesName ? `${d.SeriesName} · ${d.Label}` : d.Label)}${extra}${p.data?.conversion ? `<br/>${escapeText(p.data.conversion)}` : ''}`;
27
+ } };
28
+ const base = { backgroundColor: t.surface, color: colors.map((c, i) => color(c, i)), animation: !!o.EnableAnimation && !reducedMotion, animationDuration: 300, animationDurationUpdate: 150, textStyle: { fontFamily: 'Inter,system-ui,sans-serif', color: t.text }, aria: { enabled: true, description: `${o.Title}. ${o.Description || ''} Exact values and keyboard selection are in the data table.` }, tooltip };
29
+ const axis = { axisLine: { show: false }, axisTick: { show: false }, axisLabel: { color: t.muted, fontSize: 10, hideOverlap: true }, splitLine: { lineStyle: { color: t.border, type: 'dashed' } } };
30
+
31
+ const c = options as RadarOptions;
32
+ const top = o.ShowLegend ? 48 : 22, height = o.Height || 320;
33
+ const centerY = top + (height - top - 38) / 2;
34
+ const radius = Math.max(12, Math.min(width * .24, (height - top - 64) / 2));
35
+ const maxima = c.Categories.map((cat, i) => {
36
+ const maximum = Math.max(1, ...c.Series.map(s => s.Values[i] ?? 0));
37
+ const unit = 10 ** Math.floor(Math.log10(maximum));
38
+ return cat.Max ?? Math.ceil(maximum / unit) * unit;
39
+ });
40
+ return { ...base, legend,
41
+ tooltip: { ...tooltip, formatter: (p: any) => {
42
+ const s = c.Series.find(s => s.Key === p.data?.point?.SeriesKey);
43
+ return s ? `<strong>${escapeText(s.Label)}</strong><br/>${c.Categories.map((cat, i) => `${escapeText(cat.Label)}: ${escapeText(formatValue(s.Values[i], o))}`).join('<br/>')}` : '';
44
+ } },
45
+ radar: {
46
+ center: ['50%', centerY], radius, shape: c.GridShape === 'Circle' ? 'circle' : 'polygon', splitNumber: c.SplitNumber ?? 4,
47
+ indicator: c.Categories.map((cat, i) => ({ name: format.truncateText(`${cat.Label}${c.ShowAxisBounds !== false ? `\n0–${formatValue(maxima[i], o, true)}` : ''}`, Math.max(28, Math.min(100, width * .22)), '11px Inter,system-ui', '…'), min: 0, max: maxima[i] })),
48
+ axisName: { color: t.text, fontSize: 11, lineHeight: 15, width: Math.max(28, Math.min(100, width * .22)), overflow: 'truncate' }, axisNameGap: 7,
49
+ axisLine: { lineStyle: { color: t.border, width: 1 } }, splitLine: { lineStyle: { color: t.border, width: 1 } }, splitArea: { show: false },
50
+ },
51
+ series: c.Series.map((s, si) => {
52
+ const fill = color(s.Color, si), selected = o.SelectedKey === s.Key;
53
+ return { id: s.Key, name: s.Label, type: 'radar', symbol: seriesSymbol(si), symbolSize: selected ? 9 : 7,
54
+ lineStyle: { color: fill, width: selected ? 3 : 2.5, type: seriesDash(si) },
55
+ itemStyle: { ...markStyle(fill, t.surface, selected), decal: c.ShowArea && o.ShowPatterns ? seriesDecal(si, fill) : undefined },
56
+ areaStyle: c.ShowArea ? { color: fill, opacity: .08 } : undefined,
57
+ label: { ...label, position: 'top', formatter: (p: { value: number }) => formatValue(p.value, o, true) }, labelLayout: boundedLabels(width, height, o.ShowLegend ? 38 : 8),
58
+ emphasis: { focus: 'none', itemStyle: highlightStyle(fill), lineStyle: { width: 3 } }, blur: { lineStyle: { opacity: 1 }, itemStyle: { opacity: 1 } },
59
+ // ECharts maps missing radar values to its center. Omit incomplete polygons rather than imply zero.
60
+ data: s.Values.some(v => v === null) || !s.Values.length ? [] : [{ name: s.Label, value: s.Values, point: { Key: s.Key, Label: s.Label, Value: null, SeriesKey: s.Key, SeriesName: s.Label, PointType: 'Series' } }],
61
+ };
62
+ }),
63
+ };
64
+
65
+ }
@@ -0,0 +1,34 @@
1
+ import { format, type EChartsCoreOption } from 'echarts/core';
2
+ import type { CatalogueKind, CatalogueOptions, CartesianOptions, RadarOptions, CircularOptions, ScatterOptions, TreemapOptions, SankeyOptions, FunnelOptions, GeoMapOptions, CalendarHeatmapOptions, ChartPoint, TreeNode } from '../catalogue-types.ts';
3
+ import { cataloguePoints, treeValue } from '../catalogue-model.ts';
4
+ import { resolveChartColor, contrastText, interpolateColor } from '../colors.ts';
5
+ import { formatValue } from '../model.ts';
6
+ import { escapeText } from '../validation.ts';
7
+ import { markColor, markStyle, highlightStyle, sequentialScale, seriesSymbol, seriesDash, seriesDecal, boundedLabels } from '../accessibility.ts';
8
+ import type { themeTokens } from '../options.ts';
9
+ type Tokens = ReturnType<typeof themeTokens>;
10
+ export function buildOption(options: CatalogueOptions, t: Tokens, mapName?: string, reducedMotion = false, width = 640): EChartsCoreOption {
11
+ const kind = 'scatter' as CatalogueKind;
12
+ const o = options as any, points = cataloguePoints(kind, options);
13
+ const decal = (index: number, fill: string) => o.ShowPatterns ? seriesDecal(index, fill) : undefined;
14
+ const colors = t.palette;
15
+ const color = (c: any, i: number) => markColor(t.forcedColors ? t.text : resolveChartColor(c, colors[i % colors.length], t.dark, t.read), t.surface, kind === 'line' || kind === 'radar' || kind === 'polar' && o.PolarType === 'Line' ? 4.5 : 3.2);
16
+ const style = (key: string, c: any, i: number) => markStyle(color(c, i), t.surface, o.SelectedKey === key);
17
+ const point = (key: string, seriesKey?: string) => points.find(p => p.Key === key && (!seriesKey || p.SeriesKey === seriesKey));
18
+ const namesForLegend: string[] = ['line','radar','bar','polar'].includes(kind) ? o.Series.map((s: any) => s.Label) : kind === 'scatter' ? [...new Set<string>(o.Points.map((p: any) => p.Group || 'Observations'))] : ['pie','doughnut','nightingale','funnel'].includes(kind) ? [...new Set<string>(o.Items.map((i: any) => i.Label))] : [];
19
+ const legend = { data: namesForLegend, show: o.ShowLegend, type: 'scroll', top: 6, left: 10, right: 10, itemWidth: 24, itemHeight: 12, selectedMode: false, pageTextStyle: { color: t.muted }, pageIconColor: t.text, textStyle: { color: t.muted, fontSize: 11 } };
20
+ const label = { show: o.ShowLabels, color: contrastText(t.surface), fontSize: 11, backgroundColor: t.surface, padding: [2, 3], borderRadius: 2 };
21
+ const tooltip = { show: o.ShowTooltip, confine: true, enterable: true, backgroundColor: t.surface, borderColor: t.border, textStyle: { color: t.text, fontSize: 12 }, formatter: (p: any) => {
22
+ const d = p.data?.point as ChartPoint | undefined;
23
+ if (!d)
24
+ return `${escapeText(p.name || '')}${p.value == null || Number.isNaN(p.value) ? ' · No data' : ''}`;
25
+ const extra = d.Date ? `<br/>${escapeText(d.Date)}<br/><strong>${escapeText(formatValue(d.Value, o))}</strong>` : d.X !== undefined ? `<br/>${escapeText(o.XLabel || 'X')}: ${d.X}<br/>${escapeText(o.YLabel || 'Y')}: ${d.Y}${d.Size === undefined ? '' : `<br/>Size: ${d.Size}`}` : `<br/><strong>${escapeText(formatValue(d.Value, o))}</strong>`;
26
+ return `${escapeText(d.SeriesName ? `${d.SeriesName} · ${d.Label}` : d.Label)}${extra}${p.data?.conversion ? `<br/>${escapeText(p.data.conversion)}` : ''}`;
27
+ } };
28
+ const base = { backgroundColor: t.surface, color: colors.map((c, i) => color(c, i)), animation: !!o.EnableAnimation && !reducedMotion, animationDuration: 300, animationDurationUpdate: 150, textStyle: { fontFamily: 'Inter,system-ui,sans-serif', color: t.text }, aria: { enabled: true, description: `${o.Title}. ${o.Description || ''} Exact values and keyboard selection are in the data table.` }, tooltip };
29
+ const axis = { axisLine: { show: false }, axisTick: { show: false }, axisLabel: { color: t.muted, fontSize: 10, hideOverlap: true }, splitLine: { lineStyle: { color: t.border, type: 'dashed' } } };
30
+
31
+ const c = options as ScatterOptions, groups = [...new Set(c.Points.map(p => p.Group || 'Observations'))], max = Math.max(1, ...c.Points.map(p => p.Size || 0));
32
+ return { ...base, legend, grid: { left: 20, right: 30, top: o.ShowLegend ? 50 : 30, bottom: 36, containLabel: true }, xAxis: { ...axis, type: 'value', name: c.XLabel || 'X', nameLocation: 'middle', nameGap: 30 }, yAxis: { ...axis, type: 'value', name: c.YLabel || 'Y' }, series: groups.map((g, gi) => ({ type: 'scatter', name: g, symbol: seriesSymbol(gi), itemStyle: { color: color(c.Points.find(p => (p.Group || 'Observations') === g)?.Color, gi) }, label: { ...label, width: Math.min(140, width * .25), overflow: 'truncate', position: 'top', formatter: '{b}' }, labelLayout: boundedLabels(width, o.Height || 320, o.ShowLegend ? 38 : 8), symbolSize: (_: any, p: any) => c.Bubble ? 8 + 36 * Math.sqrt((p.data.point.Size ?? 1) / max) : 12, emphasis: { focus: 'none', scale: false }, blur: { itemStyle: { opacity: 1 }, lineStyle: { opacity: 1 }, label: { opacity: 1 } }, data: c.Points.filter(p => (p.Group || 'Observations') === g).map(p => ({ name: p.Label, value: [p.X, p.Y], point: point(p.Key), itemStyle: style(p.Key, p.Color, gi), emphasis: { itemStyle: highlightStyle(color(p.Color, gi)) } })) })) };
33
+
34
+ }
@@ -0,0 +1,235 @@
1
+ import type { CatalogueKind, CatalogueOptions, CartesianOptions, RadarOptions, CircularOptions, ScatterOptions, TreemapOptions, SankeyOptions, FunnelOptions, GeoMapOptions, CalendarHeatmapOptions, GeoJSON, ChartPoint, TreeNode } from './catalogue-types.ts';
2
+ import { assert, base, categories, value } from './validation.ts';
3
+ import { isChartColor } from './colors.ts';
4
+ import { boundedList, boundedText, safeNumber, limits } from './limits.ts';
5
+ export const dataFields: Record<CatalogueKind, string[]> = {
6
+ 'calendar-heatmap': ['Items'],
7
+ radar: ['Categories', 'Series'], line: ['Categories', 'Series'], bar: ['Categories', 'Series'], polar: ['Categories', 'Series'],
8
+ pie: ['Items', 'Rings'], doughnut: ['Items', 'Rings'], nightingale: ['Items', 'Rings'],
9
+ scatter: ['Points'], treemap: ['Nodes'], sankey: ['Nodes', 'Links'], funnel: ['Items'], 'geo-map': ['Regions', 'Locations', 'GeoJSON'],
10
+ };
11
+ export function chartData(kind: CatalogueKind, o: CatalogueOptions): Record<string, unknown> {
12
+ return Object.fromEntries(dataFields[kind].filter(k => (o as any)[k] !== undefined).map(k => [k, (o as any)[k]]));
13
+ }
14
+ export function parseChartData(kind: CatalogueKind, json: string, o: CatalogueOptions): CatalogueOptions {
15
+ let data: any;
16
+ try {
17
+ data = JSON.parse(json);
18
+ }
19
+ catch {
20
+ throw new Error('Enter valid JSON before applying the data.');
21
+ }
22
+ assert(data && typeof data === 'object' && !Array.isArray(data), 'Input must be a JSON object.');
23
+ const next: any = { ...o, SelectedKey: null, IsLoading: false };
24
+ dataFields[kind].forEach(k => { if (data[k] === undefined)
25
+ delete next[k];
26
+ else
27
+ next[k] = data[k]; });
28
+ validateCatalogue(kind, next);
29
+ return next;
30
+ }
31
+ function list(items: any[], label: string, labelField = 'Label') {
32
+ boundedList(items, label);
33
+ const keys = new Set<string>();
34
+ for (const item of items) {
35
+ assert(item && boundedText(item.Key) && item.Key.length && !keys.has(item.Key), `${label} require unique, non-empty keys.`);
36
+ assert(!labelField || boundedText(item[labelField]), `${item.Key}: ${labelField} must be text within the text limit.`);
37
+ assert(item.Color === undefined || isChartColor(item.Color), `${item.Key}: Color must be a shared ComponentColor or hex color.`);
38
+ keys.add(item.Key);
39
+ }
40
+ return keys;
41
+ }
42
+ const finite = safeNumber;
43
+ function nonnegative(n: unknown) { return n === null || (finite(n) && n >= 0); }
44
+ function choice(o: any, key: string, choices: string[]) { assert(o[key] === undefined || choices.includes(o[key]), `Invalid ${key}. Choose ${choices.join(', ')}.`); }
45
+ export function validateGeoJSON(geo: GeoJSON) {
46
+ assert(geo?.type === 'FeatureCollection' && Array.isArray(geo.features) && geo.features.length > 0, 'GeoJSON must be a non-empty FeatureCollection.');
47
+ boundedList(geo.features, 'Map features', limits.mapFeatures);
48
+ let coordinateCount = 0;
49
+ const names = new Set<string>();
50
+ geo.features.forEach(f => {
51
+ assert(f?.type === 'Feature' && typeof f.properties?.name === 'string' && f.properties.name.length && !names.has(f.properties.name), 'Each map feature needs a unique properties.name region key.');
52
+ names.add(f.properties.name);
53
+ assert(['Polygon', 'MultiPolygon'].includes(f.geometry?.type), 'Map geometry must be Polygon or MultiPolygon.');
54
+ const polygons = f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates;
55
+ assert(Array.isArray(polygons) && polygons.length > 0, 'Map polygons must contain coordinates.');
56
+ for (const polygon of polygons) {
57
+ assert(Array.isArray(polygon) && polygon.length > 0, 'Each polygon needs rings.');
58
+ for (const ring of polygon as number[][][]) {
59
+ assert(Array.isArray(ring) && ring.length >= 4, 'Each polygon ring needs at least four coordinates.');
60
+ coordinateCount += ring.length;
61
+ assert(coordinateCount <= limits.mapCoordinates, 'Map has too many coordinates. Use a simplified boundary file.');
62
+ ring.forEach(p => assert(Array.isArray(p) && p.length >= 2 && finite(p[0]) && finite(p[1]) && Math.abs(p[0]) <= 180 && Math.abs(p[1]) <= 90, 'Map coordinates must be longitude/latitude in degrees.'));
63
+ assert(ring[0][0] === ring.at(-1)![0] && ring[0][1] === ring.at(-1)![1], 'Polygon rings must close.');
64
+ }
65
+ }
66
+ });
67
+ }
68
+ export function isISODate(value: unknown): value is string {
69
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value + 'T00:00:00Z')) && new Date(value + 'T00:00:00Z').toISOString().slice(0, 10) === value;
70
+ }
71
+ export function validateCatalogue(kind: CatalogueKind, o: CatalogueOptions) {
72
+ base(o);
73
+ if ('Series' in o) {
74
+ boundedList(o.Series, 'Series'); boundedList(o.Categories, 'Categories');
75
+ assert(o.Series.length * o.Categories.length <= limits.observations, 'Too many chart observations. Aggregate the data first.');
76
+ }
77
+ for (const key of ['ShowArea', 'Nested', 'Bubble', 'Drilldown', 'ShowConversion', 'Roam'])
78
+ assert((o as any)[key] === undefined || typeof (o as any)[key] === 'boolean', `${key} must be a boolean.`);
79
+ if (kind === 'radar') {
80
+ const c = o as RadarOptions;
81
+ categories(c.Categories, 'Categories');
82
+ assert(c.Categories.length === 0 || (c.Categories.length >= 3 && c.Categories.length <= 12), 'Radar needs 3–12 categories, or an empty array for an empty state.');
83
+ list(c.Series, 'Series');
84
+ c.Categories.forEach(cat => assert(cat.Max === undefined || finite(cat.Max) && cat.Max > 0, `${cat.Key}: Max must be a positive number.`));
85
+ c.Series.forEach(s => {
86
+ assert(Array.isArray(s.Values) && s.Values.length === c.Categories.length && s.Values.every(nonnegative), `${s.Key}: Values must match categories and contain non-negative numbers or null.`);
87
+ s.Values.forEach((v, i) => assert(v === null || c.Categories[i].Max === undefined || v <= c.Categories[i].Max!, `${s.Key}: Value exceeds ${c.Categories[i].Label} Max.`));
88
+ });
89
+ choice(c, 'GridShape', ['Polygon', 'Circle']);
90
+ assert(c.SplitNumber === undefined || Number.isInteger(c.SplitNumber) && c.SplitNumber >= 2 && c.SplitNumber <= 8, 'SplitNumber must be an integer from 2 to 8.');
91
+ assert(c.ShowAxisBounds === undefined || typeof c.ShowAxisBounds === 'boolean', 'ShowAxisBounds must be Boolean.');
92
+ }
93
+ else if (['line', 'bar', 'polar'].includes(kind)) {
94
+ const c = o as CartesianOptions;
95
+ categories(c.Categories, 'Categories');
96
+ list(c.Series, 'Series');
97
+ c.Series.forEach(s => { assert(Array.isArray(s.Values) && s.Values.length === c.Categories.length && s.Values.every(value), `${s.Key}: Values must match categories and contain finite numbers or null.`); if (c.Stacking === 'Percent')
98
+ assert(s.Values.every(v => v === null || v >= 0), 'Percentage stacks require non-negative values.'); });
99
+ choice(c, 'Orientation', ['Vertical', 'Horizontal']);
100
+ choice(c, 'Stacking', ['None', 'Stacked', 'Percent']);
101
+ choice(c, 'LineStyle', ['Straight', 'Smooth', 'Step']);
102
+ choice(c, 'PolarType', ['Bar', 'Line']);
103
+ }
104
+ else if (['pie', 'doughnut', 'nightingale', 'funnel'].includes(kind)) {
105
+ const c = o as CircularOptions;
106
+ list(c.Items, 'Items');
107
+ c.Items.forEach(i => assert(nonnegative(i.Value), `${i.Key}: Value must be non-negative or null.`));
108
+ if (kind !== 'funnel') {
109
+ const rings = list(c.Rings || [], 'Rings');
110
+ c.Items.forEach(i => assert(!i.RingKey || rings.has(i.RingKey), `${i.Key}: RingKey must match a ring.`));
111
+ if (c.Nested) {
112
+ assert(rings.size > 0, 'Nested charts need Rings.');
113
+ c.Items.forEach(i => assert(i.RingKey && rings.has(i.RingKey), `${i.Key}: nested items need RingKey.`));
114
+ }
115
+ choice(c, 'Shape', ['Full', 'Half']);
116
+ choice(c, 'LabelPosition', ['Outside', 'Inside']);
117
+ choice(c, 'RoseMode', ['Area', 'Radius']);
118
+ assert(c.InnerRadius === undefined || (finite(c.InnerRadius) && c.InnerRadius >= 0 && c.InnerRadius <= 75), 'InnerRadius must be 0–75 percent.');
119
+ assert(c.CenterLabel === undefined || typeof c.CenterLabel === 'string', 'CenterLabel must be text.');
120
+ }
121
+ else
122
+ choice(o, 'Sort', ['Input', 'Descending', 'Ascending']);
123
+ }
124
+ else if (kind === 'calendar-heatmap') {
125
+ const c = o as CalendarHeatmapOptions;
126
+ assert(isISODate(c.RangeStart) && isISODate(c.RangeEnd), 'RangeStart and RangeEnd must be real dates in YYYY-MM-DD format.');
127
+ const span = (Date.parse(c.RangeEnd) - Date.parse(c.RangeStart)) / 86400000;
128
+ assert(span >= 0 && span <= 365, 'Choose a calendar range of 1–366 days.');
129
+ list(c.Items, 'Items');
130
+ const dates = new Set<string>();
131
+ c.Items.forEach(i => {
132
+ assert(isISODate(i.Date), `${i.Key}: Date must be a real YYYY-MM-DD date.`);
133
+ assert(i.Date >= c.RangeStart && i.Date <= c.RangeEnd, `${i.Key}: Date must be within the calendar range.`);
134
+ assert(!dates.has(i.Date), 'Use one item per date. Aggregate observations before passing the data.');
135
+ assert(nonnegative(i.Value), `${i.Key}: Value must be non-negative or null.`);
136
+ dates.add(i.Date);
137
+ });
138
+ choice(c, 'FirstDay', ['Monday', 'Sunday']);
139
+ assert(c.ColorScale === undefined || c.ColorScale === 'Automatic' || isChartColor(c.ColorScale), 'Invalid ColorScale.');
140
+ for (const key of ['ShowMonthLabels', 'ShowDayLabels'] as const)
141
+ assert(c[key] === undefined || typeof c[key] === 'boolean', `${key} must be a boolean.`);
142
+ }
143
+ else if (kind === 'scatter') {
144
+ const c = o as ScatterOptions;
145
+ list(c.Points, 'Points');
146
+ c.Points.forEach(p => { assert(finite(p.X) && finite(p.Y), `${p.Key}: X and Y must be finite numbers.`); assert(p.Size === undefined || (finite(p.Size) && p.Size >= 0), `${p.Key}: Size must be non-negative.`); assert(p.Group === undefined || typeof p.Group === 'string', 'Group must be text.'); });
147
+ for (const key of ['XLabel', 'YLabel'] as const)
148
+ assert(c[key] === undefined || typeof c[key] === 'string', `${key} must be text.`);
149
+ }
150
+ else if (kind === 'treemap') {
151
+ const c = o as TreemapOptions, keys = list(c.Nodes, 'Nodes');
152
+ c.Nodes.forEach(n => { assert(n.ParentKey === null || (typeof n.ParentKey === 'string' && keys.has(n.ParentKey)), `${n.Key}: ParentKey must be null or match a node.`); assert(nonnegative(n.Value), `${n.Key}: Value must be non-negative or null.`); });
153
+ const totals = treeTotals(c.Nodes), parents = new Set(c.Nodes.map(n=>n.ParentKey));
154
+ c.Nodes.forEach(n => { if (parents.has(n.Key) && n.Value !== null)
155
+ assert(totals.get(n.Key)!==null&&Math.abs(totals.get(n.Key)! - n.Value) < .000001, `${n.Key}: branch Value must equal its leaf total, or use null to calculate it.`); });
156
+ }
157
+ else if (kind === 'sankey') {
158
+ const c = o as SankeyOptions, keys = list(c.Nodes, 'Nodes');
159
+ list(c.Links, 'Links', '');
160
+ boundedList(c.Nodes, 'Flow nodes', limits.flowNodes); boundedList(c.Links, 'Flow links', limits.flowLinks);
161
+ const outgoing = new Map(c.Nodes.map(n => [n.Key, [] as string[]]));
162
+ c.Links.forEach(l => { assert(keys.has(l.SourceKey) && keys.has(l.TargetKey), `${l.Key}: link endpoints must match node keys.`); assert(finite(l.Value) && l.Value >= 0, `${l.Key}: Value must be non-negative.`); outgoing.get(l.SourceKey)!.push(l.TargetKey); });
163
+ const inbound=new Map<string,number>(),outbound=new Map<string,number>();
164
+ for(const l of c.Links){inbound.set(l.TargetKey,(inbound.get(l.TargetKey)||0)+l.Value);outbound.set(l.SourceKey,(outbound.get(l.SourceKey)||0)+l.Value);}
165
+ assert([...inbound.values(),...outbound.values()].every(safeNumber),'Flow totals exceed the supported numeric range.');
166
+ const degrees = new Map(c.Nodes.map(n=>[n.Key,0]));
167
+ c.Links.forEach(l=>degrees.set(l.TargetKey,degrees.get(l.TargetKey)!+1));
168
+ const queue = [...degrees].filter(([,n])=>!n).map(([k])=>k);
169
+ for(let i=0;i<queue.length;i++) for(const target of outgoing.get(queue[i])!) { const left=degrees.get(target)!-1; degrees.set(target,left); if(!left) queue.push(target); }
170
+ assert(queue.length === keys.size, 'Sankey links cannot contain cycles.');
171
+ choice(c, 'Orientation', ['Horizontal', 'Vertical']);
172
+ choice(c, 'NodeAlign', ['Justify', 'Left', 'Right']);
173
+ assert(c.Links.every(l => !keys.has(l.Key)), 'Link keys must be distinct from node keys.');
174
+ }
175
+ else {
176
+ const c = o as GeoMapOptions;
177
+ list(c.Regions, 'Regions');
178
+ list(c.Locations, 'Locations');
179
+ c.Regions.forEach(r => assert(value(r.Value), `${r.Key}: Value must be finite or null.`));
180
+ c.Locations.forEach(p => { assert(finite(p.Longitude) && Math.abs(p.Longitude) <= 180 && finite(p.Latitude) && Math.abs(p.Latitude) <= 90, `${p.Key}: invalid longitude/latitude.`); assert(finite(p.Value) && p.Value >= 0, `${p.Key}: Value must be non-negative.`); });
181
+ assert(c.Locations.every(p => !c.Regions.some(r => r.Key === p.Key)), 'Location and region keys must be distinct.');
182
+ choice(c, 'Map', ['World', 'US', 'Custom']);
183
+ choice(c, 'MapView', ['Mainland', 'Full']);
184
+ assert(c.MapURL === undefined || boundedText(c.MapURL) && !!c.MapURL, 'MapURL must be non-empty text.');
185
+ assert(!c.MapURL || !c.GeoJSON, 'Supply either MapURL or GeoJSON, not both.');
186
+ assert(!c.MapURL || c.Map === 'Custom', 'MapURL requires Map Custom.');
187
+ choice(c, 'MapMode', ['Regions', 'Locations', 'Combined']);
188
+ assert(c.ColorScale === undefined || c.ColorScale === 'Automatic' || isChartColor(c.ColorScale), 'Invalid ColorScale.');
189
+ if (c.GeoJSON !== undefined)
190
+ validateGeoJSON(c.GeoJSON);
191
+ if (c.Map === 'Custom') {
192
+ assert(c.GeoJSON || c.MapURL, 'Custom maps need GeoJSON or MapURL.');
193
+ if(c.GeoJSON) validateMapRegions(c, c.GeoJSON);
194
+ }
195
+ }
196
+ assert(o.SelectedKey == null || cataloguePoints(kind, o).some(p => p.Key === o.SelectedKey), 'SelectedKey must match an input key.');
197
+ }
198
+ export function validateMapRegions(o: GeoMapOptions, geo: GeoJSON) { const keys = new Set(geo.features.map(f => f.properties.name)); o.Regions.forEach(r => assert(keys.has(r.Key), `Region ${r.Key} does not exist in this map. Use its properties.name key.`)); }
199
+ export function treeTotals(nodes: TreeNode[]): Map<string,number|null> {
200
+ boundedList(nodes, 'Tree nodes', limits.treeNodes);
201
+ const children = new Map<string|null,TreeNode[]>(), totals = new Map<string,number|null>();
202
+ for(const n of nodes) { const list=children.get(n.ParentKey)||[]; list.push(n); children.set(n.ParentKey,list); }
203
+ const queue = (children.get(null)||[]).map(n=>({n,depth:1}));
204
+ for(let i=0;i<queue.length;i++) { const {n,depth}=queue[i]; assert(depth<=limits.treeDepth,'Tree exceeds maximum depth.'); for(const child of children.get(n.Key)||[]) queue.push({n:child,depth:depth+1}); assert(queue.length<=nodes.length,'Tree nodes cannot contain cycles.'); }
205
+ assert(queue.length===nodes.length,'Tree nodes cannot contain cycles or disconnected parents.');
206
+ for(let i=queue.length-1;i>=0;i--) { const n=queue[i].n,kids=children.get(n.Key)||[],known=kids.map(c=>totals.get(c.Key)).filter((v):v is number=>v!=null); const total=kids.length?(known.length?known.reduce((sum,v)=>sum+v,0):null):n.Value; assert(total===null||safeNumber(total),'Tree total exceeds the supported numeric range.'); totals.set(n.Key,total); }
207
+ return totals;
208
+ }
209
+ export function treeValue(nodes: TreeNode[], n: TreeNode): number|null { return treeTotals(nodes).get(n.Key)!; }
210
+ export function cataloguePoints(kind: CatalogueKind, o: CatalogueOptions): ChartPoint[] {
211
+ if (kind === 'radar') {
212
+ const c = o as RadarOptions;
213
+ return c.Series.flatMap(s => c.Categories.map((cat, i) => ({ Key: s.Key, Label: cat.Label, Value: s.Values[i], CategoryKey: cat.Key, SeriesKey: s.Key, SeriesName: s.Label })));
214
+ }
215
+ if (['line', 'bar', 'polar'].includes(kind)) {
216
+ const c = o as CartesianOptions;
217
+ return c.Series.flatMap(s => c.Categories.map((cat, i) => ({ Key: cat.Key, Label: cat.Label, Value: s.Values[i], SeriesKey: s.Key, SeriesName: s.Label })));
218
+ }
219
+ if (['pie', 'doughnut', 'nightingale', 'funnel'].includes(kind))
220
+ return (o as CircularOptions).Items.map(i => ({ Key: i.Key, Label: i.Label, Value: i.Value, RingKey: i.RingKey }));
221
+ if (kind === 'calendar-heatmap')
222
+ return (o as CalendarHeatmapOptions).Items.map(i => ({ ...i }));
223
+ if (kind === 'scatter')
224
+ return (o as ScatterOptions).Points.map(p => ({ ...p, Value: p.Y }));
225
+ if (kind === 'treemap') {
226
+ const c = o as TreemapOptions, totals = treeTotals(c.Nodes);
227
+ return c.Nodes.map(n => ({ Key: n.Key, Label: n.Label, Value: totals.get(n.Key)!, ParentKey: n.ParentKey }));
228
+ }
229
+ if (kind === 'sankey') {
230
+ const c = o as SankeyOptions;
231
+ return [...c.Nodes.map(n => ({ Key: n.Key, Label: n.Label, PointType: 'Node' as const, Value: Math.max(c.Links.filter(l => l.SourceKey === n.Key).reduce((s, l) => s + l.Value, 0), c.Links.filter(l => l.TargetKey === n.Key).reduce((s, l) => s + l.Value, 0)) })), ...c.Links.map(l => ({ Key: l.Key, Label: `${c.Nodes.find(n => n.Key === l.SourceKey)?.Label} → ${c.Nodes.find(n => n.Key === l.TargetKey)?.Label}`, Value: l.Value, SourceKey: l.SourceKey, TargetKey: l.TargetKey, PointType: 'Link' as const }))];
232
+ }
233
+ const c = o as GeoMapOptions;
234
+ return [...c.Regions.map(r => ({ ...r, PointType: 'Region' as const })), ...c.Locations.map(p => ({ ...p, PointType: 'Location' as const }))];
235
+ }
@@ -0,0 +1,244 @@
1
+ import * as echarts from 'echarts/core';
2
+ import {LabelLayout, LegacyGridContainLabel} from 'echarts/features';
3
+ import {TooltipComponent, AriaComponent, GraphicComponent} from 'echarts/components';
4
+ import {SVGRenderer} from 'echarts/renderers';
5
+ import type {ChartEvents, ChartInstance, ChartFormat, MixedChartOptions, HeatmapOptions} from './types.ts';
6
+ import type {CatalogueKind, OptionsByKind, ChartPoint, GeoMapOptions, GeoJSON} from './catalogue-types.ts';
7
+ import {defaults, exactValue} from './model.ts';
8
+ import {escapeText as esc} from './validation.ts';
9
+ import {themeTokens} from './options.ts';
10
+ import {stateAnnouncement} from './state-announcement.ts';
11
+ import {chartLoadingState} from './loading-state.ts';
12
+ import {createChartLegend} from './legend.ts';
13
+ import {exportImage} from './export.ts';
14
+ import {chartPoints, pointIdentity, prepareUpdate, highlightTargets, validateChart, message, type RuntimeKind, type RuntimeOptions} from './chart-state.ts';
15
+ import {resolveMap, mapRegistry} from './map-resources.ts';
16
+ import {validateMapRegions} from './catalogue-model.ts';
17
+ import './charts.css';
18
+
19
+ type Builder=(o:any,t:ReturnType<typeof themeTokens>,mapName?:string,reducedMotion?:boolean,width?:number)=>echarts.EChartsCoreOption;
20
+ /** Internal opt-in web presentation. Ordinary/native factories omit this policy. */
21
+ export interface ChartPresentation {
22
+ /** Native integration can opt in/out without replacing the chart. */
23
+ enabled?(): boolean;
24
+ mode(host: HTMLElement): 'full' | 'reduced' | 'off';
25
+ timing?(): {duration?:number;easing?:string;curve?:string};
26
+ subscribe(host: HTMLElement, refresh: () => void): () => void;
27
+ }
28
+ export function createCatalogueChart<K extends CatalogueKind>(host:HTMLElement,kind:K,initial:OptionsByKind[K],events:ChartEvents<ChartPoint>,build:Builder):ChartInstance<OptionsByKind[K]> {
29
+ return createChartRuntime(host,kind,initial,events,build);
30
+ }
31
+ /** One lifecycle for all ECharts families. Sparkline deliberately stays independent. */
32
+ export function createChartRuntime<O extends RuntimeOptions,P=ChartPoint>(host:HTMLElement,kind:RuntimeKind,initial:O,events:ChartEvents<P>,build:Builder,presentation?:ChartPresentation):ChartInstance<O> {
33
+ let options={...defaults,...structuredClone(initial)} as O, requested=options;
34
+ const emit=(callback:any,...args:any[])=>{if(destroyed)return;try{callback?.(...args);}catch(e){console.error('Chart event handler failed.',e);}};
35
+ let destroyed=false, exporting=false, revision=0, frame=0, focusFrame=0, pending=false, ready=false, lastState='', resourceError='';
36
+ let stopPresentation=()=>{};
37
+ let dataFade:Animation|undefined,dataFadeMode:string|undefined;
38
+ try{validateChart(kind,options);}catch(e){emit(events.Error,{Message:String((e as Error).message),Code:'InvalidData'});throw e;}
39
+ let chart:echarts.EChartsType|undefined,legend:ReturnType<typeof createChartLegend>|undefined,config:any;
40
+ let map:ReturnType<typeof mapRegistry.acquire>|undefined, geometry:GeoJSON|undefined,geometryKey='';
41
+ let mapAbort:AbortController|undefined; const exportAbort=new AbortController();
42
+ let treeRoot:string|undefined,treeAction='treemapRootToNode';
43
+ const root=document.createElement('section');root.className='blu-chart';
44
+ root.innerHTML='<div class="blu-chart__heading"><div><h3></h3><p></p></div><div class="blu-chart__export"><select aria-label="Chart download format"><option value="png">PNG image</option><option value="svg">SVG vector</option></select><button type="button" aria-label="Download chart">↓ <span>Download</span></button></div></div><div class="blu-chart__stage"><div class="blu-chart__plot" role="img"></div><div class="blu-chart__state" aria-hidden="true" hidden></div></div><p class="blu-chart__missing-note" hidden></p><div class="blu-chart__navigation" role="group" aria-label="Chart navigation" hidden></div><details class="blu-chart__data"><summary>View data & keyboard selections</summary><label class="blu-chart__patterns"><input type="checkbox"><span>Use fill patterns</span></label><div class="blu-chart__table" tabindex="0" role="region"></div></details><div class="blu-chart__state-announcement" role="status" aria-live="polite" aria-atomic="true"></div><div class="blu-chart__status" role="status" aria-live="polite"></div>';
45
+ host.append(root);
46
+ const $=<T extends HTMLElement=HTMLElement>(s:string)=>root.querySelector<T>(s)!;
47
+ const plot=$('.blu-chart__plot'),table=$('.blu-chart__table'),status=$('.blu-chart__status'),navigation=$('.blu-chart__navigation');
48
+ const loading=chartLoadingState($('.blu-chart__state')),announce=stateAnnouncement($('.blu-chart__state-announcement'));
49
+ const download=$<HTMLButtonElement>('.blu-chart__export button'),pattern=$<HTMLInputElement>('.blu-chart__patterns input');
50
+ const motion=matchMedia('(prefers-reduced-motion: reduce)'),forced=matchMedia('(forced-colors: active)'),scheme=matchMedia('(prefers-color-scheme: dark)');
51
+ const points=()=>chartPoints(kind,options),available=()=>points().some(p=>p.Value!==null);
52
+ const msg=(key:string,fallback:string)=>message(options,key,fallback);
53
+ function alive(){if(destroyed)throw new Error('This chart has been destroyed.');}
54
+ function report(e:unknown,Code='InvalidData',RequestId=''){if(destroyed)return;status.textContent=e instanceof Error?e.message:String(e);emit(events.Error,{Message:status.textContent,Code,RequestId});setState('Error');}
55
+ function setState(State:'Loading'|'Ready'|'Empty'|'Error'){if(State!==lastState){lastState=State;emit(events.StateChange,{State,Revision:revision});}}
56
+ function highlight(key:string|null){chart?.dispatchAction({type:'downplay'});if(!key||pending||options.IsLoading)return;
57
+ if(kind==='mixed'){const o=options as MixedChartOptions,dataIndex=o.Categories.findIndex(c=>c.Key===key);if(dataIndex>=0)o.Series.forEach((_,seriesIndex)=>chart?.dispatchAction({type:'highlight',seriesIndex,dataIndex}));return;}
58
+ if(kind==='heatmap'){const dataIndex=(options as HeatmapOptions).Cells.findIndex(c=>c.Key===key);if(dataIndex>=0)chart?.dispatchAction({type:'highlight',seriesIndex:0,dataIndex});return;}
59
+ for(const target of highlightTargets(config,key))chart?.dispatchAction({type:'highlight',...target});}
60
+ function initialize(){
61
+ if(chart||plot.clientWidth<=0||plot.clientHeight<=0)return;
62
+ echarts.use([LabelLayout,LegacyGridContainLabel,TooltipComponent,AriaComponent,GraphicComponent,SVGRenderer]);
63
+ chart=echarts.init(plot,undefined,{renderer:'svg'});legend=createChartLegend(root,chart,kind);
64
+ chart.on('click','series',(event:any)=>{
65
+ let p=event.data?.point;
66
+ if(kind==='mixed'){const o=options as MixedChartOptions;p=points().find(p=>p.Key===o.Categories[event.dataIndex]?.Key&&p.SeriesKey===o.Series[event.seriesIndex]?.Key);}
67
+ if(kind==='heatmap')p=points()[event.dataIndex];
68
+ if(p)pick(p,'Chart');
69
+ });
70
+ for(const [event,action] of [['treemaproottonode','treemapRootToNode'],['treemapzoomtonode','treemapZoomToNode']])chart.on(event,(e:any)=>{treeRoot=e.targetNodeId||e.targetNode?.getId?.();treeAction=action;});
71
+ }
72
+ function raw(p:ChartPoint){return p.Value===null?msg('NoData','No data'):exactValue(p.Value,(options as any).__valueText?.[pointIdentity(kind,p)]);}
73
+ function restorePointFocus(key:string){
74
+ cancelAnimationFrame(focusFrame);
75
+ // Safari publishes the replacement controls to its accessibility tree on
76
+ // the next frame. Focusing earlier leaves VoiceOver on a detached AX node.
77
+ focusFrame=requestAnimationFrame(()=>{
78
+ if(destroyed||!root.isConnected)return;
79
+ const active=document.activeElement as HTMLElement|null;
80
+ if(active!==document.body&&active!==table&&active?.dataset.point!==key)return;
81
+ const next=[...table.querySelectorAll<HTMLElement>('[data-point]')].find(n=>n.dataset.point===key);
82
+ (next||table).focus({preventScroll:true});
83
+ });
84
+ }
85
+ function renderTable(){
86
+ const rows=points(),active=document.activeElement as HTMLElement|null,restore=active&&table.contains(active)?active.dataset.point:undefined;
87
+ const button=(p:ChartPoint,label:string)=>options.EnableSelection&&p.Value!==null?`<button type="button" data-point="${esc(pointIdentity(kind,p))}" aria-label="${esc(`${p.Label}${p.SeriesName?', '+p.SeriesName:''}: ${raw(p)}`)}" aria-pressed="${options.SelectedKey===p.Key}">${esc(label)}</button>`:esc(label);
88
+ let head='',body='';
89
+ if(kind==='mixed'){
90
+ const o=options as MixedChartOptions,lookup=new Map(rows.map(p=>[JSON.stringify([p.Key,p.SeriesKey]),p]));
91
+ head=`<tr><th scope="col">${esc(msg('Category','Category'))}</th>${o.Series.map(s=>`<th scope="col">${esc(s.Name)}</th>`).join('')}</tr>`;
92
+ body=o.Categories.map(c=>`<tr><th scope="row">${esc(c.Label)}</th>${o.Series.map(s=>{const p=lookup.get(JSON.stringify([c.Key,s.Key]))!;return `<td>${button(p,raw(p))}</td>`;}).join('')}</tr>`).join('');
93
+ }else if(kind==='heatmap'){
94
+ const o=options as HeatmapOptions,lookup=new Map(rows.map(p=>[JSON.stringify([(p as any).XKey,(p as any).YKey]),p]));
95
+ head=`<tr><th scope="col">${esc(msg('Category','Category'))}</th>${o.XCategories.map(x=>`<th scope="col">${esc(x.Label)}</th>`).join('')}</tr>`;
96
+ body=o.YCategories.map(y=>`<tr><th scope="row">${esc(y.Label)}</th>${o.XCategories.map(x=>{const p=lookup.get(JSON.stringify([x.Key,y.Key]));return `<td>${p?button(p,raw(p)):esc(msg('NoData','No data'))}</td>`;}).join('')}</tr>`).join('');
97
+ }else{
98
+ const fields=['Key','Label','Date','Value','CategoryKey','SeriesKey','RingKey','ParentKey','X','Y','Size','Group','SourceKey','TargetKey','Longitude','Latitude','PointType'].filter(k=>rows.some(p=>(p as any)[k]!==undefined));
99
+ head=`<tr>${fields.map(k=>`<th scope="col">${esc(msg(k,k))}</th>`).join('')}</tr>`;
100
+ body=rows.map(p=>`<tr>${fields.map(k=>{const value=(p as any)[k],text=k==='Value'?raw(p):value===null?'null':String(value??'');return `<td>${k==='Label'?button(p,text):esc(text)}</td>`;}).join('')}</tr>`).join('');
101
+ }
102
+ table.setAttribute('aria-label',`${options.Title} ${msg('Values','values')}`);
103
+ table.innerHTML=`<table><caption>${esc(options.Title||'Chart')} — ${esc(msg('ExactValues','original values'))}</caption><thead>${head}</thead><tbody>${body}</tbody></table>`;
104
+ if(restore)restorePointFocus(restore);
105
+ }
106
+ function paint(reset=false){
107
+ if(dataFade&&(presentation?.enabled?.()===false||!options.EnableAnimation||presentation?.mode(host)!==dataFadeMode)){dataFade.cancel();dataFade=undefined;}
108
+ if(destroyed)return;
109
+ const busy=pending||!!options.IsLoading,empty=!available(),failed=!!resourceError&&!busy;
110
+ root.className=`blu-chart ${options.ExtendedClass||''}`;root.setAttribute('aria-label',options.AccessibleLabel||options.Title||'Chart');
111
+ $('h3').textContent=options.Title||'Chart';$('.blu-chart__heading p').textContent=options.Description||'';
112
+ $('.blu-chart__heading').hidden=!options.ShowHeader&&!options.ShowToolbar;$('h3').parentElement!.hidden=!options.ShowHeader;$('.blu-chart__export').hidden=!options.ShowToolbar;
113
+ download.querySelector('span')!.textContent=msg('Download','Download');download.setAttribute('aria-label',msg('DownloadChart','Download chart'));
114
+ $<HTMLSelectElement>('select').setAttribute('aria-label',msg('DownloadFormat','Chart download format'));
115
+ $('option[value="png"]').textContent=msg('PNG','PNG image');$('option[value="svg"]').textContent=msg('SVG','SVG vector');
116
+ navigation.setAttribute('aria-label',msg('Navigation','Chart navigation'));
117
+ $('.blu-chart__patterns').hidden=!['mixed','line','radar','bar','polar','pie','doughnut','nightingale','treemap','sankey','funnel'].includes(kind);
118
+ $('.blu-chart__patterns span').textContent=msg('Patterns',kind==='line'||kind==='radar'?'Use fill patterns (with Area fill)':'Use fill patterns');pattern.checked=!!options.ShowPatterns;
119
+ $('.blu-chart__data').hidden=busy||empty;$('.blu-chart__data').classList.toggle('blu-chart__data--compact',!options.ShowDataTable);
120
+ $('.blu-chart__data summary').textContent=options.ShowDataTable?msg('ViewData','View data & keyboard selections'):msg('ExploreData','Explore chart data');
121
+ plot.style.height=`${options.Height}px`;plot.style.visibility=busy||empty?'hidden':'visible';plot.setAttribute('aria-busy',String(busy));
122
+ if(kind==='calendar-heatmap'){const o=options as any;const weeks=Math.ceil(((Date.parse(o.RangeEnd)-Date.parse(o.RangeStart))/86400000+7)/7);plot.style.minWidth=`${Math.max(300,weeks*12+56)}px`;const stage=$('.blu-chart__stage');stage.style.overflowX='auto';stage.tabIndex=0;stage.setAttribute('role','region');stage.setAttribute('aria-label',msg('CalendarScroll','Calendar plot; scroll horizontally for more dates'));}
123
+ const label=busy?msg('Loading',pending?'Loading map…':'Loading chart…'):options.EmptyMessage||'No data to display';
124
+ loading.update(busy,empty,label,!!options.EnableAnimation&&!motion.matches&&(!presentation||presentation.enabled?.()===false||presentation.mode(host)==='full'));announce.update(failed?resourceError:!busy&&!empty?msg('Ready','Chart ready.'):label);
125
+ download.disabled=exporting||busy||empty||failed;
126
+ navigation.hidden=busy||empty||failed||!(kind==='geo-map'&&(options as any).Roam!==false||kind==='treemap'&&(options as any).Drilldown);
127
+ if(kind==='geo-map'&&!navigation.children.length)navigation.innerHTML=['in','out','left','right','up','down','reset','full'].map(a=>`<button type="button" data-nav="${a}"></button>`).join('');
128
+ if(kind==='treemap'&&!navigation.children.length)navigation.innerHTML='<button type="button" data-nav="reset"></button>';
129
+ const navLabels:Record<string,string>={in:'Zoom in',out:'Zoom out',left:'Pan left',right:'Pan right',up:'Pan up',down:'Pan down',reset:kind==='treemap'?'Show entire tree':'Reset map',full:'Show full map'};
130
+ navigation.querySelectorAll<HTMLElement>('[data-nav]').forEach(b=>{b.textContent=msg('Map_'+b.dataset.nav,navLabels[b.dataset.nav!]);});
131
+ initialize();if(reset)legend?.reset();legend?.prepare(options,busy||empty);
132
+ if(chart&&!pending&&(kind!=='geo-map'||map)){
133
+ const view=!reset&&(chart.getOption() as any)?.geo?.[0],t=themeTokens(root);
134
+ const next:any=build({...options,ShowLegend:false},t,map?.name,motion.matches,plot.clientWidth);
135
+ // The shared DOM legend owns layout and interaction; do not require ECharts' unused legend module.
136
+ delete next.legend;
137
+ next.aria={...next.aria,description:`${options.Title}. ${options.Description||''} ${msg('DataAlternative','Original values and keyboard selection are in the data table.')}`};
138
+ if(next.geo&&view){next.geo.zoom=view.zoom;if(view.center)next.geo.center=view.center;}
139
+ if(presentation&&presentation.enabled?.()!==false){
140
+ const timing=presentation.timing?.();
141
+ next.animation=!!options.EnableAnimation&&!motion.matches&&presentation.mode(host)==='full'&&timing?.duration!==0&&!busy&&!empty;
142
+ next.animationDuration=timing?.duration??750;next.animationDurationUpdate=timing?.duration??650;
143
+ next.animationEasing=next.animationEasingUpdate=timing?.easing??'cubicInOut';
144
+ // Stable data identity lets values move to their new shape instead of replaying an entrance.
145
+ next.series?.forEach((series:any, index:number)=>{
146
+ series.id ??= JSON.stringify([kind,series.type,series.name || (kind==='geo-map' ? series.type : index)]);
147
+ // Series and points keep semantic identity even when groups/categories reorder.
148
+ const identify=(datum:any, i:number)=>{
149
+ if(!datum || typeof datum!=='object' || datum.id!=null)return;
150
+ const key=datum.point?.Key ?? (kind==='mixed' ? (options as MixedChartOptions).Categories[i]?.Key : datum.name);
151
+ // Sankey source/target reference node IDs. Keep those IDs equal to the source keys.
152
+ if(key!=null)datum.id=kind==='sankey'?String(key):JSON.stringify([key,datum.point?.SeriesKey||'']);
153
+ };
154
+ series.data?.forEach(identify);series.links?.forEach(identify);
155
+ });
156
+ // A map can switch to locations only; remove the old colour scale as well.
157
+ if(kind==='geo-map' && !next.visualMap)next.visualMap=[];
158
+ // Preference changes settle any engine-owned motion immediately.
159
+ if(!next.animation&&config?.animation)chart.clear();
160
+ }
161
+ chart.resize();chart.setOption(next,presentation&&presentation.enabled?.()!==false?{notMerge:!!reset,replaceMerge:kind==='geo-map'?['series','visualMap']:['series']}:{notMerge:true});config=next;
162
+ if(reset)treeRoot=undefined;
163
+ if(kind==='treemap'&&treeRoot&&points().some(p=>p.Key===treeRoot))chart.dispatchAction({type:treeAction,seriesIndex:0,targetNodeId:treeRoot});
164
+ legend?.render(options,next,t);
165
+ }
166
+ const note=$('.blu-chart__missing-note');
167
+ if(kind==='radar'){const incomplete=(options as any).Series.filter((s:any)=>s.Values.some((v:any)=>v===null));note.hidden=!incomplete.length||busy||empty;note.textContent=`${incomplete.map((s:any)=>s.Label).join(', ')}: ${msg('IncompleteRadar','incomplete profile. Available measurements remain in the data table; missing values are not drawn as zero.')}`;}
168
+ renderTable();if(forced.matches&&!busy&&!empty)$<HTMLDetailsElement>('.blu-chart__data').open=true;
169
+ if(chart&&!pending&&!failed&&!ready&&(kind!=='geo-map'||map)){ready=true;emit(events.Ready);}
170
+ if(!destroyed)setState(busy?'Loading':failed?'Error':empty?'Empty':'Ready');
171
+ }
172
+ function commit(next:O,cleared:boolean,nextMap=map,nextGeo=geometry,nextKey=geometryKey){
173
+ const old={options,map,geometry,geometryKey,resourceError};options=next;map=nextMap;geometry=nextGeo;geometryKey=nextKey;
174
+ try{resourceError='';status.textContent='';paint(old.options.ResetKey!==next.ResetKey||old.geometryKey!==nextKey);}catch(e){options=old.options;map=old.map;geometry=old.geometry;geometryKey=old.geometryKey;resourceError=old.resourceError;requested=options;try{paint();}catch{}if(nextMap!==old.map)nextMap?.release();throw e;}
175
+ // These engines replace heat colours / link geometry in one frame. Give
176
+ // actual data changes a readable fade while retaining the same chart,
177
+ // selection and accessible data table. The normal factories omit this.
178
+ if(presentation&&presentation.enabled?.()!==false&&['heatmap','calendar-heatmap','sankey'].includes(kind)&&old.options!==next&&JSON.stringify(chartPoints(kind,old.options))!==JSON.stringify(points())){
179
+ dataFade?.cancel();dataFade=undefined;
180
+ const mode=presentation.mode(host),timing=presentation.timing?.();
181
+ if(options.EnableAnimation&&mode!=='off'&&!options.IsLoading&&available()&&timing?.duration!==0){
182
+ const duration=mode==='reduced'||motion.matches?Math.min(timing?.duration??200,200):timing?.duration??650;
183
+ dataFadeMode=mode;dataFade=plot.animate([{opacity:.35},{opacity:1}],{duration,easing:timing?.curve||'ease-in-out'});
184
+ }
185
+ }
186
+ if(old.map!==nextMap)old.map?.release();
187
+ if(cleared)emit(events.SelectionChange,{Key:null,Reason:'DataChanged'});
188
+ }
189
+ function apply(next:O,cleared=false){
190
+ const version=++revision;mapAbort?.abort();pending=false;requested=next;
191
+ if(kind!=='geo-map'){commit(next,cleared);return;}
192
+ const o=next as GeoMapOptions,key=JSON.stringify([o.Map||'World',o.MapURL||'',o.GeoJSON||null]);
193
+ if(geometry&&key===geometryKey){validateMapRegions(o,geometry);commit(next,cleared);return;}
194
+ if(o.Map==='Custom'&&o.GeoJSON){validateMapRegions(o,o.GeoJSON);const acquired=mapRegistry.acquire(o.GeoJSON);commit(next,cleared,acquired,o.GeoJSON,key);return;}
195
+ pending=true;mapAbort=new AbortController();paint();
196
+ void resolveMap(o,mapAbort.signal).then(geo=>{if(destroyed||version!==revision)return;pending=false;const acquired=mapRegistry.acquire(geo);commit(next,cleared,acquired,geo,key);}).catch(e=>{if(destroyed||version!==revision)return;pending=false;requested=options;resourceError=e instanceof Error?e.message:String(e);paint();report(e,'ResourceLoadFailed');});
197
+ }
198
+ function pick(p:ChartPoint,origin:'Chart'|'Table'){
199
+ if(destroyed||pending||options.IsLoading||p.Value===null&&!(kind==='radar'&&p.PointType==='Series'))return;
200
+ const point={...structuredClone(p),ValueText:p.Value===null?'':raw(p)},enabled=!!options.EnableSelection;
201
+ const selected=options.SelectedKey===p.Key?null:p.Key;
202
+ if(enabled){const next={...options,SelectedKey:selected};if(kind==='treemap'&&origin==='Table'&&(next as any).Drilldown){treeRoot=p.Key;treeAction='treemapRootToNode';}apply(next);if(destroyed)return;status.textContent=selected?`${msg('Selected','Selected')} ${p.Label}.`:msg('SelectionCleared','Selection cleared.');}
203
+ emit(events.PointClick,point,origin);
204
+ if(enabled&&!destroyed)emit(events.SelectionChange,{Key:selected,Reason:'User'});
205
+ }
206
+ table.onclick=e=>{
207
+ const b=(e.target as HTMLElement).closest<HTMLElement>('[data-point]'),key=b?.dataset.point;
208
+ const p=b&&points().find(p=>pointIdentity(kind,p)===key);
209
+ if(!p)return;
210
+ // VoiceOver/AXPress can activate a row without moving DOM focus first.
211
+ // Repainting replaces the button; restore its stable identity explicitly.
212
+ pick(p,'Table');
213
+ if(!destroyed&&root.isConnected&&(document.activeElement===document.body||root.contains(document.activeElement))){
214
+ restorePointFocus(key!);
215
+ }
216
+ };
217
+ pattern.onchange=()=>{instance.update({ShowPatterns:pattern.checked} as Partial<O>);emit(events.PatternsChange,{ShowPatterns:pattern.checked});};
218
+ navigation.onclick=e=>{const action=(e.target as HTMLElement).closest<HTMLElement>('[data-nav]')?.dataset.nav;if(!action||!chart)return;if(action==='full'){instance.update({MapView:'Full',ResetKey:crypto.randomUUID()} as any);return;}if(action==='reset'){treeRoot=undefined;paint(true);return;}chart.dispatchAction({type:'geoRoam',componentType:'geo',geoIndex:0,dx:action==='left'?-60:action==='right'?60:0,dy:action==='up'?-60:action==='down'?60:0,zoom:action==='in'?1.3:action==='out'?1/1.3:1,originX:plot.clientWidth/2,originY:plot.clientHeight/2});status.textContent=msg('Map_'+action,navLabelsForStatus(action));};
219
+ function navLabelsForStatus(action:string){return `Map view changed: ${action}.`;}
220
+ const refresh=()=>{try{paint();}catch(e){report(e,'RenderFailed');}};
221
+ const escape=(e:KeyboardEvent)=>{if(e.key==='Escape')chart?.dispatchAction({type:'hideTip'});};
222
+ let measuredWidth=0,measuredHeight=0;
223
+ const resize=new ResizeObserver(()=>{const width=plot.clientWidth,height=plot.clientHeight;if(width===measuredWidth&&height===measuredHeight)return;measuredWidth=width;measuredHeight=height;cancelAnimationFrame(frame);frame=requestAnimationFrame(()=>{if(!destroyed&&width>0&&height>0)refresh();});});
224
+ const theme=new MutationObserver(refresh);theme.observe(document.documentElement,{attributes:true,attributeFilter:['data-bluui-theme']});
225
+ motion.addEventListener('change',refresh);forced.addEventListener('change',refresh);scheme.addEventListener('change',refresh);document.addEventListener('keydown',escape);
226
+ const instance:ChartInstance<O>={
227
+ update(patch){alive();try{const {next,cleared}=prepareUpdate(kind,requested,patch);apply(next,cleared);}catch(e){if(!pending){requested=options;try{paint();}catch{}}report(e,/SelectedKey/.test((e as Error).message)?'InvalidSelection':'InvalidData');throw e;}},
228
+ resize(){alive();refresh();},setSelection(key){instance.update({SelectedKey:key} as Partial<O>);},highlight(key){alive();highlight(key);},
229
+ async download(format:ChartFormat='png',filename=`blukavant-${kind}.${format}`,requestId=crypto.randomUUID()){
230
+ alive();let owns=false;
231
+ try{if(!['png','svg'].includes(format))throw new Error('Choose PNG or SVG.');if(exporting)throw new Error('ExportBusy');if(!chart||pending||resourceError||options.IsLoading||!available())throw new Error('Wait for a visible chart with valid data before downloading.');
232
+ exporting=true;owns=true;download.disabled=true;const t=themeTokens(root);
233
+ await exportImage(chart,options,format,filename,t.surface,t.text,t.muted,legend?.exportLayout(),exportAbort.signal);
234
+ if(!destroyed){status.textContent=msg('ExportStarted','Chart download started.');emit(events.ExportStarted,{Format:format,FileName:filename,RequestId:requestId});emit(events.ExportComplete,{Format:format,FileName:filename});}
235
+ }catch(e){if(!destroyed)report(e,e instanceof Error&&e.message==='ExportBusy'?'ExportBusy':'ExportFailed',requestId);throw e;}
236
+ finally{if(owns){exporting=false;if(!destroyed)download.disabled=pending||!!resourceError||!!options.IsLoading||!available();}}
237
+ },
238
+ destroy(){if(destroyed)return;destroyed=true;dataFade?.cancel();stopPresentation();revision++;mapAbort?.abort();exportAbort.abort();cancelAnimationFrame(frame);cancelAnimationFrame(focusFrame);resize.disconnect();theme.disconnect();motion.removeEventListener('change',refresh);forced.removeEventListener('change',refresh);scheme.removeEventListener('change',refresh);document.removeEventListener('keydown',escape);announce.destroy();loading.destroy();legend?.destroy();chart?.dispose();map?.release();root.remove();}
239
+ };
240
+ download.onclick=()=>void instance.download($<HTMLSelectElement>('select').value as ChartFormat).catch(()=>{});
241
+ resize.observe(root);
242
+ try{apply(options);stopPresentation=presentation?.subscribe(host,refresh)||(()=>{});}catch(e){report(e);instance.destroy();throw e;}
243
+ return instance;
244
+ }