import { useCallback, useRef, useState, type ReactNode } from "react"; import { CartesianGrid, Line, LineChart, ReferenceArea, Tooltip, XAxis, YAxis, } from "recharts"; import type { TooltipContentProps } from "recharts"; export interface TrendSeries { /** key into each data row holding the position value (1 = best, serpDepth = bottom band) */ dataKey: string; name: string; color: string; /** dashed = device line where nulls are plotted in the bottom "not in top N" band */ strokeDasharray?: string; } interface TooltipEntry { dataKey?: string | number; name?: string; value: number | null; color?: string; } /** Narrowed shape of a recharts tooltip payload entry (typed `any` upstream). */ interface RechartsPayloadEntry { dataKey?: string | number; name?: string; value?: number | string | null; color?: string; } /** * Shared inverted-axis line chart for rank trends. Y-axis is reversed so #1 is * pinned at the top and an improving line moves up. The very bottom of the * plot (= serpDepth) is a muted "Not in top {serpDepth}" band; callers plot * null positions at `serpDepth` so a drop reads as the line dipping into the * band rather than a silent gap. */ export function RankTrendChart({ data, series, serpDepth, height = 224, renderTooltip, showBottomBand = false, }: { data: Array>; series: TrendSeries[]; serpDepth: number; height?: number; renderTooltip: (label: number, entries: TooltipEntry[]) => ReactNode; /** Show the muted "not in top {serpDepth}" band — only meaningful for a * single keyword's position line, not for an averaged value. */ showBottomBand?: boolean; }) { const { containerRef, width: chartWidth } = useChartWidth(); return (
Google position (1 = best) Better
{chartWidth > 0 ? ( {/* Muted bottom band: not in top {serpDepth} */} {showBottomBand && ( )} ) => { const { active, payload, label } = props; if (!active || !payload?.length || typeof label !== "number") { return null; } const entries: TooltipEntry[] = payload.map( (p: RechartsPayloadEntry) => ({ dataKey: p.dataKey, name: p.name, value: typeof p.value === "number" ? p.value : null, color: p.color, }), ); return renderTooltip(label, entries); }} cursor={{ stroke: "rgba(150,150,150,0.3)" }} /> {series.map((s) => ( ))} ) : null}
); } export function formatDateTick(value: number): string { return new Date(value).toLocaleDateString("en-US", { month: "short", day: "numeric", }); } /** Responsive chart width via ResizeObserver — recharts needs an explicit px * width. Uses a callback ref so it measures whenever the chart node mounts, * including after a loading state (an effect-on-mount would miss that and leave * the width stuck at 0). Shared by the line chart and the distribution chart. */ export function useChartWidth() { const [width, setWidth] = useState(0); const observerRef = useRef(null); const containerRef = useCallback((el: HTMLDivElement | null) => { observerRef.current?.disconnect(); observerRef.current = null; if (!el) return; setWidth(el.clientWidth); const observer = new ResizeObserver(() => setWidth(el.clientWidth)); observer.observe(el); observerRef.current = observer; }, []); return { containerRef, width }; } /** 30d / 90d / All range toggle shared by the modal and overview charts. */ const TREND_RANGES = [ { label: "30d", sinceDays: 30 }, { label: "90d", sinceDays: 90 }, { label: "All", sinceDays: 730 }, ] as const; export function TrendRangeToggle({ value, onChange, }: { value: number; onChange: (sinceDays: number) => void; }) { return (
{TREND_RANGES.map((range) => ( ))}
); }