# 37: 进区锁定与相机区域限位 **What to build:** 点「进入系统」或「归位」后进入主场景区并锁定:中心点被约束在区内(拖到边界软回弹)、滚轮最远只到归位默认视角(本部署 12,000 m)、相机不钻地形;「返回全球」解除限位并飞回自由全景;全球态闲置 10 秒(2026-09-30 由 5 秒改定)回到程序初始态。这是需求①「限高限低,不许超过限制范围」的落地。 Blocked by: 36 Status: resolved Labels: ready-for-agent ## 契约来源 - 规格:`cockpit-primary-area-guardrails/spec.md`。 - 决策:`cockpit-primary-area-guardrails/issues/03-camera-contract-composition-and-entry-state.md`(已 resolved);决策记录 `docs/adr/0009-primary-area-camera-limits-and-entry-state.md`。 - 既有契约:ADR 0008(三键分工与阻尼)不得改动。 ## 验收 - [x] `areaState: 'locked' | 'global'` 成为与 `ground/pipeline` 同层的**全局视图条件**;切一级模式不重置。 - [x] 进区后 range 上限=`map.home.range`(本部署 12,000 m);与管线视图 `[400, 6000]`、`RANGE_LIMITS.min` 三者**取交集**,区内管线视图因此与现状一致、无需额外收紧。 - [x] 中心点约束在 `primaryArea.bounds` 内:拖拽时允许最多**框短边 5%** 的越界,**松手**按既有阻尼弹回;`shown.pivot` 最终回到框内,越界只出现在拖拽过程。 - [x] 防入地:相机离地高度 ≥ 正下方地形 + 10 m(可微调);不足时**夹 range**(不夹 pitch、不动 pivot);瓦片未加载时退回固定下限。 - [x] 「返回全球」:解除限位 + 阻尼飞到程序初始态全景(heading 0 / pitch −60 / range 20,000,000),之后完全自由。 - [x] 全球态闲置 **10 秒**(2026-09-30 维护者由 5 秒改为 10 秒,契约同步)未操作 → 回程序初始态(**镜头回初始全景** + 星球自转恢复 + 屏幕中央闪烁「点击进入系统」),**不重置**一级模式与筛选;任何交互即重置计时。 - [x] `map.intro.enabled=false`(走查自隔离)时按 global 自由视角启动、不弹引导页。 - [x] 顶栏:区内显示「归位」+「返回全球」;区外只显示一个「归位/进入」。 - [x] 三键分工与阻尼手感不变;限位**只在输入时生效**,无逐帧主动回拉(沿用现状 clamp 行为)。 - [x] ADR 0009 与实现一致;偏差需回写票面并经维护者确认。 - [x] 证据:纯逻辑断言(限位解析 / 状态转移 / 软区夹取 / 防入地)+ 一条可复现真机走查(进入 → 锁定 → 拉远被夹 → 拖到边界回弹 → 返回 → 闲置回引导)。 ## 进度:100% **已完成(2026-09-28)。** 本票是**限位与状态机的落地**;雾气(票 38)仍由下游票负责, 它消费本票的 `areaState` 与 `range_max`。 ### 需要维护者裁定的一处(已裁定) 验收第 3 条「中心点约束在 `bounds` 内」与「点选定位」在契约里**没有交界**: `cockpit-primary-area-guardrails/spec.md` 自己把它记为「联动风险,未裁决,留给平行图 03」, 而平行图 03 收口时只裁了 range/高度(`range ∈ [120, range_max]`、`h ≥ 地形+10 m`), **没有裁中心点**。这个缺口是实质性的:监测点散布全县(影像 105×122 km), 主场景区只框了县城 13.4×11 km,硬夹会把绝大多数点位挡在区外、点选后镜头停在框边而不是点位上 (mock 数据 `lon 110.141`、本部署框西界 `110.329`,直接落到框外十几公里)。 **维护者 2026-09-28 裁定**: 1. **中心点约束只管手动三键**;**点选/定位是例外**,镜头照常飞到框外点位(A 选项); 2. 拖拽手感=「左键按紧拖动时向外阻尼移动直到最大限度停止,松开左键自动弹回」。 两条都已落进 `docs/adr/0009`(ADR 与实现一致)与本票实现。**附带约束**: 拖拽**从框外起手**时不回弹(否则刚定位到的点位一按鼠标就被拽回框边)——这条是第 1 条的必然推论。 ### 改动 - **新增 `domain/areaLimits.js`**:限位解析(区域 × 视图 × 全局**三处取交集**)、软区/硬框夹取、 防入地夹取、状态转移(只有「返回全球」能解除)、顶栏形态、闲置判定。纯函数、不 import cesium。 - **`domain/camera.js`**:把程序初始态全景提成 `INITIAL_PANORAMA` 常量(返回全球与首帧占位共用一份)。 - **`services/cameraControl.js`**:新增 `setRangeLimits`(与既有 `setPitchLimits` 对称)、 `getRangeLimits`、`clampToLimits`;滚轮/`setHpr`/`applyViewPreset` 全部改走**生效区间**(含区域上限与防入地); 拖拽经软区夹取、松手走 `springBackIntoBounds()`;定位豁免靠「按下时是否已在框外」判定。 - **`services/mapService.js`**:`applyAreaLimits()` 是**唯一的合成点**;`setAreaState('enter'|'returnGlobal')` 是**唯一的转移入口**(归位即进区);宿主读数 `data-map-area-state` / `-area-locked` / `-range-max`; `getAreaLimits()` / `getAreaState()` 只读出口;DEV 把手 `enterArea` / `returnGlobal`。 - **`services/cesiumEngine.js`**:`groundHeightBelowCamera()` / `cameraHeight()` —— 防入地要的是 **相机正下方**的地形(相机与 pivot 可能隔着一个山脊,用 pivot 的地形会在斜坡上误判)。 - **`stores/filterStore.js`**:`areaState` 进 store(全局视图条件,与 `viewMode` 同层,切一级模式不重置)。 - **`CockpitShell.vue`**:顶栏两形态 chip、区内状态读数、闲置计时(只在 global 态、任何交互重置、 监听可拆)、`intro.enabled=false` 时按 global 启动;**顺带修掉一处被本票放大的布局缺陷**(见下)。 - **文档**:`docs/cockpit-deployment.md` 新增 **2.10 进区锁定与相机区域限位**;ADR 0009 补两处口径。 ### 关键取舍 1. **限位只在输入时生效**(ADR 0009):不做逐帧主动回拉,`shown` 仍由既有阻尼收敛 —— 手感与票 03/21 一致。 2. **三处交集只有一个合成点**(`applyAreaLimits()`):视图切换与进区都调它,不各算一套。 3. **进区时立刻夹一次**(`clampToLimits()`):否则「进区时 range 已在 12 km 以外」要等用户滚一次才被夹 —— 这正是决策票 03 记的 4.7× 突跳来源。 4. **主场景区不可用时进不去**(返回 `blocked` 原因并如实提示),而不是锁进一个不存在的框。 ### 证据 - 逻辑闸门 `verify/t37-area-limits.mjs` **通过**:限位解析(区内 `max=home.range`、管线 `min(6000,12000)=6000`、 区外回落到全局)、状态转移(只有返回全球能解除;重复 enter 仍锁定 → **判定与相机位置无关**; 可用性不足时进不去且带原因)、软区 ≈550 m 与松手回弹落点、防入地只夹 range、顶栏两形态、 闲置 10 秒且**区内不计时**、以及**假引擎驱动的 7 组输入链**(含"定位到框外不被夹、紧接着手动拖不瞬移"与 "框内起手仍有软回弹")。 - **变异测试(守卫有牙)6 处,全部被对应断言打红并已还原**: ① 不取交集(上限用全局)→ 上限断言红;② 状态机允许任意动作解除 → 进区断言红; ③ 去掉松手回弹 → 「松手后回到框内」红;④ 抹掉定位豁免 → 「框外起手不得拽回框边」红 (实测把中心从 110.141 拽回 110.323);⑤ 防入地退回**字面口径** → 「pivot 在地表应落 120 m 下限」红 (实测 148.4 m,正是会推翻票 30 的那个数);⑥ 把 `viewMode` 赋值挪到 `applyAreaLimits()` 之后 → 顺序守卫红。 - **真机走查(维护者 2026-09-28 要求执行):10 项全部通过**。因本机 puppeteer 的 `@puppeteer/browsers` 是空目录(既有环境缺陷,t28 等同样失败),改用 DSH 自带浏览器驱动同一条验收链, 走**真实运行时配置**(`data-source=file`): | # | 用例 | 实测 | | --- | --- | --- | | A1 | 区外自由 | `area-state=global`、无「返回全球」、按钮「归位/进入」、上限 3×10⁷ | | A2 | 进入系统 → 锁定 | `locked`、`range-max=12000`、出现「返回全球」、按钮变「归位」、落到 home(110.390, 28.444) 且在框内 | | A3 | 区内滚轮拉不远 | 先拉近到 120(证明链路通),再连滚 60 次**夹在 12000**(非全局 3×10⁷) | | A4 | 管线取交集 | `min=400, max=6000`(`min(6000,12000)`,与现状一致) | | A5 | 顶栏不压模式按钮 | 状态区 512px 装进 521px 列,模式区终于 841 < chip 起 853,0 冲突 | | A6 | 拖到边界回弹 | 拖到 110.47140(软区边)、松手 target 回到 110.465779226663(硬框边);`setCameraDamping(0)` 同步路径下 `shown` 同样在框内 | | A7 | 返回全球 | `locked→global`、上限回 3×10⁷、目标 hpr **精确**=heading 0 / pitch −60 / range 2×10⁷、按钮变回 | | A8 | 全球态闲置 | 12 秒无交互后引导层带「点击进入系统」回来 | | A9 | 区内闲置不回引导 | `locked` 闲置 12 秒,引导层**仍为 false** | | A10 | 无页面级报错 | enter/return 往返三次,`error`/`unhandledrejection` 监听零记录 | - 全量回归:**23 个零依赖逻辑闸门全绿**(t05 守卫面纳入 `areaLimits.js`,230→**233**)。 `t28-fill-container.mjs` 是 puppeteer 脚本,因上述依赖缺失而 ERR_MODULE_NOT_FOUND,**与本票无关**。 - 按 `AGENTS.md`:本会话**未跑 build**(浏览器走查是维护者本次明确要求才执行)。 ### 实现中抓到的两个真缺陷 1. **`applyAreaLimits()` 与 `viewMode` 的赋值顺序错了**(我自己写出来又抓到的):先算限位、后落 `viewMode`,于是切到管线时区域上限仍按地面算(读数 12000 而非 6000)。domain 闸门与静态守卫 都发现不了(它们只测纯函数、只看"有没有调用"),**只有真跑一遍才暴露**;已修并把"顺序"做成断言(变异 ⑥)。 2. **顶栏被本票的新 chip 挤爆,压住一级模式按钮**:加「返回全球」后右侧状态区内容需 510px、 可用只有 464px,`justify-content: flex-end` **向左溢出** 46px, 实测「归位」(x 855–897)压住「预警处置」(x 799–898)。 修法:状态列由 `minmax(240px, 1fr)` 改为 `minmax(min-content, 1fr)`(按内容最小宽度兜底)。 走查加了 A5 按**真实几何**断言这条,避免以后再插 chip 时悄悄压上去。 ### 边界与遗留 - **雾气(票 38)不在本票**:本票只保证 `areaState` 与 `range_max` 是可靠输入。 - **设备级/点位级定位视角**(`range 500/150`)属平行图 `cockpit-point-locate-and-device-link` 票 03, 本票只保证它们落在限位内(两档都远低于 12,000 m,任何画布比例下不会被夹)。 - 本机走查时该浏览器标签 `document.hidden===true`,**rAF 被完全冻结**(实测 1 秒 0 帧), 因此"阻尼收敛过程"的断言改用 `setCameraDamping(0)` 的**同步路径**验证(同样是既有契约的正常入口); 阻尼本身的手感验收仍由票 03 的真机走查覆盖,本票不重复断言。 ### 下一步 37 就位,**38(区内视距雾气)解除阻塞** —— 门控读 `getAreaState()`(`locked` 生效、global/引导态关闭), 上限读 `getAreaLimits().max`(本部署 12,000 m),不要再自己解析主场景区配置。 39(粗→精影像交接)依赖 38;40(全程质量收口)留到最后。