@inflexa-ai/harness 0.42.2 → 0.43.1

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 (199) hide show
  1. package/README.md +2 -2
  2. package/dist/agents/sandbox/shared.d.ts +1 -6
  3. package/dist/agents/sandbox/shared.js +0 -15
  4. package/dist/app/chat-turn.js +13 -1
  5. package/dist/config/environment-stores.d.ts +4 -2
  6. package/dist/contracts/chat-events.d.ts +9 -1
  7. package/dist/contracts/chat-frame.d.ts +3 -3
  8. package/dist/contracts/chat-frame.js +8 -3
  9. package/dist/contracts/chat-parts.d.ts +4 -1
  10. package/dist/contracts/data-profile.d.ts +4 -6
  11. package/dist/contracts/index.d.ts +1 -1
  12. package/dist/contracts/report-blocks.d.ts +1364 -75
  13. package/dist/contracts/report-blocks.js +221 -29
  14. package/dist/contracts/report-reference.js +1 -1
  15. package/dist/contracts/schemas/chat-events.d.ts +13 -0
  16. package/dist/contracts/schemas/chat-events.js +5 -0
  17. package/dist/contracts/schemas/chat-parts.d.ts +2 -2
  18. package/dist/contracts/schemas/index.d.ts +1 -1
  19. package/dist/contracts/schemas/index.js +1 -1
  20. package/dist/execution/input-signature.d.ts +7 -13
  21. package/dist/execution/input-signature.js +0 -0
  22. package/dist/execution/run-canceler.d.ts +3 -2
  23. package/dist/execution/run-canceler.js +3 -2
  24. package/dist/execution/run-event-stream.d.ts +16 -2
  25. package/dist/execution/run-event-stream.js +54 -3
  26. package/dist/index.d.ts +1 -8
  27. package/dist/index.js +5 -16
  28. package/dist/input-scan/enrich.d.ts +0 -2
  29. package/dist/input-scan/enrich.js +0 -1
  30. package/dist/lib/metrics.js +6 -7
  31. package/dist/lib/otel-spans.d.ts +4 -9
  32. package/dist/lib/otel-spans.js +3 -8
  33. package/dist/loop/run-agent.d.ts +4 -2
  34. package/dist/loop/run-agent.js +38 -23
  35. package/dist/memory/thread-history.d.ts +1 -1
  36. package/dist/prompts/report-session.d.ts +1 -1
  37. package/dist/prompts/report-session.js +17 -0
  38. package/dist/provenance/exec-frame.d.ts +1 -1
  39. package/dist/report-model/authoring-grammar.d.ts +416 -17
  40. package/dist/report-model/authoring-grammar.js +25 -4
  41. package/dist/report-model/block-walk.d.ts +20 -4
  42. package/dist/report-model/block-walk.js +64 -7
  43. package/dist/report-model/draft-finish.d.ts +6 -2
  44. package/dist/report-model/draft-finish.js +6 -1
  45. package/dist/report-model/production-resolver.d.ts +8 -0
  46. package/dist/report-model/production-resolver.js +1 -1
  47. package/dist/report-model/validate.d.ts +20 -10
  48. package/dist/report-model/validate.js +29 -13
  49. package/dist/report-render/chart-export.d.ts +52 -0
  50. package/dist/report-render/chart-export.js +192 -0
  51. package/dist/report-render/chart-presets.d.ts +46 -47
  52. package/dist/report-render/chart-presets.js +31 -101
  53. package/dist/report-render/chart-renderers.d.ts +163 -0
  54. package/dist/report-render/chart-renderers.js +1055 -0
  55. package/dist/report-render/chart-toolbox.d.ts +124 -0
  56. package/dist/report-render/chart-toolbox.js +595 -0
  57. package/dist/report-render/chart.d.ts +60 -27
  58. package/dist/report-render/chart.js +2323 -524
  59. package/dist/report-render/design.d.ts +476 -16
  60. package/dist/report-render/design.js +390 -56
  61. package/dist/report-render/figures/common.d.ts +321 -0
  62. package/dist/report-render/figures/common.js +759 -0
  63. package/dist/report-render/figures/dendrogram.d.ts +36 -0
  64. package/dist/report-render/figures/dendrogram.js +130 -0
  65. package/dist/report-render/figures/dense.d.ts +111 -0
  66. package/dist/report-render/figures/dense.js +204 -0
  67. package/dist/report-render/figures/dotplot.d.ts +28 -0
  68. package/dist/report-render/figures/dotplot.js +234 -0
  69. package/dist/report-render/figures/embedding.d.ts +43 -0
  70. package/dist/report-render/figures/embedding.js +360 -0
  71. package/dist/report-render/figures/equal-units.d.ts +101 -0
  72. package/dist/report-render/figures/equal-units.js +215 -0
  73. package/dist/report-render/figures/forest.d.ts +20 -0
  74. package/dist/report-render/figures/forest.js +398 -0
  75. package/dist/report-render/figures/gsea.d.ts +30 -0
  76. package/dist/report-render/figures/gsea.js +391 -0
  77. package/dist/report-render/figures/heatmap.d.ts +40 -0
  78. package/dist/report-render/figures/heatmap.js +395 -0
  79. package/dist/report-render/figures/index.d.ts +135 -0
  80. package/dist/report-render/figures/index.js +100 -0
  81. package/dist/report-render/figures/km.d.ts +16 -0
  82. package/dist/report-render/figures/km.js +406 -0
  83. package/dist/report-render/figures/label-room.d.ts +130 -0
  84. package/dist/report-render/figures/label-room.js +341 -0
  85. package/dist/report-render/figures/locuszoom.d.ts +50 -0
  86. package/dist/report-render/figures/locuszoom.js +675 -0
  87. package/dist/report-render/figures/lollipop.d.ts +16 -0
  88. package/dist/report-render/figures/lollipop.js +247 -0
  89. package/dist/report-render/figures/ma.d.ts +20 -0
  90. package/dist/report-render/figures/ma.js +114 -0
  91. package/dist/report-render/figures/manhattan.d.ts +60 -0
  92. package/dist/report-render/figures/manhattan.js +343 -0
  93. package/dist/report-render/figures/oncoprint.d.ts +41 -0
  94. package/dist/report-render/figures/oncoprint.js +420 -0
  95. package/dist/report-render/figures/pca.d.ts +37 -0
  96. package/dist/report-render/figures/pca.js +241 -0
  97. package/dist/report-render/figures/qq.d.ts +30 -0
  98. package/dist/report-render/figures/qq.js +182 -0
  99. package/dist/report-render/figures/roc.d.ts +17 -0
  100. package/dist/report-render/figures/roc.js +116 -0
  101. package/dist/report-render/figures/sankey.d.ts +48 -0
  102. package/dist/report-render/figures/sankey.js +353 -0
  103. package/dist/report-render/figures/upset.d.ts +60 -0
  104. package/dist/report-render/figures/upset.js +316 -0
  105. package/dist/report-render/figures/volcano.d.ts +25 -0
  106. package/dist/report-render/figures/volcano.js +166 -0
  107. package/dist/report-render/hybrid-svg.d.ts +75 -0
  108. package/dist/report-render/hybrid-svg.js +504 -0
  109. package/dist/report-render/number-format.d.ts +19 -0
  110. package/dist/report-render/number-format.js +25 -1
  111. package/dist/report-render/page.d.ts +41 -4
  112. package/dist/report-render/page.js +247 -12
  113. package/dist/report-render/render.d.ts +13 -4
  114. package/dist/report-render/render.js +37 -6
  115. package/dist/report-render/types.d.ts +23 -2
  116. package/dist/report-render/value-bridge.d.ts +38 -3
  117. package/dist/report-render/value-bridge.js +137 -6
  118. package/dist/report-render/views/chart-view.d.ts +42 -6
  119. package/dist/report-render/views/chart-view.js +70 -9
  120. package/dist/report-render/views/page-view.js +9 -10
  121. package/dist/runtime/assemble.js +2 -2
  122. package/dist/sandbox/client.d.ts +23 -32
  123. package/dist/sandbox/client.js +3 -11
  124. package/dist/sandbox/create-sandbox.d.ts +9 -24
  125. package/dist/sandbox/create-sandbox.js +15 -30
  126. package/dist/sandbox/docker-client.d.ts +12 -22
  127. package/dist/sandbox/docker-client.js +29 -43
  128. package/dist/sandbox/exec.d.ts +58 -0
  129. package/dist/sandbox/exec.js +197 -0
  130. package/dist/sandbox/identity.d.ts +5 -5
  131. package/dist/sandbox/identity.js +7 -10
  132. package/dist/sandbox/k8s-client.d.ts +4 -10
  133. package/dist/sandbox/k8s-client.js +5 -12
  134. package/dist/sandbox/liveness.d.ts +5 -12
  135. package/dist/sandbox/liveness.js +5 -12
  136. package/dist/sandbox/notification-sweep.d.ts +4 -7
  137. package/dist/sandbox/notification-sweep.js +4 -7
  138. package/dist/sandbox/reaper.d.ts +3 -3
  139. package/dist/sandbox/reaper.js +3 -3
  140. package/dist/sandbox/sandbox-error.d.ts +10 -11
  141. package/dist/sandbox/sandbox-error.js +10 -11
  142. package/dist/sandbox/sandbox-step-translate.d.ts +1 -1
  143. package/dist/sandbox/sandbox-step-translate.js +1 -1
  144. package/dist/sandbox/types.d.ts +31 -99
  145. package/dist/sandbox/types.js +17 -84
  146. package/dist/state/active-sandboxes.d.ts +7 -32
  147. package/dist/state/active-sandboxes.js +11 -46
  148. package/dist/state/index.d.ts +1 -2
  149. package/dist/state/index.js +1 -1
  150. package/dist/state/migrations/20261005120000_drop_the_active_exec_tracking.d.ts +7 -0
  151. package/dist/state/migrations/20261005120000_drop_the_active_exec_tracking.js +12 -0
  152. package/dist/state/migrations/index.js +2 -0
  153. package/dist/state/schema.d.ts +1 -5
  154. package/dist/state/schema.js +1 -6
  155. package/dist/state/step-executions.js +1 -2
  156. package/dist/tasks/data-profile.js +7 -20
  157. package/dist/tasks/derive-table-exec.d.ts +4 -14
  158. package/dist/tasks/derive-table-exec.js +9 -24
  159. package/dist/tasks/extract-values.d.ts +2 -2
  160. package/dist/tasks/extract-values.js +4 -7
  161. package/dist/tools/define-tool.d.ts +56 -13
  162. package/dist/tools/define-tool.js +28 -1
  163. package/dist/tools/execute-analysis.js +3 -3
  164. package/dist/tools/report-session/derive-table.d.ts +8 -9
  165. package/dist/tools/report-session/derive-table.js +3 -4
  166. package/dist/tools/report-session/preview-report.js +4 -49
  167. package/dist/tools/sandbox/list-available-packages.d.ts +1 -1
  168. package/dist/tools/sandbox/list-available-packages.js +5 -4
  169. package/dist/tools/workspace/edit-file.d.ts +5 -4
  170. package/dist/tools/workspace/edit-file.js +10 -13
  171. package/dist/tools/workspace/execute-command.d.ts +27 -42
  172. package/dist/tools/workspace/execute-command.js +42 -38
  173. package/dist/tools/workspace/index.d.ts +5 -6
  174. package/dist/tools/workspace/index.js +5 -6
  175. package/dist/tools/workspace/mutator.d.ts +43 -8
  176. package/dist/tools/workspace/mutator.js +45 -34
  177. package/dist/tools/workspace/run-exec.d.ts +3 -12
  178. package/dist/tools/workspace/run-exec.js +7 -24
  179. package/dist/tools/workspace/scan-inputs.d.ts +0 -3
  180. package/dist/tools/workspace/scan-inputs.js +0 -1
  181. package/dist/tools/workspace/write-file.d.ts +12 -3
  182. package/dist/tools/workspace/write-file.js +17 -13
  183. package/dist/workflows/sandbox-step.d.ts +7 -11
  184. package/dist/workflows/sandbox-step.js +24 -27
  185. package/package.json +5 -3
  186. package/dist/sandbox/await-exec.d.ts +0 -143
  187. package/dist/sandbox/await-exec.js +0 -417
  188. package/dist/sandbox/deliver-exec-event.d.ts +0 -25
  189. package/dist/sandbox/deliver-exec-event.js +0 -29
  190. package/dist/sandbox/digest.d.ts +0 -14
  191. package/dist/sandbox/digest.js +0 -17
  192. package/dist/sandbox/exec-id.d.ts +0 -12
  193. package/dist/sandbox/exec-id.js +0 -20
  194. package/dist/sandbox/hmac.d.ts +0 -62
  195. package/dist/sandbox/hmac.js +0 -72
  196. package/dist/sandbox/submit-exec.d.ts +0 -38
  197. package/dist/sandbox/submit-exec.js +0 -80
  198. package/dist/sandbox/watchdog.d.ts +0 -71
  199. package/dist/sandbox/watchdog.js +0 -161
@@ -5,29 +5,37 @@
5
5
  * `deriveChartOption` turns that grammar and the resolved rows into one ECharts option object. The chart
6
6
  * container markup lives beside this file, and it wraps the option that this derivation gives.
7
7
  *
8
- * The derivation reads one shape. A preset expands into a composition before the derivation
9
- * (`chart-presets.ts`), and each base chart type keeps its own fixed rule. Thus the two paths cannot
10
- * drift.
8
+ * A preset draws through its figure module (`figures/`), and each base chart type keeps its own fixed rule.
9
+ * A dense figure builds its points through the composition machinery of this file, thus a figure and a
10
+ * composition read one series path.
11
11
  *
12
- * The renderer computes no aggregate. Each plotted number stays traceable to one cell of the evidence
13
- * artifact, or to a per-row transform of one cell. A pie or a heatmap arrives one row per category or per
14
- * cell, thus a repeated category or a repeated pair is a refusal and not a silent sum.
12
+ * The renderer computes no aggregate outside the named summaries: the histogram bins, the box quantiles, the
13
+ * violin density, and the shares of a normalized bar. Every other plotted number stays traceable to one
14
+ * cell of the evidence artifact, or to a per-row transform of one cell. A pie, a heatmap, a stacked bar, and
15
+ * a radar arrive one row per category or per pair, thus a repeated category or a repeated pair is a refusal
16
+ * and not a silent sum.
15
17
  *
16
18
  * The derivation is deterministic. Every object builds in one fixed key order, and the code reads no
17
19
  * clock, no random value, and no locale. A string comparison uses the code-unit order (`<`) and never
18
20
  * `localeCompare`, thus the same rows give the same bytes on every host.
19
21
  *
20
22
  * The option rides to the page as inline JSON. Thus no axis label and no tooltip can carry a function
21
- * formatter, and every formatter here is a static template of the chart runtime. As a result the
22
- * derivation bounds the tick precision of the one axis whose unit it declares itself, and every other
23
- * value axis keeps the tick values that ECharts computes.
23
+ * formatter, and every formatter here is a static template of the chart runtime or a static text. As a
24
+ * result the derivation bounds the tick precision of the one axis whose unit it declares itself, and every
25
+ * other value axis keeps the tick values that ECharts computes. A custom series names its renderer as a
26
+ * string, and each consumer of the option binds the name (`chart-renderers.ts`).
24
27
  */
25
28
  import { err, ok } from "neverthrow";
26
- import { channelColumn, channelTransform, } from "../contracts/report-blocks.js";
29
+ import { channelColumn, channelOrder, channelTransform, } from "../contracts/report-blocks.js";
27
30
  import { declaredForColumn } from "../contracts/report-reference.js";
28
31
  import { normalizeEchartSpec } from "../tools/display/normalize-echart-spec.js";
29
- import { expandPreset, isPresetChartType, presetAxisTitles, } from "./chart-presets.js";
30
- import { CHART_INLINE_OPTION_BOUND, MUTED_CHART_COLOR, SCATTER_CROWD_OPACITY, SCATTER_CROWD_ROWS, SCATTER_CROWD_SYMBOL_SIZE, SCATTER_HOVER_ROWS, SCATTER_HOVER_SYMBOL_SIZE, } from "./design.js";
32
+ import { categoryInterval, INTERVAL_RENDERER, LABEL_LINE_SHARE, OUTLINE_RENDERER, VIOLIN_GRID_POINTS } from "./chart-renderers.js";
33
+ import { BAR_VALUE_LABEL_LIMIT, CHART_BODY_PX, CHART_FONT_STACK, CHART_INK, CHART_INLINE_OPTION_BOUND, CHART_PAGE_TEXT_PX, CHART_PAGE_WIDTH_PX, CHART_SLOT_LIMIT, COLOR_SCALE_BAND_PCT, FACET_COLUMNS, FACET_PANEL_LIMIT, FACET_ROW_PX, FOCUS_CHART_COLOR, MUTED_CHART_COLOR, SCATTER_CROWD_OPACITY, SCATTER_CROWD_ROWS, SCATTER_CROWD_SYMBOL_SIZE, SCATTER_HOVER_ROWS, SCATTER_HOVER_SYMBOL_SIZE, SIZE_CHANNEL_RANGE_PX, } from "./design.js";
34
+ import { applyFigureRules, axisNameFields, categoryAxisTitle, categoryName, chartProblem as problem, colorScale, columnPresent, compareCell, continuousScale, FIGURE_BODY_MEMBER, firstAppearance, guideLine, guideMarkLine, orderCategories, orderQuickCategories, statisticText, toNumber, transformColumn, valueAxisTitle as axisTitle, withTransformedLabels, } from "./figures/common.js";
35
+ import { FIGURE_MEMBER_READERS, FIGURE_MODULES, isModuleOnlyType, } from "./figures/index.js";
36
+ import { SHAPED_SCATTER_FIGURE } from "./figures/pca.js";
37
+ import { formatNumberCell, selectNumberKind } from "./number-format.js";
38
+ export { transformColumn };
31
39
  /**
32
40
  * The category values that carry no finding.
33
41
  *
@@ -53,6 +61,23 @@ function isNullCategory(value) {
53
61
  function nullCategoryStyle(name) {
54
62
  return isNullCategory(name) ? { itemStyle: { color: MUTED_CHART_COLOR } } : {};
55
63
  }
64
+ /**
65
+ * The color of one category under a focus, or `undefined` when the chart declares no focus.
66
+ *
67
+ * A focus names the categories of the finding. Each named category takes the one focus color, and every
68
+ * other category takes the muted color. Thus a reader sees the finding first, and the rest reads as its
69
+ * reference.
70
+ */
71
+ function focusColor(focus, category) {
72
+ if (focus === undefined || category === undefined)
73
+ return undefined;
74
+ return focus.has(String(category)) ? FOCUS_CHART_COLOR : MUTED_CHART_COLOR;
75
+ }
76
+ /** The item style of one grouped series: the focus color where the chart declares a focus, else the null rule. */
77
+ function categoryStyle(name, focus) {
78
+ const color = focusColor(focus, name);
79
+ return color !== undefined ? { itemStyle: { color } } : nullCategoryStyle(name);
80
+ }
56
81
  /**
57
82
  * True when one declared series draws its bars across the plot.
58
83
  *
@@ -62,25 +87,6 @@ function nullCategoryStyle(name) {
62
87
  function isHorizontalBar(series) {
63
88
  return series.form === "bar" && series.orientation === "horizontal";
64
89
  }
65
- /**
66
- * The ten-stop viridis ramp for a heatmap `visualMap`. The stops run dark to light, thus a continuous
67
- * scale maps the low value to `#440154` and the high value to `#fde725`.
68
- */
69
- const VIRIDIS = ["#440154", "#482777", "#3e4989", "#31688e", "#26828e", "#1f9e89", "#35b779", "#6ece58", "#b5de2b", "#fde725"];
70
- /**
71
- * The distance in pixels between the x axis line and its name.
72
- *
73
- * The name sits under the middle of the axis, thus the gap must clear the axis labels below the line. The
74
- * value is a fixed constant, thus the derivation stays deterministic.
75
- */
76
- const X_AXIS_NAME_GAP = 34;
77
- /**
78
- * The label position of a vertical guide line.
79
- *
80
- * The start of such a line sits at the x axis, and its end sits at the top of the plot. The top is the band
81
- * of the y-axis title, thus a label there reads over the title.
82
- */
83
- const VERTICAL_LABEL_POSITION = "start";
84
90
  /**
85
91
  * The label position of a vertical guide band.
86
92
  *
@@ -112,54 +118,88 @@ const LABEL_CONTAINING_GRID = { containLabel: true };
112
118
  * The axis counts rows, thus a fractional tick names no count. `minInterval` holds each tick a whole count
113
119
  * apart from the next one, and it is the one static field that bounds the tick precision of a value axis.
114
120
  */
115
- const COUNT_AXIS = { type: "value", name: "Count", minInterval: 1 };
121
+ const COUNT_AXIS = { type: "value", minInterval: 1, ...axisNameFields("y", "Count") };
116
122
  /**
117
- * The name fields of an x axis.
118
- *
119
- * `normalizeEchartSpec` holds the grid to a right margin of 5 percent. The ECharts default `nameLocation`
120
- * of `"end"` puts the name at the right end of the axis, thus the name runs past that margin and the panel
121
- * clips it. A centered name sits under the middle of the axis, and no margin can cut it.
123
+ * The x name fields of a value axis. The helper names the rule of the page: the name sits under the middle
124
+ * of the axis, where no grid margin can cut it.
122
125
  */
123
126
  function xAxisName(title) {
124
- return { name: title, nameLocation: "middle", nameGap: X_AXIS_NAME_GAP };
127
+ return axisNameFields("x", title);
128
+ }
129
+ /** The y name fields of a value axis: the title turned 90 degrees, in the middle beside the axis. */
130
+ function yAxisName(title) {
131
+ return axisNameFields("y", title);
125
132
  }
126
133
  /**
127
- * The title of the axis that reads one column, most specific first: the declared label of the column, the
128
- * semantic title of the preset, then the raw column name.
134
+ * Derive the ECharts option for a chart block, then pass it through `normalizeEchartSpec`.
129
135
  *
130
- * A declared label answers for this one column of this one artifact, and a preset title answers for every
131
- * chart of its kind. Thus the label outranks the preset. The caller resolves an agent axes title over this
132
- * whole chain, because that title names this one axis of this one block.
136
+ * The normalizer owns the bottom legend and the axis-label discipline. Thus the derivation sets no `title`,
137
+ * and it sets a `legend` only where an auxiliary series would show an orphan entry. The theme palette assigns
138
+ * the series colors by their order, and a null category or a focus is the one case that names a color.
133
139
  *
134
- * A transformed channel reads a derived name such as `neg_log10(padj)`, which no declaration keys. Thus the
135
- * axis of a transform keeps the name that states the transform, and it never states the raw quantity.
140
+ * The axis of a channel names the label that the binding declares for its column. The semantic title of a
141
+ * preset answers next, and the raw column name answers last.
136
142
  */
137
- function axisTitle(labels, column, preset) {
138
- return declaredForColumn(labels, column) ?? preset ?? column;
143
+ export function deriveChartOption(block, rows, columns, inputs = {}, opts = DEFAULT_CHART_OPTS) {
144
+ return deriveRaw(block, rows, columns, undefined, inputs, opts).map((option) => reportLayout(option, block.title));
139
145
  }
146
+ /** The options of every render: the registered figure modules. */
147
+ export const DEFAULT_CHART_OPTS = { figures: FIGURE_MODULES };
140
148
  /**
141
- * The legend text of one category value: the value with each underscore as a space.
149
+ * The layout discipline of a report chart: the rules of `normalizeEchartSpec` with no toolbox, the upright
150
+ * category labels thinned to the plot width, then the figure rules that read the whole option.
142
151
  *
143
- * An analysis column carries a machine category such as `up_in_nonresponders`, and a legend reads for a
144
- * person. The replacement reads no locale, thus the same value gives the same text on every host. The raw
145
- * value stays in the data rows, thus the provenance loses nothing. No hover carries the raw text, because a
146
- * legend formatter is a function and the option rides as inline JSON.
152
+ * The chart card carries the export row, thus the save button of the discipline has no place on the page.
153
+ * Every other rule of the discipline stays. The normalizer gives a copy, thus the delete touches no input.
147
154
  */
148
- function categoryName(value) {
149
- return String(value).replaceAll("_", " ");
155
+ function reportLayout(raw, title) {
156
+ const { [FIGURE_BODY_MEMBER]: _body, ...rest } = raw;
157
+ const option = normalizeEchartSpec(rest, { title });
158
+ delete option.toolbox;
159
+ return applyFigureRules(thinnedLabels(option));
150
160
  }
151
161
  /**
152
- * Derive the ECharts option for a chart block, then pass it through `normalizeEchartSpec`.
162
+ * The height of the page chart body of one derived option, in pixels.
153
163
  *
154
- * The normalizer owns the bottom legend, the save action, and the axis-label discipline. Thus the
155
- * derivation sets no `title`, no `legend`, and no `toolbox`. The theme palette assigns the series colors by
156
- * their order, and the null category of a preset is the one series that names a color of its own.
164
+ * A figure that states its body takes that height. A facet lays its panels out three to a row, and each
165
+ * further row of panels adds the height of one row. Every other chart takes the default body.
157
166
  *
158
- * The axis of a channel names the label that the binding declares for its column. The semantic title of a
159
- * preset answers next, and the raw column name answers last.
167
+ * A figure can hold some grids of one figure, for example the count bar and the share bar beside the matrix of
168
+ * an oncoprint. Only a facet panel states the `same` outer bounds, thus the count reads those grids alone.
160
169
  */
161
- export function deriveChartOption(block, rows, columns) {
162
- return deriveRaw(block, rows, columns).map((option) => normalizeEchartSpec(option, { title: block.title }));
170
+ function bodyPxOf(raw, option) {
171
+ const stated = raw[FIGURE_BODY_MEMBER];
172
+ if (typeof stated === "number")
173
+ return Math.max(CHART_BODY_PX, stated);
174
+ const grids = option.grid;
175
+ if (!Array.isArray(grids))
176
+ return CHART_BODY_PX;
177
+ const panels = grids.filter((grid) => typeof grid === "object" && grid !== null && grid.outerBoundsMode === FACET_BOUNDS_MODE).length;
178
+ const rows = panels <= 2 ? 1 : Math.ceil(panels / FACET_COLUMNS);
179
+ return CHART_BODY_PX + (rows - 1) * FACET_ROW_PX;
180
+ }
181
+ /**
182
+ * The width of the drawn block of an option whose media rules size it, at one body height, or `undefined`
183
+ * for an option that fills its body.
184
+ *
185
+ * A figure of one unit states one rule for each square size, and each rule names the smallest body that holds
186
+ * it. The runtime applies the largest rule that fits, thus at a body of this height and of any width wider than
187
+ * the rule, the block takes the width of that rule. The card then narrows the body to that width and centers
188
+ * it. A rule that names a largest width serves one export size alone, and the page body reads no such rule.
189
+ */
190
+ function blockWidthOf(option, bodyPx) {
191
+ const media = option.media;
192
+ if (!Array.isArray(media))
193
+ return undefined;
194
+ let width;
195
+ for (const rule of media) {
196
+ const query = typeof rule === "object" && rule !== null ? rule.query : undefined;
197
+ if (query === undefined || query.maxWidth !== undefined || typeof query.minWidth !== "number" || typeof query.minHeight !== "number")
198
+ continue;
199
+ if (query.minHeight <= bodyPx)
200
+ width = query.minWidth;
201
+ }
202
+ return width;
163
203
  }
164
204
  /**
165
205
  * Derive the option of one chart, and read the shared payload of its artifact where the inline form grows
@@ -178,22 +218,37 @@ export function deriveChartOption(block, rows, columns) {
178
218
  * five-number summary, an addressed pair, and the upper half of a band are each such a value, thus no
179
219
  * descriptor can state one.
180
220
  */
181
- export function deriveChartRender(block, rows, columns, target) {
182
- const first = renderPass(block, target, (collector) => deriveRaw(block, rows, columns, collector));
221
+ export function deriveChartRender(block, rows, columns, target, inputs = {}, opts = DEFAULT_CHART_OPTS) {
222
+ const first = renderPass(block, target, (collector) => deriveRaw(block, rows, columns, collector, inputs, opts));
183
223
  if (first.isErr()) {
184
224
  return err(first.error);
185
225
  }
186
- const quick = perRowQuickPath(block);
226
+ const quick = perRowQuickPath(block, opts);
187
227
  if (first.value.readsPayload || !first.value.overBound || quick === undefined) {
188
- return ok({ option: first.value.option, readsPayload: first.value.readsPayload });
228
+ return ok(chartRender(first.value));
189
229
  }
190
- const second = renderPass(block, target, (collector) => deriveLabeled(block.id, quick.chartType, quick.encoding, rows, columns, block.binding.columnLabels, block.orientation, collector));
230
+ const second = renderPass(block, target, (collector) => deriveAsComposition(block.id, quick.chartType, quick.encoding, rows, columns, block.binding.columnLabels, block.orientation, {
231
+ collector,
232
+ focus: block.focus,
233
+ meanings: block.binding.columnMeanings,
234
+ }));
191
235
  if (second.isErr() || !second.value.readsPayload) {
192
236
  // The composition refused, or it described no build. The first option is a whole chart, thus the
193
237
  // page keeps it and the render reports no problem that the base rule did not raise.
194
- return ok({ option: first.value.option, readsPayload: first.value.readsPayload });
238
+ return ok(chartRender(first.value));
195
239
  }
196
- return ok({ option: second.value.option, readsPayload: true });
240
+ return ok(chartRender(second.value));
241
+ }
242
+ /** The render of one pass: the page option, the payload flag, the full-row option, and the body box. */
243
+ function chartRender(pass) {
244
+ const widthPx = blockWidthOf(pass.inline, pass.bodyPx);
245
+ return {
246
+ option: pass.option,
247
+ readsPayload: pass.readsPayload,
248
+ inline: pass.inline,
249
+ bodyPx: pass.bodyPx,
250
+ ...(widthPx !== undefined ? { widthPx } : {}),
251
+ };
197
252
  }
198
253
  /**
199
254
  * Run one derivation, normalize it, and route it onto the payload where the inline form passes the bound.
@@ -205,31 +260,51 @@ export function deriveChartRender(block, rows, columns, target) {
205
260
  function renderPass(block, target, derive) {
206
261
  const collector = { columns: target.columns, series: [], failed: false };
207
262
  return derive(collector).map((raw) => {
208
- const option = normalizeEchartSpec(raw, { title: block.title });
263
+ const option = reportLayout(raw, block.title);
264
+ const bodyPx = bodyPxOf(raw, option);
209
265
  const series = option.series;
210
266
  if (!Array.isArray(series) || JSON.stringify(option).length <= CHART_INLINE_OPTION_BOUND) {
211
- return { option, readsPayload: false, overBound: false };
267
+ return { option, inline: option, readsPayload: false, overBound: false, bodyPx };
212
268
  }
213
- if (collector.failed || collector.series.length === 0 || collector.series.length !== series.length) {
214
- return { option, readsPayload: false, overBound: true };
269
+ if (collector.failed || collector.series.length === 0 || !describesLead(series, collector)) {
270
+ return { option, inline: option, readsPayload: false, overBound: true, bodyPx };
215
271
  }
216
- return { option: sourcedOption(option, series, collector, target.key), readsPayload: true, overBound: true };
272
+ return { option: sourcedOption(option, series, collector, target.key), inline: option, readsPayload: true, overBound: true, bodyPx };
217
273
  });
218
274
  }
275
+ /**
276
+ * True when the descriptors of a pass describe the leading series of the option.
277
+ *
278
+ * A descriptor for each series describes the whole option. A figure can add series after the output of its
279
+ * composition, for example the band of a QQ plot. Such a series holds a value that no cell gives, thus it keeps
280
+ * its inline data, and the descriptors then describe the composition output alone. The names of the leading
281
+ * series must match that output, thus a figure that puts a series before it keeps the whole chart inline.
282
+ */
283
+ function describesLead(series, collector) {
284
+ const described = collector.series.length;
285
+ if (described === series.length)
286
+ return true;
287
+ const composed = collector.composed;
288
+ return (composed !== undefined &&
289
+ composed.length === described &&
290
+ described < series.length &&
291
+ composed.every((name, index) => series[index].name === name));
292
+ }
219
293
  /**
220
294
  * The quick path of one block that draws one point for one row, or `undefined` for every other block.
221
295
  *
222
- * A composition, a preset, and a labeled quick path each derive through the composition already, thus each
223
- * one states its own build on the first pass. A histogram, a box, a heatmap, and a pie each hold a value
224
- * that no cell of the table gives, thus none of them takes a second pass.
296
+ * A composition, a preset, and a quick path that names a point or a wide channel each derive through the
297
+ * composition already, thus each one states its own build on the first pass. A histogram, a box, a heatmap,
298
+ * a pie, a violin, the stacked forms, and a radar each hold a value that no cell of the table gives, thus
299
+ * none of them takes a second pass.
225
300
  */
226
- function perRowQuickPath(block) {
301
+ function perRowQuickPath(block, opts) {
227
302
  const chartType = block.chartType;
228
303
  const encoding = block.encoding;
229
- if (block.composition !== undefined || chartType === undefined || encoding === undefined || encoding.label !== undefined) {
304
+ if (block.composition !== undefined || chartType === undefined || encoding === undefined || routesThroughComposition(encoding)) {
230
305
  return undefined;
231
306
  }
232
- if (isPresetChartType(chartType) || LABELED_FORMS[chartType] === undefined) {
307
+ if (opts.figures[chartType] !== undefined || isModuleOnlyType(chartType) || LABELED_FORMS[chartType] === undefined || encoding.shape !== undefined) {
233
308
  return undefined;
234
309
  }
235
310
  return { chartType, encoding };
@@ -237,9 +312,9 @@ function perRowQuickPath(block) {
237
312
  /**
238
313
  * The option of a chart that reads the payload: the derived option with no row, and the data source.
239
314
  *
240
- * Each series keeps every field that the derivation gave it, and it loses its data alone. Thus the axes,
241
- * the names, the colors, and the symbol ladder of a dense chart read as they read inline, and the page
242
- * fills one member of each series.
315
+ * Each described series keeps every field that the derivation gave it, and it loses its data alone. Thus
316
+ * the axes, the names, the colors, and the symbol ladder of a dense chart read as they read inline, and the
317
+ * page fills one member of each series. A series past the descriptors keeps its inline data.
243
318
  */
244
319
  function sourcedOption(option, series, collector, key) {
245
320
  const source = {
@@ -249,21 +324,23 @@ function sourcedOption(option, series, collector, key) {
249
324
  };
250
325
  return {
251
326
  ...option,
252
- series: series.map((entry) => ({ ...entry, data: [] })),
327
+ series: series.map((entry, index) => (index < collector.series.length ? { ...entry, data: [] } : entry)),
253
328
  [CHART_SOURCE_MEMBER]: source,
254
329
  };
255
330
  }
256
331
  /**
257
332
  * Dispatch the grammar of one chart block.
258
333
  *
259
- * A composition derives directly. A preset expands into a composition first. A quick path that names a
260
- * point routes through a one-series composition, because a base rule builds a bare pair and only a
261
- * composition item carries a name. Every other quick path reaches the fixed rule of its base type.
334
+ * A composition derives directly. A chart type with a figure module derives through its module. A preset
335
+ * with no module refuses, because no other path draws it. A quick path that names a point or a wide channel
336
+ * routes through a one-series
337
+ * composition, because a base rule builds a bare pair and only a composition item carries a name, a color, a
338
+ * size, an interval, or a panel. Every other quick path reaches the fixed rule of its base type.
262
339
  *
263
- * The collector rides the composition path alone. A base rule collects nothing, thus its chart keeps its
264
- * inline data whatever its size.
340
+ * The collector rides the composition path and the composition of a figure alone. A base rule collects
341
+ * nothing, thus its chart keeps its inline data whatever its size.
265
342
  */
266
- function deriveRaw(block, rows, columns, collector) {
343
+ function deriveRaw(block, rows, columns, collector, inputs, opts) {
267
344
  const labels = block.binding.columnLabels;
268
345
  const orientation = block.orientation;
269
346
  if (orientation !== undefined && block.composition !== undefined) {
@@ -276,8 +353,15 @@ function deriveRaw(block, rows, columns, collector) {
276
353
  // A composition draws its own guide lines, thus a declared pair beside one moves nothing.
277
354
  return err(problem(block.id, "A composition draws its own guide lines, thus the chart carries no thresholds beside it."));
278
355
  }
356
+ const meanings = block.binding.columnMeanings;
279
357
  if (block.composition !== undefined) {
280
- return deriveComposition(block.id, block.composition, rows, columns, labels, { collector });
358
+ const unread = ["statistics", "track", "trees"].find((member) => declaresMember(block, member));
359
+ if (unread !== undefined) {
360
+ // A composition draws the series of one grid over one table, thus it places no statistic and it
361
+ // draws no second table.
362
+ return err(problem(block.id, `A composition reads no ${unread}. ${readersSentence(unread)}`));
363
+ }
364
+ return deriveComposition(block.id, block.composition, rows, columns, labels, { collector, focus: block.focus, meanings });
281
365
  }
282
366
  const chartType = block.chartType;
283
367
  const encoding = block.encoding;
@@ -286,30 +370,330 @@ function deriveRaw(block, rows, columns, collector) {
286
370
  // value that reaches the renderer without a parse.
287
371
  return err(problem(block.id, "The chart carries neither a chart type with an encoding, nor a composition."));
288
372
  }
289
- if (orientation !== undefined && chartType !== "bar") {
373
+ if (orientation !== undefined && !BAR_FORMS.has(chartType)) {
290
374
  // A silent ignore would teach the author a field that does nothing, thus the fault is stated.
291
- return err(problem(block.id, `The ${chartType} chart takes no orientation. An orientation is a rule of the bar alone.`));
375
+ return err(problem(block.id, `The ${chartType} chart takes no orientation. An orientation is a rule of the bar forms alone.`));
376
+ }
377
+ if (block.thresholds !== undefined && chartType !== "volcano" && chartType !== "ma") {
378
+ // The pair states a significance cut and an effect cut. The volcano reads both, and the ma reads the
379
+ // significance cut.
380
+ return err(problem(block.id, `The ${chartType} chart takes no thresholds. The thresholds are a rule of the volcano and the ma alone.`));
381
+ }
382
+ // A scatter with a shape draws through the shape draw of the PCA, because no base rule draws a symbol.
383
+ const figure = opts.figures[chartType] ?? (chartType === "scatter" && encoding.shape !== undefined ? SHAPED_SCATTER_FIGURE : undefined);
384
+ if (figure !== undefined) {
385
+ return deriveFigure(block, chartType, figure, rows, columns, collector, inputs);
292
386
  }
293
- if (block.thresholds !== undefined && chartType !== "volcano") {
294
- // The pair states a significance cut and an effect cut. The volcano is the one type that reads both.
295
- return err(problem(block.id, `The ${chartType} chart takes no thresholds. A threshold pair is a rule of the volcano alone.`));
387
+ if (isModuleOnlyType(chartType)) {
388
+ return err(problem(block.id, `The ${chartType} figure is not available yet. Draw the table with a base chart type or a composition.`));
296
389
  }
297
- if (isPresetChartType(chartType)) {
298
- return derivePreset(block.id, chartType, encoding, rows, columns, labels, block.thresholds, collector);
390
+ const unread = unreadFigureMember(block);
391
+ if (unread !== undefined) {
392
+ return err(problem(block.id, memberFault(chartType, unread)));
299
393
  }
300
- if (encoding.label !== undefined) {
301
- return deriveLabeled(block.id, chartType, encoding, rows, columns, labels, orientation, collector);
394
+ const fault = quickPathFault(chartType, encoding, block.focus);
395
+ if (fault !== undefined) {
396
+ return err(problem(block.id, fault));
397
+ }
398
+ // A stacked form draws its own panels, because a composition series stacks no parts.
399
+ const stackedFacet = STACKED_FORMS.has(chartType) ? encoding.facet : undefined;
400
+ if (routesThroughComposition(stackedFacet === undefined ? encoding : { ...encoding, facet: undefined })) {
401
+ return deriveAsComposition(block.id, chartType, encoding, rows, columns, labels, orientation, { collector, focus: block.focus, meanings });
402
+ }
403
+ if (stackedFacet !== undefined && channelTransform(stackedFacet) !== undefined) {
404
+ return err(problem(block.id, FACET_TRANSFORM));
302
405
  }
303
406
  const quick = resolveQuickPath(block.id, chartType, encoding, rows, columns, labels, orientation);
304
407
  if (quick.isErr())
305
408
  return err(quick.error);
306
- return deriveBase(quick.value.block, quick.value.rows, columns);
409
+ return deriveBase({
410
+ ...quick.value.block,
411
+ meanings,
412
+ ...(block.focus !== undefined ? { focus: block.focus } : {}),
413
+ ...(stackedFacet !== undefined ? { facet: channelColumn(stackedFacet) } : {}),
414
+ }, quick.value.rows, columns);
415
+ }
416
+ /** The members that the canonical figures add. A chart type with no figure module reads none of them. */
417
+ const FIGURE_ONLY_MEMBERS = ["shape", "p", "censor", "risk", "hit", "metric", "tracks", "statistics", "track", "trees"];
418
+ /** The first member of a block that the canonical figures add, or `undefined` when the block declares none. */
419
+ function unreadFigureMember(block) {
420
+ return FIGURE_ONLY_MEMBERS.find((member) => declaresMember(block, member));
421
+ }
422
+ /** True when the block declares one member: a channel of its encoding, the statistics, the track, the trees, or the focus. */
423
+ function declaresMember(block, member) {
424
+ switch (member) {
425
+ case "statistics":
426
+ return block.statistics !== undefined;
427
+ case "track":
428
+ return block.track !== undefined;
429
+ case "trees":
430
+ return block.trees !== undefined;
431
+ case "focus":
432
+ return block.focus !== undefined;
433
+ default:
434
+ return block.encoding?.[member] !== undefined;
435
+ }
436
+ }
437
+ /** Each member that one block declares, in the order of the grammar. */
438
+ function declaredMembers(block) {
439
+ const members = [];
440
+ for (const key of Object.keys(block.encoding ?? {})) {
441
+ if (block.encoding?.[key] !== undefined)
442
+ members.push(key);
443
+ }
444
+ for (const member of ["statistics", "track", "trees", "focus"]) {
445
+ if (declaresMember(block, member))
446
+ members.push(member);
447
+ }
448
+ return members;
449
+ }
450
+ /** The sentence that names the chart types that read one member that the canonical figures add. */
451
+ function readersSentence(member) {
452
+ const readers = FIGURE_MEMBER_READERS[member];
453
+ const list = readers.length <= 2 ? readers.join(" and ") : `${readers.slice(0, -1).join(", ")}, and ${readers[readers.length - 1]}`;
454
+ switch (member) {
455
+ case "statistics":
456
+ return `The statistics are legal on the ${list} charts.`;
457
+ case "track":
458
+ return `A track is legal on the ${list} charts.`;
459
+ case "trees":
460
+ return `The trees are legal on the ${list} charts.`;
461
+ default:
462
+ return `The "${member}" channel is legal on the ${list} charts.`;
463
+ }
464
+ }
465
+ /**
466
+ * The refusal of one member that a chart type does not read. A silent drop would teach the author a member
467
+ * that does nothing, thus the fault is stated, and it names the charts that read the member.
468
+ */
469
+ function memberFault(chartType, member) {
470
+ switch (member) {
471
+ case "statistics":
472
+ return `The ${chartType} chart prints no statistics. ${readersSentence(member)}`;
473
+ case "track":
474
+ return `The ${chartType} chart draws no track. ${readersSentence(member)}`;
475
+ case "trees":
476
+ return `The ${chartType} chart draws no tree. ${readersSentence(member)}`;
477
+ case "focus":
478
+ return `The ${chartType} chart takes no focus.`;
479
+ case "shape":
480
+ case "p":
481
+ case "censor":
482
+ case "risk":
483
+ case "hit":
484
+ case "metric":
485
+ case "tracks":
486
+ return `The ${chartType} chart takes no "${member}" channel. ${readersSentence(member)}`;
487
+ default:
488
+ return `The ${chartType} chart takes no "${member}" channel.`;
489
+ }
490
+ }
491
+ /**
492
+ * Derive a chart type through its figure module.
493
+ *
494
+ * The dispatch refuses each member that the module does not read, and it resolves the statistics and the
495
+ * track that the module reads. The module then gets the block, the rows, and the context. Its composition
496
+ * reads the collector of the pass, thus a dense figure reads the shared payload as a composition does.
497
+ */
498
+ function deriveFigure(block, chartType, figure, rows, columns, collector, inputs) {
499
+ for (const member of declaredMembers(block)) {
500
+ if (!figure.reads.has(member)) {
501
+ return err(problem(block.id, memberFault(chartType, member)));
502
+ }
503
+ }
504
+ const statistics = figureStatistics(block, inputs.statistics);
505
+ if (statistics.isErr())
506
+ return err(statistics.error);
507
+ const track = figureTrack(block, inputs.track);
508
+ if (track.isErr())
509
+ return err(track.error);
510
+ const trees = figureTrees(block, inputs.trees);
511
+ if (trees.isErr())
512
+ return err(trees.error);
513
+ const labels = block.binding.columnLabels;
514
+ const meanings = block.binding.columnMeanings;
515
+ const context = {
516
+ blockId: block.id,
517
+ labels,
518
+ meanings,
519
+ ...(columns !== undefined ? { columns } : {}),
520
+ statistics: statistics.value,
521
+ ...(track.value !== undefined ? { track: track.value } : {}),
522
+ ...(trees.value !== undefined ? { trees: trees.value } : {}),
523
+ textPx: CHART_PAGE_TEXT_PX,
524
+ compose: (composition, { keepsRowsInline, ...extras } = {}) => {
525
+ // A composition with no collector describes no page build, thus the chart keeps its rows inline.
526
+ const described = keepsRowsInline === true ? undefined : collector;
527
+ const option = deriveComposition(block.id, composition, rows, columns, labels, { ...extras, collector: described, focus: block.focus, meanings });
528
+ if (described !== undefined && option.isOk()) {
529
+ // The names of the composition output let the pass tell it apart from a series that the
530
+ // figure adds after it.
531
+ described.composed = Array.isArray(option.value.series) ? option.value.series.map((entry) => String(entry.name)) : [];
532
+ }
533
+ return option;
534
+ },
535
+ };
536
+ return figure.derive(block, rows, context);
537
+ }
538
+ /** A `missing-value` problem of a chart whose value entry lacks a part that the block declares. */
539
+ function missingPart(blockId, detail) {
540
+ return { blockId, kind: "missing-value", detail };
541
+ }
542
+ /**
543
+ * The statistics of one block with their resolved values and their shown text, in block order.
544
+ *
545
+ * The label comes from the block, and the value comes from the entry. A value entry whose count differs from
546
+ * the block names no value for some statistic, thus it refuses.
547
+ */
548
+ function figureStatistics(block, values) {
549
+ const declared = block.statistics ?? [];
550
+ if (declared.length === 0)
551
+ return ok([]);
552
+ if (values === undefined || values.length !== declared.length) {
553
+ return err(missingPart(block.id, `The chart declares ${declared.length} statistics, and its value entry carries ${values?.length ?? 0}.`));
554
+ }
555
+ return ok(declared.map((statistic, index) => ({
556
+ label: statistic.label,
557
+ value: values[index].value,
558
+ text: statisticText(values[index].value, statistic.value.locator.column, statistic.value.unit),
559
+ })));
560
+ }
561
+ /** The resolved track of one block, or `undefined` for a block that declares none. An entry with no track refuses. */
562
+ function figureTrack(block, value) {
563
+ const declared = block.track;
564
+ if (declared === undefined)
565
+ return ok(undefined);
566
+ if (value === undefined) {
567
+ return err(missingPart(block.id, "The chart declares a track, and its value entry carries no track."));
568
+ }
569
+ return ok({
570
+ rows: value.rows,
571
+ ...(value.columns !== undefined ? { columns: value.columns } : {}),
572
+ start: declared.start,
573
+ end: declared.end,
574
+ label: declared.label,
575
+ ...(declared.length !== undefined ? { length: declared.length } : {}),
576
+ labels: declared.binding.columnLabels,
577
+ meanings: declared.binding.columnMeanings,
578
+ });
579
+ }
580
+ /**
581
+ * The resolved tree of each axis of one block, or `undefined` for a block that declares none. An entry that
582
+ * carries no tree for a declared axis refuses.
583
+ */
584
+ function figureTrees(block, values) {
585
+ const declared = block.trees;
586
+ if (declared === undefined)
587
+ return ok(undefined);
588
+ const trees = {};
589
+ for (const axis of ["x", "y"]) {
590
+ const tree = declared[axis];
591
+ if (tree === undefined)
592
+ continue;
593
+ const value = values?.[axis];
594
+ if (value === undefined) {
595
+ return err(missingPart(block.id, `The chart declares a tree of ${axis}, and its value entry carries no tree of ${axis}.`));
596
+ }
597
+ trees[axis] = {
598
+ rows: value.rows,
599
+ ...(value.columns !== undefined ? { columns: value.columns } : {}),
600
+ parent: tree.parent,
601
+ child: tree.child,
602
+ height: tree.height,
603
+ labels: tree.binding.columnLabels,
604
+ meanings: tree.binding.columnMeanings,
605
+ };
606
+ }
607
+ return ok(trees);
608
+ }
609
+ /** The two stacked forms. Each one draws the facet panels of its own rule. */
610
+ const STACKED_FORMS = new Set(["stacked-bar", "normalized-bar"]);
611
+ /** The chart types that draw bars, and thus read an orientation. */
612
+ const BAR_FORMS = new Set(["bar", "stacked-bar", "normalized-bar"]);
613
+ /** The chart types that draw a continuous color, a size, an interval, and a facet. */
614
+ const COLOR_TYPES = new Set(["scatter", "bar"]);
615
+ const SIZE_TYPES = new Set(["scatter"]);
616
+ const INTERVAL_TYPES = new Set(["scatter", "bar"]);
617
+ const FACET_TYPES = new Set(["scatter", "line", "bar", "stacked-bar", "normalized-bar"]);
618
+ /**
619
+ * The chart types whose `x` categories can sort by a column. A line and a scatter sort `x` and `y` where the
620
+ * inferred axis draws categories, and the axis inference refuses the order on a value axis.
621
+ */
622
+ const ORDERED_X_TYPES = new Set(["bar", "stacked-bar", "normalized-bar", "box", "violin", "line", "scatter"]);
623
+ const ORDERED_Y_TYPES = new Set(["line", "scatter"]);
624
+ /** The chart types that take a focus with or without a group, and the ones that take it over a group alone. */
625
+ const FOCUS_TYPES = new Set(["bar", "stacked-bar", "normalized-bar", "radar"]);
626
+ const GROUPED_FOCUS_TYPES = new Set(["scatter", "line", "box", "violin"]);
627
+ /** The channels of the quick path that never draw a category axis, thus never take an order. */
628
+ const UNORDERED_CHANNELS = ["group", "value", "color", "size", "low", "high", "facet"];
629
+ /** The refusal of a color beside a group. One chart colors by one channel. */
630
+ const COLOR_BESIDE_GROUP = 'A "color" channel beside a "group" channel is a fault, because one chart colors by one channel.';
631
+ /** The refusal of a focus beside a color. One chart colors by one rule. */
632
+ const FOCUS_BESIDE_COLOR = 'A focus beside a "color" channel is a fault, because one chart colors by one rule.';
633
+ /** The refusal of a transform on a facet. A panel names one category of the raw column. */
634
+ const FACET_TRANSFORM = 'The "facet" channel reads categories, thus it takes no transform.';
635
+ /**
636
+ * The fault of a quick path whose type cannot draw one of its channels, or `undefined` for a sound one.
637
+ *
638
+ * Each rule depends on the chart type, thus the grammar admits the channel and the render states the fault.
639
+ * A silent drop would teach the author a channel that does nothing.
640
+ */
641
+ function quickPathFault(chartType, encoding, focus) {
642
+ if (encoding.color !== undefined && !COLOR_TYPES.has(chartType)) {
643
+ return `The ${chartType} chart takes no "color" channel. A continuous color is legal on a scatter and a bar.`;
644
+ }
645
+ if (encoding.color !== undefined && encoding.group !== undefined) {
646
+ return COLOR_BESIDE_GROUP;
647
+ }
648
+ if (encoding.size !== undefined && !SIZE_TYPES.has(chartType)) {
649
+ return `The ${chartType} chart takes no "size" channel. A size is legal on a scatter alone.`;
650
+ }
651
+ if ((encoding.low !== undefined || encoding.high !== undefined) && !INTERVAL_TYPES.has(chartType)) {
652
+ return `The ${chartType} chart takes no interval. The "low" and "high" channels are legal on a bar and a scatter.`;
653
+ }
654
+ if (encoding.facet !== undefined && !FACET_TYPES.has(chartType)) {
655
+ return `The ${chartType} chart takes no "facet" channel. A facet is legal on a scatter, a line, a bar, and the two stacked forms.`;
656
+ }
657
+ for (const name of UNORDERED_CHANNELS) {
658
+ const channel = encoding[name];
659
+ if (channel !== undefined && channelOrder(channel) !== undefined) {
660
+ return `The "${name}" channel draws no category axis, thus it takes no "orderBy".`;
661
+ }
662
+ }
663
+ if (encoding.x !== undefined && channelOrder(encoding.x) !== undefined && !ORDERED_X_TYPES.has(chartType)) {
664
+ return `The "x" channel of the ${chartType} chart draws no category axis, thus it takes no "orderBy".`;
665
+ }
666
+ if (encoding.y !== undefined && channelOrder(encoding.y) !== undefined && !ORDERED_Y_TYPES.has(chartType)) {
667
+ return `The "y" channel of the ${chartType} chart draws no category axis, thus it takes no "orderBy".`;
668
+ }
669
+ if (focus === undefined) {
670
+ return undefined;
671
+ }
672
+ if (encoding.color !== undefined) {
673
+ return FOCUS_BESIDE_COLOR;
674
+ }
675
+ if (FOCUS_TYPES.has(chartType)) {
676
+ return undefined;
677
+ }
678
+ if (GROUPED_FOCUS_TYPES.has(chartType)) {
679
+ return encoding.group !== undefined ? undefined : `The ${chartType} chart reads a focus over its "group" categories, and it names no "group" channel.`;
680
+ }
681
+ return `The ${chartType} chart takes no focus. A focus is legal on a bar, the two stacked forms, a radar, and a grouped scatter, line, box, or violin.`;
682
+ }
683
+ /** True when a quick path names a point or a wide channel, thus it derives through a one-series composition. */
684
+ function routesThroughComposition(encoding) {
685
+ return (encoding.label !== undefined ||
686
+ encoding.color !== undefined ||
687
+ encoding.size !== undefined ||
688
+ encoding.low !== undefined ||
689
+ encoding.high !== undefined ||
690
+ encoding.facet !== undefined);
307
691
  }
308
692
  /** Dispatch to the per-type derivation. Each type holds one fixed rule. */
309
693
  function deriveBase(block, rows, columns) {
310
694
  switch (block.chartType) {
311
695
  case "bar":
312
- return deriveBar(block, rows, columns);
696
+ return deriveBar(block, rows, columns, "bar");
313
697
  case "line":
314
698
  return deriveLine(block, rows, columns);
315
699
  case "scatter":
@@ -318,36 +702,28 @@ function deriveBase(block, rows, columns) {
318
702
  return deriveHistogram(block, rows, columns);
319
703
  case "box":
320
704
  return deriveBox(block, rows, columns);
321
- case "heatmap":
322
- return deriveHeatmap(block, rows, columns);
323
705
  case "pie":
324
706
  return derivePie(block, rows, columns);
707
+ case "violin":
708
+ return deriveViolin(block, rows, columns);
709
+ case "stacked-bar":
710
+ return deriveBar(block, rows, columns, "stacked");
711
+ case "normalized-bar":
712
+ return deriveBar(block, rows, columns, "normalized");
713
+ case "radar":
714
+ return deriveRadar(block, rows, columns);
325
715
  }
326
716
  }
327
- /** Expand one preset over its two demanded channels, then derive the composition that it gives. */
328
- function derivePreset(blockId, preset, encoding, rows, columns, labels, thresholds, collector) {
329
- const x = requireChannel(blockId, preset, encoding, "x");
330
- if (x.isErr())
331
- return err(x.error);
332
- const y = requireChannel(blockId, preset, encoding, "y");
333
- if (y.isErr())
334
- return err(y.error);
335
- const expansion = expandPreset(preset, x.value, y.value, encoding, thresholds);
336
- return deriveComposition(blockId, expansion.composition, rows, columns, labels, {
337
- preset: presetAxisTitles(preset, x.value),
338
- classification: expansion.classification,
339
- collector,
340
- });
341
- }
342
717
  /** The quick-path types that map onto one series form. A point of such a chart can carry a name. */
343
718
  const LABELED_FORMS = { bar: "bar", line: "line", scatter: "scatter" };
344
719
  /**
345
- * Derive a quick path that names its points, as a composition of one series.
720
+ * Derive a quick path that names its points or carries a wide channel, as a composition of one series.
346
721
  *
347
722
  * A histogram bins its rows, a box summarizes them, and a heatmap and a pie both address a pair or a
348
- * category. None of the four draws one point for one row, thus none of them can carry a per-row name.
723
+ * category. None of the four draws one point for one row, thus none of them can carry a per-row name. The
724
+ * caller refused each wide channel on a type that cannot draw it, thus the three forms here carry them all.
349
725
  */
350
- function deriveLabeled(blockId, chartType, encoding, rows, columns, labels, orientation, collector) {
726
+ function deriveAsComposition(blockId, chartType, encoding, rows, columns, labels, orientation, extras) {
351
727
  const form = LABELED_FORMS[chartType];
352
728
  if (form === undefined) {
353
729
  return err(problem(blockId, `The ${chartType} chart draws no point for one row, thus it takes no "label" channel.`));
@@ -367,14 +743,19 @@ function deriveLabeled(blockId, chartType, encoding, rows, columns, labels, orie
367
743
  y: y.value,
368
744
  ...(encoding.group !== undefined ? { group: encoding.group } : {}),
369
745
  ...(encoding.label !== undefined ? { label: encoding.label } : {}),
746
+ ...(encoding.color !== undefined ? { color: encoding.color } : {}),
747
+ ...(encoding.size !== undefined ? { size: encoding.size } : {}),
748
+ ...(encoding.low !== undefined ? { low: encoding.low } : {}),
749
+ ...(encoding.high !== undefined ? { high: encoding.high } : {}),
370
750
  },
371
751
  // The caller refused an orientation on every type but the bar, thus the form here is a bar
372
752
  // wherever one arrives. The arrangement crosses onto the series, and no name channel loses it.
373
753
  ...(orientation !== undefined ? { orientation } : {}),
374
754
  },
375
755
  ],
756
+ ...(encoding.facet !== undefined ? { facet: encoding.facet } : {}),
376
757
  };
377
- return deriveComposition(blockId, composition, rows, columns, labels, { collector });
758
+ return deriveComposition(blockId, composition, rows, columns, labels, extras);
378
759
  }
379
760
  /**
380
761
  * Resolve the quick path of a base chart type: the plain column of each channel, and the rows that the
@@ -410,7 +791,26 @@ function resolveQuickPath(blockId, chartType, encoding, rows, columns, labels, o
410
791
  resolved[channel] = name;
411
792
  derived.push({ column, name, transform });
412
793
  }
413
- const block = { id: blockId, chartType, encoding: resolved, labels, orientation };
794
+ const orders = {};
795
+ for (const axis of ["x", "y"]) {
796
+ const declared = encoding[axis];
797
+ const order = declared === undefined ? undefined : channelOrder(declared);
798
+ if (order === undefined)
799
+ continue;
800
+ if (rows.length > 0 && !columnPresent(order.by, rows, columns)) {
801
+ return err(problem(blockId, `The column "${order.by}" is absent from every row.`));
802
+ }
803
+ orders[axis] = order;
804
+ }
805
+ const block = {
806
+ id: blockId,
807
+ chartType,
808
+ encoding: resolved,
809
+ orders,
810
+ labels: withTransformedLabels(labels, derived),
811
+ meanings: undefined,
812
+ orientation,
813
+ };
414
814
  return ok({ block, rows: derived.length === 0 ? rows : deriveTransformedRows(rows, derived) });
415
815
  }
416
816
  /** The four channels of the quick path, in one fixed order. */
@@ -438,7 +838,6 @@ function deriveTransformedRows(rows, derived) {
438
838
  }
439
839
  return out;
440
840
  }
441
- // ── The per-type derivations ────────────────────────────────────────────────
442
841
  /**
443
842
  * A category axis, a value axis, and one bar series per group with the pairs of the two channels.
444
843
  *
@@ -451,8 +850,11 @@ function deriveTransformedRows(rows, derived) {
451
850
  * interval of each axis, and it turns an x label alone, thus a long name on y reads level and none of them
452
851
  * drops. The same arrangement inverts that axis and contains its labels, for the reasons that
453
852
  * `HORIZONTAL_CATEGORY_AXIS` and `LABEL_CONTAINING_GRID` give.
853
+ *
854
+ * The two stacked modes read the same axes, and they demand a group channel, because each one draws the
855
+ * parts of a category.
454
856
  */
455
- function deriveBar(block, rows, columns) {
857
+ function deriveBar(block, rows, columns, mode) {
456
858
  const xResult = requireColumn(block, rows, columns, "x");
457
859
  if (xResult.isErr())
458
860
  return err(xResult.error);
@@ -461,54 +863,196 @@ function deriveBar(block, rows, columns) {
461
863
  return err(yResult.error);
462
864
  const x = xResult.value;
463
865
  const y = yResult.value;
866
+ const ordered = orderQuickCategories(block.id, rows, x, block.orders.x);
867
+ if (ordered.isErr())
868
+ return err(ordered.error);
869
+ const categories = ordered.value;
464
870
  const horizontal = block.orientation === "horizontal";
465
- const categories = firstAppearance(rows.map((row) => row[x]));
466
- const series = groupedSeries(rows, block.encoding.group, (groupRows, name) => ({
467
- type: "bar",
468
- ...(name !== undefined ? { name: categoryName(name) } : {}),
469
- ...nullCategoryStyle(name),
470
- barGap: 0,
471
- data: groupRows.map((row) => (horizontal ? [row[y], row[x]] : [row[x], row[y]])),
472
- }));
871
+ let series;
872
+ if (mode === "bar") {
873
+ const group = block.encoding.group;
874
+ const focused = focusSet(block.id, block.focus, rows.length, group === undefined ? categories : firstAppearance(rows.map((row) => row[group])));
875
+ if (focused.isErr())
876
+ return err(focused.error);
877
+ const focus = focused.value;
878
+ const bars = groupedSeries(rows, group, (groupRows, name) => ({
879
+ type: "bar",
880
+ ...(name !== undefined ? { name: categoryName(name) } : {}),
881
+ ...categoryStyle(name, focus),
882
+ barGap: 0,
883
+ barCategoryGap: BAR_CATEGORY_GAP,
884
+ data: groupRows.map((row) => {
885
+ const pair = horizontal ? [row[y], categoryCell(row[x])] : [categoryCell(row[x]), row[y]];
886
+ // A bar with no group reads the focus over its own categories, thus each item carries its color.
887
+ const color = group === undefined ? focusColor(focus, row[x]) : undefined;
888
+ return color === undefined ? pair : { value: pair, itemStyle: { color } };
889
+ }),
890
+ }));
891
+ series = withValueLabels(bars, horizontal, (cell) => valueLabelText(y, cell, block.meanings));
892
+ }
893
+ else {
894
+ if (block.facet !== undefined) {
895
+ return deriveStackedFacets(block, rows, columns, x, y, categories, mode === "normalized", block.facet);
896
+ }
897
+ const stacked = stackedSeries(block, rows, columns, x, y, categories, mode === "normalized");
898
+ if (stacked.isErr())
899
+ return err(stacked.error);
900
+ series = stacked.value;
901
+ }
902
+ const valueAxis = { type: "value", ...(mode === "normalized" ? { min: 0, max: 1 } : {}), ...labelRoom(series, horizontal) };
473
903
  if (horizontal) {
474
904
  return ok({
475
- xAxis: { type: "value", ...xAxisName(axisTitle(block.labels, y)) },
476
- yAxis: { ...HORIZONTAL_CATEGORY_AXIS, data: categories, name: axisTitle(block.labels, x) },
905
+ xAxis: { ...valueAxis, ...xAxisName(axisTitle(block.labels, y)) },
906
+ yAxis: { ...HORIZONTAL_CATEGORY_AXIS, data: categories, ...axisNameFields("y", categoryAxisTitle(block.labels, x)) },
477
907
  series,
478
908
  grid: { ...LABEL_CONTAINING_GRID },
479
909
  });
480
910
  }
481
911
  return ok({
482
- xAxis: { type: "category", data: categories, ...xAxisName(axisTitle(block.labels, x)) },
483
- yAxis: { type: "value", name: axisTitle(block.labels, y) },
912
+ xAxis: { type: "category", data: categories, ...axisNameFields("x", categoryAxisTitle(block.labels, x)) },
913
+ yAxis: { ...valueAxis, ...yAxisName(axisTitle(block.labels, y)) },
484
914
  series,
485
915
  });
486
916
  }
917
+ /**
918
+ * The group series of a stacked bar, or of a normalized bar.
919
+ *
920
+ * Each series holds one value for each category, in category order, thus the chart runtime stacks the parts
921
+ * of one category by place. An absent pair holds `null`, and the stack skips it. A repeated pair is a
922
+ * refusal, because a sum of two rows is an aggregate that no cell gives.
923
+ *
924
+ * A normalized bar states the share of each part in the total of its category. The share is a named
925
+ * summary. A negative part has no share, thus it refuses and names its row. A category whose total is zero
926
+ * has no share either, thus it draws no bar.
927
+ *
928
+ * The stacked forms carry no value label, because the label of one part overlaps the next part.
929
+ */
930
+ function stackedSeries(block, rows, columns, x, y, categories, normalized, tableGroups) {
931
+ const groupResult = requireColumn(block, rows, columns, "group");
932
+ if (groupResult.isErr())
933
+ return err(groupResult.error);
934
+ const group = groupResult.value;
935
+ const groups = tableGroups ?? firstAppearance(rows.map((row) => row[group]));
936
+ const bound = slotFault(block.id, categories.length, groups.length);
937
+ if (bound !== undefined)
938
+ return err(bound);
939
+ const focused = focusSet(block.id, block.focus, rows.length, groups);
940
+ if (focused.isErr())
941
+ return err(focused.error);
942
+ const parts = new Map();
943
+ const totals = new Map();
944
+ for (const [index, row] of rows.entries()) {
945
+ const key = pairKey(row[x], row[group]);
946
+ if (parts.has(key)) {
947
+ return err(problem(block.id, `The ${block.chartType} chart holds the pair (${String(row[x])}, ${String(row[group])}) more than one time.`));
948
+ }
949
+ const value = toNumber(row[y]);
950
+ if (normalized && value !== null && value < 0) {
951
+ return err(problem(block.id, `The row ${index + 1} holds the negative part ${value}. A share reads parts that are not negative.`));
952
+ }
953
+ parts.set(key, value);
954
+ const categoryKey = cellKey(row[x]);
955
+ totals.set(categoryKey, (totals.get(categoryKey) ?? 0) + (value ?? 0));
956
+ }
957
+ return ok(groups.map((name) => ({
958
+ type: "bar",
959
+ name: categoryName(name),
960
+ ...categoryStyle(name, focused.value),
961
+ stack: STACK_NAME,
962
+ data: categories.map((category) => {
963
+ const part = parts.get(pairKey(category, name)) ?? null;
964
+ if (!normalized || part === null)
965
+ return part;
966
+ const total = totals.get(cellKey(category)) ?? 0;
967
+ return total === 0 ? null : part / total;
968
+ }),
969
+ })));
970
+ }
971
+ /** The stack name of the stacked forms. Each group series names the same stack, thus the parts stack. */
972
+ const STACK_NAME = "total";
973
+ /**
974
+ * The panels of a faceted stacked form: one panel for each value of the facet column, in first-appearance
975
+ * order, laid out as the panels of a faceted composition.
976
+ *
977
+ * Each panel stacks the parts of its own rows, thus a pair of a category and a group repeats across panels and
978
+ * never inside one. Each panel lists every category and every group of the table, in one order, thus a group
979
+ * keeps its color and its place in the stack in each panel. The value axis of every panel reads one range: the
980
+ * shares of a normalized bar run from 0 to 1, and a stacked bar runs over the largest total of each side of
981
+ * zero in any panel. The rows stay inline, because the base rule collects no page-side build.
982
+ */
983
+ function deriveStackedFacets(block, rows, columns, x, y, categories, normalized, facet) {
984
+ if (rows.length > 0 && !columnPresent(facet, rows, columns)) {
985
+ return err(problem(block.id, `The column "${facet}" is absent from every row.`));
986
+ }
987
+ const groupResult = requireColumn(block, rows, columns, "group");
988
+ if (groupResult.isErr())
989
+ return err(groupResult.error);
990
+ const group = groupResult.value;
991
+ const groups = firstAppearance(rows.map((row) => row[group]));
992
+ const names = firstAppearance(rows.map((row) => row[facet]));
993
+ if (names.length > FACET_PANEL_LIMIT) {
994
+ return err(problem(block.id, `The facet splits the table into ${names.length} panels. A facet holds ${FACET_PANEL_LIMIT} panels at most, thus a table of fewer groups serves the reader.`));
995
+ }
996
+ const series = [];
997
+ let low = 0;
998
+ let high = 0;
999
+ for (const [place, name] of names.entries()) {
1000
+ const panelRows = rows.filter((row) => row[facet] === name);
1001
+ const panel = stackedSeries(block, panelRows, columns, x, y, categories, normalized, groups);
1002
+ if (panel.isErr())
1003
+ return err(panel.error);
1004
+ for (const entry of panel.value) {
1005
+ series.push({ ...entry, stack: `${STACK_NAME}-${place}`, xAxisIndex: place, yAxisIndex: place });
1006
+ }
1007
+ for (const [index] of categories.entries()) {
1008
+ let positive = 0;
1009
+ let negative = 0;
1010
+ for (const entry of panel.value) {
1011
+ // `stackedSeries` gives each series one part or null for each category, in category order.
1012
+ const part = toNumber(entry.data[index]);
1013
+ if (part === null)
1014
+ continue;
1015
+ if (part > 0)
1016
+ positive += part;
1017
+ else
1018
+ negative += part;
1019
+ }
1020
+ high = Math.max(high, positive);
1021
+ low = Math.min(low, negative);
1022
+ }
1023
+ }
1024
+ const [min, max] = normalized ? [0, 1] : roundExtent(low, high);
1025
+ const valueAxis = { type: "value", min, max };
1026
+ const horizontal = block.orientation === "horizontal";
1027
+ const categoryTitle = categoryAxisTitle(block.labels, x);
1028
+ const shared = horizontal
1029
+ ? {
1030
+ xAxis: { ...valueAxis, ...xAxisName(axisTitle(block.labels, y)) },
1031
+ yAxis: { ...HORIZONTAL_CATEGORY_AXIS, data: categories, ...axisNameFields("y", categoryTitle) },
1032
+ }
1033
+ : {
1034
+ xAxis: { type: "category", data: categories, ...axisNameFields("x", categoryTitle) },
1035
+ yAxis: { ...valueAxis, ...yAxisName(axisTitle(block.labels, y)) },
1036
+ };
1037
+ // A table with no row still draws one empty panel, thus the chart keeps its container.
1038
+ const layout = facetLayout(names.length > 0 ? names : [undefined], shared, legendOf(groups.map((name) => categoryName(name))), 0);
1039
+ return ok({ grid: layout.grid, xAxis: layout.xAxis, yAxis: layout.yAxis, series, graphic: layout.graphic, legend: layout.legend });
1040
+ }
487
1041
  /** Per-column axis inference, and one line series per group whose data sorts by x. */
488
1042
  function deriveLine(block, rows, columns) {
489
- const xResult = requireColumn(block, rows, columns, "x");
490
- if (xResult.isErr())
491
- return err(xResult.error);
492
- const yResult = requireColumn(block, rows, columns, "y");
493
- if (yResult.isErr())
494
- return err(yResult.error);
495
- const x = xResult.value;
496
- const y = yResult.value;
497
- const series = groupedSeries(rows, block.encoding.group, (groupRows, name) => ({
498
- type: "line",
499
- ...(name !== undefined ? { name: categoryName(name) } : {}),
500
- ...nullCategoryStyle(name),
501
- showSymbol: false,
502
- data: sortByX(groupRows.map((row) => [row[x], row[y]])),
503
- }));
504
- return ok({
505
- xAxis: inferAxis(rows, x, "x", axisTitle(block.labels, x)),
506
- yAxis: inferAxis(rows, y, "y", axisTitle(block.labels, y)),
507
- series,
508
- });
1043
+ return derivePointForm(block, rows, columns, "line");
509
1044
  }
510
1045
  /** The same axis inference as a line chart, with no sort and the large-render flags. */
511
1046
  function deriveScatter(block, rows, columns) {
1047
+ return derivePointForm(block, rows, columns, "scatter");
1048
+ }
1049
+ /**
1050
+ * The base rule of a line and of a scatter: two inferred axes, and one series per group.
1051
+ *
1052
+ * A line sorts its pairs by x, because a line that zigzags states an order that no column holds. A scatter
1053
+ * keeps the row order, and it takes the large-render path of the chart runtime.
1054
+ */
1055
+ function derivePointForm(block, rows, columns, form) {
512
1056
  const xResult = requireColumn(block, rows, columns, "x");
513
1057
  if (xResult.isErr())
514
1058
  return err(xResult.error);
@@ -517,29 +1061,36 @@ function deriveScatter(block, rows, columns) {
517
1061
  return err(yResult.error);
518
1062
  const x = xResult.value;
519
1063
  const y = yResult.value;
520
- const series = groupedSeries(rows, block.encoding.group, (groupRows, name) => ({
521
- type: "scatter",
1064
+ const group = block.encoding.group;
1065
+ const focused = focusSet(block.id, block.focus, rows.length, group === undefined ? [] : firstAppearance(rows.map((row) => row[group])));
1066
+ if (focused.isErr())
1067
+ return err(focused.error);
1068
+ const xAxis = orderedAxis(block.id, rows, x, "x", axisTitles(block.labels, x), block.orders.x, namesCategories(block.meanings, x));
1069
+ if (xAxis.isErr())
1070
+ return err(xAxis.error);
1071
+ const yAxis = orderedAxis(block.id, rows, y, "y", axisTitles(block.labels, y), block.orders.y, namesCategories(block.meanings, y));
1072
+ if (yAxis.isErr())
1073
+ return err(yAxis.error);
1074
+ // A line runs along its x axis. An ordered category axis sets that order, and every other axis reads the cells.
1075
+ const places = block.orders.x !== undefined ? categoryPlaces(xAxis.value) : undefined;
1076
+ const xText = xAxis.value.type === "category";
1077
+ const yText = yAxis.value.type === "category";
1078
+ const pairOf = (row) => [xText ? categoryCell(row[x]) : row[x], yText ? categoryCell(row[y]) : row[y]];
1079
+ const series = groupedSeries(rows, group, (groupRows, name) => ({
1080
+ type: form,
522
1081
  ...(name !== undefined ? { name: categoryName(name) } : {}),
523
- ...nullCategoryStyle(name),
524
- large: true,
525
- largeThreshold: 2000,
526
- data: groupRows.map((row) => [row[x], row[y]]),
1082
+ ...categoryStyle(name, focused.value),
1083
+ ...(form === "line" ? { showSymbol: false } : { large: true, largeThreshold: 2000 }),
1084
+ data: form === "line" ? sortByX(groupRows.map(pairOf), places) : groupRows.map(pairOf),
527
1085
  }));
528
- return ok({
529
- xAxis: inferAxis(rows, x, "x", axisTitle(block.labels, x)),
530
- yAxis: inferAxis(rows, y, "y", axisTitle(block.labels, y)),
531
- series,
532
- });
1086
+ return ok({ xAxis: xAxis.value, yAxis: yAxis.value, series });
533
1087
  }
534
1088
  /**
535
- * The x axis of a histogram.
536
- *
537
- * The axis carries a bin range and not the cells of the column, thus it stays bare where the author names
538
- * nothing. A declared label names the quantity that the bins measure, and the axis then carries it.
1089
+ * The x axis of a histogram. The axis carries the bin range of the column, and a value axis always names its
1090
+ * quantity: the declared label of the column, or the raw column name.
539
1091
  */
540
1092
  function histogramXAxis(labels, column) {
541
- const label = declaredForColumn(labels, column);
542
- return { type: "value", scale: true, ...(label !== undefined ? xAxisName(label) : {}) };
1093
+ return { type: "value", scale: true, ...xAxisName(axisTitle(labels, column)) };
543
1094
  }
544
1095
  /** Equal-width bins over the global range. Each group shares the same edges. */
545
1096
  function deriveHistogram(block, rows, columns) {
@@ -577,7 +1128,13 @@ function deriveHistogram(block, rows, columns) {
577
1128
  series,
578
1129
  });
579
1130
  }
580
- /** A five-number summary per category, plus a paired scatter series for the outliers. */
1131
+ /**
1132
+ * A five-number summary per category, plus a paired scatter series for the outliers.
1133
+ *
1134
+ * A slot of fewer than five values holds no box, thus it draws its median as a short line, and its points
1135
+ * show each value where the chart draws points. A grouped box draws its points on the hidden slot axis, each
1136
+ * point inside the slot of its group.
1137
+ */
581
1138
  function deriveBox(block, rows, columns) {
582
1139
  const xResult = requireColumn(block, rows, columns, "x");
583
1140
  if (xResult.isErr())
@@ -587,21 +1144,53 @@ function deriveBox(block, rows, columns) {
587
1144
  return err(yResult.error);
588
1145
  const x = xResult.value;
589
1146
  const y = yResult.value;
590
- const categories = firstAppearance(rows.map((row) => row[x]));
591
- const groups = splitGroups(rows, block.encoding.group);
1147
+ const ordered = orderQuickCategories(block.id, rows, x, block.orders.x);
1148
+ if (ordered.isErr())
1149
+ return err(ordered.error);
1150
+ const categories = ordered.value;
1151
+ const groupColumn = block.encoding.group;
1152
+ const groups = groupColumn === undefined ? [{ name: undefined }] : firstAppearance(rows.map((row) => row[groupColumn])).map((name) => ({ name }));
1153
+ const bound = slotFault(block.id, categories.length, groups.length);
1154
+ if (bound !== undefined)
1155
+ return err(bound);
1156
+ const focused = focusSet(block.id, block.focus, rows.length, groups.flatMap((group) => (group.name === undefined ? [] : [group.name])));
1157
+ if (focused.isErr())
1158
+ return err(focused.error);
592
1159
  const series = [];
593
- for (const group of groups) {
1160
+ const buckets = slotBuckets(rows, x, y, groupColumn);
1161
+ const valuesOf = groups.map((group) => categories.map((category) => buckets.get(slotKey(group.name, category)) ?? []));
1162
+ const pointed = drawsDistributionPoints(valuesOf.flat());
1163
+ // The axis jitter centers each point on its category and not on its slot, thus a grouped box places its
1164
+ // points on the slot axis.
1165
+ const grouped = groups.length > 1;
1166
+ const points = [];
1167
+ const slotted = [];
1168
+ for (const [place, group] of groups.entries()) {
1169
+ const color = focusColor(focused.value, group.name);
1170
+ const slot = boxSlot(place, groups.length);
594
1171
  const boxData = [];
595
1172
  const outliers = [];
1173
+ const slotPointList = [];
1174
+ const medians = [];
596
1175
  for (let index = 0; index < categories.length; index++) {
597
- const values = numericColumn(group.rows.filter((row) => row[x] === categories[index]), y);
598
- // A category with fewer than five values renders as an empty box.
599
- if (values.length < 5) {
1176
+ const values = valuesOf[place][index];
1177
+ if (pointed && grouped)
1178
+ slotPointList.push(...slotPoints(values, index, slot));
1179
+ else if (pointed) {
1180
+ for (const value of values)
1181
+ points.push([index, value]);
1182
+ }
1183
+ if (values.length < BOX_MIN_VALUES) {
600
1184
  boxData.push("-");
1185
+ if (values.length > 0)
1186
+ medians.push(medianLine(values, index, slot));
601
1187
  continue;
602
1188
  }
603
1189
  const summary = boxSummary(values);
604
1190
  boxData.push(summary.box);
1191
+ // The points of a category already draw each outlier.
1192
+ if (pointed)
1193
+ continue;
605
1194
  for (const outlier of summary.outliers) {
606
1195
  outliers.push([index, outlier]);
607
1196
  }
@@ -609,192 +1198,1121 @@ function deriveBox(block, rows, columns) {
609
1198
  series.push({
610
1199
  type: "boxplot",
611
1200
  ...(group.name !== undefined ? { name: categoryName(group.name) } : {}),
1201
+ // A box draws its median in the border color, thus a focus colors the border and never the fill.
1202
+ ...(color !== undefined ? { itemStyle: { borderColor: color } } : {}),
612
1203
  data: boxData,
613
1204
  });
614
- // The outlier scatter pairs with its box series, thus it only appears when an outlier exists.
1205
+ // The outlier scatter pairs with its box series, thus it only appears when an outlier exists. A box with
1206
+ // no group takes no palette slot by name, thus its outliers take the ink of the points.
615
1207
  if (outliers.length > 0) {
1208
+ const outlierColor = color ?? (group.name === undefined ? CHART_INK : undefined);
616
1209
  series.push({
617
1210
  type: "scatter",
618
1211
  ...(group.name !== undefined ? { name: categoryName(group.name) } : {}),
1212
+ ...(outlierColor !== undefined ? { itemStyle: { color: outlierColor } } : {}),
619
1213
  symbolSize: 4,
620
1214
  data: outliers,
621
1215
  });
622
1216
  }
1217
+ const onSlots = slotSeries(group.name === undefined ? undefined : categoryName(group.name), slotPointList, medians, grouped ? color : CHART_INK);
1218
+ if (onSlots !== undefined)
1219
+ slotted.push(onSlots);
1220
+ }
1221
+ const pointName = groups[0]?.name;
1222
+ if (points.length > 0)
1223
+ series.push(pointSeries(points, pointName === undefined ? undefined : categoryName(pointName)));
1224
+ series.push(...slotted);
1225
+ const categoryAxis = {
1226
+ type: "category",
1227
+ data: categories,
1228
+ ...axisNameFields("x", categoryAxisTitle(block.labels, x)),
1229
+ ...(points.length > 0 ? JITTERED_AXIS : {}),
1230
+ };
1231
+ return ok({
1232
+ xAxis: slotted.length > 0 ? [categoryAxis, slotAxis(categories.length)] : categoryAxis,
1233
+ yAxis: { type: "value", ...yAxisName(axisTitle(block.labels, y)) },
1234
+ series,
1235
+ });
1236
+ }
1237
+ /** The fewest values of a slot that draw a box. */
1238
+ const BOX_MIN_VALUES = 5;
1239
+ /** The share of each box slot that the chart runtime leaves as the gap between two boxes. */
1240
+ const BOX_GAP_SHARE = 0.3;
1241
+ /**
1242
+ * The slot of one box of a grouped box chart, as the chart runtime lays out its boxes: the boxes of one category
1243
+ * share 80 percent of the band, and a gap of 30 percent of one share sits between two boxes.
1244
+ */
1245
+ function boxSlot(place, count) {
1246
+ const gap = (GROUP_SPAN / count) * BOX_GAP_SHARE;
1247
+ const width = (GROUP_SPAN - gap * (count - 1)) / count;
1248
+ return { center: roundFraction(width / 2 - GROUP_SPAN / 2 + place * (gap + width)), width: roundFraction(width) };
1249
+ }
1250
+ /** The slot of one violin of a grouped violin chart: the slot of a grouped bar. */
1251
+ function violinSlot(place, count) {
1252
+ return { center: slotOffset(place, count), width: roundFraction(GROUP_SPAN / count) };
1253
+ }
1254
+ /** A fraction of a band, rounded to keep a float residue out of the option. */
1255
+ function roundFraction(value) {
1256
+ return Math.round(value * 1e6) / 1e6;
1257
+ }
1258
+ /**
1259
+ * The hidden value axis over the category bands of a box or a violin. The value `i` sits at the center of the
1260
+ * category `i`, as on the category axis, thus a point or a median line states its place inside a slot.
1261
+ */
1262
+ function slotAxis(count) {
1263
+ return { type: "value", min: -0.5, max: count - 0.5, show: false };
1264
+ }
1265
+ /** The part of its slot that the points of one slot spread over, and the part that a median line spans. */
1266
+ const SLOT_POINT_SPREAD = 0.7;
1267
+ const MEDIAN_LINE_SPAN = 0.5;
1268
+ /**
1269
+ * The step of the spread of the points of one slot: the golden ratio conjugate. The places of the successive
1270
+ * points fill the slot evenly and never repeat, and the same values give the same places.
1271
+ */
1272
+ const SPREAD_STEP = 0.6180339887;
1273
+ /** The stroke width in pixels of the median line of a thin slot. */
1274
+ const MEDIAN_LINE_WIDTH_PX = 2;
1275
+ /** The points of one slot as `[band place, value]`, each value at its place across the slot, in row order. */
1276
+ function slotPoints(values, index, slot) {
1277
+ return values.map((value, place) => {
1278
+ const spread = ((((place + 0.5) * SPREAD_STEP) % 1) - 0.5) * SLOT_POINT_SPREAD * slot.width;
1279
+ return [roundFraction(index + slot.center + spread), value];
1280
+ });
1281
+ }
1282
+ /** The median line of one slot that draws no shape: a short line across the middle of the slot at the median. */
1283
+ function medianLine(values, index, slot) {
1284
+ const median = quantileType7([...values].sort((a, b) => a - b), 0.5);
1285
+ const half = (MEDIAN_LINE_SPAN * slot.width) / 2;
1286
+ return [{ coord: [roundFraction(index + slot.center - half), median] }, { coord: [roundFraction(index + slot.center + half), median] }];
1287
+ }
1288
+ /**
1289
+ * The series of one group on the slot axis: its points, and the median line of each slot that draws no shape.
1290
+ * A group with neither gives no series.
1291
+ *
1292
+ * A group takes the color of its name, as its box or its violin does, thus a thin slot reads as its group. A
1293
+ * chart with no group draws in the ink. The median line takes the color of its series.
1294
+ */
1295
+ function slotSeries(name, points, medians, color) {
1296
+ if (points.length === 0 && medians.length === 0)
1297
+ return undefined;
1298
+ return {
1299
+ ...pointSeries(points, name),
1300
+ itemStyle: { ...(color !== undefined ? { color } : {}), opacity: DISTRIBUTION_POINT_OPACITY },
1301
+ xAxisIndex: 1,
1302
+ ...(medians.length > 0
1303
+ ? {
1304
+ markLine: {
1305
+ silent: true,
1306
+ symbol: "none",
1307
+ label: { show: false },
1308
+ lineStyle: { width: MEDIAN_LINE_WIDTH_PX, type: "solid", opacity: 1 },
1309
+ data: medians.map((line) => [...line]),
1310
+ },
1311
+ }
1312
+ : {}),
1313
+ };
1314
+ }
1315
+ /**
1316
+ * The count of values up to which one category of a box or a violin draws each value as a point. A summary
1317
+ * of a few values hides them, and past the count the points paint one band over the shape.
1318
+ */
1319
+ const DISTRIBUTION_POINT_LIMIT = 200;
1320
+ /**
1321
+ * True when a box or a violin draws its points: each category holds `DISTRIBUTION_POINT_LIMIT` values or
1322
+ * fewer. One chart draws the points on every category or on none, thus no category reads as a summary alone
1323
+ * beside a category that shows each value.
1324
+ */
1325
+ function drawsDistributionPoints(categoryValues) {
1326
+ return categoryValues.every((values) => values.length <= DISTRIBUTION_POINT_LIMIT);
1327
+ }
1328
+ /** The symbol size and the opacity of each point over a box or a violin. The shape stays readable under them. */
1329
+ const DISTRIBUTION_POINT_SIZE = 3;
1330
+ const DISTRIBUTION_POINT_OPACITY = 0.6;
1331
+ /**
1332
+ * The category axis of a box or a violin with points: the chart runtime spreads the points of each category
1333
+ * across its band. `jitterOverlap: false` moves a point aside only where it overlaps an earlier one, thus a
1334
+ * point stays at its value and the spread shows the density.
1335
+ */
1336
+ const JITTERED_AXIS = { jitter: 24, jitterOverlap: false, jitterMargin: 0 };
1337
+ /** The points over a box or a violin: one small point in the ink for each value, as `[category, value]`. */
1338
+ function pointSeries(points, name) {
1339
+ return {
1340
+ type: "scatter",
1341
+ ...(name !== undefined ? { name } : {}),
1342
+ silent: true,
1343
+ symbolSize: DISTRIBUTION_POINT_SIZE,
1344
+ itemStyle: { color: CHART_INK, opacity: DISTRIBUTION_POINT_OPACITY },
1345
+ z: 5,
1346
+ data: points,
1347
+ };
1348
+ }
1349
+ /** One slice per group category, in first-appearance order. A repeated category refuses. */
1350
+ function derivePie(block, rows, columns) {
1351
+ for (const channel of ["group", "value"]) {
1352
+ if (block.encoding[channel] === undefined) {
1353
+ // An author reaches for `x` on a pie as on a bar, thus the refusal names the two channels of a slice.
1354
+ return err(problem(block.id, `The pie chart reads no "x" and no "y". It names its slices with the "group" column and sizes them with the "value" column. Give a column for the "${channel}" channel.`));
1355
+ }
1356
+ }
1357
+ const groupResult = requireColumn(block, rows, columns, "group");
1358
+ if (groupResult.isErr())
1359
+ return err(groupResult.error);
1360
+ const valueResult = requireColumn(block, rows, columns, "value");
1361
+ if (valueResult.isErr())
1362
+ return err(valueResult.error);
1363
+ const groupColumn = groupResult.value;
1364
+ const valueColumn = valueResult.value;
1365
+ const seen = new Set();
1366
+ const data = [];
1367
+ for (const row of rows) {
1368
+ const name = String(row[groupColumn]);
1369
+ if (seen.has(name)) {
1370
+ return err(problem(block.id, `The pie holds the category "${name}" more than one time.`));
1371
+ }
1372
+ seen.add(name);
1373
+ const numeric = toNumber(row[valueColumn]);
1374
+ data.push({ name, value: numeric === null ? row[valueColumn] : numeric });
1375
+ }
1376
+ return ok({
1377
+ series: [{ type: "pie", radius: "55%", data }],
1378
+ });
1379
+ }
1380
+ /**
1381
+ * One violin for each category and group: the Gaussian density of the values, and the inner mark of the
1382
+ * quartiles and the median.
1383
+ *
1384
+ * The outline is a custom series with the `outline` renderer, and the inner mark is a custom series with the
1385
+ * `interval` renderer. The half-width at each grid point is the density over the largest density of the
1386
+ * chart, times the half of one group slot. Thus the widest violin of the chart fills its slot, and each other
1387
+ * violin reads against it. A group channel draws one violin for each group, offset inside the band as the
1388
+ * grouped bar is.
1389
+ *
1390
+ * A category with fewer than five values, or with a bandwidth of zero, holds no density worth a shape, thus
1391
+ * it draws its median as a short line, and its points show each value where the chart draws points. A grouped
1392
+ * violin draws its points on the hidden slot axis, each point inside the slot of its group.
1393
+ */
1394
+ function deriveViolin(block, rows, columns) {
1395
+ const xResult = requireColumn(block, rows, columns, "x");
1396
+ if (xResult.isErr())
1397
+ return err(xResult.error);
1398
+ const yResult = requireColumn(block, rows, columns, "y");
1399
+ if (yResult.isErr())
1400
+ return err(yResult.error);
1401
+ const x = xResult.value;
1402
+ const y = yResult.value;
1403
+ const ordered = orderQuickCategories(block.id, rows, x, block.orders.x);
1404
+ if (ordered.isErr())
1405
+ return err(ordered.error);
1406
+ const categories = ordered.value;
1407
+ const groupColumn = block.encoding.group;
1408
+ const groups = groupColumn === undefined ? [{ name: undefined }] : firstAppearance(rows.map((row) => row[groupColumn])).map((name) => ({ name }));
1409
+ const bound = slotFault(block.id, categories.length, groups.length);
1410
+ if (bound !== undefined)
1411
+ return err(bound);
1412
+ const named = groups.flatMap((group) => (group.name === undefined ? [] : [group.name]));
1413
+ const focused = focusSet(block.id, block.focus, rows.length, named);
1414
+ if (focused.isErr())
1415
+ return err(focused.error);
1416
+ const buckets = slotBuckets(rows, x, y, groupColumn);
1417
+ const shapes = groups.map((group) => categories.flatMap((category, index) => {
1418
+ const shape = violinShape(buckets.get(slotKey(group.name, category)) ?? []);
1419
+ return shape === undefined ? [] : [{ ...shape, category: index }];
1420
+ }));
1421
+ let largest = 0;
1422
+ for (const shape of shapes.flat()) {
1423
+ for (const density of shape.densities)
1424
+ largest = Math.max(largest, density);
1425
+ }
1426
+ const valuesOf = groups.map((group) => categories.map((category) => buckets.get(slotKey(group.name, category)) ?? []));
1427
+ const pointed = drawsDistributionPoints(valuesOf.flat());
1428
+ // The axis jitter centers each point on its category and not on its slot, thus a grouped violin places its
1429
+ // points on the slot axis.
1430
+ const grouped = groups.length > 1;
1431
+ const slotted = [];
1432
+ const series = [];
1433
+ const legend = [];
1434
+ for (const [place, group] of groups.entries()) {
1435
+ const name = group.name !== undefined ? categoryName(group.name) : axisTitle(block.labels, y);
1436
+ const offset = slotOffset(place, groups.length);
1437
+ const slot = violinSlot(place, groups.length);
1438
+ const shaped = new Set(shapes[place].map((shape) => shape.category));
1439
+ const medians = valuesOf[place].flatMap((values, index) => (shaped.has(index) || values.length === 0 ? [] : [medianLine(values, index, slot)]));
1440
+ const slotPointList = pointed && grouped ? valuesOf[place].flatMap((values, index) => slotPoints(values, index, slot)) : [];
1441
+ const color = focusColor(focused.value, group.name);
1442
+ const onSlots = slotSeries(name, slotPointList, medians, grouped ? color : CHART_INK);
1443
+ if (onSlots !== undefined)
1444
+ slotted.push(onSlots);
1445
+ const halfWidth = VIOLIN_HALF_WIDTH / groups.length;
1446
+ series.push({
1447
+ type: "custom",
1448
+ name,
1449
+ renderItem: OUTLINE_RENDERER,
1450
+ silent: true,
1451
+ encode: { x: 0, y: [1, 2] },
1452
+ ...(color !== undefined ? { itemStyle: { color } } : nullCategoryStyle(group.name)),
1453
+ data: shapes[place].map((shape) => [
1454
+ shape.category,
1455
+ shape.grid[0],
1456
+ shape.grid[shape.grid.length - 1],
1457
+ offset,
1458
+ ...shape.grid.flatMap((value, point) => [roundWidth(largest > 0 ? (shape.densities[point] / largest) * halfWidth : 0), value]),
1459
+ ]),
1460
+ });
1461
+ series.push({
1462
+ type: "custom",
1463
+ name,
1464
+ renderItem: INTERVAL_RENDERER,
1465
+ silent: true,
1466
+ encode: { x: 0, y: [1, 2, 3] },
1467
+ data: shapes[place].map((shape) => [shape.category, shape.median, shape.q1, shape.q3, 1, offset, 1]),
1468
+ });
1469
+ legend.push(name);
1470
+ }
1471
+ const points = [];
1472
+ if (pointed && !grouped) {
1473
+ for (const [index, values] of (valuesOf[0] ?? []).entries()) {
1474
+ for (const value of values)
1475
+ points.push([index, value]);
1476
+ }
623
1477
  }
1478
+ if (points.length > 0)
1479
+ series.push(pointSeries(points, legend[0]));
1480
+ series.push(...slotted);
1481
+ const categoryAxis = {
1482
+ type: "category",
1483
+ data: categories,
1484
+ ...axisNameFields("x", categoryAxisTitle(block.labels, x)),
1485
+ ...(points.length > 0 ? JITTERED_AXIS : {}),
1486
+ };
624
1487
  return ok({
625
- xAxis: { type: "category", data: categories, ...xAxisName(axisTitle(block.labels, x)) },
626
- yAxis: { type: "value", name: axisTitle(block.labels, y) },
1488
+ xAxis: slotted.length > 0 ? [categoryAxis, slotAxis(categories.length)] : categoryAxis,
1489
+ yAxis: { type: "value", scale: true, ...yAxisName(axisTitle(block.labels, y)) },
627
1490
  series,
1491
+ legend: legendOf(legend),
628
1492
  });
629
1493
  }
630
- /** A dense grid over every pair of x and y categories, with a viridis `visualMap`. */
631
- function deriveHeatmap(block, rows, columns) {
1494
+ /**
1495
+ * One polygon for each group over the categories of `x` as the indicators.
1496
+ *
1497
+ * The indicator bound is the largest `y` value of the table, thus each polygon reads against one scale. A
1498
+ * repeated pair is a refusal, because a radar plots one cell for each pair and never a sum. An absent pair
1499
+ * holds `null`, and the chart runtime leaves a gap there. Each indicator runs from zero at the center, and the
1500
+ * chart runtime draws a negative value past the center on the far side, thus a negative value refuses and
1501
+ * names its row.
1502
+ */
1503
+ function deriveRadar(block, rows, columns) {
632
1504
  const xResult = requireColumn(block, rows, columns, "x");
633
1505
  if (xResult.isErr())
634
1506
  return err(xResult.error);
635
1507
  const yResult = requireColumn(block, rows, columns, "y");
636
1508
  if (yResult.isErr())
637
1509
  return err(yResult.error);
638
- const valueResult = requireColumn(block, rows, columns, "value");
639
- if (valueResult.isErr())
640
- return err(valueResult.error);
1510
+ const groupResult = requireColumn(block, rows, columns, "group");
1511
+ if (groupResult.isErr())
1512
+ return err(groupResult.error);
641
1513
  const x = xResult.value;
642
1514
  const y = yResult.value;
643
- const valueColumn = valueResult.value;
644
- const xCategories = firstAppearance(rows.map((row) => row[x]));
645
- const yCategories = firstAppearance(rows.map((row) => row[y]));
1515
+ const group = groupResult.value;
1516
+ const categories = firstAppearance(rows.map((row) => row[x]));
1517
+ const groups = firstAppearance(rows.map((row) => row[group]));
1518
+ const bound = slotFault(block.id, categories.length, groups.length);
1519
+ if (bound !== undefined)
1520
+ return err(bound);
1521
+ const focused = focusSet(block.id, block.focus, rows.length, groups);
1522
+ if (focused.isErr())
1523
+ return err(focused.error);
646
1524
  const cells = new Map();
647
- for (const row of rows) {
648
- const key = pairKey(row[x], row[y]);
1525
+ let largest;
1526
+ for (const [index, row] of rows.entries()) {
1527
+ const key = pairKey(row[x], row[group]);
649
1528
  if (cells.has(key)) {
650
- return err(problem(block.id, `The heatmap holds the pair (${String(row[x])}, ${String(row[y])}) more than one time.`));
1529
+ return err(problem(block.id, `The radar holds the pair (${String(row[x])}, ${String(row[group])}) more than one time.`));
651
1530
  }
652
- cells.set(key, toNumber(row[valueColumn]));
653
- }
654
- const data = [];
655
- const finite = [];
656
- for (let xi = 0; xi < xCategories.length; xi++) {
657
- for (let yi = 0; yi < yCategories.length; yi++) {
658
- const stored = cells.get(pairKey(xCategories[xi], yCategories[yi]));
659
- const value = stored === undefined ? null : stored;
660
- data.push([xi, yi, value]);
661
- if (value !== null)
662
- finite.push(value);
1531
+ const value = toNumber(row[y]);
1532
+ if (value !== null && value < 0) {
1533
+ return err(problem(block.id, `The row ${index + 1} holds the negative value ${value}. A radar measures each indicator from zero at its center.`));
663
1534
  }
1535
+ cells.set(key, value);
1536
+ if (value !== null)
1537
+ largest = largest === undefined ? value : Math.max(largest, value);
1538
+ }
1539
+ if (rows.length > 0 && (largest === undefined || largest <= 0)) {
1540
+ // The largest value bounds each indicator. A bound of zero or less draws no polygon that a reader can read.
1541
+ return err(problem(block.id, 'The radar holds no positive "y" value, thus its indicators have no bound.'));
664
1542
  }
665
- const min = finite.length > 0 ? Math.min(...finite) : 0;
666
- const max = finite.length > 0 ? Math.max(...finite) : 1;
1543
+ const names = groups.map((name) => categoryName(name));
667
1544
  return ok({
668
- xAxis: { type: "category", data: xCategories.map(String), ...xAxisName(axisTitle(block.labels, x)), splitArea: { show: true } },
669
- yAxis: { type: "category", data: yCategories.map(String), name: axisTitle(block.labels, y), splitArea: { show: true } },
670
- visualMap: {
671
- type: "continuous",
672
- min,
673
- max,
674
- calculable: true,
675
- orient: "horizontal",
676
- left: "center",
677
- bottom: 0,
678
- inRange: { color: [...VIRIDIS] },
1545
+ radar: {
1546
+ indicator: categories.map((category) => ({ name: String(category), max: largest ?? 1 })),
1547
+ radius: RADAR_RADIUS,
679
1548
  },
680
- series: [{ type: "heatmap", data }],
1549
+ series: [
1550
+ {
1551
+ type: "radar",
1552
+ data: groups.map((name, index) => ({
1553
+ name: names[index],
1554
+ value: categories.map((category) => cells.get(pairKey(category, name)) ?? null),
1555
+ ...categoryStyle(name, focused.value),
1556
+ })),
1557
+ },
1558
+ ],
1559
+ legend: legendOf(names),
681
1560
  });
682
1561
  }
683
- /** One slice per group category, in first-appearance order. A repeated category refuses. */
684
- function derivePie(block, rows, columns) {
685
- const groupResult = requireColumn(block, rows, columns, "group");
686
- if (groupResult.isErr())
687
- return err(groupResult.error);
688
- const valueResult = requireColumn(block, rows, columns, "value");
689
- if (valueResult.isErr())
690
- return err(valueResult.error);
691
- const groupColumn = groupResult.value;
692
- const valueColumn = valueResult.value;
693
- const seen = new Set();
694
- const data = [];
1562
+ /** The numeric `y` values of each slot in row order, keyed by `slotKey`, in one pass over the rows. */
1563
+ function slotBuckets(rows, x, y, groupColumn) {
1564
+ const buckets = new Map();
695
1565
  for (const row of rows) {
696
- const name = String(row[groupColumn]);
697
- if (seen.has(name)) {
698
- return err(problem(block.id, `The pie holds the category "${name}" more than one time.`));
1566
+ const value = toNumber(row[y]);
1567
+ if (value === null)
1568
+ continue;
1569
+ const key = slotKey(groupColumn === undefined ? undefined : row[groupColumn], row[x]);
1570
+ const bucket = buckets.get(key);
1571
+ if (bucket === undefined)
1572
+ buckets.set(key, [value]);
1573
+ else
1574
+ bucket.push(value);
1575
+ }
1576
+ return buckets;
1577
+ }
1578
+ /** The key of one slot: a group value and a category value. Each cell keeps its type, as a pair key does. */
1579
+ function slotKey(group, category) {
1580
+ return JSON.stringify([group === undefined ? null : cellKey(group), category === undefined ? null : cellKey(category)]);
1581
+ }
1582
+ /**
1583
+ * The refusal of a chart whose categories and groups give more slots than the bound, or `undefined` for a chart
1584
+ * inside the bound.
1585
+ *
1586
+ * A stacked form, a radar, a violin, a box, and a heatmap each lay out one slot for each pair of a category and a
1587
+ * group. A sparse table of many categories and many groups gives a grid that no reader reads and that the
1588
+ * memory of the render cannot hold, thus the render refuses it before it builds one slot.
1589
+ */
1590
+ function slotFault(blockId, categories, groups) {
1591
+ const slots = categories * groups;
1592
+ if (slots <= CHART_SLOT_LIMIT)
1593
+ return undefined;
1594
+ return problem(blockId, `The chart holds ${categories} categories and ${groups} groups, thus ${slots} slots. A chart holds ${CHART_SLOT_LIMIT} slots at most, thus a table of fewer categories or fewer groups serves the reader.`);
1595
+ }
1596
+ /** The radius of a radar. The legend and the indicator names sit outside it. */
1597
+ const RADAR_RADIUS = "62%";
1598
+ /** The tier of one row count. The two counts are constants of the design source. */
1599
+ function scatterDensity(rowCount) {
1600
+ if (rowCount > SCATTER_CROWD_ROWS)
1601
+ return "crowd";
1602
+ if (rowCount > SCATTER_HOVER_ROWS)
1603
+ return "hover";
1604
+ return "normal";
1605
+ }
1606
+ /** The point count from which the scatter renderer takes its large path. */
1607
+ const LARGE_SCATTER_THRESHOLD = 2000;
1608
+ /** The opacity of the band that an `area` series draws between its two bounds. */
1609
+ const BAND_OPACITY = 0.25;
1610
+ /** The forms whose data runs along the x axis. A line that zigzags states an order that no column holds. */
1611
+ const SORTED_FORMS = new Set(["line", "area", "step"]);
1612
+ /**
1613
+ * The tooltip of a composition whose points carry no name.
1614
+ *
1615
+ * `{a}` is the series name, and `{c}` is the value of the item. The text is a static template of the
1616
+ * chart runtime, thus the option carries no function and the inline JSON stays a pure value.
1617
+ */
1618
+ const PLAIN_TOOLTIP = { trigger: "item", formatter: "{a}: {c}" };
1619
+ /** The tooltip of a composition whose points carry a name. `{b}` is the name of the item. */
1620
+ const NAMED_TOOLTIP = { trigger: "item", formatter: "{b}<br/>{a}: {c}" };
1621
+ /**
1622
+ * The label of one named point. `{b}` is the name of the item, thus the label needs no function.
1623
+ *
1624
+ * A page-side series build writes the same member onto a flagged point, thus one constant answers for the
1625
+ * inline form and the payload form alike.
1626
+ */
1627
+ export const POINT_LABEL = { show: true, formatter: "{b}" };
1628
+ /**
1629
+ * The member of the option that carries the data source.
1630
+ *
1631
+ * The chart runtime reads no member of this name, and the page bootstrap removes it before it sets the
1632
+ * option. The name leads with two underscores, thus no reader mistakes it for a field of the runtime.
1633
+ */
1634
+ export const CHART_SOURCE_MEMBER = "__reportData";
1635
+ function deriveComposition(blockId, composition, rows, columns, labels, extras = {}) {
1636
+ const { preset, classification, collector } = extras;
1637
+ if (composition.series.length > 1 && composition.series.some(isHorizontalBar)) {
1638
+ // A horizontal bar reads its categories up the y axis, and every other form reads a value there.
1639
+ // The two share no honest axis pair on one grid, thus the mix refuses instead of plotting a lie.
1640
+ return err(problem(blockId, "A horizontal bar plots its categories on the y axis, thus it shares no axis pair with another series."));
1641
+ }
1642
+ const fault = compositionFault(composition, extras.focus);
1643
+ if (fault !== undefined) {
1644
+ return err(problem(blockId, fault));
1645
+ }
1646
+ const resolved = [];
1647
+ for (const declared of composition.series) {
1648
+ const series = resolveSeries(blockId, declared, rows, columns);
1649
+ if (series.isErr())
1650
+ return err(series.error);
1651
+ resolved.push(series.value);
1652
+ }
1653
+ if (resolved.length === 0) {
1654
+ // The schema holds a composition to one series at least. The guard states the same rule for a
1655
+ // value that reaches the renderer without a parse, because the axes come from the first series.
1656
+ return err(problem(blockId, "The composition carries no series."));
1657
+ }
1658
+ const annotations = composition.annotations ?? [];
1659
+ const labeled = pointLabelRows(blockId, annotations, rows, columns, plottedRows(resolved, rows.length));
1660
+ if (labeled.isErr())
1661
+ return err(labeled.error);
1662
+ const focused = focusSet(blockId, extras.focus, rows.length, compositionCategories(resolved, rows.length));
1663
+ if (focused.isErr())
1664
+ return err(focused.error);
1665
+ if (collector !== undefined && classification !== undefined) {
1666
+ // The page splits the rows against the same cuts, thus the rule rides beside the descriptors. A rule
1667
+ // that reads a column of its own needs that column in the payload.
1668
+ collector.rule = classification.rule;
1669
+ if (classification.rule.kind === "ma" && !collector.columns.includes(classification.rule.column)) {
1670
+ collector.failed = true;
1671
+ }
1672
+ }
1673
+ // A figure titles the transform that it applies itself with its semantic title, thus only an authored
1674
+ // transform takes the label of its column inside the transform.
1675
+ const channels = resolved.flatMap((entry) => [entry.x, entry.y, entry.y0, entry.color, entry.size, entry.low, entry.high].flatMap((channel) => channel ?? []));
1676
+ const context = {
1677
+ blockId,
1678
+ rows,
1679
+ labels: preset === undefined ? withTransformedLabels(labels, channels) : labels,
1680
+ meanings: extras.meanings,
1681
+ preset,
1682
+ classification,
1683
+ collector,
1684
+ focus: focused.value,
1685
+ labeled: labeled.value,
1686
+ density: scatterDensity(rows.length),
1687
+ annotations,
1688
+ // The axes come from the first series, thus its order sorts the shared x axis of every series.
1689
+ orderedX: resolved[0].x.order !== undefined,
1690
+ colorOnTop: extras.colorOnTop === true,
1691
+ };
1692
+ const first = resolved[0];
1693
+ const axes = compositionAxes(context, first, composition.axes);
1694
+ if (axes.isErr())
1695
+ return err(axes.error);
1696
+ const plotted = resolved.map((entry) => plottedSeries(entry, axes.value, extras.meanings));
1697
+ if (composition.facet !== undefined) {
1698
+ return deriveFacets(context, composition.facet, columns, plotted, axes.value);
1699
+ }
1700
+ const emitted = composePanel(context, plotted, undefined, axes.value, BAND_STACK_PREFIX);
1701
+ if (emitted.isErr())
1702
+ return err(emitted.error);
1703
+ const named = resolved.some((entry) => entry.label !== undefined) || labeled.value.size > 0;
1704
+ const maps = visualMaps(context, plotted, emitted.value);
1705
+ const horizontal = isHorizontalBar(first.declared);
1706
+ const series = labeledSeries(context, emitted.value, horizontal);
1707
+ const room = first.declared.form === "bar" ? labelRoom(series, horizontal) : {};
1708
+ const colored = emitted.value.some((entry) => entry.entry?.color !== undefined);
1709
+ const grid = {
1710
+ // A horizontal bar draws its category names as y labels, thus the grid must hold them.
1711
+ ...(isHorizontalBar(first.declared) ? LABEL_CONTAINING_GRID : {}),
1712
+ // A continuous color draws its scale at the right edge, thus the grid leaves that band free.
1713
+ ...(colored ? { right: COLOR_SCALE_GRID_RIGHT } : {}),
1714
+ };
1715
+ return ok({
1716
+ tooltip: named ? { ...NAMED_TOOLTIP } : { ...PLAIN_TOOLTIP },
1717
+ xAxis: horizontal ? { ...axes.value.xAxis, ...room } : axes.value.xAxis,
1718
+ yAxis: horizontal ? axes.value.yAxis : { ...axes.value.yAxis, ...room },
1719
+ series,
1720
+ ...(maps.length > 0 ? { visualMap: maps } : {}),
1721
+ ...(emitted.value.some((entry) => entry.auxiliary) ? { legend: legendOf(drawnNames(emitted.value)) } : {}),
1722
+ ...(Object.keys(grid).length > 0 ? { grid } : {}),
1723
+ });
1724
+ }
1725
+ /** The stack prefix of a band. A facet panel adds its own place, thus two panels never stack each other. */
1726
+ const BAND_STACK_PREFIX = "band-";
1727
+ /** The right margin of a grid beside a continuous color scale. */
1728
+ const COLOR_SCALE_GRID_RIGHT = `${COLOR_SCALE_BAND_PCT}%`;
1729
+ /**
1730
+ * The fault of a composition whose forms cannot draw one of their channels, or `undefined` for a sound one.
1731
+ *
1732
+ * Each rule depends on the form, thus the grammar admits the channel and the render states the fault. A
1733
+ * silent drop would teach the author a channel that does nothing.
1734
+ */
1735
+ function compositionFault(composition, focus) {
1736
+ for (const series of composition.series) {
1737
+ const form = series.form;
1738
+ const encoding = series.encoding;
1739
+ if (encoding.color !== undefined && form !== "scatter" && form !== "bar") {
1740
+ return `The ${form} series takes no "color" channel. A continuous color is legal on a scatter and a bar.`;
1741
+ }
1742
+ if (encoding.color !== undefined && encoding.group !== undefined) {
1743
+ return COLOR_BESIDE_GROUP;
1744
+ }
1745
+ if (encoding.size !== undefined && form !== "scatter") {
1746
+ return `The ${form} series takes no "size" channel. A size is legal on a scatter alone.`;
1747
+ }
1748
+ if ((encoding.low === undefined) !== (encoding.high === undefined)) {
1749
+ return 'An interval has two bounds, thus a series gives "low" and "high" together.';
1750
+ }
1751
+ if (encoding.low !== undefined && form !== "scatter" && form !== "bar") {
1752
+ return `The ${form} series takes no interval. The "low" and "high" channels are legal on a bar and a scatter.`;
1753
+ }
1754
+ for (const name of ["y0", "group", "color", "size", "low", "high"]) {
1755
+ const channel = encoding[name];
1756
+ if (channel !== undefined && channelOrder(channel) !== undefined) {
1757
+ return `The "${name}" channel draws no category axis, thus it takes no "orderBy".`;
1758
+ }
1759
+ }
1760
+ }
1761
+ for (const [place, series] of composition.series.entries()) {
1762
+ if (place === 0)
1763
+ continue;
1764
+ for (const name of ["x", "y"]) {
1765
+ if (channelOrder(series.encoding[name]) !== undefined) {
1766
+ return `The series ${place + 1} sorts its "${name}" channel with "orderBy", and the axes of a composition read the first series alone. State the order on the first series.`;
1767
+ }
1768
+ }
1769
+ }
1770
+ for (const member of ["color", "size"]) {
1771
+ const keys = new Set(composition.series.flatMap((series) => {
1772
+ const channel = series.encoding[member];
1773
+ return channel === undefined ? [] : [`${channelColumn(channel)}:${channelTransform(channel) ?? ""}`];
1774
+ }));
1775
+ if (keys.size > 1) {
1776
+ return `Two series name two different "${member}" columns. A chart reads one "${member}" channel on one scale, thus each series names the same column.`;
1777
+ }
1778
+ }
1779
+ const facet = composition.facet;
1780
+ if (facet !== undefined) {
1781
+ if (channelOrder(facet) !== undefined) {
1782
+ return 'The "facet" channel draws no category axis, thus it takes no "orderBy".';
1783
+ }
1784
+ if (channelTransform(facet) !== undefined) {
1785
+ return FACET_TRANSFORM;
1786
+ }
1787
+ const lead = composition.series[0]?.form;
1788
+ if (lead !== undefined && lead !== "scatter" && lead !== "line" && lead !== "bar") {
1789
+ return `A facet is legal on a scatter, a line, and a bar, thus the first series of a faceted composition takes one of those forms, and not the ${lead}.`;
1790
+ }
1791
+ }
1792
+ if (focus !== undefined && composition.series.some((series) => series.encoding.color !== undefined)) {
1793
+ return FOCUS_BESIDE_COLOR;
1794
+ }
1795
+ const banded = focus === undefined
1796
+ ? undefined
1797
+ : composition.series.find((series) => (series.form === "area" || series.form === "step") && series.encoding.group !== undefined);
1798
+ if (banded !== undefined) {
1799
+ return `A focus is legal on a grouped scatter, line, or bar series, and never on the grouped ${banded.form} series.`;
1800
+ }
1801
+ return undefined;
1802
+ }
1803
+ /**
1804
+ * The categories that a focus of one composition can name: the values of each group channel, and the `x`
1805
+ * values of each bar series that splits by no group.
1806
+ */
1807
+ function compositionCategories(resolved, rowCount) {
1808
+ const categories = [];
1809
+ for (const entry of resolved) {
1810
+ const channel = entry.group ?? (entry.declared.form === "bar" ? entry.x : undefined);
1811
+ if (channel === undefined)
1812
+ continue;
1813
+ for (let index = 0; index < rowCount; index += 1) {
1814
+ const value = channel.values[index];
1815
+ if (value !== null)
1816
+ categories.push(value);
1817
+ }
1818
+ }
1819
+ return categories;
1820
+ }
1821
+ /**
1822
+ * The runtime series of one panel: each declared series over each of its splits, restricted to the rows of
1823
+ * the panel, with the interval of each series after it and the marks on one carrier.
1824
+ *
1825
+ * `members` holds the rows of a facet panel, and it is absent for a chart of one panel. The bars of one panel
1826
+ * sit side by side, thus each bar split takes its own slot of the category band and its interval takes the
1827
+ * offset of that slot.
1828
+ */
1829
+ function composePanel(context, resolved, members, axes, stackPrefix) {
1830
+ const plan = [];
1831
+ for (const entry of resolved) {
1832
+ for (const split of splitSeries(context.rows, entry, context.classification)) {
1833
+ plan.push({ entry, split: members === undefined ? split : { ...split, indices: split.indices.filter((index) => members.has(index)) } });
1834
+ }
1835
+ }
1836
+ const bars = plan.filter((item) => item.entry.declared.form === "bar" && item.entry.y0 === undefined);
1837
+ const barPlaces = new Map(bars.map((item, place) => [item, place]));
1838
+ const emitted = [];
1839
+ for (const item of plan) {
1840
+ const place = barPlaces.get(item);
1841
+ const offset = place !== undefined ? slotOffset(place, bars.length) : 0;
1842
+ const built = buildSeries(context, item.entry, item.split, `${stackPrefix}${emitted.length}`, axes, offset);
1843
+ if (built.isErr())
1844
+ return err(built.error);
1845
+ emitted.push(...built.value);
1846
+ }
1847
+ const marks = markMembers(context.annotations);
1848
+ const target = markCarrier(emitted);
1849
+ if (Object.keys(marks).length > 0 && target >= 0) {
1850
+ emitted[target] = { ...emitted[target], option: { ...emitted[target].option, ...marks } };
1851
+ }
1852
+ return ok(emitted);
1853
+ }
1854
+ /** The option of each emitted series, with the value label of each bar of a small bar chart. */
1855
+ function labeledSeries(context, emitted, horizontal) {
1856
+ return withValueLabels(emitted.map((entry) => entry.option), horizontal, (cell, index) => {
1857
+ const value = emitted[index].entry?.y;
1858
+ return value === undefined ? String(cell) : valueLabelText(value.transformed ? value.name : value.column, cell, context.meanings);
1859
+ }, (index) => emitted[index].entry?.low === undefined);
1860
+ }
1861
+ /** The names of the drawn series, in series order. An auxiliary series names no entry of its own. */
1862
+ function drawnNames(emitted) {
1863
+ return emitted.filter((entry) => !entry.auxiliary).map((entry) => String(entry.option.name));
1864
+ }
1865
+ /**
1866
+ * The visual maps of one composition: one map over the continuous color, and one map over the symbol size
1867
+ * for each place that the size takes in an item.
1868
+ *
1869
+ * Each map names the series that carry its dimension, thus no map reads an interval item. A color always sits
1870
+ * at dimension 2, thus one color map covers every colored series. A size sits at dimension 2 or 3, and the
1871
+ * size maps of the two dimensions share one range. Each scale reads the drawn points of its series, thus the
1872
+ * panels of a facet share one scale. The size map shows no scale,
1873
+ * because the size reads as a relative weight of each point.
1874
+ */
1875
+ function visualMaps(context, resolved, emitted) {
1876
+ const colored = [];
1877
+ const sizedByDimension = new Map();
1878
+ for (const [index, entry] of emitted.entries()) {
1879
+ const declared = entry.entry;
1880
+ if (entry.auxiliary || declared === undefined)
1881
+ continue;
1882
+ if (declared.color !== undefined)
1883
+ colored.push(index);
1884
+ if (declared.size !== undefined) {
1885
+ const dimension = declared.color !== undefined ? 3 : 2;
1886
+ const sized = sizedByDimension.get(dimension);
1887
+ if (sized === undefined)
1888
+ sizedByDimension.set(dimension, [index]);
1889
+ else
1890
+ sized.push(index);
1891
+ }
1892
+ }
1893
+ const maps = [];
1894
+ const colorChannel = resolved.find((entry) => entry.color !== undefined)?.color;
1895
+ if (colored.length > 0 && colorChannel !== undefined) {
1896
+ const scale = continuousScale(drawnMembers(emitted, colored, 2));
1897
+ const column = colorChannel.transformed ? colorChannel.name : colorChannel.column;
1898
+ maps.push({
1899
+ type: "continuous",
1900
+ seriesIndex: colored,
1901
+ dimension: 2,
1902
+ ...colorScale(scale, axisTitle(context.labels, colorChannel.name), column, declaredForColumn(context.meanings, column)),
1903
+ });
1904
+ }
1905
+ // A size column can sit at dimension 2 of one series and at dimension 3 of another. Each map names one
1906
+ // dimension, and every map reads one range over the drawn points of every sized series, thus one value
1907
+ // draws at one size in each series.
1908
+ const sizes = [...sizedByDimension].flatMap(([dimension, seriesIndex]) => drawnMembers(emitted, seriesIndex, dimension));
1909
+ for (const [dimension, seriesIndex] of sizedByDimension) {
1910
+ maps.push({
1911
+ type: "continuous",
1912
+ show: false,
1913
+ seriesIndex,
1914
+ dimension,
1915
+ min: sizes.length > 0 ? lowest(sizes) : 0,
1916
+ max: sizes.length > 0 ? highest(sizes) : 1,
1917
+ inRange: { symbolSize: [...SIZE_CHANNEL_RANGE_PX] },
1918
+ });
1919
+ }
1920
+ return maps;
1921
+ }
1922
+ /**
1923
+ * The members at one dimension of each item that some series draw. A row whose point drops gives no item,
1924
+ * thus a scale reads the drawn points alone and a dropped row never widens it.
1925
+ */
1926
+ function drawnMembers(emitted, seriesIndex, dimension) {
1927
+ const members = [];
1928
+ for (const index of seriesIndex) {
1929
+ const data = emitted[index].option.data;
1930
+ if (!Array.isArray(data))
1931
+ continue;
1932
+ for (const item of data) {
1933
+ const value = Array.isArray(item) ? item : typeof item === "object" && item !== null ? item.value : undefined;
1934
+ const member = Array.isArray(value) ? toNumber(value[dimension]) : null;
1935
+ if (member !== null)
1936
+ members.push(member);
1937
+ }
1938
+ }
1939
+ return members;
1940
+ }
1941
+ /** The smallest of some values, read in one pass. */
1942
+ function lowest(values) {
1943
+ let min = values[0];
1944
+ for (const value of values)
1945
+ if (value < min)
1946
+ min = value;
1947
+ return min;
1948
+ }
1949
+ /** The largest of some values, read in one pass. */
1950
+ function highest(values) {
1951
+ let max = values[0];
1952
+ for (const value of values)
1953
+ if (value > max)
1954
+ max = value;
1955
+ return max;
1956
+ }
1957
+ /**
1958
+ * True when the interval of one series lies along the x axis.
1959
+ *
1960
+ * The interval sits on the value axis. For a bar it is the value axis of the orientation. For a scatter it is
1961
+ * the axis that draws no category, and the y axis when both axes draw values.
1962
+ */
1963
+ function intervalAlongX(entry, axes) {
1964
+ if (entry.declared.form === "bar") {
1965
+ return isHorizontalBar(entry.declared);
1966
+ }
1967
+ return axes.yAxis.type === "category" && axes.xAxis.type !== "category";
1968
+ }
1969
+ /** The place of each category of one axis, keyed by the text of the category, or `undefined` for a value axis. */
1970
+ function categoryPlaces(axis) {
1971
+ if (axis.type !== "category" || !Array.isArray(axis.data))
1972
+ return undefined;
1973
+ return new Map(axis.data.map((category, place) => [String(category), place]));
1974
+ }
1975
+ /**
1976
+ * The interval series of one runtime series: one custom series with the `interval` renderer, after the series
1977
+ * that it annotates.
1978
+ *
1979
+ * Each item leads with its anchor and its value, then the two bounds, the axis, the slot offset, and the mark
1980
+ * kind. The anchor of a category axis is the place of the category, because the chart runtime rounds a
1981
+ * category coordinate. A bound on the wrong side of its value states an interval that the evidence cannot
1982
+ * hold, thus it refuses and names the row.
1983
+ *
1984
+ * The series carries the name of the series that it annotates. Thus it takes the palette slot of that name,
1985
+ * and it adds no legend entry of its own.
1986
+ */
1987
+ function intervalSeries(blockId, entry, name, points, axes, offset) {
1988
+ const xCategories = categoryPlaces(axes.xAxis);
1989
+ const yCategories = categoryPlaces(axes.yAxis);
1990
+ if (entry.declared.form === "scatter" && xCategories !== undefined && yCategories !== undefined) {
1991
+ return err(problem(blockId, "An interval lies along the value axis, and both axes of this scatter draw categories."));
1992
+ }
1993
+ const alongX = intervalAlongX(entry, axes);
1994
+ const horizontal = isHorizontalBar(entry.declared);
1995
+ const items = [];
1996
+ for (const point of points) {
1997
+ const renderedX = horizontal ? point.y : point.x;
1998
+ const renderedY = horizontal ? point.x : point.y;
1999
+ const value = toNumber(alongX ? renderedX : renderedY);
2000
+ const across = alongX ? renderedY : renderedX;
2001
+ const places = alongX ? yCategories : xCategories;
2002
+ const anchor = places !== undefined ? places.get(String(across)) : toNumber(across);
2003
+ const low = point.low;
2004
+ const high = point.high;
2005
+ if (value === null || anchor === undefined || anchor === null || low === undefined || high === undefined)
2006
+ continue;
2007
+ if (low > value || high < value) {
2008
+ const detail = `The row ${point.index + 1} holds the interval from ${low} to ${high} around the plotted value ${value}. ` +
2009
+ 'The "low" bound sits at or under the value, and the "high" bound sits at or over it.';
2010
+ return err(problem(blockId, detail));
2011
+ }
2012
+ items.push(alongX ? [value, anchor, low, high, 0, offset, 0] : [anchor, value, low, high, 1, offset, 0]);
2013
+ }
2014
+ return ok({
2015
+ type: "custom",
2016
+ name,
2017
+ renderItem: INTERVAL_RENDERER,
2018
+ silent: true,
2019
+ // A bar draws at the level 2 of the runtime. The interval draws over it, thus the bar hides no whisker.
2020
+ z: INTERVAL_Z,
2021
+ encode: alongX ? { x: [0, 2, 3], y: 1 } : { x: 0, y: [1, 2, 3] },
2022
+ data: items,
2023
+ });
2024
+ }
2025
+ /** The drawing level of an interval series, over the level of a bar. */
2026
+ const INTERVAL_Z = 3;
2027
+ /**
2028
+ * Derive a faceted composition: one panel for each value of the facet column, in first-appearance order.
2029
+ *
2030
+ * Each panel holds one grid, one axis pair, and its own runtime series. The panels share one axis range on
2031
+ * each axis, computed from every row, thus the panels compare. `facetLayout` places the panels.
2032
+ *
2033
+ * The facet keeps its rows inline: a page-side build reads no panel, thus the collector fails.
2034
+ */
2035
+ function deriveFacets(context, facet, columns, resolved, axes) {
2036
+ const channel = resolveChannel(context.blockId, facet, context.rows, columns);
2037
+ if (channel.isErr())
2038
+ return err(channel.error);
2039
+ if (context.collector !== undefined) {
2040
+ context.collector.failed = true;
2041
+ }
2042
+ const split = splitByChannel(context.rows, channel.value);
2043
+ if (split.length > FACET_PANEL_LIMIT) {
2044
+ return err(problem(context.blockId, `The facet splits the table into ${split.length} panels. A facet holds ${FACET_PANEL_LIMIT} panels at most, thus a table of fewer groups serves the reader.`));
2045
+ }
2046
+ // A table with no row still draws one empty panel, thus the chart keeps its container.
2047
+ const panels = split.length > 0 ? split : [{ name: undefined, indices: [] }];
2048
+ // A panel reads the category places and the axis types alone, thus it composes before the shared ranges.
2049
+ const emitted = [];
2050
+ for (const [place, panel] of panels.entries()) {
2051
+ const series = composePanel(context, resolved, new Set(panel.indices), axes, `${BAND_STACK_PREFIX}${place}-`);
2052
+ if (series.isErr())
2053
+ return err(series.error);
2054
+ for (const entry of series.value) {
2055
+ emitted.push({ ...entry, option: { ...entry.option, xAxisIndex: place, yAxisIndex: place } });
699
2056
  }
700
- seen.add(name);
701
- const numeric = toNumber(row[valueColumn]);
702
- data.push({ name, value: numeric === null ? row[valueColumn] : numeric });
703
2057
  }
2058
+ const labeledPanels = labeledSeries(context, emitted, isHorizontalBar(resolved[0].declared));
2059
+ const labeled = resolved[0].declared.form === "bar" && Object.keys(labelRoom(labeledPanels, isHorizontalBar(resolved[0].declared))).length > 0;
2060
+ const shared = sharedAxes(context, resolved, axes, labeled);
2061
+ // A continuous color draws its scale at the right edge, thus the panels leave that band free.
2062
+ const scaleBand = emitted.some((entry) => entry.entry?.color !== undefined) ? COLOR_SCALE_BAND_PCT : 0;
2063
+ const layout = facetLayout(panels.map((panel) => panel.name), shared, legendOf(drawnNames(emitted)), scaleBand);
2064
+ const named = resolved.some((entry) => entry.label !== undefined) || context.labeled.size > 0;
2065
+ const maps = visualMaps(context, resolved, emitted);
704
2066
  return ok({
705
- series: [{ type: "pie", radius: "55%", data }],
2067
+ tooltip: named ? { ...NAMED_TOOLTIP } : { ...PLAIN_TOOLTIP },
2068
+ grid: layout.grid,
2069
+ xAxis: layout.xAxis,
2070
+ yAxis: layout.yAxis,
2071
+ series: labeledPanels,
2072
+ graphic: layout.graphic,
2073
+ ...(maps.length > 0 ? { visualMap: maps } : {}),
2074
+ legend: layout.legend,
706
2075
  });
707
2076
  }
708
- /** The tier of one row count. The two counts are constants of the design source. */
709
- function scatterDensity(rowCount) {
710
- if (rowCount > SCATTER_CROWD_ROWS)
711
- return "crowd";
712
- if (rowCount > SCATTER_HOVER_ROWS)
713
- return "hover";
714
- return "normal";
2077
+ /**
2078
+ * The layout of the panels of a facet, one panel for each name in order.
2079
+ *
2080
+ * One panel takes the full width, two panels take half each, and three or more lay out three to a row. The
2081
+ * label of each panel is a text element at the top left of its cell, because the layout discipline strips a
2082
+ * `title`. Each grid holds its labels and its names inside its own box, thus two panels never paint over each
2083
+ * other. `shared` gives the axes with the one range of every panel, and `scaleBand` is the band at the right
2084
+ * edge that a continuous color scale takes.
2085
+ */
2086
+ function facetLayout(names, shared, legend, scaleBand) {
2087
+ const columnCount = names.length === 1 ? 1 : names.length === 2 ? 2 : FACET_COLUMNS;
2088
+ const rowCount = Math.ceil(names.length / columnCount);
2089
+ const legendBand = legend.show === false ? 0 : FACET_LEGEND_BAND / rowCount;
2090
+ const titleBand = FACET_TITLE_BAND / rowCount;
2091
+ const rowBand = (100 - legendBand - titleBand) / rowCount;
2092
+ const panelWidth = (100 - FACET_Y_TITLE_BAND - scaleBand - (columnCount - 1) * FACET_GUTTER) / columnCount;
2093
+ // A panel draws no axis name, because a name narrows its panel alone. A narrow panel draws fewer ticks, and a
2094
+ // tick label that overlaps its neighbor hides.
2095
+ const xAxis = panelAxis(shared.xAxis, (CHART_PAGE_WIDTH_PX * panelWidth) / 100);
2096
+ const yAxis = panelAxis(shared.yAxis);
2097
+ const grids = [];
2098
+ const xAxes = [];
2099
+ const yAxes = [];
2100
+ const graphic = [];
2101
+ for (const [place, name] of names.entries()) {
2102
+ const left = FACET_Y_TITLE_BAND + (place % columnCount) * (panelWidth + FACET_GUTTER);
2103
+ const top = Math.floor(place / columnCount) * rowBand;
2104
+ grids.push({
2105
+ left: percent(left),
2106
+ top: percent(top + rowBand * FACET_LABEL_BAND),
2107
+ width: percent(panelWidth),
2108
+ height: percent(rowBand * (1 - FACET_LABEL_BAND)),
2109
+ outerBoundsMode: FACET_BOUNDS_MODE,
2110
+ outerBoundsContain: "all",
2111
+ });
2112
+ xAxes.push({ ...xAxis, gridIndex: place });
2113
+ yAxes.push({ ...yAxis, gridIndex: place });
2114
+ if (name !== undefined) {
2115
+ graphic.push({
2116
+ type: "text",
2117
+ left: percent(left),
2118
+ top: percent(top + rowBand * FACET_LABEL_GAP),
2119
+ style: { text: categoryName(name), fill: CHART_INK, fontFamily: CHART_FONT_STACK, fontSize: CHART_PAGE_TEXT_PX, fontWeight: "bold" },
2120
+ });
2121
+ }
2122
+ }
2123
+ // Each shared axis takes one title for the whole chart: the x title under the panels, and the y title
2124
+ // turned upright in the band at the left.
2125
+ const titleStyle = { fill: CHART_INK, fontFamily: CHART_FONT_STACK, fontSize: CHART_PAGE_TEXT_PX };
2126
+ if (typeof shared.xAxis.name === "string") {
2127
+ graphic.push({ type: "text", left: "center", bottom: percent(legendBand), style: { text: shared.xAxis.name, ...titleStyle } });
2128
+ }
2129
+ if (typeof shared.yAxis.name === "string") {
2130
+ graphic.push({ type: "text", left: 0, top: "middle", rotation: Math.PI / 2, style: { text: shared.yAxis.name, ...titleStyle } });
2131
+ }
2132
+ return { grid: grids, xAxis: xAxes, yAxis: yAxes, graphic, legend };
715
2133
  }
716
- /** The point count from which the scatter renderer takes its large path. */
717
- const LARGE_SCATTER_THRESHOLD = 2000;
718
- /** The opacity of the band that an `area` series draws between its two bounds. */
719
- const BAND_OPACITY = 0.25;
720
- /** The forms whose data runs along the x axis. A line that zigzags states an order that no column holds. */
721
- const SORTED_FORMS = new Set(["line", "area", "step"]);
722
2134
  /**
723
- * The tooltip of a composition whose points carry no name.
2135
+ * One shared axis as each panel draws it: no name and three ticks.
724
2136
  *
725
- * `{a}` is the series name, and `{c}` is the value of the item. The text is a static template of the
726
- * chart runtime, thus the option carries no function and the inline JSON stays a pure value.
2137
+ * A value label that overlaps its neighbor hides, because the next tick states the scale. A category label
2138
+ * names its bar, thus each one prints, up to the upright labels that the panel width holds (`thinnedLabels`).
2139
+ * A category label that does not fit its share of the panel width turns 45 degrees, and 90 degrees where a
2140
+ * turned label still covers its neighbor. `widthPx` is the panel width on the page, and an x axis alone states it.
727
2141
  */
728
- const PLAIN_TOOLTIP = { trigger: "item", formatter: "{a}: {c}" };
729
- /** The tooltip of a composition whose points carry a name. `{b}` is the name of the item. */
730
- const NAMED_TOOLTIP = { trigger: "item", formatter: "{b}<br/>{a}: {c}" };
2142
+ function panelAxis(axis, widthPx) {
2143
+ const label = typeof axis.axisLabel === "object" && axis.axisLabel !== null ? axis.axisLabel : {};
2144
+ const base = { ...withoutName(axis), splitNumber: FACET_SPLIT_NUMBER };
2145
+ if (axis.type !== "category" || !Array.isArray(axis.data))
2146
+ return { ...base, axisLabel: { ...label, hideOverlap: true } };
2147
+ const turn = widthPx === undefined || label.rotate !== undefined ? undefined : categoryTurn(axis.data, widthPx);
2148
+ return { ...base, axisLabel: { ...label, interval: 0, ...(turn !== undefined ? { rotate: turn } : {}) } };
2149
+ }
2150
+ /** The width of one character of the chart text, as a share of the text size. */
2151
+ const LABEL_CHARACTER_SHARE = 0.6;
731
2152
  /**
732
- * The label of one named point. `{b}` is the name of the item, thus the label needs no function.
2153
+ * The turn of the category labels of one axis at one width, or `undefined` for labels that fit level.
733
2154
  *
734
- * A page-side series build writes the same member onto a flagged point, thus one constant answers for the
735
- * inline form and the payload form alike.
2155
+ * A level label fits where its longest name fits the band of one category. A label turned 45 degrees clears
2156
+ * its neighbor where the band, measured across the turned text, holds one line of text.
736
2157
  */
737
- export const POINT_LABEL = { show: true, formatter: "{b}" };
2158
+ function categoryTurn(categories, widthPx) {
2159
+ if (categories.length === 0)
2160
+ return undefined;
2161
+ let longest = 0;
2162
+ for (const category of categories)
2163
+ longest = Math.max(longest, categoryName(category).length);
2164
+ const band = widthPx / categories.length;
2165
+ if (longest * CHART_PAGE_TEXT_PX * LABEL_CHARACTER_SHARE <= band)
2166
+ return undefined;
2167
+ return band * Math.SQRT1_2 >= CHART_PAGE_TEXT_PX * LABEL_LINE_SHARE ? 45 : 90;
2168
+ }
738
2169
  /**
739
- * The member of the option that carries the data source.
2170
+ * One option with the upright category labels of each x axis thinned to the count that the plot width of its
2171
+ * grid holds on the page.
740
2172
  *
741
- * The chart runtime reads no member of this name, and the page bootstrap removes it before it sets the
742
- * option. The name leads with two underscores, thus no reader mistakes it for a field of the runtime.
2173
+ * The layout discipline pins the interval of each axis to 0, and a facet panel pins it too. An axis of more
2174
+ * upright labels than its plot holds takes the interval of `categoryInterval` in place of that 0. A thinned
2175
+ * axis no longer names each bar, thus an option with no tooltip takes the axis tooltip, which names the
2176
+ * category under the pointer. The export states the interval again at its own width.
743
2177
  */
744
- export const CHART_SOURCE_MEMBER = "__reportData";
745
- function deriveComposition(blockId, composition, rows, columns, labels, extras = {}) {
746
- const { preset, classification, collector } = extras;
747
- if (composition.series.length > 1 && composition.series.some(isHorizontalBar)) {
748
- // A horizontal bar reads its categories up the y axis, and every other form reads a value there.
749
- // The two share no honest axis pair on one grid, thus the mix refuses instead of plotting a lie.
750
- return err(problem(blockId, "A horizontal bar plots its categories on the y axis, thus it shares no axis pair with another series."));
751
- }
752
- const resolved = [];
753
- for (const declared of composition.series) {
754
- const series = resolveSeries(blockId, declared, rows, columns);
755
- if (series.isErr())
756
- return err(series.error);
757
- resolved.push(series.value);
758
- }
759
- if (resolved.length === 0) {
760
- // The schema holds a composition to one series at least. The guard states the same rule for a
761
- // value that reaches the renderer without a parse, because the axes come from the first series.
762
- return err(problem(blockId, "The composition carries no series."));
763
- }
764
- const annotations = composition.annotations ?? [];
765
- const labeled = pointLabelRows(blockId, annotations, rows, columns, plottedRows(resolved, rows.length));
766
- if (labeled.isErr())
767
- return err(labeled.error);
768
- if (collector !== undefined && classification !== undefined) {
769
- // The page splits the rows against the same two cuts, thus the rule rides beside the descriptors.
770
- collector.rule = classification.rule;
771
- }
772
- const density = scatterDensity(rows.length);
773
- const emitted = [];
2178
+ function thinnedLabels(option) {
2179
+ if (option.xAxis === undefined)
2180
+ return option;
2181
+ let thinned = false;
2182
+ const thin = (axis) => {
2183
+ if (typeof axis !== "object" || axis === null || Array.isArray(axis))
2184
+ return axis;
2185
+ const fields = axis;
2186
+ const label = typeof fields.axisLabel === "object" && fields.axisLabel !== null ? fields.axisLabel : {};
2187
+ if (fields.type !== "category" || !Array.isArray(fields.data) || label.rotate !== 90 || label.show === false)
2188
+ return axis;
2189
+ const interval = categoryInterval(fields.data.length, CHART_PAGE_TEXT_PX, pagePlotWidth(option.grid, fields));
2190
+ if (interval === 0)
2191
+ return axis;
2192
+ thinned = true;
2193
+ return { ...fields, axisLabel: { ...label, interval } };
2194
+ };
2195
+ const xAxis = Array.isArray(option.xAxis) ? option.xAxis.map(thin) : thin(option.xAxis);
2196
+ return { ...option, xAxis, ...(thinned && option.tooltip === undefined ? { tooltip: { trigger: "axis" } } : {}) };
2197
+ }
2198
+ /**
2199
+ * The plot width of the grid of one axis on the page, in pixels. A grid that states its width gives that width,
2200
+ * as a facet panel does. Every other grid spans the page chart body less its two side margins.
2201
+ */
2202
+ function pagePlotWidth(grid, axis) {
2203
+ const entry = Array.isArray(grid) ? grid[typeof axis.gridIndex === "number" ? axis.gridIndex : 0] : grid;
2204
+ const fields = typeof entry === "object" && entry !== null ? entry : {};
2205
+ const stated = pagePx(fields.width);
2206
+ return stated > 0 ? stated : CHART_PAGE_WIDTH_PX - pagePx(fields.left) - pagePx(fields.right);
2207
+ }
2208
+ /** One horizontal length of a grid on the page, in pixels: a percent of the page chart body, or a number of pixels. Any other value takes no room. */
2209
+ function pagePx(value) {
2210
+ if (typeof value === "number")
2211
+ return value;
2212
+ return typeof value === "string" && value.endsWith("%") ? (CHART_PAGE_WIDTH_PX * Number.parseFloat(value)) / 100 : 0;
2213
+ }
2214
+ /** The outer bounds mode of each facet panel: each grid holds its labels and its names inside its own box. */
2215
+ const FACET_BOUNDS_MODE = "same";
2216
+ /** The part of one panel row that the panel label takes, over the grid, and the gap over the label. */
2217
+ const FACET_LABEL_BAND = 0.12;
2218
+ const FACET_LABEL_GAP = 0.03;
2219
+ /** The gap between two panels of one row, and the band at the left of the chart that holds the y title, in percent of the width. */
2220
+ const FACET_GUTTER = 3;
2221
+ const FACET_Y_TITLE_BAND = 5;
2222
+ /** The part of the chart height, in percent of one panel row, that the x title of a faceted chart takes. */
2223
+ const FACET_TITLE_BAND = 7;
2224
+ /** The count of ticks that a panel axis aims at. */
2225
+ const FACET_SPLIT_NUMBER = 3;
2226
+ /** The part of the chart height, in percent of one panel row, that the legend of a faceted chart takes. */
2227
+ const FACET_LEGEND_BAND = 8;
2228
+ /** One layout percentage, rounded, thus a float residue of the layout never reaches the option. */
2229
+ function percent(value) {
2230
+ return `${Math.round(value * 1e4) / 1e4}%`;
2231
+ }
2232
+ /** One axis with no name. The shared range of a facet makes the name of each inner panel a repeat. */
2233
+ function withoutName(axis) {
2234
+ const { name: _name, nameLocation: _location, nameGap: _gap, nameRotate: _rotate, ...rest } = axis;
2235
+ return rest;
2236
+ }
2237
+ /**
2238
+ * The shared axes of a facet: the axes of the whole table, with one range on each value axis.
2239
+ *
2240
+ * The range reads every plotted value of every row, the two bounds of an interval, the lower bound of a
2241
+ * band, and the zero of a bar. A bar chart with value labels adds the label room. It widens to round numbers, thus the ticks of each panel land on the same
2242
+ * values. A category axis already lists the categories of every row, and a log axis keeps the range of the
2243
+ * chart runtime.
2244
+ */
2245
+ function sharedAxes(context, resolved, axes, labeled) {
2246
+ const xs = [];
2247
+ const ys = [];
774
2248
  for (const entry of resolved) {
775
- for (const split of splitSeries(rows, entry, classification)) {
776
- const built = buildSeries(blockId, entry, split, labeled.value, density, emitted.length, labels, preset, collector);
777
- if (built.isErr())
778
- return err(built.error);
779
- emitted.push(...built.value);
2249
+ const horizontal = isHorizontalBar(entry.declared);
2250
+ const alongX = intervalAlongX(entry, axes);
2251
+ for (let index = 0; index < context.rows.length; index += 1) {
2252
+ const x = toNumber((horizontal ? entry.y : entry.x).values[index]);
2253
+ const y = toNumber((horizontal ? entry.x : entry.y).values[index]);
2254
+ if (x !== null)
2255
+ xs.push(x);
2256
+ if (y !== null)
2257
+ ys.push(y);
2258
+ const y0 = entry.y0 === undefined ? null : toNumber(entry.y0.values[index]);
2259
+ if (y0 !== null)
2260
+ ys.push(y0);
2261
+ for (const bound of [entry.low, entry.high]) {
2262
+ const value = bound === undefined ? null : toNumber(bound.values[index]);
2263
+ if (value !== null)
2264
+ (alongX ? xs : ys).push(value);
2265
+ }
780
2266
  }
2267
+ if (entry.declared.form === "bar")
2268
+ (horizontal ? xs : ys).push(0);
781
2269
  }
782
- const marks = markMembers(annotations);
783
- const target = markCarrier(emitted);
784
- if (Object.keys(marks).length > 0 && target >= 0) {
785
- emitted[target] = { ...emitted[target], option: { ...emitted[target].option, ...marks } };
786
- }
787
- const first = resolved[0];
788
- const named = resolved.some((entry) => entry.label !== undefined) || labeled.value.size > 0;
789
- const axes = compositionAxes(rows, first, composition.axes, labels, preset);
790
- return ok({
791
- tooltip: named ? { ...NAMED_TOOLTIP } : { ...PLAIN_TOOLTIP },
792
- xAxis: axes.xAxis,
793
- yAxis: axes.yAxis,
794
- series: emitted.map((entry) => entry.option),
795
- // A horizontal bar draws its category names as y labels, thus the grid must hold them.
796
- ...(isHorizontalBar(first.declared) ? { grid: { ...LABEL_CONTAINING_GRID } } : {}),
797
- });
2270
+ // The value labels of a bar sit past the bar ends, thus the value axis of the bar takes the label room.
2271
+ const horizontal = isHorizontalBar(resolved[0].declared);
2272
+ return { xAxis: sharedRange(axes.xAxis, xs, labeled && horizontal), yAxis: sharedRange(axes.yAxis, ys, labeled && !horizontal) };
2273
+ }
2274
+ /**
2275
+ * One value axis with the round range of some values. Any other axis passes through.
2276
+ *
2277
+ * `room` widens the range by the label room on each side that holds a value away from zero, as the value axis
2278
+ * of a bar chart with labels does on one panel. The zero of a bar stays the end of its side.
2279
+ */
2280
+ function sharedRange(axis, values, room) {
2281
+ if (axis.type !== "value" || values.length === 0) {
2282
+ return axis;
2283
+ }
2284
+ let low = lowest(values);
2285
+ let high = highest(values);
2286
+ if (room) {
2287
+ const span = high - low;
2288
+ if (high > 0)
2289
+ high += span * LABEL_ROOM_SHARE;
2290
+ if (low < 0)
2291
+ low -= span * LABEL_ROOM_SHARE;
2292
+ }
2293
+ const [min, max] = roundExtent(low, high);
2294
+ return { ...axis, min, max };
2295
+ }
2296
+ /**
2297
+ * The range of some values, widened to multiples of a round step.
2298
+ *
2299
+ * The step is one, two, two and a half, five, or ten times a power of ten, near one fifth of the range. A
2300
+ * range of one value widens by a tenth of that value on each side. The ends round to twelve significant
2301
+ * digits, thus a float residue never reaches an axis label.
2302
+ */
2303
+ function roundExtent(low, high) {
2304
+ let min = low;
2305
+ let max = high;
2306
+ if (min === max) {
2307
+ const pad = min === 0 ? 1 : Math.abs(min) / 10;
2308
+ min -= pad;
2309
+ max += pad;
2310
+ }
2311
+ const raw = (max - min) / 5;
2312
+ const base = Math.pow(10, Math.floor(Math.log10(raw)));
2313
+ const fraction = raw / base;
2314
+ const step = (fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 2.5 ? 2.5 : fraction <= 5 ? 5 : 10) * base;
2315
+ return [Number((Math.floor(min / step) * step).toPrecision(12)), Number((Math.ceil(max / step) * step).toPrecision(12))];
798
2316
  }
799
2317
  /** Resolve each channel of one declared series against the rows. An absent column is a refusal. */
800
2318
  function resolveSeries(blockId, declared, rows, columns) {
@@ -826,19 +2344,81 @@ function resolveSeries(blockId, declared, rows, columns) {
826
2344
  return err(absent);
827
2345
  resolved.label = rows.map((row) => row[labelColumn] ?? null);
828
2346
  }
2347
+ for (const member of ["color", "size", "low", "high"]) {
2348
+ const declaredChannel = declared.encoding[member];
2349
+ if (declaredChannel === undefined)
2350
+ continue;
2351
+ const channel = resolveChannel(blockId, declaredChannel, rows, columns);
2352
+ if (channel.isErr())
2353
+ return err(channel.error);
2354
+ resolved[member] = channel.value;
2355
+ }
829
2356
  return ok(resolved);
830
2357
  }
2358
+ /**
2359
+ * One resolved series with each positional channel in the form that its axis reads. A horizontal bar draws its
2360
+ * `x` channel on the y axis and its `y` channel on the x axis.
2361
+ */
2362
+ function plottedSeries(entry, axes, meanings) {
2363
+ const horizontal = isHorizontalBar(entry.declared);
2364
+ const xAxis = horizontal ? axes.yAxis : axes.xAxis;
2365
+ const yAxis = horizontal ? axes.xAxis : axes.yAxis;
2366
+ return {
2367
+ ...entry,
2368
+ x: plottedChannel(entry.x, xAxis, meanings),
2369
+ y: plottedChannel(entry.y, yAxis, meanings),
2370
+ ...(entry.y0 !== undefined ? { y0: plottedChannel(entry.y0, yAxis, meanings) } : {}),
2371
+ };
2372
+ }
2373
+ /**
2374
+ * One plotted channel in the form that its axis reads.
2375
+ *
2376
+ * A channel on a category axis keeps the text of each cell, thus the cluster `"6"` of a CSV draws at the
2377
+ * category `6`. A cell that the table types as a number gives its text, and the channel states it, because the
2378
+ * page build reads the payload cell as it is.
2379
+ *
2380
+ * A table that arrives as text gives each cell as a string. A column where each cell parses as a number draws
2381
+ * a value axis, thus each coordinate on it is the number of its cell. A column that holds a name keeps its
2382
+ * cells as they are: a column with a cell that is not numeric, and a column that declares a name. A
2383
+ * transformed channel gives numbers already.
2384
+ */
2385
+ function plottedChannel(channel, axis, meanings) {
2386
+ if (axis.type === "category") {
2387
+ const retyped = channel.values.some((cell) => typeof cell === "number");
2388
+ return { ...channel, values: channel.values.map((cell) => (cell === null ? null : categoryCell(cell))), ...(retyped ? { retyped: true } : {}) };
2389
+ }
2390
+ if (channel.transformed || channel.values.some((cell) => cell !== null && isNonNumericString(cell)))
2391
+ return channel;
2392
+ if (namesCategories(meanings, channel.column))
2393
+ return channel;
2394
+ return { ...channel, values: channel.values.map((cell) => (cell === null ? null : toNumber(cell))), numeric: true };
2395
+ }
2396
+ /**
2397
+ * True when a column declares the meaning of a category or of an identifier, for example the cluster `01`. Each
2398
+ * cell of such a column names a thing, thus a cell that reads as a number is still a name.
2399
+ */
2400
+ function namesCategories(meanings, column) {
2401
+ const meaning = declaredForColumn(meanings, column);
2402
+ return meaning === "category" || meaning === "identifier";
2403
+ }
831
2404
  /** Resolve one channel: its effective name, and the value that each row gives for it. */
832
2405
  function resolveChannel(blockId, channel, rows, columns) {
833
2406
  const column = channelColumn(channel);
834
2407
  const absent = requirePresent(blockId, column, rows, columns);
835
2408
  if (absent !== undefined)
836
2409
  return err(absent);
2410
+ const order = channelOrder(channel);
2411
+ if (order !== undefined) {
2412
+ const absentOrder = requirePresent(blockId, order.by, rows, columns);
2413
+ if (absentOrder !== undefined)
2414
+ return err(absentOrder);
2415
+ }
2416
+ const ordered = order !== undefined ? { order } : {};
837
2417
  const transform = channelTransform(channel);
838
2418
  if (transform === undefined) {
839
- return ok({ name: column, values: rows.map((row) => row[column] ?? null), transformed: false, column });
2419
+ return ok({ name: column, values: rows.map((row) => row[column] ?? null), transformed: false, column, ...ordered });
840
2420
  }
841
- return ok({ name: transformedName(transform, column), values: transformColumn(rows, column, transform), transformed: true, column, transform });
2421
+ return ok({ name: transformedName(transform, column), values: transformColumn(rows, column, transform), transformed: true, column, transform, ...ordered });
842
2422
  }
843
2423
  /** The refusal for a column that no row holds, or `undefined` when the column is present. */
844
2424
  function requirePresent(blockId, column, rows, columns) {
@@ -875,7 +2455,7 @@ function splitByClassification(rows, entry, classification) {
875
2455
  }));
876
2456
  const byName = new Map(splits.map((split) => [String(split.name), split]));
877
2457
  for (let index = 0; index < rows.length; index += 1) {
878
- const category = classification.categoryOf(toNumber(entry.x.values[index]), toNumber(entry.y.values[index]));
2458
+ const category = classification.categoryOf(toNumber(entry.x.values[index]), toNumber(entry.y.values[index]), index);
879
2459
  if (category === undefined)
880
2460
  continue;
881
2461
  byName.get(category)?.indices.push(index);
@@ -915,40 +2495,72 @@ function splitByChannel(rows, group) {
915
2495
  *
916
2496
  * A series of the null category takes the muted chart color, thus it recedes behind the categories that
917
2497
  * carry a finding. A preset classification states its null category itself, and a group channel of an
918
- * agent-derived column answers through the null-token test. Every other series takes no color of its own,
919
- * and the theme palette assigns one by the series order.
2498
+ * agent-derived column answers through the null-token test. A focus replaces both rules: the named group
2499
+ * takes the focus color, and every other group takes the muted color. A bar with no group reads the focus
2500
+ * over its own categories, one item at a time. Every other series takes no color of its own, and the theme
2501
+ * palette assigns one by the series order.
920
2502
  *
921
2503
  * A band draws two stacked line series, and no preset emits a band. Thus the null-category color never
922
2504
  * reaches one, and neither half of a band reports as muted.
2505
+ *
2506
+ * A series with an interval gives its interval series after it, at the slot offset of the series.
923
2507
  */
924
- function buildSeries(blockId, entry, split, labeled, density, emittedCount, labels, preset, collector) {
2508
+ function buildSeries(context, entry, split, stack, axes, offset) {
925
2509
  const form = entry.declared.form;
2510
+ const collector = context.collector;
926
2511
  const points = collectPoints(entry, split.indices);
2512
+ // A line runs along its x axis. An ordered category axis sets that order, and every other axis reads the cells.
2513
+ const places = context.orderedX ? categoryPlaces(axes.xAxis) : undefined;
2514
+ const rises = context.colorOnTop && entry.color !== undefined && !SORTED_FORMS.has(form);
927
2515
  if (SORTED_FORMS.has(form)) {
928
- points.sort((a, b) => compareCell(a.x, b.x));
2516
+ points.sort((a, b) => compareAlong(a.x, b.x, places));
2517
+ }
2518
+ else if (rises) {
2519
+ points.sort((a, b) => (a.color ?? 0) - (b.color ?? 0));
929
2520
  }
930
- const name = seriesName(entry, split.name, labels, preset?.y);
2521
+ const name = seriesName(entry, split.name, context.labels, context.preset?.y);
931
2522
  if (entry.y0 !== undefined) {
932
2523
  if (collector !== undefined) {
933
2524
  // A band draws two stacked series over one row set, and the upper one holds a difference that no
934
2525
  // cell of the table gives. Thus no descriptor states it, and the chart keeps its inline data.
935
2526
  collector.failed = true;
936
2527
  }
937
- const band = bandSeries(blockId, entry, name, points, `band-${emittedCount}`);
2528
+ const band = bandSeries(context.blockId, entry, name, points, stack);
938
2529
  if (band.isErr())
939
2530
  return err(band.error);
940
2531
  return ok([
941
- { option: band.value[0], carriesMarks: false, muted: false, empty: points.length === 0 },
942
- { option: band.value[1], carriesMarks: true, muted: false, empty: points.length === 0 },
2532
+ { option: band.value[0], carriesMarks: false, muted: false, empty: points.length === 0, auxiliary: false, entry },
2533
+ { option: band.value[1], carriesMarks: true, muted: false, empty: points.length === 0, auxiliary: false, entry },
943
2534
  ]);
944
2535
  }
945
- const muted = split.muted ?? isNullCategory(split.name);
946
- const { data, itemObjects } = seriesData(entry, points, labeled, isHorizontalBar(entry.declared));
947
- const option = { type: runtimeType(form), name, ...seriesItemStyle(muted, form, density), ...formOptions(form, density, itemObjects), data };
2536
+ const focus = context.focus;
2537
+ const seriesFocus = entry.group !== undefined ? focusColor(focus, split.name) : undefined;
2538
+ const itemFocus = focus !== undefined && entry.group === undefined && form === "bar" ? (point) => focusColor(focus, point.x) : undefined;
2539
+ const color = seriesFocus ?? ((split.muted ?? isNullCategory(split.name)) ? MUTED_CHART_COLOR : undefined);
2540
+ const { data, itemObjects } = seriesData(entry, points, context.labeled, isHorizontalBar(entry.declared), itemFocus);
2541
+ // A color or a size reads one member of each item, and the large path of the runtime draws one fill.
2542
+ const perPoint = itemObjects || entry.color !== undefined || entry.size !== undefined;
2543
+ const option = { type: runtimeType(form), name, ...seriesItemStyle(color, form, context.density), ...formOptions(form, context.density, perPoint), data };
2544
+ if (collector !== undefined) {
2545
+ collectSource(collector, entry, split, context.labeled, form, rises);
2546
+ if (itemFocus !== undefined || (places !== undefined && SORTED_FORMS.has(form))) {
2547
+ // A page-side build writes no per-item color, and it sorts a line by the cells and never by an axis
2548
+ // order. Thus such a series keeps its rows inline.
2549
+ collector.failed = true;
2550
+ }
2551
+ }
2552
+ const drawn = { option, carriesMarks: true, muted: color === MUTED_CHART_COLOR, empty: data.length === 0, auxiliary: false, entry };
2553
+ if (entry.low === undefined || entry.high === undefined) {
2554
+ return ok([drawn]);
2555
+ }
948
2556
  if (collector !== undefined) {
949
- collectSource(collector, entry, split, labeled, form);
2557
+ // A page-side build draws no interval, thus a chart with one keeps its inline data.
2558
+ collector.failed = true;
950
2559
  }
951
- return ok([{ option, carriesMarks: true, muted, empty: data.length === 0 }]);
2560
+ const interval = intervalSeries(context.blockId, entry, name, points, axes, offset);
2561
+ if (interval.isErr())
2562
+ return err(interval.error);
2563
+ return ok([drawn, { option: interval.value, carriesMarks: false, muted: false, empty: points.length === 0, auxiliary: true }]);
952
2564
  }
953
2565
  /**
954
2566
  * Collect the page-side build of one runtime series.
@@ -959,7 +2571,7 @@ function buildSeries(blockId, entry, split, labeled, density, emittedCount, labe
959
2571
  * The flags name the rows of this split alone. Thus a page-side build tests one small list for each series,
960
2572
  * and a split by a group channel or by a classification carries the labels of the rows that it holds.
961
2573
  */
962
- function collectSource(collector, entry, split, labeled, form) {
2574
+ function collectSource(collector, entry, split, labeled, form, rises) {
963
2575
  const x = columnSource(collector, entry.x);
964
2576
  const y = columnSource(collector, entry.y);
965
2577
  const group = entry.group === undefined ? undefined : columnSource(collector, entry.group);
@@ -969,36 +2581,50 @@ function collectSource(collector, entry, split, labeled, form) {
969
2581
  collector.failed = true;
970
2582
  return;
971
2583
  }
2584
+ const color = entry.color === undefined ? undefined : columnSource(collector, entry.color);
2585
+ const size = entry.size === undefined ? undefined : columnSource(collector, entry.size);
2586
+ if ((entry.color !== undefined && color === undefined) || (entry.size !== undefined && size === undefined)) {
2587
+ collector.failed = true;
2588
+ return;
2589
+ }
972
2590
  const flags = split.indices.filter((index) => labeled.has(index));
973
2591
  collector.series.push({
974
2592
  x,
975
2593
  y,
2594
+ ...(color !== undefined ? { color } : {}),
2595
+ ...(size !== undefined ? { size } : {}),
976
2596
  ...(group !== undefined ? { group } : {}),
977
2597
  ...(split.category === undefined && split.name !== undefined ? { value: split.name } : {}),
978
2598
  ...(split.category !== undefined ? { category: split.category } : {}),
979
2599
  ...(label !== undefined ? { label } : {}),
980
2600
  ...(flags.length > 0 ? { flags } : {}),
981
2601
  ...(SORTED_FORMS.has(form) ? { sort: true } : {}),
2602
+ ...(rises ? { rise: true } : {}),
982
2603
  ...(isHorizontalBar(entry.declared) ? { swap: true } : {}),
983
2604
  });
984
2605
  }
985
- /** The payload column of one resolved channel, or `undefined` when the payload holds no such column. */
2606
+ /**
2607
+ * The payload column of one resolved channel, or `undefined` when the payload holds no such column or when the
2608
+ * page cannot build the cells of the channel from it.
2609
+ */
986
2610
  function columnSource(collector, channel) {
987
2611
  const column = collector.columns.indexOf(channel.column);
988
- if (column < 0) {
2612
+ if (column < 0 || channel.retyped === true) {
989
2613
  return undefined;
990
2614
  }
991
- return channel.transform === undefined ? { column } : { column, transform: channel.transform };
2615
+ if (channel.transform !== undefined)
2616
+ return { column, transform: channel.transform };
2617
+ return channel.numeric === true ? { column, numeric: true } : { column };
992
2618
  }
993
2619
  /**
994
- * The item style of one runtime series: the muted color, and the opacity of a crowd.
2620
+ * The item style of one runtime series: its own color, and the opacity of a crowd.
995
2621
  *
996
2622
  * One member owns the item style, thus a muted crowd keeps both fields. A series that states neither emits
997
2623
  * no item style, and the theme answers for it.
998
2624
  */
999
- function seriesItemStyle(muted, form, density) {
2625
+ function seriesItemStyle(color, form, density) {
1000
2626
  const fields = {
1001
- ...(muted ? { color: MUTED_CHART_COLOR } : {}),
2627
+ ...(color !== undefined ? { color } : {}),
1002
2628
  ...(form === "scatter" && density === "crowd" ? { opacity: SCATTER_CROWD_OPACITY } : {}),
1003
2629
  };
1004
2630
  return Object.keys(fields).length > 0 ? { itemStyle: fields } : {};
@@ -1020,7 +2646,10 @@ function markCarrier(emitted) {
1020
2646
  const colored = emitted.findIndex((entry) => entry.carriesMarks && !entry.muted);
1021
2647
  return colored >= 0 ? colored : emitted.findIndex((entry) => entry.carriesMarks);
1022
2648
  }
1023
- /** The points of one group. A row whose channel gives no value drops, and no substitute value appears. */
2649
+ /**
2650
+ * The points of one group. A row whose channel gives no value drops, and no substitute value appears. A wide
2651
+ * channel reads a number, thus a row whose color, size, or bound is not numeric drops too.
2652
+ */
1024
2653
  function collectPoints(entry, indices) {
1025
2654
  const points = [];
1026
2655
  for (const index of indices) {
@@ -1028,17 +2657,32 @@ function collectPoints(entry, indices) {
1028
2657
  const y = entry.y.values[index];
1029
2658
  if (x === null || y === null)
1030
2659
  continue;
1031
- if (entry.y0 === undefined) {
1032
- points.push({ index, x, y });
1033
- continue;
2660
+ const point = { index, x, y };
2661
+ if (entry.y0 !== undefined) {
2662
+ const y0 = entry.y0.values[index];
2663
+ if (y0 === null)
2664
+ continue;
2665
+ point.y0 = y0;
1034
2666
  }
1035
- const y0 = entry.y0.values[index];
1036
- if (y0 === null)
1037
- continue;
1038
- points.push({ index, x, y, y0 });
2667
+ let complete = true;
2668
+ for (const member of WIDE_MEMBERS) {
2669
+ const channel = entry[member];
2670
+ if (channel === undefined)
2671
+ continue;
2672
+ const value = toNumber(channel.values[index]);
2673
+ if (value === null) {
2674
+ complete = false;
2675
+ break;
2676
+ }
2677
+ point[member] = value;
2678
+ }
2679
+ if (complete)
2680
+ points.push(point);
1039
2681
  }
1040
2682
  return points;
1041
2683
  }
2684
+ /** The numeric channels of a point, in the order that an item holds them. */
2685
+ const WIDE_MEMBERS = ["color", "size", "low", "high"];
1042
2686
  /**
1043
2687
  * The name of one runtime series.
1044
2688
  *
@@ -1070,15 +2714,21 @@ function seriesName(entry, group, labels, preset) {
1070
2714
  * value, and the category follows. The name of a point still reads the category channel, because the name
1071
2715
  * of a bar is what it counts and not how much it counts.
1072
2716
  */
1073
- function seriesData(entry, points, labeled, horizontal) {
2717
+ function seriesData(entry, points, labeled, horizontal, itemColor) {
1074
2718
  const data = [];
1075
2719
  let itemObjects = false;
1076
2720
  for (const point of points) {
1077
2721
  const pair = horizontal ? [point.y, point.x] : [point.x, point.y];
2722
+ // A continuous color and a size each read one member after the pair, thus a visual map names it.
2723
+ if (point.color !== undefined)
2724
+ pair.push(point.color);
2725
+ if (point.size !== undefined)
2726
+ pair.push(point.size);
1078
2727
  const label = entry.label?.[point.index];
1079
2728
  const named = label !== undefined && label !== null;
1080
2729
  const marked = labeled.has(point.index);
1081
- if (!named && !marked) {
2730
+ const color = itemColor?.(point);
2731
+ if (!named && !marked && color === undefined) {
1082
2732
  data.push(pair);
1083
2733
  continue;
1084
2734
  }
@@ -1087,7 +2737,8 @@ function seriesData(entry, points, labeled, horizontal) {
1087
2737
  value: pair,
1088
2738
  // A marked point of a series with no label channel takes the x cell as its name. Thus the
1089
2739
  // `{b}` of the label template and of the tooltip names a cell of the row, and never nothing.
1090
- name: named ? String(label) : String(point.x),
2740
+ ...(named || marked ? { name: named ? String(label) : String(point.x) } : {}),
2741
+ ...(color !== undefined ? { itemStyle: { color } } : {}),
1091
2742
  ...(marked ? { label: { ...POINT_LABEL } } : {}),
1092
2743
  });
1093
2744
  }
@@ -1138,13 +2789,17 @@ function runtimeType(form) {
1138
2789
  return "line";
1139
2790
  }
1140
2791
  }
1141
- /** The fields that one form adds to its runtime series. A scatter reads the density ladder here. */
1142
- function formOptions(form, density, itemObjects) {
2792
+ /**
2793
+ * The fields that one form adds to its runtime series. A scatter reads the density ladder here.
2794
+ *
2795
+ * `perPoint` states that an item carries a style of its own: a name, a label, a color, or a size.
2796
+ */
2797
+ function formOptions(form, density, perPoint) {
1143
2798
  switch (form) {
1144
2799
  case "bar":
1145
2800
  // The base bar rule puts the bars of one category side by side with no gap between them. A bar
1146
2801
  // of either path then reads the same.
1147
- return { barGap: 0 };
2802
+ return { barGap: 0, barCategoryGap: BAR_CATEGORY_GAP };
1148
2803
  case "line":
1149
2804
  return { showSymbol: false };
1150
2805
  case "step":
@@ -1154,9 +2809,9 @@ function formOptions(form, density, itemObjects) {
1154
2809
  case "scatter":
1155
2810
  return {
1156
2811
  ...(density === "normal" ? {} : { symbolSize: density === "crowd" ? SCATTER_CROWD_SYMBOL_SIZE : SCATTER_HOVER_SYMBOL_SIZE }),
1157
- // The large path draws a simplified point, and it drops a per-item style. Thus a series
1158
- // whose items carry a name or a label keeps the normal path.
1159
- ...(itemObjects ? {} : { large: true, largeThreshold: LARGE_SCATTER_THRESHOLD }),
2812
+ // The large path draws a simplified point in one fill, and it drops a per-item style. Thus a
2813
+ // series whose items carry a name, a label, a color, or a size keeps the normal path.
2814
+ ...(perPoint ? {} : { large: true, largeThreshold: LARGE_SCATTER_THRESHOLD }),
1160
2815
  };
1161
2816
  }
1162
2817
  }
@@ -1176,6 +2831,9 @@ function plottedRows(resolved, rowCount) {
1176
2831
  continue;
1177
2832
  if (entry.group !== undefined && entry.group.values[index] === null)
1178
2833
  continue;
2834
+ // A wide channel reads a number, and a row whose cell is not numeric draws no point.
2835
+ if (WIDE_MEMBERS.some((member) => entry[member] !== undefined && toNumber(entry[member].values[index]) === null))
2836
+ continue;
1179
2837
  plotted.add(index);
1180
2838
  break;
1181
2839
  }
@@ -1225,19 +2883,15 @@ function topRows(rows, column, order, n, plotted) {
1225
2883
  * The mark members of the annotations.
1226
2884
  *
1227
2885
  * A reference line and a reference band both carry a declared constant, thus each one rides as static
1228
- * data of a mark member and nothing here reads a cell.
1229
- *
1230
- * A guide on the x axis runs up the plot. Its end sits at the top, inside the band of the y-axis title, thus
1231
- * its label takes the position at the axis. A guide on the y axis runs across, and its end sits at the right
1232
- * edge, where the label already reads clear.
2886
+ * data of a mark member and nothing here reads a cell. Each line takes the guide style of the figure rules:
2887
+ * a thin gray dash, with its label inside the plot at the far end of the line.
1233
2888
  */
1234
2889
  function markMembers(annotations) {
1235
2890
  const lines = [];
1236
2891
  const areas = [];
1237
2892
  for (const annotation of annotations) {
1238
2893
  if (annotation.kind === "reference-line") {
1239
- const position = annotation.axis === "x" ? VERTICAL_LABEL_POSITION : undefined;
1240
- lines.push({ [axisKey(annotation.axis)]: annotation.value, ...markLabel(annotation.label, position) });
2894
+ lines.push(guideLine(annotation.axis, annotation.value, annotation.label));
1241
2895
  continue;
1242
2896
  }
1243
2897
  if (annotation.kind === "reference-band") {
@@ -1249,7 +2903,7 @@ function markMembers(annotations) {
1249
2903
  }
1250
2904
  }
1251
2905
  return {
1252
- ...(lines.length > 0 ? { markLine: { silent: true, symbol: "none", data: lines } } : {}),
2906
+ ...(lines.length > 0 ? { markLine: guideMarkLine(lines) } : {}),
1253
2907
  ...(areas.length > 0 ? { markArea: { silent: true, data: areas } } : {}),
1254
2908
  };
1255
2909
  }
@@ -1280,17 +2934,26 @@ function axisKey(axis) {
1280
2934
  * the value axis of a horizontal bar. A declared column label and a preset title both follow their own
1281
2935
  * column, thus each one lands on whichever axis draws that column.
1282
2936
  */
1283
- function compositionAxes(rows, first, axes, labels, preset) {
1284
- if (isHorizontalBar(first.declared)) {
1285
- return {
1286
- xAxis: compositionAxis(rows, first.y, "x", axes?.x, labels, preset?.y),
1287
- yAxis: barCategoryAxis(rows, first.x, "y", axes?.y, labels, preset?.x),
1288
- };
1289
- }
1290
- return {
1291
- xAxis: compositionXAxis(rows, first, axes?.x, labels, preset?.x),
1292
- yAxis: compositionAxis(rows, first.y, "y", axes?.y, labels, preset?.y),
1293
- };
2937
+ function compositionAxes(context, first, axes) {
2938
+ const preset = context.preset;
2939
+ const horizontal = isHorizontalBar(first.declared);
2940
+ const xAxis = horizontal ? compositionAxis(context, first.y, "x", axes?.x, preset?.y) : compositionXAxis(context, first, axes?.x, preset?.x);
2941
+ if (xAxis.isErr())
2942
+ return err(xAxis.error);
2943
+ const yAxis = horizontal ? barCategoryAxis(context, first.x, "y", axes?.y, preset?.x) : compositionAxis(context, first.y, "y", axes?.y, preset?.y);
2944
+ if (yAxis.isErr())
2945
+ return err(yAxis.error);
2946
+ if (first.declared.form !== "bar") {
2947
+ return ok({ xAxis: xAxis.value, yAxis: yAxis.value });
2948
+ }
2949
+ // A bar measures from zero. A fitted value axis would cut the zero off, and a bar would draw a height that
2950
+ // no cell holds. Thus the value axis of a bar keeps zero in its range.
2951
+ return ok(horizontal ? { xAxis: unscaled(xAxis.value), yAxis: yAxis.value } : { xAxis: xAxis.value, yAxis: unscaled(yAxis.value) });
2952
+ }
2953
+ /** One axis with no fitted range. A value axis then holds zero, as the chart runtime draws it by default. */
2954
+ function unscaled(axis) {
2955
+ const { scale: _scale, ...rest } = axis;
2956
+ return rest;
1294
2957
  }
1295
2958
  /**
1296
2959
  * The x axis of a composition whose bars stand up, or of any other form.
@@ -1298,30 +2961,31 @@ function compositionAxes(rows, first, axes, labels, preset) {
1298
2961
  * A bar takes a category axis, and every other form takes the inferred axis. A declared scale is the one
1299
2962
  * exception, because the author asked for a numeric axis and a category axis has no scale.
1300
2963
  */
1301
- function compositionXAxis(rows, first, declared, labels, preset) {
2964
+ function compositionXAxis(context, first, declared, preset) {
1302
2965
  if (first.declared.form !== "bar" || declared?.scale !== undefined) {
1303
- return compositionAxis(rows, first.x, "x", declared, labels, preset);
2966
+ return compositionAxis(context, first.x, "x", declared, preset);
1304
2967
  }
1305
- return barCategoryAxis(rows, first.x, "x", declared, labels, preset);
2968
+ return barCategoryAxis(context, first.x, "x", declared, preset);
1306
2969
  }
1307
2970
  /**
1308
2971
  * The category axis of a composition bar, on whichever axis it renders.
1309
2972
  *
1310
2973
  * A bar counts its categories. The base bar rule lists the values of the category channel in
1311
- * first-appearance order, thus a bar of either path draws the same axis. A declared scale asks for a
1312
- * numeric axis, and a category axis has no scale, thus such an axis falls to the inferred one.
2974
+ * first-appearance order, thus a bar of either path draws the same axis. A channel that declares an order
2975
+ * sorts the list. A declared scale asks for a numeric axis, and a category axis has no scale, thus such an
2976
+ * axis falls to the inferred one.
1313
2977
  *
1314
2978
  * A category axis on y inverts, thus the first row of the table reads at the top of the plot.
1315
2979
  */
1316
- function barCategoryAxis(rows, channel, axis, declared, labels, preset) {
2980
+ function barCategoryAxis(context, channel, axis, declared, preset) {
1317
2981
  if (declared?.scale !== undefined) {
1318
- return compositionAxis(rows, channel, axis, declared, labels, preset);
2982
+ return compositionAxis(context, channel, axis, declared, preset);
1319
2983
  }
1320
- const categories = firstAppearance(channel.values.filter((value) => value !== null));
1321
- const title = declared?.title ?? axisTitle(labels, channel.name, preset);
2984
+ const ordered = orderChannel(context, channel, firstAppearance(channel.values.filter((value) => value !== null)));
2985
+ if (ordered.isErr())
2986
+ return err(ordered.error);
1322
2987
  const typeFields = axis === "x" ? { type: "category" } : { ...HORIZONTAL_CATEGORY_AXIS };
1323
- const nameFields = axis === "x" ? xAxisName(title) : { name: title };
1324
- return { ...typeFields, data: categories, ...nameFields };
2988
+ return ok({ ...typeFields, data: ordered.value, ...axisNameFields(axis, categoryAxisTitle(context.labels, channel.name, declared?.title)) });
1325
2989
  }
1326
2990
  /**
1327
2991
  * The axis of one composition channel.
@@ -1330,21 +2994,35 @@ function barCategoryAxis(rows, channel, axis, declared, labels, preset) {
1330
2994
  * then the semantic title of the preset, and the raw column name answers last. A declared `log` scale maps
1331
2995
  * onto the logarithmic axis type. A transformed channel gives a number for each point that survives it,
1332
2996
  * thus its axis is a value axis and the cells of the untransformed column decide nothing.
2997
+ *
2998
+ * A channel that declares an order sorts the categories of an inferred category axis. On a value axis the
2999
+ * order sorts nothing, thus it refuses.
1333
3000
  */
1334
- function compositionAxis(rows, channel, axis, declared, labels, preset) {
1335
- const title = declared?.title ?? axisTitle(labels, channel.name, preset);
1336
- const nameFields = axis === "x" ? xAxisName(title) : { name: title };
1337
- if (declared?.scale === "log") {
1338
- return { type: "log", ...nameFields };
3001
+ function compositionAxis(context, channel, axis, declared, preset) {
3002
+ const titles = {
3003
+ value: declared?.title ?? axisTitle(context.labels, channel.name, preset),
3004
+ category: categoryAxisTitle(context.labels, channel.name, declared?.title),
3005
+ };
3006
+ const base = declared?.scale === "log"
3007
+ ? { type: "log", ...axisNameFields(axis, titles.value) }
3008
+ : channel.transformed
3009
+ ? { type: "value", scale: true, ...axisNameFields(axis, titles.value) }
3010
+ : inferAxis(context.rows, channel.name, axis, titles, namesCategories(context.meanings, channel.column));
3011
+ if (channel.order === undefined) {
3012
+ return ok(base);
3013
+ }
3014
+ if (base.type !== "category") {
3015
+ return err(problem(context.blockId, valueAxisOrderFault(channel.column)));
1339
3016
  }
1340
- const base = channel.transformed ? { type: "value", scale: true } : inferAxis(rows, channel.name, axis, title);
1341
- return { ...base, ...nameFields };
3017
+ const ordered = orderChannel(context, channel, base.data);
3018
+ return ordered.map((data) => ({ ...base, data }));
1342
3019
  }
1343
- // ── The shared building blocks ──────────────────────────────────────────────
1344
- /** A typed `invalid-chart-input` problem that names the block and the cause. */
1345
- function problem(blockId, detail) {
1346
- return { blockId, kind: "invalid-chart-input", detail };
3020
+ /** Sort the categories of one composition channel by its order, or keep them where it declares none. */
3021
+ function orderChannel(context, channel, categories) {
3022
+ const order = channel.order;
3023
+ return orderCategories(context.blockId, context.rows.length, (index) => channel.values[index], categories, order, (index) => (order === undefined ? undefined : context.rows[index][order.by]));
1347
3024
  }
3025
+ // ── The shared building blocks ──────────────────────────────────────────────
1348
3026
  /**
1349
3027
  * Resolve one demanded channel to its column name.
1350
3028
  *
@@ -1384,121 +3062,199 @@ function transformedName(transform, column) {
1384
3062
  return `${transform}(${column})`;
1385
3063
  }
1386
3064
  /**
1387
- * The transformed value of one column, one entry for each row. A row with no usable cell gives `null`.
1388
- *
1389
- * The page-side series build holds the twin of this function, because a chart that reads the payload
1390
- * transforms its columns in the browser. A shared test vector runs the two over one set of cells, thus the
1391
- * two cannot give different numbers in silence.
3065
+ * One cell as a category axis reads it. The chart runtime reads a number on a category axis as the place of a
3066
+ * category and never as its name, thus a number gives its text and the point draws at its own category.
1392
3067
  */
1393
- export function transformColumn(rows, column, transform) {
1394
- if (transform === "rank") {
1395
- return rankColumn(rows, column);
3068
+ function categoryCell(cell) {
3069
+ return typeof cell === "number" ? String(cell) : cell;
3070
+ }
3071
+ /** True when the cell is a string that does not represent a finite number. */
3072
+ function isNonNumericString(cell) {
3073
+ return typeof cell === "string" && toNumber(cell) === null;
3074
+ }
3075
+ /** The numeric cells of one column, in row order. A non-numeric cell drops. */
3076
+ function numericColumn(rows, column) {
3077
+ const values = [];
3078
+ for (const row of rows) {
3079
+ const value = toNumber(row[column]);
3080
+ if (value !== null)
3081
+ values.push(value);
1396
3082
  }
1397
- return rows.map((row) => applyTransform(toNumber(row[column]), transform));
3083
+ return values;
1398
3084
  }
1399
3085
  /**
1400
- * One per-row transform.
3086
+ * The axis of a line or a scatter column under the order of its channel.
1401
3087
  *
1402
- * `log10` and `neg_log10` give no value for a cell that is not positive, thus the point drops. No
1403
- * substitute value ever appears in its place.
3088
+ * A category axis sorts its categories where the channel declares an order. A value axis draws no category,
3089
+ * thus an order on it is a refusal and never a silent drop.
1404
3090
  */
1405
- function applyTransform(value, transform) {
1406
- if (value === null)
1407
- return null;
1408
- switch (transform) {
1409
- case "log10":
1410
- return value > 0 ? Math.log10(value) : null;
1411
- case "neg_log10":
1412
- return value > 0 ? -Math.log10(value) : null;
1413
- case "abs":
1414
- return Math.abs(value);
3091
+ function orderedAxis(blockId, rows, column, axis, titles, order, categorical) {
3092
+ const inferred = inferAxis(rows, column, axis, titles, categorical);
3093
+ if (order === undefined) {
3094
+ return ok(inferred);
1415
3095
  }
3096
+ if (inferred.type !== "category") {
3097
+ return err(problem(blockId, valueAxisOrderFault(column)));
3098
+ }
3099
+ const sorted = orderCategories(blockId, rows.length, (index) => String(rows[index][column]), inferred.data, order, (index) => rows[index][order.by]);
3100
+ return sorted.map((data) => ({ ...inferred, data }));
3101
+ }
3102
+ /** The refusal of an order on a channel whose axis draws values. */
3103
+ function valueAxisOrderFault(column) {
3104
+ return `The column "${column}" draws a value axis, thus its channel takes no "orderBy". An order sorts the categories of a category axis.`;
1416
3105
  }
1417
3106
  /**
1418
- * The competition rank of each cell of one column, over the ascending order of the column.
3107
+ * The set of the focus values, checked against the categories of the chart, or `undefined` for a chart that
3108
+ * declares no focus.
1419
3109
  *
1420
- * The smallest value takes the place 1, and a tie shares its place. Thus the place after a tie of two
1421
- * skips one number, which is what a competition rank states. A cell with no number takes no place, and
1422
- * its point drops. The rank reads the column alone, thus the same rows give the same places.
3110
+ * A focus value that no category holds names a finding that the chart does not draw, thus it refuses. A
3111
+ * chart with no row draws no category at all, and its empty container stands as every empty chart does.
1423
3112
  */
1424
- function rankColumn(rows, column) {
1425
- const values = rows.map((row) => toNumber(row[column]));
1426
- const counts = new Map();
1427
- for (const value of values) {
1428
- if (value !== null)
1429
- counts.set(value, (counts.get(value) ?? 0) + 1);
1430
- }
1431
- const places = new Map();
1432
- let place = 1;
1433
- for (const value of [...counts.keys()].sort((a, b) => a - b)) {
1434
- places.set(value, place);
1435
- place += counts.get(value) ?? 0;
3113
+ function focusSet(blockId, focus, rowCount, categories) {
3114
+ if (focus === undefined) {
3115
+ return ok(undefined);
3116
+ }
3117
+ if (rowCount > 0) {
3118
+ const held = new Set(categories.flatMap((category) => (category === null || category === undefined ? [] : [String(category)])));
3119
+ for (const value of focus) {
3120
+ if (!held.has(value)) {
3121
+ return err(problem(blockId, `The focus names "${value}", which no category of the chart holds.`));
3122
+ }
3123
+ }
1436
3124
  }
1437
- return values.map((value) => (value === null ? null : (places.get(value) ?? null)));
3125
+ return ok(new Set(focus));
1438
3126
  }
1439
- /** True when the column exists in the declared header, or in one row at least. */
1440
- function columnPresent(column, rows, columns) {
1441
- if (columns !== undefined && columns.includes(column))
1442
- return true;
1443
- for (const row of rows) {
1444
- if (Object.prototype.hasOwnProperty.call(row, column))
1445
- return true;
1446
- }
1447
- return false;
3127
+ /**
3128
+ * Give each bar of a small bar chart a value label.
3129
+ *
3130
+ * The count of bars reads across the series, and a chart past the bound labels nothing. A stacked part
3131
+ * carries no label, because its label overlaps the next part. A series that `labelable` refuses carries none
3132
+ * either: the whisker of an interval stands where the label would stand. An item that already carries a
3133
+ * label keeps it, thus the point name of an annotation wins. The label text is a static string, thus the option carries
3134
+ * no function and the plotted value stays the cell.
3135
+ */
3136
+ function withValueLabels(series, horizontal, textOf, labelable = () => true) {
3137
+ const labeled = (entry) => entry.type === "bar" && entry.stack === undefined && Array.isArray(entry.data);
3138
+ let count = 0;
3139
+ for (const entry of series) {
3140
+ if (labeled(entry))
3141
+ count += entry.data.length;
3142
+ }
3143
+ if (count === 0 || count > BAR_VALUE_LABEL_LIMIT) {
3144
+ return [...series];
3145
+ }
3146
+ return series.map((entry, index) => {
3147
+ if (!labeled(entry) || !labelable(index))
3148
+ return entry;
3149
+ const data = entry.data.map((item) => {
3150
+ // A bar item is a pair or an object whose value is the pair, thus the two forms read one field.
3151
+ const fields = typeof item === "object" && item !== null && !Array.isArray(item) ? item : { value: item };
3152
+ if (fields.label !== undefined || !Array.isArray(fields.value))
3153
+ return item;
3154
+ const cell = fields.value[horizontal ? 0 : 1];
3155
+ return { ...fields, label: { show: true, position: labelPosition(cell, horizontal), formatter: textOf(cell, index) } };
3156
+ });
3157
+ return { ...entry, data };
3158
+ });
1448
3159
  }
1449
- /** Convert one cell to a finite number, or `null` when it is absent or not numeric. */
1450
- function toNumber(cell) {
1451
- if (typeof cell === "number")
1452
- return Number.isFinite(cell) ? cell : null;
1453
- if (typeof cell === "string") {
1454
- const trimmed = cell.trim();
1455
- if (trimmed === "")
1456
- return null;
1457
- const parsed = Number(trimmed);
1458
- return Number.isFinite(parsed) ? parsed : null;
3160
+ /** The part of the value range that a labeled bar chart adds at an end of its value axis. */
3161
+ const LABEL_ROOM_PCT = 15;
3162
+ const LABEL_ROOM_SHARE = LABEL_ROOM_PCT / 100;
3163
+ const LABEL_AXIS_ROOM = `${LABEL_ROOM_PCT}%`;
3164
+ /**
3165
+ * The room of a value axis whose bars carry value labels, or no field for any other axis.
3166
+ *
3167
+ * A label sits past the end of its bar. The end of the longest bar lies at the end of the axis, thus without
3168
+ * room its label would stand over the category labels or past the plot. The room pads only the end of each side
3169
+ * that holds a labeled bar: the chart runtime widens the range before it adds the zero of a bar, thus a pad on a
3170
+ * side with no bar would draw a range under zero that no bar reaches.
3171
+ */
3172
+ function labelRoom(series, horizontal) {
3173
+ let negative = false;
3174
+ let positive = false;
3175
+ for (const entry of series) {
3176
+ if (entry.type !== "bar" || !Array.isArray(entry.data))
3177
+ continue;
3178
+ for (const item of entry.data) {
3179
+ if (typeof item !== "object" || item === null || Array.isArray(item) || item.label === undefined)
3180
+ continue;
3181
+ const value = item.value;
3182
+ // The sign rule of `labelPosition`: a bar under zero puts its label at its lower end.
3183
+ if ((toNumber(Array.isArray(value) ? value[horizontal ? 0 : 1] : null) ?? 0) < 0)
3184
+ negative = true;
3185
+ else
3186
+ positive = true;
3187
+ }
1459
3188
  }
1460
- return null;
3189
+ return negative || positive ? { boundaryGap: [negative ? LABEL_AXIS_ROOM : 0, positive ? LABEL_AXIS_ROOM : 0] } : {};
1461
3190
  }
1462
- /** True when the cell is a string that does not represent a finite number. */
1463
- function isNonNumericString(cell) {
1464
- return typeof cell === "string" && toNumber(cell) === null;
3191
+ /**
3192
+ * The place of the value label of one bar: past the end of the bar, away from the zero line. A negative bar
3193
+ * grows down or to the left, thus its label sits under it or at its left.
3194
+ */
3195
+ function labelPosition(cell, horizontal) {
3196
+ const negative = (toNumber(cell) ?? 0) < 0;
3197
+ if (horizontal)
3198
+ return negative ? "left" : "right";
3199
+ return negative ? "bottom" : "top";
1465
3200
  }
1466
- /** The distinct values in first-appearance order. */
1467
- function firstAppearance(values) {
1468
- const seen = new Set();
1469
- const order = [];
1470
- for (const value of values) {
1471
- if (!seen.has(value)) {
1472
- seen.add(value);
1473
- order.push(value);
1474
- }
1475
- }
1476
- return order;
3201
+ /**
3202
+ * The value label of one bar: the shown form of the number helper, for the column and the meaning that the
3203
+ * bound table declares. A card and a label of one page then read one number one way.
3204
+ */
3205
+ function valueLabelText(column, cell, meanings) {
3206
+ return formatNumberCell(cell, selectNumberKind(column, cell, declaredForColumn(meanings, column))).text;
1477
3207
  }
1478
- /** The numeric cells of one column, in row order. A non-numeric cell drops. */
1479
- function numericColumn(rows, column) {
1480
- const values = [];
1481
- for (const row of rows) {
1482
- const value = toNumber(row[column]);
1483
- if (value !== null)
1484
- values.push(value);
1485
- }
1486
- return values;
3208
+ /**
3209
+ * The legend of a chart that states its own entries: the names of the drawn series, shown at the bottom
3210
+ * where two or more of them exist, and hidden where one exists.
3211
+ *
3212
+ * An auxiliary series carries the name of the series that it annotates, thus it adds no entry of its own.
3213
+ */
3214
+ function legendOf(names) {
3215
+ const unique = firstAppearance(names);
3216
+ return unique.length >= 2 ? { bottom: 0, data: unique } : { show: false };
3217
+ }
3218
+ /**
3219
+ * The offset of one group slot inside a category band, as a fraction of the band.
3220
+ *
3221
+ * The chart runtime draws the bars of one category over 80 percent of the band, one equal slot for each
3222
+ * group. The offset is the center of the slot of `place`. The rounding keeps a float residue out of the
3223
+ * option, thus two slots of one chart read as two clean fractions.
3224
+ */
3225
+ function slotOffset(place, count) {
3226
+ if (count <= 1)
3227
+ return 0;
3228
+ return Math.round((((place + 0.5) * GROUP_SPAN) / count - GROUP_SPAN / 2) * 1e6) / 1e6;
1487
3229
  }
3230
+ /** The part of one category band that the group slots cover. Each bar series states the matching gap. */
3231
+ const GROUP_SPAN = 0.8;
1488
3232
  /**
1489
- * The axis for a line or a scatter column. A column with any non-numeric string cell is a category axis
1490
- * with its distinct values as strings. Any other column is a value axis with `scale: true`.
3233
+ * The category gap of each bar series: the part of one band that the bars leave free.
1491
3234
  *
1492
- * The `axis` argument names the channel that the column feeds. An x column takes the centered name, and a
1493
- * y column keeps the default name placement. The `title` argument names the axis, thus the column decides
1494
- * the axis type and the title decides the text.
3235
+ * The chart runtime computes a gap from the count of the bar series when a series states none, and the slot
3236
+ * offset of an interval reads a fixed span. Thus each bar states the gap that matches `GROUP_SPAN`, and an
3237
+ * interval sits over the center of its own bar.
1495
3238
  */
1496
- function inferAxis(rows, column, axis, title) {
1497
- const nameFields = axis === "x" ? xAxisName(title) : { name: title };
1498
- if (rows.some((row) => isNonNumericString(row[column]))) {
1499
- return { type: "category", data: firstAppearance(rows.map((row) => row[column])).map(String), ...nameFields };
3239
+ const BAR_CATEGORY_GAP = "20%";
3240
+ /** The two titles of the axis of one quick-path column: its value title, and its declared label alone. */
3241
+ function axisTitles(labels, column) {
3242
+ return { value: axisTitle(labels, column), category: categoryAxisTitle(labels, column) };
3243
+ }
3244
+ /**
3245
+ * The axis for a line or a scatter column. A column that `categorical` marks as a name, and a column with any
3246
+ * non-numeric string cell, is a category axis with its distinct values as strings. Any other column is a value
3247
+ * axis with `scale: true`.
3248
+ *
3249
+ * The `axis` argument names the channel that the column feeds, and the helper of the figure rules places the
3250
+ * name. The column decides the axis type, and the type decides the title: a value axis always names its
3251
+ * quantity, and a category axis names a declared title alone.
3252
+ */
3253
+ function inferAxis(rows, column, axis, titles, categorical) {
3254
+ if (categorical || rows.some((row) => isNonNumericString(row[column]))) {
3255
+ return { type: "category", data: firstAppearance(rows.map((row) => row[column])).map(String), ...axisNameFields(axis, titles.category) };
1500
3256
  }
1501
- return { type: "value", scale: true, ...nameFields };
3257
+ return { type: "value", scale: true, ...axisNameFields(axis, titles.value) };
1502
3258
  }
1503
3259
  /**
1504
3260
  * Build one series per group, in first-appearance order of the group values. A chart with no group
@@ -1514,42 +3270,18 @@ function groupedSeries(rows, groupCol, make) {
1514
3270
  }
1515
3271
  return series;
1516
3272
  }
1517
- /** The row groups in first-appearance order, or one unnamed group when there is no group column. */
1518
- function splitGroups(rows, groupCol) {
1519
- if (groupCol === undefined) {
1520
- return [{ name: undefined, rows }];
1521
- }
1522
- return firstAppearance(rows.map((row) => row[groupCol])).map((name) => ({
1523
- name,
1524
- rows: rows.filter((row) => row[groupCol] === name),
1525
- }));
1526
- }
1527
- /** Sort `[x, y]` pairs by x. Two numbers compare numerically, and any other pair compares as text. */
1528
- function sortByX(pairs) {
1529
- return [...pairs].sort((a, b) => compareCell(a[0], b[0]));
1530
- }
1531
3273
  /**
1532
- * Compare two cells for a sort by x, and for a rank rule.
1533
- *
1534
- * A pair that both hold a finite number compares by that number. A text-backed table gives each cell as a
1535
- * string, thus a code-unit order would put `"10"` between `"1"` and `"2"` and a time axis would run out of
1536
- * order. Any other pair compares by the code-unit order of the string form.
1537
- *
1538
- * The comparison never calls `localeCompare`, thus the order stays the same on every host.
3274
+ * Sort `[x, y]` pairs by x. Two numbers compare numerically, and any other pair compares as text. Where an
3275
+ * ordered category axis gives the place of each category, the pairs follow those places.
1539
3276
  */
1540
- function compareCell(a, b) {
1541
- const leftNumber = toNumber(a);
1542
- const rightNumber = toNumber(b);
1543
- if (leftNumber !== null && rightNumber !== null) {
1544
- return leftNumber - rightNumber;
1545
- }
1546
- const left = String(a);
1547
- const right = String(b);
1548
- if (left < right)
1549
- return -1;
1550
- if (left > right)
1551
- return 1;
1552
- return 0;
3277
+ function sortByX(pairs, places) {
3278
+ return [...pairs].sort((a, b) => compareAlong(a[0], b[0], places));
3279
+ }
3280
+ /** Compare two x cells along an ordered category axis, or by the compare of a sort where no axis order applies. */
3281
+ function compareAlong(a, b, places) {
3282
+ if (places === undefined)
3283
+ return compareCell(a, b);
3284
+ return (places.get(String(a)) ?? 0) - (places.get(String(b)) ?? 0);
1553
3285
  }
1554
3286
  /**
1555
3287
  * A stable key for one `(x, y)` pair. The key carries the type of each cell, thus the number `1` and the
@@ -1558,6 +3290,10 @@ function compareCell(a, b) {
1558
3290
  function pairKey(xCell, yCell) {
1559
3291
  return `${typeof xCell}:${String(xCell)}${typeof yCell}:${String(yCell)}`;
1560
3292
  }
3293
+ /** A stable key for one cell. The key carries the type of the cell, exactly as a pair key does. */
3294
+ function cellKey(cell) {
3295
+ return `${typeof cell}:${String(cell)}`;
3296
+ }
1561
3297
  // ── The histogram math ──────────────────────────────────────────────────────
1562
3298
  /**
1563
3299
  * The bin edges over the global range. The bin count is the larger of the Sturges count and the
@@ -1665,6 +3401,69 @@ function boxSummary(values) {
1665
3401
  }
1666
3402
  return { box: [whiskerLow, q1, median, q3, whiskerHigh], outliers };
1667
3403
  }
3404
+ // ── The violin math ─────────────────────────────────────────────────────────
3405
+ /** The half-width of the widest violin of a chart, as a fraction of one category band. */
3406
+ const VIOLIN_HALF_WIDTH = 0.4;
3407
+ /** The count of values under which a category draws no violin, exactly as it draws no box. */
3408
+ const VIOLIN_MIN_VALUES = 5;
3409
+ /**
3410
+ * The Gaussian kernel density of one category on the fixed grid, or `undefined` for a category that draws
3411
+ * nothing.
3412
+ *
3413
+ * The bandwidth is the Silverman rule, `0.9 * min(sd, IQR / 1.34) * n^(-1/5)`, with the sample standard
3414
+ * deviation and the type-7 quartiles of the box rule. A category whose quartiles are equal reads the
3415
+ * standard deviation alone, thus a tied middle with spread tails still draws. The grid holds 64 points
3416
+ * from the minimum to the maximum, and the last point pins to the maximum, thus a float drift cannot move
3417
+ * the end of the outline.
3418
+ */
3419
+ function violinShape(values) {
3420
+ const n = values.length;
3421
+ if (n < VIOLIN_MIN_VALUES)
3422
+ return undefined;
3423
+ const sorted = [...values].sort((a, b) => a - b);
3424
+ const q1 = quantileType7(sorted, 0.25);
3425
+ const median = quantileType7(sorted, 0.5);
3426
+ const q3 = quantileType7(sorted, 0.75);
3427
+ let sum = 0;
3428
+ for (const value of sorted)
3429
+ sum += value;
3430
+ const mean = sum / n;
3431
+ let squares = 0;
3432
+ for (const value of sorted)
3433
+ squares += (value - mean) * (value - mean);
3434
+ const sd = Math.sqrt(squares / (n - 1));
3435
+ const iqr = q3 - q1;
3436
+ const spread = iqr > 0 ? Math.min(sd, iqr / 1.34) : sd;
3437
+ const bandwidth = 0.9 * spread * Math.pow(n, -1 / 5);
3438
+ if (!(bandwidth > 0))
3439
+ return undefined;
3440
+ const min = sorted[0];
3441
+ const max = sorted[n - 1];
3442
+ const grid = [];
3443
+ for (let point = 0; point < VIOLIN_GRID_POINTS; point += 1) {
3444
+ grid.push(min + (point * (max - min)) / (VIOLIN_GRID_POINTS - 1));
3445
+ }
3446
+ grid[VIOLIN_GRID_POINTS - 1] = max;
3447
+ const norm = 1 / (n * bandwidth * Math.sqrt(2 * Math.PI));
3448
+ const densities = grid.map((at) => {
3449
+ let total = 0;
3450
+ for (const value of sorted) {
3451
+ const z = (at - value) / bandwidth;
3452
+ total += Math.exp(-0.5 * z * z);
3453
+ }
3454
+ return total * norm;
3455
+ });
3456
+ return { grid, densities, q1, median, q3 };
3457
+ }
3458
+ /**
3459
+ * Round one half-width of an outline to four decimals of a band.
3460
+ *
3461
+ * The density reads `Math.exp`, whose last bit can differ between two engines. Four decimals of a band lies
3462
+ * far under one pixel, thus the rounding costs no shape and one table gives one option on every host.
3463
+ */
3464
+ function roundWidth(width) {
3465
+ return Math.round(width * 1e4) / 1e4;
3466
+ }
1668
3467
  /**
1669
3468
  * The type-7 quantile of a sorted array. The index is `p * (n - 1)`, and the value interpolates
1670
3469
  * linearly between the `floor` and the `ceil` of that index.