Procházet zdrojové kódy

Add Swiss map component guidance

郭浩 před 1 měsícem
rodič
revize
f07de00609

+ 8 - 3
SKILL.md

@@ -256,6 +256,7 @@ cp "<SKILL_ROOT>/assets/template-swiss.html" "项目/XXX/ppt/index.html"
 - 不允许临时发明 `P23/P24`、`Swiss Image Split`、`Evidence Grid` 这类原始 22P 之外的正文结构,除非用户明确要求实验版式。
 - 顶部中文标题默认左对齐、处在左上内容轴。不要把小标题放左列、大标题放右列,造成视觉居中;只有原始 statement/split 版式允许强中心叙事。
 - SVG 只负责几何图形。不要在 SVG 里写文字标签,所有标签改用 HTML 网格/卡片/caption。
+- 地理/历史/城市路线/地点关系页使用 `S08 + Swiss Map Component`:先读 `references/swiss-map-component.md`,仍保留 `data-layout="S08"`。
 
 原始 22 个正文版式如下:
 
@@ -284,6 +285,8 @@ cp "<SKILL_ROOT>/assets/template-swiss.html" "项目/XXX/ppt/index.html"
 | S21 Tech Spec Sheet | 产品规格 / benchmark |
 | S22 Image Hero | 21:9 顶图 + 标题块 + 三列 KPI |
 
+**登记扩展**:`S08 + Swiss Map Component` 用于地点、人物住所、路线、城市关系。它不是新 layout,而是 S08 右侧插槽的 MapLibre 地图组件;必须按 `references/swiss-map-component.md` 的点位、连线、卡片和右上角缩放/拖动控制实现。
+
 选对应 layout,粘过去,改文案和图片路径即可。**务必先完成 3.0 预检**。
 
 **风格 B 版式多样性硬规则**:
@@ -419,6 +422,7 @@ guizang-ppt-skill/
     ├── layouts.md            ← 风格 A · 10 种页面布局骨架(可直接粘贴,含动效标记)
     ├── swiss-layout-lock.md  ← 风格 B · 原始 22P 版式锁,正文页必须按这里登记
     ├── layouts-swiss.md      ← 风格 B · 原始 22P 骨架说明 + 少量明确标注的实验区
+    ├── swiss-map-component.md ← 风格 B · S08 地图扩展组件(MapLibre 点位/连线/卡片/控制)
     ├── themes.md             ← 风格 A · 5 套主题色预设(只能选不能自定义)
     ├── themes-swiss.md       ← 风格 B · 4 套瑞士风主题色预设(IKB / 柠檬黄 / 柠檬绿 / 安全橙)
     ├── image-prompts.md      ← GPT-M 2.0 配图类型、比例和基础提示词
@@ -436,9 +440,10 @@ guizang-ppt-skill/
 4. 读对应的 layouts 文件挑布局:
    - 风格 A → `layouts.md`(顶部有 Pre-flight 类名清单、主题节奏规划、动效 recipe 决策树)
    - 风格 B → **先读 `swiss-layout-lock.md`**,再读 `layouts-swiss.md`;正文页必须从 S01-S22 选择,每页写 `data-layout`
-5. 如果在 Codex 中生成配图,读 `image-prompts.md` 挑图片类型、比例和基础提示词
-6. 细节调整时读 `components.md` 查组件(含 Motion 动效系统章节,主要服务风格 A;风格 B 的组件细节在 `layouts-swiss.md` 附录)
-7. 生成后先运行 `node scripts/validate-swiss-deck.mjs path/to/index.html`,再读 `checklist.md` 自检
+5. 如果风格 B 需要地点、路线、人物住所或城市关系地图,读 `swiss-map-component.md`
+6. 如果在 Codex 中生成配图,读 `image-prompts.md` 挑图片类型、比例和基础提示词
+7. 细节调整时读 `components.md` 查组件(含 Motion 动效系统章节,主要服务风格 A;风格 B 的组件细节在 `layouts-swiss.md` 附录)
+8. 生成后先运行 `node scripts/validate-swiss-deck.mjs path/to/index.html`,再读 `checklist.md` 自检
 
 **动效相关**:模板已把 Motion One 的加载和 recipe 逻辑内嵌到底部 module script。你不需要改 JS,只需要按 `layouts.md` / `layouts-swiss.md` 的骨架在 HTML 里加 `data-anim` / `data-animate` 即可。离线演示靠 `assets/motion.min.js`,断网时自动降级为"无动画但内容可读"。风格 B 模板必须保留 `B` 键低功耗模式:切换后停止 WebGL/ASCII canvas RAF,取消正在运行的 Web Animations,并把当前页内容直接 reveal 到静态最终态。
 

+ 16 - 0
references/checklist.md

@@ -40,6 +40,22 @@ node <SKILL_ROOT>/scripts/validate-swiss-deck.mjs path/to/index.html
 - 不要把小标题放左列、大标题放右侧大列,这会导致标题视觉居中。
 - 如果需要标题 + 说明两列,必须复制原始 `S11` 或 `S17` 的骨架,不要自写 `4fr 8fr`。
 
+### 0-S-3. Swiss 地图页必须用 S08 Map Component
+
+**现象**:地点/历史内容只画了简易 SVG 地图,没有真实点位、关系卡片、缩放/拖动控制,或滚轮触发了 PPT 翻页。
+
+**做法**:
+- 使用 `data-layout="S08"`。
+- 先读 `references/swiss-map-component.md`。
+- 右侧地图组件必须包含 marker 点、连接线、地点卡片、`+` / `-` / `DRAG` 控制。
+- 默认禁用 scroll zoom 和 drag pan;用户点击 `DRAG` 后才允许拖动。
+- 必须保留静态 fallback,地图 CDN 或瓦片失败时仍可读。
+
+**检查**:
+- `grep -n "data-map-ctrl" index.html`
+- `grep -n "maplibregl.Map" index.html`
+- 浏览器实测 `+` 可放大,`DRAG` 可切换为 `DRAG ON`
+
 ### 0-A. 瑞士风画布对齐法则(每一页必查 · 最常踩)
 
 **现象**:页眉 chrome-min 和底部 footer 都靠在 5vw 的边线上,但中间区域往内缩了一截,左右对不齐。

+ 4 - 0
references/layouts-swiss.md

@@ -19,6 +19,7 @@
 - 顶部中文标题默认左对齐并处在左上内容轴;不要把标题放到页面中间。
 - 不允许临时发明原始 22P 之外的正文结构。本文档末尾的 P23/P24 属于历史实验区,默认禁用。
 - 需要单张大图时使用 `S22 Image Hero`;需要多图时用 `S15/S16` 的原始矩阵/小报骨架改造成图片格。
+- 地点、路线、人物住所、城市关系页使用 `S08 + Swiss Map Component`;这仍然是 S08 的右侧插槽扩展,不是新正文页。先读 `swiss-map-component.md`。
 - SVG 只画几何,不写可见文字。标签放 HTML 里。
 - 生成完成后运行 `node scripts/validate-swiss-deck.mjs index.html`。
 
@@ -207,6 +208,7 @@ Swiss 主题有 22 个登记版式,生成时要主动展示版式系统,不要
 | 发丝线 / border-bottom | 可选;只能用于建立层级,不能为了装饰堆线 |
 | KPI / 数字 | 只在有真实数据时使用;不要为概念解释编造数值 |
 | `footnote` / 底部说明 | 可选;如果使用,必须避开 nav 安全区 |
+| `S08 + Swiss Map Component` | 地点/路线/人物住所关系专用;右侧地图必须有点、连线、卡片和 `+` / `-` / `DRAG` 控制,详见 `swiss-map-component.md` |
 
 ### 通用版式 / 非通用版式
 
@@ -825,6 +827,7 @@ Swiss 主题有 22 个登记版式,生成时要主动展示版式系统,不要
 | 4-6 行账单式 KPI | P20 Stacked Ledger |
 | 产品规格 / benchmark | P21 Tech Spec |
 | 案例图 + 数据落地 | P22 Image Hero |
+| 地点 / 路线 / 人物住所关系 | S08 + Swiss Map Component |
 | 单图解释论点 / 图文混排 | P23 Swiss Image Split |
 | 2-3 张图片/截图/图表证据链 | P24 Swiss Evidence Grid |
 
@@ -842,6 +845,7 @@ Swiss 主题有 22 个登记版式,生成时要主动展示版式系统,不要
 | 6 项对等 | P4 Six Cells / P16 Brief | 不能强凑成 4 用 P19 |
 | 3 项对等 | P5 Sub-cards / P13 Three Forces | |
 | Before/After | P8 Duo Compare(必须正好 2 项) | |
+| 地点/路线/城市关系 | S08 + Swiss Map Component | 普通 S04/S16 卡片罗列 |
 | 闭环结构 | P14 Loop Diagram | P11 横向流程(线性 ≠ 闭环) |
 | 三层嵌套 | P17 System Diagram | |
 | 时间演化(有数据) | P2 Vertical Timeline | |

+ 13 - 2
references/swiss-layout-lock.md

@@ -13,7 +13,7 @@
 ## 生成前硬规则
 
 1. 每个正文页都必须先选一个登记版式,并在 `<section>` 上写 `data-layout="Sxx"`。
-2. 不允许临时发明 `P23/P24` 这类未出现在原始 22P 的正文结构。需要图片时,优先使用 `S22 Image Hero`;多图时使用 `S15/S16` 的原始网格骨架做图片格改造,不要发明新的证据墙。
+2. 不允许临时发明 `P23/P24` 这类未出现在原始 22P 的正文结构。需要图片时,优先使用 `S22 Image Hero`;多图时使用 `S15/S16` 的原始网格骨架做图片格改造,不要发明新的证据墙。唯一登记的交互扩展是 `S08 + Swiss Map Component`,详见 `references/swiss-map-component.md`。
 3. 顶部中文标题默认左对齐并贴近左上内容轴。除原始 `S03/S09/S10` 这种 statement/split 版式外,不要把大标题放到页面水平中心。
 4. SVG 只能负责几何线条、圆、箭头、路径。不要在 SVG 里写可见文字;所有文字标签用 HTML 放在网格、卡片或 caption 里。
 5. 图片槽位和图片生成比例必须绑定。先确定版式和槽位,再生成图片。
@@ -29,7 +29,7 @@
 | S05 | 05 | Three Layers | 顶部左对齐标题,下方 `.stack-row` 三大块 | 无 |
 | S06 | 06 | KPI Tower | 左标题+右说明,下方不等高 KPI 塔 | 无 |
 | S07 | 07 | Horizontal Bar | 左对齐标题,横向条形图 | 无 |
-| S08 | 08 | Duo Compare | `.duo-compare` 两列 + 中线 | 无 |
+| S08 | 08 | Duo Compare | `.duo-compare` 两列 + 中线 | 无;地点/路线内容可使用 `S08 + Swiss Map Component` 替换右侧插槽 |
 | S09 | 09 | Dot Matrix Statement | 大号 statement + 点阵装饰 | 无 |
 | S10 | 10 | Split Closing | `.slide.split` 左巨字右列表 | 无 |
 | S11 | 11 | Horizontal Timeline | 原始 `grid-template-columns:auto 1fr` 头部 + `.timeline-h` | 无 |
@@ -45,6 +45,17 @@
 | S21 | 21 | Tech Spec Sheet | 大标题 + 三 KPI + 右下竖线矩阵 | 无 |
 | S22 | 22 | Image Hero | 顶部全宽图 + 左上白块标题 + 下方三列 KPI | 主图按 `21:9` 生成,关键主体放中央安全区 |
 
+## 登记扩展组件
+
+### S08 + Swiss Map Component
+
+- 使用场景:地理、历史、城市路线、门店/校区/事件点位、人物住所关系。
+- 版式身份:仍是 `data-layout="S08"`,不是新正文页。
+- 页面结构:顶部左对齐标题 + 左侧关系/说明卡片 + 右侧 MapLibre 地图卡片。
+- 标记结构:点 + 连线 + HTML 卡片;SVG 只画 fallback 关系线,不写文字。
+- 交互控制:右上角必须有 `+` / `-` / `DRAG`;默认禁用滚轮缩放和拖动,避免触发 PPT 翻页。
+- 详细代码和数据契约见 `references/swiss-map-component.md`。
+
 ## 图片槽位规则
 
 ### S22 · Hero Strip

+ 215 - 0
references/swiss-map-component.md

@@ -0,0 +1,215 @@
+# Swiss Map Component
+
+用于地理、历史、城市、人文路线、门店/校区/事件点位等内容。它不是新的 Swiss 正文版式,而是 **S08 Duo Compare 的右侧插槽扩展**:左侧仍是解释卡片,右侧替换为地图组件。
+
+## 何时使用
+
+- 文档里出现地点、街区、路线、人物住所、机构分布、城市漫游。
+- 用户明确希望有地图、点位、关系线或地理组件。
+- 内容需要解释“空间关系”,而不只是罗列人物或地点。
+
+## 硬规则
+
+- `<section>` 仍写 `data-layout="S08"`;不要新增 `P23/P24` 或自定义正文页。
+- 页面结构必须是:顶部标题 + 左侧说明卡片 + 右侧地图卡片。
+- 地图标记由 HTML 组件组成:点 `.pin-dot` + 连线 `.pin-line` + 卡片 `.pin-card`。
+- SVG 只画 fallback 关系线,不要在 SVG 里写文字。
+- MapLibre 地图默认关闭滚轮缩放和拖动,避免触发 PPT 翻页。
+- 右上角必须有 `+` / `-` / `DRAG` 控制。用户点击 `DRAG` 后才允许拖动地图。
+- 必须有静态 fallback:CDN 或地图瓦片失败时,仍能看到点位、关系线和卡片。
+
+## 数据契约
+
+写页面前先定义点位和关系。`x/y` 用于静态 fallback 百分比坐标,`coord` 用于 MapLibre 经纬度。
+
+```js
+const MAP_POINTS = [
+  { id: 'gu', name: '顾维钧', meta: '外交', coord: [117.2048, 39.1060], x: 62, y: 68, accent: true },
+  { id: 'cao', name: '曹锟', meta: '北洋', coord: [117.1988, 39.1080], x: 34, y: 48 },
+  { id: 'sun', name: '孙殿英', meta: '军阀', coord: [117.2028, 39.1090], x: 52, y: 54 },
+  { id: 'zhang', name: '张自忠', meta: '抗战', coord: [117.1966, 39.1120], x: 58, y: 28, accent: true },
+  { id: 'jin', name: '金氏宅邸', meta: '交通站', coord: [117.2012, 39.1114], x: 66, y: 35, side: 'left' },
+];
+
+const MAP_RELATIONS = [
+  ['gu', 'cao'],
+  ['cao', 'sun'],
+  ['zhang', 'jin'],
+];
+```
+
+## 必要 CSS
+
+放到生成页 `<head>` 的额外 `<style>` 中,不要改 `template-swiss.html` 的全局基座类。
+
+```html
+<link href="https://unpkg.com/maplibre-gl@5.14.0/dist/maplibre-gl.css" rel="stylesheet">
+<script src="https://unpkg.com/maplibre-gl@5.14.0/dist/maplibre-gl.js"></script>
+<style>
+.history-map-grid{display:grid;grid-template-columns:4.2fr 7.8fr;gap:2vw;flex:1;min-height:0;margin-top:2vh;align-items:stretch}
+.history-side{display:grid;grid-template-rows:1.08fr repeat(4,1fr);gap:1vh;min-height:0;height:100%}
+.history-side-head{background:var(--accent);color:var(--accent-on);padding:2.2vh 1.4vw 1.8vh;border-radius:3px}
+.history-side-head .big{font-family:var(--sans),var(--sans-zh);font-size:max(22px,2.2vw);font-weight:300;line-height:1.08;letter-spacing:-.02em}
+.history-side-head .small{font-family:var(--sans),var(--sans-zh);font-size:max(11px,.82vw);font-weight:300;line-height:1.55;color:rgba(255,255,255,.82);margin-top:1.2vh}
+.relation-card{background:var(--grey-1);padding:1.45vh 1.1vw;border-radius:3px;display:grid;grid-template-columns:auto 1fr;gap:.8vw;align-items:start;min-height:0}
+.relation-card .nb{font-family:var(--mono);font-size:max(10px,.75vw);letter-spacing:.16em;color:var(--accent)}
+.relation-card .ttl{font-family:var(--sans),var(--sans-zh);font-size:max(14px,1.05vw);font-weight:500;line-height:1.25}
+.relation-card .desc{font-family:var(--sans),var(--sans-zh);font-size:max(11px,.78vw);line-height:1.5;color:var(--text-secondary);margin-top:.55vh}
+.map-panel{position:relative;background:var(--grey-1);border-radius:3px;overflow:hidden;min-height:0;height:100%}
+.map-panel .map-title{position:absolute;top:1.4vh;left:1.2vw;z-index:3;background:rgba(250,250,248,.92);padding:1.2vh 1vw;border-radius:3px;max-width:28vw}
+.map-panel .map-title .k{font-family:var(--mono);font-size:max(10px,.72vw);letter-spacing:.18em;color:var(--text-helper)}
+.map-panel .map-title .t{font-family:var(--sans),var(--sans-zh);font-size:max(18px,1.5vw);font-weight:400;letter-spacing:-.015em;margin-top:.4vh}
+.map-controls{position:absolute;top:1.4vh;right:1.2vw;z-index:4;display:flex;gap:6px;background:rgba(250,250,248,.9);padding:6px;border-radius:3px}
+.map-ctrl{min-width:32px;height:32px;border:1px solid var(--ink);background:transparent;color:var(--ink);font-family:var(--mono);font-size:12px;letter-spacing:.08em;text-transform:uppercase;border-radius:0;cursor:pointer}
+.map-ctrl.drag{min-width:58px}
+.map-ctrl.active{background:var(--accent);border-color:var(--accent);color:var(--accent-on)}
+.wudadao-map,.swiss-map{position:absolute;inset:0;background:#f4f4f0}
+.wudadao-map.map-live .map-static,.swiss-map.map-live .map-static{display:none}
+.map-static{position:absolute;inset:0;display:block;background:linear-gradient(18deg,transparent 0 44%,rgba(25,25,25,.11) 44% 44.2%,transparent 44.2%),linear-gradient(-8deg,transparent 0 54%,rgba(25,25,25,.09) 54% 54.16%,transparent 54.16%),linear-gradient(0deg,transparent 0 61%,rgba(25,25,25,.08) 61% 61.15%,transparent 61.15%),#f4f4f0}
+.static-relations{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
+.static-relations line{stroke:var(--accent);stroke-width:.24;stroke-dasharray:1.4 1.2;opacity:.68}
+.static-marker{position:absolute;transform:translate(-50%,-50%);width:0;height:0}
+.static-marker .pin-dot,.person-marker .pin-dot{position:absolute;left:-6px;top:-6px;width:12px;height:12px;border-radius:50%;background:var(--ink);border:2px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.22)}
+.static-marker.accent .pin-dot,.person-marker.accent .pin-dot{background:var(--accent)}
+.static-marker .pin-line,.person-marker .pin-line{position:absolute;left:7px;top:0;width:24px;height:1px;background:var(--ink);opacity:.45}
+.static-marker.accent .pin-line,.person-marker.accent .pin-line{background:var(--accent);opacity:.75}
+.static-marker .pin-card,.person-marker .pin-card{position:absolute;left:31px;top:-18px;min-width:72px;background:rgba(250,250,248,.9);box-shadow:0 0 0 1px rgba(0,0,0,.06);border-radius:2px;padding:6px 7px;font-family:var(--sans),var(--sans-zh);white-space:nowrap}
+.static-marker .pin-name,.person-marker .pin-name{font-size:12px;line-height:1.05;color:var(--ink)}
+.static-marker .pin-meta,.person-marker .pin-meta{font-family:var(--mono);font-size:9px;line-height:1;letter-spacing:.12em;color:var(--text-helper);margin-top:4px;text-transform:uppercase}
+.static-marker.accent .pin-name,.person-marker.accent .pin-name{color:var(--accent)}
+.static-marker.left .pin-line,.person-marker.left .pin-line{left:auto;right:7px}
+.static-marker.left .pin-card,.person-marker.left .pin-card{left:auto;right:31px}
+.person-marker{position:relative;width:0;height:0;pointer-events:auto}
+.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-bottom-right{display:none!important}
+</style>
+```
+
+## 页面骨架
+
+```html
+<section class="slide" data-layout="S08" data-animate="duo-mirror">
+  <div class="canvas-card">
+    <header class="chrome-min"><div class="l">06 / NN · MAP COMPONENT</div><div class="r">MAPLIBRE / STATIC FALLBACK</div></header>
+    <h2 class="h-xl-zh">把人物住所放回街区里</h2>
+    <div class="history-map-grid">
+      <aside class="history-side">
+        <div class="history-side-head">
+          <div class="big">住所不是点位,<br/>而是关系入口。</div>
+          <div class="small">这页用地图承载空间关系,用左侧卡片解释人物之间的牵连。</div>
+        </div>
+        <div class="relation-card"><div class="nb">01</div><div><div class="ttl">顾维钧 ↔ 曹锟</div><div class="desc">说明两者为什么有关系,至少写成完整一句。</div></div></div>
+        <div class="relation-card"><div class="nb">02</div><div><div class="ttl">曹锟 ↔ 孙殿英</div><div class="desc">不要只写标签,写清历史关系或空间关系。</div></div></div>
+        <div class="relation-card"><div class="nb">03</div><div><div class="ttl">张自忠 ↔ 金氏宅邸</div><div class="desc">每张卡控制在 2-3 行,形成信息密度。</div></div></div>
+        <div class="relation-card"><div class="nb">04</div><div><div class="ttl">张自忠 ↔ 利德尔</div><div class="desc">可以用跨身份对照补充人文厚度。</div></div></div>
+      </aside>
+      <div class="map-panel">
+        <div class="map-title"><div class="k">RELATION MAP</div><div class="t">地点 / 人物 / 事件</div></div>
+        <div class="map-controls" aria-label="地图控制">
+          <button class="map-ctrl" type="button" data-map-ctrl="zoom-in" aria-label="放大地图">+</button>
+          <button class="map-ctrl" type="button" data-map-ctrl="zoom-out" aria-label="缩小地图">-</button>
+          <button class="map-ctrl drag" type="button" data-map-ctrl="drag" aria-label="拖动地图" aria-pressed="false">DRAG</button>
+        </div>
+        <div id="swiss-map" class="swiss-map" data-points='[填入 JSON]' data-relations='[填入 JSON]'>
+          <div class="map-static" aria-hidden="true">
+            <svg class="static-relations" viewBox="0 0 100 100" preserveAspectRatio="none">[静态连线]</svg>
+            [静态 marker 卡片]
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</section>
+```
+
+## 必要 JS
+
+放到 `</body>` 前。生成多张地图页时,把 id 从 `swiss-map` 改成唯一 id,并让初始化函数接收 selector。
+
+```html
+<script>
+(() => {
+  function readJson(el, key, fallback){
+    try { return JSON.parse(el.dataset[key] || ''); }
+    catch { return fallback; }
+  }
+  function initSwissMap(){
+    const el = document.getElementById('swiss-map');
+    if(!el || el.dataset.ready) return;
+    el.dataset.ready = '1';
+    const points = readJson(el, 'points', []);
+    const relations = readJson(el, 'relations', []);
+    function coord(id){ return points.find(p => p.id === id).coord; }
+    const panel = el.closest('.map-panel');
+    panel?.addEventListener('wheel', (event) => event.stopPropagation(), {passive:true});
+    ['pointerdown','pointermove','pointerup','click','dblclick','touchstart','touchmove'].forEach((type) => {
+      panel?.addEventListener(type, (event) => event.stopPropagation(), {passive:true});
+    });
+    if(window.__lowPowerMode || !window.maplibregl){
+      el.classList.add('fallback-only');
+      return;
+    }
+    const center = points.length
+      ? [points.reduce((sum, p) => sum + p.coord[0], 0) / points.length, points.reduce((sum, p) => sum + p.coord[1], 0) / points.length]
+      : [0, 0];
+    const map = new maplibregl.Map({
+      container: el,
+      style: {
+        version:8,
+        sources:{ osm:{ type:'raster', tiles:['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize:256, attribution:'© OpenStreetMap contributors' } },
+        layers:[{ id:'osm', type:'raster', source:'osm', paint:{ 'raster-saturation':-0.88, 'raster-contrast':0.08, 'raster-opacity':0.46 } }]
+      },
+      center,
+      zoom: Number(el.dataset.zoom || 15),
+      interactive: true,
+      attributionControl: false
+    });
+    map.scrollZoom.disable();
+    map.boxZoom.disable();
+    map.doubleClickZoom.disable();
+    map.dragPan.disable();
+    map.on('load', () => {
+      el.classList.add('map-live');
+      map.addSource('relations', {
+        type:'geojson',
+        data:{ type:'FeatureCollection', features:relations.map(([a,b]) => {
+          const from = coord(a);
+          const to = coord(b);
+          return from && to ? { type:'Feature', geometry:{ type:'LineString', coordinates:[from, to] }, properties:{} } : null;
+        }).filter(Boolean) }
+      });
+      map.addLayer({ id:'relations', type:'line', source:'relations', paint:{ 'line-color':'#1936b3', 'line-opacity':.62, 'line-width':2, 'line-dasharray':[2,2] } });
+      for(const p of points){
+        const marker = document.createElement('div');
+        marker.className = 'person-marker' + (p.accent ? ' accent' : '') + (p.side === 'left' ? ' left' : '');
+        marker.innerHTML = '<span class="pin-dot"></span><span class="pin-line"></span><span class="pin-card"><span class="pin-name">' + p.name + '</span><span class="pin-meta">' + p.meta + '</span></span>';
+        marker.title = p.name;
+        new maplibregl.Marker({ element: marker }).setLngLat(p.coord).addTo(map);
+      }
+      setTimeout(() => map.resize(), 300);
+    });
+    document.getElementById('deck')?.addEventListener('transitionend', () => map.resize());
+    const zoomIn = panel?.querySelector('[data-map-ctrl="zoom-in"]');
+    const zoomOut = panel?.querySelector('[data-map-ctrl="zoom-out"]');
+    const drag = panel?.querySelector('[data-map-ctrl="drag"]');
+    zoomIn?.addEventListener('click', (event) => { event.stopPropagation(); map.zoomIn(); });
+    zoomOut?.addEventListener('click', (event) => { event.stopPropagation(); map.zoomOut(); });
+    drag?.addEventListener('click', (event) => {
+      event.stopPropagation();
+      const active = drag.classList.toggle('active');
+      drag.setAttribute('aria-pressed', active ? 'true' : 'false');
+      drag.textContent = active ? 'DRAG ON' : 'DRAG';
+      if(active) map.dragPan.enable(); else map.dragPan.disable();
+    });
+  }
+  window.addEventListener('DOMContentLoaded', () => setTimeout(initSwissMap, 500));
+})();
+</script>
+```
+
+## 视觉检查
+
+- 左侧卡片总高度要和右侧地图卡片对齐,不要上浮一半。
+- 地图标题和控制按钮不能互相遮挡;点位卡片不能压到右上角控制区。
+- marker 卡片至少显示地点名,`meta` 只作为短标签。
+- 左侧关系卡不要惜字如金,每张卡应有完整一句解释。
+- 若地图无法加载,静态 fallback 仍必须可读。