UNPKG

frogpot

Version:

A library to work with directed graphs that can be projected into trees.

902 lines (890 loc) 27.3 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { Graph: () => Graph, Hierarchy: () => Hierarchy, HierarchyTree: () => Hierarchy_default, OBOGraphLoader: () => OBOGraphLoader, Path: () => Path, useNodeSearch: () => useNodeSearch }); module.exports = __toCommonJS(index_exports); // src/components/Hierarchy.tsx var import_react = __toESM(require("react"), 1); // src/path.ts var Path = class _Path { steps; constructor(steps) { this.steps = steps; if (this.steps.length === 0) { throw new Error("Cannot create an empty path."); } } get key() { return JSON.stringify(this.steps); } static fromKey(k) { return new _Path(JSON.parse(k)); } depth() { return this.steps.length; } startsWith(other) { return other.isAncestorOf(this) || other.equals(this); } isAncestorOf(other) { if (this.steps.length >= other.steps.length) return false; return this.steps.every((val, i) => val === other.steps[i]); } equals(other) { if (other.steps.length !== this.steps.length) return false; return this.steps.every((v, i) => v === other.steps[i]); } parent() { if (this.steps.length <= 1) return null; return new _Path(this.steps.slice(0, -1)); } child(uri) { return new _Path([...this.steps, uri]); } leaf() { return this.steps.at(-1); } }; // src/util.ts function takeWhile(arr, testFn) { const ret = []; let i = 0; for (const item of arr) { i += 1; if (!testFn(item, i)) { break; } ret.push(item); } return ret; } function sortByLabel(a, b) { const aLabel = a.label; const bLabel = b.label; if (aLabel === bLabel) return 0; if (!aLabel) return -1; if (!bLabel) return 1; return aLabel.localeCompare(bLabel); } // src/components/Hierarchy.tsx var import_jsx_runtime = require("react/jsx-runtime"); var d = { // width: 1000, tree: { itemHeight: 24, depthIndent: 16 } }; function drawHierarchyPath(tree, el) { const ctx = el.getContext("2d"); if (!ctx) return; const dpr = window.devicePixelRatio || 1; const cssHeight = tree.length * d.tree.itemHeight; el.style.width = "100%"; el.style.height = `${cssHeight}px`; const cssWidth = el.clientWidth; el.width = cssWidth * dpr; el.height = cssHeight * dpr; ctx.scale(dpr, dpr); ctx.clearRect(0, 0, cssWidth, cssHeight); ctx.strokeStyle = "#666"; ctx.lineWidth = 1; ctx.setLineDash([1, 1]); ctx.beginPath(); tree.forEach((originItem, i) => { const childItems = takeWhile( tree.slice(i + 1), (item) => item.depth > originItem.depth ); if (childItems.length === 0) return; const x0 = originItem.depth * d.tree.depthIndent + 5; const y0 = i * d.tree.itemHeight + d.tree.itemHeight / 2; const lastDirectChildIdx = childItems.findLastIndex((item) => item.depth === originItem.depth + 1) + 1; ctx.moveTo(x0, y0); ctx.lineTo(x0, y0 + lastDirectChildIdx * d.tree.itemHeight); childItems.forEach((childItem, i2) => { if (childItem.depth === originItem.depth + 1) { const tickWidth = d.tree.depthIndent; const y = y0 + (i2 + 1) * d.tree.itemHeight; ctx.moveTo(x0, y); ctx.lineTo(x0 + tickWidth, y); } }); }); ctx.stroke(); } function HierarchyTree(props, ref) { const { hierarchy, itemURI } = props; const canvasRef = (0, import_react.useRef)(null); const [treeState, setTreeState] = (0, import_react.useState)({ expandPaths: /* @__PURE__ */ new Set(), showPaths: /* @__PURE__ */ new Set(), selectedPath: hierarchy.getPathsForNode(props.rootURI)[0] }); (0, import_react.useEffect)(() => { const expandPaths = /* @__PURE__ */ new Set(); let showPaths = null; if (itemURI) { const node = hierarchy.getNode(itemURI); const paths = hierarchy.getPathsForNode(node); showPaths = new Set(paths.map((path) => path.key)); } setTreeState((prev) => ({ ...prev, expandPaths, showPaths: showPaths ? showPaths : prev.showPaths })); }, [itemURI]); const tree = hierarchy.projectFlatView({ showPaths: [...treeState.showPaths].map((key) => Path.fromKey(key)), expandPaths: [...treeState.expandPaths].map((key) => Path.fromKey(key)) }); (0, import_react.useImperativeHandle)( ref, () => ({ openAndFocusNode(uri) { const node = hierarchy.getNode(uri); const paths = hierarchy.getPathsForNode(node); const parents = paths.map((p) => p.parent()).filter((p) => p !== null); setTreeState((prev) => { const expandPaths = /* @__PURE__ */ new Set([ ...prev.expandPaths, ...parents.map((p) => p.key) ]); const showPaths = /* @__PURE__ */ new Set([...paths.map((path) => path.key)]); const selectedPath = paths[0]; return { expandPaths, showPaths, selectedPath }; }); } }), [] ); (0, import_react.useEffect)(() => { const canvasEl = canvasRef.current; if (!canvasEl) return; drawHierarchyPath(tree, canvasEl); }, [tree]); (0, import_react.useEffect)(() => { const uri = treeState.selectedPath.leaf(); if (props.onSelectNode) { props.onSelectNode(hierarchy.getNode(uri)); } }, [treeState.selectedPath]); const expandPath = (pathKey) => { setTreeState((prev) => ({ ...prev, expandPaths: /* @__PURE__ */ new Set([...prev.expandPaths, pathKey]) })); }; const unexpandPath = (pathKey) => { const path = Path.fromKey(pathKey); setTreeState((prev) => { const nextExpandPaths = [...prev.expandPaths].filter((_path) => { return !Path.fromKey(_path).startsWith(path); }); const nextShowPaths = [...prev.showPaths].filter((_path) => { return !Path.fromKey(_path).startsWith(path); }); let nextSelectedPath = treeState.selectedPath; if (treeState.selectedPath.startsWith(path) && !treeState.selectedPath.equals(path)) { nextSelectedPath = path; } return { selectedPath: nextSelectedPath, showPaths: new Set(nextShowPaths), expandPaths: new Set(nextExpandPaths) }; }); }; const togglePathExpansion = (path) => { if (treeState.expandPaths.has(path)) { unexpandPath(path); } else { expandPath(path); } }; return /* @__PURE__ */ (0, import_jsx_runtime.jsx)( "div", { tabIndex: 0, onKeyDown: (e) => { const curIdx = tree.findIndex( ({ path }) => path.equals(treeState.selectedPath) ); const curItem = tree[curIdx]; if (e.key === "ArrowDown") { e.preventDefault(); if (curIdx !== -1) { const nextIdx = curIdx + 1; const nextItem = tree[nextIdx]; if (nextItem) { setTreeState((prev) => ({ ...prev, selectedPath: nextItem.path })); } } } else if (e.key === "ArrowUp") { e.preventDefault(); if (curIdx > 0) { const nextIdx = curIdx - 1; const nextItem = tree[nextIdx]; if (nextItem) { setTreeState((prev) => ({ ...prev, selectedPath: nextItem.path })); } } } else if (e.key === "ArrowRight") { e.preventDefault(); if (curItem) { expandPath(curItem.path.key); } } else if (e.key === "ArrowLeft") { e.preventDefault(); if (curItem) { if (treeState.expandPaths.has(curItem.path.key)) { unexpandPath(curItem.path.key); } else { for (let i = curIdx; i >= 0; i--) { if (tree[i]?.depth === curItem.depth - 1) { setTreeState((prev) => ({ ...prev, selectedPath: tree[i].path })); break; } } } } } }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)( "div", { style: { position: "relative", border: "1px solid #ccc", width: props.width }, children: [ /* @__PURE__ */ (0, import_jsx_runtime.jsx)( "canvas", { ref: canvasRef, style: { pointerEvents: "none", position: "absolute", top: 0, left: 0 } } ), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { position: "relative" }, children: tree.map(({ item, depth, path }) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)( "div", { "data-path": path.key, style: { display: "flex", alignItems: "center", lineHeight: `${d.tree.itemHeight}px`, height: `${d.tree.itemHeight}px`, paddingLeft: `${depth * d.tree.depthIndent}px`, userSelect: "none", cursor: "pointer" }, children: [ /* @__PURE__ */ (0, import_jsx_runtime.jsx)( "span", { onClick: () => togglePathExpansion(path.key), style: { display: "inline-block", width: "18px" }, children: hierarchy.graph.childrenByURI[item.uri].length === 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime.jsx)( "span", { style: { display: "inline-flex", width: 10, height: 10, border: "2px solid #666", background: treeState.expandPaths.has(path.key) ? "#ccc" : "#fff", alignItems: "center", justifyContent: "center" }, children: treeState.expandPaths.has(path.key) ? "" : "" } ) } ), /* @__PURE__ */ (0, import_jsx_runtime.jsx)( "span", { style: { flex: 1, overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis", backgroundColor: treeState.selectedPath.equals(path) ? "#f0f0f0" : "transparent" }, title: itemURI, onMouseDown: (e) => { if (e.detail === 1) { setTreeState((prev) => ({ ...prev, selectedPath: path })); } else if (e.detail === 2) { togglePathExpansion(path.key); } }, children: item.label } ) ] }, path.key )) }) ] } ) } ); } var Hierarchy_default = import_react.default.forwardRef(HierarchyTree); // src/search.ts var import_react2 = require("react"); var import_minisearch = __toESM(require("minisearch"), 1); var import_react_highlight_words = __toESM(require("react-highlight-words"), 1); var SearchEngine = class { items; itemsByURI; miniSearch; constructor(items) { this.items = items; this.itemsByURI = new Map(items.map((item) => [item.uri, item])); this.miniSearch = new import_minisearch.default({ idField: "uri", fields: ["label", "synonyms", "definitions"], extractField: (doc, fieldName) => { if (fieldName === "synonyms" || fieldName === "definitions") { return doc[fieldName].map((x) => x.value).join(" "); } return doc[fieldName]; } }); } search(text, options) { const results = this.miniSearch.search(text, options); return results.map((res) => ({ ...res, node: this.itemsByURI.get(res.id) })); } buildIndex() { this.miniSearch.addAll(this.items); } }; function useSearchEngine(items) { const engineRef = (0, import_react2.useRef)(null); const rebuild = (items2) => { const engine = new SearchEngine(items2); engine.buildIndex(); engineRef.current = engine; }; if (engineRef.current === null) { rebuild(items); } (0, import_react2.useEffect)(() => { rebuild(items); }, [items]); return { engine: engineRef.current }; } function useNodeSearch(nodes) { const { engine } = useSearchEngine(nodes); const [query, setQuery] = (0, import_react2.useState)(""); const [results, setResults] = (0, import_react2.useState)( null ); (0, import_react2.useEffect)(() => { if (query.trim() === "") { setResults(null); return; } const results2 = engine.search(query, { prefix: true, boost: { label: 2 }, combineWith: "and" }); setResults(results2); }, [query]); const searchWords = query.split(" ").map((word) => new RegExp("\\b" + word)); const highlightText = (text, props) => (0, import_react2.createElement)(import_react_highlight_words.default, { textToHighlight: text, searchWords, ...props }); return { engine, results, query, setQuery, highlightText }; } // src/graph.ts var import_treeverse2 = __toESM(require("treeverse"), 1); // src/hierarchy.ts var import_treeverse = __toESM(require("treeverse"), 1); var Hierarchy = class { root; graph; nodesByURI; nodesByPathKey; pathsByURI; constructor(root, graph) { this.root = root; this.graph = graph; this.nodesByURI = /* @__PURE__ */ new Map(); this.nodesByPathKey = /* @__PURE__ */ new Map(); this.pathsByURI = /* @__PURE__ */ new Map(); import_treeverse.default.depth({ tree: { item: this.root, path: new Path([this.root.uri]) }, visit: (node) => { const { item, path } = node; if (!this.pathsByURI.has(item.uri)) { this.pathsByURI.set(item.uri, []); } this.nodesByURI.set(item.uri, item); this.pathsByURI.get(item.uri).push(path); this.nodesByPathKey.set(path.key, item); }, getChildren: (node) => { const { item, path } = node; const childRels = this.graph.childrenByURI[item.uri] ?? []; return childRels.map((rel) => { const child = this.graph.getItem(rel.to); return { item: child, path: path.child(child.uri) }; }); } }); } getNode(node) { if (typeof node === "string") { const ret = this.nodesByURI.get(node); if (!ret) { throw new Error(`No item in hierarchy with URI ${node}`); } return ret; } else if (node instanceof Path) { const ret = this.nodesByPathKey.get(node.key); if (!ret) { throw new Error(`No item in hierarchy at path ${node.key}`); } return ret; } else { return node; } } items() { return [...this.nodesByURI.values()].sort(sortByLabel); } getPathsForNode(node) { const _node = this.getNode(node); return this.pathsByURI.get(_node.uri) ?? []; } // Produce a projection of this hierarchy, with only certain nodes shown or // expanded. Meant for producing a portion of the hierarchy suitable for // rendering in a UI. // // Returns a one-dimensional array of HierarchyRow objects annotated by their // depth. projectFlatView(opts = {}) { const showPaths = opts.showPaths ?? []; const expandPaths = opts.expandPaths ?? []; const showKeys = new Set(showPaths.map((p) => p.key)); const expandKeys = new Set(expandPaths.map((p) => p.key)); const rows = []; const shouldIterateChildren = (path) => { if (expandKeys.has(path.key)) return true; if (showPaths.some((showPath) => path.isAncestorOf(showPath))) return true; if (expandPaths.some((expandPath) => path.isAncestorOf(expandPath))) return true; return false; }; const shouldShowPath = (path) => { const pathKey = path.key; if (path.depth() === 1) return true; if (expandKeys.has(pathKey)) return true; if (showKeys.has(pathKey)) return true; for (const showPath of showPaths) { if (path.isAncestorOf(showPath)) return true; } for (const expandPath of expandPaths) { if (path.isAncestorOf(expandPath)) return true; if (path.parent()?.equals(expandPath)) return true; } return false; }; import_treeverse.default.depth({ tree: { item: this.root, path: new Path([this.root.uri]), depth: 0, relToParent: null }, visit(node) { const { item, path, depth, relToParent } = node; if (shouldShowPath(path)) { rows.push({ item, path, depth, relToParent }); } }, getChildren: (node) => { const { item, path, depth } = node; if (!shouldIterateChildren(path)) { return []; } const childRels = this.graph.childrenByURI[item.uri] ?? []; const children = childRels.map((rel) => { const item2 = this.graph.getItem(rel.to); const relPredicate = !rel.inverse ? `^${rel.predicate}` : rel.predicate; return { item: item2, path: path.child(item2.uri), depth: depth + 1, relToParent: relPredicate }; }); children.sort((a, b) => sortByLabel(b.item, a.item)); return children; } }); return rows; } }; // src/graph.ts var Graph = class { roots; nodes; nodesByURI; childrenByURI; parentsByURI; constructor(nodes) { const nodesByURI = {}; const parentsByURI = {}; const childrenByURI = {}; for (const node of nodes) { nodesByURI[node.uri] = node; for (const [relURI, termURIs] of Object.entries(node.children)) { termURIs.forEach((childURI) => { if (!Object.hasOwn(childrenByURI, node.uri)) { childrenByURI[node.uri] = []; } if (!Object.hasOwn(parentsByURI, childURI)) { parentsByURI[childURI] = []; } childrenByURI[node.uri].push({ to: childURI, predicate: relURI, inverse: false }); parentsByURI[childURI].push({ to: node.uri, predicate: relURI, inverse: true }); }); } for (const [relURI, termURIs] of Object.entries(node.parents)) { termURIs.forEach((parentURI) => { if (!Object.hasOwn(parentsByURI, node.uri)) { parentsByURI[node.uri] = []; } if (!Object.hasOwn(childrenByURI, parentURI)) { childrenByURI[parentURI] = []; } parentsByURI[node.uri].push({ to: parentURI, predicate: relURI, inverse: false }); childrenByURI[parentURI].push({ to: node.uri, predicate: relURI, inverse: true }); }); } if (!Object.hasOwn(childrenByURI, node.uri)) { childrenByURI[node.uri] = []; } if (!Object.hasOwn(parentsByURI, node.uri)) { parentsByURI[node.uri] = []; } } const roots = nodes.filter( (item) => parentsByURI[item.uri].length === 0 && childrenByURI[item.uri].length > 0 ); this.roots = roots; this.nodes = nodes; this.nodesByURI = nodesByURI; this.parentsByURI = parentsByURI; this.childrenByURI = childrenByURI; } items() { return this.nodes; } getHierarchy(rootURI) { const item = this.getItem(rootURI); return new Hierarchy(item, this); } getRootHierarchies() { const ret = /* @__PURE__ */ new Map(); this.roots.forEach((root) => { ret.set(root.uri, this.getHierarchy(root.uri)); }); return ret; } getItem(uri) { const item = this.nodesByURI[uri]; if (item === void 0) { throw new Error(`No item in graph with URI ${uri}`); } return item; } findAllParents(item) { const parents = []; import_treeverse2.default.breadth({ tree: item, visit(item2) { parents.push(item2); }, getChildren: (node) => this.parentsByURI[node.uri].map((rel) => this.getItem(rel.to)).sort( sortByLabel ) }); parents.shift(); return parents; } findAllChildren(item) { const children = []; import_treeverse2.default.breadth({ tree: item, visit(item2) { children.push(item2); }, getChildren: (node) => this.childrenByURI[node.uri].map((rel) => this.getItem(rel.to)).sort( sortByLabel ) }); children.shift(); return children; } }; // src/loaders/obograph/schema.ts var z = __toESM(require("zod"), 1); var OBOMeta = z.object({ definition: z.optional( z.object({ val: z.string(), pred: z.optional(z.string()), xrefs: z.optional(z.array(z.string())), get meta() { return z.optional(OBOMeta); } }) ), comments: z.optional(z.array(z.string())), subsets: z.optional(z.array(z.string())), synonyms: z.optional( z.array( z.object({ synonymType: z.optional(z.string()), pred: z.optional(z.string()), val: z.string(), xrefs: z.optional(z.array(z.string())), get meta() { return z.optional(OBOMeta); } }) ) ), xrefs: z.optional( z.array( z.object({ lbl: z.optional(z.string()), pred: z.optional(z.string()), val: z.string(), xrefs: z.optional(z.array(z.string())), get meta() { return z.optional(OBOMeta); } }) ) ), basicPropertyValues: z.optional( z.array( z.object({ pred: z.string(), val: z.string(), xrefs: z.optional(z.array(z.string())), get meta() { return z.optional(OBOMeta); } }) ) ), version: z.optional(z.string()), deprecatd: z.optional(z.boolean()) }); var OBOGraph = z.object({ id: z.string(), lbl: z.optional(z.string()), meta: z.optional(OBOMeta), nodes: z.array( z.object({ id: z.string(), lbl: z.optional(z.string()), type: z.literal(["CLASS", "INDIVIDUAL", "PROPERTY"]), propertyType: z.optional(z.literal(["ANNOTATION", "OBJECT", "DATA"])), meta: z.optional(OBOMeta) }) ), edges: z.array( z.object({ sub: z.string(), pred: z.string(), obj: z.string(), meta: z.optional(OBOMeta) }) ) // logicalDefinitionAxioms, // domainRangeAxioms, // propertyChainAxioms, }); var OBOGraphsSchema = z.object({ meta: z.optional(OBOMeta), graphs: z.array(OBOGraph) }); // src/loaders/index.ts var GraphLoader = class { fromString(str) { const graph = this.loadGraphFromString(str); return this.parseGraph(graph); } async fromURI(uri, options) { const resp = await fetch(uri, options); if (!resp.ok) { throw Error(`Error requesting ${uri}: ${resp.status} ${resp.statusText}`); } const str = await resp.text(); const graph = await this.loadGraphFromString(str); return this.parseGraph(graph); } }; // src/loaders/obograph/index.ts var parentProperties = { is_a: "rdfs:subClassOf", "http://purl.obolibrary.org/obo/BFO_0000050": "BFO:0000050" }; var OBOGraphLoader = class extends GraphLoader { parseGraph(graph) { const terms = /* @__PURE__ */ new Map(); for (const node of graph.nodes) { if (node.type !== "CLASS") continue; const bpvs = node.meta?.basicPropertyValues || []; const replaced = !!bpvs.some( ({ pred }) => pred === "http://purl.obolibrary.org/obo/IAO_0100001" ); if (replaced) continue; const synonyms = node.meta?.synonyms || []; const definition = node.meta?.definition; terms.set(node.id, { uri: node.id, label: node.lbl || node.id, definitions: definition ? [{ value: definition.val }] : [], synonyms: synonyms.map((syn) => ({ value: syn.val })), parents: {}, children: {}, meta: node.meta, edges: [] }); } for (const edge of graph.edges) { if (edge.pred in parentProperties) { const predID = parentProperties[edge.pred]; const parents = terms.get(edge.sub)?.parents; if (!parents) continue; if (!Object.hasOwn(parents, predID)) { parents[predID] = []; } parents[predID].push(edge.obj); } else if (terms.has(edge.sub)) { terms.get(edge.sub).edges.push(edge); } } return new Graph([...terms.values()]); } loadGraphFromString(str) { const result = OBOGraphsSchema.safeParse(JSON.parse(str)); if (!result.success) { console.log(result.error.issues); throw Error(); } const graph = result.data.graphs[0]; return graph; } }; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { Graph, Hierarchy, HierarchyTree, OBOGraphLoader, Path, useNodeSearch }); //# sourceMappingURL=index.cjs.map