A climate risk card for a physical asset with a semicircular overall gauge that fills on mount and banded flood, heat, and wind hazard bars.
npx shadcn@latest add @paragon/weather-risk"use client";
import * as React from "react";
import { Droplets, Flame, Wind, MapPin } from "lucide-react";
import { cn } from "@/lib/utils";
type Hazard = "flood" | "heat" | "wind";
export interface WeatherRiskHazard {
kind: Hazard;
/** Risk score 0–100. */
score: number;
}
export interface WeatherRiskProps extends React.ComponentProps<"div"> {
asset?: string;
location?: string;
hazards?: WeatherRiskHazard[];
/** Time horizon for the assessment, e.g. "2050, RCP 4.5". */
horizon?: string;
}
const META: Record<
Hazard,
{ label: string; icon: React.ElementType }
> = {
flood: { label: "Flood", icon: Droplets },
heat: { label: "Extreme heat", icon: Flame },
wind: { label: "Wind", icon: Wind },
};
function band(score: number) {
if (score >= 67) return { label: "High", tone: "text-destructive", bar: "bg-destructive" };
if (score >= 34) return { label: "Moderate", tone: "text-warning", bar: "bg-warning" };
return { label: "Low", tone: "text-success", bar: "bg-success" };
}
const DEFAULT_HAZARDS: WeatherRiskHazard[] = [
{ kind: "flood", score: 74 },
{ kind: "heat", score: 52 },
{ kind: "wind", score: 21 },
];
const GR = 44;
const GC = Math.PI * GR; // half-circle circumference
/**
* A climate/weather risk gauge for a physical asset: a semicircular composite
* gauge for the overall score (arc fills on mount via a dashoffset keyframe,
* reduced-motion safe), plus per-hazard bars for flood, heat, and wind, each
* banded low/moderate/high by the semantic status tokens.
*/
export function WeatherRisk({
asset = "Gulf Terminal 4",
location = "Houston, TX",
hazards = DEFAULT_HAZARDS,
horizon = "2050 · RCP 4.5",
className,
...props
}: WeatherRiskProps) {
const overall = Math.round(
hazards.reduce((a, h) => a + h.score, 0) / Math.max(hazards.length, 1),
);
const overallBand = band(overall);
const fill = (Math.min(Math.max(overall, 0), 100) / 100) * GC;
return (
<div
data-slot="weather-risk"
className={cn(
"w-full max-w-sm rounded-xl bg-card p-5 text-card-foreground shadow-border",
className,
)}
{...props}
>
<style href="paragon-weather-risk" precedence="paragon">{`
@keyframes paragon-weather-fill { from { stroke-dashoffset: ${GC}; } }
@media (prefers-reduced-motion: reduce) {
[data-weather-arc] { animation: none !important; }
}
`}</style>
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium">{asset}</h3>
<p className="flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="size-3" />
{location}
</p>
</div>
<span className="rounded-full bg-secondary px-2.5 py-0.5 text-xs text-muted-foreground">
{horizon}
</span>
</div>
<div className="mt-2 flex flex-col items-center">
<svg
width={160}
height={92}
viewBox="0 0 160 92"
role="img"
aria-label={`Overall climate risk ${overall} of 100, ${overallBand.label}`}
>
<path
d="M 16 80 A 64 64 0 0 1 144 80"
fill="none"
className="stroke-secondary"
strokeWidth={12}
strokeLinecap="round"
/>
<path
data-weather-arc
d="M 16 80 A 64 64 0 0 1 144 80"
fill="none"
className={cn("stroke-current", overallBand.tone)}
strokeWidth={12}
strokeLinecap="round"
strokeDasharray={`${fill} ${GC}`}
strokeDashoffset={0}
style={{
animation: "paragon-weather-fill 0.9s var(--ease-out) both",
}}
/>
</svg>
<div className="-mt-6 flex flex-col items-center">
<span className="text-2xl font-semibold tabular-nums">{overall}</span>
<span className={cn("text-xs font-medium", overallBand.tone)}>
{overallBand.label} risk
</span>
</div>
</div>
<ul className="mt-4 space-y-2.5">
{hazards.map((h) => {
const m = META[h.kind];
const b = band(h.score);
return (
<li key={h.kind} className="flex items-center gap-3">
<m.icon className={cn("size-4 shrink-0", b.tone)} />
<span className="w-24 shrink-0 text-sm">{m.label}</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-secondary">
<div
className={cn(
"h-full origin-left rounded-full transition-transform duration-500 ease-out",
b.bar,
)}
style={{ transform: `scaleX(${h.score / 100})` }}
/>
</div>
<span
className={cn(
"w-16 shrink-0 text-right text-xs font-medium tabular-nums",
b.tone,
)}
>
{b.label}
</span>
</li>
);
})}
</ul>
</div>
);
}