From 3d4236e8df016732e7c5124f2e522ff4e1049258 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Mon, 24 Aug 2026 14:46:38 +0700 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20constellation=20lib=20scaffol?= =?UTF-8?q?d=20=E2=80=94=20graph/layout/camera=20pure=20modules=20(tested)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/frontend/package.json | 3 + services/frontend/pnpm-lock.yaml | 56 ++++++++ .../src/components/shell/route-scenes.ts | 39 ++++++ .../src/lib/constellation/camera.test.ts | 49 +++++++ .../frontend/src/lib/constellation/camera.ts | 68 ++++++++++ .../frontend/src/lib/constellation/graph.ts | 96 ++++++++++++++ .../lib/constellation/layout-graph.test.ts | 123 ++++++++++++++++++ .../frontend/src/lib/constellation/layout.ts | 116 +++++++++++++++++ 8 files changed, 550 insertions(+) create mode 100644 services/frontend/src/components/shell/route-scenes.ts create mode 100644 services/frontend/src/lib/constellation/camera.test.ts create mode 100644 services/frontend/src/lib/constellation/camera.ts create mode 100644 services/frontend/src/lib/constellation/graph.ts create mode 100644 services/frontend/src/lib/constellation/layout-graph.test.ts create mode 100644 services/frontend/src/lib/constellation/layout.ts diff --git a/services/frontend/package.json b/services/frontend/package.json index cb2d5957..3d21ce6f 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -12,6 +12,7 @@ "dependencies": { "@orpc/client": "1.15.0", "clsx": "^2.1.1", + "d3-force": "^3.0.0", "lucide-react": "^1.27.0", "motion": "^12.0.0", "next": "16.2.12", @@ -26,6 +27,8 @@ "devDependencies": { "@biomejs/biome": "2.2.0", "@tailwindcss/postcss": "^4", + "@types/bun": "^1.4.0", + "@types/d3-force": "^3.0.10", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/services/frontend/pnpm-lock.yaml b/services/frontend/pnpm-lock.yaml index f13c6ae4..0f8bf7fa 100644 --- a/services/frontend/pnpm-lock.yaml +++ b/services/frontend/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + d3-force: + specifier: ^3.0.0 + version: 3.0.0 lucide-react: specifier: ^1.27.0 version: 1.28.0(react@19.2.4) @@ -51,6 +54,12 @@ importers: '@tailwindcss/postcss': specifier: ^4 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': specifier: ^20 version: 20.19.43 @@ -567,6 +576,12 @@ packages: '@tweenjs/tween.js@23.1.3': 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': 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} hasBin: true + bun-types@1.4.0: + resolution: {integrity: sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q==} + caniuse-lite@1.0.30001806: resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} @@ -637,6 +655,22 @@ packages: csstype@3.2.3: 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: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1461,6 +1495,12 @@ snapshots: '@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': dependencies: undici-types: 6.21.0 @@ -1508,6 +1548,10 @@ snapshots: update-browserslist-db: 1.2.3(browserslist@4.28.7) optional: true + bun-types@1.4.0: + dependencies: + '@types/node': 20.19.43 + caniuse-lite@1.0.30001806: {} chromium-bidi@17.0.2(devtools-protocol@0.0.1666840): @@ -1531,6 +1575,18 @@ snapshots: 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: dependencies: ms: 2.1.3 diff --git a/services/frontend/src/components/shell/route-scenes.ts b/services/frontend/src/components/shell/route-scenes.ts new file mode 100644 index 00000000..3b5066b5 --- /dev/null +++ b/services/frontend/src/components/shell/route-scenes.ts @@ -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); +} diff --git a/services/frontend/src/lib/constellation/camera.test.ts b/services/frontend/src/lib/constellation/camera.test.ts new file mode 100644 index 00000000..bf8317d4 --- /dev/null +++ b/services/frontend/src/lib/constellation/camera.test.ts @@ -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); + }); +}); diff --git a/services/frontend/src/lib/constellation/camera.ts b/services/frontend/src/lib/constellation/camera.ts new file mode 100644 index 00000000..cb02b36b --- /dev/null +++ b/services/frontend/src/lib/constellation/camera.ts @@ -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, + }; +} diff --git a/services/frontend/src/lib/constellation/graph.ts b/services/frontend/src/lib/constellation/graph.ts new file mode 100644 index 00000000..394e3fff --- /dev/null +++ b/services/frontend/src/lib/constellation/graph.ts @@ -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 , 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; +} + +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: [] }; +} diff --git a/services/frontend/src/lib/constellation/layout-graph.test.ts b/services/frontend/src/lib/constellation/layout-graph.test.ts new file mode 100644 index 00000000..615c2cd0 --- /dev/null +++ b/services/frontend/src/lib/constellation/layout-graph.test.ts @@ -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)); + }); +}); diff --git a/services/frontend/src/lib/constellation/layout.ts b/services/frontend/src/lib/constellation/layout.ts new file mode 100644 index 00000000..382853de --- /dev/null +++ b/services/frontend/src/lib/constellation/layout.ts @@ -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, + })); +}