Skip to content

Commit c515cf6

Browse files
author
Test User
committed
Concept map related concepts rendering added.
1 parent d193a9d commit c515cf6

4 files changed

Lines changed: 168 additions & 26 deletions

File tree

explorer/design-system/product-patterns/thesaurus/ThesaurusExplorer.tsx

Lines changed: 164 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,10 @@ const shellBaseUX = css`
266266
height: 100%;
267267
}
268268
269+
.ux-thesaurus-scheme-map-canvas .react-flow__node {
270+
transition: transform var(--dur-fast) var(--ease-standard), opacity var(--dur-fast) var(--ease-standard);
271+
}
272+
269273
.ux-thesaurus-scheme-map-canvas .react-flow__pane {
270274
cursor: grab;
271275
}
@@ -282,7 +286,16 @@ const shellBaseUX = css`
282286
position: relative;
283287
}
284288
289+
.ux-thesaurus-flow-node__header {
290+
display: flex;
291+
min-width: 0;
292+
align-items: center;
293+
gap: var(--space-3);
294+
}
295+
285296
.ux-thesaurus-flow-node__label {
297+
flex: 1 1 auto;
298+
min-width: 0;
286299
overflow: hidden;
287300
font-size: var(--text-base);
288301
font-weight: var(--weight-bold);
@@ -291,6 +304,24 @@ const shellBaseUX = css`
291304
white-space: nowrap;
292305
}
293306
307+
.ux-thesaurus-flow-node__focus {
308+
display: inline-flex;
309+
width: var(--space-8);
310+
height: var(--space-8);
311+
flex: 0 0 auto;
312+
align-items: center;
313+
justify-content: center;
314+
margin-left: auto;
315+
border: var(--border-w) solid var(--border-subtle);
316+
border-radius: var(--radius-pill);
317+
padding: 0;
318+
cursor: pointer;
319+
font-size: var(--text-caption);
320+
font-weight: var(--weight-bold);
321+
font-family: inherit;
322+
line-height: 0;
323+
}
324+
294325
.ux-thesaurus-flow-node__meta {
295326
color: var(--text-muted);
296327
font-size: var(--text-micro);
@@ -354,6 +385,11 @@ const shellBaseUX = css`
354385
background: var(--text-strong);
355386
}
356387
388+
.ux-thesaurus-flow-node.is-focus-center {
389+
border-color: var(--concept);
390+
background: var(--bg-selected);
391+
}
392+
357393
.ux-thesaurus-scheme-map-link {
358394
fill: none;
359395
stroke-width: 3;
@@ -439,6 +475,17 @@ const shellSkinX = css`
439475
color: var(--text-body);
440476
}
441477
478+
.ux-thesaurus-flow-node__focus {
479+
background: var(--bg-surface);
480+
color: var(--text-muted);
481+
}
482+
483+
.ux-thesaurus-flow-node__focus:hover {
484+
border-color: var(--concept);
485+
background: var(--bg-hover);
486+
color: var(--text-strong);
487+
}
488+
442489
.ux-thesaurus-link:hover,
443490
.ux-thesaurus-node:hover {
444491
background: var(--bg-hover);
@@ -632,9 +679,10 @@ function ThesaurusSchemeMap({
632679
onSelect: (id: string) => void;
633680
onOpenConcept?: (id: string) => void;
634681
}) {
682+
const [focusId, setFocusId] = useState<string | null>(null);
635683
const layout = useMemo(
636-
() => buildThesaurusFlowLayout(concepts, schemeLabel, selectedId),
637-
[concepts, schemeLabel, selectedId],
684+
() => buildThesaurusFlowLayout(concepts, schemeLabel, selectedId, focusId, setFocusId),
685+
[concepts, schemeLabel, selectedId, focusId],
638686
);
639687
const layoutKey = useMemo(() => layout.nodes.map((node) => node.id).join("|"), [layout.nodes]);
640688
const [flowInstance, setFlowInstance] = useState<ReactFlowInstance<ThesaurusFlowNode, ThesaurusFlowEdge> | null>(
@@ -655,11 +703,13 @@ function ThesaurusSchemeMap({
655703
}, [flowInstance, layoutKey]);
656704

657705
useEffect(() => {
658-
if (!flowInstance || centeredSelectionRef.current === selectedId) return;
659-
const selectedNode = flowInstance.getNode(selectedId) ?? layout.nodes.find((node) => node.id === selectedId);
706+
const activeId = focusId ?? selectedId;
707+
const activeCenterKey = `${focusId ? "focus" : "select"}:${activeId}`;
708+
if (!flowInstance || centeredSelectionRef.current === activeCenterKey) return;
709+
const selectedNode = flowInstance.getNode(activeId) ?? layout.nodes.find((node) => node.id === activeId);
660710
if (!selectedNode) return;
661711

662-
centeredSelectionRef.current = selectedId;
712+
centeredSelectionRef.current = activeCenterKey;
663713
const viewport = flowInstance.getViewport();
664714
const width = selectedNode.measured?.width ?? selectedNode.width ?? THESAURUS_CONCEPT_NODE_CENTER_WIDTH;
665715
const height = selectedNode.measured?.height ?? selectedNode.height ?? THESAURUS_CONCEPT_NODE_CENTER_HEIGHT;
@@ -671,7 +721,7 @@ function ThesaurusSchemeMap({
671721
duration: THESAURUS_SELECTION_CENTER_DURATION,
672722
});
673723
});
674-
}, [flowInstance, layout.nodes, selectedId]);
724+
}, [flowInstance, focusId, layout.nodes, selectedId]);
675725

676726
return (
677727
<main className="ux-thesaurus-scheme-map" aria-label="Concept scheme map">
@@ -686,6 +736,7 @@ function ThesaurusSchemeMap({
686736
elementsSelectable
687737
proOptions={{ hideAttribution: true }}
688738
onInit={setFlowInstance}
739+
onPaneClick={() => setFocusId(null)}
689740
onNodeClick={(_event, node) => {
690741
const sourceElementId = readFlowNodeSourceElementId(node);
691742
if (sourceElementId) {
@@ -707,10 +758,15 @@ function ThesaurusSchemeMap({
707758

708759

709760
type ThesaurusFlowNodeData = Record<string, unknown> & {
761+
conceptId: string | null;
710762
label: string;
711763
meta: string;
712764
selected: boolean;
765+
focusCenter: boolean;
766+
focusable: boolean;
767+
focusRelatedCount: number;
713768
sourceElementId: string | null;
769+
onFocus: ((id: string) => void) | null;
714770
};
715771

716772
type ThesaurusFlowNode = Node<ThesaurusFlowNodeData>;
@@ -742,11 +798,28 @@ function ThesaurusFlowNodeComponent({ data, type }: NodeProps<ThesaurusFlowNode>
742798
"ux-thesaurus-flow-node",
743799
type === "scheme" ? "is-scheme" : "is-concept",
744800
data.selected && "is-selected",
801+
data.focusCenter && "is-focus-center",
745802
)}
746803
>
747804
<Handle type="target" position={Position.Left} />
748805
<Handle type="target" position={Position.Right} />
749-
<div className="ux-thesaurus-flow-node__label">{data.label}</div>
806+
<div className="ux-thesaurus-flow-node__header">
807+
<div className="ux-thesaurus-flow-node__label">{data.label}</div>
808+
{data.focusable && data.conceptId ? (
809+
<button
810+
type="button"
811+
className="ux-thesaurus-flow-node__focus"
812+
aria-label={`Show concepts related to ${data.label}`}
813+
title="related to"
814+
onClick={(event) => {
815+
event.stopPropagation();
816+
data.onFocus?.(data.conceptId as string);
817+
}}
818+
>
819+
+
820+
</button>
821+
) : null}
822+
</div>
750823
{data.meta ? <div className="ux-thesaurus-flow-node__meta">{data.meta}</div> : null}
751824
<Handle type="source" position={Position.Left} />
752825
<Handle type="source" position={Position.Right} />
@@ -770,6 +843,8 @@ function buildThesaurusFlowLayout(
770843
concepts: readonly ThesaurusConceptItem[],
771844
schemeLabel: string,
772845
selectedId: string,
846+
focusId: string | null,
847+
onFocus: (id: string | null) => void,
773848
): ThesaurusFlowLayout {
774849
const conceptIds = new Set(concepts.map((concept) => concept.id));
775850
const conceptById = new Map(concepts.map((concept) => [concept.id, concept]));
@@ -780,16 +855,26 @@ function buildThesaurusFlowLayout(
780855
const branchDirectionByTopConcept = new Map(topConceptIds.map((id, index) => [id, index % 2 === 0 ? 1 : -1]));
781856
const schemeSourceElementId = concepts.find((concept) => concept.schemeSourceElementId)?.schemeSourceElementId ?? null;
782857
const positionById = mapMindMapPositions(concepts, topConceptIds, conceptIds, branchDirectionByTopConcept);
858+
const focusNeighborhood = focusId && conceptIds.has(focusId) ? conceptNeighborhood(focusId, concepts, conceptIds) : null;
859+
const focusPositionById = focusNeighborhood
860+
? mapFocusNeighborhoodPositions(focusId as string, focusNeighborhood.visibleIds)
861+
: new Map<string, { x: number; y: number }>();
783862
const nodes: ThesaurusFlowNode[] = [
784863
{
785864
id: THESAURUS_SCHEME_NODE_ID,
786865
type: "scheme",
787866
position: { x: 0, y: 0 },
867+
hidden: focusNeighborhood !== null,
788868
data: {
869+
conceptId: null,
789870
label: schemeLabel,
790871
meta: "Concept scheme",
791872
selected: false,
873+
focusCenter: false,
874+
focusable: false,
875+
focusRelatedCount: 0,
792876
sourceElementId: schemeSourceElementId,
877+
onFocus: null,
793878
},
794879
sourcePosition: Position.Right,
795880
targetPosition: Position.Left,
@@ -801,34 +886,97 @@ function buildThesaurusFlowLayout(
801886
const isTopConcept = !concept.parentId || !conceptIds.has(concept.parentId);
802887
const topId = topConceptId(concept, conceptById, conceptIds);
803888
const branchClass = branchByTopConcept.get(topId) ?? "branch-0";
804-
const position = positionById.get(concept.id) ?? { x: MINDMAP_X_STEP, y: 0 };
889+
const isFocusVisible = focusNeighborhood?.visibleIds.has(concept.id) ?? false;
890+
const isFocusCenter = focusId === concept.id;
891+
const position = focusPositionById.get(concept.id) ?? positionById.get(concept.id) ?? { x: MINDMAP_X_STEP, y: 0 };
805892
const direction = position.x >= 0 ? 1 : -1;
893+
const relatedCount = conceptNeighborhood(concept.id, concepts, conceptIds).relatedIds.length;
806894
nodes.push({
807895
id: concept.id,
808896
type: "concept",
809897
className: branchClass,
810898
position,
899+
hidden: focusNeighborhood !== null && !isFocusVisible,
811900
data: {
901+
conceptId: concept.id,
812902
label: concept.label,
813-
meta: "",
903+
meta: isFocusCenter ? "Selected concept" : "",
814904
selected: concept.id === selectedId,
905+
focusCenter: isFocusCenter,
906+
focusable: relatedCount > 0,
907+
focusRelatedCount: relatedCount,
815908
sourceElementId: concept.sourceElementId ?? null,
909+
onFocus,
816910
},
817911
sourcePosition: direction > 0 ? Position.Right : Position.Left,
818912
targetPosition: direction > 0 ? Position.Left : Position.Right,
819913
});
820-
edges.push({
821-
id: `${isTopConcept ? THESAURUS_SCHEME_NODE_ID : concept.parentId}-${concept.id}`,
822-
source: isTopConcept ? THESAURUS_SCHEME_NODE_ID : concept.parentId as string,
823-
target: concept.id,
824-
type: "mindmap",
825-
className: `taxonomy ${branchClass}`,
826-
});
914+
if (!focusNeighborhood) {
915+
edges.push({
916+
id: `${isTopConcept ? THESAURUS_SCHEME_NODE_ID : concept.parentId}-${concept.id}`,
917+
source: isTopConcept ? THESAURUS_SCHEME_NODE_ID : concept.parentId as string,
918+
target: concept.id,
919+
type: "mindmap",
920+
className: `taxonomy ${branchClass}`,
921+
});
922+
}
923+
}
924+
925+
if (focusNeighborhood) {
926+
for (const relatedId of focusNeighborhood.relatedIds) {
927+
const related = conceptById.get(relatedId);
928+
if (!related) continue;
929+
const topId = topConceptId(related, conceptById, conceptIds);
930+
const branchClass = branchByTopConcept.get(topId) ?? "branch-0";
931+
edges.push({
932+
id: `${focusId}-${relatedId}-focus`,
933+
source: focusId as string,
934+
target: relatedId,
935+
type: "mindmap",
936+
className: `focus ${branchClass}`,
937+
});
938+
}
827939
}
828940

829941
return { nodes, edges };
830942
}
831943

944+
function conceptNeighborhood(
945+
focusId: string,
946+
concepts: readonly ThesaurusConceptItem[],
947+
conceptIds: ReadonlySet<string>,
948+
) {
949+
const focusConcept = concepts.find((concept) => concept.id === focusId);
950+
const relatedIds = new Set<string>();
951+
for (const concept of concepts) {
952+
if (concept.relatedIds.includes(focusId)) relatedIds.add(concept.id);
953+
}
954+
for (const relatedId of focusConcept?.relatedIds ?? []) {
955+
if (conceptIds.has(relatedId)) relatedIds.add(relatedId);
956+
}
957+
relatedIds.delete(focusId);
958+
return {
959+
relatedIds: Array.from(relatedIds),
960+
visibleIds: new Set([focusId, ...relatedIds]),
961+
};
962+
}
963+
964+
function mapFocusNeighborhoodPositions(focusId: string, visibleIds: ReadonlySet<string>) {
965+
const positionById = new Map<string, { x: number; y: number }>();
966+
positionById.set(focusId, { x: 0, y: 0 });
967+
const relatedIds = Array.from(visibleIds).filter((id) => id !== focusId);
968+
const radiusX = MINDMAP_X_STEP * 1.18;
969+
const radiusY = MINDMAP_Y_STEP * 1.18;
970+
relatedIds.forEach((id, index) => {
971+
const angle = (-Math.PI / 2) + (index * 2 * Math.PI) / Math.max(relatedIds.length, 1);
972+
positionById.set(id, {
973+
x: Math.cos(angle) * radiusX,
974+
y: Math.sin(angle) * radiusY,
975+
});
976+
});
977+
return positionById;
978+
}
979+
832980
function mapMindMapPositions(
833981
concepts: readonly ThesaurusConceptItem[],
834982
topConceptIds: readonly string[],

system-model/Interfaces/WebExplorer/Specifications.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,7 +831,7 @@ Thesaurus view generation behavior:
831831
- Presents selected concept details using generated SKOS fields from the normalized concept projection: the modal title carries the preferred label derived from the element name; the body promotes definition from main body content, alternative labels, scope notes, examples, broader/narrower hierarchy, related concepts, exact/close mapping relations, and ontology bridge mappings. These relation lists must exclude the selected concept itself and deduplicate reciprocal graph edges before rendering.
832832
- Shows ontology bridge context as usage evidence: ontology terms that author `reqvire:mapsToConcept` to the selected native concept appear as mapped ontology terms without making the concept an ontology child.
833833
- Provides a single scheme-scoped Map workspace for the selected concept's scheme; Thesaurus must not expose a Browse/Map segmented mode control.
834-
- Renders Thesaurus Map through the shared design-system product pattern using React Flow for node interaction and deterministic concept-scheme mind-map layout. The map canvas must be transparent, full-bleed, and full-height over the available workspace surface; concept-scheme nodes render as compact boxed anchors, concept nodes render as boxed label-first mind-map entries without role labels, and taxonomy edges render as colored curved branches.
834+
- Renders Thesaurus Map through the shared design-system product pattern using React Flow for node interaction and deterministic concept-scheme mind-map layout. The map canvas must be transparent, full-bleed, and full-height over the available workspace surface; concept-scheme nodes render as compact boxed anchors, concept nodes render as boxed label-first mind-map entries without role labels, and taxonomy edges render as colored curved branches. Concept nodes with `related` concept associations expose a compact focus affordance that isolates the selected concept and its related concepts around the focused node, hides unrelated map nodes and edges, centers the focus without changing zoom, and restores the full map when the user clicks the canvas background.
835835
- Selecting a concept from the left Thesaurus navigator must keep the active scheme Map view open and center the matching map node in the workspace without changing the user's current map zoom level.
836836
- Opens the shared element-detail modal using `thesaurus.schemes[].element_id` for concept-scheme map nodes and `thesaurus.concepts[].element_id` for concept map nodes. Activating a Thesaurus map node must never open an ontology graph node merely because ontology graph provenance or mapping data names the same SKOS IRI. Concept elements must use a SKOS-optimized modal body that keeps the standard element modal frame but promotes definition, scheme, alternative labels, scope note, examples, top concepts, broader/narrower/related concepts, exact/close matches, mapped ontology terms, and model usage before `Authored relations`, which contains remaining Reqvire Markdown relation evidence. The relation lists shown in this modal must not contain the selected concept itself or circular duplicate endpoints. The Thesaurus route must not create a separate concept-specific modal frame for this.
837837
- Keeps source navigation anchored to the Markdown `concept-scheme` or `concept` element.

system-model/Ontologies/RelationsAndImpact.md

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ reqvire:CapabilityRelationShape
222222
] ;
223223
sh:property [
224224
sh:path reqvire:verifiedBy ;
225-
sh:class reqvire:Verification ;
225+
sh:class reqvire:ConcreteVerification ;
226226
] ;
227227
sh:property [
228228
sh:path reqvire:satisfiedBy ;
@@ -282,10 +282,7 @@ reqvire:VerificationRelationShape
282282
sh:targetClass reqvire:Verification ;
283283
sh:property [
284284
sh:path reqvire:verify ;
285-
sh:or (
286-
[ sh:class reqvire:Capability ]
287-
[ sh:class reqvire:Requirement ]
288-
) ;
285+
sh:class reqvire:Requirement ;
289286
] ;
290287
sh:property [
291288
sh:path reqvire:derivedFrom ;

system-model/Ontologies/Verification.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -360,10 +360,7 @@ reqvire:ConcreteVerificationShape
360360
sh:property [
361361
sh:path reqvire:verify ;
362362
sh:minCount 1 ;
363-
sh:or (
364-
[ sh:class reqvire:Capability ]
365-
[ sh:class reqvire:Requirement ]
366-
) ;
363+
sh:class reqvire:Requirement ;
367364
] ;
368365
sh:property [
369366
sh:path reqvire:verifiedBy ;

0 commit comments

Comments
 (0)