gis-view-webgl2 0.1.6 → 0.1.7

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 (25) hide show
  1. package/README.md +1 -1
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.d.ts +86 -7
  4. package/dist/index.js +1 -1
  5. package/docs/AI/345/257/271/350/257/235/346/261/207/346/200/273.md +57 -0
  6. package/docs/GisView.md +1 -1
  7. 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 +62 -0
  8. package/docs/demos/LabelDemo.vue +2 -2
  9. package/docs/demos/StyleDemo.vue +1 -1
  10. package/docs/demos/api-index.js +8 -7
  11. package/docs/demos/gis-view-demo/useGisViewDemo.js +2 -2
  12. package/docs/manual/04-/344/272/213/344/273/266/344/270/216/345/233/236/350/260/203.md +36 -8
  13. 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 +7 -6
  14. package/docs/manual/08-/351/200/211/344/270/255/344/270/216/346/213/220/347/202/271.md +59 -3
  15. package/docs/manual/09-/351/200/232/351/201/223/344/270/216/345/244/232/345/233/236/347/272/277.md +14 -1
  16. package/docs/manual/16-/345/256/236/344/276/213/346/226/271/346/263/225/346/200/273/350/241/250.md +6 -5
  17. package/docs/manual/17-/345/217/202/346/225/260/351/200/237/346/237/245.md +7 -6
  18. package/docs/manual/18-/351/200/220/346/226/271/346/263/225/347/244/272/344/276/213.md +7 -1
  19. package/docs/manual/20-/346/200/247/350/203/275/344/270/216/345/244/247/346/225/260/346/215/256.md +1 -0
  20. package/docs/steps/README.md +4 -4
  21. package/docs/steps/selftest-demos.html +2 -2
  22. package/docs/steps/selftest-docs.html +3 -3
  23. package/docs/steps/selftest-features.html +1 -1
  24. package/docs/steps/selftest-select.html +168 -1
  25. package/package.json +1 -1
@@ -2,6 +2,33 @@
2
2
 
3
3
  > 选中与拖动的层级、框选、接口选中、线路拐点的增删改。
4
4
 
5
+ ## 选中与取消选中速查
6
+
7
+ 选中与取消选中共用同一份 state:鼠标点击、Ctrl / ⌘ 多选、框选和接口调用改的都是这一份集合,读回统一用 `isSelected()` / `getSelections()` / `getSelectionData()`。
8
+
9
+ | 想做什么 | 怎么做 |
10
+ | --- | --- |
11
+ | 选中一个 | 单击节点 / 线路;或 `view.selectFeatures(ref)` |
12
+ | 按坐标像单击一样选中 | `view.selectAt(clientX, clientY)`(通道回线按 `channelSelectMode` 摊开) |
13
+ | 换一份选中集 | `view.selectFeatures(refs)`(缺省 `mode: 'replace'`) |
14
+ | 追加 / 取反 / 移除 | `view.selectFeatures(refs, { mode: 'add' \| 'toggle' \| 'remove' })` |
15
+ | 多选 | 打开 `multiSelect` 后单击,或按住 Ctrl / ⌘ 单击;框选先开 `boxSelect`,再按住 Shift 拖动 |
16
+ | 取消指定项 | `view.deselect(refs)`,等价于 `view.selectFeatures(refs, { mode: 'remove' })` |
17
+ | 取消全部 | 单击空白处、无其它编辑态时按 Esc、`view.deselect()`、`view.selectFeatures(null)` 或 `view.selectFeatures([])` |
18
+ | 读回结果 | `view.isSelected(ref)` / `view.getSelections()` / `view.getSelectionData()` |
19
+
20
+ ```js
21
+ view.selectFeatures(['station-1', { type: 'point', index: 3 }]) // 换一份选中集
22
+ view.selectFeatures('station-2', { mode: 'add' }) // 加选
23
+ view.selectFeatures('station-1', { mode: 'toggle' }) // 已选中就取消
24
+ view.deselect('station-2') // 只取消这一项
25
+ view.deselect() // 取消全部
26
+ ```
27
+
28
+ `refs` 可以是节点 / 线路 id、`{ type, index }`、通道 / 分组写法、拾取载荷或搜索命中。公开实例没有 `select()`,旧代码统一改用 `selectFeatures()`;`deselect([])` 是「没有目标」,不会改选中集。上面按缺省 `selectTrigger: 'click'` 写;改成 `'dblClick'` 后,选中与点空白清空都由第二下完成,接口调用不受影响。
29
+
30
+ **选中状态只有一份**:`selectFeatures()` / `deselect()` 与单击、Ctrl 多选、框选改的是同一份选中 state,样式、`isSelected()` 与 `getSelectionData()` 都立即更新。选中集实际变化时,它们与点击一样会按 `onSelect → onSelectChange` 发回调(接口来源 `cause: 'api'`);区别只在入口和通知口径,不在“算不算选中”。`onSelect` 是**本次操作**回调(真实点击 / 框选 / `selectAt` 每次成功操作都发),`onSelectChange` 是**集合状态**回调(只在集合真的变了时发,适合同步面板)。选中通道回线时两条回调都带 `channelId` / `group` / `groups` 等字段;多通道看 `channelContexts`,见 [04 事件与回调](04-事件与回调.md#回调)。
31
+
5
32
  ## 选中 / 拖动的层级(selectionOnTop)
6
33
 
7
34
  `selectionOnTop`(默认 `true`)把「选中的节点、拖动中的整组、待确认合并的目标节点」以及「选中线路所在的整条通道」画在所有要素之上,与 `zIndex` 无关 —— 选中了就一定看得见。写 `selectionOnTop: false` 就完全按 `zIndex` 与数据顺序绘制。抬层只在选中 / 拖动 / 合并状态变化时整份重排一次,悬停这类高频路径不参与,十万级数据下不会拖慢交互。
@@ -54,7 +81,7 @@ createGisView(container, {
54
81
  view.updateOptions({ selectTrigger: 'click' }) // 运行时切回单击选中
55
82
  ```
56
83
 
57
- 运行时切换 `selectTrigger` 会清掉上一次点击的双击判定状态,切换后的第一下不会被算成旧点击序列的第二下。`selectFeatures()` / `select()` 这类接口选中不受触发方式影响。
84
+ 运行时切换 `selectTrigger` 会清掉上一次点击的双击判定状态,切换后的第一下不会被算成旧点击序列的第二下。`selectFeatures()` 这类接口选中不受触发方式影响。
58
85
 
59
86
  ### 选择类型过滤(selectionKinds)
60
87
 
@@ -81,7 +108,7 @@ view.selectInBox({ x: 40, y: 40, width: 320, height: 200 }, { selectionKinds: ['
81
108
 
82
109
  ## 接口选中(selectFeatures)与读回选中
83
110
 
84
- 点选、Ctrl 多选、框选与接口选中落在**同一份选中 state** 上:任何一种选上,`getSelections()` / `getSelectionData()` 与 `onSelectChange` 读到的都是它(接口选中会补一条 `cause: 'api'` 的选中集变更,所以一个回调就能覆盖所有来源)。
111
+ 点选、Ctrl 多选、框选与接口选中落在**同一份选中 state** 上:任何一种选上,`getSelections()` / `getSelectionData()` 读到的都是它。真实操作(点击 / Ctrl 取反 / 框选 / `selectAt`)每次成功操作都发 `onSelect`;按引用接口(`selectFeatures` / `deselect` / `focusOn({ select })`)只在实际改变选中集时发。`onSelectChange` 覆盖所有来源,并且只在集合真的变化时发;两条都在时顺序固定为 `onSelect → onSelectChange`(真实单击最前面还有 `onClick`)。
85
112
 
86
113
  `selectFeatures(refs, options?)` 认这些写法(传 `null` / 空数组等于清空):
87
114
 
@@ -112,6 +139,35 @@ view.selectFeatures({ type: 'channel', channelId: 'channel-a' }, { mode: 'remove
112
139
 
113
140
  拾取载荷(`pickAt` 或指针事件里的那一份)与搜索命中里可能带着 `channelId` / `channelIndex` / `groupId` —— 那是「这条回线属于哪条通道」的上下文,不是「要选整条通道」:它们的 `type` / `kind` 写着 `line` / `point`,落地选中的就是那一条要素自己(与在画布上点它一个结果)。要整条通道,显式写 `{ type: 'channel', channelId: 'channel-a' }`。
114
141
 
142
+ ### 按坐标单击选中(selectAt)
143
+
144
+ 如果手上只有鼠标坐标、又希望组件像用户单击一样选中,用 `selectAt(clientX, clientY)`:命中顺序、通道回线摊开(`channelSelectMode`)与真实点击一致,但不需要先自己拆拾取载荷。命中空白或聚合圆时返回 `null`,不改原选中集。
145
+
146
+ 它立即生效,不受 `selectTrigger: 'dblClick'` 等待影响,也不派发 `onClick` / `onDblClick`;每次成功命中都发 `onSelect`(模拟一次单击),`onSelectChange` 只在选中集实际变化时发,`cause` 都是 `'api'`。选中通道回线时两条回调都带当前命中的 `group`、整条通道的 `groups` 与顶层 `channelId`;多通道看 `channelContexts`。要取消指定项或全部,继续用 `deselect()`。
147
+
148
+ `selectAt()` 与 `selectFeatures()` 最终落在同一份选中 state,选中样式和读回结果没有区别;区别在入口、是否经过坐标命中与返回值:
149
+
150
+ | | `selectAt(clientX, clientY)` | `selectFeatures(refs, options?)` |
151
+ | --- | --- | --- |
152
+ | 输入 | 浏览器客户端坐标 | id / 下标 / 通道 / 分组等引用 |
153
+ | 命中方式 | 与真实单击同一套 `pick()`,通道回线按 `channelSelectMode` 摊开 | 按引用直接解析;整条通道要显式写 `{ type: 'channel', channelId }` |
154
+ | 怎么改选中集 | 始终像普通单击一样换一份 | `mode` 可 `'replace'` / `'add'` / `'toggle'` / `'remove'` |
155
+ | 空白 | 返回 `null`,原选中集不动 | `null` / 空数组清空;认不出的条目逐条跳过 |
156
+ | 回调 | 每次成功命中都发 `onSelect`;选中集实际变化时再发 `onSelectChange`。顺序 `onSelect → onSelectChange`(`cause: 'api'`),不发 `onClick` / `onDblClick` | 选中集实际变化时发 `onSelect → onSelectChange`(`cause: 'api'`),不发 `onClick` / `onDblClick` |
157
+ | 返回 | 本次选中载荷或 `null` | 实例本身,可链式调用 |
158
+
159
+ ```js
160
+ const at = view.worldToClient(120, 60)
161
+ const selected = view.selectAt(at[0], at[1]) // 节点 / 线路 / 通道回线,命中什么选什么
162
+
163
+ view.updateOptions({ channelSelectMode: 'channel' })
164
+ view.selectAt(at[0], at[1]) // 命中通道里任一回线 = 选中整条通道
165
+ view.selectAt(-1000, -1000) // 空白:返回 null,原选中集不动
166
+ ```
167
+
168
+ 已经知道节点 id 时直接用 `view.selectFeatures('station-1024')`,不必先求坐标;`selectAt` 留给「只有鼠标 / 触摸坐标」
169
+ 的场景(画布外点击转发、命中测试、浮层把坐标交给组件)。它不是选中节点的必经入口,也不是 `selectFeatures` 的替代品。
170
+
115
171
  聚焦同一套目标写法交给 `focusOn`,显式 `type` 还能解决节点与线路同 id 的歧义;整条通道可以写 `{ channelId }`,分组写 `{ type: 'group', channelId?, groupId }`(`groupId` 全局唯一时可省略 `channelId`)。节点、线路、通道与分组的取范围与缩放都走 `zoomToBounds`,不会因为类型不同换一套放大倍率:
116
172
 
117
173
  ```js
@@ -144,7 +200,7 @@ view.focusOn(selection.items, { select: true, zoom: true }) // 反过来也行
144
200
 
145
201
  ### 取消选中(deselect / selectFeatures(null))
146
202
 
147
- 取消某一个 / 某一类 / 全部都是**一句话**(`deselect()` 与 `selectFeatures(null)` 都表示全部取消):与选中落在同一份 state 上,清完 `getSelections()` / `getSelectionData()` 立刻读得到,`onSelectChange` 补一条 `cause: 'api'` 的选中集变更。
203
+ 取消某一个 / 某一类 / 全部都是**一句话**(`deselect()` 与 `selectFeatures(null)` 都表示全部取消):与选中落在同一份 state 上,清完 `getSelections()` / `getSelectionData()` 立刻读得到;选中集实际变化时按 `onSelect → onSelectChange` 派发,`onSelect` 的载荷为 `null`、`cause` 为 `'api'`。
148
204
 
149
205
  ```js
150
206
  view.deselect('station-1024') // 取消一个节点(id,或 { type: 'point', index: 3 })
@@ -40,7 +40,20 @@ view.showFeatures('channel', 'c1')
40
40
  view.updateOptions({ channelSelectMode: 'channel' }) // 点一下回线 = 选中整条通道
41
41
  ```
42
42
 
43
- Ctrl / ⌘ + 点击在 `'group'` / `'channel'` 下是**整批取反**:这一组 / 这一条通道都在选中集里时一起取消,否则一起选上(与单击走同一套摊开规则)。按住 `shortcuts.addWaypoint`(默认也是 Ctrl / ⌘)点击通道回线加点时,加完由这一档决定选中并高亮一条、一组还是整条通道。框选、接口选中(`select` / `selectFeatures`)与「按通道写法的接口选中」不受影响。改这一项只重画一帧、不重排场景(`updateOptions({ channelSelectMode })`)。
43
+ Ctrl / ⌘ + 点击在 `'group'` / `'channel'` 下是**整批取反**:这一组 / 这一条通道都在选中集里时一起取消,否则一起选上(与单击走同一套摊开规则)。按住 `shortcuts.addWaypoint`(默认也是 Ctrl / ⌘)点击通道回线加点时,加完由这一档决定选中并高亮一条、一组还是整条通道。框选与 `selectAt()` 也按这一档摊开;`selectFeatures` 的显式通道写法按你给的目标选中(要整条通道就写 `{ type: 'channel', channelId }`),不靠这一档推断。改这一项只重画一帧、不重排场景(`updateOptions({ channelSelectMode })`)。
44
+
45
+ 鼠标载荷(`onPointerDown` / `onPointerUp` / `onClick` / `onDblClick` / `onHover` / `pickAt()`)命中通道回线时,
46
+ `group` 是当前命中的那一组,`groups` 是同一通道的全部分组(按声明顺序,每项带 `groupIndex` / `lineIds` /
47
+ 骨架与 `path`)。点击一条回线时不必先扫一遍 `getChannelGroups()` 再自己判断是哪组;`groups.length` 就是组数。
48
+
49
+ 选中回调(`onSelect` / `onSelectChange`)带同一份通道上下文:选中集只涉及一条通道时,载荷顶层平铺
50
+ `channelId` / `channelIndex` / `channel` / `groupId` / `groupIndex` / `group` / `groups`。真实单击与
51
+ `selectAt()` 的 `group` 是命中的那一组;框选、`selectFeatures()` 这类没有单次命中组的入口,取选中集里
52
+ 第一条通道回线所在的组。`groups` 始终是整条通道的全部分组,不是只含选中的组。一次选中多条通道时顶层
53
+ 不再摊平,改看 `channelContexts`(每项带同样的字段与本次选中的 `lineIndexes` / `lineIds`);单通道不会
54
+ 额外创建 `channelContexts`。`items[]` 里每条回线只带 `channelId` / `channelIndex` / `groupId` /
55
+ `groupIndex` 标量归属,不再逐条重复挂 `group` / `groups`;分组读数统一从顶层或 `channelContexts` 取。
56
+ `onSelect` 与 `onSelectChange` 共用同一批对象引用,两条都订阅时不会各拷一份。
44
57
 
45
58
  ### 分组拐点拖动模式(channelWaypointDragMode)
46
59
 
@@ -71,18 +71,19 @@
71
71
  | `createGeoMapper(options?)` | 建一个「数据坐标 → GeoJSON 坐标」的映射器:`mode: 'lngLat' \| 'fit' \| 'scale'`、`scale`(节点与 geojson 的大小比例)、`scaleX` / `scaleY` / `offsetX` / `offsetY` / `center` / `bounds` / `data` / `padding` / `target`,返回 `map` / `unmap` / `mapPoint` / `mapLine` / `mapChannel` / `mapData` 与最终的比例偏移;没有 GeoJSON 背景时是 `null` |
72
72
  | `mapDataToGeoJson(data, options?)` | `createGeoMapper(options).mapData(data)` 的快捷写法:把 `{ points, lines, channels }` 整份搬到 GeoJSON 坐标,原数据不动;没有 GeoJSON 背景时是 `null` |
73
73
  | `pick(x, y)` | 拾取指定画布位置上的要素(`clickable: false` 的要素不会返回) |
74
- | `pickAt(clientX, clientY)` | 按**浏览器客户端坐标**拾取(就是鼠标事件里的 `clientX` / `clientY`):与指针事件载荷同一份结果(`type` / `index` / `item` / `distance` / `screen` / `coordinate` 与通道字段),另加 `target`(`'point'` / `'line'` / `'channel'`)与 `lngLat`;没命中返回 `null`。只读,不改选中也不动光标 |
74
+ | `pickAt(clientX, clientY)` | 按**浏览器客户端坐标**拾取(就是鼠标事件里的 `clientX` / `clientY`):与指针事件载荷同一份结果(`type` / `index` / `item` / `distance` / `screen` / `coordinate` 与通道字段),另加 `target`(`'point'` / `'line'` / `'channel'`)与 `lngLat`;命中通道回线时 `group` 是当前组、`groups` 是整条通道的全部分组;没命中返回 `null`。只读,不改选中也不动光标 |
75
+ | `selectAt(clientX, clientY)` | 只有客户端坐标时用它像普通单击一样立即选中命中要素:节点 / 线路直接选中,通道回线按 `channelSelectMode` 摊开;空白或聚合圆返回 `null` 且不改选中。不等待 `selectTrigger: 'dblClick'`,也不派发 `onClick` / `onDblClick`;每次成功命中都发 `onSelect`,选中集实际变化时再发 `onSelectChange`,顺序 `onSelect → onSelectChange`(`cause: 'api'`)。选中通道回线时载荷带当前 `group` / 完整 `groups`,多通道看 `channelContexts`。已知节点 / 线路 id 时直接用 `selectFeatures`,见「按坐标单击选中(selectAt)」 |
75
76
  | `hoverAt(clientX, clientY)` / `clearHover()` / `getHover()` | 程序化悬停:`hoverAt` 让指针「停」到某个客户端坐标上(悬停高亮、光标与 `onHover` 一起更新,返回与 `onHover` 载荷同一份读数,空白处 `null`;不走悬停阈值,也不用先订阅事件)、`clearHover()` 撤掉并补发一条 `onHover(null)`、`getHover()` 随时读当前这一份 —— 外部列表 / 表格 hover 到某一行时联动高亮图上的要素用它 |
76
77
  | `getFeaturesInRect(rect)` | 画布 CSS 像素矩形里有什么(与框选 / `selectInBox` 同一套坐标与判定):返回 `{ rect, worldRect, points, lines }` —— `points` 是框内的节点(与 `onSelect` 的 `items` 同一口径)、`lines` 是包围盒沾到框的线路(与 `listLines` 同一份)、`worldRect` 是这块像素对应的世界坐标范围;只读,不改选中也不画框 |
77
78
  | `getChannelsInView()` | 当前视口里有哪几条通道(同一通道的几回线只算一条):每条给 `{ channelId, channelIndex, channel, lineIndexes, groupIds }`,侧边栏「画面里的通道」、按视口统计用它 |
78
- | `selectFeatures(refs, options?)` | 接口选中(换一份选中集;`selectFeatures(null)` / 空数组就是清空):`refs` 认节点 / 线路 id、id 数组、`{ type, index }`、通道写法(`{ type: 'channel', channelId }` / `{ channelId, groupId }` / `{ channelId, lineIds }`)、分组写法(`{ type: 'group', channelId?, groupId }`,全局唯一 `groupId` 可省略通道)以及拾取载荷与搜索命中;`options.mode` 给 `'replace'`(缺省,换一份)/ `'add'` / `'toggle'`(在就取消、不在就选上)/ `'remove'`,见「接口选中(selectFeatures)与读回选中」 |
79
+ | `selectFeatures(refs, options?)` | 接口选中(换一份选中集;`selectFeatures(null)` / 空数组就是清空):`refs` 认节点 / 线路 id、id 数组、`{ type, index }`、通道写法(`{ type: 'channel', channelId }` / `{ channelId, groupId }` / `{ channelId, lineIds }`)、分组写法(`{ type: 'group', channelId?, groupId }`,全局唯一 `groupId` 可省略通道)以及拾取载荷与搜索命中;`options.mode` 给 `'replace'`(缺省,换一份)/ `'add'` / `'toggle'`(在就取消、不在就选上)/ `'remove'`。会同步选中样式与读数;选中集实际变化时按 `onSelect → onSelectChange` 派发(`cause: 'api'`),载荷带同一份通道上下文(单通道摊平,多通道看 `channelContexts`),不发 `onClick` / `onDblClick`,见「接口选中(selectFeatures)与读回选中」 |
79
80
  | `selectWaypoint(lineRef, pathIndex?, options?)` | 代码选中一个拐点:`selectWaypoint('line-1', 2)` / `{ lineIndex, pathIndex }` / `{ lineId, pathIndex }`,并同时选中所在线路;有效下标从 `getWaypoints()` 取。传 `null` 只取消拐点选中、不改要素选中集,见「代码选中拐点(selectWaypoint)」 |
80
- | `deselect(refs?)` | 取消选中:写法与 `selectFeatures` 完全一样,把这些从当前选中集里去掉、其余选中原样留着;不传 / `null` 等于取消全部(也就是 `selectFeatures(null)`),空数组是「没有目标」什么都不做,见「取消选中(deselect / selectFeatures(null))」 |
81
+ | `deselect(refs?)` | 取消选中:写法与 `selectFeatures` 完全一样,把这些从当前选中集里去掉、其余选中原样留着;不传 / `null` 等于取消全部(也就是 `selectFeatures(null)`),空数组是「没有目标」什么都不做。改变选中集时按 `onSelect → onSelectChange` 派发(`cause: 'api'`;清空时 `onSelect` 载荷为 `null`),见「取消选中(deselect / selectFeatures(null))」 |
81
82
  | `getSelections()` | 读回选中结果 `[{ type, index, item, … }]`(按选中顺序):要 id 列表 / 个数 / 包围盒看 `getSelectionData()` |
82
83
  | `isSelected(ref)` | 这个要素在不在当前选中集里:`isSelected('node-0')`(id)/ `isSelected({ type: 'line', index: 12 })`(也可以只写下标,按 id 认不出来时当下标),返回 `true` / `false`。面板上高亮「当前这一行」时不必自己比对 `getSelections()`;选中集变化时缓存一份键集合(见「接口选中」),十万级选中下这一问也是常数级 |
83
84
  | `getSelectionData()` | 选中集一次取全:读数(`ids` / `count` / `pointCount` / `lineCount` / `channelCount`)加要素本身(`points` / `lines` / `channels`(按通道去重,与 `getData()` 同一批对象)),另有 `items`(与 `getSelections()` 同一份)、`pointIndexes` / `lineIndexes` 与 `bounds`(世界包围盒,空选中时 `null`),见「接口选中(selectFeatures)与读回选中」 |
84
85
  | `getCascadeHighlight()` | 当前点击级联高亮的读数:`{ origin, scope, points, lines, pointIndexes, lineIndexes }`;没有高亮或 `cascadeHighlight` 关着时返回 `null`。它只读、不改选中集,见「点击级联高亮」 |
85
- | `selectInBox(box, options?)` | 按画布 CSS 像素矩形框选,等价于 Shift + 拖动(跟随 `shortcuts.boxSelect`);`options.selectionKinds` 可只覆盖这一次的节点 / 线路 / 通道类型过滤 |
86
+ | `selectInBox(box, options?)` | 按画布 CSS 像素矩形框选,等价于 Shift + 拖动(跟随 `shortcuts.boxSelect`);`options.selectionKinds` 可只覆盖这一次的节点 / 线路 / 通道类型过滤。每次框选操作都发 `onSelect`(空框的载荷为 `null`),选中集实际变化时再发 `onSelectChange`,顺序 `onSelect → onSelectChange`(`cause: 'boxSelect'`);通道回线的分组字段与鼠标框选同一份 |
86
87
  | `beginCopyPoint(options?)` | 开始复制一个节点:给 `id` / `index` 直接出预览(`commit: true` 出预览即落地,`follow: true` 跟随鼠标,`autoCommit: false` 停在预览态),不给来源就是「先点按钮再选节点」模式,见「节点复制」 |
87
88
  | `commitPointCopy()` / `cancelPointCopy()` | 落地 / 取消预览中的复制(返回 `end` / `cancel` 载荷) |
88
89
  | `updateCopyPoint(patch)` | 改预览节点的数据(等价于回调里的 `payload.update` / `setItem`),返回改完的节点 |
@@ -200,7 +201,7 @@
200
201
 
201
202
  返回 `this` 的方法都可以链式调用;返回其它值的方法(`get*` / `list*` / `export*` / `search` / `pick*` / `add*` / `remove*` / `copy*` / `merge*` / `locate*` 这些)在下面的「参数速查」里都标了返回类型。
202
203
 
203
- **怎么确认「组件到底有哪些属性和方法」**:上面那张表按功能分组介绍,下面的「参数速查」把实例的**每个成员逐个列全**(当前 264 个:259 个方法 + `el` / `canvas` / `gl` / `cameraAnimating` / `dataVersion` 五个属性),`options` 表列全 96 个键,数据字段表列全 250 个字段。这三份清单不靠手抄 —— 它们直接对着源码与类型对账:
204
+ **怎么确认「组件到底有哪些属性和方法」**:上面那张表按功能分组介绍,下面的「参数速查」把实例的**每个成员逐个列全**(当前 265 个:260 个方法 + `el` / `canvas` / `gl` / `cameraAnimating` / `dataVersion` 五个属性),`options` 表列全 96 个键,数据字段表列全 250 个字段。这三份清单不靠手抄 —— 它们直接对着源码与类型对账:
204
205
 
205
206
  - `node docs/steps/gen-api-index.mjs`:从 `create.js` 的实例定义、本文档的两张表与 `types.d.ts` 生成 `docs/demos/api-index.js`(`API_SUMMARY` / `API_SECTIONS` / `API_OPTIONS`),demo 的「实例 API」面板读的就是它;带 `--check` 时只校验有没有过期。
206
207
  - `node docs/steps/check-doc-coverage.mjs`:10 项双向对账 —— 实例成员 × 文档 / × 参数速查 / × 类型、参数速查与实例方法主表各自反查过期条目、类型反查过期声明、数据字段 × 文档、options 键 × 文档 / options 表反查过期字段、demo 清单是否最新。加一个实例成员、改一个 options 键而没同步文档时,这个脚本会直接报出来。
@@ -1,6 +1,6 @@
1
1
  # 17 参数速查(逐个方法)
2
2
 
3
- > 264 个成员逐个列全:参数、默认值与返回。
3
+ > 265 个成员逐个列全:参数、默认值与返回。
4
4
 
5
5
  ### 参数速查(逐个方法)
6
6
 
@@ -14,7 +14,7 @@
14
14
 
15
15
  - **同步方法返回 `this` 时可链式调用**:所有 `update*` / `set*` / `add*` / `remove*`、选中与视角方法都先完成本地状态更新再返回实例;需要结果时用相邻的 `get*` / `list*` 读回。少数返回数组、对象或 `null` 的方法在「返回」列单独标出。
16
16
  - **数据写入只发一次通知**:`updatePoint`、`updateLine`、`patch*`、`add*`、`remove*` 等每次成功改数据都会发一条对应的 `dataChange`(底层统一为 `updatePoints` / `updateLines` / `updateChannels` 等类型)并落一步历史;在 `batch()` 里连续调用会合并成一条。`emit: false` 只写数据、不发通知,适合宿主自己在保存成功后统一刷新。
17
- - **选中方法会同步选中集**:`selectFeatures` / `selectWaypoint` / `selectInBox` 先更新引擎选中态,再同步 `getSelections()`、`getSelectionData()` 与 `isSelected()`;一次有效变更会发 `onSelect`(`cause` 标明是 `api`、`box` 或指针来源)和 `onSelectChange`。没有命中任何目标时不会凭空选中,`selectWaypoint(null)` 只清拐点、不动要素选中集。
17
+ - **选中方法会同步选中集**:`selectFeatures` / `selectWaypoint` / `selectInBox` 先更新引擎选中态,再同步 `getSelections()`、`getSelectionData()` 与 `isSelected()`。点击、`selectFeatures` / `deselect` / `selectAt` / `focusOn({ select })`、`selectInBox` 改的都是同一份选中 state。真实操作(点击 / Ctrl 取反 / 框选 / `selectAt`)每次成功操作都发 `onSelect`;按引用接口(`selectFeatures` / `deselect` / `focusOn({ select })`)只在实际改变时发。`onSelectChange` 覆盖所有来源且只在集合变化时发,顺序固定为 `onSelect → onSelectChange`。接口路径不会派发 `onClick` / `onDblClick`,`cause` 为 `'api'` 或 `'boxSelect'`;选中通道时两条载荷都带单通道摊平的 `channelId` / `group` / `groups`,多通道看 `channelContexts`。没有命中任何目标时不会凭空选中,`selectWaypoint(null)` 只清拐点、不动要素选中集。
18
18
  - **视角方法会更新相机**:`focusOn` / `flyTo` / `zoomTo` / `setCamera` / `fitView` 成功落到目标范围后更新 `getCamera()`,并触发 `onCameraChange`;目标认不出来、视口尚未量出或参数非法时保持当前相机。`focusOn` 缺省只居中、不改比例;`flyTo` 只是同一条目标解析的平滑版,飞行中用户拖拽 / 滚轮、再次飞行或任何程序化改相机都会停在当前帧。`zoom` / `scale` / `center` / `padding` / `maxZoom` 只影响这一次调用,不会改写持久配置。
19
19
  - **只读方法返回快照或副本**:`getSelectionData()`、`getFilter()`、`getStats()`、`snapshotScene()`、`listPoints()` / `listLines()` 返回的是调用当时的读数,不会随后续数据变更自动更新;需要最新值就再调一次。对象型字段的含义以 `types.d.ts` 同名接口为准。
20
20
  - **失败是可控的**:找不到 id / 下标越界 / 不支持的写法会跳过这一条,能返回 `null` 或 `{ ok: false, reason }` 的就按对应签名返回;需要排查时订阅 `onWarning`。异步筛选 / 搜索通过完成事件、`getFilterResult()` / 搜索结果或返回的 Promise 取结果,不要在调用下一行假定 Worker 已经跑完。
@@ -107,7 +107,8 @@
107
107
  | `mapDataToGeoJson(data, options?)` | `data: { points?, lines?, channels? }` 要搬的数据;`options?: GisViewGeoMapperOptions` 同 `createGeoMapper` | `GisViewPoint[]; lines?: GisViewLine[]; channels?: GisViewChannel[] }>(data: T, options?: GisViewGeoMapperOptions): T \| null` |
108
108
  | `getPointer()` | 无 | `GisViewPointerState` |
109
109
  | `pick(x, y)` | `x` / `y` 画布 CSS 像素 | `GisViewPickResult \| null` |
110
- | `pickAt(clientX, clientY)` | `clientX` / `clientY` 浏览器客户端坐标(`PointerEvent` 原样给) | `GisViewPickAtResult \| null` |
110
+ | `pickAt(clientX, clientY)` | `clientX` / `clientY` 浏览器客户端坐标(`PointerEvent` 原样给);命中通道回线时返回的 `group` 是当前组、`groups` 是整条通道的全部分组 | `GisViewPickAtResult \| null` |
111
+ | `selectAt(clientX, clientY)` | `clientX` / `clientY` 浏览器客户端坐标;按普通单击的命中与通道摊开规则立即选中,空白 / 聚合圆返回 `null`、不改选中,不派发 `onClick` / `onDblClick`。每次成功命中都发 `onSelect`,选中集实际变化时再发 `onSelectChange`(`cause: 'api'`,顺序 `onSelect → onSelectChange`);通道回线载荷带当前 `group` / 完整 `groups`,多通道看 `channelContexts`。已知 id 时优先 `selectFeatures` | `GisViewSelectPayload \| null` |
111
112
  | `hoverAt(clientX, clientY)` | `clientX` / `clientY` 客户端坐标 | `GisViewHoverPayload \| null` |
112
113
  | `clearHover()` | 无 | `null` |
113
114
  | `getHover()` | 无 | `GisViewHoverPayload \| null` |
@@ -145,14 +146,14 @@
145
146
 
146
147
  | 方法 | 参数(名字: 类型 = 默认值) | 返回 |
147
148
  | --- | --- | --- |
148
- | `selectFeatures(refs, options = {})` | `refs: GisViewSelectionRefs` 要选中的东西:节点 / 线路 id、id 数组、`{ type, index }`、通道写法(`{ type: 'channel', channelId \| channelIndex }`,可再给 `groupIds` / `lineIds` / `lineIndexes` 取子集)、分组写法(`{ type: 'group', channelId?, groupId }`,`groupId` 全局唯一时可省略通道)、拾取载荷 / 搜索命中,传 `null` / 空数组等于清空一份新的选中集。认不出来的条目逐条跳过;`options?: GisViewSelectOptions` 只有 `mode`:`'replace'` 缺省换一份、`'add'` 追加、`'toggle'` 在就取消 / 不在就选上、`'remove'` 去掉 | `this`(选中真的变了才发 `onSelect` 与 `onSelectChange`,`cause` 为 `api`) |
149
+ | `selectFeatures(refs, options = {})` | `refs: GisViewSelectionRefs` 要选中的东西:节点 / 线路 id、id 数组、`{ type, index }`、通道写法(`{ type: 'channel', channelId \| channelIndex }`,可再给 `groupIds` / `lineIds` / `lineIndexes` 取子集)、分组写法(`{ type: 'group', channelId?, groupId }`,`groupId` 全局唯一时可省略通道)、拾取载荷 / 搜索命中,传 `null` / 空数组等于清空一份新的选中集。认不出来的条目逐条跳过;`options?: GisViewSelectOptions` 只有 `mode`:`'replace'` 缺省换一份、`'add'` 追加、`'toggle'` 在就取消 / 不在就选上、`'remove'` 去掉 | `this`(选中真的变了才按 `onSelect → onSelectChange` 发,`cause` 为 `api`;载荷带通道上下文) |
149
150
  | `selectWaypoint(lineRef, pathIndex?, options = {})` | `lineRef` 线路 id、`lines` 下标或 `{ lineIndex, pathIndex }` / `{ lineId, pathIndex }` 对象;`pathIndex?: number` 必须是 `getWaypoints()` 给出的可编辑中间拐点下标(首尾端点不算,通常直接从读数里取);`options?: GisViewSelectOptions` 的 `mode` 决定所在线路怎么并进当前选中集(缺省 `replace`)。有效拐点会同时选中线路;线路不存在 / 下标非法 / 拐点不可见时只清拐点选中,不猜位置;传 `null` 也只清拐点,不改要素选中集 | `this` |
150
- | `deselect(refs = null)` | `refs?: GisViewSelectionRefs` 要取消的东西(写法与 `selectFeatures` 一致:节点 / 通道 / 线路 / 混合一批);不传 / `null` 等于取消全部(= `selectFeatures(null)`),空数组是「没有目标」什么都不做 | `this` |
151
+ | `deselect(refs = null)` | `refs?: GisViewSelectionRefs` 要取消的东西(写法与 `selectFeatures` 一致:节点 / 通道 / 线路 / 混合一批);不传 / `null` 等于取消全部(= `selectFeatures(null)`),空数组是「没有目标」什么都不做 | `this`(选中真的变了才按 `onSelect → onSelectChange` 发,`cause` 为 `api`;清空时 `onSelect` 载荷为 `null`) |
151
152
  | `getSelections()` | 无 | `GisViewPickResult[]` |
152
153
  | `isSelected(ref)` | `ref: GisViewFeatureRef \| GisViewFeatureKey \| GisViewSelectionRefObject` 要素定位(id / 下标 / `{ id }` / `{ index }` / `{ type, index }` / `{ item }`) | `boolean` |
153
154
  | `getSelectionData()` | 无 | `GisViewSelectionData`:`{ ids, count, pointCount, lineCount, channelCount, points, lines, channels, items, pointIndexes, lineIndexes, bounds }`(`bounds` 是世界包围盒,空选中时 `null`) |
154
155
  | `getCascadeHighlight()` | 无 | `GisViewCascadeHighlightInfo \| null`:当前点击级联高亮的来源、范围与节点 / 线路读数 `{ origin, scope, points, lines, pointIndexes, lineIndexes }`,不改变选中集 |
155
- | `selectInBox(box, options = {})` | `box: GisViewRect` **画布 CSS 像素**矩形 `{ x, y, width, height }`(与手动框选同一套坐标);`options?: GisViewSelectInBoxOptions`:`selectionKinds` 临时覆盖这一次保留哪几类(`'point'` / `'line'` / `'channel'`、数组、`'all'` / `true` / `false` / `null`,不写沿用全局 `selectionKinds`),`originalEvent` 原样带进 `onSelect`;第二段也兼容旧的 Event 写法。空框不会选中任何要素 | `this`(发 `onSelect` / `onSelectChange`,`cause` 为框选) |
156
+ | `selectInBox(box, options = {})` | `box: GisViewRect` **画布 CSS 像素**矩形 `{ x, y, width, height }`(与手动框选同一套坐标);`options?: GisViewSelectInBoxOptions`:`selectionKinds` 临时覆盖这一次保留哪几类(`'point'` / `'line'` / `'channel'`、数组、`'all'` / `true` / `false` / `null`,不写沿用全局 `selectionKinds`),`originalEvent` 原样带进 `onSelect`;第二段也兼容旧的 Event 写法。空框不会选中任何要素 | `this`(每次框选都发 `onSelect`,空框为 `null`;集合变化时再发 `onSelectChange`,顺序 `onSelect → onSelectChange`,`cause` 为 `boxSelect`) |
156
157
  | `beginCopyPoint(options = {})` | `options: GisViewCopyStartOptions`:`id` / `index` / `item` 三选一给来源,都不给就进入「下一次点选节点」模式;`coordinate` / `screen` 是落点(缺省按 `copy.offset`);`follow` 让预览跟指针;`autoCommit` 覆盖 `copy.autoCommit`;`commit: true` 出预览即落地;`via` / `originalEvent` 原样带回。必须配置 `copy`(`enabled` 不为 `false`),否则返回 `null`。预览节点插在 `points` 末尾且不带 id,不会把绑定源 id 的线路 / 通道带过去 | `GisViewPointCopyPayload \| null`:`phase: 'start'` 的预览载荷;来源认不出来时返回 `null` |
157
158
  | `commitPointCopy()` | 无:把当前复制预览落地,先发 `commit`、落地后发 `end`;`commit` 阶段载荷里的 `cancel()` 可以拦住这次复制 | `GisViewPointCopyPayload \| null`:成功的 `end` 载荷;没有预览或已被取消时为 `null` |
158
159
  | `cancelPointCopy()` | 无:撤掉预览并补一条 `cancel`,数据与历史不动 | `GisViewPointCopyPayload \| null`:取消载荷;没有预览时为 `null` |
@@ -292,6 +292,7 @@ const pointer = view.getPointer()
292
292
  // pick(x, y) / pickAt(clientX, clientY):按画布像素 / 客户端坐标拾取
293
293
  const hit = view.pick(320, 200)
294
294
  const hitAt = view.pickAt(event.clientX, event.clientY)
295
+ hitAt?.groups // 命中通道回线:同一通道的全部分组;hitAt.group 是当前这一组
295
296
 
296
297
  // hoverAt(clientX, clientY) / clearHover() / getHover():程序化悬停 / 撤掉 / 读当前那一份
297
298
  view.hoverAt(event.clientX, event.clientY)
@@ -357,7 +358,7 @@ const state = view.getViewState()
357
358
  #### 选中、复制与合并
358
359
 
359
360
  ```js
360
- // selectFeatures(refs, options?):接口选中(与点击选中走同一份 state)
361
+ // selectFeatures(refs, options?):接口选中(与点击选中走同一份 state;实际变化时按 onSelect → onSelectChange 发,载荷带通道上下文)
361
362
  view.selectFeatures('a') // 选中一个
362
363
  view.selectFeatures(['a', 'b']) // 一批
363
364
  view.selectFeatures('a', { mode: 'add' }) // mode:replace(缺省)/ add / toggle / remove
@@ -367,6 +368,11 @@ view.selectFeatures({ type: 'group', channelId: 'c1', groupId: 'c1::2' })
367
368
  view.selectFeatures({ type: 'group', groupId: 'global-group' }) // groupId 全局唯一时可省略通道
368
369
  view.selectFeatures(null) // 清空(空数组同义)
369
370
 
371
+ // selectAt(clientX, clientY):每次命中发 onSelect,集合变化时再发 onSelectChange(不动指针、不派发 onClick);已知 id 直接用 selectFeatures
372
+ view.selectAt(event.clientX, event.clientY) // 节点 / 线路命中即选中;空白返回 null
373
+ view.updateOptions({ channelSelectMode: 'channel' })
374
+ view.selectAt(event.clientX, event.clientY) // 命中任一回线 = 选中整条通道
375
+
370
376
  // selectWaypoint(lineRef, pathIndex? / { lineIndex | lineId, pathIndex }, options?):选中拐点并带选线路
371
377
  view.selectWaypoint('l1', 2) // pathIndex 从 getWaypoints() 取
372
378
  view.selectWaypoint({ lineIndex: 0, pathIndex: 1 }, { mode: 'add' })
@@ -62,6 +62,7 @@ const stats = view.getStats() // clusterCount / clusterPointCount /
62
62
  - 视口里的节点遍历也先查点网格:`forEachPointInView` 在节点超过 `POINT_VIEW_GRID_MIN_POINTS`(2048)、点网格不脏且估计候选不足总数的 1/32(`POINT_VIEW_GRID_COST_SHARE`)时,只查视口覆盖的格子、把候选排回数据顺序再逐个精确判定;网格脏着(刚改过数据 / 拖拽中)或视口几乎铺满时仍走整份扫描,避免为了省一趟遍历先付出建网格和排序的成本。`createPointGrid.estimate(..., stopAfter)` 与查询共用同一套格子口径,传 `stopAfter` 时一旦超过阈值立即返回,只判断候选是否过密时不必扫完视口里的全部格子;框选线路则复用已有的 `forEachLineInView`,不再为每条候选线复制路径 / 包围盒 / 颜色快照。
63
63
  - 数据包围盒读数改为缓存:整份解析本来已经顺路算出点与线的范围,`getBounds()` 直接接住这一份;定向改点 / 改线、拖拽、追加要素与复制预览只把缓存标脏,下一次真被问到时才用点坐标和每条线的 `line.bounds` 重建,不再逐顶点重扫。`getBounds()` 每次返回独立副本,调用方就地修改不会污染下一次读数;追加到原范围之外、拖出边界这类增量也会立即失效。10 万点 + 10 万线(每条 10 顶点)实测连续 10 次 `getBounds()`:优化前 **5.2 ~ 6.6 ms / 次**,优化后 **0.00 ms / 次**;改一个点后缓存重建一次 **3.6 ms**,读数正确。
64
64
  - 悬停、选中、拖拽只做增量更新:选中集合用点 / 线两张下标 `Set` 维护,样式变化按要素下标重新解析,顶点与实例数据就地改写后按「实例槽位」上传,不重建整批缓冲。
65
+ - 选中事件的通道分组读数一次成型:同一条通道的 `groups` 只构造一份,`onSelect` / `onSelectChange` 与载荷顶层共享对象引用,不深拷贝;单通道不再额外生成 `channelContexts`,`items[]` 里的回线只保留 `channelId` / `channelIndex` / `groupId` / `groupIndex` 标量,不逐条重复挂 `group` / `groups`。单通道也不会先构造随后丢弃的 `lineIndexes` / `lineIds`,这两份只在多通道 `channelContexts` 里按选中顺序补一次。通道重建时顺手按 `channelIndex` 收好分组状态与组序号,后续悬停 / 选中查同通道分组和组号都是 O(1),不再每次筛整张通道表;构造一条通道的全部分组读数从 O(组数 × 通道数) 降为 O(组数)。`selectFeatures` 对同内容、同顺序的重设会在引擎刷新和 `describeSelection` 之前直接返回;事件本身只在选中操作 / 选中集变化时发生,不进入逐帧渲染路径。
65
66
  - 状态样式就地更新:悬停 / 选中样式只改颜色、大小、描边时按实例槽位覆盖,不重建整批;符号换成 / 换出贴图、阴影条数变化这类批次结构变化才回退整批重排。哪些要素当前真的按状态样式画过另有一份记账(`stateStyledSignatures`),整体重建后立刻重算,取消选中时按它精确还原本体样式,不会留下残留。
66
67
  - 动效只维护真正的动画线路:`lineEffectRuns` 让每帧的动点循环只遍历配了 `effect` 的线路,路径就地变化时只重算这一条的总长与包围盒;普通线路再多,也不会进入动画的逐帧扫描。
67
68
  - 热力图的要素采样与密度烘焙分开缓存:坐标 / 权重 / 显隐变化才重新采集,核参数 / 色带变化复用采样重烤,`density.opacity` 与 `layer` 只改绘制;拖动期间延迟到停手统一补烤。
@@ -16,11 +16,11 @@ npm run dev # 或 npx vite
16
16
  控制台里 `__gisSteps.view` 就是当前这一步的视图,`__gisSteps.DATA` 是那份园区数据,可以随手调 API。
17
17
  地址栏里带步骤号(`index.html#7`)可以直接打开某一步,也能收藏 / 分享。
18
18
 
19
- 自测页就在同一个目录下(`selftest.html` / `selftest-demo.html` / `selftest-demos.html` / `selftest-labels.html` / `selftest-select.html` / `selftest-actions.html` / `selftest-scale.html` / `selftest-perf.html` / `selftest-features.html` / `selftest-full.html` / `selftest-effects.html` / `selftest-data.html` / `selftest-decoration.html` / `selftest-docs.html`):打开就跑,标题报 `PASS` / `FAIL`,页面上的清单是逐条断言。**无头环境下 `requestAnimationFrame` 基本不推进**,动画类断言要用真时间(CDP 或直接开浏览器)跑 —— `selftest-effects.html` 因此自己装了一个「帧泵」(把 `requestAnimationFrame` 收进 Map、手动按帧推进),涟漪与动点的断言才测得动。另有 docs/index.html(文档中心:把仓库里的 markdown 读成 HTML)、docs/api/index.html(API 速查:264 个实例成员逐个一张卡,参数 / 返回 / 说明 / 示例与复制,搜索 + `#成员名` 深链、左侧分组跟随与深浅主题)、docs/guide/sample-document.html(sample-document.json 的交互图解)与 docs/steps/selftest-docs.html(上面这三个页面的自测:清单 / 渲染 / 点选标注一页跑完)。
19
+ 自测页就在同一个目录下(`selftest.html` / `selftest-demo.html` / `selftest-demos.html` / `selftest-labels.html` / `selftest-select.html` / `selftest-actions.html` / `selftest-scale.html` / `selftest-perf.html` / `selftest-features.html` / `selftest-full.html` / `selftest-effects.html` / `selftest-data.html` / `selftest-decoration.html` / `selftest-docs.html`):打开就跑,标题报 `PASS` / `FAIL`,页面上的清单是逐条断言。**无头环境下 `requestAnimationFrame` 基本不推进**,动画类断言要用真时间(CDP 或直接开浏览器)跑 —— `selftest-effects.html` 因此自己装了一个「帧泵」(把 `requestAnimationFrame` 收进 Map、手动按帧推进),涟漪与动点的断言才测得动。另有 docs/index.html(文档中心:把仓库里的 markdown 读成 HTML)、docs/api/index.html(API 速查:265 个实例成员逐个一张卡,参数 / 返回 / 说明 / 示例与复制,搜索 + `#成员名` 深链、左侧分组跟随与深浅主题)、docs/guide/sample-document.html(sample-document.json 的交互图解)与 docs/steps/selftest-docs.html(上面这三个页面的自测:清单 / 渲染 / 点选标注一页跑完)。
20
20
 
21
21
  ## 怎么查全部属性与方法
22
22
 
23
- 组件的完整 API 是同一份清单的五个去处(当前:实例成员 **264** 个 = 259 个方法 + `el` / `canvas` / `gl` / `cameraAnimating` / `dataVersion` 五个属性,`options` **96** 个键,数据字段 **250** 个):
23
+ 组件的完整 API 是同一份清单的五个去处(当前:实例成员 **265** 个 = 260 个方法 + `el` / `canvas` / `gl` / `cameraAnimating` / `dataVersion` 五个属性,`options` **96** 个键,数据字段 **250** 个):
24
24
 
25
25
  - **手册里查**:`docs/manual/` 把整本手册拆成 21 章(索引页 `docs/GisView.md` 有目录与按任务的快速跳转)—— 找方法先看 [`16 实例方法总表`](../manual/16-实例方法总表.md)、要把每个成员逐个列全(参数写「名字: 类型 = 默认值」、返回值只写类型)看 [`17 参数速查`](../manual/17-参数速查.md)、要能直接复制的调用看 [`18 逐方法示例`](../manual/18-逐方法示例.md);`options` 表与数据字段表分别在 [`03 配置项(options)`](../manual/03-配置项.md) 与 [`02 坐标与数据`](../manual/02-坐标与数据.md)。同名的接口在 `src/gis-view/types.d.ts`。
26
26
  - **页面上查**:`docs/api/index.html`(API 速查)—— 左栏按 16 个分组列出全部成员,带搜索(Ctrl+K)与方法 / 属性 / options 三档开关,当前读到的分组会跟着高亮;右栏每个成员一张卡,参数 / 返回 / 说明之外,示例块直接来自 [`18 逐方法示例`](../manual/18-逐方法示例.md),带高亮与「复制名字 / 复制代码」,旁边还有「在调试页跑 →」;地址栏带 `#updateLine` 这样的 hash 直接跳到那一条。右上角主题按钮与文档中心共用 `gisDocs.reading`,跟随系统 / 浅色 / 深色三档换页不会各记一套;深链落点还留了 `scroll-margin-top`,不会被吸顶栏盖住。页面数据来自 `docs/demos/api-index.js`(生成物,禁止手改),自测页 `docs/steps/selftest-docs.html` 盯着它别掉队。
@@ -115,12 +115,12 @@ API 名字与前面几步是一一对应的;第 11 步再补两件「数据之
115
115
  - `bench-scale.html`:百万级基准页(同样只打印读数)—— 点 / 线规模与视口走 URL 参数,`window.__bench.run(options)` 量生成数据、建场景与发散渲染、平移 / 缩放 / 放大 / 拾取、`refresh` / `updatePoint` / `updateOptions`、内存账与销毁回收;另有 `prepare`(只建场景)、`__bench.op(name)`(单步)与 `__bench.micro(count)`(原语微基准)。配 `scripts/bench-scale.mjs` 用(CDP 驱动,`--points=1000000 --gpu --port=93xx`,另有 `--profile` 抓 CPU profile、`--heap` 抓堆快照);跑全部自测页用 `scripts/run-selftests.mjs`(`node scripts/run-selftests.mjs [页面名…]`,无头 Chrome + 真时间),两个脚本都要先起 dev server。
116
116
  - `selftest-labels.html`(165 条):标签拖动与编辑、HTML 语义、高密度画布、导出分项多选的专项自测 —— 按住标签拖 90 px 之后标签真的挪了那么多、同时画布不平移,`labelDrag` 的 `start` / `move` / `end` 三态与 `offset` / `delta` 对得上,偏移进得了 `getLabelStates()`、再拖动节点时偏移照旧叠在节点位移上,`resetLabel` 之后回到默认锚点;`updateLabel` 改内容 / 字号 / 颜色(`style` 是增量合并,只改字号不动颜色)、`resetLabels` 一次清干净;点一下标签发 `labelClick` 并把这一条交给面板;线路标签的拖动与恢复默认同一条路;导出侧验「选什么导什么」(选场站只出场站、选通道自动带上拐点、不认识的分项记一条提示),`exportData({ scope: 'selection' })` 验场站 + 通道 / 只出线路 / 没选中时的 `ok: false` 三个分支。HTML 专项验全局 / 逐条 `true` / `false`、模板与 `field` / 默认名称转义、`updateLabel` 双向切换、线路标签富文本、点击载荷保留 `html` 与原标记、`getLabels()` 报运行时偏移、只改样式不替换 HTML 子节点、导出 → 回灌保持逐条语义,以及 120 条同内容标签首轮布局读取 ≤ 4 次的缓存断言。高密度专项用 1100 条纯文本验缺省 `auto` 只留一张画布、混入一条 `html: true` 仍保留那一条 DOM、`labelRender: 'dom'` 回退逐条 DOM 并清空画布、切回 `auto` 回收上一轮 div、SVG 导出包含画布标签与缩放只推一次;新增断言还验 1100 条同文案只调用 `measureText` ≤ 2 次、同一任务连续 5 次缩放只重绘 1 ~ 2 次、显式 `canvas` 在优化档遵守 `maxCount: 200`,以及 6 条线路标签在优化档全部进画布且 DOM 为 0。另验 GeoJSON 区域文字同一相机重复刷新 0 次 `style` 写入、只改字号复用同一 DOM 节点、离屏时改样式再回到视野仍能恢复显示、只改锚点位置也会重新投影,以及同一帧 12 次相机变化只做 1 次重投影。设备像素专项把 `devicePixelRatio` 临时设为 1.25,验标签落点与拖动期整层位移都落在物理像素网格上,并确认逐条标签不再各自声明 `will-change`。
117
117
  - `selftest-actions.html`:事件判读与只读接口的专项自测 —— 左右键、拖动、框选、拐点与标签的 `action` 三件套(type / target / data)、`button` / `modifiers` / `moved` / `double`、Shift 框选(含没框到东西的空框)与 Ctrl 取反在 `selectChange.action.type` 上的分辨、`pickAt` 的客户端坐标与只读性、`getFeaturesInRect`(含宽高为负的矩形)与 `getChannelsInView`、`addPoints` / `addPoint` / `addLines` / `removeLines` 的返回与 `dataChange` 类型;最后一段把第 12 步 `step-12-events.js` 挂起来跑一遍(判读读数、`probeAt` / `probeRect`、两条走廊)。页面底部流式打印 PASS / FAIL。
118
- - `selftest-select.html`:选择与框选的专项自测(69 条)—— 点选 / 悬停 / Ctrl 取反 / 多选开关的像素级状态样式与恢复,`select` / `deselect` / 通道写法 / 变更来源的接口断言,以及 SVG 状态贴图的预热与不同尺寸配方;这一轮补了 `selectionKinds: 'all'` 下 `selectInBox` 一次收下节点、独立线路与按 `channelSelectMode` 摊开的通道回线;本轮再补 SVG 导出 `currentStrokeColor` 的描边色还原,以及 `selectTrigger` 默认单击、双击选中、空白双击清空、运行时切回单击与双击 + `multiSelect` 的取反 / 累加 / 取消。
118
+ - `selftest-select.html`:选择与框选的专项自测(84 条)—— 点选 / 悬停 / Ctrl 取反 / 多选开关的像素级状态样式与恢复,`selectFeatures` / `selectAt` / `deselect` / 通道写法 / 变更来源的接口断言(含 selectFeatures 实际变化时发 onSelect + onSelectChange、重复设同一选中集不重复通知、selectAt 每次命中发 onSelect 且集合变化时再发 onSelectChange),以及 SVG 状态贴图的预热与不同尺寸配方;这一轮补了 `selectionKinds: 'all'` 下 `selectInBox` 一次收下节点、独立线路与按 `channelSelectMode` 摊开的通道回线、`selectAt` 的节点 / 线路 / 通道 / 空白四路、多分组通道在 pickAt / hoverAt / 鼠标事件里的 `group` / `groups`,以及 SVG 导出 `currentStrokeColor` 的描边色还原和 `selectTrigger` 的单击、双击、多选取反 / 累加 / 取消。本轮再加 4 条:`onSelect` / `onSelectChange` 的单通道顶层上下文与 `items[]` 标量归属、真实点击 `onClick → onSelect → onSelectChange` 顺序、`selectFeatures(整条通道)` 与 `selectAt` 的通道载荷和 `onSelect → onSelectChange` 顺序(其中 `channelIndex` 同时校验为通道声明下标);另补 1 条多通道选中断言,确认只给 `channelContexts`、不重复摊平,且两条事件共享同一批 `items`。
119
119
  - `selftest-demos.html`:演示外壳与 22 个页签(21 个功能 + 综合,按基础 / 交互 / 样式 / 数据 / 性能 / 综合六组排列)的自测(**347** 条)—— 逐个用 `?demo=<id>` 打开基础视图、标签交互、标签显示、鹰眼图配置、样式与规则、悬停与提示、通道与数据、编辑与保存、事件与方法、接口能力、批量复制与合并、拖拽落点、可视化连线、动态装饰器、批量新增、搜索与筛选、扩展能力、性能与质量、点聚合、动画效果、扩展图形与综合示例,画布与右栏读数都在、切页签会把 `?demo=` 写回地址栏、切走之后上一个实例被销毁(不留画布)、缺省页签是综合示例、页签总数与注册表一致、顶部六个分组按钮一次只展开一组且组名可见;鹰眼图配置页另验 `layers` 的自定义 `draw` 真被调用、`placement` 切到数据前 / 后时 `drawInfo.stage` 跟着变、框拖动触发 `onInteraction` 并带相机、尺寸切换走 `updateOptions` 重铺画布、DPR 2 下 `maxPixelRatio` 的 1× / 2× 备份尺寸、关掉扩展层与 `zoomMinimapBy` / `setMinimapZoom`,以及 `extentFollowCamera` 默认关闭 / 打开生效、缩放关闭时 `dragAction: 'box'` 仍可框选、`zoomable: 'follow'` 跟随主图且 `setMinimapZoom` 不抢控制权;批量复制 / 合并、通道拐点 / 分组两页另有假接口断言(Shift 框选、整份请求体、标准响应包与资源级结果、版本号 / opId、200 / 网络失败 / 500 / 409、原 opId 重试与幂等重放不推进版本、409 rebase、本地回滚);假后端严格契约另有一组直接断言(请求头与 body 不一致返回 400、baseVersion 非有限数字返回 400、同 opId 换 body 返回 409 `IDEMPOTENCY_CONFLICT` 且版本不前进),通道页还验了自定义格式按钮注册 → 导出 → 注销;扩展能力页另验自定义运算符 / 图元 / `view.use` 插件 / `defineExportFormat` / 实例命令的注册、执行、同名覆盖、卸载与业务 DTO 结构,并验同名重新注册图元当场换画笔;可视化连线页另验初始 `channels` 为空、正交折线拐点、预览箭头、取消不写数据,默认提交 / `addLines` / `addChannels` 三种落地,指定起点后一次点击即可 ready、两端示例点的显隐 / 尺寸 / 形状 / 分端颜色与 `below-points` 层级、`commitAddChannel` 直接传线路 / 通道对象(含对象 path / 颜色 / 线宽优先);动态装饰器页另验吸附方式只有「跟随 / 固定」、两档下位置都有「相对节点 / 固定位置 / 鼠标位置」三档,固定 + 相对节点只改 `anchor`,固定 / 跟随 + 鼠标位置分别映射为 `placement: pointer` + 对应 `anchor` 且不带 `at`,跟随 + 固定位置仍传 `at`;通道与数据页另验「线路分组」弹框:分组卡片与「场站对标标识」都在,行内「确认移入」只改本地草稿、组件数据不动,取消 / Esc / 遮罩关闭不写数据,「确定分组」只调用一次 `splitChannelGroups` 把整份 groups 一次落地。点聚合页另验配置控件、聚合点击只增聚合计数、`maxZoom` 展开与适应全部后恢复、半径 / 重建 / 关闭统计;接口能力页另验三种级联触发独立开关、通道 / 分组 / 拐点程序化选中、通道 `focusOn`、`lineFollowsNode` 联动,以及样式字段合并、节点 / 线路装饰 CRUD、通道线宽与线路对账、GeoJSON 背景投影、历史 / 复制 / 删除收尾、`once`、WebGL 层清理、注册表注销与初始化 options 读数;页面底部流式打印 PASS / FAIL。鹰眼图线路层另有 3 条断言:首帧只铺固定范围里的线路、同一份数据补画命中缓存、小图缩放只重铺一次且仍裁剪范围外线路。
120
120
  - `selftest-effects.html`:线路 / 节点动画与贴图预热的专项自测(96 条,页面自带「帧泵」—— 无头环境里 `requestAnimationFrame` 几乎不推进,它把 rAF 收进 Map、手动推帧,动画类断言才测得动):纯函数组(归一化、三层「关」的语义、按弧长采样、拐点连续、相位打散)、引擎组(铺批次 / 关掉一层 / 恢复 / 逐要素覆盖 / `getLineEffects` 全程贴线)、像素组(画布上真的有一个红点在两帧之间挪了位置)、**涟漪跟手组**(开着动画拖节点:拖动前圆心在节点坐标上、拖动中跟着走且是就地改实例缓冲、松手后整份重建也对齐、再拖回原位跟着回来,另有一条像素级断言量绿圈质心落在新坐标上)、**预热组**(全排列条数、第二次命中缓存、`getSymbolCombos()` 的去重与线路侧那一条、照着它配的一份**零张新画布**、反向一条「没画过的组合会当场烤、画布 +1」)、提醒组(`effects.line` 里写 `show` 被点名);这一轮另补 4 条动点缓存断言(5000 条普通线 + 2 条动点线:每帧只遍历动画线路,路径换短 / 换长后总长跟着更新)。
121
121
  - `selftest-data.html`:标准数据文档与渲染后端开关的自测(103 条)—— `normalizeDocument` 的标准写法与 JSON 安全(无效日期写 `null` 并告警、类实例不静默变成空对象)、整份文档直接当 `createGisView` 入参、`exportData({ format: 'full' })` 导出 → 重建 → 再导出逐字节相同、`importData` 的整份与分列、`renderer` 三档与能力探测、网格顶点缓存窗口,以及自定义导出格式的注册 / 上下文 DTO 映射 / 深拷贝 / 视口导出 / 注销后失效;断言全在接口层,不读像素。
122
122
  - `check-sample-fields.mjs`:不是页面、是对账脚本(`node docs/steps/check-sample-fields.mjs`)—— 核对 `sample-document.json` 自己就是标准写法(`normalizeDocument` 后逐字节相同、0 条提醒)、`docs/sample-document字段详解.md` 里的读数与 `documentCounts()` 一致、以及它的「字段索引」与从文件里摘出来的字段路径集合一个不多一个不少(当前 154 个);改完样本数据跑 `--write` 重写那两段机器块(幂等)。
123
- - `selftest-docs.html`:三个文档页 + 调试页的自测(134 条)—— 文档中心(`docs/index.html`)的清单 92 份 md(10 个根 + 82 份子文档,含新的 `接口协议.md`;树形侧栏按 `parent` / `groupLabel` 分成 6 组 `details`)、首屏 README 渲染、markdown 渲染器(表格 / 带语言代码块 / 两级列表 / 引用 / 行内代码 / md 链接 / 目录 / 首条摘要卡片 / 首段导语)、元信息里的阅读时长、索引页的子文档链接、换一份子文档、代码块复制按钮、跨文档搜索与深链、跨文档锚点链接(`文件.md#小节` 原地切文档并闪一下)、能直接跑的围栏才有的「在调试页跑」与元信息行的「这一份的调试页(N 段示例)」、390px 窄屏首屏 / 深链 / 横向溢出回归、阅读百分比与「回到顶部」的联动;调试演示页(`docs/playground/index.html`)的一个示例下拉(内置示例 + 「这一份文档」两组 optgroup)、`?doc=` 读文档抽示例、片段标注与来源子栏、顶层 await 运行、`channels` 快照、光标行列、草稿自动保存与恢复、`Ctrl+S` 运行、复制 / 清空输出、状态胶囊;API 速查页(`docs/api/index.html`)的 `__gisApi` 钩子、六张统计卡、264 张成员卡都带签名网格与示例、96 个 options 四列网格、搜索过滤与命中读数、`select(名字)` 高亮、类型开关能单独收起属性、左侧当前分组高亮与即时跳转、顶栏文档深链、每个分组的手册章节链接、深浅主题切换与阅读设置共用;图解页(`docs/guide/sample-document.html`)的 47 条数据列表、点线路 / 分组 / 标签状态的图上标注、`点击时居中` 与 `显示编号` 开关、清空标注;三个页面都跑在 iframe 里,要用 dev server 打开。
123
+ - `selftest-docs.html`:三个文档页 + 调试页的自测(134 条)—— 文档中心(`docs/index.html`)的清单 92 份 md(10 个根 + 82 份子文档,含新的 `接口协议.md`;树形侧栏按 `parent` / `groupLabel` 分成 6 组 `details`)、首屏 README 渲染、markdown 渲染器(表格 / 带语言代码块 / 两级列表 / 引用 / 行内代码 / md 链接 / 目录 / 首条摘要卡片 / 首段导语)、元信息里的阅读时长、索引页的子文档链接、换一份子文档、代码块复制按钮、跨文档搜索与深链、跨文档锚点链接(`文件.md#小节` 原地切文档并闪一下)、能直接跑的围栏才有的「在调试页跑」与元信息行的「这一份的调试页(N 段示例)」、390px 窄屏首屏 / 深链 / 横向溢出回归、阅读百分比与「回到顶部」的联动;调试演示页(`docs/playground/index.html`)的一个示例下拉(内置示例 + 「这一份文档」两组 optgroup)、`?doc=` 读文档抽示例、片段标注与来源子栏、顶层 await 运行、`channels` 快照、光标行列、草稿自动保存与恢复、`Ctrl+S` 运行、复制 / 清空输出、状态胶囊;API 速查页(`docs/api/index.html`)的 `__gisApi` 钩子、六张统计卡、265 张成员卡都带签名网格与示例、96 个 options 四列网格、搜索过滤与命中读数、`select(名字)` 高亮、类型开关能单独收起属性、左侧当前分组高亮与即时跳转、顶栏文档深链、每个分组的手册章节链接、深浅主题切换与阅读设置共用;图解页(`docs/guide/sample-document.html`)的 47 条数据列表、点线路 / 分组 / 标签状态的图上标注、`点击时居中` 与 `显示编号` 开关、清空标注;三个页面都跑在 iframe 里,要用 dev server 打开。
124
124
 
125
125
  上一轮补上的两组断言:`selftest-features.html` 新增 8 条 —— 3 条验 `channelSelectMode` 在 Ctrl + 点击加点后的高亮范围(`line` / `group` / `channel`),5 条验分组 `channelWaypointDragMode: 'vertex'`(`{ x, y }` 偏移归一化并参与实际路由、同组同序号拐点按世界增量平移、相邻拐点与其它分组不动、只写 `waypointOffsets` 不改公共 `path`、`parallel_groups.group_route` 带刚性偏移)。`selftest-demos.html` 新增 1 条,验通道演示页可切到 `vertex`,且「准备两分组拐点」会拆成两组、每组两个拐点。
126
126
 
@@ -2493,8 +2493,8 @@
2493
2493
  await wait(150)
2494
2494
  const apiItems = () => [...document.querySelectorAll('.demo-api-list:not(.demo-api-list--options) .demo-api-item')]
2495
2495
  const optionItems = () => [...document.querySelectorAll('.demo-api-list--options .demo-api-item')]
2496
- check('实例方法速查:整份成员清单按分组列全(16 组 / 264 个成员)',
2497
- Boolean(ready) && document.querySelectorAll('.demo-api-group').length === 16 && apiItems().length === 264,
2496
+ check('实例方法速查:整份成员清单按分组列全(16 组 / 265 个成员)',
2497
+ Boolean(ready) && document.querySelectorAll('.demo-api-group').length === 16 && apiItems().length === 265,
2498
2498
  `${document.querySelectorAll('.demo-api-group').length} 组 / ${apiItems().length} 个成员`)
2499
2499
  const apiNames = apiItems().map(node => node.querySelector('b')?.textContent)
2500
2500
  const roundNames = ['registerSymbol', 'unregisterSymbol', 'listSymbols']
@@ -520,8 +520,8 @@
520
520
  if (check('API 速查:挂上了 __gisApi 钩子(数据来自 api-index.js,没钩子多半是 file:// 打开的)', Boolean(apiHook))) {
521
521
  const apiDoc = apiFrame.contentDocument
522
522
  const summary = apiHook.summary
523
- check('API 速查:清单来自 api-index(264 个成员 / 259 个方法 / 5 个属性 / 96 个 options / 16 个分组)',
524
- summary.members === 264 && summary.methods === 259 && summary.properties === 5 && summary.options === 96 && summary.sections === 16,
523
+ check('API 速查:清单来自 api-index(265 个成员 / 260 个方法 / 5 个属性 / 96 个 options / 16 个分组)',
524
+ summary.members === 265 && summary.methods === 260 && summary.properties === 5 && summary.options === 96 && summary.sections === 16,
525
525
  JSON.stringify(summary))
526
526
  const topJumps = [...apiDoc.querySelectorAll('.doc-jump')]
527
527
  const jumpTargets = topJumps.map(node => `${node.dataset.docJump}:${node.getAttribute('href')}`)
@@ -559,7 +559,7 @@
559
559
  `${apiDoc.querySelectorAll('.api-member').length} 张卡片 / 属性 ${apiDoc.querySelectorAll('.api-member[data-kind="property"]').length} 张`)
560
560
  const noExample = [...apiDoc.querySelectorAll('.api-member')].filter(node => Number(node.dataset.examples) < 1)
561
561
  check('API 速查:每个成员都带至少一段示例(不出现空卡片)', noExample.length === 0,
562
- noExample.length ? noExample.map(node => node.dataset.name).join(' / ') : '264 张卡片都有示例')
562
+ noExample.length ? noExample.map(node => node.dataset.name).join(' / ') : '265 张卡片都有示例')
563
563
  check('API 速查:96 个 options 都列全(名字 / 类型 / 默认值 / 说明四件套)',
564
564
  apiDoc.querySelectorAll('.api-option').length === summary.options && [...apiDoc.querySelectorAll('.api-option')].every(node => node.querySelector('.name code') && node.querySelector('.type') && node.querySelector('.def') && node.querySelector('.doc')),
565
565
  `${apiDoc.querySelectorAll('.api-option').length} 个`)
@@ -3880,7 +3880,7 @@
3880
3880
  rdHost.remove()
3881
3881
  }
3882
3882
  {
3883
- // 接口选中(select / selectFeatures)、读回选中(getSelectionData)与聚焦(focusOn:单个 / 一批 / 只居中)
3883
+ // 接口选中(selectFeatures)、读回选中(getSelectionData)与聚焦(focusOn:单个 / 一批 / 只居中)
3884
3884
  const apiHost = document.createElement('div')
3885
3885
  apiHost.style.cssText = 'width:520px;height:340px'
3886
3886
  document.body.appendChild(apiHost)
@@ -326,6 +326,8 @@
326
326
  dzHost.style.cssText = 'width:320px;height:200px'
327
327
  document.body.append(dzHost)
328
328
  const dzChanges = []
329
+ const dzSelects = []
330
+ const dzClicks = []
329
331
  const dzView = createGisView(dzHost, {
330
332
  points: [
331
333
  { id: 'dz-a', x: 0, y: 0, size: 12 },
@@ -339,13 +341,25 @@
339
341
  channels: [{ id: 'dz-ch', from: 'dz-a', to: 'dz-b', lineIds: ['dz-l1', 'dz-l2'] }],
340
342
  showGrid: false,
341
343
  initialView: { center: [60, 0], scale: 1 },
342
- onSelectChange: payload => dzChanges.push(payload)
344
+ onSelectChange: payload => dzChanges.push(payload),
345
+ onSelect: payload => dzSelects.push(payload),
346
+ onClick: () => dzClicks.push(1)
343
347
  })
344
348
  await settle(120)
345
349
  const dzIds = () => dzView.getSelections().map(entry => String(entry.item?.id ?? entry.index)).join(',')
346
350
  dzChanges.length = 0
351
+ dzSelects.length = 0
347
352
  dzView.selectFeatures(['dz-a', 'dz-l1', 'dz-solo'])
348
353
  check('取消选中基线:一个节点 + 两条线路都在选中集里(接口选中补一条 cause: api 的变更)', dzView.getSelectionData().count === 3 && dzView.isSelected('dz-a') && dzView.isSelected('dz-solo') && dzChanges.at(-1)?.cause === 'api', dzIds())
354
+ check('selectFeatures:改同一份选中集,并同时发 onSelect 与 onSelectChange(cause: api)',
355
+ dzSelects.length === 1 && dzSelects[0]?.items?.length === 3 && dzChanges.at(-1)?.cause === 'api' && dzChanges.at(-1)?.count === 3,
356
+ `onSelect ${dzSelects.length} / onSelectChange ${dzChanges.length}`)
357
+ const dzSelectsBeforeRepeat = dzSelects.length
358
+ const dzChangesBeforeRepeat = dzChanges.length
359
+ dzView.selectFeatures(['dz-a', 'dz-l1', 'dz-solo'])
360
+ check('selectFeatures:原样重设同一份选中集只同步读数,不重复发 onSelect / onSelectChange',
361
+ dzSelects.length === dzSelectsBeforeRepeat && dzChanges.length === dzChangesBeforeRepeat,
362
+ `onSelect +${dzSelects.length - dzSelectsBeforeRepeat} / onSelectChange +${dzChanges.length - dzChangesBeforeRepeat}`)
349
363
  dzView.deselect('dz-a')
350
364
  check('deselect(节点 id):只取消这一个,其余选中原样留着', dzView.getSelectionData().count === 2 && !dzView.isSelected('dz-a') && dzView.isSelected('dz-l1') && dzView.isSelected('dz-solo'), dzIds())
351
365
  dzView.deselect(['dz-l1', { type: 'line', index: 2 }])
@@ -366,12 +380,165 @@
366
380
  check('deselect() 不传目标 = 取消全部(= selectFeatures(null))', dzView.getSelectionData().count === 0 && dzChanges.length === 1 && dzChanges[0].action?.type === 'clearSelection' && dzChanges[0].cause === 'api', `${dzIds()} / ${dzChanges.length} 条变更 / ${dzChanges[0]?.action?.type}`)
367
381
  const dzChained = dzView.selectFeatures('dz-a')
368
382
  check('selectFeatures(null) 也清空,接口都返回实例本身(可以接着链式调)', dzChained === dzView && dzView.selectFeatures(null) === dzView && dzView.getSelectionData().count === 0, dzIds())
383
+ dzView.updateOptions({ selectTrigger: 'dblClick', channelSelectMode: 'channel' })
384
+ dzChanges.length = 0
385
+ dzSelects.length = 0
386
+ dzClicks.length = 0
387
+ const dzMidOf = (id) => {
388
+ const path = dzView.getLine(id)?.path ?? []
389
+ if (!path.length) {
390
+ return null
391
+ }
392
+ const from = path[0]
393
+ const to = path[path.length - 1]
394
+ return [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]
395
+ }
396
+ const dzNodeClient = dzView.worldToClient(0, 0)
397
+ const dzNodeSelected = dzView.selectAt(dzNodeClient[0], dzNodeClient[1])
398
+ check('selectAt(节点坐标):像普通单击一样立即选中节点,不受 selectTrigger: dblClick 等待',
399
+ dzView.getSelectionData().count === 1 && dzView.isSelected('dz-a') && dzNodeSelected?.items?.[0]?.type === 'point' && dzChanges.at(-1)?.cause === 'api',
400
+ `${dzIds()} / ${dzNodeSelected?.items?.[0]?.type}`)
401
+ check('selectAt:作为模拟单击会同时发 onSelect 与 onSelectChange(cause: api)',
402
+ dzSelects.length === 1 && dzSelects[0]?.items?.[0]?.type === 'point' && dzChanges.at(-1)?.cause === 'api',
403
+ `onSelect ${dzSelects.length} / onSelectChange ${dzChanges.length}`)
404
+ const dzSoloMid = dzMidOf('dz-solo')
405
+ const dzSoloClient = dzView.worldToClient(dzSoloMid[0], dzSoloMid[1])
406
+ const dzSoloSelected = dzView.selectAt(dzSoloClient[0], dzSoloClient[1])
407
+ check('selectAt(独立线路坐标):替换选中集并选中这一条线路',
408
+ dzView.getSelectionData().count === 1 && dzView.isSelected('dz-solo') && dzSoloSelected?.items?.[0]?.type === 'line',
409
+ dzIds())
410
+ const dzChannelMid = dzMidOf('dz-l1')
411
+ const dzChannelClient = dzView.worldToClient(dzChannelMid[0], dzChannelMid[1])
412
+ const dzChannelSelected = dzView.selectAt(dzChannelClient[0], dzChannelClient[1])
413
+ check('selectAt(通道回线坐标):按 channelSelectMode: channel 摊开整条通道',
414
+ dzView.getSelectionData().count === 2 && dzView.getSelectionData().channelCount === 1 && dzView.isSelected('dz-l1') && dzView.isSelected('dz-l2') && dzChannelSelected?.items?.length === 2,
415
+ `${dzIds()} / ${dzChannelSelected?.items?.length}`)
416
+ const dzBeforeMiss = dzIds()
417
+ const dzChangesBeforeMiss = dzChanges.length
418
+ const dzSelectsBeforeMiss = dzSelects.length
419
+ const dzMiss = dzView.selectAt(-1000, -1000)
420
+ check('selectAt(空白 / 画布外):返回 null,不清空原选中、不发选中变更、也不补 onClick',
421
+ dzMiss === null && dzIds() === dzBeforeMiss && dzChanges.length === dzChangesBeforeMiss && dzSelects.length === dzSelectsBeforeMiss && dzClicks.length === 0,
422
+ `${dzBeforeMiss} -> ${dzIds()} / ${dzChanges.length} 条变更 / onSelect ${dzSelects.length} / onClick ${dzClicks.length}`)
369
423
  check('重复入口已收口:select / updatePoints / updateLines / updateChannels 都不在实例上',
370
424
  dzView.select === undefined && dzView.updatePoints === undefined && dzView.updateLines === undefined && dzView.updateChannels === undefined,
371
425
  JSON.stringify({ select: typeof dzView.select, updatePoints: typeof dzView.updatePoints, updateLines: typeof dzView.updateLines, updateChannels: typeof dzView.updateChannels }))
372
426
  dzView.destroy()
373
427
  dzHost.remove()
374
428
 
429
+ /* ---------- 指针载荷里的通道分组表:group 是命中的这一组,groups 是整条通道 ---------- */
430
+ const gpHost = document.createElement('div')
431
+ gpHost.style.cssText = 'width:420px;height:260px'
432
+ document.body.append(gpHost)
433
+ const gpDowns = []
434
+ const gpUps = []
435
+ const gpClicks = []
436
+ const gpHovers = []
437
+ const gpSelects = []
438
+ const gpChanges = []
439
+ const gpOrder = []
440
+ const gpView = createGisView(gpHost, {
441
+ points: [
442
+ { id: 'gp-a', x: 0, y: 0, size: 16 },
443
+ { id: 'gp-b', x: 180, y: 0, size: 16 },
444
+ { id: 'gp-c', x: 0, y: 80, size: 16 },
445
+ { id: 'gp-d', x: 180, y: 80, size: 16 }
446
+ ],
447
+ lines: [
448
+ { id: 'gp-l1', from: 'gp-a', to: 'gp-b' },
449
+ { id: 'gp-l2', from: 'gp-a', to: 'gp-b' },
450
+ { id: 'gp-l3', from: 'gp-a', to: 'gp-b' },
451
+ { id: 'gp-l4', from: 'gp-c', to: 'gp-d' }
452
+ ],
453
+ channels: [
454
+ {
455
+ id: 'gp-ch',
456
+ from: 'gp-a',
457
+ to: 'gp-b',
458
+ lineIds: ['gp-l1', 'gp-l2', 'gp-l3'],
459
+ groups: [
460
+ { id: 'gp-g1', lineIds: ['gp-l1'] },
461
+ { id: 'gp-g2', lineIds: ['gp-l2', 'gp-l3'] }
462
+ ]
463
+ },
464
+ { id: 'gp-ch2', from: 'gp-c', to: 'gp-d', lineIds: ['gp-l4'] }
465
+ ],
466
+ features: 'edit',
467
+ showGrid: false,
468
+ initialView: { center: [90, 0], scale: 1 },
469
+ onPointerDown: payload => gpDowns.push(payload),
470
+ onPointerUp: payload => gpUps.push(payload),
471
+ onClick: payload => { gpClicks.push(payload); gpOrder.push('click') },
472
+ onSelect: payload => { gpSelects.push(payload); gpOrder.push('select') },
473
+ onSelectChange: payload => { gpChanges.push(payload); gpOrder.push('selectChange') },
474
+ onHover: payload => gpHovers.push(payload)
475
+ })
476
+ await settle(140)
477
+ const gpBox = gpView.canvas.getBoundingClientRect()
478
+ const gpLine = gpView.getLine('gp-l1')
479
+ const gpPath = Array.isArray(gpLine?.path) ? gpLine.path : []
480
+ const gpMid = gpPath.length > 1 ? [(gpPath[0][0] + gpPath[1][0]) / 2, (gpPath[0][1] + gpPath[1][1]) / 2] : [90, 0]
481
+ const gpScreen = gpView.project(gpMid[0], gpMid[1])
482
+ const gpClientX = gpBox.left + gpScreen[0]
483
+ const gpClientY = gpBox.top + gpScreen[1]
484
+ const gpPick = gpView.pickAt(gpClientX, gpClientY)
485
+ check('pickAt(通道回线):group 是命中的这一组,groups 带同一通道的全部组(按声明顺序)',
486
+ gpPick?.groups?.length === 2 && gpPick.groups.map(group => String(group.id)).join(',') === 'gp-g1,gp-g2' && String(gpPick.group?.id) === String(gpPick.groupId) && gpPick.groupIndex === gpPick.group?.groupIndex,
487
+ JSON.stringify({ group: gpPick?.groupId, groupIndex: gpPick?.groupIndex, groups: gpPick?.groups?.map(group => group.id) }))
488
+ const gpHover = gpView.hoverAt(gpClientX, gpClientY)
489
+ check('hoverAt / onHover(通道回线):载荷带完整 groups,当前 group 与 groupIndex 指向命中组',
490
+ gpHover?.groups?.length === 2 && gpHovers.at(-1)?.groups?.length === 2 && String(gpHover.group?.id) === String(gpPick?.groupId),
491
+ JSON.stringify({ hover: gpHover?.groups?.length, callback: gpHovers.at(-1)?.groups?.length }))
492
+ const gpCommon = { bubbles: true, cancelable: true, pointerId: 91, pointerType: 'mouse', button: 0, buttons: 1, clientX: gpClientX, clientY: gpClientY }
493
+ gpView.canvas.dispatchEvent(new PointerEvent('pointerdown', gpCommon))
494
+ gpView.canvas.dispatchEvent(new PointerEvent('pointerup', { ...gpCommon, buttons: 0 }))
495
+ gpView.canvas.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, clientX: gpClientX, clientY: gpClientY }))
496
+ await settle(120)
497
+ check('onPointerDown / onPointerUp / onClick(通道回线):每个鼠标载荷都带 groups 与当前 group',
498
+ [gpDowns.at(-1), gpUps.at(-1), gpClicks.at(-1)].every(payload => payload?.groups?.length === 2 && String(payload.group?.id) === String(payload.groupId) && payload.action?.target === 'channel'),
499
+ JSON.stringify({ down: gpDowns.at(-1)?.groups?.length, up: gpUps.at(-1)?.groups?.length, click: gpClicks.at(-1)?.groups?.length }))
500
+ const gpChannelPayloadOK = payload => String(payload?.channelId) === 'gp-ch' &&
501
+ payload?.groups?.length === 2 &&
502
+ payload?.channelContexts === undefined &&
503
+ String(payload.group?.id) === String(payload.groupId) &&
504
+ payload.items?.every(item => item.type !== 'line' || (item.channelId === payload.channelId && item.channelIndex === payload.channelIndex && item.groupId !== undefined && item.groupIndex !== undefined && item.group === undefined && item.groups === undefined))
505
+ check('onSelect / onSelectChange(通道回线):单通道只摊平在顶层,items 只留标量归属且不重复挂 group / groups',
506
+ gpChannelPayloadOK(gpSelects.at(-1)) && gpChannelPayloadOK(gpChanges.at(-1)) && gpSelects.at(-1)?.items === gpChanges.at(-1)?.items,
507
+ JSON.stringify({ select: gpSelects.at(-1)?.channelId, change: gpChanges.at(-1)?.channelId, groups: gpSelects.at(-1)?.groups?.length, contexts: gpSelects.at(-1)?.channelContexts, items: gpSelects.at(-1)?.items?.map(item => ({ channelId: item.channelId, channelIndex: item.channelIndex, groupId: item.groupId, groupIndex: item.groupIndex, group: item.group, groups: item.groups })) }))
508
+ check('真实点击的派发顺序:onClick → onSelect → onSelectChange',
509
+ gpOrder.slice(-3).join('>') === 'click>select>selectChange',
510
+ gpOrder.join('>'))
511
+ gpOrder.length = 0
512
+ gpView.selectFeatures(null)
513
+ gpOrder.length = 0
514
+ gpView.selectFeatures({ type: 'channel', channelId: 'gp-ch' })
515
+ check('selectFeatures(整条通道):onSelect / onSelectChange 都带通道上下文,顺序是 onSelect → onSelectChange',
516
+ gpOrder.join('>') === 'select>selectChange' && gpChannelPayloadOK(gpSelects.at(-1)) && gpChannelPayloadOK(gpChanges.at(-1)) && gpSelects.at(-1)?.items?.length === 3,
517
+ JSON.stringify({ order: gpOrder, select: gpSelects.at(-1), change: gpChanges.at(-1) }))
518
+ gpOrder.length = 0
519
+ gpView.selectFeatures([
520
+ { type: 'channel', channelId: 'gp-ch' },
521
+ { type: 'channel', channelId: 'gp-ch2' }
522
+ ])
523
+ check('多通道选中:顶层不摊平,只给 channelContexts;每项分组完整且两条事件共享 items',
524
+ gpOrder.join('>') === 'select>selectChange' &&
525
+ gpSelects.at(-1)?.channelId === undefined && gpSelects.at(-1)?.group === undefined && gpSelects.at(-1)?.groups === undefined &&
526
+ gpSelects.at(-1)?.channelContexts?.length === 2 &&
527
+ gpSelects.at(-1).channelContexts.map(context => String(context.channelId)).join(',') === 'gp-ch,gp-ch2' &&
528
+ gpSelects.at(-1).channelContexts.map(context => context.groups.length).join(',') === '2,1' &&
529
+ gpSelects.at(-1).channelContexts.map(context => `${context.lineIndexes.length}/${context.lineIds.length}`).join(',') === '3/3,1/1' &&
530
+ gpSelects.at(-1).channelContexts.map(context => context.lineIds.join('+')).join(',') === 'gp-l1+gp-l2+gp-l3,gp-l4' &&
531
+ gpSelects.at(-1).items === gpChanges.at(-1)?.items &&
532
+ gpSelects.at(-1).items.every(item => item.type !== 'line' || (item.group === undefined && item.groups === undefined)),
533
+ JSON.stringify({ order: gpOrder, contexts: gpSelects.at(-1)?.channelContexts?.map(context => ({ channelId: context.channelId, lines: context.lineIds })), items: gpSelects.at(-1)?.items?.length }))
534
+ gpOrder.length = 0
535
+ const gpAt = gpView.selectAt(gpClientX, gpClientY)
536
+ check('selectAt:返回载荷和两条选中回调都带当前点击组的完整 groups,且不补 onClick',
537
+ gpChannelPayloadOK(gpAt) && gpChannelPayloadOK(gpSelects.at(-1)) && gpChannelPayloadOK(gpChanges.at(-1)) && gpOrder.join('>') === 'select>selectChange',
538
+ `${gpOrder.join('>')} / ${gpAt?.groupId} / groups ${gpAt?.groups?.length}`)
539
+ gpView.destroy()
540
+ gpHost.remove()
541
+
375
542
  /* ---------- 问题 3:SVG 贴图符号第一次选中 / 悬停不该「闪一下」 ---------- */
376
543
  // 贴图(SVG / 图片)符号的配色是烘在贴图里的:状态样式第一次套上去等于一份新的烘焙配方。
377
544
  // 修好之前,新贴图烤出来之前这个节点整个不画(屏幕上就是「闪一下」);修好之后要么命中