feat: Replace graphviz with d3 (#4067)
* feat: Use d3 for doc dependencies * Interim commit * Progress * Progress * Auto pan and zoom * Arrows * Remove graphviz and the code that uses it * More graphviz-related changes * Interim commit * Move things into place * Add test * Final touches * Make SVG work in Chrome * Get the docs more similarly to how the group doc page does it * Reindent * Add ability to download the SVG, and use bs fonts. * Follow @rjsparks' advice on how to compute the reference list * Interim commit * Add legend * Speed up simulation * Fix tooltips * fix: escape a period in a new url regex Co-authored-by: Robert Sparks <rjsparks@nostrum.com>
This commit is contained in:
parent
fcbd5e418f
commit
e465f1f0f0
|
@ -1,22 +0,0 @@
|
|||
#!/bin/sh
|
||||
#
|
||||
# Copyright The IETF Trust 2007, All Rights Reserved
|
||||
#
|
||||
# Requires modelviz.py from
|
||||
# http://code.djangoproject.com/wiki/DjangoGraphviz
|
||||
#
|
||||
cd ${0%/*}/../ietf
|
||||
PYTHONPATH=`dirname $PWD`:`dirname $PWD/..`
|
||||
export PYTHONPATH
|
||||
module=${PWD##*/}
|
||||
DJANGO_SETTINGS_MODULE=$module.settings
|
||||
export DJANGO_SETTINGS_MODULE
|
||||
for d in *
|
||||
do
|
||||
if grep models.Model $d/models.py > /dev/null 2>&1
|
||||
then
|
||||
models="$models $d"
|
||||
fi
|
||||
done
|
||||
modelviz.py $* $models > models.dot
|
||||
dot -Tpng models.dot > models.png
|
|
@ -32,7 +32,6 @@ RUN apt-get update --fix-missing && apt-get install -qy \
|
|||
ghostscript \
|
||||
git \
|
||||
gnupg \
|
||||
graphviz \
|
||||
jq \
|
||||
less \
|
||||
libcairo2-dev \
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
#!/bin/sh
|
||||
#
|
||||
# Copyright The IETF Trust 2007, All Rights Reserved
|
||||
#
|
||||
# Requires modelviz.py from
|
||||
# http://code.djangoproject.com/wiki/DjangoGraphviz
|
||||
#
|
||||
PYTHONPATH=`dirname $PWD`
|
||||
export PYTHONPATH
|
||||
DJANGO_SETTINGS_MODULE=ietf.settings
|
||||
export DJANGO_SETTINGS_MODULE
|
||||
for d in *
|
||||
do
|
||||
if grep models.Model $d/models.py > /dev/null 2>&1
|
||||
then
|
||||
python modelviz.py $d
|
||||
fi
|
||||
done > models-base.dot
|
||||
unflatten -f -l 10 models-base.dot | gvpr -c '
|
||||
BEG_G {
|
||||
node_t title = node($G, "title");
|
||||
title.shape="parallelogram";
|
||||
string model = $G.name;
|
||||
model = sub(model, "^ietf.");
|
||||
model = sub(model, ".models$");
|
||||
title.label = model;
|
||||
title.fontsize = 24;
|
||||
}
|
||||
' > models.dot
|
||||
dot -Tps -Gsize=10.5,8.0 -Gmargin=0.25 -Gratio=auto -Grotate=90 models.dot | sed -e 's/ Bold/-Bold/' > models.ps
|
|
@ -1,133 +0,0 @@
|
|||
# Copyright The IETF Trust 2017-2020, All Rights Reserved
|
||||
# -*- coding: utf-8 -*-
|
||||
# -*- check-flake8 -*-
|
||||
|
||||
|
||||
from django.db.models import Q
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from ietf.doc.models import RelatedDocument
|
||||
|
||||
|
||||
class Edge(object):
|
||||
def __init__(self, relateddocument):
|
||||
self.relateddocument = relateddocument
|
||||
|
||||
def __hash__(self):
|
||||
return hash("|".join([str(hash(nodename(self.relateddocument.source.name))),
|
||||
str(hash(nodename(self.relateddocument.target.document.name))),
|
||||
self.relateddocument.relationship.slug]))
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.__hash__() == other.__hash__()
|
||||
|
||||
def sourcename(self):
|
||||
return nodename(self.relateddocument.source.name)
|
||||
|
||||
def targetname(self):
|
||||
return nodename(self.relateddocument.target.document.name)
|
||||
|
||||
def styles(self):
|
||||
|
||||
# Note that the old style=dotted, color=red styling is never used
|
||||
|
||||
if self.relateddocument.is_downref():
|
||||
return { 'color': 'red', 'arrowhead': 'normalnormal' }
|
||||
else:
|
||||
styles = { 'refnorm' : { 'color': 'blue' },
|
||||
'refinfo' : { 'color': 'green' },
|
||||
'refold' : { 'color': 'orange' },
|
||||
'refunk' : { 'style': 'dashed' },
|
||||
'replaces': { 'color': 'pink', 'style': 'dashed', 'arrowhead': 'diamond' },
|
||||
}
|
||||
return styles[self.relateddocument.relationship.slug]
|
||||
|
||||
|
||||
def nodename(name):
|
||||
return name.replace('-', '_')
|
||||
|
||||
|
||||
def get_node_styles(node, group):
|
||||
|
||||
styles = dict()
|
||||
|
||||
# Shape and style (note that old diamond shape is never used
|
||||
|
||||
styles['style'] = 'filled'
|
||||
|
||||
if node.get_state('draft').slug == 'rfc':
|
||||
styles['shape'] = 'box'
|
||||
elif not node.get_state('draft-iesg').slug in ['idexists', 'watching', 'dead']:
|
||||
styles['shape'] = 'parallelogram'
|
||||
elif node.get_state('draft').slug == 'expired':
|
||||
styles['shape'] = 'house'
|
||||
styles['style'] = 'solid'
|
||||
styles['peripheries'] = 3
|
||||
elif node.get_state('draft').slug == 'repl':
|
||||
styles['shape'] = 'ellipse'
|
||||
styles['style'] = 'solid'
|
||||
styles['peripheries'] = 3
|
||||
else:
|
||||
pass # quieter form of styles['shape'] = 'ellipse'
|
||||
|
||||
# Color (note that the old 'Flat out red' is never used
|
||||
if node.group.acronym == 'none':
|
||||
styles['color'] = '"#FF800D"' # orangeish
|
||||
elif node.group == group:
|
||||
styles['color'] = '"#0AFE47"' # greenish
|
||||
else:
|
||||
styles['color'] = '"#9999FF"' # blueish
|
||||
|
||||
# Label
|
||||
label = node.name
|
||||
if label.startswith('draft-'):
|
||||
if label.startswith('draft-ietf-'):
|
||||
label = label[11:]
|
||||
else:
|
||||
label = label[6:]
|
||||
try:
|
||||
t = label.index('-')
|
||||
label = r"%s\n%s" % (label[:t], label[t+1:])
|
||||
except:
|
||||
pass
|
||||
if node.group.acronym != 'none' and node.group != group:
|
||||
label = "(%s) %s" % (node.group.acronym, label)
|
||||
if node.get_state('draft').slug == 'rfc':
|
||||
label = "%s\\n(%s)" % (label, node.canonical_name())
|
||||
styles['label'] = '"%s"' % label
|
||||
|
||||
return styles
|
||||
|
||||
|
||||
def make_dot(group):
|
||||
references = Q(source__group=group, source__type='draft', relationship__slug__startswith='ref')
|
||||
both_rfcs = Q(source__states__slug='rfc', target__docs__states__slug='rfc')
|
||||
inactive = Q(source__states__slug__in=['expired', 'repl'])
|
||||
attractor = Q(target__name__in=['rfc5000', 'rfc5741'])
|
||||
removed = Q(source__states__slug__in=['auth-rm', 'ietf-rm'])
|
||||
relations = ( RelatedDocument.objects.filter(references).exclude(both_rfcs)
|
||||
.exclude(inactive).exclude(attractor).exclude(removed) )
|
||||
|
||||
edges = set()
|
||||
for x in relations:
|
||||
target_state = x.target.document.get_state_slug('draft')
|
||||
if target_state != 'rfc' or x.is_downref():
|
||||
edges.add(Edge(x))
|
||||
|
||||
replacements = RelatedDocument.objects.filter(relationship__slug='replaces',
|
||||
target__docs__in=[x.relateddocument.target.document for x in edges])
|
||||
|
||||
for x in replacements:
|
||||
edges.add(Edge(x))
|
||||
|
||||
nodes = set([x.relateddocument.source for x in edges]).union([x.relateddocument.target.document for x in edges])
|
||||
|
||||
for node in nodes:
|
||||
node.nodename = nodename(node.name)
|
||||
node.styles = get_node_styles(node, group)
|
||||
|
||||
return render_to_string('group/dot.txt',
|
||||
dict( nodes=nodes, edges=edges )
|
||||
)
|
||||
|
||||
|
|
@ -4,8 +4,8 @@
|
|||
import io
|
||||
import os
|
||||
import datetime
|
||||
import json
|
||||
|
||||
from unittest import skipIf
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
from django.core.management import call_command
|
||||
|
@ -21,25 +21,10 @@ from ietf.doc.models import DocEvent, RelatedDocument
|
|||
from ietf.group.models import Role, Group
|
||||
from ietf.group.utils import get_group_role_emails, get_child_group_role_emails, get_group_ad_emails
|
||||
from ietf.group.factories import GroupFactory, RoleFactory
|
||||
from ietf.utils.test_runner import set_coverage_checking
|
||||
from ietf.person.factories import PersonFactory, EmailFactory
|
||||
from ietf.person.models import Person
|
||||
from ietf.utils.test_utils import login_testing_unauthorized, TestCase
|
||||
|
||||
|
||||
if getattr(settings,'SKIP_DOT_TO_PDF', False):
|
||||
skip_dot_to_pdf = True
|
||||
skip_message = "settings.SKIP_DOT_TO_PDF = %s" % skip_dot_to_pdf
|
||||
elif ( os.path.exists(settings.DOT_BINARY) and
|
||||
os.path.exists(settings.UNFLATTEN_BINARY)):
|
||||
skip_dot_to_pdf = False
|
||||
skip_message = ""
|
||||
else:
|
||||
skip_dot_to_pdf = True
|
||||
skip_message = ("Skipping dependency graph tests: One or more of the binaries for dot\n "
|
||||
"and unflatten weren't found in the locations indicated in settings.py")
|
||||
print(" "+skip_message)
|
||||
|
||||
class StreamTests(TestCase):
|
||||
def test_streams(self):
|
||||
r = self.client.get(urlreverse("ietf.group.views.streams"))
|
||||
|
@ -72,56 +57,43 @@ class StreamTests(TestCase):
|
|||
self.assertTrue(Role.objects.filter(name="delegate", group__acronym=stream_acronym, email__address="ad2@ietf.org"))
|
||||
|
||||
|
||||
@skipIf(skip_dot_to_pdf, skip_message)
|
||||
class GroupDocDependencyGraphTests(TestCase):
|
||||
|
||||
class GroupDocDependencyTests(TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
set_coverage_checking(False)
|
||||
a = WgDraftFactory()
|
||||
b = WgDraftFactory()
|
||||
RelatedDocument.objects.create(source=a,target=b.docalias.first(),relationship_id='refnorm')
|
||||
RelatedDocument.objects.create(
|
||||
source=a, target=b.docalias.first(), relationship_id="refnorm"
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
set_coverage_checking(True)
|
||||
super().tearDown()
|
||||
|
||||
def test_group_document_dependency_dotfile(self):
|
||||
def test_group_document_dependencies(self):
|
||||
for group in Group.objects.filter(Q(type="wg") | Q(type="rg")):
|
||||
client = Client(Accept='text/plain')
|
||||
for url in [ urlreverse("ietf.group.views.dependencies",kwargs=dict(acronym=group.acronym,output_type="dot")),
|
||||
urlreverse("ietf.group.views.dependencies",kwargs=dict(acronym=group.acronym,group_type=group.type_id,output_type="dot")),
|
||||
]:
|
||||
client = Client(Accept="application/json")
|
||||
for url in [
|
||||
urlreverse(
|
||||
"ietf.group.views.dependencies", kwargs=dict(acronym=group.acronym)
|
||||
),
|
||||
urlreverse(
|
||||
"ietf.group.views.dependencies",
|
||||
kwargs=dict(acronym=group.acronym, group_type=group.type_id),
|
||||
),
|
||||
]:
|
||||
r = client.get(url)
|
||||
self.assertTrue(r.status_code == 200, "Failed to receive "
|
||||
"a dot dependency graph for group: %s"%group.acronym)
|
||||
self.assertGreater(len(r.content), 0, "Dot dependency graph for group "
|
||||
"%s has no content"%group.acronym)
|
||||
self.assertTrue(
|
||||
r.status_code == 200,
|
||||
"Failed to receive a group document dependencies for group: %s"
|
||||
% group.acronym,
|
||||
)
|
||||
self.assertGreater(
|
||||
len(r.content),
|
||||
0,
|
||||
"Document dependencies for group %s has no content" % group.acronym,
|
||||
)
|
||||
try:
|
||||
json.loads(r.content)
|
||||
except Exception as e:
|
||||
self.fail("JSON load failed: %s" % e)
|
||||
|
||||
def test_group_document_dependency_pdffile(self):
|
||||
for group in Group.objects.filter(Q(type="wg") | Q(type="rg")):
|
||||
client = Client(Accept='application/pdf')
|
||||
for url in [ urlreverse("ietf.group.views.dependencies",kwargs=dict(acronym=group.acronym,output_type="pdf")),
|
||||
urlreverse("ietf.group.views.dependencies",kwargs=dict(acronym=group.acronym,group_type=group.type_id,output_type="pdf")),
|
||||
]:
|
||||
r = client.get(url)
|
||||
self.assertTrue(r.status_code == 200, "Failed to receive "
|
||||
"a pdf dependency graph for group: %s"%group.acronym)
|
||||
self.assertGreater(len(r.content), 0, "Pdf dependency graph for group "
|
||||
"%s has no content"%group.acronym)
|
||||
|
||||
def test_group_document_dependency_svgfile(self):
|
||||
for group in Group.objects.filter(Q(type="wg") | Q(type="rg")):
|
||||
client = Client(Accept='image/svg+xml')
|
||||
for url in [ urlreverse("ietf.group.views.dependencies",kwargs=dict(acronym=group.acronym,output_type="svg")),
|
||||
urlreverse("ietf.group.views.dependencies",kwargs=dict(acronym=group.acronym,group_type=group.type_id,output_type="svg")),
|
||||
]:
|
||||
r = client.get(url)
|
||||
self.assertTrue(r.status_code == 200, "Failed to receive "
|
||||
"a svg dependency graph for group: %s"%group.acronym)
|
||||
self.assertGreater(len(r.content), 0, "svg dependency graph for group "
|
||||
"%s has no content"%group.acronym)
|
||||
|
||||
|
||||
class GenerateGroupAliasesTests(TestCase):
|
||||
def setUp(self):
|
||||
|
|
|
@ -27,7 +27,7 @@ info_detail_urls = [
|
|||
url(r'^history/$',views.history),
|
||||
url(r'^history/addcomment/$',views.add_comment),
|
||||
url(r'^email/$', views.email),
|
||||
url(r'^deps/(?P<output_type>[\w-]+)/$', views.dependencies),
|
||||
url(r'^deps\.json$', views.dependencies),
|
||||
url(r'^meetings/$', views.meetings),
|
||||
url(r'^edit/$', views.edit, {'action': "edit"}),
|
||||
url(r'^edit/(?P<field>[-a-z0-9_]+)/?$', views.edit, {'action': "edit"}),
|
||||
|
|
|
@ -39,10 +39,9 @@ import datetime
|
|||
import itertools
|
||||
import io
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
|
||||
from tempfile import mkstemp
|
||||
from collections import OrderedDict, defaultdict
|
||||
from simple_history.utils import update_change_reason
|
||||
|
||||
|
@ -67,7 +66,6 @@ from ietf.doc.utils import get_chartering_type, get_tags_for_stream_id
|
|||
from ietf.doc.utils_charter import charter_name_for_group, replace_charter_of_replaced_group
|
||||
from ietf.doc.utils_search import prepare_document_table
|
||||
#
|
||||
from ietf.group.dot import make_dot
|
||||
from ietf.group.forms import (GroupForm, StatusUpdateForm, ConcludeGroupForm, StreamEditForm,
|
||||
ManageReviewRequestForm, EmailOpenAssignmentsForm, ReviewerSettingsForm,
|
||||
AddUnavailablePeriodForm, EndUnavailablePeriodForm, ReviewSecretarySettingsForm, )
|
||||
|
@ -117,7 +115,6 @@ from ietf.dbtemplate.models import DBTemplate
|
|||
from ietf.mailtrigger.utils import gather_address_lists
|
||||
from ietf.mailtrigger.models import Recipient
|
||||
from ietf.settings import MAILING_LIST_INFO_URL
|
||||
from ietf.utils.pipe import pipe
|
||||
from ietf.utils.response import permission_denied
|
||||
from ietf.utils.text import strip_suffix
|
||||
from ietf.utils import markdown
|
||||
|
@ -712,44 +709,86 @@ def materials(request, acronym, group_type=None):
|
|||
"can_manage_materials": can_manage_materials(request.user, group)
|
||||
}))
|
||||
|
||||
|
||||
@cache_page(60 * 60)
|
||||
def dependencies(request, acronym, group_type=None, output_type="pdf"):
|
||||
def dependencies(request, acronym, group_type=None):
|
||||
group = get_group_or_404(acronym, group_type)
|
||||
if not group.features.has_documents or output_type not in ["dot", "pdf", "svg"]:
|
||||
if not group.features.has_documents:
|
||||
raise Http404
|
||||
|
||||
dothandle, dotname = mkstemp()
|
||||
os.close(dothandle)
|
||||
dotfile = io.open(dotname, "w")
|
||||
dotfile.write(make_dot(group))
|
||||
dotfile.close()
|
||||
if not group.communitylist_set.exists():
|
||||
setup_default_community_list_for_group(group)
|
||||
clist = group.communitylist_set.first()
|
||||
|
||||
if (output_type == "dot"):
|
||||
return HttpResponse(make_dot(group),
|
||||
content_type='text/plain; charset=UTF-8'
|
||||
)
|
||||
docs, meta, docs_related, meta_related = prepare_group_documents(
|
||||
request, group, clist
|
||||
)
|
||||
cl_docs = set(docs).union(set(docs_related))
|
||||
|
||||
unflathandle, unflatname = mkstemp()
|
||||
os.close(unflathandle)
|
||||
outhandle, outname = mkstemp()
|
||||
os.close(outhandle)
|
||||
references = Q(
|
||||
Q(source__group=group) | Q(source__in=cl_docs),
|
||||
source__type="draft",
|
||||
relationship__slug__startswith="ref",
|
||||
)
|
||||
|
||||
pipe("%s -f -l 10 -o %s %s" % (settings.UNFLATTEN_BINARY, unflatname, dotname))
|
||||
pipe("%s -T%s -o %s %s" % (settings.DOT_BINARY, output_type, outname, unflatname))
|
||||
both_rfcs = Q(source__states__slug="rfc", target__docs__states__slug="rfc")
|
||||
inactive = Q(source__states__slug__in=["expired", "repl"])
|
||||
attractor = Q(target__name__in=["rfc5000", "rfc5741"])
|
||||
removed = Q(source__states__slug__in=["auth-rm", "ietf-rm"])
|
||||
relations = (
|
||||
RelatedDocument.objects.filter(references)
|
||||
.exclude(both_rfcs)
|
||||
.exclude(inactive)
|
||||
.exclude(attractor)
|
||||
.exclude(removed)
|
||||
)
|
||||
|
||||
outhandle = io.open(outname, "rb")
|
||||
out = outhandle.read()
|
||||
outhandle.close()
|
||||
links = set()
|
||||
for x in relations:
|
||||
target_state = x.target.document.get_state_slug("draft")
|
||||
if target_state != "rfc" or x.is_downref():
|
||||
links.add(x)
|
||||
|
||||
os.unlink(outname)
|
||||
os.unlink(unflatname)
|
||||
os.unlink(dotname)
|
||||
replacements = RelatedDocument.objects.filter(
|
||||
relationship__slug="replaces",
|
||||
target__docs__in=[x.target.document for x in links],
|
||||
)
|
||||
|
||||
for x in replacements:
|
||||
links.add(x)
|
||||
|
||||
nodes = set([x.source for x in links]).union([x.target.document for x in links])
|
||||
graph = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": x.canonical_name(),
|
||||
"rfc": x.get_state("draft").slug == "rfc",
|
||||
"post-wg": not x.get_state("draft-iesg").slug
|
||||
in ["idexists", "watching", "dead"],
|
||||
"expired": x.get_state("draft").slug == "expired",
|
||||
"replaced": x.get_state("draft").slug == "repl",
|
||||
"group": x.group.acronym if x.group.acronym != "none" else "",
|
||||
"url": x.get_absolute_url(),
|
||||
"level": x.intended_std_level.name
|
||||
if x.intended_std_level
|
||||
else x.std_level.name
|
||||
if x.std_level
|
||||
else "",
|
||||
}
|
||||
for x in nodes
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"source": x.source.canonical_name(),
|
||||
"target": x.target.document.canonical_name(),
|
||||
"rel": "downref" if x.is_downref() else x.relationship.slug,
|
||||
}
|
||||
for x in links
|
||||
],
|
||||
}
|
||||
|
||||
return HttpResponse(json.dumps(graph), content_type="application/json")
|
||||
|
||||
if (output_type == "pdf"):
|
||||
output_type = "application/pdf"
|
||||
elif (output_type == "svg"):
|
||||
output_type = "image/svg+xml"
|
||||
return HttpResponse(out, content_type=output_type)
|
||||
|
||||
def email_aliases(request, acronym=None, group_type=None):
|
||||
group = get_group_or_404(acronym,group_type) if acronym else None
|
||||
|
|
|
@ -991,8 +991,6 @@ OIDC_EXTRA_SCOPE_CLAIMS = 'ietf.ietfauth.utils.OidcExtraScopeClaims'
|
|||
# ==============================================================================
|
||||
|
||||
|
||||
DOT_BINARY = '/usr/bin/dot'
|
||||
UNFLATTEN_BINARY= '/usr/bin/unflatten'
|
||||
RSYNC_BINARY = '/usr/bin/rsync'
|
||||
YANGLINT_BINARY = '/usr/bin/yanglint'
|
||||
DE_GFM_BINARY = '/usr/bin/de-gfm.ruby2.5'
|
||||
|
|
505
ietf/static/js/document_relations.js
Normal file
505
ietf/static/js/document_relations.js
Normal file
|
@ -0,0 +1,505 @@
|
|||
const style = getComputedStyle(document.body);
|
||||
const font_size = parseFloat(style.fontSize);
|
||||
const line_height = font_size + 2;
|
||||
const font_family = style.getPropertyValue("--bs-body-font-family");
|
||||
const font = `${font_size}px ${font_family}`;
|
||||
|
||||
const green = style.getPropertyValue("--bs-green");
|
||||
const blue = style.getPropertyValue("--bs-blue");
|
||||
const orange = style.getPropertyValue("--bs-orange");
|
||||
const cyan = style.getPropertyValue("--bs-cyan");
|
||||
const yellow = style.getPropertyValue("--bs-yellow");
|
||||
const red = style.getPropertyValue("--bs-red");
|
||||
const teal = style.getPropertyValue("--bs-teal");
|
||||
const white = style.getPropertyValue("--bs-white");
|
||||
const black = style.getPropertyValue("--bs-dark");
|
||||
const gray400 = style.getPropertyValue("--bs-gray-400");
|
||||
|
||||
const link_color = {
|
||||
refinfo: green,
|
||||
refnorm: blue,
|
||||
replaces: orange,
|
||||
refunk: cyan,
|
||||
refold: yellow,
|
||||
downref: red
|
||||
};
|
||||
|
||||
const ref_type = {
|
||||
refinfo: "has an Informative reference to",
|
||||
refnorm: "has a Normative reference to",
|
||||
replaces: "replaces",
|
||||
refunk: "has an Unknown type of reference to",
|
||||
refold: "has an Undefined type of reference to",
|
||||
downref: "has a Downward reference (DOWNREF) to"
|
||||
};
|
||||
|
||||
// code partially adapted from
|
||||
// https://observablehq.com/@mbostock/fit-text-to-circle
|
||||
|
||||
function lines(text) {
|
||||
let line;
|
||||
let line_width_0 = Infinity;
|
||||
const lines = [];
|
||||
let sep = "-";
|
||||
let words = text.trim()
|
||||
.split(/-/g);
|
||||
if (words.length == 1) {
|
||||
words = text.trim()
|
||||
.split(/\s/g);
|
||||
sep = " ";
|
||||
}
|
||||
words = words.map((x, i, a) => i < a.length - 1 ? x + sep : x);
|
||||
if (words.length == 1) {
|
||||
words = text.trim()
|
||||
.split(/rfc/g)
|
||||
.map((x, i, a) => i < a.length - 1 ? x + "RFC" : x);
|
||||
}
|
||||
const target_width = Math.sqrt(measure_width(text.trim()) * line_height);
|
||||
for (let i = 0, n = words.length; i < n; ++i) {
|
||||
let line_text = (line ? line.text : "") + words[i];
|
||||
let line_width = measure_width(line_text);
|
||||
if ((line_width_0 + line_width) / 2 < target_width) {
|
||||
line.width = line_width_0 = line_width;
|
||||
line.text = line_text;
|
||||
} else {
|
||||
line_width_0 = measure_width(words[i]);
|
||||
line = { width: line_width_0, text: words[i] };
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function measure_width(text) {
|
||||
const context = document.createElement("canvas")
|
||||
.getContext("2d");
|
||||
context.font = font;
|
||||
return context.measureText(text)
|
||||
.width;
|
||||
}
|
||||
|
||||
function text_radius(lines) {
|
||||
let radius = 0;
|
||||
for (let i = 0, n = lines.length; i < n; ++i) {
|
||||
const dy = (Math.abs(i - n / 2) + 0.5) * line_height;
|
||||
const dx = lines[i].width / 2;
|
||||
radius = Math.max(radius, Math.sqrt(dx ** 2 + dy ** 2));
|
||||
}
|
||||
return radius;
|
||||
}
|
||||
|
||||
function stroke(d) {
|
||||
if (d.level == "Informational" ||
|
||||
d.level == "Experimental" || d.level == "") {
|
||||
return 1;
|
||||
}
|
||||
if (d.level == "Proposed Standard") {
|
||||
return 4;
|
||||
}
|
||||
if (d.level == "Best Current Practice") {
|
||||
return 8;
|
||||
}
|
||||
// all others (draft/full standards)
|
||||
return 10;
|
||||
}
|
||||
|
||||
function draw_graph(data, group) {
|
||||
// console.log(data);
|
||||
// let el = $.parseHTML('<svg class="w-100 h-100"></svg>');
|
||||
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([1 / 32, 32])
|
||||
.on("zoom", zoomed);
|
||||
|
||||
const width = 1000;
|
||||
const height = 1000;
|
||||
|
||||
const svg = d3.select($.parseHTML('<svg class="w-100 h-100"></svg>')[0])
|
||||
.style("font", font)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("dominant-baseline", "central")
|
||||
.attr("viewBox", [-width / 2, -height / 2, width, height])
|
||||
.call(zoom);
|
||||
|
||||
svg.append("defs")
|
||||
.selectAll("marker")
|
||||
.data(new Set(data.links.map(d => d.rel)))
|
||||
.join("marker")
|
||||
.attr("id", d => `marker-${d}`)
|
||||
.attr("viewBox", "0 -5 10 10")
|
||||
.attr("refX", 7.85)
|
||||
.attr("markerWidth", 4)
|
||||
.attr("markerHeight", 4)
|
||||
.attr("stroke-width", 0.2)
|
||||
.attr("stroke", black)
|
||||
.attr("orient", "auto")
|
||||
.attr("fill", d => link_color[d])
|
||||
.append("path")
|
||||
.attr("d", "M0,-5L10,0L0,5");
|
||||
|
||||
const link = svg.append("g")
|
||||
.attr("fill", "none")
|
||||
.attr("stroke-width", 5)
|
||||
.selectAll("path")
|
||||
.data(data.links)
|
||||
.join("path")
|
||||
.attr("title", d => `${d.source} ${ref_type[d.rel]} ${d.target}`)
|
||||
.attr("marker-end", d => `url(#marker-${d.rel})`)
|
||||
.attr("stroke", d => link_color[d.rel])
|
||||
.attr("class", d => d.rel);
|
||||
|
||||
const node = svg.append("g")
|
||||
.selectAll("g")
|
||||
.data(data.nodes)
|
||||
.join("g");
|
||||
|
||||
let max_r = 0;
|
||||
const a = node.append("a")
|
||||
.attr("href", d => d.url)
|
||||
.attr("title", d => {
|
||||
let type = ["replaced", "dead", "expired"].filter(x => d[x])
|
||||
.join(" ");
|
||||
if (type) {
|
||||
type += " ";
|
||||
}
|
||||
if (d.level) {
|
||||
type += `${d.level} `
|
||||
}
|
||||
if (d.group != undefined && d.group != "none" && d.group != "") {
|
||||
const word = d.rfc ? "from" : "in";
|
||||
type += `group document ${word} ${d.group.toUpperCase()}`;
|
||||
} else {
|
||||
type += "individual document";
|
||||
}
|
||||
const name = d.rfc ? d.id.toUpperCase() : d.id;
|
||||
return `${name} is a${"aeiou".includes(type[0].toLowerCase()) ? "n" : ""} ${type}`
|
||||
});
|
||||
|
||||
a
|
||||
.append("text")
|
||||
.attr("fill", d => d.rfc || d.replaced ? white : black)
|
||||
.each(d => {
|
||||
d.lines = lines(d.id);
|
||||
d.r = text_radius(d.lines);
|
||||
max_r = Math.max(d.r, max_r);
|
||||
})
|
||||
.selectAll("tspan")
|
||||
.data(d => d.lines)
|
||||
.join("tspan")
|
||||
.attr("x", 0)
|
||||
.attr("y", (d, i, x) => ((i - x.length / 2) + 0.5) * line_height)
|
||||
.text(d => d.text);
|
||||
|
||||
a
|
||||
.append("circle")
|
||||
.attr("stroke", black)
|
||||
.lower()
|
||||
.attr("fill", d => {
|
||||
if (d.rfc) {
|
||||
return green;
|
||||
}
|
||||
if (d.replaced) {
|
||||
return orange;
|
||||
}
|
||||
if (d.dead) {
|
||||
return red;
|
||||
}
|
||||
if (d.expired) {
|
||||
return gray400;
|
||||
}
|
||||
if (d["post-wg"]) {
|
||||
return teal;
|
||||
}
|
||||
if (d.group == group || d.group == "this group") {
|
||||
return yellow;
|
||||
}
|
||||
if (d.group == "") {
|
||||
return white;
|
||||
}
|
||||
return cyan;
|
||||
})
|
||||
.each(d => d.stroke = stroke(d))
|
||||
.attr("r", d => d.r + d.stroke / 2)
|
||||
.attr("stroke-width", d => d.stroke)
|
||||
.attr("stroke-dasharray", d => {
|
||||
if (d.group != "" || d.rfc) { return 0; }
|
||||
return 4;
|
||||
});
|
||||
|
||||
const adjust = stroke("something") / 2;
|
||||
|
||||
function ticked() {
|
||||
// don't animate each tick
|
||||
for (let i = 0; i < 3; i++) {
|
||||
this.tick();
|
||||
}
|
||||
|
||||
// code for straight links:
|
||||
// link.attr("d", function (d) {
|
||||
// const dx = d.target.x - d.source.x;
|
||||
// const dy = d.target.y - d.source.y;
|
||||
|
||||
// const path_len = Math.sqrt((dx * dx) +
|
||||
// (dy * dy));
|
||||
|
||||
// const offx = (dx * d.target.r) /
|
||||
// path_len;
|
||||
// const offy = (dy * d.target.r) /
|
||||
// path_len;
|
||||
// return `
|
||||
// M${d.source.x},${d.source.y}
|
||||
// L${d.target.x - offx},${d.target.y - offy}
|
||||
// `;
|
||||
// });
|
||||
|
||||
// code for arced links:
|
||||
link.attr("d", d => {
|
||||
const r = Math.hypot(d.target.x - d
|
||||
.source.x, d.target.y - d.source
|
||||
.y);
|
||||
return `M${d.source.x},${d.source.y} A${r},${r} 0 0,1 ${d.target.x},${d.target.y}`;
|
||||
});
|
||||
// TODO: figure out how to combine this with above
|
||||
link.attr("d", function (d) {
|
||||
const pl = this.getTotalLength();
|
||||
const start = this.getPointAtLength(
|
||||
d.source.r + d.source.stroke
|
||||
);
|
||||
const end = this.getPointAtLength(
|
||||
pl - d.target.r - d.target.stroke
|
||||
);
|
||||
const r = Math.hypot(
|
||||
d.target.x - d.source.x, d.target.y - d.source.y
|
||||
);
|
||||
return `M${start.x},${start.y} A${r},${r} 0 0,1 ${end.x},${end.y}`;
|
||||
});
|
||||
|
||||
node.selectAll("circle, text")
|
||||
.attr("transform", d => `translate(${d.x}, ${d.y})`)
|
||||
|
||||
// auto pan and zoom during simulation
|
||||
const bbox = svg.node()
|
||||
.getBBox();
|
||||
svg.attr("viewBox",
|
||||
[
|
||||
bbox.x - adjust, bbox.y - adjust,
|
||||
bbox.width + 2 * adjust, bbox.height + 2 * adjust
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
function zoomed({ transform }) {
|
||||
link.attr("transform", transform);
|
||||
node.attr("transform", transform);
|
||||
}
|
||||
|
||||
return [svg.node(), d3
|
||||
.forceSimulation()
|
||||
.nodes(data.nodes)
|
||||
.force("link", d3.forceLink(data.links)
|
||||
.id(d => d.id)
|
||||
.distance(0)
|
||||
// .strength(1)
|
||||
)
|
||||
.force("charge", d3.forceManyBody()
|
||||
.strength(-max_r))
|
||||
.force("collision", d3.forceCollide(1.25 * max_r))
|
||||
.force("x", d3.forceX())
|
||||
.force("y", d3.forceY())
|
||||
.stop()
|
||||
.on("tick", ticked)
|
||||
.on("end", function () {
|
||||
$("#download-svg")
|
||||
.removeClass("disabled")
|
||||
.html('<i class="bi bi-download"></i> Download');
|
||||
})
|
||||
];
|
||||
|
||||
// // See https://github.com/d3/d3-force/blob/master/README.md#simulation_tick
|
||||
// for (let i = 0, n = Math.ceil(Math.log(simulation.alphaMin()) /
|
||||
// Math.log(1 - simulation.alphaDecay())); i <
|
||||
// n; ++i) {
|
||||
// simulation.tick();
|
||||
// }
|
||||
// ticked();
|
||||
|
||||
}
|
||||
|
||||
// Fill modal with content from link href
|
||||
$("#deps-modal")
|
||||
.on("shown.bs.modal", function (e) {
|
||||
$(e.relatedTarget)
|
||||
.one("focus", function () {
|
||||
$(this)
|
||||
.trigger("blur");
|
||||
});
|
||||
|
||||
const link = $(e.relatedTarget)
|
||||
.data("href");
|
||||
const group = $(e.relatedTarget)
|
||||
.data("group");
|
||||
|
||||
$("#download-svg")
|
||||
.addClass("disabled");
|
||||
|
||||
$("#legend")
|
||||
.prop("disabled", true)
|
||||
.prop("checked", false);
|
||||
|
||||
if (link && $(this)
|
||||
.find(".modal-body")) {
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
const legend = {
|
||||
nodes: [{
|
||||
id: "Individual submission",
|
||||
level: "Informational",
|
||||
group: ""
|
||||
}, {
|
||||
id: "Replaced",
|
||||
level: "Experimental",
|
||||
replaced: true
|
||||
}, {
|
||||
id: "IESG or RFC queue",
|
||||
level: "Proposed Standard",
|
||||
"post-wg": true
|
||||
}, {
|
||||
id: "Product of other group",
|
||||
level: "Best Current Practice",
|
||||
group: "other group"
|
||||
}, {
|
||||
id: "Expired",
|
||||
level: "Informational",
|
||||
group: "this group",
|
||||
expired: true
|
||||
}, {
|
||||
id: "Product of this group",
|
||||
level: "Proposed Standard",
|
||||
group: "this group"
|
||||
}, {
|
||||
id: "RFC published",
|
||||
level: "Draft Standard",
|
||||
group: "other group",
|
||||
rfc: true
|
||||
}],
|
||||
links: [{
|
||||
source: "Individual submission",
|
||||
target: "Replaced",
|
||||
rel: "replaces"
|
||||
}, {
|
||||
source: "Individual submission",
|
||||
target: "IESG or RFC queue",
|
||||
rel: "refnorm"
|
||||
}, {
|
||||
source: "Expired",
|
||||
target: "RFC published",
|
||||
rel: "refunk"
|
||||
}, {
|
||||
source: "Product of other group",
|
||||
target: "IESG or RFC queue",
|
||||
rel: "refinfo"
|
||||
}, {
|
||||
source: "Product of this group",
|
||||
target: "Product of other group",
|
||||
rel: "refold"
|
||||
}, {
|
||||
source: "Product of this group",
|
||||
target: "Expired",
|
||||
rel: "downref"
|
||||
}]
|
||||
};
|
||||
let [leg_el, leg_sim] = draw_graph(legend, "this group");
|
||||
|
||||
$("#legend-tab")
|
||||
.on("show.bs.tab", function () {
|
||||
$(".modal-body")
|
||||
.children()
|
||||
.replaceWith(leg_el);
|
||||
leg_sim.restart();
|
||||
})
|
||||
.on("hide.bs.tab", function () {
|
||||
leg_sim.stop();
|
||||
});
|
||||
|
||||
d3.json(link, { signal })
|
||||
.catch(e => {})
|
||||
.then((data) => {
|
||||
// the user may have closed the modal in the meantime
|
||||
if (!$("#deps-modal")
|
||||
.hasClass("show")) {
|
||||
return;
|
||||
}
|
||||
|
||||
let [dep_el, dep_sim] = draw_graph(data, group);
|
||||
$("#dep-tab")
|
||||
.on("show.bs.tab", function () {
|
||||
$(".modal-body")
|
||||
.children()
|
||||
.replaceWith(dep_el);
|
||||
dep_sim.restart();
|
||||
})
|
||||
.on("hide.bs.tab", function () {
|
||||
dep_sim.stop();
|
||||
});
|
||||
|
||||
// shown by default
|
||||
$(".modal-body")
|
||||
.children()
|
||||
.replaceWith(dep_el);
|
||||
dep_sim.restart();
|
||||
|
||||
$('svg [title][title!=""]')
|
||||
.tooltip();
|
||||
|
||||
$("#legend")
|
||||
.prop("disabled", false)
|
||||
.on("click", function () {
|
||||
if (this.checked) {
|
||||
$(".modal-body")
|
||||
.children()
|
||||
.replaceWith(leg_el);
|
||||
leg_sim.restart();
|
||||
} else {
|
||||
$(".modal-body")
|
||||
.children()
|
||||
.replaceWith(dep_el);
|
||||
dep_sim.restart();
|
||||
}
|
||||
|
||||
$('svg [title][title!=""]')
|
||||
.tooltip();
|
||||
});
|
||||
|
||||
$(this)
|
||||
.on("hide.bs.modal", function (e) {
|
||||
controller.abort();
|
||||
if (leg_sim) {
|
||||
leg_sim.stop();
|
||||
}
|
||||
if (dep_sim) {
|
||||
dep_sim.stop();
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$("#download-svg")
|
||||
.on("click", function () {
|
||||
const html = $(".modal-body svg")
|
||||
.attr("xmlns", "http://www.w3.org/2000/svg")
|
||||
.attr("version", "1.1")
|
||||
.parent()
|
||||
.html();
|
||||
|
||||
const group = $(this)
|
||||
.data("group");
|
||||
|
||||
$(this)
|
||||
.attr("download", `${group}.svg`)
|
||||
.attr("href", "data:image/svg+xml;base64,\n" + btoa(
|
||||
unescape(
|
||||
encodeURIComponent(html))))
|
||||
});
|
|
@ -1,88 +0,0 @@
|
|||
{% load mail_filters %}{% autoescape off %}
|
||||
digraph draftdeps {
|
||||
graph [fontame=Helvetica];
|
||||
node [fontname=Helvetica];
|
||||
edge [fontname=Helvetica];
|
||||
subgraph cluster_key {
|
||||
graph [label=Key,
|
||||
rankdir=LR,
|
||||
margin=.5,
|
||||
fontname=Helvetica
|
||||
];
|
||||
subgraph key_a {
|
||||
graph [rank=same];
|
||||
key_colors [color=white,
|
||||
fontcolor=black,
|
||||
label="Colors in\nthis row"];
|
||||
key_wgdoc [color="#0AFE47",
|
||||
label="Product of\nthis group",
|
||||
style=filled,
|
||||
wg=this];
|
||||
key_otherwgdoc [color="#9999FF",
|
||||
label="Product of\nother group",
|
||||
style=filled,
|
||||
wg=blort];
|
||||
key_individual [color="#FF800D",
|
||||
label="Individual\nsubmission",
|
||||
style=filled,
|
||||
wg=individual];
|
||||
}
|
||||
subgraph key_b {
|
||||
graph [rank=same];
|
||||
key_shapes [color=white,
|
||||
fontcolor=black,
|
||||
label="Shapes in\nthis row"];
|
||||
key_active [color="#9999FF",
|
||||
label="Active\ndocument",
|
||||
style=filled];
|
||||
key_iesg [color="#9999FF",
|
||||
label="IESG or\nRFC Queue",
|
||||
shape=parallelogram,
|
||||
style=filled];
|
||||
key_rfc [color="#9999FF",
|
||||
label="RFC\nPublished",
|
||||
shape=box,
|
||||
style=filled];
|
||||
key_expired [color="#9999FF",
|
||||
label="Expired!",
|
||||
peripheries=3,
|
||||
shape=house,
|
||||
style=solid];
|
||||
key_replaced [color="#9999FF",
|
||||
label="Replaced",
|
||||
peripheries=3,
|
||||
shape=ellipse];
|
||||
}
|
||||
key_colors -> key_shapes [color=white,
|
||||
fontcolor=black,
|
||||
label="Line\ncolor\nreflects\nReference\ntype"];
|
||||
key_wgdoc -> key_active [color=orange,
|
||||
label="Orange link:\nUnsplit"];
|
||||
key_otherwgdoc -> key_active [color=green,
|
||||
label="Green link:\nInformative"];
|
||||
key_individual -> key_iesg [color=blue,
|
||||
label="Blue link:\nNormative"];
|
||||
key_otherwgdoc -> key_expired [label="Black link:\nUnknown",
|
||||
style=dashed];
|
||||
key_wgdoc -> key_rfc [color=red,
|
||||
label="Red link:\nDownref!",
|
||||
arrowhead=normalnormal];
|
||||
key_individual -> key_replaced [color=pink,
|
||||
label="Pink link:\nReplaces",
|
||||
style=dashed,
|
||||
arrowhead=diamond];
|
||||
}
|
||||
|
||||
{% for node in nodes %}
|
||||
{{ node.nodename }} [ status="{{ node.get_state.slug }}",
|
||||
wg="{{ node.group.acronym }}",{% for key,value in node.styles.items %}
|
||||
{{ key }}={{ value }},{% endfor %}
|
||||
];
|
||||
{% endfor %}
|
||||
|
||||
{% for edge in edges%}
|
||||
{{ edge.sourcename }} -> {{ edge.targetname }} {% if edge.styles %}[ {% for key,value in edge.styles.items %}{{ key }}={{ value }}{% if not forloop.last %}, {% endif %}{% endfor %} ] {% endif %};{% endfor %}
|
||||
|
||||
|
||||
}
|
||||
{% endautoescape %}
|
|
@ -1,9 +1,17 @@
|
|||
{% extends "group/group_base.html" %}
|
||||
{# Copyright The IETF Trust 2015, All Rights Reserved #}
|
||||
{% load origin %}
|
||||
{% load ietf_filters %}
|
||||
{% load static ietf_filters %}
|
||||
{% load markup_tags %}
|
||||
{% load textfilters group_filters %}
|
||||
{% block morecss %}
|
||||
#deps-modal .modal-body {
|
||||
height: 100vh;
|
||||
}
|
||||
#deps-modal .modal-body svg a {
|
||||
text-decoration: none;
|
||||
}
|
||||
{% endblock %}
|
||||
{% block group_content %}
|
||||
{% origin %}
|
||||
{% if group.state_id == "conclude" %}
|
||||
|
@ -122,10 +130,52 @@
|
|||
<td class="edit">
|
||||
</td>
|
||||
<td>
|
||||
<a class="btn btn-primary btn-sm"
|
||||
href="{% url 'ietf.group.views.dependencies' group_type=group.type_id acronym=group.acronym output_type='svg' %}">
|
||||
SVG <i class="bi bi-diagram-3"></i>
|
||||
</a>
|
||||
<button id="show-deps" data-href="{% url 'ietf.group.views.dependencies' group_type=group.type_id acronym=group.acronym %}"
|
||||
type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal"
|
||||
data-bs-target="#deps-modal" data-group="{{ group.acronym }}">
|
||||
<i class="bi bi-bounding-box-circles"></i> Show
|
||||
</button>
|
||||
<div class="modal fade" id="deps-modal" tabindex="-1" aria-labelledby="deps-modal-label"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<p class="h5 modal-title" id="deps-modal-label">Document
|
||||
dependencies</p>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"
|
||||
aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="spinner-border m-5" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="btn-group me-auto" role="group" aria-label="Pan and zoom the diagram">
|
||||
<small class="text-muted">Pan and zoom the dependency
|
||||
graph after the layout settles.</small>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="legend"
|
||||
disabled>
|
||||
<label class="form-check-label me-3" for="legend">
|
||||
Show legend
|
||||
</label>
|
||||
</div>
|
||||
<a href="#" id="download-svg" data-group="{{ group.acronym }}"
|
||||
class="btn btn-primary disabled">
|
||||
<span class="spinner-border spinner-border-sm" role="status"
|
||||
aria-hidden="true"></span> Loading...
|
||||
</a>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
@ -340,3 +390,7 @@
|
|||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
<script src="{% static 'ietf/js/d3.js' %}"></script>
|
||||
<script src="{% static 'ietf/js/document_relations.js' %}"></script>
|
||||
{% endblock %}
|
|
@ -105,6 +105,7 @@
|
|||
"ietf/static/js/d3.js",
|
||||
"ietf/static/js/datepicker.js",
|
||||
"ietf/static/js/doc-search.js",
|
||||
"ietf/static/js/document_relations.js",
|
||||
"ietf/static/js/document_timeline.js",
|
||||
"ietf/static/js/draft-submit.js",
|
||||
"ietf/static/js/edit-meeting-schedule.js",
|
||||
|
|
Loading…
Reference in a new issue