gis-view-webgl2 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +40 -8
  2. package/dist/gis-view.css +1 -1
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.d.ts +1036 -249
  5. package/dist/index.js +1 -1
  6. package/docs/AI/345/257/271/350/257/235/346/261/207/346/200/273.md +194 -0
  7. package/docs/GisView.md +9 -8
  8. package/docs/GisView/345/256/236/347/216/260/345/216/237/347/220/206.md +2 -2
  9. package/docs/ai/06-/344/274/232/350/257/235/347/264/242/345/274/225/344/270/216/345/244/215/346/237/245/346/226/271/346/263/225.md +172 -0
  10. package/docs/api/index.html +545 -59
  11. package/docs/demos/BackendSyncDemo.vue +10 -8
  12. package/docs/demos/BasicDemo.vue +101 -101
  13. package/docs/demos/BatchAddDemo.vue +5 -5
  14. package/docs/demos/ChannelDemo.vue +617 -10
  15. package/docs/demos/ConnectDemo.vue +392 -0
  16. package/docs/demos/CopyMergeDemo.vue +980 -929
  17. package/docs/demos/DecorationDraftDemo.vue +1300 -0
  18. package/docs/demos/DemoShell.vue +569 -240
  19. package/docs/demos/DragDropDemo.vue +11 -11
  20. package/docs/demos/EffectsDemo.vue +6 -6
  21. package/docs/demos/EventDemo.vue +327 -327
  22. package/docs/demos/ExtensionDemo.vue +458 -0
  23. package/docs/demos/GisViewDemo.vue +61 -48
  24. package/docs/demos/GraphicDemo.vue +338 -0
  25. package/docs/demos/LabelDemo.vue +553 -497
  26. package/docs/demos/MinimapDemo.vue +358 -0
  27. package/docs/demos/PerfDemo.vue +29 -0
  28. package/docs/demos/SearchFilterDemo.vue +412 -0
  29. package/docs/demos/StyleDemo.vue +121 -121
  30. package/docs/demos/api-index.js +98 -77
  31. package/docs/demos/demos.js +159 -135
  32. package/docs/demos/shared/demoData.js +107 -107
  33. package/docs/demos/shared/demoStyle.css +249 -249
  34. package/docs/demos/shared/fakeBackend.js +93 -6
  35. package/docs/format/03-/346/213/220/347/202/271/350/243/205/351/245/260/344/270/216/346/240/267/345/274/217.md +28 -8
  36. package/docs/format/04-/351/200/232/351/201/223/344/270/216/346/240/207/347/255/276.md +5 -4
  37. package/docs/format/06-/345/257/274/345/205/245/345/257/274/345/207/272/344/270/216/346/240/207/345/207/206/346/226/207/346/241/243.md +8 -6
  38. package/docs/format/07-/344/272/213/344/273/266/350/275/275/350/215/267/344/270/216/347/244/272/344/276/213/346/225/260/346/215/256.md +6 -6
  39. package/docs/guide/sample-document.html +4 -4
  40. package/docs/index.html +180 -9
  41. package/docs/internals/11-/350/246/206/347/233/226/345/261/202/346/240/207/347/255/276/344/270/216/346/217/220/347/244/272.md +1 -1
  42. package/docs/internals/13-/351/271/260/347/234/274/345/233/276.md +3 -2
  43. package/docs/internals/15-03-/351/200/220/350/275/256/350/256/260/345/275/225-/346/200/247/350/203/275/344/270/216/344/277/256/345/244/215-/344/270/213.md +16 -0
  44. package/docs/internals/16-03-/344/274/230/345/214/226/344/270/216/345/217/226/350/210/215-/344/270/213.md +11 -0
  45. package/docs/internals/17-/350/260/203/350/257/225/344/270/216/346/211/251/345/261/225/345/273/272/350/256/256.md +1 -1
  46. package/docs/manual/02-/345/235/220/346/240/207/344/270/216/346/225/260/346/215/256.md +18 -4
  47. package/docs/manual/03-/351/205/215/347/275/256/351/241/271.md +110 -25
  48. package/docs/manual/04-/344/272/213/344/273/266/344/270/216/345/233/236/350/260/203.md +13 -8
  49. package/docs/manual/06-/346/225/260/346/215/256/344/270/216/345/234/272/346/231/257/346/233/264/346/226/260.md +111 -10
  50. package/docs/manual/07-/346/214/207/351/222/210/344/270/216/350/246/201/347/264/240/347/274/226/350/276/221.md +47 -9
  51. package/docs/manual/08-/351/200/211/344/270/255/344/270/216/346/213/220/347/202/271.md +17 -15
  52. package/docs/manual/09-/351/200/232/351/201/223/344/270/216/345/244/232/345/233/236/347/272/277.md +61 -11
  53. package/docs/manual/10-/351/200/232/351/201/223/347/274/226/350/276/221/344/270/216/346/226/260/345/242/236.md +74 -7
  54. package/docs/manual/11-/346/240/267/345/274/217/344/270/216/350/247/204/345/210/231.md +3 -2
  55. package/docs/manual/12-/346/240/207/347/255/276/344/270/216/350/243/205/351/245/260.md +260 -3
  56. package/docs/manual/13-/345/212/250/346/225/210/344/270/216/345/233/276/345/205/203.md +6 -5
  57. package/docs/manual/14-/350/247/206/345/233/276/344/270/216/347/255/233/351/200/211.md +27 -14
  58. package/docs/manual/15-/345/257/274/345/207/272/344/270/216/351/233/206/346/210/220.md +142 -49
  59. package/docs/manual/16-/345/256/236/344/276/213/346/226/271/346/263/225/346/200/273/350/241/250.md +31 -29
  60. package/docs/manual/17-/345/217/202/346/225/260/351/200/237/346/237/245.md +55 -39
  61. package/docs/manual/18-/351/200/220/346/226/271/346/263/225/347/244/272/344/276/213.md +189 -64
  62. package/docs/manual/19-/350/257/273/346/225/260/344/270/216/346/213/226/346/213/275/351/242/204/350/247/210.md +4 -4
  63. package/docs/manual/20-/346/200/247/350/203/275/344/270/216/345/244/247/346/225/260/346/215/256.md +42 -2
  64. package/docs/manual/21-/350/257/264/346/230/216/344/270/216/345/270/270/350/247/201/351/227/256/351/242/230.md +22 -6
  65. package/docs/playground/index.html +5 -5
  66. package/docs/steps/README.md +14 -10
  67. package/docs/steps/check-doc-truth.mjs +1 -1
  68. package/docs/steps/selftest-actions.html +10 -10
  69. package/docs/steps/selftest-data.html +96 -14
  70. package/docs/steps/selftest-decoration.html +577 -0
  71. package/docs/steps/selftest-demo.html +6 -5
  72. package/docs/steps/selftest-demos.html +2201 -1612
  73. package/docs/steps/selftest-docs.html +89 -16
  74. package/docs/steps/selftest-effects.html +5 -5
  75. package/docs/steps/selftest-features.html +757 -86
  76. package/docs/steps/selftest-full.html +583 -583
  77. package/docs/steps/selftest-labels.html +1034 -652
  78. package/docs/steps/selftest-perf.html +1899 -1844
  79. package/docs/steps/selftest-scale.html +117 -7
  80. package/docs/steps/selftest-select.html +24 -17
  81. package/docs/steps/step-10-full.js +1 -1
  82. package/docs//346/216/245/345/217/243/345/215/217/350/256/256.md +167 -17
  83. package/docs//346/225/260/346/215/256/346/240/274/345/274/217.md +1 -1
  84. package/package.json +43 -43
  85. package/docs/demos/HistoryDemo.vue +0 -230
  86. package/docs/demos/InterfaceDemo.vue +0 -309
  87. package/docs/demos/SampleDemo.vue +0 -283
@@ -26,10 +26,35 @@ view.addChannelGroupWaypoint()
26
26
  - 不传坐标时按「分开分组」的默认位置落点:取这一组最长的一段骨架,在它的中点沿法线往外让一档 —— 第 1 组原地不动、第 2 组让一档、第 3 组再往外一档,档距是这一组的回线跨度 + 一个回线间距,所以拉开的几组既不会重合也不会贴在一起。被拉走的组仍然按自己在整条通道里的那几条车道相对骨架平行偏移(组内照样等距),拖走、拖回来都不影响别的分组。
27
27
  - 分组拐点只挂在**这一组的骨架**上:加 / 删 / 拖都只动这一组的线路与拐点。两种加点入口的差别只在怎么给坐标:`insertWaypoint(lineIndex, ...)`(实例层 `addWaypoint(id, ...)`)从回线进去、把坐标投影到该回线所属分组的骨架上;`addChannelGroupWaypoint()` 从分组进去、缺省位置就是上面那个「分开分组」的偏移落点。`removeWaypoint(lineIndex, pathIndex)` 与 `removeChannelGroupWaypoint()` 同理,删的都是这一组骨架上的顶点。
28
28
  - 增删都会发 `onWaypointChange`,载荷带 `channelId` / `groupId` 与代表回线(这一组的第一条)的 `line`;实例层收到之后会把这一组的声明同步回 `channels[].groups`,下次 `updateOptions` 不会把分组拐点弹回整条通道。`addChannelGroupWaypoint()` 还会顺手把这一组选出来(多选,整组高亮)。
29
- - **存的是公共拐点,不是各自的**:一个分组只有一份骨架,加 / 删 / 拖都只写这一份(数据里是 `channels[].groups[].path`);每条回线「自己的」拐点不落数据 —— 它是公共骨架按这条回线所在车道偏移推出来的只读读数(与图上画的一致)。想「让某条回线单独拐」就把它拆成新分组(`moveChannelLine(id, lineId, 'newGroup')` 或 `addChannelGroup`),新的那一组才多出一份公共骨架。
29
+ - **存的是公共拐点与分组偏移**:一个分组只有一份骨架,加 / 删 / 拖都只写这一份(数据里是 `channels[].groups[].path`);`'vertex'` 模式额外把同组同序号的刚性增量记在 `channels[].groups[].waypointOffsets`。每条回线「自己的」拐点不落数据 —— 它是公共骨架按这条回线所在车道偏移、再叠分组刚性偏移推出来的只读读数(与图上画的一致)。想「让某条回线单独拐」就把它拆成新分组(`moveChannelLine(id, lineId, 'newGroup')` 或 `addChannelGroup`),新的那一组才多出一份公共骨架。
30
30
  - 区分这两层读数用 `getChannelGroupWaypoints(channelId, groupId)`:公共那一份是 `path` / `skeleton`(返回里 `scope: 'group'`,给后端 `channel.group.path` 就是它,组内不必按回线逐条发),各自那一份是 `lines[].path` / `lines[].waypoints`(`derived: true`,只读)。加 / 删 / 拖之后照公共那一份重新拼整份 `path` 覆盖即可;各自的那份不用回写。
31
31
  - 右键交互框里的「加拐点 / 删拐点」按钮走的是同一条路径:`runChannelAction('add-waypoint' | 'remove-waypoint', { channelId, groupId, coordinate?, pathIndex? })`。
32
32
 
33
+ #### 拖动模式:parallel / vertex
34
+
35
+ 拖动分组拐点时,`channelWaypointDragMode` 决定骨架怎么改(缺省 `'parallel'`;普通线路始终只动自己的拐点):
36
+
37
+ - `'parallel'`:保留现有的共用骨架 + 平行偏移逻辑。拖动骨架顶点,组内回线按各自车道重新平行推导,拐角处的相邻顶点可能随 miter 一起微调。
38
+ - `'vertex'`:只平移当前分组同一 `pathIndex` 的拐点。组内每条回线都按相同世界增量移动这一序号,相邻拐点、其它顶点与其它分组一个都不动;结果记在分组的 `waypointOffsets` 上,不重建公共 `path`。
39
+
40
+ ```js
41
+ view.updateOptions({ channelWaypointDragMode: 'vertex' })
42
+
43
+ // 拖过之后,分组声明会写成:
44
+ {
45
+ id: 'corridor-1-b',
46
+ lineIds: ['c-3', 'c-4'],
47
+ path: [[100, 80], [260, 80]],
48
+ waypointOffsets: { '1': [42, 30] }
49
+ }
50
+ ```
51
+
52
+ - `waypointOffsets` 的键是**分组骨架顶点序号**(含首尾端点,从 0 起),只接受中间拐点;值是这一组所有成员回线都要叠加的世界坐标增量,写法为 `[dx, dy]` 或 `{ x, y }`。
53
+ - 偏移按组保存。`getChannelGroups()` / `getChannelGroupWaypoints()` / `getChannels()` 都返回 `waypointOffsets`;`getParallelGroups()` 与 `exportData({ format: 'test' | 'view', parts: ['waypoint'] })` 的 `group_route` 已经叠上偏移,导出的是实际路由。
54
+ - 组内每条回线仍先按自己的车道做平行偏移,再叠这一份刚性偏移;所以同组所有回线在 `'vertex'` 拖动时一起平移同一距离,回线之间保持原来的间距。
55
+ - 模式切换只影响下一次拖动,**不会清空已有的 `waypointOffsets`**。要恢复纯平行骨架,可把偏移拖回零,或重写分组声明并带上当前 `path`、省略 `waypointOffsets`(例如整份 `updateChannel(channelId, { groups })`);只写 `id` + `lineIds` 的分组补丁会沿用当前偏移。
56
+ - 后端同步时,通道分组拐点不能只交 `path`:同时提交 `getChannelGroupWaypoints()` 返回的 `path` 与 `waypointOffsets`,否则刷新后会回到旧骨架 / 丢掉非平行拖动结果。
57
+
33
58
  ### 通道右键交互框
34
59
 
35
60
  右键点中通道的回线,引擎把这条通道的上下文交给 `onChannelContextMenu`(**通道右键框缺省关**:右键本身要接得住 —— `contextMenu` / `interactive: true` / `features: 'view'` 任一 —— 再加 `features: { channelMenu: true }`):
@@ -58,7 +83,7 @@ view.getChannelContext()
58
83
  view.getChannelContext('corridor-1', 'corridor-1::2')
59
84
  ```
60
85
 
61
- - 载荷是 `{ channelId, channelIndex, channel, groupId, groupCount, groups, group, actions, lineIndex, lineId, coordinate, x, y, clientX, clientY, originalEvent }`:`groups` 是这条通道的全部分组读数(`{ id, channelId, index, lineIds, lineIndexes, lineCount, waypointCount, edited, anchored, current, skeleton, path }`),`group` 是其中的当前分组,`groupCount` 是组数;`x` / `y` 是命中的画布 CSS 像素(`clientX` / `clientY` 是页面坐标),交互框照着摆就行。
86
+ - 载荷是 `{ channelId, channelIndex, channel, groupId, groupCount, groups, group, actions, lineIndex, lineId, coordinate, x, y, clientX, clientY, originalEvent }`:`groups` 是这条通道的全部分组读数(`{ id, channelId, index, lineIds, lineIndexes, lineCount, waypointCount, edited, anchored, current, skeleton, path }`;引擎的完整分组读数还带 `waypointOffsets`),`group` 是其中的当前分组,`groupCount` 是组数;`x` / `y` 是命中的画布 CSS 像素(`clientX` / `clientY` 是页面坐标),交互框照着摆就行。
62
87
  - `actions` 每项 `{ id, label, enabled, reason? }`:`enabled: false` 时 `reason` 说明原因(拐点编辑关了、这一组还没有拐点、已经是多组先合并再拆…),界面直接照着渲染按钮即可。
63
88
  - 六个动作 id:`select-group`(选中该分组)/ `deselect-group`(取消该分组的选中,等价于 `deselect({ channelId, groupId })`)、`add-waypoint` / `remove-waypoint`(给这一组加 / 删拐点,见上一节)、`split`(按 `options.size` 每组几条把通道拆成多组,只有一组且多于一条回线时可用)、`merge`(其余分组并回当前这一组)。
64
89
  - 更自由的分配走 `moveChannelLine(channelId, lineId, target)`:按**回线**粒度拆成新分组或并进别的分组 —— 两回线选「拆成新分组」就是两个分组各一条(见「通道分组」)。
@@ -70,6 +95,18 @@ view.getChannelContext('corridor-1', 'corridor-1::2')
70
95
 
71
96
  `beginAddChannel()` 进入新增流程(**草稿缺省关**,写 `features: { channelDraft: true }` 或 `features: 'edit'` 才进得去;程序化的 `addChannel()` 不受限):点第一个节点当起点,一条草稿线跟着光标走(颜色 / 线宽 / 两端圆点由 `channelDraftStyle` 配),点第二个节点出方案表。**同一套流程用 `kind` 分两种**:`'channel'`(缺省)是**新建通道**,两个节点之间已经有通道就加一组 / 并进某一组;`'line'` 是**新建线路**,只加不挂任何通道的独立线路(`channels` 一个字节都不动,两个节点相同才不允许)。`circuits` 决定这一次加几条(通道回线 / 独立线路都一样)。草稿期间点空白处继续等(不会退出)、点起点同一个节点不算数、按住拖动仍然平移画布,Esc / 右键 / `cancelAddChannel()` 退出。
72
97
 
98
+ **直线 / 正交折线(`routing` / `path`)**:`beginAddChannel()` / `continueAddChannel()` 新增 `routing: 'straight' | 'orthogonal'` 与 `path`。`routing` 缺省 `'straight'`;写 `'orthogonal'` 且没有有效中间拐点时,组件自动补一个「先横后纵」的拐点 `[to.x, from.y]`。`path` 是**中间拐点**(不含两端),写了有效拐点就按它走,此时不会再追加自动拐点;草稿与 `commitAddChannel` / `addLine` / `addChannel` 共用同一份路由解析,预览和落地的折线完全一致。`onChannelDraft` / `onChannelDraftClick` 载荷里也带 `routing` / `path`,两端选好后 `path` 是实际会落地的中间拐点。
99
+
100
+ | 字段 | 类型 | 缺省 | 说明 |
101
+ | --- | --- | --- | --- |
102
+ | `color` | `string` | 蓝色 | 预览线颜色 |
103
+ | `width` | `number` | `2` | 预览线宽(CSS 像素) |
104
+ | `dot` | `number` | `4` | 两端圆点半径(CSS 像素,`0` 不画) |
105
+ | `arrow` | `boolean` | `false` | 末端是否画方向箭头 |
106
+ | `arrowSize` | `number` | `12` | 箭头大小(CSS 像素) |
107
+
108
+ `channelDraftStyle` 在草稿进行中也能改,`updateOptions({ channelDraftStyle })` 会立即重拼预览。这里的箭头只属于预览;真实线路箭头仍由 `lines[].direction` / `lineProps.direction` 决定。完整示例见 `?demo=connect`。
109
+
73
110
  **真正的新建走 `addChannel`(新签名)**:`beginAddChannel()` 只负责那条跟随光标的**示例假通道** —— 选完两个节点发结束事件(`phase: 'ready'`),落地必须自己调(`addChannel` / `addChannelGroupLines` / `joinChannelGroupLines` / `addLine`)。新签名是「通道信息 + 线路数组 line_data + 可选的分组信息」:
74
111
 
75
112
  ```js
@@ -87,6 +124,7 @@ const result = view.addChannel(
87
124
 
88
125
  - 第三段的分组信息认 `id` / `lineIds` / `path`(与通道声明里的 `groups[]` 同一套写法):`lineIds` 缺省是这次新建的全部回线,`id` 缺省单组用通道 id(多组 `通道 id::序号`);写进了认不出的成员会像 `updateChannel` 那样报一句 warning。
89
126
  - **回线 id 从哪来(`addChannel` / `addLine` 的第二段是同一套归一)**:每条写一个对象时 `id` 就是这条回线的 id(其余字段原样落在这条回线上),只写一个 id 也行 —— 裸字符串 / 数字等价于 `{ id }`,所以 `[{ id: 'line-1' }, 'line-2', 3]` 是三条回线。**没写 id 就自动编号**:`addChannel` 新签名按 `通道 id-序号`,`addLine` 的独立线路按 `from-to-序号`;显式给的 id 撞上数据里已有的由 `uniqueLineId` 往 `-1` / `-2` 排(一次加多回逐条都查)。新签名第一段的 `id` 是**通道 id**(不再当回线 id 的种子);旧写法(`addLine(input, lineItems?)` / `addChannel(input, lineItems?)`)顶层 `id` / `lineId` 与 `linePropsByMember[i].id` 是**种子**(一次多回时自动带 `-1` / `-2`)。
127
+ - **`path` / `routing` 的落地口径**:`applyChannelAdd` 对 `kind: 'line'` 写完整 `path`(首尾仍是 `from` / `to` 节点坐标),对新建通道 / 新分组只写中间拐点;逐条线路自己写了 `path` 时以它为准。预览、提交与程序化新增因此不会画出两套几何。
90
128
  - **只负责新建一个通道**:两个节点之间已经有通道时整条失败(`ok: false`),`reason` 指路下面两条方法,返回里的 `plan` 带着 `hasChannel` / `hasGroup` / `groups` / `channel`(`channel.groups` 就是已有分组的读数)—— 界面按它决定「新建」还是「并进」。
91
129
  - 旧写法(入参里带 `circuits` / `resolution` / `channelId` / `kind` 里的任何一个)**原样保留**:按方案表挑新通道 / 新分组 / 并入某组。它原来的三件事现在各有专门的方法(新代码建议直接用):
92
130
 
@@ -114,6 +152,35 @@ view.beginAddChannel()
114
152
  })
115
153
  ```
116
154
 
155
+ **取消、默认添加,或自行落地**:选完两个节点后草稿只是预览,除非 `autoCommit: true`,否则数据一个字节都不动。三种收尾方式:
156
+
157
+ - `cancelAddChannel()`:收起草稿并补一条 `phase: 'cancel'`;`endAddChannel()` 静默收起。两者都不写数据。
158
+ - `commitAddChannel()`:按推荐方案(或传入的 `resolution`)走内置落地,草稿里的 `routing` / `path` 会一起交给 `applyChannelAdd`。
159
+ - `addLines()` / `addChannels()`:绕开提交,用草稿读数自己落地。独立线路写完整 `path`;通道只把 `path` 当中间拐点。落地后调用 `endAddChannel()` 收起假通道。
160
+
161
+ ```js
162
+ const draft = view.getAddChannelDraft()
163
+ if (draft?.phase === 'ready' && draft.from && draft.to) {
164
+ const middle = draft.path ?? []
165
+ const fullPath = middle.length ? [draft.from.coordinate, ...middle, draft.to.coordinate] : null
166
+
167
+ // ① 独立线路:addLines 收现成数据对象,path 要完整(含两端)
168
+ view.addLines([{
169
+ id: 'visual-line-1',
170
+ from: draft.from.id,
171
+ to: draft.to.id,
172
+ ...(fullPath ? { path: fullPath } : {}),
173
+ color: '#2f6fd0',
174
+ direction: { count: 1, size: 12 }
175
+ }])
176
+ view.endAddChannel()
177
+
178
+ // ② 通道:addChannels 的 path 只写中间拐点(与 ① 二选一)
179
+ // view.addChannels([{ from: draft.from.id, to: draft.to.id, kind: 'channel', circuits: 2, path: middle }])
180
+ // view.endAddChannel()
181
+ }
182
+ ```
183
+
117
184
  **连续新增**:`beginAddChannel({ continuous: true })` 之后草稿不再是一次性的 —— 一条落地(或提交失败)之后草稿原地重开(发一条 `phase: 'restart'`),接着点下一对节点就是下一条线,不用再点一次「新增通道」。再给 `continuous: 'chain'`(或 `chain: true`)就是「接着上一段的终点画」:重开的草稿直接拿上一段的终点当起点(`restart` 载荷里的 `from`),一路点下去就是首尾相接的一条条线路。中途想换起点 / 退出,`cancelAddChannel()` 或 `continueAddChannel()` / `getAddChannelDraft()` 都按这套跑。`autoCommit: true` 与连续新增是正交的两件事,叠起来就是「点两个节点 → 落地 → 草稿重开」,全程不需要界面按钮。
118
185
 
119
186
  ```js
@@ -126,7 +193,7 @@ view.cancelAddChannel() // 退出草稿
126
193
 
127
194
  - **直接落地**:`view.addChannel(...)`(新签名,见上)或 `view.addChannelGroupLines` / `view.joinChannelGroupLines` —— 按 `payload.hasChannel` 自己分流,`beginAddChannel` 一个字节的数据都不改。
128
195
  - **删掉那条示例假通道**:`view.endAddChannel()`(静默收掉、不补事件)/ `view.cancelAddChannel()`(收掉并补一条 `phase: 'cancel'`)—— 提前结束不新建就是这两条;`beginAddChannel({ handoff: true })` 是「ready 发完自动收」的自动版。
129
- - **让点中的节点看起来是选中的**:`beginAddChannel({ select: true })` —— 起点先选上、第二个节点之后两个都在,走的是与普通点击同一份选中状态与回调(`onSelect` / `onSelectChange` 照发,`getSelections()` 立刻能读回来);它与 `features.select` 开关无关(和 `select()` 接口一样是显式调用)。
196
+ - **让点中的节点看起来是选中的**:`beginAddChannel({ select: true })` —— 起点先选上、第二个节点之后两个都在,走的是与普通点击同一份选中状态与回调(`onSelect` / `onSelectChange` 照发,`getSelections()` 立刻能读回来);它与 `features.select` 开关无关(和 `selectFeatures()` 接口一样是显式调用)。
130
197
 
131
198
  **交接模式(`handoff: true`)**:如果真实的新建逻辑在使用方自己手里(先弹表单、先过一遍校验、先调接口拿 id…),用交接模式最顺手 —— 两个节点选完、`phase: 'ready'` 的载荷发完,草稿(那条假通道)当场收掉(静默结束,**不补 `phase: 'cancel'`**),使用方在事件里拿着 `payload.from` / `payload.to` 走自己的 `addChannel`:
132
199
 
@@ -262,7 +329,7 @@ const plan = view.getChannelAddOptions('station-a', 'station-b')
262
329
  - 已经有通道:`newGroup`(变成新分组加入原通道,新分组不带拐点)与 `joinGroup`(并进某一组,方案里带 `groupId` / `groupIndex` / `lineCount` / `lineIds`)。上限由 `channelMaxGroups`(一条通道最多几组)与 `channelGroupMaxLines`(一组最多几条回线)决定,`0` 表示不限;超限的方案 `enabled: false` 并给出 `reason`。
263
330
  - **并进已有通道的简写**:`addChannel` 入参顶层直接写 `channelId`(并进哪条通道,按这条通道自己的推荐顺序挑:新分组 → 第一个能并的分组)/ `groupId` 或 `groupIndex`(并进哪一组)—— 与 `resolution: { type: 'joinGroup', groupId }` 是同一件事的两种写法(与 `updateLine` 的分组指令同一套)。指定了却挑不到可用方案时 `ok: false`、`reason` 写明「指定的通道 / 分组没有可用方案」,对照返回里的 `plan.options` 看每条为什么不可用;两个节点之间还没有通道时顶层 `channelId` 仍然是「新建通道的 id」。
264
331
  - `blocked: true` 只有两种情况:起点与终点是同一个节点,或者已有通道的每个方案都不可用(组数满了、每个分组也满了);这时 `reason` 给出原因,`options` 里仍然逐条列出不可用的方案。
265
- - 提交(`commitAddChannel`)与程序化新增(`addChannel` / `addLine`)返回同一份结果:`{ ok, reason?, plan, resolution, kind, circuits, line, lineId, lines, lineIds, lineIndex, channelId, groupId, channelLineIds, channelIndex }`(`kind: 'line'` 时 `channelId` 为 `null`、`resolution.type` 为 `'newLine'`;`channelLineIds` 是落地后这条通道的**完整成员表** —— 并进已有通道时就是「更新过的 `lineIds` 数组」,独立线路为 `null`);落地之后草稿自动结束,`onChannelDraft` 再发一条 `phase: 'commit'`(失败时发 `blocked`,载荷里同样带 `channelLineIds`),新回线自动选中,之后用 `getData()` / `onWaypointChange` / `updateLine()` 像普通线路一样改。
332
+ - 提交(`commitAddChannel`)与程序化新增(`addChannel` / `addLine`)返回同一份结果(草稿里的 `routing` / `path` 会自动带上):`{ ok, reason?, plan, resolution, kind, circuits, line, lineId, lines, lineIds, lineIndex, channelId, groupId, channelLineIds, channelIndex }`(`kind: 'line'` 时 `channelId` 为 `null`、`resolution.type` 为 `'newLine'`;`channelLineIds` 是落地后这条通道的**完整成员表** —— 并进已有通道时就是「更新过的 `lineIds` 数组」,独立线路为 `null`);落地之后草稿自动结束,`onChannelDraft` 再发一条 `phase: 'commit'`(失败时发 `blocked`,载荷里同样带 `channelLineIds`),新回线自动选中,之后用 `getData()` / `onWaypointChange` / `updateLine()` 像普通线路一样改。
266
333
  - **移除新增**:`removeLine(lineId)` 把刚落地的这条回线从数据里摘掉,通道声明(`lineIds` 与 `groups`)一起改,通道一条回线都不剩就整条删掉;`removeChannel(channelId)` 整条通道一起删。两个方法都返回结果对象(`{ ok, line / lineIds, channelId, removedChannel, reason? }`),并且已经处理好选中下标 —— 删掉线路之后 `getSelections()` 不会指到别的线上。
267
334
  - `getAddChannelDraft()` 读当前草稿(没有草稿时为 `null`);方案表也可以直接问:`getChannelAddOptions(from, to, count?, kind?)`,实例层按节点 id、引擎层同名方法按下标;只要「这两个点之间到底有没有通道 / 有几组」的读数(不做方案判断)用 `getChannelBetween(from, to)` —— 有通道时 `hasChannel: true`、`channel` / `groups` / `groupCount` / `currentGroupId` 与方案表同一份,没有通道时 `hasChannel: false` / `channel: null` / `groups: []`,端点认不出来返回 `null`。
268
335
 
@@ -285,8 +352,8 @@ const plan = view.getChannelAddOptions('station-a', 'station-b')
285
352
 
286
353
  ```js
287
354
  // 改名:悬停提示 / 标签模板的 {name}、搜索与自建面板都取它;传 null 摘掉名称
288
- view.setChannelName('corridor-1', '东干线')
289
- view.updateChannel('corridor-1', { name: '东干线' }) // 同一件事的整份补丁写法
355
+ view.updateChannel('corridor-1', { name: '东干线' })
356
+ view.updateChannel('corridor-1', { name: null }) // 摘掉名称
290
357
 
291
358
  // 往通道里加回线:回线本身早就在数据里(独立线路),只把它们挂进成员表
292
359
  view.addChannelLines('corridor-1', ['c-5', 'c-6'])
@@ -305,7 +372,7 @@ view.removeChannelGroup('corridor-1', 'corridor-1::2')
305
372
  view.removeChannelGroup('corridor-1', 'corridor-1::2', { targetGroupId: 'corridor-1::1' }) // 指名并到哪一组
306
373
  ```
307
374
 
308
- - **改名用 `setChannelName(id, name)`**:只动名称一项(`updateChannel(id, { name })` 是同一件事),一次只发一条 `updateChannel` 数据变更、只落一步历史;名称不参与几何,改完只重排用到它的标签与提示框。
375
+ - **改名用 `updateChannel(id, { name })`**:只动名称一项,一次只发一条 `updateChannel` 数据变更、只落一步历史;名称不参与几何,改完只重排用到它的标签与提示框。
309
376
  - **加回线用 `addChannelLines(channelId, lineIds, options?)`**:回线要在数据里已经存在(用 `addLine` / `addLines` 加的独立线路就行),挂进来之后追加在通道声明 `lineIds` 的末尾,几何改由通道的车道推导(自己数据里的 `path` 不再参与)。通道拆了分组时,新线进 `options.groupId` 指名的组、缺省进第一组;目标组的回线数到 `channelGroupMaxLines` 时整条拒绝(`ok: false` + `reason`)。**已经在这条通道里 / 已经在别的通道里(一条回线的几何只能由一个通道推导)/ 认不出的引用 / 没有 id 的回线**都进 `skipped`(每项带 `reason`),不影响其余条目;成功返回 `{ ok, reason, channelId, lineIds, skipped, groups }`。
310
377
  - **摘回线用 `removeChannelLines(channelId, lineIds)`**:成员表与分组声明一起改,空掉的分组整组去掉;回线留在数据里(变成独立线路,按自己的 `path` / `from` / `to` 画)。摘完一条不剩时返回 `ok: false`,`reason` 提示要连通道一起删用 `removeChannel`。
311
378
  - **改组的信息用 `updateChannel(id, { group })`(单数)**:只给某一组打补丁 —— 按 `group.id`(也认 `groupId`)找到那一组就并进去(只改写了的那几个字段:`lineIds` / `path` / 其余字段原样带上),找不到就当作新加一组,两个都没写就是第一组。它与 `groups`(复数、整份声明)的分工是**单组补丁 vs 整份覆盖**:两个都写时以 `groups` 为准并报一句 warning;加了成员之后顶层 `lineIds` 跟着对齐(与 `updateChannel` 的其它成员改动同一份口径)。
@@ -149,11 +149,12 @@ view.unregisterOperator('mod2')
149
149
 
150
150
  - `test(value)` 拿到条件里写的 `value`,返回逐值判定函数 `(current) => boolean`;与内置运算符一样按字段值记忆(同一小撮值反复判只算一次),分档条件在十万级数据上也不会成为热点;
151
151
  - 三种写法与规则 / 筛选 / 标签的 `when` 全都认:显式 `{ field: 'vn_kv', op: 'mod2', value: true }`、简写 `{ vn_kv: { mod2: true } }`、以及数组混用;
152
- - 注册 / 注销之后规则与筛选当场按新注册表重编译、画面重解析 —— 已经在用这个名字的条件一起生效,不用重新下发配置;
152
+ - 注册 / 注销之后规则、筛选与标签的 `when` 当场按新注册表重编译、画面重解析 —— 已经在用这个名字的条件一起生效,不用重新下发配置;标签层按条件对象复用编译结果,运算符表一变就整份作废,不会拿旧工厂闭包画下一帧;
153
153
  - 名字空 / 与内置 14 个重名 / `test` 不是函数:只报一条 `onWarning`(注册表不动);注销一个没注册过的名字返回 `false`;
154
154
  - 注销之后条件里还用着这个名字:认不出来的名字让整条条件按「不成立」处理(一个要素都不命中)—— 条件语言对认不出来的写法一直是这个口径(不会悄悄退回 `eq`),所以下线运算符之前先把配置里的引用清掉;
155
155
  - 带自定义运算符的条件算「主线程条件」(`workerSafe: false`):大数据量筛选自动留在主线程跑,结果与 Worker 路径完全一致(与函数条件同一条口径);
156
- - `view.listOperators()` 看现在能用哪些(内置在前、自定义按注册顺序跟在后面);想在 `createGisView` 之前注册(图标库那种模块顶层写法)就用上面那四个模块级函数。
156
+ - **作用域注销**:`view.registerOperator` 返回的注销函数只在当前注册项仍是自己那次注册时才摘除 —— 同名被后注册的运算符覆盖后,旧视图 / 旧插件的卸载不会误删新的;确实要按名字全局摘除时用 `view.unregisterOperator(name)`。
157
+ - `view.listOperators()` 看现在能用哪些(内置在前、自定义按注册顺序跟在后面);想在 `createGisView` 之前注册(图标库那种模块顶层写法)就用 `registerConditionOperator` / `unregisterConditionOperator` / `hasConditionOperator` / `listConditionOperators` 四个模块级函数。插件 / 微前端要连同清理函数一起交付时用 `defineConditionOperator(name, test)`,返回的清理函数与实例方法同一口径;参数不合法时直接抛 `TypeError`,不会被静默吞掉。
157
158
 
158
159
  ## 批量样式规则
159
160
 
@@ -36,7 +36,7 @@ view.updateOptions({
36
36
  | `position` | `GisViewLabelPosition` | `'right'` | 八向与居中 |
37
37
  | `offset` / `gap` | `[number, number]` / `number` | `[0, 0]` / `8` | 额外像素偏移与节点间距(`anchor: 'edge'` 时从符号外沿量起) |
38
38
  | `anchor` | `'edge' \| 'center'` | `'edge'` | 贴边方式:`edge` 让标签贴着符号外沿,节点放大后标签一起往外让;`center` 是从节点中心量 |
39
- | `html` | `boolean` | `false` | 字符串内容是否按 HTML 渲染;模板里的取值始终转义,函数返回 `HTMLElement` 时不受它影响 |
39
+ | `html` | `boolean` | `false` | 字符串 `content` 是否按 HTML 解析。逐条 `labels[].html` / `updateLabel(..., { html })` 可以覆盖全局;写 `false` 时标记按字面文本显示。模板取值、`field` 与默认名称始终转义,函数返回 `HTMLElement` 时不受它影响。**它只控制解析方式,不决定容器**:写 `false` 仍然逐条建 `.gis-view__label`,要减少高密度纯文本标签的 DOM 见「标签容器渲染(labelRender)」 |
40
40
  | `when` | `GisViewConditionInput` | 无 | 条件语言,只给命中的节点渲染标签 |
41
41
  | `maxCount` | `number` | `200` | 最多渲染多少个标签(按当前视口内可见点的顺序取,被避让丢掉的候选不计入),`0` 表示不限制;只在 `labelOptimize: true` 时生效(缺省强制显示档不截断) |
42
42
  | `className` / `style` | `string` / `Record<string, string>` | 无 | 附加类名与内联样式:**不用写 HTML 也能改外观** —— `style` 是内联样式的增量合并,`background` / `color` / `fontSize: '14px'` / `fontWeight` / `borderRadius` / `padding` 这些写什么加什么(值按 CSS 写法给字符串),逐条再叠 `updateLabel` 的 `style`;不配就是样式表里的默认(白底 88% / `#1f3550` / 12px),想清掉某一项就写 `{ 那一项: null }` |
@@ -52,7 +52,11 @@ view.updateOptions({
52
52
 
53
53
  函数形态的 `content` 会收到 `{ index, item, x, y, screen, radius }`(`radius` 是节点画在屏幕上的半径),需要自己拼 DOM 时直接返回元素即可。`anchor: 'edge'` 时标签到节点的距离始终是「符号当前的屏幕半径 + `gap`」:节点被悬停 / 选中样式放大,标签跟着往外让,缩小跟着靠近;缩放地图时标签按新相机重新投影,间距不会跟着地图一起被放大(鹰眼图放大缩小几次之后标签也不会飘出画布)。`html: true` 时字符串按 HTML 渲染:模板串里的取值仍然会转义,自己拼标记时也要记得转义业务字段。标签只渲染当前视口内的节点,相机变化后按 `requestAnimationFrame` 合并重排,移出视口的标签 DOM 回收进池子复用;单个节点可以用 `labelVisible: false`、`labelPosition`、`labelOffset` 覆盖全局配置,也可以用 `labelVisibility: 'always' | 'hidden'` 单独指定显隐策略(`labelVisible: true` 等价于 `always`)。
54
54
 
55
- 排布顺序上有两层稳定化:上一轮已经显示出来的标签优先占位(平移时不会因为扫描窗口换人而成片消失),同一轮里 `always` 的排在 `auto` 前面,`auto` 抢不到位置就不画。避让用的是按 64 px 分桶的网格索引,候选再多也不会退化成两两比较;竖长拖拽时每隔 220 ms 会补一次真实排版,不然整层变换只能带着上一轮那批标签走。整层变换只剩平移:缩放时一条标签都不缩放文字 —— 512 条以内逐个按新相机重投影,超过 512 条按排版时记下的节点中心与固定像素偏移线性推,字号、清晰度与观感都保持原样。标签位置都落在整数像素上(半像素上的文字会被重新栅格化,看着就是糊的)。默认开启的标签避让(`avoidOverlap`)在候选和已经放下的标签重叠时按 `overlapMode` 走:`'shift'`(默认)主动换落点,节点标签绕这个节点换锚点再试(`anchorFallback`,默认最多试 4 个:先换对侧,再按左 / 上 / 下…绕一圈),线路标签沿线滑动(`slideFallback`),找不到空位才不画;`'hide'` 不做任何位置回退,碰撞就直接不显示。换向只改 class 与 transform,内容与尺寸都不重量,也没多读一次布局。底图的区域文字(`background.geoJsonStyle.label`)另占一张「软」网格:标签先绕开它,实在绕不开照画 —— 数据标签优先于底图上写死的文字;只压到区域文字不会触发 `hide`。
55
+ `html` 是**逐条**开关:全局 `label.html: true` 时,某条数据仍可写 `labels: [{ id: 'default', content: '<b>{name}</b>', html: false }]`,这一条就显示字面的 `<b>…</b>`;反过来全局 `false`、单条写 `true` 也会按标记渲染。运行时用 `updateLabel(kind, ref, { html: true | false })` 双向切换,写 `null` 回到全局 / 声明值。只有字符串 `content` 受它控制,函数返回 `HTMLElement` 时始终按元素插入。`getLabels()` 的清单以及 `labelClick` / `labelDrag` 载荷里的 `label` 都会带 `html`;HTML 模式下 `content` 返回 `innerHTML`,编辑面板回写时不会把 `<b>` / `<span>` 洗成纯文本。导出数据会把显式 `true` / `false` 一起写进 `labels`,导出再回灌保持原语义。
56
+
57
+ HTML 标签不等于每帧重新 `innerHTML`:覆盖层把内容、内联样式、class 与尺寸签名分开比较,只有内容真的变化才重解析标记;只改字号 / 颜色 / 位置时内容一个字节都不重写。尺寸缓存也按内容与样式共享,不同节点上的同文案标签只量一次布局。HTML 解析本身仍有成本,能靠 `style` / `className` 完成的外观优先用它们;大量富文本标签建议配合 `labelOptimize: true`、`maxCount` 与 `scanLimit`。
58
+
59
+ 排布顺序上有两层稳定化:上一轮已经显示出来的标签优先占位(平移时不会因为扫描窗口换人而成片消失),同一轮里 `always` 的排在 `auto` 前面,`auto` 抢不到位置就不画。避让用的是按 64 px 分桶的网格索引,候选再多也不会退化成两两比较;竖长拖拽时每隔 220 ms 会补一次真实排版,不然整层变换只能带着上一轮那批标签走。整层变换只剩平移:缩放时一条标签都不缩放文字 —— 512 条以内逐个按新相机重投影,超过 512 条按排版时记下的节点中心与固定像素偏移线性推,字号、清晰度与观感都保持原样。标签落点吸附到**设备像素网格**(`devicePixelRatio × 宿主显示比例`,1.25 / 1.5 倍屏上整数 CSS 像素仍可能落在半个设备像素上);拖动期整层位移也按同一网格吸附,且逐条标签不再各自写 `will-change`,只保留标签层整体的合成提示,避免多个合成层产生相位差与重复栅格化。节点标签、线路标签、提示框、底图区域文字的投影都走这一份口径。默认开启的标签避让(`avoidOverlap`)在候选和已经放下的标签重叠时按 `overlapMode` 走:`'shift'`(默认)主动换落点,节点标签绕这个节点换锚点再试(`anchorFallback`,默认最多试 4 个:先换对侧,再按左 / 上 / 下…绕一圈),线路标签沿线滑动(`slideFallback`),找不到空位才不画;`'hide'` 不做任何位置回退,碰撞就直接不显示。换向只改 class 与 transform,内容与尺寸都不重量,也没多读一次布局。底图的区域文字(`background.geoJsonStyle.label`)另占一张「软」网格:标签先绕开它,实在绕不开照画 —— 数据标签优先于底图上写死的文字;只压到区域文字不会触发 `hide`。
56
60
 
57
61
  **一个节点上多条标签**:`label` 也可以写成数组,数组里每一条各自有内容 / 位置 / 样式 / 显隐,`id` 是它与数据里的 `labels`(见「标签(labels,导入即回显)」)以及 `updateLabel` / `removeLabel` 对上号的键:
58
62
 
@@ -128,6 +132,7 @@ view.updateOptions({
128
132
 
129
133
  - **缺省(不写 / `false`)= 强制显示**:不做避让、不按 `maxCount` 截断、不设 `scanLimit` / 扫描预算,线路标签也不做屏幕小格去重 —— 当前视口里该出现的标签一条不少,压在一起也照画。适合「标签一条都不能丢」的看图场景、标签本来就不多的小数据集,或者碰撞由使用方自己接管的时候。
130
134
  - **打开(`true`)= 优化显示**:与从前完全一致 —— `maxCount` / `avoidOverlap` / `overlapMode` / `overlapGap` / `anchorFallback` / `slideFallback` / `scanLimit` 全部生效,撞在一起的候选按 `overlapMode` 换落点(`shift`)或直接不画(`hide`);节点标签与线路标签共用一张避让网格,线路标签再多一道屏幕小格去重,十万级下标签层的开销才收得住。
135
+ - **节点候选的密集簇保护(只在优化档)**:先按 32px 屏幕小格去重(同一格一条主候选),再按 160px 粗格限流(每格最多 3 条主候选);同格 / 同粗格的其它节点进补位队列,主候选被避让丢掉时才接上。这样数据顺序前部即使堆着上万条同屏节点,也不会把 `scanLimit` 全部吃光,后面散开的节点仍能拿到标签。强制显示档不做这两层筛选,也不建补位队列。
131
136
 
132
137
  ```js
133
138
  view.updateOptions({ labelOptimize: true }) // 切到优化档;false / 不写就是强制显示
@@ -137,6 +142,21 @@ view.updateOptions({ labelOptimize: true }) // 切到优化档;false / 不写
137
142
 
138
143
  两档的差别在密集场景里是数量级:同一个视口里挤着 2 万个节点(1280×800、quality high,实测见「性能实测与 WebGPU 评估」),优化档只画得下 57 条标签、连续平移 / 缩放都在一帧 17 ms 上下(顶到刷新率);强制显示档 2 万个标签全画,同样的平移 / 缩放要 187.5 / 390.7 ms 一帧。十万级数据建议显式打开;另一头,强制显示档因为避让判定整段不走,连底图区域文字那张「软」网格都不用铺(省掉逐条量区域文字盒子的那一趟)。
139
144
 
145
+ ## 标签容器渲染(labelRender)
146
+
147
+ `html` 与顶层 `labelRender` 解决的是两件事:`html: false` 只关闭字符串的 HTML 解析,标签外壳仍是逐条 `.gis-view__label`(默认 `div`),几万条纯文本标签就会产生几万个节点;`labelRender` 决定这些纯文本标签要不要换成一张画布。
148
+
149
+ - **`auto`(缺省)**:只在**强制显示档**(`labelOptimize` 未开、`denseMode` 未激活)启用。节点层与线路层各自统计这一轮能画布的纯文本标签,某一层达到 **1024** 条,该层就改画到一张 `.gis-view__label-canvas`,不再为这些标签建 `div`;两层互不牵连。
150
+ - **`dom`**:始终逐条建 `.gis-view__label`,保持旧行为。数据量不大、要自己用 CSS / 事件精细控制标签时用它。
151
+ - **仍走 DOM 的标签**:`html: true`、函数返回 `HTMLElement`、可拖拽 / 可编辑,以及 Canvas 画不全的样式(背景图、阴影、`transform`、多行、字间距、非实线或四边不一致的边框等)会留在同层 DOM 里,和画布标签可以混排。画布只接管纯文本且样式能完整复刻的那部分。
152
+ - **画布档不参与优化档**:`labelOptimize: true` 或进入密集档后,标签仍走逐条 DOM —— 避让、换落点、截断与命中判定都要真实量尺寸,Canvas 这条路不会绕过它们。所以高密度场景应优先打开 `labelOptimize`;如果业务要求“一条都不能少”,再让缺省的 `auto` 用画布承接强制显示档。
153
+ - **画布与交互**:画布按视口 CSS 尺寸与设备像素比(含宿主显示比例,夹在 0.5 ~ 4)分配像素;平移、缩放、拖节点、拖线路、悬停半径变化时同步重算,`exportImage({ includeLabels: true })` 也会把画布标签写进 SVG。数量回落到阈值以下或切到 `dom` 时,DOM 标签会重新建回来;切换只重排标签层,不动引擎。
154
+
155
+ ```js
156
+ view.updateOptions({ labelRender: 'auto' }) // 缺省:高密度纯文本自动走单画布
157
+ view.updateOptions({ labelRender: 'dom' }) // 强制逐条 DOM,保持旧行为
158
+ ```
159
+
140
160
  ## 标签的拖动与编辑
141
161
 
142
162
  节点标签与线路标签各有一对开关(`label` / `lineLabel` 上的 `draggable` 与 `editable`,都缺省 `false`),打开之后标签自己接住指针,关着时标签照旧 `pointer-events: none`,一点都不挡画布:
@@ -147,13 +167,38 @@ view.updateOptions({
147
167
  lineLabel: { draggable: true, editable: true }
148
168
  })
149
169
 
150
- view.on('labelClick', payload => select(payload)) // { kind, index, id, labelId, item, label, parent, offset, state, selected, selection }
170
+ view.on('labelClick', payload => view.selectFeatures(payload)) // { kind, index, id, labelId, item, label, parent, offset, state, selected, selection }
151
171
  view.on('labelDrag', payload => {}) // { phase: 'start' | 'move' | 'end', kind, index, id, labelId, item, label, parent, offset, startOffset, delta, state? }
152
172
  view.on('labelContextMenu', payload => openMenu(payload)) // 右键标签:{ ...labelClick 那一份, clientX, clientY, originalEvent }
153
173
  view.on('labelDblClick', payload => openEditor(payload)) // 双击标签:载荷同上(originalEvent 是 dblclick)
154
174
  view.on('labelChange', payload => {}) // { action: 'update' | 'reset' | 'resetAll' | 'add' | 'remove' | 'restore' | 'addAll' | 'updateAll' | 'removeAll', kind, index, id, labelId?, patch?, state?, removed? }
155
175
  ```
156
176
 
177
+ ### 逐条标签的交互开关
178
+
179
+ `draggable` / `editable` 不只能配在全局 `label` / `lineLabel` 上:数据里的 `labels[]`、`addLabel` / `addLabels` 的定义也可以逐条写。规则是「这一类有任意一条全局配置打开,就成为该类标签的缺省;这一条自己写了就以自己为准」:
180
+
181
+ - 没写 `draggable` / `editable` 的标签跟随该类缺省(`options.label` 或 `options.lineLabel` 里任意一条写了 `true` 就算打开)。
182
+ - 写了 `true` 只让这一条接指针;写了 `false` 可以**单独关掉这一条**,即使全局是 `true`。
183
+ - 两个都关着(或缺省就是关)时,这一条标签保持 `pointer-events: none`,不挡画布,也不发 `labelClick` / `labelDrag` / 右键 / 双击。
184
+
185
+ ```js
186
+ const points = [{
187
+ id: 'p-1', x: 0, y: 0, name: '甲站',
188
+ labels: [
189
+ { id: 'name', content: '{name}', position: 'right', draggable: true, editable: true }, // 这一条可拖可点
190
+ { id: 'note', content: '只读备注', position: 'bottom', draggable: false, editable: false } // 全局开着也单独关掉
191
+ ]
192
+ }]
193
+
194
+ const view = createGisView(container, {
195
+ points,
196
+ label: { content: '{name}', draggable: true, editable: true } // 没写交互字段的标签跟随这一档
197
+ })
198
+ ```
199
+
200
+ 拖动仍按「要素 + `labelId`」记偏移,点击仍按同一条发 `labelClick`;A 拖出来的位移不会写到 B 身上。逐条标签如果放在优化档(`labelOptimize: true`)里,还要注意避让可能把某一条挤掉;要保证同一节点上的两条都出现,可以给它们不同的 `offset`,或在定义里写 `avoidOverlap: false`(`LabelDemo` 的第一个节点就是这么让「可拖 / 可编辑」与「只读」两条同时显示的)。
201
+
157
202
  **点的是哪一条标签、挂在哪条要素上**:`labelClick` 与 `labelDrag` 的载荷里都带两份现成的读数,不用自己去 `getLabels()` 反查:
158
203
 
159
204
  - `label` —— 这一条标签自己:`{ id, content, position, className, style, source, own, added, definition }`。`content` 是当下画在屏幕上的文本(模板 / 函数算完之后的那一份),`position` / `style` 是合并之后的生效值,`source` 说明它是哪来的(`'config'` 配置里定义的 / `'data'` 数据里写的 / `'runtime'` 运行时 `addLabel` 加的),`definition` 只在运行时加的标签上有(持久化回显按它重建)。
@@ -200,6 +245,116 @@ view.resolveLabelTargets('point', 'selection') // 先看这批选中的定位到
200
245
 
201
246
  状态按「要素 + `labelId`」记(要素按 id、没写 id 才按下标),换数据之后同 id 的标签仍带着自己那一份;`getLabelStates(kind?)` 传 `'point'` / `'line'` 只取一类。每次改动都会发一条 `labelChange`(`update` / `reset` / `resetAll` / `add` / `remove` / `restore`,载荷里带 `labelId`),面板按它回显;`setLabelStates(saved)` 能把整份状态灌回去,刷新页面 / 换一份数据之后接着用。拖动 / 点选只在标签自己身上发生,画布的框选、平移、`pointer` 那一套事件都不受影响;宿主用 CSS `zoom` 放大过时,指针位移会先除回布局像素,标签跟得住指针。三条批量的(`addLabels` / `updateLabels` / `removeLabels`)各发一条同名 `action`(`addAll` / `updateAll` / `removeAll`),载荷里带 `count` 与逐条明细;「拖得动」与「点得选」是两个开关,`editable: false` 时点一下不发 `labelClick`,`draggable: false` 的标签按下不拦事件(画布照常平移 / 缩放),松开那一下没挪出 3 px 才补发 `labelClick`。
202
247
 
248
+ ## 扩展图形(graphic)
249
+
250
+ `graphics` 是一层与节点 / 线路数据无关的装饰图形,写法参考 ECharts 的 `graphic`:按**世界坐标**锚在视图上,可以放 SVG 路径、图片或外部 Canvas。它在 WebGL 画布之上、标签层之下,不参与拾取,也不会进入数据、历史或筛选;相机平移 / 缩放与容器 resize 时只更新这一层的 DOM。
251
+
252
+ ```js
253
+ const canvas = document.createElement('canvas')
254
+ canvas.width = 120
255
+ canvas.height = 40
256
+ const context = canvas.getContext('2d')
257
+ context.fillStyle = '#2f6fd0'
258
+ context.fillRect(0, 0, 120, 40)
259
+ context.fillStyle = '#fff'
260
+ context.font = '20px sans-serif'
261
+ context.fillText('Canvas', 20, 28)
262
+
263
+ const view = createGisView(container, {
264
+ points,
265
+ lines,
266
+ graphics: [
267
+ {
268
+ id: 'zone-frame', type: 'path', x: 120, y: 80,
269
+ d: 'M0 0H200V120H0Z', viewBox: [0, 0, 200, 120],
270
+ width: 200, height: 120, anchor: 'center',
271
+ fill: 'rgba(47, 111, 208, 0.08)', stroke: '#2f6fd0', strokeWidth: 2, lineDash: [6, 4]
272
+ },
273
+ { id: 'logo', type: 'image', src: '/assets/logo.png', x: 260, y: 80, width: 48, height: 48, anchor: 'bottom-left', opacity: 0.9 },
274
+ { id: 'badge', type: 'canvas', canvas, x: 260, y: 140, width: 60, height: 20, anchor: 'top-left', scaleWithView: true }
275
+ ]
276
+ })
277
+ ```
278
+
279
+ | 字段 | 类型 | 默认值 | 说明 |
280
+ | --- | --- | --- | --- |
281
+ | `id` | `string \| number` | 自动生成 | 运行时增删改的主键;重复 id 按更新覆盖(数组初始化时后一条覆盖前一条并报 warning) |
282
+ | `type` | `'path' \| 'image' \| 'canvas'` | 按字段推断 | 有 `d` / `path` 是路径、有 `src` / `href` 是图片、有 `canvas` 是 Canvas;显式写 `type` 最清楚 |
283
+ | `x` / `y` | `number` | 必填 | 锚点的世界坐标,也可以用 `position: [x, y]` |
284
+ | `width` / `height` | `number` | 路径取 `viewBox`,Canvas 取画布尺寸 | 图形的屏幕像素尺寸;`scaleWithView: true` 时按相机比例一起缩放 |
285
+ | `anchor` | 九宫格名 / `[x, y]` | `'center'` | `'top-left'` / `'top'` / `'right'` / `'bottom-right'` 等,或 0 ~ 1 的比例;锚点同时是旋转中心 |
286
+ | `offset` | `[dx, dy]` | `[0, 0]` | 在世界坐标投影之后的屏幕像素偏移(x 向右、y 向下) |
287
+ | `rotation` | `number` | `0` | 旋转角度(度,顺时针) |
288
+ | `scale` | `number \| [sx, sy]` | `1` | 在 `width` / `height` 之上的额外缩放;锚点为中心 |
289
+ | `opacity` | `number` | `1` | 0 ~ 1,叠加在图形自身颜色透明度之上 |
290
+ | `visible` | `boolean` | `true` | 显式 `false` 只隐藏这一条,不删除 |
291
+ | `zIndex` | `number` | `0` | 只在这一层内部排序;数字大的压在上面,相同再按加入顺序 |
292
+ | `scaleWithView` | `boolean` | `false` | `false` 时屏幕尺寸恒定;`true` 时宽高乘相机比例,图形像地图要素一样被放大 / 缩小 |
293
+ | `d` / `path` | `string` | 无 | `type: 'path'` 的 SVG 路径字符串(必填其一) |
294
+ | `viewBox` | `string \| [x, y, w, h]` | `0 0 width height` | 路径自己的坐标系;`preserveAspectRatio` 固定为 `none`,路径铺满 `width × height` |
295
+ | `fill` / `stroke` / `strokeWidth` | `string` / `string` / `number` | `'none'` / `'#2f6fd0'` / `2` | 路径填充、描边与线宽;也可以写进 `style`(`style.lineWidth` 同义) |
296
+ | `lineCap` / `lineJoin` / `lineDash` / `dashOffset` | `string` / `string` / `number[]` / `number` | 圆头 / 圆角 / 实线 / 0 | 路径线型,`lineDash: [6, 4]` 是虚线 |
297
+ | `fillRule` | `'nonzero' \| 'evenodd'` | `'nonzero'` | 复杂路径的填充规则 |
298
+ | `src` / `href` / `image` | `string` | 无 | `type: 'image'` 的图片地址(必填其一);`alt`、`crossOrigin`、`referrerPolicy` 一并支持 |
299
+ | `preserveAspectRatio` | `string` | `'xMidYMid meet'` | 图片 / Canvas 的缩放方式:`meet` 等比完整显示、`slice` 等比裁切、`none` 拉满;`xMin` / `xMax` / `YMin` / `YMax` 控制对齐 |
300
+ | `canvas` / `element` | `HTMLCanvasElement` | 无 | `type: 'canvas'` 要传的外部画布(必填其一),组件直接把它挂进图形层 |
301
+ | `style` | `object` | 无 | 上面 `fill` / `stroke` / `lineWidth` / `width` / `height` / `lineDash` / `preserveAspectRatio` 等字段的等价写法;直接写在图形对象上的同名字段优先 |
302
+
303
+ **运行时增删改**:
304
+
305
+ ```js
306
+ view.addGraphic({ id: 'note', type: 'path', d: 'M0 0L12 8L0 16Z', viewBox: [0, 0, 12, 16], x: 80, y: 40, width: 12, height: 16, fill: '#e35b5b' })
307
+ view.addGraphics([{ id: 'a', type: 'image', src: '/a.png', x: 0, y: 0, width: 24, height: 24 }])
308
+ view.updateGraphic('note', { x: 100, y: 60, style: { width: 18, height: 24, fill: '#f0a020' } })
309
+ view.removeGraphic('note') // 返回删掉的那一条,没找到返回 null
310
+ view.clearGraphics() // 返回清掉的条数;节点 / 线路 / 标签都不受影响
311
+ view.getGraphic('note') // 副本,没有时 null
312
+ view.getGraphics() // 按 zIndex、再按加入顺序排的一份副本数组
313
+ ```
314
+
315
+ `addGraphic` 与 `addGraphics` 只改这一层 DOM,不触发场景重排;`updateOptions({ graphics })` 是整份替换入口(传空数组等于清空)。完整界面见 `docs/demos/GraphicDemo.vue`(页签「扩展图形」):三类图形同屏,可现场切 `scaleWithView`、改旋转 / 透明度 / `zIndex` 并增删改。导出时这一层会合进 `exportImage` 的 PNG 与 SVG(顺序是画布 → 图形 → 标签);图片跨域、Canvas 被跨域内容污染时导出会通过 `onWarning` 说明原因。图形层默认 `pointer-events: none`,想要点击请在自己的宿主层另外盖一层交互元素。
316
+
317
+ ## 节点上的装饰(图片 / SVG)
318
+
319
+ 节点也能挂图片或 SVG,数据写在 `points[].decorations`,符号写法、颜色、描边、透明度、阴影与线路装饰完全同一套。区别在定位:节点装饰默认按**屏幕像素**相对节点偏移,地图缩放时装饰与节点的像素距离保持不变;也可以写死世界坐标。
320
+
321
+ ```js
322
+ view.updateOptions({
323
+ points: [
324
+ {
325
+ id: 'node-0', x: 60, y: 80, name: 'A 站',
326
+ decorations: [
327
+ { symbol: 'star', size: 20, direction: 45, distance: 32 }, // 右上方 32px
328
+ { symbol: 'square', size: 14, offset: [-24, 16] }, // 左下 24 / 16px
329
+ { symbol: 'circle', size: 16, anchor: 'fixed', at: [120, 40] }, // 固定世界坐标
330
+ {
331
+ symbol: '<svg …>', size: 22, direction: 90, distance: 40,
332
+ connector: { color: '#7fa9dc', width: 1.2, dashed: true } // 从节点中心连到装饰中心
333
+ }
334
+ ]
335
+ }
336
+ ]
337
+ })
338
+ ```
339
+
340
+ | 字段 | 类型 | 默认值 | 说明 |
341
+ | --- | --- | --- | --- |
342
+ | `symbol` | `GisViewSymbolInput` | **必填** | 与节点 / 线路装饰同一套符号解析 |
343
+ | `anchor` | `'offset' \| 'fixed'` | `'offset'` | `offset` 相对节点;`fixed` 写死世界坐标(`at` 也自动按 `fixed` 处理) |
344
+ | `offset` | `[dx, dy] \| { x, y }` | 由方向 / 距离算出 | 相对节点的**屏幕像素**偏移(x 向右、y 向下),优先于 `distance` / `direction` |
345
+ | `distance` | `number` | `28` | 离节点的屏幕像素距离 |
346
+ | `direction` | `number` | `0` | 相对方向:0° 向上、顺时针(90° 向右、180° 向下) |
347
+ | `at` | `GisViewCoordinate` | 无 | 固定世界坐标,优先级最高;节点移动不会带走它,仍随地图一起平移 / 缩放 |
348
+ | `angle` | `number` | `0` | 图标自身旋转角(度) |
349
+ | `size` | `number` | `18` | 屏幕尺寸(CSS 像素) |
350
+ | `color` / `strokeColor` / `strokeWidth` | `string` / `string` / `number` | `'#ffffff'` / 透明 / `0` | 填充 / 描边色 / 描边宽,只对形状与 SVG 符号有意义 |
351
+ | `opacity` / `shadow` | `number` / `GisViewShadow` | `1` / 无 | 透明度与阴影,与节点同一套 |
352
+ | `connector` | `boolean \| object` | 不画 | `true` 用默认连接线;对象可写 `color` / `width` / `opacity` / `dashed` / `lineType` |
353
+
354
+ 连接线固定从节点中心画到装饰图标中心,颜色缺省 `'#6f9bd1'`、线宽 `1`、透明度 `0.82`;`dashed: true`(或 `lineType: 'dashed' / 'dotted'`)使用虚线。它和节点装饰一起进入同一套增量刷新:拖动节点、整组移动或 `updatePoint` 改坐标时,只按槽位重写受影响的装饰与连接线;相机缩放时也只重算这些装饰的位置。线路装饰同理:`anchor: 'follow'`(以及 `auto` 的比例跟随 / 沿线投影)会在 `updateLine`、拖动节点、拖动拐点或通道骨架重排之后重新落到新几何上;只有 `anchor: 'fixed'` / `at` 写死的世界坐标留在原处。
355
+
356
+ 运行时增删改走这五个方法:`getPointDecorations(id)`(副本,节点没找到给空数组)、`setPointDecorations(id, list)`(整份换,空数组 = 全摘掉)、`addPointDecoration(id, decoration)`(追加并返回加进去的那一条)、`updatePointDecoration(id, index, patch)`(只合并第 `index` 条)、`removePointDecoration(id, index)`(摘掉某一条)。下标按 `points[].decorations` 的顺序数,找不到时返回 `null`;四个写方法各只发一条数据变更、只落一步历史。
357
+
203
358
  ## 线路上的装饰(图片 / SVG)
204
359
 
205
360
  线路上任意位置都能放一个图片或 SVG,大小可自定义,和节点用的是同一套符号解析(`'circle'` / `'square'` / 图片地址 / 以 `<svg` 开头的字符串 / `{ type: 'svg', path \| markup, viewBox, fill, stroke, strokeWidth }`),位置写法与线路标签一致:
@@ -246,6 +401,108 @@ const built = createChannels([
246
401
 
247
402
  `linePropsByMember[slot]` 按回线顺序合并,覆盖范围是 `lineProps` 之上的任意 `GisViewLine` 字段(`name` / `color` / `width` 有单独的回退顺序)。
248
403
 
404
+ ### 手动删除装饰
405
+
406
+ 装饰是数据的一部分,手动删除就是把这几个字段改掉;线路侧与节点侧各有一套接口:
407
+
408
+ ```js
409
+ // 线路:删第 0 条 / 一次全删
410
+ view.removeLineDecoration('line-1', 0)
411
+ view.setLineDecorations('line-1', [])
412
+
413
+ // 节点:删第 1 条 / 一次全删
414
+ view.removePointDecoration('node-8', 1)
415
+ view.setPointDecorations('node-8', [])
416
+
417
+ // 动态装饰器还在草稿态、尚未提交:直接取消,数据不动
418
+ view.cancelDecorationDraft()
419
+
420
+ // 扩展图形是另一层(不是要素装饰):按 id 删或整层清空
421
+ view.removeGraphic('zone-frame')
422
+ view.clearGraphics()
423
+ ```
424
+
425
+ `removeLineDecoration` / `removePointDecoration` 的第二个参数都是数组下标(从 0 数);删完之后后面的条目会前移。要按稳定 id 或一次删多条,先 `getLineDecorations` / `getPointDecorations` 取副本、过滤后再 `set...Decorations`。单个删除、整份换空数组、提交前取消草稿都不会留下空壳;成功时各只发一条 `dataChange`、只落一步历史,找不到要素或下标时返回 `null` 并在 `onWarning` 里点名。`clearGraphics` 只清扩展图形层,不影响节点 / 线路上的 `decorations`。
426
+
427
+ ## 动态装饰器草稿
428
+
429
+ 想做成「先跟着鼠标预览、确认后再写数据」的挂装饰交互,用这五个方法:
430
+
431
+ ```js
432
+ const view = createGisView('#map', { points, lines, channels })
433
+
434
+ // 开启草稿:图标 / 大小 / 配色与线路、节点装饰同一套写法
435
+ view.beginDecorationDraft({
436
+ target: 'both', // line = 线路 / point = 节点 / both = 两类同时开
437
+ symbol: { type: 'svg', path: arrowPath, viewBox: [0, 0, 32, 16], fill: 'currentColor' },
438
+ size: 26,
439
+ color: '#ffd166',
440
+ parallel: true, // 线路图标是否跟着切向转;false = 始终水平
441
+ scope: 'channel', // line = 单条 / group = 所在分组 / channel = 整条通道
442
+ maxDistance: 14, // 离线路 / 节点多近才出现(屏幕像素,缺省 14)
443
+ maxPerLine: 3, // 每条线路最多几个 decoration,0 / 缺省 = 不限
444
+ maxPerPoint: 2, // 每个节点最多几个 decoration,0 / 缺省 = 不限
445
+ allowedLines: ['line-0', 'line-1'],
446
+ allowedChannels: ['channel-a'],
447
+ allowedGroups: ['channel-a::1'],
448
+ allowedPoints: ['node-0', 'node-1'],
449
+ placement: 'offset', // 位置来源:offset 相对节点 / fixed 固定坐标 / pointer 鼠标位置
450
+ anchor: 'follow', // 落地关系:follow 跟随节点 / fixed 钉在世界坐标
451
+ direction: 90, // 节点装饰方向:0 度向上、顺时针
452
+ distance: 36, // 节点装饰的屏幕像素距离
453
+ connector: { color: '#7fa9dc', width: 1.2, dashed: true }, // 节点到装饰的连接线
454
+ allowDuplicate: false,
455
+ duplicateStrategy: 'offset', // offset 自动避让 / reject 直接拦住
456
+ duplicateDistance: 8 // 屏幕像素的重复判定容差
457
+ })
458
+
459
+ // 交互中可随时改:命中中的预览与之后的提交都按新值
460
+ view.updateDecorationDraft({ size: 30, target: 'point', placement: 'fixed', anchor: 'follow', at: [120, 40] })
461
+ view.getDecorationDraft() // 当前读数:phase / hitKind / 命中目标 / 目标表 / 屏幕与世界坐标…
462
+
463
+ // 松手命中目标之后由使用方确认:线路与节点在同一批里写入
464
+ const result = view.commitDecorationDraft()
465
+ // result = { ok, count, targets, decorations, pointTargets, pointDecorations, draft? }
466
+
467
+ // 不想要了:只清假装饰,数据与历史一个字节都不动
468
+ view.cancelDecorationDraft()
469
+ ```
470
+
471
+ 指针流程与事件:
472
+
473
+ | 时机 | `phase` | 说明 |
474
+ | --- | --- | --- |
475
+ | `beginDecorationDraft()` | `start` | 开启草稿;已有草稿会被替换,通道草稿 / 复制预览 / 待确认合并会先安静退出 |
476
+ | 指针移到线路 / 通道回线或节点上 | `hover` | `hitKind` 标明这次命中 `line` 还是 `point`;线路按 `scope` 展开目标,节点只有一个目标。每移动一次更新位置,`targets` / `pointTargets` / `decoration` / `pointDecoration` / `world` / `decorationScreen` 都是最新读数 |
477
+ | 指针移出目标或画布 | `leave` | 临时图标立即消失,目标表清空;数据没动,之后移回目标会重新发 `hover` |
478
+ | 在目标上松开左键 | `ready` | 唯一表示「这里可以新增装饰」的事件;假装饰停住等确认(之后移开也不收),调用方再决定 `commitDecorationDraft()` 或 `cancelDecorationDraft()` |
479
+ | 在空白处松开左键 | 不发事件 | 不会发 `ready`,也不会有任何数据变更 |
480
+ | `updateDecorationDraft()` | `update` | 改了图标 / 大小 / 方向 / 作用域 / 位置来源 / 落地关系等,按当前指针重新判定命中 |
481
+ | `commitDecorationDraft()` | `commit` | 已批量写进 `lines[].decorations` / `points[].decorations`,整次只发一条 `batch` 数据变更、只落一条历史 |
482
+ | `cancelDecorationDraft()` / Esc / 右键 | `cancel` | 清掉假装饰,数据不动;Esc 与右键在草稿模式里直接取消 |
483
+
484
+ `target` 的三档:`line` 只查线路(缺省,原有行为不变)、`point` 只查节点、`both` 两类一起查。`both` 按屏幕距离取更近的一个,等距时节点优先;最近的那个被白名单 / 数量上限拦住时,会自动尝试另一类,只有两类都不可用才按「未命中」静默处理。`maxDistance` 对线路与节点都是屏幕像素拾取半径,默认 `14`;连续 `pointermove` 小于 `0.65px` 会跳过重算,同一个图标样式按选项缓存,通道 / 分组展开多条回线时不会重复解析符号。
485
+
486
+ 节点的位置拆成两个相互独立的选项:`placement` 只决定**位置来源**,`anchor` 只决定落地后**跟着节点还是固定在世界坐标**。两者可以自由组合,所以「跟随」与「固定」能添加的位置完全一样。
487
+
488
+ - `placement: 'offset'`(缺省):`offset: [dx, dy]` 直接给屏幕像素偏移;也可以只给 `direction` + `distance`,0° 向上、顺时针,缺省距离 `28`。
489
+ - `placement: 'fixed'`:用 `at: [x, y]` 指定世界坐标;没给 `at` 就取节点中心作为来源位置。
490
+ - `placement: 'pointer'`:以当前指针位置作为来源,预览会贴着鼠标走。
491
+ - `anchor: 'follow'`:落地为 `anchor: 'offset'` + `offset`。不管来源是相对偏移、固定坐标还是鼠标位置,都会在落点那一刻换算成「相对节点的屏幕偏移」,之后节点移动 / 缩放时保持这段像素距离。
492
+ - `anchor: 'fixed'`:落地为 `anchor: 'fixed'` + `at`。不管来源是哪一种,都会在落点那一刻换算成世界坐标,之后节点移动也不会带走它,但仍随地图一起平移 / 缩放。
493
+
494
+ `anchor` 缺省由 `placement` 推导:`offset → follow`,`fixed / pointer → fixed`,保持原有调用的落地结果。草稿里的 `placement` 只用于选位置来源,**不会写进数据**;`points[].decorations` 里的 `GisViewPointDecoration` 只有 `anchor: 'offset' | 'fixed'`。`at` 只在 `placement: 'fixed'` 时参与选点;如果有历史数据同时写了 `at` 与 `anchor: 'offset'`,渲染时仍按 `at` 固定,保持旧数据行为。
495
+
496
+ `connector` 只对节点装饰生效:`true` 或对象都会从节点中心画一条线到装饰中心,颜色 / 线宽 / 透明度 / 虚线可逐项覆盖。预览阶段也画这条线,提交后作为节点装饰的一部分持久化。
497
+
498
+ 数量与白名单:`maxPerLine` 按 `lines[].decorations.length` 计、`maxPerPoint` 按 `points[].decorations.length` 计,`0` / 缺省表示不限;达到上限的目标按「未命中」处理,不显示临时图标、不发 `hover` / `ready`。`allowedLines` / `allowedChannels` / `allowedGroups` 是三组线路白名单,三组都为空表示全部允许;任意一组有值时,线路 id、所属通道 id、所属分组 id 命中任意一项就允许。`allowedPoints` 是节点 id 白名单,空数组表示全部允许。白名单外的目标同样静默;提交前会按最新数据与提交时的 override 再复核一次,任何一条目标超限或不在白名单都整批不写,返回 `reason: 'limit'` / `'not-allowed'`。
499
+
500
+ 重复判定按「同一目标上已有装饰中心的屏幕距离」算:`allowDuplicate` 缺省 `true`(保持原有行为);设为 `false` 后,距离小于 `duplicateDistance`(缺省 `8px`)就算重复。`duplicateStrategy: 'reject'` 直接静默拦下,松手也不发 `ready`,`blockedReason` 为 `'duplicate'`;`duplicateStrategy: 'offset'` 会在线路上沿路径前后、或在节点上沿当前方向向外找一个空位,再按 `anchor` 落成屏幕偏移或世界坐标,找得到就把预览与提交一起挪过去,并在载荷里给 `duplicateAdjusted: true`。`scope` 展开多条线路时,重复判定会逐条检查所有目标线。
501
+
502
+ `scope` 的三档:`line` 只挂命中的这一条;`group` 挂它所在分组的全部回线;`channel` 挂整条通道(所有分组)的全部回线。命中线不在通道里时,`group` / `channel` 都退回单条。展开结果在 `payload.targets`(每项带 `lineIndex` / `lineId` / `channelId` / `groupId` / `channelIndex` / `existingCount`),`targetCount` 是条数;白名单与上限会逐条过滤,命中线自身被拦截时整次目标为空。
503
+
504
+ 草稿事件与 `options.onDecorationDraft` 是同一份载荷(也可以 `view.on('decorationDraft', handler)`);所有阶段字段形状固定,不适用的字段给 `null`。节点命中时看 `pointId` / `pointIndex` / `pointItem` / `pointHit` 与 `pointDecoration`,线路命中时看 `lineId` / `lineIndex` / `item` / `hit` 与 `decoration`,统一读数用 `hitKind`(`targetKind` 同义)、`lineTargetCount`、`pointTargetCount`、`targetCount` / `count`;节点草稿的 `placement` 与 `anchor` 会同时出现在载荷顶层和 `options` 里,方便面板显示当前组合。`commitDecorationDraft()` 默认用草稿当前的位置与选项,也可以临门覆盖:`commitDecorationDraft({ position: 0.5, size: 24 })`。没有命中目标 / 目标已不在 / 达到上限 / 不在白名单 / 位置重复时返回 `{ ok: false, reason, count: 0, targets: [], decorations: [], pointTargets: [], pointDecorations: [] }`,`reason` 可能是 `no-draft` / `no-target` / `targets-missing` / `limit` / `not-allowed` / `duplicate`。线路与节点同时命中时,`commitDecorationDraft()` 在同一个 `batch` 里写入,一次 `dataChange`、一条历史。换整份 `points` / `lines` 时,过期草稿会静默清掉,不会把旧位置写到新数据上。完整交互见 `docs/demos/DecorationDraftDemo.vue`(页签「动态装饰器」):线路 / 节点两个开关可以单独或同时开启;节点侧「吸附方式」只选跟随 / 固定,「位置来源」始终可选相对节点 / 固定位置 / 鼠标位置,三种来源在两档吸附下完全等价,分别映射到 `anchor` 与 `placement`;相对节点档可调方向与距离,固定位置档可直接填世界坐标,鼠标位置档以指针落点为准,三种组合都可开连接线;右侧还能调触发距离、每线 / 每点上限、节点白名单与重复策略;松手只落预览,再点「添加落点装饰」才提交。
505
+
249
506
  ## 悬浮提示
250
507
 
251
508
  配了 `tooltip` 就在悬停命中要素时显示提示框,点 / 线 / 通道可以分别配置内容,取不到的字段留空:
@@ -171,13 +171,13 @@ view.updateOptions({
171
171
 
172
172
  | 方法 | 说明 |
173
173
  | --- | --- |
174
- | `setEffects(next)` | 换一份全局配置(写法与 `options.effects` 完全一致,`false` 就是一处关掉),等价于 `updateOptions({ effects })`;返回 `this` 可以接着链式调 |
174
+ | `updateOptions({ effects })` | 换一份全局配置(写法与 `options.effects` 完全一致,`effects: false` 就是一处关掉);返回 `this` 可以接着链式调 |
175
175
  | `getEffects()` | 当前生效的读数 `{ enabled, respectReducedMotion, line, point }`(`line` / `point` 是归一化之后的那一份,没配就是 `null`)。**`enabled` 是运行时口径**:它答的是「这一刻画面会不会动」—— 只在数据里逐条配了 `effect` 时它也是 `true`,与配置里写的 `enabled` 不一定相同 |
176
176
  | `getLineEffects()` | **这一帧**算过位置的动点清单:`{ lineIndex, slot, x, y, angle, total, size }`(世界坐标 + 朝向 + 所在线路 + 沿线总长)。自建覆盖层(跟着图标走的标签 / 提示)与读数面板用它;视野外的、以及总开关关着的时候是空表 |
177
177
  | `getPointEffects()` | **这一帧**涟漪的读数清单:`{ index, x, y, color, radius, count, period, from }`(世界坐标 + 颜色 + 圈数 + 周期)。位置取的就是实例缓冲里那一份 —— 拖动就地挪过圈之后读到的也是新位置;只列视野里配了涟漪的那些,总开关关着的时候是空表。自建覆盖层(跟着涟漪画点什么)与自测用它 |
178
178
 
179
179
  ```js
180
- view.setEffects({ line: { symbol: 'arrow', size: 10, count: 2 }, point: { radius: 30 } })
180
+ view.updateOptions({ effects: { line: { symbol: 'arrow', size: 10, count: 2 }, point: { radius: 30 } } })
181
181
  view.getEffects().enabled // true(运行时口径:现在真的会动)
182
182
  for (const glyph of view.getLineEffects()) {
183
183
  // glyph.x / glyph.y 是世界坐标,glyph.angle 是弧度(屏幕系,parallel 开着才有意义)
@@ -212,7 +212,7 @@ for (const glyph of view.getLineEffects()) {
212
212
  - **一帧的写量只跟「视野里的动点」走**:动点的实例槽位在解析时就固定,每帧只覆盖那几个 float —— 十万条线的数据里,动的就是视野里那几十个。
213
213
 
214
214
  实测(无头 Chrome + SwiftShader,CDP 驱动 `docs/steps/selftest-effects.html`;小场景 = 1 条折线 + 3 个节点,涟漪 3 圈 + 动点 1 个):一帧 4.5 ~ 4.7 ms,多出来的绘制只有涟漪那一次实例化(动点并进既有批次,`drawCalls` 不会因为动点数量多出与数据量相关的调用);动点采样与折线的距离最大 0.5 个世界单位(步长 0.5 的 401 个采样点全程贴线,拐点不抄近道)。这是软件光栅化的数字,真机 GPU 上这一层可以忽略不计。
215
- **demo 里的用法**:`docs/demos/GisViewDemo.vue` 的「样式 · 标签 → 动画效果与预热」面板就是这一节:线路动点 / 节点涟漪两个开关、图标下拉与「同时几个 / 一趟几秒 / 涟漪半径 / 同时几圈」四个数字框都直接进全局配置,点一下走 `setEffects()`;三个「逐条覆盖」按钮把 `effect: { … }`(自己那一份:黄色 3 个 / 1.5 秒一趟)、`effect: true`(交回全局)、`effect: false`(这几条不动)分别写进前 3 个节点与前 3 条线路 —— 走 `batch()`,一次改动只发一条 `onDataChange`、历史只多一步;读数那一行抄的是 `getEffects()`(归一化之后的配置,`enabled` 是运行时口径)与 `getStats()` 的 `lineEffectCount` / `pointEffectCount` / `animating`,所以把「线路动点」那个勾去掉时「这一帧 动点」归零而涟漪照跑,三层「关」各自关掉的是哪一份一眼就能看出来。
215
+ **demo 里的用法**:`docs/demos/GisViewDemo.vue` 的「样式 · 标签 → 动画效果与预热」面板就是这一节:线路动点 / 节点涟漪两个开关、图标下拉与「同时几个 / 一趟几秒 / 涟漪半径 / 同时几圈」四个数字框都直接进全局配置,点一下走 `updateOptions({ effects })`;三个「逐条覆盖」按钮把 `effect: { … }`(自己那一份:黄色 3 个 / 1.5 秒一趟)、`effect: true`(交回全局)、`effect: false`(这几条不动)分别写进前 3 个节点与前 3 条线路 —— 走 `batch()`,一次改动只发一条 `onDataChange`、历史只多一步;读数那一行抄的是 `getEffects()`(归一化之后的配置,`enabled` 是运行时口径)与 `getStats()` 的 `lineEffectCount` / `pointEffectCount` / `animating`,所以把「线路动点」那个勾去掉时「这一帧 动点」归零而涟漪照跑,三层「关」各自关掉的是哪一份一眼就能看出来。
216
216
 
217
217
  **独立的动画演示**见 `docs/demos/EffectsDemo.vue`(演示页的「动画效果」页签,`?demo=effects`):它把这一节单独拿出来跑 —— 线路动点与节点涟漪各占一块面板:开 / 关、图标下拉(圆点 / 方块 / 内联 SVG 箭头,最后一种的颜色画在 SVG 里)、色板(改的是全局那一份)、「逐条不同色」勾选(把颜色写进数据的 `lines[].effect` / `points[].effect`,一次 `updateData` 合成一条改动)、「全部关掉」演示 `effects: false` 一处全关;读数栏把 `getEffects()` / `getLineEffects()` / `getPointEffects()` 与 `getStats()` 的 `lineEffectCount` / `pointEffectCount` / `animating` 摊在一起(动点位置与涟漪圆心各一行,都是世界坐标);贴图预热那一块给出四个按钮:**「按实际组合配一份(精确)」**(`getSymbolCombos()` + `prebake.combos`)、**「按当前颜色全排列配一份(粗)」**、**「再烤一遍」**(`runPrebake()`)、**「清掉」**。「动画可以设置不同的颜色」这条需求就是它:`effects.line.color` 是全局一套,`lines[].effect.color` / `points[].effect.color` 逐条盖上去(不写就跟着要素自己的颜色走)。
218
218
 
@@ -370,8 +370,9 @@ view.registerSymbol('三角', (context, info) => {
370
370
  - **非正方形的符号**:给画笔挂一个 `painter.aspect`(宽 / 高,缺省 1 = 正方形外框),`0.6` 就是「宽是高的 0.6 倍」。
371
371
  - **颜色参与缓存键**:同一支画笔配不同 `color` / `strokeColor` 各烘一张贴图(与 SVG 符号的 `currentColor` 是同一件事)—— 逐点配色时按颜色种数分档,不会一个点一张。
372
372
  - **数据里直接带画笔**也认:`symbol: { type: 'custom', draw: (context, info) => { … } }` —— 同一个函数引用共用一份贴图,适合「这个图例就画一次」的场合,不必先注册。
373
- - **登记与摘除**:`registerSymbol(name, painter)` 之后已经在用这个名字的点当场按新画法重解析一遍,返回幂等的注销函数;`listSymbols()` 看现在注册着哪些名字,`unregisterSymbol(name)` 直接注销。同名即覆盖(换一支画笔)。注销之后这个名字按**贴图地址**解释(取不到图就退回圆),所以数据还在用它的时候别注销。
374
- - **想在 `createGisView` 之前注册**(图标库在模块顶层装好的那种写法):`import { registerSymbolPainter, unregisterSymbolPainter, listSymbolPainters, hasSymbolPainter } from 'gis-view'` —— 写的是同一份注册表。
373
+ - **登记与摘除**:`registerSymbol(name, painter)` 之后已经在用这个名字的点当场按新画法重解析一遍,返回幂等的注销函数;`listSymbols()` 看现在注册着哪些名字,`unregisterSymbol(name)` 直接注销。同名即覆盖(换一支画笔):贴图缓存键带注册代次,同名重新注册即使画笔函数没变也会重新烘焙,不会沿用旧画法;标签锚点跟着新符号尺寸一起重排。注销之后这个名字按**贴图地址**解释(取不到图就退回圆),所以数据还在用它的时候别注销。
374
+ - **作用域注销**:同一名字可能被后注册的画笔覆盖,`registerSymbol` 返回的注销函数只在当前注册项仍是自己这支画笔时才真的摘掉 —— 旧视图 / 旧插件卸载不会误删新画笔;确实要按名字全局摘除时用 `unregisterSymbol(name)`。
375
+ - **想在 `createGisView` 之前注册**(图标库在模块顶层装好的那种写法):`import { registerSymbolPainter, unregisterSymbolPainter, listSymbolPainters, hasSymbolPainter } from 'gis-view'` —— 写的是同一份注册表;插件 / 微前端要带清理函数时用 `defineSymbolPainter(name, painter)`,返回的清理函数与实例方法同一口径。
375
376
  - **画笔抛错不打断画面**:这一份符号退回圆(位置与颜色仍然对得上),只有这一条符号受影响;名字或画笔不合法(空名字 / 不是函数)只报一条 `onWarning`,注册表不动。
376
377
  - **导出的 SVG 里它是一张位图**:`<image href="data:image/png">`(画笔画出来的是像素,没有矢量描述可还原);要矢量还是把图标写成 `type: 'svg'`。
377
378