ggaction 0.0.16 → 0.0.17

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 (83) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -1
  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/package.json +8 -2
  7. package/src/actions/data/bin.js +1 -1
  8. package/src/actions/data/create.js +18 -5
  9. package/src/actions/data/density.js +1 -1
  10. package/src/actions/data/derived.js +15 -1
  11. package/src/actions/data/edit.js +19 -6
  12. package/src/actions/data/filter.js +15 -4
  13. package/src/actions/data/index.js +3 -0
  14. package/src/actions/data/interval.js +2 -0
  15. package/src/actions/data/regression.js +4 -1
  16. package/src/actions/data/revise.js +37 -5
  17. package/src/actions/data/shared.js +30 -2
  18. package/src/actions/data/sort.js +21 -0
  19. package/src/actions/data/summary.js +1 -1
  20. package/src/actions/encodings/angle.js +4 -1
  21. package/src/actions/encodings/appearance.js +6 -2
  22. package/src/actions/encodings/color/continuous.js +4 -1
  23. package/src/actions/encodings/color/index.js +4 -1
  24. package/src/actions/encodings/offset.js +4 -1
  25. package/src/actions/encodings/position/resolve.js +6 -1
  26. package/src/actions/encodings/ranged.js +6 -1
  27. package/src/actions/encodings/ruleAppearance.js +4 -1
  28. package/src/actions/encodings/stroke.js +4 -1
  29. package/src/actions/encodings/strokeDash.js +6 -1
  30. package/src/actions/encodings/text.js +6 -1
  31. package/src/actions/marks/bar/create.js +8 -1
  32. package/src/actions/marks/bar/edit.js +12 -5
  33. package/src/actions/marks/point/create.js +8 -1
  34. package/src/actions/marks/point/edit.js +12 -5
  35. package/src/actions/marks/point/materialize.js +4 -2
  36. package/src/actions/marks/rect/actions.js +23 -8
  37. package/src/actions/marks/rule/actions.js +24 -8
  38. package/src/actions/marks/text/actions.js +25 -6
  39. package/src/actions/marks/tick/actions.js +23 -8
  40. package/src/actions/primitives/semanticAction.js +37 -3
  41. package/src/actions/primitives/semanticValidation/dataset.js +13 -1
  42. package/src/actions/primitives/semanticValidation/layer.js +22 -2
  43. package/src/actions/primitives/semanticValidation/scale.js +6 -0
  44. package/src/actions/regression/create.js +25 -4
  45. package/src/actions/regression/edit.js +47 -12
  46. package/src/actions/regression/follow.js +22 -0
  47. package/src/actions/scales/channels.js +11 -11
  48. package/src/actions/scales/consumers/common.js +2 -1
  49. package/src/actions/scales/create.js +2 -1
  50. package/src/actions/scales/definitions.js +1 -1
  51. package/src/actions/scales/edit.js +1 -1
  52. package/src/actions/scales/parallel.js +1 -1
  53. package/src/actions/scales/patch.js +1 -1
  54. package/src/actions/scales/preview.js +28 -1
  55. package/src/actions/theme/reconcile.js +2 -0
  56. package/src/core/action.js +3 -2
  57. package/src/core/programState.js +5 -1
  58. package/src/grammar/bin.js +39 -10
  59. package/src/grammar/datasetSchema.js +386 -0
  60. package/src/grammar/density.js +53 -5
  61. package/src/grammar/filter.js +133 -39
  62. package/src/grammar/interval.js +32 -1
  63. package/src/grammar/itemMissing.js +59 -0
  64. package/src/grammar/regression/derive.js +57 -16
  65. package/src/grammar/regression/index.js +1 -0
  66. package/src/grammar/regression/models.js +51 -22
  67. package/src/grammar/regression/parameters.js +56 -4
  68. package/src/grammar/scales/definition.js +7 -0
  69. package/src/grammar/schemas/semanticPath.js +3 -2
  70. package/src/grammar/sort.js +88 -0
  71. package/src/grammar/summary.js +117 -19
  72. package/src/grammar/transformTopology.js +1 -0
  73. package/src/grammar/transforms.js +21 -11
  74. package/src/inspection.js +495 -0
  75. package/src/inspectionDescriptors.js +3 -0
  76. package/src/materialization/bars/resolve.js +4 -2
  77. package/src/materialization/revisionIdentity.js +19 -0
  78. package/src/materialization/scales/resolve.js +67 -4
  79. package/src/materialization/text.js +4 -2
  80. package/src/persistence.js +79 -6
  81. package/src/version.js +1 -1
  82. package/types/inspection.d.ts +17 -0
  83. package/types/program.d.ts +147 -27
@@ -23,6 +23,7 @@ import {
23
23
  validateCompleteTransform
24
24
  } from "./complete.js";
25
25
  import {
26
+ normalizeFilterTransformEdit,
26
27
  normalizeFilterTransform,
27
28
  validateFilterTransform
28
29
  } from "./filter.js";
@@ -62,6 +63,7 @@ import {
62
63
  validateSummaryTransform
63
64
  } from "./summary.js";
64
65
  import { normalizeStackTransform, validateStackTransform } from "./stack.js";
66
+ import { normalizeSortTransform, validateSortTransform } from "./sort.js";
65
67
  import { validateStatisticalReferenceTransform } from
66
68
  "./statisticalReference.js";
67
69
  import { findTransformTopology } from "./transformTopology.js";
@@ -102,15 +104,6 @@ function normalizeSimple(normalize) {
102
104
  return (transform, patch) => normalize(mergedOptions(transform, patch));
103
105
  }
104
106
 
105
- function normalizeFilterEdit(transform, patch) {
106
- const base = replaceExclusive(
107
- requestedOptions(transform),
108
- patch,
109
- ["oneOf", "predicate", "range"]
110
- );
111
- return normalizeFilterTransform({ ...base, ...patch });
112
- }
113
-
114
107
  function normalizeBinEdit(transform, patch) {
115
108
  const prior = requestedOptions(transform);
116
109
  const base = replaceExclusive(
@@ -202,6 +195,7 @@ function normalizeNormalizeEdit(transform, patch) {
202
195
 
203
196
  function normalizeRegressionEdit(transform, patch) {
204
197
  const base = { ...requestedOptions(transform) };
198
+ if (patch.predict === false) delete base.predict;
205
199
  const method = patch.method ?? base.method;
206
200
  if (method !== base.method) {
207
201
  if (method !== "polynomial") delete base.degree;
@@ -212,7 +206,14 @@ function normalizeRegressionEdit(transform, patch) {
212
206
  ]) delete base[key];
213
207
  }
214
208
  }
215
- return normalizeRegressionTransform({ ...base, ...patch, method });
209
+ if (patch.interval === false) {
210
+ for (const key of ["confidenceMethod", "level", "confidence"]) delete base[key];
211
+ }
212
+ return normalizeRegressionTransform({
213
+ ...base,
214
+ ...Object.fromEntries(Object.entries(patch).filter(([key, value]) => !(key === "predict" && value === false))),
215
+ method
216
+ });
216
217
  }
217
218
 
218
219
  function normalizeTimeUnitEdit(transform, patch) {
@@ -373,7 +374,7 @@ const TRANSFORM_POLICIES = Object.freeze({
373
374
  materializeOp: "materializeFilteredData",
374
375
  replayTransform: requestedTransform,
375
376
  editable: true,
376
- normalizeEdit: normalizeFilterEdit,
377
+ normalizeEdit: normalizeFilterTransformEdit,
377
378
  outputRoles: () => ({})
378
379
  }),
379
380
  fold: Object.freeze({
@@ -442,6 +443,15 @@ const TRANSFORM_POLICIES = Object.freeze({
442
443
  normalizeEdit: normalizeRegressionEdit,
443
444
  outputRoles: () => ({})
444
445
  }),
446
+ sort: Object.freeze({
447
+ ...findTransformTopology("sort"),
448
+ validate: validateSortTransform,
449
+ materializeOp: "materializeSortedData",
450
+ replayTransform: requestedTransform,
451
+ editable: true,
452
+ normalizeEdit: normalizeSimple(normalizeSortTransform),
453
+ outputRoles: () => ({})
454
+ }),
445
455
  summary: Object.freeze({
446
456
  ...findTransformTopology("summary"),
447
457
  validate: validateSummaryTransform,
@@ -0,0 +1,495 @@
1
+ import { ChartProgram } from "./ChartProgram.js";
2
+ import { BasicChartProgram } from "./BasicChartProgram.js";
3
+ import { getWrappedActionMetadata } from "./core/action.js";
4
+ import { cloneAndFreeze, isPlainObject } from "./core/immutable.js";
5
+ import { encodeValue } from "./persistence/codec.js";
6
+ import {
7
+ findDataset,
8
+ hasDatasetOwner,
9
+ resolveDatasetReference
10
+ } from "./selectors/datasets.js";
11
+ import { findLayer } from "./selectors/layers.js";
12
+ import { findSemanticScale } from "./selectors/scales.js";
13
+ import { findCoordinate } from "./selectors/coordinates.js";
14
+ import { inferDatasetSchema } from "./grammar/datasetSchema.js";
15
+ import { BUILTIN_ACTION_DESCRIPTOR_RECORDS } from "./inspectionDescriptors.js";
16
+
17
+ const KINDS = new Set(["data", "mark", "scale", "coordinate", "guide"]);
18
+ const ACTION_DESCRIPTORS = new Map(BUILTIN_ACTION_DESCRIPTOR_RECORDS.map(
19
+ ([name, options, idOptions, prerequisites, entryPoints]) => [name, {
20
+ options: options.map(([path, required, kind, choices, unit]) => ({
21
+ path, required, kind,
22
+ ...(choices === null ? {} : { choices }),
23
+ ...(unit === null ? {} : { unit })
24
+ })),
25
+ idOptions,
26
+ prerequisites,
27
+ entryPoints
28
+ }]
29
+ ));
30
+
31
+ function requireProgram(program, label = "Program") {
32
+ if (!(program instanceof ChartProgram || program instanceof BasicChartProgram)) {
33
+ throw new TypeError(`${label} must be a ggaction program.`);
34
+ }
35
+ if (program.actionStack.length !== 0) throw new Error(`${label} must have a closed action stack.`);
36
+ return program;
37
+ }
38
+
39
+ function atChildPath(program, childPath = []) {
40
+ if (!Array.isArray(childPath)) throw new TypeError("childPath must be an array.");
41
+ let current = program;
42
+ for (const id of childPath) {
43
+ if (typeof id !== "string" || !Object.hasOwn(current.children, id)) {
44
+ throw new Error(`Unknown program child "${String(id)}".`);
45
+ }
46
+ current = current.children[id];
47
+ }
48
+ return current;
49
+ }
50
+
51
+ function validateTarget(target) {
52
+ if (!isPlainObject(target) || !KINDS.has(target.kind) || typeof target.id !== "string" || target.id.length === 0) {
53
+ throw new TypeError("Inspection target requires a supported kind and non-empty id.");
54
+ }
55
+ return target;
56
+ }
57
+
58
+ function findResource(program, target) {
59
+ const current = atChildPath(program, target.childPath);
60
+ if (target.kind === "data") {
61
+ return hasDatasetOwner(current, target.id)
62
+ ? resolveDatasetReference(current, target.id, "Inspection target")
63
+ : findDataset(current, target.id);
64
+ }
65
+ if (target.kind === "mark") return findLayer(current, target.id);
66
+ if (target.kind === "scale") return findSemanticScale(current, target.id);
67
+ if (target.kind === "coordinate") return findCoordinate(current, target.id);
68
+ return current.semanticSpec.guides[target.id];
69
+ }
70
+
71
+ function resourceRef(kind, id, childPath) {
72
+ return { kind, id, ...(childPath?.length ? { childPath: [...childPath] } : {}) };
73
+ }
74
+
75
+ export function getDatasetSchema(program, { data, childPath } = {}) {
76
+ requireProgram(program);
77
+ const current = atChildPath(program, childPath);
78
+ if (typeof data !== "string" || data.length === 0) throw new TypeError("getDatasetSchema requires data.");
79
+ const dataset = resolveDatasetReference(current, data, "Dataset schema data");
80
+ const schema = dataset.schema ?? inferDatasetSchema(dataset.values ?? []);
81
+ return cloneAndFreeze({ data: dataset.id, schema });
82
+ }
83
+
84
+ export function describeAction(program, { action, target, options = {} } = {}) {
85
+ requireProgram(program);
86
+ if (typeof action !== "string" || action.length === 0) throw new TypeError("describeAction requires action.");
87
+ if (!isPlainObject(options)) throw new TypeError("describeAction options must be a plain object.");
88
+ let targetResource;
89
+ if (target !== undefined) {
90
+ validateTarget(target);
91
+ targetResource = findResource(program, target);
92
+ if (targetResource === undefined) {
93
+ return cloneAndFreeze({
94
+ version: 1, action, applicability: "incompatible", parameterDefinitions: [], requirements: [],
95
+ checks: [{ name: "target", status: "failed", evidencePaths: [], findings: [] }],
96
+ findings: [{ severity: "error", code: "missing-resource", reason: "target-unavailable", message: `Unknown ${target.kind} "${target.id}".`, resource: resourceRef(target.kind, target.id, target.childPath) }]
97
+ });
98
+ }
99
+ if (options.target !== undefined && options.target !== target.id) {
100
+ return cloneAndFreeze({
101
+ version: 1, action, applicability: "incompatible", parameterDefinitions: [], requirements: [],
102
+ checks: [{ name: "target", status: "failed", evidencePaths: ["target"], findings: [] }],
103
+ findings: [{ severity: "error", code: "incompatible-resource", reason: "target-mismatch", message: `Inspection target "${target.id}" conflicts with options.target "${options.target}".`, resource: resourceRef(target.kind, target.id, target.childPath), optionPath: "target" }]
104
+ });
105
+ }
106
+ if (action === "encodeShape" && (target.kind !== "mark" || targetResource.mark?.type !== "point")) {
107
+ return cloneAndFreeze({
108
+ version: 1, action, applicability: "unsupported", parameterDefinitions: [], requirements: [],
109
+ checks: [{ name: "target", status: "not_applicable", evidencePaths: ["target"], findings: [] }],
110
+ findings: [{ severity: "info", code: "unsupported-combination", reason: "mark-type", message: "encodeShape supports Point marks.", resource: resourceRef(target.kind, target.id, target.childPath) }]
111
+ });
112
+ }
113
+ }
114
+ const fn = program[action];
115
+ const metadata = typeof fn === "function" ? getWrappedActionMetadata(fn) : undefined;
116
+ if (metadata === undefined) {
117
+ return cloneAndFreeze({
118
+ version: 1, action, applicability: typeof fn === "function" ? "unverified" : "unsupported",
119
+ parameterDefinitions: [], requirements: [], checks: [], findings: [{ severity: "info", code: "unsupported-format", reason: typeof fn === "function" ? "extension-unverified" : "action-unavailable", message: `Action "${action}" has no built-in inspection descriptor.` }]
120
+ });
121
+ }
122
+ const descriptor = ACTION_DESCRIPTORS.get(action);
123
+ const optionNames = metadata.options ?? descriptor?.options.map(option => option.path);
124
+ if (optionNames === undefined) {
125
+ return cloneAndFreeze({
126
+ version: 1, action, applicability: "unverified", parameterDefinitions: [], requirements: [],
127
+ checks: [{ name: "execution", status: "not_run", evidencePaths: [], findings: [] }],
128
+ findings: [{ severity: "info", code: "unsupported-format", reason: "descriptor-unavailable", message: `Action "${action}" is registered but its static option descriptor is unavailable.` }]
129
+ });
130
+ }
131
+ const unknown = Object.keys(options).find(key => !optionNames.includes(key));
132
+ const required = descriptor?.options.filter(option => option.required).map(option => option.path) ?? [];
133
+ const missing = required.filter(key => options[key] === undefined);
134
+ let fieldFinding = [];
135
+ if (targetResource !== undefined && target.kind === "mark") {
136
+ const schema = getDatasetSchema(program, {
137
+ data: targetResource.data,
138
+ childPath: target.childPath
139
+ }).schema;
140
+ if (schema.completeness === "known") {
141
+ const fields = new Set(schema.fields.map(field => field.name));
142
+ fieldFinding = (descriptor?.options ?? [])
143
+ .filter(definition => definition.kind === "field" &&
144
+ typeof options[definition.path] === "string" &&
145
+ !fields.has(options[definition.path]))
146
+ .map(definition => ({
147
+ severity: "error",
148
+ code: "missing-resource",
149
+ reason: "field-unavailable",
150
+ message: `Dataset does not contain field "${options[definition.path]}".`,
151
+ resource: resourceRef("data", targetResource.data, target.childPath),
152
+ optionPath: definition.path
153
+ }));
154
+ }
155
+ }
156
+ const finding = unknown !== undefined
157
+ ? [{ severity: "error", code: "invalid-option", reason: "unknown-option", message: `Unknown ${action} option "${unknown}".`, optionPath: unknown }]
158
+ : [...missing.map(key => ({ severity: "info", code: "missing-input", reason: "required-option", message: `${action} requires option "${key}".`, optionPath: key })), ...fieldFinding];
159
+ const applicability = unknown !== undefined || fieldFinding.length ? "incompatible" : missing.length ? "needs-input" : "supported";
160
+ return cloneAndFreeze({
161
+ version: 1,
162
+ action,
163
+ applicability,
164
+ parameterDefinitions: descriptor?.options.map(definition => ({
165
+ ...definition,
166
+ active: true
167
+ })) ?? optionNames.map(path => ({ path, kind: "object", required: false, active: true })),
168
+ requirements: missing.map(optionPath => ({ reason: "required-option", optionPath })),
169
+ checks: [
170
+ { name: "option-shape", status: unknown === undefined ? "passed" : "failed", evidencePaths: Object.keys(options), findings: unknown === undefined ? [] : finding },
171
+ { name: "execution", status: "not_run", evidencePaths: [], findings: [] }
172
+ ],
173
+ findings: finding
174
+ });
175
+ }
176
+
177
+ function canonicalInput(value) {
178
+ if (Array.isArray(value)) return value.map(canonicalInput);
179
+ if (!isPlainObject(value)) return value;
180
+ return Object.fromEntries(Object.keys(value).sort().map(
181
+ key => [key, canonicalInput(value[key])]
182
+ ));
183
+ }
184
+
185
+ function canonical(value) {
186
+ return JSON.stringify(encodeValue(canonicalInput(value)));
187
+ }
188
+
189
+ function resources(program, childPath = [], output = []) {
190
+ for (const value of program.semanticSpec.datasets) output.push({ ref: resourceRef("data", value.id, childPath), value });
191
+ for (const [orderIndex, value] of program.semanticSpec.layers.entries()) output.push({
192
+ ref: resourceRef("mark", value.id, childPath),
193
+ value: { semantic: value, graphic: program.graphicSpec.objects[value.id], config: program.markConfigs[value.id], orderIndex }
194
+ });
195
+ for (const value of program.semanticSpec.scales) output.push({ ref: resourceRef("scale", value.id, childPath), value: { semantic: value, resolved: program.resolvedScales[value.id] } });
196
+ for (const value of program.semanticSpec.coordinates) output.push({ ref: resourceRef("coordinate", value.id, childPath), value });
197
+ for (const [id, value] of Object.entries(program.semanticSpec.guides)) output.push({ ref: resourceRef("guide", id, childPath), value: { semantic: value, graphic: program.graphicSpec.objects[id], config: program.guideConfigs[id] } });
198
+ if (Object.keys(program.semanticSpec.title).length) output.push({ ref: resourceRef("guide", "title", childPath), value: { semantic: program.semanticSpec.title, config: program.guideConfigs.title } });
199
+ if (program.graphicSpec.objects.canvas !== undefined) output.push({
200
+ ref: resourceRef("canvas", "canvas", childPath),
201
+ value: program.graphicSpec.objects.canvas
202
+ });
203
+ if (Object.keys(program.compositionSpec ?? {}).length > 0) output.push({
204
+ ref: resourceRef("composition", "layout", childPath),
205
+ value: program.compositionSpec
206
+ });
207
+ for (const [id, child] of Object.entries(program.children)) resources(child, [...childPath, id], output);
208
+ return output;
209
+ }
210
+
211
+ function refKey(ref) { return `${ref.childPath?.join("/") ?? ""}|${ref.kind}|${ref.id}`; }
212
+ function effect(kind) { return kind === "data" ? "data" : kind === "scale" ? "scale" : kind === "guide" ? "guide" : ["coordinate", "canvas", "composition"].includes(kind) ? "structure" : "style"; }
213
+ function changeEffect(a, b, kind) {
214
+ if (kind !== "mark" || a === undefined || b === undefined) return effect(kind);
215
+ if (a.value.orderIndex !== b.value.orderIndex) return "structure";
216
+ if (canonical(a.value.semantic?.encoding) !== canonical(b.value.semantic?.encoding) ||
217
+ a.value.semantic?.data !== b.value.semantic?.data) return "binding";
218
+ if (canonical(a.value.semantic) !== canonical(b.value.semantic)) return "structure";
219
+ return "style";
220
+ }
221
+
222
+ export function comparePrograms(before, after, { target } = {}) {
223
+ requireProgram(before, "Before program");
224
+ requireProgram(after, "After program");
225
+ if (target !== undefined) validateTarget(target);
226
+ const left = new Map(resources(before).map(item => [refKey(item.ref), item]));
227
+ const right = new Map(resources(after).map(item => [refKey(item.ref), item]));
228
+ const keys = [...new Set([...left.keys(), ...right.keys()])].sort();
229
+ let changes = keys.flatMap(key => {
230
+ const a = left.get(key); const b = right.get(key); const item = a ?? b;
231
+ if (a !== undefined && b !== undefined && canonical(a.value) === canonical(b.value)) return [];
232
+ const semanticRoles = item.ref.kind === "mark" && a !== undefined && b !== undefined
233
+ ? [...new Set([...Object.keys(a.value.semantic?.encoding ?? {}), ...Object.keys(b.value.semantic?.encoding ?? {})])]
234
+ .filter(role => canonical(a.value.semantic?.encoding?.[role]) !== canonical(b.value.semantic?.encoding?.[role]))
235
+ : [];
236
+ return [{ resource: item.ref, operation: a === undefined ? "add" : b === undefined ? "remove" : "modify", effect: changeEffect(a, b, item.ref.kind), roles: semanticRoles }];
237
+ });
238
+ if (target !== undefined) {
239
+ const targetKeys = [before, after].flatMap(candidate => {
240
+ const resolved = findResource(candidate, target);
241
+ return resolved === undefined ? [] : [refKey(resourceRef(
242
+ target.kind,
243
+ target.kind === "data" ? resolved.id : target.id,
244
+ target.childPath
245
+ ))];
246
+ });
247
+ if (targetKeys.length === 0) throw new Error(`Unknown ${target.kind} "${target.id}" in both programs.`);
248
+ const connected = new Set(targetKeys);
249
+ for (const map of [left, right]) {
250
+ let advanced = true;
251
+ while (advanced) {
252
+ advanced = false;
253
+ for (const [candidateKey, candidate] of map) {
254
+ if (connected.has(candidateKey)) continue;
255
+ const path = canonical(candidate.ref.childPath ?? []);
256
+ const consumes = [...connected].some(connectedKey => {
257
+ const source = map.get(connectedKey);
258
+ if (source === undefined || canonical(source.ref.childPath ?? []) !== path) return false;
259
+ if (source.ref.kind === "data") {
260
+ return candidate.ref.kind === "data"
261
+ ? candidate.value.source === source.ref.id
262
+ : candidate.ref.kind === "mark" && candidate.value.semantic?.data === source.ref.id;
263
+ }
264
+ if (source.ref.kind === "mark") {
265
+ return (candidate.ref.kind === "mark" && candidate.value.semantic?.source === source.ref.id) ||
266
+ (candidate.ref.kind === "guide" && (candidate.value.semantic?.target === source.ref.id || candidate.value.config?.target === source.ref.id));
267
+ }
268
+ if (source.ref.kind === "scale") {
269
+ return (candidate.ref.kind === "mark" && Object.values(candidate.value.semantic?.encoding ?? {}).some(
270
+ encoding => encoding?.scale === source.ref.id || encoding?.dimensions?.some(dimension => dimension.scale === source.ref.id)
271
+ )) || (candidate.ref.kind === "guide" && (candidate.value.semantic?.scale === source.ref.id || candidate.value.config?.scale === source.ref.id));
272
+ }
273
+ return source.ref.kind === "coordinate" && candidate.ref.kind === "mark" &&
274
+ candidate.value.semantic?.coordinate === source.ref.id;
275
+ });
276
+ if (consumes) { connected.add(candidateKey); advanced = true; }
277
+ }
278
+ }
279
+ }
280
+ changes = changes.filter(change => connected.has(refKey(change.resource)));
281
+ }
282
+ const extensionUnknown = ![ChartProgram, BasicChartProgram].includes(before.constructor) ||
283
+ ![ChartProgram, BasicChartProgram].includes(after.constructor);
284
+ const findings = extensionUnknown ? [{
285
+ severity: "info", code: "unsupported-format", reason: "extension-unverified",
286
+ message: "Extension-owned semantic equivalence could not be fully verified."
287
+ }] : [];
288
+ return cloneAndFreeze({
289
+ version: 1,
290
+ equivalence: changes.length ? "different" : extensionUnknown ? "unknown" : "equal",
291
+ completeProgramComparison: target === undefined,
292
+ changes,
293
+ affected: changes.map(change => change.resource),
294
+ findings
295
+ });
296
+ }
297
+
298
+ function itemBounds(properties, type) {
299
+ if (!isPlainObject(properties)) return null;
300
+ if (type === "circle") {
301
+ if (!Number.isFinite(properties.x) || !Number.isFinite(properties.y) ||
302
+ !Number.isFinite(properties.radius) || properties.radius < 0) return null;
303
+ return { x: properties.x - properties.radius, y: properties.y - properties.radius,
304
+ width: properties.radius * 2, height: properties.radius * 2 };
305
+ }
306
+ if (type === "rect") {
307
+ if (![properties.x, properties.y, properties.width, properties.height].every(Number.isFinite)) return null;
308
+ return { x: Math.min(properties.x, properties.x + properties.width),
309
+ y: Math.min(properties.y, properties.y + properties.height),
310
+ width: Math.abs(properties.width), height: Math.abs(properties.height) };
311
+ }
312
+ if (type === "path" && Array.isArray(properties.commands)) {
313
+ const points = properties.commands.flatMap(command =>
314
+ Number.isFinite(command.x) && Number.isFinite(command.y) ? [[command.x, command.y]] : []
315
+ );
316
+ if (points.length === 0) return null;
317
+ const xs = points.map(point => point[0]);
318
+ const ys = points.map(point => point[1]);
319
+ return { x: Math.min(...xs), y: Math.min(...ys),
320
+ width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) };
321
+ }
322
+ if ([properties.x1, properties.y1, properties.x2, properties.y2].every(Number.isFinite)) {
323
+ return { x: Math.min(properties.x1, properties.x2), y: Math.min(properties.y1, properties.y2),
324
+ width: Math.abs(properties.x2 - properties.x1), height: Math.abs(properties.y2 - properties.y1) };
325
+ }
326
+ return null;
327
+ }
328
+
329
+ function inspectMark(program, layer, childPath) {
330
+ const graphic = program.graphicSpec.objects[layer.id];
331
+ const dataset = findDataset(program, layer.data);
332
+ const boundedTypes = new Set(["circle", "rect", "line", "path"]);
333
+ const items = graphic?.items ?? (graphic?.properties ? [{ properties: graphic.properties }] : []);
334
+ const concreteTypes = items.map(item => item.type ?? graphic?.type);
335
+ const logicalSupported = graphic !== undefined && concreteTypes.every(type =>
336
+ boundedTypes.has(type) || type === "text"
337
+ );
338
+ const geometrySupported = logicalSupported && concreteTypes.every(type => boundedTypes.has(type));
339
+ const opacityZero = items.filter(item => item.properties.opacity === 0).length;
340
+ const zeroSize = items.filter(item => { const b = itemBounds(item.properties, item.type ?? graphic?.type); return b !== null && b.width === 0 && b.height === 0; }).length;
341
+ const bounds = items.map(item => itemBounds(item.properties, item.type ?? graphic?.type)).filter(Boolean);
342
+ const canvas = program.graphicSpec.objects.canvas?.properties;
343
+ const outside = Number.isFinite(canvas?.width) && Number.isFinite(canvas?.height)
344
+ ? bounds.filter(bound => bound.x + bound.width < 0 || bound.y + bound.height < 0 ||
345
+ bound.x > canvas.width || bound.y > canvas.height).length
346
+ : null;
347
+ const union = bounds.length === 0 ? null : {
348
+ x: Math.min(...bounds.map(item => item.x)), y: Math.min(...bounds.map(item => item.y)),
349
+ width: Math.max(...bounds.map(item => item.x + item.width)) - Math.min(...bounds.map(item => item.x)),
350
+ height: Math.max(...bounds.map(item => item.y + item.height)) - Math.min(...bounds.map(item => item.y))
351
+ };
352
+ const grain = ["line", "area"].includes(layer.mark?.type) ? "series" : "item";
353
+ return {
354
+ owner: resourceRef("mark", layer.id, childPath), grain,
355
+ rawRows: dataset?.values?.length ?? null,
356
+ logicalDataItems: logicalSupported ? items.length : null,
357
+ drawablePrimitives: logicalSupported ? items.length : null,
358
+ visibleCandidates: geometrySupported ? Math.max(0, items.length - new Set([
359
+ ...items.map((item, index) => item.properties.opacity === 0 ? index : -1),
360
+ ...items.map((item, index) => { const b = itemBounds(item.properties, item.type ?? graphic?.type); return b !== null && b.width === 0 && b.height === 0 ? index : -1; }),
361
+ ...(outside === null ? [] : items.map((item, index) => { const b = itemBounds(item.properties, item.type ?? graphic?.type); return b !== null && (b.x + b.width < 0 || b.y + b.height < 0 || b.x > canvas.width || b.y > canvas.height) ? index : -1; }))
362
+ ].filter(index => index >= 0)).size) : null,
363
+ flags: [
364
+ { reason: "opacity-zero", count: opacityZero },
365
+ { reason: "zero-size", count: zeroSize },
366
+ { reason: "outside-clip", count: outside },
367
+ { reason: "missing-skipped", count: layer.mark?.missing === "skip" && dataset?.values
368
+ ? Math.max(0, dataset.values.length - items.length) : null }
369
+ ],
370
+ bounds: union,
371
+ sourceRefs: dataset === undefined ? [] : [resourceRef("data", dataset.id, childPath)],
372
+ checks: [
373
+ { name: "concrete-structure", status: graphic === undefined ? "failed" : logicalSupported ? "passed" : "not_applicable", evidencePaths: [layer.id], findings: [] },
374
+ { name: "pixel-visibility", status: "not_run", evidencePaths: [], findings: [] }
375
+ ]
376
+ };
377
+ }
378
+
379
+ function inspectGuide(program, id, childPath) {
380
+ const markIds = new Set(program.semanticSpec.layers.map(layer => layer.id));
381
+ const pattern = id === "axis" ? /axis/iu
382
+ : id === "grid" ? /grid/iu
383
+ : id === "legend" ? /legend/iu
384
+ : id === "title" ? /title|subtitle/iu
385
+ : new RegExp(id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "iu");
386
+ const graphicIds = Object.keys(program.graphicSpec.objects).filter(candidate =>
387
+ candidate !== "canvas" && !candidate.startsWith("plot-") &&
388
+ !markIds.has(candidate) && pattern.test(candidate)
389
+ );
390
+ const items = graphicIds.flatMap(candidate => {
391
+ const graphic = program.graphicSpec.objects[candidate];
392
+ if (Array.isArray(graphic?.items)) return graphic.items;
393
+ return graphic?.properties === undefined ? [] : [{ properties: graphic.properties }];
394
+ });
395
+ const semantic = id === "title"
396
+ ? program.semanticSpec.title
397
+ : program.semanticSpec.guides[id];
398
+ const sourceRefs = [];
399
+ function collectRefs(value) {
400
+ if (!isPlainObject(value)) return;
401
+ if (typeof value.scale === "string") sourceRefs.push(resourceRef("scale", value.scale, childPath));
402
+ if (typeof value.coordinate === "string") sourceRefs.push(resourceRef("coordinate", value.coordinate, childPath));
403
+ for (const child of Object.values(value)) collectRefs(child);
404
+ }
405
+ collectRefs(semantic);
406
+ return {
407
+ owner: resourceRef("guide", id, childPath),
408
+ grain: "unknown",
409
+ rawRows: null,
410
+ logicalDataItems: items.length,
411
+ drawablePrimitives: items.length,
412
+ visibleCandidates: null,
413
+ flags: [
414
+ { reason: "opacity-zero", count: null },
415
+ { reason: "zero-size", count: null },
416
+ { reason: "outside-clip", count: null },
417
+ { reason: "missing-skipped", count: null }
418
+ ],
419
+ bounds: null,
420
+ sourceRefs: [...new Map(sourceRefs.map(ref => [refKey(ref), ref])).values()],
421
+ checks: [
422
+ { name: "concrete-structure", status: graphicIds.length > 0 ? "passed" : "not_applicable", evidencePaths: graphicIds, findings: [] },
423
+ { name: "pixel-visibility", status: "not_run", evidencePaths: [], findings: [] }
424
+ ]
425
+ };
426
+ }
427
+
428
+ export function inspectProgram(program, { target } = {}) {
429
+ requireProgram(program);
430
+ if (target !== undefined) validateTarget(target);
431
+ const views = [];
432
+ function visit(current, path = []) {
433
+ const visited = new Set();
434
+ const active = new Set();
435
+ function validateGraphic(id) {
436
+ if (active.has(id)) throw new Error(`Graphic hierarchy contains a cycle at "${id}".`);
437
+ if (visited.has(id)) throw new Error(`Graphic "${id}" is referenced more than once.`);
438
+ const object = current.graphicSpec.objects[id];
439
+ if (object === undefined) throw new Error(`Graphic hierarchy references missing object "${id}".`);
440
+ visited.add(id); active.add(id);
441
+ for (const child of object.children ?? []) validateGraphic(child);
442
+ active.delete(id);
443
+ }
444
+ for (const id of current.graphicSpec.order) validateGraphic(id);
445
+ for (const layer of current.semanticSpec.layers) views.push(inspectMark(current, layer, path));
446
+ for (const id of Object.keys(current.semanticSpec.guides)) {
447
+ views.push(inspectGuide(current, id, path));
448
+ }
449
+ if (Object.keys(current.semanticSpec.title).length > 0) {
450
+ views.push(inspectGuide(current, "title", path));
451
+ }
452
+ for (const [id, child] of Object.entries(current.children)) visit(child, [...path, id]);
453
+ }
454
+ visit(program);
455
+ let filtered = views;
456
+ if (target !== undefined) {
457
+ const current = atChildPath(program, target.childPath);
458
+ const targetResource = findResource(program, target);
459
+ if (targetResource === undefined) {
460
+ throw new Error(`Unknown ${target.kind} "${target.id}".`);
461
+ }
462
+ filtered = views.filter(view => {
463
+ if (canonical(view.owner.childPath ?? []) !== canonical(target.childPath ?? [])) return false;
464
+ if (target.kind === "mark") return view.owner.id === target.id;
465
+ const layer = findLayer(current, view.owner.id);
466
+ if (target.kind === "data") return layer?.data === targetResource.id;
467
+ if (target.kind === "coordinate") return layer?.coordinate === target.id;
468
+ if (target.kind === "scale") return Object.values(layer?.encoding ?? {}).some(
469
+ encoding => encoding?.scale === target.id || encoding?.dimensions?.some(dimension => dimension.scale === target.id)
470
+ );
471
+ if (target.kind === "guide") return view.owner.kind === "guide" && view.owner.id === target.id;
472
+ return false;
473
+ });
474
+ }
475
+ const calculations = [];
476
+ function collectCalculations(current, path = []) {
477
+ for (const family of Object.values(current.materializationConfigs.calculations ?? {})) {
478
+ if (!isPlainObject(family)) continue;
479
+ for (const report of Object.values(family)) {
480
+ calculations.push(path.length === 0 ? report : { ...report, childPath: path });
481
+ }
482
+ }
483
+ for (const [id, child] of Object.entries(current.children)) {
484
+ collectCalculations(child, [...path, id]);
485
+ }
486
+ }
487
+ collectCalculations(program);
488
+ const unsupported = filtered.filter(view => view.logicalDataItems === null).map(view => view.owner);
489
+ return cloneAndFreeze({
490
+ version: 1, views: filtered,
491
+ calculations,
492
+ checks: [{ name: "pixel-visibility", status: "not_run", evidencePaths: [], findings: [] }],
493
+ findings: [], coverage: { partial: filtered.some(view => view.checks.some(check => check.status !== "passed")), unsupported }
494
+ });
495
+ }