gis-view-webgl2 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +40 -8
  2. package/dist/gis-view.css +1 -1
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.d.ts +1036 -249
  5. package/dist/index.js +1 -1
  6. package/docs/AI/345/257/271/350/257/235/346/261/207/346/200/273.md +194 -0
  7. package/docs/GisView.md +9 -8
  8. package/docs/GisView/345/256/236/347/216/260/345/216/237/347/220/206.md +2 -2
  9. package/docs/ai/06-/344/274/232/350/257/235/347/264/242/345/274/225/344/270/216/345/244/215/346/237/245/346/226/271/346/263/225.md +172 -0
  10. package/docs/api/index.html +545 -59
  11. package/docs/demos/BackendSyncDemo.vue +10 -8
  12. package/docs/demos/BasicDemo.vue +101 -101
  13. package/docs/demos/BatchAddDemo.vue +5 -5
  14. package/docs/demos/ChannelDemo.vue +617 -10
  15. package/docs/demos/ConnectDemo.vue +392 -0
  16. package/docs/demos/CopyMergeDemo.vue +980 -929
  17. package/docs/demos/DecorationDraftDemo.vue +1300 -0
  18. package/docs/demos/DemoShell.vue +569 -240
  19. package/docs/demos/DragDropDemo.vue +11 -11
  20. package/docs/demos/EffectsDemo.vue +6 -6
  21. package/docs/demos/EventDemo.vue +327 -327
  22. package/docs/demos/ExtensionDemo.vue +458 -0
  23. package/docs/demos/GisViewDemo.vue +61 -48
  24. package/docs/demos/GraphicDemo.vue +338 -0
  25. package/docs/demos/LabelDemo.vue +553 -497
  26. package/docs/demos/MinimapDemo.vue +358 -0
  27. package/docs/demos/PerfDemo.vue +29 -0
  28. package/docs/demos/SearchFilterDemo.vue +412 -0
  29. package/docs/demos/StyleDemo.vue +121 -121
  30. package/docs/demos/api-index.js +98 -77
  31. package/docs/demos/demos.js +159 -135
  32. package/docs/demos/shared/demoData.js +107 -107
  33. package/docs/demos/shared/demoStyle.css +249 -249
  34. package/docs/demos/shared/fakeBackend.js +93 -6
  35. package/docs/format/03-/346/213/220/347/202/271/350/243/205/351/245/260/344/270/216/346/240/267/345/274/217.md +28 -8
  36. package/docs/format/04-/351/200/232/351/201/223/344/270/216/346/240/207/347/255/276.md +5 -4
  37. package/docs/format/06-/345/257/274/345/205/245/345/257/274/345/207/272/344/270/216/346/240/207/345/207/206/346/226/207/346/241/243.md +8 -6
  38. package/docs/format/07-/344/272/213/344/273/266/350/275/275/350/215/267/344/270/216/347/244/272/344/276/213/346/225/260/346/215/256.md +6 -6
  39. package/docs/guide/sample-document.html +4 -4
  40. package/docs/index.html +180 -9
  41. package/docs/internals/11-/350/246/206/347/233/226/345/261/202/346/240/207/347/255/276/344/270/216/346/217/220/347/244/272.md +1 -1
  42. package/docs/internals/13-/351/271/260/347/234/274/345/233/276.md +3 -2
  43. package/docs/internals/15-03-/351/200/220/350/275/256/350/256/260/345/275/225-/346/200/247/350/203/275/344/270/216/344/277/256/345/244/215-/344/270/213.md +16 -0
  44. package/docs/internals/16-03-/344/274/230/345/214/226/344/270/216/345/217/226/350/210/215-/344/270/213.md +11 -0
  45. package/docs/internals/17-/350/260/203/350/257/225/344/270/216/346/211/251/345/261/225/345/273/272/350/256/256.md +1 -1
  46. package/docs/manual/02-/345/235/220/346/240/207/344/270/216/346/225/260/346/215/256.md +18 -4
  47. package/docs/manual/03-/351/205/215/347/275/256/351/241/271.md +110 -25
  48. package/docs/manual/04-/344/272/213/344/273/266/344/270/216/345/233/236/350/260/203.md +13 -8
  49. package/docs/manual/06-/346/225/260/346/215/256/344/270/216/345/234/272/346/231/257/346/233/264/346/226/260.md +111 -10
  50. package/docs/manual/07-/346/214/207/351/222/210/344/270/216/350/246/201/347/264/240/347/274/226/350/276/221.md +47 -9
  51. package/docs/manual/08-/351/200/211/344/270/255/344/270/216/346/213/220/347/202/271.md +17 -15
  52. package/docs/manual/09-/351/200/232/351/201/223/344/270/216/345/244/232/345/233/236/347/272/277.md +61 -11
  53. package/docs/manual/10-/351/200/232/351/201/223/347/274/226/350/276/221/344/270/216/346/226/260/345/242/236.md +74 -7
  54. package/docs/manual/11-/346/240/267/345/274/217/344/270/216/350/247/204/345/210/231.md +3 -2
  55. package/docs/manual/12-/346/240/207/347/255/276/344/270/216/350/243/205/351/245/260.md +260 -3
  56. package/docs/manual/13-/345/212/250/346/225/210/344/270/216/345/233/276/345/205/203.md +6 -5
  57. package/docs/manual/14-/350/247/206/345/233/276/344/270/216/347/255/233/351/200/211.md +27 -14
  58. package/docs/manual/15-/345/257/274/345/207/272/344/270/216/351/233/206/346/210/220.md +142 -49
  59. package/docs/manual/16-/345/256/236/344/276/213/346/226/271/346/263/225/346/200/273/350/241/250.md +31 -29
  60. package/docs/manual/17-/345/217/202/346/225/260/351/200/237/346/237/245.md +55 -39
  61. package/docs/manual/18-/351/200/220/346/226/271/346/263/225/347/244/272/344/276/213.md +189 -64
  62. package/docs/manual/19-/350/257/273/346/225/260/344/270/216/346/213/226/346/213/275/351/242/204/350/247/210.md +4 -4
  63. package/docs/manual/20-/346/200/247/350/203/275/344/270/216/345/244/247/346/225/260/346/215/256.md +42 -2
  64. package/docs/manual/21-/350/257/264/346/230/216/344/270/216/345/270/270/350/247/201/351/227/256/351/242/230.md +22 -6
  65. package/docs/playground/index.html +5 -5
  66. package/docs/steps/README.md +14 -10
  67. package/docs/steps/check-doc-truth.mjs +1 -1
  68. package/docs/steps/selftest-actions.html +10 -10
  69. package/docs/steps/selftest-data.html +96 -14
  70. package/docs/steps/selftest-decoration.html +577 -0
  71. package/docs/steps/selftest-demo.html +6 -5
  72. package/docs/steps/selftest-demos.html +2201 -1612
  73. package/docs/steps/selftest-docs.html +89 -16
  74. package/docs/steps/selftest-effects.html +5 -5
  75. package/docs/steps/selftest-features.html +757 -86
  76. package/docs/steps/selftest-full.html +583 -583
  77. package/docs/steps/selftest-labels.html +1034 -652
  78. package/docs/steps/selftest-perf.html +1899 -1844
  79. package/docs/steps/selftest-scale.html +117 -7
  80. package/docs/steps/selftest-select.html +24 -17
  81. package/docs/steps/step-10-full.js +1 -1
  82. package/docs//346/216/245/345/217/243/345/215/217/350/256/256.md +167 -17
  83. package/docs//346/225/260/346/215/256/346/240/274/345/274/217.md +1 -1
  84. package/package.json +43 -43
  85. package/docs/demos/HistoryDemo.vue +0 -230
  86. package/docs/demos/InterfaceDemo.vue +0 -309
  87. package/docs/demos/SampleDemo.vue +0 -283
@@ -1,416 +1,416 @@
1
- <!doctype html>
2
- <html lang="zh-CN">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <title>selftest full running</title>
6
- </head>
7
- <body>
8
- <div id="map" style="width:900px;height:560px"></div>
9
- <iframe id="frame" style="width:1200px;height:900px;border:1px solid #ccc"></iframe>
10
- <pre id="out"></pre>
11
- <script type="module">
12
- const results = []
13
- const check = (name, ok, extra = '') => {
14
- results.push(`${ok ? 'PASS' : 'FAIL'} ${name}${extra === '' ? '' : ` — ${extra}`}`)
15
- }
16
- const nativeCapture = Element.prototype.setPointerCapture
17
- const nativeRelease = Element.prototype.releasePointerCapture
18
- Element.prototype.setPointerCapture = function (id) {
19
- try {
20
- nativeCapture.call(this, id)
21
- }
22
- catch (error) {
23
- void error
24
- }
25
- }
26
- Element.prototype.releasePointerCapture = function (id) {
27
- try {
28
- nativeRelease.call(this, id)
29
- }
30
- catch (error) {
31
- void error
32
- }
33
- }
34
- try {
35
- const { createFullView } = await import('./step-10-full.js')
36
- const { buildParkData } = await import('./data.js')
37
- const data = buildParkData(20240915)
38
- const events = []
39
- const push = (tag, payload) => events.push([tag, payload])
40
- const view = createFullView(document.querySelector('#map'), {
41
- onHover: payload => push('hover', payload),
42
- onClick: payload => push('click', payload),
43
- onDblClick: payload => push('dblclick', payload),
44
- onSelect: payload => push('select', payload),
45
- onPointerDown: (payload, event) => push('down', payload),
46
- onPointerUp: (payload, event) => push('up', payload),
47
- onContextMenu: (payload, event) => push('contextmenu', payload),
48
- onChannelDraft: payload => push('draft', payload),
49
- onChannelDraftClick: payload => push('draftclick', payload)
50
- })
51
- const last = tag => events.filter(entry => entry[0] === tag).at(-1)?.[1] ?? null
52
- const canvas = view.el.querySelector('canvas')
53
- const pointOf = (x, y) => {
54
- const screen = view.project(x, y)
55
- const rect = canvas.getBoundingClientRect()
56
- return { clientX: rect.left + screen[0], clientY: rect.top + screen[1] }
57
- }
58
- const fire = (type, extra = {}) => canvas.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 7, pointerType: 'mouse', button: 0, buttons: 1, ...extra }))
59
- const clickAt = (x, y) => {
60
- const at = pointOf(x, y)
61
- fire('pointerdown', at)
62
- fire('pointermove', { ...at, buttons: 1 })
63
- fire('pointerup', { ...at, buttons: 0 })
64
- }
65
- const station0 = data.points[0]
66
- const station2 = data.points[2]
67
- const station4 = data.points[4]
68
- check('初始通道两条(circuits 展开成回线)', view.getChannels().length === 2, String(view.getChannels().length))
69
-
70
- fire('pointermove', { ...pointOf(station0.x, station0.y), buttons: 0 })
71
- // 悬停拾取按帧合并(高刷指针不必每个事件都走一遍拾取网格):等回调落地再看结果。
72
- // 无头浏览器里没有别的动静时 rAF 会被饿死,这里顺手改一下 DOM 把帧逼出来。
73
- const hoverPump = document.createElement('div')
74
- hoverPump.style.cssText = 'position:fixed;left:0;top:0;font-size:10px;opacity:.01'
75
- document.body.appendChild(hoverPump)
76
- for (let attempt = 0; attempt < 25 && !last('hover'); attempt += 1) {
77
- hoverPump.textContent = String(attempt)
78
- await new Promise(resolve => setTimeout(resolve, 40))
79
- }
80
- check('onHover 拿到节点', last('hover')?.type === 'point' && last('hover')?.item?.id === station0.id, JSON.stringify([last('hover')?.type, last('hover')?.item?.id]))
81
-
82
- view.beginAddChannel({})
83
- check('beginAddChannel → start', last('draft')?.phase === 'start', last('draft')?.phase)
84
- clickAt(station0.x, station0.y)
85
- check('第 1 次点击 → step 1 起点', last('draftclick')?.step === 1 && last('draftclick')?.from?.item?.id === station0.id, JSON.stringify(last('draftclick')?.from?.item?.id))
86
- clickAt(station2.x, station2.y)
87
- check('第 2 次点击 → step 2 终点', last('draftclick')?.step === 2 && last('draftclick')?.to?.item?.id === station2.id && last('draftclick')?.finished === true, JSON.stringify(last('draftclick')?.to?.item?.id))
88
- const committed = view.commitAddChannel()
89
- check('落地成真实通道', committed.ok === true && view.getChannels().length === 3, JSON.stringify(committed.lineIds))
90
- check('落地发了 commit 载荷', last('draft')?.phase === 'commit')
91
- const removed = view.removeLastAdded()
92
- check('移除新增', removed.ok === true && view.getChannels().length === 2, JSON.stringify(removed))
93
-
94
- view.beginAddChannel({ continuous: true, chain: true })
95
- clickAt(station0.x, station0.y)
96
- clickAt(station2.x, station2.y)
97
- view.commitAddChannel()
98
- check('连续新增自动重开一段', last('draft')?.phase === 'restart' && last('draft')?.continuous === true, last('draft')?.phase)
99
- check('chain:新起点 = 上一段终点', view.getAddChannelDraft()?.from?.item?.id === station2.id, JSON.stringify(view.getAddChannelDraft()?.from?.item?.id))
100
- clickAt(station4.x, station4.y)
101
- check('chain 之后接着点又是一段', last('draftclick')?.step === 2 && last('draftclick')?.from?.item?.id === station2.id && last('draftclick')?.to?.item?.id === station4.id)
102
- view.commitAddChannel()
103
- view.cancelAddChannel()
104
- check('cancelAddChannel 收起草稿', view.getAddChannelDraft() === null)
105
-
106
- fire('contextmenu', { ...pointOf(station0.x, station0.y), button: 2, buttons: 2 })
107
- check('onContextMenu 有载荷', last('contextmenu')?.item !== undefined && last('contextmenu')?.hit === true, JSON.stringify([last('contextmenu')?.type, last('contextmenu')?.item?.id]))
108
- check('onPointerDown / onPointerUp 成对', events.some(entry => entry[0] === 'down') && events.some(entry => entry[0] === 'up'))
109
- // 这一轮:坐标转换(经纬度 ↔ 画出来的 GeoJSON 世界坐标 ↔ 画布像素)、数据映射与鹰眼图范围
110
- const instance = view.view
111
- const testGeoJson = {
112
- type: 'FeatureCollection',
113
- features: [
114
- { type: 'Feature', properties: { name: '测试区' }, geometry: { type: 'Polygon', coordinates: [[[116, 30], [122, 30], [122, 35], [116, 35], [116, 30]]] } }
115
- ]
116
- }
117
- instance.updateOptions({ background: { geoJson: testGeoJson, geoJsonStyle: { aspectScale: 1, fill: '#1f4e79', fillOpacity: 0.6 } } })
118
- await new Promise(resolve => setTimeout(resolve, 150))
119
- const projection = instance.getGeoProjection()
120
- check('配了 GeoJSON 底图后拿得到经纬度投影', Boolean(projection), projection ? JSON.stringify(projection.rawBounds) : 'null')
121
- const world = instance.lngLatToWorld(118.5, 32.2)
122
- const screen = instance.lngLatToScreen(118.5, 32.2)
123
- const back = instance.screenToLngLat(screen[0], screen[1])
124
- check('lngLatToWorld 与 rawBounds 对齐(aspectScale = 1)', Math.abs(world[0] - 118.5) < 1e-9 && Math.abs(world[1] - 32.2) < 1e-9, JSON.stringify(world))
125
- check('lngLatToScreen / screenToLngLat 往返一致', Math.abs(back[0] - 118.5) < 1e-6 && Math.abs(back[1] - 32.2) < 1e-6, JSON.stringify(back))
126
- const mapped = instance.mapDataToGeoJson({ points: [{ x: 0, y: 0 }, { x: 100, y: 60 }], lines: [] }, { mode: 'fit', padding: 0.1 })
127
- const insideGeo = mapped.points.every(item => item.x >= 116 - 1e-6 && item.x <= 122 + 1e-6 && item.y >= 30 - 1e-6 && item.y <= 35 + 1e-6)
128
- check('mapDataToGeoJson(fit) 把另一套坐标铺进 GeoJSON 范围', insideGeo, JSON.stringify(mapped.points.map(item => [Number(item.x.toFixed(2)), Number(item.y.toFixed(2))])))
129
- const ratioMapper = instance.createGeoMapper({ mode: 'scale', scale: 0.5, data: { points: [{ x: 0, y: 0 }], lines: [] } })
130
- check('createGeoMapper 的 scale 就是节点与 geojson 的大小比例', Boolean(ratioMapper) && Math.abs(ratioMapper.scaleX - 0.5) < 1e-9, ratioMapper ? String(ratioMapper.scaleX) : 'null')
131
- // 悬停回调只在对上的目标变化时才发:换到另一个节点上,等一条新的 hover 再断言
132
- const hoverBefore = events.filter(entry => entry[0] === 'hover').length
133
- fire('pointermove', { ...pointOf(station4.x, station4.y), buttons: 0 })
134
- for (let attempt = 0; attempt < 25; attempt += 1) {
135
- hoverPump.textContent = `p${attempt}`
136
- await new Promise(resolve => setTimeout(resolve, 40))
137
- if (events.filter(entry => entry[0] === 'hover').length > hoverBefore) {
138
- break
139
- }
140
- }
141
- const pointer = instance.getPointer()
142
- check('getPointer 给出屏幕 / 世界 / 经纬度', pointer.inside && pointer.screen.length === 2 && pointer.world.length === 2 && Array.isArray(pointer.lngLat), JSON.stringify(pointer))
143
- check('onHover 载荷带 lngLat', Array.isArray(last('hover')?.lngLat), JSON.stringify(last('hover')?.lngLat ?? null))
144
- // 鹰眼图范围:数据与 GeoJSON 离得很远时,extent: 'auto' 要把两者一起框进小图
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>selftest full running</title>
6
+ </head>
7
+ <body>
8
+ <div id="map" style="width:900px;height:560px"></div>
9
+ <iframe id="frame" style="width:1200px;height:900px;border:1px solid #ccc"></iframe>
10
+ <pre id="out"></pre>
11
+ <script type="module">
12
+ const results = []
13
+ const check = (name, ok, extra = '') => {
14
+ results.push(`${ok ? 'PASS' : 'FAIL'} ${name}${extra === '' ? '' : ` — ${extra}`}`)
15
+ }
16
+ const nativeCapture = Element.prototype.setPointerCapture
17
+ const nativeRelease = Element.prototype.releasePointerCapture
18
+ Element.prototype.setPointerCapture = function (id) {
19
+ try {
20
+ nativeCapture.call(this, id)
21
+ }
22
+ catch (error) {
23
+ void error
24
+ }
25
+ }
26
+ Element.prototype.releasePointerCapture = function (id) {
27
+ try {
28
+ nativeRelease.call(this, id)
29
+ }
30
+ catch (error) {
31
+ void error
32
+ }
33
+ }
34
+ try {
35
+ const { createFullView } = await import('./step-10-full.js')
36
+ const { buildParkData } = await import('./data.js')
37
+ const data = buildParkData(20240915)
38
+ const events = []
39
+ const push = (tag, payload) => events.push([tag, payload])
40
+ const view = createFullView(document.querySelector('#map'), {
41
+ onHover: payload => push('hover', payload),
42
+ onClick: payload => push('click', payload),
43
+ onDblClick: payload => push('dblclick', payload),
44
+ onSelect: payload => push('select', payload),
45
+ onPointerDown: (payload, event) => push('down', payload),
46
+ onPointerUp: (payload, event) => push('up', payload),
47
+ onContextMenu: (payload, event) => push('contextmenu', payload),
48
+ onChannelDraft: payload => push('draft', payload),
49
+ onChannelDraftClick: payload => push('draftclick', payload)
50
+ })
51
+ const last = tag => events.filter(entry => entry[0] === tag).at(-1)?.[1] ?? null
52
+ const canvas = view.el.querySelector('canvas')
53
+ const pointOf = (x, y) => {
54
+ const screen = view.project(x, y)
55
+ const rect = canvas.getBoundingClientRect()
56
+ return { clientX: rect.left + screen[0], clientY: rect.top + screen[1] }
57
+ }
58
+ const fire = (type, extra = {}) => canvas.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 7, pointerType: 'mouse', button: 0, buttons: 1, ...extra }))
59
+ const clickAt = (x, y) => {
60
+ const at = pointOf(x, y)
61
+ fire('pointerdown', at)
62
+ fire('pointermove', { ...at, buttons: 1 })
63
+ fire('pointerup', { ...at, buttons: 0 })
64
+ }
65
+ const station0 = data.points[0]
66
+ const station2 = data.points[2]
67
+ const station4 = data.points[4]
68
+ check('初始通道两条(circuits 展开成回线)', view.getChannels().length === 2, String(view.getChannels().length))
69
+
70
+ fire('pointermove', { ...pointOf(station0.x, station0.y), buttons: 0 })
71
+ // 悬停拾取按帧合并(高刷指针不必每个事件都走一遍拾取网格):等回调落地再看结果。
72
+ // 无头浏览器里没有别的动静时 rAF 会被饿死,这里顺手改一下 DOM 把帧逼出来。
73
+ const hoverPump = document.createElement('div')
74
+ hoverPump.style.cssText = 'position:fixed;left:0;top:0;font-size:10px;opacity:.01'
75
+ document.body.appendChild(hoverPump)
76
+ for (let attempt = 0; attempt < 25 && !last('hover'); attempt += 1) {
77
+ hoverPump.textContent = String(attempt)
78
+ await new Promise(resolve => setTimeout(resolve, 40))
79
+ }
80
+ check('onHover 拿到节点', last('hover')?.type === 'point' && last('hover')?.item?.id === station0.id, JSON.stringify([last('hover')?.type, last('hover')?.item?.id]))
81
+
82
+ view.beginAddChannel({})
83
+ check('beginAddChannel → start', last('draft')?.phase === 'start', last('draft')?.phase)
84
+ clickAt(station0.x, station0.y)
85
+ check('第 1 次点击 → step 1 起点', last('draftclick')?.step === 1 && last('draftclick')?.from?.item?.id === station0.id, JSON.stringify(last('draftclick')?.from?.item?.id))
86
+ clickAt(station2.x, station2.y)
87
+ check('第 2 次点击 → step 2 终点', last('draftclick')?.step === 2 && last('draftclick')?.to?.item?.id === station2.id && last('draftclick')?.finished === true, JSON.stringify(last('draftclick')?.to?.item?.id))
88
+ const committed = view.commitAddChannel()
89
+ check('落地成真实通道', committed.ok === true && view.getChannels().length === 3, JSON.stringify(committed.lineIds))
90
+ check('落地发了 commit 载荷', last('draft')?.phase === 'commit')
91
+ const removed = view.removeLastAdded()
92
+ check('移除新增', removed.ok === true && view.getChannels().length === 2, JSON.stringify(removed))
93
+
94
+ view.beginAddChannel({ continuous: true, chain: true })
95
+ clickAt(station0.x, station0.y)
96
+ clickAt(station2.x, station2.y)
97
+ view.commitAddChannel()
98
+ check('连续新增自动重开一段', last('draft')?.phase === 'restart' && last('draft')?.continuous === true, last('draft')?.phase)
99
+ check('chain:新起点 = 上一段终点', view.getAddChannelDraft()?.from?.item?.id === station2.id, JSON.stringify(view.getAddChannelDraft()?.from?.item?.id))
100
+ clickAt(station4.x, station4.y)
101
+ check('chain 之后接着点又是一段', last('draftclick')?.step === 2 && last('draftclick')?.from?.item?.id === station2.id && last('draftclick')?.to?.item?.id === station4.id)
102
+ view.commitAddChannel()
103
+ view.cancelAddChannel()
104
+ check('cancelAddChannel 收起草稿', view.getAddChannelDraft() === null)
105
+
106
+ fire('contextmenu', { ...pointOf(station0.x, station0.y), button: 2, buttons: 2 })
107
+ check('onContextMenu 有载荷', last('contextmenu')?.item !== undefined && last('contextmenu')?.hit === true, JSON.stringify([last('contextmenu')?.type, last('contextmenu')?.item?.id]))
108
+ check('onPointerDown / onPointerUp 成对', events.some(entry => entry[0] === 'down') && events.some(entry => entry[0] === 'up'))
109
+ // 这一轮:坐标转换(经纬度 ↔ 画出来的 GeoJSON 世界坐标 ↔ 画布像素)、数据映射与鹰眼图范围
110
+ const instance = view.view
111
+ const testGeoJson = {
112
+ type: 'FeatureCollection',
113
+ features: [
114
+ { type: 'Feature', properties: { name: '测试区' }, geometry: { type: 'Polygon', coordinates: [[[116, 30], [122, 30], [122, 35], [116, 35], [116, 30]]] } }
115
+ ]
116
+ }
117
+ instance.updateOptions({ background: { geoJson: testGeoJson, geoJsonStyle: { aspectScale: 1, fill: '#1f4e79', fillOpacity: 0.6 } } })
118
+ await new Promise(resolve => setTimeout(resolve, 150))
119
+ const projection = instance.getGeoProjection()
120
+ check('配了 GeoJSON 底图后拿得到经纬度投影', Boolean(projection), projection ? JSON.stringify(projection.rawBounds) : 'null')
121
+ const world = instance.lngLatToWorld(118.5, 32.2)
122
+ const screen = instance.lngLatToScreen(118.5, 32.2)
123
+ const back = instance.screenToLngLat(screen[0], screen[1])
124
+ check('lngLatToWorld 与 rawBounds 对齐(aspectScale = 1)', Math.abs(world[0] - 118.5) < 1e-9 && Math.abs(world[1] - 32.2) < 1e-9, JSON.stringify(world))
125
+ check('lngLatToScreen / screenToLngLat 往返一致', Math.abs(back[0] - 118.5) < 1e-6 && Math.abs(back[1] - 32.2) < 1e-6, JSON.stringify(back))
126
+ const mapped = instance.mapDataToGeoJson({ points: [{ x: 0, y: 0 }, { x: 100, y: 60 }], lines: [] }, { mode: 'fit', padding: 0.1 })
127
+ const insideGeo = mapped.points.every(item => item.x >= 116 - 1e-6 && item.x <= 122 + 1e-6 && item.y >= 30 - 1e-6 && item.y <= 35 + 1e-6)
128
+ check('mapDataToGeoJson(fit) 把另一套坐标铺进 GeoJSON 范围', insideGeo, JSON.stringify(mapped.points.map(item => [Number(item.x.toFixed(2)), Number(item.y.toFixed(2))])))
129
+ const ratioMapper = instance.createGeoMapper({ mode: 'scale', scale: 0.5, data: { points: [{ x: 0, y: 0 }], lines: [] } })
130
+ check('createGeoMapper 的 scale 就是节点与 geojson 的大小比例', Boolean(ratioMapper) && Math.abs(ratioMapper.scaleX - 0.5) < 1e-9, ratioMapper ? String(ratioMapper.scaleX) : 'null')
131
+ // 悬停回调只在对上的目标变化时才发:换到另一个节点上,等一条新的 hover 再断言
132
+ const hoverBefore = events.filter(entry => entry[0] === 'hover').length
133
+ fire('pointermove', { ...pointOf(station4.x, station4.y), buttons: 0 })
134
+ for (let attempt = 0; attempt < 25; attempt += 1) {
135
+ hoverPump.textContent = `p${attempt}`
136
+ await new Promise(resolve => setTimeout(resolve, 40))
137
+ if (events.filter(entry => entry[0] === 'hover').length > hoverBefore) {
138
+ break
139
+ }
140
+ }
141
+ const pointer = instance.getPointer()
142
+ check('getPointer 给出屏幕 / 世界 / 经纬度', pointer.inside && pointer.screen.length === 2 && pointer.world.length === 2 && Array.isArray(pointer.lngLat), JSON.stringify(pointer))
143
+ check('onHover 载荷带 lngLat', Array.isArray(last('hover')?.lngLat), JSON.stringify(last('hover')?.lngLat ?? null))
144
+ // 鹰眼图范围:数据与 GeoJSON 离得很远时,extent: 'auto' 要把两者一起框进小图
145
145
  const { createGisView: createGisViewRaw } = await import('../../src/gis-view/index.js')
146
146
  // 本页断言是照「限数 + 避让」的老口径写的:统一按优化档(labelOptimize: true)跑
147
147
  const createGisView = (host, options = {}) => createGisViewRaw(host, { labelOptimize: true, ...options })
148
- const host = document.createElement('div')
149
- host.style.cssText = 'position:fixed;left:-9999px;top:0;width:420px;height:320px'
150
- document.body.appendChild(host)
151
- // 底图特意放在离数据很远的地方:extent 只认数据时它整个在画布外,auto 才把它一起框进来
152
- const farGeoJson = {
153
- type: 'FeatureCollection',
154
- features: [
155
- { type: 'Feature', properties: { name: '远处底图' }, geometry: { type: 'Polygon', coordinates: [[[100, 20], [160, 20], [160, 80], [100, 80], [100, 20]]] } }
156
- ]
157
- }
158
- const farView = createGisView(host, {
159
- points: [{ id: 'a', x: 0, y: 0 }, { id: 'b', x: 10, y: 6 }],
160
- lines: [{ id: 'l', from: 'a', to: 'b' }],
161
- background: { geoJson: farGeoJson, geoJsonStyle: { aspectScale: 1, fill: '#1f4e79', fillOpacity: 1 } },
162
- minimap: { width: 200, height: 160, extent: 'data' }
163
- })
164
- /**
165
- * 小图上被 GeoJSON 填充色盖住的像素数:extent 选错时这块整个跑到画布外,点数直接归零。
166
- * 这里按颜色差数,不按 alpha —— 底色会铺满整块画布,alpha 量不出范围差别。
167
- */
168
- const minimapFill = (container = host) => {
169
- const canvas = container.querySelector('.gis-view__minimap-canvas')
170
- const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data
171
- let count = 0
172
- for (let index = 0; index < pixels.length; index += 4) {
173
- if (pixels[index + 3] < 200) {
174
- continue
175
- }
176
- const near = Math.abs(pixels[index] - 31) < 30 && Math.abs(pixels[index + 1] - 78) < 30 && Math.abs(pixels[index + 2] - 121) < 30
177
- if (near) {
178
- count += 1
179
- }
180
- }
181
- return count
182
- }
183
- const settleFrames = async () => {
184
- for (let attempt = 0; attempt < 4; attempt += 1) {
185
- hoverPump.textContent = `m${attempt}`
186
- await new Promise(resolve => setTimeout(resolve, 60))
187
- }
188
- }
189
- await settleFrames()
190
- const fillData = minimapFill()
191
- farView.updateOptions({ minimap: { width: 200, height: 160, extent: 'auto' } })
192
- await settleFrames()
193
- const fillAuto = minimapFill()
194
- check('鹰眼图 extent: auto 把远离数据的 GeoJSON 一起框进来', fillAuto > 100 && fillAuto > fillData + 100, `data=${fillData} auto=${fillAuto}`)
195
- // 反过来:底图小到在合并范围里只剩一两个像素、而相机正好落在里面时,auto 以它为准 ——
196
- // 正在看的主体不该因为另一套坐标离得远就被压成一个点(数据还没映射到底图时常是这种情况)
197
- const tinyHost = document.createElement('div')
198
- tinyHost.style.cssText = 'position:fixed;left:-9999px;top:0;width:420px;height:320px'
199
- document.body.appendChild(tinyHost)
200
- const tinyGeoJson = {
201
- type: 'FeatureCollection',
202
- features: [
203
- { type: 'Feature', properties: { name: '远处小块' }, geometry: { type: 'Polygon', coordinates: [[[100, 20], [101, 20], [101, 21], [100, 21], [100, 20]]] } }
204
- ]
205
- }
206
- const tinyView = createGisView(tinyHost, {
207
- points: [{ id: 'a', x: 0, y: 0 }, { id: 'b', x: 10, y: 6 }],
208
- lines: [{ id: 'l', from: 'a', to: 'b' }],
209
- background: { geoJson: tinyGeoJson, geoJsonStyle: { aspectScale: 1, fill: '#1f4e79', fillOpacity: 1 } },
210
- minimap: { width: 200, height: 160, extent: 'auto' }
211
- })
212
- await settleFrames()
213
- const fillSquashed = minimapFill(tinyHost)
214
- tinyView.zoomToBounds({ minX: 100, minY: 20, maxX: 101, maxY: 21 })
215
- await settleFrames()
216
- const fillFocused = minimapFill(tinyHost)
217
- check('鹰眼图 extent: auto 在相机落进小范围时以它为准', fillFocused > 200 && fillFocused > fillSquashed + 200, `squashed=${fillSquashed} focused=${fillFocused}`)
218
- // 这一轮:沿用主图规则取描边色(不再是白点)、小图尺寸跟随主视图地图倍数、框与框选矩形的样式
219
- const ruleHost = document.createElement('div')
220
- ruleHost.style.cssText = 'position:fixed;left:-9999px;top:0;width:420px;height:320px'
221
- document.body.appendChild(ruleHost)
222
- const ruleView = createGisView(ruleHost, {
223
- points: [{ id: 'a', x: 0, y: 0 }, { id: 'b', x: 10, y: 0 }],
224
- lines: [{ id: 'l', from: 'a', to: 'b' }],
225
- background: false,
226
- pointRules: [{ name: '白底红边', when: { id: 'a' }, style: { color: '#ffffff', strokeColor: '#ff3200', strokeWidth: 1, size: 10 } }],
227
- minimap: {
228
- width: 200,
229
- height: 160,
230
- inheritRules: true,
231
- pointMapZoomScale: 1,
232
- lineMapZoomScale: 1,
233
- boxStyle: { fill: '#00aa55', fillOpacity: 0.5, stroke: '#00aa55', strokeWidth: 3, radius: 6 },
234
- selectStyle: { fill: '#ffcc00', stroke: '#ffcc00' },
235
- zoomable: true,
236
- dragAction: 'box'
237
- }
238
- })
239
- /** 小图上的红色像素(描边色 #ff3200):沿用主图规则时取的就是它,取错颜色会数不到 */
240
- const minimapRed = () => {
241
- const canvas = ruleHost.querySelector('.gis-view__minimap-canvas')
242
- const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data
243
- let count = 0
244
- for (let index = 0; index < pixels.length; index += 4) {
245
- if (pixels[index + 3] > 200 && pixels[index] > 180 && pixels[index + 1] < 90 && pixels[index + 2] < 90) {
246
- count += 1
247
- }
248
- }
249
- return count
250
- }
251
- await settleFrames()
252
- const redBefore = minimapRed()
253
- check('鹰眼图沿用主图规则时取描边色(不是白点)', redBefore > 0, `红色像素 ${redBefore}`)
254
- ruleView.zoomTo(ruleView.getCamera().scale * 3, [210, 160])
255
- await settleFrames()
256
- const redZoomed = minimapRed()
257
- check('鹰眼图节点跟随主视图地图倍数放大', redZoomed > redBefore * 2, `${redBefore} → ${redZoomed}`)
258
- const boxElement = ruleHost.querySelector('.gis-view__minimap-box')
259
- const boxComputed = getComputedStyle(boxElement)
260
- check(
261
- '鹰眼图视口框的颜色 / 背景 / 边框 / 圆角可配',
262
- boxComputed.backgroundColor === 'rgba(0, 170, 85, 0.5)' && boxComputed.borderTopWidth === '3px' && boxComputed.borderTopColor === 'rgb(0, 170, 85)' && boxComputed.borderTopLeftRadius === '6px',
263
- `${boxComputed.backgroundColor} ${boxComputed.borderTopWidth} ${boxComputed.borderTopColor} r=${boxComputed.borderTopLeftRadius}`
264
- )
265
- const minimapRoot = ruleHost.querySelector('.gis-view__minimap')
266
- const minimapRect = minimapRoot.getBoundingClientRect()
267
- const fireMini = (type, localX, localY, extra = {}) => minimapRoot.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 11, pointerType: 'mouse', button: 0, buttons: 1, clientX: minimapRect.left + localX, clientY: minimapRect.top + localY, ...extra }))
268
- fireMini('pointerdown', 4, 4)
269
- fireMini('pointermove', 60, 60)
270
- const selectElement = ruleHost.querySelector('.gis-view__minimap-select')
271
- const selectComputed = selectElement ? getComputedStyle(selectElement) : null
272
- check(
273
- '鹰眼图框选矩形可以单独配样式',
274
- Boolean(selectComputed) && selectComputed.display === 'block' && selectComputed.backgroundColor === 'rgba(255, 204, 0, 0.5)' && selectComputed.borderTopColor === 'rgb(255, 204, 0)',
275
- selectComputed ? `${selectComputed.display} ${selectComputed.backgroundColor} ${selectComputed.borderTopColor}` : '没有框选矩形'
276
- )
277
- fireMini('pointerup', 60, 60, { buttons: 0 })
278
- ruleView.destroy()
279
- ruleHost.remove()
280
- /** 小图上的红色像素(点色 #ff3200):点半径变了立刻看得出来 */
281
- const minimapRedIn = host => {
282
- const canvas = host.querySelector('.gis-view__minimap-canvas')
283
- const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data
284
- let count = 0
285
- for (let index = 0; index < pixels.length; index += 4) {
286
- if (pixels[index + 3] > 200 && pixels[index] > 180 && pixels[index + 1] < 90 && pixels[index + 2] < 90) {
287
- count += 1
288
- }
289
- }
290
- return count
291
- }
292
- // 这一轮:小图上的 GeoJSON 底图按小图自己的尺子画 —— 缩放主视图时线宽 / 点半径 / 区域文字不再单独放大
293
- const bgHost = document.createElement('div')
294
- bgHost.style.cssText = 'position:fixed;left:-9999px;top:0;width:420px;height:320px'
295
- document.body.appendChild(bgHost)
296
- const bgView = createGisView(bgHost, {
297
- points: [{ id: 'a', x: 0, y: 0 }, { id: 'b', x: 10, y: 10 }],
298
- background: {
299
- geoJson: {
300
- type: 'FeatureCollection',
301
- features: [
302
- { type: 'Feature', properties: { name: 'A' }, geometry: { type: 'Polygon', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]] } },
303
- { type: 'Feature', properties: { name: 'P' }, geometry: { type: 'Point', coordinates: [5, 5] } }
304
- ]
305
- },
306
- geoJsonStyle: { aspectScale: 1, fill: '#eef4ff', fillOpacity: 1, stroke: '#ff3200', pointColor: '#ff3200', pointSize: 12 }
307
- },
308
- minimap: { width: 200, height: 160, extent: 'background' }
309
- })
310
- await settleFrames()
311
- const bgRedBefore = minimapRedIn(bgHost)
312
- bgView.zoomTo(bgView.getCamera().scale * 3)
313
- // 相机变化本身不重画小图,这里强制重铺一帧,等价于下一次因数据 / 配置重画时看到的样子
314
- bgView.updateOptions({ minimap: { ...bgView.getOptions().minimap } })
315
- await settleFrames()
316
- const bgRedAfter = minimapRedIn(bgHost)
317
- check(
318
- '鹰眼图 GeoJSON 底图不再跟主视图缩放单独放大',
319
- bgRedBefore > 0 && Math.abs(bgRedAfter - bgRedBefore) <= Math.max(bgRedBefore * 0.2, 2),
320
- `主图放大 3 倍前后小图红点像素 ${bgRedBefore} → ${bgRedAfter}`
321
- )
322
- bgView.destroy()
323
- bgHost.remove()
324
- // 这一轮:小图自身倍数的联动指数(pointZoomScale)真的生效 —— 放大鹰眼图 6 倍,节点半径按 6^指数 长
325
- const zoomHost = document.createElement('div')
326
- zoomHost.style.cssText = 'position:fixed;left:-9999px;top:0;width:420px;height:320px'
327
- document.body.appendChild(zoomHost)
328
- // 小图自己的节点样式(inheritRules: false)才量得准:沿用主图规则时颜色由主图那套决定
329
- const zoomView = createGisView(zoomHost, {
330
- points: [
331
- { id: 's0', x: -30, y: -22 }, { id: 's1', x: 30, y: -22 }, { id: 's2', x: 30, y: 22 }, { id: 's3', x: -30, y: 22 },
332
- { id: 'r0', x: -3, y: -2 }, { id: 'r1', x: 3, y: -2 }, { id: 'r2', x: 3, y: 2 }, { id: 'r3', x: -3, y: 2 }, { id: 'r4', x: 0, y: 0 }
333
- ],
334
- background: false,
335
- minimap: { width: 200, height: 160, background: false, inheritRules: false, pointStyle: { color: '#ff3200', size: 4 }, pointZoomScale: 0, zoomable: true }
336
- })
337
- await settleFrames()
338
- const zoomFlatAtOne = minimapRedIn(zoomHost)
339
- zoomView.setMinimapZoom(6)
340
- await settleFrames()
341
- const zoomFlatAtSix = minimapRedIn(zoomHost)
342
- check(
343
- '鹰眼图 pointZoomScale = 0 时小图放大不改节点大小',
344
- zoomFlatAtOne > 0 && zoomFlatAtSix > 0 && zoomFlatAtSix <= zoomFlatAtOne * 1.1,
345
- `小图 1 倍 ${zoomFlatAtOne} → 6 倍 ${zoomFlatAtSix}`
346
- )
347
- zoomView.updateOptions({ minimap: { ...zoomView.getOptions().minimap, pointZoomScale: 1 } })
348
- await settleFrames()
349
- const zoomInkAtSix = minimapRedIn(zoomHost)
350
- zoomView.setMinimapZoom(1)
351
- await settleFrames()
352
- const zoomInkAtOne = minimapRedIn(zoomHost)
353
- check(
354
- '鹰眼图 pointZoomScale 生效(小图放大 6 倍,节点面积跟着长)',
355
- zoomInkAtSix > zoomFlatAtSix * 4 && zoomInkAtOne <= zoomFlatAtOne * 1.1,
356
- `6 倍:指数 0 ${zoomFlatAtSix} → 指数 1 ${zoomInkAtSix};1 倍时指数 1 只有 ${zoomInkAtOne}`
357
- )
358
- zoomView.destroy()
359
- zoomHost.remove()
360
- // 这一轮:小图 levels 挡掉的节点,连在它们身上的线路一起隐藏(与主视图筛选的 cascade 是同一套联动)
361
- const cascadeHost = document.createElement('div')
362
- cascadeHost.style.cssText = 'position:fixed;left:-9999px;top:0;width:420px;height:320px'
363
- document.body.appendChild(cascadeHost)
364
- // 1000kV 在第 2 层、500kV 在第 1 层:levels: [2] 之后只剩两个端点节点,中间那个节点上的两条线都该消失
365
- const cascadePointRules = [
366
- { when: { vn_kv: { gte: 1000 } }, style: { color: '#ff3200', size: 3, zIndex: 2 } },
367
- { when: { vn_kv: { lt: 1000 } }, style: { color: '#ff3200', size: 3, zIndex: 1 } }
368
- ]
369
- const cascadeView = createGisView(cascadeHost, {
370
- points: [{ id: 'a', x: 0, y: 0, vn_kv: 1000 }, { id: 'b', x: 10, y: 0, vn_kv: 500 }, { id: 'c', x: 20, y: 0, vn_kv: 1000 }],
371
- lines: [{ id: 'l1', from: 'a', to: 'b' }, { id: 'l2', from: 'b', to: 'c' }],
372
- background: false,
373
- minimap: {
374
- width: 200,
375
- height: 160,
376
- background: false,
377
- pointRules: cascadePointRules,
378
- lineStyle: { color: '#00aa55', width: 2, opacity: 1 },
379
- levels: [2]
380
- }
381
- })
382
- /** 小图上的线路像素(线色 #00aa55):连在被挡掉的节点上的线画不出来时它归零 */
383
- const minimapGreen = () => {
384
- const canvas = cascadeHost.querySelector('.gis-view__minimap-canvas')
385
- const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data
386
- let count = 0
387
- for (let index = 0; index < pixels.length; index += 4) {
388
- if (pixels[index + 3] > 200 && pixels[index] < 120 && pixels[index + 1] > 120 && pixels[index + 2] < 120) {
389
- count += 1
390
- }
391
- }
392
- return count
393
- }
394
- await settleFrames()
395
- const cascadeKept = minimapGreen()
396
- const cascadeNodes = minimapRedIn(cascadeHost)
397
- check('鹰眼图 levels 挡掉的节点,连在它身上的线路一起隐藏', cascadeNodes > 0 && cascadeKept === 0, `红色像素 ${cascadeNodes},线路像素 ${cascadeKept}`)
398
- cascadeView.updateOptions({ minimap: { ...cascadeView.getOptions().minimap, cascade: false } })
399
- await settleFrames()
400
- const cascadeOff = minimapGreen()
401
- check('鹰眼图 cascade: false 时线路照旧画', cascadeOff > 0, `线路像素 ${cascadeOff}`)
402
- // 没有 from / to 的数据按端点坐标联动:cascade: 'coordinate' 才认,'binding' 不认
403
- cascadeView.updateLines([{ id: 'l1', path: [[0, 0], [10, 0]] }, { id: 'l2', path: [[10, 0], [20, 0]] }])
404
- cascadeView.updateOptions({ minimap: { ...cascadeView.getOptions().minimap, cascade: 'coordinate' } })
405
- await settleFrames()
406
- const cascadeByCoordinate = minimapGreen()
407
- cascadeView.updateOptions({ minimap: { ...cascadeView.getOptions().minimap, cascade: 'binding' } })
408
- await settleFrames()
409
- const cascadeByBindingOnly = minimapGreen()
410
- check(
411
- '鹰眼图 cascade 的 coordinate / binding 各管一种联动',
412
- cascadeByCoordinate === 0 && cascadeByBindingOnly > 0,
413
- `端点坐标联动 ${cascadeByCoordinate}(应为 0),只按绑定时 ${cascadeByBindingOnly}(应大于 0)`
148
+ const host = document.createElement('div')
149
+ host.style.cssText = 'position:fixed;left:-9999px;top:0;width:420px;height:320px'
150
+ document.body.appendChild(host)
151
+ // 底图特意放在离数据很远的地方:extent 只认数据时它整个在画布外,auto 才把它一起框进来
152
+ const farGeoJson = {
153
+ type: 'FeatureCollection',
154
+ features: [
155
+ { type: 'Feature', properties: { name: '远处底图' }, geometry: { type: 'Polygon', coordinates: [[[100, 20], [160, 20], [160, 80], [100, 80], [100, 20]]] } }
156
+ ]
157
+ }
158
+ const farView = createGisView(host, {
159
+ points: [{ id: 'a', x: 0, y: 0 }, { id: 'b', x: 10, y: 6 }],
160
+ lines: [{ id: 'l', from: 'a', to: 'b' }],
161
+ background: { geoJson: farGeoJson, geoJsonStyle: { aspectScale: 1, fill: '#1f4e79', fillOpacity: 1 } },
162
+ minimap: { width: 200, height: 160, extent: 'data' }
163
+ })
164
+ /**
165
+ * 小图上被 GeoJSON 填充色盖住的像素数:extent 选错时这块整个跑到画布外,点数直接归零。
166
+ * 这里按颜色差数,不按 alpha —— 底色会铺满整块画布,alpha 量不出范围差别。
167
+ */
168
+ const minimapFill = (container = host) => {
169
+ const canvas = container.querySelector('.gis-view__minimap-canvas')
170
+ const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data
171
+ let count = 0
172
+ for (let index = 0; index < pixels.length; index += 4) {
173
+ if (pixels[index + 3] < 200) {
174
+ continue
175
+ }
176
+ const near = Math.abs(pixels[index] - 31) < 30 && Math.abs(pixels[index + 1] - 78) < 30 && Math.abs(pixels[index + 2] - 121) < 30
177
+ if (near) {
178
+ count += 1
179
+ }
180
+ }
181
+ return count
182
+ }
183
+ const settleFrames = async () => {
184
+ for (let attempt = 0; attempt < 4; attempt += 1) {
185
+ hoverPump.textContent = `m${attempt}`
186
+ await new Promise(resolve => setTimeout(resolve, 60))
187
+ }
188
+ }
189
+ await settleFrames()
190
+ const fillData = minimapFill()
191
+ farView.updateOptions({ minimap: { width: 200, height: 160, extent: 'auto' } })
192
+ await settleFrames()
193
+ const fillAuto = minimapFill()
194
+ check('鹰眼图 extent: auto 把远离数据的 GeoJSON 一起框进来', fillAuto > 100 && fillAuto > fillData + 100, `data=${fillData} auto=${fillAuto}`)
195
+ // 反过来:底图小到在合并范围里只剩一两个像素、而相机正好落在里面时,auto 以它为准 ——
196
+ // 正在看的主体不该因为另一套坐标离得远就被压成一个点(数据还没映射到底图时常是这种情况)
197
+ const tinyHost = document.createElement('div')
198
+ tinyHost.style.cssText = 'position:fixed;left:-9999px;top:0;width:420px;height:320px'
199
+ document.body.appendChild(tinyHost)
200
+ const tinyGeoJson = {
201
+ type: 'FeatureCollection',
202
+ features: [
203
+ { type: 'Feature', properties: { name: '远处小块' }, geometry: { type: 'Polygon', coordinates: [[[100, 20], [101, 20], [101, 21], [100, 21], [100, 20]]] } }
204
+ ]
205
+ }
206
+ const tinyView = createGisView(tinyHost, {
207
+ points: [{ id: 'a', x: 0, y: 0 }, { id: 'b', x: 10, y: 6 }],
208
+ lines: [{ id: 'l', from: 'a', to: 'b' }],
209
+ background: { geoJson: tinyGeoJson, geoJsonStyle: { aspectScale: 1, fill: '#1f4e79', fillOpacity: 1 } },
210
+ minimap: { width: 200, height: 160, extent: 'auto' }
211
+ })
212
+ await settleFrames()
213
+ const fillSquashed = minimapFill(tinyHost)
214
+ tinyView.zoomToBounds({ minX: 100, minY: 20, maxX: 101, maxY: 21 })
215
+ await settleFrames()
216
+ const fillFocused = minimapFill(tinyHost)
217
+ check('鹰眼图 extent: auto 在相机落进小范围时以它为准', fillFocused > 200 && fillFocused > fillSquashed + 200, `squashed=${fillSquashed} focused=${fillFocused}`)
218
+ // 这一轮:沿用主图规则取描边色(不再是白点)、小图尺寸跟随主视图地图倍数、框与框选矩形的样式
219
+ const ruleHost = document.createElement('div')
220
+ ruleHost.style.cssText = 'position:fixed;left:-9999px;top:0;width:420px;height:320px'
221
+ document.body.appendChild(ruleHost)
222
+ const ruleView = createGisView(ruleHost, {
223
+ points: [{ id: 'a', x: 0, y: 0 }, { id: 'b', x: 10, y: 0 }],
224
+ lines: [{ id: 'l', from: 'a', to: 'b' }],
225
+ background: false,
226
+ pointRules: [{ name: '白底红边', when: { id: 'a' }, style: { color: '#ffffff', strokeColor: '#ff3200', strokeWidth: 1, size: 10 } }],
227
+ minimap: {
228
+ width: 200,
229
+ height: 160,
230
+ inheritRules: true,
231
+ pointMapZoomScale: 1,
232
+ lineMapZoomScale: 1,
233
+ boxStyle: { fill: '#00aa55', fillOpacity: 0.5, stroke: '#00aa55', strokeWidth: 3, radius: 6 },
234
+ selectStyle: { fill: '#ffcc00', stroke: '#ffcc00' },
235
+ zoomable: true,
236
+ dragAction: 'box'
237
+ }
238
+ })
239
+ /** 小图上的红色像素(描边色 #ff3200):沿用主图规则时取的就是它,取错颜色会数不到 */
240
+ const minimapRed = () => {
241
+ const canvas = ruleHost.querySelector('.gis-view__minimap-canvas')
242
+ const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data
243
+ let count = 0
244
+ for (let index = 0; index < pixels.length; index += 4) {
245
+ if (pixels[index + 3] > 200 && pixels[index] > 180 && pixels[index + 1] < 90 && pixels[index + 2] < 90) {
246
+ count += 1
247
+ }
248
+ }
249
+ return count
250
+ }
251
+ await settleFrames()
252
+ const redBefore = minimapRed()
253
+ check('鹰眼图沿用主图规则时取描边色(不是白点)', redBefore > 0, `红色像素 ${redBefore}`)
254
+ ruleView.zoomTo(ruleView.getCamera().scale * 3, [210, 160])
255
+ await settleFrames()
256
+ const redZoomed = minimapRed()
257
+ check('鹰眼图节点跟随主视图地图倍数放大', redZoomed > redBefore * 2, `${redBefore} → ${redZoomed}`)
258
+ const boxElement = ruleHost.querySelector('.gis-view__minimap-box')
259
+ const boxComputed = getComputedStyle(boxElement)
260
+ check(
261
+ '鹰眼图视口框的颜色 / 背景 / 边框 / 圆角可配',
262
+ boxComputed.backgroundColor === 'rgba(0, 170, 85, 0.5)' && boxComputed.borderTopWidth === '3px' && boxComputed.borderTopColor === 'rgb(0, 170, 85)' && boxComputed.borderTopLeftRadius === '6px',
263
+ `${boxComputed.backgroundColor} ${boxComputed.borderTopWidth} ${boxComputed.borderTopColor} r=${boxComputed.borderTopLeftRadius}`
264
+ )
265
+ const minimapRoot = ruleHost.querySelector('.gis-view__minimap')
266
+ const minimapRect = minimapRoot.getBoundingClientRect()
267
+ const fireMini = (type, localX, localY, extra = {}) => minimapRoot.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 11, pointerType: 'mouse', button: 0, buttons: 1, clientX: minimapRect.left + localX, clientY: minimapRect.top + localY, ...extra }))
268
+ fireMini('pointerdown', 4, 4)
269
+ fireMini('pointermove', 60, 60)
270
+ const selectElement = ruleHost.querySelector('.gis-view__minimap-select')
271
+ const selectComputed = selectElement ? getComputedStyle(selectElement) : null
272
+ check(
273
+ '鹰眼图框选矩形可以单独配样式',
274
+ Boolean(selectComputed) && selectComputed.display === 'block' && selectComputed.backgroundColor === 'rgba(255, 204, 0, 0.5)' && selectComputed.borderTopColor === 'rgb(255, 204, 0)',
275
+ selectComputed ? `${selectComputed.display} ${selectComputed.backgroundColor} ${selectComputed.borderTopColor}` : '没有框选矩形'
276
+ )
277
+ fireMini('pointerup', 60, 60, { buttons: 0 })
278
+ ruleView.destroy()
279
+ ruleHost.remove()
280
+ /** 小图上的红色像素(点色 #ff3200):点半径变了立刻看得出来 */
281
+ const minimapRedIn = host => {
282
+ const canvas = host.querySelector('.gis-view__minimap-canvas')
283
+ const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data
284
+ let count = 0
285
+ for (let index = 0; index < pixels.length; index += 4) {
286
+ if (pixels[index + 3] > 200 && pixels[index] > 180 && pixels[index + 1] < 90 && pixels[index + 2] < 90) {
287
+ count += 1
288
+ }
289
+ }
290
+ return count
291
+ }
292
+ // 这一轮:小图上的 GeoJSON 底图按小图自己的尺子画 —— 缩放主视图时线宽 / 点半径 / 区域文字不再单独放大
293
+ const bgHost = document.createElement('div')
294
+ bgHost.style.cssText = 'position:fixed;left:-9999px;top:0;width:420px;height:320px'
295
+ document.body.appendChild(bgHost)
296
+ const bgView = createGisView(bgHost, {
297
+ points: [{ id: 'a', x: 0, y: 0 }, { id: 'b', x: 10, y: 10 }],
298
+ background: {
299
+ geoJson: {
300
+ type: 'FeatureCollection',
301
+ features: [
302
+ { type: 'Feature', properties: { name: 'A' }, geometry: { type: 'Polygon', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]] } },
303
+ { type: 'Feature', properties: { name: 'P' }, geometry: { type: 'Point', coordinates: [5, 5] } }
304
+ ]
305
+ },
306
+ geoJsonStyle: { aspectScale: 1, fill: '#eef4ff', fillOpacity: 1, stroke: '#ff3200', pointColor: '#ff3200', pointSize: 12 }
307
+ },
308
+ minimap: { width: 200, height: 160, extent: 'background' }
309
+ })
310
+ await settleFrames()
311
+ const bgRedBefore = minimapRedIn(bgHost)
312
+ bgView.zoomTo(bgView.getCamera().scale * 3)
313
+ // 相机变化本身不重画小图,这里强制重铺一帧,等价于下一次因数据 / 配置重画时看到的样子
314
+ bgView.updateOptions({ minimap: { ...bgView.getOptions().minimap } })
315
+ await settleFrames()
316
+ const bgRedAfter = minimapRedIn(bgHost)
317
+ check(
318
+ '鹰眼图 GeoJSON 底图不再跟主视图缩放单独放大',
319
+ bgRedBefore > 0 && Math.abs(bgRedAfter - bgRedBefore) <= Math.max(bgRedBefore * 0.2, 2),
320
+ `主图放大 3 倍前后小图红点像素 ${bgRedBefore} → ${bgRedAfter}`
321
+ )
322
+ bgView.destroy()
323
+ bgHost.remove()
324
+ // 这一轮:小图自身倍数的联动指数(pointZoomScale)真的生效 —— 放大鹰眼图 6 倍,节点半径按 6^指数 长
325
+ const zoomHost = document.createElement('div')
326
+ zoomHost.style.cssText = 'position:fixed;left:-9999px;top:0;width:420px;height:320px'
327
+ document.body.appendChild(zoomHost)
328
+ // 小图自己的节点样式(inheritRules: false)才量得准:沿用主图规则时颜色由主图那套决定
329
+ const zoomView = createGisView(zoomHost, {
330
+ points: [
331
+ { id: 's0', x: -30, y: -22 }, { id: 's1', x: 30, y: -22 }, { id: 's2', x: 30, y: 22 }, { id: 's3', x: -30, y: 22 },
332
+ { id: 'r0', x: -3, y: -2 }, { id: 'r1', x: 3, y: -2 }, { id: 'r2', x: 3, y: 2 }, { id: 'r3', x: -3, y: 2 }, { id: 'r4', x: 0, y: 0 }
333
+ ],
334
+ background: false,
335
+ minimap: { width: 200, height: 160, background: false, inheritRules: false, pointStyle: { color: '#ff3200', size: 4 }, pointZoomScale: 0, zoomable: true }
336
+ })
337
+ await settleFrames()
338
+ const zoomFlatAtOne = minimapRedIn(zoomHost)
339
+ zoomView.setMinimapZoom(6)
340
+ await settleFrames()
341
+ const zoomFlatAtSix = minimapRedIn(zoomHost)
342
+ check(
343
+ '鹰眼图 pointZoomScale = 0 时小图放大不改节点大小',
344
+ zoomFlatAtOne > 0 && zoomFlatAtSix > 0 && zoomFlatAtSix <= zoomFlatAtOne * 1.1,
345
+ `小图 1 倍 ${zoomFlatAtOne} → 6 倍 ${zoomFlatAtSix}`
346
+ )
347
+ zoomView.updateOptions({ minimap: { ...zoomView.getOptions().minimap, pointZoomScale: 1 } })
348
+ await settleFrames()
349
+ const zoomInkAtSix = minimapRedIn(zoomHost)
350
+ zoomView.setMinimapZoom(1)
351
+ await settleFrames()
352
+ const zoomInkAtOne = minimapRedIn(zoomHost)
353
+ check(
354
+ '鹰眼图 pointZoomScale 生效(小图放大 6 倍,节点面积跟着长)',
355
+ zoomInkAtSix > zoomFlatAtSix * 4 && zoomInkAtOne <= zoomFlatAtOne * 1.1,
356
+ `6 倍:指数 0 ${zoomFlatAtSix} → 指数 1 ${zoomInkAtSix};1 倍时指数 1 只有 ${zoomInkAtOne}`
357
+ )
358
+ zoomView.destroy()
359
+ zoomHost.remove()
360
+ // 这一轮:小图 levels 挡掉的节点,连在它们身上的线路一起隐藏(与主视图筛选的 cascade 是同一套联动)
361
+ const cascadeHost = document.createElement('div')
362
+ cascadeHost.style.cssText = 'position:fixed;left:-9999px;top:0;width:420px;height:320px'
363
+ document.body.appendChild(cascadeHost)
364
+ // 1000kV 在第 2 层、500kV 在第 1 层:levels: [2] 之后只剩两个端点节点,中间那个节点上的两条线都该消失
365
+ const cascadePointRules = [
366
+ { when: { vn_kv: { gte: 1000 } }, style: { color: '#ff3200', size: 3, zIndex: 2 } },
367
+ { when: { vn_kv: { lt: 1000 } }, style: { color: '#ff3200', size: 3, zIndex: 1 } }
368
+ ]
369
+ const cascadeView = createGisView(cascadeHost, {
370
+ points: [{ id: 'a', x: 0, y: 0, vn_kv: 1000 }, { id: 'b', x: 10, y: 0, vn_kv: 500 }, { id: 'c', x: 20, y: 0, vn_kv: 1000 }],
371
+ lines: [{ id: 'l1', from: 'a', to: 'b' }, { id: 'l2', from: 'b', to: 'c' }],
372
+ background: false,
373
+ minimap: {
374
+ width: 200,
375
+ height: 160,
376
+ background: false,
377
+ pointRules: cascadePointRules,
378
+ lineStyle: { color: '#00aa55', width: 2, opacity: 1 },
379
+ levels: [2]
380
+ }
381
+ })
382
+ /** 小图上的线路像素(线色 #00aa55):连在被挡掉的节点上的线画不出来时它归零 */
383
+ const minimapGreen = () => {
384
+ const canvas = cascadeHost.querySelector('.gis-view__minimap-canvas')
385
+ const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data
386
+ let count = 0
387
+ for (let index = 0; index < pixels.length; index += 4) {
388
+ if (pixels[index + 3] > 200 && pixels[index] < 120 && pixels[index + 1] > 120 && pixels[index + 2] < 120) {
389
+ count += 1
390
+ }
391
+ }
392
+ return count
393
+ }
394
+ await settleFrames()
395
+ const cascadeKept = minimapGreen()
396
+ const cascadeNodes = minimapRedIn(cascadeHost)
397
+ check('鹰眼图 levels 挡掉的节点,连在它身上的线路一起隐藏', cascadeNodes > 0 && cascadeKept === 0, `红色像素 ${cascadeNodes},线路像素 ${cascadeKept}`)
398
+ cascadeView.updateOptions({ minimap: { ...cascadeView.getOptions().minimap, cascade: false } })
399
+ await settleFrames()
400
+ const cascadeOff = minimapGreen()
401
+ check('鹰眼图 cascade: false 时线路照旧画', cascadeOff > 0, `线路像素 ${cascadeOff}`)
402
+ // 没有 from / to 的数据按端点坐标联动:cascade: 'coordinate' 才认,'binding' 不认
403
+ cascadeView.updateData({ lines: [{ id: 'l1', path: [[0, 0], [10, 0]] }, { id: 'l2', path: [[10, 0], [20, 0]] }] })
404
+ cascadeView.updateOptions({ minimap: { ...cascadeView.getOptions().minimap, cascade: 'coordinate' } })
405
+ await settleFrames()
406
+ const cascadeByCoordinate = minimapGreen()
407
+ cascadeView.updateOptions({ minimap: { ...cascadeView.getOptions().minimap, cascade: 'binding' } })
408
+ await settleFrames()
409
+ const cascadeByBindingOnly = minimapGreen()
410
+ check(
411
+ '鹰眼图 cascade 的 coordinate / binding 各管一种联动',
412
+ cascadeByCoordinate === 0 && cascadeByBindingOnly > 0,
413
+ `端点坐标联动 ${cascadeByCoordinate}(应为 0),只按绑定时 ${cascadeByBindingOnly}(应大于 0)`
414
414
  )
415
415
  cascadeView.destroy()
416
416
  cascadeHost.remove()
@@ -646,170 +646,170 @@
646
646
  plainMapHost.remove()
647
647
  zoomMapHost.remove()
648
648
  // 标签:停手时引擎正好在发散渲染(isLayoutReady 为假)也不能把强制排版丢掉,
649
- // 就绪之后必须重排一次,把 hideOnInteract 收起的图层放出来
650
- const { createGisViewOverlay } = await import('../../src/gis-view/overlay.js')
651
- const labelHost = document.createElement('div')
652
- labelHost.style.cssText = 'position:fixed;left:-9999px;top:0;width:800px;height:600px'
653
- document.body.appendChild(labelHost)
654
- let layoutReady = true
655
- const labelPoints = [0, 1, 2].map(index => ({ name: `P${index}`, x: index * 30 - 30, y: 0 }))
656
- const stubOverlay = createGisViewOverlay(labelHost, {
657
- getOptions: () => ({ label: { enabled: true, hideOnInteract: true, template: '{name}' }, lineLabel: { enabled: false } }),
658
- getViewport: () => ({ cssWidth: 800, cssHeight: 600, pixelRatio: 1 }),
659
- getViewRect: () => ({ minX: -100, minY: -100, maxX: 100, maxY: 100 }),
660
- getVisiblePoints: () => labelPoints.map((item, index) => ({ index, item, x: item.x, y: item.y, radius: 4 })),
661
- getPointItem: index => labelPoints[index],
662
- getPointRadius: () => 4,
663
- isPointVisible: () => true,
664
- project: (x, y) => [x * 4 + 400, 300 - y * 4],
665
- isLayoutReady: () => layoutReady,
666
- forEachLineInView: () => {},
667
- forEachLine: () => {},
668
- getLine: () => null
669
- })
670
- stubOverlay.refreshLabels()
671
- await settleFrames()
672
- const stubLayer = labelHost.querySelector('.gis-view__label-layer--point')
673
- const stubLabels = stubLayer.children.length
674
- stubOverlay.shiftLabels(1, 20, 10, false)
675
- layoutReady = false
676
- await settleFrames()
677
- layoutReady = true
678
- await settleFrames()
679
- check(
680
- '引擎没就绪时被挡下的强制排版,就绪之后标签照样恢复',
681
- stubLabels > 0 && !stubLayer.classList.contains('gis-view__label-layer--hidden'),
682
- `${stubLabels} 个标签,hidden=${stubLayer.classList.contains('gis-view__label-layer--hidden')}`
683
- )
684
- // 这一轮:交互态不能永久挂着 —— 结束事件丢了(指针在画布外松开、pointercancel、拖节点没有 end)时,
685
- // 空闲计时器到点也要自己强制排版一次,把 hideOnInteract 收起的图层放出来
686
- stubOverlay.moveLabels([{ index: 0, item: labelPoints[0] }])
687
- const hiddenAfterMove = stubLayer.classList.contains('gis-view__label-layer--hidden')
688
- await new Promise(resolve => setTimeout(resolve, 420))
689
- check(
690
- '拖节点之后没有结束事件,空闲计时器也会把标签层放出来',
691
- hiddenAfterMove && !stubLayer.classList.contains('gis-view__label-layer--hidden'),
692
- `拖动后 hidden=${hiddenAfterMove},空闲后 hidden=${stubLayer.classList.contains('gis-view__label-layer--hidden')}`
693
- )
694
- stubOverlay.destroy()
695
- labelHost.remove()
696
- tinyView.destroy()
697
- tinyHost.remove()
698
- farView.destroy()
699
- host.remove()
700
- view.destroy()
701
- }
702
- catch (error) {
703
- results.push(`ERROR ${error && error.stack ? error.stack : String(error)}`)
704
- }
705
- // 再把 index.html 的 13 个步骤逐个点一遍:能挂载、能销毁、控制台不报错
706
- try {
707
- const frame = document.querySelector('#frame')
708
- await new Promise((resolve, reject) => {
709
- frame.addEventListener('load', resolve, { once: true })
710
- frame.addEventListener('error', reject, { once: true })
711
- frame.src = './index.html'
712
- })
713
- const win = frame.contentWindow
714
- const doc = frame.contentDocument
715
- const errors = []
716
- win.addEventListener('error', event => errors.push(String(event.message)))
717
- win.addEventListener('unhandledrejection', event => errors.push(String(event.reason)))
718
- await new Promise(resolve => setTimeout(resolve, 600))
719
- const buttons = [...doc.querySelectorAll('#steps button')]
720
- check('index.html 渲染出 13 个步骤', buttons.length === 13, String(buttons.length))
721
- for (let index = 0; index < buttons.length; index += 1) {
722
- buttons[index].click()
723
- await new Promise(resolve => setTimeout(resolve, 400))
724
- const readout = doc.querySelector('#rd-step').textContent
725
- check(`第 ${index + 1} 步能挂载`, readout.includes(`当前步骤:${index + 1}.`), readout.slice(0, 48))
726
- }
727
- check('切完 13 步没有报错', errors.length === 0, errors.join(' | '))
728
- // 第 11 步:底图与初始视图都只改配置,「切换初始视图」要真的把相机比例换一档
729
- buttons[10].click()
730
- await new Promise(resolve => setTimeout(resolve, 600))
731
- const step11 = win.__gisSteps.view
732
- const camera11 = step11.getCamera()
733
- const background11 = doc.querySelector('#btn-background')
734
- doc.querySelector('#btn-initial').click()
735
- await new Promise(resolve => setTimeout(resolve, 300))
736
- const afterInitial = step11.getCamera()
737
- check('第 11 步能挂载,且工具条上的两个切档按钮可用', !background11.disabled, String(step11.getStats().drawCalls) + ' 次绘制')
738
- check('第 11 步「切换初始视图」按 initialView 换了一档比例', Math.abs(afterInitial.scale - camera11.scale) > 1e-6, `${camera11.scale.toFixed(4)} → ${afterInitial.scale.toFixed(4)}`)
739
- // 第 11 步的底图:循环切档,其中一档是 SVG 路径图片(background.image 只给 d 字符串)
740
- let stepImage = null
741
- let stepRegionLabels = 0
742
- for (let attempt = 0; attempt < 6 && !(stepImage && stepRegionLabels); attempt += 1) {
743
- doc.querySelector('#btn-background').click()
744
- await new Promise(resolve => setTimeout(resolve, 350))
745
- // __gisSteps.view 是这一步的包装对象,真正的实例挂在 .view 上
746
- const resolved = (step11.view ?? step11).getResolvedBackground?.()
747
- if (!stepImage && resolved?.image?.quad?.length === 16) {
748
- stepImage = resolved.image
749
- }
750
- stepRegionLabels = Math.max(stepRegionLabels, doc.querySelectorAll('.gis-view__background-label').length)
751
- }
752
- check('第 11 步底图能切到 SVG 路径图片并解析出四边形', Boolean(stepImage), stepImage ? JSON.stringify(stepImage.quad.slice(0, 4)) : '没有 quad')
753
- check('第 11 步的 GeoJSON 档能挂出区域文字', stepRegionLabels > 0, `${stepRegionLabels} 个区域文字`)
754
- // 第 7 ~ 10 步:在页面上真的按一遍「新增通道 + 点两个节点」,看读数与日志
755
- for (const index of [6, 7, 8, 9]) {
756
- buttons[index].click()
757
- await new Promise(resolve => setTimeout(resolve, 500))
758
- const view = win.__gisSteps.view
759
- doc.querySelector('#btn-add').click()
760
- await new Promise(resolve => setTimeout(resolve, 120))
761
- const canvas = doc.querySelector('#map canvas')
762
- const rect = canvas.getBoundingClientRect()
763
- const at = (x, y) => {
764
- const screen = view.project(x, y)
765
- return { clientX: rect.left + screen[0], clientY: rect.top + screen[1] }
766
- }
767
- const fire = (type, extra = {}) => canvas.dispatchEvent(new win.PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 3, pointerType: 'mouse', button: 0, buttons: 1, ...extra }))
768
- const clickAt = (x, y) => {
769
- const point = at(x, y)
770
- fire('pointerdown', point)
771
- fire('pointermove', { ...point, buttons: 1 })
772
- fire('pointerup', { ...point, buttons: 0 })
773
- }
774
- const data = win.__gisSteps.DATA
775
- clickAt(data.points[0].x, data.points[0].y)
776
- await new Promise(resolve => setTimeout(resolve, 120))
777
- const stepOne = doc.querySelector('#rd-draft').textContent
778
- clickAt(data.points[2].x, data.points[2].y)
779
- await new Promise(resolve => setTimeout(resolve, 120))
780
- const stepTwo = doc.querySelector('#rd-draft').textContent
781
- check(`第 ${index + 1} 步:页面点两下拿起点与终点`, stepOne.includes('step 1 起点') && stepTwo.includes('step 2 终点'), `${stepOne} | ${stepTwo}`)
782
- const before = view.getChannels?.().length ?? 0
783
- doc.querySelector('#btn-commit').click()
784
- await new Promise(resolve => setTimeout(resolve, 200))
785
- const after = view.getChannels?.().length ?? 0
786
- check(`第 ${index + 1} 步:页面点「落地」通道 +1`, after === before + 1, `${before} -> ${after}`)
787
- doc.querySelector('#btn-cancel').click()
788
- await new Promise(resolve => setTimeout(resolve, 120))
789
- }
790
- // 第 12 步:动作判读的读数与两个只读查询(pickAt / getFeaturesInRect)
791
- buttons[11].click()
792
- await new Promise(resolve => setTimeout(resolve, 600))
793
- const step12 = win.__gisSteps.view
794
- const instance12 = step12.view ?? step12
795
- const canvas12 = doc.querySelector('#map canvas')
796
- const rect12 = canvas12.getBoundingClientRect()
797
- const fire12 = (type, at, extra = {}) => canvas12.dispatchEvent(new win.PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 7, pointerType: 'mouse', ...at, ...extra }))
798
- const station12 = win.__gisSteps.DATA.points[0]
799
- const screen12 = instance12.project(station12.x, station12.y)
800
- const stationAt12 = { clientX: rect12.left + screen12[0], clientY: rect12.top + screen12[1] }
801
- fire12('pointermove', stationAt12)
802
- await new Promise(resolve => setTimeout(resolve, 140))
803
- const hitReadout = doc.querySelector('#rd-hit').textContent
804
- check('第 12 步:光标读数走 pickAt,给出这儿命中的要素', hitReadout.includes('这儿是什么') && hitReadout.includes(station12.name), hitReadout.slice(0, 64))
805
- fire12('pointerdown', stationAt12, { button: 0, buttons: 1 })
806
- fire12('pointerup', stationAt12, { button: 0, buttons: 0 })
807
- await new Promise(resolve => setTimeout(resolve, 180))
808
- const actionReadout = doc.querySelector('#rd-action').textContent
809
- check('第 12 步:点一下,读数栏的「判读」给出动作与点到的要素', actionReadout.includes('判读:') && actionReadout.includes('→ 节点'), actionReadout.slice(0, 72))
810
- doc.querySelector('#btn-rect').click()
811
- await new Promise(resolve => setTimeout(resolve, 220))
812
- const rectReadout = doc.querySelector('#rd-action').textContent
649
+ // 就绪之后必须重排一次,把 hideOnInteract 收起的图层放出来
650
+ const { createGisViewOverlay } = await import('../../src/gis-view/overlay.js')
651
+ const labelHost = document.createElement('div')
652
+ labelHost.style.cssText = 'position:fixed;left:-9999px;top:0;width:800px;height:600px'
653
+ document.body.appendChild(labelHost)
654
+ let layoutReady = true
655
+ const labelPoints = [0, 1, 2].map(index => ({ name: `P${index}`, x: index * 30 - 30, y: 0 }))
656
+ const stubOverlay = createGisViewOverlay(labelHost, {
657
+ getOptions: () => ({ label: { enabled: true, hideOnInteract: true, template: '{name}' }, lineLabel: { enabled: false } }),
658
+ getViewport: () => ({ cssWidth: 800, cssHeight: 600, pixelRatio: 1 }),
659
+ getViewRect: () => ({ minX: -100, minY: -100, maxX: 100, maxY: 100 }),
660
+ getVisiblePoints: () => labelPoints.map((item, index) => ({ index, item, x: item.x, y: item.y, radius: 4 })),
661
+ getPointItem: index => labelPoints[index],
662
+ getPointRadius: () => 4,
663
+ isPointVisible: () => true,
664
+ project: (x, y) => [x * 4 + 400, 300 - y * 4],
665
+ isLayoutReady: () => layoutReady,
666
+ forEachLineInView: () => {},
667
+ forEachLine: () => {},
668
+ getLine: () => null
669
+ })
670
+ stubOverlay.refreshLabels()
671
+ await settleFrames()
672
+ const stubLayer = labelHost.querySelector('.gis-view__label-layer--point')
673
+ const stubLabels = stubLayer.children.length
674
+ stubOverlay.shiftLabels(1, 20, 10, false)
675
+ layoutReady = false
676
+ await settleFrames()
677
+ layoutReady = true
678
+ await settleFrames()
679
+ check(
680
+ '引擎没就绪时被挡下的强制排版,就绪之后标签照样恢复',
681
+ stubLabels > 0 && !stubLayer.classList.contains('gis-view__label-layer--hidden'),
682
+ `${stubLabels} 个标签,hidden=${stubLayer.classList.contains('gis-view__label-layer--hidden')}`
683
+ )
684
+ // 这一轮:交互态不能永久挂着 —— 结束事件丢了(指针在画布外松开、pointercancel、拖节点没有 end)时,
685
+ // 空闲计时器到点也要自己强制排版一次,把 hideOnInteract 收起的图层放出来
686
+ stubOverlay.moveLabels([{ index: 0, item: labelPoints[0] }])
687
+ const hiddenAfterMove = stubLayer.classList.contains('gis-view__label-layer--hidden')
688
+ await new Promise(resolve => setTimeout(resolve, 420))
689
+ check(
690
+ '拖节点之后没有结束事件,空闲计时器也会把标签层放出来',
691
+ hiddenAfterMove && !stubLayer.classList.contains('gis-view__label-layer--hidden'),
692
+ `拖动后 hidden=${hiddenAfterMove},空闲后 hidden=${stubLayer.classList.contains('gis-view__label-layer--hidden')}`
693
+ )
694
+ stubOverlay.destroy()
695
+ labelHost.remove()
696
+ tinyView.destroy()
697
+ tinyHost.remove()
698
+ farView.destroy()
699
+ host.remove()
700
+ view.destroy()
701
+ }
702
+ catch (error) {
703
+ results.push(`ERROR ${error && error.stack ? error.stack : String(error)}`)
704
+ }
705
+ // 再把 index.html 的 13 个步骤逐个点一遍:能挂载、能销毁、控制台不报错
706
+ try {
707
+ const frame = document.querySelector('#frame')
708
+ await new Promise((resolve, reject) => {
709
+ frame.addEventListener('load', resolve, { once: true })
710
+ frame.addEventListener('error', reject, { once: true })
711
+ frame.src = './index.html'
712
+ })
713
+ const win = frame.contentWindow
714
+ const doc = frame.contentDocument
715
+ const errors = []
716
+ win.addEventListener('error', event => errors.push(String(event.message)))
717
+ win.addEventListener('unhandledrejection', event => errors.push(String(event.reason)))
718
+ await new Promise(resolve => setTimeout(resolve, 600))
719
+ const buttons = [...doc.querySelectorAll('#steps button')]
720
+ check('index.html 渲染出 13 个步骤', buttons.length === 13, String(buttons.length))
721
+ for (let index = 0; index < buttons.length; index += 1) {
722
+ buttons[index].click()
723
+ await new Promise(resolve => setTimeout(resolve, 400))
724
+ const readout = doc.querySelector('#rd-step').textContent
725
+ check(`第 ${index + 1} 步能挂载`, readout.includes(`当前步骤:${index + 1}.`), readout.slice(0, 48))
726
+ }
727
+ check('切完 13 步没有报错', errors.length === 0, errors.join(' | '))
728
+ // 第 11 步:底图与初始视图都只改配置,「切换初始视图」要真的把相机比例换一档
729
+ buttons[10].click()
730
+ await new Promise(resolve => setTimeout(resolve, 600))
731
+ const step11 = win.__gisSteps.view
732
+ const camera11 = step11.getCamera()
733
+ const background11 = doc.querySelector('#btn-background')
734
+ doc.querySelector('#btn-initial').click()
735
+ await new Promise(resolve => setTimeout(resolve, 300))
736
+ const afterInitial = step11.getCamera()
737
+ check('第 11 步能挂载,且工具条上的两个切档按钮可用', !background11.disabled, String(step11.getStats().drawCalls) + ' 次绘制')
738
+ check('第 11 步「切换初始视图」按 initialView 换了一档比例', Math.abs(afterInitial.scale - camera11.scale) > 1e-6, `${camera11.scale.toFixed(4)} → ${afterInitial.scale.toFixed(4)}`)
739
+ // 第 11 步的底图:循环切档,其中一档是 SVG 路径图片(background.image 只给 d 字符串)
740
+ let stepImage = null
741
+ let stepRegionLabels = 0
742
+ for (let attempt = 0; attempt < 6 && !(stepImage && stepRegionLabels); attempt += 1) {
743
+ doc.querySelector('#btn-background').click()
744
+ await new Promise(resolve => setTimeout(resolve, 350))
745
+ // __gisSteps.view 是这一步的包装对象,真正的实例挂在 .view 上
746
+ const resolved = (step11.view ?? step11).getResolvedBackground?.()
747
+ if (!stepImage && resolved?.image?.quad?.length === 16) {
748
+ stepImage = resolved.image
749
+ }
750
+ stepRegionLabels = Math.max(stepRegionLabels, doc.querySelectorAll('.gis-view__background-label').length)
751
+ }
752
+ check('第 11 步底图能切到 SVG 路径图片并解析出四边形', Boolean(stepImage), stepImage ? JSON.stringify(stepImage.quad.slice(0, 4)) : '没有 quad')
753
+ check('第 11 步的 GeoJSON 档能挂出区域文字', stepRegionLabels > 0, `${stepRegionLabels} 个区域文字`)
754
+ // 第 7 ~ 10 步:在页面上真的按一遍「新增通道 + 点两个节点」,看读数与日志
755
+ for (const index of [6, 7, 8, 9]) {
756
+ buttons[index].click()
757
+ await new Promise(resolve => setTimeout(resolve, 500))
758
+ const view = win.__gisSteps.view
759
+ doc.querySelector('#btn-add').click()
760
+ await new Promise(resolve => setTimeout(resolve, 120))
761
+ const canvas = doc.querySelector('#map canvas')
762
+ const rect = canvas.getBoundingClientRect()
763
+ const at = (x, y) => {
764
+ const screen = view.project(x, y)
765
+ return { clientX: rect.left + screen[0], clientY: rect.top + screen[1] }
766
+ }
767
+ const fire = (type, extra = {}) => canvas.dispatchEvent(new win.PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 3, pointerType: 'mouse', button: 0, buttons: 1, ...extra }))
768
+ const clickAt = (x, y) => {
769
+ const point = at(x, y)
770
+ fire('pointerdown', point)
771
+ fire('pointermove', { ...point, buttons: 1 })
772
+ fire('pointerup', { ...point, buttons: 0 })
773
+ }
774
+ const data = win.__gisSteps.DATA
775
+ clickAt(data.points[0].x, data.points[0].y)
776
+ await new Promise(resolve => setTimeout(resolve, 120))
777
+ const stepOne = doc.querySelector('#rd-draft').textContent
778
+ clickAt(data.points[2].x, data.points[2].y)
779
+ await new Promise(resolve => setTimeout(resolve, 120))
780
+ const stepTwo = doc.querySelector('#rd-draft').textContent
781
+ check(`第 ${index + 1} 步:页面点两下拿起点与终点`, stepOne.includes('step 1 起点') && stepTwo.includes('step 2 终点'), `${stepOne} | ${stepTwo}`)
782
+ const before = view.getChannels?.().length ?? 0
783
+ doc.querySelector('#btn-commit').click()
784
+ await new Promise(resolve => setTimeout(resolve, 200))
785
+ const after = view.getChannels?.().length ?? 0
786
+ check(`第 ${index + 1} 步:页面点「落地」通道 +1`, after === before + 1, `${before} -> ${after}`)
787
+ doc.querySelector('#btn-cancel').click()
788
+ await new Promise(resolve => setTimeout(resolve, 120))
789
+ }
790
+ // 第 12 步:动作判读的读数与两个只读查询(pickAt / getFeaturesInRect)
791
+ buttons[11].click()
792
+ await new Promise(resolve => setTimeout(resolve, 600))
793
+ const step12 = win.__gisSteps.view
794
+ const instance12 = step12.view ?? step12
795
+ const canvas12 = doc.querySelector('#map canvas')
796
+ const rect12 = canvas12.getBoundingClientRect()
797
+ const fire12 = (type, at, extra = {}) => canvas12.dispatchEvent(new win.PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 7, pointerType: 'mouse', ...at, ...extra }))
798
+ const station12 = win.__gisSteps.DATA.points[0]
799
+ const screen12 = instance12.project(station12.x, station12.y)
800
+ const stationAt12 = { clientX: rect12.left + screen12[0], clientY: rect12.top + screen12[1] }
801
+ fire12('pointermove', stationAt12)
802
+ await new Promise(resolve => setTimeout(resolve, 140))
803
+ const hitReadout = doc.querySelector('#rd-hit').textContent
804
+ check('第 12 步:光标读数走 pickAt,给出这儿命中的要素', hitReadout.includes('这儿是什么') && hitReadout.includes(station12.name), hitReadout.slice(0, 64))
805
+ fire12('pointerdown', stationAt12, { button: 0, buttons: 1 })
806
+ fire12('pointerup', stationAt12, { button: 0, buttons: 0 })
807
+ await new Promise(resolve => setTimeout(resolve, 180))
808
+ const actionReadout = doc.querySelector('#rd-action').textContent
809
+ check('第 12 步:点一下,读数栏的「判读」给出动作与点到的要素', actionReadout.includes('判读:') && actionReadout.includes('→ 节点'), actionReadout.slice(0, 72))
810
+ doc.querySelector('#btn-rect').click()
811
+ await new Promise(resolve => setTimeout(resolve, 220))
812
+ const rectReadout = doc.querySelector('#rd-action').textContent
813
813
  check('第 12 步:「框内有什么」调 getFeaturesInRect,按节点 / 线路 / 通道报数', rectReadout.includes('框内') && /节点 \d+/.test(rectReadout), rectReadout.slice(0, 72))
814
814
  // 第 13 步:工程化四件套 —— 画质 / 批量 / 撤销重做 / 功能预设
815
815
  buttons[12].click()
@@ -843,12 +843,12 @@
843
843
  check('第 13 步:功能预设切到 view(只读浏览:平移 / 选中还开、拖动关掉)',
844
844
  doc.querySelector('#rd-features').textContent.includes('拖 关') && doc.querySelector('#rd-features').textContent.includes('平移 开'),
845
845
  doc.querySelector('#rd-features').textContent)
846
- }
847
- catch (error) {
848
- results.push(`ERROR iframe ${error && error.stack ? error.stack : String(error)}`)
849
- }
850
- document.querySelector('#out').textContent = results.join('\n')
851
- document.title = results.some(line => line.startsWith('FAIL') || line.startsWith('ERROR')) ? 'selftest FAIL' : 'selftest PASS'
852
- </script>
853
- </body>
854
- </html>
846
+ }
847
+ catch (error) {
848
+ results.push(`ERROR iframe ${error && error.stack ? error.stack : String(error)}`)
849
+ }
850
+ document.querySelector('#out').textContent = results.join('\n')
851
+ document.title = results.some(line => line.startsWith('FAIL') || line.startsWith('ERROR')) ? 'selftest FAIL' : 'selftest PASS'
852
+ </script>
853
+ </body>
854
+ </html>