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; | ||||
| } | ||||
|  | ||||
| .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; | ||||
|   } | ||||
| } | ||||
| @@ -7,7 +7,7 @@ | ||||
| {%- endblock -%} | ||||
|  | ||||
| {% 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 %} | ||||
|  | ||||
| {% block title %} | ||||
| @@ -15,7 +15,14 @@ | ||||
| {% endblock %} | ||||
|  | ||||
| {% 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="toolbar-column"> | ||||
|         <div class="toolbar-input"> | ||||
| @@ -86,17 +93,36 @@ | ||||
|         </button> | ||||
|       </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> | ||||
|  | ||||
|   <script> | ||||
|     window.addEventListener("DOMContentLoaded", () => { | ||||
|       loadFamilyGraph({ | ||||
|         activeUser: {{ object.id }}, | ||||
|         depthMin: {{ depth_min }}, | ||||
|         depthMax: {{ depth_max }}, | ||||
|       }); | ||||
|     }); | ||||
|   </script> | ||||
| {% endblock %} | ||||
|  | ||||
|   | ||||
							
								
								
									
										29
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										29
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -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", | ||||
|   | ||||
| @@ -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" | ||||
|   | ||||
							
								
								
									
										2
									
								
								uv.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								uv.lock
									
									
									
										generated
									
									
									
								
							| @@ -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" }, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user