Skip to content
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

auto margins #1722

Open
wants to merge 29 commits into
base: main
Choose a base branch
from
Open

auto margins #1722

wants to merge 29 commits into from

Conversation

Fil
Copy link
Contributor

@Fil Fil commented Jun 25, 2023

Alternative to #1714. The axis mark sets the value of marginLeft (etc.) not to a number, but to a special value ("auto", not a symbol if we want to keep this usable from outside); the actual numeric value of that special value is computed in createDimensions—where the scales’ types and domains are already materialized

TODO:

  • facet axes?
  • find a “good” heuristic (consider the scales’ domain, ticks, tickFormat…)
  • consider labelAnchor ("center" needs more space)
  • use approx. text metrics instead of string.length
  • account for the possibility of a centered axis label
  • fit & test monospace (see plot().dimensions() returns the chart's dimensions #1877 for easier tests?)

closes #1451
closes #383
closes #1859

@Fil Fil requested a review from mbostock June 25, 2023 16:12
@Fil Fil mentioned this pull request Jun 25, 2023
@Fil Fil mentioned this pull request Aug 21, 2023
6 tasks
@Fil
Copy link
Contributor Author

Fil commented Sep 14, 2023

This feels almost ready; needs a bit of manual testing (and more snapshot tests to capture more ranges of ticks?). In particular with the auto mark.

cc @tophtucker

@Fil Fil marked this pull request as ready for review September 14, 2023 15:23
@mbostock
Copy link
Member

Can you regenerate the snapshots so we can more easily review the impact of this change? Thank you. 🙏

@Fil
Copy link
Contributor Author

Fil commented Sep 14, 2023

Here's a playground to develop tests: https://observablehq.com/@observablehq/auto-margins-1722

Copy link
Member

@mbostock mbostock left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we’ll need to account for the tickFormat option when computing the automatic margins (even if it’s a custom function). It should be possible to compute the formatted tick strings, and then use their text metrics to more accurately infer the needed margins? Rather than assuming that numbers/dates/etc. are formatted a specific way?

@Fil
Copy link
Contributor Author

Fil commented Sep 14, 2023

Computing all the ticks is probably more adequate, but it is also quite a bit more complicated, since the ticks are computed from the instantiated scales. I've tried to approximate it by doing half-way measures (like, apply tickFormat if given… but on what ticks?) and it seemed worse. My conclusion was that either we have a light system (this one, to which we could add a few common cases such as type: "log"), or we go full-in and measure the actual text that will be rendered, by running a (fake) axisY mark with all the options?

@mbostock
Copy link
Member

Yes, it’s complicated. 🤔

@mbostock
Copy link
Member

As discussed, we want to initialize (but not fully render) the axis text mark for y scales; this will give us the text channel which we can use to infer appropriate left & right margins.

src/dimensions.js Outdated Show resolved Hide resolved
Copy link
Member

@mbostock mbostock left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we support auto for marginLeft and marginRight, we should also support it for marginTop and marginBottom (even if those margins are comparatively “dumb” and just have the current behavior).

src/mark.js Outdated
this.marginBottom = +marginBottom;
this.marginLeft = +marginLeft;
this.marginLeft = marginLeft === "auto" ? "auto" : +marginLeft;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs isAuto helper (for case-insensitivity).

@Fil
Copy link
Contributor Author

Fil commented Sep 22, 2023

I've implemented the new strategy. Still needs a bit of work:

  • clean up the code
  • fix the two test cases mentioned above
  • keyword check on "auto" (no more keywords!)
  • consider auto for top and bottom margins

@Fil
Copy link
Contributor Author

Fil commented Sep 27, 2023

I think this is good to go! Does it need documentation?

// scales a second time.
function autoMarginK(margin, {k: scale, labelAnchor, label}, options, mark, stateByMark, scales, dimensions, context) {
const {data, facets} = stateByMark.get(mark);
const {channels} = mark.initializer(data, facets, {}, scales, dimensions, context);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don’t you want to call mark.initialize here, not mark.initializer? The latter only applies to marks with an initializer, but here we want to initialize the mark (without assuming that it has an initializer).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need the initializer because the actual ticks depend on the instantiated scales. We know that the mark has an initializer because it can only be a (tick label) axis mark.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My point is that we should follow the same code path that Plot.plot follows:

  • call mark.initialize first
  • if mark.initializer exists, call that second

We shouldn’t make such a strong assumption about how the mark is implemented. Also, I wasn’t expecting that the mark would already be initialized (using stateByMark); I was thinking we would initialize it ourselves here. So that further complicates matters.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we call autoMarginK all the marks have been initialized already, which is why we can avoid the cost of re-initializing them. I can see how reaching for stateByMark can feel messy, though. 🥺

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the mark is already initialized here, why do we need to invoke its initializer again?

@@ -81,9 +81,9 @@ function axisKy(
x,
margin,
marginTop = margin === undefined ? 20 : margin,
marginRight = margin === undefined ? (anchor === "right" ? 40 : 0) : margin,
marginRight = margin === undefined ? (anchor === "right" ? (x == null ? k : 40) : 0) : margin,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Temporarily setting this to k feels a bit dirty… it’s effectively a hidden API if you set the marginRight option to "x" or "y", right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, but I didn't find a better way.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You’ll likely have to change how the defaults are initialized rather than using default initializers.

src/plot.js Outdated
Comment on lines 163 to 169
const {scaleDescriptors, scales, dimensions, subdimensions, superdimensions} = createDimensionsScales(
channels,
marks,
stateByMark,
options,
context
);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might want to re-inline the createDimensionsScales function into plot here, given the number of arguments and return values — not sure it’s beneficial to break it out into a separate function. Perhaps I should investigate and see how it feels.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thing is we need to run it twice, so inlining it means we have to repeat these lines. Also it felt better to have the part about autoMargins live in dimensions.js (although I'd prefer to have the scales part live in plot.js… and they're intertwined!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've inlined and minimized it. Hope this is more readable

Copy link
Member

@mbostock mbostock left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great overall. I think I want to fiddle with the implementation a bit, and in particular I want to use mark.initialize instead of mark.initializer, but the results seem good and the implementation manageable. Do you feel good about it?

@Fil
Copy link
Contributor Author

Fil commented Sep 27, 2023

Yes I feel good about the result: the tests are all looking great and the only changes are improvements. I think the algorithm is correct. The implementation can certainly be better.

@Fil
Copy link
Contributor Author

Fil commented Sep 27, 2023

One thing that bugged me is that monospaceWidth and defaultWidth are not aligned; maybe there's still time to change this by making monospace chars 60 instead of 100 — it would be a slightly breaking change, but it would make the two lengths comparable, which is something that was needed here.

@Fil Fil force-pushed the fil/default-margins branch from 0b9889a to cb3d714 Compare July 29, 2024 12:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
2 participants