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.
- package/README.md +40 -8
- package/dist/gis-view.css +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +1036 -249
- package/dist/index.js +1 -1
- package/docs/AI/345/257/271/350/257/235/346/261/207/346/200/273.md +194 -0
- package/docs/GisView.md +9 -8
- package/docs/GisView/345/256/236/347/216/260/345/216/237/347/220/206.md +2 -2
- 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
- package/docs/api/index.html +545 -59
- package/docs/demos/BackendSyncDemo.vue +10 -8
- package/docs/demos/BasicDemo.vue +101 -101
- package/docs/demos/BatchAddDemo.vue +5 -5
- package/docs/demos/ChannelDemo.vue +617 -10
- package/docs/demos/ConnectDemo.vue +392 -0
- package/docs/demos/CopyMergeDemo.vue +980 -929
- package/docs/demos/DecorationDraftDemo.vue +1300 -0
- package/docs/demos/DemoShell.vue +569 -240
- package/docs/demos/DragDropDemo.vue +11 -11
- package/docs/demos/EffectsDemo.vue +6 -6
- package/docs/demos/EventDemo.vue +327 -327
- package/docs/demos/ExtensionDemo.vue +458 -0
- package/docs/demos/GisViewDemo.vue +61 -48
- package/docs/demos/GraphicDemo.vue +338 -0
- package/docs/demos/LabelDemo.vue +553 -497
- package/docs/demos/MinimapDemo.vue +358 -0
- package/docs/demos/PerfDemo.vue +29 -0
- package/docs/demos/SearchFilterDemo.vue +412 -0
- package/docs/demos/StyleDemo.vue +121 -121
- package/docs/demos/api-index.js +98 -77
- package/docs/demos/demos.js +159 -135
- package/docs/demos/shared/demoData.js +107 -107
- package/docs/demos/shared/demoStyle.css +249 -249
- package/docs/demos/shared/fakeBackend.js +93 -6
- 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
- package/docs/format/04-/351/200/232/351/201/223/344/270/216/346/240/207/347/255/276.md +5 -4
- 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
- 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
- package/docs/guide/sample-document.html +4 -4
- package/docs/index.html +180 -9
- 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
- package/docs/internals/13-/351/271/260/347/234/274/345/233/276.md +3 -2
- 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
- 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
- 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
- package/docs/manual/02-/345/235/220/346/240/207/344/270/216/346/225/260/346/215/256.md +18 -4
- package/docs/manual/03-/351/205/215/347/275/256/351/241/271.md +110 -25
- package/docs/manual/04-/344/272/213/344/273/266/344/270/216/345/233/236/350/260/203.md +13 -8
- 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
- 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
- package/docs/manual/08-/351/200/211/344/270/255/344/270/216/346/213/220/347/202/271.md +17 -15
- 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
- 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
- package/docs/manual/11-/346/240/267/345/274/217/344/270/216/350/247/204/345/210/231.md +3 -2
- package/docs/manual/12-/346/240/207/347/255/276/344/270/216/350/243/205/351/245/260.md +260 -3
- package/docs/manual/13-/345/212/250/346/225/210/344/270/216/345/233/276/345/205/203.md +6 -5
- package/docs/manual/14-/350/247/206/345/233/276/344/270/216/347/255/233/351/200/211.md +27 -14
- package/docs/manual/15-/345/257/274/345/207/272/344/270/216/351/233/206/346/210/220.md +142 -49
- 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
- package/docs/manual/17-/345/217/202/346/225/260/351/200/237/346/237/245.md +55 -39
- package/docs/manual/18-/351/200/220/346/226/271/346/263/225/347/244/272/344/276/213.md +189 -64
- 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
- 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
- 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
- package/docs/playground/index.html +5 -5
- package/docs/steps/README.md +14 -10
- package/docs/steps/check-doc-truth.mjs +1 -1
- package/docs/steps/selftest-actions.html +10 -10
- package/docs/steps/selftest-data.html +96 -14
- package/docs/steps/selftest-decoration.html +577 -0
- package/docs/steps/selftest-demo.html +6 -5
- package/docs/steps/selftest-demos.html +2201 -1612
- package/docs/steps/selftest-docs.html +89 -16
- package/docs/steps/selftest-effects.html +5 -5
- package/docs/steps/selftest-features.html +757 -86
- package/docs/steps/selftest-full.html +583 -583
- package/docs/steps/selftest-labels.html +1034 -652
- package/docs/steps/selftest-perf.html +1899 -1844
- package/docs/steps/selftest-scale.html +117 -7
- package/docs/steps/selftest-select.html +24 -17
- package/docs/steps/step-10-full.js +1 -1
- package/docs//346/216/245/345/217/243/345/215/217/350/256/256.md +167 -17
- package/docs//346/225/260/346/215/256/346/240/274/345/274/217.md +1 -1
- package/package.json +43 -43
- package/docs/demos/HistoryDemo.vue +0 -230
- package/docs/demos/InterfaceDemo.vue +0 -309
- 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>
|