gis-view-webgl2 0.1.7 → 0.1.9

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 (70) hide show
  1. package/README.md +14 -11
  2. package/dist/gis-view.css +1 -1
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.d.ts +445 -241
  5. package/dist/index.js +1 -1
  6. package/docs/AI/345/257/271/350/257/235/346/261/207/346/200/273.md +65 -498
  7. package/docs/GisView.md +1 -1
  8. package/docs/GisView/345/256/236/347/216/260/345/216/237/347/220/206.md +1 -1
  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 +114 -478
  10. package/docs/demos/BasicDemo.vue +144 -27
  11. package/docs/demos/BatchAddDemo.vue +133 -7
  12. package/docs/demos/ChannelDemo.vue +61 -0
  13. package/docs/demos/ConnectDemo.vue +164 -33
  14. package/docs/demos/CopyMergeDemo.vue +144 -42
  15. package/docs/demos/EventDemo.vue +29 -24
  16. package/docs/demos/LabelDemo.vue +5 -5
  17. package/docs/demos/api-index.js +53 -41
  18. package/docs/demos/gis-view-demo/GisViewDemoCardHeader.vue +1 -1
  19. package/docs/demos/gis-view-demo/GisViewDemoStage.vue +1 -1
  20. package/docs/demos/gis-view-demo/panels/DataPanels.vue +1 -1
  21. package/docs/demos/gis-view-demo/useGisViewDemo.js +31 -33
  22. package/docs/format/02-/350/212/202/347/202/271/344/270/216/347/272/277/350/267/257.md +1 -0
  23. package/docs/format/04-/351/200/232/351/201/223/344/270/216/346/240/207/347/255/276.md +3 -0
  24. 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 +8 -7
  25. package/docs/from0/05-/347/233/270/346/234/272/344/270/216/344/272/244/344/272/222.md +1 -1
  26. package/docs/from0/07-/351/200/232/351/201/223/345/210/206/347/273/204/344/270/216/345/244/232/345/233/236/347/272/277.md +26 -10
  27. package/docs/index.html +136 -13
  28. package/docs/internals/15-04-/351/200/220/350/275/256/350/256/260/345/275/225-/347/203/255/345/212/233/345/233/276/344/270/216/346/200/247/350/203/275.md +10 -0
  29. package/docs/manual/02-/345/235/220/346/240/207/344/270/216/346/225/260/346/215/256.md +1 -1
  30. package/docs/manual/03-/351/205/215/347/275/256/351/241/271.md +82 -14
  31. package/docs/manual/04-/344/272/213/344/273/266/344/270/216/345/233/236/350/260/203.md +23 -17
  32. package/docs/manual/05-/347/224/273/350/264/250/344/270/216/346/211/271/351/207/217/346/233/264/346/226/260.md +65 -0
  33. 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 +9 -10
  34. 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 +100 -40
  35. package/docs/manual/08-/351/200/211/344/270/255/344/270/216/346/213/220/347/202/271.md +11 -7
  36. package/docs/manual/09-/351/200/232/351/201/223/344/270/216/345/244/232/345/233/236/347/272/277.md +66 -6
  37. 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 +226 -493
  38. package/docs/manual/12-/346/240/207/347/255/276/344/270/216/350/243/205/351/245/260.md +28 -5
  39. package/docs/manual/14-/350/247/206/345/233/276/344/270/216/347/255/233/351/200/211.md +46 -14
  40. package/docs/manual/15-/345/257/274/345/207/272/344/270/216/351/233/206/346/210/220.md +5 -2
  41. package/docs/manual/16-/345/256/236/344/276/213/346/226/271/346/263/225/346/200/273/350/241/250.md +34 -29
  42. package/docs/manual/17-/345/217/202/346/225/260/351/200/237/346/237/245.md +30 -22
  43. package/docs/manual/18-/351/200/220/346/226/271/346/263/225/347/244/272/344/276/213.md +168 -81
  44. 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 +1 -1
  45. package/docs/manual/20-/346/200/247/350/203/275/344/270/216/345/244/247/346/225/260/346/215/256.md +5 -3
  46. 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 +9 -6
  47. package/docs/steps/README.md +201 -120
  48. package/docs/steps/bench-scale.html +1 -1
  49. package/docs/steps/index.html +222 -6
  50. package/docs/steps/selftest-actions.html +26 -25
  51. package/docs/steps/selftest-data.html +1 -1
  52. package/docs/steps/selftest-demos.html +290 -16
  53. package/docs/steps/selftest-docs.html +17 -6
  54. package/docs/steps/selftest-features.html +594 -54
  55. package/docs/steps/selftest-full.html +3 -3
  56. package/docs/steps/selftest-labels.html +82 -1
  57. package/docs/steps/selftest-perf.html +62 -7
  58. package/docs/steps/selftest-select.html +71 -6
  59. package/docs/steps/step-05-pick.js +4 -8
  60. package/docs/steps/step-07-channels.js +3 -5
  61. package/docs/steps/step-08-labels.js +3 -3
  62. package/docs/steps/step-10-full.js +3 -3
  63. package/docs/steps/step-12-events.js +41 -24
  64. package/docs//344/273/2160/345/210/260/345/256/214/346/225/264/347/232/204/345/256/236/347/216/260.md +1 -0
  65. package/package.json +1 -1
  66. package/docs/ai/01-/350/265/267/346/272/220/344/270/216/350/277/231/344/273/275/346/261/207/346/200/273/347/232/204/347/224/261/346/235/245.md +0 -29
  67. package/docs/ai/02-/346/214/211/345/206/205/345/256/271/345/210/206/347/261/273-/350/277/231/344/270/200/350/267/257/351/203/275/345/234/250/346/224/271/344/273/200/344/271/210.md +0 -66
  68. package/docs/ai/03-/351/207/215/350/246/201/351/227/256/347/255/224/351/200/211/357/274/210/344/270/200/357/274/211/357/274/232/351/200/211/345/236/213/351/200/232/351/201/223/344/270/216/346/240/267/345/274/217.md +0 -178
  69. package/docs/ai/04-/351/207/215/350/246/201/351/227/256/347/255/224/351/200/211/357/274/210/344/272/214/357/274/211/357/274/232/344/272/244/344/272/222/346/200/247/350/203/275/344/270/216/346/225/260/346/215/256.md +0 -140
  70. package/docs/ai/05-/351/207/215/350/246/201/351/227/256/347/255/224/351/200/211/357/274/210/344/270/211/357/274/211/357/274/232/345/256/277/344/270/273/346/227/201/346/224/257/344/270/216/346/224/266/345/217/243.md +0 -43
@@ -1056,8 +1056,8 @@
1056
1056
  ])
1057
1057
  const cmView2 = createGisView(cmHost2, {
1058
1058
  points: [
1059
- { id: 'a', x: 0, y: 0, name: '甲', vn_kv: 500, size: 30, symbol: 'circle' },
1060
- { id: 'b', x: 110, y: 0, name: '乙', size: 12, symbol: 'square' },
1059
+ { id: 'a', x: 0, y: 0, name: '甲', vn_kv: 500, size: 30, symbol: 'circle', source_only: '甲独有' },
1060
+ { id: 'b', x: 110, y: 0, name: '乙', vn_kv: 220, size: 12, symbol: 'square', target_only: '乙独有' },
1061
1061
  { id: 'c', x: 220, y: 0, name: '丙' }
1062
1062
  ],
1063
1063
  lines: cmChannelBuild.lines,
@@ -1118,8 +1118,9 @@
1118
1118
  check('确认之后发一条 confirm,载荷里是合并结果(尺寸取双方最大、sizeScale 归 1)', Boolean(cmConfirmEvent) && cmConfirmEvent.item?.size === 30 && cmConfirmEvent.item?.sizeScale === 1, JSON.stringify({ size: cmConfirmEvent?.item?.size, sizeScale: cmConfirmEvent?.item?.sizeScale }))
1119
1119
  check('合并结果的图标优先目标节点', cmConfirmEvent?.item?.symbol === 'square', JSON.stringify(cmConfirmEvent?.item?.symbol))
1120
1120
  check('确认合并:来源节点并进目标节点', cmMerged.points.length === 2 && cmMerged.points[0].id === 'b', JSON.stringify(cmMerged.points.map(point => point.id)))
1121
- check('确认合并:节点数据目标优先、来源独有的字段带过来', cmMerged.points[0].name === '乙' && cmMerged.points[0].vn_kv === 500, JSON.stringify(cmMerged.points[0]))
1121
+ check('确认合并:其它字段取保留节点、maxKeys 缺省不取电压最高', cmMerged.points[0].name === '乙' && cmMerged.points[0].vn_kv === 220 && cmMerged.points[0].target_only === '乙独有' && cmMerged.points[0].source_only === undefined, JSON.stringify(cmMerged.points[0]))
1122
1122
  check('确认合并:图标大小取双方最大、图标类型取目标', cmMerged.points[0].size === 30 && cmMerged.points[0].symbol === 'square', `${cmMerged.points[0].size}/${cmMerged.points[0].symbol}`)
1123
+ check('确认合并:保留节点带 mergeInfo,记录来源、保留前快照与受影响线 / 通道', cmMerged.points[0].mergeInfo?.version === 1 && JSON.stringify(cmMerged.points[0].mergeInfo.sourceIds) === '["a"]' && cmMerged.points[0].mergeInfo.targetPoint?.id === 'b' && cmMerged.points[0].mergeInfo.targetPoint?.vn_kv === 220 && cmMerged.points[0].mergeInfo.points?.length === 1 && cmMerged.points[0].mergeInfo.lines?.length === 2 && cmMerged.points[0].mergeInfo.channels?.length === 3, JSON.stringify({ sources: cmMerged.points[0].mergeInfo?.sourceIds, targetVoltage: cmMerged.points[0].mergeInfo?.targetPoint?.vn_kv, lines: cmMerged.points[0].mergeInfo?.lines?.length, channels: cmMerged.points[0].mergeInfo?.channels?.length }))
1123
1124
  check('确认合并:绑在来源上的线路改绑到目标', cmMerged.lines.length === 2 && cmMerged.lines[0].id === 'l1' && cmMerged.lines[0].from === 'b' && cmMerged.lines[0].to === 'c', JSON.stringify(cmMerged.lines))
1124
1125
  check('确认合并:自环线路被删掉、结果里带读数', cmMerged.lines.every(line => line.id !== 'l2') && cmMergeDone.phase === 'merge' && cmMergeDone.removedLines === 1, JSON.stringify(cmMergeDone))
1125
1126
  check('两端的通道撞成同一条:并成一条、默认各自保留分组', cmMergeDone.mergedChannels === 1 && cmMergeDone.channelsChanged === 2 && cmView2.getChannelGroups().length === 2, JSON.stringify({ merged: cmMergeDone.mergedChannels, changed: cmMergeDone.channelsChanged, groups: cmView2.getChannelGroups().length }))
@@ -1145,6 +1146,36 @@
1145
1146
  JSON.stringify({ changes: cmMergeChangeIds, surviving: [...cmMergeSurvivingChannelIds], affected: cmMergeAffectedChannelIds }))
1146
1147
  const cmApi = cmView2.mergePoints('c', 'b')
1147
1148
  check('mergePoints 接口也能合并(不弹确认框)', cmApi.ok === true && cmView2.getData().points.length === 1 && cmView2.getData().lines.length === 0, JSON.stringify(cmApi).slice(0, 120))
1149
+ const cmCumulative = cmView2.getData().points[0]
1150
+ check('连续合并到同一保留节点:mergeInfo 累加两个来源,maxKeys 缺省仍保留目标电压', JSON.stringify(cmCumulative.mergeInfo?.sourceIds) === '["a","c"]' && cmCumulative.mergeInfo?.points?.length === 2 && cmCumulative.vn_kv === 220, JSON.stringify({ sources: cmCumulative.mergeInfo?.sourceIds, voltage: cmCumulative.vn_kv }))
1151
+ cmView2.updateOptions({ history: true })
1152
+ cmView2.selectFeatures('b')
1153
+ const cmAutoSplit = cmView2.splitPoint('b')
1154
+ await settle(80)
1155
+ const cmRestoredData = cmView2.getData()
1156
+ const cmRestoredChannels = cmView2.getChannels()
1157
+ const cmRestoredA = cmRestoredData.points.find(point => point.id === 'a')
1158
+ const cmRestoredB = cmRestoredData.points.find(point => point.id === 'b')
1159
+ const cmRestoredC = cmRestoredData.points.find(point => point.id === 'c')
1160
+ check('splitPoint 省略 parts:按 mergeInfo 自动恢复全部来源节点与合并前字段', cmAutoSplit.ok === true && cmAutoSplit.autoRestored === true && cmRestoredData.points.length === 3 && Boolean(cmRestoredA) && Boolean(cmRestoredB) && Boolean(cmRestoredC) && cmRestoredA?.vn_kv === 500 && cmRestoredB?.vn_kv === 220 && cmRestoredB?.mergeInfo === undefined, JSON.stringify({ auto: cmAutoSplit.autoRestored, points: cmRestoredData.points.map(point => point.id), voltages: [cmRestoredA?.vn_kv, cmRestoredB?.vn_kv, cmRestoredC?.vn_kv] }))
1161
+ check('splitPoint 自动解除默认不选中拆分结果(只摘掉来源节点的选中态)', cmView2.getSelectionData().pointCount === 0 && cmView2.getSelections().length === 0, JSON.stringify(cmView2.getSelectionData()))
1162
+ check('splitPoint 自动解除:线路与通道端点、成员及回线全部恢复', cmRestoredData.lines.length === 3 && cmRestoredData.lines.find(line => line.id === 'l2')?.from === 'a' && cmRestoredData.lines.find(line => line.id === 'l2')?.to === 'b' && cmRestoredData.lines.find(line => line.id === 'l1')?.from === 'a' && cmRestoredData.lines.find(line => line.id === 'l3')?.to === 'b' && cmRestoredChannels.length === 3 && cmRestoredChannels.find(channel => channel.id === 'ch-ab')?.from === 'a' && cmRestoredChannels.find(channel => channel.id === 'ch-ac')?.from === 'a' && cmRestoredChannels.find(channel => channel.id === 'ch-bc')?.to === 'b', JSON.stringify({ lines: cmRestoredData.lines.map(line => `${line.id}:${line.from}->${line.to}`), channels: cmRestoredChannels.map(channel => `${channel.id}:${channel.from}->${channel.to}`) }))
1163
+ check('splitPoint 自动解除:返回 mergeSourceIds / 可提交 changes', JSON.stringify(cmAutoSplit.mergeSourceIds) === '["a","c"]' && cmAutoSplit.changes?.points?.length === 3 && cmAutoSplit.changes?.lines?.length === 3 && cmAutoSplit.changes?.channels?.length === 3, JSON.stringify({ sources: cmAutoSplit.mergeSourceIds, changes: { points: cmAutoSplit.changes?.points?.length, lines: cmAutoSplit.changes?.lines?.length, channels: cmAutoSplit.changes?.channels?.length } }))
1164
+ cmView2.undo()
1165
+ await settle(80)
1166
+ const cmUnmergeUndo = cmView2.getData()
1167
+ const cmUnmergeUndoChannels = cmView2.getChannels()
1168
+ cmView2.redo()
1169
+ await settle(80)
1170
+ const cmUnmergeRedo = cmView2.getData()
1171
+ const cmUnmergeRedoChannels = cmView2.getChannels()
1172
+ check('splitPoint 自动解除是一步历史:撤销回到合并态、重做恢复自动解除结果', cmUnmergeUndo.points.length === 1 && cmUnmergeUndo.points[0]?.id === 'b' && cmUnmergeUndo.lines.length === 0 && cmUnmergeUndoChannels.length === 0 && cmUnmergeRedo.points.length === 3 && cmUnmergeRedo.lines.length === 3 && cmUnmergeRedoChannels.length === 3, JSON.stringify({ undo: [cmUnmergeUndo.points.length, cmUnmergeUndo.lines.length, cmUnmergeUndoChannels.length], redo: [cmUnmergeRedo.points.length, cmUnmergeRedo.lines.length, cmUnmergeRedoChannels.length] }))
1173
+ cmView2.undo()
1174
+ await settle(80)
1175
+ const cmAutoSplitSelected = cmView2.splitPoint('b', null, { select: true })
1176
+ await settle(80)
1177
+ const cmAutoSelectedData = cmView2.getSelectionData()
1178
+ check('splitPoint 自动解除 select:true 时选中全部恢复节点', cmAutoSplitSelected.ok === true && cmAutoSplitSelected.autoRestored === true && cmAutoSelectedData.pointCount === 3 && cmAutoSplitSelected.pointIds.every(id => cmAutoSelectedData.points.some(point => String(point.id) === String(id))), JSON.stringify({ pointCount: cmAutoSelectedData.pointCount, ids: cmAutoSplitSelected.pointIds }))
1148
1179
 
1149
1180
  /* 移除节点:绑在它上面的线路与声明了它当端点的通道一起收拾 */
1150
1181
  const cmHost7 = document.createElement('div')
@@ -1168,6 +1199,107 @@
1168
1199
  cmView7.destroy()
1169
1200
  cmHost7.remove()
1170
1201
 
1202
+ /* merge.maxKeys:缺省不取最高;数组 / 逗号字符串都按列出的数值字段取双方最高 */
1203
+ const mkHost = document.createElement('div')
1204
+ mkHost.style.cssText = 'width:460px;height:300px'
1205
+ document.body.appendChild(mkHost)
1206
+ const mkView = createGisView(mkHost, {
1207
+ points: [
1208
+ { id: 'mk-a', x: 0, y: 0, name: '来源甲', vn_kv: 500, value: 9, loading: 0.2 },
1209
+ { id: 'mk-b', x: 80, y: 0, name: '目标乙', vn_kv: 220, value: 3, loading: 0.8 },
1210
+ { id: 'mk-c', x: 0, y: 80, name: '来源丙', vn_kv: 525, value: 12, loading: 0.1 },
1211
+ { id: 'mk-d', x: 80, y: 80, name: '目标丁', vn_kv: 1050, value: 4, loading: 0.9 }
1212
+ ],
1213
+ merge: { distance: 8, maxKeys: ['vn_kv', 'value'] },
1214
+ fitPadding: 60,
1215
+ showGrid: false
1216
+ })
1217
+ await settle(80)
1218
+ const mkFirst = mkView.mergePoints('mk-a', 'mk-b')
1219
+ const mkFirstItem = mkView.getData().points.find(point => point.id === 'mk-b')
1220
+ check('merge.maxKeys 数组:列出的 vn_kv / value 取双方最高,其余字段取目标', mkFirst.ok === true && mkFirstItem?.vn_kv === 500 && mkFirstItem?.value === 9 && mkFirstItem?.loading === 0.8 && mkFirstItem?.name === '目标乙', JSON.stringify(mkFirstItem))
1221
+ mkView.updateOptions({ merge: { enabled: true, maxKeys: 'vn_kv, value' } })
1222
+ const mkSecond = mkView.mergePoints('mk-c', 'mk-d')
1223
+ const mkSecondItem = mkView.getData().points.find(point => point.id === 'mk-d')
1224
+ check('merge.maxKeys 逗号字符串:同样只对列出的数值字段取最高', mkSecond.ok === true && mkSecondItem?.vn_kv === 1050 && mkSecondItem?.value === 12 && mkSecondItem?.loading === 0.9 && mkSecondItem?.name === '目标丁', JSON.stringify(mkSecondItem))
1225
+ mkView.destroy()
1226
+ mkHost.remove()
1227
+
1228
+ /* splitPoint.select:缺省不选中结果,也不清掉无关的线路选中;显式 true 才选中全部拆分结果 */
1229
+ const ssHost = document.createElement('div')
1230
+ ssHost.style.cssText = 'width:420px;height:260px'
1231
+ document.body.appendChild(ssHost)
1232
+ const ssView = createGisView(ssHost, {
1233
+ points: [{ id: 'ss-a', x: 0, y: 0, name: '拆分源' }, { id: 'ss-b', x: 80, y: 0 }],
1234
+ lines: [{ id: 'ss-line', from: 'ss-a', to: 'ss-b' }],
1235
+ fitPadding: 50,
1236
+ showGrid: false
1237
+ })
1238
+ await settle(80)
1239
+ ssView.selectFeatures(['ss-a', 'ss-line'])
1240
+ const ssDefault = ssView.splitPoint('ss-a', [
1241
+ { x: -1, y: 0, name: 'A' },
1242
+ { x: 1, y: 0, name: 'B' }
1243
+ ])
1244
+ const ssDefaultSelection = ssView.getSelectionData()
1245
+ check('splitPoint 缺省不选中拆分结果,其它无关选中保持', ssDefault.ok === true && ssDefaultSelection.pointCount === 0 && ssDefaultSelection.lineCount === 1, JSON.stringify(ssDefaultSelection))
1246
+ ssView.undo()
1247
+ await settle(60)
1248
+ ssView.selectFeatures('ss-a')
1249
+ const ssSelected = ssView.splitPoint('ss-a', [
1250
+ { x: -1, y: 0, name: 'A2' },
1251
+ { x: 1, y: 0, name: 'B2' }
1252
+ ], { select: true })
1253
+ const ssSelectedData = ssView.getSelectionData()
1254
+ const ssSelectedIndexes = [...ssSelectedData.pointIndexes].sort((left, right) => left - right)
1255
+ const ssResultIndexes = [...ssSelected.indexes].sort((left, right) => left - right)
1256
+ check('splitPoint({ select: true }) 选中全部拆分结果', ssSelected.ok === true && ssSelectedData.pointCount === 2 && JSON.stringify(ssSelectedIndexes) === JSON.stringify(ssResultIndexes) && ssSelectedData.lineCount === 0, JSON.stringify({ selected: ssSelectedIndexes, result: ssResultIndexes }))
1257
+ ssView.destroy()
1258
+ ssHost.remove()
1259
+
1260
+ /* 通道简略:只折叠严格超过阈值的分组;没有 groups 的通道按一整组算 */
1261
+ const csHost = document.createElement('div')
1262
+ csHost.style.cssText = 'width:520px;height:300px'
1263
+ document.body.appendChild(csHost)
1264
+ const csLineIds = Array.from({ length: 8 }, (_item, index) => `cs-l${index + 1}`)
1265
+ const csView = createGisView(csHost, {
1266
+ points: [{ id: 'cs-a', x: 0, y: 0 }, { id: 'cs-b', x: 180, y: 0 }],
1267
+ lines: csLineIds.map(id => ({ id, from: 'cs-a', to: 'cs-b' })),
1268
+ channels: [
1269
+ { id: 'cs-plain', from: 'cs-a', to: 'cs-b', lineIds: csLineIds.slice(0, 3) },
1270
+ {
1271
+ id: 'cs-grouped',
1272
+ from: 'cs-a',
1273
+ to: 'cs-b',
1274
+ groups: [
1275
+ { id: 'cs-g1', lineIds: csLineIds.slice(3, 6) },
1276
+ { id: 'cs-g2', lineIds: csLineIds.slice(6) }
1277
+ ]
1278
+ }
1279
+ ],
1280
+ channelSimplify: { enabled: true, threshold: 2, style: { color: '#ff3300', width: 5 } },
1281
+ fitPadding: 60,
1282
+ showGrid: false
1283
+ })
1284
+ await settle(120)
1285
+ const csStyles = csLineIds.map((_id, index) => csView.getLineStyle(index))
1286
+ const csGroups = csView.getChannelGroups()
1287
+ const csSnapshot = csView.getChannels()
1288
+ const csSnapshotLaneCounts = csSnapshot.map(group => new Set(group.paths.map(path => JSON.stringify(path))).size)
1289
+ const csRepresentative = csStyles[0]
1290
+ const csDataCount = csView.getData().lines.length
1291
+ check('通道简略:超过 threshold 的分组只画一条代表线,无 groups 的通道也按一组折叠', csStyles.filter(Boolean).length === 4 && csStyles[1] === null && csStyles[2] === null && csStyles[3] !== null && csStyles[4] === null && csStyles[5] === null && csStyles[6] !== null && csStyles[7] !== null, JSON.stringify(csStyles.map(style => style ? style.index : null)))
1292
+ check('通道简略:代表线套自定义颜色 / 线宽,原始数据与分组成员不变', csRepresentative?.width === 5 && Math.abs(csRepresentative.color[0] - 1) < 1e-6 && Math.abs(csRepresentative.color[1] - 0.2) < 1e-6 && csDataCount === 8 && csGroups.length === 3 && csGroups.map(group => group.lineIds.length).join(',') === '3,3,2', JSON.stringify({ style: csRepresentative, data: csDataCount, groups: csGroups.map(group => group.lineIds.length) }))
1293
+ check('通道简略:getChannels 快照为每条成员回线保留完整路径与各自车道',
1294
+ csSnapshot.length === 3 && csSnapshot.every((group, index) => group.paths.length === group.lineIndexes.length && group.paths.every(path => Array.isArray(path) && path.length >= 2) && csSnapshotLaneCounts[index] === group.paths.length),
1295
+ JSON.stringify(csSnapshot.map((group, index) => [group.lineIndexes.length, group.paths.length, csSnapshotLaneCounts[index], group.paths.map(path => path.length)])))
1296
+ csView.updateOptions({ channelSimplify: false })
1297
+ await settle(100)
1298
+ const csRestoredStyles = csLineIds.map((_id, index) => csView.getLineStyle(index))
1299
+ check('通道简略关闭后全部回线恢复,数据与分组读数仍不变', csRestoredStyles.every(Boolean) && csView.getData().lines.length === 8 && csView.getChannelGroups().length === 3, JSON.stringify(csRestoredStyles.map(style => style ? style.index : null)))
1300
+ csView.destroy()
1301
+ csHost.remove()
1302
+
1171
1303
  diag('复制 / 合并', `复制事件 ${cmEvents.length} 条,合并读数 linesChanged=${cmMergeDone.linesChanged} removedLines=${cmMergeDone.removedLines} removedChannels=${cmMergeDone.removedChannels} mergedChannels=${cmMergeDone.mergedChannels}`)
1172
1304
  cmView2.destroy()
1173
1305
  cmHost2.remove()
@@ -2264,7 +2396,7 @@
2264
2396
  check('拆分发 channelGroupChange(action: splitGroups 带新组表)', cgBus.length === 1 && cgSplit?.action?.type === 'splitGroups' && cgSplit.action.groupIds.length === 2 && cgSplit.channelId === 'ch-1' && cgSplit.groups.length === 2, JSON.stringify({ type: cgSplit?.action?.type, groupIds: cgSplit?.action?.groupIds }))
2265
2397
  check('onChannelGroupChange 回调和事件总线收到同一份载荷', cgOption.length === 1 && cgOption[0] === cgBus[0])
2266
2398
 
2267
- // 选中一条回线再合并:选中镜像里的 groupId / groupIndex 要跟着真值走(本轮修复的回归守卫)
2399
+ // 选中一条回线再合并:选中项不再挂通道字段,实时归属要跟着真值走(回归守卫)
2268
2400
  cgView.selectFeatures('c2')
2269
2401
  await settle(60)
2270
2402
  const cgTruth = () => {
@@ -2277,7 +2409,10 @@
2277
2409
  cgView.mergeChannelGroups('ch-1', [cgAfterSplit[1].id], cgAfterSplit[0].id)
2278
2410
  await settle(60)
2279
2411
  const cgMerged = cgTruth()
2280
- check('合并回线之后选中镜像的 groupId / groupIndex 一起刷新(本轮修复)', String(cgMirror()?.groupId) === cgMerged?.id && cgMirror()?.groupIndex === cgMerged?.index, `镜像 ${cgMirror()?.groupId}/${cgMirror()?.groupIndex} / 真值 ${cgMerged?.id}/${cgMerged?.index}`)
2412
+ const cgOwner = cgView.getLineChannel('c2')
2413
+ const cgOwnerGroups = cgView.getChannelGroups('ch-1')
2414
+ const cgOwnerGroupIndex = cgOwnerGroups.findIndex(group => String(group.id) === String(cgOwner?.groupId))
2415
+ check('合并回线之后 getLineChannel 的 groupId / groupIndex 一起刷新,选中项仍不挂通道字段', String(cgOwner?.groupId) === cgMerged?.id && cgOwnerGroupIndex === cgMerged?.index && cgMirror()?.groupId === undefined && cgMirror()?.groupIndex === undefined, `归属 ${cgOwner?.groupId}/${cgOwnerGroupIndex} / 真值 ${cgMerged?.id}/${cgMerged?.index} / 镜像 ${cgMirror()?.groupId}/${cgMirror()?.groupIndex}`)
2281
2416
  const cgMerge = cgBus[cgBus.length - 1]
2282
2417
  check('合并发 channelGroupChange(action: mergeGroups 带目标组与来源组)', cgMerge?.action?.type === 'mergeGroups' && String(cgMerge.action.groupId) === String(cgMerged?.id) && cgMerge.action.groupIds.length === 2, JSON.stringify({ type: cgMerge?.action?.type, groupId: cgMerge?.action?.groupId, groupIds: cgMerge?.action?.groupIds }))
2283
2418
 
@@ -2409,6 +2544,24 @@
2409
2544
  goView.destroy()
2410
2545
  goHost.remove()
2411
2546
 
2547
+ const rocHost = document.createElement('div')
2548
+ rocHost.style.cssText = 'width:420px;height:280px'
2549
+ document.body.appendChild(rocHost)
2550
+ const rocView = createGisView(rocHost, {
2551
+ points: [{ id: 'ra', x: -80, y: 0 }, { id: 'rb', x: 80, y: 0 }],
2552
+ lines: [{ id: 'r1', from: 'ra', to: 'rb' }, { id: 'r2', from: 'ra', to: 'rb' }, { id: 'r3', from: 'ra', to: 'rb' }],
2553
+ channels: [{ id: 'roc', groups: [{ id: 'roc-g1', lineIds: ['r1', 'r2'] }, { id: 'roc-g2', lineIds: ['r3'] }] }],
2554
+ showGrid: false,
2555
+ fitPadding: 60
2556
+ })
2557
+ await settle(100)
2558
+ const rocRemoved = rocView.removeChannel('roc')
2559
+ check('removeChannel 删除只写 groups 的通道时连同组内成员回线一起删净',
2560
+ rocRemoved.ok === true && rocRemoved.lineIds.length === 3 && rocView.getData().lines.length === 0 && rocView.getChannelGroups('roc').length === 0,
2561
+ JSON.stringify({ lineIds: rocRemoved.lineIds, lines: rocView.getData().lines.length, groups: rocView.getChannelGroups('roc').length }))
2562
+ rocView.destroy()
2563
+ rocHost.remove()
2564
+
2412
2565
  /* 这一轮新增:updateChannel 第三段(通道声明 + 逐条成员回线一次下发) */
2413
2566
  const ucHost = document.createElement('div')
2414
2567
  ucHost.style.cssText = 'width:420px;height:280px'
@@ -2657,7 +2810,7 @@
2657
2810
  chView.destroy()
2658
2811
  chHost.remove()
2659
2812
 
2660
- /* 这一轮新增:屏幕 ↔ 真实坐标互转 / addChannel 第二段 lineItems / beginAddChannel 交接模式 */
2813
+ /* 这一轮新增:屏幕 ↔ 真实坐标互转 / addChannel 第二段 lineData / beginAddChannel 交接模式 */
2661
2814
  const hwHost = document.createElement('div')
2662
2815
  hwHost.style.cssText = 'width:420px;height:280px'
2663
2816
  document.body.appendChild(hwHost)
@@ -2701,18 +2854,18 @@
2701
2854
  const hwClientViaScreen = hwView.screenToClient(hwScreen[0], hwScreen[1])
2702
2855
  check('screenToClient(project(世界)) 与 worldToClient(同一世界) 同一结果:反向也等价', Math.abs(hwClientViaScreen[0] - hwClient[0]) < 1e-6 && Math.abs(hwClientViaScreen[1] - hwClient[1]) < 1e-6, JSON.stringify({ viaScreen: hwClientViaScreen, viaWorld: hwClient }))
2703
2856
  // ② addChannel / addLine 第二段:连同线路一起新建
2704
- const hwAdd = hwView.addChannel({ from: 'ha', to: 'hb', name: '第二段通道', circuits: 5 }, [
2857
+ const hwAdd = hwView.addChannel({ from: 'ha', to: 'hb', name: '第二段通道' }, [
2705
2858
  { id: 'hw-l1', color: '#112233', width: 4 },
2706
2859
  { id: 'hw-l2', color: '#445566' }
2707
2860
  ])
2708
2861
  const hwLine = id => hwView.getOptions().lines.find(line => String(line.id) === id)
2709
2862
  const hwChannelOf = channelId => hwView.getOptions().channels.find(channel => String(channel.id) === String(channelId))
2710
- check('addChannel(input, lineItems):给了几条建几条(circuits: 5 忽略),逐条字段各自落地', hwAdd.ok === true && hwAdd.lineIds.length === 2 && hwAdd.circuits === 2 && (hwChannelOf(hwAdd.channelId)?.lineIds ?? []).length === 2 && hwLine('hw-l1')?.color === '#112233' && hwLine('hw-l1')?.width === 4 && hwLine('hw-l2')?.color === '#445566', JSON.stringify({ ids: hwAdd.lineIds, circuits: hwAdd.circuits, l1: hwLine('hw-l1') }))
2711
- const hwAddOne = hwView.addLine({ from: 'hc', to: 'hd', circuits: 3 }, { id: 'hw-solo', color: '#abcdef' })
2863
+ check('addChannel(spec, lineData):给几条建几条,逐条字段各自落地', hwAdd.ok === true && hwAdd.lineIds.length === 2 && hwAdd.circuits === 2 && (hwChannelOf(hwAdd.channelId)?.lineIds ?? []).length === 2 && hwLine('hw-l1')?.color === '#112233' && hwLine('hw-l1')?.width === 4 && hwLine('hw-l2')?.color === '#445566', JSON.stringify({ ids: hwAdd.lineIds, circuits: hwAdd.circuits, l1: hwLine('hw-l1') }))
2864
+ const hwAddOne = hwView.addLine({ from: 'hc', to: 'hd' }, { id: 'hw-solo', color: '#abcdef' })
2712
2865
  check('第二段收单条(非数组)也认:addLine 按它建一条独立线路', hwAddOne.ok === true && hwAddOne.lineIds.length === 1 && hwAddOne.lineIds[0] === 'hw-solo' && hwAddOne.kind === 'line' && hwLine('hw-solo')?.color === '#abcdef', JSON.stringify({ ids: hwAddOne.lineIds, circuits: hwAddOne.circuits }))
2713
- const hwBatch = hwView.addChannels([{ from: 'ha', to: 'hc', lineItems: [{ id: 'hw-b1' }, { id: 'hw-b2' }, { id: 'hw-b3' }] }])
2714
- check('addChannels 的每一项也能写 lineItems:三条一起建', hwBatch.ok === true && hwBatch.lineIds.length === 3 && ['hw-b1', 'hw-b2', 'hw-b3'].every(id => hwLine(id) !== undefined), JSON.stringify({ ids: hwBatch.lineIds }))
2715
- // ③ beginAddChannel:lineItems 对齐条数、handoff 交接
2866
+ const hwBatch = hwView.addChannels([{ from: 'ha', to: 'hc', lineData: [{ id: 'hw-b1' }, { id: 'hw-b2' }, { id: 'hw-b3' }] }])
2867
+ check('addChannels 的每一项写 lineData:三条一起建', hwBatch.ok === true && hwBatch.lineIds.length === 3 && ['hw-b1', 'hw-b2', 'hw-b3'].every(id => hwLine(id) !== undefined), JSON.stringify({ ids: hwBatch.lineIds }))
2868
+ // ③ beginAddChannel:lineData 对齐条数、handoff 交接
2716
2869
  const hwClickPoint = id => {
2717
2870
  const item = hwView.getData().points.find(point => point.id === id)
2718
2871
  return hwView.project(item.x, item.y)
@@ -2748,9 +2901,13 @@
2748
2901
  const hwHandAdd = hwReady ? hwView.addChannel({ from: hwReady.from.id, to: hwReady.to.id, name: '交接通道' }) : { ok: false }
2749
2902
  check('交接之后自己走 addChannel:真实的通道与回线落地', hwHandAdd.ok === true && hwHandAdd.channelId !== null && hwView.getOptions().lines.length === hwLineBefore + 1, JSON.stringify({ ok: hwHandAdd.ok, channelId: hwHandAdd.channelId }))
2750
2903
  hwDraftEvents.length = 0
2751
- hwView.beginAddChannel({ autoCommit: true, lineItems: [{ id: 'hw-d1', color: '#0a0b0c' }, { id: 'hw-d2', color: '#0d0e0f' }] })
2752
- await settle(40)
2753
- check('beginAddChannel({ lineItems }):草稿按第二段对齐条数(circuits = 2)', hwView.getAddChannelDraft()?.circuits === 2 && (hwView.getAddChannelDraft()?.lineItems?.length ?? 0) === 2, JSON.stringify({ circuits: hwView.getAddChannelDraft()?.circuits }))
2904
+ hwView.beginAddChannel({ lineItems: [{ id: 'hw-old-d1' }, { id: 'hw-old-d2' }] })
2905
+ await settle(40)
2906
+ check('beginAddChannel 旧 lineItems 已移除:不进入草稿的 lineData、回数按缺省 1', hwView.getAddChannelDraft()?.lineData === null && hwView.getAddChannelDraft()?.circuits === 1, JSON.stringify({ circuits: hwView.getAddChannelDraft()?.circuits, lineData: hwView.getAddChannelDraft()?.lineData }))
2907
+ hwView.cancelAddChannel()
2908
+ hwView.beginAddChannel({ autoCommit: true, lineData: [{ id: 'hw-d1', color: '#0a0b0c' }, { id: 'hw-d2', color: '#0d0e0f' }] })
2909
+ await settle(40)
2910
+ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits = 2)', hwView.getAddChannelDraft()?.circuits === 2 && (hwView.getAddChannelDraft()?.lineData?.length ?? 0) === 2, JSON.stringify({ circuits: hwView.getAddChannelDraft()?.circuits }))
2754
2911
  hwClick(hwClickPoint('hb'))
2755
2912
  await settle(30)
2756
2913
  hwClick(hwClickPoint('hd'))
@@ -2812,17 +2969,17 @@
2812
2969
  check('位置对账:新加进来的节点以加入时坐标入账(刚加不算,之后再挪才算)', mvAfterAdd === 0 && mvMovedNew.length === 1 && mvMovedNew[0].id === 'me' && mvMovedNew[0].x === 620, JSON.stringify({ afterAdd: mvAfterAdd, afterMove: mvMovedNew.map(item => item.id) }))
2813
2970
  check('位置对账:clearMovedPoints 返回实例本身(可以链式调)', mvView.clearMovedPoints('me') === mvView && mvView.clearMovedPoints().getMovedPoints().length === 0, '')
2814
2971
 
2815
- const mvAdd1 = mvView.addChannel({ from: 'ma', to: 'mb' })
2972
+ const mvAdd1 = mvView.addChannelGroupLines('mch')
2816
2973
  const mvCh1 = mvChannel('mch')
2817
- check('addChannel 已有通道:自动方案 newGroup,通道 lineIds 与 groups 一起追加', mvAdd1.ok === true && mvAdd1.resolution?.type === 'newGroup' && mvCh1.lineIds.length === 3 && mvCh1.groups.length === 2 && mvCh1.groups[1].lineIds.length === 1, JSON.stringify({ type: mvAdd1.resolution?.type, lineIds: mvCh1.lineIds, groups: mvCh1.groups.map(group => group.lineIds) }))
2818
- check('addChannel 已有通道:结果回带更新后的通道 lineIds(channelLineIds 与声明一致)', Array.isArray(mvAdd1.channelLineIds) && mvAdd1.channelLineIds.length === 3 && JSON.stringify(mvAdd1.channelLineIds) === JSON.stringify(mvCh1.lineIds), JSON.stringify(mvAdd1.channelLineIds))
2819
- const mvAdd2 = mvView.addChannel({ from: 'ma', to: 'mb', channelId: 'mch', groupIndex: 0 })
2974
+ check('addChannelGroupLines 已有通道:新分组,通道 lineIds 与 groups 一起追加', mvAdd1.ok === true && mvAdd1.resolution?.type === 'newGroup' && mvCh1.lineIds.length === 3 && mvCh1.groups.length === 2 && mvCh1.groups[1].lineIds.length === 1, JSON.stringify({ type: mvAdd1.resolution?.type, lineIds: mvCh1.lineIds, groups: mvCh1.groups.map(group => group.lineIds) }))
2975
+ check('addChannelGroupLines 已有通道:结果回带更新后的通道 lineIds(channelLineIds 与声明一致)', Array.isArray(mvAdd1.channelLineIds) && mvAdd1.channelLineIds.length === 3 && JSON.stringify(mvAdd1.channelLineIds) === JSON.stringify(mvCh1.lineIds), JSON.stringify(mvAdd1.channelLineIds))
2976
+ const mvAdd2 = mvView.joinChannelGroupLines('mch', 0)
2820
2977
  const mvCh2 = mvChannel('mch')
2821
- check('addChannel 顶层简写:channelId + groupIndex 并进指定分组(通道 lineIds 继续追加)', mvAdd2.ok === true && mvAdd2.resolution?.type === 'joinGroup' && mvAdd2.groupId === mvCh2.groups[0].id && mvCh2.lineIds.length === 4 && mvCh2.groups[0].lineIds.length === 3, JSON.stringify({ type: mvAdd2.resolution?.type, groupId: mvAdd2.groupId, lineIds: mvCh2.lineIds, groups: mvCh2.groups.map(group => group.lineIds) }))
2822
- const mvAdd3 = mvView.addChannel({ from: 'ma', to: 'mb', channelId: 'nope' })
2823
- check('addChannel 指定了不在方案里的通道:失败并给出可读原因', mvAdd3.ok === false && typeof mvAdd3.reason === 'string' && mvAdd3.reason.includes('没有可用方案'), JSON.stringify({ ok: mvAdd3.ok, reason: mvAdd3.reason }))
2824
- const mvAdd4 = mvView.addChannel({ from: 'mb', to: 'mc', channelId: 'mch-x' })
2825
- check('addChannel 两个节点之间还没有通道:顶层 channelId 仍是新建通道的 id', mvAdd4.ok === true && mvAdd4.channelId === 'mch-x' && Array.isArray(mvAdd4.channelLineIds) && mvAdd4.channelLineIds.length === 1 && mvChannel('mch-x').lineIds.length === 1, JSON.stringify({ ok: mvAdd4.ok, channelId: mvAdd4.channelId, channelLineIds: mvAdd4.channelLineIds }))
2978
+ check('joinChannelGroupLines 按分组序号并进指定分组(通道 lineIds 继续追加)', mvAdd2.ok === true && mvAdd2.resolution?.type === 'joinGroup' && mvAdd2.groupId === mvCh2.groups[0].id && mvCh2.lineIds.length === 4 && mvCh2.groups[0].lineIds.length === 3, JSON.stringify({ type: mvAdd2.resolution?.type, groupId: mvAdd2.groupId, lineIds: mvCh2.lineIds, groups: mvCh2.groups.map(group => group.lineIds) }))
2979
+ const mvAdd3 = mvView.addChannelGroupLines('nope')
2980
+ check('分组方法指定了不存在的通道:失败并给出可读原因', mvAdd3.ok === false && typeof mvAdd3.reason === 'string' && mvAdd3.reason.includes('找不到通道'), JSON.stringify({ ok: mvAdd3.ok, reason: mvAdd3.reason }))
2981
+ const mvAdd4 = mvView.addChannel({ from: 'mb', to: 'mc', id: 'mch-x' })
2982
+ check('addChannel 两个节点之间还没有通道:id 是新建通道的 id', mvAdd4.ok === true && mvAdd4.channelId === 'mch-x' && Array.isArray(mvAdd4.channelLineIds) && mvAdd4.channelLineIds.length === 1 && mvChannel('mch-x').lineIds.length === 1, JSON.stringify({ ok: mvAdd4.ok, channelId: mvAdd4.channelId, channelLineIds: mvAdd4.channelLineIds }))
2826
2983
  mvView.destroy()
2827
2984
  mvHost.remove()
2828
2985
 
@@ -3096,8 +3253,8 @@
3096
3253
  diag('右键回线中段', `世界 ${rcMidWorld.map(value => Number(value).toFixed(2)).join(',')} · 回线折点 ${Array.isArray(rcLine?.path) ? rcLine.path.length : 0}`)
3097
3254
  await rcRightClick(rcView.project(rcMidWorld[0], rcMidWorld[1]))
3098
3255
  const rcContext = rcHits[1]?.channelContext
3099
- check('右键落在回线中段:同一份 onContextMenu 载荷里带 channelContext(组数 / 当前分组 / 可用交互)', rcHits.length === 2 && rcContext?.channelId === 'ch-rc' && rcHits[1]?.lineId === 'rc-1' && rcContext?.groupCount === 1 && (rcContext?.actions ?? []).length > 0, JSON.stringify({ count: rcHits.length, channel: rcContext?.channelId, line: rcHits[1]?.lineId, groups: rcContext?.groupCount }))
3100
- check('右键通道只发这一条(不再额外发 onChannelContextMenu),线路下标 / id 也在载荷上', rcHits[1]?.type === 'line' && rcHits[1]?.channelId === 'ch-rc' && rcHits[1]?.lineId === 'rc-1' && Number.isInteger(rcHits[1]?.lineIndex) && rcHits[1]?.action?.target === 'channel', JSON.stringify({ count: rcHits.length, type: rcHits[1]?.type, lineIndex: rcHits[1]?.lineIndex, target: rcHits[1]?.action?.target }))
3256
+ check('右键落在回线中段:同一份 onContextMenu 载荷里带 channelContext(组数 / 当前分组 / 可用交互)', rcHits.length === 2 && rcContext?.channelId === 'ch-rc' && rcHits[1]?.item?.id === 'rc-1' && rcContext?.groupCount === 1 && (rcContext?.actions ?? []).length > 0, JSON.stringify({ count: rcHits.length, channel: rcContext?.channelId, line: rcHits[1]?.item?.id, groups: rcContext?.groupCount }))
3257
+ check('右键通道只发这一条(不再额外发 onChannelContextMenu),线路下标读 index、id 读 item.id', rcHits[1]?.type === 'line' && rcHits[1]?.channelId === 'ch-rc' && rcHits[1]?.item?.id === 'rc-1' && Number.isInteger(rcHits[1]?.index) && rcHits[1]?.lineId === undefined && rcHits[1]?.lineIndex === undefined && rcHits[1]?.action?.target === 'channel', JSON.stringify({ count: rcHits.length, type: rcHits[1]?.type, index: rcHits[1]?.index, item: rcHits[1]?.item?.id, target: rcHits[1]?.action?.target }))
3101
3258
  rcView.destroy()
3102
3259
  rcHost.remove()
3103
3260
 
@@ -3519,18 +3676,19 @@
3519
3676
  await settle(60)
3520
3677
  }
3521
3678
  await rdClick(rdView.project(0, 0))
3522
- check('单击载荷:selected true / wasSelected false(点之前没选中)+ selection 读数', rdClicks[0]?.selected === true && rdClicks[0]?.wasSelected === false && rdClicks[0]?.selection?.count === 1 && rdClicks[0]?.selection?.points === 1, JSON.stringify({ selected: rdClicks[0]?.selected, was: rdClicks[0]?.wasSelected, count: rdClicks[0]?.selection?.count }))
3679
+ const rdClickInfo = rdView.getSelectionInfo()
3680
+ check('单击载荷:selected true / wasSelected false(点之前没选中),总读数走 getSelectionInfo()', rdClicks[0]?.selected === true && rdClicks[0]?.wasSelected === false && rdClicks[0]?.selection === undefined && rdClickInfo.count === 1 && rdClickInfo.points === 1, JSON.stringify({ selected: rdClicks[0]?.selected, was: rdClicks[0]?.wasSelected, count: rdClickInfo.count }))
3523
3681
  await rdClick(rdView.project(0, 0))
3524
- check('再点已经选中的节点:wasSelected 也是 true(点之前就在选中集里)', rdClicks[1]?.selected === true && rdClicks[1]?.wasSelected === true && rdClicks[1]?.selection?.count === 1, JSON.stringify({ selected: rdClicks[1]?.selected, was: rdClicks[1]?.wasSelected }))
3682
+ check('再点已经选中的节点:wasSelected 也是 true(点之前就在选中集里)', rdClicks[1]?.selected === true && rdClicks[1]?.wasSelected === true && rdClicks[1]?.selection === undefined && rdView.getSelectionInfo().count === 1, JSON.stringify({ selected: rdClicks[1]?.selected, was: rdClicks[1]?.wasSelected }))
3525
3683
  const rdSelects = []
3526
3684
  rdView.on('selectChange', payload => rdSelects.push(payload))
3527
3685
  await rdClick(rdView.project(0, 0), { ctrlKey: true })
3528
3686
  check('Ctrl + 点已选中的节点:多选取反走 onSelectChange(count 0),不经过 onClick', rdSelects.length === 1 && rdSelects[0].count === 0 && rdSelects[0].items.length === 0 && rdClicks.length === 2 && rdView.getSelectionData().count === 0, JSON.stringify({ selects: rdSelects.length, clicks: rdClicks.length, count: rdSelects[0]?.count }))
3529
3687
  rdView.selectFeatures(['rd-a', 'rd-b'])
3530
3688
  await rdRightClick(rdView.project(320, 0))
3531
- check('右键落在没选中的节点上:selected / wasSelected 都是 false,selection 照旧给整份选中集', rdMenus[0]?.type === 'point' && rdMenus[0]?.item?.id === 'rd-c' && rdMenus[0]?.selected === false && rdMenus[0]?.wasSelected === false && rdMenus[0]?.selection?.count === 2, JSON.stringify({ id: rdMenus[0]?.item?.id, selected: rdMenus[0]?.selected, count: rdMenus[0]?.selection?.count }))
3689
+ check('右键落在没选中的节点上:selected / wasSelected 都是 false,总读数走 getSelectionInfo()', rdMenus[0]?.type === 'point' && rdMenus[0]?.item?.id === 'rd-c' && rdMenus[0]?.selected === false && rdMenus[0]?.wasSelected === false && rdMenus[0]?.selection === undefined && rdView.getSelectionInfo().count === 2, JSON.stringify({ id: rdMenus[0]?.item?.id, selected: rdMenus[0]?.selected, count: rdView.getSelectionInfo().count }))
3532
3690
  await rdRightClick(rdView.project(0, 0))
3533
- check('右键落在已选中的节点上:wasSelected true(据此决定要不要保住整份多选)', rdMenus[1]?.selected === true && rdMenus[1]?.wasSelected === true && rdMenus[1]?.selection?.count === 2, JSON.stringify({ selected: rdMenus[1]?.selected, was: rdMenus[1]?.wasSelected }))
3691
+ check('右键落在已选中的节点上:wasSelected true(据此决定要不要保住整份多选)', rdMenus[1]?.selected === true && rdMenus[1]?.wasSelected === true && rdMenus[1]?.selection === undefined && rdView.getSelectionInfo().count === 2, JSON.stringify({ selected: rdMenus[1]?.selected, was: rdMenus[1]?.wasSelected }))
3534
3692
  const rdPointInfo = rdView.getSelectionData()
3535
3693
  check('getSelectionData 给读数(ids / count / 各类型条数)', rdPointInfo.count === 2 && rdPointInfo.pointCount === 2 && rdPointInfo.lineCount === 0 && rdPointInfo.channelCount === 0 && rdPointInfo.ids.join(',') === 'rd-a,rd-b', JSON.stringify(rdPointInfo))
3536
3694
  rdView.selectFeatures(['rd-1', 'rd-2'])
@@ -3878,6 +4036,46 @@
3878
4036
  check('exportData:format 写错直接抛异常(不静默兜底)', /view/.test(rdThrew) && /new/.test(rdThrew) && /test/.test(rdThrew), rdThrew)
3879
4037
  rdView.destroy()
3880
4038
  rdHost.remove()
4039
+
4040
+ /* 本轮:视口导出的线路 / 通道端点模式。半区只盖住左端点,和整区对照。 */
4041
+ const veHost = document.createElement('div')
4042
+ veHost.style.cssText = 'width:480px;height:300px'
4043
+ document.body.appendChild(veHost)
4044
+ const veView = createGisView(veHost, {
4045
+ points: [
4046
+ { id: 've-a', x: -130, y: 0, size: 16 },
4047
+ { id: 've-b', x: 130, y: 0, size: 16 }
4048
+ ],
4049
+ lines: [
4050
+ { id: 've-bound', from: 've-a', to: 've-b', width: 8 },
4051
+ { id: 've-path', path: [[-80, 60], [80, 60]], width: 8 },
4052
+ { id: 've-ch-line', from: 've-a', to: 've-b', width: 8 }
4053
+ ],
4054
+ channels: [{ id: 've-ch', lineIds: ['ve-ch-line'] }],
4055
+ showGrid: false,
4056
+ fitOnInit: false,
4057
+ initialView: { center: [0, 0], scale: 1 },
4058
+ width: 480,
4059
+ height: 300
4060
+ })
4061
+ await settle(120)
4062
+ const veHalf = { minX: -200, minY: -100, maxX: 0, maxY: 100 }
4063
+ const veAll = { minX: -200, minY: -100, maxX: 200, maxY: 100 }
4064
+ const veLineIds = result => (result.lines ?? []).map(row => row.line_id).sort()
4065
+ const veLegacy = veView.exportData({ scope: 'viewport', rect: veHalf, selectionKinds: ['line'] })
4066
+ check('exportData 视口 line 旧模式:几何沾到视口的独立线路都导出', veLegacy.counts.points === 0 && veLegacy.counts.lines === 2 && veLineIds(veLegacy).join(',') === 've-bound,ve-path' && veLegacy.counts.channels === 0, JSON.stringify({ counts: veLegacy.counts, lines: veLineIds(veLegacy) }))
4067
+ const veLineHalf = veView.exportData({ scope: 'viewport', rect: veHalf, selectionKinds: ['lineEndpoints'] })
4068
+ check('exportData 视口 lineEndpoints:只盖住一端时不导出独立线路', veLineHalf.counts.lines === 0 && veLineHalf.lines.length === 0 && veLineHalf.counts.channels === 0, JSON.stringify(veLineHalf.counts))
4069
+ const veLineAll = veView.exportData({ scope: 'viewport', rect: veAll, selectionKinds: ['line-endpoints'] })
4070
+ check('exportData 视口 lineEndpoints:两端都在视口内才导出,纯 path 线路仍跳过', veLineAll.counts.points === 0 && veLineAll.counts.lines === 1 && veLineIds(veLineAll).join(',') === 've-bound', JSON.stringify({ counts: veLineAll.counts, lines: veLineIds(veLineAll) }))
4071
+ const veChannelLegacy = veView.exportData({ scope: 'viewport', rect: veHalf, selectionKinds: ['channel'] })
4072
+ check('exportData 视口 channel 旧模式:成员回线沾到视口就导出整条通道', veChannelLegacy.counts.channels === 1 && veChannelLegacy.channels[0].channel_name === 've-ch' && veChannelLegacy.counts.lines === 1, JSON.stringify({ counts: veChannelLegacy.counts, channels: veChannelLegacy.channels.map(row => row.channel_name) }))
4073
+ const veChannelHalf = veView.exportData({ scope: 'viewport', rect: veHalf, selectionKinds: ['channelEndpoints'] })
4074
+ check('exportData 视口 channelEndpoints:只盖住一端时不导出通道', veChannelHalf.counts.channels === 0 && veChannelHalf.counts.lines === 0, JSON.stringify(veChannelHalf.counts))
4075
+ const veChannelAll = veView.exportData({ scope: 'viewport', rect: veAll, selectionKinds: ['channel-endpoints'] })
4076
+ check('exportData 视口 channelEndpoints:两端都在视口内才导出整条通道与成员回线', veChannelAll.counts.points === 0 && veChannelAll.counts.channels === 1 && veChannelAll.counts.lines === 1 && veChannelAll.channels[0].channel_name === 've-ch' && veChannelAll.channels[0].line_data[0].line_id === 've-ch-line', JSON.stringify({ counts: veChannelAll.counts, lines: veLineIds({ lines: veChannelAll.channels[0].line_data }) }))
4077
+ veView.destroy()
4078
+ veHost.remove()
3881
4079
  }
3882
4080
  {
3883
4081
  // 接口选中(selectFeatures)、读回选中(getSelectionData)与聚焦(focusOn:单个 / 一批 / 只居中)
@@ -3982,27 +4180,27 @@
3982
4180
  const apiCorners = [[0, 0], [400, 40]].map(spot => apiView.project(spot[0], spot[1]))
3983
4181
  const apiInside = apiCorners.every(spot => spot[0] >= 0 && spot[0] <= apiStats.cssWidth && spot[1] >= 0 && spot[1] <= apiStats.cssHeight)
3984
4182
  check('focusOn(一批目标, { zoom: true }):整体包围盒居中,三个点都在视口里', Math.abs(apiBatch.center[0] - 200) < 1e-6 && Math.abs(apiBatch.center[1] - 20) < 1e-6 && apiBatch.scale > 0 && apiInside, JSON.stringify({ center: apiBatch.center, corners: apiCorners }))
3985
- // center: true 是原来 centerOn 的语义:只挪中心,比例一点不动
3986
- apiView.focusOn(['api-a', 'api-b'], { center: true })
4183
+ // 只挪中心、比例一点不动是 focusOn 的缺省语义
4184
+ apiView.focusOn(['api-a', 'api-b'])
3987
4185
  const apiCenter1 = apiView.getCamera()
3988
- check('focusOn(一批, { center: true }):中心挪到这一段的中点,比例一点不动', Math.abs(apiCenter1.center[0] - 100) < 1e-6 && Math.abs(apiCenter1.center[1] - 20) < 1e-6 && apiCenter1.scale === apiBatch.scale, JSON.stringify(apiCenter1.center))
3989
- apiView.focusOn([400, 0], { center: true })
4186
+ check('focusOn(一批):中心挪到这一段的中点,比例一点不动', Math.abs(apiCenter1.center[0] - 100) < 1e-6 && Math.abs(apiCenter1.center[1] - 20) < 1e-6 && apiCenter1.scale === apiBatch.scale, JSON.stringify(apiCenter1.center))
4187
+ apiView.focusOn([400, 0])
3990
4188
  const apiCenter2 = apiView.getCamera().center
3991
- check('focusOn([x, y], { center: true }) 与 { x, y } 写法都认', Math.abs(apiCenter2[0] - 400) < 1e-6 && Math.abs(apiCenter2[1]) < 1e-6, JSON.stringify(apiCenter2))
3992
- apiView.focusOn('api-b', { center: true })
4189
+ check('focusOn([x, y]) 与 { x, y } 写法都认', Math.abs(apiCenter2[0] - 400) < 1e-6 && Math.abs(apiCenter2[1]) < 1e-6, JSON.stringify(apiCenter2))
4190
+ apiView.focusOn('api-b')
3993
4191
  const apiCenter3 = apiView.getCamera().center
3994
- check('focusOn(id, { center: true }):中心挪到这个要素上', Math.abs(apiCenter3[0] - 200) < 1e-6 && Math.abs(apiCenter3[1] - 40) < 1e-6, JSON.stringify(apiCenter3))
4192
+ check('focusOn(id):中心挪到这个要素上', Math.abs(apiCenter3[0] - 200) < 1e-6 && Math.abs(apiCenter3[1] - 40) < 1e-6, JSON.stringify(apiCenter3))
3995
4193
  const apiChannelScale = apiView.getCamera().scale
3996
4194
  apiView.focusOn({ type: 'channel', channelId: 'api-ch' })
3997
4195
  const apiChannelCamera = apiView.getCamera()
3998
4196
  const apiCenter4 = apiChannelCamera.center
3999
4197
  check('focusOn(通道写法) 缺省只居中,线路 / 通道不再自动放大', Math.abs(apiCenter4[0] - 100) < 5 && Math.abs(apiCenter4[1] - 20) < 5 && apiChannelCamera.scale === apiChannelScale, JSON.stringify({ center: apiCenter4, scale: [apiChannelScale, apiChannelCamera.scale] }))
4000
- apiView.focusOn('没有这个 id', { center: true })
4198
+ apiView.focusOn('没有这个 id')
4001
4199
  const apiCenter5 = apiView.getCamera().center
4002
4200
  check('focusOn:认不出来的目标什么都不做(视角停在原处)', apiCenter5[0] === apiCenter4[0] && apiCenter5[1] === apiCenter4[1], JSON.stringify(apiCenter5))
4003
- apiView.focusOn({ minX: 0, minY: 0, maxX: 400, maxY: 40 }, { center: true })
4201
+ apiView.focusOn({ minX: 0, minY: 0, maxX: 400, maxY: 40 })
4004
4202
  const apiCenter6 = apiView.getCamera().center
4005
- check('focusOn(一段范围, { center: true }):取中心', Math.abs(apiCenter6[0] - 200) < 1e-6 && Math.abs(apiCenter6[1] - 20) < 1e-6, JSON.stringify(apiCenter6))
4203
+ check('focusOn(一段范围):取中心', Math.abs(apiCenter6[0] - 200) < 1e-6 && Math.abs(apiCenter6[1] - 20) < 1e-6, JSON.stringify(apiCenter6))
4006
4204
  // select: true:被聚焦的要素同时变成选中状态(与接口选中同一份 state);mode 决定怎么并进当前选中集
4007
4205
  const apiBeforeFocusSelect = apiView.getSelectionData().ids.join(',')
4008
4206
  apiView.focusOn(['api-a', 'api-b', 'api-c'], { select: true, zoom: true, padding: 20 })
@@ -4733,13 +4931,13 @@
4733
4931
  check('addChannel 新签名:lineIds 自动按线路数组每项的 id 构建', JSON.stringify(nwCh1?.lineIds) === JSON.stringify(['nl1', 'nl2']) && JSON.stringify(nwAdd.lineIds) === JSON.stringify(['nl1', 'nl2']), JSON.stringify(nwCh1?.lineIds))
4734
4932
  check('addChannel 新签名:线路数组逐条落地(自己的字段保留、from / to 固定两端)', nwLine('nl1')?.color === '#112233' && nwLine('nl1')?.from === 'na' && nwLine('nl1')?.to === 'nb' && nwAdd.circuits === 2, JSON.stringify({ l1: nwLine('nl1'), count: nwAdd.circuits }))
4735
4933
  check('addChannel 新签名:第三段分组信息写进声明(id / lineIds / path)', nwCh1?.groups?.length === 1 && nwCh1.groups[0].id === 'ng1' && JSON.stringify(nwCh1.groups[0].lineIds) === JSON.stringify(['nl1', 'nl2']) && JSON.stringify(nwCh1.groups[0].path) === JSON.stringify([[0, 30]]), JSON.stringify(nwCh1?.groups))
4736
- check('addChannel 新签名:落地后新回线整批选中', JSON.stringify(nwView.getSelectionData().ids) === JSON.stringify(['nl1', 'nl2']), JSON.stringify(nwView.getSelectionData().ids))
4934
+ check('addChannel 缺省不改变选中集(select 是显式选项)', nwView.getSelectionData().ids.length === 0, JSON.stringify(nwView.getSelectionData().ids))
4737
4935
 
4738
- // ② 旧写法继续可用(circuits / resolution 那一套)
4936
+ // ② 旧写法已移除;只写 { from, to } 是新签名,会新建一条回线
4739
4937
  const nwLegacy = nwView.addChannel({ from: 'nc', to: 'nd', circuits: 2, name: '丙丁' })
4740
- check('addChannel 旧写法(circuits 那套)继续可用:一次两回', nwLegacy.ok === true && nwLegacy.circuits === 2 && nwView.getOptions().lines.filter(item => item.from === 'nc' && item.to === 'nd').length === 2, JSON.stringify({ ok: nwLegacy.ok, ids: nwLegacy.lineIds }))
4938
+ check('addChannel 旧 circuits 写法已移除:明确失败且不写数据', nwLegacy.ok === false && String(nwLegacy.reason).includes('circuits') && nwView.getOptions().lines.filter(item => item.from === 'nc' && item.to === 'nd').length === 0, JSON.stringify({ ok: nwLegacy.ok, reason: nwLegacy.reason }))
4741
4939
  const nwBare = nwView.addChannel({ from: 'nc', to: 'nd' })
4742
- check('只写 { from, to } 的老调用照旧按方案表分流(已有通道 → newGroup,不被新签名当成「只新建」)', nwBare.ok === true && nwBare.resolution?.type === 'newGroup', JSON.stringify({ ok: nwBare.ok, type: nwBare.resolution?.type, reason: nwBare.reason }))
4940
+ check('只写 { from, to }:新建通道并自动建一条回线', nwBare.ok === true && nwBare.resolution?.type === 'newChannel' && nwBare.lineIds.length === 1, JSON.stringify({ ok: nwBare.ok, type: nwBare.resolution?.type, reason: nwBare.reason }))
4743
4941
 
4744
4942
  // ③ 两个节点之间已有通道:新签名整条失败,plan 里带分组信息(拿来给用户看「已经有几组」)
4745
4943
  const nwLinesBefore = nwView.getOptions().lines.length
@@ -4748,9 +4946,11 @@
4748
4946
  check('失败结果里的 plan 带 hasChannel / hasGroup / groups / channel / group', nwBlocked.plan?.hasChannel === true && nwBlocked.plan?.hasGroup === true && nwBlocked.plan.groups.length === 1 && nwBlocked.plan.channel?.id === 'nc1' && nwBlocked.plan.group?.id === 'ng1', JSON.stringify({ hasChannel: nwBlocked.plan?.hasChannel, groups: nwBlocked.plan?.groups?.map(item => item.id) }))
4749
4947
 
4750
4948
  // ④ 拆出来的两个方法:新加一组 / 并进某一组
4751
- const nwNewGroup = nwView.addChannelGroupLines('nc1', [{ id: 'nl3', color: '#334455' }], { id: 'ng2' })
4949
+ const nwSelectBefore = nwSelectChanges.length
4950
+ const nwNewGroup = nwView.addChannelGroupLines('nc1', [{ id: 'nl3', color: '#334455' }], { id: 'ng2' }, { select: true })
4752
4951
  const nwCh1b = nwChannel('nc1')
4753
4952
  check('addChannelGroupLines:新回线进新分组(成员表一起长)', nwNewGroup.ok === true && nwNewGroup.groupId === 'ng2' && nwNewGroup.resolution?.type === 'newGroup' && JSON.stringify(nwCh1b.lineIds) === JSON.stringify(['nl1', 'nl2', 'nl3']) && nwCh1b.groups.length === 2, JSON.stringify({ ok: nwNewGroup.ok, groupId: nwNewGroup.groupId }))
4953
+ check('addChannelGroupLines({ select: true }):落地后只选中这次的新回线,并只发一次选中变化', nwSelectChanges.length === nwSelectBefore + 1 && JSON.stringify(nwView.getSelectionData().ids) === JSON.stringify(['nl3']), JSON.stringify({ changes: nwSelectChanges.length - nwSelectBefore, ids: nwView.getSelectionData().ids }))
4754
4954
  const nwJoin = nwView.joinChannelGroupLines('nc1', 'ng1', [{ id: 'nl4' }])
4755
4955
  check('joinChannelGroupLines:按分组 id 并进某一组', nwJoin.ok === true && nwJoin.groupId === 'ng1' && JSON.stringify(nwChannel('nc1').groups.find(item => item.id === 'ng1').lineIds) === JSON.stringify(['nl1', 'nl2', 'nl4']), JSON.stringify({ groups: nwChannel('nc1')?.groups?.map(item => [item.id, item.lineIds]) }))
4756
4956
  const nwJoinIndex = nwView.joinChannelGroupLines('nc1', 1, 'nl5')
@@ -4759,8 +4959,10 @@
4759
4959
  check('通道找不到时拆出来的方法给出 reason、不抛错', nwJoinMiss.ok === false && String(nwJoinMiss.reason).includes('找不到通道'), JSON.stringify({ ok: nwJoinMiss.ok, reason: nwJoinMiss.reason }))
4760
4960
 
4761
4961
  // ⑤ addChannels 的每一项也认新签名(lineData + group 写在对象上)
4762
- const nwMany = nwView.addChannels([{ from: 'na', to: 'nc', name: '甲丙', id: 'nc2', lineData: [{ id: 'nl6' }, { id: 'nl7' }], group: { id: 'ng3' } }])
4962
+ const nwBatchSelectBefore = nwSelectChanges.length
4963
+ const nwMany = nwView.addChannels([{ from: 'na', to: 'nc', name: '甲丙', id: 'nc2', lineData: [{ id: 'nl6' }, { id: 'nl7' }], group: { id: 'ng3' } }], { select: true })
4763
4964
  check('addChannels 的每一项认新签名(lineData + group)', nwMany.ok === true && nwMany.channelIds[0] === 'nc2' && JSON.stringify(nwChannel('nc2')?.lineIds) === JSON.stringify(['nl6', 'nl7']) && nwChannel('nc2')?.groups?.[0]?.id === 'ng3', JSON.stringify({ ok: nwMany.ok, ids: nwMany.channelIds }))
4965
+ check('addChannels({ select: true }):两条回线一次选中,不逐条发选中变化', nwSelectChanges.length === nwBatchSelectBefore + 1 && JSON.stringify(nwView.getSelectionData().ids) === JSON.stringify(['nl6', 'nl7']), JSON.stringify({ changes: nwSelectChanges.length - nwBatchSelectBefore, ids: nwView.getSelectionData().ids }))
4764
4966
 
4765
4967
  // ⑥ updateChannel 的 group(单数:改一组的信息)/ groups(复数:整份换)
4766
4968
  nwView.updateChannel('nc1', { group: { id: 'ng1', path: [[7, 8]] } })
@@ -4784,7 +4986,7 @@
4784
4986
  send('pointerdown', { buttons: 1 })
4785
4987
  send('pointerup', { buttons: 0 })
4786
4988
  }
4787
- const nwSelectBefore = nwView.getSelectionData().ids
4989
+ const nwSelectedBeforeDraft = nwView.getSelectionData().ids
4788
4990
  nwEvents.length = 0
4789
4991
  nwSelectChanges.length = 0
4790
4992
  nwView.beginAddChannel({ select: false })
@@ -4794,7 +4996,7 @@
4794
4996
  await settle(40)
4795
4997
  nwClick(nwScreenOf('nb'))
4796
4998
  await settle(60)
4797
- check('select 关着:点两个节点不改变选中集(只画两个草稿点)', JSON.stringify(nwView.getSelectionData().ids) === JSON.stringify(nwSelectBefore) && nwSelectChanges.length === 0, JSON.stringify(nwView.getSelectionData().ids))
4999
+ check('select 关着:点两个节点不改变选中集(只画两个草稿点)', JSON.stringify(nwView.getSelectionData().ids) === JSON.stringify(nwSelectedBeforeDraft) && nwSelectChanges.length === 0, JSON.stringify(nwView.getSelectionData().ids))
4798
5000
  const nwReadyPlain = nwEvents.find(payload => payload.phase === 'ready')
4799
5001
  check('结束事件(ready)带两个节点与「没有分组」的读数', nwReadyPlain !== undefined && nwReadyPlain.from.id === 'nd' && nwReadyPlain.to.id === 'nb' && nwReadyPlain.finished === true && nwReadyPlain.hasChannel === false && nwReadyPlain.hasGroup === false && nwReadyPlain.groups.length === 0 && nwReadyPlain.group === null && nwReadyPlain.channel === null, JSON.stringify({ from: nwReadyPlain?.from?.id, to: nwReadyPlain?.to?.id, hasGroup: nwReadyPlain?.hasGroup }))
4800
5002
  check('beginAddChannel 只负责示例路径:ready 之后草稿还在、一条数据都没落', nwView.getAddChannelDraft()?.phase === 'ready' && nwEvents.every(payload => payload.phase !== 'commit'), JSON.stringify({ draft: nwView.getAddChannelDraft()?.phase, phases: nwEvents.map(payload => payload.phase) }))
@@ -4817,6 +5019,7 @@
4817
5019
  nwDraftMode = 'commit'
4818
5020
  nwEvents.length = 0
4819
5021
  const nwLinesBefore2 = nwView.getOptions().lines.length
5022
+ const nwSelectedBeforeInside = nwView.getSelectionData().ids
4820
5023
  nwView.beginAddChannel()
4821
5024
  await settle(40)
4822
5025
  nwClick(nwScreenOf('nd'))
@@ -4824,7 +5027,7 @@
4824
5027
  nwClick(nwScreenOf('nb'))
4825
5028
  await settle(80)
4826
5029
  check('事件里 endAddChannel + addChannel:真实通道与回线落地、假通道不留', nwInside?.ok === true && nwChannel('nc9') !== undefined && nwLine('nl9') !== undefined && nwLine('nl10') !== undefined && nwView.getOptions().lines.length === nwLinesBefore2 + 2, JSON.stringify({ ok: nwInside?.ok, lines: nwView.getOptions().lines.map(item => item.id) }))
4827
- check('事件里落地之后草稿收掉、新回线选中', nwView.getAddChannelDraft() === null && JSON.stringify(nwView.getSelectionData().ids) === JSON.stringify(['nl9', 'nl10']), JSON.stringify(nwView.getSelectionData().ids))
5030
+ check('事件里落地之后草稿收掉、缺省不改选中集', nwView.getAddChannelDraft() === null && JSON.stringify(nwView.getSelectionData().ids) === JSON.stringify(nwSelectedBeforeInside), JSON.stringify({ before: nwSelectedBeforeInside, after: nwView.getSelectionData().ids }))
4828
5031
 
4829
5032
  // ⑩ select: true 与结束事件里的分组读数;提前结束用 cancelAddChannel
4830
5033
  nwDraftMode = null
@@ -4912,6 +5115,53 @@
4912
5115
  JSON.stringify({ removedChannels: cbManyRemoved.removedChannels?.map(item => item.id) }))
4913
5116
  nwView.destroy()
4914
5117
 
5118
+ /* 这一轮新增:getDirectConnections 只取传入节点集合内部的直接通道 / 线路 */
5119
+ const dcHost = document.createElement('div')
5120
+ dcHost.style.cssText = 'width:420px;height:280px'
5121
+ document.body.appendChild(dcHost)
5122
+ const dcView = createGisView(dcHost, {
5123
+ points: [
5124
+ { id: 'da', x: -120, y: -60 },
5125
+ { id: 'db', x: 120, y: -60 },
5126
+ { id: 'dc', x: 120, y: 60 },
5127
+ { id: 'dd', x: -120, y: 60 }
5128
+ ],
5129
+ lines: [
5130
+ { id: 'dc-ab-1', from: 'da', to: 'db' },
5131
+ { id: 'dc-ab-2', from: 'db', to: 'da' },
5132
+ { id: 'dc-bc', from: 'db', to: 'dc' },
5133
+ { id: 'dc-cd', from: 'dc', to: 'dd' },
5134
+ { id: 'dc-loop', from: 'da', to: 'da' },
5135
+ { id: 'dc-free', path: [[0, 0], [40, 40]] }
5136
+ ],
5137
+ channels: [
5138
+ { id: 'dc-ch-ab', groups: [{ id: 'dc-ch-ab-g1', lineIds: ['dc-ab-1'] }] },
5139
+ { id: 'dc-ch-bc', from: 'db', to: 'dc', lineIds: ['dc-bc'] },
5140
+ { id: 'dc-ch-ad', from: 'da', to: 'dd', lineIds: ['dc-cd'] },
5141
+ { id: 'dc-ch-miss', from: 'da', to: 'missing', lineIds: [] },
5142
+ { id: 'dc-ch-free', groups: [{ id: 'dc-ch-free-g1', lineIds: ['dc-free'] }] }
5143
+ ],
5144
+ showGrid: false,
5145
+ fitPadding: 50
5146
+ })
5147
+ await settle(80)
5148
+ const dcAB = dcView.getDirectConnections(['da', 'db'])
5149
+ const dcAll = dcView.getDirectConnections(['da', 'db', 'dc'])
5150
+ check('getDirectConnections:只返回集合内部的直接通道 / 线路,通道与成员回线可同时命中',
5151
+ dcAB.map(item => item.type).join(',') === 'channel,line,line' && dcAB.map(item => item.channelId ?? item.lineId).join(',') === 'dc-ch-ab,dc-ab-1,dc-ab-2',
5152
+ JSON.stringify(dcAB.map(item => ({ type: item.type, id: item.channelId ?? item.lineId, from: item.from.id, to: item.to.id }))))
5153
+ check('getDirectConnections:通道端点可继承成员回线,线路保留声明方向与节点下标',
5154
+ dcAB[0].from.id === 'da' && dcAB[0].to.id === 'db' && JSON.stringify(dcAB[0].lineIds) === JSON.stringify(['dc-ab-1']) && dcAB[2].from.id === 'db' && dcAB[2].to.id === 'da' && dcAB[2].from.index === 1 && dcAB[2].to.index === 0,
5155
+ JSON.stringify({ channel: dcAB[0], reverse: dcAB[2] }))
5156
+ check('getDirectConnections:三个节点时连上集合内的第二条边,不把集合外的边一起带回来',
5157
+ dcAll.length === 5 && dcAll.some(item => item.type === 'channel' && item.channelId === 'dc-ch-bc') && dcAll.filter(item => item.type === 'line').map(item => item.lineId).join(',') === 'dc-ab-1,dc-ab-2,dc-bc',
5158
+ JSON.stringify(dcAll.map(item => ({ type: item.type, id: item.channelId ?? item.lineId }))))
5159
+ check('getDirectConnections:节点不足两个 / 有节点不存在 / 端点认不出 / 自环与未绑定线路都返回空表',
5160
+ dcView.getDirectConnections([]).length === 0 && dcView.getDirectConnections('da').length === 0 && dcView.getDirectConnections(['da', 'missing']).length === 0 && dcView.getDirectConnections(['da', 'db', 'da']).length === 3,
5161
+ JSON.stringify({ empty: dcView.getDirectConnections([]), missing: dcView.getDirectConnections(['da', 'missing']), duplicate: dcView.getDirectConnections(['da', 'db', 'da']).length }))
5162
+ dcView.destroy()
5163
+ dcHost.remove()
5164
+
4915
5165
  /* 这一轮新增:addLine / addChannel 第二段的裸 id 归一(见 15.98)+ 视口量不出来时挂起的落位请求封存的是「请求那一刻」的目标(见 16.68) */
4916
5166
  const bvHost = document.createElement('div')
4917
5167
  bvHost.style.cssText = 'width:520px;height:340px'
@@ -4938,10 +5188,10 @@
4938
5188
  check('addLine 不传 id:按 from-to-序号 自动编号(撞车由 uniqueLineId 往后排)',
4939
5189
  bvAuto.ok === true && String(bvAuto.lineId) === 'bb-bc-0',
4940
5190
  JSON.stringify({ ok: bvAuto.ok, lineId: bvAuto.lineId }))
4941
- const bvLegacy = bvView.addChannel({ from: 'ba', to: 'bb', circuits: 2 }, ['bare-4', 'bare-5'])
4942
- check('旧写法 addChannel 的 lineItems 同样认裸 id:给了两条就按两条建(circuits 忽略)',
4943
- bvLegacy.ok === true && JSON.stringify(bvLegacy.lineIds.map(String)) === JSON.stringify(['bare-4', 'bare-5']),
4944
- JSON.stringify({ ok: bvLegacy.ok, lineIds: bvLegacy.lineIds }))
5191
+ const bvChannel = bvView.addChannel({ from: 'ba', to: 'bb' }, ['bare-4', 'bare-5'])
5192
+ check('addChannel 第二段同样认裸 id:给了两条就建两条回线',
5193
+ bvChannel.ok === true && JSON.stringify(bvChannel.lineIds.map(String)) === JSON.stringify(['bare-4', 'bare-5']),
5194
+ JSON.stringify({ ok: bvChannel.ok, lineIds: bvChannel.lineIds }))
4945
5195
  bvView.destroy()
4946
5196
  bvHost.remove()
4947
5197
  // 容器先 display:none(量不出视口)再显示:挂起的那次落位兑现的是「请求那一刻」的范围,中途新加的远处节点不算进来
@@ -4993,7 +5243,96 @@
4993
5243
  JSON.stringify(dfView.getCamera()))
4994
5244
  dfView.destroy()
4995
5245
  dfHost.remove()
4996
-
5246
+
5247
+ /* 新增接口:选中由 options.select 显式打开;addLine 一步撤销;有通道声明时仍走追加式补线 */
5248
+ const awHost = document.createElement('div')
5249
+ awHost.style.cssText = 'width:520px;height:340px'
5250
+ document.body.appendChild(awHost)
5251
+ const awSelectChanges = []
5252
+ const awView = createGisView(awHost, {
5253
+ points: [
5254
+ { id: 'aa', x: -180, y: -100 },
5255
+ { id: 'ab', x: -90, y: -100 },
5256
+ { id: 'ac', x: 0, y: -100 },
5257
+ { id: 'ad', x: 90, y: -100 },
5258
+ { id: 'ae', x: 0, y: 100 },
5259
+ { id: 'af', x: 180, y: 100 }
5260
+ ],
5261
+ lines: [],
5262
+ channels: [],
5263
+ features: { channelDraft: true },
5264
+ history: true,
5265
+ showGrid: false,
5266
+ onSelectChange: event => awSelectChanges.push(event)
5267
+ })
5268
+ await settle(60)
5269
+ const awIds = () => awView.getSelectionData().ids
5270
+ const awLine = id => awView.getOptions().lines.find(item => String(item.id) === String(id))
5271
+ const awInitialChanges = awSelectChanges.length
5272
+ const awPointPlain = awView.addPoint({ id: 'aw-p1', x: 40, y: 30 })
5273
+ check('addPoint 缺省不改变选中集', awPointPlain.ok === true && awIds().length === 0 && awSelectChanges.length === awInitialChanges, JSON.stringify({ ids: awIds(), changes: awSelectChanges.length - awInitialChanges }))
5274
+ const awPointsSelected = awView.addPoints([{ id: 'aw-p2', x: 50, y: 35 }, { id: 'aw-p3', x: 60, y: 40 }], { select: true })
5275
+ check('addPoints({ select: true }):一次选中这批新节点', awPointsSelected.ok === true && JSON.stringify(awIds()) === JSON.stringify(['aw-p2', 'aw-p3']), JSON.stringify(awIds()))
5276
+ awView.selectFeatures(null)
5277
+ const awLinesPlain = awView.addLines([{ id: 'aw-l1', from: 'aa', to: 'ab' }])
5278
+ check('addLines 缺省不改变选中集', awLinesPlain.ok === true && awIds().length === 0, JSON.stringify(awIds()))
5279
+ const awLinesSelected = awView.addLines([{ id: 'aw-l2', from: 'ac', to: 'ad' }, { id: 'aw-l3', from: 'ae', to: 'af' }], { select: true })
5280
+ check('addLines({ select: true }):一次选中这批新线路', awLinesSelected.ok === true && JSON.stringify(awIds()) === JSON.stringify(['aw-l2', 'aw-l3']), JSON.stringify(awIds()))
5281
+ awView.selectFeatures(null)
5282
+ const awLinePlain = awView.addLine({ from: 'aa', to: 'ac', id: 'aw-l4' })
5283
+ check('addLine 缺省不改变选中集', awLinePlain.ok === true && awIds().length === 0, JSON.stringify(awIds()))
5284
+ const awLineSelected = awView.addLine({ from: 'ae', to: 'af', id: 'aw-l5' }, undefined, { select: true })
5285
+ check('addLine({ select: true }):只选中新增回线', awLineSelected.ok === true && JSON.stringify(awIds()) === JSON.stringify(['aw-l5']), JSON.stringify(awIds()))
5286
+ awView.selectFeatures(null)
5287
+ const awChannelPlain = awView.addChannel({ from: 'aa', to: 'ab', id: 'aw-c1' }, ['aw-c1-1'])
5288
+ check('addChannel 缺省不改变选中集', awChannelPlain.ok === true && awIds().length === 0, JSON.stringify(awIds()))
5289
+ const awChannelSelected = awView.addChannel({ from: 'ab', to: 'ad', id: 'aw-c2' }, ['aw-c2-1', 'aw-c2-2'], undefined, { select: true })
5290
+ check('addChannel({ select: true }):只选中这次新建的全部回线', awChannelSelected.ok === true && JSON.stringify(awIds()) === JSON.stringify(['aw-c2-1', 'aw-c2-2']), JSON.stringify(awIds()))
5291
+ const awLineLegacy = awView.addLine({ from: 'ac', to: 'af', circuits: 2 })
5292
+ check('addLine 旧 circuits 入参已移除:明确失败且不写数据', awLineLegacy.ok === false && String(awLineLegacy.reason).includes('circuits') && awLine('ac-af-0') === undefined, JSON.stringify({ ok: awLineLegacy.ok, reason: awLineLegacy.reason }))
5293
+ const awCommitLegacy = awView.commitAddChannel({ lineItems: [{ id: 'aw-old-commit' }] })
5294
+ check('commitAddChannel 旧 lineItems 入参已移除:明确失败且不写数据', awCommitLegacy.ok === false && String(awCommitLegacy.reason).includes('lineItems') && awLine('aw-old-commit') === undefined, JSON.stringify({ ok: awCommitLegacy.ok, reason: awCommitLegacy.reason }))
5295
+ const awChannelGroupsBefore = JSON.stringify(awView.getOptions().channels)
5296
+ const awLineCountBeforeGroups = awView.getOptions().lines.length
5297
+ const awGroupLegacy = awView.addChannelGroups('aw-c2', [{ lineData: [{ id: 'aw-group-new' }], lineItems: [{ id: 'aw-group-old' }] }])
5298
+ check('addChannelGroups 旧 lineItems 入参已移除:整组拒绝、不改数据', awGroupLegacy.ok === false && String(awGroupLegacy.reason).includes('lineItems') && JSON.stringify(awView.getOptions().channels) === awChannelGroupsBefore && awView.getOptions().lines.length === awLineCountBeforeGroups, JSON.stringify({ ok: awGroupLegacy.ok, reason: awGroupLegacy.reason }))
5299
+
5300
+ awView.beginAddChannel({ kind: 'line' })
5301
+ await settle(20)
5302
+ const awDraftKind = awView.getAddChannelDraft()?.kind
5303
+ awView.continueAddChannel({ kind: 'line' })
5304
+ await settle(20)
5305
+ const awContinuedKind = awView.getAddChannelDraft()?.kind
5306
+ awView.cancelAddChannel()
5307
+ awView.beginAddLine({ kind: 'channel' })
5308
+ await settle(20)
5309
+ const awLineDraftKind = awView.getAddChannelDraft()?.kind
5310
+ awView.cancelAddChannel()
5311
+ check('beginAddChannel 固定通道语义:运行时 kind: line 不再切换入口', awDraftKind === 'channel', JSON.stringify({ kind: awDraftKind }))
5312
+ check('continueAddChannel 不切换入口语义:通道草稿传 kind: line 仍是通道', awContinuedKind === 'channel', JSON.stringify({ kind: awContinuedKind }))
5313
+ check('beginAddLine 固定线路语义:运行时 kind: channel 不再切换入口', awLineDraftKind === 'line', JSON.stringify({ kind: awLineDraftKind }))
5314
+
5315
+ const awChannelsBeforeLine = JSON.stringify(awView.getOptions().channels)
5316
+ awView.clearHistory()
5317
+ const awHistoryLine = awView.addLine({ from: 'aa', to: 'af', id: 'aw-history' })
5318
+ await settle(20)
5319
+ const awCenter = awView.project(0, 0)
5320
+ const awHistoryHit = awView.pick(awCenter[0], awCenter[1])
5321
+ const awHistoryState = awView.getHistoryState()
5322
+ const awUndo = awView.undo()
5323
+ await settle(20)
5324
+ const awUndoHit = awView.pick(awCenter[0], awCenter[1])
5325
+ const awChannelsAfterUndo = JSON.stringify(awView.getOptions().channels)
5326
+ check('addLine 有通道声明时仍可落地与拾取,通道成员 / 分组不变', awHistoryLine.ok === true && awHistoryHit?.item?.id === 'aw-history' && awChannelsBeforeLine === JSON.stringify(awView.getOptions().channels), JSON.stringify({ hit: awHistoryHit?.item?.id }))
5327
+ check('addLine 的撤销只记一步:一次 undo 就摘掉这条回线', awHistoryState.undoCount === 1 && awUndo?.type === 'addLine' && awLine('aw-history') === undefined && awUndoHit?.item?.id !== 'aw-history' && awChannelsAfterUndo === awChannelsBeforeLine, JSON.stringify({ before: awHistoryState.undoCount, undo: awUndo, hit: awUndoHit?.item?.id }))
5328
+ const awRedo = awView.redo()
5329
+ await settle(20)
5330
+ const awRemovedLine = awView.removeLine('aw-history')
5331
+ check('addLine 重做后再删除:只摘回线,通道声明保持不变', awRedo?.type === 'addLine' && awRemovedLine.ok === true && awLine('aw-history') === undefined && JSON.stringify(awView.getOptions().channels) === awChannelsBeforeLine, JSON.stringify({ redo: awRedo, removed: awRemovedLine.ok }))
5332
+ check('有通道时 addLine 仍返回独立线路读数(channelId / channelLineIds 为 null)', awHistoryLine.channelId === null && awHistoryLine.channelLineIds === null && awHistoryLine.kind === 'line', JSON.stringify({ channelId: awHistoryLine.channelId, channelLineIds: awHistoryLine.channelLineIds }))
5333
+ awView.destroy()
5334
+ awHost.remove()
5335
+
4997
5336
  nwHost.remove()
4998
5337
  /* 这一轮新增:组件方法审计(getPointStyle / updateCopyPoint / setCurrentChannelGroup(null) / options 透传 / getElementBounds / 拐点中间点口径 / minimap.setZoom / off / runChannelAction 认不出的动作) */
4999
5338
  const auHost = document.createElement('div')
@@ -5475,6 +5814,104 @@
5475
5814
  clearedLayers === 1 && draftView.getWebGLLayers().length === 0 && (draftView.getOptions().webglLayers ?? []).length === 0 && draftView.requestRender() === draftView,
5476
5815
  JSON.stringify({ cleared: clearedLayers, layers: draftView.getWebGLLayers().length, options: draftView.getOptions().webglLayers?.length }))
5477
5816
 
5817
+ /* beginAddLine / beginAddChannel:单一通道阻断、已有通道新分组 / 指定分组、beginAddLine 挂回线 */
5818
+ const bdHost = document.createElement('div')
5819
+ bdHost.style.cssText = 'width:520px;height:320px'
5820
+ document.body.append(bdHost)
5821
+ const bdEvents = []
5822
+ const bdView = createGisView(bdHost, {
5823
+ points: [
5824
+ { id: 'bda', x: 0, y: 0, name: 'A' },
5825
+ { id: 'bdb', x: 80, y: 0, name: 'B' },
5826
+ { id: 'bdc', x: 0, y: 70, name: 'C' }
5827
+ ],
5828
+ lines: [],
5829
+ fitPadding: 60,
5830
+ showGrid: false,
5831
+ features: { channelDraft: true },
5832
+ onChannelDraft: payload => bdEvents.push(payload)
5833
+ })
5834
+ await settle(120)
5835
+ const bdSeed = bdView.addChannel({ id: 'bd-ch', name: '已有通道', from: 'bda', to: 'bdb' }, [
5836
+ { id: 'bd-l1' },
5837
+ { id: 'bd-l2' }
5838
+ ], [
5839
+ { id: 'bd-g1', lineIds: ['bd-l1'] },
5840
+ { id: 'bd-g2', lineIds: ['bd-l2'] }
5841
+ ])
5842
+ check('beginAddChannel 前置:已有通道带两个命名分组,供 singleChannel / newGroup / joinGroup 测试', bdSeed.ok === true && bdView.getChannelGroups('bd-ch').length === 2, JSON.stringify({ ok: bdSeed.ok, groups: bdView.getChannelGroups('bd-ch').map(group => group.id) }))
5843
+ const bdCanvas = bdView.canvas
5844
+ const bdClick = async (id) => {
5845
+ const point = bdView.getData().points.find(item => item.id === id)
5846
+ const screen = bdView.project(point.x, point.y)
5847
+ const rect = bdCanvas.getBoundingClientRect()
5848
+ for (const type of ['pointerdown', 'pointerup']) {
5849
+ bdCanvas.dispatchEvent(new PointerEvent(type, {
5850
+ bubbles: true,
5851
+ cancelable: true,
5852
+ composed: true,
5853
+ pointerId: 31,
5854
+ pointerType: 'mouse',
5855
+ isPrimary: true,
5856
+ button: 0,
5857
+ clientX: rect.left + screen[0],
5858
+ clientY: rect.top + screen[1]
5859
+ }))
5860
+ }
5861
+ await settle(50)
5862
+ }
5863
+
5864
+ bdView.beginAddLine({ from: 'bda', singleChannel: true })
5865
+ const bdLineStart = bdView.getAddChannelDraft()
5866
+ await bdClick('bdb')
5867
+ const bdLineReady = bdView.getAddChannelDraft()
5868
+ const bdLineBefore = bdView.getData().lines.length
5869
+ const bdLineResult = bdView.commitAddChannel()
5870
+ check('beginAddLine 不把 singleChannel 当成挂通道开关:两端已有通道仍出独立线路草稿', bdLineStart?.kind === 'line' && bdLineStart.singleChannel === false && bdLineStart.existingChannel === null && bdLineReady?.blocked === false, JSON.stringify({ kind: bdLineStart?.kind, single: bdLineStart?.singleChannel, existing: bdLineStart?.existingChannel, readyBlocked: bdLineReady?.blocked }))
5871
+ check('beginAddLine 提交后只加独立线路,不碰已有通道', bdLineResult.ok === true && bdLineResult.kind === 'line' && bdView.getData().lines.length === bdLineBefore + 1 && bdView.getChannelGroups('bd-ch').length === 2, JSON.stringify({ kind: bdLineResult.kind, channelId: bdLineResult.channelId, groups: bdView.getChannelGroups('bd-ch').length }))
5872
+
5873
+ bdEvents.length = 0
5874
+ const bdBlockLines = bdView.getData().lines.length
5875
+ bdView.beginAddChannel({ from: 'bda', singleChannel: true })
5876
+ await bdClick('bdb')
5877
+ const bdBlockDraft = bdView.getAddChannelDraft()
5878
+ const bdBlockResult = bdView.commitAddChannel()
5879
+ const bdBlockEvent = bdEvents.find(payload => payload.phase === 'blocked')
5880
+ check('singleChannel:两端已有通道时 ready 直接 blocked,并给出 singleChannel 原因', bdBlockDraft?.blocked === true && bdBlockDraft.existingChannel === 'block' && /singleChannel/.test(bdBlockDraft.reason ?? ''), JSON.stringify({ blocked: bdBlockDraft?.blocked, existing: bdBlockDraft?.existingChannel, reason: bdBlockDraft?.reason }))
5881
+ check('singleChannel:隐式提交发 blocked 事件且不改数据,事件仍报 channel / 当前电路数', bdBlockResult.ok === false && bdBlockEvent?.kind === 'channel' && bdBlockEvent?.singleChannel === true && bdView.getData().lines.length === bdBlockLines, JSON.stringify({ result: bdBlockResult.ok, event: bdBlockEvent?.phase, kind: bdBlockEvent?.kind, lines: bdView.getData().lines.length }))
5882
+
5883
+ bdEvents.length = 0
5884
+ bdView.beginAddChannel({ from: 'bda', singleChannel: true, existingChannel: 'newGroup' })
5885
+ await bdClick('bdb')
5886
+ const bdNewGroupDraft = bdView.getAddChannelDraft()
5887
+ const bdNewGroupResult = bdView.commitAddChannel()
5888
+ check('existingChannel: newGroup 覆盖 singleChannel:已有通道新加一组回线', bdNewGroupDraft?.blocked === false && bdNewGroupDraft.preferred === 'newGroup' && bdNewGroupResult.ok === true && bdNewGroupResult.channelId === 'bd-ch' && bdView.getChannelGroups('bd-ch').length === 3, JSON.stringify({ preferred: bdNewGroupDraft?.preferred, ok: bdNewGroupResult.ok, groupId: bdNewGroupResult.groupId, groups: bdView.getChannelGroups('bd-ch').length }))
5889
+
5890
+ bdEvents.length = 0
5891
+ bdView.beginAddChannel({ from: 'bda', existingChannel: 'joinGroup', groupId: 'bd-g1' })
5892
+ await bdClick('bdb')
5893
+ const bdJoinDraft = bdView.getAddChannelDraft()
5894
+ const bdJoinBefore = bdView.getChannelGroups('bd-ch').find(group => group.id === 'bd-g1')?.lineIndexes.length ?? 0
5895
+ const bdJoinResult = bdView.commitAddChannel()
5896
+ const bdJoinAfter = bdView.getChannelGroups('bd-ch').find(group => group.id === 'bd-g1')?.lineIndexes.length ?? 0
5897
+ check('existingChannel: joinGroup + groupId:新回线落进指定分组,读数带回 targetGroupId', bdJoinDraft?.targetGroupId === 'bd-g1' && bdJoinResult.ok === true && bdJoinResult.groupId === 'bd-g1' && bdJoinAfter === bdJoinBefore + 1, JSON.stringify({ draftTarget: bdJoinDraft?.targetGroupId, groupId: bdJoinResult.groupId, before: bdJoinBefore, after: bdJoinAfter }))
5898
+
5899
+ bdEvents.length = 0
5900
+ bdView.beginAddLine({ from: 'bda', existingChannel: 'joinGroup', groupId: 'bd-g2' })
5901
+ await bdClick('bdb')
5902
+ const bdAttachDraft = bdView.getAddChannelDraft()
5903
+ const bdAttachChannels = bdView.getOptions().channels.length
5904
+ const bdAttachResult = bdView.commitAddChannel()
5905
+ check('beginAddLine + existingChannel: joinGroup:线路入口也能把新回线挂进已有分组(不新建通道)', bdAttachDraft?.kind === 'line' && bdAttachDraft.existingChannel === 'joinGroup' && bdAttachDraft.targetGroupId === 'bd-g2' && bdAttachResult.ok === true && bdAttachResult.kind === 'channel' && bdAttachResult.groupId === 'bd-g2' && bdView.getOptions().channels.length === bdAttachChannels, JSON.stringify({ draftKind: bdAttachDraft?.kind, target: bdAttachDraft?.targetGroupId, resultKind: bdAttachResult.kind, groupId: bdAttachResult.groupId, channels: bdView.getOptions().channels.length }))
5906
+
5907
+ bdView.beginAddChannel({ from: 'bda', existingChannel: 'auto' })
5908
+ await bdClick('bdb')
5909
+ const bdAutoDraft = bdView.getAddChannelDraft()
5910
+ check('existingChannel: auto:有组余量时优先推荐新分组', bdAutoDraft?.blocked === false && bdAutoDraft.preferred === 'newGroup' && bdAutoDraft.existingChannel === 'auto', JSON.stringify({ preferred: bdAutoDraft?.preferred, existing: bdAutoDraft?.existingChannel }))
5911
+ bdView.cancelAddChannel()
5912
+ bdView.destroy()
5913
+ bdHost.remove()
5914
+
5478
5915
  /* 隐藏 / 显示、节点拆分、通道变化对账:接口级自测 */
5479
5916
  const splitHost = document.createElement('div')
5480
5917
  splitHost.style.cssText = 'width:640px;height:380px'
@@ -5591,6 +6028,109 @@
5591
6028
  lineOnlyView.removeLine('lo-ab')
5592
6029
  const lineOnlyAfterDelete = lineOnlyView.getChangedLines().find(line => line.id === 'lo-ab')
5593
6030
  check('getChangedLines:只有节点和线路时独立对账,删线路后同步清账', lineOnlyInitial && Boolean(lineOnlyChange) && lineOnlyChange.fields.includes('name') && !lineOnlyAfterDelete, JSON.stringify({ initial: lineOnlyInitial, change: lineOnlyChange?.reasons, after: lineOnlyAfterDelete ?? null }))
6031
+ const autoSplitHost = document.createElement('div')
6032
+ autoSplitHost.style.cssText = 'width:480px;height:280px'
6033
+ document.body.append(autoSplitHost)
6034
+ const autoSplitView = createGisView(autoSplitHost, {
6035
+ points: [
6036
+ { id: 'as-hub', x: 0, y: 0 },
6037
+ { id: 'as-a', x: 8, y: 0 },
6038
+ { id: 'as-b', x: 0, y: 8 }
6039
+ ],
6040
+ lines: [
6041
+ { id: 'as-c1', from: 'as-hub', to: 'as-a' },
6042
+ { id: 'as-c2', from: 'as-hub', to: 'as-a' },
6043
+ { id: 'as-i1', from: 'as-hub', to: 'as-b' },
6044
+ { id: 'as-i2', from: 'as-hub', to: 'as-b' },
6045
+ { id: 'as-i3', from: 'as-hub', to: 'as-a' }
6046
+ ],
6047
+ channels: [{ id: 'as-ch', from: 'as-hub', to: 'as-a', lineIds: ['as-c1', 'as-c2'] }],
6048
+ showGrid: false
6049
+ })
6050
+ const autoSplit = autoSplitView.splitPoint('as-hub', [
6051
+ { x: -1, y: 0, name: '解除 A' },
6052
+ { x: 1, y: 0, name: '解除 B' }
6053
+ ], { autoLines: 'alternate' })
6054
+ const autoAssignSlot = id => autoSplit.lineAssignments.find(assignment => assignment.lineIds.includes(id))?.index
6055
+ const autoChannelSlotA = autoAssignSlot('as-c1')
6056
+ const autoChannelSlotB = autoAssignSlot('as-c2')
6057
+ const autoSplitChannel = autoSplitView.getOptions().channels.find(channel => channel.id === 'as-ch')
6058
+ const autoSplitNewPoint = autoSplit.pointIds[1]
6059
+ check('splitPoint({ autoLines: alternate }):按通道成组轮流分配,两条同通道回线不会拆到不同节点', autoSplit.ok === true && autoSplit.autoLines === 'alternate' && autoSplit.autoAssignedLines === 5 && autoChannelSlotA !== undefined && autoChannelSlotA === autoChannelSlotB, JSON.stringify({ autoLines: autoSplit.autoLines, assigned: autoSplit.autoAssignedLines, slots: [autoChannelSlotA, autoChannelSlotB], assignments: autoSplit.lineAssignments.map(item => `${item.index}:${item.lineIds.join('+')}`) }))
6060
+ check('splitPoint({ autoLines: alternate }):通道端点跟随整组成员,lineAssignments 报出每一份的线路条数', autoSplit.lineAssignments.length === 2 && autoSplit.lineAssignments.reduce((sum, item) => sum + item.lineCount, 0) === 5 && String(autoSplitChannel?.from) === String(autoSplit.pointIds[autoChannelSlotA]) && String(autoSplitChannel?.to) === 'as-a' && autoSplitNewPoint !== 'as-hub', JSON.stringify({ channelFrom: autoSplitChannel?.from, newPoint: autoSplitNewPoint, counts: autoSplit.lineAssignments.map(item => item.lineCount) }))
6061
+ autoSplitView.destroy()
6062
+ autoSplitHost.remove()
6063
+
6064
+ /* 首屏 center / scale 简写、比例 API 与空闲检测 */
6065
+ const cameraApiHost = document.createElement('div')
6066
+ cameraApiHost.style.cssText = 'width:480px;height:280px'
6067
+ document.body.append(cameraApiHost)
6068
+ const cameraApiView = createGisView(cameraApiHost, {
6069
+ points: [{ id: 'camera-api-a', x: 0, y: 0 }],
6070
+ center: [12, -8],
6071
+ scale: 3,
6072
+ showGrid: false
6073
+ })
6074
+ await settle()
6075
+ const shorthandCamera = cameraApiView.getCamera()
6076
+ check('创建时顶层 center / scale 直接决定首屏相机', Math.abs(shorthandCamera.center[0] - 12) < 0.01 && Math.abs(shorthandCamera.center[1] + 8) < 0.01 && Math.abs(shorthandCamera.scale - 3) < 0.001, JSON.stringify(shorthandCamera))
6077
+ check('getScale / setScale:读写同一份相机比例', Math.abs(cameraApiView.getScale() - 3) < 0.001 && cameraApiView.setScale(5, [100, 80]) === cameraApiView && Math.abs(cameraApiView.getScale() - 5) < 0.001 && Math.abs(cameraApiView.getCamera().scale - 5) < 0.001, JSON.stringify({ before: 3, after: cameraApiView.getScale() }))
6078
+ cameraApiView.updateOptions({ scale: 4 })
6079
+ cameraApiView.applyInitialView()
6080
+ const mergedScaleCamera = cameraApiView.getCamera()
6081
+ cameraApiView.updateOptions({ center: [20, -10] })
6082
+ cameraApiView.applyInitialView()
6083
+ const mergedInitialView = cameraApiView.getCamera()
6084
+ check('updateOptions 的 center / scale 简写合进同一份 initialView,不会互相冲掉', Math.abs(mergedScaleCamera.center[0] - 12) < 0.01 && Math.abs(mergedScaleCamera.center[1] + 8) < 0.01 && Math.abs(mergedScaleCamera.scale - 4) < 0.001 && Math.abs(mergedInitialView.center[0] - 20) < 0.01 && Math.abs(mergedInitialView.center[1] + 10) < 0.01 && Math.abs(mergedInitialView.scale - 4) < 0.001, JSON.stringify({ afterScale: mergedScaleCamera, afterCenter: mergedInitialView }))
6085
+ cameraApiView.destroy()
6086
+ cameraApiHost.remove()
6087
+
6088
+ const precedenceHost = document.createElement('div')
6089
+ precedenceHost.style.cssText = 'width:480px;height:280px'
6090
+ document.body.append(precedenceHost)
6091
+ const precedenceView = createGisView(precedenceHost, {
6092
+ points: [{ id: 'precedence-a', x: 0, y: 0 }],
6093
+ center: [1, 2],
6094
+ scale: 9,
6095
+ initialView: { center: [30, 40], scale: 2 },
6096
+ showGrid: false
6097
+ })
6098
+ await settle()
6099
+ const precedenceCamera = precedenceView.getCamera()
6100
+ check('显式 initialView 整份优先于顶层 center / scale 简写', Math.abs(precedenceCamera.center[0] - 30) < 0.01 && Math.abs(precedenceCamera.center[1] - 40) < 0.01 && Math.abs(precedenceCamera.scale - 2) < 0.001, JSON.stringify(precedenceCamera))
6101
+ precedenceView.destroy()
6102
+ precedenceHost.remove()
6103
+
6104
+ const idleHost = document.createElement('div')
6105
+ idleHost.style.cssText = 'width:480px;height:280px'
6106
+ document.body.append(idleHost)
6107
+ const idleCalls = []
6108
+ const idleBusCalls = []
6109
+ const idleView = createGisView(idleHost, {
6110
+ points: [{ id: 'idle-a', x: 0, y: 0 }],
6111
+ idleTimeout: 45,
6112
+ onIdle: payload => idleCalls.push(payload),
6113
+ showGrid: false
6114
+ })
6115
+ idleView.on('idle', payload => idleBusCalls.push(payload))
6116
+ const idleStartedAt = idleView.getIdleTime()
6117
+ await settleMs(110)
6118
+ const firstIdle = idleCalls[0]
6119
+ const afterFirstIdle = idleView.getIdleTime()
6120
+ await settleMs(80)
6121
+ check('idleTimeout:到点只回调一次,getIdleTime 持续增长', idleStartedAt >= 0 && idleCalls.length === 1 && idleBusCalls.length === 1 && firstIdle?.timeout === 45 && firstIdle?.idleTime >= 40 && afterFirstIdle >= firstIdle?.idleTime, JSON.stringify({ start: idleStartedAt, after: afterFirstIdle, calls: idleCalls.length, bus: idleBusCalls.length, payload: firstIdle }))
6122
+ check('idleTimeout:同一空闲段不重复触发', idleCalls.length === 1 && idleBusCalls.length === 1, JSON.stringify({ calls: idleCalls.length, bus: idleBusCalls.length }))
6123
+ const idleBox = idleView.canvas.getBoundingClientRect()
6124
+ idleView.canvas.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 31, pointerType: 'mouse', clientX: idleBox.left + 20, clientY: idleBox.top + 20 }))
6125
+ const afterActivity = idleView.getIdleTime()
6126
+ await settleMs(110)
6127
+ check('idleTimeout:用户操作重置 getIdleTime,并开启下一段空闲触发', afterActivity < 40 && idleCalls.length === 2 && idleBusCalls.length === 2, JSON.stringify({ afterActivity, calls: idleCalls.length, bus: idleBusCalls.length }))
6128
+ idleView.destroy()
6129
+ const idleBeforeDestroyWait = idleCalls.length
6130
+ await settleMs(80)
6131
+ check('destroy 后清除 idle 定时器,不再触发回调', idleCalls.length === idleBeforeDestroyWait, JSON.stringify({ before: idleBeforeDestroyWait, after: idleCalls.length }))
6132
+ idleHost.remove()
6133
+
5594
6134
  lineOnlyView.destroy()
5595
6135
  lineOnlyHost.remove()
5596
6136
  splitView.destroy()