├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── app ├── assets │ ├── info_icon.svg │ ├── logo-yFiles-about.svg │ ├── logo-yFiles.svg │ └── yworks-logo.svg ├── copy-license.js ├── favicon.ico ├── index.html ├── package.json ├── src │ ├── app.ts │ ├── knwl.ts │ ├── lodlabelstyle.ts │ └── popup.ts ├── style.css └── vite-env.d.ts ├── data ├── DBPedia.ttl └── loadData.js ├── doc └── screenshot.png ├── package.json ├── sample-database ├── package.json ├── src │ ├── index.js │ ├── ontology.js │ └── store.js ├── test │ ├── elements.test.js │ ├── knowledge.test.js │ ├── schema.test.js │ └── store.test.js └── vite.config.js ├── server ├── api.js ├── package.json └── service.js ├── tsconfig.json └── vite.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### JetBrains template 3 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 4 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 5 | 6 | # User-specific stuff 7 | .idea/**/workspace.xml 8 | .idea/**/tasks.xml 9 | .idea/**/usage.statistics.xml 10 | .idea/**/dictionaries 11 | .idea/**/shelf 12 | 13 | # Generated files 14 | .idea/**/contentModel.xml 15 | 16 | # Sensitive or high-churn files 17 | .idea/**/dataSources/ 18 | .idea/**/dataSources.ids 19 | .idea/**/dataSources.local.xml 20 | .idea/**/sqlDataSources.xml 21 | .idea/**/dynamic.xml 22 | .idea/**/uiDesigner.xml 23 | .idea/**/dbnavigator.xml 24 | 25 | # Gradle 26 | .idea/**/gradle.xml 27 | .idea/**/libraries 28 | 29 | # Gradle and Maven with auto-import 30 | # When using Gradle or Maven with auto-import, you should exclude module files, 31 | # since they will be recreated, and may cause churn. Uncomment if using 32 | # auto-import. 33 | # .idea/modules.xml 34 | # .idea/*.iml 35 | # .idea/modules 36 | # *.iml 37 | # *.ipr 38 | 39 | # CMake 40 | cmake-build-*/ 41 | 42 | # Mongo Explorer plugin 43 | .idea/**/mongoSettings.xml 44 | 45 | # File-based project format 46 | *.iws 47 | 48 | # IntelliJ 49 | out/ 50 | 51 | # mpeltonen/sbt-idea plugin 52 | .idea_modules/ 53 | 54 | # JIRA plugin 55 | atlassian-ide-plugin.xml 56 | 57 | # Cursive Clojure plugin 58 | .idea/replstate.xml 59 | 60 | # Crashlytics plugin (for Android Studio and IntelliJ) 61 | com_crashlytics_export_strings.xml 62 | crashlytics.properties 63 | crashlytics-build.properties 64 | fabric.properties 65 | 66 | # Editor-based Rest Client 67 | .idea/httpRequests 68 | 69 | # Android studio 3.1+ serialized cache file 70 | .idea/caches/build_file_checksums.ser 71 | 72 | ### Node template 73 | # Logs 74 | logs 75 | *.log 76 | npm-debug.log* 77 | yarn-debug.log* 78 | yarn-error.log* 79 | lerna-debug.log* 80 | 81 | # Diagnostic reports (https://nodejs.org/api/report.html) 82 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 83 | 84 | # Runtime data 85 | pids 86 | *.pid 87 | *.seed 88 | *.pid.lock 89 | 90 | # Directory for instrumented libs generated by jscoverage/JSCover 91 | lib-cov 92 | 93 | # Coverage directory used by tools like istanbul 94 | coverage 95 | *.lcov 96 | 97 | # nyc test coverage 98 | .nyc_output 99 | 100 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 101 | .grunt 102 | 103 | # Bower dependency directory (https://bower.io/) 104 | bower_components 105 | 106 | # node-waf configuration 107 | .lock-wscript 108 | 109 | # Compiled binary addons (https://nodejs.org/api/addons.html) 110 | build/Release 111 | 112 | # Dependency directories 113 | node_modules/ 114 | jspm_packages/ 115 | 116 | # TypeScript v1 declaration files 117 | typings/ 118 | 119 | # TypeScript cache 120 | *.tsbuildinfo 121 | 122 | # Optional npm cache directory 123 | .npm 124 | 125 | # Optional eslint cache 126 | .eslintcache 127 | 128 | # Optional REPL history 129 | .node_repl_history 130 | 131 | # Output of 'npm pack' 132 | *.tgz 133 | 134 | # Yarn Integrity file 135 | .yarn-integrity 136 | 137 | # dotenv environment variables file 138 | .env 139 | .env.test 140 | 141 | # parcel-bundler cache (https://parceljs.org/) 142 | .cache 143 | 144 | # next.js build output 145 | .next 146 | 147 | # nuxt.js build output 148 | .nuxt 149 | 150 | # vuepress build output 151 | .vuepress/dist 152 | 153 | # Serverless directories 154 | .serverless/ 155 | 156 | # FuseBox cache 157 | .fusebox/ 158 | 159 | # DynamoDB Local files 160 | .dynamodb/ 161 | 162 | # Temporary merge files from mercurial 163 | *.orig 164 | 165 | # package lock files 166 | package-lock.json 167 | yarn.lock 168 | 169 | app/app/dist/ 170 | app/yfiles/ 171 | license.json 172 | live/ 173 | /app/database 174 | /dist/ 175 | app/assets/yfiles/ 176 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.html 2 | **/node_modules/ 3 | app/yfiles/** 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "endOfLine": "auto" 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) yWorks GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ontology Visualizer 2 | 3 | ![A screenshot of this sample application](doc/screenshot.png) 4 | 5 | This repository contains the sample application for the yFiles use case about 6 | an [Ontology Visualizer](https://www.yworks.com/use-case/visualizing-an-ontology). 7 | The app displays a sample ontology diagram that you can explore. 8 | 9 | ## See also 10 | 11 | - [Watch the introductory video](https://player.vimeo.com/video/389490579) of this app 12 | - [Read the article](https://www.yworks.com/use-case/visualizing-an-ontology) about an _Ontology Visualizer_ 13 | - [Learn more about yFiles](https://www.yworks.com/products/yfiles), the software library for visualizing, editing, and analyzing graphs 14 | 15 | If you have any questions or suggestions, email us at [consulting@yworks.com](mailto:consulting@yworks.com) 16 | or call [+49 7071 9709050](tel:+4970719709050). 17 | 18 | ## How to run this app 19 | 20 | You need a copy of the [yFiles for HTML](https://www.yworks.com/products/yfiles-for-html) diagramming library in order 21 | to run this application. You can download a free test version of yFiles in the 22 | [yWorks Customer Center](https://my.yworks.com/signup?product=YFILES_HTML_EVAL). 23 | 24 | Checkout this project, then extract the yFiles for HTML package to a directory next to it, e.g.: 25 | 26 | ``` 27 | documents 28 | |-- ontology-visualizer 29 | |-- yFiles-for-HTML-Complete-3.0-Evaluation 30 | ``` 31 | 32 | Afterward, enter the `ontology-visualizer` directory and run the usual commands 33 | 34 | ``` 35 | npm install 36 | ``` 37 | 38 | followed by 39 | 40 | ``` 41 | npm run load-data 42 | npm run start-server 43 | npm run dev 44 | ``` 45 | 46 | ## About 47 | 48 | This application is powered by [yFiles for HTML](https://www.yworks.com/products/yfiles-for-html), the powerful 49 | diagramming library. 50 | 51 | Turn your data into clear diagrams with the help of unequaled automatic diagram layout, use rich visualizations for your 52 | diagram elements, and give your users an intuitive interface for smooth interaction. 53 | 54 | You can learn more about the many features that come with yFiles 55 | on the [yFiles Features Overview](https://www.yworks.com/products/yfiles/features). 56 | 57 | If you want to try it for yourself, obtain a free test version of yFiles in the 58 | [yWorks Customer Center](https://my.yworks.com/signup?product=YFILES_HTML_EVAL). 59 | 60 | ## Contact 61 | 62 | If you have any questions or suggestions, email us at [consulting@yworks.com](mailto:consulting@yworks.com) 63 | or call [+49 7071 9709050](tel:+4970719709050). 64 | 65 | ## Data 66 | 67 | The app shows data from [DBpedia](http://dbpedia.org/ontology/) 68 | 69 | ## License 70 | 71 | The MIT License (MIT) 72 | 73 | Copyright (c) yWorks GmbH 74 | 75 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 76 | 77 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 78 | 79 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 80 | -------------------------------------------------------------------------------- /app/assets/info_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/assets/logo-yFiles-about.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/assets/logo-yFiles.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/assets/yworks-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 11 | 15 | 29 | 30 | 33 | 35 | 37 | 40 | 44 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /app/copy-license.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copies the license.json file from the 'yfiles' dependency of the 'demos/package.json' file to the 'demos' directory. 3 | */ 4 | 5 | import * as fs from 'node:fs' 6 | import * as path from 'node:path' 7 | import { dirname } from 'node:path' 8 | import { fileURLToPath } from 'node:url' 9 | 10 | const __dirname = dirname(fileURLToPath(import.meta.url)) 11 | 12 | const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '/package.json'), 'utf8')) 13 | const yFilesTarFile = packageJson?.dependencies?.['@yfiles/yfiles'] 14 | const destDir = path.resolve(__dirname) 15 | 16 | if (!yFilesTarFile) { 17 | console.log( 18 | `\nyFiles license was NOT copied because the 'yfiles' dependency was not detected.` + 19 | `\nPlease add your own yFiles license to the demo.`, 20 | ) 21 | process.exit(1) 22 | } 23 | 24 | const licenseFile = path.join(__dirname, path.dirname(yFilesTarFile), 'license.json') 25 | if (!fs.existsSync(licenseFile)) { 26 | console.log( 27 | `\nyFiles license was NOT copied from '${licenseFile}' because the file does not exist.` + 28 | `\nPlease add your own yFiles license to the demo.`, 29 | ) 30 | process.exit(1) 31 | } 32 | 33 | if (!fs.existsSync(destDir)) { 34 | fs.mkdirSync(destDir) 35 | } 36 | 37 | fs.copyFile(licenseFile, path.join(destDir, 'license.json'), (err) => { 38 | if (err) { 39 | console.log( 40 | `\nyFiles license was NOT copied from '${licenseFile}'.` + 41 | `\nPlease add your own yFiles license to the demo.`, 42 | ) 43 | } else { 44 | console.log(`\nyFiles license was copied from '${licenseFile}'.`) 45 | } 46 | }) 47 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yWorks/ontology-visualizer/42d8c7844502de4e1a4d5977443eee9cc9711ea2/app/favicon.ico -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Ontology Visualizer [yFiles Use Case] 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 65 | 66 |
67 |
68 |
69 |
70 |
71 | 72 |
73 | 74 |
75 | 76 |
Uri:
78 |
Parent:
80 |
81 |
82 |
83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 |
94 |
95 | 96 | 145 | 146 | 151 | 152 | 153 | 154 | 155 | 156 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ontology-visualizer-app", 3 | "description": "The sample app for the yFiles use case Ontology Visualizer", 4 | "homepage": "https://www.yworks.com/use-case/ontology-visualizer", 5 | "version": "2.0.0", 6 | "type": "module", 7 | "private": true, 8 | "scripts": { 9 | "postinstall": "node copy-license.js", 10 | "dev": "vite" 11 | }, 12 | "dependencies": { 13 | "@yfiles/yfiles": "../../yFiles-for-HTML-Complete-3.0-Evaluation/lib/yfiles-30.0.0+eval.tgz" 14 | }, 15 | "devDependencies": { 16 | "typescript": "~5.8.2", 17 | "vite": "^6.2.4" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/app.ts: -------------------------------------------------------------------------------- 1 | import { Knwl } from './knwl' 2 | import HTMLPopupSupport from './popup.ts' 3 | 4 | import { 5 | CircularLayout, 6 | Cursor, 7 | EdgePathLabelModel, 8 | ExteriorNodeLabelModel, 9 | ExteriorNodeLabelModelPosition, 10 | Font, 11 | FreeNodeLabelModel, 12 | GraphBuilder, 13 | GraphComponent, 14 | GraphInputMode, 15 | GraphItemTypes, 16 | GraphOverviewComponent, 17 | GraphOverviewRenderer, 18 | GraphViewerInputMode, 19 | HierarchicalLayout, 20 | IEdge, 21 | ILayoutAlgorithm, 22 | IModelItem, 23 | INode, 24 | Insets, 25 | IRenderContext, 26 | ItemEventArgs, 27 | LabelStyle, 28 | LayoutExecutor, 29 | License, 30 | ModifierKeys, 31 | OrganicLayout, 32 | Point, 33 | PolylineEdgeStyle, 34 | ShapeNodeStyle, 35 | Size, 36 | Stroke, 37 | TextRenderSupport, 38 | TextWrapping, 39 | } from '@yfiles/yfiles' 40 | import license from '../license.json' 41 | import { LODLabelStyleDecorator } from './lodlabelstyle.ts' 42 | 43 | // Tell the library about the license contents 44 | License.value = license 45 | 46 | // We need to load the yfiles/view-layout-bridge module explicitly to prevent the webpack 47 | // tree shaker from removing this dependency which is needed for 'morphLayout' in this app. 48 | LayoutExecutor.ensure() 49 | 50 | const defaultFontFamily = 51 | '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"' 52 | 53 | class GraphOverviewCanvasRenderer extends GraphOverviewRenderer { 54 | /** 55 | * Paints the path of the edge in a very light gray. 56 | */ 57 | paintEdge(_: IRenderContext, ctx: CanvasRenderingContext2D, edge: IEdge) { 58 | ctx.strokeStyle = '#f7f7f7' 59 | ctx.beginPath() 60 | ctx.moveTo(edge.sourcePort.location.x, edge.sourcePort.location.y) 61 | edge.bends.forEach((bend) => { 62 | ctx.lineTo(bend.location.x, bend.location.y) 63 | }) 64 | ctx.lineTo(edge.targetPort.location.x, edge.targetPort.location.y) 65 | ctx.stroke() 66 | } 67 | 68 | /** 69 | * Paints the outline of the group node in a very light gray. 70 | */ 71 | paintGroupNode(_: IRenderContext, ctx: CanvasRenderingContext2D, node: INode) { 72 | ctx.strokeStyle = '#f7f7f7' 73 | ctx.strokeRect(node.layout.x, node.layout.y, node.layout.width, node.layout.height) 74 | } 75 | 76 | /** 77 | * Paints the rectangle of the node in a very light gray 78 | */ 79 | paintNode(_: IRenderContext, ctx: CanvasRenderingContext2D, node: INode) { 80 | ctx.fillStyle = '#f7f7f7' 81 | ctx.fillRect(node.layout.x, node.layout.y, node.layout.width, node.layout.height) 82 | } 83 | } 84 | 85 | class App { 86 | private knwl: any 87 | private graphComponent: GraphComponent = null! 88 | private overviewComponent: GraphOverviewComponent = null! 89 | private nodePopup: HTMLPopupSupport = null! 90 | 91 | initialize() { 92 | this.knwl = new Knwl('http://localhost:3001/api/') 93 | 94 | // create a GraphComponent 95 | this.graphComponent = new GraphComponent('#graph') 96 | 97 | this.graphComponent.graph.nodeDefaults.size = new Size(50, 50) 98 | this.graphComponent.graph.nodeDefaults.style = new ShapeNodeStyle({ 99 | shape: 'ellipse', 100 | stroke: '2px orange', 101 | fill: 'white', 102 | }) 103 | this.graphComponent.graph.edgeDefaults.style = new PolylineEdgeStyle({ 104 | stroke: Stroke.WHITE_SMOKE, 105 | targetArrow: 'white medium triangle', 106 | smoothingLength: 10, 107 | }) 108 | 109 | this.graphComponent.graph.nodeDefaults.labels.style = new LODLabelStyleDecorator( 110 | new LabelStyle({ 111 | shape: 'round-rectangle', 112 | backgroundFill: 'rgba(255,255,255,0.9)', 113 | textFill: '#636363', 114 | font: `12px ${defaultFontFamily}`, 115 | padding: new Insets(1, 5, 2, 5), 116 | }), 117 | 0.75, 118 | ) 119 | this.graphComponent.graph.nodeDefaults.labels.layoutParameter = 120 | FreeNodeLabelModel.INSTANCE.createParameter( 121 | new Point(0.5, 1), 122 | new Point(0, 0), 123 | new Point(0.5, 0.5), 124 | ) 125 | 126 | this.initializeInputMode() 127 | this.initializePopups() 128 | this.initializeInteractions() 129 | this.knwl.getSimplifiedOntologyGraph().then(async (data: { nodes: any; links: any }) => { 130 | await this.assembleGraph(data) 131 | }) 132 | 133 | this.overviewComponent = new GraphOverviewComponent('overviewComponent', this.graphComponent) 134 | this.overviewComponent.graphOverviewRenderer = new GraphOverviewCanvasRenderer() 135 | } 136 | 137 | initializePopups() { 138 | // Creates a label model parameter that is used to position the node pop-up 139 | const nodeLabelModel = new ExteriorNodeLabelModel({ margins: 10 }) 140 | 141 | // Creates the pop-up for the node pop-up template 142 | this.nodePopup = new HTMLPopupSupport( 143 | this.graphComponent, 144 | window.document.getElementById('nodePopupContent')!, 145 | nodeLabelModel.createParameter(ExteriorNodeLabelModelPosition.TOP), 146 | ) 147 | 148 | // Creates the edge pop-up for the edge pop-up template with a suitable label model parameter 149 | // We use the EdgePathLabelModel for the edge pop-up 150 | const edgeLabelModel = new EdgePathLabelModel({ autoRotation: false }) 151 | 152 | // Creates the pop-up for the edge pop-up template 153 | const edgePopup = new HTMLPopupSupport( 154 | this.graphComponent, 155 | window.document.getElementById('edgePopupContent')!, 156 | edgeLabelModel.createRatioParameter(), 157 | ) 158 | 159 | // The following works with both GraphEditorInputMode and GraphViewerInputMode 160 | const inputMode = this.graphComponent.inputMode as GraphInputMode 161 | 162 | // The pop-up is shown for the currentItem thus nodes and edges should be focusable 163 | inputMode.focusableItems = GraphItemTypes.NODE | GraphItemTypes.EDGE 164 | 165 | // Register a listener that shows the pop-up for the currentItem 166 | this.graphComponent.selection.addEventListener( 167 | 'item-added', 168 | (event: ItemEventArgs): void => { 169 | const item = event.item 170 | if (item instanceof INode) { 171 | // update data in node pop-up 172 | this.updateNodePopupContent(this.nodePopup, item) 173 | // open node pop-up and hide edge pop-up 174 | this.nodePopup.currentItem = item 175 | edgePopup.currentItem = null 176 | } else if (item instanceof IEdge) { 177 | // update data in edge pop-up 178 | this.updateEdgePopupContent(edgePopup, item) 179 | // open edge pop-up and node edge pop-up 180 | edgePopup.currentItem = item 181 | this.nodePopup.currentItem = null 182 | } else { 183 | this.nodePopup.currentItem = null 184 | edgePopup.currentItem = null 185 | } 186 | }, 187 | ) 188 | this.graphComponent.selection.addEventListener( 189 | 'item-removed', 190 | (event: ItemEventArgs): void => { 191 | if (!event.item) { 192 | this.nodePopup.currentItem = null 193 | edgePopup.currentItem = null 194 | return 195 | } 196 | }, 197 | ) 198 | 199 | // On clicks on empty space, hide the popups 200 | inputMode.addEventListener('canvas-clicked', () => { 201 | this.nodePopup.currentItem = null 202 | edgePopup.currentItem = null 203 | }) 204 | 205 | // On press of the ESCAPE key, set currentItem to null to hide the pop-ups 206 | inputMode.keyboardInputMode.addKeyBinding('Escape', ModifierKeys.NONE, () => { 207 | this.graphComponent.currentItem = null 208 | }) 209 | } 210 | 211 | updateNodePopupContent(nodePopup: HTMLPopupSupport, node: INode) { 212 | // get business data from node tag 213 | const id = node.tag 214 | this.knwl.getClass(id).then((data: any) => { 215 | // get all divs in the pop-up 216 | const divs = nodePopup.div.getElementsByTagName('div') 217 | for (let i = 0; i < divs.length; i++) { 218 | const div = divs.item(i)! 219 | if (div.hasAttribute('data-id')) { 220 | // if div has a 'data-id' attribute, get content from the business data 221 | const id = div.getAttribute('data-id')! 222 | const link = div.children[1] as HTMLLinkElement 223 | if (id === 'id') { 224 | //make a rather than text 225 | const short = this.toShortForm(data[id]) 226 | link.href = `http://dbpedia.org/ontology/${short}` 227 | link.textContent = `dbpedia:${short}` 228 | } else { 229 | link.textContent = data[id] 230 | } 231 | } 232 | } 233 | }) 234 | } 235 | 236 | updateEdgePopupContent(edgePopup: HTMLPopupSupport, edge: IEdge) { 237 | // get business data from node tags 238 | const edgeData = edge.tag 239 | const sourceData = edge.sourcePort.owner.tag 240 | const targetData = edge.targetPort.owner.tag 241 | 242 | const font = new Font(defaultFontFamily, 12) 243 | const size = new Size(200, 20) 244 | const wrapping = TextWrapping.WRAP_CHARACTER_ELLIPSIS 245 | 246 | const sourceText = edgePopup.div.querySelector('*[data-id="sourceName"]') as SVGTextElement 247 | TextRenderSupport.addText(sourceText, this.toShortForm(sourceData), font, size, wrapping) 248 | sourceText.setAttribute('title', this.toShortForm(sourceData)) 249 | 250 | const linkText = edgePopup.div.querySelector('*[data-id="linkName"]') as SVGGElement 251 | const linkSize = new Size(170, 20) 252 | const linkFont = new Font(defaultFontFamily, 12, 'normal', 'bold') 253 | const linkShort = this.toShortForm(edgeData.uri) 254 | TextRenderSupport.addText(linkText, linkShort, linkFont, linkSize, wrapping) 255 | const linkAnchor = linkText.parentElement! 256 | linkAnchor.setAttribute('href', `http://dbpedia.org/ontology/${linkShort}`) 257 | linkText.setAttribute('title', linkShort) 258 | 259 | const targetText = edgePopup.div.querySelector('*[data-id="targetName"]') as SVGTextElement 260 | TextRenderSupport.addText(targetText, this.toShortForm(targetData), font, size, wrapping) 261 | targetText.setAttribute('title', this.toShortForm(targetData)) 262 | } 263 | 264 | /** 265 | * Shortens the Uri to something one can display. 266 | */ 267 | toShortForm(uri: any | Array): string | any { 268 | if (typeof uri === 'string') { 269 | uri = uri 270 | .replace('http://dbpedia.org/ontology', '') 271 | .replace('http://www.w3.org/1999/02/22-rdf-syntax-ns#', '') 272 | .replace('http://www.w3.org/2000/01/rdf-schema#', '') 273 | .replace('http://www.w3.org/2002/07/owl#', '') 274 | if (uri.indexOf('/') > -1) { 275 | uri = uri.slice(uri.indexOf('/') + 1) 276 | } 277 | if (uri.indexOf('#') > -1) { 278 | uri = uri.slice(uri.lastIndexOf('#') + 1) 279 | } 280 | return uri 281 | } else if (Array.isArray(uri)) { 282 | uri.map((u: any) => this.toShortForm(u)) 283 | } else { 284 | return uri.toString() 285 | } 286 | } 287 | 288 | async assembleGraph(data: { nodes: any; links: any }) { 289 | const builder = new GraphBuilder(this.graphComponent.graph) 290 | builder.createNodesSource({ data: data.nodes, id: null }) 291 | builder.createEdgesSource({ data: data.links, sourceId: 'from', targetId: 'to' }) 292 | builder.addEventListener('node-created', (event, sender) => { 293 | sender.graph.addLabel(event.item, this.toShortForm(event.item.tag)) 294 | }) 295 | this.graphComponent.graph = builder.buildGraph() 296 | await this.graphComponent.fitGraphBounds() 297 | 298 | await this.layoutHierarchical() 299 | } 300 | 301 | constructor() { 302 | this.initialize() 303 | } 304 | 305 | initializeInputMode() { 306 | const mode = new GraphViewerInputMode({ 307 | toolTipItems: GraphItemTypes.NODE, 308 | selectableItems: GraphItemTypes.NODE | GraphItemTypes.EDGE, 309 | marqueeSelectableItems: GraphItemTypes.NONE, 310 | }) 311 | 312 | mode.toolTipInputMode.toolTipLocationOffset = new Point(10, 10) 313 | mode.addEventListener('query-item-tool-tip', (event) => { 314 | if (event.item instanceof INode && !event.handled) { 315 | const nodeName = event.item.tag 316 | if (nodeName != null) { 317 | event.toolTip = nodeName 318 | event.handled = true 319 | } 320 | } 321 | }) 322 | mode.itemHoverInputMode.hoverCursor = Cursor.POINTER 323 | mode.itemHoverInputMode.hoverItems = GraphItemTypes.NODE | GraphItemTypes.EDGE 324 | mode.itemHoverInputMode.ignoreInvalidItems = true 325 | mode.itemHoverInputMode.addEventListener('hovered-item-changed', (event) => { 326 | const item = event.item 327 | const highlightIndicatorManager = this.graphComponent.highlightIndicatorManager 328 | highlightIndicatorManager.items.clear() 329 | if (item) { 330 | highlightIndicatorManager.items.add(item) 331 | if (item instanceof INode) { 332 | this.graphComponent.graph.edgesAt(item).forEach((edge) => { 333 | highlightIndicatorManager.items.add(edge) 334 | }) 335 | } else if (item instanceof IEdge) { 336 | highlightIndicatorManager.items.add(item.sourceNode) 337 | highlightIndicatorManager.items.add(item.targetNode) 338 | } 339 | } 340 | }) 341 | this.graphComponent.inputMode = mode 342 | } 343 | 344 | layoutCircular() { 345 | return this.runLayout( 346 | new CircularLayout({ 347 | partitionDescriptor: { style: 'disk' }, 348 | }), 349 | ) 350 | } 351 | 352 | layoutOrganic() { 353 | return this.runLayout( 354 | new OrganicLayout({ 355 | defaultPreferredEdgeLength: 200, 356 | }), 357 | ) 358 | } 359 | 360 | layoutHierarchical() { 361 | return this.runLayout( 362 | new HierarchicalLayout({ 363 | nodeLabelPlacement: 'consider', 364 | defaultEdgeDescriptor: { 365 | routingStyleDescriptor: { 366 | defaultRoutingStyle: 'polyline', 367 | selfLoopRoutingStyle: 'octilinear', 368 | backLoopRoutingStyle: 'octilinear', 369 | }, 370 | }, 371 | }), 372 | ) 373 | } 374 | 375 | runLayout(layout: ILayoutAlgorithm) { 376 | return this.graphComponent.applyLayoutAnimated(layout) 377 | } 378 | 379 | async zoomToLocation(item: INode) { 380 | const location = this.getFocusPoint(item) 381 | 382 | await this.graphComponent.zoomToAnimated(1.5, location) 383 | // display the popup info as well 384 | this.updateNodePopupContent(this.nodePopup, item) 385 | this.nodePopup.currentItem = item 386 | } 387 | 388 | /** 389 | * Returns the point corresponding to the given INode. 390 | */ 391 | getFocusPoint(item: IModelItem): Point { 392 | if (item instanceof IEdge) { 393 | // If the source and the target node are in the view port, then zoom to the middle point of the edge 394 | const targetNodeCenter = item.targetNode.layout.center 395 | const sourceNodeCenter = item.sourceNode.layout.center 396 | const viewport = this.graphComponent.viewport 397 | if (viewport.contains(targetNodeCenter) && viewport.contains(sourceNodeCenter)) { 398 | return new Point( 399 | (sourceNodeCenter.x + targetNodeCenter.x) / 2, 400 | (sourceNodeCenter.y + targetNodeCenter.y) / 2, 401 | ) 402 | } else { 403 | if ( 404 | viewport.center.subtract(targetNodeCenter).vectorLength < 405 | viewport.center.subtract(sourceNodeCenter).vectorLength 406 | ) { 407 | // If the source node is out of the view port, then zoom to it 408 | return sourceNodeCenter 409 | } else { 410 | // Else zoom to the target node 411 | return targetNodeCenter 412 | } 413 | } 414 | } else if (item instanceof INode) { 415 | return item.layout.center 416 | } 417 | return Point.ORIGIN 418 | } 419 | 420 | /** 421 | * Defines the various interactions in the app. 422 | */ 423 | initializeInteractions() { 424 | document.querySelector('#navFit')!.addEventListener('click', () => { 425 | void this.graphComponent.fitGraphBounds({ animated: true }) 426 | }) 427 | 428 | const overviewContainer = document.querySelector('.overview-container')! 429 | document.querySelector('#navOverview')!.addEventListener('click', () => { 430 | overviewContainer.style.opacity = overviewContainer.style.opacity === '0' ? '1' : '0' 431 | }) 432 | document.querySelector('#navCircular')!.addEventListener('click', () => { 433 | void this.layoutCircular() 434 | }) 435 | document.querySelector('#navOrganic')!.addEventListener('click', () => { 436 | void this.layoutOrganic() 437 | }) 438 | document.querySelector('#navHierarchical')!.addEventListener('click', () => { 439 | void this.layoutHierarchical() 440 | }) 441 | 442 | const search = document.querySelector('#search-stuff')! 443 | search.addEventListener('keypress', (e) => { 444 | if (e.code === 'Enter' || e.code === 'NumpadEnter') { 445 | const term = search.value 446 | search.value = '' 447 | const ns = this.graphComponent.graph.nodes 448 | for (let i = 0; i < ns.size; i++) { 449 | const n = ns.get(i) 450 | if (n.tag.toLowerCase().indexOf(term) > -1) { 451 | void this.zoomToLocation(n) 452 | return 453 | } 454 | } 455 | } 456 | }) 457 | } 458 | } 459 | 460 | new App() 461 | -------------------------------------------------------------------------------- /app/src/knwl.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Proxy to the backend. 3 | */ 4 | export class Knwl { 5 | private readonly url: string 6 | 7 | constructor(url: string) { 8 | if (!url) { 9 | throw new Error('Missing url in Knwl ctor.') 10 | } 11 | if (!url.endsWith('/')) { 12 | url += '/' 13 | } 14 | this.url = url 15 | } 16 | 17 | /** 18 | * Gets the ontology class with the specified name or uri. 19 | * @param className name or uri 20 | * @param includeProps 21 | */ 22 | async getClass(className: string, includeProps = false) { 23 | const response = await fetch(`${this.url}getClass`, { 24 | method: 'POST', 25 | headers: { 'Content-Type': 'application/json' }, 26 | body: JSON.stringify({ className: className, includeProps: includeProps }), 27 | }) 28 | 29 | if (!response.ok) { 30 | throw new Error(`HTTP error! status: ${response.status}`) 31 | } 32 | 33 | return await response.json() 34 | } 35 | 36 | /** 37 | * Returns the connected ontology as a node+links graph structure. 38 | */ 39 | async getSimplifiedOntologyGraph() { 40 | const response = await fetch(`${this.url}getSimplifiedOntologyGraph`, { 41 | method: 'GET', 42 | }) 43 | 44 | if (!response.ok) { 45 | throw new Error(`HTTP error! status: ${response.status}`) 46 | } 47 | 48 | return await response.json() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/src/lodlabelstyle.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Constructor, 3 | GeneralPath, 4 | ICanvasContext, 5 | IInputModeContext, 6 | ILabel, 7 | ILabelStyleRenderer, 8 | IRenderContext, 9 | LabelStyle, 10 | LabelStyleBase, 11 | Point, 12 | Rect, 13 | Size, 14 | Visual, 15 | } from '@yfiles/yfiles' 16 | 17 | export class LODLabelStyleDecorator extends LabelStyleBase { 18 | get renderer(): ILabelStyleRenderer { 19 | return this.wrappedStyle.renderer 20 | } 21 | 22 | constructor( 23 | private wrappedStyle: LabelStyle, 24 | private zoomThreshold: number, 25 | ) { 26 | super() 27 | } 28 | 29 | createVisual(context: IRenderContext, label: ILabel) { 30 | return context.canvasComponent.zoom < this.zoomThreshold 31 | ? null 32 | : this.wrappedStyle.renderer.getVisualCreator(label, this.wrappedStyle).createVisual(context) 33 | } 34 | 35 | updateVisual(context: IRenderContext, oldVisual: Visual, label: ILabel) { 36 | return context.canvasComponent.zoom < this.zoomThreshold 37 | ? null 38 | : this.wrappedStyle.renderer 39 | .getVisualCreator(label, this.wrappedStyle) 40 | .updateVisual(context, oldVisual) 41 | } 42 | 43 | protected getPreferredSize(label: ILabel): Size { 44 | return this.wrappedStyle.renderer.getPreferredSize(label, this.wrappedStyle) 45 | } 46 | 47 | protected getBounds(context: ICanvasContext, label: ILabel): Rect { 48 | return context.canvasComponent.zoom < this.zoomThreshold 49 | ? Rect.EMPTY 50 | : super.getBounds(context, label) 51 | } 52 | 53 | protected isInPath(context: IInputModeContext, path: GeneralPath, label: ILabel): boolean { 54 | return ( 55 | context.canvasComponent.zoom >= this.zoomThreshold && 56 | this.wrappedStyle.renderer.getLassoTestable(label, this.wrappedStyle).isInPath(context, path) 57 | ) 58 | } 59 | 60 | protected isVisible(context: ICanvasContext, rectangle: Rect, label: ILabel): boolean { 61 | return ( 62 | context.canvasComponent.zoom >= this.zoomThreshold && 63 | this.wrappedStyle.renderer 64 | .getVisibilityTestable(label, this.wrappedStyle) 65 | .isVisible(context, rectangle) 66 | ) 67 | } 68 | 69 | protected isHit(context: IInputModeContext, location: Point, label: ILabel): boolean { 70 | return ( 71 | context.canvasComponent.zoom >= this.zoomThreshold && 72 | this.wrappedStyle.renderer.getHitTestable(label, this.wrappedStyle).isHit(context, location) 73 | ) 74 | } 75 | 76 | protected isInBox(context: IInputModeContext, rectangle: Rect, label: ILabel): boolean { 77 | return ( 78 | context.canvasComponent.zoom >= this.zoomThreshold && 79 | this.wrappedStyle.renderer 80 | .getMarqueeTestable(label, this.wrappedStyle) 81 | .isInBox(context, rectangle) 82 | ) 83 | } 84 | 85 | protected lookup(label: ILabel, type: Constructor): any { 86 | return this.wrappedStyle.renderer.getContext(label, this.wrappedStyle).lookup(type) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /app/src/popup.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphComponent, 3 | IEdge, 4 | ILabelModelParameter, 5 | ILabelOwner, 6 | IModelItem, 7 | Point, 8 | SimpleLabel, 9 | Size, 10 | } from '@yfiles/yfiles' 11 | 12 | /** 13 | * This class adds an HTML panel on top of the contents of the GraphComponent that can 14 | * display arbitrary information about a {@link IModelItem graph item}. 15 | * In order to not interfere with the positioning of the pop-up, HTML content 16 | * should be added as ancestor of the {@link HTMLPopupSupport#div div element}, and 17 | * use relative positioning. This implementation uses a 18 | * {@link ILabelModelParameter label model parameter} to determine 19 | * the position of the pop-up. 20 | */ 21 | export default class HTMLPopupSupport { 22 | private $currentItem: IModelItem | null 23 | private $dirty: boolean 24 | 25 | /** 26 | * Constructor that takes the graphComponent, the container div element and an ILabelModelParameter 27 | * to determine the relative position of the popup. 28 | */ 29 | constructor( 30 | private graphComponent: GraphComponent, 31 | public div: HTMLElement, 32 | public labelModelParameter: ILabelModelParameter, 33 | ) { 34 | this.$currentItem = null 35 | this.$dirty = false 36 | 37 | // make the popup invisible 38 | div.style.opacity = '0' 39 | div.style.display = 'none' 40 | 41 | this.registerListeners() 42 | } 43 | 44 | /** 45 | * Sets the {@link IModelItem item} to display information for. 46 | * Setting this property to a value other than null shows the pop-up. 47 | * Setting the property to null hides the pop-up. 48 | * @type {IModelItem} 49 | */ 50 | set currentItem(value: IModelItem | null) { 51 | if (value === this.$currentItem) { 52 | return 53 | } 54 | this.$currentItem = value 55 | if (value !== null) { 56 | this.show() 57 | } else { 58 | this.hide() 59 | } 60 | } 61 | 62 | /** 63 | * Gets the {@link IModelItem item} to display information for. 64 | * @type {IModelItem} 65 | */ 66 | get currentItem(): IModelItem | null { 67 | return this.$currentItem 68 | } 69 | 70 | /** 71 | * Sets the flag for the current position is no longer valid. 72 | * @param value true if the current position is no longer valid, false otherwise 73 | * @type {boolean} 74 | */ 75 | set dirty(value) { 76 | this.$dirty = value 77 | } 78 | 79 | /** 80 | * Gets the flag for the current position is no longer valid. 81 | * @type {boolean} 82 | */ 83 | get dirty() { 84 | return this.$dirty 85 | } 86 | 87 | /** 88 | * Registers viewport, node bounds changes and visual tree listeners to the given graphComponent. 89 | */ 90 | registerListeners() { 91 | // Adds listener for viewport changes 92 | this.graphComponent.addEventListener('viewport-changed', (_) => { 93 | if (this.currentItem) { 94 | this.dirty = true 95 | } 96 | }) 97 | 98 | // Adds listeners for node bounds changes 99 | this.graphComponent.graph.addEventListener('node-layout-changed', (node, _) => { 100 | if ( 101 | this.currentItem && 102 | (this.currentItem === node || 103 | (this.currentItem instanceof IEdge && 104 | (node === this.currentItem.sourcePort.owner || 105 | node === this.currentItem.targetPort.owner))) 106 | ) { 107 | this.dirty = true 108 | } 109 | }) 110 | 111 | // Adds listener for updates of the visual tree 112 | this.graphComponent.addEventListener('updated-visual', (_) => { 113 | if (this.currentItem && this.dirty) { 114 | this.dirty = false 115 | this.updateLocation() 116 | } 117 | }) 118 | } 119 | 120 | /** 121 | * Makes this pop-up visible. 122 | */ 123 | show() { 124 | this.div.style.display = 'block' 125 | setTimeout(() => { 126 | this.div.style.opacity = '1' 127 | }, 0) 128 | this.updateLocation() 129 | } 130 | 131 | /** 132 | * Hides this pop-up. 133 | */ 134 | hide() { 135 | const parent = this.div.parentNode! 136 | const popupClone = this.div.cloneNode(true) as Element 137 | popupClone.setAttribute('class', `${popupClone.getAttribute('class')} popupContentClone`) 138 | parent.appendChild(popupClone) 139 | // fade the clone out, then remove it from the DOM. Both actions need to be timed. 140 | setTimeout(() => { 141 | popupClone.setAttribute('style', `${popupClone.getAttribute('style')} opacity: 0;`) 142 | setTimeout(() => { 143 | parent.removeChild(popupClone) 144 | }, 300) 145 | }, 0) 146 | this.div.style.opacity = '0' 147 | this.div.style.display = 'none' 148 | } 149 | 150 | /** 151 | * Changes the location of this pop-up to the location calculated by the 152 | * {@link HTMLPopupSupport#labelModelParameter}. Currently, this implementation does not support rotated pop-ups. 153 | */ 154 | updateLocation() { 155 | if (!this.currentItem || !this.labelModelParameter) { 156 | return 157 | } 158 | const width = this.div.clientWidth 159 | const height = this.div.clientHeight 160 | const zoom = this.graphComponent.zoom 161 | 162 | const dummyLabel = new SimpleLabel( 163 | this.currentItem as ILabelOwner, 164 | '', 165 | this.labelModelParameter, 166 | ) 167 | dummyLabel.preferredSize = new Size(width / zoom, height / zoom) 168 | const newLayout = this.labelModelParameter.model.getGeometry( 169 | dummyLabel, 170 | this.labelModelParameter, 171 | ) 172 | this.setLocation(newLayout.anchorX, newLayout.anchorY - (height + 10) / zoom, width, height) 173 | } 174 | 175 | /** 176 | * Sets the location of this pop-up to the given world coordinates. 177 | * @param {number} x The target x-coordinate of the pop-up. 178 | * @param {number} y The target y-coordinate of the pop-up. 179 | * @param {number} width 180 | * @param {number} height 181 | */ 182 | setLocation(x: number, y: number, width: number, height: number) { 183 | // Calculate the view coordinates since we have to place the div in the regular HTML coordinate space 184 | const viewPoint = this.graphComponent.worldToViewCoordinates(new Point(x, y)) 185 | const gcSize = this.graphComponent.innerSize 186 | const padding = 15 187 | const left = Math.min(gcSize.width - width - padding, Math.max(padding, viewPoint.x)) 188 | const top = Math.min(gcSize.height - height - padding, Math.max(padding, viewPoint.y)) 189 | this.div.style.left = `${left}px` 190 | this.div.style.top = `${top}px` 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /app/style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | width: 100%; 3 | height: 100%; 4 | padding: 0; 5 | margin: 0; 6 | background-color: #343a408f; 7 | font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji" 8 | } 9 | 10 | body { 11 | display: flex; 12 | flex-direction: column; 13 | } 14 | 15 | #graph { 16 | flex: 1; 17 | } 18 | 19 | @media (max-width: 460px) { 20 | #github-icon { 21 | display: none; 22 | } 23 | } 24 | 25 | @media (max-width: 405px) { 26 | #info-icon { 27 | display: none; 28 | } 29 | } 30 | 31 | .ref-icon { 32 | vertical-align: middle; 33 | color: #ffffff; 34 | font-family: Roboto, sans-serif; 35 | background-color: transparent; 36 | border: none; 37 | padding: 0; 38 | margin:0; 39 | } 40 | 41 | .ref-icon img { 42 | cursor: pointer; 43 | width: 32px; 44 | margin-right: 5px; 45 | } 46 | 47 | .navbar { 48 | display: flex; 49 | flex-direction: row; 50 | justify-content: space-between; /* Space between items */ 51 | align-items: center; /* Vertically center items */ 52 | background-color: #343a40; /* Dark background color */ 53 | color: white; /* Light text color */ 54 | width: 100%; /* Full width */ 55 | } 56 | 57 | .navbar-left { 58 | display: flex; 59 | flex-direction: row; 60 | margin-left: 10px; 61 | } 62 | 63 | .navbar-brand { 64 | display: flex; 65 | align-items: center; /* Center logo and title vertically */ 66 | color: whitesmoke; /* Title color */ 67 | text-decoration: none; /* Remove default link styles */ 68 | font-family: Roboto, sans-serif; 69 | font-size: 1.25rem; 70 | margin: 0 1rem 0 10px; 71 | } 72 | 73 | .brand-title { 74 | margin-left: 10px; /* Space between logo and title */ 75 | 76 | } 77 | 78 | .navbar-icons { 79 | display: flex; 80 | align-items: center; /* Center icons vertically */ 81 | margin: 0 10px 0 0; 82 | } 83 | 84 | .nav-links { 85 | display: flex; /* Use flexbox for nav links */ 86 | list-style: none; /* Remove bullet points */ 87 | padding: 0; 88 | } 89 | 90 | .nav-item { 91 | margin: 0 5px 0 0; /* Margin between items */ 92 | } 93 | 94 | .dropdown { 95 | margin-right: 0.5rem; 96 | } 97 | 98 | .dropdown-content { 99 | display: none; 100 | flex-direction: column; 101 | position: absolute; 102 | background-color: #f9f9f9; 103 | min-width: 160px; 104 | box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.2); 105 | z-index: 1; 106 | border-radius: 5px; 107 | } 108 | 109 | .dropdown-content button { 110 | color: black; 111 | padding: 12px 16px; 112 | text-decoration: none; 113 | text-align: left; 114 | display: block; 115 | font-family: Roboto, sans-serif; 116 | font-size: 1rem; 117 | border: none; 118 | border-radius: 5px; 119 | } 120 | 121 | .dropdown-content button:hover { 122 | background-color: #e8e8e8; 123 | } 124 | 125 | .dropdown:hover .dropdown-content { 126 | display: flex; 127 | } 128 | 129 | .navbar-dropdown { 130 | display: flex; 131 | align-items: center; /* Center dropdown items vertically */ 132 | } 133 | 134 | .dropbtn { 135 | color: #ffffffbf; 136 | font-size: 1rem; 137 | background-color: transparent; 138 | border: none; 139 | } 140 | 141 | .dropbtn:hover { 142 | color: white; 143 | } 144 | 145 | .dropdown-menu { 146 | display: none; /* Initially hide dropdowns */ 147 | position: absolute; /* Position absolute for dropdowns */ 148 | background: #343a40; /* Same as navbar background */ 149 | border-radius: 5px; /* Rounded corners for dropdowns */ 150 | z-index: 100; /* Ensure dropdown appears above content */ 151 | } 152 | 153 | .dropdown-list { 154 | display: flex; 155 | flex-direction: column; /* Stack dropdown items vertically */ 156 | } 157 | 158 | .dropdown-item { 159 | padding: 10px; 160 | color: white; /* Item text color */ 161 | text-decoration: none; /* Remove default link styles */ 162 | } 163 | .dropdown-item:hover { 164 | background-color: #495057; /* Darker background on hover */ 165 | } 166 | 167 | .search-form { 168 | display: flex; 169 | align-items: center; /* Center search input vertically */ 170 | } 171 | 172 | .search-form input { 173 | padding: 5px; /* Padding for the input box */ 174 | } 175 | 176 | .modal-header { 177 | display: flex; 178 | flex-direction: row; 179 | justify-content: center; 180 | align-items: center; 181 | border-bottom: none; 182 | margin-bottom: 2em; 183 | } 184 | 185 | .modal-header a { 186 | display: flex; 187 | align-items: center; 188 | } 189 | 190 | .modal { 191 | padding: 2em; 192 | border: none; 193 | border-radius: 5px; 194 | font-family: Montserrat, Roboto, sans-serif; 195 | line-height: 1.5em; 196 | } 197 | 198 | @media (min-width: 576px) { 199 | .modal-dialog { 200 | max-width: 600px; 201 | } 202 | } 203 | 204 | .ylogo { 205 | height: 60px; 206 | float: left; 207 | margin-right: 10px; 208 | } 209 | 210 | .ylogo-container { 211 | font-size: 14px; 212 | display: flex; 213 | align-items: center; 214 | margin-top: 3em; 215 | } 216 | 217 | .logo-link { 218 | margin: 0 auto; 219 | } 220 | 221 | .logo-link:hover { 222 | text-decoration: none; 223 | } 224 | 225 | .logo-image { 226 | height: 100px; 227 | } 228 | 229 | .logo-text { 230 | font-size: 24px; 231 | font-weight: bold; 232 | margin-left: 16px; 233 | color: #242265; 234 | } 235 | 236 | a { 237 | color: #337ab7; 238 | text-decoration: none; 239 | } 240 | 241 | 242 | .popupContent { 243 | position: absolute; 244 | display: none; /* during runtime, the display value is overridden by class HTMLPopupSupport */ 245 | border: 2px solid darkorange; 246 | border-radius: 15px; 247 | padding: 15px; 248 | overflow: hidden; 249 | background: rgba(255, 255, 255, .85); 250 | opacity: 0; /* will be faded in */ 251 | transition: opacity 0.2s ease-in; 252 | min-width: 300px; 253 | font-size: large; 254 | } 255 | 256 | #nodePopupContent { 257 | padding-bottom: 25px; 258 | } 259 | 260 | #edgePopupContent { 261 | min-width: 400px; 262 | max-width: 600px; 263 | } 264 | 265 | #edgePopupContent svg { 266 | font-size: 2.2em; 267 | width: 100%; 268 | height: 100%; 269 | } 270 | 271 | #edgePopupContent svg line { 272 | stroke: black; 273 | stroke-width: 3px; 274 | } 275 | 276 | #edgePopupContent svg polygon { 277 | fill: black; 278 | } 279 | 280 | #edgePopupContent text[data-id="linkName"] { 281 | font-weight: bold; 282 | } 283 | 284 | #edgePopupContent a:hover { 285 | fill: #0056b3; 286 | } 287 | 288 | .popupContent.popupContentClone { 289 | transition: opacity 0.2s ease-out; 290 | } 291 | 292 | 293 | .popupContentRight { 294 | position: relative; 295 | float: left; 296 | top: 10px; 297 | width: 180px; 298 | height: 100%; 299 | display: inline-block; 300 | } 301 | 302 | .popup-link { 303 | text-decoration: none; 304 | color: black; 305 | 306 | } 307 | 308 | .yfiles-node-highlight-template, 309 | .yfiles-edge-highlight-template, 310 | .yfiles-label-highlight-template, 311 | .yfiles-port-highlight-template { 312 | stroke: #5fd163; 313 | stroke-linejoin: round; 314 | stroke-dasharray: none; 315 | stroke-width: 2; 316 | } 317 | 318 | .overview-container { 319 | display: block; 320 | transition: opacity 0.5s ease-in; 321 | } 322 | 323 | #overviewComponent { 324 | height: 250px; 325 | position: absolute; 326 | width: 250px; 327 | z-index: 250; 328 | background-color: #343a40; 329 | overflow: hidden; 330 | right: 15px; 331 | } 332 | 333 | .prop-link { 334 | color: dimgrey; 335 | font-size: large; 336 | overflow-wrap: normal; 337 | } 338 | 339 | .text-truncate { 340 | white-space: nowrap; 341 | overflow: hidden; 342 | text-overflow: ellipsis; 343 | } 344 | 345 | .section-title { 346 | margin: 10px 0; 347 | font-weight: 600; 348 | color: steelblue; 349 | } 350 | 351 | #openPanel { 352 | border: none; 353 | background-color: transparent; 354 | font-size: large; 355 | } 356 | 357 | #openPanel:hover { 358 | border: none; 359 | background-color: transparent; 360 | text-decoration: underline; 361 | } 362 | -------------------------------------------------------------------------------- /app/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /data/loadData.js: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path' 2 | import { dirname } from 'node:path' 3 | import { fileURLToPath } from 'node:url' 4 | import { Knowledge } from 'ontology-database' 5 | 6 | const __dirname = dirname(fileURLToPath(import.meta.url)) 7 | const knowledge = new Knowledge('http://dbpedia.org/ontology/') 8 | 9 | knowledge.clear().then(() => { 10 | knowledge.loadData(path.join(__dirname, './DbPedia.ttl')).then(() => { 11 | knowledge.countTriples().then((count) => { 12 | console.log('DbPedia ontology loaded.') 13 | console.log(`There are now ${count} triples in the store.`) 14 | }) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /doc/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yWorks/ontology-visualizer/42d8c7844502de4e1a4d5977443eee9cc9711ea2/doc/screenshot.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ontology-visualizer-for-yfiles-for-html", 3 | "version": "2.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "load-data": "node ./data/loadData", 8 | "start-server": "node ./server/service", 9 | "dev": "cd ./app && vite" 10 | }, 11 | "devDependencies": { 12 | "prettier": "^3.5.3", 13 | "typescript": "^5.8.2", 14 | "vite": "^6.2.4" 15 | }, 16 | "workspaces": ["app", "server", "sample-database"] 17 | } 18 | -------------------------------------------------------------------------------- /sample-database/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ontology-database", 3 | "version": "2.0.0", 4 | "main": "src/index.js", 5 | "type": "module", 6 | "scripts": { 7 | "test": "vitest" 8 | }, 9 | "dependencies": { 10 | "@faker-js/faker": "^9.6.0", 11 | "classic-level": "^2.0.0", 12 | "n3": "^1.24.2", 13 | "ontology-database": "^2.0.0", 14 | "quadstore": "^15.0.0" 15 | }, 16 | "devDependencies": { 17 | "vitest": "^3.1.1" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /sample-database/src/index.js: -------------------------------------------------------------------------------- 1 | import { Schema, OntologyType, SpecialUri } from './ontology.js' 2 | import { OntologyStore } from './store.js' 3 | import { Util } from 'n3' 4 | 5 | const { isNamedNode } = Util 6 | 7 | /** 8 | * Ontology and knowledge manager which transparently handles the data management on triple level. 9 | */ 10 | class Knowledge { 11 | /** 12 | * Instantiates a new sample-database manager with the given namespace. 13 | * @param rootId namespace uri or node. 14 | */ 15 | constructor(rootId) { 16 | this._rootId = Schema.ensureRootId(rootId) 17 | this.store = new OntologyStore(this.rootId) 18 | } 19 | 20 | /** 21 | * Sets the root namespace of this sample-database manager. 22 | * @returns {string} 23 | */ 24 | get rootId() { 25 | return this._rootId 26 | } 27 | 28 | /** 29 | * Sets the root namespace of this sample-database manager. 30 | * @param v the uri of the new namespace. 31 | */ 32 | set rootId(v) { 33 | this._rootId = Schema.ensureRootId(v) 34 | // push down to the actual store 35 | this.store.rootId = this._rootId 36 | } 37 | 38 | /** 39 | * Returns the data properties of the specified class. 40 | * @param className name, uri or node of a class 41 | * @returns {Promise} 42 | */ 43 | getDataPropertyUrisOfClass(className) { 44 | return this.store.getDataPropertyUrisOfClass(className) 45 | } 46 | 47 | /** 48 | * Returns the object properties of the specified class. 49 | * @param className name, uri or node of a class 50 | * @returns {Promise} 51 | */ 52 | getObjectPropertyUrisOfClass(className) { 53 | return this.store.getObjectPropertyUrisOfClass(className) 54 | } 55 | 56 | /** 57 | * Adds an ontology class. 58 | * @param opts name or definition. 59 | * @returns { Promise} 60 | */ 61 | async addClass(opts) { 62 | let def 63 | if (typeof opts === 'string') { 64 | await this.store.addClass(opts) 65 | def = OntologyClass.serializationTemplate 66 | def.name = opts 67 | def.id = Schema.toUri(this.rootId, opts).id 68 | } else if (isNamedNode(opts)) { 69 | await this.store.addClass(opts) 70 | def = OntologyClass.serializationTemplate 71 | def.name = Schema.toShortForm(this.rootId, opts) 72 | def.id = opts 73 | } else if (isPlainObject(opts)) { 74 | if (!opts.name || !opts.className) { 75 | throw new Error("Class definition should contain 'name' or 'className'.") 76 | } 77 | const className = opts.name || opts.className 78 | if (isNamedNode(className)) { 79 | throw new Error( 80 | 'When using a plain object to create an OntologyClass the class name should be a string.', 81 | ) 82 | } 83 | if (!className) { 84 | throw new Error('The class name cannot be nil.') 85 | } 86 | let parentClassName = null 87 | if (opts.parentName || opts.parentClassName) { 88 | parentClassName = opts.parentName || opts.parentClassName 89 | } 90 | if (isNamedNode(parentClassName)) { 91 | throw new Error( 92 | 'When using a plain object to create an OntologyClass the parent class name should be a string.', 93 | ) 94 | } 95 | 96 | let label = null 97 | if (opts.label) { 98 | label = opts.label 99 | } 100 | let comment = null 101 | if (opts.comment) { 102 | comment = opts.comment 103 | } 104 | try { 105 | await this.store.addClass(className, parentClassName, label, comment) 106 | } catch (e) { 107 | console.error(e) 108 | } 109 | def = OntologyClass.serializationTemplate 110 | def.name = className 111 | def.id = Schema.toUri(this.rootId, className).id 112 | def.label = label 113 | def.comment = comment 114 | def.parentId = !parentClassName ? null : Schema.toUri(parentClassName).id 115 | def.parentName = parentClassName 116 | } 117 | return Promise.resolve(new OntologyClass(this.rootId, def)) 118 | } 119 | 120 | /** 121 | * Returns the ontology class with the given name. 122 | * @param className name, uri or node 123 | * @returns {Promise} 124 | */ 125 | async getClass(className, includeProperties = false) { 126 | const quads = await this.store.getClassQuads(className) 127 | if (!quads || quads.length === 0) { 128 | return null 129 | } 130 | if (includeProperties) { 131 | const cls = new OntologyClass(this.rootId, quads) 132 | const dataProps = await this.getDataPropertyUrisOfClass(className) 133 | const objectProps = await this.getObjectPropertyUrisOfClass(className) 134 | if (!dataProps || dataProps.length === 0) { 135 | cls.dataProperties = [] 136 | } else { 137 | cls.dataProperties = dataProps.map((d) => { 138 | return { 139 | name: Schema.toShortForm(this.rootId, d), 140 | uri: d, 141 | } 142 | }) 143 | } 144 | if (!objectProps || objectProps.length === 0) { 145 | cls.objectProperties = [] 146 | } else { 147 | cls.objectProperties = objectProps.map((d) => { 148 | return { 149 | name: Schema.toShortForm(this.rootId, d), 150 | uri: d, 151 | } 152 | }) 153 | } 154 | return cls 155 | } else { 156 | return new OntologyClass(this.rootId, quads) 157 | } 158 | } 159 | 160 | /** 161 | * Returns the object property with the given name. 162 | * @param propertyName 163 | * @returns {Promise} 164 | */ 165 | async getObjectProperty(propertyName) { 166 | const quads = await this.store.getObjectPropertyQuads(propertyName) 167 | if (!quads || quads.length === 0) { 168 | return null 169 | } 170 | return new OntologyObjectProperty(this.rootId, quads) 171 | } 172 | 173 | /** 174 | * Returns the data property with the given name. 175 | * @param propertyName 176 | * @returns {Promise} 177 | */ 178 | async getDataProperty(propertyName) { 179 | const quads = await this.store.getDataPropertyQuads(propertyName) 180 | if (!quads || quads.length === 0) { 181 | return null 182 | } 183 | return new OntologyDataProperty(this.rootId, quads) 184 | } 185 | 186 | /** 187 | * Returns whether the class name exists. 188 | * @param className name or Uri 189 | * @returns {Promise} 190 | */ 191 | classExists(className) { 192 | return this.store.classExists(className) 193 | } 194 | 195 | objectPropertyExists(propertyName) { 196 | return this.store.objectPropertyExists(propertyName) 197 | } 198 | 199 | dataPropertyExists(propertyName) { 200 | return this.store.dataPropertyExists(propertyName) 201 | } 202 | 203 | /** 204 | * Returns an array of all the ontology classes in the store. 205 | * @param onlyOwn include only the classes from the root namespace 206 | * @returns {Promise} 207 | */ 208 | getAllClassUris(onlyOwn = true) { 209 | return this.store.getClassUris(onlyOwn) 210 | } 211 | 212 | /** 213 | * Returns a graph (in json format) containing the Uris of the classes and the object properties. 214 | * Data properties, labels and comments are not included. 215 | */ 216 | getSimplifiedOntologyGraph() { 217 | return this.store.getSimplifiedOntologyGraph() 218 | } 219 | 220 | /** 221 | * Clear the triple store. 222 | * @returns {*|Promise} 223 | */ 224 | clear() { 225 | return this.store.clear() 226 | } 227 | 228 | /** 229 | * Imports the data in the triple store. 230 | * @param dataPath path to an RDF, tutrle or other triple format. 231 | * @returns {*|Promise|Promise|undefined} 232 | */ 233 | loadData(dataPath) { 234 | return this.store.loadFileData(dataPath) 235 | } 236 | 237 | /** 238 | * Returns the triple count. 239 | * @returns {Promise} 240 | */ 241 | countTriples() { 242 | return this.store.countTriples() 243 | } 244 | 245 | /** 246 | * Returns all object properties of the current namespace 247 | * as an array of graph links (plain objects with uri, from and to). 248 | * @param onlyOwn include only the elements from the root namespace 249 | * @returns {Promise<[]>} 250 | */ 251 | getSimplifiedObjectProperties(onlyOwn = true) { 252 | return this.store.getSimplifiedObjectProperties(onlyOwn) 253 | } 254 | 255 | async addObjectProperty(opts) { 256 | let def 257 | if (typeof opts === 'string') { 258 | await this.store.addObjectProperty(opts) 259 | def = OntologyObjectProperty.serializationTemplate 260 | def.name = opts 261 | def.id = Schema.toUri(this.rootId, opts).id 262 | } else if (isPlainObject(opts)) { 263 | if (!opts.name && !opts.propertyName) { 264 | throw new Error("Object property definition should contain 'name' or 'propertyName'.") 265 | } 266 | const propertyName = opts.name || opts.propertyName 267 | if (isNamedNode(propertyName)) { 268 | throw new Error( 269 | 'When using a plain object to create an object property the name should be a string.', 270 | ) 271 | } 272 | if (!propertyName) { 273 | throw new Error('The property name cannot be nil.') 274 | } 275 | let domainName = null 276 | if (opts.domainName || opts.domain) { 277 | domainName = opts.domainName || opts.domain 278 | } 279 | if (!domainName) { 280 | throw new Error(`Missing 'domain' in object property definition.`) 281 | } 282 | let rangeName = null 283 | if (opts.rangeName || opts.range) { 284 | rangeName = opts.rangeName || opts.range 285 | } 286 | if (!rangeName) { 287 | throw new Error(`Missing 'range' in object property definition.`) 288 | } 289 | 290 | let label = null 291 | if (opts.label) { 292 | label = opts.label 293 | } 294 | let comment = null 295 | if (opts.comment) { 296 | comment = opts.comment 297 | } 298 | try { 299 | await this.store.addObjectProperty(propertyName, domainName, rangeName, label, comment) 300 | } catch (e) { 301 | console.error(e) 302 | } 303 | def = OntologyObjectProperty.serializationTemplate 304 | def.name = propertyName 305 | def.id = Schema.toUri(this.rootId, propertyName).id 306 | def.label = label 307 | def.comment = comment 308 | def.domainIds = !domainName ? [] : [Schema.toUri(this.rootId, domainName).id] 309 | def.rangeIds = !rangeName ? [] : [Schema.toUri(this.rootId, rangeName).id] 310 | } 311 | return Promise.resolve(new OntologyObjectProperty(this.rootId, def)) 312 | } 313 | 314 | async addDatatypeProperty(opts) { 315 | let def 316 | if (typeof opts === 'string') { 317 | await this.store.addDatatypeProperty(opts) 318 | def = OntologyDataProperty.serializationTemplate 319 | def.name = opts 320 | def.id = Schema.toUri(this.rootId, opts).id 321 | } else if (isPlainObject(opts)) { 322 | if (!opts.name && !opts.propertyName) { 323 | throw new Error("Data property definition should contain 'name' or 'propertyName'.") 324 | } 325 | const propertyName = opts.name || opts.propertyName 326 | if (isNamedNode(propertyName)) { 327 | throw new Error( 328 | 'When using a plain object to create a data property the name should be a string.', 329 | ) 330 | } 331 | if (!propertyName) { 332 | throw new Error('The property name cannot be nil.') 333 | } 334 | let domainName = null 335 | if (opts.domainName || opts.domain) { 336 | domainName = opts.domainName || opts.domain 337 | } 338 | if (!domainName) { 339 | throw new Error(`Missing 'domain' in object property definition.`) 340 | } 341 | 342 | let label = null 343 | if (opts.label) { 344 | label = opts.label 345 | } 346 | let comment = null 347 | if (opts.comment) { 348 | comment = opts.comment 349 | } 350 | try { 351 | await this.store.addDatatypeProperty(propertyName, domainName, label, comment) 352 | } catch (e) { 353 | console.error(e) 354 | } 355 | def = OntologyDataProperty.serializationTemplate 356 | def.name = propertyName 357 | def.id = Schema.toUri(this.rootId, propertyName).id 358 | def.label = label 359 | def.comment = comment 360 | def.domainIds = !domainName ? [] : [Schema.toUri(this.rootId, domainName).id] 361 | } 362 | return Promise.resolve(new OntologyDataProperty(this.rootId, def)) 363 | } 364 | } 365 | 366 | class OntologyElement { 367 | constructor(rootId, opts) { 368 | this.rootId = Schema.validateNamespace(rootId) 369 | this.name = null 370 | this.id = null 371 | this.label = null 372 | this.comment = null 373 | this.isLoaded = false 374 | if (isPlainObject(opts)) { 375 | this.name = opts.name 376 | this.id = opts.uri 377 | this.label = opts.label 378 | this.comment = opts.comment 379 | } 380 | } 381 | 382 | /** 383 | * Returns an empty OntologyElement as a plain object. 384 | * @returns {Object} 385 | */ 386 | static get serializationTemplate() { 387 | return { 388 | name: null, 389 | id: null, 390 | label: null, 391 | comment: null, 392 | } 393 | } 394 | } 395 | 396 | class OntologyClass extends OntologyElement { 397 | /** 398 | * Creates a new instance. 399 | * @param opts a class name, quads defining the class or plain object. 400 | */ 401 | constructor(rootId, opts = null) { 402 | super(rootId, opts) 403 | this.parentId = null 404 | this.parentName = null 405 | this.dataProperties = [] 406 | this.objectProperties = [] 407 | if (Array.isArray(opts) && opts.length > 0) { 408 | //quads 409 | if (Schema.getOntologyTypeOfQuads(opts) === OntologyType.Class) { 410 | const def = Schema.getClassDetailsFromQuads(this.rootId, opts) 411 | Object.assign(this, def) 412 | } else { 413 | throw new Error( 414 | "Given quad array is not a serialization of a class and can't hydrate class.", 415 | ) 416 | } 417 | } else if (isPlainObject(opts)) { 418 | Object.assign(this, opts) 419 | } else if (typeof opts === 'string') { 420 | this.name = opts 421 | } 422 | // if nothing sets the label we'll infer it 423 | if (!this.label) { 424 | this.label = Schema.toShortForm(rootId, this.name) 425 | } 426 | // same for the parent 427 | if (!this.parentId) { 428 | this.parentId = SpecialUri.thing 429 | this.parentName = 'Thing' 430 | } 431 | } 432 | 433 | toJson() { 434 | return Object.assign(OntologyClass.serializationTemplate, this) 435 | } 436 | 437 | /** 438 | * Returns an empty OntologyClass as a plain object. 439 | * @returns {Object} 440 | */ 441 | static get serializationTemplate() { 442 | const base = super.serializationTemplate 443 | 444 | return Object.assign( 445 | { 446 | parentId: null, 447 | parentName: null, 448 | dataProperties: [], 449 | objectProperties: [], 450 | }, 451 | super.serializationTemplate, 452 | ) 453 | } 454 | } 455 | 456 | class OntologyDataProperty extends OntologyElement { 457 | /** 458 | * Creates a new instance. 459 | * @param opts a property name, quads defining the property or plain object. 460 | */ 461 | constructor(rootId, opts = null) { 462 | super(rootId, opts) 463 | this.domainIds = [] 464 | if (Array.isArray(opts) && opts.length > 0) { 465 | //quads 466 | if (Schema.getOntologyTypeOfQuads(opts) === OntologyType.DatatypeProperty) { 467 | const def = Schema.getDatatypePropertyDetailsFromQuads(rootId, opts) 468 | Object.assign(this, def) 469 | } else { 470 | throw new Error( 471 | "Given quad array is not a serialization of a data property and can't hydrate instance.", 472 | ) 473 | } 474 | } else if (isPlainObject(opts)) { 475 | Object.assign(this, opts) 476 | } else if (typeof opts === 'string') { 477 | this.name = opts 478 | } 479 | } 480 | 481 | toJson() { 482 | return Object.assign(OntologyDataProperty.serializationTemplate, this) 483 | } 484 | 485 | /** 486 | * Returns an empty OntologyObjectProperty as a plain object. 487 | * @returns {Object} 488 | */ 489 | static get serializationTemplate() { 490 | return Object.assign( 491 | { 492 | rangeIds: null, 493 | domainIds: null, 494 | }, 495 | super.serializationTemplate, 496 | ) 497 | } 498 | } 499 | 500 | class OntologyObjectProperty extends OntologyElement { 501 | /** 502 | * Creates a new instance. 503 | * @param opts a property name, quads defining the property or plain object. 504 | */ 505 | constructor(opts = null) { 506 | super(opts) 507 | this.rangeIds = [] 508 | this.domainIds = [] 509 | if (Array.isArray(opts) && opts.length > 0) { 510 | //quads 511 | if (Schema.getOntologyTypeOfQuads(opts) === OntologyType.ObjectProperty) { 512 | const def = Schema.getObjectPropertyDetailsFromQuads(opts) 513 | Object.assign(this, def) 514 | } else { 515 | throw new Error( 516 | "Given quad array is not a serialization of an object property and can't hydrate instance.", 517 | ) 518 | } 519 | } else if (isPlainObject(opts)) { 520 | Object.assign(this, opts) 521 | } else if (typeof opts === 'string') { 522 | this.name = opts 523 | } 524 | } 525 | 526 | toJson() { 527 | return Object.assign(OntologyObjectProperty.serializationTemplate, this) 528 | } 529 | 530 | /** 531 | * Returns an empty OntologyObjectProperty as a plain object. 532 | * @returns {Object} 533 | */ 534 | static get serializationTemplate() { 535 | return Object.assign( 536 | { 537 | rangeIds: null, 538 | domainIds: null, 539 | }, 540 | super.serializationTemplate, 541 | ) 542 | } 543 | } 544 | 545 | function isPlainObject(value) { 546 | return ( 547 | value !== null && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype 548 | ) 549 | } 550 | 551 | export { OntologyClass, OntologyDataProperty, OntologyObjectProperty, Knowledge } 552 | -------------------------------------------------------------------------------- /sample-database/src/ontology.js: -------------------------------------------------------------------------------- 1 | import { Util, DataFactory, Quad } from 'n3' 2 | import { faker } from '@faker-js/faker' 3 | import { OntologyClass, OntologyObjectProperty } from 'ontology-database' 4 | import { OntologyDataProperty } from './index.js' 5 | 6 | const { isNamedNode, isLiteral } = Util 7 | const { quad, namedNode, literal } = DataFactory 8 | 9 | const QuadType = { 10 | Class: 'Class', 11 | SubClass: 'SubClass', 12 | Label: 'Label', 13 | Comment: 'Comment', 14 | Other: 'Other', 15 | Thing: 'Thing', 16 | A: 'A', 17 | } 18 | const OntologyType = { 19 | Class: 'Class', 20 | DatatypeProperty: 'DatatypeProperty', 21 | ObjectProperty: 'ObjectProperty', 22 | Other: 'Other', 23 | } 24 | 25 | const SpecialUri = { 26 | a: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type', 27 | owlClass: 'http://www.w3.org/2002/07/owl#Class', 28 | owl: 'http://www.w3.org/2002/07/owl#', 29 | owlDatatypeProperty: 'http://www.w3.org/2002/07/owl#DatatypeProperty', 30 | owlObjectProperty: 'http://www.w3.org/2002/07/owl#ObjectProperty', 31 | thing: 'http://www.w3.org/2002/07/owl#Thing', 32 | subClassOf: 'http://www.w3.org/2000/01/rdf-schema#subClassOf', 33 | rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', 34 | rdfs: 'http://www.w3.org/2000/01/rdf-schema#', 35 | label: 'http://www.w3.org/2000/01/rdf-schema#label', 36 | comment: 'http://www.w3.org/2000/01/rdf-schema#comment', 37 | domain: 'http://www.w3.org/2000/01/rdf-schema#domain', 38 | range: 'http://www.w3.org/2000/01/rdf-schema#range', 39 | literal: 'http://www.w3.org/2000/01/rdf-schema#literal', 40 | } 41 | 42 | const SpecialNodes = { 43 | a: namedNode(SpecialUri.a), 44 | owlClass: namedNode(SpecialUri.owlClass), 45 | owl: namedNode(SpecialUri.owl), 46 | owlDatatypeProperty: namedNode(SpecialUri.owlDatatypeProperty), 47 | owlObjectProperty: namedNode(SpecialUri.owlObjectProperty), 48 | thing: namedNode(SpecialUri.thing), 49 | subClassOf: namedNode(SpecialUri.subClassOf), 50 | rdf: namedNode(SpecialUri.rdf), 51 | rdfs: namedNode(SpecialUri.rdfs), 52 | label: namedNode(SpecialUri.label), 53 | comment: namedNode(SpecialUri.comment), 54 | domain: namedNode(SpecialUri.domain), 55 | range: namedNode(SpecialUri.range), 56 | literal: namedNode(SpecialUri.literal), 57 | } 58 | 59 | const Schema = { 60 | get a() { 61 | return SpecialUri.a 62 | }, 63 | get aNode() { 64 | return SpecialNodes.a 65 | }, 66 | 67 | get owlClass() { 68 | return SpecialUri.owlClass 69 | }, 70 | get owlClassNode() { 71 | return SpecialNodes.owlClass 72 | }, 73 | 74 | get thing() { 75 | return SpecialUri.thing 76 | }, 77 | get thingNode() { 78 | return SpecialNodes.thing 79 | }, 80 | 81 | get subClassOf() { 82 | return SpecialUri.subClassOf 83 | }, 84 | get subClassOfNode() { 85 | return SpecialNodes.subClassOf 86 | }, 87 | 88 | get rdf() { 89 | return SpecialUri.rdf 90 | }, 91 | get rdfNode() { 92 | return SpecialNodes.rdf 93 | }, 94 | 95 | get rdfs() { 96 | return SpecialUri.rdfs 97 | }, 98 | get rdfsNode() { 99 | return SpecialNodes.rdfs 100 | }, 101 | 102 | get label() { 103 | return SpecialUri.label 104 | }, 105 | get labelNode() { 106 | return SpecialNodes.label 107 | }, 108 | 109 | get comment() { 110 | return SpecialUri.comment 111 | }, 112 | get commentNode() { 113 | return SpecialNodes.comment 114 | }, 115 | 116 | get datatypeProperty() { 117 | return SpecialUri.owlDatatypeProperty 118 | }, 119 | get datatypePropertyNode() { 120 | return SpecialNodes.owlDatatypeProperty 121 | }, 122 | 123 | get objectProperty() { 124 | return SpecialUri.owlObjectProperty 125 | }, 126 | get objectPropertyNode() { 127 | return SpecialNodes.owlObjectProperty 128 | }, 129 | 130 | get domain() { 131 | return SpecialUri.domain 132 | }, 133 | get domainNode() { 134 | return SpecialNodes.domain 135 | }, 136 | 137 | get range() { 138 | return SpecialUri.range 139 | }, 140 | get rangeNode() { 141 | return SpecialNodes.range 142 | }, 143 | 144 | validateNamespace(ns) { 145 | if (!ns) { 146 | throw new Error('Missing root in constructor.') 147 | } 148 | if (isNamedNode(ns)) { 149 | ns = ns.id 150 | } 151 | if (typeof ns !== 'string') { 152 | throw new Error('Root expected to be a string or node.') 153 | } 154 | if (!ns.endsWith('/')) { 155 | ns += '/' 156 | } 157 | return ns 158 | }, 159 | /** 160 | * Returns the quads defining an ontology class. 161 | * @param rootId the root namespace 162 | * @param className the name of the class 163 | * @param parentClassName optional parent class name 164 | * @returns {[Quad|*, Quad|*, Quad|*]} 165 | */ 166 | getClassQuads(rootId, className, parentClassName = null, label = null, comment = null) { 167 | rootId = this.ensureRootId(rootId) 168 | if (!parentClassName) { 169 | return [ 170 | quad(this.toUri(rootId, className), this.aNode, this.owlClassNode), 171 | quad(this.toUri(rootId, className), this.subClassOfNode, this.thingNode), 172 | quad( 173 | this.toUri(rootId, className), 174 | this.labelNode, 175 | literal(!label ? this.toShortForm(rootId, className) : label), 176 | ), 177 | quad(this.toUri(rootId, className), this.commentNode, literal(!comment ? '' : comment)), 178 | ] 179 | } else { 180 | return [ 181 | quad(this.toUri(rootId, className), this.aNode, this.owlClassNode), 182 | quad( 183 | this.toUri(rootId, className), 184 | this.subClassOfNode, 185 | this.toUri(rootId, parentClassName), 186 | ), 187 | quad( 188 | this.toUri(rootId, className), 189 | this.labelNode, 190 | literal(!label ? this.toShortForm(rootId, className) : label), 191 | ), 192 | quad(this.toUri(rootId, className), this.commentNode, literal(!comment ? '' : comment)), 193 | ] 194 | } 195 | }, 196 | getDataPropertyQuads(rootUri, propertyName, domain, label = null, comment = null) { 197 | const result = [ 198 | quad(this.toUri(rootUri, propertyName), this.aNode, this.datatypePropertyNode), 199 | quad( 200 | this.toUri(rootUri, propertyName), 201 | this.labelNode, 202 | literal(!label ? propertyName : label), 203 | ), 204 | quad(this.toUri(rootUri, propertyName), this.commentNode, literal(!comment ? '' : comment)), 205 | ] 206 | if (typeof domain === 'string') { 207 | result.push( 208 | quad(this.toUri(rootUri, propertyName), this.domainNode, this.toUri(rootUri, domain)), 209 | ) 210 | } else if (Array.isArray(domain)) { 211 | domain.forEach((d) => { 212 | result.push( 213 | quad(this.toUri(rootUri, propertyName), this.domainNode, this.toUri(rootUri, d)), 214 | ) 215 | }) 216 | } 217 | return result 218 | }, 219 | getObjectPropertyQuads(rootUri, propertyName, domain, range, label = null, comment = null) { 220 | const result = [ 221 | quad(this.toUri(rootUri, propertyName), this.aNode, this.objectPropertyNode), 222 | // quad(this.toUri(rootUri, propertyName), this.subClassOfNode, this.objectPropertyNode), 223 | quad( 224 | this.toUri(rootUri, propertyName), 225 | this.labelNode, 226 | literal(!label ? propertyName : label), 227 | ), 228 | quad(this.toUri(rootUri, propertyName), this.commentNode, literal(!comment ? '' : comment)), 229 | ] 230 | if (typeof domain === 'string') { 231 | result.push( 232 | quad(this.toUri(rootUri, propertyName), this.domainNode, this.toUri(rootUri, domain)), 233 | ) 234 | } else if (Array.isArray(domain)) { 235 | domain.forEach((d) => { 236 | result.push( 237 | quad(this.toUri(rootUri, propertyName), this.domainNode, this.toUri(rootUri, d)), 238 | ) 239 | }) 240 | } 241 | if (typeof range === 'string') { 242 | result.push( 243 | quad(this.toUri(rootUri, propertyName), this.rangeNode, this.toUri(rootUri, range)), 244 | ) 245 | } else if (Array.isArray(range)) { 246 | range.forEach((r) => { 247 | result.push(quad(this.toUri(rootUri, propertyName), this.rangeNode, this.toUri(rootUri, r))) 248 | }) 249 | } 250 | return result 251 | }, 252 | 253 | getClassDetailsFromQuads(rootUri, quadArray) { 254 | const def = OntologyClass.serializationTemplate 255 | let foundQuad = quadArray.find((a) => a.predicate === this.aNode) 256 | if (foundQuad) { 257 | if (foundQuad.object !== this.owlClassNode) { 258 | if (foundQuad.object === this.objectPropertyNode) 259 | throw new Error('The given quads defines an object property rather than a class.') 260 | if (foundQuad.object === this.datatypePropertyNode) 261 | throw new Error('The given quads defines a data property rather than a class.') 262 | throw new Error(`The given quads define a type '${foundQuad.object}' instead of a class.`) 263 | } 264 | def.name = this.toShortForm(rootUri, foundQuad.subject) 265 | def.id = foundQuad.subject.id 266 | } else { 267 | throw new Error('Given quads do not define an ontology element.') 268 | } 269 | foundQuad = quadArray.find((a) => a.predicate === this.subClassOfNode) 270 | if (foundQuad) { 271 | def.parentId = foundQuad.object.id 272 | def.parentName = this.toShortForm(rootUri, foundQuad.object) 273 | } 274 | foundQuad = quadArray.find((a) => a.predicate === this.labelNode) 275 | if (foundQuad) { 276 | def.label = foundQuad.object.value 277 | } 278 | foundQuad = quadArray.find((a) => a.predicate === this.comment) 279 | if (foundQuad) { 280 | def.label = foundQuad.object.value 281 | } 282 | return def 283 | }, 284 | 285 | getObjectPropertyDetailsFromQuads(rootUri, quadArray) { 286 | const def = OntologyObjectProperty.serializationTemplate 287 | let foundQuad = quadArray.find((a) => a.predicate === this.aNode) 288 | if (foundQuad) { 289 | if (foundQuad.object !== this.objectPropertyNode) { 290 | if (foundQuad.object === this.owlClassNode) 291 | throw new Error('The given quads defines a class rather than an object property.') 292 | if (foundQuad.object === this.datatypePropertyNode) 293 | throw new Error('The given quads defines a data property rather than an object property.') 294 | throw new Error( 295 | `The given quads define a type '${foundQuad.object}' instead of an object property.`, 296 | ) 297 | } 298 | def.name = this.toShortForm(rootUri, foundQuad.subject) 299 | def.id = foundQuad.subject.id 300 | } else { 301 | throw new Error('Given quads do not define an ontology element.') 302 | } 303 | 304 | foundQuad = quadArray.filter((a) => a.predicate === this.domainNode) 305 | if (foundQuad) { 306 | def.domainIds = foundQuad.map((q) => q.object.id) 307 | } 308 | foundQuad = quadArray.filter((a) => a.predicate === this.rangeNode) 309 | if (foundQuad) { 310 | def.rangeIds = foundQuad.map((q) => q.object.id) 311 | } 312 | 313 | foundQuad = quadArray.find((a) => a.predicate === this.labelNode) 314 | if (foundQuad) { 315 | def.label = foundQuad.object.value 316 | } 317 | foundQuad = quadArray.find((a) => a.predicate === this.comment) 318 | if (foundQuad) { 319 | def.label = foundQuad.object.value 320 | } 321 | return def 322 | }, 323 | 324 | getDatatypePropertyDetailsFromQuads(rootUri, quadArray) { 325 | const def = OntologyDataProperty.serializationTemplate 326 | let foundQuad = quadArray.find((a) => a.predicate === this.aNode) 327 | if (foundQuad) { 328 | if (foundQuad.object !== this.datatypePropertyNode) { 329 | if (foundQuad.object === this.owlClassNode) 330 | throw new Error('The given quads defines a class rather than a data property.') 331 | if (foundQuad.object === this.objectPropertyNode) 332 | throw new Error('The given quads defines an object property rather than a data property.') 333 | throw new Error( 334 | `The given quads define a type '${foundQuad.object}' instead of a data property.`, 335 | ) 336 | } 337 | def.name = this.toShortForm(rootUri, foundQuad.subject) 338 | def.id = foundQuad.subject.id 339 | } else { 340 | throw new Error('Given quads do not define an ontology element.') 341 | } 342 | 343 | foundQuad = quadArray.filter((a) => a.predicate === this.domainNode) 344 | if (foundQuad) { 345 | def.domainIds = foundQuad.map((q) => q.object.id) 346 | } 347 | 348 | foundQuad = quadArray.find((a) => a.predicate === this.labelNode) 349 | if (foundQuad) { 350 | def.label = foundQuad.object.value 351 | } 352 | foundQuad = quadArray.find((a) => a.predicate === this.comment) 353 | if (foundQuad) { 354 | def.label = foundQuad.object.value 355 | } 356 | return def 357 | }, 358 | 359 | /** 360 | * Returns the type of quad given. 361 | * @param q presumably a quad 362 | * @returns {string} 363 | */ 364 | getQuadType: function (q) { 365 | if (!q) { 366 | throw new Error('Missing argument in getQuadType.') 367 | } 368 | if (!(q instanceof Quad)) { 369 | throw new Error(`getQuadType expected a Quad argument.`) 370 | } else if (q.predicate === SpecialNodes.owlClass) { 371 | return QuadType.Class 372 | } else if (q.predicate === SpecialNodes.comment) { 373 | return QuadType.Comment 374 | } else if (q.predicate === SpecialNodes.comment) { 375 | return QuadType.Comment 376 | } else if (q.predicate === SpecialNodes.label) { 377 | return QuadType.Label 378 | } else if (q.predicate === SpecialNodes.a) { 379 | return QuadType.A 380 | } else if (q.predicate === SpecialNodes.thing) { 381 | return QuadType.Thing 382 | } 383 | return QuadType.Other 384 | }, 385 | 386 | /** 387 | * Given an array of quads this returns what it presumably serializes. 388 | * @param quadArray a quad array 389 | * @returns {string} 390 | */ 391 | getOntologyTypeOfQuads(quadArray) { 392 | if (!quadArray) { 393 | throw new Error('Missing argument in getOntologyTypeOfQuads.') 394 | } 395 | if (!Array.isArray(quadArray)) { 396 | throw new Error('getOntologyTypeOfQuads argument should be an array of quads.') 397 | } 398 | const found = quadArray.filter((a) => a.predicate === SpecialNodes.a) 399 | if (found.length !== 1) { 400 | return OntologyType.Other 401 | } 402 | const obj = found[0].object 403 | if (obj === SpecialNodes.owlDatatypeProperty) { 404 | return OntologyType.DatatypeProperty 405 | } else if (obj === SpecialNodes.owlObjectProperty) { 406 | return OntologyType.ObjectProperty 407 | } else if (obj === SpecialNodes.owlClass) { 408 | return OntologyType.Class 409 | } 410 | return OntologyType.Other 411 | }, 412 | 413 | /** 414 | * Returns a shortened form of the given term. 415 | * Useful for printing and literals. 416 | * @param rootUri the root namespace 417 | * @param uri anything 418 | * @returns {string} 419 | */ 420 | toShortForm(rootUri, uri) { 421 | const rootId = this.ensureRootId(rootUri) 422 | if (typeof uri === 'string') { 423 | uri = uri 424 | .replace(rootId, '') 425 | .replace(SpecialUri.rdf, '') 426 | .replace(SpecialUri.rdfs, '') 427 | .replace(SpecialUri.owl, '') 428 | if (uri.indexOf('/') > -1) { 429 | uri = uri.slice(uri.indexOf('/') + 1) 430 | } 431 | if (uri.indexOf('#') > -1) { 432 | uri = uri.slice(uri.lastIndexOf('#') + 1) 433 | } 434 | return uri 435 | } else if (isNamedNode(uri)) { 436 | return this.toShortForm(rootId, uri.id) 437 | } else if (Array.isArray(uri)) { 438 | uri.map((u) => this.toShortForm(u)) 439 | } else if (isLiteral(uri)) { 440 | return uri.value 441 | } else { 442 | return String(uri) 443 | } 444 | }, 445 | /** 446 | * Makes sure that the given things can be used as a namespace (trailing slash and such). 447 | * @param rootSomething string or node 448 | * @returns {string} 449 | */ 450 | ensureRootId(rootSomething) { 451 | if (!rootSomething) { 452 | throw new Error('Missing root Uri or Url.') 453 | } 454 | if (typeof rootSomething === 'string') { 455 | if (!rootSomething.startsWith('http')) { 456 | throw new Error(`The root '${rootSomething}' should begin with 'http' or 'https'.`) 457 | } 458 | if (rootSomething.endsWith('#')) { 459 | throw new Error(`The framework does not support naming based on '#', only on trailing '/'.`) 460 | } 461 | if (!rootSomething.endsWith('/')) { 462 | rootSomething += '/' 463 | } 464 | return rootSomething 465 | } 466 | if (isNamedNode(rootSomething)) { 467 | return this.ensureRootId(rootSomething.id) 468 | } 469 | }, 470 | /** 471 | * Assembles a named node from the given parts. 472 | * @param rootUri The root namespace. 473 | * @param args optional parts 474 | * @returns {NamedNode|*} 475 | */ 476 | toUri(rootUri, ...args) { 477 | if (!rootUri) { 478 | throw new Error('Missing root Uri') 479 | } 480 | let rootNode = isNamedNode(rootUri) ? rootUri : namedNode(rootUri) 481 | let rootId = isNamedNode(rootUri) ? rootUri.id : rootUri 482 | 483 | if (!rootId.startsWith('http')) { 484 | throw new Error(`The root Uri is not valid, it should start with 'http'.`) 485 | } 486 | 487 | if (!rootId.endsWith('/')) { 488 | rootId += '/' 489 | rootNode = namedNode(rootId) 490 | } 491 | if (args.length === 0 || !args[0]) { 492 | return rootNode 493 | } 494 | // case that the first arg is a NamedNode we return it as-is, allowing for non-root nodes 495 | if (isNamedNode(args[0])) { 496 | return args[0] 497 | } 498 | if (args[0].startsWith('http')) { 499 | // not nice to give a Url but we'll accept it 500 | return namedNode(args[0]) 501 | } 502 | const ar = [] 503 | args.forEach((arg) => { 504 | if (typeof arg !== 'string') { 505 | throw new Error('All Uri elements should be strings.') 506 | } 507 | if (arg.startsWith('/')) { 508 | arg = arg.slice(1) 509 | } 510 | if (arg.endsWith('/')) { 511 | arg = arg.slice(0, -1) 512 | } 513 | ar.push(arg) 514 | }) 515 | return namedNode(rootId + ar.join('/')) 516 | }, 517 | /** 518 | * Returns a random Uri for testing purposes. 519 | */ 520 | get random() { 521 | const parent = this 522 | return { 523 | get uri() { 524 | return parent.toUri(faker.internet.url(), faker.lorem.word()) 525 | }, 526 | get classQuads() { 527 | return parent.getClassQuads( 528 | faker.internet.url(), 529 | faker.string.uuid(), 530 | null, 531 | faker.lorem.words(4), 532 | faker.lorem.paragraph(), 533 | ) 534 | }, 535 | get classQuadsAndDetails() { 536 | const root = faker.internet.url() 537 | const className = faker.string.uuid() 538 | const parentName = faker.string.uuid() 539 | const def = { 540 | name: className, 541 | uri: parent.toUri(root, className), 542 | label: faker.lorem.word(), 543 | comment: faker.lorem.paragraph(), 544 | parentUri: parent.toUri(root, parentName), 545 | parentName: parentName, 546 | root: root, 547 | } 548 | return { 549 | details: def, 550 | quads: parent.getClassQuads(root, def.name, def.parentName, def.label, def.comment), 551 | } 552 | }, 553 | get datatypePropertyQuads() { 554 | return parent.getDataPropertyQuads( 555 | faker.internet.url(), 556 | faker.lorem.word(), 557 | faker.internet.domainWord(), 558 | faker.lorem.words(4), 559 | faker.lorem.paragraph(), 560 | ) 561 | }, 562 | } 563 | }, 564 | } 565 | 566 | export { Schema, SpecialNodes, SpecialUri, QuadType, OntologyType } 567 | -------------------------------------------------------------------------------- /sample-database/src/store.js: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path' 2 | import { dirname } from 'node:path' 3 | import * as fs from 'node:fs' 4 | import { fileURLToPath } from 'node:url' 5 | import { Schema, SpecialNodes } from './ontology.js' 6 | import { Quadstore } from 'quadstore' 7 | import { ClassicLevel } from 'classic-level' 8 | import { DataFactory, StreamParser, Util } from 'n3' 9 | 10 | const { namedNode } = DataFactory 11 | const { isNamedNode } = Util 12 | 13 | const __dirname = dirname(fileURLToPath(import.meta.url)) 14 | 15 | /** 16 | * Simplified API to manage the quadstore ontology. 17 | */ 18 | export class OntologyStore { 19 | constructor(rootId) { 20 | this.rootId = Schema.ensureRootId(rootId) 21 | const dbPath = path.join(__dirname, '../database') 22 | if (!fs.existsSync(dbPath)) { 23 | fs.mkdirSync(dbPath) 24 | } 25 | this.store = new Quadstore({ backend: new ClassicLevel(dbPath), dataFactory: DataFactory }) 26 | } 27 | 28 | /** 29 | * Imports the data in the triple store. 30 | * @param dataPath path to an RDF, turtle or other triple format. 31 | * @returns {*|Promise|Promise|undefined} 32 | */ 33 | async loadFileData(dataPath) { 34 | if (!dataPath) { 35 | throw new Error('Missing path to data file.') 36 | } 37 | if (!fs.existsSync(dataPath)) { 38 | throw new Error(`File '${dataPath}' does not exist.`) 39 | } 40 | await this.store.open() 41 | const streamParser = new StreamParser({ format: 'text/turtle' }) 42 | const inputStream = fs.createReadStream(dataPath) 43 | try { 44 | return this.store.putStream(inputStream.pipe(streamParser), { batchSize: 100 }).then(() => { 45 | this.store.close() 46 | }) 47 | } catch (e) { 48 | console.error(e) 49 | return Promise.resolve() 50 | } 51 | } 52 | 53 | /** 54 | * Returns the triple count. 55 | */ 56 | async countTriples() { 57 | // expensive wayt to do it but it's the only one 58 | const found = await this.getQuads({}) 59 | return !found ? 0 : found.length 60 | } 61 | 62 | /** 63 | * Clears the whole store. 64 | */ 65 | async clear() { 66 | await this.store.open() 67 | return new Promise((resolve, reject) => { 68 | this.store 69 | .removeMatches(null, null, null) 70 | .on('error', (err) => { 71 | reject(err) 72 | }) 73 | .on('end', () => { 74 | console.log('The store has been emptied.') 75 | resolve() 76 | }) 77 | }).then(() => { 78 | void this.store.close() 79 | }) 80 | } 81 | 82 | /** 83 | * Returns all Uri's of ontology classes. 84 | * @param onlyOwn include only the classes from the root namespace 85 | * @returns {Promise} 86 | */ 87 | async getClassUris(onlyOwn = true) { 88 | const found = await this.getQuads({ predicate: SpecialNodes.a, object: SpecialNodes.owlClass }) 89 | const result = [] 90 | for (let i = 0; i < found.length; i++) { 91 | const uri = found[i].subject.id 92 | if (onlyOwn && !uri.startsWith(this.rootId)) { 93 | continue 94 | } 95 | result.push(uri) 96 | } 97 | return result 98 | } 99 | 100 | /** 101 | * Returns all object properties of the current namespace 102 | * as an array of graph links (plain objects with uri, from and to). 103 | * @param onlyOwn include only the elements from the root namespace 104 | * @returns {Promise<[]>} 105 | */ 106 | async getSimplifiedObjectProperties(onlyOwn = true) { 107 | // SPARQL implementation: right thing to do but horrendously slow. 108 | // 109 | // let filter = ''; 110 | // if (onlyOwn) { 111 | // filter += `FILTER(STRSTARTS(STR(?uri), "${this.rootId}")) `; 112 | // } 113 | // let query = `SELECT ?d ?uri ?r WHERE { 114 | // ?uri a <${Schema.objectProperty}>; 115 | // <${Schema.domain}> ?d. 116 | // OPTIONAL { ?uri <${Schema.range}> ?ra } 117 | // bind(coalesce(?ra, 'None') as ?r) 118 | // ${filter} 119 | // }`; 120 | // return new Promise((resolve, reject) => { 121 | // this.sparql.query(query, null, (err, result) => { 122 | // if (err) { 123 | // reject(err); 124 | // } 125 | // resolve(JSON.parse(result).map(u => { 126 | // return {domain: u['?d'], uri: u['?uri'], range: u['?r']}; 127 | // })); 128 | // }); 129 | // }); 130 | 131 | // rather than building up all OntologyObjectProperty classes we pick 132 | // up all domains and ranges 133 | const uris = {} 134 | const domainQuads = await this.getQuads({ predicate: SpecialNodes.domain }) 135 | for (let i = 0; i < domainQuads.length; i++) { 136 | const q = domainQuads[i] 137 | const uri = q.subject.id 138 | const domain = q.object.id 139 | if (!domain || (onlyOwn && !uri.startsWith(this.rootId))) { 140 | continue 141 | } 142 | if (!uris[uri]) { 143 | // we'll disentangle the multiple links below 144 | uris[uri] = { froms: new Set(), tos: new Set() } 145 | } 146 | uris[uri].froms.add(domain) 147 | } 148 | const rangeQuads = await this.getQuads({ predicate: SpecialNodes.range }) 149 | for (let i = 0; i < rangeQuads.length; i++) { 150 | const q = rangeQuads[i] 151 | const uri = q.subject.id 152 | const range = q.object.id 153 | if (!range || (onlyOwn && !uri.startsWith(this.rootId))) { 154 | continue 155 | } 156 | if (!uris[uri]) { 157 | uris[uri] = { froms: new Set(), tos: new Set() } 158 | } 159 | uris[uri].tos.add(range) 160 | } 161 | 162 | // disentangle the arrays 163 | const result = [] 164 | Object.keys(uris).forEach((k) => { 165 | // object properties sometimes do not define a range or domain (corrupt ontology) 166 | if (uris[k].froms.size === 0 || uris[k].tos.size === 0) { 167 | return 168 | } 169 | 170 | // to arrays 171 | uris[k].froms = Array.from(uris[k].froms) 172 | uris[k].tos = Array.from(uris[k].tos) 173 | 174 | // all combinations between the froms and the tos: 175 | for (let i = 0; i < uris[k].froms.length; i++) { 176 | for (let j = 0; j < uris[k].tos.length; j++) { 177 | const from = uris[k].froms[i] 178 | const to = uris[k].tos[j] 179 | if (onlyOwn && (!from.startsWith(this.rootId) || !to.startsWith(this.rootId))) { 180 | continue 181 | } 182 | result.push({ 183 | uri: k, 184 | from: from, 185 | to: to, 186 | }) 187 | } 188 | } 189 | }) 190 | 191 | return result 192 | } 193 | 194 | /** 195 | * Returns the quads satisfying the given quad (SPOG) pattern. 196 | * @param pattern a quad pattern 197 | * @returns {Promise} 198 | */ 199 | async getQuads(pattern) { 200 | await this.store.open() 201 | const { items } = await this.store.get(pattern) 202 | await this.store.close() 203 | if (!items || items.length === 0) { 204 | return [] 205 | } 206 | return items 207 | } 208 | 209 | /** 210 | * Returns the first label of the specified Uri. 211 | * @param uri Uri of a node 212 | * @returns {Promise} 213 | */ 214 | async getFirstLabel(uri) { 215 | if (!isNamedNode(uri)) { 216 | uri = Schema.toUri(this.rootId, uri) 217 | } 218 | const found = await this.getQuads({ subject: uri, predicate: SpecialNodes.label }) 219 | if (!found || found.length === 0) { 220 | return null 221 | } 222 | return found[0].object.value 223 | } 224 | 225 | /** 226 | * Returns the first comment of the specified Uri. 227 | * @param uri Uri of a node 228 | * @returns {Promise} 229 | */ 230 | async getFirstComment(uri) { 231 | if (!isNamedNode(uri)) { 232 | uri = Schema.toUri(this.rootId, uri) 233 | } 234 | const found = await this.getQuads({ subject: uri, predicate: SpecialNodes.comment }) 235 | if (!found || found.length === 0) { 236 | return null 237 | } 238 | return found[0].value 239 | } 240 | 241 | /** 242 | * Returns the quads defining the given object property. 243 | * @param propertyName name or node or Uri 244 | * @param includeCommentAndLabel whether the label and comment should be fetched as well. If false the quads are present but the value is null. 245 | * @returns {Promise} 246 | */ 247 | getObjectPropertyQuads(propertyName, includeCommentAndLabel = true) { 248 | const node = Schema.toUri(this.rootId, propertyName) 249 | return new Promise(async (resolve, reject) => { 250 | const domainQuads = await this.getQuads({ subject: node, predicate: SpecialNodes.domain }) 251 | let domains = [] 252 | if (domainQuads) { 253 | domains = domainQuads.map((q) => q.object.id) 254 | } 255 | const rangeQuads = await this.getQuads({ subject: node, predicate: SpecialNodes.range }) 256 | let ranges = [] 257 | if (rangeQuads) { 258 | ranges = rangeQuads.map((q) => q.object.id) 259 | } 260 | const label = includeCommentAndLabel ? await this.getFirstLabel(node) : null 261 | const comment = includeCommentAndLabel ? await this.getFirstComment(node) : null 262 | const quads = Schema.getObjectPropertyQuads( 263 | this.rootId, 264 | propertyName, 265 | domains, 266 | ranges, 267 | label, 268 | comment, 269 | ) 270 | resolve(quads) 271 | }) 272 | } 273 | 274 | /** 275 | * Returns the quads defining the given data property. 276 | * @param propertyName name or node or Uri 277 | * @param includeCommentAndLabel whether the label and comment should be fetched as well. If false the quads are present but the value is null. 278 | * @returns {Promise} 279 | */ 280 | getDataPropertyQuads(propertyName, includeCommentAndLabel = true) { 281 | const node = Schema.toUri(this.rootId, propertyName) 282 | return new Promise(async (resolve, reject) => { 283 | const domainQuads = await this.getQuads({ subject: node, predicate: SpecialNodes.domain }) 284 | let domains = [] 285 | if (domainQuads) { 286 | domains = domainQuads.map((q) => q.object.id) 287 | } 288 | const label = includeCommentAndLabel ? await this.getFirstLabel(node) : null 289 | const comment = includeCommentAndLabel ? await this.getFirstComment(node) : null 290 | const quads = Schema.getDataPropertyQuads(this.rootId, propertyName, domains, label, comment) 291 | resolve(quads) 292 | }) 293 | } 294 | 295 | /** 296 | * Fetches the quads defining the class with the given name. 297 | * @param className class name or Uri or node 298 | * @param includeCommentAndLabel whether the label and comment should be fetched as well. If false the quads are present but the value is null. 299 | * @returns {Promise} 300 | */ 301 | getClassQuads(className, includeCommentAndLabel = true) { 302 | const node = Schema.toUri(this.rootId, className) 303 | return new Promise(async (resolve, reject) => { 304 | let parentClassName = null 305 | const parentQuad = await this.getQuads({ subject: node, predicate: SpecialNodes.subClassOf }) 306 | if (parentQuad && parentQuad.length > 0) { 307 | // we'll ignore multiple inheritance 308 | parentClassName = parentQuad[0].object 309 | } 310 | const label = includeCommentAndLabel ? await this.getFirstLabel(node) : null 311 | const comment = includeCommentAndLabel ? await this.getFirstComment(node) : null 312 | const quads = Schema.getClassQuads(this.rootId, className, parentClassName, label, comment) 313 | resolve(quads) 314 | }) 315 | } 316 | 317 | /** 318 | * Returns the data properties of the specified class. 319 | * @param className name, uri or node of a class 320 | * @returns {Promise} 321 | */ 322 | getDataPropertyUrisOfClass(className) { 323 | let classId 324 | if (isNamedNode(className)) { 325 | classId = className.id 326 | } else { 327 | const classNode = Schema.toUri(this.rootId, className) 328 | classId = classNode.id 329 | } 330 | return new Promise(async (resolve, reject) => { 331 | const allProps = await this.getDataPropertyUris() 332 | const classProps = new Set() 333 | if (!allProps || allProps.length === 0) { 334 | return resolve(classProps) 335 | } 336 | for (let i = 0; i < allProps.length; i++) { 337 | const propUri = allProps[i] 338 | const propNode = namedNode(propUri) 339 | const domQuads = await this.getQuads({ subject: propNode, predicate: SpecialNodes.domain }) 340 | if (!domQuads) { 341 | continue 342 | } 343 | domQuads.forEach((q) => { 344 | if (q.object.id === classId) { 345 | classProps.add(propUri) 346 | } 347 | }) 348 | } 349 | resolve(Array.from(classProps)) 350 | }) 351 | } 352 | 353 | /** 354 | * Returns the object properties of the specified class. 355 | * @param className name, uri or node of a class 356 | * @returns {Promise} 357 | */ 358 | getObjectPropertyUrisOfClass(className) { 359 | let classId 360 | if (isNamedNode(className)) { 361 | classId = className.id 362 | } else { 363 | const classNode = Schema.toUri(this.rootId, className) 364 | classId = classNode.id 365 | } 366 | return new Promise(async (resolve, reject) => { 367 | const allProps = await this.getObjectPropertyUris() 368 | const classProps = new Set() 369 | if (!allProps || allProps.length === 0) { 370 | return resolve(classProps) 371 | } 372 | for (let i = 0; i < allProps.length; i++) { 373 | const propUri = allProps[i] 374 | const propNode = namedNode(propUri) 375 | const domQuads = await this.getQuads({ subject: propNode, predicate: SpecialNodes.domain }) 376 | if (!domQuads) { 377 | continue 378 | } 379 | domQuads.forEach((q) => { 380 | if (q.object.id === classId) { 381 | classProps.add(propUri) 382 | } 383 | }) 384 | } 385 | resolve(Array.from(classProps)) 386 | }) 387 | } 388 | 389 | getDataPropertyUris() { 390 | return new Promise(async (resolve, reject) => { 391 | const quads = await this.getQuads({ 392 | predicate: SpecialNodes.a, 393 | object: SpecialNodes.owlDatatypeProperty, 394 | }) 395 | if (!quads) { 396 | resolve([]) 397 | } else { 398 | resolve(quads.map((q) => q.subject.id)) 399 | } 400 | }) 401 | } 402 | 403 | getObjectPropertyUris() { 404 | return new Promise(async (resolve, reject) => { 405 | const quads = await this.getQuads({ 406 | predicate: SpecialNodes.a, 407 | object: SpecialNodes.owlObjectProperty, 408 | }) 409 | if (!quads) { 410 | resolve([]) 411 | } else { 412 | resolve(quads.map((q) => q.subject.id)) 413 | } 414 | }) 415 | } 416 | 417 | /** 418 | * Adds an ontology class to the ontology. 419 | * @param className the name of the new class or named node (allows for non-root classes). 420 | * @param parentClassName optional parent class 421 | * @returns {Promise} 422 | */ 423 | async addClass(className, parentClassName = null, label = null, comment = null) { 424 | await this.store.open() 425 | await this.ensureDoesNotExist(className) 426 | if (!parentClassName) { 427 | const classQuads = Schema.getClassQuads(this.rootId, className, null, label, comment) 428 | return this.store.multiPut(classQuads) 429 | } else { 430 | return this.store 431 | .multiPut(Schema.getClassQuads(this.rootId, parentClassName)) 432 | .then(() => { 433 | return this.store.multiPut( 434 | Schema.getClassQuads(this.rootId, className, parentClassName, label, comment), 435 | ) 436 | }) 437 | .then(() => this.store.close()) 438 | } 439 | } 440 | 441 | /** 442 | * Adds an ontology object property to the ontology. 443 | * @param propertyName name, uri or node of the new property 444 | * @param domain name, uri or node of the domain 445 | * @param range name, uri or node of the range 446 | * @param label optional label to set 447 | * @param comment optional comment 448 | * @returns {Promise} 449 | */ 450 | async addObjectProperty(propertyName, domain, range, label = null, comment = null) { 451 | await this.store.open() 452 | await this.ensureDoesNotExist(propertyName) 453 | propertyName = this.ensureParameter(propertyName, 'propertyName') 454 | domain = this.ensureParameter(domain, 'domain') 455 | range = this.ensureParameter(range, 'range') 456 | 457 | return this.store 458 | .multiPut( 459 | Schema.getObjectPropertyQuads(this.rootId, propertyName, domain, range, label, comment), 460 | ) 461 | .then(() => this.store.close()) 462 | } 463 | 464 | /** 465 | * Adds a data property to the ontology. 466 | * @param propertyName name, uri or node of the new property 467 | * @param domain name, uri or node of the domain 468 | * @param label optional label to set 469 | * @param comment optional comment 470 | * @returns {Promise} 471 | */ 472 | async addDatatypeProperty(propertyName, domain, label = null, comment = null) { 473 | await this.store.open() 474 | propertyName = this.ensureParameter(propertyName, 'propertyName') 475 | domain = this.ensureParameter(domain, 'domain') 476 | await this.ensureDoesNotExist(propertyName) 477 | 478 | return this.store 479 | .multiPut(Schema.getDataPropertyQuads(this.rootId, propertyName, domain, label, comment)) 480 | .then(() => this.store.close()) 481 | } 482 | 483 | /** 484 | * Check the validity of the given value 485 | * @param s a value 486 | * @param name the name of the corresponding parameter 487 | * @returns {*} 488 | */ 489 | ensureParameter(s, name) { 490 | if (!s) { 491 | throw new Error(`Got nil parameter '${name}'.`) 492 | } 493 | if (typeof s !== 'string' && !isNamedNode(s)) { 494 | throw new Error(`Expected parameter '${name}' to be a string or a node.`) 495 | } 496 | if (isNamedNode(s)) { 497 | return s.id 498 | } 499 | return s 500 | } 501 | 502 | /** 503 | * Returns whether the given class name exists in the current ontology. 504 | * @param className name, node or Uri 505 | * @returns {Promise} 506 | */ 507 | classExists(className) { 508 | return new Promise((resolve, reject) => { 509 | const node = Schema.toUri(this.rootId, className) 510 | const found = [] 511 | this.store 512 | .match(node, SpecialNodes.a, SpecialNodes.owlClass) 513 | .on('error', (err) => { 514 | reject(err) 515 | }) 516 | .on('data', (quad) => { 517 | found.push(quad) 518 | }) 519 | .on('end', () => { 520 | resolve(found.length > 0) 521 | }) 522 | }) 523 | } 524 | 525 | async objectPropertyExists(propertyName) { 526 | await this.store.open() 527 | return new Promise((resolve, reject) => { 528 | const node = Schema.toUri(this.rootId, propertyName) 529 | const found = [] 530 | this.store 531 | .match(node, SpecialNodes.a, SpecialNodes.owlObjectProperty) 532 | .on('error', (err) => { 533 | reject(err) 534 | }) 535 | .on('data', (quad) => { 536 | found.push(quad) 537 | }) 538 | .on('end', () => { 539 | resolve(found.length > 0) 540 | }) 541 | }).then((exists) => { 542 | this.store.close() 543 | return exists 544 | }) 545 | } 546 | 547 | async dataPropertyExists(propertyName) { 548 | await this.store.open() 549 | return new Promise((resolve, reject) => { 550 | const node = Schema.toUri(this.rootId, propertyName) 551 | const found = [] 552 | this.store 553 | .match(node, SpecialNodes.a, SpecialNodes.owlDatatypeProperty) 554 | .on('error', (err) => { 555 | reject(err) 556 | }) 557 | .on('data', (quad) => { 558 | found.push(quad) 559 | }) 560 | .on('end', () => { 561 | resolve(found.length > 0) 562 | }) 563 | }).then((exists) => { 564 | this.store.close() 565 | return exists 566 | }) 567 | } 568 | 569 | async ensureDoesNotExist(uri) { 570 | uri = Schema.toUri(this.rootId, uri) 571 | const found = await this.getOntologyType(uri) 572 | if (found) { 573 | throw new Error(`An ontology object with Uri '${uri}' already exists with type '${found}'.`) 574 | } 575 | } 576 | 577 | getOntologyType(uri) { 578 | if (!uri) { 579 | throw new Error('Missing Uri parameter.') 580 | } 581 | if (typeof uri === 'string') { 582 | if (!uri.startsWith('http')) { 583 | throw new Error('The Uri parameter should be a http address.') 584 | } 585 | uri = namedNode(uri) 586 | } else if (!isNamedNode(uri)) { 587 | throw new Error('Uri parameter should be an http address or node.') 588 | } 589 | return new Promise((resolve, reject) => { 590 | const found = [] 591 | this.store 592 | .match(uri, SpecialNodes.a, null) 593 | .on('error', (err) => { 594 | reject(err) 595 | }) 596 | .on('data', (quad) => { 597 | found.push(quad) 598 | }) 599 | .on('end', () => { 600 | switch (found.length) { 601 | case 0: 602 | resolve(null) 603 | break 604 | case 1: 605 | resolve(Schema.toShortForm(this.rootId, found[0].object.id)) 606 | break 607 | default: 608 | // this one is problematic, having multiple inheritance makes things complicated 609 | resolve('Other') 610 | } 611 | }) 612 | }) 613 | } 614 | 615 | /** 616 | * Returns a graph (in json format) containing the Uris of the classes and the object properties. 617 | * Data properties, labels and comments are not included. 618 | */ 619 | async getSimplifiedOntologyGraph(onlyOwn = true, onlyConnected = true) { 620 | if (onlyConnected) { 621 | const links = await this.getSimplifiedObjectProperties(onlyOwn) 622 | const nodes = new Set() 623 | links.forEach((l) => { 624 | nodes.add(l.from) 625 | nodes.add(l.to) 626 | }) 627 | return { 628 | nodes: Array.from(nodes), 629 | links: links, 630 | } 631 | } else { 632 | const nodes = await this.getClassUris(onlyOwn) 633 | const links = await this.getSimplifiedObjectProperties(onlyOwn) 634 | return { 635 | nodes: nodes, 636 | links: links, 637 | } 638 | } 639 | } 640 | } 641 | -------------------------------------------------------------------------------- /sample-database/test/elements.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import { faker } from '@faker-js/faker' 3 | import { OntologyClass } from '../src' 4 | 5 | const rootId = faker.internet.url() 6 | describe('Elements', () => { 7 | describe('OntologyClass', () => { 8 | test('serialize', async function () { 9 | const className = faker.lorem.word() 10 | const cl = new OntologyClass(rootId, className) 11 | const obj = cl.toJson() 12 | expect(obj.id).toEqual(null) 13 | expect(obj.name).toEqual(className) 14 | }, 15000) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /sample-database/test/knowledge.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import { faker } from '@faker-js/faker' 3 | import { DataFactory } from 'n3' 4 | import * as path from 'node:path' 5 | import { Knowledge } from '../src' 6 | import { Schema, SpecialUri } from '../src/ontology' 7 | 8 | const KnowledgeTest = Knowledge 9 | 10 | const rootId = faker.internet.url() 11 | const knowledge = new KnowledgeTest(rootId) 12 | 13 | describe('Knowledge', () => { 14 | describe('addClass', () => { 15 | test('should add a class', async () => { 16 | const className = faker.string.uuid() 17 | const classId = Schema.toUri(rootId, className) 18 | const cls = await knowledge.addClass(className) 19 | const exists = await knowledge.classExists(className) 20 | expect(exists).toBe(true) 21 | expect(cls.name).toBe(className) 22 | expect(cls.parentName).toBe('Thing') 23 | expect(cls.parentId).toEqual(SpecialUri.thing) 24 | expect(cls.id).toEqual(classId.id) 25 | }, 150000) 26 | }) 27 | 28 | describe('addObjectProperty', () => { 29 | test('should add a new property', async () => { 30 | const propertyName = faker.string.uuid() 31 | const domainName = faker.string.uuid() 32 | const rangeName = faker.string.uuid() 33 | const propId = Schema.toUri(rootId, propertyName) 34 | const def = { 35 | id: propId, 36 | name: propertyName, 37 | domain: domainName, 38 | range: rangeName, 39 | } 40 | const prop1 = await knowledge.addObjectProperty(def) 41 | 42 | const exists = await knowledge.objectPropertyExists(propertyName) 43 | expect(exists).toBe(true) 44 | 45 | const prop2 = await knowledge.getObjectProperty(propertyName) 46 | const j1 = prop1.toJson() 47 | const j2 = prop2.toJson() 48 | 49 | expect(j1).toEqual(j2) 50 | }, 150000) 51 | }) 52 | 53 | describe('addDatatypeProperty', () => { 54 | test('should add a new property', async () => { 55 | const propertyName = faker.string.uuid() 56 | const domainName = faker.string.uuid() 57 | const rangeName = faker.string.uuid() 58 | const propId = Schema.toUri(rootId, propertyName) 59 | const def = { 60 | id: propId, 61 | name: propertyName, 62 | domain: domainName, 63 | range: rangeName, 64 | label: propertyName, 65 | } 66 | const prop1 = await knowledge.addDatatypeProperty(def) 67 | 68 | const exists = await knowledge.dataPropertyExists(propertyName) 69 | expect(exists).toBe(true) 70 | 71 | const prop2 = await knowledge.getDataProperty(propertyName) 72 | const j1 = prop1.toJson() 73 | const j2 = prop2.toJson() 74 | 75 | expect(j1).toEqual(j2) 76 | }, 150000) 77 | }) 78 | 79 | describe('getClass', () => { 80 | test('should get the class', async () => { 81 | const className = faker.string.uuid() 82 | const cl1 = await knowledge.addClass(className) 83 | const cl2 = await knowledge.getClass(className) 84 | const j1 = cl1.toJson() 85 | const j2 = cl2.toJson() 86 | expect(j1).toEqual(j2) 87 | }) 88 | }) 89 | 90 | describe('getAllClassUris', () => { 91 | test('should get only own classes', async () => { 92 | const className = faker.string.uuid() 93 | const classId = Schema.toUri(rootId, className) 94 | // add in-namespace class 95 | await knowledge.addClass(className) 96 | // add out-namespace class 97 | const node = DataFactory.namedNode(`http://whatever.com/${faker.string.uuid()}`) 98 | await knowledge.addClass(node) 99 | 100 | const all = await knowledge.getAllClassUris(false) 101 | const own = await knowledge.getAllClassUris(true) 102 | 103 | let found = own.find((s) => s === classId.id) 104 | expect(found).toBeTruthy() 105 | // external should not be there 106 | found = own.find((s) => s === node.id) 107 | expect(found).toBeFalsy() 108 | // but should be in the full collection 109 | found = all.filter((s) => s === node.id) 110 | expect(found.length).toBe(1) 111 | }) 112 | }) 113 | describe('loadData', () => { 114 | test('should import DbPedia', async () => { 115 | await knowledge.clear() 116 | let count = await knowledge.countTriples() 117 | expect(count).toBe(0) 118 | await knowledge.loadData(path.join(__dirname, '../../data/DbPedia.ttl')) 119 | count = await knowledge.countTriples() 120 | expect(count).toBeGreaterThan(0) 121 | console.log(`There are now ${count} triples in the store.`) 122 | }, 150000) 123 | }) 124 | 125 | describe('getSimplifiedObjectProperties', () => { 126 | test('should return the DbPedia object props', async () => { 127 | // let's take dbpedia since the testing namespace might not have any 128 | const ns = knowledge.rootId 129 | knowledge.rootId = 'http://dbpedia.org/' 130 | const props = await knowledge.getSimplifiedObjectProperties(true) 131 | knowledge.rootId = ns // set it back for rest of tests 132 | expect(Object.keys(props).length).toBeGreaterThan(0) 133 | console.log(`There are ${Object.keys(props).length} object properties.`) 134 | }, 150000) 135 | }) 136 | 137 | describe('getDataPropertyUrisOfClass', () => { 138 | test('should return the data props', async () => { 139 | // let's take dbpedia since the testing namespace might not have any 140 | const ns = knowledge.rootId 141 | knowledge.rootId = 'http://dbpedia.org/ontology/' 142 | const props = await knowledge.getDataPropertyUrisOfClass('Person') 143 | knowledge.rootId = ns // set it back for rest of tests 144 | expect(props.length).toBeGreaterThan(0) 145 | expect(props.includes('http://dbpedia.org/ontology/numberOfRun')).toBeTruthy() 146 | console.log(props) 147 | }, 150000) 148 | }) 149 | describe('getObjectPropertyUrisOfClass', () => { 150 | test('should return the object props', async () => { 151 | // let's take dbpedia since the testing namespace might not have any 152 | const ns = knowledge.rootId 153 | knowledge.rootId = 'http://dbpedia.org/ontology/' 154 | const props = await knowledge.getObjectPropertyUrisOfClass('Person') 155 | knowledge.rootId = ns // set it back for rest of tests 156 | expect(props.length).toBeGreaterThan(0) 157 | expect(props.includes('http://dbpedia.org/ontology/almaMater')).toBeTruthy() 158 | console.log(props) 159 | }, 150000) 160 | }) 161 | }) 162 | -------------------------------------------------------------------------------- /sample-database/test/schema.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import { DataFactory, Util } from 'n3' 3 | import { 4 | Schema as schemaTest, 5 | SpecialNodes as nodes, 6 | QuadType, 7 | OntologyType, 8 | } from '../src/ontology' 9 | 10 | const { quad, namedNode } = DataFactory 11 | const { isNamedNode } = Util 12 | 13 | describe('Schema', () => { 14 | describe('toUri', () => { 15 | test('should return root when no args are given', async () => { 16 | const uri = schemaTest.toUri('http://abc/') 17 | expect(isNamedNode(uri)).toBe(true) 18 | expect(uri.id).toEqual('http://abc/') 19 | }) 20 | 21 | test('should fix the trailing slash', async () => { 22 | const uri = schemaTest.toUri('http://wat.com') 23 | expect(uri.id).toEqual('http://wat.com/') 24 | }) 25 | 26 | test('should concat args', async () => { 27 | const uri = schemaTest.toUri('http://wat.com', 'a', 'b') 28 | expect(uri.id).toEqual('http://wat.com/a/b') 29 | }) 30 | 31 | test('should recognize the named node root', async () => { 32 | const uri = schemaTest.toUri(namedNode('http://qa.com'), 'a', 'b') 33 | expect(uri.id).toEqual('http://qa.com/a/b') 34 | }) 35 | }) 36 | describe('quadType', () => { 37 | test('should return a class type', async () => { 38 | const q = quad(schemaTest.random.uri, nodes.owlClass, schemaTest.random.uri) 39 | const type = schemaTest.getQuadType(q) 40 | expect(type).toEqual(QuadType.Class) 41 | }) 42 | 43 | test('should return an other type', async () => { 44 | const q = quad(schemaTest.random.uri, schemaTest.random.uri, schemaTest.random.uri) 45 | const type = schemaTest.getQuadType(q) 46 | expect(type).toEqual(QuadType.Other) 47 | }) 48 | }) 49 | describe('getOntologyTypeOfQuads', () => { 50 | test('should return a class ', async () => { 51 | const q = schemaTest.random.classQuads 52 | const type = schemaTest.getOntologyTypeOfQuads(q) 53 | expect(type).toEqual(OntologyType.Class) 54 | }) 55 | test('should return a property ', async () => { 56 | const q = schemaTest.random.datatypePropertyQuads 57 | const type = schemaTest.getOntologyTypeOfQuads(q) 58 | expect(type).toEqual(OntologyType.DatatypeProperty) 59 | }) 60 | }) 61 | describe('getClassDetailsFromQuads', () => { 62 | test('should return all info ', async () => { 63 | const { details, quads } = schemaTest.random.classQuadsAndDetails 64 | const def = schemaTest.getClassDetailsFromQuads(details.root, quads) 65 | expect(def.id).toEqual(details.uri.id) 66 | expect(def.name).toEqual(details.name) 67 | expect(def.parentName).toEqual(details.parentName) 68 | expect(def.parentId).toEqual(details.parentUri.id) 69 | }) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /sample-database/test/store.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import { OntologyStore } from '../src/store' 3 | import { faker } from '@faker-js/faker' 4 | import { Schema } from '../src/ontology' 5 | 6 | const storeTest = new OntologyStore(faker.internet.url()) 7 | 8 | describe('OntologyStore', function () { 9 | describe('addClass', () => { 10 | test('should add a class', async function () { 11 | const className = faker.string.uuid() 12 | const parentClassName = faker.lorem.word() 13 | await storeTest.addClass(className, parentClassName) 14 | const found = await storeTest.getClassQuads(className) 15 | expect(found.length).toBe(4) // gives four quads 16 | console.log(found) 17 | }, 150000) 18 | }) 19 | 20 | describe('getObjectPropertyQuads', () => { 21 | test('should get the shipCrew quads', async () => { 22 | // getting the shipCrew props from dbpedia 23 | const ns = storeTest.rootId 24 | storeTest.rootId = 'http://dbpedia.org/ontology/' 25 | const q = await storeTest.getObjectPropertyQuads('shipCrew') 26 | expect(q.length).toBeGreaterThan(0) 27 | const plain = Schema.getObjectPropertyDetailsFromQuads(storeTest.rootId, q) 28 | console.log(plain) 29 | expect(plain.name).toEqual('shipCrew') 30 | expect(plain.rangeIds.length).toEqual(1) 31 | expect(plain.domainIds.length).toEqual(1) 32 | // reset to the random one 33 | storeTest.rootId = ns 34 | }, 150000) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /sample-database/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | 3 | export default defineConfig(() => { 4 | return {} 5 | }) 6 | -------------------------------------------------------------------------------- /server/api.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | const router = express.Router() 3 | import { Knowledge } from 'ontology-database' 4 | 5 | const ontology = new Knowledge('http://dbpedia.org/ontology') 6 | 7 | router.get('/', async (req, res) => { 8 | res.json(true) 9 | }) 10 | 11 | router.post('/getClass', async (req, res) => { 12 | const name = req.body.className 13 | const includeProps = req.body.includeProps || false 14 | const found = await ontology.getClass(name, includeProps) 15 | res.json(found ? found.toJson() : null) 16 | }) 17 | 18 | router.get('/getSimplifiedOntologyGraph', async (req, res) => { 19 | const found = await ontology.getSimplifiedOntologyGraph() 20 | res.json(found) 21 | }) 22 | 23 | router.get('/getDataPropertyUrisOfClass', async (req, res) => { 24 | const name = req.body.name 25 | const found = await ontology.getDataPropertyUrisOfClass(name) 26 | res.json(found) 27 | }) 28 | router.get('/getObjectPropertyUrisOfClass', async (req, res) => { 29 | const name = req.body.name 30 | const found = await ontology.getObjectPropertyUrisOfClass(name) 31 | res.json(found) 32 | }) 33 | 34 | export default router 35 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ontology-server", 3 | "version": "2.0.0", 4 | "type": "module", 5 | "private": true, 6 | "scripts": { 7 | "start": "node ./service" 8 | }, 9 | "dependencies": { 10 | "cors": "^2.8.5", 11 | "express": "^4.21.2", 12 | "http-errors": "^2.0.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /server/service.js: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path' 2 | import { dirname } from 'node:path' 3 | import { fileURLToPath } from 'node:url' 4 | import createError from 'http-errors' 5 | import express from 'express' 6 | import cors from 'cors' 7 | import api from './api.js' 8 | 9 | const __dirname = dirname(fileURLToPath(import.meta.url)) 10 | 11 | const app = express() 12 | app.use(cors()) 13 | app.use(express.json()) 14 | app.use(express.urlencoded({ extended: false })) 15 | app.use(express.static(path.join(__dirname, 'public'))) 16 | const port = 3001 17 | 18 | app.get('/', (req, res) => res.send('This is the ontology visualizer REST backend.')) 19 | app.use('/api', api) 20 | // catch 404 and forward to error handler 21 | app.use(function (req, res, next) { 22 | next(createError(404)) 23 | }) 24 | // error handler 25 | app.use(function (err, req, res) { 26 | // set locals, only providing error in development 27 | res.locals.message = err.message 28 | res.locals.error = req.app.get('env') === 'development' ? err : {} 29 | 30 | // render the error page 31 | res.status(err.status || 500) 32 | res.send(err) 33 | }) 34 | app.listen(port, () => console.log(`Listening on port ${port}!`)) 35 | 36 | export default app 37 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": [ 24 | "app" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import * as path from 'node:path' 3 | 4 | export default defineConfig(() => { 5 | return { 6 | root: path.resolve(__dirname, `./app`), 7 | } 8 | }) 9 | --------------------------------------------------------------------------------