diff --git a/lmfdb/ecnf/isog_class.py b/lmfdb/ecnf/isog_class.py index 291b75f1c5..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 encode_plot, 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,9 +85,19 @@ 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, 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': + 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['iso_label'] + str(c['number']) + 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.isogeny_matrix_str = latex(Matrix(self.isogeny_matrix)) self.field = FIELD(self.field_label) @@ -152,10 +162,11 @@ 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), + ('Number of curves', str(self.class_size)), + ('Graph', ''), + (None, self.graph_link), + ('Conductor', '%s' % self.conductor_label)] if self.rk != '?': self.properties += [('Rank', '%s' % self.rk)] @@ -171,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 - pass - 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: [1, 1], other[0]: [-1, 1], opp: [-1, -1], other[1]: [1, -1]}) - 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.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.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/ecnf/templates/ecnf-isoclass.html b/lmfdb/ecnf/templates/ecnf-isoclass.html index 3c634bd423..35e40e3b6f 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 %} -
- -
+
+ +{% set graph_layouts = cl.graph_layouts %} +{% include "cytoscape_scripts.html" %} + {% else %}

Not available.

{% endif %} diff --git a/lmfdb/elliptic_curves/isog_class.py b/lmfdb/elliptic_curves/isog_class.py index a7d08dd61b..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 encode_plot, 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,9 +127,20 @@ 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, 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: + 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'] = ' 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')) + el['data']['j_inv'] = str(c['j_inv']) 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 +181,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)), @@ -207,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 - pass - 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:[1,1],other[0]:[-1,1],opp:[-1,-1],other[1]:[1,-1]}) - 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.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]}) - - if vertex_labels: - G.relabel(vertex_labels) - else: - G.relabel(list(range(1, n + 1))) - return G diff --git a/lmfdb/elliptic_curves/templates/ec-isoclass.html b/lmfdb/elliptic_curves/templates/ec-isoclass.html index 7665e75fe0..5a28c4d049 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 %} +
+ +{% set graph_layouts = info.graph_layouts %} +{% include "cytoscape_scripts.html" %} +

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..69a64151df --- /dev/null +++ b/lmfdb/static/isogeny_graph.js @@ -0,0 +1,296 @@ +/** + * Interactive isogeny graph rendering using Cytoscape.js + * + * Usage: initIsogenyGraph('container-id', elementsJSON, enabledLayouts, defaultLayout) + */ + +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')); +} + +// 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, defaultLayout) { + var container = document.getElementById(containerId); + if (!container || !elements || elements.length === 0) return; + + // 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') { + 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 (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(60, graphH + 100)) + 'px'; + } else { + var side = Math.min(600, Math.max(250, nNodes * 40)); + container.style.width = side + 'px'; + container.style.height = side + 'px'; + } + + var layoutOpts = (defaultLayout && LAYOUT_REGISTRY[defaultLayout]) + ? LAYOUT_REGISTRY[defaultLayout] + : { name: 'preset' }; + + var cy = cytoscape({ + container: container, + elements: elements, + layout: layoutOpts, + 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' + } + }, + { + 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': 1, + 'text-background-padding': '2px', + 'line-color': '#888', + 'width': 1.5, + 'curve-style': 'bezier', + 'color': '#555' + } + } + ] + }); + + 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 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; + + // Skip layout controls for trivial graphs + if (nNodes <= 1) { + // still set up tooltip and click handlers below + } else { + + // 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;'; + var label = document.createElement('label'); + label.textContent = 'Layout: '; + label.style.fontWeight = 'bold'; + var select = document.createElement('select'); + defaultLayout = defaultLayout || 'Preset'; + 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 + 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; + 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); + + } // end nNodes > 1 + + // 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; white-space:nowrap;'; + document.body.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.cm !== undefined) _isoTooltipLine(tooltip, 'CM: ' + d.cm); + if (d.optimal) _isoTooltipEmphasis(tooltip, 'Optimal curve'); + + tooltip.style.display = 'block'; + + // Position tooltip near the node using page coordinates + var rect = container.getBoundingClientRect(); + var pos = node.renderedPosition(); + 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 + cy.on('drag', 'node', function(evt) { + var node = evt.target; + var pos = node.position(); + var ext = cy.extent(); + 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) { + node.position({ x: x, y: y }); + } + }); + + // 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/templates/cytoscape_scripts.html b/lmfdb/templates/cytoscape_scripts.html new file mode 100644 index 0000000000..e4c72d5a47 --- /dev/null +++ b/lmfdb/templates/cytoscape_scripts.html @@ -0,0 +1,26 @@ +{# Shared Cytoscape.js scripts for interactive isogeny graphs. + 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. +#} + +{% if 'Elk-stress' in graph_layouts %} + + +{% endif %} +{% if 'Klay' in graph_layouts %} + + +{% endif %} +{% if 'Dagre' in graph_layouts %} + + +{% endif %} +{% if 'Cola' in graph_layouts %} + + +{% endif %} + diff --git a/lmfdb/utils/__init__.py b/lmfdb/utils/__init__.py index c959d337be..9d6b20b8bd 100644 --- a/lmfdb/utils/__init__.py +++ b/lmfdb/utils/__init__.py @@ -19,7 +19,8 @@ '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', + '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', @@ -98,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