Skip to content

Conversation

@gggritso
Copy link
Member

@gggritso gggritso commented Aug 14, 2024

Before:
Screenshot 2024-08-13 at 3 16 05 PM

After:
Screenshot 2024-08-13 at 3 16 17 PM

Bonus:
Screenshot 2024-08-14 at 3 53 42 PM

Explanation

The "Big Number" widget is a Dashboards feature where a user can make a query like count() and get a big honkin' number that shows how many transactions they have. The widget has some fancy CSS to help size it big (but not too big), but CSS alone isn't enough. In many cases, the big number gets truncated! That's not very nice or very useful.

This PR changes the approach. Instead of CSS, it adds a new utility component called AutoSizedText. This component mimics the height and width of its parent, and then iteratively updates the font size of its contents until they fit nicely inside the parent. e.g.,

  • The parent is 300px by 300x, and contains the text "OH HELLO"
  • The component splits the difference and renders the text at a font size of 110px ((200px + 20px) / 2). It sets the known bounds to 20px minimum and 200px maximum
  • The component determines the child is too big because it has bigger dimensions than its parent. It splits the difference between the current and minimum font size, and sets it to 165px ((110px + 200px) / 2). It updates the known bounds to 20px minimum and 200px maximum (since 200px is known to be too big) and renders again
  • The child is still too big. The component narrows the font size search field again (note: this works kind of like a binary search) by splitting the difference between the current and minimum font size ((165 + 20) / 2) to 92.5. It updates the known bounds to minimum 20px and maximum 165px.
  • This process continues until the child is either within 5% of the parent dimensions and fits inside the parent or it runs out of calculations (maximum of 5 per component)

Other Methods Considered

  1. SVG. SVGs can auto-scale to a parent, and can contain <text> elements. I didn't like this approach because font scaling by percentage is not at all the same as changing the font size. Modern typefaces do a lot of work to look legible at different sizes at the font level, and the only way to respect this is to set the correct font size
  2. CSS transform has the same problem as SVG scaling
  3. Container Queries. This doesn't work because container queries make it easier to set the font size as a proportion of the container, but our case is very complicated. For one thing, we'd need to clamp and scale on both width and height, but the width of the content is fully variable based on which characters are used. There's no known ratio that would work. For another, that approach doesn't work at all for multi-line text? Etc.
  4. Canvas. There's a way (I hear) to render text to a canvas and use measureText to get the dimensions, but that's fairly complicated

FAQs

I think the most obvious question is what's the performance like. In short, it's not great, but I did what I could. The most important thing is wrapping the updates in useTransition which will mark them as lower priority. This will allow other, higher-priority renders (like interactions with other dropdowns) to interrupt the resizing logic. I also limited the iteration count to 5, and debounced the resize listener. This seems to work well enough even with CPU throttling set pretty high.

Overall I'm not too worried because resizing is not a common operation, and it's still pretty fast compared to how long a Dashboard resize already takes (a long time).

The second is testing. Short of literal snapshots (which I don't think we do anymore) I couldn't come up with anything that felt more worthwhile than manual QA. You tell me, though.

Fixes #75730

@github-actions github-actions bot added the Scope: Frontend Automatically applied to PRs that change frontend components label Aug 14, 2024
@gggritso gggritso marked this pull request as ready for review August 14, 2024 20:18
@gggritso gggritso requested a review from a team August 14, 2024 20:18
Copy link
Member

@narsaynorath narsaynorath left a comment

Choose a reason for hiding this comment

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

Just a comment around the error-ish condition where we reach the maximum depth. I think we should be tracking something here so we can look back and see if there are issues, otherwise it's failing silently. This looks great, I owe you a hot chocolate 😁

Copy link
Contributor

@edwardgou-sentry edwardgou-sentry left a comment

Choose a reason for hiding this comment

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

Pretty cool solution! Thanks George!

@gggritso
Copy link
Member Author

@edwardgou-sentry @narsaynorath @JonasBa thanks for the reviews! I made some major changes to the component, so I'm re-requesting the review and marking comments as stale, since so much has changed!

@narsaynorath I added instrumentation! I was going to, I swear, you caught me being lazy. The condition you asked about doesn't exist anymore, so I'm just logging the iteration count
@edwardgou-sentry the code changed a lot, and I'm not calculating disparity ratios anymore, much simpler to just use pixel thresholds

@JonasBa I've addressed, I think a lot of your concerns, thank you for those suggestions:

  1. No more React state at all, everything is kept in refs
  2. Only one resize observer, and only one ref to keep track of the child
  3. The iteration runs in a loop and blocks the UI thread to prevent jitters
  4. Fewer props and simpler logic! No need to set min and max anymore
  5. No more parent mimic component

I did keep some of the old stuff:

  • useLayoutEffect is still there. I like it too much. It's a handy way to express side effects and cleanup, and I do like to re-mount when the iteration parameter changes. Thoughts?

The overall approach is still the same. I also updated the docs, while I was at it, and added more inline code comments.

Thank you for your reviews, please look again when you have a chance 🙏🏻


interface Props {
children: React.ReactNode;
maximumDifference?: number;
Copy link
Member

Choose a reason for hiding this comment

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

If I were to use a component like this, this isnt something I'd want to have to think about. I think we should remove it, and revisit the component design if this ever becomes a requirement

Copy link
Member Author

Choose a reason for hiding this comment

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

🤔 I guess that makes sense but you don't have to think about it, it's optional! I immediately thought that I'd want to change this if I use this in a component where fitment is important (e.g., in a headline or some other important visual element)

Copy link
Member

Choose a reason for hiding this comment

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

How would you go about guiding folks to set a good value here? What I'm afraid of is that everyone would just set it to 0 because they'd want it to be the exact size (which is kind of the goal already?)

Copy link
Member Author

Choose a reason for hiding this comment

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

I think that's okay, actually! In fact, maybe what I should do is set the default to 0 (or 1px just to be safe) and set it to 5px just in Dashboards where there might be many of these numbers and I don't wanna halt the UI thread too long. Thoughts?

Copy link
Member

Choose a reason for hiding this comment

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

How would you evaluate if that is a good config? Does that mean we'd need to care about how many big numbers we render and update this over time?

Copy link
Member Author

Choose a reason for hiding this comment

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

🤔 okay let me see how much the iteration count actually affects the render time on pages with a lot of big numbers. If it's low, I will set the tolerance to 1px and remove the prop

Copy link
Member Author

Choose a reason for hiding this comment

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

Alright! Sold! Removed it, and added a note about iteration counts and performance related to the threshold 👍🏻

Co-authored-by: Jonas <jonas.badalic@sentry.io>
Comment on lines +52 to +54
// Run the resize iteration in a loop. This blocks the main UI thread and prevents
// visible layout jitter. If this was done through a `ResizeObserver` or React State
// each step in the resize iteration would be visible to the user
Copy link
Member

Choose a reason for hiding this comment

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

🏅 for comments like these

gggritso and others added 2 commits August 16, 2024 16:52
Co-authored-by: Jonas <jonas.badalic@sentry.io>
@JonasBa
Copy link
Member

JonasBa commented Aug 16, 2024

Nice work @gggritso, I think you found a cleaner implementation that is less opinionated than before with the code being easier to follow!

childDimensions.height < parentDimensions.height
) {
// The element is smaller than the parent, scale up
newFontSize = (fontSizeUpperBound.current + fontSize.current) / 2;
Copy link
Member

Choose a reason for hiding this comment

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

Thoughts on trimming or rounding the floating point values here? I dont think this is an issue tbh, but it might generate some really long floats here :D

Copy link
Member Author

Choose a reason for hiding this comment

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

I could! Ostensibly modern fonts handle sub-pixel rendering gracefully enough. Are you worried about DOM bloat?

Copy link
Member

Choose a reason for hiding this comment

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

I think the browsers will handle it fine (though different platforms render text quite differently). I think its more likely just going to be an instance where someone inevitably sees a high precision float in the DOM and starts asking around or making up some wrong ideas, which is a conversation you can spare yourself from having :D

Copy link
Member

@JonasBa JonasBa left a comment

Choose a reason for hiding this comment

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

Really nice work, A+ for implementation, stories and code comments!

@gggritso gggritso merged commit ab2a5ca into master Aug 26, 2024
@gggritso gggritso deleted the feat/dashboards/auto-scale-big-number branch August 26, 2024 13:52
@github-actions github-actions bot locked and limited conversation to collaborators Sep 11, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Scope: Frontend Automatically applied to PRs that change frontend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Big number visualization doesn't scale with widget width

5 participants