-
-
Notifications
You must be signed in to change notification settings - Fork 4.6k
feat(dashboards): Auto-size Big Number widget #76209
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
Changes from all commits
6a982d0
beacb88
6ecba00
6dda54f
abf5d5e
3bd216f
963ff1e
601e99c
d791208
3de2c65
873d6ff
38e0c72
26365ab
1dc52d3
8098883
ec92cc7
9537b4b
3480b6d
6d33a87
dae91a6
cc53af0
24571c3
1abe77f
8bd2e55
b278308
beebd2e
39d2a2f
7030cb3
91a9f91
33bfd32
5424c33
4bab867
c76ee46
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| import {Fragment} from 'react'; | ||
| import styled from '@emotion/styled'; | ||
|
|
||
| import JSXNode from 'sentry/components/stories/jsxNode'; | ||
| import SizingWindow from 'sentry/components/stories/sizingWindow'; | ||
| import storyBook from 'sentry/stories/storyBook'; | ||
| import {AutoSizedText} from 'sentry/views/dashboards/widgetCard/autoSizedText'; | ||
|
|
||
| export default storyBook(AutoSizedText, story => { | ||
| story('Getting Started', () => { | ||
| return ( | ||
| <Fragment> | ||
| <p> | ||
| <JSXNode name="AutoSizedText" /> is a helper component that automatically sizes | ||
| a piece of text (single line only!) against its parent. It iteratively measures | ||
| the size of the parent element, and chooses a font size for the child element to | ||
| fit it perfectly (within reason) inside the parent. For example: | ||
| </p> | ||
|
|
||
| <SmallSizingWindow> | ||
| <AutoSizedText> | ||
| <OneLineSpan>NEWSFLASH, y'all!</OneLineSpan> | ||
| </AutoSizedText> | ||
| </SmallSizingWindow> | ||
|
|
||
| <p> | ||
| This was built for the "Big Number" widget in our Dashboards product. It's not | ||
| possible to <i>perfectly</i> size the text using only CSS and HTML! | ||
| </p> | ||
| <p> | ||
| To use <JSXNode name="AutoSizedText" />, set it as the child of positioned | ||
| element of known dimensions. Pass the content you want to size as the{' '} | ||
| <strong> | ||
| <code>children</code> | ||
| </strong> | ||
| prop. <JSXNode name="AutoSizedText" /> will set the font size of its children to | ||
| fit into the parent. | ||
| </p> | ||
| </Fragment> | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| const SmallSizingWindow = styled(SizingWindow)` | ||
| width: 300px; | ||
| height: 200px; | ||
| `; | ||
|
|
||
| const OneLineSpan = styled('span')` | ||
| white-space: nowrap; | ||
| `; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| import {useLayoutEffect, useRef} from 'react'; | ||
| import styled from '@emotion/styled'; | ||
| import * as Sentry from '@sentry/react'; | ||
|
|
||
| interface Props { | ||
| children: React.ReactNode; | ||
| } | ||
|
|
||
| export function AutoSizedText({children}: Props) { | ||
| const childRef = useRef<HTMLDivElement>(null); | ||
|
|
||
| const fontSize = useRef<number>(0); | ||
| const fontSizeLowerBound = useRef<number>(0); | ||
| const fontSizeUpperBound = useRef<number>(0); | ||
|
|
||
| useLayoutEffect(() => { | ||
| const childElement = childRef.current; // This is `SizedChild` | ||
| const parentElement = childRef.current?.parentElement; // This is the parent of `AutoSizedText` | ||
|
|
||
| if (!childElement || !parentElement) { | ||
| return undefined; | ||
| } | ||
|
|
||
| // On component first mount, register a `ResizeObserver` on the containing element. The handler fires | ||
| // on component mount, and every time the element changes size after that | ||
| const observer = new ResizeObserver(entries => { | ||
| // The entries list contains an array of every observed item. Here it is only one element | ||
| const entry = entries[0]; | ||
|
|
||
| if (!entry) { | ||
| return; | ||
| } | ||
|
|
||
| // The resize handler passes the parent's dimensions, so we don't have to get the bounding box | ||
| const parentDimensions = entry.contentRect; | ||
|
|
||
| // Reset the iteration parameters | ||
| fontSizeLowerBound.current = 0; | ||
| fontSizeUpperBound.current = parentDimensions.height; | ||
|
|
||
| let iterationCount = 0; | ||
|
|
||
| const span = Sentry.startInactiveSpan({ | ||
| op: 'function', | ||
| name: 'AutoSizedText.iterate', | ||
| }); | ||
|
|
||
| // 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 | ||
|
Comment on lines
+48
to
+50
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🏅 for comments like these |
||
| while (iterationCount <= ITERATION_LIMIT) { | ||
| const childDimensions = getElementDimensions(childElement); | ||
|
|
||
| const widthDifference = parentDimensions.width - childDimensions.width; | ||
| const heightDifference = parentDimensions.height - childDimensions.height; | ||
|
|
||
| const childFitsIntoParent = heightDifference > 0 && widthDifference > 0; | ||
| const childIsWithinWidthTolerance = | ||
| Math.abs(widthDifference) <= MAXIMUM_DIFFERENCE; | ||
| const childIsWithinHeightTolerance = | ||
| Math.abs(heightDifference) <= MAXIMUM_DIFFERENCE; | ||
|
|
||
| if ( | ||
| childFitsIntoParent && | ||
| (childIsWithinWidthTolerance || childIsWithinHeightTolerance) | ||
| ) { | ||
| // Stop the iteration, we've found a fit! | ||
| span.setAttribute('widthDifference', widthDifference); | ||
| span.setAttribute('heightDifference', heightDifference); | ||
| break; | ||
| } | ||
|
|
||
| adjustFontSize(childDimensions, parentDimensions); | ||
|
|
||
| iterationCount += 1; | ||
| } | ||
|
|
||
| span.setAttribute('iterationCount', iterationCount); | ||
JonasBa marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| span.end(); | ||
| }); | ||
|
|
||
| observer.observe(parentElement); | ||
|
|
||
| return () => { | ||
| observer.disconnect(); | ||
| }; | ||
| }, []); | ||
|
|
||
| const adjustFontSize = (childDimensions: Dimensions, parentDimensions: Dimensions) => { | ||
| const childElement = childRef.current; | ||
|
|
||
| if (!childElement) { | ||
| return; | ||
| } | ||
|
|
||
| let newFontSize; | ||
|
|
||
| if ( | ||
| childDimensions.width > parentDimensions.width || | ||
| childDimensions.height > parentDimensions.height | ||
| ) { | ||
| // The element is bigger than the parent, scale down | ||
| newFontSize = (fontSizeLowerBound.current + fontSize.current) / 2; | ||
| fontSizeUpperBound.current = fontSize.current; | ||
| } else if ( | ||
| childDimensions.width < parentDimensions.width || | ||
| childDimensions.height < parentDimensions.height | ||
| ) { | ||
gggritso marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // The element is smaller than the parent, scale up | ||
| newFontSize = (fontSizeUpperBound.current + fontSize.current) / 2; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| fontSizeLowerBound.current = fontSize.current; | ||
| } | ||
|
|
||
| // Store font size in a ref so we don't have to measure styles to get it | ||
| fontSize.current = newFontSize; | ||
| childElement.style.fontSize = `${newFontSize}px`; | ||
| }; | ||
|
|
||
| return <SizedChild ref={childRef}>{children}</SizedChild>; | ||
| } | ||
|
|
||
| const SizedChild = styled('div')` | ||
| display: inline-block; | ||
| `; | ||
|
|
||
| const ITERATION_LIMIT = 50; | ||
|
|
||
| // The maximum difference strongly affects the number of iterations required. | ||
| // A value of 10 means that matches are often found in fewer than 5 iterations. | ||
| // A value of 5 raises it to 6-7. A value of 1 brings it closer to 10. A value of | ||
| // 0 never converges. | ||
| // Note that on modern computers, even with 6x CPU throttling the iterations usually | ||
| // finish in under 5ms. | ||
| const MAXIMUM_DIFFERENCE = 1; // px | ||
|
|
||
| type Dimensions = { | ||
| height: number; | ||
| width: number; | ||
| }; | ||
|
|
||
| function getElementDimensions(element: HTMLElement): Dimensions { | ||
| const bbox = element.getBoundingClientRect(); | ||
|
|
||
| return { | ||
| width: bbox.width, | ||
| height: bbox.height, | ||
| }; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.