gis-view-webgl2 0.1.2 → 0.1.4

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 (66) hide show
  1. package/README.md +21 -14
  2. package/dist/gis-view.css +1 -1
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.d.ts +421 -76
  5. package/dist/index.js +1 -1
  6. package/docs/AI/345/257/271/350/257/235/346/261/207/346/200/273.md +151 -0
  7. package/docs/GisView.md +4 -4
  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 +137 -0
  9. package/docs/api/index.html +23 -9
  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/ExtensionDemo.vue +69 -12
  16. package/docs/demos/GisViewDemo.vue +64 -8809
  17. package/docs/demos/SearchFilterDemo.vue +13 -11
  18. package/docs/demos/api-index.js +58 -47
  19. package/docs/demos/demos.js +34 -17
  20. package/docs/demos/gis-view-demo/GisViewDemoCardHeader.vue +29 -0
  21. package/docs/demos/gis-view-demo/GisViewDemoDialogs.vue +82 -0
  22. package/docs/demos/gis-view-demo/GisViewDemoFooter.vue +16 -0
  23. package/docs/demos/gis-view-demo/GisViewDemoHeading.vue +16 -0
  24. package/docs/demos/gis-view-demo/GisViewDemoPanelStack.vue +71 -0
  25. package/docs/demos/gis-view-demo/GisViewDemoStage.vue +219 -0
  26. package/docs/demos/gis-view-demo/demo.css +1230 -0
  27. package/docs/demos/gis-view-demo/demoContext.js +12 -0
  28. package/docs/demos/gis-view-demo/panels/DataPanels.vue +303 -0
  29. package/docs/demos/gis-view-demo/panels/OpsPanels.vue +174 -0
  30. package/docs/demos/gis-view-demo/panels/StylePanels.vue +561 -0
  31. package/docs/demos/gis-view-demo/panels/ViewPanels.vue +396 -0
  32. package/docs/demos/gis-view-demo/useGisViewDemo.js +6188 -0
  33. 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
  34. package/docs/index.html +41 -4
  35. 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
  36. package/docs/manual/02-/345/235/220/346/240/207/344/270/216/346/225/260/346/215/256.md +3 -2
  37. package/docs/manual/03-/351/205/215/347/275/256/351/241/271.md +104 -16
  38. package/docs/manual/04-/344/272/213/344/273/266/344/270/216/345/233/236/350/260/203.md +15 -14
  39. 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
  40. package/docs/manual/08-/351/200/211/344/270/255/344/270/216/346/213/220/347/202/271.md +50 -3
  41. 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
  42. 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
  43. package/docs/manual/12-/346/240/207/347/255/276/344/270/216/350/243/205/351/245/260.md +13 -4
  44. package/docs/manual/13-/345/212/250/346/225/210/344/270/216/345/233/276/345/205/203.md +3 -0
  45. package/docs/manual/14-/350/247/206/345/233/276/344/270/216/347/255/233/351/200/211.md +121 -49
  46. package/docs/manual/15-/345/257/274/345/207/272/344/270/216/351/233/206/346/210/220.md +37 -3
  47. package/docs/manual/16-/345/256/236/344/276/213/346/226/271/346/263/225/346/200/273/350/241/250.md +17 -8
  48. package/docs/manual/17-/345/217/202/346/225/260/351/200/237/346/237/245.md +45 -25
  49. package/docs/manual/18-/351/200/220/346/226/271/346/263/225/347/244/272/344/276/213.md +91 -20
  50. 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 +3 -3
  51. 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
  52. 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 +11 -8
  53. package/docs/steps/README.md +17 -11
  54. package/docs/steps/selftest-actions.html +3 -5
  55. package/docs/steps/selftest-demo.html +27 -18
  56. package/docs/steps/selftest-demos.html +409 -32
  57. package/docs/steps/selftest-docs.html +33 -4
  58. package/docs/steps/selftest-effects.html +200 -0
  59. package/docs/steps/selftest-features.html +603 -21
  60. package/docs/steps/selftest-labels.html +74 -0
  61. package/docs/steps/selftest-perf.html +293 -20
  62. package/docs/steps/selftest-scale.html +150 -29
  63. package/docs/steps/selftest-select.html +90 -0
  64. package/docs/steps/step-10-full.js +1 -1
  65. package/docs/steps/step-12-events.js +1 -1
  66. package/package.json +1 -1
@@ -1592,6 +1592,293 @@
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
+ // search 也认 focusOn 的四种目标;分组命中可以直接交给 focusOn / selectFeatures
1769
+ const faGroupSearch = faView.search({ keyword: 'fa-high', scope: 'group', limit: 10 })
1770
+ const faGroupHit = faGroupSearch.groups[0]
1771
+ faView.focusOn(faGroupHit)
1772
+ const faGroupSearchCenter = faView.getCamera().center
1773
+ faView.selectFeatures(faGroupHit)
1774
+ const faGroupHitSelected = faView.getSelections().filter(item => item.type === 'line').map(item => item.item.id).sort()
1775
+ const faDefaultGroupSearch = await faView.searchAsync({ keyword: 'fa-high', limit: 10 })
1776
+ check('search:单数 group 范围返回分组命中,命中可直接 focusOn / selectFeatures', faGroupSearch.scope.length === 1 && faGroupSearch.scope[0] === 'groups' && faGroupHit?.kind === 'group' && faGroupHit.channelIndex === 1 && faGroupHit.groupId === 'fa-high' && faGroupHit.groupIndex === 0 && Math.abs(faGroupSearchCenter[1] - 40) < 0.01 && faGroupHitSelected.join(',') === 'fa-l3,fa-l4', JSON.stringify({ scope: faGroupSearch.scope, hit: faGroupHit, center: faGroupSearchCenter, selected: faGroupHitSelected }))
1777
+ check('searchAsync:缺省范围包含 groups,Worker / 同步回落都带 groups 结果', faDefaultGroupSearch.groups.length === 1 && faDefaultGroupSearch.hits.some(hit => hit.kind === 'group' && hit.groupId === 'fa-high'), JSON.stringify({ scope: faDefaultGroupSearch.scope, groups: faDefaultGroupSearch.groups.length, worker: faDefaultGroupSearch.worker }))
1778
+ // 线路 / 通道 / 分组都只在 zoom: true 时适配:与显式 zoomToBounds 的比例逐位一致,没有类型专属放大倍率
1779
+ const faLineHit = faView.search({ keyword: 'fa-around', scope: 'line', limit: 1 }).lines[0]
1780
+ const faChannelHit = faView.search({ keyword: 'fa-low-ch', scope: 'channel', limit: 1 }).channels[0]
1781
+ const faFocusScales = [faLineHit, faChannelHit, faGroupHit].map((hit) => {
1782
+ faView.zoomToBounds(hit.bounds)
1783
+ const expected = faView.getCamera().scale
1784
+ faView.focusOn(hit, { zoom: true })
1785
+ return [expected, faView.getCamera().scale]
1786
+ })
1787
+ check('focusOn({ zoom: true }):线路 / 通道 / 分组适配后的比例与同一份 bounds 的 zoomToBounds 完全一致', faFocusScales.every(([expected, actual]) => Math.abs(expected - actual) < 1e-9), JSON.stringify(faFocusScales))
1788
+ faView.destroy()
1789
+ faHost.remove()
1790
+
1791
+ /* 筛选是隐藏、不是删除:选中的要素被筛掉时保留在选中集里,条件清空后原样恢复 */
1792
+ const fsHost = document.createElement('div')
1793
+ fsHost.style.cssText = 'width:320px;height:200px'
1794
+ document.body.appendChild(fsHost)
1795
+ const fsChanges = []
1796
+ const fsView = createGisView(fsHost, {
1797
+ points: [
1798
+ { id: 'fs-a', x: -30, y: 0, level: 0 },
1799
+ { id: 'fs-b', x: 30, y: 0, level: 1 }
1800
+ ],
1801
+ lines: [{ id: 'fs-l1', from: 'fs-a', to: 'fs-b' }],
1802
+ initialView: { center: [0, 0], scale: 1 },
1803
+ showGrid: false,
1804
+ onSelectChange: payload => fsChanges.push(payload)
1805
+ })
1806
+ await settle(100)
1807
+ fsView.selectFeatures(['fs-a', 'fs-l1'])
1808
+ const fsChangesBeforeFilter = fsChanges.length
1809
+ fsView.setFilter({ points: { level: 1 }, cascade: false })
1810
+ await settle(100)
1811
+ const fsHiddenIds = fsView.getSelections().map(item => String(item.item.id)).sort().join(',')
1812
+ const fsHiddenVisible = fsView.getVisibleData().points.some(point => point.id === 'fs-a')
1813
+ const fsChangesAfterFilter = fsChanges.length
1814
+ fsView.setFilter(null)
1815
+ await settle(100)
1816
+ const fsRestoredIds = fsView.getSelections().map(item => String(item.item.id)).sort().join(',')
1817
+ check('setFilter 只隐藏要素:选中项保留,条件清空后仍选中且不额外发选中变更', fsHiddenIds === 'fs-a,fs-l1' && fsHiddenVisible === false && fsChangesAfterFilter === fsChangesBeforeFilter && fsRestoredIds === 'fs-a,fs-l1', JSON.stringify({ hidden: fsHiddenIds, visible: fsHiddenVisible, changes: [fsChangesBeforeFilter, fsChangesAfterFilter], restored: fsRestoredIds }))
1818
+ fsView.destroy()
1819
+ fsHost.remove()
1820
+
1821
+ /* lineFollowsNode: false:隐藏 / 筛选 / 删除只动节点,回线与通道停在最后坐标 */
1822
+ const lfHost = document.createElement('div')
1823
+ lfHost.style.cssText = 'width:360px;height:220px'
1824
+ document.body.appendChild(lfHost)
1825
+ const lfView = createGisView(lfHost, {
1826
+ points: [
1827
+ { id: 'lf-a', x: -30, y: 0, size: 16, level: 0 },
1828
+ { id: 'lf-b', x: 30, y: 0, size: 16, level: 1 }
1829
+ ],
1830
+ lines: [
1831
+ { id: 'lf-l1', from: 'lf-a', to: 'lf-b', color: '#d9480f' },
1832
+ { id: 'lf-l2', from: 'lf-a', to: 'lf-b', color: '#d9480f' }
1833
+ ],
1834
+ channels: [{
1835
+ id: 'lf-ch', from: 'lf-a', to: 'lf-b', lineIds: ['lf-l1', 'lf-l2'],
1836
+ tangentInset: 0, offsetMode: 'inset', inset: 0.12,
1837
+ groups: [{ id: 'lf-g', lineIds: ['lf-l1', 'lf-l2'], path: [[0, 18]] }]
1838
+ }],
1839
+ lineFollowsNode: false,
1840
+ history: true,
1841
+ initialView: { center: [0, 0], scale: 1 },
1842
+ showGrid: false
1843
+ })
1844
+ await settle(120)
1845
+ const lfPaths = () => Object.fromEntries(lfView.listLines()
1846
+ .filter(line => String(line.item.id).startsWith('lf-'))
1847
+ .map(line => [String(line.item.id), JSON.stringify(line.path)]))
1848
+ const lfBase = lfPaths()
1849
+ lfView.setFilter({ points: { level: 0 } })
1850
+ await settle(120)
1851
+ const lfFiltered = lfPaths()
1852
+ 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 }))
1853
+ lfView.updateOptions({ lineFollowsNode: true })
1854
+ await settle(120)
1855
+ check('lineFollowsNode:动态切到 true 后按默认 cascade 收起回线', lfView.listLines().filter(line => String(line.item.id).startsWith('lf-')).length === 0, JSON.stringify(lfPaths()))
1856
+ lfView.updateOptions({ lineFollowsNode: false })
1857
+ await settle(120)
1858
+ const lfToggledBack = lfPaths()
1859
+ check('lineFollowsNode:动态切回 false 后按节点最后坐标恢复回线', lfView.getVisibleData().points.length === 1 && Object.keys(lfBase).every(id => lfToggledBack[id] === lfBase[id]), JSON.stringify({ base: lfBase, current: lfToggledBack }))
1860
+ lfView.setFilter({ points: { level: 0 }, cascade: 'binding' })
1861
+ await settle(120)
1862
+ check('lineFollowsNode:false:筛选显式 cascade 仍然优先', lfView.listLines().filter(line => String(line.item.id).startsWith('lf-')).length === 0, JSON.stringify(lfPaths()))
1863
+ lfView.setFilter(null)
1864
+ await settle(120)
1865
+ lfView.hideFeatures('point', 'lf-a')
1866
+ await settle(120)
1867
+ const lfHidden = lfPaths()
1868
+ 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 }))
1869
+ lfView.showFeatures('point', 'lf-a')
1870
+ await settle(120)
1871
+ const lfRemoved = lfView.removePoints('lf-a')
1872
+ await settle(120)
1873
+ const lfAfterRemove = lfPaths()
1874
+ const lfChannelCount = lfView.getData().channels?.length ?? lfView.getChannels().length
1875
+ 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 }))
1876
+ lfView.undo()
1877
+ await settle(120)
1878
+ 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 }))
1879
+ lfView.destroy()
1880
+ lfHost.remove()
1881
+
1595
1882
  /* 问题 2(续):图标节点走的永远是「整份重排」那一支 —— 跟随几何与宽度重排从前欠在分支之后,
1596
1883
  节点换了大小 / 图标之后回线还铺在旧宽度上,要再点一下画布才追齐(同一份问题.txt 的再一轮)。
1597
1884
  这两条用贴图符号(SVG,size 8 / 24)+ tangentInset: 0:间距就是节点外框本身 */
@@ -2767,13 +3054,12 @@
2767
3054
  busView.destroy()
2768
3055
  busHost.remove()
2769
3056
 
2770
- /* 右键优先级:节点压在通道回线上时,右键给的是节点,而不是通道菜单 */
3057
+ /* 右键统一入口:节点 / 线路 / 通道都只走 onContextMenu,通道上下文挂在同一份载荷上 */
2771
3058
  const rcHost = document.createElement('div')
2772
3059
  rcHost.style.cssText = 'width:420px;height:300px'
2773
3060
  document.body.appendChild(rcHost)
2774
3061
  const rcBuilt = createChannels([{ id: 'ch-rc', from: 'rc-n1', to: 'rc-n2', lineIds: ['rc-1', 'rc-2'] }])
2775
3062
  const rcHits = []
2776
- const rcChannels = []
2777
3063
  const rcView = createGisView(rcHost, {
2778
3064
  points: [
2779
3065
  { id: 'rc-n1', x: 0, y: 0, name: '甲站', size: 20 },
@@ -2783,11 +3069,9 @@
2783
3069
  channels: rcBuilt.channels,
2784
3070
  fitPadding: 60,
2785
3071
  showGrid: false,
2786
- // 这一节验的是通道右键菜单:channelMenu 缺省关,这里显式打开
2787
- // (右键回调本体是 contextMenu,也一并打开)
2788
- features: { channelMenu: true, contextMenu: true },
2789
- onContextMenu: payload => rcHits.push(payload),
2790
- onChannelContextMenu: payload => rcChannels.push(payload)
3072
+ // 右键回调本体是 contextMenu;通道不再需要第二个功能开关
3073
+ features: { contextMenu: true },
3074
+ onContextMenu: payload => rcHits.push(payload)
2791
3075
  })
2792
3076
  await settle(140)
2793
3077
  const rcRightClick = async (screen) => {
@@ -2802,20 +3086,195 @@
2802
3086
  }))
2803
3087
  await settle(60)
2804
3088
  }
2805
- // 端点上右键:这个位置同时压着节点与回线,拾取按「点高于线」给节点,通道上下文给 null
3089
+ // 端点上右键:这个位置同时压着节点与回线,拾取按「点高于线」给节点,不附通道上下文
2806
3090
  await rcRightClick(rcView.project(0, 0))
2807
3091
  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))
3092
+ check('右键落在节点上:只发一条 onContextMenu,且没有 channelContext', rcHits.length === 1 && rcHits[0]?.channelContext === undefined, JSON.stringify({ count: rcHits.length, channelContext: rcHits[0]?.channelContext ?? null }))
2809
3093
  // 回线中段(离两端节点都远):这里才是「通道右键」
2810
3094
  const rcLine = rcView.getData().lines.find(item => String(item.id) === 'rc-1')
2811
3095
  const rcMidWorld = Array.isArray(rcLine?.path) && rcLine.path.length > 1 ? rcLine.path[Math.floor(rcLine.path.length / 2)] : [60, 0]
2812
3096
  diag('右键回线中段', `世界 ${rcMidWorld.map(value => Number(value).toFixed(2)).join(',')} · 回线折点 ${Array.isArray(rcLine?.path) ? rcLine.path.length : 0}`)
2813
3097
  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 }))
3098
+ const rcContext = rcHits[1]?.channelContext
3099
+ check('右键落在回线中段:同一份 onContextMenu 载荷里带 channelContext(组数 / 当前分组 / 可用交互)', rcHits.length === 2 && rcContext?.channelId === 'ch-rc' && rcHits[1]?.lineId === 'rc-1' && rcContext?.groupCount === 1 && (rcContext?.actions ?? []).length > 0, JSON.stringify({ count: rcHits.length, channel: rcContext?.channelId, line: rcHits[1]?.lineId, groups: rcContext?.groupCount }))
3100
+ check('右键通道只发这一条(不再额外发 onChannelContextMenu),线路下标 / id 也在载荷上', rcHits[1]?.type === 'line' && rcHits[1]?.channelId === 'ch-rc' && rcHits[1]?.lineId === 'rc-1' && Number.isInteger(rcHits[1]?.lineIndex) && rcHits[1]?.action?.target === 'channel', JSON.stringify({ count: rcHits.length, type: rcHits[1]?.type, lineIndex: rcHits[1]?.lineIndex, target: rcHits[1]?.action?.target }))
2816
3101
  rcView.destroy()
2817
3102
  rcHost.remove()
2818
3103
 
3104
+ /* 点击级联高亮:节点亮自身 + 直连线路 / 节点,线路 / 通道亮两端;与选中集完全分开 */
3105
+ const casHost = document.createElement('div')
3106
+ casHost.style.cssText = 'width:520px;height:320px'
3107
+ document.body.appendChild(casHost)
3108
+ const casBuilt = createChannels([{ id: 'cas-ch', from: 'cas-b', to: 'cas-c', circuits: 2, lineIds: ['cas-c1', 'cas-c2'] }])
3109
+ const casEvents = []
3110
+ const casBusEvents = []
3111
+ const casView = createGisView(casHost, {
3112
+ points: [
3113
+ { id: 'cas-a', x: 0, y: 0, size: 14 },
3114
+ { id: 'cas-b', x: 120, y: 0, size: 14 },
3115
+ { id: 'cas-c', x: 240, y: 0, size: 14 },
3116
+ { id: 'cas-d', x: 60, y: 90, size: 14 }
3117
+ ],
3118
+ lines: [
3119
+ { id: 'cas-ab', from: 'cas-a', to: 'cas-b', path: [[0, 0], [120, 0]] },
3120
+ ...casBuilt.lines
3121
+ ],
3122
+ channels: casBuilt.channels,
3123
+ fitPadding: 60,
3124
+ showGrid: false,
3125
+ cascadeHighlight: true,
3126
+ // 级联高亮本身不依赖 select;这里打开选中,用来验证两套状态互不污染
3127
+ features: { select: true, contextMenu: true },
3128
+ onCascadeHighlight: payload => casEvents.push(payload)
3129
+ })
3130
+ casView.on('cascadeHighlight', payload => casBusEvents.push(payload))
3131
+ await settle(140)
3132
+ const casClick = async (screen, extra = {}) => {
3133
+ cmSend(casView, 'pointerdown', screen, { buttons: 1, ...extra })
3134
+ cmSend(casView, 'pointerup', screen, { buttons: 0, button: 0, ...extra })
3135
+ await settle(60)
3136
+ }
3137
+ await casClick(casView.project(0, 0))
3138
+ const casPointInfo = casView.getCascadeHighlight()
3139
+ check('级联高亮:点节点亮自身与直连线路 / 另一端节点(默认 both)',
3140
+ casPointInfo?.origin?.target === 'point' && casPointInfo?.origin?.item?.id === 'cas-a' &&
3141
+ casPointInfo.points.map(entry => entry.item.id).sort().join(',') === 'cas-a,cas-b' &&
3142
+ casPointInfo.lines.map(entry => entry.item.id).join(',') === 'cas-ab',
3143
+ JSON.stringify({ origin: casPointInfo?.origin?.item?.id, points: casPointInfo?.points?.map(entry => entry.item.id), lines: casPointInfo?.lines?.map(entry => entry.item.id) }))
3144
+ check('级联高亮不写选中集:亮两个节点与一条线路时选中集仍只是这次点中的节点',
3145
+ casPointInfo?.pointIndexes?.length === 2 && casPointInfo?.lineIndexes?.length === 1 && casView.getSelectionData().count === 1,
3146
+ JSON.stringify({ cascade: [casPointInfo?.pointIndexes, casPointInfo?.lineIndexes], selected: casView.getSelectionData().count }))
3147
+ check('级联高亮:onCascadeHighlight 与 cascadeHighlight 事件都收到同一份读数',
3148
+ casEvents.length === 1 && casBusEvents.length === 1 && casEvents[0]?.origin?.item?.id === 'cas-a' && casBusEvents[0]?.pointIndexes?.length === 2,
3149
+ JSON.stringify({ callback: casEvents.length, event: casBusEvents.length }))
3150
+
3151
+ casEvents.length = 0
3152
+ casBusEvents.length = 0
3153
+ await casClick(casView.project(60, 0))
3154
+ const casLineInfo = casView.getCascadeHighlight()
3155
+ check('级联高亮:点线路只亮两端节点(不把线路自身算进去)',
3156
+ casLineInfo?.origin?.target === 'line' && casLineInfo?.origin?.item?.id === 'cas-ab' &&
3157
+ casLineInfo.points.map(entry => entry.item.id).sort().join(',') === 'cas-a,cas-b' && casLineInfo.lines.length === 0 && casEvents.length === 1,
3158
+ JSON.stringify({ origin: casLineInfo?.origin?.item?.id, points: casLineInfo?.points?.map(entry => entry.item.id), lines: casLineInfo?.lines?.length }))
3159
+
3160
+ casEvents.length = 0
3161
+ const casChannelGeometry = casView.getLine('cas-c1')?.path ?? [[120, 0], [240, 0]]
3162
+ const casChannelStart = casChannelGeometry[0]
3163
+ const casChannelEnd = casChannelGeometry[casChannelGeometry.length - 1]
3164
+ await casClick(casView.project((casChannelStart[0] + casChannelEnd[0]) / 2, (casChannelStart[1] + casChannelEnd[1]) / 2))
3165
+ const casChannelInfo = casView.getCascadeHighlight()
3166
+ check('级联高亮:点通道回线亮两端节点,origin 记通道 / 分组(target 为 channel)',
3167
+ casChannelInfo?.origin?.target === 'channel' && casChannelInfo?.origin?.channelId === 'cas-ch' &&
3168
+ casChannelInfo.points.map(entry => entry.item.id).sort().join(',') === 'cas-b,cas-c' && casChannelInfo.lines.length === 0 && casEvents.length === 1,
3169
+ JSON.stringify({ origin: casChannelInfo?.origin, points: casChannelInfo?.points?.map(entry => entry.item.id) }))
3170
+
3171
+ casEvents.length = 0
3172
+ await casClick([4, 4])
3173
+ check('级联高亮:点空白处收掉,getCascadeHighlight 返回 null 并发一条 null',
3174
+ casView.getCascadeHighlight() === null && casEvents.length === 1 && casEvents[0] === null,
3175
+ JSON.stringify({ info: casView.getCascadeHighlight(), events: casEvents.length }))
3176
+
3177
+ casView.updateOptions({ cascadeHighlight: { scope: 'points' } })
3178
+ await settle(60)
3179
+ casEvents.length = 0
3180
+ await casClick(casView.project(240, 0))
3181
+ const casPointsOnly = casView.getCascadeHighlight()
3182
+ check('级联高亮 scope: points:点节点只亮节点,不亮直连线路',
3183
+ casPointsOnly?.scope === 'points' && casPointsOnly.points.map(entry => entry.item.id).sort().join(',') === 'cas-b,cas-c' && casPointsOnly.lines.length === 0,
3184
+ JSON.stringify({ scope: casPointsOnly?.scope, points: casPointsOnly?.points?.map(entry => entry.item.id), lines: casPointsOnly?.lines?.length }))
3185
+
3186
+ casView.updateOptions({
3187
+ pointSelectStyle: { color: '#00cc44', strokeColor: '#006622', strokeWidth: 1, sizeScale: 1 },
3188
+ cascadeHighlight: {
3189
+ scope: 'both',
3190
+ pointStyle: { color: '#112233', strokeColor: '#445566', strokeWidth: 3, sizeScale: 1.4 },
3191
+ lineStyle: { color: '#778899', width: 7, opacity: 1 }
3192
+ }
3193
+ })
3194
+ await settle(60)
3195
+ const casPointPaint = (snapshot, id) => {
3196
+ const item = casView.getData().points.find(point => point.id === id)
3197
+ const [x, y] = casView.project(item.x, item.y)
3198
+ return [...snapshot.points, ...snapshot.raised].find(point => Math.hypot(point.x - x, point.y - y) < 3)
3199
+ }
3200
+ casEvents.length = 0
3201
+ await casClick(casView.project(0, 0))
3202
+ const casStyledSnapshot = casView.snapshotScene({ background: false })
3203
+ const casSelectedPaint = casPointPaint(casStyledSnapshot, 'cas-a')
3204
+ const casCascadePaint = casPointPaint(casStyledSnapshot, 'cas-b')
3205
+ check('级联高亮样式可单独配置:邻接节点与线路按 pointStyle / lineStyle 解析到画面',
3206
+ casCascadePaint?.fill === 'rgba(17,34,51,1)' &&
3207
+ casStyledSnapshot.lines.some(line => line.color === 'rgba(119,136,153,1)' && Math.abs(line.width - 7) < 0.2),
3208
+ JSON.stringify({ points: [...casStyledSnapshot.points, ...casStyledSnapshot.raised].map(point => point.fill), lines: casStyledSnapshot.lines.map(line => [line.color, line.width]) }))
3209
+ check('级联高亮:直接选中的节点仍走 pointSelectStyle,不套级联样式',
3210
+ casSelectedPaint?.fill === 'rgba(0,204,68,1)' && casCascadePaint?.fill !== casSelectedPaint?.fill,
3211
+ JSON.stringify({ selected: casSelectedPaint, cascade: casCascadePaint }))
3212
+
3213
+ casView.updateOptions({ pointSelectStyle: null })
3214
+ await settle(40)
3215
+ await casClick(casView.project(240, 0))
3216
+ const casNoSelectPaint = casPointPaint(casView.snapshotScene({ background: false }), 'cas-c')
3217
+ check('没配选中样式时直接选中项也不回落到级联样式(保持普通外观)',
3218
+ casNoSelectPaint?.fill !== 'rgba(17,34,51,1)' && JSON.stringify(casView.getSelectionData().ids) === JSON.stringify(['cas-c']),
3219
+ JSON.stringify({ paint: casNoSelectPaint, selected: casView.getSelectionData().ids }))
3220
+
3221
+ // 普通单击 / 多选分开控制:关闭的那一路只改选中,并把旧级联收掉;另一路照常触发
3222
+ casView.updateOptions({ cascadeHighlight: { scope: 'both', triggerOnClick: false, triggerOnMultiSelect: true } })
3223
+ await settle(40)
3224
+ await casClick(casView.project(0, 0))
3225
+ check('triggerOnClick: false:普通单击不触发级联', casView.getCascadeHighlight() === null, JSON.stringify(casView.getCascadeHighlight()))
3226
+ await casClick(casView.project(240, 0), { ctrlKey: true })
3227
+ 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 }))
3228
+ casView.updateOptions({ cascadeHighlight: { scope: 'both', triggerOnClick: true, triggerOnMultiSelect: false } })
3229
+ await settle(40)
3230
+ await casClick(casView.project(120, 0))
3231
+ const casClickOn = casView.getCascadeHighlight()
3232
+ await casClick(casView.project(60, 90), { ctrlKey: true })
3233
+ check('triggerOnMultiSelect: false:普通单击照常触发,Ctrl 多选不再更新而是收掉旧级联',
3234
+ casClickOn?.origin?.item?.id === 'cas-b' && casView.getCascadeHighlight() === null,
3235
+ JSON.stringify({ click: casClickOn?.origin?.item?.id, multi: casView.getCascadeHighlight() }))
3236
+
3237
+ // 框选:默认不触发;打开后整框邻接并集参与级联,直接框中的节点仍走 boxSelectStyle
3238
+ casView.updateOptions({
3239
+ pointBoxSelectStyle: { color: '#00ccff', strokeColor: '#005577', strokeWidth: 1, sizeScale: 1 },
3240
+ cascadeHighlight: { scope: 'both', triggerOnBoxSelect: true }
3241
+ })
3242
+ await settle(40)
3243
+ const casBoxA = casView.project(0, 0)
3244
+ const casBoxB = casView.project(120, 0)
3245
+ const casBox = {
3246
+ x: Math.min(casBoxA[0], casBoxB[0]) - 30,
3247
+ y: Math.min(casBoxA[1], casBoxB[1]) - 30,
3248
+ width: Math.abs(casBoxA[0] - casBoxB[0]) + 60,
3249
+ height: Math.abs(casBoxA[1] - casBoxB[1]) + 60
3250
+ }
3251
+ casView.selectInBox(casBox, { selectionKinds: ['point'] })
3252
+ await settle(40)
3253
+ const casBoxInfo = casView.getCascadeHighlight()
3254
+ const casBoxSnapshot = casView.snapshotScene({ background: false })
3255
+ const casBoxPaint = casPointPaint(casBoxSnapshot, 'cas-a')
3256
+ const casBoxNeighborPaint = casPointPaint(casBoxSnapshot, 'cas-c')
3257
+ check('triggerOnBoxSelect: true:框内所有命中项一起算邻接并集',
3258
+ casBoxInfo?.pointIndexes?.length === 3 && casBoxInfo?.origin?.item?.id === 'cas-a' && casBoxInfo.lineIndexes.length >= 3,
3259
+ JSON.stringify({ origin: casBoxInfo?.origin?.item?.id, points: casBoxInfo?.pointIndexes, lines: casBoxInfo?.lineIndexes }))
3260
+ check('框选直接命中的节点走 boxSelectStyle,只有框外邻接节点套级联样式',
3261
+ casBoxPaint?.fill === 'rgba(0,204,255,1)' && casBoxNeighborPaint?.fill === 'rgba(255,247,237,1)',
3262
+ JSON.stringify({ boxed: casBoxPaint, neighbor: casBoxNeighborPaint }))
3263
+ casView.updateOptions({ cascadeHighlight: { scope: 'both', triggerOnBoxSelect: false } })
3264
+ await settle(40)
3265
+ const casBoxD = casView.project(60, 90)
3266
+ casView.selectInBox({ x: casBoxD[0] - 30, y: casBoxD[1] - 30, width: 60, height: 60 }, { selectionKinds: ['point'] })
3267
+ check('triggerOnBoxSelect: false(缺省):框选只改选中集并收掉旧级联', casView.getCascadeHighlight() === null, JSON.stringify(casView.getCascadeHighlight()))
3268
+
3269
+ casEvents.length = 0
3270
+ casView.updateOptions({ cascadeHighlight: false })
3271
+ await settle(60)
3272
+ check('关掉级联高亮:当前读数与事件都收成 null',
3273
+ casView.getCascadeHighlight() === null && casEvents.length === 1 && casEvents[0] === null,
3274
+ JSON.stringify({ info: casView.getCascadeHighlight(), events: casEvents.length }))
3275
+ casView.destroy()
3276
+ casHost.remove()
3277
+
2819
3278
  /* 导出选中的节点:图片 / 数据二选一,线路按「两端都在选中集里」决定带不带 */
2820
3279
  const exHost = document.createElement('div')
2821
3280
  exHost.style.cssText = 'width:420px;height:300px'
@@ -2925,6 +3384,67 @@
2925
3384
  svgView.destroy()
2926
3385
  svgHost.remove()
2927
3386
 
3387
+ /* exportImage 的范围:viewport 只导当前可视区域;all 临时适应全部内容,出完恢复相机 */
3388
+ const scopeHost = document.createElement('div')
3389
+ scopeHost.style.cssText = 'width:360px;height:240px'
3390
+ document.body.appendChild(scopeHost)
3391
+ const scopeView = createGisView(scopeHost, {
3392
+ points: [
3393
+ { id: 'scope-a', x: 0, y: 0, size: 40, color: '#ff0000' },
3394
+ { id: 'scope-b', x: 1000, y: 0, size: 40, color: '#0000ff' }
3395
+ ],
3396
+ background: { color: '#ffffff' },
3397
+ showGrid: false
3398
+ })
3399
+ await settle(140)
3400
+ scopeView.setCamera({ center: [0, 0], scale: 6 })
3401
+ await settle(60)
3402
+ const scopeCamera = scopeView.getCamera()
3403
+ const scopeColorCounts = async (url) => {
3404
+ const image = new Image()
3405
+ await new Promise((resolve, reject) => {
3406
+ image.onload = resolve
3407
+ image.onerror = () => reject(new Error('导出图片加载失败'))
3408
+ image.src = url
3409
+ })
3410
+ const buffer = document.createElement('canvas')
3411
+ buffer.width = image.width
3412
+ buffer.height = image.height
3413
+ const context = buffer.getContext('2d')
3414
+ context.drawImage(image, 0, 0)
3415
+ const data = context.getImageData(0, 0, buffer.width, buffer.height).data
3416
+ let red = 0
3417
+ let blue = 0
3418
+ for (let index = 0; index < data.length; index += 4) {
3419
+ if (data[index] > 180 && data[index + 1] < 90 && data[index + 2] < 90) {
3420
+ red += 1
3421
+ }
3422
+ if (data[index + 2] > 180 && data[index] < 90 && data[index + 1] < 90) {
3423
+ blue += 1
3424
+ }
3425
+ }
3426
+ return { red, blue, width: buffer.width, height: buffer.height }
3427
+ }
3428
+ const scopeViewport = await scopeColorCounts(await scopeView.exportImage({ scope: 'viewport', scale: 1, includeLabels: false }))
3429
+ const scopeAll = await scopeColorCounts(await scopeView.exportImage({ scope: 'all', padding: 16, scale: 1, includeLabels: false }))
3430
+ const scopeAfter = scopeView.getCamera()
3431
+ check('exportImage scope: viewport 只含当前视口内容', scopeViewport.red > 50 && scopeViewport.blue === 0, JSON.stringify(scopeViewport))
3432
+ check('exportImage scope: all 临时适应全部内容(两个节点都进画面)', scopeAll.red > 50 && scopeAll.blue > 50, JSON.stringify(scopeAll))
3433
+ 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 }))
3434
+ const scopeSvg = decodeSvgUrl(await scopeView.exportImage({ type: 'svg', scope: 'all', padding: 12, includeLabels: false }))
3435
+ const scopeSvgAfter = scopeView.getCamera()
3436
+ 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 }))
3437
+ let scopeError = ''
3438
+ try {
3439
+ await scopeView.exportImage({ scope: 'window' })
3440
+ }
3441
+ catch (error) {
3442
+ scopeError = String(error?.message ?? error)
3443
+ }
3444
+ check('exportImage scope 写错直接抛异常(不静默按 viewport 导)', scopeError.includes('viewport') && scopeError.includes('all'), scopeError)
3445
+ scopeView.destroy()
3446
+ scopeHost.remove()
3447
+
2928
3448
  /* 适应视图的留白:padding 比画布还大时,扣完留白的可用尺寸不能被钳到 1px(那会把整幅图缩成一个点) */
2929
3449
  const fitHost = document.createElement('div')
2930
3450
  fitHost.style.cssText = 'width:220px;height:140px'
@@ -2935,13 +3455,13 @@
2935
3455
  showGrid: false
2936
3456
  })
2937
3457
  await settle(160)
2938
- fitView.focusOn('fl1', { padding: 220 })
3458
+ fitView.focusOn('fl1', { zoom: true, padding: 220 })
2939
3459
  await settle(80)
2940
3460
  const fitStart = fitView.project(0, 0)
2941
3461
  const fitEnd = fitView.project(300, 200)
2942
3462
  const fitSpan = Math.hypot(fitStart[0] - fitEnd[0], fitStart[1] - fitEnd[1])
2943
3463
  check('适应视图的留白比画布还大时:内容不会被挤成一个点(至少留给它 48px)', fitSpan > 40, `跨度 ${fitSpan.toFixed(1)}px / 画布 ${fitView.getStats().cssWidth}×${fitView.getStats().cssHeight}`)
2944
- fitView.focusOn('fl1', { padding: 20 })
3464
+ fitView.focusOn('fl1', { zoom: true, padding: 20 })
2945
3465
  await settle(80)
2946
3466
  const fitPadStart = fitView.project(0, 0)
2947
3467
  const fitPadEnd = fitView.project(300, 200)
@@ -3451,13 +3971,17 @@
3451
3971
  apiSend('pointerup', [apiRight, apiBottom], { button: 0, buttons: 0, shiftKey: true })
3452
3972
  await settle()
3453
3973
  check('框选的结果用同一份读数读回来(Shift + 拖动)', apiIds() === 'api-a,api-b,api-c' && apiView.getSelectionData().count === 3, apiIds())
3454
- // focusOn 一批目标:数组取整体包围盒 —— 这三个点的中心(200, 20)当屏幕中心、按这一批的范围适配
3974
+ // focusOn 一批目标:缺省只居中;显式 zoom: true 才按整体包围盒适配
3975
+ const apiDefaultScale = apiView.getCamera().scale
3455
3976
  apiView.focusOn(['api-a', 'api-b', 'api-c'])
3977
+ const apiCenteredBatch = apiView.getCamera()
3978
+ check('focusOn(一批目标):缺省只把中心挪到 (200, 20),比例一点不动', Math.abs(apiCenteredBatch.center[0] - 200) < 1e-6 && Math.abs(apiCenteredBatch.center[1] - 20) < 1e-6 && apiCenteredBatch.scale === apiDefaultScale, JSON.stringify({ scale: [apiDefaultScale, apiCenteredBatch.scale], center: apiCenteredBatch.center }))
3979
+ apiView.focusOn(['api-a', 'api-b', 'api-c'], { zoom: true })
3456
3980
  const apiBatch = apiView.getCamera()
3457
3981
  const apiStats = apiView.getStats()
3458
3982
  const apiCorners = [[0, 0], [400, 40]].map(spot => apiView.project(spot[0], spot[1]))
3459
3983
  const apiInside = apiCorners.every(spot => spot[0] >= 0 && spot[0] <= apiStats.cssWidth && spot[1] >= 0 && spot[1] <= apiStats.cssHeight)
3460
- check('focusOn(一批目标):整体包围盒居中(三个点的中心当屏幕中心,三个点都在视口里)', Math.abs(apiBatch.center[0] - 200) < 1e-6 && Math.abs(apiBatch.center[1] - 20) < 1e-6 && apiBatch.scale > 0 && apiInside, JSON.stringify({ center: apiBatch.center, corners: apiCorners }))
3984
+ check('focusOn(一批目标, { zoom: true }):整体包围盒居中,三个点都在视口里', Math.abs(apiBatch.center[0] - 200) < 1e-6 && Math.abs(apiBatch.center[1] - 20) < 1e-6 && apiBatch.scale > 0 && apiInside, JSON.stringify({ center: apiBatch.center, corners: apiCorners }))
3461
3985
  // center: true 是原来 centerOn 的语义:只挪中心,比例一点不动
3462
3986
  apiView.focusOn(['api-a', 'api-b'], { center: true })
3463
3987
  const apiCenter1 = apiView.getCamera()
@@ -3468,9 +3992,11 @@
3468
3992
  apiView.focusOn('api-b', { center: true })
3469
3993
  const apiCenter3 = apiView.getCamera().center
3470
3994
  check('focusOn(id, { center: true }):中心挪到这个要素上', Math.abs(apiCenter3[0] - 200) < 1e-6 && Math.abs(apiCenter3[1] - 40) < 1e-6, JSON.stringify(apiCenter3))
3471
- apiView.focusOn({ type: 'channel', channelId: 'api-ch' }, { center: true })
3472
- const apiCenter4 = apiView.getCamera().center
3473
- check('focusOn(通道写法, { center: true }):取这条通道范围的中心', Math.abs(apiCenter4[0] - 100) < 5 && Math.abs(apiCenter4[1] - 20) < 5, JSON.stringify(apiCenter4))
3995
+ const apiChannelScale = apiView.getCamera().scale
3996
+ apiView.focusOn({ type: 'channel', channelId: 'api-ch' })
3997
+ const apiChannelCamera = apiView.getCamera()
3998
+ const apiCenter4 = apiChannelCamera.center
3999
+ check('focusOn(通道写法) 缺省只居中,线路 / 通道不再自动放大', Math.abs(apiCenter4[0] - 100) < 5 && Math.abs(apiCenter4[1] - 20) < 5 && apiChannelCamera.scale === apiChannelScale, JSON.stringify({ center: apiCenter4, scale: [apiChannelScale, apiChannelCamera.scale] }))
3474
4000
  apiView.focusOn('没有这个 id', { center: true })
3475
4001
  const apiCenter5 = apiView.getCamera().center
3476
4002
  check('focusOn:认不出来的目标什么都不做(视角停在原处)', apiCenter5[0] === apiCenter4[0] && apiCenter5[1] === apiCenter4[1], JSON.stringify(apiCenter5))
@@ -3479,16 +4005,41 @@
3479
4005
  check('focusOn(一段范围, { center: true }):取中心', Math.abs(apiCenter6[0] - 200) < 1e-6 && Math.abs(apiCenter6[1] - 20) < 1e-6, JSON.stringify(apiCenter6))
3480
4006
  // select: true:被聚焦的要素同时变成选中状态(与接口选中同一份 state);mode 决定怎么并进当前选中集
3481
4007
  const apiBeforeFocusSelect = apiView.getSelectionData().ids.join(',')
3482
- apiView.focusOn(['api-a', 'api-b', 'api-c'], { select: true, padding: 20 })
4008
+ apiView.focusOn(['api-a', 'api-b', 'api-c'], { select: true, zoom: true, padding: 20 })
3483
4009
  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(',')}`)
3484
4010
  apiView.focusOn('api-a', { select: true, mode: 'remove' })
3485
4011
  check("focusOn(..., { select: true, mode: 'remove' }):只把聚焦的这个从选中集里去掉", apiView.getSelectionData().ids.join(',') === 'api-b,api-c', apiView.getSelectionData().ids.join(','))
3486
4012
  apiView.selectFeatures(null)
3487
4013
  const apiAreaScale = apiView.getCamera().scale
3488
- apiView.focusOn('api-b', { maxZoom: 12 })
4014
+ apiView.focusOn('api-b', { zoom: true, maxZoom: 12 })
3489
4015
  const apiZoomCamera = apiView.getCamera()
3490
- check('focusOn(单个节点, { maxZoom }):零面积目标撑出最小范围,放大并居中', apiZoomCamera.scale > apiAreaScale && Math.abs(apiZoomCamera.center[0] - 200) < 1e-6 && Math.abs(apiZoomCamera.center[1] - 40) < 1e-6, JSON.stringify({ scale: apiZoomCamera.scale, center: apiZoomCamera.center }))
4016
+ check('focusOn(单个节点, { zoom: true, maxZoom }):零面积目标撑出最小范围,放大并居中', apiZoomCamera.scale > apiAreaScale && Math.abs(apiZoomCamera.center[0] - 200) < 1e-6 && Math.abs(apiZoomCamera.center[1] - 40) < 1e-6, JSON.stringify({ scale: apiZoomCamera.scale, center: apiZoomCamera.center }))
4017
+ // 实例命令:注册 / 执行 / 同名覆盖 / 幂等卸载;名字与 handler 非法时只提醒、不动注册表
4018
+ let commandPayload = null
4019
+ let commandContext = null
4020
+ const disposeEcho = apiView.registerCommand(' demo:echo ', (payload, context) => {
4021
+ commandPayload = payload
4022
+ commandContext = context
4023
+ return { doubled: Number(payload?.value ?? 0) * 2 }
4024
+ })
4025
+ const echoResult = apiView.executeCommand('demo:echo', { value: 21 })
4026
+ check('registerCommand / executeCommand:名字规范化、payload 与冻结上下文可用,handler 的返回值原样带回', apiView.listCommands().join(',') === 'demo:echo' && echoResult.ok === true && echoResult.name === 'demo:echo' && echoResult.value?.doubled === 42 && commandPayload?.value === 21 && commandContext?.view === apiView && commandContext?.name === 'demo:echo' && Object.isFrozen(commandContext), JSON.stringify({ commands: apiView.listCommands(), result: echoResult, context: commandContext?.name }))
4027
+ const disposeReplacement = apiView.registerCommand('demo:echo', payload => `replacement:${payload}`)
4028
+ disposeEcho()
4029
+ const replacementResult = apiView.executeCommand('demo:echo', 'still-here')
4030
+ check('registerCommand:同名后注册覆盖先生效,旧卸载函数不会误删新命令', apiView.listCommands().join(',') === 'demo:echo' && replacementResult.ok === true && replacementResult.value === 'replacement:still-here', JSON.stringify({ commands: apiView.listCommands(), result: replacementResult }))
4031
+ disposeReplacement()
4032
+ const invalidDispose = apiView.registerCommand(' ', () => 'never')
4033
+ const invalidResult = apiView.executeCommand('', {})
4034
+ const missingResult = apiView.executeCommand('demo:missing', {})
4035
+ check('实例命令:非法名 / 未注册 / 空名字都返回 ok: false,注册表不受影响', typeof invalidDispose === 'function' && invalidResult.ok === false && missingResult.ok === false && apiView.listCommands().length === 0, JSON.stringify({ invalid: invalidResult, missing: missingResult, commands: apiView.listCommands() }))
4036
+ apiView.registerCommand('demo:throw', () => { throw new Error('命令执行失败') })
4037
+ const thrownResult = apiView.executeCommand('demo:throw', null)
4038
+ check('实例命令:handler 抛错收成 ok: false + reason,不把异常甩给调用方', thrownResult.ok === false && thrownResult.name === 'demo:throw' && thrownResult.reason === '命令执行失败', JSON.stringify(thrownResult))
4039
+ apiView.registerCommand('demo:destroyed', () => 'never')
3491
4040
  apiView.destroy()
4041
+ const afterDestroy = apiView.executeCommand('demo:destroyed', null)
4042
+ check('destroy:实例命令表清空,销毁后执行返回 ok: false', apiView.listCommands().length === 0 && afterDestroy.ok === false, JSON.stringify({ commands: apiView.listCommands(), result: afterDestroy }))
3492
4043
  apiHost.remove()
3493
4044
  }
3494
4045
  /* ---------------- 线路 zIndex:电压高的线压在上面(见 15.47) ---------------- */
@@ -4294,6 +4845,37 @@
4294
4845
  nwView.cancelAddChannel()
4295
4846
  await settle(30)
4296
4847
  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)))
4848
+
4849
+ // ⑩b 预指定起点:beginAddChannel({ from }) 直接进入「等终点」,下一次点击就是 ready
4850
+ nwEvents.length = 0
4851
+ nwSelectChanges.length = 0
4852
+ nwView.beginAddChannel({ from: 'na', select: true })
4853
+ await settle(40)
4854
+ const nwPresetFrom = nwView.getAddChannelDraft()
4855
+ check('beginAddChannel({ from }):草稿直接以该节点为起点进入 from 相位',
4856
+ nwPresetFrom?.phase === 'from' && nwPresetFrom.from?.id === 'na' && nwPresetFrom.to === null && nwEvents[0]?.phase === 'from',
4857
+ JSON.stringify({ phase: nwPresetFrom?.phase, from: nwPresetFrom?.from?.id, events: nwEvents.map(payload => payload.phase) }))
4858
+ check('beginAddChannel({ from, select: true }):预指定起点当场进选中集', JSON.stringify(nwView.getSelectionData().ids) === JSON.stringify(['na']), JSON.stringify(nwView.getSelectionData().ids))
4859
+ nwClick(nwScreenOf('nb'))
4860
+ await settle(60)
4861
+ const nwPresetReady = nwEvents.find(payload => payload.phase === 'ready')
4862
+ check('预指定起点后只需再选终点:一次点击得到 from=na / to=nb 的 ready',
4863
+ nwPresetReady?.from?.id === 'na' && nwPresetReady?.to?.id === 'nb' && nwView.getAddChannelDraft()?.phase === 'ready',
4864
+ JSON.stringify({ from: nwPresetReady?.from?.id, to: nwPresetReady?.to?.id, phases: nwEvents.map(payload => payload.phase) }))
4865
+ nwView.cancelAddChannel()
4866
+ await settle(20)
4867
+ nwView.continueAddChannel({ from: { id: 'nc' } })
4868
+ await settle(30)
4869
+ check('continueAddChannel({ from }):没有草稿时也按预指定起点重新进入', nwView.getAddChannelDraft()?.phase === 'from' && nwView.getAddChannelDraft()?.from?.id === 'nc', JSON.stringify(nwView.getAddChannelDraft()))
4870
+ nwView.cancelAddChannel()
4871
+ await settle(20)
4872
+ const nwWarnings = []
4873
+ nwView.updateOptions({ onWarning: message => nwWarnings.push(message) })
4874
+ nwView.beginAddChannel({ from: '不存在的起点' })
4875
+ await settle(20)
4876
+ check('beginAddChannel({ from }) 找不到节点:草稿不开始并报 warning',
4877
+ nwView.getAddChannelDraft() === null && nwWarnings.some(message => message.includes('找不到起点')),
4878
+ JSON.stringify({ draft: nwView.getAddChannelDraft(), warnings: nwWarnings }))
4297
4879
  nwView.updateOptions({ onChannelDraft: null })
4298
4880
  // ⑪ getChannelBetween:两个节点之间的通道读数(只读,与方案表同一份)
4299
4881
  const cbPair = nwView.getChannelBetween('na', 'nb')