From cc96c93d23c73fdd269b64e0685ef89068bdbf94 Mon Sep 17 00:00:00 2001 From: Sli Date: Wed, 18 Jun 2025 11:59:46 +0200 Subject: [PATCH 1/6] Convert family tree to typescript --- ...y-graph-index.js => family-graph-index.ts} | 213 +++++++++--------- .../templates/core/user_godfathers_tree.jinja | 20 +- package-lock.json | 29 +++ package.json | 2 + 4 files changed, 152 insertions(+), 112 deletions(-) rename core/static/bundled/user/{family-graph-index.js => family-graph-index.ts} (53%) diff --git a/core/static/bundled/user/family-graph-index.js b/core/static/bundled/user/family-graph-index.ts similarity index 53% rename from core/static/bundled/user/family-graph-index.js rename to core/static/bundled/user/family-graph-index.ts index 706697b1..e1f9adc4 100644 --- a/core/static/bundled/user/family-graph-index.js +++ b/core/static/bundled/user/family-graph-index.ts @@ -1,13 +1,26 @@ import { History, initialUrlParams, updateQueryString } from "#core:utils/history"; -import cytoscape from "cytoscape"; +import cytoscape, { + type ElementDefinition, + type NodeSingular, + type Singular, +} from "cytoscape"; import cxtmenu from "cytoscape-cxtmenu"; import klay from "cytoscape-klay"; -import { familyGetFamilyGraph } from "#openapi"; +import { type UserProfileSchema, familyGetFamilyGraph } from "#openapi"; cytoscape.use(klay); cytoscape.use(cxtmenu); -async function getGraphData(userId, godfathersDepth, godchildrenDepth) { +type GraphData = ( + | { data: UserProfileSchema } + | { data: { source: number; target: number } } +)[]; + +async function getGraphData( + userId: number, + godfathersDepth: number, + godchildrenDepth: number, +): Promise { const data = ( await familyGetFamilyGraph({ path: { @@ -34,13 +47,13 @@ async function getGraphData(userId, godfathersDepth, godchildrenDepth) { ]; } -function createGraph(container, data, activeUserId) { +function createGraph(container: HTMLDivElement, data: GraphData, activeUserId: number) { const cy = cytoscape({ boxSelectionEnabled: false, autounselectify: true, container, - elements: data, + elements: data as ElementDefinition[], minZoom: 0.5, style: [ @@ -101,28 +114,30 @@ function createGraph(container, data, activeUserId) { }, }, }); - const activeUser = cy.getElementById(activeUserId).style("shape", "rectangle"); + const activeUser = cy + .getElementById(activeUserId.toString()) + .style("shape", "rectangle"); /* Reset graph */ const resetGraph = () => { - cy.elements((element) => { + cy.elements(((element: Singular) => { if (element.hasClass("traversed")) { element.removeClass("traversed"); } if (element.hasClass("not-traversed")) { element.removeClass("not-traversed"); } - }); + }) as unknown as string); }; - const onNodeTap = (el) => { + const onNodeTap = (el: Singular) => { resetGraph(); /* Create path on graph if selected isn't the targeted user */ if (el === activeUser) { return; } - cy.elements((element) => { + cy.elements(((element: Singular) => { element.addClass("not-traversed"); - }); + }) as unknown as string); for (const traversed of cy.elements().aStar({ root: el, @@ -169,106 +184,102 @@ function createGraph(container, data, activeUserId) { return cy; } -/** - * @typedef FamilyGraphConfig - * @property {number} activeUser Id of the user to fetch the tree from - * @property {number} depthMin Minimum tree depth for godfathers and godchildren - * @property {number} depthMax Maximum tree depth for godfathers and godchildren - **/ +interface FamilyGraphConfig { + activeUser: number; // activeUser Id of the user to fetch the tree from + depthMin: number; // depthMin Minimum tree depth for godfathers and godchildren + depthMax: number; // depthMax Maximum tree depth for godfathers and godchildren +} -/** - * Create a family graph of an user - * @param {FamilyGraphConfig} config - **/ -window.loadFamilyGraph = (config) => { - document.addEventListener("alpine:init", () => { - const defaultDepth = 2; +document.addEventListener("alpine:init", () => { + const defaultDepth = 2; - function getInitialDepth(prop) { + Alpine.data("graph", (config: FamilyGraphConfig) => ({ + loading: false, + godfathersDepth: 0, + godchildrenDepth: 0, + reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true", + graph: undefined as cytoscape.Core, + graphData: {}, + + getInitialDepth(prop: string) { const value = Number.parseInt(initialUrlParams.get(prop)); if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) { return defaultDepth; } return value; - } + }, - Alpine.data("graph", () => ({ - loading: false, - godfathersDepth: getInitialDepth("godfathersDepth"), - godchildrenDepth: getInitialDepth("godchildrenDepth"), - reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true", - graph: undefined, - graphData: {}, + async init() { + this.godfathersDepth = this.getInitialDepth("godfathersDepth"); + this.godchildrenDepth = this.getInitialDepth("godchildrenDepth"); - async init() { - const delayedFetch = Alpine.debounce(async () => { - await this.fetchGraphData(); - }, 100); - for (const param of ["godfathersDepth", "godchildrenDepth"]) { - this.$watch(param, async (value) => { - if (value < config.depthMin || value > config.depthMax) { - return; - } - updateQueryString(param, value, History.Replace); - await delayedFetch(); - }); - } - this.$watch("reverse", async (value) => { - updateQueryString("reverse", value, History.Replace); - await this.reverseGraph(); - }); - this.$watch("graphData", async () => { - this.generateGraph(); - if (this.reverse) { - await this.reverseGraph(); - } - }); + const delayedFetch = Alpine.debounce(async () => { 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 }); + }, 100); + for (const param of ["godfathersDepth", "godchildrenDepth"]) { + this.$watch(param, async (value: number) => { + if (value < config.depthMin || value > config.depthMax) { + return; + } + updateQueryString(param, value.toString(), History.Replace); + await delayedFetch(); }); - this.graph.center(this.graph.elements()); - }, + } + this.$watch("reverse", async (value: number) => { + updateQueryString("reverse", value.toString(), History.Replace); + await this.reverseGraph(); + }); + this.$watch("graphData", async () => { + this.generateGraph(); + if (this.reverse) { + await this.reverseGraph(); + } + }); + await this.fetchGraphData(); + }, - async fetchGraphData() { - this.graphData = await getGraphData( - config.activeUser, - this.godfathersDepth, - this.godchildrenDepth, - ); - }, + 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); + }, - generateGraph() { - this.loading = true; - this.graph = createGraph( - $(this.$refs.graph), - this.graphData, - config.activeUser, - ); - this.loading = false; - }, - })); - }); -}; + reset() { + this.reverse = false; + this.godfathersDepth = defaultDepth; + this.godchildrenDepth = defaultDepth; + }, + + async reverseGraph() { + this.graph.elements((el: NodeSingular) => { + el.position({ x: -el.position().x, y: -el.position().y }); + }); + this.graph.center(this.graph.elements()); + }, + + async fetchGraphData() { + this.graphData = await getGraphData( + config.activeUser, + this.godfathersDepth, + this.godchildrenDepth, + ); + }, + + generateGraph() { + this.loading = true; + this.graph = createGraph( + this.$refs.graph as HTMLDivElement, + this.graphData, + config.activeUser, + ); + this.loading = false; + }, + })); +}); diff --git a/core/templates/core/user_godfathers_tree.jinja b/core/templates/core/user_godfathers_tree.jinja index 88e7ce79..218d9ca2 100644 --- a/core/templates/core/user_godfathers_tree.jinja +++ b/core/templates/core/user_godfathers_tree.jinja @@ -7,7 +7,7 @@ {%- endblock -%} {% block additional_js %} - + {% endblock %} {% block title %} @@ -15,7 +15,14 @@ {% endblock %} {% block content %} -
+
@@ -89,14 +96,5 @@
- {% endblock %} diff --git a/package-lock.json b/package-lock.json index a0228831..31bddd32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,8 @@ "@hey-api/openapi-ts": "^0.73.0", "@rollup/plugin-inject": "^5.0.5", "@types/alpinejs": "^3.13.10", + "@types/cytoscape-cxtmenu": "^3.4.4", + "@types/cytoscape-klay": "^3.1.4", "@types/jquery": "^3.5.31", "vite": "^6.2.5", "vite-bundle-visualizer": "^1.2.1", @@ -2819,6 +2821,33 @@ "@types/tern": "*" } }, + "node_modules/@types/cytoscape": { + "version": "3.21.9", + "resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.21.9.tgz", + "integrity": "sha512-JyrG4tllI6jvuISPjHK9j2Xv/LTbnLekLke5otGStjFluIyA9JjgnvgZrSBsp8cEDpiTjwgZUZwpPv8TSBcoLw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/cytoscape-cxtmenu": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/@types/cytoscape-cxtmenu/-/cytoscape-cxtmenu-3.4.4.tgz", + "integrity": "sha512-cuv+IdbKekswDRBIrHn97IYOzWS2/UjVr0kDIHCOYvqWy3iZkuGGM4qmHNPQ+63Dn7JgtmD0l3MKW1moyhoaKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cytoscape": "*" + } + }, + "node_modules/@types/cytoscape-klay": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/cytoscape-klay/-/cytoscape-klay-3.1.4.tgz", + "integrity": "sha512-H+tIadpcVjmDGWKFUfibwzIpH/kddfwAFsuhPparjiC+bWBm+MeNqIwwY+19ofkJZWcqWqZL6Jp8lkp+sP8Aig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cytoscape": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", diff --git a/package.json b/package.json index 9d7cf43a..b1b9f442 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "@rollup/plugin-inject": "^5.0.5", "@types/alpinejs": "^3.13.10", "@types/jquery": "^3.5.31", + "@types/cytoscape-cxtmenu": "^3.4.4", + "@types/cytoscape-klay": "^3.1.4", "vite": "^6.2.5", "vite-bundle-visualizer": "^1.2.1", "vite-plugin-static-copy": "^3.0.2" From 10d5b9d63f35fc398d581f2921f905ca66d28f25 Mon Sep 17 00:00:00 2001 From: Sli Date: Wed, 18 Jun 2025 12:22:30 +0200 Subject: [PATCH 2/6] Add zoom control of family graph --- .../static/bundled/user/family-graph-index.ts | 10 ++++++- .../templates/core/user_godfathers_tree.jinja | 26 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/core/static/bundled/user/family-graph-index.ts b/core/static/bundled/user/family-graph-index.ts index e1f9adc4..058d0cbe 100644 --- a/core/static/bundled/user/family-graph-index.ts +++ b/core/static/bundled/user/family-graph-index.ts @@ -16,6 +16,10 @@ type GraphData = ( | { data: { source: number; target: number } } )[]; +function isMobile() { + return window.innerWidth < 500; +} + async function getGraphData( userId: number, godfathersDepth: number, @@ -151,7 +155,6 @@ function createGraph(container: HTMLDivElement, data: GraphData, activeUserId: n cy.on("tap", "node", (tapped) => { onNodeTap(tapped.target); }); - cy.zoomingEnabled(false); /* Add context menu */ cy.cxtmenu({ @@ -200,6 +203,7 @@ document.addEventListener("alpine:init", () => { reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true", graph: undefined as cytoscape.Core, graphData: {}, + isZoomEnabled: !isMobile(), getInitialDepth(prop: string) { const value = Number.parseInt(initialUrlParams.get(prop)); @@ -235,6 +239,9 @@ document.addEventListener("alpine:init", () => { await this.reverseGraph(); } }); + this.$watch("isZoomEnabled", () => { + this.graph.userZoomingEnabled(this.isZoomEnabled); + }); await this.fetchGraphData(); }, @@ -279,6 +286,7 @@ document.addEventListener("alpine:init", () => { this.graphData, config.activeUser, ); + this.graph.userZoomingEnabled(this.isZoomEnabled); this.loading = false; }, })); diff --git a/core/templates/core/user_godfathers_tree.jinja b/core/templates/core/user_godfathers_tree.jinja index 218d9ca2..92d54772 100644 --- a/core/templates/core/user_godfathers_tree.jinja +++ b/core/templates/core/user_godfathers_tree.jinja @@ -77,6 +77,7 @@ >
+
@@ -91,6 +92,31 @@ {% trans %}Save{% endtrans %} + +
+ + + + +
From 7d454749e007fac96ccb3202040f09130ae4d4ec Mon Sep 17 00:00:00 2001 From: Sli Date: Wed, 18 Jun 2025 14:10:26 +0200 Subject: [PATCH 3/6] Add style to zoom controls on family graph --- .../static/bundled/user/family-graph-index.ts | 1 + core/static/user/user_godfathers.scss | 40 +++++++++----- .../templates/core/user_godfathers_tree.jinja | 52 +++++++++---------- 3 files changed, 55 insertions(+), 38 deletions(-) diff --git a/core/static/bundled/user/family-graph-index.ts b/core/static/bundled/user/family-graph-index.ts index 058d0cbe..b17c7420 100644 --- a/core/static/bundled/user/family-graph-index.ts +++ b/core/static/bundled/user/family-graph-index.ts @@ -287,6 +287,7 @@ document.addEventListener("alpine:init", () => { config.activeUser, ); this.graph.userZoomingEnabled(this.isZoomEnabled); + this.$refs.graph.prepend(this.$refs.zoomControl); this.loading = false; }, })); diff --git a/core/static/user/user_godfathers.scss b/core/static/user/user_godfathers.scss index 9764ee3e..d350125f 100644 --- a/core/static/user/user_godfathers.scss +++ b/core/static/user/user_godfathers.scss @@ -2,6 +2,12 @@ width: 100%; height: 70vh; display: block; + overflow: clip; +} + +.zoom-control { + float: right; + margin-right: 10px; } .graph-toolbar { @@ -12,7 +18,7 @@ justify-content: space-around; gap: 30px; - .toolbar-column{ + .toolbar-column { display: flex; flex-direction: column; gap: 20px; @@ -34,31 +40,38 @@ .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 { + + &>.fa { border-radius: 50%; font-size: 12px; padding: 5px; } - &:enabled > .fa { + + &:enabled>.fa { background-color: #354a5f; color: white; } - &:enabled:hover > .fa { + + &:enabled:hover>.fa { color: white; background-color: #35405f; // just a bit darker } - &:disabled > .fa { + + &:disabled>.fa { background-color: gray; color: white; } @@ -74,6 +87,7 @@ @media screen and (max-width: 500px) { flex-direction: column; gap: 20px; + .toolbar-column { min-width: 100%; } @@ -87,14 +101,16 @@ padding: 10px; box-sizing: border-box; - > form { + >form { margin: 0; } } + #family-tree-link { display: inline-block; margin-top: 10px; text-align: center; + @media (min-width: 450px) { margin-right: auto; } @@ -122,10 +138,10 @@ width: 100%; } - > div.mini_profile_link { + >div.mini_profile_link { position: relative; - > a { + >a { &.mini_profile_link { display: flex; flex-direction: column; @@ -140,7 +156,7 @@ max-height: 65px; } - > span { + >span { height: 150px; width: 100%; @@ -149,7 +165,7 @@ width: 80px; } - > img { + >img { width: 100%; max-width: 100%; max-height: 100%; @@ -163,7 +179,7 @@ } } - > em { + >em { box-sizing: border-box; padding: 0 5px; text-align: center; @@ -195,7 +211,7 @@ } } - > a.mini_profile_link { + >a.mini_profile_link { display: none; } } \ No newline at end of file diff --git a/core/templates/core/user_godfathers_tree.jinja b/core/templates/core/user_godfathers_tree.jinja index 92d54772..1e3e0812 100644 --- a/core/templates/core/user_godfathers_tree.jinja +++ b/core/templates/core/user_godfathers_tree.jinja @@ -77,7 +77,6 @@ >
-
@@ -92,33 +91,34 @@ {% trans %}Save{% endtrans %} - -
- - - - -
+ +
+ + + + +
+
From 94bdc5e6156fcfab95d0bc1c61b52bad477b409a Mon Sep 17 00:00:00 2001 From: Sli Date: Wed, 18 Jun 2025 14:13:06 +0200 Subject: [PATCH 4/6] Remove useless closures --- core/templates/core/user_godfathers_tree.jinja | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/templates/core/user_godfathers_tree.jinja b/core/templates/core/user_godfathers_tree.jinja index 1e3e0812..88c4709b 100644 --- a/core/templates/core/user_godfathers_tree.jinja +++ b/core/templates/core/user_godfathers_tree.jinja @@ -96,24 +96,24 @@
From ca593c7d813a4fb89fe6550d2ba2d684f06f4f4b Mon Sep 17 00:00:00 2001 From: Sli Date: Wed, 18 Jun 2025 16:09:31 +0200 Subject: [PATCH 5/6] Avoid click on graph when zooming --- core/static/bundled/user/family-graph-index.ts | 1 - core/static/user/user_godfathers.scss | 4 ++-- core/templates/core/user_godfathers_tree.jinja | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/core/static/bundled/user/family-graph-index.ts b/core/static/bundled/user/family-graph-index.ts index b17c7420..058d0cbe 100644 --- a/core/static/bundled/user/family-graph-index.ts +++ b/core/static/bundled/user/family-graph-index.ts @@ -287,7 +287,6 @@ document.addEventListener("alpine:init", () => { config.activeUser, ); this.graph.userZoomingEnabled(this.isZoomEnabled); - this.$refs.graph.prepend(this.$refs.zoomControl); this.loading = false; }, })); diff --git a/core/static/user/user_godfathers.scss b/core/static/user/user_godfathers.scss index d350125f..7c69def7 100644 --- a/core/static/user/user_godfathers.scss +++ b/core/static/user/user_godfathers.scss @@ -2,12 +2,12 @@ width: 100%; height: 70vh; display: block; - overflow: clip; } .zoom-control { - float: right; margin-right: 10px; + display: flex; + justify-content: right; } .graph-toolbar { diff --git a/core/templates/core/user_godfathers_tree.jinja b/core/templates/core/user_godfathers_tree.jinja index 88c4709b..e489fd6e 100644 --- a/core/templates/core/user_godfathers_tree.jinja +++ b/core/templates/core/user_godfathers_tree.jinja @@ -94,7 +94,7 @@
-
+
diff --git a/uv.lock b/uv.lock index b1e3d63e..4b50121e 100644 --- a/uv.lock +++ b/uv.lock @@ -1852,7 +1852,7 @@ dev = [ { name = "ipython", specifier = ">=9.0.2,<10.0.0" }, { name = "pre-commit", specifier = ">=4.1.0,<5.0.0" }, { name = "rjsmin", specifier = ">=1.2.4,<2.0.0" }, - { name = "ruff", specifier = ">=0.11.11,<1.0.0" }, + { name = "ruff", specifier = ">=0.11.13,<1.0.0" }, ] docs = [ { name = "mkdocs", specifier = ">=1.6.1,<2.0.0" },