@xeplr/ui-charts 1.0.1 → 1.0.2
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/LICENSE +21 -0
- package/README.md +45 -0
- package/lib/chart-options.js +437 -23
- package/lib/register.js +16 -2
- package/lib/rules.js +367 -0
- package/package.json +4 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Xeplr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -33,6 +33,51 @@ import { XeplrChart } from '@xeplr/ui-charts';
|
|
|
33
33
|
ECharts-mirror `<Chart spec={…} />` (backed by `buildOption`) is also exported for
|
|
34
34
|
callers who'd rather author ECharts-shaped specs directly.
|
|
35
35
|
|
|
36
|
+
### `chartType`
|
|
37
|
+
|
|
38
|
+
An **ECharts series type** — `bar`, `line`, `pie`, `scatter`, or any other,
|
|
39
|
+
including one you registered yourself. ECharts is the vocabulary; anything it
|
|
40
|
+
accepts flows through untouched.
|
|
41
|
+
|
|
42
|
+
Three aliases cover the charts every UI offers that ECharts has no series type
|
|
43
|
+
for. Each resolves to a real type *before* anything reaches ECharts, so an
|
|
44
|
+
unrenderable series is never handed over:
|
|
45
|
+
|
|
46
|
+
| you say | ECharts gets |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `area` | `line` + `areaStyle` |
|
|
49
|
+
| `donut` | `pie` + `radius: ['45%','70%']` |
|
|
50
|
+
| `hbar` | `bar` with the axis roles swapped — category on y, value on x |
|
|
51
|
+
|
|
52
|
+
Saying it in ECharts' own terms is the same chart: `chartType:'line'` with a
|
|
53
|
+
series `areaStyle` and `chartType:'area'` produce identical output.
|
|
54
|
+
|
|
55
|
+
A value axis gets `boundaryGap: [0, '10%']` by default — headroom, so the
|
|
56
|
+
tallest point doesn't sit on the top gridline and an area chart isn't clipped
|
|
57
|
+
flat against it. Nothing is added below, since a baseline belongs at zero. It
|
|
58
|
+
applies to whichever axis carries the value, so `hbar` gets it on x. Set
|
|
59
|
+
`axes.y.boundaryGap` (ECharts' own property, passed through verbatim) to
|
|
60
|
+
override.
|
|
61
|
+
|
|
62
|
+
Binding follows the type, and three types don't bind through `encode` at all —
|
|
63
|
+
handing them one draws nothing, silently:
|
|
64
|
+
|
|
65
|
+
| binding | types | shape |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| cartesian | bar, line, scatter, area… | `encode:{x,y}` + a pair of axes |
|
|
68
|
+
| name/value | pie, donut, funnel, sunburst | `encode:{itemName,value}`, **no axes** |
|
|
69
|
+
| radar | radar | `radar.indicator` from the categories; each measure is one shape over them, sharing one max |
|
|
70
|
+
| treemap | treemap | a flat `series.data` of `{name,value}` — treemap has no dataset support |
|
|
71
|
+
| heatmap | heatmap | **two** category axes + `[x,y,value]` cells + a `visualMap` |
|
|
72
|
+
|
|
73
|
+
A heatmap crosses two groupings, so it reads a second dimension from
|
|
74
|
+
`axes.x.labels[1]` — `x.labels` has always been the dimension list and other
|
|
75
|
+
charts simply use the first. Given only one it warns rather than drawing a
|
|
76
|
+
single stripe that would look like it worked.
|
|
77
|
+
|
|
78
|
+
Non-cartesian types are given **no axes at all**, including when the theme
|
|
79
|
+
carries axis styling, which would otherwise draw a bare cross behind the slices.
|
|
80
|
+
|
|
36
81
|
## The spec (organized ECharts mirror)
|
|
37
82
|
|
|
38
83
|
It **is** an ECharts `option`, just organized with sane defaults and two
|
package/lib/chart-options.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// values, and collects `warnings` for properties ECharts can't express (rather
|
|
10
10
|
// than failing). Pure — no echarts/react imports.
|
|
11
11
|
|
|
12
|
+
var applyRules = require('./rules');
|
|
13
|
+
|
|
12
14
|
function chartOptionsToOption(co, opts) {
|
|
13
15
|
co = co || {};
|
|
14
16
|
opts = opts || {};
|
|
@@ -70,7 +72,21 @@ function chartOptionsToOption(co, opts) {
|
|
|
70
72
|
var grid = {};
|
|
71
73
|
var edges = edgesFrom(ca.margin, ca.padding, ctx);
|
|
72
74
|
assign(grid, edges);
|
|
73
|
-
|
|
75
|
+
// CONTAIN WHAT LABELS?
|
|
76
|
+
//
|
|
77
|
+
// containLabel exists to stop axis labels being clipped: ECharts shrinks
|
|
78
|
+
// the plot inward until the text fits. With both axes hidden there is no
|
|
79
|
+
// text, so it reserves room for nothing — and a chart asked for zero
|
|
80
|
+
// margin on every edge still had visible space around it.
|
|
81
|
+
//
|
|
82
|
+
// That is exactly the minimalist case: no axes, no legend, no title, and
|
|
83
|
+
// an explicit margin of 0, which then did not look like 0. Derived rather
|
|
84
|
+
// than exposed as a setting, because "reserve space for the labels you are
|
|
85
|
+
// drawing" is not a preference — it is a description of whether any are
|
|
86
|
+
// being drawn.
|
|
87
|
+
var xShown = !(co.axes && co.axes.x && co.axes.x.show === false);
|
|
88
|
+
var yShown = !(co.axes && co.axes.y && co.axes.y.show === false);
|
|
89
|
+
grid.containLabel = xShown || yShown;
|
|
74
90
|
option.grid = clean(grid);
|
|
75
91
|
if (ca.style && ca.style.background && ca.style.background.color) {
|
|
76
92
|
option.backgroundColor = applyOpacity(ca.style.background.color, ca.style.background.opacity);
|
|
@@ -80,17 +96,60 @@ function chartOptionsToOption(co, opts) {
|
|
|
80
96
|
|
|
81
97
|
// dataLabel is per-series in ECharts — resolve it now, apply when series exist.
|
|
82
98
|
var seriesLabel;
|
|
99
|
+
var seriesLabelLayout;
|
|
100
|
+
var seriesLabelLine;
|
|
83
101
|
if (co.dataLabel) {
|
|
84
102
|
var dl = co.dataLabel;
|
|
85
103
|
seriesLabel = clean(assign(
|
|
86
104
|
{
|
|
87
105
|
show: dl.show !== false,
|
|
88
106
|
position: labelPosition(dl.positioning),
|
|
89
|
-
formatter: dl.formatter
|
|
107
|
+
formatter: dl.formatter,
|
|
108
|
+
// The gap between a label and the mark it belongs to.
|
|
109
|
+
distance: parseSize(dl.distance, ctx),
|
|
110
|
+
rotate: dl.rotate,
|
|
111
|
+
// A nudge for ALL of them. Per-label nudging is labelLayout's job.
|
|
112
|
+
offset: (dl.offsetX != null || dl.offsetY != null)
|
|
113
|
+
? [parseSize(dl.offsetX, ctx) || 0, parseSize(dl.offsetY, ctx) || 0]
|
|
114
|
+
: undefined,
|
|
115
|
+
minMargin: parseSize(dl.minMargin, ctx),
|
|
116
|
+
width: parseSize(dl.maxWidth, ctx),
|
|
117
|
+
overflow: dl.overflow
|
|
90
118
|
},
|
|
91
119
|
fontToTextStyle(dl.font, ctx, 'dataLabel.font'),
|
|
92
120
|
styleToEcharts(dl.style, ctx, 'dataLabel.style')
|
|
93
121
|
));
|
|
122
|
+
|
|
123
|
+
// THE CALLOUT — the leader line out to the label. Native to pie, donut and
|
|
124
|
+
// funnel; other types ignore it, so it is emitted whenever asked for
|
|
125
|
+
// rather than gated on a type the caller may know better than we do.
|
|
126
|
+
if (dl.labelLine) {
|
|
127
|
+
var ll = dl.labelLine;
|
|
128
|
+
seriesLabelLine = clean({
|
|
129
|
+
show: ll.show !== false,
|
|
130
|
+
length: parseSize(ll.length, ctx),
|
|
131
|
+
length2: parseSize(ll.length2, ctx),
|
|
132
|
+
smooth: ll.smooth,
|
|
133
|
+
minTurnAngle: ll.minTurnAngle,
|
|
134
|
+
lineStyle: clean({
|
|
135
|
+
color: ll.lineStyle && ll.lineStyle.color,
|
|
136
|
+
width: parseSize(ll.lineStyle && ll.lineStyle.width, ctx),
|
|
137
|
+
type: lineType(ll.lineStyle && ll.lineStyle.style)
|
|
138
|
+
})
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// COLLISION. The usual reason a chart's labels are unreadable is that
|
|
143
|
+
// they overprint each other, and ECharts can resolve it for the whole
|
|
144
|
+
// series — worth reaching for before anybody starts nudging labels one at
|
|
145
|
+
// a time.
|
|
146
|
+
var layout = dl.layout || {};
|
|
147
|
+
if (layout.hideOverlap != null || layout.moveOverlap) {
|
|
148
|
+
seriesLabelLayout = clean({
|
|
149
|
+
hideOverlap: layout.hideOverlap,
|
|
150
|
+
moveOverlap: layout.moveOverlap
|
|
151
|
+
});
|
|
152
|
+
}
|
|
94
153
|
}
|
|
95
154
|
|
|
96
155
|
// ── data + series (field-binding via axes.x/y.labels) ──
|
|
@@ -99,30 +158,228 @@ function chartOptionsToOption(co, opts) {
|
|
|
99
158
|
|
|
100
159
|
var xLabels = co.axes && co.axes.x && co.axes.x.labels;
|
|
101
160
|
var yLabels = co.axes && co.axes.y && co.axes.y.labels;
|
|
102
|
-
var xField = Array.isArray(xLabels) ? xLabels[0] : undefined;
|
|
161
|
+
var xField = Array.isArray(xLabels) ? labelField(xLabels[0]) : undefined;
|
|
162
|
+
|
|
163
|
+
// What ECharts is actually being asked for. `chartType` is an ECharts series
|
|
164
|
+
// type — the aliases below resolve to one, so nothing ECharts cannot read
|
|
165
|
+
// ever reaches it.
|
|
166
|
+
var shape = resolveChartType(co.chartType);
|
|
103
167
|
|
|
104
168
|
// per-type mark defaults (theme.marks[chartType]) — line width, bar radius +
|
|
105
169
|
// surface gap, symbol sizes, pie ring. Applied to every series of this type.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
170
|
+
// Looked up under the NAME that was asked for first, so a theme can style
|
|
171
|
+
// `donut` apart from `pie`, then under the type it resolves to.
|
|
172
|
+
var marks = co.marks && co.chartType
|
|
173
|
+
? (co.marks[co.chartType] || co.marks[shape.type])
|
|
174
|
+
: null;
|
|
175
|
+
|
|
176
|
+
var rows = Array.isArray(co.data) ? co.data : [];
|
|
177
|
+
|
|
178
|
+
if (shape.type && Array.isArray(yLabels) && yLabels.length) {
|
|
179
|
+
if (shape.binding === 'radar') {
|
|
180
|
+
// A radar has its own coordinate system. The CATEGORIES become the axes
|
|
181
|
+
// (indicators) and each measure becomes one shape laid over them — which
|
|
182
|
+
// is the transpose of every other chart here, and why it cannot go
|
|
183
|
+
// through encode.
|
|
184
|
+
var indicators = categoriesOf(rows, xField).map(function (c) { return { name: String(c) }; });
|
|
185
|
+
// One shared max, so two measures on the same radar can be compared.
|
|
186
|
+
// Per-axis maxima would rescale every spoke independently and make the
|
|
187
|
+
// shape meaningless.
|
|
188
|
+
var peak = 0;
|
|
189
|
+
yLabels.forEach(function (l) {
|
|
190
|
+
rows.forEach(function (r) {
|
|
191
|
+
var v = Number(r[labelField(l)]);
|
|
192
|
+
if (isFinite(v) && v > peak) peak = v;
|
|
193
|
+
});
|
|
115
194
|
});
|
|
116
|
-
if (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
195
|
+
if (peak > 0) indicators.forEach(function (ind) { ind.max = peak; });
|
|
196
|
+
option.radar = clean({ indicator: indicators });
|
|
197
|
+
option.series = [clean({
|
|
198
|
+
type: 'radar',
|
|
199
|
+
data: yLabels.map(function (label) {
|
|
200
|
+
return {
|
|
201
|
+
name: labelName(label),
|
|
202
|
+
value: rows.map(function (r) { return r[labelField(label)]; })
|
|
203
|
+
};
|
|
204
|
+
}),
|
|
205
|
+
label: seriesLabel,
|
|
206
|
+
labelLayout: seriesLabelLayout,
|
|
207
|
+
labelLine: seriesLabelLine
|
|
208
|
+
})];
|
|
209
|
+
delete option.xAxis;
|
|
210
|
+
delete option.yAxis;
|
|
211
|
+
|
|
212
|
+
} else if (shape.binding === 'treemap') {
|
|
213
|
+
// Treemap has no dataset support at all — it reads series.data, and each
|
|
214
|
+
// node is {name, value}. One measure: a rectangle has one area.
|
|
215
|
+
var tf = labelField(yLabels[0]);
|
|
216
|
+
option.series = [clean({
|
|
217
|
+
type: 'treemap',
|
|
218
|
+
data: rows.map(function (r) {
|
|
219
|
+
return { name: String(r[xField]), value: Number(r[tf]) };
|
|
220
|
+
}).filter(function (d) { return isFinite(d.value); }),
|
|
221
|
+
label: seriesLabel,
|
|
222
|
+
labelLayout: seriesLabelLayout,
|
|
223
|
+
labelLine: seriesLabelLine,
|
|
224
|
+
breadcrumb: { show: false }
|
|
225
|
+
})];
|
|
226
|
+
delete option.xAxis;
|
|
227
|
+
delete option.yAxis;
|
|
228
|
+
|
|
229
|
+
} else if (shape.binding === 'heatmap') {
|
|
230
|
+
// TWO categorical axes and a value. The second dimension comes from
|
|
231
|
+
// x.labels[1] — x.labels has always been the dimension list, and every
|
|
232
|
+
// other chart simply uses the first of them, so a heatmap needs no new
|
|
233
|
+
// field to say what it needs.
|
|
234
|
+
var xf2 = Array.isArray(xLabels) && xLabels.length > 1 ? labelField(xLabels[1]) : null;
|
|
235
|
+
var vf = labelField(yLabels[0]);
|
|
236
|
+
if (!xf2) {
|
|
237
|
+
// Said rather than drawn empty. A heatmap is a grid; with one
|
|
238
|
+
// dimension there is no grid, and rendering a single stripe would look
|
|
239
|
+
// like a chart that worked.
|
|
240
|
+
ctx.warn('a heatmap needs two dimensions — give axes.x.labels a second column');
|
|
241
|
+
option.series = [];
|
|
242
|
+
} else {
|
|
243
|
+
var xCats = categoriesOf(rows, xField);
|
|
244
|
+
var yCats = categoriesOf(rows, xf2);
|
|
245
|
+
var xAt = {}; xCats.forEach(function (c, i) { xAt[String(c)] = i; });
|
|
246
|
+
var yAt = {}; yCats.forEach(function (c, i) { yAt[String(c)] = i; });
|
|
247
|
+
var cells = [];
|
|
248
|
+
var lo = Infinity;
|
|
249
|
+
var hi = -Infinity;
|
|
250
|
+
rows.forEach(function (r) {
|
|
251
|
+
var v = Number(r[vf]);
|
|
252
|
+
if (!isFinite(v)) return;
|
|
253
|
+
if (v < lo) lo = v;
|
|
254
|
+
if (v > hi) hi = v;
|
|
255
|
+
cells.push([xAt[String(r[xField])], yAt[String(r[xf2])], v]);
|
|
256
|
+
});
|
|
257
|
+
option.xAxis = assign(option.xAxis || {}, { type: 'category', data: xCats, splitArea: { show: true } });
|
|
258
|
+
option.yAxis = assign(option.yAxis || {}, { type: 'category', data: yCats, splitArea: { show: true } });
|
|
259
|
+
// A value axis's headroom is meaningless here — both axes are
|
|
260
|
+
// categorical — and would push the grid off by a phantom cell.
|
|
261
|
+
delete option.xAxis.boundaryGap;
|
|
262
|
+
delete option.yAxis.boundaryGap;
|
|
263
|
+
// Without a visualMap every cell renders the same shade, which is a
|
|
264
|
+
// heatmap that carries no information.
|
|
265
|
+
option.visualMap = {
|
|
266
|
+
min: isFinite(lo) ? lo : 0,
|
|
267
|
+
max: isFinite(hi) ? hi : 1,
|
|
268
|
+
calculable: true,
|
|
269
|
+
orient: 'horizontal',
|
|
270
|
+
left: 'center',
|
|
271
|
+
bottom: 0,
|
|
272
|
+
inRange: Array.isArray(co.palette) && co.palette.length > 1
|
|
273
|
+
? { color: [co.palette[co.palette.length - 1], co.palette[0]] }
|
|
274
|
+
: undefined
|
|
275
|
+
};
|
|
276
|
+
option.series = [clean({ type: 'heatmap', data: cells, label: seriesLabel, labelLayout: seriesLabelLayout })];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
} else {
|
|
280
|
+
option.series = yLabels.map(function (label) {
|
|
281
|
+
var yf = labelField(label);
|
|
282
|
+
var ser = clean({
|
|
283
|
+
type: shape.type,
|
|
284
|
+
name: labelName(label),
|
|
285
|
+
// Cartesian series bind to axes; the rest name a slice and its size.
|
|
286
|
+
// A pie given {x, y} silently renders nothing — it is looking for
|
|
287
|
+
// itemName and value and finds neither.
|
|
288
|
+
encode: shape.cartesian
|
|
289
|
+
? (shape.horizontal
|
|
290
|
+
? (xField ? { y: xField, x: yf } : { x: yf })
|
|
291
|
+
: (xField ? { x: xField, y: yf } : { y: yf }))
|
|
292
|
+
: (xField ? { itemName: xField, value: yf } : { value: yf }),
|
|
293
|
+
label: seriesLabel,
|
|
294
|
+
labelLayout: seriesLabelLayout,
|
|
295
|
+
labelLine: seriesLabelLine
|
|
296
|
+
});
|
|
297
|
+
// The alias's own shaping — areaStyle for an area, a ring for a donut.
|
|
298
|
+
// Applied before marks so a theme can still override it.
|
|
299
|
+
if (shape.series) assign(ser, shape.series);
|
|
300
|
+
if (marks) applyMarks(ser, shape.type, marks, ctx);
|
|
301
|
+
return ser;
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
if (shape.cartesian) {
|
|
305
|
+
// Axis ROLES, not axis names: one carries the category, the other the
|
|
306
|
+
// value. A horizontal bar is the same chart with those swapped, which is
|
|
307
|
+
// how ECharts itself expresses it — there is no 'hbar' series type.
|
|
308
|
+
var categoryAxis = shape.horizontal ? 'yAxis' : 'xAxis';
|
|
309
|
+
var valueAxis = shape.horizontal ? 'xAxis' : 'yAxis';
|
|
310
|
+
if (option[categoryAxis]) {
|
|
311
|
+
if (option[categoryAxis].type === undefined) option[categoryAxis].type = 'category';
|
|
312
|
+
} else if (xField) option[categoryAxis] = { type: 'category' };
|
|
313
|
+
if (!option[valueAxis]) option[valueAxis] = {};
|
|
314
|
+
if (option[valueAxis].type === undefined) option[valueAxis].type = 'value';
|
|
315
|
+
// Headroom above the tallest value.
|
|
316
|
+
//
|
|
317
|
+
// ECharts ends a value axis exactly at the data's own maximum, so the
|
|
318
|
+
// highest point sits ON the top gridline and an area chart is clipped
|
|
319
|
+
// flat against it — it reads as a chart that ran out of room rather than
|
|
320
|
+
// one that peaked. VALUE_HEADROOM lifts the top of the scale clear of it.
|
|
321
|
+
//
|
|
322
|
+
// Applied to whichever axis carries the VALUE, which is x on a
|
|
323
|
+
// horizontal bar — hence here, where the role is known, rather than in a
|
|
324
|
+
// theme that can only name x and y.
|
|
325
|
+
if (option[valueAxis].boundaryGap === undefined) {
|
|
326
|
+
option[valueAxis].boundaryGap = VALUE_HEADROOM;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ── AND THE RANGE THE HEADROOM APPLIES TO ────────────────────────
|
|
330
|
+
//
|
|
331
|
+
// VALUE_HEADROOM above did NOTHING until this line existed. ECharts
|
|
332
|
+
// ignores boundaryGap on a value axis while `scale` is off: it builds
|
|
333
|
+
// a rounded range from zero instead, and a percentage extension is
|
|
334
|
+
// absorbed into that rounding. Measured — [0,'10%'] and no
|
|
335
|
+
// boundaryGap at all produced the identical axis.
|
|
336
|
+
//
|
|
337
|
+
// Which also means the zero baseline was never a decision. It is
|
|
338
|
+
// ECharts' default for `scale`, nobody set it, and a comment
|
|
339
|
+
// explaining the `0` in boundaryGap as "a baseline belongs at zero"
|
|
340
|
+
// described something that value does not do.
|
|
341
|
+
//
|
|
342
|
+
// WHICH CHARTS KEEP ZERO, and why it is not a matter of taste:
|
|
343
|
+
//
|
|
344
|
+
// bar length IS the quantity. Truncate the axis and a 5%
|
|
345
|
+
// difference draws as double — the chart states
|
|
346
|
+
// something false about the numbers.
|
|
347
|
+
// stacked the segments are cumulative sums from a baseline.
|
|
348
|
+
// Move the baseline and they stop adding up to the
|
|
349
|
+
// total they are drawn to show.
|
|
350
|
+
// filled an area's fill reads as volume under the curve, so it
|
|
351
|
+
// measures from zero the same way a bar does.
|
|
352
|
+
//
|
|
353
|
+
// Everything else — a plain line, a scatter — encodes CHANGE, and
|
|
354
|
+
// zero flattens exactly the variation it was plotted to reveal. A
|
|
355
|
+
// series moving between 62,000 and 66,000 becomes a straight line
|
|
356
|
+
// pinned to the top of an empty chart, and shrinking the card makes
|
|
357
|
+
// it flatter still.
|
|
358
|
+
var stacked = Boolean(shape.series && shape.series.stack);
|
|
359
|
+
var filled = Boolean(shape.series && shape.series.areaStyle);
|
|
360
|
+
var zeroBased = shape.type === 'bar' || stacked || filled;
|
|
361
|
+
// Only a real value axis: a heatmap is cartesian with TWO category
|
|
362
|
+
// axes, and `scale` on a category axis means nothing.
|
|
363
|
+
if (option[valueAxis].type === 'value' && option[valueAxis].scale === undefined) {
|
|
364
|
+
option[valueAxis].scale = !zeroBased;
|
|
365
|
+
}
|
|
366
|
+
} else {
|
|
367
|
+
// A pie has no axes. Left in place they draw a bare cross behind the
|
|
368
|
+
// slices — and any axis STYLING the theme carries would be rendered for
|
|
369
|
+
// a chart that has none.
|
|
370
|
+
delete option.xAxis;
|
|
371
|
+
delete option.yAxis;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
124
374
|
}
|
|
125
375
|
|
|
376
|
+
// ── conditional formatting ──
|
|
377
|
+
//
|
|
378
|
+
// Applied AFTER the series exist, because a rule is scoped to one of them
|
|
379
|
+
// and needs its index, its palette colour and the field it plots. See
|
|
380
|
+
// rules.js for which ECharts mechanism each target uses and why they differ.
|
|
381
|
+
applyRules(co, option, ctx);
|
|
382
|
+
|
|
126
383
|
// ── theme-level extras (carried on ChartOptions by the theme) ──
|
|
127
384
|
// palette → the series color cycle; fontFamily → global text default (ECharts
|
|
128
385
|
// cascades option.textStyle.fontFamily to all text, so per-block family is
|
|
@@ -133,6 +390,118 @@ function chartOptionsToOption(co, opts) {
|
|
|
133
390
|
return { option: option, seriesLabel: seriesLabel, chartType: co.chartType, warnings: warnings };
|
|
134
391
|
}
|
|
135
392
|
|
|
393
|
+
// ── chart type ───────────────────────────────────────────────────────────
|
|
394
|
+
//
|
|
395
|
+
// ECharts is the vocabulary. `chartType` IS an ECharts series type and anything
|
|
396
|
+
// ECharts accepts flows straight through — including types registered by the
|
|
397
|
+
// consumer, which is why an unrecognised one is passed on rather than refused.
|
|
398
|
+
//
|
|
399
|
+
// The exceptions are the three names every charting UI offers that ECharts has
|
|
400
|
+
// no series type for. They are not new types: each resolves HERE to a real one
|
|
401
|
+
// plus the property that makes it that chart, so what reaches ECharts is always
|
|
402
|
+
// something ECharts can read. An 'area' series would simply not render, and it
|
|
403
|
+
// would not say why.
|
|
404
|
+
//
|
|
405
|
+
// area → line, filled
|
|
406
|
+
// donut → pie with a hole
|
|
407
|
+
// hbar → bar with the axis roles swapped
|
|
408
|
+
// stackedBar → bar, stacked
|
|
409
|
+
//
|
|
410
|
+
// A caller who prefers to say it in ECharts' own terms still can: chartType
|
|
411
|
+
// 'line' with a series areaStyle is the same chart, and passes through
|
|
412
|
+
// untouched.
|
|
413
|
+
var ALIAS = {
|
|
414
|
+
area: { type: 'line', series: { areaStyle: {} } },
|
|
415
|
+
donut: { type: 'pie', series: { radius: ['45%', '70%'] } },
|
|
416
|
+
hbar: { type: 'bar', horizontal: true },
|
|
417
|
+
stackedBar: { type: 'bar', series: { stack: 'total' } },
|
|
418
|
+
// Stacking is orthogonal to orientation and to fill, so it combines with
|
|
419
|
+
// both. Named rather than left to the caller to assemble, because
|
|
420
|
+
// "horizontal AND stacked" is one chart somebody picks off a list, not two
|
|
421
|
+
// properties they are expected to know compose.
|
|
422
|
+
stackedHbar: { type: 'bar', horizontal: true, series: { stack: 'total' } },
|
|
423
|
+
stackedArea: { type: 'line', series: { areaStyle: {}, stack: 'total' } }
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
// ECharts types that are NOT plotted against a pair of axes. Everything else
|
|
427
|
+
// is assumed cartesian, which is the right default for the types a consumer is
|
|
428
|
+
// most likely to register (bar/line/scatter variants).
|
|
429
|
+
// How far past the tallest value a value axis runs — boundaryGap's
|
|
430
|
+
// [below, above] pair.
|
|
431
|
+
//
|
|
432
|
+
// The point is the '10%' ABOVE: without it a value axis ends exactly at the
|
|
433
|
+
// data's maximum, so the highest point sits on the top gridline and an area
|
|
434
|
+
// chart is clipped flat against it, reading as a chart that ran out of room
|
|
435
|
+
// rather than one that peaked.
|
|
436
|
+
//
|
|
437
|
+
// The `0` below is not a baseline decision and never was. It says only "add no
|
|
438
|
+
// padding underneath", which suits both cases: a zero-based chart is already
|
|
439
|
+
// at zero, and a scaled one should start at its own minimum rather than
|
|
440
|
+
// somewhere arbitrarily below it. What actually holds a bar chart at zero is
|
|
441
|
+
// `scale`, set where the chart's shape is known — see the block that applies
|
|
442
|
+
// this constant. An earlier comment here credited the `0` with that job, which
|
|
443
|
+
// is how a value that does nothing on its own went years without being
|
|
444
|
+
// questioned.
|
|
445
|
+
var VALUE_HEADROOM = [0, '10%'];
|
|
446
|
+
|
|
447
|
+
var NON_CARTESIAN = {
|
|
448
|
+
pie: 1, funnel: 1, gauge: 1, radar: 1, treemap: 1, sunburst: 1,
|
|
449
|
+
sankey: 1, graph: 1, tree: 1, themeRiver: 1
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// HOW a type reads its data. Most bind through the dataset with an encode, but
|
|
453
|
+
// three do not — and handing those an encode draws nothing at all, silently.
|
|
454
|
+
//
|
|
455
|
+
// cartesian encode {x, y} against a pair of axes bar, line, …
|
|
456
|
+
// nameValue encode {itemName, value} pie, funnel
|
|
457
|
+
// radar its own coordinate system: indicators + arrays
|
|
458
|
+
// treemap a flat {name, value} list; no dataset support
|
|
459
|
+
// heatmap cartesian, but TWO category axes and a value [x, y, v]
|
|
460
|
+
var BINDING = { radar: 'radar', treemap: 'treemap', heatmap: 'heatmap' };
|
|
461
|
+
|
|
462
|
+
// An axis label is the FIELD to bind, and optionally the name to show for it:
|
|
463
|
+
// either 'revenue' or { field: 'revenue', name: 'Revenue (£)' }.
|
|
464
|
+
//
|
|
465
|
+
// They are separable because the field is a key in the data and the name is
|
|
466
|
+
// for a person to read, and those are rarely the same string. Without this a
|
|
467
|
+
// legend can only ever show the raw column key — which in a BI tool is
|
|
468
|
+
// something like `a1b2c3__orders_total`. ECharts already separates them; this
|
|
469
|
+
// is series.name alongside encode, said once.
|
|
470
|
+
function labelField(label) {
|
|
471
|
+
return label && typeof label === 'object' ? label.field : label;
|
|
472
|
+
}
|
|
473
|
+
function labelName(label) {
|
|
474
|
+
if (label && typeof label === 'object') return label.name != null ? label.name : label.field;
|
|
475
|
+
return label;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function resolveChartType(name) {
|
|
479
|
+
if (!name) return { type: undefined, cartesian: true, horizontal: false, series: null, binding: 'cartesian' };
|
|
480
|
+
var alias = ALIAS[name];
|
|
481
|
+
var type = alias ? alias.type : name;
|
|
482
|
+
return {
|
|
483
|
+
type: type,
|
|
484
|
+
cartesian: !NON_CARTESIAN[type],
|
|
485
|
+
horizontal: Boolean(alias && alias.horizontal),
|
|
486
|
+
series: (alias && alias.series) || null,
|
|
487
|
+
binding: BINDING[type] || (NON_CARTESIAN[type] ? 'nameValue' : 'cartesian')
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Distinct values of a field, in the order the rows present them. Order comes
|
|
492
|
+
// from the data rather than sorting, so a caller that has already ordered its
|
|
493
|
+
// rows — by month, by rank — keeps that order on the axis.
|
|
494
|
+
function categoriesOf(rows, field) {
|
|
495
|
+
var seen = {};
|
|
496
|
+
var out = [];
|
|
497
|
+
for (var i = 0; i < (rows || []).length; i++) {
|
|
498
|
+
var v = rows[i][field];
|
|
499
|
+
var k = String(v);
|
|
500
|
+
if (!seen[k]) { seen[k] = 1; out.push(v); }
|
|
501
|
+
}
|
|
502
|
+
return out;
|
|
503
|
+
}
|
|
504
|
+
|
|
136
505
|
// theme.marks[chartType] → ECharts series style. Each chart type reads the marks
|
|
137
506
|
// it understands; unknown keys are simply not consulted (no warnings — marks are
|
|
138
507
|
// a theme convenience, not user-authored CSS).
|
|
@@ -147,7 +516,23 @@ function applyMarks(ser, type, m, ctx) {
|
|
|
147
516
|
} else if (type === 'bar') {
|
|
148
517
|
var bit = {};
|
|
149
518
|
if (m.border && m.border.radius != null) bit.borderRadius = parseSize(m.border.radius, ctx);
|
|
150
|
-
|
|
519
|
+
// The surface gap between fills, as a border the colour of the page.
|
|
520
|
+
//
|
|
521
|
+
// STACKED SERIES ONLY, and that is the whole of it. A border is the only
|
|
522
|
+
// thing that can separate two segments of one stacked bar — they share an
|
|
523
|
+
// edge, so no amount of spacing reaches between them.
|
|
524
|
+
//
|
|
525
|
+
// Between separate bars it is the wrong tool and an actively destructive
|
|
526
|
+
// one. A border eats the fill from BOTH sides at a fixed pixel width while
|
|
527
|
+
// the bar itself narrows with the category count: at 150 categories a bar
|
|
528
|
+
// is 8.5px and a 2px ring takes 47% of it; at 300 it is 4.2px and the ring
|
|
529
|
+
// takes all of it, painting the bar the colour of the background. The
|
|
530
|
+
// chart renders, reports no error, and shows nothing.
|
|
531
|
+
//
|
|
532
|
+
// Bars that merely sit next to each other are already separated by
|
|
533
|
+
// ECharts' own barGap and barCategoryGap, which are percentages and so
|
|
534
|
+
// hold at any width.
|
|
535
|
+
if (m.gap && ser.stack) {
|
|
151
536
|
if (m.gap.color != null) bit.borderColor = m.gap.color;
|
|
152
537
|
if (m.gap.width != null) bit.borderWidth = parseSize(m.gap.width, ctx);
|
|
153
538
|
}
|
|
@@ -187,9 +572,27 @@ function axisToEcharts(ax, ctx, path) {
|
|
|
187
572
|
}
|
|
188
573
|
if (ax.ticks) {
|
|
189
574
|
var tk = ax.ticks;
|
|
190
|
-
|
|
575
|
+
// `color` is the tick MARK, alongside `length` and `margin` which also
|
|
576
|
+
// describe the mark. The label's colour is `ticks.font.color`.
|
|
577
|
+
//
|
|
578
|
+
// Both used to land on axisLabel.color, where the font one always won by
|
|
579
|
+
// being assigned second — so `ticks.color` could not take effect at all,
|
|
580
|
+
// and no tick mark could be coloured. Two controls, one of them dead.
|
|
581
|
+
a.axisTick = clean({
|
|
582
|
+
show: tk.show !== false,
|
|
583
|
+
length: parseSize(tk.length, ctx),
|
|
584
|
+
lineStyle: clean({ color: tk.color })
|
|
585
|
+
});
|
|
191
586
|
a.axisLabel = clean(assign(
|
|
192
|
-
{
|
|
587
|
+
{
|
|
588
|
+
show: tk.show !== false, rotate: tk.angle, margin: parseSize(tk.margin, ctx),
|
|
589
|
+
// The axis says the same numbers the tooltip and the data labels do,
|
|
590
|
+
// so it takes the same kind of formatter they already take. Without
|
|
591
|
+
// one, a currency column's axis was the only place in the chart that
|
|
592
|
+
// could not be told what the numbers mean — and it is the part that
|
|
593
|
+
// is always on screen.
|
|
594
|
+
formatter: tk.formatter
|
|
595
|
+
},
|
|
193
596
|
fontToTextStyle(tk.font, ctx, path + '.ticks.font')
|
|
194
597
|
));
|
|
195
598
|
}
|
|
@@ -198,6 +601,17 @@ function axisToEcharts(ax, ctx, path) {
|
|
|
198
601
|
a.nameTextStyle = fontToTextStyle(ax.title.font, ctx, path + '.title.font');
|
|
199
602
|
if (ax.title.margin) a.nameGap = parseSize(ax.title.margin.top || ax.title.margin.bottom || ax.title.margin.left || ax.title.margin.right, ctx);
|
|
200
603
|
}
|
|
604
|
+
// Straight through, in ECharts' own vocabulary and meaning: a boolean or a
|
|
605
|
+
// [start, end] pair on a category axis, a [min, max] extension on a value
|
|
606
|
+
// one. Passing it verbatim is deliberate — inventing a second name for a
|
|
607
|
+
// property ECharts already has is how two vocabularies start.
|
|
608
|
+
if (ax.boundaryGap !== undefined) a.boundaryGap = ax.boundaryGap;
|
|
609
|
+
// Whether the axis fits the data or includes zero. Passed straight through
|
|
610
|
+
// and, because it is set here from the caller's options, it wins over the
|
|
611
|
+
// per-shape default applied later — which is the whole point: the default is
|
|
612
|
+
// right for the chart type, and this is for the reader who knows better
|
|
613
|
+
// about their own numbers.
|
|
614
|
+
if (ax.scale !== undefined) a.scale = ax.scale;
|
|
201
615
|
return clean(a);
|
|
202
616
|
}
|
|
203
617
|
|
package/lib/register.js
CHANGED
|
@@ -9,17 +9,31 @@
|
|
|
9
9
|
|
|
10
10
|
var echarts = require('echarts/core');
|
|
11
11
|
|
|
12
|
-
var {
|
|
12
|
+
var {
|
|
13
|
+
BarChart, LineChart, PieChart, ScatterChart,
|
|
14
|
+
// The four that need their own coordinate system or their own data shape.
|
|
15
|
+
// Registered by default rather than left to the consumer: a chart type the
|
|
16
|
+
// translator understands but the runtime cannot draw fails as an empty box
|
|
17
|
+
// with "Unknown series" in the console, which is a worse default than the
|
|
18
|
+
// few kB these cost.
|
|
19
|
+
FunnelChart, RadarChart, TreemapChart, HeatmapChart
|
|
20
|
+
} = require('echarts/charts');
|
|
13
21
|
var {
|
|
14
22
|
GridComponent, TooltipComponent, LegendComponent, TitleComponent,
|
|
15
|
-
DatasetComponent, ToolboxComponent, DataZoomComponent, MarkLineComponent, MarkPointComponent
|
|
23
|
+
DatasetComponent, ToolboxComponent, DataZoomComponent, MarkLineComponent, MarkPointComponent,
|
|
24
|
+
// RadarComponent is the radar's AXES — the series alone draws nothing.
|
|
25
|
+
// VisualMapComponent is what turns a heatmap's numbers into colours; without
|
|
26
|
+
// it every cell renders the same shade.
|
|
27
|
+
RadarComponent, VisualMapComponent
|
|
16
28
|
} = require('echarts/components');
|
|
17
29
|
var { CanvasRenderer, SVGRenderer } = require('echarts/renderers');
|
|
18
30
|
|
|
19
31
|
echarts.use([
|
|
20
32
|
BarChart, LineChart, PieChart, ScatterChart,
|
|
33
|
+
FunnelChart, RadarChart, TreemapChart, HeatmapChart,
|
|
21
34
|
GridComponent, TooltipComponent, LegendComponent, TitleComponent,
|
|
22
35
|
DatasetComponent, ToolboxComponent, DataZoomComponent, MarkLineComponent, MarkPointComponent,
|
|
36
|
+
RadarComponent, VisualMapComponent,
|
|
23
37
|
CanvasRenderer, SVGRenderer
|
|
24
38
|
]);
|
|
25
39
|
|
package/lib/rules.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
var R = require('@xeplr/rules');
|
|
2
|
+
var matches = R.matches;
|
|
3
|
+
var formatNumber = R.formatNumber;
|
|
4
|
+
|
|
5
|
+
// CONDITIONAL FORMATTING — "when this column is under zero, make THAT red".
|
|
6
|
+
//
|
|
7
|
+
// A rule has three parts, and the third depends on the second:
|
|
8
|
+
//
|
|
9
|
+
// WHEN field, op, value which column to test, and against what
|
|
10
|
+
// APPLY target which object on the chart it styles
|
|
11
|
+
// THEN then: { … } only what that object can actually take
|
|
12
|
+
//
|
|
13
|
+
// The point of naming a target is that a chart is not one thing. Colouring the
|
|
14
|
+
// bar, colouring the number printed above the bar, and colouring the category
|
|
15
|
+
// name under it are three different objects with three different vocabularies —
|
|
16
|
+
// a bar has an opacity and no font, an axis label has a font and no opacity.
|
|
17
|
+
// One rule list that cannot say which one it means can only ever style the one
|
|
18
|
+
// somebody guessed at.
|
|
19
|
+
//
|
|
20
|
+
// ── THE MECHANISM PER TARGET, and why they differ ────────────────────────
|
|
21
|
+
//
|
|
22
|
+
// Every one of these is ECharts' own API. They differ because ECharts itself
|
|
23
|
+
// supports different things in different places, which was established by
|
|
24
|
+
// rendering each and reading the output rather than by reading the docs:
|
|
25
|
+
//
|
|
26
|
+
// mark itemStyle callbacks. itemStyle.color accepts a function and
|
|
27
|
+
// is handed the whole row, so a rule can test any column and any
|
|
28
|
+
// type — including a string. Verified on bar, line, scatter,
|
|
29
|
+
// pie, funnel and treemap.
|
|
30
|
+
//
|
|
31
|
+
// dataLabel label.formatter + label.rich. Label STYLE callbacks do not
|
|
32
|
+
// work: `label: { color: fn }` puts the function's source text
|
|
33
|
+
// into the SVG's fill attribute. So the styling has to travel as
|
|
34
|
+
// rich-text markup, which is ECharts' mechanism for styling part
|
|
35
|
+
// of a string.
|
|
36
|
+
//
|
|
37
|
+
// axisX/Y axisLabel.formatter + axisLabel.rich. axisLabel.color DOES
|
|
38
|
+
// take a callback — it is documented as (value, index) => color
|
|
39
|
+
// and it works — but axisLabel.fontWeight does not. Rather than
|
|
40
|
+
// support colour one way and weight another, both go through
|
|
41
|
+
// rich, which is one code path and supports everything.
|
|
42
|
+
//
|
|
43
|
+
// THE CONDITION LIVES ELSEWHERE. `matches`, the operators and number
|
|
44
|
+
// formatting are in @xeplr/rules, because "when margin is under zero" is the
|
|
45
|
+
// same question whether the answer paints a bar, a table cell or an arrow —
|
|
46
|
+
// and a table asking it must not have to depend on a charting library. What
|
|
47
|
+
// stays here is the half that genuinely IS ECharts: turning a match into
|
|
48
|
+
// itemStyle callbacks and label formatters.
|
|
49
|
+
//
|
|
50
|
+
// A visualMap is deliberately NOT used, though it was the obvious candidate.
|
|
51
|
+
// It carries three restrictions that are the mechanism's rather than ECharts':
|
|
52
|
+
// it cannot test a string column at all, it paints an unmatched item black,
|
|
53
|
+
// and a second one on the same series replaces the first outright — silently,
|
|
54
|
+
// along with everything the first had coloured. itemStyle callbacks have none
|
|
55
|
+
// of those.
|
|
56
|
+
|
|
57
|
+
var TARGETS = { mark: 1, dataLabel: 1, axisX: 1, axisY: 1 };
|
|
58
|
+
|
|
59
|
+
// itemStyle keys a mark rule may set. ECharts accepts a callback for each.
|
|
60
|
+
var MARK_KEYS = [
|
|
61
|
+
'color', 'opacity', 'borderColor', 'borderWidth', 'borderType',
|
|
62
|
+
'borderRadius', 'shadowBlur', 'shadowColor', 'shadowOffsetX', 'shadowOffsetY'
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
// rich-text keys a text rule may set. A subset of what ECharts' rich accepts —
|
|
66
|
+
// the ones that mean something for a single run of text on a chart.
|
|
67
|
+
var TEXT_KEYS = [
|
|
68
|
+
'color', 'fontStyle', 'fontWeight', 'fontSize', 'fontFamily',
|
|
69
|
+
'backgroundColor', 'borderColor', 'borderWidth', 'borderRadius',
|
|
70
|
+
'padding', 'textBorderColor', 'textBorderWidth', 'lineHeight'
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
// ── the THEN, for text: number formatting ────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether a run of text can carry rich-text styling.
|
|
77
|
+
*
|
|
78
|
+
* ECharts' rich markup is `{styleName|text}` and has no escape sequence, so a
|
|
79
|
+
* value containing a brace or a pipe cannot be wrapped — a `}` inside would
|
|
80
|
+
* end the run early and the remainder would print unstyled.
|
|
81
|
+
*
|
|
82
|
+
* Answered rather than worked around: the text is printed as it is and the
|
|
83
|
+
* styling is dropped for that one label. Stripping the character would be
|
|
84
|
+
* silent data corruption, and wrapping it anyway would truncate the value.
|
|
85
|
+
*/
|
|
86
|
+
function richSafe(text) {
|
|
87
|
+
return String(text).indexOf('{') < 0 && String(text).indexOf('}') < 0 && String(text).indexOf('|') < 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── applying ─────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
function pick(source, keys) {
|
|
93
|
+
var out = null;
|
|
94
|
+
for (var i = 0; i < keys.length; i++) {
|
|
95
|
+
if (source[keys[i]] !== undefined) {
|
|
96
|
+
if (!out) out = {};
|
|
97
|
+
out[keys[i]] = source[keys[i]];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The rules, sorted into the buckets that each get their own mechanism.
|
|
105
|
+
*
|
|
106
|
+
* Reported as it goes: a rule that names a target nothing understands, or sets
|
|
107
|
+
* a property its target cannot take, is dropped HERE with a reason rather than
|
|
108
|
+
* emitted for ECharts to ignore. A control that appears to work is the failure
|
|
109
|
+
* this whole module is arranged to avoid.
|
|
110
|
+
*/
|
|
111
|
+
function sortRules(co, option, ctx) {
|
|
112
|
+
var yLabels = (co.axes && co.axes.y && co.axes.y.labels) || [];
|
|
113
|
+
var series = option.series || [];
|
|
114
|
+
var out = { mark: {}, dataLabel: {}, axisX: [], axisY: [] };
|
|
115
|
+
|
|
116
|
+
(co.rules || []).forEach(function (rule, i) {
|
|
117
|
+
var where = 'rules[' + i + ']';
|
|
118
|
+
if (!rule || !rule.field) { ctx.warn(where + ' names no column to test (ignored)'); return; }
|
|
119
|
+
if (!rule.op) { ctx.warn(where + ' has no comparison (ignored)'); return; }
|
|
120
|
+
var target = rule.target || 'mark';
|
|
121
|
+
if (!TARGETS[target]) { ctx.warn(where + ' applies to "' + target + '", which is not something on a chart (ignored)'); return; }
|
|
122
|
+
|
|
123
|
+
var then = rule.then || {};
|
|
124
|
+
var keys = target === 'mark' ? MARK_KEYS : TEXT_KEYS;
|
|
125
|
+
var style = pick(then, keys);
|
|
126
|
+
var changesText = target !== 'mark' && (then.hide || then.format);
|
|
127
|
+
if (!style && !changesText) {
|
|
128
|
+
ctx.warn(where + ' sets nothing its target can take, so it would do nothing (ignored)');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// Said, because the panel is generated per target and a rule that arrives
|
|
132
|
+
// with the wrong vocabulary came from somewhere else — a hand-edited
|
|
133
|
+
// config, or a target changed after the properties were chosen.
|
|
134
|
+
var stray = Object.keys(then).filter(function (k) {
|
|
135
|
+
return keys.indexOf(k) < 0 && k !== 'hide' && k !== 'format';
|
|
136
|
+
});
|
|
137
|
+
if (stray.length) ctx.warn(where + ' sets ' + stray.join(', ') + ', which "' + target + '" has no such thing (dropped)');
|
|
138
|
+
|
|
139
|
+
var entry = { rule: rule, style: style, hide: Boolean(then.hide), format: then.format || null };
|
|
140
|
+
|
|
141
|
+
if (target === 'axisX' || target === 'axisY') {
|
|
142
|
+
// An axis-label formatter is handed the axis's own value and its index,
|
|
143
|
+
// and nothing else — there is no row to look at. So a rule here can only
|
|
144
|
+
// test the column that axis is showing.
|
|
145
|
+
out[target].push(entry);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
var targets = [];
|
|
150
|
+
if (rule.series) {
|
|
151
|
+
var idx = -1;
|
|
152
|
+
for (var s = 0; s < yLabels.length; s++) if (labelFieldOf(yLabels[s]) === rule.series) idx = s;
|
|
153
|
+
if (idx < 0 || !series[idx]) {
|
|
154
|
+
ctx.warn(where + ' applies to "' + rule.series + '", which this chart does not plot (ignored)');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
targets = [idx];
|
|
158
|
+
} else {
|
|
159
|
+
for (var t = 0; t < series.length; t++) targets.push(t);
|
|
160
|
+
}
|
|
161
|
+
targets.forEach(function (si) {
|
|
162
|
+
(out[target][si] || (out[target][si] = [])).push(entry);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function labelFieldOf(label) {
|
|
169
|
+
return label && typeof label === 'object' ? label.field : label;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The row behind a datum, whatever binding put it there. */
|
|
173
|
+
function rowOf(params) {
|
|
174
|
+
var d = params && params.data;
|
|
175
|
+
if (d && typeof d === 'object' && !Array.isArray(d)) return d;
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* MARKS — one itemStyle callback per property any rule sets.
|
|
181
|
+
*
|
|
182
|
+
* The callback returns the FIRST matching rule's value, and the base value
|
|
183
|
+
* otherwise. The base matters: returning undefined for `color` renders the
|
|
184
|
+
* mark with no fill at all, so an unmatched bar would disappear rather than
|
|
185
|
+
* keep its colour. Every other property tolerates undefined, but colour is
|
|
186
|
+
* given the series' palette entry explicitly.
|
|
187
|
+
*/
|
|
188
|
+
function applyMarkRules(entriesBySeries, option, palette) {
|
|
189
|
+
Object.keys(entriesBySeries).forEach(function (si) {
|
|
190
|
+
var entries = entriesBySeries[si];
|
|
191
|
+
var ser = option.series[si];
|
|
192
|
+
if (!ser || !entries || !entries.length) return;
|
|
193
|
+
|
|
194
|
+
var props = {};
|
|
195
|
+
entries.forEach(function (e) {
|
|
196
|
+
Object.keys(e.style || {}).forEach(function (k) { props[k] = 1; });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
var base = ser.itemStyle || {};
|
|
200
|
+
var next = {};
|
|
201
|
+
for (var k in base) next[k] = base[k];
|
|
202
|
+
|
|
203
|
+
Object.keys(props).forEach(function (key) {
|
|
204
|
+
var fallback = base[key] !== undefined
|
|
205
|
+
? base[key]
|
|
206
|
+
: (key === 'color' ? palette[Number(si) % palette.length] : undefined);
|
|
207
|
+
next[key] = function (params) {
|
|
208
|
+
var row = rowOf(params);
|
|
209
|
+
for (var i = 0; i < entries.length; i++) {
|
|
210
|
+
var e = entries[i];
|
|
211
|
+
if (!e.style || e.style[key] === undefined) continue;
|
|
212
|
+
if (matches(e.rule, row ? row[e.rule.field] : undefined)) return e.style[key];
|
|
213
|
+
}
|
|
214
|
+
return fallback;
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
ser.itemStyle = next;
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* DATA LABELS — one formatter and one rich map per series.
|
|
223
|
+
*
|
|
224
|
+
* The formatter has to reproduce what the label would have said, because
|
|
225
|
+
* setting one replaces ECharts' default entirely. `valueField` is what the
|
|
226
|
+
* series plots; a non-cartesian series labels by name, which is what its
|
|
227
|
+
* binding put in params.name.
|
|
228
|
+
*/
|
|
229
|
+
function applyLabelRules(entriesBySeries, option, co, ctx) {
|
|
230
|
+
var yLabels = (co.axes && co.axes.y && co.axes.y.labels) || [];
|
|
231
|
+
var byName = !(co.chartType === undefined) && isNameValue(co.chartType);
|
|
232
|
+
|
|
233
|
+
Object.keys(entriesBySeries).forEach(function (si) {
|
|
234
|
+
var entries = entriesBySeries[si];
|
|
235
|
+
var ser = option.series[si];
|
|
236
|
+
if (!ser || !entries || !entries.length) return;
|
|
237
|
+
|
|
238
|
+
if (ser.label && ser.label.formatter) {
|
|
239
|
+
// Composing with a caller's own formatter is not possible without
|
|
240
|
+
// knowing what it means to produce. Said plainly, because a rule that
|
|
241
|
+
// quietly lost to a template set three panels away is unfindable.
|
|
242
|
+
ctx.warn('a data-label formatter is already set, so conditional formatting on the label is not applied — clear Data labels → Format to use rules');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
var valueField = labelFieldOf(yLabels[Number(si)]);
|
|
247
|
+
var rich = {};
|
|
248
|
+
entries.forEach(function (e, n) {
|
|
249
|
+
if (e.style) rich['r' + si + '_' + n] = e.style;
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// A COPY. seriesLabel is one object shared by every series, so writing a
|
|
253
|
+
// formatter into it would give every series this series' rules.
|
|
254
|
+
var label = {};
|
|
255
|
+
for (var k in (ser.label || {})) label[k] = ser.label[k];
|
|
256
|
+
|
|
257
|
+
label.formatter = function (params) {
|
|
258
|
+
var row = rowOf(params);
|
|
259
|
+
var raw = byName ? params.name : (row && valueField != null ? row[valueField] : params.value);
|
|
260
|
+
var text = raw === null || raw === undefined ? '' : String(raw);
|
|
261
|
+
|
|
262
|
+
for (var i = 0; i < entries.length; i++) {
|
|
263
|
+
var e = entries[i];
|
|
264
|
+
if (!matches(e.rule, row ? row[e.rule.field] : undefined)) continue;
|
|
265
|
+
if (e.hide) return '';
|
|
266
|
+
if (e.format) {
|
|
267
|
+
var formatted = formatNumber(raw, e.format);
|
|
268
|
+
if (formatted !== null) text = formatted;
|
|
269
|
+
}
|
|
270
|
+
var name = 'r' + si + '_' + i;
|
|
271
|
+
if (rich[name] && richSafe(text)) return '{' + name + '|' + text + '}';
|
|
272
|
+
return text;
|
|
273
|
+
}
|
|
274
|
+
return text;
|
|
275
|
+
};
|
|
276
|
+
if (Object.keys(rich).length) label.rich = rich;
|
|
277
|
+
ser.label = label;
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function isNameValue(type) {
|
|
282
|
+
return type === 'pie' || type === 'donut' || type === 'funnel' || type === 'gauge';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* AXIS LABELS — the same formatter/rich pair, on the axis.
|
|
287
|
+
*
|
|
288
|
+
* An axis-label formatter receives the tick's own value and its index. There
|
|
289
|
+
* is no row, so a rule here tests what the axis is showing and nothing else —
|
|
290
|
+
* which is exactly right for "make IPD red" and impossible for "make the IPD
|
|
291
|
+
* tick red when its margin is negative".
|
|
292
|
+
*/
|
|
293
|
+
function applyAxisRules(entries, option, which, ctx) {
|
|
294
|
+
if (!entries.length) return;
|
|
295
|
+
var key = which === 'axisX' ? 'xAxis' : 'yAxis';
|
|
296
|
+
var axis = option[key];
|
|
297
|
+
if (!axis) {
|
|
298
|
+
ctx.warn('a rule styles the ' + (which === 'axisX' ? 'X' : 'Y') + ' axis labels, but this chart has no such axis (ignored)');
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (axis.axisLabel && axis.axisLabel.formatter) {
|
|
302
|
+
ctx.warn('an ' + (which === 'axisX' ? 'X' : 'Y') + ' axis label formatter is already set, so conditional formatting on it is not applied');
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
var rich = {};
|
|
307
|
+
entries.forEach(function (e, n) {
|
|
308
|
+
if (e.style) rich[which + '_' + n] = e.style;
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
var axisLabel = {};
|
|
312
|
+
for (var k in (axis.axisLabel || {})) axisLabel[k] = axis.axisLabel[k];
|
|
313
|
+
|
|
314
|
+
axisLabel.formatter = function (value) {
|
|
315
|
+
var text = value === null || value === undefined ? '' : String(value);
|
|
316
|
+
for (var i = 0; i < entries.length; i++) {
|
|
317
|
+
var e = entries[i];
|
|
318
|
+
if (!matches(e.rule, value)) continue;
|
|
319
|
+
if (e.hide) return '';
|
|
320
|
+
if (e.format) {
|
|
321
|
+
var formatted = formatNumber(value, e.format);
|
|
322
|
+
if (formatted !== null) text = formatted;
|
|
323
|
+
}
|
|
324
|
+
var name = which + '_' + i;
|
|
325
|
+
if (rich[name] && richSafe(text)) return '{' + name + '|' + text + '}';
|
|
326
|
+
return text;
|
|
327
|
+
}
|
|
328
|
+
return text;
|
|
329
|
+
};
|
|
330
|
+
if (Object.keys(rich).length) axisLabel.rich = rich;
|
|
331
|
+
axis.axisLabel = axisLabel;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ECharts' own default series colours, for the mark fallback when the caller
|
|
335
|
+
// carries no palette. Getting this wrong means marks with no fill.
|
|
336
|
+
var DEFAULT_PALETTE = [
|
|
337
|
+
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de',
|
|
338
|
+
'#3ba272', '#fc8452', '#9a60b4', '#ea7ccc'
|
|
339
|
+
];
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Apply every rule on `co` to the option being built.
|
|
343
|
+
*
|
|
344
|
+
* Called after the series exist, because a rule is scoped to one of them and
|
|
345
|
+
* needs its index, its palette colour and the field it plots.
|
|
346
|
+
*/
|
|
347
|
+
function applyRules(co, option, ctx) {
|
|
348
|
+
if (!Array.isArray(co.rules) || !co.rules.length) return;
|
|
349
|
+
if (!Array.isArray(option.series) || !option.series.length) return;
|
|
350
|
+
|
|
351
|
+
var palette = (Array.isArray(co.palette) && co.palette.length) ? co.palette : DEFAULT_PALETTE;
|
|
352
|
+
var sorted = sortRules(co, option, ctx);
|
|
353
|
+
|
|
354
|
+
applyMarkRules(sorted.mark, option, palette);
|
|
355
|
+
applyLabelRules(sorted.dataLabel, option, co, ctx);
|
|
356
|
+
applyAxisRules(sorted.axisX, option, 'axisX', ctx);
|
|
357
|
+
applyAxisRules(sorted.axisY, option, 'axisY', ctx);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
module.exports = applyRules;
|
|
361
|
+
// Re-exported so an existing caller keeps working; the source of truth is
|
|
362
|
+
// @xeplr/rules.
|
|
363
|
+
module.exports.matches = matches;
|
|
364
|
+
module.exports.formatNumber = formatNumber;
|
|
365
|
+
module.exports.richSafe = richSafe;
|
|
366
|
+
module.exports.MARK_KEYS = MARK_KEYS;
|
|
367
|
+
module.exports.TEXT_KEYS = TEXT_KEYS;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/ui-charts",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Organized ECharts spec → option builder + React <Chart> for direct Apache ECharts (no wrapper). Premium optimizers plug in at the option level.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -33,5 +33,8 @@
|
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"echarts": "^6.1.0"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@xeplr/rules": "^1.0.0"
|
|
36
39
|
}
|
|
37
40
|
}
|