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 = ['' % (width, height)] + + # Draw edges + for u, v, label in G.edges(): + x1, y1 = coords[u] + 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 + for v in vertices: + cx, cy = coords[v] + parts.append('' % (cx, cy, r)) + parts.append('%s' % (cx, cy, str(v))) + + parts.append('') + 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 = ['' % (width, height)] @@ -1023,7 +1048,6 @@ def graph_to_svg(G, width=200, height=150): 'stroke="#888" stroke-width="1.5"/>' % (x1, y1, x2, y2)) # Draw nodes - r = 5 for v in vertices: cx, cy = coords[v] parts.append(' Date: Fri, 20 Feb 2026 10:57:23 -0500 Subject: [PATCH 07/12] Address havarddj review feedback - Move tooltip to document.body to prevent clipping by container overflow - Use white-space:nowrap instead of max-width for wider tooltips - Set text-background-opacity to 1 on edge labels to hide ghost lines - Remove ineffective cursor:pointer CSS; use jQuery on mouseover/mouseout - Add +10 margin to drag constraint for box shadow - Format torsion as "Z/2Z x Z/4Z" or "Trivial" instead of raw list --- lmfdb/ecnf/isog_class.py | 3 ++- lmfdb/elliptic_curves/isog_class.py | 2 +- lmfdb/static/isogeny_graph.js | 27 +++++++++++++++------------ 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/lmfdb/ecnf/isog_class.py b/lmfdb/ecnf/isog_class.py index c96764c181..219f49bcc6 100644 --- a/lmfdb/ecnf/isog_class.py +++ b/lmfdb/ecnf/isog_class.py @@ -94,7 +94,8 @@ def make_class(self): c = self.db_curves[idx] el['data']['url'] = curve_url(c) el['data']['label'] = c['iso_label'] + str(c['number']) - el['data']['torsion'] = str(c.get('torsion_structure', [])) + ts = c.get('torsion_structure', []) + el['data']['torsion'] = ' x '.join('Z/%dZ' % t for t in ts) if ts else 'Trivial' if c.get('cm'): el['data']['cm'] = str(c['cm']) self.graph_link = graph_to_svg(self.graph) diff --git a/lmfdb/elliptic_curves/isog_class.py b/lmfdb/elliptic_curves/isog_class.py index a562107f79..8bbb448f12 100644 --- a/lmfdb/elliptic_curves/isog_class.py +++ b/lmfdb/elliptic_curves/isog_class.py @@ -136,7 +136,7 @@ def perm(i): return next(c for c in self.curves if c['Cnumber'] == i+1)['lmfdb_n c = curve_by_label.get(label) if c: el['data']['url'] = c['curve_url_lmfdb'] - el['data']['torsion'] = str(c['torsion_structure']) + el['data']['torsion'] = ' x '.join('Z/%dZ' % t for t in c['torsion_structure']) if c['torsion_structure'] else 'Trivial' el['data']['degree'] = c['degree'] el['data']['faltings_height'] = str(c['FH']) el['data']['optimal'] = bool(c.get('optimal')) diff --git a/lmfdb/static/isogeny_graph.js b/lmfdb/static/isogeny_graph.js index f2b608a0e4..cbfad0a389 100644 --- a/lmfdb/static/isogeny_graph.js +++ b/lmfdb/static/isogeny_graph.js @@ -86,8 +86,7 @@ function initIsogenyGraph(containerId, elements) { 'width': 60, 'height': 30, 'shape': 'round-rectangle', - 'color': '#333', - 'cursor': 'pointer' + 'color': '#333' } }, { @@ -104,7 +103,7 @@ function initIsogenyGraph(containerId, elements) { 'label': 'data(label)', 'font-size': '11px', 'text-background-color': '#fff', - 'text-background-opacity': 0.85, + 'text-background-opacity': 1, 'text-background-padding': '2px', 'line-color': '#888', 'width': 1.5, @@ -222,15 +221,15 @@ function initIsogenyGraph(containerId, elements) { } // end nNodes > 1 - // Create tooltip element + // Create tooltip element (appended to document.body so it is not + // clipped by the container's overflow) 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); + 'z-index:1000; pointer-events:none; white-space:nowrap;'; + document.body.appendChild(tooltip); // Hover: show tooltip with curve metadata cy.on('mouseover', 'node', function(evt) { @@ -250,14 +249,18 @@ function initIsogenyGraph(containerId, elements) { tooltip.style.display = 'block'; - // Position tooltip near the node + // Position tooltip near the node using page coordinates + var rect = container.getBoundingClientRect(); var pos = node.renderedPosition(); - tooltip.style.left = (pos.x + 15) + 'px'; - tooltip.style.top = (pos.y - 10) + 'px'; + tooltip.style.left = (rect.left + window.scrollX + pos.x + 25) + 'px'; + tooltip.style.top = (rect.top + window.scrollY + pos.y - 10) + 'px'; + + $('html,body').css('cursor', 'pointer'); }); cy.on('mouseout', 'node', function() { tooltip.style.display = 'none'; + $('html,body').css('cursor', 'default'); }); // Prevent dragging nodes outside the visible area @@ -265,8 +268,8 @@ function initIsogenyGraph(containerId, elements) { var node = evt.target; var pos = node.position(); var ext = cy.extent(); - var hw = node.width() / 2; - var hh = node.height() / 2; + var hw = node.width() / 2 + 10; + var hh = node.height() / 2 + 10; 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) { From 4afb187d61fe5a61ccd3bb2daf18ec8b9115f53a Mon Sep 17 00:00:00 2001 From: Edgar Costa Date: Fri, 20 Feb 2026 11:50:14 -0500 Subject: [PATCH 08/12] Make layout options configurable from Python; load JS conditionally Python sets graph_layouts list, passed to JS via templates. cytoscape_scripts.html conditionally loads only the CDN scripts needed for the chosen layouts. Default set: Preset, Elk-stress, Circle, Concentric, Klay, Dagre, Cola. --- lmfdb/ecnf/isog_class.py | 2 + lmfdb/ecnf/templates/ecnf-isoclass.html | 3 +- lmfdb/elliptic_curves/isog_class.py | 2 + .../templates/ec-isoclass.html | 3 +- lmfdb/static/isogeny_graph.js | 63 +++++++++---------- lmfdb/templates/cytoscape_scripts.html | 37 ++++++----- 6 files changed, 59 insertions(+), 51 deletions(-) diff --git a/lmfdb/ecnf/isog_class.py b/lmfdb/ecnf/isog_class.py index 219f49bcc6..573a6a914f 100644 --- a/lmfdb/ecnf/isog_class.py +++ b/lmfdb/ecnf/isog_class.py @@ -99,6 +99,8 @@ def make_class(self): if c.get('cm'): el['data']['cm'] = str(c['cm']) self.graph_link = graph_to_svg(self.graph) + # Layout options for the interactive graph dropdown + self.graph_layouts = ['Preset', 'Elk-stress', 'Circle', 'Concentric', 'Klay', 'Dagre', 'Cola'] self.isogeny_matrix_str = latex(Matrix(self.isogeny_matrix)) self.field = FIELD(self.field_label) diff --git a/lmfdb/ecnf/templates/ecnf-isoclass.html b/lmfdb/ecnf/templates/ecnf-isoclass.html index 3892804918..6db4b84942 100644 --- a/lmfdb/ecnf/templates/ecnf-isoclass.html +++ b/lmfdb/ecnf/templates/ecnf-isoclass.html @@ -68,9 +68,10 @@

{{ KNOWL('ec.isogeny_graph',title='Isogeny graph') }}

{% if cl.isogeny_matrix %}
+{% set graph_layouts = cl.graph_layouts %} {% include "cytoscape_scripts.html" %} {% else %}

Not available.

diff --git a/lmfdb/elliptic_curves/isog_class.py b/lmfdb/elliptic_curves/isog_class.py index 8bbb448f12..488f6d751a 100644 --- a/lmfdb/elliptic_curves/isog_class.py +++ b/lmfdb/elliptic_curves/isog_class.py @@ -143,6 +143,8 @@ def perm(i): return next(c for c in self.curves if c['Cnumber'] == i+1)['lmfdb_n el['data']['j_inv'] = str(c['j_inv']) # SVG for properties sidebar self.graph_link = graph_to_svg(self.graph) + # Layout options for the interactive graph dropdown + self.graph_layouts = ['Preset', 'Elk-stress', 'Circle', 'Concentric', 'Klay', 'Dagre', 'Cola'] 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]) diff --git a/lmfdb/elliptic_curves/templates/ec-isoclass.html b/lmfdb/elliptic_curves/templates/ec-isoclass.html index 1586678465..188b06430b 100644 --- a/lmfdb/elliptic_curves/templates/ec-isoclass.html +++ b/lmfdb/elliptic_curves/templates/ec-isoclass.html @@ -127,9 +127,10 @@

{{ KNOWL('ec.isogeny_graph', title='Isogeny graph') }}

{% endif %}
+{% set graph_layouts = info.graph_layouts %} {% include "cytoscape_scripts.html" %}

Elliptic curves in class {{info.class_label}}

diff --git a/lmfdb/static/isogeny_graph.js b/lmfdb/static/isogeny_graph.js index cbfad0a389..699e960aee 100644 --- a/lmfdb/static/isogeny_graph.js +++ b/lmfdb/static/isogeny_graph.js @@ -1,7 +1,7 @@ /** * Interactive isogeny graph rendering using Cytoscape.js * - * Usage: initIsogenyGraph('container-id', elementsJSON) + * Usage: initIsogenyGraph('container-id', elementsJSON, enabledLayouts) */ function _isoTooltipLine(parent, text, isMath) { @@ -25,7 +25,22 @@ function _isoTooltipEmphasis(parent, text) { parent.appendChild(document.createElement('br')); } -function initIsogenyGraph(containerId, elements) { +// Registry of all known layouts. Keys are the display names used in +// the dropdown and passed from Python via graph_layouts. To add a new +// layout, add an entry here and load its JS in cytoscape_scripts.html. +var LAYOUT_REGISTRY = { + 'Preset': { name: 'preset' }, + 'Elk-stress': { name: 'elk', animate: false, padding: 30, elk: { algorithm: 'stress' } }, + 'Circle': { name: 'circle', animate: false, padding: 30 }, + 'Concentric': { name: 'concentric', animate: false, padding: 30, + concentric: function(node) { return node.degree(); }, + levelWidth: function() { return 2; } }, + 'Klay': { name: 'klay', animate: false, padding: 30 }, + 'Dagre': { name: 'dagre', animate: false, padding: 30 }, + 'Cola': { name: 'cola', animate: false, padding: 30 } +}; + +function initIsogenyGraph(containerId, elements, enabledLayouts) { var container = document.getElementById(containerId); if (!container || !elements || elements.length === 0) return; @@ -61,8 +76,9 @@ function initIsogenyGraph(containerId, elements) { container.style.height = side + 'px'; } - var elkStress = { name: 'elk', animate: false, padding: 30, elk: { algorithm: 'stress' } }; - var layoutOpts = hasPositions ? { name: 'preset' } : elkStress; + var layoutOpts = hasPositions + ? { name: 'preset' } + : (LAYOUT_REGISTRY['Elk-stress'] || { name: 'cose', animate: false, padding: 30 }); var cy = cytoscape({ container: container, @@ -139,31 +155,14 @@ function initIsogenyGraph(containerId, elements) { // still set up tooltip and click handlers below } else { - // 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' } } - }; + // Build layout map from the enabled list + var layouts = {}; + if (enabledLayouts) { + for (var ei = 0; ei < enabledLayouts.length; ei++) { + var key = enabledLayouts[ei]; + if (LAYOUT_REGISTRY[key]) layouts[key] = LAYOUT_REGISTRY[key]; + } + } var controls = document.createElement('div'); controls.style.cssText = 'margin: 8px 0;'; @@ -171,10 +170,10 @@ function initIsogenyGraph(containerId, elements) { label.textContent = 'Layout: '; label.style.fontWeight = 'bold'; var select = document.createElement('select'); - var defaultLayout = hasPositions ? 'preset' : 'elk (stress)'; + 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; + if (layoutNames[li] === 'Preset' && !hasPositions) continue; var opt = document.createElement('option'); opt.value = layoutNames[li]; opt.textContent = layoutNames[li]; @@ -182,7 +181,7 @@ function initIsogenyGraph(containerId, elements) { select.appendChild(opt); } select.addEventListener('change', function() { - if (select.value === 'preset') { + 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) { diff --git a/lmfdb/templates/cytoscape_scripts.html b/lmfdb/templates/cytoscape_scripts.html index 6c1aa83f87..e4c72d5a47 100644 --- a/lmfdb/templates/cytoscape_scripts.html +++ b/lmfdb/templates/cytoscape_scripts.html @@ -1,23 +1,26 @@ {# Shared Cytoscape.js scripts for interactive isogeny graphs. - Include this template where needed, then call: - initIsogenyGraph('container-id', elementsJSON); + Set graph_layouts before including this template, then call: + initIsogenyGraph('container-id', elementsJSON, layoutsList); + + To add layout extensions, load the required JS here (guarded by + a check on graph_layouts) and register the layout in the + LAYOUT_REGISTRY in isogeny_graph.js. #} -{# Layout extension dependencies (engines must load before their adapters) #} - - - - - - +{% if 'Elk-stress' in graph_layouts %} -{# Layout extensions #} - - - - - - - +{% endif %} +{% if 'Klay' in graph_layouts %} + + +{% endif %} +{% if 'Dagre' in graph_layouts %} + + +{% endif %} +{% if 'Cola' in graph_layouts %} + + +{% endif %} From a61ec5b3e61e436a3be4c8ee731f9b6d920ac3d9 Mon Sep 17 00:00:00 2001 From: Edgar Costa Date: Fri, 20 Feb 2026 11:53:25 -0500 Subject: [PATCH 09/12] Fix preset restore by saving deep copies of original positions --- lmfdb/static/isogeny_graph.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/lmfdb/static/isogeny_graph.js b/lmfdb/static/isogeny_graph.js index 699e960aee..4f27c16931 100644 --- a/lmfdb/static/isogeny_graph.js +++ b/lmfdb/static/isogeny_graph.js @@ -146,7 +146,16 @@ function initIsogenyGraph(containerId, elements, enabledLayouts) { } } - // Save original container dimensions for preset restore + // Save original positions and container dimensions for preset restore + var origPositions = {}; + for (var pi = 0; pi < elements.length; pi++) { + if (elements[pi].group === 'nodes' && elements[pi].position) { + origPositions[elements[pi].data.id] = { + x: elements[pi].position.x, + y: elements[pi].position.y + }; + } + } var origWidth = container.style.width; var origHeight = container.style.height; @@ -183,10 +192,9 @@ function initIsogenyGraph(containerId, elements, enabledLayouts) { 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); - } + var ids = Object.keys(origPositions); + for (var ri = 0; ri < ids.length; ri++) { + cy.getElementById(ids[ri]).position(origPositions[ids[ri]]); } container.style.width = origWidth; container.style.height = origHeight; From 47eb235375c5d4607c9bf39ff6e8581b7deceb21 Mon Sep 17 00:00:00 2001 From: Edgar Costa Date: Fri, 20 Feb 2026 11:56:14 -0500 Subject: [PATCH 10/12] Always include positions in cytoscape JSON, falling back to layout_spring This ensures the Preset layout option is always available, and the interactive graph starts with the same coordinates as the sidebar SVG. --- lmfdb/utils/utilities.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lmfdb/utils/utilities.py b/lmfdb/utils/utilities.py index a405a6a0c3..01ec9c1f08 100644 --- a/lmfdb/utils/utilities.py +++ b/lmfdb/utils/utilities.py @@ -954,21 +954,21 @@ def encode_plot(P, pad=None, pad_inches=0.1, remove_axes=False, axes_pad=None, f 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: ...}). + Positions are taken from G.get_pos() if available (as set by + make_graph), otherwise computed via layout_spring(). Returns a + list of dicts suitable for passing to cytoscape({elements: ...}). """ pos = G.get_pos() - has_pos = pos is not None and len(pos) == len(G.vertices()) + if pos is None or len(pos) < len(G.vertices()): + pos = G.layout_spring() elements = [] for v in G.vertices(): + x, y = pos[v] 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({ From f9b03a49103ddb9bea98f05a4cfc445fd0e4f955 Mon Sep 17 00:00:00 2001 From: Edgar Costa Date: Fri, 20 Feb 2026 11:58:47 -0500 Subject: [PATCH 11/12] Pass default layout from Python; use Elk-stress for non-hardcoded positions graph_to_cytoscape_json now returns (elements, has_preset). Python sets graph_default_layout to 'Preset' when positions are hardcoded or 'Elk-stress' when falling back to layout_spring. The default is passed through to JS so the initial layout and dropdown selection match. --- lmfdb/ecnf/isog_class.py | 3 ++- lmfdb/ecnf/templates/ecnf-isoclass.html | 2 +- lmfdb/elliptic_curves/isog_class.py | 3 ++- lmfdb/elliptic_curves/templates/ec-isoclass.html | 2 +- lmfdb/static/isogeny_graph.js | 12 ++++++------ lmfdb/utils/utilities.py | 11 +++++++---- 6 files changed, 19 insertions(+), 14 deletions(-) diff --git a/lmfdb/ecnf/isog_class.py b/lmfdb/ecnf/isog_class.py index 573a6a914f..578790ef75 100644 --- a/lmfdb/ecnf/isog_class.py +++ b/lmfdb/ecnf/isog_class.py @@ -85,7 +85,7 @@ def make_class(self): # Create isogeny graph: self.graph = make_graph(self.isogeny_matrix) - self.graph_data = graph_to_cytoscape_json(self.graph) + self.graph_data, has_preset = graph_to_cytoscape_json(self.graph) # Attach curve URLs and labels to nodes for el in self.graph_data: if el['group'] == 'nodes': @@ -101,6 +101,7 @@ def make_class(self): self.graph_link = graph_to_svg(self.graph) # Layout options for the interactive graph dropdown self.graph_layouts = ['Preset', 'Elk-stress', 'Circle', 'Concentric', 'Klay', 'Dagre', 'Cola'] + self.graph_default_layout = 'Preset' if has_preset else 'Elk-stress' self.isogeny_matrix_str = latex(Matrix(self.isogeny_matrix)) self.field = FIELD(self.field_label) diff --git a/lmfdb/ecnf/templates/ecnf-isoclass.html b/lmfdb/ecnf/templates/ecnf-isoclass.html index 6db4b84942..35e40e3b6f 100644 --- a/lmfdb/ecnf/templates/ecnf-isoclass.html +++ b/lmfdb/ecnf/templates/ecnf-isoclass.html @@ -71,7 +71,7 @@

{{ KNOWL('ec.isogeny_graph',title='Isogeny graph') }}

{% set graph_layouts = cl.graph_layouts %} {% include "cytoscape_scripts.html" %} {% else %}

Not available.

diff --git a/lmfdb/elliptic_curves/isog_class.py b/lmfdb/elliptic_curves/isog_class.py index 488f6d751a..fb89100cb0 100644 --- a/lmfdb/elliptic_curves/isog_class.py +++ b/lmfdb/elliptic_curves/isog_class.py @@ -127,7 +127,7 @@ 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]) - self.graph_data = graph_to_cytoscape_json(self.graph) + self.graph_data, has_preset = 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: @@ -145,6 +145,7 @@ def perm(i): return next(c for c in self.curves if c['Cnumber'] == i+1)['lmfdb_n self.graph_link = graph_to_svg(self.graph) # Layout options for the interactive graph dropdown self.graph_layouts = ['Preset', 'Elk-stress', 'Circle', 'Concentric', 'Klay', 'Dagre', 'Cola'] + self.graph_default_layout = 'Preset' if has_preset else 'Elk-stress' 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]) diff --git a/lmfdb/elliptic_curves/templates/ec-isoclass.html b/lmfdb/elliptic_curves/templates/ec-isoclass.html index 188b06430b..5a28c4d049 100644 --- a/lmfdb/elliptic_curves/templates/ec-isoclass.html +++ b/lmfdb/elliptic_curves/templates/ec-isoclass.html @@ -130,7 +130,7 @@

{{ KNOWL('ec.isogeny_graph', title='Isogeny graph') }}

{% set graph_layouts = info.graph_layouts %} {% include "cytoscape_scripts.html" %}

Elliptic curves in class {{info.class_label}}

diff --git a/lmfdb/static/isogeny_graph.js b/lmfdb/static/isogeny_graph.js index 4f27c16931..69a64151df 100644 --- a/lmfdb/static/isogeny_graph.js +++ b/lmfdb/static/isogeny_graph.js @@ -1,7 +1,7 @@ /** * Interactive isogeny graph rendering using Cytoscape.js * - * Usage: initIsogenyGraph('container-id', elementsJSON, enabledLayouts) + * Usage: initIsogenyGraph('container-id', elementsJSON, enabledLayouts, defaultLayout) */ function _isoTooltipLine(parent, text, isMath) { @@ -40,7 +40,7 @@ var LAYOUT_REGISTRY = { 'Cola': { name: 'cola', animate: false, padding: 30 } }; -function initIsogenyGraph(containerId, elements, enabledLayouts) { +function initIsogenyGraph(containerId, elements, enabledLayouts, defaultLayout) { var container = document.getElementById(containerId); if (!container || !elements || elements.length === 0) return; @@ -76,9 +76,9 @@ function initIsogenyGraph(containerId, elements, enabledLayouts) { container.style.height = side + 'px'; } - var layoutOpts = hasPositions - ? { name: 'preset' } - : (LAYOUT_REGISTRY['Elk-stress'] || { name: 'cose', animate: false, padding: 30 }); + var layoutOpts = (defaultLayout && LAYOUT_REGISTRY[defaultLayout]) + ? LAYOUT_REGISTRY[defaultLayout] + : { name: 'preset' }; var cy = cytoscape({ container: container, @@ -179,7 +179,7 @@ function initIsogenyGraph(containerId, elements, enabledLayouts) { label.textContent = 'Layout: '; label.style.fontWeight = 'bold'; var select = document.createElement('select'); - var defaultLayout = hasPositions ? 'Preset' : 'Elk-stress'; + defaultLayout = defaultLayout || 'Preset'; var layoutNames = Object.keys(layouts); for (var li = 0; li < layoutNames.length; li++) { if (layoutNames[li] === 'Preset' && !hasPositions) continue; diff --git a/lmfdb/utils/utilities.py b/lmfdb/utils/utilities.py index 01ec9c1f08..07d19719a7 100644 --- a/lmfdb/utils/utilities.py +++ b/lmfdb/utils/utilities.py @@ -955,11 +955,14 @@ def graph_to_cytoscape_json(G): """Convert a Sage Graph with positions to Cytoscape.js elements format. Positions are taken from G.get_pos() if available (as set by - make_graph), otherwise computed via layout_spring(). Returns a - list of dicts suitable for passing to cytoscape({elements: ...}). + make_graph), otherwise computed via layout_spring(). Returns + ``(elements, has_preset)`` where *elements* is a list of dicts + suitable for ``cytoscape({elements: ...})`` and *has_preset* is + True when positions came from hardcoded coordinates. """ pos = G.get_pos() - if pos is None or len(pos) < len(G.vertices()): + has_preset = pos is not None and len(pos) >= len(G.vertices()) + if not has_preset: pos = G.layout_spring() elements = [] for v in G.vertices(): @@ -975,7 +978,7 @@ def graph_to_cytoscape_json(G): "group": "edges", "data": {"source": str(u), "target": str(v), "label": str(label)}, }) - return elements + return elements, has_preset def graph_to_svg(G, max_width=200, max_height=150): From 733f3af96698a840ea0e514a39a607b6bbb70993 Mon Sep 17 00:00:00 2001 From: Edgar Costa Date: Fri, 20 Feb 2026 15:28:13 -0500 Subject: [PATCH 12/12] Unify duplicated isogeny graph utilities into lmfdb/utils/graph_utils.py Move make_graph, graph_to_cytoscape_json, graph_to_svg, setup_isogeny_graph, and GRAPH_LAYOUTS into a dedicated module, eliminating ~160 lines of near-identical code that was duplicated between elliptic_curves/isog_class.py and ecnf/isog_class.py. The unified make_graph uses ECNF's more defensive guards (checking both maxdegree and n) while keeping EC/Q's vertex_labels parameter. --- lmfdb/ecnf/isog_class.py | 90 +----------- lmfdb/elliptic_curves/isog_class.py | 92 +----------- lmfdb/utils/__init__.py | 11 +- lmfdb/utils/graph_utils.py | 211 ++++++++++++++++++++++++++++ lmfdb/utils/utilities.py | 109 -------------- 5 files changed, 224 insertions(+), 289 deletions(-) create mode 100644 lmfdb/utils/graph_utils.py diff --git a/lmfdb/ecnf/isog_class.py b/lmfdb/ecnf/isog_class.py index 578790ef75..22848307d2 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 graph_to_cytoscape_json, graph_to_svg, names_and_urls, web_latex +from lmfdb.utils import make_graph, setup_isogeny_graph, 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,7 +85,7 @@ def make_class(self): # Create isogeny graph: self.graph = make_graph(self.isogeny_matrix) - self.graph_data, has_preset = graph_to_cytoscape_json(self.graph) + self.graph_data, self.graph_link, self.graph_layouts, self.graph_default_layout = setup_isogeny_graph(self.graph) # Attach curve URLs and labels to nodes for el in self.graph_data: if el['group'] == 'nodes': @@ -98,10 +98,6 @@ def make_class(self): el['data']['torsion'] = ' x '.join('Z/%dZ' % t for t in ts) if ts else 'Trivial' if c.get('cm'): el['data']['cm'] = str(c['cm']) - self.graph_link = graph_to_svg(self.graph) - # Layout options for the interactive graph dropdown - self.graph_layouts = ['Preset', 'Elk-stress', 'Circle', 'Concentric', 'Klay', 'Dagre', 'Cola'] - self.graph_default_layout = 'Preset' if has_preset else 'Elk-stress' self.isogeny_matrix_str = latex(Matrix(self.isogeny_matrix)) self.field = FIELD(self.field_label) @@ -186,88 +182,6 @@ def make_class(self): ('isogeny class %s' % self.short_label, self.urls['class'])] -def make_graph(M): - """ - Code extracted from Sage's elliptic curve isogeny class (reshaped - in the case maxdegree==12) - """ - from sage.schemes.elliptic_curves.ell_curve_isogeny import fill_isogeny_matrix, unfill_isogeny_matrix - from sage.graphs.graph import Graph - n = M.nrows() # = M.ncols() - G = Graph(unfill_isogeny_matrix(M), format='weighted_adjacency_matrix') - MM = fill_isogeny_matrix(M) - # The maximum degree classifies the shape of the isogeny - # graph, though the number of vertices is often enough. - # This only holds over Q, so this code will need to change - # once other isogeny classes are implemented. - if n == 1: - # one vertex - 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. - G.set_pos(pos={0: [-0.5, 0], 1: [0.5, 0]}) - else: - maxdegree = max(max(MM)) - if n == 3: - # o--o--o - centervert = [i for i in range(3) if max(MM.row(i)) < maxdegree][0] - other = [i for i in range(3) if i != centervert] - G.set_pos(pos={centervert: [0, 0], other[0]: [-1, 0], other[1]: [1, 0]}) - elif maxdegree == 4: - # o--o<8 - centervert = [i for i in range(4) if max(MM.row(i)) < maxdegree][0] - other = [i for i in range(4) if i != centervert] - G.set_pos(pos={centervert: [0, 0], other[0]: [0, 1], other[1]: [-0.8660254, -0.5], other[2]: [0.8660254, -0.5]}) - elif maxdegree == 27 and n == 4: - # o--o--o--o - centers = [i for i in range(4) if list(MM.row(i)).count(3) == 2] - left = [j for j in range(4) if MM[centers[0], j] == 3 and j not in centers][0] - right = [j for j in range(4) if MM[centers[1], j] == 3 and j not in centers][0] - G.set_pos(pos={left: [-1.5, 0], centers[0]: [-0.5, 0], centers[1]: [0.5, 0], right: [1.5, 0]}) - elif n == 4: - # 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: [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] - left = [j for j in range(6) if MM[centers[0], j] == 2 and j not in centers] - right = [j for j in range(6) if MM[centers[1], j] == 2 and j not in centers] - G.set_pos(pos={centers[0]: [-0.5, 0], left[0]: [-1, 0.8660254], left[1]: [-1, -0.8660254], centers[1]: [0.5, 0], right[0]: [1, 0.8660254], right[1]: [1, -0.8660254]}) - elif maxdegree == 18 and n == 6: - # two squares joined on an edge - centers = [i for i in range(6) if list(MM.row(i)).count(3) == 2] - top = [j for j in range(6) if MM[centers[0], j] == 3] - bl = [j for j in range(6) if MM[top[0], j] == 2][0] - br = [j for j in range(6) if MM[top[1], j] == 2][0] - G.set_pos(pos={centers[0]: [0, 0.5], centers[1]: [0, -0.5], top[0]: [-1, 0.5], top[1]: [1, 0.5], bl: [-1, -0.5], br: [1, -0.5]}) - elif maxdegree == 16 and n == 8: - # tree from bottom, 3 regular except for the leaves. - centers = [i for i in range(8) if list(MM.row(i)).count(2) == 3] - center = [i for i in centers if len([j for j in centers if MM[i, j] == 2]) == 2][0] - centers.remove(center) - bottom = [j for j in range(8) if MM[center, j] == 2 and j not in centers][0] - left = [j for j in range(8) if MM[centers[0], j] == 2 and j != center] - right = [j for j in range(8) if MM[centers[1], j] == 2 and j != center] - G.set_pos(pos={center: [0, 0], bottom: [0, -1], centers[0]: [-0.8660254, 0.5], centers[1]: [0.8660254, 0.5], left[0]: [-0.8660254, 1.5], right[0]: [0.8660254, 1.5], left[1]: [-1.7320508, 0], right[1]: [1.7320508, 0]}) - elif maxdegree == 12: - # tent - centers = [i for i in range(8) if list(MM.row(i)).count(2) == 3] - left = [j for j in range(8) if MM[centers[0], j] == 2] - 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.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 - - def make_iso_matrix(clist): # clist is a list of ECNFs Elist = [E.E for E in clist] cl = Elist[0].isogeny_class() diff --git a/lmfdb/elliptic_curves/isog_class.py b/lmfdb/elliptic_curves/isog_class.py index fb89100cb0..c7365c4852 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 graph_to_cytoscape_json, graph_to_svg, prop_int_pretty, raw_typeset, integer_squarefree_part, list_to_factored_poly_otherorder +from lmfdb.utils import make_graph, setup_isogeny_graph, 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,7 +127,7 @@ 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]) - self.graph_data, has_preset = graph_to_cytoscape_json(self.graph) + self.graph_data, self.graph_link, self.graph_layouts, self.graph_default_layout = setup_isogeny_graph(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: @@ -141,11 +141,6 @@ def perm(i): return next(c for c in self.curves if c['Cnumber'] == i+1)['lmfdb_n 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) - # Layout options for the interactive graph dropdown - self.graph_layouts = ['Preset', 'Elk-stress', 'Circle', 'Concentric', 'Klay', 'Dagre', 'Cola'] - self.graph_default_layout = 'Preset' if has_preset else 'Elk-stress' 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]) @@ -222,86 +217,3 @@ def perm(i): return next(c for c in self.curves if c['Cnumber'] == i+1)['lmfdb_n else: self.has_lfunction = False -def make_graph(M, vertex_labels=None): - """ - Code extracted from Sage's elliptic curve isogeny class (reshaped - in the case maxdegree==12) - """ - from sage.schemes.elliptic_curves.ell_curve_isogeny import fill_isogeny_matrix, unfill_isogeny_matrix - from sage.graphs.graph import Graph - n = M.nrows() # = M.ncols() - G = Graph(unfill_isogeny_matrix(M), format='weighted_adjacency_matrix') - MM = fill_isogeny_matrix(M) - # The maximum degree classifies the shape of the isogeny - # graph, though the number of vertices is often enough. - # This only holds over Q, so this code will need to change - # once other isogeny classes are implemented. - if n == 1: - # one vertex - 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. - G.set_pos(pos={0:[-0.5,0],1:[0.5,0]}) - else: - maxdegree = max(max(MM)) - if n == 3: - # o--o--o - centervert = [i for i in range(3) if max(MM.row(i)) < maxdegree][0] - other = [i for i in range(3) if i != centervert] - G.set_pos(pos={centervert:[0,0],other[0]:[-1,0],other[1]:[1,0]}) - elif maxdegree == 4: - # o--o<8 - centervert = [i for i in range(4) if max(MM.row(i)) < maxdegree][0] - other = [i for i in range(4) if i != centervert] - G.set_pos(pos={centervert:[0,0],other[0]:[0,1],other[1]:[-0.8660254,-0.5],other[2]:[0.8660254,-0.5]}) - elif maxdegree == 27: - # o--o--o--o - centers = [i for i in range(4) if list(MM.row(i)).count(3) == 2] - left = [j for j in range(4) if MM[centers[0],j] == 3 and j not in centers][0] - right = [j for j in range(4) if MM[centers[1],j] == 3 and j not in centers][0] - G.set_pos(pos={left:[-1.5,0],centers[0]:[-0.5,0],centers[1]:[0.5,0],right:[1.5,0]}) - elif n == 4: - # 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:[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] - left = [j for j in range(6) if MM[centers[0],j] == 2 and j not in centers] - right = [j for j in range(6) if MM[centers[1],j] == 2 and j not in centers] - G.set_pos(pos={centers[0]:[-0.5,0],left[0]:[-1,0.8660254],left[1]:[-1,-0.8660254],centers[1]:[0.5,0],right[0]:[1,0.8660254],right[1]:[1,-0.8660254]}) - elif maxdegree == 18: - # two squares joined on an edge - centers = [i for i in range(6) if list(MM.row(i)).count(3) == 2] - top = [j for j in range(6) if MM[centers[0],j] == 3] - bl = [j for j in range(6) if MM[top[0],j] == 2][0] - br = [j for j in range(6) if MM[top[1],j] == 2][0] - G.set_pos(pos={centers[0]:[0,0.5],centers[1]:[0,-0.5],top[0]:[-1,0.5],top[1]:[1,0.5],bl:[-1,-0.5],br:[1,-0.5]}) - elif maxdegree == 16: - # tree from bottom, 3 regular except for the leaves. - centers = [i for i in range(8) if list(MM.row(i)).count(2) == 3] - center = [i for i in centers if len([j for j in centers if MM[i,j] == 2]) == 2][0] - centers.remove(center) - bottom = [j for j in range(8) if MM[center,j] == 2 and j not in centers][0] - left = [j for j in range(8) if MM[centers[0],j] == 2 and j != center] - right = [j for j in range(8) if MM[centers[1],j] == 2 and j != center] - G.set_pos(pos={center:[0,0],bottom:[0,-1],centers[0]:[-0.8660254,0.5],centers[1]:[0.8660254,0.5],left[0]:[-0.8660254,1.5],right[0]:[0.8660254,1.5],left[1]:[-1.7320508,0],right[1]:[1.7320508,0]}) - elif maxdegree == 12: - # tent - centers = [i for i in range(8) if list(MM.row(i)).count(2) == 3] - left = [j for j in range(8) if MM[centers[0],j] == 2] - 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.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) - else: - G.relabel(list(range(1, n + 1))) - return G diff --git a/lmfdb/utils/__init__.py b/lmfdb/utils/__init__.py index 702fa2ff06..9d6b20b8bd 100644 --- a/lmfdb/utils/__init__.py +++ b/lmfdb/utils/__init__.py @@ -20,6 +20,7 @@ 'Pagination', 'to_ordinal', 'debug', 'flash_error', 'flash_warning', 'flash_info', 'image_callback', 'encode_plot', 'graph_to_cytoscape_json', 'graph_to_svg', + 'make_graph', 'setup_isogeny_graph', 'GRAPH_LAYOUTS', '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,8 +74,6 @@ display_float, display_multiset, encode_plot, - graph_to_cytoscape_json, - graph_to_svg, factor_base_factor, flash_error, flash_info, @@ -100,6 +99,14 @@ pluralize, ) +from .graph_utils import ( + graph_to_cytoscape_json, + graph_to_svg, + make_graph, + setup_isogeny_graph, + GRAPH_LAYOUTS, +) + from .web_display import ( add_space_if_positive, bigint_knowl, diff --git a/lmfdb/utils/graph_utils.py b/lmfdb/utils/graph_utils.py new file mode 100644 index 0000000000..a378fd6356 --- /dev/null +++ b/lmfdb/utils/graph_utils.py @@ -0,0 +1,211 @@ + +GRAPH_LAYOUTS = ['Preset', 'Elk-stress', 'Circle', 'Concentric', 'Klay', 'Dagre', 'Cola'] + + +def graph_to_cytoscape_json(G): + """Convert a Sage Graph with positions to Cytoscape.js elements format. + + Positions are taken from G.get_pos() if available (as set by + make_graph), otherwise computed via layout_spring(). Returns + ``(elements, has_preset)`` where *elements* is a list of dicts + suitable for ``cytoscape({elements: ...})`` and *has_preset* is + True when positions came from hardcoded coordinates. + """ + pos = G.get_pos() + has_preset = pos is not None and len(pos) >= len(G.vertices()) + if not has_preset: + pos = G.layout_spring() + elements = [] + for v in G.vertices(): + x, y = pos[v] + node = { + "group": "nodes", + "data": {"id": str(v), "label": str(v)}, + "position": {"x": float(x * 150), "y": float(-y * 150)}, + } + elements.append(node) + for u, v, label in G.edges(): + elements.append({ + "group": "edges", + "data": {"source": str(u), "target": str(v), "label": str(label)}, + }) + return elements, has_preset + + +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. 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('') + + # Compute positions if not set by make_graph + if pos is None or len(pos) < n: + pos = G.layout_spring() + + if n == 1: + width = height = 2 * pad + coords = {vertices[0]: (width / 2, height / 2)} + else: + 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 + 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 = ['' % (width, height)] + + # Draw edges + for u, v, label in G.edges(): + x1, y1 = coords[u] + x2, y2 = coords[v] + parts.append('' % (x1, y1, x2, y2)) + + # Draw nodes + for v in vertices: + cx, cy = coords[v] + parts.append('' % (cx, cy, r)) + + parts.append('') + return Markup('\n'.join(parts)) + + +def make_graph(M, vertex_labels=None): + """ + Construct an isogeny graph from a degree matrix. + + Code extracted from Sage's elliptic curve isogeny class (reshaped + in the case maxdegree==12). Positions are hardcoded based on + the shape of the graph, which is classified by the maximum degree + and number of vertices. + + INPUT: + + - ``M`` -- a square matrix of isogeny degrees + - ``vertex_labels`` -- optional list of labels for the vertices; + defaults to ``[1, 2, ..., n]`` + + OUTPUT: a Sage Graph with positions set + """ + from sage.schemes.elliptic_curves.ell_curve_isogeny import fill_isogeny_matrix, unfill_isogeny_matrix + from sage.graphs.graph import Graph + n = M.nrows() # = M.ncols() + G = Graph(unfill_isogeny_matrix(M), format='weighted_adjacency_matrix') + MM = fill_isogeny_matrix(M) + if n == 1: + # one vertex + G.set_pos(pos={0: [0, 0]}) + elif n == 2: + # one edge, two vertices + G.set_pos(pos={0: [-0.5, 0], 1: [0.5, 0]}) + else: + maxdegree = max(max(MM)) + if n == 3: + # o--o--o + centervert = [i for i in range(3) if max(MM.row(i)) < maxdegree][0] + other = [i for i in range(3) if i != centervert] + G.set_pos(pos={centervert: [0, 0], other[0]: [-1, 0], other[1]: [1, 0]}) + elif maxdegree == 4: + # o--o<8 + centervert = [i for i in range(4) if max(MM.row(i)) < maxdegree][0] + other = [i for i in range(4) if i != centervert] + G.set_pos(pos={centervert: [0, 0], other[0]: [0, 1], other[1]: [-0.8660254, -0.5], other[2]: [0.8660254, -0.5]}) + elif maxdegree == 27 and n == 4: + # o--o--o--o + centers = [i for i in range(4) if list(MM.row(i)).count(3) == 2] + left = [j for j in range(4) if MM[centers[0], j] == 3 and j not in centers][0] + right = [j for j in range(4) if MM[centers[1], j] == 3 and j not in centers][0] + G.set_pos(pos={left: [-1.5, 0], centers[0]: [-0.5, 0], centers[1]: [0.5, 0], right: [1.5, 0]}) + elif n == 4: + # 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: [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] + left = [j for j in range(6) if MM[centers[0], j] == 2 and j not in centers] + right = [j for j in range(6) if MM[centers[1], j] == 2 and j not in centers] + G.set_pos(pos={centers[0]: [-0.5, 0], left[0]: [-1, 0.8660254], left[1]: [-1, -0.8660254], centers[1]: [0.5, 0], right[0]: [1, 0.8660254], right[1]: [1, -0.8660254]}) + elif maxdegree == 18 and n == 6: + # two squares joined on an edge + centers = [i for i in range(6) if list(MM.row(i)).count(3) == 2] + top = [j for j in range(6) if MM[centers[0], j] == 3] + bl = [j for j in range(6) if MM[top[0], j] == 2][0] + br = [j for j in range(6) if MM[top[1], j] == 2][0] + G.set_pos(pos={centers[0]: [0, 0.5], centers[1]: [0, -0.5], top[0]: [-1, 0.5], top[1]: [1, 0.5], bl: [-1, -0.5], br: [1, -0.5]}) + elif maxdegree == 16 and n == 8: + # tree from bottom, 3 regular except for the leaves + centers = [i for i in range(8) if list(MM.row(i)).count(2) == 3] + center = [i for i in centers if len([j for j in centers if MM[i, j] == 2]) == 2][0] + centers.remove(center) + bottom = [j for j in range(8) if MM[center, j] == 2 and j not in centers][0] + left = [j for j in range(8) if MM[centers[0], j] == 2 and j != center] + right = [j for j in range(8) if MM[centers[1], j] == 2 and j != center] + G.set_pos(pos={center: [0, 0], bottom: [0, -1], centers[0]: [-0.8660254, 0.5], centers[1]: [0.8660254, 0.5], left[0]: [-0.8660254, 1.5], right[0]: [0.8660254, 1.5], left[1]: [-1.7320508, 0], right[1]: [1.7320508, 0]}) + elif maxdegree == 12: + # tent + centers = [i for i in range(8) if list(MM.row(i)).count(2) == 3] + left = [j for j in range(8) if MM[centers[0], j] == 2] + 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.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) + else: + G.relabel(list(range(1, n + 1))) + return G + + +def setup_isogeny_graph(G): + """Return (graph_data, graph_link, graph_layouts, graph_default_layout) for an isogeny graph.""" + graph_data, has_preset = graph_to_cytoscape_json(G) + graph_link = graph_to_svg(G) + graph_default_layout = 'Preset' if has_preset else 'Elk-stress' + return graph_data, graph_link, list(GRAPH_LAYOUTS), graph_default_layout diff --git a/lmfdb/utils/utilities.py b/lmfdb/utils/utilities.py index 07d19719a7..735bfb3dc9 100644 --- a/lmfdb/utils/utilities.py +++ b/lmfdb/utils/utilities.py @@ -951,115 +951,6 @@ 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. - - Positions are taken from G.get_pos() if available (as set by - make_graph), otherwise computed via layout_spring(). Returns - ``(elements, has_preset)`` where *elements* is a list of dicts - suitable for ``cytoscape({elements: ...})`` and *has_preset* is - True when positions came from hardcoded coordinates. - """ - pos = G.get_pos() - has_preset = pos is not None and len(pos) >= len(G.vertices()) - if not has_preset: - pos = G.layout_spring() - elements = [] - for v in G.vertices(): - x, y = pos[v] - node = { - "group": "nodes", - "data": {"id": str(v), "label": str(v)}, - "position": {"x": float(x * 150), "y": float(-y * 150)}, - } - elements.append(node) - for u, v, label in G.edges(): - elements.append({ - "group": "edges", - "data": {"source": str(u), "target": str(v), "label": str(label)}, - }) - return elements, has_preset - - -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. 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('') - - # Compute positions if not set by make_graph - if pos is None or len(pos) < n: - pos = G.layout_spring() - - if n == 1: - width = height = 2 * pad - coords = {vertices[0]: (width / 2, height / 2)} - else: - 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 - 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 = ['' % (width, height)] - - # Draw edges - for u, v, label in G.edges(): - x1, y1 = coords[u] - x2, y2 = coords[v] - parts.append('' % (x1, y1, x2, y2)) - - # Draw nodes - for v in vertices: - cx, cy = coords[v] - parts.append('' % (cx, cy, r)) - - parts.append('') - return Markup('\n'.join(parts)) - - class WebObj: def __init__(self, label, data=None): self.label = label