gis-view-webgl2 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +40 -8
  2. package/dist/gis-view.css +1 -1
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.d.ts +1036 -249
  5. package/dist/index.js +1 -1
  6. package/docs/AI/345/257/271/350/257/235/346/261/207/346/200/273.md +194 -0
  7. package/docs/GisView.md +9 -8
  8. package/docs/GisView/345/256/236/347/216/260/345/216/237/347/220/206.md +2 -2
  9. package/docs/ai/06-/344/274/232/350/257/235/347/264/242/345/274/225/344/270/216/345/244/215/346/237/245/346/226/271/346/263/225.md +172 -0
  10. package/docs/api/index.html +545 -59
  11. package/docs/demos/BackendSyncDemo.vue +10 -8
  12. package/docs/demos/BasicDemo.vue +101 -101
  13. package/docs/demos/BatchAddDemo.vue +5 -5
  14. package/docs/demos/ChannelDemo.vue +617 -10
  15. package/docs/demos/ConnectDemo.vue +392 -0
  16. package/docs/demos/CopyMergeDemo.vue +980 -929
  17. package/docs/demos/DecorationDraftDemo.vue +1300 -0
  18. package/docs/demos/DemoShell.vue +569 -240
  19. package/docs/demos/DragDropDemo.vue +11 -11
  20. package/docs/demos/EffectsDemo.vue +6 -6
  21. package/docs/demos/EventDemo.vue +327 -327
  22. package/docs/demos/ExtensionDemo.vue +458 -0
  23. package/docs/demos/GisViewDemo.vue +61 -48
  24. package/docs/demos/GraphicDemo.vue +338 -0
  25. package/docs/demos/LabelDemo.vue +553 -497
  26. package/docs/demos/MinimapDemo.vue +358 -0
  27. package/docs/demos/PerfDemo.vue +29 -0
  28. package/docs/demos/SearchFilterDemo.vue +412 -0
  29. package/docs/demos/StyleDemo.vue +121 -121
  30. package/docs/demos/api-index.js +98 -77
  31. package/docs/demos/demos.js +159 -135
  32. package/docs/demos/shared/demoData.js +107 -107
  33. package/docs/demos/shared/demoStyle.css +249 -249
  34. package/docs/demos/shared/fakeBackend.js +93 -6
  35. package/docs/format/03-/346/213/220/347/202/271/350/243/205/351/245/260/344/270/216/346/240/267/345/274/217.md +28 -8
  36. package/docs/format/04-/351/200/232/351/201/223/344/270/216/346/240/207/347/255/276.md +5 -4
  37. package/docs/format/06-/345/257/274/345/205/245/345/257/274/345/207/272/344/270/216/346/240/207/345/207/206/346/226/207/346/241/243.md +8 -6
  38. package/docs/format/07-/344/272/213/344/273/266/350/275/275/350/215/267/344/270/216/347/244/272/344/276/213/346/225/260/346/215/256.md +6 -6
  39. package/docs/guide/sample-document.html +4 -4
  40. package/docs/index.html +180 -9
  41. package/docs/internals/11-/350/246/206/347/233/226/345/261/202/346/240/207/347/255/276/344/270/216/346/217/220/347/244/272.md +1 -1
  42. package/docs/internals/13-/351/271/260/347/234/274/345/233/276.md +3 -2
  43. package/docs/internals/15-03-/351/200/220/350/275/256/350/256/260/345/275/225-/346/200/247/350/203/275/344/270/216/344/277/256/345/244/215-/344/270/213.md +16 -0
  44. package/docs/internals/16-03-/344/274/230/345/214/226/344/270/216/345/217/226/350/210/215-/344/270/213.md +11 -0
  45. package/docs/internals/17-/350/260/203/350/257/225/344/270/216/346/211/251/345/261/225/345/273/272/350/256/256.md +1 -1
  46. package/docs/manual/02-/345/235/220/346/240/207/344/270/216/346/225/260/346/215/256.md +18 -4
  47. package/docs/manual/03-/351/205/215/347/275/256/351/241/271.md +110 -25
  48. package/docs/manual/04-/344/272/213/344/273/266/344/270/216/345/233/236/350/260/203.md +13 -8
  49. package/docs/manual/06-/346/225/260/346/215/256/344/270/216/345/234/272/346/231/257/346/233/264/346/226/260.md +111 -10
  50. package/docs/manual/07-/346/214/207/351/222/210/344/270/216/350/246/201/347/264/240/347/274/226/350/276/221.md +47 -9
  51. package/docs/manual/08-/351/200/211/344/270/255/344/270/216/346/213/220/347/202/271.md +17 -15
  52. package/docs/manual/09-/351/200/232/351/201/223/344/270/216/345/244/232/345/233/236/347/272/277.md +61 -11
  53. package/docs/manual/10-/351/200/232/351/201/223/347/274/226/350/276/221/344/270/216/346/226/260/345/242/236.md +74 -7
  54. package/docs/manual/11-/346/240/267/345/274/217/344/270/216/350/247/204/345/210/231.md +3 -2
  55. package/docs/manual/12-/346/240/207/347/255/276/344/270/216/350/243/205/351/245/260.md +260 -3
  56. package/docs/manual/13-/345/212/250/346/225/210/344/270/216/345/233/276/345/205/203.md +6 -5
  57. package/docs/manual/14-/350/247/206/345/233/276/344/270/216/347/255/233/351/200/211.md +27 -14
  58. package/docs/manual/15-/345/257/274/345/207/272/344/270/216/351/233/206/346/210/220.md +142 -49
  59. package/docs/manual/16-/345/256/236/344/276/213/346/226/271/346/263/225/346/200/273/350/241/250.md +31 -29
  60. package/docs/manual/17-/345/217/202/346/225/260/351/200/237/346/237/245.md +55 -39
  61. package/docs/manual/18-/351/200/220/346/226/271/346/263/225/347/244/272/344/276/213.md +189 -64
  62. package/docs/manual/19-/350/257/273/346/225/260/344/270/216/346/213/226/346/213/275/351/242/204/350/247/210.md +4 -4
  63. package/docs/manual/20-/346/200/247/350/203/275/344/270/216/345/244/247/346/225/260/346/215/256.md +42 -2
  64. package/docs/manual/21-/350/257/264/346/230/216/344/270/216/345/270/270/350/247/201/351/227/256/351/242/230.md +22 -6
  65. package/docs/playground/index.html +5 -5
  66. package/docs/steps/README.md +14 -10
  67. package/docs/steps/check-doc-truth.mjs +1 -1
  68. package/docs/steps/selftest-actions.html +10 -10
  69. package/docs/steps/selftest-data.html +96 -14
  70. package/docs/steps/selftest-decoration.html +577 -0
  71. package/docs/steps/selftest-demo.html +6 -5
  72. package/docs/steps/selftest-demos.html +2201 -1612
  73. package/docs/steps/selftest-docs.html +89 -16
  74. package/docs/steps/selftest-effects.html +5 -5
  75. package/docs/steps/selftest-features.html +757 -86
  76. package/docs/steps/selftest-full.html +583 -583
  77. package/docs/steps/selftest-labels.html +1034 -652
  78. package/docs/steps/selftest-perf.html +1899 -1844
  79. package/docs/steps/selftest-scale.html +117 -7
  80. package/docs/steps/selftest-select.html +24 -17
  81. package/docs/steps/step-10-full.js +1 -1
  82. package/docs//346/216/245/345/217/243/345/215/217/350/256/256.md +167 -17
  83. package/docs//346/225/260/346/215/256/346/240/274/345/274/217.md +1 -1
  84. package/package.json +43 -43
  85. package/docs/demos/HistoryDemo.vue +0 -230
  86. package/docs/demos/InterfaceDemo.vue +0 -309
  87. package/docs/demos/SampleDemo.vue +0 -283
@@ -34,7 +34,7 @@
34
34
  try {
35
35
  const {
36
36
  createGisView, createConditionMatcher,
37
- registerConditionOperator, unregisterConditionOperator, hasConditionOperator, listConditionOperators
37
+ defineConditionOperator, registerConditionOperator, unregisterConditionOperator, hasConditionOperator, listConditionOperators
38
38
  } = await import('../../src/gis-view/index.js')
39
39
  const { compileCondition } = await import('../../src/gis-view/condition.js')
40
40
  const { createPointGrid, createSegmentGrid } = await import('../../src/gis-view/utils.js')
@@ -148,12 +148,14 @@
148
148
  check('regex 带 g 标志时逐条判定不来回翻转(编译时去掉 g / y)', ['站A', '站B', '站C', '站D'].every(id => globalRegex({ id }) === true), ['站A', '站B', '站C', '站D'].map(id => globalRegex({ id })).join(' / '))
149
149
  /* 2.8 自定义运算符:注册之后显式条件 / 简写映射 / 规则 / 筛选都认;注销按认不出来处理;重名与坏工厂只报 onWarning */
150
150
  const operatorWarnings = []
151
- const operatorView = createGisView(host(400, 240), {
151
+ const operatorHost = host(400, 240)
152
+ const operatorView = createGisView(operatorHost, {
152
153
  points: [
153
- { id: 'op0', x: 0, y: 0, vn_kv: 4 },
154
- { id: 'op1', x: 100, y: 0, vn_kv: 5 },
155
- { id: 'op2', x: 200, y: 0, vn_kv: 6 }
154
+ { id: 'op0', x: 0, y: 0, vn_kv: 4, name: '偶数甲' },
155
+ { id: 'op1', x: 100, y: 0, vn_kv: 5, name: '奇数乙' },
156
+ { id: 'op2', x: 200, y: 0, vn_kv: 6, name: '偶数丙' }
156
157
  ],
158
+ label: { content: '{name}', when: { vn_kv: { isEven: true } } },
157
159
  filterWorker: false,
158
160
  onWarning: payload => operatorWarnings.push(typeof payload === 'string' ? payload : payload?.message ?? '')
159
161
  })
@@ -181,6 +183,10 @@
181
183
  check('自定义运算符:规则与筛选当场按新注册表重编译(偶数那条上色、奇数那条被筛掉)',
182
184
  operatorHits === 2 && operatorView.getFilterResult().hiddenPoints.length === 1 && operatorView.getFilterResult().hiddenPoints[0] === 1,
183
185
  `上色 ${operatorHits} 条 / 隐藏 ${JSON.stringify(operatorView.getFilterResult().hiddenPoints)}`)
186
+ const operatorLabels = () => [...operatorHost.querySelectorAll('.gis-view__label[data-label-kind="point"]')]
187
+ check('自定义运算符:注册之后标签 when 也按新注册表重编译(偶数两条标签出现)',
188
+ operatorLabels().length === 2 && operatorLabels().every(node => node.textContent.includes('偶数')),
189
+ operatorLabels().map(node => node.textContent).join(' / '))
184
190
  const operatorCountBefore = operatorView.listOperators().length
185
191
  operatorView.registerOperator('gte', value => () => true)
186
192
  operatorView.registerOperator('', value => () => true)
@@ -197,6 +203,51 @@
197
203
  registerConditionOperator('probeOp', value => () => true)
198
204
  check('自定义运算符:模块级注册与实例读的是同一份表,注销之后两边一起消失',
199
205
  hasConditionOperator('probeOp') === true && operatorView.listOperators().length === 15 && operatorView.unregisterOperator('probeOp') === true && hasConditionOperator('probeOp') === false)
206
+ const scopedOperator = value => current => current === value
207
+ const scopedOffA = defineConditionOperator('scopedOp', scopedOperator)
208
+ const scopedOffB = defineConditionOperator('scopedOp', scopedOperator)
209
+ const staleOperatorRemoval = scopedOffA()
210
+ const scopedOperatorSurvived = hasConditionOperator('scopedOp') &&
211
+ createConditionMatcher({ vn_kv: { scopedOp: 4 } })({ vn_kv: 4 }) === true
212
+ const activeOperatorRemoval = scopedOffB()
213
+ const repeatedOperatorRemoval = scopedOffB()
214
+ check('自定义运算符:同名后注册覆盖时旧清理函数不误删(工厂相同也算新注册),新清理函数正常摘除且幂等',
215
+ staleOperatorRemoval === false && scopedOperatorSurvived && activeOperatorRemoval === true &&
216
+ repeatedOperatorRemoval === false && hasConditionOperator('scopedOp') === false,
217
+ JSON.stringify({ stale: staleOperatorRemoval, survived: scopedOperatorSurvived, active: activeOperatorRemoval, repeated: repeatedOperatorRemoval }))
218
+ /* 2.9 标签条件的编译缓存:两条不同的 when 轮流求值时,单条缓存会每帧反复落空;
219
+ 按条件对象身份缓存之后,首次排版各编译一次,后续相机变化只查表。 */
220
+ let countedCompiles = 0
221
+ const offCounted = defineConditionOperator('countedLabelOp', value => {
222
+ countedCompiles += 1
223
+ return current => (Number(current) % 2 === 0) === (value === true)
224
+ })
225
+ const countedHost = host(400, 240)
226
+ const countedPoints = Array.from({ length: 24 }, (_, index) => ({
227
+ id: `counted-${index}`,
228
+ x: (index % 6) * 32,
229
+ y: Math.floor(index / 6) * 28,
230
+ vn_kv: index,
231
+ name: `缓存 ${index}`
232
+ }))
233
+ const countedView = createGisView(countedHost, {
234
+ points: countedPoints,
235
+ filterWorker: false,
236
+ label: [
237
+ { id: 'even', content: '偶 {name}', when: { vn_kv: { countedLabelOp: true } } },
238
+ { id: 'odd', content: '奇 {name}', when: { vn_kv: { countedLabelOp: false } } }
239
+ ]
240
+ })
241
+ await settle(160)
242
+ const countedFirst = countedCompiles
243
+ const countedCamera = countedView.getCamera()
244
+ countedView.setCamera({ center: [...countedCamera.center], scale: countedCamera.scale * 3 })
245
+ await settle(160)
246
+ check('标签条件缓存:多条不同 when 共用运算符时按对象各自复用(首轮只编译两条,相机变化不再重编译)',
247
+ countedFirst >= 2 && countedFirst <= 4 && countedCompiles === countedFirst,
248
+ `首轮 ${countedFirst} 次 / 相机变化后 ${countedCompiles} 次 / 标签 ${countedHost.querySelectorAll('.gis-view__label[data-label-kind="point"]').length} 个`)
249
+ countedView.destroy()
250
+ offCounted()
200
251
  operatorView.destroy()
201
252
  /* 2.5 筛选的数组简写:字段值写数组 = 「等于其中之一」(in),字段本身是数组时按「有交集」算 */
202
253
  const arrayPoints = [
@@ -251,6 +302,28 @@
251
302
  byCoordinate.updatePoint('a1', { x: 100, y: 0 })
252
303
  check('坐标联动:点挪回去,线又出来', byCoordinate.getFilterResult().hiddenLines.length === 0, `隐藏线 ${byCoordinate.getFilterResult().hiddenLines.length}`)
253
304
  byCoordinate.destroy()
305
+ /* 3.2 重复节点 id:引擎端点、筛选投影与按 id 定位统一只认第一条 */
306
+ const duplicatePointView = createGisView(host(400, 240), {
307
+ points: [
308
+ { id: 'pdup', x: 0, y: 0, vn_kv: 525 },
309
+ { id: 'pdup', x: 100, y: 0, vn_kv: 525 },
310
+ { id: 'keep', x: 300, y: 0, vn_kv: 525 },
311
+ { id: 'gone', x: 0, y: 0, vn_kv: 100 }
312
+ ],
313
+ lines: [{ id: 'dup-line', from: 'pdup', to: 'keep', vn_kv: 525 }],
314
+ filterWorker: false
315
+ })
316
+ await settle()
317
+ const duplicatePath = duplicatePointView.getLine('dup-line')?.path ?? []
318
+ duplicatePointView.setFilter({ points: { vn_kv: { gte: 525 } }, cascade: 'coordinate' })
319
+ await settle()
320
+ const duplicateFilterResult = duplicatePointView.getFilterResult()
321
+ check('重复节点 id:线路端点 / 筛选投影 / 按 id 定位都只认第一条(不是最后一条)',
322
+ duplicatePath[0]?.[0] === 0 && duplicatePath[0]?.[1] === 0 &&
323
+ JSON.stringify(duplicateFilterResult.hiddenPoints) === JSON.stringify([3]) &&
324
+ JSON.stringify(duplicateFilterResult.hiddenLines) === JSON.stringify([0]),
325
+ JSON.stringify({ path: duplicatePath, hiddenPoints: duplicateFilterResult.hiddenPoints, hiddenLines: duplicateFilterResult.hiddenLines }))
326
+ duplicatePointView.destroy()
254
327
  /* 3.5 主视图筛选 cascade:被筛掉的节点仍然当锚点用(几何不跟着筛选断,连不连线由 cascade 决定)。
255
328
  通道回线也是普通线路,从前一端节点被筛掉时整条通道连回线一起消失,cascade 怎么写都没用 */
256
329
  const cascadePoints = [
@@ -338,6 +411,11 @@
338
411
  pointGrid.build([{ x: 0, y: 0 }, { x: 10, y: 10 }, { x: 100, y: 100 }])
339
412
  const boxed = pointGrid.query(5, 5, 15, 15, [])
340
413
  check('点网格照旧:框选只取范围内的格子,远处的点不进候选', boxed.includes(1) && !boxed.includes(2), `候选 ${JSON.stringify(boxed)}`)
414
+ const boxedEstimate = pointGrid.estimate(5, 5, 15, 15)
415
+ const cappedGrid = createPointGrid()
416
+ cappedGrid.build([{ x: 0, y: 0 }, { x: 100, y: 100 }, { x: 200, y: 200 }, { x: 300, y: 300 }])
417
+ const cappedEstimate = cappedGrid.estimate(0, 0, 300, 300, 0)
418
+ check('点网格 estimate 与 query 同口径、可提前封顶(不建候选数组也能判断值不值得走网格)', boxedEstimate === boxed.length && boxedEstimate === 2 && cappedEstimate > 0 && cappedEstimate < 4, '估计 ' + boxedEstimate + ' / 候选 ' + boxed.length + ' · 封顶 ' + cappedEstimate + ' / 全量 4')
341
419
  /* 5. 拾取:换了线段索引之后,点与线(含长线)都还拾得到 */
342
420
  const pickPoints = [
343
421
  { id: 'k0', x: 0, y: 0, name: '甲' },
@@ -368,6 +446,20 @@
368
446
  check('改过字段之后按新值搜得到、旧值搜不到', renamed.points.length === 1 && stale.points.length === 0, `新 ${renamed.points.length} / 旧 ${stale.points.length}`)
369
447
  const byCoordinateHit = view.search({ keyword: '1234.5' }).points
370
448
  check('坐标也参与搜索(缓存里有 x / y)', byCoordinateHit.length === 1 && byCoordinateHit[0].id === 's7', `命中 ${byCoordinateHit.length} 个`)
449
+ const invalidLimit = view.search({ keyword: '站', limit: Number.NaN })
450
+ const stringFields = view.search({ keyword: '站', fields: 'name', limit: 5 })
451
+ check('搜索选项归一:坏 limit 回落到缺省上限,fields 不是数组时不让字符串被拆成逐字字段',
452
+ invalidLimit.points.length === 200 && invalidLimit.truncated === true && stringFields.points.length === 5,
453
+ JSON.stringify({ invalid: invalidLimit.points.length, fields: stringFields.points.length }))
454
+ const styleFieldSearchView = createGisView(host(240, 160), {
455
+ points: [{ id: 'sf', x: 0, y: 0, name: '普通节点', symbol: 'marker-triangle' }],
456
+ showGrid: false
457
+ })
458
+ const styleFieldHits = styleFieldSearchView.search({ keyword: 'marker-triangle', fields: ['symbol'] })
459
+ check('搜索显式 fields 指名符号 / 样式字段时不走跳过这些字段的文本缓存预筛',
460
+ styleFieldHits.points.length === 1 && styleFieldHits.points[0].field === 'symbol',
461
+ JSON.stringify(styleFieldHits.points.map(hit => ({ field: hit.field, value: hit.value }))))
462
+ styleFieldSearchView.destroy()
371
463
  const beforeSearch = JSON.stringify(view.getFilterResult().hiddenPoints.length)
372
464
  view.search({ keyword: '站', limit: 5 })
373
465
  check('搜索不改筛选(只找出来,不隐藏)', JSON.stringify(view.getFilterResult().hiddenPoints.length) === beforeSearch, `隐藏点 ${beforeSearch}`)
@@ -461,6 +553,23 @@
461
553
  check('异步搜索:通道整条命中(Worker 那边照样认得出它是哪条)', channelAsync.channels.length === 1 && hitShape(channelAsync) === hitShape(channelView.search({ keyword: '示例通道' })))
462
554
  const memberAsync = await channelView.searchAsync({ keyword: '第二回' })
463
555
  check('异步搜索:命中通道里的回线时带上第几回与它在 lines 里的下标', memberAsync.channels[0]?.memberIndex === 1 && memberAsync.channels[0]?.memberLineIndex === 1 && memberAsync.channels[0]?.memberLineId === 'c2', JSON.stringify({ index: memberAsync.channels[0]?.memberIndex, line: memberAsync.channels[0]?.memberLineId }))
556
+ const duplicateChannelView = createGisView(host(), {
557
+ points: [{ id: 'da', x: -100, y: 0 }, { id: 'db', x: 100, y: 0 }],
558
+ lines: [
559
+ { id: 'dup', from: 'da', to: 'db', name: '重复第一回' },
560
+ { id: 'dup', from: 'db', to: 'da', name: '重复第二回' }
561
+ ],
562
+ channels: [{ id: 'dup-ch', name: '重复成员', lineIds: ['dup', 'dup'] }],
563
+ searchWorker: { threshold: 1 },
564
+ showGrid: false
565
+ })
566
+ const duplicateSync = duplicateChannelView.search({ keyword: '重复第一回' })
567
+ const duplicateAsync = await duplicateChannelView.searchAsync({ keyword: '重复第一回' })
568
+ check('异步搜索:重复线路 id 与同步搜索同一口径(只认第一条,通道成员下标一致)',
569
+ duplicateAsync.channels[0]?.memberIndex === 0 && duplicateAsync.channels[0]?.memberLineIndex === 0 &&
570
+ duplicateAsync.channels[0]?.memberLineId === 'dup' && hitShape(duplicateAsync) === hitShape(duplicateSync),
571
+ JSON.stringify({ member: duplicateAsync.channels[0]?.memberIndex, line: duplicateAsync.channels[0]?.memberLineIndex, sync: hitShape(duplicateSync), async: hitShape(duplicateAsync) }))
572
+ duplicateChannelView.destroy()
464
573
  // 通道自己的字段改过(updateChannel):Worker 手里那份副本要整份作废重交,
465
574
  // 否则新名字搜不到、旧名字还搜得到 —— 与同步 search 的「结果逐字段一致」当场破掉
466
575
  channelView.updateChannel('ch-1', { name: '改过名的通道' })
@@ -499,7 +608,8 @@
499
608
  ...(index % 3 === 0 ? { effect: { period: 2, size: 8, color: '#ff0000' } } : null)
500
609
  }
501
610
  }
502
- const bigView = createGisView(host(640, 380), { points: bigPoints, lines: bigLines, quality: 'high', showGrid: false })
611
+ // 这一组核对发散渲染的槽位账,不测自动降级;显式关掉密集档,避免默认 auto 在 160 ms 后暂停动效干扰计数
612
+ const bigView = createGisView(host(640, 380), { points: bigPoints, lines: bigLines, quality: 'high', showGrid: false, denseMode: 'off' })
503
613
  const bigDeadline = Date.now() + 30000
504
614
  while (!bigView.isLayoutReady() && Date.now() < bigDeadline) {
505
615
  await settle(50)
@@ -533,7 +643,7 @@
533
643
  check('挪端点之后装饰跟着路径走(只动那一条线的装饰)', !!(movedAt && Math.abs(movedAt[0] - movedSpot[0]) < 0.5 && Math.abs(movedAt[1] - movedSpot[1]) < 0.5), movedAt ? `${movedIndexes.length} 个装饰挪了 → ${movedAt.join(',')}(期望 ${movedSpot.map(value => value.toFixed(1)).join(',')})` : `${movedIndexes.length} 个装饰挪了(期望 1 个)`)
534
644
  // 抬层:选一个排在很后面的点,它要压到装饰与所有 zIndex 层之上(发散渲染的前缀账接着用)
535
645
  const raisedItem = bigPoints[28000]
536
- bigView.select(raisedItem.id)
646
+ bigView.selectFeatures(raisedItem.id)
537
647
  await settle()
538
648
  const raisedScene = bigView.snapshotScene({ background: false })
539
649
  const raisedSpot = bigView.project(raisedItem.x, raisedItem.y)
@@ -294,14 +294,14 @@
294
294
  check('selectionKinds 只开 point:Ctrl 点击普通线路不改选中集', ckInfo().pointCount === 2 && ckInfo().lineCount === 0 && ckInfo().channelCount === 0, JSON.stringify({ points: ckInfo().pointCount, lines: ckInfo().lineCount, channels: ckInfo().channelCount }))
295
295
 
296
296
  ckView.updateOptions({ selectionKinds: ['line'] })
297
- ckView.select(null)
297
+ ckView.selectFeatures(null)
298
298
  await ckClick(0, 82, { ctrlKey: true })
299
299
  check('selectionKinds 只开 line:Ctrl 点击通道外线路生效', ckInfo().lineCount === 1 && ckInfo().channelCount === 0 && ckView.isSelected('ck-solo'), JSON.stringify({ lines: ckInfo().lineCount, channels: ckInfo().channelCount, ids: ckInfo().ids }))
300
300
  await ckClick(-130, 0, { ctrlKey: true })
301
301
  check('selectionKinds 只开 line:Ctrl 点击节点与通道回线不改选中集', ckInfo().lineCount === 1 && ckInfo().pointCount === 0 && ckInfo().channelCount === 0 && ckView.isSelected('ck-solo'), JSON.stringify({ points: ckInfo().pointCount, lines: ckInfo().lineCount, channels: ckInfo().channelCount }))
302
302
 
303
303
  ckView.updateOptions({ selectionKinds: ['channel'] })
304
- ckView.select(null)
304
+ ckView.selectFeatures(null)
305
305
  await ckClick(0, -82, { ctrlKey: true })
306
306
  check('selectionKinds 只开 channel:Ctrl 点击通道回线生效(节点与通道外线路不动)', ckInfo().lineCount === 1 && ckInfo().channelCount === 1 && ckInfo().pointCount === 0 && ckView.isSelected('ck-ch-line') && !ckView.isSelected('ck-solo'), JSON.stringify({ points: ckInfo().pointCount, lines: ckInfo().lineCount, channels: ckInfo().channelCount, ids: ckInfo().ids }))
307
307
  await ckClick(-130, 0, { ctrlKey: true })
@@ -309,11 +309,15 @@
309
309
  check('selectionKinds 只开 channel:Ctrl 点击节点 / 通道外线路都不改选中集', ckInfo().lineCount === 1 && ckInfo().channelCount === 1 && ckInfo().pointCount === 0 && ckView.isSelected('ck-ch-line'), JSON.stringify({ points: ckInfo().pointCount, lines: ckInfo().lineCount, channels: ckInfo().channelCount }))
310
310
 
311
311
  ckView.updateOptions({ selectionKinds: ['point', 'line', 'channel'] })
312
- ckView.select(null)
312
+ ckView.selectFeatures(null)
313
313
  await ckClick(-130, 0, { ctrlKey: true })
314
314
  await ckClick(0, 82, { ctrlKey: true })
315
315
  await ckClick(0, -82, { ctrlKey: true })
316
316
  check('selectionKinds 三类全开:一次 Ctrl 多选可混合累加节点 / 线路 / 通道', ckInfo().pointCount === 1 && ckInfo().lineCount === 2 && ckInfo().channelCount === 1 && ckView.isSelected('ck-a') && ckView.isSelected('ck-solo') && ckView.isSelected('ck-ch-line'), JSON.stringify({ points: ckInfo().pointCount, lines: ckInfo().lineCount, channels: ckInfo().channelCount, ids: ckInfo().ids }))
317
+ ckView.updateOptions({ selectionKinds: 'all' })
318
+ ckView.selectInBox({ x: 0, y: 0, width: ckBox.width, height: ckBox.height })
319
+ const ckBoxedInfo = ckView.getSelectionData()
320
+ check('selectInBox:线路框选与通道摊开沿用同一份判定(整框一次收下节点 / 独立线路 / 通道回线)', ckBoxedInfo.count === 4 && ckBoxedInfo.pointCount === 2 && ckBoxedInfo.lineCount === 2 && ckBoxedInfo.channelCount === 1, JSON.stringify({ items: ckBoxedInfo.count, points: ckBoxedInfo.pointCount, lines: ckBoxedInfo.lineCount, channels: ckBoxedInfo.channelCount }))
317
321
  ckView.destroy()
318
322
  ckHost.remove()
319
323
 
@@ -350,18 +354,21 @@
350
354
  check('按通道写法选上整条通道:两条回线都进来', dzView.getSelectionData().count === 2 && dzView.getSelectionData().channelCount === 1, dzIds())
351
355
  dzView.deselect({ type: 'channel', channelId: 'dz-ch' })
352
356
  check('deselect(通道写法):整条通道的两条回线一起取消', dzView.getSelectionData().count === 0 && dzView.getSelections().length === 0, dzIds())
353
- dzView.select('dz-a').select('dz-solo', { mode: 'add' })
354
- check('select(refs, options) 与 selectFeatures 同一份 options:链式 add 把两个都选上', dzView.getSelectionData().count === 2 && dzView.isSelected('dz-a') && dzView.isSelected('dz-solo'), dzIds())
355
- dzView.select('dz-a', { mode: 'remove' })
356
- check('select(refs, { mode: \'remove\' }):与 deselect(refs) 同一件事', dzView.getSelectionData().count === 1 && !dzView.isSelected('dz-a') && dzView.isSelected('dz-solo'), dzIds())
357
+ dzView.selectFeatures('dz-a').selectFeatures('dz-solo', { mode: 'add' })
358
+ check('selectFeatures(refs, options) 的链式 add 把两个都选上', dzView.getSelectionData().count === 2 && dzView.isSelected('dz-a') && dzView.isSelected('dz-solo'), dzIds())
359
+ dzView.selectFeatures('dz-a', { mode: 'remove' })
360
+ check('selectFeatures(refs, { mode: \'remove\' }):与 deselect(refs) 同一件事', dzView.getSelectionData().count === 1 && !dzView.isSelected('dz-a') && dzView.isSelected('dz-solo'), dzIds())
357
361
  const dzChangesBeforeEmpty = dzChanges.length
358
362
  dzView.deselect([])
359
363
  check('deselect(空数组):没有目标,什么都不做(也不发选中集变更)', dzView.getSelectionData().count === 1 && dzChanges.length === dzChangesBeforeEmpty, `${dzIds()} / 新增 ${dzChanges.length - dzChangesBeforeEmpty} 条变更`)
360
364
  dzChanges.length = 0
361
365
  dzView.deselect()
362
- check('deselect() 不传目标 = 取消全部(= select(null))', dzView.getSelectionData().count === 0 && dzChanges.length === 1 && dzChanges[0].action?.type === 'clearSelection' && dzChanges[0].cause === 'api', `${dzIds()} / ${dzChanges.length} 条变更 / ${dzChanges[0]?.action?.type}`)
363
- const dzChained = dzView.select('dz-a')
364
- check('取消全部的老写法 select(null) 也清空,接口都返回实例本身(可以接着链式调)', dzChained === dzView && dzView.select(null) === dzView && dzView.getSelectionData().count === 0, dzIds())
366
+ check('deselect() 不传目标 = 取消全部(= selectFeatures(null))', dzView.getSelectionData().count === 0 && dzChanges.length === 1 && dzChanges[0].action?.type === 'clearSelection' && dzChanges[0].cause === 'api', `${dzIds()} / ${dzChanges.length} 条变更 / ${dzChanges[0]?.action?.type}`)
367
+ const dzChained = dzView.selectFeatures('dz-a')
368
+ check('selectFeatures(null) 也清空,接口都返回实例本身(可以接着链式调)', dzChained === dzView && dzView.selectFeatures(null) === dzView && dzView.getSelectionData().count === 0, dzIds())
369
+ check('重复入口已收口:select / updatePoints / updateLines / updateChannels 都不在实例上',
370
+ dzView.select === undefined && dzView.updatePoints === undefined && dzView.updateLines === undefined && dzView.updateChannels === undefined,
371
+ JSON.stringify({ select: typeof dzView.select, updatePoints: typeof dzView.updatePoints, updateLines: typeof dzView.updateLines, updateChannels: typeof dzView.updateChannels }))
365
372
  dzView.destroy()
366
373
  dzHost.remove()
367
374
 
@@ -419,20 +426,20 @@
419
426
  // 状态样式是后配的:空闲预热 200ms 之后才排(见「状态样式预热」),这一刻新贴图一定还没烤出来 ——
420
427
  // 节点必须拿上一份配色的贴图顶着画,而不是整个不画
421
428
  iconView.updateOptions({ pointSelectStyle: { color: '#e01f1f' } })
422
- iconView.select('icon-a')
429
+ iconView.selectFeatures('icon-a')
423
430
  const heldOn = pixelAt(await iconFrame(), iconAt(0))
424
431
  check('第一次选中 SVG 符号:新贴图还没烤好时先顶上一种配色(不会闪没)', isBlue(heldOn), heldOn.join(','))
425
432
  const settledIcon = await iconFrame(260)
426
433
  check('第一次选中 SVG 符号:烤好之后换成选中样式色(红)', isRed(pixelAt(settledIcon, iconAt(0))), pixelAt(settledIcon, iconAt(0)).join(','))
427
434
  check('第一次选中 SVG 符号:没选中的那一个不受影响(还是自己的配色)', isBlue(pixelAt(settledIcon, iconAt(120))), pixelAt(settledIcon, iconAt(120)).join(','))
428
- iconView.select(null)
435
+ iconView.selectFeatures(null)
429
436
  // 空闲预热:全局状态样式 × 实际用到的贴图组合提前烤好,第一次选中 / 悬停不给烘焙时间也是状态色
430
437
  let prewarmedSelect = false
431
438
  for (let attempt = 0; attempt < 60 && !prewarmedSelect; attempt += 1) {
432
- iconView.select('icon-a')
439
+ iconView.selectFeatures('icon-a')
433
440
  prewarmedSelect = isRed(pixelAt(await iconFrame(), iconAt(0)))
434
441
  if (!prewarmedSelect) {
435
- iconView.select(null)
442
+ iconView.selectFeatures(null)
436
443
  await settle(60)
437
444
  }
438
445
  }
@@ -441,7 +448,7 @@
441
448
  check('getSymbolCombos:状态样式会用到的组合也收进来(喂回 prebake.combos 不重不漏)',
442
449
  iconCombos.some(combo => combo.color === '#e01f1f' && combo.symbol === svgIcon) && iconCombos.some(combo => combo.color === '#1f9d3a' && combo.symbol === svgIcon),
443
450
  JSON.stringify(iconCombos.map(combo => combo.color)))
444
- iconView.select(null)
451
+ iconView.selectFeatures(null)
445
452
  iconView.hoverAt(iconBox.left + iconAt(0)[0], iconBox.top + iconAt(0)[1])
446
453
  const hoveredIcon = pixelAt(await iconFrame(), iconAt(0))
447
454
  check('第一次悬停 SVG 符号:不给烘焙时间也是悬停样式色(绿)', isGreen(hoveredIcon), hoveredIcon.join(','))
@@ -534,7 +541,7 @@
534
541
  }
535
542
  }
536
543
  check('同配方不同尺寸:底色贴图到位后两个节点都是底色描边', strokeColorAt(dualBase, dualSmall) === 'base' && strokeColorAt(dualBase, dualBig) === 'base', `${strokeColorAt(dualBase, dualSmall)} / ${strokeColorAt(dualBase, dualBig)}`)
537
- dualView.select('dual-a')
544
+ dualView.selectFeatures('dual-a')
538
545
  await settle(30)
539
546
  // 小节点的选中贴图还在烤(占位贴图在位):这一刻把同配方、另一个尺寸的大节点也选上
540
547
  dualView.selectFeatures(['dual-a', 'dual-b'])
@@ -559,4 +566,4 @@
559
566
  document.title = results.some(text => text.startsWith('FAIL') || text.startsWith('ERROR')) ? 'FAIL' : 'PASS'
560
567
  </script>
561
568
  </body>
562
- </html>
569
+ </html>
@@ -144,7 +144,7 @@ export const createFullView = (target, options = {}) => {
144
144
  fitView: () => view.fitView(),
145
145
  zoomBy: factor => view.zoomBy(factor),
146
146
  setCamera: camera => view.setCamera(camera),
147
- select: input => view.select(input),
147
+ select: input => view.selectFeatures(input),
148
148
  destroy: () => view.destroy()
149
149
  }
150
150
  }
@@ -33,6 +33,8 @@
33
33
 
34
34
  服务端必须校验请求头与 body:`Idempotency-Key === opId`、`If-Match === "v" + baseVersion`。不一致返回 `400 BAD_REQUEST`,不要猜以哪一个为准。
35
35
 
36
+ 仓库里的假后端已经把这三条落实成可执行契约:缺头、头和 body 不一致、`baseVersion` 不是有限数字都返回 `400 BAD_REQUEST`;同一个 `opId` 换了请求体会返回 `409 IDEMPOTENCY_CONFLICT`,且版本不前进。`docs/steps/selftest-demos.html` 直接对这四种情况做断言,接入真实网关时可以拿它当最小联调用例。
37
+
36
38
  ### 2.2 公共 body 字段
37
39
 
38
40
  | 字段 | 类型 | 必填 | 说明 |
@@ -71,8 +73,8 @@ HTTP `200 OK`:
71
73
  | `traceId` | `string` | 全链路追踪 id |
72
74
  | `serverTime` | ISO 8601 | 服务端处理完成时间 |
73
75
  | `data.operation` | `string` | 实际执行的操作,例如 `copy.batch` |
74
- | `data.version` | `number` | 成功落库后的新版本;客户端下一次请求用它作 `baseVersion` |
75
- | `data.idempotent` | `boolean` | `true` 表示命中幂等缓存,没有第二次写库 |
76
+ | `data.version` | `number` | 第一次落库后的版本;客户端下一次请求用它作 `baseVersion`。幂等回放仍是这个版本,可能低于服务端当前版本 |
77
+ | `data.idempotent` | `boolean` | `true` 表示命中幂等缓存,没有第二次写库;资源映射按第一次响应,但不要把本地基线退回到 `data.version` |
76
78
  | `data.appliedAt` | ISO 8601 | 第一次真正落库的时间;回放时不变 |
77
79
  | `data.transactionId` | `string` | 本次事务 / 版本记录 id,可用于审计 |
78
80
  | `data.result` | `object` | 各接口自己的资源级结果,见后文 |
@@ -323,7 +325,7 @@ HTTP `409 Conflict` 示例:
323
325
  "channelId": "channel-a",
324
326
  "action": { "type": "splitGroups", "groupIds": ["channel-a", "channel-a::2"] },
325
327
  "groups": [
326
- { "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] } },
327
329
  { "id": "channel-a::2", "lineIds": ["line-2"], "path": [] }
328
330
  ],
329
331
  "baseVersion": 12,
@@ -335,10 +337,11 @@ HTTP `409 Conflict` 示例:
335
337
  | --- | --- | --- |
336
338
  | `channelId` | `string \| number` | 通道 id,必须与路径参数一致 |
337
339
  | `action` | `object` | 审计用动作:`splitGroups` / `mergeGroups` / `moveLine` / `addGroup` / `setGroups`;服务端不应依赖它恢复数据 |
338
- | `groups` | `Array<{ id, lineIds, path }>` | **整份**分组声明;顺序就是后续回线排布顺序 |
340
+ | `groups` | `Array<{ id, lineIds, path, waypointOffsets? }>` | **整份**分组声明;顺序就是后续回线排布顺序 |
339
341
  | `groups[].id` | `string \| number` | 分组稳定 id |
340
342
  | `groups[].lineIds` | `Array<string \| number>` | 这一组包含的回线 id;同一条回线在整份声明里只能出现一次 |
341
343
  | `groups[].path` | `[number, number][]` | 分组公共骨架的中间拐点;`[]` 是合法的「直连」,不能当成没提交 |
344
+ | `groups[].waypointOffsets` | `Record<string, [number, number] \| { x, y }>` | `vertex` 拖动的分组刚性偏移:键是含首尾端点的骨架顶点序号(只认中间拐点),值是组内所有回线共用的世界坐标增量;省略或空对象表示不带偏移 |
342
345
 
343
346
  `groups` 至少保留一组。若业务允许空通道,建议另走 `DELETE /gis/channels/{channelId}`,不要把空分组声明当作删除通道的隐式约定。
344
347
 
@@ -348,7 +351,7 @@ HTTP `409 Conflict` 示例:
348
351
  {
349
352
  "channelId": "channel-a",
350
353
  "groups": [
351
- { "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] } },
352
355
  { "id": "channel-a::2", "lineIds": ["line-2"], "path": [] }
353
356
  ],
354
357
  "groupCount": 2,
@@ -356,11 +359,11 @@ HTTP `409 Conflict` 示例:
356
359
  }
357
360
  ```
358
361
 
359
- 服务端应校验 channel 存在、`lineIds` 全部属于这条通道且不重复、每个 `path` 点都是有限数值;返回落库后的整份快照,前端用 `groups` 覆盖本地对应通道。
362
+ 服务端应校验 channel 存在、`lineIds` 全部属于这条通道且不重复、每个 `path` 点都是有限数值,以及 `waypointOffsets` 的键都落在中间拐点、值都是有限增量;返回落库后的整份快照,前端用 `groups` 覆盖本地对应通道。
360
363
 
361
364
  ### 5.2 `PATCH /gis/channels/{channelId}/groups/waypoints`
362
365
 
363
- 覆盖一条通道里某一组的**公共骨架**。`path` 只包含中间拐点,两端由通道绑定的节点补齐。拖动过程中 `phase: 'start' / 'move'` 不发请求,只在 `add` / `remove` / `end` 发一条。
366
+ 覆盖一条通道里某一组的**公共骨架与分组刚性偏移**。`path` 只包含中间拐点,两端由通道绑定的节点补齐;`waypointOffsets` 是 `channelWaypointDragMode: 'vertex'` 拖出来的按骨架序号偏移。拖动过程中 `phase: 'start' / 'move'` 不发请求,只在 `add` / `remove` / `end` 发一条。
364
367
 
365
368
  请求体:
366
369
 
@@ -370,6 +373,7 @@ HTTP `409 Conflict` 示例:
370
373
  "channelId": "channel-a",
371
374
  "groupId": "channel-a::2",
372
375
  "path": [[18.0, 10.2], [20.5, 11.1]],
376
+ "waypointOffsets": { "1": [1.2, -0.8] },
373
377
  "phase": "end",
374
378
  "scope": "group",
375
379
  "baseVersion": 12,
@@ -382,8 +386,9 @@ HTTP `409 Conflict` 示例:
382
386
  | `channelId` | `string \| number` | 通道 id |
383
387
  | `groupId` | `string \| number` | 分组 id |
384
388
  | `path` | `[number, number][]` | 整份公共骨架的中间拐点;空数组表示直连 |
389
+ | `waypointOffsets` | `Record<string, [number, number] \| { x, y }>` | 分组刚性偏移:键按含首尾端点的骨架序号算,只接受中间拐点;省略或空对象表示普通平行骨架 |
385
390
  | `phase` | `'add' \| 'remove' \| 'end'` | 实际落地阶段;`select` / `start` / `move` 不应发请求 |
386
- | `scope` | `'group'` | 公共骨架的存储范围;组内每条回线的偏移拐点是由它派生的,不单独落库 |
391
+ | `scope` | `'group'` | 公共骨架与分组刚性偏移的存储范围;组内每条回线的车道偏移是由它派生的,不单独落库 |
387
392
 
388
393
  响应 `data.result`:
389
394
 
@@ -391,10 +396,13 @@ HTTP `409 Conflict` 示例:
391
396
  {
392
397
  "target": { "type": "channelGroup", "channelId": "channel-a", "groupId": "channel-a::2", "scope": "group" },
393
398
  "path": [[18.0, 10.2], [20.5, 11.1]],
399
+ "waypointOffsets": { "1": [1.2, -0.8] },
394
400
  "pointCount": 2
395
401
  }
396
402
  ```
397
403
 
404
+ 服务端必须把 `path` 与 `waypointOffsets` 一起落库并一起返回:只收 `path` 会丢掉非平行拖动结果,刷新后分组又回到旧骨架。
405
+
398
406
  ### 5.3 `PATCH /gis/lines/{lineId}/path`
399
407
 
400
408
  覆盖独立线路的整条折线。与通道分组不同,这里 `path` **包含首尾端点**,因为独立线路不依赖通道节点补端点。
@@ -428,7 +436,7 @@ HTTP `409 Conflict` 示例:
428
436
 
429
437
  1. 组件先在本地生成新节点 / 线路 / 通道并落地,拿到 `revert()` 或整批回滚入口。
430
438
  2. 从复制载荷组 `points / lines / channels / lineChannels`,连同 `baseVersion`、`opId` 提交。
431
- 3. 200:接受 `data.version` 作为新基线;按 `idMap` 对账资源 id;清掉失败重试状态。
439
+ 3. 200:按 `data.version` 推进本地基线(用 `Math.max(已知版本, data.version)`,幂等回放不能把基线往回退);按 `idMap` 对账资源 id;清掉失败重试状态。
432
440
  4. 网络 / 500:本地结果保留,用**同一个 opId** 原样重试。
433
441
  5. 409:先拉最新 / 按业务 rebase;确认后生成**新的 opId** 再提交。
434
442
  6. 永久 4xx:调用复制载荷的 `revert()`,把整批本地结果收回。
@@ -444,12 +452,152 @@ HTTP `409 Conflict` 示例:
444
452
  ### 6.3 通道分组 / 拐点
445
453
 
446
454
  1. 只在真实落地的 `add` / `remove` / `end` 发请求;`start` / `move` 的中间态不发。
447
- 2. 请求体永远是操作完成后的**整份** `groups` 或 `path`,从 `view.getChannelGroups()` / `view.getChannelGroupWaypoints()` 重新读。
455
+ 2. 请求体永远是操作完成后的**整份** `groups`,或成对的 `path` + `waypointOffsets`,从 `view.getChannelGroups()` / `view.getChannelGroupWaypoints()` 重新读。
448
456
  3. 同一条通道的写请求串行发送;拖动期间只保留最后一个 `end`。
449
- 4. 成功用响应里的 `groups` / `path` 覆盖本地快照;如果服务端做了归一化,以服务端为准。
457
+ 4. 成功用响应里的 `groups` / `path` / `waypointOffsets` 覆盖本地快照;如果服务端做了归一化,以服务端为准。
450
458
  5. 409 优先使用错误包里的 `data.current` 做局部恢复;没有 `current` 时先拉最新文档。
451
459
 
452
- ### 6.4 重试与版本
460
+ ### 6.4 前端落地映射:拿到 `data.result` 之后
461
+
462
+ 响应字段不要从顶层猜:成功统一读 `response.body.data`,资源级结果在 `data.result`;失败统一读 `response.body.data` 里的 `currentVersion` / `current` / `conflict`。
463
+
464
+ ```js
465
+ const payload = response.body // fetch(...).then(r => r.json())
466
+ if (payload.code !== 'OK') {
467
+ throw new Error(`${payload.code}: ${payload.message}`)
468
+ }
469
+ // 幂等回放的 version 是第一次落库的版本,可能低于本地已知版本;只前进、不回退。
470
+ const version = Math.max(knownVersion, payload.data.version)
471
+ knownVersion = version // 下一次请求的 baseVersion
472
+ const result = payload.data.result // 各接口自己的资源结果
473
+ const replayed = payload.data.idempotent // true = 幂等回放,不要重复应用资源
474
+ ```
475
+
476
+ #### 复制:按 `idMap` 改写引用,按 `counts` 对账
477
+
478
+ ```js
479
+ const { created, counts, idMap } = result
480
+ const pointMap = new Map(idMap.points.map(item => [String(item.clientId), item.id]))
481
+ const lineMap = new Map(idMap.lines.map(item => [String(item.clientId), item.id]))
482
+ const channelMap = new Map(idMap.channels.map(item => [String(item.clientId), item.id]))
483
+
484
+ // 服务端保留客户端 id 时映射是恒等的;只有 id 真的变了才需要改写本地引用。
485
+ const nextPoints = request.body.points.map(point => ({
486
+ ...point,
487
+ id: pointMap.get(String(point.id)) ?? point.id
488
+ }))
489
+ const nextLines = request.body.lines.map(line => ({
490
+ ...line,
491
+ id: lineMap.get(String(line.id)) ?? line.id,
492
+ from: pointMap.get(String(line.from)) ?? line.from,
493
+ to: pointMap.get(String(line.to)) ?? line.to
494
+ }))
495
+ const nextChannels = request.body.channels.map(channel => {
496
+ const id = channelMap.get(String(channel.id)) ?? channel.id
497
+ const groups = (channel.groups ?? []).map(group => ({
498
+ ...group,
499
+ id: group.id === channel.id ? id : group.id,
500
+ lineIds: group.lineIds.map(lineId => lineMap.get(String(lineId)) ?? lineId)
501
+ }))
502
+ return {
503
+ ...channel,
504
+ id,
505
+ from: pointMap.get(String(channel.from)) ?? channel.from,
506
+ to: pointMap.get(String(channel.to)) ?? channel.to,
507
+ lineIds: (channel.lineIds ?? groups.flatMap(group => group.lineIds)).map(lineId => lineMap.get(String(lineId)) ?? lineId),
508
+ groups
509
+ }
510
+ })
511
+
512
+ if (counts.points !== nextPoints.length || counts.lines !== nextLines.length || counts.channels !== nextChannels.length) {
513
+ throw new Error('服务端 counts 与本地复制结果不一致:先拉最新文档再 rebase,不要继续编辑')
514
+ }
515
+ view.updateData(nextPoints, nextLines, nextChannels)
516
+ ```
517
+
518
+ `created` 是服务端实际落库的资源摘要,适合做列表刷新 / 审计;`counts` 用来和请求体的 `points / lines / channels` 长度对账;`idMap` 是唯一可靠的 id 改写来源。幂等回放时 `replayed === true`,资源已经存在,**不要再插入一遍**,只接受 `version` 与这份映射。
519
+
520
+ #### 合并:按响应 id 集合核对本地已合并结果
521
+
522
+ ```js
523
+ const { retainedPointIds, removedPointIds, removedLineIds, removedChannelIds } = result
524
+ // 本地在确认合并时已经落地;这里做的是对账,不是把整笔事务再执行一遍。
525
+ const ids = new Set(view.getData().points.map(point => String(point.id)))
526
+ const missingRetained = retainedPointIds.filter(id => !ids.has(String(id)))
527
+ if (missingRetained.length) {
528
+ throw new Error(`服务端保留了本地不存在的节点:${missingRetained.join(', ')}(拉最新文档)`)
529
+ }
530
+ // 正常成功路径什么都不用删;只有重放 / 重连后的补偿分支才按服务端事务集合清理残留。
531
+ if (compensating) {
532
+ view.removePoints(removedPointIds)
533
+ view.removeLines(removedLineIds)
534
+ for (const id of removedChannelIds) {
535
+ view.removeChannel(id)
536
+ }
537
+ }
538
+ ```
539
+
540
+ 正常成功路径不要主动再删一次:`mergeSelectedPoints` / 拖拽确认已经在本地完成合并,响应只用于确认 `retainedPointIds` 仍存在、被删集合确实不在。只有补偿 / 重连场景才按 `removedPointIds` / `removedLineIds` / `removedChannelIds` 清理残留;顺序是先节点、再线路、最后通道,组件会顺手收拾绑定关系。
541
+
542
+ #### 分组:用响应 `groups` 整份覆盖
543
+
544
+ ```js
545
+ // groups 是服务端落库后的完整快照,直接覆盖这条通道;path: [] 必须原样保留为直连。
546
+ view.updateChannel(result.channelId, { groups: result.groups })
547
+ ```
548
+
549
+ 不要按本地 `action` 再推导一次「哪一组被挪空」;服务端可能重排分组 id、补默认骨架或修正回线顺序,`result.groups` 才是最终声明。`groupCount` / `lineCount` 与 `groups` 对不上时视为响应损坏,先拉最新文档。
550
+
551
+ #### 拐点:按 `target` 精确覆盖
552
+
553
+ ```js
554
+ if (result.target.type === 'channelGroup') {
555
+ // 通道分组的 path 只含中间拐点;group 写法精确指向这一组,不会误改第一组。
556
+ view.updateChannel(result.target.channelId, {
557
+ group: { id: result.target.groupId, path: result.path }
558
+ })
559
+ }
560
+ else if (result.target.type === 'line') {
561
+ // 独立线路的 path 含首尾端点,正好是 updateLine({ path }) 的写法。
562
+ view.updateLine(result.target.lineId, { path: result.path })
563
+ }
564
+ ```
565
+
566
+ `path: []` 是合法结果(直连),不要用 `if (result.path)` 判断后跳过。通道分组和独立线路的 `path` 口径不同:前者只写中间拐点,后者是整条折线;把两者混用会把首尾端点写进分组骨架。
567
+
568
+ #### 409:先局部恢复,再用新版本 / 新 `opId` rebase
569
+
570
+ ```js
571
+ const failure = response.body.data
572
+ const current = failure.current
573
+ if (current?.groups) {
574
+ view.updateChannel(current.channelId, { groups: current.groups })
575
+ }
576
+ else if (current?.target?.type === 'channelGroup') {
577
+ view.updateChannel(current.target.channelId, {
578
+ group: { id: current.target.groupId, path: current.path }
579
+ })
580
+ }
581
+ else if (current?.target?.type === 'line') {
582
+ view.updateLine(current.target.lineId, { path: current.path })
583
+ }
584
+ else {
585
+ // 没有资源快照:按业务拉最新文档,整份灌回。
586
+ view.importData(await fetchLatestDocument())
587
+ }
588
+
589
+ const opId = crypto.randomUUID() // rebase 是一次新提交,不能复用旧 opId
590
+ const nextBody = {
591
+ ...rebuildRequestFromCurrentView(), // 从恢复后的视图重新读整份 groups / path + waypointOffsets
592
+ baseVersion: failure.currentVersion,
593
+ opId
594
+ }
595
+ // 请求头同步换:Idempotency-Key = opId,If-Match = `"v${failure.currentVersion}"`
596
+ ```
597
+
598
+ `data.current` 只保证是**这一笔操作涉及资源**的最新快照(通道分组 / 拐点能直接给;复制 / 合并通常没有),不要把它当成整份文档。`VERSION_CONFLICT` 可以 rebase;`IDEMPOTENCY_CONFLICT` 不可以,它是客户端把同一个 `opId` 用在了不同请求体上,必须换新 `opId` 重新组请求。
599
+
600
+ ### 6.5 重试与版本
453
601
 
454
602
  | 场景 | opId | baseVersion | 本地数据 |
455
603
  | --- | --- | --- | --- |
@@ -461,7 +609,7 @@ HTTP `409 Conflict` 示例:
461
609
 
462
610
  重试要有退避(例如 300ms / 800ms / 2s)和上限。幂等缓存的保留时间建议至少覆盖客户端最大重试窗口,例如 24 小时;跨天的长事务另存审计记录。
463
611
 
464
- ## 7. 后端落库清单
612
+ ## 8. 后端落库清单
465
613
 
466
614
  - **幂等表**:`(tenant_id, actor_id, op_id)` 唯一;保存请求哈希、HTTP 状态、响应 JSON、应用后的版本、创建时间。回放直接返回第一次响应并把 `data.idempotent` 改成 `true`。
467
615
  - **版本锁**:事务开始时 `SELECT ... FOR UPDATE` 锁定文档版本;或 `UPDATE document SET version = version + 1 WHERE id = ? AND version = ?`,影响行数为 0 就返回 409。
@@ -469,23 +617,23 @@ HTTP `409 Conflict` 示例:
469
617
  - **事务边界**:成功应用与幂等记录、版本推进在同一个数据库事务里提交;否则会出现「数据已写但幂等表没写」的重复应用风险。
470
618
  - **稳定 id**:所有分组、线路、通道都优先按 id 对账,不要用数组下标当业务主键。客户端生成 id 时,后端要校验唯一性。
471
619
  - **空 `path`**:是合法值,表示直连。校验时只检查「是否是数组 + 坐标是否有限」,不要用 truthy 判断吞掉空数组。
472
- - **响应快照**:分组 / 拐点接口返回落库后的最新 `groups` / `path`;复制返回 `idMap`;合并返回事务实际涉及的 id 集合。
620
+ - **响应快照**:分组 / 拐点接口返回落库后的最新 `groups` / `path` / `waypointOffsets`;复制返回 `idMap`;合并返回事务实际涉及的 id 集合。
473
621
  - **审计**:记录 `requestId` / `traceId` / `opId` / `transactionId` / `actor` / 资源 id,日志里不要只记一个 URL。
474
622
  - **顺序**:同文档的写请求按版本串行;客户端也应对同一条通道 / 同一份文档做串行化,避免多个成功响应相互覆盖。
475
623
 
476
- ## 8. Demo 对照
624
+ ## 9. Demo 对照
477
625
 
478
626
  | 演示页 | 操作 | 请求 | 响应里重点看 |
479
627
  | --- | --- | --- | --- |
480
628
  | 批量复制与合并 | 框选后复制 | `POST /gis/copies/batch` | `transactionId`、`created`、`counts`、`idMap` |
481
629
  | 批量复制与合并 | 拖拽 / 选中后合并 | `POST /gis/merges` | `retainedPointIds`、`removedPointIds`、删线 / 删通道 id |
482
630
  | 通道与数据 | 拆 / 并 / 挪回线 / 新建组 | `PATCH /gis/channels/{id}/groups` | `groups` 最新快照、`groupCount`、`lineCount` |
483
- | 通道与数据 | 通道分组加 / 删 / 拖拐点 | `PATCH /gis/channels/{id}/groups/waypoints` | `target`、`path`、`pointCount` |
631
+ | 通道与数据 | 通道分组加 / 删 / 拖拐点 | `PATCH /gis/channels/{id}/groups/waypoints` | `target`、`path`、`waypointOffsets`、`pointCount` |
484
632
  | 通道与数据 | 独立线路拐点 | `PATCH /gis/lines/{id}/path` | `target`、`path`、`pointCount` |
485
633
 
486
634
  两个演示的面板都提供:正常 200、网络失败、服务端 500、版本冲突 409;“重试最近失败请求”保留原 `opId`,「重放最近成功请求」用于验证幂等回放不推进版本。请求预览区会同时打印请求头、body、HTTP 状态与完整响应包。
487
635
 
488
- ## 9. 最小验收用例
636
+ ## 10. 最小验收用例
489
637
 
490
638
  1. 同一复制请求连续提交两次:第二次 HTTP 200、`data.idempotent: true`、版本不再增加、资源不重复。
491
639
  2. 复制请求网络失败后原样重试:服务端只落一次,返回与第一次一致的 `transactionId` / 资源 id。
@@ -494,3 +642,5 @@ HTTP `409 Conflict` 示例:
494
642
  5. 删除分组最后一个拐点:请求 `path: []` 被接受,服务端落库为直连。
495
643
  6. 合并事务中任何一条线路校验失败:节点、线路、通道全部回滚,版本不前进。
496
644
  7. 同一文档两笔并发写:只有一笔成功推进版本,另一笔得到 409,不出现静默覆盖。
645
+ 8. 请求头与 body 不一致:`Idempotency-Key` 不等于 `body.opId`,或 `If-Match` 不等于 `"v" + baseVersion`,返回 `400 BAD_REQUEST`,版本不前进。
646
+ 9. 同一个 `opId` 换一份请求体:返回 `409 IDEMPOTENCY_CONFLICT`,不返回第一次的成功结果,版本不前进。
@@ -14,7 +14,7 @@
14
14
  | [拐点装饰与样式](format/03-%E6%8B%90%E7%82%B9%E8%A3%85%E9%A5%B0%E4%B8%8E%E6%A0%B7%E5%BC%8F.md) | waypoints / decorations / 偏移与颜色样式 |
15
15
  | [通道与标签](format/04-%E9%80%9A%E9%81%93%E4%B8%8E%E6%A0%87%E7%AD%BE.md) | channels、分组与 parallel_groups、labels |
16
16
  | [条件语法](format/05-%E6%9D%A1%E4%BB%B6%E8%AF%AD%E6%B3%95.md) | 写函数 / `{ field, op, value }` / `{ 字段: 值 }` 三种写法 |
17
- | [导入导出与标准文档](format/06-%E5%AF%BC%E5%85%A5%E5%AF%BC%E5%87%BA%E4%B8%8E%E6%A0%87%E5%87%86%E6%96%87%E6%A1%A3.md) | exportData 的四种格式与接收 == 导出的 full |
17
+ | [导入导出与标准文档](format/06-%E5%AF%BC%E5%85%A5%E5%AF%BC%E5%87%BA%E4%B8%8E%E6%A0%87%E5%87%86%E6%96%87%E6%A1%A3.md) | exportData 的内置五档、自定义导出格式与接收 == 导出的 full |
18
18
  | [事件载荷与示例数据](format/07-%E4%BA%8B%E4%BB%B6%E8%BD%BD%E8%8D%B7%E4%B8%8E%E7%A4%BA%E4%BE%8B%E6%95%B0%E6%8D%AE.md) | 事件载荷速查 + 示例数据 + 性能与兼容 |
19
19
 
20
20
  ## 快速跳转