Graph de famille en frontend (#820)

* Remove graphviz and use cytoscape.js instead

* Frontend generated graphs
* Make installation easier and faster
* Better user experience
* Family api and improved interface
* Fix url history when using 0, improve button selection and reset reverse with reset button
* Use klay layout
* Add js translations and apply review comments
This commit is contained in:
Bartuccio Antoine
2024-09-17 12:10:06 +02:00
committed by GitHub
parent bf96d8a10c
commit f624b7c66d
29 changed files with 1332 additions and 684 deletions

View File

@ -89,7 +89,7 @@ function update_query_string(key, value, action = History.REPLACE, url = null) {
if (!url){
url = new URL(window.location.href);
}
if (!value) {
if (value === undefined || value === null || value === "") {
// If the value is null, undefined or empty => delete it
url.searchParams.delete(key)
} else if (Array.isArray(value)) {

View File

@ -239,6 +239,7 @@ a:not(.button) {
padding: 9px 13px;
border: none;
text-decoration: none;
border-radius: 5px;
&.btn-blue {
background-color: $deepblue;

View File

@ -0,0 +1,267 @@
async function get_graph_data(url, godfathers_depth, godchildren_depth) {
let data = await (
await fetch(
`${url}?godfathers_depth=${godfathers_depth}&godchildren_depth=${godchildren_depth}`,
)
).json();
return [
...data.users.map((user) => {
return { data: user };
}),
...data.relationships.map((rel) => {
return {
data: { source: rel.godfather, target: rel.godchild },
};
}),
];
}
function create_graph(container, data, active_user_id) {
let cy = cytoscape({
boxSelectionEnabled: false,
autounselectify: true,
container: container,
elements: data,
minZoom: 0.5,
style: [
// the stylesheet for the graph
{
selector: "node",
style: {
label: "data(display_name)",
"background-image": "data(profile_pict)",
width: "100%",
height: "100%",
"background-fit": "cover",
"background-repeat": "no-repeat",
shape: "ellipse",
},
},
{
selector: "edge",
style: {
width: 5,
"line-color": "#ccc",
"target-arrow-color": "#ccc",
"target-arrow-shape": "triangle",
"curve-style": "bezier",
},
},
{
selector: ".traversed",
style: {
"border-width": "5px",
"border-style": "solid",
"border-color": "red",
"target-arrow-color": "red",
"line-color": "red",
},
},
{
selector: ".not-traversed",
style: {
"line-opacity": "0.5",
"background-opacity": "0.5",
"background-image-opacity": "0.5",
},
},
],
layout: {
name: "klay",
nodeDimensionsIncludeLabels: true,
fit: true,
klay: {
addUnnecessaryBendpoints: true,
direction: 'DOWN',
nodePlacement: 'INTERACTIVE',
layoutHierarchy: true
}
}
});
let active_user = cy
.getElementById(active_user_id)
.style("shape", "rectangle");
/* Reset graph */
let reset_graph = () => {
cy.elements((element) => {
if (element.hasClass("traversed")) {
element.removeClass("traversed");
}
if (element.hasClass("not-traversed")) {
element.removeClass("not-traversed");
}
});
};
let on_node_tap = (el) => {
reset_graph();
/* Create path on graph if selected isn't the targeted user */
if (el === active_user) {
return;
}
cy.elements((element) => {
element.addClass("not-traversed");
});
cy.elements()
.aStar({
root: el,
goal: active_user,
})
.path.forEach((el) => {
el.removeClass("not-traversed");
el.addClass("traversed");
});
};
cy.on("tap", "node", (tapped) => {
on_node_tap(tapped.target);
});
cy.zoomingEnabled(false);
/* Add context menu */
if (cy.cxtmenu === undefined) {
console.error(
"ctxmenu isn't loaded, context menu won't be available on graphs",
);
return cy;
}
cy.cxtmenu({
selector: "node",
commands: [
{
content: '<i class="fa fa-external-link fa-2x"></i>',
select: function (el) {
window.open(el.data().profile_url, "_blank").focus();
},
},
{
content: '<span class="fa fa-mouse-pointer fa-2x"></span>',
select: function (el) {
on_node_tap(el);
},
},
{
content: '<i class="fa fa-eraser fa-2x"></i>',
select: function (el) {
reset_graph();
},
},
],
});
return cy;
}
document.addEventListener("alpine:init", () => {
/*
This needs some constants to be set before the document has been loaded
api_url: base url for fetching the tree as a string
active_user: id of the user to fetch the tree from
depth_min: minimum tree depth for godfathers and godchildren as an int
depth_max: maximum tree depth for godfathers and godchildren as an int
*/
const default_depth = 2;
if (
typeof api_url === "undefined" ||
typeof active_user === "undefined" ||
typeof depth_min === "undefined" ||
typeof depth_max === "undefined"
) {
console.error("Some constants are not set before using the family_graph script, please look at the documentation");
return;
}
function get_initial_depth(prop) {
let value = parseInt(initialUrlParams.get(prop));
if (isNaN(value) || value < depth_min || value > depth_max) {
return default_depth;
}
return value;
}
Alpine.data("graph", () => ({
loading: false,
godfathers_depth: get_initial_depth("godfathers_depth"),
godchildren_depth: get_initial_depth("godchildren_depth"),
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === 'true',
graph: undefined,
graph_data: {},
async init() {
let delayed_fetch = Alpine.debounce(async () => {
this.fetch_graph_data();
}, 100);
["godfathers_depth", "godchildren_depth"].forEach((param) => {
this.$watch(param, async (value) => {
if (value < depth_min || value > depth_max) {
return;
}
update_query_string(param, value, History.REPLACE);
delayed_fetch();
});
});
this.$watch("reverse", async (value) => {
update_query_string("reverse", value, History.REPLACE);
this.reverse_graph();
});
this.$watch("graph_data", async () => {
await this.generate_graph();
if (this.reverse) {
await this.reverse_graph();
}
});
this.fetch_graph_data();
},
async screenshot() {
const link = document.createElement("a");
link.href = this.graph.jpg();
link.download = gettext("family_tree.%(extension)s", "jpg");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
},
async reset() {
this.reverse = false;
this.godfathers_depth = default_depth;
this.godchildren_depth = default_depth;
},
async reverse_graph() {
this.graph.elements((el) => {
el.position(new Object({ x: -el.position().x, y: -el.position().y }));
});
this.graph.center(this.graph.elements());
},
async fetch_graph_data() {
this.graph_data = await get_graph_data(
api_url,
this.godfathers_depth,
this.godchildren_depth,
);
},
async generate_graph() {
this.loading = true;
this.graph = create_graph(
$(this.$refs.graph),
this.graph_data,
active_user,
);
this.loading = false;
},
}));
});

View File

@ -1,3 +1,85 @@
.graph {
width: 100%;
height: 70vh;
display: block;
}
.graph-toolbar {
margin-top: 10px;
margin-bottom: 10px;
display: flex;
flex-direction: row;
justify-content: space-around;
gap: 30px;
.toolbar-column{
display: flex;
flex-direction: column;
gap: 20px;
min-width: 30%;
}
.toolbar-input {
display: flex;
flex-direction: row;
gap: 20px;
align-items: center;
width: 100%;
label {
max-width: 70%;
text-align: left;
margin-bottom: 0;
}
.depth-choice {
white-space: nowrap;
input[type="number"] {
-webkit-appearance: textfield;
-moz-appearance: textfield;
appearance: textfield;
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
}
}
button {
background: none;
& > .fa {
border-radius: 50%;
font-size: 12px;
padding: 5px;
}
&:enabled > .fa {
background-color: #354a5f;
color: white;
}
&:enabled:hover > .fa {
color: white;
background-color: #35405f; // just a bit darker
}
&:disabled > .fa {
background-color: gray;
color: white;
}
}
}
input {
align-self: center;
max-width: 40px;
}
}
@media screen and (max-width: 500px) {
flex-direction: column;
gap: 20px;
.toolbar-column {
min-width: 100%;
}
}
}
.container {
display: flex;
flex-direction: column;
@ -9,6 +91,14 @@
margin: 0;
}
}
#family-tree-link {
display: inline-block;
margin-top: 10px;
text-align: center;
@media (min-width: 450px) {
margin-right: auto;
}
}
.users {
display: flex;
@ -90,7 +180,7 @@
}
}
&:last-of-type {
&.delete {
margin-top: 10px;
display: block;
text-align: center;
@ -98,7 +188,7 @@
@media (max-width: 375px) {
position: absolute;
bottom: 0%;
bottom: 0;
right: 0;
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long