Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion members/nullnet-server/ui/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useApi } from '../hooks/useApi';
import type { SessionJson } from '../types';
import { useRef, useState, useEffect } from 'react';

type Page = 'dashboard' | 'services' | 'nodes' | 'sessions' | 'pool' | 'config' | 'certificates' | 'events';
type Page = 'dashboard' | 'topology' | 'services' | 'nodes' | 'sessions' | 'pool' | 'config' | 'certificates' | 'events';

interface Props {
page: Page;
Expand All @@ -17,6 +17,7 @@ const NAV = [
group: 'Overview',
items: [
{ id: 'dashboard', icon: '⊞', label: 'Dashboard', to: '/' },
{ id: 'topology', icon: '◎', label: 'Topology', to: '/topology' },
],
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import { useTopologyData, useTopologyUI } from './TopologyContext';
import TopologyGraphSvg from './TopologyGraphSvg';
import ZoomFrame from './ZoomFrame';

export default function TopologyGraph() {
interface Props {
height?: number | string;
fill?: boolean;
anchor?: 'center' | 'top-left';
grow?: boolean;
}

export default function TopologyGraph({ height = 520, fill, anchor, grow }: Props) {
const { graph } = useTopologyData();
const {
selectedNodeId,
Expand All @@ -16,7 +23,7 @@ export default function TopologyGraph() {
if (!graph) return null;

return (
<ZoomFrame height={520}>
<ZoomFrame height={height} fill={fill} anchor={anchor} grow={grow}>
<TopologyGraphSvg
graph={graph}
selectedNodeId={selectedNodeId}
Expand Down
34 changes: 20 additions & 14 deletions members/nullnet-server/ui/src/components/topology/ZoomFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,33 +10,38 @@ interface ZoomState {
}

interface Props {
height: number;
height: number | string;
fill?: boolean;
anchor?: 'center' | 'top-left';
grow?: boolean;
children: React.ReactNode;
}

function computeHome(cw: number, ch: number, contentH: number): ZoomState {
function computeHome(cw: number, ch: number, contentH: number, anchor: 'center' | 'top-left'): ZoomState {
let scale = 1, tx = 0, ty = 0;
if (contentH > ch) {
scale = (ch / contentH) * 0.9;
tx = (cw * (1 - scale)) / 2;
ty = (ch - contentH * scale) / 2;
} else {
if (anchor === 'center') {
tx = (cw * (1 - scale)) / 2;
ty = (ch - contentH * scale) / 2;
}
} else if (anchor === 'center') {
ty = (ch - contentH) / 2;
}
return { scale, tx, ty };
}

export default function ZoomFrame({ height, children }: Props) {
export default function ZoomFrame({ height, fill, anchor = 'center', grow, children }: Props) {
const [zoom, setZoom] = useState<ZoomState>({ scale: 1, tx: 0, ty: 0 });
const dragging = useRef<{ startX: number; startY: number; startTx: number; startTy: number } | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const homeZoom = useRef<ZoomState>({ scale: 1, tx: 0, ty: 0 });
const prevContentH = useRef(0);

// Initial centering before first paint — sets prevContentH so the ResizeObserver
// below skips its first fire (same size) and only re-fits on actual graph changes.
// Initial fit — skipped in grow mode (container sizes to content, no transform needed).
useLayoutEffect(() => {
if (grow) return;
const container = containerRef.current;
const content = contentRef.current;
if (!container || !content) return;
Expand All @@ -45,13 +50,14 @@ export default function ZoomFrame({ height, children }: Props) {
const contentH = content.clientHeight;
if (!contentH) return;
prevContentH.current = contentH;
const home = computeHome(cw, ch, contentH);
const home = computeHome(cw, ch, contentH, anchor);
homeZoom.current = home;
setZoom(home);
}, []); // eslint-disable-line react-hooks/exhaustive-deps

// Re-fit when the SVG grows or shrinks (new nodes added/removed, window resize).
// Re-fit when the SVG grows or shrinks — skipped in grow mode.
useEffect(() => {
if (grow) return;
const container = containerRef.current;
const content = contentRef.current;
if (!container || !content) return;
Expand All @@ -61,7 +67,7 @@ export default function ZoomFrame({ height, children }: Props) {
prevContentH.current = contentH;
const cw = container.clientWidth;
const ch = container.clientHeight;
const home = computeHome(cw, ch, contentH);
const home = computeHome(cw, ch, contentH, anchor);
homeZoom.current = home;
setZoom(home);
});
Expand Down Expand Up @@ -113,12 +119,12 @@ export default function ZoomFrame({ height, children }: Props) {
Math.abs(zoom.ty - h.ty) < 0.5;

return (
<div style={{ position: 'relative' }}>
<div style={{ position: 'relative', ...((fill && !grow) ? { height: '100%' } : {}) }}>
<div
ref={containerRef}
style={{
height,
overflow: 'hidden',
height: grow ? 'auto' : height,
overflow: grow ? 'visible' : 'hidden',
position: 'relative',
cursor: dragging.current ? 'grabbing' : 'grab',
userSelect: 'none',
Expand Down
Loading