Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 166 additions & 78 deletions web-client/src/components/SrFeatureMenuOverlay.vue
Original file line number Diff line number Diff line change
@@ -1,100 +1,188 @@
<template>
<Teleport to="body">
<div
class="feature-menu-overlay"
v-if="visible"
:style="menuStyle"
@mousedown.stop
@click.stop
>
<div
v-for="(feature, index) in menuData as Feature<Geometry>[]"
:key="index"
class="feature-menu-item"
@click="handleSelect(feature)"
>
{{ feature.get('name') || feature.getId() || `Feature ${index + 1}` }}
</div>
</div>
</Teleport>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import type { Feature } from 'ol';
import type Geometry from 'ol/geom/Geometry';
import { ref, computed, defineExpose } from 'vue';
import SrFeatureTreeNode, { type FeatureNode, type MiniFeature, type SelectPayload } from './SrFeatureTreeNode.vue';
import { useRecTreeStore } from '@/stores/recTreeStore';

// --- utilities to accept anything from OL + clusters and keep a tiny feature API
function isMiniFeature(o: any): o is MiniFeature {
return !!o && typeof o.get === 'function' && typeof o.getId === 'function' && typeof o.getGeometry === 'function';
}
function unwrapCluster(item: any): MiniFeature[] {
const contained = item?.get?.('features');
if (Array.isArray(contained) && contained.length && isMiniFeature(contained[0])) return contained as MiniFeature[];
return isMiniFeature(item) ? [item] : [];
}
function coerceToMiniFeatures(arr: any[]): MiniFeature[] {
const out: MiniFeature[] = [];
for (const x of arr ?? []) out.push(...unwrapCluster(x));
return out;
}
function safeLabel(f: MiniFeature, i: number) {
return (f.get('name') as string) || (f.getId()?.toString() ?? '') || `Feature ${i + 1}`;
}

const menuData = ref<Feature<Geometry>[]>([]); // use a strongly typed array
// Internal state
const menuData = ref<MiniFeature[]>([]);
const visible = ref(false);
const menuStyle = ref<Record<string, string>>({});

const emit = defineEmits<{ (e: 'select', payload: SelectPayload): void }>();

// Define Emits
const emit = defineEmits<{
(e: 'select', feature: Feature<Geometry>): void
}>();
// ----- Build reqId -> { keys, labels, reqIds, order } from recTreeStore.treeData
type RecPath = { keys: string[]; labels: string[]; reqIds: number[]; order: number[] };
const recTree = useRecTreeStore();

// Public methods
const showMenu = (x: number, y: number, features: Feature<Geometry>[]) => {
visible.value = true;
menuData.value = features;
function buildIndex(): Map<number, RecPath> {
const idx = new Map<number, RecPath>();
let seq = 0;
function walk(nodes: any, keys: string[], labels: string[], reqIds: number[], order: number[]) {
for (const n of (nodes || [])) {
const k = [...keys, n.key];
const l = [...labels, n.label];
const r = [...reqIds, Number(n.data) || 0]; // every node in your recTree has data=reqId
const o = [...order, seq++];
// index by this node's reqId too, so *every level* is individually addressable
const thisReqId = Number(n.data) || 0;
if (thisReqId > 0) idx.set(thisReqId, { keys: k, labels: l, reqIds: r, order: o });
if (n.children?.length) walk(n.children, k, l, r, o);
}
}
walk(recTree.treeData, [], [], [], []);
return idx;
}

// ensure a path exists; attach {kind:'record', reqId} payload at each level
function ensurePath(root: FeatureNode[], path: RecPath): FeatureNode {
let cursor = root;
let node: FeatureNode | undefined;
for (let i = 0; i < path.labels.length; i++) {
const key = `rec-${path.keys[i]}`;
node = cursor.find(n => n.key === key);
if (!node) {
node = {
key,
label: path.labels[i],
expanded: true,
children: [],
payload: path.reqIds[i] > 0 ? { kind: 'record', reqId: path.reqIds[i] } : undefined
};
cursor.push(node);
} else if (!node.payload && path.reqIds[i] > 0) {
node.payload = { kind: 'record', reqId: path.reqIds[i] };
}
if (!node.children) node.children = [];
cursor = node.children;
}
return node!;
}

// sort to match records traversal order
function makeSorter(orderMap: Map<string, number[]>) {
return (a: FeatureNode, b: FeatureNode) => {
const ao = orderMap.get(a.key) ?? [Number.MAX_SAFE_INTEGER];
const bo = orderMap.get(b.key) ?? [Number.MAX_SAFE_INTEGER];
const len = Math.min(ao.length, bo.length);
for (let i = 0; i < len; i++) if (ao[i] !== bo[i]) return ao[i] - bo[i];
return ao.length - bo.length || a.label.localeCompare(b.label);
};
}

const rootNodes = computed<FeatureNode[]>(() => {
const items = menuData.value;
if (!items?.length) return [];

const idx = buildIndex();
const roots: FeatureNode[] = [];
const orderMap = new Map<string, number[]>();

// Build tree
items.forEach((f, i) => {
const reqId = Number(f.get('req_id')) || 0;
const path = reqId > 0 ? idx.get(reqId) : undefined;

// If we know the record path from the tree, build it and stamp ordering
let recordLeaf: FeatureNode;
if (path) {
recordLeaf = ensurePath(roots, path);
path.keys.forEach((k, j) => {
const nk = `rec-${k}`;
if (!orderMap.has(nk)) orderMap.set(nk, path.order.slice(0, j + 1));
});
} else {
// fall back to a single "Unassigned" node
const key = 'rec-unassigned';
recordLeaf = roots.find(r => r.key === key) ?? (() => {
const n: FeatureNode = { key, label: 'Unassigned', expanded: true, children: [] };
roots.push(n);
orderMap.set(key, [Number.MAX_SAFE_INTEGER]);
return n;
})();
}

const menuWidth = 220;
const menuHeight = 160;
// Put features directly under the record (to match Analysis menu feel)
const label = safeLabel(f, i);

let left = x;
let top = y;
// skip if it’s just a duplicate record polygon
if (!label.startsWith('Record:')) {
recordLeaf.children!.push({
key: `${recordLeaf.key}-leaf-${i}-${(f.getId() ?? i).toString()}`,
label,
payload: { kind: 'feature', feature: f }
});
}

const ww = window.innerWidth;
const wh = window.innerHeight;
});

// Sort consistently with records order
const sortLevel = makeSorter(orderMap);
function sortTree(nodes: FeatureNode[]) {
nodes.sort(sortLevel);
nodes.forEach(n => { if (n.children?.length) sortTree(n.children); });
}
sortTree(roots);
return roots;
});

// forward selection
function handleSelect(payload: SelectPayload) {
emit('select', payload);
hideMenu();
}

const showMenu = (x: number, y: number, features: any[]) => {
visible.value = true;
menuData.value = coerceToMiniFeatures(features);

const menuWidth = 320, menuHeight = 360;
let left = x, top = y;
const ww = window.innerWidth, wh = window.innerHeight;
if (left + menuWidth > ww) left = ww - menuWidth - 8;
if (top + menuHeight > wh) top = wh - menuHeight - 8;
left = Math.max(0, left);
top = Math.max(0, top);
left = Math.max(0, left); top = Math.max(0, top);

menuStyle.value = {
position: 'absolute',
left: `${left}px`,
top: `${top}px`,
zIndex: '1200',
background: '#222',
borderRadius: '6px',
boxShadow: '0 3px 18px rgba(0,0,0,0.3)',
padding: '8px 0',
minWidth: '180px',
position:'absolute', left:`${left}px`, top:`${top}px`, zIndex:'1200',
background:'#222', borderRadius:'8px', boxShadow:'0 6px 24px rgba(0,0,0,0.35)',
padding:'6px 0', minWidth:'260px', maxWidth:'420px', maxHeight:'60vh', overflow:'auto'
};
};
const hideMenu = () => { visible.value = false; };

const hideMenu = () => {
visible.value = false;
};

// Emit selected feature
function handleSelect(feature: Feature<Geometry>) {
emit('select', feature);
hideMenu();
}

// Expose API
defineExpose({ showMenu, hideMenu, menuData });
</script>

<style scoped>
.feature-menu-overlay {
color: #fff;
font-size: 1rem;
user-select: none;
max-width: 280px;
}

.feature-menu-item {
padding: 0.5rem 1rem;
cursor: pointer;
white-space: nowrap;
}
<template>
<Teleport to="body">
<div v-if="visible" class="feature-menu-overlay" :style="menuStyle" @mousedown.stop @click.stop>
<div v-if="rootNodes.length === 0" class="menu-header">No features</div>
<ul v-else class="tree-root">
<SrFeatureTreeNode v-for="n in rootNodes" :key="n.key" :node="n" @select="handleSelect" />
</ul>
</div>
</Teleport>
</template>

.feature-menu-item:hover {
background-color: #444;
}
<style scoped>
.feature-menu-overlay { color:#fff; font-size:.95rem; user-select:none; }
.menu-header { padding:.5rem .75rem; opacity:.8; }
.tree-root { list-style:none; margin:0; padding-left:.25rem; }
</style>
74 changes: 74 additions & 0 deletions web-client/src/components/SrFeatureTreeNode.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed } from 'vue';

// What we actually need from OL objects:
export type MiniFeature = {
get: (key: any) => any;
getId: () => any;
getGeometry: () => { getType?: () => string } | null | undefined;
};

// Discriminated payload for any clickable node
export type SelectPayload =
| { kind: 'record'; reqId: number }
| { kind: 'feature'; feature: MiniFeature };

export type FeatureNode = {
key: string;
label: string;
expanded?: boolean;
children?: FeatureNode[];
// attach a payload when this node should be “clickable”
payload?: SelectPayload;
};

const props = defineProps<{ node: FeatureNode }>();

const emit = defineEmits<{
(e: 'select', payload: SelectPayload): void
}>();

const hasChildren = computed(() => !!props.node.children && props.node.children.length > 0);

function toggleExpand(e?: MouseEvent) {
e?.stopPropagation();
props.node.expanded = !props.node.expanded;
}

function selectNode() {
if (props.node.payload) emit('select', props.node.payload);
else if (hasChildren.value) toggleExpand(); // fallback
}
</script>

<template>
<li class="tree-node">
<div class="tree-row" :class="{ branch: hasChildren, leaf: !hasChildren }" @click.stop="selectNode">
<button v-if="hasChildren" class="chev" :class="node.expanded ? 'open' : 'closed'" @click.stop="toggleExpand" aria-label="toggle">
</button>
<span v-else class="dot">•</span>
<span class="label" :title="node.label">{{ node.label }}</span>
</div>

<ul v-if="hasChildren && node.expanded" class="tree-children">
<SrFeatureTreeNode
v-for="child in node.children"
:key="child.key"
:node="child"
@select="(p) => $emit('select', p)"
/>
</ul>
</li>
</template>

<style scoped>
.tree-node { list-style: none; margin: 0; }
.tree-row { display:flex; align-items:center; gap:.35rem; padding:.35rem .75rem; cursor:pointer; border-radius:6px; }
.tree-row:hover { background:#333; }
.branch .label { font-weight:600; } .leaf .label { font-weight:400; }
.chev { width:1.1ch; transform:rotate(0); transition:transform 120ms ease; opacity:.85; background:transparent; border:0; padding:0; }
.chev.open { transform:rotate(90deg); }
.dot { width:1ch; text-align:center; opacity:.7; }
.tree-children { margin:0; padding-left:.75rem; }
</style>
29 changes: 25 additions & 4 deletions web-client/src/components/SrMap.vue
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@
import Polygon, { fromExtent as polygonFromExtent } from 'ol/geom/Polygon.js';
import { unByKey } from 'ol/Observable.js';

import type { SelectPayload, MiniFeature } from '@/components/SrFeatureTreeNode.vue';
import OLFeature from 'ol/Feature.js';



const dragAreaEl = document.createElement('div');
Expand All @@ -81,11 +84,29 @@
const tooltipRef = ref();

const wasRecordsLayerVisible = ref(false);
const isDrawing = ref(false);
const isDrawing = ref(false);

function unwrapClusterToArray(item: MiniFeature): FeatureLike[] {
const inner = item.get?.('features');
return Array.isArray(inner) && inner.length ? (inner as FeatureLike[]) : [item as unknown as FeatureLike];
}
function toVectorFeatures(arr: FeatureLike[]): Feature<Geometry>[] {
return arr.filter((f): f is Feature<Geometry> => f instanceof OLFeature);
}

function onFeatureMenuSelect(payload: SelectPayload) {
if (payload.kind === 'record') {
// Do what your Analysis menu does: e.g. navigate/select/zoom
router.push(`/analyze/${payload.reqId}`);
featureMenuOverlayRef.value?.hideMenu();
return;
}

function onFeatureMenuSelect(feature: Feature<Geometry>) {
onFeatureClick([feature]); // Use your existing handler logic
featureMenuOverlayRef.value.hideMenu();
// kind === 'feature'
const likes = unwrapClusterToArray(payload.feature);
const vectors = toVectorFeatures(likes);
if (vectors.length) onFeatureClick(vectors); // your existing handler
featureMenuOverlayRef.value?.hideMenu();
}

const reqParamsStore = useReqParamsStore();
Expand Down
2 changes: 1 addition & 1 deletion web-client/src/components/SrPlotCntrl.vue
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ const shouldDisplayGradientColorLegend = computed(() => {
const selectedColorEncodeData = chartStore.getSelectedColorEncodeData(reqIdStr.value);
let should = false;
if(selectedColorEncodeData && (selectedColorEncodeData !== 'unset')){
if(func.includes('atl03')){
if(func.includes('atl03') && (selectedColorEncodeData !== 'solid')){
if(selectedColorEncodeData !== 'atl08_class' && selectedColorEncodeData !== 'atl24_class' && selectedColorEncodeData !== 'atl03_cnf'){
should = true;
}
Expand Down
Loading