Climate

Weather Risk

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.

Install

npx shadcn@latest add @paragon/weather-risk

weather-risk.tsx

"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>
  );
}