@inflexa-ai/harness 0.20.0 → 0.21.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 (133) hide show
  1. package/dist/agents/conversation-agent.d.ts +26 -14
  2. package/dist/agents/conversation-agent.js +12 -23
  3. package/dist/agents/report-session-agent.d.ts +31 -5
  4. package/dist/agents/report-session-agent.js +35 -9
  5. package/dist/app/chat-turn.js +3 -1
  6. package/dist/app/message-assembly.d.ts +15 -1
  7. package/dist/app/message-assembly.js +22 -8
  8. package/dist/app/report-session-runtime.d.ts +21 -4
  9. package/dist/app/report-session-runtime.js +53 -6
  10. package/dist/app/spawn-report-session.d.ts +174 -17
  11. package/dist/app/spawn-report-session.js +233 -21
  12. package/dist/contracts/chat-events.d.ts +7 -2
  13. package/dist/contracts/message.d.ts +10 -0
  14. package/dist/contracts/report-blocks.d.ts +743 -19
  15. package/dist/contracts/report-blocks.js +186 -11
  16. package/dist/contracts/report-reference.d.ts +88 -0
  17. package/dist/contracts/report-reference.js +48 -0
  18. package/dist/index.d.ts +9 -0
  19. package/dist/index.js +20 -0
  20. package/dist/lib/chrome.d.ts +26 -6
  21. package/dist/lib/chrome.js +99 -32
  22. package/dist/lib/eyes.d.ts +57 -0
  23. package/dist/lib/eyes.js +45 -0
  24. package/dist/lib/page-capture.d.ts +14 -0
  25. package/dist/lib/page-capture.js +56 -2
  26. package/dist/loop/run-agent.js +58 -23
  27. package/dist/loop/tool-detail.d.ts +19 -2
  28. package/dist/loop/tool-detail.js +35 -2
  29. package/dist/memory/content-to-cortex.d.ts +3 -2
  30. package/dist/memory/content-to-cortex.js +21 -12
  31. package/dist/memory/conversation-display-replay.js +14 -7
  32. package/dist/memory/thread-history.d.ts +60 -3
  33. package/dist/memory/thread-history.js +55 -16
  34. package/dist/memory/thread-store.d.ts +11 -1
  35. package/dist/memory/thread-store.js +12 -2
  36. package/dist/prompts/conversation.d.ts +1 -1
  37. package/dist/prompts/conversation.js +23 -6
  38. package/dist/prompts/report-session.d.ts +1 -1
  39. package/dist/prompts/report-session.js +190 -9
  40. package/dist/report-model/authoring-grammar.d.ts +463 -0
  41. package/dist/report-model/authoring-grammar.js +77 -0
  42. package/dist/report-model/block-walk.d.ts +57 -14
  43. package/dist/report-model/block-walk.js +116 -11
  44. package/dist/report-model/draft-finish.d.ts +22 -4
  45. package/dist/report-model/draft-finish.js +39 -7
  46. package/dist/report-model/draft-operations.d.ts +3 -0
  47. package/dist/report-model/draft-operations.js +19 -5
  48. package/dist/report-model/draft-read.d.ts +8 -5
  49. package/dist/report-model/draft-read.js +21 -5
  50. package/dist/report-model/fixture-resolver.js +15 -4
  51. package/dist/report-model/pin-snapshot.d.ts +24 -6
  52. package/dist/report-model/pin-snapshot.js +204 -6
  53. package/dist/report-model/production-resolver.js +15 -4
  54. package/dist/report-model/reference-resolver.d.ts +50 -1
  55. package/dist/report-model/reference-resolver.js +94 -1
  56. package/dist/report-model/reference-stamp.d.ts +45 -0
  57. package/dist/report-model/reference-stamp.js +173 -0
  58. package/dist/report-model/structural-validation.d.ts +4 -1
  59. package/dist/report-model/structural-validation.js +48 -3
  60. package/dist/report-model/validate.d.ts +1 -1
  61. package/dist/report-model/validate.js +1 -1
  62. package/dist/report-render/asset-lookup.d.ts +19 -0
  63. package/dist/report-render/asset-lookup.js +37 -0
  64. package/dist/report-render/assets.d.ts +45 -1
  65. package/dist/report-render/assets.js +65 -6
  66. package/dist/report-render/chart-presets.d.ts +102 -0
  67. package/dist/report-render/chart-presets.js +205 -0
  68. package/dist/report-render/chart.d.ts +112 -10
  69. package/dist/report-render/chart.js +1164 -41
  70. package/dist/report-render/design.d.ts +99 -0
  71. package/dist/report-render/design.js +289 -107
  72. package/dist/report-render/number-format.d.ts +117 -0
  73. package/dist/report-render/number-format.js +479 -0
  74. package/dist/report-render/page.d.ts +121 -5
  75. package/dist/report-render/page.js +689 -7
  76. package/dist/report-render/references.d.ts +70 -16
  77. package/dist/report-render/references.js +54 -22
  78. package/dist/report-render/render.d.ts +23 -5
  79. package/dist/report-render/render.js +81 -22
  80. package/dist/report-render/table-data.d.ts +92 -0
  81. package/dist/report-render/table-data.js +129 -0
  82. package/dist/report-render/types.d.ts +15 -0
  83. package/dist/report-render/value-bridge.js +6 -2
  84. package/dist/report-render/views/chart-view.d.ts +12 -5
  85. package/dist/report-render/views/chart-view.js +14 -8
  86. package/dist/report-render/views/page-view.d.ts +36 -15
  87. package/dist/report-render/views/page-view.js +45 -23
  88. package/dist/report-render/views/prose.d.ts +12 -2
  89. package/dist/report-render/views/prose.js +29 -7
  90. package/dist/report-render/views/references-view.d.ts +22 -8
  91. package/dist/report-render/views/references-view.js +101 -13
  92. package/dist/report-render/views/values.d.ts +128 -13
  93. package/dist/report-render/views/values.js +199 -18
  94. package/dist/runtime/assemble.d.ts +39 -5
  95. package/dist/runtime/assemble.js +47 -6
  96. package/dist/sandbox/client.d.ts +10 -1
  97. package/dist/sandbox/create-sandbox.d.ts +9 -4
  98. package/dist/sandbox/create-sandbox.js +22 -16
  99. package/dist/sandbox/docker-client.js +10 -7
  100. package/dist/sandbox/mount-plan.d.ts +45 -7
  101. package/dist/sandbox/mount-plan.js +43 -5
  102. package/dist/sandbox/types.d.ts +6 -0
  103. package/dist/state/init.js +38 -9
  104. package/dist/state/report-session-state.d.ts +42 -1
  105. package/dist/state/report-session-state.js +70 -2
  106. package/dist/state/report-versions.d.ts +22 -17
  107. package/dist/state/report-versions.js +48 -35
  108. package/dist/state/snapshot-parse.js +16 -1
  109. package/dist/tools/define-tool.d.ts +32 -0
  110. package/dist/tools/define-tool.js +4 -0
  111. package/dist/tools/detail-resolver.d.ts +7 -2
  112. package/dist/tools/detail-resolver.js +7 -2
  113. package/dist/tools/iterate-report.d.ts +1 -1
  114. package/dist/tools/report/preview-snapshot.js +1 -0
  115. package/dist/tools/report-authoring/authoring-tools.d.ts +15 -6
  116. package/dist/tools/report-authoring/authoring-tools.js +99 -18
  117. package/dist/tools/report-session/derive-table.d.ts +162 -0
  118. package/dist/tools/report-session/derive-table.js +459 -0
  119. package/dist/tools/report-session/examine-page.d.ts +48 -18
  120. package/dist/tools/report-session/examine-page.js +204 -24
  121. package/dist/tools/report-session/index.d.ts +2 -0
  122. package/dist/tools/report-session/index.js +2 -0
  123. package/dist/tools/report-session/list-artifacts.d.ts +97 -0
  124. package/dist/tools/report-session/list-artifacts.js +197 -0
  125. package/dist/tools/report-session/preview-report.d.ts +6 -0
  126. package/dist/tools/report-session/preview-report.js +180 -28
  127. package/dist/tools/report-session/record-version.d.ts +22 -10
  128. package/dist/tools/report-session/record-version.js +99 -14
  129. package/dist/tools/start-report-session.d.ts +115 -0
  130. package/dist/tools/start-report-session.js +179 -0
  131. package/dist/workspace/paths.d.ts +50 -0
  132. package/dist/workspace/paths.js +63 -0
  133. package/package.json +2 -1
@@ -1,20 +1,67 @@
1
1
  /**
2
2
  * The chart derivation of the report page.
3
3
  *
4
- * A chart block binds one whole-table artifact and a channel mapping. `deriveChartOption` turns the
5
- * chart type, the encoding, and the resolved rows into one ECharts option object. The chart container
6
- * markup lives beside this file, and it wraps the option that this derivation gives.
4
+ * A chart block binds one whole-table artifact, and it carries the quick path or the composition.
5
+ * `deriveChartOption` turns that grammar and the resolved rows into one ECharts option object. The chart
6
+ * container markup lives beside this file, and it wraps the option that this derivation gives.
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.
7
11
  *
8
12
  * The renderer computes no aggregate. Each plotted number stays traceable to one cell of the evidence
9
- * artifact. A pie or a heatmap arrives one row per category or per cell, thus a repeated category or a
10
- * repeated pair is a refusal and not a silent sum.
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.
11
15
  *
12
16
  * The derivation is deterministic. Every object builds in one fixed key order, and the code reads no
13
17
  * clock, no random value, and no locale. A string comparison uses the code-unit order (`<`) and never
14
18
  * `localeCompare`, thus the same rows give the same bytes on every host.
19
+ *
20
+ * 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.
15
24
  */
16
25
  import { err, ok } from "neverthrow";
26
+ import { channelColumn, channelTransform, } from "../contracts/report-blocks.js";
27
+ import { declaredForColumn } from "../contracts/report-reference.js";
17
28
  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";
31
+ /**
32
+ * The category values that carry no finding.
33
+ *
34
+ * A preset draws the significance split itself, and an agent derives the same split into a column of its
35
+ * own. Such a column writes the null group with one of these three forms.
36
+ */
37
+ const NULL_CATEGORY_TOKENS = new Set(["ns", "n.s.", "not significant"]);
38
+ /**
39
+ * True when one category value states no finding.
40
+ *
41
+ * The test reads the prettified form of the value and it folds the case, thus `NS` and `not_significant`
42
+ * both match. `toLowerCase` reads no locale, thus one value gives one answer on every host.
43
+ */
44
+ function isNullCategory(value) {
45
+ return value !== undefined && NULL_CATEGORY_TOKENS.has(categoryName(value).trim().toLowerCase());
46
+ }
47
+ /**
48
+ * The item style of one grouped series of a base chart type.
49
+ *
50
+ * A null category states no finding, thus it recedes behind the categories that do. Every other series
51
+ * names no color, and the theme palette assigns one by the series order.
52
+ */
53
+ function nullCategoryStyle(name) {
54
+ return isNullCategory(name) ? { itemStyle: { color: MUTED_CHART_COLOR } } : {};
55
+ }
56
+ /**
57
+ * True when one declared series draws its bars across the plot.
58
+ *
59
+ * The category channel then renders on the y axis and the value channel renders on the x axis. Every other
60
+ * form and every other orientation gives false, thus one test answers for the whole composition path.
61
+ */
62
+ function isHorizontalBar(series) {
63
+ return series.form === "bar" && series.orientation === "horizontal";
64
+ }
18
65
  /**
19
66
  * The ten-stop viridis ramp for a heatmap `visualMap`. The stops run dark to light, thus a continuous
20
67
  * scale maps the low value to `#440154` and the high value to `#fde725`.
@@ -27,6 +74,45 @@ const VIRIDIS = ["#440154", "#482777", "#3e4989", "#31688e", "#26828e", "#1f9e89
27
74
  * value is a fixed constant, thus the derivation stays deterministic.
28
75
  */
29
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
+ /**
85
+ * The label position of a vertical guide band.
86
+ *
87
+ * A band is a rectangle, and the chart runtime gives it the element positions and not the line positions of
88
+ * a guide line. The inside bottom edge of a vertical band sits at the x axis, thus it is the position that
89
+ * matches the start of a vertical line.
90
+ */
91
+ const VERTICAL_BAND_LABEL_POSITION = "insideBottom";
92
+ /**
93
+ * The type fields of a category axis that renders on y.
94
+ *
95
+ * The chart runtime draws the first category of a y axis at the origin, thus it stacks the rows upward and
96
+ * a table that is sorted strongest-first reads weakest-on-top. The inverted axis puts the first row at the
97
+ * top, and the page then reads down in the order that the rows hold. The data order itself never moves.
98
+ */
99
+ const HORIZONTAL_CATEGORY_AXIS = { type: "category", inverse: true };
100
+ /**
101
+ * The grid of a chart whose category names render as axis labels.
102
+ *
103
+ * The theme pins `containLabel: false`, and the normalizer fills a left margin of 10 percent. A long
104
+ * category name then draws past the edge of the canvas. `containLabel` gives the measurement to the chart
105
+ * runtime, which is the one part that can measure the text. A fixed band of pixels would guess a width
106
+ * here, and a guess clips a longer name and wastes the room of a shorter one.
107
+ */
108
+ const LABEL_CONTAINING_GRID = { containLabel: true };
109
+ /**
110
+ * The y axis of a histogram.
111
+ *
112
+ * The axis counts rows, thus a fractional tick names no count. `minInterval` holds each tick a whole count
113
+ * apart from the next one, and it is the one static field that bounds the tick precision of a value axis.
114
+ */
115
+ const COUNT_AXIS = { type: "value", name: "Count", minInterval: 1 };
30
116
  /**
31
117
  * The name fields of an x axis.
32
118
  *
@@ -34,21 +120,193 @@ const X_AXIS_NAME_GAP = 34;
34
120
  * of `"end"` puts the name at the right end of the axis, thus the name runs past that margin and the panel
35
121
  * clips it. A centered name sits under the middle of the axis, and no margin can cut it.
36
122
  */
37
- function xAxisName(column) {
38
- return { name: column, nameLocation: "middle", nameGap: X_AXIS_NAME_GAP };
123
+ function xAxisName(title) {
124
+ return { name: title, nameLocation: "middle", nameGap: X_AXIS_NAME_GAP };
125
+ }
126
+ /**
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.
129
+ *
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.
133
+ *
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.
136
+ */
137
+ function axisTitle(labels, column, preset) {
138
+ return declaredForColumn(labels, column) ?? preset ?? column;
139
+ }
140
+ /**
141
+ * The legend text of one category value: the value with each underscore as a space.
142
+ *
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.
147
+ */
148
+ function categoryName(value) {
149
+ return String(value).replaceAll("_", " ");
39
150
  }
40
151
  /**
41
152
  * Derive the ECharts option for a chart block, then pass it through `normalizeEchartSpec`.
42
153
  *
43
154
  * The normalizer owns the bottom legend, the save action, and the axis-label discipline. Thus the
44
- * derivation sets no `title`, no `legend`, no `toolbox`, and no explicit series color. The theme palette
45
- * assigns the series colors by their order.
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.
157
+ *
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.
46
160
  */
47
161
  export function deriveChartOption(block, rows, columns) {
48
162
  return deriveRaw(block, rows, columns).map((option) => normalizeEchartSpec(option, { title: block.title }));
49
163
  }
164
+ /**
165
+ * Derive the option of one chart, and read the shared payload of its artifact where the inline form grows
166
+ * too large.
167
+ *
168
+ * A chart under the bound keeps its inline data, byte for byte as `deriveChartOption` gives it. Past the
169
+ * bound the series carry no row, and the option states how the page builds each series from the columnar
170
+ * payload of the artifact. Thus one dense chart costs the page one option and no second copy of the rows.
171
+ *
172
+ * A quick path of a type that draws one point for one row takes a second pass past the bound. The base rule
173
+ * of such a type builds a bare pair and it collects no descriptor, and the one-series composition plots the
174
+ * same points with the build beside them. The second pass runs past the bound alone, thus a chart under the
175
+ * bound keeps its bytes.
176
+ *
177
+ * A chart whose series holds a value that no cell gives stays inline whatever its size. A binned count, a
178
+ * five-number summary, an addressed pair, and the upper half of a band are each such a value, thus no
179
+ * descriptor can state one.
180
+ */
181
+ export function deriveChartRender(block, rows, columns, target) {
182
+ const first = renderPass(block, target, (collector) => deriveRaw(block, rows, columns, collector));
183
+ if (first.isErr()) {
184
+ return err(first.error);
185
+ }
186
+ const quick = perRowQuickPath(block);
187
+ if (first.value.readsPayload || !first.value.overBound || quick === undefined) {
188
+ return ok({ option: first.value.option, readsPayload: first.value.readsPayload });
189
+ }
190
+ const second = renderPass(block, target, (collector) => deriveLabeled(block.id, quick.chartType, quick.encoding, rows, columns, block.binding.columnLabels, block.orientation, collector));
191
+ if (second.isErr() || !second.value.readsPayload) {
192
+ // The composition refused, or it described no build. The first option is a whole chart, thus the
193
+ // 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 });
195
+ }
196
+ return ok({ option: second.value.option, readsPayload: true });
197
+ }
198
+ /**
199
+ * Run one derivation, normalize it, and route it onto the payload where the inline form passes the bound.
200
+ *
201
+ * The collector belongs to the pass, thus two passes over one block never mix their descriptors. A pass
202
+ * whose descriptor count differs from its series count describes the option only in part, thus it keeps the
203
+ * inline data.
204
+ */
205
+ function renderPass(block, target, derive) {
206
+ const collector = { columns: target.columns, series: [], failed: false };
207
+ return derive(collector).map((raw) => {
208
+ const option = normalizeEchartSpec(raw, { title: block.title });
209
+ const series = option.series;
210
+ if (!Array.isArray(series) || JSON.stringify(option).length <= CHART_INLINE_OPTION_BOUND) {
211
+ return { option, readsPayload: false, overBound: false };
212
+ }
213
+ if (collector.failed || collector.series.length === 0 || collector.series.length !== series.length) {
214
+ return { option, readsPayload: false, overBound: true };
215
+ }
216
+ return { option: sourcedOption(option, series, collector, target.key), readsPayload: true, overBound: true };
217
+ });
218
+ }
219
+ /**
220
+ * The quick path of one block that draws one point for one row, or `undefined` for every other block.
221
+ *
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.
225
+ */
226
+ function perRowQuickPath(block) {
227
+ const chartType = block.chartType;
228
+ const encoding = block.encoding;
229
+ if (block.composition !== undefined || chartType === undefined || encoding === undefined || encoding.label !== undefined) {
230
+ return undefined;
231
+ }
232
+ if (isPresetChartType(chartType) || LABELED_FORMS[chartType] === undefined) {
233
+ return undefined;
234
+ }
235
+ return { chartType, encoding };
236
+ }
237
+ /**
238
+ * The option of a chart that reads the payload: the derived option with no row, and the data source.
239
+ *
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.
243
+ */
244
+ function sourcedOption(option, series, collector, key) {
245
+ const source = {
246
+ payload: key,
247
+ ...(collector.rule !== undefined ? { rule: collector.rule } : {}),
248
+ series: collector.series,
249
+ };
250
+ return {
251
+ ...option,
252
+ series: series.map((entry) => ({ ...entry, data: [] })),
253
+ [CHART_SOURCE_MEMBER]: source,
254
+ };
255
+ }
256
+ /**
257
+ * Dispatch the grammar of one chart block.
258
+ *
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.
262
+ *
263
+ * The collector rides the composition path alone. A base rule collects nothing, thus its chart keeps its
264
+ * inline data whatever its size.
265
+ */
266
+ function deriveRaw(block, rows, columns, collector) {
267
+ const labels = block.binding.columnLabels;
268
+ const orientation = block.orientation;
269
+ if (orientation !== undefined && block.composition !== undefined) {
270
+ // The schema refine already makes this pair unrepresentable. The guard states the same rule for a
271
+ // value that reaches the renderer without a parse, because a composition states the arrangement on
272
+ // its own bar series and this one would otherwise drop in silence.
273
+ return err(problem(block.id, "A composition states the arrangement on its own bar series, thus the chart carries no orientation beside it."));
274
+ }
275
+ if (block.thresholds !== undefined && block.composition !== undefined) {
276
+ // A composition draws its own guide lines, thus a declared pair beside one moves nothing.
277
+ return err(problem(block.id, "A composition draws its own guide lines, thus the chart carries no thresholds beside it."));
278
+ }
279
+ if (block.composition !== undefined) {
280
+ return deriveComposition(block.id, block.composition, rows, columns, labels, { collector });
281
+ }
282
+ const chartType = block.chartType;
283
+ const encoding = block.encoding;
284
+ if (chartType === undefined || encoding === undefined) {
285
+ // The schema refine already makes this shape unrepresentable. The guard states the same rule for a
286
+ // value that reaches the renderer without a parse.
287
+ return err(problem(block.id, "The chart carries neither a chart type with an encoding, nor a composition."));
288
+ }
289
+ if (orientation !== undefined && chartType !== "bar") {
290
+ // 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.`));
292
+ }
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.`));
296
+ }
297
+ if (isPresetChartType(chartType)) {
298
+ return derivePreset(block.id, chartType, encoding, rows, columns, labels, block.thresholds, collector);
299
+ }
300
+ if (encoding.label !== undefined) {
301
+ return deriveLabeled(block.id, chartType, encoding, rows, columns, labels, orientation, collector);
302
+ }
303
+ const quick = resolveQuickPath(block.id, chartType, encoding, rows, columns, labels, orientation);
304
+ if (quick.isErr())
305
+ return err(quick.error);
306
+ return deriveBase(quick.value.block, quick.value.rows, columns);
307
+ }
50
308
  /** Dispatch to the per-type derivation. Each type holds one fixed rule. */
51
- function deriveRaw(block, rows, columns) {
309
+ function deriveBase(block, rows, columns) {
52
310
  switch (block.chartType) {
53
311
  case "bar":
54
312
  return deriveBar(block, rows, columns);
@@ -66,8 +324,134 @@ function deriveRaw(block, rows, columns) {
66
324
  return derivePie(block, rows, columns);
67
325
  }
68
326
  }
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
+ /** The quick-path types that map onto one series form. A point of such a chart can carry a name. */
343
+ const LABELED_FORMS = { bar: "bar", line: "line", scatter: "scatter" };
344
+ /**
345
+ * Derive a quick path that names its points, as a composition of one series.
346
+ *
347
+ * 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.
349
+ */
350
+ function deriveLabeled(blockId, chartType, encoding, rows, columns, labels, orientation, collector) {
351
+ const form = LABELED_FORMS[chartType];
352
+ if (form === undefined) {
353
+ return err(problem(blockId, `The ${chartType} chart draws no point for one row, thus it takes no "label" channel.`));
354
+ }
355
+ const x = requireChannel(blockId, chartType, encoding, "x");
356
+ if (x.isErr())
357
+ return err(x.error);
358
+ const y = requireChannel(blockId, chartType, encoding, "y");
359
+ if (y.isErr())
360
+ return err(y.error);
361
+ const composition = {
362
+ series: [
363
+ {
364
+ form,
365
+ encoding: {
366
+ x: x.value,
367
+ y: y.value,
368
+ ...(encoding.group !== undefined ? { group: encoding.group } : {}),
369
+ ...(encoding.label !== undefined ? { label: encoding.label } : {}),
370
+ },
371
+ // The caller refused an orientation on every type but the bar, thus the form here is a bar
372
+ // wherever one arrives. The arrangement crosses onto the series, and no name channel loses it.
373
+ ...(orientation !== undefined ? { orientation } : {}),
374
+ },
375
+ ],
376
+ };
377
+ return deriveComposition(blockId, composition, rows, columns, labels, { collector });
378
+ }
379
+ /**
380
+ * Resolve the quick path of a base chart type: the plain column of each channel, and the rows that the
381
+ * base rules read.
382
+ *
383
+ * A channel with no transform passes through, and the rows pass through by reference. Thus a chart with
384
+ * no transform derives the same bytes as before the grammar grew. A channel with a transform derives one
385
+ * column into a copy of each row, under a name that carries the transform. Thus an axis never names the
386
+ * untransformed column. A row whose transform gives no value drops, the same as a non-numeric cell.
387
+ */
388
+ function resolveQuickPath(blockId, chartType, encoding, rows, columns, labels, orientation) {
389
+ const resolved = {};
390
+ const derived = [];
391
+ for (const channel of CHANNELS) {
392
+ const declared = encoding[channel];
393
+ if (declared === undefined)
394
+ continue;
395
+ const column = channelColumn(declared);
396
+ const transform = channelTransform(declared);
397
+ if (transform === undefined) {
398
+ resolved[channel] = column;
399
+ continue;
400
+ }
401
+ if (rows.length > 0 && !columnPresent(column, rows, columns)) {
402
+ return err(problem(blockId, `The column "${column}" is absent from every row.`));
403
+ }
404
+ const name = transformedName(transform, column);
405
+ if (columnPresent(name, rows, columns)) {
406
+ // The derived column goes into a copy of each row. A table that already holds that name would
407
+ // lose its own column under the derived one, and the chart would plot the wrong cells.
408
+ return err(problem(blockId, `The transform of "${column}" derives the column "${name}", which the bound table already holds.`));
409
+ }
410
+ resolved[channel] = name;
411
+ derived.push({ column, name, transform });
412
+ }
413
+ const block = { id: blockId, chartType, encoding: resolved, labels, orientation };
414
+ return ok({ block, rows: derived.length === 0 ? rows : deriveTransformedRows(rows, derived) });
415
+ }
416
+ /** The four channels of the quick path, in one fixed order. */
417
+ const CHANNELS = ["x", "y", "group", "value"];
418
+ /**
419
+ * Copy each row with its derived columns beside the source columns. A row that one transform leaves
420
+ * without a value drops, thus no substitute value ever appears.
421
+ */
422
+ function deriveTransformedRows(rows, derived) {
423
+ const values = derived.map((entry) => transformColumn(rows, entry.column, entry.transform));
424
+ const out = [];
425
+ for (let index = 0; index < rows.length; index += 1) {
426
+ const next = { ...rows[index] };
427
+ let complete = true;
428
+ for (let slot = 0; slot < derived.length; slot += 1) {
429
+ const value = values[slot][index];
430
+ if (value === null) {
431
+ complete = false;
432
+ break;
433
+ }
434
+ next[derived[slot].name] = value;
435
+ }
436
+ if (complete)
437
+ out.push(next);
438
+ }
439
+ return out;
440
+ }
69
441
  // ── The per-type derivations ────────────────────────────────────────────────
70
- /** A category x axis, a value y axis, and one bar series per group with the `[x, y]` pairs. */
442
+ /**
443
+ * A category axis, a value axis, and one bar series per group with the pairs of the two channels.
444
+ *
445
+ * The vertical arrangement counts the categories along x and measures up y. The horizontal arrangement
446
+ * swaps the two axes, and the pair of each point swaps with them: the chart runtime reads the first member
447
+ * of a pair on x, thus the value leads there. The channels themselves do not move, and `x` names the
448
+ * category column under both arrangements.
449
+ *
450
+ * The category axis of the horizontal arrangement keeps every label. `normalizeEchartSpec` pins the label
451
+ * interval of each axis, and it turns an x label alone, thus a long name on y reads level and none of them
452
+ * drops. The same arrangement inverts that axis and contains its labels, for the reasons that
453
+ * `HORIZONTAL_CATEGORY_AXIS` and `LABEL_CONTAINING_GRID` give.
454
+ */
71
455
  function deriveBar(block, rows, columns) {
72
456
  const xResult = requireColumn(block, rows, columns, "x");
73
457
  if (xResult.isErr())
@@ -77,16 +461,26 @@ function deriveBar(block, rows, columns) {
77
461
  return err(yResult.error);
78
462
  const x = xResult.value;
79
463
  const y = yResult.value;
464
+ const horizontal = block.orientation === "horizontal";
80
465
  const categories = firstAppearance(rows.map((row) => row[x]));
81
466
  const series = groupedSeries(rows, block.encoding.group, (groupRows, name) => ({
82
467
  type: "bar",
83
- ...(name !== undefined ? { name: String(name) } : {}),
468
+ ...(name !== undefined ? { name: categoryName(name) } : {}),
469
+ ...nullCategoryStyle(name),
84
470
  barGap: 0,
85
- data: groupRows.map((row) => [row[x], row[y]]),
471
+ data: groupRows.map((row) => (horizontal ? [row[y], row[x]] : [row[x], row[y]])),
86
472
  }));
473
+ if (horizontal) {
474
+ return ok({
475
+ xAxis: { type: "value", ...xAxisName(axisTitle(block.labels, y)) },
476
+ yAxis: { ...HORIZONTAL_CATEGORY_AXIS, data: categories, name: axisTitle(block.labels, x) },
477
+ series,
478
+ grid: { ...LABEL_CONTAINING_GRID },
479
+ });
480
+ }
87
481
  return ok({
88
- xAxis: { type: "category", data: categories, ...xAxisName(x) },
89
- yAxis: { type: "value", name: y },
482
+ xAxis: { type: "category", data: categories, ...xAxisName(axisTitle(block.labels, x)) },
483
+ yAxis: { type: "value", name: axisTitle(block.labels, y) },
90
484
  series,
91
485
  });
92
486
  }
@@ -102,13 +496,14 @@ function deriveLine(block, rows, columns) {
102
496
  const y = yResult.value;
103
497
  const series = groupedSeries(rows, block.encoding.group, (groupRows, name) => ({
104
498
  type: "line",
105
- ...(name !== undefined ? { name: String(name) } : {}),
499
+ ...(name !== undefined ? { name: categoryName(name) } : {}),
500
+ ...nullCategoryStyle(name),
106
501
  showSymbol: false,
107
502
  data: sortByX(groupRows.map((row) => [row[x], row[y]])),
108
503
  }));
109
504
  return ok({
110
- xAxis: inferAxis(rows, x, "x"),
111
- yAxis: inferAxis(rows, y, "y"),
505
+ xAxis: inferAxis(rows, x, "x", axisTitle(block.labels, x)),
506
+ yAxis: inferAxis(rows, y, "y", axisTitle(block.labels, y)),
112
507
  series,
113
508
  });
114
509
  }
@@ -124,17 +519,28 @@ function deriveScatter(block, rows, columns) {
124
519
  const y = yResult.value;
125
520
  const series = groupedSeries(rows, block.encoding.group, (groupRows, name) => ({
126
521
  type: "scatter",
127
- ...(name !== undefined ? { name: String(name) } : {}),
522
+ ...(name !== undefined ? { name: categoryName(name) } : {}),
523
+ ...nullCategoryStyle(name),
128
524
  large: true,
129
525
  largeThreshold: 2000,
130
526
  data: groupRows.map((row) => [row[x], row[y]]),
131
527
  }));
132
528
  return ok({
133
- xAxis: inferAxis(rows, x, "x"),
134
- yAxis: inferAxis(rows, y, "y"),
529
+ xAxis: inferAxis(rows, x, "x", axisTitle(block.labels, x)),
530
+ yAxis: inferAxis(rows, y, "y", axisTitle(block.labels, y)),
135
531
  series,
136
532
  });
137
533
  }
534
+ /**
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.
539
+ */
540
+ function histogramXAxis(labels, column) {
541
+ const label = declaredForColumn(labels, column);
542
+ return { type: "value", scale: true, ...(label !== undefined ? xAxisName(label) : {}) };
543
+ }
138
544
  /** Equal-width bins over the global range. Each group shares the same edges. */
139
545
  function deriveHistogram(block, rows, columns) {
140
546
  const xResult = requireColumn(block, rows, columns, "x");
@@ -144,8 +550,8 @@ function deriveHistogram(block, rows, columns) {
144
550
  const groupCol = block.encoding.group;
145
551
  if (rows.length === 0) {
146
552
  return ok({
147
- xAxis: { type: "value", scale: true },
148
- yAxis: { type: "value", name: "Count" },
553
+ xAxis: histogramXAxis(block.labels, x),
554
+ yAxis: { ...COUNT_AXIS },
149
555
  series: [{ type: "bar", barWidth: "99%", data: [] }],
150
556
  });
151
557
  }
@@ -166,8 +572,8 @@ function deriveHistogram(block, rows, columns) {
166
572
  }
167
573
  }
168
574
  return ok({
169
- xAxis: { type: "value", scale: true },
170
- yAxis: { type: "value", name: "Count" },
575
+ xAxis: histogramXAxis(block.labels, x),
576
+ yAxis: { ...COUNT_AXIS },
171
577
  series,
172
578
  });
173
579
  }
@@ -202,22 +608,22 @@ function deriveBox(block, rows, columns) {
202
608
  }
203
609
  series.push({
204
610
  type: "boxplot",
205
- ...(group.name !== undefined ? { name: String(group.name) } : {}),
611
+ ...(group.name !== undefined ? { name: categoryName(group.name) } : {}),
206
612
  data: boxData,
207
613
  });
208
614
  // The outlier scatter pairs with its box series, thus it only appears when an outlier exists.
209
615
  if (outliers.length > 0) {
210
616
  series.push({
211
617
  type: "scatter",
212
- ...(group.name !== undefined ? { name: String(group.name) } : {}),
618
+ ...(group.name !== undefined ? { name: categoryName(group.name) } : {}),
213
619
  symbolSize: 4,
214
620
  data: outliers,
215
621
  });
216
622
  }
217
623
  }
218
624
  return ok({
219
- xAxis: { type: "category", data: categories, ...xAxisName(x) },
220
- yAxis: { type: "value", name: y },
625
+ xAxis: { type: "category", data: categories, ...xAxisName(axisTitle(block.labels, x)) },
626
+ yAxis: { type: "value", name: axisTitle(block.labels, y) },
221
627
  series,
222
628
  });
223
629
  }
@@ -259,8 +665,8 @@ function deriveHeatmap(block, rows, columns) {
259
665
  const min = finite.length > 0 ? Math.min(...finite) : 0;
260
666
  const max = finite.length > 0 ? Math.max(...finite) : 1;
261
667
  return ok({
262
- xAxis: { type: "category", data: xCategories.map(String), ...xAxisName(x), splitArea: { show: true } },
263
- yAxis: { type: "category", data: yCategories.map(String), name: y, splitArea: { show: true } },
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 } },
264
670
  visualMap: {
265
671
  type: "continuous",
266
672
  min,
@@ -299,6 +705,641 @@ function derivePie(block, rows, columns) {
299
705
  series: [{ type: "pie", radius: "55%", data }],
300
706
  });
301
707
  }
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";
715
+ }
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
+ /**
723
+ * The tooltip of a composition whose points carry no name.
724
+ *
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.
727
+ */
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}" };
731
+ /**
732
+ * The label of one named point. `{b}` is the name of the item, thus the label needs no function.
733
+ *
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.
736
+ */
737
+ export const POINT_LABEL = { show: true, formatter: "{b}" };
738
+ /**
739
+ * The member of the option that carries the data source.
740
+ *
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.
743
+ */
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 = [];
774
+ 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);
780
+ }
781
+ }
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
+ });
798
+ }
799
+ /** Resolve each channel of one declared series against the rows. An absent column is a refusal. */
800
+ function resolveSeries(blockId, declared, rows, columns) {
801
+ const x = resolveChannel(blockId, declared.encoding.x, rows, columns);
802
+ if (x.isErr())
803
+ return err(x.error);
804
+ const y = resolveChannel(blockId, declared.encoding.y, rows, columns);
805
+ if (y.isErr())
806
+ return err(y.error);
807
+ const resolved = { declared, x: x.value, y: y.value };
808
+ const declaredY0 = declared.encoding.y0;
809
+ if (declaredY0 !== undefined) {
810
+ const y0 = resolveChannel(blockId, declaredY0, rows, columns);
811
+ if (y0.isErr())
812
+ return err(y0.error);
813
+ resolved.y0 = y0.value;
814
+ }
815
+ const declaredGroup = declared.encoding.group;
816
+ if (declaredGroup !== undefined) {
817
+ const group = resolveChannel(blockId, declaredGroup, rows, columns);
818
+ if (group.isErr())
819
+ return err(group.error);
820
+ resolved.group = group.value;
821
+ }
822
+ const labelColumn = declared.encoding.label;
823
+ if (labelColumn !== undefined) {
824
+ const absent = requirePresent(blockId, labelColumn, rows, columns);
825
+ if (absent !== undefined)
826
+ return err(absent);
827
+ resolved.label = rows.map((row) => row[labelColumn] ?? null);
828
+ }
829
+ return ok(resolved);
830
+ }
831
+ /** Resolve one channel: its effective name, and the value that each row gives for it. */
832
+ function resolveChannel(blockId, channel, rows, columns) {
833
+ const column = channelColumn(channel);
834
+ const absent = requirePresent(blockId, column, rows, columns);
835
+ if (absent !== undefined)
836
+ return err(absent);
837
+ const transform = channelTransform(channel);
838
+ if (transform === undefined) {
839
+ return ok({ name: column, values: rows.map((row) => row[column] ?? null), transformed: false, column });
840
+ }
841
+ return ok({ name: transformedName(transform, column), values: transformColumn(rows, column, transform), transformed: true, column, transform });
842
+ }
843
+ /** The refusal for a column that no row holds, or `undefined` when the column is present. */
844
+ function requirePresent(blockId, column, rows, columns) {
845
+ if (rows.length > 0 && !columnPresent(column, rows, columns)) {
846
+ return problem(blockId, `The column "${column}" is absent from every row.`);
847
+ }
848
+ return undefined;
849
+ }
850
+ /**
851
+ * The splits of one declared series.
852
+ *
853
+ * A declared group channel splits the rows, because the author asked for that split. A preset that carries
854
+ * a classification splits them where no channel does. A series with neither takes every row.
855
+ */
856
+ function splitSeries(rows, entry, classification) {
857
+ if (entry.group === undefined && classification !== undefined) {
858
+ return splitByClassification(rows, entry, classification);
859
+ }
860
+ return splitByChannel(rows, entry.group);
861
+ }
862
+ /**
863
+ * Split the row indices by the classification of a preset, one row at a time.
864
+ *
865
+ * Each category takes one split, in the order that the preset declares. Thus an empty category still emits
866
+ * a series, and the legend of one preset reads the same on every table. The classification reads the
867
+ * plotted pair of the row, thus it computes no aggregate and it compares against the drawn guides.
868
+ */
869
+ function splitByClassification(rows, entry, classification) {
870
+ const splits = classification.categories.map((category, place) => ({
871
+ name: category.name,
872
+ indices: [],
873
+ muted: category.muted,
874
+ category: place,
875
+ }));
876
+ const byName = new Map(splits.map((split) => [String(split.name), split]));
877
+ for (let index = 0; index < rows.length; index += 1) {
878
+ const category = classification.categoryOf(toNumber(entry.x.values[index]), toNumber(entry.y.values[index]));
879
+ if (category === undefined)
880
+ continue;
881
+ byName.get(category)?.indices.push(index);
882
+ }
883
+ return splits;
884
+ }
885
+ /**
886
+ * Split the row indices by the group channel, in first-appearance order.
887
+ *
888
+ * A row whose group cell gives no value belongs to no series, thus it drops. A series with no group
889
+ * channel takes every row.
890
+ */
891
+ function splitByChannel(rows, group) {
892
+ if (group === undefined) {
893
+ return [{ name: undefined, indices: rows.map((_row, index) => index) }];
894
+ }
895
+ const buckets = [];
896
+ const byKey = new Map();
897
+ for (let index = 0; index < rows.length; index += 1) {
898
+ const value = group.values[index];
899
+ if (value === null)
900
+ continue;
901
+ // The key carries the type of the cell, thus the number `1` and the string `"1"` are two groups.
902
+ const key = `${typeof value}:${String(value)}`;
903
+ let bucket = byKey.get(key);
904
+ if (bucket === undefined) {
905
+ bucket = { name: value, indices: [] };
906
+ byKey.set(key, bucket);
907
+ buckets.push(bucket);
908
+ }
909
+ bucket.indices.push(index);
910
+ }
911
+ return buckets;
912
+ }
913
+ /**
914
+ * Build the runtime series of one declared series over one split of rows.
915
+ *
916
+ * A series of the null category takes the muted chart color, thus it recedes behind the categories that
917
+ * 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.
920
+ *
921
+ * A band draws two stacked line series, and no preset emits a band. Thus the null-category color never
922
+ * reaches one, and neither half of a band reports as muted.
923
+ */
924
+ function buildSeries(blockId, entry, split, labeled, density, emittedCount, labels, preset, collector) {
925
+ const form = entry.declared.form;
926
+ const points = collectPoints(entry, split.indices);
927
+ if (SORTED_FORMS.has(form)) {
928
+ points.sort((a, b) => compareCell(a.x, b.x));
929
+ }
930
+ const name = seriesName(entry, split.name, labels, preset?.y);
931
+ if (entry.y0 !== undefined) {
932
+ if (collector !== undefined) {
933
+ // A band draws two stacked series over one row set, and the upper one holds a difference that no
934
+ // cell of the table gives. Thus no descriptor states it, and the chart keeps its inline data.
935
+ collector.failed = true;
936
+ }
937
+ const band = bandSeries(blockId, entry, name, points, `band-${emittedCount}`);
938
+ if (band.isErr())
939
+ return err(band.error);
940
+ 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 },
943
+ ]);
944
+ }
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 };
948
+ if (collector !== undefined) {
949
+ collectSource(collector, entry, split, labeled, form);
950
+ }
951
+ return ok([{ option, carriesMarks: true, muted, empty: data.length === 0 }]);
952
+ }
953
+ /**
954
+ * Collect the page-side build of one runtime series.
955
+ *
956
+ * The descriptor names each column by its place in the payload. A column that the payload does not hold
957
+ * describes nothing, thus such a series marks the whole collection as failed and the chart stays inline.
958
+ *
959
+ * The flags name the rows of this split alone. Thus a page-side build tests one small list for each series,
960
+ * and a split by a group channel or by a classification carries the labels of the rows that it holds.
961
+ */
962
+ function collectSource(collector, entry, split, labeled, form) {
963
+ const x = columnSource(collector, entry.x);
964
+ const y = columnSource(collector, entry.y);
965
+ const group = entry.group === undefined ? undefined : columnSource(collector, entry.group);
966
+ const labelColumn = entry.declared.encoding.label;
967
+ const label = labelColumn === undefined ? undefined : collector.columns.indexOf(labelColumn);
968
+ if (x === undefined || y === undefined || (entry.group !== undefined && group === undefined) || label === -1) {
969
+ collector.failed = true;
970
+ return;
971
+ }
972
+ const flags = split.indices.filter((index) => labeled.has(index));
973
+ collector.series.push({
974
+ x,
975
+ y,
976
+ ...(group !== undefined ? { group } : {}),
977
+ ...(split.category === undefined && split.name !== undefined ? { value: split.name } : {}),
978
+ ...(split.category !== undefined ? { category: split.category } : {}),
979
+ ...(label !== undefined ? { label } : {}),
980
+ ...(flags.length > 0 ? { flags } : {}),
981
+ ...(SORTED_FORMS.has(form) ? { sort: true } : {}),
982
+ ...(isHorizontalBar(entry.declared) ? { swap: true } : {}),
983
+ });
984
+ }
985
+ /** The payload column of one resolved channel, or `undefined` when the payload holds no such column. */
986
+ function columnSource(collector, channel) {
987
+ const column = collector.columns.indexOf(channel.column);
988
+ if (column < 0) {
989
+ return undefined;
990
+ }
991
+ return channel.transform === undefined ? { column } : { column, transform: channel.transform };
992
+ }
993
+ /**
994
+ * The item style of one runtime series: the muted color, and the opacity of a crowd.
995
+ *
996
+ * One member owns the item style, thus a muted crowd keeps both fields. A series that states neither emits
997
+ * no item style, and the theme answers for it.
998
+ */
999
+ function seriesItemStyle(muted, form, density) {
1000
+ const fields = {
1001
+ ...(muted ? { color: MUTED_CHART_COLOR } : {}),
1002
+ ...(form === "scatter" && density === "crowd" ? { opacity: SCATTER_CROWD_OPACITY } : {}),
1003
+ };
1004
+ return Object.keys(fields).length > 0 ? { itemStyle: fields } : {};
1005
+ }
1006
+ /**
1007
+ * The index of the series that carries the mark members, or `-1` when no series can carry them.
1008
+ *
1009
+ * The chart runtime takes the stroke of a guide from the item color of its carrier, wherever the mark states
1010
+ * no color of its own. A muted carrier would thus paint each guide in the null-category color, and a carrier
1011
+ * that holds no point risks a guide that the runtime never lays out.
1012
+ *
1013
+ * Thus the ladder reads: a carrier with points and a palette color, then any carrier with a palette color,
1014
+ * then any carrier at all. Each guide reaches the page under every one of the three.
1015
+ */
1016
+ function markCarrier(emitted) {
1017
+ const drawn = emitted.findIndex((entry) => entry.carriesMarks && !entry.muted && !entry.empty);
1018
+ if (drawn >= 0)
1019
+ return drawn;
1020
+ const colored = emitted.findIndex((entry) => entry.carriesMarks && !entry.muted);
1021
+ return colored >= 0 ? colored : emitted.findIndex((entry) => entry.carriesMarks);
1022
+ }
1023
+ /** The points of one group. A row whose channel gives no value drops, and no substitute value appears. */
1024
+ function collectPoints(entry, indices) {
1025
+ const points = [];
1026
+ for (const index of indices) {
1027
+ const x = entry.x.values[index];
1028
+ const y = entry.y.values[index];
1029
+ if (x === null || y === null)
1030
+ continue;
1031
+ if (entry.y0 === undefined) {
1032
+ points.push({ index, x, y });
1033
+ continue;
1034
+ }
1035
+ const y0 = entry.y0.values[index];
1036
+ if (y0 === null)
1037
+ continue;
1038
+ points.push({ index, x, y, y0 });
1039
+ }
1040
+ return points;
1041
+ }
1042
+ /**
1043
+ * The name of one runtime series.
1044
+ *
1045
+ * Each series carries a name, thus the `{a}` of the tooltip template always names something. A series with
1046
+ * no declared name and no group takes the name of its y channel. That name resolves the same chain as the y
1047
+ * axis, the declared label over the preset title over the raw name, thus one chart names one column one way
1048
+ * and the tooltip of a group-less preset reads no machine text.
1049
+ *
1050
+ * The category value of a group prettifies, and the declared name of a series and the label of a column
1051
+ * both stay as the author wrote them. The tooltip reads this same name, thus the legend and the hover agree.
1052
+ */
1053
+ function seriesName(entry, group, labels, preset) {
1054
+ const declared = entry.declared.name;
1055
+ if (declared !== undefined && group !== undefined)
1056
+ return `${declared} (${categoryName(group)})`;
1057
+ if (declared !== undefined)
1058
+ return declared;
1059
+ if (group !== undefined)
1060
+ return categoryName(group);
1061
+ return axisTitle(labels, entry.y.name, preset);
1062
+ }
1063
+ /**
1064
+ * The data of one runtime series.
1065
+ *
1066
+ * A bare pair is the smallest item that states one point. A point that carries a name, or that the rank
1067
+ * rule marks, takes the object form, because only an object item holds a name and a label.
1068
+ *
1069
+ * The chart runtime reads the first member of a pair on the x axis. Thus a horizontal bar leads with its
1070
+ * value, and the category follows. The name of a point still reads the category channel, because the name
1071
+ * of a bar is what it counts and not how much it counts.
1072
+ */
1073
+ function seriesData(entry, points, labeled, horizontal) {
1074
+ const data = [];
1075
+ let itemObjects = false;
1076
+ for (const point of points) {
1077
+ const pair = horizontal ? [point.y, point.x] : [point.x, point.y];
1078
+ const label = entry.label?.[point.index];
1079
+ const named = label !== undefined && label !== null;
1080
+ const marked = labeled.has(point.index);
1081
+ if (!named && !marked) {
1082
+ data.push(pair);
1083
+ continue;
1084
+ }
1085
+ itemObjects = true;
1086
+ data.push({
1087
+ value: pair,
1088
+ // A marked point of a series with no label channel takes the x cell as its name. Thus the
1089
+ // `{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),
1091
+ ...(marked ? { label: { ...POINT_LABEL } } : {}),
1092
+ });
1093
+ }
1094
+ return { data, itemObjects };
1095
+ }
1096
+ /**
1097
+ * The two runtime series of a band.
1098
+ *
1099
+ * The chart runtime stacks a band. Thus the lower series carries the `y0` column, the upper series carries
1100
+ * the difference between the two columns, and the stack puts the upper line back on the `y` column. The
1101
+ * two series show no tooltip, because the difference is no cell of the table.
1102
+ *
1103
+ * A stack takes a difference that is not negative. A row where `y` is under `y0` names the lower bound as
1104
+ * the upper one, thus the band would draw from the axis and state a bound that no cell holds. The two
1105
+ * columns are in the wrong order, and the refusal names the row.
1106
+ */
1107
+ function bandSeries(blockId, entry, name, points, stack) {
1108
+ const lower = [];
1109
+ const upper = [];
1110
+ for (const point of points) {
1111
+ const base = toNumber(point.y0);
1112
+ const top = toNumber(point.y);
1113
+ if (base === null || top === null)
1114
+ continue;
1115
+ if (top < base) {
1116
+ const detail = `The band of the series "${name}" holds a row where the "${entry.y.name}" value ${top} ` +
1117
+ `is under the "${entry.y0?.name ?? ""}" value ${base}. The upper bound belongs on "y".`;
1118
+ return err(problem(blockId, detail));
1119
+ }
1120
+ lower.push([point.x, base]);
1121
+ upper.push([point.x, top - base]);
1122
+ }
1123
+ return ok([
1124
+ { type: "line", name, stack, showSymbol: false, silent: true, lineStyle: { opacity: 0 }, tooltip: { show: false }, data: lower },
1125
+ { type: "line", name, stack, showSymbol: false, areaStyle: { opacity: BAND_OPACITY }, tooltip: { show: false }, data: upper },
1126
+ ]);
1127
+ }
1128
+ /** The runtime type of one form. A step and an area are both a line with one more field. */
1129
+ function runtimeType(form) {
1130
+ switch (form) {
1131
+ case "bar":
1132
+ return "bar";
1133
+ case "scatter":
1134
+ return "scatter";
1135
+ case "line":
1136
+ case "area":
1137
+ case "step":
1138
+ return "line";
1139
+ }
1140
+ }
1141
+ /** The fields that one form adds to its runtime series. A scatter reads the density ladder here. */
1142
+ function formOptions(form, density, itemObjects) {
1143
+ switch (form) {
1144
+ case "bar":
1145
+ // The base bar rule puts the bars of one category side by side with no gap between them. A bar
1146
+ // of either path then reads the same.
1147
+ return { barGap: 0 };
1148
+ case "line":
1149
+ return { showSymbol: false };
1150
+ case "step":
1151
+ return { step: "end", showSymbol: false };
1152
+ case "area":
1153
+ return { showSymbol: false, areaStyle: {} };
1154
+ case "scatter":
1155
+ return {
1156
+ ...(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 }),
1160
+ };
1161
+ }
1162
+ }
1163
+ /**
1164
+ * The row indices that one series at least can draw.
1165
+ *
1166
+ * A channel gives no value for a cell that it cannot read, and the point of such a row drops. Thus a rank
1167
+ * rule that marked such a row would spend a place on a point that the plot never shows.
1168
+ */
1169
+ function plottedRows(resolved, rowCount) {
1170
+ const plotted = new Set();
1171
+ for (let index = 0; index < rowCount; index += 1) {
1172
+ for (const entry of resolved) {
1173
+ if (entry.x.values[index] === null || entry.y.values[index] === null)
1174
+ continue;
1175
+ if (entry.y0 !== undefined && entry.y0.values[index] === null)
1176
+ continue;
1177
+ if (entry.group !== undefined && entry.group.values[index] === null)
1178
+ continue;
1179
+ plotted.add(index);
1180
+ break;
1181
+ }
1182
+ }
1183
+ return plotted;
1184
+ }
1185
+ /**
1186
+ * The row indices that the point-label annotations mark. An absent rank column is a refusal.
1187
+ *
1188
+ * The marks name rows of the bound table, and every split reads them under the same numbers. Thus a split
1189
+ * by a group channel or by a preset classification carries each flag into the series that holds its row.
1190
+ */
1191
+ function pointLabelRows(blockId, annotations, rows, columns, plotted) {
1192
+ const marked = new Set();
1193
+ for (const annotation of annotations) {
1194
+ if (annotation.kind !== "point-labels")
1195
+ continue;
1196
+ const absent = requirePresent(blockId, annotation.column, rows, columns);
1197
+ if (absent !== undefined)
1198
+ return err(absent);
1199
+ for (const index of topRows(rows, annotation.column, annotation.order, annotation.n, plotted)) {
1200
+ marked.add(index);
1201
+ }
1202
+ }
1203
+ return ok(marked);
1204
+ }
1205
+ /**
1206
+ * The indices of the first `n` rows under the order of one column.
1207
+ *
1208
+ * A row whose cell is absent takes no place, and a row that no series draws takes no place either. Thus the
1209
+ * count of shown labels reaches the declared count wherever the table holds enough drawn rows. The sort is
1210
+ * stable, thus two equal cells keep the row order and the subset is the same on every host.
1211
+ */
1212
+ function topRows(rows, column, order, n, plotted) {
1213
+ const ranked = [];
1214
+ for (let index = 0; index < rows.length; index += 1) {
1215
+ if (plotted.has(index) && rows[index][column] !== undefined)
1216
+ ranked.push(index);
1217
+ }
1218
+ ranked.sort((a, b) => {
1219
+ const compared = compareCell(rows[a][column], rows[b][column]);
1220
+ return order === "asc" ? compared : -compared;
1221
+ });
1222
+ return ranked.slice(0, n);
1223
+ }
1224
+ /**
1225
+ * The mark members of the annotations.
1226
+ *
1227
+ * 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.
1233
+ */
1234
+ function markMembers(annotations) {
1235
+ const lines = [];
1236
+ const areas = [];
1237
+ for (const annotation of annotations) {
1238
+ 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) });
1241
+ continue;
1242
+ }
1243
+ if (annotation.kind === "reference-band") {
1244
+ const position = annotation.axis === "x" ? VERTICAL_BAND_LABEL_POSITION : undefined;
1245
+ areas.push([
1246
+ { [axisKey(annotation.axis)]: annotation.from, ...markLabel(annotation.label, position) },
1247
+ { [axisKey(annotation.axis)]: annotation.to },
1248
+ ]);
1249
+ }
1250
+ }
1251
+ return {
1252
+ ...(lines.length > 0 ? { markLine: { silent: true, symbol: "none", data: lines } } : {}),
1253
+ ...(areas.length > 0 ? { markArea: { silent: true, data: areas } } : {}),
1254
+ };
1255
+ }
1256
+ /**
1257
+ * The label of one mark member. The text is a constant of the annotation, and never a template.
1258
+ *
1259
+ * A member that carries neither a text nor a position emits no label member. Thus the chart runtime keeps
1260
+ * its own default, and a member of either kind that states nothing gives the same bytes as before.
1261
+ */
1262
+ function markLabel(label, position) {
1263
+ const fields = {
1264
+ ...(label !== undefined ? { formatter: label } : {}),
1265
+ ...(position !== undefined ? { position } : {}),
1266
+ };
1267
+ return Object.keys(fields).length > 0 ? { label: fields } : {};
1268
+ }
1269
+ /** The mark key of one axis. A mark member names `xAxis` or `yAxis`, and the constant that sits on it. */
1270
+ function axisKey(axis) {
1271
+ return axis === "x" ? "xAxis" : "yAxis";
1272
+ }
1273
+ /**
1274
+ * The two axes of a composition.
1275
+ *
1276
+ * A vertical bar counts its categories on x, and every other form reads the inferred axis there. A
1277
+ * horizontal bar swaps the two: the category channel renders on y, and the value channel renders on x.
1278
+ *
1279
+ * A declared axis names the axis that it renders on, exactly as an annotation does. Thus `axes.x` titles
1280
+ * the value axis of a horizontal bar. A declared column label and a preset title both follow their own
1281
+ * column, thus each one lands on whichever axis draws that column.
1282
+ */
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
+ };
1294
+ }
1295
+ /**
1296
+ * The x axis of a composition whose bars stand up, or of any other form.
1297
+ *
1298
+ * A bar takes a category axis, and every other form takes the inferred axis. A declared scale is the one
1299
+ * exception, because the author asked for a numeric axis and a category axis has no scale.
1300
+ */
1301
+ function compositionXAxis(rows, first, declared, labels, preset) {
1302
+ if (first.declared.form !== "bar" || declared?.scale !== undefined) {
1303
+ return compositionAxis(rows, first.x, "x", declared, labels, preset);
1304
+ }
1305
+ return barCategoryAxis(rows, first.x, "x", declared, labels, preset);
1306
+ }
1307
+ /**
1308
+ * The category axis of a composition bar, on whichever axis it renders.
1309
+ *
1310
+ * 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.
1313
+ *
1314
+ * A category axis on y inverts, thus the first row of the table reads at the top of the plot.
1315
+ */
1316
+ function barCategoryAxis(rows, channel, axis, declared, labels, preset) {
1317
+ if (declared?.scale !== undefined) {
1318
+ return compositionAxis(rows, channel, axis, declared, labels, preset);
1319
+ }
1320
+ const categories = firstAppearance(channel.values.filter((value) => value !== null));
1321
+ const title = declared?.title ?? axisTitle(labels, channel.name, preset);
1322
+ const typeFields = axis === "x" ? { type: "category" } : { ...HORIZONTAL_CATEGORY_AXIS };
1323
+ const nameFields = axis === "x" ? xAxisName(title) : { name: title };
1324
+ return { ...typeFields, data: categories, ...nameFields };
1325
+ }
1326
+ /**
1327
+ * The axis of one composition channel.
1328
+ *
1329
+ * A declared axis title wins, because it names this one axis. The declared label of the column comes next,
1330
+ * then the semantic title of the preset, and the raw column name answers last. A declared `log` scale maps
1331
+ * onto the logarithmic axis type. A transformed channel gives a number for each point that survives it,
1332
+ * thus its axis is a value axis and the cells of the untransformed column decide nothing.
1333
+ */
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 };
1339
+ }
1340
+ const base = channel.transformed ? { type: "value", scale: true } : inferAxis(rows, channel.name, axis, title);
1341
+ return { ...base, ...nameFields };
1342
+ }
302
1343
  // ── The shared building blocks ──────────────────────────────────────────────
303
1344
  /** A typed `invalid-chart-input` problem that names the block and the cause. */
304
1345
  function problem(blockId, detail) {
@@ -320,6 +1361,81 @@ function requireColumn(block, rows, columns, channel) {
320
1361
  }
321
1362
  return ok(column);
322
1363
  }
1364
+ /**
1365
+ * Resolve one demanded channel of a quick-path encoding to the channel itself.
1366
+ *
1367
+ * The composition derivation matches the column against the rows, thus this check answers for the
1368
+ * presence of the channel alone and it names the channel that the chart type demands.
1369
+ */
1370
+ function requireChannel(blockId, chartType, encoding, channel) {
1371
+ const declared = encoding[channel];
1372
+ if (declared === undefined) {
1373
+ return err(problem(blockId, `The ${chartType} chart needs a column for the "${channel}" channel.`));
1374
+ }
1375
+ return ok(declared);
1376
+ }
1377
+ /**
1378
+ * The name of a derived column, for example `neg_log10(padj)`.
1379
+ *
1380
+ * The name carries the transform, thus an axis that takes its name from the column never states the
1381
+ * untransformed quantity.
1382
+ */
1383
+ function transformedName(transform, column) {
1384
+ return `${transform}(${column})`;
1385
+ }
1386
+ /**
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.
1392
+ */
1393
+ export function transformColumn(rows, column, transform) {
1394
+ if (transform === "rank") {
1395
+ return rankColumn(rows, column);
1396
+ }
1397
+ return rows.map((row) => applyTransform(toNumber(row[column]), transform));
1398
+ }
1399
+ /**
1400
+ * One per-row transform.
1401
+ *
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.
1404
+ */
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);
1415
+ }
1416
+ }
1417
+ /**
1418
+ * The competition rank of each cell of one column, over the ascending order of the column.
1419
+ *
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.
1423
+ */
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;
1436
+ }
1437
+ return values.map((value) => (value === null ? null : (places.get(value) ?? null)));
1438
+ }
323
1439
  /** True when the column exists in the declared header, or in one row at least. */
324
1440
  function columnPresent(column, rows, columns) {
325
1441
  if (columns !== undefined && columns.includes(column))
@@ -330,7 +1446,7 @@ function columnPresent(column, rows, columns) {
330
1446
  }
331
1447
  return false;
332
1448
  }
333
- /** Convert one cell to a finite number, or `null` when it is not numeric. */
1449
+ /** Convert one cell to a finite number, or `null` when it is absent or not numeric. */
334
1450
  function toNumber(cell) {
335
1451
  if (typeof cell === "number")
336
1452
  return Number.isFinite(cell) ? cell : null;
@@ -374,10 +1490,11 @@ function numericColumn(rows, column) {
374
1490
  * with its distinct values as strings. Any other column is a value axis with `scale: true`.
375
1491
  *
376
1492
  * The `axis` argument names the channel that the column feeds. An x column takes the centered name, and a
377
- * y column keeps the default name placement.
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.
378
1495
  */
379
- function inferAxis(rows, column, axis) {
380
- const nameFields = axis === "x" ? xAxisName(column) : { name: column };
1496
+ function inferAxis(rows, column, axis, title) {
1497
+ const nameFields = axis === "x" ? xAxisName(title) : { name: title };
381
1498
  if (rows.some((row) => isNonNumericString(row[column]))) {
382
1499
  return { type: "category", data: firstAppearance(rows.map((row) => row[column])).map(String), ...nameFields };
383
1500
  }
@@ -412,13 +1529,19 @@ function sortByX(pairs) {
412
1529
  return [...pairs].sort((a, b) => compareCell(a[0], b[0]));
413
1530
  }
414
1531
  /**
415
- * Compare two cells for a sort by x. Two numbers compare by their difference. Any other pair compares
416
- * by the code-unit order of the string form. The comparison never calls `localeCompare`, thus the order
417
- * stays the same on every host.
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.
418
1539
  */
419
1540
  function compareCell(a, b) {
420
- if (typeof a === "number" && typeof b === "number") {
421
- return a - b;
1541
+ const leftNumber = toNumber(a);
1542
+ const rightNumber = toNumber(b);
1543
+ if (leftNumber !== null && rightNumber !== null) {
1544
+ return leftNumber - rightNumber;
422
1545
  }
423
1546
  const left = String(a);
424
1547
  const right = String(b);
@@ -501,7 +1624,7 @@ function histogramSeries(values, edges, name) {
501
1624
  }
502
1625
  return {
503
1626
  type: "bar",
504
- name: String(name),
1627
+ name: categoryName(name),
505
1628
  barWidth: "99%",
506
1629
  barGap: "-100%",
507
1630
  itemStyle: { opacity: 0.7 },