Openstatus www.openstatus.dev
at main 358 lines 10 kB view raw
1"use client"; 2 3import * as React from "react"; 4import * as RechartsPrimitive from "recharts"; 5 6import { cn } from "@/lib/utils"; 7 8// Format: { THEME_NAME: CSS_SELECTOR } 9const THEMES = { light: "", dark: ".dark" } as const; 10 11export type ChartConfig = { 12 [k in string]: { 13 label?: React.ReactNode; 14 icon?: React.ComponentType; 15 } & ( 16 | { color?: string; theme?: never } 17 | { color?: never; theme: Record<keyof typeof THEMES, string> } 18 ); 19}; 20 21type ChartContextProps = { 22 config: ChartConfig; 23}; 24 25const ChartContext = React.createContext<ChartContextProps | null>(null); 26 27export function useChart() { 28 const context = React.useContext(ChartContext); 29 30 if (!context) { 31 throw new Error("useChart must be used within a <ChartContainer />"); 32 } 33 34 return context; 35} 36 37function ChartContainer({ 38 id, 39 className, 40 children, 41 config, 42 ...props 43}: React.ComponentProps<"div"> & { 44 config: ChartConfig; 45 children: React.ComponentProps< 46 typeof RechartsPrimitive.ResponsiveContainer 47 >["children"]; 48}) { 49 const uniqueId = React.useId(); 50 const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; 51 52 return ( 53 <ChartContext.Provider value={{ config }}> 54 <div 55 data-slot="chart" 56 data-chart={chartId} 57 className={cn( 58 "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-hidden [&_.recharts-surface]:outline-hidden", 59 className, 60 )} 61 {...props} 62 > 63 <ChartStyle id={chartId} config={config} /> 64 <RechartsPrimitive.ResponsiveContainer> 65 {children} 66 </RechartsPrimitive.ResponsiveContainer> 67 </div> 68 </ChartContext.Provider> 69 ); 70} 71 72const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { 73 const colorConfig = Object.entries(config).filter( 74 ([, config]) => config.theme || config.color, 75 ); 76 77 if (!colorConfig.length) { 78 return null; 79 } 80 81 return ( 82 <style 83 // biome-ignore lint/security/noDangerouslySetInnerHtml: <explanation> 84 dangerouslySetInnerHTML={{ 85 __html: Object.entries(THEMES) 86 .map( 87 ([theme, prefix]) => ` 88${prefix} [data-chart=${id}] { 89${colorConfig 90 .map(([key, itemConfig]) => { 91 const color = 92 itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || 93 itemConfig.color; 94 return color ? ` --color-${key}: ${color};` : null; 95 }) 96 .join("\n")} 97} 98`, 99 ) 100 .join("\n"), 101 }} 102 /> 103 ); 104}; 105 106const ChartTooltip = RechartsPrimitive.Tooltip; 107 108function ChartTooltipContent({ 109 active, 110 payload, 111 className, 112 indicator = "dot", 113 hideLabel = false, 114 hideIndicator = false, 115 label, 116 labelFormatter, 117 labelClassName, 118 formatter, 119 color, 120 nameKey, 121 labelKey, 122}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> & 123 React.ComponentProps<"div"> & { 124 hideLabel?: boolean; 125 hideIndicator?: boolean; 126 indicator?: "line" | "dot" | "dashed"; 127 nameKey?: string; 128 labelKey?: string; 129 }) { 130 const { config } = useChart(); 131 132 const tooltipLabel = React.useMemo(() => { 133 if (hideLabel || !payload?.length) { 134 return null; 135 } 136 137 const [item] = payload; 138 const key = `${labelKey || item?.dataKey || item?.name || "value"}`; 139 const itemConfig = getPayloadConfigFromPayload(config, item, key); 140 const value = 141 !labelKey && typeof label === "string" 142 ? config[label as keyof typeof config]?.label || label 143 : itemConfig?.label; 144 145 if (labelFormatter) { 146 return ( 147 <div className={cn("font-medium", labelClassName)}> 148 {labelFormatter(value, payload)} 149 </div> 150 ); 151 } 152 153 if (!value) { 154 return null; 155 } 156 157 return <div className={cn("font-medium", labelClassName)}>{value}</div>; 158 }, [ 159 label, 160 labelFormatter, 161 payload, 162 hideLabel, 163 labelClassName, 164 config, 165 labelKey, 166 ]); 167 168 if (!active || !payload?.length) { 169 return null; 170 } 171 172 const nestLabel = payload.length === 1 && indicator !== "dot"; 173 174 return ( 175 <div 176 className={cn( 177 "grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl", 178 className, 179 )} 180 > 181 {!nestLabel ? tooltipLabel : null} 182 <div className="grid gap-1.5"> 183 {payload 184 .filter((item) => item.type !== "none") 185 .map((item, index) => { 186 const key = `${nameKey || item.name || item.dataKey || "value"}`; 187 const itemConfig = getPayloadConfigFromPayload(config, item, key); 188 const indicatorColor = color || item.payload.fill || item.color; 189 190 return ( 191 <div 192 key={item.dataKey} 193 className={cn( 194 "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", 195 indicator === "dot" && "items-center", 196 )} 197 > 198 {formatter && item?.value !== undefined && item.name ? ( 199 formatter(item.value, item.name, item, index, item.payload) 200 ) : ( 201 <> 202 {itemConfig?.icon ? ( 203 <itemConfig.icon /> 204 ) : ( 205 !hideIndicator && ( 206 <div 207 className={cn( 208 "shrink-0 rounded-(--radius-xs) border-(--color-border) bg-(--color-bg)", 209 { 210 "h-2.5 w-2.5": indicator === "dot", 211 "w-1": indicator === "line", 212 "w-0 border-[1.5px] border-dashed bg-transparent": 213 indicator === "dashed", 214 "my-0.5": nestLabel && indicator === "dashed", 215 }, 216 )} 217 style={ 218 { 219 "--color-bg": indicatorColor, 220 "--color-border": indicatorColor, 221 } as React.CSSProperties 222 } 223 /> 224 ) 225 )} 226 <div 227 className={cn( 228 "flex flex-1 justify-between leading-none", 229 nestLabel ? "items-end" : "items-center", 230 )} 231 > 232 <div className="grid gap-1.5"> 233 {nestLabel ? tooltipLabel : null} 234 <span className="text-muted-foreground"> 235 {itemConfig?.label || item.name} 236 </span> 237 </div> 238 {item.value && ( 239 <span className="font-medium font-mono text-foreground tabular-nums"> 240 {item.value.toLocaleString()} 241 </span> 242 )} 243 </div> 244 </> 245 )} 246 </div> 247 ); 248 })} 249 </div> 250 </div> 251 ); 252} 253 254const ChartLegend = RechartsPrimitive.Legend; 255 256function ChartLegendContent({ 257 className, 258 hideIcon = false, 259 payload, 260 verticalAlign = "bottom", 261 nameKey, 262}: React.ComponentProps<"div"> & 263 Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { 264 hideIcon?: boolean; 265 nameKey?: string; 266 }) { 267 const { config } = useChart(); 268 269 if (!payload?.length) { 270 return null; 271 } 272 273 return ( 274 <div 275 className={cn( 276 "flex items-center justify-center gap-4", 277 verticalAlign === "top" ? "pb-3" : "pt-3", 278 className, 279 )} 280 > 281 {payload 282 .filter((item) => item.type !== "none") 283 .map((item) => { 284 const key = `${nameKey || item.dataKey || "value"}`; 285 const itemConfig = getPayloadConfigFromPayload(config, item, key); 286 287 return ( 288 <div 289 key={item.value} 290 className={cn( 291 "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground", 292 )} 293 > 294 {itemConfig?.icon && !hideIcon ? ( 295 <itemConfig.icon /> 296 ) : ( 297 <div 298 className="h-2 w-2 shrink-0 rounded-(--radius-xs)" 299 style={{ 300 backgroundColor: item.color, 301 }} 302 /> 303 )} 304 {itemConfig?.label} 305 </div> 306 ); 307 })} 308 </div> 309 ); 310} 311 312// Helper to extract item config from a payload. 313export function getPayloadConfigFromPayload( 314 config: ChartConfig, 315 payload: unknown, 316 key: string, 317) { 318 if (typeof payload !== "object" || payload === null) { 319 return undefined; 320 } 321 322 const payloadPayload = 323 "payload" in payload && 324 typeof payload.payload === "object" && 325 payload.payload !== null 326 ? payload.payload 327 : undefined; 328 329 let configLabelKey: string = key; 330 331 if ( 332 key in payload && 333 typeof payload[key as keyof typeof payload] === "string" 334 ) { 335 configLabelKey = payload[key as keyof typeof payload] as string; 336 } else if ( 337 payloadPayload && 338 key in payloadPayload && 339 typeof payloadPayload[key as keyof typeof payloadPayload] === "string" 340 ) { 341 configLabelKey = payloadPayload[ 342 key as keyof typeof payloadPayload 343 ] as string; 344 } 345 346 return configLabelKey in config 347 ? config[configLabelKey] 348 : config[key as keyof typeof config]; 349} 350 351export { 352 ChartContainer, 353 ChartTooltip, 354 ChartTooltipContent, 355 ChartLegend, 356 ChartLegendContent, 357 ChartStyle, 358};