2025-07-08 13:38:51 +00:00

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}${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>