canteen-sim 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Song Xingxing
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,204 @@
1
+ # 🍜 明湖餐厅就餐仿真系统
2
+
3
+ > Minghu Canteen Simulation — Agent-Based Modeling for campus dining flow analysis
4
+
5
+ 北京交通大学明湖餐厅午/晚高峰就餐过程的 3D 微观仿真系统。基于 Agent-Based Modeling 方法,模拟学生从进入餐厅到就餐离开的全生命周期,支持窗口配置方案对比实验和拥堵瓶颈分析。
6
+
7
+ ## 核心特性
8
+
9
+ - **8 状态 Agent 生命周期**:进入 → 选窗口 → 排队 → 点餐 → 找座 → 就餐 → 离开
10
+ - **偏好路由算法**:偏好优先 + 容忍度降级 + 最短等待兜底
11
+ - **参数可控实验**:支持增设窗口方案对比(6/7/8 窗口)、午/晚高峰场景切换
12
+ - **实时数据看板**:平均等待、P95 等待、吞吐量、座位利用率、放弃率
13
+ - **热力图分析**:基于停留时间累积的拥堵热点检测与可视化
14
+ - **Demo Mode**:35 秒自动演示,6 阶段相机编排 + 同步字幕
15
+ - **体素风格 3D**:Minecraft 风格方块人,行走动画,电影灯光
16
+
17
+ ## 🚀 一条命令运行(无需克隆源码)
18
+
19
+ ```bash
20
+ npx canteen-sim # 自动启动本地服务器并打开浏览器
21
+ npx canteen-sim --port 8080 # 指定端口
22
+ npx canteen-sim --no-open # 不自动打开浏览器
23
+ ```
24
+
25
+ 在线版(GitHub Pages):<https://xingxing-dev.github.io/canteen-sim>
26
+
27
+ ## 快速开始(本地开发)
28
+
29
+ ```bash
30
+ # 安装依赖
31
+ npm install
32
+
33
+ # 启动开发服务器
34
+ npm run dev
35
+
36
+ # 运行测试(157 个测试)
37
+ npx vitest run
38
+
39
+ # 类型检查
40
+ npx tsc --noEmit
41
+
42
+ # 生产构建
43
+ npm run build
44
+
45
+ # 部署到 GitHub Pages
46
+ npm run deploy
47
+ ```
48
+
49
+ ## 系统架构
50
+
51
+ ```
52
+ ┌─────────────────────────────────────────────────────┐
53
+ │ App.tsx │
54
+ │ (主循环 · 相机系统 · Demo Mode) │
55
+ ├──────────┬──────────────────┬───────────────────────┤
56
+ │ UI Layer │ Scene Layer │ Simulation Layer │
57
+ │ │ │ (纯逻辑,零渲染依赖) │
58
+ ├──────────┼──────────────────┼───────────────────────┤
59
+ │ Control │ Canteen.tsx │ engine.ts │
60
+ │ Panel │ · 窗口/桌椅 │ ├─ FSM 状态机 │
61
+ │ │ · 热力图层 │ ├─ 窗口队列系统 │
62
+ │ Stats │ · 拥堵标记 │ ├─ 路由算法 │
63
+ │ Panel │ │ ├─ 到达率曲线 │
64
+ │ │ VoxelAgent.tsx │ ├─ 热力图累积 │
65
+ │ Legend │ · 方块人渲染 │ └─ 统计采集 │
66
+ │ │ · 行走动画 │ │
67
+ │ │ │ config.ts │
68
+ │ │ cameraViews.ts │ · 明湖午/晚高峰 │
69
+ │ │ · 5 预设视角 │ · 窗口配置方案 │
70
+ │ │ · 电影开场 │ │
71
+ └──────────┴──────────────────┴───────────────────────┘
72
+ ```
73
+
74
+ **关键设计决策:** 仿真引擎 (`src/simulation/`) 不依赖任何渲染库,可在 Node.js 环境独立运行和测试。渲染层通过 `SimStats` 接口订阅引擎状态。
75
+
76
+ ## 目录结构
77
+
78
+ ```
79
+ src/
80
+ ├── simulation/ # 仿真内核(纯 TypeScript,零依赖)
81
+ │ ├── engine.ts # SimulationEngine 主循环(708 行)
82
+ │ ├── agent.ts # Agent 类 + 8 状态枚举
83
+ │ ├── config.ts # 场景配置 + 明湖预设
84
+ │ ├── layout.ts # 食堂布局生成
85
+ │ ├── stats.ts # 统计接口定义
86
+ │ ├── routing.ts # 窗口选择路由
87
+ │ └── state-machine/ # 声明式 FSM 框架
88
+ │ ├── types.ts # Effect / Rule / Result 类型
89
+ │ ├── rules.ts # 9 条状态转移规则
90
+ │ └── executor.ts # 单帧单转移执行器
91
+ ├── scene/ # 3D 渲染层(React Three Fiber)
92
+ │ ├── Canteen.tsx # 食堂场景(窗口/桌椅/热力图)
93
+ │ ├── VoxelAgent.tsx # 体素人渲染 + 行走动画
94
+ │ ├── cameraViews.ts # 相机预设 + 缓动函数
95
+ │ └── instanced/ # InstancedMesh 优化(地面/墙壁)
96
+ ├── ui/ # UI 组件
97
+ │ ├── ControlPanel.tsx # 控制面板(场景/速度/视角/Demo)
98
+ │ ├── StatsPanel.tsx # 统计面板 + Recharts 实时图表
99
+ │ └── Legend.tsx # Agent 状态颜色图例
100
+ ├── App.tsx # 主应用(仿真循环/相机/灯光)
101
+ └── main.tsx # 入口
102
+ tests/
103
+ ├── simulation/ # 仿真核心测试(157 个用例)
104
+ │ ├── engine.test.ts # 引擎集成测试
105
+ │ ├── agent.test.ts # Agent 行为测试
106
+ │ ├── state-machine/ # FSM 规则/执行器测试
107
+ │ ├── minghu-features.test.ts # 明湖场景特性测试
108
+ │ └── engine-perf.test.ts # 性能基准测试
109
+ └── utils.ts # 测试工具函数
110
+ ```
111
+
112
+ ## 仿真模型
113
+
114
+ ### Agent 状态流
115
+
116
+ ```
117
+ Entering → ChoosingWindow → Queuing → Ordering → FindingSeat → Dining → Leaving → Left
118
+ ↓(耐心耗尽) ↓(无空座)
119
+ Leaving Leaving
120
+ ```
121
+
122
+ ### 窗口选择策略
123
+
124
+ 1. **偏好优先**:按个人偏好顺序检查窗口
125
+ 2. **容忍度检查**:当前队长 ≤ 个人阈值才加入
126
+ 3. **兜底策略**:所有偏好窗口超限时,选预计等待最短的窗口
127
+
128
+ ### 到达率曲线
129
+
130
+ 梯形分布:预热(2min)→ 高峰持续(~8min)→ 冷却(3min)
131
+
132
+ - 午高峰:峰值 0.95 人/秒,耐心 150s,就餐 26s
133
+ - 晚高峰:峰值 0.76 人/秒,耐心 180s,就餐 32s
134
+
135
+ ### 输出指标
136
+
137
+ | 指标 | 说明 |
138
+ |------|------|
139
+ | 平均等待 | 从加入队列到开始服务的等待时间 |
140
+ | P95 等待 | 95% 的人等待不超过此值 |
141
+ | 吞吐/分钟 | 所有窗口每分钟完成服务人数 |
142
+ | 座位利用率 | 当前占用 / 总座位数 |
143
+ | 放弃率 | 因排队超时离开的比例 |
144
+ | 热点区域 | 停留时间最高的空间位置 |
145
+
146
+ ## 场景假设
147
+
148
+ 本项目是**课程仿真估算模型**,不使用真实测量数据。明湖餐厅场景依据公开描述做抽象化建模:
149
+
150
+ - 默认布局 36×24 单位,1 入口、1 出口、6 服务窗口、72 座位
151
+ - 6 窗口服务均值分别为 7/8/9/10/11/12 秒(反映不同窗口出餐效率差异)
152
+ - 到达率和耐心值基于经验估算,非实测数据
153
+
154
+ ## 技术栈
155
+
156
+ | 层 | 技术 | 版本 |
157
+ |---|---|---|
158
+ | 语言 | TypeScript (strict) | 6.0 |
159
+ | 框架 | React | 19.2 |
160
+ | 3D | Three.js + React Three Fiber + Drei | 0.183 |
161
+ | 图表 | Recharts | 3.8 |
162
+ | 构建 | Vite | 8.0 |
163
+ | 测试 | Vitest + Testing Library | 4.1 |
164
+ | CI | GitHub Actions (Node 18/20 matrix) | — |
165
+ | 部署 | GitHub Pages (gh-pages) | — |
166
+
167
+ ## 开发
168
+
169
+ ```bash
170
+ # 开发模式(热重载)
171
+ npm run dev
172
+
173
+ # 运行测试(watch 模式)
174
+ npm test
175
+
176
+ # 测试覆盖率
177
+ npm run test:coverage
178
+
179
+ # ESLint 检查
180
+ npm run lint
181
+ ```
182
+
183
+ ## 版本管理与更新流程
184
+
185
+ 项目使用语义化版本 + CHANGELOG 记录变更。
186
+
187
+ ```bash
188
+ # 1. 修改代码 / 新增功能 / 修复 bug
189
+ # 2. 在 CHANGELOG.md 的 [Unreleased] 下记录本次变更
190
+ # 3. 提交并推送
191
+ git add -A
192
+ git commit -m "feat: 新功能描述" # 或 fix: / docs: / refactor:
193
+ git push
194
+
195
+ # GitHub Actions 自动:运行测试 → 构建 → 部署到 GitHub Pages
196
+ ```
197
+
198
+ 发版时将 `[Unreleased]` 改为版本号和日期,并更新 `package.json` 中的 version。
199
+
200
+ 完整版本历史参见 [CHANGELOG.md](./CHANGELOG.md)
201
+
202
+ ## 许可
203
+
204
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * canteen-sim CLI
4
+ * 零依赖静态服务器:托管已构建的 dist/,自动选空闲端口、自动打开浏览器。
5
+ * 用法:
6
+ * npx canteen-sim # 启动并自动打开浏览器
7
+ * npx canteen-sim --port 8080
8
+ * npx canteen-sim --no-open # 不自动打开浏览器
9
+ * npx canteen-sim --host 0.0.0.0
10
+ */
11
+ import { createServer } from 'node:http';
12
+ import { readFile, stat } from 'node:fs/promises';
13
+ import { join, normalize, extname, sep } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { spawn } from 'node:child_process';
16
+ import { platform } from 'node:os';
17
+
18
+ const root = fileURLToPath(new URL('.', import.meta.url));
19
+ const distDir = join(root, '..', 'dist');
20
+
21
+ const MIME = {
22
+ '.html': 'text/html; charset=utf-8',
23
+ '.js': 'text/javascript; charset=utf-8',
24
+ '.mjs': 'text/javascript; charset=utf-8',
25
+ '.css': 'text/css; charset=utf-8',
26
+ '.json': 'application/json; charset=utf-8',
27
+ '.svg': 'image/svg+xml',
28
+ '.png': 'image/png',
29
+ '.jpg': 'image/jpeg',
30
+ '.jpeg': 'image/jpeg',
31
+ '.gif': 'image/gif',
32
+ '.webp': 'image/webp',
33
+ '.ico': 'image/x-icon',
34
+ '.woff': 'font/woff',
35
+ '.woff2': 'font/woff2',
36
+ '.ttf': 'font/ttf',
37
+ '.wasm': 'application/wasm',
38
+ '.map': 'application/json',
39
+ '.glb': 'model/gltf-binary',
40
+ '.gltf': 'model/gltf+json',
41
+ '.txt': 'text/plain; charset=utf-8',
42
+ };
43
+
44
+ // ---- 解析命令行参数 ----
45
+ const argv = process.argv.slice(2);
46
+ function argValue(name, fallback) {
47
+ const i = argv.indexOf(name);
48
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
49
+ }
50
+ const host = argValue('--host', 'localhost');
51
+ const wantedPort = Number(argValue('--port', '4173'));
52
+ const autoOpen = !argv.includes('--no-open');
53
+
54
+ // ---- 校验 dist 是否存在 ----
55
+ try {
56
+ await stat(join(distDir, 'index.html'));
57
+ } catch {
58
+ console.error(
59
+ '\n ✗ 未找到构建产物 dist/index.html。\n' +
60
+ ' 若从源码运行,请先执行 `npm run build` 再启动。\n',
61
+ );
62
+ process.exit(1);
63
+ }
64
+
65
+ // ---- 静态文件请求处理 ----
66
+ const server = createServer(async (req, res) => {
67
+ try {
68
+ let urlPath = decodeURIComponent((req.url || '/').split('?')[0]);
69
+ if (urlPath.endsWith('/')) urlPath += 'index.html';
70
+
71
+ let filePath = normalize(join(distDir, urlPath));
72
+ // 防止路径穿越
73
+ if (filePath !== distDir && !filePath.startsWith(distDir + sep)) {
74
+ res.writeHead(403);
75
+ res.end('Forbidden');
76
+ return;
77
+ }
78
+
79
+ let data;
80
+ try {
81
+ const info = await stat(filePath);
82
+ if (info.isDirectory()) filePath = join(filePath, 'index.html');
83
+ data = await readFile(filePath);
84
+ } catch {
85
+ // SPA 兜底:未命中的路由回退到 index.html
86
+ filePath = join(distDir, 'index.html');
87
+ data = await readFile(filePath);
88
+ }
89
+
90
+ res.writeHead(200, {
91
+ 'Content-Type': MIME[extname(filePath).toLowerCase()] || 'application/octet-stream',
92
+ });
93
+ res.end(data);
94
+ } catch {
95
+ res.writeHead(500);
96
+ res.end('Internal Server Error');
97
+ }
98
+ });
99
+
100
+ // ---- 打开浏览器(跨平台,失败静默)----
101
+ function openBrowser(url) {
102
+ const cmd =
103
+ platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'cmd' : 'xdg-open';
104
+ const args = platform() === 'win32' ? ['/c', 'start', '""', url] : [url];
105
+ try {
106
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
107
+ } catch {
108
+ /* 打开失败不影响服务运行 */
109
+ }
110
+ }
111
+
112
+ // ---- 启动(端口被占用时自动回退到随机空闲端口)----
113
+ function listen(port, isRetry = false) {
114
+ server.once('error', (err) => {
115
+ if (err.code === 'EADDRINUSE' && !isRetry) {
116
+ console.warn(` ! 端口 ${port} 被占用,改用随机空闲端口...`);
117
+ listen(0, true);
118
+ } else {
119
+ console.error(` ✗ 启动失败:${err.message}`);
120
+ process.exit(1);
121
+ }
122
+ });
123
+ server.listen(port, host, () => {
124
+ const actual = server.address().port;
125
+ const url = `http://${host}:${actual}/`;
126
+ console.log(
127
+ `\n 🍜 明湖餐厅就餐仿真系统已启动\n\n ➜ ${url}\n\n 按 Ctrl+C 停止\n`,
128
+ );
129
+ if (autoOpen) openBrowser(url);
130
+ });
131
+ }
132
+
133
+ process.on('SIGINT', () => process.exit(0));
134
+ listen(Number.isFinite(wantedPort) ? wantedPort : 4173);
@@ -0,0 +1 @@
1
+ *{box-sizing:border-box;margin:0;padding:0}html,body,#root{width:100%;height:100%;overflow:hidden}