Move family_graph.js to webpack

* Remove cytoscape dependencies
This commit is contained in:
Antoine Bartuccio 2024-10-09 00:38:22 +02:00 committed by Bartuccio Antoine
parent ceee393bd8
commit 09081b03b6
12 changed files with 308 additions and 344 deletions

View File

@ -1,274 +0,0 @@
async function getGraphData(url, godfathersDepth, godchildrenDepth) {
const data = await (
await fetch(
`${url}?godfathers_depth=${godfathersDepth}&godchildren_depth=${godchildrenDepth}`,
)
).json();
return [
...data.users.map((user) => {
return { data: user };
}),
...data.relationships.map((rel) => {
return {
data: { source: rel.godfather, target: rel.godchild },
};
}),
];
}
function createGraph(container, data, activeUserId) {
// biome-ignore lint/correctness/noUndeclaredVariables: imported by user_godphaters_tree.jinja
const cy = cytoscape({
boxSelectionEnabled: false,
autounselectify: true,
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,
},
},
});
const activeUser = cy.getElementById(activeUserId).style("shape", "rectangle");
/* Reset graph */
const resetGraph = () => {
cy.elements((element) => {
if (element.hasClass("traversed")) {
element.removeClass("traversed");
}
if (element.hasClass("not-traversed")) {
element.removeClass("not-traversed");
}
});
};
const onNodeTap = (el) => {
resetGraph();
/* Create path on graph if selected isn't the targeted user */
if (el === activeUser) {
return;
}
cy.elements((element) => {
element.addClass("not-traversed");
});
for (const traversed of cy.elements().aStar({
root: el,
goal: activeUser,
}).path) {
traversed.removeClass("not-traversed");
traversed.addClass("traversed");
}
};
cy.on("tap", "node", (tapped) => {
onNodeTap(tapped.target);
});
cy.zoomingEnabled(false);
/* Add context menu */
if (cy.cxtmenu === undefined) {
throw new Error("ctxmenu isn't loaded, context menu won't be available on graphs");
}
cy.cxtmenu({
selector: "node",
commands: [
{
content: '<i class="fa fa-external-link fa-2x"></i>',
select: (el) => {
window.open(el.data().profile_url, "_blank").focus();
},
},
{
content: '<span class="fa fa-mouse-pointer fa-2x"></span>',
select: (el) => {
onNodeTap(el);
},
},
{
content: '<i class="fa fa-eraser fa-2x"></i>',
select: (_) => {
resetGraph();
},
},
],
});
return cy;
}
document.addEventListener("alpine:init", () => {
/*
This needs some constants to be set before the document has been loaded
apiUrl: base url for fetching the tree as a string
activeUser: id of the user to fetch the tree from
depthMin: minimum tree depth for godfathers and godchildren as an int
depthMax: maximum tree depth for godfathers and godchildren as an int
*/
const defaultDepth = 2;
if (
// biome-ignore lint/correctness/noUndeclaredVariables: defined by user_godfathers_tree.jinja
typeof apiUrl === "undefined" ||
// biome-ignore lint/correctness/noUndeclaredVariables: defined by user_godfathers_tree.jinja
typeof activeUser === "undefined" ||
// biome-ignore lint/correctness/noUndeclaredVariables: defined by user_godfathers_tree.jinja
typeof depthMin === "undefined" ||
// biome-ignore lint/correctness/noUndeclaredVariables: defined by user_godfathers_tree.jinja
typeof depthMax === "undefined"
) {
throw new Error(
"Some constants are not set before using the family_graph script, please look at the documentation",
);
}
function getInitialDepth(prop) {
// biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
const value = Number.parseInt(initialUrlParams.get(prop));
// biome-ignore lint/correctness/noUndeclaredVariables: defined by user_godfathers_tree.jinja
if (Number.isNaN(value) || value < depthMin || value > depthMax) {
return defaultDepth;
}
return value;
}
Alpine.data("graph", () => ({
loading: false,
godfathersDepth: getInitialDepth("godfathersDepth"),
godchildrenDepth: getInitialDepth("godchildrenDepth"),
// biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
graph: undefined,
graphData: {},
async init() {
const delayedFetch = Alpine.debounce(async () => {
await this.fetchGraphData();
}, 100);
for (const param of ["godfathersDepth", "godchildrenDepth"]) {
this.$watch(param, async (value) => {
// biome-ignore lint/correctness/noUndeclaredVariables: defined by user_godfathers_tree.jinja
if (value < depthMin || value > depthMax) {
return;
}
// biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
updateQueryString(param, value, History.REPLACE);
await delayedFetch();
});
}
this.$watch("reverse", async (value) => {
// biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
updateQueryString("reverse", value, History.REPLACE);
await this.reverseGraph();
});
this.$watch("graphData", async () => {
await this.generateGraph();
if (this.reverse) {
await this.reverseGraph();
}
});
await this.fetchGraphData();
},
screenshot() {
const link = document.createElement("a");
link.href = this.graph.jpg();
link.download = interpolate(
gettext("family_tree.%(extension)s"),
{ extension: "jpg" },
true,
);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
},
reset() {
this.reverse = false;
this.godfathersDepth = defaultDepth;
this.godchildrenDepth = defaultDepth;
},
async reverseGraph() {
this.graph.elements((el) => {
el.position({ x: -el.position().x, y: -el.position().y });
});
this.graph.center(this.graph.elements());
},
async fetchGraphData() {
this.graphData = await getGraphData(
// biome-ignore lint/correctness/noUndeclaredVariables: defined by user_godfathers_tree.jinja
apiUrl,
this.godfathersDepth,
this.godchildrenDepth,
);
},
async generateGraph() {
this.loading = true;
// biome-ignore lint/correctness/noUndeclaredVariables: defined by user_godfathers_tree.jinja
this.graph = await createGraph($(this.$refs.graph), this.graphData, activeUser);
this.loading = false;
},
}));
});

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

View File

@ -0,0 +1,259 @@
import cytoscape from "cytoscape";
import cxtmenu from "cytoscape-cxtmenu";
import klay from "cytoscape-klay";
cytoscape.use(klay);
cytoscape.use(cxtmenu);
async function getGraphData(url, godfathersDepth, godchildrenDepth) {
const data = await (
await fetch(
`${url}?godfathers_depth=${godfathersDepth}&godchildren_depth=${godchildrenDepth}`,
)
).json();
return [
...data.users.map((user) => {
return { data: user };
}),
...data.relationships.map((rel) => {
return {
data: { source: rel.godfather, target: rel.godchild },
};
}),
];
}
function createGraph(container, data, activeUserId) {
const cy = cytoscape({
boxSelectionEnabled: false,
autounselectify: true,
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,
},
},
});
const activeUser = cy.getElementById(activeUserId).style("shape", "rectangle");
/* Reset graph */
const resetGraph = () => {
cy.elements((element) => {
if (element.hasClass("traversed")) {
element.removeClass("traversed");
}
if (element.hasClass("not-traversed")) {
element.removeClass("not-traversed");
}
});
};
const onNodeTap = (el) => {
resetGraph();
/* Create path on graph if selected isn't the targeted user */
if (el === activeUser) {
return;
}
cy.elements((element) => {
element.addClass("not-traversed");
});
for (const traversed of cy.elements().aStar({
root: el,
goal: activeUser,
}).path) {
traversed.removeClass("not-traversed");
traversed.addClass("traversed");
}
};
cy.on("tap", "node", (tapped) => {
onNodeTap(tapped.target);
});
cy.zoomingEnabled(false);
/* Add context menu */
cy.cxtmenu({
selector: "node",
commands: [
{
content: '<i class="fa fa-external-link fa-2x"></i>',
select: (el) => {
window.open(el.data().profile_url, "_blank").focus();
},
},
{
content: '<span class="fa fa-mouse-pointer fa-2x"></span>',
select: (el) => {
onNodeTap(el);
},
},
{
content: '<i class="fa fa-eraser fa-2x"></i>',
select: (_) => {
resetGraph();
},
},
],
});
return cy;
}
/**
* Create a family graph of an user
* @param {string} Base url for fetching the tree as a string
* @param {string} Id of the user to fetch the tree from
* @param {number} Minimum tree depth for godfathers and godchildren
* @param {number} Maximum tree depth for godfathers and godchildren
**/
window.loadFamilyGraph = (apiUrl, activeUser, depthMin, depthMax) => {
document.addEventListener("alpine:init", () => {
const defaultDepth = 2;
function getInitialDepth(prop) {
// biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
const value = Number.parseInt(initialUrlParams.get(prop));
if (Number.isNaN(value) || value < depthMin || value > depthMax) {
return defaultDepth;
}
return value;
}
Alpine.data("graph", () => ({
loading: false,
godfathersDepth: getInitialDepth("godfathersDepth"),
godchildrenDepth: getInitialDepth("godchildrenDepth"),
// biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
graph: undefined,
graphData: {},
async init() {
const delayedFetch = Alpine.debounce(async () => {
await this.fetchGraphData();
}, 100);
for (const param of ["godfathersDepth", "godchildrenDepth"]) {
this.$watch(param, async (value) => {
if (value < depthMin || value > depthMax) {
return;
}
// biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
updateQueryString(param, value, History.REPLACE);
await delayedFetch();
});
}
this.$watch("reverse", async (value) => {
// biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
updateQueryString("reverse", value, History.REPLACE);
await this.reverseGraph();
});
this.$watch("graphData", async () => {
await this.generateGraph();
if (this.reverse) {
await this.reverseGraph();
}
});
await this.fetchGraphData();
},
screenshot() {
const link = document.createElement("a");
link.href = this.graph.jpg();
link.download = interpolate(
gettext("family_tree.%(extension)s"),
{ extension: "jpg" },
true,
);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
},
reset() {
this.reverse = false;
this.godfathersDepth = defaultDepth;
this.godchildrenDepth = defaultDepth;
},
async reverseGraph() {
this.graph.elements((el) => {
el.position({ x: -el.position().x, y: -el.position().y });
});
this.graph.center(this.graph.elements());
},
async fetchGraphData() {
this.graphData = await getGraphData(
apiUrl,
this.godfathersDepth,
this.godchildrenDepth,
);
},
async generateGraph() {
this.loading = true;
this.graph = await createGraph($(this.$refs.graph), this.graphData, activeUser);
this.loading = false;
},
}));
});
};

View File

@ -28,7 +28,7 @@ import { showSaveFilePicker } from "native-file-system-adapter";
/** /**
* Load user picture page with a nice download bar * Load user picture page with a nice download bar
* @param {String} Link to the api to fetch pictures from the user * @param {string} Url of the api endpoint to fetch pictures from the user
**/ **/
window.loadPicturePage = (apiUrl) => { window.loadPicturePage = (apiUrl) => {
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {

View File

@ -7,13 +7,7 @@
{%- endblock -%} {%- endblock -%}
{% block additional_js %} {% block additional_js %}
<script src="{{ static("vendored/cytoscape/cytoscape.min.js") }}" defer></script> <script src="{{ static("webpack/user/family-graph-index.js") }}" defer></script>
<script src="{{ static("vendored/cytoscape/cytoscape-cxtmenu.min.js") }}" defer></script>
<script src="{{ static("vendored/cytoscape/klay.min.js") }}" defer></script>
<script src="{{ static("vendored/cytoscape/cytoscape-klay.min.js") }}" defer></script>
<script src="{{ static("user/js/family_graph.js") }}" defer></script>
{% endblock %} {% endblock %}
{% block title %} {% block title %}
@ -96,10 +90,14 @@
</div> </div>
<script> <script>
const apiUrl = "{{ api_url }}"; window.addEventListener("DOMContentLoaded", () => {
const activeUser = "{{ object.id }}" loadFamilyGraph(
const depthMin = {{ depth_min }}; "{{ api_url }}",
const depthMax = {{ depth_max }}; "{{ object.id }}",
{{ depth_min }},
{{ depth_max }},
);
})
</script> </script>
{% endblock %} {% endblock %}

View File

@ -5,7 +5,7 @@
{%- endblock -%} {%- endblock -%}
{% block additional_js %} {% block additional_js %}
<script src="{{ static('webpack/users/pictures-index.js') }}" defer></script> <script src="{{ static('webpack/user/pictures-index.js') }}" defer></script>
{% endblock %} {% endblock %}
{% block title %} {% block title %}

35
package-lock.json generated
View File

@ -12,6 +12,9 @@
"@fortawesome/fontawesome-free": "^6.6.0", "@fortawesome/fontawesome-free": "^6.6.0",
"@zip.js/zip.js": "^2.7.52", "@zip.js/zip.js": "^2.7.52",
"alpinejs": "^3.14.1", "alpinejs": "^3.14.1",
"cytoscape": "^3.30.2",
"cytoscape-cxtmenu": "^3.5.0",
"cytoscape-klay": "^3.1.4",
"easymde": "^2.18.0", "easymde": "^2.18.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"jquery": "^3.7.1", "jquery": "^3.7.1",
@ -3233,6 +3236,33 @@
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
"dev": true "dev": true
}, },
"node_modules/cytoscape": {
"version": "3.30.2",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.30.2.tgz",
"integrity": "sha512-oICxQsjW8uSaRmn4UK/jkczKOqTrVqt5/1WL0POiJUT2EKNc9STM4hYFHv917yu55aTBMFNRzymlJhVAiWPCxw==",
"engines": {
"node": ">=0.10"
}
},
"node_modules/cytoscape-cxtmenu": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/cytoscape-cxtmenu/-/cytoscape-cxtmenu-3.5.0.tgz",
"integrity": "sha512-CoqgKAxvQhmHO5fEgJdBqqR2VjwK1dNkxehc2i0MUMqY0araA13z3oP/9KkprHp9Td++KlVBz6JnncNAD76T0Q==",
"peerDependencies": {
"cytoscape": "^3.2.0"
}
},
"node_modules/cytoscape-klay": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/cytoscape-klay/-/cytoscape-klay-3.1.4.tgz",
"integrity": "sha512-VwPj0VR25GPfy6qXVQRi/MYlZM/zkdvRhHlgqbM//lSvstgM6fhp3ik/uM8Wr8nlhskfqz/M1fIDmR6UckbS2A==",
"dependencies": {
"klayjs": "^0.4.1"
},
"peerDependencies": {
"cytoscape": "^3.2.0"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.7", "version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
@ -3959,6 +3989,11 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/klayjs": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/klayjs/-/klayjs-0.4.1.tgz",
"integrity": "sha512-WUNxuO7O79TEkxCj6OIaK5TJBkaWaR/IKNTakgV9PwDn+mrr63MLHed34AcE2yTaDntgO6l0zGFIzhcoTeroTA=="
},
"node_modules/lilconfig": { "node_modules/lilconfig": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz",

View File

@ -31,6 +31,9 @@
"@fortawesome/fontawesome-free": "^6.6.0", "@fortawesome/fontawesome-free": "^6.6.0",
"@zip.js/zip.js": "^2.7.52", "@zip.js/zip.js": "^2.7.52",
"alpinejs": "^3.14.1", "alpinejs": "^3.14.1",
"cytoscape": "^3.30.2",
"cytoscape-cxtmenu": "^3.5.0",
"cytoscape-klay": "^3.1.4",
"easymde": "^2.18.0", "easymde": "^2.18.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"jquery": "^3.7.1", "jquery": "^3.7.1",

View File

@ -740,10 +740,6 @@ if SENTRY_DSN:
SITH_FRONT_DEP_VERSIONS = { SITH_FRONT_DEP_VERSIONS = {
"https://github.com/chartjs/Chart.js/": "2.6.0", "https://github.com/chartjs/Chart.js/": "2.6.0",
"https://github.com/getsentry/sentry-javascript/": "8.26.0", "https://github.com/getsentry/sentry-javascript/": "8.26.0",
"https://github.com/cytoscape/cytoscape.js": "3.30.2 ",
"https://github.com/cytoscape/cytoscape.js-cxtmenu": "3.5.0",
"https://github.com/cytoscape/cytoscape.js-klay": "3.1.4",
"https://github.com/kieler/klayjs": "0.4.1", # Deprecated, elk should be used but cytoscape-elk is broken
"https://github.com/mrdoob/three.js/": "r148", "https://github.com/mrdoob/three.js/": "r148",
"https://github.com/vasturiano/three-spritetext": "1.6.5", "https://github.com/vasturiano/three-spritetext": "1.6.5",
"https://github.com/vasturiano/3d-force-graph/": "1.70.19", "https://github.com/vasturiano/3d-force-graph/": "1.70.19",