Openstatus www.openstatus.dev
at 4c0f4c00a38753a5d0dfd7e7b7b7706dec6f1503 354 lines 9.9 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 27function 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.map((item, index) => { 184 const key = `${nameKey || item.name || item.dataKey || "value"}`; 185 const itemConfig = getPayloadConfigFromPayload(config, item, key); 186 const indicatorColor = color || item.payload.fill || item.color; 187 188 return ( 189 <div 190 key={item.dataKey} 191 className={cn( 192 "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", 193 indicator === "dot" && "items-center", 194 )} 195 > 196 {formatter && item?.value !== undefined && item.name ? ( 197 formatter(item.value, item.name, item, index, item.payload) 198 ) : ( 199 <> 200 {itemConfig?.icon ? ( 201 <itemConfig.icon /> 202 ) : ( 203 !hideIndicator && ( 204 <div 205 className={cn( 206 "shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)", 207 { 208 "h-2.5 w-2.5": indicator === "dot", 209 "w-1": indicator === "line", 210 "w-0 border-[1.5px] border-dashed bg-transparent": 211 indicator === "dashed", 212 "my-0.5": nestLabel && indicator === "dashed", 213 }, 214 )} 215 style={ 216 { 217 "--color-bg": indicatorColor, 218 "--color-border": indicatorColor, 219 } as React.CSSProperties 220 } 221 /> 222 ) 223 )} 224 <div 225 className={cn( 226 "flex flex-1 justify-between leading-none", 227 nestLabel ? "items-end" : "items-center", 228 )} 229 > 230 <div className="grid gap-1.5"> 231 {nestLabel ? tooltipLabel : null} 232 <span className="text-muted-foreground"> 233 {itemConfig?.label || item.name} 234 </span> 235 </div> 236 {item.value && ( 237 <span className="font-medium font-mono text-foreground tabular-nums"> 238 {item.value.toLocaleString()} 239 </span> 240 )} 241 </div> 242 </> 243 )} 244 </div> 245 ); 246 })} 247 </div> 248 </div> 249 ); 250} 251 252const ChartLegend = RechartsPrimitive.Legend; 253 254function ChartLegendContent({ 255 className, 256 hideIcon = false, 257 payload, 258 verticalAlign = "bottom", 259 nameKey, 260}: React.ComponentProps<"div"> & 261 Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { 262 hideIcon?: boolean; 263 nameKey?: string; 264 }) { 265 const { config } = useChart(); 266 267 if (!payload?.length) { 268 return null; 269 } 270 271 return ( 272 <div 273 className={cn( 274 "flex items-center justify-center gap-4", 275 verticalAlign === "top" ? "pb-3" : "pt-3", 276 className, 277 )} 278 > 279 {payload.map((item) => { 280 const key = `${nameKey || item.dataKey || "value"}`; 281 const itemConfig = getPayloadConfigFromPayload(config, item, key); 282 283 return ( 284 <div 285 key={item.value} 286 className={cn( 287 "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground", 288 )} 289 > 290 {itemConfig?.icon && !hideIcon ? ( 291 <itemConfig.icon /> 292 ) : ( 293 <div 294 className="h-2 w-2 shrink-0 rounded-[2px]" 295 style={{ 296 backgroundColor: item.color, 297 }} 298 /> 299 )} 300 {itemConfig?.label} 301 </div> 302 ); 303 })} 304 </div> 305 ); 306} 307 308// Helper to extract item config from a payload. 309function getPayloadConfigFromPayload( 310 config: ChartConfig, 311 payload: unknown, 312 key: string, 313) { 314 if (typeof payload !== "object" || payload === null) { 315 return undefined; 316 } 317 318 const payloadPayload = 319 "payload" in payload && 320 typeof payload.payload === "object" && 321 payload.payload !== null 322 ? payload.payload 323 : undefined; 324 325 let configLabelKey: string = key; 326 327 if ( 328 key in payload && 329 typeof payload[key as keyof typeof payload] === "string" 330 ) { 331 configLabelKey = payload[key as keyof typeof payload] as string; 332 } else if ( 333 payloadPayload && 334 key in payloadPayload && 335 typeof payloadPayload[key as keyof typeof payloadPayload] === "string" 336 ) { 337 configLabelKey = payloadPayload[ 338 key as keyof typeof payloadPayload 339 ] as string; 340 } 341 342 return configLabelKey in config 343 ? config[configLabelKey] 344 : config[key as keyof typeof config]; 345} 346 347export { 348 ChartContainer, 349 ChartTooltip, 350 ChartTooltipContent, 351 ChartLegend, 352 ChartLegendContent, 353 ChartStyle, 354};