Merge remote-tracking branch 'origin/master' into issue185_ACCent_createdBy_updatedBy

This commit is contained in:
2021-11-15 14:14:12 +01:00
88 changed files with 3487 additions and 357 deletions

View File

@@ -1,32 +0,0 @@
import vis from 'vis-network/dist/vis-network.min';
require('./scss/vis.scss');
// create an array with nodes
let nodes = new vis.DataSet([
{ id: 1, label: "Node 1" },
{ id: 2, label: "Node 2" },
{ id: 3, label: "Node 3" },
{ id: 4, label: "Node 4" },
{ id: 5, label: "Node 5", cid: 1 },
]);
// create an array with edges
let edges = new vis.DataSet([
{ from: 1, to: 3 },
{ from: 1, to: 2 },
{ from: 2, to: 4 },
{ from: 2, to: 5 },
{ from: 3, to: 3 },
]);
// create a network
let container = document.getElementById("graph-relationship");
let data = {
nodes: nodes,
edges: edges,
};
let options = {};
//
let network = new vis.Network(container, data, options);

View File

@@ -1,5 +0,0 @@
div#graph-relationship {
margin: 2em auto;
height: 500px;
border: 1px solid lightgray;
}

View File

@@ -16,16 +16,15 @@
<comment v-if="accompanyingCourse.step === 'DRAFT'"></comment>
<confirm v-if="accompanyingCourse.step === 'DRAFT'"></confirm>
<div v-for="error in errorMsg" class="vue-component errors alert alert-danger">
<!-- <div v-for="error in errorMsg" v-bind:key="error.id" class="vue-component errors alert alert-danger">
<p>
<span>{{ error.sta }} {{ error.txt }}</span><br>
<span>{{ $t(error.msg) }}</span>
</p>
</div>
</div> -->
</template>
<script>
import { mapState } from 'vuex'
import { mapGetters, mapState } from 'vuex'
import Banner from './components/Banner.vue';
import StickyNav from './components/StickyNav.vue';
import OriginDemand from './components/OriginDemand.vue';
@@ -55,11 +54,12 @@ export default {
Comment,
Confirm,
},
computed: mapState([
'accompanyingCourse',
'addressContext',
'errorMsg'
])
computed: {
...mapState([
'accompanyingCourse',
'addressContext'
]),
},
};
</script>

View File

@@ -86,7 +86,8 @@ const postParticipation = (id, payload, method) => {
})
.then(response => {
if (response.ok) { return response.json(); }
throw { msg: 'Error while sending AccompanyingPeriod Course participation.', sta: response.status, txt: response.statusText, err: new Error(), body: response.body };
// TODO: adjust message according to status code? Or how to access the message from the violation array?
throw { msg: 'Error while sending AccompanyingPeriod Course participation', sta: response.status, txt: response.statusText, err: new Error(), body: response.body };
});
};

View File

@@ -10,13 +10,13 @@
<VueMultiselect
name="selectOrigin"
label="text"
v-bind:custom-label="transText"
:custom-label="transText"
track-by="id"
v-bind:multiple="false"
v-bind:searchable="true"
v-bind:placeholder="$t('origin.placeholder')"
:multiple="false"
:searchable="true"
:placeholder="$t('origin.placeholder')"
v-model="value"
v-bind:options="options"
:options="options"
@select="updateOrigin">
</VueMultiselect>
@@ -47,18 +47,18 @@ export default {
},
methods: {
getOptions() {
//console.log('loading origins list');
getListOrigins().then(response => new Promise((resolve, reject) => {
this.options = response.results;
resolve();
}));
},
updateOrigin(value) {
//console.log('value', value);
console.log('value', value);
this.$store.dispatch('updateOrigin', value);
},
transText ({ text }) {
return text.fr //TODO multilang
const parsedText = JSON.parse(text);
return parsedText.fr;
},
}
}

View File

@@ -77,7 +77,7 @@ let initPromise = Promise.all([scopesPromise, accompanyingCoursePromise])
},
mutations: {
catchError(state, error) {
console.log('### mutation: a new error have been catched and pushed in store !', error);
// console.log('### mutation: a new error have been catched and pushed in store !', error);
state.errorMsg.push(error);
},
removeParticipation(state, participation) {

View File

@@ -26,7 +26,7 @@
</div>
<div v-if="isLoadingSocialActions">
<p>spinner</p>
<i class="fa fa-circle-o-notch fa-spin fa-fw"></i>
</div>
<div v-if="hasSocialActionPicked" id="persons">
@@ -72,7 +72,7 @@
{{ $t('action.save') }}
</button>
<button class="btn btn-save" v-show="isPostingWork" disabled>
{{ $t('Save') }}
{{ $t('action.save') }}
</button>
</li>
</ul>

View File

@@ -0,0 +1,508 @@
<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>
<a type="button" class="list-group-item list-group-item-action btn btn-misc" id="exportCanvasBtn" @click="exportCanvasAsImage">
<i class="fa fa-camera fa-fw"></i> {{ $t('visgraph.screenshot') }}
</a>
<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: {
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)
},
forceUpdateComponent() {
//console.log('!! forceUpdateComponent !!')
this.refreshNetwork
this.$forceUpdate()
},
// 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"
}
},
// export image
exportCanvasAsImage() {
const canvas = document.getElementById('visgraph')
.querySelector('canvas')
console.log(canvas)
let link = document.getElementById('exportCanvasBtn')
link.download = "filiation.png"
canvas.toBlob(blob => {
console.log(blob)
link.href = URL.createObjectURL(blob)
}, 'image/png')
/*
TODO improve feature
// 1. fonctionne, mais pas de contrôle sur le nom
if (canvas && canvas.getContext('2d')) {
let img = canvas.toDataURL('image/png;base64;')
img = img.replace('image/png','image/octet-stream')
window.open(img, '', 'width=1000, height=1000')
}
// 2. fonctionne, mais 2 click et pas compatible avec tous les browsers
let link = document.getElementById('exportCanvasBtn')
link.download = "image.png"
canvas.toBlob(blob => {
link.href = URL.createObjectURL(blob)
}, 'image/png')
*/
}
}
}
</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>

View File

@@ -0,0 +1,195 @@
import { splitId } from './vis-network'
/**
* @function makeFetch
* @param method
* @param url
* @param body
* @returns {Promise<Response>}
*/
const makeFetch = (method, url, body) => {
return fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json;charset=utf-8'
},
body: (body !== null) ? JSON.stringify(body) : null
})
.then(response => {
if (response.ok) {
return response.json();
}
if (response.status === 422) {
return response.json().then(violations => {
throw ValidationException(violations)
});
}
throw {
msg: 'Error while updating AccompanyingPeriod Course.',
sta: response.status,
txt: response.statusText,
err: new Error(),
body: response.body
};
});
}
/**
* @param violations
* @constructor
*/
const ValidationException = (violations) => {
this.violations = violations
this.name = 'ValidationException'
}
/**
* @function getFetch
* @param url
* @returns {Promise<Response>}
*/
const getFetch = (url) => {
return makeFetch('GET', url, null)
}
/**
* @function postFetch
* @param url
* @param body
* @returns {Promise<Response>}
*/
const postFetch = (url, body) => {
return makeFetch('POST', url, body)
}
/**
* @function patchFetch
* @param url
* @param body
* @returns {Promise<Response>}
*/
const patchFetch = (url, body) => {
return makeFetch('PATCH', url, body)
}
/**
* @function deleteFetch
* @param url
* @param body
* @returns {Promise<Response>}
*/
const deleteFetch = (url, body) => {
return makeFetch('DELETE', url, null)
}
/**
* @function getHouseholdByPerson
* @param person
* @returns {Promise<Response>}
*/
const getHouseholdByPerson = (person) => {
//console.log('getHouseholdByPerson', person.id)
if (person.current_household_id === null) {
throw 'Currently the person has not household!'
}
return getFetch(
`/api/1.0/person/household/${person.current_household_id}.json`)
}
/**
* @function getCoursesByPerson
* @param person
* @returns {Promise<Response>}
*/
const getCoursesByPerson = (person) => {
//console.log('getCoursesByPerson', person._id)
return getFetch(
`/api/1.0/person/accompanying-course/by-person/${person._id}.json`)
}
/**
* @function getRelationshipsByPerson
* @param person
* @returns {Promise<Response>}
*/
const getRelationshipsByPerson = (person) => {
//console.log('getRelationshipsByPerson', person.id)
return getFetch(
`/api/1.0/relations/relationship/by-person/${person._id}.json`)
}
/**
* Return list of relations
* @returns {Promise<Response>}
*/
const getRelationsList = () => {
return getFetch(`/api/1.0/relations/relation.json`)
}
/**
* @function postRelationship
* @param relationship
* @returns {Promise<Response>}
*/
const postRelationship = (relationship) => {
//console.log(relationship)
return postFetch(
`/api/1.0/relations/relationship.json`,
{
type: 'relationship',
fromPerson: { type: 'person', id: splitId(relationship.from, 'id') },
toPerson: { type: 'person', id: splitId(relationship.to, 'id') },
relation: { type: 'relation', id: relationship.relation.id },
reverse: relationship.reverse
}
)
}
/**
* @function patchRelationship
* @param relationship
* @returns {Promise<Response>}
*/
const patchRelationship = (relationship) => {
//console.log(relationship)
let linkType = splitId(relationship.id, 'link')
let id = splitId(linkType, 'id')
return patchFetch(
`/api/1.0/relations/relationship/${id}.json`,
{
type: 'relationship',
fromPerson: { type: 'person', id: splitId(relationship.from, 'id') },
toPerson: { type: 'person', id: splitId(relationship.to, 'id') },
relation: { type: 'relation', id: relationship.relation.id },
reverse: relationship.reverse
}
)
}
/**
* @function deleteRelationship
* @param relationship
* @returns {Promise<Response>}
*/
const deleteRelationship = (relationship) => {
//console.log(relationship)
let linkType = splitId(relationship.id, 'link')
let id = splitId(linkType, 'id')
return deleteFetch(
`/api/1.0/relations/relationship/${id}.json`
)
}
export {
getHouseholdByPerson,
getCoursesByPerson,
getRelationshipsByPerson,
getRelationsList,
postRelationship,
patchRelationship,
deleteRelationship
}

View File

@@ -0,0 +1,62 @@
const visMessages = {
fr: {
visgraph: {
Course: 'Parcours',
Household: 'Ménage',
Holder: 'Titulaire',
Legend: 'Calques',
concerned: 'concerné',
both: 'neutre, non binaire',
woman: 'féminin',
man: 'masculin',
years: 'ans',
click_to_expand: 'cliquez pour étendre',
add_relationship_link: "Créer un lien de filiation",
edit_relationship_link: "Modifier le lien de filiation",
delete_relationship_link: "Êtes-vous sûr ?",
delete_confirmation_text: "Vous allez supprimer le lien entre ces 2 usagers.",
reverse_relation: "Inverser la relation",
relation_from_to_like: "{2} de {1}", // disable {0}
between: "entre",
and: "et",
add_link: "Créer un lien de filiation",
create_link_help: "Pour créer un lien de filiation, cliquez d'abord sur un usager, puis sur un second ; précisez ensuite la nature du lien dans le formulaire d'édition.",
refresh: "Rafraîchir",
screenshot: "Prendre une photo",
choose_relation: "Choisissez le lien de parenté",
},
edit: 'Éditer',
del: 'Supprimer',
back: 'Revenir en arrière',
addNode: 'Ajouter un noeuds',
addEdge: 'Ajouter un lien de filiation',
editNode: 'Éditer le noeuds',
editEdge: 'Éditer le lien',
addDescription: 'Cliquez dans un espace vide pour créer un nouveau nœud.',
edgeDescription: 'Cliquez sur un usager et faites glisser le lien vers un autre usager pour les connecter.',
editEdgeDescription: 'Cliquez sur les points de contrôle et faites-les glisser vers un nœud pour les relier.',
createEdgeError: 'Il est impossible de relier des arêtes à un cluster.',
deleteClusterError: 'Les clusters ne peuvent pas être supprimés.',
editClusterError: 'Les clusters ne peuvent pas être modifiés.'
},
en: {
edit: 'Edit',
del: 'Delete selected',
back: 'Back',
addNode: 'Add Node',
addEdge: 'Add Link',
editNode: 'Edit Switch',
editEdge: 'Edit Link',
addDescription: 'Click in an empty space to place a new node.',
edgeDescription: 'Click on a node and drag the link to another node to connect them.',
editEdgeDescription: 'Click on the control points and drag them to a node to connect to it.',
createEdgeError: 'Cannot link edges to a cluster.',
deleteClusterError: 'Clusters cannot be deleted.',
editClusterError: 'Clusters cannot be edited.'
}
}
export {
visMessages
}

View File

@@ -0,0 +1,24 @@
import { createApp } from "vue"
import { store } from "./store.js"
import { _createI18n } from 'ChillMainAssets/vuejs/_js/i18n'
import { visMessages } from './i18n'
import App from './App.vue'
import './vis-network'
const i18n = _createI18n(visMessages)
const container = document.getElementById('relationship-graph')
const persons = JSON.parse(container.dataset.persons)
persons.forEach(person => {
store.dispatch('addPerson', person)
store.commit('markInWhitelist', person)
})
const app = createApp({
template: `<app></app>`
})
.use(store)
.use(i18n)
.component('app', App)
.mount('#relationship-graph')

View File

@@ -0,0 +1,534 @@
import { createStore } from 'vuex'
import { getHouseholdByPerson, getCoursesByPerson, getRelationshipsByPerson } from './api'
import { getHouseholdLabel, getHouseholdWidth, getRelationshipLabel, getRelationshipTitle, getRelationshipDirection, splitId, getGender, getAge } from './vis-network'
import {visMessages} from "./i18n";
const debug = process.env.NODE_ENV !== 'production'
const store = createStore({
strict: debug,
state: {
persons: [],
households: [],
courses: [],
relationships: [],
links: [],
whitelistIds: [],
personLoadedIds: [],
householdLoadingIds: [],
courseLoadedIds: [],
relationshipLoadedIds: [],
excludedNodesIds: [],
updateHack: 0
},
getters: {
nodes(state) {
let nodes = []
state.persons.forEach(p => {
nodes.push(p)
})
state.households.forEach(h => {
nodes.push(h)
})
state.courses.forEach(c => {
nodes.push(c)
})
// except excluded nodes (unchecked layers)
state.excludedNodesIds.forEach(excluded => {
nodes = nodes.filter(n => n.id !== excluded)
})
return nodes
},
edges(state) {
return state.links
},
isInWhitelist: (state) => (person_id) => {
return state.whitelistIds.includes(person_id)
},
isHouseholdLoading: (state) => (household_id) => {
return state.householdLoadingIds.includes(household_id)
},
isCourseLoaded: (state) => (course_id) => {
return state.courseLoadedIds.includes(course_id)
},
isRelationshipLoaded: (state) => (relationship_id) => {
return state.relationshipLoadedIds.includes(relationship_id)
},
isPersonLoaded: (state) => (person_id) => {
return state.personLoadedIds.includes(person_id)
},
isExcludedNode: (state) => (id) => {
return state.excludedNodesIds.includes(id)
},
countLinksByNode: (state) => (node_id) => {
let array = []
state.links.filter(link => ! link.id.startsWith('relationship'))
.forEach(link => {
if (link.from === node_id || link.to === node_id) {
if (state.excludedNodesIds.indexOf(splitId(link.id, 'link')) === -1) {
array.push(link)
}
//console.log(link.id, state.excludedNodesIds.indexOf(splitId(link.id, 'link')))
}
})
//console.log('count links', array.length, array.map(i => i.id))
return array.length
},
getParticipationsByCourse: (state) => (course_id) => {
const course = state.courses.filter(c => c.id === course_id)[0]
const currentParticipations = course.participations.filter(p => p.endDate === null)
//console.log('get persons in', course_id, currentParticipations.map(p => p.person.id),
// 'with folded', currentParticipations.filter(p => p.person.folded === true).map(p => p.person.id))
return currentParticipations
},
getMembersByHousehold: (state) => (household_id) => {
const household = state.households.filter(h => h.id === household_id)[0]
const currentMembers = household.members.filter(m => household.current_members_id.includes(m.id))
//console.log('get persons in', household_id, currentMembers.map(m => m.person.id),
// 'with folded', currentMembers.filter(m => m.person.folded === true).map(m => m.person.id))
return currentMembers
},
/**
* This getter is a little bit mysterious :
* The 2 previous getters return complete array, but folded (missing) persons are not taken into consideration and are not displayed (!?!)
* This getter compare input array (participations|members) to personLoadedIds array
* and return complete array with folded persons taken into consideration
*
* @param state
* @param array - An array of persons from course or household.
* This array is dirty, melting persons adapted (or not) to vis, with _id and _label.
* @return array - An array of persons mapped and taken in state.persons
*/
getPersonsGroup: (state) => (array) => {
let group = []
array.forEach(item => {
let id = splitId(item.person.id, 'id')
if (state.personLoadedIds.includes(id)) {
group.push(state.persons.filter(person => person._id === id)[0])
}
})
//console.log('array', array.map(item => item.person.id))
console.log('get persons group', group.map(f => f.id))
return group
},
},
mutations: {
addPerson(state, [person, options]) {
let debug = ''
/// Debug mode: uncomment to display person_id on visgraph
//debug = `\nid ${person.id}`
person.group = person.type
person._id = person.id
person.id = `person_${person.id}`
person.label = `*${person.text}*\n_${getGender(person.gender)} - ${getAge(person.birthdate)}_${debug}` //
person.folded = false
// folded is used for missing persons
if (options.folded) {
person.title = visMessages.fr.visgraph.click_to_expand
person._label = person.label // keep label
person.label = null
person.folded = true
}
state.persons.push(person)
},
addHousehold(state, household) {
household.group = household.type
household._id = household.id
household.label = `${visMessages.fr.visgraph.Household}${household.id}`
household.id = `household_${household.id}`
state.households.push(household)
},
addCourse(state, course) {
course.group = course.type
course._id = course.id
course.label = `${visMessages.fr.visgraph.Course}${course.id}`
course.id = `accompanying_period_${course.id}`
state.courses.push(course)
},
addRelationship(state, relationship) {
relationship.group = relationship.type
relationship._id = relationship.id
relationship.id = `relationship_${relationship.id}`
state.relationships.push(relationship)
},
addLink(state, link) {
state.links.push(link)
},
updateLink(state, link) {
console.log('updateLink', link)
let link_ = {
from: `person_${link.fromPerson.id}`,
to: `person_${link.toPerson.id}`,
id: 'relationship_' + splitId(link.id,'id')
+ '-person_' + link.fromPerson.id + '-person_' + link.toPerson.id,
arrows: getRelationshipDirection(link),
color: 'lightblue',
font: { color: '#33839d' },
dashes: true,
label: getRelationshipLabel(link),
title: getRelationshipTitle(link),
relation: link.relation,
reverse: link.reverse
}
// find row position and replace by updatedLink
state.links.splice(
state.links.findIndex(item => item.id === link_.id), 1, link_
)
},
removeLink(state, link_id) {
state.links = state.links.filter(l => l.id !== link_id)
},
//// id markers
markInWhitelist(state, person) {
state.whitelistIds.push(person.id)
},
markPersonLoaded(state, id) {
state.personLoadedIds.push(id)
},
unmarkPersonLoaded(state, id) {
state.personLoadedIds = state.personLoadedIds.filter(i => i !== id)
},
markHouseholdLoading(state, id) {
//console.log('..loading household', id)
state.householdLoadingIds.push(id)
},
unmarkHouseholdLoading(state, id) {
state.householdLoadingIds = state.householdLoadingIds.filter(i => i !== id)
},
markCourseLoaded(state, id) {
state.courseLoadedIds.push(id)
},
unmarkCourseLoaded(state, id) {
state.courseLoadedIds = state.courseLoadedIds.filter(i => i !== id)
},
markRelationshipLoaded(state, id) {
state.relationshipLoadedIds.push(id)
},
unmarkRelationshipLoaded(state, id) {
state.relationshipLoadedIds = state.relationshipLoadedIds.filter(i => i !== id)
},
//// excluded
addExcludedNode(state, id) {
//console.log('==> exclude list: +', id)
state.excludedNodesIds.push(id)
},
removeExcludedNode(state, id) {
//console.log('<== exclude list: -', id)
state.excludedNodesIds = state.excludedNodesIds.filter(e => e !== id)
},
//// unfold
unfoldPerson(state, person) {
//console.log('unfoldPerson', person)
person.label = person._label
delete person._label
delete person.title
person.folded = false
},
//// force update hack
updateHack(state) {
state.updateHack = state.updateHack + 1
}
},
actions: {
/**
* Expand loop (steps 1->10), always start from a person.
* Fetch household, courses, relationships, and others persons.
* These persons are "missing" and will be first display in fold mode.
*
* 1) Add a new person
* @param object
* @param person
*/
addPerson({ commit, dispatch }, person) {
commit('markPersonLoaded', person.id)
commit('addPerson', [person, { folded: false }])
commit('updateHack')
dispatch('fetchInfoForPerson', person)
},
/**
* 2) Fetch infos for this person (hub)
* @param object
* @param person
*/
fetchInfoForPerson({ dispatch }, person) {
// TODO enfants hors ménages
// example: household 61
// console.log(person.text, 'household', person.current_household_id)
if (null !== person.current_household_id) {
dispatch('fetchHouseholdForPerson', person)
}
dispatch('fetchCoursesByPerson', person)
dispatch('fetchRelationshipByPerson', person)
},
/**
* 3) Fetch person current household (if it is not already loading)
* check first isHouseholdLoading to fetch household once
* @param object
* @param person
*/
fetchHouseholdForPerson({ commit, getters, dispatch }, person) {
//console.log(' isHouseholdLoading ?', getters.isHouseholdLoading(person.current_household_id))
if (! getters.isHouseholdLoading(person.current_household_id)) {
commit('markHouseholdLoading', person.current_household_id)
getHouseholdByPerson(person)
.then(household => new Promise(resolve => {
commit('addHousehold', household)
// DISABLED: in init or expand loop, layer is uncheck when added
//commit('addExcludedNode', household.id)
//commit('updateHack')
dispatch('addLinkFromPersonsToHousehold', household)
commit('updateHack')
resolve()
})
).catch( () => {
commit('unmarkHouseholdLoading', person.current_household_id)
})
}
},
/**
* 4) Add an edge for each household member (household -> person)
* @param object
* @param household
*/
addLinkFromPersonsToHousehold({ commit, getters, dispatch }, household) {
let members = getters.getMembersByHousehold(household.id)
console.log('add link for', members.length, 'members')
members.forEach(m => {
commit('addLink', {
from: `${m.person.type}_${m.person.id}`,
to: `household_${m.person.current_household_id}`,
id: `household_${m.person.current_household_id}-person_${m.person.id}`,
arrows: 'from',
color: 'pink',
font: { color: '#D04A60' },
label: getHouseholdLabel(m),
width: getHouseholdWidth(m),
})
if (!getters.isPersonLoaded(m.person.id)) {
dispatch('addMissingPerson', [m.person, household])
}
})
},
/**
* 5) Fetch AccompanyingCourses for the person
* @param object
* @param person
*/
fetchCoursesByPerson({ commit, dispatch }, person) {
getCoursesByPerson(person)
.then(courses => new Promise(resolve => {
dispatch('addCourses', courses)
resolve()
}))
},
/**
* 6) Add each distinct course (a person can have multiple courses)
* @param object
* @param courses
*/
addCourses({ commit, getters, dispatch }, courses) {
let currentCourses = courses.filter(c => c.closingDate === null)
currentCourses.forEach(course => {
//console.log(' isCourseLoaded ?', getters.isCourseLoaded(course.id))
if (! getters.isCourseLoaded(course.id)) {
commit('markCourseLoaded', course.id)
commit('addCourse', course)
commit('addExcludedNode', course.id) // in init or expand loop, layer is uncheck when added
dispatch('addLinkFromPersonsToCourse', course)
commit('updateHack')
}
})
},
/**
* 7) Add an edge for each course participation (course <- person)
* @param object
* @param course
*/
addLinkFromPersonsToCourse({ commit, getters, dispatch }, course) {
const participations = getters.getParticipationsByCourse(course.id)
console.log('add link for', participations.length, 'participations')
participations.forEach(p => {
//console.log(p.person.id)
commit('addLink', {
from: `${p.person.type}_${p.person.id}`,
to: `${course.id}`,
id: `accompanying_period_${splitId(course.id,'id')}-person_${p.person.id}`,
arrows: 'from',
color: 'orange',
font: { color: 'darkorange' },
})
if (!getters.isPersonLoaded(p.person.id)) {
dispatch('addMissingPerson', [p.person, course])
}
})
},
/**
* 8) Fetch Relationship
* @param object
* @param person
*/
fetchRelationshipByPerson({ dispatch }, person) {
//console.log('fetchRelationshipByPerson', person)
getRelationshipsByPerson(person)
.then(relationships => new Promise(resolve => {
dispatch('addRelationships', relationships)
resolve()
}))
},
/**
* 9) Add each distinct relationship
* @param object
* @param relationships
*/
addRelationships({ commit, getters, dispatch }, relationships) {
relationships.forEach(relationship => {
//console.log(' isRelationshipLoaded ?', getters.isRelationshipLoaded(relationship.id))
if (! getters.isRelationshipLoaded(relationship.id)) {
commit('markRelationshipLoaded', relationship.id)
commit('addRelationship', relationship)
dispatch('addLinkFromRelationship', relationship)
commit('updateHack')
}
})
},
/**
* 10) Add an edge for each relationship (person -> person)
* @param object
* @param relationship
*/
addLinkFromRelationship({ commit, getters, dispatch }, relationship) {
//console.log('-> addLink from person', relationship.fromPerson.id, 'to person', relationship.toPerson.id)
commit('addLink', {
from: `person_${relationship.fromPerson.id}`,
to: `person_${relationship.toPerson.id}`,
id: 'relationship_' + splitId(relationship.id,'id')
+ '-person_' + relationship.fromPerson.id + '-person_' + relationship.toPerson.id,
arrows: getRelationshipDirection(relationship),
color: 'lightblue',
font: { color: '#33839d' },
dashes: true,
label: getRelationshipLabel(relationship),
title: getRelationshipTitle(relationship),
relation: relationship.relation,
reverse: relationship.reverse
})
for (let person of [relationship.fromPerson, relationship.toPerson]) {
if (!getters.isPersonLoaded(person.id)) {
dispatch('addMissingPerson', [person, relationship])
}
}
},
/**
* Add missing person. node is displayed without label (folded).
* We stop here and listen on events to unfold person and expand its fetch infos
* @param object
* @param array
*/
addMissingPerson({ commit, getters, dispatch }, [person, parent]) {
console.log('! add missing Person', person.id)
commit('markPersonLoaded', person.id)
commit('addPerson', [person, { folded: true }])
if (getters.isExcludedNode(parent.id)) {
// in init or expand loop, exclude too missing persons if parent have been excluded
commit('addExcludedNode', person.id)
}
commit('updateHack')
},
/**
* ==================================================================
* Triggered by a vis-network event when clicking on a Course Node.
* Each folded node is unfold, then expanded with fetch infos
* @param object
* @param course
*/
unfoldPersonsByCourse({ getters, commit, dispatch }, course) {
const participations = getters.getParticipationsByCourse(course.id)
getters.getPersonsGroup(participations)
.forEach(person => {
if (person.folded === true) {
console.log('-=. unfold and expand person', person.id)
commit('unfoldPerson', person)
dispatch('fetchInfoForPerson', person)
}
})
},
/**
* Triggered by a vis-network event when clicking on a Household Node.
* Each folded node is unfold, then expanded with fetch infos
* @param object
* @param household
*/
unfoldPersonsByHousehold({ getters, commit, dispatch }, household) {
const members = getters.getMembersByHousehold(household.id)
getters.getPersonsGroup(members)
.forEach(person => {
if (person.folded === true) {
console.log('-=. unfold and expand person', person.id)
commit('unfoldPerson', person)
dispatch('fetchInfoForPerson', person)
}
})
},
/**
* ==================================================================
* For an excluded node, add|remove relative persons excluded too
* @param object
* @param array (add|remove action, id)
*/
excludedNode({ getters, commit }, [action, id]) {
const personGroup = () => {
switch (splitId(id, 'type')) {
case 'accompanying_period':
return getters.getParticipationsByCourse(id)
case 'household':
return getters.getMembersByHousehold(id)
default:
throw 'undefined case with this id'
}
}
let group = getters.getPersonsGroup(personGroup())
if (action === 'add') {
commit('addExcludedNode', id)
group.forEach(person => {
// countLinks < 2 but parent has just already been added !
if (!getters.isInWhitelist(person.id) && getters.countLinksByNode(person.id) < 1) {
commit('addExcludedNode', person.id)
}
})
}
if (action === 'remove') {
commit('removeExcludedNode', id)
group.forEach(person => {
commit('removeExcludedNode', person.id)
})
}
commit('updateHack')
},
}
})
export { store }

View File

@@ -0,0 +1,262 @@
import { visMessages } from './i18n'
/**
* Vis-network initial data/configuration script
* Notes:
* Use window.network and window.options to avoid conflict between vue and vis
* cfr. https://github.com/almende/vis/issues/2524#issuecomment-307108271
*/
window.network = {}
window.options = {
locale: 'fr',
locales: visMessages,
/*
configure: {
enabled: true,
filter: 'nodes,edges',
//container: undefined,
showButton: true
},
*/
physics: {
enabled: true,
barnesHut: {
theta: 0.5,
gravitationalConstant: -2000,
centralGravity: 0.08, //// 0.3
springLength: 220, //// 95
springConstant: 0.04,
damping: 0.09,
avoidOverlap: 0
},
forceAtlas2Based: {
theta: 0.5,
gravitationalConstant: -50,
centralGravity: 0.01,
springLength: 100,
springConstant: 0.08,
damping: 0.4,
avoidOverlap: 0
},
repulsion: {
centralGravity: 0.2,
springLength: 200,
springConstant: 0.05,
nodeDistance: 100,
damping: 0.09
},
hierarchicalRepulsion: {
centralGravity: 0.0,
springLength: 100,
springConstant: 0.01,
nodeDistance: 120,
damping: 0.09,
avoidOverlap: 0
},
maxVelocity: 50,
minVelocity: 0.1,
solver: 'forceAtlas2Based', //'barnesHut', //
stabilization: {
enabled: true,
iterations: 1000,
updateInterval: 100,
onlyDynamicEdges: false,
fit: true
},
timestep: 0.5,
adaptiveTimestep: true,
wind: { x: 0, y: 0 }
},
interaction: {
hover: true,
multiselect: true,
navigationButtons: false,
},
manipulation: {
enabled: false,
initiallyActive: false,
addNode: false,
deleteNode: false
},
nodes: {
borderWidth: 1,
borderWidthSelected: 3,
font: {
multi: 'md'
}
},
edges: {
font: {
color: '#b0b0b0',
size: 9,
face: 'arial',
background: 'none',
strokeWidth: 2, // px
strokeColor: '#ffffff',
align: 'middle',
multi: false,
vadjust: 0,
},
scaling:{
label: true,
},
smooth: true,
},
groups: {
person: {
shape: 'box',
shapeProperties: {
borderDashes: false,
borderRadius: 3,
},
color: {
border: '#b0b0b0',
background: 'rgb(193,229,222)',
highlight: {
border: '#89c9a9',
background: 'rgb(156,213,203)'
},
hover: {
border: '#89c9a9',
background: 'rgb(156,213,203)'
}
},
opacity: 0.85,
shadow:{
enabled: true,
color: 'rgba(0,0,0,0.5)',
size:10,
x:5,
y:5
},
},
household: {
color: 'pink'
},
accompanying_period: {
color: 'orange',
},
}
}
/**
* @param gender
* @returns {string}
*/
const getGender = (gender) => {
switch (gender) {
case 'both':
return visMessages.fr.visgraph.both
case 'woman':
return visMessages.fr.visgraph.woman
case 'man':
return visMessages.fr.visgraph.man
default:
throw 'gender undefined'
}
}
/**
* TODO Repeat getAge() in PersonRenderBox.vue
* @param birthdate
* @returns {string|null}
*/
const getAge = (birthdate) => {
if (null === birthdate) {
return null
}
const birthday = new Date(birthdate.datetime)
const now = new Date()
return (now.getFullYear() - birthday.getFullYear()) + ' '+ visMessages.fr.visgraph.years
}
/**
* Return member position in household
* @param member
* @returns string
*/
const getHouseholdLabel = (member) => {
let position = member.position.label.fr
let holder = member.holder ? ` ${visMessages.fr.visgraph.Holder}` : ''
return position + holder
}
/**
* Return edge width for member (depends of position in household)
* @param member
* @returns integer (width)
*/
const getHouseholdWidth = (member) => {
if (member.holder) {
return 5
}
if (member.shareHousehold) {
return 2
}
return 1
}
/**
* Return direction edge
* @param relationship
* @returns string
*/
const getRelationshipDirection = (relationship) => {
return (!relationship.reverse) ? 'to' : 'from'
}
/**
* Return label edge
* !! always set label in title direction (arrow is reversed, see in previous method) !!
* @param relationship
* @returns string
*/
const getRelationshipLabel = (relationship) => {
return relationship.relation.title.fr
}
/**
* Return title edge
* @param relationship
* @returns string
*/
const getRelationshipTitle = (relationship) => {
return (!relationship.reverse) ?
relationship.relation.title.fr + ': ' + relationship.fromPerson.text + '\n' + relationship.relation.reverseTitle.fr + ': ' + relationship.toPerson.text :
relationship.relation.title.fr + ': ' + relationship.toPerson.text + '\n' + relationship.relation.reverseTitle.fr + ': ' + relationship.fromPerson.text
}
/**
* Split string id and return type|id substring
* @param id
* @param position
* @returns string|integer
*/
const splitId = (id, position) => {
//console.log(id, position)
switch (position) {
case 'type': // return 'accompanying_period'
return /(.+)_/.exec(id)[1]
case 'id': // return 124
return parseInt(id.toString()
.split("_")
.pop())
case 'link':
return id.split("-")[0] // return first segment
default:
throw 'position undefined'
}
}
export {
getGender,
getAge,
getHouseholdLabel,
getHouseholdWidth,
getRelationshipDirection,
getRelationshipLabel,
getRelationshipTitle,
splitId
}

View File

@@ -20,18 +20,25 @@
v-bind:item="item">
</suggestion-third-party>
<suggestion-user
v-if="item.result.type === 'user'"
v-bind:item="item">
</suggestion-user>
</div>
</template>
<script>
import SuggestionPerson from './TypePerson';
import SuggestionThirdParty from './TypeThirdParty';
import SuggestionUser from './TypeUser';
export default {
name: 'PersonSuggestion',
components: {
SuggestionPerson,
SuggestionThirdParty,
SuggestionUser,
},
props: [
'item',

View File

@@ -0,0 +1,47 @@
<template>
<div class="container usercontainer">
<div class="user-identification">
<span class="name">
{{ item.result.text }}
</span>
</div>
</div>
<div class="right_actions">
<span class="badge rounded-pill bg-secondary">
{{ $t('user')}}
</span>
</div>
</template>
<script>
const i18n = {
messages: {
fr: {
user: 'Utilisateur' // TODO how to define other translations?
}
}
};
export default {
name: 'SuggestionUser',
props: ['item'],
i18n,
computed: {
hasParent() {
return this.$props.item.result.parent !== null;
},
}
}
</script>
<style lang="scss" scoped>
.usercontainer {
.userparent {
.name {
font-weight: bold;
font-variant: all-small-caps;
}
}
}
</style>