mirror of
https://github.com/temetro/temetro.git
synced 2026-08-28 19:37:33 +00:00
fix(graph): stop hover flicker and theme the controls
Hover state fed the nodes/edges useMemos, so every hover recreated both arrays — React Flow re-measured the dimensionless nodes and the hovered node's scale transform shifted it out from under the pointer, causing a rapid dark↔light flicker. Drive hover focus through React context instead so the nodes/edges props stay referentially stable (custom memoized node + edge read the hovered id), and drop the scale transform. Theme the React Flow controls via colorMode from next-themes so the zoom box matches the active theme instead of rendering white. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,11 +6,19 @@
|
|||||||
// d3-force simulation computed once; rendering + pan/zoom is React Flow. Nodes
|
// 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
|
// are round "dots" with the label underneath, and hovering a node highlights it
|
||||||
// and its neighbours while dimming the rest (the Obsidian focus effect).
|
// and its neighbours while dimming the rest (the Obsidian focus effect).
|
||||||
|
//
|
||||||
|
// Hover is driven through React context — NOT through the `nodes`/`edges` props
|
||||||
|
// — so the arrays React Flow receives stay referentially stable. That avoids the
|
||||||
|
// re-measure/flicker loop you get when hover state recreates the node objects
|
||||||
|
// (and the hovered node's transform shifts it out from under the cursor).
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Background,
|
Background,
|
||||||
|
BaseEdge,
|
||||||
Controls,
|
Controls,
|
||||||
type Edge,
|
type Edge,
|
||||||
|
type EdgeProps,
|
||||||
|
getStraightPath,
|
||||||
Handle,
|
Handle,
|
||||||
type Node,
|
type Node,
|
||||||
type NodeProps,
|
type NodeProps,
|
||||||
@@ -27,7 +35,8 @@ import {
|
|||||||
type SimulationLinkDatum,
|
type SimulationLinkDatum,
|
||||||
type SimulationNodeDatum,
|
type SimulationNodeDatum,
|
||||||
} from "d3-force";
|
} from "d3-force";
|
||||||
import { useMemo, useState } from "react";
|
import { useTheme } from "next-themes";
|
||||||
|
import { createContext, memo, useContext, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { Patient } from "@/lib/patients";
|
import type { Patient } from "@/lib/patients";
|
||||||
@@ -47,11 +56,15 @@ type RecordNodeData = {
|
|||||||
label: string;
|
label: string;
|
||||||
sub?: string;
|
sub?: string;
|
||||||
kind: Kind;
|
kind: Kind;
|
||||||
// Hover focus: "active" = this node or a neighbour, "dim" = unrelated, null =
|
// Ids of this node plus its direct neighbours — used to derive hover focus
|
||||||
// nothing hovered (everything at full strength).
|
// from the hovered id without recreating the node objects.
|
||||||
focus: "active" | "dim" | null;
|
family: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The id of the node currently hovered (or null). Read by nodes + edges so the
|
||||||
|
// hover highlight never touches the React Flow `nodes`/`edges` props.
|
||||||
|
const HoverContext = createContext<string | null>(null);
|
||||||
|
|
||||||
// Dot size + colour per kind. The patient is the biggest, brightest hub.
|
// Dot size + colour per kind. The patient is the biggest, brightest hub.
|
||||||
const dotClass: Record<Kind, string> = {
|
const dotClass: Record<Kind, string> = {
|
||||||
patient: "size-5 bg-primary shadow-[0_0_16px_2px] shadow-primary/40",
|
patient: "size-5 bg-primary shadow-[0_0_16px_2px] shadow-primary/40",
|
||||||
@@ -60,9 +73,14 @@ const dotClass: Record<Kind, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// A round node with the label below it and hidden connection handles so edges
|
// A round node with the label below it and hidden connection handles so edges
|
||||||
// meet the dot's centre cleanly.
|
// meet the dot's centre cleanly. Memoized; it re-renders only when the hovered
|
||||||
function RecordNode({ data }: NodeProps) {
|
// id changes (via context), never via React Flow re-measuring.
|
||||||
const { label, sub, kind, focus } = data as RecordNodeData;
|
const RecordNode = memo(function RecordNode({ id, data }: NodeProps) {
|
||||||
|
const { label, sub, kind, family } = data as RecordNodeData;
|
||||||
|
const hovered = useContext(HoverContext);
|
||||||
|
const focus: "active" | "dim" | null =
|
||||||
|
hovered == null ? null : family.includes(hovered) ? "active" : "dim";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -72,9 +90,9 @@ function RecordNode({ data }: NodeProps) {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full ring-1 ring-background/60 transition-transform duration-200",
|
"rounded-full ring-1 ring-background/60 transition-shadow duration-200",
|
||||||
dotClass[kind],
|
dotClass[kind],
|
||||||
focus === "active" && "scale-125",
|
focus === "active" && "ring-2 ring-foreground/40",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Handle className="!opacity-0" position={Position.Top} type="target" />
|
<Handle className="!opacity-0" position={Position.Top} type="target" />
|
||||||
@@ -103,9 +121,36 @@ function RecordNode({ data }: NodeProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
// A straight edge that brightens when it touches the hovered node and dims when
|
||||||
|
// it doesn't. Reads the hovered id from context so the `edges` prop stays stable.
|
||||||
|
const RecordEdge = memo(function RecordEdge({
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
}: EdgeProps) {
|
||||||
|
const hovered = useContext(HoverContext);
|
||||||
|
const [path] = getStraightPath({ sourceX, sourceY, targetX, targetY });
|
||||||
|
const touches = !hovered || source === hovered || target === hovered;
|
||||||
|
return (
|
||||||
|
<BaseEdge
|
||||||
|
path={path}
|
||||||
|
style={{
|
||||||
|
stroke: hovered && touches ? "var(--primary)" : "var(--border)",
|
||||||
|
strokeWidth: hovered && touches ? 1.75 : 1.25,
|
||||||
|
opacity: hovered && !touches ? 0.12 : 0.7,
|
||||||
|
transition: "stroke 0.2s, opacity 0.2s",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const nodeTypes = { record: RecordNode };
|
const nodeTypes = { record: RecordNode };
|
||||||
|
const edgeTypes = { record: RecordEdge };
|
||||||
|
|
||||||
// Build nodes + edges from the record. A visit links to a problem when its
|
// Build nodes + edges from the record. A visit links to a problem when its
|
||||||
// type/summary mentions the problem label (case-insensitive) — that produces
|
// type/summary mentions the problem label (case-insensitive) — that produces
|
||||||
@@ -172,27 +217,24 @@ export function RecordGraph({
|
|||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// The node the pointer is over; drives the focus highlight.
|
const { resolvedTheme } = useTheme();
|
||||||
const [hover, setHover] = useState<string | null>(null);
|
// The hovered node id — drives the focus highlight via context only.
|
||||||
|
const [hovered, setHovered] = useState<string | null>(null);
|
||||||
|
|
||||||
// Stable base layout (positions + adjacency), computed once per patient.
|
// Nodes + edges are computed once per patient and stay referentially stable
|
||||||
const base = useMemo(() => {
|
// across hover, so React Flow never re-measures (the source of the flicker).
|
||||||
|
const { nodes, edges } = useMemo(() => {
|
||||||
const g = buildGraph(patient);
|
const g = buildGraph(patient);
|
||||||
layout(g.nodes, g.edges);
|
layout(g.nodes, g.edges);
|
||||||
// Adjacency for the hover focus: a node is "active" when it is hovered or
|
// For each node, the set of ids that should stay lit when it is hovered:
|
||||||
// directly linked to the hovered node.
|
// itself plus its direct neighbours.
|
||||||
const neighbours = new Map<string, Set<string>>();
|
const family = new Map<string, Set<string>>();
|
||||||
for (const n of g.nodes) neighbours.set(n.id, new Set([n.id]));
|
for (const n of g.nodes) family.set(n.id, new Set([n.id]));
|
||||||
for (const e of g.edges) {
|
for (const e of g.edges) {
|
||||||
neighbours.get(e.source)?.add(e.target);
|
family.get(e.source)?.add(e.target);
|
||||||
neighbours.get(e.target)?.add(e.source);
|
family.get(e.target)?.add(e.source);
|
||||||
}
|
}
|
||||||
return { nodes: g.nodes, edges: g.edges, neighbours };
|
const rfNodes: Node[] = g.nodes.map((n) => ({
|
||||||
}, [patient]);
|
|
||||||
|
|
||||||
const nodes: Node[] = useMemo(() => {
|
|
||||||
const active = hover ? base.neighbours.get(hover) : null;
|
|
||||||
return base.nodes.map((n) => ({
|
|
||||||
id: n.id,
|
id: n.id,
|
||||||
type: "record",
|
type: "record",
|
||||||
position: { x: n.x ?? 0, y: n.y ?? 0 },
|
position: { x: n.x ?? 0, y: n.y ?? 0 },
|
||||||
@@ -200,27 +242,17 @@ export function RecordGraph({
|
|||||||
label: n.label,
|
label: n.label,
|
||||||
sub: n.sub,
|
sub: n.sub,
|
||||||
kind: n.kind,
|
kind: n.kind,
|
||||||
focus: active ? (active.has(n.id) ? "active" : "dim") : null,
|
family: Array.from(family.get(n.id) ?? [n.id]),
|
||||||
} satisfies RecordNodeData,
|
} satisfies RecordNodeData,
|
||||||
}));
|
}));
|
||||||
}, [base, hover]);
|
const rfEdges: Edge[] = g.edges.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
const edges: Edge[] = useMemo(() => {
|
source: e.source,
|
||||||
return base.edges.map((e) => {
|
target: e.target,
|
||||||
const touches = !hover || e.source === hover || e.target === hover;
|
type: "record",
|
||||||
return {
|
}));
|
||||||
id: e.id,
|
return { nodes: rfNodes, edges: rfEdges };
|
||||||
source: e.source,
|
}, [patient]);
|
||||||
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) {
|
if (patient.problems.length === 0 && patient.encounters.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -237,23 +269,27 @@ export function RecordGraph({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ReactFlow
|
<HoverContext.Provider value={hovered}>
|
||||||
edges={edges}
|
<ReactFlow
|
||||||
fitView
|
colorMode={resolvedTheme === "light" ? "light" : "dark"}
|
||||||
fitViewOptions={{ padding: 0.3 }}
|
edgeTypes={edgeTypes}
|
||||||
nodeTypes={nodeTypes}
|
edges={edges}
|
||||||
nodes={nodes}
|
fitView
|
||||||
nodesConnectable={false}
|
fitViewOptions={{ padding: 0.3 }}
|
||||||
nodesDraggable={false}
|
nodeTypes={nodeTypes}
|
||||||
onNodeMouseEnter={(_, node) => setHover(node.id)}
|
nodes={nodes}
|
||||||
onNodeMouseLeave={() => setHover(null)}
|
nodesConnectable={false}
|
||||||
panOnScroll
|
nodesDraggable={false}
|
||||||
proOptions={{ hideAttribution: true }}
|
onNodeMouseEnter={(_, node) => setHovered(node.id)}
|
||||||
zoomOnScroll={false}
|
onNodeMouseLeave={() => setHovered(null)}
|
||||||
>
|
panOnScroll
|
||||||
<Background color="var(--border)" gap={22} size={1.5} />
|
proOptions={{ hideAttribution: true }}
|
||||||
<Controls showInteractive={false} />
|
zoomOnScroll={false}
|
||||||
</ReactFlow>
|
>
|
||||||
|
<Background color="var(--border)" gap={22} size={1.5} />
|
||||||
|
<Controls showInteractive={false} />
|
||||||
|
</ReactFlow>
|
||||||
|
</HoverContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user