mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-07-12 03:46:16 +00:00
715 lines
27 KiB
Vue
715 lines
27 KiB
Vue
<template>
|
|
<div id="visgraph" />
|
|
|
|
<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-misc"
|
|
@click="createRelationship"
|
|
>
|
|
<i class="fa fa-plus" /> {{ $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" />
|
|
{{ $t("visgraph.screenshot") }}
|
|
</a>
|
|
</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, i) in legendLayers"
|
|
:key="`layer-${i}`"
|
|
>
|
|
<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"
|
|
:modal-dialog-class="modal.modalDialogClass"
|
|
@close="modal.showModal = false"
|
|
>
|
|
<template #header>
|
|
<h2 class="modal-title">
|
|
{{ $t(modal.title) }}
|
|
</h2>
|
|
<!-- {{ modal.data.id }} -->
|
|
</template>
|
|
<template #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">
|
|
<small>{{
|
|
getPersonAge(modal.data.from)
|
|
}}</small>
|
|
<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">
|
|
<small>{{ getPersonAge(modal.data.to) }}</small>
|
|
<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"
|
|
/>
|
|
</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 #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"
|
|
/>
|
|
</template>
|
|
</modal>
|
|
</teleport>
|
|
<ul class="record_actions sticky-form-buttons">
|
|
<li>
|
|
<add-persons
|
|
button-title="visgraph.add_person"
|
|
modal-title="visgraph.add_person"
|
|
:key="addPersons.key"
|
|
:options="addPersons.options"
|
|
@add-new-persons="addNewPersons"
|
|
ref="addPersons"
|
|
/>
|
|
</li>
|
|
</ul>
|
|
</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, getAge } from "./vis-network";
|
|
import { visMessages } from "./i18n";
|
|
import AddPersons from "ChillPersonAssets/vuejs/_components/AddPersons.vue";
|
|
|
|
export default {
|
|
name: "App",
|
|
components: {
|
|
Modal,
|
|
VueMultiselect,
|
|
AddPersons,
|
|
},
|
|
props: ["household_id"],
|
|
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,
|
|
},
|
|
},
|
|
canvas: null,
|
|
link: null,
|
|
addPersons: {
|
|
key: "filiation",
|
|
options: {
|
|
type: ["person"],
|
|
priority: null,
|
|
uniq: false,
|
|
},
|
|
},
|
|
};
|
|
},
|
|
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);
|
|
|
|
return 1;
|
|
},
|
|
|
|
legendLayers() {
|
|
//console.log('--- refresh legend and rebuild checked Layers')
|
|
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
|
|
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];
|
|
},
|
|
|
|
// eslint-disable-next-line vue/no-dupe-keys
|
|
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();
|
|
console.log(this.persons);
|
|
|
|
this.canvas = document
|
|
.getElementById("visgraph")
|
|
.querySelector("canvas");
|
|
this.link = document.getElementById("exportCanvasBtn");
|
|
},
|
|
methods: {
|
|
addNewPersons({ selected, modal }) {
|
|
// console.log('@@@ CLICK button addNewPersons', selected);
|
|
selected.forEach(function (item) {
|
|
this.$store
|
|
.dispatch("addMorePerson", item.result)
|
|
.catch(({ name, violations }) => {
|
|
if (
|
|
name === "ValidationException" ||
|
|
name === "AccessException"
|
|
) {
|
|
violations.forEach((violation) =>
|
|
this.$toast.open({ message: violation }),
|
|
);
|
|
} else {
|
|
this.$toast.open({ message: violations });
|
|
}
|
|
});
|
|
}, this);
|
|
this.$refs.addPersons.resetSearch(); // to cast child method
|
|
modal.showModal = false;
|
|
},
|
|
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 !!')
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
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);
|
|
this.$store.dispatch(
|
|
"fetchInfoForPerson",
|
|
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, reverse: false },
|
|
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];
|
|
},
|
|
getPersonAge(id) {
|
|
let person = this.getPerson(id);
|
|
return getAge(person);
|
|
},
|
|
|
|
// 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();
|
|
this.forceUpdateComponent();
|
|
},
|
|
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();
|
|
this.forceUpdateComponent();
|
|
resolve();
|
|
}),
|
|
)
|
|
.catch((error) => {
|
|
if (error.name === "ValidationException") {
|
|
for (let v of error.violations) {
|
|
this.$toast.open({ message: v });
|
|
console.log(v);
|
|
}
|
|
} else {
|
|
this.$toast.open({
|
|
message: "An error occurred",
|
|
});
|
|
}
|
|
});
|
|
|
|
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();
|
|
this.forceUpdateComponent();
|
|
resolve();
|
|
}),
|
|
)
|
|
.catch();
|
|
|
|
default:
|
|
throw "uncaught action";
|
|
}
|
|
},
|
|
|
|
// export image
|
|
async exportCanvasAsImage() {
|
|
let filename = `filiation_${this.household_id}.jpg`,
|
|
mime = "image/jpeg",
|
|
quality = 0.85,
|
|
footer = `© Chill ${new Date().getFullYear()}`,
|
|
timestamp = `${visMessages.fr.visgraph.relationship_household} n° ${this.household_id} — ${new Date().toLocaleString()}`;
|
|
|
|
// resolve toBlob in a Promise
|
|
const getCanvasBlob = (canvas) =>
|
|
new Promise((resolve) => {
|
|
canvas.toBlob((blob) => resolve(blob), mime, quality);
|
|
});
|
|
|
|
// build image from new temporary canvas
|
|
let tmpCanvas = document.createElement("canvas");
|
|
tmpCanvas.width = this.canvas.width;
|
|
tmpCanvas.height = this.canvas.height;
|
|
|
|
let ctx = tmpCanvas.getContext("2d");
|
|
ctx.beginPath();
|
|
ctx.fillStyle = "#fff";
|
|
ctx.fillRect(0, 0, tmpCanvas.width, tmpCanvas.height);
|
|
ctx.fillStyle = "#9d4600";
|
|
ctx.fillText(footer + " — " + timestamp, 5, tmpCanvas.height - 10);
|
|
ctx.drawImage(this.canvas, 0, 0);
|
|
|
|
return await getCanvasBlob(tmpCanvas).then((blob) => {
|
|
let url = document.createElement("a");
|
|
url.download = filename;
|
|
url.href = window.URL.createObjectURL(blob);
|
|
url.click();
|
|
console.log("url", url.href);
|
|
URL.revokeObjectURL(url.href);
|
|
});
|
|
},
|
|
},
|
|
};
|
|
</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>
|