|
1 | | -<template> |
2 | | - <Teleport to="body"> |
3 | | - <div |
4 | | - class="feature-menu-overlay" |
5 | | - v-if="visible" |
6 | | - :style="menuStyle" |
7 | | - @mousedown.stop |
8 | | - @click.stop |
9 | | - > |
10 | | - <div |
11 | | - v-for="(feature, index) in menuData as Feature<Geometry>[]" |
12 | | - :key="index" |
13 | | - class="feature-menu-item" |
14 | | - @click="handleSelect(feature)" |
15 | | - > |
16 | | - {{ feature.get('name') || feature.getId() || `Feature ${index + 1}` }} |
17 | | - </div> |
18 | | - </div> |
19 | | - </Teleport> |
20 | | -</template> |
21 | | - |
22 | 1 | <script setup lang="ts"> |
23 | | -import { ref } from 'vue'; |
24 | | -import type { Feature } from 'ol'; |
25 | | -import type Geometry from 'ol/geom/Geometry'; |
| 2 | +import { ref, computed, defineExpose } from 'vue'; |
| 3 | +import SrFeatureTreeNode, { type FeatureNode, type MiniFeature, type SelectPayload } from './SrFeatureTreeNode.vue'; |
| 4 | +import { useRecTreeStore } from '@/stores/recTreeStore'; |
| 5 | +
|
| 6 | +// --- utilities to accept anything from OL + clusters and keep a tiny feature API |
| 7 | +function isMiniFeature(o: any): o is MiniFeature { |
| 8 | + return !!o && typeof o.get === 'function' && typeof o.getId === 'function' && typeof o.getGeometry === 'function'; |
| 9 | +} |
| 10 | +function unwrapCluster(item: any): MiniFeature[] { |
| 11 | + const contained = item?.get?.('features'); |
| 12 | + if (Array.isArray(contained) && contained.length && isMiniFeature(contained[0])) return contained as MiniFeature[]; |
| 13 | + return isMiniFeature(item) ? [item] : []; |
| 14 | +} |
| 15 | +function coerceToMiniFeatures(arr: any[]): MiniFeature[] { |
| 16 | + const out: MiniFeature[] = []; |
| 17 | + for (const x of arr ?? []) out.push(...unwrapCluster(x)); |
| 18 | + return out; |
| 19 | +} |
| 20 | +function safeLabel(f: MiniFeature, i: number) { |
| 21 | + return (f.get('name') as string) || (f.getId()?.toString() ?? '') || `Feature ${i + 1}`; |
| 22 | +} |
26 | 23 |
|
27 | | -const menuData = ref<Feature<Geometry>[]>([]); // use a strongly typed array |
28 | | -// Internal state |
| 24 | +const menuData = ref<MiniFeature[]>([]); |
29 | 25 | const visible = ref(false); |
30 | 26 | const menuStyle = ref<Record<string, string>>({}); |
31 | 27 |
|
| 28 | +const emit = defineEmits<{ (e: 'select', payload: SelectPayload): void }>(); |
32 | 29 |
|
33 | | -// Define Emits |
34 | | -const emit = defineEmits<{ |
35 | | - (e: 'select', feature: Feature<Geometry>): void |
36 | | -}>(); |
| 30 | +// ----- Build reqId -> { keys, labels, reqIds, order } from recTreeStore.treeData |
| 31 | +type RecPath = { keys: string[]; labels: string[]; reqIds: number[]; order: number[] }; |
| 32 | +const recTree = useRecTreeStore(); |
37 | 33 |
|
38 | | -// Public methods |
39 | | -const showMenu = (x: number, y: number, features: Feature<Geometry>[]) => { |
40 | | - visible.value = true; |
41 | | - menuData.value = features; |
| 34 | +function buildIndex(): Map<number, RecPath> { |
| 35 | + const idx = new Map<number, RecPath>(); |
| 36 | + let seq = 0; |
| 37 | + function walk(nodes: any, keys: string[], labels: string[], reqIds: number[], order: number[]) { |
| 38 | + for (const n of (nodes || [])) { |
| 39 | + const k = [...keys, n.key]; |
| 40 | + const l = [...labels, n.label]; |
| 41 | + const r = [...reqIds, Number(n.data) || 0]; // every node in your recTree has data=reqId |
| 42 | + const o = [...order, seq++]; |
| 43 | + // index by this node's reqId too, so *every level* is individually addressable |
| 44 | + const thisReqId = Number(n.data) || 0; |
| 45 | + if (thisReqId > 0) idx.set(thisReqId, { keys: k, labels: l, reqIds: r, order: o }); |
| 46 | + if (n.children?.length) walk(n.children, k, l, r, o); |
| 47 | + } |
| 48 | + } |
| 49 | + walk(recTree.treeData, [], [], [], []); |
| 50 | + return idx; |
| 51 | +} |
| 52 | +
|
| 53 | +// ensure a path exists; attach {kind:'record', reqId} payload at each level |
| 54 | +function ensurePath(root: FeatureNode[], path: RecPath): FeatureNode { |
| 55 | + let cursor = root; |
| 56 | + let node: FeatureNode | undefined; |
| 57 | + for (let i = 0; i < path.labels.length; i++) { |
| 58 | + const key = `rec-${path.keys[i]}`; |
| 59 | + node = cursor.find(n => n.key === key); |
| 60 | + if (!node) { |
| 61 | + node = { |
| 62 | + key, |
| 63 | + label: path.labels[i], |
| 64 | + expanded: true, |
| 65 | + children: [], |
| 66 | + payload: path.reqIds[i] > 0 ? { kind: 'record', reqId: path.reqIds[i] } : undefined |
| 67 | + }; |
| 68 | + cursor.push(node); |
| 69 | + } else if (!node.payload && path.reqIds[i] > 0) { |
| 70 | + node.payload = { kind: 'record', reqId: path.reqIds[i] }; |
| 71 | + } |
| 72 | + if (!node.children) node.children = []; |
| 73 | + cursor = node.children; |
| 74 | + } |
| 75 | + return node!; |
| 76 | +} |
| 77 | +
|
| 78 | +// sort to match records traversal order |
| 79 | +function makeSorter(orderMap: Map<string, number[]>) { |
| 80 | + return (a: FeatureNode, b: FeatureNode) => { |
| 81 | + const ao = orderMap.get(a.key) ?? [Number.MAX_SAFE_INTEGER]; |
| 82 | + const bo = orderMap.get(b.key) ?? [Number.MAX_SAFE_INTEGER]; |
| 83 | + const len = Math.min(ao.length, bo.length); |
| 84 | + for (let i = 0; i < len; i++) if (ao[i] !== bo[i]) return ao[i] - bo[i]; |
| 85 | + return ao.length - bo.length || a.label.localeCompare(b.label); |
| 86 | + }; |
| 87 | +} |
| 88 | +
|
| 89 | +const rootNodes = computed<FeatureNode[]>(() => { |
| 90 | + const items = menuData.value; |
| 91 | + if (!items?.length) return []; |
| 92 | +
|
| 93 | + const idx = buildIndex(); |
| 94 | + const roots: FeatureNode[] = []; |
| 95 | + const orderMap = new Map<string, number[]>(); |
| 96 | +
|
| 97 | + // Build tree |
| 98 | + items.forEach((f, i) => { |
| 99 | + const reqId = Number(f.get('req_id')) || 0; |
| 100 | + const path = reqId > 0 ? idx.get(reqId) : undefined; |
| 101 | +
|
| 102 | + // If we know the record path from the tree, build it and stamp ordering |
| 103 | + let recordLeaf: FeatureNode; |
| 104 | + if (path) { |
| 105 | + recordLeaf = ensurePath(roots, path); |
| 106 | + path.keys.forEach((k, j) => { |
| 107 | + const nk = `rec-${k}`; |
| 108 | + if (!orderMap.has(nk)) orderMap.set(nk, path.order.slice(0, j + 1)); |
| 109 | + }); |
| 110 | + } else { |
| 111 | + // fall back to a single "Unassigned" node |
| 112 | + const key = 'rec-unassigned'; |
| 113 | + recordLeaf = roots.find(r => r.key === key) ?? (() => { |
| 114 | + const n: FeatureNode = { key, label: 'Unassigned', expanded: true, children: [] }; |
| 115 | + roots.push(n); |
| 116 | + orderMap.set(key, [Number.MAX_SAFE_INTEGER]); |
| 117 | + return n; |
| 118 | + })(); |
| 119 | + } |
42 | 120 |
|
43 | | - const menuWidth = 220; |
44 | | - const menuHeight = 160; |
| 121 | + // Put features directly under the record (to match Analysis menu feel) |
| 122 | + const label = safeLabel(f, i); |
45 | 123 |
|
46 | | - let left = x; |
47 | | - let top = y; |
| 124 | + // skip if it’s just a duplicate record polygon |
| 125 | + if (!label.startsWith('Record:')) { |
| 126 | + recordLeaf.children!.push({ |
| 127 | + key: `${recordLeaf.key}-leaf-${i}-${(f.getId() ?? i).toString()}`, |
| 128 | + label, |
| 129 | + payload: { kind: 'feature', feature: f } |
| 130 | + }); |
| 131 | + } |
48 | 132 |
|
49 | | - const ww = window.innerWidth; |
50 | | - const wh = window.innerHeight; |
| 133 | + }); |
| 134 | +
|
| 135 | + // Sort consistently with records order |
| 136 | + const sortLevel = makeSorter(orderMap); |
| 137 | + function sortTree(nodes: FeatureNode[]) { |
| 138 | + nodes.sort(sortLevel); |
| 139 | + nodes.forEach(n => { if (n.children?.length) sortTree(n.children); }); |
| 140 | + } |
| 141 | + sortTree(roots); |
| 142 | + return roots; |
| 143 | +}); |
| 144 | +
|
| 145 | +// forward selection |
| 146 | +function handleSelect(payload: SelectPayload) { |
| 147 | + emit('select', payload); |
| 148 | + hideMenu(); |
| 149 | +} |
| 150 | +
|
| 151 | +const showMenu = (x: number, y: number, features: any[]) => { |
| 152 | + visible.value = true; |
| 153 | + menuData.value = coerceToMiniFeatures(features); |
| 154 | +
|
| 155 | + const menuWidth = 320, menuHeight = 360; |
| 156 | + let left = x, top = y; |
| 157 | + const ww = window.innerWidth, wh = window.innerHeight; |
51 | 158 | if (left + menuWidth > ww) left = ww - menuWidth - 8; |
52 | 159 | if (top + menuHeight > wh) top = wh - menuHeight - 8; |
53 | | - left = Math.max(0, left); |
54 | | - top = Math.max(0, top); |
| 160 | + left = Math.max(0, left); top = Math.max(0, top); |
55 | 161 |
|
56 | 162 | menuStyle.value = { |
57 | | - position: 'absolute', |
58 | | - left: `${left}px`, |
59 | | - top: `${top}px`, |
60 | | - zIndex: '1200', |
61 | | - background: '#222', |
62 | | - borderRadius: '6px', |
63 | | - boxShadow: '0 3px 18px rgba(0,0,0,0.3)', |
64 | | - padding: '8px 0', |
65 | | - minWidth: '180px', |
| 163 | + position:'absolute', left:`${left}px`, top:`${top}px`, zIndex:'1200', |
| 164 | + background:'#222', borderRadius:'8px', boxShadow:'0 6px 24px rgba(0,0,0,0.35)', |
| 165 | + padding:'6px 0', minWidth:'260px', maxWidth:'420px', maxHeight:'60vh', overflow:'auto' |
66 | 166 | }; |
67 | 167 | }; |
| 168 | +const hideMenu = () => { visible.value = false; }; |
68 | 169 |
|
69 | | -const hideMenu = () => { |
70 | | - visible.value = false; |
71 | | -}; |
72 | | -
|
73 | | -// Emit selected feature |
74 | | -function handleSelect(feature: Feature<Geometry>) { |
75 | | - emit('select', feature); |
76 | | - hideMenu(); |
77 | | -} |
78 | | -
|
79 | | -// Expose API |
80 | 170 | defineExpose({ showMenu, hideMenu, menuData }); |
81 | 171 | </script> |
82 | 172 |
|
83 | | -<style scoped> |
84 | | -.feature-menu-overlay { |
85 | | - color: #fff; |
86 | | - font-size: 1rem; |
87 | | - user-select: none; |
88 | | - max-width: 280px; |
89 | | -} |
90 | | -
|
91 | | -.feature-menu-item { |
92 | | - padding: 0.5rem 1rem; |
93 | | - cursor: pointer; |
94 | | - white-space: nowrap; |
95 | | -} |
| 173 | +<template> |
| 174 | + <Teleport to="body"> |
| 175 | + <div v-if="visible" class="feature-menu-overlay" :style="menuStyle" @mousedown.stop @click.stop> |
| 176 | + <div v-if="rootNodes.length === 0" class="menu-header">No features</div> |
| 177 | + <ul v-else class="tree-root"> |
| 178 | + <SrFeatureTreeNode v-for="n in rootNodes" :key="n.key" :node="n" @select="handleSelect" /> |
| 179 | + </ul> |
| 180 | + </div> |
| 181 | + </Teleport> |
| 182 | +</template> |
96 | 183 |
|
97 | | -.feature-menu-item:hover { |
98 | | - background-color: #444; |
99 | | -} |
| 184 | +<style scoped> |
| 185 | +.feature-menu-overlay { color:#fff; font-size:.95rem; user-select:none; } |
| 186 | +.menu-header { padding:.5rem .75rem; opacity:.8; } |
| 187 | +.tree-root { list-style:none; margin:0; padding-left:.25rem; } |
100 | 188 | </style> |
0 commit comments