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.
- package/CHANGELOG.md +20 -0
- package/README.md +2 -1
- package/knowledge/action-cards.json +1 -1
- package/knowledge/intent-taxonomy.json +1 -1
- package/knowledge/mcp-resources.json +1 -1
- package/package.json +8 -2
- package/src/actions/data/bin.js +1 -1
- package/src/actions/data/create.js +18 -5
- package/src/actions/data/density.js +1 -1
- package/src/actions/data/derived.js +15 -1
- package/src/actions/data/edit.js +19 -6
- package/src/actions/data/filter.js +15 -4
- package/src/actions/data/index.js +3 -0
- package/src/actions/data/interval.js +2 -0
- package/src/actions/data/regression.js +4 -1
- package/src/actions/data/revise.js +37 -5
- package/src/actions/data/shared.js +30 -2
- package/src/actions/data/sort.js +21 -0
- package/src/actions/data/summary.js +1 -1
- package/src/actions/encodings/angle.js +4 -1
- package/src/actions/encodings/appearance.js +6 -2
- package/src/actions/encodings/color/continuous.js +4 -1
- package/src/actions/encodings/color/index.js +4 -1
- package/src/actions/encodings/offset.js +4 -1
- package/src/actions/encodings/position/resolve.js +6 -1
- package/src/actions/encodings/ranged.js +6 -1
- package/src/actions/encodings/ruleAppearance.js +4 -1
- package/src/actions/encodings/stroke.js +4 -1
- package/src/actions/encodings/strokeDash.js +6 -1
- package/src/actions/encodings/text.js +6 -1
- package/src/actions/marks/bar/create.js +8 -1
- package/src/actions/marks/bar/edit.js +12 -5
- package/src/actions/marks/point/create.js +8 -1
- package/src/actions/marks/point/edit.js +12 -5
- package/src/actions/marks/point/materialize.js +4 -2
- package/src/actions/marks/rect/actions.js +23 -8
- package/src/actions/marks/rule/actions.js +24 -8
- package/src/actions/marks/text/actions.js +25 -6
- package/src/actions/marks/tick/actions.js +23 -8
- package/src/actions/primitives/semanticAction.js +37 -3
- package/src/actions/primitives/semanticValidation/dataset.js +13 -1
- package/src/actions/primitives/semanticValidation/layer.js +22 -2
- package/src/actions/primitives/semanticValidation/scale.js +6 -0
- package/src/actions/regression/create.js +25 -4
- package/src/actions/regression/edit.js +47 -12
- package/src/actions/regression/follow.js +22 -0
- package/src/actions/scales/channels.js +11 -11
- package/src/actions/scales/consumers/common.js +2 -1
- package/src/actions/scales/create.js +2 -1
- package/src/actions/scales/definitions.js +1 -1
- package/src/actions/scales/edit.js +1 -1
- package/src/actions/scales/parallel.js +1 -1
- package/src/actions/scales/patch.js +1 -1
- package/src/actions/scales/preview.js +28 -1
- package/src/actions/theme/reconcile.js +2 -0
- package/src/core/action.js +3 -2
- package/src/core/programState.js +5 -1
- package/src/grammar/bin.js +39 -10
- package/src/grammar/datasetSchema.js +386 -0
- package/src/grammar/density.js +53 -5
- package/src/grammar/filter.js +133 -39
- package/src/grammar/interval.js +32 -1
- package/src/grammar/itemMissing.js +59 -0
- package/src/grammar/regression/derive.js +57 -16
- package/src/grammar/regression/index.js +1 -0
- package/src/grammar/regression/models.js +51 -22
- package/src/grammar/regression/parameters.js +56 -4
- package/src/grammar/scales/definition.js +7 -0
- package/src/grammar/schemas/semanticPath.js +3 -2
- package/src/grammar/sort.js +88 -0
- package/src/grammar/summary.js +117 -19
- package/src/grammar/transformTopology.js +1 -0
- package/src/grammar/transforms.js +21 -11
- package/src/inspection.js +495 -0
- package/src/inspectionDescriptors.js +3 -0
- package/src/materialization/bars/resolve.js +4 -2
- package/src/materialization/revisionIdentity.js +19 -0
- package/src/materialization/scales/resolve.js +67 -4
- package/src/materialization/text.js +4 -2
- package/src/persistence.js +79 -6
- package/src/version.js +1 -1
- package/types/inspection.d.ts +17 -0
- 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
|
-
|
|
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:
|
|
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
|
+
}
|