ggaction 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +35 -13
  3. package/package.json +18 -7
  4. package/src/actions/boxPlots/create.js +13 -7
  5. package/src/actions/boxPlots/edit.js +31 -35
  6. package/src/actions/boxPlots/materialize.js +3 -0
  7. package/src/actions/boxPlots/options.js +8 -1
  8. package/src/actions/boxPlots/resolve.js +10 -2
  9. package/src/actions/charts/bar.js +50 -0
  10. package/src/actions/charts/heatmap.js +205 -0
  11. package/src/actions/charts/histogram.js +58 -0
  12. package/src/actions/charts/index.js +15 -0
  13. package/src/actions/charts/line.js +69 -0
  14. package/src/actions/charts/parallel.js +76 -0
  15. package/src/actions/charts/scatter.js +56 -0
  16. package/src/actions/charts/shared.js +121 -0
  17. package/src/actions/coordinates/parallel.js +50 -0
  18. package/src/actions/data/bin2d.js +137 -0
  19. package/src/actions/data/density.js +54 -13
  20. package/src/actions/data/derived.js +22 -0
  21. package/src/actions/data/filter.js +5 -0
  22. package/src/actions/data/gradientProfile.js +65 -0
  23. package/src/actions/data/horizon.js +71 -0
  24. package/src/actions/data/index.js +29 -2
  25. package/src/actions/data/window.js +50 -0
  26. package/src/actions/encodings/color/index.js +17 -1
  27. package/src/actions/encodings/color/layout.js +8 -0
  28. package/src/actions/encodings/density/resolve.js +109 -0
  29. package/src/actions/encodings/density.js +193 -88
  30. package/src/actions/encodings/horizon/resolve.js +283 -0
  31. package/src/actions/encodings/horizon.js +364 -0
  32. package/src/actions/encodings/index.js +6 -0
  33. package/src/actions/encodings/parallel.js +115 -0
  34. package/src/actions/encodings/pathOrder.js +131 -0
  35. package/src/actions/encodings/position/apply.js +73 -4
  36. package/src/actions/encodings/position/index.js +15 -2
  37. package/src/actions/encodings/position/policies/arc.js +27 -4
  38. package/src/actions/encodings/position/policies/area.js +33 -2
  39. package/src/actions/encodings/position/policies/index.js +7 -2
  40. package/src/actions/encodings/position/policies/line.js +18 -2
  41. package/src/actions/encodings/position/resolve.js +24 -2
  42. package/src/actions/encodings/ranged.js +3 -1
  43. package/src/actions/encodings/ruleAppearance.js +88 -10
  44. package/src/actions/facets/actions.js +1 -2
  45. package/src/actions/facets/derive.js +33 -3
  46. package/src/actions/facets/replay.js +7 -36
  47. package/src/actions/gradientPlots/components.js +177 -0
  48. package/src/actions/gradientPlots/create.js +96 -0
  49. package/src/actions/gradientPlots/edit.js +153 -0
  50. package/src/actions/gradientPlots/index.js +24 -0
  51. package/src/actions/gradientPlots/materialize.js +287 -0
  52. package/src/actions/gradientPlots/options.js +120 -0
  53. package/src/actions/gradientPlots/paint.js +107 -0
  54. package/src/actions/gradientPlots/rebind.js +51 -0
  55. package/src/actions/gradientPlots/resolve.js +81 -0
  56. package/src/actions/guides/applicability.js +44 -17
  57. package/src/actions/guides/axes/axes.js +37 -3
  58. package/src/actions/guides/axes/index.js +2 -0
  59. package/src/actions/guides/axes/labels.js +11 -6
  60. package/src/actions/guides/axes/parallel/resolve.js +86 -0
  61. package/src/actions/guides/axes/parallel.js +228 -0
  62. package/src/actions/guides/axes/titles.js +11 -8
  63. package/src/actions/guides/guides.js +60 -5
  64. package/src/actions/guides/legends/categorical/actions.js +48 -11
  65. package/src/actions/guides/legends/edit.js +5 -0
  66. package/src/actions/guides/legends/index.js +2 -0
  67. package/src/actions/guides/legends/remove.js +4 -11
  68. package/src/actions/guides/legends/strokeWidth.js +170 -0
  69. package/src/actions/index.js +6 -0
  70. package/src/actions/marks/area/actions.js +60 -136
  71. package/src/actions/marks/area/materialize.js +163 -0
  72. package/src/actions/marks/index.js +5 -1
  73. package/src/actions/marks/line/actions.js +99 -80
  74. package/src/actions/marks/line/materialize.js +146 -0
  75. package/src/actions/marks/point/create.js +76 -0
  76. package/src/actions/marks/point/edit.js +85 -0
  77. package/src/actions/marks/point/index.js +11 -1
  78. package/src/actions/marks/point/jitter.js +72 -0
  79. package/src/actions/marks/point/{actions.js → materialize.js} +175 -170
  80. package/src/actions/marks/rect/actions.js +3 -0
  81. package/src/actions/marks/remove.js +13 -2
  82. package/src/actions/marks/rule/actions.js +25 -3
  83. package/src/actions/marks/text/actions.js +19 -4
  84. package/src/actions/marks/text/index.js +7 -1
  85. package/src/actions/marks/text/layout.js +321 -0
  86. package/src/actions/primitives/semanticValidation/dataset.js +22 -0
  87. package/src/actions/primitives/semanticValidation/guide.js +43 -0
  88. package/src/actions/primitives/semanticValidation/index.js +23 -0
  89. package/src/actions/primitives/semanticValidation/layer.js +74 -0
  90. package/src/actions/primitives/semanticValidation/scale.js +74 -0
  91. package/src/actions/primitives/semanticValidation/shared.js +5 -0
  92. package/src/actions/primitives/semanticValue.js +1 -204
  93. package/src/actions/regression/edit.js +19 -21
  94. package/src/actions/scales/consumers/common.js +18 -0
  95. package/src/actions/scales/consumers/families.js +14 -3
  96. package/src/actions/scales/definitions.js +30 -0
  97. package/src/actions/scales/edit.js +6 -204
  98. package/src/actions/scales/editPolicy.js +214 -0
  99. package/src/actions/scales/materialize.js +1 -1
  100. package/src/actions/scales/patch.js +26 -0
  101. package/src/actions/selection/actions.js +28 -3
  102. package/src/actions/violinPlots/create.js +198 -0
  103. package/src/actions/violinPlots/index.js +5 -0
  104. package/src/core/textMetrics.js +47 -0
  105. package/src/core/vocabulary.js +7 -2
  106. package/src/grammar/arcs.js +49 -12
  107. package/src/grammar/areaSeries.js +41 -5
  108. package/src/grammar/bin2d.js +333 -0
  109. package/src/grammar/categoricalDensity.js +192 -0
  110. package/src/grammar/coordinates.js +1 -1
  111. package/src/grammar/curveCommands.js +3 -1
  112. package/src/grammar/density.js +252 -26
  113. package/src/grammar/facets/dependencies.js +3 -11
  114. package/src/grammar/facets/index.js +3 -1
  115. package/src/grammar/gradientProfile.js +213 -0
  116. package/src/grammar/horizon.js +455 -0
  117. package/src/grammar/jitter.js +197 -0
  118. package/src/grammar/lineSeries.js +69 -0
  119. package/src/grammar/paint.js +88 -0
  120. package/src/grammar/parallelCoordinates.js +213 -0
  121. package/src/grammar/pathOrder.js +35 -0
  122. package/src/grammar/pointShapes.js +24 -0
  123. package/src/grammar/rules.js +1 -0
  124. package/src/grammar/scales/appearance.js +21 -0
  125. package/src/grammar/schemas/concreteGraphic.js +11 -1
  126. package/src/grammar/schemas/graphicBounds.js +19 -37
  127. package/src/grammar/schemas/semanticPath.js +17 -2
  128. package/src/grammar/transforms.js +100 -11
  129. package/src/grammar/window.js +339 -0
  130. package/src/layout/labels.js +270 -0
  131. package/src/layout/text.js +4 -1
  132. package/src/layout/title.js +10 -20
  133. package/src/materialization/dataProvenance.js +56 -2
  134. package/src/materialization/dependencies.js +9 -14
  135. package/src/materialization/guides/resources.js +74 -24
  136. package/src/materialization/horizon.js +29 -0
  137. package/src/materialization/layout.js +15 -0
  138. package/src/materialization/marks/capabilities.js +21 -1
  139. package/src/materialization/marks/index.js +3 -0
  140. package/src/materialization/marks/pathOrder.js +71 -0
  141. package/src/materialization/marks/policies.js +6 -2
  142. package/src/materialization/scaleGuideDependencies.js +8 -0
  143. package/src/materialization/scales/resolve.js +2 -0
  144. package/src/materialization/selection/items/path.js +27 -1
  145. package/src/materialization/selection/items/point.js +1 -5
  146. package/src/materialization/selection/items/rect.js +21 -0
  147. package/src/materialization/selection/styles.js +20 -9
  148. package/src/materialization/text.js +12 -4
  149. package/src/renderers/canvas/fill.js +33 -0
  150. package/src/renderers/canvas/index.js +7 -0
  151. package/src/renderers/canvas/path.js +7 -4
  152. package/src/renderers/canvas/rect.js +9 -3
  153. package/src/selectors/datasets.js +4 -0
  154. package/src/theme/defaults.js +4 -0
  155. package/types/extension.d.ts +6 -0
  156. package/types/index.d.ts +76 -0
  157. package/types/program.d.ts +632 -10
@@ -10,6 +10,7 @@ import {
10
10
  validateAggregateFieldType,
11
11
  validateAggregateFieldValues,
12
12
  } from "./aggregate.js";
13
+ import { stableOrderPathValues } from "./pathOrder.js";
13
14
 
14
15
  const SERIES_CHANNELS = Object.freeze(["group", "color", "strokeDash"]);
15
16
 
@@ -101,6 +102,51 @@ function deriveCartesianLineSeries(rows, layer) {
101
102
  : readQuantitativeField(rows, y.field);
102
103
  const seriesFields = readSeriesFields(rows, layer);
103
104
 
105
+ const pathOrder = layer.encoding?.pathOrder;
106
+ if (pathOrder !== undefined) {
107
+ if (isAggregate) {
108
+ throw new Error(
109
+ `Line mark "${layer.id}" cannot combine aggregate y and path order.`
110
+ );
111
+ }
112
+ const orderValues = readQuantitativeField(rows, pathOrder.field);
113
+ const groups = new Map();
114
+ for (let index = 0; index < rows.length; index += 1) {
115
+ const dimensions = seriesFields.fields.map(
116
+ field => seriesFields.values.get(field)[index]
117
+ );
118
+ const key = groupKey(dimensions);
119
+ const series = groups.get(key) ?? {
120
+ key: Object.fromEntries(
121
+ seriesFields.fields.map((field, item) => [field, dimensions[item]])
122
+ ),
123
+ values: [],
124
+ orderValues: []
125
+ };
126
+ series.values.push({ x: xValues[index], y: yValues[index] });
127
+ series.orderValues.push(orderValues[index]);
128
+ groups.set(key, series);
129
+ }
130
+ const series = [...groups.values()].flatMap(item => {
131
+ const values = stableOrderPathValues(
132
+ item.values,
133
+ item.orderValues,
134
+ pathOrder.order
135
+ );
136
+ return values.length < 2 ? [] : [{ key: item.key, values }];
137
+ });
138
+ if (series.length === 0) {
139
+ throw new Error(
140
+ `Line series on mark "${layer.id}" requires at least two ordered points.`
141
+ );
142
+ }
143
+ return cloneAndFreeze({
144
+ xValues: series.flatMap(item => item.values.map(value => value.x)),
145
+ yValues: series.flatMap(item => item.values.map(value => value.y)),
146
+ series
147
+ });
148
+ }
149
+
104
150
  if (directTemporal) {
105
151
  const groups = new Map();
106
152
  for (let index = 0; index < rows.length; index += 1) {
@@ -249,6 +295,11 @@ function thetaOrder(values, fieldType, domain) {
249
295
  }
250
296
 
251
297
  export function derivePolarLineSeries(rows, layer, { thetaDomain } = {}) {
298
+ if (layer.encoding?.pathOrder !== undefined) {
299
+ throw new Error(
300
+ `Polar line mark "${layer.id}" does not support path order.`
301
+ );
302
+ }
252
303
  const { theta, radius } = requirePolarLineEncoding(layer);
253
304
  const thetaValues = readThetaValues(rows, theta);
254
305
  const radiusValues = readQuantitativeField(rows, radius.field);
@@ -303,3 +354,21 @@ export function deriveLineSeries(rows, layer, options) {
303
354
  ? derivePolarLineSeries(rows, layer, options)
304
355
  : deriveCartesianLineSeries(rows, layer);
305
356
  }
357
+
358
+ export function deriveLineSeriesFieldValues(rows, layer, derived, field) {
359
+ const values = readQuantitativeField(rows, field);
360
+ if (values.some(value => value < 0)) {
361
+ throw new RangeError(`Line strokeWidth field "${field}" cannot contain negative values.`);
362
+ }
363
+ return cloneAndFreeze(derived.series.map(series => {
364
+ const matching = values.filter((_, index) => Object.entries(series.key)
365
+ .every(([key, value]) => rows[index]?.[key] === value));
366
+ const unique = [...new Set(matching)];
367
+ if (unique.length !== 1) {
368
+ throw new Error(
369
+ `Line strokeWidth field "${field}" must have one value within each series.`
370
+ );
371
+ }
372
+ return unique[0];
373
+ }));
374
+ }
@@ -0,0 +1,88 @@
1
+ import { isPlainObject } from "../core/immutable.js";
2
+
3
+ const PAINT_KEYS = Object.freeze(["type", "from", "to", "stops"]);
4
+ const POINT_KEYS = Object.freeze(["x", "y"]);
5
+ const STOP_KEYS = Object.freeze(["offset", "color"]);
6
+
7
+ function hasExactKeys(value, keys) {
8
+ const actual = Object.keys(value);
9
+ return actual.length === keys.length && keys.every(key => Object.hasOwn(value, key));
10
+ }
11
+
12
+ function validateUnitPoint(value, label) {
13
+ if (!isPlainObject(value) || !hasExactKeys(value, POINT_KEYS)) {
14
+ throw new TypeError(`${label} must be a plain { x, y } object.`);
15
+ }
16
+ for (const key of POINT_KEYS) {
17
+ if (!Number.isFinite(value[key]) || value[key] < 0 || value[key] > 1) {
18
+ throw new RangeError(`${label}.${key} must be a finite number from 0 to 1.`);
19
+ }
20
+ }
21
+ }
22
+
23
+ function validateStop(stop, index) {
24
+ const label = `Linear gradient stops[${index}]`;
25
+ if (!isPlainObject(stop) || !hasExactKeys(stop, STOP_KEYS)) {
26
+ throw new TypeError(`${label} must be a plain { offset, color } object.`);
27
+ }
28
+ if (!Number.isFinite(stop.offset) || stop.offset < 0 || stop.offset > 1) {
29
+ throw new RangeError(`${label}.offset must be a finite number from 0 to 1.`);
30
+ }
31
+ if (typeof stop.color !== "string" || stop.color.length === 0) {
32
+ throw new TypeError(`${label}.color must be a non-empty string.`);
33
+ }
34
+ }
35
+
36
+ export function isLinearGradientPaint(value) {
37
+ return isPlainObject(value) && value.type === "linear-gradient";
38
+ }
39
+
40
+ export function validateFillPaint(value, label = "fill") {
41
+ if (typeof value === "string") {
42
+ if (value.length === 0) throw new TypeError(`${label} must be a non-empty string or linear gradient.`);
43
+ return value;
44
+ }
45
+ if (!isPlainObject(value) || !hasExactKeys(value, PAINT_KEYS)) {
46
+ throw new TypeError(`${label} must be a non-empty string or linear gradient.`);
47
+ }
48
+ if (value.type !== "linear-gradient") {
49
+ throw new Error(`Unsupported fill paint type "${value.type}".`);
50
+ }
51
+ validateUnitPoint(value.from, "Linear gradient from");
52
+ validateUnitPoint(value.to, "Linear gradient to");
53
+ if (value.from.x === value.to.x && value.from.y === value.to.y) {
54
+ throw new Error("Linear gradient from and to must differ.");
55
+ }
56
+ if (!Array.isArray(value.stops) || value.stops.length < 2) {
57
+ throw new TypeError("Linear gradient stops must contain at least two entries.");
58
+ }
59
+ value.stops.forEach(validateStop);
60
+ for (let index = 1; index < value.stops.length; index += 1) {
61
+ if (value.stops[index].offset < value.stops[index - 1].offset) {
62
+ throw new Error("Linear gradient stop offsets must be nondecreasing.");
63
+ }
64
+ }
65
+ return value;
66
+ }
67
+
68
+ export function resolveLinearGradientCoordinates(paint, bounds) {
69
+ validateFillPaint(paint, "Linear gradient paint");
70
+ if (!isLinearGradientPaint(paint)) {
71
+ throw new TypeError("Linear gradient coordinates require a structured paint.");
72
+ }
73
+ if (
74
+ !isPlainObject(bounds) ||
75
+ ![bounds.left, bounds.right, bounds.top, bounds.bottom].every(Number.isFinite) ||
76
+ bounds.left > bounds.right ||
77
+ bounds.top > bounds.bottom
78
+ ) {
79
+ throw new TypeError("Linear gradient bounds must be finite and ordered.");
80
+ }
81
+ const width = bounds.right - bounds.left;
82
+ const height = bounds.bottom - bounds.top;
83
+ const point = value => Object.freeze({
84
+ x: bounds.left + width * value.x,
85
+ y: bounds.top + height * value.y
86
+ });
87
+ return Object.freeze({ from: point(paint.from), to: point(paint.to) });
88
+ }
@@ -0,0 +1,213 @@
1
+ import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
+ import { validateUserId } from "../core/identifiers.js";
3
+ import { validateOptionObject } from "../core/validation.js";
4
+ import {
5
+ mapContinuousScaleValues,
6
+ mapOrdinalPositionValues,
7
+ validateSemanticFieldType
8
+ } from "./scales/index.js";
9
+
10
+ const DIMENSION_OPTIONS = Object.freeze([
11
+ "field", "fieldType", "title", "scale"
12
+ ]);
13
+ const FIELD_TYPES = new Set(["quantitative", "ordinal"]);
14
+ const MISSING_POLICIES = new Set(["break", "drop-row", "error"]);
15
+ const DIMENSION_SCALE_OPTIONS = Object.freeze([
16
+ "type", "domain", "range", "nice", "zero", "clamp", "reverse",
17
+ "base", "exponent", "constant", "paddingInner", "paddingOuter",
18
+ "padding", "align", "unknown"
19
+ ]);
20
+
21
+ function nonEmptyString(value, label) {
22
+ if (typeof value !== "string" || value.length === 0) {
23
+ throw new TypeError(`${label} must be a non-empty string.`);
24
+ }
25
+ return value;
26
+ }
27
+
28
+ export function validateParallelKeyField(value) {
29
+ return nonEmptyString(value, "Parallel key field");
30
+ }
31
+
32
+ function inferFieldType(rows, field) {
33
+ const values = rows
34
+ .map(row => row[field])
35
+ .filter(value => value !== undefined && value !== null);
36
+ if (values.length === 0) {
37
+ throw new Error(`Cannot infer Parallel field type for "${field}".`);
38
+ }
39
+ if (values.every(Number.isFinite)) return "quantitative";
40
+ if (values.every(value => typeof value === "string")) return "ordinal";
41
+ throw new Error(
42
+ `Parallel dimension "${field}" requires an explicit compatible fieldType.`
43
+ );
44
+ }
45
+
46
+ function validateScaleOptions(value, index) {
47
+ if (value === undefined) return {};
48
+ if (!isPlainObject(value)) {
49
+ throw new TypeError(`Parallel dimension ${index} scale must be a plain object.`);
50
+ }
51
+ validateOptionObject(
52
+ value,
53
+ DIMENSION_SCALE_OPTIONS,
54
+ `Parallel dimension ${index} scale`
55
+ );
56
+ return { ...value };
57
+ }
58
+
59
+ export function validateParallelMissingPolicy(value) {
60
+ if (!MISSING_POLICIES.has(value)) {
61
+ throw new Error(`Unsupported Parallel missing policy "${value}".`);
62
+ }
63
+ return value;
64
+ }
65
+
66
+ export function validateParallelDimensions(dimensions, { normalized = false } = {}) {
67
+ if (!Array.isArray(dimensions) || dimensions.length < 2) {
68
+ throw new RangeError("Parallel coordinates require at least two dimensions.");
69
+ }
70
+ const fields = new Set();
71
+ for (const [index, dimension] of dimensions.entries()) {
72
+ if (!isPlainObject(dimension)) {
73
+ throw new TypeError(`Parallel dimension ${index} must be an object.`);
74
+ }
75
+ const supported = normalized
76
+ ? ["field", "fieldType", "title", "scale"]
77
+ : DIMENSION_OPTIONS;
78
+ validateOptionObject(dimension, supported, `Parallel dimension ${index}`);
79
+ const field = nonEmptyString(
80
+ dimension.field,
81
+ `Parallel dimension ${index} field`
82
+ );
83
+ if (fields.has(field)) {
84
+ throw new Error("Parallel dimensions must use unique fields.");
85
+ }
86
+ fields.add(field);
87
+ validateSemanticFieldType(dimension.fieldType);
88
+ if (!FIELD_TYPES.has(dimension.fieldType)) {
89
+ throw new Error(`Unsupported Parallel field type "${dimension.fieldType}".`);
90
+ }
91
+ nonEmptyString(dimension.title, `Parallel dimension ${index} title`);
92
+ validateUserId(dimension.scale, `Parallel dimension ${index} scale id`);
93
+ }
94
+ return dimensions;
95
+ }
96
+
97
+ export function normalizeParallelDimensions(rows, dimensions, target) {
98
+ if (!Array.isArray(dimensions) || dimensions.length < 2) {
99
+ throw new RangeError("Parallel coordinates require at least two dimensions.");
100
+ }
101
+ const normalized = dimensions.map((input, index) => {
102
+ const dimension = typeof input === "string" ? { field: input } : input;
103
+ if (!isPlainObject(dimension)) {
104
+ throw new TypeError(
105
+ `Parallel dimension ${index} must be a field string or plain object.`
106
+ );
107
+ }
108
+ validateOptionObject(
109
+ dimension,
110
+ DIMENSION_OPTIONS,
111
+ `Parallel dimension ${index}`
112
+ );
113
+ const field = nonEmptyString(
114
+ dimension.field,
115
+ `Parallel dimension ${index} field`
116
+ );
117
+ const fieldType = dimension.fieldType ?? inferFieldType(rows, field);
118
+ if (!FIELD_TYPES.has(fieldType)) {
119
+ throw new Error(`Unsupported Parallel field type "${fieldType}".`);
120
+ }
121
+ const scale = validateScaleOptions(dimension.scale, index);
122
+ return {
123
+ field,
124
+ fieldType,
125
+ title: dimension.title ?? field,
126
+ scale: `${target}-parallel-${index}`,
127
+ scaleOptions: scale
128
+ };
129
+ });
130
+ const fields = normalized.map(dimension => dimension.field);
131
+ if (new Set(fields).size !== fields.length) {
132
+ throw new Error("Parallel dimensions must use unique fields.");
133
+ }
134
+ return cloneAndFreeze(normalized);
135
+ }
136
+
137
+ function validDimensionValue(value, fieldType) {
138
+ return fieldType === "quantitative"
139
+ ? Number.isFinite(value)
140
+ : typeof value === "string" || Number.isFinite(value);
141
+ }
142
+
143
+ function commandsFromVertices(vertices) {
144
+ const commands = [];
145
+ let fragment = [];
146
+ const flush = () => {
147
+ if (fragment.length >= 2) {
148
+ fragment.forEach((point, index) => commands.push({
149
+ op: index === 0 ? "M" : "L",
150
+ x: point.x,
151
+ y: point.y
152
+ }));
153
+ }
154
+ fragment = [];
155
+ };
156
+ for (const vertex of vertices) {
157
+ if (vertex === undefined) flush();
158
+ else fragment.push(vertex);
159
+ }
160
+ flush();
161
+ return commands;
162
+ }
163
+
164
+ export function validateParallelRows(rows, dimensions, { key, missing }) {
165
+ const seen = new Set();
166
+ rows.forEach((row, index) => {
167
+ if (key !== undefined) {
168
+ const value = row[key];
169
+ if (value === undefined || value === null || value === "") {
170
+ throw new Error(`Parallel key is missing at row ${index}.`);
171
+ }
172
+ const token = `${typeof value}:${String(value)}`;
173
+ if (seen.has(token)) throw new Error(`Duplicate Parallel key "${value}".`);
174
+ seen.add(token);
175
+ }
176
+ const incomplete = dimensions.some(dimension =>
177
+ !validDimensionValue(row[dimension.field], dimension.fieldType)
178
+ );
179
+ if (incomplete && missing === "error") {
180
+ throw new Error(`Parallel row ${index} has a missing dimension.`);
181
+ }
182
+ });
183
+ }
184
+
185
+ export function materializeParallelRows(rows, dimensions, resolvedScales, bounds, {
186
+ key,
187
+ missing
188
+ }) {
189
+ validateParallelRows(rows, dimensions, { key, missing });
190
+ const step = bounds.width / (dimensions.length - 1);
191
+ const items = [];
192
+ rows.forEach((row, sourceRowIndex) => {
193
+ const vertices = dimensions.map((dimension, index) => {
194
+ const value = row[dimension.field];
195
+ if (!validDimensionValue(value, dimension.fieldType)) return undefined;
196
+ const scale = resolvedScales[dimension.scale];
197
+ const mapped = dimension.fieldType === "quantitative"
198
+ ? mapContinuousScaleValues([value], scale)[0]
199
+ : mapOrdinalPositionValues([value], scale)[0];
200
+ return Number.isFinite(mapped)
201
+ ? { x: bounds.x + step * index, y: mapped, value, dimension: dimension.field }
202
+ : undefined;
203
+ });
204
+ const incomplete = vertices.some(vertex => vertex === undefined);
205
+ if (incomplete && missing === "drop-row") return;
206
+ items.push({
207
+ key: key === undefined ? `source:${sourceRowIndex}` : row[key],
208
+ sourceRowIndex,
209
+ commands: commandsFromVertices(vertices)
210
+ });
211
+ });
212
+ return cloneAndFreeze(items);
213
+ }
@@ -0,0 +1,35 @@
1
+ import { cloneAndFreeze } from "../core/immutable.js";
2
+
3
+ export const PATH_ORDER_DIRECTIONS = Object.freeze([
4
+ "ascending",
5
+ "descending"
6
+ ]);
7
+
8
+ export function validatePathOrderDirection(value) {
9
+ if (!PATH_ORDER_DIRECTIONS.includes(value)) {
10
+ throw new Error(`Unsupported path order "${value}".`);
11
+ }
12
+ return value;
13
+ }
14
+
15
+ export function stableOrderPathValues(values, orderValues, order = "ascending") {
16
+ validatePathOrderDirection(order);
17
+ if (!Array.isArray(values) || !Array.isArray(orderValues)) {
18
+ throw new TypeError("Path ordering requires value and order arrays.");
19
+ }
20
+ if (values.length !== orderValues.length) {
21
+ throw new Error("Path value and order arrays must have equal length.");
22
+ }
23
+ const direction = order === "ascending" ? 1 : -1;
24
+ const ordered = values.map((value, sourceIndex) => {
25
+ const orderValue = orderValues[sourceIndex];
26
+ if (!Number.isFinite(orderValue)) {
27
+ throw new TypeError("Path order values must be finite numbers.");
28
+ }
29
+ return { value, orderValue, sourceIndex };
30
+ }).sort((left, right) =>
31
+ direction * (left.orderValue - right.orderValue) ||
32
+ left.sourceIndex - right.sourceIndex
33
+ );
34
+ return cloneAndFreeze(ordered.map(item => item.value));
35
+ }
@@ -162,3 +162,27 @@ export function createPointShapeGraphic({
162
162
  }
163
163
  };
164
164
  }
165
+
166
+ export function resolvePointShapeExtent({ shape, area, strokeWidth = 0 }) {
167
+ const validated = validatePointShape(shape);
168
+ if (!Number.isFinite(area) || area < 0) {
169
+ throw new TypeError("Point shape area must be finite and non-negative.");
170
+ }
171
+ if (!Number.isFinite(strokeWidth) || strokeWidth < 0) {
172
+ throw new TypeError("Point shape strokeWidth must be finite and non-negative.");
173
+ }
174
+ const strokeExtent = strokeWidth / 2;
175
+ if (validated === "circle") {
176
+ const radius = Math.sqrt(area / Math.PI) + strokeExtent;
177
+ return cloneAndFreeze({ x: radius, y: radius });
178
+ }
179
+ if (validated === "square") {
180
+ const halfSide = Math.sqrt(area) / 2 + strokeExtent;
181
+ return cloneAndFreeze({ x: halfSide, y: halfSide });
182
+ }
183
+ const points = normalizePolygon(shapePolygon(validated), 0, 0, area);
184
+ return cloneAndFreeze({
185
+ x: Math.max(0, ...points.map(point => Math.abs(point.x))) + strokeExtent,
186
+ y: Math.max(0, ...points.map(point => Math.abs(point.y))) + strokeExtent
187
+ });
188
+ }
@@ -12,6 +12,7 @@ const ENDPOINT_CHANNELS = Object.freeze(["x", "y", "x2", "y2"]);
12
12
  const VALUE_CHANNELS = Object.freeze([
13
13
  ...ENDPOINT_CHANNELS,
14
14
  "strokeDash",
15
+ "strokeWidth",
15
16
  "opacity"
16
17
  ]);
17
18
 
@@ -20,6 +20,7 @@ export const NAMED_DASH_PATTERNS = cloneAndFreeze({
20
20
  export { POINT_SHAPES } from "../pointShapes.js";
21
21
  export const DEFAULT_SIZE_RANGE = cloneAndFreeze([24, 196]);
22
22
  export const DEFAULT_OPACITY_RANGE = cloneAndFreeze([0.2, 1]);
23
+ export const DEFAULT_STROKE_WIDTH_RANGE = cloneAndFreeze([1, 8]);
23
24
 
24
25
  export function validateColorRange(range) {
25
26
  if (range === "auto") return range;
@@ -123,6 +124,21 @@ export function validateOpacityRange(range) {
123
124
  return cloneAndFreeze(range);
124
125
  }
125
126
 
127
+ export function validateStrokeWidthRange(range) {
128
+ if (range === "auto") return range;
129
+ if (
130
+ !Array.isArray(range) ||
131
+ range.length !== 2 ||
132
+ !range.every(value => Number.isFinite(value) && value >= 0) ||
133
+ range[0] > range[1]
134
+ ) {
135
+ throw new TypeError(
136
+ "StrokeWidth range must be an ascending pair of non-negative finite widths."
137
+ );
138
+ }
139
+ return cloneAndFreeze(range);
140
+ }
141
+
126
142
  export function validateOpacityValue(value, label = "Opacity") {
127
143
  return validateUnitInterval(value, label);
128
144
  }
@@ -166,6 +182,11 @@ export function resolveOpacityRange(range) {
166
182
  return validated === "auto" ? DEFAULT_OPACITY_RANGE : validated;
167
183
  }
168
184
 
185
+ export function resolveStrokeWidthRange(range) {
186
+ const validated = validateStrokeWidthRange(range);
187
+ return validated === "auto" ? DEFAULT_STROKE_WIDTH_RANGE : validated;
188
+ }
189
+
169
190
  export function mapOrdinalValues(values, domain, range, options = {}) {
170
191
  const hasUnknown = Object.hasOwn(options, "unknown");
171
192
  const indices = new Map(domain.map((value, index) => [value, index]));
@@ -1,5 +1,6 @@
1
1
  import { isPlainObject } from "../../core/immutable.js";
2
2
  import { validatePathCommands } from "../pathCommands.js";
3
+ import { validateFillPaint } from "../paint.js";
3
4
  import { validateGraphicProperty } from "./graphic.js";
4
5
 
5
6
  const FINITE_PROPERTIES = new Set([
@@ -10,7 +11,7 @@ const NON_NEGATIVE_PROPERTIES = new Set([
10
11
  "width", "height", "radius", "strokeWidth", "fontSize", "gap"
11
12
  ]);
12
13
  const STRING_PROPERTIES = new Set([
13
- "background", "fill", "stroke", "fontFamily", "text", "textAlign",
14
+ "background", "stroke", "fontFamily", "text", "textAlign",
14
15
  "textBaseline"
15
16
  ]);
16
17
  const TEXT_ALIGNS = new Set(["left", "right", "center", "start", "end"]);
@@ -19,6 +20,15 @@ const TEXT_BASELINES = new Set([
19
20
  ]);
20
21
 
21
22
  export function validateConcreteGraphicValue(type, property, value) {
23
+ if (property === "fill") {
24
+ if (typeof value === "string") {
25
+ validateFillPaint(value, `${type}.fill`);
26
+ } else if (type === "rect" || type === "path") {
27
+ validateFillPaint(value, `${type}.fill`);
28
+ } else {
29
+ throw new TypeError(`${type}.fill must be a non-empty string.`);
30
+ }
31
+ }
22
32
  if (FINITE_PROPERTIES.has(property) && !Number.isFinite(value)) {
23
33
  throw new TypeError(`${type}.${property} must be a finite number.`);
24
34
  }
@@ -1,4 +1,4 @@
1
- import { measureTextWidth } from "../../core/textMetrics.js";
1
+ import { resolveTextBounds } from "../../core/textMetrics.js";
2
2
  import { findGraphic, requireGraphic } from "./graphicTree.js";
3
3
 
4
4
  function unionBounds(bounds) {
@@ -74,6 +74,17 @@ function pathPoints(commands) {
74
74
  return points;
75
75
  }
76
76
 
77
+ export function resolvePathCommandBounds(commands) {
78
+ const points = pathPoints(commands);
79
+ if (points.length === 0) return undefined;
80
+ return {
81
+ left: Math.min(...points.map(point => point.x)),
82
+ right: Math.max(...points.map(point => point.x)),
83
+ top: Math.min(...points.map(point => point.y)),
84
+ bottom: Math.max(...points.map(point => point.y))
85
+ };
86
+ }
87
+
77
88
  function primitiveBounds(type, properties = {}) {
78
89
  const strokeExtent = (properties.strokeWidth ?? 0) / 2;
79
90
  if (type === "circle") {
@@ -115,47 +126,18 @@ function primitiveBounds(type, properties = {}) {
115
126
  !Number.isFinite(properties.fontSize) ||
116
127
  typeof properties.text !== "string"
117
128
  ) return undefined;
118
- const width = measureTextWidth(properties.text, properties);
119
- const align = properties.textAlign ?? "left";
120
- const left = align === "center" ? -width / 2 :
121
- ["right", "end"].includes(align) ? -width : 0;
122
- const baseline = properties.textBaseline ?? "alphabetic";
123
- const [top, bottom] = baseline === "middle"
124
- ? [-properties.fontSize / 2, properties.fontSize / 2]
125
- : ["top", "hanging"].includes(baseline)
126
- ? [0, properties.fontSize]
127
- : ["bottom", "ideographic"].includes(baseline)
128
- ? [-properties.fontSize, 0]
129
- : [-properties.fontSize * 0.8, properties.fontSize * 0.2];
130
- const rotation = properties.rotation ?? 0;
131
- const cosine = Math.cos(rotation);
132
- const sine = Math.sin(rotation);
133
- const corners = [
134
- [left, top],
135
- [left + width, top],
136
- [left + width, bottom],
137
- [left, bottom]
138
- ].map(([x, y]) => ({
139
- x: properties.x + x * cosine - y * sine,
140
- y: properties.y + x * sine + y * cosine
141
- }));
142
- return {
143
- left: Math.min(...corners.map(point => point.x)),
144
- right: Math.max(...corners.map(point => point.x)),
145
- top: Math.min(...corners.map(point => point.y)),
146
- bottom: Math.max(...corners.map(point => point.y))
147
- };
129
+ return resolveTextBounds(properties);
148
130
  }
149
131
  if (type === "path") {
150
132
  const commands = properties.commands;
151
133
  if (!Array.isArray(commands)) return undefined;
152
- const points = pathPoints(commands);
153
- if (points.length === 0) return undefined;
134
+ const bounds = resolvePathCommandBounds(commands);
135
+ if (bounds === undefined) return undefined;
154
136
  return {
155
- left: Math.min(...points.map(point => point.x)) - strokeExtent,
156
- right: Math.max(...points.map(point => point.x)) + strokeExtent,
157
- top: Math.min(...points.map(point => point.y)) - strokeExtent,
158
- bottom: Math.max(...points.map(point => point.y)) + strokeExtent
137
+ left: bounds.left - strokeExtent,
138
+ right: bounds.right + strokeExtent,
139
+ top: bounds.top - strokeExtent,
140
+ bottom: bounds.bottom + strokeExtent
159
141
  };
160
142
  }
161
143
  return undefined;
@@ -6,7 +6,7 @@ import {
6
6
  } from "../../core/vocabulary.js";
7
7
 
8
8
  const ENCODING_PATHS = Object.freeze([
9
- ...ENCODING_CHANNELS.flatMap(channel => [
9
+ ...ENCODING_CHANNELS.filter(channel => channel !== "pathOrder").flatMap(channel => [
10
10
  `encoding.${channel}.field`,
11
11
  `encoding.${channel}.datum`,
12
12
  `encoding.${channel}.fieldType`,
@@ -20,8 +20,15 @@ const ENCODING_PATHS = Object.freeze([
20
20
  "encoding.x.bin.boundaries",
21
21
  "encoding.color.layout",
22
22
  "encoding.color.aggregate",
23
+ "encoding.pathOrder.field",
24
+ "encoding.pathOrder.fieldType",
25
+ "encoding.pathOrder.order",
23
26
  "encoding.theta.aggregate",
27
+ "encoding.theta.weight",
24
28
  "encoding.text.format",
29
+ "encoding.parallel.dimensions",
30
+ "encoding.parallel.key",
31
+ "encoding.parallel.missing",
25
32
  ...CARTESIAN_POSITION_CHANNELS.flatMap(channel => [
26
33
  `encoding.${channel}.aggregate`,
27
34
  `encoding.${channel}.stack`
@@ -36,7 +43,8 @@ const ENTITY_PATHS = Object.freeze({
36
43
  layer: {
37
44
  collection: "layers",
38
45
  removableContainers: new Set(
39
- ENCODING_CHANNELS.map(channel => `encoding.${channel}`)
46
+ [...ENCODING_CHANNELS.map(channel => `encoding.${channel}`),
47
+ "encoding.parallel"]
40
48
  ),
41
49
  properties: new Set([
42
50
  "data",
@@ -74,10 +82,15 @@ const GUIDE_PATHS = new Set([
74
82
  "axis.radius.scale",
75
83
  "axis.radius.coordinate",
76
84
  "axis.radius.title",
85
+ "axis.parallel.target",
86
+ "axis.parallel.coordinate",
87
+ "axis.parallel.scales",
77
88
  "legend.color.scale",
78
89
  "legend.color.title",
79
90
  "legend.size.scale",
80
91
  "legend.size.title",
92
+ "legend.strokeWidth.scale",
93
+ "legend.strokeWidth.title",
81
94
  "legend.opacity.scale",
82
95
  "legend.opacity.title",
83
96
  "legend.series.channels",
@@ -98,12 +111,14 @@ const GUIDE_REMOVABLE_CONTAINERS = new Set([
98
111
  "axis.y",
99
112
  "axis.theta",
100
113
  "axis.radius",
114
+ "axis.parallel",
101
115
  "grid.horizontal",
102
116
  "grid.vertical",
103
117
  "grid.theta",
104
118
  "grid.radial",
105
119
  "legend.color",
106
120
  "legend.size",
121
+ "legend.strokeWidth",
107
122
  "legend.opacity",
108
123
  "legend.series"
109
124
  ]);