前端工程化

This commit is contained in:
2026-08-13 01:52:42 +08:00
parent 75bb281d4e
commit 3e9b924776
79 changed files with 4760 additions and 3989 deletions
+4
View File
@@ -2,3 +2,7 @@
/cmake-build*/
/.playwright-cli/
/Kernel/Renderive_线程模型说明.md
/Kernel/Renderive_线程问题审计.md
/webapp_gallery/node_modules/
/webapp_gallery/dist/
-2338
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
+70 -11
View File
@@ -4,6 +4,76 @@ if (RENDERIVE_BUILD_TESTS)
endif ()
rcl_add_dependency_action_targets(Renderive_Web_env ${Renderive_Web_dependencies})
set_target_properties(Renderive_Web_env PROPERTIES FOLDER Renderive_Web)
if (CMAKE_CONFIGURATION_TYPES)
set(Renderive_Web_assets_dir "${CMAKE_CURRENT_BINARY_DIR}/$<CONFIG>/webapp_gallery")
else ()
set(Renderive_Web_assets_dir "${CMAKE_CURRENT_BINARY_DIR}/webapp_gallery")
endif ()
set(Renderive_Web_node_search_paths)
if (WIN32 AND DEFINED ENV{APPDATA})
file(GLOB Renderive_Web_clion_node_versions LIST_DIRECTORIES true
"$ENV{APPDATA}/JetBrains/CLion*/node/versions/*")
list(SORT Renderive_Web_clion_node_versions COMPARE NATURAL ORDER DESCENDING)
foreach (Renderive_Web_clion_node_version IN LISTS Renderive_Web_clion_node_versions)
if (EXISTS "${Renderive_Web_clion_node_version}/node.exe"
AND EXISTS "${Renderive_Web_clion_node_version}/npm.cmd")
list(APPEND Renderive_Web_node_search_paths "${Renderive_Web_clion_node_version}")
endif ()
endforeach ()
endif ()
find_program(Renderive_Web_node_executable NAMES node node.exe
HINTS ${Renderive_Web_node_search_paths} REQUIRED)
if (WIN32)
find_program(Renderive_Web_npm_executable NAMES npm.cmd
HINTS ${Renderive_Web_node_search_paths} REQUIRED)
else ()
find_program(Renderive_Web_npm_executable NAMES npm REQUIRED)
endif ()
set(Renderive_Web_frontend_dir "${CMAKE_CURRENT_LIST_DIR}/../webapp_gallery")
set(Renderive_Web_frontend_install_stamp "${CMAKE_CURRENT_BINARY_DIR}/webapp_gallery_npm_ci.stamp")
set(Renderive_Web_frontend_build_stamp "${CMAKE_CURRENT_BINARY_DIR}/webapp_gallery_vite_build.stamp")
file(GLOB_RECURSE Renderive_Web_frontend_sources CONFIGURE_DEPENDS
"${Renderive_Web_frontend_dir}/src/*"
)
set(Renderive_Web_frontend_configuration
"${Renderive_Web_frontend_dir}/package.json"
"${Renderive_Web_frontend_dir}/package-lock.json"
"${Renderive_Web_frontend_dir}/vite.config.ts"
"${Renderive_Web_frontend_dir}/tsconfig.json"
"${Renderive_Web_frontend_dir}/tsconfig.app.json"
"${Renderive_Web_frontend_dir}/index.html"
)
add_custom_command(
OUTPUT "${Renderive_Web_frontend_install_stamp}"
COMMAND "${Renderive_Web_npm_executable}" ci
COMMAND "${CMAKE_COMMAND}" -E touch "${Renderive_Web_frontend_install_stamp}"
DEPENDS
"${Renderive_Web_frontend_dir}/package.json"
"${Renderive_Web_frontend_dir}/package-lock.json"
WORKING_DIRECTORY "${Renderive_Web_frontend_dir}"
COMMENT "Installing Renderive Gallery frontend dependencies"
VERBATIM
)
add_custom_command(
OUTPUT "${Renderive_Web_frontend_build_stamp}"
COMMAND "${Renderive_Web_npm_executable}" run build
COMMAND "${CMAKE_COMMAND}" -E touch "${Renderive_Web_frontend_build_stamp}"
DEPENDS "${Renderive_Web_frontend_install_stamp}"
${Renderive_Web_frontend_configuration}
${Renderive_Web_frontend_sources}
WORKING_DIRECTORY "${Renderive_Web_frontend_dir}"
COMMENT "Building Renderive Gallery frontend"
VERBATIM
)
add_custom_target(Renderive_Web_Assets
COMMAND "${CMAKE_COMMAND}" -E remove_directory "${Renderive_Web_assets_dir}"
COMMAND "${CMAKE_COMMAND}" -E copy_directory
"${Renderive_Web_frontend_dir}/dist"
"${Renderive_Web_assets_dir}"
DEPENDS "${Renderive_Web_frontend_build_stamp}"
COMMENT "Synchronizing Renderive Gallery frontend assets"
VERBATIM
)
library_is_installed_with_rely(Renderive_Web_dependencies_installed ${Renderive_Web_dependencies})
if (NOT Renderive_Web_dependencies_installed)
rcl_log_append("[FATAL_ERROR] Renderive_Web dependencies are not installed. Build Renderive_Web_env first")
@@ -54,17 +124,6 @@ append_glob_source(Renderive_Web_Server_sources "${Renderive_Web_Server_source_d
add_executable(Renderive_Web_Server ${Renderive_Web_Server_sources})
target_compile_features(Renderive_Web_Server PRIVATE cxx_std_20)
target_link_libraries(Renderive_Web_Server PRIVATE Renderive_Web)
if (CMAKE_CONFIGURATION_TYPES)
set(Renderive_Web_assets_dir "${CMAKE_CURRENT_BINARY_DIR}/$<CONFIG>/webapp_gallery")
else ()
set(Renderive_Web_assets_dir "${CMAKE_CURRENT_BINARY_DIR}/webapp_gallery")
endif ()
add_custom_target(Renderive_Web_Assets
COMMAND "${CMAKE_COMMAND}" -E copy_directory
"${CMAKE_CURRENT_LIST_DIR}/../webapp_gallery"
"${Renderive_Web_assets_dir}"
COMMENT "Synchronizing Renderive Web assets"
)
add_dependencies(Renderive_Web_Server Renderive_Web_Assets)
if (MSVC)
target_compile_options(Renderive_Web PRIVATE /utf-8)
File diff suppressed because it is too large Load Diff
+4 -93
View File
@@ -3,101 +3,12 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Renderive Core2 全控件性能画廊</title>
<meta name="theme-color" content="#07111f">
<title>Renderive Core2 性能画廊</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">R2</span>
<div><p class="eyebrow">CORE2 · KERNEL · WEBSOCKET</p><h1>全控件 API 与帧策略性能画廊</h1></div>
</div>
<div class="topbar-actions">
<span id="connection-status" class="connection-status" data-state="connecting"><i></i><span>读取后端目录</span></span>
<button id="toggle-streams" class="quiet-button" type="button">暂停自动像素流</button>
</div>
</header>
<section class="hero">
<div>
<p id="hero-eyebrow" class="eyebrow"></p>
<h2 id="hero-title"></h2>
<p id="mode-description">所有属性、动作、观察者和性能数据均由后端通过 WebSocket 返回。</p>
</div>
<dl class="metrics">
<div><dt id="page-count"></dt><dd>帧策略页</dd></div>
<div><dt id="case-count"></dt><dd>每页控件</dd></div>
<div><dt id="canvas-count"></dt><dd>独立场景</dd></div>
<div><dt id="api-count"></dt><dd>手测入口</dd></div>
</dl>
</section>
<nav id="mode-tabs" class="mode-tabs" aria-label="Kernel 帧控制策略"></nav>
<nav id="category-filter" class="category-filter" aria-label="控件分类"></nav>
<main id="pages" class="pages" aria-live="polite">
<div class="loading-card"><span class="loader"></span><strong>等待后端返回控件与帧策略目录</strong></div>
</main>
<aside id="context-menu" class="context-menu" hidden aria-label="后端控件测试菜单">
<header class="menu-header">
<div><p id="menu-component" class="eyebrow"></p><h2 id="menu-title"></h2><p id="menu-description"></p></div>
<button id="menu-close" class="icon-button" type="button" aria-label="关闭">×</button>
</header>
<nav class="menu-tabs" aria-label="测试菜单页面">
<button type="button" data-tab="controls" class="active">控件属性</button>
<button type="button" data-tab="actions">专属 API</button>
<button type="button" data-tab="observer">内核观察器</button>
<button type="button" data-tab="performance">性能监测</button>
<button type="button" data-tab="performance_capture">Performance Capture</button>
<button type="button" data-tab="render_plan">Render DAG</button>
</nav>
<div class="menu-refresh-bar">
<button id="menu-reset" type="button" title="清空当前控件的性能监测窗口">&#8634; 重置监测</button>
<button id="menu-refresh" type="button" title="从后端读取当前页数据">&#8635; 刷新当前页</button>
</div>
<div id="menu-body" class="menu-body"></div>
<footer class="menu-footer"><span id="menu-status">字段、验证与 API 映射来自后端 JSON</span><code>WS /renderive/gallery</code></footer>
</aside>
<div id="toast" class="toast" hidden></div>
<template id="page-template">
<section class="mode-page" hidden>
<header class="page-header"><div><p class="page-strategy"></p><h2 class="page-title"></h2></div><p class="page-description"></p></header>
<div class="gallery"></div>
</section>
</template>
<template id="card-template">
<article class="plot-card">
<header class="card-header">
<div><span class="card-category"></span><h3 class="card-title"></h3></div>
<div class="card-health">
<span class="motion-status" data-state="waiting"><i></i><span>等待动态帧</span></span>
<span class="card-socket" data-state="connecting"><i></i><span>连接中</span></span>
</div>
</header>
<div class="canvas-shell" tabindex="0">
<canvas aria-label="Core2 后端像素画布"></canvas>
<div class="canvas-hint">右键:本控件全部 API</div>
<div class="canvas-loading"><span class="loader"></span><small>创建 Kernel Scene</small></div>
</div>
<dl class="performance-strip"></dl>
<div class="limit-flags"></div>
<section class="kernel-observer-panel"></section>
<div class="card-meta">
<p class="card-description"></p>
<dl><div><dt class="card-controls"></dt><dd>属性</dd></div><div><dt class="card-actions"></dt><dd>动作</dd></div><div><dt class="card-frames">0</dt><dd>像素帧</dd></div></dl>
</div>
<footer class="card-footer">
<code class="card-component"></code>
<div><button type="button" class="frame-button">刷新一帧</button><button type="button" class="open-menu">测试菜单</button></div>
</footer>
</article>
</template>
<script src="app.js"></script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4165
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "renderive-gallery",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^6.4.8",
"@mui/material": "^6.4.8",
"@xyflow/react": "^12.4.4",
"elkjs": "^0.9.3",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@playwright/test": "^1.51.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^26.0.0",
"typescript": "^5.7.3",
"vite": "^6.1.0",
"vitest": "^3.0.8"
}
}
+2
View File
@@ -0,0 +1,2 @@
import {defineConfig,devices} from "@playwright/test";
export default defineConfig({testDir:"./tests/e2e",use:{baseURL:"http://127.0.0.1:4173",trace:"retain-on-failure"},webServer:{command:"npm run dev -- --port 4173",url:"http://127.0.0.1:4173",reuseExistingServer:true},projects:[{name:"chromium",use:{...devices["Desktop Chrome"]}}]});
+17
View File
@@ -0,0 +1,17 @@
import {Alert,Box,CircularProgress,Container,Stack,Typography} from "@mui/material";
import {useEffect,useMemo,useState} from "react";
import {use_gallery_catalog} from "./hooks/use_gallery_catalog";
import {Gallery_Toolbar} from "./gallery/gallery_toolbar";import {Gallery_Summary} from "./gallery/gallery_summary";import {Frame_Mode_Tabs} from "./gallery/frame_mode_tabs";import {Category_Filter} from "./gallery/category_filter";import {Gallery_Page} from "./gallery/gallery_page";import type {Selected_Plot} from "./gallery/plot_card";import {Inspector_Drawer} from "./inspector/inspector_drawer";
export function App() {
const {catalog,state:socket_state,message:socket_message}=use_gallery_catalog();
const [active_mode,set_active_mode]=useState(""),[active_category,set_active_category]=useState(""),[streams_paused,set_streams_paused]=useState(false),[selected_plot,set_selected_plot]=useState<Selected_Plot|null>(null),[selected_inspector_tab,set_selected_inspector_tab]=useState("controls");
const [,set_visibility_epoch]=useState(0);
useEffect(()=>{const change=()=>set_visibility_epoch(value=>value+1);document.addEventListener("visibilitychange",change);return()=>document.removeEventListener("visibilitychange",change);},[]);
useEffect(()=>{if(catalog){set_active_mode(catalog.frame_modes.some(mode=>mode.id===catalog.navigation.default_mode)?catalog.navigation.default_mode:catalog.frame_modes[0]?.id??"");set_active_category(catalog.navigation.all_categories_label);}},[catalog]);
const modes=useMemo(()=>[...(catalog?.frame_modes??[])].sort((a,b)=>a.order-b.order),[catalog]);
const cases=useMemo(()=>[...(catalog?.cases??[])].sort((a,b)=>a.order-b.order),[catalog]);
const frame_mode=modes.find(mode=>mode.id===active_mode);
const categories=catalog?[catalog.navigation.all_categories_label,...new Set(cases.map(item=>item.category))]:[];
const visible_cases=catalog?cases.filter(item=>active_category===catalog.navigation.all_categories_label||item.category===active_category):[];
return <><Gallery_Toolbar socket_state={socket_state} socket_message={socket_message} streams_paused={streams_paused} on_toggle_streams={()=>set_streams_paused(value=>!value)}/><Container maxWidth={false} sx={{py:3}}><Stack spacing={2.5}>{!catalog&&socket_state!=="error"&&<Box sx={{display:"grid",placeItems:"center",minHeight:"55vh",gap:2}}><CircularProgress/><Typography></Typography></Box>}{!catalog&&socket_state==="error"&&<Alert severity="error">{socket_message}</Alert>}{catalog&&<><Gallery_Summary catalog={catalog}/><Frame_Mode_Tabs frame_modes={modes} active_mode={active_mode} on_change={id=>{set_active_mode(id);set_selected_plot(null);}}/><Category_Filter categories={categories} active_category={active_category} on_change={set_active_category}/>{frame_mode&&<><Typography color="text.secondary">{frame_mode.description}</Typography><Gallery_Page cases={visible_cases} frame_mode={frame_mode} streams_paused={streams_paused} on_open_inspector={set_selected_plot}/></>}</>}</Stack></Container><Inspector_Drawer open={selected_plot!==null} session={selected_plot?.session??null} active_tab={selected_inspector_tab} on_change_tab={set_selected_inspector_tab} on_close={()=>set_selected_plot(null)}/></>;
}
@@ -0,0 +1,2 @@
import {List,ListItemButton,ListItemText} from "@mui/material";import type {Gallery_Captured_Frame} from "../protocol/gallery_types";import {format_nanoseconds} from "../protocol/format";
export function Capture_Frames({frames,selected_frame_id,on_select}:{frames:Gallery_Captured_Frame[];selected_frame_id:number|null;on_select:(id:number)=>void}) {return <List dense sx={{maxHeight:240,overflow:"auto"}}>{frames.map(frame=><ListItemButton key={frame.frame_id} selected={frame.frame_id===selected_frame_id} onClick={()=>on_select(frame.frame_id)}><ListItemText primary={`Frame #${frame.frame_id}`} secondary={`Plan v${frame.render_plan_version} · ${format_nanoseconds(frame.render_duration_ns)}`}/></ListItemButton>)}</List>;}
@@ -0,0 +1,14 @@
import {Divider,Paper,Stack,Typography} from "@mui/material";
import {useState} from "react";
import type {Gallery_Performance_Capture} from "../protocol/gallery_types";
import {Capture_Toolbar} from "./capture_toolbar";import {Capture_Sessions} from "./capture_sessions";import {Capture_Frames} from "./capture_frames";import {Frame_Summary} from "./frame_summary";import {Worker_Timeline} from "./worker_timeline";import {Node_Detail} from "./node_detail";import {Plan_Comparison} from "./plan_comparison";import {Render_Dag} from "../dag/render_dag";
export function Capture_Panel({capture,on_capture}:{capture:Gallery_Performance_Capture;on_capture:(action:string,count?:number)=>void}) {
const latest_session=capture.sessions.at(-1)??null;
const [selected_capture_session,set_selected_capture_session]=useState<number|null>(latest_session?.session_id??null);
const session=capture.sessions.find(item=>item.session_id===selected_capture_session)??latest_session;
const [selected_capture_frame,set_selected_capture_frame]=useState<number|null>(session?.frames.at(-1)?.frame_id??null);
const frame=session?.frames.find(item=>item.frame_id===selected_capture_frame)??session?.frames.at(-1);
const plan=frame?capture.plans.find(item=>item.version===frame.render_plan_version):undefined;
const [selected_node_id,set_selected_node_id]=useState<number|null>(null);
return <Stack spacing={2}><Paper sx={{p:2}}><Capture_Toolbar capture={capture} on_capture={on_capture}/></Paper>{session&&<><Capture_Sessions sessions={capture.sessions} selected_session_id={session.session_id} on_select={id=>{set_selected_capture_session(id);set_selected_capture_frame(null);set_selected_node_id(null);}}/><Capture_Frames frames={session.frames} selected_frame_id={frame?.frame_id??null} on_select={id=>{set_selected_capture_frame(id);set_selected_node_id(null);}}/></>}{session&&frame&&plan&&<><Divider/><Typography variant="h6">Frame Summary</Typography><Frame_Summary frame={frame}/><Typography variant="h6">Render DAG</Typography><Render_Dag plan={plan} frame={frame} statistics={session.node_statistics} selected_node_id={selected_node_id} on_select_node={set_selected_node_id}/><Typography variant="h6">Worker Timeline</Typography><Worker_Timeline frame={frame} plan={plan} selected_node_id={selected_node_id} on_select_node={set_selected_node_id}/><Typography variant="h6">Node Detail</Typography><Node_Detail frame={frame} plan={plan} session={session} selected_node_id={selected_node_id}/><Typography variant="h6">Plan Comparison</Typography><Plan_Comparison capture={capture} session={session}/></>}</Stack>;
}
@@ -0,0 +1,2 @@
import {MenuItem,TextField} from "@mui/material";import type {Gallery_Capture_Session} from "../protocol/gallery_types";
export function Capture_Sessions({sessions,selected_session_id,on_select}:{sessions:Gallery_Capture_Session[];selected_session_id:number|null;on_select:(id:number)=>void}) {return <TextField select size="small" fullWidth label="Capture Session" value={selected_session_id??""} onChange={event=>on_select(Number(event.target.value))}>{sessions.map(session=><MenuItem key={session.session_id} value={session.session_id}>Session #{session.session_id} · {session.captured_count}/{session.requested_count}</MenuItem>)}</TextField>;}
@@ -0,0 +1,2 @@
import {Button,LinearProgress,Stack,TextField,Typography} from "@mui/material";import {useState} from "react";import type {Gallery_Performance_Capture} from "../protocol/gallery_types";
export function Capture_Toolbar({capture,on_capture}:{capture:Gallery_Performance_Capture;on_capture:(action:string,count?:number)=>void}) {const [capture_count_draft,set_capture_count_draft]=useState("20");const active=capture.sessions.find(session=>session.session_id===capture.controller.session_id)||capture.sessions.find(session=>session.active);const requested=active?.requested_count??0,captured=active?.captured_count??0;return <Stack spacing={1.5}><Stack direction="row" spacing={1}><Button variant="contained" disabled={capture.controller.enabled} onClick={()=>on_capture("capture_next_frame")}>Capture next frame</Button><TextField size="small" type="number" value={capture_count_draft} inputProps={{min:1,max:1000}} onChange={event=>set_capture_count_draft(event.target.value)} sx={{width:110}}/><Button variant="outlined" disabled={capture.controller.enabled} onClick={()=>on_capture("capture_frames",Math.max(1,Math.min(1000,Number(capture_count_draft)||20)))}>Capture N frames</Button></Stack><Typography variant="caption">{requested?`captured ${captured} / ${requested}`:"Capture disabled"}</Typography>{requested>0&&<LinearProgress variant="determinate" value={Math.min(100,captured/requested*100)}/>}</Stack>;}
@@ -0,0 +1,2 @@
import {Metric_Grid} from "../common/metric_grid";import type {Gallery_Captured_Frame} from "../protocol/gallery_types";import {format_nanoseconds} from "../protocol/format";
export function Frame_Summary({frame}:{frame:Gallery_Captured_Frame}) {const workers=new Set(frame.node_executions.map(item=>item.worker_id));return <Metric_Grid values={[["Frame",`#${frame.frame_id}`],["Plan",`v${frame.render_plan_version}`],["Render",format_nanoseconds(frame.render_duration_ns)],["Nodes",frame.node_executions.length],["Workers",workers.size]]}/>;}
@@ -0,0 +1,2 @@
import {Stack,Typography} from "@mui/material";import type {Gallery_Capture_Session,Gallery_Captured_Frame,Gallery_Render_Plan} from "../protocol/gallery_types";import {Metric_Grid} from "../common/metric_grid";import {Json_Viewer} from "../common/json_viewer";import {format_nanoseconds} from "../protocol/format";
export function Node_Detail({frame,plan,session,selected_node_id}:{frame:Gallery_Captured_Frame;plan:Gallery_Render_Plan;session:Gallery_Capture_Session;selected_node_id:number|null}) {const node=plan.nodes.find(item=>item.node_id===selected_node_id),execution=frame.node_executions.find(item=>item.node_id===selected_node_id),analysis=frame.analysis?.nodes.find(item=>item.node_id===selected_node_id),history=session.node_statistics.find(item=>item.node_id===selected_node_id);if(!node||!execution)return <Typography color="text.secondary"> DAG 线</Typography>;return <Stack spacing={2}><Metric_Grid values={[["Node",`${node.owner} / ${node.name}`],["Kind",node.kind],["Current",format_nanoseconds(execution.duration_ns)],["Scheduler wait",format_nanoseconds(analysis?.scheduler_wait_ns)],["Worker",execution.worker_id],["Critical",`${((analysis?.critical_path_contribution??0)*100).toFixed(2)}%`],["Moving avg",format_nanoseconds(history?.moving_average_ns)],["P95",format_nanoseconds(history?.p95_ns)],["P99",format_nanoseconds(history?.p99_ns)]]}/>{execution.metrics&&<Json_Viewer value={execution.metrics}/>}</Stack>;}
@@ -0,0 +1,2 @@
import {Stack,Typography} from "@mui/material";import type {Gallery_Capture_Session,Gallery_Performance_Capture} from "../protocol/gallery_types";import {Json_Viewer} from "../common/json_viewer";
export function Plan_Comparison({capture,session}:{capture:Gallery_Performance_Capture;session:Gallery_Capture_Session}) {if(session.plan_statistics.length<2)return <Typography color="text.secondary">捕获不同 Render Plan 版本后可比较拓扑与性能</Typography>;const first=session.plan_statistics[0],second=session.plan_statistics.at(-1)!;const first_plan=capture.plans.find(plan=>plan.version===first.render_plan_version),second_plan=capture.plans.find(plan=>plan.version===second.render_plan_version);const first_nodes=new Set(first_plan?.nodes.map(node=>node.node_id)),second_nodes=new Set(second_plan?.nodes.map(node=>node.node_id));return <Stack><Typography>Plan v{first.render_plan_version} v{second.render_plan_version}</Typography><Json_Viewer value={{render_average_ns:[first.render_average_ns,second.render_average_ns],render_p95_ns:[first.render_p95_ns,second.render_p95_ns],added_nodes:[...second_nodes].filter(id=>!first_nodes.has(id)),removed_nodes:[...first_nodes].filter(id=>!second_nodes.has(id))}}/></Stack>;}
@@ -0,0 +1,2 @@
import {Box,Tooltip,Typography} from "@mui/material";import type {Gallery_Captured_Frame,Gallery_Render_Plan} from "../protocol/gallery_types";import {format_nanoseconds} from "../protocol/format";
export function Worker_Timeline({frame,plan,selected_node_id,on_select_node}:{frame:Gallery_Captured_Frame;plan:Gallery_Render_Plan;selected_node_id:number|null;on_select_node:(id:number)=>void}) {const duration=Math.max(1,frame.render_duration_ns);const workers=[...new Set(frame.node_executions.map(item=>item.worker_id))].toSorted((a,b)=>a-b);const names=new Map(plan.nodes.map(node=>[node.node_id,node.name]));return <Box sx={{display:"grid",gridTemplateColumns:"76px 1fr",gap:1,alignItems:"center"}}>{workers.map(worker=><Box key={worker} sx={{display:"contents"}}><Typography variant="caption">Worker {worker}</Typography><Box sx={{position:"relative",height:34,bgcolor:"rgba(255,255,255,.04)",borderRadius:1}}>{frame.node_executions.filter(item=>item.worker_id===worker).map(item=><Tooltip key={`${item.node_id}:${item.start_offset_ns}`} title={`${names.get(item.node_id)??item.node_id} · ${format_nanoseconds(item.duration_ns)}`}><Box onClick={()=>on_select_node(item.node_id)} sx={{position:"absolute",left:`${item.start_offset_ns/duration*100}%`,width:`${Math.max(.4,item.duration_ns/duration*100)}%`,top:4,bottom:4,borderRadius:.5,cursor:"pointer",bgcolor:item.node_id===selected_node_id?"secondary.main":"primary.main"}}/></Tooltip>)}</Box></Box>)}</Box>;}
@@ -0,0 +1,3 @@
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import {IconButton,Tooltip} from "@mui/material";
export function Copy_Button({value}:{value:string}) {return <Tooltip title="复制"><IconButton size="small" onClick={()=>void navigator.clipboard.writeText(value)}><ContentCopyIcon fontSize="inherit"/></IconButton></Tooltip>;}
@@ -0,0 +1,2 @@
import {Box,Typography} from "@mui/material";
export function Empty_State({message}:{message:string}) {return <Box sx={{p:4,textAlign:"center",color:"text.secondary"}}><Typography>{message}</Typography></Box>;}
@@ -0,0 +1,2 @@
import {Box} from "@mui/material";
export function Json_Viewer({value}:{value:unknown}) {return <Box component="pre" sx={{m:0,p:1.5,bgcolor:"rgba(0,0,0,.28)",borderRadius:1,overflow:"auto",fontSize:12}}>{JSON.stringify(value,null,2)}</Box>;}
@@ -0,0 +1,3 @@
import {Box} from "@mui/material";
import {Metric_Value} from "./metric_value";
export function Metric_Grid({values}:{values:Array<[string,string|number]>}) {return <Box sx={{display:"grid",gridTemplateColumns:"repeat(auto-fit,minmax(130px,1fr))",gap:1.5}}>{values.map(([label,value])=><Metric_Value key={label} label={label} value={value}/>)}</Box>;}
@@ -0,0 +1,2 @@
import {Box,Typography} from "@mui/material";
export function Metric_Value({label,value}:{label:string;value:string|number}) {return <Box><Typography variant="h6" component="strong">{value}</Typography><Typography variant="caption" color="text.secondary" display="block">{label}</Typography></Box>;}
@@ -0,0 +1,2 @@
import {Chip} from "@mui/material";
export function Status_Chip({state,label}:{state:string;label:string}) {const color=state==="ready"?"success":state==="error"||state==="closed"?"error":"warning";return <Chip size="small" color={color} variant="outlined" label={label}/>;}
+3
View File
@@ -0,0 +1,3 @@
import ELK from "elkjs/lib/elk.bundled.js";import type {Dag_View_Model} from "./dag_types";
const elk=new ELK();const layout_cache=new Map<string,Dag_View_Model>();
export async function layout_dag(model:Dag_View_Model,direction:"RIGHT"|"DOWN"="RIGHT"):Promise<Dag_View_Model>{const key=`${model.version}:${direction}:${model.nodes.map(node=>node.id).join(",")}:${model.edges.map(edge=>`${edge.source}>${edge.target}`).join(",")}`;const cached=layout_cache.get(key);if(cached)return{...cached,nodes:cached.nodes.map((node,index)=>({...node,data:model.nodes[index].data})),edges:model.edges};const result=await elk.layout({id:"root",layoutOptions:{"elk.algorithm":"layered","elk.direction":direction,"elk.spacing.nodeNode":"36","elk.layered.spacing.nodeNodeBetweenLayers":"70"},children:model.nodes.map(node=>({id:node.id,width:250,height:96})),edges:model.edges.map(edge=>({id:edge.id,sources:[edge.source],targets:[edge.target]}))});const positions=new Map(result.children?.map(node=>[node.id,{x:node.x??0,y:node.y??0}]));const laid={...model,nodes:model.nodes.map(node=>({...node,position:positions.get(node.id)??node.position}))};layout_cache.set(key,laid);return laid;}
+1
View File
@@ -0,0 +1 @@
import {Chip,Stack} from "@mui/material";export function Dag_Legend(){return <Stack direction="row" spacing={1}><Chip size="small" label="Prepare"/><Chip size="small" color="primary" label="Paint"/><Chip size="small" color="secondary" label="Critical path"/></Stack>;}
+2
View File
@@ -0,0 +1,2 @@
import type {Gallery_Captured_Frame,Gallery_Node_Statistics,Gallery_Render_Plan} from "../protocol/gallery_types";import type {Dag_View_Model} from "./dag_types";
export function build_dag_model(plan:Gallery_Render_Plan,frame:Gallery_Captured_Frame|null,statistics:Gallery_Node_Statistics[]=[],selected_node_id:number|null=null):Dag_View_Model {const executions=new Map((frame?.node_executions??[]).map(item=>[item.node_id,item]));const analysis=new Map((frame?.analysis?.nodes??[]).map(item=>[item.node_id,item]));const historical=new Map(statistics.map(item=>[item.node_id,item]));return{version:plan.version,nodes:plan.nodes.map(node=>{const id=String(node.node_id??node.id);const execution=executions.get(node.node_id);return{id,type:"dag_node",position:{x:0,y:0},data:{render_node:node,duration_ns:execution?.duration_ns,worker_id:execution?.worker_id,critical:analysis.get(node.node_id)?.on_critical_path,selected:node.node_id===selected_node_id,historical:historical.get(node.node_id)}};}),edges:plan.edges.map((edge,index)=>({id:`${edge.from}-${edge.to}-${index}`,source:String(edge.from),target:String(edge.to),animated:Boolean(frame)}))};}
+2
View File
@@ -0,0 +1,2 @@
import {Handle,Position,type NodeProps} from "@xyflow/react";import {Box,Typography} from "@mui/material";import type {Node} from "@xyflow/react";import type {Dag_Node_Data} from "./dag_types";import {format_nanoseconds} from "../protocol/format";
export function Dag_Node({data}:NodeProps<Node<Dag_Node_Data>>) {const node=data.render_node;return <Box sx={{width:250,p:1.5,borderRadius:2,border:"1px solid",borderColor:data.selected?"primary.main":data.critical?"secondary.main":"rgba(132,179,206,.35)",bgcolor:"#102239",boxShadow:data.selected?"0 0 0 2px rgba(89,214,197,.25)":"none"}}><Handle type="target" position={Position.Left}/><Typography variant="overline" color="primary.main">{node.kind}</Typography><Typography fontWeight={800} noWrap>{node.name||node.label}</Typography><Typography variant="caption" color="text.secondary">{node.owner}</Typography><Typography variant="body2">{data.duration_ns===undefined?"未执行":`${format_nanoseconds(data.duration_ns)} · worker ${data.worker_id}`}</Typography><Handle type="source" position={Position.Right}/></Box>;}
+3
View File
@@ -0,0 +1,3 @@
import type {Edge,Node} from "@xyflow/react";import type {Gallery_Render_Node} from "../protocol/gallery_types";
export interface Dag_Node_Data extends Record<string,unknown> {render_node: Gallery_Render_Node; duration_ns?: number; worker_id?: number; critical?: boolean; selected?: boolean;}
export interface Dag_View_Model {nodes: Node<Dag_Node_Data>[];edges: Edge[];version:number;}
+3
View File
@@ -0,0 +1,3 @@
import {Background,Controls,MiniMap,ReactFlow} from "@xyflow/react";import {Box} from "@mui/material";import {useEffect,useMemo,useState} from "react";import type {Gallery_Captured_Frame,Gallery_Node_Statistics,Gallery_Render_Plan} from "../protocol/gallery_types";import {build_dag_model} from "./dag_model";import {layout_dag} from "./dag_layout";import {Dag_Node} from "./dag_node";import type {Dag_View_Model} from "./dag_types";
const node_types={dag_node:Dag_Node};
export function Render_Dag({plan,frame=null,statistics=[],selected_node_id,on_select_node}:{plan:Gallery_Render_Plan;frame?:Gallery_Captured_Frame|null;statistics?:Gallery_Node_Statistics[];selected_node_id:number|null;on_select_node:(id:number)=>void}) {const source=useMemo(()=>build_dag_model(plan,frame,statistics,selected_node_id),[plan,frame,statistics,selected_node_id]);const [model,set_model]=useState<Dag_View_Model>(source);useEffect(()=>{let current=true;void layout_dag(source).then(value=>current&&set_model(value));return()=>{current=false;};},[source]);return <Box sx={{height:520,border:"1px solid rgba(132,179,206,.2)",borderRadius:2,overflow:"hidden"}}><ReactFlow nodes={model.nodes} edges={model.edges} nodeTypes={node_types} fitView nodesDraggable={false} nodesConnectable={false} onNodeClick={(_,node)=>on_select_node(Number(node.id))}><Background/><MiniMap/><Controls/></ReactFlow></Box>;}
@@ -0,0 +1,2 @@
import {Chip,Stack} from "@mui/material";
export function Category_Filter({categories,active_category,on_change}:{categories:string[];active_category:string;on_change:(category:string)=>void}) {return <Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">{categories.map(category=><Chip key={category} label={category} clickable color={category===active_category?"primary":"default"} variant={category===active_category?"filled":"outlined"} onClick={()=>on_change(category)}/>)}</Stack>;}
@@ -0,0 +1,2 @@
import {Tab,Tabs} from "@mui/material";import type {Gallery_Frame_Mode} from "../protocol/gallery_types";
export function Frame_Mode_Tabs({frame_modes,active_mode,on_change}:{frame_modes:Gallery_Frame_Mode[];active_mode:string;on_change:(id:string)=>void}) {return <Tabs value={active_mode} onChange={(_,value:string)=>on_change(value)} variant="scrollable" scrollButtons="auto">{frame_modes.map(mode=><Tab key={mode.id} value={mode.id} label={`${mode.title} · ${mode.strategy}`}/>)}</Tabs>;}
@@ -0,0 +1,2 @@
import {Box} from "@mui/material";import {useCallback,useEffect,useRef} from "react";import type {Gallery_Case,Gallery_Frame_Mode} from "../protocol/gallery_types";import type {Gallery_Plot_Session} from "../session/gallery_plot_session";import {Plot_Card,type Selected_Plot} from "./plot_card";
export function Gallery_Page({cases,frame_mode,streams_paused,on_open_inspector}:{cases:Gallery_Case[];frame_mode:Gallery_Frame_Mode;streams_paused:boolean;on_open_inspector:(plot:Selected_Plot)=>void}) {const sessions=useRef(new Set<Gallery_Plot_Session>());const register=useCallback((session:Gallery_Plot_Session,mount:boolean)=>{mount?sessions.current.add(session):sessions.current.delete(session);},[]);useEffect(()=>{let animation_frame=0;const loop=(now:number)=>{for(const session of sessions.current)session.tick(now);animation_frame=requestAnimationFrame(loop);};animation_frame=requestAnimationFrame(loop);return()=>cancelAnimationFrame(animation_frame);},[]);return <Box sx={{display:"grid",gridTemplateColumns:"repeat(auto-fit,minmax(min(100%,520px),1fr))",gap:2.5}}>{cases.map(gallery_case=><Plot_Card key={`${gallery_case.id}:${frame_mode.id}`} gallery_case={gallery_case} frame_mode={frame_mode} active={!document.hidden} streams_paused={streams_paused} on_register={register} on_open_inspector={on_open_inspector}/>)}</Box>;}
@@ -0,0 +1,2 @@
import {Box,Paper,Typography} from "@mui/material";import type {Gallery_Catalog} from "../protocol/gallery_types";import {Metric_Grid} from "../common/metric_grid";
export function Gallery_Summary({catalog}:{catalog:Gallery_Catalog}) {const coverage=catalog.coverage;return <Paper sx={{p:{xs:2,md:3},display:"grid",gridTemplateColumns:{xs:"1fr",md:"1.5fr 1fr"},gap:3,background:"linear-gradient(135deg,rgba(89,214,197,.12),rgba(255,189,105,.05))"}}><Box><Typography variant="overline" color="primary.main">{catalog.navigation.hero_eyebrow}</Typography><Typography variant="h4">{catalog.navigation.hero_title}</Typography><Typography color="text.secondary" mt={1}> WebSocket </Typography></Box><Metric_Grid values={[["帧策略页",coverage.page_count],["每页控件",coverage.case_count],["独立场景",coverage.canvas_count],["手测入口",coverage.manual_control_count+coverage.manual_action_count]]}/></Paper>;}
@@ -0,0 +1,4 @@
import PauseIcon from "@mui/icons-material/Pause";import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import {AppBar,Box,Button,Toolbar,Typography} from "@mui/material";
import {Status_Chip} from "../common/status_chip";
export function Gallery_Toolbar({socket_state,socket_message,streams_paused,on_toggle_streams}:{socket_state:string;socket_message:string;streams_paused:boolean;on_toggle_streams:()=>void}) {return <AppBar position="sticky" color="transparent" elevation={0} sx={{backdropFilter:"blur(18px)",bgcolor:"rgba(7,17,31,.84)",borderBottom:"1px solid rgba(132,179,206,.16)"}}><Toolbar sx={{gap:2}}><Box sx={{display:"flex",alignItems:"center",gap:1.5,flexGrow:1}}><Box sx={{display:"grid",placeItems:"center",width:38,height:38,borderRadius:2,bgcolor:"primary.main",color:"#041015",fontWeight:900}}>R2</Box><Box><Typography variant="overline" color="primary.main" lineHeight={1}>CORE2 · KERNEL · WEBSOCKET</Typography><Typography variant="h6" lineHeight={1.3}>Renderive </Typography></Box></Box><Status_Chip state={socket_state} label={socket_message}/><Button variant="outlined" startIcon={streams_paused?<PlayArrowIcon/>:<PauseIcon/>} onClick={on_toggle_streams}>{streams_paused?"恢复自动像素流":"暂停自动像素流"}</Button></Toolbar></AppBar>;}
+23
View File
@@ -0,0 +1,23 @@
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
import RefreshIcon from "@mui/icons-material/Refresh";
import {Box,Button,Card,CardActions,CardContent,CardHeader,Chip,Stack,Typography} from "@mui/material";
import {useEffect} from "react";
import type {Gallery_Case,Gallery_Frame_Mode} from "../protocol/gallery_types";
import {use_plot_session} from "../hooks/use_plot_session";
import type {Gallery_Plot_Session} from "../session/gallery_plot_session";
import {Plot_Canvas} from "../plot/plot_canvas";
import {Plot_Status} from "../plot/plot_status";
import {Performance_Strip} from "../plot/performance_strip";
import {Kernel_Observer_Summary} from "../plot/kernel_observer_summary";
export interface Selected_Plot {session:Gallery_Plot_Session;}
export function Plot_Card({gallery_case,frame_mode,active,streams_paused,on_register,on_open_inspector}:{gallery_case:Gallery_Case;frame_mode:Gallery_Frame_Mode;active:boolean;streams_paused:boolean;on_register:(session:Gallery_Plot_Session,mount:boolean)=>void;on_open_inspector:(plot:Selected_Plot)=>void}) {
const [session,snapshot]=use_plot_session(gallery_case,frame_mode);
useEffect(()=>{on_register(session,true);return()=>on_register(session,false);},[session,on_register]);
useEffect(()=>session.set_activity(active,streams_paused),[session,active,streams_paused]);
return <Card sx={{overflow:"hidden",display:"flex",flexDirection:"column",minWidth:0}} onContextMenu={event=>{event.preventDefault();if(snapshot.ready)on_open_inspector({session});}}>
<CardHeader title={gallery_case.title} subheader={gallery_case.component} avatar={<Chip size="small" label={gallery_case.category}/>} action={<Plot_Status snapshot={snapshot} active={active&&!streams_paused}/>}/>
<Plot_Canvas session={session}/><Performance_Strip telemetry={snapshot.telemetry}/>{frame_mode.observer_visible&&<Kernel_Observer_Summary telemetry={snapshot.telemetry}/>}<CardContent sx={{flexGrow:1}}><Typography color="text.secondary">{gallery_case.description}</Typography>{snapshot.notice&&<Typography variant="caption" color="secondary.main">{snapshot.notice}</Typography>}</CardContent>
<CardActions sx={{justifyContent:"space-between",px:2,pb:2}}><Stack direction="row" spacing={2}><Typography variant="caption">{snapshot.controls.length} </Typography><Typography variant="caption">{snapshot.actions.length} </Typography><Typography variant="caption">{snapshot.frame_count} </Typography></Stack><Box><Button size="small" startIcon={<RefreshIcon/>} onClick={()=>session.request_frame(performance.now(),true)}>{frame_mode.frame_button_label}</Button><Button size="small" startIcon={<MoreHorizIcon/>} disabled={!snapshot.ready} onClick={()=>on_open_inspector({session})}></Button></Box></CardActions>
</Card>;
}
@@ -0,0 +1,3 @@
import {useEffect, useState, type RefObject} from "react";
export interface Element_Size {width:number;height:number;}
export function use_element_size(ref: RefObject<HTMLElement>): Element_Size {const [size,set_size]=useState<Element_Size>({width:0,height:0});useEffect(()=>{const element=ref.current;if(!element)return;const observer=new ResizeObserver(entries=>{const rect=entries[0]?.contentRect;if(rect)set_size({width:rect.width,height:rect.height});});observer.observe(element);return()=>observer.disconnect();},[ref]);return size;}
@@ -0,0 +1,4 @@
import {useEffect, useState} from "react";
import type {Gallery_Catalog} from "../protocol/gallery_types";
import {load_gallery_catalog} from "../transport/catalog_loader";
export function use_gallery_catalog(): {catalog: Gallery_Catalog|null; state: string; message: string} {const [catalog,set_catalog]=useState<Gallery_Catalog|null>(null);const [status,set_status]=useState({state:"connecting",message:"读取后端目录"});useEffect(()=>load_gallery_catalog(set_catalog,(state,message)=>set_status({state,message})),[]);return {catalog,...status};}
@@ -0,0 +1,9 @@
import {useEffect, useMemo, useSyncExternalStore} from "react";
import type {Gallery_Case, Gallery_Frame_Mode} from "../protocol/gallery_types";
import {Gallery_Plot_Session} from "../session/gallery_plot_session";
export function use_plot_session(gallery_case: Gallery_Case, frame_mode: Gallery_Frame_Mode): [Gallery_Plot_Session, ReturnType<Gallery_Plot_Session["get_snapshot"]>] {
const session=useMemo(()=>new Gallery_Plot_Session(gallery_case,frame_mode),[gallery_case,frame_mode]);
const snapshot=useSyncExternalStore(session.subscribe,session.get_snapshot,session.get_snapshot);
useEffect(()=>()=>session.dispose(),[session]);
return [session,snapshot];
}
@@ -0,0 +1,3 @@
import {Button,Paper,Stack,TextField,Typography} from "@mui/material";import {useState} from "react";import type {Gallery_Action,Json_Primitive} from "../protocol/gallery_types";
function Action_Row({action,on_action}:{action:Gallery_Action;on_action:(id:string,argument?:Json_Primitive)=>void}) {const [argument,set_argument]=useState(String(action.argument_default??""));return <Paper sx={{p:1.5,display:"flex",alignItems:"center",gap:1.5}}><Stack flex={1}><Typography fontWeight={700}>{action.label}</Typography><Typography variant="caption" fontFamily="monospace">{action.api}</Typography></Stack>{action.argument_input&&<TextField size="small" type={action.argument_input} value={argument} onChange={event=>set_argument(event.target.value)} sx={{width:130}}/>}<Button variant="contained" onClick={()=>on_action(action.id,action.argument_input==="number"?Number(argument):action.argument_input?argument:undefined)}></Button></Paper>;}
export function Actions_Panel({actions,on_action}:{actions:Gallery_Action[];on_action:(id:string,argument?:Json_Primitive)=>void}) {return <Stack spacing={1}>{actions.map(action=><Action_Row key={action.id} action={action} on_action={on_action}/>)}</Stack>;}
@@ -0,0 +1,2 @@
import {FormControlLabel,MenuItem,Switch,TextField} from "@mui/material";import {useEffect,useState} from "react";import type {Gallery_Control,Json_Value} from "../protocol/gallery_types";
export function Control_Field({control,on_commit}:{control:Gallery_Control;on_commit:(value:Json_Value)=>void}) {const [draft,set_draft]=useState(String(control.value??""));const [editing,set_editing]=useState(false);useEffect(()=>{if(!editing)set_draft(String(control.value??""));},[control.value,editing]);if(control.input==="boolean")return <FormControlLabel control={<Switch checked={Boolean(control.value)} onChange={(_,checked)=>on_commit(checked)}/>} label={control.label}/>;if(control.input==="select")return <TextField select fullWidth size="small" label={control.label} value={draft} onFocus={()=>set_editing(true)} onBlur={()=>set_editing(false)} onChange={event=>{set_draft(event.target.value);on_commit(event.target.value);}}>{control.options.map(option=><MenuItem key={String(option.value)} value={String(option.value)}>{option.label}</MenuItem>)}</TextField>;const commit=()=>{set_editing(false);if(control.input!=="number"){if(draft!==String(control.value??""))on_commit(draft);return;}const number=Number(draft);if(!Number.isFinite(number)||(control.minimum!==undefined&&number<control.minimum)||(control.maximum!==undefined&&number>control.maximum)){set_draft(String(control.value??""));return;}if(number!==Number(control.value))on_commit(number);};return <TextField fullWidth size="small" label={control.label} title={control.description} type={control.input==="number"?"number":"text"} value={draft} inputProps={{min:control.minimum,max:control.maximum,step:control.step??"any"}} onFocus={()=>set_editing(true)} onChange={event=>set_draft(event.target.value)} onBlur={commit} onKeyDown={event=>{if(event.key==="Enter")commit();else if(event.key==="Escape"){set_draft(String(control.value??""));set_editing(false);}}}/>;}
@@ -0,0 +1,2 @@
import {Accordion,AccordionDetails,AccordionSummary,Stack,Typography} from "@mui/material";import ExpandMoreIcon from "@mui/icons-material/ExpandMore";import type {Gallery_Control,Json_Value} from "../protocol/gallery_types";import {Control_Field} from "./control_field";import {nested_patch} from "../protocol/gallery_descriptor";
export function Controls_Panel({controls,on_patch}:{controls:Gallery_Control[];on_patch:(target:string,patch:Record<string,Json_Value>)=>void}) {const groups=new Map<string,Gallery_Control[]>();for(const control of controls){const group=control.group||"其他";groups.set(group,[...(groups.get(group)??[]),control]);}return <Stack>{[...groups].map(([group,items],index)=><Accordion key={group} defaultExpanded={index===0}><AccordionSummary expandIcon={<ExpandMoreIcon/>}><Typography>{group} · {items.length} </Typography></AccordionSummary><AccordionDetails><Stack spacing={2}>{items.map(control=><Control_Field key={`${control.target}:${control.id}`} control={control} on_commit={value=>on_patch(control.target,nested_patch(control.path,value))}/>)}</Stack></AccordionDetails></Accordion>)}</Stack>;}
@@ -0,0 +1,4 @@
import CloseIcon from "@mui/icons-material/Close";import RefreshIcon from "@mui/icons-material/Refresh";import RestartAltIcon from "@mui/icons-material/RestartAlt";
import {Box,Drawer,IconButton,Stack,Tooltip,Typography} from "@mui/material";import {useState,useSyncExternalStore} from "react";import type {Gallery_Plot_Session} from "../session/gallery_plot_session";import {EMPTY_PLOT_SNAPSHOT} from "../session/gallery_plot_snapshot";import {Inspector_Tabs} from "./inspector_tabs";import {Controls_Panel} from "./controls_panel";import {Actions_Panel} from "./actions_panel";import {Observer_Panel} from "./observer_panel";import {Performance_Panel} from "./performance_panel";import {Capture_Panel} from "../capture/capture_panel";import {Render_Dag} from "../dag/render_dag";import {Empty_State} from "../common/empty_state";
const empty_subscribe=()=>()=>undefined;const empty_snapshot=()=>EMPTY_PLOT_SNAPSHOT;
export function Inspector_Drawer({open,session,active_tab,on_change_tab,on_close}:{open:boolean;session:Gallery_Plot_Session|null;active_tab:string;on_change_tab:(tab:string)=>void;on_close:()=>void}) {const snapshot=useSyncExternalStore(session?.subscribe??empty_subscribe,session?.get_snapshot??empty_snapshot,session?.get_snapshot??empty_snapshot);const [selected_node_id,set_selected_node_id]=useState<number|null>(null);let body=null;if(session){if(active_tab==="controls")body=<Controls_Panel controls={snapshot.controls} on_patch={(target,patch)=>session.patch(target,patch)}/>;else if(active_tab==="actions")body=<Actions_Panel actions={snapshot.actions} on_action={(action,argument)=>{session.action(action,argument);setTimeout(()=>session.request_frame(performance.now(),true),40);}}/>;else if(active_tab==="observer")body=<Observer_Panel telemetry={snapshot.telemetry}/>;else if(active_tab==="performance")body=<Performance_Panel telemetry={snapshot.telemetry}/>;else if(active_tab==="performance_capture")body=snapshot.performance_capture?<Capture_Panel capture={snapshot.performance_capture} on_capture={(action,count)=>{session.action(action,count);setTimeout(()=>session.request_frame(performance.now(),true),40);}}/>:<Empty_State message="尚无 Performance Capture 数据"/>;else if(active_tab==="render_plan")body=snapshot.render_plan?<Render_Dag plan={snapshot.render_plan} selected_node_id={selected_node_id} on_select_node={set_selected_node_id}/>:<Empty_State message="场景尚未编译 Render Plan"/>;}return <Drawer anchor="right" open={open} onClose={on_close} PaperProps={{sx:{width:{xs:"100%",md:"min(900px,72vw)"}}}}><Stack direction="row" alignItems="center" sx={{p:2,pb:1}}><Box flex={1}><Typography variant="overline" color="primary.main">{session&&`${session.frame_mode.strategy} / ${session.gallery_case.component}`}</Typography><Typography variant="h5">{session?.gallery_case.title??"Inspector"}</Typography></Box><Tooltip title="重置监测"><IconButton onClick={()=>session?.reset_monitoring()}><RestartAltIcon/></IconButton></Tooltip><Tooltip title="刷新当前页"><IconButton onClick={()=>session?.refresh()}><RefreshIcon/></IconButton></Tooltip><IconButton onClick={on_close}><CloseIcon/></IconButton></Stack><Inspector_Tabs active_tab={active_tab} on_change={on_change_tab}/><Box sx={{p:2,overflow:"auto",flex:1}}>{body}</Box></Drawer>;}
@@ -0,0 +1,3 @@
import {Tab,Tabs} from "@mui/material";
export const INSPECTOR_TABS=[{id:"controls",label:"控件属性"},{id:"actions",label:"专属 API"},{id:"observer",label:"内核观察器"},{id:"performance",label:"性能监测"},{id:"performance_capture",label:"Performance Capture"},{id:"render_plan",label:"Render DAG"}] as const;
export function Inspector_Tabs({active_tab,on_change}:{active_tab:string;on_change:(tab:string)=>void}) {return <Tabs value={active_tab} onChange={(_,value:string)=>on_change(value)} variant="scrollable" scrollButtons="auto">{INSPECTOR_TABS.map(tab=><Tab key={tab.id} value={tab.id} label={tab.label}/>)}</Tabs>;}
@@ -0,0 +1,2 @@
import {Stack,Typography} from "@mui/material";import type {Gallery_Telemetry} from "../protocol/gallery_types";import {Json_Viewer} from "../common/json_viewer";
export function Observer_Panel({telemetry}:{telemetry:Gallery_Telemetry}) {return <Stack spacing={1}><Typography variant="h6"> Renderable </Typography><Json_Viewer value={telemetry}/></Stack>;}
@@ -0,0 +1,2 @@
import {Stack,Typography} from "@mui/material";import type {Gallery_Telemetry} from "../protocol/gallery_types";import {Json_Viewer} from "../common/json_viewer";
export function Performance_Panel({telemetry}:{telemetry:Gallery_Telemetry}) {return <Stack spacing={1}><Typography variant="h6"></Typography><Json_Viewer value={{performance:telemetry.performance,client_performance:telemetry.client_performance,consumer_feedback:telemetry.consumer_feedback}}/></Stack>;}
+7
View File
@@ -0,0 +1,7 @@
import {StrictMode} from "react";
import {createRoot} from "react-dom/client";
import {CssBaseline,ThemeProvider} from "@mui/material";
import {gallery_theme} from "./theme";
import {App} from "./app";
import "@xyflow/react/dist/style.css";
createRoot(document.getElementById("root")!).render(<StrictMode><ThemeProvider theme={gallery_theme}><CssBaseline/><App/></ThemeProvider></StrictMode>);
@@ -0,0 +1,2 @@
import {Box,Typography} from "@mui/material";import type {Gallery_Telemetry} from "../protocol/gallery_types";import {Json_Viewer} from "../common/json_viewer";
export function Kernel_Observer_Summary({telemetry}:{telemetry:Gallery_Telemetry}) {const observer=telemetry.frame_observer??telemetry.observer;return <Box sx={{p:1.5,borderTop:"1px solid rgba(132,179,206,.12)"}}><Typography variant="caption" color="text.secondary">Kernel Observer</Typography>{observer?<Json_Viewer value={observer}/>:<Typography variant="body2"></Typography>}</Box>;}
@@ -0,0 +1,3 @@
import {Box,Typography} from "@mui/material";import type {Gallery_Telemetry} from "../protocol/gallery_types";import {value_at_path} from "../protocol/format";
const METRICS:Array<[string,string,string]>=[["后端 FPS","performance.measured_fps"," FPS"],["像素 FPS","performance.pixel_response_fps"," FPS"],["渲染","performance.last_render_ms"," ms"],["编码","performance.last_pixel_encode_ms"," ms"]];
export function Performance_Strip({telemetry}:{telemetry:Gallery_Telemetry}) {return <Box sx={{display:"grid",gridTemplateColumns:"repeat(4,1fr)",gap:1,p:1.5,borderTop:"1px solid rgba(132,179,206,.12)"}}>{METRICS.map(([label,path,suffix])=><Box key={path}><Typography variant="caption" color="text.secondary">{label}</Typography><Typography fontFamily="monospace">{Number(value_at_path(telemetry,path)||0).toFixed(1)}{suffix}</Typography></Box>)}</Box>;}
+3
View File
@@ -0,0 +1,3 @@
import {Box} from "@mui/material";import {useEffect,useRef} from "react";import type {Gallery_Plot_Session} from "../session/gallery_plot_session";import {use_element_size} from "../hooks/use_element_size";
function modifiers(event: {shiftKey:boolean;ctrlKey:boolean;altKey:boolean;metaKey:boolean}): number {return(event.shiftKey?1:0)|(event.ctrlKey?2:0)|(event.altKey?4:0)|(event.metaKey?8:0);}
export function Plot_Canvas({session}:{session:Gallery_Plot_Session}) {const shell_ref=useRef<HTMLDivElement>(null),canvas_ref=useRef<HTMLCanvasElement>(null);const size=use_element_size(shell_ref);useEffect(()=>{const canvas=canvas_ref.current;if(!canvas)return;session.attach_canvas(canvas);return()=>session.detach_canvas();},[session]);useEffect(()=>{if(size.width&&size.height)session.resize(size.width,size.height);},[session,size]);const position=(event:React.PointerEvent<HTMLCanvasElement>|React.WheelEvent<HTMLCanvasElement>)=>{const canvas=canvas_ref.current!;const rect=canvas.getBoundingClientRect();return{x:(event.clientX-rect.left)*canvas.width/Math.max(1,rect.width),y:(event.clientY-rect.top)*canvas.height/Math.max(1,rect.height)};};const pointer=(type:"pointer_move"|"pointer_press"|"pointer_release",event:React.PointerEvent<HTMLCanvasElement>)=>session.pointer(type,{...position(event),button:["left","middle","right"][event.button]??"none",buttons:event.buttons,modifiers:modifiers(event)});return <Box ref={shell_ref} tabIndex={0} sx={{position:"relative",aspectRatio:"16 / 10",minHeight:220,bgcolor:"#03080e",outline:"none",overflow:"hidden"}} onKeyDown={event=>session.key("key_press",{key:event.key,nativeKey:event.keyCode,repeat:event.repeat,modifiers:modifiers(event)})} onKeyUp={event=>session.key("key_release",{key:event.key,nativeKey:event.keyCode,repeat:false,modifiers:modifiers(event)})}><Box component="canvas" ref={canvas_ref} aria-label="Core2 后端像素画布" sx={{width:"100%",height:"100%",display:"block"}} onPointerMove={event=>pointer("pointer_move",event)} onPointerDown={event=>{if(event.button!==2){shell_ref.current?.focus();event.currentTarget.setPointerCapture(event.pointerId);pointer("pointer_press",event);}}} onPointerUp={event=>event.button!==2&&pointer("pointer_release",event)} onPointerLeave={()=>session.leave()} onWheel={event=>{event.preventDefault();session.wheel({...position(event),pixelDeltaX:event.deltaX,pixelDeltaY:event.deltaY,angleDeltaX:-event.deltaX*8,angleDeltaY:-event.deltaY*8,buttons:event.buttons,modifiers:modifiers(event)});}}/></Box>;}
+2
View File
@@ -0,0 +1,2 @@
import {Stack} from "@mui/material";import {Status_Chip} from "../common/status_chip";import type {Gallery_Plot_Snapshot} from "../session/gallery_plot_snapshot";
export function Plot_Status({snapshot,active}:{snapshot:Gallery_Plot_Snapshot;active:boolean}) {return <Stack direction="row" spacing={1}><Status_Chip state={active?"ready":"connecting"} label={active?"像素流活动":"非活动视图"}/><Status_Chip state={snapshot.connection_state} label={snapshot.protocol_error||({connecting:"连接中",ready:"WS 已连接",error:"连接错误",closed:"连接关闭"}[snapshot.connection_state])}/></Stack>;}
+9
View File
@@ -0,0 +1,9 @@
import type {Gallery_Dashboard_Field, Gallery_Dashboard, Json_Value} from "./gallery_types";
export function format_nanoseconds(value: unknown): string {const ns = Math.max(0, Number(value) || 0); return ns < 1_000 ? `${Math.round(ns)} ns` : ns < 1_000_000 ? `${(ns / 1_000).toFixed(2)} µs` : `${(ns / 1_000_000).toFixed(3)} ms`;}
export function value_at_path(root: unknown, path = ""): unknown {return path.split(".").filter(Boolean).reduce<unknown>((value, key) => value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined, root);}
export function format_dashboard_field(field: Gallery_Dashboard_Field, telemetry: Record<string, unknown>, dashboard: Gallery_Dashboard): string {
const raw = value_at_path(telemetry, field.source) ?? field.default ?? ""; const number = Number(raw) || 0; const digits = field.digits ?? 0;
if (field.format === "fixed") return number.toFixed(digits); if (field.format === "integer") return number.toLocaleString(); if (field.format === "milliseconds") return `${number.toFixed(digits)} ms`; if (field.format === "fps") return `${number.toFixed(digits)} FPS`; if (field.format === "bytes") return `${number.toLocaleString()} B`; if (field.format === "nanoseconds") return format_nanoseconds(number);
if (field.format === "enum") return dashboard.value_maps?.[field.value_map ?? ""]?.[String(raw)] ?? String(raw);
return String(raw ?? "—");
}
@@ -0,0 +1,31 @@
import type {Gallery_Control, Gallery_Descriptor_Field, Gallery_Resource, Json_Value} from "./gallery_types";
export function field_visible(expression: string | undefined, data: Record<string, Json_Value> | undefined): boolean {
if (!expression) return true;
const equality = expression.match(/^\$\{\$self\.([A-Za-z_][A-Za-z0-9_]*) == '([^']*)'}$/);
return equality ? String(data?.[equality[1]]) === equality[2] : true;
}
export function build_controls(resources: Gallery_Resource[] = []): Gallery_Control[] {
const controls: Gallery_Control[] = [];
const visit = (fields: Gallery_Descriptor_Field[], data: Record<string, Json_Value>, target: string, group: string, path: string[] = [], labels: string[] = []) => {
for (const field of fields ?? []) {
const presentation = field.presentation ?? {};
if (!field_visible(presentation.visible_on, data)) continue;
const field_path = [...path, field.name];
const field_labels = [...labels, presentation.label ?? field.name];
const value = data?.[field.name];
if (field.children?.length) {
visit(field.children, value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, Json_Value> : {}, target, group, field_path, field_labels);
} else if (field.editable) {
controls.push({id: field_path.join("."), target, path: field_path, label: field_labels.join(" / "), api: field_path.join("."), description: presentation.description ?? "", group, input: presentation.control === "automatic" ? "text" : presentation.control ?? "text", minimum: field.minimum, maximum: field.maximum, step: field.multiple_of, options: presentation.options ?? [], value});
}
}
};
for (const resource of resources) visit(resource.descriptor?.fields ?? [], resource.data ?? {}, resource.target, resource.view?.title ?? resource.title ?? resource.descriptor?.label ?? "控件属性");
return controls;
}
export function nested_patch(path: string[], value: Json_Value): Record<string, Json_Value> {
let result: Json_Value = value;
for (const key of [...path].reverse()) result = {[key]: result};
return result as Record<string, Json_Value>;
}
@@ -0,0 +1,9 @@
import type {Gallery_Client_Performance, Json_Primitive, Json_Value} from "./gallery_types";
export function gallery_message(type: string, payload: Record<string, Json_Value> = {}): string {
return JSON.stringify({category: "event", type, ...payload});
}
export function gallery_open_message(case_id: string, frame_mode: string): string {return gallery_message("gallery_open", {case: case_id, frame_mode});}
export function gallery_patch_message(target: string, patch: Record<string, Json_Value>): string {return gallery_message("gallery_patch", {target, patch});}
export function gallery_action_message(action: string, argument?: Json_Primitive): string {return gallery_message("gallery_action", argument === undefined ? {action} : {action, argument});}
export function gallery_metrics_payload(client_performance: Gallery_Client_Performance): Record<string, Json_Value> {return {client_metrics: client_performance as unknown as Record<string, Json_Value>};}
@@ -0,0 +1,14 @@
import type {Gallery_Response} from "./gallery_types";
const KNOWN_RESPONSE_TYPES = new Set(["catalog", "case_state", "refresh_state", "observer_state", "error"]);
export class Gallery_Protocol_Error extends Error {}
export function parse_gallery_response(raw: string): Gallery_Response | null {
let value: unknown;
try { value = JSON.parse(raw); } catch { throw new Gallery_Protocol_Error("后端返回了无效 JSON"); }
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Gallery_Protocol_Error("后端响应必须是对象");
const type = (value as {type?: unknown}).type;
if (typeof type !== "string") throw new Gallery_Protocol_Error("后端响应缺少 type");
if (!KNOWN_RESPONSE_TYPES.has(type)) return null;
return value as Gallery_Response;
}
@@ -0,0 +1,45 @@
export type Json_Primitive = string | number | boolean | null;
export type Json_Value = Json_Primitive | Json_Value[] | {[key: string]: Json_Value};
export type Connection_State = "connecting" | "ready" | "error" | "closed";
export interface Gallery_Option {label: string; value: Json_Primitive;}
export interface Gallery_Descriptor_Field {
name: string; editable?: boolean; minimum?: number; maximum?: number; multiple_of?: number;
children?: Gallery_Descriptor_Field[];
presentation?: {label?: string; description?: string; control?: string; visible_on?: string; options?: Gallery_Option[]};
}
export interface Gallery_Descriptor {label?: string; fields: Gallery_Descriptor_Field[];}
export interface Gallery_Resource {target: string; title?: string; descriptor: Gallery_Descriptor; data: Record<string, Json_Value>; view?: {title?: string};}
export interface Gallery_Control {id: string; target: string; path: string[]; label: string; api: string; description: string; group: string; input: string; minimum?: number; maximum?: number; step?: number; options: Gallery_Option[]; value: Json_Value | undefined;}
export interface Gallery_Action {id: string; label: string; api: string; group?: string; argument_input?: string; argument_default?: Json_Primitive; request_frame?: boolean;}
export interface Gallery_Navigation {default_mode: string; all_categories_label: string; hero_eyebrow: string; hero_title: string; catalog_loaded_text: string;}
export interface Gallery_Coverage {case_count: number; page_count: number; canvas_count: number; manual_control_count: number; manual_action_count: number;}
export interface Gallery_Frame_Mode {id: string; title: string; strategy: string; description: string; order: number; accent: string; automatic: boolean; request_after_response: boolean; request_on_animation_frame: boolean; observer_visible: boolean; frame_button_label: string;}
export interface Gallery_Case {id: string; title: string; description: string; component: string; category: string; order: number; control_count_by_mode?: Record<string, number>; action_count_by_mode?: Record<string, number>;}
export interface Gallery_Dashboard_Field {label: string; source?: string; sources?: string[]; enabled_source?: string; duration_sources?: Record<string, string>; duration_source?: string; active_value?: Json_Primitive; disabled_label?: string; default?: Json_Primitive; digits?: number; format?: string; separator?: string; joiner?: string; value_map?: string; cell_class?: string;}
export interface Gallery_Dashboard {performance: {fields: Gallery_Dashboard_Field[]}; limits: {title: string; aria_label: string; current_source: string; active_label: string; inactive_label: string; disabled_label: string; fields: Gallery_Dashboard_Field[]}; observer: Record<string, Json_Value>; menu_views: Record<string, Json_Value>; value_maps?: Record<string, Record<string, string>>;}
export interface Gallery_Catalog {type: "catalog"; navigation: Gallery_Navigation; dashboard: Gallery_Dashboard; frame_modes: Gallery_Frame_Mode[]; cases: Gallery_Case[]; coverage: Gallery_Coverage; descriptor?: Gallery_Descriptor; transport?: {socket_per_canvas?: boolean};}
export interface Gallery_Client_Performance {transport_fps: number; presentation_fps: number; display_interval_latest_ms: number; display_interval_ms: number; display_interval_average_ms: number; display_interval_p95_ms: number; display_interval_p99_ms: number; display_interval_deviation_ms: number; frame_round_trip_ms: number; frame_round_trip_average_ms: number; frame_round_trip_p95_ms: number; frame_round_trip_p99_ms: number; frame_round_trip_deviation_ms: number; changed_pixel_frames: number; duplicate_pixel_frames: number; overwritten_pixel_frames: number; frame_request_timeout_count: number; last_pixel_receive_age_ms: number; last_pixel_change_age_ms: number; websocket_buffered_bytes: number;}
export interface Gallery_Performance extends Record<string, Json_Value> {}
export interface Gallery_Kernel_Observer extends Record<string, Json_Value> {}
export interface Gallery_Renderable_Observer extends Gallery_Resource {}
export interface Gallery_Telemetry {[key: string]: unknown; renderable_observers?: Gallery_Renderable_Observer[]; performance_capture?: Gallery_Performance_Capture;}
export interface Gallery_Render_Node {node_id: number; id?: string | number; name: string; label?: string; owner: string; kind: string;}
export interface Gallery_Render_Edge {from: number | string; to: number | string;}
export interface Gallery_Renderable_Cache {owner_id: number; name: string; prepare_cache: string; paint_cache: string;}
export interface Gallery_Render_Plan {version: number; nodes: Gallery_Render_Node[]; edges: Gallery_Render_Edge[]; renderables: Gallery_Renderable_Cache[];}
export interface Gallery_Node_Execution {node_id: number; worker_id: number; start_offset_ns: number; end_offset_ns: number; duration_ns: number; metrics?: Record<string, Json_Primitive>;}
export interface Gallery_Node_Analysis {node_id: number; scheduler_wait_ns: number; critical_path_contribution: number; on_critical_path: boolean;}
export interface Gallery_Captured_Frame {frame_id: number; render_plan_version: number; render_duration_ns: number; node_executions: Gallery_Node_Execution[]; analysis?: {nodes: Gallery_Node_Analysis[]};}
export interface Gallery_Node_Statistics {node_id: number; moving_average_ns: number; p95_ns: number; p99_ns: number; critical_path_frequency: number;}
export interface Gallery_Plan_Statistics {render_plan_version: number; frame_count: number; render_average_ns: number; render_p95_ns: number; average_parallelism: number; peak_parallelism: number; scheduler_wait_average_ns: number; nodes: Gallery_Node_Statistics[];}
export interface Gallery_Capture_Session {session_id: number; active: boolean; requested_count: number; captured_count: number; frames: Gallery_Captured_Frame[]; node_statistics: Gallery_Node_Statistics[]; plan_statistics: Gallery_Plan_Statistics[]; summary?: Record<string, Json_Value>;}
export interface Gallery_Performance_Capture {controller: {enabled?: boolean; session_id?: number}; sessions: Gallery_Capture_Session[]; plans: Gallery_Render_Plan[];}
export interface Gallery_Error_Response {type: "error"; message: string; field_errors?: Record<string, string>;}
export interface Gallery_Case_State {type: "case_state" | "refresh_state"; case?: string; frame_mode?: {strategy?: string} | string; controls?: {resources?: Gallery_Resource[]; observers?: Gallery_Renderable_Observer[]; render_plan?: Gallery_Render_Plan; performance_capture?: Gallery_Performance_Capture}; actions?: {data?: Gallery_Action[]}; telemetry?: Gallery_Telemetry; notice?: string;}
export interface Gallery_Observer_State {type: "observer_state"; telemetry: Gallery_Telemetry;}
export type Gallery_Response = Gallery_Catalog | Gallery_Error_Response | Gallery_Case_State | Gallery_Observer_State;
@@ -0,0 +1,21 @@
export const PIXEL_FRAME_HEADER_SIZE = 16;
export interface Pixel_Frame {width: number; height: number; stride: number; pixels: Uint8Array;}
export function decode_pixel_frame(buffer: ArrayBuffer): Pixel_Frame {
if (buffer.byteLength < PIXEL_FRAME_HEADER_SIZE) throw new Error("RVP1 frame is shorter than its header");
const bytes = new Uint8Array(buffer);
if (String.fromCharCode(...bytes.subarray(0, 4)) !== "RVP1") throw new Error("RVP1 magic is invalid");
const view = new DataView(buffer, 0, PIXEL_FRAME_HEADER_SIZE);
const width = view.getUint32(4, true), height = view.getUint32(8, true), stride = view.getUint32(12, true);
if (!width || !height || stride < width * 4) throw new Error("RVP1 dimensions or stride are invalid");
const payload_size = stride * height;
if (!Number.isSafeInteger(payload_size) || buffer.byteLength < PIXEL_FRAME_HEADER_SIZE + payload_size) throw new Error("RVP1 pixel payload is truncated");
return {width, height, stride, pixels: bytes.subarray(PIXEL_FRAME_HEADER_SIZE, PIXEL_FRAME_HEADER_SIZE + payload_size)};
}
export function pack_pixel_rows(frame: Pixel_Frame): Uint8ClampedArray {
const row_size = frame.width * 4;
if (frame.stride === row_size) return new Uint8ClampedArray(frame.pixels.buffer, frame.pixels.byteOffset, row_size * frame.height);
const packed = new Uint8ClampedArray(row_size * frame.height);
for (let row = 0; row < frame.height; row++) packed.set(frame.pixels.subarray(row * frame.stride, row * frame.stride + row_size), row * row_size);
return packed;
}
@@ -0,0 +1,19 @@
import type {Gallery_Client_Performance} from "../protocol/gallery_types";
interface Timed_Sample {timestamp: number; value: number;}
interface Statistics {average: number; deviation: number; p50: number; p95: number; p99: number;}
function statistics(values: number[]): Statistics {if (!values.length) return {average: 0, deviation: 0, p50: 0, p95: 0, p99: 0}; const sorted = values.toSorted((left, right) => left - right); const average = sorted.reduce((sum, value) => sum + value, 0) / sorted.length; const deviation = Math.sqrt(sorted.reduce((sum, value) => sum + (value - average) ** 2, 0) / sorted.length); const percentile = (ratio: number) => sorted[Math.max(0, Math.ceil(ratio * sorted.length) - 1)]; return {average, deviation, p50: percentile(.5), p95: percentile(.95), p99: percentile(.99)};}
function trim(samples: Timed_Sample[], after: number): void {while (samples.length && samples[0].timestamp < after) samples.shift();}
function rate(samples: Timed_Sample[], now: number): number {trim(samples, now - 1000); return samples.length < 2 ? 0 : (samples.length - 1) * 1000 / Math.max(1, samples.at(-1)!.timestamp - samples[0].timestamp);}
export class Client_Performance {
private transport_samples: Timed_Sample[] = []; private presentation_samples: Timed_Sample[] = []; private display_samples: Timed_Sample[] = []; private round_trip_samples: Timed_Sample[] = [];
private last_animation_frame = 0; private last_pixel_receive = 0; private last_pixel_change = 0; private previous_signature: number | null = null;
private changed_frames = 0; private duplicate_frames = 0; private overwritten_frames = 0; private timeout_count = 0;
record_animation_frame(now: number): void {if (this.last_animation_frame) this.display_samples.push({timestamp: now, value: Math.max(0, now - this.last_animation_frame)}); this.last_animation_frame = now; trim(this.display_samples, now - 10_000);}
record_pixel(now: number, signature: number, overwritten: boolean): void {this.transport_samples.push({timestamp: now, value: 0}); this.last_pixel_receive = now; if (signature !== this.previous_signature) {this.changed_frames++; this.last_pixel_change = now;} else this.duplicate_frames++; this.previous_signature = signature; if (overwritten) this.overwritten_frames++;}
record_presentation(now: number): void {this.presentation_samples.push({timestamp: now, value: 0});}
record_round_trip(now: number, value: number): void {this.round_trip_samples.push({timestamp: now, value}); trim(this.round_trip_samples, now - 10_000);}
record_timeout(): void {this.timeout_count++;}
reset(): void {this.transport_samples=[]; this.presentation_samples=[]; this.display_samples=[]; this.round_trip_samples=[]; this.last_animation_frame=0; this.last_pixel_receive=0; this.last_pixel_change=0; this.previous_signature=null; this.changed_frames=0; this.duplicate_frames=0; this.overwritten_frames=0; this.timeout_count=0;}
snapshot(now: number, websocket_buffered_bytes: number): Gallery_Client_Performance {trim(this.round_trip_samples, now - 10_000); trim(this.display_samples, now - 10_000); const display=statistics(this.display_samples.map(item=>item.value)); const round_trip=statistics(this.round_trip_samples.map(item=>item.value)); return {transport_fps:rate(this.transport_samples,now),presentation_fps:rate(this.presentation_samples,now),display_interval_latest_ms:this.display_samples.at(-1)?.value??0,display_interval_ms:display.p50,display_interval_average_ms:display.average,display_interval_p95_ms:display.p95,display_interval_p99_ms:display.p99,display_interval_deviation_ms:display.deviation,frame_round_trip_ms:this.round_trip_samples.at(-1)?.value??0,frame_round_trip_average_ms:round_trip.average,frame_round_trip_p95_ms:round_trip.p95,frame_round_trip_p99_ms:round_trip.p99,frame_round_trip_deviation_ms:round_trip.deviation,changed_pixel_frames:this.changed_frames,duplicate_pixel_frames:this.duplicate_frames,overwritten_pixel_frames:this.overwritten_frames,frame_request_timeout_count:this.timeout_count,last_pixel_receive_age_ms:this.last_pixel_receive?Math.max(0,now-this.last_pixel_receive):0,last_pixel_change_age_ms:this.last_pixel_change?Math.max(0,now-this.last_pixel_change):0,websocket_buffered_bytes};}
}
@@ -0,0 +1,9 @@
export const FRAME_TIMEOUT_MS = 1500;
export class Frame_Request_Controller {
private pending = false; private started_at = 0; private timeout: number | undefined;
constructor(private readonly send_frame: () => boolean, private readonly on_timeout: () => void, private readonly on_round_trip: (now: number, value: number) => void) {}
request_frame(now: number): boolean {if (this.pending || !this.send_frame()) return false; this.pending=true; this.started_at=now; this.timeout=window.setTimeout(()=>{this.pending=false;this.started_at=0;this.timeout=undefined;this.on_timeout();},FRAME_TIMEOUT_MS); return true;}
receive_frame(now: number): void {if (!this.pending) return; this.pending=false; window.clearTimeout(this.timeout); this.timeout=undefined; this.on_round_trip(now,Math.max(0,now-this.started_at)); this.started_at=0;}
cancel(): void {this.pending=false;this.started_at=0;window.clearTimeout(this.timeout);this.timeout=undefined;}
is_pending(): boolean {return this.pending;}
}
@@ -0,0 +1,8 @@
import {decode_pixel_frame, pack_pixel_rows, type Pixel_Frame} from "../protocol/pixel_frame";
export class Pixel_Presenter {
private latest_frame: Pixel_Frame | null = null; private canvas: HTMLCanvasElement | null = null; private context: CanvasRenderingContext2D | null = null;
attach(canvas: HTMLCanvasElement): void {this.canvas=canvas; this.context=canvas.getContext("2d",{alpha:false});}
detach(): void {this.canvas=null;this.context=null;this.latest_frame=null;}
accept(buffer: ArrayBuffer): {signature: number; overwritten: boolean} {const frame=decode_pixel_frame(buffer); let hash=(2166136261^frame.width^(frame.height<<16))>>>0; const count=Math.min(4096,frame.pixels.length); for(let index=0;index<count;index++){const offset=count<=1?0:Math.floor(index*(frame.pixels.length-1)/(count-1));hash=Math.imul(hash^frame.pixels[offset],16777619)>>>0;} const overwritten=this.latest_frame!==null;this.latest_frame=frame;return {signature:hash,overwritten};}
present(): boolean {const frame=this.latest_frame, canvas=this.canvas, context=this.context;if(!frame||!canvas||!context)return false;this.latest_frame=null;if(canvas.width!==frame.width||canvas.height!==frame.height){canvas.width=frame.width;canvas.height=frame.height;}const pixels=new Uint8ClampedArray(frame.width*frame.height*4);pixels.set(pack_pixel_rows(frame));context.putImageData(new ImageData(pixels,frame.width,frame.height),0,0);return true;}
}
@@ -0,0 +1,45 @@
import {build_controls} from "../protocol/gallery_descriptor";
import {parse_gallery_response} from "../protocol/gallery_parser";
import type {Gallery_Case, Gallery_Frame_Mode, Gallery_Performance_Capture, Gallery_Render_Plan, Json_Primitive, Json_Value} from "../protocol/gallery_types";
import {Gallery_Socket, gallery_socket_url} from "../transport/gallery_socket";
import {Client_Performance} from "../runtime/client_performance";
import {Frame_Request_Controller} from "../runtime/frame_request_controller";
import {Pixel_Presenter} from "../runtime/pixel_presenter";
import {EMPTY_PLOT_SNAPSHOT, type Gallery_Plot_Snapshot} from "./gallery_plot_snapshot";
export class Gallery_Plot_Session {
private snapshot: Gallery_Plot_Snapshot = EMPTY_PLOT_SNAPSHOT;
private readonly listeners = new Set<() => void>();
private readonly socket: Gallery_Socket;
private readonly presenter = new Pixel_Presenter();
private readonly client_performance = new Client_Performance();
private readonly frame_controller: Frame_Request_Controller;
private disposed = false; private visible = false; private streams_paused = false; private reconnect_timer: number | undefined; private last_observe_at = 0; private sent_width = 0; private sent_height = 0; private received_frames = 0;
constructor(readonly gallery_case: Gallery_Case, readonly frame_mode: Gallery_Frame_Mode) {
this.socket = new Gallery_Socket(gallery_socket_url(), {on_json: raw=>this.receive_json(raw),on_binary: buffer=>this.receive_pixels(buffer),on_state: state=>this.receive_socket_state(state)});
this.frame_controller = new Frame_Request_Controller(()=>this.socket.send("frame"),()=>{this.client_performance.record_timeout();if(this.stream_active())this.request_frame(performance.now());},(now,value)=>this.client_performance.record_round_trip(now,value));
}
subscribe = (listener: () => void): (() => void) => {this.listeners.add(listener);return()=>this.listeners.delete(listener);};
get_snapshot = (): Gallery_Plot_Snapshot => this.snapshot;
private update(patch: Partial<Gallery_Plot_Snapshot>): void {this.snapshot={...this.snapshot,...patch};this.listeners.forEach(listener=>listener());}
connect(): void {if(this.disposed)return;window.clearTimeout(this.reconnect_timer);this.socket.connect();}
dispose(): void {this.disposed=true;window.clearTimeout(this.reconnect_timer);this.frame_controller.cancel();if(this.snapshot.ready)this.socket.send("hide");this.socket.close();this.presenter.detach();this.listeners.clear();}
attach_canvas(canvas: HTMLCanvasElement): void {this.presenter.attach(canvas);}
detach_canvas(): void {this.presenter.detach();}
set_activity(visible: boolean, streams_paused: boolean): void {const was_active=this.stream_active();this.visible=visible;this.streams_paused=streams_paused;if(visible&&!this.socket.is_open())this.connect();const active=this.stream_active();if(this.snapshot.ready&&active!==was_active)this.socket.send(active?"show":"hide");if(active)this.request_frame(performance.now());}
private stream_active(): boolean {return this.visible&&!this.streams_paused;}
private receive_socket_state(state: "connecting"|"ready"|"error"|"closed"): void {if(this.disposed)return;if(state==="ready"){this.update({connection_state:"ready",protocol_error:""});this.socket.send("gallery_open",{case:this.gallery_case.id,frame_mode:this.frame_mode.id});}else{this.update({connection_state:state,ready:state==="error"?this.snapshot.ready:false});if(state==="closed"&&this.visible)this.reconnect_timer=window.setTimeout(()=>this.connect(),1000);}}
private receive_json(raw: string): void {try{const response=parse_gallery_response(raw);if(!response)return;if(response.type==="error"){this.update({notice:Object.values(response.field_errors??{})[0]??response.message});return;}if(response.type==="observer_state"){this.update({telemetry:response.telemetry,performance_capture:response.telemetry.performance_capture??this.snapshot.performance_capture,frame_count:this.received_frames});return;}if(response.type==="case_state"||response.type==="refresh_state"){const controls=response.controls;this.update({ready:true,controls:build_controls(controls?.resources),actions:response.actions?.data??[],telemetry:response.telemetry??{},render_plan:controls?.render_plan??null,performance_capture:controls?.performance_capture??response.telemetry?.performance_capture??null,notice:response.notice??"",frame_count:this.received_frames,protocol_error:""});if(this.stream_active()){this.socket.send("show");this.request_frame(performance.now());}}}catch(error){this.update({connection_state:"error",protocol_error:error instanceof Error?error.message:"协议解析失败"});this.socket.close();}}
private receive_pixels(buffer: ArrayBuffer): void {try{const now=performance.now();this.frame_controller.receive_frame(now);const accepted=this.presenter.accept(buffer);this.received_frames++;this.client_performance.record_pixel(now,accepted.signature,accepted.overwritten);if(this.frame_mode.request_after_response&&this.stream_active())this.request_frame(now);}catch(error){this.update({connection_state:"error",protocol_error:error instanceof Error?error.message:"像素协议错误"});this.socket.close();}}
tick(now: number): void {if(!this.stream_active())return;if(this.presenter.present())this.client_performance.record_presentation(now);if(this.frame_mode.request_on_animation_frame)this.request_frame(now);if(this.snapshot.ready&&now-this.last_observe_at>=650){this.last_observe_at=now;this.socket.send("gallery_observe",{client_metrics:this.client_performance.snapshot(now,this.socket.buffered_bytes()) as unknown as Json_Value});}}
request_frame(now=performance.now(), explicit=false): boolean {if(!this.snapshot.ready||!this.socket.is_open()||(!explicit&&!this.stream_active())||(explicit&&!this.visible)||(!explicit&&!this.frame_mode.automatic))return false;return this.frame_controller.request_frame(now);}
resize(width: number,height: number): void {width=Math.max(240,Math.round(width));height=Math.max(180,Math.round(height));if(width===this.sent_width&&height===this.sent_height)return;this.sent_width=width;this.sent_height=height;if(this.socket.is_open())this.socket.send("resize",{width,height});}
pointer(type: "pointer_move"|"pointer_press"|"pointer_release", payload: Record<string, Json_Value>): void {this.socket.send(type,payload);}
leave(): void {this.socket.send("leave");}
wheel(payload: Record<string, Json_Value>): void {this.socket.send("wheel",payload);}
key(type: "key_press"|"key_release",payload: Record<string, Json_Value>): void {this.socket.send(type,payload);}
patch(target: string,patch: Record<string, Json_Value>): void {this.socket.send("gallery_patch",{target,patch});}
action(action: string,argument?: Json_Primitive): void {this.socket.send("gallery_action",argument===undefined?{action}:{action,argument});}
refresh(): void {this.socket.send("gallery_refresh",{client_metrics:this.client_performance.snapshot(performance.now(),this.socket.buffered_bytes()) as unknown as Json_Value});}
reset_monitoring(): void {this.client_performance.reset();this.socket.send("gallery_reset_monitoring");}
}
@@ -0,0 +1,3 @@
import type {Connection_State, Gallery_Action, Gallery_Control, Gallery_Performance_Capture, Gallery_Render_Plan, Gallery_Telemetry} from "../protocol/gallery_types";
export interface Gallery_Plot_Snapshot {connection_state: Connection_State; ready: boolean; controls: Gallery_Control[]; actions: Gallery_Action[]; telemetry: Gallery_Telemetry; render_plan: Gallery_Render_Plan | null; performance_capture: Gallery_Performance_Capture | null; notice: string; frame_count: number; protocol_error: string;}
export const EMPTY_PLOT_SNAPSHOT: Gallery_Plot_Snapshot = {connection_state:"connecting",ready:false,controls:[],actions:[],telemetry:{},render_plan:null,performance_capture:null,notice:"",frame_count:0,protocol_error:""};
+2
View File
@@ -0,0 +1,2 @@
import {createTheme} from "@mui/material/styles";
export const gallery_theme=createTheme({palette:{mode:"dark",primary:{main:"#59d6c5"},secondary:{main:"#ffbd69"},background:{default:"#07111f",paper:"#0d1b2d"}},shape:{borderRadius:12},typography:{fontFamily:'Inter,"Segoe UI","Microsoft YaHei",sans-serif',h1:{fontWeight:800},h2:{fontWeight:750},button:{textTransform:"none",fontWeight:700}},components:{MuiPaper:{styleOverrides:{root:{backgroundImage:"none",border:"1px solid rgba(132,179,206,.16)"}}},MuiButton:{defaultProps:{disableElevation:true}}}});
@@ -0,0 +1,25 @@
import {parse_gallery_response} from "../protocol/gallery_parser";
import {Gallery_Socket, gallery_socket_url} from "./gallery_socket";
import type {Gallery_Catalog} from "../protocol/gallery_types";
export function load_gallery_catalog(on_catalog: (catalog: Gallery_Catalog) => void, on_state: (state: string, message: string) => void): () => void {
let disposed = false, completed = false, reconnect_timer: number | undefined;
let socket: Gallery_Socket;
const connect = () => {
socket = new Gallery_Socket(gallery_socket_url(), {
on_state: state => {
if (disposed) return;
if (state === "ready") socket.send("gallery_catalog");
else if (state === "error") on_state("error", "目录连接错误 · 自动重连");
else if (state === "closed" && !completed) reconnect_timer = window.setTimeout(connect, 1000);
},
on_binary: () => undefined,
on_json: raw => {
try {const response = parse_gallery_response(raw); if (response?.type === "catalog") {completed = true; on_catalog(response); on_state("ready", response.navigation.catalog_loaded_text); socket.close();} else if (response?.type === "error") on_state("error", response.message);} catch (error) {on_state("error", error instanceof Error ? error.message : "目录解析失败");}
}
});
socket.connect();
};
connect();
return () => {disposed = true; window.clearTimeout(reconnect_timer); socket?.close();};
}
@@ -0,0 +1,29 @@
import {gallery_message} from "../protocol/gallery_messages";
import type {Json_Value} from "../protocol/gallery_types";
export interface Gallery_Socket_Events {on_json(raw: string): void; on_binary(buffer: ArrayBuffer): void; on_state(state: "connecting" | "ready" | "error" | "closed"): void;}
export function gallery_socket_url(location_value: Location = window.location): string {
const query = new URLSearchParams(location_value.search);
const hosted = location_value.protocol !== "file:" && ["/", "/index.html", "/gallery", "/gallery/"].includes(location_value.pathname);
const preview = location_value.port === "63342";
const port = query.get("port") || (hosted && !preview ? location_value.port : "8848") || "8848";
const host = query.get("host") || (hosted ? location_value.hostname : "127.0.0.1") || "127.0.0.1";
return `${location_value.protocol === "https:" ? "wss" : "ws"}://${host}:${port}/renderive/gallery`;
}
export class Gallery_Socket {
private socket: WebSocket | null = null;
constructor(private readonly url: string, private readonly events: Gallery_Socket_Events) {}
connect(): void {
if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) return;
this.events.on_state("connecting");
const socket = new WebSocket(this.url); this.socket = socket; socket.binaryType = "arraybuffer";
socket.addEventListener("open", () => {if (this.socket === socket) this.events.on_state("ready");});
socket.addEventListener("message", event => {if (this.socket !== socket) return; typeof event.data === "string" ? this.events.on_json(event.data) : event.data instanceof ArrayBuffer && this.events.on_binary(event.data);});
socket.addEventListener("error", () => {if (this.socket === socket) this.events.on_state("error");});
socket.addEventListener("close", () => {if (this.socket === socket) {this.socket = null; this.events.on_state("closed");}});
}
close(): void {const socket = this.socket; this.socket = null; socket?.close();}
send(type: string, payload: Record<string, Json_Value> = {}): boolean {if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return false; this.socket.send(gallery_message(type, payload)); return true;}
buffered_bytes(): number {return this.socket?.bufferedAmount ?? 0;}
is_open(): boolean {return this.socket?.readyState === WebSocket.OPEN;}
}
-87
View File
@@ -1,87 +0,0 @@
:root {
color-scheme: dark;
font-family: Inter, "Segoe UI Variable", "Microsoft YaHei UI", sans-serif;
--bg: #07090d; --panel: #10141b; --panel2: #151b24; --line: #29313d;
--strong: #3b4655; --text: #f2f5f8; --muted: #929eac; --accent: #45ddbe;
--blue: #6aa9ff; --warning: #ffd166; --danger: #ff6885;
}
* { box-sizing: border-box; }
html { background: var(--bg); }
body {
margin: 0; min-width: 320px; min-height: 100vh; color: var(--text);
background: linear-gradient(rgba(255,255,255,.018) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.018) 1px, transparent 1px),
radial-gradient(circle at 16% -10%, rgba(69,221,190,.12), transparent 34rem), var(--bg);
background-size: 24px 24px, 24px 24px, auto, auto;
}
button, input, select { font: inherit; } button { color: inherit; }
h1, h2, h3, p { margin-top: 0; }
.eyebrow { margin: 0 0 5px; color: var(--accent); font: 700 10px/1.2 ui-monospace, monospace; letter-spacing: .15em; text-transform: uppercase; }
.topbar { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 22px; min-height: 76px; padding: 12px clamp(18px,4vw,56px); border-bottom: 1px solid #ffffff14; background: #07090dea; backdrop-filter: blur(20px); }
.brand, .topbar-actions { display: flex; align-items: center; gap: 14px; }
.brand-mark { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 12px; color: #07110f; background: var(--accent); font: 800 16px/1 ui-monospace, monospace; }
h1 { margin: 0; font-size: clamp(17px,2vw,23px); }
.connection-status, .card-socket { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font: 600 11px/1 ui-monospace, monospace; }
.connection-status i, .card-socket i { width: 8px; height: 8px; border-radius: 50%; background: var(--warning); box-shadow: 0 0 12px currentColor; }
[data-state="ready"] i { background: var(--accent); } [data-state="error"] i { background: var(--danger); }
.card-health { display:flex; align-items:flex-end; flex-direction:column; gap:7px; }
.motion-status { display:inline-flex; align-items:center; gap:6px; color:var(--muted); font:600 9px/1 ui-monospace,monospace; }
.motion-status i { width:6px; height:6px; border-radius:50%; background:var(--warning); }
.motion-status[data-state="moving"] { color:var(--accent); }.motion-status[data-state="moving"] i { background:var(--accent); box-shadow:0 0 10px var(--accent); animation:motion-pulse .9s ease-in-out infinite alternate; }
.motion-status[data-state="duplicate"] { color:var(--warning); }.motion-status[data-state="duplicate"] i { background:var(--warning); }
.motion-status[data-state="stalled"] { color:var(--danger); }.motion-status[data-state="stalled"] i { background:var(--danger); }
@keyframes motion-pulse { to { opacity:.35; transform:scale(.72); } }
.quiet-button, .open-menu, .frame-button, .icon-button, .menu-tabs button, .menu-refresh-bar button, .category-filter button, .mode-tabs button, .action-button { border: 1px solid var(--line); border-radius: 8px; background: var(--panel2); cursor: pointer; transition: .16s border-color,.16s background,.16s transform; }
button:hover { border-color: var(--accent); } button:active { transform: translateY(1px); }
.quiet-button { padding: 9px 13px; font-size: 12px; }
.hero { display: grid; grid-template-columns: minmax(300px,1fr) minmax(430px,.9fr); align-items: end; gap: 40px; max-width: 1680px; margin: auto; padding: clamp(32px,5vw,64px) clamp(18px,4vw,56px) 28px; }
.hero h2 { margin-bottom: 10px; font-size: clamp(27px,4vw,47px); letter-spacing: -.045em; }
.hero > div > p:last-child { margin: 0; max-width: 750px; color: var(--muted); line-height: 1.7; }
.metrics { display: grid; grid-template-columns: repeat(4,1fr); gap: 1px; margin: 0; border: 1px solid var(--line); background: var(--line); }
.metrics div { padding: 17px; background: var(--panel); } .metrics dt { color: var(--text); font: 750 clamp(20px,3vw,33px)/1 ui-monospace,monospace; } .metrics dd { margin: 8px 0 0; color: var(--muted); font-size: 10px; }
.mode-tabs { display: grid; grid-template-columns: repeat(auto-fit,minmax(220px,1fr)); gap: 10px; max-width: 1680px; margin: auto; padding: 0 clamp(18px,4vw,56px) 18px; }
.mode-tabs button { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; color: var(--muted); text-align: left; }
.mode-tabs button span { color: var(--text); font-weight: 750; } .mode-tabs button code { font-size: 10px; }
.mode-tabs button.active { border-color: var(--accent); background: #123029; box-shadow: inset 0 0 0 1px #45ddbe35; }
.category-filter { display: flex; gap: 7px; overflow-x: auto; max-width: 1680px; margin: auto; padding: 0 clamp(18px,4vw,56px) 20px; }
.category-filter button { flex: 0 0 auto; padding: 8px 11px; color: var(--muted); font-size: 11px; }
.category-filter button.active { color: #06110e; border-color: var(--accent); background: var(--accent); }
.pages, .mode-page { max-width: 1680px; margin: auto; }
.mode-page[hidden] { display: none; }
.page-header { display: flex; align-items: end; justify-content: space-between; gap: 24px; padding: 3px clamp(18px,4vw,56px) 18px; }
.page-header h2 { margin: 0; font-size: 23px; }.page-strategy { margin-bottom: 5px; color: var(--accent); font: 700 10px/1 ui-monospace,monospace; }.page-description { max-width: 650px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; text-align: right; }
.gallery { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 18px; padding: 0 clamp(18px,4vw,56px) 70px; }
.loading-card { display: grid; place-items: center; gap: 12px; min-height: 360px; margin: 0 clamp(18px,4vw,56px); border: 1px dashed var(--strong); color: var(--muted); }
.loader { width: 18px; height: 18px; border: 2px solid var(--strong); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } }
.plot-card { min-width: 0; overflow: hidden; border: 1px solid var(--line); border-top-color: var(--mode-accent); border-radius: 13px; background: linear-gradient(180deg,#ffffff08,transparent 30%),var(--panel); box-shadow: 0 20px 55px #0000002e; }
.plot-card[hidden] { display:none; }
.card-header, .card-footer { display:flex; align-items:center; justify-content:space-between; gap:15px; padding:13px 15px; }.card-header { border-bottom:1px solid var(--line); }.card-category { display:block; margin-bottom:4px; color:var(--blue); font:700 9px/1 ui-monospace,monospace; letter-spacing:.1em; }.card-title { margin:0; font-size:17px; }
.canvas-shell { position:relative; height:clamp(245px,27vw,350px); outline:none; background:#060a11; cursor:crosshair; }.canvas-shell:focus-visible { box-shadow:inset 0 0 0 2px var(--accent); }.canvas-shell canvas { display:block; width:100%; height:100%; }.canvas-hint { position:absolute; right:9px; bottom:8px; padding:5px 7px; border:1px solid #ffffff1e; border-radius:5px; color:#ffffffa8; background:#04070bc9; font:9px/1 ui-monospace,monospace; pointer-events:none; }.canvas-loading { position:absolute; inset:0; display:grid; place-content:center; justify-items:center; gap:9px; color:var(--muted); background:#080d15; }.plot-card[data-ready="true"] .canvas-loading { display:none; }
.performance-strip { display:grid; grid-template-columns:repeat(auto-fit,minmax(105px,1fr)); gap:1px; margin:0; border-block:1px solid var(--line); background:var(--line); }.performance-strip div { min-width:0; padding:9px 8px; background:#0c1118; }.performance-strip dt { min-width:0; overflow:visible; color:var(--accent); font:700 11px/1.35 ui-monospace,monospace; text-overflow:clip; white-space:normal; overflow-wrap:anywhere; word-break:break-word; }.performance-strip [data-format="text"] dt,.performance-strip [data-format="duration_enum"] dt { font-size:10px; }.performance-strip dd { margin:5px 0 0; color:var(--muted); font-size:8px; line-height:1.35; white-space:normal; overflow-wrap:anywhere; word-break:break-word; }
.limit-flags { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; padding:7px 10px; border-bottom:1px solid var(--line); color:var(--muted); background:#090e15; font:9px/1.35 ui-monospace,monospace; }.limit-flags strong { grid-column:1/-1; color:var(--text); }.limit-flags span { min-width:0; padding:6px 7px; border:1px solid var(--line); border-radius:4px; line-height:1.45; white-space:normal; overflow-wrap:anywhere; word-break:break-word; }.limit-flags span[data-active="true"] { border-color:#ffb84d88; color:#ffcf82; background:#ff9d1712; }.limit-flags b { color:inherit; white-space:normal; overflow-wrap:anywhere; }.plot-card[data-observer-visible="false"] .limit-flags { display:none; }
.kernel-observer-panel { border-bottom:1px solid var(--line); background:#080d14; font-family:ui-monospace,monospace; }
.kernel-observer-panel > header { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:9px 11px; border-bottom:1px solid var(--line); color:var(--muted); font-size:8px; flex-wrap:wrap; }
.kernel-observer-panel > header div:first-child { display:flex; align-items:center; gap:9px; min-width:0; flex-wrap:wrap; }.kernel-observer-panel > header span { color:var(--accent); letter-spacing:.08em; overflow-wrap:anywhere; }.kernel-observer-panel > header strong,.kernel-observer-panel > header b { color:var(--text); font-weight:700; overflow-wrap:anywhere; }
.observer-counters,.latency-summary,.client-summary,.latency-details { display:grid; gap:1px; background:var(--line); }
.observer-counters { grid-template-columns:repeat(auto-fit,minmax(90px,1fr)); }.latency-summary,.client-summary { grid-template-columns:repeat(auto-fit,minmax(105px,1fr)); border-top:1px solid var(--line); }.latency-details { grid-template-columns:repeat(auto-fit,minmax(145px,1fr)); border-top:1px solid var(--line); }
.observer-counters div,.latency-summary div,.client-summary div,.latency-details div { min-width:0; padding:8px 7px; background:#0b1119; }
.kernel-observer-panel small { display:block; min-width:0; overflow:visible; margin-bottom:5px; color:var(--muted); font-size:7px; line-height:1.35; text-overflow:clip; white-space:normal; overflow-wrap:anywhere; word-break:break-word; }
.kernel-observer-panel b { display:block; min-width:0; overflow:visible; color:#b7c7dc; font-size:9px; line-height:1.35; text-overflow:clip; white-space:normal; overflow-wrap:anywhere; word-break:break-word; }
.latency-summary b { color:var(--accent); font-size:10px; }.latency-summary .critical { background:#10201e; }.latency-summary .critical b { color:#72f3d9; }
.plot-card[data-observer-visible="false"] .kernel-observer-panel { display:none; }
.card-meta { display:grid; grid-template-columns:1fr auto; gap:17px; padding:13px 15px; }.card-description { margin:0; color:var(--muted); font-size:11px; line-height:1.55; }.card-meta dl { display:flex; margin:0; }.card-meta dl div { min-width:52px; padding-left:11px; border-left:1px solid var(--line); }.card-meta dt { font:700 14px/1 ui-monospace,monospace; }.card-meta dd { margin:5px 0 0; color:var(--muted); font-size:9px; }
.card-footer { border-top:1px solid var(--line); background:#0000001e; }.card-footer code { color:var(--accent); font-size:10px; }.card-footer > div { display:flex; gap:7px; }.open-menu,.frame-button { padding:7px 9px; color:var(--muted); font-size:10px; }.frame-button { color:var(--text); }
.context-menu { position:fixed; z-index:100; width:min(570px,calc(100vw - 24px)); max-height:min(840px,calc(100vh - 24px)); overflow:hidden; border:1px solid var(--strong); border-radius:12px; background:#0f131afa; box-shadow:0 30px 90px #0000009e; backdrop-filter:blur(22px); }.context-menu[hidden] { display:none; }
.menu-header { display:flex; justify-content:space-between; gap:18px; padding:16px 18px 13px; border-bottom:1px solid var(--line); }.menu-header h2 { margin-bottom:6px; font-size:19px; }.menu-header p:last-child { margin:0; color:var(--muted); font-size:10px; line-height:1.5; }.icon-button { flex:0 0 auto; width:31px; height:31px; font-size:20px; }
.menu-tabs { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); padding:8px; border-bottom:1px solid var(--line); }.menu-tabs button { min-width:0; padding:8px 3px; border-color:transparent; color:var(--muted); background:transparent; font-size:9px; white-space:normal; }.menu-tabs button.active { color:var(--text); border-color:var(--strong); background:var(--panel2); }
.menu-refresh-bar { display:flex; justify-content:flex-end; gap:8px; padding:8px 12px 0; }.menu-refresh-bar button { min-width:112px; padding:7px 10px; color:var(--accent); font-size:10px; }.menu-refresh-bar button:disabled { cursor:wait; opacity:.55; }
.menu-body { max-height:calc(min(840px,100vh - 24px) - 232px); overflow:auto; padding:12px; overscroll-behavior:contain; }.control-group { border-bottom:1px solid var(--line); }.control-group + .control-group { margin-top:8px; }.control-group > summary { position:sticky; z-index:2; top:-12px; display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:38px; padding:9px 8px; color:var(--accent); background:#111720f8; cursor:pointer; font:700 10px/1.35 ui-monospace,monospace; list-style-position:inside; }.control-group > summary span { min-width:0; overflow-wrap:anywhere; }.control-group > summary small { flex:0 0 auto; color:var(--muted); font-size:8px; font-weight:600; }.control-group[open] > summary { border-bottom:1px solid var(--line); color:var(--text); }
.control-row { display:grid; grid-template-columns:minmax(145px,1fr) minmax(130px,.75fr); gap:13px; align-items:center; min-height:49px; padding:8px 9px; border-top:1px solid #ffffff0e; }.control-copy label,.action-copy strong { display:block; margin-bottom:4px; font-size:11px; }.control-copy code,.action-copy code { display:block; overflow:hidden; color:var(--muted); font-size:9px; text-overflow:ellipsis; white-space:nowrap; }.control-input { width:100%; min-width:0; padding:7px 8px; border:1px solid var(--strong); border-radius:6px; outline:none; color:var(--text); background:#090d13; font-size:10px; }.control-input:focus { border-color:var(--accent); }input[type="color"].control-input { height:34px; padding:3px; }input[type="checkbox"].control-input { justify-self:end; width:38px; height:20px; accent-color:var(--accent); }
.action-row { display:flex; align-items:center; justify-content:space-between; gap:13px; padding:10px 9px; border-top:1px solid #ffffff0e; }.action-copy { min-width:0; }.action-controls { display:flex; gap:7px; align-items:center; }.action-controls input { width:105px; }.action-button { padding:7px 10px; color:#07110f; border-color:var(--accent); background:var(--accent); font-size:10px; font-weight:700; }
.telemetry-grid { display:grid; grid-template-columns:minmax(165px,.7fr) 1fr; margin:0; border:1px solid var(--line); }.telemetry-grid dt,.telemetry-grid dd { margin:0; padding:8px 10px; border-bottom:1px solid var(--line); font:10px/1.4 ui-monospace,monospace; overflow-wrap:anywhere; }.telemetry-grid dt { color:var(--muted); background:#ffffff06; }.telemetry-grid dd { color:var(--accent); }
.descriptor-group .telemetry-grid { border:0; }
.performance-capture-view,.render-plan-view{display:grid;gap:12px;min-width:0}.capture-section{min-width:0;padding:12px;border:1px solid var(--line);border-radius:8px;background:#0b1017}.capture-section>h3{margin:0 0 10px;color:#dce7f4;font-size:12px;letter-spacing:.06em;text-transform:uppercase}.capture-section>h4{margin:12px 0 7px;color:#9fb2c8;font-size:11px}.capture-control-row,.capture-browser-selectors,.capture-plan-selectors{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.capture-control-row button,.capture-frame-list button,.capture-plan-selectors select,.capture-browser-selectors select{border:1px solid #41556e;border-radius:5px;background:#121b26;color:#e9eef5;padding:7px 9px}.capture-control-row input{width:78px;border:1px solid #41556e;border-radius:5px;background:#090e15;color:#fff;padding:7px}.capture-control-row button:disabled{opacity:.45}.capture-progress{margin-top:9px;color:#91a5bb;font:11px ui-monospace,monospace}.capture-progress[data-active="true"]{color:#45ddbe}.capture-frame-list{display:grid;gap:5px;margin-top:9px;max-height:190px;overflow:auto}.capture-frame-list button{text-align:left;font:11px ui-monospace,monospace}.capture-frame-list button[data-selected="true"]{border-color:#45ddbe;background:#10251f}.capture-cache{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:7px}.capture-cache>h3{grid-column:1/-1}.capture-cache-card{display:grid;gap:4px;padding:8px;border:1px solid #2a394c;border-radius:5px;background:#0e151e}.capture-cache-card strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.capture-cache-card span{color:#91a5bb;font:10px ui-monospace,monospace}.capture-dag-section,.capture-timeline-section{overflow:auto}.capture-dag,.capture-timeline{display:block;min-width:850px;width:100%;height:auto}.capture-dag-heading{fill:#91a5bb;font:bold 11px ui-monospace,monospace;letter-spacing:.12em}.capture-dag-edge{fill:none;stroke:#536a84;stroke-width:1.4;opacity:.75}.capture-dag-node{cursor:pointer;outline:none}.capture-dag-node rect{rx:6;fill:#151d28;stroke:#45566c;stroke-width:1.2}.capture-dag-node[data-kind="prepare"] rect{fill:#10241f;stroke:#2f8873}.capture-dag-node[data-kind="paint"] rect{fill:#132037;stroke:#477bc1}.capture-dag-node[data-kind="composite"] rect{fill:#2b2110;stroke:#b98a38}.capture-dag-node[data-critical="true"] rect{stroke:#ffbb4d;stroke-width:2}.capture-dag-node[data-selected="true"] rect{stroke:#ff668a;stroke-width:3}.capture-dag-node text{fill:#e9eef5;font:10px ui-monospace,monospace;pointer-events:none}.capture-dag-node text:nth-of-type(n+2){fill:#9fb2c8}.capture-time-grid{stroke:#29384a;stroke-width:1}.capture-time-label,.capture-worker-label{fill:#91a5bb;font:9px ui-monospace,monospace}.capture-time-label{text-anchor:middle}.capture-time-block rect{fill:#3c78c2;stroke:#74a8e8;rx:3;cursor:pointer}.capture-time-block[data-selected="true"] rect{fill:#c44869;stroke:#ff8ca8}.capture-metrics{display:grid;grid-template-columns:minmax(120px,1fr) minmax(140px,1.5fr);gap:1px;margin:0;background:#233044}.capture-metrics dt,.capture-metrics dd{margin:0;padding:7px;background:#0e151e}.capture-metrics dt{color:#91a5bb}.capture-metrics dd{color:#eef4fb;font:11px ui-monospace,monospace;overflow-wrap:anywhere}.capture-frequency-list{margin-top:8px;color:#9fb2c8;font:10px ui-monospace,monospace}.capture-plan-selectors{margin-bottom:9px}.capture-plan-selectors select,.capture-browser-selectors select{min-width:145px}.capture-node-detail{border-color:#394c64}
.menu-footer { display:flex; justify-content:space-between; gap:14px; padding:10px 14px; border-top:1px solid var(--line); color:var(--muted); font-size:9px; }.menu-footer code { color:var(--accent); }
.toast { position:fixed; z-index:140; left:50%; bottom:22px; max-width:min(560px,calc(100vw - 30px)); padding:11px 15px; border:1px solid var(--strong); border-radius:8px; background:var(--panel2); box-shadow:0 18px 50px #00000073; transform:translateX(-50%); font-size:11px; }.toast[data-error="true"] { border-color:var(--danger); }
@media (max-width:980px) { .hero { grid-template-columns:1fr; }.gallery { grid-template-columns:1fr; }.mode-tabs button { flex-direction:column; align-items:flex-start; }.page-header { align-items:flex-start; flex-direction:column; }.page-description { text-align:left; } }
@media (max-width:650px) { .topbar { align-items:flex-start; }.topbar-actions { align-items:flex-end; flex-direction:column; }.connection-status span { display:none; }.metrics { grid-template-columns:repeat(2,1fr); }.mode-tabs { grid-template-columns:1fr; }.mode-tabs button { flex-direction:row; }.card-meta { grid-template-columns:1fr; }.performance-strip { grid-template-columns:repeat(auto-fit,minmax(92px,1fr)); }.limit-flags { grid-template-columns:1fr; }.observer-counters { grid-template-columns:repeat(auto-fit,minmax(80px,1fr)); }.latency-summary,.client-summary,.latency-details { grid-template-columns:repeat(auto-fit,minmax(100px,1fr)); }.context-menu { inset:auto 6px 6px!important; width:auto; max-height:calc(100vh - 12px); }.telemetry-grid { grid-template-columns:minmax(120px,.8fr) 1fr; }.menu-tabs button { font-size:8px; } }
@@ -0,0 +1,3 @@
import {fireEvent,render,screen} from "@testing-library/react";import {describe,expect,it,vi} from "vitest";import {Control_Field} from "../../src/inspector/control_field";
const control={id:"gain",target:"plot",path:["gain"],label:"Gain",api:"gain",description:"",group:"Plot",input:"number",minimum:0,maximum:10,options:[],value:3};
describe("control draft",()=>{it("does not overwrite an active draft during backend refresh",()=>{const commit=vi.fn();const view=render(<Control_Field control={control} on_commit={commit}/>);const input=screen.getByLabelText("Gain");fireEvent.focus(input);fireEvent.change(input,{target:{value:"7"}});view.rerender(<Control_Field control={{...control,value:4}} on_commit={commit}/>);expect(input).toHaveValue(7);fireEvent.blur(input);expect(commit).toHaveBeenCalledWith(7);});});
@@ -0,0 +1,2 @@
import {describe,expect,it} from "vitest";import {build_dag_model} from "../../src/dag/dag_model";
describe("DAG view model",()=>{it("uses node_id for current plan and capture overlays",()=>{const plan={version:2,nodes:[{node_id:7,name:"paint",owner:"plot",kind:"paint"}],edges:[],renderables:[]};const frame={frame_id:1,render_plan_version:2,render_duration_ns:10,node_executions:[{node_id:7,worker_id:3,start_offset_ns:0,end_offset_ns:10,duration_ns:10}],analysis:{nodes:[{node_id:7,scheduler_wait_ns:1,critical_path_contribution:1,on_critical_path:true}]}};const model=build_dag_model(plan,frame,[],7);expect(model.nodes[0].id).toBe("7");expect(model.nodes[0].data).toMatchObject({duration_ns:10,worker_id:3,critical:true,selected:true});});});
+2
View File
@@ -0,0 +1,2 @@
import {expect,test} from "@playwright/test";
test("renders the React gallery shell while catalog reconnects",async({page})=>{await page.goto("/");await expect(page.getByText("Renderive 性能画廊")).toBeVisible();await expect(page.getByText("等待后端返回控件与帧策略目录")).toBeVisible();});
@@ -0,0 +1,2 @@
import {describe,expect,it} from "vitest";import {Gallery_Protocol_Error,parse_gallery_response} from "../../src/protocol/gallery_parser";
describe("gallery parser",()=>{it("accepts known responses",()=>expect(parse_gallery_response('{"type":"observer_state","telemetry":{}}')?.type).toBe("observer_state"));it("ignores future response types",()=>expect(parse_gallery_response('{"type":"future"}')).toBeNull());it("contains malformed input at the boundary",()=>expect(()=>parse_gallery_response("{" )).toThrow(Gallery_Protocol_Error));});
@@ -0,0 +1,3 @@
import {describe,expect,it} from "vitest";import {decode_pixel_frame,pack_pixel_rows} from "../../src/protocol/pixel_frame";
function frame(width:number,height:number,stride:number,payload:number[]):ArrayBuffer {const buffer=new ArrayBuffer(16+payload.length);const bytes=new Uint8Array(buffer);bytes.set([82,86,80,49]);const view=new DataView(buffer);view.setUint32(4,width,true);view.setUint32(8,height,true);view.setUint32(12,stride,true);bytes.set(payload,16);return buffer;}
describe("RVP1 decoder",()=>{it("decodes packed RGBA",()=>{const result=decode_pixel_frame(frame(1,1,4,[1,2,3,4]));expect(result).toMatchObject({width:1,height:1,stride:4});expect([...pack_pixel_rows(result)]).toEqual([1,2,3,4]);});it("removes per-row padding",()=>{const result=decode_pixel_frame(frame(1,2,8,[1,2,3,4,90,91,92,93,5,6,7,8,94,95,96,97]));expect([...pack_pixel_rows(result)]).toEqual([1,2,3,4,5,6,7,8]);});it("rejects invalid magic, stride, and truncated payload",()=>{expect(()=>decode_pixel_frame(frame(1,1,3,[1,2,3]))).toThrow();const truncated=frame(2,2,8,[1,2,3]);expect(()=>decode_pixel_frame(truncated)).toThrow();const magic=frame(1,1,4,[1,2,3,4]);new Uint8Array(magic)[0]=0;expect(()=>decode_pixel_frame(magic)).toThrow();});});
@@ -0,0 +1,2 @@
import {describe,expect,it} from "vitest";import {Client_Performance} from "../../src/runtime/client_performance";
describe("client performance",()=>{it("tracks change, duplicate and overwrite independently",()=>{const performance=new Client_Performance();performance.record_animation_frame(10);performance.record_animation_frame(26);performance.record_pixel(30,1,false);performance.record_pixel(40,1,true);performance.record_pixel(50,2,false);performance.record_presentation(55);performance.record_round_trip(60,12);const value=performance.snapshot(70,9);expect(value.changed_pixel_frames).toBe(2);expect(value.duplicate_pixel_frames).toBe(1);expect(value.overwritten_pixel_frames).toBe(1);expect(value.display_interval_latest_ms).toBe(16);expect(value.websocket_buffered_bytes).toBe(9);});});
@@ -0,0 +1,2 @@
import {afterEach,describe,expect,it,vi} from "vitest";import {FRAME_TIMEOUT_MS,Frame_Request_Controller} from "../../src/runtime/frame_request_controller";
describe("frame request controller",()=>{afterEach(()=>vi.useRealTimers());it("allows one in-flight request and records binary RTT",()=>{const send=vi.fn(()=>true),round_trip=vi.fn();const controller=new Frame_Request_Controller(send,vi.fn(),round_trip);expect(controller.request_frame(10)).toBe(true);expect(controller.request_frame(11)).toBe(false);controller.receive_frame(26);expect(round_trip).toHaveBeenCalledWith(26,16);expect(controller.request_frame(27)).toBe(true);});it("releases a timed-out request",()=>{vi.useFakeTimers();const timeout=vi.fn();const controller=new Frame_Request_Controller(()=>true,timeout,vi.fn());controller.request_frame(0);vi.advanceTimersByTime(FRAME_TIMEOUT_MS);expect(timeout).toHaveBeenCalledOnce();expect(controller.is_pending()).toBe(false);});});
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src", "tests", "vite.config.ts"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/theme.ts","./src/capture/capture_frames.tsx","./src/capture/capture_panel.tsx","./src/capture/capture_sessions.tsx","./src/capture/capture_toolbar.tsx","./src/capture/frame_summary.tsx","./src/capture/node_detail.tsx","./src/capture/plan_comparison.tsx","./src/capture/worker_timeline.tsx","./src/common/copy_button.tsx","./src/common/empty_state.tsx","./src/common/json_viewer.tsx","./src/common/metric_grid.tsx","./src/common/metric_value.tsx","./src/common/status_chip.tsx","./src/dag/dag_layout.ts","./src/dag/dag_legend.tsx","./src/dag/dag_model.ts","./src/dag/dag_node.tsx","./src/dag/dag_types.ts","./src/dag/render_dag.tsx","./src/gallery/category_filter.tsx","./src/gallery/frame_mode_tabs.tsx","./src/gallery/gallery_page.tsx","./src/gallery/gallery_summary.tsx","./src/gallery/gallery_toolbar.tsx","./src/gallery/plot_card.tsx","./src/hooks/use_element_size.ts","./src/hooks/use_gallery_catalog.ts","./src/hooks/use_plot_session.ts","./src/inspector/actions_panel.tsx","./src/inspector/control_field.tsx","./src/inspector/controls_panel.tsx","./src/inspector/inspector_drawer.tsx","./src/inspector/inspector_tabs.tsx","./src/inspector/observer_panel.tsx","./src/inspector/performance_panel.tsx","./src/plot/kernel_observer_summary.tsx","./src/plot/performance_strip.tsx","./src/plot/plot_canvas.tsx","./src/plot/plot_status.tsx","./src/protocol/format.ts","./src/protocol/gallery_descriptor.ts","./src/protocol/gallery_messages.ts","./src/protocol/gallery_parser.ts","./src/protocol/gallery_types.ts","./src/protocol/pixel_frame.ts","./src/runtime/client_performance.ts","./src/runtime/frame_request_controller.ts","./src/runtime/pixel_presenter.ts","./src/session/gallery_plot_session.ts","./src/session/gallery_plot_snapshot.ts","./src/transport/catalog_loader.ts","./src/transport/gallery_socket.ts","./tests/setup.ts","./tests/components/control_field.test.tsx","./tests/dag/dag_model.test.ts","./tests/e2e/gallery.spec.ts","./tests/protocol/gallery_parser.test.ts","./tests/protocol/pixel_frame.test.ts","./tests/runtime/client_performance.test.ts","./tests/runtime/frame_request_controller.test.ts","./vite.config.ts"],"version":"5.9.3"}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{"path": "./tsconfig.app.json"}]
}
+9
View File
@@ -0,0 +1,9 @@
import {defineConfig} from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
base: "./",
build: {outDir: "dist", emptyOutDir: true},
test: {environment: "jsdom", setupFiles: "./tests/setup.ts", exclude: ["tests/e2e/**", "node_modules/**", "dist/**"]}
});