PageSourceSearch

https://astraagents.github.io/pathway_explorer/js/figure-mode.js

js astraagents.github.io collected 2026-10-03 08:40:09 UTC 13,279 bytes, 409 lines download raw bytes

1/**
2 * figure-mode.js — Toggleable "Figure mode" that re-renders the synthesis
3 * network into a paper-figure-friendly form (~183 x 170 mm).
4 *
5 * Non-destructive: snapshots cytoscape display state on enable, restores on
6 * disable. Does NOT modify graph-layout.js or cytoscape-bridge.js.
7 *
8 * Transformations:
9 *   1. Inline common species (H2O, CO2, NH3, H2, NH2OH) as edge labels
10 *      ("+ H2O" on input edges, "- H2O" on output edges); hide their nodes
11 *      and incident edges.
12 *   2. Replace pbr-/rxn- reaction labels with sequential numbers 1..N and
13 *      shrink the reaction nodes to small circles.
14 *   3. Switch to horizontal (LR) layout with tighter spacing, then fit.
15 *   4. Shorten long IUPAC molecule names via a small lookup map.
16 */
17(function() {
18  'use strict';
19
20  var SV = window.SynthVis = window.SynthVis || {};
21
22  // "Utility" species inlined as edge labels. Match against formula,
23  // commonName, and iupacName (lowercased, trimmed). Value = display symbol.
24  var COMMON_SPECIES = {
25    // 'h2o':              'H\u2082O',
26    // 'water':            'H\u2082O',
27    // 'co2':              'CO\u2082',
28    // 'carbon dioxide':   'CO\u2082',
29    // 'co':               'CO',
30    // 'carbon monoxide':  'CO',
31    // 'nh3':              'NH\u2083',
32    // 'ammonia':          'NH\u2083',
33    // 'azane':            'NH\u2083',
34    // 'h2':               'H\u2082',
35    // 'hydrogen':         'H\u2082',
36    // 'hydrogen gas':     'H\u2082',
37    // 'molecular hydrogen': 'H\u2082',
38    // 'h2s':              'H\u2082S',
39    // 'hydrogen sulfide': 'H\u2082S',
40    // 'nh2oh':            'NH\u2082OH',
41    // 'hydroxylamine':    'NH\u2082OH',
42    // 'hcoo-':            'HCOO\u207B',
43    // 'cho2-':            'HCOO\u207B',
44    // 'formate':          'HCOO\u207B',
45    // 'formic acid':      'HCOOH',
46    // 'hconh2':           'HCONH\u2082',
47    // 'ch3no':            'HCONH\u2082',
48    // 'formamide':        'HCONH\u2082',
49    // 'o2':               'O\u2082',
50    // 'oxygen':           'O\u2082'
51  };
52
53  function commonSymbolFor(node) {
54    if (!node || !node.isNode() || !node.hasClass('molecule')) return null;
55    var keys = [node.data('formula'), node.data('commonName'), node.data('iupacName')];
56    for (var i = 0; i < keys.length; i++) {
57      var k = (keys[i] || '').toString().trim().toLowerCase();
58      if (k && Object.prototype.hasOwnProperty.call(COMMON_SPECIES, k)) {
59        return COMMON_SPECIES[k];
60      }
61    }
62    return null;
63  }
64
65  // Long IUPAC / common names → compact label for figure mode.
66  var LABEL_SHORTCUTS = {
67    // '4-hydroxy-2-oxoglutaric acid': '4-OH-2-OG',
68    // '2-oxopentanedioic acid':       '\u03B1-KG',
69    // '\u03B1-ketoglutaric acid':     '\u03B1-KG',
70    // '2-oxoglutarate':               '\u03B1-KG',
71    // '2-aminopentanedioic acid':     'L-Glu',
72    // 'glutamic acid':                'L-Glu',
73    // 'L-Glutamate':                  'L-Glu',
74    // '2-amino-4-hydroxy-4-methylpentanedioic acid': '2-A-4-OH-4-Me-PDA',
75    // '4-oxopent-2-enedioic acid':    '4-oxo-pent-2-enedioate',
76    // '2-hydroxyglutaric acid':       '2-OH-Glu',
77    // '2-hydroxypentanedinitrile':    '2-OH-PDN',
78    // '2-aminopentanedinitrile':      '2-A-PDN'
79  };
80
81  var snapshot = null;
82  var active = false;
83
84  function getCy() { return SV.cy; }
85
86  function isCommonSpeciesNode(node) {
87    return commonSymbolFor(node) !== null;
88  }
89
90  function shortLabelFor(node) {
91    var orig = node.data('label') || '';
92    var iupac = node.data('iupacName') || '';
93    var common = node.data('commonName') || '';
94    if (LABEL_SHORTCUTS[orig]) return LABEL_SHORTCUTS[orig];
95    if (LABEL_SHORTCUTS[iupac]) return LABEL_SHORTCUTS[iupac];
96    if (LABEL_SHORTCUTS[common]) return LABEL_SHORTCUTS[common];
97    // Heuristic: if very long, prefer commonName, else truncate.
98    if (orig.length > 22) {
99      if (common && common.length < orig.length) return common;
100      return orig.slice(0, 20) + '\u2026';
101    }
102    return orig;
103  }
104
105  /**
106   * Capture state we'll mutate so we can restore on disable.
107   */
108  // Target paper size (mm) for figure mode.
109  var FIGURE_W_MM = 183;
110  var FIGURE_H_MM = 170;
111  var PX_PER_MM = 3.7795275591;
112  var FIGURE_PAD_MM = 4;
113
114  /**
115   * Uniformly scale visible node positions so that the true rendered
116   * bounding box (including labels and node extents) fits inside the
117   * target mm box. Run after layout, before setCanvasLimit.
118   */
119  function fitVisibleToBox(cy) {
120    var visible = cy.elements().not('.fm-hidden');
121    if (visible.length === 0) return;
122    var bb = visible.boundingBox({ includeLabels: true, includeOverlays: false });
123    var bbW = bb.w || 1;
124    var bbH = bb.h || 1;
125    var targetW = (FIGURE_W_MM - 2 * FIGURE_PAD_MM) * PX_PER_MM;
126    var targetH = (FIGURE_H_MM - 2 * FIGURE_PAD_MM) * PX_PER_MM;
127    var scale = Math.min(targetW / bbW, targetH / bbH);
128    if (!isFinite(scale) || scale <= 0 || Math.abs(scale - 1) < 0.001) return;
129    var cx = (bb.x1 + bb.x2) / 2;
130    var cy0 = (bb.y1 + bb.y2) / 2;
131    visible.nodes().forEach(function(n) {
132      var p = n.position();
133      n.position({
134        x: cx + (p.x - cx) * scale,
135        y: cy0 + (p.y - cy0) * scale
136      });
137    });
138  }
139
140  function takeSnapshot(cy) {
141    var snap = {
142      layoutDirection: SV.layoutDirection,
143      canvasLimitW: SV._canvasLimitW,
144      canvasLimitH: SV._canvasLimitH,
145      nodes: {},
146      edges: {},
147      pan: cy.pan(),
148      zoom: cy.zoom()
149    };
150    cy.nodes().forEach(function(n) {
151      snap.nodes[n.id()] = {
152        label: n.data('label'),
153        position: { x: n.position('x'), y: n.position('y') },
154        hidden: n.hidden(),
155        classes: n.classes().slice()
156      };
157    });
158    cy.edges().forEach(function(e) {
159      snap.edges[e.id()] = {
160        label: e.data('label'),
161        hidden: e.hidden(),
162        classes: e.classes().slice()
163      };
164    });
165    return snap;
166  }
167
168  function restoreSnapshot(cy, snap) {
169    cy.batch(function() {
170      cy.nodes().forEach(function(n) {
171        var s = snap.nodes[n.id()];
172        if (!s) return;
173        n.data('label', s.label);
174        n.position(s.position);
175        if (s.hidden) n.hide(); else n.show();
176        // Reset figure-mode-only classes
177        n.removeClass('fm-hidden fm-rxn fm-mol');
178      });
179      cy.edges().forEach(function(e) {
180        var s = snap.edges[e.id()];
181        if (!s) return;
182        e.data('label', s.label || '');
183        if (s.hidden) e.hide(); else e.show();
184        e.removeClass('fm-edge fm-hidden');
185      });
186    });
187    if (snap.layoutDirection) SV.layoutDirection = snap.layoutDirection;
188    cy.zoom(snap.zoom);
189    cy.pan(snap.pan);
190  }
191
192  /**
193   * Inject figure-mode-specific cytoscape styles once.
194   */
195  function ensureStyles(cy) {
196    if (cy.scratch('_fmStylesInstalled')) return;
197    cy.scratch('_fmStylesInstalled', true);
198    var extra = [
199      {
200        selector: 'node.fm-rxn',
201        style: {
202          'shape': 'round-rectangle',
203          'width': 36,
204          'height': 24,
205          'font-size': 11,
206          'font-weight': 700,
207          'border-width': 1,
208          'text-valign': 'center',
209          'text-halign': 'center',
210          'text-margin-y': 0,
211          'color': '#000000'
212        }
213      },
214      {
215        // Sizing/font only — leave background, text-valign etc. to the
216        // existing image-mode classes (has-image / images-hidden / names-only)
217        // so the user's image-mode toggle still works in figure mode.
218        selector: 'node.fm-mol',
219        style: {
220          'width': 60,
221          'height': 60,
222          'border-width': 1.5,
223          'font-size': 8,
224          'font-weight': 600,
225          'text-wrap': 'wrap',
226          'text-max-width': 52,
227          'text-overflow-wrap': 'anywhere'
228        }
229      },
230      {
231        selector: '.fm-hidden',
232        style: { 'display': 'none' }
233      },
234      {
235        selector: 'edge.fm-edge',
236        style: {
237          'label': 'data(label)',
238          'font-size': 9,
239          'font-style': 'italic',
240          'color': '#444',
241          'text-background-color': '#ffffff',
242          'text-background-opacity': 0.85,
243          'text-background-padding': 1,
244          'text-rotation': 'autorotate'
245        }
246      }
247    ];
248    cy.style().fromJson(cy.style().json().concat(extra)).update();
249  }
250
251  /**
252   * Topologically order reaction nodes (by current y then x in standard
253   * layout) and assign sequential numbers.
254   */
255  function numberReactions(cy) {
256    var rxns = cy.nodes('.reaction').filter(function(n) { return !n.hasClass('fm-hidden'); });
257    var arr = rxns.toArray().slice();
258    arr.sort(function(a, b) {
259      var pa = a.position(), pb = b.position();
260      if (Math.abs(pa.y - pb.y) > 4) return pa.y - pb.y;
261      return pa.x - pb.x;
262    });
263    var mapping = {};
264    arr.forEach(function(n, i) {
265      var num = i + 1;
266      mapping[n.id()] = num;
267      n.data('label', String(num));
268      n.addClass('fm-rxn');
269    });
270    return mapping;
271  }
272
273  /**
274   * Hide common-species molecules and inline them as edge labels on the
275   * reactions they participate in.
276   */
277  function inlineCommonSpecies(cy) {
278    var hiddenCount = 0;
279    cy.nodes('.molecule').forEach(function(mol) {
280      var sym = commonSymbolFor(mol);
281      if (!sym) return;
282
283      // For each reaction this molecule touches, append the symbol to a
284      // *surviving* edge from/to that reaction so it remains visible.
285      mol.connectedEdges().forEach(function(e) {
286        // The other endpoint is a reaction.
287        var other = (e.source().id() === mol.id()) ? e.target() : e.source();
288        if (!other.hasClass('reaction')) return;
289        var asInput = (e.source().id() === mol.id()); // mol -> rxn
290        var sign = asInput ? '+ ' : '\u2212 ';
291
292        // Find a surviving edge connected to that reaction (not touching a
293        // hidden common-species node) to carry the label.
294        var carrier = null;
295        other.connectedEdges().some(function(ce) {
296          if (ce.id() === e.id()) return false;
297          var otherEnd = (ce.source().id() === other.id()) ? ce.target() : ce.source();
298          if (isCommonSpeciesNode(otherEnd)) return false;
299          // Prefer matching direction (input edge for input species).
300          var ceIsInput = (ce.target().id() === other.id());
301          if (ceIsInput === asInput) { carrier = ce; return true; }
302          if (!carrier) carrier = ce;
303          return false;
304        });
305
306        if (carrier) {
307          var existing = carrier.data('label') || '';
308          var addition = sign + sym;
309          carrier.data('label', existing ? existing + '  ' + addition : addition);
310          carrier.addClass('fm-edge');
311        }
312
313        e.addClass('fm-hidden');
314      });
315
316      mol.addClass('fm-hidden');
317      hiddenCount++;
318    });
319    return hiddenCount;
320  }
321
322  /**
323   * Re-run the standard layout in horizontal mode and apply positions.
324   */
325  function relayoutDagre(cy) {
326    // Run cytoscape-dagre on the *visible* elements only (skip the
327    // common-species nodes/edges we just hid via .fm-hidden).
328    var eles = cy.elements().not('.fm-hidden');
329    var layout = eles.layout({
330      name: 'dagre',
331      rankDir: 'LR',
332      // nodeSep: 130,
333      edgeSep: 100,
334      // rankSep: 130,
335      animate: false,
336      fit: true,
337      padding: 10,
338      acyclicer: 'greedy',
339      ranker: 'tight-tree'
340    });
341    layout.run();
342  }
343
344  function shortenMoleculeLabels(cy) {
345    cy.nodes('.molecule').forEach(function(mol) {
346      if (mol.hasClass('fm-hidden')) return;
347      var s = shortLabelFor(mol);
348      if (s !== mol.data('label')) mol.data('label', s);
349      mol.addClass('fm-mol');
350    });
351  }
352
353  function fitToFigure(cy) {
354    // 183x170 mm aspect — Cytoscape works in pixels, so just fit with margin.
355    cy.fit(undefined, 24);
356  }
357
358  // ---- Public API ----
359
360  SV.figureMode = {
361    get isActive() { return active; },
362
363    enable: function(_graph) {
364      var cy = getCy();
365      if (!cy || active) return;
366      ensureStyles(cy);
367      snapshot = takeSnapshot(cy);
368      cy.batch(function() {
369        inlineCommonSpecies(cy);
370        shortenMoleculeLabels(cy);
371      });
372      // Dagre must run outside cy.batch so it sees finalized classes/sizes.
373      relayoutDagre(cy);
374      numberReactions(cy);
375      // Scale visible nodes so node+label extents fit the mm box.
376      fitVisibleToBox(cy);
377      // Constrain to 183 x 170 mm so SVG export uses the right physical size.
378      if (typeof SV.setCanvasLimit === 'function') {
379        SV.setCanvasLimit(FIGURE_W_MM, FIGURE_H_MM);
380      }
381      fitToFigure(cy);
382      active = true;
383    },
384
385    disable: function() {
386      var cy = getCy();
387      if (!cy || !active || !snapshot) return;
388      // Restore previous canvas limit (or clear it).
389      if (typeof SV.setCanvasLimit === 'function') {
390        if (snapshot.canvasLimitW && snapshot.canvasLimitH) {
391          SV.setCanvasLimit(snapshot.canvasLimitW, snapshot.canvasLimitH);
392        } else {
393          SV.setCanvasLimit(null, null);
394        }
395      }
396      restoreSnapshot(cy, snapshot);
397      snapshot = null;
398      active = false;
399      cy.fit(undefined, 30);
400    },
401
402    toggle: function(graph) {
403      if (active) this.disable();
404      else this.enable(graph);
405      return active;
406    }
407  };
408
409})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.