resource/js/tab-hierarchy.js
/* global Vue */
/* global partialPageLoad, getConceptURL */
const tabHierApp = Vue.createApp({
data () {
return {
hierarchy: [],
loadingHierarchy: true,
loadingChildren: [],
selectedConcept: '',
listStyle: {}
}
},
provide () {
return {
partialPageLoad,
getConceptURL,
showNotation: window.SKOSMOS.showNotation
}
},
mounted () {
// load hierarchy if hierarchy tab is active when the page is first opened (otherwise only load hierachy when the tab is clicked)
if (document.querySelector('#hierarchy > a').classList.contains('active')) {
this.loadHierarchy()
}
},
beforeUpdate () {
this.setListStyle()
},
methods: {
handleClickHierarchyEvent () {
// only load hierarchy if hierarchy tab is available
if (!document.querySelector('#hierarchy > a').classList.contains('disabled')) {
this.loadHierarchy()
}
},
loadHierarchy () {
// if we are on a concept page, load hierarchy for the concept, otherwise load top concepts
if (window.SKOSMOS.uri) {
this.loadConceptHierarchy()
} else {
this.loadTopConcepts()
}
},
loadTopConcepts () {
this.loadingHierarchy = true
fetch('rest/v1/' + window.SKOSMOS.vocab + '/topConcepts/?lang=' + window.SKOSMOS.content_lang)
.then(data => {
return data.json()
})
.then(data => {
console.log('data', data)
this.hierarchy = []
for (const c of data.topconcepts.sort((a, b) => this.compareConcepts(a, b))) {
this.hierarchy.push({ uri: c.uri, label: c.label, hasChildren: c.hasChildren, children: [], isOpen: false, notation: c.notation })
}
this.loadingHierarchy = false
console.log('hier', this.hierarchy)
})
},
loadConceptHierarchy () {
this.loadingHierarchy = true
fetch('rest/v1/' + window.SKOSMOS.vocab + '/hierarchy/?uri=' + window.SKOSMOS.uri + '&lang=' + window.SKOSMOS.content_lang)
.then(data => {
return data.json()
})
.then(data => {
console.log('data', data)
this.hierarchy = []
// transform broaderTransitive to an array and sort it
const bt = Object.values(data.broaderTransitive).sort((a, b) => this.compareConcepts(a, b))
const parents = [] // queue of nodes in hierarchy tree with potential missing child nodes
// add top concepts to hierarchy tree
for (const concept of bt) {
if (concept.top) {
if (concept.narrower) {
// children of the current concept
const children = concept.narrower
.sort((a, b) => this.compareConcepts(a, b))
.map(c => {
return { uri: c.uri, label: c.label, hasChildren: c.hasChildren, children: [], isOpen: false, notation: c.notation }
})
// new concept node to be added to hierarchy tree
const conceptNode = { uri: concept.uri, label: concept.prefLabel, hasChildren: true, children, isOpen: true, notation: concept.notation }
// push new concept to hierarchy tree
this.hierarchy.push(conceptNode)
// push new concept to parent queue
parents.push(conceptNode)
} else {
// push new concept node to hierarchy tree
this.hierarchy.push({ uri: concept.uri, label: concept.prefLabel, hasChildren: concept.hasChildren, children: [], isOpen: false, notation: concept.notation })
}
}
}
// add other concepts to hierarhy tree
while (parents.length !== 0) {
const parent = parents.shift() // parent node with potential missing child nodes
const concepts = []
// find all concepts in broaderTransative which have current parent node as parent
for (const concept of bt) {
if (concept.broader && concept.broader.includes(parent.uri)) {
concepts.push(concept)
}
}
// for all found concepts, add their children to hierarchy
for (const concept of concepts) {
if (concept.narrower) {
// corresponding concept node in hierarchy tree
const conceptNode = parent.children.find(c => c.uri === concept.uri)
// children of current concept
const children = concept.narrower
.sort((a, b) => this.compareConcepts(a, b))
.map(c => {
return { uri: c.uri, label: c.label, hasChildren: c.hasChildren, children: [], isOpen: false, notation: c.notation }
})
// set children of current concept as children of concept node
conceptNode.children = children
conceptNode.isOpen = children.length !== 0
// push concept node to parent queue
parents.push(conceptNode)
}
}
}
this.loadingHierarchy = false
this.selectedConcept = window.SKOSMOS.uri
console.log('hier', this.hierarchy)
})
},
loadChildren (concept) {
// load children only if concept has children but they have not been loaded yet
if (concept.children.length === 0 && concept.hasChildren) {
this.loadingChildren.push(concept)
fetch('rest/v1/' + window.SKOSMOS.vocab + '/children?uri=' + concept.uri + '&lang=' + window.SKOSMOS.content_lang)
.then(data => {
return data.json()
})
.then(data => {
console.log('data', data)
for (const c of data.narrower.sort((a, b) => this.compareConcepts(a, b))) {
concept.children.push({ uri: c.uri, label: c.prefLabel, hasChildren: c.hasChildren, children: [], isOpen: false, notation: c.notation })
}
this.loadingChildren = this.loadingChildren.filter(x => x !== concept)
console.log('hier', this.hierarchy)
})
}
},
setListStyle () {
const height = document.getElementById('sidebar-tabs').clientHeight
const width = document.getElementById('sidebar-tabs').clientWidth
this.listStyle = {
height: 'calc( 100% - ' + height + 'px)',
width: width + 'px'
}
},
compareConcepts (a, b) {
let strA, strB
if (window.SKOSMOS.sortByNotation) {
if (a.notation && b.notation) {
// Set strings as notation if both have notation codes
strA = a.notation
strB = b.notation
} else if (a.notation && !b.notation) {
// Sort a before b if b has no notation
return -1
} else if (!a.notation && b.notation) {
// Sort b before a if a has no notation
return 1
}
}
// Set strings to label/prefLabel if sorting should not be based on notation or if neither concept has notations
strA = strA || a.label || a.prefLabel || ''
strB = strB || b.label || b.prefLabel || ''
// Set language and options
const lang = window.SKOSMOS.content_lang || window.SKOSMOS.lang
const options = {
numeric: window.SKOSMOS.sortByNotation === 'natural', // Set numeric to true if sort should be natural
sensitivity: 'variant' // Strings that differ in base letters, diacritic marks, or case compare as unequal
}
return strA.localeCompare(strB, lang, options)
}
},
template: `
<div v-click-tab-hierarchy="handleClickHierarchyEvent" v-resize-window="setListStyle">
<div id="hierarchy-list" class="sidebar-list p-0" :style="listStyle">
<ul class="list-group" v-if="!loadingHierarchy">
<tab-hier-wrapper
:hierarchy="hierarchy"
:selectedConcept="selectedConcept"
:loadingChildren="loadingChildren"
@load-children="loadChildren($event)"
@select-concept="selectedConcept = $event"
></tab-hier-wrapper>
</ul>
<template v-else>
<i class="fa-solid fa-spinner fa-spin-pulse"></i>
</template>
</div>
</div>
`
})
/* Custom directive used to add an event listener on clicks on the hierarchy nav-item element */
tabHierApp.directive('click-tab-hierarchy', {
beforeMount: (el, binding) => {
el.clickTabEvent = event => {
binding.value() // calling the method given as the attribute value (handleClickHierarchyEvent)
}
document.querySelector('#hierarchy').addEventListener('click', el.clickTabEvent) // registering an event listener on clicks on the hierarchy nav-item element
},
unmounted: el => {
document.querySelector('#hierarchy').removeEventListener('click', el.clickTabEvent)
}
})
/* Custom directive used to add an event listener on resizing the window */
tabHierApp.directive('resize-window', {
beforeMount: (el, binding) => {
el.resizeWindowEvent = event => {
binding.value() // calling the method given as the attribute value (setListStyle)
}
window.addEventListener('resize', el.resizeWindowEvent) // registering an event listener on resizing the window
},
unmounted: el => {
window.removeEventListener('resize', el.resizeWindowEvent)
}
})
tabHierApp.component('tab-hier-wrapper', {
props: ['hierarchy', 'selectedConcept', 'loadingChildren'],
emits: ['loadChildren', 'selectConcept'],
mounted () {
// scroll automatically to selected concept after the whole hierarchy tree has been mounted
if (this.selectedConcept) {
const selected = document.querySelectorAll('#hierarchy-list .list-group-item .selected')[0]
const list = document.querySelector('#hierarchy-list')
// distances to the top of the page
const selectedTop = selected.getBoundingClientRect().top
const listTop = list.getBoundingClientRect().top
// height of the visible portion of the list element
const listHeight = list.getBoundingClientRect().bottom < window.innerHeight
? list.getBoundingClientRect().height
: window.innerHeight - listTop
list.scrollBy({
top: selectedTop - listTop - listHeight / 2, // scroll top of selected element to the middle of list element
behavior: 'smooth'
})
}
},
methods: {
loadChildren (concept) {
this.$emit('loadChildren', concept)
},
selectConcept (concept) {
this.$emit('selectConcept', concept)
}
},
template: `
<template v-for="(c, i) in hierarchy" >
<tab-hier
:concept="c"
:selectedConcept="selectedConcept"
:isTopConcept="true"
:isLast="i == hierarchy.length - 1"
:loadingChildren="loadingChildren"
@load-children="loadChildren($event)"
@select-concept="selectConcept($event)"
></tab-hier>
</template>
`
})
tabHierApp.component('tab-hier', {
props: ['concept', 'selectedConcept', 'isTopConcept', 'isLast', 'loadingChildren'],
emits: ['loadChildren', 'selectConcept'],
inject: ['partialPageLoad', 'getConceptURL', 'showNotation'],
methods: {
handleClickOpenEvent (concept) {
concept.isOpen = !concept.isOpen
this.$emit('loadChildren', concept)
},
handleClickConceptEvent (event, concept) {
concept.isOpen = true
this.$emit('loadChildren', concept)
this.$emit('selectConcept', concept.uri)
this.partialPageLoad(event, this.getConceptURL(concept.uri))
},
loadChildrenRecursive (concept) {
this.$emit('loadChildren', concept)
},
selectConceptRecursive (concept) {
this.$emit('selectConcept', concept)
}
},
template: `
<li class="list-group-item p-0" :class="{ 'top-concept': isTopConcept }">
<button type="button" class="hierarchy-button btn btn-primary" aria-label="Open"
:class="{ 'open': concept.isOpen }"
v-if="concept.hasChildren"
@click="handleClickOpenEvent(concept)"
>
<template v-if="loadingChildren.includes(concept)">
<i class="fa-solid fa-spinner fa-spin-pulse"></i>
</template>
<template v-else>
<img v-if="concept.isOpen" alt="" src="resource/pics/black-lower-right-triangle.png">
<img v-else alt="" src="resource/pics/lower-right-triangle.png">
</template>
</button>
<span class="concept-label" :class="{ 'last': isLast }">
<a :class="{ 'selected': selectedConcept === concept.uri }"
:href="getConceptURL(concept.uri)"
@click="handleClickConceptEvent($event, concept)"
aria-label="Go to the concept page"
>
<span v-if="showNotation && concept.notation" class="concept-notation">{{ concept.notation }} </span>
{{ concept.label }}
</a>
</span>
<ul class="list-group ps-3" v-if="concept.children.length !== 0 && concept.isOpen">
<template v-for="(c, i) in concept.children">
<tab-hier
:concept="c"
:selectedConcept="selectedConcept"
:isTopConcept="false"
:isLast="i == concept.children.length - 1"
:loadingChildren="loadingChildren"
@load-children="loadChildrenRecursive($event)"
@select-concept="selectConceptRecursive($event)"
></tab-hier>
</template>
</ul>
</li>
`
})
tabHierApp.mount('#tab-hierarchy')