gis-view-webgl2 0.1.1 → 0.1.3
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 +23 -7
- package/dist/gis-view.css +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +563 -61
- package/dist/index.js +1 -1
- package/docs/AI/345/257/271/350/257/235/346/261/207/346/200/273.md +182 -0
- package/docs/GisView.md +2 -2
- 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 +165 -0
- package/docs/api/index.html +1 -1
- package/docs/demos/ApiCoverageDemo.vue +644 -0
- package/docs/demos/ChannelDemo.vue +580 -18
- package/docs/demos/ClusterDemo.vue +251 -0
- package/docs/demos/CopyMergeDemo.vue +60 -10
- package/docs/demos/EventDemo.vue +3 -3
- package/docs/demos/GisViewDemo.vue +64 -8809
- package/docs/demos/LabelDemo.vue +67 -11
- package/docs/demos/api-index.js +58 -41
- package/docs/demos/demos.js +33 -16
- package/docs/demos/gis-view-demo/GisViewDemoCardHeader.vue +28 -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/03-/346/213/220/347/202/271/350/243/205/351/245/260/344/270/216/346/240/267/345/274/217.md +4 -2
- package/docs/format/04-/351/200/232/351/201/223/344/270/216/346/240/207/347/255/276.md +5 -4
- 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/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 +10 -4
- package/docs/manual/03-/351/205/215/347/275/256/351/241/271.md +105 -16
- package/docs/manual/04-/344/272/213/344/273/266/344/270/216/345/233/236/350/260/203.md +8 -8
- 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 +100 -0
- 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 +78 -3
- package/docs/manual/08-/351/200/211/344/270/255/344/270/216/346/213/220/347/202/271.md +31 -5
- package/docs/manual/09-/351/200/232/351/201/223/344/270/216/345/244/232/345/233/236/347/272/277.md +64 -10
- 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 +65 -8
- package/docs/manual/12-/346/240/207/347/255/276/344/270/216/350/243/205/351/245/260.md +54 -1
- 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 +35 -19
- package/docs/manual/15-/345/257/274/345/207/272/344/270/216/351/233/206/346/210/220.md +5 -0
- package/docs/manual/16-/345/256/236/344/276/213/346/226/271/346/263/225/346/200/273/350/241/250.md +18 -6
- package/docs/manual/17-/345/217/202/346/225/260/351/200/237/346/237/245.md +47 -22
- package/docs/manual/18-/351/200/220/346/226/271/346/263/225/347/244/272/344/276/213.md +80 -13
- package/docs/manual/20-/346/200/247/350/203/275/344/270/216/345/244/247/346/225/260/346/215/256.md +27 -0
- 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 +5 -5
- package/docs/steps/README.md +18 -10
- package/docs/steps/selftest-actions.html +3 -5
- package/docs/steps/selftest-demo.html +27 -18
- package/docs/steps/selftest-demos.html +578 -53
- package/docs/steps/selftest-docs.html +7 -6
- package/docs/steps/selftest-effects.html +200 -0
- package/docs/steps/selftest-features.html +725 -12
- package/docs/steps/selftest-labels.html +323 -4
- package/docs/steps/selftest-perf.html +293 -20
- package/docs/steps/selftest-scale.html +52 -29
- package/docs/steps/step-10-full.js +1 -1
- package/docs/steps/step-12-events.js +1 -1
- package/docs//346/216/245/345/217/243/345/215/217/350/256/256.md +17 -11
- package/package.json +2 -2
|
@@ -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()
|
|
@@ -123,6 +123,7 @@
|
|
|
123
123
|
const plainView = createGisView(host(320, 200), {
|
|
124
124
|
points: [{ id: 'p0', x: 0, y: 0 }, { id: 'p1', x: 40, y: 0 }],
|
|
125
125
|
lines: [{ id: 'pl0', from: 'p0', to: 'p1', start: [-20, -20], end: [60, -20] }],
|
|
126
|
+
lineFollowsNode: false,
|
|
126
127
|
filterWorker: false
|
|
127
128
|
})
|
|
128
129
|
await settle()
|
|
@@ -132,8 +133,8 @@
|
|
|
132
133
|
`首顶点 ${JSON.stringify(firstVertex())}`)
|
|
133
134
|
plainView.updatePoint('p0', { visible: false })
|
|
134
135
|
await settle()
|
|
135
|
-
check('无筛选:visible: false
|
|
136
|
-
plainView.isPointVisible(0) === false && firstVertex()?.[0] ===
|
|
136
|
+
check('无筛选:lineFollowsNode: false 时 visible: false 只收起节点,绑定线停在最后坐标',
|
|
137
|
+
plainView.isPointVisible(0) === false && firstVertex()?.[0] === 0,
|
|
137
138
|
`首顶点 ${JSON.stringify(firstVertex())}`)
|
|
138
139
|
plainView.updatePoint('p0', { visible: true })
|
|
139
140
|
await settle()
|
|
@@ -776,36 +777,58 @@
|
|
|
776
777
|
const claimPath = claimView.getLine('cl')?.path
|
|
777
778
|
check('追加被线路按 id 认领的节点:回落整份重建,线跟着出来', claimPath?.length === 2 && Math.abs(claimPath[1][0] - 200) < 0.5 && claimView.getStats().pointCount === 3, `${claimPath?.length} 个顶点 / ${claimView.getStats().pointCount} 个点`)
|
|
778
779
|
claimView.destroy()
|
|
779
|
-
/*
|
|
780
|
-
const
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
780
|
+
/* 适应视图的节点间距(fitSpacing):远处孤立点撑大包围盒时,按最近可见节点中心距反推比例 */
|
|
781
|
+
const spacingPoints = [
|
|
782
|
+
{ id: 'f0', x: 0, y: 0, size: 10 },
|
|
783
|
+
{ id: 'f0b', x: 0, y: 0, size: 10 },
|
|
784
|
+
{ id: 'f1', x: 60, y: 0, size: 10 },
|
|
785
|
+
{ id: 'f2', x: 200, y: 0, size: 10 },
|
|
786
|
+
{ id: 'f3', x: 0, y: 200, size: 10 },
|
|
787
|
+
{ id: 'fx', x: 10000, y: 10000, size: 10 }
|
|
788
|
+
]
|
|
789
|
+
const fitSpacingBaseView = createGisView(host(640, 320), { points: spacingPoints, showGrid: false })
|
|
790
|
+
const spacingView = createGisView(host(640, 320), { points: spacingPoints, fitSpacing: { minDistance: 24, maxZoom: 0 }, showGrid: false })
|
|
786
791
|
await settle(140)
|
|
787
|
-
const
|
|
788
|
-
const
|
|
789
|
-
check('
|
|
790
|
-
check('
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
const
|
|
794
|
-
|
|
795
|
-
|
|
792
|
+
const plainFit = fitSpacingBaseView.getCamera().scale
|
|
793
|
+
const spacingFit = spacingView.getCamera().scale
|
|
794
|
+
check('适应节点间距:配置写进 getOptions(面板回显读得到)', spacingView.getOptions().fitSpacing?.minDistance === 24, JSON.stringify(spacingView.getOptions().fitSpacing))
|
|
795
|
+
check('适应节点间距:远处孤立点撑大包围盒后仍提高比例(重合点不算最近距离)', spacingFit > plainFit * 5, `${plainFit} → ${spacingFit}`)
|
|
796
|
+
const spacingCenter = spacingView.getCamera().center
|
|
797
|
+
check('适应节点间距:相机保持在普通适应的包围盒中心', Math.abs(spacingCenter[0] - 5000) < 0.5 && Math.abs(spacingCenter[1] - 5000) < 0.5, JSON.stringify(spacingCenter))
|
|
798
|
+
const spacingViewport = spacingView.getViewport()
|
|
799
|
+
const spacingCenterScreen = spacingView.project(5000, 5000)
|
|
800
|
+
check('适应节点间距:放大围绕普通适应中心发生,不跳到最近节点对', Math.abs(spacingCenterScreen[0] - spacingViewport.cssWidth / 2) < 0.1 && Math.abs(spacingCenterScreen[1] - spacingViewport.cssHeight / 2) < 0.1, JSON.stringify(spacingCenterScreen))
|
|
801
|
+
const spacingScreen0 = spacingView.project(0, 0)
|
|
802
|
+
const spacingScreen1 = spacingView.project(60, 0)
|
|
803
|
+
const spacingScreen = Math.hypot(spacingScreen1[0] - spacingScreen0[0], spacingScreen1[1] - spacingScreen0[1])
|
|
804
|
+
check('适应节点间距:最近节点中心距达到 minDistance(CSS 像素)', Math.abs(spacingScreen - 24) < 0.1, String(spacingScreen))
|
|
805
|
+
spacingView.updateOptions({ fitSpacing: { minDistance: 24, maxZoom: 2 } })
|
|
806
|
+
spacingView.fitView()
|
|
796
807
|
await settle(60)
|
|
797
|
-
const
|
|
798
|
-
check('
|
|
799
|
-
|
|
800
|
-
|
|
808
|
+
const spacingCapped = spacingView.getCamera().scale
|
|
809
|
+
check('适应节点间距:maxZoom 限制相对普通适应比例的上限', Math.abs(spacingCapped - plainFit * 2) < 1e-6, `${plainFit} → ${spacingCapped}`)
|
|
810
|
+
spacingView.updateOptions({ fitSpacing: false })
|
|
811
|
+
spacingView.fitView()
|
|
801
812
|
await settle(60)
|
|
802
|
-
const
|
|
803
|
-
check('
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
813
|
+
const spacingOff = spacingView.getCamera()
|
|
814
|
+
check('适应节点间距:关闭后恢复普通适应比例与包围盒中心', Math.abs(spacingOff.scale - plainFit) < 1e-9 && Math.abs(spacingOff.center[0] - 5000) < 0.5, `${spacingCapped} → ${spacingOff.scale} / ${JSON.stringify(spacingOff.center)}`)
|
|
815
|
+
/* 规则网格里最近距离满地都是:比例仍按最近距离算,相机中心继续沿用普通适应的包围盒中心 */
|
|
816
|
+
const gridPoints = []
|
|
817
|
+
for (let row = 0; row < 5; row += 1) {
|
|
818
|
+
for (let column = 0; column < 5; column += 1) {
|
|
819
|
+
gridPoints.push({ id: `g${column}_${row}`, x: column * 10, y: row * 10, size: 10 })
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
gridPoints.push({ id: 'gx', x: 10000, y: 10000, size: 10 })
|
|
823
|
+
const gridSpacingView = createGisView(host(640, 320), { points: gridPoints, fitSpacing: { minDistance: 24, maxZoom: 0 }, showGrid: false })
|
|
824
|
+
await settle(140)
|
|
825
|
+
const gridCenter = gridSpacingView.getCamera().center
|
|
826
|
+
check('适应节点间距:最近距离并列时只调整比例,相机仍停在普通适应中心',
|
|
827
|
+
Math.abs(gridCenter[0] - 5000) <= 0.5 && Math.abs(gridCenter[1] - 5000) <= 0.5,
|
|
828
|
+
JSON.stringify(gridCenter))
|
|
829
|
+
gridSpacingView.destroy()
|
|
830
|
+
fitSpacingBaseView.destroy()
|
|
831
|
+
spacingView.destroy()
|
|
809
832
|
}
|
|
810
833
|
catch (error) {
|
|
811
834
|
check('页面没有抛错', false, String(error && error.stack ? error.stack : error).split('\n').slice(0, 3).join(' | '))
|
|
@@ -76,7 +76,7 @@ export const createFullView = (target, options = {}) => {
|
|
|
76
76
|
draggable: true,
|
|
77
77
|
boxSelect: true,
|
|
78
78
|
waypointEditable: true,
|
|
79
|
-
features: { pan: true, zoom: true, hover: true, select: true, contextMenu: true, keyboard: true, channelDraft: true
|
|
79
|
+
features: { pan: true, zoom: true, hover: true, select: true, contextMenu: true, keyboard: true, channelDraft: true },
|
|
80
80
|
// 组件的回调都带载荷:type / index / item 就是「当前是哪个元素」,world 是光标处的世界坐标
|
|
81
81
|
onHover: payload => forward.onHover?.(payload),
|
|
82
82
|
onClick: payload => forward.onClick?.(payload),
|
|
@@ -119,7 +119,7 @@ export const createActionView = (target, options = {}) => {
|
|
|
119
119
|
draggable: true,
|
|
120
120
|
boxSelect: true,
|
|
121
121
|
waypointEditable: true,
|
|
122
|
-
features: { pan: true, zoom: true, hover: true, select: true, contextMenu: true, keyboard: true, channelDraft: true
|
|
122
|
+
features: { pan: true, zoom: true, hover: true, select: true, contextMenu: true, keyboard: true, channelDraft: true },
|
|
123
123
|
// 下面五条是同一条路:每个回调先报一句判读,再交给页面自己那一条(日志、草稿读数都还在)
|
|
124
124
|
onPointerDown: (payload, event) => {
|
|
125
125
|
report(describeAction(payload, '按下'))
|
|
@@ -325,7 +325,7 @@ HTTP `409 Conflict` 示例:
|
|
|
325
325
|
"channelId": "channel-a",
|
|
326
326
|
"action": { "type": "splitGroups", "groupIds": ["channel-a", "channel-a::2"] },
|
|
327
327
|
"groups": [
|
|
328
|
-
{ "id": "channel-a", "lineIds": ["line-0", "line-1"], "path": [[18.0, 10.2], [20.5, 11.1]] },
|
|
328
|
+
{ "id": "channel-a", "lineIds": ["line-0", "line-1"], "path": [[18.0, 10.2], [20.5, 11.1]], "waypointOffsets": { "1": [1.2, -0.8] } },
|
|
329
329
|
{ "id": "channel-a::2", "lineIds": ["line-2"], "path": [] }
|
|
330
330
|
],
|
|
331
331
|
"baseVersion": 12,
|
|
@@ -337,10 +337,11 @@ HTTP `409 Conflict` 示例:
|
|
|
337
337
|
| --- | --- | --- |
|
|
338
338
|
| `channelId` | `string \| number` | 通道 id,必须与路径参数一致 |
|
|
339
339
|
| `action` | `object` | 审计用动作:`splitGroups` / `mergeGroups` / `moveLine` / `addGroup` / `setGroups`;服务端不应依赖它恢复数据 |
|
|
340
|
-
| `groups` | `Array<{ id, lineIds, path }>` | **整份**分组声明;顺序就是后续回线排布顺序 |
|
|
340
|
+
| `groups` | `Array<{ id, lineIds, path, waypointOffsets? }>` | **整份**分组声明;顺序就是后续回线排布顺序 |
|
|
341
341
|
| `groups[].id` | `string \| number` | 分组稳定 id |
|
|
342
342
|
| `groups[].lineIds` | `Array<string \| number>` | 这一组包含的回线 id;同一条回线在整份声明里只能出现一次 |
|
|
343
343
|
| `groups[].path` | `[number, number][]` | 分组公共骨架的中间拐点;`[]` 是合法的「直连」,不能当成没提交 |
|
|
344
|
+
| `groups[].waypointOffsets` | `Record<string, [number, number] \| { x, y }>` | `vertex` 拖动的分组刚性偏移:键是含首尾端点的骨架顶点序号(只认中间拐点),值是组内所有回线共用的世界坐标增量;省略或空对象表示不带偏移 |
|
|
344
345
|
|
|
345
346
|
`groups` 至少保留一组。若业务允许空通道,建议另走 `DELETE /gis/channels/{channelId}`,不要把空分组声明当作删除通道的隐式约定。
|
|
346
347
|
|
|
@@ -350,7 +351,7 @@ HTTP `409 Conflict` 示例:
|
|
|
350
351
|
{
|
|
351
352
|
"channelId": "channel-a",
|
|
352
353
|
"groups": [
|
|
353
|
-
{ "id": "channel-a", "lineIds": ["line-0", "line-1"], "path": [[18.0, 10.2], [20.5, 11.1]] },
|
|
354
|
+
{ "id": "channel-a", "lineIds": ["line-0", "line-1"], "path": [[18.0, 10.2], [20.5, 11.1]], "waypointOffsets": { "1": [1.2, -0.8] } },
|
|
354
355
|
{ "id": "channel-a::2", "lineIds": ["line-2"], "path": [] }
|
|
355
356
|
],
|
|
356
357
|
"groupCount": 2,
|
|
@@ -358,11 +359,11 @@ HTTP `409 Conflict` 示例:
|
|
|
358
359
|
}
|
|
359
360
|
```
|
|
360
361
|
|
|
361
|
-
服务端应校验 channel 存在、`lineIds` 全部属于这条通道且不重复、每个 `path`
|
|
362
|
+
服务端应校验 channel 存在、`lineIds` 全部属于这条通道且不重复、每个 `path` 点都是有限数值,以及 `waypointOffsets` 的键都落在中间拐点、值都是有限增量;返回落库后的整份快照,前端用 `groups` 覆盖本地对应通道。
|
|
362
363
|
|
|
363
364
|
### 5.2 `PATCH /gis/channels/{channelId}/groups/waypoints`
|
|
364
365
|
|
|
365
|
-
|
|
366
|
+
覆盖一条通道里某一组的**公共骨架与分组刚性偏移**。`path` 只包含中间拐点,两端由通道绑定的节点补齐;`waypointOffsets` 是 `channelWaypointDragMode: 'vertex'` 拖出来的按骨架序号偏移。拖动过程中 `phase: 'start' / 'move'` 不发请求,只在 `add` / `remove` / `end` 发一条。
|
|
366
367
|
|
|
367
368
|
请求体:
|
|
368
369
|
|
|
@@ -372,6 +373,7 @@ HTTP `409 Conflict` 示例:
|
|
|
372
373
|
"channelId": "channel-a",
|
|
373
374
|
"groupId": "channel-a::2",
|
|
374
375
|
"path": [[18.0, 10.2], [20.5, 11.1]],
|
|
376
|
+
"waypointOffsets": { "1": [1.2, -0.8] },
|
|
375
377
|
"phase": "end",
|
|
376
378
|
"scope": "group",
|
|
377
379
|
"baseVersion": 12,
|
|
@@ -384,8 +386,9 @@ HTTP `409 Conflict` 示例:
|
|
|
384
386
|
| `channelId` | `string \| number` | 通道 id |
|
|
385
387
|
| `groupId` | `string \| number` | 分组 id |
|
|
386
388
|
| `path` | `[number, number][]` | 整份公共骨架的中间拐点;空数组表示直连 |
|
|
389
|
+
| `waypointOffsets` | `Record<string, [number, number] \| { x, y }>` | 分组刚性偏移:键按含首尾端点的骨架序号算,只接受中间拐点;省略或空对象表示普通平行骨架 |
|
|
387
390
|
| `phase` | `'add' \| 'remove' \| 'end'` | 实际落地阶段;`select` / `start` / `move` 不应发请求 |
|
|
388
|
-
| `scope` | `'group'` |
|
|
391
|
+
| `scope` | `'group'` | 公共骨架与分组刚性偏移的存储范围;组内每条回线的车道偏移是由它派生的,不单独落库 |
|
|
389
392
|
|
|
390
393
|
响应 `data.result`:
|
|
391
394
|
|
|
@@ -393,10 +396,13 @@ HTTP `409 Conflict` 示例:
|
|
|
393
396
|
{
|
|
394
397
|
"target": { "type": "channelGroup", "channelId": "channel-a", "groupId": "channel-a::2", "scope": "group" },
|
|
395
398
|
"path": [[18.0, 10.2], [20.5, 11.1]],
|
|
399
|
+
"waypointOffsets": { "1": [1.2, -0.8] },
|
|
396
400
|
"pointCount": 2
|
|
397
401
|
}
|
|
398
402
|
```
|
|
399
403
|
|
|
404
|
+
服务端必须把 `path` 与 `waypointOffsets` 一起落库并一起返回:只收 `path` 会丢掉非平行拖动结果,刷新后分组又回到旧骨架。
|
|
405
|
+
|
|
400
406
|
### 5.3 `PATCH /gis/lines/{lineId}/path`
|
|
401
407
|
|
|
402
408
|
覆盖独立线路的整条折线。与通道分组不同,这里 `path` **包含首尾端点**,因为独立线路不依赖通道节点补端点。
|
|
@@ -446,9 +452,9 @@ HTTP `409 Conflict` 示例:
|
|
|
446
452
|
### 6.3 通道分组 / 拐点
|
|
447
453
|
|
|
448
454
|
1. 只在真实落地的 `add` / `remove` / `end` 发请求;`start` / `move` 的中间态不发。
|
|
449
|
-
2. 请求体永远是操作完成后的**整份** `groups`
|
|
455
|
+
2. 请求体永远是操作完成后的**整份** `groups`,或成对的 `path` + `waypointOffsets`,从 `view.getChannelGroups()` / `view.getChannelGroupWaypoints()` 重新读。
|
|
450
456
|
3. 同一条通道的写请求串行发送;拖动期间只保留最后一个 `end`。
|
|
451
|
-
4. 成功用响应里的 `groups` / `path` 覆盖本地快照;如果服务端做了归一化,以服务端为准。
|
|
457
|
+
4. 成功用响应里的 `groups` / `path` / `waypointOffsets` 覆盖本地快照;如果服务端做了归一化,以服务端为准。
|
|
452
458
|
5. 409 优先使用错误包里的 `data.current` 做局部恢复;没有 `current` 时先拉最新文档。
|
|
453
459
|
|
|
454
460
|
### 6.4 前端落地映射:拿到 `data.result` 之后
|
|
@@ -582,7 +588,7 @@ else {
|
|
|
582
588
|
|
|
583
589
|
const opId = crypto.randomUUID() // rebase 是一次新提交,不能复用旧 opId
|
|
584
590
|
const nextBody = {
|
|
585
|
-
...rebuildRequestFromCurrentView(), // 从恢复后的视图重新读整份 groups / path
|
|
591
|
+
...rebuildRequestFromCurrentView(), // 从恢复后的视图重新读整份 groups / path + waypointOffsets
|
|
586
592
|
baseVersion: failure.currentVersion,
|
|
587
593
|
opId
|
|
588
594
|
}
|
|
@@ -611,7 +617,7 @@ const nextBody = {
|
|
|
611
617
|
- **事务边界**:成功应用与幂等记录、版本推进在同一个数据库事务里提交;否则会出现「数据已写但幂等表没写」的重复应用风险。
|
|
612
618
|
- **稳定 id**:所有分组、线路、通道都优先按 id 对账,不要用数组下标当业务主键。客户端生成 id 时,后端要校验唯一性。
|
|
613
619
|
- **空 `path`**:是合法值,表示直连。校验时只检查「是否是数组 + 坐标是否有限」,不要用 truthy 判断吞掉空数组。
|
|
614
|
-
- **响应快照**:分组 / 拐点接口返回落库后的最新 `groups` / `path`;复制返回 `idMap`;合并返回事务实际涉及的 id 集合。
|
|
620
|
+
- **响应快照**:分组 / 拐点接口返回落库后的最新 `groups` / `path` / `waypointOffsets`;复制返回 `idMap`;合并返回事务实际涉及的 id 集合。
|
|
615
621
|
- **审计**:记录 `requestId` / `traceId` / `opId` / `transactionId` / `actor` / 资源 id,日志里不要只记一个 URL。
|
|
616
622
|
- **顺序**:同文档的写请求按版本串行;客户端也应对同一条通道 / 同一份文档做串行化,避免多个成功响应相互覆盖。
|
|
617
623
|
|
|
@@ -622,7 +628,7 @@ const nextBody = {
|
|
|
622
628
|
| 批量复制与合并 | 框选后复制 | `POST /gis/copies/batch` | `transactionId`、`created`、`counts`、`idMap` |
|
|
623
629
|
| 批量复制与合并 | 拖拽 / 选中后合并 | `POST /gis/merges` | `retainedPointIds`、`removedPointIds`、删线 / 删通道 id |
|
|
624
630
|
| 通道与数据 | 拆 / 并 / 挪回线 / 新建组 | `PATCH /gis/channels/{id}/groups` | `groups` 最新快照、`groupCount`、`lineCount` |
|
|
625
|
-
| 通道与数据 | 通道分组加 / 删 / 拖拐点 | `PATCH /gis/channels/{id}/groups/waypoints` | `target`、`path`、`pointCount` |
|
|
631
|
+
| 通道与数据 | 通道分组加 / 删 / 拖拐点 | `PATCH /gis/channels/{id}/groups/waypoints` | `target`、`path`、`waypointOffsets`、`pointCount` |
|
|
626
632
|
| 通道与数据 | 独立线路拐点 | `PATCH /gis/lines/{id}/path` | `target`、`path`、`pointCount` |
|
|
627
633
|
|
|
628
634
|
两个演示的面板都提供:正常 200、网络失败、服务端 500、版本冲突 409;“重试最近失败请求”保留原 `opId`,「重放最近成功请求」用于验证幂等回放不推进版本。请求预览区会同时打印请求头、body、HTTP 状态与完整响应包。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gis-view-webgl2",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "WebGL2 矢量视图:点、线与 GeoJSON / 图片背景层,无内置界面,点击 / 双击 / 拖拽可配置",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -40,4 +40,4 @@
|
|
|
40
40
|
"vite-plugin-lib-inject-css": "^2.2.2",
|
|
41
41
|
"vue": "^3.5.32"
|
|
42
42
|
}
|
|
43
|
-
}
|
|
43
|
+
}
|