Add zoom controls and Ctrl/Cmd-wheel zoom to the org chart

This commit is contained in:
ZacharyZhang-NY committed 2026-05-30 16:27:22 -04:00
1 parent afd96fe42c
commit 87dfa9345b
2 files changed
+119 -26

No files matched your search

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Avatar } from "../../shared/ui";
import type { AgentRecord, EmployeeRecord } from "../../../../shared/domain";
@@ -7,6 +7,9 @@ const NODE_H = 64;
const H_GAP = 24;
const V_GAP = 44;
const LEVEL_H = NODE_H + V_GAP;
const ZOOM_MIN = 0.4;
const ZOOM_MAX = 1.6;
const ZOOM_STEP = 0.15;
interface LayoutNode {
id: string;
@@ -44,12 +47,53 @@ export function OrgChartTree({
}) {
const layout = useMemo(() => buildLayout(boardChair, agents), [boardChair, agents]);
const scrollRef = useRef<HTMLDivElement>(null);
const scaleRef = useRef(1);
const pendingCenter = useRef<{ cx: number; cy: number } | null>(null);
const [scale, setScale] = useState(1);
const rootX = layout?.root.x ?? 0;
// Zoom while keeping the current viewport center fixed.
const zoomTo = useCallback((next: number) => {
const clamped = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(next * 100) / 100));
const el = scrollRef.current;
if (el) {
pendingCenter.current = {
cx: (el.scrollLeft + el.clientWidth / 2) / scaleRef.current,
cy: (el.scrollTop + el.clientHeight / 2) / scaleRef.current,
};
}
scaleRef.current = clamped;
setScale(clamped);
}, []);
// Ctrl/Cmd + wheel zooms (non-passive so we can prevent the browser zoom).
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const onWheel = (event: WheelEvent) => {
if (!(event.ctrlKey || event.metaKey)) return;
event.preventDefault();
zoomTo(scaleRef.current - Math.sign(event.deltaY) * ZOOM_STEP);
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, [zoomTo]);
// After a zoom re-render, restore the captured center point.
useEffect(() => {
const el = scrollRef.current;
const center = pendingCenter.current;
if (el && center) {
el.scrollLeft = center.cx * scale - el.clientWidth / 2;
el.scrollTop = center.cy * scale - el.clientHeight / 2;
pendingCenter.current = null;
}
}, [scale]);
// Open the chart looking at the top of the company, not a wall of whitespace.
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollLeft = Math.max(0, rootX - el.clientWidth / 2);
if (el) el.scrollLeft = Math.max(0, rootX * scaleRef.current - el.clientWidth / 2);
}, [rootX]);
if (!layout) return null;
@@ -58,29 +102,38 @@ export function OrgChartTree({
const edges = collectEdges(root);
return (
<div ref={scrollRef} className="org-scroll">
<div className="org-canvas" style={{ position: "relative", width, height, margin: "20px auto 4px" }}>
<svg width={width} height={height} style={{ position: "absolute", inset: 0, pointerEvents: "none" }} aria-hidden="true">
{edges.map((edge, index) => (
<path key={index} d={elbow(edge)} stroke="var(--border-strong)" fill="none" strokeWidth={1} />
))}
</svg>
{nodes.map((node) => (
<button
key={node.id}
type="button"
className={`org-node-card ${node.human ? "human" : ""}`.trim()}
style={{ left: node.x - NODE_W / 2, top: node.y, width: NODE_W, height: NODE_H }}
onClick={() => { if (node.agentId) onSelectAgent(node.agentId); }}
disabled={!node.agentId}
>
<Avatar name={node.name} bot={!node.human} size="sm" />
<span className="org-node-text">
<span className="org-node-name">{node.name}</span>
<span className="org-node-role">{node.role}</span>
</span>
</button>
))}
<div className="org-chart">
<div className="org-zoom">
<button type="button" className="org-zoom-btn" aria-label="Zoom out" disabled={scale <= ZOOM_MIN} onClick={() => zoomTo(scale - ZOOM_STEP)}>−</button>
<button type="button" className="org-zoom-label" aria-label="Reset zoom" onClick={() => zoomTo(1)}>{Math.round(scale * 100)}%</button>
<button type="button" className="org-zoom-btn" aria-label="Zoom in" disabled={scale >= ZOOM_MAX} onClick={() => zoomTo(scale + ZOOM_STEP)}>+</button>
</div>
<div className="org-scroll" ref={scrollRef}>
<div className="org-sizer" style={{ width: width * scale, height: height * scale }}>
<div className="org-canvas" style={{ width, height, transform: `scale(${scale})`, transformOrigin: "0 0" }}>
<svg width={width} height={height} style={{ position: "absolute", inset: 0, pointerEvents: "none" }} aria-hidden="true">
{edges.map((edge, index) => (
<path key={index} d={elbow(edge)} stroke="var(--border-strong)" fill="none" strokeWidth={1} />
))}
</svg>
{nodes.map((node) => (
<button
key={node.id}
type="button"
className={`org-node-card ${node.human ? "human" : ""}`.trim()}
style={{ left: node.x - NODE_W / 2, top: node.y, width: NODE_W, height: NODE_H }}
onClick={() => { if (node.agentId) onSelectAgent(node.agentId); }}
disabled={!node.agentId}
>
<Avatar name={node.name} bot={!node.human} size="sm" />
<span className="org-node-text">
<span className="org-node-name">{node.name}</span>
<span className="org-node-role">{node.role}</span>
</span>
</button>
))}
</div>
</div>
</div>
</div>
);
+41 -1
View File
@@ -388,7 +388,47 @@ a { color: inherit; }
/* Hierarchical org chart: nodes are absolutely positioned by a layout pass,
connectors are drawn in an SVG underlay (see OrgChartTree). */
.org-scroll { overflow-x: auto; overflow-y: hidden; }
.org-chart { position: relative; }
.org-zoom {
position: absolute;
top: -6px;
right: 0;
z-index: 3;
display: flex;
align-items: center;
gap: 2px;
padding: 2px;
border: 1px solid var(--border);
border-radius: var(--r-sm);
background: var(--panel);
box-shadow: var(--shadow-sm);
}
.org-zoom-btn {
width: 26px;
height: 24px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--text-2);
font-size: 15px;
line-height: 1;
}
.org-zoom-btn:hover:not(:disabled) { background: var(--hover); color: var(--text-1); }
.org-zoom-btn:disabled { color: var(--text-4); cursor: default; }
.org-zoom-label {
min-width: 46px;
height: 24px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--text-2);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.org-zoom-label:hover { background: var(--hover); color: var(--text-1); }
.org-scroll { overflow: auto; max-height: 62vh; }
.org-sizer { position: relative; margin: 20px auto 4px; }
.org-canvas { position: absolute; top: 0; left: 0; }
.org-node-card {
position: absolute;
display: flex;