Skip to content

Commit 09622c9

Browse files
committed
Add embeddable npm stats chart
1 parent d8b3481 commit 09622c9

6 files changed

Lines changed: 700 additions & 4 deletions

File tree

src/components/npm-stats/NPMStatsChart.tsx

Lines changed: 248 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'
33
import * as Plot from '@observablehq/plot'
44
import * as d3 from 'd3'
55
import { GIFEncoder, applyPalette, quantize } from 'gifenc'
6-
import { Download, List } from 'lucide-react'
6+
import { Check, Code2, Copy, Download, List } from 'lucide-react'
77
import {
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

12711281
const 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'
12881298
const 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'
12901302
const svgNamespace = 'http://www.w3.org/2000/svg'
12911303

12921304
function getExportFileName(format: ChartExportFormat) {
@@ -2416,13 +2428,15 @@ function renderPlotLegend({
24162428
function 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+
25112737
function 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
29733199
export 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

29973226
export 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

Comments
 (0)