mirror of
				https://github.com/ae-utbm/sith.git
				synced 2025-10-30 16:43:55 +00:00 
			
		
		
		
	Merge pull request #1134 from ae-utbm/family
Add zoom controls to family graph
This commit is contained in:
		| @@ -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: '<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; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * @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; |  | ||||||
|       }, |  | ||||||
|     })); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
							
								
								
									
										287
									
								
								core/static/bundled/user/family-graph-index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										287
									
								
								core/static/bundled/user/family-graph-index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<GraphData> { | ||||||
|  |   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: '<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; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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; | ||||||
|  |     }, | ||||||
|  |   })); | ||||||
|  | }); | ||||||
| @@ -4,6 +4,12 @@ | |||||||
|   display: block; |   display: block; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .zoom-control { | ||||||
|  |   margin-right: 10px; | ||||||
|  |   display: flex; | ||||||
|  |   justify-content: right; | ||||||
|  | } | ||||||
|  |  | ||||||
| .graph-toolbar { | .graph-toolbar { | ||||||
|   margin-top: 10px; |   margin-top: 10px; | ||||||
|   margin-bottom: 10px; |   margin-bottom: 10px; | ||||||
| @@ -12,7 +18,7 @@ | |||||||
|   justify-content: space-around; |   justify-content: space-around; | ||||||
|   gap: 30px; |   gap: 30px; | ||||||
|  |  | ||||||
|   .toolbar-column{ |   .toolbar-column { | ||||||
|     display: flex; |     display: flex; | ||||||
|     flex-direction: column; |     flex-direction: column; | ||||||
|     gap: 20px; |     gap: 20px; | ||||||
| @@ -34,31 +40,38 @@ | |||||||
|  |  | ||||||
|     .depth-choice { |     .depth-choice { | ||||||
|       white-space: nowrap; |       white-space: nowrap; | ||||||
|  |  | ||||||
|       input[type="number"] { |       input[type="number"] { | ||||||
|         -webkit-appearance: textfield; |         -webkit-appearance: textfield; | ||||||
|         -moz-appearance: textfield; |         -moz-appearance: textfield; | ||||||
|         appearance: textfield; |         appearance: textfield; | ||||||
|  |  | ||||||
|         &::-webkit-inner-spin-button, |         &::-webkit-inner-spin-button, | ||||||
|         &::-webkit-outer-spin-button { |         &::-webkit-outer-spin-button { | ||||||
|           -webkit-appearance: none; |           -webkit-appearance: none; | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       button { |       button { | ||||||
|         background: none; |         background: none; | ||||||
|         & > .fa { |  | ||||||
|  |         &>.fa { | ||||||
|           border-radius: 50%; |           border-radius: 50%; | ||||||
|           font-size: 12px; |           font-size: 12px; | ||||||
|           padding: 5px; |           padding: 5px; | ||||||
|         } |         } | ||||||
|         &:enabled > .fa { |  | ||||||
|  |         &:enabled>.fa { | ||||||
|           background-color: #354a5f; |           background-color: #354a5f; | ||||||
|           color: white; |           color: white; | ||||||
|         } |         } | ||||||
|         &:enabled:hover > .fa { |  | ||||||
|  |         &:enabled:hover>.fa { | ||||||
|           color: white; |           color: white; | ||||||
|           background-color: #35405f; // just a bit darker |           background-color: #35405f; // just a bit darker | ||||||
|         } |         } | ||||||
|         &:disabled > .fa { |  | ||||||
|  |         &:disabled>.fa { | ||||||
|           background-color: gray; |           background-color: gray; | ||||||
|           color: white; |           color: white; | ||||||
|         } |         } | ||||||
| @@ -74,6 +87,7 @@ | |||||||
|   @media screen and (max-width: 500px) { |   @media screen and (max-width: 500px) { | ||||||
|     flex-direction: column; |     flex-direction: column; | ||||||
|     gap: 20px; |     gap: 20px; | ||||||
|  |  | ||||||
|     .toolbar-column { |     .toolbar-column { | ||||||
|       min-width: 100%; |       min-width: 100%; | ||||||
|     } |     } | ||||||
| @@ -87,14 +101,16 @@ | |||||||
|   padding: 10px; |   padding: 10px; | ||||||
|   box-sizing: border-box; |   box-sizing: border-box; | ||||||
|  |  | ||||||
|   > form { |   >form { | ||||||
|     margin: 0; |     margin: 0; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| #family-tree-link { | #family-tree-link { | ||||||
|   display: inline-block; |   display: inline-block; | ||||||
|   margin-top: 10px; |   margin-top: 10px; | ||||||
|   text-align: center; |   text-align: center; | ||||||
|  |  | ||||||
|   @media (min-width: 450px) { |   @media (min-width: 450px) { | ||||||
|     margin-right: auto; |     margin-right: auto; | ||||||
|   } |   } | ||||||
| @@ -122,10 +138,10 @@ | |||||||
|     width: 100%; |     width: 100%; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   > div.mini_profile_link { |   >div.mini_profile_link { | ||||||
|     position: relative; |     position: relative; | ||||||
|  |  | ||||||
|     > a { |     >a { | ||||||
|       &.mini_profile_link { |       &.mini_profile_link { | ||||||
|         display: flex; |         display: flex; | ||||||
|         flex-direction: column; |         flex-direction: column; | ||||||
| @@ -140,7 +156,7 @@ | |||||||
|           max-height: 65px; |           max-height: 65px; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         > span { |         >span { | ||||||
|           height: 150px; |           height: 150px; | ||||||
|           width: 100%; |           width: 100%; | ||||||
|  |  | ||||||
| @@ -149,7 +165,7 @@ | |||||||
|             width: 80px; |             width: 80px; | ||||||
|           } |           } | ||||||
|  |  | ||||||
|           > img { |           >img { | ||||||
|             width: 100%; |             width: 100%; | ||||||
|             max-width: 100%; |             max-width: 100%; | ||||||
|             max-height: 100%; |             max-height: 100%; | ||||||
| @@ -163,7 +179,7 @@ | |||||||
|           } |           } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         > em { |         >em { | ||||||
|           box-sizing: border-box; |           box-sizing: border-box; | ||||||
|           padding: 0 5px; |           padding: 0 5px; | ||||||
|           text-align: center; |           text-align: center; | ||||||
| @@ -195,7 +211,7 @@ | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   > a.mini_profile_link { |   >a.mini_profile_link { | ||||||
|     display: none; |     display: none; | ||||||
|   } |   } | ||||||
| } | } | ||||||
| @@ -7,7 +7,7 @@ | |||||||
| {%- endblock -%} | {%- endblock -%} | ||||||
|  |  | ||||||
| {% block additional_js %} | {% block additional_js %} | ||||||
|   <script type="module" src="{{ static("bundled/user/family-graph-index.js") }}"></script> |   <script type="module" src="{{ static("bundled/user/family-graph-index.ts") }}"></script> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block title %} | {% block title %} | ||||||
| @@ -15,7 +15,14 @@ | |||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
|   <div x-data="graph" :aria-busy="loading"> |   <div | ||||||
|  |     x-data="graph({ | ||||||
|  |             activeUser: {{ object.id }}, | ||||||
|  |             depthMin: {{ depth_min }}, | ||||||
|  |             depthMax: {{ depth_max }}, | ||||||
|  |             })" | ||||||
|  |     :aria-busy="loading" | ||||||
|  |   > | ||||||
|     <div class="graph-toolbar"> |     <div class="graph-toolbar"> | ||||||
|       <div class="toolbar-column"> |       <div class="toolbar-column"> | ||||||
|         <div class="toolbar-input"> |         <div class="toolbar-input"> | ||||||
| @@ -86,17 +93,36 @@ | |||||||
|         </button> |         </button> | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|  |  | ||||||
|  |     <div class="zoom-control" x-ref="zoomControl"> | ||||||
|  |       <button | ||||||
|  |         @click="graph.zoom(graph.zoom() + 1)" | ||||||
|  |         :disabled="!isZoomEnabled" | ||||||
|  |       > | ||||||
|  |         <i class="fa-solid fa-magnifying-glass-plus"></i> | ||||||
|  |       </button> | ||||||
|  |       <button | ||||||
|  |         @click="graph.zoom(graph.zoom() - 1)" | ||||||
|  |         :disabled="!isZoomEnabled" | ||||||
|  |       > | ||||||
|  |         <i class="fa-solid fa-magnifying-glass-minus"></i> | ||||||
|  |       </button> | ||||||
|  |       <button | ||||||
|  |         x-show="isZoomEnabled" | ||||||
|  |         @click="isZoomEnabled = false" | ||||||
|  |       > | ||||||
|  |         <i class="fa-solid fa-unlock"></i> | ||||||
|  |       </button> | ||||||
|  |       <button | ||||||
|  |         x-show="!isZoomEnabled" | ||||||
|  |         @click="isZoomEnabled = true" | ||||||
|  |       > | ||||||
|  |         <i class="fa-solid fa-lock"></i> | ||||||
|  |       </button> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|     <div x-ref="graph" class="graph"></div> |     <div x-ref="graph" class="graph"></div> | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
|   <script> |  | ||||||
|     window.addEventListener("DOMContentLoaded", () => { |  | ||||||
|       loadFamilyGraph({ |  | ||||||
|         activeUser: {{ object.id }}, |  | ||||||
|         depthMin: {{ depth_min }}, |  | ||||||
|         depthMax: {{ depth_max }}, |  | ||||||
|       }); |  | ||||||
|     }); |  | ||||||
|   </script> |  | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										29
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										29
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -45,6 +45,8 @@ | |||||||
|         "@hey-api/openapi-ts": "^0.73.0", |         "@hey-api/openapi-ts": "^0.73.0", | ||||||
|         "@rollup/plugin-inject": "^5.0.5", |         "@rollup/plugin-inject": "^5.0.5", | ||||||
|         "@types/alpinejs": "^3.13.10", |         "@types/alpinejs": "^3.13.10", | ||||||
|  |         "@types/cytoscape-cxtmenu": "^3.4.4", | ||||||
|  |         "@types/cytoscape-klay": "^3.1.4", | ||||||
|         "@types/jquery": "^3.5.31", |         "@types/jquery": "^3.5.31", | ||||||
|         "vite": "^6.2.5", |         "vite": "^6.2.5", | ||||||
|         "vite-bundle-visualizer": "^1.2.1", |         "vite-bundle-visualizer": "^1.2.1", | ||||||
| @@ -2819,6 +2821,33 @@ | |||||||
|         "@types/tern": "*" |         "@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": { |     "node_modules/@types/estree": { | ||||||
|       "version": "1.0.8", |       "version": "1.0.8", | ||||||
|       "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", |       "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", | ||||||
|   | |||||||
| @@ -31,6 +31,8 @@ | |||||||
|     "@rollup/plugin-inject": "^5.0.5", |     "@rollup/plugin-inject": "^5.0.5", | ||||||
|     "@types/alpinejs": "^3.13.10", |     "@types/alpinejs": "^3.13.10", | ||||||
|     "@types/jquery": "^3.5.31", |     "@types/jquery": "^3.5.31", | ||||||
|  |     "@types/cytoscape-cxtmenu": "^3.4.4", | ||||||
|  |     "@types/cytoscape-klay": "^3.1.4", | ||||||
|     "vite": "^6.2.5", |     "vite": "^6.2.5", | ||||||
|     "vite-bundle-visualizer": "^1.2.1", |     "vite-bundle-visualizer": "^1.2.1", | ||||||
|     "vite-plugin-static-copy": "^3.0.2" |     "vite-plugin-static-copy": "^3.0.2" | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								uv.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								uv.lock
									
									
									
										generated
									
									
									
								
							| @@ -1852,7 +1852,7 @@ dev = [ | |||||||
|     { name = "ipython", specifier = ">=9.0.2,<10.0.0" }, |     { name = "ipython", specifier = ">=9.0.2,<10.0.0" }, | ||||||
|     { name = "pre-commit", specifier = ">=4.1.0,<5.0.0" }, |     { name = "pre-commit", specifier = ">=4.1.0,<5.0.0" }, | ||||||
|     { name = "rjsmin", specifier = ">=1.2.4,<2.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 = [ | docs = [ | ||||||
|     { name = "mkdocs", specifier = ">=1.6.1,<2.0.0" }, |     { name = "mkdocs", specifier = ">=1.6.1,<2.0.0" }, | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user