Add a Production dashboard with an animated knowledge graph
The sidebar now groups Production under Dashboard and the four existing pages under Settings, and Production is the landing page. It shows content in progress, material, content made, approved and LMS-ready counts, plus a knowledge graph linking Brain documents, results, found sources and schedules, with the schedule and what is being made right now beside it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
38
src/express/graph.test.ts
Normal file
38
src/express/graph.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Source } from '../api/engine'
|
||||
import { buildGraph, HEIGHT, WIDTH } from './graph'
|
||||
import type { FoundSource, Material } from './results'
|
||||
import type { Schedule } from './schedules'
|
||||
|
||||
const doc = (docId: number, title: string) => ({ docId, title, originalName: `${title}.pdf` }) as Source
|
||||
const material = (id: number, status: Material['status'], sources: string[]) =>
|
||||
({ id, agentId: 7, kind: 'summary', status, planTitle: `Lesson ${id}`, sources }) as Material
|
||||
const schedule = { agent: { id: 7, name: 'Express · 2 summary per week' } } as Schedule
|
||||
|
||||
describe('buildGraph', () => {
|
||||
const data = {
|
||||
docs: [doc(1, 'Onboarding'), doc(2, 'Safety')],
|
||||
materials: [material(10, 'accepted', ['Onboarding.pdf', 'Safety']), material(11, 'generating', [])],
|
||||
sources: [{ id: 5, agentId: 7, title: 'Article', documentId: 2 } as FoundSource],
|
||||
schedules: [schedule],
|
||||
}
|
||||
const graph = buildGraph(data, c => c)
|
||||
|
||||
it('puts results in progress in their own cluster', () => {
|
||||
expect(graph.nodes.find(n => n.id === 'mat-7-10')?.cluster).toBe('made')
|
||||
expect(graph.nodes.find(n => n.id === 'mat-7-11')?.cluster).toBe('working')
|
||||
})
|
||||
|
||||
it('links results to their sources and schedule, and found sources to their document', () => {
|
||||
const cross = graph.links.filter(l => !l.hub).map(l => `${l.source}>${l.target}`)
|
||||
expect(cross).toEqual(expect.arrayContaining(['mat-7-10>doc-1', 'mat-7-10>doc-2', 'mat-7-10>agent-7', 'found-7-5>doc-2']))
|
||||
})
|
||||
|
||||
it('lays out the same data the same way, inside the canvas', () => {
|
||||
expect(buildGraph(data, c => c).nodes).toEqual(graph.nodes)
|
||||
for (const n of graph.nodes) {
|
||||
expect(n.x).toBeGreaterThanOrEqual(0); expect(n.x).toBeLessThanOrEqual(WIDTH)
|
||||
expect(n.y).toBeGreaterThanOrEqual(0); expect(n.y).toBeLessThanOrEqual(HEIGHT)
|
||||
}
|
||||
})
|
||||
})
|
||||
133
src/express/graph.ts
Normal file
133
src/express/graph.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// The Production knowledge graph: every Brain document, result, found source and schedule as a node,
|
||||
// grouped around one hub per cluster and linked where the engine says they are related (a result's
|
||||
// sources, the schedule that made it, the document a found source became). Laid out once with a
|
||||
// small seeded force simulation, so the same data always draws the same picture.
|
||||
import type { Source } from '../api/engine'
|
||||
import { isWorking, type FoundSource, type Material } from './results'
|
||||
import type { Schedule } from './schedules'
|
||||
|
||||
export type Cluster = 'sources' | 'made' | 'working' | 'research' | 'schedules'
|
||||
export const CLUSTERS: Cluster[] = ['sources', 'made', 'working', 'research', 'schedules']
|
||||
|
||||
export interface GraphNode {
|
||||
id: string
|
||||
cluster: Cluster
|
||||
label: string
|
||||
hub: boolean
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface GraphLink { source: string; target: string; hub: boolean }
|
||||
|
||||
export interface Graph { nodes: GraphNode[]; links: GraphLink[]; width: number; height: number }
|
||||
|
||||
export const WIDTH = 1000
|
||||
export const HEIGHT = 640
|
||||
|
||||
// Where each cluster settles, around the middle of the canvas.
|
||||
const ANCHOR: Record<Cluster, [number, number]> = {
|
||||
sources: [560, 360], made: [450, 430], working: [330, 300], research: [440, 200], schedules: [700, 230],
|
||||
}
|
||||
|
||||
const norm = (s: string) => s.trim().toLowerCase()
|
||||
|
||||
export function buildGraph(
|
||||
{ docs, materials, sources, schedules }: { docs: Source[]; materials: Material[]; sources: FoundSource[]; schedules: Schedule[] },
|
||||
hubLabel: (c: Cluster) => string,
|
||||
): Graph {
|
||||
const nodes: GraphNode[] = CLUSTERS.map(c => ({ id: `hub-${c}`, cluster: c, label: hubLabel(c), hub: true, x: 0, y: 0 }))
|
||||
const links: GraphLink[] = []
|
||||
const add = (id: string, cluster: Cluster, label: string) => {
|
||||
nodes.push({ id, cluster, label, hub: false, x: 0, y: 0 })
|
||||
links.push({ source: id, target: `hub-${cluster}`, hub: true })
|
||||
}
|
||||
const link = (source: string, target: string) => links.push({ source, target, hub: false })
|
||||
|
||||
// Results name their sources by title, so documents are found by title or file name.
|
||||
const docByName = new Map<string, string>()
|
||||
for (const d of docs) {
|
||||
add(`doc-${d.docId}`, 'sources', d.title)
|
||||
docByName.set(norm(d.title), `doc-${d.docId}`)
|
||||
docByName.set(norm(d.originalName), `doc-${d.docId}`)
|
||||
}
|
||||
for (const s of schedules) add(`agent-${s.agent.id}`, 'schedules', s.agent.name.split(' · ').slice(1).join(' · ') || s.agent.name)
|
||||
const agents = new Set(schedules.map(s => `agent-${s.agent.id}`))
|
||||
|
||||
for (const m of materials) {
|
||||
const id = `${m.kind === 'video' ? 'video' : 'mat'}-${m.agentId}-${m.id}`
|
||||
add(id, isWorking(m) ? 'working' : 'made', m.planTitle ?? m.kind)
|
||||
if (agents.has(`agent-${m.agentId}`)) link(id, `agent-${m.agentId}`)
|
||||
for (const title of new Set(m.sources)) {
|
||||
const doc = docByName.get(norm(title))
|
||||
if (doc) link(id, doc)
|
||||
}
|
||||
}
|
||||
for (const s of sources) {
|
||||
const id = `found-${s.agentId}-${s.id}`
|
||||
add(id, 'research', s.title)
|
||||
if (agents.has(`agent-${s.agentId}`)) link(id, `agent-${s.agentId}`)
|
||||
if (s.documentId != null && docs.some(d => d.docId === s.documentId)) link(id, `doc-${s.documentId}`)
|
||||
}
|
||||
|
||||
layout(nodes, links)
|
||||
return { nodes, links, width: WIDTH, height: HEIGHT }
|
||||
}
|
||||
|
||||
// Deterministic per node, so a refetch with the same data does not reshuffle the picture.
|
||||
function seeded(id: string) {
|
||||
let h = 2166136261
|
||||
for (let i = 0; i < id.length; i++) h = Math.imul(h ^ id.charCodeAt(i), 16777619)
|
||||
return () => {
|
||||
h = Math.imul(h ^ (h >>> 15), 2246822507)
|
||||
h = Math.imul(h ^ (h >>> 13), 3266489909)
|
||||
return ((h ^= h >>> 16) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function layout(nodes: GraphNode[], links: GraphLink[], iterations = 260) {
|
||||
const index = new Map(nodes.map((n, i) => [n.id, i]))
|
||||
const vx = new Float64Array(nodes.length)
|
||||
const vy = new Float64Array(nodes.length)
|
||||
for (const n of nodes) {
|
||||
const rand = seeded(n.id)
|
||||
const [ax, ay] = ANCHOR[n.cluster]
|
||||
n.x = ax + (n.hub ? 0 : (rand() - 0.5) * 120)
|
||||
n.y = ay + (n.hub ? 0 : (rand() - 0.5) * 120)
|
||||
}
|
||||
const pairs = links.map(l => [index.get(l.source)!, index.get(l.target)!, l.hub] as const).filter(([a, b]) => a != null && b != null)
|
||||
|
||||
for (let it = 0; it < iterations; it++) {
|
||||
const alpha = 1 - it / iterations
|
||||
// Repulsion keeps nodes from piling up; only near neighbours matter.
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const dx = nodes[j].x - nodes[i].x
|
||||
const dy = nodes[j].y - nodes[i].y
|
||||
const d2 = dx * dx + dy * dy + 0.01
|
||||
if (d2 > 140 * 140) continue
|
||||
const f = 260 / d2
|
||||
vx[i] -= dx * f; vy[i] -= dy * f
|
||||
vx[j] += dx * f; vy[j] += dy * f
|
||||
}
|
||||
}
|
||||
// Springs: short to the cluster hub, longer across clusters.
|
||||
for (const [a, b, hub] of pairs) {
|
||||
const dx = nodes[b].x - nodes[a].x
|
||||
const dy = nodes[b].y - nodes[a].y
|
||||
const d = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
const f = ((d - (hub ? 46 : 110)) / d) * (hub ? 0.06 : 0.015)
|
||||
vx[a] += dx * f; vy[a] += dy * f
|
||||
vx[b] -= dx * f; vy[b] -= dy * f
|
||||
}
|
||||
nodes.forEach((n, i) => {
|
||||
const [ax, ay] = ANCHOR[n.cluster]
|
||||
const pull = n.hub ? 0.12 : 0.012
|
||||
vx[i] += (ax - n.x) * pull
|
||||
vy[i] += (ay - n.y) * pull
|
||||
n.x = Math.min(WIDTH - 20, Math.max(20, n.x + vx[i] * alpha))
|
||||
n.y = Math.min(HEIGHT - 20, Math.max(20, n.y + vy[i] * alpha))
|
||||
vx[i] *= 0.6; vy[i] *= 0.6
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user