gis-view-webgl2 0.1.4 → 0.1.6

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 (37) hide show
  1. package/README.md +10 -7
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.d.ts +225 -20
  4. package/dist/index.js +1 -1
  5. package/docs/AI/345/257/271/350/257/235/346/261/207/346/200/273.md +109 -0
  6. package/docs/GisView.md +53 -53
  7. 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 +91 -0
  8. package/docs/demos/ConnectDemo.vue +114 -3
  9. package/docs/demos/MinimapDemo.vue +216 -17
  10. package/docs/demos/SearchFilterDemo.vue +113 -1
  11. package/docs/demos/api-index.js +28 -20
  12. package/docs/demos/demos.js +1 -1
  13. package/docs/demos/gis-view-demo/GisViewDemoStage.vue +25 -16
  14. package/docs/demos/gis-view-demo/useGisViewDemo.js +27 -16
  15. package/docs/index.html +3 -3
  16. package/docs/internals/13-/351/271/260/347/234/274/345/233/276.md +8 -5
  17. package/docs/internals/16-01-/344/274/230/345/214/226/344/270/216/345/217/226/350/210/215-/344/270/212.md +18 -5
  18. 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 -1
  19. package/docs/internals/18-/346/200/247/350/203/275/345/256/236/346/265/213/344/270/216WebGPU/350/257/204/344/274/260.md +73 -1
  20. package/docs/manual/03-/351/205/215/347/275/256/351/241/271.md +14 -5
  21. package/docs/manual/04-/344/272/213/344/273/266/344/270/216/345/233/236/350/260/203.md +2 -2
  22. package/docs/manual/05-/347/224/273/350/264/250/344/270/216/346/211/271/351/207/217/346/233/264/346/226/260.md +1 -1
  23. package/docs/manual/09-/351/200/232/351/201/223/344/270/216/345/244/232/345/233/236/347/272/277.md +1 -1
  24. 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 +423 -3
  25. package/docs/manual/14-/350/247/206/345/233/276/344/270/216/347/255/233/351/200/211.md +99 -30
  26. package/docs/manual/16-/345/256/236/344/276/213/346/226/271/346/263/225/346/200/273/350/241/250.md +12 -7
  27. package/docs/manual/17-/345/217/202/346/225/260/351/200/237/346/237/245.md +14 -6
  28. package/docs/manual/18-/351/200/220/346/226/271/346/263/225/347/244/272/344/276/213.md +137 -6
  29. 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 -0
  30. package/docs/manual/20-/346/200/247/350/203/275/344/270/216/345/244/247/346/225/260/346/215/256.md +8 -3
  31. 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 +6 -3
  32. package/docs/steps/README.md +6 -6
  33. package/docs/steps/selftest-demos.html +252 -9
  34. package/docs/steps/selftest-docs.html +3 -3
  35. package/docs/steps/selftest-full.html +357 -13
  36. package/docs/steps/selftest-perf.html +599 -382
  37. package/package.json +1 -1
@@ -28,78 +28,78 @@
28
28
  // 拾取用的节点:线路只挂在前面 8000 个节点上,取后面的才不会被线抢走命中
29
29
  const clearPoint = 15000
30
30
  try {
31
- const { createGisView: createGisViewRaw, QUALITY_LEVELS, QUALITY_MODES, createQualityGovernor, normalizeQualityOptions } = await import('../../src/gis-view/index.js')
32
- const { normalizePointCluster, pointClusterZoomActive } = await import('../../src/gis-view/cluster.js')
33
- const { createHeatmap } = await import('../../src/gis-view/heatmap.js')
34
- const { normalizeDensity, renderDensityField } = await import('../../src/gis-view/density.js')
35
- const clusterDefault = normalizePointCluster(true)
36
- const clusterClamped = normalizePointCluster({ enabled: true, radius: 2, minPoints: 1, maxZoom: -3 })
37
- check('点聚合:true 用缺省档(半径 60 / 最少 2 点 / 不限缩放)',
38
- clusterDefault.enabled && clusterDefault.radius === 60 && clusterDefault.minPoints === 2 && clusterDefault.maxZoom === 0,
39
- JSON.stringify(clusterDefault))
40
- check('点聚合:半径 / 最少点数 / maxZoom 都夹到可用区间', clusterClamped.radius === 8 && clusterClamped.minPoints === 2 && clusterClamped.maxZoom === 0,
41
- JSON.stringify(clusterClamped))
42
- check('点聚合:maxZoom 按相机比例 / 基准比例判定,超过后展开',
43
- pointClusterZoomActive({ enabled: true, maxZoom: 2 }, 1.5, 1) === true && pointClusterZoomActive({ enabled: true, maxZoom: 2 }, 2.1, 1) === false,
44
- '1.5× 聚合 / 2.1× 展开')
45
- // 性能断言是照「优化档」(限数 + 避让)测的老口径:统一带上 labelOptimize: true
46
- const createGisView = (host, options = {}) => createGisViewRaw(host, { labelOptimize: true, ...options })
47
- /* ---------------- 热力图语义键:只改密度画法不重采要素 ---------------- */
48
- let semanticHeatmapOption = {
49
- enabled: true,
50
- field: 'value',
51
- targets: ['point', 'line'],
52
- domain: [0, 100],
53
- colors: ['#0b3d91', '#ffd166', '#ff5e5e'],
54
- density: { opacity: 1, layer: 'below', radius: 24 }
55
- }
56
- const semanticHeatmap = createHeatmap(() => semanticHeatmapOption, () => [])
57
- const styleKeyBefore = semanticHeatmap.styleKey()
58
- const weightKeyBefore = semanticHeatmap.weightKey()
59
- semanticHeatmapOption = {
60
- ...semanticHeatmapOption,
61
- density: { ...semanticHeatmapOption.density, opacity: 0.25, layer: 'top', radius: 40 }
62
- }
63
- check('热力图语义键:只改 density 的透明度 / 画层 / 核半径,样式键与权重键都不变',
64
- semanticHeatmap.styleKey() === styleKeyBefore && semanticHeatmap.weightKey() === weightKeyBefore)
65
- const densityOnlyStyleKey = semanticHeatmap.styleKey()
66
- const densityOnlyWeightKey = semanticHeatmap.weightKey()
67
- semanticHeatmapOption = { ...semanticHeatmapOption, colors: ['#123456', '#7ce0b0', '#ff8a3d'] }
68
- check('热力图语义键:换色带只改样式键,权重键保持不动',
69
- semanticHeatmap.styleKey() !== densityOnlyStyleKey && semanticHeatmap.weightKey() === densityOnlyWeightKey)
70
- const colorWeightKey = semanticHeatmap.weightKey()
71
- semanticHeatmapOption = { ...semanticHeatmapOption, field: 'score' }
72
- check('热力图语义键:换取值字段时两个键都变(密度场要重新采样)',
73
- semanticHeatmap.weightKey() !== colorWeightKey && semanticHeatmap.styleKey() !== densityOnlyStyleKey)
74
-
75
- /* ---------------- 热力图密度场:透明边缘的 RGB 预乘 alpha ---------------- */
76
- const premultOptions = normalizeDensity({ style: 'smooth', max: 1, radius: 6, resolution: 64, threshold: 0, core: 0 })
77
- const premultPalette = {
78
- bands: [[1, 0, 0, 1]],
79
- smooth: [[1, 0, 0, 1], [1, 0, 0, 1]],
80
- core: null
81
- }
82
- const premultField = renderDensityField([{ x: 0, y: 0, ratio: 1 }], premultOptions, premultPalette)
83
- let premultPartial = 0
84
- let premultBad = 0
85
- if (premultField) {
86
- for (let offset = 0; offset < premultField.pixels.length; offset += 4) {
87
- const alpha = premultField.pixels[offset + 3]
88
- if (alpha === 0 || alpha === 255) {
89
- continue
90
- }
91
- premultPartial += 1
92
- // 纯红色带:预乘后 R 应与 A 同值、G/B 都还是 0;旧实现固定写 R=255,透明边缘会发灰。
93
- if (premultField.pixels[offset] !== alpha || premultField.pixels[offset + 1] !== 0 || premultField.pixels[offset + 2] !== 0) {
94
- premultBad += 1
95
- }
96
- }
97
- }
98
- check('热力图密度场:透明边缘的 RGB 已预乘(R 跟 A 同降、G/B 为 0,不再留灰边)',
99
- Boolean(premultField) && premultPartial > 0 && premultBad === 0,
100
- `${premultField?.width ?? 0}×${premultField?.height ?? 0} / 半透明 ${premultPartial} 格 / 未预乘 ${premultBad} 格`)
101
-
102
- /* ---------------- 档位表与归一(纯函数) ---------------- */
31
+ const { createGisView: createGisViewRaw, QUALITY_LEVELS, QUALITY_MODES, createQualityGovernor, normalizeQualityOptions } = await import('../../src/gis-view/index.js')
32
+ const { normalizePointCluster, pointClusterZoomActive } = await import('../../src/gis-view/cluster.js')
33
+ const { createHeatmap } = await import('../../src/gis-view/heatmap.js')
34
+ const { normalizeDensity, renderDensityField } = await import('../../src/gis-view/density.js')
35
+ const clusterDefault = normalizePointCluster(true)
36
+ const clusterClamped = normalizePointCluster({ enabled: true, radius: 2, minPoints: 1, maxZoom: -3 })
37
+ check('点聚合:true 用缺省档(半径 60 / 最少 2 点 / 不限缩放)',
38
+ clusterDefault.enabled && clusterDefault.radius === 60 && clusterDefault.minPoints === 2 && clusterDefault.maxZoom === 0,
39
+ JSON.stringify(clusterDefault))
40
+ check('点聚合:半径 / 最少点数 / maxZoom 都夹到可用区间', clusterClamped.radius === 8 && clusterClamped.minPoints === 2 && clusterClamped.maxZoom === 0,
41
+ JSON.stringify(clusterClamped))
42
+ check('点聚合:maxZoom 按相机比例 / 基准比例判定,超过后展开',
43
+ pointClusterZoomActive({ enabled: true, maxZoom: 2 }, 1.5, 1) === true && pointClusterZoomActive({ enabled: true, maxZoom: 2 }, 2.1, 1) === false,
44
+ '1.5× 聚合 / 2.1× 展开')
45
+ // 性能断言是照「优化档」(限数 + 避让)测的老口径:统一带上 labelOptimize: true
46
+ const createGisView = (host, options = {}) => createGisViewRaw(host, { labelOptimize: true, ...options })
47
+ /* ---------------- 热力图语义键:只改密度画法不重采要素 ---------------- */
48
+ let semanticHeatmapOption = {
49
+ enabled: true,
50
+ field: 'value',
51
+ targets: ['point', 'line'],
52
+ domain: [0, 100],
53
+ colors: ['#0b3d91', '#ffd166', '#ff5e5e'],
54
+ density: { opacity: 1, layer: 'below', radius: 24 }
55
+ }
56
+ const semanticHeatmap = createHeatmap(() => semanticHeatmapOption, () => [])
57
+ const styleKeyBefore = semanticHeatmap.styleKey()
58
+ const weightKeyBefore = semanticHeatmap.weightKey()
59
+ semanticHeatmapOption = {
60
+ ...semanticHeatmapOption,
61
+ density: { ...semanticHeatmapOption.density, opacity: 0.25, layer: 'top', radius: 40 }
62
+ }
63
+ check('热力图语义键:只改 density 的透明度 / 画层 / 核半径,样式键与权重键都不变',
64
+ semanticHeatmap.styleKey() === styleKeyBefore && semanticHeatmap.weightKey() === weightKeyBefore)
65
+ const densityOnlyStyleKey = semanticHeatmap.styleKey()
66
+ const densityOnlyWeightKey = semanticHeatmap.weightKey()
67
+ semanticHeatmapOption = { ...semanticHeatmapOption, colors: ['#123456', '#7ce0b0', '#ff8a3d'] }
68
+ check('热力图语义键:换色带只改样式键,权重键保持不动',
69
+ semanticHeatmap.styleKey() !== densityOnlyStyleKey && semanticHeatmap.weightKey() === densityOnlyWeightKey)
70
+ const colorWeightKey = semanticHeatmap.weightKey()
71
+ semanticHeatmapOption = { ...semanticHeatmapOption, field: 'score' }
72
+ check('热力图语义键:换取值字段时两个键都变(密度场要重新采样)',
73
+ semanticHeatmap.weightKey() !== colorWeightKey && semanticHeatmap.styleKey() !== densityOnlyStyleKey)
74
+
75
+ /* ---------------- 热力图密度场:透明边缘的 RGB 预乘 alpha ---------------- */
76
+ const premultOptions = normalizeDensity({ style: 'smooth', max: 1, radius: 6, resolution: 64, threshold: 0, core: 0 })
77
+ const premultPalette = {
78
+ bands: [[1, 0, 0, 1]],
79
+ smooth: [[1, 0, 0, 1], [1, 0, 0, 1]],
80
+ core: null
81
+ }
82
+ const premultField = renderDensityField([{ x: 0, y: 0, ratio: 1 }], premultOptions, premultPalette)
83
+ let premultPartial = 0
84
+ let premultBad = 0
85
+ if (premultField) {
86
+ for (let offset = 0; offset < premultField.pixels.length; offset += 4) {
87
+ const alpha = premultField.pixels[offset + 3]
88
+ if (alpha === 0 || alpha === 255) {
89
+ continue
90
+ }
91
+ premultPartial += 1
92
+ // 纯红色带:预乘后 R 应与 A 同值、G/B 都还是 0;旧实现固定写 R=255,透明边缘会发灰。
93
+ if (premultField.pixels[offset] !== alpha || premultField.pixels[offset + 1] !== 0 || premultField.pixels[offset + 2] !== 0) {
94
+ premultBad += 1
95
+ }
96
+ }
97
+ }
98
+ check('热力图密度场:透明边缘的 RGB 已预乘(R 跟 A 同降、G/B 为 0,不再留灰边)',
99
+ Boolean(premultField) && premultPartial > 0 && premultBad === 0,
100
+ `${premultField?.width ?? 0}×${premultField?.height ?? 0} / 半透明 ${premultPartial} 格 / 未预乘 ${premultBad} 格`)
101
+
102
+ /* ---------------- 档位表与归一(纯函数) ---------------- */
103
103
  check('QUALITY_MODES 是五档:四个固定档 + auto', QUALITY_MODES.join(',') === 'high,balanced,lean,fast,auto', QUALITY_MODES.join(','))
104
104
  check('QUALITY_LEVELS 从高到低,越往下省得越多', QUALITY_LEVELS.length === 4 &&
105
105
  QUALITY_LEVELS[0].glow && QUALITY_LEVELS[0].ornament && QUALITY_LEVELS[0].scale === 1 &&
@@ -144,72 +144,72 @@
144
144
  smoothGovernor.sample(0.2, 16.7)
145
145
  }
146
146
  check('自动档:60Hz 的正常间隔(16.7ms)不会误判成超标', smoothGovernor.state().name === 'high', smoothGovernor.state().name)
147
- const fixed = createQualityGovernor('balanced')
148
- fixed.sample(50)
149
- check('固定档不受帧耗时影响', fixed.state().name === 'balanced' && fixed.state().glow === false && fixed.state().scale === 1, `${fixed.state().name} glow=${fixed.state().glow}`)
150
-
151
- /* ---------------- 视口可见性:离开视口停动画,回来再续 ---------------- */
152
- const visibilityHost = document.createElement('div')
153
- visibilityHost.style.cssText = 'position:fixed;left:-10000px;top:0;width:320px;height:200px'
154
- document.body.append(visibilityHost)
155
- const visibilityView = createGisView(visibilityHost, {
156
- points: [{ id: 'visibility-point', x: 0, y: 0 }],
157
- lines: [],
158
- effects: { point: { radius: 16, period: 1.5, count: 1 } },
159
- showGrid: false
160
- })
161
- visibilityView.fitView()
162
- await sleep(260)
163
- const hiddenAnimating = visibilityView.getStats().animating
164
- visibilityHost.style.left = '0px'
165
- await sleep(260)
166
- const shownAnimating = visibilityView.getStats().animating
167
- check('画布离开视口时连续动画停住,回到视口后再继续',
168
- hiddenAnimating === false && shownAnimating === true,
169
- `视口外 animating=${hiddenAnimating} / 视口内 animating=${shownAnimating}`)
170
- visibilityView.destroy()
171
- visibilityHost.remove()
172
-
173
- /* ---------------- 大数据密集档:手动开关 / auto 阈值 / 效果恢复 ---------------- */
174
- const denseHost = document.createElement('div')
175
- denseHost.style.cssText = 'width:420px;height:280px'
176
- document.querySelector('#map').append(denseHost)
177
- const densePoints = Array.from({ length: 10 }, (unused, index) => ({ id: `dp-${index}`, x: index * 20, y: index % 3 * 20, name: `密集 ${index}` }))
178
- const denseView = createGisView(denseHost, {
179
- points: densePoints,
180
- lines: [],
181
- width: 420,
182
- height: 280,
183
- denseMode: 'on'
184
- })
185
- const denseOn = denseView.getStats().dense
186
- check('密集档 on:读数 active / reason=manual-on,动画整体暂停', denseOn?.active === true && denseOn?.reason === 'manual-on' && denseView.getEffects().enabled === false, JSON.stringify(denseOn))
187
- // 密集期间改动画配置:有效配置仍关着,但用户写的那一份要留在 options 里
188
- denseView.updateOptions({ effects: { point: { radius: 12, period: 2, count: 1 } } })
189
- check('密集期间下发 effects:有效配置仍暂停(用户配置留待退出后恢复)', denseView.getEffects().enabled === false && denseView.getOptions().effects?.point?.radius === 12, JSON.stringify(denseView.getEffects()))
190
- denseView.updateOptions({ denseMode: 'off' })
191
- await sleep(30)
192
- check('密集档 off:动画按用户配置恢复', denseView.getStats().dense?.active === false && denseView.getStats().dense?.reason === 'manual-off' && denseView.getEffects().enabled === true, JSON.stringify({ dense: denseView.getStats().dense, effects: denseView.getEffects().enabled }))
193
- denseView.updateOptions({ denseMode: { mode: 'auto', visibleThreshold: 6, densityThreshold: 1e9, pointThreshold: 1e9 } })
194
- await sleep(240)
195
- const denseAuto = denseView.getStats().dense
196
- check('密集档 auto:可见节点越过 visibleThreshold 时自动进入(reason=visible)', denseAuto?.active === true && denseAuto?.reason === 'visible' && denseAuto?.visiblePoints >= 6, JSON.stringify(denseAuto))
197
- denseView.updateOptions({ denseMode: { mode: 'auto', visibleThreshold: 100, densityThreshold: 20, pointThreshold: 1e9 } })
198
- await sleep(240)
199
- const denseDensity = denseView.getStats().dense
200
- check('密集档 auto:屏幕密度越线也能进入(reason=density)', denseDensity?.active === true && denseDensity?.reason === 'density', JSON.stringify(denseDensity))
201
- denseView.updateOptions({ denseMode: { mode: 'auto', visibleThreshold: 999, densityThreshold: 1e9, pointThreshold: 5 } })
202
- await sleep(240)
203
- const denseTotal = denseView.getStats().dense
204
- check('密集档 auto:数据总量越线直接进入(reason=total)', denseTotal?.active === true && denseTotal?.reason === 'total' && denseTotal?.visiblePoints === null, JSON.stringify(denseTotal))
205
- denseView.updateOptions({ denseMode: { mode: 'auto', visibleThreshold: 100, densityThreshold: 1e9, pointThreshold: 1e9 } })
206
- await sleep(240)
207
- const denseBelow = denseView.getStats().dense
208
- check('密集档 auto:阈值以下主动退出(reason=below-threshold)', denseBelow?.active === false && denseBelow?.reason === 'below-threshold' && denseView.getEffects().enabled === true, JSON.stringify(denseBelow))
209
- denseView.destroy()
210
- denseHost.remove()
211
-
212
- /* ---------------- 大数据量:建场景 / 渲染 / 拾取 / 换档 ---------------- */
147
+ const fixed = createQualityGovernor('balanced')
148
+ fixed.sample(50)
149
+ check('固定档不受帧耗时影响', fixed.state().name === 'balanced' && fixed.state().glow === false && fixed.state().scale === 1, `${fixed.state().name} glow=${fixed.state().glow}`)
150
+
151
+ /* ---------------- 视口可见性:离开视口停动画,回来再续 ---------------- */
152
+ const visibilityHost = document.createElement('div')
153
+ visibilityHost.style.cssText = 'position:fixed;left:-10000px;top:0;width:320px;height:200px'
154
+ document.body.append(visibilityHost)
155
+ const visibilityView = createGisView(visibilityHost, {
156
+ points: [{ id: 'visibility-point', x: 0, y: 0 }],
157
+ lines: [],
158
+ effects: { point: { radius: 16, period: 1.5, count: 1 } },
159
+ showGrid: false
160
+ })
161
+ visibilityView.fitView()
162
+ await sleep(260)
163
+ const hiddenAnimating = visibilityView.getStats().animating
164
+ visibilityHost.style.left = '0px'
165
+ await sleep(260)
166
+ const shownAnimating = visibilityView.getStats().animating
167
+ check('画布离开视口时连续动画停住,回到视口后再继续',
168
+ hiddenAnimating === false && shownAnimating === true,
169
+ `视口外 animating=${hiddenAnimating} / 视口内 animating=${shownAnimating}`)
170
+ visibilityView.destroy()
171
+ visibilityHost.remove()
172
+
173
+ /* ---------------- 大数据密集档:手动开关 / auto 阈值 / 效果恢复 ---------------- */
174
+ const denseHost = document.createElement('div')
175
+ denseHost.style.cssText = 'width:420px;height:280px'
176
+ document.querySelector('#map').append(denseHost)
177
+ const densePoints = Array.from({ length: 10 }, (unused, index) => ({ id: `dp-${index}`, x: index * 20, y: index % 3 * 20, name: `密集 ${index}` }))
178
+ const denseView = createGisView(denseHost, {
179
+ points: densePoints,
180
+ lines: [],
181
+ width: 420,
182
+ height: 280,
183
+ denseMode: 'on'
184
+ })
185
+ const denseOn = denseView.getStats().dense
186
+ check('密集档 on:读数 active / reason=manual-on,动画整体暂停', denseOn?.active === true && denseOn?.reason === 'manual-on' && denseView.getEffects().enabled === false, JSON.stringify(denseOn))
187
+ // 密集期间改动画配置:有效配置仍关着,但用户写的那一份要留在 options 里
188
+ denseView.updateOptions({ effects: { point: { radius: 12, period: 2, count: 1 } } })
189
+ check('密集期间下发 effects:有效配置仍暂停(用户配置留待退出后恢复)', denseView.getEffects().enabled === false && denseView.getOptions().effects?.point?.radius === 12, JSON.stringify(denseView.getEffects()))
190
+ denseView.updateOptions({ denseMode: 'off' })
191
+ await sleep(30)
192
+ check('密集档 off:动画按用户配置恢复', denseView.getStats().dense?.active === false && denseView.getStats().dense?.reason === 'manual-off' && denseView.getEffects().enabled === true, JSON.stringify({ dense: denseView.getStats().dense, effects: denseView.getEffects().enabled }))
193
+ denseView.updateOptions({ denseMode: { mode: 'auto', visibleThreshold: 6, densityThreshold: 1e9, pointThreshold: 1e9 } })
194
+ await sleep(240)
195
+ const denseAuto = denseView.getStats().dense
196
+ check('密集档 auto:可见节点越过 visibleThreshold 时自动进入(reason=visible)', denseAuto?.active === true && denseAuto?.reason === 'visible' && denseAuto?.visiblePoints >= 6, JSON.stringify(denseAuto))
197
+ denseView.updateOptions({ denseMode: { mode: 'auto', visibleThreshold: 100, densityThreshold: 20, pointThreshold: 1e9 } })
198
+ await sleep(240)
199
+ const denseDensity = denseView.getStats().dense
200
+ check('密集档 auto:屏幕密度越线也能进入(reason=density)', denseDensity?.active === true && denseDensity?.reason === 'density', JSON.stringify(denseDensity))
201
+ denseView.updateOptions({ denseMode: { mode: 'auto', visibleThreshold: 999, densityThreshold: 1e9, pointThreshold: 5 } })
202
+ await sleep(240)
203
+ const denseTotal = denseView.getStats().dense
204
+ check('密集档 auto:数据总量越线直接进入(reason=total)', denseTotal?.active === true && denseTotal?.reason === 'total' && denseTotal?.visiblePoints === null, JSON.stringify(denseTotal))
205
+ denseView.updateOptions({ denseMode: { mode: 'auto', visibleThreshold: 100, densityThreshold: 1e9, pointThreshold: 1e9 } })
206
+ await sleep(240)
207
+ const denseBelow = denseView.getStats().dense
208
+ check('密集档 auto:阈值以下主动退出(reason=below-threshold)', denseBelow?.active === false && denseBelow?.reason === 'below-threshold' && denseView.getEffects().enabled === true, JSON.stringify(denseBelow))
209
+ denseView.destroy()
210
+ denseHost.remove()
211
+
212
+ /* ---------------- 大数据量:建场景 / 渲染 / 拾取 / 换档 ---------------- */
213
213
  const pointCount = 20000
214
214
  const lineCount = 8000
215
215
  const points = new Array(pointCount)
@@ -243,6 +243,35 @@
243
243
  const refreshMs = now() - refreshStarted
244
244
  diag('整份重排一帧', `${refreshMs.toFixed(1)} ms(引擎读数 frameMs=${big.getStats().frameMs.toFixed(2)},drawCalls=${big.getStats().drawCalls})`)
245
245
  check('大数据量下一帧画得完(有 drawCalls,没有报错)', big.getStats().drawCalls > 0)
246
+ // getBounds() 缓存:整份解析后直接命中;定向改点 / 改线要立即失效,不能读到旧范围
247
+ const boundsHost = document.createElement('div')
248
+ document.querySelector('#map').append(boundsHost)
249
+ const boundsView = createGisView(boundsHost, {
250
+ points: [{ id: 'bp0', x: 10, y: 10 }, { id: 'bp1', x: 20, y: 20 }],
251
+ lines: [{ id: 'bl0', path: [[10, 10], [15, 15]] }],
252
+ width: 320,
253
+ height: 200
254
+ })
255
+ const boundsBefore = boundsView.getBounds()
256
+ boundsView.updatePoint('bp0', { x: -100, y: -200 })
257
+ const boundsAfterPoint = boundsView.getBounds()
258
+ boundsView.updateLine('bl0', { path: [[-300, -400], [0, 0]] })
259
+ const boundsAfterLine = boundsView.getBounds()
260
+ check('数据包围盒缓存:整份解析后命中缓存,定向改点 / 改线都会立即失效',
261
+ boundsBefore.minX === 10 && boundsAfterPoint.minX === -100 && boundsAfterPoint.minY === -200 && boundsAfterLine.minX === -300 && boundsAfterLine.minY === -400,
262
+ `${JSON.stringify(boundsBefore)} → ${JSON.stringify(boundsAfterPoint)} → ${JSON.stringify(boundsAfterLine)}`)
263
+ boundsAfterLine.minX = 99999
264
+ const boundsAfterMutation = boundsView.getBounds()
265
+ check('数据包围盒缓存:调用方修改返回值不会污染下一次读数',
266
+ boundsAfterMutation.minX === -300 && boundsAfterMutation.minY === -400,
267
+ JSON.stringify(boundsAfterMutation))
268
+ boundsView.addPoint({ id: 'bp2', x: -600, y: -700 })
269
+ const boundsAfterAppend = boundsView.getBounds()
270
+ check('数据包围盒缓存:追加到范围外的节点会立即扩大读数',
271
+ boundsAfterAppend.minX === -600 && boundsAfterAppend.minY === -700,
272
+ JSON.stringify(boundsAfterAppend))
273
+ boundsView.destroy()
274
+ boundsHost.remove()
246
275
  // 两万个点会走发散渲染:等它铺完再拾取(没铺到的节点按设计不参与拾取)
247
276
  const readyDeadline = now() + 8000
248
277
  while (!big.isLayoutReady() && now() < readyDeadline) {
@@ -265,7 +294,7 @@
265
294
  // 选中 N 个节点时每个节点的连带线路都要算一遍:从前是「N × 全部线路」的比对,
266
295
  // 框选几千个节点、删除确认面板还没弹出来就先卡住了;现在按 id 建表、线路与通道各扫一遍。
267
296
  const impactCount = 8000
268
- big.selectFeatures(points.slice(0, impactCount).map(point => point.id))
297
+ big.selectFeatures(points.slice(0, impactCount).map(point => point.id))
269
298
  const removeStarted = now()
270
299
  const removePlan = big.requestRemove()
271
300
  const removeMs = now() - removeStarted
@@ -274,7 +303,7 @@
274
303
  JSON.stringify(removePlan.counts))
275
304
  diag(`删除确认载荷(选中 ${impactCount} 点 / ${lineCount} 线)`, `${removeMs.toFixed(1)} ms`)
276
305
  removePlan.cancel()
277
- big.selectFeatures(null)
306
+ big.selectFeatures(null)
278
307
 
279
308
  /* ---------------- 通道在场时的补点 / 删点:这一轮之前一律回落整份重建 ---------------- */
280
309
  // 通道的成员表按线路下标记、骨架按锚点 id 定:声明逐条没变、没删到回线与锚点时走增量
@@ -382,16 +411,16 @@
382
411
  await sleep(20)
383
412
  }
384
413
  await sleep(300)
385
- spriteProbe.selectFeatures(['sprite-0'])
414
+ spriteProbe.selectFeatures(['sprite-0'])
386
415
  await sleep(500)
387
- spriteProbe.selectFeatures(null)
416
+ spriteProbe.selectFeatures(null)
388
417
  await sleep(200)
389
418
  spriteProbe.refresh()
390
419
  await sleep(120)
391
420
  spriteProbe.refresh()
392
421
  await sleep(120)
393
422
  const spriteCallsBefore = spriteProbe.getStats().drawCalls
394
- spriteProbe.selectFeatures(['sprite-0'])
423
+ spriteProbe.selectFeatures(['sprite-0'])
395
424
  await sleep(200)
396
425
  const spriteCallsAfter = spriteProbe.getStats().drawCalls
397
426
  check('贴图增量建组只多一次绘制调用(编号表与绘制序分开,新建的组不会被画两遍)', spriteCallsAfter - spriteCallsBefore === 1, `${spriteCallsBefore} → ${spriteCallsAfter}`)
@@ -581,23 +610,25 @@
581
610
  }
582
611
  const anchorView = createGisView(anchorHost, { points: anchorPoints, lines: anchorLines, width: 900, height: 560, label: { content: '{name}', maxCount: 200 }, lineLabel: { maxCount: 60 } })
583
612
  await sleep(200)
584
- for (let step = 0; step < 6; step += 1) {
585
- anchorView.setCamera({ center: [1000 + step * 120, 700 + step * 60], scale: 1.4 })
586
- // 用 rAF 等这一步(不能用 sleep):相机停手后标签层才会照着新视口收一遍候选,
587
- // sleep 期间那次「停手重排」会把签名全部量完,后面的读数就被抹平了
588
- await new Promise(resolve => requestAnimationFrame(() => resolve()))
589
- }
613
+ for (let step = 0; step < 6; step += 1) {
614
+ anchorView.setCamera({ center: [1000 + step * 120, 700 + step * 60], scale: 1.4 })
615
+ // 用 rAF 等这一步:相机每动一下标签层就照着新视口收一遍候选
616
+ await new Promise(resolve => requestAnimationFrame(() => resolve()))
617
+ }
618
+ // 程序化相机运动还有 160ms 的轻量画法窗口,停稳后会补一张全质量帧并再收一次候选。
619
+ // 先让这一帧落地,再开始计量,后面测到的才是稳态重排,而不是收尾那一帧的噪声。
620
+ await sleep(800)
590
621
  const anchorStats = { reads: 0, duplicates: 0 }
591
- const anchorSeen = new Set()
592
- const anchorReads = []
593
- const anchorDuplicates = []
622
+ const anchorSeen = new Set()
623
+ const anchorReads = []
624
+ const anchorDuplicates = []
594
625
  const nativeAnchorRect = Element.prototype.getBoundingClientRect
595
626
  Element.prototype.getBoundingClientRect = function (...args) {
596
- if (this.classList?.contains('gis-view__label')) {
597
- anchorStats.reads += 1
598
- // 按标签身份(类型 + 行号 + 标签名)去重:标签节点是循环复用的,按节点去重会把「同一格换了条标签」误判成重复量
599
- const anchorKey = `${this.dataset.labelKind}#${this.dataset.labelIndex}#${this.dataset.labelLabel}`
600
- if (anchorSeen.has(anchorKey)) {
627
+ if (this.classList?.contains('gis-view__label')) {
628
+ anchorStats.reads += 1
629
+ // 按标签身份(类型 + 行号 + 标签名)去重:标签节点是循环复用的,按节点去重会把「同一格换了条标签」误判成重复量
630
+ const anchorKey = `${this.dataset.labelKind}#${this.dataset.labelIndex}#${this.dataset.labelLabel}`
631
+ if (anchorSeen.has(anchorKey)) {
601
632
  anchorStats.duplicates += 1
602
633
  }
603
634
  anchorSeen.add(anchorKey)
@@ -605,9 +636,9 @@
605
636
  return nativeAnchorRect.apply(this, args)
606
637
  }
607
638
  const anchorRound = () => {
608
- anchorStats.reads = 0
609
- anchorStats.duplicates = 0
610
- anchorSeen.clear()
639
+ anchorStats.reads = 0
640
+ anchorStats.duplicates = 0
641
+ anchorSeen.clear()
611
642
  }
612
643
  try {
613
644
  // 跑 6 轮:换锚点(anchorFallback)/ 沿线滑动(slideFallback)会让某一轮补画几十条
@@ -615,21 +646,21 @@
615
646
  // 最后三轮盯的就是「收敛之后一次布局都不读」
616
647
  for (let round = 0; round < 6; round += 1) {
617
648
  anchorRound()
618
- anchorView.updateOptions({ label: { content: '{name}', maxCount: 200 }, lineLabel: { maxCount: 60 } })
619
- anchorReads.push(anchorStats.reads)
620
- anchorDuplicates.push(anchorStats.duplicates)
649
+ anchorView.updateOptions({ label: { content: '{name}', maxCount: 200 }, lineLabel: { maxCount: 60 } })
650
+ anchorReads.push(anchorStats.reads)
651
+ anchorDuplicates.push(anchorStats.duplicates)
621
652
  }
622
653
  // 改一条视口里的标签(下标 = 76 行 × 400 列 + 150 → 世界坐标 1500, 760):这一轮同样不该整层重量
623
654
  anchorRound()
624
- anchorView.updatePoint('a' + (76 * 400 + 150), { name: '改过的名字' })
625
- anchorReads.push(anchorStats.reads)
626
- anchorDuplicates.push(anchorStats.duplicates)
655
+ anchorView.updatePoint('a' + (76 * 400 + 150), { name: '改过的名字' })
656
+ anchorReads.push(anchorStats.reads)
657
+ anchorDuplicates.push(anchorStats.duplicates)
627
658
  }
628
659
  finally {
629
660
  Element.prototype.getBoundingClientRect = nativeAnchorRect
630
661
  }
631
- diag('放大后连续整层重排 · 标签量测读数', `逐轮 ${anchorReads.join(' / ')} 次 · 同一轮里重复量 ${anchorDuplicates.join(' / ')} 条(按标签身份去重;旧写法第 1 轮 2662 次里有 1235 次是把同一条标签又量了一遍)`)
632
- check('稳态重排(后 3 轮)一次布局都不读(避让盒全走算术)', anchorReads.slice(3, 6).every(value => value === 0), `第 4~6 轮 ${anchorReads.slice(3, 6).join(' / ')} 次`)
662
+ diag('放大后连续整层重排 · 标签量测读数', `逐轮 ${anchorReads.join(' / ')} 次 · 同一轮里重复量 ${anchorDuplicates.join(' / ')} 条(按标签身份去重;旧写法第 1 轮 2662 次里有 1235 次是把同一条标签又量了一遍)`)
663
+ check('稳态重排(后 3 轮)一次布局都不读(避让盒全走算术)', anchorReads.slice(3, 6).every(value => value === 0), `第 4~6 轮 ${anchorReads.slice(3, 6).join(' / ')} 次`)
633
664
  // 换落点那条路补画的新标签都是「第一次出现」,各量一次尺寸就进缓存;整段合计要有限,
634
665
  // 真退回「每轮把所有标签都真量一遍」的老写法时这里会立刻爆掉(旧写法一轮 1299 ~ 2662 次)
635
666
  check('稳态重排里补画的新标签只量第一次出现的那一次(合计有限、之后回零)', anchorReads.slice(1, 6).reduce((sum, value) => sum + value, 0) <= 400, `第 2~6 轮合计 ${anchorReads.slice(1, 6).reduce((sum, value) => sum + value, 0)} 次(逐轮 ${anchorReads.slice(1, 6).join(' / ')})`)
@@ -932,7 +963,7 @@
932
963
  removeView.setFilter({ points: { flag: 'show' }, worker: false })
933
964
  await sleep(60)
934
965
  const hiddenBefore = [...removeView.getFilterResult().hiddenPoints]
935
- removeView.selectFeatures('d15000')
966
+ removeView.selectFeatures('d15000')
936
967
  await sleep(30)
937
968
  const removeRebuildStarted = now()
938
969
  removeView.refresh()
@@ -967,7 +998,7 @@
967
998
  }
968
999
  removedFullView.setCamera(removeCamera)
969
1000
  removedFullView.setFilter({ points: { flag: 'show' }, worker: false })
970
- removedFullView.selectFeatures('d15000')
1001
+ removedFullView.selectFeatures('d15000')
971
1002
  await sleep(80)
972
1003
  const removedFullScene = removedFullView.snapshotScene()
973
1004
  const removedText = JSON.stringify(removedScene)
@@ -1065,7 +1096,7 @@
1065
1096
  view.setFilter({ points: { flag: 'show' }, lines: { flag: 'show' }, worker: false })
1066
1097
  }
1067
1098
  await sleep(60)
1068
- lineOnlyView.selectFeatures('q8000')
1099
+ lineOnlyView.selectFeatures('q8000')
1069
1100
  await sleep(40)
1070
1101
  let lineOnlyFallbackMs = Infinity
1071
1102
  for (const id of lineOnlyRemovedIds) {
@@ -1094,7 +1125,7 @@
1094
1125
  }
1095
1126
  lineOnlyFullView.setCamera(lineOnlyCamera)
1096
1127
  lineOnlyFullView.setFilter({ points: { flag: 'show' }, lines: { flag: 'show' }, worker: false })
1097
- lineOnlyFullView.selectFeatures('q8000')
1128
+ lineOnlyFullView.selectFeatures('q8000')
1098
1129
  await sleep(80)
1099
1130
  const lineOnlyScene = lineOnlyView.snapshotScene()
1100
1131
  const lineOnlyFullScene = lineOnlyFullView.snapshotScene()
@@ -1241,6 +1272,36 @@
1241
1272
  check('样式快路径:只动线路样式,画出来的线宽照着新配置走',
1242
1273
  widthAfter.length > 0 && widthAfter.every((width, index) => width > 0 && Math.abs(width - widthBefore[index] * 3) < 0.01),
1243
1274
  `${widthBefore[0]} -> ${widthAfter[0]}`)
1275
+ // 线路窄样式:只改全局线宽 / 颜色 / 透明度,逐条重算 paint 链并补槽位;dataVersion 与虚线 / 自身样式都不动
1276
+ const lineNarrowVersion = fastView.dataVersion
1277
+ const lineNarrowStarted = now()
1278
+ fastView.updateLineStyle({ width: 7.5, color: '#1aa0b0', opacity: 0.66 })
1279
+ const lineNarrowMs = now() - lineNarrowStarted
1280
+ const lineNarrowStyle = fastView.getLineStyle(2)
1281
+ check('线路窄样式:getLineStyle 读到新的线宽 / 颜色 / 透明度,dataVersion 不变',
1282
+ lineNarrowStyle !== null && Math.abs(lineNarrowStyle.width - 7.5) < 1e-6 && Math.abs(lineNarrowStyle.opacity - 0.66) < 1e-6 &&
1283
+ lineNarrowStyle.color[1] > 0.5 && lineNarrowStyle.color[2] > 0.5 && lineNarrowVersion === fastView.dataVersion,
1284
+ `${JSON.stringify(lineNarrowStyle)} · dataVersion ${lineNarrowVersion} -> ${fastView.dataVersion}`)
1285
+ diag('线路窄样式:2000 条只改全局线宽 / 颜色 / 透明度', `${lineNarrowMs.toFixed(2)} ms`)
1286
+ fastView.updateLine('fl0', { width: 11, color: '#ff0000' })
1287
+ fastView.updateLine('fl1230', { dash: 'dashed' })
1288
+ fastView.updateLineStyle({ width: 3, color: '#33aaff', opacity: 1 })
1289
+ const lineOwnStyle = fastView.getLineStyle(0)
1290
+ const lineDashScene = fastView.snapshotScene()
1291
+ check('线路窄样式:逐线自身线宽 / 颜色优先,全局换色不会覆盖它',
1292
+ lineOwnStyle !== null && Math.abs(lineOwnStyle.width - 11) < 1e-6 && lineOwnStyle.color[0] > 0.99 && lineOwnStyle.color[1] < 0.01,
1293
+ JSON.stringify(lineOwnStyle))
1294
+ check('线路窄样式:只换线宽 / 颜色 / 透明度时虚线图案保持不动', lineDashScene.lines.some(line => Array.isArray(line.dash) && line.dash.length > 0))
1295
+ // 有线路规则时窄路必须让开:规则改出来的宽度 / 颜色仍按规则走,规则外的那条跟着全局样式
1296
+ fastView.updateOptions({ lineRules: [{ when: { id: 'fl2' }, style: { width: 8, color: '#00aa55' } }] })
1297
+ fastView.updateLineStyle({ width: 2, color: '#888888' })
1298
+ const lineRuleStyle = fastView.getLineStyle(2)
1299
+ const lineGlobalStyle = fastView.getLineStyle(3)
1300
+ check('线路窄样式:存在线路规则时回退整份解析,规则优先于新的全局线样式',
1301
+ lineRuleStyle !== null && Math.abs(lineRuleStyle.width - 8) < 1e-6 && lineRuleStyle.color[1] > 0.6 && lineRuleStyle.color[0] < 0.01 &&
1302
+ lineGlobalStyle !== null && Math.abs(lineGlobalStyle.width - 2) < 1e-6,
1303
+ `规则 ${JSON.stringify(lineRuleStyle)} / 全局 ${JSON.stringify(lineGlobalStyle)}`)
1304
+ fastView.updateOptions({ lineRules: [] })
1244
1305
  // 热力图:值域色带换一份,重上色之后与「一开始就用这份色带」逐字段一致
1245
1306
  const heatCount = 1200
1246
1307
  const heatPoints = new Array(heatCount)
@@ -1278,63 +1339,63 @@
1278
1339
  const heatFreshView = createGisView(heatFreshHost, { points: heatPoints, lines: heatLines, heatmap: heatB, width: 640, height: 380, quality: 'high' })
1279
1340
  heatFreshView.setCamera({ center: [320, 200], scale: 1 })
1280
1341
  const heatFreshText = JSON.stringify(heatFreshView.snapshotScene())
1281
- check('样式快路径:同一份热力图配置原样再发一次,值域按当前数据重算(与整份重建一致)',
1282
- heatRefreshedText !== heatAfter && heatRefreshedText === heatFreshText,
1283
- heatRefreshedText === heatFreshText ? `快照 ${heatRefreshedText.length} 字节,逐字段一致` : diffText(heatRefreshedText, heatFreshText))
1284
- // 密度场:只改 opacity 时复用上一份采样与贴图;换色带 / 换要素值才重烤。
1285
- // 判断重烤用 heatDensityStats 的对象身份:只改画法那条快路不会换它。
1286
- const densityOption = {
1287
- enabled: true,
1288
- field: 'value',
1289
- targets: ['point'],
1290
- domain: [0, 100],
1291
- colors: ['#0b3d91', '#ffd166', '#ff5e5e'],
1292
- density: { opacity: 1, radius: 22, resolution: 192, levels: 8 }
1293
- }
1294
- const densityHost = document.createElement('div')
1295
- document.querySelector('#map').append(densityHost)
1296
- const densityView = createGisView(densityHost, {
1297
- points: heatPoints,
1298
- lines: [],
1299
- heatmap: densityOption,
1300
- width: 640,
1301
- height: 380,
1302
- quality: 'high',
1303
- showGrid: false,
1304
- background: null
1305
- })
1306
- densityView.fitView()
1307
- await densityView.whenRendered()
1308
- await densityView.whenRendered()
1309
- const densityFirst = densityView.getStats().heatDensity
1310
- check('热力图密度场:首帧烤出有效场(有采样数、贴图尺寸非零)',
1311
- Boolean(densityFirst) && densityFirst.samples > 0 && densityFirst.width > 0 && densityFirst.height > 0,
1312
- JSON.stringify(densityFirst))
1313
- const opacityOption = { ...densityOption, density: { ...densityOption.density, opacity: 0.35 } }
1314
- const opacityStarted = now()
1315
- densityView.updateOptions({ heatmap: opacityOption })
1316
- const opacityUpdateMs = now() - opacityStarted
1317
- await densityView.whenRendered()
1318
- const densityAfterOpacity = densityView.getStats().heatDensity
1319
- check('热力图密度场:只改 density.opacity 不重采、不重烤(复用同一份烘焙读数)',
1320
- densityAfterOpacity === densityFirst)
1321
- const colorOption = { ...opacityOption, colors: ['#123456', '#7ce0b0', '#ff8a3d'] }
1322
- const colorStarted = now()
1323
- densityView.updateOptions({ heatmap: colorOption })
1324
- const colorUpdateMs = now() - colorStarted
1325
- await densityView.whenRendered()
1326
- const densityAfterColor = densityView.getStats().heatDensity
1327
- check('热力图密度场:换色带会重烤(生成新一份烘焙读数)',
1328
- densityAfterColor !== densityAfterOpacity && densityAfterColor.samples === densityAfterOpacity.samples)
1329
- densityView.updatePoint('h0', { value: 7 })
1330
- await densityView.whenRendered()
1331
- const densityAfterPoint = densityView.getStats().heatDensity
1332
- check('热力图密度场:定向 updatePoint 改要素值后场跟着重烤(不再停在旧数据上)',
1333
- densityAfterPoint !== densityAfterColor && densityAfterPoint.samples === densityAfterColor.samples)
1334
- diag('热力图密度场更新', `纯透明度 ${opacityUpdateMs.toFixed(2)} ms / 换色带 ${colorUpdateMs.toFixed(2)} ms;烘焙 ${densityAfterColor.ms.toFixed(1)} ms`)
1335
- densityView.destroy()
1336
- densityHost.remove()
1337
- heatFreshView.destroy()
1342
+ check('样式快路径:同一份热力图配置原样再发一次,值域按当前数据重算(与整份重建一致)',
1343
+ heatRefreshedText !== heatAfter && heatRefreshedText === heatFreshText,
1344
+ heatRefreshedText === heatFreshText ? `快照 ${heatRefreshedText.length} 字节,逐字段一致` : diffText(heatRefreshedText, heatFreshText))
1345
+ // 密度场:只改 opacity 时复用上一份采样与贴图;换色带 / 换要素值才重烤。
1346
+ // 判断重烤用 heatDensityStats 的对象身份:只改画法那条快路不会换它。
1347
+ const densityOption = {
1348
+ enabled: true,
1349
+ field: 'value',
1350
+ targets: ['point'],
1351
+ domain: [0, 100],
1352
+ colors: ['#0b3d91', '#ffd166', '#ff5e5e'],
1353
+ density: { opacity: 1, radius: 22, resolution: 192, levels: 8 }
1354
+ }
1355
+ const densityHost = document.createElement('div')
1356
+ document.querySelector('#map').append(densityHost)
1357
+ const densityView = createGisView(densityHost, {
1358
+ points: heatPoints,
1359
+ lines: [],
1360
+ heatmap: densityOption,
1361
+ width: 640,
1362
+ height: 380,
1363
+ quality: 'high',
1364
+ showGrid: false,
1365
+ background: null
1366
+ })
1367
+ densityView.fitView()
1368
+ await densityView.whenRendered()
1369
+ await densityView.whenRendered()
1370
+ const densityFirst = densityView.getStats().heatDensity
1371
+ check('热力图密度场:首帧烤出有效场(有采样数、贴图尺寸非零)',
1372
+ Boolean(densityFirst) && densityFirst.samples > 0 && densityFirst.width > 0 && densityFirst.height > 0,
1373
+ JSON.stringify(densityFirst))
1374
+ const opacityOption = { ...densityOption, density: { ...densityOption.density, opacity: 0.35 } }
1375
+ const opacityStarted = now()
1376
+ densityView.updateOptions({ heatmap: opacityOption })
1377
+ const opacityUpdateMs = now() - opacityStarted
1378
+ await densityView.whenRendered()
1379
+ const densityAfterOpacity = densityView.getStats().heatDensity
1380
+ check('热力图密度场:只改 density.opacity 不重采、不重烤(复用同一份烘焙读数)',
1381
+ densityAfterOpacity === densityFirst)
1382
+ const colorOption = { ...opacityOption, colors: ['#123456', '#7ce0b0', '#ff8a3d'] }
1383
+ const colorStarted = now()
1384
+ densityView.updateOptions({ heatmap: colorOption })
1385
+ const colorUpdateMs = now() - colorStarted
1386
+ await densityView.whenRendered()
1387
+ const densityAfterColor = densityView.getStats().heatDensity
1388
+ check('热力图密度场:换色带会重烤(生成新一份烘焙读数)',
1389
+ densityAfterColor !== densityAfterOpacity && densityAfterColor.samples === densityAfterOpacity.samples)
1390
+ densityView.updatePoint('h0', { value: 7 })
1391
+ await densityView.whenRendered()
1392
+ const densityAfterPoint = densityView.getStats().heatDensity
1393
+ check('热力图密度场:定向 updatePoint 改要素值后场跟着重烤(不再停在旧数据上)',
1394
+ densityAfterPoint !== densityAfterColor && densityAfterPoint.samples === densityAfterColor.samples)
1395
+ diag('热力图密度场更新', `纯透明度 ${opacityUpdateMs.toFixed(2)} ms / 换色带 ${colorUpdateMs.toFixed(2)} ms;烘焙 ${densityAfterColor.ms.toFixed(1)} ms`)
1396
+ densityView.destroy()
1397
+ densityHost.remove()
1398
+ heatFreshView.destroy()
1338
1399
  heatFreshHost.remove()
1339
1400
  heatView.destroy()
1340
1401
  heatHost.remove()
@@ -1811,18 +1872,18 @@
1811
1872
  document.body.appendChild(thinHost)
1812
1873
  const thinPoints = Array.from({ length: 12 }, (unused, index) => ({ id: `tp-${index}`, x: index * 10, y: index % 3 * 6, name: `小点 ${index}` }))
1813
1874
  const thinLines = thinPoints.slice(1).map((point, index) => ({ id: `tl-${index}`, from: thinPoints[index].id, to: point.id }))
1814
- const thinView = createGisView(thinHost, {
1875
+ const thinView = createGisView(thinHost, {
1815
1876
  points: thinPoints,
1816
1877
  lines: thinLines,
1817
1878
  pointStyle: { size: 6 },
1818
1879
  lineStyle: { width: 1 },
1819
1880
  fitPadding: 20,
1820
1881
  // 交互期的那几条读数(细线快路径 / 后备缓冲)靠拖动触发:平移显式打开
1821
- features: { pan: true }
1822
- })
1823
- // 页面底部的临时画布先滚进视口:动画与交互帧只在可见时连续推进
1824
- thinHost.scrollIntoView({ block: 'center', inline: 'nearest' })
1825
- await sleep(400)
1882
+ features: { pan: true }
1883
+ })
1884
+ // 页面底部的临时画布先滚进视口:动画与交互帧只在可见时连续推进
1885
+ thinHost.scrollIntoView({ block: 'center', inline: 'nearest' })
1886
+ await sleep(400)
1826
1887
  const thinCanvas = thinHost.querySelector('canvas')
1827
1888
  const thinPointerId = 91
1828
1889
  const fireThin = (type, at) => thinCanvas.dispatchEvent(new PointerEvent(type, {
@@ -1882,11 +1943,74 @@
1882
1943
  thinView.updateOptions({ interactionNoMsaa: false })
1883
1944
  const noMsaaOff = await statsWhilePanning()
1884
1945
  check('interactionNoMsaa: false 时交互期也直接画进画布', noMsaaOff.noMsaa === false)
1885
- thinView.updateOptions({ interactionNoMsaa: true })
1886
- const noMsaaBack = await statsWhilePanning()
1887
- check('改回 true 又走后备缓冲(开关是每帧现读的)', noMsaaBack.noMsaa === msaaOn)
1888
-
1889
- // 动画在跑的那些帧也走这一档:开一组涟漪、不碰指针,等一帧读读数(判定是 interacting || animationRunning())
1946
+ thinView.updateOptions({ interactionNoMsaa: true })
1947
+ const noMsaaBack = await statsWhilePanning()
1948
+ check('改回 true 又走后备缓冲(开关是每帧现读的)', noMsaaBack.noMsaa === msaaOn)
1949
+
1950
+ /* ---------------- 程序化相机运动复用交互快路径 ---------------- */
1951
+ // zoomBy / setCamera / centerOn 不经过 beginInteraction,但同样要吃到非 MSAA 与细线那一档:
1952
+ // 运动中的帧按轻量画法走,停稳 160ms 后自动补一张全质量静态帧。
1953
+ await sleep(400)
1954
+ check('相机静止时仍直接画进 MSAA 画布', thinView.getStats().noMsaa === false && thinView.getStats().thinLines === false)
1955
+ // 运动帧只能换画法,不能降低渲染分辨率:拉长帧间隔触发旧的自适应降档条件,
1956
+ // 只要 viewport 仍是画布尺寸,贴回就是 1:1,不会出现整幅放大后的短暂模糊。
1957
+ const cameraGl = thinCanvas.getContext('webgl2')
1958
+ const viewportPrototype = Object.getPrototypeOf(cameraGl)
1959
+ const originalViewport = viewportPrototype.viewport
1960
+ const viewportSizes = []
1961
+ viewportPrototype.viewport = function (...args) {
1962
+ if (this === cameraGl) {
1963
+ viewportSizes.push([args[2], args[3]])
1964
+ }
1965
+ return originalViewport.apply(this, args)
1966
+ }
1967
+ try {
1968
+ const motionStart = thinView.getCamera()
1969
+ for (let step = 0; step < 5; step += 1) {
1970
+ thinView.setCamera({
1971
+ center: [motionStart.center[0] + step + 1, motionStart.center[1] + step],
1972
+ scale: motionStart.scale * (1 + step * 0.01)
1973
+ })
1974
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
1975
+ await sleep(35)
1976
+ }
1977
+ const mismatched = viewportSizes.filter(([width, height]) => width !== thinCanvas.width || height !== thinCanvas.height)
1978
+ check('相机运动帧始终保持画布全分辨率(不会先模糊一下)',
1979
+ viewportSizes.length > 0 && mismatched.length === 0,
1980
+ `viewport ${viewportSizes.length} 次 · 非全尺寸 ${mismatched.length} 次 · canvas=${thinCanvas.width}x${thinCanvas.height}${mismatched.length ? ` · 最后 ${mismatched.at(-1).join('x')}` : ''}`)
1981
+ }
1982
+ finally {
1983
+ viewportPrototype.viewport = originalViewport
1984
+ }
1985
+ await sleep(400)
1986
+ const statsWhileCameraMoving = async (move) => {
1987
+ move()
1988
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
1989
+ return thinView.getStats()
1990
+ }
1991
+ const cameraMoving = await statsWhileCameraMoving(() => thinView.zoomBy(1.02))
1992
+ check('程序化 zoomBy 的运动帧走细线快路径', cameraMoving.thinLines === true,
1993
+ `thinLines=${cameraMoving.thinLines}`)
1994
+ check('程序化 zoomBy 的运动帧走非 MSAA 后备缓冲(画布开了 MSAA 时)',
1995
+ cameraMoving.noMsaa === msaaOn,
1996
+ `antialias=${msaaOn} noMsaa=${cameraMoving.noMsaa}`)
1997
+ await sleep(400)
1998
+ const cameraSettled = thinView.getStats()
1999
+ check('相机停稳后回到全质量静态帧', cameraSettled.thinLines === false && cameraSettled.noMsaa === false,
2000
+ `thinLines=${cameraSettled.thinLines} noMsaa=${cameraSettled.noMsaa}`)
2001
+ const sameCamera = thinView.getCamera()
2002
+ thinView.setCamera({ center: sameCamera.center, scale: sameCamera.scale })
2003
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
2004
+ const cameraNoop = thinView.getStats()
2005
+ check('同一位置重复 setCamera 不会再进轻量档', cameraNoop.thinLines === false && cameraNoop.noMsaa === false,
2006
+ `thinLines=${cameraNoop.thinLines} noMsaa=${cameraNoop.noMsaa}`)
2007
+ thinView.updateOptions({ interactionNoMsaa: false })
2008
+ const cameraNoMsaaOff = await statsWhileCameraMoving(() => thinView.setCamera({ center: [thinView.getCamera().center[0] + 1, thinView.getCamera().center[1]] }))
2009
+ check('interactionNoMsaa: false 时程序化相机运动也直接画进画布', cameraNoMsaaOff.noMsaa === false)
2010
+ thinView.updateOptions({ interactionNoMsaa: true })
2011
+ await sleep(400)
2012
+
2013
+ // 动画在跑的那些帧也走这一档:开一组涟漪、不碰指针,等一帧读读数(判定是 interacting || animationRunning())
1890
2014
  thinView.updateOptions({ effects: { point: { radius: 14, period: 2, count: 1 } } })
1891
2015
  await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
1892
2016
  await sleep(160)
@@ -1927,12 +2051,11 @@
1927
2051
  const targetFlag = thinView.getStats().noMsaa
1928
2052
  thinView.updateOptions({ interactionNoMsaa: false })
1929
2053
  thinView.refresh({ reflow: false })
1930
- const direct = readAll()
1931
- const directFlag = thinView.getStats().noMsaa
1932
- fireThin('pointerup', thinAt)
1933
- thinView.updateOptions({ interactionNoMsaa: true })
1934
- let differing = 0
1935
- let totalDelta = 0
2054
+ const direct = readAll()
2055
+ const directFlag = thinView.getStats().noMsaa
2056
+ thinView.updateOptions({ interactionNoMsaa: true })
2057
+ let differing = 0
2058
+ let totalDelta = 0
1936
2059
  for (let index = 0; index < withTarget.length; index += 1) {
1937
2060
  const delta = Math.abs(withTarget[index] - direct[index])
1938
2061
  totalDelta += delta
@@ -1941,10 +2064,53 @@
1941
2064
  }
1942
2065
  }
1943
2066
  const thinPixels = thinCanvas.width * thinCanvas.height
1944
- check('后备缓冲那一帧与直接画的那一帧画面基本一致(差别只在抗锯齿边缘)',
1945
- targetFlag === true && directFlag === false && differing / 4 < thinPixels * 0.2 && totalDelta / withTarget.length < 4,
1946
- `${(differing / 4 / thinPixels * 100).toFixed(1)}% 像素有差 · 平均差 ${(totalDelta / withTarget.length).toFixed(2)}`);
1947
- }
2067
+ check('后备缓冲那一帧与直接画的那一帧画面基本一致(差别只在抗锯齿边缘)',
2068
+ targetFlag === true && directFlag === false && differing / 4 < thinPixels * 0.2 && totalDelta / withTarget.length < 4,
2069
+ `${(differing / 4 / thinPixels * 100).toFixed(1)}% 像素有差 · 平均差 ${(totalDelta / withTarget.length).toFixed(2)}`);
2070
+ // 细线快路径现在走专用的轻量片元管线:同一交互状态再切回四边形,颜色与位置必须对得上,
2071
+ // 差别只能来自 1 设备像素线宽 / 端点圆头,不能把整批线画丢、画黑或画成别的颜色。
2072
+ thinView.updateOptions({ interactionThinLines: false })
2073
+ thinView.refresh({ reflow: false })
2074
+ const quad = readAll()
2075
+ const quadFlag = thinView.getStats().thinLines
2076
+ thinView.updateOptions({ interactionThinLines: true })
2077
+ let thinDiffering = 0
2078
+ let thinTotalDelta = 0
2079
+ let thinInk = 0
2080
+ let quadInk = 0
2081
+ const background = [quad[0], quad[1], quad[2]]
2082
+ for (let index = 0; index < quad.length; index += 4) {
2083
+ const thinDelta = Math.max(
2084
+ Math.abs(withTarget[index] - background[0]),
2085
+ Math.abs(withTarget[index + 1] - background[1]),
2086
+ Math.abs(withTarget[index + 2] - background[2])
2087
+ )
2088
+ const quadDelta = Math.max(
2089
+ Math.abs(quad[index] - background[0]),
2090
+ Math.abs(quad[index + 1] - background[1]),
2091
+ Math.abs(quad[index + 2] - background[2])
2092
+ )
2093
+ if (thinDelta > 12) {
2094
+ thinInk += 1
2095
+ }
2096
+ if (quadDelta > 12) {
2097
+ quadInk += 1
2098
+ }
2099
+ for (let channel = 0; channel < 4; channel += 1) {
2100
+ const delta = Math.abs(quad[index + channel] - withTarget[index + channel])
2101
+ thinTotalDelta += delta
2102
+ if (delta > 0) {
2103
+ thinDiffering += 1
2104
+ }
2105
+ }
2106
+ }
2107
+ check('细线快路径与四边形画法位置 / 颜色一致(差别只在 1px 线宽边缘)',
2108
+ targetFlag === true && quadFlag === false && thinInk > 100 && quadInk > 100 &&
2109
+ thinInk / quadInk > 0.5 && thinInk / quadInk < 2 &&
2110
+ thinDiffering / 4 < thinPixels * 0.2 && thinTotalDelta / quad.length < 4,
2111
+ `${(thinDiffering / 4 / thinPixels * 100).toFixed(1)}% 像素有差 · 平均差 ${(thinTotalDelta / quad.length).toFixed(2)} · 线像素 ${thinInk}/${quadInk}`)
2112
+ fireThin('pointerup', thinAt)
2113
+ }
1948
2114
  /* ---------------- 拖通道拐点:整条通道每帧重算的账(3000 回线不再是每次指针移动 140 ms) ---------------- */
1949
2115
  // 老写法:拐点批次每帧整份重建 —— 通道里任一回线被选中就按整条通道的成员解析一遍把手,
1950
2116
  // 3000 ~ 5000 回线的通道每次 pointermove 要 140 ms 上下(拖动时每帧一次,画面直接卡住)。
@@ -1964,17 +2130,17 @@
1964
2130
  channelLines.push({ id: `cm-${index}`, from: 'p1', to: 'p2' })
1965
2131
  channelLineIds.push(`cm-${index}`)
1966
2132
  }
1967
- const channelView = createGisView(channelHost, {
2133
+ const channelView = createGisView(channelHost, {
1968
2134
  points: channelPoints,
1969
2135
  lines: channelLines,
1970
2136
  channels: [{ id: 'ch-cost', from: 'p1', to: 'p2', lineIds: channelLineIds, path: [[100, 30]] }],
1971
2137
  fitPadding: 20,
1972
- waypointEditable: true
1973
- })
1974
- channelView.selectFeatures('cm-0')
1975
- // 拐点拖动靠真实指针命中:先把这张临时画布滚进视口,别让命中坐标落在视口外
1976
- channelHost.scrollIntoView({ block: 'center', inline: 'nearest' })
1977
- await sleep(200)
2138
+ waypointEditable: true
2139
+ })
2140
+ channelView.selectFeatures('cm-0')
2141
+ // 拐点拖动靠真实指针命中:先把这张临时画布滚进视口,别让命中坐标落在视口外
2142
+ channelHost.scrollIntoView({ block: 'center', inline: 'nearest' })
2143
+ await sleep(200)
1978
2144
  const channelCanvas = channelHost.querySelector('canvas')
1979
2145
  const channelWaypoint = channelView.getWaypoints(0).find(item => item.pathIndex === 1)
1980
2146
  const channelSkeletonBefore = channelView.getChannelGroups('ch-cost')[0].skeleton.map(coordinate => coordinate[0])
@@ -2049,136 +2215,187 @@
2049
2215
  const goCostBest = Math.min(...goCostMerge)
2050
2216
  diag('分组编辑(2 万回线、只写 groups 的通道,合并一次)', `最快 ${goCostBest.toFixed(1)} ms`)
2051
2217
  check('2 万回线的通道合并分组:40 ms 以内(成员池一次建表 + 集合去重,老的逐条 includes 是平方级)', goCostBest < 40, `最快 ${goCostBest.toFixed(1)} ms`)
2052
- check('只写 groups 的通道合并之后成员一条不少(2 万条只走声明,不靠顶层 lineIds)', goCostMergedLines === goCostCount, `${goCostMergedLines} / ${goCostCount}`)
2053
-
2054
- /* ---------------- 点聚合:展示成员、拾取、点击语义与 maxZoom ---------------- */
2055
- const clusterPointCount = 12000
2056
- const clusterRandom = (() => {
2057
- let seed = 20261021
2058
- return () => {
2059
- seed = (seed * 1664525 + 1013904223) % 4294967296
2060
- return seed / 4294967296
2061
- }
2062
- })()
2063
- const clusterCenters = [[0, 0], [120, 0], [60, 90]]
2064
- // 留出几个远离簇中心的点,覆盖 getClusters() 的单成员分支;它们仍计入总点数。
2065
- const clusterIsolated = [
2066
- [-360, -170], [380, -170], [-360, 200], [380, 200],
2067
- [-440, 20], [460, 20], [20, -220], [20, 240]
2068
- ]
2069
- const clusterPoints = new Array(clusterPointCount)
2070
- for (let index = 0; index < clusterPointCount; index += 1) {
2071
- const isolatedIndex = index - (clusterPointCount - clusterIsolated.length)
2072
- if (isolatedIndex >= 0) {
2073
- clusterPoints[index] = {
2074
- id: `cp${index}`,
2075
- x: clusterIsolated[isolatedIndex][0],
2076
- y: clusterIsolated[isolatedIndex][1],
2077
- name: `独立点 ${index}`
2078
- }
2079
- continue
2080
- }
2081
- const center = clusterCenters[index % clusterCenters.length]
2082
- clusterPoints[index] = {
2083
- id: `cp${index}`,
2084
- x: center[0] + (clusterRandom() - 0.5) * 24,
2085
- y: center[1] + (clusterRandom() - 0.5) * 24,
2086
- name: `聚合点 ${index}`
2087
- }
2088
- }
2089
- const clusterHost = document.createElement('div')
2090
- clusterHost.style.cssText = 'width:640px;height:380px'
2091
- document.body.appendChild(clusterHost)
2092
- let clusterEvents = 0
2093
- let plainEvents = 0
2094
- let lastClusterPayload = null
2095
- const clusterView = createGisView(clusterHost, {
2096
- points: clusterPoints,
2097
- width: 640,
2098
- height: 380,
2099
- pointStyle: { size: 4 },
2100
- features: 'view',
2101
- fitPadding: 24,
2102
- pointCluster: { enabled: true, radius: 60, minPoints: 2 }
2103
- })
2104
- clusterView.on('clusterClick', payload => {
2105
- clusterEvents += 1
2106
- lastClusterPayload = payload
2107
- })
2108
- clusterView.on('click', () => {
2109
- plainEvents += 1
2110
- })
2111
- await clusterView.whenRendered()
2112
- const clusterStats = clusterView.getStats()
2113
- check('点聚合:一万二千点建出多成员圆,统计口径自洽',
2114
- clusterStats.clusterCount > 0 && clusterStats.clusterPointCount >= clusterStats.clusterCount && clusterStats.clusterMs >= 0,
2115
- JSON.stringify({ clusterCount: clusterStats.clusterCount, clusterPointCount: clusterStats.clusterPointCount, clusterMs: clusterStats.clusterMs }))
2116
- check('点聚合:getPointCluster() 给出归一化配置与 active',
2117
- clusterView.getPointCluster()?.active === true && clusterView.getPointCluster()?.radius === 60,
2118
- JSON.stringify(clusterView.getPointCluster()))
2119
- const clusterGroups = clusterView.getClusters()
2120
- const multiClusters = clusterGroups.filter(item => item.count > 1)
2121
- const singletonGroups = clusterGroups.filter(item => item.count === 1)
2122
- check('点聚合:getClusters() 含单成员,单成员 count=1、多成员 id 以 cluster: 开头',
2123
- singletonGroups.length > 0 && multiClusters.length > 0 && singletonGroups.every(item => item.count === 1 && item.pointIndexes.length === 1) && multiClusters.every(item => item.id.startsWith('cluster:')),
2124
- `${clusterGroups.length} 组(${singletonGroups.length} 单成员 / ${multiClusters.length} 多成员)`)
2125
- check('点聚合:原始数据与 getData() 一条不少',
2126
- clusterView.getData().points.length === clusterPointCount && clusterView.listPoints({ visibleOnly: false }).length === clusterPointCount,
2127
- `getData ${clusterView.getData().points.length} / listPoints ${clusterView.listPoints({ visibleOnly: false }).length}`)
2128
- const visibleSingletons = clusterView.listPoints({ visibleOnly: true })
2129
- let visitedSingletons = 0
2130
- clusterView.forEachPointInView(clusterView.getViewRect(), () => {
2131
- visitedSingletons += 1
2132
- })
2133
- check('点聚合:聚合内部成员不出现在 visibleOnly / forEachPointInView',
2134
- visibleSingletons.length === clusterPointCount - clusterStats.clusterPointCount && visitedSingletons === visibleSingletons.length,
2135
- `可见 ${visibleSingletons.length} / 遍历 ${visitedSingletons} / 隐藏 ${clusterStats.clusterPointCount}`)
2136
- const clusterPick = clusterView.pick(multiClusters[0].screen[0], multiClusters[0].screen[1])
2137
- check('点聚合:聚合圆拾取返回 type=cluster,带 count / pointIds / bounds',
2138
- clusterPick?.type === 'cluster' && clusterPick.count >= 2 && clusterPick.pointIds.length === clusterPick.count && Boolean(clusterPick.bounds),
2139
- JSON.stringify({ type: clusterPick?.type, count: clusterPick?.count, ids: clusterPick?.pointIds?.length }))
2140
- const selectionProbe = visibleSingletons[0]
2141
- clusterView.selectFeatures([selectionProbe.item.id])
2142
- await clusterView.whenRendered()
2143
- const selectedBefore = clusterView.getSelections().map(entry => entry.item?.id ?? entry.index).join(',')
2144
- const clusterCanvas = clusterHost.querySelector('canvas')
2145
- // 合成指针事件使用真实客户端坐标:先把画布滚进视口,避免 clientY 落在窗口外
2146
- clusterCanvas.scrollIntoView({ block: 'center' })
2147
- await sleep(50)
2148
- const clickPick = clusterView.pick(multiClusters[0].screen[0], multiClusters[0].screen[1])
2149
- const clickScreen = clickPick?.screen ?? multiClusters[0].screen
2150
- const clickClient = clusterView.screenToClient(clickScreen[0], clickScreen[1])
2151
- const fireCluster = type => clusterCanvas.dispatchEvent(new PointerEvent(type, {
2152
- bubbles: true, cancelable: true, pointerId: 93, pointerType: 'mouse', button: 0,
2153
- buttons: type === 'pointerup' ? 0 : 1, clientX: clickClient[0], clientY: clickClient[1]
2154
- }))
2155
- fireCluster('pointerdown')
2156
- fireCluster('pointerup')
2157
- await sleep(30)
2158
- const selectedAfter = clusterView.getSelections().map(entry => entry.item?.id ?? entry.index).join(',')
2218
+ check('只写 groups 的通道合并之后成员一条不少(2 万条只走声明,不靠顶层 lineIds)', goCostMergedLines === goCostCount, `${goCostMergedLines} / ${goCostCount}`)
2219
+
2220
+ /* ---------------- 点聚合:展示成员、拾取、点击语义与 maxZoom ---------------- */
2221
+ const clusterPointCount = 12000
2222
+ const clusterRandom = (() => {
2223
+ let seed = 20261021
2224
+ return () => {
2225
+ seed = (seed * 1664525 + 1013904223) % 4294967296
2226
+ return seed / 4294967296
2227
+ }
2228
+ })()
2229
+ const clusterCenters = [[0, 0], [120, 0], [60, 90]]
2230
+ // 留出几个远离簇中心的点,覆盖 getClusters() 的单成员分支;它们仍计入总点数。
2231
+ const clusterIsolated = [
2232
+ [-360, -170], [380, -170], [-360, 200], [380, 200],
2233
+ [-440, 20], [460, 20], [20, -220], [20, 240]
2234
+ ]
2235
+ const clusterPoints = new Array(clusterPointCount)
2236
+ for (let index = 0; index < clusterPointCount; index += 1) {
2237
+ const isolatedIndex = index - (clusterPointCount - clusterIsolated.length)
2238
+ if (isolatedIndex >= 0) {
2239
+ clusterPoints[index] = {
2240
+ id: `cp${index}`,
2241
+ x: clusterIsolated[isolatedIndex][0],
2242
+ y: clusterIsolated[isolatedIndex][1],
2243
+ name: `独立点 ${index}`
2244
+ }
2245
+ continue
2246
+ }
2247
+ const center = clusterCenters[index % clusterCenters.length]
2248
+ clusterPoints[index] = {
2249
+ id: `cp${index}`,
2250
+ x: center[0] + (clusterRandom() - 0.5) * 24,
2251
+ y: center[1] + (clusterRandom() - 0.5) * 24,
2252
+ name: `聚合点 ${index}`
2253
+ }
2254
+ }
2255
+ const clusterHost = document.createElement('div')
2256
+ clusterHost.style.cssText = 'width:640px;height:380px'
2257
+ document.body.appendChild(clusterHost)
2258
+ let clusterEvents = 0
2259
+ let plainEvents = 0
2260
+ let lastClusterPayload = null
2261
+ const clusterView = createGisView(clusterHost, {
2262
+ points: clusterPoints,
2263
+ width: 640,
2264
+ height: 380,
2265
+ pointStyle: { size: 4 },
2266
+ features: 'view',
2267
+ fitPadding: 24,
2268
+ pointCluster: { enabled: true, radius: 60, minPoints: 2 }
2269
+ })
2270
+ clusterView.on('clusterClick', payload => {
2271
+ clusterEvents += 1
2272
+ lastClusterPayload = payload
2273
+ })
2274
+ clusterView.on('click', () => {
2275
+ plainEvents += 1
2276
+ })
2277
+ await clusterView.whenRendered()
2278
+ const clusterStats = clusterView.getStats()
2279
+ check('点聚合:一万二千点建出多成员圆,统计口径自洽',
2280
+ clusterStats.clusterCount > 0 && clusterStats.clusterPointCount >= clusterStats.clusterCount && clusterStats.clusterMs >= 0,
2281
+ JSON.stringify({ clusterCount: clusterStats.clusterCount, clusterPointCount: clusterStats.clusterPointCount, clusterMs: clusterStats.clusterMs }))
2282
+ check('点聚合:getPointCluster() 给出归一化配置与 active',
2283
+ clusterView.getPointCluster()?.active === true && clusterView.getPointCluster()?.radius === 60,
2284
+ JSON.stringify(clusterView.getPointCluster()))
2285
+ const clusterGroups = clusterView.getClusters()
2286
+ const multiClusters = clusterGroups.filter(item => item.count > 1)
2287
+ const singletonGroups = clusterGroups.filter(item => item.count === 1)
2288
+ check('点聚合:getClusters() 含单成员,单成员 count=1、多成员 id 以 cluster: 开头',
2289
+ singletonGroups.length > 0 && multiClusters.length > 0 && singletonGroups.every(item => item.count === 1 && item.pointIndexes.length === 1) && multiClusters.every(item => item.id.startsWith('cluster:')),
2290
+ `${clusterGroups.length} 组(${singletonGroups.length} 单成员 / ${multiClusters.length} 多成员)`)
2291
+ check('点聚合:原始数据与 getData() 一条不少',
2292
+ clusterView.getData().points.length === clusterPointCount && clusterView.listPoints({ visibleOnly: false }).length === clusterPointCount,
2293
+ `getData ${clusterView.getData().points.length} / listPoints ${clusterView.listPoints({ visibleOnly: false }).length}`)
2294
+ const visibleSingletons = clusterView.listPoints({ visibleOnly: true })
2295
+ let visitedSingletons = 0
2296
+ clusterView.forEachPointInView(clusterView.getViewRect(), () => {
2297
+ visitedSingletons += 1
2298
+ })
2299
+ check('点聚合:聚合内部成员不出现在 visibleOnly / forEachPointInView',
2300
+ visibleSingletons.length === clusterPointCount - clusterStats.clusterPointCount && visitedSingletons === visibleSingletons.length,
2301
+ `可见 ${visibleSingletons.length} / 遍历 ${visitedSingletons} / 隐藏 ${clusterStats.clusterPointCount}`)
2302
+ const clusterPick = clusterView.pick(multiClusters[0].screen[0], multiClusters[0].screen[1])
2303
+ check('点聚合:聚合圆拾取返回 type=cluster,带 count / pointIds / bounds',
2304
+ clusterPick?.type === 'cluster' && clusterPick.count >= 2 && clusterPick.pointIds.length === clusterPick.count && Boolean(clusterPick.bounds),
2305
+ JSON.stringify({ type: clusterPick?.type, count: clusterPick?.count, ids: clusterPick?.pointIds?.length }))
2306
+ const selectionProbe = visibleSingletons[0]
2307
+ clusterView.selectFeatures([selectionProbe.item.id])
2308
+ await clusterView.whenRendered()
2309
+ const selectedBefore = clusterView.getSelections().map(entry => entry.item?.id ?? entry.index).join(',')
2310
+ const clusterCanvas = clusterHost.querySelector('canvas')
2311
+ // 合成指针事件使用真实客户端坐标:先把画布滚进视口,避免 clientY 落在窗口外
2312
+ clusterCanvas.scrollIntoView({ block: 'center' })
2313
+ await sleep(50)
2314
+ const clickPick = clusterView.pick(multiClusters[0].screen[0], multiClusters[0].screen[1])
2315
+ const clickScreen = clickPick?.screen ?? multiClusters[0].screen
2316
+ const clickClient = clusterView.screenToClient(clickScreen[0], clickScreen[1])
2317
+ const fireCluster = type => clusterCanvas.dispatchEvent(new PointerEvent(type, {
2318
+ bubbles: true, cancelable: true, pointerId: 93, pointerType: 'mouse', button: 0,
2319
+ buttons: type === 'pointerup' ? 0 : 1, clientX: clickClient[0], clientY: clickClient[1]
2320
+ }))
2321
+ fireCluster('pointerdown')
2322
+ fireCluster('pointerup')
2323
+ await sleep(30)
2324
+ const selectedAfter = clusterView.getSelections().map(entry => entry.item?.id ?? entry.index).join(',')
2159
2325
  check('点聚合:点击聚合只发 clusterClick,不发普通 click,选中集不变',
2160
2326
  clusterEvents === 1 && plainEvents === 0 && selectedAfter === selectedBefore && lastClusterPayload?.action?.type === 'clusterClick' && lastClusterPayload?.action?.data?.type === 'cluster',
2161
2327
  `clusterClick=${clusterEvents} click=${plainEvents} 选中 ${selectedBefore} → ${selectedAfter} action=${lastClusterPayload?.action?.type} clickPick=${clickPick?.type ?? 'null'} clickScreen=${clickScreen.map(value => value.toFixed(1)).join(',')} client=${clickClient.map(value => value.toFixed(1)).join(',')}`)
2162
2328
  const fitScale = clusterView.getCamera().scale
2163
- clusterView.updateOptions({ pointCluster: { enabled: true, radius: 60, minPoints: 2, maxZoom: 2 } })
2164
- clusterView.setCamera({ scale: fitScale * 3 })
2165
- await clusterView.whenRendered()
2166
- const zoomedStats = clusterView.getStats()
2167
- check('点聚合:缩放超过 maxZoom 后展开原始点(active=false、聚合圆清空)',
2168
- clusterView.getPointCluster()?.active === false && zoomedStats.clusterCount === 0 && clusterView.listPoints({ visibleOnly: true }).length === clusterPointCount,
2169
- JSON.stringify({ active: clusterView.getPointCluster()?.active, clusterCount: zoomedStats.clusterCount, visible: clusterView.listPoints({ visibleOnly: true }).length }))
2329
+ // 聚合成员在相机运动期会推迟重算;这一档没有线、又显式关掉非 MSAA 后备缓冲,
2330
+ // 当前帧不会画出任何轻量几何 —— 停稳计时器仍必须补一帧,不能把聚合结果留在旧缩放档。
2331
+ const clustersBeforeCamera = clusterView.getStats().clusterCount
2332
+ clusterView.updateOptions({ interactionNoMsaa: false })
2333
+ clusterView.setCamera({ scale: fitScale * 0.55 })
2334
+ await sleep(420)
2335
+ const clustersAfterCamera = clusterView.getStats().clusterCount
2336
+ check('点聚合:程序化相机停稳后一定补算成员(没有轻量帧也不例外)',
2337
+ clustersAfterCamera < clustersBeforeCamera,
2338
+ `${clustersBeforeCamera} → ${clustersAfterCamera}`)
2170
2339
  clusterView.setCamera({ scale: fitScale })
2171
- await clusterView.whenRendered()
2172
- clusterView.updateOptions({ pointCluster: false })
2173
- await clusterView.whenRendered()
2174
- const disabledStats = clusterView.getStats()
2175
- check('点聚合:关掉之后原始点全部恢复,getPointCluster().enabled=false',
2176
- clusterView.getPointCluster()?.enabled === false && clusterView.getPointCluster()?.active === false && disabledStats.clusterCount === 0 && disabledStats.clusterPointCount === 0,
2177
- JSON.stringify({ cluster: clusterView.getPointCluster(), stats: { count: disabledStats.clusterCount, points: disabledStats.clusterPointCount } }))
2178
- clusterView.destroy()
2179
- clusterHost.remove()
2180
-
2181
- thinView.destroy()
2340
+ await sleep(420)
2341
+ clusterView.updateOptions({ interactionNoMsaa: true })
2342
+ clusterView.updateOptions({ pointCluster: { enabled: true, radius: 60, minPoints: 2, maxZoom: 2 } })
2343
+ clusterView.setCamera({ scale: fitScale * 3 })
2344
+ await clusterView.whenRendered()
2345
+ const zoomedStats = clusterView.getStats()
2346
+ check('点聚合:缩放超过 maxZoom 后展开原始点(active=false、聚合圆清空)',
2347
+ clusterView.getPointCluster()?.active === false && zoomedStats.clusterCount === 0 && clusterView.listPoints({ visibleOnly: true }).length === clusterPointCount,
2348
+ JSON.stringify({ active: clusterView.getPointCluster()?.active, clusterCount: zoomedStats.clusterCount, visible: clusterView.listPoints({ visibleOnly: true }).length }))
2349
+ clusterView.setCamera({ scale: fitScale })
2350
+ await clusterView.whenRendered()
2351
+ clusterView.updateOptions({ pointCluster: false })
2352
+ await clusterView.whenRendered()
2353
+ const disabledStats = clusterView.getStats()
2354
+ check('点聚合:关掉之后原始点全部恢复,getPointCluster().enabled=false',
2355
+ clusterView.getPointCluster()?.enabled === false && clusterView.getPointCluster()?.active === false && disabledStats.clusterCount === 0 && disabledStats.clusterPointCount === 0,
2356
+ JSON.stringify({ cluster: clusterView.getPointCluster(), stats: { count: disabledStats.clusterCount, points: disabledStats.clusterPointCount } }))
2357
+ clusterView.destroy()
2358
+ clusterHost.remove()
2359
+
2360
+ /* ---------------- 同一批点线数据原样回灌:两半都没换时别白解析 ---------------- */
2361
+ // 组件把同一批对象原样再交一次是常规写法(刷新配置 / 只换别的选项时顺带带上 data)。
2362
+ // 这里量 5 次取中位;每次回灌之后数据、线路实例数与相机都必须原样。
2363
+ big.fitView(0)
2364
+ big.refresh()
2365
+ big.refresh()
2366
+ const refeedPoints = big.getData().points
2367
+ const refeedLines = big.getData().lines
2368
+ const refeedInstances = big.getStats().lineInstances
2369
+ const refeedStarted = now()
2370
+ const refeedSamples = []
2371
+ for (let round = 0; round < 5; round += 1) {
2372
+ const started = now()
2373
+ big.updateOptions({ points: refeedPoints, lines: refeedLines })
2374
+ refeedSamples.push(now() - started)
2375
+ }
2376
+ refeedSamples.sort((left, right) => left - right)
2377
+ const refeedMs = refeedSamples[Math.floor(refeedSamples.length / 2)]
2378
+ const refeedData = big.getData()
2379
+ check('同一批点线原样回灌:条数 / 线路实例数都不变(解析结果可以整半沿用)',
2380
+ refeedData.points.length === pointCount && refeedData.lines.length === lineCount && big.getStats().lineInstances === refeedInstances,
2381
+ JSON.stringify({ points: refeedData.points.length, lines: refeedData.lines.length, instances: big.getStats().lineInstances, expected: refeedInstances }))
2382
+ diag(`同一批点线原样回灌(${pointCount} 点 / ${lineCount} 线)`, `中位 ${refeedMs.toFixed(1)} ms(5 次:${refeedSamples.map(value => value.toFixed(1)).join(' / ')};总观测 ${(now() - refeedStarted).toFixed(1)} ms)`)
2383
+ // 同一批数据回灌不能让通道几何停在上一轮:声明没换时沿用,换了骨架必须照常重推
2384
+ big.updateOptions({ channels: [{ id: 'reuse-guard', from: 'p0', to: 'p1', lineIds: ['l0', 'l1'], path: [[20, 20]] }] })
2385
+ const guardBefore = big.getChannelGroupWaypoints('reuse-guard')
2386
+ big.updateOptions({ points: refeedPoints, lines: refeedLines })
2387
+ const guardAfter = big.getChannelGroupWaypoints('reuse-guard')
2388
+ check('同一批点线回灌:通道声明没换时,回线派生折线原样保留',
2389
+ JSON.stringify(guardBefore?.lines?.map(line => line.path)) === JSON.stringify(guardAfter?.lines?.map(line => line.path)),
2390
+ JSON.stringify({ before: guardBefore?.lines?.map(line => line.waypoints), after: guardAfter?.lines?.map(line => line.waypoints) }))
2391
+ big.updateOptions({ channels: [{ id: 'reuse-guard', from: 'p0', to: 'p1', lineIds: ['l0', 'l1'], path: [[80, 80]] }] })
2392
+ const guardChanged = big.getChannelGroupWaypoints('reuse-guard')
2393
+ check('同一批点线回灌:换通道骨架时线路那一半不能沿用旧几何',
2394
+ JSON.stringify(guardChanged?.lines?.map(line => line.waypoints)) !== JSON.stringify(guardAfter?.lines?.map(line => line.waypoints)),
2395
+ JSON.stringify({ before: guardAfter?.lines?.map(line => line.waypoints), after: guardChanged?.lines?.map(line => line.waypoints) }))
2396
+ big.updateOptions({ channels: [] })
2397
+
2398
+ thinView.destroy()
2182
2399
  thinHost.remove()
2183
2400
  big.destroy()
2184
2401
  bigHost.remove()