gis-view-webgl2 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +40 -8
  2. package/dist/gis-view.css +1 -1
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.d.ts +1036 -249
  5. package/dist/index.js +1 -1
  6. package/docs/AI/345/257/271/350/257/235/346/261/207/346/200/273.md +194 -0
  7. package/docs/GisView.md +9 -8
  8. package/docs/GisView/345/256/236/347/216/260/345/216/237/347/220/206.md +2 -2
  9. package/docs/ai/06-/344/274/232/350/257/235/347/264/242/345/274/225/344/270/216/345/244/215/346/237/245/346/226/271/346/263/225.md +172 -0
  10. package/docs/api/index.html +545 -59
  11. package/docs/demos/BackendSyncDemo.vue +10 -8
  12. package/docs/demos/BasicDemo.vue +101 -101
  13. package/docs/demos/BatchAddDemo.vue +5 -5
  14. package/docs/demos/ChannelDemo.vue +617 -10
  15. package/docs/demos/ConnectDemo.vue +392 -0
  16. package/docs/demos/CopyMergeDemo.vue +980 -929
  17. package/docs/demos/DecorationDraftDemo.vue +1300 -0
  18. package/docs/demos/DemoShell.vue +569 -240
  19. package/docs/demos/DragDropDemo.vue +11 -11
  20. package/docs/demos/EffectsDemo.vue +6 -6
  21. package/docs/demos/EventDemo.vue +327 -327
  22. package/docs/demos/ExtensionDemo.vue +458 -0
  23. package/docs/demos/GisViewDemo.vue +61 -48
  24. package/docs/demos/GraphicDemo.vue +338 -0
  25. package/docs/demos/LabelDemo.vue +553 -497
  26. package/docs/demos/MinimapDemo.vue +358 -0
  27. package/docs/demos/PerfDemo.vue +29 -0
  28. package/docs/demos/SearchFilterDemo.vue +412 -0
  29. package/docs/demos/StyleDemo.vue +121 -121
  30. package/docs/demos/api-index.js +98 -77
  31. package/docs/demos/demos.js +159 -135
  32. package/docs/demos/shared/demoData.js +107 -107
  33. package/docs/demos/shared/demoStyle.css +249 -249
  34. package/docs/demos/shared/fakeBackend.js +93 -6
  35. package/docs/format/03-/346/213/220/347/202/271/350/243/205/351/245/260/344/270/216/346/240/267/345/274/217.md +28 -8
  36. package/docs/format/04-/351/200/232/351/201/223/344/270/216/346/240/207/347/255/276.md +5 -4
  37. package/docs/format/06-/345/257/274/345/205/245/345/257/274/345/207/272/344/270/216/346/240/207/345/207/206/346/226/207/346/241/243.md +8 -6
  38. package/docs/format/07-/344/272/213/344/273/266/350/275/275/350/215/267/344/270/216/347/244/272/344/276/213/346/225/260/346/215/256.md +6 -6
  39. package/docs/guide/sample-document.html +4 -4
  40. package/docs/index.html +180 -9
  41. package/docs/internals/11-/350/246/206/347/233/226/345/261/202/346/240/207/347/255/276/344/270/216/346/217/220/347/244/272.md +1 -1
  42. package/docs/internals/13-/351/271/260/347/234/274/345/233/276.md +3 -2
  43. package/docs/internals/15-03-/351/200/220/350/275/256/350/256/260/345/275/225-/346/200/247/350/203/275/344/270/216/344/277/256/345/244/215-/344/270/213.md +16 -0
  44. package/docs/internals/16-03-/344/274/230/345/214/226/344/270/216/345/217/226/350/210/215-/344/270/213.md +11 -0
  45. package/docs/internals/17-/350/260/203/350/257/225/344/270/216/346/211/251/345/261/225/345/273/272/350/256/256.md +1 -1
  46. package/docs/manual/02-/345/235/220/346/240/207/344/270/216/346/225/260/346/215/256.md +18 -4
  47. package/docs/manual/03-/351/205/215/347/275/256/351/241/271.md +110 -25
  48. package/docs/manual/04-/344/272/213/344/273/266/344/270/216/345/233/236/350/260/203.md +13 -8
  49. package/docs/manual/06-/346/225/260/346/215/256/344/270/216/345/234/272/346/231/257/346/233/264/346/226/260.md +111 -10
  50. package/docs/manual/07-/346/214/207/351/222/210/344/270/216/350/246/201/347/264/240/347/274/226/350/276/221.md +47 -9
  51. package/docs/manual/08-/351/200/211/344/270/255/344/270/216/346/213/220/347/202/271.md +17 -15
  52. package/docs/manual/09-/351/200/232/351/201/223/344/270/216/345/244/232/345/233/236/347/272/277.md +61 -11
  53. package/docs/manual/10-/351/200/232/351/201/223/347/274/226/350/276/221/344/270/216/346/226/260/345/242/236.md +74 -7
  54. package/docs/manual/11-/346/240/267/345/274/217/344/270/216/350/247/204/345/210/231.md +3 -2
  55. package/docs/manual/12-/346/240/207/347/255/276/344/270/216/350/243/205/351/245/260.md +260 -3
  56. package/docs/manual/13-/345/212/250/346/225/210/344/270/216/345/233/276/345/205/203.md +6 -5
  57. package/docs/manual/14-/350/247/206/345/233/276/344/270/216/347/255/233/351/200/211.md +27 -14
  58. package/docs/manual/15-/345/257/274/345/207/272/344/270/216/351/233/206/346/210/220.md +142 -49
  59. package/docs/manual/16-/345/256/236/344/276/213/346/226/271/346/263/225/346/200/273/350/241/250.md +31 -29
  60. package/docs/manual/17-/345/217/202/346/225/260/351/200/237/346/237/245.md +55 -39
  61. package/docs/manual/18-/351/200/220/346/226/271/346/263/225/347/244/272/344/276/213.md +189 -64
  62. package/docs/manual/19-/350/257/273/346/225/260/344/270/216/346/213/226/346/213/275/351/242/204/350/247/210.md +4 -4
  63. package/docs/manual/20-/346/200/247/350/203/275/344/270/216/345/244/247/346/225/260/346/215/256.md +42 -2
  64. package/docs/manual/21-/350/257/264/346/230/216/344/270/216/345/270/270/350/247/201/351/227/256/351/242/230.md +22 -6
  65. package/docs/playground/index.html +5 -5
  66. package/docs/steps/README.md +14 -10
  67. package/docs/steps/check-doc-truth.mjs +1 -1
  68. package/docs/steps/selftest-actions.html +10 -10
  69. package/docs/steps/selftest-data.html +96 -14
  70. package/docs/steps/selftest-decoration.html +577 -0
  71. package/docs/steps/selftest-demo.html +6 -5
  72. package/docs/steps/selftest-demos.html +2201 -1612
  73. package/docs/steps/selftest-docs.html +89 -16
  74. package/docs/steps/selftest-effects.html +5 -5
  75. package/docs/steps/selftest-features.html +757 -86
  76. package/docs/steps/selftest-full.html +583 -583
  77. package/docs/steps/selftest-labels.html +1034 -652
  78. package/docs/steps/selftest-perf.html +1899 -1844
  79. package/docs/steps/selftest-scale.html +117 -7
  80. package/docs/steps/selftest-select.html +24 -17
  81. package/docs/steps/step-10-full.js +1 -1
  82. package/docs//346/216/245/345/217/243/345/215/217/350/256/256.md +167 -17
  83. package/docs//346/225/260/346/215/256/346/240/274/345/274/217.md +1 -1
  84. package/package.json +43 -43
  85. package/docs/demos/HistoryDemo.vue +0 -230
  86. package/docs/demos/InterfaceDemo.vue +0 -309
  87. package/docs/demos/SampleDemo.vue +0 -283
@@ -983,7 +983,7 @@
983
983
 
984
984
  /* 快捷键:Ctrl+C / Ctrl+V 与默认框选键 */
985
985
  cmEvents.length = 0
986
- cmView.select('a')
986
+ cmView.selectFeatures('a')
987
987
  cmView.el.dispatchEvent(new KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true }))
988
988
  check('Ctrl+C 把选中的节点收进剪贴板(默认快捷键)', cmView.getPointClipboard()?.item.id === 'a', cmView.getPointClipboard()?.item.id)
989
989
  check('Ctrl+C 命中 onPointCopy 的 clipboard 载荷(copy + keyboard + shortcuts 三件套)', cmShortcutClipboard?.phase === 'clipboard' && cmShortcutClipboard?.via === 'shortcut' && cmShortcutClipboard?.count === 1 && cmShortcutClipboard?.item?.id === 'a', JSON.stringify({ phase: cmShortcutClipboard?.phase, via: cmShortcutClipboard?.via, count: cmShortcutClipboard?.count, item: cmShortcutClipboard?.item?.id }))
@@ -998,13 +998,13 @@
998
998
  cmView.fitView()
999
999
  await settle(30)
1000
1000
  const cmAnchor = cmView.project(0, 0)
1001
- cmView.select(null)
1001
+ cmView.selectFeatures(null)
1002
1002
  await cmDrag(cmView, [cmAnchor[0] - 26, cmAnchor[1] - 26], [cmAnchor[0] + 26, cmAnchor[1] + 26], { ctrlKey: true })
1003
1003
  check('默认框选键是 Shift 时 Ctrl 拖动不再框选', cmView.getSelections().length === 0, `选中 ${cmView.getSelections().length}`)
1004
1004
  cmView.fitView()
1005
1005
  await settle(30)
1006
1006
  const cmAnchor2 = cmView.project(0, 0)
1007
- cmView.select(null)
1007
+ cmView.selectFeatures(null)
1008
1008
  await cmDrag(cmView, [cmAnchor2[0] - 26, cmAnchor2[1] - 26], [cmAnchor2[0] + 26, cmAnchor2[1] + 26], { shiftKey: true })
1009
1009
  check('按住新的框选键(Shift)拖动出选框并选中节点', cmView.getSelections().length >= 1 && cmView.getSelections().some(item => item.item.id === 'a'), `选中 ${cmView.getSelections().map(item => item.item.id).join(',')}`)
1010
1010
  cmView.updateOptions({ shortcuts: { boxSelect: 'ctrl' } })
@@ -1249,6 +1249,47 @@
1249
1249
  check('mergePoints 找不到节点:ok: false + 一条失败事件', cmFail.ok === false && cmMergeEvents5[cmMergeEvents5.length - 1].phase === 'merge' && cmMergeEvents5[cmMergeEvents5.length - 1].ok === false && cmView5.getPointMerge() === null, JSON.stringify(cmFail).slice(0, 160))
1250
1250
  cmView5.destroy()
1251
1251
  cmHost5.remove()
1252
+
1253
+ /* 合并触发时机:碰撞(两个半径相碰)/ 中心点进入目标节点区域 */
1254
+ const triggerHost = document.createElement('div')
1255
+ triggerHost.style.cssText = 'width:520px;height:300px'
1256
+ document.body.appendChild(triggerHost)
1257
+ const triggerEvents = []
1258
+ const triggerView = createGisView(triggerHost, {
1259
+ points: [{ id: 'source', x: 0, y: 0, size: 12 }, { id: 'target', x: 140, y: 0, size: 12 }],
1260
+ fitPadding: 70,
1261
+ showGrid: false,
1262
+ merge: { trigger: 'center', confirm: false },
1263
+ draggable: true,
1264
+ onPointMerge: payload => triggerEvents.push(payload)
1265
+ })
1266
+ await settle(120)
1267
+ const triggerTarget = triggerView.project(140, 0)
1268
+ await cmDrag(triggerView, triggerView.project(0, 0), [triggerTarget[0] - 10, triggerTarget[1]])
1269
+ check('merge.trigger=center:中心还差 10px 没进入目标区域时不触发(同样距离下碰撞档会触发)', triggerEvents.length === 0 && triggerView.getData().points.length === 2, JSON.stringify({ events: triggerEvents.map(item => item.phase), points: triggerView.getData().points.map(item => item.id) }))
1270
+ const triggerSource = triggerView.getData().points.find(point => point.id === 'source')
1271
+ await cmDrag(triggerView, triggerView.project(triggerSource.x, triggerSource.y), triggerTarget)
1272
+ check('merge.trigger=center:中心进入目标区域后触发,载荷带 trigger=center', triggerView.getData().points.length === 1 && triggerView.getData().points[0].id === 'target' && triggerEvents[0]?.trigger === 'center', JSON.stringify({ points: triggerView.getData().points.map(item => item.id), trigger: triggerEvents[0]?.trigger }))
1273
+ triggerView.destroy()
1274
+ triggerHost.remove()
1275
+
1276
+ const collisionHost = document.createElement('div')
1277
+ collisionHost.style.cssText = 'width:520px;height:300px'
1278
+ document.body.appendChild(collisionHost)
1279
+ const collisionView = createGisView(collisionHost, {
1280
+ points: [{ id: 'source', x: 0, y: 0, size: 12 }, { id: 'target', x: 140, y: 0, size: 12 }],
1281
+ fitPadding: 70,
1282
+ showGrid: false,
1283
+ merge: { trigger: 'collision', confirm: false },
1284
+ draggable: true
1285
+ })
1286
+ await settle(120)
1287
+ const collisionTarget = collisionView.project(140, 0)
1288
+ await cmDrag(collisionView, collisionView.project(0, 0), [collisionTarget[0] - 10, collisionTarget[1]])
1289
+ check('merge.trigger=collision:中心还差 10px 时两个节点半径已经相碰,照旧触发', collisionView.getData().points.length === 1 && collisionView.getData().points[0].id === 'target', JSON.stringify(collisionView.getData().points.map(item => item.id)))
1290
+ collisionView.destroy()
1291
+ collisionHost.remove()
1292
+
1252
1293
  /* 合并 ④:框选 / 多选之后按接口一次并完(mergeSelectedPoints) */
1253
1294
  const msHost = document.createElement('div')
1254
1295
  msHost.style.cssText = 'width:560px;height:360px'
@@ -1288,7 +1329,7 @@
1288
1329
  })
1289
1330
  await settle(140)
1290
1331
  const msIds = () => msView.getData().points.map(point => point.id ?? '(无id)').join(',')
1291
- msView.select(['h', 'a', 'b', 'c'])
1332
+ msView.selectFeatures(['h', 'a', 'b', 'c'])
1292
1333
  await settle(30)
1293
1334
  const msBulk = msView.mergeSelectedPoints()
1294
1335
  check('框选 / 多选之后一步并完:自动挑「绑定线路最多」的节点当目标', msBulk.ok === true && msBulk.phase === 'mergeSelected' && msBulk.target.item.id === 'h' && msBulk.sourcesMerged === 3 && msBulk.skipped.length === 0, JSON.stringify({ target: msBulk.target?.item?.id, merged: msBulk.sourcesMerged, skipped: msBulk.skipped }))
@@ -1304,7 +1345,7 @@
1304
1345
  /* 相机移动不该收掉待确认的合并(小图拖动 / moveCenter 走的是同一条路) */
1305
1346
  msView.fitView()
1306
1347
  await settle(60)
1307
- msView.select(null)
1348
+ msView.selectFeatures(null)
1308
1349
  await cmDrag(msView, msView.project(100, 0), msView.project(0, 0))
1309
1350
  const msPend = msView.getPointMerge()
1310
1351
  check('把节点拖到目标上:进入待确认(读得到 pending 载荷)', msPend !== null && msPend.phase === 'state' && msPend.confirmed === false, JSON.stringify(msPend?.phase))
@@ -1319,16 +1360,16 @@
1319
1360
  const msWrapped = msView.batch(() => msView.mergeSelectedPoints({ target: 'h', sources: ['b', 'c'] }), { label: '批量合并' })
1320
1361
  const msWrappedUndo = msView.undo()
1321
1362
  check('batch 里调 mergeSelectedPoints:整批只占一步历史(撤销一次全回去)', msWrapped?.ok === true && msWrapped.sourcesMerged === 2 && msWrappedUndo?.label === '批量合并' && msIds() === 'h,a,b,c,lock,(无id)', JSON.stringify({ merged: msWrapped?.sourcesMerged, label: msWrappedUndo?.label }))
1322
- msView.select(null)
1363
+ msView.selectFeatures(null)
1323
1364
  const msEmpty = msView.mergeSelectedPoints()
1324
1365
  check('没选中节点:ok: false 并带回原因', msEmpty.ok === false && msEmpty.phase === 'mergeSelected' && msEmpty.reason === '没有选中的节点' && msEmpty.target === null, msEmpty.reason)
1325
- msView.select('h')
1366
+ msView.selectFeatures('h')
1326
1367
  const msOne = msView.mergeSelectedPoints()
1327
1368
  check('只选中一个节点:自动挑目标时要两个以上', msOne.ok === false && msOne.reason === '选中的节点不足两个', msOne.reason)
1328
- msView.select(['h', 'lock'])
1369
+ msView.selectFeatures(['h', 'lock'])
1329
1370
  const msAllLocked = msView.mergeSelectedPoints()
1330
1371
  check('选中里只剩 mergeable: false 的节点:一个都并不了', msAllLocked.ok === false && msAllLocked.reason === '可以合并的节点不足两个', msAllLocked.reason)
1331
- msView.select(['h', 'a', 'lock'])
1372
+ msView.selectFeatures(['h', 'a', 'lock'])
1332
1373
  const msSelective = msView.mergeSelectedPoints()
1333
1374
  check('多选里混着 mergeable: false:跳过它、其余照并(skipped 说明原因)', msSelective.ok === true && msSelective.sourcesMerged === 1 && msSelective.merged[0].source.item.id === 'a' && msSelective.skipped.length === 1 && msSelective.skipped[0].id === 'lock' && msSelective.skipped[0].reason === '标记了 mergeable: false', JSON.stringify({ merged: msSelective.sourcesMerged, skipped: msSelective.skipped }))
1334
1375
  check('只有一个来源时走普通的一次合并(数据变更不是 batch)', msChanges[msChanges.length - 1].type === 'mergePoints', msChanges[msChanges.length - 1].type)
@@ -1401,14 +1442,14 @@
1401
1442
  const cmCenter = cmLayerView.project(0, 0)
1402
1443
  const cmOverPixel = await cmPixelAt(cmLayerView, cmCenter)
1403
1444
  check('没选中时上层节点压在下面那个之上(按数据顺序画)', cmIsBlue(cmOverPixel), JSON.stringify(cmOverPixel))
1404
- cmLayerView.select('under')
1445
+ cmLayerView.selectFeatures('under')
1405
1446
  await settle(60)
1406
1447
  const cmRaisedPixel = await cmPixelAt(cmLayerView, cmCenter)
1407
1448
  check('选中的节点抬到最上层(与 zIndex / 数据顺序无关)', cmIsRed(cmRaisedPixel), JSON.stringify(cmRaisedPixel))
1408
- cmLayerView.select(null)
1449
+ cmLayerView.selectFeatures(null)
1409
1450
  cmLayerView.updateOptions({ selectionOnTop: false })
1410
1451
  await settle(60)
1411
- cmLayerView.select('under')
1452
+ cmLayerView.selectFeatures('under')
1412
1453
  await settle(60)
1413
1454
  const cmFlatPixel = await cmPixelAt(cmLayerView, cmCenter)
1414
1455
  check('selectionOnTop: false 时不抬层(仍然按数据顺序画)', cmIsBlue(cmFlatPixel), JSON.stringify(cmFlatPixel))
@@ -1464,11 +1505,11 @@
1464
1505
  const cmLineSpot = cmRaiseLineView.project(0, 0.6)
1465
1506
  const cmLineStillPixel = await cmPixelAt(cmRaiseLineView, cmLineSpot)
1466
1507
  check('两条线重叠处:后画的那条压在上面', cmIsBlue(cmLineStillPixel), JSON.stringify(cmLineStillPixel))
1467
- cmRaiseLineView.select('line-low')
1508
+ cmRaiseLineView.selectFeatures('line-low')
1468
1509
  await settle(90)
1469
1510
  const cmLineRaisePixel = await cmPixelAt(cmRaiseLineView, cmLineSpot)
1470
1511
  check('选中线路:抬到最上层(与数据顺序无关)', cmIsRed(cmLineRaisePixel), JSON.stringify(cmLineRaisePixel))
1471
- cmRaiseLineView.select(null)
1512
+ cmRaiseLineView.selectFeatures(null)
1472
1513
  await settle(90)
1473
1514
  const cmLineBackPixel = await cmPixelAt(cmRaiseLineView, cmLineSpot)
1474
1515
  check('取消选中:线路恢复原来的次序', cmIsBlue(cmLineBackPixel), JSON.stringify(cmLineBackPixel))
@@ -1504,7 +1545,7 @@
1504
1545
  cmChannelStillPixels.push(await cmPixelAt(cmRaiseChannelView, spot))
1505
1546
  }
1506
1547
  check('通道回线与另一条线交叉处:后画的普通线压在上面', cmChannelStillPixels.every(cmIsBlue), JSON.stringify(cmChannelStillPixels))
1507
- cmRaiseChannelView.select('ch-c1')
1548
+ cmRaiseChannelView.selectFeatures('ch-c1')
1508
1549
  await settle(120)
1509
1550
  const cmChannelRaisePixels = []
1510
1551
  for (const spot of cmChannelSpots) {
@@ -1747,7 +1788,7 @@
1747
1788
  // ② 整份换成新对象(等价于撤销 / 重新拉一份数据):收掉并补一条 cancel,载荷带 invalidated
1748
1789
  const pendSourceBefore = pendView.getData().points.find(point => point.id === 'a')
1749
1790
  const pendSourceX = pendSourceBefore.x
1750
- pendView.updatePoints(pendView.getData().points.map(point => ({ ...point })))
1791
+ pendView.updateData({ points: pendView.getData().points.map(point => ({ ...point })) })
1751
1792
  await settle(80)
1752
1793
  const pendCancel = pendMergeEvents[pendMergeEvents.length - 1]
1753
1794
  const pendSourceAfter = pendView.getData().points.find(point => point.id === 'a')
@@ -1859,11 +1900,11 @@
1859
1900
  check('updateOptions 发 onDataChange(changed 标出改的是哪一类)', evLog.data.length === 1 && evLog.data[0].type === 'updateOptions' && evLog.data[0].changed.points === false && evLog.data[0].changed.channels === true, evTypes())
1860
1901
 
1861
1902
  evLog.select.length = 0
1862
- evView.select('a')
1903
+ evView.selectFeatures('a')
1863
1904
  check('接口选中发 onSelectChange(cause 为 api)', evLog.select.length === 1 && evLog.select[0].cause === 'api' && evLog.select[0].count === 1 && evLog.select[0].ids[0] === 'a' && evLog.select[0].points === 1, JSON.stringify(evLog.select.map(entry => `${entry.cause}:${entry.count}`)))
1864
- evView.select('a')
1905
+ evView.selectFeatures('a')
1865
1906
  check('选中集没变就不再发 onSelectChange', evLog.select.length === 1, `次数 ${evLog.select.length}`)
1866
- evView.select(null)
1907
+ evView.selectFeatures(null)
1867
1908
  check('清空选中也发一条(count: 0 / cause: api)', evLog.select.length === 2 && evLog.select[1].count === 0 && evLog.select[1].cause === 'api', JSON.stringify(evLog.select.map(entry => entry.count)))
1868
1909
 
1869
1910
  evLog.shortcut.length = 0
@@ -1937,7 +1978,7 @@
1937
1978
  check('onChannelGroupChange 回调和事件总线收到同一份载荷', cgOption.length === 1 && cgOption[0] === cgBus[0])
1938
1979
 
1939
1980
  // 选中一条回线再合并:选中镜像里的 groupId / groupIndex 要跟着真值走(本轮修复的回归守卫)
1940
- cgView.select('c2')
1981
+ cgView.selectFeatures('c2')
1941
1982
  await settle(60)
1942
1983
  const cgTruth = () => {
1943
1984
  const groups = cgView.getChannelGroups('ch-1')
@@ -2005,8 +2046,8 @@
2005
2046
  ulHost.remove()
2006
2047
  /* 这一轮新增:通道改名 / 通道下增减回线 / 删除分组(问题.txt 第 2 条那几个「方法有吗」) */
2007
2048
  const cgNameBefore = cgData.length
2008
- cgView.setChannelName('ch-1', '甲通道')
2009
- check('setChannelName 改通道名称:数据副本落地 + 一条 updateChannel 变更', cgView.getOptions().channels[0].name === '甲通道' && cgData.length === cgNameBefore + 1 && cgData[cgData.length - 1].type === 'updateChannel', JSON.stringify({ name: cgView.getOptions().channels[0].name, type: cgData[cgData.length - 1]?.type }))
2049
+ cgView.updateChannel('ch-1', { name: '甲通道' })
2050
+ check('updateChannel({ name }) 改通道名称:数据副本落地 + 一条 updateChannel 变更', cgView.getOptions().channels[0].name === '甲通道' && cgData.length === cgNameBefore + 1 && cgData[cgData.length - 1].type === 'updateChannel', JSON.stringify({ name: cgView.getOptions().channels[0].name, type: cgData[cgData.length - 1]?.type }))
2010
2051
 
2011
2052
  cgView.addLine({ id: 'c3', from: 'ca', to: 'cb', color: '#8a5cf6' })
2012
2053
  const cgMemberCount = () => cgView.getChannels().reduce((total, state) => total + state.lineIndexes.length, 0)
@@ -2143,7 +2184,7 @@
2143
2184
  ucView.destroy()
2144
2185
  ucHost.remove()
2145
2186
 
2146
- /* 这一轮新增:改 lineIds 增删线路(有 groups 也生效)+ updateChannels 写回分组 id + 声明校验 warning */
2187
+ /* 这一轮新增:改 lineIds 增删线路(有 groups 也生效)+ updateData({ channels }) 写回分组 id + 声明校验 warning */
2147
2188
  const chHost = document.createElement('div')
2148
2189
  chHost.style.cssText = 'width:420px;height:280px'
2149
2190
  document.body.appendChild(chHost)
@@ -2164,6 +2205,10 @@
2164
2205
  await settle(150)
2165
2206
  const chGroups = () => chView.getChannelGroups('chA').map(group => ({ id: group.id, lineIds: group.lineIds }))
2166
2207
  const chMemberCount = () => chView.getChannels().reduce((total, state) => total + state.lineIndexes.length, 0)
2208
+ const chInView = chView.getChannelsInView()
2209
+ check('getChannelsInView 按视口单遍归并通道,回线与分组 id 都去重',
2210
+ chInView.length === 1 && chInView[0].channelId === 'chA' && chInView[0].lineIndexes.length === 2 && chInView[0].groupIds.length === 1,
2211
+ JSON.stringify(chInView))
2167
2212
 
2168
2213
  chView.updateChannel('chA', { groups: [{ id: 'ga', lineIds: ['k1'] }, { id: 'gb', lineIds: ['k2'] }] })
2169
2214
  await settle(60)
@@ -2191,16 +2236,16 @@
2191
2236
  check('updateChannel 找不到通道 / 没给补丁各报一句 warning,不静默空操作', chWarn.length === 2, JSON.stringify(chWarn))
2192
2237
 
2193
2238
  chWarn.length = 0
2194
- chView.updateChannels([{ id: 'chA', name: 'A 通道', groups: [{ lineIds: ['k3'] }, { lineIds: ['nope2'] }] }])
2239
+ chView.updateData({ channels: [{ id: 'chA', name: 'A 通道', groups: [{ lineIds: ['k3'] }, { lineIds: ['nope2'] }] }] })
2195
2240
  await settle(60)
2196
2241
  const chDeclared = chView.getOptions().channels[0].groups ?? []
2197
- check('updateChannels 把引擎补好的分组 id / 拐点路径写回声明(缺省 id 不再留在数据里)', chDeclared.length === 1 && typeof chDeclared[0].id === 'string' && chDeclared[0].id.length > 0 && Array.isArray(chDeclared[0].path) && JSON.stringify(chDeclared[0].lineIds) === JSON.stringify(['k3']), JSON.stringify(chDeclared))
2198
- check('updateChannels 里认不出的成员也报一句 warning', chWarn.length === 1 && String(chWarn[0]).includes('nope2'), JSON.stringify(chWarn))
2242
+ check('updateData({ channels }) 把引擎补好的分组 id / 拐点路径写回声明(缺省 id 不再留在数据里)', chDeclared.length === 1 && typeof chDeclared[0].id === 'string' && chDeclared[0].id.length > 0 && Array.isArray(chDeclared[0].path) && JSON.stringify(chDeclared[0].lineIds) === JSON.stringify(['k3']), JSON.stringify(chDeclared))
2243
+ check('updateData({ channels }) 里认不出的成员也报一句 warning', chWarn.length === 1 && String(chWarn[0]).includes('nope2'), JSON.stringify(chWarn))
2199
2244
  /* 这一轮新增:整份提交的几个入口也走同一份调和(lineIds 是成员整表),写回时成员表对齐到分组 */
2200
- chView.updateChannels([{ id: 'chA', lineIds: ['k1', 'k2'], groups: [{ id: 'ga', lineIds: ['k1'] }, { id: 'gb', lineIds: ['k2'] }, { id: 'gc', lineIds: ['k9'] }] }])
2245
+ chView.updateData({ channels: [{ id: 'chA', lineIds: ['k1', 'k2'], groups: [{ id: 'ga', lineIds: ['k1'] }, { id: 'gb', lineIds: ['k2'] }, { id: 'gc', lineIds: ['k9'] }] }] })
2201
2246
  await settle()
2202
2247
  const chWhole = chGroups()
2203
- check('updateChannels 整份提交也调和:声明里 lineIds 与 groups 冲突时按 lineIds 收窄(多出的组丢掉)', JSON.stringify(chWhole.map(group => group.lineIds)) === JSON.stringify([['k1'], ['k2']]) && chMemberCount() === 2, JSON.stringify({ groups: chWhole, members: chMemberCount() }))
2248
+ check('updateData({ channels }) 整份提交也调和:声明里 lineIds 与 groups 冲突时按 lineIds 收窄(多出的组丢掉)', JSON.stringify(chWhole.map(group => group.lineIds)) === JSON.stringify([['k1'], ['k2']]) && chMemberCount() === 2, JSON.stringify({ groups: chWhole, members: chMemberCount() }))
2204
2249
 
2205
2250
  chView.updateOptions({ channels: [{ id: 'chA', lineIds: ['k9'] }] })
2206
2251
  await settle()
@@ -2214,18 +2259,18 @@
2214
2259
  await settle()
2215
2260
  check('importData({ channels }) 导入的声明同样过调和(applied.channels 为真)', chImported.ok === true && chImported.applied.channels === true && chMemberCount() === 2, JSON.stringify({ ok: chImported.ok, applied: chImported.applied, members: chMemberCount() }))
2216
2261
 
2217
- chView.updateChannels([{ id: 'chA', groups: [{ id: 'ga', lineIds: ['k1'] }, { id: 'gb', lineIds: ['k9'] }] }])
2262
+ chView.updateData({ channels: [{ id: 'chA', groups: [{ id: 'ga', lineIds: ['k1'] }, { id: 'gb', lineIds: ['k9'] }] }] })
2218
2263
  await settle()
2219
2264
  const chOnlyGroups = chView.getOptions().channels[0]
2220
2265
  check('只写 groups 的声明原样保留(不补 lineIds,成员由 groups 决定)', chOnlyGroups.lineIds === undefined && chMemberCount() === 2 && chGroups().length === 2, JSON.stringify({ lineIds: chOnlyGroups.lineIds, members: chMemberCount(), groups: chGroups() }))
2221
2266
 
2222
- chView.updateChannels([{ id: 'chA', lineIds: ['k1'], groups: [{ id: 'ga', lineIds: ['k1'] }] }])
2267
+ chView.updateData({ channels: [{ id: 'chA', lineIds: ['k1'], groups: [{ id: 'ga', lineIds: ['k1'] }] }] })
2223
2268
  await settle()
2224
2269
  chView.updateChannel('chA', { groups: [{ id: 'ga', lineIds: ['k1'] }, { id: 'gb', lineIds: ['k9'] }] })
2225
2270
  await settle()
2226
2271
  check('写回时成员表跟着分组对齐:挪进分组的回线补进 lineIds(下一轮整份提交不会被收窄剔掉)', chMemberCount() === 2 && JSON.stringify(chView.getOptions().channels[0].lineIds) === JSON.stringify(['k1', 'k9']), JSON.stringify(chView.getOptions().channels[0]))
2227
2272
 
2228
- chView.updateChannels([JSON.parse(JSON.stringify(chView.getOptions().channels[0]))])
2273
+ chView.updateData({ channels: [JSON.parse(JSON.stringify(chView.getOptions().channels[0]))] })
2229
2274
  await settle()
2230
2275
  check('拆分 / 挪组之后把声明原样整份重提交:成员与分组一个都不丢', chMemberCount() === 2 && chGroups().length === 2, JSON.stringify({ groups: chGroups(), members: chMemberCount() }))
2231
2276
 
@@ -2471,8 +2516,8 @@
2471
2516
  mvView.undo()
2472
2517
  check('位置对账:撤销回到基线之后不再算「位置变过」', mvView.getMovedPoints().length === 0, JSON.stringify(mvView.getMovedPoints().map(item => item.id)))
2473
2518
  mvView.updatePoint('mc', { x: 900, y: 0 })
2474
- mvView.updatePoints(mvView.getData().points)
2475
- check('位置对账:整份换节点数据(updatePoints)按新数据对齐', mvView.getMovedPoints().length === 0, JSON.stringify(mvView.getMovedPoints().map(item => item.id)))
2519
+ mvView.updateData({ points: mvView.getData().points })
2520
+ check('位置对账:整份换节点数据(updateData({ points }))按新数据对齐', mvView.getMovedPoints().length === 0, JSON.stringify(mvView.getMovedPoints().map(item => item.id)))
2476
2521
  mvView.addPoints({ id: 'me', x: 600, y: 0 })
2477
2522
  const mvAfterAdd = mvView.getMovedPoints().length
2478
2523
  mvView.updatePoint('me', { x: 620, y: 20 })
@@ -2580,6 +2625,42 @@
2580
2625
  ftView.destroy()
2581
2626
  ftHost.remove()
2582
2627
 
2628
+ // 拐点只有一道总闸:waypoint / waypointEditable 关闭时新增、移动与删除一起停
2629
+ const waHost = document.createElement('div')
2630
+ waHost.style.cssText = 'width:320px;height:200px'
2631
+ document.body.appendChild(waHost)
2632
+ const waLog = []
2633
+ const waView = createGisView(waHost, {
2634
+ points: [{ id: 'a', x: 0, y: 0 }, { id: 'b', x: 100, y: 0 }],
2635
+ lines: [{ id: 'wa-line', from: 'a', to: 'b' }],
2636
+ showGrid: false,
2637
+ features: { waypoint: false },
2638
+ onWaypointChange: payload => waLog.push(payload)
2639
+ })
2640
+ await settle(120)
2641
+ const waState = waView.getFeatures()
2642
+ waView.addWaypoint('wa-line', [50, 10])
2643
+ await settle(60)
2644
+ check('waypoint:false 总闸关闭新增、且不再有 waypointAdd 细分开关',
2645
+ waState.waypoint === false && !Object.prototype.hasOwnProperty.call(waState, 'waypointAdd') && (waView.getData().lines[0].path ?? null) === null && waLog.length === 0,
2646
+ JSON.stringify({ waypoint: waState.waypoint, keys: Object.keys(waState), path: waView.getData().lines[0].path, events: waLog.length }))
2647
+ waView.setFeatures({ waypoint: true })
2648
+ await settle(60)
2649
+ waView.addWaypoint('wa-line', [50, 10])
2650
+ await settle(60)
2651
+ const waPath = waView.getData().lines[0].path
2652
+ check('打开 waypoint 之后 addWaypoint 正常插入并发 add',
2653
+ waView.getFeatures().waypoint === true && waPath?.length === 3 && waLog.length === 1 && waLog[0].phase === 'add',
2654
+ JSON.stringify({ waypoint: waView.getFeatures().waypoint, path: waPath, events: waLog.map(item => item.phase) }))
2655
+ waView.setFeatures({ waypoint: false })
2656
+ const waRemoved = waView.removeWaypoint('wa-line', 1)
2657
+ await settle(60)
2658
+ check('waypoint:false 同时关闭已有拐点的删除(接口空操作、数据不动)',
2659
+ waRemoved === waView && waView.getData().lines[0].path?.length === 3 && waLog.length === 1,
2660
+ JSON.stringify({ removed: waRemoved, path: waView.getData().lines[0].path, events: waLog.length }))
2661
+ waView.destroy()
2662
+ waHost.remove()
2663
+
2583
2664
  // 缺省不记历史:一个开关都不开时与从前完全一样
2584
2665
  const nhHost = document.createElement('div')
2585
2666
  nhHost.style.cssText = 'width:320px;height:200px'
@@ -2600,7 +2681,7 @@
2600
2681
  const stHost = document.createElement('div')
2601
2682
  stHost.style.cssText = 'width:320px;height:200px'
2602
2683
  document.body.appendChild(stHost)
2603
- const stView = createGisView(stHost, { points: [{ id: 'a', x: 0, y: 0 }, { id: 'b', x: 100, y: 40 }], features: 'static', showGrid: false })
2684
+ const stView = createGisView(stHost, { points: [{ id: 'a', x: 0, y: 0, name: '甲' }, { id: 'b', x: 100, y: 40 }], features: 'static', showGrid: false, label: { content: '{name}' } })
2604
2685
  await settle(150)
2605
2686
  const stScale = stView.getCamera().scale
2606
2687
  const stBox = stView.canvas.getBoundingClientRect()
@@ -2608,6 +2689,9 @@
2608
2689
  await settle(80)
2609
2690
  check('features 预设 static:滚轮不缩放', stView.getCamera().scale === stScale, `${stScale} -> ${stView.getCamera().scale}`)
2610
2691
  check('features 预设 static:指针事件照旧(getFeatures 里 pan / zoom / hover / select 全是 false)', stView.getFeatures().pan === false && stView.getFeatures().zoom === false && stView.getFeatures().hover === false && stView.getFeatures().select === false, JSON.stringify(stView.getFeatures()))
2692
+ check('features 预设 static:全部开关为 false,配了标签也不产生 DOM',
2693
+ Object.values(stView.getFeatures()).every(value => value === false) && stView.el.querySelectorAll('.gis-view__label-layer--point .gis-view__label').length === 0,
2694
+ JSON.stringify({ state: stView.getFeatures(), labels: stView.el.querySelectorAll('.gis-view__label-layer--point .gis-view__label').length }))
2611
2695
  stView.destroy()
2612
2696
  stHost.remove()
2613
2697
 
@@ -2753,7 +2837,7 @@
2753
2837
  const exEmpty = await exView.exportSelection({ format: 'data' })
2754
2838
  check('没选中节点就导出:ok: false + 原因,不抛异常', exEmpty.ok === false && /没有要导出的节点/.test(String(exEmpty.reason)) && exEmpty.points.length === 0 && exEmpty.image === null, exEmpty.reason)
2755
2839
  check('导出失败时点 / 线 / 下标都回空数组', exEmpty.pointIndexes.length === 0 && exEmpty.lineIndexes.length === 0 && exEmpty.box === null, JSON.stringify({ points: exEmpty.pointIndexes, lines: exEmpty.lineIndexes, box: exEmpty.box }))
2756
- exView.select(['ex-a', 'ex-b'])
2840
+ exView.selectFeatures(['ex-a', 'ex-b'])
2757
2841
  const exCamera = exView.getCamera()
2758
2842
  const exVisible = exView.listPoints({ visibleOnly: true }).length
2759
2843
  const exData = await exView.exportSelection({ format: 'data', withLines: true })
@@ -2922,14 +3006,14 @@
2922
3006
  rdView.on('selectChange', payload => rdSelects.push(payload))
2923
3007
  await rdClick(rdView.project(0, 0), { ctrlKey: true })
2924
3008
  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 }))
2925
- rdView.select(['rd-a', 'rd-b'])
3009
+ rdView.selectFeatures(['rd-a', 'rd-b'])
2926
3010
  await rdRightClick(rdView.project(320, 0))
2927
3011
  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 }))
2928
3012
  await rdRightClick(rdView.project(0, 0))
2929
3013
  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 }))
2930
3014
  const rdPointInfo = rdView.getSelectionData()
2931
3015
  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))
2932
- rdView.select(['rd-1', 'rd-2'])
3016
+ rdView.selectFeatures(['rd-1', 'rd-2'])
2933
3017
  const rdInfo = rdView.getSelectionData()
2934
3018
  check('getSelectionData:选中一条通道的两回线时 lines 两条、channels 去重成一个', rdInfo.count === 2 && rdInfo.lineCount === 2 && rdInfo.channelCount === 1 && rdInfo.lines.length === 2 && rdInfo.channels.length === 1 && rdInfo.channels[0].id === 'rd-ch' && rdInfo.lineIndexes.length === 2 && rdInfo.items.length === 2 && rdInfo.items[0].item === rdInfo.lines[0], JSON.stringify({ channels: rdInfo.channels.map(item => item.id), lineIndexes: rdInfo.lineIndexes }))
2935
3019
 
@@ -2960,7 +3044,8 @@
2960
3044
  channels: csmBuilt.channels,
2961
3045
  fitPadding: 60,
2962
3046
  showGrid: false,
2963
- // 点回线摊开选中集:点击选中显式打开
3047
+ // 点回线摊开选中集:先关拐点编辑,Ctrl 点击才能测「整批取反」;加点那一段再打开
3048
+ waypointEditable: false,
2964
3049
  features: { select: true }
2965
3050
  })
2966
3051
  await settle(140)
@@ -3001,9 +3086,99 @@
3001
3086
  // 通道外的线路没有分组可摊,照旧只选它自己
3002
3087
  await csmClick(csmView.project(100, 160))
3003
3088
  check('通道外的线路不受选中模式影响(还是只选自己)', csmIds() === 'csm-solo', csmIds())
3089
+ // Ctrl + 点击给通道回线加点:加完后的选中 / 高亮范围与普通单击共用 channelSelectMode
3090
+ csmView.updateOptions({ waypointEditable: true })
3091
+ await settle(60)
3092
+ const csmGroupSkeleton = () => csmView.getChannelGroups('csm-ch').find(group => String(group.id) === 'csm-g2')?.skeleton.length ?? 0
3093
+ const csmAddBefore = csmGroupSkeleton()
3094
+ csmView.updateOptions({ channelSelectMode: 'line' })
3095
+ await csmClick(csmView.project(50, 60), { ctrlKey: true })
3096
+ const csmLineAdd = csmView.getSelectionData()
3097
+ check('Ctrl + 点击加点(line):只高亮被加点的那一条回线', csmLineAdd.lineCount === 1 && csmGroupSkeleton() === csmAddBefore + 1 && csmIds() !== 'csm-2,csm-3' && csmIds() !== 'csm-1,csm-2,csm-3', JSON.stringify({ ids: csmIds(), waypoints: csmGroupSkeleton() }))
3098
+ csmView.updateOptions({ channelSelectMode: 'group' })
3099
+ await csmClick(csmView.project(100, 60), { ctrlKey: true })
3100
+ check('Ctrl + 点击加点(group):加完连所在分组一起高亮', csmIds() === 'csm-2,csm-3' && csmGroupSkeleton() === csmAddBefore + 2, JSON.stringify({ ids: csmIds(), waypoints: csmGroupSkeleton() }))
3101
+ csmView.updateOptions({ channelSelectMode: 'channel' })
3102
+ await csmClick(csmView.project(150, 60), { ctrlKey: true })
3103
+ check('Ctrl + 点击加点(channel):加完整条通道一起高亮', csmIds() === 'csm-1,csm-2,csm-3' && csmView.getSelectionData().channelCount === 1 && csmGroupSkeleton() === csmAddBefore + 3, JSON.stringify({ ids: csmIds(), waypoints: csmGroupSkeleton() }))
3004
3104
  csmView.destroy()
3005
3105
  csmHost.remove()
3006
3106
 
3107
+ /* 分组拐点 vertex 拖动:只平移当前分组同一 pathIndex 的顶点,其它拐点与分组不动 */
3108
+ const vwdHost = document.createElement('div')
3109
+ vwdHost.style.cssText = 'width:560px;height:360px'
3110
+ document.body.appendChild(vwdHost)
3111
+ const vwdView = createGisView(vwdHost, {
3112
+ points: [{ id: 'vwd-a', x: 0, y: 0 }, { id: 'vwd-b', x: 360, y: 0 }],
3113
+ lines: [
3114
+ { id: 'vwd-1', from: 'vwd-a', to: 'vwd-b' },
3115
+ { id: 'vwd-2', from: 'vwd-a', to: 'vwd-b' },
3116
+ { id: 'vwd-3', from: 'vwd-a', to: 'vwd-b' },
3117
+ { id: 'vwd-4', from: 'vwd-a', to: 'vwd-b' }
3118
+ ],
3119
+ channels: [{
3120
+ id: 'vwd-ch',
3121
+ from: 'vwd-a',
3122
+ to: 'vwd-b',
3123
+ groups: [
3124
+ { id: 'vwd-g1', lineIds: ['vwd-1', 'vwd-2'], path: [[100, 80], [260, 80]] },
3125
+ { id: 'vwd-g2', lineIds: ['vwd-3', 'vwd-4'], path: [[100, -80], [260, -80]], waypointOffsets: { '1': { x: 0, y: 6 } } }
3126
+ ]
3127
+ }],
3128
+ waypointEditable: true,
3129
+ waypointVisibility: 'always',
3130
+ channelWaypointDragMode: 'vertex',
3131
+ fitPadding: 70,
3132
+ showGrid: false
3133
+ })
3134
+ await settle(160)
3135
+ const vwdSend = (type, screen, extra = {}) => {
3136
+ const box = vwdView.canvas.getBoundingClientRect()
3137
+ vwdView.canvas.dispatchEvent(new PointerEvent(type, {
3138
+ bubbles: true,
3139
+ cancelable: true,
3140
+ pointerId: 79,
3141
+ pointerType: 'mouse',
3142
+ button: extra.button ?? 0,
3143
+ buttons: extra.buttons ?? (type === 'pointerup' ? 0 : 1),
3144
+ clientX: box.left + screen[0],
3145
+ clientY: box.top + screen[1],
3146
+ ...extra
3147
+ }))
3148
+ }
3149
+ const vwdBefore = vwdView.getChannelGroupWaypoints('vwd-ch', 'vwd-g1')
3150
+ const vwdOtherBefore = vwdView.getChannelGroupWaypoints('vwd-ch', 'vwd-g2')
3151
+ const vwdWorldStart = vwdBefore.lines[0].waypoints[0]
3152
+ const vwdWorldTarget = [vwdWorldStart[0] + 42, vwdWorldStart[1] + 30]
3153
+ const vwdStart = vwdView.project(vwdWorldStart[0], vwdWorldStart[1])
3154
+ const vwdTarget = vwdView.project(vwdWorldTarget[0], vwdWorldTarget[1])
3155
+ const vwdObjectOffset = vwdView.getChannelGroups('vwd-ch').find(group => String(group.id) === 'vwd-g2')?.waypointOffsets?.['1']
3156
+ const vwdObjectLane = vwdView.getChannels().find(group => String(group.id) === 'vwd-g2')?.offsets?.[0] ?? NaN
3157
+ check('分组拐点偏移:{ x, y } 声明归一成 [dx, dy] 并参与实际路由', Array.isArray(vwdObjectOffset) && Math.abs(vwdObjectOffset[0]) <= 1e-3 && Math.abs(vwdObjectOffset[1] - 6) <= 1e-3 && Math.abs(vwdOtherBefore.lines[0].waypoints[0][1] - (vwdOtherBefore.skeleton[1][1] + vwdObjectLane + 6)) <= 1e-3, JSON.stringify({ offset: vwdObjectOffset, lane: vwdObjectLane, skeleton: vwdOtherBefore.skeleton, line: vwdOtherBefore.lines[0].waypoints[0] }))
3158
+ vwdSend('pointermove', vwdStart, { buttons: 0 })
3159
+ vwdSend('pointerdown', vwdStart, { buttons: 1 })
3160
+ vwdSend('pointermove', [(vwdStart[0] + vwdTarget[0]) / 2, (vwdStart[1] + vwdTarget[1]) / 2], { buttons: 1 })
3161
+ vwdSend('pointermove', vwdTarget, { buttons: 1 })
3162
+ vwdSend('pointerup', vwdTarget, { buttons: 0 })
3163
+ await settle(180)
3164
+ const vwdAfter = vwdView.getChannelGroupWaypoints('vwd-ch', 'vwd-g1')
3165
+ const vwdOtherAfter = vwdView.getChannelGroupWaypoints('vwd-ch', 'vwd-g2')
3166
+ const vwdClose = (left, right, tolerance = 1e-3) => Math.abs(left - right) <= tolerance
3167
+ const vwdMoved = vwdAfter.lines[0].waypoints[0]
3168
+ const vwdPartnerBefore = vwdBefore.lines[1].waypoints[0]
3169
+ const vwdPartnerAfter = vwdAfter.lines[1].waypoints[0]
3170
+ const vwdAdjacent = vwdBefore.lines[0].waypoints[1]
3171
+ const vwdAdjacentAfter = vwdAfter.lines[0].waypoints[1]
3172
+ const vwdRoute = vwdView.getParallelGroups().find(group => String(group.group_id) === 'vwd-g1')
3173
+ const vwdOffset = vwdAfter.waypointOffsets['1']
3174
+ const vwdDeclared = vwdView.getOptions().channels[0].groups.find(group => String(group.id) === 'vwd-g1')?.waypointOffsets
3175
+ check('分组拐点 vertex:目标分组的同序号拐点按世界增量平移', vwdClose(vwdMoved[0] - vwdWorldStart[0], 42) && vwdClose(vwdMoved[1] - vwdWorldStart[1], 30) && vwdClose(vwdPartnerAfter[0] - vwdPartnerBefore[0], 42) && vwdClose(vwdPartnerAfter[1] - vwdPartnerBefore[1], 30), JSON.stringify({ moved: vwdMoved, partnerDelta: [vwdPartnerAfter[0] - vwdPartnerBefore[0], vwdPartnerAfter[1] - vwdPartnerBefore[1]] }))
3176
+ check('分组拐点 vertex:相邻拐点与其它分组一个都不动', vwdClose(vwdAdjacentAfter[0], vwdAdjacent[0]) && vwdClose(vwdAdjacentAfter[1], vwdAdjacent[1]) && JSON.stringify(vwdOtherAfter.lines.map(line => line.path)) === JSON.stringify(vwdOtherBefore.lines.map(line => line.path)), JSON.stringify({ adjacent: vwdAdjacentAfter, other: vwdOtherAfter.lines[0].waypoints }))
3177
+ check('分组拐点 vertex:只写 waypointOffsets,不改分组公共 path', vwdOffset && Object.keys(vwdAfter.waypointOffsets).join(',') === '1' && JSON.stringify(vwdAfter.path) === JSON.stringify(vwdBefore.path) && vwdDeclared && vwdClose(vwdDeclared['1']?.[0] ?? NaN, vwdOffset[0]) && vwdClose(vwdDeclared['1']?.[1] ?? NaN, vwdOffset[1]), JSON.stringify({ offsets: vwdAfter.waypointOffsets, declared: vwdDeclared, path: vwdAfter.path }))
3178
+ check('分组拐点 vertex:parallel_groups.group_route 带刚性偏移(导出不回到旧骨架)', Array.isArray(vwdOffset) && vwdClose(vwdRoute?.group_route?.[1]?.[0] ?? NaN, vwdBefore.skeleton[1][0] + vwdOffset[0]) && vwdClose(vwdRoute?.group_route?.[1]?.[1] ?? NaN, vwdBefore.skeleton[1][1] + vwdOffset[1]), JSON.stringify(vwdRoute?.group_route ?? null))
3179
+ vwdView.destroy()
3180
+ vwdHost.remove()
3181
+
3007
3182
  /* 删除(Delete 键):先出载荷说清「删什么 / 连带什么 / 能按哪些范围删」,确认之后才动数据 */
3008
3183
  const rmHost = document.createElement('div')
3009
3184
  rmHost.style.cssText = 'width:520px;height:320px'
@@ -3039,7 +3214,7 @@
3039
3214
  const rmKey = (key, extra = {}) => {
3040
3215
  rmView.el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...extra }))
3041
3216
  }
3042
- rmView.select(['rm-2'])
3217
+ rmView.selectFeatures(['rm-2'])
3043
3218
  rmKey('Delete')
3044
3219
  await settle(40)
3045
3220
  const rmPlan = rmView.getRemoveRequest()
@@ -3050,13 +3225,13 @@
3050
3225
  rmView.cancelRemove()
3051
3226
  await settle(40)
3052
3227
  check('取消删除:数据不动、事件给 phase: cancel、待确认的载荷收掉', rmView.getData().lines.length === 4 && rmEvents.length === 2 && rmEvents[1].phase === 'cancel' && rmEvents[1].cancelled === true && rmView.getRemoveRequest() === null, JSON.stringify(rmEvents.map(item => item.phase)))
3053
- rmView.select(['rm-2'])
3228
+ rmView.selectFeatures(['rm-2'])
3054
3229
  rmKey('Delete')
3055
3230
  await settle(40)
3056
3231
  rmView.resolveRemove('channels')
3057
3232
  await settle(80)
3058
3233
  check('按「整条通道」确认:三条回线一起删、通道声明也摘掉,通道外的线路留着', rmView.getData().lines.length === 1 && rmView.getData().lines[0].id === 'rm-solo' && rmView.getOptions().channels.length === 0 && rmEvents.at(-1).phase === 'remove' && rmEvents.at(-1).scope === 'channels' && rmEvents.at(-1).result.lineIds.length === 3, JSON.stringify({ lines: rmView.getData().lines.map(item => item.id), channels: rmView.getOptions().channels.length }))
3059
- rmView.select(['rm-a'])
3234
+ rmView.selectFeatures(['rm-a'])
3060
3235
  rmKey('Delete')
3061
3236
  await settle(40)
3062
3237
  const rmPointPlan = rmView.getRemoveRequest()
@@ -3068,7 +3243,7 @@
3068
3243
  await settle(80)
3069
3244
  check('撤销一步:节点与连带删掉的线路一起回来', rmView.getData().points.length === 3 && rmView.getData().lines.length === 1 && rmView.getData().lines[0].id === 'rm-solo', JSON.stringify({ points: rmView.getData().points.length, lines: rmView.getData().lines.map(item => item.id) }))
3070
3245
  rmView.updateOptions({ shortcuts: { remove: 'shift+delete' } })
3071
- rmView.select(['rm-solo'])
3246
+ rmView.selectFeatures(['rm-solo'])
3072
3247
  rmKey('Delete')
3073
3248
  await settle(40)
3074
3249
  check('快捷键单独配:单独的 Delete 不再触发删除', rmView.getRemoveRequest() === null && rmView.getData().lines.length === 1, JSON.stringify(rmView.getRemoveRequest()))
@@ -3077,7 +3252,7 @@
3077
3252
  check('配成 Shift+Delete:按住 Shift 再按才出载荷', Boolean(rmView.getRemoveRequest()) && rmView.getRemoveRequest().counts.lines === 1, JSON.stringify(rmView.getRemoveRequest()?.counts))
3078
3253
  rmView.cancelRemove()
3079
3254
  /* 待确认期间数据换过:确认时整条请求作废(不按旧下标删),事件给 phase: cancel + invalidated */
3080
- rmView.select(['rm-solo'])
3255
+ rmView.selectFeatures(['rm-solo'])
3081
3256
  rmKey('Delete', { shiftKey: true })
3082
3257
  await settle(40)
3083
3258
  rmView.updateData([{ id: 'rm-a', x: 0, y: 0 }, { id: 'rm-b', x: 200, y: 0 }, { id: 'rm-c', x: 100, y: -80 }, { id: 'rm-d', x: 40, y: 40 }], [{ id: 'rm-solo', from: 'rm-a', to: 'rm-c' }])
@@ -3099,44 +3274,44 @@
3099
3274
  showGrid: false
3100
3275
  })
3101
3276
  await settle(120)
3102
- rmOffView.select(['rm-off-line'])
3277
+ rmOffView.selectFeatures(['rm-off-line'])
3103
3278
  rmOffView.el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true, cancelable: true }))
3104
3279
  await settle(40)
3105
3280
  check('开关关着(features.remove 缺省):Delete 不出删除载荷,数据不动', rmOffView.getRemoveRequest() === null && rmOffView.getData().lines.length === 1, JSON.stringify({ lines: rmOffView.getData().lines.length }))
3106
3281
  rmOffView.destroy()
3107
3282
  rmOffHost.remove()
3108
3283
 
3109
- const rdStations = rdView.exportStationData()
3110
- check('exportStationData:new.json 的 station_data 口径(列名 / 母线清单 / 业务列原样透传)', rdStations.ok === true && rdStations.station_data.length === 3 && rdStations.station_data[0].station_id === 'rd-a' && rdStations.station_data[0].station_name === '甲站' && rdStations.station_data[0].vn_kv === 220 && rdStations.station_data[0].bus_id_list.join(',') === 'b-1,b-2' && Array.isArray(rdStations.station_data[0].trafo_id_list) && rdStations.station_data[0].max_loading_rate === 0.8, JSON.stringify(rdStations.station_data[0]).slice(0, 200))
3111
- check('exportStationData:四至范围与 show_station_list / show_station_data 一起给', Number.isFinite(rdStations.max_lat) && Number.isFinite(rdStations.min_lon) && rdStations.show_station_list.join(',') === 'b-1,b-2,b-3' && rdStations.show_station_data[0].join(',') === 'b-1,b-2', JSON.stringify({ box: [rdStations.min_lon, rdStations.min_lat, rdStations.max_lon, rdStations.max_lat], list: rdStations.show_station_list }))
3112
- const rdLean = rdView.exportStationData({ points: ['rd-a'], box: false, showStation: false })
3113
- check('exportStationData:box / showStation 关掉就不带那几列,只留选中的这一个站', rdLean.station_data.length === 1 && !('max_lat' in rdLean) && !('show_station_list' in rdLean), JSON.stringify(Object.keys(rdLean)))
3284
+ const rdStations = rdView.exportData({ format: 'new', parts: ['station'] })
3285
+ check('exportData(format: new, parts: station):new.json 的 station_data 口径(列名 / 母线清单 / 业务列原样透传)', rdStations.ok === true && rdStations.station_data.length === 3 && rdStations.station_data[0].station_id === 'rd-a' && rdStations.station_data[0].station_name === '甲站' && rdStations.station_data[0].vn_kv === 220 && rdStations.station_data[0].bus_id_list.join(',') === 'b-1,b-2' && Array.isArray(rdStations.station_data[0].trafo_id_list) && rdStations.station_data[0].max_loading_rate === 0.8, JSON.stringify(rdStations.station_data[0]).slice(0, 200))
3286
+ check('exportData(format: new, parts: station):四至范围与 show_station_list / show_station_data 一起给', Number.isFinite(rdStations.max_lat) && Number.isFinite(rdStations.min_lon) && rdStations.show_station_list.join(',') === 'b-1,b-2,b-3' && rdStations.show_station_data[0].join(',') === 'b-1,b-2', JSON.stringify({ box: [rdStations.min_lon, rdStations.min_lat, rdStations.max_lon, rdStations.max_lat], list: rdStations.show_station_list }))
3287
+ const rdLean = rdView.exportData({ format: 'new', parts: ['station'], points: ['rd-a'], box: false, showStation: false })
3288
+ check('exportData(format: new, parts: station):box / showStation 关掉就不带那几列,只留选中的这一个站', rdLean.station_data.length === 1 && !('max_lat' in rdLean) && !('show_station_list' in rdLean), JSON.stringify(Object.keys(rdLean)))
3114
3289
  const rdCopyRow = rdStations.station_data[0]
3115
3290
  rdCopyRow.bus_id_list.push('改一改')
3116
3291
  check('导出的是拷贝:改导出结果不动组件里的那一份', rdView.getData().points[0].bus_id_list.length === 2, `组件里 ${rdView.getData().points[0].bus_id_list.length} 个`)
3117
- const rdPoints = rdView.exportPointData()
3118
- check('exportPointData:test.json 的 point_data 口径(同一份数据换列名,带分区列)', rdPoints.ok === true && rdPoints.point_data.length === 3 && rdPoints.point_data[0].station_id === 'rd-a' && rdPoints.point_data[0].station_name === '甲站' && rdPoints.point_data[0].lon === 0 && rdPoints.point_data[0].lat === 0 && rdPoints.counts.points === 3, JSON.stringify({ keys: Object.keys(rdPoints.point_data[0] ?? {}).length, count: rdPoints.counts.points, lon: rdPoints.point_data[0].lon, lat: rdPoints.point_data[0].lat }))
3119
- check('exportPointData:zone_id / zone_name 两列在(没给值就是 null)', 'zone_id' in rdPoints.point_data[0] && 'zone_name' in rdPoints.point_data[0] && rdPoints.point_data[0].bus_name_list.length === 2, JSON.stringify({ zone_id: rdPoints.point_data[0].zone_id }))
3120
- const rdLines = rdView.exportLineData()
3292
+ const rdPoints = rdView.exportData({ format: 'test', parts: ['station'] })
3293
+ check('exportData(format: test, parts: station):test.json 的 point_data 口径(同一份数据换列名,带分区列)', rdPoints.ok === true && rdPoints.point_data.length === 3 && rdPoints.point_data[0].station_id === 'rd-a' && rdPoints.point_data[0].station_name === '甲站' && rdPoints.point_data[0].lon === 0 && rdPoints.point_data[0].lat === 0 && rdPoints.counts.points === 3, JSON.stringify({ keys: Object.keys(rdPoints.point_data[0] ?? {}).length, count: rdPoints.counts.points, lon: rdPoints.point_data[0].lon, lat: rdPoints.point_data[0].lat }))
3294
+ check('exportData(format: test, parts: station):zone_id / zone_name 两列在(没给值就是 null)', 'zone_id' in rdPoints.point_data[0] && 'zone_name' in rdPoints.point_data[0] && rdPoints.point_data[0].bus_name_list.length === 2, JSON.stringify({ zone_id: rdPoints.point_data[0].zone_id }))
3295
+ const rdLines = rdView.exportData({ format: 'test', parts: ['line'] })
3121
3296
  const rdChannelLine = rdLines.line_data.filter(row => row.channel_id === 'rd-ch')
3122
- check('exportLineData:通道回线带上 channel_id / channel_name / parallel_group_id 与第几回', rdLines.ok === true && rdLines.line_data.length === 3 && rdChannelLine.length === 2 && rdChannelLine[0].parallel_group_id !== undefined && rdChannelLine[0].index === 0 && rdChannelLine[1].index === 1 && rdChannelLine.every(row => Array.isArray(row.coords_list) && row.coords_list.length >= 2), JSON.stringify(rdChannelLine.map(row => ({ id: row.line_id, group: row.parallel_group_id, index: row.index }))))
3123
- check('exportLineData:不在通道里的线路 channel_id 给 null,折线就是画出来的那一份', rdLines.line_data.some(row => row.line_id === 'rd-solo' && row.channel_id === null && row.coords_list.length === 3), JSON.stringify(rdLines.line_data.map(row => ({ id: row.line_id, channel: row.channel_id, points: row.coords_list.length }))))
3124
- const rdChannels = rdView.exportChannelData()
3125
- check('exportChannelData:一条通道一行,回线在 line_data 里、路由在 coords 里', rdChannels.ok === true && rdChannels.channel_data.length === 1 && rdChannels.counts.channels === 1 && rdChannels.counts.lines === 2 && rdChannels.channel_data[0].line_data.length === 2 && Array.isArray(rdChannels.channel_data[0].coords) && rdChannels.channel_data[0].coords.length >= 2, JSON.stringify({ channels: rdChannels.counts.channels, lines: rdChannels.counts.lines, coords: rdChannels.channel_data[0].coords.length }))
3126
- const rdChannelLean = rdView.exportChannelData({ channels: [0], lines: ['rd-1'] })
3127
- check('exportChannelData:options.lines 能把通道里的 line_data 收到这几条回线', rdChannelLean.channel_data[0].line_data.length === 1 && rdChannelLean.channel_data[0].line_data[0].line_id === 'rd-1', JSON.stringify(rdChannelLean.channel_data[0].line_data.map(row => row.line_id)))
3128
- const rdGroupsOff = rdView.exportParallelGroups()
3129
- check('exportParallelGroups:没加过拐点时是空的一份(与通道数据各走各的)', rdGroupsOff.ok === true && rdGroupsOff.parallel_groups.length === 0 && rdGroupsOff.counts.parallelGroups === 0, JSON.stringify(rdGroupsOff.counts))
3297
+ check('exportData(format: test, parts: line):通道回线带上 channel_id / channel_name / parallel_group_id 与第几回', rdLines.ok === true && rdLines.line_data.length === 3 && rdChannelLine.length === 2 && rdChannelLine[0].parallel_group_id !== undefined && rdChannelLine[0].index === 0 && rdChannelLine[1].index === 1 && rdChannelLine.every(row => Array.isArray(row.coords_list) && row.coords_list.length >= 2), JSON.stringify(rdChannelLine.map(row => ({ id: row.line_id, group: row.parallel_group_id, index: row.index }))))
3298
+ check('exportData(format: test, parts: line):不在通道里的线路 channel_id 给 null,折线就是画出来的那一份', rdLines.line_data.some(row => row.line_id === 'rd-solo' && row.channel_id === null && row.coords_list.length === 3), JSON.stringify(rdLines.line_data.map(row => ({ id: row.line_id, channel: row.channel_id, points: row.coords_list.length }))))
3299
+ const rdChannels = rdView.exportData({ format: 'new', parts: ['channel'] })
3300
+ check('exportData(format: new, parts: channel):一条通道一行,回线在 line_data 里、路由在 coords 里', rdChannels.ok === true && rdChannels.channel_data.length === 1 && rdChannels.counts.channels === 1 && rdChannels.counts.lines === 2 && rdChannels.channel_data[0].line_data.length === 2 && Array.isArray(rdChannels.channel_data[0].coords) && rdChannels.channel_data[0].coords.length >= 2, JSON.stringify({ channels: rdChannels.counts.channels, lines: rdChannels.counts.lines, coords: rdChannels.channel_data[0].coords.length }))
3301
+ const rdChannelLean = rdView.exportData({ format: 'new', parts: ['channel'], channels: [0], lines: ['rd-1'] })
3302
+ check('exportData(format: new, parts: channel):options.lines 能把通道里的 line_data 收到这几条回线', rdChannelLean.channel_data[0].line_data.length === 1 && rdChannelLean.channel_data[0].line_data[0].line_id === 'rd-1', JSON.stringify(rdChannelLean.channel_data[0].line_data.map(row => row.line_id)))
3303
+ const rdGroupsOff = rdView.exportData({ format: 'test', parts: ['waypoint'] })
3304
+ check('exportData(format: test, parts: waypoint):没加过拐点时是空的一份(与通道数据各走各的)', rdGroupsOff.ok === true && rdGroupsOff.parallel_groups.length === 0 && rdGroupsOff.counts.parallelGroups === 0, JSON.stringify(rdGroupsOff.counts))
3130
3305
  rdView.addWaypoint('rd-1', [80, 40])
3131
3306
  await settle(80)
3132
- const rdGroupsOn = rdView.exportParallelGroups()
3133
- check('加了拐点之后:parallel_groups 单独给出这一组(group_id / member_line_ids / group_route 对得上)', rdGroupsOn.parallel_groups.length === 1 && rdGroupsOn.counts.parallelGroups === 1 && rdGroupsOn.counts.waypoints >= 1 && Array.isArray(rdGroupsOn.parallel_groups[0].group_route) && rdGroupsOn.parallel_groups[0].group_route.length >= 3 && rdGroupsOn.parallel_groups[0].member_line_ids.length >= 1, JSON.stringify(rdGroupsOn.parallel_groups[0]).slice(0, 200))
3134
- const rdGroupsAll = rdView.exportParallelGroups({ all: true })
3135
- check('exportParallelGroups:all 为 true 时把没编辑过的分组 / 带折线的线路也列出来', rdGroupsAll.parallel_groups.some(group => String(group.group_id) === 'rd-solo'), JSON.stringify(rdGroupsAll.parallel_groups.map(group => group.group_id)))
3136
- const rdOneGroup = rdView.exportParallelGroups({ channels: ['rd-ch'] })
3137
- check('exportParallelGroups:channels 只留这条通道那一份', rdOneGroup.parallel_groups.length === 1 && rdOneGroup.parallel_groups[0].group_route.length >= 3, JSON.stringify(rdOneGroup.parallel_groups.map(group => group.group_id)))
3138
- const rdNoGroup = rdView.exportParallelGroups({ channels: ['没有这条通道'] })
3139
- check('exportParallelGroups:通道 id 解不出来时给空表 + 一条 warnings', rdNoGroup.parallel_groups.length === 0 && rdNoGroup.warnings.length === 1, JSON.stringify(rdNoGroup.warnings))
3307
+ const rdGroupsOn = rdView.exportData({ format: 'test', parts: ['waypoint'] })
3308
+ check('加了拐点之后:parallel_groups 单独给出这一组(group_id / member_line_ids / group_route 对得上)', rdGroupsOn.parallel_groups.length === 1 && rdGroupsOn.counts.parallelGroups === 1 && Array.isArray(rdGroupsOn.parallel_groups[0].group_route) && rdGroupsOn.parallel_groups[0].group_route.length >= 3 && rdGroupsOn.parallel_groups[0].member_line_ids.length >= 1, JSON.stringify(rdGroupsOn.parallel_groups[0]).slice(0, 200))
3309
+ const rdGroupsAll = rdView.exportData({ format: 'test', parts: ['waypoint'], all: true })
3310
+ check('exportData(format: test, parts: waypoint):all 为 true 时把没编辑过的分组 / 带折线的线路也列出来', rdGroupsAll.parallel_groups.some(group => String(group.group_id) === 'rd-solo'), JSON.stringify(rdGroupsAll.parallel_groups.map(group => group.group_id)))
3311
+ const rdOneGroup = rdView.exportData({ format: 'test', parts: ['waypoint'], channels: ['rd-ch'] })
3312
+ check('exportData(format: test, parts: waypoint):channels 只留这条通道那一份', rdOneGroup.parallel_groups.length === 1 && rdOneGroup.parallel_groups[0].group_route.length >= 3, JSON.stringify(rdOneGroup.parallel_groups.map(group => group.group_id)))
3313
+ const rdNoGroup = rdView.exportData({ format: 'test', parts: ['waypoint'], channels: ['没有这条通道'] })
3314
+ check('exportData(format: test, parts: waypoint):通道 id 解不出来时给空表 + 一条 warnings', rdNoGroup.parallel_groups.length === 0 && rdNoGroup.warnings.length === 1, JSON.stringify(rdNoGroup.warnings))
3140
3315
  const rdFour = rdView.exportData({ format: 'view' })
3141
3316
  check("exportData format: view(缺省):points / lines / channels / parallelGroups 四份分开", rdFour.ok === true && rdFour.points.length === 3 && rdFour.lines.length === 3 && rdFour.channels.length === 1 && Array.isArray(rdFour.parallelGroups) && rdFour.counts.points === 3 && rdFour.counts.lines === 3 && rdFour.counts.channels === 1, JSON.stringify(rdFour.counts))
3142
3317
  const rdNew = rdView.exportData({ format: 'new' })
@@ -3145,6 +3320,32 @@
3145
3320
  check("exportData format: test:test.json 口径的整份(point_data + line_data + parallel_groups)", rdTest.point_data.length === 3 && rdTest.line_data.length === 3 && Array.isArray(rdTest.switch_data) && rdTest.parallel_groups.length >= 1 && rdTest.counts.points === 3, JSON.stringify(rdTest.counts))
3146
3321
  const rdFiltered = rdView.exportData({ format: 'view', points: ['rd-a'], lines: ['rd-solo'], channels: ['rd-ch'] })
3147
3322
  check('exportData:三个筛选取一部分时只导这几个(id / 下标 / { id } 都认)', rdFiltered.points.length === 1 && rdFiltered.lines.length === 1 && rdFiltered.channels.length === 1 && rdFiltered.lines[0].line_id === 'rd-solo', JSON.stringify(rdFiltered.counts))
3323
+ const rdStationOnly = rdView.exportData({ format: 'view', parts: ['station'] })
3324
+ check('exportData:parts 只留场站时不生成线路 / 通道 / 拐点', rdStationOnly.points.length === 3 && rdStationOnly.lines === undefined && rdStationOnly.channels === undefined && rdStationOnly.parallelGroups === undefined && rdStationOnly.counts.lines === 0 && rdStationOnly.counts.channels === 0, JSON.stringify(rdStationOnly.counts))
3325
+ const rdViewportUnified = rdView.exportData({ scope: 'viewport', margin: 0 })
3326
+ const rdViewportRepeat = rdView.exportData({ scope: 'viewport' })
3327
+ check('exportData(scope: viewport) 可重复且带 scope / viewRect / margin', JSON.stringify(rdViewportUnified) === JSON.stringify(rdViewportRepeat) && rdViewportUnified.scope === 'viewport' && rdViewportUnified.viewRect && rdViewportUnified.margin === 0, JSON.stringify({ scope: rdViewportUnified.scope, rect: rdViewportUnified.viewRect, margin: rdViewportUnified.margin }))
3328
+ const rdSelectionUnified = rdView.exportData({ scope: 'selection', points: ['rd-a', 'rd-b'], parts: ['station', 'channel'] })
3329
+ const rdSelectionRepeat = rdView.exportData({ scope: 'selection', points: ['rd-a', 'rd-b'], parts: ['station', 'channel'] })
3330
+ check('exportData(scope: selection) 可重复且把下标 / parts 一并回显', JSON.stringify(rdSelectionUnified) === JSON.stringify(rdSelectionRepeat) && rdSelectionUnified.scope === 'selection' && rdSelectionUnified.pointIndexes.length === 2 && Array.isArray(rdSelectionUnified.channelIndexes), JSON.stringify({ scope: rdSelectionUnified.scope, points: rdSelectionUnified.pointIndexes, channels: rdSelectionUnified.channelIndexes }))
3331
+ const rdRemovedAliases = ['exportStationData', 'exportPointData', 'exportLineData', 'exportChannelData', 'exportParallelGroups', 'exportViewportData', 'exportSelectionData']
3332
+ check('七个旧分项导出别名已经从实例上删除,只保留统一 exportData', rdRemovedAliases.every(name => rdView[name] === undefined) && typeof rdView.exportData === 'function', rdRemovedAliases.map(name => `${name}:${typeof rdView[name]}`).join(' / '))
3333
+ const rdRemovedThinWrappers = ['setEffects', 'setPointSize', 'setPointSymbol', 'setPointColor', 'setLineWidth', 'setLineColor', 'setChannelWidth', 'setChannelNodeWidth', 'setChannelInset', 'setChannelTangentInset', 'setChannelName', 'clearFilter', 'clearDropPreview']
3334
+ check('十三个重复的单项 / 清空快捷方法已经从实例上删除,统一走 update* / setFilter(null) / setDropPreview(null)', rdRemovedThinWrappers.every(name => rdView[name] === undefined) && typeof rdView.updatePoint === 'function' && typeof rdView.updateLine === 'function' && typeof rdView.updateChannel === 'function' && typeof rdView.updateOptions === 'function' && typeof rdView.setFilter === 'function' && typeof rdView.setDropPreview === 'function', rdRemovedThinWrappers.map(name => `${name}:${typeof rdView[name]}`).join(' / '))
3335
+ const rdInputViewport = rdView.exportData({ scope: 'viewport', format: 'input' })
3336
+ check('format: input 的视口结果保持回灌形状,不额外夹带 scope / viewRect / margin', Array.isArray(rdInputViewport.points) && !('scope' in rdInputViewport) && !('viewRect' in rdInputViewport) && !('margin' in rdInputViewport), Object.keys(rdInputViewport).join(','))
3337
+ const rdChannelLeanUnified = rdView.exportData({ format: 'new', parts: ['channel'], channels: ['rd-ch'], lines: ['rd-1'] })
3338
+ check('exportData:统一入口下通道的 line_data 仍按 lines 收窄(只留选中的回线)', rdChannelLeanUnified.channel_data.length === 1 && rdChannelLeanUnified.channel_data[0].line_data.length === 1 && rdChannelLeanUnified.channel_data[0].line_data[0].line_id === 'rd-1', JSON.stringify(rdChannelLeanUnified.channel_data[0]?.line_data?.map(row => row.line_id)))
3339
+ const rdFull = rdView.exportData({ format: 'full', scope: 'viewport' })
3340
+ check('exportData:format full 始终返回标准文档本身,不夹带 scope / viewRect 读数', rdFull.format === 'gis-view' && !('scope' in rdFull) && !('viewRect' in rdFull) && !('counts' in rdFull), Object.keys(rdFull).join(','))
3341
+ let rdScopeThrew = ''
3342
+ try {
3343
+ rdView.exportData({ scope: 'window' })
3344
+ }
3345
+ catch (error) {
3346
+ rdScopeThrew = String(error && error.message ? error.message : error)
3347
+ }
3348
+ check('exportData:scope 写错直接抛异常(不静默按全量导)', /viewport/.test(rdScopeThrew) && /selection/.test(rdScopeThrew) && /window/.test(rdScopeThrew), rdScopeThrew)
3148
3349
  const rdBad = rdView.exportData({ format: 'test', lines: ['没有这条线'] })
3149
3350
  check('解不出的引用记进 warnings,其余照常导出(ok 仍是 true)', rdBad.ok === true && rdBad.line_data.length === 0 && rdBad.warnings.length === 1 && /没找到/.test(String(rdBad.warnings[0])), JSON.stringify(rdBad.warnings))
3150
3351
  let rdThrew = ''
@@ -3229,9 +3430,9 @@
3229
3430
  check('selectFeatures:拾取载荷(pickAt 的结果)可以直接喂进来', apiIds() === 'api-a', apiIds())
3230
3431
  apiView.selectFeatures(apiView.search({ keyword: 'api-l1' }).hits[0])
3231
3432
  check('selectFeatures:搜索命中(kind: line)可以直接喂进来', apiIds() === 'api-l1', apiIds())
3232
- apiView.select(null)
3433
+ apiView.selectFeatures(null)
3233
3434
  const apiEmpty = apiView.getSelectionData()
3234
- check('select(null) 清空选中,getSelectionData 给空的一份(bounds 是 null)', apiEmpty.count === 0 && apiEmpty.ids.length === 0 && apiEmpty.bounds === null, JSON.stringify(apiEmpty.count))
3435
+ check('selectFeatures(null) 清空选中,getSelectionData 给空的一份(bounds 是 null)', apiEmpty.count === 0 && apiEmpty.ids.length === 0 && apiEmpty.bounds === null, JSON.stringify(apiEmpty.count))
3235
3436
  apiSend('pointermove', apiPointScreen)
3236
3437
  apiSend('pointerdown', apiPointScreen, { button: 0, buttons: 1 })
3237
3438
  apiSend('pointerup', apiPointScreen, { button: 0, buttons: 0 })
@@ -3282,7 +3483,7 @@
3282
3483
  check('focusOn(一批, { select: true }):被聚焦的这一批同时选中(整份选中集换成这三个)', apiView.getSelectionData().ids.join(',') === 'api-a,api-b,api-c' && apiBeforeFocusSelect === 'api-a,api-b,api-c' && apiView.getSelectionData().count === 3, `${apiBeforeFocusSelect} -> ${apiView.getSelectionData().ids.join(',')}`)
3283
3484
  apiView.focusOn('api-a', { select: true, mode: 'remove' })
3284
3485
  check("focusOn(..., { select: true, mode: 'remove' }):只把聚焦的这个从选中集里去掉", apiView.getSelectionData().ids.join(',') === 'api-b,api-c', apiView.getSelectionData().ids.join(','))
3285
- apiView.select(null)
3486
+ apiView.selectFeatures(null)
3286
3487
  const apiAreaScale = apiView.getCamera().scale
3287
3488
  apiView.focusOn('api-b', { maxZoom: 12 })
3288
3489
  const apiZoomCamera = apiView.getCamera()
@@ -3433,7 +3634,8 @@
3433
3634
  const pluginLog = []
3434
3635
  const pluginWarnings = []
3435
3636
  const pluginErrors = []
3436
- let optionPluginInstance = null
3637
+ let optionPluginInstance = null
3638
+ let optionPluginContext = null
3437
3639
  const pluginView = createGisView(pluginHost, {
3438
3640
  points: [{ id: 'pg-1', x: 0, y: 0 }],
3439
3641
  lines: [],
@@ -3441,19 +3643,22 @@
3441
3643
  height: 160,
3442
3644
  onWarning: text => pluginWarnings.push(text),
3443
3645
  onError: text => pluginErrors.push(text),
3444
- plugins: [v => {
3646
+ plugins: [(v, context) => {
3445
3647
  optionPluginInstance = v
3648
+ optionPluginContext = context
3446
3649
  return { destroy: () => pluginLog.push('options') }
3447
3650
  }]
3448
3651
  })
3449
- check('options.plugins 拿到的就是实例本身', optionPluginInstance === pluginView)
3450
- let usePluginInstance = null
3652
+ check('options.plugins 拿到实例本身与带 apiVersion 的只读上下文', optionPluginInstance === pluginView && optionPluginContext?.apiVersion === 1 && optionPluginContext.view === pluginView && Object.isFrozen(optionPluginContext), JSON.stringify(optionPluginContext ? { apiVersion: optionPluginContext.apiVersion, sameView: optionPluginContext.view === pluginView } : null))
3653
+ let usePluginInstance = null
3654
+ let usePluginContext = null
3451
3655
  let useDestroyCount = 0
3452
- const disposeUse = pluginView.use(v => {
3656
+ const disposeUse = pluginView.use((v, context) => {
3453
3657
  usePluginInstance = v
3658
+ usePluginContext = context
3454
3659
  return { destroy: () => { useDestroyCount += 1 } }
3455
3660
  })
3456
- check('use() 立即执行、拿到的也是同一个实例', usePluginInstance === pluginView)
3661
+ check('use() 立即执行,实例与带版本的上下文都和 options.plugins 一致', usePluginInstance === pluginView && usePluginContext?.apiVersion === 1 && usePluginContext.view === pluginView && Object.isFrozen(usePluginContext))
3457
3662
  disposeUse()
3458
3663
  disposeUse()
3459
3664
  check('use() 返回的卸载函数跑插件的 destroy(重复调用只跑一次)', useDestroyCount === 1, String(useDestroyCount))
@@ -3664,7 +3869,7 @@
3664
3869
  await edgeSleep(120)
3665
3870
  const shiftDropFrame = await frameOf(shiftView)
3666
3871
  check('删掉选中里的那一条:读数只剩 c,b 的位置不留选中色', shiftView.getSelectionData().ids.join(',') === 'sp-c' && !pixelIs(shiftDropFrame, shiftSpotB, 'green') && pixelIs(shiftDropFrame, shiftSpotC, 'green'), JSON.stringify({ ids: shiftView.getSelectionData().ids, b: pixelIs(shiftDropFrame, shiftSpotB, 'green'), c: pixelIs(shiftDropFrame, shiftSpotC, 'green') }))
3667
- shiftView.select(null)
3872
+ shiftView.selectFeatures(null)
3668
3873
  await edgeSleep(120)
3669
3874
  const shiftClearFrame = await frameOf(shiftView)
3670
3875
  check('删点之后取消选中:c 的选中色收掉、回到自己的底色(不会停在别人身上)', !pixelIs(shiftClearFrame, shiftSpotC, 'green') && pixelIsWhite(shiftClearFrame, shiftSpotC), JSON.stringify({ green: pixelIs(shiftClearFrame, shiftSpotC, 'green'), white: pixelIsWhite(shiftClearFrame, shiftSpotC) }))
@@ -3691,7 +3896,7 @@
3691
3896
  })
3692
3897
  await edgeSleep(120)
3693
3898
  const lineSpot = lineShiftView.project(200, 120)
3694
- lineShiftView.select('ssl2')
3899
+ lineShiftView.selectFeatures('ssl2')
3695
3900
  lineShiftView.removeLines('ssl1')
3696
3901
  await edgeSleep(120)
3697
3902
  const lineShiftFrame = await frameOf(lineShiftView)
@@ -3712,7 +3917,7 @@
3712
3917
  })
3713
3918
  await edgeSleep(120)
3714
3919
  const mergeSpotC = mergeShiftView.project(280, 0)
3715
- mergeShiftView.select('mp-c')
3920
+ mergeShiftView.selectFeatures('mp-c')
3716
3921
  mergeShiftView.mergePoints('mp-a', 'mp-b')
3717
3922
  await edgeSleep(120)
3718
3923
  const mergeShiftFrame = await frameOf(mergeShiftView)
@@ -3734,7 +3939,7 @@
3734
3939
  await edgeSleep(120)
3735
3940
  const fallbackSpotB = fallbackShiftView.project(140, 0)
3736
3941
  const fallbackSpotC = fallbackShiftView.project(280, 0)
3737
- fallbackShiftView.select('fp-c')
3942
+ fallbackShiftView.selectFeatures('fp-c')
3738
3943
  fallbackShiftView.removePoints('fp-a')
3739
3944
  await edgeSleep(120)
3740
3945
  const fallbackFrame = await frameOf(fallbackShiftView)
@@ -3887,11 +4092,37 @@
3887
4092
  /<image[^>]+href="data:image\/png;base64/.test(symSvg),
3888
4093
  (symSvg.match(/<image[^>]*/) ?? ['没有 <image>'])[0].slice(0, 90))
3889
4094
  check('自定义图元:listSymbols 报得出注册过的名字', symView.listSymbols().includes('三角'), JSON.stringify(symView.listSymbols()))
4095
+ // 同名覆盖要换一张贴图:画笔从三角形换成整块实心方块,斜角立刻从透明变成实色。
4096
+ // 贴图缓存的来源键没带注册代次时,这里会继续拿旧三角形,画面看起来像「注册没生效」。
4097
+ const symBlockPainter = (context, info) => {
4098
+ context.fillStyle = '#c837d6'
4099
+ context.fillRect(0, 0, info.width, info.height)
4100
+ }
4101
+ symView.registerSymbol('三角', symBlockPainter)
4102
+ await edgeSleep(240)
4103
+ const symReregistered = await symShot(symView)
4104
+ const symReregisteredCorner = symRead(symReregistered, symCorner)
4105
+ check('自定义图元:同名重新注册会重新烘焙贴图(三角形换成整块色块,原来的斜角变实了)',
4106
+ symReregisteredCorner[3] > 200 && symReregisteredCorner[0] > 150 && symReregisteredCorner[1] < 120 && symReregisteredCorner[2] > 150,
4107
+ symReregisteredCorner.join(','))
3890
4108
  symView.registerSymbol(' ', trianglePainter)
3891
4109
  symView.registerSymbol('空画笔', null)
3892
4110
  check('自定义图元:名字空的 / 画笔不是函数只报一条 onWarning,注册表不动',
3893
4111
  warnedNames.length === 2 && symView.listSymbols().length === 1 && warnedNames.every(message => message.includes('registerSymbol')),
3894
4112
  warnedNames.join(' | '))
4113
+ const scopedSymbolPainter = (context, info) => {
4114
+ context.fillStyle = info.color
4115
+ context.fillRect(0, 0, info.width, info.height)
4116
+ }
4117
+ const scopedSymbolOffA = symView.registerSymbol('作用域图元', scopedSymbolPainter)
4118
+ const scopedSymbolOffB = symView.registerSymbol('作用域图元', scopedSymbolPainter)
4119
+ scopedSymbolOffA()
4120
+ const scopedSymbolSurvived = symView.listSymbols().includes('作用域图元')
4121
+ scopedSymbolOffB()
4122
+ scopedSymbolOffB()
4123
+ check('自定义图元:同名覆盖后旧注销函数不误删新注册(画笔相同也算新注册),新注销函数重复调用安全',
4124
+ scopedSymbolSurvived && !symView.listSymbols().includes('作用域图元'),
4125
+ `旧注销后仍在 ${scopedSymbolSurvived} / 最终 ${JSON.stringify(symView.listSymbols())}`)
3895
4126
  symView.unregisterSymbol('三角')
3896
4127
  symOff()
3897
4128
  await edgeSleep(240)
@@ -4214,9 +4445,9 @@
4214
4445
  const auGeometry = id => auView.listLines({}).find(item => item.item?.id === id)?.path ?? null
4215
4446
  const auWaypoints = id => auView.getWaypoints(auLineIndex(id)).map(item => item.coordinate)
4216
4447
 
4217
- // ① getPointStyle 读的是「样式链定下来的那一份」:setPointSize / updatePoint 之后必须跟着变
4218
- auView.setPointSize('a', 34)
4219
- check('getPointStyle:setPointSize 之后读回来就是新尺寸', auView.getPointStyle(auPointIndex('a'))?.size === 34, JSON.stringify(auView.getPointStyle(auPointIndex('a'))))
4448
+ // ① getPointStyle 读的是「样式链定下来的那一份」:updatePoint({ size }) 之后必须跟着变
4449
+ auView.updatePoint('a', { size: 34 })
4450
+ check('getPointStyle:updatePoint({ size }) 之后读回来就是新尺寸', auView.getPointStyle(auPointIndex('a'))?.size === 34, JSON.stringify(auView.getPointStyle(auPointIndex('a'))))
4220
4451
  auView.updatePoint('a', { size: 26 })
4221
4452
  check('getPointStyle:updatePoint({ size }) 之后也跟上(不再停在初次解析的那一份)', auView.getPointStyle(auPointIndex('a'))?.size === 26, JSON.stringify(auView.getPointStyle(auPointIndex('a'))))
4222
4453
 
@@ -4229,7 +4460,7 @@
4229
4460
  auView.cancelPointCopy()
4230
4461
 
4231
4462
  // ③ setCurrentChannelGroup(id, null) = 不选中(不是「选中第一组」)
4232
- auView.select('m1')
4463
+ auView.selectFeatures('m1')
4233
4464
  const auSelectedBefore = auView.getSelections().filter(entry => entry.type === 'line').length
4234
4465
  auView.setCurrentChannelGroup('au-ch', null)
4235
4466
  const auSelectedAfter = auView.getSelections().filter(entry => entry.type === 'line').length
@@ -4344,6 +4575,446 @@
4344
4575
  JSON.stringify(gwView.getOptions().channels[0]?.groups ?? null))
4345
4576
  gwView.destroy()
4346
4577
  gwHost.remove()
4578
+
4579
+ /* ⑪ 扩展图形:path / image / canvas 三类,世界坐标跟随相机,DOM 层级在画布与标签之间,导出 PNG / SVG 都包含 */
4580
+ const grHost = document.createElement('div')
4581
+ grHost.style.cssText = 'width:480px;height:300px'
4582
+ document.body.appendChild(grHost)
4583
+ const grImageCanvas = document.createElement('canvas')
4584
+ grImageCanvas.width = 8
4585
+ grImageCanvas.height = 8
4586
+ const grImageContext = grImageCanvas.getContext('2d')
4587
+ grImageContext.fillStyle = '#ffcc00'
4588
+ grImageContext.fillRect(0, 0, 8, 8)
4589
+ const grLiveCanvas = document.createElement('canvas')
4590
+ grLiveCanvas.width = 16
4591
+ grLiveCanvas.height = 16
4592
+ const grLiveContext = grLiveCanvas.getContext('2d')
4593
+ grLiveContext.fillStyle = '#00aa66'
4594
+ grLiveContext.fillRect(0, 0, 16, 16)
4595
+ const grBase = [
4596
+ { id: 'g-path', type: 'path', x: 0, y: 0, width: 36, height: 36, viewBox: [0, 0, 10, 10], d: 'M0 0H10V10H0Z', fill: '#ff0000', stroke: 'none', anchor: 'center' },
4597
+ { id: 'g-image', type: 'image', src: grImageCanvas.toDataURL('image/png'), x: 80, y: 0, width: 32, height: 32, anchor: 'center' },
4598
+ { id: 'g-canvas', type: 'canvas', canvas: grLiveCanvas, x: -80, y: 0, width: 32, height: 32, anchor: 'center', scaleWithView: true }
4599
+ ]
4600
+ const grWarns = []
4601
+ const grView = createGisView(grHost, {
4602
+ points: [],
4603
+ lines: [],
4604
+ graphics: grBase,
4605
+ initialView: { scale: 2, center: [0, 0] },
4606
+ showGrid: false,
4607
+ onWarning: message => grWarns.push(message)
4608
+ })
4609
+ await settle(220)
4610
+ const grNode = id => grHost.querySelector(`[data-graphic-id=${id}]`)
4611
+ const grChildren = [...grView.el.children]
4612
+ const grCanvasIndex = grChildren.indexOf(grView.canvas)
4613
+ const grLayerIndex = grChildren.indexOf(grHost.querySelector('.gis-view__graphics'))
4614
+ const grLabelIndex = grChildren.indexOf(grHost.querySelector('.gis-view__labels'))
4615
+ check('扩展图形:初始化同时接受 path / image / canvas 三类,并生成对应 DOM',
4616
+ grNode('g-path')?.dataset.graphicType === 'path' && grNode('g-image')?.querySelector('img') instanceof HTMLImageElement && grNode('g-canvas')?.contains(grLiveCanvas) === true,
4617
+ ['g-path', 'g-image', 'g-canvas'].map(id => grNode(id)?.dataset.graphicType ?? 'missing').join(' / '))
4618
+ check('扩展图形:DOM 顺序为 canvas → graphics → labels(图形压在要素上、标签下)',
4619
+ grCanvasIndex >= 0 && grCanvasIndex < grLayerIndex && grLayerIndex < grLabelIndex,
4620
+ JSON.stringify({ canvas: grCanvasIndex, graphics: grLayerIndex, labels: grLabelIndex }))
4621
+ const grPathNode = grNode('g-path')
4622
+ const grCanvasNode = grNode('g-canvas')
4623
+ const grBefore = {
4624
+ left: Number.parseFloat(grPathNode.style.left),
4625
+ width: Number.parseFloat(grPathNode.style.width),
4626
+ canvasWidth: Number.parseFloat(grCanvasNode.style.width)
4627
+ }
4628
+ const grCamera = grView.getCamera()
4629
+ grView.setCamera({ ...grCamera, center: [grCamera.center[0] + 20, grCamera.center[1]], scale: grCamera.scale * 2 })
4630
+ await settle(80)
4631
+ const grAfter = {
4632
+ left: Number.parseFloat(grPathNode.style.left),
4633
+ width: Number.parseFloat(grPathNode.style.width),
4634
+ canvasWidth: Number.parseFloat(grCanvasNode.style.width)
4635
+ }
4636
+ check('扩展图形:相机移动后重投影(屏幕位置跟着变)', grAfter.left !== grBefore.left, `${grBefore.left} → ${grAfter.left}`)
4637
+ check('扩展图形:默认尺寸保持屏幕像素,scaleWithView: true 时宽高随相机缩放',
4638
+ Math.abs(grAfter.width - grBefore.width) < 0.5 && Math.abs(grAfter.canvasWidth - grBefore.canvasWidth * 2) < 0.5,
4639
+ JSON.stringify({ path: [grBefore.width, grAfter.width], canvas: [grBefore.canvasWidth, grAfter.canvasWidth] }))
4640
+ const grScene = grView.snapshotScene({ background: false })
4641
+ check('扩展图形:snapshotScene 带 scene.graphics(屏幕坐标、尺寸、类型齐全)',
4642
+ grScene?.graphics?.length === 3 && grScene.graphics.every(item => typeof item.x === 'number' && typeof item.width === 'number') && grScene.graphics.some(item => item.type === 'path') && grScene.graphics.some(item => item.type === 'image') && grScene.graphics.some(item => item.type === 'canvas'),
4643
+ JSON.stringify(grScene?.graphics?.map(item => ({ id: item.id, type: item.type, width: item.width })) ?? null))
4644
+ const grDecodeSvg = url => {
4645
+ const base64 = String(url).slice('data:image/svg+xml;base64,'.length)
4646
+ return new TextDecoder().decode(Uint8Array.from(atob(base64), char => char.charCodeAt(0)))
4647
+ }
4648
+ const grSvg = grDecodeSvg(await grView.exportImage({ type: 'svg', includeLabels: false, background: 'none' }))
4649
+ check('扩展图形:SVG 导出包含路径与图片 / Canvas 的 <image>',
4650
+ grSvg.includes('M0 0H10V10H0Z') && (grSvg.match(/<image/g) ?? []).length >= 2,
4651
+ `path=${grSvg.includes('M0 0H10V10H0Z')} / image=${(grSvg.match(/<image/g) ?? []).length}`)
4652
+ const grPixelAt = async (url, x, y) => {
4653
+ const image = new Image()
4654
+ await new Promise((resolve, reject) => {
4655
+ image.onload = resolve
4656
+ image.onerror = () => reject(new Error('扩展图形导出图片加载失败'))
4657
+ image.src = url
4658
+ })
4659
+ const buffer = document.createElement('canvas')
4660
+ buffer.width = image.width
4661
+ buffer.height = image.height
4662
+ const context = buffer.getContext('2d')
4663
+ context.drawImage(image, 0, 0)
4664
+ return [...context.getImageData(Math.round(x), Math.round(y), 1, 1).data]
4665
+ }
4666
+ const grPoint = grView.project(0, 0)
4667
+ const grPixel = await grPixelAt(await grView.exportImage({ background: 'none', includeLabels: false, scale: 1 }), grPoint[0], grPoint[1])
4668
+ check('扩展图形:PNG 导出按「画布 → 图形 → 标签」次序合成(路径像素真的进了图)',
4669
+ grPixel[0] > 200 && grPixel[1] < 80 && grPixel[2] < 80 && grPixel[3] > 200,
4670
+ `rgba(${grPixel.join(',')})`)
4671
+ const grAdded = grView.addGraphic({ id: 'g-dyn', type: 'path', x: 0, y: 100, width: 20, height: 20, viewBox: [0, 0, 10, 10], d: 'M0 0H10V10H0Z', fill: '#3355ff', zIndex: 9 })
4672
+ const grBulk = grView.addGraphics([{ id: 'g-low', type: 'path', x: 0, y: -100, width: 20, height: 20, viewBox: [0, 0, 10, 10], d: 'M0 0H10V10H0Z', fill: '#999999', zIndex: -5 }])
4673
+ const grUpdated = grView.updateGraphic('g-dyn', { style: { width: 50, height: 30, fill: '#00aa00' } })
4674
+ const grBadType = grView.addGraphic({ id: 'g-bad', type: 'triangle', x: 0, y: 0, width: 10, height: 10, d: 'M0 0H10V10Z' })
4675
+ const grDuplicate = grView.addGraphics([{ id: 'g-low', type: 'path', x: 0, y: -100, width: 24, height: 20, viewBox: [0, 0, 10, 10], d: 'M0 0H10V10H0Z', fill: '#777777', zIndex: -4 }])
4676
+ const grOrdered = grView.getGraphics()
4677
+ check('扩展图形:addGraphic / addGraphics / updateGraphic / getGraphic 都能用(style.width / height 也能改)',
4678
+ grAdded?.id === 'g-dyn' && grBulk.length === 1 && grUpdated?.width === 50 && grUpdated?.height === 30 && grUpdated?.fill === '#00aa00' && grView.getGraphic('g-image')?.type === 'image' && grView.getGraphic('missing') === null,
4679
+ JSON.stringify({ added: grAdded?.id, bulk: grBulk.length, updated: grUpdated && { width: grUpdated.width, height: grUpdated.height, fill: grUpdated.fill } }))
4680
+ check('扩展图形:显式写错的 type 不静默按 path 处理,跳过并给出 warning', grBadType === null && grView.getGraphic('g-bad') === null && grWarns.some(message => message.includes('triangle') && message.includes('不支持')), grWarns.join(' | '))
4681
+ check('扩展图形:addGraphics 里重复 id 按更新覆盖并点名 warning', grDuplicate.length === 1 && grView.getGraphic('g-low')?.fill === '#777777' && grWarns.some(message => message.includes('addGraphics') && message.includes('g-low')), JSON.stringify({ count: grDuplicate.length, fill: grView.getGraphic('g-low')?.fill }))
4682
+ check('扩展图形:getGraphics 按 zIndex、再按加入顺序排', grOrdered[0]?.id === 'g-low' && grOrdered[grOrdered.length - 1]?.id === 'g-dyn', grOrdered.map(item => `${item.id}:${item.zIndex}`).join(' / '))
4683
+ const grRemoved = grView.removeGraphic('g-dyn')
4684
+ const grRemovedBulk = grView.removeGraphic('g-low')
4685
+ const grMissing = grView.removeGraphic('missing')
4686
+ const grCleared = grView.clearGraphics()
4687
+ check('扩展图形:removeGraphic / clearGraphics 返回值与清空结果一致',
4688
+ grRemoved?.id === 'g-dyn' && grRemovedBulk?.id === 'g-low' && grMissing === null && grCleared === 3 && grView.getGraphics().length === 0,
4689
+ JSON.stringify({ removed: grRemoved?.id, removedBulk: grRemovedBulk?.id, missing: grMissing, cleared: grCleared }))
4690
+ grView.updateOptions({ graphics: grBase })
4691
+ await settle(120)
4692
+ check('扩展图形:运行时清空后再传回同一份初始数组仍能恢复(配置来源不会因同一引用短路)',
4693
+ grView.getGraphics().length === 3 && Boolean(grNode('g-path')) && Boolean(grNode('g-image')) && Boolean(grNode('g-canvas')),
4694
+ `${grView.getGraphics().length} 条 / DOM ${grHost.querySelectorAll('.gis-view__graphic').length} 个`)
4695
+ grView.destroy()
4696
+ grHost.remove()
4697
+
4698
+ /* 这一轮新增:通道草稿样式 / 多回线预览 / 选起点后点空白取消 / WebGL 自定义层 */
4699
+ const { channelDraftArrowPlacement } = await import('../../src/gis-view/engine.js')
4700
+ const arrowQuarter = channelDraftArrowPlacement([[0, 0], [100, 0]], 0.25)
4701
+ const arrowDefault = channelDraftArrowPlacement([[0, 0], [100, 0]])
4702
+ const arrowClamped = channelDraftArrowPlacement([[0, 0], [100, 0]], 2)
4703
+ check('草稿箭头位置按弧长比例取点(25% / 缺省 50% / 越界夹到端点)',
4704
+ Math.abs(arrowQuarter?.center[0] - 25) < 1e-9 && Math.abs(arrowDefault?.center[0] - 50) < 1e-9 && Math.abs(arrowClamped?.center[0] - 100) < 1e-9 && arrowQuarter.unitX === 1,
4705
+ JSON.stringify({ quarter: arrowQuarter?.center, center: arrowDefault?.center, end: arrowClamped?.center }))
4706
+
4707
+ const draftHost = document.createElement('div')
4708
+ draftHost.style.cssText = 'width:520px;height:340px'
4709
+ document.body.appendChild(draftHost)
4710
+ const draftEvents = []
4711
+ const draftClicks = []
4712
+ const draftDraws = []
4713
+ const draftErrors = []
4714
+ const draftCustomDraw = (context) => {
4715
+ draftDraws.push(context)
4716
+ // 故意把 GL 状态改坏:组件应在 customDraw 返回后收回内置绘制依赖的状态
4717
+ context.gl.enable(context.gl.SCISSOR_TEST)
4718
+ context.gl.scissor(0, 0, 1, 1)
4719
+ context.gl.viewport(1, 1, 1, 1)
4720
+ context.gl.disable(context.gl.BLEND)
4721
+ }
4722
+ const draftView = createGisView(draftHost, {
4723
+ points: [{ id: 'd-a', x: 0, y: 0 }, { id: 'd-b', x: 180, y: 0 }, { id: 'd-c', x: 0, y: 140 }],
4724
+ lines: [],
4725
+ fitPadding: 70,
4726
+ showGrid: false,
4727
+ features: { channelDraft: true },
4728
+ onChannelDraft: payload => draftEvents.push(payload),
4729
+ onChannelDraftClick: payload => draftClicks.push(payload),
4730
+ onError: message => draftErrors.push(String(message))
4731
+ })
4732
+ await settle(150)
4733
+ const draftCanvas = draftView.canvas
4734
+ const dispatchDraftPointer = (type, x, y) => {
4735
+ const screen = draftView.project(x, y)
4736
+ const rect = draftCanvas.getBoundingClientRect()
4737
+ draftCanvas.dispatchEvent(new PointerEvent(type, {
4738
+ bubbles: true,
4739
+ cancelable: true,
4740
+ composed: true,
4741
+ pointerId: 19,
4742
+ pointerType: 'mouse',
4743
+ isPrimary: true,
4744
+ button: 0,
4745
+ clientX: rect.left + screen[0],
4746
+ clientY: rect.top + screen[1]
4747
+ }))
4748
+ }
4749
+ const clickDraftWorld = async (x, y) => {
4750
+ dispatchDraftPointer('pointerdown', x, y)
4751
+ dispatchDraftPointer('pointerup', x, y)
4752
+ await settle(40)
4753
+ }
4754
+ draftView.beginAddChannel({
4755
+ circuits: 3,
4756
+ routing: 'orthogonal',
4757
+ channelDraftStyle: {
4758
+ color: '#1456d8',
4759
+ width: 3,
4760
+ dot: 3,
4761
+ dotColor: '#e58a2b',
4762
+ arrow: true,
4763
+ arrowSize: 15,
4764
+ arrowPositions: [0.25, 0.75],
4765
+ arrowReverse: true,
4766
+ arrowColor: '#128457',
4767
+ circuitSpacing: 18,
4768
+ opacity: 0.65,
4769
+ customDraw: draftCustomDraw
4770
+ }
4771
+ })
4772
+ const draftStart = draftView.getAddChannelDraft()
4773
+ check('beginAddChannel 接收本次草稿完整样式,3 回通道与箭头位置都进载荷',
4774
+ draftStart?.phase === 'start' && draftStart.circuits === 3 && draftStart.style?.color === '#1456d8' && draftStart.style?.dotColor === '#e58a2b' && draftStart.style?.arrow === true && draftStart.style?.arrowReverse === true && draftStart.style?.arrowColor === '#128457' && draftStart.style?.circuitSpacing === 18 && draftStart.style?.opacity === 0.65 && draftStart.style?.arrowPositions.join(',') === '0.25,0.75' && draftStart.style?.customDraw === draftCustomDraw,
4775
+ JSON.stringify(draftStart?.style))
4776
+
4777
+ await clickDraftWorld(0, 0)
4778
+ const styleReturn = draftView.updateAddChannelStyle({ arrowPositions: [0.15, 0.5, 0.85], circuitSpacing: 24, opacity: 0.5 })
4779
+ dispatchDraftPointer('pointermove', 110, 100)
4780
+ await settle(30)
4781
+ const draftStyled = draftView.getAddChannelDraft()
4782
+ check('updateAddChannelStyle 字段级合并箭头位置 / 回线间距 / 透明度,并保持链式返回',
4783
+ styleReturn === draftView && draftStyled?.phase === 'from' && draftStyled.style.arrowPositions.join(',') === '0.15,0.5,0.85' && draftStyled.style.circuitSpacing === 24 && draftStyled.style.opacity === 0.5 && draftStyled.style.color === '#1456d8',
4784
+ JSON.stringify(draftStyled?.style))
4785
+
4786
+ draftDraws.length = 0
4787
+ await draftView.exportImage({ background: 'none', includeLabels: false, scale: 1 })
4788
+ const draftDraw = draftDraws.at(-1)
4789
+ const draftStarts = new Set((draftDraw?.circuitPaths ?? []).map(path => `${path[0]?.[0]?.toFixed(3)},${path[0]?.[1]?.toFixed(3)}`))
4790
+ check('customDraw 拿到最终 GL / 相机 / 完整路径与 3 条平行回线,可完全接管预览绘制',
4791
+ draftDraws.length > 0 && draftDraw?.gl === draftView.gl && draftDraw?.canvas === draftCanvas && draftDraw?.circuits === 3 && draftDraw?.circuitPaths.length === 3 && draftStarts.size === 3 && draftDraw?.path.length >= 2 && draftDraw?.kind === 'channel' && draftDraw?.phase === 'from' && draftDraw?.style.arrowPositions.join(',') === '0.15,0.5,0.85' && draftErrors.length === 0,
4792
+ JSON.stringify({ draws: draftDraws.length, circuits: draftDraw?.circuits, starts: [...draftStarts], path: draftDraw?.path?.length, errors: draftErrors }))
4793
+
4794
+ const draftResetReturn = draftView.updateAddChannelStyle(null)
4795
+ const draftReset = draftView.getAddChannelDraft()
4796
+ check('updateAddChannelStyle(null) 回到全局缺省样式(箭头关、单中点位置、间距 10)',
4797
+ draftResetReturn === draftView && draftReset?.style.arrow === false && draftReset.style.arrowPositions.join(',') === '0.5' && draftReset.style.circuitSpacing === 10 && draftReset.style.opacity === 1 && typeof draftReset.style.customDraw === 'undefined',
4798
+ JSON.stringify(draftReset?.style))
4799
+
4800
+ const draftDataBeforeCancel = draftView.getData()
4801
+ draftEvents.length = 0
4802
+ draftClicks.length = 0
4803
+ await clickDraftWorld(110, 100)
4804
+ const draftCancel = draftEvents.find(payload => payload.phase === 'cancel')
4805
+ const draftCancelClick = draftClicks.at(-1)
4806
+ check('已选起点后点节点以外的位置直接取消:草稿清空、cancel 同时给 onChannelDraft 与 onChannelDraftClick',
4807
+ draftView.getAddChannelDraft() === null && draftCancel?.reason === 'click-outside-node' && draftCancel?.hit === false && draftCancelClick?.phase === 'cancel' && draftCancelClick?.reason === 'click-outside-node' && draftView.getData().lines.length === draftDataBeforeCancel.lines.length,
4808
+ JSON.stringify({ draft: draftView.getAddChannelDraft(), event: draftCancel?.reason, click: draftCancelClick?.reason }))
4809
+
4810
+ draftView.beginAddChannel({ circuits: 2 })
4811
+ draftEvents.length = 0
4812
+ draftClicks.length = 0
4813
+ await clickDraftWorld(110, 100)
4814
+ check('还没选起点时点空白仍保持草稿并发送 pick(不会误取消)',
4815
+ draftView.getAddChannelDraft()?.phase === 'start' && draftClicks.at(-1)?.phase === 'pick' && draftClicks.at(-1)?.hit === false && !draftEvents.some(payload => payload.phase === 'cancel'),
4816
+ JSON.stringify({ draft: draftView.getAddChannelDraft()?.phase, click: draftClicks.at(-1)?.phase, events: draftEvents.map(payload => payload.phase) }))
4817
+ draftView.cancelAddChannel()
4818
+
4819
+ /* WebGL 自定义层:最终画布、排序 / 开关 / 卸载,以及每层开画前的状态重置 */
4820
+ let lowLayerCalls = 0
4821
+ let highLayerCalls = 0
4822
+ let highLayerState = null
4823
+ draftView.addWebGLLayer({
4824
+ id: 'test-high',
4825
+ order: 20,
4826
+ draw: ({ gl }) => {
4827
+ highLayerCalls += 1
4828
+ highLayerState = {
4829
+ scissor: gl.isEnabled(gl.SCISSOR_TEST),
4830
+ blend: gl.isEnabled(gl.BLEND),
4831
+ viewport: Array.from(gl.getParameter(gl.VIEWPORT)),
4832
+ framebuffer: gl.getParameter(gl.FRAMEBUFFER_BINDING)
4833
+ }
4834
+ }
4835
+ })
4836
+ draftView.addWebGLLayer({
4837
+ id: 'test-low',
4838
+ order: 10,
4839
+ draw: ({ gl }) => {
4840
+ lowLayerCalls += 1
4841
+ gl.enable(gl.SCISSOR_TEST)
4842
+ gl.scissor(0, 0, 2, 2)
4843
+ gl.viewport(3, 3, 4, 4)
4844
+ gl.disable(gl.BLEND)
4845
+ }
4846
+ })
4847
+ const layerOrder = draftView.getWebGLLayers()
4848
+ check('view.gl / addWebGLLayer / getWebGLLayers 可用,层按 order 排序(同 id 可覆盖)',
4849
+ typeof WebGL2RenderingContext !== 'undefined' && draftView.gl instanceof WebGL2RenderingContext && layerOrder.map(layer => layer.id).join(',') === 'test-low,test-high' && layerOrder.every(layer => typeof layer.draw === 'function') && draftView.getOptions().webglLayers.map(layer => layer.id).join(',') === 'test-low,test-high',
4850
+ JSON.stringify({ gl: Boolean(draftView.gl), order: layerOrder.map(layer => `${layer.id}:${layer.order}`), options: draftView.getOptions().webglLayers?.map(layer => layer.id) }))
4851
+
4852
+ await draftView.exportImage({ background: 'none', includeLabels: false, scale: 1 })
4853
+ check('自定义层在内置内容之后执行;前一层改坏的 scissor / viewport / blend 不会漏给下一层',
4854
+ lowLayerCalls > 0 && highLayerCalls > 0 && highLayerState?.scissor === false && highLayerState?.blend === true && highLayerState?.framebuffer === null && highLayerState?.viewport.join(',') === [0, 0, draftCanvas.width, draftCanvas.height].join(','),
4855
+ JSON.stringify({ low: lowLayerCalls, high: highLayerCalls, state: highLayerState }))
4856
+
4857
+ const lowBeforeDisabled = lowLayerCalls
4858
+ const highBeforeDisabled = highLayerCalls
4859
+ const disabledLayer = draftView.updateWebGLLayer('test-low', { enabled: false })
4860
+ await draftView.exportImage({ background: 'none', includeLabels: false, scale: 1 })
4861
+ check('enabled: false 只跳过这一层,其它层照常绘制',
4862
+ disabledLayer?.enabled === false && lowLayerCalls === lowBeforeDisabled && highLayerCalls > highBeforeDisabled,
4863
+ JSON.stringify({ low: [lowBeforeDisabled, lowLayerCalls], high: [highBeforeDisabled, highLayerCalls] }))
4864
+
4865
+ const reenabled = draftView.updateWebGLLayer('test-low', { enabled: true, order: 40 })
4866
+ await draftView.exportImage({ background: 'none', includeLabels: false, scale: 1 })
4867
+ draftView.updateWebGLLayer('test-high', { order: 50 })
4868
+ check('updateWebGLLayer 可原地改 enabled / order,排序马上反映在 getWebGLLayers',
4869
+ reenabled?.enabled === true && reenabled.order === 40 && draftView.getWebGLLayers().map(layer => `${layer.id}:${layer.order}`).join(',') === 'test-low:40,test-high:50',
4870
+ JSON.stringify(draftView.getWebGLLayers().map(layer => `${layer.id}:${layer.order}`)))
4871
+
4872
+ const removedLow = draftView.removeWebGLLayer('test-low')
4873
+ const missingLayer = draftView.updateWebGLLayer('missing', { order: 1 })
4874
+ const unsubscribe = draftView.addWebGLLayer({ id: 'test-off', order: 5, draw: () => {} })
4875
+ const offAdded = draftView.getWebGLLayers().some(layer => layer.id === 'test-off')
4876
+ unsubscribe()
4877
+ check('removeWebGLLayer / addWebGLLayer 的卸载函数 / 不存在的 id 都按接口约定返回',
4878
+ removedLow?.id === 'test-low' && missingLayer === null && draftView.removeWebGLLayer('missing') === null && offAdded && !draftView.getWebGLLayers().some(layer => layer.id === 'test-off'),
4879
+ JSON.stringify({ removed: removedLow?.id, missing: missingLayer, offAdded, layers: draftView.getWebGLLayers().map(layer => layer.id) }))
4880
+
4881
+ const staleUnload = draftView.addWebGLLayer({ id: 'test-replaced', order: 6, draw: () => {} })
4882
+ const replacementDraw = () => {}
4883
+ draftView.addWebGLLayer({ id: 'test-replaced', order: 6, draw: replacementDraw })
4884
+ staleUnload()
4885
+ const replacedLayer = draftView.getWebGLLayers().find(layer => layer.id === 'test-replaced')
4886
+ check('同 id 的 WebGL 层被覆盖后,旧卸载函数不会误删新层',
4887
+ replacedLayer?.draw === replacementDraw && (draftView.getOptions().webglLayers ?? []).some(layer => layer.id === 'test-replaced'),
4888
+ JSON.stringify({ layers: draftView.getWebGLLayers().map(layer => layer.id), options: draftView.getOptions().webglLayers?.map(layer => layer.id) }))
4889
+ draftView.removeWebGLLayer('test-replaced')
4890
+
4891
+ const clearedLayers = draftView.clearWebGLLayers()
4892
+ check('clearWebGLLayers 清空并同步 getOptions().webglLayers;requestRender 可链式调用',
4893
+ clearedLayers === 1 && draftView.getWebGLLayers().length === 0 && (draftView.getOptions().webglLayers ?? []).length === 0 && draftView.requestRender() === draftView,
4894
+ JSON.stringify({ cleared: clearedLayers, layers: draftView.getWebGLLayers().length, options: draftView.getOptions().webglLayers?.length }))
4895
+
4896
+ /* 隐藏 / 显示、节点拆分、通道变化对账:接口级自测 */
4897
+ const splitHost = document.createElement('div')
4898
+ splitHost.style.cssText = 'width:640px;height:380px'
4899
+ document.body.append(splitHost)
4900
+ const splitView = createGisView(splitHost, {
4901
+ points: [
4902
+ { id: 'sv-a', x: 0, y: 0, name: 'A' },
4903
+ { id: 'sv-b', x: 8, y: 0, name: 'B' },
4904
+ { id: 'sv-c', x: 0, y: 8, name: 'C' }
4905
+ ],
4906
+ lines: [
4907
+ { id: 'sv-ab', from: 'sv-a', to: 'sv-b', name: 'AB' },
4908
+ { id: 'sv-ab2', from: 'sv-a', to: 'sv-b', name: 'AB-2' },
4909
+ { id: 'sv-ac', from: 'sv-a', to: 'sv-c', name: 'AC' }
4910
+ ],
4911
+ channels: [{ id: 'sv-ch', from: 'sv-a', to: 'sv-b', lineIds: ['sv-ab', 'sv-ab2'] }],
4912
+ initialView: { scale: 1, center: [2, 2] },
4913
+ labelOptimize: false,
4914
+ showGrid: false,
4915
+ waypointEditable: true,
4916
+ history: { limit: 20 }
4917
+ })
4918
+ const splitOutcome = splitView.splitPoint('sv-a', [
4919
+ { x: -1, y: -1, name: 'A-左' },
4920
+ { x: 1, y: 1, name: 'A-右', lines: ['sv-ab'] }
4921
+ ])
4922
+ const splitData = splitView.getData()
4923
+ const splitB = splitData.points.find(point => point.name === 'A-右')
4924
+ const splitAb = splitData.lines.find(line => line.id === 'sv-ab')
4925
+ const splitAb2 = splitData.lines.find(line => line.id === 'sv-ab2')
4926
+ const splitAc = splitData.lines.find(line => line.id === 'sv-ac')
4927
+ const splitChannel = splitView.getOptions().channels.find(channel => channel.id === 'sv-ch')
4928
+ check('splitPoint:第一份复用原下标 / id,第二份追加并拿到新 id', splitOutcome.ok === true && splitOutcome.items.length === 2 && splitOutcome.indexes[0] === 0 && splitData.points.length === 4 && splitOutcome.pointIds[0] === 'sv-a' && splitOutcome.pointIds[1] !== 'sv-a', JSON.stringify({ ids: splitOutcome.pointIds, indexes: splitOutcome.indexes, points: splitData.points.length }))
4929
+ check('splitPoint:显式分给第二份的线路改绑,未分配的线路跟第一份', splitAb?.from === splitB?.id && splitAb2?.from === 'sv-a' && splitAc?.from === 'sv-a', JSON.stringify({ ab: splitAb?.from, ab2: splitAb2?.from, ac: splitAc?.from, b: splitB?.id }))
4930
+ check('splitPoint:通道没写 channels 时跟随第一条被分配过的成员回线', String(splitChannel?.from) === String(splitB?.id) && String(splitChannel?.to) === 'sv-b', JSON.stringify({ from: splitChannel?.from, to: splitChannel?.to, b: splitB?.id }))
4931
+ check('splitPoint:返回可直接提交的 changeSet 与 splitPoint 计数', splitOutcome.changes?.removedPoint?.id === 'sv-a' && splitOutcome.changes?.points?.length === 2 && splitOutcome.changes?.lines?.some(line => line.id === 'sv-ab') && splitOutcome.changes?.channels?.some(channel => channel.id === 'sv-ch') && splitOutcome.linesChanged === 3 && splitOutcome.channelsChanged === 1, JSON.stringify({ lines: splitOutcome.changes?.lines?.map(line => line.id), channels: splitOutcome.changes?.channels?.map(channel => channel.id) }))
4932
+ const changedAfterSplit = splitView.getChangedChannels().find(channel => channel.id === 'sv-ch')
4933
+ check('getChangedChannels:拆分后的端点变化入账,reason 带 endpoints', Boolean(changedAfterSplit) && changedAfterSplit.endpointsChanged && changedAfterSplit.reasons.includes('endpoints'), JSON.stringify(changedAfterSplit?.reasons))
4934
+ splitView.undo()
4935
+ const undoData = splitView.getData()
4936
+ check('splitPoint:撤销一步回到拆分前(节点、线路、通道端点全恢复)', undoData.points.length === 3 && undoData.lines.find(line => line.id === 'sv-ab')?.from === 'sv-a' && splitView.getOptions().channels.find(channel => channel.id === 'sv-ch')?.from === 'sv-a', JSON.stringify({ points: undoData.points.length, from: undoData.lines.find(line => line.id === 'sv-ab')?.from }))
4937
+ splitView.redo()
4938
+ check('splitPoint:重做恢复拆分结果', splitView.getData().points.length === 4 && splitView.getData().lines.find(line => line.id === 'sv-ab')?.from !== 'sv-a', JSON.stringify({ points: splitView.getData().points.length }))
4939
+ splitView.clearChangedChannels()
4940
+ check('clearChangedChannels:全部对账后最近变化为空', splitView.getChangedChannels().length === 0, JSON.stringify(splitView.getChangedChannels().map(item => item.id)))
4941
+ const addedSplitGroup = splitView.addChannelGroup('sv-ch', ['sv-ab2'])
4942
+ const fullGroupChange = splitView.getChangedChannels().find(channel => channel.id === 'sv-ch')
4943
+ const ignoredGroupChange = splitView.getChangedChannels({ ignoreGroups: true }).find(channel => channel.id === 'sv-ch')
4944
+ check('getChangedChannels({ ignoreGroups }):分组新增 / 删除不入账', addedSplitGroup.ok === true && fullGroupChange?.groupsChanged === true && !ignoredGroupChange, JSON.stringify({ added: addedSplitGroup.ok, full: fullGroupChange?.reasons, ignored: ignoredGroupChange?.reasons }))
4945
+ splitView.clearChangedChannels()
4946
+ if (addedSplitGroup.ok) {
4947
+ splitView.removeChannelGroup('sv-ch', addedSplitGroup.groupId)
4948
+ splitView.clearChangedChannels()
4949
+ }
4950
+ const splitGroup = splitView.getChannelGroups('sv-ch')[0]
4951
+ splitView.addChannelGroupWaypoint('sv-ch', splitGroup?.id)
4952
+ const waypointChange = splitView.getChangedChannels({ reasons: ['waypoints'] }).find(channel => channel.id === 'sv-ch')
4953
+ const ignoredWaypointChange = splitView.getChangedChannels({ ignoreGroups: true, reasons: ['waypoints'] }).find(channel => channel.id === 'sv-ch')
4954
+ check('getChangedChannels({ reasons: [waypoints] }):只返回拐点变化并收窄 reasons', Boolean(waypointChange) && waypointChange.waypointsChanged && waypointChange.reasons.length === 1 && waypointChange.reasons[0] === 'waypoints', JSON.stringify(waypointChange?.reasons))
4955
+ check('getChangedChannels({ ignoreGroups }):已有分组的拐点变化照常报', Boolean(ignoredWaypointChange) && ignoredWaypointChange.waypointsChanged && ignoredWaypointChange.reasons.length === 1 && ignoredWaypointChange.reasons[0] === 'waypoints', JSON.stringify(ignoredWaypointChange?.reasons))
4956
+ splitView.clearChangedChannels('sv-ch')
4957
+ splitView.clearChangedLines()
4958
+ splitView.updateLine('sv-ac', { name: 'AC-变更' })
4959
+ const fieldLineChange = splitView.getChangedLines({ reasons: ['fields'] }).find(line => line.id === 'sv-ac')
4960
+ check('getChangedLines({ reasons: [fields] }):线路字段变化入账', Boolean(fieldLineChange) && fieldLineChange.fields.includes('name') && fieldLineChange.reasons.length === 1 && fieldLineChange.reasons[0] === 'fields', JSON.stringify(fieldLineChange?.reasons))
4961
+ splitView.clearChangedLines('sv-ac')
4962
+ splitView.setLineWaypoints('sv-ac', [[4, 4]])
4963
+ const pathLineChange = splitView.getChangedLines({ reasons: ['path'] }).find(line => line.id === 'sv-ac')
4964
+ check('getChangedLines:path 变化独立记 path,读取不改基准', Boolean(pathLineChange) && pathLineChange.pathChanged && pathLineChange.reasons.includes('path') && splitView.getChangedLines({ reasons: ['path'] }).length === 1, JSON.stringify(pathLineChange?.reasons))
4965
+ splitView.clearChangedLines()
4966
+ const hideChannel = splitView.hideFeatures('channel', 'sv-ch')
4967
+ const hiddenChannelRead = splitView.getChannel('sv-ch')
4968
+ const hideChanged = splitView.getChangedChannels().find(channel => channel.id === 'sv-ch')
4969
+ check('hideFeatures(channel):visible 落数据、通道退场,变化账本记 visibility', hideChannel.ok === true && splitView.getOptions().channels.find(channel => channel.id === 'sv-ch')?.visible === false && hiddenChannelRead?.lineIndexes?.length === 0 && hideChanged?.visibilityChanged === true, JSON.stringify({ ok: hideChannel.ok, paths: hiddenChannelRead?.lineIndexes?.length, reasons: hideChanged?.reasons }))
4970
+ const showChannel = splitView.showFeatures('channel', 'sv-ch')
4971
+ check('showFeatures(channel):隐藏后原样恢复(回线、分组、拐点都还在)', showChannel.ok === true && splitView.getChannel('sv-ch')?.lineIndexes?.length === 2 && splitView.getOptions().channels.find(channel => channel.id === 'sv-ch')?.visible === true && splitView.getChannelGroups('sv-ch').length === 1, JSON.stringify({ lines: splitView.getChannel('sv-ch')?.lineIndexes?.length, groups: splitView.getChannelGroups('sv-ch').length }))
4972
+ const generatedId = splitView.getData().points.find(point => point.name === 'A-右')?.id
4973
+ const hidePoint = splitView.hideFeatures('point', generatedId)
4974
+ const hideLine = splitView.hideFeatures('line', 'sv-ac')
4975
+ check('hideFeatures(point / line):只改 visible,数据不删', hidePoint.ok === true && hideLine.ok === true && splitView.getData().points.find(point => point.id === generatedId)?.visible === false && splitView.getData().lines.find(line => line.id === 'sv-ac')?.visible === false, JSON.stringify({ point: hidePoint.ok, line: hideLine.ok }))
4976
+ const hiddenLineChange = splitView.getChangedLines().find(line => line.id === 'sv-ac')
4977
+ check('getChangedLines:线路显隐变化入账', Boolean(hiddenLineChange) && hiddenLineChange.visibilityChanged && hiddenLineChange.reasons.includes('visibility'), JSON.stringify(hiddenLineChange?.reasons))
4978
+ splitView.showFeatures('point', generatedId)
4979
+ splitView.showFeatures('line', 'sv-ac')
4980
+ check('showFeatures(point / line):恢复后 visible 回到 true', splitView.getData().points.find(point => point.id === generatedId)?.visible === true && splitView.getData().lines.find(line => line.id === 'sv-ac')?.visible === true, '')
4981
+ splitView.clearChangedLines('sv-ac')
4982
+ splitView.updateLine('sv-ac', { name: 'AC-删除前' })
4983
+ const lineChangedBeforeDelete = splitView.getChangedLines().some(line => line.id === 'sv-ac')
4984
+ splitView.removeLine('sv-ac')
4985
+ const lineAfterDelete = splitView.getChangedLines().find(line => line.id === 'sv-ac')
4986
+ check('getChangedLines:线路删除同步清账,不再返回已删线路', lineChangedBeforeDelete && !lineAfterDelete, JSON.stringify({ before: lineChangedBeforeDelete, after: lineAfterDelete ?? null }))
4987
+ splitView.clearChangedChannels()
4988
+ splitView.updateChannel('sv-ch', { name: '待删通道' })
4989
+ const channelChangedBeforeDelete = splitView.getChangedChannels().some(channel => channel.id === 'sv-ch')
4990
+ splitView.removeChannel('sv-ch')
4991
+ const channelAfterDelete = splitView.getChangedChannels().find(channel => channel.id === 'sv-ch')
4992
+ check('getChangedChannels:通道删除同步清账,不再返回已删通道', channelChangedBeforeDelete && !channelAfterDelete, JSON.stringify({ before: channelChangedBeforeDelete, after: channelAfterDelete ?? null }))
4993
+ const lineOnlyHost = document.createElement('div')
4994
+ lineOnlyHost.style.cssText = 'width:480px;height:280px'
4995
+ document.body.append(lineOnlyHost)
4996
+ const lineOnlyView = createGisView(lineOnlyHost, {
4997
+ points: [
4998
+ { id: 'lo-a', x: 0, y: 0 },
4999
+ { id: 'lo-b', x: 8, y: 0 }
5000
+ ],
5001
+ lines: [{ id: 'lo-ab', from: 'lo-a', to: 'lo-b', name: 'AB' }],
5002
+ initialView: { scale: 1, center: [4, 0] },
5003
+ labelOptimize: false,
5004
+ showGrid: false
5005
+ })
5006
+ const lineOnlyInitial = lineOnlyView.getChangedLines().length === 0
5007
+ lineOnlyView.updateLine('lo-ab', { name: 'AB-变更' })
5008
+ const lineOnlyChange = lineOnlyView.getChangedLines({ reasons: ['fields'] }).find(line => line.id === 'lo-ab')
5009
+ lineOnlyView.removeLine('lo-ab')
5010
+ const lineOnlyAfterDelete = lineOnlyView.getChangedLines().find(line => line.id === 'lo-ab')
5011
+ check('getChangedLines:只有节点和线路时独立对账,删线路后同步清账', lineOnlyInitial && Boolean(lineOnlyChange) && lineOnlyChange.fields.includes('name') && !lineOnlyAfterDelete, JSON.stringify({ initial: lineOnlyInitial, change: lineOnlyChange?.reasons, after: lineOnlyAfterDelete ?? null }))
5012
+ lineOnlyView.destroy()
5013
+ lineOnlyHost.remove()
5014
+ splitView.destroy()
5015
+ splitHost.remove()
5016
+ draftView.destroy()
5017
+ draftHost.remove()
4347
5018
  auView.destroy()
4348
5019
  auHost.remove()
4349
5020
  symView.destroy()