diff --git a/src/components/SingleTermView/OverView/Hierarchy.jsx b/src/components/SingleTermView/OverView/Hierarchy.jsx
index c3e9706e..5ac4be5e 100644
--- a/src/components/SingleTermView/OverView/Hierarchy.jsx
+++ b/src/components/SingleTermView/OverView/Hierarchy.jsx
@@ -39,35 +39,99 @@ const toIri = (idLike) => {
return idLike;
};
+// Collect all item ids that match a filter (id | label | iri)
+const collectMatchingIds = (items = [], term = "") => {
+ if (!term) return [];
+ const q = String(term).toLowerCase();
+ const out = new Set();
+ const stack = [...items];
+ while (stack.length) {
+ const node = stack.pop();
+ const id = String(node?.id ?? "");
+ const label = String(node?.label ?? "");
+ const iri = String(node?.iri ?? "");
+ if (
+ id.toLowerCase().includes(q) ||
+ label.toLowerCase().includes(q) ||
+ iri.toLowerCase().includes(q)
+ ) out.add(id);
+ if (Array.isArray(node?.children)) stack.push(...node.children);
+ }
+ return [...out];
+};
+
const Hierarchy = ({
- options = { children: [], superclasses: [] }, // {children:[{id,label}], superclasses:[...]}
- selectedValue, // { id, label }
+ options = { children: [], superclasses: [] },
+ selectedValue,
onSelect,
- // prebuilt trees (arrays of {id,label,iri,children})
treeChildren = [],
treeSuperclasses = [],
loading = false,
}) => {
- const [type, setType] = React.useState(SUPERCLASSES); // default to superclasses
+ const [type, setType] = React.useState(SUPERCLASSES);
const [currentId, setCurrentId] = React.useState(null);
+ const [manualHighlightedIds, setManualHighlightedIds] = React.useState([]);
+ const hasSearchedRef = React.useRef(false);
+ const initialSelectedRef = React.useRef(selectedValue?.id || null);
- // when selection or type changes, recompute which tree + currentId to show
- React.useEffect(() => {
- const focusIri = toIri(selectedValue?.id);
- const items = type === CHILDREN ? treeChildren : treeSuperclasses;
- setCurrentId(findFirstRenderedId(items, focusIri));
- }, [selectedValue, type, treeChildren, treeSuperclasses]);
+ const preSearchSelectionRef = React.useRef(null);
+ const [searchTerm, setSearchTerm] = React.useState("");
const items = type === CHILDREN ? treeChildren : treeSuperclasses;
const childCount = items?.[0]?.children?.length || 0;
- const handleSelectChange = (_event, value) => onSelect?.(value);
- const gotoFirstOption = () => {
- const opts = type === CHILDREN ? options.children : options.superclasses;
- if (opts?.length) onSelect?.(opts[0]);
+ React.useEffect(() => {
+ const focusIri = toIri(selectedValue?.id);
+ const renderedId = findFirstRenderedId(items, focusIri);
+ setCurrentId(renderedId);
+ }, [selectedValue, type, treeChildren, treeSuperclasses, items]);
+
+ const handleSelectChange = (value) => {
+ if (!preSearchSelectionRef.current && selectedValue) {
+ preSearchSelectionRef.current = selectedValue;
+ }
+ onSelect?.(value);
+ hasSearchedRef.current = true;
+
+ setSearchTerm("");
+ setManualHighlightedIds([]);
+
+ const focusIri = toIri(value?.id);
+ const idInTree = findFirstRenderedId(items, focusIri);
+ if (idInTree) setCurrentId(idInTree);
};
+
+ const handleRefresh = () => {
+ const toRestore = preSearchSelectionRef.current || initialSelectedRef.current || selectedValue;
+ if (toRestore) {
+ onSelect?.(toRestore);
+ const iri = toIri(toRestore?.id);
+ const idInTree = findFirstRenderedId(items, iri);
+ setCurrentId(idInTree);
+ }
+ setSearchTerm("");
+ setManualHighlightedIds([]);
+ hasSearchedRef.current = false;
+ preSearchSelectionRef.current = null;
+ };
+
+ const handleAimFocus = () => {
+ const base = hasSearchedRef.current
+ ? (selectedValue || preSearchSelectionRef.current || { id: initialSelectedRef.current })
+ : (preSearchSelectionRef.current || selectedValue || { id: initialSelectedRef.current });
+
+ const focusIri = toIri(base?.id);
+ const id = findFirstRenderedId(items, focusIri);
+
+ setCurrentId(id);
+ setManualHighlightedIds(id ? [id] : []); // <-- pass to tree as highlight
+ };
const singleSearchOptions = type === CHILDREN ? options.children : options.superclasses;
+ const highlightedIdsFromSearch = React.useMemo(
+ () => collectMatchingIds(items, searchTerm),
+ [items, searchTerm]
+ );
if (loading) {
return
@@ -75,6 +139,7 @@ const Hierarchy = ({
}
+
return (
@@ -83,17 +148,32 @@ const Hierarchy = ({
Type:
setType(v)}
+ onChange={(v) => {
+ setType(v);
+ // reset only the visual search highlight when switching trees
+ setSearchTerm("");
+ }}
options={[
{ value: CHILDREN, label: 'Children' },
{ value: SUPERCLASSES, label: 'Superclasses' },
]}
/>
-
- }
- />
-);
+const StyledLabel = styled('span')(() => ({
+ display: 'flex',
+ alignItems: 'center',
+ gap: '.5rem',
+ position: 'relative'
+}));
+
+const HoverActions = styled('span')(() => ({
+ display: 'none',
+ alignItems: 'center',
+ marginLeft: '0.25rem'
+}));
+
+const StyledTreeItemBase = (props) => {
+ const {
+ label,
+ currentId,
+ highlightedIds,
+ itemId,
+ idToIri,
+ idToHasChildren
+ } = props;
+
+ const isCurrent = currentId && itemId === currentId;
+ const isHighlighted = Array.isArray(highlightedIds) && highlightedIds.includes(itemId);
+ const isLeaf = idToHasChildren?.get(itemId) === false;
+
+ const iri = idToIri?.get(itemId) || itemId;
+
+ return (
+
+
+ {label}
+
+
+ {isCurrent && (
+
+ )}
+
+ {isHighlighted && !isCurrent && (
+
+ )}
+
+ {/* Leaf-only hover action: open in new tab */}
+ {isLeaf && (
+
+
+ {
+ e.stopPropagation();
+ try {
+ window.open(iri, "_blank", "noopener,noreferrer");
+ } catch {
+ window.open(String(iri), "_blank");
+ }
+ }}
+ >
+
+
+
+
+ )}
+
+ }
+ sx={{
+ [`& .${treeItemClasses.content}:hover .hover-actions`]: { display: 'inline-flex' },
+ [`& .${treeItemClasses.label}`]: { fontSize: '0.875rem', fontWeight: 400 },
+ color: gray500,
+ }}
+ />
+ );
+};
StyledTreeItemBase.propTypes = {
label: PropTypes.string,
currentId: PropTypes.string,
- itemId: PropTypes.string
+ highlightedIds: PropTypes.arrayOf(PropTypes.string),
+ itemId: PropTypes.string,
+ idToIri: PropTypes.instanceOf(Map),
+ idToHasChildren: PropTypes.instanceOf(Map)
};
-
-const StyledTreeItem = styled(StyledTreeItemBase)(() => ({
- color: gray500,
- [`& .${treeItemClasses.content}`]: {
- [`& .${treeItemClasses.label}`]: { fontSize: '0.875rem', fontWeight: 400 },
- },
- [`& .${treeItemClasses.groupTransition}`]: { marginLeft: 14 },
-}));
-
const kidsOf = (n, getItemChildren) => {
const k = getItemChildren(n);
return Array.isArray(k) ? k : [];
@@ -64,28 +129,82 @@ const CustomizedTreeView = ({
items = [],
loading = false,
currentId = null,
+ highlightedIds = [],
getItemId = (i) => i.id,
getItemLabel = (i) => i.label,
getItemChildren = (i) => i.children || [],
+ defaultExpanded = false
}) => {
const [expanded, setExpanded] = useState([]);
+ // precompute roots for safety (keeps tree visible even if expanded becomes empty)
+ const rootIds = useMemo(() => (items || []).map(getItemId), [items, getItemId]);
+
+ // index useful metadata for slots (fast lookups in item renderer)
+ const { idToIri, idToHasChildren } = useMemo(() => {
+ const iriMap = new Map();
+ const hasChildrenMap = new Map();
+ const stack = [...items];
+ while (stack.length) {
+ const node = stack.pop();
+ const id = getItemId(node);
+ const kids = kidsOf(node, getItemChildren);
+ iriMap.set(id, node?.iri ?? null);
+ hasChildrenMap.set(id, kids.length > 0);
+ stack.push(...kids);
+ }
+ return { idToIri: iriMap, idToHasChildren: hasChildrenMap };
+ }, [items, getItemChildren, getItemId]);
+
// compute path to current node
const pathToCurrent = useMemo(
() => (currentId ? findPathToId(items, currentId, getItemId, getItemChildren) : []),
[items, currentId, getItemId, getItemChildren]
);
- // only auto-expand when the path actually changes
- const pathKey = useMemo(() => pathToCurrent.join('|'), [pathToCurrent]);
- const lastPathKeyRef = useRef('');
+ // also expand ancestors of matched nodes so highlights are visible
+ const matchAncestorIds = useMemo(() => {
+ if (!highlightedIds?.length) return [];
+ const set = new Set();
+ const dfs = (nodes, ancestors = []) => {
+ for (const n of nodes || []) {
+ const id = getItemId(n);
+ const nextAncestors = [...ancestors, id];
+ if (highlightedIds.includes(id)) nextAncestors.forEach(a => set.add(a));
+ dfs(getItemChildren(n), nextAncestors);
+ }
+ };
+ dfs(items, []);
+ return [...set];
+ }, [items, highlightedIds, getItemChildren, getItemId]);
+
+ // desired expansions: keep visibility of current and matches; retain user toggles
+ const desiredExpanded = useMemo(() => {
+ const ancestorsOfCurrent = pathToCurrent.slice(0, -1);
+ if (defaultExpanded === true) {
+ // superclasses: open roots initially; ensure ancestors for matches/current are open too
+ return Array.from(new Set([...rootIds, ...ancestorsOfCurrent, ...matchAncestorIds]));
+ }
+ // children: collapsed by default, but still ensure current/match ancestors are visible
+ return Array.from(new Set([...ancestorsOfCurrent, ...matchAncestorIds]));
+ }, [defaultExpanded, pathToCurrent, matchAncestorIds, rootIds]);
+
+ // only adjust expansions when the derived set changes — merge with existing so tree never "disappears"
+ const desiredKey = useMemo(() => desiredExpanded.join('|'), [desiredExpanded]);
+ const lastKey = useRef('');
useEffect(() => {
- if (pathKey && pathKey !== lastPathKeyRef.current) {
- const ancestors = pathToCurrent.slice(0, -1);
- setExpanded(ancestors); // set once per path change
- lastPathKeyRef.current = pathKey;
+ if (desiredKey !== lastKey.current) {
+ setExpanded(prev => {
+ const merged = new Set([...(prev || []), ...desiredExpanded]);
+ // As a final guard: if merged is empty but we want roots visible for defaultExpanded=true
+ if (merged.size === 0 && defaultExpanded === true && rootIds.length) {
+ rootIds.forEach(id => merged.add(id));
+ }
+ return [...merged];
+ });
+ lastKey.current = desiredKey;
}
- }, [pathKey, pathToCurrent]);
+ }, [desiredKey, desiredExpanded, defaultExpanded, rootIds]);
if (loading) {
return (
@@ -103,13 +222,22 @@ const CustomizedTreeView = ({
getItemLabel={getItemLabel}
getItemChildren={getItemChildren}
expandedItems={expanded}
- onExpandedItemsChange={(_e, ids) => setExpanded(ids)} // user can toggle
+ onExpandedItemsChange={(_e, ids) => setExpanded(ids)}
+ selectedItems={currentId ? [currentId] : []}
+ onSelectedItemsChange={() => { /* keep selection controlled by currentId */ }}
slots={{
- item: StyledTreeItem,
+ item: StyledTreeItemBase,
expandIcon: ChevronRightOutlinedIcon,
collapseIcon: ExpandLessOutlinedIcon,
}}
- slotProps={{ item: { currentId } }}
+ slotProps={{
+ item: {
+ currentId,
+ highlightedIds,
+ idToIri,
+ idToHasChildren
+ }
+ }}
/>
);
};
@@ -118,9 +246,11 @@ CustomizedTreeView.propTypes = {
items: PropTypes.array,
loading: PropTypes.bool,
currentId: PropTypes.string,
+ highlightedIds: PropTypes.arrayOf(PropTypes.string),
getItemId: PropTypes.func,
getItemLabel: PropTypes.func,
getItemChildren: PropTypes.func,
+ defaultExpanded: PropTypes.bool
};
export default CustomizedTreeView;
diff --git a/src/parsers/hierarchies-parser.tsx b/src/parsers/hierarchies-parser.tsx
index bde2aa2e..c64ab55f 100644
--- a/src/parsers/hierarchies-parser.tsx
+++ b/src/parsers/hierarchies-parser.tsx
@@ -1,6 +1,6 @@
export const ILX_PART_OF = 'Is part of';
export const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
-export const OWL_OBJECT_PROPERTY = 'owl:ObjectProperty';
+export const OWL_OBJECT_PROPERTY = 'owl:';
export const RDFS_LABEL = 'http://www.w3.org/2000/01/rdf-schema#label';
export const PART_OF_IRI = 'http://uri.interlex.org/base/ilx_0112785';
@@ -212,8 +212,10 @@ export const toHierarchyOptionsFromTriples = (triples: Triple[] = []) => {
const labelMap = buildLabelMap(triples);
const ids = new Set();
for (const t of triples) {
- if (t.subject?.id) ids.add(toIri(t.subject.id));
- if (t.object?.id) ids.add(toIri(t.object.id));
+ if ( t.object?.id !== "" && !t.object?.id?.includes(OWL_OBJECT_PROPERTY) && !t.subject?.id?.includes(OWL_OBJECT_PROPERTY) ) {
+ if (t.subject?.id) ids.add(toIri(t.subject.id));
+ if (t.object?.id) ids.add(toIri(t.object.id));
+ }
}
const out = Array.from(ids).map((iri) => ({ id: iri, label: labelOf(iri, labelMap) }));
// stable order: label asc