@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'
33import * as Plot from '@observablehq/plot'
44import * as d3 from 'd3'
55import { GIFEncoder , applyPalette , quantize } from 'gifenc'
6- import { Download , List } from 'lucide-react'
6+ import { Check , Code2 , Copy , Download , List } from 'lucide-react'
77import {
88 DropdownMenu ,
99 DropdownMenuContent ,
@@ -1267,6 +1267,16 @@ type LatestBucketOffsetBounds = {
12671267 maxOffset : number
12681268 minOffset : number
12691269}
1270+ export type NpmStatsChartEmbedOptions = {
1271+ includeTimelineRange : boolean
1272+ lockWidth : boolean
1273+ showLegend : boolean
1274+ }
1275+ export type NpmStatsChartEmbedConfig = {
1276+ buildUrl : ( options : NpmStatsChartEmbedOptions ) => string
1277+ hasTimelineRange : boolean
1278+ hasWidth : boolean
1279+ }
12701280
12711281const chartExportOptions = [
12721282 { value : 'svg' , label : 'SVG' } ,
@@ -1287,6 +1297,8 @@ const chartActionDropdownContentStyles =
12871297 'z-50 min-w-[120px] rounded-md bg-white p-1.5 shadow-lg dark:bg-gray-800'
12881298const chartActionDropdownItemStyles =
12891299 'flex w-full cursor-pointer items-center rounded px-1.5 py-1 text-left text-xs outline-none hover:bg-gray-500/20 data-highlighted:bg-gray-500/20 data-highlighted:text-blue-500'
1300+ const chartEmbedInputStyles =
1301+ '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'
12901302const svgNamespace = 'http://www.w3.org/2000/svg'
12911303
12921304function getExportFileName ( format : ChartExportFormat ) {
@@ -2416,13 +2428,15 @@ function renderPlotLegend({
24162428function ChartActions ( {
24172429 canExportAnimation,
24182430 disabled,
2431+ embedConfig,
24192432 exportingFormat,
24202433 onExport,
24212434 onToggleLegend,
24222435 showLegend,
24232436} : {
24242437 canExportAnimation : boolean
24252438 disabled : boolean
2439+ embedConfig ?: NpmStatsChartEmbedConfig
24262440 exportingFormat : ChartExportFormat | null
24272441 onExport : ( format : ChartExportFormat ) => void
24282442 onToggleLegend : ( ) => void
@@ -2504,10 +2518,222 @@ function ChartActions({
25042518 < List className = "size-3" />
25052519 </ button >
25062520 </ Tooltip >
2521+ { embedConfig ? < EmbedChartAction embedConfig = { embedConfig } /> : null }
25072522 </ div >
25082523 )
25092524}
25102525
2526+ function getAbsoluteEmbedUrl ( url : string ) {
2527+ const base =
2528+ typeof window === 'undefined'
2529+ ? 'https://tanstack.com'
2530+ : window . location . origin
2531+
2532+ return new URL ( url , base ) . toString ( )
2533+ }
2534+
2535+ function CopyButton ( {
2536+ copied,
2537+ label,
2538+ onCopy,
2539+ } : {
2540+ copied : boolean
2541+ label : string
2542+ onCopy : ( ) => void
2543+ } ) {
2544+ return (
2545+ < button
2546+ className = "flex shrink-0 items-center gap-1 rounded bg-gray-500/10 px-2 py-1 text-[11px] font-medium text-gray-600 hover:bg-gray-500/20 hover:text-blue-500 dark:text-gray-300 dark:hover:text-gray-100"
2547+ onClick = { onCopy }
2548+ type = "button"
2549+ >
2550+ { copied ? < Check className = "size-3" /> : < Copy className = "size-3" /> }
2551+ { copied ? 'Copied' : label }
2552+ </ button >
2553+ )
2554+ }
2555+
2556+ function EmbedOption ( {
2557+ checked,
2558+ children,
2559+ disabled,
2560+ onCheckedChange,
2561+ } : {
2562+ checked : boolean
2563+ children : React . ReactNode
2564+ disabled ?: boolean
2565+ onCheckedChange : ( checked : boolean ) => void
2566+ } ) {
2567+ return (
2568+ < label
2569+ className = { twMerge (
2570+ 'flex items-center gap-2 text-xs text-gray-700 dark:text-gray-200' ,
2571+ disabled && 'cursor-not-allowed opacity-50' ,
2572+ ) }
2573+ >
2574+ < input
2575+ checked = { checked }
2576+ className = "size-3.5 accent-blue-500"
2577+ disabled = { disabled }
2578+ onChange = { ( event ) => onCheckedChange ( event . currentTarget . checked ) }
2579+ type = "checkbox"
2580+ />
2581+ < span > { children } </ span >
2582+ </ label >
2583+ )
2584+ }
2585+
2586+ function EmbedChartAction ( {
2587+ embedConfig,
2588+ } : {
2589+ embedConfig : NpmStatsChartEmbedConfig
2590+ } ) {
2591+ const [ showLegend , setShowLegend ] = React . useState ( true )
2592+ const [ includeTimelineRange , setIncludeTimelineRange ] = React . useState (
2593+ embedConfig . hasTimelineRange ,
2594+ )
2595+ const [ lockWidth , setLockWidth ] = React . useState ( false )
2596+ const [ iframeHeight , setIframeHeight ] = React . useState ( 420 )
2597+ const [ copiedTarget , setCopiedTarget ] = React . useState <
2598+ 'iframe' | 'url' | null
2599+ > ( null )
2600+
2601+ React . useEffect ( ( ) => {
2602+ if ( ! embedConfig . hasTimelineRange ) {
2603+ setIncludeTimelineRange ( false )
2604+ }
2605+ } , [ embedConfig . hasTimelineRange ] )
2606+
2607+ React . useEffect ( ( ) => {
2608+ if ( ! embedConfig . hasWidth ) {
2609+ setLockWidth ( false )
2610+ }
2611+ } , [ embedConfig . hasWidth ] )
2612+
2613+ const embedUrl = getAbsoluteEmbedUrl (
2614+ embedConfig . buildUrl ( {
2615+ includeTimelineRange,
2616+ lockWidth,
2617+ showLegend,
2618+ } ) ,
2619+ )
2620+ const iframeCode = `<iframe src="${ embedUrl } " title="TanStack NPM Stats chart" loading="lazy" style="width:100%;height:${ iframeHeight } px;border:0;"></iframe>`
2621+
2622+ const copyText = React . useCallback (
2623+ async ( target : 'iframe' | 'url' , text : string ) => {
2624+ await navigator . clipboard . writeText ( text )
2625+ setCopiedTarget ( target )
2626+ window . setTimeout ( ( ) => {
2627+ setCopiedTarget ( ( currentTarget ) =>
2628+ currentTarget === target ? null : currentTarget ,
2629+ )
2630+ } , 1500 )
2631+ } ,
2632+ [ ] ,
2633+ )
2634+
2635+ return (
2636+ < DropdownMenu >
2637+ < Tooltip content = "Embed chart" >
2638+ < DropdownMenuTrigger asChild >
2639+ < button
2640+ aria-label = "Embed chart"
2641+ className = { chartActionButtonStyles }
2642+ type = "button"
2643+ >
2644+ < Code2 className = "size-3" />
2645+ </ button >
2646+ </ DropdownMenuTrigger >
2647+ </ Tooltip >
2648+ < DropdownMenuContent
2649+ align = "end"
2650+ className = "z-50 w-[min(420px,calc(100vw-2rem))] rounded-md bg-white p-3 text-gray-900 shadow-lg dark:bg-gray-800 dark:text-gray-100"
2651+ collisionPadding = { 8 }
2652+ sideOffset = { 5 }
2653+ >
2654+ < div className = "space-y-3" >
2655+ < div className = "flex items-center justify-between gap-3" >
2656+ < div className = "text-xs font-medium" > Embed chart</ div >
2657+ < label className = "flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300" >
2658+ < span > Iframe height</ span >
2659+ < input
2660+ className = "w-16 rounded border border-gray-500/20 bg-gray-50 px-1.5 py-1 text-right font-mono text-[11px] outline-none focus:border-blue-500 dark:bg-gray-900"
2661+ max = { 1200 }
2662+ min = { 240 }
2663+ onChange = { ( event ) => {
2664+ const nextHeight = Number ( event . currentTarget . value )
2665+ if ( ! Number . isFinite ( nextHeight ) ) return
2666+ setIframeHeight ( Math . max ( 240 , Math . min ( 1200 , nextHeight ) ) )
2667+ } }
2668+ type = "number"
2669+ value = { iframeHeight }
2670+ />
2671+ </ label >
2672+ </ div >
2673+
2674+ < div className = "grid gap-1.5" >
2675+ < EmbedOption checked = { showLegend } onCheckedChange = { setShowLegend } >
2676+ Show legend by default
2677+ </ EmbedOption >
2678+ < EmbedOption
2679+ checked = { includeTimelineRange }
2680+ disabled = { ! embedConfig . hasTimelineRange }
2681+ onCheckedChange = { setIncludeTimelineRange }
2682+ >
2683+ Include current timeline zoom
2684+ </ EmbedOption >
2685+ < EmbedOption
2686+ checked = { lockWidth }
2687+ disabled = { ! embedConfig . hasWidth }
2688+ onCheckedChange = { setLockWidth }
2689+ >
2690+ Lock current chart width
2691+ </ EmbedOption >
2692+ </ div >
2693+
2694+ < div className = "space-y-1.5" >
2695+ < div className = "flex items-center justify-between gap-2" >
2696+ < span className = "text-[11px] font-medium text-gray-500" > URL</ span >
2697+ < CopyButton
2698+ copied = { copiedTarget === 'url' }
2699+ label = "Copy URL"
2700+ onCopy = { ( ) => {
2701+ void copyText ( 'url' , embedUrl )
2702+ } }
2703+ />
2704+ </ div >
2705+ < input
2706+ className = { chartEmbedInputStyles }
2707+ readOnly
2708+ value = { embedUrl }
2709+ />
2710+ </ div >
2711+
2712+ < div className = "space-y-1.5" >
2713+ < div className = "flex items-center justify-between gap-2" >
2714+ < span className = "text-[11px] font-medium text-gray-500" >
2715+ Iframe
2716+ </ span >
2717+ < CopyButton
2718+ copied = { copiedTarget === 'iframe' }
2719+ label = "Copy iframe"
2720+ onCopy = { ( ) => {
2721+ void copyText ( 'iframe' , iframeCode )
2722+ } }
2723+ />
2724+ </ div >
2725+ < textarea
2726+ className = { twMerge ( chartEmbedInputStyles , 'min-h-20 resize-none' ) }
2727+ readOnly
2728+ value = { iframeCode }
2729+ />
2730+ </ div >
2731+ </ div >
2732+ </ DropdownMenuContent >
2733+ </ DropdownMenu >
2734+ )
2735+ }
2736+
25112737function useElementWidth ( ) {
25122738 const containerRef = React . useRef < HTMLDivElement > ( null )
25132739 const [ width , setWidth ] = React . useState ( 0 )
@@ -2972,6 +3198,7 @@ function PlotFigure({
29723198// Props for the NPMStatsChart component
29733199export type NPMStatsChartProps = {
29743200 actionsContainer ?: HTMLElement | null
3201+ embedConfig ?: NpmStatsChartEmbedConfig
29753202 onActionsMountedChange ?: ( mounted : boolean ) => void
29763203 queryData : NpmQueryData | undefined
29773204 height : number
@@ -2986,16 +3213,19 @@ export type NPMStatsChartProps = {
29863213 showDataMode : ShowDataMode
29873214 normalizeBaseline ?: boolean
29883215 showBaseline ?: boolean
3216+ showLegend ?: boolean
29893217 timelineEnd ?: number
29903218 timelineStart ?: number
29913219 animationBucketOffsetBounds ?: LatestBucketOffsetBounds
29923220 animationFrameIntervalMs ?: number
29933221 animationQueryData ?: NpmQueryData
3222+ onShowLegendChange ?: ( showLegend : boolean ) => void
29943223 onTimelineRangeChange ?: ( range : TimelineRangeValue ) => void
29953224}
29963225
29973226export function NPMStatsChart ( {
29983227 actionsContainer,
3228+ embedConfig,
29993229 onActionsMountedChange,
30003230 queryData,
30013231 height,
@@ -3010,11 +3240,13 @@ export function NPMStatsChart({
30103240 showDataMode,
30113241 normalizeBaseline = true ,
30123242 showBaseline = false ,
3243+ showLegend : controlledShowLegend ,
30133244 timelineEnd,
30143245 timelineStart,
30153246 animationBucketOffsetBounds,
30163247 animationFrameIntervalMs,
30173248 animationQueryData,
3249+ onShowLegendChange,
30183250 onTimelineRangeChange,
30193251} : NPMStatsChartProps ) {
30203252 const { containerRef, width } = useElementWidth ( )
@@ -3025,7 +3257,9 @@ export function NPMStatsChart({
30253257 ) } `
30263258 const legendRef = React . useRef < HTMLDivElement > ( null )
30273259 const plotRef = React . useRef < ReturnType < typeof Plot . plot > | null > ( null )
3028- const [ showLegend , setShowLegend ] = React . useState ( false )
3260+ const [ uncontrolledShowLegend , setUncontrolledShowLegend ] =
3261+ React . useState ( false )
3262+ const showLegend = controlledShowLegend ?? uncontrolledShowLegend
30293263 const showLegendRef = React . useRef ( showLegend )
30303264 const [ chartRendered , setChartRendered ] = React . useState ( false )
30313265 const [ exportingFormat , setExportingFormat ] =
@@ -3166,18 +3400,29 @@ export function NPMStatsChart({
31663400 ] ,
31673401 )
31683402
3403+ const handleToggleLegend = React . useCallback ( ( ) => {
3404+ const nextShowLegend = ! showLegend
3405+
3406+ if ( controlledShowLegend === undefined ) {
3407+ setUncontrolledShowLegend ( nextShowLegend )
3408+ }
3409+
3410+ onShowLegendChange ?.( nextShowLegend )
3411+ } , [ controlledShowLegend , onShowLegendChange , showLegend ] )
3412+
31693413 const canUseChartActions =
31703414 chartRendered && ! ! queryData ?. length && width > 0 && ! exportingFormat
31713415 const chartActions = actionsContainer
31723416 ? createPortal (
31733417 < ChartActions
31743418 canExportAnimation = { canExportAnimation }
31753419 disabled = { ! canUseChartActions }
3420+ embedConfig = { embedConfig }
31763421 exportingFormat = { exportingFormat }
31773422 onExport = { ( format ) => {
31783423 void handleExport ( format )
31793424 } }
3180- onToggleLegend = { ( ) => setShowLegend ( ( value ) => ! value ) }
3425+ onToggleLegend = { handleToggleLegend }
31813426 showLegend = { canUseChartActions && showLegend }
31823427 /> ,
31833428 actionsContainer ,
0 commit comments