# 36: 主场景区配置(`map.primaryArea`) **What to build:** 驾驶舱配置新增主场景区矩形 `map.primaryArea.bounds`。部署写好即生效;未配置或顺序非法时,依赖它的区域限位与自建雾整体关闭,并在状态浮标如实提示原因 —— 即决策票 01 的「部署期准备候选值 + 运行时未配置则关闭」。 Blocked by: None Status: resolved Labels: ready-for-agent ## 契约来源 - 规格:`cockpit-primary-area-guardrails/spec.md`(决策票 03 的落地口径)。 - 决策:`cockpit-primary-area-guardrails/issues/01-primary-area-config-contract.md`(已 resolved)。 ## 验收 - [x] `map.primaryArea.bounds` 取 `[west, south, east, north]`(度)四元数组,与 `map.overlays[].bounds` 同口径。 - [x] 运行时校验 `west < east`、`south < north`;**不自动翻转**甲方给的 `[W, N, E, S]` 顺序。 - [x] 未配置 → 关闭依赖主场景区的区域限位与自建雾;顺序非法 → 记入配置问题并同样走关闭路径,保留原因。 - [x] 生效 / 关闭都体现在既有配置状态浮标(或等价 `data-*` 读数)上,人工与走查都能读到,不只是注释。 - [x] 本部署显式值=甲方框定的沅陵县城范围;`config.example.json` 同步该字段形状。 - [x] 改配置不需要改前端源码即可生效,沿用现有配置分层(内置默认 → 构建期环境 → 运行时文件 → 宿主注入)。 - [x] 逻辑断言覆盖四类:合法 / 顺序写反 / 缺字段 / 数值个数不对。 ## 进度:100% **已完成(2026-09-28)。** 本票是**配置与开关的落地**:只回答「限位与自建雾能不能开、开在哪个矩形」, 不实现限位本身(票 37)也不实现雾(票 38)——那两张票消费本票的出口。 ### 改动 - **新增 `domain/primaryArea.js`**:形状 / 顺序判定的**唯一**一处(配置归一化也调它,不写第二份), 另给三态 `ready|missing|invalid`、浮标措辞、关闭原因词表。纯函数、不 import cesium(与 `domain/basemap.js`、`mapLayers.js` 同模式)。 - **`services/cockpitConfig.js`**:内置默认补 `primaryArea: { bounds: null, configured: false }` —— **故意不预置任何地理常量**(决策票 01 Q3=A:兜底归部署期算,不写死源码); `normalizeConfig` 原样收下数组(**不翻转**)、把不可用原因记进 `issues` 并保留。 `configured` 用来区分「部署没写这个字段」(未配置)与「写了但是空的」(写错了)。 - **`services/mapService.js`**:启动时按配置算一次计划(**放在「引擎已由配置关闭」那条早退之前**), 浮标加一句 `主场景区:…`,宿主加 `data-map-primary-area` / `data-map-primary-area-reason`, `getLifecycleStats()` 补三个字段,并给出票 37/38 的**唯一入口** `getPrimaryAreaPlan()`(返回副本)。 - **配置面**:`public/cockpit/config.json` 写本部署显式值;`config.example.json` 同步字段形状与一条字段说明; `docs/cockpit-deployment.md` 新增 **2.9 主场景区**(顺序是硬要求、三种浮标读数的对照表、矩形怎么定)。 - **验证面**:新增 `verify/t36-primary-area.mjs`(逻辑闸门)+ `verify/t36-primary-area-walkthrough.mjs`(真机走查); `verify/t05-adapter.mjs` 的静态守卫面按仓库惯例纳入新 domain 文件(218→**230** 项)。 ### 关键取舍 1. **顺序非法走关闭,不做兜底翻转**(决策票 01 Q2=A)。翻对了没人会发现配置抄错, 翻错了就是拿一段假范围去锁相机 —— 两条都让部署问题在运行期隐身。 关闭时**保留原因**,且未配置 / 形状错 / 顺序错三种原因措辞互不相同(排查时能直接对号)。 2. **计划与引擎解耦**:主场景区是配置事实,引擎失败或被关掉都不改变答案。 因此它算在引擎早退之前,且在 `booting/failed/disabled` 下浮标也照说这一句。 3. **出口给副本**,消费方改不动服务层那份计划 ——「谁开的、开在哪儿」只能有一个来源。 ### 证据 - 逻辑闸门 `t36-primary-area.mjs` **通过**,覆盖验收点名的四类输入(合法 / 顺序写反 / 缺字段 / 个数不对, 其中个数类含 3 个、5 个、空数组、混字符串、null、NaN、非数组、对象共 8 种)。 另含:甲方原样的 `[W,N,E,S]` 必须判非法且**不给出矩形**;三条关闭路径原因两两不同; 本部署显式值与甲方原值**只差纬度两值对调**(逐位差 < 1e-9 度,仅截位);模板示例值自身合法; 以及静态守卫(判定唯一、无内置地理常量、无翻转、只读出口齐)。 - **DOM 宿主执行证据**(脱浏览器,等价替代「人工读得到」):用最小 DOM 宿主真跑 `mountMap`, 读回宿主 `dataset` 与浮标文本 —— 未读到配置前**不宣布结论**;关引擎的部署浮标上照样读到 `主场景区:配置无效(顺序应为 [西,南,东,北],不自动翻转) · 限位与雾气关闭`。 - **变异测试(守卫有牙)**:5 处变异全部被对应断言打红并已还原 —— ① 删顺序校验 → 零跨度用例红;② 改成自动翻转 → 下标对调守卫红; ③ 配置层不记 issue → 「顺序非法应记一条」红;④ 出口返回内部引用 → 「不得被调用方改写」红; ⑤ 去掉关引擎时的浮标那句 → 第 ⑧ 条回归红。 另反向确认 t05 的 D3/D4 对新 domain 文件有牙(塞 `fetch` / 反向 import service 均打红)。 - 全量回归:**21 个零依赖逻辑闸门全绿**(含 t05 扩面后 230/230)。`t28-fill-container.mjs` 是 puppeteer 脚本, 因 DSH profile 的 `@puppeteer/browsers` 是**空目录**(0 个文件)而 ERR_MODULE_NOT_FOUND, **与本票无关**(改动前后同样失败;本票走查因此改用 DSH 自带浏览器,见下)。 ### 真机走查:**已运行,5/5 通过**(维护者 2026-09-28 明确要求「先运行一次真机走查」) **运行方式与脚本原始设计不同,且证据更强**:`verify/t36-primary-area-walkthrough.mjs` 的 puppeteer 路线在本机被 `@puppeteer/browsers` 包为空目录卡住(与本票无关,t28 等所有 puppeteer 脚本同样失败), 故改用 DSH 自带浏览器**手工驱动同一条验收链**(真实 Chrome、真实 `yarn dev` @ `127.0.0.1:8000`、 真实页面 `#/cockpit`)。这比脚本的 `evaluateOnNewDocument` 注入多证明一件事: **配置是走真实运行时文件层(`data-source=file`)生效的**,不是注入旁路。 | # | 用例 | 改 `public/cockpit/config.json` | 实测读数 | 结果 | | --- | --- | --- | --- | --- | | A1 | 合法 | 本部署显式值 | `data-map-primary-area=ready`;浮标 `主场景区:13.4×11.1 km`;出口 `bounds` 四元齐 | ✅ | | A2 | 未配置 | 删除 `map.primaryArea` | `missing`;原因 `未配置`;浮标 `未配置 · 限位与雾气关闭`;出口 **bounds=null** | ✅ | | A3 | 顺序写反 | 甲方原样 `[W,N,E,S]` | `invalid`;原因 `顺序应为 [西,南,东,北],不自动翻转`;出口 **bounds=null**(没被默默翻转) | ✅ | | A4 | 个数不对 | `[110, 28, 111]` | `invalid`;原因 `bounds 应为四个有限数字`(与 A3 措辞**不同**,排查能对号) | ✅ | | A5 | 关引擎 | `cesium.enabled=false` | 引擎 `disabled`、`canvas=0`,但 `primaryArea=ready` 且**浮标照样读到** `主场景区:13.4×11.1 km` | ✅ | - 每条都同时读了三处:宿主 `data-map-primary-area`(`-reason`)、`__cockpitMap.getPrimaryAreaPlan()`、浮标文本 —— 即「人工读得到」与「走查读得到」在同一时刻都被证实,不只是断言源码里存在。 - 截图存证(人眼复核):`t36-a1-ready.png`(生效,浮标在中央顶部一行内可读)、 `t36-a3-invalid.png`(关闭,浮标写明顺序原因 + 「限位与雾气关闭」)。 **顺带证实了「主场景区排在最前」这个决定的必要性**:A3 关闭路径那句明显更长, 排在后面时截图里尾部已被 `…` 省略;提到最前之后,最关键的那句完整可见。 - 原始 puppeteer 脚本保留(仍是维护者日后一键复跑的入口),但**它在本机的依赖问题是既有环境缺陷**, 不是本票引入,也未因此在票面把它记成"已跑过脚本"。A5 那条恰是脚本设计时就覆盖的用例,实测与脚本预期一致。 - 走查后 `public/cockpit/config.json` 已**逐字节还原**(2291 bytes、无 BOM、`intro.enabled=true`、本部署 bounds), 与运行前备份 `Compare` 相等。 ### 修掉的一个真缺陷(DOM 探针抓到) 浮标原来只在引擎 `ready` 时才拼业务读数,于是**把引擎关掉的部署**(`map.cesium.enabled=false`) 在主场景区这件事上完全失声 —— 而这恰恰是最需要看到「限位与雾气关了」的那种部署。 已改为:配置到手后,无论引擎处于何种状态,这一句都跟着说;引擎就绪时仍按原顺序拼在业务读数最前。 (顺手把主场景区提到 `basemapText` 之前:这根浮标是 `nowrap + ellipsis`,关闭路径那句更长,排后面会被尾省略。) ### 下一步 36 就位,**37(进区锁定与相机区域限位)解除阻塞**(38 仍等 37:它的门控要 `areaState`)。 37 与 38 都从 `mapService.getPrimaryAreaPlan()` 取「开不开、开在哪儿」,不要再自己解析一遍配置 ——判定只有 `domain/primaryArea.js` 一处,自己再判一次就会出现第二套口径。 40(全程质量收口)按 36→37→38→39→40 的顺序留到最后。