From 5299ea8919ccaa76cb3a049e740a96acffd4b926 Mon Sep 17 00:00:00 2001
From: Edgar Costa
Date: Fri, 13 Feb 2026 11:21:07 -0500
Subject: [PATCH 01/12] Replace static PNG isogeny graphs with interactive
Cytoscape.js
Addresses #6686 (excessive whitespace) and #2801 (interactive graphs).
Static matplotlib-rendered PNGs are replaced with Cytoscape.js graphs
featuring clickable nodes (navigate to curve pages), hover tooltips
with curve metadata, and optimal curve highlighting. The properties
sidebar now uses a compact server-generated SVG. Trivial (single-curve)
isogeny classes now display their graph as well.
---
lmfdb/ecnf/isog_class.py | 23 ++-
lmfdb/ecnf/templates/ecnf-isoclass.html | 10 +-
lmfdb/elliptic_curves/isog_class.py | 24 ++-
.../templates/ec-isoclass.html | 16 +-
lmfdb/static/isogeny_graph.js | 138 ++++++++++++++++++
lmfdb/utils/__init__.py | 4 +-
lmfdb/utils/utilities.py | 85 +++++++++++
7 files changed, 277 insertions(+), 23 deletions(-)
create mode 100644 lmfdb/static/isogeny_graph.js
diff --git a/lmfdb/ecnf/isog_class.py b/lmfdb/ecnf/isog_class.py
index 291b75f1c5..35b7d19c26 100644
--- a/lmfdb/ecnf/isog_class.py
+++ b/lmfdb/ecnf/isog_class.py
@@ -1,6 +1,6 @@
from flask import url_for
from lmfdb import db
-from lmfdb.utils import encode_plot, names_and_urls, web_latex
+from lmfdb.utils import graph_to_cytoscape_json, graph_to_svg, names_and_urls, web_latex
from lmfdb.logger import make_logger
from lmfdb.ecnf.WebEllipticCurve import web_ainvs, FIELD
from lmfdb.number_fields.web_number_field import field_pretty, nf_display_knowl
@@ -85,9 +85,16 @@ def make_class(self):
# Create isogeny graph:
self.graph = make_graph(self.isogeny_matrix)
- P = self.graph.plot(edge_labels=True)
- self.graph_img = encode_plot(P, transparent=True)
- self.graph_link = '
' % self.graph_img
+ self.graph_data = graph_to_cytoscape_json(self.graph)
+ # Attach curve URLs and labels to nodes
+ for el in self.graph_data:
+ if el['group'] == 'nodes':
+ idx = int(el['data']['id']) - 1 # 1-indexed labels
+ if 0 <= idx < len(self.db_curves):
+ c = self.db_curves[idx]
+ el['data']['url'] = curve_url(c)
+ el['data']['label'] = c['short_label']
+ self.graph_link = graph_to_svg(self.graph)
self.isogeny_matrix_str = latex(Matrix(self.isogeny_matrix))
self.field = FIELD(self.field_label)
@@ -152,10 +159,10 @@ def make_class(self):
self.friends += [('L-function not available', "")]
self.properties = [('Base field', self.field_name),
- ('Label', self.class_label)]
- if self.class_size > 1:
- self.properties.append((None, self.graph_link))
- self.properties.append(('Conductor', '%s' % self.conductor_label))
+ ('Label', self.class_label),
+ ('Graph', ''),
+ (None, self.graph_link),
+ ('Conductor', '%s' % self.conductor_label)]
if self.rk != '?':
self.properties += [('Rank', '%s' % self.rk)]
diff --git a/lmfdb/ecnf/templates/ecnf-isoclass.html b/lmfdb/ecnf/templates/ecnf-isoclass.html
index 3c634bd423..c878f016f1 100644
--- a/lmfdb/ecnf/templates/ecnf-isoclass.html
+++ b/lmfdb/ecnf/templates/ecnf-isoclass.html
@@ -66,9 +66,13 @@ {{ KNOWL('ec.isogeny_matrix',title='Isogeny matrix') }}
{{ KNOWL('ec.isogeny_graph',title='Isogeny graph') }}
{% if cl.isogeny_matrix %}
-
-
-
+
+
+
+
+
{% else %}
Not available.
{% endif %}
diff --git a/lmfdb/elliptic_curves/isog_class.py b/lmfdb/elliptic_curves/isog_class.py
index a7d08dd61b..09d24005e0 100644
--- a/lmfdb/elliptic_curves/isog_class.py
+++ b/lmfdb/elliptic_curves/isog_class.py
@@ -1,5 +1,5 @@
from flask import url_for
-from lmfdb.utils import encode_plot, prop_int_pretty, raw_typeset, integer_squarefree_part, list_to_factored_poly_otherorder
+from lmfdb.utils import graph_to_cytoscape_json, graph_to_svg, prop_int_pretty, raw_typeset, integer_squarefree_part, list_to_factored_poly_otherorder
from lmfdb.elliptic_curves import ec_logger
from lmfdb.elliptic_curves.web_ec import split_lmfdb_label, split_cremona_label, OPTIMALITY_BOUND, CREMONA_BOUND
from lmfdb.number_fields.web_number_field import field_pretty
@@ -127,9 +127,22 @@ def perm(i): return next(c for c in self.curves if c['Cnumber'] == i+1)['lmfdb_n
# Create isogeny graph with appropriate vertex labels:
self.graph = make_graph(M, [c['short_label'] for c in self.curves])
- P = self.graph.plot(edge_labels=True, vertex_size=1000)
- self.graph_img = encode_plot(P, transparent=True)
- self.graph_link = '
' % self.graph_img
+ self.graph_data = graph_to_cytoscape_json(self.graph)
+ # Attach curve metadata to nodes for tooltip display
+ curve_by_label = {c['short_label']: c for c in self.curves}
+ for el in self.graph_data:
+ if el['group'] == 'nodes':
+ label = el['data']['label']
+ c = curve_by_label.get(label)
+ if c:
+ el['data']['url'] = c['curve_url_lmfdb']
+ el['data']['torsion'] = str(c['torsion_structure'])
+ el['data']['degree'] = c['degree']
+ el['data']['faltings_height'] = str(c['FH'])
+ el['data']['optimal'] = bool(c.get('optimal'))
+ el['data']['j_inv'] = str(c['j_inv'])
+ # SVG for properties sidebar
+ self.graph_link = graph_to_svg(self.graph)
self.newform = raw_typeset(PowerSeriesRing(QQ, 'q')(classdata['anlist'], 20, check=True))
self.newform_label = ".".join([str(self.conductor), str(2), 'a', self.iso_label])
@@ -170,8 +183,7 @@ def perm(i): return next(c for c in self.curves if c['Cnumber'] == i+1)['lmfdb_n
('CM', '%s' % self.CMfield),
('Rank', prop_int_pretty(self.rank))
]
- if ncurves > 1:
- self.properties += [('Graph', ''),(None, self.graph_link)]
+ self.properties += [('Graph', ''), (None, self.graph_link)]
self.downloads = [('q-expansion to text', url_for(".download_EC_qexp", label=self.lmfdb_iso, limit=1000)),
('All stored data to text', url_for(".download_EC_all", label=self.lmfdb_iso)),
diff --git a/lmfdb/elliptic_curves/templates/ec-isoclass.html b/lmfdb/elliptic_curves/templates/ec-isoclass.html
index 7665e75fe0..2cfa1a6c10 100644
--- a/lmfdb/elliptic_curves/templates/ec-isoclass.html
+++ b/lmfdb/elliptic_curves/templates/ec-isoclass.html
@@ -107,7 +107,7 @@
});
-{% if info.class_size>1 %}
+{% if info.class_size > 1 %}
{{ KNOWL('ec.isogeny_matrix',title='Isogeny matrix') }}
@@ -118,14 +118,20 @@ {{ KNOWL('ec.isogeny_matrix',title='Isogeny matrix') }}
\({{info.isogeny_matrix_str}}\)
+{% endif %}
+
{{ KNOWL('ec.isogeny_graph', title='Isogeny graph') }}
{{ place_code('plot') }}
+{% if info.class_size > 1 %}
The vertices are labelled with {{info.label_type}} labels.
-
-
-
-
{% endif %}
+
+
+
+
+
Elliptic curves in class {{info.class_label}}
{{ place_code('curves') }}
diff --git a/lmfdb/static/isogeny_graph.js b/lmfdb/static/isogeny_graph.js
new file mode 100644
index 0000000000..8d8a79505e
--- /dev/null
+++ b/lmfdb/static/isogeny_graph.js
@@ -0,0 +1,138 @@
+/**
+ * Interactive isogeny graph rendering using Cytoscape.js
+ *
+ * Usage: initIsogenyGraph('container-id', elementsJSON)
+ */
+
+function _isoTooltipLine(parent, text, isMath) {
+ var span = document.createElement('span');
+ span.textContent = text;
+ parent.appendChild(span);
+ parent.appendChild(document.createElement('br'));
+}
+
+function _isoTooltipBold(parent, text) {
+ var b = document.createElement('strong');
+ b.textContent = text;
+ parent.appendChild(b);
+ parent.appendChild(document.createElement('br'));
+}
+
+function _isoTooltipEmphasis(parent, text) {
+ var em = document.createElement('em');
+ em.textContent = text;
+ parent.appendChild(em);
+ parent.appendChild(document.createElement('br'));
+}
+
+function initIsogenyGraph(containerId, elements) {
+ var container = document.getElementById(containerId);
+ if (!container || !elements || elements.length === 0) return;
+
+ var cy = cytoscape({
+ container: container,
+ elements: elements,
+ layout: { name: 'preset' },
+ userZoomingEnabled: false,
+ userPanningEnabled: false,
+ boxSelectionEnabled: false,
+ style: [
+ {
+ selector: 'node',
+ style: {
+ 'label': 'data(label)',
+ 'text-valign': 'center',
+ 'text-halign': 'center',
+ 'font-size': '12px',
+ 'font-family': 'sans-serif',
+ 'background-color': '#fff',
+ 'border-width': 2,
+ 'border-color': '#555',
+ 'width': 60,
+ 'height': 30,
+ 'shape': 'round-rectangle',
+ 'color': '#333',
+ 'cursor': 'pointer'
+ }
+ },
+ {
+ selector: 'node[?optimal]',
+ style: {
+ 'border-width': 3,
+ 'border-color': '#0055a2',
+ 'background-color': '#e8f0fe'
+ }
+ },
+ {
+ selector: 'edge',
+ style: {
+ 'label': 'data(label)',
+ 'font-size': '11px',
+ 'text-background-color': '#fff',
+ 'text-background-opacity': 0.85,
+ 'text-background-padding': '2px',
+ 'line-color': '#888',
+ 'width': 1.5,
+ 'curve-style': 'bezier',
+ 'color': '#555'
+ }
+ }
+ ]
+ });
+
+ cy.fit(30);
+
+ // Minimum size enforcement: if the graph is too small, zoom out a bit
+ var bb = cy.elements().boundingBox();
+ if (bb.w < 80 && bb.h < 80) {
+ cy.zoom(cy.zoom() * 0.7);
+ cy.center();
+ }
+
+ // Create tooltip element
+ var tooltip = document.createElement('div');
+ tooltip.className = 'isogeny-tooltip';
+ tooltip.style.cssText = 'display:none; position:absolute; background:#fff; ' +
+ 'border:1px solid #ccc; border-radius:4px; padding:8px 12px; ' +
+ 'font-size:13px; line-height:1.5; box-shadow:0 2px 8px rgba(0,0,0,0.15); ' +
+ 'z-index:1000; pointer-events:none; max-width:280px;';
+ container.style.position = 'relative';
+ container.appendChild(tooltip);
+
+ // Hover: show tooltip with curve metadata
+ cy.on('mouseover', 'node', function(evt) {
+ var node = evt.target;
+ var d = node.data();
+
+ // Clear previous content safely
+ while (tooltip.firstChild) tooltip.removeChild(tooltip.firstChild);
+
+ _isoTooltipBold(tooltip, d.label);
+ if (d.j_inv !== undefined) _isoTooltipLine(tooltip, 'j-invariant: ' + d.j_inv);
+ if (d.torsion !== undefined) _isoTooltipLine(tooltip, 'Torsion: ' + d.torsion);
+ if (d.degree !== undefined && d.degree !== 0) _isoTooltipLine(tooltip, 'Modular degree: ' + d.degree);
+ if (d.faltings_height !== undefined) _isoTooltipLine(tooltip, 'Faltings height: ' + d.faltings_height);
+ if (d.optimal) _isoTooltipEmphasis(tooltip, 'Optimal curve');
+
+ tooltip.style.display = 'block';
+
+ // Position tooltip near the node
+ var pos = node.renderedPosition();
+ tooltip.style.left = (pos.x + 15) + 'px';
+ tooltip.style.top = (pos.y - 10) + 'px';
+ });
+
+ cy.on('mouseout', 'node', function() {
+ tooltip.style.display = 'none';
+ });
+
+ // Click: navigate to curve page
+ cy.on('tap', 'node', function(evt) {
+ var url = evt.target.data('url');
+ if (url) {
+ window.location.href = url;
+ }
+ });
+
+ return cy;
+}
diff --git a/lmfdb/utils/__init__.py b/lmfdb/utils/__init__.py
index c959d337be..702fa2ff06 100644
--- a/lmfdb/utils/__init__.py
+++ b/lmfdb/utils/__init__.py
@@ -19,7 +19,7 @@
'code_snippet_knowl', 'integer_divisors', 'integer_prime_divisors',
'Pagination', 'to_ordinal',
'debug', 'flash_error', 'flash_warning', 'flash_info',
- 'image_callback', 'encode_plot',
+ 'image_callback', 'encode_plot', 'graph_to_cytoscape_json', 'graph_to_svg',
'parse_ints', 'parse_posints', 'parse_signed_ints', 'parse_floats',
'parse_mod1', 'parse_rational', 'parse_padicfields',
'parse_rational_to_list', 'parse_inertia', 'parse_group_label_or_order',
@@ -73,6 +73,8 @@
display_float,
display_multiset,
encode_plot,
+ graph_to_cytoscape_json,
+ graph_to_svg,
factor_base_factor,
flash_error,
flash_info,
diff --git a/lmfdb/utils/utilities.py b/lmfdb/utils/utilities.py
index 735bfb3dc9..39660af8f2 100644
--- a/lmfdb/utils/utilities.py
+++ b/lmfdb/utils/utilities.py
@@ -951,6 +951,91 @@ def encode_plot(P, pad=None, pad_inches=0.1, remove_axes=False, axes_pad=None, f
return "data:image/png;base64," + quote(b64encode(buf))
+def graph_to_cytoscape_json(G):
+ """Convert a Sage Graph with positions to Cytoscape.js elements format.
+
+ The graph should have positions set via set_pos() (as done by make_graph
+ in the isogeny class modules). Returns a list of dicts suitable for
+ passing to cytoscape({elements: ...}).
+ """
+ pos = G.get_pos()
+ elements = []
+ for v in G.vertices():
+ if pos and v in pos:
+ x, y = pos[v]
+ else:
+ x, y = 0, 0
+ elements.append({
+ "group": "nodes",
+ "data": {"id": str(v), "label": str(v)},
+ "position": {"x": float(x * 150), "y": float(-y * 150)},
+ })
+ for u, v, label in G.edges():
+ elements.append({
+ "group": "edges",
+ "data": {"source": str(u), "target": str(v), "label": str(label)},
+ })
+ return elements
+
+
+def graph_to_svg(G, width=200, height=150):
+ """Generate a compact SVG string for the properties box sidebar.
+
+ Produces a lightweight inline SVG from a Sage Graph with positions,
+ suitable for embedding directly in HTML.
+ """
+ from markupsafe import Markup
+ pos = G.get_pos()
+ vertices = G.vertices()
+ n = len(vertices)
+
+ if n == 0:
+ return Markup('' % (width, height))
+
+ # For single vertex, center it
+ if pos is None or n == 1:
+ coords = {v: (width / 2, height / 2) for v in vertices}
+ else:
+ xs = [pos[v][0] for v in vertices]
+ ys = [pos[v][1] for v in vertices]
+ min_x, max_x = min(xs), max(xs)
+ min_y, max_y = min(ys), max(ys)
+ range_x = max_x - min_x if max_x != min_x else 1
+ range_y = max_y - min_y if max_y != min_y else 1
+ pad = 30
+ coords = {}
+ for v in vertices:
+ cx = pad + (pos[v][0] - min_x) / range_x * (width - 2 * pad)
+ cy = pad + (max_y - pos[v][1]) / range_y * (height - 2 * pad)
+ coords[v] = (cx, cy)
+
+ parts = ['')
+ return Markup('\n'.join(parts))
+
+
class WebObj:
def __init__(self, label, data=None):
self.label = label
From 816f01d873594566773492d6109abd5c29ae5645 Mon Sep 17 00:00:00 2001
From: Edgar Costa
Date: Fri, 13 Feb 2026 11:31:15 -0500
Subject: [PATCH 02/12] Auto-size isogeny graph container to fit content
Compute container dimensions from actual node positions instead of
using a fixed 600x400 box. Eliminates excessive vertical whitespace
for small/horizontal graphs (e.g. 2-node classes).
---
lmfdb/ecnf/templates/ecnf-isoclass.html | 2 +-
.../elliptic_curves/templates/ec-isoclass.html | 2 +-
lmfdb/static/isogeny_graph.js | 17 +++++++++++++++++
3 files changed, 19 insertions(+), 2 deletions(-)
diff --git a/lmfdb/ecnf/templates/ecnf-isoclass.html b/lmfdb/ecnf/templates/ecnf-isoclass.html
index c878f016f1..0f95ae56b5 100644
--- a/lmfdb/ecnf/templates/ecnf-isoclass.html
+++ b/lmfdb/ecnf/templates/ecnf-isoclass.html
@@ -66,7 +66,7 @@ {{ KNOWL('ec.isogeny_matrix',title='Isogeny matrix') }}
{{ KNOWL('ec.isogeny_graph',title='Isogeny graph') }}
{% if cl.isogeny_matrix %}
-
+
diff --git a/lmfdb/elliptic_curves/templates/ec-isoclass.html b/lmfdb/elliptic_curves/templates/ec-isoclass.html
index 2cfa1a6c10..8c73c3f958 100644
--- a/lmfdb/elliptic_curves/templates/ec-isoclass.html
+++ b/lmfdb/elliptic_curves/templates/ec-isoclass.html
@@ -125,7 +125,7 @@ {{ KNOWL('ec.isogeny_graph', title='Isogeny graph') }}
{% if info.class_size > 1 %}
The vertices are labelled with {{info.label_type}} labels.
{% endif %}
-
+
diff --git a/lmfdb/static/isogeny_graph.js b/lmfdb/static/isogeny_graph.js
index 8d8a79505e..d18416dc98 100644
--- a/lmfdb/static/isogeny_graph.js
+++ b/lmfdb/static/isogeny_graph.js
@@ -29,6 +29,23 @@ function initIsogenyGraph(containerId, elements) {
var container = document.getElementById(containerId);
if (!container || !elements || elements.length === 0) return;
+ // Auto-size container based on actual graph positions
+ var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
+ for (var i = 0; i < elements.length; i++) {
+ if (elements[i].group === 'nodes' && elements[i].position) {
+ var p = elements[i].position;
+ if (p.x < minX) minX = p.x;
+ if (p.x > maxX) maxX = p.x;
+ if (p.y < minY) minY = p.y;
+ if (p.y > maxY) maxY = p.y;
+ }
+ }
+ var graphW = (maxX > -Infinity) ? maxX - minX : 0;
+ var graphH = (maxY > -Infinity) ? maxY - minY : 0;
+ // Padding accounts for node size (60x30) + edge labels + margin
+ container.style.width = Math.min(600, Math.max(200, graphW + 160)) + 'px';
+ container.style.height = Math.min(400, Math.max(80, graphH + 100)) + 'px';
+
var cy = cytoscape({
container: container,
elements: elements,
From 0fe497f4693ce8c755db669614f723c2f9484b85 Mon Sep 17 00:00:00 2001
From: Edgar Costa
Date: Fri, 13 Feb 2026 11:33:03 -0500
Subject: [PATCH 03/12] Left-align isogeny graph containers
---
lmfdb/ecnf/templates/ecnf-isoclass.html | 2 +-
lmfdb/elliptic_curves/templates/ec-isoclass.html | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/lmfdb/ecnf/templates/ecnf-isoclass.html b/lmfdb/ecnf/templates/ecnf-isoclass.html
index 0f95ae56b5..295c065262 100644
--- a/lmfdb/ecnf/templates/ecnf-isoclass.html
+++ b/lmfdb/ecnf/templates/ecnf-isoclass.html
@@ -66,7 +66,7 @@ {{ KNOWL('ec.isogeny_matrix',title='Isogeny matrix') }}
{{ KNOWL('ec.isogeny_graph',title='Isogeny graph') }}
{% if cl.isogeny_matrix %}
-
+
diff --git a/lmfdb/elliptic_curves/templates/ec-isoclass.html b/lmfdb/elliptic_curves/templates/ec-isoclass.html
index 8c73c3f958..d7e0e02d97 100644
--- a/lmfdb/elliptic_curves/templates/ec-isoclass.html
+++ b/lmfdb/elliptic_curves/templates/ec-isoclass.html
@@ -125,7 +125,7 @@ {{ KNOWL('ec.isogeny_graph', title='Isogeny graph') }}
{% if info.class_size > 1 %}
The vertices are labelled with {{info.label_type}} labels.
{% endif %}
-
+
From 23d2056006baadf7165db534bdf75a75541ef4da Mon Sep 17 00:00:00 2001
From: Edgar Costa
Date: Fri, 13 Feb 2026 11:37:56 -0500
Subject: [PATCH 04/12] Fix ECNF graphs with no preset positions; add class
size to sidebar
For ECNF isogeny classes with topologies not handled by make_graph
(e.g. 18 curves), fall back to cytoscape's cose layout instead of
placing all nodes at origin. SVG sidebar uses circular layout as
fallback. Add 'Number of curves' to ECNF properties sidebar.
---
lmfdb/ecnf/isog_class.py | 1 +
lmfdb/static/isogeny_graph.js | 32 +++++++++++++++++++++++++-------
lmfdb/utils/utilities.py | 25 ++++++++++++++++---------
3 files changed, 42 insertions(+), 16 deletions(-)
diff --git a/lmfdb/ecnf/isog_class.py b/lmfdb/ecnf/isog_class.py
index 35b7d19c26..d494b3b772 100644
--- a/lmfdb/ecnf/isog_class.py
+++ b/lmfdb/ecnf/isog_class.py
@@ -160,6 +160,7 @@ def make_class(self):
self.properties = [('Base field', self.field_name),
('Label', self.class_label),
+ ('Number of curves', str(self.class_size)),
('Graph', ''),
(None, self.graph_link),
('Conductor', '%s' % self.conductor_label)]
diff --git a/lmfdb/static/isogeny_graph.js b/lmfdb/static/isogeny_graph.js
index d18416dc98..d16dbdfc70 100644
--- a/lmfdb/static/isogeny_graph.js
+++ b/lmfdb/static/isogeny_graph.js
@@ -29,10 +29,12 @@ function initIsogenyGraph(containerId, elements) {
var container = document.getElementById(containerId);
if (!container || !elements || elements.length === 0) return;
- // Auto-size container based on actual graph positions
+ // Check if positions are provided (preset layout) or need auto-layout
+ var hasPositions = false;
var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (var i = 0; i < elements.length; i++) {
if (elements[i].group === 'nodes' && elements[i].position) {
+ hasPositions = true;
var p = elements[i].position;
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
@@ -40,16 +42,32 @@ function initIsogenyGraph(containerId, elements) {
if (p.y > maxY) maxY = p.y;
}
}
- var graphW = (maxX > -Infinity) ? maxX - minX : 0;
- var graphH = (maxY > -Infinity) ? maxY - minY : 0;
- // Padding accounts for node size (60x30) + edge labels + margin
- container.style.width = Math.min(600, Math.max(200, graphW + 160)) + 'px';
- container.style.height = Math.min(400, Math.max(80, graphH + 100)) + 'px';
+
+ if (hasPositions) {
+ var graphW = maxX - minX;
+ var graphH = maxY - minY;
+ container.style.width = Math.min(600, Math.max(200, graphW + 160)) + 'px';
+ container.style.height = Math.min(400, Math.max(80, graphH + 100)) + 'px';
+ } else {
+ // Count nodes to scale container for auto-layout
+ var nNodes = 0;
+ for (var i = 0; i < elements.length; i++) {
+ if (elements[i].group === 'nodes') nNodes++;
+ }
+ var side = Math.min(600, Math.max(250, nNodes * 40));
+ container.style.width = side + 'px';
+ container.style.height = side + 'px';
+ }
+
+ var layoutOpts = hasPositions
+ ? { name: 'preset' }
+ : { name: 'cose', animate: false, nodeRepulsion: function() { return 8000; },
+ idealEdgeLength: function() { return 80; }, padding: 30 };
var cy = cytoscape({
container: container,
elements: elements,
- layout: { name: 'preset' },
+ layout: layoutOpts,
userZoomingEnabled: false,
userPanningEnabled: false,
boxSelectionEnabled: false,
diff --git a/lmfdb/utils/utilities.py b/lmfdb/utils/utilities.py
index 39660af8f2..cb4876e4c0 100644
--- a/lmfdb/utils/utilities.py
+++ b/lmfdb/utils/utilities.py
@@ -959,17 +959,17 @@ def graph_to_cytoscape_json(G):
passing to cytoscape({elements: ...}).
"""
pos = G.get_pos()
+ has_pos = pos is not None and len(pos) == len(G.vertices())
elements = []
for v in G.vertices():
- if pos and v in pos:
- x, y = pos[v]
- else:
- x, y = 0, 0
- elements.append({
+ node = {
"group": "nodes",
"data": {"id": str(v), "label": str(v)},
- "position": {"x": float(x * 150), "y": float(-y * 150)},
- })
+ }
+ if has_pos:
+ x, y = pos[v]
+ node["position"] = {"x": float(x * 150), "y": float(-y * 150)}
+ elements.append(node)
for u, v, label in G.edges():
elements.append({
"group": "edges",
@@ -992,9 +992,16 @@ def graph_to_svg(G, width=200, height=150):
if n == 0:
return Markup('' % (width, height))
- # For single vertex, center it
- if pos is None or n == 1:
+ # For single vertex, center it; for missing positions, use circular layout
+ if n == 1:
coords = {v: (width / 2, height / 2) for v in vertices}
+ elif pos is None or len(pos) < n:
+ cx0, cy0 = width / 2, height / 2
+ r0 = min(width, height) / 2 - 20
+ coords = {}
+ for i, v in enumerate(vertices):
+ angle = 2 * math.pi * i / n - math.pi / 2
+ coords[v] = (cx0 + r0 * math.cos(angle), cy0 + r0 * math.sin(angle))
else:
xs = [pos[v][0] for v in vertices]
ys = [pos[v][1] for v in vertices]
From ecec5bc4ee17e0031f4d65fc6f2a0f0e350b1859 Mon Sep 17 00:00:00 2001
From: Edgar Costa
Date: Fri, 13 Feb 2026 15:12:50 -0500
Subject: [PATCH 05/12] Add layout testing dropdown and refine isogeny graph
display
- Extract CDN scripts into shared cytoscape_scripts.html template
- Add layout dropdown with 18 options (7 built-in + 11 extensions)
including cola, dagre, klay, elk variants, fcose, etc.
- Default to preset layout when make_graph provides positions,
elk(stress) otherwise; hide preset option when not available
- Sidebar SVG: use layout_spring() fallback, remove labels, use
smaller filled dots for a cleaner compact appearance
---
lmfdb/ecnf/templates/ecnf-isoclass.html | 3 +-
.../templates/ec-isoclass.html | 3 +-
lmfdb/static/isogeny_graph.js | 93 +++++++++++++++++--
lmfdb/templates/cytoscape_scripts.html | 23 +++++
lmfdb/utils/utilities.py | 28 ++----
5 files changed, 121 insertions(+), 29 deletions(-)
create mode 100644 lmfdb/templates/cytoscape_scripts.html
diff --git a/lmfdb/ecnf/templates/ecnf-isoclass.html b/lmfdb/ecnf/templates/ecnf-isoclass.html
index 295c065262..3892804918 100644
--- a/lmfdb/ecnf/templates/ecnf-isoclass.html
+++ b/lmfdb/ecnf/templates/ecnf-isoclass.html
@@ -68,8 +68,7 @@ {{ KNOWL('ec.isogeny_graph',title='Isogeny graph') }}
{% if cl.isogeny_matrix %}
-
-
+{% include "cytoscape_scripts.html" %}
diff --git a/lmfdb/elliptic_curves/templates/ec-isoclass.html b/lmfdb/elliptic_curves/templates/ec-isoclass.html
index d7e0e02d97..1586678465 100644
--- a/lmfdb/elliptic_curves/templates/ec-isoclass.html
+++ b/lmfdb/elliptic_curves/templates/ec-isoclass.html
@@ -127,8 +127,7 @@ {{ KNOWL('ec.isogeny_graph', title='Isogeny graph') }}
{% endif %}
-
-
+{% include "cytoscape_scripts.html" %}
diff --git a/lmfdb/static/isogeny_graph.js b/lmfdb/static/isogeny_graph.js
index d16dbdfc70..cdc03f305d 100644
--- a/lmfdb/static/isogeny_graph.js
+++ b/lmfdb/static/isogeny_graph.js
@@ -29,7 +29,7 @@ function initIsogenyGraph(containerId, elements) {
var container = document.getElementById(containerId);
if (!container || !elements || elements.length === 0) return;
- // Check if positions are provided (preset layout) or need auto-layout
+ // Check if preset positions are provided
var hasPositions = false;
var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (var i = 0; i < elements.length; i++) {
@@ -49,7 +49,6 @@ function initIsogenyGraph(containerId, elements) {
container.style.width = Math.min(600, Math.max(200, graphW + 160)) + 'px';
container.style.height = Math.min(400, Math.max(80, graphH + 100)) + 'px';
} else {
- // Count nodes to scale container for auto-layout
var nNodes = 0;
for (var i = 0; i < elements.length; i++) {
if (elements[i].group === 'nodes') nNodes++;
@@ -59,10 +58,8 @@ function initIsogenyGraph(containerId, elements) {
container.style.height = side + 'px';
}
- var layoutOpts = hasPositions
- ? { name: 'preset' }
- : { name: 'cose', animate: false, nodeRepulsion: function() { return 8000; },
- idealEdgeLength: function() { return 80; }, padding: 30 };
+ var elkStress = { name: 'elk', animate: false, padding: 30, elk: { algorithm: 'stress' } };
+ var layoutOpts = hasPositions ? { name: 'preset' } : elkStress;
var cy = cytoscape({
container: container,
@@ -124,6 +121,90 @@ function initIsogenyGraph(containerId, elements) {
cy.center();
}
+ // Save original container dimensions for preset restore
+ var origWidth = container.style.width;
+ var origHeight = container.style.height;
+
+ // Layout selector for testing
+ var layouts = {
+ // Built-in
+ 'preset': { name: 'preset' },
+ 'cose': { name: 'cose', animate: false, padding: 30 },
+ 'circle': { name: 'circle', animate: false, padding: 30 },
+ 'concentric': { name: 'concentric', animate: false, padding: 30,
+ concentric: function(node) { return node.degree(); },
+ levelWidth: function() { return 2; } },
+ 'breadthfirst': { name: 'breadthfirst', animate: false, padding: 30 },
+ 'grid': { name: 'grid', animate: false, padding: 30 },
+ 'random': { name: 'random', animate: false, padding: 30 },
+ // Extensions
+ 'fcose': { name: 'fcose', animate: false, padding: 30 },
+ 'cola': { name: 'cola', animate: false, padding: 30 },
+ 'dagre': { name: 'dagre', animate: false, padding: 30 },
+ 'avsdf': { name: 'avsdf', animate: false, padding: 30 },
+ 'cise': { name: 'cise', animate: false, padding: 30 },
+ 'klay': { name: 'klay', animate: false, padding: 30 },
+ 'cose-bilkent': { name: 'cose-bilkent', animate: false, padding: 30 },
+ 'elk (layered)': { name: 'elk', animate: false, padding: 30, elk: { algorithm: 'layered' } },
+ 'elk (mrtree)': { name: 'elk', animate: false, padding: 30, elk: { algorithm: 'mrtree' } },
+ 'elk (stress)': { name: 'elk', animate: false, padding: 30, elk: { algorithm: 'stress' } },
+ 'elk (force)': { name: 'elk', animate: false, padding: 30, elk: { algorithm: 'force' } }
+ };
+
+ var controls = document.createElement('div');
+ controls.style.cssText = 'margin: 8px 0;';
+ var label = document.createElement('label');
+ label.textContent = 'Layout: ';
+ label.style.fontWeight = 'bold';
+ var select = document.createElement('select');
+ var defaultLayout = hasPositions ? 'preset' : 'elk (stress)';
+ var layoutNames = Object.keys(layouts);
+ for (var li = 0; li < layoutNames.length; li++) {
+ if (layoutNames[li] === 'preset' && !hasPositions) continue;
+ var opt = document.createElement('option');
+ opt.value = layoutNames[li];
+ opt.textContent = layoutNames[li];
+ if (layoutNames[li] === defaultLayout) opt.selected = true;
+ select.appendChild(opt);
+ }
+ select.addEventListener('change', function() {
+ if (select.value === 'preset') {
+ // Restore original positions and container size
+ for (var ei = 0; ei < elements.length; ei++) {
+ if (elements[ei].group === 'nodes' && elements[ei].position) {
+ cy.getElementById(elements[ei].data.id).position(elements[ei].position);
+ }
+ }
+ container.style.width = origWidth;
+ container.style.height = origHeight;
+ cy.resize();
+ cy.fit(30);
+ } else {
+ // Ensure enough room for computed layouts
+ container.style.width = '500px';
+ container.style.height = '400px';
+ cy.resize();
+ try {
+ cy.layout(layouts[select.value]).run();
+ cy.fit(30);
+ } catch (e) {
+ console.warn('Layout "' + select.value + '" failed:', e.message);
+ // Show inline error
+ var msg = document.createElement('div');
+ msg.textContent = 'Layout "' + select.value + '" not available: ' + e.message;
+ msg.style.cssText = 'color:#c00; font-size:13px; margin-top:4px;';
+ if (controls.querySelector('.layout-error')) {
+ controls.removeChild(controls.querySelector('.layout-error'));
+ }
+ msg.className = 'layout-error';
+ controls.appendChild(msg);
+ }
+ }
+ });
+ label.appendChild(select);
+ controls.appendChild(label);
+ container.parentNode.insertBefore(controls, container);
+
// Create tooltip element
var tooltip = document.createElement('div');
tooltip.className = 'isogeny-tooltip';
diff --git a/lmfdb/templates/cytoscape_scripts.html b/lmfdb/templates/cytoscape_scripts.html
new file mode 100644
index 0000000000..6c1aa83f87
--- /dev/null
+++ b/lmfdb/templates/cytoscape_scripts.html
@@ -0,0 +1,23 @@
+{# Shared Cytoscape.js scripts for interactive isogeny graphs.
+ Include this template where needed, then call:
+ initIsogenyGraph('container-id', elementsJSON);
+#}
+
+{# Layout extension dependencies (engines must load before their adapters) #}
+
+
+
+
+
+
+
+{# Layout extensions #}
+
+
+
+
+
+
+
+
+
diff --git a/lmfdb/utils/utilities.py b/lmfdb/utils/utilities.py
index cb4876e4c0..d28394c0c7 100644
--- a/lmfdb/utils/utilities.py
+++ b/lmfdb/utils/utilities.py
@@ -992,16 +992,13 @@ def graph_to_svg(G, width=200, height=150):
if n == 0:
return Markup('' % (width, height))
- # For single vertex, center it; for missing positions, use circular layout
+ # Compute positions if not set by make_graph
+ if pos is None or len(pos) < n:
+ pos = G.layout_spring()
+
+ # For single vertex, center it
if n == 1:
coords = {v: (width / 2, height / 2) for v in vertices}
- elif pos is None or len(pos) < n:
- cx0, cy0 = width / 2, height / 2
- r0 = min(width, height) / 2 - 20
- coords = {}
- for i, v in enumerate(vertices):
- angle = 2 * math.pi * i / n - math.pi / 2
- coords[v] = (cx0 + r0 * math.cos(angle), cy0 + r0 * math.sin(angle))
else:
xs = [pos[v][0] for v in vertices]
ys = [pos[v][1] for v in vertices]
@@ -1009,7 +1006,7 @@ def graph_to_svg(G, width=200, height=150):
min_y, max_y = min(ys), max(ys)
range_x = max_x - min_x if max_x != min_x else 1
range_y = max_y - min_y if max_y != min_y else 1
- pad = 30
+ pad = 15
coords = {}
for v in vertices:
cx = pad + (pos[v][0] - min_x) / range_x * (width - 2 * pad)
@@ -1024,20 +1021,13 @@ def graph_to_svg(G, width=200, height=150):
x2, y2 = coords[v]
parts.append('' % (x1, y1, x2, y2))
- # Edge label at midpoint
- mx, my = (x1 + x2) / 2, (y1 + y2) / 2
- parts.append('%s' % (mx, my, str(label)))
# Draw nodes
- r = 14
+ r = 5
for v in vertices:
cx, cy = coords[v]
- parts.append('' % (cx, cy, r))
- parts.append('%s' % (cx, cy, str(v)))
+ parts.append('' % (cx, cy, r))
parts.append('')
return Markup('\n'.join(parts))
From ac9c82e1218e7eb4f67c59b378644968b3d14a6d Mon Sep 17 00:00:00 2001
From: Edgar Costa
Date: Fri, 13 Feb 2026 15:12:50 -0500
Subject: [PATCH 06/12] Address PR review feedback for isogeny graphs
- Adaptive SVG sizing in sidebar to reduce whitespace for small graphs
- Shrink square graph coordinates to match double-square spacing
- Spread out tent graph (maxdegree=12) to prevent node overlap
- Set position for single-vertex graphs; compact container with no
layout dropdown
- Use short labels (e.g. "a1") for ECNF graph nodes
- Add torsion and CM to ECNF node tooltips
---
lmfdb/ecnf/isog_class.py | 17 ++++---
lmfdb/elliptic_curves/isog_class.py | 12 ++---
lmfdb/static/isogeny_graph.js | 72 +++++++++++++++++++++--------
lmfdb/utils/utilities.py | 56 +++++++++++++++-------
4 files changed, 108 insertions(+), 49 deletions(-)
diff --git a/lmfdb/ecnf/isog_class.py b/lmfdb/ecnf/isog_class.py
index d494b3b772..c96764c181 100644
--- a/lmfdb/ecnf/isog_class.py
+++ b/lmfdb/ecnf/isog_class.py
@@ -93,7 +93,10 @@ def make_class(self):
if 0 <= idx < len(self.db_curves):
c = self.db_curves[idx]
el['data']['url'] = curve_url(c)
- el['data']['label'] = c['short_label']
+ el['data']['label'] = c['iso_label'] + str(c['number'])
+ el['data']['torsion'] = str(c.get('torsion_structure', []))
+ if c.get('cm'):
+ el['data']['cm'] = str(c['cm'])
self.graph_link = graph_to_svg(self.graph)
self.isogeny_matrix_str = latex(Matrix(self.isogeny_matrix))
@@ -195,7 +198,7 @@ def make_graph(M):
# once other isogeny classes are implemented.
if n == 1:
# one vertex
- pass
+ G.set_pos(pos={0:[0,0]})
elif n == 2:
# one edge, two vertices. We align horizontally and put
# the lower number on the left vertex.
@@ -222,7 +225,7 @@ def make_graph(M):
# square
opp = [i for i in range(1, 4) if not MM[0, i].is_prime()][0]
other = [i for i in range(1, 4) if i != opp]
- G.set_pos(pos={0: [1, 1], other[0]: [-1, 1], opp: [-1, -1], other[1]: [1, -1]})
+ G.set_pos(pos={0: [0.5, 0.5], other[0]: [-0.5, 0.5], opp: [-0.5, -0.5], other[1]: [0.5, -0.5]})
elif maxdegree == 8:
# 8>o--o<8
centers = [i for i in range(6) if list(MM.row(i)).count(2) == 3]
@@ -252,10 +255,10 @@ def make_graph(M):
right = []
for i in range(3):
right.append([j for j in range(8) if MM[centers[1], j] == 2 and MM[left[i], j] == 3][0])
- G.set_pos(pos={centers[0]: [-0.3, 0], centers[1]: [0.3, 0],
- left[0]: [-0.14, 0.15], right[0]: [0.14, 0.15],
- left[1]: [-0.14, -0.15], right[1]: [0.14, -0.15],
- left[2]: [-0.14, -0.3], right[2]: [0.14, -0.3]})
+ G.set_pos(pos={centers[0]: [-0.5, 0], centers[1]: [0.5, 0],
+ left[0]: [-1.5, 1], right[0]: [1.5, 1],
+ left[1]: [-1.5, 0], right[1]: [1.5, 0],
+ left[2]: [-1.5, -1], right[2]: [1.5, -1]})
G.relabel(list(range(1, n + 1)))
return G
diff --git a/lmfdb/elliptic_curves/isog_class.py b/lmfdb/elliptic_curves/isog_class.py
index 09d24005e0..a562107f79 100644
--- a/lmfdb/elliptic_curves/isog_class.py
+++ b/lmfdb/elliptic_curves/isog_class.py
@@ -235,7 +235,7 @@ def make_graph(M, vertex_labels=None):
# once other isogeny classes are implemented.
if n == 1:
# one vertex
- pass
+ G.set_pos(pos={0:[0,0]})
elif n == 2:
# one edge, two vertices. We align horizontally and put
# the lower number on the left vertex.
@@ -262,7 +262,7 @@ def make_graph(M, vertex_labels=None):
# square
opp = [i for i in range(1,4) if not MM[0,i].is_prime()][0]
other = [i for i in range(1,4) if i != opp]
- G.set_pos(pos={0:[1,1],other[0]:[-1,1],opp:[-1,-1],other[1]:[1,-1]})
+ G.set_pos(pos={0:[0.5,0.5],other[0]:[-0.5,0.5],opp:[-0.5,-0.5],other[1]:[0.5,-0.5]})
elif maxdegree == 8:
# 8>o--o<8
centers = [i for i in range(6) if list(MM.row(i)).count(2) == 3]
@@ -292,10 +292,10 @@ def make_graph(M, vertex_labels=None):
right = []
for i in range(3):
right.append([j for j in range(8) if MM[centers[1],j] == 2 and MM[left[i],j] == 3][0])
- G.set_pos(pos={centers[0]:[-0.3,0],centers[1]:[0.3,0],
- left[0]:[-0.14,0.15], right[0]:[0.14,0.15],
- left[1]:[-0.14,-0.15],right[1]:[0.14,-0.15],
- left[2]:[-0.14,-0.3],right[2]:[0.14,-0.3]})
+ G.set_pos(pos={centers[0]:[-0.5,0],centers[1]:[0.5,0],
+ left[0]:[-1.5,1], right[0]:[1.5,1],
+ left[1]:[-1.5,0],right[1]:[1.5,0],
+ left[2]:[-1.5,-1],right[2]:[1.5,-1]})
if vertex_labels:
G.relabel(vertex_labels)
diff --git a/lmfdb/static/isogeny_graph.js b/lmfdb/static/isogeny_graph.js
index cdc03f305d..f2b608a0e4 100644
--- a/lmfdb/static/isogeny_graph.js
+++ b/lmfdb/static/isogeny_graph.js
@@ -29,30 +29,33 @@ function initIsogenyGraph(containerId, elements) {
var container = document.getElementById(containerId);
if (!container || !elements || elements.length === 0) return;
- // Check if preset positions are provided
+ // Count nodes and check if preset positions are provided
+ var nNodes = 0;
var hasPositions = false;
var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (var i = 0; i < elements.length; i++) {
- if (elements[i].group === 'nodes' && elements[i].position) {
- hasPositions = true;
- var p = elements[i].position;
- if (p.x < minX) minX = p.x;
- if (p.x > maxX) maxX = p.x;
- if (p.y < minY) minY = p.y;
- if (p.y > maxY) maxY = p.y;
+ if (elements[i].group === 'nodes') {
+ nNodes++;
+ if (elements[i].position) {
+ hasPositions = true;
+ var p = elements[i].position;
+ if (p.x < minX) minX = p.x;
+ if (p.x > maxX) maxX = p.x;
+ if (p.y < minY) minY = p.y;
+ if (p.y > maxY) maxY = p.y;
+ }
}
}
- if (hasPositions) {
+ if (nNodes === 1) {
+ container.style.width = '200px';
+ container.style.height = '80px';
+ } else if (hasPositions) {
var graphW = maxX - minX;
var graphH = maxY - minY;
container.style.width = Math.min(600, Math.max(200, graphW + 160)) + 'px';
- container.style.height = Math.min(400, Math.max(80, graphH + 100)) + 'px';
+ container.style.height = Math.min(400, Math.max(60, graphH + 100)) + 'px';
} else {
- var nNodes = 0;
- for (var i = 0; i < elements.length; i++) {
- if (elements[i].group === 'nodes') nNodes++;
- }
var side = Math.min(600, Math.max(250, nNodes * 40));
container.style.width = side + 'px';
container.style.height = side + 'px';
@@ -112,19 +115,31 @@ function initIsogenyGraph(containerId, elements) {
]
});
- cy.fit(30);
-
- // Minimum size enforcement: if the graph is too small, zoom out a bit
- var bb = cy.elements().boundingBox();
- if (bb.w < 80 && bb.h < 80) {
- cy.zoom(cy.zoom() * 0.7);
+ if (nNodes === 1) {
+ // Single node: match the apparent node size of small multi-node graphs
+ cy.zoom(1.2);
cy.center();
+ cy.nodes().ungrabify();
+ } else {
+ cy.fit(30);
+
+ // Minimum size enforcement: if the graph is too small, zoom out a bit
+ var bb = cy.elements().boundingBox();
+ if (bb.w < 80 && bb.h < 80) {
+ cy.zoom(cy.zoom() * 0.7);
+ cy.center();
+ }
}
// Save original container dimensions for preset restore
var origWidth = container.style.width;
var origHeight = container.style.height;
+ // Skip layout controls for trivial graphs
+ if (nNodes <= 1) {
+ // still set up tooltip and click handlers below
+ } else {
+
// Layout selector for testing
var layouts = {
// Built-in
@@ -205,6 +220,8 @@ function initIsogenyGraph(containerId, elements) {
controls.appendChild(label);
container.parentNode.insertBefore(controls, container);
+ } // end nNodes > 1
+
// Create tooltip element
var tooltip = document.createElement('div');
tooltip.className = 'isogeny-tooltip';
@@ -228,6 +245,7 @@ function initIsogenyGraph(containerId, elements) {
if (d.torsion !== undefined) _isoTooltipLine(tooltip, 'Torsion: ' + d.torsion);
if (d.degree !== undefined && d.degree !== 0) _isoTooltipLine(tooltip, 'Modular degree: ' + d.degree);
if (d.faltings_height !== undefined) _isoTooltipLine(tooltip, 'Faltings height: ' + d.faltings_height);
+ if (d.cm !== undefined) _isoTooltipLine(tooltip, 'CM: ' + d.cm);
if (d.optimal) _isoTooltipEmphasis(tooltip, 'Optimal curve');
tooltip.style.display = 'block';
@@ -242,6 +260,20 @@ function initIsogenyGraph(containerId, elements) {
tooltip.style.display = 'none';
});
+ // Prevent dragging nodes outside the visible area
+ cy.on('drag', 'node', function(evt) {
+ var node = evt.target;
+ var pos = node.position();
+ var ext = cy.extent();
+ var hw = node.width() / 2;
+ var hh = node.height() / 2;
+ var x = Math.max(ext.x1 + hw, Math.min(ext.x2 - hw, pos.x));
+ var y = Math.max(ext.y1 + hh, Math.min(ext.y2 - hh, pos.y));
+ if (x !== pos.x || y !== pos.y) {
+ node.position({ x: x, y: y });
+ }
+ });
+
// Click: navigate to curve page
cy.on('tap', 'node', function(evt) {
var url = evt.target.data('url');
diff --git a/lmfdb/utils/utilities.py b/lmfdb/utils/utilities.py
index d28394c0c7..a405a6a0c3 100644
--- a/lmfdb/utils/utilities.py
+++ b/lmfdb/utils/utilities.py
@@ -978,40 +978,65 @@ def graph_to_cytoscape_json(G):
return elements
-def graph_to_svg(G, width=200, height=150):
+def graph_to_svg(G, max_width=200, max_height=150):
"""Generate a compact SVG string for the properties box sidebar.
Produces a lightweight inline SVG from a Sage Graph with positions,
- suitable for embedding directly in HTML.
+ suitable for embedding directly in HTML. Dimensions are computed
+ adaptively from the graph layout to avoid excess whitespace.
"""
from markupsafe import Markup
pos = G.get_pos()
vertices = G.vertices()
n = len(vertices)
+ pad = 15
+ r = 5
if n == 0:
- return Markup('' % (width, height))
+ return Markup('')
# Compute positions if not set by make_graph
if pos is None or len(pos) < n:
pos = G.layout_spring()
- # For single vertex, center it
if n == 1:
- coords = {v: (width / 2, height / 2) for v in vertices}
+ width = height = 2 * pad
+ coords = {vertices[0]: (width / 2, height / 2)}
else:
- xs = [pos[v][0] for v in vertices]
- ys = [pos[v][1] for v in vertices]
+ xs = [float(pos[v][0]) for v in vertices]
+ ys = [float(pos[v][1]) for v in vertices]
min_x, max_x = min(xs), max(xs)
min_y, max_y = min(ys), max(ys)
- range_x = max_x - min_x if max_x != min_x else 1
- range_y = max_y - min_y if max_y != min_y else 1
- pad = 15
- coords = {}
- for v in vertices:
- cx = pad + (pos[v][0] - min_x) / range_x * (width - 2 * pad)
- cy = pad + (max_y - pos[v][1]) / range_y * (height - 2 * pad)
- coords[v] = (cx, cy)
+ range_x = max_x - min_x
+ range_y = max_y - min_y
+
+ if range_x == 0 and range_y == 0:
+ width = height = 2 * pad
+ coords = {v: (width / 2, height / 2) for v in vertices}
+ elif range_y == 0:
+ # Horizontal layout (e.g. path graphs)
+ width = max_width
+ height = 2 * pad
+ coords = {v: (pad + (pos[v][0] - min_x) / range_x * (width - 2 * pad),
+ height / 2) for v in vertices}
+ elif range_x == 0:
+ # Vertical layout
+ width = 2 * pad
+ height = min(max_height, max_width)
+ coords = {v: (width / 2,
+ pad + (max_y - pos[v][1]) / range_y * (height - 2 * pad))
+ for v in vertices}
+ else:
+ # General case: fit to max_width, scale height by aspect ratio
+ aspect = range_y / range_x
+ width = max_width
+ draw_w = width - 2 * pad
+ height = min(max_height, int(draw_w * aspect + 2 * pad))
+ height = max(height, 2 * pad)
+ draw_h = height - 2 * pad
+ coords = {v: (pad + (pos[v][0] - min_x) / range_x * draw_w,
+ pad + (max_y - pos[v][1]) / range_y * draw_h)
+ for v in vertices}
parts = ['