Skip to content

Commit 8a12c2f

Browse files
committed
Fix npm stats embed sizing
1 parent 616c16a commit 8a12c2f

2 files changed

Lines changed: 202 additions & 31 deletions

File tree

src/components/npm-stats/NPMStatsChart.tsx

Lines changed: 190 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1291,6 +1291,8 @@ const chartExportOptions = [
12911291
const animatedExportMaxFrames = 240
12921292
const animatedExportMinDelayMs = 35
12931293
const animatedExportMaxDelayMs = 100
1294+
const axisTickAverageCharacterWidth = 6.4
1295+
const axisTickLineHeight = 12
12941296
const chartActionButtonStyles =
12951297
'flex size-6 items-center justify-center rounded bg-gray-500/10 text-gray-600 hover:bg-gray-500/20 hover:text-blue-500 dark:text-gray-400 dark:hover:text-gray-100'
12961298
const chartActionDropdownContentStyles =
@@ -1301,6 +1303,80 @@ const chartEmbedInputStyles =
13011303
'w-full rounded border border-gray-500/20 bg-gray-50 px-2 py-1.5 font-mono text-[11px] leading-snug outline-none focus:border-blue-500 dark:bg-gray-900'
13021304
const svgNamespace = 'http://www.w3.org/2000/svg'
13031305

1306+
function clampNumber(value: number, min: number, max: number) {
1307+
return Math.max(min, Math.min(value, max))
1308+
}
1309+
1310+
function getEstimatedAxisLabelWidth(label: string) {
1311+
return Math.ceil(label.length * axisTickAverageCharacterWidth)
1312+
}
1313+
1314+
function getCategoricalAxisLeftMargin({
1315+
labels,
1316+
width,
1317+
}: {
1318+
labels: Array<string>
1319+
width: number
1320+
}) {
1321+
const longestLabelWidth =
1322+
d3.max(labels, (label) => getEstimatedAxisLabelWidth(label)) ?? 0
1323+
const maxMargin = clampNumber(Math.round(width * 0.38), 110, 280)
1324+
1325+
return clampNumber(longestLabelWidth + 26, 90, maxMargin)
1326+
}
1327+
1328+
function getVerticalCategoricalXAxisLayout({
1329+
height,
1330+
labels,
1331+
marginLeft,
1332+
marginRight,
1333+
width,
1334+
}: {
1335+
height: number
1336+
labels: Array<string>
1337+
marginLeft: number
1338+
marginRight: number
1339+
width: number
1340+
}) {
1341+
const availableWidth = Math.max(0, width - marginLeft - marginRight)
1342+
const tickSlotWidth = labels.length ? availableWidth / labels.length : 0
1343+
const longestLabelWidth =
1344+
d3.max(labels, (label) => getEstimatedAxisLabelWidth(label)) ?? 0
1345+
const shouldRotateTicks =
1346+
labels.length > 0 && longestLabelWidth > Math.max(36, tickSlotWidth - 8)
1347+
1348+
if (!shouldRotateTicks) {
1349+
return {
1350+
labelOffset: 35,
1351+
marginBottom: 64,
1352+
tickRotate: undefined,
1353+
}
1354+
}
1355+
1356+
const tickAngle = Math.PI / 6
1357+
const rotatedTickHeight = Math.ceil(
1358+
longestLabelWidth * Math.sin(tickAngle) +
1359+
axisTickLineHeight * Math.cos(tickAngle),
1360+
)
1361+
const maxMarginBottom = clampNumber(Math.round(height * 0.45), 88, 160)
1362+
1363+
return {
1364+
labelOffset: 55,
1365+
marginBottom: clampNumber(44 + rotatedTickHeight, 76, maxMarginBottom),
1366+
tickRotate: -30,
1367+
}
1368+
}
1369+
1370+
function getPlotContentHeight({
1371+
reservedHeight,
1372+
totalHeight,
1373+
}: {
1374+
reservedHeight: number
1375+
totalHeight: number | undefined
1376+
}) {
1377+
return Math.max(1, Math.floor((totalHeight ?? 0) - reservedHeight))
1378+
}
1379+
13041380
function getExportFileName(format: ChartExportFormat) {
13051381
if (format === 'jpeg') return 'npm-stats-chart.jpg'
13061382
return `npm-stats-chart.${format}`
@@ -1629,11 +1705,17 @@ function createLatestBarExportPlot({
16291705
})
16301706
const isStackedBar = chartType === 'stacked-bar'
16311707
const isHorizontalBar = barOrientation === 'horizontal'
1632-
const longestLatestBarLabelLength =
1633-
d3.max(latestBarDomain, (label) => label.length) ?? 0
1708+
const marginRight = 10
16341709
const marginLeft = isHorizontalBar
1635-
? Math.min(240, Math.max(90, longestLatestBarLabelLength * 7 + 20))
1710+
? getCategoricalAxisLeftMargin({ labels: latestBarDomain, width })
16361711
: 70
1712+
const verticalXAxisLayout = getVerticalCategoricalXAxisLayout({
1713+
height,
1714+
labels: latestBarDomain,
1715+
marginLeft,
1716+
marginRight,
1717+
width,
1718+
})
16371719
const getSeriesColor = (d: { name?: string }) =>
16381720
d.name ? getPackageColor(d.name, packages) : 'currentColor'
16391721
const getSubPackageColor = (d: { groupName: string; packageIndex: number }) =>
@@ -1670,8 +1752,8 @@ function createLatestBarExportPlot({
16701752
})
16711753
const plot = Plot.plot({
16721754
marginLeft,
1673-
marginRight: 10,
1674-
marginBottom: isHorizontalBar ? 70 : 90,
1755+
marginRight,
1756+
marginBottom: isHorizontalBar ? 64 : verticalXAxisLayout.marginBottom,
16751757
width,
16761758
height,
16771759
marks: (
@@ -1722,9 +1804,9 @@ function createLatestBarExportPlot({
17221804
x: {
17231805
domain: isHorizontalBar ? undefined : latestBarDomain,
17241806
label: isHorizontalBar ? 'Downloads' : null,
1725-
labelOffset: isHorizontalBar ? 35 : 55,
1807+
labelOffset: isHorizontalBar ? 35 : verticalXAxisLayout.labelOffset,
17261808
tickFormat: isHorizontalBar ? formatCompactAxisNumber : undefined,
1727-
tickRotate: isHorizontalBar ? undefined : -30,
1809+
tickRotate: isHorizontalBar ? undefined : verticalXAxisLayout.tickRotate,
17281810
},
17291811
y: {
17301812
domain: isHorizontalBar ? latestBarDomain : undefined,
@@ -2779,6 +2861,51 @@ function useElementWidth() {
27792861
return { containerRef, width }
27802862
}
27812863

2864+
function useMeasuredElementHeight<T extends HTMLElement>() {
2865+
const [element, setElement] = React.useState<T | null>(null)
2866+
const [height, setHeight] = React.useState(0)
2867+
2868+
const ref = React.useCallback((nextElement: T | null) => {
2869+
setElement(nextElement)
2870+
}, [])
2871+
2872+
React.useEffect(() => {
2873+
if (!element) {
2874+
setHeight(0)
2875+
return
2876+
}
2877+
2878+
const setMeasuredHeight = (nextHeight: number) => {
2879+
setHeight((previousHeight) =>
2880+
previousHeight === nextHeight ? previousHeight : nextHeight,
2881+
)
2882+
}
2883+
2884+
const updateHeight = () => {
2885+
setMeasuredHeight(Math.ceil(element.getBoundingClientRect().height))
2886+
}
2887+
2888+
updateHeight()
2889+
2890+
const ownerWindow = element.ownerDocument.defaultView
2891+
if (!ownerWindow?.ResizeObserver) {
2892+
ownerWindow?.addEventListener('resize', updateHeight)
2893+
return () => {
2894+
ownerWindow?.removeEventListener('resize', updateHeight)
2895+
}
2896+
}
2897+
2898+
const resizeObserver = new ownerWindow.ResizeObserver(updateHeight)
2899+
resizeObserver.observe(element)
2900+
2901+
return () => {
2902+
resizeObserver.disconnect()
2903+
}
2904+
}, [element])
2905+
2906+
return { height, ref }
2907+
}
2908+
27822909
function TimelineRangeScrubber({
27832910
dates,
27842911
endIndex,
@@ -2963,7 +3090,7 @@ function TimelineRangeScrubber({
29633090

29643091
return (
29653092
<div
2966-
className="group relative mt-1 h-8 cursor-crosshair"
3093+
className="group relative h-8 cursor-crosshair"
29673094
onDoubleClick={handleSelectionDoubleClick}
29683095
onPointerCancel={handleSelectionPointerCancel}
29693096
onPointerDown={handleSelectionPointerDown}
@@ -3067,18 +3194,40 @@ function PlotFigure({
30673194
const dataUpdateKeyRef = React.useRef<string | undefined>(undefined)
30683195
const layoutKeyRef = React.useRef<string | undefined>(undefined)
30693196
const showLegendRef = React.useRef(showLegend)
3197+
const { height: legendHeight, ref: measuredLegendRef } =
3198+
useMeasuredElementHeight<HTMLDivElement>()
3199+
const { height: footerHeight, ref: footerRef } =
3200+
useMeasuredElementHeight<HTMLDivElement>()
30703201
const sizeRef = React.useRef<{
30713202
height: number | undefined
30723203
width: number | undefined
30733204
} | null>(null)
30743205

3206+
const setLegendRef = React.useCallback(
3207+
(element: HTMLDivElement | null) => {
3208+
legendRef.current = element
3209+
measuredLegendRef(element)
3210+
},
3211+
[legendRef, measuredLegendRef],
3212+
)
3213+
const plotOptions = React.useMemo(
3214+
() => ({
3215+
...options,
3216+
height: getPlotContentHeight({
3217+
reservedHeight: legendHeight + footerHeight,
3218+
totalHeight: options.height,
3219+
}),
3220+
}),
3221+
[footerHeight, legendHeight, options],
3222+
)
3223+
30753224
React.useEffect(() => {
30763225
const container = containerRef.current
30773226
if (!container) return
30783227

30793228
const nextSize = {
3080-
height: options.height,
3081-
width: options.width,
3229+
height: plotOptions.height,
3230+
width: plotOptions.width,
30823231
}
30833232
const previousSize = sizeRef.current
30843233
const sizeChanged =
@@ -3115,7 +3264,7 @@ function PlotFigure({
31153264
}
31163265

31173266
const replacePlot = () => {
3118-
commitPlot(Plot.plot(options))
3267+
commitPlot(Plot.plot(plotOptions))
31193268
}
31203269

31213270
const domainOrderKey = domain?.join('\u0000')
@@ -3136,7 +3285,7 @@ function PlotFigure({
31363285
plotSvg?.updateBarPlot
31373286
) {
31383287
if (domainOrderChanged || domainIdentityChanged || dataChanged) {
3139-
const nextPlot = Plot.plot(options)
3288+
const nextPlot = Plot.plot(plotOptions)
31403289
commitPlot(nextPlot)
31413290
plotSvg.updateBarPlot({
31423291
nextBarKeys: barKeys,
@@ -3158,7 +3307,7 @@ function PlotFigure({
31583307
legendRef,
31593308
layoutKey,
31603309
onRenderedChange,
3161-
options,
3310+
plotOptions,
31623311
plotRef,
31633312
])
31643313

@@ -3181,16 +3330,20 @@ function PlotFigure({
31813330
)
31823331

31833332
return (
3184-
<div className="relative">
3333+
<div className="relative" style={{ height: options.height }}>
31853334
<div
31863335
className={twMerge(
3187-
'mb-2 flex justify-center overflow-x-auto px-2 text-xs',
3336+
'flex justify-center overflow-x-auto px-2 pb-2 text-xs',
31883337
!showLegend && 'hidden',
31893338
)}
3190-
ref={legendRef}
3339+
ref={setLegendRef}
31913340
/>
31923341
<div ref={containerRef} />
3193-
{footer}
3342+
{footer ? (
3343+
<div className="flow-root pt-1" ref={footerRef}>
3344+
{footer}
3345+
</div>
3346+
) : null}
31943347
</div>
31953348
)
31963349
}
@@ -3963,11 +4116,17 @@ export function NPMStatsChart({
39634116
const isLatestBar = isLatestGroupedBar || isLatestStackedBar
39644117
const isHorizontalBar = isLatestBar && barOrientation === 'horizontal'
39654118
const isLatestVerticalBar = isLatestBar && barOrientation === 'vertical'
3966-
const longestLatestBarLabelLength =
3967-
d3.max(latestBarDomain, (label) => label.length) ?? 0
4119+
const marginRight = 10
39684120
const marginLeft = isHorizontalBar
3969-
? Math.min(240, Math.max(90, longestLatestBarLabelLength * 7 + 20))
4121+
? getCategoricalAxisLeftMargin({ labels: latestBarDomain, width })
39704122
: 70
4123+
const latestVerticalXAxisLayout = getVerticalCategoricalXAxisLayout({
4124+
height,
4125+
labels: latestBarDomain,
4126+
marginLeft,
4127+
marginRight,
4128+
width,
4129+
})
39714130
const xLabel = isHorizontalBar
39724131
? 'Downloads'
39734132
: viewMode === 'latest'
@@ -4069,7 +4228,7 @@ export function NPMStatsChart({
40694228
dates={timelineScrubberDates}
40704229
endIndex={timelineEndIndex}
40714230
marginLeft={marginLeft}
4072-
marginRight={10}
4231+
marginRight={marginRight}
40734232
onRangeChange={(nextStartIndex, nextEndIndex) => {
40744233
if (
40754234
nextStartIndex <= 0 &&
@@ -4098,8 +4257,10 @@ export function NPMStatsChart({
40984257
showLegend={showLegend}
40994258
options={{
41004259
marginLeft,
4101-
marginRight: 10,
4102-
marginBottom: isLatestVerticalBar ? 90 : 70,
4260+
marginRight,
4261+
marginBottom: isLatestVerticalBar
4262+
? latestVerticalXAxisLayout.marginBottom
4263+
: 70,
41034264
width,
41044265
height,
41054266
clip: hasTimelineZoom ? true : undefined,
@@ -4302,9 +4463,13 @@ export function NPMStatsChart({
43024463
? latestBarDomain
43034464
: timelineZoomDomain,
43044465
label: xLabel,
4305-
labelOffset: isLatestVerticalBar ? 55 : 35,
4466+
labelOffset: isLatestVerticalBar
4467+
? latestVerticalXAxisLayout.labelOffset
4468+
: 35,
43064469
tickFormat: xTickFormat,
4307-
tickRotate: isLatestVerticalBar ? -30 : undefined,
4470+
tickRotate: isLatestVerticalBar
4471+
? latestVerticalXAxisLayout.tickRotate
4472+
: undefined,
43084473
},
43094474
y: {
43104475
domain: isHorizontalBar ? latestBarDomain : undefined,

src/routes/stats/npm/embed.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -270,21 +270,27 @@ function NpmStatsEmbed() {
270270
: undefined
271271

272272
return (
273-
<div
274-
className="relative flex h-dvh w-screen items-stretch overflow-hidden bg-white text-gray-950 dark:bg-gray-950 dark:text-gray-50"
275-
ref={containerRef}
276-
>
273+
<div className="relative flex h-dvh w-screen overflow-hidden bg-white text-gray-950 dark:bg-gray-950 dark:text-gray-50">
277274
<style
278275
dangerouslySetInnerHTML={{
279276
__html: `
280277
html, body {
281278
background: transparent !important;
279+
height: 100%;
280+
margin: 0 !important;
281+
overflow: hidden;
282+
width: 100%;
282283
}
283284
`,
284285
}}
285286
/>
286287
<div
287-
className={width ? 'min-w-0' : 'min-w-0 flex-1'}
288+
className={
289+
width
290+
? 'min-h-0 min-w-0 flex-1'
291+
: 'min-h-0 min-w-0 flex-1 self-stretch'
292+
}
293+
ref={containerRef}
288294
style={chartWidthStyle}
289295
>
290296
{queryPackageGroups.length ? (
@@ -329,7 +335,7 @@ html, body {
329335
) : null}
330336
</div>
331337
<a
332-
className="absolute right-2 bottom-2 rounded bg-white/85 px-2 py-1 text-[10px] font-medium text-gray-600 shadow-sm ring-1 ring-gray-200 backdrop-blur hover:text-gray-950 dark:bg-gray-950/85 dark:text-gray-400 dark:ring-gray-800 dark:hover:text-gray-50"
338+
className="absolute top-1 right-1 z-10 rounded bg-white/75 px-1.5 py-0.5 text-[9px] leading-none font-medium text-gray-500 ring-1 ring-gray-200/80 backdrop-blur hover:text-gray-950 dark:bg-gray-950/75 dark:text-gray-500 dark:ring-gray-800/80 dark:hover:text-gray-50"
333339
href="/stats/npm"
334340
rel="noreferrer"
335341
target="_blank"

0 commit comments

Comments
 (0)