Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
import { OverviewClient } from "@/components/overview-client";
import { getLatestRanking, getMetadata } from "@/lib/data/queries";
import {
getLatestRanking,
getMetadata,
getStateStackupSummaries
} from "@/lib/data/queries";
import { DEFAULT_METRIC_ID } from "@/lib/metrics/catalog";

export const dynamic = "force-dynamic";

export default async function HomePage() {
const { metrics, refreshStatus } = await getMetadata();
const overview = await getLatestRanking(DEFAULT_METRIC_ID);
const [{ metrics, refreshStatus }, overview, stateSummaries] = await Promise.all([
getMetadata(),
getLatestRanking(DEFAULT_METRIC_ID),
getStateStackupSummaries()
]);

return (
<OverviewClient
metrics={metrics}
refreshStatus={refreshStatus}
stateSummaries={stateSummaries}
initialMetric={overview.metric}
initialRows={overview.rows}
/>
Expand Down
35 changes: 35 additions & 0 deletions app/states/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { StateExplorerControls } from "@/components/state-explorer-controls";
import { StateProfileView } from "@/components/state-profile-view";
import { JURISDICTIONS } from "@/lib/data/jurisdictions";
import { getStateProfile } from "@/lib/data/queries";

export const dynamic = "force-dynamic";

const DEFAULT_STATE_SLUG = "california";

type StatesPageProps = {
searchParams: Promise<{
state?: string;
}>;
};

export default async function StatesPage({ searchParams }: StatesPageProps) {
const { state } = await searchParams;
const jurisdictions = [...JURISDICTIONS].sort((left, right) =>
left.name.localeCompare(right.name)
);
const selectedSlug = jurisdictions.some((jurisdiction) => jurisdiction.slug === state)
? state ?? DEFAULT_STATE_SLUG
: DEFAULT_STATE_SLUG;
const profile = await getStateProfile(selectedSlug);

return (
<div className="space-y-6">
<StateExplorerControls
jurisdictions={jurisdictions}
selectedSlug={selectedSlug}
/>
<StateProfileView profile={profile} showHeading={false} />
</div>
);
}
145 changes: 139 additions & 6 deletions components/choropleth-map.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
"use client";

import type { KeyboardEvent, MouseEvent } from "react";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { ComposableMap, Geographies, Geography } from "react-simple-maps";

import type { RankingRow } from "@/lib/types";
import type { RankingRow, StateStackupSummary } from "@/lib/types";

const GEO_URL = "https://cdn.jsdelivr.net/npm/us-atlas@3/states-10m.json";

type ChoroplethMapProps = {
rows: RankingRow[];
stateSummaries: StateStackupSummary[];
};

function fillColor(percentile: number) {
Expand All @@ -19,27 +23,114 @@ function fillColor(percentile: number) {
return "#efb366";
}

export function ChoroplethMap({ rows }: ChoroplethMapProps) {
export function ChoroplethMap({ rows, stateSummaries }: ChoroplethMapProps) {
const router = useRouter();
const containerRef = useRef<HTMLDivElement | null>(null);
const rowByFips = new Map(rows.map((row) => [row.jurisdiction.fips, row]));
const summaryByFips = new Map(
stateSummaries.map((summary) => [summary.jurisdiction.fips, summary])
);
const [hovered, setHovered] = useState<{
summary: StateStackupSummary;
position: { x: number; y: number };
} | null>(null);

function updateTooltipPosition(event: MouseEvent<SVGPathElement>) {
const bounds = containerRef.current?.getBoundingClientRect();

if (!bounds) {
return { x: 0, y: 0 };
}

return {
x: event.clientX - bounds.left,
y: event.clientY - bounds.top
};
}

return (
<div className="overflow-hidden rounded-[2rem] border border-border/70 bg-white p-4">
<div
ref={containerRef}
className="relative overflow-hidden rounded-[2rem] border border-border/70 bg-white p-4"
>
<ComposableMap projection="geoAlbersUsa" className="h-auto w-full">
<Geographies geography={GEO_URL}>
{({ geographies }: { geographies: Array<{ id: string | number; rsmKey: string }> }) =>
geographies.map((geography: { id: string | number; rsmKey: string }) => {
const row = rowByFips.get(String(geography.id).padStart(2, "0"));
const fips = String(geography.id).padStart(2, "0");
const row = rowByFips.get(fips);
const summary = summaryByFips.get(fips);

return (
<Geography
key={geography.rsmKey}
geography={geography}
tabIndex={summary ? 0 : -1}
role={summary ? "link" : undefined}
aria-label={
summary
? `Open ${summary.jurisdiction.name} in the states explorer`
: undefined
}
stroke="#f8fafc"
strokeWidth={0.75}
fill={row ? fillColor(row.percentile) : "#e7ecef"}
onMouseEnter={(event: MouseEvent<SVGPathElement>) => {
if (!summary) {
return;
}

setHovered({
summary,
position: updateTooltipPosition(event)
});
}}
onMouseMove={(event: MouseEvent<SVGPathElement>) => {
if (!summary) {
return;
}

setHovered({
summary,
position: updateTooltipPosition(event)
});
}}
onMouseLeave={() => setHovered(null)}
onFocus={() => {
if (!summary) {
return;
}

setHovered({
summary,
position: { x: 24, y: 24 }
});
}}
onBlur={() => setHovered(null)}
onClick={() => {
if (!summary) {
return;
}

router.push(`/states?state=${summary.jurisdiction.slug}`);
}}
onKeyDown={(event: KeyboardEvent<SVGPathElement>) => {
if (!summary) {
return;
}

if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
router.push(`/states?state=${summary.jurisdiction.slug}`);
}
}}
style={{
default: { outline: "none" },
hover: { outline: "none", fill: row ? "#0b5f61" : "#d9e4e8" },
default: { outline: "none", cursor: summary ? "pointer" : "default" },
hover: {
outline: "none",
fill: row ? "#0b5f61" : "#d9e4e8",
cursor: summary ? "pointer" : "default"
},
pressed: { outline: "none" }
}}
/>
Expand All @@ -48,6 +139,48 @@ export function ChoroplethMap({ rows }: ChoroplethMapProps) {
}
</Geographies>
</ComposableMap>
{hovered ? (
<div
className="pointer-events-none absolute z-10 w-72 rounded-3xl border border-border/70 bg-white/95 p-4 shadow-mellow backdrop-blur"
style={{
left: Math.min(hovered.position.x + 16, 520),
top: Math.max(hovered.position.y - 12, 16)
}}
>
<div className="text-sm font-semibold">{hovered.summary.jurisdiction.name}</div>
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-muted-foreground">
Ranking snapshot
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<div className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
Best 3
</div>
<div className="mt-2 space-y-2 text-sm">
{hovered.summary.best.map((item) => (
<div key={`${hovered.summary.jurisdiction.slug}-${item.metricId}`}>
<div className="font-medium">{item.metricLabel}</div>
<div className="text-muted-foreground">Rank #{item.rank}</div>
</div>
))}
</div>
</div>
<div>
<div className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
Worst 3
</div>
<div className="mt-2 space-y-2 text-sm">
{hovered.summary.worst.map((item) => (
<div key={`${hovered.summary.jurisdiction.slug}-${item.metricId}`}>
<div className="font-medium">{item.metricLabel}</div>
<div className="text-muted-foreground">Rank #{item.rank}</div>
</div>
))}
</div>
</div>
</div>
</div>
) : null}
</div>
);
}
11 changes: 9 additions & 2 deletions components/overview-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,26 @@ import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardDescription, CardTitle } from "@/components/ui/card";
import { formatMetricValue } from "@/lib/metrics/format";
import type { MetricDefinition, RankingRow, RefreshStatus } from "@/lib/types";
import type {
MetricDefinition,
RankingRow,
RefreshStatus,
StateStackupSummary
} from "@/lib/types";
import { formatDateLabel } from "@/lib/utils";

type OverviewClientProps = {
metrics: MetricDefinition[];
refreshStatus: RefreshStatus[];
stateSummaries: StateStackupSummary[];
initialMetric: MetricDefinition;
initialRows: RankingRow[];
};

export function OverviewClient({
metrics,
refreshStatus,
stateSummaries,
initialMetric,
initialRows
}: OverviewClientProps) {
Expand Down Expand Up @@ -117,7 +124,7 @@ export function OverviewClient({
<MetricPicker metrics={metrics} value={metric.id} onChange={handleMetricChange} />
</div>
<div className={isLoading ? "opacity-60 transition-opacity" : "transition-opacity"}>
<ChoroplethMap rows={rows} />
<ChoroplethMap rows={rows} stateSummaries={stateSummaries} />
</div>
<div className="flex flex-wrap items-center gap-3 rounded-3xl bg-muted/40 px-4 py-3 text-sm text-muted-foreground">
<span className="font-semibold text-foreground">Relative rank</span>
Expand Down
14 changes: 13 additions & 1 deletion components/site-header.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";

import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";

const NAV_ITEMS = [
{ href: "/", label: "Overview" },
{ href: "/rankings", label: "Rankings" },
{ href: "/states", label: "States" },
{ href: "/methodology", label: "Methodology" }
];

export function SiteHeader() {
const pathname = usePathname();

return (
<header className="sticky top-0 z-40 border-b border-border/70 bg-background/80 backdrop-blur">
<div className="mx-auto flex max-w-7xl items-center justify-between gap-6 px-4 py-4 sm:px-6 lg:px-8">
Expand All @@ -29,7 +36,12 @@ export function SiteHeader() {
<Link
key={item.href}
href={item.href}
className="rounded-full px-4 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
className={cn(
"rounded-full px-4 py-2 text-sm font-medium transition-colors",
pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href))
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
{item.label}
</Link>
Expand Down
Loading
Loading