gis-view-webgl2 0.1.13 → 0.1.15
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.
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +140 -60
- package/dist/index.js +1 -1
- package/docs/AI/345/257/271/350/257/235/346/261/207/346/200/273.md +73 -5
- package/docs/GisView.md +49 -49
- 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 +87 -16
- package/docs/demos/ApiCoverageDemo.vue +1 -5
- package/docs/demos/BackendSyncDemo.vue +1 -2
- package/docs/demos/BackgroundDemo.vue +68 -1
- package/docs/demos/BasicDemo.vue +76 -76
- package/docs/demos/BatchAddDemo.vue +2 -1
- package/docs/demos/ChannelDemo.vue +1 -3
- package/docs/demos/ClusterDemo.vue +48 -1
- package/docs/demos/CopyMergeDemo.vue +899 -904
- package/docs/demos/DemoShell.vue +336 -336
- package/docs/demos/EventDemo.vue +300 -300
- package/docs/demos/ExtensionDemo.vue +200 -200
- package/docs/demos/GisViewDemo.vue +64 -64
- package/docs/demos/LabelDemo.vue +489 -489
- package/docs/demos/StyleDemo.vue +67 -67
- package/docs/demos/api-index.js +24 -29
- package/docs/demos/demos.js +92 -92
- package/docs/demos/gis-view-demo/useGisViewDemo.js +19 -14
- package/docs/demos/shared/demoData.js +107 -107
- package/docs/demos/shared/demoStyle.css +249 -249
- package/docs/format/02-/350/212/202/347/202/271/344/270/216/347/272/277/350/267/257.md +3 -3
- 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 +3 -0
- package/docs/from0/01-/345/205/210/346/212/212/345/234/260/345/233/276/346/203/263/346/270/205/346/245/232.md +8 -7
- package/docs/index.html +3 -3
- package/docs/internals/04-/347/233/270/346/234/272/345/235/220/346/240/207/344/270/216/345/203/217/347/264/240.md +6 -4
- package/docs/internals/14-/350/203/214/346/231/257/345/261/202.md +2 -2
- package/docs/manual/02-/345/235/220/346/240/207/344/270/216/346/225/260/346/215/256.md +36 -16
- package/docs/manual/03-/351/205/215/347/275/256/351/241/271.md +38 -84
- package/docs/manual/04-/344/272/213/344/273/266/344/270/216/345/233/236/350/260/203.md +36 -19
- 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 +1 -1
- 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 +7 -7
- package/docs/manual/08-/351/200/211/344/270/255/344/270/216/346/213/220/347/202/271.md +15 -15
- 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 +1 -1
- package/docs/manual/12-/346/240/207/347/255/276/344/270/216/350/243/205/351/245/260.md +6 -0
- package/docs/manual/14-/350/247/206/345/233/276/344/270/216/347/255/233/351/200/211.md +1 -1
- package/docs/manual/15-/345/257/274/345/207/272/344/270/216/351/233/206/346/210/220.md +2 -0
- package/docs/manual/16-/345/256/236/344/276/213/346/226/271/346/263/225/346/200/273/350/241/250.md +10 -8
- package/docs/manual/17-/345/217/202/346/225/260/351/200/237/346/237/245.md +11 -7
- package/docs/manual/18-/351/200/220/346/226/271/346/263/225/347/244/272/344/276/213.md +27 -8
- package/docs/manual/20-/346/200/247/350/203/275/344/270/216/345/244/247/346/225/260/346/215/256.md +27 -4
- 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 +3 -3
- package/docs/playground/index.html +1 -1
- package/docs/sample-document/345/255/227/346/256/265/350/257/246/350/247/243.md +2 -2
- package/docs/steps/check-doc-truth.mjs +1 -1
- package/docs/steps/selftest-actions.html +4 -4
- package/docs/steps/selftest-data.html +5 -5
- package/docs/steps/selftest-demos.html +1683 -1619
- package/docs/steps/selftest-docs.html +4 -4
- package/docs/steps/selftest-effects.html +1 -1
- package/docs/steps/selftest-features.html +548 -47
- package/docs/steps/selftest-full.html +566 -566
- package/docs/steps/selftest-labels.html +647 -647
- package/docs/steps/selftest-perf.html +2282 -2237
- package/docs/steps/selftest-select.html +53 -14
- package/docs/steps/step-10-full.js +1 -4
- package/docs/steps/step-12-events.js +1 -4
- package/package.json +43 -43
|
@@ -1,275 +1,275 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="zh-CN">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8" />
|
|
5
|
-
<title>perf running</title>
|
|
6
|
-
</head>
|
|
7
|
-
<body>
|
|
8
|
-
<div id="map" style="width:640px;height:380px"></div>
|
|
9
|
-
<pre id="out"></pre>
|
|
10
|
-
<script type="module">
|
|
11
|
-
// 画质档位(含自动升降)/ 批处理 / 配置提醒的自测:断言全在接口层,不读像素。
|
|
12
|
-
// 大数据量那一组既量一次真实的建场景、拾取与批量改数据耗时(DIAG),也拿它跑一遍自动档的阶梯。
|
|
13
|
-
const results = []
|
|
14
|
-
// 每条结果都立刻落到 #out 上:真卡住时,最后一行就是卡住的位置
|
|
15
|
-
const flush = () => {
|
|
16
|
-
document.querySelector('#out').textContent = results.join('\n')
|
|
17
|
-
}
|
|
18
|
-
const check = (name, ok, extra = '') => {
|
|
19
|
-
results.push(`${ok ? 'PASS' : 'FAIL'} ${name}${extra === '' ? '' : ` — ${extra}`}`)
|
|
20
|
-
flush()
|
|
21
|
-
}
|
|
22
|
-
const diag = (name, extra) => {
|
|
23
|
-
results.push(`DIAG ${name} — ${extra}`)
|
|
24
|
-
flush()
|
|
25
|
-
}
|
|
26
|
-
const now = () => performance.now()
|
|
27
|
-
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
28
|
-
// 拾取用的节点:线路只挂在前面 8000 个节点上,取后面的才不会被线抢走命中
|
|
29
|
-
const clearPoint = 15000
|
|
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
|
-
/* ---------------- 档位表与归一(纯函数) ---------------- */
|
|
103
|
-
check('QUALITY_MODES 是五档:四个固定档 + auto', QUALITY_MODES.join(',') === 'high,balanced,lean,fast,auto', QUALITY_MODES.join(','))
|
|
104
|
-
check('QUALITY_LEVELS 从高到低,越往下省得越多', QUALITY_LEVELS.length === 4 &&
|
|
105
|
-
QUALITY_LEVELS[0].glow && QUALITY_LEVELS[0].ornament && QUALITY_LEVELS[0].scale === 1 &&
|
|
106
|
-
!QUALITY_LEVELS[1].glow && QUALITY_LEVELS[1].ornament &&
|
|
107
|
-
!QUALITY_LEVELS[2].glow && !QUALITY_LEVELS[2].ornament &&
|
|
108
|
-
QUALITY_LEVELS[3].scale < 1, JSON.stringify(QUALITY_LEVELS))
|
|
109
|
-
check('quality: true 就是 auto', normalizeQualityOptions(true).mode === 'auto')
|
|
110
|
-
check('写错的档位名退回 high', normalizeQualityOptions('nope').mode === 'high' && normalizeQualityOptions({ mode: 'nope' }).mode === 'high')
|
|
111
|
-
const clamped = normalizeQualityOptions({ mode: 'auto', budgetMs: -5, minScale: 3 })
|
|
112
|
-
check('budgetMs / minScale 越界会夹回可用区间', clamped.budgetMs === 12 && clamped.minScale === 1, JSON.stringify(clamped))
|
|
113
|
-
|
|
114
|
-
/* ---------------- 调节器:掉档 / 升档 / 固定档 ---------------- */
|
|
115
|
-
const switched = []
|
|
116
|
-
const governor = createQualityGovernor({ mode: 'auto', budgetMs: 8 }, state => switched.push(state.name))
|
|
117
|
-
const feed = (ms, count) => {
|
|
118
|
-
for (let index = 0; index < count; index += 1) {
|
|
119
|
-
governor.sample(ms)
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
feed(30, 60)
|
|
123
|
-
check('自动档:连续超预算就掉一档(并回调新档位)', governor.state().name === 'balanced' && switched.includes('balanced'), `${governor.state().name} / ${switched.join(',')}`)
|
|
124
|
-
await sleep(900)
|
|
125
|
-
feed(30, 35)
|
|
126
|
-
check('自动档:还在超预算就继续往下掉', governor.state().name === 'lean', governor.state().name)
|
|
127
|
-
await sleep(900)
|
|
128
|
-
feed(30, 35)
|
|
129
|
-
check('自动档:掉到最低档改用更小的分辨率渲染', governor.state().name === 'fast' && governor.state().scale === 0.75, `${governor.state().name} ${governor.state().scale}`)
|
|
130
|
-
await sleep(900)
|
|
131
|
-
feed(1, 70)
|
|
132
|
-
check('自动档:预算富余一阵子才升回一档(升档比降档保守)', governor.state().name === 'lean', governor.state().name)
|
|
133
|
-
// 整帧间隔也是成本信号:渲染耗时(frameMs)量不到填充率与合成 —— 软件光栅 / 低端显卡上
|
|
134
|
-
// 十万级数据 frameMs 一直 0.1ms、实际十几帧,自动档以前永远不会掉档
|
|
135
|
-
const gapGovernor = createQualityGovernor({ mode: 'auto', budgetMs: 12 })
|
|
136
|
-
for (let index = 0; index < 40; index += 1) {
|
|
137
|
-
gapGovernor.sample(0.2, 200)
|
|
138
|
-
}
|
|
139
|
-
check('自动档:渲染耗时没超但整帧间隔掉到十几帧,照样往下掉(并记进读数)',
|
|
140
|
-
gapGovernor.state().name !== 'high' && gapGovernor.state().gapMs === 200,
|
|
141
|
-
`${gapGovernor.state().name} gap=${gapGovernor.state().gapMs}`)
|
|
142
|
-
const smoothGovernor = createQualityGovernor({ mode: 'auto', budgetMs: 12 })
|
|
143
|
-
for (let index = 0; index < 80; index += 1) {
|
|
144
|
-
smoothGovernor.sample(0.2, 16.7)
|
|
145
|
-
}
|
|
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
|
-
/* ---------------- 大数据量:建场景 / 渲染 / 拾取 / 换档 ---------------- */
|
|
213
|
-
const pointCount = 20000
|
|
214
|
-
const lineCount = 8000
|
|
215
|
-
const points = new Array(pointCount)
|
|
216
|
-
for (let index = 0; index < pointCount; index += 1) {
|
|
217
|
-
points[index] = { id: `p${index}`, x: (index % 200) * 20, y: Math.floor(index / 200) * 20, name: `节点 ${index}`, size: 6 }
|
|
218
|
-
}
|
|
219
|
-
const lines = new Array(lineCount)
|
|
220
|
-
for (let index = 0; index < lineCount; index += 1) {
|
|
221
|
-
lines[index] = { id: `l${index}`, from: points[index % lineCount].id, to: points[(index * 7 + 1) % lineCount].id, color: '#2f6fd0', width: 1 }
|
|
222
|
-
}
|
|
223
|
-
const qualityChanges = []
|
|
224
|
-
const bigPayloads = []
|
|
225
|
-
const bigHost = document.createElement('div')
|
|
226
|
-
document.querySelector('#map').append(bigHost)
|
|
227
|
-
const created = now()
|
|
228
|
-
const big = createGisView(bigHost, {
|
|
229
|
-
points,
|
|
230
|
-
lines,
|
|
231
|
-
width: 640,
|
|
232
|
-
height: 380,
|
|
233
|
-
quality: 'high',
|
|
234
|
-
onDataChange: payload => bigPayloads.push(payload),
|
|
235
|
-
onQualityChange: state => qualityChanges.push(state.name)
|
|
236
|
-
})
|
|
237
|
-
diag('建场景(20000 点 / 8000 线)', `${(now() - created).toFixed(1)} ms`)
|
|
238
|
-
const stats = big.getStats()
|
|
239
|
-
check('大数据量场景的条数与数据一致', stats.pointCount === pointCount && stats.lineCount === lineCount, `${stats.pointCount} 点 / ${stats.lineCount} 线`)
|
|
240
|
-
check('渲染读数里带上画质那一栏', stats.quality?.name === 'high' && big.getQuality().mode === 'high', JSON.stringify(stats.quality))
|
|
241
|
-
const refreshStarted = now()
|
|
242
|
-
big.refresh()
|
|
243
|
-
const refreshMs = now() - refreshStarted
|
|
244
|
-
diag('整份重排一帧', `${refreshMs.toFixed(1)} ms(引擎读数 frameMs=${big.getStats().frameMs.toFixed(2)},drawCalls=${big.getStats().drawCalls})`)
|
|
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))
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<title>perf running</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<div id="map" style="width:640px;height:380px"></div>
|
|
9
|
+
<pre id="out"></pre>
|
|
10
|
+
<script type="module">
|
|
11
|
+
// 画质档位(含自动升降)/ 批处理 / 配置提醒的自测:断言全在接口层,不读像素。
|
|
12
|
+
// 大数据量那一组既量一次真实的建场景、拾取与批量改数据耗时(DIAG),也拿它跑一遍自动档的阶梯。
|
|
13
|
+
const results = []
|
|
14
|
+
// 每条结果都立刻落到 #out 上:真卡住时,最后一行就是卡住的位置
|
|
15
|
+
const flush = () => {
|
|
16
|
+
document.querySelector('#out').textContent = results.join('\n')
|
|
17
|
+
}
|
|
18
|
+
const check = (name, ok, extra = '') => {
|
|
19
|
+
results.push(`${ok ? 'PASS' : 'FAIL'} ${name}${extra === '' ? '' : ` — ${extra}`}`)
|
|
20
|
+
flush()
|
|
21
|
+
}
|
|
22
|
+
const diag = (name, extra) => {
|
|
23
|
+
results.push(`DIAG ${name} — ${extra}`)
|
|
24
|
+
flush()
|
|
25
|
+
}
|
|
26
|
+
const now = () => performance.now()
|
|
27
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
28
|
+
// 拾取用的节点:线路只挂在前面 8000 个节点上,取后面的才不会被线抢走命中
|
|
29
|
+
const clearPoint = 15000
|
|
30
|
+
try {
|
|
31
|
+
const { buildSceneSvg, 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
|
+
check('QUALITY_MODES 是五档:四个固定档 + auto', QUALITY_MODES.join(',') === 'high,balanced,lean,fast,auto', QUALITY_MODES.join(','))
|
|
104
|
+
check('QUALITY_LEVELS 从高到低,越往下省得越多', QUALITY_LEVELS.length === 4 &&
|
|
105
|
+
QUALITY_LEVELS[0].glow && QUALITY_LEVELS[0].ornament && QUALITY_LEVELS[0].scale === 1 &&
|
|
106
|
+
!QUALITY_LEVELS[1].glow && QUALITY_LEVELS[1].ornament &&
|
|
107
|
+
!QUALITY_LEVELS[2].glow && !QUALITY_LEVELS[2].ornament &&
|
|
108
|
+
QUALITY_LEVELS[3].scale < 1, JSON.stringify(QUALITY_LEVELS))
|
|
109
|
+
check('quality: true 就是 auto', normalizeQualityOptions(true).mode === 'auto')
|
|
110
|
+
check('写错的档位名退回 high', normalizeQualityOptions('nope').mode === 'high' && normalizeQualityOptions({ mode: 'nope' }).mode === 'high')
|
|
111
|
+
const clamped = normalizeQualityOptions({ mode: 'auto', budgetMs: -5, minScale: 3 })
|
|
112
|
+
check('budgetMs / minScale 越界会夹回可用区间', clamped.budgetMs === 12 && clamped.minScale === 1, JSON.stringify(clamped))
|
|
113
|
+
|
|
114
|
+
/* ---------------- 调节器:掉档 / 升档 / 固定档 ---------------- */
|
|
115
|
+
const switched = []
|
|
116
|
+
const governor = createQualityGovernor({ mode: 'auto', budgetMs: 8 }, state => switched.push(state.name))
|
|
117
|
+
const feed = (ms, count) => {
|
|
118
|
+
for (let index = 0; index < count; index += 1) {
|
|
119
|
+
governor.sample(ms)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
feed(30, 60)
|
|
123
|
+
check('自动档:连续超预算就掉一档(并回调新档位)', governor.state().name === 'balanced' && switched.includes('balanced'), `${governor.state().name} / ${switched.join(',')}`)
|
|
124
|
+
await sleep(900)
|
|
125
|
+
feed(30, 35)
|
|
126
|
+
check('自动档:还在超预算就继续往下掉', governor.state().name === 'lean', governor.state().name)
|
|
127
|
+
await sleep(900)
|
|
128
|
+
feed(30, 35)
|
|
129
|
+
check('自动档:掉到最低档改用更小的分辨率渲染', governor.state().name === 'fast' && governor.state().scale === 0.75, `${governor.state().name} ${governor.state().scale}`)
|
|
130
|
+
await sleep(900)
|
|
131
|
+
feed(1, 70)
|
|
132
|
+
check('自动档:预算富余一阵子才升回一档(升档比降档保守)', governor.state().name === 'lean', governor.state().name)
|
|
133
|
+
// 整帧间隔也是成本信号:渲染耗时(frameMs)量不到填充率与合成 —— 软件光栅 / 低端显卡上
|
|
134
|
+
// 十万级数据 frameMs 一直 0.1ms、实际十几帧,自动档以前永远不会掉档
|
|
135
|
+
const gapGovernor = createQualityGovernor({ mode: 'auto', budgetMs: 12 })
|
|
136
|
+
for (let index = 0; index < 40; index += 1) {
|
|
137
|
+
gapGovernor.sample(0.2, 200)
|
|
138
|
+
}
|
|
139
|
+
check('自动档:渲染耗时没超但整帧间隔掉到十几帧,照样往下掉(并记进读数)',
|
|
140
|
+
gapGovernor.state().name !== 'high' && gapGovernor.state().gapMs === 200,
|
|
141
|
+
`${gapGovernor.state().name} gap=${gapGovernor.state().gapMs}`)
|
|
142
|
+
const smoothGovernor = createQualityGovernor({ mode: 'auto', budgetMs: 12 })
|
|
143
|
+
for (let index = 0; index < 80; index += 1) {
|
|
144
|
+
smoothGovernor.sample(0.2, 16.7)
|
|
145
|
+
}
|
|
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
|
+
/* ---------------- 大数据量:建场景 / 渲染 / 拾取 / 换档 ---------------- */
|
|
213
|
+
const pointCount = 20000
|
|
214
|
+
const lineCount = 8000
|
|
215
|
+
const points = new Array(pointCount)
|
|
216
|
+
for (let index = 0; index < pointCount; index += 1) {
|
|
217
|
+
points[index] = { id: `p${index}`, x: (index % 200) * 20, y: Math.floor(index / 200) * 20, name: `节点 ${index}`, size: 6 }
|
|
218
|
+
}
|
|
219
|
+
const lines = new Array(lineCount)
|
|
220
|
+
for (let index = 0; index < lineCount; index += 1) {
|
|
221
|
+
lines[index] = { id: `l${index}`, from: points[index % lineCount].id, to: points[(index * 7 + 1) % lineCount].id, color: '#2f6fd0', width: 1 }
|
|
222
|
+
}
|
|
223
|
+
const qualityChanges = []
|
|
224
|
+
const bigPayloads = []
|
|
225
|
+
const bigHost = document.createElement('div')
|
|
226
|
+
document.querySelector('#map').append(bigHost)
|
|
227
|
+
const created = now()
|
|
228
|
+
const big = createGisView(bigHost, {
|
|
229
|
+
points,
|
|
230
|
+
lines,
|
|
231
|
+
width: 640,
|
|
232
|
+
height: 380,
|
|
233
|
+
quality: 'high',
|
|
234
|
+
onDataChange: payload => bigPayloads.push(payload),
|
|
235
|
+
onQualityChange: state => qualityChanges.push(state.name)
|
|
236
|
+
})
|
|
237
|
+
diag('建场景(20000 点 / 8000 线)', `${(now() - created).toFixed(1)} ms`)
|
|
238
|
+
const stats = big.getStats()
|
|
239
|
+
check('大数据量场景的条数与数据一致', stats.pointCount === pointCount && stats.lineCount === lineCount, `${stats.pointCount} 点 / ${stats.lineCount} 线`)
|
|
240
|
+
check('渲染读数里带上画质那一栏', stats.quality?.name === 'high' && big.getQuality().mode === 'high', JSON.stringify(stats.quality))
|
|
241
|
+
const refreshStarted = now()
|
|
242
|
+
big.refresh()
|
|
243
|
+
const refreshMs = now() - refreshStarted
|
|
244
|
+
diag('整份重排一帧', `${refreshMs.toFixed(1)} ms(引擎读数 frameMs=${big.getStats().frameMs.toFixed(2)},drawCalls=${big.getStats().drawCalls})`)
|
|
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
273
|
boundsView.destroy()
|
|
274
274
|
boundsHost.remove()
|
|
275
275
|
// 节点 id 索引:整份 refresh 后仍能解析 from / to;定向改 id 时旧键必须立即失效、新键马上可绑定
|
|
@@ -301,52 +301,52 @@
|
|
|
301
301
|
idIndexView.destroy()
|
|
302
302
|
idIndexHost.remove()
|
|
303
303
|
// 两万个点会走发散渲染:等它铺完再拾取(没铺到的节点按设计不参与拾取)
|
|
304
|
-
const readyDeadline = now() + 8000
|
|
305
|
-
while (!big.isLayoutReady() && now() < readyDeadline) {
|
|
306
|
-
await sleep(50)
|
|
307
|
-
}
|
|
308
|
-
check('两万点走发散渲染:几帧之内铺完(isLayoutReady)', big.isLayoutReady(), `等待 ${(8000 - (readyDeadline - now())).toFixed(0)} ms`)
|
|
309
|
-
const target = points[clearPoint]
|
|
310
|
-
// 先把相机对准这个节点再拾取:屏幕上只有它落在拾取半径里,量到的才是真实的命中
|
|
311
|
-
big.setCamera({ center: [target.x, target.y], scale: 3 })
|
|
312
|
-
const screen = big.project(target.x, target.y)
|
|
313
|
-
const picked = big.pick(screen[0], screen[1])
|
|
314
|
-
check('大数据量拾取:网格里按坐标直接命中节点', picked?.item?.id === target.id, JSON.stringify({ id: picked?.item?.id, 期望: target.id, screen: screen.map(value => Number(value).toFixed(1)) }))
|
|
315
|
-
const pickStarted = now()
|
|
316
|
-
for (let index = 0; index < 500; index += 1) {
|
|
317
|
-
big.pick(screen[0] + (index % 5), screen[1] + (index % 3))
|
|
318
|
-
}
|
|
319
|
-
diag('连做 500 次拾取', `${((now() - pickStarted) / 500).toFixed(3)} ms / 次`)
|
|
320
|
-
|
|
321
|
-
/* ---------------- 删除确认面板:选中集大时「连带影响」的那笔账 ---------------- */
|
|
322
|
-
// 选中 N 个节点时每个节点的连带线路都要算一遍:从前是「N × 全部线路」的比对,
|
|
323
|
-
// 框选几千个节点、删除确认面板还没弹出来就先卡住了;现在按 id 建表、线路与通道各扫一遍。
|
|
324
|
-
const impactCount = 8000
|
|
325
|
-
big.selectFeatures(points.slice(0, impactCount).map(point => point.id))
|
|
326
|
-
const removeStarted = now()
|
|
327
|
-
const removePlan = big.requestRemove()
|
|
328
|
-
const removeMs = now() - removeStarted
|
|
329
|
-
check('删除确认:选中一批节点时连带影响算得全(线路按两端绑定认领、没有重复)',
|
|
330
|
-
removePlan.ok === true && removePlan.counts.points === impactCount && removePlan.counts.cascadedLines === lineCount,
|
|
331
|
-
JSON.stringify(removePlan.counts))
|
|
332
|
-
diag(`删除确认载荷(选中 ${impactCount} 点 / ${lineCount} 线)`, `${removeMs.toFixed(1)} ms`)
|
|
333
|
-
removePlan.cancel()
|
|
334
|
-
big.selectFeatures(null)
|
|
335
|
-
|
|
336
|
-
/* ---------------- 通道在场时的补点 / 删点:这一轮之前一律回落整份重建 ---------------- */
|
|
337
|
-
// 通道的成员表按线路下标记、骨架按锚点 id 定:声明逐条没变、没删到回线与锚点时走增量
|
|
338
|
-
big.updateOptions({ channels: [{ id: 'bench-ch', from: 'p0', to: 'p1', lineIds: ['l0', 'l1'] }] })
|
|
339
|
-
const channelReadyDeadline = now() + 8000
|
|
340
|
-
while (!big.isLayoutReady() && now() < channelReadyDeadline) {
|
|
341
|
-
await sleep(50)
|
|
342
|
-
}
|
|
343
|
-
const channelAddStarted = now()
|
|
344
|
-
big.addPoints([{ id: 'bench-new', x: 0, y: 0 }, { id: 'bench-new2', x: 20, y: 0 }])
|
|
345
|
-
const channelAddMs = now() - channelAddStarted
|
|
346
|
-
const channelRemoveStarted = now()
|
|
347
|
-
big.removePoints(['bench-new', 'bench-new2'])
|
|
348
|
-
const channelRemoveMs = now() - channelRemoveStarted
|
|
349
|
-
diag('通道在场时补 2 个点', `${channelAddMs.toFixed(1)} ms(这一轮之前 40 ms 上下)`)
|
|
304
|
+
const readyDeadline = now() + 8000
|
|
305
|
+
while (!big.isLayoutReady() && now() < readyDeadline) {
|
|
306
|
+
await sleep(50)
|
|
307
|
+
}
|
|
308
|
+
check('两万点走发散渲染:几帧之内铺完(isLayoutReady)', big.isLayoutReady(), `等待 ${(8000 - (readyDeadline - now())).toFixed(0)} ms`)
|
|
309
|
+
const target = points[clearPoint]
|
|
310
|
+
// 先把相机对准这个节点再拾取:屏幕上只有它落在拾取半径里,量到的才是真实的命中
|
|
311
|
+
big.setCamera({ center: [target.x, target.y], scale: 3 })
|
|
312
|
+
const screen = big.project(target.x, target.y)
|
|
313
|
+
const picked = big.pick(screen[0], screen[1])
|
|
314
|
+
check('大数据量拾取:网格里按坐标直接命中节点', picked?.item?.id === target.id, JSON.stringify({ id: picked?.item?.id, 期望: target.id, screen: screen.map(value => Number(value).toFixed(1)) }))
|
|
315
|
+
const pickStarted = now()
|
|
316
|
+
for (let index = 0; index < 500; index += 1) {
|
|
317
|
+
big.pick(screen[0] + (index % 5), screen[1] + (index % 3))
|
|
318
|
+
}
|
|
319
|
+
diag('连做 500 次拾取', `${((now() - pickStarted) / 500).toFixed(3)} ms / 次`)
|
|
320
|
+
|
|
321
|
+
/* ---------------- 删除确认面板:选中集大时「连带影响」的那笔账 ---------------- */
|
|
322
|
+
// 选中 N 个节点时每个节点的连带线路都要算一遍:从前是「N × 全部线路」的比对,
|
|
323
|
+
// 框选几千个节点、删除确认面板还没弹出来就先卡住了;现在按 id 建表、线路与通道各扫一遍。
|
|
324
|
+
const impactCount = 8000
|
|
325
|
+
big.selectFeatures(points.slice(0, impactCount).map(point => point.id))
|
|
326
|
+
const removeStarted = now()
|
|
327
|
+
const removePlan = big.requestRemove()
|
|
328
|
+
const removeMs = now() - removeStarted
|
|
329
|
+
check('删除确认:选中一批节点时连带影响算得全(线路按两端绑定认领、没有重复)',
|
|
330
|
+
removePlan.ok === true && removePlan.counts.points === impactCount && removePlan.counts.cascadedLines === lineCount,
|
|
331
|
+
JSON.stringify(removePlan.counts))
|
|
332
|
+
diag(`删除确认载荷(选中 ${impactCount} 点 / ${lineCount} 线)`, `${removeMs.toFixed(1)} ms`)
|
|
333
|
+
removePlan.cancel()
|
|
334
|
+
big.selectFeatures(null)
|
|
335
|
+
|
|
336
|
+
/* ---------------- 通道在场时的补点 / 删点:这一轮之前一律回落整份重建 ---------------- */
|
|
337
|
+
// 通道的成员表按线路下标记、骨架按锚点 id 定:声明逐条没变、没删到回线与锚点时走增量
|
|
338
|
+
big.updateOptions({ channels: [{ id: 'bench-ch', from: 'p0', to: 'p1', lineIds: ['l0', 'l1'] }] })
|
|
339
|
+
const channelReadyDeadline = now() + 8000
|
|
340
|
+
while (!big.isLayoutReady() && now() < channelReadyDeadline) {
|
|
341
|
+
await sleep(50)
|
|
342
|
+
}
|
|
343
|
+
const channelAddStarted = now()
|
|
344
|
+
big.addPoints([{ id: 'bench-new', x: 0, y: 0 }, { id: 'bench-new2', x: 20, y: 0 }])
|
|
345
|
+
const channelAddMs = now() - channelAddStarted
|
|
346
|
+
const channelRemoveStarted = now()
|
|
347
|
+
big.removePoints(['bench-new', 'bench-new2'])
|
|
348
|
+
const channelRemoveMs = now() - channelRemoveStarted
|
|
349
|
+
diag('通道在场时补 2 个点', `${channelAddMs.toFixed(1)} ms(这一轮之前 40 ms 上下)`)
|
|
350
350
|
diag('通道在场时删这 2 个点', `${channelRemoveMs.toFixed(1)} ms(这一轮之前 35 ms 上下)`)
|
|
351
351
|
check('通道在场时的增删:通道成员表不动、点真的加上又删掉', big.getData().points.length === pointCount && big.getChannel('bench-ch').lineIndexes.length === 2, JSON.stringify({ points: big.getData().points.length, members: big.getChannel('bench-ch').lineIndexes }))
|
|
352
352
|
// addLine 是末尾补线:已有通道声明时也走追加式补线,不能为了加一条独立线路把整份场景重排。
|
|
@@ -377,294 +377,294 @@
|
|
|
377
377
|
big.removeLine(id)
|
|
378
378
|
}
|
|
379
379
|
big.updateOptions({ channels: [] })
|
|
380
|
-
|
|
381
|
-
/* ---------------- 分区(分块)渲染值不值得:视口变化时一帧的成本怎么变 ---------------- */
|
|
382
|
-
// 「把地图拆成一块一块、只画视口里的块」省的是「视口外那些实例的顶点变换」。
|
|
383
|
-
// 这里量两件事:① 一帧的渲染耗时在「整份在视口里」与「放大到只看得见 1%」之间差多少
|
|
384
|
-
// (差得少说明视口外的实例本来就不值钱,拆块省不到多少);② 视口里枚举节点用网格版
|
|
385
|
-
// (forEachPointInView)比拿整份快照再筛快多少(这块是真有收益的,标签排版已经改走它)。
|
|
386
|
-
big.fitView(0)
|
|
387
|
-
big.refresh()
|
|
388
|
-
big.refresh()
|
|
389
|
-
const fullFrame = big.getStats()
|
|
390
|
-
big.setCamera({ center: [target.x, target.y], scale: 40 })
|
|
391
|
-
big.refresh()
|
|
392
|
-
big.refresh()
|
|
393
|
-
const zoomFrame = big.getStats()
|
|
394
|
-
const viewRect = big.getViewRect()
|
|
395
|
-
// 先把点网格建好:下面这一组量的才是「网格可用时」的视口枚举
|
|
396
|
-
big.pick(screen[0], screen[1])
|
|
397
|
-
let countAll = 0
|
|
398
|
-
let countView = 0
|
|
399
|
-
let snapshotMs = 0
|
|
400
|
-
let gridMs = 0
|
|
401
|
-
for (let round = 0; round < 20; round += 1) {
|
|
402
|
-
// 旧口径:拿整份可见快照(listPoints 每次现建十万个对象)再逐个筛视口
|
|
403
|
-
const snapshotStarted = now()
|
|
404
|
-
countAll = 0
|
|
405
|
-
for (const point of big.listPoints({ visibleOnly: true })) {
|
|
406
|
-
if (point.x < viewRect.minX || point.x > viewRect.maxX || point.y < viewRect.minY || point.y > viewRect.maxY) {
|
|
407
|
-
continue
|
|
408
|
-
}
|
|
409
|
-
countAll += 1
|
|
410
|
-
}
|
|
411
|
-
snapshotMs += now() - snapshotStarted
|
|
412
|
-
// 新口径:网格版只回调视口里那一片
|
|
413
|
-
const gridStarted = now()
|
|
414
|
-
countView = 0
|
|
415
|
-
big.forEachPointInView(viewRect, () => {
|
|
416
|
-
countView += 1
|
|
417
|
-
})
|
|
418
|
-
gridMs += now() - gridStarted
|
|
419
|
-
}
|
|
420
|
-
snapshotMs /= 20
|
|
421
|
-
gridMs /= 20
|
|
422
|
-
check('分区枚举:网格版碰到的正是视口里那一片(与整份筛出来的条数一致)', countAll === countView && countView > 0, `${countView} / ${countAll}`)
|
|
423
|
-
const expectedViewIndexes = big.listPoints({ visibleOnly: true })
|
|
424
|
-
.filter(point => point.x >= viewRect.minX && point.x <= viewRect.maxX && point.y >= viewRect.minY && point.y <= viewRect.maxY)
|
|
425
|
-
.map(point => point.index)
|
|
426
|
-
const actualViewIndexes = []
|
|
427
|
-
big.forEachPointInView(viewRect, index => {
|
|
428
|
-
actualViewIndexes.push(index)
|
|
429
|
-
})
|
|
430
|
-
check('分区枚举:网格路径仍按数据顺序访问(与整份筛出来的下标序列一致)', actualViewIndexes.join(',') === expectedViewIndexes.join(','), actualViewIndexes.length + ' / ' + expectedViewIndexes.length)
|
|
431
|
-
let earlyViewCalls = 0
|
|
432
|
-
big.forEachPointInView(viewRect, () => {
|
|
433
|
-
earlyViewCalls += 1
|
|
434
|
-
return false
|
|
435
|
-
})
|
|
436
|
-
check('分区枚举:回调返回 false 立即结束(最多访问一个)', earlyViewCalls === (expectedViewIndexes.length ? 1 : 0), String(earlyViewCalls))
|
|
437
|
-
diag('视口枚举(放大到一小片)', `整份快照再筛 ${snapshotMs.toFixed(3)} ms vs 网格版 ${gridMs.toFixed(3)} ms`)
|
|
438
|
-
const zoomShare = pointCount > 0 ? countView / pointCount : 0
|
|
439
|
-
diag('同一份数据的一帧', `整份在视口里 ${fullFrame.frameMs.toFixed(2)} ms / ${fullFrame.drawCalls} drawCalls · 只看得见 ${(zoomShare * 100).toFixed(2)}% ${zoomFrame.frameMs.toFixed(2)} ms / ${zoomFrame.drawCalls} drawCalls`)
|
|
440
|
-
check('渲染耗时与「视口里有多少」基本脱钩(视口外的实例不值钱,拆块省不到)', zoomFrame.frameMs <= fullFrame.frameMs * 2 + 1, `${fullFrame.frameMs.toFixed(2)} → ${zoomFrame.frameMs.toFixed(2)} ms`)
|
|
441
|
-
check('drawCalls 与要素总量脱钩(一次实例化画完,不按区域拆批)', fullFrame.drawCalls > 0 && fullFrame.drawCalls <= 40, `drawCalls=${fullFrame.drawCalls}`)
|
|
442
|
-
|
|
443
|
-
/* ---------------- 贴图分组增量建组:绘制序只该多一项 ---------------- */
|
|
444
|
-
// 整份重排之后 spriteGroups 与 spriteDrawOrder 若共用同一个数组,createSpriteGroup 的
|
|
445
|
-
// 「push 编号表 + 按层插绘制序」会把新建的组放进同一份数组两次 —— 那一组画两遍
|
|
446
|
-
// (半透明贴图还会叠深)。先用一轮选中把选中样式的贴图烤熟(熟了就无异步重排),
|
|
447
|
-
// 再清空重测:第二次选中当场新建一组,drawCalls 只该 +1(共用数组的版本 +2)
|
|
448
|
-
const spriteHost = document.createElement('div')
|
|
449
|
-
document.querySelector('#map').append(spriteHost)
|
|
450
|
-
const spriteSymbol = { type: 'svg', path: 'M12 1.5 L22.5 12 L12 22.5 L1.5 12 Z', viewBox: [0, 0, 24, 24] }
|
|
451
|
-
const spriteProbe = createGisView(spriteHost, {
|
|
452
|
-
points: [
|
|
453
|
-
{ id: 'sprite-0', x: 0, y: 0 },
|
|
454
|
-
{ id: 'sprite-1', x: 60, y: 0 },
|
|
455
|
-
{ id: 'sprite-2', x: 120, y: 0 }
|
|
456
|
-
],
|
|
457
|
-
width: 320,
|
|
458
|
-
height: 200,
|
|
459
|
-
pointStyle: { symbol: spriteSymbol, size: 10 },
|
|
460
|
-
pointSelectStyle: { color: '#e0592e' },
|
|
461
|
-
// 抬层会另开一层分组,这里只量「新建一组」这一件事
|
|
462
|
-
selectionOnTop: false
|
|
463
|
-
})
|
|
464
|
-
const spriteReadyDeadline = now() + 8000
|
|
465
|
-
while (!spriteProbe.isLayoutReady() && now() < spriteReadyDeadline) {
|
|
466
|
-
await sleep(20)
|
|
467
|
-
}
|
|
468
|
-
await sleep(300)
|
|
469
|
-
spriteProbe.selectFeatures(['sprite-0'])
|
|
470
|
-
await sleep(500)
|
|
471
|
-
spriteProbe.selectFeatures(null)
|
|
472
|
-
await sleep(200)
|
|
473
|
-
spriteProbe.refresh()
|
|
474
|
-
await sleep(120)
|
|
475
|
-
spriteProbe.refresh()
|
|
476
|
-
await sleep(120)
|
|
477
|
-
const spriteCallsBefore = spriteProbe.getStats().drawCalls
|
|
478
|
-
spriteProbe.selectFeatures(['sprite-0'])
|
|
479
|
-
await sleep(200)
|
|
480
|
-
const spriteCallsAfter = spriteProbe.getStats().drawCalls
|
|
481
|
-
check('贴图增量建组只多一次绘制调用(编号表与绘制序分开,新建的组不会被画两遍)', spriteCallsAfter - spriteCallsBefore === 1, `${spriteCallsBefore} → ${spriteCallsAfter}`)
|
|
482
|
-
spriteProbe.destroy()
|
|
483
|
-
spriteHost.remove()
|
|
484
|
-
|
|
485
|
-
/* ---------------- 线路批次的视口裁剪:放大之后只铺视野里那一片 ---------------- */
|
|
486
|
-
// 线路放大之后绝大多数在视野之外,铺进实例批次就是为每一条白付一次顶点变换与裁剪;
|
|
487
|
-
// 这一层按「视口 + 线宽余量」把批次裁到视野里那一片(逐段判定:包围盒对又长又斜的线几乎筛不掉)。
|
|
488
|
-
// 两条断言:① 放大后批次条数跟着缩(读数 lineInstances);② 视野挪回来画面一个像素都不差。
|
|
489
|
-
{
|
|
490
|
-
const shot = async () => {
|
|
491
|
-
const url = await big.exportImage({ includeLabels: false, scale: 1 })
|
|
492
|
-
const image = new Image()
|
|
493
|
-
await new Promise((resolve, reject) => {
|
|
494
|
-
image.onload = resolve
|
|
495
|
-
image.onerror = () => reject(new Error('导出图片加载失败'))
|
|
496
|
-
image.src = url
|
|
497
|
-
})
|
|
498
|
-
const buffer = document.createElement('canvas')
|
|
499
|
-
buffer.width = image.width
|
|
500
|
-
buffer.height = image.height
|
|
501
|
-
const context = buffer.getContext('2d')
|
|
502
|
-
context.drawImage(image, 0, 0)
|
|
503
|
-
return context.getImageData(0, 0, buffer.width, buffer.height).data
|
|
504
|
-
}
|
|
505
|
-
// 放大要挑一个「真有线经过」的节点当中心:线路只连 points[0..lineCount),clearPoint 落在后半段,
|
|
506
|
-
// 直接拿它当中心的话 40 倍视野里一条线都没有(读数是 0,看着反倒像裁剪裁坏了)。
|
|
507
|
-
const lineTarget = points[clearPoint % lineCount]
|
|
508
|
-
big.fitView(0)
|
|
509
|
-
big.refresh()
|
|
510
|
-
const fitCamera = big.getCamera()
|
|
511
|
-
const fullInstances = big.getStats().lineInstances
|
|
512
|
-
const before = await shot()
|
|
513
|
-
big.setCamera({ center: [lineTarget.x, lineTarget.y], scale: 10 })
|
|
514
|
-
big.refresh()
|
|
515
|
-
const zoomInstances = big.getStats().lineInstances
|
|
516
|
-
check('视口裁剪:放大到只看得见一小片之后,线路批次只铺视野里那一片(lineInstances 跟着缩)',
|
|
517
|
-
zoomInstances > 0 && zoomInstances * 4 < fullInstances, `整份 ${fullInstances} 段 → 放大后 ${zoomInstances} 段`)
|
|
518
|
-
big.setCamera(fitCamera)
|
|
519
|
-
big.refresh()
|
|
520
|
-
const backInstances = big.getStats().lineInstances
|
|
521
|
-
check('视口裁剪:视野挪回去之后线路又全铺回来(条数回到整份)', backInstances === fullInstances, `${fullInstances} → ${zoomInstances} → ${backInstances}`)
|
|
522
|
-
const after = await shot()
|
|
523
|
-
let diffPixels = 0
|
|
524
|
-
for (let index = 0; index < after.length; index += 4) {
|
|
525
|
-
if (before[index] !== after[index] || before[index + 1] !== after[index + 1] || before[index + 2] !== after[index + 2]) {
|
|
526
|
-
diffPixels += 1
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
check('视口裁剪:裁剪只是不铺、不是不画 —— 转一圈回来整幅像素一模一样', diffPixels === 0, `差异像素 ${diffPixels} / ${after.length / 4}`)
|
|
530
|
-
diag('视口裁剪', `整份 ${fullInstances} 段 / 放大后 ${zoomInstances} 段 / 回来 ${backInstances} 段,像素差 ${diffPixels}`)
|
|
531
|
-
|
|
532
|
-
/* ---------------- 线路几何的定向更新:能就地补的就地补,补不了的攒到画之前重铺一次 ---------------- */
|
|
533
|
-
// 改一条线的路径有两种形态:顶点数没变(拖拐点那种)按槽位就地写,批次条数一个都不动;
|
|
534
|
-
// 顶点数变了(加 / 删拐点)段数对不上,只能整批重铺 —— 但重铺记在账上,一帧里连着改几十条也只重铺一次(见 15.63)。
|
|
535
|
-
// 这里量的是账本本身:读数来自 lineInstances,不读像素也不量时间。
|
|
536
|
-
{
|
|
537
|
-
const geometryHost = document.createElement('div')
|
|
538
|
-
document.querySelector('#map').append(geometryHost)
|
|
539
|
-
const geometryCount = 200
|
|
540
|
-
const geometryLines = new Array(geometryCount)
|
|
541
|
-
for (let index = 0; index < geometryCount; index += 1) {
|
|
542
|
-
geometryLines[index] = { id: `g${index}`, path: [[0, index * 4], [400, index * 4]], color: '#2f6fd0', width: 2 }
|
|
543
|
-
}
|
|
544
|
-
const geometry = createGisView(geometryHost, { points: [], lines: geometryLines, width: 640, height: 380, quality: 'high' })
|
|
545
|
-
geometry.fitView()
|
|
546
|
-
geometry.refresh()
|
|
547
|
-
const geometryBefore = geometry.getStats().lineInstances
|
|
548
|
-
check('线路几何:200 条 path 声明的线路都铺进了批次', geometryBefore === geometryCount, `${geometryBefore} 段`)
|
|
549
|
-
for (const line of geometryLines) {
|
|
550
|
-
geometry.updateLine(line.id, { path: [[0, 0], [200, 0]] })
|
|
551
|
-
}
|
|
552
|
-
check('线路几何(顶点数不变):按槽位就地补,批次条数与重铺前一致',
|
|
553
|
-
geometry.getStats().lineInstances === geometryBefore, `${geometryBefore} → ${geometry.getStats().lineInstances}`)
|
|
554
|
-
for (const line of geometryLines) {
|
|
555
|
-
geometry.updateLine(line.id, { path: [[0, 0], [200, 0], [200, 60]] })
|
|
556
|
-
}
|
|
557
|
-
const geometryPending = geometry.getStats().lineInstances
|
|
558
|
-
check('线路几何(顶点数变了):重铺记在账上,改完这一刻批次还没动(一帧里连着改只重铺一次)',
|
|
559
|
-
geometryPending === geometryBefore, `${geometryPending} / ${geometryBefore}`)
|
|
560
|
-
geometry.refresh({ reflow: false })
|
|
561
|
-
const geometryRebuilt = geometry.getStats().lineInstances
|
|
562
|
-
check('线路几何(顶点数变了):画之前那一趟一次把 200 条都补上(每条多一段)',
|
|
563
|
-
geometryRebuilt === geometryBefore + geometryCount, `${geometryPending} → ${geometryRebuilt}(期望 ${geometryBefore + geometryCount})`)
|
|
564
|
-
for (const line of geometryLines) {
|
|
565
|
-
geometry.updateLine(line.id, { path: [[0, 0], [200, 0]] })
|
|
566
|
-
}
|
|
567
|
-
geometry.refresh({ reflow: false })
|
|
568
|
-
check('线路几何(段数换回去):批次条数回到原样(两条路的账本都没错位)',
|
|
569
|
-
geometry.getStats().lineInstances === geometryBefore, `${geometryRebuilt} → ${geometry.getStats().lineInstances}`)
|
|
570
|
-
// 视口外的线挪进视野:它的槽位记的是 -1,补丁补不了,画之前那一趟要把它铺回来
|
|
571
|
-
geometry.setCamera({ center: [200, 398], scale: 2 })
|
|
572
|
-
geometry.refresh()
|
|
573
|
-
geometry.updateLine('g0', { path: [[100000, 100000], [100010, 100000]] })
|
|
574
|
-
geometry.refresh({ reflow: false })
|
|
575
|
-
const geometryAway = geometry.getStats().lineInstances
|
|
576
|
-
geometry.updateLine('g0', { path: [[190, 398], [210, 398]] })
|
|
577
|
-
geometry.refresh({ reflow: false })
|
|
578
|
-
check('线路几何:挪出视口的线被裁掉、挪回来又铺上(画之前那一趟按新几何重算裁剪)',
|
|
579
|
-
geometry.getStats().lineInstances === geometryAway + 1, `${geometryAway} → ${geometry.getStats().lineInstances}`)
|
|
580
|
-
diag('线路几何的定向更新', `整批条目 ${geometryBefore} → 顶点数变了重铺后 ${geometryRebuilt} → 放大裁剪后 ${geometryAway}`)
|
|
581
|
-
geometry.destroy()
|
|
582
|
-
geometryHost.remove()
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
/* ---------------- 标签层:一次定向更新的开销在哪 ---------------- */
|
|
587
|
-
// 「拆块」真正省得下来的是「每次交互要按视口重算的那部分」,而这一块几乎全在标签层:
|
|
588
|
-
// 上一轮把候选缩到视口里那一片(forEachPointInView),这一轮再把「量尺寸」与「摘节点」分开 ——
|
|
589
|
-
// dropLabel 会把节点从 DOM 里摘掉(remove 让布局作废),夹在量尺寸中间时这一批里每一条都要各自重排一次。
|
|
590
|
-
// 这里量一次「全在视口里」的代价,顺带盯住「定向更新不会让已经画出来的标签换人」这条不变量。
|
|
591
|
-
const labelHost = document.createElement('div')
|
|
592
|
-
document.querySelector('#map').append(labelHost)
|
|
593
|
-
const labelPoints = new Array(pointCount)
|
|
594
|
-
for (let index = 0; index < pointCount; index += 1) {
|
|
595
|
-
labelPoints[index] = { id: `q${index}`, x: (index % 200) * 20, y: Math.floor(index / 200) * 20, name: `标签节点 ${index}`, size: 6 }
|
|
596
|
-
}
|
|
597
|
-
const labelView = createGisView(labelHost, { points: labelPoints, lines: [], width: 640, height: 380, quality: 'high', label: { content: '{name}', maxCount: 200 } })
|
|
598
|
-
// 改配置只把排版挂到下一帧,这里先做一次定向更新把标签铺出来,量的才是「已经有标签之后」的重排
|
|
599
|
-
labelView.updatePoint(labelPoints[5000].id, { status: 'warm' })
|
|
600
|
-
// 排版挂在 rAF 上:先等一帧让第一批标签落进 DOM,下面两条断言才有东西可盯(否则是空转)
|
|
601
|
-
await sleep(120)
|
|
602
|
-
const labelSignature = () => [...labelHost.querySelectorAll('.gis-view__label')]
|
|
603
|
-
.map(node => `${node.dataset.labelIndex ?? ''}|${node.textContent}`)
|
|
604
|
-
.sort()
|
|
605
|
-
.join(',')
|
|
606
|
-
// 「量尺寸与摘节点分开」改的只是「什么时候把节点摘掉」,已经画出来的标签一条都不该少,
|
|
607
|
-
// 所以这里盯的是「上一轮那一批还在不在」(多补进来的新标签是允许的:候选窗口会往前推进)
|
|
608
|
-
const labelsBefore = labelSignature() === '' ? [] : labelSignature().split(',')
|
|
609
|
-
const labelUpdateStarted = now()
|
|
610
|
-
for (let round = 0; round < 10; round += 1) {
|
|
611
|
-
labelView.updatePoint(labelPoints[5000].id, { status: `s${round}` })
|
|
612
|
-
}
|
|
613
|
-
const labelsAfter = new Set(labelSignature() === '' ? [] : labelSignature().split(','))
|
|
614
|
-
diag('两万点全在视口里 · 带标签时的一次 updatePoint', `${((now() - labelUpdateStarted) / 10).toFixed(2)} ms / 次 · DOM 标签 ${labelsAfter.size} 个(这一轮之前是 55 ms 上下)`)
|
|
615
|
-
check('定向更新之后,上一轮画出来的标签一条不少', labelsBefore.every(key => labelsAfter.has(key)), `${labelsBefore.length} 条里留下 ${labelsBefore.filter(key => labelsAfter.has(key)).length} 条`)
|
|
616
|
-
// 「尺寸按内容签名缓存 + 位置从 transform 里算」之后,整层重排只在签名第一次出现时量一次布局:
|
|
617
|
-
// 这里用「改样式 → 当场重排」当确定性触发器(编辑面板回显 / 拖完标签都是这一条路),
|
|
618
|
-
// 量测次数应当只跟新出现的签名条数有关,不再跟候选窗口的条数成正比 ——
|
|
619
|
-
// 从前的写法每轮要为 800 多条候选各读一次 getBoundingClientRect,一次重排 30 ms 上下
|
|
620
|
-
const labelMeasureStats = { label: 0, total: 0 }
|
|
621
|
-
const labelRelayoutRounds = []
|
|
622
|
-
const labelRelayoutMs = []
|
|
623
|
-
const nativeMeasureRect = Element.prototype.getBoundingClientRect
|
|
624
|
-
Element.prototype.getBoundingClientRect = function (...args) {
|
|
625
|
-
labelMeasureStats.total += 1
|
|
626
|
-
if (this.classList?.contains('gis-view__label')) {
|
|
627
|
-
labelMeasureStats.label += 1
|
|
628
|
-
}
|
|
629
|
-
return nativeMeasureRect.apply(this, args)
|
|
630
|
-
}
|
|
631
|
-
try {
|
|
632
|
-
for (let round = 0; round < 5; round += 1) {
|
|
633
|
-
labelMeasureStats.label = 0
|
|
634
|
-
const relayoutStarted = now()
|
|
635
|
-
// 同一份样式补丁反复回显:整层重排照排,内容却一条都没变,一条都不该再量
|
|
636
|
-
labelView.updateLabel('point', labelPoints[5000].id, { style: { color: '#123456' } })
|
|
637
|
-
labelRelayoutMs.push(now() - relayoutStarted)
|
|
638
|
-
labelRelayoutRounds.push(labelMeasureStats.label)
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
finally {
|
|
642
|
-
Element.prototype.getBoundingClientRect = nativeMeasureRect
|
|
643
|
-
}
|
|
644
|
-
const labelMeasureTotal = labelRelayoutRounds.reduce((sum, value) => sum + value, 0)
|
|
645
|
-
const labelRelayoutAvg = labelRelayoutMs.reduce((sum, value) => sum + value, 0) / labelRelayoutMs.length
|
|
646
|
-
diag('五轮强制重排(改样式)· 量测读数', `逐轮标签量测 ${labelRelayoutRounds.join(' / ')} 次 · 同步耗时 ${labelRelayoutAvg.toFixed(2)} ms / 次(旧写法是 832 次 / 轮、30 ms 上下)`)
|
|
647
|
-
check('整层重排只在新签名上量布局(五轮合计 ≤ 40 次,旧写法 832 次 / 轮)', labelMeasureTotal <= 40, `五轮合计 ${labelMeasureTotal} 次`)
|
|
648
|
-
check('内容没变的那些轮次一次布局都不读(第 3 轮起每轮 0 次)', labelRelayoutRounds.slice(2).every(value => value === 0), `第 3~5 轮 ${labelRelayoutRounds.slice(2).join(' / ')} 次`)
|
|
649
|
-
// 这一轮:右锚点(`translate(0, -50%)`)的避让盒改成纯算术。从前解析器只认「两段都是百分比」,
|
|
650
|
-
// 默认右锚点 `translate(0, -50%)` 解析不出来 —— 尺寸命中缓存、盒子又作废的那些标签,
|
|
651
|
-
// 每轮重排都要在避让那一步再真量一次(一次读布局一百多微秒),节点在池子里转一圈回来又是一批。
|
|
652
|
-
// 这里用「4 万点 + 4 万条长线、放大之后连续整层重排」量这组读数:第 1 轮把新签名量完之后,
|
|
653
|
-
// 内容 / 样式 / 视口都没变的轮次既不该再读布局,也不该有哪条标签在同一轮里被量第二次。
|
|
654
|
-
const anchorHost = document.createElement('div')
|
|
655
|
-
document.body.append(anchorHost)
|
|
656
|
-
const anchorPoints = new Array(40000)
|
|
657
|
-
for (let index = 0; index < anchorPoints.length; index += 1) {
|
|
658
|
-
anchorPoints[index] = { id: `a${index}`, x: (index % 400) * 10, y: Math.floor(index / 400) * 10, name: `锚点 ${index}`, size: 5 }
|
|
659
|
-
}
|
|
660
|
-
let anchorSeed = 12345
|
|
661
|
-
const anchorRandom = () => { anchorSeed = (anchorSeed * 1103515245 + 12345) & 0x7fffffff; return anchorSeed / 0x7fffffff }
|
|
662
|
-
const anchorLines = new Array(40000)
|
|
663
|
-
for (let index = 0; index < anchorLines.length; index += 1) {
|
|
664
|
-
anchorLines[index] = { id: `ax${index}`, start: [anchorRandom() * 4000, anchorRandom() * 2500], end: [anchorRandom() * 4000, anchorRandom() * 2500], name: `长线 ${index}` }
|
|
665
|
-
}
|
|
380
|
+
|
|
381
|
+
/* ---------------- 分区(分块)渲染值不值得:视口变化时一帧的成本怎么变 ---------------- */
|
|
382
|
+
// 「把地图拆成一块一块、只画视口里的块」省的是「视口外那些实例的顶点变换」。
|
|
383
|
+
// 这里量两件事:① 一帧的渲染耗时在「整份在视口里」与「放大到只看得见 1%」之间差多少
|
|
384
|
+
// (差得少说明视口外的实例本来就不值钱,拆块省不到多少);② 视口里枚举节点用网格版
|
|
385
|
+
// (forEachPointInView)比拿整份快照再筛快多少(这块是真有收益的,标签排版已经改走它)。
|
|
386
|
+
big.fitView(0)
|
|
387
|
+
big.refresh()
|
|
388
|
+
big.refresh()
|
|
389
|
+
const fullFrame = big.getStats()
|
|
390
|
+
big.setCamera({ center: [target.x, target.y], scale: 40 })
|
|
391
|
+
big.refresh()
|
|
392
|
+
big.refresh()
|
|
393
|
+
const zoomFrame = big.getStats()
|
|
394
|
+
const viewRect = big.getViewRect()
|
|
395
|
+
// 先把点网格建好:下面这一组量的才是「网格可用时」的视口枚举
|
|
396
|
+
big.pick(screen[0], screen[1])
|
|
397
|
+
let countAll = 0
|
|
398
|
+
let countView = 0
|
|
399
|
+
let snapshotMs = 0
|
|
400
|
+
let gridMs = 0
|
|
401
|
+
for (let round = 0; round < 20; round += 1) {
|
|
402
|
+
// 旧口径:拿整份可见快照(listPoints 每次现建十万个对象)再逐个筛视口
|
|
403
|
+
const snapshotStarted = now()
|
|
404
|
+
countAll = 0
|
|
405
|
+
for (const point of big.listPoints({ visibleOnly: true })) {
|
|
406
|
+
if (point.x < viewRect.minX || point.x > viewRect.maxX || point.y < viewRect.minY || point.y > viewRect.maxY) {
|
|
407
|
+
continue
|
|
408
|
+
}
|
|
409
|
+
countAll += 1
|
|
410
|
+
}
|
|
411
|
+
snapshotMs += now() - snapshotStarted
|
|
412
|
+
// 新口径:网格版只回调视口里那一片
|
|
413
|
+
const gridStarted = now()
|
|
414
|
+
countView = 0
|
|
415
|
+
big.forEachPointInView(viewRect, () => {
|
|
416
|
+
countView += 1
|
|
417
|
+
})
|
|
418
|
+
gridMs += now() - gridStarted
|
|
419
|
+
}
|
|
420
|
+
snapshotMs /= 20
|
|
421
|
+
gridMs /= 20
|
|
422
|
+
check('分区枚举:网格版碰到的正是视口里那一片(与整份筛出来的条数一致)', countAll === countView && countView > 0, `${countView} / ${countAll}`)
|
|
423
|
+
const expectedViewIndexes = big.listPoints({ visibleOnly: true })
|
|
424
|
+
.filter(point => point.x >= viewRect.minX && point.x <= viewRect.maxX && point.y >= viewRect.minY && point.y <= viewRect.maxY)
|
|
425
|
+
.map(point => point.index)
|
|
426
|
+
const actualViewIndexes = []
|
|
427
|
+
big.forEachPointInView(viewRect, index => {
|
|
428
|
+
actualViewIndexes.push(index)
|
|
429
|
+
})
|
|
430
|
+
check('分区枚举:网格路径仍按数据顺序访问(与整份筛出来的下标序列一致)', actualViewIndexes.join(',') === expectedViewIndexes.join(','), actualViewIndexes.length + ' / ' + expectedViewIndexes.length)
|
|
431
|
+
let earlyViewCalls = 0
|
|
432
|
+
big.forEachPointInView(viewRect, () => {
|
|
433
|
+
earlyViewCalls += 1
|
|
434
|
+
return false
|
|
435
|
+
})
|
|
436
|
+
check('分区枚举:回调返回 false 立即结束(最多访问一个)', earlyViewCalls === (expectedViewIndexes.length ? 1 : 0), String(earlyViewCalls))
|
|
437
|
+
diag('视口枚举(放大到一小片)', `整份快照再筛 ${snapshotMs.toFixed(3)} ms vs 网格版 ${gridMs.toFixed(3)} ms`)
|
|
438
|
+
const zoomShare = pointCount > 0 ? countView / pointCount : 0
|
|
439
|
+
diag('同一份数据的一帧', `整份在视口里 ${fullFrame.frameMs.toFixed(2)} ms / ${fullFrame.drawCalls} drawCalls · 只看得见 ${(zoomShare * 100).toFixed(2)}% ${zoomFrame.frameMs.toFixed(2)} ms / ${zoomFrame.drawCalls} drawCalls`)
|
|
440
|
+
check('渲染耗时与「视口里有多少」基本脱钩(视口外的实例不值钱,拆块省不到)', zoomFrame.frameMs <= fullFrame.frameMs * 2 + 1, `${fullFrame.frameMs.toFixed(2)} → ${zoomFrame.frameMs.toFixed(2)} ms`)
|
|
441
|
+
check('drawCalls 与要素总量脱钩(一次实例化画完,不按区域拆批)', fullFrame.drawCalls > 0 && fullFrame.drawCalls <= 40, `drawCalls=${fullFrame.drawCalls}`)
|
|
442
|
+
|
|
443
|
+
/* ---------------- 贴图分组增量建组:绘制序只该多一项 ---------------- */
|
|
444
|
+
// 整份重排之后 spriteGroups 与 spriteDrawOrder 若共用同一个数组,createSpriteGroup 的
|
|
445
|
+
// 「push 编号表 + 按层插绘制序」会把新建的组放进同一份数组两次 —— 那一组画两遍
|
|
446
|
+
// (半透明贴图还会叠深)。先用一轮选中把选中样式的贴图烤熟(熟了就无异步重排),
|
|
447
|
+
// 再清空重测:第二次选中当场新建一组,drawCalls 只该 +1(共用数组的版本 +2)
|
|
448
|
+
const spriteHost = document.createElement('div')
|
|
449
|
+
document.querySelector('#map').append(spriteHost)
|
|
450
|
+
const spriteSymbol = { type: 'svg', path: 'M12 1.5 L22.5 12 L12 22.5 L1.5 12 Z', viewBox: [0, 0, 24, 24] }
|
|
451
|
+
const spriteProbe = createGisView(spriteHost, {
|
|
452
|
+
points: [
|
|
453
|
+
{ id: 'sprite-0', x: 0, y: 0 },
|
|
454
|
+
{ id: 'sprite-1', x: 60, y: 0 },
|
|
455
|
+
{ id: 'sprite-2', x: 120, y: 0 }
|
|
456
|
+
],
|
|
457
|
+
width: 320,
|
|
458
|
+
height: 200,
|
|
459
|
+
pointStyle: { symbol: spriteSymbol, size: 10 },
|
|
460
|
+
pointSelectStyle: { color: '#e0592e' },
|
|
461
|
+
// 抬层会另开一层分组,这里只量「新建一组」这一件事
|
|
462
|
+
selectionOnTop: false
|
|
463
|
+
})
|
|
464
|
+
const spriteReadyDeadline = now() + 8000
|
|
465
|
+
while (!spriteProbe.isLayoutReady() && now() < spriteReadyDeadline) {
|
|
466
|
+
await sleep(20)
|
|
467
|
+
}
|
|
468
|
+
await sleep(300)
|
|
469
|
+
spriteProbe.selectFeatures(['sprite-0'])
|
|
470
|
+
await sleep(500)
|
|
471
|
+
spriteProbe.selectFeatures(null)
|
|
472
|
+
await sleep(200)
|
|
473
|
+
spriteProbe.refresh()
|
|
474
|
+
await sleep(120)
|
|
475
|
+
spriteProbe.refresh()
|
|
476
|
+
await sleep(120)
|
|
477
|
+
const spriteCallsBefore = spriteProbe.getStats().drawCalls
|
|
478
|
+
spriteProbe.selectFeatures(['sprite-0'])
|
|
479
|
+
await sleep(200)
|
|
480
|
+
const spriteCallsAfter = spriteProbe.getStats().drawCalls
|
|
481
|
+
check('贴图增量建组只多一次绘制调用(编号表与绘制序分开,新建的组不会被画两遍)', spriteCallsAfter - spriteCallsBefore === 1, `${spriteCallsBefore} → ${spriteCallsAfter}`)
|
|
482
|
+
spriteProbe.destroy()
|
|
483
|
+
spriteHost.remove()
|
|
484
|
+
|
|
485
|
+
/* ---------------- 线路批次的视口裁剪:放大之后只铺视野里那一片 ---------------- */
|
|
486
|
+
// 线路放大之后绝大多数在视野之外,铺进实例批次就是为每一条白付一次顶点变换与裁剪;
|
|
487
|
+
// 这一层按「视口 + 线宽余量」把批次裁到视野里那一片(逐段判定:包围盒对又长又斜的线几乎筛不掉)。
|
|
488
|
+
// 两条断言:① 放大后批次条数跟着缩(读数 lineInstances);② 视野挪回来画面一个像素都不差。
|
|
489
|
+
{
|
|
490
|
+
const shot = async () => {
|
|
491
|
+
const url = await big.exportImage({ includeLabels: false, scale: 1 })
|
|
492
|
+
const image = new Image()
|
|
493
|
+
await new Promise((resolve, reject) => {
|
|
494
|
+
image.onload = resolve
|
|
495
|
+
image.onerror = () => reject(new Error('导出图片加载失败'))
|
|
496
|
+
image.src = url
|
|
497
|
+
})
|
|
498
|
+
const buffer = document.createElement('canvas')
|
|
499
|
+
buffer.width = image.width
|
|
500
|
+
buffer.height = image.height
|
|
501
|
+
const context = buffer.getContext('2d')
|
|
502
|
+
context.drawImage(image, 0, 0)
|
|
503
|
+
return context.getImageData(0, 0, buffer.width, buffer.height).data
|
|
504
|
+
}
|
|
505
|
+
// 放大要挑一个「真有线经过」的节点当中心:线路只连 points[0..lineCount),clearPoint 落在后半段,
|
|
506
|
+
// 直接拿它当中心的话 40 倍视野里一条线都没有(读数是 0,看着反倒像裁剪裁坏了)。
|
|
507
|
+
const lineTarget = points[clearPoint % lineCount]
|
|
508
|
+
big.fitView(0)
|
|
509
|
+
big.refresh()
|
|
510
|
+
const fitCamera = big.getCamera()
|
|
511
|
+
const fullInstances = big.getStats().lineInstances
|
|
512
|
+
const before = await shot()
|
|
513
|
+
big.setCamera({ center: [lineTarget.x, lineTarget.y], scale: 10 })
|
|
514
|
+
big.refresh()
|
|
515
|
+
const zoomInstances = big.getStats().lineInstances
|
|
516
|
+
check('视口裁剪:放大到只看得见一小片之后,线路批次只铺视野里那一片(lineInstances 跟着缩)',
|
|
517
|
+
zoomInstances > 0 && zoomInstances * 4 < fullInstances, `整份 ${fullInstances} 段 → 放大后 ${zoomInstances} 段`)
|
|
518
|
+
big.setCamera(fitCamera)
|
|
519
|
+
big.refresh()
|
|
520
|
+
const backInstances = big.getStats().lineInstances
|
|
521
|
+
check('视口裁剪:视野挪回去之后线路又全铺回来(条数回到整份)', backInstances === fullInstances, `${fullInstances} → ${zoomInstances} → ${backInstances}`)
|
|
522
|
+
const after = await shot()
|
|
523
|
+
let diffPixels = 0
|
|
524
|
+
for (let index = 0; index < after.length; index += 4) {
|
|
525
|
+
if (before[index] !== after[index] || before[index + 1] !== after[index + 1] || before[index + 2] !== after[index + 2]) {
|
|
526
|
+
diffPixels += 1
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
check('视口裁剪:裁剪只是不铺、不是不画 —— 转一圈回来整幅像素一模一样', diffPixels === 0, `差异像素 ${diffPixels} / ${after.length / 4}`)
|
|
530
|
+
diag('视口裁剪', `整份 ${fullInstances} 段 / 放大后 ${zoomInstances} 段 / 回来 ${backInstances} 段,像素差 ${diffPixels}`)
|
|
531
|
+
|
|
532
|
+
/* ---------------- 线路几何的定向更新:能就地补的就地补,补不了的攒到画之前重铺一次 ---------------- */
|
|
533
|
+
// 改一条线的路径有两种形态:顶点数没变(拖拐点那种)按槽位就地写,批次条数一个都不动;
|
|
534
|
+
// 顶点数变了(加 / 删拐点)段数对不上,只能整批重铺 —— 但重铺记在账上,一帧里连着改几十条也只重铺一次(见 15.63)。
|
|
535
|
+
// 这里量的是账本本身:读数来自 lineInstances,不读像素也不量时间。
|
|
536
|
+
{
|
|
537
|
+
const geometryHost = document.createElement('div')
|
|
538
|
+
document.querySelector('#map').append(geometryHost)
|
|
539
|
+
const geometryCount = 200
|
|
540
|
+
const geometryLines = new Array(geometryCount)
|
|
541
|
+
for (let index = 0; index < geometryCount; index += 1) {
|
|
542
|
+
geometryLines[index] = { id: `g${index}`, path: [[0, index * 4], [400, index * 4]], color: '#2f6fd0', width: 2 }
|
|
543
|
+
}
|
|
544
|
+
const geometry = createGisView(geometryHost, { points: [], lines: geometryLines, width: 640, height: 380, quality: 'high' })
|
|
545
|
+
geometry.fitView()
|
|
546
|
+
geometry.refresh()
|
|
547
|
+
const geometryBefore = geometry.getStats().lineInstances
|
|
548
|
+
check('线路几何:200 条 path 声明的线路都铺进了批次', geometryBefore === geometryCount, `${geometryBefore} 段`)
|
|
549
|
+
for (const line of geometryLines) {
|
|
550
|
+
geometry.updateLine(line.id, { path: [[0, 0], [200, 0]] })
|
|
551
|
+
}
|
|
552
|
+
check('线路几何(顶点数不变):按槽位就地补,批次条数与重铺前一致',
|
|
553
|
+
geometry.getStats().lineInstances === geometryBefore, `${geometryBefore} → ${geometry.getStats().lineInstances}`)
|
|
554
|
+
for (const line of geometryLines) {
|
|
555
|
+
geometry.updateLine(line.id, { path: [[0, 0], [200, 0], [200, 60]] })
|
|
556
|
+
}
|
|
557
|
+
const geometryPending = geometry.getStats().lineInstances
|
|
558
|
+
check('线路几何(顶点数变了):重铺记在账上,改完这一刻批次还没动(一帧里连着改只重铺一次)',
|
|
559
|
+
geometryPending === geometryBefore, `${geometryPending} / ${geometryBefore}`)
|
|
560
|
+
geometry.refresh({ reflow: false })
|
|
561
|
+
const geometryRebuilt = geometry.getStats().lineInstances
|
|
562
|
+
check('线路几何(顶点数变了):画之前那一趟一次把 200 条都补上(每条多一段)',
|
|
563
|
+
geometryRebuilt === geometryBefore + geometryCount, `${geometryPending} → ${geometryRebuilt}(期望 ${geometryBefore + geometryCount})`)
|
|
564
|
+
for (const line of geometryLines) {
|
|
565
|
+
geometry.updateLine(line.id, { path: [[0, 0], [200, 0]] })
|
|
566
|
+
}
|
|
567
|
+
geometry.refresh({ reflow: false })
|
|
568
|
+
check('线路几何(段数换回去):批次条数回到原样(两条路的账本都没错位)',
|
|
569
|
+
geometry.getStats().lineInstances === geometryBefore, `${geometryRebuilt} → ${geometry.getStats().lineInstances}`)
|
|
570
|
+
// 视口外的线挪进视野:它的槽位记的是 -1,补丁补不了,画之前那一趟要把它铺回来
|
|
571
|
+
geometry.setCamera({ center: [200, 398], scale: 2 })
|
|
572
|
+
geometry.refresh()
|
|
573
|
+
geometry.updateLine('g0', { path: [[100000, 100000], [100010, 100000]] })
|
|
574
|
+
geometry.refresh({ reflow: false })
|
|
575
|
+
const geometryAway = geometry.getStats().lineInstances
|
|
576
|
+
geometry.updateLine('g0', { path: [[190, 398], [210, 398]] })
|
|
577
|
+
geometry.refresh({ reflow: false })
|
|
578
|
+
check('线路几何:挪出视口的线被裁掉、挪回来又铺上(画之前那一趟按新几何重算裁剪)',
|
|
579
|
+
geometry.getStats().lineInstances === geometryAway + 1, `${geometryAway} → ${geometry.getStats().lineInstances}`)
|
|
580
|
+
diag('线路几何的定向更新', `整批条目 ${geometryBefore} → 顶点数变了重铺后 ${geometryRebuilt} → 放大裁剪后 ${geometryAway}`)
|
|
581
|
+
geometry.destroy()
|
|
582
|
+
geometryHost.remove()
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/* ---------------- 标签层:一次定向更新的开销在哪 ---------------- */
|
|
587
|
+
// 「拆块」真正省得下来的是「每次交互要按视口重算的那部分」,而这一块几乎全在标签层:
|
|
588
|
+
// 上一轮把候选缩到视口里那一片(forEachPointInView),这一轮再把「量尺寸」与「摘节点」分开 ——
|
|
589
|
+
// dropLabel 会把节点从 DOM 里摘掉(remove 让布局作废),夹在量尺寸中间时这一批里每一条都要各自重排一次。
|
|
590
|
+
// 这里量一次「全在视口里」的代价,顺带盯住「定向更新不会让已经画出来的标签换人」这条不变量。
|
|
591
|
+
const labelHost = document.createElement('div')
|
|
592
|
+
document.querySelector('#map').append(labelHost)
|
|
593
|
+
const labelPoints = new Array(pointCount)
|
|
594
|
+
for (let index = 0; index < pointCount; index += 1) {
|
|
595
|
+
labelPoints[index] = { id: `q${index}`, x: (index % 200) * 20, y: Math.floor(index / 200) * 20, name: `标签节点 ${index}`, size: 6 }
|
|
596
|
+
}
|
|
597
|
+
const labelView = createGisView(labelHost, { points: labelPoints, lines: [], width: 640, height: 380, quality: 'high', label: { content: '{name}', maxCount: 200 } })
|
|
598
|
+
// 改配置只把排版挂到下一帧,这里先做一次定向更新把标签铺出来,量的才是「已经有标签之后」的重排
|
|
599
|
+
labelView.updatePoint(labelPoints[5000].id, { status: 'warm' })
|
|
600
|
+
// 排版挂在 rAF 上:先等一帧让第一批标签落进 DOM,下面两条断言才有东西可盯(否则是空转)
|
|
601
|
+
await sleep(120)
|
|
602
|
+
const labelSignature = () => [...labelHost.querySelectorAll('.gis-view__label')]
|
|
603
|
+
.map(node => `${node.dataset.labelIndex ?? ''}|${node.textContent}`)
|
|
604
|
+
.sort()
|
|
605
|
+
.join(',')
|
|
606
|
+
// 「量尺寸与摘节点分开」改的只是「什么时候把节点摘掉」,已经画出来的标签一条都不该少,
|
|
607
|
+
// 所以这里盯的是「上一轮那一批还在不在」(多补进来的新标签是允许的:候选窗口会往前推进)
|
|
608
|
+
const labelsBefore = labelSignature() === '' ? [] : labelSignature().split(',')
|
|
609
|
+
const labelUpdateStarted = now()
|
|
610
|
+
for (let round = 0; round < 10; round += 1) {
|
|
611
|
+
labelView.updatePoint(labelPoints[5000].id, { status: `s${round}` })
|
|
612
|
+
}
|
|
613
|
+
const labelsAfter = new Set(labelSignature() === '' ? [] : labelSignature().split(','))
|
|
614
|
+
diag('两万点全在视口里 · 带标签时的一次 updatePoint', `${((now() - labelUpdateStarted) / 10).toFixed(2)} ms / 次 · DOM 标签 ${labelsAfter.size} 个(这一轮之前是 55 ms 上下)`)
|
|
615
|
+
check('定向更新之后,上一轮画出来的标签一条不少', labelsBefore.every(key => labelsAfter.has(key)), `${labelsBefore.length} 条里留下 ${labelsBefore.filter(key => labelsAfter.has(key)).length} 条`)
|
|
616
|
+
// 「尺寸按内容签名缓存 + 位置从 transform 里算」之后,整层重排只在签名第一次出现时量一次布局:
|
|
617
|
+
// 这里用「改样式 → 当场重排」当确定性触发器(编辑面板回显 / 拖完标签都是这一条路),
|
|
618
|
+
// 量测次数应当只跟新出现的签名条数有关,不再跟候选窗口的条数成正比 ——
|
|
619
|
+
// 从前的写法每轮要为 800 多条候选各读一次 getBoundingClientRect,一次重排 30 ms 上下
|
|
620
|
+
const labelMeasureStats = { label: 0, total: 0 }
|
|
621
|
+
const labelRelayoutRounds = []
|
|
622
|
+
const labelRelayoutMs = []
|
|
623
|
+
const nativeMeasureRect = Element.prototype.getBoundingClientRect
|
|
624
|
+
Element.prototype.getBoundingClientRect = function (...args) {
|
|
625
|
+
labelMeasureStats.total += 1
|
|
626
|
+
if (this.classList?.contains('gis-view__label')) {
|
|
627
|
+
labelMeasureStats.label += 1
|
|
628
|
+
}
|
|
629
|
+
return nativeMeasureRect.apply(this, args)
|
|
630
|
+
}
|
|
631
|
+
try {
|
|
632
|
+
for (let round = 0; round < 5; round += 1) {
|
|
633
|
+
labelMeasureStats.label = 0
|
|
634
|
+
const relayoutStarted = now()
|
|
635
|
+
// 同一份样式补丁反复回显:整层重排照排,内容却一条都没变,一条都不该再量
|
|
636
|
+
labelView.updateLabel('point', labelPoints[5000].id, { style: { color: '#123456' } })
|
|
637
|
+
labelRelayoutMs.push(now() - relayoutStarted)
|
|
638
|
+
labelRelayoutRounds.push(labelMeasureStats.label)
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
finally {
|
|
642
|
+
Element.prototype.getBoundingClientRect = nativeMeasureRect
|
|
643
|
+
}
|
|
644
|
+
const labelMeasureTotal = labelRelayoutRounds.reduce((sum, value) => sum + value, 0)
|
|
645
|
+
const labelRelayoutAvg = labelRelayoutMs.reduce((sum, value) => sum + value, 0) / labelRelayoutMs.length
|
|
646
|
+
diag('五轮强制重排(改样式)· 量测读数', `逐轮标签量测 ${labelRelayoutRounds.join(' / ')} 次 · 同步耗时 ${labelRelayoutAvg.toFixed(2)} ms / 次(旧写法是 832 次 / 轮、30 ms 上下)`)
|
|
647
|
+
check('整层重排只在新签名上量布局(五轮合计 ≤ 40 次,旧写法 832 次 / 轮)', labelMeasureTotal <= 40, `五轮合计 ${labelMeasureTotal} 次`)
|
|
648
|
+
check('内容没变的那些轮次一次布局都不读(第 3 轮起每轮 0 次)', labelRelayoutRounds.slice(2).every(value => value === 0), `第 3~5 轮 ${labelRelayoutRounds.slice(2).join(' / ')} 次`)
|
|
649
|
+
// 这一轮:右锚点(`translate(0, -50%)`)的避让盒改成纯算术。从前解析器只认「两段都是百分比」,
|
|
650
|
+
// 默认右锚点 `translate(0, -50%)` 解析不出来 —— 尺寸命中缓存、盒子又作废的那些标签,
|
|
651
|
+
// 每轮重排都要在避让那一步再真量一次(一次读布局一百多微秒),节点在池子里转一圈回来又是一批。
|
|
652
|
+
// 这里用「4 万点 + 4 万条长线、放大之后连续整层重排」量这组读数:第 1 轮把新签名量完之后,
|
|
653
|
+
// 内容 / 样式 / 视口都没变的轮次既不该再读布局,也不该有哪条标签在同一轮里被量第二次。
|
|
654
|
+
const anchorHost = document.createElement('div')
|
|
655
|
+
document.body.append(anchorHost)
|
|
656
|
+
const anchorPoints = new Array(40000)
|
|
657
|
+
for (let index = 0; index < anchorPoints.length; index += 1) {
|
|
658
|
+
anchorPoints[index] = { id: `a${index}`, x: (index % 400) * 10, y: Math.floor(index / 400) * 10, name: `锚点 ${index}`, size: 5 }
|
|
659
|
+
}
|
|
660
|
+
let anchorSeed = 12345
|
|
661
|
+
const anchorRandom = () => { anchorSeed = (anchorSeed * 1103515245 + 12345) & 0x7fffffff; return anchorSeed / 0x7fffffff }
|
|
662
|
+
const anchorLines = new Array(40000)
|
|
663
|
+
for (let index = 0; index < anchorLines.length; index += 1) {
|
|
664
|
+
anchorLines[index] = { id: `ax${index}`, start: [anchorRandom() * 4000, anchorRandom() * 2500], end: [anchorRandom() * 4000, anchorRandom() * 2500], name: `长线 ${index}` }
|
|
665
|
+
}
|
|
666
666
|
const anchorView = createGisView(anchorHost, { points: anchorPoints, lines: anchorLines, width: 900, height: 560, progressiveInit: false, labelOptimize: true, label: { content: '{name}', maxCount: 200 }, lineLabel: { maxCount: 60 } })
|
|
667
|
-
await sleep(200)
|
|
667
|
+
await sleep(200)
|
|
668
668
|
for (let step = 0; step < 6; step += 1) {
|
|
669
669
|
anchorView.setCamera({ center: [1000 + step * 120, 700 + step * 60], scale: 1.4 })
|
|
670
670
|
// 用 rAF 等这一步:相机每动一下标签层就照着新视口收一遍候选
|
|
@@ -695,771 +695,771 @@
|
|
|
695
695
|
if (this.classList?.contains('gis-view__label')) {
|
|
696
696
|
anchorStats.reads += 1
|
|
697
697
|
// 按标签身份(类型 + 行号 + 标签名)去重:标签节点是循环复用的,按节点去重会把「同一格换了条标签」误判成重复量
|
|
698
|
-
const anchorKey = `${this.dataset.labelKind}#${this.dataset.labelIndex}#${this.dataset.labelLabel}`
|
|
699
|
-
if (anchorSeen.has(anchorKey)) {
|
|
700
|
-
anchorStats.duplicates += 1
|
|
701
|
-
}
|
|
702
|
-
anchorSeen.add(anchorKey)
|
|
703
|
-
}
|
|
704
|
-
return nativeAnchorRect.apply(this, args)
|
|
705
|
-
}
|
|
698
|
+
const anchorKey = `${this.dataset.labelKind}#${this.dataset.labelIndex}#${this.dataset.labelLabel}`
|
|
699
|
+
if (anchorSeen.has(anchorKey)) {
|
|
700
|
+
anchorStats.duplicates += 1
|
|
701
|
+
}
|
|
702
|
+
anchorSeen.add(anchorKey)
|
|
703
|
+
}
|
|
704
|
+
return nativeAnchorRect.apply(this, args)
|
|
705
|
+
}
|
|
706
706
|
const anchorRound = () => {
|
|
707
707
|
anchorStats.reads = 0
|
|
708
708
|
anchorStats.duplicates = 0
|
|
709
709
|
anchorSeen.clear()
|
|
710
710
|
}
|
|
711
|
-
try {
|
|
712
|
-
// 跑 6 轮:换锚点(anchorFallback)/ 沿线滑动(slideFallback)会让某一轮补画几十条
|
|
713
|
-
// 从没出现过的标签 —— 第一次出现必须量一次尺寸,这一笔有限、下一轮起回零;
|
|
714
|
-
// 最后三轮盯的就是「收敛之后一次布局都不读」
|
|
715
|
-
for (let round = 0; round < 6; round += 1) {
|
|
716
|
-
anchorRound()
|
|
717
|
-
anchorView.updateOptions({ label: { content: '{name}', maxCount: 200 }, lineLabel: { maxCount: 60 } })
|
|
711
|
+
try {
|
|
712
|
+
// 跑 6 轮:换锚点(anchorFallback)/ 沿线滑动(slideFallback)会让某一轮补画几十条
|
|
713
|
+
// 从没出现过的标签 —— 第一次出现必须量一次尺寸,这一笔有限、下一轮起回零;
|
|
714
|
+
// 最后三轮盯的就是「收敛之后一次布局都不读」
|
|
715
|
+
for (let round = 0; round < 6; round += 1) {
|
|
716
|
+
anchorRound()
|
|
717
|
+
anchorView.updateOptions({ label: { content: '{name}', maxCount: 200 }, lineLabel: { maxCount: 60 } })
|
|
718
718
|
anchorReads.push(anchorStats.reads)
|
|
719
719
|
anchorDuplicates.push(anchorStats.duplicates)
|
|
720
|
-
}
|
|
721
|
-
// 改一条视口里的标签(下标 = 76 行 × 400 列 + 150 → 世界坐标 1500, 760):这一轮同样不该整层重量
|
|
722
|
-
anchorRound()
|
|
723
|
-
anchorView.updatePoint('a' + (76 * 400 + 150), { name: '改过的名字' })
|
|
724
|
-
anchorReads.push(anchorStats.reads)
|
|
725
|
-
anchorDuplicates.push(anchorStats.duplicates)
|
|
726
|
-
}
|
|
727
|
-
finally {
|
|
728
|
-
Element.prototype.getBoundingClientRect = nativeAnchorRect
|
|
729
|
-
}
|
|
720
|
+
}
|
|
721
|
+
// 改一条视口里的标签(下标 = 76 行 × 400 列 + 150 → 世界坐标 1500, 760):这一轮同样不该整层重量
|
|
722
|
+
anchorRound()
|
|
723
|
+
anchorView.updatePoint('a' + (76 * 400 + 150), { name: '改过的名字' })
|
|
724
|
+
anchorReads.push(anchorStats.reads)
|
|
725
|
+
anchorDuplicates.push(anchorStats.duplicates)
|
|
726
|
+
}
|
|
727
|
+
finally {
|
|
728
|
+
Element.prototype.getBoundingClientRect = nativeAnchorRect
|
|
729
|
+
}
|
|
730
730
|
diag('放大后连续整层重排 · 标签量测读数', `逐轮 ${anchorReads.join(' / ')} 次 · 同一轮里重复量 ${anchorDuplicates.join(' / ')} 条(按标签身份去重;旧写法第 1 轮 2662 次里有 1235 次是把同一条标签又量了一遍)`)
|
|
731
|
-
check('稳态重排(后 3 轮)一次布局都不读(避让盒全走算术)', anchorReads.slice(3, 6).every(value => value === 0), `第 4~6 轮 ${anchorReads.slice(3, 6).join(' / ')} 次`)
|
|
732
|
-
// 换落点那条路补画的新标签都是「第一次出现」,各量一次尺寸就进缓存;整段合计要有限,
|
|
733
|
-
// 真退回「每轮把所有标签都真量一遍」的老写法时这里会立刻爆掉(旧写法一轮 1299 ~ 2662 次)
|
|
734
|
-
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(' / ')})`)
|
|
735
|
-
check('整层重排里没有哪条标签(按身份)被量第二次(避让盒不再退回真量一次)', anchorDuplicates.every(value => value === 0), `逐轮重复 ${anchorDuplicates.join(' / ')} 条`)
|
|
731
|
+
check('稳态重排(后 3 轮)一次布局都不读(避让盒全走算术)', anchorReads.slice(3, 6).every(value => value === 0), `第 4~6 轮 ${anchorReads.slice(3, 6).join(' / ')} 次`)
|
|
732
|
+
// 换落点那条路补画的新标签都是「第一次出现」,各量一次尺寸就进缓存;整段合计要有限,
|
|
733
|
+
// 真退回「每轮把所有标签都真量一遍」的老写法时这里会立刻爆掉(旧写法一轮 1299 ~ 2662 次)
|
|
734
|
+
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(' / ')})`)
|
|
735
|
+
check('整层重排里没有哪条标签(按身份)被量第二次(避让盒不再退回真量一次)', anchorDuplicates.every(value => value === 0), `逐轮重复 ${anchorDuplicates.join(' / ')} 条`)
|
|
736
736
|
check('改一条可见标签之后的定向重排也不读布局、不重复量', anchorDuplicates[6] === 0 && anchorReads[6] === 0, `读数 ${anchorReads[6]} 次 · 重复 ${anchorDuplicates[6]} 条`)
|
|
737
|
-
anchorView.destroy()
|
|
738
|
-
anchorHost.remove()
|
|
739
|
-
labelView.destroy()
|
|
740
|
-
labelHost.remove()
|
|
741
|
-
big.setQuality('lean')
|
|
742
|
-
check('setQuality 换档:读数与回调一起到(lean 关掉光晕与装饰)', big.getQuality().name === 'lean' && big.getQuality().ornament === false && qualityChanges.includes('lean'), `${big.getQuality().name} / ${qualityChanges.join(',')}`)
|
|
743
|
-
big.setQuality('fast')
|
|
744
|
-
big.refresh()
|
|
745
|
-
const fastRatio = big.getStats().pixelRatio
|
|
746
|
-
check('fast 档按更低的分辨率渲染(pixelRatio 跟着降)', fastRatio < 0.9 && Math.abs(fastRatio - 0.75) < 0.03, fastRatio.toFixed(3))
|
|
747
|
-
big.setQuality('high')
|
|
748
|
-
big.refresh()
|
|
749
|
-
check('切回 high:分辨率与装饰都回满', big.getStats().pixelRatio > 0.9 && big.getQuality().ornament === true, big.getStats().pixelRatio.toFixed(3))
|
|
750
|
-
const qualityEvents = []
|
|
751
|
-
big.on('qualityChange', state => qualityEvents.push(state.name))
|
|
752
|
-
big.setQuality('balanced')
|
|
753
|
-
check('qualityChange 事件与 onQualityChange 是同一份读数', qualityEvents.join(',') === 'balanced', qualityEvents.join(','))
|
|
754
|
-
|
|
755
|
-
/* ---------------- 追加式补点:addPoint 的增量路与整份重建等价 ---------------- */
|
|
756
|
-
// 逐个 addPoint 原来是「整份重排一次」;这一轮认出「数据末尾就多了这几条」,
|
|
757
|
-
// 只解析这几条、只往实例批次里补这几条(引擎的 appendPoints)。这里盯三件事:
|
|
758
|
-
// ① 与「同一份数据一开始就整份铺好」画出来的场景逐字段一致(含绘制次序)
|
|
759
|
-
// ② 追加的点真的进了场景、索引与标签层 ③ 单次代价明显低于一次整份重排(增量路没生效就会掉回原来的量级)
|
|
760
|
-
const appendCount = 20000
|
|
761
|
-
const appendPoints = new Array(appendCount)
|
|
762
|
-
for (let index = 0; index < appendCount; index += 1) {
|
|
763
|
-
appendPoints[index] = { id: `a${index}`, x: (index % 200) * 20, y: Math.floor(index / 200) * 20, color: '#4b8dff', size: 8 }
|
|
764
|
-
}
|
|
765
|
-
const appendCamera = { center: [1000, 1000], scale: 1 }
|
|
766
|
-
const appendedList = new Array(5)
|
|
767
|
-
for (let index = 0; index < appendedList.length; index += 1) {
|
|
768
|
-
appendedList[index] = { id: `a-new-${index}`, x: 1000 + index * 12, y: 1000 + index * 12, color: '#ff3300', size: 8 }
|
|
769
|
-
}
|
|
770
|
-
const appendHost = document.createElement('div')
|
|
771
|
-
document.querySelector('#map').append(appendHost)
|
|
772
|
-
const appendView = createGisView(appendHost, { points: appendPoints, lines: [], width: 640, height: 380, quality: 'high' })
|
|
773
|
-
// 两万个点会走发散渲染:先等它铺完,追加才有「已经铺好的场景」可接
|
|
774
|
-
const appendDeadline = now() + 8000
|
|
775
|
-
while (!appendView.isLayoutReady() && now() < appendDeadline) {
|
|
776
|
-
await sleep(50)
|
|
777
|
-
}
|
|
778
|
-
appendView.setCamera(appendCamera)
|
|
779
|
-
// 单次代价按「最快的一次」判:整批平均值会被 GC 与同页别的大场景搅动,
|
|
780
|
-
// 而这条断言要看的是快路径有没有生效 —— 生效时最快那一次是零点几毫秒,
|
|
781
|
-
// 回落整份重建时每一次都在重排的量级上;平均值仍照常报在读数里
|
|
782
|
-
const appendStarted = now()
|
|
783
|
-
let appendFastestMs = Infinity
|
|
784
|
-
for (const item of appendedList) {
|
|
785
|
-
const callStarted = now()
|
|
786
|
-
appendView.addPoint(item)
|
|
787
|
-
appendFastestMs = Math.min(appendFastestMs, now() - callStarted)
|
|
788
|
-
}
|
|
789
|
-
const appendMs = (now() - appendStarted) / appendedList.length
|
|
790
|
-
const appendedScene = appendView.snapshotScene()
|
|
791
|
-
// 对照:同一份数据(含追加的那几条)一开始就整份铺好,相机对准同一个位置
|
|
792
|
-
const appendFullHost = document.createElement('div')
|
|
793
|
-
document.querySelector('#map').append(appendFullHost)
|
|
794
|
-
const appendFullView = createGisView(appendFullHost, { points: appendPoints.concat(appendedList), lines: [], width: 640, height: 380, quality: 'high' })
|
|
795
|
-
const appendFullDeadline = now() + 8000
|
|
796
|
-
while (!appendFullView.isLayoutReady() && now() < appendFullDeadline) {
|
|
797
|
-
await sleep(50)
|
|
798
|
-
}
|
|
799
|
-
appendFullView.setCamera(appendCamera)
|
|
800
|
-
const appendFullScene = appendFullView.snapshotScene()
|
|
801
|
-
const appendedText = JSON.stringify(appendedScene)
|
|
802
|
-
const appendFullText = JSON.stringify(appendFullScene)
|
|
803
|
-
const appendSame = appendedText === appendFullText
|
|
804
|
-
let appendDiff = `快照 ${appendedText.length} 字节,逐字段一致`
|
|
805
|
-
if (!appendSame) {
|
|
806
|
-
let at = 0
|
|
807
|
-
while (at < appendedText.length && appendedText[at] === appendFullText[at]) {
|
|
808
|
-
at += 1
|
|
809
|
-
}
|
|
810
|
-
appendDiff = `第 ${at} 个字符起不同:${appendedText.slice(Math.max(0, at - 30), at + 50)} / ${appendFullText.slice(Math.max(0, at - 30), at + 50)}`
|
|
811
|
-
}
|
|
812
|
-
check('追加式补点:新点进了场景(快照里数得出这几条)', appendedScene.points.filter(node => node.fill === 'rgba(255,51,0,1)').length === appendedList.length, `${appendedScene.points.length} 个点里 ${appendedScene.points.filter(node => node.fill === 'rgba(255,51,0,1)').length} 个新点`)
|
|
813
|
-
check('追加式补点:画出来的场景与整份重建逐字段一致(含绘制次序)', appendSame, appendDiff)
|
|
814
|
-
const appendSpot = appendView.project(appendedList[4].x, appendedList[4].y)
|
|
815
|
-
const appendHit = appendView.pick(appendSpot[0], appendSpot[1])
|
|
816
|
-
check('追加式补点:新点能被拾取(索引表也跟着补了)', appendHit?.item?.id === 'a-new-4', JSON.stringify({ id: appendHit?.item?.id }))
|
|
817
|
-
const appendLabelHost = document.createElement('div')
|
|
818
|
-
document.querySelector('#map').append(appendLabelHost)
|
|
819
|
-
const appendLabelView = createGisView(appendLabelHost, { points: [{ id: 'x1', x: 0, y: 0, name: '甲' }], lines: [], width: 640, height: 380, label: { content: '{name}' } })
|
|
820
|
-
appendLabelView.addPoint({ id: 'x2', x: 40, y: 0, name: '乙新增' })
|
|
821
|
-
await sleep(60)
|
|
822
|
-
const appendLabels = [...appendLabelHost.querySelectorAll('.gis-view__label')].map(node => node.textContent.trim())
|
|
823
|
-
check('追加式补点:新点的标签照常铺出来(标签层没被这条捷径跳过)', appendLabels.includes('乙新增'), appendLabels.join(' / '))
|
|
824
|
-
const appendRefreshStarted = now()
|
|
825
|
-
appendView.refresh()
|
|
826
|
-
const appendRebuildMs = now() - appendRefreshStarted
|
|
827
|
-
diag('追加 1 个节点(两万点场景)', `${appendMs.toFixed(2)} ms / 次(最快一次 ${appendFastestMs.toFixed(2)} ms)· 同一场景整份重排 ${appendRebuildMs.toFixed(1)} ms`)
|
|
828
|
-
check('追加一个节点的代价明显低于一次整份重排(增量路真的生效)', appendFastestMs * 5 < appendRebuildMs, `最快一次 ${appendFastestMs.toFixed(2)} ms · 平均 ${appendMs.toFixed(2)} ms vs 整份重排 ${appendRebuildMs.toFixed(1)} ms`)
|
|
829
|
-
appendLabelView.destroy()
|
|
830
|
-
appendLabelHost.remove()
|
|
831
|
-
appendFullView.destroy()
|
|
832
|
-
appendFullHost.remove()
|
|
833
|
-
appendView.destroy()
|
|
834
|
-
appendHost.remove()
|
|
835
|
-
|
|
836
|
-
/* ---------------- 删完立刻补:发散渲染还欠着账时的补点(见 16.60) ---------------- */
|
|
837
|
-
// 删除的增量路会把正在跑的发散渲染停掉、按挪过下标的解析结果重开一轮(见 16.60):
|
|
838
|
-
// 那一轮还没铺完时又来一条 addPoint,从前会掉回整份重解析(几十毫秒)。
|
|
839
|
-
// 这一轮认下这条路:先停掉那一轮、把末尾这几条补进解析结果与批次、再按新的解析结果重开一轮。
|
|
840
|
-
// 点数要留在发散渲染的阈值(两万)之上:删掉一条之后重开的那一轮才真的还在跑
|
|
841
|
-
// 盯三件事:① 删完之后场景确实处在「只铺到一段」的状态(这条断言守的就是那条路,退化成整份重建时它会先红)
|
|
842
|
-
// ② 补进来的点照常进场景、铺完之后与「一开始就整份铺好」逐字段一致 ③ 代价仍是增量级
|
|
843
|
-
const resumeCount = 22000
|
|
844
|
-
const resumePoints = new Array(resumeCount)
|
|
845
|
-
for (let index = 0; index < resumeCount; index += 1) {
|
|
846
|
-
resumePoints[index] = { id: `r${index}`, x: (index % 200) * 16, y: Math.floor(index / 200) * 16, size: 6 }
|
|
847
|
-
}
|
|
848
|
-
// 线路那一半是有意加的:回落整份重建时它要多跑一趟几万条线的重解析,
|
|
849
|
-
// 「补点仍是增量级」这一条才有足够宽的比值可判(端点都落在前五千个点上,删的 r5000 不在其中)
|
|
850
|
-
const resumeLines = new Array(12000)
|
|
851
|
-
for (let index = 0; index < resumeLines.length; index += 1) {
|
|
852
|
-
resumeLines[index] = { id: `rl${index}`, from: `r${index % 5000}`, to: `r${(index * 3 + 7) % 5000}` }
|
|
853
|
-
}
|
|
854
|
-
const resumeCamera = { center: [1600, 800], scale: 1 }
|
|
855
|
-
const resumeHost = document.createElement('div')
|
|
856
|
-
document.querySelector('#map').append(resumeHost)
|
|
857
|
-
const resumeView = createGisView(resumeHost, { points: resumePoints, lines: resumeLines, width: 640, height: 380, quality: 'high' })
|
|
858
|
-
const resumeDeadline = now() + 8000
|
|
859
|
-
while (!resumeView.isLayoutReady() && now() < resumeDeadline) {
|
|
860
|
-
await sleep(50)
|
|
861
|
-
}
|
|
862
|
-
resumeView.setCamera(resumeCamera)
|
|
863
|
-
// 三轮「删一条 → 立刻补一条」。删除走完时发散渲染已经重开了一轮、场景又只铺到一段,
|
|
864
|
-
// 紧接着到的那条补丁正是在这种时候来的 —— 单次量噪声大(第一次还冷着),按最快的一次判快路径
|
|
865
|
-
const resumeRemoved = []
|
|
866
|
-
const resumeAddedList = []
|
|
867
|
-
let resumedPending = true
|
|
868
|
-
let resumeFastestMs = Infinity
|
|
869
|
-
for (let round = 0; round < 3; round += 1) {
|
|
870
|
-
resumeRemoved.push(`r${5000 + round}`)
|
|
871
|
-
resumeView.removePoints(`r${5000 + round}`)
|
|
872
|
-
resumedPending = resumedPending && !resumeView.isLayoutReady()
|
|
873
|
-
const added = { id: `r-new-${round}`, x: 1650 + round * 12, y: 850 + round * 12, color: '#ff3300', size: 8 }
|
|
874
|
-
resumeAddedList.push(added)
|
|
875
|
-
const started = now()
|
|
876
|
-
resumeView.addPoint(added)
|
|
877
|
-
resumeFastestMs = Math.min(resumeFastestMs, now() - started)
|
|
878
|
-
const settled = now() + 8000
|
|
879
|
-
while (!resumeView.isLayoutReady() && now() < settled) {
|
|
880
|
-
await sleep(50)
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
const resumeScene = resumeView.snapshotScene()
|
|
884
|
-
// 对照:一开始就是「删掉那三条、补上这三条」的那一份数据,整份铺好
|
|
885
|
-
const resumeRemovedSet = new Set(resumeRemoved)
|
|
886
|
-
const resumeFullPoints = resumePoints.filter(point => !resumeRemovedSet.has(point.id)).concat(resumeAddedList)
|
|
887
|
-
const resumeFullHost = document.createElement('div')
|
|
888
|
-
document.querySelector('#map').append(resumeFullHost)
|
|
889
|
-
const resumeFullView = createGisView(resumeFullHost, { points: resumeFullPoints, lines: resumeLines, width: 640, height: 380, quality: 'high' })
|
|
890
|
-
const resumeFullDeadline = now() + 8000
|
|
891
|
-
while (!resumeFullView.isLayoutReady() && now() < resumeFullDeadline) {
|
|
892
|
-
await sleep(50)
|
|
893
|
-
}
|
|
894
|
-
resumeFullView.setCamera(resumeCamera)
|
|
895
|
-
const resumeFullScene = resumeFullView.snapshotScene()
|
|
896
|
-
const resumeText = JSON.stringify(resumeScene)
|
|
897
|
-
const resumeFullText = JSON.stringify(resumeFullScene)
|
|
898
|
-
let resumeDiff = `快照 ${resumeText.length} 字节,逐字段一致`
|
|
899
|
-
if (resumeText !== resumeFullText) {
|
|
900
|
-
let at = 0
|
|
901
|
-
while (at < resumeText.length && resumeText[at] === resumeFullText[at]) {
|
|
902
|
-
at += 1
|
|
903
|
-
}
|
|
904
|
-
resumeDiff = `第 ${at} 个字符起不同:${resumeText.slice(Math.max(0, at - 30), at + 50)} / ${resumeFullText.slice(Math.max(0, at - 30), at + 50)}`
|
|
905
|
-
}
|
|
906
|
-
const resumeRefreshStarted = now()
|
|
907
|
-
resumeView.refresh()
|
|
908
|
-
const resumeRebuildMs = now() - resumeRefreshStarted
|
|
909
|
-
diag('删完立刻补 1 个节点(两万二千点 + 一万二千线)', `最快一次 ${resumeFastestMs.toFixed(2)} ms · 同一场景整份重排 ${resumeRebuildMs.toFixed(1)} ms`)
|
|
910
|
-
check('删完立刻补:删除之后场景确实回到「只铺到一段」的发散渲染状态(这条断言守的就是那条路)', resumedPending, `isLayoutReady=${resumeView.isLayoutReady()}`)
|
|
911
|
-
check('删完立刻补:铺完之后画出来的场景与整份重建逐字段一致(含新点的颜色与次序)', resumeText === resumeFullText, resumeDiff)
|
|
912
|
-
check('删完立刻补:发散渲染欠着账时补点仍是增量级(没回落整份重解析)', resumeFastestMs * 5 < resumeRebuildMs, `最快一次 ${resumeFastestMs.toFixed(2)} ms vs 整份重排 ${resumeRebuildMs.toFixed(1)} ms`)
|
|
913
|
-
const resumeLast = resumeAddedList[resumeAddedList.length - 1]
|
|
914
|
-
const resumeSpot = resumeView.project(resumeLast.x, resumeLast.y)
|
|
915
|
-
const resumeHit = resumeView.pick(resumeSpot[0], resumeSpot[1])
|
|
916
|
-
check('删完立刻补:补进来的点能被拾取(索引表跟着补了)', resumeHit?.item?.id === resumeLast.id, JSON.stringify({ id: resumeHit?.item?.id ?? null, 应为: resumeLast.id }))
|
|
917
|
-
resumeFullView.destroy()
|
|
918
|
-
resumeFullHost.remove()
|
|
919
|
-
resumeView.destroy()
|
|
920
|
-
resumeHost.remove()
|
|
921
|
-
|
|
922
|
-
/* ---------------- 线路动点在画画时补点:插段没打乱动点的槽位账(见 16.60) ---------------- */
|
|
923
|
-
// appendPoints 把新点插在「点符号」那一段的末尾,后面几段(背景点 / 动点 / 装饰 / 抬层点)跟着往后挪:
|
|
924
|
-
// 动点的槽位表从前没跟着挪,每帧按弧长写回时就落到别人身上(新点被顶到动点的位置上)。
|
|
925
|
-
// 这里用「带跑动动画的场景补一个点」守住这一趟的账:场景与新点都要对得上整份重建那一份
|
|
926
|
-
const effectPoints = []
|
|
927
|
-
for (let index = 0; index < 200; index += 1) {
|
|
928
|
-
effectPoints[index] = { id: `e${index}`, x: (index % 20) * 30, y: Math.floor(index / 20) * 30, size: 6 }
|
|
929
|
-
}
|
|
930
|
-
const effectHost = document.createElement('div')
|
|
931
|
-
document.querySelector('#map').append(effectHost)
|
|
932
|
-
const effectView = createGisView(effectHost, {
|
|
933
|
-
points: effectPoints,
|
|
934
|
-
lines: [{ id: 'ef1', from: 'e0', to: 'e199', effect: true }],
|
|
935
|
-
effects: { line: { symbol: 'square', size: 10 } },
|
|
936
|
-
width: 640, height: 380, quality: 'high'
|
|
937
|
-
})
|
|
938
|
-
const effectAdded = { id: 'e-new', x: 15, y: 15, color: '#00ff88', size: 10 }
|
|
939
|
-
await sleep(80)
|
|
940
|
-
const effectStarted = now()
|
|
941
|
-
effectView.addPoint(effectAdded)
|
|
942
|
-
const effectAddMs = now() - effectStarted
|
|
943
|
-
await sleep(120)
|
|
944
|
-
const effectScene = effectView.snapshotScene()
|
|
945
|
-
const effectFullHost = document.createElement('div')
|
|
946
|
-
document.querySelector('#map').append(effectFullHost)
|
|
947
|
-
const effectFullView = createGisView(effectFullHost, {
|
|
948
|
-
points: effectPoints.concat([effectAdded]),
|
|
949
|
-
lines: [{ id: 'ef1', from: 'e0', to: 'e199', effect: true }],
|
|
950
|
-
effects: { line: { symbol: 'square', size: 10 } },
|
|
951
|
-
width: 640, height: 380, quality: 'high'
|
|
952
|
-
})
|
|
953
|
-
await sleep(120)
|
|
954
|
-
const effectFullScene = effectFullView.snapshotScene()
|
|
955
|
-
diag('带线路动点的场景补 1 个节点', `${effectAddMs.toFixed(2)} ms`)
|
|
956
|
-
check('线路动点在画画时补点:动点与节点都在画(这一条守的是「插段之后账没乱」)', effectScene.points.filter(node => node.fill === 'rgba(0,255,136,1)').length === 1, `${effectScene.points.length} 个点里 ${effectScene.points.filter(node => node.fill === 'rgba(0,255,136,1)').length} 个新点`)
|
|
957
|
-
check('线路动点在画画时补点:画出来的场景与整份重建逐字段一致', JSON.stringify(effectScene) === JSON.stringify(effectFullScene), `新点 ${effectScene.points.length} / 对照 ${effectFullScene.points.length}`)
|
|
958
|
-
effectFullView.destroy()
|
|
959
|
-
effectFullHost.remove()
|
|
960
|
-
effectView.destroy()
|
|
961
|
-
effectHost.remove()
|
|
962
|
-
|
|
963
|
-
/* ---------------- 删除的增量路:removePoints 的增量路与整份重建等价 ---------------- */
|
|
964
|
-
// 删节点从前是「整份重排一次」;这一轮认出「新数组就是上一轮那批删掉几条」,
|
|
965
|
-
// 就地过滤解析结果、槽位表跟着搬一次,线路批次整个留着(引擎的 16.37)。这里盯五件事:
|
|
966
|
-
// ① 与「一开始就用删减后的数据铺好」画出来的场景逐字段一致(含删掉的线路不再画)
|
|
967
|
-
// ② 被删的节点拾取不到、邻点按 id 仍能拾取、getPoint 的下标按新数组对齐
|
|
968
|
-
// ③ 筛选的隐藏集合与选中镜像平移到新下标
|
|
969
|
-
// ④ 标签层没被这条捷径跳过
|
|
970
|
-
// ⑤ 单次删除明显快于「整份重建那条路」(增量没生效就会掉回同一个量级)
|
|
971
|
-
const removeCount = 20000
|
|
972
|
-
const removeLineCount = 24000
|
|
973
|
-
const removeHidden = [12000, 12001]
|
|
974
|
-
const removeHiddenSet = new Set(removeHidden)
|
|
975
|
-
const removePoints = new Array(removeCount)
|
|
976
|
-
for (let index = 0; index < removeCount; index += 1) {
|
|
977
|
-
removePoints[index] = { id: `d${index}`, x: (index % 200) * 14, y: Math.floor(index / 200) * 14, name: `删点 ${index}`, flag: removeHiddenSet.has(index) ? 'hide' : 'show' }
|
|
978
|
-
}
|
|
979
|
-
const removeLines = new Array(removeLineCount)
|
|
980
|
-
for (let index = 0; index < removeLineCount; index += 1) {
|
|
981
|
-
removeLines[index] = { id: `dl${index}`, from: `d${index % removeCount}`, to: `d${(index * 7 + 11) % removeCount}` }
|
|
982
|
-
}
|
|
983
|
-
// 逐个删的这六条都排在最后(下标平移的账里不动),再删两批:四条 + 连续一千条
|
|
984
|
-
const removedSingles = ['d19999', 'd19998', 'd19997', 'd19996', 'd19995', 'd19994']
|
|
985
|
-
const removedBatch = []
|
|
986
|
-
for (let index = 10000; index < 10004; index += 1) {
|
|
987
|
-
removedBatch.push(`d${index}`)
|
|
988
|
-
}
|
|
989
|
-
for (let index = 5000; index < 6000; index += 1) {
|
|
990
|
-
removedBatch.push(`d${index}`)
|
|
991
|
-
}
|
|
992
|
-
const removedAll = new Set([...removedSingles, ...removedBatch])
|
|
993
|
-
const removedPointIndexes = []
|
|
994
|
-
for (let index = 0; index < removeCount; index += 1) {
|
|
995
|
-
if (removedAll.has(`d${index}`)) {
|
|
996
|
-
removedPointIndexes.push(index)
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
// 老下标按这次删除平移:排在删掉的下标后面的往前挪 —— 隐藏集合与选中镜像该落到的就是它
|
|
1000
|
-
const shiftAfterRemoval = index => index - removedPointIndexes.filter(value => value < index).length
|
|
1001
|
-
const reducedPoints = removePoints.filter(point => !removedAll.has(point.id))
|
|
1002
|
-
const reducedLines = removeLines.filter(line => !removedAll.has(String(line.from)) && !removedAll.has(String(line.to)))
|
|
1003
|
-
const removeCamera = { center: [1400, 140], scale: 1 }
|
|
1004
|
-
// 对照场景:同一份数据、其中一个点配了 zIndex(分层信息让引擎那条窄路口拒绝增量、照常整份重建)。
|
|
1005
|
-
// 「整份重建那条路」的成本拿它量 —— 同一份相机、同一组删除,比的才是同一种活
|
|
1006
|
-
const layeredPoints = removePoints.map((point, index) => (index === 0 ? { ...point, zIndex: 2 } : point))
|
|
1007
|
-
const fallbackHost = document.createElement('div')
|
|
1008
|
-
document.querySelector('#map').append(fallbackHost)
|
|
1009
|
-
const fallbackView = createGisView(fallbackHost, { points: layeredPoints, lines: removeLines, width: 1200, height: 700, quality: 'high' })
|
|
1010
|
-
const fallbackDeadline = now() + 12000
|
|
1011
|
-
while (!fallbackView.isLayoutReady() && now() < fallbackDeadline) {
|
|
1012
|
-
await sleep(50)
|
|
1013
|
-
}
|
|
1014
|
-
fallbackView.setCamera(removeCamera)
|
|
1015
|
-
let fallbackFastestMs = Infinity
|
|
1016
|
-
for (const id of removedSingles) {
|
|
1017
|
-
const callStarted = now()
|
|
1018
|
-
fallbackView.removePoints([id])
|
|
1019
|
-
fallbackFastestMs = Math.min(fallbackFastestMs, now() - callStarted)
|
|
1020
|
-
}
|
|
1021
|
-
fallbackView.destroy()
|
|
1022
|
-
fallbackHost.remove()
|
|
1023
|
-
const removeHost = document.createElement('div')
|
|
1024
|
-
document.querySelector('#map').append(removeHost)
|
|
1025
|
-
const removeView = createGisView(removeHost, { points: removePoints, lines: removeLines, width: 1200, height: 700, quality: 'high', pointSelectStyle: { color: '#ff00ff', size: 18 } })
|
|
1026
|
-
const removeDeadline = now() + 12000
|
|
1027
|
-
while (!removeView.isLayoutReady() && now() < removeDeadline) {
|
|
1028
|
-
await sleep(50)
|
|
1029
|
-
}
|
|
1030
|
-
removeView.setCamera(removeCamera)
|
|
1031
|
-
removeView.setFilter({ points: { flag: 'show' }, worker: false })
|
|
1032
|
-
await sleep(60)
|
|
1033
|
-
const hiddenBefore = [...removeView.getFilterResult().hiddenPoints]
|
|
1034
|
-
removeView.selectFeatures('d15000')
|
|
1035
|
-
await sleep(30)
|
|
1036
|
-
const removeRebuildStarted = now()
|
|
1037
|
-
removeView.refresh()
|
|
1038
|
-
const removeRebuildMs = now() - removeRebuildStarted
|
|
1039
|
-
// 单次代价按「最快的一次」判:整批平均值会被 GC 与同页别的大场景搅动,
|
|
1040
|
-
// 而这条断言要看的是快路径有没有生效 —— 生效时最快那几次是个位数毫秒,
|
|
1041
|
-
// 回落整份重建时每一次都在整份重排的量级上;平均值仍照常报在读数里
|
|
1042
|
-
const removeTimes = []
|
|
1043
|
-
for (const id of removedSingles) {
|
|
1044
|
-
const callStarted = now()
|
|
1045
|
-
removeView.removePoints([id])
|
|
1046
|
-
removeTimes.push(now() - callStarted)
|
|
1047
|
-
}
|
|
1048
|
-
const removeFastestMs = Math.min(...removeTimes)
|
|
1049
|
-
const removeAverageMs = removeTimes.reduce((sum, value) => sum + value, 0) / removeTimes.length
|
|
1050
|
-
const removeBatchStarted = now()
|
|
1051
|
-
removeView.removePoints(removedBatch)
|
|
1052
|
-
const removeBatchMs = now() - removeBatchStarted
|
|
1053
|
-
const removeReadyDeadline = now() + 12000
|
|
1054
|
-
while (!removeView.isLayoutReady() && now() < removeReadyDeadline) {
|
|
1055
|
-
await sleep(50)
|
|
1056
|
-
}
|
|
1057
|
-
await sleep(80)
|
|
1058
|
-
const removedScene = removeView.snapshotScene()
|
|
1059
|
-
// 对照:同一份「删减后的数据」一开始就整份铺好,相机、筛选与选中对准同一份
|
|
1060
|
-
const removedFullHost = document.createElement('div')
|
|
1061
|
-
document.querySelector('#map').append(removedFullHost)
|
|
1062
|
-
const removedFullView = createGisView(removedFullHost, { points: reducedPoints, lines: reducedLines, width: 1200, height: 700, quality: 'high', pointSelectStyle: { color: '#ff00ff', size: 18 } })
|
|
1063
|
-
const removedFullDeadline = now() + 12000
|
|
1064
|
-
while (!removedFullView.isLayoutReady() && now() < removedFullDeadline) {
|
|
1065
|
-
await sleep(50)
|
|
1066
|
-
}
|
|
1067
|
-
removedFullView.setCamera(removeCamera)
|
|
1068
|
-
removedFullView.setFilter({ points: { flag: 'show' }, worker: false })
|
|
1069
|
-
removedFullView.selectFeatures('d15000')
|
|
1070
|
-
await sleep(80)
|
|
1071
|
-
const removedFullScene = removedFullView.snapshotScene()
|
|
1072
|
-
const removedText = JSON.stringify(removedScene)
|
|
1073
|
-
const removedFullText = JSON.stringify(removedFullScene)
|
|
1074
|
-
const removedSame = removedText === removedFullText
|
|
1075
|
-
let removedDiff = `快照 ${removedText.length} 字节,逐字段一致`
|
|
1076
|
-
if (!removedSame) {
|
|
1077
|
-
let at = 0
|
|
1078
|
-
while (at < removedText.length && removedText[at] === removedFullText[at]) {
|
|
1079
|
-
at += 1
|
|
1080
|
-
}
|
|
1081
|
-
removedDiff = `第 ${at} 个字符起不同:${removedText.slice(Math.max(0, at - 30), at + 50)} / ${removedFullText.slice(Math.max(0, at - 30), at + 50)}`
|
|
1082
|
-
}
|
|
1083
|
-
check('删除的增量路:删减之后的场景与整份重建逐字段一致(含删掉的线路不再画)', removedSame, removedDiff)
|
|
1084
|
-
const removedScreen = removeView.project(removePoints[5500].x, removePoints[5500].y)
|
|
1085
|
-
const removedHit = removeView.pick(removedScreen[0], removedScreen[1])
|
|
1086
|
-
check('删除的增量路:被删的节点拾取不到(解析结果与索引表都摘干净了)', removedHit?.item?.id !== 'd5500', JSON.stringify({ id: removedHit?.item?.id ?? null }))
|
|
1087
|
-
const keepScreen = removeView.project(removePoints[15000].x, removePoints[15000].y)
|
|
1088
|
-
const keepHit = removeView.pick(keepScreen[0], keepScreen[1])
|
|
1089
|
-
check('删除的增量路:邻点按 id 仍能拾取、getPoint 的下标按新数组对齐', keepHit?.item?.id === 'd15000' && removeView.getPoint('d6000')?.index === 5000 && removeView.getPoint('d19999') === null, JSON.stringify({ 拾取: keepHit?.item?.id ?? null, d6000: removeView.getPoint('d6000')?.index ?? null }))
|
|
1090
|
-
const hiddenAfter = [...removeView.getFilterResult().hiddenPoints]
|
|
1091
|
-
const hiddenWanted = removeHidden.map(shiftAfterRemoval)
|
|
1092
|
-
check('删除的增量路:筛选的隐藏集合平移到新下标(还是原来那两个要素)', JSON.stringify(hiddenAfter) === JSON.stringify(hiddenWanted) && hiddenBefore.length === removeHidden.length, `删除前 ${JSON.stringify(hiddenBefore)} → 删除后 ${JSON.stringify(hiddenAfter)},应为 ${JSON.stringify(hiddenWanted)}`)
|
|
1093
|
-
const selectionAfter = removeView.getSelections()
|
|
1094
|
-
const selectionWanted = shiftAfterRemoval(15000)
|
|
1095
|
-
check('删除的增量路:选中镜像平移到新下标、指的还是同一个要素', selectionAfter.length === 1 && selectionAfter[0].item?.id === 'd15000' && selectionAfter[0].index === selectionWanted, JSON.stringify({ id: selectionAfter[0]?.item?.id ?? null, index: selectionAfter[0]?.index ?? null, 应为: selectionWanted }))
|
|
1096
|
-
const removeLabelHost = document.createElement('div')
|
|
1097
|
-
document.querySelector('#map').append(removeLabelHost)
|
|
1098
|
-
const removeLabelView = createGisView(removeLabelHost, { points: [{ id: 'y1', x: 0, y: 0, name: '甲删' }, { id: 'y2', x: 60, y: 0, name: '乙留' }, { id: 'y3', x: 120, y: 0, name: '丙删' }], lines: [], width: 640, height: 380, label: { content: '{name}' } })
|
|
1099
|
-
await sleep(60)
|
|
1100
|
-
removeLabelView.removePoints(['y1', 'y3'])
|
|
1101
|
-
await sleep(80)
|
|
1102
|
-
const removeLabels = [...removeLabelHost.querySelectorAll('.gis-view__label')].map(node => node.textContent.trim())
|
|
1103
|
-
check('删除的增量路:标签层跟着重排(删掉的不再留、留下的照常铺)', removeLabels.includes('乙留') && !removeLabels.includes('甲删') && !removeLabels.includes('丙删'), removeLabels.join(' / '))
|
|
1104
|
-
diag('删 1 个节点(两万点 / 两万四千线场景)', `最快 ${removeFastestMs.toFixed(2)} ms / 平均 ${removeAverageMs.toFixed(2)} ms · 整份重建那条路最快 ${fallbackFastestMs.toFixed(2)} ms · 同一场景整份重排 ${removeRebuildMs.toFixed(1)} ms`)
|
|
1105
|
-
diag('一次删 1004 个节点(两万点场景)', `${removeBatchMs.toFixed(1)} ms`)
|
|
1106
|
-
check('删除的增量路:单次删除明显快于整份重建那条路(增量真的生效)', removeFastestMs * 1.5 < fallbackFastestMs, `增量最快 ${removeFastestMs.toFixed(2)} ms vs 整份重建最快 ${fallbackFastestMs.toFixed(2)} ms`)
|
|
1107
|
-
removeLabelView.destroy()
|
|
1108
|
-
removeLabelHost.remove()
|
|
1109
|
-
removedFullView.destroy()
|
|
1110
|
-
removedFullHost.remove()
|
|
1111
|
-
removeView.destroy()
|
|
1112
|
-
removeHost.remove()
|
|
1113
|
-
/* ---------------- 纯删线的增量路(见 16.39):点一条没删时线路那一半单独走窄路 ---------------- */
|
|
1114
|
-
// 从前的窄路口要求「节点数组真的少了几条」;纯删线(组件那条删除入口把点数组原样带回来)只能整份重排。
|
|
1115
|
-
// 现在点那一半逐元素对得上时也认:点侧账(过滤 / 符号账本 / 包围盒 / 涟漪)整段跳过,只搬线路的槽位表与下标。
|
|
1116
|
-
// 这里盯四件事:
|
|
1117
|
-
// ① 与「一开始就用删减后的线路铺好」的场景逐字段一致(点 / 线 / 包围盒都在快照里)
|
|
1118
|
-
// ② 删掉的线路按 id 查不到、邻线按 id 仍能查到、getLine 的下标按新数组对齐
|
|
1119
|
-
// ③ 选中镜像与筛选的隐藏集合平移到新下标(指的还是原来那几条)
|
|
1120
|
-
// ④ 单次纯删线明显快于「整份重建那条路」(有一条线配了 zIndex,线路层序让窄路口拒绝增量)
|
|
1121
|
-
const lineOnlyPointCount = 16000
|
|
1122
|
-
const lineOnlyCount = 12000
|
|
1123
|
-
const lineOnlyHiddenPoints = [4000, 4001]
|
|
1124
|
-
const lineOnlyHiddenLines = [200, 9000]
|
|
1125
|
-
const buildLineOnlyData = (layered) => {
|
|
1126
|
-
const hiddenPointSet = new Set(lineOnlyHiddenPoints)
|
|
1127
|
-
const hiddenLineSet = new Set(lineOnlyHiddenLines)
|
|
1128
|
-
const points = new Array(lineOnlyPointCount)
|
|
1129
|
-
for (let index = 0; index < lineOnlyPointCount; index += 1) {
|
|
1130
|
-
points[index] = { id: `p${index}`, x: (index % 160) * 15, y: Math.floor(index / 160) * 15, flag: hiddenPointSet.has(index) ? 'hide' : 'show' }
|
|
1131
|
-
}
|
|
1132
|
-
const lines = new Array(lineOnlyCount)
|
|
1133
|
-
for (let index = 0; index < lineOnlyCount; index += 1) {
|
|
1134
|
-
lines[index] = {
|
|
1135
|
-
id: `q${index}`,
|
|
1136
|
-
from: `p${index % lineOnlyPointCount}`,
|
|
1137
|
-
to: `p${(index * 11 + 7) % lineOnlyPointCount}`,
|
|
1138
|
-
flag: hiddenLineSet.has(index) ? 'hide' : 'show',
|
|
1139
|
-
// 分层数据:线路 zIndex 让「线路层序」这一笔账存在 —— 窄路口据此回落整份重建,
|
|
1140
|
-
// 对照场景量的就是那条路的代价(与增量路同一份数据、同一组删除)
|
|
1141
|
-
...(layered && index === 0 ? { zIndex: 2 } : null)
|
|
1142
|
-
}
|
|
1143
|
-
}
|
|
1144
|
-
return { points, lines }
|
|
1145
|
-
}
|
|
1146
|
-
const lineOnlyRemovedIds = ['q3', 'q6000', 'q11999']
|
|
1147
|
-
const lineOnlyRemovedSet = new Set(lineOnlyRemovedIds)
|
|
1148
|
-
const lineOnlyCamera = { center: [1200, 700], scale: 1 }
|
|
1149
|
-
const lineOnlyData = buildLineOnlyData(false)
|
|
1150
|
-
const lineOnlyFallbackData = buildLineOnlyData(true)
|
|
1151
|
-
const lineOnlyReducedLines = lineOnlyData.lines.filter(line => !lineOnlyRemovedSet.has(line.id))
|
|
1152
|
-
const lineOnlyHost = document.createElement('div')
|
|
1153
|
-
document.querySelector('#map').append(lineOnlyHost)
|
|
1154
|
-
const lineOnlyView = createGisView(lineOnlyHost, { points: lineOnlyData.points, lines: lineOnlyData.lines, width: 1200, height: 700, quality: 'high' })
|
|
1155
|
-
const lineOnlyFallbackHost = document.createElement('div')
|
|
1156
|
-
document.querySelector('#map').append(lineOnlyFallbackHost)
|
|
1157
|
-
const lineOnlyFallbackView = createGisView(lineOnlyFallbackHost, { points: lineOnlyFallbackData.points, lines: lineOnlyFallbackData.lines, width: 1200, height: 700, quality: 'high' })
|
|
1158
|
-
for (const view of [lineOnlyView, lineOnlyFallbackView]) {
|
|
1159
|
-
const deadline = now() + 12000
|
|
1160
|
-
while (!view.isLayoutReady() && now() < deadline) {
|
|
1161
|
-
await sleep(50)
|
|
1162
|
-
}
|
|
1163
|
-
view.setCamera(lineOnlyCamera)
|
|
1164
|
-
view.setFilter({ points: { flag: 'show' }, lines: { flag: 'show' }, worker: false })
|
|
1165
|
-
}
|
|
1166
|
-
await sleep(60)
|
|
1167
|
-
lineOnlyView.selectFeatures('q8000')
|
|
1168
|
-
await sleep(40)
|
|
1169
|
-
let lineOnlyFallbackMs = Infinity
|
|
1170
|
-
for (const id of lineOnlyRemovedIds) {
|
|
1171
|
-
const callStarted = now()
|
|
1172
|
-
lineOnlyFallbackView.removeLines([id])
|
|
1173
|
-
lineOnlyFallbackMs = Math.min(lineOnlyFallbackMs, now() - callStarted)
|
|
1174
|
-
}
|
|
1175
|
-
let lineOnlyFastestMs = Infinity
|
|
1176
|
-
for (const id of lineOnlyRemovedIds) {
|
|
1177
|
-
const callStarted = now()
|
|
1178
|
-
lineOnlyView.removeLines([id])
|
|
1179
|
-
lineOnlyFastestMs = Math.min(lineOnlyFastestMs, now() - callStarted)
|
|
1180
|
-
}
|
|
1181
|
-
const lineOnlyDeadline = now() + 12000
|
|
1182
|
-
while (!lineOnlyView.isLayoutReady() && now() < lineOnlyDeadline) {
|
|
1183
|
-
await sleep(50)
|
|
1184
|
-
}
|
|
1185
|
-
await sleep(80)
|
|
1186
|
-
// 对照:同一份「删减后的线路」一开始就整份铺好,相机 / 筛选 / 选中对准同一份
|
|
1187
|
-
const lineOnlyFullHost = document.createElement('div')
|
|
1188
|
-
document.querySelector('#map').append(lineOnlyFullHost)
|
|
1189
|
-
const lineOnlyFullView = createGisView(lineOnlyFullHost, { points: lineOnlyData.points, lines: lineOnlyReducedLines, width: 1200, height: 700, quality: 'high' })
|
|
1190
|
-
const lineOnlyFullDeadline = now() + 12000
|
|
1191
|
-
while (!lineOnlyFullView.isLayoutReady() && now() < lineOnlyFullDeadline) {
|
|
1192
|
-
await sleep(50)
|
|
1193
|
-
}
|
|
1194
|
-
lineOnlyFullView.setCamera(lineOnlyCamera)
|
|
1195
|
-
lineOnlyFullView.setFilter({ points: { flag: 'show' }, lines: { flag: 'show' }, worker: false })
|
|
1196
|
-
lineOnlyFullView.selectFeatures('q8000')
|
|
1197
|
-
await sleep(80)
|
|
1198
|
-
const lineOnlyScene = lineOnlyView.snapshotScene()
|
|
1199
|
-
const lineOnlyFullScene = lineOnlyFullView.snapshotScene()
|
|
1200
|
-
const lineOnlyText = JSON.stringify(lineOnlyScene)
|
|
1201
|
-
const lineOnlyFullText = JSON.stringify(lineOnlyFullScene)
|
|
1202
|
-
const lineOnlySame = lineOnlyText === lineOnlyFullText
|
|
1203
|
-
let lineOnlyDiff = `快照 ${lineOnlyText.length} 字节,逐字段一致`
|
|
1204
|
-
if (!lineOnlySame) {
|
|
1205
|
-
const lineOnlyIdAt = (view, index) => view.getLine(index)?.item?.id ?? null
|
|
1206
|
-
const countLines = scene => scene.lines.length
|
|
1207
|
-
const firstDiff = (left, right) => {
|
|
1208
|
-
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
1209
|
-
if (JSON.stringify(left[index]) !== JSON.stringify(right[index])) {
|
|
1210
|
-
return index
|
|
1211
|
-
}
|
|
1212
|
-
}
|
|
1213
|
-
return -1
|
|
1214
|
-
}
|
|
1215
|
-
const at = firstDiff(lineOnlyScene.lines, lineOnlyFullScene.lines)
|
|
1216
|
-
let dataMismatch = -1
|
|
1217
|
-
for (let index = 0; index < lineOnlyReducedLines.length; index += 1) {
|
|
1218
|
-
const left = lineOnlyView.getLine(index)
|
|
1219
|
-
const right = lineOnlyFullView.getLine(index)
|
|
1220
|
-
if ((left?.item?.id ?? null) !== (right?.item?.id ?? null) || JSON.stringify(left?.path ?? null) !== JSON.stringify(right?.path ?? null)) {
|
|
1221
|
-
dataMismatch = index
|
|
1222
|
-
break
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
const detail = at < 0 ? '无' : `${JSON.stringify(lineOnlyIdAt(lineOnlyView, at))} vs ${JSON.stringify(lineOnlyIdAt(lineOnlyFullView, at))}:${JSON.stringify(lineOnlyScene.lines[at])} / ${JSON.stringify(lineOnlyFullScene.lines[at])}`
|
|
1226
|
-
const dataDetail = dataMismatch < 0 ? '逐下标一致' : `第 ${dataMismatch} 条:${JSON.stringify(lineOnlyView.getLine(dataMismatch)?.item?.id ?? null)} vs ${JSON.stringify(lineOnlyFullView.getLine(dataMismatch)?.item?.id ?? null)}:${JSON.stringify(lineOnlyView.getLine(dataMismatch)?.path ?? null)} / ${JSON.stringify(lineOnlyFullView.getLine(dataMismatch)?.path ?? null)}`
|
|
1227
|
-
lineOnlyDiff = `行数 ${countLines(lineOnlyScene)} vs ${countLines(lineOnlyFullScene)} · 首个不同在第 ${at} 条(${detail})· 按下标查线:${dataDetail}`
|
|
1228
|
-
}
|
|
1229
|
-
check('纯删线的增量路:删减之后的场景与整份重建逐字段一致(删掉的线路不再画)', lineOnlySame, lineOnlyDiff)
|
|
1230
|
-
check('纯删线的增量路:删掉的线路按 id 查不到、邻线下标按新数组对齐', lineOnlyView.getLine('q3') === null && lineOnlyView.getLine('q6000') === null && lineOnlyView.getLine('q11999') === null && lineOnlyView.getLine('q4')?.index === 3 && lineOnlyView.getLine('q6001')?.index === 5999 && lineOnlyView.getLine('q11998')?.index === 11996, JSON.stringify({ q4: lineOnlyView.getLine('q4')?.index ?? null, q6001: lineOnlyView.getLine('q6001')?.index ?? null, q11998: lineOnlyView.getLine('q11998')?.index ?? null }))
|
|
1231
|
-
const lineOnlyHiddenAfter = [...lineOnlyView.getFilterResult().hiddenLines].sort((left, right) => left - right)
|
|
1232
|
-
// 隐藏集合按 cascade 算:标了 hide 的两条(老下标 200 / 9000 → 199 / 8998)连同绑在隐藏节点上的那几条一起藏 ——
|
|
1233
|
-
// 与整份重建那一份逐项比才是准的,再点名确认平移后的两条标了 hide 的线还在集合里
|
|
1234
|
-
const lineOnlyHiddenFull = [...lineOnlyFullView.getFilterResult().hiddenLines].sort((left, right) => left - right)
|
|
1235
|
-
const lineOnlySelection = lineOnlyView.getSelections()
|
|
1236
|
-
check('纯删线的增量路:隐藏集合与选中镜像平移到新下标(指的还是原来那几条)', JSON.stringify(lineOnlyHiddenAfter) === JSON.stringify(lineOnlyHiddenFull) && lineOnlyHiddenAfter.includes(199) && lineOnlyHiddenAfter.includes(8998) && lineOnlySelection.length === 1 && lineOnlySelection[0].item?.id === 'q8000' && lineOnlySelection[0].index === 7998, JSON.stringify({ hidden: lineOnlyHiddenAfter, 整份重建: lineOnlyHiddenFull, selection: lineOnlySelection[0]?.item?.id ?? null, index: lineOnlySelection[0]?.index ?? null }))
|
|
1237
|
-
diag('删 1 条线(一万六点 / 一万两千线场景,点一条没删)', `增量最快 ${lineOnlyFastestMs.toFixed(2)} ms · 整份重建那条路最快 ${lineOnlyFallbackMs.toFixed(2)} ms · 删完的场景与整份重建一致:${lineOnlySame}`)
|
|
1238
|
-
check('纯删线的增量路:单次删除明显快于整份重建那条路(增量真的生效)', lineOnlyFastestMs * 1.5 < lineOnlyFallbackMs, `增量最快 ${lineOnlyFastestMs.toFixed(2)} ms vs 整份重建最快 ${lineOnlyFallbackMs.toFixed(2)} ms`)
|
|
1239
|
-
lineOnlyFullView.destroy()
|
|
1240
|
-
lineOnlyFullHost.remove()
|
|
1241
|
-
lineOnlyView.destroy()
|
|
1242
|
-
lineOnlyHost.remove()
|
|
1243
|
-
lineOnlyFallbackView.destroy()
|
|
1244
|
-
lineOnlyFallbackHost.remove()
|
|
1245
|
-
/* ---------------- 样式快路径:混合补丁 / 原样重发 / 热力图都不重铺场景 ---------------- */
|
|
1246
|
-
// 面板上「拖一个滑块」常常是把基准样式与状态样式一起下发的混合补丁
|
|
1247
|
-
// (updateOptions({ pointStyle, lineStyle, pointHoverStyle, lineHoverStyle }))。
|
|
1248
|
-
// 这一类补丁走「逐要素重解析 + 槽位覆盖」的快路径,且两半的解析输入都没变时整半跳过。
|
|
1249
|
-
// 这里盯四件事:
|
|
1250
|
-
// ① 混合补丁之后的画面与「一开始就这么配」逐字段一致(含正悬停着的那一条)
|
|
1251
|
-
// ② 同一份配置原样再发一次:场景逐字段不动,代价接近零
|
|
1252
|
-
// ③ 只换热力图色带同样走快路径,重上色之后与整份重建逐字段一致
|
|
1253
|
-
// ④ 只动线路样式时节点那一半与装饰锚点逐字段不动(几何没动就不重算)
|
|
1254
|
-
const fastCount = 6000
|
|
1255
|
-
const fastLineCount = 2000
|
|
1256
|
-
const fastPoints = new Array(fastCount)
|
|
1257
|
-
for (let index = 0; index < fastCount; index += 1) {
|
|
1258
|
-
fastPoints[index] = { id: `f${index}`, x: (index % 60) * 14, y: Math.floor(index / 60) * 14, name: `快路径 ${index}` }
|
|
1259
|
-
}
|
|
1260
|
-
const fastLines = new Array(fastLineCount)
|
|
1261
|
-
for (let index = 0; index < fastLineCount; index += 1) {
|
|
1262
|
-
fastLines[index] = {
|
|
1263
|
-
id: `fl${index}`,
|
|
1264
|
-
from: `f${index}`,
|
|
1265
|
-
to: `f${(index * 7 + 11) % fastCount}`,
|
|
1266
|
-
decorations: [{ position: 0.5, symbol: 'square', size: 6, color: '#ffcc33' }]
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1269
|
-
const fastStyle = {
|
|
1270
|
-
pointStyle: { size: 8, color: '#3fa35e', strokeColor: '#123456', strokeWidth: 1.5 },
|
|
1271
|
-
lineStyle: { width: 2, color: 'rgba(214, 92, 40, 0.85)' },
|
|
1272
|
-
pointHoverStyle: { size: 16, color: '#ffd400' },
|
|
1273
|
-
lineHoverStyle: { width: 5, color: '#ffd400' }
|
|
1274
|
-
}
|
|
1275
|
-
const fastCamera = { center: [420, 280], scale: 1 }
|
|
1276
|
-
const fastHost = document.createElement('div')
|
|
1277
|
-
document.querySelector('#map').append(fastHost)
|
|
1278
|
-
// 悬停要显式打开(缺省全关,见 src/gis-view/features.js)
|
|
1279
|
-
const fastView = createGisView(fastHost, { points: fastPoints, lines: fastLines, width: 640, height: 380, quality: 'high', features: { hover: true } })
|
|
1280
|
-
const fastFullHost = document.createElement('div')
|
|
1281
|
-
document.querySelector('#map').append(fastFullHost)
|
|
1282
|
-
const fastFullView = createGisView(fastFullHost, { points: fastPoints, lines: fastLines, width: 640, height: 380, quality: 'high', features: { hover: true }, ...fastStyle })
|
|
1283
|
-
fastView.setCamera(fastCamera)
|
|
1284
|
-
fastFullView.setCamera(fastCamera)
|
|
1285
|
-
// 悬停:指针停在编号 1230 的节点上(世界坐标 420 / 280,正好落在相机中心),两个实例停同一条
|
|
1286
|
-
const hoverNode = (view, host) => {
|
|
1287
|
-
const canvas = host.querySelector('canvas')
|
|
1288
|
-
const box = canvas.getBoundingClientRect()
|
|
1289
|
-
const spot = view.project(420, 280)
|
|
1290
|
-
canvas.dispatchEvent(new PointerEvent('pointermove', {
|
|
1291
|
-
bubbles: true, cancelable: true, pointerId: 77, pointerType: 'mouse', button: 0, buttons: 0,
|
|
1292
|
-
clientX: box.left + spot[0], clientY: box.top + spot[1]
|
|
1293
|
-
}))
|
|
1294
|
-
}
|
|
1295
|
-
hoverNode(fastView, fastHost)
|
|
1296
|
-
hoverNode(fastFullView, fastFullHost)
|
|
1297
|
-
await sleep(120)
|
|
1298
|
-
const fastFullText = JSON.stringify(fastFullView.snapshotScene())
|
|
1299
|
-
const fastStarted = now()
|
|
1300
|
-
fastView.updateOptions(fastStyle)
|
|
1301
|
-
const fastPatchMs = now() - fastStarted
|
|
1302
|
-
const fastScene = fastView.snapshotScene()
|
|
1303
|
-
const fastText = JSON.stringify(fastScene)
|
|
1304
|
-
const diffText = (left, right) => {
|
|
1305
|
-
let at = 0
|
|
1306
|
-
while (at < left.length && left[at] === right[at]) {
|
|
1307
|
-
at += 1
|
|
1308
|
-
}
|
|
1309
|
-
return `第 ${at} 个字符起不同:${left.slice(Math.max(0, at - 30), at + 50)} / ${right.slice(Math.max(0, at - 30), at + 50)}`
|
|
1310
|
-
}
|
|
1311
|
-
const hoveredNode = fastScene.points.find(node => node.fill === 'rgba(255,212,0,1)')
|
|
1312
|
-
check('样式快路径:混合补丁(基准 + 状态样式)之后的画面与整份重建逐字段一致',
|
|
1313
|
-
fastText === fastFullText,
|
|
1314
|
-
fastText === fastFullText ? `快照 ${fastText.length} 字节,逐字段一致` : diffText(fastText, fastFullText))
|
|
1315
|
-
check('样式快路径:悬停中的那一条按新的悬停样式重解析(不是恰好比对一致)',
|
|
1316
|
-
hoveredNode?.fill === 'rgba(255,212,0,1)' && hoveredNode.size > 8,
|
|
1317
|
-
JSON.stringify({ size: hoveredNode?.size, fill: hoveredNode?.fill }))
|
|
1318
|
-
let repeatMs = Infinity
|
|
1319
|
-
for (let round = 0; round < 3; round += 1) {
|
|
1320
|
-
const repeatStarted = now()
|
|
1321
|
-
fastView.updateOptions(fastStyle)
|
|
1322
|
-
repeatMs = Math.min(repeatMs, now() - repeatStarted)
|
|
1323
|
-
}
|
|
1324
|
-
check('样式快路径:同一份配置原样再发一次,场景逐字段不动', JSON.stringify(fastView.snapshotScene()) === fastText)
|
|
1325
|
-
check('样式快路径:原样重发的代价接近零(两半的解析输入都没变,整半跳过)',
|
|
1326
|
-
repeatMs * 10 < fastPatchMs,
|
|
1327
|
-
`原样 ${repeatMs.toFixed(2)} ms vs 混合补丁 ${fastPatchMs.toFixed(2)} ms`)
|
|
1328
|
-
const beforeLineScene = fastView.snapshotScene()
|
|
1329
|
-
const widthBefore = beforeLineScene.lines.map(line => line.width)
|
|
1330
|
-
fastView.updateOptions({ lineStyle: { width: 6, color: 'rgba(26, 159, 176, 0.9)' } })
|
|
1331
|
-
const afterLineScene = fastView.snapshotScene()
|
|
1332
|
-
const widthAfter = afterLineScene.lines.map(line => line.width)
|
|
1333
|
-
check('样式快路径:只动线路样式,节点那一半逐字段不动',
|
|
1334
|
-
JSON.stringify(afterLineScene.points) === JSON.stringify(beforeLineScene.points) &&
|
|
1335
|
-
JSON.stringify(afterLineScene.raised) === JSON.stringify(beforeLineScene.raised) &&
|
|
1336
|
-
JSON.stringify(afterLineScene.backgroundPoints) === JSON.stringify(beforeLineScene.backgroundPoints))
|
|
1337
|
-
check('样式快路径:只动线路样式,装饰锚点逐字段不动(几何没动就不重算)',
|
|
1338
|
-
beforeLineScene.decorations.length > 0 && JSON.stringify(afterLineScene.decorations) === JSON.stringify(beforeLineScene.decorations),
|
|
1339
|
-
`${beforeLineScene.decorations.length} 个装饰`)
|
|
1340
|
-
check('样式快路径:只动线路样式,画出来的线宽照着新配置走',
|
|
1341
|
-
widthAfter.length > 0 && widthAfter.every((width, index) => width > 0 && Math.abs(width - widthBefore[index] * 3) < 0.01),
|
|
1342
|
-
`${widthBefore[0]} -> ${widthAfter[0]}`)
|
|
1343
|
-
// 线路窄样式:只改全局线宽 / 颜色 / 透明度,逐条重算 paint 链并补槽位;dataVersion 与虚线 / 自身样式都不动
|
|
1344
|
-
const lineNarrowVersion = fastView.dataVersion
|
|
1345
|
-
const lineNarrowStarted = now()
|
|
1346
|
-
fastView.updateLineStyle({ width: 7.5, color: '#1aa0b0', opacity: 0.66 })
|
|
1347
|
-
const lineNarrowMs = now() - lineNarrowStarted
|
|
1348
|
-
const lineNarrowStyle = fastView.getLineStyle(2)
|
|
1349
|
-
check('线路窄样式:getLineStyle 读到新的线宽 / 颜色 / 透明度,dataVersion 不变',
|
|
1350
|
-
lineNarrowStyle !== null && Math.abs(lineNarrowStyle.width - 7.5) < 1e-6 && Math.abs(lineNarrowStyle.opacity - 0.66) < 1e-6 &&
|
|
1351
|
-
lineNarrowStyle.color[1] > 0.5 && lineNarrowStyle.color[2] > 0.5 && lineNarrowVersion === fastView.dataVersion,
|
|
1352
|
-
`${JSON.stringify(lineNarrowStyle)} · dataVersion ${lineNarrowVersion} -> ${fastView.dataVersion}`)
|
|
1353
|
-
diag('线路窄样式:2000 条只改全局线宽 / 颜色 / 透明度', `${lineNarrowMs.toFixed(2)} ms`)
|
|
1354
|
-
fastView.updateLine('fl0', { width: 11, color: '#ff0000' })
|
|
1355
|
-
fastView.updateLine('fl1230', { dash: 'dashed' })
|
|
1356
|
-
fastView.updateLineStyle({ width: 3, color: '#33aaff', opacity: 1 })
|
|
1357
|
-
const lineOwnStyle = fastView.getLineStyle(0)
|
|
1358
|
-
const lineDashScene = fastView.snapshotScene()
|
|
1359
|
-
check('线路窄样式:逐线自身线宽 / 颜色优先,全局换色不会覆盖它',
|
|
1360
|
-
lineOwnStyle !== null && Math.abs(lineOwnStyle.width - 11) < 1e-6 && lineOwnStyle.color[0] > 0.99 && lineOwnStyle.color[1] < 0.01,
|
|
1361
|
-
JSON.stringify(lineOwnStyle))
|
|
1362
|
-
check('线路窄样式:只换线宽 / 颜色 / 透明度时虚线图案保持不动', lineDashScene.lines.some(line => Array.isArray(line.dash) && line.dash.length > 0))
|
|
1363
|
-
// 有线路规则时窄路必须让开:规则改出来的宽度 / 颜色仍按规则走,规则外的那条跟着全局样式
|
|
1364
|
-
fastView.updateOptions({ lineRules: [{ when: { id: 'fl2' }, style: { width: 8, color: '#00aa55' } }] })
|
|
1365
|
-
fastView.updateLineStyle({ width: 2, color: '#888888' })
|
|
1366
|
-
const lineRuleStyle = fastView.getLineStyle(2)
|
|
1367
|
-
const lineGlobalStyle = fastView.getLineStyle(3)
|
|
1368
|
-
check('线路窄样式:存在线路规则时回退整份解析,规则优先于新的全局线样式',
|
|
1369
|
-
lineRuleStyle !== null && Math.abs(lineRuleStyle.width - 8) < 1e-6 && lineRuleStyle.color[1] > 0.6 && lineRuleStyle.color[0] < 0.01 &&
|
|
1370
|
-
lineGlobalStyle !== null && Math.abs(lineGlobalStyle.width - 2) < 1e-6,
|
|
1371
|
-
`规则 ${JSON.stringify(lineRuleStyle)} / 全局 ${JSON.stringify(lineGlobalStyle)}`)
|
|
1372
|
-
fastView.updateOptions({ lineRules: [] })
|
|
1373
|
-
// 热力图:值域色带换一份,重上色之后与「一开始就用这份色带」逐字段一致
|
|
1374
|
-
const heatCount = 1200
|
|
1375
|
-
const heatPoints = new Array(heatCount)
|
|
1376
|
-
for (let index = 0; index < heatCount; index += 1) {
|
|
1377
|
-
heatPoints[index] = { id: `h${index}`, x: (index % 40) * 16, y: Math.floor(index / 40) * 16, value: index % 100 }
|
|
1378
|
-
}
|
|
1379
|
-
const heatLines = new Array(300)
|
|
1380
|
-
for (let index = 0; index < heatLines.length; index += 1) {
|
|
1381
|
-
heatLines[index] = { id: `hl${index}`, from: `h${index}`, to: `h${(index * 13 + 5) % heatCount}`, value: (index * 7) % 100 }
|
|
1382
|
-
}
|
|
1383
|
-
const heatA = { enabled: true, field: 'value', targets: ['point', 'line'], colors: ['#0b3d91', '#ffd166', '#ff5e5e'] }
|
|
1384
|
-
const heatB = { enabled: true, field: 'value', targets: ['point', 'line'], colors: ['#123456', '#7ce0b0', '#ff8a3d'] }
|
|
1385
|
-
const heatHost = document.createElement('div')
|
|
1386
|
-
document.querySelector('#map').append(heatHost)
|
|
1387
|
-
const heatView = createGisView(heatHost, { points: heatPoints, lines: heatLines, heatmap: heatA, width: 640, height: 380, quality: 'high' })
|
|
1388
|
-
const heatFullHost = document.createElement('div')
|
|
1389
|
-
document.querySelector('#map').append(heatFullHost)
|
|
1390
|
-
const heatFullView = createGisView(heatFullHost, { points: heatPoints, lines: heatLines, heatmap: heatB, width: 640, height: 380, quality: 'high' })
|
|
1391
|
-
heatView.setCamera({ center: [320, 200], scale: 1 })
|
|
1392
|
-
heatFullView.setCamera({ center: [320, 200], scale: 1 })
|
|
1393
|
-
const heatBefore = JSON.stringify(heatView.snapshotScene())
|
|
1394
|
-
heatView.updateOptions({ heatmap: heatB })
|
|
1395
|
-
const heatAfter = JSON.stringify(heatView.snapshotScene())
|
|
1396
|
-
const heatFullText = JSON.stringify(heatFullView.snapshotScene())
|
|
1397
|
-
check('样式快路径:换热力图色带走的也是快路径,画面与整份重建逐字段一致',
|
|
1398
|
-
heatAfter === heatFullText,
|
|
1399
|
-
heatAfter === heatFullText ? `快照 ${heatAfter.length} 字节,逐字段一致` : diffText(heatAfter, heatFullText))
|
|
1400
|
-
check('样式快路径:色带真的换过(不是两份快照都没上色)', heatBefore !== heatAfter)
|
|
1401
|
-
// 同一份配置对象原样再发一次:配置是按引用存的,就地改过数据(值域跟着变)时也要重算
|
|
1402
|
-
heatPoints[0].value = 1000000
|
|
1403
|
-
heatView.updateOptions({ heatmap: heatB })
|
|
1404
|
-
const heatRefreshedText = JSON.stringify(heatView.snapshotScene())
|
|
1405
|
-
const heatFreshHost = document.createElement('div')
|
|
1406
|
-
document.querySelector('#map').append(heatFreshHost)
|
|
1407
|
-
const heatFreshView = createGisView(heatFreshHost, { points: heatPoints, lines: heatLines, heatmap: heatB, width: 640, height: 380, quality: 'high' })
|
|
1408
|
-
heatFreshView.setCamera({ center: [320, 200], scale: 1 })
|
|
1409
|
-
const heatFreshText = JSON.stringify(heatFreshView.snapshotScene())
|
|
1410
|
-
check('样式快路径:同一份热力图配置原样再发一次,值域按当前数据重算(与整份重建一致)',
|
|
1411
|
-
heatRefreshedText !== heatAfter && heatRefreshedText === heatFreshText,
|
|
1412
|
-
heatRefreshedText === heatFreshText ? `快照 ${heatRefreshedText.length} 字节,逐字段一致` : diffText(heatRefreshedText, heatFreshText))
|
|
1413
|
-
// 密度场:只改 opacity 时复用上一份采样与贴图;换色带 / 换要素值才重烤。
|
|
1414
|
-
// 判断重烤用 heatDensityStats 的对象身份:只改画法那条快路不会换它。
|
|
1415
|
-
const densityOption = {
|
|
1416
|
-
enabled: true,
|
|
1417
|
-
field: 'value',
|
|
1418
|
-
targets: ['point'],
|
|
1419
|
-
domain: [0, 100],
|
|
1420
|
-
colors: ['#0b3d91', '#ffd166', '#ff5e5e'],
|
|
1421
|
-
density: { opacity: 1, radius: 22, resolution: 192, levels: 8 }
|
|
1422
|
-
}
|
|
1423
|
-
const densityHost = document.createElement('div')
|
|
1424
|
-
document.querySelector('#map').append(densityHost)
|
|
1425
|
-
const densityView = createGisView(densityHost, {
|
|
1426
|
-
points: heatPoints,
|
|
1427
|
-
lines: [],
|
|
1428
|
-
heatmap: densityOption,
|
|
1429
|
-
width: 640,
|
|
1430
|
-
height: 380,
|
|
1431
|
-
quality: 'high',
|
|
1432
|
-
showGrid: false,
|
|
1433
|
-
background: null
|
|
1434
|
-
})
|
|
1435
|
-
densityView.fitView()
|
|
1436
|
-
await densityView.whenRendered()
|
|
1437
|
-
await densityView.whenRendered()
|
|
1438
|
-
const densityFirst = densityView.getStats().heatDensity
|
|
1439
|
-
check('热力图密度场:首帧烤出有效场(有采样数、贴图尺寸非零)',
|
|
1440
|
-
Boolean(densityFirst) && densityFirst.samples > 0 && densityFirst.width > 0 && densityFirst.height > 0,
|
|
1441
|
-
JSON.stringify(densityFirst))
|
|
1442
|
-
const opacityOption = { ...densityOption, density: { ...densityOption.density, opacity: 0.35 } }
|
|
1443
|
-
const opacityStarted = now()
|
|
1444
|
-
densityView.updateOptions({ heatmap: opacityOption })
|
|
1445
|
-
const opacityUpdateMs = now() - opacityStarted
|
|
1446
|
-
await densityView.whenRendered()
|
|
1447
|
-
const densityAfterOpacity = densityView.getStats().heatDensity
|
|
1448
|
-
check('热力图密度场:只改 density.opacity 不重采、不重烤(复用同一份烘焙读数)',
|
|
1449
|
-
densityAfterOpacity === densityFirst)
|
|
1450
|
-
const colorOption = { ...opacityOption, colors: ['#123456', '#7ce0b0', '#ff8a3d'] }
|
|
1451
|
-
const colorStarted = now()
|
|
1452
|
-
densityView.updateOptions({ heatmap: colorOption })
|
|
1453
|
-
const colorUpdateMs = now() - colorStarted
|
|
1454
|
-
await densityView.whenRendered()
|
|
1455
|
-
const densityAfterColor = densityView.getStats().heatDensity
|
|
1456
|
-
check('热力图密度场:换色带会重烤(生成新一份烘焙读数)',
|
|
1457
|
-
densityAfterColor !== densityAfterOpacity && densityAfterColor.samples === densityAfterOpacity.samples)
|
|
1458
|
-
densityView.updatePoint('h0', { value: 7 })
|
|
1459
|
-
await densityView.whenRendered()
|
|
1460
|
-
const densityAfterPoint = densityView.getStats().heatDensity
|
|
1461
|
-
check('热力图密度场:定向 updatePoint 改要素值后场跟着重烤(不再停在旧数据上)',
|
|
1462
|
-
densityAfterPoint !== densityAfterColor && densityAfterPoint.samples === densityAfterColor.samples)
|
|
737
|
+
anchorView.destroy()
|
|
738
|
+
anchorHost.remove()
|
|
739
|
+
labelView.destroy()
|
|
740
|
+
labelHost.remove()
|
|
741
|
+
big.setQuality('lean')
|
|
742
|
+
check('setQuality 换档:读数与回调一起到(lean 关掉光晕与装饰)', big.getQuality().name === 'lean' && big.getQuality().ornament === false && qualityChanges.includes('lean'), `${big.getQuality().name} / ${qualityChanges.join(',')}`)
|
|
743
|
+
big.setQuality('fast')
|
|
744
|
+
big.refresh()
|
|
745
|
+
const fastRatio = big.getStats().pixelRatio
|
|
746
|
+
check('fast 档按更低的分辨率渲染(pixelRatio 跟着降)', fastRatio < 0.9 && Math.abs(fastRatio - 0.75) < 0.03, fastRatio.toFixed(3))
|
|
747
|
+
big.setQuality('high')
|
|
748
|
+
big.refresh()
|
|
749
|
+
check('切回 high:分辨率与装饰都回满', big.getStats().pixelRatio > 0.9 && big.getQuality().ornament === true, big.getStats().pixelRatio.toFixed(3))
|
|
750
|
+
const qualityEvents = []
|
|
751
|
+
big.on('qualityChange', state => qualityEvents.push(state.name))
|
|
752
|
+
big.setQuality('balanced')
|
|
753
|
+
check('qualityChange 事件与 onQualityChange 是同一份读数', qualityEvents.join(',') === 'balanced', qualityEvents.join(','))
|
|
754
|
+
|
|
755
|
+
/* ---------------- 追加式补点:addPoint 的增量路与整份重建等价 ---------------- */
|
|
756
|
+
// 逐个 addPoint 原来是「整份重排一次」;这一轮认出「数据末尾就多了这几条」,
|
|
757
|
+
// 只解析这几条、只往实例批次里补这几条(引擎的 appendPoints)。这里盯三件事:
|
|
758
|
+
// ① 与「同一份数据一开始就整份铺好」画出来的场景逐字段一致(含绘制次序)
|
|
759
|
+
// ② 追加的点真的进了场景、索引与标签层 ③ 单次代价明显低于一次整份重排(增量路没生效就会掉回原来的量级)
|
|
760
|
+
const appendCount = 20000
|
|
761
|
+
const appendPoints = new Array(appendCount)
|
|
762
|
+
for (let index = 0; index < appendCount; index += 1) {
|
|
763
|
+
appendPoints[index] = { id: `a${index}`, x: (index % 200) * 20, y: Math.floor(index / 200) * 20, color: '#4b8dff', size: 8 }
|
|
764
|
+
}
|
|
765
|
+
const appendCamera = { center: [1000, 1000], scale: 1 }
|
|
766
|
+
const appendedList = new Array(5)
|
|
767
|
+
for (let index = 0; index < appendedList.length; index += 1) {
|
|
768
|
+
appendedList[index] = { id: `a-new-${index}`, x: 1000 + index * 12, y: 1000 + index * 12, color: '#ff3300', size: 8 }
|
|
769
|
+
}
|
|
770
|
+
const appendHost = document.createElement('div')
|
|
771
|
+
document.querySelector('#map').append(appendHost)
|
|
772
|
+
const appendView = createGisView(appendHost, { points: appendPoints, lines: [], width: 640, height: 380, quality: 'high' })
|
|
773
|
+
// 两万个点会走发散渲染:先等它铺完,追加才有「已经铺好的场景」可接
|
|
774
|
+
const appendDeadline = now() + 8000
|
|
775
|
+
while (!appendView.isLayoutReady() && now() < appendDeadline) {
|
|
776
|
+
await sleep(50)
|
|
777
|
+
}
|
|
778
|
+
appendView.setCamera(appendCamera)
|
|
779
|
+
// 单次代价按「最快的一次」判:整批平均值会被 GC 与同页别的大场景搅动,
|
|
780
|
+
// 而这条断言要看的是快路径有没有生效 —— 生效时最快那一次是零点几毫秒,
|
|
781
|
+
// 回落整份重建时每一次都在重排的量级上;平均值仍照常报在读数里
|
|
782
|
+
const appendStarted = now()
|
|
783
|
+
let appendFastestMs = Infinity
|
|
784
|
+
for (const item of appendedList) {
|
|
785
|
+
const callStarted = now()
|
|
786
|
+
appendView.addPoint(item)
|
|
787
|
+
appendFastestMs = Math.min(appendFastestMs, now() - callStarted)
|
|
788
|
+
}
|
|
789
|
+
const appendMs = (now() - appendStarted) / appendedList.length
|
|
790
|
+
const appendedScene = appendView.snapshotScene()
|
|
791
|
+
// 对照:同一份数据(含追加的那几条)一开始就整份铺好,相机对准同一个位置
|
|
792
|
+
const appendFullHost = document.createElement('div')
|
|
793
|
+
document.querySelector('#map').append(appendFullHost)
|
|
794
|
+
const appendFullView = createGisView(appendFullHost, { points: appendPoints.concat(appendedList), lines: [], width: 640, height: 380, quality: 'high' })
|
|
795
|
+
const appendFullDeadline = now() + 8000
|
|
796
|
+
while (!appendFullView.isLayoutReady() && now() < appendFullDeadline) {
|
|
797
|
+
await sleep(50)
|
|
798
|
+
}
|
|
799
|
+
appendFullView.setCamera(appendCamera)
|
|
800
|
+
const appendFullScene = appendFullView.snapshotScene()
|
|
801
|
+
const appendedText = JSON.stringify(appendedScene)
|
|
802
|
+
const appendFullText = JSON.stringify(appendFullScene)
|
|
803
|
+
const appendSame = appendedText === appendFullText
|
|
804
|
+
let appendDiff = `快照 ${appendedText.length} 字节,逐字段一致`
|
|
805
|
+
if (!appendSame) {
|
|
806
|
+
let at = 0
|
|
807
|
+
while (at < appendedText.length && appendedText[at] === appendFullText[at]) {
|
|
808
|
+
at += 1
|
|
809
|
+
}
|
|
810
|
+
appendDiff = `第 ${at} 个字符起不同:${appendedText.slice(Math.max(0, at - 30), at + 50)} / ${appendFullText.slice(Math.max(0, at - 30), at + 50)}`
|
|
811
|
+
}
|
|
812
|
+
check('追加式补点:新点进了场景(快照里数得出这几条)', appendedScene.points.filter(node => node.fill === 'rgba(255,51,0,1)').length === appendedList.length, `${appendedScene.points.length} 个点里 ${appendedScene.points.filter(node => node.fill === 'rgba(255,51,0,1)').length} 个新点`)
|
|
813
|
+
check('追加式补点:画出来的场景与整份重建逐字段一致(含绘制次序)', appendSame, appendDiff)
|
|
814
|
+
const appendSpot = appendView.project(appendedList[4].x, appendedList[4].y)
|
|
815
|
+
const appendHit = appendView.pick(appendSpot[0], appendSpot[1])
|
|
816
|
+
check('追加式补点:新点能被拾取(索引表也跟着补了)', appendHit?.item?.id === 'a-new-4', JSON.stringify({ id: appendHit?.item?.id }))
|
|
817
|
+
const appendLabelHost = document.createElement('div')
|
|
818
|
+
document.querySelector('#map').append(appendLabelHost)
|
|
819
|
+
const appendLabelView = createGisView(appendLabelHost, { points: [{ id: 'x1', x: 0, y: 0, name: '甲' }], lines: [], width: 640, height: 380, label: { content: '{name}' } })
|
|
820
|
+
appendLabelView.addPoint({ id: 'x2', x: 40, y: 0, name: '乙新增' })
|
|
821
|
+
await sleep(60)
|
|
822
|
+
const appendLabels = [...appendLabelHost.querySelectorAll('.gis-view__label')].map(node => node.textContent.trim())
|
|
823
|
+
check('追加式补点:新点的标签照常铺出来(标签层没被这条捷径跳过)', appendLabels.includes('乙新增'), appendLabels.join(' / '))
|
|
824
|
+
const appendRefreshStarted = now()
|
|
825
|
+
appendView.refresh()
|
|
826
|
+
const appendRebuildMs = now() - appendRefreshStarted
|
|
827
|
+
diag('追加 1 个节点(两万点场景)', `${appendMs.toFixed(2)} ms / 次(最快一次 ${appendFastestMs.toFixed(2)} ms)· 同一场景整份重排 ${appendRebuildMs.toFixed(1)} ms`)
|
|
828
|
+
check('追加一个节点的代价明显低于一次整份重排(增量路真的生效)', appendFastestMs * 5 < appendRebuildMs, `最快一次 ${appendFastestMs.toFixed(2)} ms · 平均 ${appendMs.toFixed(2)} ms vs 整份重排 ${appendRebuildMs.toFixed(1)} ms`)
|
|
829
|
+
appendLabelView.destroy()
|
|
830
|
+
appendLabelHost.remove()
|
|
831
|
+
appendFullView.destroy()
|
|
832
|
+
appendFullHost.remove()
|
|
833
|
+
appendView.destroy()
|
|
834
|
+
appendHost.remove()
|
|
835
|
+
|
|
836
|
+
/* ---------------- 删完立刻补:发散渲染还欠着账时的补点(见 16.60) ---------------- */
|
|
837
|
+
// 删除的增量路会把正在跑的发散渲染停掉、按挪过下标的解析结果重开一轮(见 16.60):
|
|
838
|
+
// 那一轮还没铺完时又来一条 addPoint,从前会掉回整份重解析(几十毫秒)。
|
|
839
|
+
// 这一轮认下这条路:先停掉那一轮、把末尾这几条补进解析结果与批次、再按新的解析结果重开一轮。
|
|
840
|
+
// 点数要留在发散渲染的阈值(两万)之上:删掉一条之后重开的那一轮才真的还在跑
|
|
841
|
+
// 盯三件事:① 删完之后场景确实处在「只铺到一段」的状态(这条断言守的就是那条路,退化成整份重建时它会先红)
|
|
842
|
+
// ② 补进来的点照常进场景、铺完之后与「一开始就整份铺好」逐字段一致 ③ 代价仍是增量级
|
|
843
|
+
const resumeCount = 22000
|
|
844
|
+
const resumePoints = new Array(resumeCount)
|
|
845
|
+
for (let index = 0; index < resumeCount; index += 1) {
|
|
846
|
+
resumePoints[index] = { id: `r${index}`, x: (index % 200) * 16, y: Math.floor(index / 200) * 16, size: 6 }
|
|
847
|
+
}
|
|
848
|
+
// 线路那一半是有意加的:回落整份重建时它要多跑一趟几万条线的重解析,
|
|
849
|
+
// 「补点仍是增量级」这一条才有足够宽的比值可判(端点都落在前五千个点上,删的 r5000 不在其中)
|
|
850
|
+
const resumeLines = new Array(12000)
|
|
851
|
+
for (let index = 0; index < resumeLines.length; index += 1) {
|
|
852
|
+
resumeLines[index] = { id: `rl${index}`, from: `r${index % 5000}`, to: `r${(index * 3 + 7) % 5000}` }
|
|
853
|
+
}
|
|
854
|
+
const resumeCamera = { center: [1600, 800], scale: 1 }
|
|
855
|
+
const resumeHost = document.createElement('div')
|
|
856
|
+
document.querySelector('#map').append(resumeHost)
|
|
857
|
+
const resumeView = createGisView(resumeHost, { points: resumePoints, lines: resumeLines, width: 640, height: 380, quality: 'high' })
|
|
858
|
+
const resumeDeadline = now() + 8000
|
|
859
|
+
while (!resumeView.isLayoutReady() && now() < resumeDeadline) {
|
|
860
|
+
await sleep(50)
|
|
861
|
+
}
|
|
862
|
+
resumeView.setCamera(resumeCamera)
|
|
863
|
+
// 三轮「删一条 → 立刻补一条」。删除走完时发散渲染已经重开了一轮、场景又只铺到一段,
|
|
864
|
+
// 紧接着到的那条补丁正是在这种时候来的 —— 单次量噪声大(第一次还冷着),按最快的一次判快路径
|
|
865
|
+
const resumeRemoved = []
|
|
866
|
+
const resumeAddedList = []
|
|
867
|
+
let resumedPending = true
|
|
868
|
+
let resumeFastestMs = Infinity
|
|
869
|
+
for (let round = 0; round < 3; round += 1) {
|
|
870
|
+
resumeRemoved.push(`r${5000 + round}`)
|
|
871
|
+
resumeView.removePoints(`r${5000 + round}`)
|
|
872
|
+
resumedPending = resumedPending && !resumeView.isLayoutReady()
|
|
873
|
+
const added = { id: `r-new-${round}`, x: 1650 + round * 12, y: 850 + round * 12, color: '#ff3300', size: 8 }
|
|
874
|
+
resumeAddedList.push(added)
|
|
875
|
+
const started = now()
|
|
876
|
+
resumeView.addPoint(added)
|
|
877
|
+
resumeFastestMs = Math.min(resumeFastestMs, now() - started)
|
|
878
|
+
const settled = now() + 8000
|
|
879
|
+
while (!resumeView.isLayoutReady() && now() < settled) {
|
|
880
|
+
await sleep(50)
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
const resumeScene = resumeView.snapshotScene()
|
|
884
|
+
// 对照:一开始就是「删掉那三条、补上这三条」的那一份数据,整份铺好
|
|
885
|
+
const resumeRemovedSet = new Set(resumeRemoved)
|
|
886
|
+
const resumeFullPoints = resumePoints.filter(point => !resumeRemovedSet.has(point.id)).concat(resumeAddedList)
|
|
887
|
+
const resumeFullHost = document.createElement('div')
|
|
888
|
+
document.querySelector('#map').append(resumeFullHost)
|
|
889
|
+
const resumeFullView = createGisView(resumeFullHost, { points: resumeFullPoints, lines: resumeLines, width: 640, height: 380, quality: 'high' })
|
|
890
|
+
const resumeFullDeadline = now() + 8000
|
|
891
|
+
while (!resumeFullView.isLayoutReady() && now() < resumeFullDeadline) {
|
|
892
|
+
await sleep(50)
|
|
893
|
+
}
|
|
894
|
+
resumeFullView.setCamera(resumeCamera)
|
|
895
|
+
const resumeFullScene = resumeFullView.snapshotScene()
|
|
896
|
+
const resumeText = JSON.stringify(resumeScene)
|
|
897
|
+
const resumeFullText = JSON.stringify(resumeFullScene)
|
|
898
|
+
let resumeDiff = `快照 ${resumeText.length} 字节,逐字段一致`
|
|
899
|
+
if (resumeText !== resumeFullText) {
|
|
900
|
+
let at = 0
|
|
901
|
+
while (at < resumeText.length && resumeText[at] === resumeFullText[at]) {
|
|
902
|
+
at += 1
|
|
903
|
+
}
|
|
904
|
+
resumeDiff = `第 ${at} 个字符起不同:${resumeText.slice(Math.max(0, at - 30), at + 50)} / ${resumeFullText.slice(Math.max(0, at - 30), at + 50)}`
|
|
905
|
+
}
|
|
906
|
+
const resumeRefreshStarted = now()
|
|
907
|
+
resumeView.refresh()
|
|
908
|
+
const resumeRebuildMs = now() - resumeRefreshStarted
|
|
909
|
+
diag('删完立刻补 1 个节点(两万二千点 + 一万二千线)', `最快一次 ${resumeFastestMs.toFixed(2)} ms · 同一场景整份重排 ${resumeRebuildMs.toFixed(1)} ms`)
|
|
910
|
+
check('删完立刻补:删除之后场景确实回到「只铺到一段」的发散渲染状态(这条断言守的就是那条路)', resumedPending, `isLayoutReady=${resumeView.isLayoutReady()}`)
|
|
911
|
+
check('删完立刻补:铺完之后画出来的场景与整份重建逐字段一致(含新点的颜色与次序)', resumeText === resumeFullText, resumeDiff)
|
|
912
|
+
check('删完立刻补:发散渲染欠着账时补点仍是增量级(没回落整份重解析)', resumeFastestMs * 5 < resumeRebuildMs, `最快一次 ${resumeFastestMs.toFixed(2)} ms vs 整份重排 ${resumeRebuildMs.toFixed(1)} ms`)
|
|
913
|
+
const resumeLast = resumeAddedList[resumeAddedList.length - 1]
|
|
914
|
+
const resumeSpot = resumeView.project(resumeLast.x, resumeLast.y)
|
|
915
|
+
const resumeHit = resumeView.pick(resumeSpot[0], resumeSpot[1])
|
|
916
|
+
check('删完立刻补:补进来的点能被拾取(索引表跟着补了)', resumeHit?.item?.id === resumeLast.id, JSON.stringify({ id: resumeHit?.item?.id ?? null, 应为: resumeLast.id }))
|
|
917
|
+
resumeFullView.destroy()
|
|
918
|
+
resumeFullHost.remove()
|
|
919
|
+
resumeView.destroy()
|
|
920
|
+
resumeHost.remove()
|
|
921
|
+
|
|
922
|
+
/* ---------------- 线路动点在画画时补点:插段没打乱动点的槽位账(见 16.60) ---------------- */
|
|
923
|
+
// appendPoints 把新点插在「点符号」那一段的末尾,后面几段(背景点 / 动点 / 装饰 / 抬层点)跟着往后挪:
|
|
924
|
+
// 动点的槽位表从前没跟着挪,每帧按弧长写回时就落到别人身上(新点被顶到动点的位置上)。
|
|
925
|
+
// 这里用「带跑动动画的场景补一个点」守住这一趟的账:场景与新点都要对得上整份重建那一份
|
|
926
|
+
const effectPoints = []
|
|
927
|
+
for (let index = 0; index < 200; index += 1) {
|
|
928
|
+
effectPoints[index] = { id: `e${index}`, x: (index % 20) * 30, y: Math.floor(index / 20) * 30, size: 6 }
|
|
929
|
+
}
|
|
930
|
+
const effectHost = document.createElement('div')
|
|
931
|
+
document.querySelector('#map').append(effectHost)
|
|
932
|
+
const effectView = createGisView(effectHost, {
|
|
933
|
+
points: effectPoints,
|
|
934
|
+
lines: [{ id: 'ef1', from: 'e0', to: 'e199', effect: true }],
|
|
935
|
+
effects: { line: { symbol: 'square', size: 10 } },
|
|
936
|
+
width: 640, height: 380, quality: 'high'
|
|
937
|
+
})
|
|
938
|
+
const effectAdded = { id: 'e-new', x: 15, y: 15, color: '#00ff88', size: 10 }
|
|
939
|
+
await sleep(80)
|
|
940
|
+
const effectStarted = now()
|
|
941
|
+
effectView.addPoint(effectAdded)
|
|
942
|
+
const effectAddMs = now() - effectStarted
|
|
943
|
+
await sleep(120)
|
|
944
|
+
const effectScene = effectView.snapshotScene()
|
|
945
|
+
const effectFullHost = document.createElement('div')
|
|
946
|
+
document.querySelector('#map').append(effectFullHost)
|
|
947
|
+
const effectFullView = createGisView(effectFullHost, {
|
|
948
|
+
points: effectPoints.concat([effectAdded]),
|
|
949
|
+
lines: [{ id: 'ef1', from: 'e0', to: 'e199', effect: true }],
|
|
950
|
+
effects: { line: { symbol: 'square', size: 10 } },
|
|
951
|
+
width: 640, height: 380, quality: 'high'
|
|
952
|
+
})
|
|
953
|
+
await sleep(120)
|
|
954
|
+
const effectFullScene = effectFullView.snapshotScene()
|
|
955
|
+
diag('带线路动点的场景补 1 个节点', `${effectAddMs.toFixed(2)} ms`)
|
|
956
|
+
check('线路动点在画画时补点:动点与节点都在画(这一条守的是「插段之后账没乱」)', effectScene.points.filter(node => node.fill === 'rgba(0,255,136,1)').length === 1, `${effectScene.points.length} 个点里 ${effectScene.points.filter(node => node.fill === 'rgba(0,255,136,1)').length} 个新点`)
|
|
957
|
+
check('线路动点在画画时补点:画出来的场景与整份重建逐字段一致', JSON.stringify(effectScene) === JSON.stringify(effectFullScene), `新点 ${effectScene.points.length} / 对照 ${effectFullScene.points.length}`)
|
|
958
|
+
effectFullView.destroy()
|
|
959
|
+
effectFullHost.remove()
|
|
960
|
+
effectView.destroy()
|
|
961
|
+
effectHost.remove()
|
|
962
|
+
|
|
963
|
+
/* ---------------- 删除的增量路:removePoints 的增量路与整份重建等价 ---------------- */
|
|
964
|
+
// 删节点从前是「整份重排一次」;这一轮认出「新数组就是上一轮那批删掉几条」,
|
|
965
|
+
// 就地过滤解析结果、槽位表跟着搬一次,线路批次整个留着(引擎的 16.37)。这里盯五件事:
|
|
966
|
+
// ① 与「一开始就用删减后的数据铺好」画出来的场景逐字段一致(含删掉的线路不再画)
|
|
967
|
+
// ② 被删的节点拾取不到、邻点按 id 仍能拾取、getPoint 的下标按新数组对齐
|
|
968
|
+
// ③ 筛选的隐藏集合与选中镜像平移到新下标
|
|
969
|
+
// ④ 标签层没被这条捷径跳过
|
|
970
|
+
// ⑤ 单次删除明显快于「整份重建那条路」(增量没生效就会掉回同一个量级)
|
|
971
|
+
const removeCount = 20000
|
|
972
|
+
const removeLineCount = 24000
|
|
973
|
+
const removeHidden = [12000, 12001]
|
|
974
|
+
const removeHiddenSet = new Set(removeHidden)
|
|
975
|
+
const removePoints = new Array(removeCount)
|
|
976
|
+
for (let index = 0; index < removeCount; index += 1) {
|
|
977
|
+
removePoints[index] = { id: `d${index}`, x: (index % 200) * 14, y: Math.floor(index / 200) * 14, name: `删点 ${index}`, flag: removeHiddenSet.has(index) ? 'hide' : 'show' }
|
|
978
|
+
}
|
|
979
|
+
const removeLines = new Array(removeLineCount)
|
|
980
|
+
for (let index = 0; index < removeLineCount; index += 1) {
|
|
981
|
+
removeLines[index] = { id: `dl${index}`, from: `d${index % removeCount}`, to: `d${(index * 7 + 11) % removeCount}` }
|
|
982
|
+
}
|
|
983
|
+
// 逐个删的这六条都排在最后(下标平移的账里不动),再删两批:四条 + 连续一千条
|
|
984
|
+
const removedSingles = ['d19999', 'd19998', 'd19997', 'd19996', 'd19995', 'd19994']
|
|
985
|
+
const removedBatch = []
|
|
986
|
+
for (let index = 10000; index < 10004; index += 1) {
|
|
987
|
+
removedBatch.push(`d${index}`)
|
|
988
|
+
}
|
|
989
|
+
for (let index = 5000; index < 6000; index += 1) {
|
|
990
|
+
removedBatch.push(`d${index}`)
|
|
991
|
+
}
|
|
992
|
+
const removedAll = new Set([...removedSingles, ...removedBatch])
|
|
993
|
+
const removedPointIndexes = []
|
|
994
|
+
for (let index = 0; index < removeCount; index += 1) {
|
|
995
|
+
if (removedAll.has(`d${index}`)) {
|
|
996
|
+
removedPointIndexes.push(index)
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
// 老下标按这次删除平移:排在删掉的下标后面的往前挪 —— 隐藏集合与选中镜像该落到的就是它
|
|
1000
|
+
const shiftAfterRemoval = index => index - removedPointIndexes.filter(value => value < index).length
|
|
1001
|
+
const reducedPoints = removePoints.filter(point => !removedAll.has(point.id))
|
|
1002
|
+
const reducedLines = removeLines.filter(line => !removedAll.has(String(line.from)) && !removedAll.has(String(line.to)))
|
|
1003
|
+
const removeCamera = { center: [1400, 140], scale: 1 }
|
|
1004
|
+
// 对照场景:同一份数据、其中一个点配了 zIndex(分层信息让引擎那条窄路口拒绝增量、照常整份重建)。
|
|
1005
|
+
// 「整份重建那条路」的成本拿它量 —— 同一份相机、同一组删除,比的才是同一种活
|
|
1006
|
+
const layeredPoints = removePoints.map((point, index) => (index === 0 ? { ...point, zIndex: 2 } : point))
|
|
1007
|
+
const fallbackHost = document.createElement('div')
|
|
1008
|
+
document.querySelector('#map').append(fallbackHost)
|
|
1009
|
+
const fallbackView = createGisView(fallbackHost, { points: layeredPoints, lines: removeLines, width: 1200, height: 700, quality: 'high' })
|
|
1010
|
+
const fallbackDeadline = now() + 12000
|
|
1011
|
+
while (!fallbackView.isLayoutReady() && now() < fallbackDeadline) {
|
|
1012
|
+
await sleep(50)
|
|
1013
|
+
}
|
|
1014
|
+
fallbackView.setCamera(removeCamera)
|
|
1015
|
+
let fallbackFastestMs = Infinity
|
|
1016
|
+
for (const id of removedSingles) {
|
|
1017
|
+
const callStarted = now()
|
|
1018
|
+
fallbackView.removePoints([id])
|
|
1019
|
+
fallbackFastestMs = Math.min(fallbackFastestMs, now() - callStarted)
|
|
1020
|
+
}
|
|
1021
|
+
fallbackView.destroy()
|
|
1022
|
+
fallbackHost.remove()
|
|
1023
|
+
const removeHost = document.createElement('div')
|
|
1024
|
+
document.querySelector('#map').append(removeHost)
|
|
1025
|
+
const removeView = createGisView(removeHost, { points: removePoints, lines: removeLines, width: 1200, height: 700, quality: 'high', pointSelectStyle: { color: '#ff00ff', size: 18 } })
|
|
1026
|
+
const removeDeadline = now() + 12000
|
|
1027
|
+
while (!removeView.isLayoutReady() && now() < removeDeadline) {
|
|
1028
|
+
await sleep(50)
|
|
1029
|
+
}
|
|
1030
|
+
removeView.setCamera(removeCamera)
|
|
1031
|
+
removeView.setFilter({ points: { flag: 'show' }, worker: false })
|
|
1032
|
+
await sleep(60)
|
|
1033
|
+
const hiddenBefore = [...removeView.getFilterResult().hiddenPoints]
|
|
1034
|
+
removeView.selectFeatures('d15000')
|
|
1035
|
+
await sleep(30)
|
|
1036
|
+
const removeRebuildStarted = now()
|
|
1037
|
+
removeView.refresh()
|
|
1038
|
+
const removeRebuildMs = now() - removeRebuildStarted
|
|
1039
|
+
// 单次代价按「最快的一次」判:整批平均值会被 GC 与同页别的大场景搅动,
|
|
1040
|
+
// 而这条断言要看的是快路径有没有生效 —— 生效时最快那几次是个位数毫秒,
|
|
1041
|
+
// 回落整份重建时每一次都在整份重排的量级上;平均值仍照常报在读数里
|
|
1042
|
+
const removeTimes = []
|
|
1043
|
+
for (const id of removedSingles) {
|
|
1044
|
+
const callStarted = now()
|
|
1045
|
+
removeView.removePoints([id])
|
|
1046
|
+
removeTimes.push(now() - callStarted)
|
|
1047
|
+
}
|
|
1048
|
+
const removeFastestMs = Math.min(...removeTimes)
|
|
1049
|
+
const removeAverageMs = removeTimes.reduce((sum, value) => sum + value, 0) / removeTimes.length
|
|
1050
|
+
const removeBatchStarted = now()
|
|
1051
|
+
removeView.removePoints(removedBatch)
|
|
1052
|
+
const removeBatchMs = now() - removeBatchStarted
|
|
1053
|
+
const removeReadyDeadline = now() + 12000
|
|
1054
|
+
while (!removeView.isLayoutReady() && now() < removeReadyDeadline) {
|
|
1055
|
+
await sleep(50)
|
|
1056
|
+
}
|
|
1057
|
+
await sleep(80)
|
|
1058
|
+
const removedScene = removeView.snapshotScene()
|
|
1059
|
+
// 对照:同一份「删减后的数据」一开始就整份铺好,相机、筛选与选中对准同一份
|
|
1060
|
+
const removedFullHost = document.createElement('div')
|
|
1061
|
+
document.querySelector('#map').append(removedFullHost)
|
|
1062
|
+
const removedFullView = createGisView(removedFullHost, { points: reducedPoints, lines: reducedLines, width: 1200, height: 700, quality: 'high', pointSelectStyle: { color: '#ff00ff', size: 18 } })
|
|
1063
|
+
const removedFullDeadline = now() + 12000
|
|
1064
|
+
while (!removedFullView.isLayoutReady() && now() < removedFullDeadline) {
|
|
1065
|
+
await sleep(50)
|
|
1066
|
+
}
|
|
1067
|
+
removedFullView.setCamera(removeCamera)
|
|
1068
|
+
removedFullView.setFilter({ points: { flag: 'show' }, worker: false })
|
|
1069
|
+
removedFullView.selectFeatures('d15000')
|
|
1070
|
+
await sleep(80)
|
|
1071
|
+
const removedFullScene = removedFullView.snapshotScene()
|
|
1072
|
+
const removedText = JSON.stringify(removedScene)
|
|
1073
|
+
const removedFullText = JSON.stringify(removedFullScene)
|
|
1074
|
+
const removedSame = removedText === removedFullText
|
|
1075
|
+
let removedDiff = `快照 ${removedText.length} 字节,逐字段一致`
|
|
1076
|
+
if (!removedSame) {
|
|
1077
|
+
let at = 0
|
|
1078
|
+
while (at < removedText.length && removedText[at] === removedFullText[at]) {
|
|
1079
|
+
at += 1
|
|
1080
|
+
}
|
|
1081
|
+
removedDiff = `第 ${at} 个字符起不同:${removedText.slice(Math.max(0, at - 30), at + 50)} / ${removedFullText.slice(Math.max(0, at - 30), at + 50)}`
|
|
1082
|
+
}
|
|
1083
|
+
check('删除的增量路:删减之后的场景与整份重建逐字段一致(含删掉的线路不再画)', removedSame, removedDiff)
|
|
1084
|
+
const removedScreen = removeView.project(removePoints[5500].x, removePoints[5500].y)
|
|
1085
|
+
const removedHit = removeView.pick(removedScreen[0], removedScreen[1])
|
|
1086
|
+
check('删除的增量路:被删的节点拾取不到(解析结果与索引表都摘干净了)', removedHit?.item?.id !== 'd5500', JSON.stringify({ id: removedHit?.item?.id ?? null }))
|
|
1087
|
+
const keepScreen = removeView.project(removePoints[15000].x, removePoints[15000].y)
|
|
1088
|
+
const keepHit = removeView.pick(keepScreen[0], keepScreen[1])
|
|
1089
|
+
check('删除的增量路:邻点按 id 仍能拾取、getPoint 的下标按新数组对齐', keepHit?.item?.id === 'd15000' && removeView.getPoint('d6000')?.index === 5000 && removeView.getPoint('d19999') === null, JSON.stringify({ 拾取: keepHit?.item?.id ?? null, d6000: removeView.getPoint('d6000')?.index ?? null }))
|
|
1090
|
+
const hiddenAfter = [...removeView.getFilterResult().hiddenPoints]
|
|
1091
|
+
const hiddenWanted = removeHidden.map(shiftAfterRemoval)
|
|
1092
|
+
check('删除的增量路:筛选的隐藏集合平移到新下标(还是原来那两个要素)', JSON.stringify(hiddenAfter) === JSON.stringify(hiddenWanted) && hiddenBefore.length === removeHidden.length, `删除前 ${JSON.stringify(hiddenBefore)} → 删除后 ${JSON.stringify(hiddenAfter)},应为 ${JSON.stringify(hiddenWanted)}`)
|
|
1093
|
+
const selectionAfter = removeView.getSelections()
|
|
1094
|
+
const selectionWanted = shiftAfterRemoval(15000)
|
|
1095
|
+
check('删除的增量路:选中镜像平移到新下标、指的还是同一个要素', selectionAfter.length === 1 && selectionAfter[0].item?.id === 'd15000' && selectionAfter[0].index === selectionWanted, JSON.stringify({ id: selectionAfter[0]?.item?.id ?? null, index: selectionAfter[0]?.index ?? null, 应为: selectionWanted }))
|
|
1096
|
+
const removeLabelHost = document.createElement('div')
|
|
1097
|
+
document.querySelector('#map').append(removeLabelHost)
|
|
1098
|
+
const removeLabelView = createGisView(removeLabelHost, { points: [{ id: 'y1', x: 0, y: 0, name: '甲删' }, { id: 'y2', x: 60, y: 0, name: '乙留' }, { id: 'y3', x: 120, y: 0, name: '丙删' }], lines: [], width: 640, height: 380, label: { content: '{name}' } })
|
|
1099
|
+
await sleep(60)
|
|
1100
|
+
removeLabelView.removePoints(['y1', 'y3'])
|
|
1101
|
+
await sleep(80)
|
|
1102
|
+
const removeLabels = [...removeLabelHost.querySelectorAll('.gis-view__label')].map(node => node.textContent.trim())
|
|
1103
|
+
check('删除的增量路:标签层跟着重排(删掉的不再留、留下的照常铺)', removeLabels.includes('乙留') && !removeLabels.includes('甲删') && !removeLabels.includes('丙删'), removeLabels.join(' / '))
|
|
1104
|
+
diag('删 1 个节点(两万点 / 两万四千线场景)', `最快 ${removeFastestMs.toFixed(2)} ms / 平均 ${removeAverageMs.toFixed(2)} ms · 整份重建那条路最快 ${fallbackFastestMs.toFixed(2)} ms · 同一场景整份重排 ${removeRebuildMs.toFixed(1)} ms`)
|
|
1105
|
+
diag('一次删 1004 个节点(两万点场景)', `${removeBatchMs.toFixed(1)} ms`)
|
|
1106
|
+
check('删除的增量路:单次删除明显快于整份重建那条路(增量真的生效)', removeFastestMs * 1.5 < fallbackFastestMs, `增量最快 ${removeFastestMs.toFixed(2)} ms vs 整份重建最快 ${fallbackFastestMs.toFixed(2)} ms`)
|
|
1107
|
+
removeLabelView.destroy()
|
|
1108
|
+
removeLabelHost.remove()
|
|
1109
|
+
removedFullView.destroy()
|
|
1110
|
+
removedFullHost.remove()
|
|
1111
|
+
removeView.destroy()
|
|
1112
|
+
removeHost.remove()
|
|
1113
|
+
/* ---------------- 纯删线的增量路(见 16.39):点一条没删时线路那一半单独走窄路 ---------------- */
|
|
1114
|
+
// 从前的窄路口要求「节点数组真的少了几条」;纯删线(组件那条删除入口把点数组原样带回来)只能整份重排。
|
|
1115
|
+
// 现在点那一半逐元素对得上时也认:点侧账(过滤 / 符号账本 / 包围盒 / 涟漪)整段跳过,只搬线路的槽位表与下标。
|
|
1116
|
+
// 这里盯四件事:
|
|
1117
|
+
// ① 与「一开始就用删减后的线路铺好」的场景逐字段一致(点 / 线 / 包围盒都在快照里)
|
|
1118
|
+
// ② 删掉的线路按 id 查不到、邻线按 id 仍能查到、getLine 的下标按新数组对齐
|
|
1119
|
+
// ③ 选中镜像与筛选的隐藏集合平移到新下标(指的还是原来那几条)
|
|
1120
|
+
// ④ 单次纯删线明显快于「整份重建那条路」(有一条线配了 zIndex,线路层序让窄路口拒绝增量)
|
|
1121
|
+
const lineOnlyPointCount = 16000
|
|
1122
|
+
const lineOnlyCount = 12000
|
|
1123
|
+
const lineOnlyHiddenPoints = [4000, 4001]
|
|
1124
|
+
const lineOnlyHiddenLines = [200, 9000]
|
|
1125
|
+
const buildLineOnlyData = (layered) => {
|
|
1126
|
+
const hiddenPointSet = new Set(lineOnlyHiddenPoints)
|
|
1127
|
+
const hiddenLineSet = new Set(lineOnlyHiddenLines)
|
|
1128
|
+
const points = new Array(lineOnlyPointCount)
|
|
1129
|
+
for (let index = 0; index < lineOnlyPointCount; index += 1) {
|
|
1130
|
+
points[index] = { id: `p${index}`, x: (index % 160) * 15, y: Math.floor(index / 160) * 15, flag: hiddenPointSet.has(index) ? 'hide' : 'show' }
|
|
1131
|
+
}
|
|
1132
|
+
const lines = new Array(lineOnlyCount)
|
|
1133
|
+
for (let index = 0; index < lineOnlyCount; index += 1) {
|
|
1134
|
+
lines[index] = {
|
|
1135
|
+
id: `q${index}`,
|
|
1136
|
+
from: `p${index % lineOnlyPointCount}`,
|
|
1137
|
+
to: `p${(index * 11 + 7) % lineOnlyPointCount}`,
|
|
1138
|
+
flag: hiddenLineSet.has(index) ? 'hide' : 'show',
|
|
1139
|
+
// 分层数据:线路 zIndex 让「线路层序」这一笔账存在 —— 窄路口据此回落整份重建,
|
|
1140
|
+
// 对照场景量的就是那条路的代价(与增量路同一份数据、同一组删除)
|
|
1141
|
+
...(layered && index === 0 ? { zIndex: 2 } : null)
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
return { points, lines }
|
|
1145
|
+
}
|
|
1146
|
+
const lineOnlyRemovedIds = ['q3', 'q6000', 'q11999']
|
|
1147
|
+
const lineOnlyRemovedSet = new Set(lineOnlyRemovedIds)
|
|
1148
|
+
const lineOnlyCamera = { center: [1200, 700], scale: 1 }
|
|
1149
|
+
const lineOnlyData = buildLineOnlyData(false)
|
|
1150
|
+
const lineOnlyFallbackData = buildLineOnlyData(true)
|
|
1151
|
+
const lineOnlyReducedLines = lineOnlyData.lines.filter(line => !lineOnlyRemovedSet.has(line.id))
|
|
1152
|
+
const lineOnlyHost = document.createElement('div')
|
|
1153
|
+
document.querySelector('#map').append(lineOnlyHost)
|
|
1154
|
+
const lineOnlyView = createGisView(lineOnlyHost, { points: lineOnlyData.points, lines: lineOnlyData.lines, width: 1200, height: 700, quality: 'high' })
|
|
1155
|
+
const lineOnlyFallbackHost = document.createElement('div')
|
|
1156
|
+
document.querySelector('#map').append(lineOnlyFallbackHost)
|
|
1157
|
+
const lineOnlyFallbackView = createGisView(lineOnlyFallbackHost, { points: lineOnlyFallbackData.points, lines: lineOnlyFallbackData.lines, width: 1200, height: 700, quality: 'high' })
|
|
1158
|
+
for (const view of [lineOnlyView, lineOnlyFallbackView]) {
|
|
1159
|
+
const deadline = now() + 12000
|
|
1160
|
+
while (!view.isLayoutReady() && now() < deadline) {
|
|
1161
|
+
await sleep(50)
|
|
1162
|
+
}
|
|
1163
|
+
view.setCamera(lineOnlyCamera)
|
|
1164
|
+
view.setFilter({ points: { flag: 'show' }, lines: { flag: 'show' }, worker: false })
|
|
1165
|
+
}
|
|
1166
|
+
await sleep(60)
|
|
1167
|
+
lineOnlyView.selectFeatures('q8000')
|
|
1168
|
+
await sleep(40)
|
|
1169
|
+
let lineOnlyFallbackMs = Infinity
|
|
1170
|
+
for (const id of lineOnlyRemovedIds) {
|
|
1171
|
+
const callStarted = now()
|
|
1172
|
+
lineOnlyFallbackView.removeLines([id])
|
|
1173
|
+
lineOnlyFallbackMs = Math.min(lineOnlyFallbackMs, now() - callStarted)
|
|
1174
|
+
}
|
|
1175
|
+
let lineOnlyFastestMs = Infinity
|
|
1176
|
+
for (const id of lineOnlyRemovedIds) {
|
|
1177
|
+
const callStarted = now()
|
|
1178
|
+
lineOnlyView.removeLines([id])
|
|
1179
|
+
lineOnlyFastestMs = Math.min(lineOnlyFastestMs, now() - callStarted)
|
|
1180
|
+
}
|
|
1181
|
+
const lineOnlyDeadline = now() + 12000
|
|
1182
|
+
while (!lineOnlyView.isLayoutReady() && now() < lineOnlyDeadline) {
|
|
1183
|
+
await sleep(50)
|
|
1184
|
+
}
|
|
1185
|
+
await sleep(80)
|
|
1186
|
+
// 对照:同一份「删减后的线路」一开始就整份铺好,相机 / 筛选 / 选中对准同一份
|
|
1187
|
+
const lineOnlyFullHost = document.createElement('div')
|
|
1188
|
+
document.querySelector('#map').append(lineOnlyFullHost)
|
|
1189
|
+
const lineOnlyFullView = createGisView(lineOnlyFullHost, { points: lineOnlyData.points, lines: lineOnlyReducedLines, width: 1200, height: 700, quality: 'high' })
|
|
1190
|
+
const lineOnlyFullDeadline = now() + 12000
|
|
1191
|
+
while (!lineOnlyFullView.isLayoutReady() && now() < lineOnlyFullDeadline) {
|
|
1192
|
+
await sleep(50)
|
|
1193
|
+
}
|
|
1194
|
+
lineOnlyFullView.setCamera(lineOnlyCamera)
|
|
1195
|
+
lineOnlyFullView.setFilter({ points: { flag: 'show' }, lines: { flag: 'show' }, worker: false })
|
|
1196
|
+
lineOnlyFullView.selectFeatures('q8000')
|
|
1197
|
+
await sleep(80)
|
|
1198
|
+
const lineOnlyScene = lineOnlyView.snapshotScene()
|
|
1199
|
+
const lineOnlyFullScene = lineOnlyFullView.snapshotScene()
|
|
1200
|
+
const lineOnlyText = JSON.stringify(lineOnlyScene)
|
|
1201
|
+
const lineOnlyFullText = JSON.stringify(lineOnlyFullScene)
|
|
1202
|
+
const lineOnlySame = lineOnlyText === lineOnlyFullText
|
|
1203
|
+
let lineOnlyDiff = `快照 ${lineOnlyText.length} 字节,逐字段一致`
|
|
1204
|
+
if (!lineOnlySame) {
|
|
1205
|
+
const lineOnlyIdAt = (view, index) => view.getLine(index)?.item?.id ?? null
|
|
1206
|
+
const countLines = scene => scene.lines.length
|
|
1207
|
+
const firstDiff = (left, right) => {
|
|
1208
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
1209
|
+
if (JSON.stringify(left[index]) !== JSON.stringify(right[index])) {
|
|
1210
|
+
return index
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
return -1
|
|
1214
|
+
}
|
|
1215
|
+
const at = firstDiff(lineOnlyScene.lines, lineOnlyFullScene.lines)
|
|
1216
|
+
let dataMismatch = -1
|
|
1217
|
+
for (let index = 0; index < lineOnlyReducedLines.length; index += 1) {
|
|
1218
|
+
const left = lineOnlyView.getLine(index)
|
|
1219
|
+
const right = lineOnlyFullView.getLine(index)
|
|
1220
|
+
if ((left?.item?.id ?? null) !== (right?.item?.id ?? null) || JSON.stringify(left?.path ?? null) !== JSON.stringify(right?.path ?? null)) {
|
|
1221
|
+
dataMismatch = index
|
|
1222
|
+
break
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
const detail = at < 0 ? '无' : `${JSON.stringify(lineOnlyIdAt(lineOnlyView, at))} vs ${JSON.stringify(lineOnlyIdAt(lineOnlyFullView, at))}:${JSON.stringify(lineOnlyScene.lines[at])} / ${JSON.stringify(lineOnlyFullScene.lines[at])}`
|
|
1226
|
+
const dataDetail = dataMismatch < 0 ? '逐下标一致' : `第 ${dataMismatch} 条:${JSON.stringify(lineOnlyView.getLine(dataMismatch)?.item?.id ?? null)} vs ${JSON.stringify(lineOnlyFullView.getLine(dataMismatch)?.item?.id ?? null)}:${JSON.stringify(lineOnlyView.getLine(dataMismatch)?.path ?? null)} / ${JSON.stringify(lineOnlyFullView.getLine(dataMismatch)?.path ?? null)}`
|
|
1227
|
+
lineOnlyDiff = `行数 ${countLines(lineOnlyScene)} vs ${countLines(lineOnlyFullScene)} · 首个不同在第 ${at} 条(${detail})· 按下标查线:${dataDetail}`
|
|
1228
|
+
}
|
|
1229
|
+
check('纯删线的增量路:删减之后的场景与整份重建逐字段一致(删掉的线路不再画)', lineOnlySame, lineOnlyDiff)
|
|
1230
|
+
check('纯删线的增量路:删掉的线路按 id 查不到、邻线下标按新数组对齐', lineOnlyView.getLine('q3') === null && lineOnlyView.getLine('q6000') === null && lineOnlyView.getLine('q11999') === null && lineOnlyView.getLine('q4')?.index === 3 && lineOnlyView.getLine('q6001')?.index === 5999 && lineOnlyView.getLine('q11998')?.index === 11996, JSON.stringify({ q4: lineOnlyView.getLine('q4')?.index ?? null, q6001: lineOnlyView.getLine('q6001')?.index ?? null, q11998: lineOnlyView.getLine('q11998')?.index ?? null }))
|
|
1231
|
+
const lineOnlyHiddenAfter = [...lineOnlyView.getFilterResult().hiddenLines].sort((left, right) => left - right)
|
|
1232
|
+
// 隐藏集合按 cascade 算:标了 hide 的两条(老下标 200 / 9000 → 199 / 8998)连同绑在隐藏节点上的那几条一起藏 ——
|
|
1233
|
+
// 与整份重建那一份逐项比才是准的,再点名确认平移后的两条标了 hide 的线还在集合里
|
|
1234
|
+
const lineOnlyHiddenFull = [...lineOnlyFullView.getFilterResult().hiddenLines].sort((left, right) => left - right)
|
|
1235
|
+
const lineOnlySelection = lineOnlyView.getSelections()
|
|
1236
|
+
check('纯删线的增量路:隐藏集合与选中镜像平移到新下标(指的还是原来那几条)', JSON.stringify(lineOnlyHiddenAfter) === JSON.stringify(lineOnlyHiddenFull) && lineOnlyHiddenAfter.includes(199) && lineOnlyHiddenAfter.includes(8998) && lineOnlySelection.length === 1 && lineOnlySelection[0].item?.id === 'q8000' && lineOnlySelection[0].index === 7998, JSON.stringify({ hidden: lineOnlyHiddenAfter, 整份重建: lineOnlyHiddenFull, selection: lineOnlySelection[0]?.item?.id ?? null, index: lineOnlySelection[0]?.index ?? null }))
|
|
1237
|
+
diag('删 1 条线(一万六点 / 一万两千线场景,点一条没删)', `增量最快 ${lineOnlyFastestMs.toFixed(2)} ms · 整份重建那条路最快 ${lineOnlyFallbackMs.toFixed(2)} ms · 删完的场景与整份重建一致:${lineOnlySame}`)
|
|
1238
|
+
check('纯删线的增量路:单次删除明显快于整份重建那条路(增量真的生效)', lineOnlyFastestMs * 1.5 < lineOnlyFallbackMs, `增量最快 ${lineOnlyFastestMs.toFixed(2)} ms vs 整份重建最快 ${lineOnlyFallbackMs.toFixed(2)} ms`)
|
|
1239
|
+
lineOnlyFullView.destroy()
|
|
1240
|
+
lineOnlyFullHost.remove()
|
|
1241
|
+
lineOnlyView.destroy()
|
|
1242
|
+
lineOnlyHost.remove()
|
|
1243
|
+
lineOnlyFallbackView.destroy()
|
|
1244
|
+
lineOnlyFallbackHost.remove()
|
|
1245
|
+
/* ---------------- 样式快路径:混合补丁 / 原样重发 / 热力图都不重铺场景 ---------------- */
|
|
1246
|
+
// 面板上「拖一个滑块」常常是把基准样式与状态样式一起下发的混合补丁
|
|
1247
|
+
// (updateOptions({ pointStyle, lineStyle, pointHoverStyle, lineHoverStyle }))。
|
|
1248
|
+
// 这一类补丁走「逐要素重解析 + 槽位覆盖」的快路径,且两半的解析输入都没变时整半跳过。
|
|
1249
|
+
// 这里盯四件事:
|
|
1250
|
+
// ① 混合补丁之后的画面与「一开始就这么配」逐字段一致(含正悬停着的那一条)
|
|
1251
|
+
// ② 同一份配置原样再发一次:场景逐字段不动,代价接近零
|
|
1252
|
+
// ③ 只换热力图色带同样走快路径,重上色之后与整份重建逐字段一致
|
|
1253
|
+
// ④ 只动线路样式时节点那一半与装饰锚点逐字段不动(几何没动就不重算)
|
|
1254
|
+
const fastCount = 6000
|
|
1255
|
+
const fastLineCount = 2000
|
|
1256
|
+
const fastPoints = new Array(fastCount)
|
|
1257
|
+
for (let index = 0; index < fastCount; index += 1) {
|
|
1258
|
+
fastPoints[index] = { id: `f${index}`, x: (index % 60) * 14, y: Math.floor(index / 60) * 14, name: `快路径 ${index}` }
|
|
1259
|
+
}
|
|
1260
|
+
const fastLines = new Array(fastLineCount)
|
|
1261
|
+
for (let index = 0; index < fastLineCount; index += 1) {
|
|
1262
|
+
fastLines[index] = {
|
|
1263
|
+
id: `fl${index}`,
|
|
1264
|
+
from: `f${index}`,
|
|
1265
|
+
to: `f${(index * 7 + 11) % fastCount}`,
|
|
1266
|
+
decorations: [{ position: 0.5, symbol: 'square', size: 6, color: '#ffcc33' }]
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
const fastStyle = {
|
|
1270
|
+
pointStyle: { size: 8, color: '#3fa35e', strokeColor: '#123456', strokeWidth: 1.5 },
|
|
1271
|
+
lineStyle: { width: 2, color: 'rgba(214, 92, 40, 0.85)' },
|
|
1272
|
+
pointHoverStyle: { size: 16, color: '#ffd400' },
|
|
1273
|
+
lineHoverStyle: { width: 5, color: '#ffd400' }
|
|
1274
|
+
}
|
|
1275
|
+
const fastCamera = { center: [420, 280], scale: 1 }
|
|
1276
|
+
const fastHost = document.createElement('div')
|
|
1277
|
+
document.querySelector('#map').append(fastHost)
|
|
1278
|
+
// 悬停要显式打开(缺省全关,见 src/gis-view/features.js)
|
|
1279
|
+
const fastView = createGisView(fastHost, { points: fastPoints, lines: fastLines, width: 640, height: 380, quality: 'high', features: { hover: true } })
|
|
1280
|
+
const fastFullHost = document.createElement('div')
|
|
1281
|
+
document.querySelector('#map').append(fastFullHost)
|
|
1282
|
+
const fastFullView = createGisView(fastFullHost, { points: fastPoints, lines: fastLines, width: 640, height: 380, quality: 'high', features: { hover: true }, ...fastStyle })
|
|
1283
|
+
fastView.setCamera(fastCamera)
|
|
1284
|
+
fastFullView.setCamera(fastCamera)
|
|
1285
|
+
// 悬停:指针停在编号 1230 的节点上(世界坐标 420 / 280,正好落在相机中心),两个实例停同一条
|
|
1286
|
+
const hoverNode = (view, host) => {
|
|
1287
|
+
const canvas = host.querySelector('canvas')
|
|
1288
|
+
const box = canvas.getBoundingClientRect()
|
|
1289
|
+
const spot = view.project(420, 280)
|
|
1290
|
+
canvas.dispatchEvent(new PointerEvent('pointermove', {
|
|
1291
|
+
bubbles: true, cancelable: true, pointerId: 77, pointerType: 'mouse', button: 0, buttons: 0,
|
|
1292
|
+
clientX: box.left + spot[0], clientY: box.top + spot[1]
|
|
1293
|
+
}))
|
|
1294
|
+
}
|
|
1295
|
+
hoverNode(fastView, fastHost)
|
|
1296
|
+
hoverNode(fastFullView, fastFullHost)
|
|
1297
|
+
await sleep(120)
|
|
1298
|
+
const fastFullText = JSON.stringify(fastFullView.snapshotScene())
|
|
1299
|
+
const fastStarted = now()
|
|
1300
|
+
fastView.updateOptions(fastStyle)
|
|
1301
|
+
const fastPatchMs = now() - fastStarted
|
|
1302
|
+
const fastScene = fastView.snapshotScene()
|
|
1303
|
+
const fastText = JSON.stringify(fastScene)
|
|
1304
|
+
const diffText = (left, right) => {
|
|
1305
|
+
let at = 0
|
|
1306
|
+
while (at < left.length && left[at] === right[at]) {
|
|
1307
|
+
at += 1
|
|
1308
|
+
}
|
|
1309
|
+
return `第 ${at} 个字符起不同:${left.slice(Math.max(0, at - 30), at + 50)} / ${right.slice(Math.max(0, at - 30), at + 50)}`
|
|
1310
|
+
}
|
|
1311
|
+
const hoveredNode = fastScene.points.find(node => node.fill === 'rgba(255,212,0,1)')
|
|
1312
|
+
check('样式快路径:混合补丁(基准 + 状态样式)之后的画面与整份重建逐字段一致',
|
|
1313
|
+
fastText === fastFullText,
|
|
1314
|
+
fastText === fastFullText ? `快照 ${fastText.length} 字节,逐字段一致` : diffText(fastText, fastFullText))
|
|
1315
|
+
check('样式快路径:悬停中的那一条按新的悬停样式重解析(不是恰好比对一致)',
|
|
1316
|
+
hoveredNode?.fill === 'rgba(255,212,0,1)' && hoveredNode.size > 8,
|
|
1317
|
+
JSON.stringify({ size: hoveredNode?.size, fill: hoveredNode?.fill }))
|
|
1318
|
+
let repeatMs = Infinity
|
|
1319
|
+
for (let round = 0; round < 3; round += 1) {
|
|
1320
|
+
const repeatStarted = now()
|
|
1321
|
+
fastView.updateOptions(fastStyle)
|
|
1322
|
+
repeatMs = Math.min(repeatMs, now() - repeatStarted)
|
|
1323
|
+
}
|
|
1324
|
+
check('样式快路径:同一份配置原样再发一次,场景逐字段不动', JSON.stringify(fastView.snapshotScene()) === fastText)
|
|
1325
|
+
check('样式快路径:原样重发的代价接近零(两半的解析输入都没变,整半跳过)',
|
|
1326
|
+
repeatMs * 10 < fastPatchMs,
|
|
1327
|
+
`原样 ${repeatMs.toFixed(2)} ms vs 混合补丁 ${fastPatchMs.toFixed(2)} ms`)
|
|
1328
|
+
const beforeLineScene = fastView.snapshotScene()
|
|
1329
|
+
const widthBefore = beforeLineScene.lines.map(line => line.width)
|
|
1330
|
+
fastView.updateOptions({ lineStyle: { width: 6, color: 'rgba(26, 159, 176, 0.9)' } })
|
|
1331
|
+
const afterLineScene = fastView.snapshotScene()
|
|
1332
|
+
const widthAfter = afterLineScene.lines.map(line => line.width)
|
|
1333
|
+
check('样式快路径:只动线路样式,节点那一半逐字段不动',
|
|
1334
|
+
JSON.stringify(afterLineScene.points) === JSON.stringify(beforeLineScene.points) &&
|
|
1335
|
+
JSON.stringify(afterLineScene.raised) === JSON.stringify(beforeLineScene.raised) &&
|
|
1336
|
+
JSON.stringify(afterLineScene.backgroundPoints) === JSON.stringify(beforeLineScene.backgroundPoints))
|
|
1337
|
+
check('样式快路径:只动线路样式,装饰锚点逐字段不动(几何没动就不重算)',
|
|
1338
|
+
beforeLineScene.decorations.length > 0 && JSON.stringify(afterLineScene.decorations) === JSON.stringify(beforeLineScene.decorations),
|
|
1339
|
+
`${beforeLineScene.decorations.length} 个装饰`)
|
|
1340
|
+
check('样式快路径:只动线路样式,画出来的线宽照着新配置走',
|
|
1341
|
+
widthAfter.length > 0 && widthAfter.every((width, index) => width > 0 && Math.abs(width - widthBefore[index] * 3) < 0.01),
|
|
1342
|
+
`${widthBefore[0]} -> ${widthAfter[0]}`)
|
|
1343
|
+
// 线路窄样式:只改全局线宽 / 颜色 / 透明度,逐条重算 paint 链并补槽位;dataVersion 与虚线 / 自身样式都不动
|
|
1344
|
+
const lineNarrowVersion = fastView.dataVersion
|
|
1345
|
+
const lineNarrowStarted = now()
|
|
1346
|
+
fastView.updateLineStyle({ width: 7.5, color: '#1aa0b0', opacity: 0.66 })
|
|
1347
|
+
const lineNarrowMs = now() - lineNarrowStarted
|
|
1348
|
+
const lineNarrowStyle = fastView.getLineStyle(2)
|
|
1349
|
+
check('线路窄样式:getLineStyle 读到新的线宽 / 颜色 / 透明度,dataVersion 不变',
|
|
1350
|
+
lineNarrowStyle !== null && Math.abs(lineNarrowStyle.width - 7.5) < 1e-6 && Math.abs(lineNarrowStyle.opacity - 0.66) < 1e-6 &&
|
|
1351
|
+
lineNarrowStyle.color[1] > 0.5 && lineNarrowStyle.color[2] > 0.5 && lineNarrowVersion === fastView.dataVersion,
|
|
1352
|
+
`${JSON.stringify(lineNarrowStyle)} · dataVersion ${lineNarrowVersion} -> ${fastView.dataVersion}`)
|
|
1353
|
+
diag('线路窄样式:2000 条只改全局线宽 / 颜色 / 透明度', `${lineNarrowMs.toFixed(2)} ms`)
|
|
1354
|
+
fastView.updateLine('fl0', { width: 11, color: '#ff0000' })
|
|
1355
|
+
fastView.updateLine('fl1230', { dash: 'dashed' })
|
|
1356
|
+
fastView.updateLineStyle({ width: 3, color: '#33aaff', opacity: 1 })
|
|
1357
|
+
const lineOwnStyle = fastView.getLineStyle(0)
|
|
1358
|
+
const lineDashScene = fastView.snapshotScene()
|
|
1359
|
+
check('线路窄样式:逐线自身线宽 / 颜色优先,全局换色不会覆盖它',
|
|
1360
|
+
lineOwnStyle !== null && Math.abs(lineOwnStyle.width - 11) < 1e-6 && lineOwnStyle.color[0] > 0.99 && lineOwnStyle.color[1] < 0.01,
|
|
1361
|
+
JSON.stringify(lineOwnStyle))
|
|
1362
|
+
check('线路窄样式:只换线宽 / 颜色 / 透明度时虚线图案保持不动', lineDashScene.lines.some(line => Array.isArray(line.dash) && line.dash.length > 0))
|
|
1363
|
+
// 有线路规则时窄路必须让开:规则改出来的宽度 / 颜色仍按规则走,规则外的那条跟着全局样式
|
|
1364
|
+
fastView.updateOptions({ lineRules: [{ when: { id: 'fl2' }, style: { width: 8, color: '#00aa55' } }] })
|
|
1365
|
+
fastView.updateLineStyle({ width: 2, color: '#888888' })
|
|
1366
|
+
const lineRuleStyle = fastView.getLineStyle(2)
|
|
1367
|
+
const lineGlobalStyle = fastView.getLineStyle(3)
|
|
1368
|
+
check('线路窄样式:存在线路规则时回退整份解析,规则优先于新的全局线样式',
|
|
1369
|
+
lineRuleStyle !== null && Math.abs(lineRuleStyle.width - 8) < 1e-6 && lineRuleStyle.color[1] > 0.6 && lineRuleStyle.color[0] < 0.01 &&
|
|
1370
|
+
lineGlobalStyle !== null && Math.abs(lineGlobalStyle.width - 2) < 1e-6,
|
|
1371
|
+
`规则 ${JSON.stringify(lineRuleStyle)} / 全局 ${JSON.stringify(lineGlobalStyle)}`)
|
|
1372
|
+
fastView.updateOptions({ lineRules: [] })
|
|
1373
|
+
// 热力图:值域色带换一份,重上色之后与「一开始就用这份色带」逐字段一致
|
|
1374
|
+
const heatCount = 1200
|
|
1375
|
+
const heatPoints = new Array(heatCount)
|
|
1376
|
+
for (let index = 0; index < heatCount; index += 1) {
|
|
1377
|
+
heatPoints[index] = { id: `h${index}`, x: (index % 40) * 16, y: Math.floor(index / 40) * 16, value: index % 100 }
|
|
1378
|
+
}
|
|
1379
|
+
const heatLines = new Array(300)
|
|
1380
|
+
for (let index = 0; index < heatLines.length; index += 1) {
|
|
1381
|
+
heatLines[index] = { id: `hl${index}`, from: `h${index}`, to: `h${(index * 13 + 5) % heatCount}`, value: (index * 7) % 100 }
|
|
1382
|
+
}
|
|
1383
|
+
const heatA = { enabled: true, field: 'value', targets: ['point', 'line'], colors: ['#0b3d91', '#ffd166', '#ff5e5e'] }
|
|
1384
|
+
const heatB = { enabled: true, field: 'value', targets: ['point', 'line'], colors: ['#123456', '#7ce0b0', '#ff8a3d'] }
|
|
1385
|
+
const heatHost = document.createElement('div')
|
|
1386
|
+
document.querySelector('#map').append(heatHost)
|
|
1387
|
+
const heatView = createGisView(heatHost, { points: heatPoints, lines: heatLines, heatmap: heatA, width: 640, height: 380, quality: 'high' })
|
|
1388
|
+
const heatFullHost = document.createElement('div')
|
|
1389
|
+
document.querySelector('#map').append(heatFullHost)
|
|
1390
|
+
const heatFullView = createGisView(heatFullHost, { points: heatPoints, lines: heatLines, heatmap: heatB, width: 640, height: 380, quality: 'high' })
|
|
1391
|
+
heatView.setCamera({ center: [320, 200], scale: 1 })
|
|
1392
|
+
heatFullView.setCamera({ center: [320, 200], scale: 1 })
|
|
1393
|
+
const heatBefore = JSON.stringify(heatView.snapshotScene())
|
|
1394
|
+
heatView.updateOptions({ heatmap: heatB })
|
|
1395
|
+
const heatAfter = JSON.stringify(heatView.snapshotScene())
|
|
1396
|
+
const heatFullText = JSON.stringify(heatFullView.snapshotScene())
|
|
1397
|
+
check('样式快路径:换热力图色带走的也是快路径,画面与整份重建逐字段一致',
|
|
1398
|
+
heatAfter === heatFullText,
|
|
1399
|
+
heatAfter === heatFullText ? `快照 ${heatAfter.length} 字节,逐字段一致` : diffText(heatAfter, heatFullText))
|
|
1400
|
+
check('样式快路径:色带真的换过(不是两份快照都没上色)', heatBefore !== heatAfter)
|
|
1401
|
+
// 同一份配置对象原样再发一次:配置是按引用存的,就地改过数据(值域跟着变)时也要重算
|
|
1402
|
+
heatPoints[0].value = 1000000
|
|
1403
|
+
heatView.updateOptions({ heatmap: heatB })
|
|
1404
|
+
const heatRefreshedText = JSON.stringify(heatView.snapshotScene())
|
|
1405
|
+
const heatFreshHost = document.createElement('div')
|
|
1406
|
+
document.querySelector('#map').append(heatFreshHost)
|
|
1407
|
+
const heatFreshView = createGisView(heatFreshHost, { points: heatPoints, lines: heatLines, heatmap: heatB, width: 640, height: 380, quality: 'high' })
|
|
1408
|
+
heatFreshView.setCamera({ center: [320, 200], scale: 1 })
|
|
1409
|
+
const heatFreshText = JSON.stringify(heatFreshView.snapshotScene())
|
|
1410
|
+
check('样式快路径:同一份热力图配置原样再发一次,值域按当前数据重算(与整份重建一致)',
|
|
1411
|
+
heatRefreshedText !== heatAfter && heatRefreshedText === heatFreshText,
|
|
1412
|
+
heatRefreshedText === heatFreshText ? `快照 ${heatRefreshedText.length} 字节,逐字段一致` : diffText(heatRefreshedText, heatFreshText))
|
|
1413
|
+
// 密度场:只改 opacity 时复用上一份采样与贴图;换色带 / 换要素值才重烤。
|
|
1414
|
+
// 判断重烤用 heatDensityStats 的对象身份:只改画法那条快路不会换它。
|
|
1415
|
+
const densityOption = {
|
|
1416
|
+
enabled: true,
|
|
1417
|
+
field: 'value',
|
|
1418
|
+
targets: ['point'],
|
|
1419
|
+
domain: [0, 100],
|
|
1420
|
+
colors: ['#0b3d91', '#ffd166', '#ff5e5e'],
|
|
1421
|
+
density: { opacity: 1, radius: 22, resolution: 192, levels: 8 }
|
|
1422
|
+
}
|
|
1423
|
+
const densityHost = document.createElement('div')
|
|
1424
|
+
document.querySelector('#map').append(densityHost)
|
|
1425
|
+
const densityView = createGisView(densityHost, {
|
|
1426
|
+
points: heatPoints,
|
|
1427
|
+
lines: [],
|
|
1428
|
+
heatmap: densityOption,
|
|
1429
|
+
width: 640,
|
|
1430
|
+
height: 380,
|
|
1431
|
+
quality: 'high',
|
|
1432
|
+
showGrid: false,
|
|
1433
|
+
background: null
|
|
1434
|
+
})
|
|
1435
|
+
densityView.fitView()
|
|
1436
|
+
await densityView.whenRendered()
|
|
1437
|
+
await densityView.whenRendered()
|
|
1438
|
+
const densityFirst = densityView.getStats().heatDensity
|
|
1439
|
+
check('热力图密度场:首帧烤出有效场(有采样数、贴图尺寸非零)',
|
|
1440
|
+
Boolean(densityFirst) && densityFirst.samples > 0 && densityFirst.width > 0 && densityFirst.height > 0,
|
|
1441
|
+
JSON.stringify(densityFirst))
|
|
1442
|
+
const opacityOption = { ...densityOption, density: { ...densityOption.density, opacity: 0.35 } }
|
|
1443
|
+
const opacityStarted = now()
|
|
1444
|
+
densityView.updateOptions({ heatmap: opacityOption })
|
|
1445
|
+
const opacityUpdateMs = now() - opacityStarted
|
|
1446
|
+
await densityView.whenRendered()
|
|
1447
|
+
const densityAfterOpacity = densityView.getStats().heatDensity
|
|
1448
|
+
check('热力图密度场:只改 density.opacity 不重采、不重烤(复用同一份烘焙读数)',
|
|
1449
|
+
densityAfterOpacity === densityFirst)
|
|
1450
|
+
const colorOption = { ...opacityOption, colors: ['#123456', '#7ce0b0', '#ff8a3d'] }
|
|
1451
|
+
const colorStarted = now()
|
|
1452
|
+
densityView.updateOptions({ heatmap: colorOption })
|
|
1453
|
+
const colorUpdateMs = now() - colorStarted
|
|
1454
|
+
await densityView.whenRendered()
|
|
1455
|
+
const densityAfterColor = densityView.getStats().heatDensity
|
|
1456
|
+
check('热力图密度场:换色带会重烤(生成新一份烘焙读数)',
|
|
1457
|
+
densityAfterColor !== densityAfterOpacity && densityAfterColor.samples === densityAfterOpacity.samples)
|
|
1458
|
+
densityView.updatePoint('h0', { value: 7 })
|
|
1459
|
+
await densityView.whenRendered()
|
|
1460
|
+
const densityAfterPoint = densityView.getStats().heatDensity
|
|
1461
|
+
check('热力图密度场:定向 updatePoint 改要素值后场跟着重烤(不再停在旧数据上)',
|
|
1462
|
+
densityAfterPoint !== densityAfterColor && densityAfterPoint.samples === densityAfterColor.samples)
|
|
1463
1463
|
diag('热力图密度场更新', `纯透明度 ${opacityUpdateMs.toFixed(2)} ms / 换色带 ${colorUpdateMs.toFixed(2)} ms;烘焙 ${densityAfterColor.ms.toFixed(1)} ms`)
|
|
1464
1464
|
densityView.destroy()
|
|
1465
1465
|
densityHost.remove()
|
|
@@ -1679,553 +1679,553 @@
|
|
|
1679
1679
|
heatMotionHost.remove()
|
|
1680
1680
|
|
|
1681
1681
|
heatFreshView.destroy()
|
|
1682
|
-
heatFreshHost.remove()
|
|
1683
|
-
heatView.destroy()
|
|
1684
|
-
heatHost.remove()
|
|
1685
|
-
heatFullView.destroy()
|
|
1686
|
-
heatFullHost.remove()
|
|
1687
|
-
fastView.destroy()
|
|
1688
|
-
fastHost.remove()
|
|
1689
|
-
fastFullView.destroy()
|
|
1690
|
-
fastFullHost.remove()
|
|
1691
|
-
/* ---------------- 基准尺寸快路径:只改 pointStyle.size / sizeScale ---------------- */
|
|
1692
|
-
// 面板上拖「节点大小」滑块下发的是「只改了尺寸」的补丁:这一种补丁走一条窄路 ——
|
|
1693
|
-
// 每个要素只重算尺寸链与画出来的符号,规则 / 热力 / 颜色 / 描边 / 阴影 / 效果一个都不碰。
|
|
1694
|
-
// 这一组盯四件事:
|
|
1695
|
-
// ① 改完之后与「一开始就这么大」逐字段一致(含逐点 size / sizeScale / 描边覆盖与方形符号)
|
|
1696
|
-
// ② 悬停中的那一条按「新基准尺寸 × 悬停倍率」重算(状态样式没被窄路吃掉)
|
|
1697
|
-
// ③ 贴图 / SVG 符号退回整条路,画面同样与整份重建一致
|
|
1698
|
-
// ④ 只改 sizeScale(没改 size)也走同一条窄路
|
|
1699
|
-
const sizeCount = 2400
|
|
1700
|
-
const sizePoints = new Array(sizeCount)
|
|
1701
|
-
for (let index = 0; index < sizeCount; index += 1) {
|
|
1702
|
-
const point = { id: `s${index}`, x: (index % 60) * 16, y: Math.floor(index / 60) * 16 }
|
|
1703
|
-
if (index % 5 === 0) {
|
|
1704
|
-
point.size = 24
|
|
1705
|
-
}
|
|
1706
|
-
if (index % 7 === 0) {
|
|
1707
|
-
point.sizeScale = 0.5
|
|
1708
|
-
}
|
|
1709
|
-
if (index % 11 === 0) {
|
|
1710
|
-
point.strokeWidth = 2
|
|
1711
|
-
}
|
|
1712
|
-
if (index % 13 === 0) {
|
|
1713
|
-
point.symbol = { type: 'square' }
|
|
1714
|
-
}
|
|
1715
|
-
sizePoints[index] = point
|
|
1716
|
-
}
|
|
1717
|
-
const sizeStyle = { color: '#3fa35e', strokeColor: '#123456', strokeWidth: 1.5 }
|
|
1718
|
-
const sizeHoverStyle = { sizeScale: 1.8 }
|
|
1719
|
-
const sizeHost = document.createElement('div')
|
|
1720
|
-
document.querySelector('#map').append(sizeHost)
|
|
1721
|
-
const sizeView = createGisView(sizeHost, {
|
|
1722
|
-
points: sizePoints,
|
|
1723
|
-
pointStyle: { ...sizeStyle, size: 8 },
|
|
1724
|
-
pointHoverStyle: sizeHoverStyle,
|
|
1725
|
-
features: { hover: true },
|
|
1726
|
-
width: 640, height: 380, quality: 'high', showGrid: false
|
|
1727
|
-
})
|
|
1728
|
-
const sizeFullHost = document.createElement('div')
|
|
1729
|
-
document.querySelector('#map').append(sizeFullHost)
|
|
1730
|
-
const sizeFullView = createGisView(sizeFullHost, {
|
|
1731
|
-
points: sizePoints,
|
|
1732
|
-
pointStyle: { ...sizeStyle, size: 14, sizeScale: 1.25 },
|
|
1733
|
-
pointHoverStyle: sizeHoverStyle,
|
|
1734
|
-
features: { hover: true },
|
|
1735
|
-
width: 640, height: 380, quality: 'high', showGrid: false
|
|
1736
|
-
})
|
|
1737
|
-
const sizeCamera = { center: [470, 190], scale: 1 }
|
|
1738
|
-
sizeView.setCamera(sizeCamera)
|
|
1739
|
-
sizeFullView.setCamera(sizeCamera)
|
|
1740
|
-
// 悬停要显式打开(缺省全关):指针停在编号 1224 的节点上,两个实例停同一条
|
|
1741
|
-
const hoverSpot = (view, host, point) => {
|
|
1742
|
-
const canvas = host.querySelector('canvas')
|
|
1743
|
-
const box = canvas.getBoundingClientRect()
|
|
1744
|
-
const spot = view.project(point.x, point.y)
|
|
1745
|
-
canvas.dispatchEvent(new PointerEvent('pointermove', {
|
|
1746
|
-
bubbles: true, cancelable: true, pointerId: 78, pointerType: 'mouse', button: 0, buttons: 0,
|
|
1747
|
-
clientX: box.left + spot[0], clientY: box.top + spot[1]
|
|
1748
|
-
}))
|
|
1749
|
-
}
|
|
1750
|
-
const sizeHoverPoint = sizePoints[1224]
|
|
1751
|
-
hoverSpot(sizeView, sizeHost, sizeHoverPoint)
|
|
1752
|
-
hoverSpot(sizeFullView, sizeFullHost, sizeHoverPoint)
|
|
1753
|
-
await sleep(120)
|
|
1754
|
-
const sizeRadius = (view, index) => view.listPoints({ indexes: [index] })[0]?.radius ?? 0
|
|
1755
|
-
const sizeStyleOf = (view, index) => JSON.stringify(view.getPointStyle(index))
|
|
1756
|
-
const sizeIndexes = [1224, 1000, 1200, 1218, 1235, 0]
|
|
1757
|
-
const sizePatchStarted = now()
|
|
1758
|
-
sizeView.updateOptions({ pointStyle: { ...sizeStyle, size: 14, sizeScale: 1.25 } })
|
|
1759
|
-
const sizePatchMs = now() - sizePatchStarted
|
|
1760
|
-
const sizeSceneText = JSON.stringify(sizeView.snapshotScene())
|
|
1761
|
-
const sizeFullText = JSON.stringify(sizeFullView.snapshotScene())
|
|
1762
|
-
check('尺寸快路径:只改 size / sizeScale 之后的画面与「一开始就这么大」逐字段一致',
|
|
1763
|
-
sizeSceneText === sizeFullText,
|
|
1764
|
-
sizeSceneText === sizeFullText ? `快照 ${sizeSceneText.length} 字节,逐字段一致(改一次 ${sizePatchMs.toFixed(2)} ms)` : diffText(sizeSceneText, sizeFullText))
|
|
1765
|
-
const sizeRadiiMatch = sizeIndexes.every(index => Math.abs(sizeRadius(sizeView, index) - sizeRadius(sizeFullView, index)) < 1e-6)
|
|
1766
|
-
const sizeStylesMatch = sizeIndexes.every(index => sizeStyleOf(sizeView, index) === sizeStyleOf(sizeFullView, index))
|
|
1767
|
-
check('尺寸快路径:逐点读数(画出来的半径 + 样式链尺寸)逐个与整份重建一致',
|
|
1768
|
-
sizeRadiiMatch && sizeStylesMatch,
|
|
1769
|
-
`半径${sizeRadiiMatch ? '一致' : '不一致'} / 样式${sizeStylesMatch ? '一致' : '不一致'}:${sizeIndexes.map(index => `${index}=${sizeRadius(sizeView, index)}`).join(' ')}`)
|
|
1770
|
-
check('尺寸快路径:悬停中的那一条按「新基准尺寸 × 悬停倍率」重算(状态样式没被吃掉)',
|
|
1771
|
-
Math.abs(sizeRadius(sizeView, 1224) / sizeRadius(sizeView, 1223) - 1.8) < 0.01,
|
|
1772
|
-
`悬停 vs 旁边的普通节点 (期望 1.8 倍)`)
|
|
1773
|
-
// ④ 只改 sizeScale:一样走窄路,一样与「一开始就这么配」逐字段一致
|
|
1774
|
-
const sizeScaleHost = document.createElement('div')
|
|
1775
|
-
document.querySelector('#map').append(sizeScaleHost)
|
|
1776
|
-
const sizeScaleView = createGisView(sizeScaleHost, {
|
|
1777
|
-
points: sizePoints,
|
|
1778
|
-
pointStyle: { ...sizeStyle, size: 14, sizeScale: 1.6 },
|
|
1779
|
-
pointHoverStyle: sizeHoverStyle,
|
|
1780
|
-
features: { hover: true },
|
|
1781
|
-
width: 640, height: 380, quality: 'high', showGrid: false
|
|
1782
|
-
})
|
|
1783
|
-
sizeScaleView.setCamera(sizeCamera)
|
|
1784
|
-
hoverSpot(sizeScaleView, sizeScaleHost, sizeHoverPoint)
|
|
1785
|
-
sizeView.updateOptions({ pointStyle: { ...sizeStyle, size: 14, sizeScale: 1.6 } })
|
|
1786
|
-
await sleep(60)
|
|
1787
|
-
check('尺寸快路径:只换 sizeScale 也走同一条窄路(画面与整份重建逐字段一致)',
|
|
1788
|
-
JSON.stringify(sizeView.snapshotScene()) === JSON.stringify(sizeScaleView.snapshotScene()))
|
|
1789
|
-
// ③ 贴图 / SVG 符号:尺寸会换烘焙档位与渲染项,这一半退回整条路,结果照样一致
|
|
1790
|
-
const svgSymbol = { type: 'svg', path: 'M8 1 L15 8 L8 15 L1 8 Z', viewBox: [0, 0, 16, 16], fill: '#ffffff', stroke: '#123456', strokeWidth: 1 }
|
|
1791
|
-
const svgCount = 240
|
|
1792
|
-
const svgPoints = new Array(svgCount)
|
|
1793
|
-
for (let index = 0; index < svgCount; index += 1) {
|
|
1794
|
-
svgPoints[index] = { id: `g${index}`, x: (index % 20) * 22, y: Math.floor(index / 20) * 22, symbol: index % 2 ? svgSymbol : 'circle' }
|
|
1795
|
-
}
|
|
1796
|
-
const svgHost = document.createElement('div')
|
|
1797
|
-
document.querySelector('#map').append(svgHost)
|
|
1798
|
-
const svgView = createGisView(svgHost, { points: svgPoints, pointStyle: { size: 8 }, width: 640, height: 380, quality: 'high', showGrid: false })
|
|
1799
|
-
const svgFullHost = document.createElement('div')
|
|
1800
|
-
document.querySelector('#map').append(svgFullHost)
|
|
1801
|
-
const svgFullView = createGisView(svgFullHost, { points: svgPoints, pointStyle: { size: 14 }, width: 640, height: 380, quality: 'high', showGrid: false })
|
|
1802
|
-
svgView.setCamera({ center: [210, 130], scale: 1 })
|
|
1803
|
-
svgFullView.setCamera({ center: [210, 130], scale: 1 })
|
|
1804
|
-
svgView.updateOptions({ pointStyle: { size: 14 } })
|
|
1805
|
-
await sleep(60)
|
|
1806
|
-
check('尺寸快路径:贴图 / SVG 符号退回整条路,画面同样与整份重建一致',
|
|
1807
|
-
JSON.stringify(svgView.snapshotScene()) === JSON.stringify(svgFullView.snapshotScene()))
|
|
1808
|
-
svgFullView.destroy()
|
|
1809
|
-
svgFullHost.remove()
|
|
1810
|
-
svgView.destroy()
|
|
1811
|
-
svgHost.remove()
|
|
1812
|
-
sizeScaleView.destroy()
|
|
1813
|
-
sizeScaleHost.remove()
|
|
1814
|
-
sizeFullView.destroy()
|
|
1815
|
-
sizeFullHost.remove()
|
|
1816
|
-
sizeView.destroy()
|
|
1817
|
-
sizeHost.remove()
|
|
1818
|
-
/* ---------------- 基准配色快路径:只改 pointStyle 的配色链 ---------------- */
|
|
1819
|
-
// 面板上换调色板、拖透明度下发的是「只改了配色」的补丁:颜色 / 描边色 / 描边宽 / 透明度
|
|
1820
|
-
// 走另一条窄路 —— 每个要素只重算配色链,尺寸链 / 规则 / 热力 / 阴影 / 效果 / 层级一个都不碰。
|
|
1821
|
-
// 这一组盯六件事:
|
|
1822
|
-
// ① 改完之后与「一开始就这么配」逐字段一致(含逐点自己的颜色 / 不透明度与方形符号)
|
|
1823
|
-
// ② 逐点把配色链整个定死了的那些算出来没变(窄路跳过它们),没配的按新基准上色
|
|
1824
|
-
// ③ 尺寸链一个字节都没动:逐点半径与整份重建一致,悬停那条还是「基准尺寸 × 悬停倍率」
|
|
1825
|
-
// ④ 悬停中的那一条按「新基准配色 + 悬停覆盖」重算(状态样式没被窄路吃掉)
|
|
1826
|
-
// ⑤ 配色 + 尺寸同一次补丁里一起改:两条链都走,结果照样与整份重建一致
|
|
1827
|
-
// ⑥ 贴图 / SVG 符号退回整条路(颜色会进烘焙键),画面同样逐字段一致
|
|
1828
|
-
const paintCount = 2400
|
|
1829
|
-
const paintPoints = new Array(paintCount)
|
|
1830
|
-
for (let index = 0; index < paintCount; index += 1) {
|
|
1831
|
-
const point = { id: `p${index}`, x: (index % 60) * 16, y: Math.floor(index / 60) * 16 }
|
|
1832
|
-
if (index % 5 === 0) {
|
|
1833
|
-
point.color = '#ff8800'
|
|
1834
|
-
point.opacity = 0.35
|
|
1835
|
-
}
|
|
1836
|
-
if (index % 7 === 0) {
|
|
1837
|
-
point.strokeColor = '#1f6f2e'
|
|
1838
|
-
point.strokeWidth = 4
|
|
1839
|
-
}
|
|
1840
|
-
if (index % 13 === 0) {
|
|
1841
|
-
point.symbol = { type: 'square' }
|
|
1842
|
-
}
|
|
1843
|
-
paintPoints[index] = point
|
|
1844
|
-
}
|
|
1845
|
-
const paintBase = { size: 10, color: '#3fa35e', strokeColor: '#123456', strokeWidth: 1 }
|
|
1846
|
-
const paintNext = { size: 10, color: '#2f7bd9', strokeColor: '#7a1f1f', strokeWidth: 3, opacity: 0.6 }
|
|
1847
|
-
const paintHoverStyle = { color: '#ffd400', sizeScale: 1.5 }
|
|
1848
|
-
const paintHost = document.createElement('div')
|
|
1849
|
-
document.querySelector('#map').append(paintHost)
|
|
1850
|
-
const paintView = createGisView(paintHost, {
|
|
1851
|
-
points: paintPoints,
|
|
1852
|
-
pointStyle: paintBase,
|
|
1853
|
-
pointHoverStyle: paintHoverStyle,
|
|
1854
|
-
features: { hover: true },
|
|
1855
|
-
width: 640, height: 380, quality: 'high', showGrid: false
|
|
1856
|
-
})
|
|
1857
|
-
const paintFullHost = document.createElement('div')
|
|
1858
|
-
document.querySelector('#map').append(paintFullHost)
|
|
1859
|
-
const paintFullView = createGisView(paintFullHost, {
|
|
1860
|
-
points: paintPoints,
|
|
1861
|
-
pointStyle: paintNext,
|
|
1862
|
-
pointHoverStyle: paintHoverStyle,
|
|
1863
|
-
features: { hover: true },
|
|
1864
|
-
width: 640, height: 380, quality: 'high', showGrid: false
|
|
1865
|
-
})
|
|
1866
|
-
const paintCamera = { center: [470, 190], scale: 1 }
|
|
1867
|
-
paintView.setCamera(paintCamera)
|
|
1868
|
-
paintFullView.setCamera(paintCamera)
|
|
1869
|
-
const paintHoverPoint = paintPoints[1224]
|
|
1870
|
-
hoverSpot(paintView, paintHost, paintHoverPoint)
|
|
1871
|
-
hoverSpot(paintFullView, paintFullHost, paintHoverPoint)
|
|
1872
|
-
await sleep(120)
|
|
1873
|
-
const paintBeforeText = JSON.stringify(paintView.snapshotScene())
|
|
1874
|
-
const paintPatchStarted = now()
|
|
1875
|
-
paintView.updateOptions({ pointStyle: paintNext })
|
|
1876
|
-
const paintPatchMs = now() - paintPatchStarted
|
|
1877
|
-
const paintAfterText = JSON.stringify(paintView.snapshotScene())
|
|
1878
|
-
const paintFullText = JSON.stringify(paintFullView.snapshotScene())
|
|
1879
|
-
check('配色快路径:只改颜色 / 描边之后的画面与「一开始就这么配」逐字段一致',
|
|
1880
|
-
paintAfterText === paintFullText,
|
|
1881
|
-
paintAfterText === paintFullText ? `快照 ${paintAfterText.length} 字节,逐字段一致(改一次 ${paintPatchMs.toFixed(2)} ms)` : diffText(paintAfterText, paintFullText))
|
|
1882
|
-
check('配色快路径:配色真的换过(不是两份快照都没上色)', paintBeforeText !== paintAfterText)
|
|
1883
|
-
// ② 逐点把配色链整个定死了的(编号 35 的倍数:自身颜色 + 不透明度 + 描边)这一轮算出来没变,
|
|
1884
|
-
// 只定了一半的(5 的倍数)另一半跟着新基准走 —— 快照里的颜色前缀对上就说明这一条没被顶掉
|
|
1885
|
-
const paintOwnKept = paintAfterText.includes('rgba(255,136,0,0.35)') && paintAfterText.includes('rgba(31,111,46,0.35)')
|
|
1886
|
-
const paintBaseApplied = paintAfterText.includes('rgba(47,123,217,0.6)') && paintAfterText.includes('rgba(122,31,31,0.6)')
|
|
1887
|
-
check('配色快路径:逐点自己的颜色 / 不透明度 / 描边没被基准配色顶掉,没配的那些按新基准上色',
|
|
1888
|
-
paintOwnKept && paintBaseApplied,
|
|
1889
|
-
`自身配色${paintOwnKept ? '保持' : '被改'} / 新基准配色${paintBaseApplied ? '已生效' : '没生效'}`)
|
|
1890
|
-
const paintRadius = (view, index) => view.listPoints({ indexes: [index] })[0]?.radius ?? 0
|
|
1891
|
-
const paintSizesKept = [1224, 1000, 1225, 1232].every(index => Math.abs(paintRadius(paintView, index) - paintRadius(paintFullView, index)) < 1e-6)
|
|
1892
|
-
check('配色快路径:尺寸链一个字节都没动(逐点半径与整份重建一致)', paintSizesKept)
|
|
1893
|
-
check('配色快路径:悬停中的那一条按「新基准配色 + 悬停覆盖」重算(状态样式没被窄路吃掉)',
|
|
1894
|
-
paintAfterText.includes('rgba(255,212,0,0.6)'))
|
|
1895
|
-
const paintHoverRatio = paintRadius(paintView, 1224) / paintRadius(paintView, 1223)
|
|
1896
|
-
check('配色快路径:悬停那条的尺寸还是「基准尺寸 × 悬停倍率」(配色没顺手把尺寸改了)',
|
|
1897
|
-
Math.abs(paintHoverRatio - 1.5) < 0.01,
|
|
1898
|
-
`悬停 / 旁边的普通节点 = ${paintHoverRatio.toFixed(3)}(期望 1.5)`)
|
|
1899
|
-
// ⑤ 配色 + 尺寸同一次补丁里一起改:先配色后尺寸两条链都走,结果与「一开始就这么配」逐字段一致
|
|
1900
|
-
const paintBothStyle = { size: 14, color: '#8a2be2', strokeColor: '#0f5132', strokeWidth: 2, opacity: 0.85 }
|
|
1901
|
-
const paintBothHost = document.createElement('div')
|
|
1902
|
-
document.querySelector('#map').append(paintBothHost)
|
|
1903
|
-
const paintBothView = createGisView(paintBothHost, {
|
|
1904
|
-
points: paintPoints,
|
|
1905
|
-
pointStyle: paintBothStyle,
|
|
1906
|
-
pointHoverStyle: paintHoverStyle,
|
|
1907
|
-
features: { hover: true },
|
|
1908
|
-
width: 640, height: 380, quality: 'high', showGrid: false
|
|
1909
|
-
})
|
|
1910
|
-
paintBothView.setCamera(paintCamera)
|
|
1911
|
-
hoverSpot(paintBothView, paintBothHost, paintHoverPoint)
|
|
1912
|
-
paintView.updateOptions({ pointStyle: paintBothStyle })
|
|
1913
|
-
await sleep(120)
|
|
1914
|
-
const paintBothText = JSON.stringify(paintView.snapshotScene())
|
|
1915
|
-
const paintBothFullText = JSON.stringify(paintBothView.snapshotScene())
|
|
1916
|
-
check('配色快路径:配色 + 尺寸同一次补丁里一起改(两条链都走),画面与整份重建逐字段一致',
|
|
1917
|
-
paintBothText === paintBothFullText,
|
|
1918
|
-
paintBothText === paintBothFullText ? `快照 ${paintBothText.length} 字节,逐字段一致` : diffText(paintBothText, paintBothFullText))
|
|
1919
|
-
// ⑥ 贴图 / SVG 符号:颜色可能进烘焙键,这一半退回整条路,结果照样一致
|
|
1920
|
-
const paintSvgCount = 240
|
|
1921
|
-
const paintSvgPoints = new Array(paintSvgCount)
|
|
1922
|
-
for (let index = 0; index < paintSvgCount; index += 1) {
|
|
1923
|
-
paintSvgPoints[index] = { id: `q${index}`, x: (index % 20) * 22, y: Math.floor(index / 20) * 22, symbol: index % 2 ? svgSymbol : 'circle' }
|
|
1924
|
-
}
|
|
1925
|
-
const paintSvgHost = document.createElement('div')
|
|
1926
|
-
document.querySelector('#map').append(paintSvgHost)
|
|
1927
|
-
const paintSvgView = createGisView(paintSvgHost, { points: paintSvgPoints, pointStyle: { size: 10, color: '#3fa35e', strokeColor: '#123456', strokeWidth: 1 }, width: 640, height: 380, quality: 'high', showGrid: false })
|
|
1928
|
-
const paintSvgFullHost = document.createElement('div')
|
|
1929
|
-
document.querySelector('#map').append(paintSvgFullHost)
|
|
1930
|
-
const paintSvgFullView = createGisView(paintSvgFullHost, { points: paintSvgPoints, pointStyle: { size: 10, color: '#c0392b', strokeColor: '#1b1b1b', strokeWidth: 2, opacity: 0.7 }, width: 640, height: 380, quality: 'high', showGrid: false })
|
|
1931
|
-
paintSvgView.setCamera({ center: [210, 130], scale: 1 })
|
|
1932
|
-
paintSvgFullView.setCamera({ center: [210, 130], scale: 1 })
|
|
1933
|
-
paintSvgView.updateOptions({ pointStyle: { size: 10, color: '#c0392b', strokeColor: '#1b1b1b', strokeWidth: 2, opacity: 0.7 } })
|
|
1934
|
-
await sleep(60)
|
|
1935
|
-
check('配色快路径:贴图 / SVG 符号退回整条路,画面同样与整份重建一致',
|
|
1936
|
-
JSON.stringify(paintSvgView.snapshotScene()) === JSON.stringify(paintSvgFullView.snapshotScene()))
|
|
1937
|
-
paintSvgFullView.destroy()
|
|
1938
|
-
paintSvgFullHost.remove()
|
|
1939
|
-
paintSvgView.destroy()
|
|
1940
|
-
paintSvgHost.remove()
|
|
1941
|
-
paintBothView.destroy()
|
|
1942
|
-
paintBothHost.remove()
|
|
1943
|
-
paintFullView.destroy()
|
|
1944
|
-
paintFullHost.remove()
|
|
1945
|
-
paintView.destroy()
|
|
1946
|
-
paintHost.remove()
|
|
1947
|
-
/* ---------------- 线路标签候选复用:改一条节点的标签不重扫线路 ---------------- */
|
|
1948
|
-
// 定向更新(updatePoint / patchPoints)只动节点侧(没改坐标 / id、也没有筛选联动)时,
|
|
1949
|
-
// 线路标签层接着用上一轮收好的候选 —— 十万条线的那一趟视口扫描(每次重排里最贵的一步)整个省掉。
|
|
1950
|
-
// 这一块盯四件事:
|
|
1951
|
-
// ① 走复用这条路改出来的标签层状态,与「配置换一份新对象、线路侧整层重排」逐字一致
|
|
1952
|
-
// ② 改线路自己的内容时缓存作废(新内容真的画出来)
|
|
1953
|
-
// ③ 挪节点(线路几何跟着动)时缓存作废,线路标签按新几何重摆
|
|
1954
|
-
// ④ 相机停手后的重排按新视口重收候选
|
|
1955
|
-
const reuseCount = 20000
|
|
1956
|
-
const reuseLineCount = 10000
|
|
1957
|
-
const reusePoints = new Array(reuseCount)
|
|
1958
|
-
for (let index = 0; index < reuseCount; index += 1) {
|
|
1959
|
-
reusePoints[index] = { id: `r${index}`, x: (index % 200) * 20, y: Math.floor(index / 200) * 20, name: `复用节点 ${index}` }
|
|
1960
|
-
}
|
|
1961
|
-
const reuseLines = new Array(reuseLineCount)
|
|
1962
|
-
for (let index = 0; index < reuseLineCount; index += 1) {
|
|
1963
|
-
const from = (index * 7) % reuseCount
|
|
1964
|
-
reuseLines[index] = { id: `rl${index}`, from: `r${from}`, to: `r${(from + 1 + (index % 17)) % reuseCount}`, name: `复用线路 ${index}` }
|
|
1965
|
-
}
|
|
1966
|
-
// 每次比对都换一份新的配置对象:定义表按配置对象身份缓存,换对象就等于「线路侧整层重排」
|
|
1967
|
-
const reuseConfig = () => ({ lineLabel: { content: '{name}', maxCount: 12 }, label: { content: '{name}', maxCount: 40 } })
|
|
1968
|
-
const reuseHost = document.createElement('div')
|
|
1969
|
-
document.querySelector('#map').append(reuseHost)
|
|
1970
|
-
const reuseView = createGisView(reuseHost, { points: reusePoints, lines: reuseLines, ...reuseConfig(), width: 640, height: 380, quality: 'high', showGrid: false })
|
|
1971
|
-
await sleep(160)
|
|
1972
|
-
const reuseNodes = () => [...reuseHost.querySelectorAll('.gis-view__label')]
|
|
1973
|
-
const reuseState = () => reuseNodes()
|
|
1974
|
-
.map(node => `${node.dataset.labelKind}|${node.dataset.labelIndex}|${node.dataset.labelLabel}|${node.textContent}|${node.style.transform}`)
|
|
1975
|
-
.sort()
|
|
1976
|
-
.join('\n')
|
|
1977
|
-
const reuseTextOf = (kind, index) => reuseNodes()
|
|
1978
|
-
.filter(node => node.dataset.labelKind === kind && node.dataset.labelIndex === String(index))
|
|
1979
|
-
.map(node => node.textContent)
|
|
1980
|
-
.join('|')
|
|
1981
|
-
const reusePointNode = reuseNodes().find(node => node.dataset.labelKind === 'point')
|
|
1982
|
-
const reuseLineNode = reuseNodes().find(node => node.dataset.labelKind === 'line')
|
|
1983
|
-
const reusePointIndex = Number(reusePointNode?.dataset.labelIndex ?? -1)
|
|
1984
|
-
const reuseLineIndex = Number(reuseLineNode?.dataset.labelIndex ?? -1)
|
|
1985
|
-
check('线路标签候选复用:两个标签层都画出来了(下面几条断言才有东西可盯)',
|
|
1986
|
-
reusePointIndex >= 0 && reuseLineIndex >= 0 && reuseNodes().length > 2,
|
|
1987
|
-
`${reuseNodes().length} 条标签:节点 ${reusePointIndex} / 线路 ${reuseLineIndex}`)
|
|
1988
|
-
// ① 改一条正在显示的节点标签内容:走「候选复用」,状态与整层重排逐字一致
|
|
1989
|
-
reuseView.updatePoint(`r${reusePointIndex}`, { name: '复用之后的内容' })
|
|
1990
|
-
await sleep(60)
|
|
1991
|
-
const reusedPointText = reuseTextOf('point', reusePointIndex)
|
|
1992
|
-
const reusedState = reuseState()
|
|
1993
|
-
reuseView.updateOptions(reuseConfig())
|
|
1994
|
-
await sleep(60)
|
|
1995
|
-
check('线路标签候选复用:改节点标签之后的标签层与整层重排逐字一致',
|
|
1996
|
-
reusedState === reuseState(),
|
|
1997
|
-
reusedState === reuseState() ? `${reuseNodes().length} 条标签逐字一致` : diffText(reusedState, reuseState()))
|
|
1998
|
-
check('线路标签候选复用:改的就是那一条(内容真的换了,不是两份快照都没动)', reusedPointText === '复用之后的内容', reusedPointText)
|
|
1999
|
-
// ② 改一条正在显示的线路内容:线路侧自己也变了,缓存必须作废
|
|
2000
|
-
reuseView.updateLine(`rl${reuseLineIndex}`, { name: '线路改过' })
|
|
2001
|
-
await sleep(60)
|
|
2002
|
-
const changedLineText = reuseTextOf('line', reuseLineIndex)
|
|
2003
|
-
const changedLineState = reuseState()
|
|
2004
|
-
reuseView.updateOptions(reuseConfig())
|
|
2005
|
-
await sleep(60)
|
|
2006
|
-
check('线路标签候选复用:改线路内容时缓存作废(新内容画出来、状态与整层重排一致)',
|
|
2007
|
-
changedLineText === '线路改过' && changedLineState === reuseState(),
|
|
2008
|
-
`内容 ${changedLineText};${changedLineState === reuseState() ? '状态一致' : diffText(changedLineState, reuseState())}`)
|
|
2009
|
-
// ③ 挪一个节点:线路几何跟着动,线路标签必须按新几何重摆
|
|
2010
|
-
reuseView.updatePoint(`r${reusePointIndex}`, { x: 6000, y: 4500 })
|
|
2011
|
-
await sleep(60)
|
|
2012
|
-
const movedState = reuseState()
|
|
2013
|
-
reuseView.updateOptions(reuseConfig())
|
|
2014
|
-
await sleep(60)
|
|
2015
|
-
check('线路标签候选复用:挪节点之后线路标签按新几何重摆(缓存已作废)',
|
|
2016
|
-
movedState !== reusedState && movedState === reuseState(),
|
|
2017
|
-
movedState === reuseState() ? '与整层重排一致' : diffText(movedState, reuseState()))
|
|
2018
|
-
// ④ 相机停手:按新视口重收候选
|
|
2019
|
-
reuseView.setCamera({ center: [2000, 1000], scale: 2.5 })
|
|
2020
|
-
await sleep(320)
|
|
2021
|
-
const zoomedState = reuseState()
|
|
2022
|
-
reuseView.updateOptions(reuseConfig())
|
|
2023
|
-
await sleep(60)
|
|
2024
|
-
check('线路标签候选复用:相机停手后的重排按新视口收候选(与整层重排一致)',
|
|
2025
|
-
zoomedState === reuseState(),
|
|
2026
|
-
zoomedState === reuseState() ? '与整层重排一致' : diffText(zoomedState, reuseState()))
|
|
2027
|
-
// 连着一圈点更新:第一次收候选(存缓存)、后续复用 —— 量一个中位数给文档留个读数。
|
|
2028
|
-
// 改的是「当前正画在屏幕上的那几条」,它们一定在候选窗口里,改完能直接读回来比对
|
|
2029
|
-
const reuseShownIndexes = reuseNodes()
|
|
2030
|
-
.filter(node => node.dataset.labelKind === 'point')
|
|
2031
|
-
.map(node => Number(node.dataset.labelIndex))
|
|
2032
|
-
.filter(index => index >= 0)
|
|
2033
|
-
const reuseSamples = []
|
|
2034
|
-
let lastReuseIndex = -1
|
|
2035
|
-
for (let round = 0; round < 24; round += 1) {
|
|
2036
|
-
const index = reuseShownIndexes[round % reuseShownIndexes.length]
|
|
2037
|
-
lastReuseIndex = index
|
|
2038
|
-
const started = now()
|
|
2039
|
-
reuseView.updatePoint(`r${index}`, { name: `连改 ${round}` })
|
|
2040
|
-
reuseSamples.push(now() - started)
|
|
2041
|
-
}
|
|
2042
|
-
reuseSamples.sort((left, right) => left - right)
|
|
2043
|
-
diag('线路标签候选复用:连续 24 次定向更新(2 万点 / 1 万线 + 两层标签)', `中位 ${reuseSamples[12].toFixed(2)} ms / 次,最大 ${reuseSamples[23].toFixed(2)} ms`)
|
|
2044
|
-
await sleep(60)
|
|
2045
|
-
const lastReuseText = reuseTextOf('point', lastReuseIndex)
|
|
2046
|
-
check('线路标签候选复用:连续更新之后标签内容跟着走', lastReuseText === '连改 23', lastReuseText)
|
|
2047
|
-
reuseView.destroy()
|
|
2048
|
-
reuseHost.remove()
|
|
2049
|
-
/* ---------------- 提醒:写错的键 / 档位 / 事件名 ---------------- */
|
|
2050
|
-
const warnings = []
|
|
2051
|
-
const warnHost = document.createElement('div')
|
|
2052
|
-
document.querySelector('#map').append(warnHost)
|
|
2053
|
-
const warned = createGisView(warnHost, {
|
|
2054
|
-
points: [],
|
|
2055
|
-
lines: [],
|
|
2056
|
-
draggable2: true,
|
|
2057
|
-
quality: 'nope',
|
|
2058
|
-
features: { drag: true, nope: true },
|
|
2059
|
-
onWarning: message => warnings.push(message)
|
|
2060
|
-
})
|
|
2061
|
-
check('未知配置项会点名(不再悄悄不生效)', warnings.some(message => message.includes('未知配置项 "draggable2"')), warnings[0])
|
|
2062
|
-
check('未知画质档位会点名', warnings.some(message => message.includes('未知的画质档位 "nope"')))
|
|
2063
|
-
check('未知功能开关会点名', warnings.some(message => message.includes('未知的功能开关 "nope"')))
|
|
2064
|
-
warned.on('nope', () => {})
|
|
2065
|
-
check('订阅未知事件名会点名', warnings.some(message => message.includes('未知的事件名 "nope"')))
|
|
2066
|
-
const warnedOnce = []
|
|
2067
|
-
const onceHost = document.createElement('div')
|
|
2068
|
-
document.querySelector('#map').append(onceHost)
|
|
2069
|
-
const onceView = createGisView(onceHost, { points: [], lines: [], typoKey: 1, onWarning: message => warnedOnce.push(message) })
|
|
2070
|
-
onceView.updateOptions({ typoKey: 2 })
|
|
2071
|
-
check('同一个键只提醒一次(updateOptions 反复调用不会刷屏)', warnedOnce.filter(message => message.includes('typoKey')).length === 1, `${warnedOnce.length} 条`)
|
|
2072
|
-
diag('配置提醒', warnings.join(' | '))
|
|
2073
|
-
warned.destroy()
|
|
2074
|
-
warnHost.remove()
|
|
2075
|
-
onceView.destroy()
|
|
2076
|
-
onceHost.remove()
|
|
2077
|
-
|
|
2078
|
-
/* ---------------- 批处理:一次回调 / 一条历史 ---------------- */
|
|
2079
|
-
const batchHost = document.createElement('div')
|
|
2080
|
-
document.querySelector('#map').append(batchHost)
|
|
2081
|
-
const payloads = []
|
|
2082
|
-
const small = createGisView(batchHost, {
|
|
2083
|
-
points: [{ id: 'a', x: 0, y: 0, name: '甲' }, { id: 'b', x: 60, y: 0, name: '乙' }, { id: 'c', x: 120, y: 0, name: '丙' }],
|
|
2084
|
-
lines: [],
|
|
2085
|
-
history: true,
|
|
2086
|
-
onDataChange: payload => payloads.push(payload)
|
|
2087
|
-
})
|
|
2088
|
-
const names = () => small.getData().points.map(point => point.name).join(',')
|
|
2089
|
-
const returned = small.batch(() => {
|
|
2090
|
-
small.updatePoint('a', { name: '甲改' })
|
|
2091
|
-
small.updatePoint('b', { name: '乙改' })
|
|
2092
|
-
small.updatePoint('c', { name: '丙改' })
|
|
2093
|
-
return 'batch 的返回值'
|
|
2094
|
-
})
|
|
2095
|
-
check('batch 返回回调的返回值', returned === 'batch 的返回值', String(returned))
|
|
2096
|
-
check('batch 里三次改动只发一条 onDataChange(type: batch)', payloads.length === 1 && payloads[0].type === 'batch' && payloads[0].changes.length === 3, JSON.stringify(payloads.map(payload => `${payload.type}:${payload.changes?.length ?? 0}`)))
|
|
2097
|
-
check('batch 的载荷按调用顺序列出每一步', payloads[0].changes.map(change => change.type).join(',') === 'updatePoint,updatePoint,updatePoint', JSON.stringify(payloads[0].changes.map(change => change.type)))
|
|
2098
|
-
check('batch 在历史里只占一步', small.getHistoryState().undoCount === 1 && small.getHistory()[0].type === 'batch', JSON.stringify(small.getHistoryState()))
|
|
2099
|
-
small.undo()
|
|
2100
|
-
check('撤销一步:整批改动一起回去', names() === '甲,乙,丙', names())
|
|
2101
|
-
small.redo()
|
|
2102
|
-
check('重做一步:整批改动一起回来', names() === '甲改,乙改,丙改', names())
|
|
2103
|
-
small.batch(() => {})
|
|
2104
|
-
check('空 batch 不会在历史里留空记录', small.getHistoryState().undoCount === 1, JSON.stringify(small.getHistoryState()))
|
|
2105
|
-
let threw = false
|
|
2106
|
-
try {
|
|
2107
|
-
small.batch(() => {
|
|
2108
|
-
small.updatePoint('a', { name: '甲抛错' })
|
|
2109
|
-
throw new Error('故意抛错')
|
|
2110
|
-
})
|
|
2111
|
-
}
|
|
2112
|
-
catch (error) {
|
|
2113
|
-
threw = error.message === '故意抛错'
|
|
2114
|
-
}
|
|
2115
|
-
check('batch 里抛错照常往外抛,已经改过的那部分仍合发一条', threw && small.getData().points[0].name === '甲抛错' && payloads.at(-1).type === 'batch' && payloads.at(-1).changes.length === 1, `${threw} / ${small.getData().points[0].name}`)
|
|
2116
|
-
const beforeNested = payloads.length
|
|
2117
|
-
small.batch(() => {
|
|
2118
|
-
small.updatePoint('b', { name: '乙嵌套' })
|
|
2119
|
-
small.batch(() => small.updatePoint('c', { name: '丙嵌套' }))
|
|
2120
|
-
})
|
|
2121
|
-
check('嵌套 batch 只在最外面收口一次', payloads.length === beforeNested + 1 && payloads.at(-1).changes.length === 2, `changes=${payloads.at(-1)?.changes?.length}`)
|
|
2122
|
-
check('batch 之后数据与 getData 一致', small.getData().points.map(point => point.id).join(',') === 'a,b,c', names())
|
|
2123
|
-
const plainHost = document.createElement('div')
|
|
2124
|
-
document.querySelector('#map').append(plainHost)
|
|
2125
|
-
const plainPayloads = []
|
|
2126
|
-
const plain = createGisView(plainHost, { points: [{ id: 'a', x: 0, y: 0, name: '甲' }], lines: [], onDataChange: payload => plainPayloads.push(payload) })
|
|
2127
|
-
plain.batch(() => plain.updatePoint('a', { name: '甲改' }))
|
|
2128
|
-
check('历史关着时 batch 照常工作(只是不记录)', plain.getHistoryState().enabled === false && plainPayloads.length === 1 && plainPayloads[0].type === 'batch' && plain.undo() === null, JSON.stringify(plain.getHistoryState()))
|
|
2129
|
-
|
|
2130
|
-
/* ---------------- 大数据量下的批量更新(回调次数就是差距) ---------------- */
|
|
2131
|
-
const runEach = (count) => {
|
|
2132
|
-
const started = now()
|
|
2133
|
-
for (let index = 0; index < count; index += 1) {
|
|
2134
|
-
big.updatePoint(points[index].id, { name: `逐个 ${index}` })
|
|
2135
|
-
}
|
|
2136
|
-
return now() - started
|
|
2137
|
-
}
|
|
2138
|
-
const eachMs = runEach(500)
|
|
2139
|
-
const eachPayloads = bigPayloads.filter(payload => payload.type === 'updatePoint').length
|
|
2140
|
-
const batchStarted = now()
|
|
2141
|
-
big.batch(() => {
|
|
2142
|
-
for (let index = 0; index < 500; index += 1) {
|
|
2143
|
-
big.updatePoint(points[index].id, { name: `批量 ${index}` })
|
|
2144
|
-
}
|
|
2145
|
-
})
|
|
2146
|
-
const batchMs = now() - batchStarted
|
|
2147
|
-
check('大数据量下 batch 只发一条(逐个调用是 500 条)', eachPayloads === 500 && bigPayloads.at(-1).type === 'batch' && bigPayloads.at(-1).changes.length === 500, `逐个 ${eachPayloads} 条 / batch 1 条(含 ${bigPayloads.at(-1).changes.length} 步)`)
|
|
2148
|
-
diag('改 500 个节点(20000 点场景)', `逐个调用 ${eachMs.toFixed(1)} ms / batch ${batchMs.toFixed(1)} ms`)
|
|
2149
|
-
|
|
2150
|
-
/* ---------------- 交互期细线快路径(gl.LINES)与 MSAA 读数 ---------------- */
|
|
2151
|
-
// 断言只看接口层读数:交互那一帧 getStats().thinLines 是不是真、停手后回不回来、
|
|
2152
|
-
// 门槛(线宽 / 虚线)与开关能不能把它关掉 —— 不读像素、不量毫秒。
|
|
2153
|
-
const thinHost = document.createElement('div')
|
|
2154
|
-
thinHost.style.cssText = 'width:520px;height:320px'
|
|
2155
|
-
document.body.appendChild(thinHost)
|
|
2156
|
-
const thinPoints = Array.from({ length: 12 }, (unused, index) => ({ id: `tp-${index}`, x: index * 10, y: index % 3 * 6, name: `小点 ${index}` }))
|
|
2157
|
-
const thinLines = thinPoints.slice(1).map((point, index) => ({ id: `tl-${index}`, from: thinPoints[index].id, to: point.id }))
|
|
2158
|
-
const thinView = createGisView(thinHost, {
|
|
2159
|
-
points: thinPoints,
|
|
2160
|
-
lines: thinLines,
|
|
2161
|
-
pointStyle: { size: 6 },
|
|
2162
|
-
lineStyle: { width: 1 },
|
|
2163
|
-
fitPadding: 20,
|
|
2164
|
-
// 交互期的那几条读数(细线快路径 / 后备缓冲)靠拖动触发:平移显式打开
|
|
2165
|
-
features: { pan: true }
|
|
2166
|
-
})
|
|
2167
|
-
// 页面底部的临时画布先滚进视口:动画与交互帧只在可见时连续推进
|
|
2168
|
-
thinHost.scrollIntoView({ block: 'center', inline: 'nearest' })
|
|
2169
|
-
await sleep(400)
|
|
2170
|
-
const thinCanvas = thinHost.querySelector('canvas')
|
|
2171
|
-
const thinPointerId = 91
|
|
2172
|
-
const fireThin = (type, at) => thinCanvas.dispatchEvent(new PointerEvent(type, {
|
|
2173
|
-
bubbles: true, cancelable: true, pointerId: thinPointerId, pointerType: 'mouse', button: 0,
|
|
2174
|
-
buttons: type === 'pointerup' ? 0 : 1, ...at
|
|
2175
|
-
}))
|
|
2176
|
-
// 每次挪 6px 起步:引擎要「超过 3px」(DRAG_THRESHOLD)才认成真的在拖,正好 3px 不算;
|
|
2177
|
-
// 每次都从 pointerdown 重新起手 —— 少了按下这一步,引擎会直接忽略这一串 pointermove。
|
|
2178
|
-
let thinMoves = 0
|
|
2179
|
-
/** 拖一下再读这一帧的读数:pointermove 之后等两帧,读数就是「交互期」那一帧的 */
|
|
2180
|
-
const statsWhilePanning = async () => {
|
|
2181
|
-
const box = thinCanvas.getBoundingClientRect()
|
|
2182
|
-
const centerX = box.left + box.width / 2
|
|
2183
|
-
const centerY = box.top + box.height / 2
|
|
2184
|
-
fireThin('pointerdown', { clientX: centerX, clientY: centerY })
|
|
2185
|
-
thinMoves += 6
|
|
2186
|
-
const at = { clientX: centerX + thinMoves, clientY: centerY }
|
|
2187
|
-
fireThin('pointermove', at)
|
|
2188
|
-
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
|
2189
|
-
const stats = thinView.getStats()
|
|
2190
|
-
fireThin('pointerup', at)
|
|
2191
|
-
return stats
|
|
2192
|
-
}
|
|
2193
|
-
const thinReadings = thinView.getStats()
|
|
2194
|
-
check('getStats() 给出 antialias / software / thinLines 三个读数',
|
|
2195
|
-
typeof thinReadings.antialias === 'boolean' && typeof thinReadings.software === 'boolean' && typeof thinReadings.thinLines === 'boolean',
|
|
2196
|
-
`antialias=${thinReadings.antialias} software=${thinReadings.software} thinLines=${thinReadings.thinLines}`)
|
|
2197
|
-
check('静止画面不走细线快路径(画的是带像素宽度的四边形)', thinReadings.thinLines === false)
|
|
2198
|
-
const thinPanning = await statsWhilePanning()
|
|
2199
|
-
check('交互期 1px 实线整批改走 gl.LINES 快路径', thinPanning.thinLines === true)
|
|
2200
|
-
await sleep(400)
|
|
2201
|
-
check('停手后回到四边形展开', thinView.getStats().thinLines === false)
|
|
2202
|
-
thinView.updateOptions({ interactionThinLines: false })
|
|
2203
|
-
const thinDisabled = await statsWhilePanning()
|
|
2204
|
-
check('interactionThinLines: false 时交互期也不走快路径', thinDisabled.thinLines === false)
|
|
2205
|
-
thinView.updateOptions({ interactionThinLines: true, lineStyle: { width: 8 } })
|
|
2206
|
-
const thinThick = await statsWhilePanning()
|
|
2207
|
-
check('线宽 8px(超过 3px 门槛)时整批留在四边形展开', thinThick.thinLines === false)
|
|
2208
|
-
thinView.updateOptions({ lineStyle: { width: 1, dash: 'dashed' } })
|
|
2209
|
-
const thinDashed = await statsWhilePanning()
|
|
2210
|
-
check('整批里有虚线时也不走快路径(细线画不出虚线)', thinDashed.thinLines === false)
|
|
2211
|
-
thinView.updateOptions({ lineStyle: { width: 1, dash: 'solid' } })
|
|
2212
|
-
const thinBack = await statsWhilePanning()
|
|
2213
|
-
check('改回细实线又走快路径(门槛是每帧现算的)', thinBack.thinLines === true)
|
|
2214
|
-
|
|
2215
|
-
/* ---------------- 交互期非 MSAA 后备缓冲(interactionNoMsaa) ---------------- */
|
|
2216
|
-
// 与细线快路径同一套量法(拖一下再读那一帧的读数),另外补一条像素级核对:
|
|
2217
|
-
// 同一个交互状态(不挪指针)下「开 / 关后备缓冲」各抓一帧,两帧只该在抗锯齿的边缘上有差别。
|
|
2218
|
-
const msaaOn = thinReadings.antialias === true
|
|
2219
|
-
const noMsaaPanning = await statsWhilePanning()
|
|
2220
|
-
check('getStats() 给出 noMsaa 读数', typeof noMsaaPanning.noMsaa === 'boolean', `noMsaa=${noMsaaPanning.noMsaa}`)
|
|
2221
|
-
check('画布开了 MSAA 时交互期走非 MSAA 后备缓冲(画布没开 MSAA 就不必多这一趟)',
|
|
2222
|
-
noMsaaPanning.noMsaa === msaaOn,
|
|
2223
|
-
`antialias=${thinReadings.antialias} 交互期 noMsaa=${noMsaaPanning.noMsaa}`)
|
|
2224
|
-
await sleep(400)
|
|
2225
|
-
check('停手后又直接画进画布(静态画面照旧走 MSAA)', thinView.getStats().noMsaa === false)
|
|
2226
|
-
thinView.updateOptions({ interactionNoMsaa: false })
|
|
2227
|
-
const noMsaaOff = await statsWhilePanning()
|
|
2228
|
-
check('interactionNoMsaa: false 时交互期也直接画进画布', noMsaaOff.noMsaa === false)
|
|
1682
|
+
heatFreshHost.remove()
|
|
1683
|
+
heatView.destroy()
|
|
1684
|
+
heatHost.remove()
|
|
1685
|
+
heatFullView.destroy()
|
|
1686
|
+
heatFullHost.remove()
|
|
1687
|
+
fastView.destroy()
|
|
1688
|
+
fastHost.remove()
|
|
1689
|
+
fastFullView.destroy()
|
|
1690
|
+
fastFullHost.remove()
|
|
1691
|
+
/* ---------------- 基准尺寸快路径:只改 pointStyle.size / sizeScale ---------------- */
|
|
1692
|
+
// 面板上拖「节点大小」滑块下发的是「只改了尺寸」的补丁:这一种补丁走一条窄路 ——
|
|
1693
|
+
// 每个要素只重算尺寸链与画出来的符号,规则 / 热力 / 颜色 / 描边 / 阴影 / 效果一个都不碰。
|
|
1694
|
+
// 这一组盯四件事:
|
|
1695
|
+
// ① 改完之后与「一开始就这么大」逐字段一致(含逐点 size / sizeScale / 描边覆盖与方形符号)
|
|
1696
|
+
// ② 悬停中的那一条按「新基准尺寸 × 悬停倍率」重算(状态样式没被窄路吃掉)
|
|
1697
|
+
// ③ 贴图 / SVG 符号退回整条路,画面同样与整份重建一致
|
|
1698
|
+
// ④ 只改 sizeScale(没改 size)也走同一条窄路
|
|
1699
|
+
const sizeCount = 2400
|
|
1700
|
+
const sizePoints = new Array(sizeCount)
|
|
1701
|
+
for (let index = 0; index < sizeCount; index += 1) {
|
|
1702
|
+
const point = { id: `s${index}`, x: (index % 60) * 16, y: Math.floor(index / 60) * 16 }
|
|
1703
|
+
if (index % 5 === 0) {
|
|
1704
|
+
point.size = 24
|
|
1705
|
+
}
|
|
1706
|
+
if (index % 7 === 0) {
|
|
1707
|
+
point.sizeScale = 0.5
|
|
1708
|
+
}
|
|
1709
|
+
if (index % 11 === 0) {
|
|
1710
|
+
point.strokeWidth = 2
|
|
1711
|
+
}
|
|
1712
|
+
if (index % 13 === 0) {
|
|
1713
|
+
point.symbol = { type: 'square' }
|
|
1714
|
+
}
|
|
1715
|
+
sizePoints[index] = point
|
|
1716
|
+
}
|
|
1717
|
+
const sizeStyle = { color: '#3fa35e', strokeColor: '#123456', strokeWidth: 1.5 }
|
|
1718
|
+
const sizeHoverStyle = { sizeScale: 1.8 }
|
|
1719
|
+
const sizeHost = document.createElement('div')
|
|
1720
|
+
document.querySelector('#map').append(sizeHost)
|
|
1721
|
+
const sizeView = createGisView(sizeHost, {
|
|
1722
|
+
points: sizePoints,
|
|
1723
|
+
pointStyle: { ...sizeStyle, size: 8 },
|
|
1724
|
+
pointHoverStyle: sizeHoverStyle,
|
|
1725
|
+
features: { hover: true },
|
|
1726
|
+
width: 640, height: 380, quality: 'high', showGrid: false
|
|
1727
|
+
})
|
|
1728
|
+
const sizeFullHost = document.createElement('div')
|
|
1729
|
+
document.querySelector('#map').append(sizeFullHost)
|
|
1730
|
+
const sizeFullView = createGisView(sizeFullHost, {
|
|
1731
|
+
points: sizePoints,
|
|
1732
|
+
pointStyle: { ...sizeStyle, size: 14, sizeScale: 1.25 },
|
|
1733
|
+
pointHoverStyle: sizeHoverStyle,
|
|
1734
|
+
features: { hover: true },
|
|
1735
|
+
width: 640, height: 380, quality: 'high', showGrid: false
|
|
1736
|
+
})
|
|
1737
|
+
const sizeCamera = { center: [470, 190], scale: 1 }
|
|
1738
|
+
sizeView.setCamera(sizeCamera)
|
|
1739
|
+
sizeFullView.setCamera(sizeCamera)
|
|
1740
|
+
// 悬停要显式打开(缺省全关):指针停在编号 1224 的节点上,两个实例停同一条
|
|
1741
|
+
const hoverSpot = (view, host, point) => {
|
|
1742
|
+
const canvas = host.querySelector('canvas')
|
|
1743
|
+
const box = canvas.getBoundingClientRect()
|
|
1744
|
+
const spot = view.project(point.x, point.y)
|
|
1745
|
+
canvas.dispatchEvent(new PointerEvent('pointermove', {
|
|
1746
|
+
bubbles: true, cancelable: true, pointerId: 78, pointerType: 'mouse', button: 0, buttons: 0,
|
|
1747
|
+
clientX: box.left + spot[0], clientY: box.top + spot[1]
|
|
1748
|
+
}))
|
|
1749
|
+
}
|
|
1750
|
+
const sizeHoverPoint = sizePoints[1224]
|
|
1751
|
+
hoverSpot(sizeView, sizeHost, sizeHoverPoint)
|
|
1752
|
+
hoverSpot(sizeFullView, sizeFullHost, sizeHoverPoint)
|
|
1753
|
+
await sleep(120)
|
|
1754
|
+
const sizeRadius = (view, index) => view.listPoints({ indexes: [index] })[0]?.radius ?? 0
|
|
1755
|
+
const sizeStyleOf = (view, index) => JSON.stringify(view.getPointStyle(index))
|
|
1756
|
+
const sizeIndexes = [1224, 1000, 1200, 1218, 1235, 0]
|
|
1757
|
+
const sizePatchStarted = now()
|
|
1758
|
+
sizeView.updateOptions({ pointStyle: { ...sizeStyle, size: 14, sizeScale: 1.25 } })
|
|
1759
|
+
const sizePatchMs = now() - sizePatchStarted
|
|
1760
|
+
const sizeSceneText = JSON.stringify(sizeView.snapshotScene())
|
|
1761
|
+
const sizeFullText = JSON.stringify(sizeFullView.snapshotScene())
|
|
1762
|
+
check('尺寸快路径:只改 size / sizeScale 之后的画面与「一开始就这么大」逐字段一致',
|
|
1763
|
+
sizeSceneText === sizeFullText,
|
|
1764
|
+
sizeSceneText === sizeFullText ? `快照 ${sizeSceneText.length} 字节,逐字段一致(改一次 ${sizePatchMs.toFixed(2)} ms)` : diffText(sizeSceneText, sizeFullText))
|
|
1765
|
+
const sizeRadiiMatch = sizeIndexes.every(index => Math.abs(sizeRadius(sizeView, index) - sizeRadius(sizeFullView, index)) < 1e-6)
|
|
1766
|
+
const sizeStylesMatch = sizeIndexes.every(index => sizeStyleOf(sizeView, index) === sizeStyleOf(sizeFullView, index))
|
|
1767
|
+
check('尺寸快路径:逐点读数(画出来的半径 + 样式链尺寸)逐个与整份重建一致',
|
|
1768
|
+
sizeRadiiMatch && sizeStylesMatch,
|
|
1769
|
+
`半径${sizeRadiiMatch ? '一致' : '不一致'} / 样式${sizeStylesMatch ? '一致' : '不一致'}:${sizeIndexes.map(index => `${index}=${sizeRadius(sizeView, index)}`).join(' ')}`)
|
|
1770
|
+
check('尺寸快路径:悬停中的那一条按「新基准尺寸 × 悬停倍率」重算(状态样式没被吃掉)',
|
|
1771
|
+
Math.abs(sizeRadius(sizeView, 1224) / sizeRadius(sizeView, 1223) - 1.8) < 0.01,
|
|
1772
|
+
`悬停 vs 旁边的普通节点 (期望 1.8 倍)`)
|
|
1773
|
+
// ④ 只改 sizeScale:一样走窄路,一样与「一开始就这么配」逐字段一致
|
|
1774
|
+
const sizeScaleHost = document.createElement('div')
|
|
1775
|
+
document.querySelector('#map').append(sizeScaleHost)
|
|
1776
|
+
const sizeScaleView = createGisView(sizeScaleHost, {
|
|
1777
|
+
points: sizePoints,
|
|
1778
|
+
pointStyle: { ...sizeStyle, size: 14, sizeScale: 1.6 },
|
|
1779
|
+
pointHoverStyle: sizeHoverStyle,
|
|
1780
|
+
features: { hover: true },
|
|
1781
|
+
width: 640, height: 380, quality: 'high', showGrid: false
|
|
1782
|
+
})
|
|
1783
|
+
sizeScaleView.setCamera(sizeCamera)
|
|
1784
|
+
hoverSpot(sizeScaleView, sizeScaleHost, sizeHoverPoint)
|
|
1785
|
+
sizeView.updateOptions({ pointStyle: { ...sizeStyle, size: 14, sizeScale: 1.6 } })
|
|
1786
|
+
await sleep(60)
|
|
1787
|
+
check('尺寸快路径:只换 sizeScale 也走同一条窄路(画面与整份重建逐字段一致)',
|
|
1788
|
+
JSON.stringify(sizeView.snapshotScene()) === JSON.stringify(sizeScaleView.snapshotScene()))
|
|
1789
|
+
// ③ 贴图 / SVG 符号:尺寸会换烘焙档位与渲染项,这一半退回整条路,结果照样一致
|
|
1790
|
+
const svgSymbol = { type: 'svg', path: 'M8 1 L15 8 L8 15 L1 8 Z', viewBox: [0, 0, 16, 16], fill: '#ffffff', stroke: '#123456', strokeWidth: 1 }
|
|
1791
|
+
const svgCount = 240
|
|
1792
|
+
const svgPoints = new Array(svgCount)
|
|
1793
|
+
for (let index = 0; index < svgCount; index += 1) {
|
|
1794
|
+
svgPoints[index] = { id: `g${index}`, x: (index % 20) * 22, y: Math.floor(index / 20) * 22, symbol: index % 2 ? svgSymbol : 'circle' }
|
|
1795
|
+
}
|
|
1796
|
+
const svgHost = document.createElement('div')
|
|
1797
|
+
document.querySelector('#map').append(svgHost)
|
|
1798
|
+
const svgView = createGisView(svgHost, { points: svgPoints, pointStyle: { size: 8 }, width: 640, height: 380, quality: 'high', showGrid: false })
|
|
1799
|
+
const svgFullHost = document.createElement('div')
|
|
1800
|
+
document.querySelector('#map').append(svgFullHost)
|
|
1801
|
+
const svgFullView = createGisView(svgFullHost, { points: svgPoints, pointStyle: { size: 14 }, width: 640, height: 380, quality: 'high', showGrid: false })
|
|
1802
|
+
svgView.setCamera({ center: [210, 130], scale: 1 })
|
|
1803
|
+
svgFullView.setCamera({ center: [210, 130], scale: 1 })
|
|
1804
|
+
svgView.updateOptions({ pointStyle: { size: 14 } })
|
|
1805
|
+
await sleep(60)
|
|
1806
|
+
check('尺寸快路径:贴图 / SVG 符号退回整条路,画面同样与整份重建一致',
|
|
1807
|
+
JSON.stringify(svgView.snapshotScene()) === JSON.stringify(svgFullView.snapshotScene()))
|
|
1808
|
+
svgFullView.destroy()
|
|
1809
|
+
svgFullHost.remove()
|
|
1810
|
+
svgView.destroy()
|
|
1811
|
+
svgHost.remove()
|
|
1812
|
+
sizeScaleView.destroy()
|
|
1813
|
+
sizeScaleHost.remove()
|
|
1814
|
+
sizeFullView.destroy()
|
|
1815
|
+
sizeFullHost.remove()
|
|
1816
|
+
sizeView.destroy()
|
|
1817
|
+
sizeHost.remove()
|
|
1818
|
+
/* ---------------- 基准配色快路径:只改 pointStyle 的配色链 ---------------- */
|
|
1819
|
+
// 面板上换调色板、拖透明度下发的是「只改了配色」的补丁:颜色 / 描边色 / 描边宽 / 透明度
|
|
1820
|
+
// 走另一条窄路 —— 每个要素只重算配色链,尺寸链 / 规则 / 热力 / 阴影 / 效果 / 层级一个都不碰。
|
|
1821
|
+
// 这一组盯六件事:
|
|
1822
|
+
// ① 改完之后与「一开始就这么配」逐字段一致(含逐点自己的颜色 / 不透明度与方形符号)
|
|
1823
|
+
// ② 逐点把配色链整个定死了的那些算出来没变(窄路跳过它们),没配的按新基准上色
|
|
1824
|
+
// ③ 尺寸链一个字节都没动:逐点半径与整份重建一致,悬停那条还是「基准尺寸 × 悬停倍率」
|
|
1825
|
+
// ④ 悬停中的那一条按「新基准配色 + 悬停覆盖」重算(状态样式没被窄路吃掉)
|
|
1826
|
+
// ⑤ 配色 + 尺寸同一次补丁里一起改:两条链都走,结果照样与整份重建一致
|
|
1827
|
+
// ⑥ 贴图 / SVG 符号退回整条路(颜色会进烘焙键),画面同样逐字段一致
|
|
1828
|
+
const paintCount = 2400
|
|
1829
|
+
const paintPoints = new Array(paintCount)
|
|
1830
|
+
for (let index = 0; index < paintCount; index += 1) {
|
|
1831
|
+
const point = { id: `p${index}`, x: (index % 60) * 16, y: Math.floor(index / 60) * 16 }
|
|
1832
|
+
if (index % 5 === 0) {
|
|
1833
|
+
point.color = '#ff8800'
|
|
1834
|
+
point.opacity = 0.35
|
|
1835
|
+
}
|
|
1836
|
+
if (index % 7 === 0) {
|
|
1837
|
+
point.strokeColor = '#1f6f2e'
|
|
1838
|
+
point.strokeWidth = 4
|
|
1839
|
+
}
|
|
1840
|
+
if (index % 13 === 0) {
|
|
1841
|
+
point.symbol = { type: 'square' }
|
|
1842
|
+
}
|
|
1843
|
+
paintPoints[index] = point
|
|
1844
|
+
}
|
|
1845
|
+
const paintBase = { size: 10, color: '#3fa35e', strokeColor: '#123456', strokeWidth: 1 }
|
|
1846
|
+
const paintNext = { size: 10, color: '#2f7bd9', strokeColor: '#7a1f1f', strokeWidth: 3, opacity: 0.6 }
|
|
1847
|
+
const paintHoverStyle = { color: '#ffd400', sizeScale: 1.5 }
|
|
1848
|
+
const paintHost = document.createElement('div')
|
|
1849
|
+
document.querySelector('#map').append(paintHost)
|
|
1850
|
+
const paintView = createGisView(paintHost, {
|
|
1851
|
+
points: paintPoints,
|
|
1852
|
+
pointStyle: paintBase,
|
|
1853
|
+
pointHoverStyle: paintHoverStyle,
|
|
1854
|
+
features: { hover: true },
|
|
1855
|
+
width: 640, height: 380, quality: 'high', showGrid: false
|
|
1856
|
+
})
|
|
1857
|
+
const paintFullHost = document.createElement('div')
|
|
1858
|
+
document.querySelector('#map').append(paintFullHost)
|
|
1859
|
+
const paintFullView = createGisView(paintFullHost, {
|
|
1860
|
+
points: paintPoints,
|
|
1861
|
+
pointStyle: paintNext,
|
|
1862
|
+
pointHoverStyle: paintHoverStyle,
|
|
1863
|
+
features: { hover: true },
|
|
1864
|
+
width: 640, height: 380, quality: 'high', showGrid: false
|
|
1865
|
+
})
|
|
1866
|
+
const paintCamera = { center: [470, 190], scale: 1 }
|
|
1867
|
+
paintView.setCamera(paintCamera)
|
|
1868
|
+
paintFullView.setCamera(paintCamera)
|
|
1869
|
+
const paintHoverPoint = paintPoints[1224]
|
|
1870
|
+
hoverSpot(paintView, paintHost, paintHoverPoint)
|
|
1871
|
+
hoverSpot(paintFullView, paintFullHost, paintHoverPoint)
|
|
1872
|
+
await sleep(120)
|
|
1873
|
+
const paintBeforeText = JSON.stringify(paintView.snapshotScene())
|
|
1874
|
+
const paintPatchStarted = now()
|
|
1875
|
+
paintView.updateOptions({ pointStyle: paintNext })
|
|
1876
|
+
const paintPatchMs = now() - paintPatchStarted
|
|
1877
|
+
const paintAfterText = JSON.stringify(paintView.snapshotScene())
|
|
1878
|
+
const paintFullText = JSON.stringify(paintFullView.snapshotScene())
|
|
1879
|
+
check('配色快路径:只改颜色 / 描边之后的画面与「一开始就这么配」逐字段一致',
|
|
1880
|
+
paintAfterText === paintFullText,
|
|
1881
|
+
paintAfterText === paintFullText ? `快照 ${paintAfterText.length} 字节,逐字段一致(改一次 ${paintPatchMs.toFixed(2)} ms)` : diffText(paintAfterText, paintFullText))
|
|
1882
|
+
check('配色快路径:配色真的换过(不是两份快照都没上色)', paintBeforeText !== paintAfterText)
|
|
1883
|
+
// ② 逐点把配色链整个定死了的(编号 35 的倍数:自身颜色 + 不透明度 + 描边)这一轮算出来没变,
|
|
1884
|
+
// 只定了一半的(5 的倍数)另一半跟着新基准走 —— 快照里的颜色前缀对上就说明这一条没被顶掉
|
|
1885
|
+
const paintOwnKept = paintAfterText.includes('rgba(255,136,0,0.35)') && paintAfterText.includes('rgba(31,111,46,0.35)')
|
|
1886
|
+
const paintBaseApplied = paintAfterText.includes('rgba(47,123,217,0.6)') && paintAfterText.includes('rgba(122,31,31,0.6)')
|
|
1887
|
+
check('配色快路径:逐点自己的颜色 / 不透明度 / 描边没被基准配色顶掉,没配的那些按新基准上色',
|
|
1888
|
+
paintOwnKept && paintBaseApplied,
|
|
1889
|
+
`自身配色${paintOwnKept ? '保持' : '被改'} / 新基准配色${paintBaseApplied ? '已生效' : '没生效'}`)
|
|
1890
|
+
const paintRadius = (view, index) => view.listPoints({ indexes: [index] })[0]?.radius ?? 0
|
|
1891
|
+
const paintSizesKept = [1224, 1000, 1225, 1232].every(index => Math.abs(paintRadius(paintView, index) - paintRadius(paintFullView, index)) < 1e-6)
|
|
1892
|
+
check('配色快路径:尺寸链一个字节都没动(逐点半径与整份重建一致)', paintSizesKept)
|
|
1893
|
+
check('配色快路径:悬停中的那一条按「新基准配色 + 悬停覆盖」重算(状态样式没被窄路吃掉)',
|
|
1894
|
+
paintAfterText.includes('rgba(255,212,0,0.6)'))
|
|
1895
|
+
const paintHoverRatio = paintRadius(paintView, 1224) / paintRadius(paintView, 1223)
|
|
1896
|
+
check('配色快路径:悬停那条的尺寸还是「基准尺寸 × 悬停倍率」(配色没顺手把尺寸改了)',
|
|
1897
|
+
Math.abs(paintHoverRatio - 1.5) < 0.01,
|
|
1898
|
+
`悬停 / 旁边的普通节点 = ${paintHoverRatio.toFixed(3)}(期望 1.5)`)
|
|
1899
|
+
// ⑤ 配色 + 尺寸同一次补丁里一起改:先配色后尺寸两条链都走,结果与「一开始就这么配」逐字段一致
|
|
1900
|
+
const paintBothStyle = { size: 14, color: '#8a2be2', strokeColor: '#0f5132', strokeWidth: 2, opacity: 0.85 }
|
|
1901
|
+
const paintBothHost = document.createElement('div')
|
|
1902
|
+
document.querySelector('#map').append(paintBothHost)
|
|
1903
|
+
const paintBothView = createGisView(paintBothHost, {
|
|
1904
|
+
points: paintPoints,
|
|
1905
|
+
pointStyle: paintBothStyle,
|
|
1906
|
+
pointHoverStyle: paintHoverStyle,
|
|
1907
|
+
features: { hover: true },
|
|
1908
|
+
width: 640, height: 380, quality: 'high', showGrid: false
|
|
1909
|
+
})
|
|
1910
|
+
paintBothView.setCamera(paintCamera)
|
|
1911
|
+
hoverSpot(paintBothView, paintBothHost, paintHoverPoint)
|
|
1912
|
+
paintView.updateOptions({ pointStyle: paintBothStyle })
|
|
1913
|
+
await sleep(120)
|
|
1914
|
+
const paintBothText = JSON.stringify(paintView.snapshotScene())
|
|
1915
|
+
const paintBothFullText = JSON.stringify(paintBothView.snapshotScene())
|
|
1916
|
+
check('配色快路径:配色 + 尺寸同一次补丁里一起改(两条链都走),画面与整份重建逐字段一致',
|
|
1917
|
+
paintBothText === paintBothFullText,
|
|
1918
|
+
paintBothText === paintBothFullText ? `快照 ${paintBothText.length} 字节,逐字段一致` : diffText(paintBothText, paintBothFullText))
|
|
1919
|
+
// ⑥ 贴图 / SVG 符号:颜色可能进烘焙键,这一半退回整条路,结果照样一致
|
|
1920
|
+
const paintSvgCount = 240
|
|
1921
|
+
const paintSvgPoints = new Array(paintSvgCount)
|
|
1922
|
+
for (let index = 0; index < paintSvgCount; index += 1) {
|
|
1923
|
+
paintSvgPoints[index] = { id: `q${index}`, x: (index % 20) * 22, y: Math.floor(index / 20) * 22, symbol: index % 2 ? svgSymbol : 'circle' }
|
|
1924
|
+
}
|
|
1925
|
+
const paintSvgHost = document.createElement('div')
|
|
1926
|
+
document.querySelector('#map').append(paintSvgHost)
|
|
1927
|
+
const paintSvgView = createGisView(paintSvgHost, { points: paintSvgPoints, pointStyle: { size: 10, color: '#3fa35e', strokeColor: '#123456', strokeWidth: 1 }, width: 640, height: 380, quality: 'high', showGrid: false })
|
|
1928
|
+
const paintSvgFullHost = document.createElement('div')
|
|
1929
|
+
document.querySelector('#map').append(paintSvgFullHost)
|
|
1930
|
+
const paintSvgFullView = createGisView(paintSvgFullHost, { points: paintSvgPoints, pointStyle: { size: 10, color: '#c0392b', strokeColor: '#1b1b1b', strokeWidth: 2, opacity: 0.7 }, width: 640, height: 380, quality: 'high', showGrid: false })
|
|
1931
|
+
paintSvgView.setCamera({ center: [210, 130], scale: 1 })
|
|
1932
|
+
paintSvgFullView.setCamera({ center: [210, 130], scale: 1 })
|
|
1933
|
+
paintSvgView.updateOptions({ pointStyle: { size: 10, color: '#c0392b', strokeColor: '#1b1b1b', strokeWidth: 2, opacity: 0.7 } })
|
|
1934
|
+
await sleep(60)
|
|
1935
|
+
check('配色快路径:贴图 / SVG 符号退回整条路,画面同样与整份重建一致',
|
|
1936
|
+
JSON.stringify(paintSvgView.snapshotScene()) === JSON.stringify(paintSvgFullView.snapshotScene()))
|
|
1937
|
+
paintSvgFullView.destroy()
|
|
1938
|
+
paintSvgFullHost.remove()
|
|
1939
|
+
paintSvgView.destroy()
|
|
1940
|
+
paintSvgHost.remove()
|
|
1941
|
+
paintBothView.destroy()
|
|
1942
|
+
paintBothHost.remove()
|
|
1943
|
+
paintFullView.destroy()
|
|
1944
|
+
paintFullHost.remove()
|
|
1945
|
+
paintView.destroy()
|
|
1946
|
+
paintHost.remove()
|
|
1947
|
+
/* ---------------- 线路标签候选复用:改一条节点的标签不重扫线路 ---------------- */
|
|
1948
|
+
// 定向更新(updatePoint / patchPoints)只动节点侧(没改坐标 / id、也没有筛选联动)时,
|
|
1949
|
+
// 线路标签层接着用上一轮收好的候选 —— 十万条线的那一趟视口扫描(每次重排里最贵的一步)整个省掉。
|
|
1950
|
+
// 这一块盯四件事:
|
|
1951
|
+
// ① 走复用这条路改出来的标签层状态,与「配置换一份新对象、线路侧整层重排」逐字一致
|
|
1952
|
+
// ② 改线路自己的内容时缓存作废(新内容真的画出来)
|
|
1953
|
+
// ③ 挪节点(线路几何跟着动)时缓存作废,线路标签按新几何重摆
|
|
1954
|
+
// ④ 相机停手后的重排按新视口重收候选
|
|
1955
|
+
const reuseCount = 20000
|
|
1956
|
+
const reuseLineCount = 10000
|
|
1957
|
+
const reusePoints = new Array(reuseCount)
|
|
1958
|
+
for (let index = 0; index < reuseCount; index += 1) {
|
|
1959
|
+
reusePoints[index] = { id: `r${index}`, x: (index % 200) * 20, y: Math.floor(index / 200) * 20, name: `复用节点 ${index}` }
|
|
1960
|
+
}
|
|
1961
|
+
const reuseLines = new Array(reuseLineCount)
|
|
1962
|
+
for (let index = 0; index < reuseLineCount; index += 1) {
|
|
1963
|
+
const from = (index * 7) % reuseCount
|
|
1964
|
+
reuseLines[index] = { id: `rl${index}`, from: `r${from}`, to: `r${(from + 1 + (index % 17)) % reuseCount}`, name: `复用线路 ${index}` }
|
|
1965
|
+
}
|
|
1966
|
+
// 每次比对都换一份新的配置对象:定义表按配置对象身份缓存,换对象就等于「线路侧整层重排」
|
|
1967
|
+
const reuseConfig = () => ({ lineLabel: { content: '{name}', maxCount: 12 }, label: { content: '{name}', maxCount: 40 } })
|
|
1968
|
+
const reuseHost = document.createElement('div')
|
|
1969
|
+
document.querySelector('#map').append(reuseHost)
|
|
1970
|
+
const reuseView = createGisView(reuseHost, { points: reusePoints, lines: reuseLines, ...reuseConfig(), width: 640, height: 380, quality: 'high', showGrid: false })
|
|
1971
|
+
await sleep(160)
|
|
1972
|
+
const reuseNodes = () => [...reuseHost.querySelectorAll('.gis-view__label')]
|
|
1973
|
+
const reuseState = () => reuseNodes()
|
|
1974
|
+
.map(node => `${node.dataset.labelKind}|${node.dataset.labelIndex}|${node.dataset.labelLabel}|${node.textContent}|${node.style.transform}`)
|
|
1975
|
+
.sort()
|
|
1976
|
+
.join('\n')
|
|
1977
|
+
const reuseTextOf = (kind, index) => reuseNodes()
|
|
1978
|
+
.filter(node => node.dataset.labelKind === kind && node.dataset.labelIndex === String(index))
|
|
1979
|
+
.map(node => node.textContent)
|
|
1980
|
+
.join('|')
|
|
1981
|
+
const reusePointNode = reuseNodes().find(node => node.dataset.labelKind === 'point')
|
|
1982
|
+
const reuseLineNode = reuseNodes().find(node => node.dataset.labelKind === 'line')
|
|
1983
|
+
const reusePointIndex = Number(reusePointNode?.dataset.labelIndex ?? -1)
|
|
1984
|
+
const reuseLineIndex = Number(reuseLineNode?.dataset.labelIndex ?? -1)
|
|
1985
|
+
check('线路标签候选复用:两个标签层都画出来了(下面几条断言才有东西可盯)',
|
|
1986
|
+
reusePointIndex >= 0 && reuseLineIndex >= 0 && reuseNodes().length > 2,
|
|
1987
|
+
`${reuseNodes().length} 条标签:节点 ${reusePointIndex} / 线路 ${reuseLineIndex}`)
|
|
1988
|
+
// ① 改一条正在显示的节点标签内容:走「候选复用」,状态与整层重排逐字一致
|
|
1989
|
+
reuseView.updatePoint(`r${reusePointIndex}`, { name: '复用之后的内容' })
|
|
1990
|
+
await sleep(60)
|
|
1991
|
+
const reusedPointText = reuseTextOf('point', reusePointIndex)
|
|
1992
|
+
const reusedState = reuseState()
|
|
1993
|
+
reuseView.updateOptions(reuseConfig())
|
|
1994
|
+
await sleep(60)
|
|
1995
|
+
check('线路标签候选复用:改节点标签之后的标签层与整层重排逐字一致',
|
|
1996
|
+
reusedState === reuseState(),
|
|
1997
|
+
reusedState === reuseState() ? `${reuseNodes().length} 条标签逐字一致` : diffText(reusedState, reuseState()))
|
|
1998
|
+
check('线路标签候选复用:改的就是那一条(内容真的换了,不是两份快照都没动)', reusedPointText === '复用之后的内容', reusedPointText)
|
|
1999
|
+
// ② 改一条正在显示的线路内容:线路侧自己也变了,缓存必须作废
|
|
2000
|
+
reuseView.updateLine(`rl${reuseLineIndex}`, { name: '线路改过' })
|
|
2001
|
+
await sleep(60)
|
|
2002
|
+
const changedLineText = reuseTextOf('line', reuseLineIndex)
|
|
2003
|
+
const changedLineState = reuseState()
|
|
2004
|
+
reuseView.updateOptions(reuseConfig())
|
|
2005
|
+
await sleep(60)
|
|
2006
|
+
check('线路标签候选复用:改线路内容时缓存作废(新内容画出来、状态与整层重排一致)',
|
|
2007
|
+
changedLineText === '线路改过' && changedLineState === reuseState(),
|
|
2008
|
+
`内容 ${changedLineText};${changedLineState === reuseState() ? '状态一致' : diffText(changedLineState, reuseState())}`)
|
|
2009
|
+
// ③ 挪一个节点:线路几何跟着动,线路标签必须按新几何重摆
|
|
2010
|
+
reuseView.updatePoint(`r${reusePointIndex}`, { x: 6000, y: 4500 })
|
|
2011
|
+
await sleep(60)
|
|
2012
|
+
const movedState = reuseState()
|
|
2013
|
+
reuseView.updateOptions(reuseConfig())
|
|
2014
|
+
await sleep(60)
|
|
2015
|
+
check('线路标签候选复用:挪节点之后线路标签按新几何重摆(缓存已作废)',
|
|
2016
|
+
movedState !== reusedState && movedState === reuseState(),
|
|
2017
|
+
movedState === reuseState() ? '与整层重排一致' : diffText(movedState, reuseState()))
|
|
2018
|
+
// ④ 相机停手:按新视口重收候选
|
|
2019
|
+
reuseView.setCamera({ center: [2000, 1000], scale: 2.5 })
|
|
2020
|
+
await sleep(320)
|
|
2021
|
+
const zoomedState = reuseState()
|
|
2022
|
+
reuseView.updateOptions(reuseConfig())
|
|
2023
|
+
await sleep(60)
|
|
2024
|
+
check('线路标签候选复用:相机停手后的重排按新视口收候选(与整层重排一致)',
|
|
2025
|
+
zoomedState === reuseState(),
|
|
2026
|
+
zoomedState === reuseState() ? '与整层重排一致' : diffText(zoomedState, reuseState()))
|
|
2027
|
+
// 连着一圈点更新:第一次收候选(存缓存)、后续复用 —— 量一个中位数给文档留个读数。
|
|
2028
|
+
// 改的是「当前正画在屏幕上的那几条」,它们一定在候选窗口里,改完能直接读回来比对
|
|
2029
|
+
const reuseShownIndexes = reuseNodes()
|
|
2030
|
+
.filter(node => node.dataset.labelKind === 'point')
|
|
2031
|
+
.map(node => Number(node.dataset.labelIndex))
|
|
2032
|
+
.filter(index => index >= 0)
|
|
2033
|
+
const reuseSamples = []
|
|
2034
|
+
let lastReuseIndex = -1
|
|
2035
|
+
for (let round = 0; round < 24; round += 1) {
|
|
2036
|
+
const index = reuseShownIndexes[round % reuseShownIndexes.length]
|
|
2037
|
+
lastReuseIndex = index
|
|
2038
|
+
const started = now()
|
|
2039
|
+
reuseView.updatePoint(`r${index}`, { name: `连改 ${round}` })
|
|
2040
|
+
reuseSamples.push(now() - started)
|
|
2041
|
+
}
|
|
2042
|
+
reuseSamples.sort((left, right) => left - right)
|
|
2043
|
+
diag('线路标签候选复用:连续 24 次定向更新(2 万点 / 1 万线 + 两层标签)', `中位 ${reuseSamples[12].toFixed(2)} ms / 次,最大 ${reuseSamples[23].toFixed(2)} ms`)
|
|
2044
|
+
await sleep(60)
|
|
2045
|
+
const lastReuseText = reuseTextOf('point', lastReuseIndex)
|
|
2046
|
+
check('线路标签候选复用:连续更新之后标签内容跟着走', lastReuseText === '连改 23', lastReuseText)
|
|
2047
|
+
reuseView.destroy()
|
|
2048
|
+
reuseHost.remove()
|
|
2049
|
+
/* ---------------- 提醒:写错的键 / 档位 / 事件名 ---------------- */
|
|
2050
|
+
const warnings = []
|
|
2051
|
+
const warnHost = document.createElement('div')
|
|
2052
|
+
document.querySelector('#map').append(warnHost)
|
|
2053
|
+
const warned = createGisView(warnHost, {
|
|
2054
|
+
points: [],
|
|
2055
|
+
lines: [],
|
|
2056
|
+
draggable2: true,
|
|
2057
|
+
quality: 'nope',
|
|
2058
|
+
features: { drag: true, nope: true },
|
|
2059
|
+
onWarning: message => warnings.push(message)
|
|
2060
|
+
})
|
|
2061
|
+
check('未知配置项会点名(不再悄悄不生效)', warnings.some(message => message.includes('未知配置项 "draggable2"')), warnings[0])
|
|
2062
|
+
check('未知画质档位会点名', warnings.some(message => message.includes('未知的画质档位 "nope"')))
|
|
2063
|
+
check('未知功能开关会点名', warnings.some(message => message.includes('未知的功能开关 "nope"')))
|
|
2064
|
+
warned.on('nope', () => {})
|
|
2065
|
+
check('订阅未知事件名会点名', warnings.some(message => message.includes('未知的事件名 "nope"')))
|
|
2066
|
+
const warnedOnce = []
|
|
2067
|
+
const onceHost = document.createElement('div')
|
|
2068
|
+
document.querySelector('#map').append(onceHost)
|
|
2069
|
+
const onceView = createGisView(onceHost, { points: [], lines: [], typoKey: 1, onWarning: message => warnedOnce.push(message) })
|
|
2070
|
+
onceView.updateOptions({ typoKey: 2 })
|
|
2071
|
+
check('同一个键只提醒一次(updateOptions 反复调用不会刷屏)', warnedOnce.filter(message => message.includes('typoKey')).length === 1, `${warnedOnce.length} 条`)
|
|
2072
|
+
diag('配置提醒', warnings.join(' | '))
|
|
2073
|
+
warned.destroy()
|
|
2074
|
+
warnHost.remove()
|
|
2075
|
+
onceView.destroy()
|
|
2076
|
+
onceHost.remove()
|
|
2077
|
+
|
|
2078
|
+
/* ---------------- 批处理:一次回调 / 一条历史 ---------------- */
|
|
2079
|
+
const batchHost = document.createElement('div')
|
|
2080
|
+
document.querySelector('#map').append(batchHost)
|
|
2081
|
+
const payloads = []
|
|
2082
|
+
const small = createGisView(batchHost, {
|
|
2083
|
+
points: [{ id: 'a', x: 0, y: 0, name: '甲' }, { id: 'b', x: 60, y: 0, name: '乙' }, { id: 'c', x: 120, y: 0, name: '丙' }],
|
|
2084
|
+
lines: [],
|
|
2085
|
+
history: true,
|
|
2086
|
+
onDataChange: payload => payloads.push(payload)
|
|
2087
|
+
})
|
|
2088
|
+
const names = () => small.getData().points.map(point => point.name).join(',')
|
|
2089
|
+
const returned = small.batch(() => {
|
|
2090
|
+
small.updatePoint('a', { name: '甲改' })
|
|
2091
|
+
small.updatePoint('b', { name: '乙改' })
|
|
2092
|
+
small.updatePoint('c', { name: '丙改' })
|
|
2093
|
+
return 'batch 的返回值'
|
|
2094
|
+
})
|
|
2095
|
+
check('batch 返回回调的返回值', returned === 'batch 的返回值', String(returned))
|
|
2096
|
+
check('batch 里三次改动只发一条 onDataChange(type: batch)', payloads.length === 1 && payloads[0].type === 'batch' && payloads[0].changes.length === 3, JSON.stringify(payloads.map(payload => `${payload.type}:${payload.changes?.length ?? 0}`)))
|
|
2097
|
+
check('batch 的载荷按调用顺序列出每一步', payloads[0].changes.map(change => change.type).join(',') === 'updatePoint,updatePoint,updatePoint', JSON.stringify(payloads[0].changes.map(change => change.type)))
|
|
2098
|
+
check('batch 在历史里只占一步', small.getHistoryState().undoCount === 1 && small.getHistory()[0].type === 'batch', JSON.stringify(small.getHistoryState()))
|
|
2099
|
+
small.undo()
|
|
2100
|
+
check('撤销一步:整批改动一起回去', names() === '甲,乙,丙', names())
|
|
2101
|
+
small.redo()
|
|
2102
|
+
check('重做一步:整批改动一起回来', names() === '甲改,乙改,丙改', names())
|
|
2103
|
+
small.batch(() => {})
|
|
2104
|
+
check('空 batch 不会在历史里留空记录', small.getHistoryState().undoCount === 1, JSON.stringify(small.getHistoryState()))
|
|
2105
|
+
let threw = false
|
|
2106
|
+
try {
|
|
2107
|
+
small.batch(() => {
|
|
2108
|
+
small.updatePoint('a', { name: '甲抛错' })
|
|
2109
|
+
throw new Error('故意抛错')
|
|
2110
|
+
})
|
|
2111
|
+
}
|
|
2112
|
+
catch (error) {
|
|
2113
|
+
threw = error.message === '故意抛错'
|
|
2114
|
+
}
|
|
2115
|
+
check('batch 里抛错照常往外抛,已经改过的那部分仍合发一条', threw && small.getData().points[0].name === '甲抛错' && payloads.at(-1).type === 'batch' && payloads.at(-1).changes.length === 1, `${threw} / ${small.getData().points[0].name}`)
|
|
2116
|
+
const beforeNested = payloads.length
|
|
2117
|
+
small.batch(() => {
|
|
2118
|
+
small.updatePoint('b', { name: '乙嵌套' })
|
|
2119
|
+
small.batch(() => small.updatePoint('c', { name: '丙嵌套' }))
|
|
2120
|
+
})
|
|
2121
|
+
check('嵌套 batch 只在最外面收口一次', payloads.length === beforeNested + 1 && payloads.at(-1).changes.length === 2, `changes=${payloads.at(-1)?.changes?.length}`)
|
|
2122
|
+
check('batch 之后数据与 getData 一致', small.getData().points.map(point => point.id).join(',') === 'a,b,c', names())
|
|
2123
|
+
const plainHost = document.createElement('div')
|
|
2124
|
+
document.querySelector('#map').append(plainHost)
|
|
2125
|
+
const plainPayloads = []
|
|
2126
|
+
const plain = createGisView(plainHost, { points: [{ id: 'a', x: 0, y: 0, name: '甲' }], lines: [], onDataChange: payload => plainPayloads.push(payload) })
|
|
2127
|
+
plain.batch(() => plain.updatePoint('a', { name: '甲改' }))
|
|
2128
|
+
check('历史关着时 batch 照常工作(只是不记录)', plain.getHistoryState().enabled === false && plainPayloads.length === 1 && plainPayloads[0].type === 'batch' && plain.undo() === null, JSON.stringify(plain.getHistoryState()))
|
|
2129
|
+
|
|
2130
|
+
/* ---------------- 大数据量下的批量更新(回调次数就是差距) ---------------- */
|
|
2131
|
+
const runEach = (count) => {
|
|
2132
|
+
const started = now()
|
|
2133
|
+
for (let index = 0; index < count; index += 1) {
|
|
2134
|
+
big.updatePoint(points[index].id, { name: `逐个 ${index}` })
|
|
2135
|
+
}
|
|
2136
|
+
return now() - started
|
|
2137
|
+
}
|
|
2138
|
+
const eachMs = runEach(500)
|
|
2139
|
+
const eachPayloads = bigPayloads.filter(payload => payload.type === 'updatePoint').length
|
|
2140
|
+
const batchStarted = now()
|
|
2141
|
+
big.batch(() => {
|
|
2142
|
+
for (let index = 0; index < 500; index += 1) {
|
|
2143
|
+
big.updatePoint(points[index].id, { name: `批量 ${index}` })
|
|
2144
|
+
}
|
|
2145
|
+
})
|
|
2146
|
+
const batchMs = now() - batchStarted
|
|
2147
|
+
check('大数据量下 batch 只发一条(逐个调用是 500 条)', eachPayloads === 500 && bigPayloads.at(-1).type === 'batch' && bigPayloads.at(-1).changes.length === 500, `逐个 ${eachPayloads} 条 / batch 1 条(含 ${bigPayloads.at(-1).changes.length} 步)`)
|
|
2148
|
+
diag('改 500 个节点(20000 点场景)', `逐个调用 ${eachMs.toFixed(1)} ms / batch ${batchMs.toFixed(1)} ms`)
|
|
2149
|
+
|
|
2150
|
+
/* ---------------- 交互期细线快路径(gl.LINES)与 MSAA 读数 ---------------- */
|
|
2151
|
+
// 断言只看接口层读数:交互那一帧 getStats().thinLines 是不是真、停手后回不回来、
|
|
2152
|
+
// 门槛(线宽 / 虚线)与开关能不能把它关掉 —— 不读像素、不量毫秒。
|
|
2153
|
+
const thinHost = document.createElement('div')
|
|
2154
|
+
thinHost.style.cssText = 'width:520px;height:320px'
|
|
2155
|
+
document.body.appendChild(thinHost)
|
|
2156
|
+
const thinPoints = Array.from({ length: 12 }, (unused, index) => ({ id: `tp-${index}`, x: index * 10, y: index % 3 * 6, name: `小点 ${index}` }))
|
|
2157
|
+
const thinLines = thinPoints.slice(1).map((point, index) => ({ id: `tl-${index}`, from: thinPoints[index].id, to: point.id }))
|
|
2158
|
+
const thinView = createGisView(thinHost, {
|
|
2159
|
+
points: thinPoints,
|
|
2160
|
+
lines: thinLines,
|
|
2161
|
+
pointStyle: { size: 6 },
|
|
2162
|
+
lineStyle: { width: 1 },
|
|
2163
|
+
fitPadding: 20,
|
|
2164
|
+
// 交互期的那几条读数(细线快路径 / 后备缓冲)靠拖动触发:平移显式打开
|
|
2165
|
+
features: { pan: true }
|
|
2166
|
+
})
|
|
2167
|
+
// 页面底部的临时画布先滚进视口:动画与交互帧只在可见时连续推进
|
|
2168
|
+
thinHost.scrollIntoView({ block: 'center', inline: 'nearest' })
|
|
2169
|
+
await sleep(400)
|
|
2170
|
+
const thinCanvas = thinHost.querySelector('canvas')
|
|
2171
|
+
const thinPointerId = 91
|
|
2172
|
+
const fireThin = (type, at) => thinCanvas.dispatchEvent(new PointerEvent(type, {
|
|
2173
|
+
bubbles: true, cancelable: true, pointerId: thinPointerId, pointerType: 'mouse', button: 0,
|
|
2174
|
+
buttons: type === 'pointerup' ? 0 : 1, ...at
|
|
2175
|
+
}))
|
|
2176
|
+
// 每次挪 6px 起步:引擎要「超过 3px」(DRAG_THRESHOLD)才认成真的在拖,正好 3px 不算;
|
|
2177
|
+
// 每次都从 pointerdown 重新起手 —— 少了按下这一步,引擎会直接忽略这一串 pointermove。
|
|
2178
|
+
let thinMoves = 0
|
|
2179
|
+
/** 拖一下再读这一帧的读数:pointermove 之后等两帧,读数就是「交互期」那一帧的 */
|
|
2180
|
+
const statsWhilePanning = async () => {
|
|
2181
|
+
const box = thinCanvas.getBoundingClientRect()
|
|
2182
|
+
const centerX = box.left + box.width / 2
|
|
2183
|
+
const centerY = box.top + box.height / 2
|
|
2184
|
+
fireThin('pointerdown', { clientX: centerX, clientY: centerY })
|
|
2185
|
+
thinMoves += 6
|
|
2186
|
+
const at = { clientX: centerX + thinMoves, clientY: centerY }
|
|
2187
|
+
fireThin('pointermove', at)
|
|
2188
|
+
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
|
2189
|
+
const stats = thinView.getStats()
|
|
2190
|
+
fireThin('pointerup', at)
|
|
2191
|
+
return stats
|
|
2192
|
+
}
|
|
2193
|
+
const thinReadings = thinView.getStats()
|
|
2194
|
+
check('getStats() 给出 antialias / software / thinLines 三个读数',
|
|
2195
|
+
typeof thinReadings.antialias === 'boolean' && typeof thinReadings.software === 'boolean' && typeof thinReadings.thinLines === 'boolean',
|
|
2196
|
+
`antialias=${thinReadings.antialias} software=${thinReadings.software} thinLines=${thinReadings.thinLines}`)
|
|
2197
|
+
check('静止画面不走细线快路径(画的是带像素宽度的四边形)', thinReadings.thinLines === false)
|
|
2198
|
+
const thinPanning = await statsWhilePanning()
|
|
2199
|
+
check('交互期 1px 实线整批改走 gl.LINES 快路径', thinPanning.thinLines === true)
|
|
2200
|
+
await sleep(400)
|
|
2201
|
+
check('停手后回到四边形展开', thinView.getStats().thinLines === false)
|
|
2202
|
+
thinView.updateOptions({ interactionThinLines: false })
|
|
2203
|
+
const thinDisabled = await statsWhilePanning()
|
|
2204
|
+
check('interactionThinLines: false 时交互期也不走快路径', thinDisabled.thinLines === false)
|
|
2205
|
+
thinView.updateOptions({ interactionThinLines: true, lineStyle: { width: 8 } })
|
|
2206
|
+
const thinThick = await statsWhilePanning()
|
|
2207
|
+
check('线宽 8px(超过 3px 门槛)时整批留在四边形展开', thinThick.thinLines === false)
|
|
2208
|
+
thinView.updateOptions({ lineStyle: { width: 1, dash: 'dashed' } })
|
|
2209
|
+
const thinDashed = await statsWhilePanning()
|
|
2210
|
+
check('整批里有虚线时也不走快路径(细线画不出虚线)', thinDashed.thinLines === false)
|
|
2211
|
+
thinView.updateOptions({ lineStyle: { width: 1, dash: 'solid' } })
|
|
2212
|
+
const thinBack = await statsWhilePanning()
|
|
2213
|
+
check('改回细实线又走快路径(门槛是每帧现算的)', thinBack.thinLines === true)
|
|
2214
|
+
|
|
2215
|
+
/* ---------------- 交互期非 MSAA 后备缓冲(interactionNoMsaa) ---------------- */
|
|
2216
|
+
// 与细线快路径同一套量法(拖一下再读那一帧的读数),另外补一条像素级核对:
|
|
2217
|
+
// 同一个交互状态(不挪指针)下「开 / 关后备缓冲」各抓一帧,两帧只该在抗锯齿的边缘上有差别。
|
|
2218
|
+
const msaaOn = thinReadings.antialias === true
|
|
2219
|
+
const noMsaaPanning = await statsWhilePanning()
|
|
2220
|
+
check('getStats() 给出 noMsaa 读数', typeof noMsaaPanning.noMsaa === 'boolean', `noMsaa=${noMsaaPanning.noMsaa}`)
|
|
2221
|
+
check('画布开了 MSAA 时交互期走非 MSAA 后备缓冲(画布没开 MSAA 就不必多这一趟)',
|
|
2222
|
+
noMsaaPanning.noMsaa === msaaOn,
|
|
2223
|
+
`antialias=${thinReadings.antialias} 交互期 noMsaa=${noMsaaPanning.noMsaa}`)
|
|
2224
|
+
await sleep(400)
|
|
2225
|
+
check('停手后又直接画进画布(静态画面照旧走 MSAA)', thinView.getStats().noMsaa === false)
|
|
2226
|
+
thinView.updateOptions({ interactionNoMsaa: false })
|
|
2227
|
+
const noMsaaOff = await statsWhilePanning()
|
|
2228
|
+
check('interactionNoMsaa: false 时交互期也直接画进画布', noMsaaOff.noMsaa === false)
|
|
2229
2229
|
thinView.updateOptions({ interactionNoMsaa: true })
|
|
2230
2230
|
const noMsaaBack = await statsWhilePanning()
|
|
2231
2231
|
check('改回 true 又走后备缓冲(开关是每帧现读的)', noMsaaBack.noMsaa === msaaOn)
|
|
@@ -2294,59 +2294,59 @@
|
|
|
2294
2294
|
await sleep(400)
|
|
2295
2295
|
|
|
2296
2296
|
// 动画在跑的那些帧也走这一档:开一组涟漪、不碰指针,等一帧读读数(判定是 interacting || animationRunning())
|
|
2297
|
-
thinView.updateOptions({ effects: { point: { radius: 14, period: 2, count: 1 } } })
|
|
2298
|
-
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
|
2299
|
-
await sleep(160)
|
|
2300
|
-
const animatingStats = thinView.getStats()
|
|
2301
|
-
check('动画帧也走非 MSAA 后备缓冲(不碰指针同样受益)',
|
|
2302
|
-
animatingStats.animating === true && animatingStats.noMsaa === msaaOn,
|
|
2303
|
-
`animating=${animatingStats.animating} noMsaa=${animatingStats.noMsaa}`)
|
|
2304
|
-
thinView.updateOptions({ effects: false })
|
|
2305
|
-
await sleep(260)
|
|
2306
|
-
check('动画关掉、又没交互时回到直接画(静态画面照旧走 MSAA)', thinView.getStats().noMsaa === false)
|
|
2307
|
-
// 交互期改画布尺寸:后备缓冲跟着重建,读数照真、画面照画
|
|
2308
|
-
thinHost.style.width = '420px'
|
|
2309
|
-
thinHost.style.height = '260px'
|
|
2310
|
-
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
|
2311
|
-
const resizedPanning = await statsWhilePanning()
|
|
2312
|
-
check('交互期改画布尺寸之后后备缓冲跟着重建(不报错、读数照真)',
|
|
2313
|
-
resizedPanning.noMsaa === msaaOn && thinCanvas.width < 520,
|
|
2314
|
-
`${thinCanvas.width}x${thinCanvas.height} noMsaa=${resizedPanning.noMsaa}`)
|
|
2315
|
-
if (msaaOn) {
|
|
2316
|
-
// 像素级核对:同一个交互状态(不挪指针)下各渲一帧,整幅读回来比一遍
|
|
2317
|
-
const thinGl = thinCanvas.getContext('webgl2')
|
|
2318
|
-
const readAll = () => {
|
|
2319
|
-
const buffer = new Uint8Array(thinCanvas.width * thinCanvas.height * 4)
|
|
2320
|
-
thinGl.readPixels(0, 0, thinCanvas.width, thinCanvas.height, thinGl.RGBA, thinGl.UNSIGNED_BYTE, buffer)
|
|
2321
|
-
return buffer
|
|
2322
|
-
}
|
|
2323
|
-
const thinBox = thinCanvas.getBoundingClientRect()
|
|
2324
|
-
const thinCenterX = thinBox.left + thinBox.width / 2
|
|
2325
|
-
const thinCenterY = thinBox.top + thinBox.height / 2
|
|
2326
|
-
// 起手之后不再挪指针:两次渲染之间场景完全没变,差别只能来自后备缓冲那一档
|
|
2327
|
-
fireThin('pointerdown', { clientX: thinCenterX, clientY: thinCenterY })
|
|
2328
|
-
thinMoves += 6
|
|
2329
|
-
const thinAt = { clientX: thinCenterX + thinMoves, clientY: thinCenterY }
|
|
2330
|
-
fireThin('pointermove', thinAt)
|
|
2331
|
-
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
|
2332
|
-
thinView.refresh({ reflow: false })
|
|
2333
|
-
const withTarget = readAll()
|
|
2334
|
-
const targetFlag = thinView.getStats().noMsaa
|
|
2335
|
-
thinView.updateOptions({ interactionNoMsaa: false })
|
|
2336
|
-
thinView.refresh({ reflow: false })
|
|
2297
|
+
thinView.updateOptions({ effects: { point: { radius: 14, period: 2, count: 1 } } })
|
|
2298
|
+
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
|
2299
|
+
await sleep(160)
|
|
2300
|
+
const animatingStats = thinView.getStats()
|
|
2301
|
+
check('动画帧也走非 MSAA 后备缓冲(不碰指针同样受益)',
|
|
2302
|
+
animatingStats.animating === true && animatingStats.noMsaa === msaaOn,
|
|
2303
|
+
`animating=${animatingStats.animating} noMsaa=${animatingStats.noMsaa}`)
|
|
2304
|
+
thinView.updateOptions({ effects: false })
|
|
2305
|
+
await sleep(260)
|
|
2306
|
+
check('动画关掉、又没交互时回到直接画(静态画面照旧走 MSAA)', thinView.getStats().noMsaa === false)
|
|
2307
|
+
// 交互期改画布尺寸:后备缓冲跟着重建,读数照真、画面照画
|
|
2308
|
+
thinHost.style.width = '420px'
|
|
2309
|
+
thinHost.style.height = '260px'
|
|
2310
|
+
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
|
2311
|
+
const resizedPanning = await statsWhilePanning()
|
|
2312
|
+
check('交互期改画布尺寸之后后备缓冲跟着重建(不报错、读数照真)',
|
|
2313
|
+
resizedPanning.noMsaa === msaaOn && thinCanvas.width < 520,
|
|
2314
|
+
`${thinCanvas.width}x${thinCanvas.height} noMsaa=${resizedPanning.noMsaa}`)
|
|
2315
|
+
if (msaaOn) {
|
|
2316
|
+
// 像素级核对:同一个交互状态(不挪指针)下各渲一帧,整幅读回来比一遍
|
|
2317
|
+
const thinGl = thinCanvas.getContext('webgl2')
|
|
2318
|
+
const readAll = () => {
|
|
2319
|
+
const buffer = new Uint8Array(thinCanvas.width * thinCanvas.height * 4)
|
|
2320
|
+
thinGl.readPixels(0, 0, thinCanvas.width, thinCanvas.height, thinGl.RGBA, thinGl.UNSIGNED_BYTE, buffer)
|
|
2321
|
+
return buffer
|
|
2322
|
+
}
|
|
2323
|
+
const thinBox = thinCanvas.getBoundingClientRect()
|
|
2324
|
+
const thinCenterX = thinBox.left + thinBox.width / 2
|
|
2325
|
+
const thinCenterY = thinBox.top + thinBox.height / 2
|
|
2326
|
+
// 起手之后不再挪指针:两次渲染之间场景完全没变,差别只能来自后备缓冲那一档
|
|
2327
|
+
fireThin('pointerdown', { clientX: thinCenterX, clientY: thinCenterY })
|
|
2328
|
+
thinMoves += 6
|
|
2329
|
+
const thinAt = { clientX: thinCenterX + thinMoves, clientY: thinCenterY }
|
|
2330
|
+
fireThin('pointermove', thinAt)
|
|
2331
|
+
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
|
2332
|
+
thinView.refresh({ reflow: false })
|
|
2333
|
+
const withTarget = readAll()
|
|
2334
|
+
const targetFlag = thinView.getStats().noMsaa
|
|
2335
|
+
thinView.updateOptions({ interactionNoMsaa: false })
|
|
2336
|
+
thinView.refresh({ reflow: false })
|
|
2337
2337
|
const direct = readAll()
|
|
2338
2338
|
const directFlag = thinView.getStats().noMsaa
|
|
2339
2339
|
thinView.updateOptions({ interactionNoMsaa: true })
|
|
2340
2340
|
let differing = 0
|
|
2341
2341
|
let totalDelta = 0
|
|
2342
|
-
for (let index = 0; index < withTarget.length; index += 1) {
|
|
2343
|
-
const delta = Math.abs(withTarget[index] - direct[index])
|
|
2344
|
-
totalDelta += delta
|
|
2345
|
-
if (delta > 0) {
|
|
2346
|
-
differing += 1
|
|
2347
|
-
}
|
|
2348
|
-
}
|
|
2349
|
-
const thinPixels = thinCanvas.width * thinCanvas.height
|
|
2342
|
+
for (let index = 0; index < withTarget.length; index += 1) {
|
|
2343
|
+
const delta = Math.abs(withTarget[index] - direct[index])
|
|
2344
|
+
totalDelta += delta
|
|
2345
|
+
if (delta > 0) {
|
|
2346
|
+
differing += 1
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
const thinPixels = thinCanvas.width * thinCanvas.height
|
|
2350
2350
|
check('后备缓冲那一帧与直接画的那一帧画面基本一致(差别只在抗锯齿边缘)',
|
|
2351
2351
|
targetFlag === true && directFlag === false && differing / 4 < thinPixels * 0.2 && totalDelta / withTarget.length < 4,
|
|
2352
2352
|
`${(differing / 4 / thinPixels * 100).toFixed(1)}% 像素有差 · 平均差 ${(totalDelta / withTarget.length).toFixed(2)}`);
|
|
@@ -2394,220 +2394,265 @@
|
|
|
2394
2394
|
`${(thinDiffering / 4 / thinPixels * 100).toFixed(1)}% 像素有差 · 平均差 ${(thinTotalDelta / quad.length).toFixed(2)} · 线像素 ${thinInk}/${quadInk}`)
|
|
2395
2395
|
fireThin('pointerup', thinAt)
|
|
2396
2396
|
}
|
|
2397
|
-
/* ---------------- 拖通道拐点:整条通道每帧重算的账(3000 回线不再是每次指针移动 140 ms) ---------------- */
|
|
2398
|
-
// 老写法:拐点批次每帧整份重建 —— 通道里任一回线被选中就按整条通道的成员解析一遍把手,
|
|
2399
|
-
// 3000 ~ 5000 回线的通道每次 pointermove 要 140 ms 上下(拖动时每帧一次,画面直接卡住)。
|
|
2400
|
-
// 现在拐点批次只在结构变了时重建、几何只把缓存里的把手换个位置,同一场景回到个位数毫秒。
|
|
2401
|
-
// 量法:起手之后连续 60 次 pointermove(每次挪过 3px 的拖动阈值),去掉前 5 帧(首批实例上传
|
|
2402
|
-
// 与缓存首建)取中位。只量「处理这一次指针事件」的耗时,不含 rAF 里的绘制。
|
|
2403
|
-
// 另外两条是防「省下来的时间是因为什么都没干」:整条通道的回线必须一条不落地跟着骨架走,
|
|
2404
|
-
// 落地后写回的声明也得是新的骨架。
|
|
2405
|
-
const channelHost = document.createElement('div')
|
|
2406
|
-
channelHost.style.cssText = 'width:900px;height:560px'
|
|
2407
|
-
document.body.appendChild(channelHost)
|
|
2408
|
-
const channelMembers = 3000
|
|
2409
|
-
const channelPoints = [{ id: 'p1', x: 0, y: 0 }, { id: 'p2', x: 200, y: 0 }]
|
|
2410
|
-
const channelLines = []
|
|
2411
|
-
const channelLineIds = []
|
|
2412
|
-
for (let index = 0; index < channelMembers; index += 1) {
|
|
2413
|
-
channelLines.push({ id: `cm-${index}`, from: 'p1', to: 'p2' })
|
|
2414
|
-
channelLineIds.push(`cm-${index}`)
|
|
2415
|
-
}
|
|
2416
|
-
const channelView = createGisView(channelHost, {
|
|
2417
|
-
points: channelPoints,
|
|
2418
|
-
lines: channelLines,
|
|
2419
|
-
channels: [{ id: 'ch-cost', from: 'p1', to: 'p2', lineIds: channelLineIds, path: [[100, 30]] }],
|
|
2420
|
-
fitPadding: 20,
|
|
2421
|
-
|
|
2422
|
-
})
|
|
2423
|
-
channelView.selectFeatures('cm-0')
|
|
2424
|
-
// 拐点拖动靠真实指针命中:先把这张临时画布滚进视口,别让命中坐标落在视口外
|
|
2425
|
-
channelHost.scrollIntoView({ block: 'center', inline: 'nearest' })
|
|
2426
|
-
await sleep(200)
|
|
2427
|
-
const channelCanvas = channelHost.querySelector('canvas')
|
|
2428
|
-
const channelWaypoint = channelView.getWaypoints(0).find(item => item.pathIndex === 1)
|
|
2429
|
-
const channelSkeletonBefore = channelView.getChannelGroups('ch-cost')[0].skeleton.map(coordinate => coordinate[0])
|
|
2430
|
-
const channelMidsBefore = channelView.getChannels()[0].paths.map(path => path[1][0])
|
|
2431
|
-
const channelStart = channelView.project(channelWaypoint.coordinate[0], channelWaypoint.coordinate[1])
|
|
2432
|
-
const channelBox = channelCanvas.getBoundingClientRect()
|
|
2433
|
-
const fireChannel = (type, clientX, clientY) => channelCanvas.dispatchEvent(new PointerEvent(type, {
|
|
2434
|
-
bubbles: true, cancelable: true, pointerId: 92, pointerType: 'mouse', button: 0,
|
|
2435
|
-
buttons: type === 'pointerup' ? 0 : 1, clientX, clientY
|
|
2436
|
-
}))
|
|
2437
|
-
const channelOriginX = channelBox.left + channelStart[0]
|
|
2438
|
-
const channelOriginY = channelBox.top + channelStart[1]
|
|
2439
|
-
fireChannel('pointerdown', channelOriginX, channelOriginY)
|
|
2440
|
-
fireChannel('pointermove', channelOriginX + 2, channelOriginY)
|
|
2441
|
-
const channelFrameMs = []
|
|
2442
|
-
for (let step = 0; step < 60; step += 1) {
|
|
2443
|
-
const started = now()
|
|
2444
|
-
fireChannel('pointermove', channelOriginX + 2 + step * 0.5, channelOriginY)
|
|
2445
|
-
channelFrameMs.push(now() - started)
|
|
2446
|
-
}
|
|
2447
|
-
fireChannel('pointerup', channelOriginX + 32, channelOriginY)
|
|
2448
|
-
await new Promise(resolve => requestAnimationFrame(() => resolve()))
|
|
2449
|
-
const channelSteady = channelFrameMs.slice(5).sort((left, right) => left - right)
|
|
2450
|
-
const channelPerFrame = channelSteady[Math.floor(channelSteady.length / 2)]
|
|
2451
|
-
diag('拖通道拐点(3000 回线的通道)', `每次指针移动中位 ${channelPerFrame.toFixed(2)} ms / 最慢 ${channelSteady[channelSteady.length - 1].toFixed(2)} ms`)
|
|
2452
|
-
check('拖通道拐点:3000 回线的通道每次指针移动回到个位数毫秒(整份重建的老写法 140 ms 上下)',
|
|
2453
|
-
channelPerFrame < 25,
|
|
2454
|
-
`每帧中位 ${channelPerFrame.toFixed(2)} ms / 最慢 ${channelSteady[channelSteady.length - 1].toFixed(2)} ms`)
|
|
2455
|
-
const channelMidsAfter = channelView.getChannels()[0].paths.map(path => path[1][0])
|
|
2456
|
-
const channelFollowed = channelMidsAfter.filter((value, index) => value > channelMidsBefore[index] + 1e-9).length
|
|
2457
|
-
check('拖通道拐点:整条通道的回线一条不落地跟着骨架走(3000 条全动,不是省下时间什么都没干)',
|
|
2458
|
-
channelFollowed === channelMembers,
|
|
2459
|
-
`${channelFollowed} / ${channelMembers} 条回线跟着走`)
|
|
2460
|
-
const channelDeclared = channelView.getChannelGroups('ch-cost')[0].path[0][0]
|
|
2461
|
-
check('拖通道拐点:落地后写回的声明也是新骨架(缓存没把几何顶回旧的那一份)',
|
|
2462
|
-
channelDeclared > channelSkeletonBefore[1] + 1e-9,
|
|
2463
|
-
`骨架中点 ${channelSkeletonBefore[1].toFixed(2)} → ${channelDeclared.toFixed(2)}`)
|
|
2464
|
-
channelView.destroy()
|
|
2465
|
-
channelHost.remove()
|
|
2466
|
-
|
|
2467
|
-
/* ---------------- 分组编辑的成员池账(只写 groups 的通道 + 2 万回线的拆 / 并) ---------------- */
|
|
2468
|
-
// 老写法:setChannelGroups 逐条 includes 判成员(平方级),2 万回线一次拆分 / 合并要 56 ~ 92 ms,
|
|
2469
|
-
// profile 里 collectChannelMembers 占 46%;成员池改成一次建表 + 集合去重之后同一套调用回到 20 ~ 30 ms。
|
|
2470
|
-
// 顺带守一条新的正确性口径:只写 groups、不写顶层 lineIds 的通道,分组操作以前整条静默空操作。
|
|
2471
|
-
const goCostHost = document.createElement('div')
|
|
2472
|
-
goCostHost.style.cssText = 'width:900px;height:560px'
|
|
2473
|
-
document.body.appendChild(goCostHost)
|
|
2474
|
-
const goCostCount = 20000
|
|
2475
|
-
const goCostPoints = [{ id: 'pa', x: 0, y: 0 }, { id: 'pb', x: 400, y: 0 }]
|
|
2476
|
-
const goCostLines = []
|
|
2477
|
-
const goCostFirst = []
|
|
2478
|
-
const goCostSecond = []
|
|
2479
|
-
for (let index = 0; index < goCostCount; index += 1) {
|
|
2480
|
-
goCostLines.push({ id: `gm-${index}`, from: 'pa', to: 'pb' })
|
|
2481
|
-
;(index < goCostCount / 2 ? goCostFirst : goCostSecond).push(`gm-${index}`)
|
|
2482
|
-
}
|
|
2483
|
-
const goCostMerge = []
|
|
2484
|
-
let goCostMergedLines = 0
|
|
2485
|
-
for (let round = 0; round < 3; round += 1) {
|
|
2486
|
-
const goCostView = createGisView(goCostHost, {
|
|
2487
|
-
points: goCostPoints,
|
|
2488
|
-
lines: goCostLines.map(item => ({ ...item })),
|
|
2489
|
-
channels: [{ id: 'go-cost', groups: [{ id: 'go-a', lineIds: [...goCostFirst] }, { id: 'go-b', lineIds: [...goCostSecond] }] }]
|
|
2490
|
-
})
|
|
2491
|
-
const goCostStarted = now()
|
|
2492
|
-
goCostView.mergeChannelGroups('go-cost', ['go-b'], 'go-a')
|
|
2493
|
-
goCostMerge.push(now() - goCostStarted)
|
|
2494
|
-
goCostMergedLines = goCostView.getChannelGroups('go-cost')[0].lineIds.length
|
|
2495
|
-
goCostView.destroy()
|
|
2496
|
-
}
|
|
2497
|
-
goCostHost.remove()
|
|
2498
|
-
const goCostBest = Math.min(...goCostMerge)
|
|
2499
|
-
diag('分组编辑(2 万回线、只写 groups 的通道,合并一次)', `最快 ${goCostBest.toFixed(1)} ms`)
|
|
2500
|
-
check('2 万回线的通道合并分组:40 ms 以内(成员池一次建表 + 集合去重,老的逐条 includes 是平方级)', goCostBest < 40, `最快 ${goCostBest.toFixed(1)} ms`)
|
|
2501
|
-
check('只写 groups 的通道合并之后成员一条不少(2 万条只走声明,不靠顶层 lineIds)', goCostMergedLines === goCostCount, `${goCostMergedLines} / ${goCostCount}`)
|
|
2502
|
-
|
|
2503
|
-
/* ---------------- 点聚合:展示成员、拾取、点击语义与 maxZoom ---------------- */
|
|
2504
|
-
const clusterPointCount = 12000
|
|
2505
|
-
const clusterRandom = (() => {
|
|
2506
|
-
let seed = 20261021
|
|
2507
|
-
return () => {
|
|
2508
|
-
seed = (seed * 1664525 + 1013904223) % 4294967296
|
|
2509
|
-
return seed / 4294967296
|
|
2510
|
-
}
|
|
2511
|
-
})()
|
|
2512
|
-
const clusterCenters = [[0, 0], [120, 0], [60, 90]]
|
|
2513
|
-
// 留出几个远离簇中心的点,覆盖 getClusters() 的单成员分支;它们仍计入总点数。
|
|
2514
|
-
const clusterIsolated = [
|
|
2515
|
-
[-360, -170], [380, -170], [-360, 200], [380, 200],
|
|
2516
|
-
[-440, 20], [460, 20], [20, -220], [20, 240]
|
|
2517
|
-
]
|
|
2518
|
-
const clusterPoints = new Array(clusterPointCount)
|
|
2519
|
-
for (let index = 0; index < clusterPointCount; index += 1) {
|
|
2520
|
-
const isolatedIndex = index - (clusterPointCount - clusterIsolated.length)
|
|
2521
|
-
if (isolatedIndex >= 0) {
|
|
2522
|
-
clusterPoints[index] = {
|
|
2523
|
-
id: `cp${index}`,
|
|
2524
|
-
x: clusterIsolated[isolatedIndex][0],
|
|
2525
|
-
y: clusterIsolated[isolatedIndex][1],
|
|
2526
|
-
name: `独立点 ${index}`
|
|
2527
|
-
}
|
|
2528
|
-
continue
|
|
2529
|
-
}
|
|
2530
|
-
const center = clusterCenters[index % clusterCenters.length]
|
|
2531
|
-
clusterPoints[index] = {
|
|
2532
|
-
id: `cp${index}`,
|
|
2533
|
-
x: center[0] + (clusterRandom() - 0.5) * 24,
|
|
2534
|
-
y: center[1] + (clusterRandom() - 0.5) * 24,
|
|
2535
|
-
name: `聚合点 ${index}`
|
|
2536
|
-
}
|
|
2537
|
-
}
|
|
2538
|
-
const clusterHost = document.createElement('div')
|
|
2539
|
-
clusterHost.style.cssText = 'width:640px;height:380px'
|
|
2540
|
-
document.body.appendChild(clusterHost)
|
|
2541
|
-
let clusterEvents = 0
|
|
2542
|
-
let plainEvents = 0
|
|
2543
|
-
let lastClusterPayload = null
|
|
2544
|
-
const clusterView = createGisView(clusterHost, {
|
|
2545
|
-
points: clusterPoints,
|
|
2546
|
-
width: 640,
|
|
2547
|
-
height: 380,
|
|
2548
|
-
pointStyle: { size: 4 },
|
|
2549
|
-
features: 'view',
|
|
2550
|
-
fitPadding: 24,
|
|
2551
|
-
pointCluster: { enabled: true, radius: 60, minPoints: 2 }
|
|
2552
|
-
})
|
|
2553
|
-
clusterView.on('clusterClick', payload => {
|
|
2554
|
-
clusterEvents += 1
|
|
2555
|
-
lastClusterPayload = payload
|
|
2556
|
-
})
|
|
2557
|
-
clusterView.on('click', () => {
|
|
2558
|
-
plainEvents += 1
|
|
2559
|
-
})
|
|
2560
|
-
await clusterView.whenRendered()
|
|
2561
|
-
const clusterStats = clusterView.getStats()
|
|
2562
|
-
check('点聚合:一万二千点建出多成员圆,统计口径自洽',
|
|
2563
|
-
clusterStats.clusterCount > 0 && clusterStats.clusterPointCount >= clusterStats.clusterCount && clusterStats.clusterMs >= 0,
|
|
2564
|
-
JSON.stringify({ clusterCount: clusterStats.clusterCount, clusterPointCount: clusterStats.clusterPointCount, clusterMs: clusterStats.clusterMs }))
|
|
2565
|
-
check('点聚合:getPointCluster() 给出归一化配置与 active',
|
|
2566
|
-
clusterView.getPointCluster()?.active === true && clusterView.getPointCluster()?.radius === 60,
|
|
2567
|
-
JSON.stringify(clusterView.getPointCluster()))
|
|
2568
|
-
const clusterGroups = clusterView.getClusters()
|
|
2569
|
-
const multiClusters = clusterGroups.filter(item => item.count > 1)
|
|
2570
|
-
const singletonGroups = clusterGroups.filter(item => item.count === 1)
|
|
2571
|
-
check('点聚合:getClusters() 含单成员,单成员 count=1、多成员 id 以 cluster: 开头',
|
|
2572
|
-
singletonGroups.length > 0 && multiClusters.length > 0 && singletonGroups.every(item => item.count === 1 && item.pointIndexes.length === 1) && multiClusters.every(item => item.id.startsWith('cluster:')),
|
|
2573
|
-
`${clusterGroups.length} 组(${singletonGroups.length} 单成员 / ${multiClusters.length} 多成员)`)
|
|
2574
|
-
check('点聚合:原始数据与 getData() 一条不少',
|
|
2575
|
-
clusterView.getData().points.length === clusterPointCount && clusterView.listPoints({ visibleOnly: false }).length === clusterPointCount,
|
|
2576
|
-
`getData ${clusterView.getData().points.length} / listPoints ${clusterView.listPoints({ visibleOnly: false }).length}`)
|
|
2577
|
-
const visibleSingletons = clusterView.listPoints({ visibleOnly: true })
|
|
2578
|
-
let visitedSingletons = 0
|
|
2579
|
-
clusterView.forEachPointInView(clusterView.getViewRect(), () => {
|
|
2580
|
-
visitedSingletons += 1
|
|
2581
|
-
})
|
|
2582
|
-
check('点聚合:聚合内部成员不出现在 visibleOnly / forEachPointInView',
|
|
2583
|
-
visibleSingletons.length === clusterPointCount - clusterStats.clusterPointCount && visitedSingletons === visibleSingletons.length,
|
|
2584
|
-
`可见 ${visibleSingletons.length} / 遍历 ${visitedSingletons} / 隐藏 ${clusterStats.clusterPointCount}`)
|
|
2585
|
-
const clusterPick = clusterView.pick(multiClusters[0].screen[0], multiClusters[0].screen[1])
|
|
2586
|
-
check('点聚合:聚合圆拾取返回 type=cluster,带 count / pointIds / bounds',
|
|
2587
|
-
clusterPick?.type === 'cluster' && clusterPick.count >= 2 && clusterPick.pointIds.length === clusterPick.count && Boolean(clusterPick.bounds),
|
|
2588
|
-
JSON.stringify({ type: clusterPick?.type, count: clusterPick?.count, ids: clusterPick?.pointIds?.length }))
|
|
2589
|
-
const selectionProbe = visibleSingletons[0]
|
|
2590
|
-
clusterView.selectFeatures([selectionProbe.item.id])
|
|
2591
|
-
await clusterView.whenRendered()
|
|
2592
|
-
const selectedBefore = clusterView.getSelections().map(entry => entry.item?.id ?? entry.index).join(',')
|
|
2593
|
-
const clusterCanvas = clusterHost.querySelector('canvas')
|
|
2594
|
-
// 合成指针事件使用真实客户端坐标:先把画布滚进视口,避免 clientY 落在窗口外
|
|
2595
|
-
clusterCanvas.scrollIntoView({ block: 'center' })
|
|
2596
|
-
await sleep(50)
|
|
2597
|
-
const clickPick = clusterView.pick(multiClusters[0].screen[0], multiClusters[0].screen[1])
|
|
2598
|
-
const clickScreen = clickPick?.screen ?? multiClusters[0].screen
|
|
2599
|
-
const clickClient = clusterView.screenToClient(clickScreen[0], clickScreen[1])
|
|
2600
|
-
const fireCluster = type => clusterCanvas.dispatchEvent(new PointerEvent(type, {
|
|
2601
|
-
bubbles: true, cancelable: true, pointerId: 93, pointerType: 'mouse', button: 0,
|
|
2602
|
-
buttons: type === 'pointerup' ? 0 : 1, clientX: clickClient[0], clientY: clickClient[1]
|
|
2603
|
-
}))
|
|
2604
|
-
fireCluster('pointerdown')
|
|
2605
|
-
fireCluster('pointerup')
|
|
2606
|
-
await sleep(30)
|
|
2607
|
-
const selectedAfter = clusterView.getSelections().map(entry => entry.item?.id ?? entry.index).join(',')
|
|
2397
|
+
/* ---------------- 拖通道拐点:整条通道每帧重算的账(3000 回线不再是每次指针移动 140 ms) ---------------- */
|
|
2398
|
+
// 老写法:拐点批次每帧整份重建 —— 通道里任一回线被选中就按整条通道的成员解析一遍把手,
|
|
2399
|
+
// 3000 ~ 5000 回线的通道每次 pointermove 要 140 ms 上下(拖动时每帧一次,画面直接卡住)。
|
|
2400
|
+
// 现在拐点批次只在结构变了时重建、几何只把缓存里的把手换个位置,同一场景回到个位数毫秒。
|
|
2401
|
+
// 量法:起手之后连续 60 次 pointermove(每次挪过 3px 的拖动阈值),去掉前 5 帧(首批实例上传
|
|
2402
|
+
// 与缓存首建)取中位。只量「处理这一次指针事件」的耗时,不含 rAF 里的绘制。
|
|
2403
|
+
// 另外两条是防「省下来的时间是因为什么都没干」:整条通道的回线必须一条不落地跟着骨架走,
|
|
2404
|
+
// 落地后写回的声明也得是新的骨架。
|
|
2405
|
+
const channelHost = document.createElement('div')
|
|
2406
|
+
channelHost.style.cssText = 'width:900px;height:560px'
|
|
2407
|
+
document.body.appendChild(channelHost)
|
|
2408
|
+
const channelMembers = 3000
|
|
2409
|
+
const channelPoints = [{ id: 'p1', x: 0, y: 0 }, { id: 'p2', x: 200, y: 0 }]
|
|
2410
|
+
const channelLines = []
|
|
2411
|
+
const channelLineIds = []
|
|
2412
|
+
for (let index = 0; index < channelMembers; index += 1) {
|
|
2413
|
+
channelLines.push({ id: `cm-${index}`, from: 'p1', to: 'p2' })
|
|
2414
|
+
channelLineIds.push(`cm-${index}`)
|
|
2415
|
+
}
|
|
2416
|
+
const channelView = createGisView(channelHost, {
|
|
2417
|
+
points: channelPoints,
|
|
2418
|
+
lines: channelLines,
|
|
2419
|
+
channels: [{ id: 'ch-cost', from: 'p1', to: 'p2', lineIds: channelLineIds, path: [[100, 30]] }],
|
|
2420
|
+
fitPadding: 20,
|
|
2421
|
+
features: { waypoint: true }
|
|
2422
|
+
})
|
|
2423
|
+
channelView.selectFeatures('cm-0')
|
|
2424
|
+
// 拐点拖动靠真实指针命中:先把这张临时画布滚进视口,别让命中坐标落在视口外
|
|
2425
|
+
channelHost.scrollIntoView({ block: 'center', inline: 'nearest' })
|
|
2426
|
+
await sleep(200)
|
|
2427
|
+
const channelCanvas = channelHost.querySelector('canvas')
|
|
2428
|
+
const channelWaypoint = channelView.getWaypoints(0).find(item => item.pathIndex === 1)
|
|
2429
|
+
const channelSkeletonBefore = channelView.getChannelGroups('ch-cost')[0].skeleton.map(coordinate => coordinate[0])
|
|
2430
|
+
const channelMidsBefore = channelView.getChannels()[0].paths.map(path => path[1][0])
|
|
2431
|
+
const channelStart = channelView.project(channelWaypoint.coordinate[0], channelWaypoint.coordinate[1])
|
|
2432
|
+
const channelBox = channelCanvas.getBoundingClientRect()
|
|
2433
|
+
const fireChannel = (type, clientX, clientY) => channelCanvas.dispatchEvent(new PointerEvent(type, {
|
|
2434
|
+
bubbles: true, cancelable: true, pointerId: 92, pointerType: 'mouse', button: 0,
|
|
2435
|
+
buttons: type === 'pointerup' ? 0 : 1, clientX, clientY
|
|
2436
|
+
}))
|
|
2437
|
+
const channelOriginX = channelBox.left + channelStart[0]
|
|
2438
|
+
const channelOriginY = channelBox.top + channelStart[1]
|
|
2439
|
+
fireChannel('pointerdown', channelOriginX, channelOriginY)
|
|
2440
|
+
fireChannel('pointermove', channelOriginX + 2, channelOriginY)
|
|
2441
|
+
const channelFrameMs = []
|
|
2442
|
+
for (let step = 0; step < 60; step += 1) {
|
|
2443
|
+
const started = now()
|
|
2444
|
+
fireChannel('pointermove', channelOriginX + 2 + step * 0.5, channelOriginY)
|
|
2445
|
+
channelFrameMs.push(now() - started)
|
|
2446
|
+
}
|
|
2447
|
+
fireChannel('pointerup', channelOriginX + 32, channelOriginY)
|
|
2448
|
+
await new Promise(resolve => requestAnimationFrame(() => resolve()))
|
|
2449
|
+
const channelSteady = channelFrameMs.slice(5).sort((left, right) => left - right)
|
|
2450
|
+
const channelPerFrame = channelSteady[Math.floor(channelSteady.length / 2)]
|
|
2451
|
+
diag('拖通道拐点(3000 回线的通道)', `每次指针移动中位 ${channelPerFrame.toFixed(2)} ms / 最慢 ${channelSteady[channelSteady.length - 1].toFixed(2)} ms`)
|
|
2452
|
+
check('拖通道拐点:3000 回线的通道每次指针移动回到个位数毫秒(整份重建的老写法 140 ms 上下)',
|
|
2453
|
+
channelPerFrame < 25,
|
|
2454
|
+
`每帧中位 ${channelPerFrame.toFixed(2)} ms / 最慢 ${channelSteady[channelSteady.length - 1].toFixed(2)} ms`)
|
|
2455
|
+
const channelMidsAfter = channelView.getChannels()[0].paths.map(path => path[1][0])
|
|
2456
|
+
const channelFollowed = channelMidsAfter.filter((value, index) => value > channelMidsBefore[index] + 1e-9).length
|
|
2457
|
+
check('拖通道拐点:整条通道的回线一条不落地跟着骨架走(3000 条全动,不是省下时间什么都没干)',
|
|
2458
|
+
channelFollowed === channelMembers,
|
|
2459
|
+
`${channelFollowed} / ${channelMembers} 条回线跟着走`)
|
|
2460
|
+
const channelDeclared = channelView.getChannelGroups('ch-cost')[0].path[0][0]
|
|
2461
|
+
check('拖通道拐点:落地后写回的声明也是新骨架(缓存没把几何顶回旧的那一份)',
|
|
2462
|
+
channelDeclared > channelSkeletonBefore[1] + 1e-9,
|
|
2463
|
+
`骨架中点 ${channelSkeletonBefore[1].toFixed(2)} → ${channelDeclared.toFixed(2)}`)
|
|
2464
|
+
channelView.destroy()
|
|
2465
|
+
channelHost.remove()
|
|
2466
|
+
|
|
2467
|
+
/* ---------------- 分组编辑的成员池账(只写 groups 的通道 + 2 万回线的拆 / 并) ---------------- */
|
|
2468
|
+
// 老写法:setChannelGroups 逐条 includes 判成员(平方级),2 万回线一次拆分 / 合并要 56 ~ 92 ms,
|
|
2469
|
+
// profile 里 collectChannelMembers 占 46%;成员池改成一次建表 + 集合去重之后同一套调用回到 20 ~ 30 ms。
|
|
2470
|
+
// 顺带守一条新的正确性口径:只写 groups、不写顶层 lineIds 的通道,分组操作以前整条静默空操作。
|
|
2471
|
+
const goCostHost = document.createElement('div')
|
|
2472
|
+
goCostHost.style.cssText = 'width:900px;height:560px'
|
|
2473
|
+
document.body.appendChild(goCostHost)
|
|
2474
|
+
const goCostCount = 20000
|
|
2475
|
+
const goCostPoints = [{ id: 'pa', x: 0, y: 0 }, { id: 'pb', x: 400, y: 0 }]
|
|
2476
|
+
const goCostLines = []
|
|
2477
|
+
const goCostFirst = []
|
|
2478
|
+
const goCostSecond = []
|
|
2479
|
+
for (let index = 0; index < goCostCount; index += 1) {
|
|
2480
|
+
goCostLines.push({ id: `gm-${index}`, from: 'pa', to: 'pb' })
|
|
2481
|
+
;(index < goCostCount / 2 ? goCostFirst : goCostSecond).push(`gm-${index}`)
|
|
2482
|
+
}
|
|
2483
|
+
const goCostMerge = []
|
|
2484
|
+
let goCostMergedLines = 0
|
|
2485
|
+
for (let round = 0; round < 3; round += 1) {
|
|
2486
|
+
const goCostView = createGisView(goCostHost, {
|
|
2487
|
+
points: goCostPoints,
|
|
2488
|
+
lines: goCostLines.map(item => ({ ...item })),
|
|
2489
|
+
channels: [{ id: 'go-cost', groups: [{ id: 'go-a', lineIds: [...goCostFirst] }, { id: 'go-b', lineIds: [...goCostSecond] }] }]
|
|
2490
|
+
})
|
|
2491
|
+
const goCostStarted = now()
|
|
2492
|
+
goCostView.mergeChannelGroups('go-cost', ['go-b'], 'go-a')
|
|
2493
|
+
goCostMerge.push(now() - goCostStarted)
|
|
2494
|
+
goCostMergedLines = goCostView.getChannelGroups('go-cost')[0].lineIds.length
|
|
2495
|
+
goCostView.destroy()
|
|
2496
|
+
}
|
|
2497
|
+
goCostHost.remove()
|
|
2498
|
+
const goCostBest = Math.min(...goCostMerge)
|
|
2499
|
+
diag('分组编辑(2 万回线、只写 groups 的通道,合并一次)', `最快 ${goCostBest.toFixed(1)} ms`)
|
|
2500
|
+
check('2 万回线的通道合并分组:40 ms 以内(成员池一次建表 + 集合去重,老的逐条 includes 是平方级)', goCostBest < 40, `最快 ${goCostBest.toFixed(1)} ms`)
|
|
2501
|
+
check('只写 groups 的通道合并之后成员一条不少(2 万条只走声明,不靠顶层 lineIds)', goCostMergedLines === goCostCount, `${goCostMergedLines} / ${goCostCount}`)
|
|
2502
|
+
|
|
2503
|
+
/* ---------------- 点聚合:展示成员、拾取、点击语义与 maxZoom ---------------- */
|
|
2504
|
+
const clusterPointCount = 12000
|
|
2505
|
+
const clusterRandom = (() => {
|
|
2506
|
+
let seed = 20261021
|
|
2507
|
+
return () => {
|
|
2508
|
+
seed = (seed * 1664525 + 1013904223) % 4294967296
|
|
2509
|
+
return seed / 4294967296
|
|
2510
|
+
}
|
|
2511
|
+
})()
|
|
2512
|
+
const clusterCenters = [[0, 0], [120, 0], [60, 90]]
|
|
2513
|
+
// 留出几个远离簇中心的点,覆盖 getClusters() 的单成员分支;它们仍计入总点数。
|
|
2514
|
+
const clusterIsolated = [
|
|
2515
|
+
[-360, -170], [380, -170], [-360, 200], [380, 200],
|
|
2516
|
+
[-440, 20], [460, 20], [20, -220], [20, 240]
|
|
2517
|
+
]
|
|
2518
|
+
const clusterPoints = new Array(clusterPointCount)
|
|
2519
|
+
for (let index = 0; index < clusterPointCount; index += 1) {
|
|
2520
|
+
const isolatedIndex = index - (clusterPointCount - clusterIsolated.length)
|
|
2521
|
+
if (isolatedIndex >= 0) {
|
|
2522
|
+
clusterPoints[index] = {
|
|
2523
|
+
id: `cp${index}`,
|
|
2524
|
+
x: clusterIsolated[isolatedIndex][0],
|
|
2525
|
+
y: clusterIsolated[isolatedIndex][1],
|
|
2526
|
+
name: `独立点 ${index}`
|
|
2527
|
+
}
|
|
2528
|
+
continue
|
|
2529
|
+
}
|
|
2530
|
+
const center = clusterCenters[index % clusterCenters.length]
|
|
2531
|
+
clusterPoints[index] = {
|
|
2532
|
+
id: `cp${index}`,
|
|
2533
|
+
x: center[0] + (clusterRandom() - 0.5) * 24,
|
|
2534
|
+
y: center[1] + (clusterRandom() - 0.5) * 24,
|
|
2535
|
+
name: `聚合点 ${index}`
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
const clusterHost = document.createElement('div')
|
|
2539
|
+
clusterHost.style.cssText = 'width:640px;height:380px'
|
|
2540
|
+
document.body.appendChild(clusterHost)
|
|
2541
|
+
let clusterEvents = 0
|
|
2542
|
+
let plainEvents = 0
|
|
2543
|
+
let lastClusterPayload = null
|
|
2544
|
+
const clusterView = createGisView(clusterHost, {
|
|
2545
|
+
points: clusterPoints,
|
|
2546
|
+
width: 640,
|
|
2547
|
+
height: 380,
|
|
2548
|
+
pointStyle: { size: 4 },
|
|
2549
|
+
features: 'view',
|
|
2550
|
+
fitPadding: 24,
|
|
2551
|
+
pointCluster: { enabled: true, radius: 60, minPoints: 2 }
|
|
2552
|
+
})
|
|
2553
|
+
clusterView.on('clusterClick', payload => {
|
|
2554
|
+
clusterEvents += 1
|
|
2555
|
+
lastClusterPayload = payload
|
|
2556
|
+
})
|
|
2557
|
+
clusterView.on('click', () => {
|
|
2558
|
+
plainEvents += 1
|
|
2559
|
+
})
|
|
2560
|
+
await clusterView.whenRendered()
|
|
2561
|
+
const clusterStats = clusterView.getStats()
|
|
2562
|
+
check('点聚合:一万二千点建出多成员圆,统计口径自洽',
|
|
2563
|
+
clusterStats.clusterCount > 0 && clusterStats.clusterPointCount >= clusterStats.clusterCount && clusterStats.clusterMs >= 0,
|
|
2564
|
+
JSON.stringify({ clusterCount: clusterStats.clusterCount, clusterPointCount: clusterStats.clusterPointCount, clusterMs: clusterStats.clusterMs }))
|
|
2565
|
+
check('点聚合:getPointCluster() 给出归一化配置与 active',
|
|
2566
|
+
clusterView.getPointCluster()?.active === true && clusterView.getPointCluster()?.radius === 60,
|
|
2567
|
+
JSON.stringify(clusterView.getPointCluster()))
|
|
2568
|
+
const clusterGroups = clusterView.getClusters()
|
|
2569
|
+
const multiClusters = clusterGroups.filter(item => item.count > 1)
|
|
2570
|
+
const singletonGroups = clusterGroups.filter(item => item.count === 1)
|
|
2571
|
+
check('点聚合:getClusters() 含单成员,单成员 count=1、多成员 id 以 cluster: 开头',
|
|
2572
|
+
singletonGroups.length > 0 && multiClusters.length > 0 && singletonGroups.every(item => item.count === 1 && item.pointIndexes.length === 1) && multiClusters.every(item => item.id.startsWith('cluster:')),
|
|
2573
|
+
`${clusterGroups.length} 组(${singletonGroups.length} 单成员 / ${multiClusters.length} 多成员)`)
|
|
2574
|
+
check('点聚合:原始数据与 getData() 一条不少',
|
|
2575
|
+
clusterView.getData().points.length === clusterPointCount && clusterView.listPoints({ visibleOnly: false }).length === clusterPointCount,
|
|
2576
|
+
`getData ${clusterView.getData().points.length} / listPoints ${clusterView.listPoints({ visibleOnly: false }).length}`)
|
|
2577
|
+
const visibleSingletons = clusterView.listPoints({ visibleOnly: true })
|
|
2578
|
+
let visitedSingletons = 0
|
|
2579
|
+
clusterView.forEachPointInView(clusterView.getViewRect(), () => {
|
|
2580
|
+
visitedSingletons += 1
|
|
2581
|
+
})
|
|
2582
|
+
check('点聚合:聚合内部成员不出现在 visibleOnly / forEachPointInView',
|
|
2583
|
+
visibleSingletons.length === clusterPointCount - clusterStats.clusterPointCount && visitedSingletons === visibleSingletons.length,
|
|
2584
|
+
`可见 ${visibleSingletons.length} / 遍历 ${visitedSingletons} / 隐藏 ${clusterStats.clusterPointCount}`)
|
|
2585
|
+
const clusterPick = clusterView.pick(multiClusters[0].screen[0], multiClusters[0].screen[1])
|
|
2586
|
+
check('点聚合:聚合圆拾取返回 type=cluster,带 count / pointIds / bounds',
|
|
2587
|
+
clusterPick?.type === 'cluster' && clusterPick.count >= 2 && clusterPick.pointIds.length === clusterPick.count && Boolean(clusterPick.bounds),
|
|
2588
|
+
JSON.stringify({ type: clusterPick?.type, count: clusterPick?.count, ids: clusterPick?.pointIds?.length }))
|
|
2589
|
+
const selectionProbe = visibleSingletons[0]
|
|
2590
|
+
clusterView.selectFeatures([selectionProbe.item.id])
|
|
2591
|
+
await clusterView.whenRendered()
|
|
2592
|
+
const selectedBefore = clusterView.getSelections().map(entry => entry.item?.id ?? entry.index).join(',')
|
|
2593
|
+
const clusterCanvas = clusterHost.querySelector('canvas')
|
|
2594
|
+
// 合成指针事件使用真实客户端坐标:先把画布滚进视口,避免 clientY 落在窗口外
|
|
2595
|
+
clusterCanvas.scrollIntoView({ block: 'center' })
|
|
2596
|
+
await sleep(50)
|
|
2597
|
+
const clickPick = clusterView.pick(multiClusters[0].screen[0], multiClusters[0].screen[1])
|
|
2598
|
+
const clickScreen = clickPick?.screen ?? multiClusters[0].screen
|
|
2599
|
+
const clickClient = clusterView.screenToClient(clickScreen[0], clickScreen[1])
|
|
2600
|
+
const fireCluster = type => clusterCanvas.dispatchEvent(new PointerEvent(type, {
|
|
2601
|
+
bubbles: true, cancelable: true, pointerId: 93, pointerType: 'mouse', button: 0,
|
|
2602
|
+
buttons: type === 'pointerup' ? 0 : 1, clientX: clickClient[0], clientY: clickClient[1]
|
|
2603
|
+
}))
|
|
2604
|
+
fireCluster('pointerdown')
|
|
2605
|
+
fireCluster('pointerup')
|
|
2606
|
+
await sleep(30)
|
|
2607
|
+
const selectedAfter = clusterView.getSelections().map(entry => entry.item?.id ?? entry.index).join(',')
|
|
2608
2608
|
check('点聚合:点击聚合只发 clusterClick,不发普通 click,选中集不变',
|
|
2609
2609
|
clusterEvents === 1 && plainEvents === 0 && selectedAfter === selectedBefore && lastClusterPayload?.action?.type === 'clusterClick' && lastClusterPayload?.type === 'cluster' && lastClusterPayload?.count > 1 && Array.isArray(lastClusterPayload?.pointIds) && lastClusterPayload?.action?.data === undefined && lastClusterPayload?.originalEvent instanceof Event,
|
|
2610
2610
|
`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(',')}`)
|
|
2611
|
+
const clusterIcon = { type: 'svg', path: 'M0 -11 L3.2 -3.4 L11.5 -3.4 L4.9 1.8 L7.3 9.6 L0 5 L-7.3 9.6 L-4.9 1.8 L-11.5 -3.4 L-3.2 -3.4 Z', viewBox: [-12, -12, 24, 24], fill: 'currentColor' }
|
|
2612
|
+
clusterView.updateOptions({ pointCluster: {
|
|
2613
|
+
enabled: true,
|
|
2614
|
+
radius: 60,
|
|
2615
|
+
minPoints: 2,
|
|
2616
|
+
color: '#f05a3c',
|
|
2617
|
+
strokeColor: '#1b3358',
|
|
2618
|
+
strokeWidth: 3,
|
|
2619
|
+
opacity: 0.8,
|
|
2620
|
+
textColor: '#ffe08a',
|
|
2621
|
+
fontSize: 18,
|
|
2622
|
+
symbol: clusterIcon,
|
|
2623
|
+
showCount: false,
|
|
2624
|
+
sizeScale: 1.2
|
|
2625
|
+
} })
|
|
2626
|
+
await clusterView.whenRendered()
|
|
2627
|
+
const styledCluster = clusterView.getPointCluster()
|
|
2628
|
+
check('点聚合:自定义颜色 / 描边 / 透明度 / 字号 / 图标 / 尺寸都进入归一化读数',
|
|
2629
|
+
styledCluster?.color === '#f05a3c' && styledCluster?.strokeColor === '#1b3358' && styledCluster?.strokeWidth === 3 && styledCluster?.opacity === 0.8 && styledCluster?.textColor === '#ffe08a' && styledCluster?.fontSize === 18 && styledCluster?.symbol?.type === 'svg' && styledCluster?.showCount === false && styledCluster?.sizeScale === 1.2,
|
|
2630
|
+
JSON.stringify(styledCluster))
|
|
2631
|
+
const styledScene = clusterView.snapshotScene({ background: false })
|
|
2632
|
+
const styledSceneCluster = styledScene.clusters[0]
|
|
2633
|
+
const styledSvg = buildSceneSvg(styledScene)
|
|
2634
|
+
check('点聚合快照:自定义填充 / 描边 / 文字样式与 SVG 图标同源',
|
|
2635
|
+
styledSceneCluster?.fill.includes('240,90,60') && styledSceneCluster?.stroke.includes('27,51,88') && styledSceneCluster?.strokeWidth === 3 && styledSceneCluster?.opacity === 0.8 && styledSceneCluster?.textColor === '#ffe08a' && styledSceneCluster?.fontSize === 18 && styledSceneCluster?.icon?.kind === 'svg',
|
|
2636
|
+
JSON.stringify({ fill: styledSceneCluster?.fill, stroke: styledSceneCluster?.stroke, icon: styledSceneCluster?.icon?.kind, size: [styledSceneCluster?.icon?.width, styledSceneCluster?.icon?.height] }))
|
|
2637
|
+
check('点聚合 showCount:false:覆盖层不生成数量 DOM,SVG 也不拼数量文字',
|
|
2638
|
+
clusterHost.querySelectorAll('.gis-view__cluster-label').length === 0 && !styledSvg.includes(`>${styledSceneCluster?.count}</text>`),
|
|
2639
|
+
`DOM ${clusterHost.querySelectorAll('.gis-view__cluster-label').length} / count ${styledSceneCluster?.count}`)
|
|
2640
|
+
clusterView.updateOptions({ pointCluster: {
|
|
2641
|
+
enabled: true,
|
|
2642
|
+
radius: 60,
|
|
2643
|
+
minPoints: 2,
|
|
2644
|
+
color: '#f05a3c',
|
|
2645
|
+
textColor: '#ffe08a',
|
|
2646
|
+
fontSize: 16,
|
|
2647
|
+
symbol: clusterIcon,
|
|
2648
|
+
showCount: true,
|
|
2649
|
+
sizeScale: 1.2
|
|
2650
|
+
} })
|
|
2651
|
+
await clusterView.whenRendered()
|
|
2652
|
+
const shownScene = clusterView.snapshotScene({ background: false })
|
|
2653
|
+
check('点聚合 showCount:true:覆盖层与导出都按自定义字号 / 颜色恢复数量文字',
|
|
2654
|
+
clusterHost.querySelectorAll('.gis-view__cluster-label').length > 0 && buildSceneSvg(shownScene).includes(`>${shownScene.clusters[0]?.count}</text>`) && shownScene.clusters[0]?.fontSize === 16 && shownScene.clusters[0]?.textColor === '#ffe08a',
|
|
2655
|
+
`DOM ${clusterHost.querySelectorAll('.gis-view__cluster-label').length} / ${shownScene.clusters[0]?.count}`)
|
|
2611
2656
|
const fitScale = clusterView.getCamera().scale
|
|
2612
2657
|
// 聚合成员在相机运动期会推迟重算;这一档没有线、又显式关掉非 MSAA 后备缓冲,
|
|
2613
2658
|
// 当前帧不会画出任何轻量几何 —— 停稳计时器仍必须补一帧,不能把聚合结果留在旧缩放档。
|
|
@@ -2623,59 +2668,59 @@
|
|
|
2623
2668
|
await sleep(420)
|
|
2624
2669
|
clusterView.updateOptions({ interactionNoMsaa: true })
|
|
2625
2670
|
clusterView.updateOptions({ pointCluster: { enabled: true, radius: 60, minPoints: 2, maxZoom: 2 } })
|
|
2626
|
-
clusterView.setCamera({ scale: fitScale * 3 })
|
|
2627
|
-
await clusterView.whenRendered()
|
|
2628
|
-
const zoomedStats = clusterView.getStats()
|
|
2629
|
-
check('点聚合:缩放超过 maxZoom 后展开原始点(active=false、聚合圆清空)',
|
|
2630
|
-
clusterView.getPointCluster()?.active === false && zoomedStats.clusterCount === 0 && clusterView.listPoints({ visibleOnly: true }).length === clusterPointCount,
|
|
2631
|
-
JSON.stringify({ active: clusterView.getPointCluster()?.active, clusterCount: zoomedStats.clusterCount, visible: clusterView.listPoints({ visibleOnly: true }).length }))
|
|
2632
|
-
clusterView.setCamera({ scale: fitScale })
|
|
2633
|
-
await clusterView.whenRendered()
|
|
2634
|
-
clusterView.updateOptions({ pointCluster: false })
|
|
2635
|
-
await clusterView.whenRendered()
|
|
2636
|
-
const disabledStats = clusterView.getStats()
|
|
2637
|
-
check('点聚合:关掉之后原始点全部恢复,getPointCluster().enabled=false',
|
|
2638
|
-
clusterView.getPointCluster()?.enabled === false && clusterView.getPointCluster()?.active === false && disabledStats.clusterCount === 0 && disabledStats.clusterPointCount === 0,
|
|
2639
|
-
JSON.stringify({ cluster: clusterView.getPointCluster(), stats: { count: disabledStats.clusterCount, points: disabledStats.clusterPointCount } }))
|
|
2640
|
-
clusterView.destroy()
|
|
2641
|
-
clusterHost.remove()
|
|
2642
|
-
|
|
2643
|
-
/* ---------------- 同一批点线数据原样回灌:两半都没换时别白解析 ---------------- */
|
|
2644
|
-
// 组件把同一批对象原样再交一次是常规写法(刷新配置 / 只换别的选项时顺带带上 data)。
|
|
2645
|
-
// 这里量 5 次取中位;每次回灌之后数据、线路实例数与相机都必须原样。
|
|
2646
|
-
big.fitView(0)
|
|
2647
|
-
big.refresh()
|
|
2648
|
-
big.refresh()
|
|
2649
|
-
const refeedPoints = big.getData().points
|
|
2650
|
-
const refeedLines = big.getData().lines
|
|
2651
|
-
const refeedInstances = big.getStats().lineInstances
|
|
2652
|
-
const refeedStarted = now()
|
|
2653
|
-
const refeedSamples = []
|
|
2654
|
-
for (let round = 0; round < 5; round += 1) {
|
|
2655
|
-
const started = now()
|
|
2656
|
-
big.updateOptions({ points: refeedPoints, lines: refeedLines })
|
|
2657
|
-
refeedSamples.push(now() - started)
|
|
2658
|
-
}
|
|
2659
|
-
refeedSamples.sort((left, right) => left - right)
|
|
2660
|
-
const refeedMs = refeedSamples[Math.floor(refeedSamples.length / 2)]
|
|
2661
|
-
const refeedData = big.getData()
|
|
2662
|
-
check('同一批点线原样回灌:条数 / 线路实例数都不变(解析结果可以整半沿用)',
|
|
2663
|
-
refeedData.points.length === pointCount && refeedData.lines.length === lineCount && big.getStats().lineInstances === refeedInstances,
|
|
2664
|
-
JSON.stringify({ points: refeedData.points.length, lines: refeedData.lines.length, instances: big.getStats().lineInstances, expected: refeedInstances }))
|
|
2665
|
-
diag(`同一批点线原样回灌(${pointCount} 点 / ${lineCount} 线)`, `中位 ${refeedMs.toFixed(1)} ms(5 次:${refeedSamples.map(value => value.toFixed(1)).join(' / ')};总观测 ${(now() - refeedStarted).toFixed(1)} ms)`)
|
|
2666
|
-
// 同一批数据回灌不能让通道几何停在上一轮:声明没换时沿用,换了骨架必须照常重推
|
|
2667
|
-
big.updateOptions({ channels: [{ id: 'reuse-guard', from: 'p0', to: 'p1', lineIds: ['l0', 'l1'], path: [[20, 20]] }] })
|
|
2668
|
-
const guardBefore = big.getChannelGroupWaypoints('reuse-guard')
|
|
2669
|
-
big.updateOptions({ points: refeedPoints, lines: refeedLines })
|
|
2670
|
-
const guardAfter = big.getChannelGroupWaypoints('reuse-guard')
|
|
2671
|
-
check('同一批点线回灌:通道声明没换时,回线派生折线原样保留',
|
|
2672
|
-
JSON.stringify(guardBefore?.lines?.map(line => line.path)) === JSON.stringify(guardAfter?.lines?.map(line => line.path)),
|
|
2673
|
-
JSON.stringify({ before: guardBefore?.lines?.map(line => line.waypoints), after: guardAfter?.lines?.map(line => line.waypoints) }))
|
|
2674
|
-
big.updateOptions({ channels: [{ id: 'reuse-guard', from: 'p0', to: 'p1', lineIds: ['l0', 'l1'], path: [[80, 80]] }] })
|
|
2675
|
-
const guardChanged = big.getChannelGroupWaypoints('reuse-guard')
|
|
2676
|
-
check('同一批点线回灌:换通道骨架时线路那一半不能沿用旧几何',
|
|
2677
|
-
JSON.stringify(guardChanged?.lines?.map(line => line.waypoints)) !== JSON.stringify(guardAfter?.lines?.map(line => line.waypoints)),
|
|
2678
|
-
JSON.stringify({ before: guardAfter?.lines?.map(line => line.waypoints), after: guardChanged?.lines?.map(line => line.waypoints) }))
|
|
2671
|
+
clusterView.setCamera({ scale: fitScale * 3 })
|
|
2672
|
+
await clusterView.whenRendered()
|
|
2673
|
+
const zoomedStats = clusterView.getStats()
|
|
2674
|
+
check('点聚合:缩放超过 maxZoom 后展开原始点(active=false、聚合圆清空)',
|
|
2675
|
+
clusterView.getPointCluster()?.active === false && zoomedStats.clusterCount === 0 && clusterView.listPoints({ visibleOnly: true }).length === clusterPointCount,
|
|
2676
|
+
JSON.stringify({ active: clusterView.getPointCluster()?.active, clusterCount: zoomedStats.clusterCount, visible: clusterView.listPoints({ visibleOnly: true }).length }))
|
|
2677
|
+
clusterView.setCamera({ scale: fitScale })
|
|
2678
|
+
await clusterView.whenRendered()
|
|
2679
|
+
clusterView.updateOptions({ pointCluster: false })
|
|
2680
|
+
await clusterView.whenRendered()
|
|
2681
|
+
const disabledStats = clusterView.getStats()
|
|
2682
|
+
check('点聚合:关掉之后原始点全部恢复,getPointCluster().enabled=false',
|
|
2683
|
+
clusterView.getPointCluster()?.enabled === false && clusterView.getPointCluster()?.active === false && disabledStats.clusterCount === 0 && disabledStats.clusterPointCount === 0,
|
|
2684
|
+
JSON.stringify({ cluster: clusterView.getPointCluster(), stats: { count: disabledStats.clusterCount, points: disabledStats.clusterPointCount } }))
|
|
2685
|
+
clusterView.destroy()
|
|
2686
|
+
clusterHost.remove()
|
|
2687
|
+
|
|
2688
|
+
/* ---------------- 同一批点线数据原样回灌:两半都没换时别白解析 ---------------- */
|
|
2689
|
+
// 组件把同一批对象原样再交一次是常规写法(刷新配置 / 只换别的选项时顺带带上 data)。
|
|
2690
|
+
// 这里量 5 次取中位;每次回灌之后数据、线路实例数与相机都必须原样。
|
|
2691
|
+
big.fitView(0)
|
|
2692
|
+
big.refresh()
|
|
2693
|
+
big.refresh()
|
|
2694
|
+
const refeedPoints = big.getData().points
|
|
2695
|
+
const refeedLines = big.getData().lines
|
|
2696
|
+
const refeedInstances = big.getStats().lineInstances
|
|
2697
|
+
const refeedStarted = now()
|
|
2698
|
+
const refeedSamples = []
|
|
2699
|
+
for (let round = 0; round < 5; round += 1) {
|
|
2700
|
+
const started = now()
|
|
2701
|
+
big.updateOptions({ points: refeedPoints, lines: refeedLines })
|
|
2702
|
+
refeedSamples.push(now() - started)
|
|
2703
|
+
}
|
|
2704
|
+
refeedSamples.sort((left, right) => left - right)
|
|
2705
|
+
const refeedMs = refeedSamples[Math.floor(refeedSamples.length / 2)]
|
|
2706
|
+
const refeedData = big.getData()
|
|
2707
|
+
check('同一批点线原样回灌:条数 / 线路实例数都不变(解析结果可以整半沿用)',
|
|
2708
|
+
refeedData.points.length === pointCount && refeedData.lines.length === lineCount && big.getStats().lineInstances === refeedInstances,
|
|
2709
|
+
JSON.stringify({ points: refeedData.points.length, lines: refeedData.lines.length, instances: big.getStats().lineInstances, expected: refeedInstances }))
|
|
2710
|
+
diag(`同一批点线原样回灌(${pointCount} 点 / ${lineCount} 线)`, `中位 ${refeedMs.toFixed(1)} ms(5 次:${refeedSamples.map(value => value.toFixed(1)).join(' / ')};总观测 ${(now() - refeedStarted).toFixed(1)} ms)`)
|
|
2711
|
+
// 同一批数据回灌不能让通道几何停在上一轮:声明没换时沿用,换了骨架必须照常重推
|
|
2712
|
+
big.updateOptions({ channels: [{ id: 'reuse-guard', from: 'p0', to: 'p1', lineIds: ['l0', 'l1'], path: [[20, 20]] }] })
|
|
2713
|
+
const guardBefore = big.getChannelGroupWaypoints('reuse-guard')
|
|
2714
|
+
big.updateOptions({ points: refeedPoints, lines: refeedLines })
|
|
2715
|
+
const guardAfter = big.getChannelGroupWaypoints('reuse-guard')
|
|
2716
|
+
check('同一批点线回灌:通道声明没换时,回线派生折线原样保留',
|
|
2717
|
+
JSON.stringify(guardBefore?.lines?.map(line => line.path)) === JSON.stringify(guardAfter?.lines?.map(line => line.path)),
|
|
2718
|
+
JSON.stringify({ before: guardBefore?.lines?.map(line => line.waypoints), after: guardAfter?.lines?.map(line => line.waypoints) }))
|
|
2719
|
+
big.updateOptions({ channels: [{ id: 'reuse-guard', from: 'p0', to: 'p1', lineIds: ['l0', 'l1'], path: [[80, 80]] }] })
|
|
2720
|
+
const guardChanged = big.getChannelGroupWaypoints('reuse-guard')
|
|
2721
|
+
check('同一批点线回灌:换通道骨架时线路那一半不能沿用旧几何',
|
|
2722
|
+
JSON.stringify(guardChanged?.lines?.map(line => line.waypoints)) !== JSON.stringify(guardAfter?.lines?.map(line => line.waypoints)),
|
|
2723
|
+
JSON.stringify({ before: guardAfter?.lines?.map(line => line.waypoints), after: guardChanged?.lines?.map(line => line.waypoints) }))
|
|
2679
2724
|
big.updateOptions({ channels: [] })
|
|
2680
2725
|
|
|
2681
2726
|
/* ---------------- visualMap:图例更新快路径 / 同签名零 DOM 写入 / 自动值域 ---------------- */
|
|
@@ -2722,20 +2767,20 @@
|
|
|
2722
2767
|
visualPerfHost.remove()
|
|
2723
2768
|
|
|
2724
2769
|
thinView.destroy()
|
|
2725
|
-
thinHost.remove()
|
|
2726
|
-
big.destroy()
|
|
2727
|
-
bigHost.remove()
|
|
2728
|
-
small.destroy()
|
|
2729
|
-
batchHost.remove()
|
|
2730
|
-
plain.destroy()
|
|
2731
|
-
plainHost.remove()
|
|
2732
|
-
}
|
|
2733
|
-
catch (error) {
|
|
2734
|
-
results.push(`ERROR ${error && error.stack ? error.stack : String(error)}`)
|
|
2735
|
-
}
|
|
2736
|
-
document.querySelector('#out').textContent = results.join('\n')
|
|
2737
|
-
document.title = results.some(line => line.startsWith('FAIL') || line.startsWith('ERROR')) ? 'perf FAIL' : 'perf PASS'
|
|
2738
|
-
</script>
|
|
2739
|
-
</body>
|
|
2740
|
-
</html>
|
|
2741
|
-
|
|
2770
|
+
thinHost.remove()
|
|
2771
|
+
big.destroy()
|
|
2772
|
+
bigHost.remove()
|
|
2773
|
+
small.destroy()
|
|
2774
|
+
batchHost.remove()
|
|
2775
|
+
plain.destroy()
|
|
2776
|
+
plainHost.remove()
|
|
2777
|
+
}
|
|
2778
|
+
catch (error) {
|
|
2779
|
+
results.push(`ERROR ${error && error.stack ? error.stack : String(error)}`)
|
|
2780
|
+
}
|
|
2781
|
+
document.querySelector('#out').textContent = results.join('\n')
|
|
2782
|
+
document.title = results.some(line => line.startsWith('FAIL') || line.startsWith('ERROR')) ? 'perf FAIL' : 'perf PASS'
|
|
2783
|
+
</script>
|
|
2784
|
+
</body>
|
|
2785
|
+
</html>
|
|
2786
|
+
|