Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
6a982d0
First version of `AutoSizedText`
gggritso Aug 13, 2024
beacb88
Crimes
gggritso Aug 13, 2024
6ecba00
Correctly respond to resize
gggritso Aug 14, 2024
6dda54f
Add basic stories
gggritso Aug 14, 2024
abf5d5e
Remove mysterious margin
gggritso Aug 14, 2024
3bd216f
Improve layout
gggritso Aug 14, 2024
963ff1e
Adjust padding
gggritso Aug 14, 2024
601e99c
Adjust spacing
gggritso Aug 14, 2024
d791208
Debounce the resize
gggritso Aug 14, 2024
3de2c65
More inline docs
gggritso Aug 14, 2024
873d6ff
Fix rendering inside modal
gggritso Aug 14, 2024
38e0c72
Fix mobile layout
gggritso Aug 14, 2024
26365ab
Reduce minimum font size
gggritso Aug 14, 2024
1dc52d3
Crimes
gggritso Aug 14, 2024
8098883
Put behind feature flag
gggritso Aug 14, 2024
ec92cc7
Explain override component
gggritso Aug 14, 2024
9537b4b
Set up iteration using `ResizeObserver`
gggritso Aug 16, 2024
3480b6d
Remove use of parent ref
gggritso Aug 16, 2024
6d33a87
Get rid of the parent element
gggritso Aug 16, 2024
dae91a6
Cleanup
gggritso Aug 16, 2024
cc53af0
Fix disparity calculation
gggritso Aug 16, 2024
24571c3
More cleanup
gggritso Aug 16, 2024
1abe77f
Block UI while recalculating size
gggritso Aug 16, 2024
8bd2e55
Cleanup big time
gggritso Aug 16, 2024
b278308
Update iteration order
gggritso Aug 16, 2024
beebd2e
Clarify documentation
gggritso Aug 16, 2024
39d2a2f
Add instrumentation
gggritso Aug 16, 2024
7030cb3
Update usage
gggritso Aug 16, 2024
91a9f91
Exit loop safely
gggritso Aug 16, 2024
33bfd32
Fix weird return
gggritso Aug 16, 2024
5424c33
Add difference telemetry
gggritso Aug 16, 2024
4bab867
Remove threshold config
gggritso Aug 16, 2024
c76ee46
Merge branch 'master' into feat/dashboards/auto-scale-big-number
gggritso Aug 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Comment on lines +48 to +50
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

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;
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

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