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:
@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
|
|||||||
import { AuthGate } from './auth/AuthGate'
|
import { AuthGate } from './auth/AuthGate'
|
||||||
import { AppLayout } from './components/AppLayout'
|
import { AppLayout } from './components/AppLayout'
|
||||||
import { BrainPage } from './pages/Brain'
|
import { BrainPage } from './pages/Brain'
|
||||||
|
import { ProductionPage } from './pages/Production'
|
||||||
import { InstructionPage } from './pages/Instruction'
|
import { InstructionPage } from './pages/Instruction'
|
||||||
import { SchedulePage } from './pages/Schedule'
|
import { SchedulePage } from './pages/Schedule'
|
||||||
import { ResultsPage } from './pages/Results'
|
import { ResultsPage } from './pages/Results'
|
||||||
@@ -11,6 +12,7 @@ export default function App() {
|
|||||||
<AuthGate>
|
<AuthGate>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppLayout />}>
|
<Route element={<AppLayout />}>
|
||||||
|
<Route path="/production" element={<ProductionPage />} />
|
||||||
<Route path="/brain" element={<BrainPage />} />
|
<Route path="/brain" element={<BrainPage />} />
|
||||||
<Route path="/instruction" element={<InstructionPage />} />
|
<Route path="/instruction" element={<InstructionPage />} />
|
||||||
<Route path="/schedule" element={<SchedulePage />} />
|
<Route path="/schedule" element={<SchedulePage />} />
|
||||||
@@ -21,7 +23,7 @@ export default function App() {
|
|||||||
<Route path="/perintah" element={<Navigate replace to="/instruction" />} />
|
<Route path="/perintah" element={<Navigate replace to="/instruction" />} />
|
||||||
<Route path="/jadwal" element={<Navigate replace to="/schedule" />} />
|
<Route path="/jadwal" element={<Navigate replace to="/schedule" />} />
|
||||||
<Route path="/hasil" element={<Navigate replace to="/results" />} />
|
<Route path="/hasil" element={<Navigate replace to="/results" />} />
|
||||||
<Route path="*" element={<Navigate replace to="/brain" />} />
|
<Route path="*" element={<Navigate replace to="/production" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthGate>
|
</AuthGate>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,13 +7,21 @@ import { workspaceCredits } from '../api/engine'
|
|||||||
import { Brand, Icon, type IconName } from './Icon'
|
import { Brand, Icon, type IconName } from './Icon'
|
||||||
import { Toaster } from './Toast'
|
import { Toaster } from './Toast'
|
||||||
|
|
||||||
// Express Mode has four doors: feed the Brain, give an instruction, watch the schedule, collect results.
|
// The Production dashboard shows what Express is making; the four settings doors run it:
|
||||||
export const NAV: { to: string; label: Key; icon: IconName }[] = [
|
// feed the Brain, give an instruction, watch the schedule, collect results.
|
||||||
{ to: '/brain', label: 'nav.brain', icon: 'brain' },
|
export const NAV_GROUPS: { label: Key; items: { to: string; label: Key; icon: IconName }[] }[] = [
|
||||||
{ to: '/instruction', label: 'nav.instruction', icon: 'spark' },
|
{ label: 'nav.dashboard', items: [{ to: '/production', label: 'nav.production', icon: 'chart' }] },
|
||||||
{ to: '/schedule', label: 'nav.schedule', icon: 'calendar' },
|
{
|
||||||
{ to: '/results', label: 'nav.results', icon: 'layers' },
|
label: 'nav.settings',
|
||||||
|
items: [
|
||||||
|
{ to: '/brain', label: 'nav.brain', icon: 'brain' },
|
||||||
|
{ to: '/instruction', label: 'nav.instruction', icon: 'spark' },
|
||||||
|
{ to: '/schedule', label: 'nav.schedule', icon: 'calendar' },
|
||||||
|
{ to: '/results', label: 'nav.results', icon: 'layers' },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
export const NAV = NAV_GROUPS.flatMap(g => g.items)
|
||||||
|
|
||||||
export function LanguageSwitch() {
|
export function LanguageSwitch() {
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
@@ -129,15 +137,17 @@ export function AppLayout() {
|
|||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<NavLink to="/brain"><Brand /></NavLink>
|
<NavLink to="/production"><Brand /></NavLink>
|
||||||
<OrgSwitcher />
|
<OrgSwitcher />
|
||||||
<div className="nav-label">{t('nav.workspace')}</div>
|
|
||||||
<nav aria-label={t('layout.mainNav')}>
|
<nav aria-label={t('layout.mainNav')}>
|
||||||
{NAV.map(n => (
|
{NAV_GROUPS.map(g => [
|
||||||
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : undefined)}>
|
<div key={g.label} className="nav-label">{t(g.label)}</div>,
|
||||||
<Icon name={n.icon} />{t(n.label)}
|
...g.items.map(n => (
|
||||||
</NavLink>
|
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : undefined)}>
|
||||||
))}
|
<Icon name={n.icon} />{t(n.label)}
|
||||||
|
</NavLink>
|
||||||
|
)),
|
||||||
|
])}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="sidebar-help">
|
<div className="sidebar-help">
|
||||||
<Icon name="info" />
|
<Icon name="info" />
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const paths: Record<string, string> = {
|
|||||||
video: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="m10 9 5 3-5 3z"/>',
|
video: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="m10 9 5 3-5 3z"/>',
|
||||||
audio: '<path d="M4 13v-2a8 8 0 0 1 16 0v2"/><rect x="3" y="11" width="4" height="8" rx="2"/><rect x="17" y="11" width="4" height="8" rx="2"/>',
|
audio: '<path d="M4 13v-2a8 8 0 0 1 16 0v2"/><rect x="3" y="11" width="4" height="8" rx="2"/><rect x="17" y="11" width="4" height="8" rx="2"/>',
|
||||||
scorm: '<path d="m12 3 9 5-9 5-9-5zM3 8v9l9 5 9-5V8M12 13v9"/>',
|
scorm: '<path d="m12 3 9 5-9 5-9-5zM3 8v9l9 5 9-5V8M12 13v9"/>',
|
||||||
|
chart: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>',
|
||||||
coin: '<circle cx="12" cy="12" r="9"/><path d="M15 9.5c-.5-1-1.6-1.5-3-1.5-1.7 0-3 .8-3 2s1.3 1.7 3 2 3 .8 3 2-1.3 2-3 2c-1.4 0-2.5-.5-3-1.5M12 6v2M12 16v2"/>',
|
coin: '<circle cx="12" cy="12" r="9"/><path d="M15 9.5c-.5-1-1.6-1.5-3-1.5-1.7 0-3 .8-3 2s1.3 1.7 3 2 3 .8 3 2-1.3 2-3 2c-1.4 0-2.5-.5-3-1.5M12 6v2M12 16v2"/>',
|
||||||
brain: '<path d="M12 5c-3-4-8-1-7 3-4 2-3 7 0 8-1 4 5 6 7 2M12 5c3-4 8-1 7 3 4 2 3 7 0 8 1 4-5 6-7 2M12 5v15M7 9l3 2M17 9l-3 2M6 15l3-1M18 15l-3-1"/>',
|
brain: '<path d="M12 5c-3-4-8-1-7 3-4 2-3 7 0 8-1 4 5 6 7 2M12 5c3-4 8-1 7 3 4 2 3 7 0 8 1 4-5 6-7 2M12 5v15M7 9l3 2M17 9l-3 2M6 15l3-1M18 15l-3-1"/>',
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/i18n.ts
72
src/i18n.ts
@@ -11,7 +11,9 @@ const en = {
|
|||||||
'nav.instruction': 'Instruction',
|
'nav.instruction': 'Instruction',
|
||||||
'nav.schedule': 'Schedule',
|
'nav.schedule': 'Schedule',
|
||||||
'nav.results': 'Results',
|
'nav.results': 'Results',
|
||||||
'nav.workspace': 'WORKSPACE',
|
'nav.dashboard': 'Dashboard',
|
||||||
|
'nav.settings': 'Settings',
|
||||||
|
'nav.production': 'Production',
|
||||||
'layout.learningSpace': 'Learning space',
|
'layout.learningSpace': 'Learning space',
|
||||||
'layout.switchOrg': 'Switch organization',
|
'layout.switchOrg': 'Switch organization',
|
||||||
'layout.breadcrumb': 'Workspace',
|
'layout.breadcrumb': 'Workspace',
|
||||||
@@ -44,6 +46,39 @@ const en = {
|
|||||||
'common.retry': 'Try again',
|
'common.retry': 'Try again',
|
||||||
'common.credits': '{n} credits',
|
'common.credits': '{n} credits',
|
||||||
|
|
||||||
|
// Production
|
||||||
|
'prod.title': 'Production',
|
||||||
|
'prod.description': 'Everything Express is producing from the Brain: material, content made, what is live, and what is being made right now.',
|
||||||
|
'prod.metricWorking': 'Content in progress',
|
||||||
|
'prod.metricWorkingSub': 'being researched or generated now',
|
||||||
|
'prod.metricMaterial': 'Material',
|
||||||
|
'prod.metricMaterialSub': '{n} in the Brain',
|
||||||
|
'prod.metricMade': 'Content made',
|
||||||
|
'prod.metricMadeSub': 'results the AI has produced',
|
||||||
|
'prod.metricLive': 'In production',
|
||||||
|
'prod.metricLiveSub': 'approved for use',
|
||||||
|
'prod.metricLms': 'On the LMS',
|
||||||
|
'prod.metricLmsSub': 'approved SCORM modules',
|
||||||
|
'prod.graphTitle': 'Knowledge graph',
|
||||||
|
'prod.clickNode': 'Click a node',
|
||||||
|
'prod.loadingGraph': 'Drawing the knowledge graph…',
|
||||||
|
'prod.graphLabel': 'Knowledge graph of {nodes} nodes',
|
||||||
|
'prod.hub.sources': 'Sources',
|
||||||
|
'prod.hub.made': 'Made',
|
||||||
|
'prod.hub.working': 'In progress',
|
||||||
|
'prod.hub.research': 'Research',
|
||||||
|
'prod.hub.schedules': 'Repeats',
|
||||||
|
'prod.nodes': 'Nodes',
|
||||||
|
'prod.nodesSub': 'in this Brain',
|
||||||
|
'prod.links': 'Links',
|
||||||
|
'prod.linksSub': 'connections between nodes',
|
||||||
|
'prod.scheduleTitle': 'Schedule',
|
||||||
|
'prod.noSchedules': 'No schedules yet.',
|
||||||
|
'prod.giveInstruction': 'Give an instruction',
|
||||||
|
'prod.nowTitle': 'Happening now',
|
||||||
|
'prod.nothingNow': 'Nothing is being made right now.',
|
||||||
|
'prod.more': '+{n} more in Results',
|
||||||
|
|
||||||
// Brain
|
// Brain
|
||||||
'brain.title': 'Brain',
|
'brain.title': 'Brain',
|
||||||
'brain.description': 'All team material is processed into one Brain that keeps growing. New content the AI creates goes in here too.',
|
'brain.description': 'All team material is processed into one Brain that keeps growing. New content the AI creates goes in here too.',
|
||||||
@@ -390,11 +425,44 @@ const en = {
|
|||||||
export type Key = keyof typeof en
|
export type Key = keyof typeof en
|
||||||
|
|
||||||
const id: Record<Key, string> = {
|
const id: Record<Key, string> = {
|
||||||
|
'prod.title': 'Produksi',
|
||||||
|
'prod.description': 'Semua yang diproduksi Express dari Otak: materi, konten yang dibuat, yang sudah tayang, dan yang sedang dibuat saat ini.',
|
||||||
|
'prod.metricWorking': 'Konten sedang dibuat',
|
||||||
|
'prod.metricWorkingSub': 'sedang diriset atau dibuat sekarang',
|
||||||
|
'prod.metricMaterial': 'Materi',
|
||||||
|
'prod.metricMaterialSub': '{n} sudah di Otak',
|
||||||
|
'prod.metricMade': 'Konten dibuat',
|
||||||
|
'prod.metricMadeSub': 'hasil yang sudah dibuat AI',
|
||||||
|
'prod.metricLive': 'Sudah produksi',
|
||||||
|
'prod.metricLiveSub': 'disetujui untuk dipakai',
|
||||||
|
'prod.metricLms': 'Sudah di LMS',
|
||||||
|
'prod.metricLmsSub': 'modul SCORM yang disetujui',
|
||||||
|
'prod.graphTitle': 'Knowledge graph',
|
||||||
|
'prod.clickNode': 'Klik sebuah node',
|
||||||
|
'prod.loadingGraph': 'Menggambar knowledge graph…',
|
||||||
|
'prod.graphLabel': 'Knowledge graph berisi {nodes} node',
|
||||||
|
'prod.hub.sources': 'Sumber',
|
||||||
|
'prod.hub.made': 'Dibuat',
|
||||||
|
'prod.hub.working': 'Sedang dibuat',
|
||||||
|
'prod.hub.research': 'Riset',
|
||||||
|
'prod.hub.schedules': 'Berulang',
|
||||||
|
'prod.nodes': 'Node',
|
||||||
|
'prod.nodesSub': 'di Otak ini',
|
||||||
|
'prod.links': 'Tautan',
|
||||||
|
'prod.linksSub': 'koneksi antar node',
|
||||||
|
'prod.scheduleTitle': 'Jadwal',
|
||||||
|
'prod.noSchedules': 'Belum ada jadwal.',
|
||||||
|
'prod.giveInstruction': 'Beri perintah',
|
||||||
|
'prod.nowTitle': 'Sedang berjalan',
|
||||||
|
'prod.nothingNow': 'Tidak ada yang sedang dibuat.',
|
||||||
|
'prod.more': '+{n} lagi di Hasil',
|
||||||
'nav.brain': 'Otak',
|
'nav.brain': 'Otak',
|
||||||
'nav.instruction': 'Perintah',
|
'nav.instruction': 'Perintah',
|
||||||
'nav.schedule': 'Jadwal',
|
'nav.schedule': 'Jadwal',
|
||||||
'nav.results': 'Hasil',
|
'nav.results': 'Hasil',
|
||||||
'nav.workspace': 'RUANG KERJA',
|
'nav.dashboard': 'Dasbor',
|
||||||
|
'nav.settings': 'Pengaturan',
|
||||||
|
'nav.production': 'Produksi',
|
||||||
'layout.learningSpace': 'Ruang belajar',
|
'layout.learningSpace': 'Ruang belajar',
|
||||||
'layout.switchOrg': 'Ganti organisasi',
|
'layout.switchOrg': 'Ganti organisasi',
|
||||||
'layout.breadcrumb': 'Ruang kerja',
|
'layout.breadcrumb': 'Ruang kerja',
|
||||||
|
|||||||
244
src/pages/Production.tsx
Normal file
244
src/pages/Production.tsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { listDocuments } from '../api/engine'
|
||||||
|
import { Icon } from '../components/Icon'
|
||||||
|
import { PageHead, Skeleton } from '../components/Page'
|
||||||
|
import { buildGraph, CLUSTERS, type Cluster, type Graph, type GraphNode } from '../express/graph'
|
||||||
|
import { describeRule } from '../express/parse'
|
||||||
|
import { isWorking, listResults, titleOf } from '../express/results'
|
||||||
|
import { listSchedules } from '../express/schedules'
|
||||||
|
import { locale, t, type Key } from '../i18n'
|
||||||
|
|
||||||
|
const HUB_LABEL: Record<Cluster, Key> = {
|
||||||
|
sources: 'prod.hub.sources', made: 'prod.hub.made', working: 'prod.hub.working', research: 'prod.hub.research', schedules: 'prod.hub.schedules',
|
||||||
|
}
|
||||||
|
|
||||||
|
const when = (iso: string) => new Intl.DateTimeFormat(locale(), { weekday: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
|
||||||
|
|
||||||
|
// The dashboard: what is in the Brain, what the AI made from it, what is still being made, and the
|
||||||
|
// knowledge graph that ties them together. Shares its queries with the Brain, Schedule and Results pages.
|
||||||
|
export function ProductionPage() {
|
||||||
|
const docs = useQuery({ queryKey: ['documents'], queryFn: listDocuments, refetchInterval: 20_000 })
|
||||||
|
const results = useQuery({
|
||||||
|
queryKey: ['results'],
|
||||||
|
queryFn: listResults,
|
||||||
|
refetchInterval: q => (q.state.data?.materials.some(isWorking) ? 10_000 : 60_000),
|
||||||
|
})
|
||||||
|
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
|
||||||
|
|
||||||
|
const materials = results.data?.materials ?? []
|
||||||
|
const working = materials.filter(isWorking)
|
||||||
|
const made = materials.filter(m => !isWorking(m) && m.status !== 'failed')
|
||||||
|
const approved = materials.filter(m => m.status === 'accepted')
|
||||||
|
// No LMS is connected yet: an approved SCORM module is the package that goes into one.
|
||||||
|
const lms = approved.filter(m => m.kind === 'scorm')
|
||||||
|
const inBrain = docs.data?.filter(d => d.knowledge === 'ready').length ?? 0
|
||||||
|
const graph = useMemo(
|
||||||
|
() => (docs.data && results.data && schedules.data
|
||||||
|
? buildGraph({ docs: docs.data, materials: results.data.materials, sources: results.data.sources, schedules: schedules.data }, c => t(HUB_LABEL[c]))
|
||||||
|
: null),
|
||||||
|
[docs.data, results.data, schedules.data],
|
||||||
|
)
|
||||||
|
|
||||||
|
const num = (n: number, ready: boolean) => (ready ? <strong>{n.toLocaleString(locale())}</strong> : <Skeleton className="skel-num" />)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHead title={t('prod.title')} description={t('prod.description')} />
|
||||||
|
<div className="metrics prod-metrics">
|
||||||
|
<div className="metric">
|
||||||
|
<span><span className="live-dot" />{t('prod.metricWorking')}</span>{num(working.length, !!results.data)}<small>{t('prod.metricWorkingSub')}</small>
|
||||||
|
</div>
|
||||||
|
<div className="metric"><span>{t('prod.metricMaterial')}</span>{num(docs.data?.length ?? 0, !!docs.data)}<small>{t('prod.metricMaterialSub', { n: inBrain })}</small></div>
|
||||||
|
<div className="metric"><span>{t('prod.metricMade')}</span>{num(made.length, !!results.data)}<small>{t('prod.metricMadeSub')}</small></div>
|
||||||
|
<div className="metric"><span>{t('prod.metricLive')}</span>{num(approved.length, !!results.data)}<small>{t('prod.metricLiveSub')}</small></div>
|
||||||
|
<div className="metric"><span>{t('prod.metricLms')}</span>{num(lms.length, !!results.data)}<small>{t('prod.metricLmsSub')}</small></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="graph-stage" aria-label={t('prod.graphTitle')}>
|
||||||
|
{graph
|
||||||
|
? <KnowledgeGraph graph={graph} />
|
||||||
|
: <div className="graph-loading" role="status">{docs.isError || results.isError ? t('brain.engineDown') : t('prod.loadingGraph')}</div>}
|
||||||
|
|
||||||
|
<aside className="graph-panel">
|
||||||
|
<div className="graph-stats">
|
||||||
|
<div><small>{t('prod.nodes')}</small>{graph ? <b>{graph.nodes.filter(n => !n.hub).length}</b> : <b>-</b>}<span>{t('prod.nodesSub')}</span></div>
|
||||||
|
<div><small>{t('prod.links')}</small>{graph ? <b>{graph.links.filter(l => !l.hub).length}</b> : <b>-</b>}<span>{t('prod.linksSub')}</span></div>
|
||||||
|
</div>
|
||||||
|
<h3>{t('prod.scheduleTitle')}</h3>
|
||||||
|
{schedules.data?.length === 0 && <p className="graph-empty">{t('prod.noSchedules')} <Link to="/instruction">{t('prod.giveInstruction')}</Link></p>}
|
||||||
|
<ul className="graph-list">
|
||||||
|
{schedules.data?.slice(0, 4).map(s => (
|
||||||
|
<li key={s.agent.id}>
|
||||||
|
<time>{s.active && s.agent.next_run_at ? when(s.agent.next_run_at) : '-'}</time>
|
||||||
|
<span>{describeRule(s.rule)}</span>
|
||||||
|
<em className={s.active ? 'on' : undefined}>{t(s.active ? 'sch.active' : 'sch.paused')}</em>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<h3>{t('prod.nowTitle')}</h3>
|
||||||
|
{results.data && working.length === 0 && <p className="graph-empty">{t('prod.nothingNow')}</p>}
|
||||||
|
<ul className="graph-list now">
|
||||||
|
{working.slice(0, 6).map(m => (
|
||||||
|
<li key={`${m.kind}-${m.agentId}-${m.id}`}>
|
||||||
|
<i className="node-dot working" />
|
||||||
|
<span>{titleOf(m)}</span>
|
||||||
|
<em>{t(`res.state.${m.status as 'preparing' | 'generating'}`)}</em>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{working.length > 6 && <Link className="graph-more" to="/results">{t('prod.more', { n: working.length - 6 })}</Link>}
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Motion: nodes grow out of their hub on first draw, then drift gently around their place while
|
||||||
|
// light travels along the links between clusters. Positions are written straight to the DOM each
|
||||||
|
// frame, so hovering or selecting (React state) never fights the animation.
|
||||||
|
const DRIFT = 5
|
||||||
|
const MAX_PARTICLES = 80
|
||||||
|
const ease = (x: number) => 1 - Math.pow(1 - Math.min(1, Math.max(0, x)), 3)
|
||||||
|
|
||||||
|
function curve(ax: number, ay: number, bx: number, by: number, bend: number) {
|
||||||
|
const mx = (ax + bx) / 2, my = (ay + by) / 2
|
||||||
|
return { cx: mx - (by - ay) * bend, cy: my + (bx - ax) * bend }
|
||||||
|
}
|
||||||
|
|
||||||
|
function KnowledgeGraph({ graph }: { graph: Graph }) {
|
||||||
|
const [selected, setSelected] = useState<GraphNode | null>(null)
|
||||||
|
const [hover, setHover] = useState<string | null>(null)
|
||||||
|
const nodeEls = useRef<(SVGGElement | null)[]>([])
|
||||||
|
const linkEls = useRef<(SVGPathElement | null)[]>([])
|
||||||
|
const particleEls = useRef<(SVGCircleElement | null)[]>([])
|
||||||
|
const index = useMemo(() => new Map(graph.nodes.map((n, i) => [n.id, i])), [graph])
|
||||||
|
const pairs = useMemo(() => graph.links.map((l, i) => ({
|
||||||
|
a: index.get(l.source)!, b: index.get(l.target)!, hub: l.hub, bend: l.hub ? 0.08 * (i % 2 ? 1 : -1) : 0.18 * (i % 2 ? 1 : -1),
|
||||||
|
})), [graph, index])
|
||||||
|
const particles = useMemo(() => pairs.map((p, i) => ({ ...p, link: i })).filter(p => !p.hub).slice(0, MAX_PARTICLES), [pairs])
|
||||||
|
const degree = useMemo(() => {
|
||||||
|
const d = new Map<string, number>()
|
||||||
|
for (const l of graph.links) if (!l.hub) { d.set(l.source, (d.get(l.source) ?? 0) + 1); d.set(l.target, (d.get(l.target) ?? 0) + 1) }
|
||||||
|
return d
|
||||||
|
}, [graph])
|
||||||
|
const stars = useMemo(() => Array.from({ length: 70 }, (_, i) => {
|
||||||
|
const r = (k: number) => (Math.sin(i * 127.1 + k * 311.7) * 43758.5453) % 1
|
||||||
|
return { x: Math.abs(r(1)) * graph.width, y: Math.abs(r(2)) * graph.height, s: 0.4 + Math.abs(r(3)) * 1.1, d: Math.abs(r(4)) * 6 }
|
||||||
|
}), [graph])
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
const hubOf = graph.nodes.map(n => index.get(`hub-${n.cluster}`)!)
|
||||||
|
const phase = graph.nodes.map((_, i) => i * 2.399)
|
||||||
|
const x = new Float64Array(graph.nodes.length)
|
||||||
|
const y = new Float64Array(graph.nodes.length)
|
||||||
|
const t0 = performance.now()
|
||||||
|
let frame = 0
|
||||||
|
const draw = (now: number) => {
|
||||||
|
const t = still ? 1e9 : now - t0
|
||||||
|
graph.nodes.forEach((n, i) => {
|
||||||
|
const hub = graph.nodes[hubOf[i]]
|
||||||
|
const grow = n.hub ? ease(t / 700) : ease((t - 250 - (i % 24) * 25) / 1100)
|
||||||
|
const drift = still ? 0 : (n.hub ? 0.4 : 1) * DRIFT
|
||||||
|
const k = t / 1000
|
||||||
|
x[i] = hub.x + (n.x - hub.x) * grow + Math.sin(k * 0.6 + phase[i]) * drift
|
||||||
|
y[i] = hub.y + (n.y - hub.y) * grow + Math.cos(k * 0.5 + phase[i] * 1.3) * drift
|
||||||
|
const el = nodeEls.current[i]
|
||||||
|
if (el) {
|
||||||
|
el.setAttribute('transform', `translate(${x[i].toFixed(1)} ${y[i].toFixed(1)})`)
|
||||||
|
el.style.opacity = n.hub ? '' : String(Math.min(1, grow * 1.4))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pairs.forEach((p, i) => {
|
||||||
|
const { cx, cy } = curve(x[p.a], y[p.a], x[p.b], y[p.b], p.bend)
|
||||||
|
linkEls.current[i]?.setAttribute('d', `M${x[p.a].toFixed(1)} ${y[p.a].toFixed(1)}Q${cx.toFixed(1)} ${cy.toFixed(1)} ${x[p.b].toFixed(1)} ${y[p.b].toFixed(1)}`)
|
||||||
|
})
|
||||||
|
particles.forEach((p, i) => {
|
||||||
|
const el = particleEls.current[i]
|
||||||
|
if (!el) return
|
||||||
|
if (still) { el.style.display = 'none'; return }
|
||||||
|
const u = ((t / 1000) * (0.18 + (i % 5) * 0.03) + i * 0.37) % 1
|
||||||
|
const { cx, cy } = curve(x[p.a], y[p.a], x[p.b], y[p.b], p.bend)
|
||||||
|
const v = 1 - u
|
||||||
|
el.setAttribute('cx', (v * v * x[p.a] + 2 * v * u * cx + u * u * x[p.b]).toFixed(1))
|
||||||
|
el.setAttribute('cy', (v * v * y[p.a] + 2 * v * u * cy + u * u * y[p.b]).toFixed(1))
|
||||||
|
el.style.opacity = String(Math.sin(u * Math.PI) * Math.min(1, t / 1600))
|
||||||
|
})
|
||||||
|
if (!still) frame = requestAnimationFrame(draw)
|
||||||
|
}
|
||||||
|
draw(t0)
|
||||||
|
if (!still) frame = requestAnimationFrame(draw)
|
||||||
|
return () => cancelAnimationFrame(frame)
|
||||||
|
}, [graph, index, pairs, particles])
|
||||||
|
|
||||||
|
const focus = hover ?? selected?.id ?? null
|
||||||
|
const neighbours = useMemo(() => {
|
||||||
|
if (!focus) return null
|
||||||
|
const set = new Set([focus])
|
||||||
|
for (const l of graph.links) {
|
||||||
|
if (l.source === focus) set.add(l.target)
|
||||||
|
if (l.target === focus) set.add(l.source)
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}, [focus, graph])
|
||||||
|
const lit = (p: { a: number; b: number }) => neighbours?.has(graph.nodes[p.a].id) && neighbours.has(graph.nodes[p.b].id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="graph-note">
|
||||||
|
<i className="live-dot" />
|
||||||
|
{selected
|
||||||
|
? <><b>{selected.label}</b>{!selected.hub && <span>{t(HUB_LABEL[selected.cluster])}</span>}<button type="button" aria-label={t('res.close')} onClick={() => setSelected(null)}><Icon name="close" /></button></>
|
||||||
|
: <><b>{t('prod.graphTitle')}</b><span>{t('prod.clickNode')}</span></>}
|
||||||
|
</div>
|
||||||
|
<svg className={`graph-svg${neighbours ? ' focused' : ''}`} viewBox={`0 0 ${graph.width} ${graph.height}`} role="img"
|
||||||
|
aria-label={t('prod.graphLabel', { nodes: graph.nodes.length - CLUSTERS.length })} onClick={() => setSelected(null)}>
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="graph-glow"><stop offset="0" stopColor="#2e90fa" stopOpacity=".32" /><stop offset=".5" stopColor="#6941c6" stopOpacity=".08" /><stop offset="1" stopColor="#2e90fa" stopOpacity="0" /></radialGradient>
|
||||||
|
<radialGradient id="graph-core"><stop offset="0" stopColor="#fff" /><stop offset=".55" stopColor="#fff" stopOpacity=".9" /><stop offset="1" stopColor="#fff" stopOpacity=".2" /></radialGradient>
|
||||||
|
<filter id="graph-bloom" x="-200%" y="-200%" width="500%" height="500%"><feGaussianBlur stdDeviation="3" result="b" /><feMerge><feMergeNode in="b" /><feMergeNode in="SourceGraphic" /></feMerge></filter>
|
||||||
|
</defs>
|
||||||
|
<g className="graph-stars">
|
||||||
|
{stars.map((s, i) => <circle key={i} cx={s.x} cy={s.y} r={s.s} style={{ animationDelay: `${s.d}s` }} />)}
|
||||||
|
</g>
|
||||||
|
<circle className="graph-glow" cx={500} cy={320} r={320} fill="url(#graph-glow)" />
|
||||||
|
<ellipse className="graph-orbit slow" cx={500} cy={320} rx={430} ry={275} />
|
||||||
|
<ellipse className="graph-orbit" cx={500} cy={320} rx={300} ry={195} />
|
||||||
|
<g className="graph-links">
|
||||||
|
{pairs.map((p, i) => (
|
||||||
|
<path key={i} ref={el => { linkEls.current[i] = el }} style={{ ['--c' as string]: `var(--g-${graph.nodes[p.a].cluster})` }}
|
||||||
|
className={`${p.hub ? 'hub' : 'cross'}${lit(p) ? ' lit' : neighbours ? ' dim' : ''}`} />
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
<g className="graph-particles">
|
||||||
|
{particles.map((p, i) => (
|
||||||
|
<circle key={i} r={1.8} ref={el => { particleEls.current[i] = el }} className={`c-${graph.nodes[p.a].cluster}${neighbours && !lit(p) ? ' dim' : ''}`} />
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
{graph.nodes.map((n, i) => {
|
||||||
|
const r = n.hub ? 9 : n.cluster === 'schedules' ? 5.5 : 3.2 + Math.min(3, (degree.get(n.id) ?? 0) * 0.7)
|
||||||
|
return (
|
||||||
|
<g key={n.id} ref={el => { nodeEls.current[i] = el }}
|
||||||
|
className={`graph-node c-${n.cluster}${n.hub ? ' hub' : ''}${neighbours && !neighbours.has(n.id) ? ' dim' : ''}${hover === n.id ? ' hover' : ''}${selected?.id === n.id ? ' sel' : ''}`}
|
||||||
|
onMouseEnter={() => setHover(n.id)} onMouseLeave={() => setHover(null)}
|
||||||
|
onClick={e => { e.stopPropagation(); setSelected(n) }}>
|
||||||
|
<circle className="hit" r={r + 8} />
|
||||||
|
{n.hub && <><circle className="ring" r={r + 4} /><circle className="ring late" r={r + 4} /><circle className="halo" r={26} /></>}
|
||||||
|
<circle className="core" r={r} filter="url(#graph-bloom)" />
|
||||||
|
{n.hub && <circle className="dot" r={r * 0.45} />}
|
||||||
|
<text y={-r - 8}>{n.label.length > 40 ? n.label.slice(0, 38) + '…' : n.label}</text>
|
||||||
|
<title>{n.label}</title>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<ul className="graph-legend">
|
||||||
|
{CLUSTERS.map(c => <li key={c}><i className={`node-dot ${c}`} />{t(HUB_LABEL[c])}</li>)}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -281,3 +281,99 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
|
|||||||
.app main{animation:page-in .28s ease-out}
|
.app main{animation:page-in .28s ease-out}
|
||||||
@keyframes page-in{from{opacity:0;transform:translateY(6px)}}
|
@keyframes page-in{from{opacity:0;transform:translateY(6px)}}
|
||||||
@media (prefers-reduced-motion:reduce){.skel,.app main{animation:none}}
|
@media (prefers-reduced-motion:reduce){.skel,.app main{animation:none}}
|
||||||
|
|
||||||
|
/* Sidebar groups: Dashboard, then Settings */
|
||||||
|
.sidebar nav .nav-label{margin:0 12px 6px}
|
||||||
|
.sidebar nav .nav-label~.nav-label{margin-top:18px}
|
||||||
|
@media(max-width:700px){.sidebar nav .nav-label{display:none}}
|
||||||
|
|
||||||
|
/* Production: metric row with the in-progress count as the hero, then the knowledge graph */
|
||||||
|
.prod-metrics{grid-template-columns:repeat(5,minmax(0,1fr))}
|
||||||
|
.prod-metrics .metric>span{display:flex;align-items:center;gap:8px}
|
||||||
|
.live-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#4ade80;box-shadow:0 0 0 0 rgba(74,222,128,.6);animation:live-pulse 1.8s ease-out infinite;flex-shrink:0}
|
||||||
|
@keyframes live-pulse{70%{box-shadow:0 0 0 8px rgba(74,222,128,0)}100%{box-shadow:0 0 0 0 rgba(74,222,128,0)}}
|
||||||
|
|
||||||
|
.graph-stage{--g-sources:#6ce9a6;--g-made:#fde272;--g-working:#c3b5fd;--g-research:#84caff;--g-schedules:#fdb022;
|
||||||
|
position:relative;min-height:640px;border-radius:22px;overflow:hidden;color:#e4e7ec;
|
||||||
|
background:radial-gradient(ellipse at 45% 50%,#0f2a4d 0%,#0a1628 55%,#060d1a 100%);box-shadow:0 24px 60px -28px rgba(16,24,40,.6)}
|
||||||
|
.graph-svg{position:absolute;inset:0;width:calc(100% - 300px);height:100%;display:block}
|
||||||
|
.graph-loading{position:absolute;inset:0 300px 0 0;display:grid;place-items:center;color:#98a2b3;font-size:13px}
|
||||||
|
.graph-stars circle{fill:#fff;opacity:.25;animation:twinkle 4s ease-in-out infinite}
|
||||||
|
@keyframes twinkle{50%{opacity:.04}}
|
||||||
|
.graph-glow{transform-origin:500px 320px;animation:glow-breathe 7s ease-in-out infinite}
|
||||||
|
@keyframes glow-breathe{50%{transform:scale(1.08);opacity:.75}}
|
||||||
|
.graph-orbit{fill:none;stroke:rgba(132,202,255,.12);stroke-dasharray:2 7;transform-origin:500px 320px;animation:orbit-spin 90s linear infinite}
|
||||||
|
.graph-orbit.slow{stroke:rgba(132,202,255,.07);animation-duration:160s;animation-direction:reverse}
|
||||||
|
@keyframes orbit-spin{to{transform:rotate(360deg)}}
|
||||||
|
.graph-links path{fill:none;transition:opacity .3s,stroke .3s,stroke-width .3s}
|
||||||
|
.graph-links path.hub{stroke:var(--c);stroke-opacity:.16;stroke-width:.8}
|
||||||
|
.graph-links path.cross{stroke:rgba(132,202,255,.22);stroke-width:.9;stroke-dasharray:4 5;animation:dash-flow 3s linear infinite}
|
||||||
|
@keyframes dash-flow{to{stroke-dashoffset:-18}}
|
||||||
|
.graph-links path.lit{stroke:var(--c);stroke-opacity:.9;stroke-width:1.6}
|
||||||
|
.graph-links path.dim{opacity:.12}
|
||||||
|
.graph-particles circle{fill:var(--c);filter:drop-shadow(0 0 3px var(--c));opacity:0;transition:opacity .3s}
|
||||||
|
.graph-particles circle.dim{visibility:hidden}
|
||||||
|
.graph-node{cursor:pointer;transition:opacity .3s}
|
||||||
|
.graph-node.dim{opacity:.2!important}
|
||||||
|
.graph-node .hit{fill:transparent}
|
||||||
|
.graph-node .core{fill:var(--c);transition:transform .25s cubic-bezier(.3,1.6,.5,1)}
|
||||||
|
.graph-node:is(.hover,.sel) .core{transform:scale(1.7)}
|
||||||
|
.graph-node.hub .core{fill:var(--c);opacity:.9}
|
||||||
|
.graph-node.hub:is(.hover,.sel) .core{transform:scale(1.25)}
|
||||||
|
.graph-node .dot{fill:url(#graph-core);pointer-events:none}
|
||||||
|
.graph-node .halo{fill:var(--c);opacity:.1;animation:halo-breathe 4s ease-in-out infinite}
|
||||||
|
@keyframes halo-breathe{50%{opacity:.2;transform:scale(1.15)}}
|
||||||
|
.graph-node .ring{fill:none;stroke:var(--c);stroke-width:1.2;opacity:0;animation:ring-out 3.2s ease-out infinite}
|
||||||
|
.graph-node .ring.late{animation-delay:1.6s}
|
||||||
|
@keyframes ring-out{0%{opacity:.7;transform:scale(1)}100%{opacity:0;transform:scale(3.2)}}
|
||||||
|
.graph-node :is(.core,.halo,.ring){transform-box:fill-box;transform-origin:center}
|
||||||
|
.graph-node text{fill:#fff;font-size:13px;font-weight:700;letter-spacing:.01em;text-anchor:middle;paint-order:stroke;stroke:#08142a;stroke-width:4px;stroke-linejoin:round;pointer-events:none}
|
||||||
|
.graph-node:not(.hub) text{font-size:11.5px;font-weight:600;opacity:0;transform:translateY(4px);transition:opacity .2s,transform .2s}
|
||||||
|
.graph-node:not(.hub):is(.hover,.sel) text{opacity:1;transform:none}
|
||||||
|
.graph-node.c-working:not(.hub) .core{animation:node-pulse 1.6s ease-in-out infinite}
|
||||||
|
@keyframes node-pulse{50%{opacity:.4}}
|
||||||
|
.c-sources{--c:var(--g-sources)}.c-made{--c:var(--g-made)}.c-working{--c:var(--g-working)}.c-research{--c:var(--g-research)}.c-schedules{--c:var(--g-schedules)}
|
||||||
|
|
||||||
|
.graph-note{position:absolute;top:16px;left:16px;z-index:2;display:flex;align-items:center;gap:8px;max-width:min(460px,calc(100% - 340px));
|
||||||
|
padding:8px 14px;border-radius:999px;background:rgba(10,22,40,.8);border:1px solid rgba(255,255,255,.12);font-size:12.5px;backdrop-filter:blur(8px)}
|
||||||
|
.graph-note b{color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.graph-note span{color:#98a2b3;white-space:nowrap}
|
||||||
|
.graph-note button{display:grid;place-items:center;border:0;background:none;color:#98a2b3;padding:0;cursor:pointer}
|
||||||
|
.graph-note svg{width:14px;height:14px}
|
||||||
|
.graph-legend{position:absolute;left:16px;bottom:16px;z-index:2;display:flex;flex-wrap:wrap;gap:6px 14px;list-style:none;margin:0;padding:0;font-size:11.5px;color:#98a2b3}
|
||||||
|
.graph-legend li{display:flex;align-items:center;gap:6px}
|
||||||
|
.node-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--c);box-shadow:0 0 6px var(--c);flex-shrink:0}
|
||||||
|
.node-dot.sources{--c:var(--g-sources)}.node-dot.made{--c:var(--g-made)}.node-dot.working{--c:var(--g-working)}.node-dot.research{--c:var(--g-research)}.node-dot.schedules{--c:var(--g-schedules)}
|
||||||
|
|
||||||
|
.graph-panel{position:absolute;top:16px;right:16px;bottom:16px;width:284px;z-index:2;overflow:auto;padding:14px;border-radius:18px;
|
||||||
|
background:rgba(10,22,40,.72);border:1px solid rgba(255,255,255,.1);backdrop-filter:blur(10px)}
|
||||||
|
.graph-panel h3{font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:#98a2b3;margin:18px 4px 8px;font-weight:650}
|
||||||
|
.graph-stats{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||||||
|
.graph-stats>div{padding:12px;border-radius:12px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08)}
|
||||||
|
.graph-stats small{display:block;font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;color:#98a2b3}
|
||||||
|
.graph-stats b{display:block;font-size:28px;font-weight:650;color:#fff;margin:6px 0 2px;font-variant-numeric:tabular-nums}
|
||||||
|
.graph-stats span{font-size:11px;color:#98a2b3}
|
||||||
|
.graph-list{list-style:none;margin:0;padding:0;display:grid}
|
||||||
|
.graph-list li{display:grid;grid-template-columns:auto 1fr auto;gap:4px 10px;align-items:start;padding:9px 4px;border-bottom:1px solid rgba(255,255,255,.06);font-size:12.5px;line-height:1.45}
|
||||||
|
.graph-list li:last-child{border-bottom:0}
|
||||||
|
.graph-list time{font:600 11px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;color:#84caff;white-space:nowrap}
|
||||||
|
.graph-list span{color:#e4e7ec;min-width:0;overflow-wrap:anywhere}
|
||||||
|
.graph-list em{font-style:normal;font-size:10.5px;color:#98a2b3;border:1px solid rgba(255,255,255,.14);border-radius:999px;padding:1px 8px;white-space:nowrap}
|
||||||
|
.graph-list em.on{color:#6ce9a6;border-color:rgba(108,233,166,.4)}
|
||||||
|
.graph-list.now li{align-items:center}
|
||||||
|
.graph-list.now em{border:0;padding:0}
|
||||||
|
.graph-empty{font-size:12.5px;color:#98a2b3;margin:0 4px}
|
||||||
|
.graph-empty a,.graph-more{color:#84caff}
|
||||||
|
.graph-more{display:block;font-size:12px;margin:8px 4px 0}
|
||||||
|
|
||||||
|
@media(max-width:1120px){.prod-metrics{grid-template-columns:repeat(4,minmax(0,1fr))}.prod-metrics .metric:first-child{grid-column:1/-1}}
|
||||||
|
@media(max-width:900px){
|
||||||
|
.graph-stage{min-height:0;display:flex;flex-direction:column}
|
||||||
|
.graph-svg{position:relative;width:100%;height:auto;aspect-ratio:1000/640}
|
||||||
|
.graph-loading{position:relative;inset:auto;min-height:280px}
|
||||||
|
.graph-note{position:relative;top:auto;left:auto;margin:16px 16px 0;max-width:none;align-self:flex-start}
|
||||||
|
.graph-legend{position:relative;left:auto;bottom:auto;margin:0 16px 12px}
|
||||||
|
.graph-panel{position:relative;inset:auto;width:auto;margin:0 16px 16px}
|
||||||
|
}
|
||||||
|
@media(max-width:700px){.prod-metrics{grid-template-columns:1fr 1fr}.prod-metrics .metric:first-child{grid-column:1/-1}}
|
||||||
|
@media (prefers-reduced-motion:reduce){.live-dot,.graph-stars circle,.graph-glow,.graph-orbit,.graph-links path,.graph-node *{animation:none!important}}
|
||||||
|
|||||||
Reference in New Issue
Block a user