@inflexa-ai/harness 0.19.2 → 0.21.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.
- package/dist/agents/conversation-agent.d.ts +26 -14
- package/dist/agents/conversation-agent.js +12 -23
- package/dist/agents/report-session-agent.d.ts +31 -5
- package/dist/agents/report-session-agent.js +31 -6
- package/dist/app/chat-turn.js +3 -1
- package/dist/app/message-assembly.d.ts +15 -1
- package/dist/app/message-assembly.js +22 -8
- package/dist/app/report-session-runtime.d.ts +21 -4
- package/dist/app/report-session-runtime.js +53 -6
- package/dist/app/spawn-report-session.d.ts +174 -17
- package/dist/app/spawn-report-session.js +233 -21
- package/dist/contracts/chat-events.d.ts +7 -2
- package/dist/contracts/message.d.ts +10 -0
- package/dist/contracts/report-blocks.d.ts +743 -19
- package/dist/contracts/report-blocks.js +186 -11
- package/dist/contracts/report-reference.d.ts +88 -0
- package/dist/contracts/report-reference.js +48 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +20 -0
- package/dist/lib/chrome.d.ts +26 -6
- package/dist/lib/chrome.js +99 -32
- package/dist/lib/eyes.d.ts +57 -0
- package/dist/lib/eyes.js +45 -0
- package/dist/lib/page-capture.d.ts +14 -0
- package/dist/lib/page-capture.js +56 -2
- package/dist/literature/sources/pubmed.js +2 -1
- package/dist/loop/run-agent.js +58 -23
- package/dist/loop/tool-detail.d.ts +19 -2
- package/dist/loop/tool-detail.js +35 -2
- package/dist/memory/content-to-cortex.d.ts +3 -2
- package/dist/memory/content-to-cortex.js +21 -12
- package/dist/memory/conversation-display-replay.js +14 -7
- package/dist/memory/thread-history.d.ts +60 -3
- package/dist/memory/thread-history.js +55 -16
- package/dist/memory/thread-store.d.ts +11 -1
- package/dist/memory/thread-store.js +12 -2
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +23 -6
- package/dist/prompts/report-session.d.ts +1 -1
- package/dist/prompts/report-session.js +183 -9
- package/dist/report-model/authoring-grammar.d.ts +463 -0
- package/dist/report-model/authoring-grammar.js +77 -0
- package/dist/report-model/block-walk.d.ts +57 -14
- package/dist/report-model/block-walk.js +116 -11
- package/dist/report-model/draft-finish.d.ts +22 -4
- package/dist/report-model/draft-finish.js +39 -7
- package/dist/report-model/draft-operations.d.ts +3 -0
- package/dist/report-model/draft-operations.js +19 -5
- package/dist/report-model/draft-read.d.ts +8 -5
- package/dist/report-model/draft-read.js +21 -5
- package/dist/report-model/fixture-resolver.js +15 -4
- package/dist/report-model/pin-snapshot.d.ts +24 -6
- package/dist/report-model/pin-snapshot.js +204 -6
- package/dist/report-model/production-resolver.js +15 -4
- package/dist/report-model/reference-resolver.d.ts +50 -1
- package/dist/report-model/reference-resolver.js +94 -1
- package/dist/report-model/reference-stamp.d.ts +45 -0
- package/dist/report-model/reference-stamp.js +173 -0
- package/dist/report-model/structural-validation.d.ts +4 -1
- package/dist/report-model/structural-validation.js +48 -3
- package/dist/report-model/validate.d.ts +1 -1
- package/dist/report-model/validate.js +1 -1
- package/dist/report-render/asset-lookup.d.ts +19 -0
- package/dist/report-render/asset-lookup.js +37 -0
- package/dist/report-render/assets.d.ts +45 -1
- package/dist/report-render/assets.js +65 -6
- package/dist/report-render/chart-presets.d.ts +102 -0
- package/dist/report-render/chart-presets.js +205 -0
- package/dist/report-render/chart.d.ts +112 -10
- package/dist/report-render/chart.js +1164 -41
- package/dist/report-render/design.d.ts +99 -0
- package/dist/report-render/design.js +289 -107
- package/dist/report-render/number-format.d.ts +117 -0
- package/dist/report-render/number-format.js +479 -0
- package/dist/report-render/page.d.ts +121 -5
- package/dist/report-render/page.js +689 -7
- package/dist/report-render/references.d.ts +70 -16
- package/dist/report-render/references.js +54 -22
- package/dist/report-render/render.d.ts +23 -5
- package/dist/report-render/render.js +81 -22
- package/dist/report-render/table-data.d.ts +92 -0
- package/dist/report-render/table-data.js +129 -0
- package/dist/report-render/types.d.ts +15 -0
- package/dist/report-render/value-bridge.js +6 -2
- package/dist/report-render/views/chart-view.d.ts +12 -5
- package/dist/report-render/views/chart-view.js +14 -8
- package/dist/report-render/views/page-view.d.ts +36 -15
- package/dist/report-render/views/page-view.js +45 -23
- package/dist/report-render/views/prose.d.ts +12 -2
- package/dist/report-render/views/prose.js +29 -7
- package/dist/report-render/views/references-view.d.ts +22 -8
- package/dist/report-render/views/references-view.js +101 -13
- package/dist/report-render/views/values.d.ts +128 -13
- package/dist/report-render/views/values.js +199 -18
- package/dist/runtime/assemble.d.ts +39 -5
- package/dist/runtime/assemble.js +47 -6
- package/dist/sandbox/client.d.ts +10 -1
- package/dist/sandbox/create-sandbox.d.ts +9 -4
- package/dist/sandbox/create-sandbox.js +22 -16
- package/dist/sandbox/docker-client.js +10 -7
- package/dist/sandbox/k8s-client.js +11 -18
- package/dist/sandbox/mount-plan.d.ts +45 -7
- package/dist/sandbox/mount-plan.js +43 -5
- package/dist/sandbox/types.d.ts +10 -6
- package/dist/state/init.js +38 -9
- package/dist/state/report-session-state.d.ts +42 -1
- package/dist/state/report-session-state.js +70 -2
- package/dist/state/report-versions.d.ts +22 -17
- package/dist/state/report-versions.js +48 -35
- package/dist/state/snapshot-parse.js +16 -1
- package/dist/tasks/data-profile.d.ts +7 -4
- package/dist/tasks/data-profile.js +9 -9
- package/dist/tools/define-tool.d.ts +32 -0
- package/dist/tools/define-tool.js +4 -0
- package/dist/tools/detail-resolver.d.ts +7 -2
- package/dist/tools/detail-resolver.js +7 -2
- package/dist/tools/iterate-report.d.ts +1 -1
- package/dist/tools/report/preview-snapshot.js +1 -0
- package/dist/tools/report-authoring/authoring-tools.d.ts +15 -6
- package/dist/tools/report-authoring/authoring-tools.js +99 -18
- package/dist/tools/report-session/derive-table.d.ts +162 -0
- package/dist/tools/report-session/derive-table.js +459 -0
- package/dist/tools/report-session/examine-page.d.ts +48 -18
- package/dist/tools/report-session/examine-page.js +204 -24
- package/dist/tools/report-session/index.d.ts +2 -0
- package/dist/tools/report-session/index.js +2 -0
- package/dist/tools/report-session/list-artifacts.d.ts +97 -0
- package/dist/tools/report-session/list-artifacts.js +197 -0
- package/dist/tools/report-session/preview-report.d.ts +6 -0
- package/dist/tools/report-session/preview-report.js +180 -28
- package/dist/tools/report-session/record-version.d.ts +22 -10
- package/dist/tools/report-session/record-version.js +99 -14
- package/dist/tools/start-report-session.d.ts +115 -0
- package/dist/tools/start-report-session.js +179 -0
- package/dist/workflows/sandbox-step.d.ts +7 -4
- package/dist/workflows/sandbox-step.js +10 -12
- package/dist/workspace/paths.d.ts +50 -0
- package/dist/workspace/paths.js +63 -0
- 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
|
|
5
|
-
*
|
|
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
|
|
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(
|
|
38
|
-
return { name:
|
|
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
|
|
45
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
148
|
-
yAxis: {
|
|
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:
|
|
170
|
-
yAxis: {
|
|
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:
|
|
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:
|
|
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(
|
|
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
|
|
416
|
-
*
|
|
417
|
-
*
|
|
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
|
-
|
|
421
|
-
|
|
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:
|
|
1627
|
+
name: categoryName(name),
|
|
505
1628
|
barWidth: "99%",
|
|
506
1629
|
barGap: "-100%",
|
|
507
1630
|
itemStyle: { opacity: 0.7 },
|