diff --git a/src/dimensions.js b/src/dimensions.js index ada0f64787..02d4a3e1ca 100644 --- a/src/dimensions.js +++ b/src/dimensions.js @@ -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 @@ -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; @@ -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; @@ -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; @@ -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; @@ -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; } @@ -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()]; +} diff --git a/src/marks/axis.js b/src/marks/axis.js index 3a0e644909..26178a0255 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -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, @@ -98,6 +98,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, { @@ -128,6 +138,8 @@ function axisKy( marginRight, marginBottom, marginLeft, + autoMarginRight, + autoMarginLeft, ariaLabel, ...options }) @@ -185,9 +197,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, @@ -202,6 +214,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, { @@ -232,6 +254,8 @@ function axisKx( marginRight, marginBottom, marginLeft, + autoMarginTop, + autoMarginBottom, ariaLabel, ...options }) @@ -646,6 +670,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; } @@ -719,7 +748,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); diff --git a/src/plot.js b/src/plot.js index 16976c2585..1ee25bc1f6 100644 --- a/src/plot.js +++ b/src/plot.js @@ -1,7 +1,7 @@ import {creator, geoPath, 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"; @@ -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, xyProjection} 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"; @@ -139,17 +139,6 @@ 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; @@ -157,7 +146,6 @@ export function plot(options = {}) { let figure = svg; // replaced with the figure element, if any context.ownerSVGElement = svg; context.className = className; - context.projection = createProjection(options, subdimensions); // A path generator for marks that want to draw GeoJSON. context.path = function () { @@ -176,6 +164,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; @@ -709,37 +733,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()]; -} diff --git a/test/marks/cell-test.js b/test/marks/cell-test.js index 0370357cb6..6f84c86713 100644 --- a/test/marks/cell-test.js +++ b/test/marks/cell-test.js @@ -1,5 +1,6 @@ import * as Plot from "@observablehq/plot"; import assert from "assert"; +import {JSDOM} from "jsdom"; it("cell() has the expected defaults", () => { const cell = Plot.cell(); @@ -121,8 +122,9 @@ it("cellY() defaults y to identity and x to null", () => { }); it("cell() is incompatible with a projection", () => { + const {window} = new JSDOM(""); assert.throws( - () => Plot.cell([]).plot({projection: "equal-earth"}), + () => Plot.cell([]).plot({projection: "equal-earth", document: window.document}), /scale incompatible with channel: projection !== band/ ); }); diff --git a/test/output/aaplCloseAxisMargins.svg b/test/output/aaplCloseAxisMargins.svg new file mode 100644 index 0000000000..29f02472c1 --- /dev/null +++ b/test/output/aaplCloseAxisMargins.svg @@ -0,0 +1,55 @@ + + + + + 2014 + 2015 + 2016 + 2017 + 2018 + + + + + + + 0 + 100 + + + Close ↑ + + + + 0 + 100 + + + + + \ No newline at end of file diff --git a/test/output/aaplCloseLabel.svg b/test/output/aaplCloseLabel.svg new file mode 100644 index 0000000000..c7f10ff3dd --- /dev/null +++ b/test/output/aaplCloseLabel.svg @@ -0,0 +1,123 @@ + + + + + 0 + 50 + + + ↑ Close + + + + 19May + 26 + 2Jun + 9 + 16 + 23 + 30 + 7Jul + 14 + 21 + 28 + 4Aug + + + Trading → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/aaplCloseLabelTop.svg b/test/output/aaplCloseLabelTop.svg new file mode 100644 index 0000000000..d6747a5e9d --- /dev/null +++ b/test/output/aaplCloseLabelTop.svg @@ -0,0 +1,123 @@ + + + + + 0 + 50 + + + ↑ Close + + + + May19 + 26 + Jun2 + 9 + 16 + 23 + 30 + Jul7 + 14 + 21 + 28 + Aug4 + + + Trading → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/athletesSortWeightLimit.svg b/test/output/athletesSortWeightLimit.svg index 249a8f3107..0e7930615a 100644 --- a/test/output/athletesSortWeightLimit.svg +++ b/test/output/athletesSortWeightLimit.svg @@ -14,160 +14,160 @@ } - LCA - CHA - TOG - ETH - BHU - CAM - TAN - PRK - COD - ERI + LCA + CHA + TOG + ETH + BHU + CAM + TAN + PRK + COD + ERI - - nationality + + nationality - 40 - 60 - 80 - 100 - 120 - 140 - 160 + 40 + 60 + 80 + 100 + 120 + 140 + 160 weight → - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + \ No newline at end of file diff --git a/test/output/autoAreaStackColor.svg b/test/output/autoAreaStackColor.svg index b7b47e58f2..b08ace289c 100644 --- a/test/output/autoAreaStackColor.svg +++ b/test/output/autoAreaStackColor.svg @@ -14,72 +14,62 @@ } - 0 - 2,000 - 4,000 - 6,000 - 8,000 - 10,000 - 12,000 - 14,000 + 0 + 2,000 + 4,000 + 6,000 + 8,000 + 10,000 + 12,000 + 14,000 - - ↑ unemployed + + ↑ unemployed - 2000 - 2001 - 2002 - 2003 - 2004 - 2005 - 2006 - 2007 - 2008 - 2009 - 2010 + 2000 + 2002 + 2004 + 2006 + 2008 + 2010 - - - - - - - - - - - - - - + + + + + + + + + + + + + + - + \ No newline at end of file diff --git a/test/output/autoBarColorReducer.svg b/test/output/autoBarColorReducer.svg index fc39a7d55f..86ff9ee2fa 100644 --- a/test/output/autoBarColorReducer.svg +++ b/test/output/autoBarColorReducer.svg @@ -14,48 +14,48 @@ } - Adelie - Chinstrap - Gentoo + Adelie + Chinstrap + Gentoo - - species + + species - 0 - 20 - 40 - 60 - 80 - 100 - 120 - 140 + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 Frequency → - - - + + + - + \ No newline at end of file diff --git a/test/output/autoBarMode.svg b/test/output/autoBarMode.svg index 33b85940f9..025b8c6f1b 100644 --- a/test/output/autoBarMode.svg +++ b/test/output/autoBarMode.svg @@ -14,35 +14,35 @@ } - Adelie - Chinstrap - Gentoo + Adelie + Chinstrap + Gentoo - - species + + species -