/
aposdupos
/
cofounder-table-structure
Обзор
Документация
Войти
/
aposdupos
/
cofounder-table-structure
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
scripts/layout.ts
193 строки
8 KB
Влад
feat: интерактивная карта серверного контракта CoFounder-ai
28 июл 2026, 15:30
28 июл 2026, 15:30
38555db
Код
Авторство
О чём код?
/** * Раскладка узлов графа — считается ОДИН РАЗ здесь, при генерации. * * В рантайме никакой симуляции нет: координаты лежат готовыми числами в * `src/data/graph/layout.ts`. Отсюда три следствия, ради которых так и сделано: * граф открывается мгновенно, при перерисовке не может «прыгнуть» в принципе, * а неудачно вставший узел правится руками в том же .ts — ровно как остальные * данные проекта. * * Math.random подменяется детерминированным PRNG на время симуляции: d3-force * дёргает его в jiggle() при совпадении координат, и без подмены два прогона * генератора давали бы разные файлы. */ import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY, type SimulationLinkDatum, type SimulationNodeDatum, } from 'd3-force'; import type { GraphEdge, GraphNode, NodePosition } from '../src/types'; /** mulberry32 — короткий PRNG с воспроизводимой последовательностью. */ function seededRandom(seed: number): () => number { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function withSeed<T>(seed: number, fn: () => T): T { const original = Math.random; Math.random = seededRandom(seed); try { return fn(); } finally { Math.random = original; } } interface SimNode extends SimulationNodeDatum { id: string; weight: number; } const SEED = 20260728; const NODE_W = 190; const NODE_H = 60; /** * Силовая раскладка для связной части графа. Изолированные узлы в симуляцию не * попадают — их размазывает по всему полю и они мешают читать связный кластер; * для них ниже отдельная сетка. */ export function forceLayout( nodes: readonly GraphNode[], edges: readonly GraphEdge[], options: { width: number; height: number; linkDistance?: number; charge?: number }, ): Record<string, NodePosition> { const connected = new Set<string>(); for (const e of edges) { connected.add(e.source); connected.add(e.target); } const degree = new Map<string, number>(); for (const e of edges) { degree.set(e.source, (degree.get(e.source) ?? 0) + 1); degree.set(e.target, (degree.get(e.target) ?? 0) + 1); } const simNodes: SimNode[] = nodes .filter((n) => connected.has(n.id)) .map((n) => ({ id: n.id, weight: degree.get(n.id) ?? 1 })); const nodeIndex = new Set(simNodes.map((n) => n.id)); const simLinks: Array<SimulationLinkDatum<SimNode>> = edges .filter((e) => nodeIndex.has(e.source) && nodeIndex.has(e.target)) .map((e) => ({ source: e.source, target: e.target })); const positions: Record<string, NodePosition> = {}; withSeed(SEED, () => { const sim = forceSimulation<SimNode>(simNodes) .force( 'link', forceLink<SimNode, SimulationLinkDatum<SimNode>>(simLinks) .id((d) => d.id) .distance(options.linkDistance ?? 220) .strength(0.35), ) // Узлы с большой степенью отталкивают сильнее — так auth.users и // workspaces расходятся в разные концы и не слипаются в одну кляксу. .force( 'charge', forceManyBody<SimNode>().strength((d) => (options.charge ?? -1400) - d.weight * 45), ) .force('center', forceCenter(options.width / 2, options.height / 2)) .force('collide', forceCollide<SimNode>(() => Math.max(NODE_W, NODE_H) * 0.72)) .force('x', forceX(options.width / 2).strength(0.03)) .force('y', forceY(options.height / 2).strength(0.05)) .stop(); // Симуляция прогоняется синхронно фиксированным числом тиков — // никакой зависимости от таймера и, значит, от машины. for (let i = 0; i < 600; i++) sim.tick(); for (const n of simNodes) { positions[n.id] = { x: Math.round(n.x ?? 0), y: Math.round(n.y ?? 0) }; } }); // Изолированные — ровной сеткой под связным графом, отдельной группой. const isolated = nodes.filter((n) => !connected.has(n.id)); const perRow = 5; const gridTop = options.height + 140; isolated.forEach((n, i) => { positions[n.id] = { x: Math.round(options.width / 2 - ((perRow - 1) * 250) / 2 + (i % perRow) * 250), y: Math.round(gridTop + Math.floor(i / perRow) * 120), }; }); return normalize(positions); } /** * Радиальная двудольная раскладка для слоя «Ручки → таблицы». * * Силовая симуляция на четверти тысячи узлов даёт клубок, в котором ничего не * найти, поэтому порядок задан явно. Ключевое здесь — не «два кольца», а * выравнивание: каждая таблица получает сектор шириной пропорционально числу * своих ручек и стоит ровно посреди него, а её ручки — снаружи в том же * секторе. Без этого ручки одной таблицы расползаются по всей окружности, * подсветка соседей вылезает за края экрана и смысл слоя пропадает. */ export function radialByGroup( groups: ReadonlyArray<{ inner: GraphNode; outer: GraphNode[] }>, options: { innerRadius: number; outerRadius: number }, ): Record<string, NodePosition> { const positions: Record<string, NodePosition> = {}; const total = groups.reduce((n, g) => n + Math.max(g.outer.length, 1), 0); const start = -Math.PI / 2; let cursor = 0; for (const group of groups) { const weight = Math.max(group.outer.length, 1); const sectorStart = (cursor / total) * Math.PI * 2; const sectorSize = (weight / total) * Math.PI * 2; const mid = start + sectorStart + sectorSize / 2; positions[group.inner.id] = { x: Math.round(Math.cos(mid) * options.innerRadius), y: Math.round(Math.sin(mid) * options.innerRadius), }; group.outer.forEach((node, i) => { // +0.5 — узлы встают по центрам своих долей сектора, а не по границам. const angle = start + sectorStart + ((i + 0.5) / weight) * sectorSize; positions[node.id] = { x: Math.round(Math.cos(angle) * options.outerRadius), y: Math.round(Math.sin(angle) * options.outerRadius), }; }); cursor += weight; } return normalize(positions); } /** Сдвигает всю раскладку в положительные координаты с полем по краям. */ function normalize(positions: Record<string, NodePosition>): Record<string, NodePosition> { const values = Object.values(positions); if (values.length === 0) return positions; const minX = Math.min(...values.map((p) => p.x)); const minY = Math.min(...values.map((p) => p.y)); const pad = 120; const out: Record<string, NodePosition> = {}; for (const [id, p] of Object.entries(positions)) { out[id] = { x: p.x - minX + pad, y: p.y - minY + pad }; } return out; }