Skip to content

Commit c2f89b2

Browse files
committedFeb 11, 2025·
fix: Fix zoom bug in the Feed Tree
1 parent f9c44b4 commit c2f89b2

File tree

1 file changed

+47
-136
lines changed

1 file changed

+47
-136
lines changed
 

‎src/components/FeedTree/FeedTree.tsx

+47-136
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,7 @@ import ChrisAPIClient from "../../api/chrisapiclient";
3333
import { ThemeContext } from "../DarkTheme/useTheme";
3434

3535
// -------------- Actions & Slices --------------
36-
import {
37-
getSelectedPlugin,
38-
setPluginInstancesAndSelectedPlugin,
39-
} from "../../store/pluginInstance/pluginInstanceSlice";
36+
import { getSelectedPlugin } from "../../store/pluginInstance/pluginInstanceSlice";
4037

4138
// -------------- Modals (AddNode, DeleteNode, Pipeline, etc.) --------------
4239
import AddNodeConnect from "../AddNode/AddNode";
@@ -68,7 +65,7 @@ export interface FeedTreeProps {
6865
changeLayout: () => void;
6966
onNodeClick: (node: TreeNodeDatum) => void;
7067
addNodeLocally: (instance: PluginInstance | PluginInstance[]) => void;
71-
removeNodeLocally: (ids: number[]) => void; // <-- NEW
68+
removeNodeLocally: (ids: number[]) => void;
7269
pluginInstances: PluginInstance[];
7370
statuses: {
7471
[id: number]: string;
@@ -159,7 +156,7 @@ export default function FeedTreeCanvas(props: FeedTreeProps) {
159156
onNodeClick,
160157
addNodeLocally,
161158
removeNodeLocally,
162-
pluginInstances,
159+
163160
statuses,
164161
feed,
165162
} = props;
@@ -177,6 +174,9 @@ export default function FeedTreeCanvas(props: FeedTreeProps) {
177174
const canvasRef = useRef<HTMLCanvasElement>(null);
178175
const containerRef = useRef<HTMLDivElement>(null);
179176

177+
// Keep track if we've already centered the tree once
178+
const initialRenderRef = useRef(true);
179+
180180
// Get container dimensions
181181
const size = useSize(containerRef);
182182
const width = size?.width;
@@ -196,15 +196,13 @@ export default function FeedTreeCanvas(props: FeedTreeProps) {
196196
const orientation = state.switchState.orientation;
197197

198198
// -------------- Redux: entire pluginInstances array & selected plugin --------------
199-
200199
const selectedPlugin = useAppSelector(
201200
(store) => store.instance.selectedPlugin,
202201
);
203202

204203
// -------------- 3) Pipeline creation mutation --------------
205204
const [api, contextHolder] = notification.useNotification();
206205

207-
// 1) Remove onMutate, keep onSuccess/onError
208206
const pipelineMutation = useMutation({
209207
mutationFn: (nodeToZip: PluginInstance) => fetchPipeline(nodeToZip),
210208
onSuccess: () => {
@@ -219,30 +217,23 @@ export default function FeedTreeCanvas(props: FeedTreeProps) {
219217
},
220218
});
221219

222-
// 2) Show "preparing" only if pipeline is found
223220
const fetchPipeline = async (pluginInst: PluginInstance) => {
224221
const client = ChrisAPIClient.getClient();
225-
226222
// Attempt to find the pipeline
227223
const pipelineList = await client.getPipelines({ name: "zip v20240311" });
228224
const pipelines = pipelineList.getItems();
229225
if (!pipelines || pipelines.length === 0) {
230-
// Throw error *before* showing the notification
231226
throw new Error("The zip pipeline is not registered. Contact admin.");
232227
}
233-
234-
// Only after confirming pipeline exists:
235228
api.info({
236229
message: "Preparing to initiate the zipping process...",
237230
});
238231

239-
// Then continue with normal logic
240232
const pipeline = pipelines[0];
241233
const { id: pipelineId } = pipeline.data;
242234

243235
const workflow = await client.createWorkflow(
244-
pipelineId,
245-
// @ts-ignore: ignoring the mismatch if any
236+
pipelineId, // @ts-ignore
246237
{
247238
previous_plugin_inst_id: pluginInst.data.id,
248239
},
@@ -253,7 +244,6 @@ export default function FeedTreeCanvas(props: FeedTreeProps) {
253244
});
254245
const newItems = pluginInstancesResponse.getItems();
255246

256-
// Merge new items into local state
257247
if (newItems && newItems.length > 0) {
258248
const firstInstance = newItems[newItems.length - 1];
259249
dispatch(getSelectedPlugin(firstInstance));
@@ -264,15 +254,13 @@ export default function FeedTreeCanvas(props: FeedTreeProps) {
264254
};
265255

266256
// -------------- 1) Build a D3 tree layout in memory --------------
267-
// We'll create a "hierarchy" and compute x,y for each node. Then optionally center root.
268257
const d3 = React.useMemo(() => {
269258
if (!data)
270259
return {
271260
nodes: [] as HierarchyPointNode<TreeNodeDatum>[],
272261
links: [] as HierarchyPointLink<TreeNodeDatum>[],
273262
};
274263

275-
// Build a D3 tree with nodeSize & orientation
276264
const d3Tree = tree<TreeNodeDatum>()
277265
.nodeSize(
278266
orientation === "horizontal"
@@ -285,21 +273,18 @@ export default function FeedTreeCanvas(props: FeedTreeProps) {
285273
: SEPARATION.nonSiblings,
286274
);
287275

288-
// Convert root data -> hierarchical layout
289276
const root = hierarchy(data, (d) => d.children);
290-
const layoutRoot = d3Tree(root); // <-- sets x, y on each node
277+
const layoutRoot = d3Tree(root);
291278
const computedNodes = layoutRoot.descendants();
292279
const computedLinks = layoutRoot.links();
293280

294-
// 3) If you want to add "ts" cross-links (tsIds)
281+
// Add any extra TS links if needed
295282
const newLinks: HierarchyPointLink<TreeNodeDatum>[] = [];
296283
if (tsIds && Object.keys(tsIds).length > 0) {
297284
for (const link of computedLinks) {
298285
const sourceId = link.source.data.id;
299286
const targetId = link.target.data.id;
300-
301287
if (tsIds[targetId] || tsIds[sourceId]) {
302-
// We'll just do a naive approach to add dash links
303288
const topologicalLink = tsIds[targetId] ? link.target : link.source;
304289
const parents = tsIds[topologicalLink.data.id];
305290
if (parents && parents.length > 0) {
@@ -330,10 +315,11 @@ export default function FeedTreeCanvas(props: FeedTreeProps) {
330315
};
331316
}, [data, tsIds, orientation]);
332317

333-
// -------------- 4) Bind d3-zoom to the canvas with the updated transform --------------
318+
// -------------- 2) Bind d3-zoom, center only once on mount --------------
334319
useEffect(() => {
335320
if (!canvasRef.current || !d3.rootNode || !width || !height) return;
336321

322+
// We'll throttle the "zoom" event so we don't set state too often.
337323
const handleZoom = throttle(
338324
(event: D3ZoomEvent<HTMLCanvasElement, unknown>) => {
339325
setTransform({
@@ -352,24 +338,30 @@ export default function FeedTreeCanvas(props: FeedTreeProps) {
352338
.scaleExtent([SCALE_EXTENT.min, SCALE_EXTENT.max])
353339
.on("zoom", handleZoom);
354340

355-
const root = d3.rootNode; // HierarchyPointNode
356-
const centerX = width / 2 - root.x;
357-
const centerY = height / 7 - root.y;
341+
// Attach the zoom behavior to the canvas
342+
const selection = select(canvasRef.current).call(zoomBehavior);
358343

359-
select(canvasRef.current)
360-
.call(zoomBehavior)
361-
// Center the root node in the canvas
362-
.call(
344+
// Only center on the *first* render
345+
if (initialRenderRef.current) {
346+
const root = d3.rootNode;
347+
const centerX = width / 2 - root.x;
348+
const centerY = height / 7 - root.y;
349+
350+
// Programmatically set the initial zoom/pan
351+
selection.call(
363352
zoomBehavior.transform,
364353
zoomIdentity.translate(centerX, centerY).scale(INITIAL_SCALE),
365354
);
366355

356+
initialRenderRef.current = false;
357+
}
358+
367359
return () => {
368-
select(canvasRef.current).on(".zoom", null);
360+
selection.on(".zoom", null);
369361
};
370362
}, [d3.rootNode, width, height]);
371363

372-
// -------------- 5) Draw the nodes/links onto the canvas --------------
364+
// -------------- 3) Draw the tree on the canvas --------------
373365
useEffect(() => {
374366
const canvas = canvasRef.current;
375367
if (!canvas || !width || !height) return;
@@ -414,7 +406,6 @@ export default function FeedTreeCanvas(props: FeedTreeProps) {
414406
overlayScale: state.overlayScale.enabled
415407
? state.overlayScale.type
416408
: undefined,
417-
418409
selectedId: selectedPlugin?.data.id,
419410
finalStatus,
420411
});
@@ -436,7 +427,7 @@ export default function FeedTreeCanvas(props: FeedTreeProps) {
436427
selectedPlugin,
437428
]);
438429

439-
// -------------- 6) Canvas click => left-click node selection --------------
430+
// -------------- Canvas click => node hit test --------------
440431
const handleCanvasClick = useCallback(
441432
(evt: React.MouseEvent<HTMLCanvasElement>) => {
442433
if (!canvasRef.current) return;
@@ -451,7 +442,6 @@ export default function FeedTreeCanvas(props: FeedTreeProps) {
451442
const zoomedY =
452443
(mouseY * ratio - transform.y * ratio) / (transform.k * ratio);
453444

454-
// Circle hit test
455445
for (const node of d3.nodes) {
456446
const dx = node.x - zoomedX;
457447
const dy = node.y - zoomedY;
@@ -465,38 +455,29 @@ export default function FeedTreeCanvas(props: FeedTreeProps) {
465455
[transform, onNodeClick, d3.nodes],
466456
);
467457

458+
// Utility to convert node coords -> screen coords for context menu
468459
const getNodeScreenCoords = useCallback(
469460
(
470461
nodeX: number,
471462
nodeY: number,
472463
transform: { x: number; y: number; k: number },
473464
containerRect: DOMRect,
474465
) => {
475-
// 1) Account for the zoom/pan transform
476-
// “canvasX” = transform.x + transform.k * nodeX
477-
// “canvasY” = transform.y + transform.k * nodeY
478466
const canvasX = transform.x + transform.k * nodeX;
479467
const canvasY = transform.y + transform.k * nodeY;
480-
481-
// 2) Convert from “canvas space” to “page” coords
482-
// by adding the container’s bounding rect offsets
483468
const screenX = containerRect.left + canvasX;
484469
const screenY = containerRect.top + canvasY;
485-
486470
return { screenX, screenY };
487471
},
488472
[],
489473
);
490474

491-
// -------------- 7) Canvas contextmenu => open custom context menu --------------
475+
// -------------- Canvas contextmenu => custom context menu --------------
492476
const handleCanvasContextMenu = useCallback(
493477
(evt: React.MouseEvent<HTMLCanvasElement>) => {
494478
evt.preventDefault();
495479
if (!canvasRef.current) return;
496480

497-
// We'll still do the 'hit test' to see if user right-clicked a node
498-
499-
// GEtting the mouse position relative to the canvas.
500481
const rect = canvasRef.current.getBoundingClientRect();
501482
const mouseX = evt.clientX - rect.left;
502483
const mouseY = evt.clientY - rect.top;
@@ -513,29 +494,18 @@ export default function FeedTreeCanvas(props: FeedTreeProps) {
513494
const dy = node.y - zoomedY;
514495
const dist = Math.sqrt(dx * dx + dy * dy);
515496
if (dist <= DEFAULT_NODE_RADIUS) {
516-
foundNode = node.data; // node.data is your “TreeNodeDatum”
497+
foundNode = node.data;
517498
break;
518499
}
519500
}
520501

521502
if (foundNode) {
522-
// We have a node. Now, instead of using evt.clientX, we want to get the
523-
// node’s on-screen coords, so the menu is adjacent to the node circle.
524-
525503
const containerRect = containerRef.current?.getBoundingClientRect();
526504
if (!containerRect) return;
527505

528-
// The node’s “layout” coords are in “foundNode.x, foundNode.y” inside your d3 Node
529-
// but we stored “foundNode” as node.data. So we need the actual node’s x,y from d3
530-
// If you have that in “(node.x, node.y)”, store it. For example, if your “foundNode” had that:
531-
// For this snippet we assume “foundNode.x, foundNode.y” are available, or you could store them.
532-
// If you only kept node.data, you need to keep the entire HierarchyPointNode instead.
533-
534-
// This is the tricky part: we need the “HierarchyPointNode”.
535-
// Let’s say you keep it as nodeOfInterest: HierarchyPointNode<TreeNodeDatum>.
536-
// Then nodeOfInterest.x, nodeOfInterest.y are the layout coords.
537-
// For the sake of example:
538-
const nodeOfInterest = d3.nodes.find((n) => n.data.id === foundNode.id);
506+
const nodeOfInterest = d3.nodes.find(
507+
(n) => n.data.id === foundNode!.id,
508+
);
539509
if (!nodeOfInterest) return;
540510

541511
const { screenX, screenY } = getNodeScreenCoords(
@@ -545,23 +515,21 @@ export default function FeedTreeCanvas(props: FeedTreeProps) {
545515
containerRect,
546516
);
547517

548-
// + some offset to not overlap the circle
549518
setContextMenuPosition({
550519
x: screenX + 20,
551520
y: screenY + 10,
552521
visible: true,
553522
});
554523
setContextMenuNode(foundNode);
555524
} else {
556-
// user right-clicked empty space => maybe hide menu
557525
setContextMenuNode(null);
558526
setContextMenuPosition({ x: 0, y: 0, visible: false });
559527
}
560528
},
561529
[transform, d3.nodes, getNodeScreenCoords],
562530
);
563531

564-
// -------------- 8) Handle toggles & orientation --------------
532+
// -------------- Toggle handlers --------------
565533
const handleChange = useCallback(
566534
(feature: Feature, payload?: any) => {
567535
updateState((draft) => {
@@ -589,19 +557,15 @@ export default function FeedTreeCanvas(props: FeedTreeProps) {
589557
[updateState],
590558
);
591559

592-
// -------------- 9) Render --------------
593560
return (
594561
<div ref={containerRef} style={{ width: "100%", height: "100%" }}>
595-
{/* Notification context holder for pipeline creation */}
596562
{contextHolder}
597-
598-
{/* Modals for AddNode, DeleteNode, Pipeline, etc. */}
599563
<Modals
600564
feed={feed}
601565
addNodeLocally={addNodeLocally}
602-
removeNodeLocally={removeNodeLocally} // <-- pass down to Modals
566+
removeNodeLocally={removeNodeLocally}
603567
/>
604-
{/* Context menu (conditionally rendered) */}
568+
605569
{contextMenuPosition.visible && contextMenuNode && (
606570
<div
607571
style={{
@@ -625,7 +589,7 @@ export default function FeedTreeCanvas(props: FeedTreeProps) {
625589
</div>
626590
)}
627591

628-
{/* Controls for labels, orientation, layout switch, scaling, search */}
592+
{/* Controls */}
629593
<div
630594
className="feed-tree__controls"
631595
style={{ display: "flex", gap: 10, margin: 10 }}
@@ -712,49 +676,34 @@ export default function FeedTreeCanvas(props: FeedTreeProps) {
712676
);
713677
}
714678

715-
// -------------- DRAWING FUNCTIONS --------------
716-
717-
// A) Draw Link
718-
719-
/**
720-
* Draw a simple straight line from parent->child,
721-
* then draw a small arrowhead near the child.
722-
*/
679+
// -------------- DRAWING UTILS --------------
723680
function drawLink(
724681
ctx: CanvasRenderingContext2D,
725682
linkData: HierarchyPointLink<TreeNodeDatum>,
726683
isDarkTheme: boolean,
727684
) {
728685
const { source, target } = linkData;
729686
const nodeRadius = DEFAULT_NODE_RADIUS;
730-
731-
// If target is a "ts" plugin => dashed line
732687
const isTs = target.data.item.data.plugin_type === "ts";
733688

734-
// offset line so it doesn’t overlap the circle radius
735689
const dx = target.x - source.x;
736690
const dy = target.y - source.y;
737691
const dist = Math.sqrt(dx * dx + dy * dy);
738692
if (dist === 0) return;
739693

740-
// unit direction from parent->child
741694
const nx = dx / dist;
742695
const ny = dy / dist;
743696

744-
// line start, offset by nodeRadius
745697
const sourceX = source.x + nodeRadius * nx;
746698
const sourceY = source.y + nodeRadius * ny;
747-
748-
// line end, offset behind the child’s node
749-
const childOffset = nodeRadius + 4; // extra 4 so the arrow isn't too close
699+
const childOffset = nodeRadius + 4;
750700
const targetX = target.x - childOffset * nx;
751701
const targetY = target.y - childOffset * ny;
752702

753-
// 1) Draw the line
754703
ctx.save();
755704
ctx.beginPath();
756705
ctx.strokeStyle = isDarkTheme ? "#F2F9F9" : "#6A6E73";
757-
ctx.lineWidth = 0.5; // thinner lines
706+
ctx.lineWidth = 0.5;
758707
if (isTs) {
759708
ctx.setLineDash([4, 2]);
760709
} else {
@@ -764,17 +713,10 @@ function drawLink(
764713
ctx.lineTo(targetX, targetY);
765714
ctx.stroke();
766715

767-
// 2) Draw an arrowhead at the child end
768-
// We'll define a small helper to do so:
769716
drawArrowHead(ctx, sourceX, sourceY, targetX, targetY);
770-
771717
ctx.restore();
772718
}
773719

774-
/**
775-
* Draw a small arrowhead pointing from (x1,y1) -> (x2,y2).
776-
* We'll place the arrow tip exactly at (x2,y2).
777-
*/
778720
function drawArrowHead(
779721
ctx: CanvasRenderingContext2D,
780722
x1: number,
@@ -783,14 +725,9 @@ function drawArrowHead(
783725
y2: number,
784726
arrowSize = 8,
785727
) {
786-
// angle from parent->child
787728
const angle = Math.atan2(y2 - y1, x2 - x1);
788-
789729
ctx.beginPath();
790-
// Move to the arrow tip
791730
ctx.moveTo(x2, y2);
792-
793-
// "wings" at angle ± some spread
794731
ctx.lineTo(
795732
x2 - arrowSize * Math.cos(angle - Math.PI / 7),
796733
y2 - arrowSize * Math.sin(angle - Math.PI / 7),
@@ -799,13 +736,11 @@ function drawArrowHead(
799736
x2 - arrowSize * Math.cos(angle + Math.PI / 7),
800737
y2 - arrowSize * Math.sin(angle + Math.PI / 7),
801738
);
802-
803739
ctx.closePath();
804-
// fill using the same color as the link stroke
805-
ctx.fillStyle = ctx.strokeStyle;
740+
ctx.fillStyle = ctx.strokeStyle as string;
806741
ctx.fill();
807742
}
808-
// B) Draw Node
743+
809744
interface DrawNodeOptions {
810745
ctx: CanvasRenderingContext2D;
811746
node: HierarchyPointNode<TreeNodeDatum>;
@@ -817,10 +752,6 @@ interface DrawNodeOptions {
817752
finalStatus: string | undefined;
818753
}
819754

820-
/**
821-
* Replicates Node logic: color by status, highlight if selected or if search hits,
822-
* optional overlay scaling, label toggles, parent error => notExecuted
823-
*/
824755
function drawNode({
825756
ctx,
826757
node,
@@ -835,13 +766,9 @@ function drawNode({
835766
const data = node.data;
836767
const itemData = data.item.data;
837768

838-
// 1) Node color by status
839769
const color = getStatusColor(finalStatus, data, searchFilter);
840-
841-
// 2) If node is selected => highlight ring
842770
const isSelected = selectedId === itemData.id;
843771

844-
// 3) overlay scale factor (time, CPU, memory, etc.)
845772
let factor = 1;
846773
if (overlayScale === "time" && itemData.start_date && itemData.end_date) {
847774
const start = new Date(itemData.start_date).getTime();
@@ -851,7 +778,6 @@ function drawNode({
851778
if (factor < 1) factor = 1;
852779
}
853780

854-
// 4) Main circle
855781
ctx.save();
856782
ctx.beginPath();
857783
ctx.arc(node.x, node.y, baseRadius, 0, 2 * Math.PI);
@@ -864,7 +790,6 @@ function drawNode({
864790
ctx.stroke();
865791
}
866792

867-
// 5) If factor > 1, draw outer ring
868793
if (factor > 1) {
869794
ctx.beginPath();
870795
ctx.arc(node.x, node.y, baseRadius * factor, 0, 2 * Math.PI);
@@ -873,7 +798,6 @@ function drawNode({
873798
ctx.stroke();
874799
}
875800

876-
// 6) Label if toggled or search highlight
877801
const isSearchHit = color === "red" && searchFilter;
878802
if (toggleLabel || isSearchHit) {
879803
ctx.fillStyle = isDarkTheme ? "#fff" : "#000";
@@ -885,17 +809,11 @@ function drawNode({
885809
ctx.restore();
886810
}
887811

888-
// -------------- Helper for node color --------------
889812
function getStatusColor(
890813
status: string | undefined,
891814
data: TreeNodeDatum,
892-
893815
searchFilter: string,
894816
): string {
895-
// Default color
896-
let color = "#F0AB00";
897-
898-
// If searchFilter is present, highlight matching name/title in red
899817
if (searchFilter) {
900818
const term = searchFilter.toLowerCase();
901819
const pluginName = data.item.data.plugin_name?.toLowerCase() || "";
@@ -910,22 +828,15 @@ function getStatusColor(
910828
case "scheduled":
911829
case "registeringFiles":
912830
case "created":
913-
color = "#bee1f4";
914-
break;
831+
return "#bee1f4";
915832
case "waiting":
916-
color = "#aaa";
917-
break;
833+
return "#aaa";
918834
case "finishedSuccessfully":
919-
color = "#004080";
920-
break;
835+
return "#004080";
921836
case "finishedWithError":
922837
case "cancelled":
923-
color = "#c9190b";
924-
break;
838+
return "#c9190b";
925839
default:
926-
color = "#004080"; // fallback
927-
break;
840+
return "#F0AB00";
928841
}
929-
930-
return color;
931842
}

0 commit comments

Comments
 (0)
Please sign in to comment.