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:
Lars Eggert 2022-07-21 20:14:45 +03:00 committed by GitHub
parent fcbd5e418f
commit e465f1f0f0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 667 additions and 372 deletions

View file

@ -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

View file

@ -32,7 +32,6 @@ RUN apt-get update --fix-missing && apt-get install -qy \
ghostscript \
git \
gnupg \
graphviz \
jq \
less \
libcairo2-dev \

View file

@ -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

View file

@ -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 )
)

View file

@ -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):

View file

@ -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"}),

View file

@ -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

View file

@ -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'

View 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))))
});

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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",