Add Swiss map component guidance

This commit is contained in:
郭浩 committed 2026-05-13 13:27:30 +08:00
1 parent f6676c3f31
commit f07de00609
5 files changed
+256 -5

No files matched your search

+16
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 仍必须可读。