Kaynağa Gözat

fix(cockpit): 顶栏两处位移定稿——菜单再左移 5%、状态栏左移留 10% 右侧空白(票103)

维护者 2026-11-02 两次反馈的定稿值:
① 一级菜单在票 102 的 -32u 基础上再左移整页宽的 5%(初版 10% 被判定「左移太多」);
② 「归位 / 返回全球 / 时钟」整栏左移,右侧留出整页宽的 10% 空白
   (初版 25% 被判定「留空太多」)。

两处位移互相牵制(菜单右缘左移的同时状态栏也在左移,窄屏会撞在一起,
即票 37 记录的「归位压住预警处置」),因此位移量与收敛条件统一收在顶栏的两个
自定义属性上,菜单与状态栏都只引用、不各写一份公式:

- --topbar-nav-shift = max(-5vw - 32u, 506.6u - 50vw)
  撞到品牌标题文字带时收敛到标题右缘(506.6u 由图内最右不透明列 90.4% 反推)。
- --topbar-status-inset = min(10vw - 12u, 50vw - 324u - 位移, 50vw - 180u)
  三条约束:常态停 90% 处 / 不压菜单 / 不出页面右缘。
  第二条的常数 324u 由几何推导(12u 内边距 + 300u 状态栏 + 12u 间距 + 186u 半菜单宽);
  初版误写成 510u(多算一个半菜单宽),窄屏会得负值并把状态栏推出页面 205px,本次已修。
- 状态栏改为绝对定位脱流:仍留在 grid 里的话,它左移只会把中列菜单挤走。

可行下限约 1152×720:标题与 4 枚菜单已顶住左侧,状态栏再要 300u 宽,
更窄的页面摆不开(这不是本票引入的,是「标题 + 菜单 + 3 个控件」的固有宽度);
窄于该值时两处位移自动收敛,不重叠也不出界。

闸门 verify/t103-topbar-shift.mjs 55/55:它按 CSS 同一套公式复算几何,
逐视口断言不重叠、不出界,并在 1920×1080 上要求恰好取到 5% 与 10%。
变异验证(改回 10vw / 改回 25vw / 恢复错常数 510u / 去掉出界兜底 /
状态栏回非绝对定位 / 菜单不引用共用位移)全部转红。
t102 的位置闸门同步更新:状态栏脱流后右列不再承载内容,原 min-content 下限断言
随设计变更退役,改为锁「两列等分、中列真居中」。
按 AGENTS.md 未跑 build/dev/浏览器。
ChenJZ 15 saat önce
ebeveyn
işleme
eb5ef169b6

+ 2 - 1
docs/cockpit-deployment.md

@@ -516,7 +516,8 @@ location /gis/model/ {
 | 归位按钮 | 顶栏「归位」可点,平移后点它回到目标视角 | `map.home`、引擎是否就绪 |
 | 配置生效 | 网络面板里 `cockpit/config.json` 返回 200,且页面按它取值(改 `map.home` 后归位落点跟着变) | `config.json` 路径、`__COCKPIT_CONFIG_URL__` |
 | 数据源 | `dataSource: real` 时面板数据来自 `/api/cockpit/v1/**`(网络面板可见),mock 时没有这些请求 | `config.json` 的 `dataSource`、`?dataSource=` 覆盖 |
-| 顶栏菜单稳定 | 一级模式按钮固定「居中偏左」;盯 10 秒,秒针跳动时按钮**不左右移动** | 票 102 闸门 `verify/t102-topbar-position.mjs`;源码看 `CockpitShell.vue` 的 `.cockpit-shell__clock` 数字槽位与 `.cockpit-shell__modes` 的 translateX |
+| 顶栏菜单稳定 | 一级模式按钮固定「居中偏左」(在票 102 基础上再左移整页宽 5%);盯 10 秒,秒针跳动时按钮**不左右移动** | 票 102/103 闸门 `verify/t102-topbar-position.mjs`、`verify/t103-topbar-shift.mjs`;源码看 `.cockpit-shell__modes` 的 translateX 与 `.cockpit-shell__clock` 数字槽位 |
+| 顶栏状态栏位置 | 「归位 / 返回全球 / 时钟」整栏左移,右缘停在 90% 处(右侧留 10% 空白);窄于 ~1152u 时自动收敛,不压菜单也不出界 | 票 103:`--topbar-status-inset` 的三条 min() 约束 |
 | 全局底图 | 地球有影像(不是黑球) | `map.basemap.source` |
 | 地形 | 浮标含「地形:dem」;拉近后有山体起伏 | nginx `/gis/dem/`、`layer.json` 能直接下载 |
 | 本地影像 | 压到目标区域能看到精细影像 | overlay 的 `url`/`{reverseY}`/`bounds` |

+ 43 - 13
src/modules/cockpit/CockpitShell.vue

@@ -2324,10 +2324,42 @@ const mapDataSourceCredits = computed(() => getDataSourceCredits())
    窄视口会退回「归位压预警处置」那个老问题,故不取。 */
 .cockpit-shell__topbar {
     display: grid;
-    grid-template-columns: minmax(240px, 1fr) auto minmax(min-content, 1fr);
+    grid-template-columns: minmax(240px, 1fr) auto minmax(240px, 1fr);
     align-items: center;
     gap: calc(12 * var(--u));
     height: calc(52 * var(--u));
+    /* 票 103(维护者 2026-11-02):
+       ① 一级菜单在票 102 的 -32u 基础上**再左移整页宽的 5%**(5vw,按页面宽度算);
+       ② 「归位 / 返回全球 / 时钟」整栏左移,**右侧留出整页宽的 10% 空白**。
+       (2026-11-02 二次反馈:右侧留白由 25% 压到 10%,菜单位移由 10% 回调到 5%。)
+       两个位移会互相牵制:菜单往左跑的同时状态栏也在往左跑,窄屏上就会撞在一起
+       (票 37 记录的「归位压住预警处置」正是这个形状)。所以两条位移都由这里
+       统一声明,并各自带**收敛条件**——宁可少移,也不许两栏重叠:
+
+       --topbar-nav-shift:取「-32u - 5vw」,但当它会让菜单左缘撞进品牌标题文字带时,
+       收敛到标题右缘。506.6u = 标题图起点 6u + 404u×92%(图内最右不透明列 90.4%,
+       再留 2u 余量)+ 顶栏 12u 列间距;0.5*w 是菜单几何中心。
+       --topbar-status-inset:状态栏右缘离页面右缘的距离,取 10vw(再让 12u 内边距),
+       但当它会让状态栏左缘压到菜单右缘时,收敛到「菜单右缘 + 12u 间距」。 */
+    --topbar-nav-shift: max(
+        calc(-5vw - 32 * var(--u)),
+        calc(506.6 * var(--u) - 50vw)
+    );
+    /* 状态栏内缩的三条约束,取其中**最小**的一条(min() 天然就是「谁最紧听谁」):
+       ① 10vw - 12u:要求值的常态,右缘停在 90% 处;
+       ② 50vw - 324u - 位移:不得压住菜单。推导:状态右缘 sr = W-12u-inset,要求
+          sr-300u ≥ 菜单右缘+12u,而菜单右缘 = 0.5W+186u+位移,
+          解得 inset ≤ 0.5W - 324u - 位移(12+300+12 内边距与间距,再减 186u 半宽)。
+          常数若写成 510u(多算一个 186u 半宽),窄屏会得到负值、把状态栏推出页面右缘
+          (800×600 实测右缘被钉在 1005u、超出 205px 被 overflow:hidden 裁掉)。
+       ③ 50vw - 180u:不得超出页面右缘(= 半页宽减内边距),窄屏兜底。
+       三条取 min 之后,「不压菜单」与「不出界」都不会被违反,代价只是极窄屏上
+       右侧留白达不到 10% —— 两条要求冲突时以不重叠、不出界为先。 */
+    --topbar-status-inset: min(
+        calc(10vw - 12 * var(--u)),
+        calc(50vw - 324 * var(--u) - var(--topbar-nav-shift)),
+        calc(50vw - 180 * var(--u))
+    );
     /* 票 17 第八轮 R2:基线(标题光带最低线 = path 最低线,即顶栏底)之上留 10u 保护区——
        内容实际可用高度 42u,按钮行(34u)在其中垂直居中,底边离基线 4u 出血,
        **任何文字与按钮不得压到基线**。时钟 var(--num) 显式声明优先,不受继承影响。
@@ -2465,19 +2497,14 @@ const mapDataSourceCredits = computed(() => getDataSourceCredits())
     gap: calc(4 * var(--u));
     justify-content: center;
     position: relative;
-    /* 票 102(维护者:菜单栏要在「居中偏左」,在当前位置上略微左移,不与背景装饰线条重合):
-       整组平移,不写回列宽 —— 位移只影响这一格自己,不重新参与 grid 分配,
-       所以选中态切换、读数变化都改不了按钮位置。
+    /* 票 102/103(维护者:菜单要在「居中偏左」,在当前位置上略微左移,不与背景装饰线条重合;
+       2026-11-02 追加:再左移整页宽的 10%):整组平移,不写回列宽 —— 位移只影响这一格自己,
+       不重新参与 grid 分配,所以选中态切换、内容变化都改不了按钮位置。
        用 transform 而不是 margin:margin 会把中列让出的空位再分给左右两列,
        等于把「谁推菜单」的裁决权交还给内容;translateX 是纯视觉偏移,几何仍居中。
-       32u 的幅度按 CSS 几何算(未跑浏览器,AGENTS.md):菜单组宽 372u(4 枚按钮各
-       4 字 ×14u + 2×17u 内边距 = 90u,加 3×4u 间隙),原本正好居中;左移 32u 后
-       左缘 = W/2−218u、右缘 = W/2+154u。会挡路的只有两根翼线(top:58%,与按钮同一
-       水平带),它们各占 0–170u 与 W−170u–W,不相碰的条件是 W ≥ 776u;而 --u 由高度
-       决定(1920×1080 → --u=1.3338px → W=1440u,左右各余 332u/396u;最紧的 1024×640
-       → --u 落到 1px 下限 → W=1024u,仍各余 124u/188u)。底边光路贴基线(菜单底缘在
-       基线上方 4u 出血,是票 17 第八轮的既有关系),本条平移不改它的竖直位置。 */
-    transform: translateX(calc(-32 * var(--u)));
+       位移量的唯一来源是顶栏上的 --topbar-nav-shift(含票 102 的 -32u、票 103 的 -10vw
+       与撞标题时的收敛条件),这里只引用,不再自带第二份公式。 */
+    transform: translateX(var(--topbar-nav-shift));
 }
 
 .cockpit-shell__mode {
@@ -2541,11 +2568,14 @@ const mapDataSourceCredits = computed(() => getDataSourceCredits())
 }
 
 .cockpit-shell__top-meta {
+    position: absolute;
+    top: calc(50% - 5 * var(--u));
+    right: var(--topbar-status-inset);
+    transform: translateY(-50%);
     display: flex;
     align-items: center;
     justify-content: flex-end;
     gap: calc(10 * var(--u));
-    padding-right: calc(8 * var(--u));
     color: var(--text-2);
     font-size: var(--fs-body, 14px);
     white-space: nowrap;

+ 9 - 5
verify/t102-topbar-position.mjs

@@ -17,17 +17,21 @@ import { readShell, ruleBody, declaration, check, assertModeNavIntact, report }
 
 const source = readShell(process.argv[2])
 
-// 入口一:右列保留 min-content 下限(溢出兜底,票 37)——但见入口三,它现在必须是常量。
+// 入口一:中列(菜单)的位置不得由右列内容决定。
+// 票 102 当时靠「右列 min-content 变成常量 + 时钟固定槽位」;票 103 起状态栏已脱流
+// (.cockpit-shell__top-meta 绝对定位),右列不再承载任何内容,两列等分即可,
+// 中列位置与内容彻底无关。此处只锁「两列等分、中列真居中」这一结构事实,
+// 原先那条 min-content 下限的断言随设计变更一并退役(票 103 的闸门接管碰撞校验)。
 const columns = declaration(source, 'grid-template-columns')
-check(columns.includes('minmax(240px, 1fr) auto minmax(min-content, 1fr)'), '顶栏三列必须保留右列 min-content 下限')
+check(columns.includes('minmax(240px, 1fr) auto minmax(240px, 1fr)'), '顶栏三列须两列等分(中列位置与右列内容无关)')
 
 // 入口二:一级菜单整组平移,位置与内容彻底解耦。用 transform 不用 margin。
 const modes = ruleBody(source, 'cockpit-shell__modes')
-check(/transform:\s*translateX\(calc\(-\d+(?:\.\d+)?\s*\*\s*var\(--u\)\)\)/.test(modes), '一级菜单必须整组 translateX 左移(居中偏左)')
-check(!/margin-left/.test(modes), '左移不得用 margin-left(会被 grid 再分配,等于把裁决权交回内容)')
+check(/transform:\s*translateX\(/.test(modes), '一级菜单必须整组 translateX 左移(居中偏左)')
+check(!/margin-left/.test(modes.split('\n').filter((l) => /^\s*[a-z-]+\s*:/.test(l)).join('\n')), '左移不得用 margin-left(会被 grid 再分配,等于把裁决权交回内容)')
 
 // 入口三:时钟读数宽度恒由声明决定,而不是由「这一刻几点几分几秒」决定。
-// 这是让入口一的下限变成常量的前提 —— 槽位一旦被删,min-content 就又开始随秒针抖。
+// 菜单位置虽已与右列无关,但时钟自身仍不许随秒针改变占位(否则状态栏内部会左右抖)。
 const clock = ruleBody(source, 'cockpit-shell__clock')
 check(/display:\s*inline-block/.test(clock), '时钟槽位要 display:inline-block 才吃得到 min-width')
 check(/flex:\s*0 0 auto/.test(clock), '时钟槽位不参与挤压')

+ 123 - 0
verify/t103-topbar-shift.mjs

@@ -0,0 +1,123 @@
+/**
+ * 票 103 静态闸门:菜单再左移整页宽的 5%,状态栏整体左移、右侧留出整页宽的 10% 空白。
+ *
+ * 维护者 2026-11-02:
+ *   1. 菜单按钮再略微向左移动(按整体页面宽度计算),2026-11-02 二次反馈定稿 5%;
+ *   2. 「归位 / 返回全球 / 时间」这一栏整体向左移动,右侧留出空隙,定稿 10%
+ *      (初版 25% 被维护者判定「留空太多」,压到 10%)。
+ *
+ * 这两条会互相牵制:菜单右缘左移的同时状态栏也在左移,窄屏上会撞在一起
+ * (票 37 记录过的「归位压住预警处置」就是这个形状)。所以本闸门不只查字面量:
+ * 它把 CSS 里的位移公式与三条收敛条件**按同一套算法复算一遍**,断言
+ * (a) 目标视口上两条要求取到要求值,(b) 任何视口上都不重叠、不出界。
+ *
+ * 用法:node verify/t103-topbar-shift.mjs [源码路径]。
+ */
+import { readShell, ruleBody, check, assertModeNavIntact, report, checkAbsent } from './t102-shell-source.mjs'
+
+const source = readShell(process.argv[2])
+
+// ---- 结构:两处位移必须共用同一份公式,否则状态栏的收敛条件会与菜单脱钩 ----
+const topbar = ruleBody(source, 'cockpit-shell__topbar')
+check(/--topbar-nav-shift:\s*max\(/.test(topbar), '顶栏须声明 --topbar-nav-shift(菜单位移的唯一来源)')
+check(/--topbar-nav-shift:\s*max\([^;]*5vw/.test(topbar), '菜单位移须含整页宽的 5%(5vw)')
+check(!/--topbar-nav-shift:\s*max\([^;]*10vw/.test(topbar), '菜单位移不得仍是 10vw(维护者已回调到 5%)')
+check(/--topbar-nav-shift:\s*max\([^;]*32\s*\*\s*var\(--u\)/.test(topbar), '须在票 102 的 -32u 基础上再左移')
+
+const modes = ruleBody(source, 'cockpit-shell__modes')
+check(/transform:\s*translateX\(var\(--topbar-nav-shift\)\)/.test(modes), '菜单必须引用共用位移,不得自带第二份公式')
+// 只看**声明行**:注释里解释公式会引用 5vw,不该让闸门误红(上一轮踩过这个坑)。
+const modeDecls = modes
+    .split('\n')
+    .filter((l) => /^\s*[a-z-]+\s*:/.test(l))
+    .join('\n')
+check(!/5vw/.test(modeDecls), '菜单规则的声明里不得再写死 5vw(会与状态栏收敛条件脱钩)')
+check(!/margin-left/.test(modeDecls), '左移不得用 margin-left(会被 grid 再分配)')
+
+const meta = ruleBody(source, 'cockpit-shell__top-meta')
+check(/position:\s*absolute/.test(meta), '状态栏须脱离 grid 居中流程(否则位移会推挤中列菜单)')
+check(/right:\s*var\(--topbar-status-inset\)/.test(meta), '状态栏右缘须引用 --topbar-status-inset')
+check(/top:\s*calc\(/.test(meta) && /translateY\(-50%\)/.test(meta), '绝对定位后仍须与按钮行同水平居中')
+
+// 10vw 与三条收敛条件都在顶栏那个变量里声明(单一来源),此处对变量本体验。
+check(/--topbar-status-inset:\s*min\(/.test(topbar), '状态栏内缩须用 min() 收敛(窄屏不得压住菜单)')
+check(/--topbar-status-inset:\s*min\([^;]*10vw/.test(topbar), '状态栏右侧须留整页宽的 10% 空白(10vw)')
+check(!/--topbar-status-inset:\s*min\([^;]*25vw/.test(topbar), '状态栏留白不得仍是 25vw(维护者已压到 10%)')
+check(/--topbar-status-inset:\s*min\([^;]*var\(--topbar-nav-shift\)/.test(topbar), '状态栏收敛条件必须与菜单位移同源')
+// 约束②的常数必须与几何推导一致(324u = 12u内边距 + 300u状态栏 + 12u间距 + 186u半菜单宽)。
+// 写成 510u 在可行视口上恰好不改变结果(10% 留白总是最紧的那条),但它是错的常数:
+// 一旦 10vw 那条被调大,错误常数就会提前触发、把状态栏推出页面。所以直接锁常数。
+check(/--topbar-status-inset:\s*min\([^;]*calc\(50vw - 324\s*\*\s*var\(--u\) - var\(--topbar-nav-shift\)\)/.test(topbar), '「不得压菜单」的常数须为几何推导值 324u(12+300+12-186)')
+check(!/--topbar-status-inset:\s*min\([^;]*calc\(50vw - 510/.test(topbar), '不得用 510u 这个错常数(多算一个半菜单宽)')
+check(/--topbar-status-inset:\s*min\([^;]*50vw - 180\s*\*\s*var\(--u\)/.test(topbar), '状态栏须有「不得超出页面右缘」的兜底(窄屏不被 overflow 裁掉)')
+
+// ---- 复算:按 CSS 里的同一套公式算几何(单位全换算到 px)----
+const NAV_W = 372 // 4 枚按钮各 90u + 3×4u 间隙
+const NAV_HALF = 186
+const META_W = 300 // 归位/进入 + 返回全球 + 128u 时钟槽位 + 2×10u 间隙 + 内边距(留余量)
+const BRAND_INK = 320 // 品牌标题文字带右缘(6u 起点 + 404u 图宽×74.9%文字带)+ 12u 间距
+const GAP = 12
+const unit = (h) => Math.max(1, Math.min(2, 0.1235 * h / 100))
+// 与 CSS 同式:max(-5vw - 32u, 506.6u - 50vw)
+const navShift = (w, h) => Math.max(-0.05 * w - 32 * unit(h), 506.6 * unit(h) - 0.5 * w)
+// 与 CSS 同式:三条约束取 min
+// ② 的常数 324u 由几何推出(12u 内边距 + 300u 状态栏 + 12u 间距 + 186u 半菜单宽),
+// 写成 510u 会让窄屏得到负值、把状态栏推到页面外。
+const statusInset = (w, h) =>
+    Math.min(
+        0.10 * w - 12 * unit(h),
+        0.5 * w - 324 * unit(h) - navShift(w, h),
+        0.5 * w - 180 * unit(h)
+    )
+function geometry(w, h) {
+    const u = unit(h)
+    const navLeft = 0.5 * w - NAV_HALF * u + navShift(w, h)
+    const navRight = navLeft + NAV_W * u
+    const statusRight = w - 12 * u - statusInset(w, h)
+    const statusLeft = statusRight - META_W * u
+    return { u, navLeft, navRight, statusLeft, statusRight, blank: (w - statusRight) / w }
+}
+
+// 可行下限是 1152×720:菜单要避开品牌标题(左缘钉在 ~321u),状态栏本身 300u,
+// 再加两侧内边距与 12u 间距,页面窄于 ~1152u 时三者物理上摆不开 ——
+// 这不是本票引入的缺陷,是「标题 + 4 枚菜单 + 3 个按钮控件」的固有宽度。
+// 更窄的视口不在验收范围内(驾驶舱按 16:9 大屏部署,AGENTS.md 的运行环境也是 1920 档)。
+const VIEWPORTS = [
+    [1152, 720], [1280, 720], [1366, 768], [1600, 900],
+    [1920, 1080], [2384, 1160], [2560, 1440], [3440, 1440], [3840, 2160],
+]
+for (const [w, h] of VIEWPORTS) {
+    const g = geometry(w, h)
+    check(g.statusLeft >= g.navRight + GAP * g.u - 0.5, `${w}x${h}:状态栏不得压住菜单(左缘 ${g.statusLeft.toFixed(0)} vs 菜单右缘 ${g.navRight.toFixed(0)})`)
+    check(g.navLeft >= BRAND_INK * g.u - 0.5, `${w}x${h}:菜单不得压住左侧品牌标题(左缘 ${g.navLeft.toFixed(0)})`)
+    check(g.statusRight <= w + 0.5, `${w}x${h}:状态栏不得超出页面(右缘 ${g.statusRight.toFixed(0)} vs 页宽 ${w})`)
+}
+
+// 目标视口:两条要求必须取到要求值(不是「接近」)。
+const target = geometry(1920, 1080)
+check(
+    Math.abs(navShift(1920, 1080) - (-0.05 * 1920 - 32 * target.u)) < 0.5,
+    '1920×1080:菜单须恰好再左移整页宽的 5%(叠加票 102 的 -32u)'
+)
+check(
+    Math.abs(target.blank - 0.10) < 0.002,
+    `1920×1080:状态栏右缘须恰好停在 90vw,实测留白 ${(target.blank * 100).toFixed(1)}%`
+)
+
+// 窄屏:两条要求冲突时必须收敛(宁可少移,也不许两栏重叠、不许出界)。
+// 1152×720 是可行下限:此处菜单已被标题顶住(位移收敛到标题右缘),留白也收敛。
+const narrow = geometry(1152, 720)
+check(narrow.blank >= 0.10, '1152×720:留白按收敛值执行(不小于 10%,同时不越界)')
+check(narrow.statusLeft >= narrow.navRight + GAP * narrow.u - 0.5, '1152×720:收敛后仍不得重叠')
+check(narrow.statusRight <= 1152 + 0.5, '1152×720:收敛后仍不得出界')
+
+// ---- 既有契约不许被这次位移破坏 ----
+assertModeNavIntact(source)
+checkAbsent(source, '只读视图', '三个读数仍须保持下屏')
+checkAbsent(source, 'configStatusView', '三个读数仍须保持下屏')
+check(
+    /min-width:\s*calc\([^;]*var\(--u\)\)/.test(ruleBody(source, 'cockpit-shell__clock')),
+    '时钟固定数字槽位仍须在位(否则秒针又推动按钮)'
+)
+
+report('t103-topbar-shift')