Mathieu Jaumotte 95610ffd34 visgraph: improve update graph mechanism
adding an updateHack in store, and a watcher in component.
* updateHack increment a value in the lpop,
* the watcher detect when value changes
* and $forceUpdate

improve layer checkbox legend refresh and rebuild
2021-11-10 20:09:18 +01:00

474 lines
17 KiB
Vue

<template>
<div id="visgraph"></div>
<teleport to="#visgraph-legend">
<div class="post-menu">
<div class="list-group mt-4">
<button type="button" class="list-group-item list-group-item-action btn btn-create" @click="createRelationship">
{{ $t('visgraph.add_link') }}
</button>
<button type="button" class="list-group-item list-group-item-action btn btn-misc">
<i class="fa fa-camera fa-fw"></i> {{ $t('visgraph.screenshot') }}
</button>
<button type="button" class="list-group-item list-group-item-action btn btn-light" @click="refreshNetwork">
<i class="fa fa-refresh fa-fw"></i> {{ $t('visgraph.refresh') }}
</button>
</div>
<div v-if="displayHelpMessage" class="alert alert-info mt-3">
{{ $t('visgraph.create_link_help') }}
</div>
<div class="my-4 legend">
<h3>{{ $t('visgraph.Legend') }}</h3>
<div class="list-group">
<label class="list-group-item" v-for="layer in legendLayers">
<input
class="form-check-input me-1"
type="checkbox"
:value="layer.id"
v-model="checkedLayers"
@change="toggleLayer"
/>
{{ layer.label }}
</label>
</div>
</div>
</div>
</teleport>
<teleport to="body">
<modal v-if="modal.showModal" :modalDialogClass="modal.modalDialogClass" @close="modal.showModal = false">
<template v-slot:header>
<h2 class="modal-title">{{ $t(modal.title) }}</h2>
<!-- {{ modal.data.id }} -->
</template>
<template v-slot:body>
<div v-if="modal.action === 'delete'">
<p>{{ $t('visgraph.delete_confirmation_text') }}</p>
</div>
<div v-else>
<form>
<div class="row">
<div class="col-12 text-center">{{ $t('visgraph.between') }}<br>{{ $t('visgraph.and') }}</div>
<div class="col">
<h4>{{ getPerson(modal.data.from).text }}</h4>
<p class="text-start" v-if="relation && relation.title">
<span v-if="reverse">
{{ $t('visgraph.relation_from_to_like', [ getPerson(modal.data.from).text, getPerson(modal.data.to).text, relation.reverseTitle.fr.toLowerCase() ])}}
</span>
<span v-else>
{{ $t('visgraph.relation_from_to_like', [ getPerson(modal.data.from).text, getPerson(modal.data.to).text, relation.title.fr.toLowerCase() ])}}
</span>
</p>
</div>
<div class="col text-end">
<h4>{{ getPerson(modal.data.to).text }}</h4>
<p class="text-end" v-if="relation && relation.title">
<span v-if="reverse">
{{ $t('visgraph.relation_from_to_like', [ getPerson(modal.data.to).text, getPerson(modal.data.from).text, relation.title.fr.toLowerCase() ])}}
</span>
<span v-else>
{{ $t('visgraph.relation_from_to_like', [ getPerson(modal.data.to).text, getPerson(modal.data.from).text, relation.reverseTitle.fr.toLowerCase() ])}}
</span>
</p>
</div>
</div>
<div class="my-3">
<VueMultiselect
id="relation"
label="title"
track-by="id"
:custom-label="customLabel"
:placeholder="$t('visgraph.choose_relation')"
:close-on-select="true"
:multiple="false"
:searchable="true"
:options="relations"
v-model="relation"
:value="relation"
>
</VueMultiselect>
</div>
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
id="reverse"
v-model="reverse"
>
<label class="form-check-label" for="reverse">{{ $t('visgraph.reverse_relation') }}</label>
</div>
</form>
</div>
</template>
<template v-slot:footer>
<button class="btn" :class="modal.button.class" @click="submitRelationship">
{{ $t(modal.button.text)}}
</button>
<button class="btn btn-delete" v-if="modal.action === 'edit'" @click="dropRelationship"></button>
</template>
</modal>
</teleport>
</template>
<script>
import vis from 'vis-network/dist/vis-network'
import { mapState, mapGetters } from "vuex"
import Modal from 'ChillMainAssets/vuejs/_components/Modal'
import VueMultiselect from 'vue-multiselect'
import { getRelationsList, postRelationship, patchRelationship, deleteRelationship } from "./api";
import { splitId } from "./vis-network";
export default {
name: "App",
components: {
Modal,
VueMultiselect
},
data() {
return {
container: '',
checkedLayers: [],
relations: [],
displayHelpMessage: false,
listenPersonFlag: 'normal',
newEdgeData: {},
modal: {
showModal: false,
modalDialogClass: "modal-md",
title: null,
action: null,
data: {
type: 'relationship',
from: null,
to: null,
relation: null,
reverse: false
},
button: {
class: null,
text: null
},
}
}
},
computed: {
...mapGetters(['nodes', 'edges',
// not used 'isInWhitelist', 'isHouseholdLoading', 'isCourseLoaded', 'isRelationshipLoaded', 'isPersonLoaded', 'isExcludedNode', 'countLinksByNode', 'getParticipationsByCourse', 'getMembersByHousehold', 'getPersonsGroup',
]),
...mapState(['persons', 'households', 'courses', 'excludedNodesIds', 'updateHack',
// not used 'links', 'relationships', 'whitelistIds', 'personLoadedIds', 'householdLoadingIds', 'courseLoadedIds', 'relationshipLoadedIds',
]),
visgraph_data() {
console.log('::: visgraph_data :::', this.nodes.length, 'nodes,', this.edges.length, 'edges')
return {
nodes: this.nodes,
edges: this.edges
}
},
refreshNetwork() {
console.log('--- refresh network')
window.network.setData(this.visgraph_data)
},
legendLayers() {
console.log('--- refresh legend and rebuild checked Layers')
this.checkedLayers = []
let layersDisplayed = [
...this.nodes.filter(n => n.id.startsWith('household')),
...this.nodes.filter(n => n.id.startsWith('accompanying'))
]
layersDisplayed.forEach(layer => {
this.checkedLayers.push(layer.id)
})
return [
...this.households,
...this.courses
]
},
checkedLayers() { // required to refresh data checkedLayers
console.log('--- checkedLayers')
return this.checkedLayers
},
relation: {
get() {
return this.modal.data.relation
},
set(value) {
this.modal.data.relation = value
}
},
reverse: {
get() {
return this.modal.data.reverse
},
set(value) {
this.modal.data.reverse = value
}
},
},
watch: {
updateHack(newValue, oldValue) {
console.log(`--- updateHack ${oldValue} <> ${newValue}`)
if (oldValue !== newValue) {
this.forceUpdateComponent()
}
}
},
mounted() {
//console.log('=== mounted: init graph')
this.initGraph()
this.listenOnGraph()
this.getRelationsList()
},
methods: {
forceUpdateComponent() {
//console.log('!! forceUpdateComponent !!')
this.refreshNetwork
this.$forceUpdate()
},
initGraph() {
this.container = document.getElementById('visgraph')
// Instanciate vis objects in separate window variables, see vis-network.js
window.network = new vis.Network(this.container, this.visgraph_data, window.options)
},
// events
listenOnGraph() {
window.network.on('selectNode', (data) => {
if (data.nodes.length > 1) {
throw 'Multi selection is not allowed. Disable it in options.interaction !'
}
let node = data.nodes[0]
let nodeType = splitId(node, 'type')
switch (nodeType) {
case 'person':
let person = this.nodes.filter(n => n.id === node)[0]
console.log('@@@@@@ event on selected Node', person.id)
if (this.listenPersonFlag === 'normal') {
if (person.folded === true) {
console.log(' @@> expand mode event')
this.$store.commit('unfoldPerson', person)
}
} else {
console.log(' @@> create link mode event')
this.listenStepsToAddRelationship(person)
}
break
case 'household':
let household = this.nodes.filter(n => n.id === node)[0]
console.log('@@@@@@ event on selected Node', household.id)
this.$store.dispatch('unfoldPersonsByHousehold', household)
break
case 'accompanying_period':
let course = this.nodes.filter(n => n.id === node)[0]
console.log('@@@@@@ event on selected Node', course.id)
this.$store.dispatch('unfoldPersonsByCourse', course)
break
default:
throw 'event is undefined for this type of node'
}
this.forceUpdateComponent()
})
window.network.on('selectEdge', (data) => {
if (data.nodes.length !== 0 || data.edges.length !== 1) {
return false //we don't want to trigger nodeEdge or multiselect !
}
let link = data.edges[0]
let linkType = splitId(link, 'link')
console.log('@@@@@ event on selected Edge', data.edges.length, linkType, data)
if (linkType.startsWith('relationship')) {
//console.log('linkType relationship')
let relationships = this.edges.filter(l => l.id === link)
if (relationships.length > 1) {
throw 'error: only one link is allowed between two person!'
}
let relationship = relationships[0]
//console.log(relationship)
this.editRelationshipModal({
from: relationship.from,
to: relationship.to,
id: relationship.id,
relation: relationship.relation,
reverse: relationship.reverse
})
}
})
},
listenStepsToAddRelationship(person) {
console.log(' @@> listenStep', this.listenPersonFlag)
if (this.listenPersonFlag === 'step2') {
//console.log(' @@> person 2', person)
this.newEdgeData.to = person.id
this.addRelationshipModal(this.newEdgeData)
this.displayHelpMessage = false
this.listenPersonFlag = 'normal'
this.newEdgeData = {}
}
if (this.listenPersonFlag === 'step1') {
//console.log(' @@> person 1', person)
this.newEdgeData.from = person.id
this.listenPersonFlag = 'step2'
}
},
/// control Layers
toggleLayer(value) {
let id = value.target.value
console.log('@@@@@@ toggle Layer', id)
this.forceUpdateComponent()
if (this.checkedLayers.includes(id)) {
this.removeLayer(id)
} else {
this.addLayer(id)
}
},
addLayer(id) {
//console.log('+ addLayer', id)
this.checkedLayers.push(id)
this.$store.dispatch('excludedNode', ['remove', id])
},
removeLayer(id) {
//console.log('- removeLayer', id)
this.checkedLayers = this.checkedLayers.filter(i => i !== id)
this.$store.dispatch('excludedNode', ['add', id])
},
/// control Modal
addRelationshipModal(edgeData) {
//console.log('==- addRelationshipModal', edgeData)
this.modal = {
data: { from: edgeData.from, to: edgeData.to },
action: 'create',
showModal: true,
title: 'visgraph.add_relationship_link',
button: { class: 'btn-create', text: 'action.create' }
}
},
editRelationshipModal(edgeData) {
//console.log('==- editRelationshipModal', edgeData)
this.modal = {
data: edgeData,
action: 'edit',
showModal: true,
title: 'visgraph.edit_relationship_link',
button: { class: 'btn-edit', text: 'action.edit' }
}
},
// form
resetForm() {
this.modal = {
data: { type: 'relationship', from: null, to: null, relation: null, reverse: false },
action: null,
title: null,
button: { class: null, text: null, }
}
console.log('==- reset Form', this.modal.data)
},
getRelationsList() {
//console.log('fetch relationsList')
return getRelationsList().then(relations => new Promise(resolve => {
//console.log('+ relations list', relations.results.length)
this.relations = relations.results.filter(r => r.isActive === true)
resolve()
})).catch()
},
customLabel(value) {
//console.log('customLabel', value)
return (value.title && value.reverseTitle) ? `${value.title.fr}${value.reverseTitle.fr}` : ''
},
getPerson(id) {
let person = this.persons.filter(p => p.id === id)
return person[0]
},
// actions
createRelationship() {
this.displayHelpMessage = true
this.listenPersonFlag = 'step1' // toggle listener in create link mode
console.log(' @@> switch listener to create link mode:', this.listenPersonFlag)
},
dropRelationship() {
//console.log('delete', this.modal.data)
deleteRelationship(this.modal.data)
.catch()
this.$store.commit('removeLink', this.modal.data.id)
this.modal.showModal = false
this.resetForm()
},
submitRelationship() {
console.log('submitRelationship', this.modal.action)
switch (this.modal.action) {
case 'create':
return postRelationship(this.modal.data)
.then(relationship => new Promise(resolve => {
console.log('post relationship response', relationship)
this.$store.dispatch('addLinkFromRelationship', relationship)
this.modal.showModal = false
this.resetForm()
resolve()
}))
.catch()
case 'edit':
return patchRelationship(this.modal.data)
.then(relationship => new Promise(resolve => {
console.log('patch relationship response', relationship)
this.$store.commit('updateLink', relationship)
this.modal.showModal = false
this.resetForm()
resolve()
}))
.catch()
default:
throw "uncaught action"
}
}
}
}
</script>
<style src="vis-network/dist/dist/vis-network.min.css"></style>
<style src="vue-multiselect/dist/vue-multiselect.css"></style>
<style lang="scss" scoped>
div#visgraph {
height: 700px;
margin: auto;
}
div#visgraph-legend {
div.post-menu.legend {
}
}
.modal-mask {
background-color: rgba(0, 0, 0, 0.25);
}
.debug {
margin: 1em; padding: 1em;
color: dimgray;
font-style: italic;
font-size: 80%;
}
</style>