production-dashboard #1
@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AuthGate } from './auth/AuthGate'
|
||||
import { AppLayout } from './components/AppLayout'
|
||||
import { BrainPage } from './pages/Brain'
|
||||
import { ProductionPage } from './pages/Production'
|
||||
import { InstructionPage } from './pages/Instruction'
|
||||
import { SchedulePage } from './pages/Schedule'
|
||||
import { ResultsPage } from './pages/Results'
|
||||
@@ -11,6 +12,7 @@ export default function App() {
|
||||
<AuthGate>
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/production" element={<ProductionPage />} />
|
||||
<Route path="/brain" element={<BrainPage />} />
|
||||
<Route path="/instruction" element={<InstructionPage />} />
|
||||
<Route path="/schedule" element={<SchedulePage />} />
|
||||
@@ -21,7 +23,7 @@ export default function App() {
|
||||
<Route path="/perintah" element={<Navigate replace to="/instruction" />} />
|
||||
<Route path="/jadwal" element={<Navigate replace to="/schedule" />} />
|
||||
<Route path="/hasil" element={<Navigate replace to="/results" />} />
|
||||
<Route path="*" element={<Navigate replace to="/brain" />} />
|
||||
<Route path="*" element={<Navigate replace to="/production" />} />
|
||||
</Routes>
|
||||
</AuthGate>
|
||||
)
|
||||
|
||||
@@ -7,13 +7,21 @@ import { workspaceCredits } from '../api/engine'
|
||||
import { Brand, Icon, type IconName } from './Icon'
|
||||
import { Toaster } from './Toast'
|
||||
|
||||
// Express Mode has four doors: feed the Brain, give an instruction, watch the schedule, collect results.
|
||||
export const NAV: { to: string; label: Key; icon: IconName }[] = [
|
||||
{ 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' },
|
||||
// The Production dashboard shows what Express is making; the four settings doors run it:
|
||||
// feed the Brain, give an instruction, watch the schedule, collect results.
|
||||
export const NAV_GROUPS: { label: Key; items: { to: string; label: Key; icon: IconName }[] }[] = [
|
||||
{ label: 'nav.dashboard', items: [{ to: '/production', label: 'nav.production', icon: 'chart' }] },
|
||||
{
|
||||
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() {
|
||||
const lang = useLang()
|
||||
@@ -129,15 +137,17 @@ export function AppLayout() {
|
||||
return (
|
||||
<div className="app">
|
||||
<aside className="sidebar">
|
||||
<NavLink to="/brain"><Brand /></NavLink>
|
||||
<NavLink to="/production"><Brand /></NavLink>
|
||||
<OrgSwitcher />
|
||||
<div className="nav-label">{t('nav.workspace')}</div>
|
||||
<nav aria-label={t('layout.mainNav')}>
|
||||
{NAV.map(n => (
|
||||
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : undefined)}>
|
||||
<Icon name={n.icon} />{t(n.label)}
|
||||
</NavLink>
|
||||
))}
|
||||
{NAV_GROUPS.map(g => [
|
||||
<div key={g.label} className="nav-label">{t(g.label)}</div>,
|
||||
...g.items.map(n => (
|
||||
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : undefined)}>
|
||||
<Icon name={n.icon} />{t(n.label)}
|
||||
</NavLink>
|
||||
)),
|
||||
])}
|
||||
</nav>
|
||||
<div className="sidebar-help">
|
||||
<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"/>',
|
||||
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"/>',
|
||||
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"/>',
|
||||
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.schedule': 'Schedule',
|
||||
'nav.results': 'Results',
|
||||
'nav.workspace': 'WORKSPACE',
|
||||
'nav.dashboard': 'Dashboard',
|
||||
'nav.settings': 'Settings',
|
||||
'nav.production': 'Production',
|
||||
'layout.learningSpace': 'Learning space',
|
||||
'layout.switchOrg': 'Switch organization',
|
||||
'layout.breadcrumb': 'Workspace',
|
||||
@@ -44,6 +46,39 @@ const en = {
|
||||
'common.retry': 'Try again',
|
||||
'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.title': 'Brain',
|
||||
'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
|
||||
|
||||
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.instruction': 'Perintah',
|
||||
'nav.schedule': 'Jadwal',
|
||||
'nav.results': 'Hasil',
|
||||
'nav.workspace': 'RUANG KERJA',
|
||||
'nav.dashboard': 'Dasbor',
|
||||
'nav.settings': 'Pengaturan',
|
||||
'nav.production': 'Produksi',
|
||||
'layout.learningSpace': 'Ruang belajar',
|
||||
'layout.switchOrg': 'Ganti organisasi',
|
||||
'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}
|
||||
@keyframes page-in{from{opacity:0;transform:translateY(6px)}}
|
||||
@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