gis-view-webgl2 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +40 -8
  2. package/dist/gis-view.css +1 -1
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.d.ts +1036 -249
  5. package/dist/index.js +1 -1
  6. package/docs/AI/345/257/271/350/257/235/346/261/207/346/200/273.md +194 -0
  7. package/docs/GisView.md +9 -8
  8. package/docs/GisView/345/256/236/347/216/260/345/216/237/347/220/206.md +2 -2
  9. package/docs/ai/06-/344/274/232/350/257/235/347/264/242/345/274/225/344/270/216/345/244/215/346/237/245/346/226/271/346/263/225.md +172 -0
  10. package/docs/api/index.html +545 -59
  11. package/docs/demos/BackendSyncDemo.vue +10 -8
  12. package/docs/demos/BasicDemo.vue +101 -101
  13. package/docs/demos/BatchAddDemo.vue +5 -5
  14. package/docs/demos/ChannelDemo.vue +617 -10
  15. package/docs/demos/ConnectDemo.vue +392 -0
  16. package/docs/demos/CopyMergeDemo.vue +980 -929
  17. package/docs/demos/DecorationDraftDemo.vue +1300 -0
  18. package/docs/demos/DemoShell.vue +569 -240
  19. package/docs/demos/DragDropDemo.vue +11 -11
  20. package/docs/demos/EffectsDemo.vue +6 -6
  21. package/docs/demos/EventDemo.vue +327 -327
  22. package/docs/demos/ExtensionDemo.vue +458 -0
  23. package/docs/demos/GisViewDemo.vue +61 -48
  24. package/docs/demos/GraphicDemo.vue +338 -0
  25. package/docs/demos/LabelDemo.vue +553 -497
  26. package/docs/demos/MinimapDemo.vue +358 -0
  27. package/docs/demos/PerfDemo.vue +29 -0
  28. package/docs/demos/SearchFilterDemo.vue +412 -0
  29. package/docs/demos/StyleDemo.vue +121 -121
  30. package/docs/demos/api-index.js +98 -77
  31. package/docs/demos/demos.js +159 -135
  32. package/docs/demos/shared/demoData.js +107 -107
  33. package/docs/demos/shared/demoStyle.css +249 -249
  34. package/docs/demos/shared/fakeBackend.js +93 -6
  35. package/docs/format/03-/346/213/220/347/202/271/350/243/205/351/245/260/344/270/216/346/240/267/345/274/217.md +28 -8
  36. package/docs/format/04-/351/200/232/351/201/223/344/270/216/346/240/207/347/255/276.md +5 -4
  37. package/docs/format/06-/345/257/274/345/205/245/345/257/274/345/207/272/344/270/216/346/240/207/345/207/206/346/226/207/346/241/243.md +8 -6
  38. package/docs/format/07-/344/272/213/344/273/266/350/275/275/350/215/267/344/270/216/347/244/272/344/276/213/346/225/260/346/215/256.md +6 -6
  39. package/docs/guide/sample-document.html +4 -4
  40. package/docs/index.html +180 -9
  41. package/docs/internals/11-/350/246/206/347/233/226/345/261/202/346/240/207/347/255/276/344/270/216/346/217/220/347/244/272.md +1 -1
  42. package/docs/internals/13-/351/271/260/347/234/274/345/233/276.md +3 -2
  43. package/docs/internals/15-03-/351/200/220/350/275/256/350/256/260/345/275/225-/346/200/247/350/203/275/344/270/216/344/277/256/345/244/215-/344/270/213.md +16 -0
  44. package/docs/internals/16-03-/344/274/230/345/214/226/344/270/216/345/217/226/350/210/215-/344/270/213.md +11 -0
  45. package/docs/internals/17-/350/260/203/350/257/225/344/270/216/346/211/251/345/261/225/345/273/272/350/256/256.md +1 -1
  46. package/docs/manual/02-/345/235/220/346/240/207/344/270/216/346/225/260/346/215/256.md +18 -4
  47. package/docs/manual/03-/351/205/215/347/275/256/351/241/271.md +110 -25
  48. package/docs/manual/04-/344/272/213/344/273/266/344/270/216/345/233/236/350/260/203.md +13 -8
  49. package/docs/manual/06-/346/225/260/346/215/256/344/270/216/345/234/272/346/231/257/346/233/264/346/226/260.md +111 -10
  50. package/docs/manual/07-/346/214/207/351/222/210/344/270/216/350/246/201/347/264/240/347/274/226/350/276/221.md +47 -9
  51. package/docs/manual/08-/351/200/211/344/270/255/344/270/216/346/213/220/347/202/271.md +17 -15
  52. package/docs/manual/09-/351/200/232/351/201/223/344/270/216/345/244/232/345/233/236/347/272/277.md +61 -11
  53. package/docs/manual/10-/351/200/232/351/201/223/347/274/226/350/276/221/344/270/216/346/226/260/345/242/236.md +74 -7
  54. package/docs/manual/11-/346/240/267/345/274/217/344/270/216/350/247/204/345/210/231.md +3 -2
  55. package/docs/manual/12-/346/240/207/347/255/276/344/270/216/350/243/205/351/245/260.md +260 -3
  56. package/docs/manual/13-/345/212/250/346/225/210/344/270/216/345/233/276/345/205/203.md +6 -5
  57. package/docs/manual/14-/350/247/206/345/233/276/344/270/216/347/255/233/351/200/211.md +27 -14
  58. package/docs/manual/15-/345/257/274/345/207/272/344/270/216/351/233/206/346/210/220.md +142 -49
  59. package/docs/manual/16-/345/256/236/344/276/213/346/226/271/346/263/225/346/200/273/350/241/250.md +31 -29
  60. package/docs/manual/17-/345/217/202/346/225/260/351/200/237/346/237/245.md +55 -39
  61. package/docs/manual/18-/351/200/220/346/226/271/346/263/225/347/244/272/344/276/213.md +189 -64
  62. package/docs/manual/19-/350/257/273/346/225/260/344/270/216/346/213/226/346/213/275/351/242/204/350/247/210.md +4 -4
  63. package/docs/manual/20-/346/200/247/350/203/275/344/270/216/345/244/247/346/225/260/346/215/256.md +42 -2
  64. package/docs/manual/21-/350/257/264/346/230/216/344/270/216/345/270/270/350/247/201/351/227/256/351/242/230.md +22 -6
  65. package/docs/playground/index.html +5 -5
  66. package/docs/steps/README.md +14 -10
  67. package/docs/steps/check-doc-truth.mjs +1 -1
  68. package/docs/steps/selftest-actions.html +10 -10
  69. package/docs/steps/selftest-data.html +96 -14
  70. package/docs/steps/selftest-decoration.html +577 -0
  71. package/docs/steps/selftest-demo.html +6 -5
  72. package/docs/steps/selftest-demos.html +2201 -1612
  73. package/docs/steps/selftest-docs.html +89 -16
  74. package/docs/steps/selftest-effects.html +5 -5
  75. package/docs/steps/selftest-features.html +757 -86
  76. package/docs/steps/selftest-full.html +583 -583
  77. package/docs/steps/selftest-labels.html +1034 -652
  78. package/docs/steps/selftest-perf.html +1899 -1844
  79. package/docs/steps/selftest-scale.html +117 -7
  80. package/docs/steps/selftest-select.html +24 -17
  81. package/docs/steps/step-10-full.js +1 -1
  82. package/docs//346/216/245/345/217/243/345/215/217/350/256/256.md +167 -17
  83. package/docs//346/225/260/346/215/256/346/240/274/345/274/217.md +1 -1
  84. package/package.json +43 -43
  85. package/docs/demos/HistoryDemo.vue +0 -230
  86. package/docs/demos/InterfaceDemo.vue +0 -309
  87. package/docs/demos/SampleDemo.vue +0 -283
@@ -0,0 +1,1300 @@
1
+ <script setup>
2
+ /**
3
+ * 动态装饰器草稿演示:线路与节点分开开关,也可以同时开启。
4
+ * 节点把「吸附方式」和「位置来源」拆成两个正交选项:吸附方式只决定落地后跟随节点还是钉在世界坐标,
5
+ * 位置来源两档都支持相对偏移 / 固定坐标 / 鼠标位置,因此两种吸附方式能添加的位置完全一样。
6
+ *
7
+ * 交互分三段,与拖拽落点 demo 一样,真正写数据留到使用方确认:
8
+ * 1) beginDecorationDraft:进入草稿模式,图标 / 尺寸 / 颜色 / 方向 / 作用域都从右侧面板取;
9
+ * 2) pointermove:引擎按 target 查线路 / 节点,命中就画预览;两类同时开启时取屏幕距离更近的那一个,
10
+ * 离开目标立即发 leave 并清掉假装饰;pointerup 仍命中才发 ready;
11
+ * 3) ready 之后面板给「添加真实装饰 / 取消」:commitDecorationDraft 把同一份 decoration 追加到全部目标
12
+ * 线路(一个 batch、一条 dataChange、一步历史),cancelDecorationDraft 让数据一个字节都不动。
13
+ *
14
+ * 线路方向:parallel 打开时每个图标按所在线路的切向转;关掉就保持水平。作用域:line 只挂命中的一条,
15
+ * group 挂它所在分组的全部回线,channel 挂整条通道的所有回线(不在通道里的线路两种范围都退回单条)。
16
+ * 节点:placement 只负责位置来源(offset / fixed / pointer),anchor 再决定落成屏幕偏移(follow)还是世界坐标(fixed)。
17
+ */
18
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
19
+ import { createGisView } from '../../src/index.js'
20
+ import { buildDemoData } from './shared/demoData.js'
21
+ import './shared/demoStyle.css'
22
+
23
+ const STAR_PATH = 'M12 2.6l2.9 6.1 6.7.9-4.9 4.6 1.2 6.6L12 17.6 6.1 20.8l1.2-6.6-4.9-4.6 6.7-.9z'
24
+ const ARROW_PATH = 'M2 5h20V1l8 7-8 7v-4H2z'
25
+ const PIN_PATH = 'M16 1a8 8 0 0 0-8 8c0 6 8 14 8 14s8-8 8-14a8 8 0 0 0-8-8zm0 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6z'
26
+
27
+ /** 图标选择:自定义 SVG 与内置形状走同一套 symbol 解析,缺省颜色由 panels 的 color 决定 */
28
+ const SYMBOLS = [
29
+ {
30
+ id: 'arrow',
31
+ label: '方向箭头',
32
+ glyph: '➜',
33
+ hint: '横向箭头:打开「与线路平行」时最容易看出角度',
34
+ symbol: { type: 'svg', path: ARROW_PATH, viewBox: [0, 0, 32, 16], fill: 'currentColor', stroke: 'currentStrokeColor', strokeWidth: 0.8 }
35
+ },
36
+ {
37
+ id: 'star',
38
+ label: '星标',
39
+ glyph: '★',
40
+ hint: '自定义 SVG 星标:图标样式与真实 lines[].decorations 完全同一套',
41
+ symbol: { type: 'svg', path: STAR_PATH, viewBox: [0, 0, 24, 24], fill: 'currentColor', stroke: 'currentStrokeColor', strokeWidth: 0.8 }
42
+ },
43
+ {
44
+ id: 'pin',
45
+ label: '定位针',
46
+ glyph: '◆',
47
+ hint: '带方向的定位针:不平行时保持屏幕水平',
48
+ symbol: { type: 'svg', path: PIN_PATH, viewBox: [0, 0, 32, 32], fill: 'currentColor', stroke: 'currentStrokeColor', strokeWidth: 0.8 }
49
+ },
50
+ { id: 'circle', label: '圆点', glyph: '●', hint: '内置圆点:方向不平行时画出来是正圆', symbol: 'circle' },
51
+ { id: 'square', label: '方块', glyph: '■', hint: '内置方块:打开平行时跟着线路方向转', symbol: 'square' }
52
+ ]
53
+
54
+ const PHASE_LABELS = {
55
+ start: '已开启,等待移到目标上',
56
+ hover: '跟随指针',
57
+ leave: '已离开目标',
58
+ ready: '松手完成,等待确认',
59
+ update: '配置已更新',
60
+ commit: '已写入数据',
61
+ cancel: '已取消'
62
+ }
63
+
64
+ const emit = defineEmits(['notice'])
65
+ const container = ref(null)
66
+ const dataset = buildDemoData({ count: 24 })
67
+ const lineEnabled = ref(false)
68
+ const pointEnabled = ref(false)
69
+ const symbolId = ref('arrow')
70
+ const size = ref(26)
71
+ const color = ref('#ffd166')
72
+ const strokeColor = ref('#ffffff')
73
+ const opacity = ref(0.95)
74
+ const parallel = ref(true)
75
+ const scope = ref('channel')
76
+ const triggerDistance = ref(14)
77
+ const maxPerLine = ref(0)
78
+ const allowedLinesText = ref('')
79
+ const allowedChannelsText = ref('')
80
+ const allowedGroupsText = ref('')
81
+ const pointSnapMode = ref('follow')
82
+ const pointSnapPosition = ref('offset')
83
+ const pointDistance = ref(36)
84
+ const pointDirection = ref(0)
85
+ const pointAtX = ref(dataset.points[0]?.x ?? 0)
86
+ const pointAtY = ref(dataset.points[0]?.y ?? 0)
87
+ const maxPerPoint = ref(0)
88
+ const allowedPointsText = ref('')
89
+ const connectorEnabled = ref(true)
90
+ const connectorColor = ref('#7fa9dc')
91
+ const connectorWidth = ref(1.2)
92
+ const connectorDashed = ref(false)
93
+ const allowDuplicate = ref(true)
94
+ const duplicateStrategy = ref('offset')
95
+ const duplicateDistance = ref(8)
96
+ const draft = ref(null)
97
+ const lastCommit = ref(null)
98
+ const logLines = ref([])
99
+ const logSeq = ref(0)
100
+ const dragOver = ref(false)
101
+ const dragSymbolId = ref('')
102
+ const pendingDrop = ref(null)
103
+ const dropReadout = ref('从左侧拖拽一个图标到线路上;松手后还要点「添加落点装饰」才写数据')
104
+ let view = null
105
+ let offEvents = []
106
+ let restartTimer = 0
107
+ let lastHoverKey = ''
108
+ let hoverCandidate = null
109
+ let previewPointer = null
110
+ let previewFrame = 0
111
+ let previewTimer = 0
112
+ let previewQueued = false
113
+ let dragGhost = null
114
+ let lastPointerWorld = null
115
+
116
+ /** 白名单输入:中英文逗号 / 空格都能分隔,留空 = 不限制这一类 */
117
+ const parseIdList = (text) => String(text ?? '')
118
+ .split(/[\s,,]+/)
119
+ .map(entry => entry.trim())
120
+ .filter(Boolean)
121
+ const currentSymbol = computed(() => SYMBOLS.find(item => item.id === symbolId.value)?.symbol ?? 'circle')
122
+ const currentSymbolLabel = computed(() => SYMBOLS.find(item => item.id === symbolId.value)?.label ?? '圆点')
123
+ const anyTargetEnabled = computed(() => lineEnabled.value || pointEnabled.value)
124
+ const targetMode = computed(() => lineEnabled.value && pointEnabled.value ? 'both' : pointEnabled.value ? 'point' : 'line')
125
+ const targetModeText = computed(() => targetMode.value === 'both' ? '线路 + 节点' : targetMode.value === 'point' ? '节点' : '线路')
126
+ const anchorLabels = { follow: '跟随', fixed: '固定' }
127
+ const placementLabels = { offset: '相对节点', fixed: '固定位置', pointer: '鼠标位置' }
128
+ const pointModeText = (placement, anchor) => `${anchorLabels[anchor] ?? anchor} · ${placementLabels[placement] ?? placement}`
129
+ const pointSnapText = computed(() => pointModeText(pointSnapPosition.value, pointSnapMode.value))
130
+ const draftHintText = computed(() => {
131
+ if (draft.value?.phase === 'ready') {
132
+ return '已松手:在下方确认或取消'
133
+ }
134
+ if (targetMode.value === 'both') {
135
+ return '把指针移到线路或节点附近:临时图标按更近的目标出现'
136
+ }
137
+ return targetMode.value === 'point'
138
+ ? '把指针移到节点附近:临时图标会按当前吸附方式与位置来源出现'
139
+ : '把指针移到线路 / 通道回线上:临时图标会跟着线路出现'
140
+ })
141
+ const decoratorOptions = computed(() => ({
142
+ target: targetMode.value,
143
+ symbol: currentSymbol.value,
144
+ size: size.value,
145
+ color: color.value,
146
+ strokeColor: strokeColor.value,
147
+ strokeWidth: 1.2,
148
+ opacity: opacity.value,
149
+ parallel: parallel.value,
150
+ scope: scope.value,
151
+ maxDistance: triggerDistance.value,
152
+ maxPerLine: Math.max(0, Math.floor(Number(maxPerLine.value) || 0)),
153
+ maxPerPoint: Math.max(0, Math.floor(Number(maxPerPoint.value) || 0)),
154
+ allowedLines: parseIdList(allowedLinesText.value),
155
+ allowedChannels: parseIdList(allowedChannelsText.value),
156
+ allowedGroups: parseIdList(allowedGroupsText.value),
157
+ allowedPoints: parseIdList(allowedPointsText.value),
158
+ placement: pointSnapPosition.value,
159
+ anchor: pointSnapMode.value,
160
+ distance: pointDistance.value,
161
+ direction: pointDirection.value,
162
+ at: pointSnapPosition.value === 'fixed'
163
+ ? [Number(pointAtX.value) || 0, Number(pointAtY.value) || 0]
164
+ : undefined,
165
+ angle: 0,
166
+ connector: connectorEnabled.value
167
+ ? { color: connectorColor.value, width: connectorWidth.value, opacity: 0.9, dashed: connectorDashed.value }
168
+ : undefined,
169
+ allowDuplicate: allowDuplicate.value,
170
+ duplicateStrategy: duplicateStrategy.value,
171
+ duplicateDistance: duplicateDistance.value
172
+ }))
173
+ const ruleText = computed(() => {
174
+ const parts = [
175
+ `触发距离 ${triggerDistance.value}px`,
176
+ `目标:${targetMode.value === 'both' ? '线路 + 节点' : targetMode.value === 'point' ? '节点' : '线路'}`,
177
+ maxPerLine.value > 0 ? `每条线最多 ${maxPerLine.value} 个` : '每条线不限量',
178
+ maxPerPoint.value > 0 ? `每个节点最多 ${maxPerPoint.value} 个` : '每个节点不限量'
179
+ ]
180
+ const allow = [
181
+ parseIdList(allowedLinesText.value).length ? `线路 ${parseIdList(allowedLinesText.value).join('、')}` : '',
182
+ parseIdList(allowedChannelsText.value).length ? `通道 ${parseIdList(allowedChannelsText.value).join('、')}` : '',
183
+ parseIdList(allowedGroupsText.value).length ? `分组 ${parseIdList(allowedGroupsText.value).join('、')}` : '',
184
+ parseIdList(allowedPointsText.value).length ? `节点 ${parseIdList(allowedPointsText.value).join('、')}` : ''
185
+ ].filter(Boolean)
186
+ parts.push(allow.length ? `允许:${allow.join(' / ')}` : '允许:全部线路 / 节点')
187
+ parts.push(allowDuplicate.value ? '同位置:允许重复' : duplicateStrategy.value === 'reject' ? '同位置:重复时拒绝' : '同位置:自动避让')
188
+ return parts.join(' · ')
189
+ })
190
+ const phaseText = computed(() => PHASE_LABELS[draft.value?.phase] ?? '未开启')
191
+ const hitText = computed(() => {
192
+ const current = draft.value
193
+ if (!current) {
194
+ return '—'
195
+ }
196
+ if (current.hitKind === 'point') {
197
+ return `节点 ${current.pointId} · ${pointModeText(current.placement, current.anchor)}`
198
+ }
199
+ if (!current.lineId) {
200
+ return '—'
201
+ }
202
+ if (current.channelId !== null && current.channelId !== undefined) {
203
+ return `${current.lineId} · 通道 ${current.channelId}${current.groupId ? ` · 分组 ${current.groupId}` : ''}`
204
+ }
205
+ return String(current.lineId)
206
+ })
207
+ const targetText = computed(() => {
208
+ const current = draft.value
209
+ if (current?.hitKind === 'point') {
210
+ return current.pointTargets?.length ? `节点 ${current.pointTargets.map(target => target.pointId).join('、')}` : '—'
211
+ }
212
+ const targets = draft.value?.targets ?? []
213
+ if (!targets.length) {
214
+ return '—'
215
+ }
216
+ const ids = targets.slice(0, 8).map(target => String(target.lineId))
217
+ return `${targets.length} 条${ids.length ? `:${ids.join('、')}${targets.length > ids.length ? '…' : ''}` : ''}`
218
+ })
219
+ const positionText = computed(() => {
220
+ const current = draft.value
221
+ if (current?.hitKind === 'point') {
222
+ if (current.placement === 'offset') {
223
+ return `${pointDirection.value}° / ${pointDistance.value}px`
224
+ }
225
+ if (current.placement === 'fixed') {
226
+ return current.world ? `世界 ${current.world.map(value => Number(value).toFixed(1)).join(', ')}` : '固定位置'
227
+ }
228
+ return current.decorationScreen ? `屏幕 ${current.decorationScreen.map(value => value.toFixed(1)).join(', ')}` : '鼠标位置'
229
+ }
230
+ return current?.position === null || current?.position === undefined ? '—' : `${(current.position * 100).toFixed(1)}%`
231
+ })
232
+ const angleText = computed(() => `${((draft.value?.angle ?? 0) * 180 / Math.PI).toFixed(1)}°`)
233
+ const worldText = computed(() => draft.value?.world ? draft.value.world.map(value => Number(value).toFixed(3)).join(', ') : '—')
234
+ const decorationScreenText = computed(() => draft.value?.decorationScreen ? draft.value.decorationScreen.map(value => Number(value).toFixed(1)).join(', ') : '—')
235
+ const blockedText = computed(() => {
236
+ const reason = draft.value?.blockedReason
237
+ if (!reason) {
238
+ return ''
239
+ }
240
+ return {
241
+ 'not-allowed': '目标不在白名单里',
242
+ limit: '目标已达到装饰上限',
243
+ duplicate: '该位置已有装饰,当前策略不允许重复',
244
+ 'targets-missing': '目标已经不在数据里'
245
+ }[reason] ?? String(reason)
246
+ })
247
+ const confirmHint = computed(() => {
248
+ if (!draft.value || draft.value.phase !== 'ready') {
249
+ return ''
250
+ }
251
+ if (draft.value.hitKind === 'point') {
252
+ return `${currentSymbolLabel.value} ${draft.value.size}px · 将添加到节点 ${draft.value.pointId}(${pointModeText(draft.value.placement, draft.value.anchor)}${connectorEnabled.value ? ' + 连接线' : ''})`
253
+ }
254
+ return `${currentSymbolLabel.value} ${draft.value.size}px · 将添加到 ${draft.value.targetCount} 条线路(${draft.value.scope})`
255
+ })
256
+ const lastCommitText = computed(() => {
257
+ const current = lastCommit.value
258
+ if (!current) {
259
+ return '确认一次之后这里给出真实写入的读数'
260
+ }
261
+ const target = current.pointIds?.length ? `节点 ${current.pointIds.join('、')}` : current.lineIds?.join('、') || '—'
262
+ const detail = current.placement
263
+ ? pointModeText(current.placement, current.anchor)
264
+ : `${current.parallel ? '平行' : '水平'}${current.scope === 'drag' ? ' · 拖拽落点' : ` · 沿线 ${((current.position ?? 0) * 100).toFixed(1)}%`}`
265
+ return `${current.symbol} · ${current.size}px · ${detail} · ${target}`
266
+ })
267
+
268
+ const pushLog = (name, text) => {
269
+ logSeq.value += 1
270
+ logLines.value = [{ id: logSeq.value, name, text }, ...logLines.value].slice(0, 16)
271
+ }
272
+
273
+ /* ---- 左侧看板:拖到线路上 → 落点预览 → 点「添加落点装饰」才写数据 ---- */
274
+
275
+ const reasonText = (reason) => ({
276
+ miss: '没有拖到线路附近',
277
+ 'not-allowed': '这条线路不在允许范围内',
278
+ limit: '这条线路已经达到装饰上限',
279
+ 'targets-missing': '线路已经不在数据里'
280
+ }[reason] ?? `没有添加:${reason}`)
281
+ const symbolItemOf = id => SYMBOLS.find(item => item.id === id) ?? SYMBOLS[0]
282
+ const canvasPointOf = event => view?.clientToScreen(event.clientX, event.clientY) ?? [0, 0]
283
+ const decorationFor = (paletteId, position) => {
284
+ const item = symbolItemOf(paletteId)
285
+ return {
286
+ position,
287
+ anchor: 'follow',
288
+ parallel: parallel.value,
289
+ symbol: item.symbol,
290
+ size: size.value,
291
+ color: color.value,
292
+ strokeColor: strokeColor.value,
293
+ strokeWidth: 1.2,
294
+ opacity: opacity.value
295
+ }
296
+ }
297
+ /** 拖拽落线前按当前白名单 / 上限做一次判断:不允许的目标不画预览、不进入待添加 */
298
+ const linePermissionAt = (hit) => {
299
+ if (!view || !hit) {
300
+ return { ok: false, reason: 'miss' }
301
+ }
302
+ const options = decoratorOptions.value
303
+ const lineId = hit.item?.id ?? hit.index
304
+ const membership = view.getLineChannel(hit.index)
305
+ const channelId = membership?.channelId ?? hit.channelId ?? null
306
+ const groupId = membership?.groupId ?? null
307
+ const hasWhitelist = options.allowedLines.length || options.allowedChannels.length || options.allowedGroups.length
308
+ const matches = list => list.some(id => String(id) === String(lineId))
309
+ const allowed = !hasWhitelist
310
+ || matches(options.allowedLines)
311
+ || (channelId !== null && options.allowedChannels.some(id => String(id) === String(channelId)))
312
+ || (groupId !== null && options.allowedGroups.some(id => String(id) === String(groupId)))
313
+ if (!allowed) {
314
+ return { ok: false, reason: 'not-allowed', lineId, channelId, groupId }
315
+ }
316
+ const existingCount = view.getLineDecorations(lineId).length
317
+ if (options.maxPerLine > 0 && existingCount >= options.maxPerLine) {
318
+ return { ok: false, reason: 'limit', lineId, channelId, groupId, existingCount }
319
+ }
320
+ return { ok: true, lineId, channelId, groupId, existingCount }
321
+ }
322
+ const resolveDropCandidate = (paletteId, screen) => {
323
+ if (!view) {
324
+ return { ok: false, reason: 'miss' }
325
+ }
326
+ const hit = view.nearestLineAt(screen[0], screen[1], { maxDistance: triggerDistance.value, withScreen: true })
327
+ if (!hit) {
328
+ return { ok: false, reason: 'miss' }
329
+ }
330
+ const permission = linePermissionAt(hit)
331
+ if (!permission.ok) {
332
+ return { ...permission, hit }
333
+ }
334
+ const world = hit.screen ? view.unproject(hit.screen[0], hit.screen[1]) : view.unproject(screen[0], screen[1])
335
+ return {
336
+ ok: true,
337
+ paletteId,
338
+ screen: [screen[0], screen[1]],
339
+ world,
340
+ hit,
341
+ lineId: permission.lineId,
342
+ channelId: permission.channelId,
343
+ groupId: permission.groupId,
344
+ existingCount: permission.existingCount,
345
+ decoration: decorationFor(paletteId, hit.position)
346
+ }
347
+ }
348
+ const showDropCandidate = (candidate) => {
349
+ if (!view || !candidate?.ok) {
350
+ return null
351
+ }
352
+ return view.setDropPreview({
353
+ kind: 'decoration',
354
+ screen: candidate.screen,
355
+ world: candidate.world,
356
+ item: candidate.decoration
357
+ })
358
+ }
359
+ const describeDropCandidate = (candidate) => {
360
+ const item = symbolItemOf(candidate.paletteId)
361
+ return `${item.label} → ${candidate.lineId} · 沿线 ${(candidate.decoration.position * 100).toFixed(1)}%${candidate.channelId !== null ? ` · 通道 ${candidate.channelId}` : ''}${candidate.groupId !== null ? ` · 分组 ${candidate.groupId}` : ''}`
362
+ }
363
+ const clearDropPreview = () => {
364
+ if (previewFrame) {
365
+ cancelAnimationFrame(previewFrame)
366
+ previewFrame = 0
367
+ }
368
+ if (previewTimer) {
369
+ clearTimeout(previewTimer)
370
+ previewTimer = 0
371
+ }
372
+ previewQueued = false
373
+ previewPointer = null
374
+ hoverCandidate = null
375
+ view?.setDropPreview(null)
376
+ }
377
+ const ensureDragGhost = () => {
378
+ if (!dragGhost) {
379
+ dragGhost = document.createElement('div')
380
+ dragGhost.className = 'dv-drag-ghost'
381
+ dragGhost.setAttribute('aria-hidden', 'true')
382
+ dragGhost.style.cssText = 'position:fixed;left:-10px;top:-10px;width:1px;height:1px;opacity:0;pointer-events:none'
383
+ document.body.appendChild(dragGhost)
384
+ }
385
+ return dragGhost
386
+ }
387
+ const flushDropPreview = () => {
388
+ if (!previewQueued) {
389
+ return
390
+ }
391
+ previewQueued = false
392
+ if (previewFrame) {
393
+ cancelAnimationFrame(previewFrame)
394
+ previewFrame = 0
395
+ }
396
+ if (previewTimer) {
397
+ clearTimeout(previewTimer)
398
+ previewTimer = 0
399
+ }
400
+ if (!dragSymbolId.value || !view) {
401
+ return
402
+ }
403
+ const candidate = resolveDropCandidate(dragSymbolId.value, previewPointer ?? [0, 0])
404
+ hoverCandidate = candidate.ok ? candidate : null
405
+ if (!candidate.ok) {
406
+ view.setDropPreview(null)
407
+ dropReadout.value = reasonText(candidate.reason)
408
+ return
409
+ }
410
+ showDropCandidate(candidate)
411
+ dropReadout.value = describeDropCandidate(candidate)
412
+ }
413
+ const scheduleDropPreview = () => {
414
+ if (previewQueued) {
415
+ return
416
+ }
417
+ previewQueued = true
418
+ previewFrame = requestAnimationFrame(flushDropPreview)
419
+ previewTimer = setTimeout(flushDropPreview, 32)
420
+ }
421
+ const selectPalette = (item) => {
422
+ symbolId.value = item.id
423
+ pushLog('pick', `已选中「${item.label}」:可以继续用右侧「开启动态装饰」,也可以直接拖到线路上`)
424
+ }
425
+ const onDragStart = (event, item) => {
426
+ if (!view) {
427
+ return
428
+ }
429
+ if (pendingDrop.value) {
430
+ pendingDrop.value = null
431
+ clearDropPreview()
432
+ }
433
+ dragSymbolId.value = item.id
434
+ symbolId.value = item.id
435
+ if (event.dataTransfer) {
436
+ event.dataTransfer.effectAllowed = 'copy'
437
+ event.dataTransfer.setData('application/x-gis-view-decoration', item.id)
438
+ event.dataTransfer.setData('text/plain', item.id)
439
+ event.dataTransfer.setDragImage(ensureDragGhost(), 0, 0)
440
+ }
441
+ pushLog('dragstart', `拖拽「${item.label}」:靠近线路才显示落点,松手后还要确认`)
442
+ }
443
+ const onDragOver = (event) => {
444
+ dragOver.value = true
445
+ if (event.dataTransfer) {
446
+ event.dataTransfer.dropEffect = 'copy'
447
+ }
448
+ const screen = canvasPointOf(event)
449
+ if (previewPointer && previewPointer[0] === screen[0] && previewPointer[1] === screen[1]) {
450
+ return
451
+ }
452
+ previewPointer = screen
453
+ scheduleDropPreview()
454
+ }
455
+ const onDragLeave = (event) => {
456
+ if (container.value && event.relatedTarget instanceof Node && container.value.contains(event.relatedTarget)) {
457
+ return
458
+ }
459
+ dragOver.value = false
460
+ if (pendingDrop.value) {
461
+ const restored = showDropCandidate(pendingDrop.value)
462
+ pendingDrop.value = { ...pendingDrop.value, preview: restored }
463
+ }
464
+ else {
465
+ clearDropPreview()
466
+ }
467
+ }
468
+ const settleDropPreview = () => {
469
+ clearDropPreview()
470
+ if (pendingDrop.value) {
471
+ const preview = showDropCandidate(pendingDrop.value)
472
+ pendingDrop.value = { ...pendingDrop.value, preview }
473
+ }
474
+ }
475
+ const onDragEnd = () => {
476
+ dragOver.value = false
477
+ dragSymbolId.value = ''
478
+ settleDropPreview()
479
+ }
480
+ const onDrop = (event) => {
481
+ dragOver.value = false
482
+ if (previewFrame) {
483
+ cancelAnimationFrame(previewFrame)
484
+ previewFrame = 0
485
+ }
486
+ if (previewTimer) {
487
+ clearTimeout(previewTimer)
488
+ previewTimer = 0
489
+ }
490
+ previewQueued = false
491
+ const paletteId = event.dataTransfer?.getData('application/x-gis-view-decoration')
492
+ || event.dataTransfer?.getData('text/plain')
493
+ || dragSymbolId.value
494
+ const item = SYMBOLS.find(entry => entry.id === paletteId)
495
+ if (!item || !view) {
496
+ clearDropPreview()
497
+ return
498
+ }
499
+ const candidate = resolveDropCandidate(item.id, canvasPointOf(event))
500
+ if (!candidate.ok) {
501
+ clearDropPreview()
502
+ dropReadout.value = reasonText(candidate.reason)
503
+ pushLog('drop', `${reasonText(candidate.reason)}:这次没有进入待添加`)
504
+ emit('notice', reasonText(candidate.reason))
505
+ return
506
+ }
507
+ const preview = showDropCandidate(candidate)
508
+ pendingDrop.value = { ...candidate, preview }
509
+ dropReadout.value = `${describeDropCandidate(candidate)} · 点「添加落点装饰」才写入`
510
+ pushLog('drop', `已落到 ${candidate.lineId}(沿线 ${(candidate.decoration.position * 100).toFixed(1)}%):数据未变,等待确认`)
511
+ emit('notice', `已选好 ${candidate.lineId},点「添加落点装饰」写入真实数据`)
512
+ }
513
+ const confirmDrop = () => {
514
+ const current = pendingDrop.value
515
+ if (!current || !view) {
516
+ return
517
+ }
518
+ const permission = linePermissionAt(current.hit)
519
+ if (!permission.ok) {
520
+ dropReadout.value = reasonText(permission.reason)
521
+ pushLog('add', reasonText(permission.reason))
522
+ return
523
+ }
524
+ const added = view.addLineDecoration(current.lineId, { ...current.decoration })
525
+ if (!added) {
526
+ dropReadout.value = reasonText('targets-missing')
527
+ pushLog('add', reasonText('targets-missing'))
528
+ return
529
+ }
530
+ const item = symbolItemOf(current.paletteId)
531
+ lastCommit.value = {
532
+ count: 1,
533
+ lineIds: [current.lineId],
534
+ size: current.decoration.size,
535
+ symbol: item.label,
536
+ position: current.decoration.position,
537
+ parallel: current.decoration.parallel,
538
+ scope: 'drag'
539
+ }
540
+ pendingDrop.value = null
541
+ clearDropPreview()
542
+ dropReadout.value = `已写入 ${current.lineId}:沿线 ${(current.decoration.position * 100).toFixed(1)}%`
543
+ pushLog('add', `addLineDecoration → ${current.lineId} · ${item.label} · 沿线 ${(current.decoration.position * 100).toFixed(1)}%`)
544
+ emit('notice', `已把「${item.label}」写到 ${current.lineId}`)
545
+ }
546
+ const cancelDrop = () => {
547
+ if (!pendingDrop.value) {
548
+ clearDropPreview()
549
+ return
550
+ }
551
+ const lineId = pendingDrop.value.lineId
552
+ pendingDrop.value = null
553
+ clearDropPreview()
554
+ dropReadout.value = '已取消待添加:数据一个字节没动'
555
+ pushLog('cancel', `取消落点装饰 ${lineId}:没有写数据`)
556
+ }
557
+
558
+ const scheduleStart = () => {
559
+ if (restartTimer) {
560
+ clearTimeout(restartTimer)
561
+ }
562
+ restartTimer = window.setTimeout(() => {
563
+ restartTimer = 0
564
+ if (anyTargetEnabled.value && view) {
565
+ view.beginDecorationDraft({ ...decoratorOptions.value })
566
+ }
567
+ }, 0)
568
+ }
569
+
570
+ const handleDraft = (payload) => {
571
+ if (!payload) {
572
+ return
573
+ }
574
+ draft.value = payload
575
+ if (payload.phase === 'hover') {
576
+ // hover 每次移动都发:面板读数实时更新,日志只在「换了目标 / 作用域」时记一条,别刷屏
577
+ const key = `${payload.hitKind}:${payload.lineId ?? payload.pointId}:${payload.scope}:${payload.targetCount}`
578
+ if (key !== lastHoverKey) {
579
+ lastHoverKey = key
580
+ pushLog('decorationDraft', payload.hitKind === 'point'
581
+ ? `hover · 节点 ${payload.pointId} · ${pointModeText(payload.placement, payload.anchor)} → ${payload.targetCount} 个`
582
+ : `hover · ${payload.lineId ?? '—'} · ${payload.scope} → ${payload.targetCount} 条`)
583
+ }
584
+ return
585
+ }
586
+ lastHoverKey = ''
587
+ if (payload.phase === 'start') {
588
+ pushLog('decorationDraft', `start · target=${payload.target} · ${payload.size}px · ${payload.symbol}`)
589
+ return
590
+ }
591
+ if (payload.phase === 'leave') {
592
+ pushLog('decorationDraft', `leave · 指针离开目标${payload.blockedReason ? `(${payload.blockedReason})` : ''},假装饰收掉`)
593
+ return
594
+ }
595
+ if (payload.phase === 'ready') {
596
+ if (payload.hitKind === 'point') {
597
+ pushLog('decorationDraft', `ready · 节点 ${payload.pointId} · ${pointModeText(payload.placement, payload.anchor)}${payload.duplicateAdjusted ? '(已避让重复)' : ''}`)
598
+ emit('notice', `装饰器已准备好:节点 ${payload.pointId},点「添加真实装饰」写入数据`)
599
+ }
600
+ else {
601
+ pushLog('decorationDraft', `ready · ${payload.lineId} · 沿线 ${(payload.position * 100).toFixed(1)}% · ${payload.targetCount} 条`)
602
+ emit('notice', `装饰器已准备好:${payload.targetCount} 条线路,点「添加真实装饰」写入数据`)
603
+ }
604
+ return
605
+ }
606
+ if (payload.phase === 'update') {
607
+ pushLog('decorationDraft', `update · ${payload.symbol} ${payload.size}px · target=${payload.target}${payload.allowDuplicate ? ' · 允许重复' : ' · 禁止重复'}`)
608
+ return
609
+ }
610
+ if (payload.phase === 'commit') {
611
+ lastCommit.value = {
612
+ count: payload.targetCount,
613
+ lineIds: (payload.targets ?? []).map(target => target.lineId),
614
+ pointIds: (payload.pointTargets ?? []).map(target => target.pointId),
615
+ size: payload.size,
616
+ symbol: payload.symbol,
617
+ position: payload.position,
618
+ parallel: payload.parallel,
619
+ scope: payload.scope,
620
+ placement: payload.hitKind === 'point' ? payload.placement : undefined,
621
+ anchor: payload.hitKind === 'point' ? payload.anchor : undefined
622
+ }
623
+ const target = lastCommit.value.pointIds.length ? `节点 ${lastCommit.value.pointIds.join('、')}` : `线路 ${lastCommit.value.lineIds.join('、')}`
624
+ pushLog('decorationDraft', `commit · 已写入 ${target}`)
625
+ emit('notice', `已写入 ${target} 的真实装饰`)
626
+ scheduleStart()
627
+ return
628
+ }
629
+ if (payload.phase === 'cancel') {
630
+ pushLog('decorationDraft', `cancel · 假装饰已取消${payload.reason ? `(${payload.reason})` : ''}`)
631
+ scheduleStart()
632
+ }
633
+ }
634
+
635
+ const buildOptions = () => ({
636
+ points: dataset.points,
637
+ lines: dataset.lines,
638
+ channels: dataset.channels,
639
+ channelWidth: 2.4,
640
+ channelInset: 1.8,
641
+ pointZoomScale: 0.5,
642
+ pointStyle: { size: 6, color: '#ffffff', strokeColor: '#4c8dff', strokeWidth: 1.4 },
643
+ lineStyle: { width: 1.3, color: 'rgba(120, 168, 226, 0.58)' },
644
+ showGrid: true,
645
+ gridColor: 'rgba(126, 168, 214, 0.22)',
646
+ fitPadding: 50,
647
+ features: 'edit',
648
+ label: { content: '{name}', position: 'top', gap: 7, maxCount: 40, style: { color: '#27405f', fontSize: '12px' } },
649
+ labelOptimize: true,
650
+ onReady: () => {
651
+ emit('notice', `画布就绪:分别勾选线路 / 节点装饰后移到目标上(${dataset.points.length} 个节点 / ${dataset.channels.length} 条通道)`)
652
+ pushLog('ready', '视图就绪:线路与节点开关独立,也可以同时开启')
653
+ }
654
+ })
655
+
656
+ const startDraft = () => {
657
+ if (!view || !anyTargetEnabled.value) {
658
+ return
659
+ }
660
+ lastHoverKey = ''
661
+ view.beginDecorationDraft({ ...decoratorOptions.value })
662
+ }
663
+
664
+ const stopDraft = () => {
665
+ if (!view) {
666
+ return
667
+ }
668
+ lastHoverKey = ''
669
+ view.cancelDecorationDraft()
670
+ draft.value = null
671
+ }
672
+
673
+ const commitDraft = () => {
674
+ if (!view || draft.value?.phase !== 'ready') {
675
+ return
676
+ }
677
+ const result = view.commitDecorationDraft({ ...decoratorOptions.value })
678
+ if (!result.ok) {
679
+ pushLog('commit', `没有写入:${result.reason ?? 'unknown'}`)
680
+ return
681
+ }
682
+ draft.value = null
683
+ }
684
+
685
+ const cancelDraft = () => {
686
+ if (!view || !view.getDecorationDraft()) {
687
+ return
688
+ }
689
+ view.cancelDecorationDraft()
690
+ }
691
+
692
+ const clearAdded = () => {
693
+ if (!view) {
694
+ return
695
+ }
696
+ if (pendingDrop.value) {
697
+ cancelDrop()
698
+ }
699
+ const data = view.getData()
700
+ const decoratedLines = data.lines.filter(line => line.decorations?.length)
701
+ const decoratedPoints = data.points.filter(point => point.decorations?.length)
702
+ if (!decoratedLines.length && !decoratedPoints.length) {
703
+ pushLog('clear', '当前没有已添加的装饰')
704
+ return
705
+ }
706
+ view.batch(() => {
707
+ for (const line of decoratedLines) {
708
+ view.setLineDecorations(line.id, [])
709
+ }
710
+ for (const point of decoratedPoints) {
711
+ view.setPointDecorations(point.id, [])
712
+ }
713
+ }, { label: '清空演示装饰' })
714
+ lastCommit.value = null
715
+ pushLog('clear', `已清掉 ${decoratedLines.length} 条线路 / ${decoratedPoints.length} 个节点上的装饰(一次 batch)`)
716
+ }
717
+
718
+ watch([lineEnabled, pointEnabled], () => {
719
+ if (!view) {
720
+ return
721
+ }
722
+ if (anyTargetEnabled.value) {
723
+ startDraft()
724
+ }
725
+ else {
726
+ stopDraft()
727
+ }
728
+ })
729
+
730
+ watch(decoratorOptions, (next) => {
731
+ if (view?.getDecorationDraft()) {
732
+ view.updateDecorationDraft({ ...next })
733
+ }
734
+ })
735
+
736
+ const subscribe = () => {
737
+ offEvents.forEach(off => off())
738
+ offEvents = [
739
+ view.on('decorationDraft', handleDraft),
740
+ view.on('dataChange', payload => {
741
+ pushLog('dataChange', `${payload.type} · 点 ${payload.counts?.points ?? '—'} / 线 ${payload.counts?.lines ?? '—'} / 通道 ${payload.counts?.channels ?? '—'}`)
742
+ })
743
+ ]
744
+ }
745
+ const onCanvasPointerMove = (event) => {
746
+ if (!view) {
747
+ return
748
+ }
749
+ const screen = canvasPointOf(event)
750
+ lastPointerWorld = view.unproject(screen[0], screen[1])
751
+ }
752
+ const usePointerAsFixed = () => {
753
+ const world = lastPointerWorld ?? draft.value?.world ?? null
754
+ if (!world) {
755
+ pushLog('fixed', '指针还没在画布上移动过,先移到目标附近再取坐标')
756
+ return
757
+ }
758
+ pointAtX.value = Number(world[0].toFixed(4))
759
+ pointAtY.value = Number(world[1].toFixed(4))
760
+ pointSnapPosition.value = 'fixed'
761
+ pushLog('fixed', `固定位置已取指针坐标:${pointAtX.value}, ${pointAtY.value}`)
762
+ }
763
+ const onKeyDown = (event) => {
764
+ if (event.key !== 'Escape') {
765
+ return
766
+ }
767
+ const tag = event.target?.tagName
768
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
769
+ return
770
+ }
771
+ if (pendingDrop.value) {
772
+ cancelDrop()
773
+ return
774
+ }
775
+ if (view?.getDecorationDraft()) {
776
+ cancelDraft()
777
+ }
778
+ }
779
+
780
+ onMounted(() => {
781
+ view = createGisView(container.value, buildOptions())
782
+ subscribe()
783
+ window.addEventListener('keydown', onKeyDown)
784
+ })
785
+
786
+ onBeforeUnmount(() => {
787
+ lineEnabled.value = false
788
+ pointEnabled.value = false
789
+ window.removeEventListener('keydown', onKeyDown)
790
+ if (restartTimer) {
791
+ clearTimeout(restartTimer)
792
+ restartTimer = 0
793
+ }
794
+ pendingDrop.value = null
795
+ dragSymbolId.value = ''
796
+ clearDropPreview()
797
+ dragGhost?.remove()
798
+ dragGhost = null
799
+ offEvents.forEach(off => off())
800
+ offEvents = []
801
+ view?.destroy()
802
+ view = null
803
+ })
804
+ </script>
805
+
806
+ <template>
807
+ <section class="dv-demo dv-dec-demo">
808
+ <aside class="dv-dec-palette">
809
+ <div class="dv-dec-palette__head">
810
+ <strong>图标看板</strong>
811
+ <span>拖到线路</span>
812
+ </div>
813
+ <button
814
+ v-for="item in SYMBOLS"
815
+ :key="item.id"
816
+ type="button"
817
+ class="dv-dec-palette__item"
818
+ :class="{ 'dv-dec-palette__item--active': symbolId === item.id }"
819
+ :draggable="true"
820
+ :data-demo="`decoration-palette-${item.id}`"
821
+ :title="item.hint"
822
+ @click="selectPalette(item)"
823
+ @dragstart="onDragStart($event, item)"
824
+ @dragend="onDragEnd"
825
+ >
826
+ <span class="dv-dec-palette__glyph" :style="{ color }">{{ item.glyph }}</span>
827
+ <span>{{ item.label }}</span>
828
+ </button>
829
+ <p class="dv-hint">按住图标拖到线路附近,松手只落预览;点右侧「添加落点装饰」才调用 addLineDecoration 写数据。节点装饰请在右侧开启「节点装饰」后直接指向节点。</p>
830
+ </aside>
831
+
832
+ <div class="dv-stage">
833
+ <div
834
+ ref="container"
835
+ class="dv-canvas"
836
+ :class="{ 'dv-canvas--active': anyTargetEnabled, 'dv-canvas--over': dragOver }"
837
+ @dragover.prevent="onDragOver"
838
+ @dragleave="onDragLeave"
839
+ @drop.prevent="onDrop"
840
+ @pointermove="onCanvasPointerMove"
841
+ ></div>
842
+ <div class="dv-toolbar">
843
+ <button type="button" @click="view?.fitView(50)">适应全部</button>
844
+ <button type="button" @click="clearAdded">清空演示装饰</button>
845
+ </div>
846
+ <p v-if="anyTargetEnabled" class="dv-draft-hint" :class="{ 'dv-draft-hint--ready': draft?.phase === 'ready' }">
847
+ {{ draftHintText }}
848
+ </p>
849
+ <div v-if="draft?.phase === 'ready'" class="dv-confirm">
850
+ <span class="dv-confirm__text">{{ confirmHint }}</span>
851
+ <button type="button" class="dv-button dv-button--primary" @click="commitDraft">添加真实装饰</button>
852
+ <button type="button" class="dv-button" @click="cancelDraft">取消(Esc)</button>
853
+ </div>
854
+ </div>
855
+
856
+ <aside class="dv-panel">
857
+ <div class="dv-group">
858
+ <h3>装饰器开关<span class="dv-chip">{{ anyTargetEnabled ? targetModeText : '未开启' }}</span></h3>
859
+ <div class="dv-row">
860
+ <label><input v-model="lineEnabled" type="checkbox" data-demo="decoration-line-enabled" /> 线路装饰</label>
861
+ <label><input v-model="pointEnabled" type="checkbox" data-demo="decoration-point-enabled" /> 节点装饰</label>
862
+ </div>
863
+ <p class="dv-hint">两个开关互不依赖:只开一类就只命中那类;同时开启时按屏幕距离取更近的目标,等距优先节点。</p>
864
+ </div>
865
+
866
+ <div class="dv-group">
867
+ <h3>图标样式<span class="dv-chip">{{ currentSymbolLabel }}</span></h3>
868
+ <div class="dv-row">
869
+ <label class="dv-field">图标
870
+ <select v-model="symbolId" class="dv-input">
871
+ <option v-for="item in SYMBOLS" :key="item.id" :value="item.id">{{ item.label }}</option>
872
+ </select>
873
+ </label>
874
+ </div>
875
+ <div class="dv-row">
876
+ <label class="dv-field dv-field--wide">大小
877
+ <input v-model.number="size" type="range" min="8" max="48" step="1" />
878
+ <span class="dv-value">{{ size }} px</span>
879
+ </label>
880
+ </div>
881
+ <div class="dv-row">
882
+ <label class="dv-field">颜色 <input v-model="color" class="dv-color" type="color" /></label>
883
+ <label class="dv-field">描边 <input v-model="strokeColor" class="dv-color" type="color" /></label>
884
+ <label class="dv-field dv-field--wide">透明度
885
+ <input v-model.number="opacity" type="range" min="0.2" max="1" step="0.05" />
886
+ <span class="dv-value">{{ Math.round(opacity * 100) }}%</span>
887
+ </label>
888
+ </div>
889
+ <p class="dv-hint">{{ SYMBOLS.find(item => item.id === symbolId)?.hint }}</p>
890
+ <p class="dv-hint">开启后由引擎画临时图标;确认前不写数据、不记历史。移出目标会自动收掉,松开左键仍命中才进入 ready。</p>
891
+ </div>
892
+
893
+ <div class="dv-group" :class="{ 'dv-group--muted': !lineEnabled }">
894
+ <h3>线路装饰<span class="dv-chip">{{ lineEnabled ? '参与命中' : '已关闭' }}</span></h3>
895
+ <div class="dv-row">
896
+ <label class="dv-field">作用范围
897
+ <select v-model="scope" class="dv-input" data-demo="decoration-scope">
898
+ <option value="line">单条线路</option>
899
+ <option value="group">整个分组</option>
900
+ <option value="channel">整条通道</option>
901
+ </select>
902
+ </label>
903
+ <label><input v-model="parallel" type="checkbox" /> 与线路方向平行</label>
904
+ </div>
905
+ <div class="dv-row">
906
+ <label class="dv-field">每条线路最多
907
+ <input v-model.number="maxPerLine" class="dv-input dv-input--number" type="number" min="0" step="1" data-demo="decoration-max-per-line" />
908
+ <span class="dv-hint">0 = 不限</span>
909
+ </label>
910
+ </div>
911
+ <div class="dv-row">
912
+ <label class="dv-field dv-field--wide">允许线路 id
913
+ <input v-model="allowedLinesText" class="dv-input" type="text" placeholder="留空 = 全部,如 line-0, line-1" data-demo="decoration-allowed-lines" />
914
+ </label>
915
+ </div>
916
+ <div class="dv-row">
917
+ <label class="dv-field dv-field--wide">允许通道 id
918
+ <input v-model="allowedChannelsText" class="dv-input" type="text" placeholder="如 channel-a, channel-b" data-demo="decoration-allowed-channels" />
919
+ </label>
920
+ </div>
921
+ <div class="dv-row">
922
+ <label class="dv-field dv-field--wide">允许分组 id
923
+ <input v-model="allowedGroupsText" class="dv-input" type="text" placeholder="如 channel-a, channel-c" data-demo="decoration-allowed-groups" />
924
+ </label>
925
+ </div>
926
+ <p class="dv-hint">三组允许范围都留空 = 全部允许;任意一组有值时,线路 / 通道 / 分组命中任意一项即可。超出白名单或达到上限时不会触发 hover / ready。</p>
927
+ </div>
928
+
929
+ <div class="dv-group" :class="{ 'dv-group--muted': !pointEnabled }">
930
+ <h3>节点装饰<span class="dv-chip">{{ pointEnabled ? pointSnapText : '已关闭' }}</span></h3>
931
+ <div class="dv-row">
932
+ <label class="dv-field">吸附方式
933
+ <select v-model="pointSnapMode" class="dv-input" data-demo="decoration-point-snap-mode">
934
+ <option value="follow">跟随</option>
935
+ <option value="fixed">固定</option>
936
+ </select>
937
+ </label>
938
+ </div>
939
+ <div class="dv-row">
940
+ <label class="dv-field">位置来源
941
+ <select v-model="pointSnapPosition" class="dv-input" data-demo="decoration-point-snap-position">
942
+ <option value="offset">相对节点(偏移)</option>
943
+ <option value="fixed">固定位置(世界坐标)</option>
944
+ <option value="pointer">鼠标位置</option>
945
+ </select>
946
+ </label>
947
+ </div>
948
+ <template v-if="pointSnapPosition === 'offset'">
949
+ <div class="dv-row">
950
+ <label class="dv-field dv-field--wide">方向(0° 向上、顺时针)
951
+ <input v-model.number="pointDirection" type="range" min="0" max="359" step="1" data-demo="decoration-point-direction" />
952
+ <span class="dv-value">{{ pointDirection }}°</span>
953
+ </label>
954
+ </div>
955
+ <div class="dv-row">
956
+ <label class="dv-field dv-field--wide">距离(屏幕像素)
957
+ <input v-model.number="pointDistance" type="range" min="0" max="160" step="1" data-demo="decoration-point-distance" />
958
+ <span class="dv-value">{{ pointDistance }} px</span>
959
+ </label>
960
+ </div>
961
+ <p class="dv-hint">先用方向 / 距离算出相对节点偏移。跟随直接保存这段屏幕偏移;固定会把当时的位置换算成世界坐标后钉住。</p>
962
+ </template>
963
+ <template v-else-if="pointSnapPosition === 'fixed'">
964
+ <div class="dv-row">
965
+ <label class="dv-field">X
966
+ <input v-model.number="pointAtX" class="dv-input dv-input--number" type="number" step="0.1" data-demo="decoration-point-at-x" />
967
+ </label>
968
+ <label class="dv-field">Y
969
+ <input v-model.number="pointAtY" class="dv-input dv-input--number" type="number" step="0.1" data-demo="decoration-point-at-y" />
970
+ </label>
971
+ </div>
972
+ <div class="dv-row">
973
+ <button type="button" class="dv-button" data-demo="decoration-point-at-pointer" @click="usePointerAsFixed">取当前指针坐标</button>
974
+ </div>
975
+ <p class="dv-hint">跟随会把这组世界坐标换算成相对节点的屏幕偏移,节点移动时保持这段像素距离;固定则一直留在这组世界坐标。</p>
976
+ </template>
977
+ <p v-else class="dv-hint">以落点时的指针位置为准。跟随保存指针相对节点的屏幕偏移;固定保存指针的世界坐标,节点移动后不跟随。</p>
978
+ <div class="dv-row">
979
+ <label class="dv-field">每个节点最多
980
+ <input v-model.number="maxPerPoint" class="dv-input dv-input--number" type="number" min="0" step="1" data-demo="decoration-max-per-point" />
981
+ <span class="dv-hint">0 = 不限</span>
982
+ </label>
983
+ </div>
984
+ <div class="dv-row">
985
+ <label class="dv-field dv-field--wide">允许节点 id
986
+ <input v-model="allowedPointsText" class="dv-input" type="text" placeholder="留空 = 全部,如 node-0, node-1" data-demo="decoration-allowed-points" />
987
+ </label>
988
+ </div>
989
+ <div class="dv-row">
990
+ <label><input v-model="connectorEnabled" type="checkbox" data-demo="decoration-connector" /> 从节点中心画连接线</label>
991
+ </div>
992
+ <template v-if="connectorEnabled">
993
+ <div class="dv-row">
994
+ <label class="dv-field">颜色 <input v-model="connectorColor" class="dv-color" type="color" /></label>
995
+ <label class="dv-field">线宽
996
+ <input v-model.number="connectorWidth" class="dv-input dv-input--number" type="number" min="0.2" max="8" step="0.1" />
997
+ <span class="dv-hint">px</span>
998
+ </label>
999
+ <label><input v-model="connectorDashed" type="checkbox" /> 虚线</label>
1000
+ </div>
1001
+ </template>
1002
+ <p class="dv-hint">吸附方式只决定落地后是否跟随节点;位置来源三档在跟随 / 固定下都能选,可添加的位置完全一样。连接线跟着装饰中心走。</p>
1003
+ </div>
1004
+
1005
+ <div class="dv-group">
1006
+ <h3>触发与限制<span class="dv-chip">{{ triggerDistance }}px</span></h3>
1007
+ <div class="dv-row">
1008
+ <label class="dv-field dv-field--wide">触发距离(屏幕像素)
1009
+ <input v-model.number="triggerDistance" type="range" min="2" max="40" step="1" data-demo="decoration-trigger-distance" />
1010
+ <span class="dv-value">{{ triggerDistance }} px</span>
1011
+ </label>
1012
+ </div>
1013
+ <p class="dv-readout">{{ ruleText }}</p>
1014
+ </div>
1015
+
1016
+ <div class="dv-group">
1017
+ <h3>同位置重复<span class="dv-chip">{{ allowDuplicate ? '允许' : '禁止' }}</span></h3>
1018
+ <div class="dv-row">
1019
+ <label><input v-model="allowDuplicate" type="checkbox" data-demo="decoration-allow-duplicate" /> 允许同一位置重复添加</label>
1020
+ </div>
1021
+ <template v-if="!allowDuplicate">
1022
+ <div class="dv-row">
1023
+ <label class="dv-field">处理方式
1024
+ <select v-model="duplicateStrategy" class="dv-input" data-demo="decoration-duplicate-strategy">
1025
+ <option value="offset">自动避让</option>
1026
+ <option value="reject">直接拒绝</option>
1027
+ </select>
1028
+ </label>
1029
+ <label class="dv-field">判定距离
1030
+ <input v-model.number="duplicateDistance" class="dv-input dv-input--number" type="number" min="1" max="64" step="1" data-demo="decoration-duplicate-distance" />
1031
+ <span class="dv-hint">px</span>
1032
+ </label>
1033
+ </div>
1034
+ </template>
1035
+ <p class="dv-hint">关闭「允许重复」后,线路装饰沿线路前后避让,节点装饰沿当前偏移方向往外避让;选「直接拒绝」时该目标不会触发 ready,也不会写入。</p>
1036
+ </div>
1037
+
1038
+ <div class="dv-group">
1039
+ <h3>拖拽添加<span class="dv-chip">{{ pendingDrop ? '待确认' : '空' }}</span></h3>
1040
+ <p class="dv-readout">{{ dropReadout }}</p>
1041
+ <div class="dv-row">
1042
+ <button type="button" class="dv-button dv-button--primary" :disabled="!pendingDrop" data-demo="decoration-drop-confirm" @click="confirmDrop">添加落点装饰</button>
1043
+ <button type="button" class="dv-button" :disabled="!pendingDrop" data-demo="decoration-drop-cancel" @click="cancelDrop">取消</button>
1044
+ </div>
1045
+ <p v-if="pendingDrop" class="dv-hint">落点已选好,确认后才调用 addLineDecoration;取消只收掉预览,数据与历史都不动。</p>
1046
+ </div>
1047
+
1048
+ <div class="dv-group">
1049
+ <h3>当前草稿<span class="dv-chip">{{ phaseText }}</span></h3>
1050
+ <p class="dv-readout">阶段:{{ draft?.phase ?? '—' }} · {{ phaseText }}</p>
1051
+ <p class="dv-readout">命中:{{ hitText }}</p>
1052
+ <p class="dv-readout">目标:{{ targetText }}</p>
1053
+ <p v-if="draft?.hitKind === 'point'" class="dv-readout">位置:{{ positionText }} · 屏幕:{{ decorationScreenText }} · 世界:{{ worldText }}</p>
1054
+ <p v-else class="dv-readout">沿线:{{ positionText }} · 角度:{{ angleText }} · 尺寸:{{ draft?.size ?? size }}px</p>
1055
+ <p v-if="draft?.hitKind === 'point'" class="dv-readout">连接线:{{ draft?.connector ? '开启' : '关闭' }} · 重复避让:{{ draft?.duplicateAdjusted ? '已避让' : '未触发' }}</p>
1056
+ <p v-else class="dv-readout">方向:{{ draft?.parallel ? '跟随线路切向' : '保持水平' }} · 范围:{{ draft?.scope ?? scope }}</p>
1057
+ <p v-if="draft?.blockedReason" class="dv-readout dv-readout--blocked">阻挡:{{ blockedText }}</p>
1058
+ </div>
1059
+
1060
+ <div class="dv-group">
1061
+ <h3>新增 / 取消<span class="dv-chip">{{ draft?.phase === 'ready' ? '等确认' : '空' }}</span></h3>
1062
+ <div class="dv-row">
1063
+ <button type="button" class="dv-button dv-button--primary" :disabled="draft?.phase !== 'ready'" @click="commitDraft">添加真实装饰</button>
1064
+ <button type="button" class="dv-button" :disabled="!draft" @click="cancelDraft">取消(Esc)</button>
1065
+ </div>
1066
+ <p class="dv-hint">线路确认会按 scope 给每条目标线路追加同位置、同方向的 decoration;节点确认只给命中的节点追加一条。整批只发一条 dataChange、只记一步历史;取消 / Esc 都只清假装饰。</p>
1067
+ </div>
1068
+
1069
+ <div class="dv-group">
1070
+ <h3>最近一次写入<span class="dv-chip">{{ lastCommit ? `${lastCommit.count} 个` : '还没有' }}</span></h3>
1071
+ <p class="dv-readout">{{ lastCommitText }}</p>
1072
+ <p v-if="lastCommit?.lineIds?.length" class="dv-readout">线路:{{ lastCommit.lineIds.join('、') }}</p>
1073
+ <p v-if="lastCommit?.pointIds?.length" class="dv-readout">节点:{{ lastCommit.pointIds.join('、') }}</p>
1074
+ </div>
1075
+
1076
+ <div class="dv-group">
1077
+ <h3>事件日志</h3>
1078
+ <ul class="dv-log">
1079
+ <li v-for="line in logLines" :key="line.id"><strong>{{ line.name }}</strong> · {{ line.text }}</li>
1080
+ <li v-if="!logLines.length">还没有事件:勾选线路 / 节点装饰后移到目标上</li>
1081
+ </ul>
1082
+ </div>
1083
+ </aside>
1084
+ </section>
1085
+ </template>
1086
+
1087
+ <style scoped>
1088
+ .dv-dec-demo {
1089
+ grid-template-columns: minmax(150px, 184px) minmax(0, 1fr) clamp(320px, 27vw, 440px);
1090
+ }
1091
+
1092
+ .dv-group--muted {
1093
+ opacity: 0.72;
1094
+ }
1095
+
1096
+ .dv-readout--blocked {
1097
+ background: #fff3f0;
1098
+ color: #a34332;
1099
+ }
1100
+
1101
+ .dv-dec-palette {
1102
+ display: flex;
1103
+ flex-direction: column;
1104
+ gap: 7px;
1105
+ max-height: clamp(460px, calc(100vh - 320px), 860px);
1106
+ padding: 10px;
1107
+ overflow: auto;
1108
+ border: 1px solid #e2e9f2;
1109
+ border-radius: 10px;
1110
+ background: #fff;
1111
+ box-shadow: 0 8px 24px rgba(31, 62, 105, 0.05);
1112
+ }
1113
+
1114
+ .dv-dec-palette__head {
1115
+ display: flex;
1116
+ align-items: baseline;
1117
+ justify-content: space-between;
1118
+ gap: 8px;
1119
+ padding: 2px 2px 5px;
1120
+ color: #17233d;
1121
+ font-size: 13px;
1122
+ }
1123
+
1124
+ .dv-dec-palette__head span {
1125
+ color: #8191a8;
1126
+ font-size: 11px;
1127
+ white-space: nowrap;
1128
+ }
1129
+
1130
+ .dv-dec-palette__item {
1131
+ display: flex;
1132
+ align-items: center;
1133
+ gap: 8px;
1134
+ padding: 8px 9px;
1135
+ border: 1px solid #d6e0ec;
1136
+ border-radius: 8px;
1137
+ background: #fff;
1138
+ color: #1f2a44;
1139
+ font-size: 12px;
1140
+ text-align: left;
1141
+ cursor: grab;
1142
+ }
1143
+
1144
+ .dv-dec-palette__item:hover {
1145
+ border-color: #9bc0e8;
1146
+ background: #eef4fc;
1147
+ }
1148
+
1149
+ .dv-dec-palette__item:active {
1150
+ cursor: grabbing;
1151
+ }
1152
+
1153
+ .dv-dec-palette__item--active {
1154
+ border-color: #3678bf;
1155
+ background: #eaf2fc;
1156
+ box-shadow: inset 3px 0 0 #3678bf;
1157
+ }
1158
+
1159
+ .dv-dec-palette__glyph {
1160
+ width: 18px;
1161
+ font-size: 15px;
1162
+ line-height: 1;
1163
+ text-align: center;
1164
+ }
1165
+
1166
+ .dv-dec-palette > .dv-hint {
1167
+ margin-top: auto;
1168
+ padding: 4px 2px 0;
1169
+ }
1170
+
1171
+ .dv-canvas--active {
1172
+ outline: 2px dashed rgba(54, 120, 191, 0.55);
1173
+ outline-offset: -8px;
1174
+ }
1175
+
1176
+ .dv-canvas--over {
1177
+ outline-color: rgba(222, 143, 34, 0.82);
1178
+ background: #fffaf1;
1179
+ }
1180
+
1181
+ .dv-draft-hint {
1182
+ position: absolute;
1183
+ left: 50%;
1184
+ bottom: 18px;
1185
+ z-index: 4;
1186
+ margin: 0;
1187
+ padding: 6px 14px;
1188
+ border-radius: 999px;
1189
+ background: rgba(54, 120, 191, 0.92);
1190
+ color: #fff;
1191
+ font-size: 12px;
1192
+ transform: translateX(-50%);
1193
+ pointer-events: none;
1194
+ }
1195
+
1196
+ .dv-draft-hint--ready {
1197
+ background: rgba(222, 143, 34, 0.94);
1198
+ }
1199
+
1200
+ .dv-confirm {
1201
+ position: absolute;
1202
+ left: 50%;
1203
+ bottom: 54px;
1204
+ z-index: 5;
1205
+ display: flex;
1206
+ align-items: center;
1207
+ gap: 8px;
1208
+ padding: 6px 8px;
1209
+ border-radius: 10px;
1210
+ background: rgba(255, 255, 255, 0.96);
1211
+ box-shadow: 0 10px 26px rgba(31, 62, 105, 0.2);
1212
+ transform: translateX(-50%);
1213
+ }
1214
+
1215
+ .dv-confirm__text {
1216
+ max-width: 520px;
1217
+ color: #526b8d;
1218
+ font-size: 12px;
1219
+ line-height: 1.5;
1220
+ }
1221
+
1222
+ .dv-field {
1223
+ display: inline-flex;
1224
+ align-items: center;
1225
+ gap: 6px;
1226
+ color: #4b5b76;
1227
+ font-size: 12px;
1228
+ }
1229
+
1230
+ .dv-field--wide {
1231
+ flex: 1;
1232
+ min-width: 180px;
1233
+ }
1234
+
1235
+ .dv-input {
1236
+ min-width: 0;
1237
+ flex: 1;
1238
+ padding: 4px 6px;
1239
+ border: 1px solid #d6e0ec;
1240
+ border-radius: 6px;
1241
+ background: #fff;
1242
+ color: #1f2a44;
1243
+ font-size: 12px;
1244
+ }
1245
+
1246
+ .dv-input--number {
1247
+ flex: 0 0 72px;
1248
+ width: 72px;
1249
+ }
1250
+
1251
+ .dv-value {
1252
+ min-width: 42px;
1253
+ color: #2f5e94;
1254
+ font-variant-numeric: tabular-nums;
1255
+ text-align: right;
1256
+ }
1257
+
1258
+ .dv-color {
1259
+ width: 30px;
1260
+ height: 26px;
1261
+ padding: 1px;
1262
+ border: 1px solid #d6e0ec;
1263
+ border-radius: 6px;
1264
+ background: #fff;
1265
+ cursor: pointer;
1266
+ }
1267
+
1268
+ @media (max-width: 1280px) {
1269
+ .dv-dec-demo {
1270
+ grid-template-columns: minmax(0, 1fr) clamp(320px, 27vw, 440px);
1271
+ }
1272
+
1273
+ .dv-dec-palette {
1274
+ grid-column: 1 / -1;
1275
+ flex-direction: row;
1276
+ flex-wrap: wrap;
1277
+ align-items: center;
1278
+ max-height: none;
1279
+ }
1280
+
1281
+ .dv-dec-palette__head {
1282
+ flex: 1 1 100%;
1283
+ }
1284
+
1285
+ .dv-dec-palette__item {
1286
+ flex: 0 1 auto;
1287
+ }
1288
+
1289
+ .dv-dec-palette > .dv-hint {
1290
+ flex: 1 1 240px;
1291
+ margin-top: 0;
1292
+ }
1293
+ }
1294
+
1295
+ @media (max-width: 1080px) {
1296
+ .dv-dec-demo {
1297
+ grid-template-columns: minmax(0, 1fr);
1298
+ }
1299
+ }
1300
+ </style>