ggaction 0.0.12 → 0.0.15
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 +127 -0
- package/README.md +20 -11
- package/knowledge/action-card.schema.json +1 -1
- package/knowledge/action-cards.json +1 -1
- package/knowledge/action-cards.schema.json +1 -1
- package/knowledge/extension-authoring.md +2 -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 +10 -3
- package/src/actions/basic.js +4 -2
- package/src/actions/boxPlots/components.js +14 -4
- package/src/actions/boxPlots/edit.js +31 -5
- package/src/actions/boxPlots/materialize.js +13 -4
- package/src/actions/boxPlots/options.js +48 -16
- package/src/actions/canvas/actions.js +3 -1
- package/src/actions/canvas/fitting.js +211 -0
- package/src/actions/canvas/index.js +2 -0
- package/src/actions/categoryOrder/index.js +18 -6
- package/src/actions/charts/area.js +51 -0
- package/src/actions/charts/bar.js +13 -6
- package/src/actions/charts/beeswarm.js +70 -0
- package/src/actions/charts/density.js +48 -0
- package/src/actions/charts/ecdf.js +186 -0
- package/src/actions/charts/endpoints.js +473 -0
- package/src/actions/charts/heatmap.js +4 -3
- package/src/actions/charts/histogram.js +5 -4
- package/src/actions/charts/horizon.js +73 -0
- package/src/actions/charts/index.js +37 -0
- package/src/actions/charts/interval-regression.js +172 -0
- package/src/actions/charts/line.js +8 -6
- package/src/actions/charts/parallel.js +8 -2
- package/src/actions/charts/pie.js +52 -0
- package/src/actions/charts/polar.js +134 -0
- package/src/actions/charts/radar.js +305 -0
- package/src/actions/charts/radial.js +50 -0
- package/src/actions/charts/raincloud.js +422 -0
- package/src/actions/charts/rug-strip.js +283 -0
- package/src/actions/charts/scatter.js +11 -3
- package/src/actions/charts/shared.js +69 -10
- package/src/actions/composition/actions.js +129 -3
- package/src/actions/coordinates/actions.js +1 -1
- package/src/actions/coordinates/edit.js +76 -0
- package/src/actions/data/bin.js +29 -0
- package/src/actions/data/bin2d.js +57 -185
- package/src/actions/data/complete.js +26 -0
- package/src/actions/data/computed.js +24 -0
- package/src/actions/data/create.js +2 -2
- package/src/actions/data/density.js +12 -20
- package/src/actions/data/derived.js +111 -15
- package/src/actions/data/ecdf.js +38 -0
- package/src/actions/data/edit.js +539 -0
- package/src/actions/data/filter.js +226 -30
- package/src/actions/data/fold.js +24 -0
- package/src/actions/data/impute.js +26 -0
- package/src/actions/data/index.js +38 -0
- package/src/actions/data/interval.js +2 -0
- package/src/actions/data/intervalEdit.js +86 -4
- package/src/actions/data/normalize.js +27 -0
- package/src/actions/data/regression.js +19 -21
- package/src/actions/data/revisionBindings.js +145 -0
- package/src/actions/data/shared.js +20 -4
- package/src/actions/data/stack.js +26 -0
- package/src/actions/data/summary.js +26 -0
- package/src/actions/data/timeUnit.js +4 -1
- package/src/actions/data/window.js +1 -1
- package/src/actions/encodings/appearance.js +32 -13
- package/src/actions/encodings/barWidth.js +8 -0
- package/src/actions/encodings/basic.js +2 -0
- package/src/actions/encodings/channels.js +415 -0
- package/src/actions/encodings/color/continuous.js +19 -59
- package/src/actions/encodings/color/index.js +30 -23
- package/src/actions/encodings/color/layout.js +3 -97
- package/src/actions/encodings/color/policy.js +5 -8
- package/src/actions/encodings/density/resolve.js +37 -5
- package/src/actions/encodings/density.js +124 -34
- package/src/actions/encodings/histogram.js +3 -2
- package/src/actions/encodings/horizon/resolve.js +8 -4
- package/src/actions/encodings/horizon.js +18 -8
- package/src/actions/encodings/index.js +6 -0
- package/src/actions/encodings/offset.js +30 -29
- package/src/actions/encodings/parallel.js +2 -8
- package/src/actions/encodings/position/apply.js +63 -11
- package/src/actions/encodings/position/index.js +52 -6
- package/src/actions/encodings/position/policies/arc.js +14 -0
- package/src/actions/encodings/position/policies/area.js +4 -42
- package/src/actions/encodings/position/policies/bar.js +57 -13
- package/src/actions/encodings/position/resolve.js +88 -26
- package/src/actions/encodings/ranged.js +123 -97
- package/src/actions/encodings/remove.js +44 -34
- package/src/actions/encodings/ruleAppearance.js +24 -32
- package/src/actions/encodings/seriesLayout.js +105 -0
- package/src/actions/encodings/shared.js +107 -37
- package/src/actions/encodings/stroke.js +226 -0
- package/src/actions/encodings/strokeDash.js +9 -6
- package/src/actions/encodings/temporal.js +7 -0
- package/src/actions/encodings/text.js +30 -15
- package/src/actions/errorBands/create.js +41 -8
- package/src/actions/errorBands/edit.js +360 -25
- package/src/actions/errorBars/create.js +56 -17
- package/src/actions/errorBars/edit.js +372 -21
- package/src/actions/facets/actions.js +619 -41
- package/src/actions/facets/derive.js +139 -25
- package/src/actions/gradientPlots/components.js +43 -13
- package/src/actions/gradientPlots/edit.js +14 -4
- package/src/actions/gradientPlots/materialize.js +8 -8
- package/src/actions/gradientPlots/options.js +10 -2
- package/src/actions/guides/applicability.js +28 -19
- package/src/actions/guides/axes/axes.js +4 -3
- package/src/actions/guides/axes/axis.js +18 -18
- package/src/actions/guides/axes/components.js +43 -0
- package/src/actions/guides/axes/edit.js +16 -51
- package/src/actions/guides/axes/labels.js +184 -28
- package/src/actions/guides/axes/lines.js +13 -7
- package/src/actions/guides/axes/parallel/lifecycle.js +117 -0
- package/src/actions/guides/axes/parallel/policy.js +137 -0
- package/src/actions/guides/axes/parallel/resolve.js +50 -25
- package/src/actions/guides/axes/parallel.js +93 -217
- package/src/actions/guides/axes/policy.js +21 -67
- package/src/actions/guides/axes/tickGroups.js +15 -8
- package/src/actions/guides/axes/ticks.js +11 -6
- package/src/actions/guides/axes/titles.js +32 -11
- package/src/actions/guides/basic.js +2 -0
- package/src/actions/guides/facade.js +106 -0
- package/src/actions/guides/facadeAxes.js +200 -0
- package/src/actions/guides/facadeLegend.js +63 -0
- package/src/actions/guides/grids/grid.js +37 -33
- package/src/actions/guides/grids/resolve.js +17 -4
- package/src/actions/guides/guides.js +54 -31
- package/src/actions/guides/legends/blocks.js +426 -0
- package/src/actions/guides/legends/categorical/actions.js +309 -279
- package/src/actions/guides/legends/categorical/components.js +21 -17
- package/src/actions/guides/legends/categorical/index.js +37 -33
- package/src/actions/guides/legends/categorical/layout.js +123 -354
- package/src/actions/guides/legends/categorical/options.js +32 -16
- package/src/actions/guides/legends/categorical/recipes.js +114 -9
- package/src/actions/guides/legends/categorical/resolve.js +30 -15
- package/src/actions/guides/legends/categorical/symbols.js +87 -33
- package/src/actions/guides/legends/continuous/common.js +99 -26
- package/src/actions/guides/legends/continuous/gradient.js +120 -89
- package/src/actions/guides/legends/continuous/index.js +8 -2
- package/src/actions/guides/legends/continuous/interval.js +79 -95
- package/src/actions/guides/legends/continuous/opacity.js +113 -89
- package/src/actions/guides/legends/continuous/stroke.js +525 -0
- package/src/actions/guides/legends/creation.js +40 -0
- package/src/actions/guides/legends/edit.js +321 -90
- package/src/actions/guides/legends/focused.js +2 -2
- package/src/actions/guides/legends/index.js +2 -0
- package/src/actions/guides/legends/lane.js +149 -102
- package/src/actions/guides/legends/lifecycle.js +143 -0
- package/src/actions/guides/legends/remove.js +43 -78
- package/src/actions/guides/legends/sampling.js +140 -0
- package/src/actions/guides/legends/size.js +213 -116
- package/src/actions/guides/legends/strokeWidth.js +121 -73
- package/src/actions/guides/legends/target.js +123 -0
- package/src/actions/guides/legends/transition.js +192 -0
- package/src/actions/guides/polar/axes/facade.js +81 -66
- package/src/actions/guides/polar/axes/labels.js +28 -8
- package/src/actions/guides/polar/axes/lines.js +12 -5
- package/src/actions/guides/polar/axes/shared.js +22 -0
- package/src/actions/guides/polar/axes/ticks.js +6 -5
- package/src/actions/guides/polar/axes/titles.js +6 -5
- package/src/actions/guides/polar/grids.js +8 -8
- package/src/actions/guides/polar/resolve.js +12 -8
- package/src/actions/guides/reuse.js +44 -0
- package/src/actions/guides/tickValues.js +19 -21
- package/src/actions/index.js +15 -2
- package/src/actions/intervals/resolve.js +35 -15
- package/src/actions/marks/arc/actions.js +78 -20
- package/src/actions/marks/area/actions.js +51 -12
- package/src/actions/marks/area/materialize.js +22 -3
- package/src/actions/marks/bar/create.js +11 -2
- package/src/actions/marks/bar/edit.js +20 -3
- package/src/actions/marks/bar/materialize.js +33 -9
- package/src/actions/marks/index.js +14 -0
- package/src/actions/marks/line/actions.js +71 -113
- package/src/actions/marks/line/materialize.js +57 -56
- package/src/actions/marks/point/create.js +8 -2
- package/src/actions/marks/point/edit.js +29 -21
- package/src/actions/marks/point/index.js +1 -0
- package/src/actions/marks/point/jitter.js +3 -0
- package/src/actions/marks/point/materialize.js +154 -16
- package/src/actions/marks/point/packing.js +68 -0
- package/src/actions/marks/rect/actions.js +33 -12
- package/src/actions/marks/references.js +658 -0
- package/src/actions/marks/remove.js +87 -4
- package/src/actions/marks/rule/actions.js +77 -9
- package/src/actions/marks/rule/appearance.js +26 -0
- package/src/actions/marks/shared.js +13 -13
- package/src/actions/marks/text/actions.js +296 -13
- package/src/actions/marks/text/index.js +4 -0
- package/src/actions/marks/text/layout.js +156 -8
- package/src/actions/marks/text/removal.js +173 -0
- package/src/actions/marks/text/selection.js +73 -0
- package/src/actions/marks/tick/actions.js +22 -5
- package/src/actions/primitives/createGraphics.js +3 -1
- package/src/actions/primitives/editGraphics.js +26 -0
- package/src/actions/primitives/semantic.js +3 -2
- package/src/actions/primitives/semanticAction.js +120 -10
- package/src/actions/primitives/semanticValidation/guide.js +10 -0
- package/src/actions/primitives/semanticValidation/index.js +9 -1
- package/src/actions/primitives/semanticValidation/layer.js +23 -1
- package/src/actions/primitives/semanticValidation/scale.js +38 -8
- package/src/actions/regression/components.js +26 -11
- package/src/actions/regression/create.js +11 -3
- package/src/actions/regression/edit.js +40 -13
- package/src/actions/regression/resolve.js +1 -1
- package/src/actions/resources/index.js +7 -0
- package/src/actions/resources/remove.js +172 -0
- package/src/actions/scales/channels.js +212 -0
- package/src/actions/scales/consumers/common.js +21 -54
- package/src/actions/scales/consumers/families.js +28 -14
- package/src/actions/scales/consumers/index.js +42 -4
- package/src/actions/scales/consumers/seriesLayout.js +57 -12
- package/src/actions/scales/create.js +58 -26
- package/src/actions/scales/definitions.js +109 -39
- package/src/actions/scales/edit.js +101 -34
- package/src/actions/scales/editPolicy.js +95 -35
- package/src/actions/scales/index.js +6 -0
- package/src/actions/scales/materialize.js +14 -49
- package/src/actions/scales/offset.js +48 -0
- package/src/actions/scales/parallel.js +71 -0
- package/src/actions/scales/patch.js +1 -1
- package/src/actions/scales/preview.js +140 -0
- package/src/actions/scales/quantitativeColor.js +9 -5
- package/src/actions/selection/actions.js +154 -30
- package/src/actions/selection/index.js +3 -0
- package/src/actions/theme/actions.js +98 -0
- package/src/actions/theme/composition.js +238 -0
- package/src/actions/theme/index.js +16 -0
- package/src/actions/theme/reconcile.js +1130 -0
- package/src/actions/theme/state.js +160 -0
- package/src/actions/titles/actions.js +11 -10
- package/src/actions/titles/resolve.js +1 -40
- package/src/actions/violinPlots/create.js +90 -43
- package/src/actions/violinPlots/edit.js +192 -0
- package/src/actions/violinPlots/index.js +2 -0
- package/src/core/ChartProgram.js +7 -0
- package/src/core/action.js +32 -2
- package/src/core/compositionState.js +141 -8
- package/src/core/programState.js +20 -0
- package/src/core/resourceReferences.js +332 -0
- package/src/core/vocabulary.js +16 -3
- package/src/grammar/aggregate.js +66 -23
- package/src/grammar/arcs.js +58 -1
- package/src/grammar/areaEndpoints.js +46 -0
- package/src/grammar/areaSeries.js +87 -41
- package/src/grammar/bars/aggregate.js +37 -13
- package/src/grammar/bars/geometry.js +44 -0
- package/src/grammar/bars/policy.js +9 -7
- package/src/grammar/bin.js +226 -0
- package/src/grammar/categoryOrder.js +34 -0
- package/src/grammar/complete.js +279 -0
- package/src/grammar/computed.js +351 -0
- package/src/grammar/coordinates.js +37 -0
- package/src/grammar/density.js +239 -37
- package/src/grammar/displayLabels.js +57 -0
- package/src/grammar/ecdf.js +198 -0
- package/src/grammar/facets/dependencies.js +133 -1
- package/src/grammar/facets/guides.js +3 -0
- package/src/grammar/facets/index.js +123 -52
- package/src/grammar/facets/scales.js +15 -18
- package/src/grammar/fold.js +123 -0
- package/src/grammar/horizon.js +6 -4
- package/src/grammar/impute.js +340 -0
- package/src/grammar/interval.js +37 -20
- package/src/grammar/lineSeries.js +20 -63
- package/src/grammar/markFilter.js +22 -4
- package/src/grammar/markLabelSelection.js +57 -0
- package/src/grammar/markLabels.js +110 -0
- package/src/grammar/normalize.js +366 -0
- package/src/grammar/parallelAxisTitles.js +16 -0
- package/src/grammar/pathSeries.js +109 -0
- package/src/grammar/pointPacking.js +183 -0
- package/src/grammar/pointShapes.js +13 -4
- package/src/grammar/polar.js +90 -8
- package/src/grammar/positionDatum.js +12 -0
- package/src/grammar/rects.js +9 -1
- package/src/grammar/regression/derive.js +4 -0
- package/src/grammar/regression/index.js +8 -2
- package/src/grammar/regression/models.js +22 -7
- package/src/grammar/regression/parameters.js +63 -13
- package/src/grammar/rotation.js +29 -0
- package/src/grammar/roundedRect.js +180 -0
- package/src/grammar/rules.js +8 -21
- package/src/grammar/scales/color.js +23 -1
- package/src/grammar/scales/colorConsumers.js +57 -0
- package/src/grammar/scales/continuous.js +2 -2
- package/src/grammar/scales/definition.js +42 -5
- package/src/grammar/scales/discretized.js +11 -5
- package/src/grammar/scales/fields.js +38 -8
- package/src/grammar/scales/index.js +1 -0
- package/src/grammar/scales/mapping.js +2 -0
- package/src/grammar/scales/ordinal.js +12 -3
- package/src/grammar/scales/policies.js +2 -2
- package/src/grammar/scales/radial.js +55 -0
- package/src/grammar/scales/size.js +456 -0
- package/src/grammar/scales/types.js +1 -0
- package/src/grammar/schemas/concreteGraphic.js +14 -2
- package/src/grammar/schemas/graphic.js +15 -0
- package/src/grammar/schemas/graphicBounds.js +179 -30
- package/src/grammar/schemas/semanticPath.js +23 -4
- package/src/grammar/stack.js +170 -0
- package/src/grammar/statisticalReference.js +103 -0
- package/src/grammar/statistics/confidenceInterval.js +101 -0
- package/src/grammar/strokeStyle.js +77 -0
- package/src/grammar/summary.js +245 -0
- package/src/grammar/text.js +22 -16
- package/src/grammar/timeUnit.js +234 -13
- package/src/grammar/transformTopology.js +10 -0
- package/src/grammar/transforms.js +418 -16
- package/src/grammar/valueFormat.js +112 -0
- package/src/grammar/weightedStatistics.js +374 -0
- package/src/grammar/window.js +232 -21
- package/src/layout/aspect.js +80 -0
- package/src/layout/facets.js +100 -12
- package/src/layout/guideCollisions.js +12 -0
- package/src/layout/labels.js +343 -0
- package/src/layout/legend.js +2 -10
- package/src/layout/legendItems.js +102 -0
- package/src/layout/legendLane.js +100 -11
- package/src/materialization/bars/aggregate.js +56 -147
- package/src/materialization/bars/histogram.js +104 -20
- package/src/materialization/bars/ranged.js +19 -3
- package/src/materialization/bars/resolve.js +7 -4
- package/src/materialization/categorySlotOffset.js +40 -0
- package/src/materialization/composition.js +3 -1
- package/src/materialization/compositionSnapshot.js +1 -1
- package/src/materialization/coordinateBounds.js +204 -0
- package/src/materialization/dependencies.js +93 -16
- package/src/materialization/encodings.js +1 -1
- package/src/materialization/facetGuides/legacyCategorical.js +3 -0
- package/src/materialization/facetGuides/placement.js +8 -2
- package/src/materialization/facetGuides/preparation.js +12 -7
- package/src/materialization/facetHeaders.js +346 -0
- package/src/materialization/facets.js +23 -66
- package/src/materialization/guides/layout.js +57 -0
- package/src/materialization/guides/resources.js +28 -2
- package/src/materialization/legends.js +11 -2
- package/src/materialization/marks/capabilities.js +20 -5
- package/src/materialization/marks/grouping.js +52 -0
- package/src/materialization/marks/index.js +68 -27
- package/src/materialization/marks/policies.js +4 -1
- package/src/materialization/rect.js +55 -42
- package/src/materialization/rectConfig.js +5 -1
- package/src/materialization/rowEncoding.js +13 -13
- package/src/materialization/scaleGuideDependencies.js +8 -5
- package/src/materialization/scales/map.js +6 -3
- package/src/materialization/scales/policies/arc.js +42 -0
- package/src/materialization/scales/policies/binnedPosition.js +7 -4
- package/src/materialization/scales/policies/offset.js +50 -8
- package/src/materialization/scales/policies/series.js +5 -1
- package/src/materialization/scales/resolve.js +65 -17
- package/src/materialization/selection/compatibility.js +11 -0
- package/src/materialization/selection/items/arc.js +28 -4
- package/src/materialization/selection/items/bar.js +42 -71
- package/src/materialization/selection/items/common.js +27 -6
- package/src/materialization/selection/items/path.js +3 -13
- package/src/materialization/selection/items/rect.js +35 -8
- package/src/materialization/selection/path.js +1 -1
- package/src/materialization/selection/point.js +3 -1
- package/src/materialization/text.js +324 -14
- package/src/mcp/adapter.js +2 -2
- package/src/renderers/canvas/circle.js +2 -3
- package/src/renderers/canvas/line.js +2 -3
- package/src/renderers/canvas/native.js +4 -4
- package/src/renderers/canvas/path.js +2 -3
- package/src/renderers/canvas/rect.js +2 -3
- package/src/renderers/canvas/stroke.js +11 -0
- package/src/renderers/svg.js +19 -0
- package/src/selectors/datasets.js +44 -0
- package/src/selectors/layers.js +7 -2
- package/src/selectors/scales.js +6 -0
- package/src/theme/defaults.js +140 -0
- package/types/basic.d.ts +17 -2
- package/types/index.d.ts +211 -0
- package/types/program.d.ts +2182 -204
- package/src/materialization/bars/grouped.js +0 -144
package/src/layout/labels.js
CHANGED
|
@@ -19,6 +19,349 @@ const MAX_EXHAUSTIVE_OFFSET_STEPS = 28;
|
|
|
19
19
|
const MAX_SEARCH_DISPLACEMENT = 1_000_000;
|
|
20
20
|
const DISTANT_RING_COUNT = 16;
|
|
21
21
|
const DISTANT_ANGLE_COUNT = 32;
|
|
22
|
+
const PLACEMENT_OPTIONS = Object.freeze([
|
|
23
|
+
"anchor", "gap", "overflow", "leader"
|
|
24
|
+
]);
|
|
25
|
+
const PLACEMENT_LEADER_OPTIONS = Object.freeze(["stroke", "strokeWidth"]);
|
|
26
|
+
const SEMANTIC_ANCHORS = new Set([
|
|
27
|
+
"center", "insideStart", "insideEnd", "outsideStart", "outsideEnd"
|
|
28
|
+
]);
|
|
29
|
+
const OVERFLOW_POLICIES = new Set(["hide", "outside", "allow"]);
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_MARK_LABEL_PLACEMENT = cloneAndFreeze({
|
|
32
|
+
gap: 4,
|
|
33
|
+
overflow: "hide",
|
|
34
|
+
leader: false
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export function markLabelPlacementLeaderId(target) {
|
|
38
|
+
return `${target}-placement-leaders`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizePlacementLeader(value) {
|
|
42
|
+
if (value === undefined || value === false) return false;
|
|
43
|
+
validateOptionObject(value, PLACEMENT_LEADER_OPTIONS, "mark label placement leader");
|
|
44
|
+
const leader = { stroke: "#94a3b8", strokeWidth: 1, ...value };
|
|
45
|
+
if (typeof leader.stroke !== "string" || leader.stroke.length === 0) {
|
|
46
|
+
throw new TypeError("Mark label placement leader stroke must be a non-empty string.");
|
|
47
|
+
}
|
|
48
|
+
validateNonNegativeFinite(
|
|
49
|
+
leader.strokeWidth,
|
|
50
|
+
"Mark label placement leader strokeWidth"
|
|
51
|
+
);
|
|
52
|
+
return leader;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function normalizeMarkLabelPlacement(value) {
|
|
56
|
+
validateOptionObject(value, PLACEMENT_OPTIONS, "mark label placement", {
|
|
57
|
+
allowEmpty: false,
|
|
58
|
+
emptyError: TypeError
|
|
59
|
+
});
|
|
60
|
+
if (!SEMANTIC_ANCHORS.has(value.anchor)) {
|
|
61
|
+
throw new Error(`Unsupported mark label anchor "${value.anchor}".`);
|
|
62
|
+
}
|
|
63
|
+
const gap = value.gap ?? DEFAULT_MARK_LABEL_PLACEMENT.gap;
|
|
64
|
+
const overflowPolicy = value.overflow ?? DEFAULT_MARK_LABEL_PLACEMENT.overflow;
|
|
65
|
+
validateNonNegativeFinite(gap, "Mark label placement gap");
|
|
66
|
+
if (!OVERFLOW_POLICIES.has(overflowPolicy)) {
|
|
67
|
+
throw new Error(`Unsupported mark label overflow "${overflowPolicy}".`);
|
|
68
|
+
}
|
|
69
|
+
return cloneAndFreeze({
|
|
70
|
+
anchor: value.anchor,
|
|
71
|
+
gap,
|
|
72
|
+
overflow: overflowPolicy,
|
|
73
|
+
leader: normalizePlacementLeader(value.leader)
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function finitePoint(value, label) {
|
|
78
|
+
if (
|
|
79
|
+
!isPlainObject(value) ||
|
|
80
|
+
!Number.isFinite(value.x) ||
|
|
81
|
+
!Number.isFinite(value.y)
|
|
82
|
+
) {
|
|
83
|
+
throw new TypeError(`${label} requires finite x and y.`);
|
|
84
|
+
}
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function unitVector(value, label) {
|
|
89
|
+
finitePoint(value, label);
|
|
90
|
+
const length = Math.hypot(value.x, value.y);
|
|
91
|
+
if (!Number.isFinite(length) || length <= 0) {
|
|
92
|
+
throw new RangeError(`${label} requires a non-zero direction.`);
|
|
93
|
+
}
|
|
94
|
+
return { x: value.x / length, y: value.y / length };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function validateIntervalBounds(bounds) {
|
|
98
|
+
if (
|
|
99
|
+
!isPlainObject(bounds) ||
|
|
100
|
+
![bounds.left, bounds.right, bounds.top, bounds.bottom].every(Number.isFinite) ||
|
|
101
|
+
bounds.right < bounds.left ||
|
|
102
|
+
bounds.bottom < bounds.top
|
|
103
|
+
) {
|
|
104
|
+
throw new RangeError("Interval label placement requires ordered finite bounds.");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function validatePlacementGeometry(geometry, anchor) {
|
|
109
|
+
if (!isPlainObject(geometry)) {
|
|
110
|
+
throw new TypeError("Mark label placement requires source geometry.");
|
|
111
|
+
}
|
|
112
|
+
if (geometry.kind === "interval") {
|
|
113
|
+
finitePoint(geometry.center, "Interval label center");
|
|
114
|
+
for (const role of ["start", "end"]) {
|
|
115
|
+
finitePoint(geometry[role]?.point, `Interval label ${role}`);
|
|
116
|
+
unitVector(geometry[role]?.outward, `Interval label ${role}`);
|
|
117
|
+
}
|
|
118
|
+
validateIntervalBounds(geometry.bounds);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (geometry.kind === "arc") {
|
|
122
|
+
if (![geometry.centerX, geometry.centerY, geometry.startTheta,
|
|
123
|
+
geometry.endTheta, geometry.innerRadius, geometry.outerRadius]
|
|
124
|
+
.every(Number.isFinite)) {
|
|
125
|
+
throw new TypeError("Arc label placement requires finite sector geometry.");
|
|
126
|
+
}
|
|
127
|
+
if (
|
|
128
|
+
geometry.innerRadius < 0 ||
|
|
129
|
+
geometry.outerRadius <= geometry.innerRadius ||
|
|
130
|
+
geometry.startTheta === geometry.endTheta ||
|
|
131
|
+
Math.abs(geometry.endTheta - geometry.startTheta) > 360
|
|
132
|
+
) {
|
|
133
|
+
throw new RangeError("Arc label placement requires an ordered annular sector.");
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (geometry.kind === "point") {
|
|
138
|
+
finitePoint(geometry.center, "Point label center");
|
|
139
|
+
if (anchor === "outsideEnd") {
|
|
140
|
+
finitePoint(geometry.end?.point, "Polar Point label boundary");
|
|
141
|
+
unitVector(geometry.end?.outward, "Polar Point label direction");
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
throw new Error(`Unsupported mark label placement geometry "${geometry.kind}".`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function assertMarkLabelPlacementSupport({ markType, coordinateType, intervalAxis }, placement) {
|
|
149
|
+
const anchor = placement.anchor;
|
|
150
|
+
if (markType === "bar" || markType === "arc") return placement;
|
|
151
|
+
if (markType === "rect") {
|
|
152
|
+
if (anchor === "center" || intervalAxis !== undefined) return placement;
|
|
153
|
+
throw new Error("Rect semantic label placement requires one explicit interval axis.");
|
|
154
|
+
}
|
|
155
|
+
if (markType === "point") {
|
|
156
|
+
if (anchor === "center" || (coordinateType === "polar" && anchor === "outsideEnd")) {
|
|
157
|
+
return placement;
|
|
158
|
+
}
|
|
159
|
+
throw new Error(
|
|
160
|
+
coordinateType === "polar"
|
|
161
|
+
? `Polar Point does not support mark label anchor "${anchor}".`
|
|
162
|
+
: `Point does not support mark label anchor "${anchor}".`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (markType === "line") {
|
|
166
|
+
throw new Error("Line labels retain the endpoint API and do not accept semantic placement objects.");
|
|
167
|
+
}
|
|
168
|
+
if (markType === "rule") {
|
|
169
|
+
throw new Error("Rule labels do not support semantic placement objects.");
|
|
170
|
+
}
|
|
171
|
+
throw new Error(`Mark type "${markType}" does not support semantic label placement.`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function localTextBounds(text) {
|
|
175
|
+
return resolveTextBounds({ ...text, x: 0, y: 0 });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function projectionRange(bounds, direction) {
|
|
179
|
+
const values = [
|
|
180
|
+
{ x: bounds.left, y: bounds.top },
|
|
181
|
+
{ x: bounds.right, y: bounds.top },
|
|
182
|
+
{ x: bounds.right, y: bounds.bottom },
|
|
183
|
+
{ x: bounds.left, y: bounds.bottom }
|
|
184
|
+
].map(point => point.x * direction.x + point.y * direction.y);
|
|
185
|
+
return { minimum: Math.min(...values), maximum: Math.max(...values) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function boundaryCandidate(boundary, direction, gap, text) {
|
|
189
|
+
const resolvedDirection = unitVector(direction, "Mark label placement direction");
|
|
190
|
+
const support = projectionRange(localTextBounds(text), resolvedDirection);
|
|
191
|
+
const distance = gap - support.minimum;
|
|
192
|
+
return {
|
|
193
|
+
x: boundary.x + resolvedDirection.x * distance + (text.dx ?? 0),
|
|
194
|
+
y: boundary.y + resolvedDirection.y * distance + (text.dy ?? 0),
|
|
195
|
+
sourceX: boundary.x,
|
|
196
|
+
sourceY: boundary.y
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function centerCandidate(center, text) {
|
|
201
|
+
return {
|
|
202
|
+
x: center.x + (text.dx ?? 0),
|
|
203
|
+
y: center.y + (text.dy ?? 0),
|
|
204
|
+
sourceX: center.x,
|
|
205
|
+
sourceY: center.y
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function candidateBounds(candidate, text) {
|
|
210
|
+
return resolveTextBounds({ ...text, x: candidate.x, y: candidate.y });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function intervalContains(bounds, container) {
|
|
214
|
+
const epsilon = 1e-9;
|
|
215
|
+
return bounds.left >= container.left - epsilon &&
|
|
216
|
+
bounds.right <= container.right + epsilon &&
|
|
217
|
+
bounds.top >= container.top - epsilon &&
|
|
218
|
+
bounds.bottom <= container.bottom + epsilon;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function distanceToRectangle(centerX, centerY, bounds) {
|
|
222
|
+
const x = Math.max(bounds.left, Math.min(centerX, bounds.right));
|
|
223
|
+
const y = Math.max(bounds.top, Math.min(centerY, bounds.bottom));
|
|
224
|
+
return Math.hypot(x - centerX, y - centerY);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function thetaForPoint(point, geometry) {
|
|
228
|
+
return Math.atan2(point.x - geometry.centerX, geometry.centerY - point.y) *
|
|
229
|
+
180 / Math.PI;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function angleWithin(theta, start, end) {
|
|
233
|
+
const sweep = end - start;
|
|
234
|
+
if (Math.abs(sweep) >= 360 - 1e-9) return true;
|
|
235
|
+
const direction = Math.sign(sweep);
|
|
236
|
+
const progress = ((direction * (theta - start)) % 360 + 360) % 360;
|
|
237
|
+
return progress <= Math.abs(sweep) + 1e-9;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function arcContains(bounds, geometry) {
|
|
241
|
+
const points = [
|
|
242
|
+
{ x: bounds.left, y: bounds.top },
|
|
243
|
+
{ x: bounds.right, y: bounds.top },
|
|
244
|
+
{ x: bounds.right, y: bounds.bottom },
|
|
245
|
+
{ x: bounds.left, y: bounds.bottom },
|
|
246
|
+
{ x: (bounds.left + bounds.right) / 2, y: bounds.top },
|
|
247
|
+
{ x: bounds.right, y: (bounds.top + bounds.bottom) / 2 },
|
|
248
|
+
{ x: (bounds.left + bounds.right) / 2, y: bounds.bottom },
|
|
249
|
+
{ x: bounds.left, y: (bounds.top + bounds.bottom) / 2 }
|
|
250
|
+
];
|
|
251
|
+
const minimumRadius = distanceToRectangle(
|
|
252
|
+
geometry.centerX,
|
|
253
|
+
geometry.centerY,
|
|
254
|
+
bounds
|
|
255
|
+
);
|
|
256
|
+
const maximumRadius = Math.max(...points.slice(0, 4).map(point =>
|
|
257
|
+
Math.hypot(point.x - geometry.centerX, point.y - geometry.centerY)
|
|
258
|
+
));
|
|
259
|
+
const epsilon = 1e-9;
|
|
260
|
+
return minimumRadius >= geometry.innerRadius - epsilon &&
|
|
261
|
+
maximumRadius <= geometry.outerRadius + epsilon &&
|
|
262
|
+
points.every(point => angleWithin(
|
|
263
|
+
thetaForPoint(point, geometry),
|
|
264
|
+
geometry.startTheta,
|
|
265
|
+
geometry.endTheta
|
|
266
|
+
));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function arcRole(geometry, role) {
|
|
270
|
+
const theta = geometry.startTheta +
|
|
271
|
+
(geometry.endTheta - geometry.startTheta) / 2;
|
|
272
|
+
const radians = theta * Math.PI / 180;
|
|
273
|
+
const radial = { x: Math.sin(radians), y: -Math.cos(radians) };
|
|
274
|
+
const radius = role === "start" ? geometry.innerRadius : geometry.outerRadius;
|
|
275
|
+
return {
|
|
276
|
+
point: {
|
|
277
|
+
x: geometry.centerX + radial.x * radius,
|
|
278
|
+
y: geometry.centerY + radial.y * radius
|
|
279
|
+
},
|
|
280
|
+
outward: role === "start"
|
|
281
|
+
? { x: -radial.x, y: -radial.y }
|
|
282
|
+
: radial
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function geometryRole(geometry, role) {
|
|
287
|
+
return geometry.kind === "arc" ? arcRole(geometry, role) : geometry[role];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function geometryCenter(geometry) {
|
|
291
|
+
if (geometry.kind !== "arc") return geometry.center;
|
|
292
|
+
const role = arcRole(geometry, "end");
|
|
293
|
+
const radius = (geometry.innerRadius + geometry.outerRadius) / 2;
|
|
294
|
+
return {
|
|
295
|
+
x: geometry.centerX + role.outward.x * radius,
|
|
296
|
+
y: geometry.centerY + role.outward.y * radius
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function candidateForAnchor(anchor, geometry, placement, text) {
|
|
301
|
+
if (anchor === "center") return centerCandidate(geometryCenter(geometry), text);
|
|
302
|
+
const roleName = anchor.endsWith("Start") ? "start" : "end";
|
|
303
|
+
const role = geometryRole(geometry, roleName);
|
|
304
|
+
const outside = anchor.startsWith("outside");
|
|
305
|
+
const direction = outside
|
|
306
|
+
? role.outward
|
|
307
|
+
: { x: -role.outward.x, y: -role.outward.y };
|
|
308
|
+
return boundaryCandidate(role.point, direction, placement.gap, text);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function arcOutsideStartCrossesCenter(candidate, geometry, text) {
|
|
312
|
+
if (geometry.kind !== "arc") return false;
|
|
313
|
+
const radial = arcRole(geometry, "end").outward;
|
|
314
|
+
const projections = projectionRange(candidateBounds(candidate, text), radial);
|
|
315
|
+
const centerProjection = geometry.centerX * radial.x + geometry.centerY * radial.y;
|
|
316
|
+
return projections.minimum < centerProjection - 1e-9;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function fitsSource(candidate, geometry, text) {
|
|
320
|
+
if (geometry.kind === "point") return true;
|
|
321
|
+
const bounds = candidateBounds(candidate, text);
|
|
322
|
+
return geometry.kind === "arc"
|
|
323
|
+
? arcContains(bounds, geometry)
|
|
324
|
+
: intervalContains(bounds, geometry.bounds);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function outsideFallback(anchor) {
|
|
328
|
+
return anchor === "insideStart" ? "outsideStart" : "outsideEnd";
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function resolveMarkLabelPlacement({ placement, geometry, text } = {}) {
|
|
332
|
+
const normalized = normalizeMarkLabelPlacement(placement);
|
|
333
|
+
validatePlacementGeometry(geometry, normalized.anchor);
|
|
334
|
+
let anchor = normalized.anchor;
|
|
335
|
+
let candidate = candidateForAnchor(anchor, geometry, normalized, text);
|
|
336
|
+
const requiresFit = anchor === "center" || anchor.startsWith("inside");
|
|
337
|
+
if (requiresFit && !fitsSource(candidate, geometry, text)) {
|
|
338
|
+
if (normalized.overflow === "hide") {
|
|
339
|
+
return cloneAndFreeze({ visible: false, anchor, fallback: false });
|
|
340
|
+
}
|
|
341
|
+
if (normalized.overflow === "outside") {
|
|
342
|
+
anchor = outsideFallback(anchor);
|
|
343
|
+
candidate = candidateForAnchor(anchor, geometry, normalized, text);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (
|
|
347
|
+
anchor === "outsideStart" &&
|
|
348
|
+
normalized.overflow !== "allow" &&
|
|
349
|
+
arcOutsideStartCrossesCenter(candidate, geometry, text)
|
|
350
|
+
) {
|
|
351
|
+
return cloneAndFreeze({
|
|
352
|
+
visible: false,
|
|
353
|
+
anchor,
|
|
354
|
+
fallback: anchor !== normalized.anchor
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return cloneAndFreeze({
|
|
358
|
+
visible: true,
|
|
359
|
+
anchor,
|
|
360
|
+
fallback: anchor !== normalized.anchor,
|
|
361
|
+
...candidate,
|
|
362
|
+
bounds: candidateBounds(candidate, text)
|
|
363
|
+
});
|
|
364
|
+
}
|
|
22
365
|
|
|
23
366
|
export const DEFAULT_LABEL_LAYOUT_GEOMETRY = cloneAndFreeze({
|
|
24
367
|
axis: "both",
|
package/src/layout/legend.js
CHANGED
|
@@ -3,19 +3,11 @@ import {
|
|
|
3
3
|
measureTextWidth
|
|
4
4
|
} from "../core/textMetrics.js";
|
|
5
5
|
|
|
6
|
-
export function measureLegendSymbolHeight(config) {
|
|
7
|
-
return Math.max(...config.symbol.layers.map(layer => {
|
|
8
|
-
if (layer.type === "swatch") return layer.height;
|
|
9
|
-
if (layer.type === "point") return layer.size * 2;
|
|
10
|
-
return layer.lineWidth;
|
|
11
|
-
}));
|
|
12
|
-
}
|
|
13
|
-
|
|
14
6
|
export function measureLegendTextWidth(value, style = { fontSize: 12 }) {
|
|
15
7
|
return measureTextWidth(formatVisibleText(value), style);
|
|
16
8
|
}
|
|
17
9
|
|
|
18
|
-
export function resolveLegendGrid(config, width, count) {
|
|
10
|
+
export function resolveLegendGrid(config, width, count, symbolHeight) {
|
|
19
11
|
const labels = config.domain.map(formatVisibleText);
|
|
20
12
|
const itemWidths = labels.map(
|
|
21
13
|
label => width + config.labels.offset +
|
|
@@ -39,7 +31,7 @@ export function resolveLegendGrid(config, width, count) {
|
|
|
39
31
|
config.itemGap * Math.max(0, actualColumns - 1);
|
|
40
32
|
const rowHeight = Math.max(
|
|
41
33
|
config.labels.fontSize,
|
|
42
|
-
|
|
34
|
+
symbolHeight
|
|
43
35
|
);
|
|
44
36
|
const gridHeight = rowHeight * rowCount +
|
|
45
37
|
config.itemGap * Math.max(0, rowCount - 1);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { measureTextWidth, resolveTextBounds } from "../core/textMetrics.js";
|
|
2
|
+
import { alignLegendStart, resolveLegendGrid } from "./legend.js";
|
|
3
|
+
|
|
4
|
+
// Measure item content before choosing its edge. Family owners supply the
|
|
5
|
+
// sample dimensions and formatted labels; this module does not inspect scales.
|
|
6
|
+
export function resolveLegendItemLayout(plot, config, labels, symbol, canvas) {
|
|
7
|
+
const strokes = labels.map((_, index) => Array.isArray(symbol.strokeWidth)
|
|
8
|
+
? symbol.strokeWidth[index] : symbol.strokeWidth ?? 0);
|
|
9
|
+
const sampleBounds = labels.map((_, index) => symbol.itemBounds?.[index] ?? {
|
|
10
|
+
left: -strokes[index] / 2, right: symbol.width + strokes[index] / 2,
|
|
11
|
+
top: -symbol.height / 2 - strokes[index] / 2,
|
|
12
|
+
bottom: symbol.height / 2 + strokes[index] / 2
|
|
13
|
+
});
|
|
14
|
+
// Retain a family's minimum slot while reserving its complete painted extent.
|
|
15
|
+
const sampleLeft = Math.min(0, ...sampleBounds.map(bounds => bounds.left));
|
|
16
|
+
const sampleRight = Math.max(symbol.width, ...sampleBounds.map(bounds => bounds.right));
|
|
17
|
+
const sampleWidth = sampleRight - sampleLeft;
|
|
18
|
+
const sampleHeight = Math.max(symbol.height,
|
|
19
|
+
...sampleBounds.map(bounds => 2 * Math.max(-bounds.top, bounds.bottom)));
|
|
20
|
+
const side = ["left", "right"].includes(config.position);
|
|
21
|
+
const titleVisible = config.titleVisible !== false;
|
|
22
|
+
const titleWidth = titleVisible ? measureTextWidth(config.title, config.titleStyle) : 0;
|
|
23
|
+
const titleHeight = titleVisible ? config.titleStyle.fontSize : 0;
|
|
24
|
+
const itemWidth = sampleWidth + config.labels.offset + Math.max(...labels.map(
|
|
25
|
+
text => measureTextWidth(text, config.labels)
|
|
26
|
+
));
|
|
27
|
+
let symbolX;
|
|
28
|
+
let labelX;
|
|
29
|
+
let itemY;
|
|
30
|
+
let title;
|
|
31
|
+
if (config.layout === "legacy-bottom") {
|
|
32
|
+
const widths = labels.map(text => sampleWidth + config.labels.offset + measureTextWidth(text, config.labels));
|
|
33
|
+
const width = widths.reduce((sum, value) => sum + value, 0) + config.itemGap * (labels.length - 1);
|
|
34
|
+
const x = config.align === "center" ? (canvas.width - width) / 2 : alignLegendStart(plot, width, config.align);
|
|
35
|
+
let cursor = x;
|
|
36
|
+
symbolX = widths.map(width => {
|
|
37
|
+
const start = cursor - sampleLeft;
|
|
38
|
+
cursor += width + config.itemGap;
|
|
39
|
+
return start;
|
|
40
|
+
});
|
|
41
|
+
labelX = symbolX.map(value => value + sampleRight + config.labels.offset);
|
|
42
|
+
itemY = labels.map(() => canvas.height - 28);
|
|
43
|
+
title = { x: x + width / 2, y: canvas.height - 52, align: "center" };
|
|
44
|
+
} else if (side) {
|
|
45
|
+
const width = Math.max(itemWidth, titleWidth);
|
|
46
|
+
const x = config.position === "right" ? plot.x + plot.width + config.offset
|
|
47
|
+
: plot.x - config.offset - width;
|
|
48
|
+
const itemHeight = Math.max(sampleHeight, config.labels.fontSize);
|
|
49
|
+
const pitch = Math.max(config.itemGap, itemHeight);
|
|
50
|
+
symbolX = labels.map(() => x - sampleLeft);
|
|
51
|
+
labelX = labels.map(() => x + sampleWidth + config.labels.offset);
|
|
52
|
+
const firstY = Math.max(plot.y + 52, titleVisible
|
|
53
|
+
? plot.y + 20 + titleHeight / 2 + 12 + itemHeight / 2 : plot.y + 52);
|
|
54
|
+
itemY = labels.map((_, index) => firstY + index * pitch);
|
|
55
|
+
title = { x, y: plot.y + 20, align: "left" };
|
|
56
|
+
} else {
|
|
57
|
+
const grid = resolveLegendGrid({ ...config, domain: labels }, sampleWidth, labels.length, sampleHeight);
|
|
58
|
+
const inline = config.titlePosition === "left" && titleVisible;
|
|
59
|
+
const prefix = inline ? titleWidth + 20 : 0;
|
|
60
|
+
const width = inline ? prefix + grid.gridWidth : Math.max(titleWidth, grid.gridWidth);
|
|
61
|
+
const top = !inline && titleVisible ? titleHeight + 12 : 0;
|
|
62
|
+
const height = inline ? Math.max(titleHeight, grid.gridHeight) : top + grid.gridHeight;
|
|
63
|
+
const x = alignLegendStart(plot, width, config.align);
|
|
64
|
+
const y = config.position === "top" ? plot.y - config.offset - height
|
|
65
|
+
: plot.y + plot.height + config.offset;
|
|
66
|
+
const gridX = inline ? x + prefix : x + (width - grid.gridWidth) / 2;
|
|
67
|
+
const gridY = inline ? y + (height - grid.gridHeight) / 2 : y + top;
|
|
68
|
+
let cursor = 0;
|
|
69
|
+
const columns = grid.columnWidths.map(width => {
|
|
70
|
+
const start = cursor;
|
|
71
|
+
cursor += width + config.itemGap;
|
|
72
|
+
return start;
|
|
73
|
+
});
|
|
74
|
+
symbolX = grid.cells.map(cell => gridX + columns[cell.column] - sampleLeft);
|
|
75
|
+
labelX = symbolX.map(value => value + sampleRight + config.labels.offset);
|
|
76
|
+
itemY = grid.cells.map(cell => gridY + grid.rowHeight / 2 + cell.row * (grid.rowHeight + config.itemGap));
|
|
77
|
+
title = inline ? { x, y: y + height / 2, align: "left" }
|
|
78
|
+
: { x: x + width / 2, y: y + titleHeight / 2, align: "center" };
|
|
79
|
+
}
|
|
80
|
+
const textBounds = (x, y, text, style, align = "left") => resolveTextBounds({
|
|
81
|
+
x, y, text, ...style, textAlign: align, textBaseline: "middle"
|
|
82
|
+
});
|
|
83
|
+
const bounds = [
|
|
84
|
+
...(titleVisible ? [textBounds(title.x, title.y, config.title, config.titleStyle, title.align)] : []),
|
|
85
|
+
...labels.map((label, index) => textBounds(labelX[index], itemY[index], label, config.labels)),
|
|
86
|
+
...symbolX.map((x, index) => {
|
|
87
|
+
const local = sampleBounds[index];
|
|
88
|
+
return { left: x + local.left, right: x + local.right,
|
|
89
|
+
top: itemY[index] + local.top, bottom: itemY[index] + local.bottom };
|
|
90
|
+
})
|
|
91
|
+
];
|
|
92
|
+
if (config.layout === "legacy-bottom") {
|
|
93
|
+
const itemBounds = titleVisible ? bounds.slice(1) : bounds;
|
|
94
|
+
if (titleVisible && itemBounds.some(bound => bound.top < bounds[0].bottom)) {
|
|
95
|
+
throw new Error("Legacy bottom legend requires more space between its fixed title and item rows.");
|
|
96
|
+
}
|
|
97
|
+
if (bounds.some(bound => bound.top <= plot.y + plot.height)) {
|
|
98
|
+
throw new Error("Legacy bottom legend requires more bottom-margin space.");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { symbolX, labelX, itemY, title, bounds };
|
|
102
|
+
}
|
package/src/layout/legendLane.js
CHANGED
|
@@ -4,6 +4,28 @@ export const SIDE_LEGEND_SYMBOL_CENTER = 16;
|
|
|
4
4
|
export const SIDE_LEGEND_LABEL_START = 44;
|
|
5
5
|
export const HORIZONTAL_LEGEND_TITLE_ELEMENT_GAP = 12;
|
|
6
6
|
|
|
7
|
+
export function isHorizontalEdgeLegend(config) {
|
|
8
|
+
return config !== undefined &&
|
|
9
|
+
["top", "bottom"].includes(config.position) &&
|
|
10
|
+
config.layout !== "legacy-bottom";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveSingleHorizontalLegendPlacement({ plot, canvas, config, bounds }) {
|
|
14
|
+
if (!isHorizontalEdgeLegend(config)) {
|
|
15
|
+
throw new Error("Single legend placement requires a horizontal edge layout.");
|
|
16
|
+
}
|
|
17
|
+
const dx = config.align === "left" ? plot.x - bounds.left
|
|
18
|
+
: config.align === "right" ? plot.x + plot.width - bounds.right
|
|
19
|
+
: plot.x + plot.width / 2 - midpoint(bounds.left, bounds.right);
|
|
20
|
+
const dy = config.position === "top" ? plot.y - config.offset - bounds.bottom
|
|
21
|
+
: plot.y + plot.height + config.offset - bounds.top;
|
|
22
|
+
const occupied = translateBounds(bounds, dx, dy);
|
|
23
|
+
if (occupied.left < 0 || occupied.right > canvas.width || occupied.top < 0 || occupied.bottom > canvas.height) {
|
|
24
|
+
throw new Error(`Legend layout requires more ${config.position}-margin or Canvas space.`);
|
|
25
|
+
}
|
|
26
|
+
return { dx, dy, occupied };
|
|
27
|
+
}
|
|
28
|
+
|
|
7
29
|
function midpoint(start, end) {
|
|
8
30
|
const sum = start + end;
|
|
9
31
|
const result = Number.isFinite(sum) ? sum / 2 : start / 2 + end / 2;
|
|
@@ -25,24 +47,25 @@ function decoration(border) {
|
|
|
25
47
|
: border.padding + border.lineWidth / 2;
|
|
26
48
|
}
|
|
27
49
|
|
|
28
|
-
function horizontalExtent(blocks) {
|
|
50
|
+
function horizontalExtent(blocks, labelStart) {
|
|
29
51
|
let left = 0;
|
|
30
52
|
let right = 0;
|
|
31
53
|
for (const block of blocks) {
|
|
32
|
-
|
|
54
|
+
const inset = block.inset ?? 0;
|
|
55
|
+
if (block.title !== undefined) right = Math.max(right, block.title.width + inset);
|
|
33
56
|
left = Math.min(left,
|
|
34
|
-
SIDE_LEGEND_SYMBOL_CENTER + block.symbol.left - block.symbol.centerX
|
|
57
|
+
SIDE_LEGEND_SYMBOL_CENTER + block.symbol.left - block.symbol.centerX - inset
|
|
35
58
|
);
|
|
36
59
|
right = Math.max(
|
|
37
60
|
right,
|
|
38
|
-
SIDE_LEGEND_SYMBOL_CENTER + block.symbol.right - block.symbol.centerX,
|
|
39
|
-
|
|
61
|
+
SIDE_LEGEND_SYMBOL_CENTER + block.symbol.right - block.symbol.centerX + inset,
|
|
62
|
+
labelStart + block.labels.width + inset
|
|
40
63
|
);
|
|
41
64
|
}
|
|
42
65
|
return { left, right };
|
|
43
66
|
}
|
|
44
67
|
|
|
45
|
-
function placeBlock(block, cursor) {
|
|
68
|
+
function placeBlock(block, cursor, labelStart) {
|
|
46
69
|
let dy;
|
|
47
70
|
if (block.title === undefined) {
|
|
48
71
|
dy = cursor - block.bounds.top;
|
|
@@ -65,7 +88,7 @@ function placeBlock(block, cursor) {
|
|
|
65
88
|
? undefined
|
|
66
89
|
: -block.title.x,
|
|
67
90
|
symbolDx: SIDE_LEGEND_SYMBOL_CENTER - block.symbol.centerX,
|
|
68
|
-
labelDx:
|
|
91
|
+
labelDx: labelStart - block.labels.x,
|
|
69
92
|
bounds
|
|
70
93
|
};
|
|
71
94
|
}
|
|
@@ -87,7 +110,10 @@ export function resolveSideLegendLane({
|
|
|
87
110
|
}
|
|
88
111
|
const blocks = groups.flatMap(group => group.blocks);
|
|
89
112
|
if (blocks.length < 2) return undefined;
|
|
90
|
-
const
|
|
113
|
+
const labelStart = Math.max(SIDE_LEGEND_LABEL_START, ...blocks.map(
|
|
114
|
+
block => SIDE_LEGEND_SYMBOL_CENTER + Math.abs(block.labels.x - block.symbol.centerX)
|
|
115
|
+
));
|
|
116
|
+
const laneExtent = horizontalExtent(blocks, labelStart);
|
|
91
117
|
const offset = blocks.reduce(
|
|
92
118
|
(maximum, block) => Math.max(maximum, block.offset),
|
|
93
119
|
-Infinity
|
|
@@ -96,7 +122,7 @@ export function resolveSideLegendLane({
|
|
|
96
122
|
? plot.x + plot.width + offset
|
|
97
123
|
: plot.x - offset - laneExtent.right;
|
|
98
124
|
const symbolCenterX = titleStartX + SIDE_LEGEND_SYMBOL_CENTER;
|
|
99
|
-
const labelStartX = titleStartX +
|
|
125
|
+
const labelStartX = titleStartX + labelStart;
|
|
100
126
|
const first = blocks[0];
|
|
101
127
|
let cursor = first.title === undefined
|
|
102
128
|
? plot.y + 12
|
|
@@ -110,7 +136,7 @@ export function resolveSideLegendLane({
|
|
|
110
136
|
if (occupiedGroups.length > 0) cursor += inset;
|
|
111
137
|
const groupPlacements = [];
|
|
112
138
|
for (const block of group.blocks) {
|
|
113
|
-
const placement = placeBlock(block, cursor);
|
|
139
|
+
const placement = placeBlock(block, cursor, labelStart);
|
|
114
140
|
groupPlacements.push(placement);
|
|
115
141
|
placements.push({
|
|
116
142
|
...placement,
|
|
@@ -123,7 +149,7 @@ export function resolveSideLegendLane({
|
|
|
123
149
|
cursor = placement.bounds.bottom + SIDE_LEGEND_BLOCK_GAP;
|
|
124
150
|
}
|
|
125
151
|
const vertical = unionBounds(groupPlacements.map(item => item.bounds));
|
|
126
|
-
const groupExtent = horizontalExtent(group.blocks);
|
|
152
|
+
const groupExtent = horizontalExtent(group.blocks, labelStart);
|
|
127
153
|
const foreground = {
|
|
128
154
|
left: titleStartX + groupExtent.left,
|
|
129
155
|
right: titleStartX + groupExtent.right,
|
|
@@ -232,6 +258,13 @@ function normalizeHorizontalRow(entries) {
|
|
|
232
258
|
)
|
|
233
259
|
: commonTitleY + titleDescent + HORIZONTAL_LEGEND_TITLE_ELEMENT_GAP;
|
|
234
260
|
return entries.map(({ group, dx }) => {
|
|
261
|
+
if (group.atomic) {
|
|
262
|
+
const contentDy = elementAnchor - group.element.top;
|
|
263
|
+
const foreground = translateBounds(group.content, dx, contentDy);
|
|
264
|
+
return { id: group.id, dx, titleDy: contentDy, contentDy, foreground,
|
|
265
|
+
occupied: expandBounds(foreground, group.inset), padding: group.padding,
|
|
266
|
+
backgroundId: group.backgroundId };
|
|
267
|
+
}
|
|
235
268
|
const contentDy = commonTitleY === undefined
|
|
236
269
|
? elementAnchor - midpoint(
|
|
237
270
|
group.element.top,
|
|
@@ -350,3 +383,59 @@ export function resolveHorizontalLegendLane({
|
|
|
350
383
|
rowCount: packed.length
|
|
351
384
|
};
|
|
352
385
|
}
|
|
386
|
+
|
|
387
|
+
// Compose independently measured content blocks before the outer edge lane
|
|
388
|
+
// treats the whole group as one indivisible item.
|
|
389
|
+
export function resolveHorizontalLegendGroup({ edge, plot, canvas, groups,
|
|
390
|
+
align, offset, border, backgroundId, collisionBounds = [] }) {
|
|
391
|
+
const inset = decoration(border);
|
|
392
|
+
const innerPlot = { ...plot, x: plot.x + inset, width: plot.width - inset * 2 };
|
|
393
|
+
const packed = packHorizontalRows(groups, innerPlot);
|
|
394
|
+
const rows = packed.map(normalizeHorizontalRow);
|
|
395
|
+
let cursor = 0;
|
|
396
|
+
const placements = [];
|
|
397
|
+
for (const row of rows) {
|
|
398
|
+
const bounds = unionBounds(row.map(item => item.occupied));
|
|
399
|
+
const dy = edge === "top" ? cursor - bounds.bottom : cursor - bounds.top;
|
|
400
|
+
const translated = row.map(item => translateHorizontalPlacement(item, dy));
|
|
401
|
+
placements.push(...translated);
|
|
402
|
+
const occupied = unionBounds(translated.map(item => item.occupied));
|
|
403
|
+
cursor = edge === "top" ? occupied.top - HORIZONTAL_LEGEND_BLOCK_GAP
|
|
404
|
+
: occupied.bottom + HORIZONTAL_LEGEND_BLOCK_GAP;
|
|
405
|
+
}
|
|
406
|
+
const foreground = unionBounds(placements.map(item => item.occupied));
|
|
407
|
+
const occupied = expandBounds(foreground, inset);
|
|
408
|
+
const width = occupied.right - occupied.left;
|
|
409
|
+
const x = align === "left" ? plot.x : align === "right" ? plot.x + plot.width - width
|
|
410
|
+
: plot.x + (plot.width - width) / 2;
|
|
411
|
+
const dx = x - occupied.left;
|
|
412
|
+
const dy = edge === "top" ? plot.y - offset - occupied.bottom
|
|
413
|
+
: plot.y + plot.height + offset - occupied.top;
|
|
414
|
+
const finalBounds = translateBounds(occupied, dx, dy);
|
|
415
|
+
if (finalBounds.left < 0 || finalBounds.right > canvas.width ||
|
|
416
|
+
finalBounds.top < 0 || finalBounds.bottom > canvas.height) {
|
|
417
|
+
throw new Error(`Combined legend requires more ${edge}-margin or Canvas space.`);
|
|
418
|
+
}
|
|
419
|
+
if (collisionBounds.some(bounds => overlap(finalBounds, bounds))) {
|
|
420
|
+
throw new Error(`Combined ${edge} legend and ${edge === "top" ? "chart titles" : "x-axis guides"} require more margin space.`);
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
placements: placements.map(item => ({ ...item, dx: item.dx + dx,
|
|
424
|
+
titleDy: item.titleDy + dy, contentDy: item.contentDy + dy,
|
|
425
|
+
...(item.backgroundId === undefined ? {} : { background: {
|
|
426
|
+
id: item.backgroundId,
|
|
427
|
+
x: item.foreground.left + dx - item.padding,
|
|
428
|
+
y: item.foreground.top + dy - item.padding,
|
|
429
|
+
width: item.foreground.right - item.foreground.left + item.padding * 2,
|
|
430
|
+
height: item.foreground.bottom - item.foreground.top + item.padding * 2
|
|
431
|
+
} }) })),
|
|
432
|
+
occupied: finalBounds,
|
|
433
|
+
background: backgroundId === undefined ? undefined : {
|
|
434
|
+
id: backgroundId,
|
|
435
|
+
x: foreground.left + dx - border.padding,
|
|
436
|
+
y: foreground.top + dy - border.padding,
|
|
437
|
+
width: foreground.right - foreground.left + border.padding * 2,
|
|
438
|
+
height: foreground.bottom - foreground.top + border.padding * 2
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
}
|