From e465f1f0f0bcd5c7fd56842cc689a3e9c4b172b1 Mon Sep 17 00:00:00 2001 From: Lars Eggert Date: Thu, 21 Jul 2022 20:14:45 +0300 Subject: [PATCH] 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 --- bin/graph-models | 22 -- docker/base.Dockerfile | 1 - ietf/bin/graphall | 30 -- ietf/group/dot.py | 133 ------- ietf/group/tests.py | 88 ++--- ietf/group/urls.py | 2 +- ietf/group/views.py | 103 ++++-- ietf/settings.py | 2 - ietf/static/js/document_relations.js | 505 ++++++++++++++++++++++++++ ietf/templates/group/dot.txt | 88 ----- ietf/templates/group/group_about.html | 64 +++- package.json | 1 + 12 files changed, 667 insertions(+), 372 deletions(-) delete mode 100755 bin/graph-models delete mode 100755 ietf/bin/graphall delete mode 100644 ietf/group/dot.py create mode 100644 ietf/static/js/document_relations.js delete mode 100644 ietf/templates/group/dot.txt diff --git a/bin/graph-models b/bin/graph-models deleted file mode 100755 index 087ec3574..000000000 --- a/bin/graph-models +++ /dev/null @@ -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 diff --git a/docker/base.Dockerfile b/docker/base.Dockerfile index 3203b8a07..97604e62f 100644 --- a/docker/base.Dockerfile +++ b/docker/base.Dockerfile @@ -32,7 +32,6 @@ RUN apt-get update --fix-missing && apt-get install -qy \ ghostscript \ git \ gnupg \ - graphviz \ jq \ less \ libcairo2-dev \ diff --git a/ietf/bin/graphall b/ietf/bin/graphall deleted file mode 100755 index 8eddf582d..000000000 --- a/ietf/bin/graphall +++ /dev/null @@ -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 diff --git a/ietf/group/dot.py b/ietf/group/dot.py deleted file mode 100644 index e5fa1b297..000000000 --- a/ietf/group/dot.py +++ /dev/null @@ -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 ) - ) - - diff --git a/ietf/group/tests.py b/ietf/group/tests.py index 74995e60c..b168e4d3b 100644 --- a/ietf/group/tests.py +++ b/ietf/group/tests.py @@ -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): diff --git a/ietf/group/urls.py b/ietf/group/urls.py index 07eb2c4b6..46bde4ede 100644 --- a/ietf/group/urls.py +++ b/ietf/group/urls.py @@ -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[\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[-a-z0-9_]+)/?$', views.edit, {'action': "edit"}), diff --git a/ietf/group/views.py b/ietf/group/views.py index 88267e788..51ecc0e54 100644 --- a/ietf/group/views.py +++ b/ietf/group/views.py @@ -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 diff --git a/ietf/settings.py b/ietf/settings.py index 9121c3de1..518ad0951 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -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' diff --git a/ietf/static/js/document_relations.js b/ietf/static/js/document_relations.js new file mode 100644 index 000000000..e6b885d83 --- /dev/null +++ b/ietf/static/js/document_relations.js @@ -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(''); + + const zoom = d3.zoom() + .scaleExtent([1 / 32, 32]) + .on("zoom", zoomed); + + const width = 1000; + const height = 1000; + + const svg = d3.select($.parseHTML('')[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(' 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)))) + }); diff --git a/ietf/templates/group/dot.txt b/ietf/templates/group/dot.txt deleted file mode 100644 index ed2658c5a..000000000 --- a/ietf/templates/group/dot.txt +++ /dev/null @@ -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 %} diff --git a/ietf/templates/group/group_about.html b/ietf/templates/group/group_about.html index 44906a3fa..66f196652 100644 --- a/ietf/templates/group/group_about.html +++ b/ietf/templates/group/group_about.html @@ -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 @@ - - SVG - + + {% endif %} @@ -340,3 +390,7 @@ {% endif %} {% endif %} {% endblock %} +{% block js %} + + +{% endblock %} \ No newline at end of file diff --git a/package.json b/package.json index ffb1f2fa1..d3a044651 100644 --- a/package.json +++ b/package.json @@ -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",