Walk, transit, and school livability scores shown as sweeping radial dials with a band tint and overall average.
npx shadcn@latest add @paragon/neighborhood-score"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { Footprints, TrainFront, GraduationCap, MapPin } from "lucide-react";
import { cn } from "@/lib/utils";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
export interface ScoreMetric {
key: string;
label: string;
/** Score 0–100. */
value: number;
icon: "walk" | "transit" | "school";
/** Optional detail shown in the tooltip. */
detail?: string;
}
export interface NeighborhoodScoreProps extends React.ComponentProps<"div"> {
metrics?: ScoreMetric[];
/** Suppresses the dial sweep animation and number count-up. */
static?: boolean;
}
const ICONS = {
walk: Footprints,
transit: TrainFront,
school: GraduationCap,
} as const;
const BAND = {
strong: { color: "var(--color-success)", text: "text-success", label: "Excellent" },
fair: { color: "var(--color-warning)", text: "text-warning", label: "Fair" },
weak: { color: "var(--color-destructive)", text: "text-destructive", label: "Limited" },
} as const;
function band(value: number) {
if (value >= 80) return BAND.strong;
if (value >= 55) return BAND.fair;
return BAND.weak;
}
// Dial geometry — a single coordinate space, arc computed from circumference.
const SIZE = 64;
const THICKNESS = 6;
const CX = SIZE / 2;
const CY = SIZE / 2;
const R = (SIZE - THICKNESS) / 2 - 1;
const CIRC = 2 * Math.PI * R;
function Dial({
metric,
animate,
armed,
isStatic,
index,
}: {
metric: ScoreMetric;
animate: boolean;
armed: boolean;
isStatic: boolean;
index: number;
}) {
const v = Math.min(Math.max(metric.value, 0), 100) / 100;
const Icon = ICONS[metric.icon];
const b = band(metric.value);
// Full sweep matches value exactly: offset = C·(1 − value/100).
const offset = CIRC * (1 - (armed ? v : 0));
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`${metric.label} score ${Math.round(metric.value)} of 100`}
className={cn(
"group flex flex-col items-center gap-2 rounded-lg p-1 outline-none",
"transition-[scale] duration-150 ease-out active:not-disabled:scale-[0.97]",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
<div className="relative">
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
className="size-16 -rotate-90"
aria-hidden
>
<circle
cx={CX}
cy={CY}
r={R}
fill="none"
className="stroke-secondary"
strokeWidth={THICKNESS}
vectorEffect="non-scaling-stroke"
/>
<circle
cx={CX}
cy={CY}
r={R}
fill="none"
stroke={b.color}
strokeWidth={THICKNESS}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
strokeDasharray={CIRC}
strokeDashoffset={offset}
style={{
transition: animate
? `stroke-dashoffset 700ms var(--ease-out) ${index * 90}ms, stroke 300ms var(--ease-out)`
: "stroke 300ms var(--ease-out)",
}}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-semibold tabular-nums">
<NumberTicker
value={Math.round(metric.value)}
static={isStatic}
delay={animate ? index * 0.09 : 0}
/>
</span>
</div>
</div>
<span className="flex items-center gap-1 text-xs text-muted-foreground transition-[color] duration-(--duration-fast) group-hover:text-foreground">
<Icon aria-hidden className="size-3.5 shrink-0" />
{metric.label}
</span>
</button>
</TooltipTrigger>
<TooltipContent>
<div className="font-medium">
{metric.label} · {Math.round(metric.value)}/100
</div>
<div className={cn("text-primary-foreground/70")}>
{metric.detail ?? b.label}
</div>
</TooltipContent>
</Tooltip>
);
}
/**
* Neighborhood livability scores (walk / transit / school) shown as radial
* dials. Each ring is a full circle whose arc is computed from its
* circumference (C = 2πr, offset = C·(1 − value/100)), so the sweep matches
* the value exactly; the dials draw in on first view via stroke-dashoffset and
* tint by band (success / warning / destructive). Centers count up on a
* NumberTicker and each dial exposes a tooltip. Reduced motion (or `static`)
* renders the final state immediately. Deterministic — geometry is derived.
*/
export function NeighborhoodScore({
metrics = [],
static: isStatic = false,
className,
...props
}: NeighborhoodScoreProps) {
const ref = React.useRef<HTMLDivElement>(null);
const reducedMotion = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
const animate = !isStatic && !reducedMotion;
const armed = !animate || inView;
const avg =
metrics.length > 0
? metrics.reduce((s, m) => s + m.value, 0) / metrics.length
: 0;
const avgBand = band(avg);
return (
<TooltipProvider>
<div
ref={ref}
data-slot="neighborhood-score"
style={{
opacity: armed ? 1 : 0,
transform: armed ? "translateY(0)" : "translateY(12px)",
filter: armed ? "blur(0)" : "blur(4px)",
transition: animate
? "opacity 300ms var(--ease-out), transform 300ms var(--ease-out), filter 300ms var(--ease-out)"
: undefined,
}}
className={cn(
"w-full max-w-sm rounded-xl bg-card p-5 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-baseline justify-between gap-3">
<p className="min-w-0 truncate text-sm font-medium">Neighborhood</p>
{metrics.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`Average livability ${Math.round(avg)} of 100`}
className="shrink-0 rounded-md text-xs text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
Avg{" "}
<span
className={cn("font-semibold tabular-nums", avgBand.text)}
>
<NumberTicker value={Math.round(avg)} static={isStatic} />
</span>
<span className="text-muted-foreground">/100</span>
</button>
</TooltipTrigger>
<TooltipContent>{avgBand.label} livability</TooltipContent>
</Tooltip>
)}
</div>
{metrics.length > 0 ? (
<div className="mt-4 flex items-start justify-around gap-1">
{metrics.map((m, i) => (
<Dial
key={m.key}
metric={m}
animate={animate}
armed={armed}
isStatic={isStatic}
index={i}
/>
))}
</div>
) : (
<div className="mt-4 flex flex-col items-center gap-2 rounded-lg border border-dashed border-border py-8 text-center">
<MapPin aria-hidden className="size-5 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
No livability data yet
</p>
</div>
)}
</div>
</TooltipProvider>
);
}