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
@@ -1,318 +1,318 @@
1
- <!doctype html>
2
- <html lang="zh-CN">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <title>labels running</title>
6
- </head>
7
- <body>
8
- <div id=map style=width:640px;height:380px></div>
9
- <pre id=out></pre>
10
- <script type="module">
11
- // 标签拖动 / 编辑与导出分项(多选)的自测:
12
- // 拖动走合成指针事件(headless 里没有真指针),位置读标签自身的 getBoundingClientRect
13
- const results = []
14
- const check = (name, ok, extra = '') => {
15
- results.push(`${ok ? 'PASS' : 'FAIL'} ${name}${extra === '' ? '' : ` — ${extra}`}`)
16
- }
17
- // 合成事件没有真实指针:setPointerCapture 会抛 InvalidPointerId,测试里吞掉
18
- const nativeCapture = Element.prototype.setPointerCapture
19
- const nativeRelease = Element.prototype.releasePointerCapture
20
- Element.prototype.setPointerCapture = function (id) {
21
- try {
22
- nativeCapture.call(this, id)
23
- }
24
- catch (error) {
25
- void error
26
- }
27
- }
28
- Element.prototype.releasePointerCapture = function (id) {
29
- try {
30
- nativeRelease.call(this, id)
31
- }
32
- catch (error) {
33
- void error
34
- }
35
- }
36
- try {
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>labels running</title>
6
+ </head>
7
+ <body>
8
+ <div id=map style=width:640px;height:380px></div>
9
+ <pre id=out></pre>
10
+ <script type="module">
11
+ // 标签拖动 / 编辑与导出分项(多选)的自测:
12
+ // 拖动走合成指针事件(headless 里没有真指针),位置读标签自身的 getBoundingClientRect
13
+ const results = []
14
+ const check = (name, ok, extra = '') => {
15
+ results.push(`${ok ? 'PASS' : 'FAIL'} ${name}${extra === '' ? '' : ` — ${extra}`}`)
16
+ }
17
+ // 合成事件没有真实指针:setPointerCapture 会抛 InvalidPointerId,测试里吞掉
18
+ const nativeCapture = Element.prototype.setPointerCapture
19
+ const nativeRelease = Element.prototype.releasePointerCapture
20
+ Element.prototype.setPointerCapture = function (id) {
21
+ try {
22
+ nativeCapture.call(this, id)
23
+ }
24
+ catch (error) {
25
+ void error
26
+ }
27
+ }
28
+ Element.prototype.releasePointerCapture = function (id) {
29
+ try {
30
+ nativeRelease.call(this, id)
31
+ }
32
+ catch (error) {
33
+ void error
34
+ }
35
+ }
36
+ try {
37
37
  const { createGisView: createGisViewRaw } = await import('../../src/gis-view/index.js')
38
38
  // 本页绝大多数断言是照「限数 + 避让」的老口径写的:统一按优化档(labelOptimize: true)跑;
39
39
  // 组件缺省那一档(不写 = 强制显示)在页面末尾第 21 组单独断言
40
40
  const createGisView = (host, options = {}) => createGisViewRaw(host, { labelOptimize: true, ...options })
41
- const settle = (ms = 80) => new Promise(resolve => setTimeout(resolve, ms))
42
- const points = [
43
- { id: 'a', x: 0, y: 0, name: '甲站' },
44
- { id: 'b', x: 260, y: 0, name: '乙站' },
45
- { id: 'c', x: 130, y: 180, name: '丙站' }
46
- ]
47
- const lines = [
48
- { id: 'l1', from: 'a', to: 'b', name: '甲乙线' },
49
- { id: 'l2', from: 'b', to: 'c', name: '乙丙线' }
50
- ]
51
- const map = document.querySelector('#map')
52
- const drags = []
53
- const clicks = []
54
- const changes = []
55
- const view = createGisView(map, {
56
- points,
57
- lines,
58
- label: { enabled: true, content: '{name}', draggable: true, editable: true, maxCount: 5 },
59
- lineLabel: { enabled: true, content: '{name}', draggable: true, editable: true, maxCount: 5 },
60
- showGrid: false,
61
- onLabelDrag: payload => drags.push(payload),
62
- onLabelClick: payload => clicks.push(payload),
63
- onLabelChange: payload => changes.push(payload)
64
- })
65
- await settle(160)
66
- const pointLabel = index => map.querySelector(`.gis-view__label[data-label-kind='point'][data-label-index='${index}']`)
67
- const lineLabel = index => map.querySelector(`.gis-view__label[data-label-kind='line'][data-label-index='${index}']`)
68
- const centerOf = (node) => {
69
- const box = node.getBoundingClientRect()
70
- return [box.left + box.width / 2, box.top + box.height / 2]
71
- }
72
- const fire = (target, type, x, y) => target.dispatchEvent(new PointerEvent(type, {
73
- bubbles: true,
74
- cancelable: true,
75
- pointerId: 7,
76
- pointerType: 'mouse',
77
- button: 0,
78
- buttons: type === 'pointerup' ? 0 : 1,
79
- clientX: x,
80
- clientY: y
81
- }))
82
- const node0 = pointLabel(0)
83
- check('标签带上可拖 / 可点的标记', Boolean(node0) && node0.classList.contains('gis-view__label--draggable') && node0.classList.contains('gis-view__label--editable'), node0 ? node0.className : '没有标签')
84
- // 开关是配置项:关掉之后标签立刻把指针还给画布,勾回来再重新接住
85
- view.updateOptions({ label: { enabled: true, content: '{name}', draggable: false, editable: false, maxCount: 5 } })
86
- await settle(200)
87
- const plainLabel = pointLabel(0)
88
- check('updateOptions 关掉 draggable / editable 之后标签不再接住指针', Boolean(plainLabel) && !plainLabel.classList.contains('gis-view__label--draggable') && !plainLabel.classList.contains('gis-view__label--editable') && getComputedStyle(plainLabel).pointerEvents === 'none', plainLabel ? `${plainLabel.className} · ${getComputedStyle(plainLabel).pointerEvents}` : '没有标签')
89
- view.updateOptions({ label: { enabled: true, content: '{name}', draggable: true, editable: true, maxCount: 5 } })
90
- await settle(200)
91
- check('勾回来之后标签又接住指针', pointLabel(0)?.classList.contains('gis-view__label--draggable') === true && getComputedStyle(pointLabel(0)).pointerEvents === 'auto', String(getComputedStyle(pointLabel(0)).pointerEvents))
92
- const camera0 = view.getCamera()
93
- const screen0 = view.project(0, 0)
94
- const start = centerOf(node0)
95
- // 默认位置与节点中心的差:恢复默认之后要回到这一份上
96
- const defaultGap = [start[0] - screen0[0], start[1] - screen0[1]]
97
-
98
- // 1. 拖标签:按下 → 移动 → 抬起(拖 64 / 36 像素)
99
- fire(node0, 'pointerdown', start[0], start[1])
100
- fire(window, 'pointermove', start[0] + 64, start[1] + 36)
101
- await settle(40)
102
- const during = centerOf(node0)
103
- fire(window, 'pointerup', start[0] + 64, start[1] + 36)
104
- await settle(120)
105
- const dragged = centerOf(pointLabel(0))
106
- check('拖标签:指针拖 64 / 36,标签跟着走同一段', Math.abs(dragged[0] - start[0] - 64) <= 2 && Math.abs(dragged[1] - start[1] - 36) <= 2, `拖动中 ${(during[0] - start[0]).toFixed(1)} / ${(during[1] - start[1]).toFixed(1)},停手后 ${(dragged[0] - start[0]).toFixed(1)} / ${(dragged[1] - start[1]).toFixed(1)}`)
107
- check('拖动期间画布不跟着平移', Math.abs(view.getCamera().center[0] - camera0.center[0]) < 1e-6 && Math.abs(view.getCamera().center[1] - camera0.center[1]) < 1e-6, JSON.stringify(view.getCamera().center))
108
- check('拖动过程发 labelDrag 的 start / move / end', drags.length >= 3 && drags[0].phase === 'start' && drags[1].phase === 'move' && drags[drags.length - 1].phase === 'end' && drags[drags.length - 1].moved === true, drags.map(item => item.phase).join(','))
109
- const dragState = view.getLabelStates('point').find(item => item.id === 'a')
110
- check('拖出来的偏移记进状态(按 id 存)', Boolean(dragState) && Math.abs(dragState.offset[0] - 64) <= 1 && Math.abs(dragState.offset[1] - 36) <= 1, JSON.stringify(dragState?.offset ?? null))
111
-
112
- // 2. 移动节点:偏移照旧,标签跟着节点一起走
113
- const beforeMove = centerOf(pointLabel(0))
41
+ const settle = (ms = 80) => new Promise(resolve => setTimeout(resolve, ms))
42
+ const points = [
43
+ { id: 'a', x: 0, y: 0, name: '甲站' },
44
+ { id: 'b', x: 260, y: 0, name: '乙站' },
45
+ { id: 'c', x: 130, y: 180, name: '丙站' }
46
+ ]
47
+ const lines = [
48
+ { id: 'l1', from: 'a', to: 'b', name: '甲乙线' },
49
+ { id: 'l2', from: 'b', to: 'c', name: '乙丙线' }
50
+ ]
51
+ const map = document.querySelector('#map')
52
+ const drags = []
53
+ const clicks = []
54
+ const changes = []
55
+ const view = createGisView(map, {
56
+ points,
57
+ lines,
58
+ label: { enabled: true, content: '{name}', draggable: true, editable: true, maxCount: 5 },
59
+ lineLabel: { enabled: true, content: '{name}', draggable: true, editable: true, maxCount: 5 },
60
+ showGrid: false,
61
+ onLabelDrag: payload => drags.push(payload),
62
+ onLabelClick: payload => clicks.push(payload),
63
+ onLabelChange: payload => changes.push(payload)
64
+ })
65
+ await settle(160)
66
+ const pointLabel = index => map.querySelector(`.gis-view__label[data-label-kind='point'][data-label-index='${index}']`)
67
+ const lineLabel = index => map.querySelector(`.gis-view__label[data-label-kind='line'][data-label-index='${index}']`)
68
+ const centerOf = (node) => {
69
+ const box = node.getBoundingClientRect()
70
+ return [box.left + box.width / 2, box.top + box.height / 2]
71
+ }
72
+ const fire = (target, type, x, y) => target.dispatchEvent(new PointerEvent(type, {
73
+ bubbles: true,
74
+ cancelable: true,
75
+ pointerId: 7,
76
+ pointerType: 'mouse',
77
+ button: 0,
78
+ buttons: type === 'pointerup' ? 0 : 1,
79
+ clientX: x,
80
+ clientY: y
81
+ }))
82
+ const node0 = pointLabel(0)
83
+ check('标签带上可拖 / 可点的标记', Boolean(node0) && node0.classList.contains('gis-view__label--draggable') && node0.classList.contains('gis-view__label--editable'), node0 ? node0.className : '没有标签')
84
+ // 开关是配置项:关掉之后标签立刻把指针还给画布,勾回来再重新接住
85
+ view.updateOptions({ label: { enabled: true, content: '{name}', draggable: false, editable: false, maxCount: 5 } })
86
+ await settle(200)
87
+ const plainLabel = pointLabel(0)
88
+ check('updateOptions 关掉 draggable / editable 之后标签不再接住指针', Boolean(plainLabel) && !plainLabel.classList.contains('gis-view__label--draggable') && !plainLabel.classList.contains('gis-view__label--editable') && getComputedStyle(plainLabel).pointerEvents === 'none', plainLabel ? `${plainLabel.className} · ${getComputedStyle(plainLabel).pointerEvents}` : '没有标签')
89
+ view.updateOptions({ label: { enabled: true, content: '{name}', draggable: true, editable: true, maxCount: 5 } })
90
+ await settle(200)
91
+ check('勾回来之后标签又接住指针', pointLabel(0)?.classList.contains('gis-view__label--draggable') === true && getComputedStyle(pointLabel(0)).pointerEvents === 'auto', String(getComputedStyle(pointLabel(0)).pointerEvents))
92
+ const camera0 = view.getCamera()
93
+ const screen0 = view.project(0, 0)
94
+ const start = centerOf(node0)
95
+ // 默认位置与节点中心的差:恢复默认之后要回到这一份上
96
+ const defaultGap = [start[0] - screen0[0], start[1] - screen0[1]]
97
+
98
+ // 1. 拖标签:按下 → 移动 → 抬起(拖 64 / 36 像素)
99
+ fire(node0, 'pointerdown', start[0], start[1])
100
+ fire(window, 'pointermove', start[0] + 64, start[1] + 36)
101
+ await settle(40)
102
+ const during = centerOf(node0)
103
+ fire(window, 'pointerup', start[0] + 64, start[1] + 36)
104
+ await settle(120)
105
+ const dragged = centerOf(pointLabel(0))
106
+ check('拖标签:指针拖 64 / 36,标签跟着走同一段', Math.abs(dragged[0] - start[0] - 64) <= 2 && Math.abs(dragged[1] - start[1] - 36) <= 2, `拖动中 ${(during[0] - start[0]).toFixed(1)} / ${(during[1] - start[1]).toFixed(1)},停手后 ${(dragged[0] - start[0]).toFixed(1)} / ${(dragged[1] - start[1]).toFixed(1)}`)
107
+ check('拖动期间画布不跟着平移', Math.abs(view.getCamera().center[0] - camera0.center[0]) < 1e-6 && Math.abs(view.getCamera().center[1] - camera0.center[1]) < 1e-6, JSON.stringify(view.getCamera().center))
108
+ check('拖动过程发 labelDrag 的 start / move / end', drags.length >= 3 && drags[0].phase === 'start' && drags[1].phase === 'move' && drags[drags.length - 1].phase === 'end' && drags[drags.length - 1].moved === true, drags.map(item => item.phase).join(','))
109
+ const dragState = view.getLabelStates('point').find(item => item.id === 'a')
110
+ check('拖出来的偏移记进状态(按 id 存)', Boolean(dragState) && Math.abs(dragState.offset[0] - 64) <= 1 && Math.abs(dragState.offset[1] - 36) <= 1, JSON.stringify(dragState?.offset ?? null))
111
+
112
+ // 2. 移动节点:偏移照旧,标签跟着节点一起走
113
+ const beforeMove = centerOf(pointLabel(0))
114
114
  view.updateData({ points: points.map(item => (item.id === 'a' ? { ...item, x: 120 } : item)) })
115
- await settle(200)
116
- const screenMoved = view.project(120, 0)
117
- const moveDelta = [screenMoved[0] - screen0[0], screenMoved[1] - screen0[1]]
118
- const afterMove = centerOf(pointLabel(0))
119
- check('移动节点:标签带着偏移跟着节点走', Math.abs(afterMove[0] - beforeMove[0] - moveDelta[0]) <= 2 && Math.abs(afterMove[1] - beforeMove[1] - moveDelta[1]) <= 2, `节点投影位移 ${moveDelta.map(value => value.toFixed(1)).join(' / ')},标签位移 ${[afterMove[0] - beforeMove[0], afterMove[1] - beforeMove[1]].map(value => value.toFixed(1)).join(' / ')}`)
120
- check('移动节点不会把偏移弄丢', Math.abs(view.getLabelStates('point').find(item => item.id === 'a')?.offset[0] - 64) <= 1, JSON.stringify(view.getLabelStates('point').find(item => item.id === 'a')?.offset ?? null))
121
-
122
- // 3. 恢复默认位置:回到「节点右侧 gap 处」,偏移清掉
123
- view.resetLabel('point', 'a')
124
- await settle(160)
125
- const resetCenter = centerOf(pointLabel(0))
126
- const screenReset = view.project(120, 0)
127
- check('resetLabel 恢复默认位置', Math.abs(resetCenter[0] - screenReset[0] - defaultGap[0]) <= 2 && Math.abs(resetCenter[1] - screenReset[1] - defaultGap[1]) <= 2, `期望 ${defaultGap.map(value => value.toFixed(1)).join(' / ')},实际 ${[resetCenter[0] - screenReset[0], resetCenter[1] - screenReset[1]].map(value => value.toFixed(1)).join(' / ')}`)
128
- check('恢复默认之后状态里不再有这一条', !view.getLabelStates('point').some(item => item.id === 'a'), JSON.stringify(view.getLabelStates('point').map(item => item.id)))
129
-
130
- // 4. 改内容 / 字号 / 颜色,再恢复默认
131
- view.updateLabel('point', 'a', { content: '改过的名字', style: { fontSize: '20px', color: 'rgb(224, 86, 160)' } })
132
- await settle(160)
133
- const editedNode = pointLabel(0)
134
- const editedStyle = getComputedStyle(editedNode)
135
- check('updateLabel 改内容 / 字号 / 颜色', editedNode.textContent.trim() === '改过的名字' && editedStyle.fontSize === '20px' && editedStyle.color === 'rgb(224, 86, 160)', `${editedNode.textContent.trim()} / ${editedStyle.fontSize} / ${editedStyle.color}`)
136
- check('改过的标签仍然留着背景底色(style 是增量合并)', editedStyle.backgroundColor !== 'rgba(0, 0, 0, 0)', editedStyle.backgroundColor)
137
- check('labelChange 事件带上 update 与 patch', changes.some(item => item.action === 'update' && item.kind === 'point' && item.id === 'a' && item.patch?.content === '改过的名字'), changes.map(item => item.action).join(','))
138
- view.resetLabel('point', 'a')
139
- await settle(160)
140
- const backNode = pointLabel(0)
141
- check('resetLabel 恢复默认内容与样式', backNode.textContent.trim() === '甲站' && getComputedStyle(backNode).fontSize !== '20px', `${backNode.textContent.trim()} / ${getComputedStyle(backNode).fontSize}`)
142
-
143
- // 5. 点一下标签(没拖动):发一条 labelClick
144
- const clickNode = pointLabel(1)
145
- const clickAt = centerOf(clickNode)
146
- fire(clickNode, 'pointerdown', clickAt[0], clickAt[1])
147
- fire(window, 'pointerup', clickAt[0], clickAt[1])
148
- await settle(60)
149
- check('点一下标签发 labelClick(编辑面板按它选中)', clicks.length === 1 && clicks[0].kind === 'point' && clicks[0].id === 'b' && clicks[0].state === null, JSON.stringify(clicks.map(item => item.id)))
150
-
151
- // 6. 线路标签:各拖一次,偏移两类分开记
152
- const lineNode = lineLabel(0)
153
- const lineStart = centerOf(lineNode)
154
- fire(lineNode, 'pointerdown', lineStart[0], lineStart[1])
155
- fire(window, 'pointermove', lineStart[0] + 40, lineStart[1] - 50)
156
- fire(window, 'pointerup', lineStart[0] + 40, lineStart[1] - 50)
157
- await settle(160)
158
- const lineAfter = centerOf(lineLabel(0))
159
- const lineStates = view.getLabelStates('line')
160
- check('线路标签也能拖,位移与指针一致', Math.abs(lineAfter[0] - lineStart[0] - 40) <= 2 && Math.abs(lineAfter[1] - lineStart[1] + 50) <= 2, `${(lineAfter[0] - lineStart[0]).toFixed(1)} / ${(lineAfter[1] - lineStart[1]).toFixed(1)}`)
161
- check('线路标签的偏移单独记一份', lineStates.length === 1 && lineStates[0].kind === 'line' && Math.abs(lineStates[0].offset[1] + 50) <= 1, JSON.stringify(lineStates.map(item => item.offset)))
162
- view.resetLabels('line')
163
- await settle(120)
164
- check('resetLabels 把这一类全部恢复默认', view.getLabelStates('line').length === 0 && changes.some(item => item.action === 'resetAll' && item.kind === 'line'), JSON.stringify(view.getLabelStates().map(item => item.kind)))
165
-
166
- // 6b. 线路标签分 5 步拖(每步 -10 像素,合计 -50):每一步的位移都要跟着指针走
167
- // 曾经的坑:每一步都拿「上一次的落点」当基准,位移会累加成 10+20+30+40+50 = 150,看着像标签自己飞走
168
- const waitLineLabel = async () => {
169
- for (let wait = 0; wait < 20 && !lineLabel(0); wait += 1) {
170
- await settle(40)
171
- }
172
- return lineLabel(0)
173
- }
174
- const stepNode = await waitLineLabel()
175
- const stepStart = centerOf(stepNode)
176
- const stepDrifts = []
177
- fire(stepNode, 'pointerdown', stepStart[0], stepStart[1])
178
- for (let step = 1; step <= 5; step += 1) {
179
- fire(window, 'pointermove', stepStart[0] - step * 10, stepStart[1])
180
- await settle(24)
181
- stepDrifts.push(centerOf(stepNode)[0] - stepStart[0] + step * 10)
182
- }
183
- fire(window, 'pointerup', stepStart[0] - 50, stepStart[1])
184
- await settle(200)
185
- const stepStates = view.getLabelStates('line')
186
- check('线路标签分 5 步拖:每一步都跟着指针走,不按步数累加', stepDrifts.every(drift => Math.abs(drift) <= 2), `每步偏差 ${stepDrifts.map(value => value.toFixed(1)).join(', ')}`)
187
- check('分步拖动记下来的偏移还是指针位移', stepStates.length === 1 && Math.abs(stepStates[0].offset[0] + 50) <= 1 && Math.abs(stepStates[0].offset[1]) <= 1, JSON.stringify(stepStates.map(item => item.offset)))
188
- view.resetLabels('line')
189
- await settle(200)
190
- // 7. 导出数据:多选分项(默认四份都要,选了通道 / 线路自动带上拐点)
191
- const all = view.exportData({ format: 'view' })
192
- const stationOnly = view.exportData({ format: 'view', parts: ['station'] })
193
- const stationChannel = view.exportData({ format: 'view', parts: ['station', 'channel'] })
194
- const lineOnly = view.exportData({ format: 'view', parts: ['line'] })
195
- check('导出数据:不写 parts 还是四份都要', ['points', 'lines', 'channels', 'parallelGroups'].every(key => Array.isArray(all[key])) && all.parts === undefined, Object.keys(all).join(','))
196
- check('导出数据:选场站就只出场站', Array.isArray(stationOnly.points) && stationOnly.points.length === 3 && stationOnly.lines === undefined && stationOnly.channels === undefined && stationOnly.parallelGroups === undefined, `${Object.keys(stationOnly).join(',')} · ${JSON.stringify(stationOnly.parts)}`)
197
- check('导出数据:选场站 + 通道时拐点自动带上、线路不要', Array.isArray(stationChannel.points) && Array.isArray(stationChannel.channels) && Array.isArray(stationChannel.parallelGroups) && stationChannel.lines === undefined, JSON.stringify(stationChannel.parts))
198
- check('导出数据:选线路也自动带上拐点', Array.isArray(lineOnly.lines) && lineOnly.lines.length === 2 && Array.isArray(lineOnly.parallelGroups) && lineOnly.points === undefined, JSON.stringify(lineOnly.parts))
199
- check('导出数据:不认识的分项记一条提示', view.exportData({ format: 'view', parts: ['station', 'abc'] }).warnings.some(text => text.includes('abc')), JSON.stringify(view.exportData({ format: 'view', parts: ['station', 'abc'] }).warnings))
200
-
201
- // 8. 导出选中数据:选中口径与 exportSelection 一致,出的是一份份业务表口径
115
+ await settle(200)
116
+ const screenMoved = view.project(120, 0)
117
+ const moveDelta = [screenMoved[0] - screen0[0], screenMoved[1] - screen0[1]]
118
+ const afterMove = centerOf(pointLabel(0))
119
+ check('移动节点:标签带着偏移跟着节点走', Math.abs(afterMove[0] - beforeMove[0] - moveDelta[0]) <= 2 && Math.abs(afterMove[1] - beforeMove[1] - moveDelta[1]) <= 2, `节点投影位移 ${moveDelta.map(value => value.toFixed(1)).join(' / ')},标签位移 ${[afterMove[0] - beforeMove[0], afterMove[1] - beforeMove[1]].map(value => value.toFixed(1)).join(' / ')}`)
120
+ check('移动节点不会把偏移弄丢', Math.abs(view.getLabelStates('point').find(item => item.id === 'a')?.offset[0] - 64) <= 1, JSON.stringify(view.getLabelStates('point').find(item => item.id === 'a')?.offset ?? null))
121
+
122
+ // 3. 恢复默认位置:回到「节点右侧 gap 处」,偏移清掉
123
+ view.resetLabel('point', 'a')
124
+ await settle(160)
125
+ const resetCenter = centerOf(pointLabel(0))
126
+ const screenReset = view.project(120, 0)
127
+ check('resetLabel 恢复默认位置', Math.abs(resetCenter[0] - screenReset[0] - defaultGap[0]) <= 2 && Math.abs(resetCenter[1] - screenReset[1] - defaultGap[1]) <= 2, `期望 ${defaultGap.map(value => value.toFixed(1)).join(' / ')},实际 ${[resetCenter[0] - screenReset[0], resetCenter[1] - screenReset[1]].map(value => value.toFixed(1)).join(' / ')}`)
128
+ check('恢复默认之后状态里不再有这一条', !view.getLabelStates('point').some(item => item.id === 'a'), JSON.stringify(view.getLabelStates('point').map(item => item.id)))
129
+
130
+ // 4. 改内容 / 字号 / 颜色,再恢复默认
131
+ view.updateLabel('point', 'a', { content: '改过的名字', style: { fontSize: '20px', color: 'rgb(224, 86, 160)' } })
132
+ await settle(160)
133
+ const editedNode = pointLabel(0)
134
+ const editedStyle = getComputedStyle(editedNode)
135
+ check('updateLabel 改内容 / 字号 / 颜色', editedNode.textContent.trim() === '改过的名字' && editedStyle.fontSize === '20px' && editedStyle.color === 'rgb(224, 86, 160)', `${editedNode.textContent.trim()} / ${editedStyle.fontSize} / ${editedStyle.color}`)
136
+ check('改过的标签仍然留着背景底色(style 是增量合并)', editedStyle.backgroundColor !== 'rgba(0, 0, 0, 0)', editedStyle.backgroundColor)
137
+ check('labelChange 事件带上 update 与 patch', changes.some(item => item.action === 'update' && item.kind === 'point' && item.id === 'a' && item.patch?.content === '改过的名字'), changes.map(item => item.action).join(','))
138
+ view.resetLabel('point', 'a')
139
+ await settle(160)
140
+ const backNode = pointLabel(0)
141
+ check('resetLabel 恢复默认内容与样式', backNode.textContent.trim() === '甲站' && getComputedStyle(backNode).fontSize !== '20px', `${backNode.textContent.trim()} / ${getComputedStyle(backNode).fontSize}`)
142
+
143
+ // 5. 点一下标签(没拖动):发一条 labelClick
144
+ const clickNode = pointLabel(1)
145
+ const clickAt = centerOf(clickNode)
146
+ fire(clickNode, 'pointerdown', clickAt[0], clickAt[1])
147
+ fire(window, 'pointerup', clickAt[0], clickAt[1])
148
+ await settle(60)
149
+ check('点一下标签发 labelClick(编辑面板按它选中)', clicks.length === 1 && clicks[0].kind === 'point' && clicks[0].id === 'b' && clicks[0].state === null, JSON.stringify(clicks.map(item => item.id)))
150
+
151
+ // 6. 线路标签:各拖一次,偏移两类分开记
152
+ const lineNode = lineLabel(0)
153
+ const lineStart = centerOf(lineNode)
154
+ fire(lineNode, 'pointerdown', lineStart[0], lineStart[1])
155
+ fire(window, 'pointermove', lineStart[0] + 40, lineStart[1] - 50)
156
+ fire(window, 'pointerup', lineStart[0] + 40, lineStart[1] - 50)
157
+ await settle(160)
158
+ const lineAfter = centerOf(lineLabel(0))
159
+ const lineStates = view.getLabelStates('line')
160
+ check('线路标签也能拖,位移与指针一致', Math.abs(lineAfter[0] - lineStart[0] - 40) <= 2 && Math.abs(lineAfter[1] - lineStart[1] + 50) <= 2, `${(lineAfter[0] - lineStart[0]).toFixed(1)} / ${(lineAfter[1] - lineStart[1]).toFixed(1)}`)
161
+ check('线路标签的偏移单独记一份', lineStates.length === 1 && lineStates[0].kind === 'line' && Math.abs(lineStates[0].offset[1] + 50) <= 1, JSON.stringify(lineStates.map(item => item.offset)))
162
+ view.resetLabels('line')
163
+ await settle(120)
164
+ check('resetLabels 把这一类全部恢复默认', view.getLabelStates('line').length === 0 && changes.some(item => item.action === 'resetAll' && item.kind === 'line'), JSON.stringify(view.getLabelStates().map(item => item.kind)))
165
+
166
+ // 6b. 线路标签分 5 步拖(每步 -10 像素,合计 -50):每一步的位移都要跟着指针走
167
+ // 曾经的坑:每一步都拿「上一次的落点」当基准,位移会累加成 10+20+30+40+50 = 150,看着像标签自己飞走
168
+ const waitLineLabel = async () => {
169
+ for (let wait = 0; wait < 20 && !lineLabel(0); wait += 1) {
170
+ await settle(40)
171
+ }
172
+ return lineLabel(0)
173
+ }
174
+ const stepNode = await waitLineLabel()
175
+ const stepStart = centerOf(stepNode)
176
+ const stepDrifts = []
177
+ fire(stepNode, 'pointerdown', stepStart[0], stepStart[1])
178
+ for (let step = 1; step <= 5; step += 1) {
179
+ fire(window, 'pointermove', stepStart[0] - step * 10, stepStart[1])
180
+ await settle(24)
181
+ stepDrifts.push(centerOf(stepNode)[0] - stepStart[0] + step * 10)
182
+ }
183
+ fire(window, 'pointerup', stepStart[0] - 50, stepStart[1])
184
+ await settle(200)
185
+ const stepStates = view.getLabelStates('line')
186
+ check('线路标签分 5 步拖:每一步都跟着指针走,不按步数累加', stepDrifts.every(drift => Math.abs(drift) <= 2), `每步偏差 ${stepDrifts.map(value => value.toFixed(1)).join(', ')}`)
187
+ check('分步拖动记下来的偏移还是指针位移', stepStates.length === 1 && Math.abs(stepStates[0].offset[0] + 50) <= 1 && Math.abs(stepStates[0].offset[1]) <= 1, JSON.stringify(stepStates.map(item => item.offset)))
188
+ view.resetLabels('line')
189
+ await settle(200)
190
+ // 7. 导出数据:多选分项(默认四份都要,选了通道 / 线路自动带上拐点)
191
+ const all = view.exportData({ format: 'view' })
192
+ const stationOnly = view.exportData({ format: 'view', parts: ['station'] })
193
+ const stationChannel = view.exportData({ format: 'view', parts: ['station', 'channel'] })
194
+ const lineOnly = view.exportData({ format: 'view', parts: ['line'] })
195
+ check('导出数据:不写 parts 还是四份都要', ['points', 'lines', 'channels', 'parallelGroups'].every(key => Array.isArray(all[key])) && all.parts === undefined, Object.keys(all).join(','))
196
+ check('导出数据:选场站就只出场站', Array.isArray(stationOnly.points) && stationOnly.points.length === 3 && stationOnly.lines === undefined && stationOnly.channels === undefined && stationOnly.parallelGroups === undefined, `${Object.keys(stationOnly).join(',')} · ${JSON.stringify(stationOnly.parts)}`)
197
+ check('导出数据:选场站 + 通道时拐点自动带上、线路不要', Array.isArray(stationChannel.points) && Array.isArray(stationChannel.channels) && Array.isArray(stationChannel.parallelGroups) && stationChannel.lines === undefined, JSON.stringify(stationChannel.parts))
198
+ check('导出数据:选线路也自动带上拐点', Array.isArray(lineOnly.lines) && lineOnly.lines.length === 2 && Array.isArray(lineOnly.parallelGroups) && lineOnly.points === undefined, JSON.stringify(lineOnly.parts))
199
+ check('导出数据:不认识的分项记一条提示', view.exportData({ format: 'view', parts: ['station', 'abc'] }).warnings.some(text => text.includes('abc')), JSON.stringify(view.exportData({ format: 'view', parts: ['station', 'abc'] }).warnings))
200
+
201
+ // 8. 导出选中数据:选中口径与 exportSelection 一致,出的是一份份业务表口径
202
202
  const selection = view.exportData({ scope: 'selection', points: ['a', 'b'], parts: ['station', 'channel'] })
203
- check('导出选中数据:场站 + 通道(只留选中集里的回线)', selection.ok === true && selection.points.length === 2 && Array.isArray(selection.channels) && Array.isArray(selection.parallelGroups) && selection.lines === undefined, `points ${selection.points.length} / channels ${selection.channels.length} / parts ${JSON.stringify(selection.parts)}`)
203
+ check('导出选中数据:场站 + 通道(只留选中集里的回线)', selection.ok === true && selection.points.length === 2 && Array.isArray(selection.channels) && Array.isArray(selection.parallelGroups) && selection.lines === undefined, `points ${selection.points.length} / channels ${selection.channels.length} / parts ${JSON.stringify(selection.parts)}`)
204
204
  const selectionLines = view.exportData({ scope: 'selection', points: ['a', 'b'], parts: ['line'] })
205
- check('导出选中数据:只取线路时,两端都在选中集里的才算', selectionLines.ok === true && selectionLines.lines.length === 1 && selectionLines.lines[0].line_id === 'l1' && selectionLines.points === undefined, `lines ${selectionLines.lines.map(item => item.line_id).join(',')}`)
205
+ check('导出选中数据:只取线路时,两端都在选中集里的才算', selectionLines.ok === true && selectionLines.lines.length === 1 && selectionLines.lines[0].line_id === 'l1' && selectionLines.points === undefined, `lines ${selectionLines.lines.map(item => item.line_id).join(',')}`)
206
206
  const noRefs = view.exportData({ scope: 'selection', points: ['nope'] })
207
- check('导出选中数据:引用解不出来时 ok:false + warnings', noRefs.ok === false && noRefs.warnings.length === 1 && noRefs.points === undefined, JSON.stringify(noRefs.warnings))
207
+ check('导出选中数据:引用解不出来时 ok:false + warnings', noRefs.ok === false && noRefs.warnings.length === 1 && noRefs.points === undefined, JSON.stringify(noRefs.warnings))
208
208
  view.selectFeatures(['a', 'b'])
209
- await settle(60)
209
+ await settle(60)
210
210
  const fromSelection = view.exportData({ scope: 'selection', parts: ['station'] })
211
- check('导出选中数据:不给 points 时用当前选中集', fromSelection.ok === true && fromSelection.pointIndexes.length === 2, JSON.stringify(fromSelection.pointIndexes))
212
-
213
- // 9. 同一要素多条标签:数组配置 + 数据里的 labels(位置 / 样式 / 偏移逐条算),导出再导入要能回显
214
- const multiHost = document.createElement('div')
215
- multiHost.style.cssText = 'width:640px;height:380px'
216
- document.body.append(multiHost)
217
- const multiPoints = [
218
- { id: 'a', x: 0, y: 0, name: '甲站', vn_kv: 500 },
219
- {
220
- id: 'b',
221
- x: 260,
222
- y: 0,
223
- name: '乙站',
224
- vn_kv: 220,
225
- labels: [
226
- { id: 'name', content: '{name}', position: 'top' },
227
- { id: 'volt', content: '{vn_kv} kV', position: 'bottom', offset: [6, 2], style: { color: 'rgb(200, 40, 40)' } }
228
- ]
229
- }
230
- ]
231
- const multiLines = [{ id: 'l1', from: 'a', to: 'b', name: '甲乙线', labels: [{ id: 'kind', content: '线路', position: 0.25 }] }]
232
- const multi = createGisView(multiHost, {
233
- points: multiPoints,
234
- lines: multiLines,
235
- label: [
236
- { id: 'name', content: '{name}', position: 'right', maxCount: 5 },
237
- { id: 'volt', content: '{vn_kv} kV', position: 'left', maxCount: 5, style: { color: 'rgb(0, 90, 200)' } }
238
- ],
239
- lineLabel: [
240
- { id: 'default', content: '{name}', maxCount: 5 },
241
- { id: 'kind', content: 'K', maxCount: 5, parallel: true }
242
- ],
243
- showGrid: false
244
- })
245
- await settle(240)
246
- const labelsOf = (host, kind, index) => [...host.querySelectorAll(`.gis-view__label[data-label-kind='${kind}'][data-label-index='${index}']`)]
247
- const labelById = (host, kind, index, id) => labelsOf(host, kind, index).find(node => node.dataset.labelLabel === id)
248
- check('数组配置:一个节点上摆多条标签', labelsOf(multiHost, 'point', 0).length === 2, labelsOf(multiHost, 'point', 0).map(node => node.dataset.labelLabel).join(','))
249
- check('多条标签各用各的位置', labelById(multiHost, 'point', 0, 'volt')?.classList.contains('gis-view__label--left') === true && labelById(multiHost, 'point', 0, 'name')?.classList.contains('gis-view__label--right') === true, labelsOf(multiHost, 'point', 0).map(node => `${node.dataset.labelLabel}:${node.className}`).join(' | '))
250
- check('数据里的 labels 按 id 覆盖那一条(位置 / 样式)', labelsOf(multiHost, 'point', 1).length === 2 && labelById(multiHost, 'point', 1, 'volt')?.classList.contains('gis-view__label--bottom') === true && getComputedStyle(labelById(multiHost, 'point', 1, 'volt')).color === 'rgb(200, 40, 40)', labelsOf(multiHost, 'point', 1).map(node => `${node.dataset.labelLabel}:${getComputedStyle(node).color}`).join(' | '))
251
- check('线路标签也能多条(配置里的 + 数据里多出来的)', labelsOf(multiHost, 'line', 0).length === 2, labelsOf(multiHost, 'line', 0).map(node => node.dataset.labelLabel).join(','))
252
- const listB = multi.getLabels('point', 'b')
253
- check('getLabels 给出一条要素上的标签清单', Array.isArray(listB) && listB.map(item => item.id).join(',') === 'name,volt', JSON.stringify(listB?.map(item => item.id)))
254
- check('getLabels 每条都说清是哪来的(配置 / 数据 / 运行时加的)', listB?.map(item => `${item.id}:${item.source}`).join(',') === 'name:data,volt:data', JSON.stringify(listB?.map(item => item.source)))
255
- check('getLabels / updateLabel 指向不存在的标签时返回 null 并记提示', multi.getLabels('point', 'nope') === null && multi.updateLabel('point', 'a', { labelId: 'nope', content: 'x' }) === null, JSON.stringify(multi.exportData({ format: 'view', parts: ['station'] }).warnings.slice(-1)))
256
- const edited = multi.updateLabel('point', 'b', { labelId: 'volt', content: '改过', style: { fontSize: '18px' } })
257
- await settle(160)
258
- const voltB = labelById(multiHost, 'point', 1, 'volt')
259
- check('updateLabel 带 labelId 只改这一条', edited?.labelId === 'volt' && voltB?.textContent.trim() === '改过' && getComputedStyle(voltB).fontSize === '18px' && getComputedStyle(voltB).color === 'rgb(200, 40, 40)', `${voltB?.textContent} / ${voltB ? getComputedStyle(voltB).fontSize : ''}`)
260
- check('同一要素上的另一条标签不受影响', labelById(multiHost, 'point', 1, 'name')?.textContent.trim() === '乙站', labelById(multiHost, 'point', 1, 'name')?.textContent)
261
- const added = multi.addLabel('point', 'a', { content: '运行时加的', position: 'bottom', style: { color: 'rgb(10, 130, 10)' } })
262
- await settle(160)
263
- check('addLabel 运行时加一条(id 自动 note1)', added?.labelId === 'note1' && labelsOf(multiHost, 'point', 0).length === 3, `${added?.labelId} / ${labelsOf(multiHost, 'point', 0).length}`)
264
- check('getLabels 能看到运行时加的那一条(source 说明是运行时加的)', (multi.getLabels('point', 'a') ?? []).some(item => item.id === 'note1' && item.added === true && item.source === 'runtime'), JSON.stringify((multi.getLabels('point', 'a') ?? []).map(item => `${item.id}:${item.source}`)))
265
- const removed = multi.removeLabel('point', 'b', 'name')
266
- await settle(160)
267
- check('removeLabel 把声明的那一条改成隐藏', removed?.removed === false && removed?.hidden === true && labelById(multiHost, 'point', 1, 'name') === undefined, JSON.stringify(removed))
268
- check('隐藏记在状态里(getLabels / getLabelStates 都看得到)', multi.getLabels('point', 'b')?.find(item => item.id === 'name')?.hidden === true && multi.getLabelStates('point').some(state => state.labelId === 'name' && state.hidden === true), JSON.stringify(multi.getLabelStates('point').map(state => [state.labelId, state.hidden])))
269
- const shown = multi.updateLabel('point', 'b', { labelId: 'name', hidden: false })
270
- await settle(160)
271
- check('updateLabel 写 hidden: false 把隐藏撤回来', shown?.hidden === null && labelById(multiHost, 'point', 1, 'name') !== undefined, JSON.stringify(shown))
272
- // 10. 导出:labels 列带上内容 / 样式 / 偏移 / 隐藏,喂回去就能回显
273
- multi.removeLabel('point', 'b', 'name')
274
- await settle(160)
275
- const exported = multi.exportData({ format: 'view', parts: ['station'] })
276
- const rowB = exported.points.find(row => row.station_id === 'b')
277
- const rowA = exported.points.find(row => row.station_id === 'a')
278
- check('导出数据带上 labels(内容 / 位置 / 偏移 / 样式)', rowB?.labels?.find(item => item.id === 'volt')?.content === '改过' && rowB.labels.find(item => item.id === 'volt')?.style?.fontSize === '18px' && JSON.stringify(rowB.labels.find(item => item.id === 'volt')?.offset) === '[6,2]' && rowB.labels.find(item => item.id === 'volt')?.position === 'bottom', JSON.stringify(rowB?.labels ?? null))
279
- check('导出数据带上运行时加的标签', rowA?.labels?.some(item => item.id === 'note1' && item.content === '运行时加的') === true, JSON.stringify(rowA?.labels ?? null))
280
- check('导出数据带上隐藏的那一条', rowB?.labels?.find(item => item.id === 'name')?.hidden === true, JSON.stringify(rowB?.labels ?? null))
281
- check('labels: false 时一条都不带', multi.exportData({ format: 'view', parts: ['station'], labels: false }).points.every(row => row.labels === undefined), JSON.stringify(multi.exportData({ format: 'view', parts: ['station'], labels: false }).points[0]))
282
- check('labels 写不认识的值记一条提示', multi.exportData({ format: 'view', parts: ['station'], labels: 'x' }).warnings.some(text => text.includes('labels')), JSON.stringify(multi.exportData({ format: 'view', parts: ['station'], labels: 'x' }).warnings))
283
- // 回显:把导出的记录原样喂回去(不配 label,只靠数据里的 labels 画出来)
284
- const reHost = document.createElement('div')
285
- reHost.style.cssText = 'width:640px;height:380px'
286
- document.body.append(reHost)
287
- const reView = createGisView(reHost, {
288
- points: exported.points.map(row => ({ id: row.station_id, x: row.lon, y: row.lat, name: row.station_name, vn_kv: row.vn_kv, labels: row.labels })),
289
- lines: [],
290
- showGrid: false
291
- })
292
- await settle(240)
293
- const reLabels = [...reHost.querySelectorAll('.gis-view__label')]
294
- check('导出后再导入能回显(不配 label 也画得出来)', reLabels.length === 4, `${reLabels.length} 条:${reLabels.map(node => node.dataset.labelLabel + ':' + node.textContent).join(' | ')}`)
295
- check('回显的样式跟着走', reLabels.some(node => node.dataset.labelLabel === 'note1' && getComputedStyle(node).color === 'rgb(10, 130, 10)'), reLabels.map(node => `${node.dataset.labelLabel}:${getComputedStyle(node).color}`).join(' | '))
296
- check('回显保持隐藏的那一条不画', labelById(reHost, 'point', 1, 'name') === undefined, labelsOf(reHost, 'point', 1).map(node => node.dataset.labelLabel).join(','))
297
- // 11. setLabelStates:把状态整份灌回一个新实例
298
- const states = multi.getLabelStates('point')
299
- check('新实例一开始没有运行时状态(回显全靠数据)', reView.getLabelStates('point').length === 0, String(reView.getLabelStates('point').length))
300
- const reStates = reView.setLabelStates(states)
301
- await settle(200)
302
- check('setLabelStates 把状态整份灌回去', reStates.applied.length === states.length && reView.getLabelStates('point').length === states.length, `${reStates.applied.length}/${states.length} applied, skipped ${reStates.skipped.length}`)
303
- check('回灌之后状态里带着改过的内容与样式', reView.getLabelStates('point').some(state => state.labelId === 'volt' && state.content === '改过' && state.style?.fontSize === '18px'), JSON.stringify(reView.getLabelStates('point').map(state => [state.labelId, state.content])))
304
- // 12. 运行时加的标签后来被写回数据(导出再导入那一步)之后再删它:连数据里声明的那一份一起收起来
305
- const claimHost = document.createElement('div')
306
- claimHost.style.cssText = 'width:320px;height:220px'
307
- document.body.append(claimHost)
308
- const claimView = createGisView(claimHost, { points: [{ id: 'p1', x: 0, y: 0, name: '甲' }], lines: [], label: { content: '{name}' }, showGrid: false })
309
- await settle(240)
310
- claimView.addLabel('point', 'p1', { content: '备注', position: 'bottom' })
311
- claimView.updatePoint('p1', { labels: [{ id: 'default' }, { id: 'note1', content: '备注', position: 'bottom' }] })
312
- await settle(240)
313
- const claimRemove = claimView.removeLabel('point', 'p1', 'note1')
314
- await settle(240)
315
- check('运行时加的标签写回数据之后再删:连数据里声明的那一份一起收起来', claimRemove?.removed === true && claimRemove?.hidden === true && claimHost.querySelectorAll('.gis-view__label').length === 1, `${JSON.stringify(claimRemove)} · 剩 ${claimHost.querySelectorAll('.gis-view__label').length} 条标签`)
211
+ check('导出选中数据:不给 points 时用当前选中集', fromSelection.ok === true && fromSelection.pointIndexes.length === 2, JSON.stringify(fromSelection.pointIndexes))
212
+
213
+ // 9. 同一要素多条标签:数组配置 + 数据里的 labels(位置 / 样式 / 偏移逐条算),导出再导入要能回显
214
+ const multiHost = document.createElement('div')
215
+ multiHost.style.cssText = 'width:640px;height:380px'
216
+ document.body.append(multiHost)
217
+ const multiPoints = [
218
+ { id: 'a', x: 0, y: 0, name: '甲站', vn_kv: 500 },
219
+ {
220
+ id: 'b',
221
+ x: 260,
222
+ y: 0,
223
+ name: '乙站',
224
+ vn_kv: 220,
225
+ labels: [
226
+ { id: 'name', content: '{name}', position: 'top' },
227
+ { id: 'volt', content: '{vn_kv} kV', position: 'bottom', offset: [6, 2], style: { color: 'rgb(200, 40, 40)' } }
228
+ ]
229
+ }
230
+ ]
231
+ const multiLines = [{ id: 'l1', from: 'a', to: 'b', name: '甲乙线', labels: [{ id: 'kind', content: '线路', position: 0.25 }] }]
232
+ const multi = createGisView(multiHost, {
233
+ points: multiPoints,
234
+ lines: multiLines,
235
+ label: [
236
+ { id: 'name', content: '{name}', position: 'right', maxCount: 5 },
237
+ { id: 'volt', content: '{vn_kv} kV', position: 'left', maxCount: 5, style: { color: 'rgb(0, 90, 200)' } }
238
+ ],
239
+ lineLabel: [
240
+ { id: 'default', content: '{name}', maxCount: 5 },
241
+ { id: 'kind', content: 'K', maxCount: 5, parallel: true }
242
+ ],
243
+ showGrid: false
244
+ })
245
+ await settle(240)
246
+ const labelsOf = (host, kind, index) => [...host.querySelectorAll(`.gis-view__label[data-label-kind='${kind}'][data-label-index='${index}']`)]
247
+ const labelById = (host, kind, index, id) => labelsOf(host, kind, index).find(node => node.dataset.labelLabel === id)
248
+ check('数组配置:一个节点上摆多条标签', labelsOf(multiHost, 'point', 0).length === 2, labelsOf(multiHost, 'point', 0).map(node => node.dataset.labelLabel).join(','))
249
+ check('多条标签各用各的位置', labelById(multiHost, 'point', 0, 'volt')?.classList.contains('gis-view__label--left') === true && labelById(multiHost, 'point', 0, 'name')?.classList.contains('gis-view__label--right') === true, labelsOf(multiHost, 'point', 0).map(node => `${node.dataset.labelLabel}:${node.className}`).join(' | '))
250
+ check('数据里的 labels 按 id 覆盖那一条(位置 / 样式)', labelsOf(multiHost, 'point', 1).length === 2 && labelById(multiHost, 'point', 1, 'volt')?.classList.contains('gis-view__label--bottom') === true && getComputedStyle(labelById(multiHost, 'point', 1, 'volt')).color === 'rgb(200, 40, 40)', labelsOf(multiHost, 'point', 1).map(node => `${node.dataset.labelLabel}:${getComputedStyle(node).color}`).join(' | '))
251
+ check('线路标签也能多条(配置里的 + 数据里多出来的)', labelsOf(multiHost, 'line', 0).length === 2, labelsOf(multiHost, 'line', 0).map(node => node.dataset.labelLabel).join(','))
252
+ const listB = multi.getLabels('point', 'b')
253
+ check('getLabels 给出一条要素上的标签清单', Array.isArray(listB) && listB.map(item => item.id).join(',') === 'name,volt', JSON.stringify(listB?.map(item => item.id)))
254
+ check('getLabels 每条都说清是哪来的(配置 / 数据 / 运行时加的)', listB?.map(item => `${item.id}:${item.source}`).join(',') === 'name:data,volt:data', JSON.stringify(listB?.map(item => item.source)))
255
+ check('getLabels / updateLabel 指向不存在的标签时返回 null 并记提示', multi.getLabels('point', 'nope') === null && multi.updateLabel('point', 'a', { labelId: 'nope', content: 'x' }) === null, JSON.stringify(multi.exportData({ format: 'view', parts: ['station'] }).warnings.slice(-1)))
256
+ const edited = multi.updateLabel('point', 'b', { labelId: 'volt', content: '改过', style: { fontSize: '18px' } })
257
+ await settle(160)
258
+ const voltB = labelById(multiHost, 'point', 1, 'volt')
259
+ check('updateLabel 带 labelId 只改这一条', edited?.labelId === 'volt' && voltB?.textContent.trim() === '改过' && getComputedStyle(voltB).fontSize === '18px' && getComputedStyle(voltB).color === 'rgb(200, 40, 40)', `${voltB?.textContent} / ${voltB ? getComputedStyle(voltB).fontSize : ''}`)
260
+ check('同一要素上的另一条标签不受影响', labelById(multiHost, 'point', 1, 'name')?.textContent.trim() === '乙站', labelById(multiHost, 'point', 1, 'name')?.textContent)
261
+ const added = multi.addLabel('point', 'a', { content: '运行时加的', position: 'bottom', style: { color: 'rgb(10, 130, 10)' } })
262
+ await settle(160)
263
+ check('addLabel 运行时加一条(id 自动 note1)', added?.labelId === 'note1' && labelsOf(multiHost, 'point', 0).length === 3, `${added?.labelId} / ${labelsOf(multiHost, 'point', 0).length}`)
264
+ check('getLabels 能看到运行时加的那一条(source 说明是运行时加的)', (multi.getLabels('point', 'a') ?? []).some(item => item.id === 'note1' && item.added === true && item.source === 'runtime'), JSON.stringify((multi.getLabels('point', 'a') ?? []).map(item => `${item.id}:${item.source}`)))
265
+ const removed = multi.removeLabel('point', 'b', 'name')
266
+ await settle(160)
267
+ check('removeLabel 把声明的那一条改成隐藏', removed?.removed === false && removed?.hidden === true && labelById(multiHost, 'point', 1, 'name') === undefined, JSON.stringify(removed))
268
+ check('隐藏记在状态里(getLabels / getLabelStates 都看得到)', multi.getLabels('point', 'b')?.find(item => item.id === 'name')?.hidden === true && multi.getLabelStates('point').some(state => state.labelId === 'name' && state.hidden === true), JSON.stringify(multi.getLabelStates('point').map(state => [state.labelId, state.hidden])))
269
+ const shown = multi.updateLabel('point', 'b', { labelId: 'name', hidden: false })
270
+ await settle(160)
271
+ check('updateLabel 写 hidden: false 把隐藏撤回来', shown?.hidden === null && labelById(multiHost, 'point', 1, 'name') !== undefined, JSON.stringify(shown))
272
+ // 10. 导出:labels 列带上内容 / 样式 / 偏移 / 隐藏,喂回去就能回显
273
+ multi.removeLabel('point', 'b', 'name')
274
+ await settle(160)
275
+ const exported = multi.exportData({ format: 'view', parts: ['station'] })
276
+ const rowB = exported.points.find(row => row.station_id === 'b')
277
+ const rowA = exported.points.find(row => row.station_id === 'a')
278
+ check('导出数据带上 labels(内容 / 位置 / 偏移 / 样式)', rowB?.labels?.find(item => item.id === 'volt')?.content === '改过' && rowB.labels.find(item => item.id === 'volt')?.style?.fontSize === '18px' && JSON.stringify(rowB.labels.find(item => item.id === 'volt')?.offset) === '[6,2]' && rowB.labels.find(item => item.id === 'volt')?.position === 'bottom', JSON.stringify(rowB?.labels ?? null))
279
+ check('导出数据带上运行时加的标签', rowA?.labels?.some(item => item.id === 'note1' && item.content === '运行时加的') === true, JSON.stringify(rowA?.labels ?? null))
280
+ check('导出数据带上隐藏的那一条', rowB?.labels?.find(item => item.id === 'name')?.hidden === true, JSON.stringify(rowB?.labels ?? null))
281
+ check('labels: false 时一条都不带', multi.exportData({ format: 'view', parts: ['station'], labels: false }).points.every(row => row.labels === undefined), JSON.stringify(multi.exportData({ format: 'view', parts: ['station'], labels: false }).points[0]))
282
+ check('labels 写不认识的值记一条提示', multi.exportData({ format: 'view', parts: ['station'], labels: 'x' }).warnings.some(text => text.includes('labels')), JSON.stringify(multi.exportData({ format: 'view', parts: ['station'], labels: 'x' }).warnings))
283
+ // 回显:把导出的记录原样喂回去(不配 label,只靠数据里的 labels 画出来)
284
+ const reHost = document.createElement('div')
285
+ reHost.style.cssText = 'width:640px;height:380px'
286
+ document.body.append(reHost)
287
+ const reView = createGisView(reHost, {
288
+ points: exported.points.map(row => ({ id: row.station_id, x: row.lon, y: row.lat, name: row.station_name, vn_kv: row.vn_kv, labels: row.labels })),
289
+ lines: [],
290
+ showGrid: false
291
+ })
292
+ await settle(240)
293
+ const reLabels = [...reHost.querySelectorAll('.gis-view__label')]
294
+ check('导出后再导入能回显(不配 label 也画得出来)', reLabels.length === 4, `${reLabels.length} 条:${reLabels.map(node => node.dataset.labelLabel + ':' + node.textContent).join(' | ')}`)
295
+ check('回显的样式跟着走', reLabels.some(node => node.dataset.labelLabel === 'note1' && getComputedStyle(node).color === 'rgb(10, 130, 10)'), reLabels.map(node => `${node.dataset.labelLabel}:${getComputedStyle(node).color}`).join(' | '))
296
+ check('回显保持隐藏的那一条不画', labelById(reHost, 'point', 1, 'name') === undefined, labelsOf(reHost, 'point', 1).map(node => node.dataset.labelLabel).join(','))
297
+ // 11. setLabelStates:把状态整份灌回一个新实例
298
+ const states = multi.getLabelStates('point')
299
+ check('新实例一开始没有运行时状态(回显全靠数据)', reView.getLabelStates('point').length === 0, String(reView.getLabelStates('point').length))
300
+ const reStates = reView.setLabelStates(states)
301
+ await settle(200)
302
+ check('setLabelStates 把状态整份灌回去', reStates.applied.length === states.length && reView.getLabelStates('point').length === states.length, `${reStates.applied.length}/${states.length} applied, skipped ${reStates.skipped.length}`)
303
+ check('回灌之后状态里带着改过的内容与样式', reView.getLabelStates('point').some(state => state.labelId === 'volt' && state.content === '改过' && state.style?.fontSize === '18px'), JSON.stringify(reView.getLabelStates('point').map(state => [state.labelId, state.content])))
304
+ // 12. 运行时加的标签后来被写回数据(导出再导入那一步)之后再删它:连数据里声明的那一份一起收起来
305
+ const claimHost = document.createElement('div')
306
+ claimHost.style.cssText = 'width:320px;height:220px'
307
+ document.body.append(claimHost)
308
+ const claimView = createGisView(claimHost, { points: [{ id: 'p1', x: 0, y: 0, name: '甲' }], lines: [], label: { content: '{name}' }, showGrid: false })
309
+ await settle(240)
310
+ claimView.addLabel('point', 'p1', { content: '备注', position: 'bottom' })
311
+ claimView.updatePoint('p1', { labels: [{ id: 'default' }, { id: 'note1', content: '备注', position: 'bottom' }] })
312
+ await settle(240)
313
+ const claimRemove = claimView.removeLabel('point', 'p1', 'note1')
314
+ await settle(240)
315
+ check('运行时加的标签写回数据之后再删:连数据里声明的那一份一起收起来', claimRemove?.removed === true && claimRemove?.hidden === true && claimHost.querySelectorAll('.gis-view__label').length === 1, `${JSON.stringify(claimRemove)} · 剩 ${claimHost.querySelectorAll('.gis-view__label').length} 条标签`)
316
316
  claimView.destroy()
317
317
  claimHost.remove()
318
318
  // 12.5 追加要素之后数据标签读数就地补上(见 create 的 extendDataLabelsOnAppend):新来的标签当场画出来、
@@ -347,70 +347,70 @@
347
347
  check('整份重建之后标签集合逐条一致(就地补的读数与重扫同一口径)', JSON.stringify(appendTexts()) === JSON.stringify(appendBeforeRefresh), `${appendBeforeRefresh.join(' | ')} → ${appendTexts().join(' | ')}`)
348
348
  appendView.destroy()
349
349
  appendHost.remove()
350
- // 13. 标签配置里写错的键:创建 / updateOptions 时都点名,带上是哪一组、第几条
351
- const warnHost = document.createElement('div')
352
- warnHost.style.cssText = 'width:320px;height:220px'
353
- document.body.append(warnHost)
354
- const labelWarnings = []
355
- const warnView = createGisView(warnHost, {
356
- points: [{ id: 'p1', x: 0, y: 0, name: '甲' }],
357
- lines: [],
358
- label: { content: '{name}', positon: 'right' },
359
- lineLabel: [{ content: '{name}' }, { content: '{name}', parrallel: true }],
360
- showGrid: false,
361
- onWarning: message => labelWarnings.push(message)
362
- })
363
- warnView.updateOptions({ label: { content: '{name}', coontent: 'x' } })
364
- warnView.destroy()
365
- warnHost.remove()
366
- check('标签配置里写错的键直接点名(哪一组、第几条、哪个键)', labelWarnings.some(text => text.includes('positon') && text.includes('label 第 1 条')) && labelWarnings.some(text => text.includes('parrallel') && text.includes('lineLabel 第 2 条')) && labelWarnings.some(text => text.includes('coontent')), `${labelWarnings.length} 条:${labelWarnings.join(' / ')}`)
367
- // 14. 运行时加的标签:不用自己写 draggable / editable 也能拖能点,位置现给;事件载荷带 label(内容 / 来源)与 parent(所属要素)
368
- const addHost = document.createElement('div')
369
- addHost.style.cssText = 'width:320px;height:220px'
370
- document.body.append(addHost)
371
- const addClicks = []
372
- const addDrags = []
373
- const addView = createGisView(addHost, {
374
- points: [{ id: 'p1', x: 0, y: 0, name: '甲' }],
375
- lines: [],
376
- label: { enabled: true, content: '{name}', maxCount: 5, draggable: true, editable: true },
377
- showGrid: false,
378
- onLabelClick: payload => addClicks.push(payload),
379
- onLabelDrag: payload => addDrags.push(payload)
380
- })
381
- await settle(200)
382
- const addedState = addView.addLabel('point', 'p1', { content: '备注', position: 'top-left' })
383
- await settle(200)
384
- const addedNode = addHost.querySelector(`.gis-view__label[data-label-label='${addedState.labelId}']`)
385
- check('运行时加的标签跟着这一类的缺省接住指针(不用自己写 draggable / editable)', Boolean(addedNode) && addedNode.classList.contains('gis-view__label--draggable') && addedNode.classList.contains('gis-view__label--editable'), addedNode ? addedNode.className : '没有标签')
386
- check('新加标签的位置就是 addLabel 里给的那一个', addedNode?.classList.contains('gis-view__label--top-left') === true, addedNode ? addedNode.className : '没有标签')
387
- const addedStart = centerOf(addedNode)
388
- fire(addedNode, 'pointerdown', addedStart[0], addedStart[1])
389
- fire(window, 'pointermove', addedStart[0] + 40, addedStart[1] + 24)
390
- await settle(40)
391
- fire(window, 'pointerup', addedStart[0] + 40, addedStart[1] + 24)
392
- await settle(160)
393
- const addedOffset = addView.getLabelStates('point').find(item => item.labelId === addedState.labelId)?.offset ?? null
394
- check('新加的标签拖得动(偏移按「要素 + labelId」记)', Array.isArray(addedOffset) && Math.abs(addedOffset[0] - 40) <= 1 && Math.abs(addedOffset[1] - 24) <= 1, JSON.stringify(addedOffset))
395
- const addedDrag = addDrags[addDrags.length - 1]
396
- check('拖动载荷里带这一条标签的读数(内容 / 来源)与所属要素', addedDrag?.label?.id === addedState.labelId && addedDrag?.label?.source === 'runtime' && addedDrag?.label?.content === '备注' && addedDrag?.parent?.type === 'point' && addedDrag?.parent?.id === 'p1' && addedDrag?.parent?.index === 0 && addedDrag?.parent?.channel === null, JSON.stringify({ label: addedDrag?.label, parent: addedDrag?.parent }))
397
- // 点一下(不移动)走 labelClick:载荷里的 label.content 是当下画出来的那一份
398
- // 拖动停手之后整层重排过,节点可能换了:按 dataset 重新取一次,别用旧的引用
399
- const addedFresh = addHost.querySelector(`.gis-view__label[data-label-label='${addedState.labelId}']`)
400
- const addedNodes = () => [...addHost.querySelectorAll('.gis-view__label')].map(node => `${node.dataset.labelLabel}:${node.textContent}`).join(' | ')
401
- check('拖动之后这一条标签还在画面上(重排之后仍找得到)', Boolean(addedFresh), addedNodes() || '一条标签都没有')
402
- if (addedFresh) {
403
- const addedAgain = centerOf(addedFresh)
404
- fire(addedFresh, 'pointerdown', addedAgain[0], addedAgain[1])
405
- fire(window, 'pointerup', addedAgain[0], addedAgain[1])
406
- await settle(120)
407
- }
408
- const addedClick = addClicks[addClicks.length - 1]
409
- check('点新加的标签:labelClick 说清是哪一条、内容是什么、父级是谁', addedClick?.labelId === addedState.labelId && addedClick?.label?.content === '备注' && addedClick?.label?.position === 'top-left' && addedClick?.parent?.id === 'p1', JSON.stringify({ labelId: addedClick?.labelId, label: addedClick?.label, parent: addedClick?.parent }))
410
- // 位置挑一个不与默认标签重叠的:避让默认开着,正叠着的标签整条不画
411
- const quietState = addView.addLabel('point', 'p1', { content: '别拖我', position: 'bottom', draggable: false, editable: false })
412
- await settle(160)
413
- const quietNode = addHost.querySelector(`.gis-view__label[data-label-label='${quietState.labelId}']`)
350
+ // 13. 标签配置里写错的键:创建 / updateOptions 时都点名,带上是哪一组、第几条
351
+ const warnHost = document.createElement('div')
352
+ warnHost.style.cssText = 'width:320px;height:220px'
353
+ document.body.append(warnHost)
354
+ const labelWarnings = []
355
+ const warnView = createGisView(warnHost, {
356
+ points: [{ id: 'p1', x: 0, y: 0, name: '甲' }],
357
+ lines: [],
358
+ label: { content: '{name}', positon: 'right' },
359
+ lineLabel: [{ content: '{name}' }, { content: '{name}', parrallel: true }],
360
+ showGrid: false,
361
+ onWarning: message => labelWarnings.push(message)
362
+ })
363
+ warnView.updateOptions({ label: { content: '{name}', coontent: 'x' } })
364
+ warnView.destroy()
365
+ warnHost.remove()
366
+ check('标签配置里写错的键直接点名(哪一组、第几条、哪个键)', labelWarnings.some(text => text.includes('positon') && text.includes('label 第 1 条')) && labelWarnings.some(text => text.includes('parrallel') && text.includes('lineLabel 第 2 条')) && labelWarnings.some(text => text.includes('coontent')), `${labelWarnings.length} 条:${labelWarnings.join(' / ')}`)
367
+ // 14. 运行时加的标签:不用自己写 draggable / editable 也能拖能点,位置现给;事件载荷带 label(内容 / 来源)与 parent(所属要素)
368
+ const addHost = document.createElement('div')
369
+ addHost.style.cssText = 'width:320px;height:220px'
370
+ document.body.append(addHost)
371
+ const addClicks = []
372
+ const addDrags = []
373
+ const addView = createGisView(addHost, {
374
+ points: [{ id: 'p1', x: 0, y: 0, name: '甲' }],
375
+ lines: [],
376
+ label: { enabled: true, content: '{name}', maxCount: 5, draggable: true, editable: true },
377
+ showGrid: false,
378
+ onLabelClick: payload => addClicks.push(payload),
379
+ onLabelDrag: payload => addDrags.push(payload)
380
+ })
381
+ await settle(200)
382
+ const addedState = addView.addLabel('point', 'p1', { content: '备注', position: 'top-left' })
383
+ await settle(200)
384
+ const addedNode = addHost.querySelector(`.gis-view__label[data-label-label='${addedState.labelId}']`)
385
+ check('运行时加的标签跟着这一类的缺省接住指针(不用自己写 draggable / editable)', Boolean(addedNode) && addedNode.classList.contains('gis-view__label--draggable') && addedNode.classList.contains('gis-view__label--editable'), addedNode ? addedNode.className : '没有标签')
386
+ check('新加标签的位置就是 addLabel 里给的那一个', addedNode?.classList.contains('gis-view__label--top-left') === true, addedNode ? addedNode.className : '没有标签')
387
+ const addedStart = centerOf(addedNode)
388
+ fire(addedNode, 'pointerdown', addedStart[0], addedStart[1])
389
+ fire(window, 'pointermove', addedStart[0] + 40, addedStart[1] + 24)
390
+ await settle(40)
391
+ fire(window, 'pointerup', addedStart[0] + 40, addedStart[1] + 24)
392
+ await settle(160)
393
+ const addedOffset = addView.getLabelStates('point').find(item => item.labelId === addedState.labelId)?.offset ?? null
394
+ check('新加的标签拖得动(偏移按「要素 + labelId」记)', Array.isArray(addedOffset) && Math.abs(addedOffset[0] - 40) <= 1 && Math.abs(addedOffset[1] - 24) <= 1, JSON.stringify(addedOffset))
395
+ const addedDrag = addDrags[addDrags.length - 1]
396
+ check('拖动载荷里带这一条标签的读数(内容 / 来源)与所属要素', addedDrag?.label?.id === addedState.labelId && addedDrag?.label?.source === 'runtime' && addedDrag?.label?.content === '备注' && addedDrag?.parent?.type === 'point' && addedDrag?.parent?.id === 'p1' && addedDrag?.parent?.index === 0 && addedDrag?.parent?.channel === null, JSON.stringify({ label: addedDrag?.label, parent: addedDrag?.parent }))
397
+ // 点一下(不移动)走 labelClick:载荷里的 label.content 是当下画出来的那一份
398
+ // 拖动停手之后整层重排过,节点可能换了:按 dataset 重新取一次,别用旧的引用
399
+ const addedFresh = addHost.querySelector(`.gis-view__label[data-label-label='${addedState.labelId}']`)
400
+ const addedNodes = () => [...addHost.querySelectorAll('.gis-view__label')].map(node => `${node.dataset.labelLabel}:${node.textContent}`).join(' | ')
401
+ check('拖动之后这一条标签还在画面上(重排之后仍找得到)', Boolean(addedFresh), addedNodes() || '一条标签都没有')
402
+ if (addedFresh) {
403
+ const addedAgain = centerOf(addedFresh)
404
+ fire(addedFresh, 'pointerdown', addedAgain[0], addedAgain[1])
405
+ fire(window, 'pointerup', addedAgain[0], addedAgain[1])
406
+ await settle(120)
407
+ }
408
+ const addedClick = addClicks[addClicks.length - 1]
409
+ check('点新加的标签:labelClick 说清是哪一条、内容是什么、父级是谁', addedClick?.labelId === addedState.labelId && addedClick?.label?.content === '备注' && addedClick?.label?.position === 'top-left' && addedClick?.parent?.id === 'p1', JSON.stringify({ labelId: addedClick?.labelId, label: addedClick?.label, parent: addedClick?.parent }))
410
+ // 位置挑一个不与默认标签重叠的:避让默认开着,正叠着的标签整条不画
411
+ const quietState = addView.addLabel('point', 'p1', { content: '别拖我', position: 'bottom', draggable: false, editable: false })
412
+ await settle(160)
413
+ const quietNode = addHost.querySelector(`.gis-view__label[data-label-label='${quietState.labelId}']`)
414
414
  check('逐条写 draggable / editable: false 的标签单独把指针还回去', Boolean(quietNode) && !quietNode.classList.contains('gis-view__label--draggable') && !quietNode.classList.contains('gis-view__label--editable'), quietNode ? quietNode.className : '没有标签')
415
415
  addView.destroy()
416
416
  addHost.remove()
@@ -471,279 +471,279 @@
471
471
  perItemView.destroy()
472
472
  perItemHost.remove()
473
473
  // 15. 线路在通道里:parent.channel 说清「哪条通道的哪一回线」(id / groupId / 通道下标 / 分组里的位置)
474
- const chanHost = document.createElement('div')
475
- chanHost.style.cssText = 'width:320px;height:220px'
476
- document.body.append(chanHost)
477
- const chanClicks = []
478
- const chanView = createGisView(chanHost, {
479
- points: [{ id: 'p1', x: 0, y: 0, name: '甲' }, { id: 'p2', x: 200, y: 0, name: '乙' }],
480
- lines: [
481
- // 每条回线自己写 lineLabel 与位置:通道里的并行回线因此各显示各的(不占扫描名额、不参与屏幕小格去重)
482
- { id: 'l1', from: 'p1', to: 'p2', name: '一回线', lineLabel: '{name}', lineLabelPosition: 0.35 },
483
- { id: 'l2', from: 'p1', to: 'p2', name: '二回线', lineLabel: '{name}', lineLabelPosition: 0.65 }
484
- ],
485
- channels: [{ id: 'ch1', name: '示例通道', lineIds: ['l1', 'l2'] }],
486
- lineLabel: { enabled: true, content: '{name}', maxCount: 5, editable: true },
487
- showGrid: false,
488
- onLabelClick: payload => chanClicks.push(payload)
489
- })
490
- await settle(240)
491
- const chanNode = chanHost.querySelector(".gis-view__label[data-label-kind='line'][data-label-index='1']")
492
- check('通道回线的标签照常画出来', Boolean(chanNode), chanNode ? chanNode.textContent : '没有标签')
493
- if (chanNode) {
494
- const chanCenter = centerOf(chanNode)
495
- fire(chanNode, 'pointerdown', chanCenter[0], chanCenter[1])
496
- fire(window, 'pointerup', chanCenter[0], chanCenter[1])
497
- await settle(120)
498
- }
499
- const chanParent = chanClicks[chanClicks.length - 1]?.parent ?? null
500
- check('通道回线的 labelClick 带通道 / 分组读数(哪条通道、哪一组、第几回)', chanParent?.type === 'line' && chanParent?.id === 'l2' && chanParent?.index === 1 && chanParent?.channel?.id === 'ch1' && String(chanParent?.channel?.groupId) === 'ch1' && chanParent?.channel?.index === 0 && chanParent?.channel?.slot === 1 && chanParent?.channel?.item?.id === 'ch1', JSON.stringify(chanParent))
501
- const chanLabel = chanClicks[chanClicks.length - 1]?.label ?? null
502
- check('通道回线的载荷里也有这一条标签自己的读数(id / 内容 / 来源)', chanLabel?.id === 'default' && chanLabel?.source === 'data' && chanLabel?.content === '二回线', JSON.stringify(chanLabel))
503
- chanView.destroy()
504
- chanHost.remove()
474
+ const chanHost = document.createElement('div')
475
+ chanHost.style.cssText = 'width:320px;height:220px'
476
+ document.body.append(chanHost)
477
+ const chanClicks = []
478
+ const chanView = createGisView(chanHost, {
479
+ points: [{ id: 'p1', x: 0, y: 0, name: '甲' }, { id: 'p2', x: 200, y: 0, name: '乙' }],
480
+ lines: [
481
+ // 每条回线自己写 lineLabel 与位置:通道里的并行回线因此各显示各的(不占扫描名额、不参与屏幕小格去重)
482
+ { id: 'l1', from: 'p1', to: 'p2', name: '一回线', lineLabel: '{name}', lineLabelPosition: 0.35 },
483
+ { id: 'l2', from: 'p1', to: 'p2', name: '二回线', lineLabel: '{name}', lineLabelPosition: 0.65 }
484
+ ],
485
+ channels: [{ id: 'ch1', name: '示例通道', lineIds: ['l1', 'l2'] }],
486
+ lineLabel: { enabled: true, content: '{name}', maxCount: 5, editable: true },
487
+ showGrid: false,
488
+ onLabelClick: payload => chanClicks.push(payload)
489
+ })
490
+ await settle(240)
491
+ const chanNode = chanHost.querySelector(".gis-view__label[data-label-kind='line'][data-label-index='1']")
492
+ check('通道回线的标签照常画出来', Boolean(chanNode), chanNode ? chanNode.textContent : '没有标签')
493
+ if (chanNode) {
494
+ const chanCenter = centerOf(chanNode)
495
+ fire(chanNode, 'pointerdown', chanCenter[0], chanCenter[1])
496
+ fire(window, 'pointerup', chanCenter[0], chanCenter[1])
497
+ await settle(120)
498
+ }
499
+ const chanParent = chanClicks[chanClicks.length - 1]?.parent ?? null
500
+ check('通道回线的 labelClick 带通道 / 分组读数(哪条通道、哪一组、第几回)', chanParent?.type === 'line' && chanParent?.id === 'l2' && chanParent?.index === 1 && chanParent?.channel?.id === 'ch1' && String(chanParent?.channel?.groupId) === 'ch1' && chanParent?.channel?.index === 0 && chanParent?.channel?.slot === 1 && chanParent?.channel?.item?.id === 'ch1', JSON.stringify(chanParent))
501
+ const chanLabel = chanClicks[chanClicks.length - 1]?.label ?? null
502
+ check('通道回线的载荷里也有这一条标签自己的读数(id / 内容 / 来源)', chanLabel?.id === 'default' && chanLabel?.source === 'data' && chanLabel?.content === '二回线', JSON.stringify(chanLabel))
503
+ chanView.destroy()
504
+ chanHost.remove()
505
505
  // 16. 右键:接住右键之后默认把浏览器的系统菜单收掉(contextmenu 的默认行为),preventContextMenu: false 时保留
506
506
  // (右键回调本身也是开关:缺省全关,这里显式打开,见 src/gis-view/features.js)
507
507
  view.updateOptions({ features: { contextMenu: true } })
508
508
  const canvasNode = map.querySelector('.gis-view__canvas')
509
- const menuEvent = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2, clientX: 40, clientY: 40 })
510
- canvasNode.dispatchEvent(menuEvent)
511
- check('右键默认取消系统默认事件(浏览器菜单不再弹)', menuEvent.defaultPrevented === true, `defaultPrevented ${menuEvent.defaultPrevented}`)
512
- view.updateOptions({ preventContextMenu: false })
513
- const freeEvent = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2, clientX: 40, clientY: 40 })
514
- canvasNode.dispatchEvent(freeEvent)
515
- check('preventContextMenu: false 时把系统菜单还给浏览器', freeEvent.defaultPrevented === false, `defaultPrevented ${freeEvent.defaultPrevented}`)
516
- view.updateOptions({ preventContextMenu: true })
517
- // 17. 标签上的右键 / 双击:labelContextMenu / labelDblClick 带这一条标签的读数、所属要素与指针位置
518
- const menuHost = document.createElement('div')
519
- menuHost.style.cssText = 'width:320px;height:220px'
520
- document.body.append(menuHost)
521
- const menuPayloads = []
522
- const dblPayloads = []
523
- const menuView = createGisView(menuHost, {
524
- points: [{ id: 'm1', x: 0, y: 0, name: '右键站' }],
525
- lines: [],
526
- label: { enabled: true, content: '{name}', maxCount: 5, draggable: true, editable: true },
527
- showGrid: false,
528
- onLabelContextMenu: payload => menuPayloads.push(payload),
529
- onLabelDblClick: payload => dblPayloads.push(payload)
530
- })
531
- await settle(220)
532
- const menuLabel = () => menuHost.querySelector(".gis-view__label[data-label-kind='point']")
533
- const firstMenu = menuLabel()
534
- if (firstMenu) {
535
- const menuRect = firstMenu.getBoundingClientRect()
536
- const labelMenuEvent = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2, clientX: menuRect.left + 4, clientY: menuRect.top + 4 })
537
- firstMenu.dispatchEvent(labelMenuEvent)
538
- const menuPayload = menuPayloads[menuPayloads.length - 1]
509
+ const menuEvent = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2, clientX: 40, clientY: 40 })
510
+ canvasNode.dispatchEvent(menuEvent)
511
+ check('右键默认取消系统默认事件(浏览器菜单不再弹)', menuEvent.defaultPrevented === true, `defaultPrevented ${menuEvent.defaultPrevented}`)
512
+ view.updateOptions({ preventContextMenu: false })
513
+ const freeEvent = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2, clientX: 40, clientY: 40 })
514
+ canvasNode.dispatchEvent(freeEvent)
515
+ check('preventContextMenu: false 时把系统菜单还给浏览器', freeEvent.defaultPrevented === false, `defaultPrevented ${freeEvent.defaultPrevented}`)
516
+ view.updateOptions({ preventContextMenu: true })
517
+ // 17. 标签上的右键 / 双击:labelContextMenu / labelDblClick 带这一条标签的读数、所属要素与指针位置
518
+ const menuHost = document.createElement('div')
519
+ menuHost.style.cssText = 'width:320px;height:220px'
520
+ document.body.append(menuHost)
521
+ const menuPayloads = []
522
+ const dblPayloads = []
523
+ const menuView = createGisView(menuHost, {
524
+ points: [{ id: 'm1', x: 0, y: 0, name: '右键站' }],
525
+ lines: [],
526
+ label: { enabled: true, content: '{name}', maxCount: 5, draggable: true, editable: true },
527
+ showGrid: false,
528
+ onLabelContextMenu: payload => menuPayloads.push(payload),
529
+ onLabelDblClick: payload => dblPayloads.push(payload)
530
+ })
531
+ await settle(220)
532
+ const menuLabel = () => menuHost.querySelector(".gis-view__label[data-label-kind='point']")
533
+ const firstMenu = menuLabel()
534
+ if (firstMenu) {
535
+ const menuRect = firstMenu.getBoundingClientRect()
536
+ const labelMenuEvent = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2, clientX: menuRect.left + 4, clientY: menuRect.top + 4 })
537
+ firstMenu.dispatchEvent(labelMenuEvent)
538
+ const menuPayload = menuPayloads[menuPayloads.length - 1]
539
539
  check('右键标签发 labelContextMenu:哪一条 / 内容 / 挂在谁 / 指针位置都在,系统菜单被拦下', menuPayload?.labelId === 'default' && menuPayload?.label?.content === '右键站' && menuPayload?.parent?.id === 'm1' && Math.abs(menuPayload.originalEvent.clientX - (menuRect.left + 4)) < 0.01 && menuPayload.clientX === undefined && labelMenuEvent.defaultPrevented === true, JSON.stringify({ labelId: menuPayload?.labelId, content: menuPayload?.label?.content, parent: menuPayload?.parent?.id, x: menuPayload?.originalEvent?.clientX, prevented: labelMenuEvent.defaultPrevented }))
540
- check('右键载荷里的 state 与偏移读数和点选同一套', menuPayload?.state === null && Array.isArray(menuPayload?.offset) && menuPayload.offset[0] === 0 && menuPayload.offset[1] === 0, JSON.stringify({ state: menuPayload?.state, offset: menuPayload?.offset }))
541
- }
542
- menuView.updateOptions({ preventContextMenu: false })
543
- const freeLabel = menuLabel()
544
- if (freeLabel) {
545
- const freeMenu = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2, clientX: 12, clientY: 12 })
546
- freeLabel.dispatchEvent(freeMenu)
547
- check('preventContextMenu: false 时标签右键把系统菜单还给浏览器(事件照发)', freeMenu.defaultPrevented === false && menuPayloads.length === 2, `defaultPrevented ${freeMenu.defaultPrevented} · ${menuPayloads.length} 条`)
548
- }
549
- menuView.updateOptions({ preventContextMenu: true })
550
- const dblLabel = menuLabel()
551
- if (dblLabel) {
552
- dblLabel.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
553
- }
554
- const dblPayload = dblPayloads[dblPayloads.length - 1]
555
- check('双击标签发 labelDblClick(载荷与右键同一份读数)', dblPayload?.labelId === 'default' && dblPayload?.label?.content === '右键站' && dblPayload?.parent?.id === 'm1', JSON.stringify({ labelId: dblPayload?.labelId, parent: dblPayload?.parent?.id }))
556
- // 不接指针的标签(draggable / editable 都关着)不接右键:一条不发,也不拦系统菜单
557
- const quietMenuBefore = menuPayloads.length
558
- menuView.updateOptions({ label: { enabled: true, content: '{name}', maxCount: 5, draggable: false, editable: false } })
559
- await settle(160)
560
- const quietLabel = menuLabel()
561
- if (quietLabel) {
562
- const quietMenu = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2, clientX: 12, clientY: 12 })
563
- quietLabel.dispatchEvent(quietMenu)
564
- check('不接指针的标签不接右键(事件让给画布,也不拦系统菜单)', menuPayloads.length === quietMenuBefore && quietMenu.defaultPrevented === false, `${menuPayloads.length} 条 · defaultPrevented ${quietMenu.defaultPrevented}`)
565
- }
566
- menuView.destroy()
567
- menuHost.remove()
568
- // 18. 统一加 / 改 / 删:addLabels / updateLabels / removeLabels / resolveLabelTargets(关键字定位、选择集、整条通道、函数定义)
569
- const batchHost = document.createElement('div')
570
- batchHost.style.cssText = 'width:420px;height:280px'
571
- document.body.append(batchHost)
572
- const batchChanges = []
573
- const batchView = createGisView(batchHost, {
574
- points: [
575
- { id: 'a', x: 0, y: 0, name: '甲' },
576
- { id: 'b', x: 120, y: 0, name: '乙' },
577
- { id: 'c', x: 240, y: 0, name: '丙' }
578
- ],
579
- lines: [
580
- { id: 'l1', from: 'a', to: 'b', name: '甲乙线' },
581
- { id: 'l2', from: 'b', to: 'c', name: '乙丙线' }
582
- ],
583
- channels: [{ id: 'ch1', name: '通道一', lineIds: ['l1', 'l2'] }],
584
- label: { enabled: true, content: '{name}', maxCount: 8, draggable: true, editable: true },
585
- lineLabel: { enabled: true, content: '{name}', maxCount: 8, editable: true },
586
- showGrid: false,
587
- onLabelChange: payload => batchChanges.push(payload)
588
- })
589
- await settle(260)
590
- const pointTargets = batchView.resolveLabelTargets('point', 'all')
591
- const visibleTargets = batchView.resolveLabelTargets('point', 'visible')
592
- const lineTargets = batchView.resolveLabelTargets('line', { channel: 'ch1' })
593
- const someTargets = batchView.resolveLabelTargets('point', ['a', 2])
594
- check('resolveLabelTargets:按关键字 / 数组 / 整条通道定位到要素', pointTargets.length === 3 && visibleTargets.length >= 1 && lineTargets.map(item => item.id).join(',') === 'l1,l2' && someTargets.map(item => item.id).join(',') === 'a,c', JSON.stringify({ all: pointTargets.length, visible: visibleTargets.length, lines: lineTargets.map(item => item.id), some: someTargets.map(item => item.id) }))
595
- check('通道里的回线目标带通道读数(哪条通道 / 哪一组 / 第几回)', lineTargets.every(item => item.channel?.id === 'ch1') && lineTargets.map(item => item.channel?.slot).join(',') === '0,1', JSON.stringify(lineTargets.map(item => item.channel)))
596
- const addedAll = batchView.addLabels('point', 'all', (item, index) => ({ content: `${item.name}·${index}`, position: 'bottom' }))
597
- await settle(220)
598
- check('addLabels:一次给每个节点各加一条(函数定义各写各的内容)', addedAll.added.length === 3 && addedAll.skipped.length === 0 && addedAll.added.every(entry => entry.labelId === 'note1') && batchView.getLabels('point', 'b').some(row => row.id === 'note1' && row.content === '乙·1'), JSON.stringify({ added: addedAll.added.length, skipped: addedAll.skipped.length, contents: batchView.getLabels('point', 'b').map(row => `${row.id}:${row.content}`) }))
599
- const dupAll = batchView.addLabels('point', 'all', { content: '重复' }, { labelId: 'note1' })
600
- check('addLabels:已经有同 id 的要素缺省跳过(reason: exists)', dupAll.added.length === 0 && dupAll.updated.length === 0 && dupAll.skipped.length === 3 && dupAll.skipped.every(entry => entry.reason === 'exists'), JSON.stringify({ skipped: dupAll.skipped.length, reason: dupAll.skipped[0]?.reason }))
601
- const overAll = batchView.addLabels('point', 'all', { content: '覆盖过', position: 'top' }, { labelId: 'note1', overwrite: true })
602
- await settle(220)
603
- check('addLabels overwrite: true 改成覆盖(走 updated),内容当场换掉', overAll.updated.length === 3 && overAll.added.length === 0 && batchView.getLabels('point', 'a').some(row => row.id === 'note1' && row.content === '覆盖过'), JSON.stringify({ updated: overAll.updated.length, added: overAll.added.length }))
540
+ check('右键载荷里的 state 与偏移读数和点选同一套', menuPayload?.state === null && Array.isArray(menuPayload?.offset) && menuPayload.offset[0] === 0 && menuPayload.offset[1] === 0, JSON.stringify({ state: menuPayload?.state, offset: menuPayload?.offset }))
541
+ }
542
+ menuView.updateOptions({ preventContextMenu: false })
543
+ const freeLabel = menuLabel()
544
+ if (freeLabel) {
545
+ const freeMenu = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2, clientX: 12, clientY: 12 })
546
+ freeLabel.dispatchEvent(freeMenu)
547
+ check('preventContextMenu: false 时标签右键把系统菜单还给浏览器(事件照发)', freeMenu.defaultPrevented === false && menuPayloads.length === 2, `defaultPrevented ${freeMenu.defaultPrevented} · ${menuPayloads.length} 条`)
548
+ }
549
+ menuView.updateOptions({ preventContextMenu: true })
550
+ const dblLabel = menuLabel()
551
+ if (dblLabel) {
552
+ dblLabel.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
553
+ }
554
+ const dblPayload = dblPayloads[dblPayloads.length - 1]
555
+ check('双击标签发 labelDblClick(载荷与右键同一份读数)', dblPayload?.labelId === 'default' && dblPayload?.label?.content === '右键站' && dblPayload?.parent?.id === 'm1', JSON.stringify({ labelId: dblPayload?.labelId, parent: dblPayload?.parent?.id }))
556
+ // 不接指针的标签(draggable / editable 都关着)不接右键:一条不发,也不拦系统菜单
557
+ const quietMenuBefore = menuPayloads.length
558
+ menuView.updateOptions({ label: { enabled: true, content: '{name}', maxCount: 5, draggable: false, editable: false } })
559
+ await settle(160)
560
+ const quietLabel = menuLabel()
561
+ if (quietLabel) {
562
+ const quietMenu = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2, clientX: 12, clientY: 12 })
563
+ quietLabel.dispatchEvent(quietMenu)
564
+ check('不接指针的标签不接右键(事件让给画布,也不拦系统菜单)', menuPayloads.length === quietMenuBefore && quietMenu.defaultPrevented === false, `${menuPayloads.length} 条 · defaultPrevented ${quietMenu.defaultPrevented}`)
565
+ }
566
+ menuView.destroy()
567
+ menuHost.remove()
568
+ // 18. 统一加 / 改 / 删:addLabels / updateLabels / removeLabels / resolveLabelTargets(关键字定位、选择集、整条通道、函数定义)
569
+ const batchHost = document.createElement('div')
570
+ batchHost.style.cssText = 'width:420px;height:280px'
571
+ document.body.append(batchHost)
572
+ const batchChanges = []
573
+ const batchView = createGisView(batchHost, {
574
+ points: [
575
+ { id: 'a', x: 0, y: 0, name: '甲' },
576
+ { id: 'b', x: 120, y: 0, name: '乙' },
577
+ { id: 'c', x: 240, y: 0, name: '丙' }
578
+ ],
579
+ lines: [
580
+ { id: 'l1', from: 'a', to: 'b', name: '甲乙线' },
581
+ { id: 'l2', from: 'b', to: 'c', name: '乙丙线' }
582
+ ],
583
+ channels: [{ id: 'ch1', name: '通道一', lineIds: ['l1', 'l2'] }],
584
+ label: { enabled: true, content: '{name}', maxCount: 8, draggable: true, editable: true },
585
+ lineLabel: { enabled: true, content: '{name}', maxCount: 8, editable: true },
586
+ showGrid: false,
587
+ onLabelChange: payload => batchChanges.push(payload)
588
+ })
589
+ await settle(260)
590
+ const pointTargets = batchView.resolveLabelTargets('point', 'all')
591
+ const visibleTargets = batchView.resolveLabelTargets('point', 'visible')
592
+ const lineTargets = batchView.resolveLabelTargets('line', { channel: 'ch1' })
593
+ const someTargets = batchView.resolveLabelTargets('point', ['a', 2])
594
+ check('resolveLabelTargets:按关键字 / 数组 / 整条通道定位到要素', pointTargets.length === 3 && visibleTargets.length >= 1 && lineTargets.map(item => item.id).join(',') === 'l1,l2' && someTargets.map(item => item.id).join(',') === 'a,c', JSON.stringify({ all: pointTargets.length, visible: visibleTargets.length, lines: lineTargets.map(item => item.id), some: someTargets.map(item => item.id) }))
595
+ check('通道里的回线目标带通道读数(哪条通道 / 哪一组 / 第几回)', lineTargets.every(item => item.channel?.id === 'ch1') && lineTargets.map(item => item.channel?.slot).join(',') === '0,1', JSON.stringify(lineTargets.map(item => item.channel)))
596
+ const addedAll = batchView.addLabels('point', 'all', (item, index) => ({ content: `${item.name}·${index}`, position: 'bottom' }))
597
+ await settle(220)
598
+ check('addLabels:一次给每个节点各加一条(函数定义各写各的内容)', addedAll.added.length === 3 && addedAll.skipped.length === 0 && addedAll.added.every(entry => entry.labelId === 'note1') && batchView.getLabels('point', 'b').some(row => row.id === 'note1' && row.content === '乙·1'), JSON.stringify({ added: addedAll.added.length, skipped: addedAll.skipped.length, contents: batchView.getLabels('point', 'b').map(row => `${row.id}:${row.content}`) }))
599
+ const dupAll = batchView.addLabels('point', 'all', { content: '重复' }, { labelId: 'note1' })
600
+ check('addLabels:已经有同 id 的要素缺省跳过(reason: exists)', dupAll.added.length === 0 && dupAll.updated.length === 0 && dupAll.skipped.length === 3 && dupAll.skipped.every(entry => entry.reason === 'exists'), JSON.stringify({ skipped: dupAll.skipped.length, reason: dupAll.skipped[0]?.reason }))
601
+ const overAll = batchView.addLabels('point', 'all', { content: '覆盖过', position: 'top' }, { labelId: 'note1', overwrite: true })
602
+ await settle(220)
603
+ check('addLabels overwrite: true 改成覆盖(走 updated),内容当场换掉', overAll.updated.length === 3 && overAll.added.length === 0 && batchView.getLabels('point', 'a').some(row => row.id === 'note1' && row.content === '覆盖过'), JSON.stringify({ updated: overAll.updated.length, added: overAll.added.length }))
604
604
  batchView.selectFeatures(['a', 'c'])
605
- await settle(140)
606
- const selTargets = batchView.resolveLabelTargets('point', 'selection')
607
- const selAdded = batchView.addLabels('point', 'selection', { content: '选中的', position: 'left' }, { labelId: 'note2' })
608
- await settle(200)
609
- check('addLabels 直接给「当前选中集」加标签(没选中的那条不加)', selTargets.map(item => item.id).join(',') === 'a,c' && selAdded.added.length === 2 && !batchView.getLabels('point', 'b').some(row => row.id === 'note2'), JSON.stringify({ targets: selTargets.map(item => item.id), added: selAdded.added.map(entry => entry.id) }))
610
- const chanAdded = batchView.addLabels('line', { channel: 'ch1' }, { content: '通道标签', position: 0.4 })
611
- await settle(220)
612
- check('addLabels 用 { channel } 一次给整条通道的回线都加上', chanAdded.added.length === 2 && chanAdded.added.every(entry => entry.labelId === 'note1') && batchView.getLabels('line', 'l2').some(row => row.id === 'note1' && row.content === '通道标签'), JSON.stringify(chanAdded.added.map(entry => entry.id)))
613
- const lineUpdated = batchView.updateLabels('line', { channel: 'ch1' }, { labelId: 'note1', style: { color: 'rgb(200, 40, 40)' }, offset: [6, 0] })
614
- await settle(200)
615
- const batchLineStates = batchView.getLabelStates('line').filter(state => state.labelId === 'note1')
616
- check('updateLabels:一批一起改样式与偏移(落到逐条状态里)', lineUpdated.updated.length === 2 && lineUpdated.skipped.length === 0 && batchLineStates.length === 2 && batchLineStates.every(state => state.style?.color === 'rgb(200, 40, 40)' && Math.abs(state.offset[0] - 6) < 0.01), JSON.stringify(batchLineStates.map(state => ({ id: state.id, style: state.style, offset: state.offset }))))
617
- const lineRemoved = batchView.removeLabels('line', { channel: 'ch1' }, 'note1')
618
- await settle(200)
619
- check('removeLabels:一批一起删(运行时加的删掉那一份,声明的不受影响)', lineRemoved.removed === 2 && lineRemoved.hidden === 0 && lineRemoved.items.length === 2 && !batchView.getLabels('line', 'l1').some(row => row.id === 'note1') && batchView.getLabels('line', 'l1').some(row => row.id === 'default'), JSON.stringify({ removed: lineRemoved.removed, hidden: lineRemoved.hidden }))
620
- const missingRemoved = batchView.removeLabels('point', 'all', 'nope')
621
- check('removeLabels:没有这一条时记进 skipped(不报错)', missingRemoved.items.length === 0 && missingRemoved.skipped.length === 3 && missingRemoved.skipped.every(entry => entry.reason === 'missing'), JSON.stringify(missingRemoved.skipped.map(entry => entry.reason)))
622
- const noCreate = batchView.updateLabels('point', 'all', { labelId: 'nope2', content: 'x' })
623
- const created = batchView.updateLabels('point', 'all', { labelId: 'nope2', content: '补一条' }, { create: true })
624
- await settle(200)
625
- check('updateLabels:没有这一条时缺省跳过,create: true 才补一条', noCreate.updated.length === 0 && noCreate.skipped.length === 3 && created.updated.length === 3 && created.skipped.length === 0, JSON.stringify({ noCreate: noCreate.skipped.length, created: created.updated.length }))
626
- const batchActions = batchChanges.map(payload => payload.action)
627
- check('批处理各发一条 labelChange(addAll / updateAll / removeAll)', batchActions.includes('addAll') && batchActions.includes('updateAll') && batchActions.includes('removeAll') && batchChanges.some(payload => payload.action === 'addAll' && payload.count === 3), batchActions.join(','))
628
- batchView.destroy()
629
- batchHost.remove()
630
- // 19. 拖得动 ≠ 点得选:draggable / editable 各自管各自的手势(这一轮修的边界)
631
- const gestureHost = document.createElement('div')
632
- gestureHost.style.cssText = 'width:360px;height:240px'
633
- document.body.append(gestureHost)
634
- const gestureClicks = []
635
- const gestureView = createGisView(gestureHost, {
636
- points: [{ id: 'g1', x: 0, y: 0, name: '甲' }],
637
- lines: [],
638
- label: { enabled: true, content: '{name}', maxCount: 4, draggable: false, editable: true },
639
- showGrid: false,
640
- onLabelClick: payload => gestureClicks.push(payload)
641
- })
642
- await settle(240)
643
- const gestureNode = () => gestureHost.querySelector('.gis-view__label')
644
- const gesturePoint = () => {
645
- const rect = gestureNode().getBoundingClientRect()
646
- return [rect.left + rect.width / 2, rect.top + rect.height / 2]
647
- }
648
- const fireGesture = (target, type, x, y) => target.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, composed: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, clientX: x, clientY: y }))
649
- // 只点不拖:editable 打开 → 发 labelClick
650
- if (gestureNode()) {
651
- const [x, y] = gesturePoint()
652
- fireGesture(gestureNode(), 'pointerdown', x, y)
653
- fireGesture(window, 'pointerup', x, y)
654
- await settle(120)
655
- }
656
- check('editable: true / draggable: false 的标签点得选(发 labelClick)', gestureClicks.length === 1 && gestureClicks[0]?.labelId === 'default', `${gestureClicks.length} 条`)
657
- // 再拖一段:draggable 关着 → 一动都不动,也不写偏移状态
658
- if (gestureNode()) {
659
- const [x, y] = gesturePoint()
660
- fireGesture(gestureNode(), 'pointerdown', x, y)
661
- fireGesture(window, 'pointermove', x + 40, y + 24)
662
- fireGesture(window, 'pointerup', x + 40, y + 24)
663
- await settle(200)
664
- }
665
- check('draggable: false 的标签拖不动(偏移状态一条都不写)', gestureView.getLabelStates('point').length === 0, JSON.stringify(gestureView.getLabelStates('point')))
666
- // 换成只能拖不能点:拖得动,但点一下不发 labelClick
667
- gestureView.updateOptions({ label: { enabled: true, content: '{name}', maxCount: 4, draggable: true, editable: false } })
668
- await settle(220)
669
- const gestureDragClicks = []
670
- gestureView.on('labelClick', payload => gestureDragClicks.push(payload))
671
- const gestureDrags = []
672
- gestureView.on('labelDrag', payload => { if (payload.phase === 'end') gestureDrags.push(payload) })
673
- if (gestureNode()) {
674
- const [x, y] = gesturePoint()
675
- fireGesture(gestureNode(), 'pointerdown', x, y)
676
- fireGesture(window, 'pointerup', x, y)
677
- await settle(120)
678
- }
679
- check('draggable: true / editable: false 的标签点一下不发 labelClick(拖得动不等于点得选)', gestureDragClicks.length === 0, `${gestureDragClicks.length} 条`)
680
- if (gestureNode()) {
681
- const [x, y] = gesturePoint()
682
- fireGesture(gestureNode(), 'pointerdown', x, y)
683
- fireGesture(window, 'pointermove', x + 30, y + 18)
684
- fireGesture(window, 'pointerup', x + 30, y + 18)
685
- await settle(220)
686
- }
687
- const dragStates = gestureView.getLabelStates('point')
688
- check('同一条标签照旧拖得动(偏移记进状态,labelDrag 的 end 也发了)', gestureDrags.length === 1 && dragStates.length === 1 && Math.abs(dragStates[0].offset[1] - 18) < 1.5, JSON.stringify({ drags: gestureDrags.length, offset: dragStates[0]?.offset }))
689
- gestureView.destroy()
690
- gestureHost.remove()
691
- // 17. 缩放往返与拖动之后的相对位置:标签尺寸走内容签名缓存、位置从自己写下的 transform 里算出来,
692
- // 这两条都不该被缩放 / 重排弄出累积漂移(量测次数本身在 selftest-perf 里盯)
693
- const zoomHost = document.createElement('div')
694
- zoomHost.style.cssText = 'width:640px;height:380px'
695
- document.body.append(zoomHost)
696
- const zoomPoints = [
697
- { id: 'a', x: 0, y: 0, name: '甲站' },
698
- { id: 'b', x: 260, y: 0, name: '乙站' },
699
- { id: 'c', x: 130, y: 180, name: '丙站' }
700
- ]
701
- const zoomView = createGisView(zoomHost, {
702
- points: zoomPoints,
703
- lines: [],
704
- label: { enabled: true, content: '{name}', draggable: true, editable: true, maxCount: 5 },
705
- showGrid: false
706
- })
707
- await settle(240)
708
- const zoomNode = index => zoomHost.querySelector(`.gis-view__label[data-label-kind='point'][data-label-index='${index}']`)
709
- // 标签中心相对「节点投影」的偏移:换了相机之后这个差要原样保留(放大不把它拉开、往返不累积漂移)
710
- const zoomRelative = index => {
711
- const center = centerOf(zoomNode(index))
712
- const projected = zoomView.project(zoomPoints[index].x, zoomPoints[index].y)
713
- const frame = zoomHost.getBoundingClientRect()
714
- return [center[0] - frame.left - projected[0], center[1] - frame.top - projected[1]]
715
- }
716
- // 放大倍数刻意留小:再大一点三个节点就被推出视口,量到的「没有标签」是正常的
717
- const zoomCamera = zoomView.getCamera()
718
- const zoomBefore = [0, 1, 2].map(zoomRelative)
719
- zoomView.setCamera({ ...zoomCamera, scale: zoomCamera.scale * 1.25 })
720
- await settle(260)
721
- const zoomIn = [0, 1, 2].map(zoomRelative)
722
- zoomView.setCamera(zoomCamera)
723
- await settle(260)
724
- const zoomBack = [0, 1, 2].map(zoomRelative)
725
- const driftOf = (list, base) => list.map((value, index) => Math.max(Math.abs(value[0] - base[index][0]), Math.abs(value[1] - base[index][1])))
726
- check('放大之后标签还贴着各自的节点(相对偏移不变)', driftOf(zoomIn, zoomBefore).every(value => value <= 1), `三个标签最大差 ${driftOf(zoomIn, zoomBefore).map(value => value.toFixed(2)).join(' / ')} px`)
727
- check('缩回原样:标签位置与缩放前一致(往返一次漂移 ≤ 0.5 像素)', driftOf(zoomBack, zoomBefore).every(value => value <= 0.5), `三个标签最大差 ${driftOf(zoomBack, zoomBefore).map(value => value.toFixed(2)).join(' / ')} px`)
728
- // 拖过的标签再经历「改样式强制重排 + 平移往返」:偏移一个像素都不该走样
729
- const zoomDragStart = centerOf(zoomNode(0))
730
- fire(zoomNode(0), 'pointerdown', zoomDragStart[0], zoomDragStart[1])
731
- fire(window, 'pointermove', zoomDragStart[0] + 48, zoomDragStart[1] + 30)
732
- fire(window, 'pointerup', zoomDragStart[0] + 48, zoomDragStart[1] + 30)
733
- await settle(220)
734
- const zoomDragged = zoomRelative(0)
735
- zoomView.updateLabel('point', 'a', { style: { color: 'rgb(17, 68, 119)' } })
736
- await settle(180)
737
- const zoomAfterRelayout = zoomRelative(0)
738
- const zoomPan = zoomView.getCamera()
739
- zoomView.setCamera({ ...zoomPan, center: [zoomPan.center[0] + 20, zoomPan.center[1] + 10] })
740
- await settle(280)
741
- zoomView.setCamera(zoomPan)
742
- await settle(280)
743
- const zoomAfterPan = zoomRelative(0)
744
- const dragDrift = Math.max(Math.abs(zoomAfterRelayout[0] - zoomDragged[0]), Math.abs(zoomAfterRelayout[1] - zoomDragged[1]), Math.abs(zoomAfterPan[0] - zoomDragged[0]), Math.abs(zoomAfterPan[1] - zoomDragged[1]))
745
- check('拖过的标签在强制重排 / 平移往返之后偏移仍然精确(≤ 0.5 像素)', dragDrift <= 0.5, `拖后 ${zoomDragged.map(value => value.toFixed(2)).join(' / ')} · 重排后 ${zoomAfterRelayout.map(value => value.toFixed(2)).join(' / ')} · 平移往返后 ${zoomAfterPan.map(value => value.toFixed(2)).join(' / ')}`)
746
- check('拖出来的偏移还在状态里(往返没有把它清掉)', Math.abs(zoomView.getLabelStates('point').find(state => state.id === 'a')?.offset?.[0] - 48) <= 1, JSON.stringify(zoomView.getLabelStates('point').map(state => state.offset)))
605
+ await settle(140)
606
+ const selTargets = batchView.resolveLabelTargets('point', 'selection')
607
+ const selAdded = batchView.addLabels('point', 'selection', { content: '选中的', position: 'left' }, { labelId: 'note2' })
608
+ await settle(200)
609
+ check('addLabels 直接给「当前选中集」加标签(没选中的那条不加)', selTargets.map(item => item.id).join(',') === 'a,c' && selAdded.added.length === 2 && !batchView.getLabels('point', 'b').some(row => row.id === 'note2'), JSON.stringify({ targets: selTargets.map(item => item.id), added: selAdded.added.map(entry => entry.id) }))
610
+ const chanAdded = batchView.addLabels('line', { channel: 'ch1' }, { content: '通道标签', position: 0.4 })
611
+ await settle(220)
612
+ check('addLabels 用 { channel } 一次给整条通道的回线都加上', chanAdded.added.length === 2 && chanAdded.added.every(entry => entry.labelId === 'note1') && batchView.getLabels('line', 'l2').some(row => row.id === 'note1' && row.content === '通道标签'), JSON.stringify(chanAdded.added.map(entry => entry.id)))
613
+ const lineUpdated = batchView.updateLabels('line', { channel: 'ch1' }, { labelId: 'note1', style: { color: 'rgb(200, 40, 40)' }, offset: [6, 0] })
614
+ await settle(200)
615
+ const batchLineStates = batchView.getLabelStates('line').filter(state => state.labelId === 'note1')
616
+ check('updateLabels:一批一起改样式与偏移(落到逐条状态里)', lineUpdated.updated.length === 2 && lineUpdated.skipped.length === 0 && batchLineStates.length === 2 && batchLineStates.every(state => state.style?.color === 'rgb(200, 40, 40)' && Math.abs(state.offset[0] - 6) < 0.01), JSON.stringify(batchLineStates.map(state => ({ id: state.id, style: state.style, offset: state.offset }))))
617
+ const lineRemoved = batchView.removeLabels('line', { channel: 'ch1' }, 'note1')
618
+ await settle(200)
619
+ check('removeLabels:一批一起删(运行时加的删掉那一份,声明的不受影响)', lineRemoved.removed === 2 && lineRemoved.hidden === 0 && lineRemoved.items.length === 2 && !batchView.getLabels('line', 'l1').some(row => row.id === 'note1') && batchView.getLabels('line', 'l1').some(row => row.id === 'default'), JSON.stringify({ removed: lineRemoved.removed, hidden: lineRemoved.hidden }))
620
+ const missingRemoved = batchView.removeLabels('point', 'all', 'nope')
621
+ check('removeLabels:没有这一条时记进 skipped(不报错)', missingRemoved.items.length === 0 && missingRemoved.skipped.length === 3 && missingRemoved.skipped.every(entry => entry.reason === 'missing'), JSON.stringify(missingRemoved.skipped.map(entry => entry.reason)))
622
+ const noCreate = batchView.updateLabels('point', 'all', { labelId: 'nope2', content: 'x' })
623
+ const created = batchView.updateLabels('point', 'all', { labelId: 'nope2', content: '补一条' }, { create: true })
624
+ await settle(200)
625
+ check('updateLabels:没有这一条时缺省跳过,create: true 才补一条', noCreate.updated.length === 0 && noCreate.skipped.length === 3 && created.updated.length === 3 && created.skipped.length === 0, JSON.stringify({ noCreate: noCreate.skipped.length, created: created.updated.length }))
626
+ const batchActions = batchChanges.map(payload => payload.action)
627
+ check('批处理各发一条 labelChange(addAll / updateAll / removeAll)', batchActions.includes('addAll') && batchActions.includes('updateAll') && batchActions.includes('removeAll') && batchChanges.some(payload => payload.action === 'addAll' && payload.count === 3), batchActions.join(','))
628
+ batchView.destroy()
629
+ batchHost.remove()
630
+ // 19. 拖得动 ≠ 点得选:draggable / editable 各自管各自的手势(这一轮修的边界)
631
+ const gestureHost = document.createElement('div')
632
+ gestureHost.style.cssText = 'width:360px;height:240px'
633
+ document.body.append(gestureHost)
634
+ const gestureClicks = []
635
+ const gestureView = createGisView(gestureHost, {
636
+ points: [{ id: 'g1', x: 0, y: 0, name: '甲' }],
637
+ lines: [],
638
+ label: { enabled: true, content: '{name}', maxCount: 4, draggable: false, editable: true },
639
+ showGrid: false,
640
+ onLabelClick: payload => gestureClicks.push(payload)
641
+ })
642
+ await settle(240)
643
+ const gestureNode = () => gestureHost.querySelector('.gis-view__label')
644
+ const gesturePoint = () => {
645
+ const rect = gestureNode().getBoundingClientRect()
646
+ return [rect.left + rect.width / 2, rect.top + rect.height / 2]
647
+ }
648
+ const fireGesture = (target, type, x, y) => target.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, composed: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, clientX: x, clientY: y }))
649
+ // 只点不拖:editable 打开 → 发 labelClick
650
+ if (gestureNode()) {
651
+ const [x, y] = gesturePoint()
652
+ fireGesture(gestureNode(), 'pointerdown', x, y)
653
+ fireGesture(window, 'pointerup', x, y)
654
+ await settle(120)
655
+ }
656
+ check('editable: true / draggable: false 的标签点得选(发 labelClick)', gestureClicks.length === 1 && gestureClicks[0]?.labelId === 'default', `${gestureClicks.length} 条`)
657
+ // 再拖一段:draggable 关着 → 一动都不动,也不写偏移状态
658
+ if (gestureNode()) {
659
+ const [x, y] = gesturePoint()
660
+ fireGesture(gestureNode(), 'pointerdown', x, y)
661
+ fireGesture(window, 'pointermove', x + 40, y + 24)
662
+ fireGesture(window, 'pointerup', x + 40, y + 24)
663
+ await settle(200)
664
+ }
665
+ check('draggable: false 的标签拖不动(偏移状态一条都不写)', gestureView.getLabelStates('point').length === 0, JSON.stringify(gestureView.getLabelStates('point')))
666
+ // 换成只能拖不能点:拖得动,但点一下不发 labelClick
667
+ gestureView.updateOptions({ label: { enabled: true, content: '{name}', maxCount: 4, draggable: true, editable: false } })
668
+ await settle(220)
669
+ const gestureDragClicks = []
670
+ gestureView.on('labelClick', payload => gestureDragClicks.push(payload))
671
+ const gestureDrags = []
672
+ gestureView.on('labelDrag', payload => { if (payload.phase === 'end') gestureDrags.push(payload) })
673
+ if (gestureNode()) {
674
+ const [x, y] = gesturePoint()
675
+ fireGesture(gestureNode(), 'pointerdown', x, y)
676
+ fireGesture(window, 'pointerup', x, y)
677
+ await settle(120)
678
+ }
679
+ check('draggable: true / editable: false 的标签点一下不发 labelClick(拖得动不等于点得选)', gestureDragClicks.length === 0, `${gestureDragClicks.length} 条`)
680
+ if (gestureNode()) {
681
+ const [x, y] = gesturePoint()
682
+ fireGesture(gestureNode(), 'pointerdown', x, y)
683
+ fireGesture(window, 'pointermove', x + 30, y + 18)
684
+ fireGesture(window, 'pointerup', x + 30, y + 18)
685
+ await settle(220)
686
+ }
687
+ const dragStates = gestureView.getLabelStates('point')
688
+ check('同一条标签照旧拖得动(偏移记进状态,labelDrag 的 end 也发了)', gestureDrags.length === 1 && dragStates.length === 1 && Math.abs(dragStates[0].offset[1] - 18) < 1.5, JSON.stringify({ drags: gestureDrags.length, offset: dragStates[0]?.offset }))
689
+ gestureView.destroy()
690
+ gestureHost.remove()
691
+ // 17. 缩放往返与拖动之后的相对位置:标签尺寸走内容签名缓存、位置从自己写下的 transform 里算出来,
692
+ // 这两条都不该被缩放 / 重排弄出累积漂移(量测次数本身在 selftest-perf 里盯)
693
+ const zoomHost = document.createElement('div')
694
+ zoomHost.style.cssText = 'width:640px;height:380px'
695
+ document.body.append(zoomHost)
696
+ const zoomPoints = [
697
+ { id: 'a', x: 0, y: 0, name: '甲站' },
698
+ { id: 'b', x: 260, y: 0, name: '乙站' },
699
+ { id: 'c', x: 130, y: 180, name: '丙站' }
700
+ ]
701
+ const zoomView = createGisView(zoomHost, {
702
+ points: zoomPoints,
703
+ lines: [],
704
+ label: { enabled: true, content: '{name}', draggable: true, editable: true, maxCount: 5 },
705
+ showGrid: false
706
+ })
707
+ await settle(240)
708
+ const zoomNode = index => zoomHost.querySelector(`.gis-view__label[data-label-kind='point'][data-label-index='${index}']`)
709
+ // 标签中心相对「节点投影」的偏移:换了相机之后这个差要原样保留(放大不把它拉开、往返不累积漂移)
710
+ const zoomRelative = index => {
711
+ const center = centerOf(zoomNode(index))
712
+ const projected = zoomView.project(zoomPoints[index].x, zoomPoints[index].y)
713
+ const frame = zoomHost.getBoundingClientRect()
714
+ return [center[0] - frame.left - projected[0], center[1] - frame.top - projected[1]]
715
+ }
716
+ // 放大倍数刻意留小:再大一点三个节点就被推出视口,量到的「没有标签」是正常的
717
+ const zoomCamera = zoomView.getCamera()
718
+ const zoomBefore = [0, 1, 2].map(zoomRelative)
719
+ zoomView.setCamera({ ...zoomCamera, scale: zoomCamera.scale * 1.25 })
720
+ await settle(260)
721
+ const zoomIn = [0, 1, 2].map(zoomRelative)
722
+ zoomView.setCamera(zoomCamera)
723
+ await settle(260)
724
+ const zoomBack = [0, 1, 2].map(zoomRelative)
725
+ const driftOf = (list, base) => list.map((value, index) => Math.max(Math.abs(value[0] - base[index][0]), Math.abs(value[1] - base[index][1])))
726
+ check('放大之后标签还贴着各自的节点(相对偏移不变)', driftOf(zoomIn, zoomBefore).every(value => value <= 1), `三个标签最大差 ${driftOf(zoomIn, zoomBefore).map(value => value.toFixed(2)).join(' / ')} px`)
727
+ check('缩回原样:标签位置与缩放前一致(往返一次漂移 ≤ 0.5 像素)', driftOf(zoomBack, zoomBefore).every(value => value <= 0.5), `三个标签最大差 ${driftOf(zoomBack, zoomBefore).map(value => value.toFixed(2)).join(' / ')} px`)
728
+ // 拖过的标签再经历「改样式强制重排 + 平移往返」:偏移一个像素都不该走样
729
+ const zoomDragStart = centerOf(zoomNode(0))
730
+ fire(zoomNode(0), 'pointerdown', zoomDragStart[0], zoomDragStart[1])
731
+ fire(window, 'pointermove', zoomDragStart[0] + 48, zoomDragStart[1] + 30)
732
+ fire(window, 'pointerup', zoomDragStart[0] + 48, zoomDragStart[1] + 30)
733
+ await settle(220)
734
+ const zoomDragged = zoomRelative(0)
735
+ zoomView.updateLabel('point', 'a', { style: { color: 'rgb(17, 68, 119)' } })
736
+ await settle(180)
737
+ const zoomAfterRelayout = zoomRelative(0)
738
+ const zoomPan = zoomView.getCamera()
739
+ zoomView.setCamera({ ...zoomPan, center: [zoomPan.center[0] + 20, zoomPan.center[1] + 10] })
740
+ await settle(280)
741
+ zoomView.setCamera(zoomPan)
742
+ await settle(280)
743
+ const zoomAfterPan = zoomRelative(0)
744
+ const dragDrift = Math.max(Math.abs(zoomAfterRelayout[0] - zoomDragged[0]), Math.abs(zoomAfterRelayout[1] - zoomDragged[1]), Math.abs(zoomAfterPan[0] - zoomDragged[0]), Math.abs(zoomAfterPan[1] - zoomDragged[1]))
745
+ check('拖过的标签在强制重排 / 平移往返之后偏移仍然精确(≤ 0.5 像素)', dragDrift <= 0.5, `拖后 ${zoomDragged.map(value => value.toFixed(2)).join(' / ')} · 重排后 ${zoomAfterRelayout.map(value => value.toFixed(2)).join(' / ')} · 平移往返后 ${zoomAfterPan.map(value => value.toFixed(2)).join(' / ')}`)
746
+ check('拖出来的偏移还在状态里(往返没有把它清掉)', Math.abs(zoomView.getLabelStates('point').find(state => state.id === 'a')?.offset?.[0] - 48) <= 1, JSON.stringify(zoomView.getLabelStates('point').map(state => state.offset)))
747
747
  zoomView.destroy()
748
748
  // 20. 标签撞上之后的第二条路:换锚点 / 沿线挪(anchorFallback / slideFallback),底图区域文字只算软冲突
749
749
  const overlaps = (a, b) => a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom
@@ -903,7 +903,7 @@
903
903
  bgLineView.destroy()
904
904
  bgHost.textContent = ''
905
905
  bgHost.remove()
906
-
906
+
907
907
  // 21. 标签显示优化开关:缺省(不写)走「强制显示」——不避让、不按 maxCount 截断;打开才与老口径一致
908
908
  const optHost = document.createElement('div')
909
909
  optHost.style.cssText = 'width:520px;height:320px'
@@ -1452,13 +1452,13 @@
1452
1452
  reView.destroy()
1453
1453
  multiHost.remove()
1454
1454
  reHost.remove()
1455
- view.destroy()
1456
- }
1457
- catch (error) {
1458
- results.push(`ERROR ${error && error.stack ? error.stack : String(error)}`)
1459
- }
1460
- document.querySelector('#out').textContent = results.join('\n')
1461
- document.title = results.some(line => line.startsWith('FAIL') || line.startsWith('ERROR')) ? 'labels FAIL' : 'labels PASS'
1462
- </script>
1463
- </body>
1464
- </html>
1455
+ view.destroy()
1456
+ }
1457
+ catch (error) {
1458
+ results.push(`ERROR ${error && error.stack ? error.stack : String(error)}`)
1459
+ }
1460
+ document.querySelector('#out').textContent = results.join('\n')
1461
+ document.title = results.some(line => line.startsWith('FAIL') || line.startsWith('ERROR')) ? 'labels FAIL' : 'labels PASS'
1462
+ </script>
1463
+ </body>
1464
+ </html>