# 顶层结构（envelope）详解

导出/导入的工程 body 来自编辑器 `toProjectBody()`，形状由 SDK `VpProjectBody` 定义。导入时 `project` 喂给 `new VpProject(project, init)`，其余喂给运行时数据层。

## 0. 一句话规则

- 节点 = 一棵 `VpNode` 树（页面/组件/piece 同构）。
- 导出**不做"去默认值压缩"**：`options` 里有什么就导出什么；没写的项在运行时由蓝图默认值回落。所以手写时**只写需要的项**即可，但 `position`/`size` 建议显式写。

## 1. body 顶层

```jsonc
{
  "project": { /* 必填，见 §2 */ },
  "connections": [],      // 可选，缺省 []
  "connectionData": {},   // 可选，缺省 {}
  "dataRules": [],   // 可选，缺省 []
  "projectParams": {}     // 可选；千人千面参数，一般不写（project.params 已够）
}
```

类型（SDK `VpProjectBody`）：

```ts
interface VpProjectBody {
  project: VpProjectModel
  connections?: VpConnection[]
  connectionData?: VpConnectionData          // Record<connUid, VpDataTable[]>
  dataRules?: unknown[]
  projectParams?: Record<string, unknown>
}
```

## 2. project

```jsonc
{
  "name": "工程名",          // 可选
  "screen": { /* 主页面，必填 */ },
  "screens": [ /* 其余普通页面，可选 */ ],
  "foregroundScreen": { /* 前景层，可选 */ },
  "backgroundScreen": { /* 背景层，可选 */ },
  "windows": [ /* 窗口（弹窗/抽屉/锚定窗口），可选，见 §2b */ ],
  "params": {},             // 项目参数（千人千面）；一般 {}
  "settings": {}            // 工程级设置（工具栏/访问/访客/演示/sizeModes 断点…）；一般 {}
}
```

- 导出时把第一个普通页面放 `screen`，其余放 `screens[]`；导入时再合并回 `VpProject.screens` 顺序。
- 前景/背景层**也用 `type:"screen"`**，区别只在它们挂在 `foregroundScreen`/`backgroundScreen` 键上。
- **最小可导入** project：`{ "screen": { "uid":"screen_main", "type":"screen", "widgets":[] } }`。

类型（`VpProjectModel`）：`id?`、`name?`、`screen`(必填)、`foregroundScreen?`、`backgroundScreen?`、`screens?`、`windows?`、`params?`、`settings?`、`version?`。

## 2b. 窗口 windows（工程级弹窗/抽屉/锚定窗口）

`project.windows[]` 是与页面**平级**的顶层实体（对照 ExB `config.dialogs`）：节点 `type:"window"`（结构同 screen，内容放 `widgets`），由 **`switchWindow` 动作**（`windowUid` + `operate`: open/close/toggle）在播放态开关，或用 **`window-instance` 组件**静态嵌入页面复用。不用窗口就省略整个键。地图图层的 `feature-panel-uid`（点击要素弹锚定窗口）引用的正是这里的窗口 uid。

窗口节点关键 options：

| option | 默认 | 说明 |
|---|---|---|
| `window-mode` | `fixed` | `fixed` 固定位置 / `anchored` 锚定（贴触发组件弹出，地图点位触发时跟随地图） |
| `window-position` | `center` | 固定窗口五锚点：`center`/`top`/`bottom`/`left`/`right`（顶/底只高度生效宽=页宽，左/右只宽度生效） |
| `size` + `size-unit` | — / `px` | 窗口大小；单位 `px`/`percent`(相对舞台) |
| `window-offset` / `window-margin` | `[0,0]` / `[0,0,0,0]` | 位置偏移；贴边窗口铺满轴两侧留白(上下左右) |
| `block-page` + `background-mask-color` | `true` / 半透黑 | 模态：激活期间页面不可交互+遮罩（锚定窗口不适用） |
| `close-outside` | `true` | 点遮罩/画面外关闭 |
| `titlebar-show` + `titlebar-text/height/bg/color/font-size/draggable` | `false` | 内置标题栏（留空 text=窗口名；固定窗口播放态可拖动，位置不持久化） |
| `close-btn-show` + `close-btn-color/size` | `true` | 关闭按钮 |
| 锚定：`anchor-placement`/`anchor-gap`/`anchor-arrow` | `auto`/8/`true` | 弹出朝向（auto 自动翻转）/锚点间距/指向箭头 |
| `window-bg` / `box-shadow-*` / `show-scroll` | `#1b1f27` / 关 / `false` | 背景色/框阴影/内容滚动 |

打开方式除 switchWindow 外：页面 `open-with-window`（进页自动开，一次会话一次）、工程 `settings.splashWindowUid`（启动闪屏，至多一个）、SDK `ctx.openWindow(uid)`（宿主程序化开关）。多窗口按打开顺序成 z 序，Esc 关最顶层。

## 3. 通用节点 VpNode

所有页面/组件/piece 都是这一种结构：

```ts
interface VpNode {
  uid: string
  type: string
  name?: string
  enabled?: boolean            // piece 用，缺省 true
  locked?: boolean
  options?: Record<string, any>
  stateOverrides?: Record<string, Record<string, any>>
  sizeModeOverrides?: Partial<Record<'MEDIUM'|'SMALL', Record<string, any>>>  // 设备差异覆盖（LARGE=基线 options）
  states?: VpState[]
  state?: string
  widgets?: VpNode[]           // 容器(页面/分组面板)的子组件
  pieces?: VpNode[]            // builder(地图/组态)的子元素
  unitTemplate?: VpNode        // 仅 data-repeater：单元模板（group-panel 节点）
  pending?: VpNode[]           // 仅页面根：待处理暂存区（不渲染，编辑器「加回画布」用）
  privateData?: VpPrivateData  // 组件私有数据（表格默认示例数据用）
  conditionalStyles?: VpConditionalStyle[]
  extensions?: VpExtension[]   // 二次开发脚本（代码面板）：{uid,name,enabled,code}
  guideLines?: Record<string, unknown>
}
```

手写时通常只用到：`uid`、`type`、`name?`、`options`、（容器）`widgets`、（地图/组态）`pieces`、（数据循环容器）`unitTemplate`、（多状态）`stateOverrides`、（设备差异）`sizeModeOverrides`。`component-instance` 节点另有顶层 `masterId`/`masterVersion`/`props`/`overrides`/`master`（见 [widgets/containers.md](widgets/containers.md) §5bb）。

## 4. 页面节点 screen

```jsonc
{
  "uid": "screen_main",
  "type": "screen",            // 恒为 screen
  "name": "主页面",            // 可选
  "options": {
    "fullscreen-mode": "stretch",  // 铺满屏幕；不写则默认 scaling 会留黑边
    "size": [1920, 1080],          // 画布尺寸 [宽,高]
    "background-color": "#0c0d0e"  // 背景色
  },
  "stateOverrides": {},
  "widgets": [ /* 顶层组件 */ ]
}
```

页面默认 options（省略即用默认）：

| option | 默认 | 说明 |
|---|---|---|
| `layout-profile` | 推断 | **布局画像主声明**（`canvas-scale`固定大屏 / `canvas-percent`百分比自适应 / `flow-fluid`流式），生成工程时**必写**（见 [visual-style.md](visual-style.md) §3 铺满守则、本文 §4.1） |
| `size` | `[1920,1080]` | 画布大小 `[宽,高]` |
| `background-color` | `#1c222a` | 背景色（新建工程模板常设 `#0c0d0e`） |
| `fullscreen-mode` | `scaling` | 全屏适配方式（见下） |
| `fullscreen-fill-color` | `#000000` | 适配留白色（仅 scaling 留边时可见） |
| `fill-auto-expand` | `false` | 仅 `fill-uniform`/`responsive`：把无显式锚点的主视觉拉满剩余画布（见 4.1） |
| `container-layout` | `static` | `static`/`fluid`/`grid` |
| `carousel-animate` | `false` | 页面轮播动画 |

响应式断点：`project.settings.sizeModes.breakpoints` 声明 SMALL/MEDIUM 宽度阈值，形状 `"settings": { "sizeModes": { "breakpoints": { "small": 767, "medium": 1279 } } }`（视口宽 ≤small→SMALL、≤medium→MEDIUM、否则 LARGE；缺省即此默认，非法/`small≥medium` 回退默认；同级 `widths`/`heights` 只管编辑态预览画布；配合节点 `sizeModeOverrides` 与 `device-visible` 做设备差异化，示例见 `data/组件/入门/28`）。设备档位对**所有**适配方式生效（固定画布也按视口宽切档），但固定画布页里 `position`/`size` 不参与设备差量——写进 `sizeModeOverrides` 也不生效，几何由整屏缩放 + 锚点/自动填充负责；可按设备覆盖的是 `device-visible`、字号配色、容器 `grid-columns`/`fluid-wrap`/`container-layout`、文案等。

### 4.1 fullscreen-mode（全屏适配方式）—— 让大屏铺满屏幕 ⚠️

运行/预览态由运行时的 `useStageScaling` 按**激活页面**的 `fullscreen-mode` 把画布缩放进视口：

| 值 | 含义 | 铺满? | 取舍 |
|---|---|---|---|
| `scaling`(默认) | 等比缩放 contain：`min(cw/bw,ch/bh)` 居中 | ❌ 视口比例≠画布时**留黑边** | 保比、不裁不变形 |
| `fill-uniform` | **等比铺满(左上·不变形)**：等比 min 缩放锚左上，画布撑大到视口、右/下空白由**页面背景**延展填充（仿 autofit） | ✅ | 不变形、内容完整、无黑边；内容锚左上不居中，空白区只有背景（背景为纯色/渐变时视觉无缝）——**「不变形又要铺满」的正解** |
| `adaptive` | 铺满裁切 cover：`max(...)` 居中溢出裁剪 | ✅ | 保比，但**裁掉贴边内容** |
| `stretch` | 拉伸：X/Y 独立铺满 | ✅ | **所有内容都在**；真 16:9 屏与画布同比时无变形，偏离比例才拉伸变形 |
| `responsive` | 页面尺寸跟随容器、内容不缩放回流 | ✅(尺寸) | 固定坐标布局会错位，配百分比/流式用 |
| `fit-width` / `fit-height` | 按宽/高等比铺满，另一轴溢出**滚动** | ✅(单轴) | 竖屏长页门户 / LED 横带条屏 |
| `original` | 1:1 不缩放居中 | ❌ | 仅调试/特殊场景 |

- **缺省 `scaling` 不占满屏幕**：因为默认画布是 16:9(1920×1080)，而预览/发布视口（带工具条、非 16:9 显示器、窗口化）常不是 16:9，于是 contain 留黑边。
- **结论**：要铺满，就在**每个 `type:"screen"` 节点（含 `screens[]`/`foregroundScreen`/`backgroundScreen`）的 options 显式写 `fullscreen-mode`**。固定画布（`container-layout:"static"`）默认用 `"fill-uniform"` + `"fill-auto-expand":true`（不变形+铺满，主视觉吃掉撑开的画布，见 `data/布局/适配/13`～`15`）。`"stretch"` 会把圆拉成椭圆，只留对照例 `data/布局/适配/16`。要裁贴边用 `"adaptive"`。
- **流式/百分比页面（`container-layout:"fluid"` 或画像 `flow-fluid`/`canvas-percent`）只能写 `"responsive"`**：画布=视口、内容回流，`fill-uniform` 对它们无意义（`fill-auto-expand` 也只对 `static` 生效）。

编辑器的新建工程模板起手式已默认 `"fullscreen-mode":"fill-uniform"` + `fill-auto-expand`；但**手写/生成的 JSON 不经过模板**——生成工程时仍须显式写 `fullscreen-mode`。

#### fill-uniform 让内容吃掉扩展区（不变形 + 不留空）

`fill-uniform` 与 `scaling` 的内容倍率相同，差别是留白成了**真实画布**（画布 = 视口 ÷ 缩放系数，多出的在右/下）。谁去占用它有两种写法，优先第一种：

- **锚点**（精确）：主视觉写 `"responsive-x":"left-right","responsive-y":"top-bottom"`（四边贴边），侧栏写 `"responsive-x":"right","responsive-y":"top-bottom"`。锚点原先只在 `responsive` 下生效，现在 `fill-uniform` 同样生效。
- **自动填充**（兜底）：页面写 `"fill-auto-expand": true`，把**没有显式锚点**的 `map-2d`/`scene-3d`/`scada-2d`/`basic-table`/`basic-list`/`deco-border`/布局容器，以及够大的 `basic-card` / `chart-*`（宽 ≥ 画布 15%、高 ≥ 10%）拉到剩余空间，并夹在包裹它的外框、同宽顶栏、右侧与下方邻居之内。外框若也是填充候选，会先撑开，再让内部主视觉跟到新内缘（组态套 `deco-border` 时才能铺满）。贴着设计稿右缘的窄栏（距右约 96px 内的状态卡）随画布变宽右移，与贴边栏水平重叠的同侧小控件作为侧栏簇一起走；横排 KPI 末卡不搬（不依赖是否有装饰边框）。说明顶栏比主视觉宽但右侧还有贴边控件时，主视觉吃到控件左缘。侧栏也可写 `responsive-x: right`。自定义组件可用 `meta.canvasFill` 声明是否候选。

**主视觉被四周固定件包围的通用配方**（左右面板 + 顶/底工具条，见 `data/布局/适配/20`）：两侧面板写 `"responsive-x":"left"`/`"right"` + `"top-bottom"`，底部按钮排写 `"responsive-x":"left-right"` + `"responsive-y":"bottom"`，中间主视觉不写锚点——锚点先跑，自动填充再把主视觉夹进「右侧邻居左缘、下方邻居上缘」。按钮排要按钮跟着变宽重排就用 `layout-flow-row`（带主轴对齐项，`fluid-wrap:"nowrap"` + `fluid-justify-content:"space-between"`；`group-panel` 只有换行开关）。

两者共存时锚点优先。容器不想被撑大（要卡片数不变）就写 `"canvas-grow":"scale"`——但它**只管尺寸不管位置**：这种容器要放在画布增长的背面（靠里一侧）让主视觉吸收增量，摆在右缘/底缘会留一块没人占的背景空白；要贴边又不变大就用锚点 `"responsive-x":"right"`。只写运行态几何，不改 options、不进历史。

## 5. 组件节点 widget

```jsonc
{
  "uid": "w_bar1",            // 任意唯一串（一般无前缀）
  "type": "chart-bar",        // 见 widgets/index.md 注册类型
  "name": "销售柱图",          // 可选；省略用组件默认名
  "options": {
    "position": [100, 100],   // 左上角 [x,y]，默认 [0,0]
    "size": [420, 300],       // [宽,高]，默认随组件
    "category-field": [["conn_x","tbl_x","月份"]],
    "metric-fields": [["conn_x","tbl_x","销售额"]]
  },
  "stateOverrides": {}
}
```

- `name` 仅在你想固定显示名时写；否则按 `defaultName`（如柱状图取 `title` 或回落"柱状图"）。
- 容器型(`group-panel`)把子组件放 `widgets: []`，子组件 `position` 相对容器左上角。
- builder 型(`map-2d`)把本体/图层放 `pieces: []`（见 §7）。

## 6. stateOverrides（多状态）

- 默认状态的值放 `options`；额外状态的**差异覆盖**放 `stateOverrides[stateUid]`。
- 不用多状态：给 `{}` 或直接省略 `stateOverrides`/`states`。
- `stateOverrides["s_x"]: {}` 表示状态 `s_x` 存在但无覆盖（读取回落 `options`）。没有 `stateOverrides[""]` 这种写法（默认态永远读 `options`）。

绝大多数手写工程**不需要**多状态，留 `{}` 即可。

## 7. piece 节点（仅 builder 型，如 2D 地图）

```jsonc
{
  "uid": "map-body",
  "type": "map-body",
  "name": "场景",
  "enabled": true,            // 缺省 true
  "options": { "coordinate-system": "geographic", "basemap": "gaode-vec", "map-center": [116.4,39.9], "map-zoom": 4 },  // coordinate-system 缺省为 "cad"，不写则 basemap 被忽略、渲染空白 CAD 图
  "stateOverrides": {}
}
```

- `map-2d` 的 `pieces` 数组**第一个应是 `map-body`**（本体，单例不可删，建议 `uid:"map-body"`）。
- 其余 piece 为图层：`map-tile` / `map-marker` / `map-geojson`（见 [widgets/map-2d.md](widgets/map-2d.md)）。
- piece 可再嵌 `pieces`（一般用不到）。

## 8. options 序列化的真相（重要）

- 编辑器**不**在导出时和默认值比对裁剪 —— `node.options` 里存在的键就原样导出，被"重置"过的键才会被删掉。
- 因此真实导出文件是**稀疏混合**：新建组件会带 `size`/`position` 等少量键，其余走默认。
- 手写策略：**只写需要偏离默认的项 + 必要的 `position`/`size` + 字段绑定**。其它一律省略，交给默认值，文件更短更稳。
- 想知道某项默认值：查 [widgets/index.md](widgets/index.md) 对应家族文档，或自动生成的 [widget-schema.generated.json](widget-schema.generated.json)。

## 9. 特殊 option 值形态（少见，按需）

- 元素引用：`{ "refType":"element", "targetPath":[screenUid, widgetUid?, pieceUid?] }`（`link-section`/`target-map`/`window` 等引用型 option 与动作目标都用它）。
- 文件引用：`{ "refType":"file", "relativePath":"...", "url":"..." }`。
- 交互(`options.interactions`)：**`{ "version": 2, "groups": [...] }`**，分组按 trigger 内容匹配（v1 哈希键形态已废弃）；完整写法见 [interactions.md](interactions.md)。
