gis-view-webgl2 0.1.7 → 0.1.9

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 (70) hide show
  1. package/README.md +14 -11
  2. package/dist/gis-view.css +1 -1
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.d.ts +445 -241
  5. package/dist/index.js +1 -1
  6. package/docs/AI/345/257/271/350/257/235/346/261/207/346/200/273.md +65 -498
  7. package/docs/GisView.md +1 -1
  8. package/docs/GisView/345/256/236/347/216/260/345/216/237/347/220/206.md +1 -1
  9. package/docs/ai/06-/344/274/232/350/257/235/347/264/242/345/274/225/344/270/216/345/244/215/346/237/245/346/226/271/346/263/225.md +114 -478
  10. package/docs/demos/BasicDemo.vue +144 -27
  11. package/docs/demos/BatchAddDemo.vue +133 -7
  12. package/docs/demos/ChannelDemo.vue +61 -0
  13. package/docs/demos/ConnectDemo.vue +164 -33
  14. package/docs/demos/CopyMergeDemo.vue +144 -42
  15. package/docs/demos/EventDemo.vue +29 -24
  16. package/docs/demos/LabelDemo.vue +5 -5
  17. package/docs/demos/api-index.js +53 -41
  18. package/docs/demos/gis-view-demo/GisViewDemoCardHeader.vue +1 -1
  19. package/docs/demos/gis-view-demo/GisViewDemoStage.vue +1 -1
  20. package/docs/demos/gis-view-demo/panels/DataPanels.vue +1 -1
  21. package/docs/demos/gis-view-demo/useGisViewDemo.js +31 -33
  22. package/docs/format/02-/350/212/202/347/202/271/344/270/216/347/272/277/350/267/257.md +1 -0
  23. package/docs/format/04-/351/200/232/351/201/223/344/270/216/346/240/207/347/255/276.md +3 -0
  24. package/docs/format/07-/344/272/213/344/273/266/350/275/275/350/215/267/344/270/216/347/244/272/344/276/213/346/225/260/346/215/256.md +8 -7
  25. package/docs/from0/05-/347/233/270/346/234/272/344/270/216/344/272/244/344/272/222.md +1 -1
  26. package/docs/from0/07-/351/200/232/351/201/223/345/210/206/347/273/204/344/270/216/345/244/232/345/233/236/347/272/277.md +26 -10
  27. package/docs/index.html +136 -13
  28. package/docs/internals/15-04-/351/200/220/350/275/256/350/256/260/345/275/225-/347/203/255/345/212/233/345/233/276/344/270/216/346/200/247/350/203/275.md +10 -0
  29. package/docs/manual/02-/345/235/220/346/240/207/344/270/216/346/225/260/346/215/256.md +1 -1
  30. package/docs/manual/03-/351/205/215/347/275/256/351/241/271.md +82 -14
  31. package/docs/manual/04-/344/272/213/344/273/266/344/270/216/345/233/236/350/260/203.md +23 -17
  32. package/docs/manual/05-/347/224/273/350/264/250/344/270/216/346/211/271/351/207/217/346/233/264/346/226/260.md +65 -0
  33. 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 +9 -10
  34. 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 +100 -40
  35. package/docs/manual/08-/351/200/211/344/270/255/344/270/216/346/213/220/347/202/271.md +11 -7
  36. package/docs/manual/09-/351/200/232/351/201/223/344/270/216/345/244/232/345/233/236/347/272/277.md +66 -6
  37. 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 +226 -493
  38. package/docs/manual/12-/346/240/207/347/255/276/344/270/216/350/243/205/351/245/260.md +28 -5
  39. package/docs/manual/14-/350/247/206/345/233/276/344/270/216/347/255/233/351/200/211.md +46 -14
  40. package/docs/manual/15-/345/257/274/345/207/272/344/270/216/351/233/206/346/210/220.md +5 -2
  41. package/docs/manual/16-/345/256/236/344/276/213/346/226/271/346/263/225/346/200/273/350/241/250.md +34 -29
  42. package/docs/manual/17-/345/217/202/346/225/260/351/200/237/346/237/245.md +30 -22
  43. package/docs/manual/18-/351/200/220/346/226/271/346/263/225/347/244/272/344/276/213.md +168 -81
  44. package/docs/manual/19-/350/257/273/346/225/260/344/270/216/346/213/226/346/213/275/351/242/204/350/247/210.md +1 -1
  45. package/docs/manual/20-/346/200/247/350/203/275/344/270/216/345/244/247/346/225/260/346/215/256.md +5 -3
  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 +9 -6
  47. package/docs/steps/README.md +201 -120
  48. package/docs/steps/bench-scale.html +1 -1
  49. package/docs/steps/index.html +222 -6
  50. package/docs/steps/selftest-actions.html +26 -25
  51. package/docs/steps/selftest-data.html +1 -1
  52. package/docs/steps/selftest-demos.html +290 -16
  53. package/docs/steps/selftest-docs.html +17 -6
  54. package/docs/steps/selftest-features.html +594 -54
  55. package/docs/steps/selftest-full.html +3 -3
  56. package/docs/steps/selftest-labels.html +82 -1
  57. package/docs/steps/selftest-perf.html +62 -7
  58. package/docs/steps/selftest-select.html +71 -6
  59. package/docs/steps/step-05-pick.js +4 -8
  60. package/docs/steps/step-07-channels.js +3 -5
  61. package/docs/steps/step-08-labels.js +3 -3
  62. package/docs/steps/step-10-full.js +3 -3
  63. package/docs/steps/step-12-events.js +41 -24
  64. package/docs//344/273/2160/345/210/260/345/256/214/346/225/264/347/232/204/345/256/236/347/216/260.md +1 -0
  65. package/package.json +1 -1
  66. package/docs/ai/01-/350/265/267/346/272/220/344/270/216/350/277/231/344/273/275/346/261/207/346/200/273/347/232/204/347/224/261/346/235/245.md +0 -29
  67. package/docs/ai/02-/346/214/211/345/206/205/345/256/271/345/210/206/347/261/273-/350/277/231/344/270/200/350/267/257/351/203/275/345/234/250/346/224/271/344/273/200/344/271/210.md +0 -66
  68. package/docs/ai/03-/351/207/215/350/246/201/351/227/256/347/255/224/351/200/211/357/274/210/344/270/200/357/274/211/357/274/232/351/200/211/345/236/213/351/200/232/351/201/223/344/270/216/346/240/267/345/274/217.md +0 -178
  69. package/docs/ai/04-/351/207/215/350/246/201/351/227/256/347/255/224/351/200/211/357/274/210/344/272/214/357/274/211/357/274/232/344/272/244/344/272/222/346/200/247/350/203/275/344/270/216/346/225/260/346/215/256.md +0 -140
  70. package/docs/ai/05-/351/207/215/350/246/201/351/227/256/347/255/224/351/200/211/357/274/210/344/270/211/357/274/211/357/274/232/345/256/277/344/270/273/346/227/201/346/224/257/344/270/216/346/224/266/345/217/243.md +0 -43
@@ -3,9 +3,11 @@
3
3
  * 可视化连线:只靠 points + lines 也能独立成图,再把「新增一条线」的几种常见配置摆在一起。
4
4
  *
5
5
  * 这一页的初始数据明确是 channels: [] —— 没有任何通道,节点与线路照样能渲染、选中与新增。
6
- * 「开始连线」走 beginAddChannel(kind 为 line 或 channel),两个节点选完先出草稿:
6
+ * 「开始连线」按类型分流:独立线路走 beginAddLine,通道走 beginAddChannel,两个节点选完先出草稿:
7
7
  * - routing: 'orthogonal' 让预览自动补一个先横后纵的 90° 拐点;
8
- * - channelDraftStyle 直接交给 beginAddChannel,草稿期间用 updateAddChannelStyle 原地改箭头位置 / 间距 / 配色;
8
+ * - channelDraftStyle 直接交给 beginAddLine / beginAddChannel,草稿期间用 updateAddChannelStyle 原地改箭头位置 / 间距 / 配色;
9
+ * - 预置一条已有通道(两组)后,可演示 singleChannel 阻断、existingChannel 新分组或并入指定分组;
10
+ * - beginAddLine 显式写 existingChannel 时会把新回线挂进已有通道,不写则始终是独立线路;
9
11
  * - 取消只收草稿,数据一个字节不动;
10
12
  * - 选好起点后点节点以外的位置也会直接取消,事件里的 reason 是 click-outside-node;
11
13
  * - 默认添加走 commitAddChannel;
@@ -29,6 +31,10 @@ const arrowSize = ref(12)
29
31
  const arrowPositionsText = ref('0.5')
30
32
  const circuitSpacing = ref(12)
31
33
  const startPointId = ref('')
34
+ const singleChannel = ref(true)
35
+ const existingChannelPolicy = ref('none')
36
+ const groupTarget = ref('')
37
+ const existingGroups = ref([])
32
38
  const dotsVisible = ref(true)
33
39
  const dotSize = ref(5)
34
40
  const startDotColor = ref('#2f6fd0')
@@ -38,6 +44,7 @@ const draftLayer = ref('above')
38
44
  const draft = ref(null)
39
45
  const actionText = ref('配置好线条之后点「开始连线」,依次点两个节点')
40
46
  const dataText = ref('正在创建视图…')
47
+ const eventText = ref('channelDraft:尚未触发')
41
48
  const cancelText = ref('尚未取消:选好起点后点一下节点以外的空白处,应收到 reason = click-outside-node')
42
49
  const logLines = ref([])
43
50
  const logText = computed(() => logLines.value.length
@@ -78,6 +85,15 @@ const createInitialLines = () => {
78
85
 
79
86
  const isChannel = computed(() => linkKind.value !== 'line')
80
87
  const circuits = computed(() => linkKind.value === 'channel-3' ? 3 : linkKind.value === 'channel-2' ? 2 : 1)
88
+ const addMethod = computed(() => isChannel.value ? 'beginAddChannel' : 'beginAddLine')
89
+ const groupLineCount = group => Number(group?.lineCount ?? group?.lineIndexes?.length ?? group?.lineIds?.length ?? 0)
90
+ const groupOptions = computed(() => existingGroups.value.map((group, index) => ({
91
+ value: String(group.id),
92
+ label: `第 ${index + 1} 组 · ${String(group.id)} · ${groupLineCount(group)} 回`
93
+ })))
94
+ const existingText = computed(() => existingGroups.value.length
95
+ ? `已有通道 connect-existing-channel:${existingGroups.value.map((group, index) => `第 ${index + 1} 组 ${groupLineCount(group)} 回`).join(' / ')}`
96
+ : '已有通道:未预置(先点上方按钮创建一条两组的通道)')
81
97
  const arrowPositions = computed(() => arrowPositionsText.value.split(',').map(value => Number(value.trim())).filter(value => Number.isFinite(value)))
82
98
  const startPointLabel = computed(() => {
83
99
  const point = points.find(item => String(item.id) === String(startPointId.value))
@@ -100,7 +116,12 @@ const draftText = computed(() => {
100
116
  }
101
117
  const to = current.to.item?.name ?? current.to.id
102
118
  const bends = current.path?.length ?? 0
103
- return `${kind} · ${from} → ${to} · ${current.routing === 'orthogonal' ? '正交折线' : '直线'} · 中间拐点 ${bends} · 箭头 ${current.style?.arrow ? current.style.arrowPositions.map(value => `${Math.round(value * 100)}%`).join('/') : '关'} · 回线间距 ${current.style?.circuitSpacing ?? 0}px`
119
+ const policy = current.existingChannel
120
+ ? ` · existingChannel=${current.existingChannel}${current.targetGroupId !== null ? ` → ${String(current.targetGroupId)}` : ''}`
121
+ : ''
122
+ const single = current.singleChannel ? ' · singleChannel' : ''
123
+ const blocked = current.blocked ? ` · 已阻断:${current.reason ?? '未知原因'}` : ''
124
+ return `${kind}${single}${policy} · ${from} → ${to} · ${current.routing === 'orthogonal' ? '正交折线' : '直线'} · 中间拐点 ${bends} · 箭头 ${current.style?.arrow ? current.style.arrowPositions.map(value => `${Math.round(value * 100)}%`).join('/') : '关'} · 回线间距 ${current.style?.circuitSpacing ?? 0}px${blocked}`
104
125
  })
105
126
 
106
127
  const pushLog = (name, text) => {
@@ -143,24 +164,91 @@ const currentLineProps = () => ({
143
164
  direction: arrow.value ? { count: 1, size: arrowSize.value } : false
144
165
  })
145
166
 
167
+ /** 读取预置通道的分组,供 joinGroup 下拉直接选择;预置前后 / 草稿 ready 时都刷新一次 */
168
+ const refreshExistingGroups = () => {
169
+ if (!view) {
170
+ return
171
+ }
172
+ const between = view.getChannelBetween('node-0', 'node-1')
173
+ existingGroups.value = between?.hasChannel ? [...(between.groups ?? [])] : []
174
+ if (!existingGroups.value.some(group => String(group.id) === String(groupTarget.value))) {
175
+ groupTarget.value = existingGroups.value[0]?.id ?? ''
176
+ }
177
+ }
178
+
179
+ /** 预置一条 node-0 ↔ node-1 的通道,带两个分组,专门演示单一通道和指定分组 */
180
+ const seedExistingChannel = () => {
181
+ if (!view) {
182
+ return
183
+ }
184
+ view.endAddChannel()
185
+ draft.value = null
186
+ const between = view.getChannelBetween('node-0', 'node-1')
187
+ if (between?.hasChannel) {
188
+ refreshExistingGroups()
189
+ actionText.value = `已有通道 ${String(between.channelId)} 已存在,共 ${existingGroups.value.length} 组;可以直接开始连线`
190
+ pushLog('seed-existing', actionText.value)
191
+ return
192
+ }
193
+ const lineA = 'connect-existing-line-a'
194
+ const lineB = 'connect-existing-line-b'
195
+ const result = view.addChannel({
196
+ id: 'connect-existing-channel',
197
+ name: '演示已有通道(两组)',
198
+ from: 'node-0',
199
+ to: 'node-1'
200
+ }, [
201
+ { id: lineA, name: '已有通道 · A 组回线', color: '#2f6fd0', width: 2.2 },
202
+ { id: lineB, name: '已有通道 · B 组回线', color: '#d9544d', width: 2.2 }
203
+ ], [
204
+ { id: 'connect-existing-group-a', name: 'A 组', lineIds: [lineA] },
205
+ { id: 'connect-existing-group-b', name: 'B 组', lineIds: [lineB] }
206
+ ], { select: true })
207
+ refreshExistingGroups()
208
+ refreshState()
209
+ if (!result.ok) {
210
+ actionText.value = `预置已有通道失败:${result.reason ?? '未知原因'}`
211
+ }
212
+ else {
213
+ actionText.value = `已预置已有通道 ${String(result.channelId)}:node-0 ↔ node-1,共 ${existingGroups.value.length} 组;现在可用 singleChannel / newGroup / joinGroup 演示`
214
+ }
215
+ pushLog('addChannel(预置已有通道)', actionText.value)
216
+ emit('notice', actionText.value)
217
+ }
218
+
146
219
  const startDraft = () => {
147
220
  if (!view) {
148
221
  return
149
222
  }
150
223
  view.endAddChannel()
151
- view.beginAddChannel({
224
+ const options = {
152
225
  ...(startPointId.value ? { from: startPointId.value } : {}),
153
- kind: isChannel.value ? 'channel' : 'line',
154
226
  circuits: circuits.value,
155
227
  routing: routing.value,
156
228
  select: true,
157
229
  channelDraftStyle: currentDraftStyle(),
158
230
  lineProps: currentLineProps()
159
- })
231
+ }
232
+ if (existingChannelPolicy.value !== 'none') {
233
+ options.existingChannel = existingChannelPolicy.value
234
+ }
235
+ if (existingChannelPolicy.value === 'joinGroup' && groupTarget.value !== '') {
236
+ options.groupId = groupTarget.value
237
+ }
238
+ if (isChannel.value) {
239
+ // beginAddChannel 的单一通道模式:已有通道时按 existingChannel 处理,不写时默认 block
240
+ options.singleChannel = singleChannel.value
241
+ view.beginAddChannel(options)
242
+ }
243
+ else {
244
+ // beginAddLine 不认 singleChannel;要挂进已有通道必须显式写 existingChannel
245
+ view.beginAddLine(options)
246
+ }
247
+ const modeText = existingChannelPolicy.value === 'none' ? '未指定已有通道策略' : `existingChannel=${existingChannelPolicy.value}`
160
248
  actionText.value = startPointId.value
161
- ? `开始连线:起点已指定为 ${startPointLabel.value},下一次点节点只选终点(${isChannel.value ? `通道 ${circuits.value} 回` : '独立线路'} / ${routing.value === 'orthogonal' ? '正交折线' : '直线'})`
162
- : `开始连线:先点起点,再点终点(${isChannel.value ? `通道 ${circuits.value} 回` : '独立线路'} / ${routing.value === 'orthogonal' ? '正交折线' : '直线'})`
163
- pushLog('beginAddChannel', actionText.value)
249
+ ? `${addMethod.value}:起点已指定为 ${startPointLabel.value},下一次点节点只选终点(${isChannel.value ? `通道 ${circuits.value} 回` : '独立线路'} / ${modeText})`
250
+ : `${addMethod.value}:先点起点,再点终点(${isChannel.value ? `通道 ${circuits.value} 回` : '独立线路'} / ${modeText})`
251
+ pushLog(addMethod.value, actionText.value)
164
252
  }
165
253
 
166
254
  const cancelDraft = () => {
@@ -184,17 +272,15 @@ const commitDraft = () => {
184
272
  if (!view || !canFinish.value) {
185
273
  return
186
274
  }
187
- const result = view.commitAddChannel()
275
+ const result = view.commitAddChannel(undefined, { select: true })
188
276
  refreshState()
189
277
  if (!result.ok) {
190
- actionText.value = `commitAddChannel 没有落地:${result.reason ?? '未知原因'}`
278
+ actionText.value = `commitAddChannel → blocked:${result.reason ?? '未知原因'}`
191
279
  }
192
280
  else {
193
281
  const lines = result.lines?.length ?? (result.line ? 1 : 0)
194
- actionText.value = `commitAddChannel → ${result.kind === 'line' ? '独立线路' : '通道'} ${String(result.channelId ?? result.lineId ?? '')} · ${lines} 回线`
195
- if (result.lineIds?.length) {
196
- view.selectFeatures(result.lineIds)
197
- }
282
+ const group = result.groupId !== undefined && result.groupId !== null ? ` · 分组 ${String(result.groupId)}` : ''
283
+ actionText.value = `commitAddChannel → ${result.kind === 'line' ? '独立线路' : '通道'} ${String(result.channelId ?? result.lineId ?? '')} · ${lines} 回线${group}`
198
284
  }
199
285
  pushLog('commitAddChannel', actionText.value)
200
286
  emit('notice', actionText.value)
@@ -238,7 +324,7 @@ const commitWithObject = () => {
238
324
  groups: [{ id: channelId, lineIds, ...(middle ? { path: middle } : {}) }]
239
325
  }
240
326
  }
241
- const result = view.commitAddChannel(payload)
327
+ const result = view.commitAddChannel(current.kind === 'line' ? { line: payload } : { channel: payload }, { select: true })
242
328
  refreshState()
243
329
  if (!result.ok) {
244
330
  actionText.value = `commitAddChannel(对象) 没有落地:${result.reason ?? '未知原因'}`
@@ -246,9 +332,6 @@ const commitWithObject = () => {
246
332
  else {
247
333
  const lines = result.lines?.length ?? (result.line ? 1 : 0)
248
334
  actionText.value = `commitAddChannel(对象) → ${result.kind === 'line' ? '线路对象' : '通道对象'} ${String(result.channelId ?? result.lineId ?? '')} · ${lines} 回线`
249
- if (result.lineIds?.length) {
250
- view.selectFeatures(result.lineIds)
251
- }
252
335
  }
253
336
  pushLog('commitAddChannel(对象)', actionText.value)
254
337
  emit('notice', actionText.value)
@@ -303,23 +386,27 @@ const addWithChannels = () => {
303
386
  sequence += 1
304
387
  const middle = middlePathOf(current)
305
388
  const kind = current.kind === 'line' ? 'line' : 'channel'
306
- const result = view.addChannels([{
307
- id: `connect-${kind}-${sequence}`,
308
- name: `${kind === 'line' ? '独立线路' : '可视化通道'} ${sequence}`,
309
- from: current.from.id,
310
- to: current.to.id,
311
- kind,
312
- ...(kind === 'channel' ? { circuits: current.circuits } : {}),
313
- ...(middle ? { path: middle } : {}),
314
- lineProps: currentLineProps()
315
- }])
389
+ const result = kind === 'line'
390
+ ? view.addLine({
391
+ id: `connect-line-${sequence}`,
392
+ name: `独立线路 ${sequence}`,
393
+ from: current.from.id,
394
+ to: current.to.id,
395
+ ...(middle ? { path: middle } : {}),
396
+ lineProps: currentLineProps()
397
+ }, undefined, { select: true })
398
+ : view.addChannels([{
399
+ id: `connect-channel-${sequence}`,
400
+ name: `可视化通道 ${sequence}`,
401
+ from: current.from.id,
402
+ to: current.to.id,
403
+ ...(middle ? { path: middle } : {}),
404
+ lineData: Array.from({ length: Math.max(Number(current.circuits) || 1, 1) }, () => ({ ...currentLineProps() }))
405
+ }], { select: true })
316
406
  refreshState()
317
407
  if (result.ok) {
318
408
  view.endAddChannel()
319
409
  draft.value = null
320
- if (result.lineIds.length) {
321
- view.selectFeatures(result.lineIds)
322
- }
323
410
  actionText.value = `addChannels → ${result.count} 条 · ${result.lineIds.length} 回线${result.channelIds.length ? ` · 通道 ${result.channelIds.join(' / ')}` : ' · 未新建通道'}${middle ? ` · path ${middle.length} 拐点` : ''}`
324
411
  }
325
412
  else {
@@ -338,6 +425,11 @@ const resetScene = () => {
338
425
  view.clearHistory()
339
426
  view.fitView(56)
340
427
  draft.value = null
428
+ existingGroups.value = []
429
+ groupTarget.value = ''
430
+ existingChannelPolicy.value = 'none'
431
+ singleChannel.value = true
432
+ eventText.value = 'channelDraft:尚未触发'
341
433
  sequence = 0
342
434
  actionText.value = '已恢复为 channels: [] 的纯点 + 线场景'
343
435
  refreshState()
@@ -366,6 +458,15 @@ onMounted(() => {
366
458
  },
367
459
  onChannelDraft: payload => {
368
460
  draft.value = payload
461
+ const target = payload.targetGroupId !== undefined && payload.targetGroupId !== null ? ` · targetGroup=${String(payload.targetGroupId)}` : ''
462
+ eventText.value = `channelDraft:phase=${payload.phase} · kind=${payload.kind ?? '—'} · blocked=${payload.blocked === true} · singleChannel=${payload.singleChannel === true} · existingChannel=${payload.existingChannel ?? 'null'}${target}${payload.reason ? ` · reason=${payload.reason}` : ''}`
463
+ if (payload.phase === 'ready' && payload.hasChannel) {
464
+ refreshExistingGroups()
465
+ }
466
+ if (payload.phase === 'blocked') {
467
+ actionText.value = `失败事件(phase=blocked):${payload.reason ?? '当前草稿不允许新增'}`
468
+ emit('notice', actionText.value)
469
+ }
369
470
  if (payload.phase === 'cancel') {
370
471
  cancelText.value = `取消:phase = ${payload.phase} · reason = ${payload.reason ?? '未提供'} · 起点 ${payload.from?.item?.name ?? payload.from?.id ?? '—'}`
371
472
  actionText.value = payload.reason === 'click-outside-node'
@@ -438,7 +539,36 @@ onBeforeUnmount(() => {
438
539
  </label>
439
540
  <label>回线间距 <input v-model.number="circuitSpacing" type="range" min="6" max="28" step="1" data-demo="connect-spacing" /> {{ circuitSpacing }}px</label>
440
541
  </div>
441
- <p class="dv-hint">草稿样式直接传给 <code>beginAddChannel({ channelDraftStyle })</code>;连线期间改颜色 / 线宽 / 箭头位置 / 回线间距会走 <code>updateAddChannelStyle()</code>,预览立即更新。正交折线会自动补一个 90 度拐点;落地线路用完整 path,通道只写中间拐点。</p>
542
+ <p class="dv-hint">草稿样式直接传给 <code>beginAddLine</code> / <code>beginAddChannel</code>;连线期间改颜色 / 线宽 / 箭头位置 / 回线间距会走 <code>updateAddChannelStyle()</code>,预览立即更新。正交折线会自动补一个 90 度拐点;落地线路用完整 path,通道只写中间拐点。</p>
543
+ </div>
544
+
545
+ <div class="dv-group">
546
+ <h3>单一通道与已有分组</h3>
547
+ <div class="dv-row">
548
+ <button type="button" class="dv-button" data-demo="connect-seed-existing" :disabled="isDraftLive" @click="seedExistingChannel">预置已有通道(两组)</button>
549
+ </div>
550
+ <div class="dv-row">
551
+ <label><input v-model="singleChannel" type="checkbox" data-demo="connect-single-channel" :disabled="!isChannel || isDraftLive" /> singleChannel</label>
552
+ <label>已有通道时
553
+ <select v-model="existingChannelPolicy" data-demo="connect-existing-mode" :disabled="isDraftLive">
554
+ <option value="none">不指定(线路默认独立)</option>
555
+ <option value="auto">auto · 新分组优先</option>
556
+ <option value="newGroup">newGroup · 新分组</option>
557
+ <option value="joinGroup">joinGroup · 指定分组</option>
558
+ <option value="block">block · 直接失败</option>
559
+ </select>
560
+ </label>
561
+ </div>
562
+ <div class="dv-row">
563
+ <label>目标分组
564
+ <select v-model="groupTarget" data-demo="connect-group-target" :disabled="existingChannelPolicy !== 'joinGroup' || isDraftLive">
565
+ <option v-if="!groupOptions.length" value="">先预置已有通道</option>
566
+ <option v-for="group in groupOptions" :key="group.value" :value="group.value">{{ group.label }}</option>
567
+ </select>
568
+ </label>
569
+ </div>
570
+ <p class="dv-readout" data-demo="connect-existing-readout">{{ existingText }}</p>
571
+ <p class="dv-hint"><code>beginAddChannel({ singleChannel: true })</code> 在两端已有通道时默认阻断;显式 <code>existingChannel</code> 可改为新分组 / 指定分组 / 自动。<code>beginAddLine</code> 不读 singleChannel,但显式写 <code>existingChannel: 'newGroup' / 'joinGroup'</code> 时,会把新回线加进已有通道。</p>
442
572
  </div>
443
573
 
444
574
  <div class="dv-group">
@@ -481,6 +611,7 @@ onBeforeUnmount(() => {
481
611
  <p class="dv-readout" data-demo="connect-draft-readout">{{ draftText }}</p>
482
612
  <p class="dv-readout" data-demo="connect-data-readout">{{ dataText }}</p>
483
613
  <p class="dv-readout" data-demo="connect-action-readout">{{ actionText }}</p>
614
+ <p class="dv-readout" data-demo="connect-event-readout">{{ eventText }}</p>
484
615
  <p class="dv-readout" data-demo="connect-cancel-readout">{{ cancelText }}</p>
485
616
  </div>
486
617
 
@@ -25,7 +25,8 @@ const copyItemsText = ref('—')
25
25
  const copyLineChannelsText = ref('—')
26
26
  const manualText = ref('—')
27
27
  const mergeBackendText = ref('合并变更 → 后端事务:—')
28
- const splitText = ref('拆分:先选中一个节点(不选就取第一个),再点拆分')
28
+ const splitText = ref('自动解除合并:先选中一个合并后的节点(不选就找第一个带 mergeInfo 的节点)')
29
+ const lastSplitResult = shallowRef(null)
29
30
  const copyBackendText = ref('复制落库:—')
30
31
  const mergeSyncText = ref('合并落库:—')
31
32
  const backendMode = ref('success')
@@ -44,8 +45,12 @@ const mergedVoltage = ref('525')
44
45
  const mergedColor = ref('#ff5d8f')
45
46
  const mergedSize = ref('16')
46
47
  const mergedSymbol = ref('square')
47
- const mergedStrokeColor = ref('#ffffff')
48
- const mergeTargetId = ref('')
48
+ const mergedStrokeColor = ref('#ffffff')
49
+ /** 合并时取双方最高值的字段;空字符串表示不取任何键 */
50
+ const mergeMaxKeys = ref('')
51
+ /** 拆分 / 自动解除合并后是否选中结果节点 */
52
+ const splitSelect = ref(false)
53
+ const mergeTargetId = ref('')
49
54
  const mergeKeep = ref('target')
50
55
  const rewriteOnConfirm = ref(true)
51
56
  const pointOptions = ref([])
@@ -72,6 +77,7 @@ let disposed = false
72
77
 
73
78
  const pointLabel = item => String(item?.name ?? item?.label ?? item?.id ?? '节点')
74
79
  const pointKey = item => String(item?.id ?? item?.label ?? item?.name ?? '—')
80
+ const parsedMergeMaxKeys = () => mergeMaxKeys.value.split(',').map(key => key.trim()).filter(Boolean)
75
81
  const itemId = item => (item?.id ?? item?.line_id ?? item?.channel_id ?? '无 id')
76
82
  /** 复制载荷的 lines 与 lineChannels 一一对齐:直接列出每条回线复制后属于哪条通道 / 哪一组 */
77
83
  const describeLineChannels = (payload) => {
@@ -101,6 +107,14 @@ const describeMergeChanges = changes => describeMergeChangeSets(changes ? [chang
101
107
  const describeMergeImpact = impact => (impact
102
108
  ? `影响 ${impact.lineCount} 条线路(改线 ${impact.linesChanged} / 删线 ${impact.removedLines})· 通道 改 ${impact.channelsChanged} / 删 ${impact.removedChannels} / 并 ${impact.mergedChannels}`
103
109
  : '影响读数:当前数据对不上')
110
+ const voltageOf = item => item?.vn_kv ?? item?.voltage ?? item?.voltageKv ?? item?.voltage_level ?? item?.voltageLevel ?? '—'
111
+ /** 合并追溯读数:来源、保留前电压与快照条数,演示解除合并需要的字段都在这里 */
112
+ const describeMergeInfo = (item) => {
113
+ const info = item?.mergeInfo
114
+ if (!info || typeof info !== 'object') return 'mergeInfo —'
115
+ const sourceIds = (info.sourceIds ?? info.points?.map(snapshot => snapshot.id) ?? []).map(id => String(id ?? '无 id')).join(' / ') || '—'
116
+ return `mergeInfo v${info.version} · 保留前 ${info.targetPoint?.name ?? '—'} / ${voltageOf(info.targetPoint)} kV · 来源 [${sourceIds}] · 快照 线 ${info.lines?.length ?? 0} / 通道 ${info.channels?.length ?? 0}`
117
+ }
104
118
  const backendBusy = computed(() => backendBusyCount.value > 0)
105
119
  const backendStateText = computed(() => `假后端 v${backendSnapshot.value.version} · 已应用 ${backendSnapshot.value.applied} 条 · op 缓存 ${backendSnapshot.value.cached} · 幂等回放 ${backendSnapshot.value.replays ?? 0} 次 · 模拟延迟 ${backendSnapshot.value.latency}ms`)
106
120
  const backendModeHint = computed(() => FAKE_BACKEND_MODES.find(item => item.value === backendMode.value)?.hint ?? '')
@@ -748,42 +762,64 @@ const prepareMerge = () => {
748
762
  return actionText.value
749
763
  }
750
764
 
751
- /** 节点拆分:把连在来源节点上的线路隔一条分给第二份,通道端点默认跟随成员线路 */
765
+ /** 解除合并:优先用 mergeInfo 自动恢复全部来源;没有合并记录时才退回手工两份拆分 */
752
766
  const splitSelectedPoint = () => {
753
767
  const data = view?.getData()
754
768
  if (!data?.points?.length) return '没有可拆分的节点'
755
- const source = selectedPoints.value[0] ?? data.points[0]
756
- const linked = data.lines
757
- .map((line, index) => ({ line, index }))
758
- .filter(({ line }) => String(line.from) === String(source.id) || String(line.to) === String(source.id))
759
- const movedLines = linked.filter((_entry, index) => index % 2 === 1).map(entry => entry.line.id ?? entry.index)
769
+ const mergedPoint = selectedPoints.value.find(item => item?.mergeInfo) ?? data.points.find(item => item?.mergeInfo)
770
+ const source = mergedPoint ?? selectedPoints.value[0] ?? data.points[0]
771
+ const auto = Boolean(mergedPoint?.mergeInfo)
760
772
  splitRound += 1
761
773
  const offset = 0.42 + splitRound * 0.04
762
- const result = view.splitPoint(source.id, [
763
- {
764
- x: Number(source.x) - offset,
765
- y: Number(source.y) - offset * 0.55,
766
- name: `${pointLabel(source)} · A`,
767
- zone_name: '拆分后 · A'
768
- },
769
- {
770
- x: Number(source.x) + offset,
771
- y: Number(source.y) + offset * 0.55,
772
- name: `${pointLabel(source)} · B`,
773
- zone_name: '拆分后 · B',
774
- lines: movedLines
775
- }
776
- ])
774
+ const result = auto
775
+ ? view.splitPoint(source.id, null, { label: '解除合并(自动恢复)', select: splitSelect.value })
776
+ : view.splitPoint(source.id, [
777
+ {
778
+ x: Number(source.x) - offset,
779
+ y: Number(source.y) - offset * 0.55,
780
+ name: `${pointLabel(source)} · A`,
781
+ zone_name: '解除合并后 · A'
782
+ },
783
+ {
784
+ x: Number(source.x) + offset,
785
+ y: Number(source.y) + offset * 0.55,
786
+ name: `${pointLabel(source)} · B`,
787
+ zone_name: '解除合并后 · B'
788
+ }
789
+ ], { autoLines: 'alternate', label: '解除合并(手工拆分)', select: splitSelect.value })
790
+ lastSplitResult.value = result
777
791
  if (!result.ok) {
778
- splitText.value = `拆分失败:${result.reason}`
792
+ splitText.value = `解除合并失败:${result.reason}`
779
793
  actionText.value = splitText.value
780
794
  pushLog('splitPoint', splitText.value)
781
795
  return splitText.value
782
796
  }
783
- splitText.value = `splitPoint:${pointLabel(source)} → ${result.pointIds.join(' / ')} · 改线 ${result.linesChanged} / 删线 ${result.removedLines} · 改通道 ${result.channelsChanged} / 删通道 ${result.removedChannels} · 第 2 份接回线 ${movedLines.length} 条`
797
+ if (result.autoRestored) {
798
+ const restoredNames = (result.items ?? []).map(item => `${pointLabel(item)}(${String(voltageOf(item))} kV)`).join(' / ')
799
+ splitText.value = `splitPoint(自动解除合并):${pointLabel(source)} → ${restoredNames} · 恢复来源 ${(result.mergeSourceIds ?? []).map(id => String(id)).join(' / ') || '—'} · 改线 ${result.linesChanged} / 改通道 ${result.channelsChanged} · ${splitSelect.value ? '已选中拆分结果' : '未选中拆分结果'} · mergeInfo 已从保留节点移除`
800
+ actionText.value = splitText.value
801
+ pushLog('splitPoint/auto', splitText.value)
802
+ refreshState()
803
+ return splitText.value
804
+ }
805
+ const lineSlot = new Map()
806
+ for (const assignment of result.lineAssignments ?? []) {
807
+ for (const lineId of assignment.lineIds ?? []) {
808
+ lineSlot.set(String(lineId), assignment.index)
809
+ }
810
+ }
811
+ const channelsKept = (data.channels ?? []).every((channel) => {
812
+ const slots = new Set()
813
+ for (const lineId of channel.lineIds ?? []) {
814
+ const slot = lineSlot.get(String(lineId))
815
+ if (slot !== undefined) slots.add(slot)
816
+ }
817
+ return slots.size <= 1
818
+ })
819
+ const counts = result.lineAssignments.map(assignment => `${assignment.index === 0 ? 'A' : assignment.index === 1 ? 'B' : String(assignment.index + 1)} ${assignment.lineCount}`).join(' / ')
820
+ splitText.value = `splitPoint(解除合并):${pointLabel(source)} → ${result.pointIds.join(' / ')} · autoLines=${result.autoLines} · 自动分线 ${result.autoAssignedLines}(${counts})· 改线 ${result.linesChanged} / 删线 ${result.removedLines} · 改通道 ${result.channelsChanged} / 删通道 ${result.removedChannels} · 通道成组${channelsKept ? '保持完整' : '发生拆分'} · ${splitSelect.value ? '已选中拆分结果' : '未选中拆分结果'}`
784
821
  actionText.value = splitText.value
785
822
  pushLog('splitPoint', splitText.value)
786
- view.selectFeatures(result.pointIds)
787
823
  refreshState()
788
824
  return splitText.value
789
825
  }
@@ -804,7 +840,7 @@ const mergePatch = () => {
804
840
  return patch
805
841
  }
806
842
 
807
- const mergeSelectedByApi = () => {
843
+ const mergeSelectedByApi = () => {
808
844
  const points = selectedPoints.value
809
845
  if (points.length < 2) return 'mergeSelectedPoints:至少选中两个节点'
810
846
  const targetId = mergeTargetOptions.value.some(item => item.value === mergeTargetId.value)
@@ -829,8 +865,59 @@ const mergeSelectedByApi = () => {
829
865
  actionText.value = mergeText.value
830
866
  pushLog('mergeSelectedPoints', mergeText.value)
831
867
  refreshState()
832
- return mergeText.value
833
- }
868
+ return mergeText.value
869
+ }
870
+
871
+ /** 把面板上的字段清单写进 merge.maxKeys;空清单表示所有业务字段都取保留节点 */
872
+ const applyMergeMaxKeys = () => {
873
+ if (!view) return '画布还没准备好'
874
+ const maxKeys = parsedMergeMaxKeys()
875
+ const merge = view.getOptions().merge ?? {}
876
+ view.updateOptions({ merge: { ...merge, enabled: true, maxKeys } })
877
+ actionText.value = maxKeys.length
878
+ ? `merge.maxKeys = [${maxKeys.join(', ')}]:合并时这些数值字段取双方最高值`
879
+ : 'merge.maxKeys = []:合并时所有业务字段保留目标节点(组件不再默认取电压最高)'
880
+ pushLog('merge.maxKeys', actionText.value)
881
+ return actionText.value
882
+ }
883
+
884
+ /** 缺省合并:不传 item,按 merge.maxKeys 决定哪些数值字段取双方最高值 */
885
+ const mergeSelectedByDefault = () => {
886
+ const points = selectedPoints.value
887
+ if (points.length < 2) return 'mergeSelectedPoints:至少选中两个节点'
888
+ const targetId = mergeTargetOptions.value.some(item => item.value === mergeTargetId.value)
889
+ ? mergeTargetId.value
890
+ : mergeTargetOptions.value[0].value
891
+ const target = points.find(item => pointKey(item) === targetId)
892
+ const sources = points.filter(item => pointKey(item) !== targetId).map(item => item.id)
893
+ const maxKeys = parsedMergeMaxKeys()
894
+ const maxReadout = maxKeys.length
895
+ ? maxKeys.map((key) => {
896
+ const values = points.map(item => Number(item[key])).filter(Number.isFinite)
897
+ return `${key}=${values.length ? Math.max(...values) : '无数字'}`
898
+ }).join(' / ')
899
+ : '未配置(全部字段取保留节点)'
900
+ const result = view.mergeSelectedPoints({
901
+ target: { id: target?.id ?? targetId },
902
+ sources,
903
+ label: 'demo 缺省合并(maxKeys)'
904
+ })
905
+ if (result.ok) {
906
+ const item = result.target?.item
907
+ view.selectFeatures(target?.id ?? targetId)
908
+ mergeText.value = `mergeSelectedPoints(缺省规则)· 保留 ${pointLabel(item)} · maxKeys ${maxReadout} · 结果 ${String(voltageOf(item))} kV · 名称 ${item?.name ?? '—'} / 图标 ${String(item?.symbol ?? '—')} · ${describeMergeInfo(item)}`
909
+ pendingText.value = maxKeys.length
910
+ ? `这次没有 item 改写:maxKeys 列出的字段取参与场站最高值,其余字段取保留节点`
911
+ : '这次没有 item 改写:maxKeys 为空,全部业务字段(含电压)取保留节点'
912
+ }
913
+ else {
914
+ mergeText.value = `mergeSelectedPoints 失败:${result.reason}`
915
+ }
916
+ actionText.value = mergeText.value
917
+ pushLog('mergeSelectedPoints/default', mergeText.value)
918
+ refreshState()
919
+ return mergeText.value
920
+ }
834
921
 
835
922
  const handlePointMerge = (payload) => {
836
923
  if (payload.phase === 'pending') {
@@ -868,7 +955,7 @@ const handlePointMerge = (payload) => {
868
955
  if (payload.phase === 'merge') {
869
956
  mergeBackendText.value = payload.ok ? describeMergeChanges(payload.changes) : '合并变更 → 后端事务:合并失败,没有变更集'
870
957
  mergeText.value = payload.ok
871
- ? `merge · 被并 ${pointLabel(payload.source?.item)} → 保留 ${pointLabel(payload.target?.item)} · 最终 ${pointLabel(payload.item)} · 色 ${payload.item?.color ?? '—'} / 尺寸 ${payload.item?.size ?? '—'} / 图标 ${String(payload.item?.symbol ?? '—')} · 改线 ${payload.linesChanged} / 删线 ${payload.removedLines}`
958
+ ? `merge · 被并 ${pointLabel(payload.source?.item)} → 保留 ${pointLabel(payload.target?.item)} · 最终 ${pointLabel(payload.item)} · 电压 ${String(voltageOf(payload.item))} kV · 色 ${payload.item?.color ?? '—'} / 尺寸 ${payload.item?.size ?? '—'} / 图标 ${String(payload.item?.symbol ?? '—')} · 改线 ${payload.linesChanged} / 删线 ${payload.removedLines} · ${describeMergeInfo(payload.item)}`
872
959
  : `merge 失败:${payload.reason}`
873
960
  pendingMerge.value = null
874
961
  pendingText.value = payload.ok ? '合并已落地,拖动来源节点已经收进保留目标' : `合并失败:${payload.reason}`
@@ -921,13 +1008,16 @@ const resetScene = () => {
921
1008
  copyText.value = '复制:—'
922
1009
  copyLineChannelsText.value = '—'
923
1010
  mergeBackendText.value = '合并变更 → 后端事务:—'
924
- splitText.value = '拆分:先选中一个节点(不选就取第一个),再点拆分'
1011
+ splitText.value = '自动解除合并:先选中一个合并后的节点(不选就找第一个带 mergeInfo 的节点)'
925
1012
  clipboardText.value = '剪贴板:—'
926
1013
  copyEventText.value = '还没有 pointCopy 事件'
927
1014
  copySourcesText.value = '—'
928
1015
  copyItemsText.value = '—'
929
1016
  manualText.value = '—'
930
1017
  mergeKeep.value = 'target'
1018
+ mergeMaxKeys.value = ''
1019
+ splitSelect.value = false
1020
+ view.updateOptions({ merge: { enabled: true, distance: 12, channelGroups: 'keep', maxKeys: [] } })
931
1021
  resetBackendDemo()
932
1022
  copyRound = 0
933
1023
  pasteRound = 0
@@ -1000,7 +1090,7 @@ onMounted(() => {
1000
1090
  },
1001
1091
  // Ctrl+C / Ctrl+V 监听的三件套:copy 打开 + keyboard 打开 + 快捷键映射(不写也是这两个缺省值)
1002
1092
  shortcuts: { copy: 'ctrl+c', paste: 'ctrl+v' },
1003
- merge: { enabled: true, distance: 12, channelGroups: 'keep' },
1093
+ merge: { enabled: true, distance: 12, channelGroups: 'keep', maxKeys: [] },
1004
1094
  onReady: () => {
1005
1095
  nextTick(() => {
1006
1096
  view?.fitView(50)
@@ -1166,22 +1256,34 @@ onBeforeUnmount(() => {
1166
1256
  <input v-model="mergedStrokeColor" data-demo="merge-stroke-color" type="text" />
1167
1257
  </label>
1168
1258
  </div>
1169
- <div class="dv-row">
1170
- <label><input v-model="rewriteOnConfirm" type="checkbox" /> 手动拖拽合并确认时改写 payload.item</label>
1171
- </div>
1172
- <div class="dv-row">
1173
- <button type="button" class="dv-button dv-button--primary" data-demo="merge-selected" :disabled="!canMerge" @click="mergeSelectedByApi">合并选中(指定保留节点)</button>
1174
- </div>
1259
+ <div class="dv-row">
1260
+ <label><input v-model="rewriteOnConfirm" type="checkbox" /> 手动拖拽合并确认时改写 payload.item</label>
1261
+ </div>
1262
+ <div class="dv-row cm-form">
1263
+ <label>
1264
+ maxKeys(逗号分隔)
1265
+ <input v-model="mergeMaxKeys" data-demo="merge-max-keys" type="text" placeholder="例如 vn_kv,value;留空则都不取最高" />
1266
+ </label>
1267
+ <button type="button" class="dv-button" data-demo="merge-apply-max-keys" @click="applyMergeMaxKeys">应用 maxKeys</button>
1268
+ </div>
1269
+ <div class="dv-row">
1270
+ <button type="button" class="dv-button dv-button--primary" data-demo="merge-default" :disabled="!canMerge" @click="mergeSelectedByDefault">按缺省规则合并(按 maxKeys 取最高)</button>
1271
+ <button type="button" class="dv-button dv-button--primary" data-demo="merge-selected" :disabled="!canMerge" @click="mergeSelectedByApi">合并选中(指定保留节点)</button>
1272
+ </div>
1175
1273
  <p class="dv-readout" data-demo="merge-readout">{{ mergeText }}</p>
1176
1274
  <p class="dv-readout" data-demo="merge-backend-readout">{{ mergeBackendText }}</p>
1177
- <p class="dv-hint">mergeSelectedPoints 只并选中集;target 可以指定保留谁,item 只改最终节点的字段。拖点相撞走 pending / confirm 两站,确认前也能改写。合并落地后的 changes 可直接作为后端事务:删除来源节点、更新保留节点、更新 / 删除线路与通道。</p>
1275
+ <p class="dv-hint">「按缺省规则合并」不传 item:只有 <code>merge.maxKeys</code> 列出的数值字段取双方最高值(缺省为空,不取任何键),名称 / 图标等其余业务字段取保留节点;合并后的节点带 <code>mergeInfo</code>(来源快照、线路 / 通道快照)。「指定保留节点」仍可用 item 覆盖字段。拖点相撞走 pending / confirm 两站,确认前也能改写。合并落地后的 changes 可直接作为后端事务。</p>
1178
1276
  </div>
1179
1277
  <div class="dv-group">
1180
- <h3>4 · 拆分节点</h3>
1278
+ <h3>4 · 解除合并</h3>
1279
+ <div class="dv-row">
1280
+ <label><input v-model="splitSelect" data-demo="split-select" type="checkbox" /> 解除合并后选中恢复出的节点</label>
1281
+ </div>
1181
1282
  <div class="dv-row">
1182
- <button type="button" class="dv-button dv-button--primary" data-demo="split-selected" @click="splitSelectedPoint">拆分选中节点</button>
1283
+ <button type="button" class="dv-button dv-button--primary" data-demo="split-selected" @click="splitSelectedPoint">自动解除合并(恢复来源)</button>
1183
1284
  </div>
1184
1285
  <p class="dv-readout" data-demo="split-readout">{{ splitText }}</p>
1286
+ <p class="dv-hint"><code>splitPoint(source)</code> 省略 parts 时读取节点上的 <code>mergeInfo</code>,恢复合并前的保留站和全部来源节点,并按快照恢复线路端点、通道端点、分组与回线归属。默认 <code>select: false</code>,拆分后不选中任何恢复节点;勾选这一项才传 <code>select: true</code>。没有合并记录时自动退回 <code>splitPoint(source, [A, B], { autoLines: 'alternate' })</code> 的手工拆分;自动路径返回 <code>autoRestored: true</code> 与 <code>mergeSourceIds</code>。</p>
1185
1287
  </div>
1186
1288
  <div class="dv-group">
1187
1289
  <h3>5 · 待确认合并</h3>