diff --git a/frontend/components/graph/record-graph.tsx b/frontend/components/graph/record-graph.tsx index 3bbdcd6..ddde5db 100644 --- a/frontend/components/graph/record-graph.tsx +++ b/frontend/components/graph/record-graph.tsx @@ -3,7 +3,9 @@ // An Obsidian-style knowledge graph of one patient's record: the patient sits // at the centre, their problems (illnesses) and encounters (visits) orbit it, // and a visit links to a problem when the visit references it. Layout is a -// d3-force simulation computed once; rendering + pan/zoom/drag is React Flow. +// d3-force simulation computed once; rendering + pan/zoom is React Flow. Nodes +// are round "dots" with the label underneath, and hovering a node highlights it +// and its neighbours while dimming the rest (the Obsidian focus effect). import { Background, @@ -25,7 +27,7 @@ import { type SimulationLinkDatum, type SimulationNodeDatum, } from "d3-force"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import type { Patient } from "@/lib/patients"; @@ -41,29 +43,64 @@ type SimNode = SimulationNodeDatum & { }; type SimLink = SimulationLinkDatum; -type RecordNodeData = { label: string; sub?: string; kind: Kind }; - -const kindClass: Record = { - patient: "bg-primary text-primary-foreground border-primary px-4 py-3 text-sm", - problem: "bg-destructive/12 text-foreground border-destructive/50", - visit: "bg-muted text-foreground border-border", +type RecordNodeData = { + label: string; + sub?: string; + kind: Kind; + // Hover focus: "active" = this node or a neighbour, "dim" = unrelated, null = + // nothing hovered (everything at full strength). + focus: "active" | "dim" | null; }; -// A pill node with hidden connection handles so edges meet it cleanly. +// Dot size + colour per kind. The patient is the biggest, brightest hub. +const dotClass: Record = { + patient: "size-5 bg-primary shadow-[0_0_16px_2px] shadow-primary/40", + problem: "size-3.5 bg-destructive shadow-[0_0_12px_1px] shadow-destructive/30", + visit: "size-3 bg-foreground/55", +}; + +// A round node with the label below it and hidden connection handles so edges +// meet the dot's centre cleanly. function RecordNode({ data }: NodeProps) { - const { label, sub, kind } = data as RecordNodeData; + const { label, sub, kind, focus } = data as RecordNodeData; return (
- -
{label}
- {sub ?
{sub}
: null} - +
+ + +
+
+ + {label} + + {sub ? ( + + {sub} + + ) : null} +
); } @@ -75,27 +112,30 @@ const nodeTypes = { record: RecordNode }; // the clustered "hub" look. function buildGraph(patient: Patient): { nodes: SimNode[]; - edges: { source: string; target: string }[]; + edges: { id: string; source: string; target: string }[]; } { const nodes: SimNode[] = [ { id: "patient", kind: "patient", label: patient.name }, ]; - const edges: { source: string; target: string }[] = []; + const edges: { id: string; source: string; target: string }[] = []; + let edgeSeq = 0; + const link = (source: string, target: string) => + edges.push({ id: `e-${edgeSeq++}`, source, target }); patient.problems.forEach((p, i) => { const id = `prob-${i}`; nodes.push({ id, kind: "problem", label: p.label, sub: p.since }); - edges.push({ source: "patient", target: id }); + link("patient", id); }); patient.encounters.forEach((e, i) => { const id = `enc-${i}`; nodes.push({ id, kind: "visit", label: e.type, sub: e.date }); - edges.push({ source: "patient", target: id }); + link("patient", id); const hay = `${e.type} ${e.summary}`.toLowerCase(); patient.problems.forEach((p, pi) => { if (p.label && hay.includes(p.label.toLowerCase())) { - edges.push({ source: id, target: `prob-${pi}` }); + link(id, `prob-${pi}`); } }); }); @@ -104,7 +144,10 @@ function buildGraph(patient: Patient): { } // Run the force simulation to completion so positions are stable on first paint. -function layout(nodes: SimNode[], edges: { source: string; target: string }[]) { +function layout( + nodes: SimNode[], + edges: { source: string; target: string }[], +) { const links: SimLink[] = edges.map((e) => ({ ...e })); forceSimulation(nodes) .force("charge", forceManyBody().strength(-340)) @@ -112,11 +155,11 @@ function layout(nodes: SimNode[], edges: { source: string; target: string }[]) { "link", forceLink(links) .id((d) => d.id) - .distance(96) - .strength(0.45), + .distance(110) + .strength(0.5), ) .force("center", forceCenter(240, 170)) - .force("collide", forceCollide(50)) + .force("collide", forceCollide(56)) .stop() .tick(320); } @@ -129,50 +172,86 @@ export function RecordGraph({ className?: string; }) { const { t } = useTranslation(); + // The node the pointer is over; drives the focus highlight. + const [hover, setHover] = useState(null); - const { nodes, edges } = useMemo(() => { + // Stable base layout (positions + adjacency), computed once per patient. + const base = useMemo(() => { const g = buildGraph(patient); layout(g.nodes, g.edges); - const rfNodes: Node[] = g.nodes.map((n) => ({ + // Adjacency for the hover focus: a node is "active" when it is hovered or + // directly linked to the hovered node. + const neighbours = new Map>(); + for (const n of g.nodes) neighbours.set(n.id, new Set([n.id])); + for (const e of g.edges) { + neighbours.get(e.source)?.add(e.target); + neighbours.get(e.target)?.add(e.source); + } + return { nodes: g.nodes, edges: g.edges, neighbours }; + }, [patient]); + + const nodes: Node[] = useMemo(() => { + const active = hover ? base.neighbours.get(hover) : null; + return base.nodes.map((n) => ({ id: n.id, type: "record", position: { x: n.x ?? 0, y: n.y ?? 0 }, - data: { label: n.label, sub: n.sub, kind: n.kind }, + data: { + label: n.label, + sub: n.sub, + kind: n.kind, + focus: active ? (active.has(n.id) ? "active" : "dim") : null, + } satisfies RecordNodeData, })); - const rfEdges: Edge[] = g.edges.map((e, i) => ({ - id: `e-${i}`, - source: e.source, - target: e.target, - style: { stroke: "var(--border)", strokeWidth: 1.5 }, - })); - return { nodes: rfNodes, edges: rfEdges }; - }, [patient]); + }, [base, hover]); + + const edges: Edge[] = useMemo(() => { + return base.edges.map((e) => { + const touches = !hover || e.source === hover || e.target === hover; + return { + id: e.id, + source: e.source, + target: e.target, + style: { + stroke: touches && hover ? "var(--primary)" : "var(--border)", + strokeWidth: touches && hover ? 1.75 : 1.25, + opacity: hover && !touches ? 0.12 : 0.7, + transition: "stroke 0.2s, opacity 0.2s", + }, + }; + }); + }, [base, hover]); if (patient.problems.length === 0 && patient.encounters.length === 0) { return ( -

{t("patientCard.graph.empty")}

+

+ {t("patientCard.graph.empty")} +

); } return (
setHover(node.id)} + onNodeMouseLeave={() => setHover(null)} panOnScroll proOptions={{ hideAttribution: true }} zoomOnScroll={false} > - +
diff --git a/frontend/components/patients/patient-detail-sheet.tsx b/frontend/components/patients/patient-detail-sheet.tsx index 2134a71..95fadbc 100644 --- a/frontend/components/patients/patient-detail-sheet.tsx +++ b/frontend/components/patients/patient-detail-sheet.tsx @@ -5,9 +5,17 @@ import { useTranslation } from "react-i18next"; import { AiBadge } from "@/components/ai-badge"; import { PatientFormDialog } from "@/components/chat/patient-form-dialog"; +import { RecordGraph } from "@/components/graph/record-graph"; import { PatientDetail } from "@/components/patients/patient-detail"; import { TransferPatientDialog } from "@/components/patients/transfer-patient-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { + Dialog, + DialogDescription, + DialogHeader, + DialogPopup, + DialogTitle, +} from "@/components/ui/dialog"; import { Sheet, SheetHeader, @@ -73,6 +81,8 @@ export function PatientDetailSheet({ const [editOpen, setEditOpen] = useState(false); const [transferOpen, setTransferOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); + // Graph popped out of the sheet into its own dialog (the sheet closes first). + const [graphOpen, setGraphOpen] = useState(false); // Related records aggregated into the sheet for a 360° view. const [prescriptions, setPrescriptions] = useState([]); const [appointments, setAppointments] = useState([]); @@ -161,6 +171,10 @@ export function PatientDetailSheet({ setEditKey((k) => k + 1); setEditOpen(true); }} + onOpenGraph={() => { + onOpenChange(false); + setGraphOpen(true); + }} onTransfer={ canTransfer ? () => setTransferOpen(true) : undefined } @@ -203,6 +217,25 @@ export function PatientDetailSheet({ title={t("patients.delete.title")} /> )} + + {patient && ( + + + + + {t("patientCard.graph.title")} · {patient.name} + + + {t("patientCard.graph.hint")} + + + + + + )} ); } diff --git a/frontend/components/patients/patient-detail.tsx b/frontend/components/patients/patient-detail.tsx index b0bbe1b..b17307a 100644 --- a/frontend/components/patients/patient-detail.tsx +++ b/frontend/components/patients/patient-detail.tsx @@ -1,14 +1,23 @@ "use client"; -import { ArrowLeftRight, Pencil, Trash2 } from "lucide-react"; -import type { ReactNode } from "react"; +import { ArrowLeftRight, Network, Pencil, Trash2 } from "lucide-react"; +import { type ReactNode, useState } from "react"; import { useTranslation } from "react-i18next"; import { Sparkline } from "@/components/chat/sparkline"; -import { RecordGraph } from "@/components/graph/record-graph"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@/components/ui/dialog"; import type { Appointment } from "@/lib/appointments"; import { formatMoney, @@ -17,6 +26,16 @@ import { } from "@/lib/invoices"; import type { AllergySeverity, LabFlag, Patient, Trend } from "@/lib/patients"; import type { Prescription } from "@/lib/prescriptions"; +import { cn } from "@/lib/utils"; + +// A record "file" surfaced both in the graph and the sheet's clickable list. +type RecordFile = { + id: string; + kind: "problem" | "visit"; + title: string; + sub: string; + rows: { label: string; value: string }[]; +}; type BadgeVariant = "default" | "secondary" | "destructive" | "outline"; @@ -91,6 +110,7 @@ export function PatientDetail({ onEdit, onTransfer, onDelete, + onOpenGraph, prescriptions, appointments, invoices, @@ -99,6 +119,8 @@ export function PatientDetail({ onEdit?: () => void; onTransfer?: () => void; onDelete?: () => void; + // Pops the record graph out into its own dialog (closing this sheet). + onOpenGraph?: () => void; prescriptions?: Prescription[]; appointments?: Appointment[]; invoices?: Invoice[]; @@ -106,6 +128,30 @@ export function PatientDetail({ const { t } = useTranslation(); const sex = t(`patientCard.sex.${patient.sex}`); const idLine = `${patient.age} · ${sex} · MRN ${patient.fileNumber}`; + // The record "file" opened in a detail dialog from the records list. + const [openFile, setOpenFile] = useState(null); + + // The same problems + visits the graph plots, as a clickable list. + const files: RecordFile[] = [ + ...patient.problems.map((p, i) => ({ + id: `prob-${i}`, + kind: "problem" as const, + title: p.label, + sub: p.since, + rows: [{ label: t("patientCard.graph.fields.since"), value: p.since }], + })), + ...patient.encounters.map((e, i) => ({ + id: `enc-${i}`, + kind: "visit" as const, + title: e.type, + sub: e.date, + rows: [ + { label: t("patientCard.graph.fields.date"), value: e.date }, + { label: t("patientCard.graph.fields.provider"), value: e.provider }, + { label: t("patientCard.graph.fields.summary"), value: e.summary }, + ].filter((r) => r.value), + })), + ]; return (
@@ -187,10 +233,56 @@ export function PatientDetail({
-

- {t("patientCard.graph.hint")} -

- +
+
+

+ {t("patientCard.graph.hint")} +

+ {onOpenGraph && ( + + )} +
+ {files.length === 0 ? ( +

+ {t("patientCard.graph.empty")} +

+ ) : ( +
+ {files.map((file) => ( + + ))} +
+ )} +
@@ -416,6 +508,49 @@ export function PatientDetail({ )}
)} + + { + if (!o) setOpenFile(null); + }} + open={openFile !== null} + > + + + + + {openFile?.title} + + + {openFile + ? t(`patientCard.graph.kind.${openFile.kind}`) + : ""} + + + + {openFile?.rows.map((row) => ( +
+ + {row.label} + + {row.value} +
+ ))} +
+ + }> + {t("patientCard.graph.close")} + + +
+
); } diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index 202b505..3fa42e5 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -1111,8 +1111,20 @@ }, "graph": { "title": "Record graph", - "hint": "How this patient's problems and visits connect. Drag nodes; scroll to pan.", - "empty": "Not enough record data to graph yet." + "hint": "Hover a node to focus its connections. Scroll to pan, pinch to zoom.", + "empty": "Not enough record data to graph yet.", + "open": "Open graph", + "close": "Close", + "kind": { + "problem": "Problem", + "visit": "Visit" + }, + "fields": { + "since": "Since", + "date": "Date", + "provider": "Provider", + "summary": "Summary" + } }, "appointments": { "title": "Appointments",