Skip to content

auto margins #1722

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 29 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
1debd94
auto margins
Fil Jun 25, 2023
a4ebdee
progress save
Fil Jun 27, 2023
2c50736
fy (and flipped y/fy)
Fil Sep 14, 2023
79fa805
impact on tests
Fil Sep 14, 2023
d7916fc
autoMarginK signature
Fil Sep 21, 2023
cd88d7f
When margins are "auto", run the dimensions calculation twice if any …
Fil Sep 22, 2023
39e2852
fix tests
Fil Sep 22, 2023
353f045
changed plots
Fil Sep 22, 2023
a7f86ae
295
Fil Sep 22, 2023
9cc55bd
we need to know if it's y or fy, to apply a margin or facet margin
Fil Sep 23, 2023
d8559ab
fix for when the axis has x positioning
Fil Sep 23, 2023
df517c3
refactor
Fil Sep 25, 2023
277734e
no special keywords
Fil Sep 25, 2023
6e48b06
coerce tick label to string before calling defaultWidth
Fil Sep 26, 2023
4dc5624
monospace
Fil Sep 26, 2023
de4251e
simpler (we'll see later if we need to change the values for monospace)
Fil Sep 26, 2023
76a23be
skeleton of tests
Fil Sep 26, 2023
8b29b78
OK!
Fil Sep 27, 2023
bacd5b9
fix a bug with aspectRatio
Fil Sep 28, 2023
350c182
don't suppose that the mark that pilots autoMargin has an initializer
Fil Sep 28, 2023
165056d
add test for aspectRatio and margins
Fil Sep 28, 2023
015711f
nicer defaults
Fil Sep 28, 2023
63070d9
typo
Fil Sep 28, 2023
a8ee323
inline & clean up
Fil Sep 28, 2023
64538a8
auto-margin top and bottom (closes #1859)
Fil Oct 3, 2023
4bec5b5
adopts the new monospace relative width (#1880)
Fil Oct 12, 2023
cb3d714
fix tests
Fil Jul 29, 2024
bb905bd
Merge branch 'main' into fil/default-margins
Fil Aug 6, 2024
1f01c40
Merge branch 'main' into fil/default-margins
Fil Aug 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 106 additions & 8 deletions src/dimensions.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,50 @@
import {extent} from "d3";
import {extent, max} from "d3";
import {projectionAspectRatio} from "./projection.js";
import {isOrdinalScale} from "./scales.js";
import {offset} from "./style.js";
import {defaultWidth, monospaceWidth} from "./marks/text.js";
import {outerDimensions} from "./scales.js";
import {formatAxisLabel} from "./marks/axis.js";

const marginMedium = 60;
const marginLarge = 90;

// When axes have "auto" margins, we might need to adjust the margins, after
// seeing the actual tick labels. In that case we’ll compute the dimensions and
// scales a second time.
export function autoMarginK(
margin,
{scale, labelAnchor, label},
options,
mark,
stateByMark,
scales,
dimensions,
context
) {
const actualLabel = formatAxisLabel(scale, scales[scale], {...options, label});
let {data, facets, channels} = stateByMark.get(mark);
if (mark.initializer) ({channels} = mark.initializer(data, facets, {}, scales, dimensions, context));
if (scale === "y" || scale === "fy") {
const width = mark.monospace ? monospaceWidth : defaultWidth;
const labelPenalty = actualLabel && (labelAnchor === "center" || (labelAnchor == null && scales[scale].bandwidth));
const l = max(channels.text.value, (t) => (t ? width(`${t}`) : NaN)) + (labelPenalty ? 100 : 0);
const m = l >= 500 ? marginLarge : l >= 295 ? marginMedium : null;
return m === null
? options
: scale === "fy"
? {...options, facet: {[margin]: m, ...options.facet}}
: {[margin]: m, ...options};
}
// For the x scale, we bump the margin only if the axis uses multi-line ticks!
const re = new RegExp(/\n/);
const m = actualLabel && channels.text.value.some((d) => re.test(d)) ? 40 : null;
return m === null
? options
: scale === "fx"
? {...options, facet: {[margin]: m, ...options.facet}}
: {[margin]: m, ...options};
}

export function createDimensions(scales, marks, options = {}) {
// Compute the default margins: the maximum of the marks’ margins. While not
Expand All @@ -11,7 +54,29 @@ export function createDimensions(scales, marks, options = {}) {
marginBottomDefault = 0.5 + offset,
marginLeftDefault = 0.5 - offset;

for (const {marginTop, marginRight, marginBottom, marginLeft} of marks) {
// The left and right margins default to a value inferred from the y (and fy)
// scales, if present. Axis tick marks specify a minimum value for the margin,
// that might be auto when it needs to be set from the actual tick labels. In
// that case, we will compute the chart dimensions as if we used the default
// small margin, compute all the tick labels and check their lengths, then
// revise the dimensions if necessary.
const autoMargins = [];
for (const m of marks) {
let {
marginTop,
marginRight,
marginBottom,
marginLeft,
autoMarginTop,
autoMarginRight,
autoMarginBottom,
autoMarginLeft,
frameAnchor
} = m;
if (autoMarginTop) autoMargins.push(["marginTop", autoMarginTop, m]);
if (autoMarginRight && frameAnchor === "right") autoMargins.push(["marginRight", autoMarginRight, m]);
if (autoMarginBottom) autoMargins.push(["marginBottom", autoMarginBottom, m]);
if (autoMarginLeft && frameAnchor === "left") autoMargins.push(["marginLeft", autoMarginLeft, m]);
if (marginTop > marginTopDefault) marginTopDefault = marginTop;
if (marginRight > marginRightDefault) marginRightDefault = marginRight;
if (marginBottom > marginBottomDefault) marginBottomDefault = marginBottom;
Expand Down Expand Up @@ -41,9 +106,9 @@ export function createDimensions(scales, marks, options = {}) {
height = autoHeight(scales, options, {
width,
marginTopDefault,
marginRightDefault,
marginRight,
marginBottomDefault,
marginLeftDefault
marginLeft
}) + Math.max(0, marginTop - marginTopDefault + marginBottom - marginBottomDefault)
} = options;

Expand Down Expand Up @@ -84,13 +149,13 @@ export function createDimensions(scales, marks, options = {}) {
};
}

return dimensions;
return {dimensions, autoMargins};
}

function autoHeight(
{x, y, fy, fx},
{projection, aspectRatio},
{width, marginTopDefault, marginRightDefault, marginBottomDefault, marginLeftDefault}
{width, marginTopDefault, marginRight, marginBottomDefault, marginLeft}
) {
const nfy = fy ? fy.scale.domain().length || 1 : 1;

Expand All @@ -101,7 +166,7 @@ function autoHeight(
const nfx = fx ? fx.scale.domain().length : 1;
const far = ((1.1 * nfy - 0.1) / (1.1 * nfx - 0.1)) * ar; // 0.1 is default facet padding
const lar = Math.max(0.1, Math.min(10, far)); // clamp the aspect ratio to a “reasonable” value
return Math.round((width - marginLeftDefault - marginRightDefault) * lar + marginTopDefault + marginBottomDefault);
return Math.round((width - marginLeft - marginRight) * lar + marginTopDefault + marginBottomDefault);
}
const ny = y ? (isOrdinalScale(y) ? y.scale.domain().length || 1 : Math.max(7, 17 / nfy)) : 1;

Expand All @@ -112,7 +177,7 @@ function autoHeight(
const ratio = aspectRatioLength("y", y) / (aspectRatioLength("x", x) * aspectRatio);
const fxb = fx ? fx.scale.bandwidth() : 1;
const fyb = fy ? fy.scale.bandwidth() : 1;
const w = fxb * (width - marginLeftDefault - marginRightDefault) - x.insetLeft - x.insetRight;
const w = fxb * (width - marginLeft - marginRight) - x.insetLeft - x.insetRight;
return (ratio * w + y.insetTop + y.insetBottom) / fyb + marginTopDefault + marginBottomDefault;
}

Expand Down Expand Up @@ -146,3 +211,36 @@ function aspectRatioLength(k, scale) {
const [min, max] = extent(domain);
return Math.abs(transform(max) - transform(min));
}

// This differs from the other outerDimensions in that it accounts for rounding
// and outer padding in the facet scales; we want the frame to align exactly
// with the actual range, not the desired range.
export function actualDimensions({fx, fy}, dimensions) {
const {marginTop, marginRight, marginBottom, marginLeft, width, height} = outerDimensions(dimensions);
const fxr = fx && outerRange(fx);
const fyr = fy && outerRange(fy);
return {
marginTop: fy ? fyr[0] : marginTop,
marginRight: fx ? width - fxr[1] : marginRight,
marginBottom: fy ? height - fyr[1] : marginBottom,
marginLeft: fx ? fxr[0] : marginLeft,
// Some marks, namely the x- and y-axis labels, want to know what the
// desired (rather than actual) margins are for positioning.
inset: {
marginTop: dimensions.marginTop,
marginRight: dimensions.marginRight,
marginBottom: dimensions.marginBottom,
marginLeft: dimensions.marginLeft
},
width,
height
};
}

function outerRange(scale) {
const domain = scale.domain;
let x1 = scale.scale(domain[0]);
let x2 = scale.scale(domain[domain.length - 1]);
if (x2 < x1) [x1, x2] = [x2, x1];
return [x1, x2 + scale.scale.bandwidth()];
}
39 changes: 34 additions & 5 deletions src/marks/axis.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,9 @@ function axisKy(
x,
margin,
marginTop = margin === undefined ? 20 : margin,
marginRight = margin === undefined ? (anchor === "right" ? 40 : 0) : margin,
marginRight,
marginBottom = margin === undefined ? 20 : margin,
marginLeft = margin === undefined ? (anchor === "left" ? 40 : 0) : margin,
marginLeft,
label,
labelAnchor,
labelArrow,
Expand All @@ -97,6 +97,16 @@ function axisKy(
tickRotate = number(tickRotate);
if (labelAnchor !== undefined) labelAnchor = keyword(labelAnchor, "labelAnchor", ["center", "top", "bottom"]);
labelArrow = maybeLabelArrow(labelArrow);
const autoMarginRight = margin === undefined &&
marginRight === undefined &&
anchor === "right" &&
x == null && {scale: k, labelAnchor, label};
marginRight ??= margin === undefined ? (anchor === "right" ? 40 : 0) : margin;
const autoMarginLeft = margin === undefined &&
marginLeft === undefined &&
anchor === "left" &&
x == null && {scale: k, labelAnchor, label};
marginLeft ??= margin === undefined ? (anchor === "left" ? 40 : 0) : margin;
return marks(
tickSize && !isNoneish(stroke)
? axisTickKy(k, anchor, data, {
Expand Down Expand Up @@ -126,6 +136,8 @@ function axisKy(
marginRight,
marginBottom,
marginLeft,
autoMarginRight,
autoMarginLeft,
...options
})
: null,
Expand Down Expand Up @@ -182,9 +194,9 @@ function axisKx(
tickRotate,
y,
margin,
marginTop = margin === undefined ? (anchor === "top" ? 30 : 0) : margin,
marginTop,
marginRight = margin === undefined ? 20 : margin,
marginBottom = margin === undefined ? (anchor === "bottom" ? 30 : 0) : margin,
marginBottom,
marginLeft = margin === undefined ? 20 : margin,
label,
labelAnchor,
Expand All @@ -198,6 +210,16 @@ function axisKx(
tickRotate = number(tickRotate);
if (labelAnchor !== undefined) labelAnchor = keyword(labelAnchor, "labelAnchor", ["center", "left", "right"]);
labelArrow = maybeLabelArrow(labelArrow);
const autoMarginTop = margin === undefined &&
marginTop === undefined &&
anchor === "top" &&
y == null && {scale: k, labelAnchor, label};
marginTop ??= margin === undefined ? (anchor === "top" ? 30 : 0) : margin;
const autoMarginBottom = margin === undefined &&
marginBottom === undefined &&
anchor === "bottom" &&
y == null && {scale: k, labelAnchor, label};
marginBottom ??= margin === undefined ? (anchor === "bottom" ? 30 : 0) : margin;
return marks(
tickSize && !isNoneish(stroke)
? axisTickKx(k, anchor, data, {
Expand Down Expand Up @@ -227,6 +249,8 @@ function axisKx(
marginRight,
marginBottom,
marginLeft,
autoMarginTop,
autoMarginBottom,
...options
})
: null,
Expand Down Expand Up @@ -632,6 +656,11 @@ function axisMark(mark, k, data, properties, options, initialize) {
channels = {};
}
if (properties !== undefined) Object.assign(m, properties);
m.autoMarginLeft = options.autoMarginLeft;
m.autoMarginTop = options.autoMarginTop;
m.autoMarginRight = options.autoMarginRight;
m.autoMarginBottom = options.autoMarginBottom;
m.autoMarginLeft = options.autoMarginLeft;
if (m.clip === undefined) m.clip = false; // don’t clip axes by default
return m;
}
Expand Down Expand Up @@ -705,7 +734,7 @@ function inferFontVariant(scale) {

// Takes the scale label, and if this is not an ordinal scale and the label was
// inferred from an associated channel, adds an orientation-appropriate arrow.
function formatAxisLabel(k, scale, {anchor, label = scale.label, labelAnchor, labelArrow} = {}) {
export function formatAxisLabel(k, scale, {anchor, label = scale.label, labelAnchor, labelArrow} = {}) {
if (label == null || (label.inferred && hasTemporalDomain(scale) && /^(date|time|year)$/i.test(label))) return;
label = String(label); // coerce to a string after checking if inferred
if (labelArrow === "auto") labelArrow = (!scale.bandwidth || scale.interval) && !/[↑↓→←]/.test(label);
Expand Down
86 changes: 38 additions & 48 deletions src/plot.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {creator, select} from "d3";
import {createChannel, inferChannelScale} from "./channel.js";
import {createContext} from "./context.js";
import {createDimensions} from "./dimensions.js";
import {createDimensions, autoMarginK, actualDimensions} from "./dimensions.js";
import {createFacets, recreateFacets, facetExclude, facetGroups, facetTranslator, facetFilter} from "./facet.js";
import {pointer, pointerX, pointerY} from "./interactions/pointer.js";
import {createLegends, exposeLegends} from "./legends.js";
Expand All @@ -13,7 +13,7 @@ import {isColor, isIterable, isNone, isScaleOptions} from "./options.js";
import {dataify, lengthof, map, yes, maybeIntervalTransform, subarray} from "./options.js";
import {createProjection, getGeometryChannels, hasProjection} from "./projection.js";
import {createScales, createScaleFunctions, autoScaleRange, exposeScales} from "./scales.js";
import {innerDimensions, outerDimensions} from "./scales.js";
import {innerDimensions} from "./scales.js";
import {isPosition, registry as scaleRegistry} from "./scales/index.js";
import {applyInlineStyles, maybeClassName} from "./style.js";
import {initializer} from "./transforms/basic.js";
Expand Down Expand Up @@ -139,25 +139,13 @@ export function plot(options = {}) {
stateByMark.set(mark, {data, facets, channels});
}

// Initalize the scales and dimensions.
const scaleDescriptors = createScales(addScaleChannels(channelsByScale, stateByMark, options), options);
const dimensions = createDimensions(scaleDescriptors, marks, options);

autoScaleRange(scaleDescriptors, dimensions);

const scales = createScaleFunctions(scaleDescriptors);
const {fx, fy} = scales;
const subdimensions = fx || fy ? innerDimensions(scaleDescriptors, dimensions) : dimensions;
const superdimensions = fx || fy ? actualDimensions(scales, dimensions) : dimensions;

// Initialize the context.
const context = createContext(options);
const document = context.document;
const svg = creator("svg").call(document.documentElement);
let figure = svg; // replaced with the figure element, if any
context.ownerSVGElement = svg;
context.className = className;
context.projection = createProjection(options, subdimensions);

// Allows e.g. the axis mark to determine faceting lazily.
context.filterFacets = (data, channels) => {
Expand All @@ -171,6 +159,42 @@ export function plot(options = {}) {
return {...state, channels: {...state.channels, ...facetState?.channels}};
};

// Initialize the dimensions and scales. Needs a double take when the left or
// right margins are based on the y (and fy) actual tick labels.
const channels = addScaleChannels(channelsByScale, stateByMark, options);
let scaleDescriptors = createScales(channels, options);
let {dimensions, autoMargins} = createDimensions(scaleDescriptors, marks, options);
autoScaleRange(scaleDescriptors, dimensions); // !! mutates scales ranges…
let scales = createScaleFunctions(scaleDescriptors);
let {fx, fy} = scales;
let subdimensions = fx || fy ? innerDimensions(scaleDescriptors, dimensions) : dimensions;
let superdimensions = fx || fy ? actualDimensions(scaleDescriptors, dimensions) : dimensions;
context.projection = createProjection(options, subdimensions);

// Review the auto margins and create new scales if more space is needed.
const originalOptions = options;
for (const [margin, scale, mark] of autoMargins) {
options = autoMarginK(
margin,
scale,
options,
mark,
stateByMark,
scales,
mark.facet === "super" ? superdimensions : subdimensions,
context
);
}
if (options !== originalOptions) {
scaleDescriptors = createScales(channels, options);
dimensions = createDimensions(scaleDescriptors, marks, options).dimensions;
autoScaleRange(scaleDescriptors, dimensions);
({fx, fy} = scales = createScaleFunctions(scaleDescriptors));
subdimensions = fx || fy ? innerDimensions(scaleDescriptors, dimensions) : dimensions;
superdimensions = fx || fy ? actualDimensions(scaleDescriptors, dimensions) : dimensions;
context.projection = createProjection(options, subdimensions);
}

// Allows e.g. the pointer transform to support viewof.
context.dispatchValue = (value) => {
if (figure.value === value) return;
Expand Down Expand Up @@ -704,37 +728,3 @@ function inheritScaleLabels(newScales, scales) {
}
return newScales;
}

// This differs from the other outerDimensions in that it accounts for rounding
// and outer padding in the facet scales; we want the frame to align exactly
// with the actual range, not the desired range.
function actualDimensions({fx, fy}, dimensions) {
const {marginTop, marginRight, marginBottom, marginLeft, width, height} = outerDimensions(dimensions);
const fxr = fx && outerRange(fx);
const fyr = fy && outerRange(fy);
return {
marginTop: fy ? fyr[0] : marginTop,
marginRight: fx ? width - fxr[1] : marginRight,
marginBottom: fy ? height - fyr[1] : marginBottom,
marginLeft: fx ? fxr[0] : marginLeft,
// Some marks, namely the x- and y-axis labels, want to know what the
// desired (rather than actual) margins are for positioning.
inset: {
marginTop: dimensions.marginTop,
marginRight: dimensions.marginRight,
marginBottom: dimensions.marginBottom,
marginLeft: dimensions.marginLeft
},
width,
height
};
}

function outerRange(scale) {
const domain = scale.domain();
if (domain.length === 0) return [0, scale.bandwidth()];
let x1 = scale(domain[0]);
let x2 = scale(domain[domain.length - 1]);
if (x2 < x1) [x1, x2] = [x2, x1];
return [x1, x2 + scale.bandwidth()];
}
Loading