gis-view-webgl2 0.1.2 → 0.1.3

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 (61) hide show
  1. package/README.md +12 -8
  2. package/dist/gis-view.css +1 -1
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.d.ts +309 -59
  5. package/dist/index.js +1 -1
  6. package/docs/AI/345/257/271/350/257/235/346/261/207/346/200/273.md +117 -0
  7. package/docs/GisView.md +2 -2
  8. 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 +107 -0
  9. package/docs/api/index.html +1 -1
  10. package/docs/demos/ApiCoverageDemo.vue +644 -0
  11. package/docs/demos/ChannelDemo.vue +14 -12
  12. package/docs/demos/ClusterDemo.vue +251 -0
  13. package/docs/demos/CopyMergeDemo.vue +3 -4
  14. package/docs/demos/EventDemo.vue +3 -3
  15. package/docs/demos/GisViewDemo.vue +64 -8809
  16. package/docs/demos/api-index.js +48 -41
  17. package/docs/demos/demos.js +33 -16
  18. package/docs/demos/gis-view-demo/GisViewDemoCardHeader.vue +28 -0
  19. package/docs/demos/gis-view-demo/GisViewDemoDialogs.vue +82 -0
  20. package/docs/demos/gis-view-demo/GisViewDemoFooter.vue +16 -0
  21. package/docs/demos/gis-view-demo/GisViewDemoHeading.vue +16 -0
  22. package/docs/demos/gis-view-demo/GisViewDemoPanelStack.vue +71 -0
  23. package/docs/demos/gis-view-demo/GisViewDemoStage.vue +219 -0
  24. package/docs/demos/gis-view-demo/demo.css +1230 -0
  25. package/docs/demos/gis-view-demo/demoContext.js +12 -0
  26. package/docs/demos/gis-view-demo/panels/DataPanels.vue +303 -0
  27. package/docs/demos/gis-view-demo/panels/OpsPanels.vue +174 -0
  28. package/docs/demos/gis-view-demo/panels/StylePanels.vue +561 -0
  29. package/docs/demos/gis-view-demo/panels/ViewPanels.vue +396 -0
  30. package/docs/demos/gis-view-demo/useGisViewDemo.js +6188 -0
  31. 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 +3 -2
  32. package/docs/internals/16-03-/344/274/230/345/214/226/344/270/216/345/217/226/350/210/215-/344/270/213.md +1 -1
  33. package/docs/manual/02-/345/235/220/346/240/207/344/270/216/346/225/260/346/215/256.md +3 -2
  34. package/docs/manual/03-/351/205/215/347/275/256/351/241/271.md +87 -14
  35. package/docs/manual/04-/344/272/213/344/273/266/344/270/216/345/233/236/350/260/203.md +8 -8
  36. 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 +40 -3
  37. package/docs/manual/08-/351/200/211/344/270/255/344/270/216/346/213/220/347/202/271.md +26 -0
  38. package/docs/manual/09-/351/200/232/351/201/223/344/270/216/345/244/232/345/233/236/347/272/277.md +6 -2
  39. 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 +39 -7
  40. package/docs/manual/12-/346/240/207/347/255/276/344/270/216/350/243/205/351/245/260.md +13 -4
  41. package/docs/manual/13-/345/212/250/346/225/210/344/270/216/345/233/276/345/205/203.md +3 -0
  42. package/docs/manual/14-/350/247/206/345/233/276/344/270/216/347/255/233/351/200/211.md +35 -19
  43. package/docs/manual/15-/345/257/274/345/207/272/344/270/216/351/233/206/346/210/220.md +5 -0
  44. package/docs/manual/16-/345/256/236/344/276/213/346/226/271/346/263/225/346/200/273/350/241/250.md +12 -6
  45. package/docs/manual/17-/345/217/202/346/225/260/351/200/237/346/237/245.md +39 -22
  46. package/docs/manual/18-/351/200/220/346/226/271/346/263/225/347/244/272/344/276/213.md +52 -13
  47. package/docs/manual/20-/346/200/247/350/203/275/344/270/216/345/244/247/346/225/260/346/215/256.md +25 -1
  48. 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 +5 -5
  49. package/docs/steps/README.md +17 -11
  50. package/docs/steps/selftest-actions.html +3 -5
  51. package/docs/steps/selftest-demo.html +27 -18
  52. package/docs/steps/selftest-demos.html +387 -27
  53. package/docs/steps/selftest-docs.html +4 -4
  54. package/docs/steps/selftest-effects.html +200 -0
  55. package/docs/steps/selftest-features.html +512 -11
  56. package/docs/steps/selftest-labels.html +74 -0
  57. package/docs/steps/selftest-perf.html +293 -20
  58. package/docs/steps/selftest-scale.html +52 -29
  59. package/docs/steps/step-10-full.js +1 -1
  60. package/docs/steps/step-12-events.js +1 -1
  61. package/package.json +1 -1
@@ -1592,6 +1592,243 @@
1592
1592
  cwView.destroy()
1593
1593
  cwHost.remove()
1594
1594
 
1595
+ /* 问题 3(本轮):setFilter 只让节点不画,不该改变仍显示的通道宽度 ——
1596
+ 从前锚点被筛掉就不在 pointByIndex 里,通道退回默认宽度;只筛掉一端时还会改按另一端尺寸算 */
1597
+ const cwfHost = document.createElement('div')
1598
+ cwfHost.style.cssText = 'width:320px;height:200px'
1599
+ document.body.appendChild(cwfHost)
1600
+ const cwfView = createGisView(cwfHost, {
1601
+ points: [
1602
+ { id: 'cwf-a', x: -30, y: 0, size: 8, symbol: 'square', level: 1 },
1603
+ { id: 'cwf-b', x: 30, y: 0, size: 24, symbol: 'square', level: 2 }
1604
+ ],
1605
+ lines: [
1606
+ { id: 'cwf-l1', from: 'cwf-a', to: 'cwf-b', width: 1 },
1607
+ { id: 'cwf-l2', from: 'cwf-a', to: 'cwf-b', width: 1 }
1608
+ ],
1609
+ channels: [{ id: 'cwf-ch', from: 'cwf-a', to: 'cwf-b', lineIds: ['cwf-l1', 'cwf-l2'], tangentInset: 0 }],
1610
+ initialView: { center: [0, 0], scale: 1 },
1611
+ showGrid: false
1612
+ })
1613
+ await settle(150)
1614
+ const cwfGap = () => {
1615
+ const tops = cwfView.listLines().filter(line => String(line.item.id).startsWith('cwf-')).map(line => line.path[0][1])
1616
+ return tops.length === 2 ? Math.abs(tops[0] - tops[1]) : 0
1617
+ }
1618
+ const cwfBase = cwfGap()
1619
+ check('筛选前通道宽度基线:两端取小的尺寸 8(tangentInset: 0)', Math.abs(cwfBase - 8) < 0.01, `间距 ${cwfBase}`)
1620
+ cwfView.setFilter({ points: { level: 2 }, cascade: false })
1621
+ await settle(100)
1622
+ check('setFilter 只筛掉小尺寸锚点:回线仍按两端较小值取宽,不跳到大节点尺寸', Math.abs(cwfGap() - cwfBase) < 0.01, `${cwfBase} → ${cwfGap()}`)
1623
+ cwfView.setFilter({ points: { level: 0 }, cascade: false })
1624
+ await settle(100)
1625
+ check('setFilter 把两端锚点都筛掉:通道回线仍显示,宽度不退到默认 channelWidth', cwfView.listLines().filter(line => String(line.item.id).startsWith('cwf-')).length === 2 && Math.abs(cwfGap() - cwfBase) < 0.01, `间距 ${cwfBase} → ${cwfGap()}`)
1626
+ cwfView.setFilter(null)
1627
+ await settle(100)
1628
+ check('清除筛选:通道宽度与筛选前完全一致', Math.abs(cwfGap() - cwfBase) < 0.01, `间距 ${cwfBase} → ${cwfGap()}`)
1629
+ // 可见锚点若按选中 / 悬停后的尺寸算,筛掉另一端时它会变成唯一锚点,通道宽度就会跳
1630
+ cwfView.updateOptions({ pointSelectStyle: { sizeScale: 2 } })
1631
+ cwfView.selectFeatures('cwf-b')
1632
+ await settle(80)
1633
+ cwfView.setFilter({ points: { level: 1 }, cascade: false })
1634
+ await settle(100)
1635
+ check('筛选叠加选中样式:锚点宽度仍按中性尺寸取,不随选中放大而跳', Math.abs(cwfGap() - cwfBase) < 0.01, `间距 ${cwfBase} → ${cwfGap()}`)
1636
+ cwfView.setFilter({ points: { level: 0 }, cascade: false })
1637
+ await settle(100)
1638
+ check('选中状态下把两端都筛掉:通道宽度仍保持原值', Math.abs(cwfGap() - cwfBase) < 0.01, `间距 ${cwfBase} → ${cwfGap()}`)
1639
+ cwfView.setFilter(null)
1640
+ await settle(80)
1641
+ cwfView.destroy()
1642
+ cwfHost.remove()
1643
+
1644
+ /* 分组通道 + 成员回线被筛:筛选只影响可见性,保留下来的回线仍按原来的车道与宽度走 */
1645
+ const gfwHost = document.createElement('div')
1646
+ gfwHost.style.cssText = 'width:340px;height:220px'
1647
+ document.body.appendChild(gfwHost)
1648
+ const gfwView = createGisView(gfwHost, {
1649
+ points: [
1650
+ { id: 'gfw-a', x: -30, y: 0, size: 8, symbol: 'square', level: 1 },
1651
+ { id: 'gfw-b', x: 30, y: 0, size: 24, symbol: 'square', level: 2 }
1652
+ ],
1653
+ lines: [
1654
+ { id: 'gfw-l1', from: 'gfw-a', to: 'gfw-b', color: '#ff0000' },
1655
+ { id: 'gfw-l2', from: 'gfw-a', to: 'gfw-b', color: '#ff0000' },
1656
+ { id: 'gfw-l3', from: 'gfw-a', to: 'gfw-b', color: '#0000ff' },
1657
+ { id: 'gfw-l4', from: 'gfw-a', to: 'gfw-b', color: '#0000ff' }
1658
+ ],
1659
+ channels: [{
1660
+ id: 'gfw-ch', from: 'gfw-a', to: 'gfw-b', lineIds: ['gfw-l1', 'gfw-l2', 'gfw-l3', 'gfw-l4'],
1661
+ tangentInset: 0, offsetMode: 'inset', inset: 0.1,
1662
+ groups: [
1663
+ { id: 'gfw-top', lineIds: ['gfw-l1', 'gfw-l2'], path: [[0, 24]] },
1664
+ { id: 'gfw-bot', lineIds: ['gfw-l3', 'gfw-l4'], path: [[0, -24]] }
1665
+ ]
1666
+ }],
1667
+ initialView: { center: [0, 0], scale: 1 },
1668
+ showGrid: false
1669
+ })
1670
+ await settle(150)
1671
+ const gfwPaths = () => Object.fromEntries(gfwView.listLines()
1672
+ .filter(line => String(line.item.id).startsWith('gfw-'))
1673
+ .map(line => [String(line.item.id), JSON.stringify(line.path)]))
1674
+ const gfwBase = gfwPaths()
1675
+ const gfwGeometry = () => JSON.stringify({
1676
+ channels: gfwView.getChannels(),
1677
+ groups: gfwView.getChannelGroups('gfw-ch')
1678
+ })
1679
+ const gfwGeometryBase = gfwGeometry()
1680
+ gfwView.setFilter({ points: { level: 0 }, cascade: false })
1681
+ await settle(100)
1682
+ const gfwNoPoints = gfwPaths()
1683
+ check('分组通道:两端节点被筛掉时四条回线仍在原车道', Object.keys(gfwNoPoints).length === 4 && Object.keys(gfwBase).every(id => gfwNoPoints[id] === gfwBase[id]), JSON.stringify({ base: gfwBase, current: gfwNoPoints }))
1684
+ gfwView.setFilter({ lines: { color: '#ff0000' }, cascade: false })
1685
+ await settle(100)
1686
+ const gfwFiltered = gfwPaths()
1687
+ check('分组通道:筛掉一半成员回线后,保留回线的几何与筛选前完全一致', Object.keys(gfwFiltered).length === 2 && gfwFiltered['gfw-l1'] === gfwBase['gfw-l1'] && gfwFiltered['gfw-l2'] === gfwBase['gfw-l2'], JSON.stringify({ base: [gfwBase['gfw-l1'], gfwBase['gfw-l2']], current: gfwFiltered }))
1688
+ gfwView.setFilter(null)
1689
+ await settle(80)
1690
+ const gfwRestored = gfwPaths()
1691
+ check('分组通道:清除筛选后四条回线逐顶点回到筛选前的几何', Object.keys(gfwRestored).length === 4 && Object.keys(gfwBase).every(id => gfwRestored[id] === gfwBase[id]), JSON.stringify({ base: gfwBase, current: gfwRestored }))
1692
+ gfwView.setFilter({ lines: { color: '#ff0000' }, cascade: false })
1693
+ await settle(80)
1694
+ gfwView.setFilter(null)
1695
+ await settle(80)
1696
+ const gfwRestoredAgain = gfwPaths()
1697
+ check('分组通道:连续隐藏 / 恢复成员回线后仍按原车道与骨架绘制', Object.keys(gfwRestoredAgain).length === 4 && Object.keys(gfwBase).every(id => gfwRestoredAgain[id] === gfwBase[id]), JSON.stringify({ base: gfwBase, current: gfwRestoredAgain }))
1698
+ // 默认 binding 级联会把两端节点、绑定回线与整条通道一起收起;恢复时引擎必须按当前锚点重新接上,
1699
+ // 不能沿用「隐藏期间没有骨架」的通道状态。这里连通道 / 分组读数一起对,避免只看 listLines 漏掉串线。
1700
+ gfwView.setFilter({ points: { level: 0 } })
1701
+ await settle(100)
1702
+ check('分组通道:默认 binding 级联筛掉两端节点时回线一起收起', gfwView.listLines().filter(line => String(line.item.id).startsWith('gfw-')).length === 0, JSON.stringify({ lines: gfwPaths() }))
1703
+ gfwView.setFilter(null)
1704
+ await settle(100)
1705
+ check('分组通道:默认级联恢复后回线、通道骨架与分组骨架逐项回到原值', Object.keys(gfwPaths()).length === 4 && Object.keys(gfwBase).every(id => gfwPaths()[id] === gfwBase[id]) && gfwGeometry() === gfwGeometryBase, JSON.stringify({ base: gfwGeometryBase, current: gfwGeometry() }))
1706
+ gfwView.hideFeatures('point', ['gfw-a', 'gfw-b'])
1707
+ await settle(100)
1708
+ gfwView.showFeatures('point', ['gfw-a', 'gfw-b'])
1709
+ await settle(100)
1710
+ check('分组通道:visible 隐藏 / 显示两端节点后几何仍逐项回到原值', Object.keys(gfwPaths()).length === 4 && Object.keys(gfwBase).every(id => gfwPaths()[id] === gfwBase[id]) && gfwGeometry() === gfwGeometryBase, JSON.stringify({ base: gfwGeometryBase, current: gfwGeometry() }))
1711
+ gfwView.destroy()
1712
+ gfwHost.remove()
1713
+
1714
+ /* 焦点类型消歧 + 分组选中 + 拐点选中 */
1715
+ const faHost = document.createElement('div')
1716
+ faHost.style.cssText = 'width:420px;height:260px'
1717
+ document.body.appendChild(faHost)
1718
+ const faView = createGisView(faHost, {
1719
+ points: [
1720
+ { id: 'same', x: 0, y: 0 },
1721
+ { id: 'fa-low-a', x: -50, y: -40 },
1722
+ { id: 'fa-low-b', x: 50, y: -40 },
1723
+ { id: 'fa-high-a', x: -50, y: 40 },
1724
+ { id: 'fa-high-b', x: 50, y: 40 }
1725
+ ],
1726
+ lines: [
1727
+ { id: 'same', path: [[-100, 0], [-80, 0]] },
1728
+ { id: 'fa-l1', from: 'fa-low-a', to: 'fa-low-b' },
1729
+ { id: 'fa-l2', from: 'fa-low-a', to: 'fa-low-b' },
1730
+ { id: 'fa-l3', from: 'fa-high-a', to: 'fa-high-b' },
1731
+ { id: 'fa-l4', from: 'fa-high-a', to: 'fa-high-b' },
1732
+ { id: 'fa-around', path: [[0, 80], [50, 100], [100, 80]] }
1733
+ ],
1734
+ channels: [
1735
+ { id: 'fa-low-ch', from: 'fa-low-a', to: 'fa-low-b', lineIds: ['fa-l1', 'fa-l2'], groups: [{ id: 'fa-low', lineIds: ['fa-l1', 'fa-l2'] }] },
1736
+ { id: 'fa-high-ch', from: 'fa-high-a', to: 'fa-high-b', lineIds: ['fa-l3', 'fa-l4'], groups: [{ id: 'fa-high', lineIds: ['fa-l3', 'fa-l4'] }] }
1737
+ ],
1738
+ initialView: { center: [0, 0], scale: 1 },
1739
+ waypointEditable: true,
1740
+ showGrid: false
1741
+ })
1742
+ await settle(100)
1743
+ faView.focusOn('same')
1744
+ const faStringCenter = faView.getCamera().center
1745
+ faView.focusOn({ type: 'line', id: 'same' })
1746
+ const faLineCenter = faView.getCamera().center
1747
+ faView.focusOn({ type: 'point', id: 'same' })
1748
+ const faPointCenter = faView.getCamera().center
1749
+ faView.focusOn({ type: 'channel', id: 'fa-low-ch' })
1750
+ const faChannelCenter = faView.getCamera().center
1751
+ faView.focusOn({ type: 'group', groupId: 'fa-high' })
1752
+ const faHighCenter = faView.getCamera().center
1753
+ faView.focusOn({ type: 'group', channelId: 'fa-low-ch', groupId: 'fa-low' })
1754
+ const faLowCenter = faView.getCamera().center
1755
+ check('focusOn:字符串同 id 先聚焦节点,显式 type 可聚焦同名线路', Math.abs(faStringCenter[0]) < 0.01 && Math.abs(faPointCenter[0]) < 0.01 && Math.abs(faLineCenter[0] + 90) < 0.01, JSON.stringify({ string: faStringCenter, point: faPointCenter, line: faLineCenter }))
1756
+ check('focusOn:显式通道 / 分组类型按各自范围居中(groupId 唯一时可不写 channelId)', Math.abs(faChannelCenter[0]) < 0.01 && Math.abs(faHighCenter[1] - 40) < 0.01 && Math.abs(faLowCenter[1] + 40) < 0.01, JSON.stringify({ channel: faChannelCenter, high: faHighCenter, low: faLowCenter }))
1757
+ faView.selectFeatures({ type: 'group', channelId: 'fa-low-ch', groupId: 'fa-low' })
1758
+ const faSelectedGroup = faView.getSelections().filter(item => item.type === 'line').map(item => item.item.id).sort()
1759
+ faView.selectFeatures({ type: 'group', groupId: 'fa-high' })
1760
+ const faSelectedUnique = faView.getSelections().filter(item => item.type === 'line').map(item => item.item.id).sort()
1761
+ check('selectFeatures:type=group 与唯一 groupId 都只选中该分组', faSelectedGroup.join(',') === 'fa-l1,fa-l2' && faSelectedUnique.join(',') === 'fa-l3,fa-l4', JSON.stringify({ low: faSelectedGroup, high: faSelectedUnique }))
1762
+ faView.selectWaypoint('fa-around', 1)
1763
+ const faWaypointSelected = faView.getWaypoints().some(item => item.itemId === undefined && item.lineIndex === 5 && item.pathIndex === 1 && item.selected)
1764
+ const faWaypointLineSelected = faView.getSelections().some(item => item.type === 'line' && item.item.id === 'fa-around')
1765
+ faView.selectFeatures('fa-around')
1766
+ const faWaypointCleared = faView.getWaypoints().filter(item => item.lineIndex === 5).every(item => !item.selected)
1767
+ check('selectWaypoint:按线路 + 下标选中拐点并带选线路,selectFeatures 会清掉拐点选中', faWaypointSelected && faWaypointLineSelected && faWaypointCleared, JSON.stringify({ selected: faWaypointSelected, line: faWaypointLineSelected, cleared: faWaypointCleared, all: faView.getWaypoints(), lines: faView.getData().lines }))
1768
+ faView.destroy()
1769
+ faHost.remove()
1770
+
1771
+ /* lineFollowsNode: false:隐藏 / 筛选 / 删除只动节点,回线与通道停在最后坐标 */
1772
+ const lfHost = document.createElement('div')
1773
+ lfHost.style.cssText = 'width:360px;height:220px'
1774
+ document.body.appendChild(lfHost)
1775
+ const lfView = createGisView(lfHost, {
1776
+ points: [
1777
+ { id: 'lf-a', x: -30, y: 0, size: 16, level: 0 },
1778
+ { id: 'lf-b', x: 30, y: 0, size: 16, level: 1 }
1779
+ ],
1780
+ lines: [
1781
+ { id: 'lf-l1', from: 'lf-a', to: 'lf-b', color: '#d9480f' },
1782
+ { id: 'lf-l2', from: 'lf-a', to: 'lf-b', color: '#d9480f' }
1783
+ ],
1784
+ channels: [{
1785
+ id: 'lf-ch', from: 'lf-a', to: 'lf-b', lineIds: ['lf-l1', 'lf-l2'],
1786
+ tangentInset: 0, offsetMode: 'inset', inset: 0.12,
1787
+ groups: [{ id: 'lf-g', lineIds: ['lf-l1', 'lf-l2'], path: [[0, 18]] }]
1788
+ }],
1789
+ lineFollowsNode: false,
1790
+ history: true,
1791
+ initialView: { center: [0, 0], scale: 1 },
1792
+ showGrid: false
1793
+ })
1794
+ await settle(120)
1795
+ const lfPaths = () => Object.fromEntries(lfView.listLines()
1796
+ .filter(line => String(line.item.id).startsWith('lf-'))
1797
+ .map(line => [String(line.item.id), JSON.stringify(line.path)]))
1798
+ const lfBase = lfPaths()
1799
+ lfView.setFilter({ points: { level: 0 } })
1800
+ await settle(120)
1801
+ const lfFiltered = lfPaths()
1802
+ check('lineFollowsNode:false:筛选未写 cascade 时只隐藏节点,回线几何不变', lfView.getVisibleData().points.length === 1 && lfView.listLines().filter(line => String(line.item.id).startsWith('lf-')).length === 2 && Object.keys(lfBase).every(id => lfFiltered[id] === lfBase[id]), JSON.stringify({ base: lfBase, current: lfFiltered }))
1803
+ lfView.updateOptions({ lineFollowsNode: true })
1804
+ await settle(120)
1805
+ check('lineFollowsNode:动态切到 true 后按默认 cascade 收起回线', lfView.listLines().filter(line => String(line.item.id).startsWith('lf-')).length === 0, JSON.stringify(lfPaths()))
1806
+ lfView.updateOptions({ lineFollowsNode: false })
1807
+ await settle(120)
1808
+ const lfToggledBack = lfPaths()
1809
+ check('lineFollowsNode:动态切回 false 后按节点最后坐标恢复回线', lfView.getVisibleData().points.length === 1 && Object.keys(lfBase).every(id => lfToggledBack[id] === lfBase[id]), JSON.stringify({ base: lfBase, current: lfToggledBack }))
1810
+ lfView.setFilter({ points: { level: 0 }, cascade: 'binding' })
1811
+ await settle(120)
1812
+ check('lineFollowsNode:false:筛选显式 cascade 仍然优先', lfView.listLines().filter(line => String(line.item.id).startsWith('lf-')).length === 0, JSON.stringify(lfPaths()))
1813
+ lfView.setFilter(null)
1814
+ await settle(120)
1815
+ lfView.hideFeatures('point', 'lf-a')
1816
+ await settle(120)
1817
+ const lfHidden = lfPaths()
1818
+ check('lineFollowsNode:false:visible:false 隐藏节点后回线停在最后坐标', lfView.listLines().filter(line => String(line.item.id).startsWith('lf-')).length === 2 && Object.keys(lfBase).every(id => lfHidden[id] === lfBase[id]), JSON.stringify({ base: lfBase, current: lfHidden }))
1819
+ lfView.showFeatures('point', 'lf-a')
1820
+ await settle(120)
1821
+ const lfRemoved = lfView.removePoints('lf-a')
1822
+ await settle(120)
1823
+ const lfAfterRemove = lfPaths()
1824
+ const lfChannelCount = lfView.getData().channels?.length ?? lfView.getChannels().length
1825
+ check('lineFollowsNode:false:删除节点不删回线 / 通道,几何继续停在最后坐标', lfRemoved.ok && lfRemoved.removedLines === 0 && lfRemoved.removedChannels === 0 && lfView.getData().points.length === 1 && lfView.getData().lines.length === 2 && lfChannelCount === 1 && Object.keys(lfBase).every(id => lfAfterRemove[id] === lfBase[id]), JSON.stringify({ removed: lfRemoved, paths: lfAfterRemove }))
1826
+ lfView.undo()
1827
+ await settle(120)
1828
+ check('lineFollowsNode:false:撤销删除后节点与原有几何一起回来', lfView.getData().points.length === 2 && lfView.getData().lines.length === 2 && Object.keys(lfBase).every(id => lfPaths()[id] === lfBase[id]), JSON.stringify({ base: lfBase, current: lfPaths(), points: lfView.getData().points }))
1829
+ lfView.destroy()
1830
+ lfHost.remove()
1831
+
1595
1832
  /* 问题 2(续):图标节点走的永远是「整份重排」那一支 —— 跟随几何与宽度重排从前欠在分支之后,
1596
1833
  节点换了大小 / 图标之后回线还铺在旧宽度上,要再点一下画布才追齐(同一份问题.txt 的再一轮)。
1597
1834
  这两条用贴图符号(SVG,size 8 / 24)+ tangentInset: 0:间距就是节点外框本身 */
@@ -2767,13 +3004,12 @@
2767
3004
  busView.destroy()
2768
3005
  busHost.remove()
2769
3006
 
2770
- /* 右键优先级:节点压在通道回线上时,右键给的是节点,而不是通道菜单 */
3007
+ /* 右键统一入口:节点 / 线路 / 通道都只走 onContextMenu,通道上下文挂在同一份载荷上 */
2771
3008
  const rcHost = document.createElement('div')
2772
3009
  rcHost.style.cssText = 'width:420px;height:300px'
2773
3010
  document.body.appendChild(rcHost)
2774
3011
  const rcBuilt = createChannels([{ id: 'ch-rc', from: 'rc-n1', to: 'rc-n2', lineIds: ['rc-1', 'rc-2'] }])
2775
3012
  const rcHits = []
2776
- const rcChannels = []
2777
3013
  const rcView = createGisView(rcHost, {
2778
3014
  points: [
2779
3015
  { id: 'rc-n1', x: 0, y: 0, name: '甲站', size: 20 },
@@ -2783,11 +3019,9 @@
2783
3019
  channels: rcBuilt.channels,
2784
3020
  fitPadding: 60,
2785
3021
  showGrid: false,
2786
- // 这一节验的是通道右键菜单:channelMenu 缺省关,这里显式打开
2787
- // (右键回调本体是 contextMenu,也一并打开)
2788
- features: { channelMenu: true, contextMenu: true },
2789
- onContextMenu: payload => rcHits.push(payload),
2790
- onChannelContextMenu: payload => rcChannels.push(payload)
3022
+ // 右键回调本体是 contextMenu;通道不再需要第二个功能开关
3023
+ features: { contextMenu: true },
3024
+ onContextMenu: payload => rcHits.push(payload)
2791
3025
  })
2792
3026
  await settle(140)
2793
3027
  const rcRightClick = async (screen) => {
@@ -2802,20 +3036,195 @@
2802
3036
  }))
2803
3037
  await settle(60)
2804
3038
  }
2805
- // 端点上右键:这个位置同时压着节点与回线,拾取按「点高于线」给节点,通道上下文给 null
3039
+ // 端点上右键:这个位置同时压着节点与回线,拾取按「点高于线」给节点,不附通道上下文
2806
3040
  await rcRightClick(rcView.project(0, 0))
2807
3041
  check('右键落在通道端点(节点压着回线):onContextMenu 给的是节点', rcHits[0]?.type === 'point' && rcHits[0]?.item?.id === 'rc-n1', JSON.stringify({ type: rcHits[0]?.type, id: rcHits[0]?.item?.id }))
2808
- check('右键落在节点上:onChannelContextMenu 收到 null,不弹通道菜单', rcChannels.length === 1 && rcChannels[0] === null, JSON.stringify(rcChannels[0] ?? null))
3042
+ check('右键落在节点上:只发一条 onContextMenu,且没有 channelContext', rcHits.length === 1 && rcHits[0]?.channelContext === undefined, JSON.stringify({ count: rcHits.length, channelContext: rcHits[0]?.channelContext ?? null }))
2809
3043
  // 回线中段(离两端节点都远):这里才是「通道右键」
2810
3044
  const rcLine = rcView.getData().lines.find(item => String(item.id) === 'rc-1')
2811
3045
  const rcMidWorld = Array.isArray(rcLine?.path) && rcLine.path.length > 1 ? rcLine.path[Math.floor(rcLine.path.length / 2)] : [60, 0]
2812
3046
  diag('右键回线中段', `世界 ${rcMidWorld.map(value => Number(value).toFixed(2)).join(',')} · 回线折点 ${Array.isArray(rcLine?.path) ? rcLine.path.length : 0}`)
2813
3047
  await rcRightClick(rcView.project(rcMidWorld[0], rcMidWorld[1]))
2814
- check('右键落在回线中段:给出通道上下文(组数 / 当前分组 / 可用交互)', rcChannels[1]?.channelId === 'ch-rc' && rcChannels[1]?.lineId === 'rc-1' && rcChannels[1]?.groupCount === 1 && (rcChannels[1]?.actions ?? []).length > 0, JSON.stringify({ channel: rcChannels[1]?.channelId, line: rcChannels[1]?.lineId, groups: rcChannels[1]?.groupCount }))
2815
- check('右键通道时通用回调照旧发一条(点 / 线 / 空白都有载荷)', rcHits.length === 2 && rcHits[1]?.type === 'line' && rcHits[1]?.channelId === 'ch-rc', JSON.stringify({ count: rcHits.length, type: rcHits[1]?.type }))
3048
+ const rcContext = rcHits[1]?.channelContext
3049
+ check('右键落在回线中段:同一份 onContextMenu 载荷里带 channelContext(组数 / 当前分组 / 可用交互)', rcHits.length === 2 && rcContext?.channelId === 'ch-rc' && rcHits[1]?.lineId === 'rc-1' && rcContext?.groupCount === 1 && (rcContext?.actions ?? []).length > 0, JSON.stringify({ count: rcHits.length, channel: rcContext?.channelId, line: rcHits[1]?.lineId, groups: rcContext?.groupCount }))
3050
+ check('右键通道只发这一条(不再额外发 onChannelContextMenu),线路下标 / id 也在载荷上', rcHits[1]?.type === 'line' && rcHits[1]?.channelId === 'ch-rc' && rcHits[1]?.lineId === 'rc-1' && Number.isInteger(rcHits[1]?.lineIndex) && rcHits[1]?.action?.target === 'channel', JSON.stringify({ count: rcHits.length, type: rcHits[1]?.type, lineIndex: rcHits[1]?.lineIndex, target: rcHits[1]?.action?.target }))
2816
3051
  rcView.destroy()
2817
3052
  rcHost.remove()
2818
3053
 
3054
+ /* 点击级联高亮:节点亮自身 + 直连线路 / 节点,线路 / 通道亮两端;与选中集完全分开 */
3055
+ const casHost = document.createElement('div')
3056
+ casHost.style.cssText = 'width:520px;height:320px'
3057
+ document.body.appendChild(casHost)
3058
+ const casBuilt = createChannels([{ id: 'cas-ch', from: 'cas-b', to: 'cas-c', circuits: 2, lineIds: ['cas-c1', 'cas-c2'] }])
3059
+ const casEvents = []
3060
+ const casBusEvents = []
3061
+ const casView = createGisView(casHost, {
3062
+ points: [
3063
+ { id: 'cas-a', x: 0, y: 0, size: 14 },
3064
+ { id: 'cas-b', x: 120, y: 0, size: 14 },
3065
+ { id: 'cas-c', x: 240, y: 0, size: 14 },
3066
+ { id: 'cas-d', x: 60, y: 90, size: 14 }
3067
+ ],
3068
+ lines: [
3069
+ { id: 'cas-ab', from: 'cas-a', to: 'cas-b', path: [[0, 0], [120, 0]] },
3070
+ ...casBuilt.lines
3071
+ ],
3072
+ channels: casBuilt.channels,
3073
+ fitPadding: 60,
3074
+ showGrid: false,
3075
+ cascadeHighlight: true,
3076
+ // 级联高亮本身不依赖 select;这里打开选中,用来验证两套状态互不污染
3077
+ features: { select: true, contextMenu: true },
3078
+ onCascadeHighlight: payload => casEvents.push(payload)
3079
+ })
3080
+ casView.on('cascadeHighlight', payload => casBusEvents.push(payload))
3081
+ await settle(140)
3082
+ const casClick = async (screen, extra = {}) => {
3083
+ cmSend(casView, 'pointerdown', screen, { buttons: 1, ...extra })
3084
+ cmSend(casView, 'pointerup', screen, { buttons: 0, button: 0, ...extra })
3085
+ await settle(60)
3086
+ }
3087
+ await casClick(casView.project(0, 0))
3088
+ const casPointInfo = casView.getCascadeHighlight()
3089
+ check('级联高亮:点节点亮自身与直连线路 / 另一端节点(默认 both)',
3090
+ casPointInfo?.origin?.target === 'point' && casPointInfo?.origin?.item?.id === 'cas-a' &&
3091
+ casPointInfo.points.map(entry => entry.item.id).sort().join(',') === 'cas-a,cas-b' &&
3092
+ casPointInfo.lines.map(entry => entry.item.id).join(',') === 'cas-ab',
3093
+ JSON.stringify({ origin: casPointInfo?.origin?.item?.id, points: casPointInfo?.points?.map(entry => entry.item.id), lines: casPointInfo?.lines?.map(entry => entry.item.id) }))
3094
+ check('级联高亮不写选中集:亮两个节点与一条线路时选中集仍只是这次点中的节点',
3095
+ casPointInfo?.pointIndexes?.length === 2 && casPointInfo?.lineIndexes?.length === 1 && casView.getSelectionData().count === 1,
3096
+ JSON.stringify({ cascade: [casPointInfo?.pointIndexes, casPointInfo?.lineIndexes], selected: casView.getSelectionData().count }))
3097
+ check('级联高亮:onCascadeHighlight 与 cascadeHighlight 事件都收到同一份读数',
3098
+ casEvents.length === 1 && casBusEvents.length === 1 && casEvents[0]?.origin?.item?.id === 'cas-a' && casBusEvents[0]?.pointIndexes?.length === 2,
3099
+ JSON.stringify({ callback: casEvents.length, event: casBusEvents.length }))
3100
+
3101
+ casEvents.length = 0
3102
+ casBusEvents.length = 0
3103
+ await casClick(casView.project(60, 0))
3104
+ const casLineInfo = casView.getCascadeHighlight()
3105
+ check('级联高亮:点线路只亮两端节点(不把线路自身算进去)',
3106
+ casLineInfo?.origin?.target === 'line' && casLineInfo?.origin?.item?.id === 'cas-ab' &&
3107
+ casLineInfo.points.map(entry => entry.item.id).sort().join(',') === 'cas-a,cas-b' && casLineInfo.lines.length === 0 && casEvents.length === 1,
3108
+ JSON.stringify({ origin: casLineInfo?.origin?.item?.id, points: casLineInfo?.points?.map(entry => entry.item.id), lines: casLineInfo?.lines?.length }))
3109
+
3110
+ casEvents.length = 0
3111
+ const casChannelGeometry = casView.getLine('cas-c1')?.path ?? [[120, 0], [240, 0]]
3112
+ const casChannelStart = casChannelGeometry[0]
3113
+ const casChannelEnd = casChannelGeometry[casChannelGeometry.length - 1]
3114
+ await casClick(casView.project((casChannelStart[0] + casChannelEnd[0]) / 2, (casChannelStart[1] + casChannelEnd[1]) / 2))
3115
+ const casChannelInfo = casView.getCascadeHighlight()
3116
+ check('级联高亮:点通道回线亮两端节点,origin 记通道 / 分组(target 为 channel)',
3117
+ casChannelInfo?.origin?.target === 'channel' && casChannelInfo?.origin?.channelId === 'cas-ch' &&
3118
+ casChannelInfo.points.map(entry => entry.item.id).sort().join(',') === 'cas-b,cas-c' && casChannelInfo.lines.length === 0 && casEvents.length === 1,
3119
+ JSON.stringify({ origin: casChannelInfo?.origin, points: casChannelInfo?.points?.map(entry => entry.item.id) }))
3120
+
3121
+ casEvents.length = 0
3122
+ await casClick([4, 4])
3123
+ check('级联高亮:点空白处收掉,getCascadeHighlight 返回 null 并发一条 null',
3124
+ casView.getCascadeHighlight() === null && casEvents.length === 1 && casEvents[0] === null,
3125
+ JSON.stringify({ info: casView.getCascadeHighlight(), events: casEvents.length }))
3126
+
3127
+ casView.updateOptions({ cascadeHighlight: { scope: 'points' } })
3128
+ await settle(60)
3129
+ casEvents.length = 0
3130
+ await casClick(casView.project(240, 0))
3131
+ const casPointsOnly = casView.getCascadeHighlight()
3132
+ check('级联高亮 scope: points:点节点只亮节点,不亮直连线路',
3133
+ casPointsOnly?.scope === 'points' && casPointsOnly.points.map(entry => entry.item.id).sort().join(',') === 'cas-b,cas-c' && casPointsOnly.lines.length === 0,
3134
+ JSON.stringify({ scope: casPointsOnly?.scope, points: casPointsOnly?.points?.map(entry => entry.item.id), lines: casPointsOnly?.lines?.length }))
3135
+
3136
+ casView.updateOptions({
3137
+ pointSelectStyle: { color: '#00cc44', strokeColor: '#006622', strokeWidth: 1, sizeScale: 1 },
3138
+ cascadeHighlight: {
3139
+ scope: 'both',
3140
+ pointStyle: { color: '#112233', strokeColor: '#445566', strokeWidth: 3, sizeScale: 1.4 },
3141
+ lineStyle: { color: '#778899', width: 7, opacity: 1 }
3142
+ }
3143
+ })
3144
+ await settle(60)
3145
+ const casPointPaint = (snapshot, id) => {
3146
+ const item = casView.getData().points.find(point => point.id === id)
3147
+ const [x, y] = casView.project(item.x, item.y)
3148
+ return [...snapshot.points, ...snapshot.raised].find(point => Math.hypot(point.x - x, point.y - y) < 3)
3149
+ }
3150
+ casEvents.length = 0
3151
+ await casClick(casView.project(0, 0))
3152
+ const casStyledSnapshot = casView.snapshotScene({ background: false })
3153
+ const casSelectedPaint = casPointPaint(casStyledSnapshot, 'cas-a')
3154
+ const casCascadePaint = casPointPaint(casStyledSnapshot, 'cas-b')
3155
+ check('级联高亮样式可单独配置:邻接节点与线路按 pointStyle / lineStyle 解析到画面',
3156
+ casCascadePaint?.fill === 'rgba(17,34,51,1)' &&
3157
+ casStyledSnapshot.lines.some(line => line.color === 'rgba(119,136,153,1)' && Math.abs(line.width - 7) < 0.2),
3158
+ JSON.stringify({ points: [...casStyledSnapshot.points, ...casStyledSnapshot.raised].map(point => point.fill), lines: casStyledSnapshot.lines.map(line => [line.color, line.width]) }))
3159
+ check('级联高亮:直接选中的节点仍走 pointSelectStyle,不套级联样式',
3160
+ casSelectedPaint?.fill === 'rgba(0,204,68,1)' && casCascadePaint?.fill !== casSelectedPaint?.fill,
3161
+ JSON.stringify({ selected: casSelectedPaint, cascade: casCascadePaint }))
3162
+
3163
+ casView.updateOptions({ pointSelectStyle: null })
3164
+ await settle(40)
3165
+ await casClick(casView.project(240, 0))
3166
+ const casNoSelectPaint = casPointPaint(casView.snapshotScene({ background: false }), 'cas-c')
3167
+ check('没配选中样式时直接选中项也不回落到级联样式(保持普通外观)',
3168
+ casNoSelectPaint?.fill !== 'rgba(17,34,51,1)' && JSON.stringify(casView.getSelectionData().ids) === JSON.stringify(['cas-c']),
3169
+ JSON.stringify({ paint: casNoSelectPaint, selected: casView.getSelectionData().ids }))
3170
+
3171
+ // 普通单击 / 多选分开控制:关闭的那一路只改选中,并把旧级联收掉;另一路照常触发
3172
+ casView.updateOptions({ cascadeHighlight: { scope: 'both', triggerOnClick: false, triggerOnMultiSelect: true } })
3173
+ await settle(40)
3174
+ await casClick(casView.project(0, 0))
3175
+ check('triggerOnClick: false:普通单击不触发级联', casView.getCascadeHighlight() === null, JSON.stringify(casView.getCascadeHighlight()))
3176
+ await casClick(casView.project(240, 0), { ctrlKey: true })
3177
+ check('triggerOnMultiSelect: true:Ctrl 多选仍触发级联', casView.getCascadeHighlight()?.origin?.item?.id === 'cas-c' && casView.getCascadeHighlight()?.pointIndexes?.length === 2, JSON.stringify({ origin: casView.getCascadeHighlight()?.origin?.item?.id, points: casView.getCascadeHighlight()?.pointIndexes }))
3178
+ casView.updateOptions({ cascadeHighlight: { scope: 'both', triggerOnClick: true, triggerOnMultiSelect: false } })
3179
+ await settle(40)
3180
+ await casClick(casView.project(120, 0))
3181
+ const casClickOn = casView.getCascadeHighlight()
3182
+ await casClick(casView.project(60, 90), { ctrlKey: true })
3183
+ check('triggerOnMultiSelect: false:普通单击照常触发,Ctrl 多选不再更新而是收掉旧级联',
3184
+ casClickOn?.origin?.item?.id === 'cas-b' && casView.getCascadeHighlight() === null,
3185
+ JSON.stringify({ click: casClickOn?.origin?.item?.id, multi: casView.getCascadeHighlight() }))
3186
+
3187
+ // 框选:默认不触发;打开后整框邻接并集参与级联,直接框中的节点仍走 boxSelectStyle
3188
+ casView.updateOptions({
3189
+ pointBoxSelectStyle: { color: '#00ccff', strokeColor: '#005577', strokeWidth: 1, sizeScale: 1 },
3190
+ cascadeHighlight: { scope: 'both', triggerOnBoxSelect: true }
3191
+ })
3192
+ await settle(40)
3193
+ const casBoxA = casView.project(0, 0)
3194
+ const casBoxB = casView.project(120, 0)
3195
+ const casBox = {
3196
+ x: Math.min(casBoxA[0], casBoxB[0]) - 30,
3197
+ y: Math.min(casBoxA[1], casBoxB[1]) - 30,
3198
+ width: Math.abs(casBoxA[0] - casBoxB[0]) + 60,
3199
+ height: Math.abs(casBoxA[1] - casBoxB[1]) + 60
3200
+ }
3201
+ casView.selectInBox(casBox, { selectionKinds: ['point'] })
3202
+ await settle(40)
3203
+ const casBoxInfo = casView.getCascadeHighlight()
3204
+ const casBoxSnapshot = casView.snapshotScene({ background: false })
3205
+ const casBoxPaint = casPointPaint(casBoxSnapshot, 'cas-a')
3206
+ const casBoxNeighborPaint = casPointPaint(casBoxSnapshot, 'cas-c')
3207
+ check('triggerOnBoxSelect: true:框内所有命中项一起算邻接并集',
3208
+ casBoxInfo?.pointIndexes?.length === 3 && casBoxInfo?.origin?.item?.id === 'cas-a' && casBoxInfo.lineIndexes.length >= 3,
3209
+ JSON.stringify({ origin: casBoxInfo?.origin?.item?.id, points: casBoxInfo?.pointIndexes, lines: casBoxInfo?.lineIndexes }))
3210
+ check('框选直接命中的节点走 boxSelectStyle,只有框外邻接节点套级联样式',
3211
+ casBoxPaint?.fill === 'rgba(0,204,255,1)' && casBoxNeighborPaint?.fill === 'rgba(255,247,237,1)',
3212
+ JSON.stringify({ boxed: casBoxPaint, neighbor: casBoxNeighborPaint }))
3213
+ casView.updateOptions({ cascadeHighlight: { scope: 'both', triggerOnBoxSelect: false } })
3214
+ await settle(40)
3215
+ const casBoxD = casView.project(60, 90)
3216
+ casView.selectInBox({ x: casBoxD[0] - 30, y: casBoxD[1] - 30, width: 60, height: 60 }, { selectionKinds: ['point'] })
3217
+ check('triggerOnBoxSelect: false(缺省):框选只改选中集并收掉旧级联', casView.getCascadeHighlight() === null, JSON.stringify(casView.getCascadeHighlight()))
3218
+
3219
+ casEvents.length = 0
3220
+ casView.updateOptions({ cascadeHighlight: false })
3221
+ await settle(60)
3222
+ check('关掉级联高亮:当前读数与事件都收成 null',
3223
+ casView.getCascadeHighlight() === null && casEvents.length === 1 && casEvents[0] === null,
3224
+ JSON.stringify({ info: casView.getCascadeHighlight(), events: casEvents.length }))
3225
+ casView.destroy()
3226
+ casHost.remove()
3227
+
2819
3228
  /* 导出选中的节点:图片 / 数据二选一,线路按「两端都在选中集里」决定带不带 */
2820
3229
  const exHost = document.createElement('div')
2821
3230
  exHost.style.cssText = 'width:420px;height:300px'
@@ -2925,6 +3334,67 @@
2925
3334
  svgView.destroy()
2926
3335
  svgHost.remove()
2927
3336
 
3337
+ /* exportImage 的范围:viewport 只导当前可视区域;all 临时适应全部内容,出完恢复相机 */
3338
+ const scopeHost = document.createElement('div')
3339
+ scopeHost.style.cssText = 'width:360px;height:240px'
3340
+ document.body.appendChild(scopeHost)
3341
+ const scopeView = createGisView(scopeHost, {
3342
+ points: [
3343
+ { id: 'scope-a', x: 0, y: 0, size: 40, color: '#ff0000' },
3344
+ { id: 'scope-b', x: 1000, y: 0, size: 40, color: '#0000ff' }
3345
+ ],
3346
+ background: { color: '#ffffff' },
3347
+ showGrid: false
3348
+ })
3349
+ await settle(140)
3350
+ scopeView.setCamera({ center: [0, 0], scale: 6 })
3351
+ await settle(60)
3352
+ const scopeCamera = scopeView.getCamera()
3353
+ const scopeColorCounts = async (url) => {
3354
+ const image = new Image()
3355
+ await new Promise((resolve, reject) => {
3356
+ image.onload = resolve
3357
+ image.onerror = () => reject(new Error('导出图片加载失败'))
3358
+ image.src = url
3359
+ })
3360
+ const buffer = document.createElement('canvas')
3361
+ buffer.width = image.width
3362
+ buffer.height = image.height
3363
+ const context = buffer.getContext('2d')
3364
+ context.drawImage(image, 0, 0)
3365
+ const data = context.getImageData(0, 0, buffer.width, buffer.height).data
3366
+ let red = 0
3367
+ let blue = 0
3368
+ for (let index = 0; index < data.length; index += 4) {
3369
+ if (data[index] > 180 && data[index + 1] < 90 && data[index + 2] < 90) {
3370
+ red += 1
3371
+ }
3372
+ if (data[index + 2] > 180 && data[index] < 90 && data[index + 1] < 90) {
3373
+ blue += 1
3374
+ }
3375
+ }
3376
+ return { red, blue, width: buffer.width, height: buffer.height }
3377
+ }
3378
+ const scopeViewport = await scopeColorCounts(await scopeView.exportImage({ scope: 'viewport', scale: 1, includeLabels: false }))
3379
+ const scopeAll = await scopeColorCounts(await scopeView.exportImage({ scope: 'all', padding: 16, scale: 1, includeLabels: false }))
3380
+ const scopeAfter = scopeView.getCamera()
3381
+ check('exportImage scope: viewport 只含当前视口内容', scopeViewport.red > 50 && scopeViewport.blue === 0, JSON.stringify(scopeViewport))
3382
+ check('exportImage scope: all 临时适应全部内容(两个节点都进画面)', scopeAll.red > 50 && scopeAll.blue > 50, JSON.stringify(scopeAll))
3383
+ check('exportImage scope: all 出图后相机原样恢复', scopeAfter.scale === scopeCamera.scale && scopeAfter.center[0] === scopeCamera.center[0] && scopeAfter.center[1] === scopeCamera.center[1], JSON.stringify({ before: scopeCamera, after: scopeAfter }))
3384
+ const scopeSvg = decodeSvgUrl(await scopeView.exportImage({ type: 'svg', scope: 'all', padding: 12, includeLabels: false }))
3385
+ const scopeSvgAfter = scopeView.getCamera()
3386
+ check('exportImage scope: all 在 SVG 分支同样生效并恢复相机', (scopeSvg.match(/<circle/g) || []).length >= 2 && scopeSvgAfter.scale === scopeCamera.scale, JSON.stringify({ circles: (scopeSvg.match(/<circle/g) || []).length, camera: scopeSvgAfter }))
3387
+ let scopeError = ''
3388
+ try {
3389
+ await scopeView.exportImage({ scope: 'window' })
3390
+ }
3391
+ catch (error) {
3392
+ scopeError = String(error?.message ?? error)
3393
+ }
3394
+ check('exportImage scope 写错直接抛异常(不静默按 viewport 导)', scopeError.includes('viewport') && scopeError.includes('all'), scopeError)
3395
+ scopeView.destroy()
3396
+ scopeHost.remove()
3397
+
2928
3398
  /* 适应视图的留白:padding 比画布还大时,扣完留白的可用尺寸不能被钳到 1px(那会把整幅图缩成一个点) */
2929
3399
  const fitHost = document.createElement('div')
2930
3400
  fitHost.style.cssText = 'width:220px;height:140px'
@@ -4294,6 +4764,37 @@
4294
4764
  nwView.cancelAddChannel()
4295
4765
  await settle(30)
4296
4766
  check('提前结束:cancelAddChannel 收掉草稿并补一条 cancel(不新建)', nwView.getAddChannelDraft() === null && nwEvents.some(payload => payload.phase === 'cancel') && nwEvents.every(payload => payload.phase !== 'commit'), JSON.stringify(nwEvents.map(payload => payload.phase)))
4767
+
4768
+ // ⑩b 预指定起点:beginAddChannel({ from }) 直接进入「等终点」,下一次点击就是 ready
4769
+ nwEvents.length = 0
4770
+ nwSelectChanges.length = 0
4771
+ nwView.beginAddChannel({ from: 'na', select: true })
4772
+ await settle(40)
4773
+ const nwPresetFrom = nwView.getAddChannelDraft()
4774
+ check('beginAddChannel({ from }):草稿直接以该节点为起点进入 from 相位',
4775
+ nwPresetFrom?.phase === 'from' && nwPresetFrom.from?.id === 'na' && nwPresetFrom.to === null && nwEvents[0]?.phase === 'from',
4776
+ JSON.stringify({ phase: nwPresetFrom?.phase, from: nwPresetFrom?.from?.id, events: nwEvents.map(payload => payload.phase) }))
4777
+ check('beginAddChannel({ from, select: true }):预指定起点当场进选中集', JSON.stringify(nwView.getSelectionData().ids) === JSON.stringify(['na']), JSON.stringify(nwView.getSelectionData().ids))
4778
+ nwClick(nwScreenOf('nb'))
4779
+ await settle(60)
4780
+ const nwPresetReady = nwEvents.find(payload => payload.phase === 'ready')
4781
+ check('预指定起点后只需再选终点:一次点击得到 from=na / to=nb 的 ready',
4782
+ nwPresetReady?.from?.id === 'na' && nwPresetReady?.to?.id === 'nb' && nwView.getAddChannelDraft()?.phase === 'ready',
4783
+ JSON.stringify({ from: nwPresetReady?.from?.id, to: nwPresetReady?.to?.id, phases: nwEvents.map(payload => payload.phase) }))
4784
+ nwView.cancelAddChannel()
4785
+ await settle(20)
4786
+ nwView.continueAddChannel({ from: { id: 'nc' } })
4787
+ await settle(30)
4788
+ check('continueAddChannel({ from }):没有草稿时也按预指定起点重新进入', nwView.getAddChannelDraft()?.phase === 'from' && nwView.getAddChannelDraft()?.from?.id === 'nc', JSON.stringify(nwView.getAddChannelDraft()))
4789
+ nwView.cancelAddChannel()
4790
+ await settle(20)
4791
+ const nwWarnings = []
4792
+ nwView.updateOptions({ onWarning: message => nwWarnings.push(message) })
4793
+ nwView.beginAddChannel({ from: '不存在的起点' })
4794
+ await settle(20)
4795
+ check('beginAddChannel({ from }) 找不到节点:草稿不开始并报 warning',
4796
+ nwView.getAddChannelDraft() === null && nwWarnings.some(message => message.includes('找不到起点')),
4797
+ JSON.stringify({ draft: nwView.getAddChannelDraft(), warnings: nwWarnings }))
4297
4798
  nwView.updateOptions({ onChannelDraft: null })
4298
4799
  // ⑪ getChannelBetween:两个节点之间的通道读数(只读,与方案表同一份)
4299
4800
  const cbPair = nwView.getChannelBetween('na', 'nb')