Skip to content

Commit

Permalink
feat(dashboards): Auto-size Big Number widget (#76209)
Browse files Browse the repository at this point in the history
**Before:**
<img width="1194" alt="Screenshot 2024-08-13 at 3 16 05 PM"
src="https://github.com/user-attachments/assets/354672f6-6537-44cf-b760-e5f6eeba5185">

**After:**
<img width="1198" alt="Screenshot 2024-08-13 at 3 16 17 PM"
src="https://github.com/user-attachments/assets/dd66ce6b-6183-4855-be8b-89c900d4589e">

**Bonus:**
![Screenshot 2024-08-14 at 3 53
42 PM](https://github.com/user-attachments/assets/e8a61c28-dec9-403f-8af9-fcf971ac31e2)

## 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](#75730)

---------

Co-authored-by: Jonas <jonas.badalic@sentry.io>
  • Loading branch information
gggritso and JonasBa authored Aug 26, 2024
1 parent c39489e commit ab2a5ca
Show file tree
Hide file tree
Showing 3 changed files with 255 additions and 1 deletion.
51 changes: 51 additions & 0 deletions static/app/views/dashboards/widgetCard/autoSizedText.stories.tsx
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;
`;
148 changes: 148 additions & 0 deletions static/app/views/dashboards/widgetCard/autoSizedText.tsx
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
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);
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
) {
// The element is smaller than the parent, scale up
newFontSize = (fontSizeUpperBound.current + fontSize.current) / 2;
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,
};
}
57 changes: 56 additions & 1 deletion static/app/views/dashboards/widgetCard/chart.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type React from 'react';
import {Component} from 'react';
import type {InjectedRouter} from 'react-router';
import type {Theme} from '@emotion/react';
Expand Down Expand Up @@ -51,6 +52,7 @@ import {
} from 'sentry/utils/discover/fields';
import getDynamicText from 'sentry/utils/getDynamicText';
import {eventViewFromWidget} from 'sentry/views/dashboards/utils';
import {AutoSizedText} from 'sentry/views/dashboards/widgetCard/autoSizedText';

import {getFormatter} from '../../../components/charts/components/tooltip';
import {getDatasetConfig} from '../datasetConfig/base';
Expand Down Expand Up @@ -243,7 +245,7 @@ class WidgetCardChart extends Component<WidgetCardChartProps, State> {
? containerHeight - parseInt(space(1), 10) - parseInt(space(3), 10)
: `max(min(8vw, 90px), ${space(4)})`;

return (
return !organization.features.includes('auto-size-big-number-widget') ? (
<BigNumber
key={`big_number:${result.title}`}
style={{
Expand All @@ -255,6 +257,18 @@ class WidgetCardChart extends Component<WidgetCardChartProps, State> {
{rendered}
</Tooltip>
</BigNumber>
) : expandNumbers ? (
<BigText>{rendered}</BigText>
) : (
<AutoResizeParent key={`big_number:${result.title}`}>
<AutoSizedText>
<NumberContainerOverride>
<Tooltip title={rendered} showOnlyOnOverflow>
{rendered}
</Tooltip>
</NumberContainerOverride>
</AutoSizedText>
</AutoResizeParent>
);
});
}
Expand Down Expand Up @@ -582,6 +596,7 @@ const BigNumberResizeWrapper = styled('div')`
height: 100%;
width: 100%;
overflow: hidden;
position: relative;
`;

const BigNumber = styled('div')`
Expand All @@ -599,6 +614,46 @@ const BigNumber = styled('div')`
}
`;

const AutoResizeParent = styled('div')`
position: absolute;
color: ${p => p.theme.headingColor};
inset: ${space(1)} ${space(3)} 0 ${space(3)};
* {
line-height: 1;
text-align: left !important;
}
`;

const BigText = styled('div')`
display: block;
width: 100%;
color: ${p => p.theme.headingColor};
font-size: max(min(8vw, 90px), 30px);
padding: ${space(1)} ${space(3)} 0 ${space(3)};
white-space: nowrap;
* {
text-align: left !important;
}
`;

/**
* This component overrides the default behavior of `NumberContainer`,
* which wraps every single number in big widgets. This override forces
* `NumberContainer` to never truncate its values, which makes it possible
* to auto-size them.
*/
const NumberContainerOverride = styled('div')`
display: inline-block;
* {
text-overflow: clip !important;
display: inline;
white-space: nowrap;
}
`;

const ChartWrapper = styled('div')<{autoHeightResize: boolean; noPadding?: boolean}>`
${p => p.autoHeightResize && 'height: 100%;'}
padding: ${p => (p.noPadding ? `0` : `0 ${space(3)} ${space(3)}`)};
Expand Down

0 comments on commit ab2a5ca

Please sign in to comment.