feat(frontend): constellation lib scaffold — graph/layout/camera pure modules (tested)
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@orpc/client": "1.15.0",
|
"@orpc/client": "1.15.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
"lucide-react": "^1.27.0",
|
"lucide-react": "^1.27.0",
|
||||||
"motion": "^12.0.0",
|
"motion": "^12.0.0",
|
||||||
"next": "16.2.12",
|
"next": "16.2.12",
|
||||||
@@ -26,6 +27,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.2.0",
|
"@biomejs/biome": "2.2.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bun": "^1.4.0",
|
||||||
|
"@types/d3-force": "^3.0.10",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
Generated
+56
@@ -14,6 +14,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
d3-force:
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.0.0
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.27.0
|
specifier: ^1.27.0
|
||||||
version: 1.28.0(react@19.2.4)
|
version: 1.28.0(react@19.2.4)
|
||||||
@@ -51,6 +54,12 @@ importers:
|
|||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4
|
specifier: ^4
|
||||||
version: 4.3.3
|
version: 4.3.3
|
||||||
|
'@types/bun':
|
||||||
|
specifier: ^1.4.0
|
||||||
|
version: 1.4.0
|
||||||
|
'@types/d3-force':
|
||||||
|
specifier: ^3.0.10
|
||||||
|
version: 3.0.10
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^20
|
specifier: ^20
|
||||||
version: 20.19.43
|
version: 20.19.43
|
||||||
@@ -567,6 +576,12 @@ packages:
|
|||||||
'@tweenjs/tween.js@23.1.3':
|
'@tweenjs/tween.js@23.1.3':
|
||||||
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
|
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
|
||||||
|
|
||||||
|
'@types/bun@1.4.0':
|
||||||
|
resolution: {integrity: sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ==}
|
||||||
|
|
||||||
|
'@types/d3-force@3.0.10':
|
||||||
|
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
|
||||||
|
|
||||||
'@types/node@20.19.43':
|
'@types/node@20.19.43':
|
||||||
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
|
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
|
||||||
|
|
||||||
@@ -611,6 +626,9 @@ packages:
|
|||||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
bun-types@1.4.0:
|
||||||
|
resolution: {integrity: sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q==}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001806:
|
caniuse-lite@1.0.30001806:
|
||||||
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
|
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
|
||||||
|
|
||||||
@@ -637,6 +655,22 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
d3-dispatch@3.0.1:
|
||||||
|
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-force@3.0.0:
|
||||||
|
resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-quadtree@3.0.1:
|
||||||
|
resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-timer@3.0.1:
|
||||||
|
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -1461,6 +1495,12 @@ snapshots:
|
|||||||
|
|
||||||
'@tweenjs/tween.js@23.1.3': {}
|
'@tweenjs/tween.js@23.1.3': {}
|
||||||
|
|
||||||
|
'@types/bun@1.4.0':
|
||||||
|
dependencies:
|
||||||
|
bun-types: 1.4.0
|
||||||
|
|
||||||
|
'@types/d3-force@3.0.10': {}
|
||||||
|
|
||||||
'@types/node@20.19.43':
|
'@types/node@20.19.43':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
@@ -1508,6 +1548,10 @@ snapshots:
|
|||||||
update-browserslist-db: 1.2.3(browserslist@4.28.7)
|
update-browserslist-db: 1.2.3(browserslist@4.28.7)
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
bun-types@1.4.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 20.19.43
|
||||||
|
|
||||||
caniuse-lite@1.0.30001806: {}
|
caniuse-lite@1.0.30001806: {}
|
||||||
|
|
||||||
chromium-bidi@17.0.2(devtools-protocol@0.0.1666840):
|
chromium-bidi@17.0.2(devtools-protocol@0.0.1666840):
|
||||||
@@ -1531,6 +1575,18 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
d3-dispatch@3.0.1: {}
|
||||||
|
|
||||||
|
d3-force@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
d3-dispatch: 3.0.1
|
||||||
|
d3-quadtree: 3.0.1
|
||||||
|
d3-timer: 3.0.1
|
||||||
|
|
||||||
|
d3-quadtree@3.0.1: {}
|
||||||
|
|
||||||
|
d3-timer@3.0.1: {}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Scene config: maps pathname → constellation scene definition.
|
||||||
|
* Pure data; the stage reads it to build graphs and place overlays.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
||||||
|
import { channelsToGraph, statsToGraph } from "@/lib/constellation/graph";
|
||||||
|
|
||||||
|
export interface SceneDef {
|
||||||
|
route: string;
|
||||||
|
label: string;
|
||||||
|
/** Build the graph from typed SSR seed data. */
|
||||||
|
build: (seed: SceneSeed) => ConstellationGraph;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything a scene may need — all optional, scenes pick what they use. */
|
||||||
|
export interface SceneSeed {
|
||||||
|
stats?: import("@/lib/types").DashboardStats;
|
||||||
|
channels?: import("@/lib/types").DashboardChannel[];
|
||||||
|
guildLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SCENES: SceneDef[] = [
|
||||||
|
{
|
||||||
|
route: "/dashboard/",
|
||||||
|
label: "Deck",
|
||||||
|
build: (s) => (s.stats ? statsToGraph(s.stats) : { nodes: [], edges: [] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: "/channels/",
|
||||||
|
label: "Channels",
|
||||||
|
build: (s) =>
|
||||||
|
s.channels ? channelsToGraph(s.channels) : { nodes: [], edges: [] },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function resolveScene(pathname: string): SceneDef | undefined {
|
||||||
|
return SCENES.find((sc) => sc.route === pathname);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { fitCamera, flyTo } from "./camera";
|
||||||
|
|
||||||
|
describe("flyTo", () => {
|
||||||
|
const from = { x: 0, y: 0, z: 1 };
|
||||||
|
const to = { x: 100, y: -50, z: 2 };
|
||||||
|
|
||||||
|
test("t=0 returns origin", () => {
|
||||||
|
expect(flyTo(from, to, 0)).toEqual({ x: 0, y: 0, z: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("t=1 arrives exactly", () => {
|
||||||
|
expect(flyTo(from, to, 1)).toEqual(to);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("monotonic progress", () => {
|
||||||
|
let prev = -Infinity;
|
||||||
|
for (let i = 0; i <= 20; i++) {
|
||||||
|
const s = flyTo(from, to, i / 20);
|
||||||
|
expect(s.x).toBeGreaterThan(prev);
|
||||||
|
prev = s.x;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clamps out-of-range t", () => {
|
||||||
|
expect(flyTo(from, to, -3)).toEqual(from);
|
||||||
|
expect(flyTo(from, to, 7)).toEqual(to);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fitCamera", () => {
|
||||||
|
test("empty scene centers", () => {
|
||||||
|
const c = fitCamera([], 800, 600);
|
||||||
|
expect(c.x).toBe(400);
|
||||||
|
expect(c.y).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zooms to fit nodes with padding", () => {
|
||||||
|
const nodes = [
|
||||||
|
{ x: 100, y: 100, r: 10 },
|
||||||
|
{ x: 500, y: 400, r: 10 },
|
||||||
|
];
|
||||||
|
const c = fitCamera(nodes, 800, 600);
|
||||||
|
expect(c.x).toBe(300);
|
||||||
|
expect(c.y).toBe(250);
|
||||||
|
expect(c.z).toBeGreaterThan(0);
|
||||||
|
expect(c.z).toBeLessThanOrEqual(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Camera math for constellation fly-to — pure, easing-based interpolation.
|
||||||
|
*/
|
||||||
|
export interface CameraState {
|
||||||
|
/** Camera center in canvas space. */
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
/** Zoom factor (1 = fit). */
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Easing = (t: number) => number;
|
||||||
|
|
||||||
|
/** Smooth ease-in-out cubic. Monotonic on [0,1]. */
|
||||||
|
export const easeInOutCubic: Easing = (t) =>
|
||||||
|
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
||||||
|
|
||||||
|
export function flyTo(
|
||||||
|
from: CameraState,
|
||||||
|
to: CameraState,
|
||||||
|
t: number,
|
||||||
|
ease: Easing = easeInOutCubic,
|
||||||
|
): CameraState {
|
||||||
|
const clamped = Math.max(0, Math.min(1, t));
|
||||||
|
const k = ease(clamped);
|
||||||
|
return {
|
||||||
|
x: from.x + (to.x - from.x) * k,
|
||||||
|
y: from.y + (to.y - from.y) * k,
|
||||||
|
z: from.z + (to.z - from.z) * k,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fit-to-view camera so a scene always starts framed. */
|
||||||
|
export function fitCamera(
|
||||||
|
nodes: { x: number; y: number; r?: number }[],
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
padding = 80,
|
||||||
|
): CameraState {
|
||||||
|
if (nodes.length === 0 || width <= 0 || height <= 0) {
|
||||||
|
return { x: width / 2, y: height / 2, z: 1 };
|
||||||
|
}
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
for (const n of nodes) {
|
||||||
|
const r = n.r ?? 8;
|
||||||
|
minX = Math.min(minX, n.x - r);
|
||||||
|
maxX = Math.max(maxX, n.x + r);
|
||||||
|
minY = Math.min(minY, n.y - r);
|
||||||
|
maxY = Math.max(maxY, n.y + r);
|
||||||
|
}
|
||||||
|
const w = Math.max(1, maxX - minX);
|
||||||
|
const h = Math.max(1, maxY - minY);
|
||||||
|
const z = Math.min(
|
||||||
|
3,
|
||||||
|
Math.max(
|
||||||
|
0.35,
|
||||||
|
Math.min((width - padding * 2) / w, (height - padding * 2) / h),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
x: (minX + maxX) / 2,
|
||||||
|
y: (minY + maxY) / 2,
|
||||||
|
z,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Constellation graph model — pure data, no React/DOM.
|
||||||
|
* Builders convert existing API payloads into star-graph structures.
|
||||||
|
*/
|
||||||
|
import type { DashboardChannel, DashboardStats } from "@/lib/types";
|
||||||
|
|
||||||
|
export type NodeKind =
|
||||||
|
| "guild"
|
||||||
|
| "channel"
|
||||||
|
| "message"
|
||||||
|
| "flagged"
|
||||||
|
| "speaker"
|
||||||
|
| "media"
|
||||||
|
| "term"
|
||||||
|
| "metric";
|
||||||
|
|
||||||
|
export interface GraphNode {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: NodeKind;
|
||||||
|
/** Optional route this node navigates to (plain <a href>, trailingSlash). */
|
||||||
|
href?: string;
|
||||||
|
/** Relative magnitude used for radius/glow (0..1 normalized by caller). */
|
||||||
|
value?: number;
|
||||||
|
/** Extra scene-specific payload (e.g. flagged count, culture summary). */
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphEdge {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
/** Edge thickness/pulse driver. Defaults to 0.5. */
|
||||||
|
weight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConstellationGraph {
|
||||||
|
nodes: GraphNode[];
|
||||||
|
edges: GraphEdge[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp01 = (n: number, max: number): number =>
|
||||||
|
max <= 0 ? 0.25 : Math.min(1, Math.max(0.05, n / max));
|
||||||
|
|
||||||
|
/** Dashboard scene: guild star at center, top channels orbiting it. */
|
||||||
|
export function statsToGraph(stats: DashboardStats): ConstellationGraph {
|
||||||
|
const top = stats.top_channels ?? [];
|
||||||
|
const maxCount = top.reduce((m, c) => Math.max(m, c.message_count), 0);
|
||||||
|
const nodes: GraphNode[] = [
|
||||||
|
{
|
||||||
|
id: "guild",
|
||||||
|
label: "GMW",
|
||||||
|
kind: "guild",
|
||||||
|
value: 1,
|
||||||
|
meta: {
|
||||||
|
total_messages: stats.total_messages,
|
||||||
|
total_flagged: stats.total_flagged,
|
||||||
|
active_users_24h: stats.active_users_24h,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const edges: GraphEdge[] = [];
|
||||||
|
for (const ch of top) {
|
||||||
|
nodes.push({
|
||||||
|
id: `channel:${ch.channel_id}`,
|
||||||
|
label: ch.channel_name || ch.channel_id,
|
||||||
|
kind: "channel",
|
||||||
|
href: "/channels/",
|
||||||
|
value: clamp01(ch.message_count, maxCount),
|
||||||
|
meta: { message_count: ch.message_count },
|
||||||
|
});
|
||||||
|
edges.push({
|
||||||
|
source: "guild",
|
||||||
|
target: `channel:${ch.channel_id}`,
|
||||||
|
weight: clamp01(ch.message_count, maxCount),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Channels scene: every channel is a star; size = traffic, color = flags. */
|
||||||
|
export function channelsToGraph(
|
||||||
|
channels: DashboardChannel[],
|
||||||
|
): ConstellationGraph {
|
||||||
|
const maxMsg = channels.reduce((m, c) => Math.max(m, c.total_messages), 0);
|
||||||
|
const nodes: GraphNode[] = channels.map((c) => ({
|
||||||
|
id: `channel:${c.channel_id}`,
|
||||||
|
label: c.channel_name || c.channel_id,
|
||||||
|
kind: "channel",
|
||||||
|
value: clamp01(c.total_messages, maxMsg),
|
||||||
|
meta: {
|
||||||
|
flagged_count: c.flagged_count,
|
||||||
|
culture_summary: c.culture_summary,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return { nodes, edges: [] };
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { channelsToGraph, statsToGraph } from "./graph";
|
||||||
|
import { computeLayout, radiusFor } from "./layout";
|
||||||
|
|
||||||
|
const statsFixture = {
|
||||||
|
total_messages: 1200,
|
||||||
|
total_users: 40,
|
||||||
|
total_flagged: 30,
|
||||||
|
total_clean: 1100,
|
||||||
|
total_warned: 12,
|
||||||
|
total_error: 2,
|
||||||
|
total_voice_recordings: 8,
|
||||||
|
total_profiles: 40,
|
||||||
|
today_messages: 55,
|
||||||
|
today_flagged: 3,
|
||||||
|
active_users_24h: 18,
|
||||||
|
top_channels: [
|
||||||
|
{ channel_id: "c1", channel_name: "general", message_count: 400 },
|
||||||
|
{ channel_id: "c2", channel_name: "random", message_count: 200 },
|
||||||
|
],
|
||||||
|
moderation_overview: { pending: 1, processing: 0, error: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("statsToGraph", () => {
|
||||||
|
test("guild center + channel nodes + edges", () => {
|
||||||
|
const g = statsToGraph(statsFixture);
|
||||||
|
expect(g.nodes.length).toBe(3);
|
||||||
|
expect(g.nodes[0]?.id).toBe("guild");
|
||||||
|
expect(g.edges.length).toBe(2);
|
||||||
|
expect(g.edges[0]?.source).toBe("guild");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bigger channel gets bigger value", () => {
|
||||||
|
const g = statsToGraph(statsFixture);
|
||||||
|
const c1 = g.nodes.find((n) => n.label === "general");
|
||||||
|
const c2 = g.nodes.find((n) => n.label === "random");
|
||||||
|
expect((c1?.value ?? 0) > (c2?.value ?? 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty top_channels still yields guild node", () => {
|
||||||
|
const g = statsToGraph({ ...statsFixture, top_channels: [] });
|
||||||
|
expect(g.nodes.length).toBe(1);
|
||||||
|
expect(g.edges.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("channelsToGraph", () => {
|
||||||
|
test("maps every channel", () => {
|
||||||
|
const chans = [
|
||||||
|
{
|
||||||
|
channel_id: "a",
|
||||||
|
channel_name: "alpha",
|
||||||
|
total_messages: 10,
|
||||||
|
flagged_count: 1,
|
||||||
|
},
|
||||||
|
{ channel_id: "b", total_messages: 5, flagged_count: 0 },
|
||||||
|
];
|
||||||
|
const g = channelsToGraph(chans);
|
||||||
|
expect(g.nodes.length).toBe(2);
|
||||||
|
expect(g.nodes[1]?.label).toBe("b"); // falls back to id when name null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeLayout", () => {
|
||||||
|
const nodes = [
|
||||||
|
{ id: "guild", kind: "guild", value: 1 },
|
||||||
|
{ id: "channel:c1", kind: "channel", value: 0.8 },
|
||||||
|
{ id: "channel:c2", kind: "channel", value: 0.4 },
|
||||||
|
];
|
||||||
|
const edges = [
|
||||||
|
{ source: "guild", target: "channel:c1" },
|
||||||
|
{ source: "guild", target: "channel:c2" },
|
||||||
|
];
|
||||||
|
|
||||||
|
test("deterministic for same seed", () => {
|
||||||
|
const a = computeLayout(nodes, edges, {
|
||||||
|
width: 900,
|
||||||
|
height: 700,
|
||||||
|
seed: 42,
|
||||||
|
});
|
||||||
|
const b = computeLayout(nodes, edges, {
|
||||||
|
width: 900,
|
||||||
|
height: 700,
|
||||||
|
seed: 42,
|
||||||
|
});
|
||||||
|
expect(a.map((n) => [n.id, Math.round(n.x), Math.round(n.y)])).toEqual(
|
||||||
|
b.map((n) => [n.id, Math.round(n.x), Math.round(n.y)]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all finite positions within canvas bounds", () => {
|
||||||
|
const out = computeLayout(nodes, edges, {
|
||||||
|
width: 900,
|
||||||
|
height: 700,
|
||||||
|
seed: 7,
|
||||||
|
});
|
||||||
|
for (const n of out) {
|
||||||
|
expect(Number.isFinite(n.x)).toBe(true);
|
||||||
|
expect(Number.isFinite(n.y)).toBe(true);
|
||||||
|
expect(n.x).toBeGreaterThanOrEqual(-50);
|
||||||
|
expect(n.x).toBeLessThanOrEqual(950);
|
||||||
|
expect(n.y).toBeGreaterThanOrEqual(-50);
|
||||||
|
expect(n.y).toBeLessThanOrEqual(750);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reduced motion returns static ring", () => {
|
||||||
|
const out = computeLayout(nodes, edges, {
|
||||||
|
width: 900,
|
||||||
|
height: 700,
|
||||||
|
reduced: true,
|
||||||
|
});
|
||||||
|
expect(out.length).toBe(3);
|
||||||
|
expect(Number.isFinite(out[0]?.y ?? NaN)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("radiusFor", () => {
|
||||||
|
test("guild larger than channel larger than message", () => {
|
||||||
|
expect(radiusFor("guild", 1)).toBeGreaterThan(radiusFor("channel", 1));
|
||||||
|
expect(radiusFor("channel", 1)).toBeGreaterThan(radiusFor("message", 1));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Deterministic constellation layout — pure functions over graph data.
|
||||||
|
* d3-force with a seeded PRNG so every render produces the same sky.
|
||||||
|
* `prefers-reduced-motion` callers use the static radial fallback.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
forceCenter,
|
||||||
|
forceCollide,
|
||||||
|
forceLink,
|
||||||
|
forceManyBody,
|
||||||
|
forceSimulation,
|
||||||
|
type SimulationNodeDatum,
|
||||||
|
} from "d3-force";
|
||||||
|
|
||||||
|
export interface LayoutNode extends SimulationNodeDatum {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
/** Screen-space radius in px (already scaled by value + kind). */
|
||||||
|
r: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutOptions {
|
||||||
|
/** Canvas width in CSS px. */
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
seed?: number;
|
||||||
|
/** Static radial ring layout (no simulation). */
|
||||||
|
reduced?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mulberry32 — small, fast, deterministic. */
|
||||||
|
function mulberry32(seed: number): () => number {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a |= 0;
|
||||||
|
a = (a + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) | 0;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function radiusFor(kind: string, value = 0.5): number {
|
||||||
|
const base =
|
||||||
|
kind === "guild"
|
||||||
|
? 34
|
||||||
|
: kind === "channel"
|
||||||
|
? 16
|
||||||
|
: kind === "flagged"
|
||||||
|
? 11
|
||||||
|
: 9;
|
||||||
|
return base * (0.55 + 0.9 * Math.max(0, Math.min(1, value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute final positions. Same inputs (+seed) ⇒ identical output positions.
|
||||||
|
*/
|
||||||
|
export function computeLayout(
|
||||||
|
nodesIn: { id: string; kind: string; value?: number }[],
|
||||||
|
edges: { source: string; target: string }[],
|
||||||
|
opts: LayoutOptions,
|
||||||
|
): LayoutNode[] {
|
||||||
|
const rand = mulberry32(opts.seed ?? 42);
|
||||||
|
const cx = opts.width / 2;
|
||||||
|
const cy = opts.height / 2;
|
||||||
|
|
||||||
|
if (opts.reduced || nodesIn.length === 0) {
|
||||||
|
return nodesIn.map((n, i) => ({
|
||||||
|
...n,
|
||||||
|
x: cx + radiusFor(n.kind, n.value),
|
||||||
|
y:
|
||||||
|
cy +
|
||||||
|
Math.sin((i / Math.max(1, nodesIn.length)) * Math.PI * 2) *
|
||||||
|
Math.min(cx, cy) *
|
||||||
|
0.62,
|
||||||
|
r: radiusFor(n.kind, n.value),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const simNodes = nodesIn.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
kind: n.kind,
|
||||||
|
value: n.value ?? 0.5,
|
||||||
|
r: radiusFor(n.kind, n.value),
|
||||||
|
x: cx + (rand() - 0.5) * opts.width * 0.8,
|
||||||
|
y: cy + (rand() - 0.5) * opts.height * 0.8,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sim = forceSimulation(simNodes)
|
||||||
|
.force("charge", forceManyBody().strength(-420))
|
||||||
|
.force(
|
||||||
|
"link",
|
||||||
|
forceLink(edges.map((e) => ({ ...e })))
|
||||||
|
.id((d) => (d as { id: string }).id)
|
||||||
|
.distance(120)
|
||||||
|
.strength(0.6),
|
||||||
|
)
|
||||||
|
.force("center", forceCenter(cx, cy))
|
||||||
|
.force(
|
||||||
|
"collide",
|
||||||
|
forceCollide((d) => (d as { r: number }).r + 14),
|
||||||
|
)
|
||||||
|
.stop();
|
||||||
|
|
||||||
|
// Fixed tick budget keeps the result deterministic across machines.
|
||||||
|
for (let i = 0; i < 240; i++) sim.tick();
|
||||||
|
sim.on("tick", null);
|
||||||
|
|
||||||
|
return simNodes.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
x: Number.isFinite(n.x) ? n.x : cx,
|
||||||
|
y: Number.isFinite(n.y) ? n.y : cy,
|
||||||
|
r: n.r,
|
||||||
|
}));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user