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;
letter-spacing: 0.2px;
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 {
@ -160,6 +177,10 @@
stroke-width: 2px;
stroke-dasharray: 4, 4;
vector-effect: non-scaling-stroke;
transition:
opacity 0.16s ease,
stroke-width 0.16s ease,
stroke 0.16s ease;
}
.visualiser-outcome-connector-arrow {
@ -176,6 +197,27 @@
stroke-linecap: round;
stroke-linejoin: round;
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) {

View File

@ -295,6 +295,7 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
}, []);
const levels = React.useMemo(() => buildLevels(tasks), [tasks]);
const edges = React.useMemo(() => buildEdges(tasks), [tasks]);
const [hoveredGuid, setHoveredGuid] = React.useState<string | null>(null);
const levelByGuid = React.useMemo(() => {
const levelMap = new Map<string, number>();
levels.forEach((level, levelIndex) => {
@ -408,6 +409,31 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
return sharedLaneByTarget;
}, [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(() => {
const measurePositions = () => {
@ -470,6 +496,22 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
};
}, [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 (
<div className="visualiser-root">
<div className="visualiser-container" ref={visualiserContainerRef}>
@ -483,22 +525,31 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
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
key={task.config.guid as string}
className="visualiser-node"
data-guid={task.config.guid as string}
key={guid}
className={`visualiser-node${isHovered ? " is-hovered" : ""}${isRelated ? " is-related" : ""}${isDim ? " is-dim" : ""}`}
data-guid={guid}
onMouseEnter={() => setHoveredGuid(guid)}
onMouseLeave={() => setHoveredGuid(null)}
>
<div className="visualiser-node-content">
<ValidationErrorIcon
visible={
taskValidation[task.config.guid as string] === false
}
visible={taskValidation[guid] === false}
/>
<span>{(task.config.name as string) || task.type}</span>
</div>
</div>
))}
);
})}
</div>
</div>
))}
@ -643,11 +694,17 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
return tEnum(matchedOutcome ?? outcome);
})
.join(", ");
const edgeIsActive =
hoveredGuid !== null &&
(edge.sourceGuid === hoveredGuid ||
edge.targetGuid === hoveredGuid);
const edgeIsDim = hoveredGuid !== null && !edgeIsActive;
const adjustedLabelY = getAdjustedLabelY(labelX, labelY - 1.25);
return (
<g key={`${edge.sourceGuid}-${edge.targetGuid}`}>
<path
className="visualiser-outcome-connector-line"
className={`visualiser-outcome-connector-line${edgeIsActive ? " is-active" : ""}${edgeIsDim ? " is-dim" : ""}`}
d={path}
markerEnd={
isMergedLeft ? undefined : "url(#visualiser-arrow)"
@ -656,8 +713,8 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
{label && (
<text
x={labelX}
y={labelY - 1.25}
className="visualiser-outcome-connector-label"
y={adjustedLabelY}
className={`visualiser-outcome-connector-label${edgeIsActive ? " is-active" : ""}${edgeIsDim ? " is-dim" : ""}`}
textAnchor={labelAnchor}
dominantBaseline="middle"
>
@ -676,11 +733,20 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
const endX = target.x - target.width / 2;
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 (
<path
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}`}
markerEnd="url(#visualiser-arrow)"
/>