@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
@@ -0,0 +1,759 @@
1
+ /**
2
+ * The shared parts of each chart: the axis builders, the guide lines, the statistics text, the color scale,
3
+ * the size legend, the categorical palette, and the legend icons.
4
+ *
5
+ * The shared chart path of `chart.ts` and each figure module read these helpers, and no figure writes an axis
6
+ * style of its own. Thus each rule of a publication figure holds in one place, and each chart of a page obeys
7
+ * it:
8
+ *
9
+ * - The y title turns 90 degrees and sits in the middle beside its axis. The x title sits in the middle under
10
+ * its axis.
11
+ * - A category axis shows a title only where the block declares one. A value axis always shows a title.
12
+ * - A guide line is thin, gray, and dashed. Its label sits inside the plot at the far end of the line, thus it
13
+ * never covers a tick label.
14
+ * - A continuous color scale prints its title and its two ends as static text, and it shows no drag control.
15
+ * - Eight categories or fewer take the Okabe-Ito palette, and more take the wide palette.
16
+ * - A legend icon matches the form of its series.
17
+ *
18
+ * Each helper gives plain data, because the option rides to the page as inline JSON.
19
+ */
20
+ import { err, ok } from "neverthrow";
21
+ import { channelColumn, channelOrder, channelTransform } from "../../contracts/report-blocks.js";
22
+ import { declaredForColumn } from "../../contracts/report-reference.js";
23
+ import { CHART_BODY_MAX_PX, CHART_FONT_STACK, CHART_INK, CHART_PAGE_TEXT_PX, CHART_PALETTE, CHART_WIDE_PALETTE, DIVERGING_RAMP, GUIDE_LINE_COLOR, GUIDE_LINE_WIDTH_PX, MUTED_CHART_COLOR, SEQUENTIAL_RAMP, SIZE_CHANNEL_RANGE_PX, } from "../design.js";
24
+ import { formatNumberCell, selectNumberKind, typographicExponent } from "../number-format.js";
25
+ import { STEM_RENDERER } from "../chart-renderers.js";
26
+ // ── The cells ───────────────────────────────────────────────────────────────
27
+ /** A typed `invalid-chart-input` problem that names the block and the cause. */
28
+ export function chartProblem(blockId, detail) {
29
+ return { blockId, kind: "invalid-chart-input", detail };
30
+ }
31
+ /** Convert one cell to a finite number, or `null` when it is absent or not numeric. */
32
+ export function toNumber(cell) {
33
+ if (typeof cell === "number")
34
+ return Number.isFinite(cell) ? cell : null;
35
+ if (typeof cell === "string") {
36
+ const trimmed = cell.trim();
37
+ if (trimmed === "")
38
+ return null;
39
+ const parsed = Number(trimmed);
40
+ return Number.isFinite(parsed) ? parsed : null;
41
+ }
42
+ return null;
43
+ }
44
+ /**
45
+ * The legend text of one category value: the value with each underscore as a space.
46
+ *
47
+ * An analysis column carries a machine category such as `up_in_nonresponders`, and a legend reads for a
48
+ * person. The replacement reads no locale, thus the same value gives the same text on every host. The raw
49
+ * value stays in the data rows, thus the provenance loses nothing.
50
+ */
51
+ export function categoryName(value) {
52
+ return String(value).replaceAll("_", " ");
53
+ }
54
+ /**
55
+ * The lane of each span in turn, for at most `count` spans: the lowest lane whose last span ends under the start
56
+ * of the next span, else a new lane under the others.
57
+ *
58
+ * A tree over the lanes holds the least end of each range of lanes, thus the lowest free lane takes one walk down
59
+ * the tree, and a track of many overlapping spans takes linear-logarithmic time. A lane that holds no span yet
60
+ * ends at minus infinity, thus the lowest such lane is the next new lane.
61
+ */
62
+ export function firstFreeLanes(count) {
63
+ let leaves = 1;
64
+ while (leaves < count)
65
+ leaves *= 2;
66
+ const least = new Array(2 * leaves).fill(Number.NEGATIVE_INFINITY);
67
+ return (start, end) => {
68
+ let node = 1;
69
+ while (node < leaves)
70
+ node = least[2 * node] < start ? 2 * node : 2 * node + 1;
71
+ least[node] = end;
72
+ for (let parent = node >> 1; parent >= 1; parent >>= 1)
73
+ least[parent] = Math.min(least[2 * parent], least[2 * parent + 1]);
74
+ return node - leaves;
75
+ };
76
+ }
77
+ /** The distinct values in first-appearance order. */
78
+ export function firstAppearance(values) {
79
+ const seen = new Set();
80
+ const order = [];
81
+ for (const value of values) {
82
+ if (!seen.has(value)) {
83
+ seen.add(value);
84
+ order.push(value);
85
+ }
86
+ }
87
+ return order;
88
+ }
89
+ /** True when the column exists in the declared header, or in one row at least. */
90
+ export function columnPresent(column, rows, columns) {
91
+ if (columns !== undefined && columns.includes(column))
92
+ return true;
93
+ for (const row of rows) {
94
+ if (Object.prototype.hasOwnProperty.call(row, column))
95
+ return true;
96
+ }
97
+ return false;
98
+ }
99
+ /**
100
+ * Compare two cells for a sort by x, and for a rank rule.
101
+ *
102
+ * A pair that both hold a finite number compares by that number. A text-backed table gives each cell as a
103
+ * string, thus a code-unit order would put `"10"` between `"1"` and `"2"` and a time axis would run out of
104
+ * order. Any other pair compares by the code-unit order of the string form.
105
+ *
106
+ * The comparison never calls `localeCompare`, thus the order stays the same on every host.
107
+ */
108
+ export function compareCell(a, b) {
109
+ const leftNumber = toNumber(a);
110
+ const rightNumber = toNumber(b);
111
+ if (leftNumber !== null && rightNumber !== null) {
112
+ return leftNumber - rightNumber;
113
+ }
114
+ const left = String(a);
115
+ const right = String(b);
116
+ if (left < right)
117
+ return -1;
118
+ if (left > right)
119
+ return 1;
120
+ return 0;
121
+ }
122
+ /**
123
+ * Sort the categories of one channel by the value of its order column.
124
+ *
125
+ * The key of a category is the cell of the order column in each row of that category, and it must be one
126
+ * value across those rows. A category whose rows disagree has no one place, thus it refuses and names the
127
+ * category. The compare of the sort reads two numbers as numbers and anything else as text. The sort is
128
+ * stable and a descending order negates the compare, thus a tie keeps the first appearance in both
129
+ * directions.
130
+ *
131
+ * `categoryAt` gives the category of one row, or no value for a row that draws none, and `keyAt` gives the
132
+ * cell of the order column of one row. The categories and the rows match by their text, thus a category list
133
+ * of strings and a column of numbers still meet. A row that lacks the order cell gives its category no place,
134
+ * thus it refuses.
135
+ */
136
+ export function orderCategories(blockId, rowCount, categoryAt, categories, order, keyAt) {
137
+ if (order === undefined) {
138
+ return ok([...categories]);
139
+ }
140
+ const keys = new Map();
141
+ for (let index = 0; index < rowCount; index += 1) {
142
+ const category = categoryAt(index);
143
+ if (category === null || category === undefined)
144
+ continue;
145
+ const key = keyAt(index);
146
+ if (key === undefined) {
147
+ return err(chartProblem(blockId, `The category "${categoryName(category)}" holds no value in the "${order.by}" column, thus it has no place in the order.`));
148
+ }
149
+ const held = keys.get(String(category));
150
+ if (held === undefined) {
151
+ keys.set(String(category), key);
152
+ }
153
+ else if (compareCell(held, key) !== 0) {
154
+ return err(chartProblem(blockId, `The category "${categoryName(category)}" holds two values in the "${order.by}" column, thus it has no one place in the order.`));
155
+ }
156
+ }
157
+ const direction = order.order === "desc" ? -1 : 1;
158
+ return ok(categories
159
+ .map((category, place) => ({ category, place, key: keys.get(String(category)) ?? "" }))
160
+ .sort((a, b) => direction * compareCell(a.key, b.key) || a.place - b.place)
161
+ .map((entry) => entry.category));
162
+ }
163
+ /**
164
+ * Sort the categories of one quick-path channel that reads a plain column, by the order of that channel.
165
+ * The row of each category reads the category column and the order column of the same row.
166
+ */
167
+ export function orderQuickCategories(blockId, rows, column, order) {
168
+ return orderCategories(blockId, rows.length, (index) => rows[index][column], firstAppearance(rows.map((row) => row[column])), order, (index) => (order === undefined ? undefined : rows[index][order.by]));
169
+ }
170
+ /**
171
+ * The column of one channel that a figure reads as a plain column, or `undefined` where the block names no
172
+ * such channel.
173
+ *
174
+ * A figure reads the cells of such a channel as they are. Thus a transform or an order on the channel would
175
+ * do nothing, and it refuses. A column that no row holds refuses, as it does on every other chart.
176
+ */
177
+ export function plainColumn(figure, encoding, channel, source) {
178
+ const declared = encoding[channel];
179
+ if (declared === undefined)
180
+ return ok(undefined);
181
+ if (channelTransform(declared) !== undefined || channelOrder(declared) !== undefined) {
182
+ return err(chartProblem(source.blockId, `The ${figure} figure reads the "${channel}" channel as a plain column, thus it takes no transform and no order.`));
183
+ }
184
+ const column = channelColumn(declared);
185
+ if (source.rows.length > 0 && !columnPresent(column, source.rows, source.columns)) {
186
+ return err(chartProblem(source.blockId, `The column "${column}" is absent from every row.`));
187
+ }
188
+ return ok(column);
189
+ }
190
+ /** The column of one channel that a figure demands, read as a plain column. An absent channel refuses. */
191
+ export function demandedColumn(figure, encoding, channel, source) {
192
+ return plainColumn(figure, encoding, channel, source).andThen((column) => column === undefined ? err(chartProblem(source.blockId, `The ${figure} figure needs a column for the "${channel}" channel.`)) : ok(column));
193
+ }
194
+ /**
195
+ * The transformed value of one column, one entry for each row. A row with no usable cell gives `null`.
196
+ *
197
+ * The page-side series build holds the twin of this function, because a chart that reads the payload
198
+ * transforms its columns in the browser. A shared test vector runs the two over one set of cells, thus the
199
+ * two cannot give different numbers in silence.
200
+ */
201
+ export function transformColumn(rows, column, transform) {
202
+ if (transform === "rank") {
203
+ return rankColumn(rows, column);
204
+ }
205
+ return rows.map((row) => applyTransform(toNumber(row[column]), transform));
206
+ }
207
+ /**
208
+ * One per-row transform.
209
+ *
210
+ * `log10` and `neg_log10` give no value for a cell that is not positive, thus the point drops. No
211
+ * substitute value ever appears in its place.
212
+ */
213
+ function applyTransform(value, transform) {
214
+ if (value === null)
215
+ return null;
216
+ switch (transform) {
217
+ case "log10":
218
+ return value > 0 ? Math.log10(value) : null;
219
+ case "neg_log10":
220
+ return value > 0 ? -Math.log10(value) : null;
221
+ case "abs":
222
+ return Math.abs(value);
223
+ }
224
+ }
225
+ /**
226
+ * The competition rank of each cell of one column, over the ascending order of the column.
227
+ *
228
+ * The smallest value takes the place 1, and a tie shares its place. Thus the place after a tie of two
229
+ * skips one number, which is what a competition rank states. A cell with no number takes no place, and
230
+ * its point drops. The rank reads the column alone, thus the same rows give the same places.
231
+ */
232
+ function rankColumn(rows, column) {
233
+ const values = rows.map((row) => toNumber(row[column]));
234
+ const counts = new Map();
235
+ for (const value of values) {
236
+ if (value !== null)
237
+ counts.set(value, (counts.get(value) ?? 0) + 1);
238
+ }
239
+ const places = new Map();
240
+ let place = 1;
241
+ for (const value of [...counts.keys()].sort((a, b) => a - b)) {
242
+ places.set(value, place);
243
+ place += counts.get(value) ?? 0;
244
+ }
245
+ return values.map((value) => (value === null ? null : (places.get(value) ?? null)));
246
+ }
247
+ // ── The axes ────────────────────────────────────────────────────────────────
248
+ /**
249
+ * The distance in pixels between the x axis line and its name.
250
+ *
251
+ * The name sits under the middle of the axis, thus the gap must clear the axis labels below the line. The
252
+ * value is a fixed constant, thus the derivation stays deterministic.
253
+ */
254
+ export const X_AXIS_NAME_GAP = 34;
255
+ /**
256
+ * The distance in pixels between the tick labels of the y axis and its name.
257
+ *
258
+ * The chart runtime moves a name that overlaps the tick labels past them, and the gap then counts from the
259
+ * labels. Thus a long tick label never runs under the title.
260
+ */
261
+ export const Y_AXIS_NAME_GAP = 10;
262
+ /**
263
+ * The name fields of one axis, or no field where the axis carries no title.
264
+ *
265
+ * The x title sits under the middle of the axis. The y title turns 90 degrees and sits in the middle beside
266
+ * the axis, thus it reads along the axis and it takes no width from the plot.
267
+ */
268
+ export function axisNameFields(axis, title) {
269
+ if (title === undefined)
270
+ return {};
271
+ if (axis === "x")
272
+ return { name: title, nameLocation: "middle", nameGap: X_AXIS_NAME_GAP };
273
+ return { name: title, nameLocation: "middle", nameRotate: 90, nameGap: Y_AXIS_NAME_GAP };
274
+ }
275
+ /**
276
+ * The title of a quantity under one per-row transform, for example `log10(Normalized count)` or
277
+ * `−log10(Adjusted p-value)`. The minus sign is the character, not a hyphen.
278
+ */
279
+ export function transformedTitle(transform, base) {
280
+ switch (transform) {
281
+ case "log10":
282
+ return `log10(${base})`;
283
+ case "neg_log10":
284
+ return `−log10(${base})`;
285
+ case "abs":
286
+ return `|${base}|`;
287
+ case "rank":
288
+ return `rank of ${base}`;
289
+ }
290
+ }
291
+ /**
292
+ * The labels of a bound table with one more entry for each transformed channel whose column declares a label.
293
+ * The derived name of the channel maps to the label inside the transform, thus an axis over the channel reads
294
+ * `log10(Normalized count)` and never `log10(normalized_count)`. A derived name that the table declares itself
295
+ * keeps its own label, and a column with no label keeps the derived name.
296
+ */
297
+ export function withTransformedLabels(labels, channels) {
298
+ let out = labels;
299
+ for (const channel of channels) {
300
+ if (channel.transform === undefined || declaredForColumn(out, channel.name) !== undefined)
301
+ continue;
302
+ const label = declaredForColumn(labels, channel.column);
303
+ if (label !== undefined)
304
+ out = { ...out, [channel.name]: transformedTitle(channel.transform, label) };
305
+ }
306
+ return out;
307
+ }
308
+ /**
309
+ * The title of a value axis that reads one column, most specific first: the declared label of the column, the
310
+ * semantic title of the preset, then the raw column name.
311
+ *
312
+ * A declared label answers for this one column of this one artifact, and a preset title answers for every
313
+ * chart of its kind. Thus the label outranks the preset. The caller resolves an agent axes title over this
314
+ * whole chain, because that title names this one axis of this one block.
315
+ */
316
+ export function valueAxisTitle(labels, column, preset) {
317
+ return declaredForColumn(labels, column) ?? preset ?? column;
318
+ }
319
+ /**
320
+ * The title of a category axis, or `undefined` where the block declares none.
321
+ *
322
+ * The category names already state what the axis holds, and a raw column name such as `cluster` or `sample`
323
+ * repeats them in machine text. Thus a category axis carries a title only where the author declared one: an
324
+ * axes title of the block, or the label of the column.
325
+ */
326
+ export function categoryAxisTitle(labels, column, declared) {
327
+ return declared ?? declaredForColumn(labels, column);
328
+ }
329
+ /**
330
+ * One value axis: the axis type, a fitted range where the chart asks for one, and the name fields.
331
+ *
332
+ * `scale` fits the range to the data, and a bar leaves it out because a bar measures from zero. `log` gives
333
+ * the logarithmic axis type.
334
+ */
335
+ export function valueAxis(axis, title, fields = {}) {
336
+ return {
337
+ type: fields.log === true ? "log" : "value",
338
+ ...(fields.scale === true ? { scale: true } : {}),
339
+ ...(fields.min !== undefined ? { min: fields.min } : {}),
340
+ ...(fields.max !== undefined ? { max: fields.max } : {}),
341
+ ...axisNameFields(axis, title),
342
+ };
343
+ }
344
+ /**
345
+ * One category axis: the categories in their order, the direction, and the declared title alone.
346
+ *
347
+ * `topDown` inverts a y axis. The chart runtime draws the first category of a y axis at the origin, thus a
348
+ * table that reads top-down needs the inverted axis to put its first row at the top.
349
+ */
350
+ export function categoryAxis(axis, categories, fields = {}) {
351
+ return {
352
+ type: "category",
353
+ ...(axis === "y" && fields.topDown === true ? { inverse: true } : {}),
354
+ data: [...categories],
355
+ ...axisNameFields(axis, fields.title),
356
+ };
357
+ }
358
+ // ── The guide lines ─────────────────────────────────────────────────────────
359
+ /** The stroke of each guide line. */
360
+ export const GUIDE_LINE_STYLE = { color: GUIDE_LINE_COLOR, width: GUIDE_LINE_WIDTH_PX, type: "dashed" };
361
+ /**
362
+ * The label place of each guide line: inside the plot, at the far end of the line.
363
+ *
364
+ * A vertical line ends at the top of the plot and a horizontal line ends at the right edge. Both ends lie away
365
+ * from the tick labels, thus a guide label never covers a tick.
366
+ */
367
+ export const GUIDE_LABEL_POSITION = "insideEndTop";
368
+ /**
369
+ * One guide line of a mark member: a constant on one axis, and its label.
370
+ *
371
+ * A guide with no text shows no label. The runtime would print the constant there, and a bare number beside a
372
+ * line reads as a plotted value.
373
+ */
374
+ export function guideLine(axis, value, label) {
375
+ return {
376
+ [axis === "x" ? "xAxis" : "yAxis"]: value,
377
+ label: label !== undefined ? { show: true, position: GUIDE_LABEL_POSITION, formatter: label, color: CHART_INK } : { show: false },
378
+ };
379
+ }
380
+ /** The mark member of some guide lines, in the one guide style. */
381
+ export function guideMarkLine(lines) {
382
+ return { silent: true, symbol: "none", lineStyle: { ...GUIDE_LINE_STYLE }, data: [...lines] };
383
+ }
384
+ /**
385
+ * The shown text of one statistic, in the number format of its column, with the power of ten of a figure, and
386
+ * the unit of its reference after it.
387
+ *
388
+ * The kind follows the column of the locator, as a table cell does. Thus a p-value prints in the scientific
389
+ * form, for example `1.3 × 10⁻³`, and a stored zero of a p-value prints the below-resolution form and never a
390
+ * bare zero. A percent sign joins its value, for example `27.5%`, and every other unit follows a space, for
391
+ * example `426 days`.
392
+ */
393
+ export function statisticText(value, column, unit) {
394
+ const text = typographicExponent(formatNumberCell(value, selectNumberKind(column, value)).text);
395
+ if (unit === undefined || unit.trim() === "")
396
+ return text;
397
+ return unit === "%" ? `${text}%` : `${text} ${unit}`;
398
+ }
399
+ /** The two edges of the chart that each corner reads. */
400
+ const CORNER_SIDES = {
401
+ "top-left": { vertical: "top", horizontal: "left" },
402
+ "top-right": { vertical: "top", horizontal: "right" },
403
+ "bottom-left": { vertical: "bottom", horizontal: "left" },
404
+ "bottom-right": { vertical: "bottom", horizontal: "right" },
405
+ };
406
+ /**
407
+ * The inset of a statistics text from the edge of the chart, in percent of the chart. The inset clears the
408
+ * grid margins that the layout discipline gives, thus the text sits inside the plot.
409
+ */
410
+ const STATISTICS_INSET = { left: "12%", right: "7%", top: "12%", bottom: "24%" };
411
+ /**
412
+ * The statistics of a figure as one text element inside the plot: one line for each statistic, in block
413
+ * order, in the form `label = value`.
414
+ *
415
+ * A `graphic` element sits at a place of the chart and reads no data coordinate, thus the figure names the
416
+ * corner that its field uses. A figure with no statistic gives no element.
417
+ */
418
+ export function statisticsGraphic(statistics, corner) {
419
+ if (statistics.length === 0)
420
+ return [];
421
+ const { vertical, horizontal } = CORNER_SIDES[corner];
422
+ return [
423
+ {
424
+ type: "text",
425
+ [horizontal]: STATISTICS_INSET[horizontal],
426
+ [vertical]: STATISTICS_INSET[vertical],
427
+ z: 100,
428
+ silent: true,
429
+ style: {
430
+ text: statistics.map((statistic) => `${statistic.label} = ${statistic.text}`).join("\n"),
431
+ fill: CHART_INK,
432
+ fontFamily: CHART_FONT_STACK,
433
+ fontSize: CHART_PAGE_TEXT_PX,
434
+ align: horizontal,
435
+ },
436
+ },
437
+ ];
438
+ }
439
+ /** The inset of a statistics text from its data corner, in pixels. */
440
+ export const STATISTICS_POINT_INSET_PX = 8;
441
+ /**
442
+ * The statistics of a figure as the label of one point of no size at a corner of the data: one line for each
443
+ * statistic, in block order, in the form `label = value`.
444
+ *
445
+ * A `graphic` element sits at a place of the container, and a plot whose size follows the container moves
446
+ * away from it. A point sits in the data, thus its label stays at the corner of the plot in every container
447
+ * and in every export. The corner names the side of the point that the text takes. The label reads the text
448
+ * from the name of the point, thus no character of a statistic reads as a template of the chart runtime. A
449
+ * figure with no statistic gives no series.
450
+ */
451
+ export function statisticsSeries(statistics, point, corner) {
452
+ if (statistics.length === 0)
453
+ return [];
454
+ const { vertical, horizontal } = CORNER_SIDES[corner];
455
+ const inset = STATISTICS_POINT_INSET_PX;
456
+ return [
457
+ {
458
+ type: "scatter",
459
+ silent: true,
460
+ symbolSize: 0,
461
+ z: 100,
462
+ tooltip: { show: false },
463
+ label: {
464
+ show: true,
465
+ position: [0, 0],
466
+ formatter: "{b}",
467
+ align: horizontal,
468
+ verticalAlign: vertical,
469
+ offset: [horizontal === "left" ? inset : -inset, vertical === "top" ? inset : -inset],
470
+ color: CHART_INK,
471
+ },
472
+ data: [{ value: [...point], name: statistics.map((statistic) => `${statistic.label} = ${statistic.text}`).join("\n") }],
473
+ },
474
+ ];
475
+ }
476
+ /**
477
+ * The scale of one continuous color over its values.
478
+ *
479
+ * A column whose values cross zero takes the diverging ramp, centered on zero, thus a negative value and a
480
+ * positive value of one size read as two colors of one strength. Every other column takes the sequential
481
+ * ramp over its own range. The extent reads the values in one pass, thus a large column never spreads onto
482
+ * the call stack.
483
+ */
484
+ export function continuousScale(values) {
485
+ if (values.length === 0) {
486
+ return { min: 0, max: 1, ramp: SEQUENTIAL_RAMP };
487
+ }
488
+ let min = values[0];
489
+ let max = values[0];
490
+ for (const value of values) {
491
+ if (value < min)
492
+ min = value;
493
+ if (value > max)
494
+ max = value;
495
+ }
496
+ if (min < 0 && max > 0) {
497
+ const reach = Math.max(-min, max);
498
+ return { min: -reach, max: reach, ramp: DIVERGING_RAMP };
499
+ }
500
+ return { min, max, ramp: SEQUENTIAL_RAMP };
501
+ }
502
+ /** The shown text of one end of a scale over one column, in the number format of that column, with the power of ten of a figure. */
503
+ export function scaleEndText(end, column, meaning) {
504
+ return typographicExponent(formatNumberCell(end, selectNumberKind(column, end, meaning)).text);
505
+ }
506
+ /**
507
+ * The members of one continuous color map: the range, the place at the right edge of the plot, the title and
508
+ * the two ends as static text, and the ramp.
509
+ *
510
+ * The map shows no drag control, because a figure is a still image and a handle would print its own raw
511
+ * value. The number helper formats each end, thus the scale and a table cell of one column read alike. The
512
+ * title and the upper end share the top text, one line each, and the lower end sits under the bar.
513
+ */
514
+ export function colorScale(scale, title, column, meaning) {
515
+ return {
516
+ min: scale.min,
517
+ max: scale.max,
518
+ calculable: false,
519
+ orient: "vertical",
520
+ right: 0,
521
+ top: "middle",
522
+ text: [`${title}\n${scaleEndText(scale.max, column, meaning)}`, scaleEndText(scale.min, column, meaning)],
523
+ inRange: { color: [...scale.ramp] },
524
+ };
525
+ }
526
+ // ── The size legend ─────────────────────────────────────────────────────────
527
+ /** The gap in pixels between two reference circles of a size legend, and between a circle and its text. */
528
+ const SIZE_LEGEND_GAP_PX = 6;
529
+ /**
530
+ * The size legend of a size channel: the title, and three reference circles with their values.
531
+ *
532
+ * The circles show the smallest value, the middle of the range, and the largest value, at the symbol size
533
+ * that the size map gives each one. The number helper formats each value. The legend is one `graphic` group,
534
+ * thus a figure places it where its field puts it. A range of one value gives one circle.
535
+ */
536
+ export function sizeLegend(range, title, column, place, meaning) {
537
+ const [small, large] = SIZE_CHANNEL_RANGE_PX;
538
+ const values = range.max > range.min ? [range.min, (range.min + range.max) / 2, range.max] : [range.min];
539
+ const children = [
540
+ { type: "text", x: 0, y: 0, style: { text: title, fill: CHART_INK, fontFamily: CHART_FONT_STACK, fontSize: CHART_PAGE_TEXT_PX } },
541
+ ];
542
+ let y = CHART_PAGE_TEXT_PX + SIZE_LEGEND_GAP_PX;
543
+ for (const value of values) {
544
+ const diameter = range.max > range.min ? small + ((value - range.min) / (range.max - range.min)) * (large - small) : small;
545
+ const radius = diameter / 2;
546
+ children.push({ type: "circle", shape: { cx: large / 2, cy: y + radius, r: radius }, style: { fill: MUTED_CHART_COLOR } });
547
+ children.push({
548
+ type: "text",
549
+ x: large + SIZE_LEGEND_GAP_PX,
550
+ y: y + radius,
551
+ style: {
552
+ text: scaleEndText(value, column, meaning),
553
+ fill: CHART_INK,
554
+ fontFamily: CHART_FONT_STACK,
555
+ fontSize: CHART_PAGE_TEXT_PX,
556
+ verticalAlign: "middle",
557
+ },
558
+ });
559
+ y += diameter + SIZE_LEGEND_GAP_PX;
560
+ }
561
+ return { type: "group", ...place, children };
562
+ }
563
+ // ── The palette and the legend ──────────────────────────────────────────────
564
+ /** The categorical palette of a chart that draws `count` categories. */
565
+ export function categoricalPalette(count) {
566
+ return count <= CHART_PALETTE.length ? CHART_PALETTE : CHART_WIDE_PALETTE;
567
+ }
568
+ /** The width of one character of the chart text, and the line pitch of a legend, as a share of the text size. */
569
+ const LEGEND_CHARACTER_SHARE = 0.6;
570
+ const LEGEND_LINE_SHARE = 1.25;
571
+ /** The item gap of the legend in the theme, and the gap between an icon and its text, in pixels. */
572
+ const LEGEND_ITEM_GAP_PX = 16;
573
+ const LEGEND_ICON_GAP_PX = 5;
574
+ /**
575
+ * The count of lines that the entries of a horizontal legend fill at one width, at one text size. An entry is
576
+ * the icon of the text size, the gap to its text, and the text. The entries fill each line in order, one item
577
+ * gap apart. The export estimates the lines of a legend with the same rule.
578
+ */
579
+ export function legendLineCount(entries, widthPx, textPx) {
580
+ let lines = entries.length === 0 ? 0 : 1;
581
+ let used = 0;
582
+ for (const name of entries) {
583
+ const entry = textPx + LEGEND_ICON_GAP_PX + name.length * textPx * LEGEND_CHARACTER_SHARE;
584
+ if (used > 0 && used + LEGEND_ITEM_GAP_PX + entry > widthPx) {
585
+ lines += 1;
586
+ used = entry;
587
+ }
588
+ else {
589
+ used = used > 0 ? used + LEGEND_ITEM_GAP_PX + entry : entry;
590
+ }
591
+ }
592
+ return lines;
593
+ }
594
+ /** The height of one line of a legend at one text size, in pixels: the text and the item gap under it. */
595
+ export function legendLinePx(textPx, itemGapPx = LEGEND_ITEM_GAP_PX) {
596
+ return textPx * LEGEND_LINE_SHARE + itemGapPx;
597
+ }
598
+ /**
599
+ * The legend icon of a line and of a step: a flat bar of eight units by one. The legend keeps the aspect of
600
+ * the path, thus the icon draws as a short line at the width of one legend item.
601
+ */
602
+ export const LINE_LEGEND_ICON = "path://M0,0H8V1H0Z";
603
+ /** The legend icon of a bar, an area, a box, and a shape: a filled square. */
604
+ export const AREA_LEGEND_ICON = "rect";
605
+ /** The legend icon of a point. */
606
+ export const POINT_LEGEND_ICON = "circle";
607
+ /** The legend icon of one runtime series, by its form. */
608
+ export function legendIconOf(series) {
609
+ switch (series.type) {
610
+ case "line":
611
+ return series.areaStyle !== undefined ? AREA_LEGEND_ICON : LINE_LEGEND_ICON;
612
+ case "scatter":
613
+ case "effectScatter":
614
+ return POINT_LEGEND_ICON;
615
+ case "custom":
616
+ return series.renderItem === STEM_RENDERER ? POINT_LEGEND_ICON : AREA_LEGEND_ICON;
617
+ default:
618
+ return AREA_LEGEND_ICON;
619
+ }
620
+ }
621
+ /** The series of an option, as objects. */
622
+ function seriesOf(option) {
623
+ const series = option.series;
624
+ if (!Array.isArray(series))
625
+ return [];
626
+ return series.filter((entry) => typeof entry === "object" && entry !== null);
627
+ }
628
+ /**
629
+ * The icon of each series name: the form of the first series of that name that draws the data.
630
+ *
631
+ * A silent series of a name draws beside a series of the same name, for example the lower half of a band or
632
+ * the interval over a bar. Thus a name takes its icon from its first series that is not silent, and from its
633
+ * first series where every series of the name is silent.
634
+ */
635
+ function iconsByName(option) {
636
+ const icons = new Map();
637
+ const fallback = new Map();
638
+ for (const series of seriesOf(option)) {
639
+ if (typeof series.name !== "string")
640
+ continue;
641
+ if (!fallback.has(series.name))
642
+ fallback.set(series.name, legendIconOf(series));
643
+ if (series.silent !== true && !icons.has(series.name))
644
+ icons.set(series.name, legendIconOf(series));
645
+ }
646
+ for (const [name, icon] of fallback) {
647
+ if (!icons.has(name))
648
+ icons.set(name, icon);
649
+ }
650
+ return icons;
651
+ }
652
+ /**
653
+ * The legend of an option with an icon on each entry that names a series.
654
+ *
655
+ * A legend that shows states its entries. An entry that names a series takes the icon of that series, and an
656
+ * entry that names a data item keeps the default of the runtime. An entry that states its own icon keeps it,
657
+ * for example the symbol of a shape beside the circle of a group. A legend that states no entries lists the
658
+ * series names in order, as the runtime does.
659
+ */
660
+ function iconLegend(option) {
661
+ const legend = option.legend;
662
+ if (typeof legend !== "object" || legend === null || Array.isArray(legend))
663
+ return undefined;
664
+ const fields = legend;
665
+ if (fields.show === false)
666
+ return undefined;
667
+ const icons = iconsByName(option);
668
+ if (icons.size === 0)
669
+ return undefined;
670
+ const declared = Array.isArray(fields.data) ? fields.data : [...icons.keys()];
671
+ const data = declared.map((entry) => {
672
+ if (typeof entry === "object" && entry !== null && typeof entry.icon === "string")
673
+ return entry;
674
+ const name = typeof entry === "string" ? entry : typeof entry === "object" && entry !== null ? entry.name : undefined;
675
+ const icon = typeof name === "string" ? icons.get(name) : undefined;
676
+ if (icon === undefined || typeof name !== "string")
677
+ return entry;
678
+ return typeof entry === "object" && entry !== null ? { ...entry, icon } : { name, icon };
679
+ });
680
+ return { ...fields, data };
681
+ }
682
+ /**
683
+ * The count of palette slots that an option draws.
684
+ *
685
+ * The runtime gives a series its palette color by its name, thus series of one name share one slot. A pie
686
+ * and a radar give each data item its own slot.
687
+ */
688
+ function paletteSlots(option) {
689
+ const names = new Set();
690
+ let items = 0;
691
+ for (const series of seriesOf(option)) {
692
+ if (series.type === "pie" || series.type === "radar") {
693
+ items = Math.max(items, Array.isArray(series.data) ? series.data.length : 0);
694
+ continue;
695
+ }
696
+ if (typeof series.name === "string")
697
+ names.add(series.name);
698
+ }
699
+ return Math.max(items, names.size);
700
+ }
701
+ /**
702
+ * One axis with its title moved off its labels, where it carries a title.
703
+ *
704
+ * The chart runtime moves the title of an axis past the labels that it would cover, and it measures the drawn
705
+ * labels to do so. A grid that contains its labels turns the move off by default, thus a long category label
706
+ * of such a grid runs under the title. The rule states the move on each titled axis of such an option, thus
707
+ * the title clears the labels on the page and in each export.
708
+ */
709
+ function movedTitle(axis) {
710
+ if (typeof axis !== "object" || axis === null)
711
+ return axis;
712
+ const fields = axis;
713
+ return typeof fields.name === "string" && fields.name !== "" ? { ...fields, nameMoveOverlap: true } : axis;
714
+ }
715
+ /** True when a grid, or one grid of a list, contains its labels. */
716
+ function containsLabels(grid) {
717
+ const grids = Array.isArray(grid) ? grid : [grid];
718
+ return grids.some((entry) => typeof entry === "object" && entry !== null && entry.containLabel === true);
719
+ }
720
+ /** Each axis of one axis member with its title moved off its labels. */
721
+ function movedTitles(axes) {
722
+ return Array.isArray(axes) ? axes.map(movedTitle) : movedTitle(axes);
723
+ }
724
+ /**
725
+ * The figure rules that read the whole option: the wide palette past eight categories, the icon of each
726
+ * legend entry, and the title of each axis off its labels.
727
+ *
728
+ * The derivation and each figure module give an option, and this pass runs on it after the layout discipline.
729
+ * An option that states its own palette keeps it.
730
+ */
731
+ export function applyFigureRules(option) {
732
+ const out = { ...option };
733
+ if (out.color === undefined && paletteSlots(option) > CHART_PALETTE.length) {
734
+ out.color = [...CHART_WIDE_PALETTE];
735
+ }
736
+ const legend = iconLegend(option);
737
+ if (legend !== undefined)
738
+ out.legend = legend;
739
+ if (containsLabels(out.grid)) {
740
+ if (out.xAxis !== undefined)
741
+ out.xAxis = movedTitles(out.xAxis);
742
+ if (out.yAxis !== undefined)
743
+ out.yAxis = movedTitles(out.yAxis);
744
+ }
745
+ return out;
746
+ }
747
+ // ── The chart body ──────────────────────────────────────────────────────────
748
+ /**
749
+ * The option member in which a figure states the height of the page chart body that it needs, in pixels.
750
+ *
751
+ * A figure of many rows, for example a heatmap of many genes, needs a body taller than the default body to
752
+ * print each row name. The chart runtime reads no such field, and the render takes the member off the option
753
+ * before the option reaches the page or the export.
754
+ */
755
+ export const FIGURE_BODY_MEMBER = "reportFigureBodyPx";
756
+ /** One option with the height of the chart body that its figure needs, bounded by the largest body. */
757
+ export function withFigureBody(option, bodyPx) {
758
+ return { ...option, [FIGURE_BODY_MEMBER]: Math.min(CHART_BODY_MAX_PX, Math.ceil(bodyPx)) };
759
+ }