diff --git a/R/visualizeNetworksWithHTML.R b/R/visualizeNetworksWithHTML.R index db8b64c..7a2316d 100644 --- a/R/visualizeNetworksWithHTML.R +++ b/R/visualizeNetworksWithHTML.R @@ -296,30 +296,122 @@ getEdgeStyle <- function(interaction, category, edge_type) { } createNodeElements <- function(nodes, displayLabelType = "id") { - # Map logFC to colors if logFC column exists if ("logFC" %in% names(nodes)) { node_colors <- mapLogFCToColor(nodes$logFC) } else { - node_colors <- rep("#D3D3D3", nrow(nodes)) # Default color + node_colors <- rep("#D3D3D3", nrow(nodes)) } - # Determine which column to use for labels - label_column <- if(displayLabelType == "hgncName" && "hgncName" %in% names(nodes)) { + label_column <- if (displayLabelType == "hgncName" && "hgncName" %in% names(nodes)) { "hgncName" } else { "id" } - apply(cbind(nodes, color = node_colors), 1, function(row) { - # Use the appropriate label, fallback to id if hgncName is missing/empty - display_label <- if(label_column == "hgncName" && !is.na(row['hgncName']) && row['hgncName'] != "") { - row['hgncName'] + node_elements <- c() + ptm_elements <- c() + emitted_proteins <- c() + emitted_compounds <- c() + emitted_ptm_nodes <- c() + emitted_ptm_edges <- c() + + # Pre-compute which protein ids have at least one PTM site row, + # so we know upfront whether a compound wrapper is needed + has_ptm_sites <- if ("Site" %in% names(nodes)) { + ids_with_sites <- unique(nodes$id[!is.na(nodes$Site) & trimws(nodes$Site) != ""]) + ids_with_sites + } else { + c() + } + + for (i in seq_len(nrow(nodes))) { + row <- nodes[i, ] + color <- node_colors[i] + has_site <- "Site" %in% names(nodes) && !is.na(row$Site) && trimws(row$Site) != "" + + display_label <- if (label_column == "hgncName" && !is.na(row$hgncName) && row$hgncName != "") { + row$hgncName } else { - row['id'] + row$id } - paste0("{ data: { id: '", row['id'], "', label: '", display_label, "', color: '", row['color'], "' } }") - }) + needs_compound <- row$id %in% has_ptm_sites + compound_id <- paste0(row$id, "__compound__") + + # Emit invisible compound container node once per protein that has PTM children + if (needs_compound && !(compound_id %in% emitted_compounds)) { + node_elements <- c(node_elements, + paste0("{ data: { id: '", escape_js_string(compound_id), + "', node_type: 'compound' } }") + ) + emitted_compounds <- c(emitted_compounds, compound_id) + } + + # Emit protein node once, assigning it to the compound if one exists + if (!(row$id %in% emitted_proteins)) { + parent_field <- if (needs_compound) { + paste0(", parent: '", escape_js_string(compound_id), "'") + } else { + "" + } + node_elements <- c(node_elements, + paste0("{ data: { id: '", escape_js_string(row$id), + "', label: '", escape_js_string(display_label), + "', color: '", color, + "', node_type: 'protein'", + parent_field, + " } }") + ) + emitted_proteins <- c(emitted_proteins, row$id) + } + + # Emit one PTM child node + attachment edge per individual site + if (has_site) { + sites <- trimws(unlist(strsplit(as.character(row$Site), "[_,;|]"))) + sites <- unique(sites[sites != ""]) + + for (site in sites) { + ptm_node_id <- paste0(row$id, "__ptm__", site) + safe_ptm_id <- escape_js_string(ptm_node_id) + safe_parent <- escape_js_string(row$id) + safe_site <- escape_js_string(site) + + # PTM node also belongs to the same compound container + if (!(ptm_node_id %in% emitted_ptm_nodes)) { + ptm_elements <- c(ptm_elements, + paste0("{ data: { id: '", safe_ptm_id, + "', label: '", safe_site, + "', color: '", color, + "', parent_protein: '", safe_parent, + "', parent: '", escape_js_string(compound_id), "'", + ", node_type: 'ptm' } }") + ) + emitted_ptm_nodes <- c(emitted_ptm_nodes, ptm_node_id) + } + + ptm_edge_id_raw <- paste0(row$id, "__ptm_edge__", site) + if (!(ptm_edge_id_raw %in% emitted_ptm_edges)) { + ptm_edge_id <- escape_js_string(ptm_edge_id_raw) + ptm_elements <- c(ptm_elements, + paste0("{ data: { id: '", ptm_edge_id, + "', source: '", safe_parent, + "', target: '", safe_ptm_id, + "', edge_type: 'ptm_attachment',", + " category: 'ptm_attachment',", + " interaction: '',", + " color: '", color, "',", + " line_style: 'dotted',", + " arrow_shape: 'none',", + " width: 1.5,", + " tooltip: '' } }") + ) + emitted_ptm_edges <- c(emitted_ptm_edges, ptm_edge_id_raw) + } + } + } + } + + return(c(node_elements, ptm_elements)) } createEdgeElements <- function(edges, nodes = NULL) { @@ -493,6 +585,49 @@ generateCytoscapeConfig <- function(nodes, edges, `source-arrow-shape` = "triangle", `target-arrow-shape` = "triangle" ) + ), + list( + selector = "node[node_type = 'ptm']", + style = list( + shape = "ellipse", + width = "20px", + height = "20px", + `background-color` = "data(color)", # <-- was hardcoded "#9932CC" + `border-color` = "#333", # <-- neutral border instead of purple + `border-width` = 1.5, + label = "data(label)", + `font-size` = "8px", + `font-weight` = "normal", + color = "#000000", # <-- dark text works across the logFC palette + `text-valign` = "center", + `text-halign` = "center", + `text-wrap` = "wrap", + `text-max-width` = "18px" + ) + ), + # --- PTM attachment edge style (hide label, keep it subtle) --- + list( + selector = "edge[edge_type = 'ptm_attachment']", + style = list( + `line-style` = "dotted", + `line-color` = "#9932CC", + width = 1.5, + `target-arrow-shape` = "none", + `source-arrow-shape` = "none", + label = "", # no label on these connector edges + `z-index` = 0 # render behind main edges + ) + ), + list( + selector = "node[node_type = 'compound']", + style = list( + `background-opacity` = 0, + `border-width` = 0, + `border-opacity` = 0, + `padding` = "10px", + label = "", + `z-index` = 0 + ) ) ) @@ -555,6 +690,43 @@ generateJavaScriptCode <- function(config) { layout: ", layout_js, " }); + // After layout completes, reposition PTM nodes directly beside their parent protein + cy.on('layoutstop', function() { + var ptmNodes = cy.nodes('[node_type = \"ptm\"]'); + ptmNodes.forEach(function(ptmNode) { + var parentId = ptmNode.data('parent_protein'); + var parentNode = cy.getElementById(parentId); + if (parentNode.length === 0) return; + + var parentPos = parentNode.position(); + var parentW = parentNode.outerWidth(); + var parentH = parentNode.outerHeight(); + var ptmR = ptmNode.outerWidth() / 2; // PTM node is a small circle + + // Collect all PTM siblings so we can fan them around the parent + var siblings = cy.nodes('[parent_protein = \"' + parentId + '\"]'); + var idx = siblings.indexOf(ptmNode); + var total = siblings.length; + + // Distribute siblings evenly across the bottom arc of the parent + // angleStart/End in radians: spread across bottom 180 degrees + var angleStart = Math.PI * 0.15; + var angleEnd = Math.PI * 0.85; + var angle = total === 1 + ? Math.PI / 2 // single PTM: directly below center + : angleStart + (angleEnd - angleStart) * (idx / (total - 1)); + + // Place PTM node just outside the parent border + var offsetX = (parentW / 2 + ptmR + 4) * Math.cos(angle); + var offsetY = (parentH / 2 + ptmR + 4) * Math.sin(angle); + + ptmNode.position({ + x: parentPos.x + offsetX, + y: parentPos.y + offsetY + }); + }); + }); + // Create tooltip element var tooltip = document.createElement('div'); tooltip.style.cssText = `