ggaction 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/package.json +84 -0
- package/src/AGENTS.md +119 -0
- package/src/ChartProgram.js +10 -0
- package/src/actions/boxPlots/components.js +88 -0
- package/src/actions/boxPlots/create.js +84 -0
- package/src/actions/boxPlots/index.js +10 -0
- package/src/actions/boxPlots/materialize.js +183 -0
- package/src/actions/boxPlots/options.js +147 -0
- package/src/actions/boxPlots/resolve.js +38 -0
- package/src/actions/canvas/actions.js +144 -0
- package/src/actions/canvas/index.js +6 -0
- package/src/actions/coordinates/actions.js +85 -0
- package/src/actions/coordinates/index.js +5 -0
- package/src/actions/data/box.js +40 -0
- package/src/actions/data/create.js +33 -0
- package/src/actions/data/density.js +62 -0
- package/src/actions/data/derived.js +52 -0
- package/src/actions/data/filter.js +168 -0
- package/src/actions/data/index.js +32 -0
- package/src/actions/data/interval.js +69 -0
- package/src/actions/data/regression.js +71 -0
- package/src/actions/data/shared.js +25 -0
- package/src/actions/encodings/appearance.js +243 -0
- package/src/actions/encodings/barWidth.js +60 -0
- package/src/actions/encodings/color.js +329 -0
- package/src/actions/encodings/density.js +225 -0
- package/src/actions/encodings/histogram.js +50 -0
- package/src/actions/encodings/index.js +23 -0
- package/src/actions/encodings/offset.js +104 -0
- package/src/actions/encodings/position/policies/area.js +24 -0
- package/src/actions/encodings/position/policies/bar.js +110 -0
- package/src/actions/encodings/position/policies/common.js +17 -0
- package/src/actions/encodings/position/policies/index.js +25 -0
- package/src/actions/encodings/position/policies/line.js +74 -0
- package/src/actions/encodings/position/policies/point.js +19 -0
- package/src/actions/encodings/position/policies/rule.js +17 -0
- package/src/actions/encodings/position/resolve.js +170 -0
- package/src/actions/encodings/position.js +176 -0
- package/src/actions/encodings/ranged.js +276 -0
- package/src/actions/encodings/ruleAppearance.js +47 -0
- package/src/actions/encodings/shared.js +164 -0
- package/src/actions/encodings/strokeDash.js +145 -0
- package/src/actions/errorBands/components.js +81 -0
- package/src/actions/errorBands/create.js +159 -0
- package/src/actions/errorBands/index.js +7 -0
- package/src/actions/errorBands/resolve.js +28 -0
- package/src/actions/errorBars/components.js +62 -0
- package/src/actions/errorBars/create.js +144 -0
- package/src/actions/errorBars/index.js +7 -0
- package/src/actions/errorBars/resolve.js +25 -0
- package/src/actions/guides/axes/axes.js +204 -0
- package/src/actions/guides/axes/axis.js +131 -0
- package/src/actions/guides/axes/index.js +17 -0
- package/src/actions/guides/axes/labels.js +231 -0
- package/src/actions/guides/axes/lines.js +149 -0
- package/src/actions/guides/axes/policy.js +155 -0
- package/src/actions/guides/axes/tickGroups.js +149 -0
- package/src/actions/guides/axes/ticks.js +179 -0
- package/src/actions/guides/axes/titles.js +264 -0
- package/src/actions/guides/grids/grid.js +228 -0
- package/src/actions/guides/grids/index.js +1 -0
- package/src/actions/guides/grids/resolve.js +258 -0
- package/src/actions/guides/guides.js +138 -0
- package/src/actions/guides/index.js +11 -0
- package/src/actions/guides/legends/categorical/actions.js +329 -0
- package/src/actions/guides/legends/categorical/components.js +140 -0
- package/src/actions/guides/legends/categorical/index.js +49 -0
- package/src/actions/guides/legends/categorical/layout.js +445 -0
- package/src/actions/guides/legends/categorical/options.js +188 -0
- package/src/actions/guides/legends/categorical/recipes.js +120 -0
- package/src/actions/guides/legends/categorical/resolve.js +131 -0
- package/src/actions/guides/legends/categorical/symbols.js +280 -0
- package/src/actions/guides/legends/categorical/validation.js +27 -0
- package/src/actions/guides/legends/continuous/common.js +273 -0
- package/src/actions/guides/legends/continuous/gradient.js +363 -0
- package/src/actions/guides/legends/continuous/index.js +25 -0
- package/src/actions/guides/legends/continuous/interval.js +234 -0
- package/src/actions/guides/legends/continuous/opacity.js +382 -0
- package/src/actions/guides/legends/edit.js +314 -0
- package/src/actions/guides/legends/index.js +9 -0
- package/src/actions/guides/legends/size.js +220 -0
- package/src/actions/guides/tickValues.js +84 -0
- package/src/actions/index.js +31 -0
- package/src/actions/intervals/resolve.js +352 -0
- package/src/actions/marks/area.js +369 -0
- package/src/actions/marks/bar/create.js +46 -0
- package/src/actions/marks/bar/edit.js +90 -0
- package/src/actions/marks/bar/index.js +9 -0
- package/src/actions/marks/bar/materialize.js +130 -0
- package/src/actions/marks/index.js +13 -0
- package/src/actions/marks/line.js +248 -0
- package/src/actions/marks/point.js +420 -0
- package/src/actions/marks/rule.js +287 -0
- package/src/actions/marks/shared.js +112 -0
- package/src/actions/primitives/createGraphics.js +163 -0
- package/src/actions/primitives/editGraphics.js +208 -0
- package/src/actions/primitives/index.js +9 -0
- package/src/actions/primitives/semantic.js +168 -0
- package/src/actions/primitives/semanticValue.js +182 -0
- package/src/actions/regression/components.js +216 -0
- package/src/actions/regression/create.js +102 -0
- package/src/actions/regression/index.js +23 -0
- package/src/actions/regression/resolve.js +57 -0
- package/src/actions/scales/consumers.js +283 -0
- package/src/actions/scales/create.js +148 -0
- package/src/actions/scales/definitions.js +319 -0
- package/src/actions/scales/edit.js +290 -0
- package/src/actions/scales/index.js +11 -0
- package/src/actions/scales/materialize.js +466 -0
- package/src/actions/scales/quantitativeColor.js +45 -0
- package/src/actions/selection/actions.js +384 -0
- package/src/actions/selection/index.js +23 -0
- package/src/actions/titles/actions.js +235 -0
- package/src/actions/titles/index.js +19 -0
- package/src/actions/titles/resolve.js +321 -0
- package/src/core/ChartProgram.js +316 -0
- package/src/core/action.js +120 -0
- package/src/core/identifiers.js +28 -0
- package/src/core/immutable.js +94 -0
- package/src/core/specs.js +29 -0
- package/src/core/textMetrics.js +23 -0
- package/src/core/validation.js +54 -0
- package/src/core/vocabulary.js +58 -0
- package/src/extension.js +2 -0
- package/src/grammar/aggregate.js +259 -0
- package/src/grammar/areaSeries.js +217 -0
- package/src/grammar/bars/aggregate.js +98 -0
- package/src/grammar/bars/geometry.js +54 -0
- package/src/grammar/bars/policy.js +90 -0
- package/src/grammar/boxPlot.js +140 -0
- package/src/grammar/coordinates.js +25 -0
- package/src/grammar/curveCommands.js +356 -0
- package/src/grammar/density.js +296 -0
- package/src/grammar/filter.js +169 -0
- package/src/grammar/histogram.js +332 -0
- package/src/grammar/interval.js +343 -0
- package/src/grammar/lineSeries.js +197 -0
- package/src/grammar/markFilter.js +46 -0
- package/src/grammar/markSelection.js +326 -0
- package/src/grammar/palettes.js +208 -0
- package/src/grammar/pathCommands.js +86 -0
- package/src/grammar/pointShapes.js +164 -0
- package/src/grammar/positionCompatibility.js +32 -0
- package/src/grammar/regression.js +578 -0
- package/src/grammar/ruleAppearance.js +12 -0
- package/src/grammar/rules.js +90 -0
- package/src/grammar/scales/appearance.js +185 -0
- package/src/grammar/scales/color.js +349 -0
- package/src/grammar/scales/continuous.js +111 -0
- package/src/grammar/scales/definition.js +153 -0
- package/src/grammar/scales/discretized.js +159 -0
- package/src/grammar/scales/fields.js +133 -0
- package/src/grammar/scales/index.js +12 -0
- package/src/grammar/scales/mapping.js +27 -0
- package/src/grammar/scales/ordinal.js +221 -0
- package/src/grammar/scales/policies.js +50 -0
- package/src/grammar/scales/temporal.js +53 -0
- package/src/grammar/scales/transformed.js +236 -0
- package/src/grammar/scales/types.js +102 -0
- package/src/grammar/scales/validation.js +66 -0
- package/src/grammar/scales.js +1 -0
- package/src/grammar/schemas/concreteGraphic.js +73 -0
- package/src/grammar/schemas/graphic.js +131 -0
- package/src/grammar/schemas/graphicBounds.js +146 -0
- package/src/grammar/schemas/graphicTree.js +133 -0
- package/src/grammar/schemas/mark.js +4 -0
- package/src/grammar/schemas/semanticPath.js +174 -0
- package/src/grammar/seriesLayout.js +76 -0
- package/src/grammar/ticks.js +149 -0
- package/src/grammar/transforms.js +37 -0
- package/src/index.js +2 -0
- package/src/layout/canvas.js +114 -0
- package/src/layout/grid.js +17 -0
- package/src/layout/legend.js +51 -0
- package/src/layout/text.js +62 -0
- package/src/layout/title.js +107 -0
- package/src/materialization/bars/aggregate.js +184 -0
- package/src/materialization/bars/grouped.js +117 -0
- package/src/materialization/bars/histogram.js +163 -0
- package/src/materialization/bars/ranged.js +53 -0
- package/src/materialization/bars/resolve.js +56 -0
- package/src/materialization/dataProvenance.js +15 -0
- package/src/materialization/density.js +29 -0
- package/src/materialization/dependencies.js +134 -0
- package/src/materialization/encodings.js +50 -0
- package/src/materialization/graphicHierarchy.js +104 -0
- package/src/materialization/legends.js +20 -0
- package/src/materialization/marks.js +110 -0
- package/src/materialization/planner.js +38 -0
- package/src/materialization/selection/filter.js +40 -0
- package/src/materialization/selection/items.js +503 -0
- package/src/materialization/selection/path.js +37 -0
- package/src/materialization/selection/point.js +118 -0
- package/src/materialization/selection/policies/area.js +10 -0
- package/src/materialization/selection/policies/bar.js +10 -0
- package/src/materialization/selection/policies/index.js +40 -0
- package/src/materialization/selection/policies/line.js +10 -0
- package/src/materialization/selection/policies/point.js +10 -0
- package/src/materialization/selection/policies/rule.js +10 -0
- package/src/materialization/selection/state.js +67 -0
- package/src/materialization/selection/styles.js +183 -0
- package/src/renderers/canvas/circle.js +62 -0
- package/src/renderers/canvas/index.js +132 -0
- package/src/renderers/canvas/line.js +54 -0
- package/src/renderers/canvas/path.js +98 -0
- package/src/renderers/canvas/rect.js +57 -0
- package/src/renderers/canvas/text.js +96 -0
- package/src/renderers/canvas/validation.js +23 -0
- package/src/renderers/png.js +29 -0
- package/src/selectors/coordinates.js +13 -0
- package/src/selectors/datasets.js +13 -0
- package/src/selectors/index.js +4 -0
- package/src/selectors/layers.js +32 -0
- package/src/selectors/scales.js +21 -0
- package/src/theme/defaults.js +13 -0
- package/types/extension.d.ts +14 -0
- package/types/index.d.ts +81 -0
- package/types/png.d.ts +14 -0
- package/types/program.d.ts +1096 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { isPlainObject } from "../../../../core/immutable.js";
|
|
2
|
+
import { noOptions } from "../../../../core/validation.js";
|
|
3
|
+
import { mapOrdinalValues } from "../../../../grammar/scales.js";
|
|
4
|
+
import { resolveGraphicBounds } from "../../../../layout/canvas.js";
|
|
5
|
+
import { unionConcreteGraphicBounds } from "../../../../grammar/schemas/graphicBounds.js";
|
|
6
|
+
import { DEFAULT_COLORS } from "../../../../theme/defaults.js";
|
|
7
|
+
import {
|
|
8
|
+
alignLegendStart,
|
|
9
|
+
measureLegendSymbolHeight,
|
|
10
|
+
measureLegendTextWidth,
|
|
11
|
+
resolveLegendGrid
|
|
12
|
+
} from "../../../../layout/legend.js";
|
|
13
|
+
|
|
14
|
+
export function activeConfig(program) {
|
|
15
|
+
const entries = ["series", "color"]
|
|
16
|
+
.filter(kind => program.guideConfigs.legend?.[kind] !== undefined)
|
|
17
|
+
.map(kind => [kind, program.guideConfigs.legend[kind]]);
|
|
18
|
+
if (entries.length !== 1) {
|
|
19
|
+
throw new Error("Legend component requires one categorical legend config.");
|
|
20
|
+
}
|
|
21
|
+
return { kind: entries[0][0], config: entries[0][1] };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function prefix(config) {
|
|
25
|
+
return config.kind === "series" ? "seriesLegend" : "colorLegend";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function symbolGraphic(config, type) {
|
|
29
|
+
const onlyDefault = config.symbol.layers.length === 1 &&
|
|
30
|
+
((config.kind === "series" && type === "line") ||
|
|
31
|
+
(config.kind === "color" && type === "swatch"));
|
|
32
|
+
if (onlyDefault) return `${prefix(config)}Symbols`;
|
|
33
|
+
const suffix = { line: "Lines", point: "Points", swatch: "Swatches" }[type];
|
|
34
|
+
return `${prefix(config)}Symbol${suffix}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function graphic(config, component) {
|
|
38
|
+
return `${prefix(config)}${component}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function symbolWidth(config) {
|
|
42
|
+
return Math.max(...config.symbol.layers.map(layer => {
|
|
43
|
+
if (layer.type === "line") return layer.length;
|
|
44
|
+
if (layer.type === "point") return layer.size * 2;
|
|
45
|
+
return layer.width;
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function leftGuideBoundary(program, bounds) {
|
|
50
|
+
const ids = ["yAxisLine", "yAxisTicks", "yAxisLabels", "yAxisTitle"]
|
|
51
|
+
.filter(id => program.graphicSpec.objects[id] !== undefined);
|
|
52
|
+
const occupied = unionConcreteGraphicBounds(program.graphicSpec, ids);
|
|
53
|
+
return occupied === undefined ? bounds.x : Math.min(bounds.x, occupied.left);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveLeftSizeMetrics(program, sizeConfig) {
|
|
57
|
+
if (sizeConfig === undefined) return undefined;
|
|
58
|
+
const scale = program.resolvedScales[sizeConfig.scale];
|
|
59
|
+
if (scale?.type !== "linear") {
|
|
60
|
+
throw new Error(`Legend requires resolved linear scale "${sizeConfig.scale}".`);
|
|
61
|
+
}
|
|
62
|
+
const values = Array.from(
|
|
63
|
+
{ length: sizeConfig.count },
|
|
64
|
+
(_, index) => scale.domain[0] +
|
|
65
|
+
index / (sizeConfig.count - 1) * (scale.domain[1] - scale.domain[0])
|
|
66
|
+
);
|
|
67
|
+
const labels = values.map(value => String(+value.toPrecision(3)));
|
|
68
|
+
const maximumRadius = Math.sqrt(Math.max(...scale.range) / Math.PI);
|
|
69
|
+
return { scale, values, labels, maximumRadius };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resolveLeftLayout(program, bounds, canvas, config, width, count) {
|
|
73
|
+
const sizeConfig = program.guideConfigs.legend?.size;
|
|
74
|
+
const size = resolveLeftSizeMetrics(program, sizeConfig);
|
|
75
|
+
const padding = config.border === false ? 0 : config.border.padding;
|
|
76
|
+
const categoricalWidth = Math.max(
|
|
77
|
+
config.titleVisible === false ? 0 : String(config.title).length * 6,
|
|
78
|
+
...config.domain.map(value =>
|
|
79
|
+
width + config.labels.offset + measureLegendTextWidth(value)
|
|
80
|
+
)
|
|
81
|
+
);
|
|
82
|
+
const sizeWidth = size === undefined
|
|
83
|
+
? 0
|
|
84
|
+
: Math.max(
|
|
85
|
+
String(sizeConfig.title).length * 6,
|
|
86
|
+
...size.labels.map(label => 44 + measureLegendTextWidth(label))
|
|
87
|
+
);
|
|
88
|
+
const contentWidth = Math.max(categoricalWidth, sizeWidth);
|
|
89
|
+
const occupiedRight = bounds.x - config.offset;
|
|
90
|
+
const contentRight = occupiedRight - padding;
|
|
91
|
+
const start = contentRight - contentWidth;
|
|
92
|
+
if (start < padding) {
|
|
93
|
+
throw new Error("Legend layout requires more left-margin space.");
|
|
94
|
+
}
|
|
95
|
+
if (occupiedRight >= leftGuideBoundary(program, bounds)) {
|
|
96
|
+
throw new Error("Left legend and y-axis guides require more left-margin space.");
|
|
97
|
+
}
|
|
98
|
+
const symbolX = Array(count).fill(start);
|
|
99
|
+
const itemY = Array.from(
|
|
100
|
+
{ length: count },
|
|
101
|
+
(_, index) => bounds.y + 52 + index * config.itemGap
|
|
102
|
+
);
|
|
103
|
+
const labelX = symbolX.map(value => value + width + config.labels.offset);
|
|
104
|
+
const titleX = start;
|
|
105
|
+
const titleY = bounds.y + 20;
|
|
106
|
+
const sizeTitleY = bounds.y + 56 + count * 34 + 22;
|
|
107
|
+
const sizeItemY = size?.values.map((_, index) => sizeTitleY + 34 + index * 40);
|
|
108
|
+
const sizeSymbolX = size?.values.map(() => start + 16);
|
|
109
|
+
const sizeLabelX = size?.values.map(() => start + 44);
|
|
110
|
+
const categoricalTop = config.titleVisible === false
|
|
111
|
+
? itemY[0] - Math.max(
|
|
112
|
+
config.labels.fontSize,
|
|
113
|
+
measureLegendSymbolHeight(config)
|
|
114
|
+
) / 2
|
|
115
|
+
: titleY - config.titleStyle.fontSize / 2;
|
|
116
|
+
const sizeBottom = size === undefined
|
|
117
|
+
? -Infinity
|
|
118
|
+
: sizeItemY.at(-1) + Math.max(size.maximumRadius, config.labels.fontSize / 2);
|
|
119
|
+
const categoricalBottom = itemY.at(-1) +
|
|
120
|
+
Math.max(config.labels.fontSize, measureLegendSymbolHeight(config)) / 2;
|
|
121
|
+
const blockTop = Math.min(categoricalTop, sizeTitleY - config.titleStyle.fontSize / 2);
|
|
122
|
+
const blockBottom = Math.max(categoricalBottom, sizeBottom);
|
|
123
|
+
if (blockTop < 0 || blockBottom > canvas.properties.height) {
|
|
124
|
+
throw new Error("Legend layout requires more vertical Canvas space.");
|
|
125
|
+
}
|
|
126
|
+
let background;
|
|
127
|
+
if (config.border !== false) {
|
|
128
|
+
const backgroundTop = Math.floor(
|
|
129
|
+
blockTop - config.border.padding - config.border.lineWidth
|
|
130
|
+
);
|
|
131
|
+
const backgroundBottom = Math.ceil(blockBottom + config.border.padding);
|
|
132
|
+
background = {
|
|
133
|
+
x: start - config.border.padding,
|
|
134
|
+
y: backgroundTop,
|
|
135
|
+
width: occupiedRight - (start - config.border.padding),
|
|
136
|
+
height: backgroundBottom - backgroundTop
|
|
137
|
+
};
|
|
138
|
+
if (
|
|
139
|
+
background.x < 0 || background.y < 0 ||
|
|
140
|
+
background.x + background.width > canvas.properties.width ||
|
|
141
|
+
background.y + background.height > canvas.properties.height
|
|
142
|
+
) {
|
|
143
|
+
throw new Error("Legend background requires more left-margin space.");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
symbolX,
|
|
148
|
+
itemY,
|
|
149
|
+
labelX,
|
|
150
|
+
titleX,
|
|
151
|
+
titleY,
|
|
152
|
+
background,
|
|
153
|
+
blockTop,
|
|
154
|
+
blockBottom,
|
|
155
|
+
size: size === undefined ? undefined : {
|
|
156
|
+
...size,
|
|
157
|
+
titleX: start,
|
|
158
|
+
titleY: sizeTitleY,
|
|
159
|
+
itemY: sizeItemY,
|
|
160
|
+
symbolX: sizeSymbolX,
|
|
161
|
+
labelX: sizeLabelX
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function resolveTopLayout(program, bounds, canvas, config, width, count) {
|
|
167
|
+
const { cells, columnWidths, gridWidth, gridHeight, rowHeight } =
|
|
168
|
+
resolveLegendGrid(config, width, count);
|
|
169
|
+
const titleWidth = config.title.length * 7;
|
|
170
|
+
const titleGap = config.titlePosition === "left" ? 20 : 12;
|
|
171
|
+
const totalWidth = config.titlePosition === "left"
|
|
172
|
+
? titleWidth + titleGap + gridWidth
|
|
173
|
+
: Math.max(titleWidth, gridWidth);
|
|
174
|
+
const start = alignLegendStart(bounds, totalWidth, config.align);
|
|
175
|
+
if (start < 0 || start + totalWidth > canvas.properties.width) {
|
|
176
|
+
throw new Error("Legend layout requires more horizontal Canvas space.");
|
|
177
|
+
}
|
|
178
|
+
const blockBottom = bounds.y - config.offset;
|
|
179
|
+
const gridStart = config.titlePosition === "left"
|
|
180
|
+
? start + titleWidth + titleGap
|
|
181
|
+
: start + (totalWidth - gridWidth) / 2;
|
|
182
|
+
const gridTop = blockBottom - gridHeight;
|
|
183
|
+
const blockTop = config.titlePosition === "left"
|
|
184
|
+
? Math.min(gridTop, blockBottom / 2 + gridTop / 2 - config.titleStyle.fontSize / 2)
|
|
185
|
+
: gridTop - titleGap - config.titleStyle.fontSize;
|
|
186
|
+
if (blockTop < 0) {
|
|
187
|
+
throw new Error("Legend layout requires more top-margin space.");
|
|
188
|
+
}
|
|
189
|
+
const titleGraphic = program.graphicSpec.objects.chartSubtitle ??
|
|
190
|
+
program.graphicSpec.objects.chartTitle;
|
|
191
|
+
if (
|
|
192
|
+
titleGraphic?.type === "text" &&
|
|
193
|
+
titleGraphic.properties.y + titleGraphic.properties.fontSize / 2 >= blockTop
|
|
194
|
+
) {
|
|
195
|
+
throw new Error("Top legend and chart title require more top-margin space.");
|
|
196
|
+
}
|
|
197
|
+
const columnX = [];
|
|
198
|
+
let cursor = gridStart;
|
|
199
|
+
for (const columnWidth of columnWidths) {
|
|
200
|
+
columnX.push(cursor);
|
|
201
|
+
cursor += columnWidth + config.itemGap;
|
|
202
|
+
}
|
|
203
|
+
const symbolX = cells.map(cell => columnX[cell.column]);
|
|
204
|
+
const labelX = symbolX.map(value => value + width + config.labels.offset);
|
|
205
|
+
const itemY = cells.map(cell =>
|
|
206
|
+
gridTop + rowHeight / 2 + cell.row * (rowHeight + config.itemGap)
|
|
207
|
+
);
|
|
208
|
+
const titleX = config.titlePosition === "left"
|
|
209
|
+
? start
|
|
210
|
+
: start + totalWidth / 2;
|
|
211
|
+
const titleY = config.titlePosition === "left"
|
|
212
|
+
? gridTop + gridHeight / 2
|
|
213
|
+
: gridTop - titleGap - config.titleStyle.fontSize / 2;
|
|
214
|
+
let background;
|
|
215
|
+
if (config.border !== false) {
|
|
216
|
+
const x = start - config.border.padding;
|
|
217
|
+
const y = blockTop - config.border.padding;
|
|
218
|
+
const backgroundWidth = totalWidth + config.border.padding * 2;
|
|
219
|
+
const height = blockBottom - blockTop + config.border.padding * 2;
|
|
220
|
+
if (x < 0 || y < 0 || x + backgroundWidth > canvas.properties.width) {
|
|
221
|
+
throw new Error("Legend background requires more top or horizontal space.");
|
|
222
|
+
}
|
|
223
|
+
background = { x, y, width: backgroundWidth, height };
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
symbolX,
|
|
227
|
+
itemY,
|
|
228
|
+
labelX,
|
|
229
|
+
titleX,
|
|
230
|
+
titleY,
|
|
231
|
+
background,
|
|
232
|
+
blockTop,
|
|
233
|
+
blockBottom
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function resolveBottomLayout(program, bounds, canvas, config, width, count) {
|
|
238
|
+
const { cells, columnWidths, gridWidth, gridHeight, rowHeight } =
|
|
239
|
+
resolveLegendGrid(config, width, count);
|
|
240
|
+
const titleWidth = config.title.length * 7;
|
|
241
|
+
const titleGap = config.titlePosition === "left" ? 20 : 12;
|
|
242
|
+
const totalWidth = config.titlePosition === "left"
|
|
243
|
+
? titleWidth + titleGap + gridWidth
|
|
244
|
+
: Math.max(titleWidth, gridWidth);
|
|
245
|
+
const start = alignLegendStart(bounds, totalWidth, config.align);
|
|
246
|
+
if (start < 0 || start + totalWidth > canvas.properties.width) {
|
|
247
|
+
throw new Error("Legend layout requires more horizontal Canvas space.");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const blockTop = bounds.y + bounds.height + config.offset;
|
|
251
|
+
const gridStart = config.titlePosition === "left"
|
|
252
|
+
? start + titleWidth + titleGap
|
|
253
|
+
: start + (totalWidth - gridWidth) / 2;
|
|
254
|
+
const gridTop = config.titlePosition === "left"
|
|
255
|
+
? blockTop
|
|
256
|
+
: blockTop + config.titleStyle.fontSize + titleGap;
|
|
257
|
+
const blockBottom = gridTop + gridHeight;
|
|
258
|
+
const titleX = config.titlePosition === "left"
|
|
259
|
+
? start
|
|
260
|
+
: start + totalWidth / 2;
|
|
261
|
+
const titleY = config.titlePosition === "left"
|
|
262
|
+
? gridTop + gridHeight / 2
|
|
263
|
+
: blockTop + config.titleStyle.fontSize / 2;
|
|
264
|
+
|
|
265
|
+
const axisTitle = program.graphicSpec.objects.xAxisTitle;
|
|
266
|
+
const occupiedTop = config.border === false
|
|
267
|
+
? blockTop
|
|
268
|
+
: blockTop - config.border.padding;
|
|
269
|
+
if (
|
|
270
|
+
axisTitle?.type === "text" &&
|
|
271
|
+
axisTitle.properties.y + axisTitle.properties.fontSize / 2 >= occupiedTop
|
|
272
|
+
) {
|
|
273
|
+
throw new Error("Bottom legend and x-axis title require more bottom-margin space.");
|
|
274
|
+
}
|
|
275
|
+
const occupiedBottom = config.border === false
|
|
276
|
+
? blockBottom
|
|
277
|
+
: blockBottom + config.border.padding;
|
|
278
|
+
if (occupiedTop < 0 || occupiedBottom > canvas.properties.height) {
|
|
279
|
+
throw new Error("Legend layout requires more bottom-margin space.");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const columnX = [];
|
|
283
|
+
let cursor = gridStart;
|
|
284
|
+
for (const columnWidth of columnWidths) {
|
|
285
|
+
columnX.push(cursor);
|
|
286
|
+
cursor += columnWidth + config.itemGap;
|
|
287
|
+
}
|
|
288
|
+
const symbolX = cells.map(cell => columnX[cell.column]);
|
|
289
|
+
const labelX = symbolX.map(value => value + width + config.labels.offset);
|
|
290
|
+
const itemY = cells.map(cell =>
|
|
291
|
+
gridTop + rowHeight / 2 + cell.row * (rowHeight + config.itemGap)
|
|
292
|
+
);
|
|
293
|
+
let background;
|
|
294
|
+
if (config.border !== false) {
|
|
295
|
+
background = {
|
|
296
|
+
x: start - config.border.padding,
|
|
297
|
+
y: blockTop - config.border.padding,
|
|
298
|
+
width: totalWidth + config.border.padding * 2,
|
|
299
|
+
height: blockBottom - blockTop + config.border.padding * 2
|
|
300
|
+
};
|
|
301
|
+
if (
|
|
302
|
+
background.x < 0 ||
|
|
303
|
+
background.y < 0 ||
|
|
304
|
+
background.x + background.width > canvas.properties.width ||
|
|
305
|
+
background.y + background.height > canvas.properties.height
|
|
306
|
+
) {
|
|
307
|
+
throw new Error("Legend background requires more bottom or horizontal space.");
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
symbolX,
|
|
312
|
+
itemY,
|
|
313
|
+
labelX,
|
|
314
|
+
titleX,
|
|
315
|
+
titleY,
|
|
316
|
+
background,
|
|
317
|
+
blockTop,
|
|
318
|
+
blockBottom
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function resolveCompactBottomLayout(bounds, canvas, config, width, count) {
|
|
323
|
+
const labels = config.domain.map(String);
|
|
324
|
+
const itemWidths = labels.map(
|
|
325
|
+
label => width + config.labels.offset + label.length * 7
|
|
326
|
+
);
|
|
327
|
+
const totalWidth = itemWidths.reduce((sum, value) => sum + value, 0) +
|
|
328
|
+
config.itemGap * Math.max(0, count - 1);
|
|
329
|
+
const start = config.align === "center"
|
|
330
|
+
? (canvas.properties.width - totalWidth) / 2
|
|
331
|
+
: alignLegendStart(bounds, totalWidth, config.align);
|
|
332
|
+
if (start < 0 || start + totalWidth > canvas.properties.width) {
|
|
333
|
+
throw new Error("Legend layout requires more horizontal Canvas space.");
|
|
334
|
+
}
|
|
335
|
+
const symbolX = [];
|
|
336
|
+
const labelX = [];
|
|
337
|
+
let cursor = start;
|
|
338
|
+
for (let index = 0; index < count; index += 1) {
|
|
339
|
+
symbolX.push(cursor);
|
|
340
|
+
labelX.push(cursor + width + config.labels.offset);
|
|
341
|
+
cursor += itemWidths[index] + config.itemGap;
|
|
342
|
+
}
|
|
343
|
+
const itemY = Array(count).fill(canvas.properties.height - 28);
|
|
344
|
+
const titleX = start + totalWidth / 2;
|
|
345
|
+
const titleY = canvas.properties.height - 52;
|
|
346
|
+
if (titleY <= bounds.y + bounds.height) {
|
|
347
|
+
throw new Error("Legend layout requires more bottom-margin space.");
|
|
348
|
+
}
|
|
349
|
+
let background;
|
|
350
|
+
if (config.border !== false) {
|
|
351
|
+
const maxHeight = Math.max(
|
|
352
|
+
config.labels.fontSize,
|
|
353
|
+
measureLegendSymbolHeight(config)
|
|
354
|
+
);
|
|
355
|
+
const x = start - config.border.padding;
|
|
356
|
+
const y = titleY - config.titleStyle.fontSize / 2 - config.border.padding;
|
|
357
|
+
background = {
|
|
358
|
+
x,
|
|
359
|
+
y,
|
|
360
|
+
width: totalWidth + config.border.padding * 2,
|
|
361
|
+
height: itemY[0] + maxHeight / 2 + config.border.padding - y
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
return { symbolX, itemY, labelX, titleX, titleY, background };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function resolveLayout(program, config) {
|
|
368
|
+
const bounds = resolveGraphicBounds(program);
|
|
369
|
+
const canvas = program.graphicSpec.objects.canvas;
|
|
370
|
+
if (
|
|
371
|
+
bounds === undefined ||
|
|
372
|
+
![bounds.x, bounds.y, bounds.width, bounds.height].every(Number.isFinite) ||
|
|
373
|
+
canvas?.type !== "canvas" ||
|
|
374
|
+
!Number.isFinite(canvas.properties.width) ||
|
|
375
|
+
!Number.isFinite(canvas.properties.height)
|
|
376
|
+
) {
|
|
377
|
+
throw new Error("Legend layout requires Canvas bounds, width, and height.");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const count = config.domain.length;
|
|
381
|
+
const width = symbolWidth(config);
|
|
382
|
+
if (config.position === "right") {
|
|
383
|
+
const symbolX = Array(count).fill(bounds.x + bounds.width + 30);
|
|
384
|
+
const itemY = Array.from(
|
|
385
|
+
{ length: count },
|
|
386
|
+
(_, index) => bounds.y + 52 + index * config.itemGap
|
|
387
|
+
);
|
|
388
|
+
const labelX = symbolX.map(value => value + width + config.labels.offset);
|
|
389
|
+
if (labelX.some(value => value >= canvas.properties.width)) {
|
|
390
|
+
throw new Error("Legend layout requires more right-margin space.");
|
|
391
|
+
}
|
|
392
|
+
const titleX = symbolX[0];
|
|
393
|
+
const titleY = bounds.y + 20;
|
|
394
|
+
let background;
|
|
395
|
+
if (config.border !== false) {
|
|
396
|
+
const x = symbolX[0] - config.border.padding;
|
|
397
|
+
const y = bounds.y + 8;
|
|
398
|
+
const backgroundWidth =
|
|
399
|
+
canvas.properties.width - x - config.border.padding;
|
|
400
|
+
const height = itemY.at(-1) - y + config.border.padding;
|
|
401
|
+
if (backgroundWidth <= 0 || height <= 0) {
|
|
402
|
+
throw new Error("Legend background requires positive width and height.");
|
|
403
|
+
}
|
|
404
|
+
background = { x, y, width: backgroundWidth, height };
|
|
405
|
+
}
|
|
406
|
+
return { symbolX, itemY, labelX, titleX, titleY, background };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (config.position === "left") {
|
|
410
|
+
return resolveLeftLayout(program, bounds, canvas, config, width, count);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (config.position === "top") {
|
|
414
|
+
return resolveTopLayout(program, bounds, canvas, config, width, count);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (config.bottomGrid !== true) {
|
|
418
|
+
return resolveCompactBottomLayout(bounds, canvas, config, width, count);
|
|
419
|
+
}
|
|
420
|
+
return resolveBottomLayout(program, bounds, canvas, config, width, count);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function resolveAppearance(program, config) {
|
|
424
|
+
let colors = config.domain.map(() => DEFAULT_COLORS.mark);
|
|
425
|
+
let dashes = config.domain.map(() => []);
|
|
426
|
+
let shapes = config.domain.map(() => "circle");
|
|
427
|
+
for (let index = 0; index < config.channels.length; index += 1) {
|
|
428
|
+
const scale = program.resolvedScales[config.scales[index]];
|
|
429
|
+
const values = mapOrdinalValues(config.domain, scale.domain, scale.range);
|
|
430
|
+
if (config.channels[index] === "color") colors = values;
|
|
431
|
+
if (config.channels[index] === "strokeDash") dashes = values;
|
|
432
|
+
if (config.channels[index] === "shape") shapes = values;
|
|
433
|
+
}
|
|
434
|
+
return { colors, dashes, shapes };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export { noOptions };
|
|
438
|
+
|
|
439
|
+
export function layerFor(config, type) {
|
|
440
|
+
const layer = config.symbol.layers.find(item => item.type === type);
|
|
441
|
+
if (layer === undefined) {
|
|
442
|
+
throw new Error(`Legend recipe does not contain a ${type} layer.`);
|
|
443
|
+
}
|
|
444
|
+
return layer;
|
|
445
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { isPlainObject } from "../../../../core/immutable.js";
|
|
2
|
+
import { CATEGORICAL_LEGEND_CHANNELS } from "../../../../core/vocabulary.js";
|
|
3
|
+
import { normalizeRecipe } from "./recipes.js";
|
|
4
|
+
import {
|
|
5
|
+
nonEmptyString,
|
|
6
|
+
nonNegative,
|
|
7
|
+
positive,
|
|
8
|
+
validateFontWeight,
|
|
9
|
+
validateKeys,
|
|
10
|
+
validateObject
|
|
11
|
+
} from "./validation.js";
|
|
12
|
+
import { DEFAULT_COLORS, DEFAULT_FONT_FAMILY } from
|
|
13
|
+
"../../../../theme/defaults.js";
|
|
14
|
+
|
|
15
|
+
export const CHANNELS = CATEGORICAL_LEGEND_CHANNELS;
|
|
16
|
+
const OPTIONS = Object.freeze([
|
|
17
|
+
"target",
|
|
18
|
+
"channels",
|
|
19
|
+
"position",
|
|
20
|
+
"align",
|
|
21
|
+
"direction",
|
|
22
|
+
"columns",
|
|
23
|
+
"offset",
|
|
24
|
+
"titlePosition",
|
|
25
|
+
"title",
|
|
26
|
+
"symbol",
|
|
27
|
+
"labels",
|
|
28
|
+
"titleStyle",
|
|
29
|
+
"itemGap",
|
|
30
|
+
"border"
|
|
31
|
+
]);
|
|
32
|
+
const TEXT_OPTIONS = Object.freeze([
|
|
33
|
+
"offset",
|
|
34
|
+
"color",
|
|
35
|
+
"fontSize",
|
|
36
|
+
"fontFamily",
|
|
37
|
+
"fontWeight"
|
|
38
|
+
]);
|
|
39
|
+
const TITLE_OPTIONS = Object.freeze([
|
|
40
|
+
"color",
|
|
41
|
+
"fontSize",
|
|
42
|
+
"fontFamily",
|
|
43
|
+
"fontWeight"
|
|
44
|
+
]);
|
|
45
|
+
const BORDER_OPTIONS = Object.freeze([
|
|
46
|
+
"color",
|
|
47
|
+
"lineWidth",
|
|
48
|
+
"padding",
|
|
49
|
+
"background"
|
|
50
|
+
]);
|
|
51
|
+
const COMMON_DEFAULTS = Object.freeze({
|
|
52
|
+
labels: Object.freeze({
|
|
53
|
+
color: DEFAULT_COLORS.text,
|
|
54
|
+
fontSize: 12,
|
|
55
|
+
fontFamily: DEFAULT_FONT_FAMILY,
|
|
56
|
+
fontWeight: "normal"
|
|
57
|
+
}),
|
|
58
|
+
titleStyle: Object.freeze({
|
|
59
|
+
color: DEFAULT_COLORS.text,
|
|
60
|
+
fontSize: 13,
|
|
61
|
+
fontFamily: DEFAULT_FONT_FAMILY,
|
|
62
|
+
fontWeight: 600
|
|
63
|
+
}),
|
|
64
|
+
border: Object.freeze({
|
|
65
|
+
color: DEFAULT_COLORS.border,
|
|
66
|
+
lineWidth: 1,
|
|
67
|
+
padding: 12,
|
|
68
|
+
background: "transparent"
|
|
69
|
+
})
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function normalizeBorder(border) {
|
|
73
|
+
if (border === undefined || border === false) return false;
|
|
74
|
+
if (border !== true) {
|
|
75
|
+
validateObject(border, BORDER_OPTIONS, "createLegend.border");
|
|
76
|
+
}
|
|
77
|
+
const normalized = {
|
|
78
|
+
...COMMON_DEFAULTS.border,
|
|
79
|
+
...(border === true ? {} : border)
|
|
80
|
+
};
|
|
81
|
+
nonEmptyString(normalized.color, "Legend border color");
|
|
82
|
+
nonEmptyString(normalized.background, "Legend border background");
|
|
83
|
+
nonNegative(normalized.lineWidth, "Legend border lineWidth");
|
|
84
|
+
nonNegative(normalized.padding, "Legend border padding");
|
|
85
|
+
return normalized;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function normalizeOptions(args, kind) {
|
|
89
|
+
if (!isPlainObject(args)) {
|
|
90
|
+
throw new TypeError("createLegend options must be a plain object.");
|
|
91
|
+
}
|
|
92
|
+
validateKeys(args, OPTIONS, "createLegend");
|
|
93
|
+
if (Object.hasOwn(args, "labels")) {
|
|
94
|
+
validateObject(args.labels, TEXT_OPTIONS, "createLegend.labels");
|
|
95
|
+
}
|
|
96
|
+
if (Object.hasOwn(args, "titleStyle")) {
|
|
97
|
+
validateObject(
|
|
98
|
+
args.titleStyle,
|
|
99
|
+
TITLE_OPTIONS,
|
|
100
|
+
"createLegend.titleStyle"
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const defaults = {
|
|
105
|
+
position: "right",
|
|
106
|
+
align: "center",
|
|
107
|
+
offset: kind === "series" ? 10 : 8
|
|
108
|
+
};
|
|
109
|
+
const labels = {
|
|
110
|
+
...COMMON_DEFAULTS.labels,
|
|
111
|
+
offset: defaults.offset,
|
|
112
|
+
...(args.labels ?? {})
|
|
113
|
+
};
|
|
114
|
+
const titleStyle = {
|
|
115
|
+
...COMMON_DEFAULTS.titleStyle,
|
|
116
|
+
...(args.titleStyle ?? {})
|
|
117
|
+
};
|
|
118
|
+
const position = args.position ?? defaults.position;
|
|
119
|
+
const align = args.align ?? defaults.align;
|
|
120
|
+
const direction = args.direction ?? (position === "left" ? "vertical" : "horizontal");
|
|
121
|
+
const columns = args.columns;
|
|
122
|
+
const offset = args.offset ?? 8;
|
|
123
|
+
const titlePosition = args.titlePosition ?? "top";
|
|
124
|
+
const itemGap = args.itemGap ?? (["right", "left"].includes(position)
|
|
125
|
+
? 28
|
|
126
|
+
: position === "top" ? 24 : 20);
|
|
127
|
+
const bottomGrid = position !== "bottom" || [
|
|
128
|
+
"columns",
|
|
129
|
+
"direction",
|
|
130
|
+
"offset",
|
|
131
|
+
"titlePosition",
|
|
132
|
+
"itemGap"
|
|
133
|
+
].some(key => Object.hasOwn(args, key));
|
|
134
|
+
|
|
135
|
+
if (!["right", "left", "bottom", "top"].includes(position)) {
|
|
136
|
+
throw new Error(`Unsupported legend position "${position}".`);
|
|
137
|
+
}
|
|
138
|
+
if (!["left", "center", "right"].includes(align)) {
|
|
139
|
+
throw new Error(`Unsupported legend alignment "${align}".`);
|
|
140
|
+
}
|
|
141
|
+
if (["right", "left"].includes(position) && align !== "center") {
|
|
142
|
+
throw new Error("Side legends currently require center alignment.");
|
|
143
|
+
}
|
|
144
|
+
if (!["horizontal", "vertical"].includes(direction)) {
|
|
145
|
+
throw new Error(`Unsupported legend direction "${direction}".`);
|
|
146
|
+
}
|
|
147
|
+
if (position === "left" && direction !== "vertical") {
|
|
148
|
+
throw new Error("Left legends currently require vertical direction.");
|
|
149
|
+
}
|
|
150
|
+
if (position === "left" && columns !== undefined) {
|
|
151
|
+
throw new Error("Left legends do not accept columns.");
|
|
152
|
+
}
|
|
153
|
+
if (columns !== undefined && (!Number.isInteger(columns) || columns <= 0)) {
|
|
154
|
+
throw new RangeError("Legend columns must be a positive integer.");
|
|
155
|
+
}
|
|
156
|
+
nonNegative(offset, "Legend offset");
|
|
157
|
+
if (!["top", "left"].includes(titlePosition)) {
|
|
158
|
+
throw new Error(`Unsupported legend titlePosition "${titlePosition}".`);
|
|
159
|
+
}
|
|
160
|
+
nonNegative(labels.offset, "Legend label offset");
|
|
161
|
+
nonEmptyString(labels.color, "Legend label color");
|
|
162
|
+
positive(labels.fontSize, "Legend label fontSize");
|
|
163
|
+
nonEmptyString(labels.fontFamily, "Legend label fontFamily");
|
|
164
|
+
validateFontWeight(labels.fontWeight, "Legend label fontWeight");
|
|
165
|
+
nonEmptyString(titleStyle.color, "Legend title color");
|
|
166
|
+
positive(titleStyle.fontSize, "Legend title fontSize");
|
|
167
|
+
nonEmptyString(titleStyle.fontFamily, "Legend title fontFamily");
|
|
168
|
+
validateFontWeight(titleStyle.fontWeight, "Legend title fontWeight");
|
|
169
|
+
positive(itemGap, "Legend itemGap");
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
target: args.target,
|
|
173
|
+
channels: args.channels,
|
|
174
|
+
position,
|
|
175
|
+
align,
|
|
176
|
+
direction,
|
|
177
|
+
columns,
|
|
178
|
+
offset,
|
|
179
|
+
titlePosition,
|
|
180
|
+
title: args.title,
|
|
181
|
+
symbol: normalizeRecipe(args.symbol, kind),
|
|
182
|
+
labels,
|
|
183
|
+
titleStyle,
|
|
184
|
+
itemGap,
|
|
185
|
+
bottomGrid,
|
|
186
|
+
border: normalizeBorder(args.border)
|
|
187
|
+
};
|
|
188
|
+
}
|