ggaction 0.0.24 → 0.0.26
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 +47 -0
- package/README.md +2 -2
- package/knowledge/action-cards.json +1 -1
- package/knowledge/intent-taxonomy.json +1 -1
- package/knowledge/mcp-resources.json +1 -1
- package/knowledge/task-resolver.js +1 -1
- package/package.json +1 -1
- package/src/accessibility.js +13 -0
- package/src/actions/canvas/actions.js +2 -1
- package/src/actions/canvas/fitting.js +68 -13
- package/src/actions/charts/beeswarm.js +2 -1
- package/src/actions/charts/ecdf.js +2 -1
- package/src/actions/charts/endpoints.js +25 -16
- package/src/actions/charts/heatmap.js +5 -3
- package/src/actions/charts/missing.js +17 -0
- package/src/actions/charts/radar.js +70 -33
- package/src/actions/charts/raincloud.js +18 -4
- package/src/actions/charts/rug-strip.js +2 -2
- package/src/actions/charts/shared.js +17 -0
- package/src/actions/composition/actions.js +24 -7
- package/src/actions/composition/adjacent.js +90 -0
- package/src/actions/composition/index.js +11 -1
- package/src/actions/composition/layer.js +42 -0
- package/src/actions/composition/layerPlan.js +176 -0
- package/src/actions/composition/targets.js +105 -0
- package/src/actions/data/derived.js +3 -0
- package/src/actions/data/filter.js +1 -1
- package/src/actions/data/raincloudFilter.js +3 -1
- package/src/actions/encodings/appearance.js +26 -7
- package/src/actions/encodings/channels.js +1 -0
- package/src/actions/encodings/color/continuous.js +37 -34
- package/src/actions/encodings/color/index.js +57 -43
- package/src/actions/encodings/color/policy.js +15 -0
- package/src/actions/encodings/density/resolve.js +3 -2
- package/src/actions/encodings/density.js +2 -2
- package/src/actions/encodings/offset.js +2 -0
- package/src/actions/encodings/pathOrder.js +2 -0
- package/src/actions/encodings/position/areaAlignment.js +42 -0
- package/src/actions/encodings/position/index.js +37 -3
- package/src/actions/encodings/position/policies/line.js +1 -1
- package/src/actions/encodings/ranged.js +2 -0
- package/src/actions/encodings/remove.js +13 -10
- package/src/actions/encodings/ruleAppearance.js +14 -2
- package/src/actions/facets/actions.js +1 -1
- package/src/actions/guides/axes/axes.js +2 -1
- package/src/actions/guides/axes/edit.js +1 -1
- package/src/actions/guides/axes/labels.js +2 -1
- package/src/actions/guides/axes/parallel/lifecycle.js +11 -4
- package/src/actions/guides/axes/parallel/policy.js +5 -2
- package/src/actions/guides/axes/parallel/resolve.js +3 -1
- package/src/actions/guides/axes/parallel.js +9 -4
- package/src/actions/guides/axes/titles.js +1 -1
- package/src/actions/guides/facadeLegend.js +3 -2
- package/src/actions/guides/grids/grid.js +3 -2
- package/src/actions/guides/grids/resolve.js +2 -1
- package/src/actions/guides/guides.js +3 -2
- package/src/actions/guides/legends/automatic.js +28 -0
- package/src/actions/guides/legends/blocks.js +15 -21
- package/src/actions/guides/legends/categorical/actions.js +1 -1
- package/src/actions/guides/legends/categorical/components.js +4 -3
- package/src/actions/guides/legends/categorical/layout.js +5 -2
- package/src/actions/guides/legends/categorical/options.js +9 -14
- package/src/actions/guides/legends/categorical/recipes.js +3 -0
- package/src/actions/guides/legends/categorical/resolve.js +7 -5
- package/src/actions/guides/legends/colorSize.js +51 -14
- package/src/actions/guides/legends/continuous/common.js +1 -1
- package/src/actions/guides/legends/continuous/gradient.js +1 -1
- package/src/actions/guides/legends/continuous/opacity.js +2 -0
- package/src/actions/guides/legends/edit.js +1 -1
- package/src/actions/guides/legends/focused.js +2 -2
- package/src/actions/guides/legends/group.js +1 -1
- package/src/actions/guides/legends/lane.js +1 -1
- package/src/actions/guides/legends/lifecycle.js +2 -2
- package/src/actions/guides/legends/remove.js +18 -4
- package/src/actions/guides/legends/size.js +1 -1
- package/src/actions/guides/legends/transition.js +15 -6
- package/src/actions/guides/polar/axes/facade.js +4 -2
- package/src/actions/guides/polar/axes/labels.js +6 -1
- package/src/actions/guides/polar/axes/shared.js +4 -4
- package/src/actions/guides/polar/axes/titles.js +13 -2
- package/src/actions/guides/polar/grids.js +6 -3
- package/src/actions/guides/tickValues.js +7 -1
- package/src/actions/index.js +2 -0
- package/src/actions/marks/area/actions.js +9 -2
- package/src/actions/marks/area/materialize.js +1 -1
- package/src/actions/marks/bar/materialize.js +4 -4
- package/src/actions/marks/lifecycle.js +14 -0
- package/src/actions/marks/line/actions.js +3 -0
- package/src/actions/marks/line/materialize.js +4 -2
- package/src/actions/marks/point/edit.js +2 -0
- package/src/actions/marks/point/jitter.js +2 -0
- package/src/actions/marks/point/materialize.js +5 -4
- package/src/actions/marks/point/packing.js +2 -0
- package/src/actions/marks/references.js +5 -3
- package/src/actions/marks/remove.js +16 -0
- package/src/actions/marks/shared.js +2 -1
- package/src/actions/marks/text/actions.js +5 -2
- package/src/actions/overlays/index.js +168 -0
- package/src/actions/overlays/snapshot.js +45 -0
- package/src/actions/primitives/editGraphics.js +21 -27
- package/src/actions/primitives/semanticAction.js +4 -5
- package/src/actions/primitives/semanticValidation/guide.js +1 -0
- package/src/actions/primitives/semanticValidation/layer.js +7 -0
- package/src/actions/scales/consumers/common.js +10 -0
- package/src/actions/scales/consumers/families.js +1 -1
- package/src/actions/scales/preview.js +1 -1
- package/src/actions/selection/actions.js +63 -11
- package/src/actions/titles/actions.js +3 -14
- package/src/actions/titles/resolve.js +12 -9
- package/src/core/action.js +41 -25
- package/src/core/compositionScales.js +12 -0
- package/src/core/compositionState.js +16 -2
- package/src/core/immutable.js +11 -6
- package/src/core/programState.js +16 -0
- package/src/core/resourceReferences.js +19 -3
- package/src/core/validation.js +7 -0
- package/src/core/vocabulary.js +8 -2
- package/src/grammar/actionTargets.js +24 -0
- package/src/grammar/bin2d.js +7 -0
- package/src/grammar/colorSizeLegend.js +56 -5
- package/src/grammar/displayLabels.js +10 -0
- package/src/grammar/facetHeaders.js +1 -1
- package/src/grammar/facets/dependencies.js +10 -3
- package/src/grammar/facets/guides.js +2 -1
- package/src/grammar/facets/legendIntent.js +7 -5
- package/src/grammar/legendBlocks.js +1 -1
- package/src/grammar/legendCombinations.js +1 -1
- package/src/grammar/legendGroups.js +1 -1
- package/src/grammar/legendItemsOffset.js +12 -0
- package/src/grammar/legendOverflow.js +25 -0
- package/src/grammar/lineSeries.js +1 -1
- package/src/grammar/markSelection.js +23 -0
- package/src/grammar/overlay.js +51 -0
- package/src/grammar/parallelCoordinates.js +1 -208
- package/src/grammar/pathCommands.js +8 -2
- package/src/grammar/pathSeries.js +1 -1
- package/src/grammar/plotComposition.js +152 -0
- package/src/grammar/pointShapes.js +18 -0
- package/src/grammar/scales/transformed.js +1 -1
- package/src/grammar/schemas/concreteGraphic.js +8 -1
- package/src/grammar/schemas/graphicBounds.js +8 -1
- package/src/grammar/schemas/graphicTree.js +18 -2
- package/src/grammar/schemas/semanticPath.js +3 -0
- package/src/grammar/strokeStyle.js +14 -8
- package/src/grammar/text.js +72 -0
- package/src/inspection.js +1 -1
- package/src/inspectionDescriptors.js +1 -1
- package/src/layout/composition.js +30 -4
- package/src/layout/compositionLegend.js +91 -0
- package/src/layout/facetHeaderTitles.js +1 -1
- package/src/layout/labels.js +55 -27
- package/src/layout/legend.js +3 -2
- package/src/layout/legendGroup.js +1 -1
- package/src/layout/legendItems.js +7 -6
- package/src/layout/legendLane.js +1 -1
- package/src/layout/legendText.js +1 -1
- package/src/materialization/adjacentSource.js +27 -0
- package/src/materialization/composition.js +165 -52
- package/src/materialization/compositionLegends.js +120 -0
- package/src/materialization/compositionScales.js +125 -0
- package/src/materialization/compositionTitle.js +96 -0
- package/src/materialization/facetGuides/edit.js +22 -2
- package/src/materialization/facetGuides/layout.js +1 -1
- package/src/materialization/facetGuides/owner.js +4 -3
- package/src/materialization/facetGuides/ownerLayout.js +12 -6
- package/src/materialization/facetGuides/placement.js +19 -22
- package/src/materialization/facetGuides/preparation.js +17 -5
- package/src/materialization/facetHeaders.js +5 -1
- package/src/materialization/facets.js +1 -1
- package/src/materialization/guides/layout.js +1 -1
- package/src/materialization/guides/resources.js +1 -1
- package/src/materialization/guides/visibility.js +105 -0
- package/src/materialization/legendClip.js +1 -1
- package/src/materialization/legendGroups.js +1 -1
- package/src/materialization/legendItemsOffset.js +68 -0
- package/src/materialization/legendSuppression.js +22 -0
- package/src/materialization/legends.js +3 -2
- package/src/materialization/marks/capabilities.js +4 -0
- package/src/materialization/marks/index.js +5 -1
- package/src/materialization/marks/pathOrder.js +1 -1
- package/src/materialization/marks/policies.js +2 -1
- package/src/materialization/overlayItems.js +154 -0
- package/src/materialization/scales/policies/binnedPosition.js +43 -5
- package/src/materialization/scales/policies/series.js +12 -4
- package/src/materialization/scales/resolve.js +2 -0
- package/src/materialization/selection/items/bar.js +21 -3
- package/src/materialization/selection/items/common.js +18 -5
- package/src/materialization/selection/items/overlay.js +15 -0
- package/src/materialization/selection/items/path.js +1 -0
- package/src/materialization/selection/items/point.js +2 -3
- package/src/materialization/selection/items/tick.js +3 -12
- package/src/materialization/selection/path.js +2 -2
- package/src/materialization/selection/policies/bar.js +2 -0
- package/src/materialization/selection/policies/index.js +35 -1
- package/src/materialization/selection/policies/point.js +2 -0
- package/src/materialization/selection/policies/text.js +17 -0
- package/src/materialization/selection/rows.js +103 -0
- package/src/materialization/selection/state.js +19 -3
- package/src/materialization/selection/styles.js +8 -0
- package/src/materialization/text.js +6 -489
- package/src/materialization/textGeometry.js +503 -0
- package/src/materialization/textLines.js +20 -0
- package/src/persistence/validation.js +97 -24
- package/src/renderers/canvas/index.js +12 -7
- package/src/renderers/canvas/text.js +5 -5
- package/src/renderers/nativeBackend.js +25 -0
- package/src/renderers/pdf.js +3 -3
- package/src/renderers/png.js +4 -3
- package/src/renderers/svgSerialization.js +1 -643
- package/src/scene.js +5 -3
- package/src/selectors/plotComposition.js +115 -0
- package/src/selectors/selectionTargets.js +19 -0
- package/src/version.js +1 -1
- package/types/basic.d.ts +2 -2
- package/types/index.d.ts +11 -0
- package/types/inspection.d.ts +34 -0
- package/types/program.d.ts +553 -365
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { cloneAndFreeze, isPlainObject } from "../../core/immutable.js";
|
|
2
|
+
import { axisGraphicIds } from "../../selectors/axes.js";
|
|
3
|
+
import { legendResourcePolicies } from "./resources.js";
|
|
4
|
+
|
|
5
|
+
// Visibility is retained guide intent. Paint is derived, not a deletion of the
|
|
6
|
+
// guide or its typography. Keeping geometry also preserves relative anchors.
|
|
7
|
+
export function splitGuideVisibility(args, parts = ["line", "ticks", "labels", "title"]) {
|
|
8
|
+
if (!isPlainObject(args)) return { args, visibility: {} };
|
|
9
|
+
if (args.ticksAndLabels !== undefined && (args.ticks !== undefined || args.labels !== undefined)) throw new Error("Axis edit cannot combine ticksAndLabels with ticks or labels.");
|
|
10
|
+
const out = { ...args }, visibility = {};
|
|
11
|
+
const take = (object, key) => {
|
|
12
|
+
if (!Object.hasOwn(object, "visible")) return;
|
|
13
|
+
if (typeof object.visible !== "boolean") throw new TypeError(`${key}.visible must be a boolean.`);
|
|
14
|
+
visibility[key] = object.visible; delete object.visible;
|
|
15
|
+
};
|
|
16
|
+
take(out, "all");
|
|
17
|
+
for (const part of parts) if (isPlainObject(out[part])) {
|
|
18
|
+
out[part] = { ...out[part] }; take(out[part], part);
|
|
19
|
+
if (Object.hasOwn(visibility, part) && !Object.keys(out[part]).length) delete out[part];
|
|
20
|
+
}
|
|
21
|
+
if (isPlainObject(out.ticksAndLabels)) {
|
|
22
|
+
out.ticksAndLabels = { ...out.ticksAndLabels };
|
|
23
|
+
for (const part of ["ticks", "labels"]) if (isPlainObject(out.ticksAndLabels[part])) {
|
|
24
|
+
out.ticksAndLabels[part] = { ...out.ticksAndLabels[part] };
|
|
25
|
+
take(out.ticksAndLabels[part], part);
|
|
26
|
+
if (Object.hasOwn(visibility, part) && !Object.keys(out.ticksAndLabels[part]).length) delete out.ticksAndLabels[part];
|
|
27
|
+
}
|
|
28
|
+
if ((Object.hasOwn(visibility, "ticks") || Object.hasOwn(visibility, "labels")) && !Object.keys(out.ticksAndLabels).length) delete out.ticksAndLabels;
|
|
29
|
+
}
|
|
30
|
+
return { args: out, visibility };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function patchGuideVisibility(program, family, id, patch) {
|
|
34
|
+
if (!Object.keys(patch).length) return program;
|
|
35
|
+
const config = program.guideConfigs[family]?.[id];
|
|
36
|
+
if (!config) throw new Error(`Visibility requires an existing ${family} guide.`);
|
|
37
|
+
return program._withMaterializationConfig(["guides", family, id], {
|
|
38
|
+
...config, visibility: { ...config.visibility, ...patch }
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function paint(program, restore) {
|
|
43
|
+
const retained = program.materializationConfigs.guideVisibilityPaint ?? {};
|
|
44
|
+
const backups = {}, objects = { ...program.graphicSpec.objects };
|
|
45
|
+
let changed = false; const visited = new Set();
|
|
46
|
+
function visit(id, hide = false) {
|
|
47
|
+
const object = objects[id]; if (!object || visited.has(id)) return; visited.add(id);
|
|
48
|
+
const patch = (item, key) => {
|
|
49
|
+
const previous = retained[id]?.[key];
|
|
50
|
+
if (restore && previous === undefined || !restore && !hide) return item;
|
|
51
|
+
const properties = { ...item.properties };
|
|
52
|
+
if (restore) {
|
|
53
|
+
if (previous.opacity === null) delete properties.opacity;
|
|
54
|
+
else properties.opacity = previous.opacity;
|
|
55
|
+
} else {
|
|
56
|
+
(backups[id] ??= {})[key] = { opacity: properties.opacity ?? null };
|
|
57
|
+
properties.opacity = 0;
|
|
58
|
+
}
|
|
59
|
+
changed = true; return { ...item, properties };
|
|
60
|
+
};
|
|
61
|
+
let next = object;
|
|
62
|
+
if (object.items) next = { ...next, items: object.items.map(item => patch(item, item.id)) };
|
|
63
|
+
else if (object.type !== "canvas" && object.properties) next = patch(next, id);
|
|
64
|
+
objects[id] = next;
|
|
65
|
+
for (const child of object.children ?? []) visit(child, hide);
|
|
66
|
+
}
|
|
67
|
+
if (restore) {
|
|
68
|
+
if (!Object.keys(retained).length) return program;
|
|
69
|
+
for (const id of Object.keys(retained)) visit(id);
|
|
70
|
+
} else {
|
|
71
|
+
const parts = ["line", "ticks", "labels", "title"];
|
|
72
|
+
for (const [id, config] of Object.entries(program.guideConfigs.axis ?? {})) {
|
|
73
|
+
const channel = program.semanticSpec.guides.axis?.[id]?.channel ?? id;
|
|
74
|
+
if (channel === "parallel") continue;
|
|
75
|
+
const visibility = config.visibility ?? {};
|
|
76
|
+
axisGraphicIds(channel, id).forEach((graphic, index) => {
|
|
77
|
+
if (visibility.all === false || visibility[parts[index]] === false) visit(graphic, true);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
for (const policy of legendResourcePolicies()) {
|
|
81
|
+
const visibility = program.guideConfigs.legend?.[policy.kind]?.visibility ?? {};
|
|
82
|
+
for (const id of policy.graphicIds) if (visibility.all === false ||
|
|
83
|
+
id.endsWith("Labels") && visibility.labels === false ||
|
|
84
|
+
id.endsWith("Title") && visibility.titleStyle === false) visit(id, true);
|
|
85
|
+
}
|
|
86
|
+
for (const [direction, config] of Object.entries(program.guideConfigs.grid ?? {})) {
|
|
87
|
+
if (config.visible === false) visit({horizontal:"horizontalGridLines",vertical:"verticalGridLines",theta:"thetaGridLines",radius:"radialGridCircles",radial:"radialGridCircles"}[direction], true);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
let next = changed ? program._clone({ graphicSpec: cloneAndFreeze({ ...program.graphicSpec, objects }) }) : program;
|
|
91
|
+
return restore || !Object.keys(backups).length
|
|
92
|
+
? next._withoutMaterializationConfig(["guideVisibilityPaint"])
|
|
93
|
+
: next._withMaterializationConfig(["guideVisibilityPaint"], backups);
|
|
94
|
+
}
|
|
95
|
+
export function restoreGuideVisibilityPaint(program) { return paint(program, true); }
|
|
96
|
+
export function applyGuideVisibilityPaint(program) { return paint(restoreGuideVisibilityPaint(program), false); }
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
// Grids change paint/geometry inside the plot and do not need cross-guide reflow.
|
|
100
|
+
export function withGuideVisibilityPaint(implementation) {
|
|
101
|
+
return function (args) {
|
|
102
|
+
if (this.context.deferGuideLayoutValidation === true) return implementation.call(this, args);
|
|
103
|
+
return applyGuideVisibilityPaint(implementation.call(restoreGuideVisibilityPaint(this), args));
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -14,7 +14,7 @@ function roles(kind,config){
|
|
|
14
14
|
type:layer.type==='line'?'line':layer.type==='point'?config.channels.includes('shape')?'collection':'circle':hasRectRadius(layer)?'collection':'rect'
|
|
15
15
|
}));
|
|
16
16
|
const prefix=kind==='gradient'?'colorGradient':`${kind}Legend`;
|
|
17
|
-
return [{id:`${prefix}Symbols`,type:'circle',point:true}];
|
|
17
|
+
return [{id:`${prefix}Symbols`,type:config.shapeScale===undefined?'circle':'collection',point:true}];
|
|
18
18
|
}
|
|
19
19
|
function wrapped(owner){return owner?.children?.some(id=>id.startsWith(clipPrefix));}
|
|
20
20
|
export function hasLegendClipGraphics(program){
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{localLegendPosition as
|
|
1
|
+
import{localLegendPosition as u,localLegendPlacement as d,retainsLegendSlot as c}from"../grammar/legendGroups.js";import"../core/vocabulary.js";import{PLOT_LEGEND_POSITIONS as l}from"../layout/legend.js";function p(e,t){return e.guideConfigs.legendGroups?.[t]}function m(e,t){const n=d(t);if(e.context.facetLegendIntrinsic!==!0&&p(e,t.target)===void 0&&n?.position===t.position&&t.inferredOffset!==!1&&["left","right","top","bottom"].includes(t.position))return t;const o=n===void 0?p(e,t.target)??(e.context.facetLegendIntrinsic===!0?{placement:{position:t.position}}:void 0):{placement:n};if(o===void 0)return t;const i=o.placement.position,r=l.includes(i),s=r?t.direction==="horizontal"?"top":"right":i??(l.includes(t.position)?t.direction==="horizontal"?"top":"right":t.position??"right");return{...t,...n===void 0?{}:{blockLocalPosition:n.position},position:s,offset:0,align:"left",groupIntrinsic:!0,...r?{groupCorner:!0}:{},...s==="right"?{align:"center"}:{}}}export{m as intrinsicLegendConfig,p as legendGroup,d as localLegendPlacement,u as localLegendPosition,c as retainsLegendSlot};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { cloneAndFreeze } from "../core/immutable.js";
|
|
2
|
+
import { legendResourcePolicies } from "./guides/resources.js";
|
|
3
|
+
import { translatedProperties } from "./graphicTranslation.js";
|
|
4
|
+
import { unionConcreteGraphicBounds } from "../grammar/schemas/graphicBounds.js";
|
|
5
|
+
import { editLegendBorderBounds } from "./legendBorder.js";
|
|
6
|
+
|
|
7
|
+
// Layout always works with the native, unshifted block. Retain its concrete
|
|
8
|
+
// baseline so an unrelated transaction can restore it without regenerating
|
|
9
|
+
// marks or accumulating translations. Rebuilt resources supersede old paint.
|
|
10
|
+
export function restoreLegendItemsOffset(program) {
|
|
11
|
+
const retained = program.materializationConfigs.legendItemsPaint;
|
|
12
|
+
if (!retained) return program;
|
|
13
|
+
const objects = { ...program.graphicSpec.objects };
|
|
14
|
+
for (const [id, { before, after }] of Object.entries(retained)) {
|
|
15
|
+
if (objects[id] === after || JSON.stringify(objects[id]) === JSON.stringify(after)) objects[id] = before;
|
|
16
|
+
}
|
|
17
|
+
return program._clone({ graphicSpec: cloneAndFreeze({ ...program.graphicSpec, objects }) })
|
|
18
|
+
._withoutMaterializationConfig(["legendItemsPaint"]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function applyLegendItemsOffset(program) {
|
|
22
|
+
let next = restoreLegendItemsOffset(program);
|
|
23
|
+
if (!Object.values(next.guideConfigs.legend ?? {}).some(config => config.itemsOffset?.x || config.itemsOffset?.y)) return next;
|
|
24
|
+
const baseline = next.graphicSpec.objects, objects = { ...baseline }, retained = {};
|
|
25
|
+
const touched = new Set();
|
|
26
|
+
function shift(id, dx, dy) {
|
|
27
|
+
const object = objects[id];
|
|
28
|
+
if (!object) return;
|
|
29
|
+
touched.add(id);
|
|
30
|
+
const moved = { ...object };
|
|
31
|
+
if (object.properties) moved.properties = translatedProperties(object.type, object.properties, dx, dy);
|
|
32
|
+
if (object.clip) moved.clip = { ...object.clip, x: object.clip.x + dx, y: object.clip.y + dy };
|
|
33
|
+
if (object.items) moved.items = object.items.map(item => ({ ...item,
|
|
34
|
+
properties: translatedProperties(item.type ?? object.type, item.properties, dx, dy) }));
|
|
35
|
+
objects[id] = moved;
|
|
36
|
+
// Canvas children inherit the translated clipping window.
|
|
37
|
+
if (object.type !== "canvas") for (const child of object.children ?? []) shift(child, dx, dy);
|
|
38
|
+
}
|
|
39
|
+
const changed = [];
|
|
40
|
+
for (const policy of legendResourcePolicies()) {
|
|
41
|
+
const config = next.guideConfigs.legend?.[policy.kind];
|
|
42
|
+
const { x = 0, y = 0 } = config?.itemsOffset ?? {};
|
|
43
|
+
if (!x && !y) continue;
|
|
44
|
+
const body = policy.graphicIds.filter(id => !/(Title|Background)$/.test(id));
|
|
45
|
+
for (const id of body) shift(id, x, y);
|
|
46
|
+
changed.push({ policy, config });
|
|
47
|
+
}
|
|
48
|
+
if (!changed.length) return next;
|
|
49
|
+
next = next._clone({ graphicSpec: cloneAndFreeze({ ...next.graphicSpec, objects }) });
|
|
50
|
+
for (const { policy, config } of changed) {
|
|
51
|
+
const background = policy.graphicIds.find(id => id.endsWith("Background"));
|
|
52
|
+
if (!next.graphicSpec.objects[background]) continue;
|
|
53
|
+
touched.add(background);
|
|
54
|
+
const bounds = unionConcreteGraphicBounds(next.graphicSpec,
|
|
55
|
+
policy.graphicIds.filter(id => id !== background && next.graphicSpec.objects[id]), next.materializationConfigs.textMetrics);
|
|
56
|
+
if (!bounds) continue;
|
|
57
|
+
const padding = config.border?.padding ?? 0;
|
|
58
|
+
next = editLegendBorderBounds(next, background, { x: bounds.left - padding, y: bounds.top - padding,
|
|
59
|
+
width: bounds.right - bounds.left + padding * 2, height: bounds.bottom - bounds.top + padding * 2 });
|
|
60
|
+
}
|
|
61
|
+
for (const id of touched) {
|
|
62
|
+
const object = next.graphicSpec.objects[id];
|
|
63
|
+
if (baseline[id] && object !== baseline[id]) {
|
|
64
|
+
retained[id] = { before: baseline[id], after: object };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return next._withMaterializationConfig(["legendItemsPaint"], retained);
|
|
68
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { legendBlockChannels } from "../grammar/legendBlocks.js";
|
|
2
|
+
|
|
3
|
+
// Replayable authoring intent, independent of the current concrete legend blocks.
|
|
4
|
+
export function suppressLegendChannels(program, target, channels) {
|
|
5
|
+
const previous = program.guideConfigs.suppressedLegends?.[target]?.channels ?? [];
|
|
6
|
+
return program._withMaterializationConfig(["guides", "suppressedLegends", target],
|
|
7
|
+
{ channels: [...new Set([...previous, ...channels])] });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function restoreCreatedLegendChannels(program) {
|
|
11
|
+
let next = program;
|
|
12
|
+
for (const [target, { channels: suppressed }] of Object.entries(program.guideConfigs.suppressedLegends ?? {})) {
|
|
13
|
+
const active = new Set(Object.entries(program.guideConfigs.legend ?? {})
|
|
14
|
+
.filter(([, config]) => config.target === target)
|
|
15
|
+
.flatMap(([kind, config]) => legendBlockChannels(kind, config)));
|
|
16
|
+
const remaining = suppressed.filter(channel => !active.has(channel));
|
|
17
|
+
if (remaining.length === suppressed.length) continue;
|
|
18
|
+
next = remaining.length ? next._withMaterializationConfig(["guides", "suppressedLegends", target], { channels: remaining })
|
|
19
|
+
: next._withoutMaterializationConfig(["guides", "suppressedLegends", target]);
|
|
20
|
+
}
|
|
21
|
+
return next;
|
|
22
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import {colorSizeScaleIds} from "../grammar/colorSizeLegend.js";
|
|
1
2
|
import { findLayer } from "../selectors/layers.js";
|
|
2
3
|
import { LEGEND_CONFIG_KINDS } from "../core/vocabulary.js";
|
|
3
4
|
|
|
4
5
|
export function isOpacityLegendLayer(layer) {
|
|
5
|
-
return ["point", "line", "bar", "rect"].includes(layer.mark?.type) &&
|
|
6
|
+
return ["point", "line", "area", "bar", "rect"].includes(layer.mark?.type) &&
|
|
6
7
|
layer.encoding?.opacity?.scale !== undefined;
|
|
7
8
|
}
|
|
8
9
|
|
|
@@ -24,6 +25,6 @@ export function materializedLegendUsesScale(program, id) {
|
|
|
24
25
|
const order = program.semanticSpec.guides.legend?.[kind]?.order;
|
|
25
26
|
const linkedScale = order?.channel === undefined ? undefined
|
|
26
27
|
: findLayer(program, config.target)?.encoding?.[order.channel]?.scale;
|
|
27
|
-
return
|
|
28
|
+
return (kind === "colorSize" ? colorSizeScaleIds(config).includes(id) : config.scale === id || config.scales?.includes(id)) || linkedScale === id;
|
|
28
29
|
});
|
|
29
30
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isHorizontalLine } from "../../grammar/lineSeries.js";
|
|
1
2
|
import { findDataset } from "../../selectors/datasets.js";
|
|
2
3
|
import { findLayer } from "../../selectors/layers.js";
|
|
3
4
|
import { findCoordinate } from "../../selectors/coordinates.js";
|
|
@@ -37,6 +38,7 @@ export function canMaterializeTick(_program, layer) {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export function canMaterializeLine(program, layer) {
|
|
41
|
+
if (layer.derivedBindings?.overlay) return hasCartesianPositionScales(layer);
|
|
40
42
|
const parallel = layer.encoding?.parallel;
|
|
41
43
|
if (parallel !== undefined) {
|
|
42
44
|
const coordinate = findCoordinate(program, layer.coordinate);
|
|
@@ -45,6 +47,8 @@ export function canMaterializeLine(program, layer) {
|
|
|
45
47
|
parallel.dimensions?.length >= 2 &&
|
|
46
48
|
parallel.dimensions.every(dimension => dimension.scale !== undefined);
|
|
47
49
|
}
|
|
50
|
+
if (isHorizontalLine(layer)) return canMaterializeLine(program, { ...layer,
|
|
51
|
+
encoding: { ...layer.encoding, x: layer.encoding.y, y: layer.encoding.x } });
|
|
48
52
|
const dataset = findDataset(program, layer.data);
|
|
49
53
|
const interval = dataset?.transform?.some(item => item.type === "interval");
|
|
50
54
|
if (hasPolarPositionScales(layer)) {
|
|
@@ -36,6 +36,7 @@ export function getScaleConsumerMarkSteps(program, scaleIds) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export function getMarkRematerializationStep(program, layer) {
|
|
39
|
+
if (layer.derivedBindings?.overlay) return { op: "rematerializeOverlay", args: { id: layer.id } };
|
|
39
40
|
if (program.markConfigs?.[layer.id]?.statisticalReference !== undefined) {
|
|
40
41
|
return { op: "rematerializeStatisticalReference", args: { id: layer.id } };
|
|
41
42
|
}
|
|
@@ -63,7 +64,8 @@ export function getSourceDependentMarkSteps(program, sourceId) {
|
|
|
63
64
|
const statistical = program.markConfigs?.[layer.id]
|
|
64
65
|
?.statisticalReference?.source === sourceId;
|
|
65
66
|
const filteredCompanion = program.markConfigs?.[layer.id]?.markFilter?.owner === sourceId;
|
|
66
|
-
|
|
67
|
+
const overlay = layer.derivedBindings?.overlay?.source === sourceId;
|
|
68
|
+
return sourceOwned || statistical || filteredCompanion || overlay
|
|
67
69
|
? [getMarkMaterializationStep(program, layer) ??
|
|
68
70
|
getExistingMarkRematerializationStep(program, layer)].filter(
|
|
69
71
|
step => step !== undefined
|
|
@@ -104,6 +106,7 @@ export function getPositionEncodingMaterializationSteps(program, layer, scaleId)
|
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
export function getScaleConsumerMaterializationMode(layer, channel) {
|
|
109
|
+
if (layer.derivedBindings?.overlay) return "defer";
|
|
107
110
|
const policy = getMarkMaterializationPolicy(layer);
|
|
108
111
|
if (policy === undefined) return "direct";
|
|
109
112
|
if (POSITION_ENCODING_CHANNELS.includes(channel)) {
|
|
@@ -118,6 +121,7 @@ export function getScaleConsumerMaterializationMode(layer, channel) {
|
|
|
118
121
|
}
|
|
119
122
|
|
|
120
123
|
export function canDeferScaleConsumerApplication(layer) {
|
|
124
|
+
if (layer.derivedBindings?.overlay) return true;
|
|
121
125
|
return getMarkMaterializationPolicy(layer)?.scaleApplication.deferWithMark === true;
|
|
122
126
|
}
|
|
123
127
|
|
|
@@ -56,7 +56,7 @@ export function pathOrderCompatibility(program, layer) {
|
|
|
56
56
|
reason: "does not support generated error or regression paths"
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
|
-
if (layer.mark.type === "line" && isAggregate(layer.encoding?.y?.aggregate)) {
|
|
59
|
+
if (layer.mark.type === "line" && (isAggregate(layer.encoding?.y?.aggregate) || isAggregate(layer.encoding?.x?.aggregate))) {
|
|
60
60
|
return { compatible: false, reason: "does not support aggregate lines" };
|
|
61
61
|
}
|
|
62
62
|
return { compatible: true, dataset };
|
|
@@ -51,7 +51,8 @@ const MARK_MATERIALIZATION_POLICIES = Object.freeze({
|
|
|
51
51
|
acceptsResolvedScales: true,
|
|
52
52
|
positionEncoding: Object.freeze({ incomplete: "scale", scaleFirst: true }),
|
|
53
53
|
encoding: Object.freeze({
|
|
54
|
-
|
|
54
|
+
completeOnly: true,
|
|
55
|
+
sharedChannels: Object.freeze(["color", "stroke", "opacity"]),
|
|
55
56
|
skipRematerialization: isIntentionallyEmptyArea
|
|
56
57
|
}),
|
|
57
58
|
scaleApplication: Object.freeze({ default: "defer" })
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { findCoordinate } from "../selectors/coordinates.js";
|
|
2
|
+
import { requireLayer } from "../selectors/layers.js";
|
|
3
|
+
import { requireMaterializedDataset } from "../selectors/datasets.js";
|
|
4
|
+
import { resolveBarItems, resolveBarItemDefinitions } from "./selection/items/bar.js";
|
|
5
|
+
import { resolvePointItems } from "./selection/items/point.js";
|
|
6
|
+
import { applyItemMissingPolicy } from "../grammar/itemMissing.js";
|
|
7
|
+
import { resolveBarChannels, resolveBarColorLayout } from "../grammar/bars/policy.js";
|
|
8
|
+
import { overlayGroupFields, overlayPositionEncoding, overlaySourceReason } from "../grammar/overlay.js";
|
|
9
|
+
import { mapContinuousScaleValues, mapOrdinalPositionValues } from "../grammar/scales/index.js";
|
|
10
|
+
import { deriveLineSeries, resolveLineBins } from "../grammar/lineSeries.js";
|
|
11
|
+
import { deriveAreaSeries, deriveCenteredAreaSeries, deriveDensityAreaSeries, layoutDensityAreaSeries } from "../grammar/areaSeries.js";
|
|
12
|
+
import { findUpstreamTransform } from "./dataProvenance.js";
|
|
13
|
+
import { requireSemanticScale } from "../selectors/scales.js";
|
|
14
|
+
import { uniqueFields, ownFields, channelMapFromRow } from "./selection/items/common.js";
|
|
15
|
+
import { pointGraphicAnchor } from "../grammar/pointShapes.js";
|
|
16
|
+
import { mapScaleConsumerValues } from "./scales/map.js";
|
|
17
|
+
import { resolveDirectionValues } from "../grammar/direction.js";
|
|
18
|
+
|
|
19
|
+
export function overlaySegments(group, binding) {
|
|
20
|
+
const segments = [[]];
|
|
21
|
+
for (const point of group.points) {
|
|
22
|
+
const before = segments.at(-1).at(-1);
|
|
23
|
+
if (binding.missing === "break" && ((before?.bin && point.bin &&
|
|
24
|
+
Math.abs(before.bin[1] - point.bin[0]) > 1e-10) ||
|
|
25
|
+
(Number.isFinite(before?.categoryIndex) && Number.isFinite(point.categoryIndex) &&
|
|
26
|
+
Math.abs(point.categoryIndex - before.categoryIndex) > 1))) segments.push([]);
|
|
27
|
+
segments.at(-1).push(point);
|
|
28
|
+
}
|
|
29
|
+
return segments.filter(points => points.length);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sourcePointAnchor(program, source, item, graphics) {
|
|
33
|
+
const owner = program.graphicSpec.objects[source.id];
|
|
34
|
+
const graphic = graphics.get(item.graphicIds[0]);
|
|
35
|
+
const encoding = source.encoding.shape;
|
|
36
|
+
const shape = encoding?.scale ? mapScaleConsumerValues([item.fields[encoding.field]], program.resolvedScales[encoding.scale], "shape")[0]
|
|
37
|
+
: program.markConfigs[source.id].shape ?? "circle";
|
|
38
|
+
const angle = resolveDirectionValues(item.members, source.encoding.angle)?.[0];
|
|
39
|
+
return pointGraphicAnchor({ ...graphic, type: graphic.type ?? owner.type }, shape, angle);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function mapPosition(program, source, channel, value) {
|
|
43
|
+
const encoding = source.encoding[channel];
|
|
44
|
+
const scale = program.resolvedScales[encoding.scale];
|
|
45
|
+
const map = ["nominal", "ordinal"].includes(encoding.fieldType)
|
|
46
|
+
? mapOrdinalPositionValues : mapContinuousScaleValues;
|
|
47
|
+
return map([value], scale)[0];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function itemGroups(items, source, binding) {
|
|
51
|
+
const fields = overlayGroupFields(source);
|
|
52
|
+
const groups = new Map();
|
|
53
|
+
for (const item of items) {
|
|
54
|
+
const tuple = fields.map(field => item.fields[field] ?? item.members[0]?.[field]);
|
|
55
|
+
const key = binding.series === "total" ? "total" : JSON.stringify(tuple);
|
|
56
|
+
if (!groups.has(key)) groups.set(key, { key, members: [], points: [] });
|
|
57
|
+
const group = groups.get(key);
|
|
58
|
+
group.members.push(...item.members);
|
|
59
|
+
group.points.push(item);
|
|
60
|
+
}
|
|
61
|
+
return [...groups.values()];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function resolveOverlaySeries(program, binding, { geometry = true } = {}) {
|
|
65
|
+
const source = requireLayer(program, binding.source);
|
|
66
|
+
const dataset = applyItemMissingPolicy(source, requireMaterializedDataset(program, source.data));
|
|
67
|
+
const density = findUpstreamTransform(program, dataset, 'density');
|
|
68
|
+
const unsupported = overlaySourceReason(source, findCoordinate(program, source.coordinate)?.type, density);
|
|
69
|
+
if (unsupported) throw new Error(unsupported);
|
|
70
|
+
if (["bar", "point"].includes(source.mark.type)) {
|
|
71
|
+
const bar = source.mark.type === "bar";
|
|
72
|
+
if (!bar && !binding.orderBy) throw new Error("Connecting point results requires explicit orderBy x or y.");
|
|
73
|
+
const channels = bar ? resolveBarChannels(source) : { category: binding.orderBy, measure: binding.orderBy === "y" ? "x" : "y" };
|
|
74
|
+
// Scale domains need the new final item grain before source graphics have
|
|
75
|
+
// been rebuilt (for example, after facet filtering). Geometry consumers
|
|
76
|
+
// still resolve and validate the fully materialized source collections.
|
|
77
|
+
const items = geometry
|
|
78
|
+
? bar ? resolveBarItems(program, source, dataset, "item") : resolvePointItems(program, source, dataset)
|
|
79
|
+
: bar ? resolveBarItemDefinitions(program, source, dataset)
|
|
80
|
+
: dataset.values.map(row => ({ fields: ownFields(row), channels: channelMapFromRow(row, source), members: [row] }));
|
|
81
|
+
const graphics = bar || !geometry ? undefined : new Map(program.graphicSpec.objects[source.id].items.map(item => [item.id, item]));
|
|
82
|
+
const anchors = items.map(item => {
|
|
83
|
+
const p = item.properties;
|
|
84
|
+
const measure = item.channels[`${channels.measure}2`] ?? item.channels[channels.measure];
|
|
85
|
+
const point = !geometry ? item.channels : bar ? p : sourcePointAnchor(program, source, item, graphics);
|
|
86
|
+
const endpoint = bar && geometry ? mapPosition(program, source, channels.measure, measure) : point[channels.measure];
|
|
87
|
+
const x = geometry && bar && channels.category === "x" ? p.x + p.width / 2 : channels.measure === "x" ? endpoint : point.x;
|
|
88
|
+
const y = geometry && bar && channels.category === "y" ? p.y + p.height / 2 : channels.measure === "y" ? endpoint : point.y;
|
|
89
|
+
const category = item.channels[channels.category];
|
|
90
|
+
const categoryEnd = item.channels[`${channels.category}2`];
|
|
91
|
+
const categoryScale = program.resolvedScales[source.encoding[channels.category].scale];
|
|
92
|
+
const categoryValue = bar && source.encoding[channels.category].bin ? (category + categoryEnd) / 2 : category;
|
|
93
|
+
return { ...item, x, y, measure, category,
|
|
94
|
+
fields: { ...item.fields, ...(bar ? { [source.encoding[channels.measure].field]: measure,
|
|
95
|
+
[source.encoding[channels.category].field]: categoryValue } : {}) },
|
|
96
|
+
channels: { ...item.channels, ...(bar ? { [channels.measure]: measure, [channels.category]: categoryValue } : {}) },
|
|
97
|
+
...(["point", "band", "ordinal"].includes(categoryScale.type) ? { categoryIndex: categoryScale.domain.indexOf(category) } : {}),
|
|
98
|
+
...(bar && channels.category === "x" && source.encoding.x.bin ? { bin: [category, categoryEnd] } : {}) };
|
|
99
|
+
}).filter(item => !geometry || Number.isFinite(item.x) && Number.isFinite(item.y));
|
|
100
|
+
let groups = itemGroups(anchors, source, binding);
|
|
101
|
+
if (bar && binding.series === "total") {
|
|
102
|
+
if (overlayGroupFields(source).length && !["stack", "fill", "diverging"].includes(resolveBarColorLayout(source))) {
|
|
103
|
+
throw new Error("A total overlay requires stacked source series; grouped and overlapping bars have separate endpoints.");
|
|
104
|
+
}
|
|
105
|
+
const cells = new Map();
|
|
106
|
+
for (const item of anchors) {
|
|
107
|
+
const key = JSON.stringify([item.category, item.bin, item.measure < 0 ? "negative" : "positive"]);
|
|
108
|
+
const previous = cells.get(key);
|
|
109
|
+
if (!previous || Math.abs(item.measure) > Math.abs(previous.measure)) cells.set(key, item);
|
|
110
|
+
}
|
|
111
|
+
groups = [false, true].map(negative => ({ key: negative ? "negative-total" : "positive-total",
|
|
112
|
+
points: [...cells.values()].filter(item => (item.measure < 0) === negative), members: anchors.flatMap(item => item.members) }))
|
|
113
|
+
.filter(group => group.points.length);
|
|
114
|
+
}
|
|
115
|
+
const direction = binding.orderBy ?? channels.category;
|
|
116
|
+
for (const group of groups) {
|
|
117
|
+
// Y categories run upward in data space. Preserve the source scale's order.
|
|
118
|
+
const sign = direction === "y" ? -1 : 1;
|
|
119
|
+
group.points.sort((a, b) => sign * (a[direction] - b[direction]));
|
|
120
|
+
}
|
|
121
|
+
return groups;
|
|
122
|
+
}
|
|
123
|
+
const x = source.encoding.x;
|
|
124
|
+
const derived = source.mark.type === "line"
|
|
125
|
+
? deriveLineSeries(dataset.values, source, { scales: program.semanticSpec.scales,
|
|
126
|
+
xDomain: program.resolvedScales[x.scale]?.domain,
|
|
127
|
+
yDomain: program.resolvedScales[source.encoding.y.scale]?.domain,
|
|
128
|
+
...(x.bin ? { xBinBoundaries: resolveLineBins(dataset.values, source, requireSemanticScale(program, x.scale)).boundaries } : {}) })
|
|
129
|
+
: density ? layoutDensityAreaSeries(deriveDensityAreaSeries(dataset.values, source, density), source.layout?.mode ?? source.encoding.color?.layout ?? "overlay")
|
|
130
|
+
: source.encoding.y?.stack === "center" ? deriveCenteredAreaSeries(dataset.values, source)
|
|
131
|
+
: deriveAreaSeries(dataset.values, source, { xDomain: program.resolvedScales[x.scale]?.domain,
|
|
132
|
+
yDomain: program.resolvedScales[source.encoding.y.scale]?.domain });
|
|
133
|
+
if (binding.series === "total" && derived.series.length > 1) {
|
|
134
|
+
throw new Error("Total overlays currently require stacked bars; use source series for line or area results.");
|
|
135
|
+
}
|
|
136
|
+
function endpoint(point, channel) {
|
|
137
|
+
if (channel === "y" && point.upper !== undefined) return point.upper;
|
|
138
|
+
if (source.mark.type === "area" && (source.layout?.mode ?? "overlay") === "overlay" &&
|
|
139
|
+
Object.hasOwn(source.encoding[`${channel}2`] ?? {}, "datum")) return point[channel];
|
|
140
|
+
return point[`${channel}2`] ?? point[channel];
|
|
141
|
+
}
|
|
142
|
+
return derived.series.map((series, index) => {
|
|
143
|
+
const members = series.sourceIndices ? series.sourceIndices.map(i => dataset.values[i])
|
|
144
|
+
: dataset.values.filter(row => Object.entries(series.key).every(([field, value]) => row[field] === value));
|
|
145
|
+
return { key: JSON.stringify([series.key, index]), members,
|
|
146
|
+
points: series.values.map((point, i) => ({ key: JSON.stringify([series.key, i]), members,
|
|
147
|
+
fields: { ...uniqueFields(members), ...series.key,
|
|
148
|
+
[overlayPositionEncoding(source, "x").field]: endpoint(point, "x"),
|
|
149
|
+
[overlayPositionEncoding(source, "y").field]: endpoint(point, "y") },
|
|
150
|
+
x: mapPosition(program, source, "x", endpoint(point, "x")),
|
|
151
|
+
y: mapPosition(program, source, "y", endpoint(point, "y")),
|
|
152
|
+
channels: { x: endpoint(point, "x"), y: endpoint(point, "y") } })) };
|
|
153
|
+
});
|
|
154
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { isSourceOwnedText } from "../../../grammar/text.js";
|
|
2
|
-
import {
|
|
2
|
+
import { compositionChannel } from "../../../selectors/plotComposition.js";
|
|
3
|
+
import { findHistogramBinIndex, resolveHistogramBins } from "../../../grammar/histogram.js";
|
|
3
4
|
|
|
4
5
|
export function resolveBinnedPositionDomain({
|
|
5
6
|
valuesByConsumer,
|
|
7
|
+
program,
|
|
6
8
|
channel,
|
|
7
9
|
scale,
|
|
8
10
|
id
|
|
@@ -14,9 +16,21 @@ export function resolveBinnedPositionDomain({
|
|
|
14
16
|
);
|
|
15
17
|
if (binnedPositions.length === 0) return undefined;
|
|
16
18
|
const independentConsumers = valuesByConsumer.filter(({ consumer, scalePolicy }) =>
|
|
17
|
-
scalePolicy !== false && !isSourceOwnedText(consumer.layer)
|
|
19
|
+
scalePolicy !== false && !isSourceOwnedText(consumer.layer) && consumer.layer.mark?.type !== "tick"
|
|
18
20
|
);
|
|
19
|
-
|
|
21
|
+
const signature = consumer => {
|
|
22
|
+
const role = program && compositionChannel(program, consumer.encoding, consumer.layer.data);
|
|
23
|
+
return role && role.fieldType === "quantitative" ? JSON.stringify([
|
|
24
|
+
role.field ?? null, role.fields ?? null, role.measurement.unit, role.temporalUnit ?? null
|
|
25
|
+
]) : undefined;
|
|
26
|
+
};
|
|
27
|
+
const expected = signature(binnedPositions[0].consumer);
|
|
28
|
+
// Binning changes sampling, not the physical value units of a position.
|
|
29
|
+
// Raw points and compatible summary paths can share that coordinate while
|
|
30
|
+
// retaining their own observations and the binned mark's original bins.
|
|
31
|
+
const compatibleUnbinned = expected !== undefined && independentConsumers.every(({ consumer }) =>
|
|
32
|
+
signature(consumer) === expected);
|
|
33
|
+
if (channel !== "x" || (binnedPositions.length !== independentConsumers.length && !compatibleUnbinned)) {
|
|
20
34
|
throw new Error(
|
|
21
35
|
`Binned scale "${id}" cannot be shared with an unbinned consumer.`
|
|
22
36
|
);
|
|
@@ -27,11 +41,35 @@ export function resolveBinnedPositionDomain({
|
|
|
27
41
|
if (binDefinitions.size !== 1) {
|
|
28
42
|
throw new Error(`Binned scale "${id}" requires one shared bin definition.`);
|
|
29
43
|
}
|
|
30
|
-
|
|
44
|
+
const bins = resolveHistogramBins({
|
|
31
45
|
values: binnedPositions.flatMap(item => item.values).filter(value => value !== undefined),
|
|
32
46
|
bin: binnedPositions[0].consumer.encoding.bin,
|
|
33
47
|
domain: scale.domain,
|
|
34
48
|
nice: scale.nice ?? true,
|
|
35
49
|
zero: scale.zero ?? false
|
|
36
|
-
})
|
|
50
|
+
});
|
|
51
|
+
if (Array.isArray(scale.domain)) return bins.domain;
|
|
52
|
+
let [minimum, maximum] = bins.domain;
|
|
53
|
+
// A final-item filter freezes the bin partition so surviving observations
|
|
54
|
+
// are never rebinned. Those boundaries describe the partition, not a fixed
|
|
55
|
+
// display domain: auto scales follow the retained bins' complete intervals.
|
|
56
|
+
// An unfiltered consumer still contributes its full requested bin extent.
|
|
57
|
+
if (binnedPositions.every(({ consumer }) => program?.markConfigs[consumer.layer.id]?.markFilter)) {
|
|
58
|
+
let first = Infinity, last = -1;
|
|
59
|
+
for (const { values } of binnedPositions) for (const value of values) {
|
|
60
|
+
if (!Number.isFinite(value)) continue;
|
|
61
|
+
const index = findHistogramBinIndex(value, bins.boundaries);
|
|
62
|
+
if (index < 0) continue;
|
|
63
|
+
first = Math.min(first, index); last = Math.max(last, index);
|
|
64
|
+
}
|
|
65
|
+
if (last >= 0) {
|
|
66
|
+
minimum = bins.boundaries[first]; maximum = bins.boundaries[last + 1];
|
|
67
|
+
if (scale.zero) { minimum = Math.min(0, minimum); maximum = Math.max(0, maximum); }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const { values } of independentConsumers) for (const value of values) {
|
|
71
|
+
if (!Number.isFinite(value)) continue;
|
|
72
|
+
minimum = Math.min(minimum, value); maximum = Math.max(maximum, value);
|
|
73
|
+
}
|
|
74
|
+
return [minimum, maximum];
|
|
37
75
|
}
|
|
@@ -5,8 +5,10 @@ import {
|
|
|
5
5
|
resolveTransformedDomain
|
|
6
6
|
} from "../../../grammar/scales/index.js";
|
|
7
7
|
import { numericExtent } from "../../../grammar/numeric.js";
|
|
8
|
+
import { compositionChannel } from "../../../selectors/plotComposition.js";
|
|
8
9
|
|
|
9
10
|
export function resolveSeriesLayoutDomain({
|
|
11
|
+
program,
|
|
10
12
|
id,
|
|
11
13
|
scale,
|
|
12
14
|
valuesByConsumer,
|
|
@@ -15,7 +17,9 @@ export function resolveSeriesLayoutDomain({
|
|
|
15
17
|
if (!seriesLayouts.some(Boolean)) return undefined;
|
|
16
18
|
const activeLayouts = seriesLayouts.filter(Boolean);
|
|
17
19
|
const layouts = new Set(activeLayouts.map(item => item.layout));
|
|
18
|
-
|
|
20
|
+
// Independent native layers may use different layouts in the same value
|
|
21
|
+
// units. Only normalized/centered policies change the axis interpretation.
|
|
22
|
+
if (layouts.size !== 1 && [...layouts].some(layout => !["group", "overlay", "stack", "diverging"].includes(layout))) {
|
|
19
23
|
throw new Error(`Series layout scale "${id}" requires one layout policy.`);
|
|
20
24
|
}
|
|
21
25
|
const layoutConsumers = valuesByConsumer.filter(
|
|
@@ -36,8 +40,12 @@ export function resolveSeriesLayoutDomain({
|
|
|
36
40
|
layout === "overlay" ||
|
|
37
41
|
Object.hasOwn(directEncoding, "datum") ||
|
|
38
42
|
(
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
(directEncoding.field === layoutConsumer.encoding.field || program &&
|
|
44
|
+
compositionChannel(program, directEncoding, consumer.layer.data)?.field ===
|
|
45
|
+
compositionChannel(program, layoutConsumer.encoding, layoutConsumer.layer.data)?.field) && (
|
|
46
|
+
aggregate !== "count" || (directEncoding.aggregate === "count" &&
|
|
47
|
+
JSON.stringify(consumer.layer.encoding.x?.bin) === JSON.stringify(layoutConsumer.layer.encoding.x?.bin))
|
|
48
|
+
)
|
|
41
49
|
)
|
|
42
50
|
);
|
|
43
51
|
})
|
|
@@ -61,7 +69,7 @@ export function resolveSeriesLayoutDomain({
|
|
|
61
69
|
}
|
|
62
70
|
}
|
|
63
71
|
if (
|
|
64
|
-
["group", "overlay"].includes(
|
|
72
|
+
[...layouts].some(item => ["group", "overlay"].includes(item)) &&
|
|
65
73
|
scale.domain !== "auto" &&
|
|
66
74
|
(numericExtent(scale.domain)[0] > 0 || numericExtent(scale.domain)[1] < 0)
|
|
67
75
|
) {
|
|
@@ -325,6 +325,7 @@ export function resolveScaleMaterialization(options) {
|
|
|
325
325
|
const binnedDomain = isDiscretizedColor
|
|
326
326
|
? undefined
|
|
327
327
|
: resolveBinnedPositionDomain({
|
|
328
|
+
program: options.program,
|
|
328
329
|
valuesByConsumer,
|
|
329
330
|
channel,
|
|
330
331
|
scale,
|
|
@@ -334,6 +335,7 @@ export function resolveScaleMaterialization(options) {
|
|
|
334
335
|
const seriesDomain = isDiscretizedColor || binnedDomain !== undefined
|
|
335
336
|
? undefined
|
|
336
337
|
: resolveSeriesLayoutDomain({
|
|
338
|
+
program: options.program,
|
|
337
339
|
id,
|
|
338
340
|
scale,
|
|
339
341
|
valuesByConsumer,
|