From ab6111db8cb6ae9078399378502c1c9fa5dc78a3 Mon Sep 17 00:00:00 2001 From: "J.-S. Caux" <J.S.Caux@uva.nl> Date: Sat, 14 May 2016 17:05:02 +0200 Subject: [PATCH] Improve graphs: zooming and panning --- scipost/admin.py | 21 ++++++- scipost/forms.py | 5 +- scipost/models.py | 37 ++++++------ scipost/templates/scipost/graph.html | 86 +++++++++++++++++----------- scipost/views.py | 50 ++++++++++------ 5 files changed, 124 insertions(+), 75 deletions(-) diff --git a/scipost/admin.py b/scipost/admin.py index d0ef3f8ae..4c855d93e 100644 --- a/scipost/admin.py +++ b/scipost/admin.py @@ -36,6 +36,23 @@ admin.site.register(List, ListAdmin) admin.site.register(Team) -admin.site.register(Graph) +#admin.site.register(Graph) -admin.site.register(Node) +#admin.site.register(Node) + +#admin.site.register(Arc) + +class NodeInline(admin.StackedInline): + model = Node + +class ArcInline(admin.StackedInline): + model = Arc + +class GraphAdmin(GuardedModelAdmin): + inlines = [ + NodeInline, + ArcInline, + ] + search_fields = ['last_name', 'email'] + +admin.site.register(Graph, GraphAdmin) diff --git a/scipost/forms.py b/scipost/forms.py index 8075d64ea..5ac2b48e0 100644 --- a/scipost/forms.py +++ b/scipost/forms.py @@ -166,13 +166,14 @@ class CreateNodeForm(forms.ModelForm): fields = ['name', 'description'] -class CreateLinkForm(forms.Form): +class CreateArcForm(forms.Form): source = forms.ModelChoiceField(queryset=None) target = forms.ModelChoiceField(queryset=None) + length = forms.ChoiceField(choices=ARC_LENGTHS) def __init__(self, *args, **kwargs): graph = kwargs.pop('graph') - super(CreateLinkForm, self).__init__(*args, **kwargs) + super(CreateArcForm, self).__init__(*args, **kwargs) self.fields['source'].queryset = Node.objects.filter(graph=graph) self.fields['target'].queryset = Node.objects.filter(graph=graph) diff --git a/scipost/models.py b/scipost/models.py index 1cb8231b9..344870f0f 100644 --- a/scipost/models.py +++ b/scipost/models.py @@ -413,22 +413,6 @@ class Graph(models.Model): return template.render(context) -# ARC_LENGTHS = [ -# (4, '4'), (8, '8'), (16, '16'), (32, '32'), (64, '64'), (128, '128') -# ] - -# class Arc(models.Model): -# """ -# Arc of a graph, linking two nodes. -# The length is user-adjustable. -# """ -# graph = models.ForeignKey(Graph, default=None) -# added_by = models.ForeignKey(Contributor, default=None) -# created = models.DateTimeField(default=timezone.now) -# node_from = models.ForeignKey(Node) -# node_to = models.ForeignKey(Node) -# length = models.PositiveSmallIntegerField(choices=ARC_LENGTHS) - class Node(models.Model): """ Node of a graph (directed). @@ -439,7 +423,7 @@ class Node(models.Model): added_by = models.ForeignKey(Contributor, default=None) created = models.DateTimeField(default=timezone.now) name = models.CharField(max_length=100) - arcs_in = models.ManyToManyField('self', blank=True, related_name='node_arcs_in', symmetrical=False) # arcs from another node pointing into this node +# REPLACED BY CLASS Arc arcs_in = models.ManyToManyField('self', blank=True, related_name='node_arcs_in', symmetrical=False) # arcs from another node pointing into this node description = models.TextField(blank=True, null=True) submissions = models.ManyToManyField('submissions.Submission', blank=True, related_name='node_submissions') commentaries = models.ManyToManyField('commentaries.Commentary', blank=True, related_name='node_commentaries') @@ -469,3 +453,22 @@ class Node(models.Model): output = '<div style="font-size: 60%">' + self.contents + '</div>' template = Template(output) return template.render() + + +ARC_LENGTHS = [ +# (4, '4'), (8, '8'), (16, '16'), (32, '32'), (64, '64'), (128, '128') + (1, '1'), (2, '2'), (3, '3'), (4, '4'), (5, '5'), (6, '6'), (7, '7'), (8, '8'), + ] + +class Arc(models.Model): + """ + Arc of a graph, linking two nodes. + The length is user-adjustable. + """ + graph = models.ForeignKey(Graph, default=None) + added_by = models.ForeignKey(Contributor, default=None) + created = models.DateTimeField(default=timezone.now) + source = models.ForeignKey(Node, related_name='source') + target = models.ForeignKey(Node, related_name='target') + length = models.PositiveSmallIntegerField(choices=ARC_LENGTHS, default=32) + diff --git a/scipost/templates/scipost/graph.html b/scipost/templates/scipost/graph.html index ac3ec9121..19679c37d 100644 --- a/scipost/templates/scipost/graph.html +++ b/scipost/templates/scipost/graph.html @@ -5,7 +5,13 @@ {% block headsup %} <style> -.link { + + .overlay { + fill: none; + pointer-events: all; + } + +.arc { fill: none; stroke: #666; stroke-width: 1.5px; @@ -38,28 +44,29 @@ $(".node_contents").hide(); $(".delete_node").hide(); $("#NodeForm").hide(); -$("#LinkForm").hide(); +$("#ArcForm").hide(); $("#ManageTeamsForm").hide(); $("#ManageTeamsFormButton").click(function(){ $("#ManageTeamsForm").toggle(); $("#NodeForm").hide(); -$("#LinkForm").hide(); +$("#ArcForm").hide(); }); $("#NodeFormButton").click(function(){ $("#ManageTeamsForm").hide(); $("#NodeForm").toggle(); -$("#LinkForm").hide(); +$("#ArcForm").hide(); }); -$("#LinkFormButton").click(function(){ +$("#ArcFormButton").click(function(){ $("#ManageTeamsForm").hide(); $("#NodeForm").hide(); -$("#LinkForm").toggle(); +$("#ArcForm").toggle(); }); }); + d3.json("{% url 'scipost:api_graph' graph_id=graph.id %}", function(error, json) { if (error) return console.warn(error); @@ -71,23 +78,32 @@ nodesjson.forEach(function(node) { nodes[node.name].y = 150 + 100 * Math.random(); }); -var links = json['links']; -links.forEach(function(link) { - link.source = nodes[link.source]; - link.target = nodes[link.target]; +var arcsjson = json['arcs']; +var arcs = []; +arcsjson.forEach(function(arc) { + arcs[arc.id] = arc; + arcs[arc.id].source = nodes[arc.source]; + arcs[arc.id].target = nodes[arc.target]; + arcs[arc.id].length = arc.length; }); +function arclength(arc) { + return arc.length * 50; +} + var width = 600; var height = 300; var force = d3.layout.force() .nodes(d3.values(nodes)) - .links(links) +// .links(arcs) + .links(d3.values(arcs)) .size([width, height]) .friction(0.9) .linkStrength(1) - .linkDistance(80) - .charge(-400) +// .linkDistance(80) + .linkDistance(arclength) + .charge(-40) .gravity(0.1) .theta(0.8) .alpha(0.1) @@ -95,20 +111,23 @@ var force = d3.layout.force() .start(); -// .call(d3.behavior.zoom().on("zoom", rescale)) -function rescale() { - trans=d3.event.translate; - scale=d3.event.scale; - - vis.attr("transform", - "translate(" + trans + ")" - + " scale(" + scale + ")"); -} - - var svg = d3.select("#graphic").append("svg") .attr("width", width) - .attr("height", height); +.attr("height", height) +.append("g") +.call(d3.behavior.zoom().on("zoom", zoom)) +.on("dblclick.zoom", null) +//.on("mousedown.zoom", null) +.append("g"); + +svg.append("rect") +.attr("class", "overlay") +.attr("width", width) +.attr("height", height); + +function zoom() { +svg.attr("transform", "translate(" + d3.event.translate + ")scale(" + d3.event.scale + ")"); +} svg.append("defs").append("marker") .attr("id", "arrowhead") @@ -124,7 +143,7 @@ svg.append("defs").append("marker") var path = svg.append("g").selectAll("path") .data(force.links()) .enter().append("path") - .attr("class", "link") + .attr("class", "arc") .attr("marker-end", "url(#arrowhead)"); var circle = svg.append("g").selectAll("circle") @@ -132,7 +151,8 @@ var circle = svg.append("g").selectAll("circle") .enter().append("circle") .attr("r", 6) .attr("id", function(d) { return d.id; }) - .call(force.drag); +.call(force.drag); + circle.on("click", function(){ d3.selectAll("circle").style("fill", "#ccc"); @@ -142,10 +162,6 @@ circle.on("click", function(){ $(".node_id" + $(this).attr("id")).show(); }); -circle.on("dblclick", function(){ - d3.select(this).style("fill", "red"); -}); - var text = svg.append("g").selectAll("text") .data(force.nodes()) .enter().append("text") @@ -214,13 +230,13 @@ function transform(d) { </table> <input type="submit" value="Create Node" /> </form> - <button class="GraphButton" id="LinkFormButton"><h1>Add a Link</h1></button> - <form action="{% url 'scipost:graph' graph_id=graph.id %}" method="post" id="LinkForm"> + <button class="GraphButton" id="ArcFormButton"><h1>Add an Arc</h1></button> + <form action="{% url 'scipost:graph' graph_id=graph.id %}" method="post" id="ArcForm"> {% csrf_token %} <table> - {{ create_link_form.as_table }} + {{ create_arc_form.as_table }} </table> - <input type="submit" value="Create Link" /> + <input type="submit" value="Create Arc" /> </form> </div> <div class="col-10"> diff --git a/scipost/views.py b/scipost/views.py index a86d55780..e214c2a04 100644 --- a/scipost/views.py +++ b/scipost/views.py @@ -872,13 +872,14 @@ def create_graph(request): def graph(request, graph_id): graph = get_object_or_404(Graph, pk=graph_id) nodes = Node.objects.filter(graph=graph) + arcs = Arc.objects.filter(graph=graph) if request.method == "POST": attach_teams_form = ManageTeamsForm(request.POST, contributor=request.user.contributor, initial={'teams_with_access': graph.teams_with_access.all()} ) create_node_form = CreateNodeForm(request.POST) - create_link_form = CreateLinkForm(request.POST, graph=graph) + create_arc_form = CreateArcForm(request.POST, graph=graph) if attach_teams_form.has_changed() and attach_teams_form.is_valid(): graph.teams_with_access = attach_teams_form.cleaned_data['teams_with_access'] graph.save() @@ -889,22 +890,28 @@ def graph(request, graph_id): name=create_node_form.cleaned_data['name'], description=create_node_form.cleaned_data['description']) newnode.save() - elif create_link_form.has_changed() and create_link_form.is_valid(): - sourcenode = create_link_form.cleaned_data['source'] - targetnode = create_link_form.cleaned_data['target'] + elif create_arc_form.has_changed() and create_arc_form.is_valid(): + sourcenode = create_arc_form.cleaned_data['source'] + targetnode = create_arc_form.cleaned_data['target'] if sourcenode != targetnode: - targetnode.arcs_in.add(sourcenode) - targetnode.save() + newarc = Arc(graph=graph, + added_by=request.user.contributor, + created=timezone.now(), + source=sourcenode, + target=targetnode, + length=create_arc_form.cleaned_data['length'] + ) + newarc.save() else: attach_teams_form = ManageTeamsForm(contributor=request.user.contributor, initial={'teams_with_access': graph.teams_with_access.all()} ) create_node_form = CreateNodeForm() - create_link_form = CreateLinkForm(graph=graph) + create_arc_form = CreateArcForm(graph=graph) context = {'graph': graph, 'nodes': nodes, 'attach_teams_form': attach_teams_form, 'create_node_form': create_node_form, - 'create_link_form': create_link_form} + 'create_arc_form': create_arc_form} return render(request, 'scipost/graph.html', context) @@ -920,9 +927,9 @@ def edit_graph_node(request, node_id): node.description=edit_node_form.cleaned_data['description'] node.save() create_node_form = CreateNodeForm() - create_link_form = CreateLinkForm(graph=node.graph) + create_arc_form = CreateArcForm(graph=node.graph) context = {'create_node_form': create_node_form, - 'create_link_form': create_link_form} + 'create_arc_form': create_arc_form} return redirect(reverse('scipost:graph', kwargs={'graph_id': node.graph.id}), context) else: edit_node_form = CreateNodeForm(instance=node) @@ -938,10 +945,9 @@ def delete_graph_node(request, node_id): raise PermissionDenied else: # Remove all the graph arcs - nodes = Node.objects.filter(graph=node.graph) - for othernode in nodes: - othernode.arcs_in.remove(node) - othernode.save() + Arc.objects.filter(source=node).delete() + Arc.objects.filter(target=node).delete() + # Delete node itself node.delete() return redirect(reverse('scipost:graph', kwargs={'graph_id': node.graph.id})) @@ -951,11 +957,17 @@ def api_graph(request, graph_id): """ Produce JSON data to plot graph """ graph = get_object_or_404(Graph, pk=graph_id) nodes = Node.objects.filter(graph=graph) + arcs = Arc.objects.filter(graph=graph) nodesjson = [] - links = [] + arcsjson = [] for node in nodes: nodesjson.append({'name': node.name, 'id': node.id}) - for origin in node.arcs_in.all(): - links.append({'source': origin.name, 'source_id': origin.id, - 'target': node.name, 'target_id': node.id}) - return JsonResponse({'nodes': nodesjson, 'links': links}, safe=False) +# for origin in node.arcs_in.all(): +# links.append({'source': origin.name, 'source_id': origin.id, +# 'target': node.name, 'target_id': node.id}) + for arc in arcs: + arcsjson.append({'id': arc.id, + 'source': arc.source.name, 'source_id': arc.source.id, + 'target': arc.target.name, 'target_id': arc.target.id, + 'length': arc.length}) + return JsonResponse({'nodes': nodesjson, 'arcs': arcsjson}, safe=False) -- GitLab