4 Commits

Author SHA1 Message Date
imperosol
32f63fb57f include only subscribed users and their direct relations in the galaxy 2025-09-11 19:49:04 +02:00
thomas girod
b767079c5a Merge pull request #1167 from ae-utbm/page-n+1
Page n+1
2025-09-08 11:28:55 +02:00
imperosol
37961e437b fix: N+1 queries on PageListView 2025-09-04 17:39:17 +02:00
imperosol
b97a1a2e56 improve User.can_view and User.can_edit 2025-09-04 17:38:58 +02:00
11 changed files with 235 additions and 247 deletions

View File

@@ -560,7 +560,7 @@ class User(AbstractUser):
"""Determine if the object is owned by the user."""
if hasattr(obj, "is_owned_by") and obj.is_owned_by(self):
return True
if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group.id):
if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group_id):
return True
return self.is_root
@@ -569,9 +569,15 @@ class User(AbstractUser):
if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self):
return True
if hasattr(obj, "edit_groups"):
for pk in obj.edit_groups.values_list("pk", flat=True):
if self.is_in_group(pk=pk):
return True
if (
hasattr(obj, "_prefetched_objects_cache")
and "edit_groups" in obj._prefetched_objects_cache
):
pks = [g.id for g in obj.edit_groups.all()]
else:
pks = list(obj.edit_groups.values_list("id", flat=True))
if any(self.is_in_group(pk=pk) for pk in pks):
return True
if isinstance(obj, User) and obj == self:
return True
return self.is_owner(obj)
@@ -581,9 +587,18 @@ class User(AbstractUser):
if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self):
return True
if hasattr(obj, "view_groups"):
for pk in obj.view_groups.values_list("pk", flat=True):
if self.is_in_group(pk=pk):
return True
# if "view_groups" has already been prefetched, use
# the prefetch cache, else fetch only the ids, to make
# the query lighter.
if (
hasattr(obj, "_prefetched_objects_cache")
and "view_groups" in obj._prefetched_objects_cache
):
pks = [g.id for g in obj.view_groups.all()]
else:
pks = list(obj.view_groups.values_list("id", flat=True))
if any(self.is_in_group(pk=pk) for pk in pks):
return True
return self.can_edit(obj)
def can_be_edited_by(self, user):
@@ -1384,9 +1399,9 @@ class Page(models.Model):
@cached_property
def is_club_page(self):
club_root_page = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first()
return club_root_page is not None and (
self == club_root_page or club_root_page in self.get_parent_list()
return (
self.name == settings.SITH_CLUB_ROOT_PAGE
or settings.SITH_CLUB_ROOT_PAGE in [p.name for p in self.get_parent_list()]
)
@cached_property

View File

@@ -5,16 +5,12 @@
{% endblock %}
{% block content %}
{% if page_list %}
<h3>{% trans %}Page list{% endtrans %}</h3>
<ul>
{% for p in page_list %}
<li><a href="{{ p.get_absolute_url() }}">{{ p.get_display_name() }}</a></li>
{% endfor %}
</ul>
{% else %}
{% trans %}There is no page in this website.{% endtrans %}
{% endif %}
<h3>{% trans %}Page list{% endtrans %}</h3>
<ul>
{% for p in page_list %}
<li><a href="{{ p.get_absolute_url() }}">{{ p.display_name }}</a></li>
{% endfor %}
</ul>
{% endblock %}

View File

@@ -12,7 +12,10 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import F, OuterRef, Subquery
from django.db.models.functions import Coalesce
# This file contains all the views that concern the page model
from django.forms.models import modelform_factory
@@ -43,6 +46,20 @@ class CanEditPagePropMixin(CanEditPropMixin):
class PageListView(CanViewMixin, ListView):
model = Page
template_name = "core/page_list.jinja"
queryset = (
Page.objects.annotate(
display_name=Coalesce(
Subquery(
PageRev.objects.filter(page=OuterRef("id"))
.order_by("-date")
.values("title")[:1]
),
F("name"),
)
)
.prefetch_related("view_groups")
.select_related("parent")
)
class PageView(CanViewMixin, DetailView):

View File

@@ -45,8 +45,9 @@ class Command(BaseCommand):
"verbosity level should be between 0 and 2 included", stacklevel=2
)
if options["verbosity"] == 2:
if options["verbosity"] >= 2:
logger.setLevel(logging.DEBUG)
logging.getLogger("django.db.backends").setLevel(logging.DEBUG)
elif options["verbosity"] == 1:
logger.setLevel(logging.INFO)
else:
@@ -59,6 +60,3 @@ class Command(BaseCommand):
Galaxy.objects.filter(state__isnull=True).delete()
logger.info("Ruled the galaxy in {} queries.".format(len(connection.queries)))
if options["verbosity"] > 2:
for q in connection.queries:
logger.debug(q)

View File

@@ -31,13 +31,14 @@ from collections import defaultdict
from typing import NamedTuple, TypedDict
from django.db import models
from django.db.models import Count, F, Q, QuerySet
from django.utils.timezone import localdate
from django.db.models import Count, Exists, F, OuterRef, Q, QuerySet
from django.utils.timezone import localdate, now
from django.utils.translation import gettext_lazy as _
from club.models import Membership
from core.models import User
from sas.models import PeoplePictureRelation, Picture
from subscription.models import Subscription
class GalaxyStar(models.Model):
@@ -198,8 +199,16 @@ class Galaxy(models.Model):
cls, picture_count_threshold: int = DEFAULT_PICTURE_COUNT_THRESHOLD
) -> QuerySet[User]:
return (
User.objects.exclude(subscriptions=None)
.annotate(pictures_count=Count("pictures"))
User.objects.filter(is_subscriber_viewable=True)
.exclude(subscriptions=None)
.annotate(
pictures_count=Count("pictures"),
is_active_in_galaxy=Exists(
Subscription.objects.filter(
member=OuterRef("id"), subscription_end__gt=now()
)
),
)
.filter(pictures_count__gt=picture_count_threshold)
.distinct()
)
@@ -290,9 +299,9 @@ class Galaxy(models.Model):
31/12/2022 (also two years, but with an offset of one year), then their
club score is 365.
"""
memberships = user.memberships.only("start_date", "end_date", "club_id")
memberships = user.memberships.values("start_date", "end_date", "club_id")
result = defaultdict(int)
now = localdate()
today = localdate()
for membership in memberships:
# This is a N+1 query, but 92% of galaxy users have less than 10 memberships.
# Only 5 users have more than 30 memberships.
@@ -300,23 +309,23 @@ class Galaxy(models.Model):
Membership.objects.exclude(user=user)
.filter(
Q( # start2 <= start1 <= end2
start_date__lte=membership.start_date,
end_date__gte=membership.start_date,
start_date__lte=membership["start_date"],
end_date__gte=membership["start_date"],
)
| Q( # start2 <= start1 <= now
start_date__lte=membership.start_date, end_date=None
| Q( # start2 <= start1 <= today
start_date__lte=membership["start_date"], end_date=None
)
| Q( # start1 <= start2 <= end2
start_date__gte=membership.start_date,
start_date__lte=membership.end_date or now,
start_date__gte=membership["start_date"],
start_date__lte=membership["end_date"] or today,
),
club_id=membership.club_id,
club_id=membership["club_id"],
)
.only("start_date", "end_date", "user_id")
)
for other in common_memberships:
start = max(membership.start_date, other.start_date)
end = min(membership.end_date or now, other.end_date or now)
start = max(membership["start_date"], other.start_date)
end = min(membership["end_date"] or today, other.end_date or today)
result[other.user_id] += (end - start).days * cls.CLUBS_POINTS
return result
@@ -382,18 +391,22 @@ class Galaxy(models.Model):
# this is memory expensive but prevents a lot of db hits, therefore
# is far more time efficient
rulable_users = list(self.get_rulable_users(picture_count_threshold))
rulable_users_count = len(rulable_users)
rulable_users_qs = self.get_rulable_users(picture_count_threshold)
active_users_count = rulable_users_qs.filter(is_active_in_galaxy=True).count()
rulable_users = list(rulable_users_qs)
user1_count = 0
self.logger.info(
f"{rulable_users_count} citizen have been listed. Starting to rule."
f" {len(rulable_users)} citizens (with {active_users_count} active ones) "
f"have been listed. Starting to rule."
)
self.logger.info("Creating stars for all citizen")
individual_scores = self.compute_individual_scores()
GalaxyStar.objects.bulk_create(
[
GalaxyStar(owner=user, galaxy=self, mass=individual_scores[user.id])
GalaxyStar(
owner_id=user.id, galaxy=self, mass=individual_scores[user.id]
)
for user in rulable_users
]
)
@@ -405,9 +418,9 @@ class Galaxy(models.Model):
t_global_start = time.time()
while len(rulable_users) > 0:
user1 = rulable_users.pop()
if not user1.is_active_in_galaxy:
continue
user1_count += 1
rulable_users_count2 = len(rulable_users)
star1 = stars[user1.id]
lanes = []
@@ -448,17 +461,20 @@ class Galaxy(models.Model):
self.logger.info("")
self.logger.info(f" Ruling of {self} ".center(60, "#"))
self.logger.info(
f"Progression: {user1_count}/{rulable_users_count} "
f"citizen -- {rulable_users_count - user1_count} remaining"
f"Progression: {user1_count}/{active_users_count} "
f"citizen -- {active_users_count - user1_count} remaining"
)
self.logger.info(f"Speed: {global_avg_speed:.2f} citizen per second")
eta = rulable_users_count2 // global_avg_speed
eta = len(rulable_users) // global_avg_speed
self.logger.info(
f"ETA: {int(eta // 60 % 60)} minutes {int(eta % 60)} seconds"
)
self.logger.info("#" * 60)
t_global_start = time.time()
count, _ = self.stars.filter(Q(lanes1=None) & Q(lanes2=None)).delete()
self.logger.info(f"{count} orphan stars have been trimmed.")
# Here, we get the IDs of the old galaxies that we'll need to delete. In normal operation, only one galaxy
# should be returned, and we can't delete it yet, as it's the one still displayed by the Sith.
old_galaxies_pks = list(

View File

@@ -0,0 +1,138 @@
import { default as ForceGraph3D } from "3d-force-graph";
import { forceX, forceY, forceZ } from "d3-force-3d";
// biome-ignore lint/style/noNamespaceImport: This is how it should be imported
import * as Three from "three";
import SpriteText from "three-spritetext";
/**
* @typedef GalaxyConfig
* @property {number} nodeId id of the current user node
* @property {string} dataUrl url to fetch the galaxy data from
**/
/**
* Load the galaxy of an user
* @param {GalaxyConfig} config
**/
window.loadGalaxy = (config) => {
window.getNodeFromId = (id) => {
return Graph.graphData().nodes.find((n) => n.id === id);
};
window.getLinksFromNodeId = (id) => {
return Graph.graphData().links.filter(
(l) => l.source.id === id || l.target.id === id,
);
};
window.focusNode = (node) => {
highlightNodes.clear();
highlightLinks.clear();
hoverNode = node || null;
if (node) {
// collect neighbors and links for highlighting
for (const link of window.getLinksFromNodeId(node.id)) {
highlightLinks.add(link);
highlightNodes.add(link.source);
highlightNodes.add(link.target);
}
}
// refresh node and link display
Graph.nodeThreeObject(Graph.nodeThreeObject())
.linkWidth(Graph.linkWidth())
.linkDirectionalParticles(Graph.linkDirectionalParticles());
// Aim at node from outside it
const distance = 42;
const distRatio = 1 + distance / Math.hypot(node.x, node.y, node.z);
const newPos =
node.x || node.y || node.z
? { x: node.x * distRatio, y: node.y * distRatio, z: node.z * distRatio }
: { x: 0, y: 0, z: distance }; // special case if node is in (0,0,0)
Graph.cameraPosition(
newPos, // new position
node, // lookAt ({ x, y, z })
3000, // ms transition duration
);
};
const highlightNodes = new Set();
const highlightLinks = new Set();
let hoverNode = null;
const grpahDiv = document.getElementById("3d-graph");
const Graph = ForceGraph3D();
Graph(grpahDiv);
Graph.jsonUrl(config.dataUrl)
.width(
grpahDiv.parentElement.clientWidth > 1200
? 1200
: grpahDiv.parentElement.clientWidth,
) // Not perfect at all. JS-fu master from the future, please fix this :-)
.height(1000)
.enableNodeDrag(false) // allow easier navigation
.onNodeClick((node) => {
const camera = Graph.cameraPosition();
const distance = Math.sqrt(
(node.x - camera.x) ** 2 + (node.y - camera.y) ** 2 + (node.z - camera.z) ** 2,
);
if (distance < 120 || highlightNodes.has(node)) {
window.focusNode(node);
}
})
.linkWidth((link) => (highlightLinks.has(link) ? 0.4 : 0.0))
.linkColor((link) =>
highlightLinks.has(link) ? "rgba(255,160,0,1)" : "rgba(128,255,255,0.6)",
)
.linkVisibility((link) => highlightLinks.has(link))
.nodeVisibility((node) => highlightNodes.has(node) || node.mass > 4)
// .linkDirectionalParticles(link => highlightLinks.has(link) ? 3 : 1) // kinda buggy for now, and slows this a bit, but would be great to help visualize lanes
.linkDirectionalParticleWidth(0.2)
.linkDirectionalParticleSpeed(-0.006)
.nodeThreeObject((node) => {
const sprite = new SpriteText(node.name);
sprite.material.depthWrite = false; // make sprite background transparent
sprite.color = highlightNodes.has(node)
? node === hoverNode
? "rgba(200,0,0,1)"
: "rgba(255,160,0,0.8)"
: "rgba(0,255,255,0.2)";
sprite.textHeight = 2;
sprite.center = new Three.Vector2(1.2, 0.5);
return sprite;
})
.onEngineStop(() => {
window.focusNode(window.getNodeFromId(config.nodeId));
Graph.onEngineStop(() => {
/* nope */
}); // don't call ourselves in a loop while moving the focus
});
// Set distance between stars
Graph.d3Force("link").distance((link) => link.value);
// Set high masses nearer the center of the galaxy
// TODO: quick and dirty strength computation, this will need tuning.
Graph.d3Force(
"positionX",
forceX().strength((node) => {
return 1 - 1 / node.mass;
}),
);
Graph.d3Force(
"positionY",
forceY().strength((node) => {
return 1 - 1 / node.mass;
}),
);
Graph.d3Force(
"positionZ",
forceZ().strength((node) => {
return 1 - 1 / node.mass;
}),
);
};

View File

@@ -1,137 +0,0 @@
import { exportToHtml } from "#core:utils/globals";
import cytoscape from "cytoscape";
import d3Force, { type D3ForceLayoutOptions } from "cytoscape-d3-force";
cytoscape.use(d3Force);
interface GalaxyConfig {
nodeId: number;
dataUrl: string;
}
async function getGraphData(dataUrl: string) {
const response = await fetch(dataUrl);
if (!response.ok) {
return [];
}
const content = await response.json();
const nodes = content.nodes.map((node, i) => {
return {
group: "nodes",
data: {
id: node.id,
name: node.name,
mass: node.mass,
},
};
});
const edges = content.links.map((link) => {
return {
group: "edges",
data: {
id: `edge_${link.source}_${link.value}`,
source: link.source,
target: link.target,
value: link.value,
},
};
});
return { nodes: nodes, edges: edges };
}
exportToHtml("loadGalaxy", async (config: GalaxyConfig) => {
const graphDiv = document.getElementById("3d-graph");
const elements = await getGraphData(config.dataUrl);
const cy = cytoscape({
container: graphDiv,
elements: elements,
style: [
{
selector: "node",
style: {
label: "data(name)",
"background-color": "red",
},
},
{
selector: ".focused",
style: {
"border-width": "5px",
"border-style": "solid",
"border-color": "black",
"target-arrow-color": "black",
"line-color": "black",
},
},
{
selector: "edge",
style: {
width: 0.1,
},
},
{
selector: ".direct",
style: {
width: "5px",
"line-color": "red",
},
},
],
layout: {
name: "d3-force",
animate: true,
fit: false,
ungrabifyWhileSimulating: true,
fixedAfterDragging: true,
linkId: (node) => {
return node.id;
},
linkDistance: (link) => {
return elements.nodes.length * 10;
},
linkStrength: (link) => {
return 1 / Math.max(1, link?.value);
},
linkIterations: 10,
manyBodyStrength: (node) => {
return node?.mass;
},
// manyBodyDistanceMin: 500,
collideRadius: () => {
return 50;
},
ready: (e) => {
// Center on current user node at the start of the simulation
// Color all direct paths from that citizen to it's neighbor
const citizen = e.cy.nodes(`#${config.nodeId}`)[0];
citizen.addClass("focused");
citizen.connectedEdges().addClass("direct");
e.cy.center(citizen);
},
tick: () => {
// Center on current user node during simulation
const citizen = cy.nodes(`#${config.nodeId}`)[0];
cy.center(citizen);
},
stop: (e) => {
// Disable user grabbing of nodes
// This has to be disabled after the simulation is done
// Otherwise the simulation can't move nodes
e.cy.autolock(true);
},
} as D3ForceLayoutOptions,
});
});

View File

@@ -5,14 +5,14 @@
{% endblock %}
{% block additional_js %}
<script type="module" src="{{ static('bundled/galaxy/galaxy-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/galaxy/galaxy-index.js') }}"></script>
{% endblock %}
{% block content %}
{% if object.current_star %}
<div style="display: flex; flex-wrap: wrap;">
<div style="width: 100%; height: 70vh; display: block" id="3d-graph"></div>
<div id="3d-graph"></div>
<div style="margin: 1em;">
<p><a onclick="window.focusNode(window.getNodeFromId({{ object.id }}))">Reset on {{ object.get_display_name() }}</a></p>

View File

@@ -122,7 +122,7 @@ class TestGalaxyModel(TestCase):
self.com,
]
with self.assertNumQueries(44):
with self.assertNumQueries(38):
while len(users) > 0:
user1 = users.pop(0)
family_scores = Galaxy.compute_user_family_score(user1)
@@ -150,7 +150,7 @@ class TestGalaxyModel(TestCase):
that the number of queries to rule the galaxy is stable.
"""
galaxy = Galaxy.objects.create()
with self.assertNumQueries(39):
with self.assertNumQueries(36):
galaxy.rule(0) # We want everybody here

59
package-lock.json generated
View File

@@ -25,7 +25,6 @@
"country-flag-emoji-polyfill": "^0.1.8",
"cytoscape": "^3.30.2",
"cytoscape-cxtmenu": "^3.5.0",
"cytoscape-d3-force": "^1.1.4",
"cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.5",
"easymde": "^2.19.0",
@@ -47,7 +46,6 @@
"@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.10",
"@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-d3-force": "^1.0.0",
"@types/cytoscape-klay": "^3.1.4",
"@types/jquery": "^3.5.31",
"@types/js-cookie": "^3.0.6",
@@ -2875,16 +2873,6 @@
"@types/cytoscape": "*"
}
},
"node_modules/@types/cytoscape-d3-force": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/cytoscape-d3-force/-/cytoscape-d3-force-1.0.0.tgz",
"integrity": "sha512-1eRd9xr/DvJ4MIA5lCEG8DMX2Ha87qAbpP7irpuKZun0ZCBQPpoOBo9mPl0WrkJbXH+hHwG8s3E2CpUz3HxLrw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/cytoscape": "^3.0.9"
}
},
"node_modules/@types/cytoscape-klay": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@types/cytoscape-klay/-/cytoscape-klay-3.1.4.tgz",
@@ -3542,18 +3530,6 @@
"cytoscape": "^3.2.0"
}
},
"node_modules/cytoscape-d3-force": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/cytoscape-d3-force/-/cytoscape-d3-force-1.1.4.tgz",
"integrity": "sha512-8NjI/yEoB3YqVsdf7ud7Oh8Kyi+C9Lhh1fICmtemIo6EC1ZUtm8KcPNLkQySYO8nRS2mQKj5eVdCr7W0L8ONoQ==",
"license": "MIT",
"dependencies": {
"d3-force": "^2.0.1"
},
"peerDependencies": {
"cytoscape": "^3.2.0"
}
},
"node_modules/cytoscape-klay": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/cytoscape-klay/-/cytoscape-klay-3.1.4.tgz",
@@ -3602,17 +3578,6 @@
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-2.1.1.tgz",
"integrity": "sha512-nAuHEzBqMvpFVMf9OX75d00OxvOXdxY+xECIXjW6Gv8BRrXu6gAWbv/9XKrvfJ5i5DCokDW7RYE50LRoK092ew==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-dispatch": "1 - 2",
"d3-quadtree": "1 - 2",
"d3-timer": "1 - 2"
}
},
"node_modules/d3-force-3d": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz",
@@ -3629,24 +3594,6 @@
"node": ">=12"
}
},
"node_modules/d3-force/node_modules/d3-dispatch": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz",
"integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==",
"license": "BSD-3-Clause"
},
"node_modules/d3-force/node_modules/d3-quadtree": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-2.0.0.tgz",
"integrity": "sha512-b0Ed2t1UUalJpc3qXzKi+cPGxeXRr4KU9YSlocN74aTzp6R/Ud43t79yLLqxHRWZfsvWXmbDWPpoENK1K539xw==",
"license": "BSD-3-Clause"
},
"node_modules/d3-force/node_modules/d3-timer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz",
"integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==",
"license": "BSD-3-Clause"
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
@@ -5790,9 +5737,9 @@
}
},
"node_modules/vite": {
"version": "6.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -31,7 +31,6 @@
"@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.10",
"@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-d3-force": "^1.0.0",
"@types/cytoscape-klay": "^3.1.4",
"@types/jquery": "^3.5.31",
"@types/js-cookie": "^3.0.6",
@@ -57,7 +56,6 @@
"country-flag-emoji-polyfill": "^0.1.8",
"cytoscape": "^3.30.2",
"cytoscape-cxtmenu": "^3.5.0",
"cytoscape-d3-force": "^1.1.4",
"cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.5",
"easymde": "^2.19.0",