gis-view-webgl2 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/index.cjs +1 -1
  2. package/dist/index.d.ts +140 -60
  3. package/dist/index.js +1 -1
  4. package/docs/AI/345/257/271/350/257/235/346/261/207/346/200/273.md +73 -5
  5. package/docs/GisView.md +49 -49
  6. package/docs/ai/06-/344/274/232/350/257/235/347/264/242/345/274/225/344/270/216/345/244/215/346/237/245/346/226/271/346/263/225.md +87 -16
  7. package/docs/demos/ApiCoverageDemo.vue +1 -5
  8. package/docs/demos/BackendSyncDemo.vue +1 -2
  9. package/docs/demos/BackgroundDemo.vue +68 -1
  10. package/docs/demos/BasicDemo.vue +76 -76
  11. package/docs/demos/BatchAddDemo.vue +2 -1
  12. package/docs/demos/ChannelDemo.vue +1 -3
  13. package/docs/demos/ClusterDemo.vue +48 -1
  14. package/docs/demos/CopyMergeDemo.vue +899 -904
  15. package/docs/demos/DemoShell.vue +336 -336
  16. package/docs/demos/EventDemo.vue +300 -300
  17. package/docs/demos/ExtensionDemo.vue +200 -200
  18. package/docs/demos/GisViewDemo.vue +64 -64
  19. package/docs/demos/LabelDemo.vue +489 -489
  20. package/docs/demos/StyleDemo.vue +67 -67
  21. package/docs/demos/api-index.js +24 -29
  22. package/docs/demos/demos.js +92 -92
  23. package/docs/demos/gis-view-demo/useGisViewDemo.js +19 -14
  24. package/docs/demos/shared/demoData.js +107 -107
  25. package/docs/demos/shared/demoStyle.css +249 -249
  26. package/docs/format/02-/350/212/202/347/202/271/344/270/216/347/272/277/350/267/257.md +3 -3
  27. package/docs/format/03-/346/213/220/347/202/271/350/243/205/351/245/260/344/270/216/346/240/267/345/274/217.md +3 -0
  28. package/docs/from0/01-/345/205/210/346/212/212/345/234/260/345/233/276/346/203/263/346/270/205/346/245/232.md +8 -7
  29. package/docs/index.html +3 -3
  30. package/docs/internals/04-/347/233/270/346/234/272/345/235/220/346/240/207/344/270/216/345/203/217/347/264/240.md +6 -4
  31. package/docs/internals/14-/350/203/214/346/231/257/345/261/202.md +2 -2
  32. package/docs/manual/02-/345/235/220/346/240/207/344/270/216/346/225/260/346/215/256.md +36 -16
  33. package/docs/manual/03-/351/205/215/347/275/256/351/241/271.md +38 -84
  34. package/docs/manual/04-/344/272/213/344/273/266/344/270/216/345/233/236/350/260/203.md +36 -19
  35. package/docs/manual/06-/346/225/260/346/215/256/344/270/216/345/234/272/346/231/257/346/233/264/346/226/260.md +1 -1
  36. package/docs/manual/07-/346/214/207/351/222/210/344/270/216/350/246/201/347/264/240/347/274/226/350/276/221.md +7 -7
  37. package/docs/manual/08-/351/200/211/344/270/255/344/270/216/346/213/220/347/202/271.md +15 -15
  38. package/docs/manual/10-/351/200/232/351/201/223/347/274/226/350/276/221/344/270/216/346/226/260/345/242/236.md +1 -1
  39. package/docs/manual/12-/346/240/207/347/255/276/344/270/216/350/243/205/351/245/260.md +6 -0
  40. package/docs/manual/14-/350/247/206/345/233/276/344/270/216/347/255/233/351/200/211.md +1 -1
  41. package/docs/manual/15-/345/257/274/345/207/272/344/270/216/351/233/206/346/210/220.md +2 -0
  42. package/docs/manual/16-/345/256/236/344/276/213/346/226/271/346/263/225/346/200/273/350/241/250.md +10 -8
  43. package/docs/manual/17-/345/217/202/346/225/260/351/200/237/346/237/245.md +11 -7
  44. package/docs/manual/18-/351/200/220/346/226/271/346/263/225/347/244/272/344/276/213.md +27 -8
  45. package/docs/manual/20-/346/200/247/350/203/275/344/270/216/345/244/247/346/225/260/346/215/256.md +27 -4
  46. package/docs/manual/21-/350/257/264/346/230/216/344/270/216/345/270/270/350/247/201/351/227/256/351/242/230.md +3 -3
  47. package/docs/playground/index.html +1 -1
  48. package/docs/sample-document/345/255/227/346/256/265/350/257/246/350/247/243.md +2 -2
  49. package/docs/steps/check-doc-truth.mjs +1 -1
  50. package/docs/steps/selftest-actions.html +4 -4
  51. package/docs/steps/selftest-data.html +5 -5
  52. package/docs/steps/selftest-demos.html +1683 -1619
  53. package/docs/steps/selftest-docs.html +4 -4
  54. package/docs/steps/selftest-effects.html +1 -1
  55. package/docs/steps/selftest-features.html +548 -47
  56. package/docs/steps/selftest-full.html +566 -566
  57. package/docs/steps/selftest-labels.html +647 -647
  58. package/docs/steps/selftest-perf.html +2282 -2237
  59. package/docs/steps/selftest-select.html +53 -14
  60. package/docs/steps/step-10-full.js +1 -4
  61. package/docs/steps/step-12-events.js +1 -4
  62. package/package.json +43 -43
@@ -41,6 +41,7 @@
41
41
  const { compileLineRules, resolveLineStyle } = await import('../../src/gis-view/line.js')
42
42
  const { parseColor } = await import('../../src/gis-view/utils.js')
43
43
  const { resolveGeoJson } = await import('../../src/gis-view/background.js')
44
+ const { boundsOfData, createGeoMapper, createGeoProjection } = await import('../../src/gis-view/projection.js')
44
45
  // 本页断言是照「限数 + 避让」的老口径写的:统一按优化档(labelOptimize: true)跑
45
46
  const createGisView = (host, options = {}) => createGisViewRaw(host, { labelOptimize: true, ...options })
46
47
  const { dcLineDecoration, dcLineDecorationSymbol, pathSymbol } = await import('../demos/gisStyle.js')
@@ -344,6 +345,92 @@
344
345
  Math.abs(rotatedGeo.regions[0].center[0] - 116.66666666666666) < 1e-6 && Math.abs(rotatedGeo.regions[0].center[1] - 216.66666666666669) < 1e-6,
345
346
  JSON.stringify({ rotation: rotatedGeo.rotation, bounds: rotatedGeo.bounds, rawBounds: rotatedGeo.rawBounds, center: rotatedGeo.regions[0].center }))
346
347
 
348
+ // 旋转 / 平移后的投影:经纬度换算必须跟最终画出来的几何完全重合,不能只算 aspectScale
349
+ const rotatedProjection = createGeoProjection(rotatedGeo)
350
+ const rotatedProjectedCorner = rotatedProjection.project(0, 0)
351
+ const rotatedCornerPoint = rotatedGeo.regions[0].polygons[0][0][0]
352
+ const rotatedRoundtrip = rotatedProjection.unproject(rotatedProjectedCorner[0], rotatedProjectedCorner[1])
353
+ check('getGeoProjection 对 geoJsonStyle.bounds + rotation 做完整正反换算',
354
+ Math.abs(rotatedProjectedCorner[0] - rotatedCornerPoint[0]) < 1e-6 && Math.abs(rotatedProjectedCorner[1] - rotatedCornerPoint[1]) < 1e-6 &&
355
+ Math.abs(rotatedRoundtrip[0]) < 1e-9 && Math.abs(rotatedRoundtrip[1]) < 1e-9,
356
+ JSON.stringify({ projected: rotatedProjectedCorner, geometry: rotatedCornerPoint, roundtrip: rotatedRoundtrip }))
357
+
358
+ const aspectMapper = createGeoMapper({ bounds: { minX: 0, minY: 0, maxX: 10, maxY: 10 }, aspectScale: 0.75 }, { mode: 'scale' })
359
+ const aspectMapped = aspectMapper.map([0, 0])
360
+ const aspectUnmapped = aspectMapper.unmap(aspectMapped)
361
+ const mappedLine = aspectMapper.mapLine({ from: 'a', to: 'b', start: [0, 0], end: [10, 10], path: [[0, 0], [10, 10]] })
362
+ const endpointBounds = boundsOfData({ points: [], lines: [{ start: [2, 20], end: [12, 30] }] })
363
+ check('默认 scale 映射与 aspectScale 对齐,start / end 一起映射且参与 fit 范围',
364
+ Math.abs(aspectMapped[0] - 1.25) < 1e-9 && Math.abs(aspectMapped[1]) < 1e-9 &&
365
+ Math.abs(aspectUnmapped[0]) < 1e-9 && Math.abs(aspectUnmapped[1]) < 1e-9 &&
366
+ Array.isArray(mappedLine.start) && Array.isArray(mappedLine.end) && mappedLine.from === 'a' && mappedLine.to === 'b' &&
367
+ endpointBounds?.minX === 2 && endpointBounds?.maxX === 12 && endpointBounds?.minY === 20 && endpointBounds?.maxY === 30,
368
+ JSON.stringify({ mapped: aspectMapped, roundtrip: aspectUnmapped, line: mappedLine, endpointBounds }))
369
+
370
+ const nestedMapper = createGeoMapper({ bounds: { minX: 0, minY: 0, maxX: 10, maxY: 10 }, aspectScale: 1 }, {
371
+ mode: 'scale', scaleX: 2, scaleY: 3, offsetX: 10, offsetY: 20
372
+ })
373
+ const nestedChannel = nestedMapper.mapChannel({
374
+ id: 'nested', path: [[1, 1]],
375
+ groups: [{ lineIds: ['l1'], path: [[2, 2], { x: 3, y: 4 }], waypointOffsets: { 1: [1, 2], 2: { x: -1, y: 0.5 } } }]
376
+ })
377
+ const nestedPoint = nestedMapper.mapPoint({
378
+ x: 1, y: 1, decorations: [{ at: [2, 2] }, { anchor: 'fixed' }]
379
+ })
380
+ const nestedLine = nestedMapper.mapLine({
381
+ start: { x: 1, y: 1 }, end: [2, 2],
382
+ decorations: [{ position: [3, 3], at: { x: 4, y: 4 } }, { position: 0.5 }, { position: { distance: 10 } }]
383
+ })
384
+ const nestedBounds = boundsOfData({
385
+ points: [{ x: 0, y: 0, decorations: [{ at: [100, 200] }] }],
386
+ lines: [{ path: [{ x: 2, y: 3 }], decorations: [{ position: [4, 5] }] }],
387
+ channels: [{ groups: [{ path: [{ x: 6, y: 7 }] }] }]
388
+ })
389
+ check('映射器覆盖通道分组、对象坐标与固定坐标装饰',
390
+ nestedChannel.path[0][0] === 7 && nestedChannel.path[0][1] === 13 &&
391
+ nestedChannel.groups[0].path[0][0] === 9 && nestedChannel.groups[0].path[0][1] === 16 &&
392
+ nestedChannel.groups[0].path[1].x === 11 && nestedChannel.groups[0].path[1].y === 22 &&
393
+ nestedChannel.groups[0].waypointOffsets[1][0] === 2 && nestedChannel.groups[0].waypointOffsets[1][1] === 6 &&
394
+ nestedChannel.groups[0].waypointOffsets[2].x === -2 && nestedChannel.groups[0].waypointOffsets[2].y === 1.5 &&
395
+ nestedPoint.decorations[0].at[0] === 9 && nestedPoint.decorations[0].at[1] === 16 &&
396
+ nestedLine.start.x === 7 && nestedLine.start.y === 13 && nestedLine.end[0] === 9 && nestedLine.end[1] === 16 &&
397
+ nestedLine.decorations[0].position[0] === 11 && nestedLine.decorations[1].position === 0.5 &&
398
+ nestedLine.decorations[2].position.distance === 10 && nestedLine.decorations[0].at.x === 13 && nestedLine.decorations[0].at.y === 22 &&
399
+ nestedBounds?.minX === 0 && nestedBounds?.maxX === 100 && nestedBounds?.minY === 0 && nestedBounds?.maxY === 200,
400
+ JSON.stringify({ channel: nestedChannel, point: nestedPoint, line: nestedLine, bounds: nestedBounds }))
401
+
402
+ const rotatedVectorMapper = createGeoMapper({
403
+ bounds: { minX: 0, minY: 0, maxX: 10, maxY: 10 }, aspectScale: 1,
404
+ drawTransform: { scaleX: 2, scaleY: 1, offsetX: 100, offsetY: 50, center: [110, 55], rotation: 90 }
405
+ }, { mode: 'scale' })
406
+ const rotatedOffset = rotatedVectorMapper.mapChannel({ groups: [{ lineIds: ['l1'], waypointOffsets: { 0: [1, 0] } }] }).groups[0].waypointOffsets[0]
407
+ check('waypointOffsets 按向量映射,旋转后不叠平移',
408
+ Math.abs(rotatedOffset[0]) < 1e-9 && Math.abs(rotatedOffset[1] + 2) < 1e-9,
409
+ JSON.stringify({ offset: rotatedOffset }))
410
+
411
+ // world 模式:把当前画出来的世界坐标反投影回 GeoJSON 的真实经纬度;旋转背景后仍可往返
412
+ const worldMapper = createGeoMapper(rotatedGeo, { mode: 'world' })
413
+ const worldMappedCorner = worldMapper.map(rotatedProjectedCorner)
414
+ const worldRoundtrip = worldMapper.unmap(worldMappedCorner)
415
+ const worldOffset = worldMapper.mapChannel({ groups: [{ lineIds: ['l1'], waypointOffsets: { 0: [1, 0] } }] }).groups[0].waypointOffsets[0]
416
+ const worldOffsetExpected = [
417
+ rotatedProjection.unproject(1, 0)[0] - rotatedProjection.unproject(0, 0)[0],
418
+ rotatedProjection.unproject(1, 0)[1] - rotatedProjection.unproject(0, 0)[1]
419
+ ]
420
+ check('world 映射把画出来的世界坐标反投影成真实经纬度,旋转后往返与向量换算一致',
421
+ Math.abs(worldMappedCorner[0]) < 1e-6 && Math.abs(worldMappedCorner[1]) < 1e-6 &&
422
+ Math.abs(worldRoundtrip[0] - rotatedProjectedCorner[0]) < 1e-6 && Math.abs(worldRoundtrip[1] - rotatedProjectedCorner[1]) < 1e-6 &&
423
+ Math.abs(worldOffset[0] - worldOffsetExpected[0]) < 1e-9 && Math.abs(worldOffset[1] - worldOffsetExpected[1]) < 1e-9,
424
+ JSON.stringify({ mapped: worldMappedCorner, roundtrip: worldRoundtrip, offset: worldOffset, expected: worldOffsetExpected }))
425
+
426
+ const normalizedGeo = resolveGeoJson({
427
+ type: 'Polygon',
428
+ coordinates: [[[0, 0], [0, 0], [10, 0], [10, 5], [10, 5], [0, 5], [0, 0]]]
429
+ }, { aspectScale: 1 }, 1)
430
+ check('GeoJSON 闭合尾点与相邻重复点在剖分前归一化,不重复铺描边',
431
+ normalizedGeo.strokes.length / 4 === 4 && normalizedGeo.fills.length / 6 === 2 && normalizedGeo.rawBounds?.minX === 0 && normalizedGeo.rawBounds?.maxX === 10,
432
+ JSON.stringify({ segments: normalizedGeo.strokes.length / 4, triangles: normalizedGeo.fills.length / 6, rawBounds: normalizedGeo.rawBounds }))
433
+
347
434
  // 背景编辑模式:网架的点击 / 拖点 / 平移 / 右键 / 删除键全部停用,滚轮缩放仍保留
348
435
  const editingBox = canvas.getBoundingClientRect()
349
436
  const editingPointScreen = id => {
@@ -531,6 +618,59 @@
531
618
  Math.abs(apiPatchedState.width - (apiWidthBefore + 2)) < 1e-6 && Math.abs(apiPatchedState.rotation - 15) < 1e-6 &&
532
619
  view.getOptions().background?.geoJson?.type === 'FeatureCollection' && view.getOptions().background?.geoJsonStyle?.rotation === 15,
533
620
  JSON.stringify({ width: apiPatchedState.width, rotation: apiPatchedState.rotation }))
621
+ const networkPoint = view.getData().points[0]
622
+ const expectedNetworkLngLat = view.worldToLngLat(networkPoint.x, networkPoint.y)
623
+ const convertedNetwork = view.mapDataToLngLat()
624
+ check('mapDataToLngLat:背景编辑后按当前 bounds / rotation 转换整份网架,返回副本且原数据不变',
625
+ Boolean(expectedNetworkLngLat && convertedNetwork?.points?.length && convertedNetwork.lines?.length) && Array.isArray(convertedNetwork.channels) &&
626
+ Math.abs(convertedNetwork.points[0].x - expectedNetworkLngLat[0]) < 1e-9 && Math.abs(convertedNetwork.points[0].y - expectedNetworkLngLat[1]) < 1e-9 &&
627
+ networkPoint.x === 100 && networkPoint.y === -70 && view.getData().points[0].x === 100,
628
+ JSON.stringify({ converted: convertedNetwork?.points?.[0], expected: expectedNetworkLngLat, original: networkPoint }))
629
+ const convertedSnapshot = JSON.stringify(convertedNetwork)
630
+ const restoredNetwork = view.mapLngLatToData(convertedNetwork)
631
+ const originalNetwork = {
632
+ points: view.getData().points,
633
+ lines: view.getData().lines,
634
+ channels: view.getOptions().channels
635
+ }
636
+ const sameGeometry = (left, right) => {
637
+ if (typeof left === 'number' && typeof right === 'number') {
638
+ return Math.abs(left - right) < 1e-7
639
+ }
640
+ if (Array.isArray(left) || Array.isArray(right)) {
641
+ return Array.isArray(left) && Array.isArray(right) && left.length === right.length &&
642
+ left.every((value, index) => sameGeometry(value, right[index]))
643
+ }
644
+ if (left && right && typeof left === 'object' && typeof right === 'object') {
645
+ const leftKeys = Object.keys(left)
646
+ return leftKeys.length === Object.keys(right).length &&
647
+ leftKeys.every(key => Object.prototype.hasOwnProperty.call(right, key) && sameGeometry(left[key], right[key]))
648
+ }
649
+ return left === right
650
+ }
651
+ check('mapLngLatToData:经纬度按当前底图投影写回世界坐标,节点 / 线路 / 通道分组几何与原数据一致,且不改传入的存档',
652
+ Boolean(restoredNetwork?.points?.length && restoredNetwork.lines?.length && Array.isArray(restoredNetwork.channels)) &&
653
+ sameGeometry(restoredNetwork.points, originalNetwork.points) &&
654
+ sameGeometry(restoredNetwork.lines, originalNetwork.lines) &&
655
+ sameGeometry(restoredNetwork.channels, originalNetwork.channels) &&
656
+ JSON.stringify(convertedNetwork) === convertedSnapshot,
657
+ JSON.stringify({ restored: restoredNetwork?.points?.[0], original: originalNetwork.points[0], unchanged: JSON.stringify(convertedNetwork) === convertedSnapshot }))
658
+ const roundtripAnchor = originalNetwork.points[0]
659
+ const roundtripScreenBefore = view.project(roundtripAnchor.x, roundtripAnchor.y)
660
+ view.updateData({ points: [], lines: [], channels: [] })
661
+ const emptyNetworkSizes = [view.getData().points.length, view.getData().lines.length, view.getOptions().channels.length]
662
+ view.updateData(restoredNetwork)
663
+ await hoverFrame()
664
+ const roundtripAnchorAfter = view.getData().points.find(point => point.id === roundtripAnchor.id)
665
+ const roundtripScreenAfter = roundtripAnchorAfter ? view.project(roundtripAnchorAfter.x, roundtripAnchorAfter.y) : null
666
+ check('保存 → 清空 → mapLngLatToData 回灌:节点 / 线路 / 通道恢复,首节点屏幕落点不变',
667
+ emptyNetworkSizes.every(size => size === 0) &&
668
+ roundtripAnchorAfter && Math.abs(roundtripAnchorAfter.x - roundtripAnchor.x) < 1e-7 && Math.abs(roundtripAnchorAfter.y - roundtripAnchor.y) < 1e-7 &&
669
+ view.getData().lines.length === originalNetwork.lines.length && view.getOptions().channels.length === originalNetwork.channels.length &&
670
+ roundtripScreenAfter && Math.hypot(roundtripScreenAfter[0] - roundtripScreenBefore[0], roundtripScreenAfter[1] - roundtripScreenBefore[1]) < 1e-6,
671
+ JSON.stringify({ empty: emptyNetworkSizes, world: roundtripAnchorAfter, screen: [roundtripScreenBefore, roundtripScreenAfter] }))
672
+ view.updateData(originalNetwork)
673
+ await hoverFrame()
534
674
  const endEditorReturn = view.endBackgroundEdit()
535
675
  await hoverFrame()
536
676
  check('endBackgroundEdit:移除编辑层、读数回到 null 并恢复进入前的 backgroundEditing',
@@ -634,7 +774,7 @@
634
774
  lines: [{ id: 'bound', from: 'p1', to: 'p2', color: '#1f5fd0', width: 8, decorations: [decoration] }],
635
775
  showGrid: false,
636
776
  // 这一节要加 / 删拐点,看「钉住」的装饰是不是贴着线走:拐点编辑缺省关(见 src/gis-view/features.js)
637
- waypointEditable: true
777
+ features: { waypoint: true }
638
778
  })
639
779
  const shot2 = async () => {
640
780
  await settle()
@@ -1009,7 +1149,7 @@
1009
1149
  lineLabel: { enabled: true, position: 0.5, maxCount: 8 },
1010
1150
  showGrid: false,
1011
1151
  // 这里要按住节点拖一下看标签跟不跟:拖拽缺省关(见 src/gis-view/features.js)
1012
- draggable: true
1152
+ features: { drag: true }
1013
1153
  })
1014
1154
  const followCanvas = view9.canvas
1015
1155
  const followBox = followCanvas.getBoundingClientRect()
@@ -1152,10 +1292,8 @@
1152
1292
  fitPadding: 70,
1153
1293
  // 复制缺省关(见 src/gis-view/features.js):这一节按住 Alt 点节点要出预览
1154
1294
  copy: { enabled: true },
1155
- // 下面还要按住 Shift 拖出选框验默认快捷键:框选缺省也关
1156
- boxSelect: true,
1157
- // Esc 取消复制 / Ctrl+C 收剪贴板 / Ctrl+V 粘贴都走快捷键:键盘也显式打开
1158
- features: { keyboard: true },
1295
+ // 下面还要按住 Shift 拖出选框验默认快捷键、Ctrl+C / Ctrl+V 验剪贴板:框选与键盘显式打开
1296
+ features: { keyboard: true, boxSelect: true },
1159
1297
  // 显式写一遍映射,验证不依赖默认值也能监听到 onPointCopy
1160
1298
  shortcuts: { copy: 'ctrl+c', paste: 'ctrl+v' },
1161
1299
  onPointCopy: payload => {
@@ -1363,7 +1501,7 @@
1363
1501
  fitPadding: 70,
1364
1502
  showGrid: false,
1365
1503
  // 这一节要按住节点拖到另一个节点上:拖拽缺省关(见 src/gis-view/features.js)
1366
- draggable: true,
1504
+ features: { drag: true },
1367
1505
  merge: { distance: 8 },
1368
1506
  onPointDrag: payload => cmDragEvents.push(payload),
1369
1507
  onPointMerge: payload => {
@@ -1614,7 +1752,7 @@
1614
1752
  showGrid: false,
1615
1753
  merge: { confirm: payload => Promise.resolve().then(() => payload.confirm()) },
1616
1754
  // 拖节点合并不用拖拽开关?用——拖拽缺省关,这里显式打开
1617
- draggable: true,
1755
+ features: { drag: true },
1618
1756
  onPointMerge: payload => {
1619
1757
  if (payload.phase === 'pending' && cmCancelNext) {
1620
1758
  payload.cancel()
@@ -1650,7 +1788,7 @@
1650
1788
  showGrid: false,
1651
1789
  merge: { distance: 8, channelGroups: 'merge' },
1652
1790
  // 拖节点合并不用拖拽开关?用——拖拽缺省关,这里显式打开
1653
- draggable: true,
1791
+ features: { drag: true },
1654
1792
  onPointMerge: payload => {
1655
1793
  cmMergeEvents5.push(payload)
1656
1794
  if (payload.phase === 'pending') {
@@ -1690,7 +1828,7 @@
1690
1828
  fitPadding: 70,
1691
1829
  showGrid: false,
1692
1830
  merge: { trigger: 'center', confirm: false },
1693
- draggable: true,
1831
+ features: { drag: true },
1694
1832
  onPointMerge: payload => triggerEvents.push(payload)
1695
1833
  })
1696
1834
  await settle(120)
@@ -1711,7 +1849,7 @@
1711
1849
  fitPadding: 70,
1712
1850
  showGrid: false,
1713
1851
  merge: { trigger: 'collision', confirm: false },
1714
- draggable: true
1852
+ features: { drag: true }
1715
1853
  })
1716
1854
  await settle(120)
1717
1855
  const collisionTarget = collisionView.project(140, 0)
@@ -1753,7 +1891,7 @@
1753
1891
  // 合并缺省关:这一节要把节点拖到目标上验待确认状态,显式打开
1754
1892
  merge: { enabled: true },
1755
1893
  // 拖拽同样缺省关:上面这一拖是「按住节点拖」
1756
- draggable: true,
1894
+ features: { drag: true },
1757
1895
  onPointMerge: payload => msEvents.push(payload),
1758
1896
  onDataChange: payload => msChanges.push(payload)
1759
1897
  })
@@ -1898,7 +2036,7 @@
1898
2036
  initialView: { center: [0, 0], scale: 2.5 },
1899
2037
  showGrid: false,
1900
2038
  // 这一节要按住节点拖到另一个节点上:拖拽缺省关
1901
- draggable: true
2039
+ features: { drag: true }
1902
2040
  })
1903
2041
  await settle(150)
1904
2042
  const cmLowScreen = cmRaiseView.project(-5, 0)
@@ -1950,7 +2088,7 @@
1950
2088
  cmRaiseChannelHost.style.cssText = 'width:320px;height:240px'
1951
2089
  document.body.appendChild(cmRaiseChannelHost)
1952
2090
  const cmRaiseChannelBuild = createChannels([
1953
- { id: 'raise-ch', from: 'ch-n1', to: 'ch-n2', lineIds: ['ch-c1', 'ch-c2'] }
2091
+ { id: 'raise-ch', from: 'ch-n1', to: 'ch-n2', circuits: 2, lineIds: ['ch-c1', 'ch-c2'] }
1954
2092
  ])
1955
2093
  const cmRaiseChannelView = createGisView(cmRaiseChannelHost, {
1956
2094
  points: [
@@ -1958,8 +2096,8 @@
1958
2096
  { id: 'ch-n2', x: 30, y: 0, size: 8 }
1959
2097
  ],
1960
2098
  lines: [
1961
- { id: 'ch-c1', from: 'ch-n1', to: 'ch-n2', color: '#ff3b30', width: 8 },
1962
- { id: 'ch-c2', from: 'ch-n1', to: 'ch-n2', color: '#ff3b30', width: 8 },
2099
+ { id: 'ch-c1', from: 'ch-n1', to: 'ch-n2', color: '#ff3b30', width: 8, selectStyle: { color: '#ff3b30' }, decorations: [{ position: 0.25, symbol: 'square', size: 6, color: '#22c55e', followSelectColor: true }] },
2100
+ { id: 'ch-c2', from: 'ch-n1', to: 'ch-n2', color: '#ff3b30', width: 8, selectStyle: { color: '#ff3b30' }, decorations: [{ position: 0.75, symbol: 'square', size: 6, color: '#22c55e', followSelectColor: true }] },
1963
2101
  { id: 'ch-cross', path: [[0, -20], [0, 20]], color: '#1f5fd0', width: 8 }
1964
2102
  ],
1965
2103
  channels: cmRaiseChannelBuild.channels,
@@ -1982,6 +2120,12 @@
1982
2120
  cmChannelRaisePixels.push(await cmPixelAt(cmRaiseChannelView, spot))
1983
2121
  }
1984
2122
  check('选中通道里的一条回线:整条通道(两条回线)都抬到最上面', cmChannelRaisePixels.every(cmIsRed), JSON.stringify(cmChannelRaisePixels))
2123
+ cmRaiseChannelView.selectFeatures([{ type: 'channel', channelId: 'raise-ch' }])
2124
+ await settle(120)
2125
+ const cmChannelScene = cmRaiseChannelView.snapshotScene({ background: false })
2126
+ check('选中整条通道:成员回线的装饰一起抬层并跟随通道选中色',
2127
+ cmChannelScene.raisedDecorations.length === 2 && cmChannelScene.raisedDecorations.every(glyph => glyph.fill === cmChannelScene.raisedLines[0]?.color),
2128
+ JSON.stringify({ selected: cmRaiseChannelView.getSelections().map(entry => entry.type + ':' + entry.id), raisedLines: cmChannelScene.raisedLines.length, decorations: cmChannelScene.raisedDecorations.map(glyph => glyph.fill) }))
1985
2129
  cmRaiseChannelView.destroy()
1986
2130
  cmRaiseChannelHost.remove()
1987
2131
 
@@ -2166,7 +2310,7 @@
2166
2310
  { id: 'fa-high-ch', from: 'fa-high-a', to: 'fa-high-b', lineIds: ['fa-l3', 'fa-l4'], groups: [{ id: 'fa-high', lineIds: ['fa-l3', 'fa-l4'] }] }
2167
2311
  ],
2168
2312
  initialView: { center: [0, 0], scale: 1 },
2169
- waypointEditable: true,
2313
+ features: { waypoint: true },
2170
2314
  showGrid: false
2171
2315
  })
2172
2316
  await settle(100)
@@ -2358,7 +2502,7 @@
2358
2502
  points: [{ id: 'edge-node', x: 0, y: 0, size: 44, color: '#ffffff', strokeColor: '#4c8dff', strokeWidth: 1.5 }],
2359
2503
  initialView: { center: [0, 0], scale: 3 },
2360
2504
  showGrid: false,
2361
- draggable: true
2505
+ features: { drag: true }
2362
2506
  })
2363
2507
  await settle(180)
2364
2508
  // 不传背景(backgroundColor 为 undefined)这一档才走交互期的后备缓冲:与屏幕上那一帧是同一条路
@@ -2418,9 +2562,8 @@
2418
2562
  initialView: { center: [0, 0], scale: 3 },
2419
2563
  showGrid: false,
2420
2564
  pixelRatio: 1,
2421
- draggable: true,
2422
- // 空白处按下拖动 = 平移:进交互期那一帧要它才走到非 MSAA 后备缓冲
2423
- features: { pan: true }
2565
+ // 空白处按下拖动 = 平移、节点按下拖动 = 拖拽:进交互期那一帧要它们才走到非 MSAA 后备缓冲
2566
+ features: { pan: true, drag: true }
2424
2567
  })
2425
2568
  await settle(180)
2426
2569
  check('奇数设备像素的画布:画布宽确实是奇数(吸附必须在设备像素坐标系里做)', cmOddView.canvas.width % 2 === 1, '画布 ' + cmOddView.canvas.width + '×' + cmOddView.canvas.height)
@@ -2481,11 +2624,10 @@
2481
2624
  fitPadding: 60,
2482
2625
  showGrid: false,
2483
2626
  // 这一节要同时验合并与复制预览两条未完成交互:三个开关都缺省关,显式打开
2484
- draggable: true,
2485
2627
  copy: { enabled: true },
2486
2628
  merge: { distance: 8 },
2487
- // 下面还要开始一段通道草稿(点两个节点加通道):草稿开关缺省也是关的
2488
- features: { channelDraft: true },
2629
+ // 下面还要开始一段通道草稿(点两个节点加通道):草稿与拖拽开关缺省也是关的
2630
+ features: { channelDraft: true, drag: true },
2489
2631
  onPointMerge: payload => {
2490
2632
  pendMergeEvents.push(payload)
2491
2633
  if (payload.phase === 'pending') {
@@ -2554,7 +2696,7 @@
2554
2696
  fitPadding: 60,
2555
2697
  showGrid: false,
2556
2698
  // 逐点开关验的是「重叠时能不能合 / 能不能复制」,先把两个开关打开、拖拽打开
2557
- draggable: true,
2699
+ features: { drag: true },
2558
2700
  copy: { enabled: false },
2559
2701
  merge: { enabled: false }
2560
2702
  })
@@ -2589,11 +2731,9 @@
2589
2731
  renderEmitInterval: 0,
2590
2732
  // 下面要拖节点验历史、加 / 删拐点验 onDataChange、Ctrl+C / Ctrl+V 验剪贴板:
2591
2733
  // 这几个编辑开关缺省都关(见 src/gis-view/features.js),这里显式打开
2592
- draggable: true,
2593
- waypointEditable: true,
2594
2734
  copy: { enabled: true },
2595
2735
  // Delete / Ctrl+C / Ctrl+V 这些快捷键要显式打开
2596
- features: { keyboard: true },
2736
+ features: { keyboard: true, drag: true, waypoint: true },
2597
2737
  onDataChange: payload => evLog.data.push(payload),
2598
2738
  onSelectChange: payload => evLog.select.push(payload),
2599
2739
  onShortcut: payload => evLog.shortcut.push(payload),
@@ -2764,6 +2904,90 @@
2764
2904
  `原第 ${ulSplitIndex} 组 → 第 0 组`)
2765
2905
  ulView.destroy()
2766
2906
  ulHost.remove()
2907
+ /* 定向更新第一次补上状态样式 / effect:没有全局样式时也要当场进总账,不能等下一次 refresh */
2908
+ const dynPointHost = document.createElement('div')
2909
+ dynPointHost.style.cssText = 'width:420px;height:280px'
2910
+ document.body.appendChild(dynPointHost)
2911
+ const dynPointView = createGisView(dynPointHost, {
2912
+ points: [
2913
+ { id: 'dyn-p', x: 0, y: 0, size: 16, color: '#334155' },
2914
+ { id: 'dyn-q', x: 120, y: 0, size: 16 }
2915
+ ],
2916
+ lines: [{ id: 'dyn-l', from: 'dyn-p', to: 'dyn-q', color: '#2563eb', width: 4 }],
2917
+ selectionOnTop: false,
2918
+ showGrid: false,
2919
+ fitPadding: 50
2920
+ })
2921
+ await settle(140)
2922
+ dynPointView.updatePoint('dyn-p', { selectStyle: { color: '#e11d48' } })
2923
+ dynPointView.selectFeatures('dyn-p')
2924
+ await settle(140)
2925
+ const dynPointSpot = dynPointView.project(0, 0)
2926
+ const dynPointSnapshot = dynPointView.snapshotScene({ background: false })
2927
+ const dynPointPaint = [...dynPointSnapshot.points, ...dynPointSnapshot.raised]
2928
+ .find(point => Math.hypot(point.x - dynPointSpot[0], point.y - dynPointSpot[1]) < 3)
2929
+ check('updatePoint 首次补 selectStyle 后立即选中:无全局状态样式时也走状态重解析',
2930
+ dynPointPaint?.fill === 'rgba(225,29,72,1)',
2931
+ `节点填充 ${dynPointPaint?.fill}`)
2932
+ dynPointView.deselect()
2933
+ await settle(60)
2934
+ dynPointView.updatePoint('dyn-p', { effect: { radius: 12, period: 8 } })
2935
+ await settle(160)
2936
+ const dynPointStats = dynPointView.getStats()
2937
+ check('updatePoint 首次补 effect 后无需 refresh:涟漪实例与动画时钟当场开起来',
2938
+ dynPointStats.pointEffectCount === 1 && dynPointStats.animating === true,
2939
+ JSON.stringify({ effects: dynPointStats.pointEffectCount, animating: dynPointStats.animating }))
2940
+ const dynPointEffectsBeforeRemove = dynPointView.getEffects().enabled
2941
+ dynPointView.removePoints('dyn-p')
2942
+ await settle(100)
2943
+ const dynPointEffectsAfterRemove = dynPointView.getEffects().enabled
2944
+ check('删掉最后一个带 effect 的节点:逐要素效果总账跟着清零,不会一直把动画判成开启',
2945
+ dynPointEffectsBeforeRemove === true && dynPointEffectsAfterRemove === false,
2946
+ JSON.stringify({ before: dynPointEffectsBeforeRemove, after: dynPointEffectsAfterRemove }))
2947
+ dynPointView.destroy()
2948
+ dynPointHost.remove()
2949
+
2950
+ const dynLineHost = document.createElement('div')
2951
+ dynLineHost.style.cssText = 'width:420px;height:280px'
2952
+ document.body.appendChild(dynLineHost)
2953
+ const dynLineView = createGisView(dynLineHost, {
2954
+ points: [{ id: 'dyn-la', x: 0, y: 0 }, { id: 'dyn-lb', x: 120, y: 0 }],
2955
+ lines: [{ id: 'dyn-line', from: 'dyn-la', to: 'dyn-lb', color: '#2563eb', width: 4 }],
2956
+ selectionOnTop: false,
2957
+ showGrid: false,
2958
+ fitPadding: 50
2959
+ })
2960
+ await settle(140)
2961
+ dynLineView.updateLine('dyn-line', { selectStyle: { color: '#22c55e', width: 10 } })
2962
+ dynLineView.selectFeatures('dyn-line')
2963
+ await settle(140)
2964
+ const dynLineSnapshot = dynLineView.snapshotScene({ background: false })
2965
+ const dynLinePaint = [...dynLineSnapshot.lines, ...dynLineSnapshot.raisedLines]
2966
+ .find(line => line.color === 'rgba(34,197,94,1)' && line.width >= 10)
2967
+ check('updateLine 首次补 selectStyle 后立即选中:无全局状态样式时也走状态重解析',
2968
+ dynLinePaint !== undefined,
2969
+ JSON.stringify((dynLineSnapshot.lines ?? []).map(line => [line.color, line.width])))
2970
+ dynLineView.deselect()
2971
+ await settle(60)
2972
+ dynLineView.updateLine('dyn-line', { effect: { period: 8, size: 10 } })
2973
+ await settle(160)
2974
+ const dynLineStats = dynLineView.getStats()
2975
+ check('updateLine 首次补 effect 后无需 refresh:动点与动画时钟当场开起来',
2976
+ dynLineStats.lineEffectCount === 1 && dynLineStats.animating === true,
2977
+ JSON.stringify({ effects: dynLineStats.lineEffectCount, animating: dynLineStats.animating }))
2978
+ let dynUnboundError = ''
2979
+ try {
2980
+ dynLineView.updatePoint('dyn-la', { id: 'dyn-la-renamed' })
2981
+ }
2982
+ catch (error) {
2983
+ dynUnboundError = error?.message ?? String(error)
2984
+ }
2985
+ const dynUnboundLine = dynLineView.getLine('dyn-line')
2986
+ check('updatePoint 改掉绑定节点 id:旧回线缺端点时按局部路径收口,不抛数组长度错误',
2987
+ dynUnboundError === '' && (dynUnboundLine?.path?.length ?? 0) < 2,
2988
+ JSON.stringify({ error: dynUnboundError || null, path: dynUnboundLine?.path ?? null }))
2989
+ dynLineView.destroy()
2990
+ dynLineHost.remove()
2767
2991
  /* 这一轮新增:通道改名 / 通道下增减回线 / 删除分组(问题.txt 第 2 条那几个「方法有吗」) */
2768
2992
  const cgNameBefore = cgData.length
2769
2993
  cgView.updateChannel('ch-1', { name: '甲通道' })
@@ -3230,7 +3454,7 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
3230
3454
  channels: mvBuilt.channels,
3231
3455
  fitPadding: 70,
3232
3456
  showGrid: false,
3233
- draggable: true,
3457
+ features: { drag: true },
3234
3458
  history: true
3235
3459
  })
3236
3460
  await settle()
@@ -3437,6 +3661,103 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
3437
3661
  stView.destroy()
3438
3662
  stHost.remove()
3439
3663
 
3664
+ // features 统一收口:别名映射到规范开关、规范键优先、数组明确拒绝
3665
+ const featureAliasHost = document.createElement('div')
3666
+ featureAliasHost.style.cssText = 'width:320px;height:200px'
3667
+ document.body.appendChild(featureAliasHost)
3668
+ const featureAliasView = createGisView(featureAliasHost, {
3669
+ points: [{ id: 'a', x: 0, y: 0 }],
3670
+ showGrid: false,
3671
+ features: {
3672
+ draggable: true,
3673
+ waypointEditable: true,
3674
+ clickable: true,
3675
+ interactive: true,
3676
+ multiSelect: true,
3677
+ toggleSelect: true
3678
+ }
3679
+ })
3680
+ await settle(100)
3681
+ const featureAliasState = featureAliasView.getFeatures()
3682
+ check('features 别名:draggable / waypointEditable / clickable / interactive 映射到规范开关',
3683
+ featureAliasState.drag === true && featureAliasState.waypoint === true && featureAliasState.hover === true && featureAliasState.select === true && featureAliasState.pan === true && featureAliasState.zoom === true && featureAliasState.contextMenu === true,
3684
+ JSON.stringify(featureAliasState))
3685
+ check('features 读数只含 19 个规范键,不把别名混进 getFeatures()',
3686
+ Object.keys(featureAliasState).length === 19 && !Object.prototype.hasOwnProperty.call(featureAliasState, 'draggable') && !Object.prototype.hasOwnProperty.call(featureAliasState, 'interactive'),
3687
+ `${Object.keys(featureAliasState).length} 键:${Object.keys(featureAliasState).join(',')}`)
3688
+ check('features 收口 multiSelect / toggleSelect:有效值可直接驱动复选框',
3689
+ featureAliasState.multiSelect === true && featureAliasState.toggleSelect === true,
3690
+ JSON.stringify({ multiSelect: featureAliasState.multiSelect, toggleSelect: featureAliasState.toggleSelect }))
3691
+ featureAliasView.setFeatures({ draggable: false, drag: true, clickable: false, select: true, interactive: false, pan: true })
3692
+ const featureAliasOverride = featureAliasView.getFeatures()
3693
+ check('features 同一张表同时写别名与规范名:规范名优先',
3694
+ featureAliasOverride.drag === true && featureAliasOverride.select === true && featureAliasOverride.pan === true && featureAliasOverride.hover === false && featureAliasOverride.zoom === false && featureAliasOverride.contextMenu === false,
3695
+ JSON.stringify(featureAliasOverride))
3696
+ featureAliasView.destroy()
3697
+ featureAliasHost.remove()
3698
+
3699
+ // 旧顶层交互写法全部不再生效:只发迁移提示,权限必须走 features(以拖不动作为实际生效判据)
3700
+ const legacyFeatureNames = ['draggable', 'interactive', 'clickable', 'boxSelect', 'multiSelect', 'toggleSelect', 'waypointEditable']
3701
+ const legacyFeatureWarnings = []
3702
+ const legacyFeatureHost = document.createElement('div')
3703
+ legacyFeatureHost.style.cssText = 'width:320px;height:200px'
3704
+ document.body.appendChild(legacyFeatureHost)
3705
+ const legacyFeatureView = createGisView(legacyFeatureHost, {
3706
+ points: [{ id: 'legacy-feature', x: 0, y: 0 }],
3707
+ showGrid: false,
3708
+ draggable: true,
3709
+ interactive: true,
3710
+ clickable: true,
3711
+ boxSelect: true,
3712
+ multiSelect: true,
3713
+ toggleSelect: true,
3714
+ waypointEditable: true,
3715
+ onWarning: message => legacyFeatureWarnings.push(message)
3716
+ })
3717
+ await settle(100)
3718
+ legacyFeatureView.updateOptions({
3719
+ draggable: true,
3720
+ interactive: true,
3721
+ clickable: true,
3722
+ boxSelect: true,
3723
+ multiSelect: true,
3724
+ toggleSelect: true,
3725
+ waypointEditable: true
3726
+ })
3727
+ await cmDrag(legacyFeatureView, legacyFeatureView.project(0, 0), legacyFeatureView.project(40, 40))
3728
+ const legacyFeaturePoint = legacyFeatureView.getData().points[0]
3729
+ const legacyFeatureOptions = legacyFeatureView.getOptions()
3730
+ const legacyFeatureState = legacyFeatureView.getFeatures()
3731
+ const legacyWarningText = legacyFeatureWarnings.join('\n')
3732
+ check('七个旧顶层交互开关在创建与 updateOptions 都不再生效:逐项告警、不留在 getOptions、对应 features 仍关闭且节点拖不动',
3733
+ legacyFeaturePoint.x === 0 && legacyFeaturePoint.y === 0 &&
3734
+ legacyFeatureState.drag === false && legacyFeatureState.pan === false && legacyFeatureState.zoom === false &&
3735
+ legacyFeatureState.hover === false && legacyFeatureState.select === false && legacyFeatureState.contextMenu === false &&
3736
+ legacyFeatureState.boxSelect === false && legacyFeatureState.multiSelect === false &&
3737
+ legacyFeatureState.toggleSelect === false && legacyFeatureState.waypoint === false &&
3738
+ legacyFeatureNames.every(name => !Object.prototype.hasOwnProperty.call(legacyFeatureOptions, name)) &&
3739
+ legacyFeatureNames.every(name => legacyWarningText.includes(`顶层 ${name}`) && legacyWarningText.includes('features:')),
3740
+ JSON.stringify({ state: legacyFeatureState, point: legacyFeaturePoint, options: legacyFeatureOptions, warnings: legacyFeatureWarnings }))
3741
+ legacyFeatureView.destroy()
3742
+ legacyFeatureHost.remove()
3743
+
3744
+ const arrayWarnings = []
3745
+ const arrayHost = document.createElement('div')
3746
+ arrayHost.style.cssText = 'width:320px;height:200px'
3747
+ document.body.appendChild(arrayHost)
3748
+ const arrayView = createGisView(arrayHost, {
3749
+ points: [{ id: 'a', x: 0, y: 0 }],
3750
+ showGrid: false,
3751
+ features: [{ drag: true }],
3752
+ onWarning: message => arrayWarnings.push(message)
3753
+ })
3754
+ await settle(100)
3755
+ check('features 数组写法:明确警告并按缺省全关处理',
3756
+ arrayWarnings.some(message => message.includes('不认数组')) && Object.values(arrayView.getFeatures()).every(value => value === false),
3757
+ JSON.stringify({ warnings: arrayWarnings, state: arrayView.getFeatures() }))
3758
+ arrayView.destroy()
3759
+ arrayHost.remove()
3760
+
3440
3761
  /* ---------------- 两层开关重合的那几项:features 读数与详细配置同一条口径(见 15.65) ----------------
3441
3762
  tooltip / label / lineLabel / minimap 既能用功能开关关,也能在详细配置里写 enabled: false。
3442
3763
  从前 getFeatures() 只看「这份详细配置在不在」:写了 enabled: false 还报 true。现在按「与」算 ——
@@ -3723,7 +4044,8 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
3723
4044
  check('triggerOnBoxSelect: false(缺省):框选只改选中集并收掉旧级联', casView.getCascadeHighlight() === null, JSON.stringify(casView.getCascadeHighlight()))
3724
4045
 
3725
4046
  // 取消选中时级联一起收掉:toggleSelect 的取消只发一条 null,接口取消也走同一口径
3726
- casView.updateOptions({ toggleSelect: true, cascadeHighlight: { scope: 'both', triggerOnClick: true, triggerOnMultiSelect: true } })
4047
+ casView.setFeatures({ ...casView.getFeatures(), toggleSelect: true })
4048
+ casView.updateOptions({ cascadeHighlight: { scope: 'both', triggerOnClick: true, triggerOnMultiSelect: true } })
3727
4049
  casView.selectFeatures(null)
3728
4050
  await settle(120)
3729
4051
  await casClick(casView.project(0, 0))
@@ -3981,10 +4303,8 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
3981
4303
  channels: rdBuilt.channels,
3982
4304
  fitPadding: 60,
3983
4305
  showGrid: false,
3984
- // 这一节要 addWaypoint 之后看平行分组:拐点编辑缺省关,这里显式打开
3985
- waypointEditable: true,
3986
- // 单选取反 / 右键读数要 select 与 contextMenu(缺省全关)
3987
- features: { select: true, contextMenu: true },
4306
+ // 拐点编辑缺省关;单选取反 / 右键读数要 select 与 contextMenu
4307
+ features: { select: true, contextMenu: true, waypoint: true },
3988
4308
  onClick: payload => rdClicks.push(payload),
3989
4309
  onContextMenu: payload => rdMenus.push(payload)
3990
4310
  })
@@ -4054,8 +4374,7 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
4054
4374
  channels: csmBuilt.channels,
4055
4375
  fitPadding: 60,
4056
4376
  showGrid: false,
4057
- // 点回线摊开选中集:先关拐点编辑,Ctrl 点击才能测「整批取反」;加点那一段再打开
4058
- waypointEditable: false,
4377
+ // 点回线摊开选中集:先不开拐点编辑,Ctrl 点击才能测「整批取反」;加点那一段再打开
4059
4378
  features: { select: true }
4060
4379
  })
4061
4380
  await settle(140)
@@ -4097,7 +4416,7 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
4097
4416
  await csmClick(csmView.project(100, 160))
4098
4417
  check('通道外的线路不受选中模式影响(还是只选自己)', csmIds() === 'csm-solo', csmIds())
4099
4418
  // Ctrl + 点击给通道回线加点:加完后的选中 / 高亮范围与普通单击共用 channelSelectMode
4100
- csmView.updateOptions({ waypointEditable: true })
4419
+ csmView.setFeatures({ ...csmView.getFeatures(), waypoint: true })
4101
4420
  await settle(60)
4102
4421
  const csmGroupSkeleton = () => csmView.getChannelGroups('csm-ch').find(group => String(group.id) === 'csm-g2')?.skeleton.length ?? 0
4103
4422
  const csmAddBefore = csmGroupSkeleton()
@@ -4135,7 +4454,7 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
4135
4454
  { id: 'vwd-g2', lineIds: ['vwd-3', 'vwd-4'], path: [[100, -80], [260, -80]], waypointOffsets: { '1': { x: 0, y: 6 } } }
4136
4455
  ]
4137
4456
  }],
4138
- waypointEditable: true,
4457
+ features: { waypoint: true },
4139
4458
  waypointVisibility: 'always',
4140
4459
  channelWaypointDragMode: 'vertex',
4141
4460
  fitPadding: 70,
@@ -4425,10 +4744,8 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
4425
4744
  channels: apiBuilt.channels,
4426
4745
  fitPadding: 60,
4427
4746
  showGrid: false,
4428
- // 下面要按住 Shift 拖出一个框验框选读数:框选缺省关,这里显式打开
4429
- boxSelect: true,
4430
- // 单击选中也要显式打开(缺省全关)
4431
- features: { select: true }
4747
+ // 单击选中与 Shift 框选都要显式打开(缺省全关)
4748
+ features: { select: true, boxSelect: true }
4432
4749
  })
4433
4750
  await settle()
4434
4751
  const apiIds = () => apiView.getSelectionData().ids.join(',')
@@ -4620,6 +4937,9 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
4620
4937
  layerView.updateLine('lay-v', { zIndex: 0 })
4621
4938
  const resetLayer = await layerCenterColor()
4622
4939
  check('线路 zIndex:写回 0 之后又回到规则那一层', isRed(resetLayer), `中心像素 ${resetLayer}`)
4940
+ layerView.updateOptions({ background: '#fafafa' })
4941
+ const reusedLineLayer = await layerCenterColor()
4942
+ check('只换背景、线路解析整半复用时,zIndex 层序仍然保留', isRed(reusedLineLayer), `中心像素 ${reusedLineLayer}`)
4623
4943
  /* 线路方向箭头:与装饰同一条批次(画在所有线之上),全局开 / 逐条关 / 整份摘都要即时生效 */
4624
4944
  layerView.updateOptions({ lineDirection: true })
4625
4945
  const directionLayer = await layerCenterColor()
@@ -4706,6 +5026,187 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
4706
5026
  followView.destroy()
4707
5027
  followHost.remove()
4708
5028
  }
5029
+ /* ---------------- 装饰随所属要素抬层 / 跟随选中色 ---------------- */
5030
+ {
5031
+ const decorationHost = document.createElement('div')
5032
+ decorationHost.style.cssText = 'width:360px;height:240px'
5033
+ document.body.append(decorationHost)
5034
+ const followSvgSymbol = {
5035
+ type: 'svg',
5036
+ path: 'M 2,8 L 14,8',
5037
+ viewBox: [0, 0, 16, 16],
5038
+ fill: 'currentColor',
5039
+ stroke: 'currentStrokeColor',
5040
+ strokeWidth: 4
5041
+ }
5042
+ const decorationView = createGisView(decorationHost, {
5043
+ points: [
5044
+ {
5045
+ id: 'decor-selected-node', x: 100, y: 60, size: 14, color: '#334155',
5046
+ selectStyle: { color: '#e11d48' },
5047
+ decorations: [
5048
+ { symbol: 'square', size: 36, offset: [0, 0], color: '#f59e0b' },
5049
+ { symbol: 'circle', size: 12, offset: [18, 0], color: '#06b6d4', followSelectColor: true },
5050
+ { symbol: followSvgSymbol, size: 12, offset: [0, 18], color: '#f97316', strokeColor: '#0ea5e9', followSelectColor: true }
5051
+ ]
5052
+ }
5053
+ ],
5054
+ lines: [
5055
+ {
5056
+ id: 'decor-selected-line', path: [[-160, 60], [160, 60]], color: '#2563eb', width: 6,
5057
+ selectStyle: { color: '#22c55e', width: 20 },
5058
+ decorations: [
5059
+ { position: 0.5, symbol: 'square', size: 36, color: '#ff0000' },
5060
+ { position: 0.25, symbol: 'circle', size: 14, color: '#7c3aed', followSelectColor: true },
5061
+ { position: 0.125, symbol: followSvgSymbol, size: 14, color: '#f97316', strokeColor: '#0ea5e9', followSelectColor: true }
5062
+ ]
5063
+ },
5064
+ {
5065
+ id: 'decor-plain-line', path: [[-160, -70], [160, -70]], color: '#2563eb', width: 6,
5066
+ decorations: [{ position: 0.5, symbol: 'square', size: 20, color: '#eab308', followSelectColor: true }]
5067
+ }
5068
+ ],
5069
+ initialView: { scale: 1, center: [0, 0] },
5070
+ background: { color: '#ffffff' },
5071
+ showGrid: false
5072
+ })
5073
+ await settle()
5074
+ const decorationSnapshot = () => decorationView.snapshotScene({ background: false })
5075
+ const decorationAt = (snapshot, listName, x, y) => {
5076
+ const [sx, sy] = decorationView.project(x, y)
5077
+ return snapshot[listName].find(glyph => Math.hypot(glyph.x - sx, glyph.y - sy) < 2)
5078
+ }
5079
+ const red = 'rgba(255,0,0,1)'
5080
+ const green = 'rgba(34,197,94,1)'
5081
+ const purple = 'rgba(124,58,237,1)'
5082
+ const gold = 'rgba(245,158,11,1)'
5083
+ const rose = 'rgba(225,29,72,1)'
5084
+ const cyan = 'rgba(6,182,212,1)'
5085
+ const yellow = 'rgba(234,179,8,1)'
5086
+ const orangeSvg = 'rgba(249, 115, 22, 1)'
5087
+ const skySvg = 'rgba(14, 165, 233, 1)'
5088
+ const greenSvg = 'rgba(34, 197, 94, 1)'
5089
+ const roseSvg = 'rgba(225, 29, 72, 1)'
5090
+ const svgFill = (glyph) => /\bfill=\x22([^\x22]+)\x22/.exec(glyph?.inner ?? '')?.[1] ?? null
5091
+ const svgStroke = (glyph) => /\bstroke=\x22([^\x22]+)\x22/.exec(glyph?.inner ?? '')?.[1] ?? null
5092
+
5093
+ decorationView.selectFeatures(['decor-selected-line', 'decor-selected-node'])
5094
+ await settle()
5095
+ const selectedDecorations = decorationSnapshot()
5096
+ check('选中线路 / 节点的 decorations 进入抬层,普通线路的装饰仍在普通层',
5097
+ selectedDecorations.raisedLines.length === 1 && selectedDecorations.raised.length === 1 &&
5098
+ selectedDecorations.raisedDecorations.length === 6 && selectedDecorations.decorations.length === 1,
5099
+ JSON.stringify({ raisedLines: selectedDecorations.raisedLines.length, raised: selectedDecorations.raised.length, raisedDecorations: selectedDecorations.raisedDecorations.length, decorations: selectedDecorations.decorations.length }))
5100
+ check('followSelectColor 打开时装饰跟随所属线路 / 节点的最终选中色,关闭时保留自己的颜色',
5101
+ decorationAt(selectedDecorations, 'raisedDecorations', 0, 60)?.fill === red &&
5102
+ decorationAt(selectedDecorations, 'raisedDecorations', -80, 60)?.fill === green &&
5103
+ decorationAt(selectedDecorations, 'raisedDecorations', 100, 60)?.fill === gold &&
5104
+ decorationAt(selectedDecorations, 'raisedDecorations', 118, 60)?.fill === rose &&
5105
+ decorationAt(selectedDecorations, 'decorations', 0, -70)?.fill === yellow,
5106
+ JSON.stringify({ lineOwn: decorationAt(selectedDecorations, 'raisedDecorations', 0, 60)?.fill, lineFollow: decorationAt(selectedDecorations, 'raisedDecorations', -80, 60)?.fill, pointOwn: decorationAt(selectedDecorations, 'raisedDecorations', 100, 60)?.fill, pointFollow: decorationAt(selectedDecorations, 'raisedDecorations', 118, 60)?.fill, unselected: decorationAt(selectedDecorations, 'decorations', 0, -70)?.fill }))
5107
+ const selectedSvgLine = decorationAt(selectedDecorations, 'raisedDecorations', -120, 60)
5108
+ const selectedSvgPoint = decorationAt(selectedDecorations, 'raisedDecorations', 100, 42)
5109
+ check('SVG path 的 followSelectColor 改描边:线路取绿色、节点取玫红,填充仍保留自身橙色',
5110
+ svgFill(selectedSvgLine) === orangeSvg && svgStroke(selectedSvgLine) === greenSvg &&
5111
+ svgFill(selectedSvgPoint) === orangeSvg && svgStroke(selectedSvgPoint) === roseSvg,
5112
+ JSON.stringify({ lineFill: svgFill(selectedSvgLine), lineStroke: svgStroke(selectedSvgLine), pointFill: svgFill(selectedSvgPoint), pointStroke: svgStroke(selectedSvgPoint) }))
5113
+
5114
+ const decorationUrl = await decorationView.exportImage({ background: '#ffffff', includeLabels: false, scale: 1 })
5115
+ const decorationImage = new Image()
5116
+ await new Promise((resolve, reject) => {
5117
+ decorationImage.onload = resolve
5118
+ decorationImage.onerror = () => reject(new Error('导出图片加载失败'))
5119
+ decorationImage.src = decorationUrl
5120
+ })
5121
+ const decorationCanvas = document.createElement('canvas')
5122
+ decorationCanvas.width = decorationImage.width
5123
+ decorationCanvas.height = decorationImage.height
5124
+ const decorationContext = decorationCanvas.getContext('2d')
5125
+ decorationContext.drawImage(decorationImage, 0, 0)
5126
+ const decorationPixels = decorationContext.getImageData(0, 0, decorationCanvas.width, decorationCanvas.height)
5127
+ const decorationFrame = { pixels: decorationPixels.data, width: decorationCanvas.width, height: decorationCanvas.height, base: [255, 255, 255] }
5128
+ const around = (x, y, half = 3) => {
5129
+ const spot = decorationView.project(x, y)
5130
+ return [spot[0] - half, spot[1] - half, spot[0] + half, spot[1] + half]
5131
+ }
5132
+ check('线路装饰抬起后压在选中线路之上(中心仍是装饰自己的红色)',
5133
+ colorCount(decorationFrame, around(0, 60, 2), [255, 0, 0], 20) >= 9,
5134
+ `中心红色像素 ${colorCount(decorationFrame, around(0, 60, 2), [255, 0, 0], 20)}`)
5135
+ check('节点装饰抬起后压在选中线路上、仍在节点本体之下(线宽覆盖区能看到金色装饰)',
5136
+ colorCount(decorationFrame, around(100, 52, 2), [245, 158, 11], 24) >= 6,
5137
+ `金色像素 ${colorCount(decorationFrame, around(100, 52, 2), [245, 158, 11], 24)}`)
5138
+ const decorationSvgUrl = await decorationView.exportImage({ type: 'svg', background: 'none', includeLabels: false })
5139
+ const decorationSvg = new TextDecoder().decode(Uint8Array.from(
5140
+ atob(String(decorationSvgUrl).slice('data:image/svg+xml;base64,'.length)),
5141
+ char => char.charCodeAt(0)
5142
+ ))
5143
+ const raisedLinePaint = selectedDecorations.raisedLines[0]?.color
5144
+ const raisedDecorationPaint = decorationAt(selectedDecorations, 'raisedDecorations', 0, 60)?.fill
5145
+ const raisedLineAt = decorationSvg.indexOf(String(raisedLinePaint))
5146
+ const raisedDecorationAt = decorationSvg.indexOf(String(raisedDecorationPaint))
5147
+ check('SVG 导出把抬层装饰排在抬层线路之后',
5148
+ raisedLineAt >= 0 && raisedDecorationAt > raisedLineAt,
5149
+ `线路 ${raisedLineAt} / 装饰 ${raisedDecorationAt}`)
5150
+
5151
+ decorationView.deselect()
5152
+ await settle()
5153
+ const clearedDecorations = decorationSnapshot()
5154
+ check('取消选中后装饰回到普通层,followSelectColor 恢复装饰自己的颜色',
5155
+ clearedDecorations.raisedDecorations.length === 0 && clearedDecorations.decorations.length === 7 &&
5156
+ decorationAt(clearedDecorations, 'decorations', -80, 60)?.fill === purple &&
5157
+ decorationAt(clearedDecorations, 'decorations', 118, 60)?.fill === cyan,
5158
+ JSON.stringify({ raised: clearedDecorations.raisedDecorations.length, decorations: clearedDecorations.decorations.length, line: decorationAt(clearedDecorations, 'decorations', -80, 60)?.fill, point: decorationAt(clearedDecorations, 'decorations', 118, 60)?.fill }))
5159
+ const clearedSvgLine = decorationAt(clearedDecorations, 'decorations', -120, 60)
5160
+ const clearedSvgPoint = decorationAt(clearedDecorations, 'decorations', 100, 42)
5161
+ check('取消选中后 SVG path 的描边恢复装饰自己的 strokeColor',
5162
+ svgFill(clearedSvgLine) === orangeSvg && svgStroke(clearedSvgLine) === skySvg &&
5163
+ svgFill(clearedSvgPoint) === orangeSvg && svgStroke(clearedSvgPoint) === skySvg,
5164
+ JSON.stringify({ lineStroke: svgStroke(clearedSvgLine), pointStroke: svgStroke(clearedSvgPoint) }))
5165
+
5166
+ // selectionOnTop: false 不重排,专测「只换样式」的快路径也会重算 followSelectColor
5167
+ decorationView.updateOptions({ selectionOnTop: false })
5168
+ await settle()
5169
+ decorationView.selectFeatures('decor-selected-line')
5170
+ await settle()
5171
+ const fastLine = decorationSnapshot()
5172
+ check('selectionOnTop: false 时线路装饰仍跟随选中色(样式快路径没有跳过它)',
5173
+ fastLine.raisedDecorations.length === 0 && decorationAt(fastLine, 'decorations', -80, 60)?.fill === green,
5174
+ JSON.stringify({ raised: fastLine.raisedDecorations.length, line: decorationAt(fastLine, 'decorations', -80, 60)?.fill }))
5175
+ const fastSvgLine = decorationAt(fastLine, 'decorations', -120, 60)
5176
+ check('selectionOnTop: false 时 SVG path 的选中色仍写进描边',
5177
+ svgFill(fastSvgLine) === orangeSvg && svgStroke(fastSvgLine) === greenSvg,
5178
+ JSON.stringify({ fill: svgFill(fastSvgLine), stroke: svgStroke(fastSvgLine) }))
5179
+ decorationView.deselect()
5180
+ await settle()
5181
+ const fastLineCleared = decorationSnapshot()
5182
+ check('selectionOnTop: false 取消选中后线路装饰颜色也还原',
5183
+ decorationAt(fastLineCleared, 'decorations', -80, 60)?.fill === purple,
5184
+ `线路装饰 ${decorationAt(fastLineCleared, 'decorations', -80, 60)?.fill}`)
5185
+ check('selectionOnTop: false 取消选中后线路 SVG path 的描边也还原',
5186
+ svgStroke(decorationAt(fastLineCleared, 'decorations', -120, 60)) === skySvg,
5187
+ `线路 SVG 描边 ${svgStroke(decorationAt(fastLineCleared, 'decorations', -120, 60))}`)
5188
+ decorationView.selectFeatures('decor-selected-node')
5189
+ await settle()
5190
+ const fastPoint = decorationSnapshot()
5191
+ check('selectionOnTop: false 时节点装饰跟随选中色(节点槽位补丁也覆盖装饰)',
5192
+ decorationAt(fastPoint, 'decorations', 118, 60)?.fill === rose,
5193
+ `节点装饰 ${decorationAt(fastPoint, 'decorations', 118, 60)?.fill}`)
5194
+ const fastSvgPoint = decorationAt(fastPoint, 'decorations', 100, 42)
5195
+ check('selectionOnTop: false 时节点 SVG path 的选中色写进描边',
5196
+ svgFill(fastSvgPoint) === orangeSvg && svgStroke(fastSvgPoint) === roseSvg,
5197
+ JSON.stringify({ fill: svgFill(fastSvgPoint), stroke: svgStroke(fastSvgPoint) }))
5198
+ decorationView.deselect()
5199
+ await settle()
5200
+ const fastPointCleared = decorationSnapshot()
5201
+ check('selectionOnTop: false 取消选中后节点装饰颜色还原',
5202
+ decorationAt(fastPointCleared, 'decorations', 118, 60)?.fill === cyan,
5203
+ `节点装饰 ${decorationAt(fastPointCleared, 'decorations', 118, 60)?.fill}`)
5204
+ check('selectionOnTop: false 取消选中后节点 SVG path 的描边也还原',
5205
+ svgStroke(decorationAt(fastPointCleared, 'decorations', 100, 42)) === skySvg,
5206
+ `节点 SVG 描边 ${svgStroke(decorationAt(fastPointCleared, 'decorations', 100, 42))}`)
5207
+ decorationView.destroy()
5208
+ decorationHost.remove()
5209
+ }
4709
5210
  /* ---------------- 运行时插件:view.use() ---------------- */
4710
5211
  // options.plugins 只能在创建那一刻挂;use() 是同一个执行 / 清理口径的运行时版本 ——
4711
5212
  // 立即执行、返回卸载函数、销毁时与 options 插件一起按相反顺序清理。这里把这几条各断言一次
@@ -4736,7 +5237,7 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
4736
5237
  })
4737
5238
  check('options.plugins 拿到实例本身与带 apiVersion 的只读上下文', optionPluginInstance === pluginView && optionPluginContext?.apiVersion === 1 && optionPluginContext.view === pluginView && Object.isFrozen(optionPluginContext), JSON.stringify(optionPluginContext ? { apiVersion: optionPluginContext.apiVersion, sameView: optionPluginContext.view === pluginView } : null))
4738
5239
  check('插件上下文带独立 signal,创建期间未中止', optionSignal instanceof AbortSignal && optionSignal.aborted === false && optionSignal !== null, String(optionSignal?.aborted))
4739
- let usePluginInstance = null
5240
+ let usePluginInstance = null
4740
5241
  let usePluginContext = null
4741
5242
  let useDestroyCount = 0
4742
5243
  const disposeUse = pluginView.use((v, context) => {
@@ -5091,7 +5592,7 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
5091
5592
  { id: 'wp2', path: [[0, 120], [120, 120], [240, 120]], width: 6, color: '#1f5fd0' },
5092
5593
  { id: 'wp3', path: [[0, 240], [120, 240], [240, 240]], width: 6, color: '#1f5fd0' }
5093
5594
  ],
5094
- waypointEditable: true,
5595
+ features: { waypoint: true },
5095
5596
  waypointVisibility: 'always',
5096
5597
  showGrid: false
5097
5598
  })
@@ -5745,7 +6246,7 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
5745
6246
  { id: 'm2', from: 'a', to: 'c' }
5746
6247
  ],
5747
6248
  channels: [{ id: 'au-ch', from: 'a', to: 'c', lineIds: ['m1', 'm2'] }],
5748
- waypointEditable: true,
6249
+ features: { waypoint: true },
5749
6250
  waypointVisibility: 'always',
5750
6251
  copy: true,
5751
6252
  fitPadding: 60,
@@ -5854,7 +6355,7 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
5854
6355
  { id: 'beta', lineIds: ['g2'], path: [[30, -40]] }
5855
6356
  ]
5856
6357
  }],
5857
- waypointEditable: true
6358
+ features: { waypoint: true }
5858
6359
  })
5859
6360
  await settle()
5860
6361
  const gwGeometry = id => gwView.listLines({}).find(entry => entry.item?.id === id)?.path ?? null
@@ -6324,7 +6825,7 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
6324
6825
  initialView: { scale: 1, center: [2, 2] },
6325
6826
  labelOptimize: false,
6326
6827
  showGrid: false,
6327
- waypointEditable: true,
6828
+ features: { waypoint: true },
6328
6829
  history: { limit: 20 }
6329
6830
  })
6330
6831
  const splitOutcome = splitView.splitPoint('sv-a', [