@@ -1291,6 +1291,8 @@ const chartExportOptions = [
12911291const animatedExportMaxFrames = 240
12921292const animatedExportMinDelayMs = 35
12931293const animatedExportMaxDelayMs = 100
1294+ const axisTickAverageCharacterWidth = 6.4
1295+ const axisTickLineHeight = 12
12941296const 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'
12961298const 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'
13021304const 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+
13041380function 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+
27822909function 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 ,
0 commit comments