diff --git a/README.md b/README.md index 90fc9b6..35f458d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

- Version + Version License Docker TypeScript diff --git a/backend/package.json b/backend/package.json index 35b9983..5e91f8b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.23.21-beta", + "version": "0.23.22-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/frontend/package.json b/frontend/package.json index b680d54..9edc3c1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.23.21-beta", + "version": "0.23.22-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/pages/TopologyPage.tsx b/frontend/src/pages/TopologyPage.tsx index 879519e..0fda1fe 100644 --- a/frontend/src/pages/TopologyPage.tsx +++ b/frontend/src/pages/TopologyPage.tsx @@ -26,6 +26,7 @@ import { topologyApi } from '../services/api'; import type { TopologyDevice, TopologyLink, ExternalTopologyNode } from '../types'; import clsx from 'clsx'; import { useThemeStore } from '../store/themeStore'; +import { layoutTree } from '../utils/topologyLayout'; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -335,40 +336,19 @@ function buildGraph( }); } - const slotWidth = new Map(); - function computeWidth(id: string): number { - const kids = children.get(id) || []; - if (!kids.length) { slotWidth.set(id, 1); return 1; } - let total = 0; - for (const k of kids) total += computeWidth(k); - slotWidth.set(id, Math.max(1, total)); - return slotWidth.get(id)!; + // Layout runs in slots and rows; pixels are this file's business. Wide groups + // of sibling leaves become blocks rather than one enormous row — see + // utils/topologyLayout for why breadth must stop meaning width (#115). + const laid = layoutTree(rootId, children, depth); + for (const [id, p] of laid.positions) { + positions.set(id, { + x: componentOffsetX + (p.x + 0.5) * NODE_SLOT - NODE_W / 2, + y: p.y * (NODE_H + V_GAP), + }); } - computeWidth(rootId); - function assign(id: string, leftSlot: number): void { - const kids = children.get(id) || []; - if (!kids.length) { - positions.set(id, { - x: componentOffsetX + (leftSlot + 0.5) * NODE_SLOT - NODE_W / 2, - y: (depth.get(id) || 0) * (NODE_H + V_GAP), - }); - return; - } - let cursor = leftSlot; - for (const k of kids) { assign(k, cursor); cursor += slotWidth.get(k)!; } - const fk = positions.get(kids[0])!, lk = positions.get(kids[kids.length - 1])!; - positions.set(id, { x: (fk.x + lk.x) / 2, y: (depth.get(id) || 0) * (NODE_H + V_GAP) }); - } - assign(rootId, 0); - - let compMaxDepth = 0; - for (const id of comp) { - const d = depth.get(id); - if (d !== undefined) compMaxDepth = Math.max(compMaxDepth, d); - } - globalMaxDepth = Math.max(globalMaxDepth, compMaxDepth); - componentOffsetX += (slotWidth.get(rootId) || 1) * NODE_SLOT + COMPONENT_GAP; + globalMaxDepth = Math.max(globalMaxDepth, laid.maxRow); + componentOffsetX += laid.widthSlots * NODE_SLOT + COMPONENT_GAP; } // ── Orphan row at the bottom ───────────────────────────────────────────────── diff --git a/frontend/src/utils/topologyLayout.test.ts b/frontend/src/utils/topologyLayout.test.ts new file mode 100644 index 0000000..d631ce5 --- /dev/null +++ b/frontend/src/utils/topologyLayout.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from 'vitest'; +import { layoutTree, blockColumns, DEFAULT_LAYOUT } from './topologyLayout'; + +/** Build children/depth maps from a plain description, as the page does by BFS. */ +function tree(spec: Record, root: string) { + const children = new Map(); + const depth = new Map([[root, 0]]); + const walk = [root]; + while (walk.length) { + const id = walk.shift()!; + const kids = spec[id] || []; + children.set(id, kids); + for (const k of kids) { + if (!depth.has(k)) { depth.set(k, depth.get(id)! + 1); walk.push(k); } + } + } + return { children, depth }; +} + +const flatFleet = (n: number) => { + const kids = Array.from({ length: n }, (_, i) => `sw${i}`); + return tree({ core: kids }, 'core'); +}; + +describe('blockColumns', () => { + it('leaves small groups as a single row', () => { + expect(blockColumns(1)).toBe(1); + expect(blockColumns(6)).toBe(6); + }); + + it('wraps larger groups, favouring width over height', () => { + // Screens are landscape; a tall narrow column reads worse than a wide band. + expect(blockColumns(58)).toBe(10); + expect(blockColumns(20)).toBe(6); + }); + + it('never exceeds the ceiling', () => { + expect(blockColumns(10_000)).toBe(DEFAULT_LAYOUT.maxBlockCols); + }); +}); + +describe('layoutTree — the #115 case', () => { + it('bounds the width of a shallow, wide network', () => { + // One core, 58 access switches. The old layout gave every leaf its own + // column: 58 slots, over ten thousand pixels, one unreadable row. + const { children, depth } = flatFleet(58); + const r = layoutTree('core', children, depth); + expect(r.widthSlots).toBe(10); + expect(r.widthSlots).toBeLessThan(58); + expect(r.maxRow).toBe(6); // traded width for a handful of rows + }); + + it('places every node exactly once, with no two on the same spot', () => { + const { children, depth } = flatFleet(58); + const r = layoutTree('core', children, depth); + expect(r.positions.size).toBe(59); + const seen = new Set([...r.positions.values()].map((p) => `${p.x}:${p.y}`)); + expect(seen.size).toBe(59); + }); + + it('keeps children below their parent', () => { + const { children, depth } = flatFleet(58); + const r = layoutTree('core', children, depth); + const coreY = r.positions.get('core')!.y; + for (const [id, p] of r.positions) { + if (id !== 'core') expect(p.y).toBeGreaterThan(coreY); + } + }); + + it('centres the parent over its children', () => { + const { children, depth } = flatFleet(58); + const r = layoutTree('core', children, depth); + const xs = [...r.positions.entries()].filter(([id]) => id !== 'core').map(([, p]) => p.x); + const core = r.positions.get('core')!.x; + expect(core).toBeGreaterThanOrEqual(Math.min(...xs)); + expect(core).toBeLessThanOrEqual(Math.max(...xs)); + }); +}); + +describe('layoutTree — shapes that must not regress', () => { + it('leaves a small tree as a plain row', () => { + const { children, depth } = tree({ core: ['a', 'b', 'c'] }, 'core'); + const r = layoutTree('core', children, depth); + expect(r.widthSlots).toBe(3); + expect(r.maxRow).toBe(1); + expect(r.positions.get('a')!.y).toBe(1); + expect(r.positions.get('c')!.x).toBe(2); + }); + + it('preserves hierarchy where there genuinely is one', () => { + const { children, depth } = tree({ + core: ['d1', 'd2'], + d1: ['a1', 'a2'], + d2: ['b1', 'b2'], + }, 'core'); + const r = layoutTree('core', children, depth); + expect(r.positions.get('d1')!.y).toBe(1); + expect(r.positions.get('a1')!.y).toBe(2); + expect(r.positions.get('d1')!.x).toBeLessThan(r.positions.get('d2')!.x); + expect(r.maxRow).toBe(2); + }); + + it('handles a mix of branches and a wide leaf block', () => { + const leaves = Array.from({ length: 20 }, (_, i) => `l${i}`); + const { children, depth } = tree({ core: ['dist', ...leaves], dist: ['x', 'y'] }, 'core'); + const r = layoutTree('core', children, depth); + expect(r.positions.get('dist')!.y).toBe(1); + expect(r.positions.get('x')!.y).toBe(2); + expect(r.widthSlots).toBe(2 + blockColumns(20)); + expect(r.positions.get('l0')!.x).toBeGreaterThan(r.positions.get('dist')!.x); + }); + + it('handles a single isolated node', () => { + const { children, depth } = tree({ only: [] }, 'only'); + const r = layoutTree('only', children, depth); + expect(r.widthSlots).toBe(1); + expect(r.maxRow).toBe(0); + expect(r.positions.get('only')).toEqual({ x: 0, y: 0 }); + }); + + it('handles a deep chain without widening', () => { + const { children, depth } = tree({ a: ['b'], b: ['c'], c: ['d'], d: [] }, 'a'); + const r = layoutTree('a', children, depth); + expect(r.widthSlots).toBe(1); + expect(r.maxRow).toBe(3); + }); +}); diff --git a/frontend/src/utils/topologyLayout.ts b/frontend/src/utils/topologyLayout.ts new file mode 100644 index 0000000..0b8f344 --- /dev/null +++ b/frontend/src/utils/topologyLayout.ts @@ -0,0 +1,130 @@ +/** + * Tree layout for the topology map, in abstract slot/row units. + * + * The previous layout gave every leaf its own column, so a subtree's width was + * simply its leaf count. That is fine for a deep, branching network and useless + * for a real one: a core switch with fifty-eight access switches under it + * produced a single row fifty-eight slots wide — well over ten thousand pixels, + * which is what made the map unusable at scale (#115). + * + * Real networks are shallow and wide, so breadth has to stop translating + * directly into horizontal distance. Large groups of sibling leaves are laid out + * as a block instead of a row, which trades width for height and turns a linear + * growth curve into roughly a square-root one. Children with subtrees of their + * own keep the classic tidy-tree treatment, because there the hierarchy is the + * information and flattening it would lose meaning. + * + * Coordinates are slots and rows rather than pixels. The renderer owns the + * conversion, and this stays testable without a DOM. + */ + +export interface LayoutOptions { + /** Sibling leaves beyond this are laid out as a block rather than a row. */ + wrapThreshold: number; + /** Hard ceiling on block width, so one huge group cannot dominate the canvas. */ + maxBlockCols: number; +} + +export const DEFAULT_LAYOUT: LayoutOptions = { wrapThreshold: 6, maxBlockCols: 12 }; + +export interface Placement { + /** Horizontal position in slots. Fractional for a parent centred over its children. */ + x: number; + /** Vertical position in rows. */ + y: number; +} + +export interface LayoutResult { + positions: Map; + /** Total width in slots, which is what the caller offsets the next component by. */ + widthSlots: number; + /** Deepest row used, so callers can place anything that follows below it. */ + maxRow: number; +} + +/** + * Columns for a block of `n` sibling leaves. + * + * Slightly wider than tall — screens are landscape and a column of forty reads + * worse than a wide band of four rows. + */ +export function blockColumns(n: number, opts: LayoutOptions = DEFAULT_LAYOUT): number { + if (n <= opts.wrapThreshold) return Math.max(1, n); + return Math.max(1, Math.min(opts.maxBlockCols, Math.ceil(Math.sqrt(n * 1.6)))); +} + +/** + * Lay out one connected component rooted at `rootId`. + * + * `children` must describe a tree — the caller derives it by breadth-first walk, + * which is what makes cycles safe: a node is reached once and keeps the first + * parent that finds it. + */ +export function layoutTree( + rootId: string, + children: Map, + depth: Map, + opts: LayoutOptions = DEFAULT_LAYOUT, +): LayoutResult { + const positions = new Map(); + const isLeaf = (id: string) => (children.get(id) || []).length === 0; + + /** Width in slots, and how many rows the node's own leaf block occupies. */ + const width = new Map(); + const blockRows = new Map(); + + const measure = (id: string): number => { + const kids = children.get(id) || []; + if (!kids.length) { width.set(id, 1); blockRows.set(id, 0); return 1; } + + const leaves = kids.filter(isLeaf); + const branches = kids.filter((k) => !isLeaf(k)); + + const branchWidth = branches.reduce((sum, k) => sum + measure(k), 0); + const cols = leaves.length ? blockColumns(leaves.length, opts) : 0; + const rows = leaves.length ? Math.ceil(leaves.length / cols) : 0; + blockRows.set(id, rows); + + const total = Math.max(1, branchWidth + cols); + width.set(id, total); + return total; + }; + measure(rootId); + + let maxRow = 0; + + const place = (id: string, left: number): void => { + const row = depth.get(id) ?? 0; + maxRow = Math.max(maxRow, row); + + const kids = children.get(id) || []; + if (!kids.length) { + positions.set(id, { x: left, y: row }); + return; + } + + const leaves = kids.filter(isLeaf); + const branches = kids.filter((k) => !isLeaf(k)); + + // Branch subtrees first, left to right, so the block sits beside them. + let cursor = left; + for (const b of branches) { + place(b, cursor); + cursor += width.get(b)!; + } + + // Then the block of leaf children, filling left to right, top to bottom. + const cols = leaves.length ? blockColumns(leaves.length, opts) : 0; + leaves.forEach((leaf, i) => { + const r = row + 1 + Math.floor(i / cols); + positions.set(leaf, { x: cursor + (i % cols), y: r }); + maxRow = Math.max(maxRow, r); + }); + + // Centre the parent over everything beneath it. + positions.set(id, { x: left + (width.get(id)! - 1) / 2, y: row }); + }; + place(rootId, 0); + + return { positions, widthSlots: width.get(rootId) || 1, maxRow }; +} diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 9338777..b691f1d 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.23.21 Beta'; +export const APP_VERSION = 'v0.23.22 Beta';