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.