gis-view-webgl2 0.1.6 → 0.1.8

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 (55) hide show
  1. package/README.md +9 -8
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.d.ts +316 -233
  4. package/dist/index.js +1 -1
  5. package/docs/AI/345/257/271/350/257/235/346/261/207/346/200/273.md +106 -0
  6. package/docs/GisView.md +1 -1
  7. 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 +110 -0
  8. package/docs/demos/BasicDemo.vue +2 -2
  9. package/docs/demos/BatchAddDemo.vue +6 -6
  10. package/docs/demos/ConnectDemo.vue +164 -33
  11. package/docs/demos/CopyMergeDemo.vue +27 -14
  12. package/docs/demos/EventDemo.vue +17 -20
  13. package/docs/demos/LabelDemo.vue +7 -7
  14. package/docs/demos/StyleDemo.vue +1 -1
  15. package/docs/demos/api-index.js +34 -31
  16. package/docs/demos/gis-view-demo/GisViewDemoCardHeader.vue +1 -1
  17. package/docs/demos/gis-view-demo/GisViewDemoStage.vue +1 -1
  18. package/docs/demos/gis-view-demo/panels/DataPanels.vue +1 -1
  19. package/docs/demos/gis-view-demo/useGisViewDemo.js +33 -35
  20. 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
  21. package/docs/from0/05-/347/233/270/346/234/272/344/270/216/344/272/244/344/272/222.md +1 -1
  22. 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
  23. package/docs/manual/02-/345/235/220/346/240/207/344/270/216/346/225/260/346/215/256.md +1 -1
  24. package/docs/manual/04-/344/272/213/344/273/266/344/270/216/345/233/236/350/260/203.md +49 -16
  25. 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
  26. 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 +60 -36
  27. package/docs/manual/08-/351/200/211/344/270/255/344/270/216/346/213/220/347/202/271.md +60 -4
  28. package/docs/manual/09-/351/200/232/351/201/223/344/270/216/345/244/232/345/233/236/347/272/277.md +17 -1
  29. 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
  30. package/docs/manual/12-/346/240/207/347/255/276/344/270/216/350/243/205/351/245/260.md +2 -2
  31. package/docs/manual/14-/350/247/206/345/233/276/344/270/216/347/255/233/351/200/211.md +12 -13
  32. package/docs/manual/16-/345/256/236/344/276/213/346/226/271/346/263/225/346/200/273/350/241/250.md +26 -23
  33. package/docs/manual/17-/345/217/202/346/225/260/351/200/237/346/237/245.md +23 -20
  34. package/docs/manual/18-/351/200/220/346/226/271/346/263/225/347/244/272/344/276/213.md +132 -79
  35. 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
  36. package/docs/manual/20-/346/200/247/350/203/275/344/270/216/345/244/247/346/225/260/346/215/256.md +2 -1
  37. 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 +4 -4
  38. package/docs/steps/README.md +9 -7
  39. package/docs/steps/bench-scale.html +1 -1
  40. package/docs/steps/index.html +6 -6
  41. package/docs/steps/selftest-actions.html +26 -25
  42. package/docs/steps/selftest-data.html +1 -1
  43. package/docs/steps/selftest-demos.html +102 -6
  44. package/docs/steps/selftest-docs.html +3 -3
  45. package/docs/steps/selftest-features.html +284 -52
  46. package/docs/steps/selftest-full.html +3 -3
  47. package/docs/steps/selftest-labels.html +1 -1
  48. package/docs/steps/selftest-perf.html +31 -4
  49. package/docs/steps/selftest-select.html +183 -1
  50. package/docs/steps/step-05-pick.js +4 -8
  51. package/docs/steps/step-07-channels.js +3 -5
  52. package/docs/steps/step-08-labels.js +3 -3
  53. package/docs/steps/step-10-full.js +3 -3
  54. package/docs/steps/step-12-events.js +41 -24
  55. package/package.json +1 -1
@@ -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('解除合并:先选中一个节点(不选就取第一个),再点按钮')
29
+ const lastSplitResult = shallowRef(null)
29
30
  const copyBackendText = ref('复制落库:—')
30
31
  const mergeSyncText = ref('合并落库:—')
31
32
  const backendMode = ref('success')
@@ -748,15 +749,11 @@ const prepareMerge = () => {
748
749
  return actionText.value
749
750
  }
750
751
 
751
- /** 节点拆分:把连在来源节点上的线路隔一条分给第二份,通道端点默认跟随成员线路 */
752
+ /** 解除合并:splitPoint 的 autoLines=alternate 按通道成组、独立线路单条轮流分配,不会把同通道成员拆散 */
752
753
  const splitSelectedPoint = () => {
753
754
  const data = view?.getData()
754
755
  if (!data?.points?.length) return '没有可拆分的节点'
755
756
  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)
760
757
  splitRound += 1
761
758
  const offset = 0.42 + splitRound * 0.04
762
759
  const result = view.splitPoint(source.id, [
@@ -764,23 +761,38 @@ const splitSelectedPoint = () => {
764
761
  x: Number(source.x) - offset,
765
762
  y: Number(source.y) - offset * 0.55,
766
763
  name: `${pointLabel(source)} · A`,
767
- zone_name: '拆分后 · A'
764
+ zone_name: '解除合并后 · A'
768
765
  },
769
766
  {
770
767
  x: Number(source.x) + offset,
771
768
  y: Number(source.y) + offset * 0.55,
772
769
  name: `${pointLabel(source)} · B`,
773
- zone_name: '拆分后 · B',
774
- lines: movedLines
770
+ zone_name: '解除合并后 · B'
775
771
  }
776
- ])
772
+ ], { autoLines: 'alternate', label: '解除合并' })
773
+ lastSplitResult.value = result
777
774
  if (!result.ok) {
778
- splitText.value = `拆分失败:${result.reason}`
775
+ splitText.value = `解除合并失败:${result.reason}`
779
776
  actionText.value = splitText.value
780
777
  pushLog('splitPoint', splitText.value)
781
778
  return splitText.value
782
779
  }
783
- splitText.value = `splitPoint:${pointLabel(source)} → ${result.pointIds.join(' / ')} · 改线 ${result.linesChanged} / 删线 ${result.removedLines} · 改通道 ${result.channelsChanged} / 删通道 ${result.removedChannels} · 第 2 份接回线 ${movedLines.length} 条`
780
+ const lineSlot = new Map()
781
+ for (const assignment of result.lineAssignments ?? []) {
782
+ for (const lineId of assignment.lineIds ?? []) {
783
+ lineSlot.set(String(lineId), assignment.index)
784
+ }
785
+ }
786
+ const channelsKept = (data.channels ?? []).every((channel) => {
787
+ const slots = new Set()
788
+ for (const lineId of channel.lineIds ?? []) {
789
+ const slot = lineSlot.get(String(lineId))
790
+ if (slot !== undefined) slots.add(slot)
791
+ }
792
+ return slots.size <= 1
793
+ })
794
+ const counts = result.lineAssignments.map(assignment => `${assignment.index === 0 ? 'A' : assignment.index === 1 ? 'B' : String(assignment.index + 1)} ${assignment.lineCount}`).join(' / ')
795
+ splitText.value = `splitPoint(解除合并):${pointLabel(source)} → ${result.pointIds.join(' / ')} · autoLines=${result.autoLines} · 自动分线 ${result.autoAssignedLines}(${counts})· 改线 ${result.linesChanged} / 删线 ${result.removedLines} · 改通道 ${result.channelsChanged} / 删通道 ${result.removedChannels} · 通道成组${channelsKept ? '保持完整' : '发生拆分'}`
784
796
  actionText.value = splitText.value
785
797
  pushLog('splitPoint', splitText.value)
786
798
  view.selectFeatures(result.pointIds)
@@ -1177,11 +1189,12 @@ onBeforeUnmount(() => {
1177
1189
  <p class="dv-hint">mergeSelectedPoints 只并选中集;target 可以指定保留谁,item 只改最终节点的字段。拖点相撞走 pending / confirm 两站,确认前也能改写。合并落地后的 changes 可直接作为后端事务:删除来源节点、更新保留节点、更新 / 删除线路与通道。</p>
1178
1190
  </div>
1179
1191
  <div class="dv-group">
1180
- <h3>4 · 拆分节点</h3>
1192
+ <h3>4 · 解除合并</h3>
1181
1193
  <div class="dv-row">
1182
- <button type="button" class="dv-button dv-button--primary" data-demo="split-selected" @click="splitSelectedPoint">拆分选中节点</button>
1194
+ <button type="button" class="dv-button dv-button--primary" data-demo="split-selected" @click="splitSelectedPoint">解除合并(拆分节点)</button>
1183
1195
  </div>
1184
1196
  <p class="dv-readout" data-demo="split-readout">{{ splitText }}</p>
1197
+ <p class="dv-hint"><code>splitPoint(source, [A, B], { autoLines: 'alternate' })</code> 把没显式分配的线路按“同一条通道的成员成组、独立线路单条”轮流分给 A / B;返回的 <code>autoAssignedLines</code> 与 <code>lineAssignments</code> 直接显示每一份接到哪些回线。</p>
1185
1198
  </div>
1186
1199
  <div class="dv-group">
1187
1200
  <h3>5 · 待确认合并</h3>
@@ -58,23 +58,22 @@ const describePayload = (payload) => {
58
58
  if (payload.index !== undefined) parts.push(`index=${payload.index}`)
59
59
  if (payload.labelId !== undefined) parts.push(`labelId=${payload.labelId}`)
60
60
  if (payload.count !== undefined) parts.push(`count=${payload.count}`)
61
- if (payload.selection?.count !== undefined) parts.push(`selection=${payload.selection.count}`)
62
- if (payload.channelId !== undefined) parts.push(`channel=${payload.channelId}`)
61
+ if (payload.channelId !== undefined) parts.push(`channel=${payload.channelId}`)
63
62
  if (payload.camera !== undefined) parts.push('camera=…')
64
63
  if (payload.screen !== undefined) parts.push(`screen=[${payload.screen.map(value => Number(value).toFixed(0)).join(', ')}]`)
65
64
  if (payload.action) {
66
65
  const action = payload.action
67
- parts.push(`action=${action.type}/${action.target}${action.moved ? '+moved' : ''}${action.double ? '+double' : ''}@${action.button ?? '-'}`)
68
- parts.push(`data=${dataLabel(action.data)}`)
66
+ parts.push(`action=${action.type}/${action.target}${action.moved ? '+moved' : ''}${action.double ? '+double' : ''}@${payload.originalEvent?.button ?? '-'}`)
67
+ parts.push(`data=${dataLabel(hitDataOf(payload))}`)
69
68
  }
70
69
  if (!parts.length) parts.push(`keys=[${Object.keys(payload).slice(0, 6).join(', ')}]`)
71
70
  return parts.join(' · ')
72
71
  }
73
72
 
74
73
  /**
75
- * action.data 给的是哪一份数据:节点 / 线路 / 通道看 id,选中集报条数,
76
- * 标签事件(labelClick / labelDrag / labelContextMenu / labelDblClick)给的是 { label, parent } —— 报内容与它挂在哪个要素上。
77
- */
74
+ * 命中的数据在载荷顶层:节点 / 线路看 item,通道看 channel,选中集报 items 条数,
75
+ * 标签(labelClick / labelDrag / labelContextMenu / labelDblClick)看 label / parent。
76
+ */
78
77
  const dataLabel = (value) => {
79
78
  if (Array.isArray(value)) return `${value.length} 项`
80
79
  if (!value || typeof value !== 'object') return String(value)
@@ -88,12 +87,10 @@ const dataLabel = (value) => {
88
87
  return value.id ?? value.channelId ?? value.name ?? `对象 keys=[${Object.keys(value).slice(0, 4).join(', ')}]`
89
88
  }
90
89
 
91
- /**
92
- * 要 DOM 事件的三个事件(pointerDown / pointerUp / contextMenu)在总线上
93
- * 载荷是 `{ payload, event }`(与回调的两个参数一一对应),其余事件载荷就是回调里那一份 —— 这里统一拆开。
94
- */
95
- const EVENT_PAYLOAD_EVENTS = new Set(['pointerDown', 'pointerUp', 'contextMenu'])
96
- const unwrapPayload = (raw, name) => (EVENT_PAYLOAD_EVENTS.has(name) && raw && typeof raw === 'object' && 'payload' in raw ? raw.payload : raw)
90
+ /** 一次事件里可读的那份顶层数据:不把通道 / 分组上下文再打成一个 action.data */
91
+ const hitDataOf = payload => payload.item ?? payload.channel ?? payload.label ?? payload.line
92
+ ?? (payload.type === 'cluster' ? { id: payload.id, count: payload.count } : null)
93
+ ?? (Array.isArray(payload.items) ? payload.items : null)
97
94
 
98
95
  /** 会带 action 的事件名:* 订阅里从载荷上认出「这一次是什么操作」 */
99
96
  const ACTION_EVENTS = new Set(['click', 'dblClick', 'pointerDown', 'pointerUp', 'contextMenu', 'labelClick', 'labelDrag', 'labelContextMenu', 'labelDblClick', 'selectChange', 'waypointChange'])
@@ -257,10 +254,8 @@ const dismissCopy = () => {
257
254
 
258
255
  onMounted(() => {
259
256
  view = createGisView(container.value, buildOptions())
260
- // 一条 '*' 订阅全部事件:回调拿到的第二参就是事件名(与 options 里的 onXxx 同一份载荷)
261
- offAll = view.on('*', (raw, name) => {
262
- // 四个要 DOM 事件的事件先拆包,后面按「载荷就是回调里那一份」统一读
263
- const payload = unwrapPayload(raw, name)
257
+ // 一条 '*' 订阅全部事件:回调拿到的第二参就是事件名;载荷与 options 里的 onXxx 完全同一份
258
+ offAll = view.on('*', (payload, name) => {
264
259
  counts.value = { ...counts.value, [name]: (counts.value[name] ?? 0) + 1 }
265
260
  // 合并 / 复制:同一条 * 订阅里收中间态,确认条与日志读的是同一份载荷
266
261
  if (name === 'pointMerge') {
@@ -275,8 +270,10 @@ onMounted(() => {
275
270
  if (ACTION_EVENTS.has(name) && payload?.action) {
276
271
  // 一次操作的判读:什么操作 / 点到了什么 / 拿到了什么数据 —— 左键与右键都走这一套
277
272
  const action = payload.action
278
- const held = Object.entries(action.modifiers ?? {}).filter(([, on]) => on).map(([key]) => key).join('+')
279
- lastAction.value = `${name}:${action.type} → ${action.target}${action.data === undefined || action.data === null ? '' : ` · ${dataLabel(action.data)}`}${action.button ? ` · ${action.button}` : ''}${held ? ` · ${held}` : ''}${action.moved ? ' · 拖动过' : ''}${action.double ? ' · 双击第二下' : ''}`
273
+ const event = payload.originalEvent
274
+ const held = [event?.ctrlKey && 'ctrl', event?.metaKey && 'meta', event?.shiftKey && 'shift', event?.altKey && 'alt'].filter(Boolean).join('+')
275
+ const data = hitDataOf(payload)
276
+ lastAction.value = `${name}:${action.type} → ${action.target}${data === null || data === undefined ? '' : ` · ${dataLabel(data)}`}${event ? ` · button ${event.button}` : ''}${held ? ` · ${held}` : ''}${action.moved ? ' · 拖动过' : ''}${action.double ? ' · 双击第二下' : ''}`
280
277
  }
281
278
  if (!showHighFrequency.value && HIGH_FREQUENCY.has(name)) {
282
279
  return
@@ -350,7 +347,7 @@ onBeforeUnmount(() => {
350
347
  <div class="dv-group">
351
348
  <h3>动作判读(action)</h3>
352
349
  <p class="dv-readout">{{ lastAction }}</p>
353
- <p class="dv-hint">在画布上点一下 / 右键 / 拖一段 / 框选一下:这里按 action.type 说清「是什么操作」、action.target 说清「点到了什么」、action.data 就是那一份数据(点 / 线是数据里的对象、通道回线是通道定义)。</p>
350
+ <p class="dv-hint">在画布上点一下 / 右键 / 拖一段 / 框选一下:action.type 说清「是什么操作」、action.target 说清「点到了什么」,命中的数据直接读载荷顶层的 item / channel / label 等字段;按键与修饰键读 originalEvent。</p>
354
351
  </div>
355
352
  <div class="dv-group">
356
353
  <h3>事件计数(* 订阅)</h3>
@@ -338,11 +338,11 @@ const onLabelDrag = (payload) => {
338
338
  const onLabelContextMenu = (payload) => {
339
339
  hit.value = payload
340
340
  syncFormFromHit()
341
- // 用载荷里的 clientX / clientY 把自绘菜单贴在指针位置(系统菜单已被 preventContextMenu 收掉)
342
- menu.open = true
343
- menu.x = payload.clientX
344
- menu.y = payload.clientY
345
- pushLog('labelContextMenu', `右键 ${describeLabel(payload)} @ ${payload.clientX}, ${payload.clientY}`)
341
+ // 用原始指针事件的 clientX / clientY 把自绘菜单贴在指针位置(系统菜单已被 preventContextMenu 收掉)
342
+ menu.open = true
343
+ menu.x = payload.originalEvent?.clientX ?? 0
344
+ menu.y = payload.originalEvent?.clientY ?? 0
345
+ pushLog('labelContextMenu', `右键 ${describeLabel(payload)} @ ${menu.x}, ${menu.y}`)
346
346
  }
347
347
 
348
348
  const onLabelDblClick = (payload) => {
@@ -477,8 +477,8 @@ onMounted(() => {
477
477
  pushLog('labelChange', describeChange(payload))
478
478
  syncLabelStates()
479
479
  }],
480
- // 接口选中(select / clearSelection)不发 onSelect,只发 selectChange:两条路都接上
481
- ['selectChange', () => syncSelection()]
480
+ // 选中镜像统一走 selectChange:覆盖点击与接口来源,且重复设同一份选中集不会重复通知
481
+ ['selectChange', () => syncSelection()]
482
482
  ].map(([name, handler]) => view.on(name, handler))
483
483
  window.addEventListener('keydown', onKeyDown)
484
484
  window.addEventListener('pointerdown', onGlobalPointerDown)
@@ -86,7 +86,7 @@ const buildOptions = () => ({
86
86
  syncRules()
87
87
  },
88
88
  /**
89
- * 读数接 onSelectChange 而不是 onSelect:前者对「点选、框选、API select()」都会发,
89
+ * 读数接 onSelectChange 而不是 onSelect:前者对「点选、框选、API selectFeatures()」都会发,
90
90
  * 后者只代表画布上的那一次点击 —— 用接口选中时读数也不会停在「未选中」。
91
91
  * 选中高亮的颜色 / 尺寸写在 pointSelectStyle / lineSelectStyle 里,这里只让面板知道选中集变了。
92
92
  */