diff --git a/core/static/bundled/user/family-graph-index.js b/core/static/bundled/user/family-graph-index.js deleted file mode 100644 index 706697b1..00000000 --- a/core/static/bundled/user/family-graph-index.js +++ /dev/null @@ -1,274 +0,0 @@ -import { History, initialUrlParams, updateQueryString } from "#core:utils/history"; -import cytoscape from "cytoscape"; -import cxtmenu from "cytoscape-cxtmenu"; -import klay from "cytoscape-klay"; -import { familyGetFamilyGraph } from "#openapi"; - -cytoscape.use(klay); -cytoscape.use(cxtmenu); - -async function getGraphData(userId, godfathersDepth, godchildrenDepth) { - const data = ( - await familyGetFamilyGraph({ - path: { - // biome-ignore lint/style/useNamingConvention: api is snake_case - user_id: userId, - }, - query: { - // biome-ignore lint/style/useNamingConvention: api is snake_case - godfathers_depth: godfathersDepth, - // biome-ignore lint/style/useNamingConvention: api is snake_case - godchildren_depth: godchildrenDepth, - }, - }) - ).data; - 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: '', - select: (el) => { - window.open(el.data().profile_url, "_blank").focus(); - }, - }, - - { - content: '', - select: (el) => { - onNodeTap(el); - }, - }, - - { - content: '', - select: (_) => { - resetGraph(); - }, - }, - ], - }); - - 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 - **/ - -/** - * Create a family graph of an user - * @param {FamilyGraphConfig} config - **/ -window.loadFamilyGraph = (config) => { - document.addEventListener("alpine:init", () => { - const defaultDepth = 2; - - function getInitialDepth(prop) { - 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() { - 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(); - } - }); - 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( - config.activeUser, - this.godfathersDepth, - this.godchildrenDepth, - ); - }, - - generateGraph() { - this.loading = true; - this.graph = createGraph( - $(this.$refs.graph), - this.graphData, - config.activeUser, - ); - this.loading = false; - }, - })); - }); -}; diff --git a/core/static/bundled/user/family-graph-index.ts b/core/static/bundled/user/family-graph-index.ts new file mode 100644 index 00000000..d8179c07 --- /dev/null +++ b/core/static/bundled/user/family-graph-index.ts @@ -0,0 +1,287 @@ +import { History, initialUrlParams, updateQueryString } from "#core:utils/history"; +import cytoscape, { + type ElementDefinition, + type NodeSingular, + type Singular, +} from "cytoscape"; +import cxtmenu from "cytoscape-cxtmenu"; +import klay, { type KlayLayoutOptions } from "cytoscape-klay"; +import { type UserProfileSchema, familyGetFamilyGraph } from "#openapi"; + +cytoscape.use(klay); +cytoscape.use(cxtmenu); + +type GraphData = ( + | { data: UserProfileSchema } + | { data: { source: number; target: number } } +)[]; + +function isMobile() { + return window.innerWidth < 500; +} + +async function getGraphData( + userId: number, + godfathersDepth: number, + godchildrenDepth: number, +): Promise { + const data = ( + await familyGetFamilyGraph({ + path: { + // biome-ignore lint/style/useNamingConvention: api is snake_case + user_id: userId, + }, + query: { + // biome-ignore lint/style/useNamingConvention: api is snake_case + godfathers_depth: godfathersDepth, + // biome-ignore lint/style/useNamingConvention: api is snake_case + godchildren_depth: godchildrenDepth, + }, + }) + ).data; + return [ + ...data.users.map((user) => { + return { data: user }; + }), + ...data.relationships.map((rel) => { + return { + data: { source: rel.godfather, target: rel.godchild }, + }; + }), + ]; +} + +function createGraph(container: HTMLDivElement, data: GraphData, activeUserId: number) { + const cy = cytoscape({ + boxSelectionEnabled: false, + autounselectify: true, + + container, + elements: data as ElementDefinition[], + 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, + }, + } as KlayLayoutOptions, + }); + const activeUser = cy + .getElementById(activeUserId.toString()) + .style("shape", "rectangle"); + /* Reset graph */ + const resetGraph = () => { + cy.elements().removeClass("traversed not-traversed"); + }; + + const onNodeTap = (el: Singular) => { + resetGraph(); + /* Create path on graph if selected isn't the targeted user */ + if (el === activeUser) { + return; + } + cy.elements().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); + }); + + /* Add context menu */ + cy.cxtmenu({ + selector: "node", + + commands: [ + { + content: '', + select: (el) => { + window.open(el.data().profile_url, "_blank").focus(); + }, + }, + + { + content: '', + select: (el) => { + onNodeTap(el); + }, + }, + + { + content: '', + select: (_) => { + resetGraph(); + }, + }, + ], + }); + + return cy; +} + +interface FamilyGraphConfig { + /**Id of the user to fetch the tree from*/ + activeUser: number; + /**Minimum tree depth for godfathers and godchildren*/ + depthMin: number; + /**Maximum tree depth for godfathers and godchildren*/ + depthMax: number; +} + +document.addEventListener("alpine:init", () => { + const defaultDepth = 2; + + Alpine.data("graph", (config: FamilyGraphConfig) => ({ + loading: false, + godfathersDepth: 0, + godchildrenDepth: 0, + reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true", + graph: undefined as cytoscape.Core, + graphData: {}, + isZoomEnabled: !isMobile(), + + getInitialDepth(prop: string) { + const value = Number.parseInt(initialUrlParams.get(prop)); + if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) { + return defaultDepth; + } + return value; + }, + + async init() { + this.godfathersDepth = this.getInitialDepth("godfathersDepth"); + this.godchildrenDepth = this.getInitialDepth("godchildrenDepth"); + + const delayedFetch = Alpine.debounce(async () => { + await this.fetchGraphData(); + }, 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.$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(); + } + }); + this.$watch("isZoomEnabled", () => { + this.graph.userZoomingEnabled(this.isZoomEnabled); + }); + 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: 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.graph.userZoomingEnabled(this.isZoomEnabled); + this.loading = false; + }, + })); +}); diff --git a/core/static/user/user_godfathers.scss b/core/static/user/user_godfathers.scss index 9764ee3e..7c69def7 100644 --- a/core/static/user/user_godfathers.scss +++ b/core/static/user/user_godfathers.scss @@ -4,6 +4,12 @@ display: block; } +.zoom-control { + margin-right: 10px; + display: flex; + justify-content: right; +} + .graph-toolbar { margin-top: 10px; margin-bottom: 10px; @@ -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 88e7ce79..946ed196 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 %} -
+
@@ -86,17 +93,36 @@
+ +
+ + + + +
+
- {% 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" 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" },