ggaction 0.0.15 → 0.0.16

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 (236) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +22 -2
  3. package/knowledge/action-cards.json +1 -1
  4. package/knowledge/intent-taxonomy.json +1 -1
  5. package/knowledge/mcp-resources.json +1 -1
  6. package/knowledge/task-packet.schema.json +1 -1
  7. package/knowledge/task-resolver.js +1 -1
  8. package/package.json +33 -6
  9. package/src/BasicChartProgram.js +3 -1
  10. package/src/ChartProgram.js +2 -0
  11. package/src/accessibility.js +157 -0
  12. package/src/actions/boxPlots/components.js +6 -8
  13. package/src/actions/boxPlots/create.js +4 -5
  14. package/src/actions/boxPlots/edit.js +3 -4
  15. package/src/actions/boxPlots/materialize.js +1 -1
  16. package/src/actions/canvas/actions.js +3 -3
  17. package/src/actions/canvas/fitting.js +1 -1
  18. package/src/actions/categoryOrder/index.js +6 -8
  19. package/src/actions/charts/area.js +1 -1
  20. package/src/actions/charts/bar.js +1 -1
  21. package/src/actions/charts/beeswarm.js +1 -1
  22. package/src/actions/charts/density.js +1 -1
  23. package/src/actions/charts/ecdf.js +2 -2
  24. package/src/actions/charts/endpoints.js +4 -4
  25. package/src/actions/charts/heatmap.js +1 -1
  26. package/src/actions/charts/histogram.js +1 -1
  27. package/src/actions/charts/horizon.js +1 -1
  28. package/src/actions/charts/interval-regression.js +2 -2
  29. package/src/actions/charts/line.js +1 -1
  30. package/src/actions/charts/parallel.js +1 -1
  31. package/src/actions/charts/pie.js +1 -1
  32. package/src/actions/charts/polar.js +2 -2
  33. package/src/actions/charts/radar.js +1 -1
  34. package/src/actions/charts/radial.js +2 -2
  35. package/src/actions/charts/raincloud.js +2 -2
  36. package/src/actions/charts/rug-strip.js +2 -2
  37. package/src/actions/charts/scatter.js +1 -1
  38. package/src/actions/composition/actions.js +20 -24
  39. package/src/actions/coordinates/actions.js +1 -1
  40. package/src/actions/coordinates/edit.js +1 -1
  41. package/src/actions/data/basicBin2d.js +4 -5
  42. package/src/actions/data/bin.js +2 -2
  43. package/src/actions/data/bin2d.js +6 -8
  44. package/src/actions/data/bin2dMaterialize.js +4 -5
  45. package/src/actions/data/box.js +4 -4
  46. package/src/actions/data/complete.js +2 -2
  47. package/src/actions/data/computed.js +2 -2
  48. package/src/actions/data/create.js +5 -12
  49. package/src/actions/data/density.js +3 -3
  50. package/src/actions/data/derived.js +10 -14
  51. package/src/actions/data/ecdf.js +2 -2
  52. package/src/actions/data/edit.js +35 -23
  53. package/src/actions/data/filter.js +14 -20
  54. package/src/actions/data/fold.js +2 -2
  55. package/src/actions/data/gradientProfile.js +2 -2
  56. package/src/actions/data/horizon.js +2 -2
  57. package/src/actions/data/impute.js +2 -2
  58. package/src/actions/data/index.js +55 -54
  59. package/src/actions/data/interval.js +2 -2
  60. package/src/actions/data/intervalEdit.js +26 -18
  61. package/src/actions/data/normalize.js +2 -2
  62. package/src/actions/data/regression.js +5 -6
  63. package/src/actions/data/revise.js +48 -0
  64. package/src/actions/data/revisionBindings.js +13 -33
  65. package/src/actions/data/stack.js +2 -2
  66. package/src/actions/data/summary.js +2 -2
  67. package/src/actions/data/timeUnit.js +2 -2
  68. package/src/actions/data/window.js +2 -2
  69. package/src/actions/encodings/angle.js +1 -1
  70. package/src/actions/encodings/appearance.js +4 -4
  71. package/src/actions/encodings/barWidth.js +1 -1
  72. package/src/actions/encodings/channels.js +2 -2
  73. package/src/actions/encodings/color/index.js +1 -1
  74. package/src/actions/encodings/density.js +2 -2
  75. package/src/actions/encodings/histogram.js +1 -1
  76. package/src/actions/encodings/horizon.js +6 -8
  77. package/src/actions/encodings/offset.js +2 -2
  78. package/src/actions/encodings/parallel.js +4 -5
  79. package/src/actions/encodings/pathOrder.js +6 -11
  80. package/src/actions/encodings/position/index.js +4 -4
  81. package/src/actions/encodings/ranged.js +5 -5
  82. package/src/actions/encodings/remove.js +4 -5
  83. package/src/actions/encodings/ruleAppearance.js +1 -1
  84. package/src/actions/encodings/seriesLayout.js +1 -1
  85. package/src/actions/encodings/stroke.js +1 -1
  86. package/src/actions/encodings/strokeDash.js +2 -2
  87. package/src/actions/encodings/text.js +1 -1
  88. package/src/actions/errorBands/create.js +11 -17
  89. package/src/actions/errorBands/edit.js +13 -131
  90. package/src/actions/errorBars/create.js +8 -13
  91. package/src/actions/errorBars/edit.js +10 -121
  92. package/src/actions/facets/actions.js +30 -16
  93. package/src/actions/facets/derive.js +8 -2
  94. package/src/actions/facets/guides.js +4 -5
  95. package/src/actions/facets/replay.js +4 -5
  96. package/src/actions/gradientPlots/components.js +5 -6
  97. package/src/actions/gradientPlots/create.js +4 -5
  98. package/src/actions/gradientPlots/edit.js +4 -5
  99. package/src/actions/gradientPlots/materialize.js +2 -2
  100. package/src/actions/gradientPlots/rebind.js +4 -5
  101. package/src/actions/guides/axes/axes.js +23 -4
  102. package/src/actions/guides/axes/axis.js +2 -2
  103. package/src/actions/guides/axes/edit.js +2 -2
  104. package/src/actions/guides/axes/labels.js +9 -9
  105. package/src/actions/guides/axes/lines.js +9 -7
  106. package/src/actions/guides/axes/parallel/lifecycle.js +13 -13
  107. package/src/actions/guides/axes/parallel.js +5 -5
  108. package/src/actions/guides/axes/remove.js +4 -4
  109. package/src/actions/guides/axes/tickGroups.js +4 -4
  110. package/src/actions/guides/axes/ticks.js +13 -4
  111. package/src/actions/guides/axes/titles.js +10 -10
  112. package/src/actions/guides/grids/grid.js +18 -29
  113. package/src/actions/guides/guides.js +1 -1
  114. package/src/actions/guides/legends/blocks.js +1 -1
  115. package/src/actions/guides/legends/categorical/actions.js +4 -4
  116. package/src/actions/guides/legends/categorical/components.js +38 -33
  117. package/src/actions/guides/legends/categorical/layout.js +1 -1
  118. package/src/actions/guides/legends/categorical/symbols.js +27 -30
  119. package/src/actions/guides/legends/continuous/common.js +46 -8
  120. package/src/actions/guides/legends/continuous/gradient.js +89 -155
  121. package/src/actions/guides/legends/continuous/interval.js +13 -13
  122. package/src/actions/guides/legends/continuous/opacity.js +30 -79
  123. package/src/actions/guides/legends/continuous/stroke.js +22 -322
  124. package/src/actions/guides/legends/edit.js +1 -1
  125. package/src/actions/guides/legends/focused.js +5 -5
  126. package/src/actions/guides/legends/lane.js +6 -6
  127. package/src/actions/guides/legends/remove.js +4 -5
  128. package/src/actions/guides/legends/size.js +21 -82
  129. package/src/actions/guides/legends/strokeWidth.js +17 -77
  130. package/src/actions/guides/polar/axes/facade.js +4 -4
  131. package/src/actions/guides/polar/axes/labels.js +5 -5
  132. package/src/actions/guides/polar/axes/lines.js +9 -15
  133. package/src/actions/guides/polar/axes/ticks.js +9 -15
  134. package/src/actions/guides/polar/axes/titles.js +5 -5
  135. package/src/actions/guides/polar/grids.js +11 -13
  136. package/src/actions/index.js +2 -0
  137. package/src/actions/intervals/revision.js +126 -0
  138. package/src/actions/marks/arc/actions.js +3 -3
  139. package/src/actions/marks/area/actions.js +4 -4
  140. package/src/actions/marks/bar/create.js +1 -1
  141. package/src/actions/marks/bar/edit.js +1 -1
  142. package/src/actions/marks/bar/materialize.js +12 -38
  143. package/src/actions/marks/line/actions.js +3 -3
  144. package/src/actions/marks/point/create.js +1 -1
  145. package/src/actions/marks/point/edit.js +1 -1
  146. package/src/actions/marks/point/jitter.js +6 -8
  147. package/src/actions/marks/point/materialize.js +16 -24
  148. package/src/actions/marks/point/packing.js +6 -8
  149. package/src/actions/marks/rect/actions.js +3 -3
  150. package/src/actions/marks/references.js +8 -10
  151. package/src/actions/marks/remove.js +5 -49
  152. package/src/actions/marks/rule/actions.js +4 -4
  153. package/src/actions/marks/text/actions.js +5 -5
  154. package/src/actions/marks/text/layout.js +12 -20
  155. package/src/actions/marks/text/removal.js +1 -1
  156. package/src/actions/marks/text/selection.js +4 -5
  157. package/src/actions/marks/tick/actions.js +3 -3
  158. package/src/actions/primitives/createGraphics.js +5 -4
  159. package/src/actions/primitives/editGraphics.js +5 -4
  160. package/src/actions/primitives/graphicProperties.js +6 -0
  161. package/src/actions/primitives/semantic.js +10 -3
  162. package/src/actions/primitives/semanticAction.js +7 -4
  163. package/src/actions/primitives/semanticValidation/dataset.js +2 -4
  164. package/src/actions/regression/components.js +10 -14
  165. package/src/actions/regression/create.js +3 -4
  166. package/src/actions/regression/edit.js +3 -4
  167. package/src/actions/resources/remove.js +12 -11
  168. package/src/actions/scales/create.js +1 -1
  169. package/src/actions/scales/edit.js +1 -1
  170. package/src/actions/scales/materialize.js +1 -1
  171. package/src/actions/scales/offset.js +2 -2
  172. package/src/actions/scales/parallel.js +1 -1
  173. package/src/actions/scales/quantitativeColor.js +4 -5
  174. package/src/actions/selection/actions.js +25 -36
  175. package/src/actions/textMetrics/index.js +56 -0
  176. package/src/actions/theme/actions.js +2 -2
  177. package/src/actions/theme/reconcile.js +65 -121
  178. package/src/actions/titles/actions.js +8 -8
  179. package/src/actions/titles/resolve.js +3 -3
  180. package/src/actions/violinPlots/create.js +1 -1
  181. package/src/actions/violinPlots/edit.js +1 -1
  182. package/src/core/action.js +107 -35
  183. package/src/core/diagnostics.js +16 -0
  184. package/src/core/extensionRegistry.js +27 -0
  185. package/src/core/font.js +18 -0
  186. package/src/core/identifiers.js +5 -4
  187. package/src/core/immutable.js +10 -3
  188. package/src/core/optionRequirements.js +8 -0
  189. package/src/core/programState.js +1 -1
  190. package/src/core/resourceReferences.js +15 -6
  191. package/src/core/textMetricProfile.js +28 -0
  192. package/src/core/textMetrics.js +24 -3
  193. package/src/core/validation.js +22 -10
  194. package/src/diagnostics.js +1 -0
  195. package/src/grammar/impute.js +3 -2
  196. package/src/grammar/scales/types.js +8 -0
  197. package/src/grammar/schemas/graphicBounds.js +11 -11
  198. package/src/layout/labels.js +11 -10
  199. package/src/layout/legend.js +4 -4
  200. package/src/layout/legendItems.js +6 -6
  201. package/src/layout/text.js +10 -9
  202. package/src/layout/title.js +9 -9
  203. package/src/materialization/facetGuides/placement.js +1 -1
  204. package/src/materialization/facetGuides/preparation.js +1 -1
  205. package/src/materialization/facetHeaders.js +11 -10
  206. package/src/materialization/guides/layout.js +3 -3
  207. package/src/materialization/scaleGuideDependencies.js +10 -0
  208. package/src/materialization/selection/items/common.js +1 -1
  209. package/src/materialization/text.js +2 -2
  210. package/src/materialization/typography.js +31 -0
  211. package/src/mcp/cli.js +5 -3
  212. package/src/persistence/codec.js +65 -0
  213. package/src/persistence/validation.js +185 -0
  214. package/src/persistence.js +74 -0
  215. package/src/renderers/canvas/index.js +5 -4
  216. package/src/renderers/nativeBackend.js +10 -0
  217. package/src/renderers/options.js +13 -0
  218. package/src/renderers/pdf.js +25 -54
  219. package/src/renderers/png.js +22 -17
  220. package/src/renderers/svg.js +10 -14
  221. package/src/renderers/text.js +1 -11
  222. package/src/selectors/coordinates.js +3 -1
  223. package/src/selectors/datasets.js +8 -6
  224. package/src/selectors/layers.js +6 -4
  225. package/src/selectors/markOwners.js +46 -0
  226. package/src/selectors/scales.js +6 -4
  227. package/src/theme/defaults.js +2 -1
  228. package/src/version.js +2 -0
  229. package/types/accessibility.d.ts +37 -0
  230. package/types/basic.d.ts +4 -2
  231. package/types/diagnostics.d.ts +15 -0
  232. package/types/index.d.ts +5 -0
  233. package/types/pdf.d.ts +9 -0
  234. package/types/persistence.d.ts +7 -0
  235. package/types/png.d.ts +9 -0
  236. package/types/program.d.ts +34 -1
@@ -4,6 +4,32 @@ import { isPlainObject } from "./immutable.js";
4
4
  const ACTION_NAME_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
5
5
  const DEFINITION_KEYS = new Set(["name", "actions"]);
6
6
  const registeredExtensionNames = new Set();
7
+ const extensionByAction = new Map();
8
+ const builtinOpsByClass = new WeakMap();
9
+
10
+ export function recordBuiltinActions(ProgramClass, additional = []) {
11
+ builtinOpsByClass.set(ProgramClass, new Set([
12
+ ...Object.getOwnPropertyNames(ProgramClass.prototype).flatMap(name => {
13
+ const metadata = getWrappedActionMetadata(ProgramClass.prototype[name]);
14
+ return metadata ? [metadata.op] : [];
15
+ }), ...additional
16
+ ]));
17
+ }
18
+
19
+ export function isBuiltinProgramClass(ProgramClass) {
20
+ return builtinOpsByClass.has(ProgramClass);
21
+ }
22
+
23
+ export function requiredTraceExtension(ProgramClass, op) {
24
+ if (op === "program" || builtinOpsByClass.get(ProgramClass)?.has(op)) return undefined;
25
+ const extension = extensionByAction.get(op);
26
+ if (extension !== undefined) return extension;
27
+ throw new TypeError(`Cannot persist unregistered action "${op}".`);
28
+ }
29
+
30
+ export function hasRegisteredExtension(name) {
31
+ return registeredExtensionNames.has(name);
32
+ }
7
33
 
8
34
  function readDataProperty(object, key, owner) {
9
35
  const descriptor = Object.getOwnPropertyDescriptor(object, key);
@@ -101,4 +127,5 @@ export function registerProgramExtension(ProgramClass, definition) {
101
127
 
102
128
  Object.defineProperties(ProgramClass.prototype, descriptors);
103
129
  registeredExtensionNames.add(name);
130
+ for (const actionName of actionNames) extensionByAction.set(actionName, name);
104
131
  }
@@ -0,0 +1,18 @@
1
+ export const DEFAULT_FONT_FAMILY = "sans-serif";
2
+
3
+ export function normalizeRendererFontWeight(fontWeight) {
4
+ if (typeof fontWeight !== "number") return fontWeight;
5
+ return Math.min(900, Math.max(100, Math.round(fontWeight / 100) * 100));
6
+ }
7
+
8
+ export function textMetricKey({ text, fontFamily, fontSize, fontWeight }) {
9
+ return JSON.stringify([text, fontFamily, fontSize, fontWeight]);
10
+ }
11
+
12
+ export function textMetricFontWeight(value = "normal") {
13
+ if (typeof value === "number") return normalizeRendererFontWeight(value);
14
+ if (value === "normal") return 400;
15
+ if (value === "bold") return 700;
16
+ if (/^[1-9]00$/u.test(value)) return Number(value);
17
+ return undefined;
18
+ }
@@ -1,12 +1,13 @@
1
+ import { annotateError } from "./diagnostics.js";
1
2
  export const USER_ID_SOURCE = "[A-Za-z0-9_-]+";
2
3
 
3
4
  const USER_ID_PATTERN = new RegExp(`^${USER_ID_SOURCE}$`);
4
5
 
5
6
  export function validateUserId(id, label = "ID") {
6
7
  if (typeof id !== "string" || !USER_ID_PATTERN.test(id)) {
7
- throw new TypeError(
8
+ throw annotateError(new TypeError(
8
9
  `${label} must contain only letters, numbers, _ or - and must not be empty.`
9
- );
10
+ ), { code: "invalid-value", optionPath: label });
10
11
  }
11
12
 
12
13
  return id;
@@ -20,9 +21,9 @@ export function resolveOptionalUserId(id, {
20
21
  }) {
21
22
  if (id !== undefined) return validateUserId(id, label);
22
23
  if (ambiguous) {
23
- throw new Error(
24
+ throw annotateError(new Error(
24
25
  `${operation} requires an explicit ${label.toLowerCase()} because its default is ambiguous.`
25
- );
26
+ ), { code: "ambiguous-resource", operation, optionPath: label });
26
27
  }
27
28
  return validateUserId(defaultId, label);
28
29
  }
@@ -22,11 +22,18 @@ export function isOwned(value) {
22
22
  return value !== null && typeof value === "object" && ownedValues.has(value);
23
23
  }
24
24
 
25
- export function cloneAndFreeze(value, ancestors = new WeakSet()) {
25
+ export function cloneAndFreeze(value, ancestors = new WeakSet(), path = "state") {
26
+ if (typeof value === "function") {
27
+ throw new TypeError(`Cannot store a function at ${path} in a ChartProgram.`);
28
+ }
26
29
  if (value === null || typeof value !== "object") {
27
30
  return value;
28
31
  }
29
32
 
33
+ // Only library-owned values are recursively immutable; Object.freeze alone
34
+ // does not prove ownership of a caller's descendants.
35
+ if (isOwned(value)) return value;
36
+
30
37
  if (ancestors.has(value)) {
31
38
  throw new TypeError("Cannot store cyclic values in a ChartProgram.");
32
39
  }
@@ -36,12 +43,12 @@ export function cloneAndFreeze(value, ancestors = new WeakSet()) {
36
43
  let clone;
37
44
 
38
45
  if (Array.isArray(value)) {
39
- clone = value.map(item => cloneAndFreeze(item, ancestors));
46
+ clone = value.map((item, index) => cloneAndFreeze(item, ancestors, `${path}[${index}]`));
40
47
  } else if (isPlainObject(value)) {
41
48
  clone = Object.fromEntries(
42
49
  Object.entries(value).map(([key, item]) => [
43
50
  key,
44
- cloneAndFreeze(item, ancestors)
51
+ cloneAndFreeze(item, ancestors, `${path}.${key}`)
45
52
  ])
46
53
  );
47
54
  } else {
@@ -0,0 +1,8 @@
1
+ // The method's value-producing contract is shared by runtime validation and
2
+ // authoring knowledge; editing can inherit these values from stored state.
3
+ export const IMPUTE_REQUIRED_OPTIONS = Object.freeze({
4
+ constant: Object.freeze(["value"]),
5
+ forward: Object.freeze(["sortBy"]),
6
+ backward: Object.freeze(["sortBy"]),
7
+ linear: Object.freeze(["sortBy"])
8
+ });
@@ -23,7 +23,7 @@ export function ownChildPrograms(children, ProgramClass) {
23
23
  if (!(program instanceof ProgramClass)) {
24
24
  throw new TypeError(`ChartProgram child "${id}" must be a ChartProgram.`);
25
25
  }
26
- owned[id] = program;
26
+ Object.defineProperty(owned, id, { value: program, enumerable: true });
27
27
  }
28
28
  return freezeOwned(owned);
29
29
  }
@@ -148,14 +148,19 @@ export function collectMarkConfigReferences(program) {
148
148
  "coordinate", value, "markConfig", ownerId, path
149
149
  ));
150
150
  }
151
- if ((key === "scale" || key.endsWith("Scale")) && typeof value === "string") {
151
+ const reservedScale = root === "regression" && key === "colorScale" && config.regression.groupBy === undefined;
152
+ if (!reservedScale && (key === "scale" || key.endsWith("Scale")) && typeof value === "string") {
152
153
  append(references, reference("scale", value, "markConfig", ownerId, path));
153
154
  } else if (key === "scale" && isPlainObject(value)) {
154
155
  append(references, reference(
155
156
  "scale", value.id, "markConfig", ownerId, [...path, "id"]
156
157
  ));
157
158
  }
158
- if (MARK_ID_KEYS.has(key)) {
159
+ const inactiveMark = (root === "boxPlot" && key === "outlierId" && !Object.hasOwn(program.markConfigs, value)) ||
160
+ (root === "errorBar" && ["lowerCapId", "upperCapId"].includes(key) && config.errorBar.caps === false) ||
161
+ (root === "errorBand" && ["lowerBoundaryId", "upperBoundaryId"].includes(key) &&
162
+ !Object.hasOwn(program.markConfigs, value));
163
+ if (MARK_ID_KEYS.has(key) && !inactiveMark) {
159
164
  append(references, reference("mark", value, "markConfig", ownerId, path));
160
165
  }
161
166
  if (
@@ -316,7 +321,13 @@ export function collectResourceReferences(program, { kind, id }) {
316
321
  throw new Error(`Unknown resource kind "${kind}".`);
317
322
  }
318
323
  const target = validateUserId(id, "Resource id");
319
- const combined = [
324
+ const combined = collectAllResourceReferences(program).filter(value => value.kind === kind && value.id === target);
325
+ const unique = new Map(combined.map(value => [referenceKey(value), value]));
326
+ return freezeOwned([...unique.values()].sort(compareReferences));
327
+ }
328
+
329
+ export function collectAllResourceReferences(program) {
330
+ return [
320
331
  ...collectSemanticDataReferences(program),
321
332
  ...collectSemanticScaleReferences(program),
322
333
  ...collectSemanticCoordinateReferences(program),
@@ -326,7 +337,5 @@ export function collectResourceReferences(program, { kind, id }) {
326
337
  ...collectDataOwnerReferences(program),
327
338
  ...collectCompositionReferences(program),
328
339
  ...collectContextReferences(program)
329
- ].filter(value => value.kind === kind && value.id === target);
330
- const unique = new Map(combined.map(value => [referenceKey(value), value]));
331
- return freezeOwned([...unique.values()].sort(compareReferences));
340
+ ];
332
341
  }
@@ -0,0 +1,28 @@
1
+ import { cloneAndFreeze } from "./immutable.js";
2
+ import { validateUserId } from "./identifiers.js";
3
+ import { validateOptionObject, validatePositiveFinite, validateNonNegativeFinite, validateNonEmptyString } from "./validation.js";
4
+
5
+ import { textMetricKey } from "./font.js";
6
+
7
+ export function normalizeTextMetricProfile(profile) {
8
+ validateOptionObject(profile, ["schemaVersion", "id", "measurements"], "Text metrics profile");
9
+ if (profile.schemaVersion !== 1) throw new Error("Text metrics require schemaVersion 1.");
10
+ validateUserId(profile.id, "Text metrics profile id");
11
+ if (!Array.isArray(profile.measurements)) throw new TypeError("Text metrics measurements must be an array.");
12
+ const seen = new Set();
13
+ for (const measurement of profile.measurements) {
14
+ validateOptionObject(measurement, ["text", "fontFamily", "fontSize", "fontWeight", "width"], "Text measurement");
15
+ if (typeof measurement.text !== "string") throw new TypeError("Text measurement text must be a string.");
16
+ validateNonEmptyString(measurement.fontFamily, "Text measurement fontFamily");
17
+ validatePositiveFinite(measurement.fontSize, "Text measurement fontSize");
18
+ validateNonNegativeFinite(measurement.width, "Text measurement width");
19
+ if (!Number.isInteger(measurement.fontWeight) || measurement.fontWeight < 100 ||
20
+ measurement.fontWeight > 900 || measurement.fontWeight % 100 !== 0) {
21
+ throw new RangeError("Text measurement fontWeight must be 100–900 in steps of 100.");
22
+ }
23
+ const key = textMetricKey(measurement);
24
+ if (seen.has(key)) throw new Error("Duplicate text measurement combination.");
25
+ seen.add(key);
26
+ }
27
+ return cloneAndFreeze(profile);
28
+ }
@@ -1,3 +1,21 @@
1
+ import { DEFAULT_FONT_FAMILY, textMetricFontWeight, textMetricKey } from "./font.js";
2
+
3
+ import { isOwned } from "./immutable.js";
4
+
5
+ const measurementIndexes = new WeakMap();
6
+
7
+ function measuredWidth(profile, text, style) {
8
+ if (profile === undefined) return undefined;
9
+ let index = measurementIndexes.get(profile);
10
+ if (index === undefined) {
11
+ index = new Map(profile.measurements.map(measurement =>
12
+ [textMetricKey(measurement), measurement.width]));
13
+ if (isOwned(profile)) measurementIndexes.set(profile, index);
14
+ }
15
+ return index.get(textMetricKey({ text, fontFamily: style.fontFamily ?? DEFAULT_FONT_FAMILY,
16
+ fontSize: style.fontSize, fontWeight: textMetricFontWeight(style.fontWeight) }));
17
+ }
18
+
1
19
  function codePointWidth(codePoint) {
2
20
  if (/\s/u.test(codePoint)) return 0.28;
3
21
  if (/[iIl.,:;!'|]/u.test(codePoint)) return 0.27;
@@ -25,7 +43,8 @@ export function textBoundsFitCanvas(bounds, canvas) {
25
43
 
26
44
  export function measureTextWidth(
27
45
  text,
28
- { fontSize, fontFamily, fontWeight } = {}
46
+ { fontSize, fontFamily, fontWeight } = {},
47
+ profile
29
48
  ) {
30
49
  if (typeof text !== "string") {
31
50
  throw new TypeError("Text measurement requires a string.");
@@ -33,6 +52,8 @@ export function measureTextWidth(
33
52
  if (!Number.isFinite(fontSize) || fontSize <= 0) {
34
53
  throw new RangeError("Text measurement requires a positive fontSize.");
35
54
  }
55
+ const measured = measuredWidth(profile, text, { fontSize, fontFamily, fontWeight });
56
+ if (measured !== undefined) return measured;
36
57
  let joined = false;
37
58
  let width = 0;
38
59
  for (const codePoint of text) {
@@ -76,11 +97,11 @@ export function resolveTextBounds({
76
97
  textAlign = "left",
77
98
  textBaseline = "alphabetic",
78
99
  rotation = 0
79
- } = {}) {
100
+ } = {}, profile) {
80
101
  if (![x, y, rotation].every(Number.isFinite)) {
81
102
  throw new TypeError("Text bounds require finite x, y, and rotation values.");
82
103
  }
83
- const width = measureTextWidth(text, { fontSize, fontFamily, fontWeight });
104
+ const width = measureTextWidth(text, { fontSize, fontFamily, fontWeight }, profile);
84
105
  const [left, right] = textAlign === "center"
85
106
  ? [-width / 2, width / 2]
86
107
  : ["right", "end"].includes(textAlign) ? [-width, 0] : [0, width];
@@ -1,15 +1,27 @@
1
+ import { annotateError } from "./diagnostics.js";
1
2
  import { isPlainObject } from "./immutable.js";
2
3
 
3
4
  export const MAX_GENERATED_ITEMS = 10_000;
4
5
  export const MAX_WORK_ITEMS = 10_000_000;
5
6
 
7
+ export function validateDataRows(values, label = "Dataset") {
8
+ if (!Array.isArray(values)) {
9
+ throw annotateError(new TypeError(`${label} requires values to be an array.`), { code: "invalid-value", optionPath: "values" });
10
+ }
11
+ for (let index = 0; index < values.length; index += 1) {
12
+ if (!Object.hasOwn(values, index) || !isPlainObject(values[index])) {
13
+ throw annotateError(new TypeError(`${label} requires every row to be a plain object; invalid row at index ${index}.`), { code: "invalid-value", optionPath: `values[${index}]` });
14
+ }
15
+ }
16
+ }
17
+
6
18
  export function validateGeneratedItemLimit(
7
19
  value,
8
20
  label,
9
21
  maximum = MAX_GENERATED_ITEMS
10
22
  ) {
11
23
  if (value > maximum) {
12
- throw new RangeError(`${label} must not exceed ${maximum}.`);
24
+ throw annotateError(new RangeError(`${label} must not exceed ${maximum}.`), { code: "resource-limit", optionPath: label, limit: maximum, actual: value });
13
25
  }
14
26
  return value;
15
27
  }
@@ -21,7 +33,7 @@ export function validateWorkLimit(value, label) {
21
33
  export function validateKeys(value, supported, label) {
22
34
  for (const key of Object.keys(value)) {
23
35
  if (!supported.includes(key)) {
24
- throw new Error(`Unknown ${label} option "${key}".`);
36
+ throw annotateError(new Error(`Unknown ${label} option "${key}".`), { code: "invalid-option", optionPath: key });
25
37
  }
26
38
  }
27
39
  }
@@ -33,18 +45,18 @@ export function validateOptionObject(value, supported, label, {
33
45
  emptyError = TypeError
34
46
  } = {}) {
35
47
  if (!isPlainObject(value)) {
36
- throw new TypeError(plainObjectMessage);
48
+ throw annotateError(new TypeError(plainObjectMessage), { code: "invalid-option" });
37
49
  }
38
50
  if (supported !== undefined) validateKeys(value, supported, label);
39
51
  if (!allowEmpty && Object.keys(value).length === 0) {
40
- throw new emptyError(emptyMessage);
52
+ throw annotateError(new emptyError(emptyMessage), { code: "invalid-option" });
41
53
  }
42
54
  return value;
43
55
  }
44
56
 
45
57
  export function noOptions(args, operation) {
46
58
  if (!isPlainObject(args) || Object.keys(args).length > 0) {
47
- throw new Error(`${operation} does not accept options.`);
59
+ throw annotateError(new Error(`${operation} does not accept options.`), { code: "invalid-option", operation });
48
60
  }
49
61
  }
50
62
 
@@ -59,30 +71,30 @@ export function sameOrderedValues(left, right) {
59
71
 
60
72
  export function validateNonEmptyString(value, label) {
61
73
  if (typeof value !== "string" || value.length === 0) {
62
- throw new TypeError(`${label} must be a non-empty string.`);
74
+ throw annotateError(new TypeError(`${label} must be a non-empty string.`), { code: "invalid-value", optionPath: label });
63
75
  }
64
76
  return value;
65
77
  }
66
78
 
67
79
  export function validateUnitInterval(value, label) {
68
80
  if (!Number.isFinite(value) || value < 0 || value > 1) {
69
- throw new RangeError(
81
+ throw annotateError(new RangeError(
70
82
  `${label} must be between 0 and 1 (values from 0 to 1).`
71
- );
83
+ ), { code: "invalid-value", optionPath: label });
72
84
  }
73
85
  return value;
74
86
  }
75
87
 
76
88
  export function validatePositiveFinite(value, label) {
77
89
  if (!Number.isFinite(value) || value <= 0) {
78
- throw new RangeError(`${label} must be a positive finite number.`);
90
+ throw annotateError(new RangeError(`${label} must be a positive finite number.`), { code: "invalid-value", optionPath: label });
79
91
  }
80
92
  return value;
81
93
  }
82
94
 
83
95
  export function validateNonNegativeFinite(value, label) {
84
96
  if (!Number.isFinite(value) || value < 0) {
85
- throw new RangeError(`${label} must be a non-negative finite number.`);
97
+ throw annotateError(new RangeError(`${label} must be a non-negative finite number.`), { code: "invalid-value", optionPath: label });
86
98
  }
87
99
  return value;
88
100
  }
@@ -0,0 +1 @@
1
+ export { getErrorDetails } from "./core/diagnostics.js";
@@ -1,3 +1,4 @@
1
+ import { IMPUTE_REQUIRED_OPTIONS } from "../core/optionRequirements.js";
1
2
  import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
3
  import { interpolateNumber, inverseLerp } from "./numeric.js";
3
4
  import { normalizeTemporalValue } from "./scales/fields.js";
@@ -6,7 +7,7 @@ const TRANSFORM_KEYS = Object.freeze([
6
7
  "type", "fields", "groupBy", "sortBy", "method", "value", "edges", "maxGap"
7
8
  ]);
8
9
  const SORT_KEYS = Object.freeze(["field", "order"]);
9
- const METHODS = Object.freeze(["constant", "forward", "backward", "linear"]);
10
+ const METHODS = Object.freeze(Object.keys(IMPUTE_REQUIRED_OPTIONS));
10
11
 
11
12
  function rejectUnknownKeys(value, supported, label) {
12
13
  const unknown = Object.keys(value).find(key => !supported.includes(key));
@@ -98,7 +99,7 @@ export function validateImputeTransform(transform) {
98
99
  throw new Error(`Unsupported impute method "${transform.method}".`);
99
100
  }
100
101
  const hasValue = Object.hasOwn(transform, "value");
101
- if (transform.method === "constant") {
102
+ if (IMPUTE_REQUIRED_OPTIONS[transform.method].includes("value")) {
102
103
  if (!hasValue) throw new TypeError("Impute constant requires a value.");
103
104
  if (transform.value === undefined) {
104
105
  throw new TypeError("Impute constant value must be a JSON-safe scalar.");
@@ -26,6 +26,14 @@ export const COMPLETE_SCALE_TYPES = Object.freeze([
26
26
  ...new Set(Object.values(SCALE_TYPES_BY_ROLE).flat())
27
27
  ]);
28
28
 
29
+ // Dimensionless scale spacing is shared by generic and focused editors.
30
+ export const SCALE_OPTION_UNITS = Object.freeze({
31
+ padding: "band-fraction",
32
+ paddingInner: "band-fraction",
33
+ paddingOuter: "band-fraction",
34
+ align: "ratio"
35
+ });
36
+
29
37
  const SCALE_PROPERTY_TYPES = Object.freeze({
30
38
  midpoint: Object.freeze(["sequential"]),
31
39
  nice: Object.freeze(["linear", "log", "pow", "sqrt", "symlog", "time"]),
@@ -376,7 +376,7 @@ function hasActiveStroke(properties) {
376
376
  (properties.strokeWidth ?? 0) > 0;
377
377
  }
378
378
 
379
- function primitiveBounds(type, properties = {}) {
379
+ function primitiveBounds(type, properties = {}, profile) {
380
380
  const strokeExtent = hasActiveStroke(properties)
381
381
  ? properties.strokeWidth / 2
382
382
  : 0;
@@ -420,7 +420,7 @@ function primitiveBounds(type, properties = {}) {
420
420
  !Number.isFinite(properties.fontSize) ||
421
421
  typeof properties.text !== "string"
422
422
  ) return undefined;
423
- return resolveTextBounds(properties);
423
+ return resolveTextBounds(properties, profile);
424
424
  }
425
425
  if (type === "path") {
426
426
  const commands = properties.commands;
@@ -469,15 +469,15 @@ function ancestorOffset(graphicSpec, target) {
469
469
  }
470
470
  }
471
471
 
472
- function objectBounds(graphicSpec, id, object, ancestors, x, y) {
472
+ function objectBounds(graphicSpec, id, object, ancestors, x, y, profile) {
473
473
  if (ancestors.has(id)) {
474
474
  throw new Error(`Graphic attachment cycle includes "${id}".`);
475
475
  }
476
476
  ancestors.add(id);
477
477
  let result = object.items === undefined
478
- ? translateBounds(primitiveBounds(object.type, object.properties), x, y)
478
+ ? translateBounds(primitiveBounds(object.type, object.properties, profile), x, y)
479
479
  : unionBounds(object.items.map(item => translateBounds(
480
- primitiveBounds(item.type ?? object.type, item.properties),
480
+ primitiveBounds(item.type ?? object.type, item.properties, profile),
481
481
  x,
482
482
  y
483
483
  )));
@@ -489,7 +489,7 @@ function objectBounds(graphicSpec, id, object, ancestors, x, y) {
489
489
  throw new Error(`Unknown attached graphic "${childId}".`);
490
490
  }
491
491
  const bounds = objectBounds(
492
- graphicSpec, childId, child.object, ancestors, childX, childY
492
+ graphicSpec, childId, child.object, ancestors, childX, childY, profile
493
493
  );
494
494
  if (bounds !== undefined) {
495
495
  result = includeExtent(
@@ -501,23 +501,23 @@ function objectBounds(graphicSpec, id, object, ancestors, x, y) {
501
501
  return result;
502
502
  }
503
503
 
504
- export function resolveConcreteGraphicBounds(graphicSpec, target) {
504
+ export function resolveConcreteGraphicBounds(graphicSpec, target, profile) {
505
505
  const found = requireGraphic(graphicSpec, target);
506
506
  const [x, y] = ancestorOffset(graphicSpec, target);
507
507
  return requireFiniteBounds(found.kind === "item"
508
508
  ? translateBounds(
509
- primitiveBounds(found.object.type ?? found.owner.type, found.object.properties),
509
+ primitiveBounds(found.object.type ?? found.owner.type, found.object.properties, profile),
510
510
  x,
511
511
  y
512
512
  )
513
- : objectBounds(graphicSpec, found.id, found.object, new Set(), x, y));
513
+ : objectBounds(graphicSpec, found.id, found.object, new Set(), x, y, profile));
514
514
  }
515
515
 
516
- export function unionConcreteGraphicBounds(graphicSpec, targets) {
516
+ export function unionConcreteGraphicBounds(graphicSpec, targets, profile) {
517
517
  if (!Array.isArray(targets)) {
518
518
  throw new TypeError("Graphic bounds targets must be an array.");
519
519
  }
520
520
  return unionBounds(targets.map(target =>
521
- resolveConcreteGraphicBounds(graphicSpec, target)
521
+ resolveConcreteGraphicBounds(graphicSpec, target, profile)
522
522
  ));
523
523
  }
@@ -172,7 +172,7 @@ export function assertMarkLabelPlacementSupport({ markType, coordinateType, inte
172
172
  }
173
173
 
174
174
  function localTextBounds(text) {
175
- return resolveTextBounds({ ...text, x: 0, y: 0 });
175
+ return resolveTextBounds({ ...text, x: 0, y: 0 }, text.profile);
176
176
  }
177
177
 
178
178
  function projectionRange(bounds, direction) {
@@ -207,7 +207,7 @@ function centerCandidate(center, text) {
207
207
  }
208
208
 
209
209
  function candidateBounds(candidate, text) {
210
- return resolveTextBounds({ ...text, x: candidate.x, y: candidate.y });
210
+ return resolveTextBounds({ ...text, x: candidate.x, y: candidate.y }, text.profile);
211
211
  }
212
212
 
213
213
  function intervalContains(bounds, container) {
@@ -328,7 +328,8 @@ function outsideFallback(anchor) {
328
328
  return anchor === "insideStart" ? "outsideStart" : "outsideEnd";
329
329
  }
330
330
 
331
- export function resolveMarkLabelPlacement({ placement, geometry, text } = {}) {
331
+ export function resolveMarkLabelPlacement({ placement, geometry, text } = {}, profile) {
332
+ text = { ...text, profile };
332
333
  const normalized = normalizeMarkLabelPlacement(placement);
333
334
  validatePlacementGeometry(geometry, normalized.anchor);
334
335
  let anchor = normalized.anchor;
@@ -501,7 +502,7 @@ export function enumerateLabelOffsets({ axis, padding, maxDisplacement }) {
501
502
  return cloneAndFreeze(candidates);
502
503
  }
503
504
 
504
- function textBounds(item, offset = { x: 0, y: 0 }) {
505
+ function textBounds(item, offset = { x: 0, y: 0 }, profile) {
505
506
  return resolveTextBounds({
506
507
  x: item.x + offset.x,
507
508
  y: item.y + offset.y,
@@ -512,7 +513,7 @@ function textBounds(item, offset = { x: 0, y: 0 }) {
512
513
  textAlign: item.textAlign,
513
514
  textBaseline: item.textBaseline,
514
515
  rotation: item.rotation
515
- });
516
+ }, profile);
516
517
  }
517
518
 
518
519
  function candidateScore(bounds, boundary, placed, candidate, order) {
@@ -552,7 +553,7 @@ function overlapPairs(items) {
552
553
  return pairs;
553
554
  }
554
555
 
555
- export function resolveLabelLayout({ items, bounds, ...options } = {}) {
556
+ export function resolveLabelLayout({ items, bounds, ...options } = {}, profile) {
556
557
  if (!Array.isArray(items)) {
557
558
  throw new TypeError("Label layout requires an item array.");
558
559
  }
@@ -567,14 +568,14 @@ export function resolveLabelLayout({ items, bounds, ...options } = {}) {
567
568
  );
568
569
  const base = items.map(item => ({
569
570
  id: item.id,
570
- collisionBounds: expanded(textBounds(item), policy.padding)
571
+ collisionBounds: expanded(textBounds(item, undefined, profile), policy.padding)
571
572
  }));
572
573
  const placed = [];
573
574
  const resolved = [];
574
575
  for (const item of items) {
575
576
  let best;
576
577
  for (const [order, candidate] of candidates.entries()) {
577
- const boundsAtCandidate = textBounds(item, candidate);
578
+ const boundsAtCandidate = textBounds(item, candidate, profile);
578
579
  const collisionBounds = expanded(boundsAtCandidate, policy.padding);
579
580
  const score = candidateScore(
580
581
  collisionBounds,
@@ -619,7 +620,7 @@ export function resolveLabelLayout({ items, bounds, ...options } = {}) {
619
620
  });
620
621
  }
621
622
 
622
- export function assertPolarTextLayout({ canvas, items, label }) {
623
+ export function assertPolarTextLayout({ canvas, items, label }, profile) {
623
624
  const dimensions = canvas?.properties;
624
625
  if (
625
626
  ![dimensions?.width, dimensions?.height].every(Number.isFinite) ||
@@ -627,7 +628,7 @@ export function assertPolarTextLayout({ canvas, items, label }) {
627
628
  ) {
628
629
  throw new Error("Polar text layout requires finite Canvas dimensions.");
629
630
  }
630
- const bounds = items.map(resolveTextBounds);
631
+ const bounds = items.map(item => resolveTextBounds(item, profile));
631
632
  const outside = bounds.some(item => !textBoundsFitCanvas(item, dimensions));
632
633
  let overlap = false;
633
634
  for (let first = 0; first < bounds.length && !overlap; first += 1) {
@@ -3,15 +3,15 @@ import {
3
3
  measureTextWidth
4
4
  } from "../core/textMetrics.js";
5
5
 
6
- export function measureLegendTextWidth(value, style = { fontSize: 12 }) {
7
- return measureTextWidth(formatVisibleText(value), style);
6
+ export function measureLegendTextWidth(value, style = { fontSize: 12 }, profile) {
7
+ return measureTextWidth(formatVisibleText(value), style, profile);
8
8
  }
9
9
 
10
- export function resolveLegendGrid(config, width, count, symbolHeight) {
10
+ export function resolveLegendGrid(config, width, count, symbolHeight, profile) {
11
11
  const labels = config.domain.map(formatVisibleText);
12
12
  const itemWidths = labels.map(
13
13
  label => width + config.labels.offset +
14
- measureLegendTextWidth(label, config.labels)
14
+ measureLegendTextWidth(label, config.labels, profile)
15
15
  );
16
16
  const columnCount = Math.min(config.columns ?? count, count);
17
17
  const rowCount = Math.ceil(count / columnCount);
@@ -3,7 +3,7 @@ import { alignLegendStart, resolveLegendGrid } from "./legend.js";
3
3
 
4
4
  // Measure item content before choosing its edge. Family owners supply the
5
5
  // sample dimensions and formatted labels; this module does not inspect scales.
6
- export function resolveLegendItemLayout(plot, config, labels, symbol, canvas) {
6
+ export function resolveLegendItemLayout(plot, config, labels, symbol, canvas, profile) {
7
7
  const strokes = labels.map((_, index) => Array.isArray(symbol.strokeWidth)
8
8
  ? symbol.strokeWidth[index] : symbol.strokeWidth ?? 0);
9
9
  const sampleBounds = labels.map((_, index) => symbol.itemBounds?.[index] ?? {
@@ -19,17 +19,17 @@ export function resolveLegendItemLayout(plot, config, labels, symbol, canvas) {
19
19
  ...sampleBounds.map(bounds => 2 * Math.max(-bounds.top, bounds.bottom)));
20
20
  const side = ["left", "right"].includes(config.position);
21
21
  const titleVisible = config.titleVisible !== false;
22
- const titleWidth = titleVisible ? measureTextWidth(config.title, config.titleStyle) : 0;
22
+ const titleWidth = titleVisible ? measureTextWidth(config.title, config.titleStyle, profile) : 0;
23
23
  const titleHeight = titleVisible ? config.titleStyle.fontSize : 0;
24
24
  const itemWidth = sampleWidth + config.labels.offset + Math.max(...labels.map(
25
- text => measureTextWidth(text, config.labels)
25
+ text => measureTextWidth(text, config.labels, profile)
26
26
  ));
27
27
  let symbolX;
28
28
  let labelX;
29
29
  let itemY;
30
30
  let title;
31
31
  if (config.layout === "legacy-bottom") {
32
- const widths = labels.map(text => sampleWidth + config.labels.offset + measureTextWidth(text, config.labels));
32
+ const widths = labels.map(text => sampleWidth + config.labels.offset + measureTextWidth(text, config.labels, profile));
33
33
  const width = widths.reduce((sum, value) => sum + value, 0) + config.itemGap * (labels.length - 1);
34
34
  const x = config.align === "center" ? (canvas.width - width) / 2 : alignLegendStart(plot, width, config.align);
35
35
  let cursor = x;
@@ -54,7 +54,7 @@ export function resolveLegendItemLayout(plot, config, labels, symbol, canvas) {
54
54
  itemY = labels.map((_, index) => firstY + index * pitch);
55
55
  title = { x, y: plot.y + 20, align: "left" };
56
56
  } else {
57
- const grid = resolveLegendGrid({ ...config, domain: labels }, sampleWidth, labels.length, sampleHeight);
57
+ const grid = resolveLegendGrid({ ...config, domain: labels }, sampleWidth, labels.length, sampleHeight, profile);
58
58
  const inline = config.titlePosition === "left" && titleVisible;
59
59
  const prefix = inline ? titleWidth + 20 : 0;
60
60
  const width = inline ? prefix + grid.gridWidth : Math.max(titleWidth, grid.gridWidth);
@@ -79,7 +79,7 @@ export function resolveLegendItemLayout(plot, config, labels, symbol, canvas) {
79
79
  }
80
80
  const textBounds = (x, y, text, style, align = "left") => resolveTextBounds({
81
81
  x, y, text, ...style, textAlign: align, textBaseline: "middle"
82
- });
82
+ }, profile);
83
83
  const bounds = [
84
84
  ...(titleVisible ? [textBounds(title.x, title.y, config.title, config.titleStyle, title.align)] : []),
85
85
  ...labels.map((label, index) => textBounds(labelX[index], itemY[index], label, config.labels)),