mirror of
https://github.com/ae-utbm/sith.git
synced 2026-10-13 14:16:04 +00:00
Graph de famille en frontend (#820)
* Remove graphviz and use cytoscape.js instead * Frontend generated graphs * Make installation easier and faster * Better user experience * Family api and improved interface * Fix url history when using 0, improve button selection and reset reverse with reset button * Use klay layout * Add js translations and apply review comments
This commit is contained in:
1 parent
bf96d8a10c
commit
f624b7c66d
29 files changed
+1332
-684
No files matched your search
+52
-1
@@ -1,10 +1,19 @@
|
||||
from typing import Annotated
|
||||
|
||||
import annotated_types
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse
|
||||
from ninja_extra import ControllerBase, api_controller, route
|
||||
from ninja_extra.exceptions import PermissionDenied
|
||||
|
||||
from club.models import Mailing
|
||||
from core.schemas import MarkdownSchema
|
||||
from core.api_permissions import CanView
|
||||
from core.models import User
|
||||
from core.schemas import (
|
||||
FamilyGodfatherSchema,
|
||||
MarkdownSchema,
|
||||
UserFamilySchema,
|
||||
)
|
||||
from core.templatetags.renderer import markdown
|
||||
|
||||
|
||||
@@ -27,3 +36,45 @@ class MailingListController(ControllerBase):
|
||||
).prefetch_related("subscriptions")
|
||||
data = "\n".join(m.fetch_format() for m in mailings)
|
||||
return data
|
||||
|
||||
|
||||
DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)]
|
||||
DEFAULT_DEPTH = 4
|
||||
|
||||
|
||||
@api_controller("/family")
|
||||
class FamilyController(ControllerBase):
|
||||
@route.get(
|
||||
"/{user_id}",
|
||||
permissions=[CanView],
|
||||
response=UserFamilySchema,
|
||||
url_name="family_graph",
|
||||
)
|
||||
def get_family_graph(
|
||||
self,
|
||||
user_id: int,
|
||||
godfathers_depth: DepthValue = DEFAULT_DEPTH,
|
||||
godchildren_depth: DepthValue = DEFAULT_DEPTH,
|
||||
):
|
||||
user: User = self.get_object_or_exception(User, pk=user_id)
|
||||
|
||||
relations = user.get_family(godfathers_depth, godchildren_depth)
|
||||
if not relations:
|
||||
# If the user has no relations, return only the user
|
||||
# He is alone in its family, but the family exists nonetheless
|
||||
return {"users": [user], "relationships": []}
|
||||
|
||||
user_ids = {r.from_user_id for r in relations} | {
|
||||
r.to_user_id for r in relations
|
||||
}
|
||||
return {
|
||||
"users": User.objects.filter(id__in=user_ids).distinct(),
|
||||
"relationships": (
|
||||
[
|
||||
FamilyGodfatherSchema(
|
||||
godchild=r.from_user_id, godfather=r.to_user_id
|
||||
)
|
||||
for r in relations
|
||||
]
|
||||
),
|
||||
}
|
||||
@@ -78,7 +78,7 @@ class CanView(BasePermission):
|
||||
"""Check that this user has the permission to view the object of this route.
|
||||
|
||||
Wrap the `user.can_view(obj)` method.
|
||||
To see an example, look at the exemple in the module docstring.
|
||||
To see an example, look at the example in the module docstring.
|
||||
"""
|
||||
|
||||
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
|
||||
@@ -94,7 +94,7 @@ class CanEdit(BasePermission):
|
||||
"""Check that this user has the permission to edit the object of this route.
|
||||
|
||||
Wrap the `user.can_edit(obj)` method.
|
||||
To see an example, look at the exemple in the module docstring.
|
||||
To see an example, look at the example in the module docstring.
|
||||
"""
|
||||
|
||||
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
|
||||
@@ -110,7 +110,7 @@ class IsOwner(BasePermission):
|
||||
"""Check that this user owns the object of this route.
|
||||
|
||||
Wrap the `user.is_owner(obj)` method.
|
||||
To see an example, look at the exemple in the module docstring.
|
||||
To see an example, look at the example in the module docstring.
|
||||
"""
|
||||
|
||||
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
|
||||
|
||||
@@ -57,6 +57,7 @@ from django.utils.functional import cached_property
|
||||
from django.utils.html import escape
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
from pydantic.v1 import NonNegativeInt
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from club.models import Club
|
||||
@@ -606,6 +607,41 @@ class User(AbstractBaseUser):
|
||||
today.year - born.year - ((today.month, today.day) < (born.month, born.day))
|
||||
)
|
||||
|
||||
def get_family(
|
||||
self,
|
||||
godfathers_depth: NonNegativeInt = 4,
|
||||
godchildren_depth: NonNegativeInt = 4,
|
||||
) -> set[User.godfathers.through]:
|
||||
"""Get the family of the user, with the given depth.
|
||||
|
||||
Args:
|
||||
godfathers_depth: The number of generations of godfathers to fetch
|
||||
godchildren_depth: The number of generations of godchildren to fetch
|
||||
|
||||
Returns:
|
||||
A list of family relationships in this user's family
|
||||
"""
|
||||
res = []
|
||||
for depth, key, reverse_key in [
|
||||
(godfathers_depth, "from_user_id", "to_user_id"),
|
||||
(godchildren_depth, "to_user_id", "from_user_id"),
|
||||
]:
|
||||
if depth == 0:
|
||||
continue
|
||||
links = list(User.godfathers.through.objects.filter(**{key: self.id}))
|
||||
res.extend(links)
|
||||
for _ in range(1, depth):
|
||||
ids = [getattr(c, reverse_key) for c in links]
|
||||
links = list(
|
||||
User.godfathers.through.objects.filter(
|
||||
**{f"{key}__in": ids}
|
||||
).exclude(id__in=[r.id for r in res])
|
||||
)
|
||||
if not links:
|
||||
break
|
||||
res.extend(links)
|
||||
return set(res)
|
||||
|
||||
def email_user(self, subject, message, from_email=None, **kwargs):
|
||||
"""Sends an email to this User."""
|
||||
if from_email is None:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||
from ninja import ModelSchema, Schema
|
||||
|
||||
from core.models import User
|
||||
@@ -13,3 +14,41 @@ class SimpleUserSchema(ModelSchema):
|
||||
|
||||
class MarkdownSchema(Schema):
|
||||
text: str
|
||||
|
||||
|
||||
class UserProfileSchema(ModelSchema):
|
||||
"""The necessary information to show a user profile"""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "nick_name", "first_name", "last_name"]
|
||||
|
||||
display_name: str
|
||||
profile_url: str
|
||||
profile_pict: str
|
||||
|
||||
@staticmethod
|
||||
def resolve_display_name(obj: User) -> str:
|
||||
return obj.get_display_name()
|
||||
|
||||
@staticmethod
|
||||
def resolve_profile_url(obj: User) -> str:
|
||||
return obj.get_absolute_url()
|
||||
|
||||
@staticmethod
|
||||
def resolve_profile_pict(obj: User) -> str:
|
||||
if obj.profile_pict_id is None:
|
||||
return staticfiles_storage.url("core/img/unknown.jpg")
|
||||
return obj.profile_pict.get_download_url()
|
||||
|
||||
|
||||
class FamilyGodfatherSchema(Schema):
|
||||
godfather: int
|
||||
godchild: int
|
||||
|
||||
|
||||
class UserFamilySchema(Schema):
|
||||
"""Represent a graph of a user's family"""
|
||||
|
||||
users: list[UserProfileSchema]
|
||||
relationships: list[FamilyGodfatherSchema]
|
||||
@@ -89,7 +89,7 @@ function update_query_string(key, value, action = History.REPLACE, url = null) {
|
||||
if (!url){
|
||||
url = new URL(window.location.href);
|
||||
}
|
||||
if (!value) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
// If the value is null, undefined or empty => delete it
|
||||
url.searchParams.delete(key)
|
||||
} else if (Array.isArray(value)) {
|
||||
|
||||
@@ -239,6 +239,7 @@ a:not(.button) {
|
||||
padding: 9px 13px;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
|
||||
&.btn-blue {
|
||||
background-color: $deepblue;
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
async function get_graph_data(url, godfathers_depth, godchildren_depth) {
|
||||
let data = await (
|
||||
await fetch(
|
||||
`${url}?godfathers_depth=${godfathers_depth}&godchildren_depth=${godchildren_depth}`,
|
||||
)
|
||||
).json();
|
||||
return [
|
||||
...data.users.map((user) => {
|
||||
return { data: user };
|
||||
}),
|
||||
...data.relationships.map((rel) => {
|
||||
return {
|
||||
data: { source: rel.godfather, target: rel.godchild },
|
||||
};
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function create_graph(container, data, active_user_id) {
|
||||
let cy = cytoscape({
|
||||
boxSelectionEnabled: false,
|
||||
autounselectify: true,
|
||||
|
||||
container: 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
|
||||
}
|
||||
}
|
||||
});
|
||||
let active_user = cy
|
||||
.getElementById(active_user_id)
|
||||
.style("shape", "rectangle");
|
||||
/* Reset graph */
|
||||
let reset_graph = () => {
|
||||
cy.elements((element) => {
|
||||
if (element.hasClass("traversed")) {
|
||||
element.removeClass("traversed");
|
||||
}
|
||||
if (element.hasClass("not-traversed")) {
|
||||
element.removeClass("not-traversed");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let on_node_tap = (el) => {
|
||||
reset_graph();
|
||||
/* Create path on graph if selected isn't the targeted user */
|
||||
if (el === active_user) {
|
||||
return;
|
||||
}
|
||||
cy.elements((element) => {
|
||||
element.addClass("not-traversed");
|
||||
});
|
||||
|
||||
cy.elements()
|
||||
.aStar({
|
||||
root: el,
|
||||
goal: active_user,
|
||||
})
|
||||
.path.forEach((el) => {
|
||||
el.removeClass("not-traversed");
|
||||
el.addClass("traversed");
|
||||
});
|
||||
};
|
||||
|
||||
cy.on("tap", "node", (tapped) => {
|
||||
on_node_tap(tapped.target);
|
||||
});
|
||||
cy.zoomingEnabled(false);
|
||||
|
||||
/* Add context menu */
|
||||
if (cy.cxtmenu === undefined) {
|
||||
console.error(
|
||||
"ctxmenu isn't loaded, context menu won't be available on graphs",
|
||||
);
|
||||
return cy;
|
||||
}
|
||||
cy.cxtmenu({
|
||||
selector: "node",
|
||||
|
||||
commands: [
|
||||
{
|
||||
content: '<i class="fa fa-external-link fa-2x"></i>',
|
||||
select: function (el) {
|
||||
window.open(el.data().profile_url, "_blank").focus();
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
content: '<span class="fa fa-mouse-pointer fa-2x"></span>',
|
||||
select: function (el) {
|
||||
on_node_tap(el);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
content: '<i class="fa fa-eraser fa-2x"></i>',
|
||||
select: function (el) {
|
||||
reset_graph();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return cy;
|
||||
}
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
/*
|
||||
This needs some constants to be set before the document has been loaded
|
||||
|
||||
api_url: base url for fetching the tree as a string
|
||||
active_user: id of the user to fetch the tree from
|
||||
depth_min: minimum tree depth for godfathers and godchildren as an int
|
||||
depth_max: maximum tree depth for godfathers and godchildren as an int
|
||||
*/
|
||||
const default_depth = 2;
|
||||
|
||||
if (
|
||||
typeof api_url === "undefined" ||
|
||||
typeof active_user === "undefined" ||
|
||||
typeof depth_min === "undefined" ||
|
||||
typeof depth_max === "undefined"
|
||||
) {
|
||||
console.error("Some constants are not set before using the family_graph script, please look at the documentation");
|
||||
return;
|
||||
}
|
||||
|
||||
function get_initial_depth(prop) {
|
||||
let value = parseInt(initialUrlParams.get(prop));
|
||||
if (isNaN(value) || value < depth_min || value > depth_max) {
|
||||
return default_depth;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
Alpine.data("graph", () => ({
|
||||
loading: false,
|
||||
godfathers_depth: get_initial_depth("godfathers_depth"),
|
||||
godchildren_depth: get_initial_depth("godchildren_depth"),
|
||||
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === 'true',
|
||||
graph: undefined,
|
||||
graph_data: {},
|
||||
|
||||
async init() {
|
||||
let delayed_fetch = Alpine.debounce(async () => {
|
||||
this.fetch_graph_data();
|
||||
}, 100);
|
||||
["godfathers_depth", "godchildren_depth"].forEach((param) => {
|
||||
this.$watch(param, async (value) => {
|
||||
if (value < depth_min || value > depth_max) {
|
||||
return;
|
||||
}
|
||||
update_query_string(param, value, History.REPLACE);
|
||||
delayed_fetch();
|
||||
});
|
||||
});
|
||||
this.$watch("reverse", async (value) => {
|
||||
update_query_string("reverse", value, History.REPLACE);
|
||||
this.reverse_graph();
|
||||
});
|
||||
this.$watch("graph_data", async () => {
|
||||
await this.generate_graph();
|
||||
if (this.reverse) {
|
||||
await this.reverse_graph();
|
||||
}
|
||||
});
|
||||
this.fetch_graph_data();
|
||||
},
|
||||
|
||||
async screenshot() {
|
||||
const link = document.createElement("a");
|
||||
link.href = this.graph.jpg();
|
||||
link.download = gettext("family_tree.%(extension)s", "jpg");
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
},
|
||||
|
||||
async reset() {
|
||||
this.reverse = false;
|
||||
this.godfathers_depth = default_depth;
|
||||
this.godchildren_depth = default_depth;
|
||||
},
|
||||
|
||||
async reverse_graph() {
|
||||
this.graph.elements((el) => {
|
||||
el.position(new Object({ x: -el.position().x, y: -el.position().y }));
|
||||
});
|
||||
this.graph.center(this.graph.elements());
|
||||
},
|
||||
|
||||
async fetch_graph_data() {
|
||||
this.graph_data = await get_graph_data(
|
||||
api_url,
|
||||
this.godfathers_depth,
|
||||
this.godchildren_depth,
|
||||
);
|
||||
},
|
||||
|
||||
async generate_graph() {
|
||||
this.loading = true;
|
||||
this.graph = create_graph(
|
||||
$(this.$refs.graph),
|
||||
this.graph_data,
|
||||
active_user,
|
||||
);
|
||||
this.loading = false;
|
||||
},
|
||||
}));
|
||||
});
|
||||
@@ -1,3 +1,85 @@
|
||||
.graph {
|
||||
width: 100%;
|
||||
height: 70vh;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.graph-toolbar {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
gap: 30px;
|
||||
|
||||
.toolbar-column{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
min-width: 30%;
|
||||
}
|
||||
|
||||
.toolbar-input {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
label {
|
||||
max-width: 70%;
|
||||
text-align: left;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.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 {
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
padding: 5px;
|
||||
}
|
||||
&:enabled > .fa {
|
||||
background-color: #354a5f;
|
||||
color: white;
|
||||
}
|
||||
&:enabled:hover > .fa {
|
||||
color: white;
|
||||
background-color: #35405f; // just a bit darker
|
||||
}
|
||||
&:disabled > .fa {
|
||||
background-color: gray;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
align-self: center;
|
||||
max-width: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
.toolbar-column {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -9,6 +91,14 @@
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
#family-tree-link {
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
@media (min-width: 450px) {
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.users {
|
||||
display: flex;
|
||||
@@ -90,7 +180,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
&.delete {
|
||||
margin-top: 10px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
@@ -98,7 +188,7 @@
|
||||
|
||||
@media (max-width: 375px) {
|
||||
position: absolute;
|
||||
bottom: 0%;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Minified by jsDelivr using Terser v5.15.1.
|
||||
* Original file: /npm/cytoscape-cxtmenu@3.5.0/cytoscape-cxtmenu.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.cytoscapeCxtmenu=e():t.cytoscapeCxtmenu=e()}(this,(function(){return function(t){var e={};function i(n){if(e[n])return e[n].exports;var o=e[n]={i:n,l:!1,exports:{}};return t[n].call(o.exports,o,o.exports,i),o.l=!0,o.exports}return i.m=t,i.c=e,i.i=function(t){return t},i.d=function(t,e,n){i.o(t,e)||Object.defineProperty(t,e,{configurable:!1,enumerable:!0,get:n})},i.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return i.d(e,"a",e),e},i.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},i.p="",i(i.s=4)}([function(t,e,i){"use strict";var n=i(2),o=i(1),a=i(3),d=a.removeEles,r=a.setStyles,l=a.createElement,s=a.getPixelRatio,c=a.getOffset;t.exports=function(t){var e=o({},n,t),i=this,a=i.container(),u=void 0,p={options:e,handlers:[],container:l({class:"cxtmenu"})},h=p.container,g=l(),f=l({tag:"canvas"}),m=[],v=f.getContext("2d"),x=100,P=2*(x+e.activePadding),b=void 0,y=void 0;function S(t,e){E.drawBg=[t,e]}function M(t,i){v.globalCompositeOperation="source-over",v.clearRect(0,0,P,P),v.fillStyle=e.fillColor;for(var n=2*Math.PI/m.length,o=Math.PI/2,a=o+n,d=0;d<m.length;d++){var r=m[d];r.fillColor&&(v.fillStyle=r.fillColor),v.beginPath(),v.moveTo(t+e.activePadding,t+e.activePadding),v.arc(t+e.activePadding,t+e.activePadding,t,2*Math.PI-o,2*Math.PI-a,!0),v.closePath(),v.fill(),o+=n,a+=n,v.fillStyle=e.fillColor}v.globalCompositeOperation="destination-out",v.strokeStyle="white",v.lineWidth=e.separatorWidth,a=(o=Math.PI/2)+n;for(var l=0;l<m.length;l++){var s=t*Math.cos(o),c=t*Math.sin(o);v.beginPath(),v.moveTo(t+e.activePadding,t+e.activePadding),v.lineTo(t+e.activePadding+s,t+e.activePadding-c),v.closePath(),v.stroke(),o+=n,a+=n}v.fillStyle="white",v.globalCompositeOperation="destination-out",v.beginPath(),v.arc(t+e.activePadding,t+e.activePadding,i+e.spotlightPadding,0,2*Math.PI,!0),v.closePath(),v.fill(),v.globalCompositeOperation="source-over"}function w(t,i,n,o,a){var d=2*Math.PI/m.length,r=Math.PI/2,l=r+d;r+=d*b,l+=d*b,v.fillStyle=e.activeFillColor,v.strokeStyle="black",v.lineWidth=1,v.beginPath(),v.moveTo(n+e.activePadding,n+e.activePadding),v.arc(n+e.activePadding,n+e.activePadding,n+e.activePadding,2*Math.PI-r,2*Math.PI-l,!0),v.closePath(),v.fill(),v.fillStyle="white",v.globalCompositeOperation="destination-out";var s=n+e.activePadding+t/n*(a+e.spotlightPadding-e.indicatorSize/4),c=n+e.activePadding+i/n*(a+e.spotlightPadding-e.indicatorSize/4),u=Math.PI/4-o;v.translate(s,c),v.rotate(u);var p=e.indicatorSize>a+e.spotlightPadding?a+e.spotlightPadding:e.indicatorSize;v.beginPath(),v.fillRect(-p/2,-p/2,p,p),v.closePath(),v.fill(),v.rotate(-u),v.translate(-s,-c),v.beginPath(),v.arc(n+e.activePadding,n+e.activePadding,a+e.spotlightPadding,0,2*Math.PI,!0),v.closePath(),v.fill(),v.globalCompositeOperation="source-over"}function C(){var t=s(),e=P,i=P;f.width=e*t,f.height=i*t,f.style.width=e+"px",f.style.height=i+"px",v.setTransform(1,0,0,1,0,0),v.scale(t,t)}a.insertBefore(h,a.firstChild),h.appendChild(g),g.appendChild(f),r(h,{position:"absolute",zIndex:e.zIndex,userSelect:"none",pointerEvents:"none"}),["mousedown","mousemove","mouseup","contextmenu"].forEach((function(t){h.addEventListener(t,(function(t){return t.preventDefault(),!1}))})),r(g,{display:"none",width:P+"px",height:P+"px",position:"absolute",zIndex:1,marginLeft:-e.activePadding+"px",marginTop:-e.activePadding+"px",userSelect:"none"}),f.width=P,f.height=P;var R=!0,E={},I=window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.msRequestAnimationFrame||function(t){return setTimeout(t,16)};C(),function t(){E.drawBg&&M.apply(null,E.drawBg),E.drawCommands&&w.apply(null,E.drawCommands),E={},R&&I(t)}();var O,T,F,z,N,j,k,L,A,q=void 0,W=void 0,B=void 0,H={on:function(t,e,n){var o=n;return"core"===e&&(o=function(t){if(t.cyTarget===i||t.target===i)return n.apply(this,[t])}),p.handlers.push({events:t,selector:e,fn:o}),"core"===e?i.on(t,o):i.on(t,e,o),this}};function X(){R=!1,function(){for(var t=p.handlers,e=0;e<t.length;e++){var n=t[e];"core"===n.selector?i.off(n.events,n.fn):i.off(n.events,n.selector,n.fn)}window.removeEventListener("resize",C)}(),h.remove()}return T=void 0,F=!1,z=void 0,N=void 0,j=void 0,k=void 0,L=void 0,A=function(){T&&u.grabify(),z&&i.userZoomingEnabled(!0),N&&i.userPanningEnabled(!0),j&&i.boxSelectionEnabled(!0)},window.addEventListener("resize",C),H.on("resize",(function(){C()})).on(e.openMenuEvents,e.selector,(function(t){u=this;var n=this,o=this===i;if(F&&(g.style.display="none",F=!1,A()),"function"==typeof e.commands){var s=e.commands(u);s.then?s.then((function(t){m=t,p()})):(m=s,p())}else m=e.commands,p();function p(){if(m&&0!==m.length){z=i.userZoomingEnabled(),i.userZoomingEnabled(!1),N=i.userPanningEnabled(),i.userPanningEnabled(!1),j=i.boxSelectionEnabled(),i.boxSelectioLine truncated
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Minified by jsDelivr using Terser v5.3.0.
|
||||
* Original file: /npm/cytoscape-klay@3.1.4/cytoscape-klay.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
!function(e,r){"object"==typeof exports&&"object"==typeof module?module.exports=r(require("klayjs")):"function"==typeof define&&define.amd?define(["klayjs"],r):"object"==typeof exports?exports.cytoscapeKlay=r(require("klayjs")):e.cytoscapeKlay=r(e.$klay)}(this,(function(e){return function(e){var r={};function n(t){if(r[t])return r[t].exports;var i=r[t]={i:t,l:!1,exports:{}};return e[t].call(i.exports,i,i.exports,n),i.l=!0,i.exports}return n.m=e,n.c=r,n.i=function(e){return e},n.d=function(e,r,t){n.o(e,r)||Object.defineProperty(e,r,{configurable:!1,enumerable:!0,get:t})},n.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(r,"a",r),r},n.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},n.p="",n(n.s=3)}([function(e,r,n){"use strict";var t=n(4),i=n(1),a=n(2),o={addUnnecessaryBendpoints:"de.cau.cs.kieler.klay.layered.unnecessaryBendpoints",alignment:"de.cau.cs.kieler.alignment",aspectRatio:"de.cau.cs.kieler.aspectRatio",borderSpacing:"borderSpacing",compactComponents:"de.cau.cs.kieler.klay.layered.components.compact",compactionStrategy:"de.cau.cs.kieler.klay.layered.nodeplace.compactionStrategy",contentAlignment:"de.cau.cs.kieler.klay.layered.contentAlignment",crossingMinimization:"de.cau.cs.kieler.klay.layered.crossMin",cycleBreaking:"de.cau.cs.kieler.klay.layered.cycleBreaking",debugMode:"de.cau.cs.kieler.debugMode",direction:"de.cau.cs.kieler.direction",edgeLabelSideSelection:"de.cau.cs.kieler.klay.layered.edgeLabelSideSelection",edgeRouting:"de.cau.cs.kieler.edgeRouting",edgeSpacingFactor:"de.cau.cs.kieler.klay.layered.edgeSpacingFactor",feedbackEdges:"de.cau.cs.kieler.klay.layered.feedBackEdges",fixedAlignment:"de.cau.cs.kieler.klay.layered.fixedAlignment",greedySwitchCrossingMinimization:"de.cau.cs.kieler.klay.layered.greedySwitch",hierarchyHandling:"de.cau.cs.kieler.hierarchyHandling",inLayerSpacingFactor:"de.cau.cs.kieler.klay.layered.inLayerSpacingFactor",interactiveReferencePoint:"de.cau.cs.kieler.klay.layered.interactiveReferencePoint",layerConstraint:"de.cau.cs.kieler.klay.layered.layerConstraint",layoutHierarchy:"de.cau.cs.kieler.layoutHierarchy",linearSegmentsDeflectionDampening:"de.cau.cs.kieler.klay.layered.linearSegmentsDeflectionDampening",mergeEdges:"de.cau.cs.kieler.klay.layered.mergeEdges",mergeHierarchyCrossingEdges:"de.cau.cs.kieler.klay.layered.mergeHierarchyEdges",noLayout:"de.cau.cs.kieler.noLayout",nodeLabelPlacement:"de.cau.cs.kieler.nodeLabelPlacement",nodeLayering:"de.cau.cs.kieler.klay.layered.nodeLayering",nodePlacement:"de.cau.cs.kieler.klay.layered.nodePlace",portAlignment:"de.cau.cs.kieler.portAlignment",portAlignmentEastern:"de.cau.cs.kieler.portAlignment.east",portAlignmentNorth:"de.cau.cs.kieler.portAlignment.north",portAlignmentSouth:"de.cau.cs.kieler.portAlignment.south",portAlignmentWest:"de.cau.cs.kieler.portAlignment.west",portConstraints:"de.cau.cs.kieler.portConstraints",portLabelPlacement:"de.cau.cs.kieler.portLabelPlacement",portOffset:"de.cau.cs.kieler.offset",portSide:"de.cau.cs.kieler.portSide",portSpacing:"de.cau.cs.kieler.portSpacing",postCompaction:"de.cau.cs.kieler.klay.layered.postCompaction",priority:"de.cau.cs.kieler.priority",randomizationSeed:"de.cau.cs.kieler.randomSeed",routeSelfLoopInside:"de.cau.cs.kieler.selfLoopInside",separateConnectedComponents:"de.cau.cs.kieler.separateConnComp",sizeConstraint:"de.cau.cs.kieler.sizeConstraint",sizeOptions:"de.cau.cs.kieler.sizeOptions",spacing:"de.cau.cs.kieler.spacing",splineSelfLoopPlacement:"de.cau.cs.kieler.klay.layered.splines.selfLoopPlacement",thoroughness:"de.cau.cs.kieler.klay.layered.thoroughness",wideNodesOnMultipleLayers:"de.cau.cs.kieler.klay.layered.wideNodesOnMultipleLayers"},c=function(e){for(var r=Object.keys(e),n={},t=0;t<r.length;t++){var i=r[t],a=o[i],c=e[i];n[a]=c}return n},s={interactiveReferencePoint:"CENTER"},l=function(e){for(var r=e.parent(),n=e.scratch("klay"),t={x:n.x,y:n.y};r.nonempty();){var i=r.scratch("klay");t.x+=i.x,t.y+=i.y,r=r.parent()}return t},d=function(e,r){var n=e.layoutDimensions(r),t=e.numericStyle("padding"),i={_cyEle:e,id:e.id(),padding:{top:t,left:t,bottom:t,right:t}};return e.isParent()||(i.width=n.w,i.height=n.h),e.scratch("klay",i),i},u=function(e,r){var n={_cyEle:e,id:e.id(),source:e.data("source"),target:e.data("target"),properties:{}},t=r.priority(e);return null!=t&&(n.properties.priority=t),e.scratch("klay",n),n};function p(e){var r=e.klay;this.options=i({},a,e),this.options.klay=i({},a.klay,r,s)}p.prototype.run=function(){var e=this.options,r=e.eles,n=r.nodes(),i=function(e,r,n){for(var t=[],i=[],a={},o={id:"root",children:[],edges:[]},c=0;c<e.length;c++){var s=e[c],l=d(s,n);t.push(l),a[s.id()]=l}for(var p=0;p<r.length;p++){var y=r[p],g=u(y,n);i.push(g),a[y.id()]=g}for(var k=0;k<t.length;k++){var f=t[k],m=f._cyEle;if(m.isChild()){var h=a[m.parent().id()];(h.children=h.children||[]).push(f)}else o.children.push(f)}for(var S=0;S<i.length;S++){var E=i[S],v=E._cyEle;v.source().parent(),v.target().parLine truncated
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) 2016-2024, The Cytoscape Consortium.
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the “Software”), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
* of the Software, and to permit persons to whom the Software is furnished to do
|
||||
* so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).cytoscape=t()}(this,(function(){"use strict";function e(t){return(e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(t)}function t(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function n(e,t){for(var n=0;n<t.length;n++){var r=t[n];r.enumerable=r.enumerable||!1,r.configurable=!0,"value"in r&&(r.writable=!0),Object.defineProperty(e,r.key,r)}}function r(e,t,r){return t&&n(e.prototype,t),r&&n(e,r),Object.defineProperty(e,"prototype",{writable:!1}),e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function a(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null==n)return;var r,i,a=[],o=!0,s=!1;try{for(n=n.call(e);!(o=(r=n.next()).done)&&(a.push(r.value),!t||a.length!==t);o=!0);}catch(e){s=!0,i=e}finally{try{o||null==n.return||n.return()}finally{if(s)throw i}}return a}(e,t)||o(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function o(e,t){if(e){if("string"==typeof e)return s(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?s(e,t):void 0}}function s(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n<t;n++)r[n]=e[n];return r}function l(e,t){var n="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(!n){if(Array.isArray(e)||(n=o(e))||t&&e&&"number"==typeof e.length){n&&(e=n);var r=0,i=function(){};return{s:i,n:function(){return r>=e.length?{done:!0}:{done:!1,value:e[r++]}},e:function(e){throw e},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var a,s=!0,l=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return s=e.done,e},e:function(e){l=!0,a=e},f:function(){try{s||null==n.return||n.return()}finally{if(l)throw a}}}}var u="undefined"==typeof window?null:window,c=u?u.navigator:null;u&&u.document;var d=e(""),h=e({}),p=e((function(){})),f="undefined"==typeof HTMLElement?"undefined":e(HTMLElement),g=function(e){return e&&e.instanceString&&y(e.instanceString)?e.instanceString():null},v=function(t){return null!=t&&e(t)==d},y=function(t){return null!=t&&e(t)===p},m=function(e){return!E(e)&&(Array.isArray?Array.isArray(e):null!=e&&e instanceof Array)},b=function(t){return null!=t&&e(t)===h&&!m(t)&&t.constructor===Object},x=function(t){return null!=t&&e(t)===e(1)&&!isNaN(t)},w=function(e){return"undefined"===f?void 0:null!=e&&e instanceof HTMLElement},E=function(e){return k(e)||C(e)},k=function(e){return"collection"===g(e)&&e._private.single},C=function(e){return"collection"===g(e)&&!e._private.single},S=function(e){return"core"===g(e)},P=function(e){return"stylesheet"===g(e)},D=function(e){return null==e||!(""!==e&&!e.match(/^\s+$/))},T=function(t){return function(t){return null!=t&&e(t)===h}(t)&&y(t.then)},_=function(e,t){t||(t=function(){if(1===arguments.length)return arguments[0];if(0===arguments.length)return"undefined";for(var e=[],t=0;t<arguments.length;t++)e.push(arguments[t]);return e.join("$")});var n=function n(){var r,i=this,a=arguments,o=t.apply(i,a),s=n.cache;return(r=s[o])||(r=s[o]=e.apply(i,a)),r};return n.cache={},n},M=_((function(e){return e.replace(/([A-Z])/g,(function(e){return"-"+e.toLowerCase()}))})),B=_((function(e){return e.replace(/(-\w)/g,(function(e){return e[1].toUpperCase()}))})),N=_((function(e,t){return e+t[0].toUpperCase()+t.substring(1)}),(function(e,t){return e+"$"+t})),z=function(e){return D(e)?e:e.charAt(0).toUpperCase()+e.substring(1)},I="(?:[-+]?(?:(?:\\d+|\\d*\\.\\d+)(?:[Ee][+-]?\\d+)?))",A=function(e,t){return e<t?-1:e>t?1:0},L=null!=Object.assign?Object.assign.bind(Object):function(e){for(var t=arguments,n=1;n<t.length;n++){var r=t[n];if(null!=r)for(var i=Object.keys(r),a=0;a<i.length;a++){var o=i[a];e[o]=r[o]}}return e},O=function(e){return(m(e)?e:null)||function(e){return R[e.toLowerCase()]}(e)||function(e){if((4===e.length||7===e.length)&&"#"===e[0]){var t,n,r;return 4===e.length?(t=parseInt(e[1]+e[1],16),n=parseInt(e[2]+e[2],16),r=parseInt(e[3]+e[3],16)):(t=parseInt(e[1]+e[2],16),n=parseInt(e[3]+e[4],16),r=parseInt(e[5]+e[6],16)),[t,n,r]}}(e)||function(e){var t,n=new RegExp("^rgb[a]?\\(((?:[-+]?(?:(?:\\d+|\\d*\\.\\d+)(?:[Ee][+-]?\\d+)?))[%]?)\\s*,\\s*((?:[-+]?(?:(?:\\d+|\\dLine truncated
|
||||
/*!
|
||||
Embeddable Minimum Strictly-Compliant Promises/A+ 1.1.1 Thenable
|
||||
Copyright (c) 2013-2014 Ralf S. Engelschall (http://engelschall.com)
|
||||
Licensed under The MIT License (http://opensource.org/licenses/MIT)
|
||||
*/
|
||||
var cr=function e(t){if(!(this instanceof e))return new e(t);this.id="Thenable/1.0.7",this.state=0,this.fulfillValue=void 0,this.rejectReason=void 0,this.onFulfilled=[],this.onRejected=[],this.proxy={then:this.then.bind(this)},"function"==typeof t&&t.call(this,this.fulfill.bind(this),this.reject.bind(this))};cr.prototype={fulfill:function(e){return dr(this,1,"fulfillValue",e)},reject:function(e){return dr(this,2,"rejectReason",e)},then:function(e,t){var n=new cr;return this.onFulfilled.push(fr(e,n,"fulfill")),this.onRejected.push(fr(t,n,"reject")),hr(this),n.proxy}};var dr=function(e,t,n,r){return 0===e.state&&(e.state=t,e[n]=r,hr(e)),e},hr=function(e){1===e.state?pr(e,"onFulfilled",e.fulfillValue):2===e.state&&pr(e,"onRejected",e.rejectReason)},pr=function(e,t,n){if(0!==e[t].length){var r=e[t];e[t]=[];var i=function(){for(var e=0;e<r.length;e++)r[e](n)};"function"==typeof setImmediate?setImmediate(i):setTimeout(i,0)}},fr=function(e,t,n){return function(r){if("function"!=typeof e)t[n].call(t,r);else{var i;try{i=e(r)}catch(e){return void t.reject(e)}gr(t,i)}}},gr=function t(n,r){if(n!==r&&n.proxy!==r){var i;if("object"===e(r)&&null!==r||"function"==typeof r)try{i=r.then}catch(e){return void n.reject(e)}if("function"!=typeof i)n.fulfill(r);else{var a=!1;try{i.call(r,(function(e){a||(a=!0,e===r?n.reject(new TypeError("circular thenable chain")):t(n,e))}),(function(e){a||(a=!0,n.reject(e))}))}catch(e){a||n.reject(e)}}}else n.reject(new TypeError("cannot resolve promise with itself"))};cr.all=function(e){return new cr((function(t,n){for(var r=new Array(e.length),i=0,a=function(n,a){r[n]=a,++i===e.length&&t(r)},o=0;o<e.length;o++)!function(t){var r=e[t];null!=r&&null!=r.then?r.then((function(e){a(t,e)}),(function(e){n(e)})):a(t,r)}(o)}))},cr.resolve=function(e){return new cr((function(t,n){t(e)}))},cr.reject=function(e){return new cr((function(t,n){n(e)}))};var vr="undefined"!=typeof Promise?Promise:cr,yr=function(e,t,n){var r=S(e),i=!r,a=this._private=L({duration:1e3},t,n);if(a.target=e,a.style=a.style||a.css,a.started=!1,a.playing=!1,a.hooked=!1,a.applying=!1,a.progress=0,a.completes=[],a.frames=[],a.complete&&y(a.complete)&&a.completes.push(a.complete),i){var o=e.position();a.startPosition=a.startPosition||{x:o.x,y:o.y},a.startStyle=a.startStyle||e.cy().style().getAnimationStartStyle(e,a.style)}if(r){var s=e.pan();a.startPan={x:s.x,y:s.y},a.startZoom=e.zoom()}this.length=1,this[0]=this},mr=yr.prototype;L(mr,{instanceString:function(){return"animation"},hook:function(){var e=this._private;if(!e.hooked){var t=e.target._private.animation;(e.queue?t.queue:t.current).push(this),E(e.target)&&e.target.cy().addToAnimationPool(e.target),e.hooked=!0}return this},play:function(){var e=this._private;return 1===e.progress&&(e.progress=0),e.playing=!0,e.started=!1,e.stopped=!1,this.hook(),this},playing:function(){return this._private.playing},apply:function(){var e=this._private;return e.applying=!0,e.started=!1,e.stopped=!1,this.hook(),this},applying:function(){return this._private.applying},pause:function(){var e=this._private;return e.playing=!1,e.started=!1,this},stop:function(){var e=this._private;return e.playing=!1,e.started=!1,e.stopped=!0,this},rewind:function(){return this.progress(0)},fastforward:function(){return this.progress(1)},time:function(e){var t=this._private;return void 0===e?t.progress*t.duration:this.progress(e/t.duration)},progress:function(e){var t=this._private,n=t.playing;return void 0===e?t.progress:(n&&this.pause(),t.progress=e,t.started=!1,n&&this.play(),this)},completed:function(){return 1===this._private.progress},reverse:function(){var e=this._private,t=e.playing;t&&this.pause(),e.progress=1-e.progress,e.started=!1;var n=function(t,n){var r=e[t];null!=r&&(e[t]=e[n],e[n]=r)};if(n("zoom","startZoom"),n("pan","startPan"),n("position","startPosition"),e.style)for(var r=0;r<e.style.length;r++){var i=e.style[r],a=i.name,o=e.startStyle[a];e.startStyle[a]=i,e.style[r]=o}return t&&this.play(),this},promise:function(e){var t,n=this._private;switch(e){case"frame":t=n.frames;break;default:case"complete":case"completed":t=n.completes}return new vr((function(e,n){t.push((function(){e()}))}))}}),mr.complete=mr.completed,mr.run=mr.play,mr.running=mr.playing;var br={animated:function(){return function(){var e=void 0!==this.length?this:[this];if(!(this._private.cy||this).styleEnabled())return!1;var t=e[0];return t?t._private.animation.current.length>0:void 0}},clearQueue:function(){return function(){var e=void 0!==this.length?this:[this];if(!(this._private.cy||this).styleEnabled())return this;for(var t=0;t<e.length;t++){e[t]._private.animation.queue=[]}return this}},delay:function(){return function(e,t){return(this._private.cy||this).styleEnabled()?this.animate({delay:e,duration:e,complete:t}):this}},delayAnimation:function(){return function(e,t){return(this._private.cy||this).styleEnabled()?this.animation({delay:e,duration:e,complete:t}):this}},animation:function(){return function(e,t){var n=void 0!==this.lLine truncated
|
||||
/*! Bezier curve function generator. Copyright Gaetan Renaudeau. MIT License: http://en.wikipedia.org/wiki/MIT_License */
|
||||
/*! Runge-Kutta spring physics function generator. Adapted from Framer.js, copyright Koen Bok. MIT License: http://en.wikipedia.org/wiki/MIT_License */
|
||||
var is=function(){function e(e){return-e.tension*e.x-e.friction*e.v}function t(t,n,r){var i={x:t.x+r.dx*n,v:t.v+r.dv*n,tension:t.tension,friction:t.friction};return{dx:i.v,dv:e(i)}}function n(n,r){var i={dx:n.v,dv:e(n)},a=t(n,.5*r,i),o=t(n,.5*r,a),s=t(n,r,o),l=1/6*(i.dx+2*(a.dx+o.dx)+s.dx),u=1/6*(i.dv+2*(a.dv+o.dv)+s.dv);return n.x=n.x+l*r,n.v=n.v+u*r,n}return function e(t,r,i){var a,o,s,l={x:-1,v:0,tension:null,friction:null},u=[0],c=0;for(t=parseFloat(t)||500,r=parseFloat(r)||20,i=i||null,l.tension=t,l.friction=r,o=(a=null!==i)?(c=e(t,r))/i*.016:.016;s=n(s||l,o),u.push(1+s.x),c+=16,Math.abs(s.x)>1e-4&&Math.abs(s.v)>1e-4;);return a?function(e){return u[e*(u.length-1)|0]}:c}}(),as=function(e,t,n,r){var i=function(e,t,n,r){var i=4,a=.001,o=1e-7,s=10,l=11,u=1/(l-1),c="undefined"!=typeof Float32Array;if(4!==arguments.length)return!1;for(var d=0;d<4;++d)if("number"!=typeof arguments[d]||isNaN(arguments[d])||!isFinite(arguments[d]))return!1;e=Math.min(e,1),n=Math.min(n,1),e=Math.max(e,0),n=Math.max(n,0);var h=c?new Float32Array(l):new Array(l);function p(e,t){return 1-3*t+3*e}function f(e,t){return 3*t-6*e}function g(e){return 3*e}function v(e,t,n){return((p(t,n)*e+f(t,n))*e+g(t))*e}function y(e,t,n){return 3*p(t,n)*e*e+2*f(t,n)*e+g(t)}function m(t,r){for(var a=0;a<i;++a){var o=y(r,e,n);if(0===o)return r;r-=(v(r,e,n)-t)/o}return r}function b(){for(var t=0;t<l;++t)h[t]=v(t*u,e,n)}function x(t,r,i){var a,l,u=0;do{(a=v(l=r+(i-r)/2,e,n)-t)>0?i=l:r=l}while(Math.abs(a)>o&&++u<s);return l}function w(t){for(var r=0,i=1,o=l-1;i!==o&&h[i]<=t;++i)r+=u;--i;var s=r+(t-h[i])/(h[i+1]-h[i])*u,c=y(s,e,n);return c>=a?m(t,s):0===c?s:x(t,r,r+u)}var E=!1;function k(){E=!0,e===t&&n===r||b()}var C=function(i){return E||k(),e===t&&n===r?i:0===i?0:1===i?1:v(w(i),t,r)};C.getControlPoints=function(){return[{x:e,y:t},{x:n,y:r}]};var S="generateBezier("+[e,t,n,r]+")";return C.toString=function(){return S},C}(e,t,n,r);return function(e,t,n){return e+(t-e)*i(n)}},os={linear:function(e,t,n){return e+(t-e)*n},ease:as(.25,.1,.25,1),"ease-in":as(.42,0,1,1),"ease-out":as(0,0,.58,1),"ease-in-out":as(.42,0,.58,1),"ease-in-sine":as(.47,0,.745,.715),"ease-out-sine":as(.39,.575,.565,1),"ease-in-out-sine":as(.445,.05,.55,.95),"ease-in-quad":as(.55,.085,.68,.53),"ease-out-quad":as(.25,.46,.45,.94),"ease-in-out-quad":as(.455,.03,.515,.955),"ease-in-cubic":as(.55,.055,.675,.19),"ease-out-cubic":as(.215,.61,.355,1),"ease-in-out-cubic":as(.645,.045,.355,1),"ease-in-quart":as(.895,.03,.685,.22),"ease-out-quart":as(.165,.84,.44,1),"ease-in-out-quart":as(.77,0,.175,1),"ease-in-quint":as(.755,.05,.855,.06),"ease-out-quint":as(.23,1,.32,1),"ease-in-out-quint":as(.86,0,.07,1),"ease-in-expo":as(.95,.05,.795,.035),"ease-out-expo":as(.19,1,.22,1),"ease-in-out-expo":as(1,0,0,1),"ease-in-circ":as(.6,.04,.98,.335),"ease-out-circ":as(.075,.82,.165,1),"ease-in-out-circ":as(.785,.135,.15,.86),spring:function(e,t,n){if(0===n)return os.linear;var r=is(e,t,n);return function(e,t,n){return e+(t-e)*r(n)}},"cubic-bezier":as};function ss(e,t,n,r,i){if(1===r)return n;if(t===n)return n;var a=i(t,n,r);return null==e||((e.roundValue||e.color)&&(a=Math.round(a)),void 0!==e.min&&(a=Math.max(a,e.min)),void 0!==e.max&&(a=Math.min(a,e.max))),a}function ls(e,t){return null!=e.pfValue||null!=e.value?null==e.pfValue||null!=t&&"%"===t.type.units?e.value:e.pfValue:e}function us(e,t,n,r,i){var a=null!=i?i.type:null;n<0?n=0:n>1&&(n=1);var o=ls(e,i),s=ls(t,i);if(x(o)&&x(s))return ss(a,o,s,n,r);if(m(o)&&m(s)){for(var l=[],u=0;u<s.length;u++){var c=o[u],d=s[u];if(null!=c&&null!=d){var h=ss(a,c,d,n,r);l.push(h)}else l.push(d)}return l}}function cs(e,t,n,r){var i=!r,a=e._private,o=t._private,s=o.easing,l=o.startTime,u=(r?e:e.cy()).style();if(!o.easingImpl)if(null==s)o.easingImpl=os.linear;else{var c,d,h;if(v(s))c=u.parse("transition-timing-function",s).value;else c=s;v(c)?(d=c,h=[]):(d=c[1],h=c.slice(2).map((function(e){return+e}))),h.length>0?("spring"===d&&h.push(o.duration),o.easingImpl=os[d].apply(null,h)):o.easingImpl=os[d]}var p,f=o.easingImpl;if(p=0===o.duration?1:(n-l)/o.duration,o.applying&&(p=o.progress),p<0?p=0:p>1&&(p=1),null==o.delay){var g=o.startPosition,y=o.position;if(y&&i&&!e.locked()){var m={};ds(g.x,y.x)&&(m.x=us(g.x,y.x,p,f)),ds(g.y,y.y)&&(m.y=us(g.y,y.y,p,f)),e.position(m)}var b=o.startPan,x=o.pan,w=a.pan,E=null!=x&&r;E&&(ds(b.x,x.x)&&(w.x=us(b.x,x.x,p,f)),ds(b.y,x.y)&&(w.y=us(b.y,x.y,p,f)),e.emit("pan"));var k=o.startZoom,C=o.zoom,S=null!=C&&r;S&&(ds(k,C)&&(a.zoom=Tt(a.minZoom,us(k,C,p,f),a.maxZoom)),e.emit("zoom")),(E||S)&&e.emit("viewport");var P=o.style;if(P&&P.length>0&&i){for(var D=0;D<P.length;D++){var T=P[D],_=T.name,M=T,B=o.startStyle[_],N=us(B,M,p,f,u.properties[B.name]);u.overrideBypass(e,_,N)}e.emit("style")}}return o.progress=p,p}function ds(e,t){return null!=e&&null!=t&&(!(!x(e)||!x(t))||!(!e||!t))}function hs(e,t,n,r){var i=t._private;i.started=!0,i.startTime=n-i.progress*i.duration}function ps(e,t){var n=t._private.aniEles,r=[];function i(t,n){var i=t._private,aLine truncated
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Minified by jsDelivr using Terser v5.19.2.
|
||||
* Original file: /npm/klayjs@0.4.1/klay.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
var klayregister,klaycallback;!function(){var n;function t(){}function e(){}function r(){}function c(){}function i(){}function a(){}function u(){}function f(){}function s(){}function b(){}function h(){}function l(){}function w(){}function d(){}function g(){}function v(){}function k(){}function p(){}function m(){}function j(){}function y(){}function S(){}function E(){}function H(){}function C(){}function I(){}function L(){}function G(){}function N(){}function O(){}function T(){}function P(){}function R(){}function Y(){}function A(){}function _(){}function M(){}function D(){}function x(){}function B(){}function $(){}function U(){}function F(){}function V(){}function z(){}function q(){}function Q(){}function K(){}function W(){}function J(){}function Z(){}function X(){}function nn(){}function tn(){}function en(){}function rn(){}function cn(){}function an(){}function un(){}function on(){}function fn(){}function sn(){}function bn(){}function hn(){}function ln(){}function wn(){}function dn(){}function gn(){}function vn(){}function kn(){}function pn(){}function mn(){}function jn(){}function yn(){}function Sn(){}function En(){}function Hn(){}function Cn(){}function In(){}function Ln(){}function Gn(){}function Nn(){}function On(){}function Tn(){}function Pn(){}function Rn(){}function Yn(){}function An(){}function _n(){}function Mn(){}function Dn(){}function xn(){}function Bn(){}function $n(){}function Un(){}function Fn(){}function Vn(){}function zn(){}function qn(){}function Qn(){}function Kn(){}function Wn(){}function Jn(){}function Zn(){}function Xn(){}function nt(){}function tt(){}function et(){}function rt(){}function ct(){}function it(){}function at(){}function ut(){}function ot(){}function ft(){}function st(){}function bt(){}function ht(){}function lt(){}function wt(){}function dt(){}function gt(){}function vt(){}function kt(){Bh()}function pt(){Ay()}function mt(){Kl()}function jt(){Qd()}function yt(){ni()}function St(){ti()}function Et(){sa()}function Ht(){Kd()}function Ct(){_h()}function It(){DE()}function Lt(){Mh()}function Gt(){Dh()}function Nt(){HI()}function Ot(){FH()}function Tt(){fb(this)}function Pt(){}function Rt(){Sf(this)}function Yt(){}function At(n){this.a=n}function _t(n){this.a=n}function Mt(n){this.a=n}function Dt(n){this.a=n}function xt(n){this.a=n}function Bt(n){this.a=n}function $t(n){this.a=n}function Ut(n){this.a=n}function Ft(n){this.a=n}function Vt(n){this.b=n}function zt(n){this.a=n}function qt(n){this.a=n}function Qt(n){this.a=n}function Kt(n){this.a=n}function Wt(n){this.a=n}function Jt(n){this.a=n}function Zt(n){this.a=n}function Xt(n){this.a=n}function ne(n){this.a=n}function te(n){this.a=n}function ee(n){this.a=n}function re(n){this.a=n}function ce(n){this.a=n}function ie(n){this.a=n}function ae(n){this.a=n}function ue(n){this.e=n}function oe(n){this.a=n}function fe(n){this.a=n}function se(n){this.a=n}function be(n){this.a=n}function he(n){this.a=n}function le(n){this.a=n}function we(n){this.a=n}function de(n){this.a=n}function ge(n){this.a=n}function ve(n){this.a=n}function ke(n){this.a=n}function pe(n){this.a=n}function me(n){this.a=n}function je(n){this.a=n}function ye(n){this.a=n}function Se(n){this.a=n}function Ee(n){this.a=n}function He(n){this.a=n}function Ce(n){this.a=n}function Ie(n){this.a=n}function Le(n){this.a=n}function Ge(n){this.c=n}function Ne(n){this.a=n}function Oe(n){this.a=n}function Te(n){this.a=n}function Pe(n){this.a=n}function Re(n){this.a=n}function Ye(n){this.a=n}function Ae(n){this.a=n}function _e(n){this.a=n}function Me(n){this.a=n}function De(n){this.a=n}function xe(n){this.d=n}function Be(n){this.a=n}function $e(n){this.a=n}function Ue(n){this.a=n}function Fe(n){this.a=n}function Ve(n){this.b=n}function ze(n){this.a=n}function qe(n){this.a=n}function Qe(n){this.c=n}function Ke(n){this.a=n}function We(n){this.a=n}function Je(n){this.a=n}function Ze(n){this.b=n}function Xe(n){this.b=n}function nr(n){this.c=n}function tr(n){this.a=n}function er(n){this.a=n}function rr(n){this.a=n}function cr(){this.a=[]}function ir(n){this.a=n}function ar(n){this.a=n}function ur(n){n.b=n.a}function or(n){n.c=n.d.d}function fr(n,t){n.g=t}function sr(n,t){n.k=t}function br(n,t){n.e.k=t}function hr(n){return n.a}function lr(n){return n.a}function wr(n){return n.a}function dr(n){return n.a}function gr(n){return n.a}function vr(){return null}function kr(){return null}function pr(){this.c=this}function mr(){fb(this)}function jr(){Ik(this)}function yr(n){!function(n,t){var e,r,c,i,a,u,o;for(o=0,r=0,c=t.length;r<c;++r)for(i=0,a=(e=t[r]).length;i<a;++i)for(u=new Qe(e[i].f);u.a<u.c.c.length;)zh(rv(u),7).k=o++;n.a=Pk(OQ,BP,26,o,12,1)}(this,n)}function Sr(n){gf(),function(n){var t,e,r,c,i,a,u,o,f,s;for(c=zh(ca(n,(HT(),J_)),15).a,u=new Qe(AC(n));u.a<u.c.c.length;){for((e=new xN(a=zh(rv(u),626))).d=c,e.k=zh(NS(a,vM),15).a,r=zh(NS(a,dM),149),t=ei(ri(Ob(Pj(zh(a.e,9),(jT(),fz))))),s=new Qe(HH(a));s.a<s.c.c.length;)f=zh(rv(s),161),r==(Vp(),XM)?fO(f,t,c):r==nD&&AO(f,c),qG(f);zO(e,zh(NS(a,Line truncated
|
||||
@@ -112,7 +112,7 @@
|
||||
|
||||
{% macro delete_godfather(user, profile, godfather, is_father) %}
|
||||
{% if user == profile or user.is_root or user.is_board_member %}
|
||||
<a href="{{ url("core:user_godfathers_delete", user_id=profile.id, godfather_id=godfather.id, is_father=is_father) }}">{% trans %}Delete{% endtrans %}</a>
|
||||
<a class="delete" href="{{ url("core:user_godfathers_delete", user_id=profile.id, godfather_id=godfather.id, is_father=is_father) }}">{% trans %}Delete{% endtrans %}</a>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
@@ -11,14 +11,20 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<a href="{{ url('core:user_godfathers_tree_pict', user_id=profile.id) }}?family">
|
||||
{% trans %}Show family picture{% endtrans %}
|
||||
</a>
|
||||
{% if godchildren or godfathers %}
|
||||
<a
|
||||
href="{{ url('core:user_godfathers_tree', user_id=profile.id) }}"
|
||||
class="btn btn-blue"
|
||||
id="family-tree-link"
|
||||
>
|
||||
{% trans %}Show family tree{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<h4>{% trans %}Godfathers / Godmothers{% endtrans %}</h4>
|
||||
{% if profile.godfathers.exists() %}
|
||||
{% if godfathers %}
|
||||
<ul class="users">
|
||||
{% for u in profile.godfathers.all() %}
|
||||
{% for u in godfathers %}
|
||||
<li class="users-card">
|
||||
<a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link">
|
||||
{{ u.get_mini_item() | safe }}
|
||||
@@ -28,17 +34,14 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<a href="{{ url('core:user_godfathers_tree', user_id=profile.id) }}">
|
||||
{% trans %}Show ancestors tree{% endtrans %}
|
||||
</a>
|
||||
{% else %}
|
||||
<p>{% trans %}No godfathers / godmothers{% endtrans %}
|
||||
{% endif %}
|
||||
|
||||
<h4>{% trans %}Godchildren{% endtrans %}</h4>
|
||||
{% if profile.godchildren.exists() %}
|
||||
{% if godchildren %}
|
||||
<ul class="users">
|
||||
{% for u in profile.godchildren.all() %}
|
||||
{% for u in godchildren %}
|
||||
<li class="users-card">
|
||||
<a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link">
|
||||
{{ u.get_mini_item()|safe }}
|
||||
@@ -47,10 +50,6 @@
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<a href="{{ url('core:user_godfathers_tree', user_id=profile.id) }}?descent">
|
||||
{% trans %}Show descent tree{% endtrans %}
|
||||
</a>
|
||||
{% else %}
|
||||
<p>{% trans %}No godchildren{% endtrans %}
|
||||
{% endif %}
|
||||
|
||||
@@ -1,54 +1,105 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
{% set depth_min=0 %}
|
||||
{% set depth_max=10 %}
|
||||
|
||||
{%- block additional_css -%}
|
||||
<link rel="stylesheet" href="{{ scss('user/user_godfathers.scss') }}">
|
||||
{%- endblock -%}
|
||||
|
||||
{% block additional_js %}
|
||||
<script src="{{ static("vendored/cytoscape/cytoscape.min.js") }}" defer></script>
|
||||
<script src="{{ static("vendored/cytoscape/cytoscape-cxtmenu.min.js") }}" defer></script>
|
||||
|
||||
<script src="{{ static("vendored/cytoscape/klay.min.js") }}" defer></script>
|
||||
<script src="{{ static("vendored/cytoscape/cytoscape-klay.min.js") }}" defer></script>
|
||||
|
||||
<script src="{{ static("user/js/family_graph.js") }}" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}
|
||||
{% if param == "godchildren" %}
|
||||
{% trans user_name=profile.get_display_name() %}{{ user_name }}'s godchildren{% endtrans %}
|
||||
{% else %}
|
||||
{% trans user_name=profile.get_display_name() %}{{ user_name }}'s godfathers{% endtrans %}
|
||||
{% endif %}
|
||||
{% trans user_name=profile.get_display_name() %}{{ user_name }}'s family tree{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% macro display_members_list(user) %}
|
||||
{% if user.__getattribute__(param).exists() %}
|
||||
<ul>
|
||||
{% for u in user.__getattribute__(param).all() %}
|
||||
<li>
|
||||
<a href="{{ url("core:user_godfathers", user_id=u.id) }}">
|
||||
{{ u.get_short_name() }}
|
||||
</a>
|
||||
{% if u in members_set %}
|
||||
{% trans %}Already seen (check above){% endtrans %}
|
||||
{% else %}
|
||||
{{ members_set.add(u) or "" }}
|
||||
{{ display_members_list(u) }}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<p><a href="{{ url("core:user_godfathers", user_id=profile.id) }}">
|
||||
{% trans %}Back to family{% endtrans %}</a></p>
|
||||
{% if profile.__getattribute__(param).exists() %}
|
||||
{% if param == "godchildren" %}
|
||||
<p><a href="{{ url("core:user_godfathers_tree_pict", user_id=profile.id) }}?descent">
|
||||
{% trans %}Show a picture of the tree{% endtrans %}</a></p>
|
||||
<h4>{% trans u=profile.get_short_name() %}Descent tree of {{ u }}{% endtrans %}</h4>
|
||||
{% else %}
|
||||
<p><a href="{{ url("core:user_godfathers_tree_pict", user_id=profile.id) }}?ancestors">
|
||||
{% trans %}Show a picture of the tree{% endtrans %}</a></p>
|
||||
<h4>{% trans u=profile.get_short_name() %}Ancestors tree of {{ u }}{% endtrans %}</h4>
|
||||
{% endif %}
|
||||
{{ members_set.add(profile) or "" }}
|
||||
{{ display_members_list(profile) }}
|
||||
{% else %}
|
||||
{% if param == "godchildren" %}
|
||||
<p>{% trans %}No godchildren{% endtrans %}
|
||||
{% else %}
|
||||
<p>{% trans %}No godfathers / godmothers{% endtrans %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div x-data="graph" :aria-busy="loading">
|
||||
<div class="graph-toolbar">
|
||||
<div class="toolbar-column">
|
||||
<div class="toolbar-input">
|
||||
<label for="godfather-depth-input">
|
||||
{% trans min=depth_min, max=depth_max %}Max godfather depth between {{ min }} and {{ max }}{% endtrans %}
|
||||
</label>
|
||||
<span class="depth-choice">
|
||||
<button
|
||||
@click="godfathers_depth--"
|
||||
:disabled="godfathers_depth <= {{ depth_min }}"
|
||||
><i class="fa fa-minus fa-xs"></i></button>
|
||||
<input
|
||||
x-model="godfathers_depth"
|
||||
x-ref="godfather_depth_input"
|
||||
type="number"
|
||||
name="godfathers_depth"
|
||||
id="godfather-depth-input"
|
||||
min="{{ depth_min }}"
|
||||
max="{{ depth_max }}"
|
||||
/>
|
||||
<button
|
||||
@click="godfathers_depth++"
|
||||
:disabled="godfathers_depth >= {{ depth_max }}"
|
||||
><i class="fa fa-plus"
|
||||
></i></button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-input">
|
||||
<label for="godchild-depth-input">
|
||||
{% trans min=depth_min, max=depth_max %}Max godchildren depth between {{ min }} and {{ max }}{% endtrans %}
|
||||
</label>
|
||||
<span class="depth-choice">
|
||||
<button
|
||||
@click="godchildren_depth--"
|
||||
:disabled="godchildren_depth <= {{ depth_min }}"
|
||||
><i
|
||||
class="fa fa-minus fa-xs"
|
||||
></i></button>
|
||||
<input
|
||||
x-model="godchildren_depth"
|
||||
type="number"
|
||||
name="godchildren_depth"
|
||||
id="godchild-depth-input"
|
||||
min="{{ depth_min }}"
|
||||
max="{{ depth_max }}"
|
||||
/>
|
||||
<button
|
||||
@click="godchildren_depth++"
|
||||
:disabled="godchildren_depth >= {{ depth_max }}"
|
||||
><i class="fa fa-plus"
|
||||
></i></button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-column">
|
||||
<div class="toolbar-input">
|
||||
<label for="reverse-checkbox">{% trans %}Reverse{% endtrans %}</label>
|
||||
<input x-model="reverse" type="checkbox" name="reverse" id="reverse-checkbox">
|
||||
</div>
|
||||
<button class="btn btn-grey" @click="reset">
|
||||
{% trans %}Reset{% endtrans %}
|
||||
</button>
|
||||
<button class="btn btn-grey" @click="screenshot">
|
||||
<i class="fa fa-camera"></i>
|
||||
{% trans %}Save{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div x-ref="graph" class="graph"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const api_url = "{{ api_url }}";
|
||||
const active_user = "{{ object.id }}"
|
||||
const depth_min = {{ depth_min }};
|
||||
const depth_max = {{ depth_max }};
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Whitespace-only changes.
File renamed without changes.
@@ -0,0 +1,187 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import User
|
||||
|
||||
|
||||
class TestFetchFamilyApi(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
# Relations (A -> B means A is the godchild of B):
|
||||
# main_user -> user0 -> user3
|
||||
# -> user1 -> user6 -> user7 -> user8 -> user9
|
||||
# -> user2 -> user10
|
||||
#
|
||||
# main_user <- user3 <- user11
|
||||
# <- user12
|
||||
# <- user4 <- user13
|
||||
# <- user14
|
||||
# <- user15 <- user16
|
||||
# <- user5
|
||||
|
||||
cls.main_user = baker.make(User)
|
||||
cls.users = baker.make(User, _quantity=17)
|
||||
cls.main_user.godfathers.add(*cls.users[0:3])
|
||||
cls.main_user.godchildren.add(*cls.users[3:6])
|
||||
cls.users[1].godfathers.add(cls.users[6])
|
||||
cls.users[6].godfathers.add(cls.users[7])
|
||||
cls.users[7].godfathers.add(cls.users[8])
|
||||
cls.users[8].godfathers.add(cls.users[9])
|
||||
cls.users[2].godfathers.add(cls.users[10])
|
||||
|
||||
cls.users[3].godchildren.add(cls.users[11], cls.users[12])
|
||||
cls.users[4].godchildren.add(*cls.users[13:16])
|
||||
cls.users[15].godchildren.add(cls.users[16])
|
||||
|
||||
cls.root_user = baker.make(User, is_superuser=True)
|
||||
cls.subscriber_user = subscriber_user.make()
|
||||
|
||||
def setUp(self):
|
||||
self.maxDiff = None
|
||||
|
||||
def test_fetch_family_forbidden(self):
|
||||
# Anonymous user
|
||||
response = self.client.get(
|
||||
reverse("api:family_graph", args=[self.main_user.id])
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
self.client.force_login(baker.make(User)) # unsubscribed user
|
||||
response = self.client.get(
|
||||
reverse("api:family_graph", args=[self.main_user.id])
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_fetch_family_hidden_user(self):
|
||||
self.main_user.is_subscriber_viewable = False
|
||||
self.main_user.save()
|
||||
for user_to_login, error_code in [
|
||||
(self.main_user, 200),
|
||||
(self.subscriber_user, 403),
|
||||
(self.root_user, 200),
|
||||
]:
|
||||
self.client.force_login(user_to_login)
|
||||
response = self.client.get(
|
||||
reverse("api:family_graph", args=[self.main_user.id])
|
||||
)
|
||||
assert response.status_code == error_code
|
||||
|
||||
def test_fetch_family_with_zero_depth(self):
|
||||
"""Fetch the family with a depth of 0."""
|
||||
self.client.force_login(self.main_user)
|
||||
response = self.client.get(
|
||||
reverse("api:family_graph", args=[self.main_user.id])
|
||||
+ f"?godfathers_depth=0&godchildren_depth=0"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert [u["id"] for u in response.json()["users"]] == [self.main_user.id]
|
||||
assert response.json()["relationships"] == []
|
||||
|
||||
def test_fetch_empty_family(self):
|
||||
empty_user = baker.make(User)
|
||||
self.client.force_login(empty_user)
|
||||
response = self.client.get(reverse("api:family_graph", args=[empty_user.id]))
|
||||
assert response.status_code == 200
|
||||
assert [u["id"] for u in response.json()["users"]] == [empty_user.id]
|
||||
assert response.json()["relationships"] == []
|
||||
|
||||
def test_fetch_whole_family(self):
|
||||
self.client.force_login(self.main_user)
|
||||
response = self.client.get(
|
||||
reverse("api:family_graph", args=[self.main_user.id])
|
||||
+ f"?godfathers_depth=10&godchildren_depth=10"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert [u["id"] for u in response.json()["users"]] == [
|
||||
self.main_user.id,
|
||||
*[u.id for u in self.users],
|
||||
]
|
||||
self.assertCountEqual(
|
||||
response.json()["relationships"],
|
||||
[
|
||||
{"godfather": self.users[0].id, "godchild": self.main_user.id},
|
||||
{"godfather": self.users[1].id, "godchild": self.main_user.id},
|
||||
{"godfather": self.users[2].id, "godchild": self.main_user.id},
|
||||
{"godfather": self.main_user.id, "godchild": self.users[3].id},
|
||||
{"godfather": self.main_user.id, "godchild": self.users[4].id},
|
||||
{"godfather": self.main_user.id, "godchild": self.users[5].id},
|
||||
{"godfather": self.users[6].id, "godchild": self.users[1].id},
|
||||
{"godfather": self.users[7].id, "godchild": self.users[6].id},
|
||||
{"godfather": self.users[8].id, "godchild": self.users[7].id},
|
||||
{"godfather": self.users[9].id, "godchild": self.users[8].id},
|
||||
{"godfather": self.users[10].id, "godchild": self.users[2].id},
|
||||
{"godfather": self.users[3].id, "godchild": self.users[11].id},
|
||||
{"godfather": self.users[3].id, "godchild": self.users[12].id},
|
||||
{"godfather": self.users[4].id, "godchild": self.users[13].id},
|
||||
{"godfather": self.users[4].id, "godchild": self.users[14].id},
|
||||
{"godfather": self.users[4].id, "godchild": self.users[15].id},
|
||||
{"godfather": self.users[15].id, "godchild": self.users[16].id},
|
||||
],
|
||||
)
|
||||
|
||||
def test_fetch_family_first_level(self):
|
||||
"""Fetch only the first level of the family."""
|
||||
self.client.force_login(self.main_user)
|
||||
response = self.client.get(
|
||||
reverse("api:family_graph", args=[self.main_user.id])
|
||||
+ f"?godfathers_depth=1&godchildren_depth=1"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert [u["id"] for u in response.json()["users"]] == [
|
||||
self.main_user.id,
|
||||
*[u.id for u in self.users[:6]],
|
||||
]
|
||||
self.assertCountEqual(
|
||||
response.json()["relationships"],
|
||||
[
|
||||
{"godfather": self.users[0].id, "godchild": self.main_user.id},
|
||||
{"godfather": self.users[1].id, "godchild": self.main_user.id},
|
||||
{"godfather": self.users[2].id, "godchild": self.main_user.id},
|
||||
{"godfather": self.main_user.id, "godchild": self.users[3].id},
|
||||
{"godfather": self.main_user.id, "godchild": self.users[4].id},
|
||||
{"godfather": self.main_user.id, "godchild": self.users[5].id},
|
||||
],
|
||||
)
|
||||
|
||||
def test_fetch_family_only_godfathers(self):
|
||||
"""Fetch only the godfathers."""
|
||||
self.client.force_login(self.main_user)
|
||||
response = self.client.get(
|
||||
reverse("api:family_graph", args=[self.main_user.id])
|
||||
+ f"?godfathers_depth=10&godchildren_depth=0"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert [u["id"] for u in response.json()["users"]] == [
|
||||
self.main_user.id,
|
||||
*[u.id for u in self.users[:3]],
|
||||
*[u.id for u in self.users[6:11]],
|
||||
]
|
||||
self.assertCountEqual(
|
||||
response.json()["relationships"],
|
||||
[
|
||||
{"godfather": self.users[0].id, "godchild": self.main_user.id},
|
||||
{"godfather": self.users[1].id, "godchild": self.main_user.id},
|
||||
{"godfather": self.users[2].id, "godchild": self.main_user.id},
|
||||
{"godfather": self.users[6].id, "godchild": self.users[1].id},
|
||||
{"godfather": self.users[7].id, "godchild": self.users[6].id},
|
||||
{"godfather": self.users[8].id, "godchild": self.users[7].id},
|
||||
{"godfather": self.users[9].id, "godchild": self.users[8].id},
|
||||
{"godfather": self.users[10].id, "godchild": self.users[2].id},
|
||||
],
|
||||
)
|
||||
|
||||
def test_nb_queries(self):
|
||||
# The number of queries should be 1 per level of existing depth.
|
||||
with self.assertNumQueries(0):
|
||||
self.main_user.get_family(godfathers_depth=0, godchildren_depth=0)
|
||||
with self.assertNumQueries(3):
|
||||
self.main_user.get_family(godfathers_depth=3, godchildren_depth=0)
|
||||
with self.assertNumQueries(3):
|
||||
self.main_user.get_family(godfathers_depth=0, godchildren_depth=3)
|
||||
with self.assertNumQueries(6):
|
||||
self.main_user.get_family(godfathers_depth=3, godchildren_depth=3)
|
||||
with self.assertNumQueries(4):
|
||||
# If a level is empty, the next ones should not be queried.
|
||||
self.main_user.get_family(godfathers_depth=0, godchildren_depth=10)
|
||||
@@ -112,11 +112,6 @@ urlpatterns = [
|
||||
UserGodfathersTreeView.as_view(),
|
||||
name="user_godfathers_tree",
|
||||
),
|
||||
path(
|
||||
"user/<int:user_id>/godfathers/tree/pict/",
|
||||
UserGodfathersTreePictureView.as_view(),
|
||||
name="user_godfathers_tree_pict",
|
||||
),
|
||||
path(
|
||||
"user/<int:user_id>/godfathers/<int:godfather_id>/<bool:is_father>/delete/",
|
||||
delete_user_godfather,
|
||||
|
||||
+32
-1
@@ -335,9 +335,40 @@ class UserGodfathersForm(forms.Form):
|
||||
label=_("Add"),
|
||||
)
|
||||
user = AutoCompleteSelectField(
|
||||
"users", required=True, label=_("Select user"), help_text=None
|
||||
"users", required=True, label=_("Select user"), help_text=""
|
||||
)
|
||||
|
||||
def __init__(self, *args, user: User, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.target_user = user
|
||||
|
||||
def clean_user(self):
|
||||
other_user = self.cleaned_data.get("user")
|
||||
if not other_user:
|
||||
raise ValidationError(_("This user does not exist"))
|
||||
if other_user == self.target_user:
|
||||
raise ValidationError(_("You cannot be related to yourself"))
|
||||
return other_user
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if not self.is_valid():
|
||||
return self.cleaned_data
|
||||
other_user = self.cleaned_data["user"]
|
||||
if self.cleaned_data["type"] == "godfather":
|
||||
if self.target_user.godfathers.contains(other_user):
|
||||
self.add_error(
|
||||
"user",
|
||||
_("%s is already your godfather") % (other_user.get_short_name()),
|
||||
)
|
||||
else:
|
||||
if self.target_user.godchildren.contains(other_user):
|
||||
self.add_error(
|
||||
"user",
|
||||
_("%s is already your godchild") % (other_user.get_short_name()),
|
||||
)
|
||||
return self.cleaned_data
|
||||
|
||||
|
||||
class PagePropForm(forms.ModelForm):
|
||||
error_css_class = "error"
|
||||
|
||||
+19
-97
@@ -34,7 +34,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.forms import CheckboxSelectMultiple
|
||||
from django.forms.models import modelform_factory
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.loader import render_to_string
|
||||
from django.template.response import TemplateResponse
|
||||
@@ -323,7 +323,7 @@ def delete_user_godfather(request, user_id, godfather_id, is_father):
|
||||
return redirect("core:user_godfathers", user_id=user_id)
|
||||
|
||||
|
||||
class UserGodfathersView(UserTabsMixin, CanViewMixin, DetailView):
|
||||
class UserGodfathersView(UserTabsMixin, CanViewMixin, DetailView, FormView):
|
||||
"""Display a user's godfathers."""
|
||||
|
||||
model = User
|
||||
@@ -331,27 +331,23 @@ class UserGodfathersView(UserTabsMixin, CanViewMixin, DetailView):
|
||||
context_object_name = "profile"
|
||||
template_name = "core/user_godfathers.jinja"
|
||||
current_tab = "godfathers"
|
||||
form_class = UserGodfathersForm
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
self.form = UserGodfathersForm(request.POST)
|
||||
if self.form.is_valid() and self.form.cleaned_data["user"] != self.object:
|
||||
if self.form.cleaned_data["type"] == "godfather":
|
||||
self.object.godfathers.add(self.form.cleaned_data["user"])
|
||||
self.object.save()
|
||||
else:
|
||||
self.object.godchildren.add(self.form.cleaned_data["user"])
|
||||
self.object.save()
|
||||
self.form = UserGodfathersForm()
|
||||
return super().get(request, *args, **kwargs)
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {"user": self.object}
|
||||
|
||||
def form_valid(self, form):
|
||||
if form.cleaned_data["type"] == "godfather":
|
||||
self.object.godfathers.add(form.cleaned_data["user"])
|
||||
else:
|
||||
self.object.godchildren.add(form.cleaned_data["user"])
|
||||
return redirect("core:user_godfathers", user_id=self.object.id)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
try:
|
||||
kwargs["form"] = self.form
|
||||
except:
|
||||
kwargs["form"] = UserGodfathersForm()
|
||||
return kwargs
|
||||
return super().get_context_data(**kwargs) | {
|
||||
"godfathers": list(self.object.godfathers.select_related("profile_pict")),
|
||||
"godchildren": list(self.object.godchildren.select_related("profile_pict")),
|
||||
}
|
||||
|
||||
|
||||
class UserGodfathersTreeView(UserTabsMixin, CanViewMixin, DetailView):
|
||||
@@ -365,86 +361,12 @@ class UserGodfathersTreeView(UserTabsMixin, CanViewMixin, DetailView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
if "descent" in self.request.GET:
|
||||
kwargs["param"] = "godchildren"
|
||||
else:
|
||||
kwargs["param"] = "godfathers"
|
||||
kwargs["members_set"] = set()
|
||||
kwargs["api_url"] = reverse(
|
||||
"api:family_graph", kwargs={"user_id": self.object.id}
|
||||
)
|
||||
return kwargs
|
||||
|
||||
|
||||
class UserGodfathersTreePictureView(CanViewMixin, DetailView):
|
||||
"""Display a user's tree as a picture."""
|
||||
|
||||
model = User
|
||||
pk_url_kwarg = "user_id"
|
||||
|
||||
def build_complex_graph(self):
|
||||
import pygraphviz as pgv
|
||||
|
||||
self.depth = int(self.request.GET.get("depth", 4))
|
||||
if self.param == "godfathers":
|
||||
self.graph = pgv.AGraph(strict=False, directed=True, rankdir="BT")
|
||||
else:
|
||||
self.graph = pgv.AGraph(strict=False, directed=True)
|
||||
family = set()
|
||||
self.level = 1
|
||||
|
||||
# Since the tree isn't very deep, we can build it recursively
|
||||
def crawl_family(user):
|
||||
if self.level > self.depth:
|
||||
return
|
||||
self.level += 1
|
||||
for u in user.__getattribute__(self.param).all():
|
||||
self.graph.add_edge(user.get_short_name(), u.get_short_name())
|
||||
if u not in family:
|
||||
family.add(u)
|
||||
crawl_family(u)
|
||||
self.level -= 1
|
||||
|
||||
self.graph.add_node(self.object.get_short_name())
|
||||
family.add(self.object)
|
||||
crawl_family(self.object)
|
||||
|
||||
def build_family_graph(self):
|
||||
import pygraphviz as pgv
|
||||
|
||||
self.graph = pgv.AGraph(strict=False, directed=True)
|
||||
self.graph.add_node(self.object.get_short_name())
|
||||
for u in self.object.godfathers.all():
|
||||
self.graph.add_edge(u.get_short_name(), self.object.get_short_name())
|
||||
for u in self.object.godchildren.all():
|
||||
self.graph.add_edge(self.object.get_short_name(), u.get_short_name())
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
if "descent" in self.request.GET:
|
||||
self.param = "godchildren"
|
||||
elif "ancestors" in self.request.GET:
|
||||
self.param = "godfathers"
|
||||
else:
|
||||
self.param = "family"
|
||||
|
||||
if self.param == "family":
|
||||
self.build_family_graph()
|
||||
else:
|
||||
self.build_complex_graph()
|
||||
# Pimp the graph before display
|
||||
self.graph.node_attr["color"] = "lightblue"
|
||||
self.graph.node_attr["style"] = "filled"
|
||||
main_node = self.graph.get_node(self.object.get_short_name())
|
||||
main_node.attr["color"] = "sandybrown"
|
||||
main_node.attr["shape"] = "rect"
|
||||
if self.param == "godchildren":
|
||||
self.graph.graph_attr["label"] = _("Godchildren")
|
||||
elif self.param == "godfathers":
|
||||
self.graph.graph_attr["label"] = _("Family")
|
||||
else:
|
||||
self.graph.graph_attr["label"] = _("Family")
|
||||
img = self.graph.draw(format="png", prog="dot")
|
||||
return HttpResponse(img, content_type="image/png")
|
||||
|
||||
|
||||
class UserStatsView(UserTabsMixin, CanViewMixin, DetailView):
|
||||
"""Display a user's stats."""
|
||||
|
||||
|
||||
Reference in new issue
Block a user