diff --git a/interlex.yaml b/interlex.yaml index 126323ca..6313d8f9 100644 --- a/interlex.yaml +++ b/interlex.yaml @@ -345,49 +345,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /{group}/query/transitive/{property}/{start}?depth: - get: - summary: Retrieve hierarchy results - operationId: get_hierarchy_results - tags: - - API - parameters: - - name: group - in: path - required: true - description: base group - schema: - type: string - - name: property - in: path - required: true - description: property - schema: - type: string - - name: start - in: path - required: true - description: start - schema: - type: string - responses: - 200: - description: A paged array of results - headers: - x-next: - description: A link to the next page of responses - schema: - type: string - content: - application/json: - schema: - $ref: '#/components/schemas/Hierarchies' - default: - description: unexpected error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' /{group}/addTerm: post: summary: Used to add a new term diff --git a/nginx/default.conf b/nginx/default.conf index a048d664..6af93430 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -8,8 +8,8 @@ server { # Proxy for Elasticsearch API requests location /api/elasticsearch { - proxy_pass https://scicrunch.org/api/1/elastic/Interlex_pr/_search; - proxy_set_header Host scicrunch.org; + proxy_pass https://api.scicrunch.io/elastic/v1/Interlex_pr/_search; + proxy_set_header Host api.scicrunch.io; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; diff --git a/src/api/endpoints/apiActions.ts b/src/api/endpoints/apiActions.ts index ca5a41c7..06fe6f0f 100644 --- a/src/api/endpoints/apiActions.ts +++ b/src/api/endpoints/apiActions.ts @@ -20,23 +20,25 @@ export const createPostRequest = (endpoint: string, headers : export const createGetRequest = (endpoint: string, contentType?: string) => { return (params?: P, options?: SecondParameter, signal?: AbortSignal) => { + const absUrl = endpoint.startsWith('http') + ? endpoint + : new URL(endpoint.startsWith('/') ? endpoint : `/${endpoint}`, window.location.origin).toString(); + const config: AxiosRequestConfig = { - url: endpoint, + url: absUrl, method: "GET", params, signal, withCredentials: true - } + }; if (contentType) { config.headers = { ...config.headers, - "Content-Type": contentType, - } + Accept: contentType, + }; } - return customInstance(config, options).then(response => { - return response; - }); + return customInstance(config, options).then(response => response); } } \ No newline at end of file diff --git a/src/api/endpoints/apiService.ts b/src/api/endpoints/apiService.ts index b3a0b17a..03b3fe09 100644 --- a/src/api/endpoints/apiService.ts +++ b/src/api/endpoints/apiService.ts @@ -1,7 +1,8 @@ import { createPostRequest, createGetRequest } from "./apiActions"; import { API_CONFIG } from "../../config"; import termParser from "../../parsers/termParser"; -import { jsonldToTriplesAndEdges, PART_OF_IRI } from './hiearchies-parser' +import { jsonldToTriplesAndEdges, PART_OF_IRI } from '../../parsers/hierarchies-parser' +import { buildPredicateGroupsForFocus } from "../../parsers/predicateParser"; export interface LoginRequest { username: string @@ -302,7 +303,7 @@ export const getVariant = (group: string, term: string) => { return createGetRequest(`/${group}/variant/${term}`, "application/json")(); }; -export const getTermHierarchies = async ({ +export const getTermPredicates = async ({ groupname, termId, objToSub = true, @@ -311,20 +312,55 @@ export const getTermHierarchies = async ({ termId: string; objToSub?: boolean; }) => { - const base = `/${groupname}/query/transitive/${encodeURIComponent(termId)}/ilx.partOf:`; - const url1 = `${base}?obj-to-sub=${objToSub}`; - const url2 = `${base}.jsonld?obj-to-sub=${objToSub}`; + const url = new URL( + `/${groupname}/query/transitive/${encodeURIComponent(termId)}/ilx.partOf:?obj-to-sub=true`, + window.location.origin + ).toString(); + + const resp = await fetch(url, { + headers: { Accept: "application/ld+json" }, + credentials: "include", + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const ct = resp.headers.get("content-type") || ""; + if (!/application\/(ld\+json|json)/i.test(ct)) { + throw new Error(`Server did not return JSON-LD (content-type: ${ct || "n/a"})`); + } + const jsonld = await resp.json(); - try { - const res1 = await createGetRequest(url1, 'application/ld+json')(); - return jsonldToTriplesAndEdges(res1); - } catch { - try { - const res2 = await createGetRequest(url2, 'application/ld+json')(); - return jsonldToTriplesAndEdges(res2); - } catch (e2: any) { - console.error('getTermHierarchies failed', e2); - return { error: true, message: e2?.message || String(e2) }; - } + // Build gold-standard predicate groups for the focus owl:Class + const predicates = buildPredicateGroupsForFocus(jsonld, termId); + return predicates; +}; + +export const getTermHierarchies = async ({ + groupname, + termId, + objToSub = false, +}: { + groupname: string; + termId: string; + objToSub?: boolean; +}) => { + const url = new URL( + `/${groupname}/query/transitive/${encodeURIComponent(termId)}/ilx.partOf:?obj-to-sub=${objToSub}`, + window.location.origin + ).toString(); + + const resp = await fetch(url, { + headers: { Accept: "application/ld+json" }, + credentials: "include", + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const ct = resp.headers.get("content-type") || ""; + if (!/application\/(ld\+json|json)/i.test(ct)) { + throw new Error(`Server did not return JSON-LD (content-type: ${ct || "n/a"})`); } + const jsonld = await resp.json(); + + // Only what Hierarchy needs + const parsed = jsonldToTriplesAndEdges(jsonld); + const triples = Array.isArray(parsed) ? parsed : (parsed?.triples || parsed?.edges || []); + + return { triples }; }; diff --git a/src/api/endpoints/hiearchies-parser.ts b/src/api/endpoints/hiearchies-parser.ts deleted file mode 100644 index 4ae132dd..00000000 --- a/src/api/endpoints/hiearchies-parser.ts +++ /dev/null @@ -1,85 +0,0 @@ -// Minimal JSON-LD → { triples, edges } converter used by getTermHierarchies - -export const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; -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'; - -type JsonLdNode = { - '@id': string; - '@type'?: string[] | string; - [k: string]: any; -}; - -function firstString(o: any): string | undefined { - if (o == null) return; - if (typeof o === 'string') return o; - if (Array.isArray(o)) return firstString(o[0]); - if (typeof o === 'object') { - if ('@value' in o) return String(o['@value']); - if ('@id' in o) return String(o['@id']); - } -} - -function ids(objs: any): string[] { - if (!objs) return []; - const arr = Array.isArray(objs) ? objs : [objs]; - return arr.map(v => (typeof v === 'string' ? v : v?.['@id'])).filter(Boolean); -} - -export type Triple = { - subject: { id: string; label: string }; - predicate: { id: string; label: string }; - object: { id: string; label: string }; -}; - -export type Edge = { - from: { id: string; label: string }; - to: { id: string; label: string }; -}; - -export function jsonldToTriplesAndEdges(jsonld: any): { triples: Triple[]; edges: Edge[] } { - const graph: JsonLdNode[] = - Array.isArray(jsonld) ? jsonld : - Array.isArray(jsonld?.['@graph']) ? jsonld['@graph'] : - jsonld?.['@id'] ? [jsonld] : []; - - // id → label map (prefer rdfs:label) - const labelById = new Map(); - for (const n of graph) { - const id = n['@id']; if (!id) continue; - const lbl = firstString(n['label']) ?? firstString(n['rdfs:label']) ?? firstString(n[RDFS_LABEL]); - if (lbl) labelById.set(id, lbl); - } - - const triples: Triple[] = []; - const edges: Edge[] = []; - - const addTriple = (s: string, p: string, oId?: string, oLabel?: string) => { - const subj = { id: s, label: labelById.get(s) ?? s }; - const pred = { id: p, label: p }; - const obj = oId ? { id: oId, label: labelById.get(oId) ?? oId } : { id: '', label: oLabel ?? '' }; - triples.push({ subject: subj, predicate: pred, object: obj }); - }; - - for (const n of graph) { - const s = n['@id']; if (!s) continue; - - // rdf:type - for (const t of ids(n['@type'])) addTriple(s, RDF_TYPE, t); - - // rdfs:label - const lbl = firstString(n['label']) ?? firstString(n['rdfs:label']) ?? firstString(n[RDFS_LABEL]); - if (lbl) addTriple(s, RDFS_LABEL, undefined, lbl); - - // ilx.partOf (accept compact or expanded) - for (const o of [...ids(n['partOf']), ...ids(n[PART_OF_IRI])]) { - addTriple(s, PART_OF_IRI, o); - edges.push({ - from: { id: s, label: labelById.get(s) ?? s }, - to: { id: o, label: labelById.get(o) ?? o }, - }); - } - } - - return { triples, edges }; -} diff --git a/src/api/endpoints/swaggerMockMissingEndpoints.msw.ts b/src/api/endpoints/swaggerMockMissingEndpoints.msw.ts index 35c48ac1..769ae86a 100644 --- a/src/api/endpoints/swaggerMockMissingEndpoints.msw.ts +++ b/src/api/endpoints/swaggerMockMissingEndpoints.msw.ts @@ -1910,7 +1910,6 @@ export const getGetOrganizationsCuriesResponseMock = () => ((() => { }]; })()) -export const getGetHierarchyResultsResponseMock = (): Hierarchies => (Array.from({ length: faker.number.int({ min: 1, max: 10 }) }, (_, i) => i + 1).map(() => ([]))) export const getAddTermResponseMock = () => ((() => { return { @@ -4294,20 +4293,6 @@ export const getGetOrganizationsCuriesMockHandler = (overrideResponse?: Curies) }) } -export const getGetHierarchyResultsMockHandler = (overrideResponse?: Hierarchies) => { - return http.get('*/:group/query/transitive/:property/:start?depth', async () => { - await delay(1000); - return new HttpResponse(JSON.stringify(overrideResponse !== undefined ? overrideResponse : getGetHierarchyResultsResponseMock()), - { - status: 200, - headers: { - 'Content-Type': 'application/json', - } - } - ) - }) -} - export const getAddTermMockHandler = (overrideResponse?: Error) => { return http.post('*/:group/addTerm', async () => { await delay(1000); @@ -4531,7 +4516,6 @@ export const getSwaggerMockMissingEndpointsMock = () => [ getGetOrganizationsTermsMockHandler(), getGetOrganizationsOntologiesMockHandler(), getGetOrganizationsCuriesMockHandler(), - getGetHierarchyResultsMockHandler(), getAddTermMockHandler(), getBulkEditTermsMockHandler(), getGetMatchTermsMockHandler(), diff --git a/src/api/endpoints/swaggerMockMissingEndpoints.ts b/src/api/endpoints/swaggerMockMissingEndpoints.ts index d7004d7e..302b5f6a 100644 --- a/src/api/endpoints/swaggerMockMissingEndpoints.ts +++ b/src/api/endpoints/swaggerMockMissingEndpoints.ts @@ -192,19 +192,6 @@ export const getOrganizationsCuries = ( options); } -/** - * @summary Retrieve hierarchy results - */ -export const getHierarchyResults = ( - group: string, - property: string, - start: string, - options?: SecondParameter,) => { - return customInstance( - {url: `/${group}/query/transitive/${property}/${start}?depth`, method: 'GET' - }, - options); - } /** * @summary Used to add a new term @@ -430,7 +417,6 @@ export type GetOrganizationsResult = NonNullable>> export type GetOrganizationsOntologiesResult = NonNullable>> export type GetOrganizationsCuriesResult = NonNullable>> -export type GetHierarchyResultsResult = NonNullable>> export type AddTermResult = NonNullable>> export type BulkEditTermsResult = NonNullable>> export type GetMatchTermsResult = NonNullable>> diff --git a/src/components/GraphViewer/GraphStructure.jsx b/src/components/GraphViewer/GraphStructure.jsx index 99eb007e..7e7109c0 100644 --- a/src/components/GraphViewer/GraphStructure.jsx +++ b/src/components/GraphViewer/GraphStructure.jsx @@ -15,96 +15,115 @@ const safe = (v, fallback = "unknown") => { function normalizeRows(pred) { if (!pred) return []; - // 1) new shape from getTermHierarchies grouping + // new shapes if (Array.isArray(pred.rows) && pred.rows.length) return pred.rows; if (Array.isArray(pred.values) && pred.values.length) return pred.values; - // 2) legacy tableData + // legacy tableData (strings only) if (Array.isArray(pred.tableData) && pred.tableData.length) { - return pred.tableData.map(r => ({ + return pred.tableData.map((r) => ({ subject: r.subject, - subjectId: r.subject, // no id in legacy, use label + subjectId: r.subject || r.subjectId, object: r.object, - objectId: r.object, + objectId: r.object || r.objectId, })); } - // 3) edges fallback + // edges fallback if (Array.isArray(pred.edges) && pred.edges.length) { - return pred.edges.map(e => ({ - subject: e?.from?.label || e?.from?.id, - subjectId: e?.from?.id || e?.from?.label, - object: e?.to?.label || e?.to?.id, - objectId: e?.to?.id || e?.to?.label, + return pred.edges.map((e) => ({ + subject: (e?.from && (e.from.label || e.from.id)) || "unknown", + subjectId: (e?.from && (e.from.id || e.from.label)) || "unknown", + object: (e?.to && (e.to.label || e.to.id)) || "unknown", + objectId: (e?.to && (e.to.id || e.to.label)) || "unknown", })); } return []; } -// Pick a root subject (most frequent). Fallback to first row. -function pickRoot(rows) { - if (!rows.length) return { key: "unknown", label: "unknown" }; - const counts = new Map(); - const firstLabelById = new Map(); - for (const r of rows) { - const id = r.subjectId || r.subject; - if (!id) continue; - counts.set(id, (counts.get(id) || 0) + 1); - if (!firstLabelById.has(id)) firstLabelById.set(id, r.subject || r.subjectId || id); - } - let rootKey = null, max = -1; - for (const [k, v] of counts.entries()) { - if (v > max) { max = v; rootKey = k; } - } - if (!rootKey) { - const r0 = rows[0]; - const id = r0.subjectId || r0.subject || "unknown"; - return { key: id, label: r0.subject || id }; - } - return { key: rootKey, label: firstLabelById.get(rootKey) || rootKey }; -} - -// Build Root → Predicate → Unique Objects +/** + * Behavior: + * - If all rows share the same subject → keep old layout: + * Root(Subject) → Predicate → Unique Objects + * - If multiple subjects → show all subjects: + * Root(Predicate) → Subject_1 → Objects + * Subject_2 → Objects + */ export const getGraphStructure = (pred) => { const rows = normalizeRows(pred); if (!rows.length) { return { name: "No data", id: "no-data", type: ROOT, value: 0, children: [] }; } - const { key: rootKey, label: rootLabel } = pickRoot(rows); - const forRoot = rows.filter(r => (r.subjectId || r.subject) === rootKey); + // Bucket rows by subject + const subjMap = new Map(); // subjectId -> { label, rows[] } + for (const r of rows) { + const sid = safe(r.subjectId || r.subject); + const slabel = safe(r.subject); + if (!subjMap.has(sid)) subjMap.set(sid, { label: slabel, rows: [] }); + subjMap.get(sid).rows.push({ + subject: slabel, + subjectId: sid, + object: safe(r.object), + objectId: safe(r.objectId || r.object), + }); + } + const uniqueSubjects = Array.from(subjMap.keys()); const predicateLabel = safe(pred?.title, "predicate"); const predicateId = predicateLabel; - const seen = new Set(); - const objects = []; - for (const r of forRoot) { - const oid = r.objectId || r.object; - if (!oid) continue; - if (seen.has(oid)) continue; - seen.add(oid); - objects.push({ - name: safe(r.object), - id: safe(oid), - type: OBJECT, - children: [], + // SINGLE-SUBJECT layout (backwards-compatible) + if (uniqueSubjects.length === 1) { + const sid = uniqueSubjects[0]; + const bucket = subjMap.get(sid); + const seen = new Set(); + const objects = []; + for (const r of bucket.rows) { + const oid = r.objectId; + if (!oid || seen.has(oid)) continue; + seen.add(oid); + objects.push({ name: safe(r.object), id: oid, type: OBJECT, children: [] }); + } + return { + name: bucket.label, + id: sid, + type: ROOT, + value: bucket.rows.length || pred?.count || 0, + children: [ + { name: predicateLabel, id: predicateId, type: PREDICATE, children: objects }, + ], + }; + } + + // MULTI-SUBJECT layout + const subjectNodes = []; + for (const sid of uniqueSubjects) { + const bucket = subjMap.get(sid); + const seen = new Set(); + const objects = []; + for (const r of bucket.rows) { + const oid = r.objectId; + if (!oid || seen.has(oid)) continue; + seen.add(oid); + // ensure uniqueness under the predicate root + objects.push({ name: safe(r.object), id: `${sid}::${oid}`, type: OBJECT, children: [] }); + } + subjectNodes.push({ + name: bucket.label, + id: sid, + type: SUBJECT, + value: bucket.rows.length, + children: objects, }); } return { - name: safe(rootLabel), - id: safe(rootKey), + name: predicateLabel, + id: predicateId, type: ROOT, - value: forRoot.length || pred?.count || 0, - children: [ - { - name: predicateLabel, - id: predicateId, - type: PREDICATE, - children: objects, - }, - ], + value: rows.length || pred?.count || 0, + children: subjectNodes, }; }; diff --git a/src/components/SingleTermView/OverView/Hierarchy.jsx b/src/components/SingleTermView/OverView/Hierarchy.jsx index 7b85a47b..c3e9706e 100644 --- a/src/components/SingleTermView/OverView/Hierarchy.jsx +++ b/src/components/SingleTermView/OverView/Hierarchy.jsx @@ -1,161 +1,134 @@ +// SingleTermView/OverView/Hierarchy.jsx +import React from "react"; +import PropTypes from "prop-types"; import { Box, Button, Divider, Stack, - Typography + Typography, + CircularProgress } from "@mui/material"; import { vars } from "../../../theme/variables"; -import React from "react"; -import PropTypes from "prop-types"; import { RestartAlt, TargetCross } from "../../../Icons"; import SingleSearch from "../SingleSearch"; import CustomizedTreeView from "../../common/CustomizedTreeView"; import CustomSingleSelect from "../../common/CustomSingleSelect"; const { gray600, gray800 } = vars; +const CHILDREN = 'children'; +const SUPERCLASSES = 'superclasses'; -function mapsFromTriples(triples) { - const idLabelMap = {}; - const parentToChildren = {}; - - for (const t of triples || []) { - const pred = (t.predicate?.label || t.predicate?.id || "").toLowerCase(); - - if (pred.endsWith("label") || pred.includes("rdfs:label")) { - if (t.subject?.id) idLabelMap[t.subject.id] = t.object?.label || t.object?.id || t.subject.id; - continue; - } - - if (pred.endsWith("ilx.partof:") || pred.includes("partof") || pred.includes("is part of")) { - const child = t.subject?.id; - const parent = t.object?.id; - if (child && parent) { - if (!parentToChildren[parent]) parentToChildren[parent] = []; - if (!parentToChildren[parent].includes(child)) parentToChildren[parent].push(child); - } - } +// Find first tree item whose .iri matches focusIri +const findFirstRenderedId = (items = [], focusIri) => { + const stack = [...items]; + while (stack.length) { + const node = stack.shift(); + if (node?.iri === focusIri) return node.id; + if (Array.isArray(node?.children)) stack.push(...node.children); } + return null; +}; - return { idLabelMap, parentToChildren }; -} - -function buildTree(rootId, mapping, idLabelMap) { - const visited = new Set(); - const build = (id) => { - if (visited.has(id)) return { id, label: idLabelMap[id] || id, children: [], isCycle: true }; - visited.add(id); - const kids = (mapping[id] || []).map(build); - return { id, label: idLabelMap[id] || id, children: kids }; - }; - return [build(rootId)]; -} +// Normalize "ILX:0100573" -> "http://uri.interlex.org/base/ilx_0100573" +const toIri = (idLike) => { + if (!idLike) return ""; + if (/^https?:\/\//i.test(idLike)) return idLike; + const m = String(idLike).match(/^ILX:(\d+)$/i); + if (m) return `http://uri.interlex.org/base/ilx_${m[1]}`; + return idLike; +}; const Hierarchy = ({ - options = [], - selectedValue, + options = { children: [], superclasses: [] }, // {children:[{id,label}], superclasses:[...]} + selectedValue, // { id, label } onSelect, - triplesChildren = [], - triplesSuperclasses = [], + // prebuilt trees (arrays of {id,label,iri,children}) + treeChildren = [], + treeSuperclasses = [], + loading = false, }) => { - const [type, setType] = React.useState("children"); - const [treeData, setTreeData] = React.useState([]); - const [loading, setLoading] = React.useState(false); + const [type, setType] = React.useState(SUPERCLASSES); // default to superclasses + const [currentId, setCurrentId] = React.useState(null); + // when selection or type changes, recompute which tree + currentId to show React.useEffect(() => { - const triples = type === "children" ? triplesChildren : triplesSuperclasses; - if (!selectedValue?.handler || !Array.isArray(triples)) { - setTreeData([]); - return; - } - setLoading(true); - try { - const { idLabelMap, parentToChildren } = mapsFromTriples(triples); - let mapping = parentToChildren; - if (type === "superclasses") { - const childToParents = {}; - for (const [parent, kids] of Object.entries(parentToChildren)) { - for (const kid of kids) { - if (!childToParents[kid]) childToParents[kid] = []; - if (!childToParents[kid].includes(parent)) childToParents[kid].push(parent); - } - } - mapping = childToParents; - } - const tree = buildTree(selectedValue.handler, mapping, idLabelMap); - setTreeData(tree); - } catch (e) { - console.error(e); - setTreeData([]); - } finally { - setLoading(false); - } - }, [selectedValue, type, triplesChildren, triplesSuperclasses]); + const focusIri = toIri(selectedValue?.id); + const items = type === CHILDREN ? treeChildren : treeSuperclasses; + setCurrentId(findFirstRenderedId(items, focusIri)); + }, [selectedValue, type, treeChildren, treeSuperclasses]); + + const items = type === CHILDREN ? treeChildren : treeSuperclasses; + const childCount = items?.[0]?.children?.length || 0; - const childCount = treeData?.[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]); + }; + + const singleSearchOptions = type === CHILDREN ? options.children : options.superclasses; + + if (loading) { + return + + + } return ( - - + + Hierarchy - - - Type: - + + Type: setType(v)} options={[ - { value: "children", label: "Children" }, - { value: "superclasses", label: "Superclasses" }, + { value: CHILDREN, label: 'Children' }, + { value: SUPERCLASSES, label: 'Superclasses' }, ]} /> - - + - - Total number of first generation {type === "children" ? "children" : "superclasses"}: {childCount} + + + Total number of first generation {type === CHILDREN ? CHILDREN : SUPERCLASSES}: {childCount} ); }; Hierarchy.propTypes = { - options: PropTypes.arrayOf( - PropTypes.shape({ - label: PropTypes.string, - handler: PropTypes.string.isRequired, - }) - ), - selectedValue: PropTypes.shape({ - label: PropTypes.string, - handler: PropTypes.string, + options: PropTypes.shape({ + children: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string, label: PropTypes.string })), + superclasses: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string, label: PropTypes.string })), }), + selectedValue: PropTypes.shape({ id: PropTypes.string, label: PropTypes.string }), onSelect: PropTypes.func, - triplesChildren: PropTypes.array, - triplesSuperclasses: PropTypes.array, + treeChildren: PropTypes.array, // array of TreeItem + treeSuperclasses: PropTypes.array, // array of TreeItem + loading: PropTypes.bool, }; export default Hierarchy; diff --git a/src/components/SingleTermView/OverView/OverView.jsx b/src/components/SingleTermView/OverView/OverView.jsx index c35bdc77..4ff4c96e 100644 --- a/src/components/SingleTermView/OverView/OverView.jsx +++ b/src/components/SingleTermView/OverView/OverView.jsx @@ -1,76 +1,88 @@ +// SingleTermView/OverView/OverView.jsx import { Box, Divider, Grid, } from "@mui/material"; import Details from "./Details"; -import { debounce } from 'lodash'; -import PropTypes from 'prop-types'; +import { debounce } from "lodash"; +import PropTypes from "prop-types"; import Hierarchy from "./Hierarchy"; import Predicates from "./Predicates"; import RawDataViewer from "./RawDataViewer"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { getMatchTerms, getRawData, getTermHierarchies } from "../../../api/endpoints/apiService"; +import { + getMatchTerms, + getRawData, + getTermHierarchies, + getTermPredicates, +} from "../../../api/endpoints/apiService"; + +import { + toHierarchyOptionsFromTriples, + buildChildrenTreeFromTriples, + buildSuperclassesTreeFromTriples, + dedupePredicateGroups +} from "../../../parsers/hierarchies-parser"; -const OverView = ({ searchTerm, isCodeViewVisible, selectedDataFormat, group = "base" }) => { +const OverView = ({ searchTerm, isCodeViewVisible = false, selectedDataFormat, group = "base" }) => { const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); + const [pageLoading, setPageLoading] = useState(true); const [jsonData, setJsonData] = useState(null); - // options for Hierarchy’s SingleSearch - const [hierarchyOptions, setHierarchyOptions] = useState([]); - const [selectedValue, setSelectedValue] = useState(null); - - // hierarchies for the currently selected value - const [triplesChildren, setTriplesChildren] = useState([]); - const [triplesSuperclasses, setTriplesSuperclasses] = useState([]); - - // eslint-disable-next-line react-hooks/exhaustive-deps - const fetchTerms = useCallback( - debounce((term) => { - if (term) { - getMatchTerms(group, term).then(apiData => { + // SingleSearch options + selection + const [hierarchyOptions, setHierarchyOptions] = useState({}); + const [selectedValue, setSelectedValue] = useState(null); // {id, label} + + // computed trees + const [treeChildren, setTreeChildren] = useState([]); + const [treeSuperclasses, setTreeSuperclasses] = useState([]); + + // predicates + const [predicateGroups, setPredicateGroups] = useState([]); + + // loading flags + const [loadingHierarchies, setLoadingHierarchies] = useState(true); + const [loadingPredicates, setLoadingPredicates] = useState(true); + + // debounced search (explicit deps to satisfy eslint) + const debouncedFetchTerms = useMemo( + () => + debounce(async (term, groupname) => { + if (!term) { + setData(null); + setSelectedValue(null); + setPageLoading(false); + return; + } + try { + const apiData = await getMatchTerms(groupname, term); const results = apiData?.results || []; - setData(results?.[0] || null); - - // Build options { label, handler } - const opts = results - .map((r) => { - const label = - r.label || - r.rdfsLabel || - r.prefLabel || - r.term || - r.name || - r.curie || - r.id; - const handler = - r.curie || r.ilx || r.id || r.termId || r.identifier; - return label && handler ? { label, handler } : null; - }) - .filter(Boolean); - - // de-dupe by handler - const seen = new Set(); - const deduped = opts.filter(o => (seen.has(o.handler) ? false : (seen.add(o.handler), true))); - - setHierarchyOptions(deduped); - - // default selection - setSelectedValue(prev => - prev && deduped.some(o => o.handler === prev?.handler) ? prev : deduped[0] || null - ); - - setLoading(false); - }); - } else { - setData(null); - setHierarchyOptions([]); - setSelectedValue(null); - setLoading(false); - } - }, 300), - [group] + const first = results?.[0] || null; + + // normalize first result -> { id, label } + let id = + first?.curie || + first?.ilx || + first?.id || + first?.termId || + first?.identifier || + null; + let label = + first?.label || + first?.rdfsLabel || + first?.prefLabel || + first?.term || + first?.name || + id; + + if (id) setSelectedValue({ id, label }); + setData(first); + } finally { + setPageLoading(false); + } + }, 300), + [] ); const fetchJSONFile = useCallback(() => { @@ -78,73 +90,115 @@ const OverView = ({ searchTerm, isCodeViewVisible, selectedDataFormat, group = " setJsonData(null); return; } - getRawData(group, searchTerm, 'jsonld').then(rawResponse => { + getRawData(group, searchTerm, "jsonld").then((rawResponse) => { setJsonData(rawResponse); }); }, [searchTerm, group]); - // Fetch hierarchies for selectedValue + // normalize ID for API calls + const toILX = (curieLike) => { + let t = (curieLike || "").split("/").pop() || curieLike; + return t.replace(/^ilx_/i, "ILX:"); + }; + + // fetch both directions + compute trees + build options const fetchHierarchies = useCallback(async (curieLike, groupname) => { + setLoadingHierarchies(true); try { - await Promise.all([ - getTermHierarchies({ groupname, termId: curieLike, objToSub: true }), - getTermHierarchies({ groupname, termId: curieLike, objToSub: false }), + const termId = toILX(curieLike); + + const [childRes, superRes] = await Promise.all([ + getTermHierarchies({ groupname, termId, objToSub: true }), + getTermHierarchies({ groupname, termId, objToSub: false }), ]); + + const childTriples = childRes?.triples || []; + const superTriples = superRes?.triples || []; + + // trees for the currently selected ID + setTreeChildren(buildChildrenTreeFromTriples(childTriples, termId)); + setTreeSuperclasses(buildSuperclassesTreeFromTriples(superTriples, termId)); + + // update SingleSearch options (union of both) + const children = toHierarchyOptionsFromTriples(childTriples); + const superclasses = toHierarchyOptionsFromTriples(superTriples); + setHierarchyOptions({children: children, superclasses: superclasses}); + setLoadingHierarchies(false); } catch (e) { console.error("fetchHierarchies error:", e); - setTriplesChildren([]); - setTriplesSuperclasses([]); + setTreeChildren([]); + setTreeSuperclasses([]); + setHierarchyOptions([]); + setLoadingHierarchies(false); + } + }, []); + + const fetchPredicates = useCallback(async (curieLike, groupname) => { + setLoadingPredicates(true); + try { + const termId = toILX(curieLike); + const groups = await getTermPredicates({ groupname, termId }); + setPredicateGroups(groups || []); + } catch (e) { + console.error("fetchPredicates error:", e); + setPredicateGroups([]); + } finally { + setLoadingPredicates(false); } }, []); useEffect(() => { - setLoading(true); - fetchTerms(searchTerm); + setPageLoading(true); + setLoadingHierarchies(true); + setLoadingPredicates(true); + debouncedFetchTerms(searchTerm, group); fetchJSONFile(); - return () => { - fetchTerms.cancel(); - }; - }, [searchTerm, fetchTerms, fetchJSONFile]); + return () => debouncedFetchTerms.cancel(); + }, [searchTerm, group, debouncedFetchTerms, fetchJSONFile]); useEffect(() => { - // infer groupname from data if you store it in context; fallback to "base" - const groupname = group || "base"; - if (selectedValue?.handler) { - fetchHierarchies(selectedValue.handler, groupname); + if (selectedValue?.id) { + fetchHierarchies(selectedValue.id, "base"); + fetchPredicates(selectedValue.id, "base"); } else { - setTriplesChildren([]); - setTriplesSuperclasses([]); + setTreeChildren([]); + setTreeSuperclasses([]); + setPredicateGroups([]); + setHierarchyOptions([]); } - }, [selectedValue, group, fetchHierarchies]); + }, [selectedValue, fetchHierarchies, fetchPredicates]); const memoData = useMemo(() => data, [data]); + const rawPredicates = [ + ...(Array.isArray(predicateGroups) ? predicateGroups : []), + ...(memoData && Array.isArray(memoData.predicates) ? memoData.predicates : []), + ]; + + const predicates = dedupePredicateGroups(rawPredicates); + return ( - + {isCodeViewVisible ? ( ) : ( <> -
- +
+ - + - + @@ -152,13 +206,13 @@ const OverView = ({ searchTerm, isCodeViewVisible, selectedDataFormat, group = " )} ); -} +}; OverView.propTypes = { searchTerm: PropTypes.string, isCodeViewVisible: PropTypes.bool, selectedDataFormat: PropTypes.string, - group: PropTypes.string -} + group: PropTypes.string, +}; export default OverView; diff --git a/src/components/SingleTermView/OverView/Predicates.jsx b/src/components/SingleTermView/OverView/Predicates.jsx index 9af95822..bd23e3ae 100644 --- a/src/components/SingleTermView/OverView/Predicates.jsx +++ b/src/components/SingleTermView/OverView/Predicates.jsx @@ -1,114 +1,50 @@ import React from "react"; -import PropTypes from 'prop-types'; -import ExpandIcon from '@mui/icons-material/Expand'; -import RemoveIcon from '@mui/icons-material/Remove'; +import PropTypes from "prop-types"; +import ExpandIcon from "@mui/icons-material/Expand"; +import RemoveIcon from "@mui/icons-material/Remove"; import PredicatesAccordion from "./PredicatesAccordion"; +import CircularProgress from '@mui/material/CircularProgress'; import { Box, Typography, ToggleButton, ToggleButtonGroup } from "@mui/material"; import { vars } from "../../../theme/variables"; const { gray800 } = vars; -const FALLBACK_PREDICATE = "predicate"; +const Predicates = ({ data, isGraphVisible, loading }) => { + const [toggleButtonValue, setToggleButtonValue] = React.useState("expand"); -function groupsFromTriples(triples) { - const byPred = new Map(); - for (const t of triples || []) { - const key = (t?.predicate?.label || t?.predicate?.id || "").trim() || FALLBACK_PREDICATE; - if (!byPred.has(key)) byPred.set(key, []); - byPred.get(key).push(t); - } - const groups = []; - for (const [predLabel, items] of byPred.entries()) { - const rows = items.map((t) => ({ - subject: t.subject?.label || t.subject?.id, - subjectId: t.subject?.id, - object: t.object?.label || t.object?.id, - objectId: t.object?.id, - })); - const edges = items.map((t) => ({ - from: t.subject, - to: t.object, - predicate: t.predicate, - })); - groups.push({ - title: predLabel, - count: items.length, - rows, - values: rows, - edges, - forceGraph: false, - }); - } - return groups; -} - -const Predicates = ({ basePredicates = [], triplesChildren = [], triplesSuperclasses = [], isGraphVisible }) => { - const [predicates, setPredicates] = React.useState([]); - const [toggleButtonValue, setToggleButtonValue] = React.useState('expand'); - - const onToggleButtonChange = (_event, newValue) => { - if (newValue) setToggleButtonValue(newValue); - }; - - React.useEffect(() => { - // Build groups from both directions and merge with existing basePredicates - const groupsChildren = groupsFromTriples(triplesChildren); - const groupsSupers = groupsFromTriples(triplesSuperclasses); + const predicates = React.useMemo(() => { + if (Array.isArray(data)) return data; + if (Array.isArray(data)) return data; + return []; + }, [data]); - const all = [...(Array.isArray(basePredicates) ? basePredicates : [])]; + const onToggleButtonChange = (_e, v) => v && setToggleButtonValue(v); - const pushOrMerge = (g) => { - const idx = all.findIndex(p => p.title === g.title); - if (idx === -1) { - all.push(g); - } else { - const existing = all[idx]; - const combinedRows = [...(existing.rows || existing.values || []), ...(g.rows || g.values || [])]; - const combinedEdges = [...(existing.edges || []), ...(g.edges || [])]; - all[idx] = { - ...existing, - count: (existing.count || 0) + (g.count || 0), - rows: combinedRows, - values: combinedRows, - edges: combinedEdges, - }; - } - }; - - groupsChildren.forEach(pushOrMerge); - groupsSupers.forEach(pushOrMerge); - - setPredicates(all); - }, [basePredicates, triplesChildren, triplesSuperclasses]); + if (loading) { + return + + + } return ( - - + + Predicates - + - - - - - - + + @@ -116,10 +52,9 @@ const Predicates = ({ basePredicates = [], triplesChildren = [], triplesSupercla }; Predicates.propTypes = { - basePredicates: PropTypes.array, - triplesChildren: PropTypes.array, - triplesSuperclasses: PropTypes.array, - isGraphVisible: PropTypes.bool + data: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), + isGraphVisible: PropTypes.bool, + loading: PropTypes.bool }; export default Predicates; diff --git a/src/components/SingleTermView/OverView/PredicatesAccordion.jsx b/src/components/SingleTermView/OverView/PredicatesAccordion.jsx index ed38e43b..3fdbb0bd 100644 --- a/src/components/SingleTermView/OverView/PredicatesAccordion.jsx +++ b/src/components/SingleTermView/OverView/PredicatesAccordion.jsx @@ -22,9 +22,11 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { vars } from "../../../theme/variables"; const { gray600 } = vars; +const TABLE_VIEW = 'tableView'; +const GRAPH_VIEW = 'graphView'; const PredicatesAccordion = ({ data, expandAllPredicates, isGraphVisible }) => { - const [toggleButtonValues, setToggleButtonValues] = useState(data?.map(() => 'tableView') || []); + const [toggleButtonValues, setToggleButtonValues] = useState(data?.map(() => TABLE_VIEW) || []); const [openViewDiagram, setOpenViewDiagram] = React.useState(false); const [selectedItem, setSelectedItem] = useState(null); const [expandedItems, setExpandedItems] = useState(data?.map(() => false) || []); @@ -54,7 +56,7 @@ const PredicatesAccordion = ({ data, expandAllPredicates, isGraphVisible }) => { }; useEffect(() => { - const newToggleButtonValues = data?.map((d) => (d.forceGraph ? "graphView" : "tableView")) || []; + const newToggleButtonValues = data?.map((d) => (d.forceGraph ? GRAPH_VIEW : TABLE_VIEW)) || []; const newExpandedItems = data?.map(() => expandAllPredicates) || []; setToggleButtonValues(newToggleButtonValues); setExpandedItems(newExpandedItems); @@ -100,10 +102,10 @@ const PredicatesAccordion = ({ data, expandAllPredicates, isGraphVisible }) => { exclusive onChange={onToggleButtonChange(index)} > - + - + @@ -112,7 +114,7 @@ const PredicatesAccordion = ({ data, expandAllPredicates, isGraphVisible }) => { - {toggleButtonValues[index] === 'tableView' ? ( + {toggleButtonValues[index] === TABLE_VIEW ? ( { - const [type, setType] = React.useState(selectedItem?.title); - const [count, setCount] = React.useState(selectedItem?.count) +const HeaderRightSideContent = ({ handleOpenAddPredicate, selectedItem, predicates }) => { + // predicates is an array of groups: [{ title, count, rows }, ...] + const options = React.useMemo( + () => (predicates || []).map(p => ({ label: p.title, value: p.title })), + [predicates] + ); + const countByTitle = React.useMemo(() => { + const m = new Map(); + (predicates || []).forEach(p => m.set(p.title, p.count ?? 0)); + return m; + }, [predicates]); + + const [type, setType] = React.useState(selectedItem?.title || ''); + const [count, setCount] = React.useState( + selectedItem?.title ? (countByTitle.get(selectedItem.title) ?? selectedItem?.count ?? 0) : 0 + ); + + // keep in sync when selection/predicates change + React.useEffect(() => { + const t = selectedItem?.title || ''; + setType(t); + setCount(countByTitle.get(t) ?? selectedItem?.count ?? 0); + }, [selectedItem, countByTitle]); const handleChangeType = (value) => { - setType(value) - const selectedTypeCount = predicates.find(predicate => predicate.label === value).count - setCount(selectedTypeCount) - } + setType(value); + setCount(countByTitle.get(value) ?? 0); + }; return ( - - + + Number of this type: {count} - Predicate type: + + Predicate type: + - + - ) -} -const ViewDiagramDialog = ({open, handleClose, image, selectedItem, predicates}) => { - const [openAddPredicate, setOpenAddPredicate] = useState(false) - - const handleCloseAddPredicate = () => { - setOpenAddPredicate(false) - } - const handleOpenAddPredicate = () => { - setOpenAddPredicate(true) - } - const predicatesOptions = predicates.map(row => ({ + ); +}; +const ViewDiagramDialog = ({ open, handleClose, image, selectedItem, predicates }) => { + const [openAddPredicate, setOpenAddPredicate] = useState(false); + + const handleCloseAddPredicate = () => setOpenAddPredicate(false); + const handleOpenAddPredicate = () => setOpenAddPredicate(true); + + // simple label/value list only for the add dialog + const predicatesOptions = (predicates || []).map(row => ({ label: row.title, value: row.title - })) + })); + return ( <> - } + HeaderRightSideContent={ + + } > - { - openAddPredicate && - } + + {openAddPredicate && ( + + )} - ) -} + ); +}; HeaderRightSideContent.propTypes = { handleOpenAddPredicate: PropTypes.func, diff --git a/src/components/common/CustomizedTreeView.jsx b/src/components/common/CustomizedTreeView.jsx index 0648b07f..1cd75279 100644 --- a/src/components/common/CustomizedTreeView.jsx +++ b/src/components/common/CustomizedTreeView.jsx @@ -5,6 +5,7 @@ import { RichTreeView } from '@mui/x-tree-view/RichTreeView'; import { TreeItem, treeItemClasses } from '@mui/x-tree-view/TreeItem'; import ExpandLessOutlinedIcon from '@mui/icons-material/ExpandLessOutlined'; import ChevronRightOutlinedIcon from '@mui/icons-material/ChevronRightOutlined'; +import { useEffect, useMemo, useRef, useState } from "react"; import { vars } from "../../theme/variables"; const { gray500, brand200, brand50 } = vars; @@ -29,11 +30,12 @@ const StyledTreeItemBase = (props) => ( ); StyledTreeItemBase.propTypes = { - label: PropTypes.node, + label: PropTypes.string, currentId: PropTypes.string, - itemId: PropTypes.string, // provided by MUI TreeItem + itemId: PropTypes.string }; + const StyledTreeItem = styled(StyledTreeItemBase)(() => ({ color: gray500, [`& .${treeItemClasses.content}`]: { @@ -42,7 +44,49 @@ const StyledTreeItem = styled(StyledTreeItemBase)(() => ({ [`& .${treeItemClasses.groupTransition}`]: { marginLeft: 14 }, })); -const CustomizedTreeView = ({ items = [], loading = false, currentId = null }) => { +const kidsOf = (n, getItemChildren) => { + const k = getItemChildren(n); + return Array.isArray(k) ? k : []; +}; + +// depth-first: return ids from root → target; [] if not found +function findPathToId(nodes, targetId, getItemId, getItemChildren) { + for (const n of nodes || []) { + const id = getItemId(n); + if (id === targetId) return [id]; + const childPath = findPathToId(kidsOf(n, getItemChildren), targetId, getItemId, getItemChildren); + if (childPath.length) return [id, ...childPath]; + } + return []; +} + +const CustomizedTreeView = ({ + items = [], + loading = false, + currentId = null, + getItemId = (i) => i.id, + getItemLabel = (i) => i.label, + getItemChildren = (i) => i.children || [], +}) => { + const [expanded, setExpanded] = useState([]); + + // 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(''); + useEffect(() => { + if (pathKey && pathKey !== lastPathKeyRef.current) { + const ancestors = pathToCurrent.slice(0, -1); + setExpanded(ancestors); // set once per path change + lastPathKeyRef.current = pathKey; + } + }, [pathKey, pathToCurrent]); + if (loading) { return ( @@ -54,16 +98,18 @@ const CustomizedTreeView = ({ items = [], loading = false, currentId = null }) = return ( setExpanded(ids)} // user can toggle slots={{ item: StyledTreeItem, expandIcon: ChevronRightOutlinedIcon, - collapseIcon: ExpandLessOutlinedIcon - }} - slotProps={{ - item: { currentId }, + collapseIcon: ExpandLessOutlinedIcon, }} + slotProps={{ item: { currentId } }} /> ); }; @@ -72,6 +118,9 @@ CustomizedTreeView.propTypes = { items: PropTypes.array, loading: PropTypes.bool, currentId: PropTypes.string, + getItemId: PropTypes.func, + getItemLabel: PropTypes.func, + getItemChildren: PropTypes.func, }; export default CustomizedTreeView; diff --git a/src/parsers/hierarchies-parser.tsx b/src/parsers/hierarchies-parser.tsx new file mode 100644 index 00000000..bde2aa2e --- /dev/null +++ b/src/parsers/hierarchies-parser.tsx @@ -0,0 +1,376 @@ +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 RDFS_LABEL = 'http://www.w3.org/2000/01/rdf-schema#label'; +export const PART_OF_IRI = 'http://uri.interlex.org/base/ilx_0112785'; + +type NodeRef = { id: string; label: string }; +type Edge = { from: NodeRef; to: NodeRef }; + +function firstString(v: any): string | undefined { + if (!v) return undefined; + if (typeof v === 'string') return v; + if (Array.isArray(v)) { + for (const x of v) { + if (typeof x === 'string') return x; + if (x && typeof x === 'object' && typeof x['@value'] === 'string') return x['@value']; + } + } + if (typeof v === 'object' && typeof v['@value'] === 'string') return v['@value']; + return undefined; +} + +function ids(v: any): string[] { + if (!v) return []; + if (Array.isArray(v)) { + return v.flatMap(ids); + } + if (typeof v === 'object' && typeof v['@id'] === 'string') { + return [v['@id']]; + } + if (typeof v === 'string') return [v]; + return []; +} + +// http://uri.../ilx_0100573 -> ILX:0100573 +export const toCurie = (id?: string) => { + if (!id) return id; + const m = id.match(/\/ilx_(\d+)$/i); + return m ? `ILX:${m[1]}` : id; +}; + +export function jsonldToTriplesAndEdges(jsonld: any): { triples: Triple[]; edges: Edge[] } { + const graph: any[] = + Array.isArray(jsonld) ? jsonld : + Array.isArray(jsonld?.['@graph']) ? jsonld['@graph'] : + jsonld?.['@id'] ? [jsonld] : []; + + // Build id → label + const labelById = new Map(); + for (const n of graph) { + const id = n['@id']; if (!id) continue; + const lbl = firstString(n['rdfs:label']) ?? firstString(n['label']); + if (lbl) labelById.set(id, lbl); + } + + const triples: Triple[] = []; + const edges: Edge[] = []; + + const addTriple = (s: string, p: string, oId?: string, oLabel?: string) => { + const subj = { id: s, label: labelById.get(s) ?? s }; + const pred = { id: p, label: p }; + const obj = oId ? { id: oId, label: labelById.get(oId) ?? oId } : { id: '', label: oLabel ?? '' }; + triples.push({ subject: subj, predicate: pred, object: obj }); + }; + + for (const n of graph) { + const s = n['@id']; if (!s) continue; + + // rdf:type + for (const t of ids(n['@type'])) addTriple(s, RDF_TYPE, t); + + // rdfs:label + const lbl = firstString(n['rdfs:label']) ?? firstString(n['label']); + if (lbl) addTriple(s, RDFS_LABEL, undefined, lbl); + + // ilx.partOf in ALL its forms + const partOfTargets = [ + ...ids(n['ilx.partOf']), // compact (what your endpoint returns) + ...ids(n['partOf']), // generic compact + ...ids(n[PART_OF_IRI]) // expanded IRI (just in case) + ]; + for (const o of partOfTargets) { + addTriple(s, PART_OF_IRI, o); + edges.push({ + from: { id: s, label: labelById.get(s) ?? s }, + to: { id: o, label: labelById.get(o) ?? o }, + }); + } + } + + return { triples, edges }; +} + +// parsers/hierarchies-parser.tsx +export type Triple = { + subject?: { id?: string; label?: string }; + predicate?: { id?: string; label?: string }; + object?: { id?: string; label?: string }; +}; + +export type TreeItem = { + id: string; // must be unique per node instance in the tree + label: string; + iri: string; // stable node identity (IRI), used to find the "current" item + children?: TreeItem[]; +}; + +// ---------- helpers ---------- +const CURIE_BASE = "http://uri.interlex.org/base/"; + +const toIri = (idLike: string): string => { + if (!idLike) return ""; + if (/^https?:\/\//i.test(idLike)) return idLike; + // ILX:0100573 -> http://uri.interlex.org/base/ilx_0100573 + const m = idLike.match(/^ILX:(\d+)$/i); + if (m) return `${CURIE_BASE}ilx_${m[1]}`; + return idLike; // fallback +}; + +const labelOf = (iri: string, labelMap: Record): string => { + if (labelMap[iri]) return labelMap[iri]; + // friendly fallback label from tail + try { + const tail = iri.split("/").pop() || iri; + return tail.replace(/^ilx_/i, "ILX:").toUpperCase(); + } catch { + return iri; + } +}; + +const isPartOf = (pred?: { id?: string; label?: string }) => { + const s = (pred?.label || pred?.id || "").toLowerCase(); + // match ilx.partOf or anything that includes 'partof' robustly + return s.includes("partof") || s.endsWith("0112785"); +}; + +// Collect node labels from any triple (subject/object labels are present even when the +// predicate is not rdfs:label). This makes us resilient to the sample payloads. +const buildLabelMap = (triples: Triple[]) => { + const map: Record = {}; + for (const t of triples) { + if (t.subject?.id) { + if (t.subject.label) map[t.subject.id] = t.subject.label; + else if (!map[t.subject.id]) map[t.subject.id] = t.subject.id; + } + if (t.object?.id) { + if (t.object.label) map[t.object.id] = t.object.label; + else if (!map[t.object.id]) map[t.object.id] = t.object.id; + } + } + return map; +}; + +// Extract edges child -> parent +const extractEdges = (triples: Triple[]) => { + const edges: Array<{ child: string; parent: string }> = []; + for (const t of triples) { + if (!isPartOf(t.predicate)) continue; + const child = toIri(t.subject?.id || ""); + const parent = toIri(t.object?.id || ""); + if (child && parent) edges.push({ child, parent }); + } + return edges; +}; + +// Ensure RichTreeView item ids are unique even when the same node appears in multiple branches. +// We derive a unique id from the path. +const makeItem = (iri: string, label: string, path: string[]): TreeItem => ({ + id: [...path, iri].join(" > "), // path-based unique id + label, + iri, +}); + +// Depth-first build from a mapping +const buildFrom = ( + rootIri: string, + childrenOf: Record, + labelMap: Record, + path: string[] = [] +): TreeItem => { + const item = makeItem(rootIri, labelOf(rootIri, labelMap), path); + const kids = childrenOf[rootIri] || []; + item.children = kids.map((kid) => + buildFrom(kid, childrenOf, labelMap, [...path, rootIri]) + ); + return item; +}; + +// Walk downwards but keep only branches that eventually reach `focusIri`. +const includesFocus = ( + iri: string, + childrenOf: Record, + focusIri: string, + memo = new Map() +): boolean => { + if (memo.has(iri)) return memo.get(iri)!; + if (iri === focusIri) { memo.set(iri, true); return true; } + const kids = childrenOf[iri] || []; + const res = kids.some((k) => includesFocus(k, childrenOf, focusIri, memo)); + memo.set(iri, res); + return res; +}; + +// ---------- API expected by OverView.jsx ---------- + +/** + * Options for SingleSearch derived from the triples in a direction. + * Returns [{ id, label }] + */ +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)); + } + const out = Array.from(ids).map((iri) => ({ id: iri, label: labelOf(iri, labelMap) })); + // stable order: label asc + out.sort((a, b) => a.label.localeCompare(b.label)); + return out; +}; + +/** + * Build a tree where the selected focus is the ROOT and its descendants expand below. + */ +export const buildChildrenTreeFromTriples = (triples: Triple[] = [], focusIdLike: string) => { + const focusIri = toIri(focusIdLike); + const labelMap = buildLabelMap(triples); + const edges = extractEdges(triples); + + // parent -> children mapping (for "children" view we want focus -> descendants) + const childrenOf: Record = {}; + for (const { child, parent } of edges) { + if (!childrenOf[parent]) childrenOf[parent] = []; + if (!childrenOf[parent].includes(child)) childrenOf[parent].push(child); + } + + // If the focus has no entry in childrenOf, still create a solitary node + if (!childrenOf[focusIri]) childrenOf[focusIri] = []; + + // Build the subtree only from the focus downwards: + const pruneToFocus = (iri: string, seen = new Set()) => { + if (seen.has(iri)) return [] as string[]; // break cycles + seen.add(iri); + const kids = childrenOf[iri] || []; + const list: string[] = []; + for (const k of kids) { + list.push(k); + pruneToFocus(k, seen); + } + return list; + }; + pruneToFocus(focusIri); + + return [buildFrom(focusIri, childrenOf, labelMap)]; +}; + +/** + * Build a tree of ANCESTORS only (selected focus at the BOTTOM). + * We start at graph roots (nodes with no parents) and keep only branches that reach the focus. + */ +export const buildSuperclassesTreeFromTriples = (triples: Triple[] = [], focusIdLike: string) => { + const focusIri = toIri(focusIdLike); + const labelMap = buildLabelMap(triples); + const edges = extractEdges(triples); + + // Build child->parents and parent->children + const parentsOf: Record = {}; + const childrenOf: Record = {}; + + const allNodes = new Set(); + for (const { child, parent } of edges) { + allNodes.add(child); allNodes.add(parent); + if (!parentsOf[child]) parentsOf[child] = []; + if (!parentsOf[child].includes(parent)) parentsOf[child].push(parent); + + if (!childrenOf[parent]) childrenOf[parent] = []; + if (!childrenOf[parent].includes(child)) childrenOf[parent].push(child); + } + + // Focus might not appear in edges (edge case). Still create a trivial leaf. + allNodes.add(focusIri); + + // Keep only nodes on a path to focus + const memo = new Map(); + const onPathToFocus = (iri: string) => includesFocus(iri, childrenOf, focusIri, memo); + + // Roots: nodes with no parents in this subgraph + const roots = Array.from(allNodes).filter((n) => (parentsOf[n] || []).length === 0); + + // Build filtered tree(s) from roots down, pruning branches that don't reach focus + const buildFiltered = (iri: string, path: string[]): TreeItem | null => { + if (!onPathToFocus(iri)) return null; + const item = makeItem(iri, labelOf(iri, labelMap), path); + const kids = (childrenOf[iri] || []) + .map((k) => buildFiltered(k, [...path, iri])) + .filter(Boolean) as TreeItem[]; + + // In an ancestor tree, we want to stop exactly at the focus leaf (don’t expand its children) + if (iri === focusIri) { + item.children = []; + return item; + } + + item.children = kids; + return item; + }; + + const forest = roots + .map((r) => buildFiltered(r, [])) + .filter(Boolean) as TreeItem[]; + + // If nothing reached the focus (degenerate), just render the focus as a single node + if (!forest.length) { + forest.push(makeItem(focusIri, labelOf(focusIri, labelMap), [])); + } + return forest; +}; + +// --- add in OverView.jsx --- +const rowSig = (r = {}) => { + const s = r.subjectId ?? r.subject ?? ""; + const p = r.predicate ?? ""; // some rows won’t have this; ok + const o = r.objectId ?? r.object ?? ""; + return `${s}|${p}|${o}`; +}; + +const pickRowsArray = (g = {}) => + g.rows ?? g.values ?? g.tableData ?? g.edges ?? []; + +export const dedupePredicateGroups = (groups = []) => { + const byTitle = new Map(); + + for (const g of groups.filter(Boolean)) { + const key = String(g.title ?? "").trim().toLowerCase(); + if (!key) continue; + + if (!byTitle.has(key)) { + // normalize count + const base = { ...g }; + const rows = pickRowsArray(base); + base.count = Array.isArray(rows) ? rows.length : (base.count ?? 0); + byTitle.set(key, base); + continue; + } + + // merge with existing + const cur = byTitle.get(key); + const a = pickRowsArray(cur); + const b = pickRowsArray(g); + + const seen = new Set(a.map(rowSig)); + const merged = [...a]; + for (const r of (Array.isArray(b) ? b : [])) { + const sig = rowSig(r); + if (!seen.has(sig)) { + seen.add(sig); + merged.push(r); + } + } + + // put merged rows back into whichever property exists + if (cur.rows || g.rows) cur.rows = merged; + else if (cur.values || g.values) cur.values = merged; + else if (cur.tableData || g.tableData) cur.tableData = merged; + else cur.edges = merged; + + cur.forceGraph = cur.forceGraph || g.forceGraph; + cur.count = merged.length; + + byTitle.set(key, cur); + } + + return Array.from(byTitle.values()); +}; diff --git a/src/parsers/predicateParser.tsx b/src/parsers/predicateParser.tsx new file mode 100644 index 00000000..49dbe55c --- /dev/null +++ b/src/parsers/predicateParser.tsx @@ -0,0 +1,97 @@ +export type PredicateTriple = { + subject: string; + predicate: string; + object: string; + }; + + export type PredicateGroup = { + title: string; + count: number; + tableData: PredicateTriple[]; + }; + + /** + * Build predicate groups for the focus owl:Class in a JSON-LD document. + * Collects: + * - All outgoing predicates on the focus node (including @id/@type). + * - All inbound predicates from any other node where the object equals the focus @id. + */ + export function buildPredicateGroupsForFocus(jsonld: any, termId?: string): PredicateGroup[] { + const graph: any[] = Array.isArray(jsonld?.['@graph']) ? jsonld['@graph'] : []; + + // Resolve focus node + const ilxSuffix = (termId || '').replace(/^ILX:/i, 'ilx_'); + const endsWithIlx = (id: string) => + typeof id === 'string' && + ilxSuffix && + id.toLowerCase().endsWith(`/${ilxSuffix.toLowerCase()}`); + + let focus: any = + graph.find((o) => o?.['@type'] === 'owl:Class' && endsWithIlx(o?.['@id'])) || + graph.find((o) => o?.['@type'] === 'owl:Class') || + null; + + const flatten = (v: any): string => { + if (v == null) return ''; + if (typeof v === 'string') return v; + if (Array.isArray(v)) return v.map(flatten).join(', '); + if (typeof v === 'object') return v['@id'] ?? v['@value'] ?? v.value ?? JSON.stringify(v); + return String(v); + }; + + const predicatesMap: Record = {}; + const pushTriple = (subjectId: string, predicateKey: string, value: any) => { + const triple = { subject: subjectId, predicate: predicateKey, object: flatten(value) }; + (predicatesMap[predicateKey] ||= []).push(triple); + }; + + if (!focus || !focus['@id']) return []; + + const focusId = String(focus['@id']); + + // 1) Outgoing: everything on the focus node + pushTriple(focusId, '@id', focusId); + const typeVal = focus['@type']; + if (typeVal !== undefined) { + if (Array.isArray(typeVal)) typeVal.forEach((v) => pushTriple(focusId, '@type', v)); + else pushTriple(focusId, '@type', typeVal); + } + for (const key of Object.keys(focus)) { + if (key === '@id' || key === '@type' || key === 'isAbout' || key === 'ilx.isAbout') continue; + const val = (focus as any)[key]; + if (Array.isArray(val)) val.forEach((v) => pushTriple(focusId, key, v)); + else pushTriple(focusId, key, val); + } + + // 2) Inbound: for every other node, if any property value equals the focus @id, include that triple + for (const node of graph) { + const subjId = node?.['@id']; + if (!subjId) continue; + + for (const key of Object.keys(node)) { + if (key === '@id' || key === '@type') continue; + const val = (node as any)[key]; + + // helper to check a value (object|string|array) for references to focusId + const addIfMatches = (v: any) => { + if (v && typeof v === 'object' && v['@id'] === focusId) { + pushTriple(String(subjId), key, v); + } else if (typeof v === 'string' && v === focusId) { + pushTriple(String(subjId), key, v); + } + }; + + if (Array.isArray(val)) val.forEach(addIfMatches); + else addIfMatches(val); + } + } + + // Group into the gold-standard shape + return Object.keys(predicatesMap).map((title) => ({ + title, + count: predicatesMap[title].length, + tableData: predicatesMap[title], + })); + } + + \ No newline at end of file diff --git a/vite.config.js b/vite.config.js index b3b39372..ca7b3561 100644 --- a/vite.config.js +++ b/vite.config.js @@ -38,6 +38,48 @@ export default defineConfig({ }); }, }, + '^/([^/]+)/priv/entity(.*)': { + target: "https://uri.olympiangods.org", + secure: false, + changeOrigin: true, + headers: { + "Content-Type": "application/json", + }, + configure: (proxy, _options) => { + console.log(_options); + proxy.on('error', (err, _req, _res) => { + console.log('proxy error', err); + console.log('proxy request', _req); + console.log('proxy response', _res); + }); + proxy.on('proxyReq', (proxyReq, req, _res) => { + console.log('Sending Request to the Target:', req.method, req.url); + console.log('Headers sent to backend:', proxyReq.getHeaders()); + console.log('Response:', _res); + console.log('Request:', proxyReq); + }); + proxy.on('proxyRes', (proxyRes, req, res) => { + console.log('Received response', res); + console.log('Received Response from the Target:', proxyRes.statusCode, req.url); + const location = proxyRes.headers['location']; + console.log('Received location', location); + if (proxyRes.statusCode === 303 && location) { + // Prevent browser from seeing the actual Location + delete proxyRes.headers['location']; + // Inject the location into a custom header we can use in Axios + res.setHeader('X-Redirect-Location', location); + } + + // Required for credentialed CORS + const origin = req.headers.origin; + if (origin) { + res.setHeader('Access-Control-Allow-Origin', origin); + } + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.setHeader('Access-Control-Expose-Headers', 'X-Redirect-Location'); + }); + }, + }, '^/([^/]+)/priv/(.*)': { target: "https://uri.olympiangods.org", secure: false,