diff --git a/package.json b/package.json index 1e75f6a..3db939f 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "workspaces": [ "packages/*" ], - "description": "Web components to for accessing SPARQL endpoints.", + "description": "Web components for accessing SPARQL endpoints.", "license": "MIT", "author": { "name": "Vincent Emonet", @@ -14,7 +14,7 @@ }, "scripts": { "build": "npm run build:overview && npm run build:editor && npm run build:demo", - "dev": "npm run dev:editor", + "dev": "npm run build:overview && npm run dev:editor", "build:editor": "npm run build -w packages/sparql-editor", "dev:editor": "npm run dev -w packages/sparql-editor", "build:overview": "npm run build -w packages/sparql-overview", diff --git a/packages/demo/package.json b/packages/demo/package.json index a3c99d6..8cb9715 100644 --- a/packages/demo/package.json +++ b/packages/demo/package.json @@ -1,7 +1,7 @@ { - "name": "@sib-swiss/sparql-editor-demo", + "name": "@sib-swiss/sparql-demo", "version": "0.2.4", - "description": "A demo website of the SPARQL query editor. Built on the popular YASGUI editor, it provides context-aware autocomplete for classes and predicates based on the content of the endpoints.", + "description": "A demo website of the SPARQL tools.", "license": "MIT", "author": { "name": "Vincent Emonet", diff --git a/packages/demo/src/index.html b/packages/demo/src/index.html index 5a72bb4..9fc6f6e 100644 --- a/packages/demo/src/index.html +++ b/packages/demo/src/index.html @@ -30,7 +30,7 @@ diff --git a/packages/sparql-editor/src/sparql-editor.ts b/packages/sparql-editor/src/sparql-editor.ts index 41a0c64..f30024e 100644 --- a/packages/sparql-editor/src/sparql-editor.ts +++ b/packages/sparql-editor/src/sparql-editor.ts @@ -106,14 +106,14 @@ export class SparqlEditor extends HTMLElement { -
`; - // TODO: + // TODO: this.appendChild(style); // NOTE: autocompleters are executed when Yasgui is instantiated @@ -204,7 +204,7 @@ export class SparqlEditor extends HTMLElement { Yasgui.Yasr.defaults.prefixes = this.meta[endpoint].prefixes; // Hide or show the Classes overview button - const clsOverviewBtn = this.querySelector("#sparql-cls-overview") as HTMLElement; + const clsOverviewBtn = this.querySelector("#sparql-cls-overview-btn") as HTMLElement; if (Object.keys(this.meta[endpoint].void).length > 0) { clsOverviewBtn.style.display = ""; } else { @@ -274,7 +274,10 @@ export class SparqlEditor extends HTMLElement { }); }); this.yasgui?.on("tabAdd", () => { - setTimeout(() => this.showExamples()); + setTimeout(() => { + this.showExamples(); + this.showOverview(); + }); }); // Button to clear and update cache of SPARQL endpoints metadata @@ -585,16 +588,14 @@ ex:${exampleUri} a sh:SPARQLExecutable${ } async showOverview() { - const overviewBtn = this.querySelector("#sparql-cls-overview") as HTMLButtonElement; - // Create dialog for examples + const overviewBtn = this.querySelector("#sparql-cls-overview-btn") as HTMLButtonElement; + const existingOverviewDialog = this.querySelector("#sparql-cls-overview-dialog") as HTMLDialogElement; + if (existingOverviewDialog) existingOverviewDialog.remove(); const overviewDialog = document.createElement("dialog"); - // exQueryDialog.style.margin = "1em"; - // exQueryDialog.style.width = "calc(100vw - 8px)"; + // Create dialog for overview + overviewDialog.id = "sparql-cls-overview-dialog"; overviewDialog.style.width = "100%"; overviewDialog.style.height = "100%"; - overviewDialog.style.borderColor = "#cccccc"; - overviewDialog.style.backgroundColor = "#f5f5f5"; - overviewDialog.style.borderRadius = "10px"; overviewDialog.innerHTML = `
`; @@ -607,13 +608,13 @@ ex:${exampleUri} a sh:SPARQLExecutable${ dialogCloseBtn.style.top = "1.5em"; dialogCloseBtn.style.right = "2em"; overviewDialog.appendChild(dialogCloseBtn); - document.body.appendChild(overviewDialog); + this.appendChild(overviewDialog); - overviewBtn.addEventListener("click", () => { - overviewDialog.showModal(); - document.body.style.overflow = "hidden"; - }); - overviewBtn.addEventListener("click", () => { + // Remove previous event listeners + overviewBtn.replaceWith(overviewBtn.cloneNode(true)); + const newOverviewBtn = this.querySelector("#sparql-cls-overview-btn") as HTMLButtonElement; + + newOverviewBtn.addEventListener("click", () => { overviewDialog.showModal(); document.body.style.overflow = "hidden"; }); diff --git a/packages/sparql-editor/src/styles.ts b/packages/sparql-editor/src/styles.ts index 0a508cc..238f632 100644 --- a/packages/sparql-editor/src/styles.ts +++ b/packages/sparql-editor/src/styles.ts @@ -7,88 +7,90 @@ // .yasr .dataTable { // font-size: 0.9em; // } -export const editorCss = `.sparql-editor-container a { - text-decoration: none; - color: #00709b; -} -.sparql-editor-container a:hover { - filter: brightness(60%); -} -.sparql-editor-container button { - cursor: pointer; -} -.sparql-editor-container { +export const editorCss = `.sparql-editor-container { + --btn-color: #e30613; + --btn-bg-color: #f8bca5; + font-family: Arial, sans-serif; display: flex; flex-direction: row; -} -.sparql-editor-container .sparql-examples { - padding-left: 1em; -} -.sparql-editor-container input.sparql-search-examples-input { - width: 300px; - padding: 0.5em; - border-radius: 5px; -} -@media (max-width: 600px) { - .sparql-editor-container { - flex-direction: column; + a { + text-decoration: none; + color: #00709b; } - .sparql-editor-container .sparql-examples { - display: none; + a:hover { + filter: brightness(60%); } - .sparql-editor-container #sparql-examples-top-btn { - display: inline-block; + dialog { + border-color: #cccccc; + background-color: #f5f5f5; + border-radius: 10px; } - .sparql-editor-container input.sparql-search-examples-input { - width: 100%; + .sparql-examples { + padding-left: 1em; } -} -@media (min-width: 600px) { - .sparql-editor-container #sparql-examples-top-btn { - display: none !important; + input.sparql-search-examples-input { + width: 300px; + padding: 0.5em; + border-radius: 5px; + } + .yasr_results { + overflow-x: auto; } -} - - -.sparql-editor-container { - --btn-color: #e30613; - --btn-bg-color: #f8bca5; - font-family: Arial, sans-serif; -} -.sparql-editor-container #status-link { + #status-link { display: inline-flex; justify-content: center; align-items: center; border-radius: 50%; cursor: pointer; padding: 3px; -} + } -@keyframes spin { - to { transform: rotate(360deg); } + button.btn { + background-color: var(--btn-bg-color); + color: var(--btn-color); + border: none; + padding: 0.3em 0.4em; + border-radius: 5px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* Subtle shadow */ + cursor: pointer; + transition: background-color 0.3s ease, box-shadow 0.3s ease; + } + button.btn:hover { + filter: brightness(90%); + box-shadow: 0 3px 6px rgba(0, 0, 0, 0.2); /* Larger shadow on hover */ + } + button.btn:active { + filter: brightness(80%); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* Reset shadow on click */ + } } -.sparql-editor-container button.btn { - background-color: var(--btn-bg-color); - color: var(--btn-color); - border: none; - padding: 0.3em 0.4em; - border-radius: 5px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* Subtle shadow */ - cursor: pointer; - transition: background-color 0.3s ease, box-shadow 0.3s ease; -} -.sparql-editor-container button.btn:hover { - filter: brightness(90%); - box-shadow: 0 3px 6px rgba(0, 0, 0, 0.2); /* Larger shadow on hover */ -} -.sparql-editor-container button.btn:active { - filter: brightness(80%); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* Reset shadow on click */ +@media (max-width: 600px) { + .sparql-editor-container { + flex-direction: column; + + .sparql-examples { + display: none; + } + #sparql-examples-top-btn { + display: inline-block; + } + input.sparql-search-examples-input { + width: 100%; + } + } +} +@media (min-width: 600px) { + .sparql-editor-container #sparql-examples-top-btn { + display: none !important; + } } +@keyframes spin { + to { transform: rotate(360deg); } +} button.yasqe_share { display: none !important; diff --git a/packages/sparql-overview/README.md b/packages/sparql-overview/README.md index be64c05..588d176 100644 --- a/packages/sparql-overview/README.md +++ b/packages/sparql-overview/README.md @@ -2,9 +2,9 @@ # ๐Ÿ’ซ SPARQL overview web component -[![NPM](https://img.shields.io/npm/v/@sib-swiss/sparql-metamap)](https://www.npmjs.com/package/@sib-swiss/sparql-metamap) -[![Tests](https://github.com/sib-swiss/sparql-metamap/actions/workflows/test.yml/badge.svg)](https://github.com/sib-swiss/sparql-metamap/actions/workflows/test.yml) -[![Deploy demo to GitHub Pages](https://github.com/sib-swiss/sparql-metamap/actions/workflows/deploy.yml/badge.svg)](https://github.com/sib-swiss/sparql-metamap/actions/workflows/deploy.yml) +[![NPM](https://img.shields.io/npm/v/@sib-swiss/sparql-overview)](https://www.npmjs.com/package/@sib-swiss/sparql-overview) +[![Tests](https://github.com/sib-swiss/sparql-editor/actions/workflows/test.yml/badge.svg)](https://github.com/sib-swiss/sparql-v/actions/workflows/test.yml) +[![Deploy demo to GitHub Pages](https://github.com/sib-swiss/sparql-editor/actions/workflows/deploy.yml/badge.svg)](https://github.com/sib-swiss/sparql-editor/actions/workflows/deploy.yml) @@ -12,34 +12,34 @@ A standard web component to visualize classes and their relations as a network. The editor retrieves VoID description about the endpoints by directly querying them with SPARQL. -๐Ÿ‘†๏ธ You can **try it** for a few SPARQL endpoints of the SIB, such as UniProt and Bgee, here: **[sib-swiss.github.io/sparql-metamap](https://sib-swiss.github.io/sparql-metamap)** +๐Ÿ‘†๏ธ You can **try it** for a few SPARQL endpoints of the SIB, such as UniProt and Bgee, here: **[sib-swiss.github.io/sparql-editor/overview](https://sib-swiss.github.io/sparql-editor/overview)** ## ๐Ÿš€ Use 1. Import from a CDN: ```html - + ``` Or install with a package manager in your project: ```bash - npm install --save @sib-swiss/sparql-metamap + npm install --save @sib-swiss/sparql-overview # or - pnpm add @sib-swiss/sparql-metamap + pnpm add @sib-swiss/sparql-overview ``` 2. Use the custom element in your HTML/JSX/TSX code: ```html - + ``` You can also pass a list of endpoints URLs separated by commas to enable users to choose from different endpoints: ```html - + ``` > [!WARNING] @@ -62,15 +62,12 @@ Create a `index.html` file with: - +
- -

About

-

This SPARQL endpoint contains...

-
+
@@ -88,4 +85,4 @@ python -m http.server # ๐Ÿง‘โ€๐Ÿ’ป Contributing -Checkout [CONTRIBUTING.md](https://github.com/sib-swiss/sparql-metamap/blob/main/CONTRIBUTING.md) for more details on how to run this in development and make a contribution. +Checkout [CONTRIBUTING.md](https://github.com/sib-swiss/sparql-editor/blob/main/CONTRIBUTING.md) for more details on how to run this in development and make a contribution. diff --git a/packages/sparql-overview/index.html b/packages/sparql-overview/index.html index 53d57d9..f7c1826 100644 --- a/packages/sparql-overview/index.html +++ b/packages/sparql-overview/index.html @@ -18,7 +18,7 @@
- +
diff --git a/packages/sparql-overview/src/sparql-overview.ts b/packages/sparql-overview/src/sparql-overview.ts index 3de938a..8b7f84d 100644 --- a/packages/sparql-overview/src/sparql-overview.ts +++ b/packages/sparql-overview/src/sparql-overview.ts @@ -3,75 +3,14 @@ import Sigma from "sigma"; import forceAtlas2 from "graphology-layout-forceatlas2"; import FA2Layout from "graphology-layout-forceatlas2/worker"; import iwanthue from "iwanthue"; -import {DEFAULT_EDGE_CURVATURE, EdgeCurvedArrowProgram, indexParallelEdgesIndex} from "@sigma/edge-curve"; +import {EdgeCurvedArrowProgram, indexParallelEdgesIndex} from "@sigma/edge-curve"; import {EdgeArrowProgram} from "sigma/rendering"; import type {Coordinates, EdgeDisplayData, NodeDisplayData} from "sigma/types"; // import { createNodeImageProgram } from "@sigma/node-image"; // import ForceSupervisor from "graphology-layout-force/worker"; -import {getPrefixes, compressUri, queryEndpoint, SparqlResultBindings} from "./utils"; - -const voidQuery = `PREFIX owl: -PREFIX rdfs: -PREFIX sh: -PREFIX sd: -PREFIX void: -PREFIX void-ext: -SELECT DISTINCT ?subjectClass ?prop ?objectClass ?objectDatatype ?triples -?objectClassTopParent ?objectClassTopParentLabel ?subjectClassTopParent ?subjectClassTopParentLabel -?subjectClassLabel ?objectClassLabel ?subjectClassComment ?objectClassComment ?propLabel ?propComment -WHERE { - { - SELECT * WHERE { - { - ?s sd:graph ?graph . - ?graph void:classPartition ?cp . - ?cp void:class ?subjectClass ; - void:propertyPartition ?pp . - OPTIONAL {?subjectClass rdfs:label ?subjectClassLabel } - OPTIONAL {?subjectClass rdfs:comment ?subjectClassComment } - OPTIONAL { - ?subjectClass rdfs:subClassOf* ?subjectClassTopParent . - OPTIONAL {?subjectClassTopParent rdfs:label ?subjectClassTopParentLabel} - FILTER(isIRI(?subjectClassTopParent) && ?subjectClassTopParent != owl:Thing && ?subjectClassTopParent != owl:Class) - MINUS { - ?subjectClassTopParent rdfs:subClassOf ?intermediateParent . - FILTER(?intermediateParent != owl:Thing && ?intermediateParent != owl:Class) - } - } - - ?pp void:property ?prop ; - void:triples ?triples . - OPTIONAL {?prop rdfs:label ?propLabel } - OPTIONAL {?prop rdfs:comment ?propComment } - OPTIONAL { - { - ?pp void:classPartition [ void:class ?objectClass ] . - OPTIONAL {?objectClass rdfs:label ?objectClassLabel } - OPTIONAL {?objectClass rdfs:comment ?objectClassComment } - OPTIONAL { - ?objectClass rdfs:subClassOf* ?objectClassTopParent . - OPTIONAL {?objectClassTopParent rdfs:label ?objectClassTopParentLabel} - FILTER(isIRI(?objectClassTopParent) && ?objectClassTopParent != owl:Thing && ?objectClassTopParent != owl:Class) - MINUS { - ?objectClassTopParent rdfs:subClassOf ?intermediateParent . - FILTER(?intermediateParent != owl:Thing && ?intermediateParent != owl:Class) - } - } - } UNION { - ?pp void-ext:datatypePartition [ void-ext:datatype ?objectDatatype ] . - } - } - } UNION { - ?linkset void:subjectsTarget [ void:class ?subjectClass ] ; - void:linkPredicate ?prop ; - void:objectsTarget [ void:class ?objectClass ] . - } - - } - } -} ORDER BY ?subjectClass ?objectClass ?objectDatatype ?graph ?triples`; +import {getPrefixes, compressUri, queryEndpoint, SparqlResultBindings, getEdgeCurvature, voidQuery} from "./utils"; type Cluster = { label: string; @@ -112,28 +51,30 @@ function isMetadataNode(node: string) { */ export class SparqlOverview extends HTMLElement { endpoints: {[key: string]: EndpointInfo} = {}; - // meta: EndpointsMetadata; - // void: {[key: string]: SparqlResultBindings[]} = {}; prefixes: {[key: string]: string} = {}; showMetadata: boolean = false; + // meta: EndpointsMetadata; + // void: {[key: string]: SparqlResultBindings[]} = {}; + // Hovered stuff hoveredNode?: string; - searchQuery: string = ""; - // State derived from query: + hoveredNeighbors?: Set; + // Clicked stuff + selectedNodes: Set = new Set(); selectedNode?: string; selectedEdge?: string; + // Search classes + searchQuery: string = ""; suggestions?: Set; - // State derived from hovered node: - hoveredNeighbors?: Set; + // Predicates and clusters filters predicatesCount: {[key: string]: {count: number; label: string}} = {}; + clusters: {[key: string]: Cluster} = {}; hidePredicates: Set = new Set(); hideClusters: Set = new Set(); - clusters: {[key: string]: Cluster} = {}; - graph: Graph; - renderer: Sigma | undefined; + renderer?: Sigma; // https://github.com/jacomyal/sigma.js/issues/197 constructor() { @@ -155,51 +96,77 @@ export class SparqlOverview extends HTMLElement { } #sparql-overview { height: 100%; - } - #overview-predicate-sidebar { - float: left; - // width: fit-content; - width: 230px; - padding-right: 0.5em; - overflow-y: auto; - height: 100%; - } - #overview-predicate-sidebar p, h3, h5 { - margin: .5em 0; - } - #overview-predicate-sidebar a { - text-decoration: none; - } - #network-container { - width: 100%; - float: right; - height: 100%; - border: 1px solid lightgray; - } - #sparql-overview hr { - width: 80%; - border: none; - height: 1px; - background: lightgrey; - } - .clusterLabel { - // position: absolute; - // transform: translate(-50%, -50%); - // font-size: 1.8rem; - font-family: sans-serif; - font-variant: small-caps; - font-weight: 400; - text-shadow: 2px 2px 1px white, -2px -2px 1px white, -2px 2px 1px white, 2px -2px 1px white; - } - #sparql-overview code { - font-family: 'Fira Code', monospace; - font-size: 0.95rem; - border-radius: 6px; - padding: 0.2em 0.4em; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); - border: 1px solid #e0e0e0; - display: inline-block; - word-wrap: break-word; + + a { + text-decoration: none; + } + a:hover { + filter: brightness(60%); + text-decoration: underline; + } + #overview-predicate-sidebar { + float: left; + // width: fit-content; + width: 230px; + padding-right: 0.5em; + overflow-y: auto; + height: 100%a:hover { + filter: brightness(60%); + text-decoration: underline; +}; + } + #overview-predicate-sidebar p, h3, h5 { + margin: .5em 0; + } + #network-container { + width: 100%; + float: right; + height: 100%; + border: 1px solid lightgray; + border-radius: 2px; + } + hr { + width: 80%; + border: none; + height: 1px; + background: lightgrey; + } + .clusterLabel { + position: absolute; + // transform: translate(-50%, -50%); + font-size: 1.5rem; + font-family: sans-serif; + font-variant: small-caps; + font-weight: 400; + text-shadow: 2px 2px 1px white, -2px -2px 1px white, -2px 2px 1px white, 2px -2px 1px white; + } + code { + font-family: 'Fira Code', monospace; + font-size: 0.95rem; + border-radius: 6px; + padding: 0.2em 0.4em; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + border: 1px solid #e0e0e0; + display: inline-block; + word-wrap: break-word; + } + dialog { + border-color: #cccccc; + background-color: #f5f5f5; + border-radius: 10px; + } + button { + // background-color: var(--btn-bg-color); + // color: var(--btn-color); + font-size: 0.9em; + border: none; + padding: 0.3em 0.4em; + border-radius: 5px; + transition: background-color 0.3s ease, box-shadow 0.3s ease; + } + button:hover { + filter: brightness(90%); + } } `; const container = document.createElement("div"); @@ -207,26 +174,41 @@ export class SparqlOverview extends HTMLElement { container.style.display = "flex"; container.className = "container"; container.style.height = "100%"; + let endpointsHtml = ""; + for (const endpoint of Object.keys(this.endpoints)) { + endpointsHtml += `

${endpoint}

`; + } container.innerHTML = `
-
- +
+ +
+ +

+ Overview of classes and their relations for SPARQL endpoint +

+ ${endpointsHtml} +

+ ๐Ÿ’ก ctrl + left click to select multiple nodes. +

+ +
Filter predicates ยท - - + +

Filter clusters ยท - - + +
@@ -269,6 +251,15 @@ export class SparqlOverview extends HTMLElement { else showMetaButton.textContent = "Show metadata"; await this.initGraph(); }); + const showInfoButton = this.querySelector("#overview-show-info") as HTMLButtonElement; + const dialogInfo = this.querySelector("#overview-dialog-info") as HTMLDialogElement; + showInfoButton.addEventListener("click", async () => { + dialogInfo.showModal(); + }); + const closeInfoButton = this.querySelector("#overview-close-info") as HTMLButtonElement; + closeInfoButton.addEventListener("click", async () => { + dialogInfo.close(); + }); // Filtering buttons for clusters const showAllClustersButton = this.querySelector("#overview-show-clusters") as HTMLButtonElement; @@ -289,9 +280,6 @@ export class SparqlOverview extends HTMLElement { this.hideClusters = new Set(Object.keys(this.clusters)); this.renderer?.refresh({skipIndexation: true}); }); - - // const palette = iwanthue(Object.keys(countryClusters).length, { seed: "eurSISCountryClusters" }); - this.graph = new Graph({multi: true}); } @@ -309,13 +297,18 @@ export class SparqlOverview extends HTMLElement { this.predicatesCount = {}; this.clusters = {}; + const defaultNodeSize = 10; + // let largestNodeCount = 0; + let largestEdgeCount = 0; + + // Create nodes and edges based on SPARQL query results for (const [endpoint, info] of Object.entries(this.endpoints)) { if (!info.void) continue; for (const row of info.void) { - // const count = parseInt(row.triples.value); if (!this.showMetadata && (isMetadataNode(row.subjectClass.value) || isMetadataNode(row.objectClass?.value))) continue; - const count = 10; + const count = row.triples ? parseInt(row.triples.value) : 5; + if (largestEdgeCount < count) largestEdgeCount = count; // Get the cluster for the subject node const subjCluster = isMetadataNode(row.subjectClass.value) @@ -334,7 +327,8 @@ export class SparqlOverview extends HTMLElement { this.graph.addNode(subjUri, { label: subjCurie, curie: subjCurie, - size: count, + // count: 1, + size: defaultNodeSize, cluster: subjCluster, endpoint: endpoint, datatypes: [], @@ -346,6 +340,9 @@ export class SparqlOverview extends HTMLElement { if (row.subjectClassComment) this.graph.updateNodeAttribute(subjUri, "comment", () => row.subjectClassComment.value); } + this.graph.updateNodeAttribute(subjUri, "count", (value: number) => value + count); + // const subjCount = parseInt(this.graph.getNodeAttribute(subjUri, "count")) + // if (largestNodeCount < subjCount) largestNodeCount = subjCount; // Handle when the object is a datatype (string, integer, etc) if (row.objectDatatype) { @@ -386,7 +383,8 @@ export class SparqlOverview extends HTMLElement { this.graph.addNode(objUri, { label: objCurie, curie: objCurie, - size: count, + // count: 1, + size: defaultNodeSize, cluster: objCluster, endpoint: endpoint, datatypes: [], @@ -413,10 +411,12 @@ export class SparqlOverview extends HTMLElement { label: predCurie, curie: predCurie, uri: row.prop.value, + count: count, size: 2, - // size: count, + // TODO: dynamic size: count, ? type: "arrow", }; + if (row.triples) edgeAttrs.triples = parseInt(row.triples.value); if (row.propLabel) { edgeAttrs.curie = edgeAttrs.label; edgeAttrs.label = row.propLabel.value; @@ -436,6 +436,7 @@ export class SparqlOverview extends HTMLElement { if (this.graph.nodes().length < 2) { console.warn(`No VoID description found in endpoint ${this.endpointUrl()}`); + // TODO: show error message on screen return; } @@ -470,15 +471,44 @@ export class SparqlOverview extends HTMLElement { } } - // We need to manually set some x/y coordinates for the nodes - let i = 1; + // Define initial positions of nodes based on their clusters. + // Comment `layout.start()` to see initial position in dev + const clusterPositions: {[key: string]: {x: number; y: number}} = {}; + let clusterIndex = 0; + for (const clusterId in this.clusters) { + const angle = (clusterIndex * 2 * Math.PI) / Object.keys(this.clusters).length; + clusterIndex++; + const radius = 200; // Distance from the center for clusters + clusterPositions[clusterId] = { + x: radius * Math.cos(angle), + y: radius * Math.sin(angle), + }; + } + + // const largestNodeSize = 20; + const largestEdgeSize = 8; + + this.graph.forEachEdge((_edge, atts) => { + atts.size = (atts.count * largestEdgeSize) / largestEdgeCount; + if (atts.size < 2) atts.size = 2; + }); + + // We need to manually set some x/y coordinates for each node this.graph.forEachNode((_node, atts) => { - const angle = (i * 2 * Math.PI) / this.graph.order; - i++; - atts.x = 100 * Math.cos(angle); - atts.y = 100 * Math.sin(angle); + const clusterPos = clusterPositions[atts.cluster]; + // Add random offset to spread nodes within the cluster + const offset = 20; + const randomAngle = Math.random() * 2 * Math.PI; + const randomRadius = Math.random() * offset; + atts.x = clusterPos.x + randomRadius * Math.cos(randomAngle); + atts.y = clusterPos.y + randomRadius * Math.sin(randomAngle); + + // atts.size = atts.count * largestNodeSize / largestNodeCount; + // if (atts.size < 1) atts.size = 2; + // node color depends on the cluster it belongs to atts.color = this.clusters[atts.cluster].color; + // TODO: get largest node size up there, then define node size based on largest node // node size depends on its degree (number of connected edges) // atts.size = Math.sqrt(this.graph.degree(node)) / 2; this.clusters[atts.cluster].positions.push({x: atts.x, y: atts.y}); @@ -576,12 +606,21 @@ export class SparqlOverview extends HTMLElement { this.setHoveredNode(undefined); if (!this.selectedNode) this.displayNodeInfo(undefined); }); - // TODO: highlight node on click - this.renderer.on("clickNode", ({node}) => { + this.renderer.on("clickNode", ({node, event}) => { + if (event.original.ctrlKey) { + if (this.selectedNodes.has(node)) { + this.selectedNodes.delete(node); + return; + } else this.selectedNodes.add(node); + } else { + // Normal click: select the node and display its info + this.selectedNodes = new Set([node]); + } this.selectedNode = node; this.displayNodeInfo(node); }); this.renderer.on("clickStage", () => { + this.selectedNodes = new Set(); this.selectedNode = undefined; this.displayNodeInfo(undefined); this.selectedEdge = undefined; @@ -610,7 +649,7 @@ export class SparqlOverview extends HTMLElement { res.hidden = true; } // If a node is selected, it is highlighted - if (this.selectedNode === node) { + if (this.selectedNodes.has(node)) { res.highlighted = true; res.hidden = false; } else if (this.suggestions) { @@ -641,6 +680,16 @@ export class SparqlOverview extends HTMLElement { if (this.hoveredNode && !this.graph.hasExtremity(edge, this.hoveredNode)) { res.hidden = true; } + + // If multiple nodes are selected, show edges between selected nodes + if (this.selectedNodes && this.selectedNodes.size > 0) { + if (this.selectedNodes.has(this.graph.source(edge)) || this.selectedNodes.has(this.graph.target(edge))) { + res.hidden = false; + } else { + res.hidden = true; + } + } + if (this.hoveredNode && this.graph.hasExtremity(edge, this.hoveredNode)) { res.hidden = false; } @@ -667,8 +716,8 @@ export class SparqlOverview extends HTMLElement { return res; }); - this.renderPredicateList(); - this.renderClusterList(); + this.renderPredicatesFilter(); + this.renderClustersFilter(); // Feed the datalist autocomplete values: const searchSuggestions = this.querySelector("#suggestions") as HTMLDataListElement; @@ -678,7 +727,7 @@ export class SparqlOverview extends HTMLElement { .map(node => ``) .join("\n"); - // // Create the clustersLabel layer + // // Add clusters labels at their barycenter // const clustersLayer = document.createElement("div"); // clustersLayer.id = "clustersLayer"; // clustersLayer.style.width = "100%"; @@ -686,9 +735,8 @@ export class SparqlOverview extends HTMLElement { // clustersLayer.style.position = "absolute"; // let clusterLabelsDoms = ""; // for (const c in this.clusters) { - // // for each cluster create a div label // const cluster = this.clusters[c]; - // // adapt the position to viewport coordinates + // // For each cluster adapt the position to viewport coordinates // const viewportPos = this.renderer.graphToViewport(cluster as Coordinates); // clusterLabelsDoms += `
${cluster.label}
`; // } @@ -710,29 +758,37 @@ export class SparqlOverview extends HTMLElement { // }); setTimeout(() => { + // this.renderer?.refresh({skipIndexation: true}); layout.kill(); }, 3000); // console.log(this.graph.getNodeAttributes("http://purl.uniprot.org/core/Protein")); } - displayEdgeInfo(edge: string | undefined) { + // TODO: make edge width based on triplesCount, with a ratio based on largest triplesCount + displayEdgeInfo(edge?: string) { const edgeInfoDiv = this.querySelector("#overview-edge-info") as HTMLElement; edgeInfoDiv.innerHTML = ""; if (edge) { const edgeAttrs = this.graph.getEdgeAttributes(edge); const connectedNodes = this.graph.extremities(edge); + const triplesCount = edgeAttrs.triples ? `${edgeAttrs.triples.toLocaleString()}
` : ""; edgeInfoDiv.innerHTML = `
`; - edgeInfoDiv.innerHTML += `

${edgeAttrs.curie}

`; - if (edgeAttrs.displayLabel) edgeInfoDiv.innerHTML += `

${edgeAttrs.displayLabel}

`; - if (edgeAttrs.comment) edgeInfoDiv.innerHTML += `

${edgeAttrs.comment}

`; + // edgeInfoDiv.innerHTML += `

${edgeAttrs.curie}

`; + edgeInfoDiv.innerHTML += `
- ${this.getCurie(connectedNodes[0])}
โฌ‡๏ธ
${this.getCurie(connectedNodes[1])} + ${triplesCount} + ${this.graph.getNodeAttributes(connectedNodes[0]).label} +
โฌ‡๏ธ ${edgeAttrs.curie}
+ ${this.graph.getNodeAttributes(connectedNodes[1]).label}
`; + // TODO: add `triples` counts retrieved from VoID on subjectClass/objectClass relations when possible + if (edgeAttrs.displayLabel) edgeInfoDiv.innerHTML += `

${edgeAttrs.displayLabel}

`; + if (edgeAttrs.comment) edgeInfoDiv.innerHTML += `

${edgeAttrs.comment}

`; } this.renderer?.refresh({skipIndexation: true}); } - displayNodeInfo(node: string | undefined) { + displayNodeInfo(node?: string) { const nodeInfoDiv = this.querySelector("#overview-node-info") as HTMLElement; nodeInfoDiv.innerHTML = ""; if (node) { @@ -766,7 +822,6 @@ export class SparqlOverview extends HTMLElement { .filter(({label}) => label.toLowerCase().includes(lcQuery)); // If we have a single perfect match, them we remove the suggestions, and consider the user has selected a node if (suggestions.length === 1 && suggestions[0].label === query) { - // this.selectedNode = suggestions[0].id; this.selectedNode = suggestions[0].id; this.displayNodeInfo(suggestions[0].id); this.suggestions = undefined; @@ -845,7 +900,7 @@ export class SparqlOverview extends HTMLElement { // this.saveMetaToLocalStorage(); } - renderPredicateList() { + renderPredicatesFilter() { const sidebar = this.querySelector("#overview-predicates-list") as HTMLElement; sidebar.innerHTML = ""; const sortedPredicates = Object.entries(this.predicatesCount).sort((a, b) => b[1].count - a[1].count); @@ -873,7 +928,7 @@ export class SparqlOverview extends HTMLElement { this.renderer?.refresh({skipIndexation: true}); } - renderClusterList() { + renderClustersFilter() { const sidebar = this.querySelector("#overview-clusters-list") as HTMLElement; sidebar.innerHTML = ""; const sortedClusters = Object.entries(this.clusters).sort((a, b) => b[1].count - a[1].count); @@ -902,12 +957,4 @@ export class SparqlOverview extends HTMLElement { } } -function getEdgeCurvature(index: number, maxIndex: number): number { - if (maxIndex <= 0) throw new Error("Invalid maxIndex"); - if (index < 0) return -getEdgeCurvature(-index, maxIndex); - const amplitude = 3.5; - const maxCurvature = amplitude * (1 - Math.exp(-maxIndex / amplitude)) * DEFAULT_EDGE_CURVATURE; - return (maxCurvature * index) / maxIndex; -} - customElements.define("sparql-overview", SparqlOverview); diff --git a/packages/sparql-overview/src/utils.ts b/packages/sparql-overview/src/utils.ts index 6d0ccb3..815c5a2 100644 --- a/packages/sparql-overview/src/utils.ts +++ b/packages/sparql-overview/src/utils.ts @@ -1,3 +1,5 @@ +import {DEFAULT_EDGE_CURVATURE} from "@sigma/edge-curve"; + export type EndpointsMetadata = { // Endpoint URL [key: string]: { @@ -38,7 +40,7 @@ export function compressUri(prefixes: {[key: string]: string}, uri: string): str export async function queryEndpoint(query: string, endpoint: string): Promise { // We add `&ac=1` to all the queries to exclude these queries from stats const response = await fetch(`${endpoint}?ac=1&query=${encodeURIComponent(query)}`, { - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(20000), headers: { Accept: "application/sparql-results+json", }, @@ -117,6 +119,14 @@ export async function getVoidDescription(endpoint: string): Promise<[VoidDict, s return [voidDescription, Array.from(clsSet).sort(), Array.from(predSet).sort()]; } +export function getEdgeCurvature(index: number, maxIndex: number): number { + if (maxIndex <= 0) throw new Error("Invalid maxIndex"); + if (index < 0) return -getEdgeCurvature(-index, maxIndex); + const amplitude = 3.5; + const maxCurvature = amplitude * (1 - Math.exp(-maxIndex / amplitude)) * DEFAULT_EDGE_CURVATURE; + return (maxCurvature * index) / maxIndex; +} + // // Initialize prefixes with some defaults? // this.prefixes = new Map([ // ["rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"], @@ -133,3 +143,125 @@ export async function getVoidDescription(endpoint: string): Promise<[VoidDict, s // ["dc", "http://purl.org/dc/terms/"], // ["faldo", "http://biohackathon.org/resource/faldo#"], // ]); + +export const voidQuery = `PREFIX owl: +PREFIX rdfs: +PREFIX sh: +PREFIX sd: +PREFIX void: +PREFIX void-ext: +SELECT DISTINCT ?subjectClass ?prop ?objectClass ?objectDatatype ?triples +?objectClassTopParent ?objectClassTopParentLabel ?subjectClassTopParent ?subjectClassTopParentLabel +?subjectClassLabel ?objectClassLabel ?subjectClassComment ?objectClassComment ?propLabel ?propComment +WHERE { + { + SELECT * WHERE { + { + ?s sd:graph ?graph . + ?graph void:classPartition ?cp . + ?cp void:class ?subjectClass ; + void:propertyPartition ?pp . + OPTIONAL {?subjectClass rdfs:label ?subjectClassLabel } + OPTIONAL {?subjectClass rdfs:comment ?subjectClassComment } + OPTIONAL { + ?subjectClass rdfs:subClassOf* ?subjectClassTopParent . + OPTIONAL {?subjectClassTopParent rdfs:label ?subjectClassTopParentLabel} + FILTER(isIRI(?subjectClassTopParent) && ?subjectClassTopParent != owl:Thing && ?subjectClassTopParent != owl:Class) + MINUS { + ?subjectClassTopParent rdfs:subClassOf ?intermediateParent . + FILTER(?intermediateParent != owl:Thing && ?intermediateParent != owl:Class) + } + } + + ?pp void:property ?prop ; + void:triples ?triples . + OPTIONAL {?prop rdfs:label ?propLabel } + OPTIONAL {?prop rdfs:comment ?propComment } + OPTIONAL { + { + ?pp void:classPartition [ void:class ?objectClass ] . + OPTIONAL {?objectClass rdfs:label ?objectClassLabel } + OPTIONAL {?objectClass rdfs:comment ?objectClassComment } + OPTIONAL { + ?objectClass rdfs:subClassOf* ?objectClassTopParent . + OPTIONAL {?objectClassTopParent rdfs:label ?objectClassTopParentLabel} + FILTER(isIRI(?objectClassTopParent) && ?objectClassTopParent != owl:Thing && ?objectClassTopParent != owl:Class) + MINUS { + ?objectClassTopParent rdfs:subClassOf ?intermediateParent . + FILTER(?intermediateParent != owl:Thing && ?intermediateParent != owl:Class) + } + } + } UNION { + ?pp void-ext:datatypePartition [ void-ext:datatype ?objectDatatype ] . + } + } + } UNION { + ?linkset void:subjectsTarget [ void:class ?subjectClass ] ; + void:linkPredicate ?prop ; + void:objectsTarget [ void:class ?objectClass ] . + } + + } + } +} ORDER BY ?subjectClass ?objectClass ?objectDatatype ?graph ?triples`; + +// export const voidQuery = `PREFIX owl: +// PREFIX rdfs: +// PREFIX sh: +// PREFIX sd: +// PREFIX void: +// PREFIX void-ext: +// SELECT DISTINCT ?subjectClass ?prop ?objectClass ?objectDatatype ?triples +// ?objectClassTopParent ?objectClassTopParentLabel ?subjectClassTopParent ?subjectClassTopParentLabel +// ?subjectClassLabel ?objectClassLabel ?subjectClassComment ?objectClassComment ?propLabel ?propComment +// WHERE { +// { +// SELECT * WHERE { +// { +// ?s sd:graph ?graph . +// ?graph void:classPartition ?cp . +// ?cp void:class ?subjectClass ; +// void:propertyPartition ?pp . +// OPTIONAL {?subjectClass rdfs:label ?subjectClassLabel } +// OPTIONAL {?subjectClass rdfs:comment ?subjectClassComment } +// OPTIONAL { +// ?subjectClass rdfs:subClassOf ?subjectClassTopParent . +// OPTIONAL {?subjectClassTopParent rdfs:label ?subjectClassTopParentLabel} +// # FILTER(isIRI(?subjectClassTopParent) && ?subjectClassTopParent != owl:Thing && ?subjectClassTopParent != owl:Class) +// # MINUS { +// # ?subjectClassTopParent rdfs:subClassOf ?intermediateParent . +// # FILTER(?intermediateParent != owl:Thing && ?intermediateParent != owl:Class) +// # } +// } + +// ?pp void:property ?prop ; +// void:triples ?triples . +// OPTIONAL {?prop rdfs:label ?propLabel } +// OPTIONAL {?prop rdfs:comment ?propComment } +// OPTIONAL { +// { +// ?pp void:classPartition [ void:class ?objectClass ] . +// OPTIONAL {?objectClass rdfs:label ?objectClassLabel } +// OPTIONAL {?objectClass rdfs:comment ?objectClassComment } +// OPTIONAL { +// ?objectClass rdfs:subClassOf ?objectClassTopParent . +// OPTIONAL {?objectClassTopParent rdfs:label ?objectClassTopParentLabel} +// # FILTER(isIRI(?objectClassTopParent) && ?objectClassTopParent != owl:Thing && ?objectClassTopParent != owl:Class) +// # MINUS { +// # ?objectClassTopParent rdfs:subClassOf ?intermediateParent . +// # FILTER(?intermediateParent != owl:Thing && ?intermediateParent != owl:Class) +// # } +// } +// } UNION { +// ?pp void-ext:datatypePartition [ void-ext:datatype ?objectDatatype ] . +// } +// } +// } UNION { +// ?linkset void:subjectsTarget [ void:class ?subjectClass ] ; +// void:linkPredicate ?prop ; +// void:objectsTarget [ void:class ?objectClass ] . +// } + +// } +// } +// } ORDER BY ?subjectClass ?objectClass ?objectDatatype ?graph ?triples`