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