Add zoom controls and Ctrl/Cmd-wheel zoom to the org chart
This commit is contained in:
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 { Avatar } from "../../shared/ui";
|
||||||
import type { AgentRecord, EmployeeRecord } from "../../../../shared/domain";
|
import type { AgentRecord, EmployeeRecord } from "../../../../shared/domain";
|
||||||
|
|
||||||
@@ -7,6 +7,9 @@ const NODE_H = 64;
|
|||||||
const H_GAP = 24;
|
const H_GAP = 24;
|
||||||
const V_GAP = 44;
|
const V_GAP = 44;
|
||||||
const LEVEL_H = NODE_H + V_GAP;
|
const LEVEL_H = NODE_H + V_GAP;
|
||||||
|
const ZOOM_MIN = 0.4;
|
||||||
|
const ZOOM_MAX = 1.6;
|
||||||
|
const ZOOM_STEP = 0.15;
|
||||||
|
|
||||||
interface LayoutNode {
|
interface LayoutNode {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -44,12 +47,53 @@ export function OrgChartTree({
|
|||||||
}) {
|
}) {
|
||||||
const layout = useMemo(() => buildLayout(boardChair, agents), [boardChair, agents]);
|
const layout = useMemo(() => buildLayout(boardChair, agents), [boardChair, agents]);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
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;
|
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.
|
// Open the chart looking at the top of the company, not a wall of whitespace.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current;
|
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]);
|
}, [rootX]);
|
||||||
|
|
||||||
if (!layout) return null;
|
if (!layout) return null;
|
||||||
@@ -58,29 +102,38 @@ export function OrgChartTree({
|
|||||||
const edges = collectEdges(root);
|
const edges = collectEdges(root);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={scrollRef} className="org-scroll">
|
<div className="org-chart">
|
||||||
<div className="org-canvas" style={{ position: "relative", width, height, margin: "20px auto 4px" }}>
|
<div className="org-zoom">
|
||||||
<svg width={width} height={height} style={{ position: "absolute", inset: 0, pointerEvents: "none" }} aria-hidden="true">
|
<button type="button" className="org-zoom-btn" aria-label="Zoom out" disabled={scale <= ZOOM_MIN} onClick={() => zoomTo(scale - ZOOM_STEP)}>−</button>
|
||||||
{edges.map((edge, index) => (
|
<button type="button" className="org-zoom-label" aria-label="Reset zoom" onClick={() => zoomTo(1)}>{Math.round(scale * 100)}%</button>
|
||||||
<path key={index} d={elbow(edge)} stroke="var(--border-strong)" fill="none" strokeWidth={1} />
|
<button type="button" className="org-zoom-btn" aria-label="Zoom in" disabled={scale >= ZOOM_MAX} onClick={() => zoomTo(scale + ZOOM_STEP)}>+</button>
|
||||||
))}
|
</div>
|
||||||
</svg>
|
<div className="org-scroll" ref={scrollRef}>
|
||||||
{nodes.map((node) => (
|
<div className="org-sizer" style={{ width: width * scale, height: height * scale }}>
|
||||||
<button
|
<div className="org-canvas" style={{ width, height, transform: `scale(${scale})`, transformOrigin: "0 0" }}>
|
||||||
key={node.id}
|
<svg width={width} height={height} style={{ position: "absolute", inset: 0, pointerEvents: "none" }} aria-hidden="true">
|
||||||
type="button"
|
{edges.map((edge, index) => (
|
||||||
className={`org-node-card ${node.human ? "human" : ""}`.trim()}
|
<path key={index} d={elbow(edge)} stroke="var(--border-strong)" fill="none" strokeWidth={1} />
|
||||||
style={{ left: node.x - NODE_W / 2, top: node.y, width: NODE_W, height: NODE_H }}
|
))}
|
||||||
onClick={() => { if (node.agentId) onSelectAgent(node.agentId); }}
|
</svg>
|
||||||
disabled={!node.agentId}
|
{nodes.map((node) => (
|
||||||
>
|
<button
|
||||||
<Avatar name={node.name} bot={!node.human} size="sm" />
|
key={node.id}
|
||||||
<span className="org-node-text">
|
type="button"
|
||||||
<span className="org-node-name">{node.name}</span>
|
className={`org-node-card ${node.human ? "human" : ""}`.trim()}
|
||||||
<span className="org-node-role">{node.role}</span>
|
style={{ left: node.x - NODE_W / 2, top: node.y, width: NODE_W, height: NODE_H }}
|
||||||
</span>
|
onClick={() => { if (node.agentId) onSelectAgent(node.agentId); }}
|
||||||
</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -388,7 +388,47 @@ a { color: inherit; }
|
|||||||
|
|
||||||
/* Hierarchical org chart: nodes are absolutely positioned by a layout pass,
|
/* Hierarchical org chart: nodes are absolutely positioned by a layout pass,
|
||||||
connectors are drawn in an SVG underlay (see OrgChartTree). */
|
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 {
|
.org-node-card {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in new issue
Block a user