The visualiser now has on hover highlighting

This commit is contained in:
Colin Dawson 2026-03-13 00:55:21 +00:00
parent f506f047b8
commit 1a3aaac5b3
2 changed files with 127 additions and 19 deletions

View File

@ -46,6 +46,23 @@
text-align: center; text-align: center;
letter-spacing: 0.2px; letter-spacing: 0.2px;
box-shadow: 0 6px 14px rgba($blue, 0.2); box-shadow: 0 6px 14px rgba($blue, 0.2);
transition:
opacity 0.16s ease,
transform 0.16s ease,
box-shadow 0.16s ease;
}
.visualiser-node.is-hovered {
transform: translateY(-1px);
box-shadow: 0 10px 22px rgba($blue, 0.33);
}
.visualiser-node.is-related {
box-shadow: 0 8px 18px rgba($blue, 0.27);
}
.visualiser-node.is-dim {
opacity: 0.45;
} }
.visualiser-node-content { .visualiser-node-content {
@ -160,6 +177,10 @@
stroke-width: 2px; stroke-width: 2px;
stroke-dasharray: 4, 4; stroke-dasharray: 4, 4;
vector-effect: non-scaling-stroke; vector-effect: non-scaling-stroke;
transition:
opacity 0.16s ease,
stroke-width 0.16s ease,
stroke 0.16s ease;
} }
.visualiser-outcome-connector-arrow { .visualiser-outcome-connector-arrow {
@ -176,6 +197,27 @@
stroke-linecap: round; stroke-linecap: round;
stroke-linejoin: round; stroke-linejoin: round;
user-select: none; user-select: none;
transition:
opacity 0.16s ease,
fill 0.16s ease;
}
.visualiser-outcome-connector-line.is-active {
stroke-width: 2.6px;
stroke: rgba($blue, 0.75);
stroke-dasharray: 0;
}
.visualiser-outcome-connector-line.is-dim {
opacity: 0.2;
}
.visualiser-outcome-connector-label.is-active {
fill: darken($blue, 8%);
}
.visualiser-outcome-connector-label.is-dim {
opacity: 0.25;
} }
@include color-mode(dark) { @include color-mode(dark) {

View File

@ -295,6 +295,7 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
}, []); }, []);
const levels = React.useMemo(() => buildLevels(tasks), [tasks]); const levels = React.useMemo(() => buildLevels(tasks), [tasks]);
const edges = React.useMemo(() => buildEdges(tasks), [tasks]); const edges = React.useMemo(() => buildEdges(tasks), [tasks]);
const [hoveredGuid, setHoveredGuid] = React.useState<string | null>(null);
const levelByGuid = React.useMemo(() => { const levelByGuid = React.useMemo(() => {
const levelMap = new Map<string, number>(); const levelMap = new Map<string, number>();
levels.forEach((level, levelIndex) => { levels.forEach((level, levelIndex) => {
@ -408,6 +409,31 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
return sharedLaneByTarget; return sharedLaneByTarget;
}, [edges, levelByGuid, edgeLaneOffsetByKey, nodePositions]); }, [edges, levelByGuid, edgeLaneOffsetByKey, nodePositions]);
const connectedByGuid = React.useMemo(() => {
const map = new Map<string, Set<string>>();
tasks.forEach((task) => {
map.set(task.config.guid as string, new Set<string>());
});
edges.forEach((edge) => {
const source = map.get(edge.sourceGuid) ?? new Set<string>();
source.add(edge.targetGuid);
map.set(edge.sourceGuid, source);
const target = map.get(edge.targetGuid) ?? new Set<string>();
target.add(edge.sourceGuid);
map.set(edge.targetGuid, target);
});
return map;
}, [tasks, edges]);
const relatedGuids = React.useMemo(() => {
if (!hoveredGuid) {
return new Set<string>();
}
return connectedByGuid.get(hoveredGuid) ?? new Set<string>();
}, [hoveredGuid, connectedByGuid]);
React.useEffect(() => { React.useEffect(() => {
const measurePositions = () => { const measurePositions = () => {
@ -470,6 +496,22 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
}; };
}, [levels]); }, [levels]);
const labelCollisionState = new Map<string, number[]>();
const getAdjustedLabelY = (x: number, preferredY: number): number => {
const bucket = Math.round(x / 8).toString();
const used = labelCollisionState.get(bucket) ?? [];
let y = preferredY;
const minGap = 2.1;
while (used.some((existingY) => Math.abs(existingY - y) < minGap)) {
y += minGap;
}
used.push(y);
labelCollisionState.set(bucket, used);
return y;
};
return ( return (
<div className="visualiser-root"> <div className="visualiser-root">
<div className="visualiser-container" ref={visualiserContainerRef}> <div className="visualiser-container" ref={visualiserContainerRef}>
@ -483,22 +525,31 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
gap: "40px", gap: "40px",
}} }}
> >
{level.map((task) => ( {level.map((task) => {
const guid = task.config.guid as string;
const isHovered = hoveredGuid === guid;
const isRelated =
hoveredGuid !== null && relatedGuids.has(guid);
const isDim =
hoveredGuid !== null && !isHovered && !isRelated;
return (
<div <div
key={task.config.guid as string} key={guid}
className="visualiser-node" className={`visualiser-node${isHovered ? " is-hovered" : ""}${isRelated ? " is-related" : ""}${isDim ? " is-dim" : ""}`}
data-guid={task.config.guid as string} data-guid={guid}
onMouseEnter={() => setHoveredGuid(guid)}
onMouseLeave={() => setHoveredGuid(null)}
> >
<div className="visualiser-node-content"> <div className="visualiser-node-content">
<ValidationErrorIcon <ValidationErrorIcon
visible={ visible={taskValidation[guid] === false}
taskValidation[task.config.guid as string] === false
}
/> />
<span>{(task.config.name as string) || task.type}</span> <span>{(task.config.name as string) || task.type}</span>
</div> </div>
</div> </div>
))} );
})}
</div> </div>
</div> </div>
))} ))}
@ -643,11 +694,17 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
return tEnum(matchedOutcome ?? outcome); return tEnum(matchedOutcome ?? outcome);
}) })
.join(", "); .join(", ");
const edgeIsActive =
hoveredGuid !== null &&
(edge.sourceGuid === hoveredGuid ||
edge.targetGuid === hoveredGuid);
const edgeIsDim = hoveredGuid !== null && !edgeIsActive;
const adjustedLabelY = getAdjustedLabelY(labelX, labelY - 1.25);
return ( return (
<g key={`${edge.sourceGuid}-${edge.targetGuid}`}> <g key={`${edge.sourceGuid}-${edge.targetGuid}`}>
<path <path
className="visualiser-outcome-connector-line" className={`visualiser-outcome-connector-line${edgeIsActive ? " is-active" : ""}${edgeIsDim ? " is-dim" : ""}`}
d={path} d={path}
markerEnd={ markerEnd={
isMergedLeft ? undefined : "url(#visualiser-arrow)" isMergedLeft ? undefined : "url(#visualiser-arrow)"
@ -656,8 +713,8 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
{label && ( {label && (
<text <text
x={labelX} x={labelX}
y={labelY - 1.25} y={adjustedLabelY}
className="visualiser-outcome-connector-label" className={`visualiser-outcome-connector-label${edgeIsActive ? " is-active" : ""}${edgeIsDim ? " is-dim" : ""}`}
textAnchor={labelAnchor} textAnchor={labelAnchor}
dominantBaseline="middle" dominantBaseline="middle"
> >
@ -676,11 +733,20 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
const endX = target.x - target.width / 2; const endX = target.x - target.width / 2;
const endY = target.y; const endY = target.y;
const mergedIsActive =
hoveredGuid !== null &&
(hoveredGuid === targetGuid ||
edges.some(
(edge) =>
edge.targetGuid === targetGuid &&
edge.sourceGuid === hoveredGuid,
));
const mergedIsDim = hoveredGuid !== null && !mergedIsActive;
return ( return (
<path <path
key={`merged-left-${targetGuid}`} key={`merged-left-${targetGuid}`}
className="visualiser-outcome-connector-line" className={`visualiser-outcome-connector-line${mergedIsActive ? " is-active" : ""}${mergedIsDim ? " is-dim" : ""}`}
d={`M ${laneX} ${endY} L ${endX} ${endY}`} d={`M ${laneX} ${endY} L ${endX} ${endY}`}
markerEnd="url(#visualiser-arrow)" markerEnd="url(#visualiser-arrow)"
/> />