mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
Clickable card dialogs, hover sparkline tooltips, fix row cropping
- Each patient card is now a Dialog trigger (nativeButton=false for accessible click/keyboard) that opens a roomier detail dialog with expanded per-topic info (full demographics, vitals/labs min·max·latest with a larger chart, all labs with dates, full visit notes). - Sparkline shows the value (dot + tooltip) at the nearest reading on mousemove; reused larger in the dialogs. - Row padding pb-2 → p-2 so card tops and the first/last card edges (ring/shadow/rounded corners) are no longer clipped by the scroller. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,9 +11,18 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Sparkline } from "@/components/chat/sparkline";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AllergySeverity, LabFlag, Patient, Trend } from "@/lib/patients";
|
||||
|
||||
type BadgeVariant = "default" | "secondary" | "destructive" | "outline";
|
||||
@@ -45,8 +54,10 @@ const statusVariant: Record<Patient["status"], BadgeVariant> = {
|
||||
|
||||
const sexLabel: Record<Patient["sex"], string> = { F: "Female", M: "Male" };
|
||||
|
||||
// Fixed width so the cards sit in a horizontal scroll row instead of squashing.
|
||||
const rowCard = "w-80 shrink-0 snap-start";
|
||||
// Fixed width so the cards sit in a horizontal scroll row instead of squashing,
|
||||
// plus a subtle clickable affordance (they open a detail dialog).
|
||||
const rowCard =
|
||||
"w-80 shrink-0 cursor-pointer snap-start text-left outline-none transition hover:ring-foreground/20 focus-visible:ring-2 focus-visible:ring-ring";
|
||||
|
||||
function SectionLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
@@ -75,24 +86,121 @@ function Row({ label, value }: { label: ReactNode; value: ReactNode }) {
|
||||
}
|
||||
|
||||
function TrendBlock({ trend }: { trend: Trend }) {
|
||||
const latest = trend.points.at(-1);
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<SectionLabel>{`${trend.label} · last ${trend.points.length}`}</SectionLabel>
|
||||
<span className="text-foreground">
|
||||
{latest}
|
||||
{trend.points.at(-1)}
|
||||
<span className="text-muted-foreground"> {trend.unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Sparkline points={trend.points} />
|
||||
<Sparkline points={trend.points} unit={trend.unit} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({ patient }: { patient: Patient }) {
|
||||
function TrendDetail({ trend }: { trend: Trend }) {
|
||||
const min = Math.min(...trend.points);
|
||||
const max = Math.max(...trend.points);
|
||||
return (
|
||||
<Card className={rowCard} size="sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<SectionLabel>{`${trend.label} · last ${trend.points.length} readings`}</SectionLabel>
|
||||
<div className="flex gap-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Latest{" "}
|
||||
<span className="text-foreground">
|
||||
{trend.points.at(-1)} {trend.unit}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Min <span className="text-foreground">{min}</span>
|
||||
</span>
|
||||
<span>
|
||||
Max <span className="text-foreground">{max}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Sparkline className="h-24" points={trend.points} unit={trend.unit} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertBadges({ alerts }: { alerts: string[] }) {
|
||||
if (alerts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{alerts.map((alert) => (
|
||||
<Badge key={alert} variant="outline">
|
||||
{alert}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A card that previews `children` and opens a roomier dialog of `detail` on click.
|
||||
function ExpandableCard({
|
||||
title,
|
||||
description,
|
||||
detail,
|
||||
children,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
detail: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
nativeButton={false}
|
||||
render={<Card className={rowCard} size="sm" />}
|
||||
>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[80dvh] gap-4 overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
||||
</DialogHeader>
|
||||
{detail}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({ patient }: { patient: Patient }) {
|
||||
const idLine = `${patient.age} · ${sexLabel[patient.sex]} · MRN ${patient.fileNumber}`;
|
||||
return (
|
||||
<ExpandableCard
|
||||
description={idLine}
|
||||
detail={
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<Stat label="Full name" value={patient.name} />
|
||||
<Stat label="MRN" value={patient.fileNumber} />
|
||||
<Stat label="Age" value={patient.age} />
|
||||
<Stat label="Sex" value={sexLabel[patient.sex]} />
|
||||
<Stat label="Primary care" value={patient.pcp} />
|
||||
<Stat
|
||||
label="Status"
|
||||
value={<span className="capitalize">{patient.status}</span>}
|
||||
/>
|
||||
<Stat label="Last seen" value={patient.encounters[0]?.date ?? "—"} />
|
||||
<Stat
|
||||
label="Allergies"
|
||||
value={patient.allergies.length || "None"}
|
||||
/>
|
||||
</div>
|
||||
<AlertBadges alerts={patient.alerts} />
|
||||
</div>
|
||||
}
|
||||
title={patient.name}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="size-10">
|
||||
@@ -100,9 +208,7 @@ function SummaryCard({ patient }: { patient: Patient }) {
|
||||
</Avatar>
|
||||
<div className="grid flex-1 gap-0.5">
|
||||
<CardTitle>{patient.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{patient.age} · {sexLabel[patient.sex]} · MRN {patient.fileNumber}
|
||||
</CardDescription>
|
||||
<CardDescription>{idLine}</CardDescription>
|
||||
</div>
|
||||
<Badge className="capitalize" variant={statusVariant[patient.status]}>
|
||||
{patient.status}
|
||||
@@ -116,17 +222,9 @@ function SummaryCard({ patient }: { patient: Patient }) {
|
||||
<Stat label="Active meds" value={patient.medications.length} />
|
||||
<Stat label="Open problems" value={patient.problems.length} />
|
||||
</div>
|
||||
{patient.alerts.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{patient.alerts.map((alert) => (
|
||||
<Badge key={alert} variant="outline">
|
||||
{alert}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<AlertBadges alerts={patient.alerts} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,29 +236,78 @@ function VitalsCard({ patient }: { patient: Patient }) {
|
||||
{ label: "Temp", value: vitals.temp },
|
||||
{ label: "SpO₂", value: vitals.spo2 },
|
||||
];
|
||||
const vitalsGrid = (gapY: string) => (
|
||||
<div className={cn("grid grid-cols-2 gap-x-4", gapY)}>
|
||||
{vitalItems.map((item) => (
|
||||
<Stat key={item.label} label={item.label} value={item.value} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className={rowCard} size="sm">
|
||||
<ExpandableCard
|
||||
description={`Taken ${vitals.takenAt}`}
|
||||
detail={
|
||||
<div className="flex flex-col gap-4">
|
||||
{vitalsGrid("gap-y-3")}
|
||||
<Separator />
|
||||
<TrendDetail trend={patient.vitalsTrend} />
|
||||
</div>
|
||||
}
|
||||
title="Vitals"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Vitals</CardTitle>
|
||||
<CardDescription>Taken {vitals.takenAt}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-3">
|
||||
{vitalItems.map((item) => (
|
||||
<Stat key={item.label} label={item.label} value={item.value} />
|
||||
))}
|
||||
</div>
|
||||
{vitalsGrid("gap-y-3")}
|
||||
<Separator />
|
||||
<TrendBlock trend={patient.vitalsTrend} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
|
||||
function labValue(value: string, flag: LabFlag) {
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{value}
|
||||
<Badge className="capitalize" variant={labFlagVariant[flag]}>
|
||||
{flag}
|
||||
</Badge>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LabsCard({ patient }: { patient: Patient }) {
|
||||
return (
|
||||
<Card className={rowCard} size="sm">
|
||||
<ExpandableCard
|
||||
description={`As of ${patient.labs[0]?.takenAt ?? "—"}`}
|
||||
detail={
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{patient.labs.map((lab) => (
|
||||
<div
|
||||
className="flex items-center justify-between gap-3"
|
||||
key={lab.name}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-foreground">{lab.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{lab.takenAt}
|
||||
</span>
|
||||
</div>
|
||||
{labValue(lab.value, lab.flag)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Separator />
|
||||
<TrendDetail trend={patient.labTrend} />
|
||||
</div>
|
||||
}
|
||||
title="Labs"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Labs</CardTitle>
|
||||
<CardDescription>As of {patient.labs[0]?.takenAt ?? "—"}</CardDescription>
|
||||
@@ -171,147 +318,169 @@ function LabsCard({ patient }: { patient: Patient }) {
|
||||
<Row
|
||||
key={lab.name}
|
||||
label={lab.name}
|
||||
value={
|
||||
<span className="flex items-center gap-2">
|
||||
{lab.value}
|
||||
<Badge className="capitalize" variant={labFlagVariant[lab.flag]}>
|
||||
{lab.flag}
|
||||
</Badge>
|
||||
</span>
|
||||
}
|
||||
value={labValue(lab.value, lab.flag)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Separator />
|
||||
<TrendBlock trend={patient.labTrend} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
|
||||
function MedicationsCard({ patient }: { patient: Patient }) {
|
||||
const list = (
|
||||
<div className="flex flex-col gap-2">
|
||||
{patient.medications.map((med) => (
|
||||
<Row
|
||||
key={med.name}
|
||||
label={med.name}
|
||||
value={`${med.dose} · ${med.frequency}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Card className={rowCard} size="sm">
|
||||
<ExpandableCard
|
||||
description={`${patient.medications.length} active`}
|
||||
detail={list}
|
||||
title="Medications"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Medications</CardTitle>
|
||||
<CardDescription>{patient.medications.length} active</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{patient.medications.map((med) => (
|
||||
<Row
|
||||
key={med.name}
|
||||
label={med.name}
|
||||
value={`${med.dose} · ${med.frequency}`}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CardContent>{list}</CardContent>
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ProblemsCard({ patient }: { patient: Patient }) {
|
||||
const list = (
|
||||
<div className="flex flex-col gap-2">
|
||||
{patient.problems.map((problem) => (
|
||||
<Row
|
||||
key={problem.label}
|
||||
label={problem.label}
|
||||
value={`since ${problem.since}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Card className={rowCard} size="sm">
|
||||
<ExpandableCard
|
||||
description={`${patient.problems.length} active`}
|
||||
detail={list}
|
||||
title="Problems"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Problems</CardTitle>
|
||||
<CardDescription>{patient.problems.length} active</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{patient.problems.map((problem) => (
|
||||
<Row
|
||||
key={problem.label}
|
||||
label={problem.label}
|
||||
value={`since ${problem.since}`}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CardContent>{list}</CardContent>
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
|
||||
function AllergiesList({ patient }: { patient: Patient }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<AlertBadges alerts={patient.alerts} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<SectionLabel>Allergies</SectionLabel>
|
||||
{patient.allergies.length === 0 ? (
|
||||
<p className="text-muted-foreground">No known allergies.</p>
|
||||
) : (
|
||||
patient.allergies.map((allergy) => (
|
||||
<Row
|
||||
key={allergy.substance}
|
||||
label={
|
||||
<>
|
||||
{allergy.substance}
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
— {allergy.reaction}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
value={
|
||||
<Badge
|
||||
className="capitalize"
|
||||
variant={severityVariant[allergy.severity]}
|
||||
>
|
||||
{allergy.severity}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AllergiesCard({ patient }: { patient: Patient }) {
|
||||
return (
|
||||
<Card className={rowCard} size="sm">
|
||||
<ExpandableCard
|
||||
detail={<AllergiesList patient={patient} />}
|
||||
title="Allergies & alerts"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Allergies & alerts</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{patient.alerts.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{patient.alerts.map((alert) => (
|
||||
<Badge key={alert} variant="outline">
|
||||
{alert}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<SectionLabel>Allergies</SectionLabel>
|
||||
{patient.allergies.length === 0 ? (
|
||||
<p className="text-muted-foreground">No known allergies.</p>
|
||||
) : (
|
||||
patient.allergies.map((allergy) => (
|
||||
<Row
|
||||
key={allergy.substance}
|
||||
label={
|
||||
<>
|
||||
{allergy.substance}
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
— {allergy.reaction}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
value={
|
||||
<Badge
|
||||
className="capitalize"
|
||||
variant={severityVariant[allergy.severity]}
|
||||
>
|
||||
{allergy.severity}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<CardContent>
|
||||
<AllergiesList patient={patient} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
|
||||
function VisitsList({ patient }: { patient: Patient }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{patient.encounters.map((encounter) => (
|
||||
<div
|
||||
className="flex flex-col gap-0.5"
|
||||
key={encounter.date + encounter.type}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-foreground">{encounter.type}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{encounter.date}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground">{encounter.summary}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{encounter.provider}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VisitsCard({ patient }: { patient: Patient }) {
|
||||
return (
|
||||
<Card className={rowCard} size="sm">
|
||||
<ExpandableCard
|
||||
description={`${patient.encounters.length} recent`}
|
||||
detail={<VisitsList patient={patient} />}
|
||||
title="Recent visits"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent visits</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{patient.encounters.map((encounter) => (
|
||||
<div
|
||||
className="flex flex-col gap-0.5"
|
||||
key={encounter.date + encounter.type}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-foreground">{encounter.type}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{encounter.date}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground">{encounter.summary}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{encounter.provider}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<CardContent>
|
||||
<VisitsList patient={patient} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingCards() {
|
||||
return (
|
||||
<>
|
||||
<Card className={rowCard} size="sm">
|
||||
<Card className="w-80 shrink-0 snap-start" size="sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
@@ -328,7 +497,7 @@ function LoadingCards() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
{[0, 1, 2, 3, 4, 5].map((card) => (
|
||||
<Card className={rowCard} key={card} size="sm">
|
||||
<Card className="w-80 shrink-0 snap-start" key={card} size="sm">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</CardHeader>
|
||||
@@ -358,7 +527,7 @@ export function PatientResult({ status, fileNumber, patient }: PatientResultProp
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="no-scrollbar flex w-full snap-x items-stretch gap-4 overflow-x-auto pb-2">
|
||||
<div className="no-scrollbar flex w-full snap-x items-stretch gap-4 overflow-x-auto p-2">
|
||||
{status === "loading" || !patient ? (
|
||||
<LoadingCards />
|
||||
) : (
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { type MouseEvent, useId, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// A tiny dependency-free trend chart: a line with a soft shaded fill beneath it.
|
||||
// Stretches to its container width; colored via `currentColor` (default text-primary).
|
||||
// Moving the cursor over it reveals the value at the nearest reading.
|
||||
export function Sparkline({
|
||||
points,
|
||||
unit,
|
||||
className,
|
||||
}: {
|
||||
points: number[];
|
||||
unit?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const gradientId = useId();
|
||||
const [active, setActive] = useState<number | null>(null);
|
||||
|
||||
if (points.length === 0) {
|
||||
return null;
|
||||
@@ -22,7 +26,7 @@ export function Sparkline({
|
||||
const width = 100;
|
||||
const top = 3;
|
||||
const bottom = 29;
|
||||
const baseline = 32;
|
||||
const viewHeight = 32;
|
||||
const min = Math.min(...points);
|
||||
const max = Math.max(...points);
|
||||
const range = max - min;
|
||||
@@ -34,35 +38,73 @@ export function Sparkline({
|
||||
range === 0
|
||||
? (top + bottom) / 2
|
||||
: bottom - ((value - min) / range) * (bottom - top);
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
return { value, x, y, xPct: x, yPct: (y / viewHeight) * 100 };
|
||||
});
|
||||
|
||||
const line = coords.map((c, i) => `${i === 0 ? "M" : "L"}${c}`).join(" ");
|
||||
const area = `${line} L${width},${baseline} L0,${baseline} Z`;
|
||||
const line = coords
|
||||
.map((c, i) => `${i === 0 ? "M" : "L"}${c.x.toFixed(2)},${c.y.toFixed(2)}`)
|
||||
.join(" ");
|
||||
const area = `${line} L${width},${viewHeight} L0,${viewHeight} Z`;
|
||||
|
||||
const handleMove = (event: MouseEvent<HTMLDivElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const ratio = (event.clientX - rect.left) / rect.width;
|
||||
const index = Math.round(ratio * (points.length - 1));
|
||||
setActive(Math.max(0, Math.min(points.length - 1, index)));
|
||||
};
|
||||
|
||||
const point = active === null ? null : coords[active];
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={cn("h-10 w-full text-primary", className)}
|
||||
preserveAspectRatio="none"
|
||||
viewBox={`0 0 ${width} ${baseline}`}
|
||||
<div
|
||||
className={cn("relative h-10 w-full text-primary", className)}
|
||||
onMouseLeave={() => setActive(null)}
|
||||
onMouseMove={handleMove}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="currentColor" stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={area} fill={`url(#${gradientId})`} stroke="none" />
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="size-full"
|
||||
preserveAspectRatio="none"
|
||||
viewBox={`0 0 ${width} ${viewHeight}`}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="currentColor" stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={area} fill={`url(#${gradientId})`} stroke="none" />
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{point && (
|
||||
<>
|
||||
<span
|
||||
className="pointer-events-none absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary ring-2 ring-popover"
|
||||
style={{ left: `${point.xPct}%`, top: `${point.yPct}%` }}
|
||||
/>
|
||||
<span
|
||||
className="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-[calc(100%+6px)] rounded-md bg-popover px-1.5 py-0.5 text-xs whitespace-nowrap text-popover-foreground shadow ring-1 ring-foreground/10"
|
||||
style={{
|
||||
left: `${Math.min(85, Math.max(15, point.xPct))}%`,
|
||||
top: `${point.yPct}%`,
|
||||
}}
|
||||
>
|
||||
<span className="text-foreground">{point.value}</span>
|
||||
{unit ? (
|
||||
<span className="text-muted-foreground"> {unit}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user