# 可移植 SVG 图元

> 编写并分享静态 SVG 图元，带安全的 Ganter 绑定和有类型的连接点。

Ganter 图元把图形和实时数据分开。图形就是普通的静态 SVG。每一层数值映射和每一段动画都归 Ganter Lab 自己管，所以导入进来的文件跑不了脚本、事件处理程序、内嵌的动画代码或者网络请求。本页是这套编写模型的完整参考：一个图元声明什么、导入接受和拒绝什么，以及图元怎样在工作站之间流转。

一个图元在图形之上声明三样东西：

- **可绑定区域**：有名字的输入（填充、描边、运动、文字、可见性），放置之后的[仪表画面](home)实例再从一个 Logic 地址或者一个固定值把它们喂起来。
- **连接点**：周边上有类型的连接位置，好让仪表画面在图元之间走管道、风管、导线和轴。
- **建议**：可选的、明确摆好姿态的组合方式，说明它和别的图元怎么拼。

## 系统图元与我的图元 {#system-symbols-and-my-symbols}

目录有两种来源，凡是列出图元的地方都能按它筛选：

| 来源 | 引用 | 它是什么 |
| --- | --- | --- |
| 系统 | `system:<stable-key>` | 随应用一起发布的内置图元库。可以查看、可以导出，但绝不能就地编辑。 |
| 我的 | `user:<guid>` | 你创建、导入或者复制出来的图元。可以编辑，也可以删除。 |

每一个系统图元都提供**复制到“我的图元”**：这个副本是一份冻结下来、独立归你所有的用户素材（图形、视图框、适配、占位尺寸、可绑定区域、类型配置、连接点和建议一起复制过去），所以后来某次应用更新改了原件，它照样按你选定的样子工作。删除一个用户图元会先问一句；还引用着它的仪表画面显示一个占位图形，而不是就此坏掉。

## 造一个图元的几条路 {#ways-to-create-a-symbol}

- **导入一个文件**：**Logic > Assets > Symbols > 导入图元**接受一个普通的 `.svg`，或者一个完整的 `.ganter-symbol.json` 包。每一次导入都造一个**新**素材，各有各的身份；它绝不覆盖已有的图元，撞名时靠加后缀（“ 2”“ 3”……）解决，而不是失败。选择器在读任何字节之前就拒绝超过 **1 MB** 的文件。裸 SVG 导入之后，应用会报出它移除了多少处不安全的内容（如果有的话）。
- **在应用里编写**：**新建用户图元**会打开一个编辑器，用来填名称、SVG 标记、仪表画面布局（适配和初始占位尺寸）以及那些可绑定区域；图元一旦存在，合法的更改就自动保存。连接点的几何位置在应用里有意**不**可编辑：有类型的连接点只能由一个包提供，而由包定义的连接点在后续编辑中原样只读保留。
- **问一个 AI**：两条路走的是完全同一份约定。通过内置的 [MCP endpoint](agent)，`symbol_validate` 工具把导入空跑一遍，`symbol_import` 把一个新素材存下来；两者的说明里都带着完整的约定。而在一个够不着应用的外部对话里，图元面板上的**复制对话指令**会复制一段自成一体的提示词；把返回的 SVG 或者包存成文件，再在这里导入。

关于它周围那个资源工作区，见 [Logic](logic)。

## 备好一个普通的 SVG {#prepare-a-plain-svg}

给你想绑定的每一个部件一个稳定的 SVG `id`。导入之后，这些 id 会出现在可绑定区域的目标下拉框里。id 和可绑定区域的名称共用一套写法：首字符是字母或者 `_`，后面接字母、数字、`_`、`.`、`:` 或者 `-`，最长 128 个字符。

可选的 `data-ganter-*` 属性，是一个图元唯一可以携带的 Ganter 专有标记：

| 属性 | 写在哪里 | 它声明什么 |
| --- | --- | --- |
| `data-ganter-pivot="x y"` | 任何可绑定的元素 | 旋转、缩放和持续旋转这三种可绑定区域所用的确切支点（视图框坐标）。这三种的目标元素上必须有它。 |
| `data-ganter-spin-ratio` | 一个持续旋转元素的子元素 | 一个看得见的子转子的相对转速。有限、非零，绝对值不超过 100；负值表示反向。除非这个元素也声明了自己的支点，否则它会被移除。 |
| `data-ganter-role` | 任何元素 | 给编写工具用的一个稳定语义角色。仅供参考。 |
| `data-ganter-fit-width` | `<text>` / `<tspan>` | 文字可绑定区域的一个可选的限宽显示：绑上去的文字量出来比这么多视图框单位还宽时，字号会缩小以便放得下。 |
| `data-ganter-fit-min-font-size` | `<text>` / `<tspan>` | 缩小时允许到的最小字号。再小就显示回退文字，而完整的值仍留在无障碍名称和提示文字里。 |
| `data-ganter-fit-fallback` | `<text>` / `<tspan>` | 连最小字号都放不下这个值时显示的那段文字。 |

连接点不住在 SVG 里，也不对准 SVG 元素的 id。叫做 `data-ganter-port` 或者 `data-ganter-port-*` 的属性不属于这个格式：裸 SVG 导入会把它们移除，而带着它们的严格包会被拒绝。

裸 SVG 导入永远得到一个合法的**仅视觉**的图元：零个连接点、零个可绑定区域。检视面板会说明没有可连接的连接点，因为角色、类型、朝外方向和内部路径都没有声明。Ganter 不从文件名、类别或者图形去猜这些字段，也不提供内部的连接点或路径编辑器。

## 净化器接受什么 {#what-the-sanitizer-accepts}

同一套 SVG 策略适用于每一条路（文件导入、应用内编辑器、MCP、包）。它有意比 SVG 小一圈：

- **保留**：惰性的几何和结构，也就是 `svg`、`g`、`defs`、`symbol`、`use`、`path`、`rect`、`circle`、`ellipse`、`line`、`polyline`、`polygon`、`text`、`tspan`、`title`、`desc`、`clipPath`、`mask`、`linearGradient`、`radialGradient`、`stop`、`pattern`、`marker`，连同表现属性（填充、描边、不透明度、变换、样式）和 ARIA 标签。
- **移除或拒绝**：`<script>`、`<style>`、`<foreignObject>`、事件处理属性、内嵌动画，以及任何外部的或者 `data:`/`file:`/`http(s)` 的 URL。只有内部的 `url(#id)` 和 `href="#id"` 引用留得下来。
- **限值**：标记最大 **512 KB**、最多 **4096** 个元素。

安全的上色值是十六进制颜色、CSS 颜色名、`rgb()`/`rgba()`/`hsl()`/`hsla()`，以及应用的主题变量 `var(--color-<name>)`（例如 `var(--color-success)`、`var(--color-symbol-off)`）。一份图形靠主题变量适应浅色和深色：发布一份引用变量的图，永远不要发两个主题版本。

违规怎么处理，取决于走的是哪条路：**裸 SVG** 会被修补（不安全的内容被移除，每一次移除都作为一条警告报出来）；**包**是严格的，会点名第一批违规并拒绝，而不是悄悄替它修补。

## 可移植的 v2 包 {#portable-package-v2}

导出一个图元会造出一个 `.ganter-symbol.json` 文件。外壳的每一个字段都是必填的；那三个约定数组可以为空，但必须存在：

| 字段 | 要求 |
| --- | --- |
| `$schema` | 严格是 `https://ganterlab.com/schemas/ganter-symbol-v2.schema.json`。 |
| `format` | 严格是 `ganter-symbol`。 |
| `version` | 严格是 `2`。版本 1 的包会被拒绝，而不是交给一个旧版解析器。 |
| `name` | 显示名称。导入时可以覆盖它，撞名时自动加后缀。 |
| `svg` | 净化过的静态 SVG，它的根元素带着一个和 `viewBox` 字段相等的 `viewBox`。 |
| `viewBox` | `minX minY width height`：四个有限数，宽和高为正。 |
| `fit` | `contain`、`stretch`、`stretch-x` 或者 `stretch-y`。 |
| `footprint` | `{ "width": 1..24, "height": 1..12 }`，也就是建议的仪表画面格数。 |
| `slots` | 可绑定区域声明的数组（见下）。 |
| `typeConfigurations` | 共用的连接点类型选择的数组（见下）。 |
| `terminals` | 最多 16 个完整连接点的数组（见下）。 |
| `suggestions` | 按顺序排列的编写好的组合方式的数组（见下）。 |

公开的 [Ganter symbol v2 JSON Schema](/schemas/ganter-symbol-v2.schema.json) 校验传输用的那层外壳。导入程序随后做 JSON Schema 表达不了的那些语义检查：SVG 目标是否存在、内部路径的几何、建议的兼容性。包在每一个方面都是严格的：不安全的标记、未知字段、数组里的空条目，以及旧的 `ports[]`、`anchors[]`、`profile` 和 `connectionStyle` 约定，都会被拒绝而不是被修补。

包里从来不含工作站地址、仪表画面绑定、某个实例的类型选择、数据库 GUID 或者用户身份。带着 Logic 地址的可绑定区域甚至无法导出：地址属于放置好的那个实例，不属于这个可复用的图元。

## 尺寸、视图框和适配 {#size-view-box-and-fit}

**视图框**是这个图元自然的坐标空间。连接点和内部路径都在它里面声明，而它必须和 SVG 根自己的 `viewBox` 完全一致；两个视图框对不上的包会被拒绝。非正方形的视图框完全支持，所以一条长长的输送带不必挤在一个正方形里。

**适配方式**说的是图形怎样使用操作员画出来的那个仪表画面矩形：

| 适配 | 行为 |
| --- | --- |
| `contain` | 保持长宽比；图形在格子矩形里留边显示。这是默认值。 |
| `stretch` | 两个方向都填满矩形，必要时变形。 |
| `stretch-x` | 横向拉伸，竖向保持自然比例。 |
| `stretch-y` | 竖向拉伸，横向保持自然比例。 |

**占位尺寸**是图元第一次放置时的初始大小，以仪表画面的格数计：宽 1 到 24，高 1 到 12（编辑器默认 4 × 4）。它是一个建议，不是一条约束；操作员之后可以随意调整大小。

有两道各自独立的关卡限制一次导入的大小：文件选择器在读文件之前就拒绝超过 **1 MB** 的东西，而净化器拒绝超过 **512 KB** 或者超过 **4096** 个元素的标记。

## 可绑定区域：绑定的那层接口 {#slots--the-binding-surface}

一个可绑定区域声明一路输入：它驱动哪个元素、对它做什么，以及输入值怎样映射到输出。放置好的仪表画面实例之后为每一个可绑定区域挑定信号源（一个 Logic 地址或者一个固定值）；图元本身从不保存信号源。

每一个可绑定区域带着：

| 字段 | 含义 |
| --- | --- |
| `name` | 显示给仪表画面作者看的参数名。写法见上；在这个图元的各个可绑定区域之间不重名。 |
| `elementId` | 一个已存在的 SVG 元素的 `id`。同一个元素在每个通道上最多接一个可绑定区域，所以两个区域不会为同一个元素的填充打架。 |
| `kind` | 下面十三种之一。 |
| `inMin`、`inMax` | 输入范围：映射到 `outMin`/`outMax` 的那两个值。有限数；连续的那几种用它，`fill`/`stroke` 把它当阈值中点用。 |
| `outMin`、`outMax` | 按这一种的单位给出的输出范围（度、视图框单位、0..1、度每秒）。有限数。 |
| `outMinSecondary`、`outMaxSecondary` | 可选的第二路输出轴，只用于 `translate` 和 `scale`：主输出是 X，第二路是 Y。要么两个都给，要么都不给。 |
| `onColor`、`offColor` | 给 `fill`/`stroke` 用的安全上色值（阈值两侧的两种状态）。对 `fillColor`/`strokeColor` 来说，`onColor` 是检视面板的预览颜色。 |

那十三种可绑定区域：

| 种类 | 它驱动什么 | 备注 |
| --- | --- | --- |
| `rotate` | 旋转元素；输入线性映射成角度。 | 目标必须声明 `data-ganter-pivot`。 |
| `opacity` | 元素的不透明度；输入映射到 0..1。 | |
| `fill` | 在输入范围中点以下用 `offColor` 上填充色，到达或超过中点用 `onColor`。Bool 输入直接切换。 | 对准可以填充的形状（不含 `<line>`）。 |
| `visible` | 按值的真假显示或者隐藏元素。 | |
| `text` | 用格式化之后的值替换元素的文字内容。 | 只对准 `<text>`/`<tspan>`；配上 `data-ganter-fit-*` 属性就是一个限宽的数值显示。 |
| `translate` | 以视图框单位移动元素；主输出是 X，可选的第二路是 Y。 | |
| `scale` | 缩放元素；主输出是 X，第二路是 Y（不给就是等比缩放）。 | 目标必须声明 `data-ganter-pivot`。 |
| `stroke` | 按和 `fill` 一样的阈值规则给描边上色。 | |
| `spin` | 随时间持续旋转；输入映射成角速度，单位是度每秒。带着 `data-ganter-spin-ratio` 的子元素按声明的比例一起转。 | 目标必须声明 `data-ganter-pivot`。运动由宿主调度，与操作系统的动画偏好无关。 |
| `spinEnabled` | 暂停或者恢复同一个元素上的 `spin` 区域，而不隐藏它。 | 要求同一个元素上有一个 `spin` 区域。 |
| `fillColor` | 直接用绑上来的颜色值设置填充（一个颜色素材、一个颜色变量，或者一个安全的 CSS 颜色）。 | |
| `strokeColor` | 直接用绑上来的颜色值设置描边。 | |
| `strokeWidth` | 直接设置描边宽度，以 SVG 用户单位计，**0.5 到 32**；不接受单位后缀，也不接受 CSS。 | 输入不合法、解析不到或者质量不好时，恢复编写时的 `stroke-width`；重置、重新挂载和释放同样如此。 |

这些可绑定区域给图形上样式；它们从不给仪表画面的连线走向上样式，那些走向的颜色和宽度是按连线类型全局定的。

## 绑定归谁所有 {#binding-ownership}

图元声明的是参数和允许的类型选择，不是实时信号源，也不是某个实例的选择。放下一个图元组件之后，操作员把每一个可绑定区域映射到一个 Logic 地址或者一个固定值，并在那个实例上为每一个可配置的连接点挑定类型。这些选择留在工作站里，绝不会随可复用的图元一起导出。没有映射的可绑定区域保持中性：元素保留它编写时的外观。

## 全局位置与连线类型 {#global-positions-and-connection-types}

每一个连接点都引用固定的十六个周边锚点目录里的一个位置。包里只存 `anchorId`；它从不重复、也不覆盖坐标。这些位置，以视图框的归一化比例表示：

- 上边：`top-left` (0, 0)、`top-25` (0.25, 0)、`top-50` (0.5, 0)、`top-75` (0.75, 0)、`top-right` (1, 0)；
- 右边：`right-25` (1, 0.25)、`right-50` (1, 0.5)、`right-75` (1, 0.75)；
- 下边，顺时针：`bottom-right` (1, 1)、`bottom-75` (0.75, 1)、`bottom-50` (0.5, 1)、`bottom-25` (0.25, 1)、`bottom-left` (0, 1)；
- 左边，继续顺时针：`left-75` (0, 0.75)、`left-50` (0, 0.5)、`left-25` (0, 0.25)。

这些是周边上的位置，不是一个 5×5 的网格。一个图元既不能新增位置，也不能存自己的锚点坐标。

那八种全局连线类型是 `liquid`、`gas`、`air-duct`、`electrical`、`signal`、`network`、`material` 和 `mechanical-shaft`。请用 **Logic > Assets > Connection types**配置每一种类型对应的颜色和显示描边宽度。图元和连线保留的是稳定的类型 id，所以一次全局更改会给用到它的每一条走向重新上样式。包从不复制颜色或者宽度，也没有小、标准、大之类的走向变体。

两个端点在解析出同一种类型时构成一条已定义的连线。连接点的角色有助于编写和复核，但它本身并不禁止某种接线方式。

## 共用的类型选择 {#shared-type-choices}

一个能配合不止一种介质工作的图元，声明一份共用的类型配置，而不是把自己的图形复制一遍：

```json
{
  "id": "process-type",
  "name": "Process type",
  "allowedTypes": ["liquid", "gas"],
  "defaultType": "liquid"
}
```

`id` 是一个稳定的键：小写字母和数字，中间用单个短横线连接，首尾都是字母或数字（大写会被拒绝）。`allowedTypes` 非空，装的是各不相同的全局类型 id；`defaultType` 必须属于它；`name` 必填。每一个引用 `process-type` 的连接点，都跟着放置好的那个仪表画面实例上做出的同一个选择走。一份定义可以带着好几份互不相干的配置，比如工艺介质和放空介质，而别的连接点仍然是固定的。

## 完整的连接点 {#complete-terminals}

每一个可连接的连接点都声明一个全局位置、一个角色、恰好一个固定类型**或**一份共用配置、一个原始朝向下的朝外方向，以及一条内部到达路径：

```json
{
  "anchorId": "left-50",
  "role": "input",
  "typeConfiguration": "process-type",
  "direction": "west",
  "internalPath": [
    { "x": 18, "y": 50 },
    { "x": 36, "y": 50 },
    { "x": 48, "y": 62 }
  ]
}
```

那些规则，全部在导入时强制执行：

- 最多 **16** 个连接点，每一个锚点 id 最多用一次。
- `role` 是 `input`、`output` 或者 `bidirectional`；`direction` 是 `north`、`east`、`south` 或者 `west`。
- `type`（一种全局连线类型）和 `typeConfiguration`（一个已声明的配置 id）恰好给一个，不能都给，也不能都不给。
- 全局锚点换算到 SVG 自然视图框之后，就是 `internalPath` 隐含的起点；不要把它当作第一个点再写一遍。坐标是视图框里的绝对值，不是归一化比例，而且路径至少有一个点。
- 每一段都留在视图框内，长度非零，并且是水平、竖直或者正好 45°；相邻两段的转角最大 90°。
- 连接点要求 SVG 本身带有视图框。

仪表画面把内部路径画在设备图形下面，随着缩放、四分之一圈旋转和翻转一起变换，并保留全局连线类型的显示宽度，而不是让它跟着图元一起缩放，这样一条连续的线就从一个本体内部一直画到另一个本体内部。

一个连接点是要么全给、要么不给。带着残缺连接点的严格包是不合法的；只有视觉部分的包用一个空的 `terminals` 数组代替。任何缺口都绝不会被猜着填上。

## 按顺序排列的建议 {#ordered-suggestions}

一条建议记下一种有意为之的组合方式，连目标的确切姿态一起记：

```json
{
  "sourceAnchorId": "right-75",
  "targetSymbol": "system:capping-station",
  "targetAnchorId": "left-75",
  "targetRotationDegrees": 0,
  "targetFlipHorizontal": false,
  "targetFlipVertical": false,
  "connectionType": "material"
}
```

校验：`sourceAnchorId` 必须是这个图元自己的某个连接点；`targetSymbol` 必须是一个带来源限定的引用，而在一个可移植的包里它只能是 `system:<stable-key>`（用户 GUID 只在它所属的那台工作站里有意义），并且点名的那个图元要在系统目录里存在、且在 `targetAnchorId` 上有一个连接点；旋转是 `0`、`90`、`180` 或者 `270`，两个翻转标志都必填；每一条建议都必须唯一。`connectionType` 只有在两端相等的固定类型已经把场景说清楚时才可以省略；只要有一端是可配置的，它就必填，并且那时它必须在两端都被允许。

建议是有方向的，数组的顺序定优先级，但它们绝不是普通仪表画面连线的白名单。`suggestions` 数组为空时，检视面板会说明没有建议的组合方式，而不是自己编一个目录里的搭档出来。

## 导入会拒绝什么 {#what-the-import-refuses}

这是一份拒绝清单，好让一次失败的导入可以读出来，而不用猜。对裸 SVG 来说只有第一组适用，而其中只有大小上限、XML 不良构和缺少 `<svg>` 根会真的拒绝导入，不被允许的标记则是带一条警告修补掉。对一个包来说，下面的一切都是硬拒绝。

**图形**

- 文件超过 1 MB、标记超过 512 KB，或者元素超过 4096 个。
- XML 不良构，或者根不是一个净化过的 `<svg>`。
- 在包里：任何按白名单本来要移除的标记（脚本、事件处理属性、外部引用、不被允许的元素或属性）。
- `viewBox` 不是四个有限数、宽高不为正，或者和 SVG 根的不一致；包里干脆没有根视图框。

**外壳**

- schema、format 或者 version 不是 v2 约定的那些值；任何地方出现未知字段；缺少 `name`、`fit`、`footprint` 或者那三个数组中的任何一个；数组里出现空条目。
- `fit` 不在那四种模式里；占位尺寸超出 1–24 × 1–12。

**可绑定区域**

- 名称或者元素 id 不合写法；可绑定区域重名；目标 id 在 SVG 里不存在；目标元素接不了的种类（文字类用在非文字元素上、填充类用在 `<line>` 上）。
- `rotate`、`spin` 或者 `scale` 区域的目标没有有限的 `data-ganter-pivot`；`spinEnabled` 区域所在的元素上没有 `spin` 区域。
- 同一个元素的同一个通道上有两个区域。除了 `fill` 和 `fillColor` 共用填充通道、`stroke` 和 `strokeColor` 共用描边通道之外，每一种自成一个通道，所以一个元素不能接两次旋转，正如它不能接两次填充。
- 范围里出现非有限数；第二路输出只给了一端，或者出现在 `translate`/`scale` 之外的种类上；不安全的上色值；带着 Logic 地址的可绑定区域。

**连线**

- 超过 16 个连接点；锚点 id 重复或者未知；一个连接点同时给了或者都没给 `type`/`typeConfiguration`；未知的类型或配置 id；不合法的内部路径（跑出视图框、长度为零或者角度不对的线段、转角超过 90°）。
- 类型配置的 id 不合法、缺少名称、允许类型重复或未知，或者默认值不在它自己的列表里。
- 一条建议的源连接点不存在、目标不是带着所点名连接点的系统图元、姿态不是四分之一圈的整数倍，或者连线类型在两端不被同时接受。

[MCP endpoint](agent)上的 `symbol_validate` 会把这次导入原样空跑一遍，报出每一条错误、警告和说明，什么都不存下来。

## 打包与分享 {#packaging-and-sharing}

**导出图元包**（系统图元和用户图元一样可用）把 `<name>.ganter-symbol.json` 下载下来，里面是净化过的图形和完整的约定：`$schema`、`format`、`version`、`name`、`svg`、`viewBox`、`fit`、`footprint`、`slots[]`、`typeConfigurations[]`、`terminals[]` 和 `suggestions[]`，那三个连接数组即使为空也在。绝不随之走的东西有：工作站地址、单个实例的类型选择、数据库身份、用户身份，以及这台工作站的连线类型颜色和宽度。

在收下它的那台工作站上，同一个文件走**导入图元**（或者走 MCP 的 `symbol_import`），落地成一个新的用户图元，连接点一个不少。因为类型身份是一个稳定的 id、而外观是全局的，导入进来的图元，它的走向立刻跟上收下它那台工作站自己的连线类型样式。
