gis-view-webgl2 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -14
- package/dist/gis-view.css +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +421 -76
- package/dist/index.js +1 -1
- package/docs/AI/345/257/271/350/257/235/346/261/207/346/200/273.md +151 -0
- package/docs/GisView.md +4 -4
- package/docs/ai/06-/344/274/232/350/257/235/347/264/242/345/274/225/344/270/216/345/244/215/346/237/245/346/226/271/346/263/225.md +137 -0
- package/docs/api/index.html +23 -9
- package/docs/demos/ApiCoverageDemo.vue +644 -0
- package/docs/demos/ChannelDemo.vue +14 -12
- package/docs/demos/ClusterDemo.vue +251 -0
- package/docs/demos/CopyMergeDemo.vue +3 -4
- package/docs/demos/EventDemo.vue +3 -3
- package/docs/demos/ExtensionDemo.vue +69 -12
- package/docs/demos/GisViewDemo.vue +64 -8809
- package/docs/demos/SearchFilterDemo.vue +13 -11
- package/docs/demos/api-index.js +58 -47
- package/docs/demos/demos.js +34 -17
- package/docs/demos/gis-view-demo/GisViewDemoCardHeader.vue +29 -0
- package/docs/demos/gis-view-demo/GisViewDemoDialogs.vue +82 -0
- package/docs/demos/gis-view-demo/GisViewDemoFooter.vue +16 -0
- package/docs/demos/gis-view-demo/GisViewDemoHeading.vue +16 -0
- package/docs/demos/gis-view-demo/GisViewDemoPanelStack.vue +71 -0
- package/docs/demos/gis-view-demo/GisViewDemoStage.vue +219 -0
- package/docs/demos/gis-view-demo/demo.css +1230 -0
- package/docs/demos/gis-view-demo/demoContext.js +12 -0
- package/docs/demos/gis-view-demo/panels/DataPanels.vue +303 -0
- package/docs/demos/gis-view-demo/panels/OpsPanels.vue +174 -0
- package/docs/demos/gis-view-demo/panels/StylePanels.vue +561 -0
- package/docs/demos/gis-view-demo/panels/ViewPanels.vue +396 -0
- package/docs/demos/gis-view-demo/useGisViewDemo.js +6188 -0
- package/docs/format/07-/344/272/213/344/273/266/350/275/275/350/215/267/344/270/216/347/244/272/344/276/213/346/225/260/346/215/256.md +3 -2
- package/docs/index.html +41 -4
- package/docs/internals/16-03-/344/274/230/345/214/226/344/270/216/345/217/226/350/210/215-/344/270/213.md +1 -1
- package/docs/manual/02-/345/235/220/346/240/207/344/270/216/346/225/260/346/215/256.md +3 -2
- package/docs/manual/03-/351/205/215/347/275/256/351/241/271.md +104 -16
- package/docs/manual/04-/344/272/213/344/273/266/344/270/216/345/233/236/350/260/203.md +15 -14
- package/docs/manual/07-/346/214/207/351/222/210/344/270/216/350/246/201/347/264/240/347/274/226/350/276/221.md +40 -3
- package/docs/manual/08-/351/200/211/344/270/255/344/270/216/346/213/220/347/202/271.md +50 -3
- package/docs/manual/09-/351/200/232/351/201/223/344/270/216/345/244/232/345/233/236/347/272/277.md +6 -2
- package/docs/manual/10-/351/200/232/351/201/223/347/274/226/350/276/221/344/270/216/346/226/260/345/242/236.md +39 -7
- package/docs/manual/12-/346/240/207/347/255/276/344/270/216/350/243/205/351/245/260.md +13 -4
- package/docs/manual/13-/345/212/250/346/225/210/344/270/216/345/233/276/345/205/203.md +3 -0
- package/docs/manual/14-/350/247/206/345/233/276/344/270/216/347/255/233/351/200/211.md +121 -49
- package/docs/manual/15-/345/257/274/345/207/272/344/270/216/351/233/206/346/210/220.md +37 -3
- package/docs/manual/16-/345/256/236/344/276/213/346/226/271/346/263/225/346/200/273/350/241/250.md +17 -8
- package/docs/manual/17-/345/217/202/346/225/260/351/200/237/346/237/245.md +45 -25
- package/docs/manual/18-/351/200/220/346/226/271/346/263/225/347/244/272/344/276/213.md +91 -20
- package/docs/manual/19-/350/257/273/346/225/260/344/270/216/346/213/226/346/213/275/351/242/204/350/247/210.md +3 -3
- package/docs/manual/20-/346/200/247/350/203/275/344/270/216/345/244/247/346/225/260/346/215/256.md +25 -1
- package/docs/manual/21-/350/257/264/346/230/216/344/270/216/345/270/270/350/247/201/351/227/256/351/242/230.md +11 -8
- package/docs/steps/README.md +17 -11
- package/docs/steps/selftest-actions.html +3 -5
- package/docs/steps/selftest-demo.html +27 -18
- package/docs/steps/selftest-demos.html +409 -32
- package/docs/steps/selftest-docs.html +33 -4
- package/docs/steps/selftest-effects.html +200 -0
- package/docs/steps/selftest-features.html +603 -21
- package/docs/steps/selftest-labels.html +74 -0
- package/docs/steps/selftest-perf.html +293 -20
- package/docs/steps/selftest-scale.html +150 -29
- package/docs/steps/selftest-select.html +90 -0
- package/docs/steps/step-10-full.js +1 -1
- package/docs/steps/step-12-events.js +1 -1
- package/package.json +1 -1
|
@@ -1287,8 +1287,82 @@
|
|
|
1287
1287
|
const exportedZoomed = exportedZoom.node
|
|
1288
1288
|
const expectedZoomX = canvasLabelView.project(anchorPoint.x, anchorPoint.y)[0] + anchorGap
|
|
1289
1289
|
check('Canvas 标签随缩放只推一次,不与导出坐标二次叠加', Number.isFinite(anchorGap) && exportedZoomed !== null && Math.abs(Number(exportedZoomed.getAttribute('x')) - expectedZoomX) <= 1, `期望 x=${expectedZoomX.toFixed(1)} / 实际 ${exportedZoomed?.getAttribute('x') ?? '没有标签'} / 文本 ${exportedZoom.texts.length} 条:${exportedZoom.texts.slice(0, 8).join(',')} / 画布 ${canvasCount()} / DOM ${canvasDomCount()} / 收起 ${canvasLayer?.classList.contains('gis-view__label-layer--hidden')} / 相机 ${JSON.stringify(canvasLabelView.getCamera())}`)
|
|
1290
|
+
// 前面的导出断言把视图放大了 2 倍:缩回能看见全部 1100 条的尺度,后面的数量断言才有稳定基数
|
|
1291
|
+
canvasLabelView.setCamera({ ...cameraBeforeCanvasZoom, scale: 1 })
|
|
1292
|
+
await settle(600)
|
|
1293
|
+
// 29. 画布文字测量缓存:1100 条同文案标签只量一次宽度
|
|
1294
|
+
const measureContext = labelCanvas.getContext('2d')
|
|
1295
|
+
const nativeMeasureText = measureContext.measureText
|
|
1296
|
+
let measureCalls = 0
|
|
1297
|
+
measureContext.measureText = function (...args) {
|
|
1298
|
+
measureCalls += 1
|
|
1299
|
+
return nativeMeasureText.apply(this, args)
|
|
1300
|
+
}
|
|
1301
|
+
const sameNamePoints = canvasLabelPoints.map(point => ({ ...point, name: '同名' }))
|
|
1302
|
+
try {
|
|
1303
|
+
canvasLabelView.updateOptions({ points: sameNamePoints, labelRender: 'canvas' })
|
|
1304
|
+
await settle(600)
|
|
1305
|
+
}
|
|
1306
|
+
finally {
|
|
1307
|
+
measureContext.measureText = nativeMeasureText
|
|
1308
|
+
}
|
|
1309
|
+
check('1100 条同文案画布标签共用文字测量缓存(measureText ≤ 2 次)', canvasCount() >= 1100 && canvasDomCount() === 0 && measureCalls <= 2, `画布 ${canvasCount()} 条 / DOM ${canvasDomCount()} 条 / measureText ${measureCalls} 次`)
|
|
1310
|
+
// 30. 连续缩放按 rAF 合并:同一次任务里的多次相机变化只重绘一次
|
|
1311
|
+
const nativeClearRect = measureContext.clearRect
|
|
1312
|
+
let canvasClears = 0
|
|
1313
|
+
measureContext.clearRect = function (...args) {
|
|
1314
|
+
canvasClears += 1
|
|
1315
|
+
return nativeClearRect.apply(this, args)
|
|
1316
|
+
}
|
|
1317
|
+
const cameraBeforeBurst = canvasLabelView.getCamera()
|
|
1318
|
+
try {
|
|
1319
|
+
for (let step = 1; step <= 5; step += 1) {
|
|
1320
|
+
canvasLabelView.setCamera({ ...cameraBeforeBurst, scale: cameraBeforeBurst.scale * (1 + step * 0.01) })
|
|
1321
|
+
}
|
|
1322
|
+
await settle(80)
|
|
1323
|
+
}
|
|
1324
|
+
finally {
|
|
1325
|
+
measureContext.clearRect = nativeClearRect
|
|
1326
|
+
}
|
|
1327
|
+
check('连续 5 次缩放变化只重绘画布一次', canvasClears >= 1 && canvasClears <= 2, `clearRect ${canvasClears} 次 / 画布 ${canvasCount()} 条`)
|
|
1328
|
+
// 31. 显式 canvas 不受 1024 条阈值限制,优化档也走画布并遵守 maxCount
|
|
1329
|
+
canvasLabelView.updateOptions({
|
|
1330
|
+
points: sameNamePoints,
|
|
1331
|
+
labelOptimize: true,
|
|
1332
|
+
labelRender: 'canvas',
|
|
1333
|
+
label: { enabled: true, content: '{name}', maxCount: 200 }
|
|
1334
|
+
})
|
|
1335
|
+
await settle(600)
|
|
1336
|
+
check('labelRender: canvas 在优化档强制画布并遵守 maxCount(200 条)', canvasCount() > 0 && canvasCount() <= 200 && canvasDomCount() === 0, `画布 ${canvasCount()} 条 / DOM ${canvasDomCount()} 条 / maxCount ${canvasLabelView.getOptions().label?.maxCount}`)
|
|
1290
1337
|
canvasLabelView.destroy()
|
|
1291
1338
|
canvasLabelHost.remove()
|
|
1339
|
+
const canvasLineHost = document.createElement('div')
|
|
1340
|
+
canvasLineHost.style.cssText = 'width:800px;height:500px'
|
|
1341
|
+
document.body.append(canvasLineHost)
|
|
1342
|
+
const canvasLinePoints = []
|
|
1343
|
+
const canvasLines = []
|
|
1344
|
+
for (let row = 0; row < 6; row += 1) {
|
|
1345
|
+
const from = `line-from-${row}`
|
|
1346
|
+
const to = `line-to-${row}`
|
|
1347
|
+
canvasLinePoints.push({ id: from, x: -220, y: (row - 2.5) * 52, name: from })
|
|
1348
|
+
canvasLinePoints.push({ id: to, x: 220, y: (row - 2.5) * 52, name: to })
|
|
1349
|
+
canvasLines.push({ id: `canvas-line-${row}`, from, to, name: `线路 ${row}` })
|
|
1350
|
+
}
|
|
1351
|
+
const canvasLineView = createGisViewRaw(canvasLineHost, {
|
|
1352
|
+
points: canvasLinePoints,
|
|
1353
|
+
lines: canvasLines,
|
|
1354
|
+
initialView: { scale: 1, center: [0, 0] },
|
|
1355
|
+
lineLabel: { enabled: true, content: '{name}', position: 0.5, maxCount: 20 },
|
|
1356
|
+
labelOptimize: true,
|
|
1357
|
+
labelRender: 'canvas',
|
|
1358
|
+
showGrid: false
|
|
1359
|
+
})
|
|
1360
|
+
await settle(300)
|
|
1361
|
+
const lineCanvasLayer = canvasLineHost.querySelector('.gis-view__label-layer--line')
|
|
1362
|
+
const lineCanvas = lineCanvasLayer?.querySelector('.gis-view__label-canvas')
|
|
1363
|
+
check('线路标签同样支持 labelRender: canvas(优化档、6 条全部进画布)', Number(lineCanvas?.dataset.labelCount || 0) === 6 && (lineCanvasLayer?.querySelectorAll('.gis-view__label').length ?? 0) === 0, `画布 ${lineCanvas?.dataset.labelCount ?? 0} 条 / DOM ${lineCanvasLayer?.querySelectorAll('.gis-view__label').length ?? 0} 条`)
|
|
1364
|
+
canvasLineView.destroy()
|
|
1365
|
+
canvasLineHost.remove()
|
|
1292
1366
|
richView.destroy()
|
|
1293
1367
|
reRichView.destroy()
|
|
1294
1368
|
richHost.remove()
|
|
@@ -28,11 +28,78 @@
|
|
|
28
28
|
// 拾取用的节点:线路只挂在前面 8000 个节点上,取后面的才不会被线抢走命中
|
|
29
29
|
const clearPoint = 15000
|
|
30
30
|
try {
|
|
31
|
-
const { createGisView: createGisViewRaw, QUALITY_LEVELS, QUALITY_MODES, createQualityGovernor, normalizeQualityOptions } = await import('../../src/gis-view/index.js')
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
/* ---------------- 档位表与归一(纯函数) ---------------- */
|
|
36
103
|
check('QUALITY_MODES 是五档:四个固定档 + auto', QUALITY_MODES.join(',') === 'high,balanced,lean,fast,auto', QUALITY_MODES.join(','))
|
|
37
104
|
check('QUALITY_LEVELS 从高到低,越往下省得越多', QUALITY_LEVELS.length === 4 &&
|
|
38
105
|
QUALITY_LEVELS[0].glow && QUALITY_LEVELS[0].ornament && QUALITY_LEVELS[0].scale === 1 &&
|
|
@@ -81,6 +148,28 @@
|
|
|
81
148
|
fixed.sample(50)
|
|
82
149
|
check('固定档不受帧耗时影响', fixed.state().name === 'balanced' && fixed.state().glow === false && fixed.state().scale === 1, `${fixed.state().name} glow=${fixed.state().glow}`)
|
|
83
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
|
+
|
|
84
173
|
/* ---------------- 大数据密集档:手动开关 / auto 阈值 / 效果恢复 ---------------- */
|
|
85
174
|
const denseHost = document.createElement('div')
|
|
86
175
|
denseHost.style.cssText = 'width:420px;height:280px'
|
|
@@ -1189,10 +1278,63 @@
|
|
|
1189
1278
|
const heatFreshView = createGisView(heatFreshHost, { points: heatPoints, lines: heatLines, heatmap: heatB, width: 640, height: 380, quality: 'high' })
|
|
1190
1279
|
heatFreshView.setCamera({ center: [320, 200], scale: 1 })
|
|
1191
1280
|
const heatFreshText = JSON.stringify(heatFreshView.snapshotScene())
|
|
1192
|
-
check('样式快路径:同一份热力图配置原样再发一次,值域按当前数据重算(与整份重建一致)',
|
|
1193
|
-
heatRefreshedText !== heatAfter && heatRefreshedText === heatFreshText,
|
|
1194
|
-
heatRefreshedText === heatFreshText ? `快照 ${heatRefreshedText.length} 字节,逐字段一致` : diffText(heatRefreshedText, heatFreshText))
|
|
1195
|
-
|
|
1281
|
+
check('样式快路径:同一份热力图配置原样再发一次,值域按当前数据重算(与整份重建一致)',
|
|
1282
|
+
heatRefreshedText !== heatAfter && heatRefreshedText === heatFreshText,
|
|
1283
|
+
heatRefreshedText === heatFreshText ? `快照 ${heatRefreshedText.length} 字节,逐字段一致` : diffText(heatRefreshedText, heatFreshText))
|
|
1284
|
+
// 密度场:只改 opacity 时复用上一份采样与贴图;换色带 / 换要素值才重烤。
|
|
1285
|
+
// 判断重烤用 heatDensityStats 的对象身份:只改画法那条快路不会换它。
|
|
1286
|
+
const densityOption = {
|
|
1287
|
+
enabled: true,
|
|
1288
|
+
field: 'value',
|
|
1289
|
+
targets: ['point'],
|
|
1290
|
+
domain: [0, 100],
|
|
1291
|
+
colors: ['#0b3d91', '#ffd166', '#ff5e5e'],
|
|
1292
|
+
density: { opacity: 1, radius: 22, resolution: 192, levels: 8 }
|
|
1293
|
+
}
|
|
1294
|
+
const densityHost = document.createElement('div')
|
|
1295
|
+
document.querySelector('#map').append(densityHost)
|
|
1296
|
+
const densityView = createGisView(densityHost, {
|
|
1297
|
+
points: heatPoints,
|
|
1298
|
+
lines: [],
|
|
1299
|
+
heatmap: densityOption,
|
|
1300
|
+
width: 640,
|
|
1301
|
+
height: 380,
|
|
1302
|
+
quality: 'high',
|
|
1303
|
+
showGrid: false,
|
|
1304
|
+
background: null
|
|
1305
|
+
})
|
|
1306
|
+
densityView.fitView()
|
|
1307
|
+
await densityView.whenRendered()
|
|
1308
|
+
await densityView.whenRendered()
|
|
1309
|
+
const densityFirst = densityView.getStats().heatDensity
|
|
1310
|
+
check('热力图密度场:首帧烤出有效场(有采样数、贴图尺寸非零)',
|
|
1311
|
+
Boolean(densityFirst) && densityFirst.samples > 0 && densityFirst.width > 0 && densityFirst.height > 0,
|
|
1312
|
+
JSON.stringify(densityFirst))
|
|
1313
|
+
const opacityOption = { ...densityOption, density: { ...densityOption.density, opacity: 0.35 } }
|
|
1314
|
+
const opacityStarted = now()
|
|
1315
|
+
densityView.updateOptions({ heatmap: opacityOption })
|
|
1316
|
+
const opacityUpdateMs = now() - opacityStarted
|
|
1317
|
+
await densityView.whenRendered()
|
|
1318
|
+
const densityAfterOpacity = densityView.getStats().heatDensity
|
|
1319
|
+
check('热力图密度场:只改 density.opacity 不重采、不重烤(复用同一份烘焙读数)',
|
|
1320
|
+
densityAfterOpacity === densityFirst)
|
|
1321
|
+
const colorOption = { ...opacityOption, colors: ['#123456', '#7ce0b0', '#ff8a3d'] }
|
|
1322
|
+
const colorStarted = now()
|
|
1323
|
+
densityView.updateOptions({ heatmap: colorOption })
|
|
1324
|
+
const colorUpdateMs = now() - colorStarted
|
|
1325
|
+
await densityView.whenRendered()
|
|
1326
|
+
const densityAfterColor = densityView.getStats().heatDensity
|
|
1327
|
+
check('热力图密度场:换色带会重烤(生成新一份烘焙读数)',
|
|
1328
|
+
densityAfterColor !== densityAfterOpacity && densityAfterColor.samples === densityAfterOpacity.samples)
|
|
1329
|
+
densityView.updatePoint('h0', { value: 7 })
|
|
1330
|
+
await densityView.whenRendered()
|
|
1331
|
+
const densityAfterPoint = densityView.getStats().heatDensity
|
|
1332
|
+
check('热力图密度场:定向 updatePoint 改要素值后场跟着重烤(不再停在旧数据上)',
|
|
1333
|
+
densityAfterPoint !== densityAfterColor && densityAfterPoint.samples === densityAfterColor.samples)
|
|
1334
|
+
diag('热力图密度场更新', `纯透明度 ${opacityUpdateMs.toFixed(2)} ms / 换色带 ${colorUpdateMs.toFixed(2)} ms;烘焙 ${densityAfterColor.ms.toFixed(1)} ms`)
|
|
1335
|
+
densityView.destroy()
|
|
1336
|
+
densityHost.remove()
|
|
1337
|
+
heatFreshView.destroy()
|
|
1196
1338
|
heatFreshHost.remove()
|
|
1197
1339
|
heatView.destroy()
|
|
1198
1340
|
heatHost.remove()
|
|
@@ -1669,16 +1811,18 @@
|
|
|
1669
1811
|
document.body.appendChild(thinHost)
|
|
1670
1812
|
const thinPoints = Array.from({ length: 12 }, (unused, index) => ({ id: `tp-${index}`, x: index * 10, y: index % 3 * 6, name: `小点 ${index}` }))
|
|
1671
1813
|
const thinLines = thinPoints.slice(1).map((point, index) => ({ id: `tl-${index}`, from: thinPoints[index].id, to: point.id }))
|
|
1672
|
-
const thinView = createGisView(thinHost, {
|
|
1814
|
+
const thinView = createGisView(thinHost, {
|
|
1673
1815
|
points: thinPoints,
|
|
1674
1816
|
lines: thinLines,
|
|
1675
1817
|
pointStyle: { size: 6 },
|
|
1676
1818
|
lineStyle: { width: 1 },
|
|
1677
1819
|
fitPadding: 20,
|
|
1678
1820
|
// 交互期的那几条读数(细线快路径 / 后备缓冲)靠拖动触发:平移显式打开
|
|
1679
|
-
features: { pan: true }
|
|
1680
|
-
})
|
|
1681
|
-
|
|
1821
|
+
features: { pan: true }
|
|
1822
|
+
})
|
|
1823
|
+
// 页面底部的临时画布先滚进视口:动画与交互帧只在可见时连续推进
|
|
1824
|
+
thinHost.scrollIntoView({ block: 'center', inline: 'nearest' })
|
|
1825
|
+
await sleep(400)
|
|
1682
1826
|
const thinCanvas = thinHost.querySelector('canvas')
|
|
1683
1827
|
const thinPointerId = 91
|
|
1684
1828
|
const fireThin = (type, at) => thinCanvas.dispatchEvent(new PointerEvent(type, {
|
|
@@ -1820,15 +1964,17 @@
|
|
|
1820
1964
|
channelLines.push({ id: `cm-${index}`, from: 'p1', to: 'p2' })
|
|
1821
1965
|
channelLineIds.push(`cm-${index}`)
|
|
1822
1966
|
}
|
|
1823
|
-
const channelView = createGisView(channelHost, {
|
|
1967
|
+
const channelView = createGisView(channelHost, {
|
|
1824
1968
|
points: channelPoints,
|
|
1825
1969
|
lines: channelLines,
|
|
1826
1970
|
channels: [{ id: 'ch-cost', from: 'p1', to: 'p2', lineIds: channelLineIds, path: [[100, 30]] }],
|
|
1827
1971
|
fitPadding: 20,
|
|
1828
|
-
waypointEditable: true
|
|
1829
|
-
})
|
|
1972
|
+
waypointEditable: true
|
|
1973
|
+
})
|
|
1830
1974
|
channelView.selectFeatures('cm-0')
|
|
1831
|
-
|
|
1975
|
+
// 拐点拖动靠真实指针命中:先把这张临时画布滚进视口,别让命中坐标落在视口外
|
|
1976
|
+
channelHost.scrollIntoView({ block: 'center', inline: 'nearest' })
|
|
1977
|
+
await sleep(200)
|
|
1832
1978
|
const channelCanvas = channelHost.querySelector('canvas')
|
|
1833
1979
|
const channelWaypoint = channelView.getWaypoints(0).find(item => item.pathIndex === 1)
|
|
1834
1980
|
const channelSkeletonBefore = channelView.getChannelGroups('ch-cost')[0].skeleton.map(coordinate => coordinate[0])
|
|
@@ -1903,9 +2049,136 @@
|
|
|
1903
2049
|
const goCostBest = Math.min(...goCostMerge)
|
|
1904
2050
|
diag('分组编辑(2 万回线、只写 groups 的通道,合并一次)', `最快 ${goCostBest.toFixed(1)} ms`)
|
|
1905
2051
|
check('2 万回线的通道合并分组:40 ms 以内(成员池一次建表 + 集合去重,老的逐条 includes 是平方级)', goCostBest < 40, `最快 ${goCostBest.toFixed(1)} ms`)
|
|
1906
|
-
check('只写 groups 的通道合并之后成员一条不少(2 万条只走声明,不靠顶层 lineIds)', goCostMergedLines === goCostCount, `${goCostMergedLines} / ${goCostCount}`)
|
|
1907
|
-
|
|
1908
|
-
|
|
2052
|
+
check('只写 groups 的通道合并之后成员一条不少(2 万条只走声明,不靠顶层 lineIds)', goCostMergedLines === goCostCount, `${goCostMergedLines} / ${goCostCount}`)
|
|
2053
|
+
|
|
2054
|
+
/* ---------------- 点聚合:展示成员、拾取、点击语义与 maxZoom ---------------- */
|
|
2055
|
+
const clusterPointCount = 12000
|
|
2056
|
+
const clusterRandom = (() => {
|
|
2057
|
+
let seed = 20261021
|
|
2058
|
+
return () => {
|
|
2059
|
+
seed = (seed * 1664525 + 1013904223) % 4294967296
|
|
2060
|
+
return seed / 4294967296
|
|
2061
|
+
}
|
|
2062
|
+
})()
|
|
2063
|
+
const clusterCenters = [[0, 0], [120, 0], [60, 90]]
|
|
2064
|
+
// 留出几个远离簇中心的点,覆盖 getClusters() 的单成员分支;它们仍计入总点数。
|
|
2065
|
+
const clusterIsolated = [
|
|
2066
|
+
[-360, -170], [380, -170], [-360, 200], [380, 200],
|
|
2067
|
+
[-440, 20], [460, 20], [20, -220], [20, 240]
|
|
2068
|
+
]
|
|
2069
|
+
const clusterPoints = new Array(clusterPointCount)
|
|
2070
|
+
for (let index = 0; index < clusterPointCount; index += 1) {
|
|
2071
|
+
const isolatedIndex = index - (clusterPointCount - clusterIsolated.length)
|
|
2072
|
+
if (isolatedIndex >= 0) {
|
|
2073
|
+
clusterPoints[index] = {
|
|
2074
|
+
id: `cp${index}`,
|
|
2075
|
+
x: clusterIsolated[isolatedIndex][0],
|
|
2076
|
+
y: clusterIsolated[isolatedIndex][1],
|
|
2077
|
+
name: `独立点 ${index}`
|
|
2078
|
+
}
|
|
2079
|
+
continue
|
|
2080
|
+
}
|
|
2081
|
+
const center = clusterCenters[index % clusterCenters.length]
|
|
2082
|
+
clusterPoints[index] = {
|
|
2083
|
+
id: `cp${index}`,
|
|
2084
|
+
x: center[0] + (clusterRandom() - 0.5) * 24,
|
|
2085
|
+
y: center[1] + (clusterRandom() - 0.5) * 24,
|
|
2086
|
+
name: `聚合点 ${index}`
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
const clusterHost = document.createElement('div')
|
|
2090
|
+
clusterHost.style.cssText = 'width:640px;height:380px'
|
|
2091
|
+
document.body.appendChild(clusterHost)
|
|
2092
|
+
let clusterEvents = 0
|
|
2093
|
+
let plainEvents = 0
|
|
2094
|
+
let lastClusterPayload = null
|
|
2095
|
+
const clusterView = createGisView(clusterHost, {
|
|
2096
|
+
points: clusterPoints,
|
|
2097
|
+
width: 640,
|
|
2098
|
+
height: 380,
|
|
2099
|
+
pointStyle: { size: 4 },
|
|
2100
|
+
features: 'view',
|
|
2101
|
+
fitPadding: 24,
|
|
2102
|
+
pointCluster: { enabled: true, radius: 60, minPoints: 2 }
|
|
2103
|
+
})
|
|
2104
|
+
clusterView.on('clusterClick', payload => {
|
|
2105
|
+
clusterEvents += 1
|
|
2106
|
+
lastClusterPayload = payload
|
|
2107
|
+
})
|
|
2108
|
+
clusterView.on('click', () => {
|
|
2109
|
+
plainEvents += 1
|
|
2110
|
+
})
|
|
2111
|
+
await clusterView.whenRendered()
|
|
2112
|
+
const clusterStats = clusterView.getStats()
|
|
2113
|
+
check('点聚合:一万二千点建出多成员圆,统计口径自洽',
|
|
2114
|
+
clusterStats.clusterCount > 0 && clusterStats.clusterPointCount >= clusterStats.clusterCount && clusterStats.clusterMs >= 0,
|
|
2115
|
+
JSON.stringify({ clusterCount: clusterStats.clusterCount, clusterPointCount: clusterStats.clusterPointCount, clusterMs: clusterStats.clusterMs }))
|
|
2116
|
+
check('点聚合:getPointCluster() 给出归一化配置与 active',
|
|
2117
|
+
clusterView.getPointCluster()?.active === true && clusterView.getPointCluster()?.radius === 60,
|
|
2118
|
+
JSON.stringify(clusterView.getPointCluster()))
|
|
2119
|
+
const clusterGroups = clusterView.getClusters()
|
|
2120
|
+
const multiClusters = clusterGroups.filter(item => item.count > 1)
|
|
2121
|
+
const singletonGroups = clusterGroups.filter(item => item.count === 1)
|
|
2122
|
+
check('点聚合:getClusters() 含单成员,单成员 count=1、多成员 id 以 cluster: 开头',
|
|
2123
|
+
singletonGroups.length > 0 && multiClusters.length > 0 && singletonGroups.every(item => item.count === 1 && item.pointIndexes.length === 1) && multiClusters.every(item => item.id.startsWith('cluster:')),
|
|
2124
|
+
`${clusterGroups.length} 组(${singletonGroups.length} 单成员 / ${multiClusters.length} 多成员)`)
|
|
2125
|
+
check('点聚合:原始数据与 getData() 一条不少',
|
|
2126
|
+
clusterView.getData().points.length === clusterPointCount && clusterView.listPoints({ visibleOnly: false }).length === clusterPointCount,
|
|
2127
|
+
`getData ${clusterView.getData().points.length} / listPoints ${clusterView.listPoints({ visibleOnly: false }).length}`)
|
|
2128
|
+
const visibleSingletons = clusterView.listPoints({ visibleOnly: true })
|
|
2129
|
+
let visitedSingletons = 0
|
|
2130
|
+
clusterView.forEachPointInView(clusterView.getViewRect(), () => {
|
|
2131
|
+
visitedSingletons += 1
|
|
2132
|
+
})
|
|
2133
|
+
check('点聚合:聚合内部成员不出现在 visibleOnly / forEachPointInView',
|
|
2134
|
+
visibleSingletons.length === clusterPointCount - clusterStats.clusterPointCount && visitedSingletons === visibleSingletons.length,
|
|
2135
|
+
`可见 ${visibleSingletons.length} / 遍历 ${visitedSingletons} / 隐藏 ${clusterStats.clusterPointCount}`)
|
|
2136
|
+
const clusterPick = clusterView.pick(multiClusters[0].screen[0], multiClusters[0].screen[1])
|
|
2137
|
+
check('点聚合:聚合圆拾取返回 type=cluster,带 count / pointIds / bounds',
|
|
2138
|
+
clusterPick?.type === 'cluster' && clusterPick.count >= 2 && clusterPick.pointIds.length === clusterPick.count && Boolean(clusterPick.bounds),
|
|
2139
|
+
JSON.stringify({ type: clusterPick?.type, count: clusterPick?.count, ids: clusterPick?.pointIds?.length }))
|
|
2140
|
+
const selectionProbe = visibleSingletons[0]
|
|
2141
|
+
clusterView.selectFeatures([selectionProbe.item.id])
|
|
2142
|
+
await clusterView.whenRendered()
|
|
2143
|
+
const selectedBefore = clusterView.getSelections().map(entry => entry.item?.id ?? entry.index).join(',')
|
|
2144
|
+
const clusterCanvas = clusterHost.querySelector('canvas')
|
|
2145
|
+
// 合成指针事件使用真实客户端坐标:先把画布滚进视口,避免 clientY 落在窗口外
|
|
2146
|
+
clusterCanvas.scrollIntoView({ block: 'center' })
|
|
2147
|
+
await sleep(50)
|
|
2148
|
+
const clickPick = clusterView.pick(multiClusters[0].screen[0], multiClusters[0].screen[1])
|
|
2149
|
+
const clickScreen = clickPick?.screen ?? multiClusters[0].screen
|
|
2150
|
+
const clickClient = clusterView.screenToClient(clickScreen[0], clickScreen[1])
|
|
2151
|
+
const fireCluster = type => clusterCanvas.dispatchEvent(new PointerEvent(type, {
|
|
2152
|
+
bubbles: true, cancelable: true, pointerId: 93, pointerType: 'mouse', button: 0,
|
|
2153
|
+
buttons: type === 'pointerup' ? 0 : 1, clientX: clickClient[0], clientY: clickClient[1]
|
|
2154
|
+
}))
|
|
2155
|
+
fireCluster('pointerdown')
|
|
2156
|
+
fireCluster('pointerup')
|
|
2157
|
+
await sleep(30)
|
|
2158
|
+
const selectedAfter = clusterView.getSelections().map(entry => entry.item?.id ?? entry.index).join(',')
|
|
2159
|
+
check('点聚合:点击聚合只发 clusterClick,不发普通 click,选中集不变',
|
|
2160
|
+
clusterEvents === 1 && plainEvents === 0 && selectedAfter === selectedBefore && lastClusterPayload?.action?.type === 'clusterClick' && lastClusterPayload?.action?.data?.type === 'cluster',
|
|
2161
|
+
`clusterClick=${clusterEvents} click=${plainEvents} 选中 ${selectedBefore} → ${selectedAfter} action=${lastClusterPayload?.action?.type} clickPick=${clickPick?.type ?? 'null'} clickScreen=${clickScreen.map(value => value.toFixed(1)).join(',')} client=${clickClient.map(value => value.toFixed(1)).join(',')}`)
|
|
2162
|
+
const fitScale = clusterView.getCamera().scale
|
|
2163
|
+
clusterView.updateOptions({ pointCluster: { enabled: true, radius: 60, minPoints: 2, maxZoom: 2 } })
|
|
2164
|
+
clusterView.setCamera({ scale: fitScale * 3 })
|
|
2165
|
+
await clusterView.whenRendered()
|
|
2166
|
+
const zoomedStats = clusterView.getStats()
|
|
2167
|
+
check('点聚合:缩放超过 maxZoom 后展开原始点(active=false、聚合圆清空)',
|
|
2168
|
+
clusterView.getPointCluster()?.active === false && zoomedStats.clusterCount === 0 && clusterView.listPoints({ visibleOnly: true }).length === clusterPointCount,
|
|
2169
|
+
JSON.stringify({ active: clusterView.getPointCluster()?.active, clusterCount: zoomedStats.clusterCount, visible: clusterView.listPoints({ visibleOnly: true }).length }))
|
|
2170
|
+
clusterView.setCamera({ scale: fitScale })
|
|
2171
|
+
await clusterView.whenRendered()
|
|
2172
|
+
clusterView.updateOptions({ pointCluster: false })
|
|
2173
|
+
await clusterView.whenRendered()
|
|
2174
|
+
const disabledStats = clusterView.getStats()
|
|
2175
|
+
check('点聚合:关掉之后原始点全部恢复,getPointCluster().enabled=false',
|
|
2176
|
+
clusterView.getPointCluster()?.enabled === false && clusterView.getPointCluster()?.active === false && disabledStats.clusterCount === 0 && disabledStats.clusterPointCount === 0,
|
|
2177
|
+
JSON.stringify({ cluster: clusterView.getPointCluster(), stats: { count: disabledStats.clusterCount, points: disabledStats.clusterPointCount } }))
|
|
2178
|
+
clusterView.destroy()
|
|
2179
|
+
clusterHost.remove()
|
|
2180
|
+
|
|
2181
|
+
thinView.destroy()
|
|
1909
2182
|
thinHost.remove()
|
|
1910
2183
|
big.destroy()
|
|
1911
2184
|
bigHost.remove()
|