diff --git a/package-lock.json b/package-lock.json index 29746492..8b0a89c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@abi-software/map-side-bar", - "version": "2.12.0", + "version": "2.12.0-acupoints.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@abi-software/map-side-bar", - "version": "2.12.0", + "version": "2.12.0-acupoints.2", "license": "Apache-2.0", "dependencies": { "@abi-software/gallery": "^1.2.0", @@ -6344,9 +6344,9 @@ } }, "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "version": "5.4.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", + "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", "dev": true, "dependencies": { "esbuild": "^0.21.3", diff --git a/package.json b/package.json index d250b6cf..c1ec8af6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@abi-software/map-side-bar", - "version": "2.12.0", + "version": "2.12.0-acupoints.2", "license": "Apache-2.0", "repository": { "type": "git", diff --git a/src/App.vue b/src/App.vue index 4d2db5d9..56f7c832 100644 --- a/src/App.vue +++ b/src/App.vue @@ -18,6 +18,7 @@ keyword search Get facets Create Data/Annotation + Search Acupoints Connectivity Search @@ -42,6 +47,7 @@ + + diff --git a/src/components/AcupointsInfoSearch.vue b/src/components/AcupointsInfoSearch.vue new file mode 100644 index 00000000..86ab6b2c --- /dev/null +++ b/src/components/AcupointsInfoSearch.vue @@ -0,0 +1,430 @@ + + + + + + + Search + + + + + Curated: + + + + + + + + On MRI: + + + + + + + + + + + + No results found - Please change your search / filter criteria. + + + + + + + + + diff --git a/src/components/SideBar.vue b/src/components/SideBar.vue index b27cde12..9c25e7fc 100644 --- a/src/components/SideBar.vue +++ b/src/components/SideBar.vue @@ -64,6 +64,14 @@ @connectivity-item-close="onConnectivityItemClose" /> + + + { return (id === undefined || tabEntry.id === id) && @@ -407,6 +427,14 @@ export default { updateConnectivityError: function (errorInfo) { EventBus.emit('connectivity-error', errorInfo); }, + openAcupointsSearch: function (term) { + this.drawerOpen = true + // Because refs are in v-for, nextTick is needed here + this.$nextTick(() => { + const tabRef = this.getTabRef(undefined, 'acupoints', true); + tabRef.search(term); + }) + }, /** * Store available anatomy facets data for connectivity list component */ @@ -503,6 +531,11 @@ export default { // This should respect the information provided by the property tabEntries: function () { return this.tabs.filter((tab) => + (tab.type === "acupoints" && + ( + this.acupointsInfoList && + Object.keys(this.acupointsInfoList).length > 0 + )) || tab.type === "datasetExplorer" || tab.type === "connectivityExplorer" || ( @@ -565,6 +598,16 @@ export default { this.$emit('connectivity-source-change', payLoad); }) + // Emit acupoints clicked event + EventBus.on('acupoints-clicked', (payLoad) => { + this.$emit('acupoints-clicked', payLoad); + }) + + // Emit acupoints hovered event + EventBus.on('acupoints-hovered', (payLoad) => { + this.$emit('acupoints-hovered', payLoad); + }) + // Get available anatomy facets for the connectivity info EventBus.on('available-facets', (payLoad) => { this.availableAnatomyFacets = payLoad.find((facet) => facet.label === 'Anatomical Structure').children diff --git a/src/services/flatmapKnowledge.js b/src/services/flatmapKnowledge.js new file mode 100644 index 00000000..e5116292 --- /dev/null +++ b/src/services/flatmapKnowledge.js @@ -0,0 +1,94 @@ +async function getReferenceConnectivitiesFromStorage(resource) { + const flatmapKnowledgeRaw = sessionStorage.getItem('flatmap-knowledge'); + + if (flatmapKnowledgeRaw) { + const flatmapKnowledge = JSON.parse(flatmapKnowledgeRaw); + const dataWithRefs = flatmapKnowledge.filter((x) => x.references && x.references.length); + const foundData = dataWithRefs.filter((x) => x.references.includes(resource)); + + if (foundData.length) { + const featureIds = foundData.map((x) => x.id); + return featureIds; + } + } + return []; +} + +async function getReferenceConnectivitiesByAPI(mapImp, resource, flatmapQueries) { + const knowledgeSource = getKnowledgeSource(mapImp); + const sql = `select knowledge from knowledge + where source="${knowledgeSource}" and + knowledge like "%${resource}%" order by source desc`; + console.log(sql) + const response = await flatmapQueries.flatmapQuery(sql); + const mappedData = response.values.map((x) => x[0]); + const parsedData = mappedData.map((x) => JSON.parse(x)); + const featureIds = parsedData.map((x) => x.id); + return featureIds; +} + +function getKnowledgeSource(mapImp) { + let mapKnowledgeSource = ''; + if (mapImp.provenance?.connectivity) { + const sckanProvenance = mapImp.provenance.connectivity; + if ('knowledge-source' in sckanProvenance) { + mapKnowledgeSource = sckanProvenance['knowledge-source']; + } else if ('npo' in sckanProvenance) { + mapKnowledgeSource = `${sckanProvenance.npo.release}-npo`; + } + } + + return mapKnowledgeSource; +} + +function loadAndStoreKnowledge(mapImp, flatmapQueries) { + const knowledgeSource = getKnowledgeSource(mapImp); + const sql = `select knowledge from knowledge + where source="${knowledgeSource}" + order by source desc`; + const flatmapKnowledge = sessionStorage.getItem('flatmap-knowledge'); + + if (!flatmapKnowledge) { + flatmapQueries.flatmapQuery(sql).then((response) => { + const mappedData = response.values.map(x => x[0]); + const parsedData = mappedData.map(x => JSON.parse(x)); + sessionStorage.setItem('flatmap-knowledge', JSON.stringify(parsedData)); + updateFlatmapKnowledgeCache(); + }); + } +} + +function updateFlatmapKnowledgeCache() { + const CACHE_LIFETIME = 24 * 60 * 60 * 1000; // One day + const now = new Date(); + const expiry = now.getTime() + CACHE_LIFETIME; + + sessionStorage.setItem('flatmap-knowledge-expiry', expiry); +} + +function removeFlatmapKnowledgeCache() { + const keys = [ + 'flatmap-knowledge', + 'flatmap-knowledge-expiry', + ]; + keys.forEach((key) => { + sessionStorage.removeItem(key); + }); +} + +function refreshFlatmapKnowledgeCache() { + const expiry = sessionStorage.getItem('flatmap-knowledge-expiry'); + const now = new Date(); + + if (now.getTime() > expiry) { + removeFlatmapKnowledgeCache(); + } +} + +export { + getReferenceConnectivitiesFromStorage, + getReferenceConnectivitiesByAPI, + loadAndStoreKnowledge, + getKnowledgeSource, + refreshFlatmapKnowledgeCache, +} diff --git a/src/services/flatmapQueries.js b/src/services/flatmapQueries.js new file mode 100644 index 00000000..b343a043 --- /dev/null +++ b/src/services/flatmapQueries.js @@ -0,0 +1,498 @@ +/* eslint-disable no-alert, no-console */ +// remove duplicates by stringifying the objects +const removeDuplicates = function (arrayOfAnything) { + if (!arrayOfAnything) return [] + return [...new Set(arrayOfAnything.map((e) => JSON.stringify(e)))].map((e) => + JSON.parse(e) + ) +} + +const cachedLabels = {} +const cachedTaxonLabels = []; + +const findTaxonomyLabel = async function (flatmapAPI, taxonomy) { + if (cachedLabels && cachedLabels.hasOwnProperty(taxonomy)) { + return cachedLabels[taxonomy] + } + + return new Promise((resolve) => { + fetch(`${flatmapAPI}knowledge/label/${taxonomy}`, { + method: 'GET', + }) + .then((response) => response.json()) + .then((data) => { + let label = data.label + if (label === 'Mammalia') { + label = 'Mammalia not otherwise specified' + } + cachedLabels[taxonomy] = label + resolve(label) + }) + .catch((error) => { + console.error('Error:', error) + cachedLabels[taxonomy] = taxonomy + resolve(taxonomy) + }) + }) +} + +const findTaxonomyLabels = async function (mapImp, taxonomies) { + const intersectionTaxonomies = taxonomies.filter((taxonomy) => + cachedTaxonLabels.some((obj) => obj.taxon === taxonomy) + ); + + const foundCachedTaxonLabels = cachedTaxonLabels.filter((obj) => + intersectionTaxonomies.includes(obj.taxon) + ); + + const leftoverTaxonomies = taxonomies.filter((taxonomy) => + !intersectionTaxonomies.includes(taxonomy) + ); + + if (!leftoverTaxonomies.length) { + return foundCachedTaxonLabels; + } else { + const entityLabels = await mapImp.queryLabels(leftoverTaxonomies); + if (entityLabels.length) { + entityLabels.forEach((entityLabel) => { + let { entity: taxon, label } = entityLabel; + if (label === 'Mammalia') { + label = 'Mammalia not otherwise specified' + } + const item = { taxon, label }; + foundCachedTaxonLabels.push(item); + cachedTaxonLabels.push(item); + }); + return foundCachedTaxonLabels; + } + } +} + +const inArray = function (ar1, ar2) { + if (!ar1 || !ar2) return false + let as1 = JSON.stringify(ar1) + let as2 = JSON.stringify(ar2) + return as1.indexOf(as2) !== -1 +} + +let FlatmapQueries = function () { + this.initialise = function (flatmapApi) { + this.flatmapApi = flatmapApi + this.destinations = [] + this.origins = [] + this.components = [] + this.rawURLs = [] + this.controller = undefined + this.uberons = [] + this.lookUp = [] + } + + this.createTooltipData = async function (mapImp, eventData) { + let hyperlinks = [] + if ( + eventData.feature.hyperlinks && + eventData.feature.hyperlinks.length > 0 + ) { + hyperlinks = eventData.feature.hyperlinks + } else { + hyperlinks = this.rawURLs; + } + let taxonomyLabel = undefined + if (eventData.provenanceTaxonomy) { + taxonomyLabel = [] + const entityLabels = await findTaxonomyLabels(mapImp, eventData.provenanceTaxonomy); + if (entityLabels.length) { + entityLabels.forEach((entityLabel) => { + const { label } = entityLabel; + taxonomyLabel.push(label); + }); + } + } + + let tooltipData = { + destinations: this.destinations, + origins: this.origins, + components: this.components, + destinationsWithDatasets: this.destinationsWithDatasets, + originsWithDatasets: this.originsWithDatasets, + componentsWithDatasets: this.componentsWithDatasets, + title: eventData.label, + featureId: eventData.resource, + hyperlinks: hyperlinks, + provenanceTaxonomy: eventData.provenanceTaxonomy, + provenanceTaxonomyLabel: taxonomyLabel, + } + return tooltipData + } + + this.createComponentsLabelList = function (components, lookUp) { + let labelList = [] + components.forEach((n) => { + labelList.push(this.createLabelFromNeuralNode(n[0]), lookUp) + if (n.length === 2) { + labelList.push(this.createLabelFromNeuralNode(n[1]), lookUp) + } + }) + return labelList + } + + this.createLabelLookup = function (mapImp, uberons) { + return new Promise(async (resolve) => { + let uberonMap = {} + this.uberons = [] + const entityLabels = await findTaxonomyLabels(mapImp, uberons); + if (entityLabels.length) { + entityLabels.forEach((entityLabel) => { + const { taxon: entity, label } = entityLabel; + uberonMap[entity] = label; + this.uberons.push({ + id: entity, + name: label, + }) + }); + resolve(uberonMap) + } + }) + } + + this.buildConnectivitySqlStatement = function (keastIds) { + let sql = 'select knowledge from knowledge where entity in (' + if (keastIds.length === 1) { + sql += `'${keastIds[0]}')` + } else if (keastIds.length > 1) { + for (let i in keastIds) { + sql += `'${keastIds[i]}'${i >= keastIds.length - 1 ? ')' : ','} ` + } + } + return sql + } + + this.buildLabelSqlStatement = function (uberons) { + let sql = 'select entity, label from labels where entity in (' + if (uberons.length === 1) { + sql += `'${uberons[0]}')` + } else if (uberons.length > 1) { + for (let i in uberons) { + sql += `'${uberons[i]}'${i >= uberons.length - 1 ? ')' : ','} ` + } + } + return sql + } + + this.findAllIdsFromConnectivity = function (connectivity) { + let dnodes = connectivity.connectivity.flat() // get nodes from edgelist + let nodes = [...new Set(dnodes)] // remove duplicates + let found = [] + nodes.forEach((n) => { + if (Array.isArray(n)) { + found.push(n.flat()) + } else { + found.push(n) + } + }) + return [...new Set(found.flat())] + } + + this.flattenConntectivity = function (connectivity) { + let dnodes = connectivity.flat() // get nodes from edgelist + let nodes = [...new Set(dnodes)] // remove duplicates + let found = [] + nodes.forEach((n) => { + if (Array.isArray(n)) { + found.push(n.flat()) + } else { + found.push(n) + } + }) + return found.flat() + } + + this.findComponents = function (connectivity) { + let dnodes = connectivity.connectivity.flat() // get nodes from edgelist + let nodes = removeDuplicates(dnodes) + + let found = [] + let terminal = false + nodes.forEach((node) => { + terminal = false + // Check if the node is an destination or origin (note that they are labelled dendrite and axon as opposed to origin and destination) + if (inArray(connectivity.axons, node)) { + terminal = true + } + if (connectivity.somas && inArray(connectivity.somas, node)) { + terminal = true + } + if (inArray(connectivity.dendrites, node)) { + terminal = true + } + if (!terminal) { + found.push(node) + } + }) + + return found + } + + this.retrieveFlatmapKnowledgeForEvent = async function (mapImp, eventData) { + // check if there is an existing query + if (this.controller) this.controller.abort() + + // set up the abort controller + this.controller = new AbortController() + const signal = this.controller.signal + + const keastIds = eventData.resource + this.destinations = [] + this.origins = [] + this.components = [] + this.rawURLs = [] + if (!keastIds || keastIds.length == 0 || !keastIds[0]) return + + let prom1 = this.queryForConnectivityNew(mapImp, keastIds, signal) // This on returns a promise so dont need 'await' + let results = await Promise.all([prom1]) + return results + } + + this.queryForConnectivityNew = function (mapImp, keastIds, signal, processConnectivity=true) { + return new Promise((resolve) => { + mapImp.queryKnowledge(keastIds[0]) + .then((response) => { + if (this.checkConnectivityExists(response)) { + let connectivity = response; + if (processConnectivity) { + this.processConnectivity(mapImp, connectivity).then((processedConnectivity) => { + // response.references is publication urls + if (response.references) { + // with publications from both PubMed and Others + this.rawURLs = [...response.references]; + resolve(processedConnectivity) + } else { + // without publications + resolve(processedConnectivity) + } + }) + } + else resolve(connectivity) + } else { + resolve(false) + } + }) + .catch((error) => { + if (error.name === 'AbortError') { + // This error is from AbortController's abort method. + } else { + // console.error('Error:', error) + // TODO: to update after queryKnowledge method update + console.warn(`Unable to get the knowledge for the entity ${keastIds[0]}.`) + } + resolve(false) + }) + }) + } + + this.queryForConnectivity = function (mapImp, keastIds, signal, processConnectivity=true) { + const data = { sql: this.buildConnectivitySqlStatement(keastIds) } + const headers = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + ...(signal ? { signal: signal } : {}), // add signal to header if it exists + } + return new Promise((resolve) => { + fetch(`${this.flatmapApi}knowledge/query/`, headers) + .then((response) => response.json()) + .then((data) => { + if (this.connectivityExists(data)) { + let connectivity = JSON.parse(data.values[0][0]) + if (processConnectivity) { + this.processConnectivity(mapImp, connectivity).then((processedConnectivity) => { + resolve(processedConnectivity) + }) + } + else resolve(connectivity) + } else { + resolve(false) + } + }) + .catch((error) => { + if (error.name === 'AbortError') { + // This error is from AbortController's abort method. + } else { + console.error('Error:', error) + } + resolve(false) + }) + }) + } + + this.checkConnectivityExists = function (data) { + return data && data.connectivity?.length; + }; + + this.connectivityExists = function (data) { + if ( + data.values && + data.values.length > 0 && + JSON.parse(data.values[0][0]).connectivity && + JSON.parse(data.values[0][0]).connectivity.length > 0 + ) { + return true + } else { + return false + } + } + + // This function is used to determine if a node is a single node or a node with multiple children + // Returns the id of the node if it is a single node, otherwise returns false + this.findIfNodeIsSingle = function (node) { + if (node.length === 1) { // If the node is in the form [id] + console.error("Server returns a single node", node) + return node[0] + } else { + if (node.length === 2 && node[1].length === 0) { // If the node is in the form [id, []] + return node[0] + } else { + return false // If the node is in the form [id, [id1, id2]] + } + } + } + + this.createLabelFromNeuralNode = function (node, lookUp) { + + // Check if the node is a single node or a node with multiple children + let nodeIsSingle = this.findIfNodeIsSingle(node) + + // Case where node is in the form [id] + if (nodeIsSingle) { + return lookUp[nodeIsSingle] + } + + // Case where node is in the form [id, [id1 (,id2)]] + let label = lookUp[node[0]] + if (node.length === 2 && node[1].length > 0) { + node[1].forEach((n) => { + if (lookUp[n] == undefined) { + label += `, ${n}` + } else { + label += `, ${lookUp[n]}` + } + }) + } + return label + } + + this.flattenAndFindDatasets = function (components, axons, dendrites) { + // process the nodes for finding datasets (Note this is not critical to the tooltip, only for the 'search on components' button) + let componentsFlat = this.flattenConntectivity(components) + let axonsFlat = this.flattenConntectivity(axons) + let dendritesFlat = this.flattenConntectivity(dendrites) + + // Filter for the anatomy which is annotated on datasets + this.destinationsWithDatasets = this.uberons.filter( + (ub) => axonsFlat.indexOf(ub.id) !== -1 + ) + this.originsWithDatasets = this.uberons.filter( + (ub) => dendritesFlat.indexOf(ub.id) !== -1 + ) + this.componentsWithDatasets = this.uberons.filter( + (ub) => componentsFlat.indexOf(ub.id) !== -1 + ) + } + + this.processConnectivity = function (mapImp, connectivity) { + return new Promise((resolve) => { + // Filter the origin and destinations from components + let components = this.findComponents(connectivity) + + // Remove duplicates + let axons = removeDuplicates(connectivity.axons) + //Somas will become part of origins, support this for future proof + let dendrites = [] + if (connectivity.somas && connectivity.somas.length > 0) { + dendrites.push(...connectivity.somas) + } + if (connectivity.dendrites && connectivity.dendrites.length > 0) { + dendrites.push(...connectivity.dendrites) + } + dendrites = removeDuplicates(dendrites) + + // Create list of ids to get labels for + let conIds = this.findAllIdsFromConnectivity(connectivity) + + // Create readable labels from the nodes. Setting this to 'this.origins' updates the display + this.createLabelLookup(mapImp, conIds).then((lookUp) => { + this.destinations = axons.map((a) => + this.createLabelFromNeuralNode(a, lookUp) + ) + this.origins = dendrites.map((d) => + this.createLabelFromNeuralNode(d, lookUp) + ) + this.components = components.map((c) => + this.createLabelFromNeuralNode(c, lookUp) + ) + this.flattenAndFindDatasets(components, axons, dendrites) + resolve({ + ids: { + axons: axons, + dendrites: dendrites, + components: components, + }, + labels: { + destinations: this.destinations, + origins: this.origins, + components: this.components, + } + }) + }) + }) + } + + this.flattenConntectivity = function (connectivity) { + let dnodes = connectivity.flat() // get nodes from edgelist + let nodes = [...new Set(dnodes)] // remove duplicates + let found = [] + nodes.forEach((n) => { + if (Array.isArray(n)) { + found.push(n.flat()) + } else { + found.push(n) + } + }) + return found.flat() + } + + this.buildPubmedSqlStatement = function (keastIds) { + let sql = 'select distinct publication from publications where entity in (' + if (keastIds.length === 1) { + sql += `'${keastIds[0]}')` + } else if (keastIds.length > 1) { + for (let i in keastIds) { + sql += `'${keastIds[i]}'${i >= keastIds.length - 1 ? ')' : ','} ` + } + } + return sql + } + + this.buildPubmedSqlStatementForModels = function (model) { + return `select distinct publication from publications where entity = '${model}'` + } + + this.flatmapQuery = function (sql) { + const data = { sql: sql } + return fetch(`${this.flatmapApi}knowledge/query/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }) + .then((response) => response.json()) + .catch((error) => { + console.error('Error:', error) + }) + } +} + +export { FlatmapQueries, findTaxonomyLabel, findTaxonomyLabels }