@inflexa-ai/harness 0.42.2 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +3 -1
  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,234 @@
1
+ /**
2
+ * The dot plot: a marker dot plot of a single-cell study, or the enrichment dot plot of a gene-set test.
3
+ *
4
+ * The canonical marker dot plot is the scanpy `pl.dotplot` and the Seurat `DotPlot`: the clusters down the y
5
+ * axis, the genes along x, the fraction of expressing cells as the dot size, and the mean expression as the
6
+ * dot color. The canonical enrichment dot plot is the enrichplot `dotplot`: the terms down the y axis, the gene
7
+ * ratio along x, the gene count as the dot size, and the adjusted p as the dot color. Each plot shows two
8
+ * keys: a size legend of three reference circles, and a sequential color scale with its two ends.
9
+ *
10
+ * The y axis reads top-down in the order of its channel, thus a table that sorts its terms by their ratio
11
+ * reads from the first term down. The x axis draws categories where its column holds text, and values where
12
+ * it holds numbers.
13
+ *
14
+ * The dots build through the composition machinery. The figure moves each dot onto the place of its category,
15
+ * and the page builds no such move, thus the rows stay inline whatever their count.
16
+ */
17
+ import { err, ok } from "neverthrow";
18
+ import { channelColumn, channelOrder, channelTransform } from "../../contracts/report-blocks.js";
19
+ import { declaredForColumn } from "../../contracts/report-reference.js";
20
+ import { CHART_BODY_PX, SEQUENTIAL_RAMP } from "../design.js";
21
+ import { categoryAxis, categoryAxisTitle, chartProblem, colorScale, orderQuickCategories, sizeLegend, toNumber, transformedTitle, withFigureBody, } from "./common.js";
22
+ /** The band at the right of the plot that holds the color scale and the size legend, in percent of the width. */
23
+ const DOTPLOT_KEY_BAND = "28%";
24
+ /** The top of the color scale, and its length in pixels. The size legend sits under it. */
25
+ const DOTPLOT_SCALE_TOP = "8%";
26
+ const DOTPLOT_SCALE_LENGTH_PX = 100;
27
+ /** The place of the size legend: at the bottom right corner, under the color scale. */
28
+ const DOTPLOT_SIZE_PLACE = { right: 8, bottom: 8 };
29
+ /**
30
+ * The room at each end of a value axis of dots, as a share of the axis. The largest dot sits at an end of the
31
+ * data, and the room keeps it inside the plot.
32
+ */
33
+ const DOTPLOT_VALUE_ROOM = ["6%", "6%"];
34
+ /**
35
+ * The longest category name that a y label draws on one line, in characters. enrichplot wraps a term at 30
36
+ * characters, thus a long term takes two lines and the plot keeps its width.
37
+ */
38
+ const DOTPLOT_WRAP_CHARACTERS = 30;
39
+ /** The most lines of one term. A longer term ends its last line with an ellipsis. */
40
+ const DOTPLOT_TERM_LINES = 2;
41
+ /** The height of one line of a y label, and the gap between two terms, in pixels at the page text size. */
42
+ export const DOTPLOT_LINE_PX = 14;
43
+ export const DOTPLOT_ROW_GAP_PX = 6;
44
+ /**
45
+ * The share of the body that the plot takes at least: the layout discipline gives the top margin and the band
46
+ * of the turned x labels and the x title.
47
+ */
48
+ const DOTPLOT_PLOT_SHARE = 2 / 3;
49
+ /**
50
+ * One term wrapped at its spaces into lines of 30 characters at most, two lines at most. A term past two lines
51
+ * ends its second line with an ellipsis, thus two terms never run into each other. A word longer than a line
52
+ * stays whole.
53
+ */
54
+ export function wrappedTerm(term) {
55
+ const lines = [];
56
+ let line = "";
57
+ for (const word of term.split(" ")) {
58
+ if (line === "")
59
+ line = word;
60
+ else if (line.length + 1 + word.length <= DOTPLOT_WRAP_CHARACTERS)
61
+ line = `${line} ${word}`;
62
+ else {
63
+ lines.push(line);
64
+ line = word;
65
+ }
66
+ }
67
+ if (line !== "")
68
+ lines.push(line);
69
+ if (lines.length <= DOTPLOT_TERM_LINES)
70
+ return lines.join("\n");
71
+ const kept = lines.slice(0, DOTPLOT_TERM_LINES);
72
+ const last = kept[DOTPLOT_TERM_LINES - 1].split(" ");
73
+ while (last.length > 1 && `${last.join(" ")}…`.length > DOTPLOT_WRAP_CHARACTERS)
74
+ last.pop();
75
+ kept[DOTPLOT_TERM_LINES - 1] = `${last.join(" ")}…`;
76
+ return kept.join("\n");
77
+ }
78
+ /**
79
+ * The column name that formats the ends of a transformed scale. A transform gives a number that is no p-value
80
+ * and no count, thus its ends format as a plain number whatever the source column.
81
+ */
82
+ const TRANSFORMED_COLUMN = "value";
83
+ /** The dot plot. */
84
+ export const DOTPLOT_FIGURE = {
85
+ reads: new Set(["x", "y", "size", "color", "label"]),
86
+ derive: deriveDotplot,
87
+ };
88
+ /** The title of a scale over one channel: the declared label or the column, inside its transform. */
89
+ function scaleTitle(labels, channel) {
90
+ const column = channelColumn(channel);
91
+ const base = declaredForColumn(labels, column) ?? column;
92
+ const transform = channelTransform(channel);
93
+ return transform === undefined ? base : transformedTitle(transform, base);
94
+ }
95
+ /**
96
+ * The column name and the meaning that format the ends of a scale over one channel. A transformed channel
97
+ * formats as a plain number, because its value is no p-value and no count of the source column.
98
+ */
99
+ function formatSource(context, channel) {
100
+ if (channelTransform(channel) !== undefined)
101
+ return [TRANSFORMED_COLUMN, undefined];
102
+ const column = channelColumn(channel);
103
+ return [column, declaredForColumn(context.meanings, column)];
104
+ }
105
+ /** The members of one item: the array form, or the value of the object form. */
106
+ function itemValue(item) {
107
+ if (Array.isArray(item))
108
+ return item;
109
+ if (typeof item === "object" && item !== null && Array.isArray(item.value))
110
+ return item.value;
111
+ return undefined;
112
+ }
113
+ /** One item with its y member set to the place of its category. */
114
+ function placedItem(item, place) {
115
+ const value = itemValue(item);
116
+ if (value === undefined)
117
+ return item;
118
+ const moved = [value[0], place, ...value.slice(2)];
119
+ return Array.isArray(item) ? moved : { ...item, value: moved };
120
+ }
121
+ function deriveDotplot(block, rows, context) {
122
+ const encoding = block.encoding ?? {};
123
+ const { x, y, size, color, label } = encoding;
124
+ if (x === undefined || y === undefined) {
125
+ return err(chartProblem(block.id, `The dotplot chart needs a column for the "${x === undefined ? "x" : "y"}" channel.`));
126
+ }
127
+ if (size === undefined && color === undefined) {
128
+ return err(chartProblem(block.id, 'The dotplot sizes its dots by a "size" channel and colors them by a "color" channel. Name one of the two at least.'));
129
+ }
130
+ if (channelTransform(y) !== undefined) {
131
+ return err(chartProblem(block.id, 'The "y" channel of the dotplot names categories, thus it takes no transform.'));
132
+ }
133
+ const yColumn = channelColumn(y);
134
+ const categories = orderQuickCategories(block.id, rows, yColumn, channelOrder(y));
135
+ if (categories.isErr())
136
+ return err(categories.error);
137
+ const composition = {
138
+ series: [
139
+ {
140
+ form: "scatter",
141
+ encoding: {
142
+ x,
143
+ // The figure orders the y categories itself, thus the composition reads the plain column.
144
+ y: yColumn,
145
+ ...(size !== undefined ? { size } : {}),
146
+ ...(color !== undefined ? { color } : {}),
147
+ ...(label !== undefined ? { label } : {}),
148
+ },
149
+ },
150
+ ],
151
+ };
152
+ // The figure moves each dot onto the place of its term, thus the rows stay inline.
153
+ const composed = context.compose(composition, { keepsRowsInline: true });
154
+ if (composed.isErr())
155
+ return err(composed.error);
156
+ const option = composed.value;
157
+ const places = new Map(categories.value.map((category, place) => [String(category), place]));
158
+ const series = (Array.isArray(option.series) ? option.series : []).map((entry) => ({
159
+ ...entry,
160
+ data: (Array.isArray(entry.data) ? entry.data : []).flatMap((item) => {
161
+ const place = places.get(String(itemValue(item)?.[1]));
162
+ return place === undefined ? [] : [placedItem(item, place)];
163
+ }),
164
+ }));
165
+ const wraps = categories.value.some((category) => String(category).length > DOTPLOT_WRAP_CHARACTERS);
166
+ // The series place each dot by the index of its term, thus the axis can show the wrapped text of a term.
167
+ const terms = wraps ? categories.value.map((category) => wrappedTerm(String(category))) : undefined;
168
+ const yAxis = {
169
+ ...categoryAxis("y", terms ?? categories.value, { title: categoryAxisTitle(context.labels, yColumn), topDown: true }),
170
+ ...(wraps ? { axisLabel: { lineHeight: DOTPLOT_LINE_PX } } : {}),
171
+ };
172
+ const lines = terms === undefined ? 1 : highestOf(terms.map((term) => term.split("\n").length));
173
+ const body = (categories.value.length * (lines * DOTPLOT_LINE_PX + DOTPLOT_ROW_GAP_PX)) / DOTPLOT_PLOT_SHARE;
174
+ const maps = Array.isArray(option.visualMap) ? option.visualMap : [];
175
+ const keys = [];
176
+ const graphic = [];
177
+ const colorMap = maps.find((entry) => entry.show !== false);
178
+ if (color !== undefined && colorMap !== undefined) {
179
+ const values = drawnMembers(series, 2);
180
+ const scale = { min: lowestOf(values), max: highestOf(values), ramp: SEQUENTIAL_RAMP };
181
+ const { right: _right, top: _top, ...scaled } = colorScale(scale, scaleTitle(context.labels, color), ...formatSource(context, color));
182
+ keys.push({ ...colorMap, ...scaled, right: 0, top: DOTPLOT_SCALE_TOP, itemHeight: DOTPLOT_SCALE_LENGTH_PX });
183
+ }
184
+ const sizeMaps = maps.filter((entry) => entry.show === false);
185
+ keys.push(...sizeMaps);
186
+ if (size !== undefined && sizeMaps.length > 0) {
187
+ const range = { min: toNumber(sizeMaps[0].min) ?? 0, max: toNumber(sizeMaps[0].max) ?? 0 };
188
+ graphic.push(sizeLegend(range, scaleTitle(context.labels, size), formatSource(context, size)[0], DOTPLOT_SIZE_PLACE, formatSource(context, size)[1]));
189
+ }
190
+ const composedX = typeof option.xAxis === "object" && option.xAxis !== null ? option.xAxis : {};
191
+ const xAxis = composedX.type === "value"
192
+ ? { ...composedX, boundaryGap: [...DOTPLOT_VALUE_ROOM], axisLabel: { ...composedX.axisLabel, hideOverlap: true } }
193
+ : composedX;
194
+ const drawn = {
195
+ ...option,
196
+ series,
197
+ xAxis,
198
+ yAxis,
199
+ visualMap: keys,
200
+ ...(graphic.length > 0 ? { graphic } : {}),
201
+ legend: { show: false },
202
+ // The grid holds its labels and its titles inside the chart, as the runtime does by default.
203
+ grid: { right: DOTPLOT_KEY_BAND },
204
+ };
205
+ return ok(body > CHART_BODY_PX ? withFigureBody(drawn, body) : drawn);
206
+ }
207
+ /** The members at one dimension of each item of some series. */
208
+ function drawnMembers(series, dimension) {
209
+ const members = [];
210
+ for (const entry of series) {
211
+ for (const item of Array.isArray(entry.data) ? entry.data : []) {
212
+ const member = toNumber(itemValue(item)?.[dimension]);
213
+ if (member !== null)
214
+ members.push(member);
215
+ }
216
+ }
217
+ return members;
218
+ }
219
+ /** The smallest of some values, or zero for none. */
220
+ function lowestOf(values) {
221
+ let low = values.length > 0 ? values[0] : 0;
222
+ for (const value of values)
223
+ if (value < low)
224
+ low = value;
225
+ return low;
226
+ }
227
+ /** The largest of some values, or one for none. */
228
+ function highestOf(values) {
229
+ let high = values.length > 0 ? values[0] : 1;
230
+ for (const value of values)
231
+ if (value > high)
232
+ high = value;
233
+ return high;
234
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The embedding of cells: a UMAP or a t-SNE, one point for each cell.
3
+ *
4
+ * The canonical figure is the scanpy `pl.umap` and the Seurat `DimPlot` of a single-cell study. The two
5
+ * coordinates carry no unit that a reader reads, thus the axes hide, and a small key at the bottom left corner
6
+ * names the two dimensions. One unit on x equals one unit on y, because the embedding places near cells near.
7
+ * The point size follows the cell count by the scanpy rule, and the points draw with a light opacity, thus a
8
+ * dense cluster reads by its shape.
9
+ *
10
+ * A `group` colors the cells by category, and each category name draws on the data at the median place of its
11
+ * cells, as scanpy `legend_loc="on data"` does. No legend draws beside the names. A `color` colors the cells on
12
+ * a continuous scale: a zero draws in a light gray ground, the scale clips at the 99th percentile, and the high
13
+ * values draw on top. A `facet` splits the cells into panels of one shared range.
14
+ *
15
+ * The points build through the composition machinery, thus a dense table reads the shared payload of its
16
+ * artifact. The names of the categories draw as a small series after the points, and it keeps its rows inline.
17
+ */
18
+ import type { FigureModule } from "./index.js";
19
+ /** The scanpy point area of one cell, in square points, for each cell of the plot: 120000 / n. */
20
+ export declare const SCANPY_AREA_PT2 = 120000;
21
+ /** The bounds of the point diameter of an embedding, in pixels. */
22
+ export declare const EMBEDDING_POINT_MIN_PX = 1;
23
+ export declare const EMBEDDING_POINT_MAX_PX = 6;
24
+ /** The opacity of a point of an embedding. An overlap of cells reads darker than a lone cell. */
25
+ export declare const EMBEDDING_OPACITY = 0.6;
26
+ /** The ground color of a cell whose continuous value is zero: the light gray of the Seurat `FeaturePlot`. */
27
+ export declare const EMBEDDING_GROUND = "#d3d3d3";
28
+ /** The percentile at which the continuous scale of an embedding clips, as scanpy `vmax="p99"` does. */
29
+ export declare const EMBEDDING_CLIP_QUANTILE = 0.99;
30
+ /**
31
+ * The cell count from which the points draw on the large path of the runtime. The large path draws a square
32
+ * for each point, thus a smaller embedding draws round points as scanpy does. The large path fills each point
33
+ * with the one color of its series and reads no per-point color of a scale, thus a continuous color never
34
+ * takes it.
35
+ */
36
+ export declare const EMBEDDING_LARGE_CELLS = 50000;
37
+ /** The embedding of cells. */
38
+ export declare const EMBEDDING_FIGURE: FigureModule;
39
+ /**
40
+ * The point diameter of an embedding of `cells` cells, in pixels: the scanpy area 120000 / n in square points,
41
+ * as a diameter at 96 pixels for each inch, bounded to the embedding range.
42
+ */
43
+ export declare function embeddingPointPx(cells: number): number;
@@ -0,0 +1,360 @@
1
+ /**
2
+ * The embedding of cells: a UMAP or a t-SNE, one point for each cell.
3
+ *
4
+ * The canonical figure is the scanpy `pl.umap` and the Seurat `DimPlot` of a single-cell study. The two
5
+ * coordinates carry no unit that a reader reads, thus the axes hide, and a small key at the bottom left corner
6
+ * names the two dimensions. One unit on x equals one unit on y, because the embedding places near cells near.
7
+ * The point size follows the cell count by the scanpy rule, and the points draw with a light opacity, thus a
8
+ * dense cluster reads by its shape.
9
+ *
10
+ * A `group` colors the cells by category, and each category name draws on the data at the median place of its
11
+ * cells, as scanpy `legend_loc="on data"` does. No legend draws beside the names. A `color` colors the cells on
12
+ * a continuous scale: a zero draws in a light gray ground, the scale clips at the 99th percentile, and the high
13
+ * values draw on top. A `facet` splits the cells into panels of one shared range.
14
+ *
15
+ * The points build through the composition machinery, thus a dense table reads the shared payload of its
16
+ * artifact. The names of the categories draw as a small series after the points, and it keeps its rows inline.
17
+ */
18
+ import { err, ok } from "neverthrow";
19
+ import { channelColumn, channelOrder, channelTransform } from "../../contracts/report-blocks.js";
20
+ import { declaredForColumn } from "../../contracts/report-reference.js";
21
+ import { CHART_INK, CHART_PAGE_TEXT_PX, DIVERGING_RAMP, SEQUENTIAL_RAMP } from "../design.js";
22
+ import { categoricalPalette, categoryName, chartProblem, colorScale, firstAppearance, toNumber, transformColumn, valueAxisTitle } from "./common.js";
23
+ import { equalRanges, squareLayout } from "./equal-units.js";
24
+ import { nameAnchors } from "./label-room.js";
25
+ /** The scanpy point area of one cell, in square points, for each cell of the plot: 120000 / n. */
26
+ export const SCANPY_AREA_PT2 = 120_000;
27
+ /** The bounds of the point diameter of an embedding, in pixels. */
28
+ export const EMBEDDING_POINT_MIN_PX = 1;
29
+ export const EMBEDDING_POINT_MAX_PX = 6;
30
+ /** The opacity of a point of an embedding. An overlap of cells reads darker than a lone cell. */
31
+ export const EMBEDDING_OPACITY = 0.6;
32
+ /** The ground color of a cell whose continuous value is zero: the light gray of the Seurat `FeaturePlot`. */
33
+ export const EMBEDDING_GROUND = "#d3d3d3";
34
+ /** The percentile at which the continuous scale of an embedding clips, as scanpy `vmax="p99"` does. */
35
+ export const EMBEDDING_CLIP_QUANTILE = 0.99;
36
+ /** The share of the longer span that pads each side of an embedding. */
37
+ const EMBEDDING_PAD = 0.03;
38
+ /**
39
+ * The margins of the squares of an embedding, in pixels: the panel name over each square, and the corner key
40
+ * under and beside the first square.
41
+ */
42
+ const EMBEDDING_MARGINS = { top: 22, bottom: 22, left: 22, right: 8 };
43
+ /**
44
+ * The smallest width of the band of the color scale at the right of the squares, in pixels. A longer scale
45
+ * title widens the band to its own width, thus the narrowed page body never cuts it.
46
+ */
47
+ const EMBEDDING_SCALE_BAND_PX = 96;
48
+ /** The width of one character of the scale title, as a share of the page text size, and the pad of the band. */
49
+ const SCALE_CHARACTER_SHARE = 0.6;
50
+ const SCALE_BAND_PAD_PX = 8;
51
+ /** The arrow of the corner key. It points along the axis that its dimension names. */
52
+ const KEY_ARROW = " →";
53
+ /** The width of the white outline of a category name on the data, in pixels, as scanpy `legend_fontoutline=2`. */
54
+ const NAME_OUTLINE_PX = 2;
55
+ /**
56
+ * The cell count from which the points draw on the large path of the runtime. The large path draws a square
57
+ * for each point, thus a smaller embedding draws round points as scanpy does. The large path fills each point
58
+ * with the one color of its series and reads no per-point color of a scale, thus a continuous color never
59
+ * takes it.
60
+ */
61
+ export const EMBEDDING_LARGE_CELLS = 50_000;
62
+ /** The drawing order of the category names, over each point. */
63
+ const NAME_Z = 10;
64
+ /** The axis parts that an embedding hides: the line, the ticks, the tick labels, and the grid lines. */
65
+ const HIDDEN_AXIS_PARTS = {
66
+ axisLine: { show: false },
67
+ axisTick: { show: false },
68
+ axisLabel: { show: false },
69
+ splitLine: { show: false },
70
+ };
71
+ /** The embedding of cells. */
72
+ export const EMBEDDING_FIGURE = {
73
+ reads: new Set(["x", "y", "group", "color", "facet", "label"]),
74
+ derive: deriveEmbedding,
75
+ };
76
+ /**
77
+ * The point diameter of an embedding of `cells` cells, in pixels: the scanpy area 120000 / n in square points,
78
+ * as a diameter at 96 pixels for each inch, bounded to the embedding range.
79
+ */
80
+ export function embeddingPointPx(cells) {
81
+ const areaPt2 = SCANPY_AREA_PT2 / Math.max(1, cells);
82
+ const diameterPx = 2 * Math.sqrt(areaPt2 / Math.PI) * (96 / 72);
83
+ return Math.round(Math.min(EMBEDDING_POINT_MAX_PX, Math.max(EMBEDDING_POINT_MIN_PX, diameterPx)) * 10) / 10;
84
+ }
85
+ /** The value of one channel in each row: the transformed number where the channel declares a transform. */
86
+ function channelValues(rows, channel) {
87
+ const column = channelColumn(channel);
88
+ const transform = channelTransform(channel);
89
+ return transform === undefined ? rows.map((row) => row[column] ?? null) : transformColumn(rows, column, transform);
90
+ }
91
+ /** The axis name of one channel: the column, with its transform where it declares one. */
92
+ function channelName(channel) {
93
+ const transform = channelTransform(channel);
94
+ return transform === undefined ? channelColumn(channel) : `${transform}(${channelColumn(channel)})`;
95
+ }
96
+ function deriveEmbedding(block, rows, context) {
97
+ const encoding = block.encoding ?? {};
98
+ const { x, y, group, color, facet, label } = encoding;
99
+ if (x === undefined || y === undefined) {
100
+ return err(chartProblem(block.id, `The embedding chart needs a column for the "${x === undefined ? "x" : "y"}" channel.`));
101
+ }
102
+ if (group === undefined && color === undefined) {
103
+ return err(chartProblem(block.id, 'The embedding colors its cells by a "group" channel or by a "color" channel. Name one of the two.'));
104
+ }
105
+ if (group !== undefined && color !== undefined) {
106
+ return err(chartProblem(block.id, 'A "color" channel beside a "group" channel is a fault, because one chart colors by one channel.'));
107
+ }
108
+ for (const [member, channel] of [
109
+ ["x", x],
110
+ ["y", y],
111
+ ]) {
112
+ if (channelOrder(channel) !== undefined) {
113
+ return err(chartProblem(block.id, `The "${member}" channel of the embedding draws a hidden value axis, thus it takes no "orderBy".`));
114
+ }
115
+ }
116
+ const composition = {
117
+ series: [
118
+ {
119
+ form: "scatter",
120
+ encoding: {
121
+ x,
122
+ y,
123
+ ...(group !== undefined ? { group } : {}),
124
+ ...(color !== undefined ? { color } : {}),
125
+ ...(label !== undefined ? { label } : {}),
126
+ },
127
+ },
128
+ ],
129
+ ...(facet !== undefined ? { facet } : {}),
130
+ };
131
+ const composed = context.compose(composition, color !== undefined ? { colorOnTop: true } : {});
132
+ if (composed.isErr())
133
+ return err(composed.error);
134
+ const xs = channelValues(rows, x);
135
+ const ys = channelValues(rows, y);
136
+ const groups = group === undefined ? undefined : channelValues(rows, group);
137
+ const colors = color === undefined ? undefined : channelValues(rows, color);
138
+ const facets = facet === undefined ? undefined : rows.map((row) => row[channelColumn(facet)] ?? null);
139
+ const panelNames = facets === undefined ? [] : firstAppearance(facets.filter((cell) => cell !== null).map((cell) => `${typeof cell}:${String(cell)}`));
140
+ const panelOf = new Map(panelNames.map((key, place) => [key, place]));
141
+ const cells = [];
142
+ for (let index = 0; index < rows.length; index += 1) {
143
+ const px = toNumber(xs[index]);
144
+ const py = toNumber(ys[index]);
145
+ if (px === null || py === null)
146
+ continue;
147
+ const cellGroup = groups?.[index] ?? null;
148
+ if (groups !== undefined && cellGroup === null)
149
+ continue;
150
+ const cellColor = colors === undefined ? null : toNumber(colors[index]);
151
+ if (colors !== undefined && cellColor === null)
152
+ continue;
153
+ const facetCell = facets?.[index] ?? null;
154
+ if (facets !== undefined && facetCell === null)
155
+ continue;
156
+ cells.push({
157
+ x: px,
158
+ y: py,
159
+ panel: facetCell === null ? 0 : (panelOf.get(`${typeof facetCell}:${String(facetCell)}`) ?? 0),
160
+ ...(cellGroup !== null ? { group: categoryName(cellGroup) } : {}),
161
+ ...(cellColor !== null ? { color: cellColor } : {}),
162
+ });
163
+ }
164
+ const option = composed.value;
165
+ const panels = Math.max(1, panelNames.length);
166
+ const pointPx = embeddingPointPx(cells.length);
167
+ const series = (Array.isArray(option.series) ? option.series : []).map((entry) => ({
168
+ ...entry,
169
+ large: color === undefined && cells.length >= EMBEDDING_LARGE_CELLS,
170
+ symbolSize: pointPx,
171
+ itemStyle: {
172
+ ...(typeof entry.itemStyle === "object" && entry.itemStyle !== null ? entry.itemStyle : {}),
173
+ opacity: EMBEDDING_OPACITY,
174
+ },
175
+ }));
176
+ const ranges = equalRanges(cells.map((cell) => cell.x), cells.map((cell) => cell.y), { pad: EMBEDDING_PAD, round: false });
177
+ const scaleTitle = color !== undefined ? valueAxisTitle(context.labels, channelName(color)) : "";
178
+ const scaleBand = Math.max(EMBEDDING_SCALE_BAND_PX, Math.ceil(scaleTitle.length * CHART_PAGE_TEXT_PX * SCALE_CHARACTER_SHARE) + SCALE_BAND_PAD_PX);
179
+ const layout = squareLayout({
180
+ panels,
181
+ margins: EMBEDDING_MARGINS,
182
+ side: color !== undefined ? scaleBand : 0,
183
+ visualMaps: color !== undefined ? 1 : 0,
184
+ points: { series: series.length, px: pointPx },
185
+ });
186
+ const xAxes = [];
187
+ const yAxes = [];
188
+ const keyStyle = { color: CHART_INK, align: "left" };
189
+ for (let panel = 0; panel < panels; panel += 1) {
190
+ const key = panel === 0;
191
+ xAxes.push({
192
+ type: "value",
193
+ gridIndex: panel,
194
+ ...ranges.x,
195
+ ...HIDDEN_AXIS_PARTS,
196
+ ...(key
197
+ ? {
198
+ name: `${valueAxisTitle(context.labels, channelName(x))}${KEY_ARROW}`,
199
+ nameLocation: "start",
200
+ nameGap: 0,
201
+ nameTextStyle: { ...keyStyle, verticalAlign: "top" },
202
+ }
203
+ : {}),
204
+ });
205
+ yAxes.push({
206
+ type: "value",
207
+ gridIndex: panel,
208
+ ...ranges.y,
209
+ ...HIDDEN_AXIS_PARTS,
210
+ ...(key
211
+ ? {
212
+ name: `${valueAxisTitle(context.labels, channelName(y))}${KEY_ARROW}`,
213
+ nameLocation: "start",
214
+ nameRotate: 90,
215
+ nameGap: 0,
216
+ nameTextStyle: { ...keyStyle, verticalAlign: "bottom" },
217
+ }
218
+ : {}),
219
+ });
220
+ }
221
+ // Each panel names its facet value on an axis of no series over its square, thus the name moves with the square.
222
+ for (const [panel, name] of panelNames.entries()) {
223
+ xAxes.push({
224
+ type: "value",
225
+ gridIndex: panel,
226
+ position: "top",
227
+ ...HIDDEN_AXIS_PARTS,
228
+ name: categoryName(name.slice(name.indexOf(":") + 1)),
229
+ nameLocation: "start",
230
+ nameGap: 0,
231
+ nameTextStyle: { color: CHART_INK, align: "left", verticalAlign: "bottom", fontWeight: "bold" },
232
+ });
233
+ }
234
+ const out = {
235
+ ...(option.tooltip !== undefined ? { tooltip: option.tooltip } : {}),
236
+ grid: layout.grid,
237
+ xAxis: xAxes,
238
+ yAxis: yAxes,
239
+ series,
240
+ legend: { show: false },
241
+ media: layout.media,
242
+ };
243
+ if (group !== undefined) {
244
+ const names = firstAppearance(cells.flatMap((cell) => (cell.group === undefined ? [] : [cell.group])));
245
+ return ok({
246
+ ...out,
247
+ color: [...categoricalPalette(names.length)],
248
+ series: [...series, ...nameSeries(cells, names, panels, ranges)],
249
+ });
250
+ }
251
+ const colorChannel = color;
252
+ const map = clippedScale(cells.flatMap((cell) => (cell.color === undefined ? [] : [cell.color])), scaleTitle, channelName(colorChannel), declaredForColumn(context.meanings, channelColumn(colorChannel)));
253
+ const composedMaps = Array.isArray(option.visualMap) ? option.visualMap : [];
254
+ const seriesIndex = composedMaps.find((entry) => entry.dimension === 2)?.seriesIndex ?? series.map((_entry, index) => index);
255
+ const place = layout.visualMap?.[0] ?? {};
256
+ return ok({ ...out, visualMap: [{ type: "continuous", seriesIndex, dimension: 2, ...map, ...place }] });
257
+ }
258
+ /**
259
+ * The continuous scale of an embedding, clipped at the 99th percentile of its values.
260
+ *
261
+ * A column of no negative value takes the sequential ramp from zero to the percentile. A zero lies outside the
262
+ * selected range, thus it draws in the gray ground, and a value past the percentile draws in the top color. A
263
+ * column that crosses zero takes the diverging ramp, symmetric at the percentile of the absolute values, and it
264
+ * draws no ground, because its zero is the middle of the scale.
265
+ */
266
+ function clippedScale(values, title, column, meaning) {
267
+ const sorted = [...values].sort((a, b) => a - b);
268
+ const negative = sorted.length > 0 && sorted[0] < 0;
269
+ if (negative) {
270
+ const reach = quantile(values.map((value) => Math.abs(value)).sort((a, b) => a - b), EMBEDDING_CLIP_QUANTILE);
271
+ const end = reach > 0 ? reach : 1;
272
+ return withoutRight(colorScale({ min: -end, max: end, ramp: DIVERGING_RAMP }, title, column, meaning));
273
+ }
274
+ const positive = sorted.find((value) => value > 0);
275
+ const clip = quantile(sorted, EMBEDDING_CLIP_QUANTILE);
276
+ const top = clip > 0 ? clip : (sorted[sorted.length - 1] ?? 0) > 0 ? sorted[sorted.length - 1] : 1;
277
+ const low = positive === undefined ? top : Math.min(positive, top);
278
+ return {
279
+ ...withoutRight(colorScale({ min: 0, max: top, ramp: SEQUENTIAL_RAMP }, title, column, meaning)),
280
+ range: [low, top],
281
+ outOfRange: { color: EMBEDDING_GROUND },
282
+ };
283
+ }
284
+ /** A color scale with no right edge. The layout places the scale by its left edge in the band. */
285
+ function withoutRight(map) {
286
+ const { right: _right, ...rest } = map;
287
+ return rest;
288
+ }
289
+ /**
290
+ * The linear quantile of sorted values, as numpy and R type 7 give it. An empty list gives zero.
291
+ */
292
+ function quantile(sorted, p) {
293
+ if (sorted.length === 0)
294
+ return 0;
295
+ const place = (sorted.length - 1) * p;
296
+ const low = Math.floor(place);
297
+ const high = Math.ceil(place);
298
+ return sorted[low] + (sorted[high] - sorted[low]) * (place - low);
299
+ }
300
+ /**
301
+ * The series of the category names on the data: one name for each category of each panel, at the median x and
302
+ * the median y of its cells.
303
+ *
304
+ * The median is the computed summary of this figure. It sits inside the cloud of a curved cluster, where a mean
305
+ * can fall between two arms. The names draw in the ink with a white outline, over every point. A name that
306
+ * would cover the name of a larger category moves along y by whole lines inside the square, and a name that
307
+ * finds no free place in the square draws nothing.
308
+ */
309
+ function nameSeries(cells, names, panels, plot) {
310
+ const out = [];
311
+ for (let panel = 0; panel < panels; panel += 1) {
312
+ const byName = new Map();
313
+ for (const cell of cells) {
314
+ if (cell.panel !== panel || cell.group === undefined)
315
+ continue;
316
+ let entry = byName.get(cell.group);
317
+ if (entry === undefined) {
318
+ entry = { xs: [], ys: [] };
319
+ byName.set(cell.group, entry);
320
+ }
321
+ entry.xs.push(cell.x);
322
+ entry.ys.push(cell.y);
323
+ }
324
+ const present = names.filter((name) => byName.has(name));
325
+ const anchors = nameAnchors(present.map((name) => {
326
+ const entry = byName.get(name) ?? { xs: [], ys: [] };
327
+ return { x: median(entry.xs), y: median(entry.ys), text: name };
328
+ }), present.map((name) => byName.get(name)?.xs.length ?? 0), plot);
329
+ const data = present.flatMap((name, place) => {
330
+ const anchor = anchors[place];
331
+ return anchor === undefined ? [] : [{ value: [anchor.x, anchor.y], name }];
332
+ });
333
+ out.push({
334
+ type: "scatter",
335
+ name: "Category names",
336
+ silent: true,
337
+ symbolSize: 0,
338
+ z: NAME_Z,
339
+ xAxisIndex: panel,
340
+ yAxisIndex: panel,
341
+ tooltip: { show: false },
342
+ label: {
343
+ show: true,
344
+ formatter: "{b}",
345
+ position: "inside",
346
+ color: CHART_INK,
347
+ fontWeight: "bold",
348
+ textBorderColor: "#ffffff",
349
+ textBorderWidth: NAME_OUTLINE_PX,
350
+ },
351
+ data,
352
+ });
353
+ }
354
+ return out;
355
+ }
356
+ /** The median of some values, read after one sort. */
357
+ function median(values) {
358
+ const sorted = [...values].sort((a, b) => a - b);
359
+ return Number(quantile(sorted, 0.5).toPrecision(12));
360
+ }